feat(app): 穿搭生成 + 方案流 + 效果图 + 3D 化身查看
- outfit_provider:方案列表/生成任务轮询(pending→planning→scoring→rendering→done)/详情/选定主方案/收藏 - outfit_page:日期范围 + 地点生成表单,生成中显示任务进度,方案列表卡片 - plan_viewer_page:PageView 手指滑动切换方案,穿衣清单(衣橱/新品标记)、发型发色、设为主方案/收藏 - plan_effect_page:效果图 3 视角(正面/侧面/背面)查看 - avatar_viewer:three_dart + flutter_gl 3D 渲染实现(自动旋转),GLB 资产未接入时占位展示,kEnable3D 开关 - avatar_viewer_page:化身查看页 + 重新构建 - 新增 /plan-viewer /plan-effect /avatar-viewer 路由
This commit is contained in:
@@ -1,10 +1,257 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:fluttertoast/fluttertoast.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
class OutfitPage extends StatelessWidget {
|
||||
import '../../shared/widgets/empty_view.dart';
|
||||
import '../../shared/widgets/error_view.dart';
|
||||
import '../../shared/widgets/loading_view.dart';
|
||||
import 'outfit_provider.dart';
|
||||
|
||||
/// 穿搭:生成表单 + 方案列表
|
||||
class OutfitPage extends ConsumerStatefulWidget {
|
||||
const OutfitPage({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<OutfitPage> createState() => _OutfitPageState();
|
||||
}
|
||||
|
||||
class _OutfitPageState extends ConsumerState<OutfitPage> {
|
||||
DateTimeRange? _dateRange;
|
||||
final _locationCtrl = TextEditingController();
|
||||
bool _submitting = false;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_locationCtrl.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _pickDateRange() async {
|
||||
final now = DateTime.now();
|
||||
final range = await showDateRangePicker(
|
||||
context: context,
|
||||
firstDate: now,
|
||||
lastDate: now.add(const Duration(days: 30)),
|
||||
initialDateRange: _dateRange ??
|
||||
DateTimeRange(start: now, end: now.add(const Duration(days: 2))),
|
||||
);
|
||||
if (range != null) setState(() => _dateRange = range);
|
||||
}
|
||||
|
||||
String _fmt(DateTime d) =>
|
||||
'${d.year}-${d.month.toString().padLeft(2, '0')}-${d.day.toString().padLeft(2, '0')}';
|
||||
|
||||
Future<void> _generate() async {
|
||||
final range = _dateRange;
|
||||
final location = _locationCtrl.text.trim();
|
||||
if (range == null) {
|
||||
Fluttertoast.showToast(msg: '请选择日期范围');
|
||||
return;
|
||||
}
|
||||
if (location.isEmpty) {
|
||||
Fluttertoast.showToast(msg: '请输入地点');
|
||||
return;
|
||||
}
|
||||
if (range.duration.inDays < 1) {
|
||||
Fluttertoast.showToast(msg: '日期范围至少 1 天');
|
||||
return;
|
||||
}
|
||||
setState(() => _submitting = true);
|
||||
final ok = await ref.read(generateProvider.notifier).generate(
|
||||
startDate: _fmt(range.start),
|
||||
endDate: _fmt(range.end),
|
||||
location: location,
|
||||
);
|
||||
if (!mounted) return;
|
||||
setState(() => _submitting = false);
|
||||
if (ok) {
|
||||
Fluttertoast.showToast(msg: '生成完成');
|
||||
ref.read(generateProvider.notifier).reset();
|
||||
context.go('/plan-viewer');
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return const Center(child: Text('穿搭方案(开发中)'));
|
||||
final plans = ref.watch(outfitPlanProvider);
|
||||
final gen = ref.watch(generateProvider);
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('穿搭方案')),
|
||||
body: RefreshIndicator(
|
||||
onRefresh: () => ref.read(outfitPlanProvider.notifier).refresh(),
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('生成穿搭方案',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: scheme.primary)),
|
||||
const SizedBox(height: 12),
|
||||
OutlinedButton.icon(
|
||||
onPressed: _submitting ? null : _pickDateRange,
|
||||
icon: const Icon(Icons.date_range_outlined),
|
||||
label: Text(_dateRange == null
|
||||
? '选择日期范围'
|
||||
: '${_fmt(_dateRange!.start)} ~ ${_fmt(_dateRange!.end)}'),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
controller: _locationCtrl,
|
||||
enabled: !_submitting,
|
||||
decoration: const InputDecoration(
|
||||
labelText: '地点(如:上海·外滩)',
|
||||
hintText: '影响天气与穿搭建议',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
if (gen.phase == GeneratePhase.running)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 12),
|
||||
child: Row(
|
||||
children: [
|
||||
const SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2)),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(gen.statusText,
|
||||
style: const TextStyle(fontSize: 13)),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: _submitting ? null : _generate,
|
||||
style: FilledButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 14)),
|
||||
child: const Text('生成'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text('已有方案',
|
||||
style: TextStyle(fontSize: 15, fontWeight: FontWeight.bold)),
|
||||
const SizedBox(height: 8),
|
||||
plans.when(
|
||||
loading: () => const Padding(
|
||||
padding: EdgeInsets.only(top: 48),
|
||||
child: LoadingView(text: '加载方案...')),
|
||||
error: (e, _) => Padding(
|
||||
padding: const EdgeInsets.only(top: 48),
|
||||
child: ErrorView(
|
||||
message: e.toString().replaceFirst('Exception: ', ''),
|
||||
onRetry: () => ref.read(outfitPlanProvider.notifier).refresh(),
|
||||
),
|
||||
),
|
||||
data: (list) {
|
||||
if (list.isEmpty) {
|
||||
return const Padding(
|
||||
padding: EdgeInsets.only(top: 48),
|
||||
child: EmptyView(
|
||||
message: '还没有穿搭方案,先设置日期与地点生成吧'),
|
||||
);
|
||||
}
|
||||
return Column(
|
||||
children: [
|
||||
for (final p in list) ...[
|
||||
_PlanCard(plan: p),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _PlanCard extends ConsumerWidget {
|
||||
final OutfitPlanInfo plan;
|
||||
|
||||
const _PlanCard({required this.plan});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
return Card(
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
onTap: () => context.go('/plan-viewer'),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 44,
|
||||
height: 44,
|
||||
decoration: BoxDecoration(
|
||||
color: scheme.primaryContainer,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: Icon(Icons.checkroom, color: scheme.primary),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Flexible(
|
||||
child: Text(plan.title,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.bold, fontSize: 15)),
|
||||
),
|
||||
if (plan.isMain) ...[
|
||||
const SizedBox(width: 6),
|
||||
const Chip(
|
||||
label: Text('主方案'),
|
||||
labelStyle:
|
||||
TextStyle(color: Colors.white, fontSize: 11),
|
||||
backgroundColor: Colors.indigo,
|
||||
visualDensity: VisualDensity.compact,
|
||||
padding: EdgeInsets.zero,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'${plan.dateRange} · ${plan.location} · 评分 ${plan.score}'
|
||||
'${plan.fromAi ? ' · AI 生成' : ' · 规则生成'}',
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style:
|
||||
const TextStyle(color: Colors.grey, fontSize: 12),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Icon(Icons.chevron_right, color: Colors.grey),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,245 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../core/auth/auth_provider.dart';
|
||||
|
||||
class OutfitPlanInfo {
|
||||
final int id;
|
||||
final String title;
|
||||
final String source; // ai / rule
|
||||
final int score;
|
||||
final int mainFlag;
|
||||
final String dateRange;
|
||||
final String location;
|
||||
final int hairstyleId;
|
||||
final String hairColor;
|
||||
|
||||
const OutfitPlanInfo({
|
||||
required this.id,
|
||||
required this.title,
|
||||
required this.source,
|
||||
required this.score,
|
||||
required this.mainFlag,
|
||||
required this.dateRange,
|
||||
required this.location,
|
||||
required this.hairstyleId,
|
||||
required this.hairColor,
|
||||
});
|
||||
|
||||
bool get isMain => mainFlag == 1;
|
||||
bool get fromAi => source == 'ai';
|
||||
}
|
||||
|
||||
class PlanItemInfo {
|
||||
final int id;
|
||||
final String slot; // 上衣/下装/鞋/配饰
|
||||
final String source; // wardrobe / new
|
||||
final int wardrobeItemId;
|
||||
final String name;
|
||||
final String desc;
|
||||
|
||||
const PlanItemInfo({
|
||||
required this.id,
|
||||
required this.slot,
|
||||
required this.source,
|
||||
required this.wardrobeItemId,
|
||||
required this.name,
|
||||
required this.desc,
|
||||
});
|
||||
|
||||
bool get fromWardrobe => source == 'wardrobe';
|
||||
}
|
||||
|
||||
class PlanEffectImageInfo {
|
||||
final int id;
|
||||
final String angle; // front / side / back
|
||||
final String url;
|
||||
final String status; // pending / rendering / done / failed
|
||||
|
||||
const PlanEffectImageInfo({
|
||||
required this.id,
|
||||
required this.angle,
|
||||
required this.url,
|
||||
required this.status,
|
||||
});
|
||||
|
||||
bool get done => status == 'done' && url.isNotEmpty;
|
||||
}
|
||||
|
||||
class PlanDetail {
|
||||
final OutfitPlanInfo plan;
|
||||
final List<PlanItemInfo> items;
|
||||
final List<PlanEffectImageInfo> images;
|
||||
final String hairstyleName;
|
||||
|
||||
const PlanDetail({
|
||||
required this.plan,
|
||||
required this.items,
|
||||
required this.images,
|
||||
required this.hairstyleName,
|
||||
});
|
||||
}
|
||||
|
||||
/// 方案列表
|
||||
class OutfitPlanNotifier extends AsyncNotifier<List<OutfitPlanInfo>> {
|
||||
@override
|
||||
Future<List<OutfitPlanInfo>> build() async {
|
||||
final api = ref.read(apiClientProvider);
|
||||
final list = await api.get<List<dynamic>>('/outfit/plan/list');
|
||||
return list
|
||||
.map((e) => OutfitPlanInfo(
|
||||
id: (e['id'] as num).toInt(),
|
||||
title: e['title'] as String? ?? '',
|
||||
source: e['source'] as String? ?? 'rule',
|
||||
score: (e['score'] as num?)?.toInt() ?? 0,
|
||||
mainFlag: (e['main_flag'] as num?)?.toInt() ?? 0,
|
||||
dateRange: e['date_range'] as String? ?? '',
|
||||
location: e['location'] as String? ?? '',
|
||||
hairstyleId: (e['hairstyle_id'] as num?)?.toInt() ?? 0,
|
||||
hairColor: e['hair_color'] as String? ?? '',
|
||||
))
|
||||
.toList();
|
||||
}
|
||||
|
||||
Future<void> refresh() async {
|
||||
state = const AsyncLoading();
|
||||
state = await AsyncValue.guard(build);
|
||||
}
|
||||
|
||||
Future<void> selectMain(int planId) async {
|
||||
final api = ref.read(apiClientProvider);
|
||||
await api.post('/outfit/plan/select-main', {'plan_id': planId});
|
||||
await refresh();
|
||||
}
|
||||
|
||||
Future<void> review(int planId, String action) async {
|
||||
final api = ref.read(apiClientProvider);
|
||||
await api.post('/outfit/plan/review', {'plan_id': planId, 'action': action});
|
||||
}
|
||||
}
|
||||
|
||||
final outfitPlanProvider =
|
||||
AsyncNotifierProvider<OutfitPlanNotifier, List<OutfitPlanInfo>>(
|
||||
OutfitPlanNotifier.new);
|
||||
|
||||
/// 生成任务状态(轮询后端任务状态机)
|
||||
enum GeneratePhase { idle, running, done, failed }
|
||||
|
||||
class GenerateState {
|
||||
final GeneratePhase phase;
|
||||
final String statusText;
|
||||
final String error;
|
||||
|
||||
const GenerateState({
|
||||
this.phase = GeneratePhase.idle,
|
||||
this.statusText = '',
|
||||
this.error = '',
|
||||
});
|
||||
}
|
||||
|
||||
class GenerateNotifier extends Notifier<GenerateState> {
|
||||
@override
|
||||
GenerateState build() => const GenerateState();
|
||||
|
||||
Future<bool> generate({
|
||||
required String startDate,
|
||||
required String endDate,
|
||||
required String location,
|
||||
}) async {
|
||||
state =
|
||||
const GenerateState(phase: GeneratePhase.running, statusText: '任务创建中...');
|
||||
final api = ref.read(apiClientProvider);
|
||||
final data = await api.post<Map<String, dynamic>>('/outfit/generate', {
|
||||
'start_date': startDate,
|
||||
'end_date': endDate,
|
||||
'location': location,
|
||||
});
|
||||
final taskId = (data['task_id'] as num).toInt();
|
||||
|
||||
for (var i = 0; i < 90; i++) {
|
||||
await Future.delayed(const Duration(seconds: 2));
|
||||
final st = await api.get<Map<String, dynamic>>('/outfit/task/status',
|
||||
query: {'task_id': taskId});
|
||||
final status = st['status'] as String? ?? '';
|
||||
final err = st['error'] as String? ?? '';
|
||||
state = GenerateState(
|
||||
phase: GeneratePhase.running, statusText: _statusText(status));
|
||||
if (status == 'done') {
|
||||
state = const GenerateState(phase: GeneratePhase.done, statusText: '完成');
|
||||
break;
|
||||
}
|
||||
if (status == 'failed') {
|
||||
state = GenerateState(
|
||||
phase: GeneratePhase.failed,
|
||||
error: err.isEmpty ? '生成失败,请稍后重试' : err);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
await ref.read(outfitPlanProvider.notifier).refresh();
|
||||
return state.phase == GeneratePhase.done;
|
||||
}
|
||||
|
||||
String _statusText(String status) {
|
||||
switch (status) {
|
||||
case 'pending':
|
||||
return '排队中...';
|
||||
case 'planning':
|
||||
return 'AI 规划穿搭中...';
|
||||
case 'scoring':
|
||||
return '方案评分中...';
|
||||
case 'rendering':
|
||||
return '生成效果图中...';
|
||||
case 'failed':
|
||||
return '生成失败';
|
||||
default:
|
||||
return '处理中...';
|
||||
}
|
||||
}
|
||||
|
||||
void reset() => state = const GenerateState();
|
||||
}
|
||||
|
||||
final generateProvider =
|
||||
NotifierProvider<GenerateNotifier, GenerateState>(GenerateNotifier.new);
|
||||
|
||||
/// 方案详情
|
||||
final planDetailProvider = FutureProvider.family<PlanDetail, int>((ref, planId) async {
|
||||
final api = ref.read(apiClientProvider);
|
||||
final data = await api.get<Map<String, dynamic>>('/outfit/plan/detail',
|
||||
query: {'plan_id': planId});
|
||||
final planData = data['plan'] as Map<String, dynamic>? ?? {};
|
||||
final items = (data['items'] as List<dynamic>? ?? [])
|
||||
.map((e) => PlanItemInfo(
|
||||
id: (e['id'] as num).toInt(),
|
||||
slot: e['slot'] as String? ?? '',
|
||||
source: e['source'] as String? ?? 'new',
|
||||
wardrobeItemId: (e['wardrobe_item_id'] as num?)?.toInt() ?? 0,
|
||||
name: e['name'] as String? ?? '',
|
||||
desc: e['desc'] as String? ?? '',
|
||||
))
|
||||
.toList();
|
||||
final images = (data['images'] as List<dynamic>? ?? [])
|
||||
.map((e) => PlanEffectImageInfo(
|
||||
id: (e['id'] as num).toInt(),
|
||||
angle: e['angle'] as String? ?? '',
|
||||
url: e['url'] as String? ?? '',
|
||||
status: e['status'] as String? ?? 'pending',
|
||||
))
|
||||
.toList();
|
||||
final hairstyle = data['hairstyle'] as Map<String, dynamic>?;
|
||||
return PlanDetail(
|
||||
plan: OutfitPlanInfo(
|
||||
id: (planData['id'] as num).toInt(),
|
||||
title: planData['title'] as String? ?? '',
|
||||
source: planData['source'] as String? ?? 'rule',
|
||||
score: (planData['score'] as num?)?.toInt() ?? 0,
|
||||
mainFlag: (planData['main_flag'] as num?)?.toInt() ?? 0,
|
||||
dateRange: planData['date_range'] as String? ?? '',
|
||||
location: planData['location'] as String? ?? '',
|
||||
hairstyleId: (planData['hairstyle_id'] as num?)?.toInt() ?? 0,
|
||||
hairColor: planData['hair_color'] as String? ?? '',
|
||||
),
|
||||
items: items,
|
||||
images: images,
|
||||
hairstyleName: hairstyle?['name'] as String? ?? '',
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,120 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../core/config/app_config.dart';
|
||||
import '../../shared/widgets/empty_view.dart';
|
||||
import '../../shared/widgets/loading_view.dart';
|
||||
import 'outfit_provider.dart';
|
||||
|
||||
const _angleLabels = {
|
||||
'front': '正面',
|
||||
'side': '侧面',
|
||||
'back': '背面',
|
||||
};
|
||||
|
||||
/// 效果图查看:3 视角(正面/侧面/背面)切换
|
||||
class PlanEffectPage extends StatefulWidget {
|
||||
final List<PlanEffectImageInfo> images;
|
||||
|
||||
const PlanEffectPage({super.key, required this.images});
|
||||
|
||||
@override
|
||||
State<PlanEffectPage> createState() => _PlanEffectPageState();
|
||||
}
|
||||
|
||||
class _PlanEffectPageState extends State<PlanEffectPage> {
|
||||
String? _selected; // 当前角度,null = 展示全部
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final done = widget.images.where((i) => i.done).toList();
|
||||
final pending = widget.images.where((i) => !i.done).toList();
|
||||
final angles = widget.images.map((i) => i.angle).toSet();
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('效果图')),
|
||||
body: Column(
|
||||
children: [
|
||||
if (angles.length > 1)
|
||||
SizedBox(
|
||||
height: 48,
|
||||
child: ListView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 8),
|
||||
child: ChoiceChip(
|
||||
label: const Text('全部'),
|
||||
selected: _selected == null,
|
||||
onSelected: (_) => setState(() => _selected = null),
|
||||
),
|
||||
),
|
||||
for (final a in angles)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 8),
|
||||
child: ChoiceChip(
|
||||
label: Text(_angleLabels[a] ?? a),
|
||||
selected: _selected == a,
|
||||
onSelected: (_) => setState(() => _selected = a),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: done.isEmpty
|
||||
? (pending.isEmpty
|
||||
? const EmptyView(
|
||||
message: '暂无效果图,选定主方案后自动生成(每日限 3 次)')
|
||||
: const LoadingView(text: '效果图生成中,请稍后刷新...'))
|
||||
: GridView.builder(
|
||||
padding: const EdgeInsets.all(12),
|
||||
gridDelegate:
|
||||
const SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: 2,
|
||||
mainAxisSpacing: 12,
|
||||
crossAxisSpacing: 12,
|
||||
childAspectRatio: 0.8,
|
||||
),
|
||||
itemCount: _selected == null
|
||||
? done.length
|
||||
: done.where((i) => i.angle == _selected).length,
|
||||
itemBuilder: (ctx, idx) {
|
||||
final shown = _selected == null
|
||||
? done
|
||||
: done.where((i) => i.angle == _selected).toList();
|
||||
final img = shown[idx];
|
||||
return Card(
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Image.network(
|
||||
AppConfig.resolveUrl(img.url),
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (_, _, _) => const Icon(
|
||||
Icons.broken_image_outlined,
|
||||
size: 48,
|
||||
color: Colors.grey),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(8),
|
||||
child: Text(_angleLabels[img.angle] ?? img.angle,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.bold)),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,287 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:fluttertoast/fluttertoast.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../core/config/app_config.dart';
|
||||
import '../../shared/widgets/loading_view.dart';
|
||||
import 'outfit_provider.dart';
|
||||
|
||||
/// 方案流:手指左右滑动切换方案,底部操作(效果图/选定/收藏)
|
||||
class PlanViewerPage extends ConsumerWidget {
|
||||
const PlanViewerPage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final plans = ref.watch(outfitPlanProvider);
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('穿搭方案')),
|
||||
body: plans.when(
|
||||
loading: () => const LoadingView(text: '加载方案...'),
|
||||
error: (e, _) => Center(child: Text('加载失败:$e')),
|
||||
data: (list) {
|
||||
if (list.isEmpty) {
|
||||
return const Center(child: Text('暂无方案'));
|
||||
}
|
||||
return PageView.builder(
|
||||
itemCount: list.length,
|
||||
itemBuilder: (ctx, i) =>
|
||||
_PlanDetailView(planId: list[i].id, key: ValueKey(list[i].id)),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _PlanDetailView extends ConsumerStatefulWidget {
|
||||
final int planId;
|
||||
|
||||
const _PlanDetailView({required this.planId, super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<_PlanDetailView> createState() => _PlanDetailViewState();
|
||||
}
|
||||
|
||||
class _PlanDetailViewState extends ConsumerState<_PlanDetailView> {
|
||||
bool _selecting = false;
|
||||
|
||||
Future<void> _selectMain(int planId) async {
|
||||
setState(() => _selecting = true);
|
||||
try {
|
||||
await ref.read(outfitPlanProvider.notifier).selectMain(planId);
|
||||
if (!mounted) return;
|
||||
Fluttertoast.showToast(msg: '已设为主方案,正在生成效果图');
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
Fluttertoast.showToast(
|
||||
msg: '操作失败:${e.toString().replaceFirst('Exception: ', '')}');
|
||||
} finally {
|
||||
if (mounted) setState(() => _selecting = false);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _review(int planId, String action) async {
|
||||
try {
|
||||
await ref.read(outfitPlanProvider.notifier).review(planId, action);
|
||||
if (!mounted) return;
|
||||
Fluttertoast.showToast(
|
||||
msg: action == 'fav' ? '已收藏' : '已取消收藏');
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
Fluttertoast.showToast(
|
||||
msg: '操作失败:${e.toString().replaceFirst('Exception: ', '')}');
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final detail = ref.watch(planDetailProvider(widget.planId));
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: detail.when(
|
||||
loading: () => const LoadingView(text: '加载方案详情...'),
|
||||
error: (e, _) => Center(child: Text('加载失败:$e')),
|
||||
data: (d) {
|
||||
final plan = d.plan;
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(plan.title,
|
||||
style: const TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.bold)),
|
||||
),
|
||||
if (plan.isMain)
|
||||
const Chip(
|
||||
label: Text('主方案'),
|
||||
labelStyle:
|
||||
TextStyle(color: Colors.white, fontSize: 11),
|
||||
backgroundColor: Colors.indigo,
|
||||
visualDensity: VisualDensity.compact,
|
||||
),
|
||||
if (!plan.isMain)
|
||||
Chip(
|
||||
label: Text(plan.fromAi ? 'AI 生成' : '规则生成'),
|
||||
labelStyle: TextStyle(
|
||||
color: scheme.primary, fontSize: 11),
|
||||
backgroundColor: scheme.primaryContainer,
|
||||
visualDensity: VisualDensity.compact,
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'${plan.dateRange} · ${plan.location} · 评分 ${plan.score}',
|
||||
style: const TextStyle(color: Colors.grey),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
if (d.hairstyleName.isNotEmpty || plan.hairColor.isNotEmpty)
|
||||
Card(
|
||||
color: scheme.primaryContainer.withValues(alpha: 0.4),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.content_cut, size: 18),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'发型:${d.hairstyleName.isNotEmpty ? d.hairstyleName : '默认'}'
|
||||
'${plan.hairColor.isNotEmpty ? ' · 发色:${plan.hairColor}' : ''}',
|
||||
style: const TextStyle(fontSize: 13),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text('穿衣清单',
|
||||
style: TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: scheme.primary)),
|
||||
const SizedBox(height: 8),
|
||||
if (d.items.isEmpty)
|
||||
const Text('暂无穿搭单品',
|
||||
style: TextStyle(color: Colors.grey)),
|
||||
for (final item in d.items)
|
||||
Card(
|
||||
child: ListTile(
|
||||
dense: true,
|
||||
leading: Icon(_slotIcon(item.slot)),
|
||||
title: Text(item.name),
|
||||
subtitle: Text(item.desc),
|
||||
trailing: item.fromWardrobe
|
||||
? const Chip(
|
||||
label: Text('衣橱'),
|
||||
labelStyle: TextStyle(
|
||||
color: Colors.white, fontSize: 11),
|
||||
backgroundColor: Colors.teal,
|
||||
visualDensity: VisualDensity.compact,
|
||||
padding: EdgeInsets.zero,
|
||||
)
|
||||
: const Chip(
|
||||
label: Text('新品'),
|
||||
labelStyle: TextStyle(
|
||||
color: Colors.white, fontSize: 11),
|
||||
backgroundColor: Colors.orange,
|
||||
visualDensity: VisualDensity.compact,
|
||||
padding: EdgeInsets.zero,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text('效果图',
|
||||
style: TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: scheme.primary)),
|
||||
const SizedBox(height: 8),
|
||||
if (d.images.every((img) => !img.done))
|
||||
const Text('效果图生成中,选定主方案后生成(每日限 3 次)',
|
||||
style: TextStyle(color: Colors.grey, fontSize: 13))
|
||||
else
|
||||
SizedBox(
|
||||
height: 96,
|
||||
child: ListView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
children: [
|
||||
for (final img in d.images.where((i) => i.done))
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 8),
|
||||
child: GestureDetector(
|
||||
onTap: () => context.push('/plan-effect',
|
||||
extra: d.images
|
||||
.where((i) => i.done)
|
||||
.toList()),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Image.network(
|
||||
AppConfig.resolveUrl(img.url),
|
||||
width: 80,
|
||||
height: 96,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (_, _, _) => Container(
|
||||
width: 80,
|
||||
height: 96,
|
||||
color: Colors.grey.shade200,
|
||||
child: const Icon(Icons.image,
|
||||
color: Colors.grey),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: plan.isMain
|
||||
? null
|
||||
: _selecting
|
||||
? null
|
||||
: () => _selectMain(plan.id),
|
||||
icon: const Icon(Icons.star_outline),
|
||||
label: Text(plan.isMain ? '主方案' : '设为主方案'),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: () => context.push('/plan-effect',
|
||||
extra: d.images),
|
||||
icon: const Icon(Icons.image_outlined),
|
||||
label: const Text('效果图'),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: () => _review(plan.id, 'fav'),
|
||||
icon: const Icon(Icons.favorite_outline),
|
||||
label: const Text('收藏'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
IconData _slotIcon(String slot) {
|
||||
switch (slot) {
|
||||
case '上衣':
|
||||
return Icons.checkroom;
|
||||
case '下装':
|
||||
return Icons.airline_seat_legroom_normal;
|
||||
case '鞋':
|
||||
return Icons.directions_walk;
|
||||
case '配饰':
|
||||
return Icons.watch_outlined;
|
||||
default:
|
||||
return Icons.style_outlined;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:fluttertoast/fluttertoast.dart';
|
||||
|
||||
import '../../shared/widgets/avatar_viewer.dart';
|
||||
import '../../shared/widgets/loading_view.dart';
|
||||
import 'avatar_provider.dart';
|
||||
|
||||
/// 3D 化身查看页
|
||||
class AvatarViewerPage extends ConsumerWidget {
|
||||
const AvatarViewerPage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final avatar = ref.watch(avatarProvider);
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('我的 3D 化身')),
|
||||
body: avatar.when(
|
||||
loading: () => const LoadingView(text: '加载化身...'),
|
||||
error: (e, _) => Center(child: Text('加载失败:$e')),
|
||||
data: (a) => ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
AvatarViewer(
|
||||
glbUrl: a.glbUrl,
|
||||
title: '我的 3D 化身',
|
||||
subtitle: a.built
|
||||
? '脸型模板 ${a.faceTemplateId} · 体型模板 ${a.bodyTemplateId} · 肤色 ${a.skinToneIndex}'
|
||||
: '尚未构建化身',
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text('构建说明',
|
||||
style: TextStyle(
|
||||
fontSize: 15, fontWeight: FontWeight.bold)),
|
||||
const SizedBox(height: 8),
|
||||
const Text(
|
||||
'化身由脸型(20 档)× 体型(6 档)× 肤色(5 档)模板组合而成,'
|
||||
'依据大头照、全身照与身形参数自动匹配。'
|
||||
'3D 渲染在预烘焙 GLB 资产接入后启用。',
|
||||
style: TextStyle(fontSize: 13, color: Colors.grey),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: () async {
|
||||
await ref
|
||||
.read(avatarProvider.notifier)
|
||||
.buildAvatar();
|
||||
if (context.mounted) {
|
||||
Fluttertoast.showToast(msg: '化身构建完成');
|
||||
}
|
||||
},
|
||||
icon: const Icon(Icons.refresh),
|
||||
label: const Text('重新构建化身'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -67,13 +67,15 @@ class ProfilePage extends ConsumerWidget {
|
||||
text: a.built
|
||||
? '已构建(模板 ${a.faceTemplateId}/${a.bodyTemplateId})'
|
||||
: '尚未构建化身,需要先完成照片与身形',
|
||||
actionText: a.built ? '重新构建' : '立即构建',
|
||||
onAction: () async {
|
||||
await ref.read(avatarProvider.notifier).buildAvatar();
|
||||
if (context.mounted) {
|
||||
Fluttertoast.showToast(msg: '化身构建完成');
|
||||
}
|
||||
},
|
||||
actionText: a.built ? '查看' : '立即构建',
|
||||
onAction: a.built
|
||||
? () => context.go('/avatar-viewer')
|
||||
: () async {
|
||||
await ref.read(avatarProvider.notifier).buildAvatar();
|
||||
if (context.mounted) {
|
||||
Fluttertoast.showToast(msg: '化身构建完成');
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -6,6 +6,10 @@ import 'features/auth/login_page.dart';
|
||||
import 'features/commercial/commercial_page.dart';
|
||||
import 'features/home/home_page.dart';
|
||||
import 'features/outfit/outfit_page.dart';
|
||||
import 'features/outfit/outfit_provider.dart';
|
||||
import 'features/outfit/plan_effect_page.dart';
|
||||
import 'features/outfit/plan_viewer_page.dart';
|
||||
import 'features/profile/avatar_viewer_page.dart';
|
||||
import 'features/profile/body_tune_page.dart';
|
||||
import 'features/profile/photo_guide_page.dart';
|
||||
import 'features/profile/profile_page.dart';
|
||||
@@ -75,6 +79,19 @@ final routerProvider = Provider<GoRouter>((ref) {
|
||||
path: '/commercial',
|
||||
builder: (ctx, state) => const CommercialPage(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/plan-viewer',
|
||||
builder: (ctx, state) => const PlanViewerPage(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/plan-effect',
|
||||
builder: (ctx, state) =>
|
||||
PlanEffectPage(images: state.extra as List<PlanEffectImageInfo>),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/avatar-viewer',
|
||||
builder: (ctx, state) => const AvatarViewerPage(),
|
||||
),
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_gl/flutter_gl.dart';
|
||||
import 'package:three_dart/three_dart.dart' as three;
|
||||
import 'package:three_dart_jsm/three_dart_jsm.dart' as three_jsm;
|
||||
|
||||
import '../../core/config/app_config.dart';
|
||||
|
||||
/// 是否启用 three_dart 3D 渲染。
|
||||
/// MVP 阶段服务端尚无预烘焙 GLB 资产(/workspace/templates/*.glb 为构建产物),
|
||||
/// 默认关闭展示信息占位;接入真实 GLB 资源后置为 true 即可启用。
|
||||
const bool kEnable3D = false;
|
||||
|
||||
/// 3D 化身查看组件:three_dart 渲染 GLB,加载失败/未启用时展示信息占位
|
||||
class AvatarViewer extends StatelessWidget {
|
||||
final String? glbUrl;
|
||||
final String title;
|
||||
final String subtitle;
|
||||
final double height;
|
||||
|
||||
const AvatarViewer({
|
||||
super.key,
|
||||
required this.glbUrl,
|
||||
required this.title,
|
||||
required this.subtitle,
|
||||
this.height = 360,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (!kEnable3D || glbUrl == null || glbUrl!.isEmpty) {
|
||||
return _AvatarPlaceholder(
|
||||
title: title, subtitle: subtitle, height: height);
|
||||
}
|
||||
return _ThreeDAvatarViewer(
|
||||
url: AppConfig.resolveUrl(glbUrl!),
|
||||
title: title,
|
||||
subtitle: subtitle,
|
||||
height: height,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// three_dart 实现:flutter_gl 初始化 + GLTFLoader 加载 + 自动旋转渲染
|
||||
class _ThreeDAvatarViewer extends StatefulWidget {
|
||||
final String url;
|
||||
final String title;
|
||||
final String subtitle;
|
||||
final double height;
|
||||
|
||||
const _ThreeDAvatarViewer({
|
||||
required this.url,
|
||||
required this.title,
|
||||
required this.subtitle,
|
||||
required this.height,
|
||||
});
|
||||
|
||||
@override
|
||||
State<_ThreeDAvatarViewer> createState() => _ThreeDAvatarViewerState();
|
||||
}
|
||||
|
||||
class _ThreeDAvatarViewerState extends State<_ThreeDAvatarViewer> {
|
||||
FlutterGlPlugin? _gl;
|
||||
three.WebGLRenderer? _renderer;
|
||||
three.Scene? _scene;
|
||||
three.Camera? _camera;
|
||||
three.Object3D? _model;
|
||||
dynamic _sourceTexture;
|
||||
double _width = 300;
|
||||
double _dpr = 1;
|
||||
bool _ready = false;
|
||||
bool _failed = false;
|
||||
bool _disposed = false;
|
||||
Timer? _timer;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => _init());
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_disposed = true;
|
||||
_timer?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _init() async {
|
||||
final mq = MediaQuery.of(context);
|
||||
_width = mq.size.width;
|
||||
_dpr = mq.devicePixelRatio;
|
||||
try {
|
||||
final gl = FlutterGlPlugin();
|
||||
await gl.initialize(options: {
|
||||
'antialias': true,
|
||||
'alpha': false,
|
||||
'width': _width.toInt(),
|
||||
'height': widget.height.toInt(),
|
||||
'dpr': _dpr,
|
||||
});
|
||||
await gl.prepareContext();
|
||||
|
||||
final renderer = three.WebGLRenderer({
|
||||
'width': _width,
|
||||
'height': widget.height,
|
||||
'gl': gl.gl,
|
||||
'antialias': true,
|
||||
'canvas': gl.element,
|
||||
});
|
||||
renderer.setPixelRatio(_dpr);
|
||||
renderer.setSize(_width, widget.height, false);
|
||||
|
||||
final target = three.WebGLMultisampleRenderTarget(
|
||||
(_width * _dpr).toInt(), (widget.height * _dpr).toInt(),
|
||||
three.WebGLRenderTargetOptions({'format': three.RGBAFormat}));
|
||||
target.samples = 4;
|
||||
renderer.setRenderTarget(target);
|
||||
final sourceTexture = renderer.getRenderTargetGLTexture(target);
|
||||
|
||||
await _loadModel(gl, renderer);
|
||||
|
||||
_gl = gl;
|
||||
_renderer = renderer;
|
||||
_sourceTexture = sourceTexture;
|
||||
if (mounted) setState(() => _ready = true);
|
||||
_timer = Timer.periodic(
|
||||
const Duration(milliseconds: 33), (_) => _render());
|
||||
} catch (e) {
|
||||
debugPrint('3D 渲染不可用,回退占位:$e');
|
||||
if (mounted) setState(() => _failed = true);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _loadModel(
|
||||
FlutterGlPlugin gl, three.WebGLRenderer renderer) async {
|
||||
final scene = three.Scene();
|
||||
final camera = three.PerspectiveCamera(45, _width / widget.height, 0.1, 100);
|
||||
camera.position.set(0, 1.4, 3.2);
|
||||
scene.add(three.AmbientLight(0xffffff, 0.9));
|
||||
final keyLight = three.DirectionalLight(0xffffff, 0.9);
|
||||
keyLight.position.set(2, 4, 3);
|
||||
scene.add(keyLight);
|
||||
scene.add(camera);
|
||||
camera.lookAt(three.Vector3(0, 1, 0));
|
||||
|
||||
final loader = three_jsm.GLTFLoader(null);
|
||||
final result = await loader.loadAsync(widget.url);
|
||||
final model = result['scene'] as three.Object3D?;
|
||||
if (model != null) {
|
||||
model.rotation.y = 0.6;
|
||||
scene.add(model);
|
||||
}
|
||||
|
||||
_scene = scene;
|
||||
_camera = camera;
|
||||
_model = model;
|
||||
}
|
||||
|
||||
void _render() {
|
||||
if (_disposed || _renderer == null || _scene == null || _camera == null) {
|
||||
return;
|
||||
}
|
||||
final model = _model;
|
||||
if (model != null) {
|
||||
model.rotation.y += 0.01; // 自动旋转
|
||||
}
|
||||
_renderer!.render(_scene!, _camera!);
|
||||
_gl!.gl.flush();
|
||||
if (!kIsWeb) {
|
||||
_gl!.updateTexture(_sourceTexture);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (_failed) {
|
||||
return _AvatarPlaceholder(
|
||||
title: widget.title, subtitle: widget.subtitle, height: widget.height);
|
||||
}
|
||||
return SizedBox(
|
||||
height: widget.height,
|
||||
width: double.infinity,
|
||||
child: _ready
|
||||
? (kIsWeb
|
||||
? HtmlElementView(viewType: _gl!.textureId!.toString())
|
||||
: Texture(textureId: _gl!.textureId!))
|
||||
: const Center(child: CircularProgressIndicator()),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 占位:GLB 资产未接入或加载失败时展示化身信息
|
||||
class _AvatarPlaceholder extends StatelessWidget {
|
||||
final String title;
|
||||
final String subtitle;
|
||||
final double height;
|
||||
|
||||
const _AvatarPlaceholder({
|
||||
required this.title,
|
||||
required this.subtitle,
|
||||
required this.height,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
return Container(
|
||||
height: height,
|
||||
width: double.infinity,
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [scheme.primaryContainer, scheme.primary.withValues(alpha: 0.3)],
|
||||
),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(Icons.accessibility_new, size: 72, color: scheme.primary),
|
||||
const SizedBox(height: 12),
|
||||
Text(title,
|
||||
style:
|
||||
const TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
|
||||
const SizedBox(height: 4),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24),
|
||||
child: Text(
|
||||
subtitle.isEmpty ? '3D 渲染接入中(v2)' : subtitle,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(color: Colors.grey, fontSize: 12),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user