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:
2026-07-31 12:50:02 +08:00
parent ceef7bc64e
commit c4fd49bd7e
10 changed files with 1374 additions and 132 deletions
+249 -2
View File
@@ -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),
],
),
),
),
);
}
}
+245
View File
@@ -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? ?? '',
);
});
+120
View File
@@ -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)),
),
],
),
);
},
),
),
],
),
);
}
}
+287
View File
@@ -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('重新构建化身'),
),
),
],
),
),
),
],
),
),
);
}
}
+9 -7
View File
@@ -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: '化身构建完成');
}
},
),
),
);
+17
View File
@@ -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(),
),
],
);
});
+242
View File
@@ -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),
),
),
],
),
);
}
}
+131 -123
View File
@@ -6,7 +6,7 @@ packages:
description:
name: _fe_analyzer_shared
sha256: a49d6cf99e8d8e7a8e93668d09ced0bbdb954d0b4fccc2f5f9241c6b87fad95c
url: "https://pub.flutter-io.cn"
url: "https://pub.dev"
source: hosted
version: "99.0.0"
analyzer:
@@ -14,7 +14,7 @@ packages:
description:
name: analyzer
sha256: "663efa951fb8a45e06f491223a604c93820598f20e6a99c25617a1576065e8b7"
url: "https://pub.flutter-io.cn"
url: "https://pub.dev"
source: hosted
version: "12.1.0"
archive:
@@ -22,7 +22,7 @@ packages:
description:
name: archive
sha256: cb6a278ef2dbb298455e1a713bda08524a175630ec643a242c399c932a0a1f7d
url: "https://pub.flutter-io.cn"
url: "https://pub.dev"
source: hosted
version: "3.6.1"
args:
@@ -30,7 +30,7 @@ packages:
description:
name: args
sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04
url: "https://pub.flutter-io.cn"
url: "https://pub.dev"
source: hosted
version: "2.7.0"
async:
@@ -38,7 +38,7 @@ packages:
description:
name: async
sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37
url: "https://pub.flutter-io.cn"
url: "https://pub.dev"
source: hosted
version: "2.13.1"
boolean_selector:
@@ -46,7 +46,7 @@ packages:
description:
name: boolean_selector
sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea"
url: "https://pub.flutter-io.cn"
url: "https://pub.dev"
source: hosted
version: "2.1.2"
characters:
@@ -54,7 +54,7 @@ packages:
description:
name: characters
sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b
url: "https://pub.flutter-io.cn"
url: "https://pub.dev"
source: hosted
version: "1.4.1"
charcode:
@@ -62,7 +62,7 @@ packages:
description:
name: charcode
sha256: fb0f1107cac15a5ea6ef0a6ef71a807b9e4267c713bb93e00e92d737cc8dbd8a
url: "https://pub.flutter-io.cn"
url: "https://pub.dev"
source: hosted
version: "1.4.0"
cli_config:
@@ -70,7 +70,7 @@ packages:
description:
name: cli_config
sha256: ac20a183a07002b700f0c25e61b7ee46b23c309d76ab7b7640a028f18e4d99ec
url: "https://pub.flutter-io.cn"
url: "https://pub.dev"
source: hosted
version: "0.2.0"
clock:
@@ -78,7 +78,7 @@ packages:
description:
name: clock
sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b
url: "https://pub.flutter-io.cn"
url: "https://pub.dev"
source: hosted
version: "1.1.2"
collection:
@@ -86,7 +86,7 @@ packages:
description:
name: collection
sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76"
url: "https://pub.flutter-io.cn"
url: "https://pub.dev"
source: hosted
version: "1.19.1"
convert:
@@ -94,7 +94,7 @@ packages:
description:
name: convert
sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68
url: "https://pub.flutter-io.cn"
url: "https://pub.dev"
source: hosted
version: "3.1.2"
coverage:
@@ -102,7 +102,7 @@ packages:
description:
name: coverage
sha256: "956a3de0725ca232ad353565a8290d3357592bf4250f6f298a185e2d949c5d3d"
url: "https://pub.flutter-io.cn"
url: "https://pub.dev"
source: hosted
version: "1.15.1"
cross_file:
@@ -110,7 +110,7 @@ packages:
description:
name: cross_file
sha256: "92c9c43c383bfa1c32079d3bc492d55d6d4318044b7b47edaff8971cbb555c51"
url: "https://pub.flutter-io.cn"
url: "https://pub.dev"
source: hosted
version: "0.3.5+4"
crypto:
@@ -118,7 +118,7 @@ packages:
description:
name: crypto
sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf
url: "https://pub.flutter-io.cn"
url: "https://pub.dev"
source: hosted
version: "3.0.7"
csslib:
@@ -126,7 +126,7 @@ packages:
description:
name: csslib
sha256: "09bad715f418841f976c77db72d5398dc1253c21fb9c0c7f0b0b985860b2d58e"
url: "https://pub.flutter-io.cn"
url: "https://pub.dev"
source: hosted
version: "1.0.2"
cupertino_icons:
@@ -134,31 +134,31 @@ packages:
description:
name: cupertino_icons
sha256: "41e005c33bd814be4d3096aff55b1908d419fde52ca656c8c47719ec745873cd"
url: "https://pub.flutter-io.cn"
url: "https://pub.dev"
source: hosted
version: "1.0.9"
dio:
dependency: "direct main"
description:
name: dio
sha256: ea2bad3c89a27635ce2d85cce4d6b199da49a5a48ec77b03e45b65a3b90922b0
url: "https://pub.flutter-io.cn"
sha256: "0df44ebba85e503958eb75d07eedd3c86275a58c1d3eda2f2ce8f0a2c3abbb3c"
url: "https://pub.dev"
source: hosted
version: "5.10.0"
version: "5.11.0"
dio_web_adapter:
dependency: transitive
description:
name: dio_web_adapter
sha256: dd58dc3861eb36edb13b217efc006a1c21e5bbc341de8c229b85634fa5e362e4
url: "https://pub.flutter-io.cn"
sha256: "0786d0b7295a373de356fc0af4f6f1d0ab2844ed31b19dfc5e7556b70e24212c"
url: "https://pub.dev"
source: hosted
version: "2.2.0"
version: "2.2.1"
fake_async:
dependency: transitive
description:
name: fake_async
sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44"
url: "https://pub.flutter-io.cn"
url: "https://pub.dev"
source: hosted
version: "1.3.3"
ffi:
@@ -166,7 +166,7 @@ packages:
description:
name: ffi
sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45"
url: "https://pub.flutter-io.cn"
url: "https://pub.dev"
source: hosted
version: "2.2.0"
file:
@@ -174,7 +174,7 @@ packages:
description:
name: file
sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4
url: "https://pub.flutter-io.cn"
url: "https://pub.dev"
source: hosted
version: "7.0.1"
file_selector_linux:
@@ -182,7 +182,7 @@ packages:
description:
name: file_selector_linux
sha256: "2567f398e06ac72dcf2e98a0c95df2a9edd03c2c2e0cacd4780f20cdf56263a0"
url: "https://pub.flutter-io.cn"
url: "https://pub.dev"
source: hosted
version: "0.9.4"
file_selector_macos:
@@ -190,7 +190,7 @@ packages:
description:
name: file_selector_macos
sha256: "5e0bbe9c312416f1787a68259ea1505b52f258c587f12920422671807c4d618a"
url: "https://pub.flutter-io.cn"
url: "https://pub.dev"
source: hosted
version: "0.9.5"
file_selector_platform_interface:
@@ -198,7 +198,7 @@ packages:
description:
name: file_selector_platform_interface
sha256: "35e0bd61ebcdb91a3505813b055b09b79dfdc7d0aee9c09a7ba59ae4bb13dc85"
url: "https://pub.flutter-io.cn"
url: "https://pub.dev"
source: hosted
version: "2.7.0"
file_selector_windows:
@@ -206,7 +206,7 @@ packages:
description:
name: file_selector_windows
sha256: "62197474ae75893a62df75939c777763d39c2bc5f73ce5b88497208bc269abfd"
url: "https://pub.flutter-io.cn"
url: "https://pub.dev"
source: hosted
version: "0.9.3+5"
fixnum:
@@ -214,7 +214,7 @@ packages:
description:
name: fixnum
sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be
url: "https://pub.flutter-io.cn"
url: "https://pub.dev"
source: hosted
version: "1.1.1"
flutter:
@@ -223,11 +223,11 @@ packages:
source: sdk
version: "0.0.0"
flutter_gl:
dependency: transitive
dependency: "direct main"
description:
name: flutter_gl
sha256: de74c88f77228f47dd280e2092b50eb49fe1fc008de74047d195a27f2db0b491
url: "https://pub.flutter-io.cn"
url: "https://pub.dev"
source: hosted
version: "0.0.21"
flutter_gl_macos:
@@ -235,7 +235,7 @@ packages:
description:
name: flutter_gl_macos
sha256: "62aa244d4aa9127115df651baec070893718dd27571c8dbca5374dbcb9fd4849"
url: "https://pub.flutter-io.cn"
url: "https://pub.dev"
source: hosted
version: "0.0.5"
flutter_gl_platform_interface:
@@ -243,7 +243,7 @@ packages:
description:
name: flutter_gl_platform_interface
sha256: "03062491fac26d0fda80703e4a01894ecc4fec4f7e5cec131ef021fbf46b2fda"
url: "https://pub.flutter-io.cn"
url: "https://pub.dev"
source: hosted
version: "0.0.4"
flutter_gl_web:
@@ -251,7 +251,7 @@ packages:
description:
name: flutter_gl_web
sha256: "005dc72618ee14659dff7dfe72d6b1fce0efdda745cfebfe4618ce7ac2044af9"
url: "https://pub.flutter-io.cn"
url: "https://pub.dev"
source: hosted
version: "0.0.5"
flutter_gl_windows:
@@ -259,7 +259,7 @@ packages:
description:
name: flutter_gl_windows
sha256: cd9259fb8178863de9e667129f73447fa174447a3c4abd16defa07af648850e4
url: "https://pub.flutter-io.cn"
url: "https://pub.dev"
source: hosted
version: "0.0.4"
flutter_lints:
@@ -267,7 +267,7 @@ packages:
description:
name: flutter_lints
sha256: "3105dc8492f6183fb076ccf1f351ac3d60564bff92e20bfc4af9cc1651f4e7e1"
url: "https://pub.flutter-io.cn"
url: "https://pub.dev"
source: hosted
version: "6.0.0"
flutter_plugin_android_lifecycle:
@@ -275,17 +275,17 @@ packages:
description:
name: flutter_plugin_android_lifecycle
sha256: "3854fe5e3bff0b113c658f260b90c95dea17c92db0f2addeac2e343dd9969785"
url: "https://pub.flutter-io.cn"
url: "https://pub.dev"
source: hosted
version: "2.0.35"
flutter_riverpod:
dependency: "direct main"
description:
name: flutter_riverpod
sha256: "9255e1e3ad6e38906a1b4f8287678f95f378744c5b46b1985588543f3f19046e"
url: "https://pub.flutter-io.cn"
sha256: "56e81e662e6e54e59b231da57e90ac0d06ac67d8e3f2273c129a37ae6282b40c"
url: "https://pub.dev"
source: hosted
version: "3.3.2"
version: "3.4.2"
flutter_test:
dependency: "direct dev"
description: flutter
@@ -301,7 +301,7 @@ packages:
description:
name: fluttertoast
sha256: "7903c9d5339173497bfecbc23bc4212f5a87e0edfac2e1693fb74465ea67da7e"
url: "https://pub.flutter-io.cn"
url: "https://pub.dev"
source: hosted
version: "9.1.0"
frontend_server_client:
@@ -309,7 +309,7 @@ packages:
description:
name: frontend_server_client
sha256: f64a0333a82f30b0cca061bc3d143813a486dc086b574bfb233b7c1372427694
url: "https://pub.flutter-io.cn"
url: "https://pub.dev"
source: hosted
version: "4.0.0"
glob:
@@ -317,7 +317,7 @@ packages:
description:
name: glob
sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de
url: "https://pub.flutter-io.cn"
url: "https://pub.dev"
source: hosted
version: "2.1.3"
go_router:
@@ -325,7 +325,7 @@ packages:
description:
name: go_router
sha256: "5922b2861e2235a3504896f0d6fa07d84141b480cf52eecd2f42cd25585a9e8a"
url: "https://pub.flutter-io.cn"
url: "https://pub.dev"
source: hosted
version: "17.3.0"
html:
@@ -333,7 +333,7 @@ packages:
description:
name: html
sha256: "6d1264f2dffa1b1101c25a91dff0dc2daee4c18e87cd8538729773c073dbf602"
url: "https://pub.flutter-io.cn"
url: "https://pub.dev"
source: hosted
version: "0.15.6"
http:
@@ -341,7 +341,7 @@ packages:
description:
name: http
sha256: "5895291c13fa8a3bd82e76d5627f69e0d85ca6a30dcac95c4ea19a5d555879c2"
url: "https://pub.flutter-io.cn"
url: "https://pub.dev"
source: hosted
version: "0.13.6"
http_multi_server:
@@ -349,7 +349,7 @@ packages:
description:
name: http_multi_server
sha256: aa6199f908078bb1c5efb8d8638d4ae191aac11b311132c3ef48ce352fb52ef8
url: "https://pub.flutter-io.cn"
url: "https://pub.dev"
source: hosted
version: "3.2.2"
http_parser:
@@ -357,7 +357,7 @@ packages:
description:
name: http_parser
sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571"
url: "https://pub.flutter-io.cn"
url: "https://pub.dev"
source: hosted
version: "4.1.2"
image:
@@ -365,7 +365,7 @@ packages:
description:
name: image
sha256: "8e9d133755c3e84c73288363e6343157c383a0c6c56fc51afcc5d4d7180306d6"
url: "https://pub.flutter-io.cn"
url: "https://pub.dev"
source: hosted
version: "3.3.0"
image_picker:
@@ -373,7 +373,7 @@ packages:
description:
name: image_picker
sha256: d8402284df184bc05f4a2210c6c23983b0720f4cd87cbd05c5390a78af602667
url: "https://pub.flutter-io.cn"
url: "https://pub.dev"
source: hosted
version: "1.2.3"
image_picker_android:
@@ -381,7 +381,7 @@ packages:
description:
name: image_picker_android
sha256: "6f3a1995eafb000333174fae92202622033b0ee7fd917a6cd3730295264df84a"
url: "https://pub.flutter-io.cn"
url: "https://pub.dev"
source: hosted
version: "0.8.13+19"
image_picker_for_web:
@@ -389,7 +389,7 @@ packages:
description:
name: image_picker_for_web
sha256: "66257a3191ab360d23a55c8241c91a6e329d31e94efa7be9cf7a212e65850214"
url: "https://pub.flutter-io.cn"
url: "https://pub.dev"
source: hosted
version: "3.1.1"
image_picker_ios:
@@ -397,7 +397,7 @@ packages:
description:
name: image_picker_ios
sha256: b9c4a438a9ff4f60808c9cf0039b93a42bb6c2211ef6ebb647394b2b3fa84588
url: "https://pub.flutter-io.cn"
url: "https://pub.dev"
source: hosted
version: "0.8.13+6"
image_picker_linux:
@@ -405,7 +405,7 @@ packages:
description:
name: image_picker_linux
sha256: "1f81c5f2046b9ab724f85523e4af65be1d47b038160a8c8deed909762c308ed4"
url: "https://pub.flutter-io.cn"
url: "https://pub.dev"
source: hosted
version: "0.2.2"
image_picker_macos:
@@ -413,7 +413,7 @@ packages:
description:
name: image_picker_macos
sha256: "86f0f15a309de7e1a552c12df9ce5b59fe927e71385329355aec4776c6a8ec91"
url: "https://pub.flutter-io.cn"
url: "https://pub.dev"
source: hosted
version: "0.2.2+1"
image_picker_platform_interface:
@@ -421,7 +421,7 @@ packages:
description:
name: image_picker_platform_interface
sha256: "567e056716333a1647c64bb6bd873cff7622233a5c3f694be28a583d4715690c"
url: "https://pub.flutter-io.cn"
url: "https://pub.dev"
source: hosted
version: "2.11.1"
image_picker_windows:
@@ -429,7 +429,7 @@ packages:
description:
name: image_picker_windows
sha256: d248c86554a72b5495a31c56f060cf73a41c7ff541689327b1a7dbccc33adfae
url: "https://pub.flutter-io.cn"
url: "https://pub.dev"
source: hosted
version: "0.2.2"
io:
@@ -437,7 +437,7 @@ packages:
description:
name: io
sha256: dfd5a80599cf0165756e3181807ed3e77daf6dd4137caaad72d0b7931597650b
url: "https://pub.flutter-io.cn"
url: "https://pub.dev"
source: hosted
version: "1.0.5"
leak_tracker:
@@ -445,7 +445,7 @@ packages:
description:
name: leak_tracker
sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de"
url: "https://pub.flutter-io.cn"
url: "https://pub.dev"
source: hosted
version: "11.0.2"
leak_tracker_flutter_testing:
@@ -453,7 +453,7 @@ packages:
description:
name: leak_tracker_flutter_testing
sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1"
url: "https://pub.flutter-io.cn"
url: "https://pub.dev"
source: hosted
version: "3.0.10"
leak_tracker_testing:
@@ -461,7 +461,7 @@ packages:
description:
name: leak_tracker_testing
sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1"
url: "https://pub.flutter-io.cn"
url: "https://pub.dev"
source: hosted
version: "3.0.2"
lints:
@@ -469,15 +469,23 @@ packages:
description:
name: lints
sha256: "12f842a479589fea194fe5c5a3095abc7be0c1f2ddfa9a0e76aed1dbd26a87df"
url: "https://pub.flutter-io.cn"
url: "https://pub.dev"
source: hosted
version: "6.1.0"
listen:
dependency: transitive
description:
name: listen
sha256: cb0ad74d7453d8f83a7c1fae401828681ef5df21d41a3a26a04af590c985d085
url: "https://pub.dev"
source: hosted
version: "1.0.0-beta.4"
logging:
dependency: transitive
description:
name: logging
sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61
url: "https://pub.flutter-io.cn"
url: "https://pub.dev"
source: hosted
version: "1.3.0"
matcher:
@@ -485,7 +493,7 @@ packages:
description:
name: matcher
sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861
url: "https://pub.flutter-io.cn"
url: "https://pub.dev"
source: hosted
version: "0.12.19"
material_color_utilities:
@@ -493,7 +501,7 @@ packages:
description:
name: material_color_utilities
sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b"
url: "https://pub.flutter-io.cn"
url: "https://pub.dev"
source: hosted
version: "0.13.0"
meta:
@@ -501,7 +509,7 @@ packages:
description:
name: meta
sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349"
url: "https://pub.flutter-io.cn"
url: "https://pub.dev"
source: hosted
version: "1.18.0"
mime:
@@ -509,7 +517,7 @@ packages:
description:
name: mime
sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6"
url: "https://pub.flutter-io.cn"
url: "https://pub.dev"
source: hosted
version: "2.0.0"
node_preamble:
@@ -517,7 +525,7 @@ packages:
description:
name: node_preamble
sha256: "6e7eac89047ab8a8d26cf16127b5ed26de65209847630400f9aefd7cd5c730db"
url: "https://pub.flutter-io.cn"
url: "https://pub.dev"
source: hosted
version: "2.0.2"
opentype_dart:
@@ -525,7 +533,7 @@ packages:
description:
name: opentype_dart
sha256: "4bd96aeed494289a87e92bde20afe60f59648dcef253c0a7159b65ffa23899dc"
url: "https://pub.flutter-io.cn"
url: "https://pub.dev"
source: hosted
version: "0.0.1"
package_config:
@@ -533,7 +541,7 @@ packages:
description:
name: package_config
sha256: f096c55ebb7deb7e384101542bfba8c52696c1b56fca2eb62827989ef2353bbc
url: "https://pub.flutter-io.cn"
url: "https://pub.dev"
source: hosted
version: "2.2.0"
path:
@@ -541,7 +549,7 @@ packages:
description:
name: path
sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5"
url: "https://pub.flutter-io.cn"
url: "https://pub.dev"
source: hosted
version: "1.9.1"
path_provider_linux:
@@ -549,7 +557,7 @@ packages:
description:
name: path_provider_linux
sha256: "58c2005f147315b11e9b4a7bc889cd5203e250cba8e3f012dae259b4972b5c16"
url: "https://pub.flutter-io.cn"
url: "https://pub.dev"
source: hosted
version: "2.2.2"
path_provider_platform_interface:
@@ -557,7 +565,7 @@ packages:
description:
name: path_provider_platform_interface
sha256: "484838772624c3a4b94f1e44a3e19897fee738f2d5c4ce448443b0417f7c9dda"
url: "https://pub.flutter-io.cn"
url: "https://pub.dev"
source: hosted
version: "2.1.3"
path_provider_windows:
@@ -565,7 +573,7 @@ packages:
description:
name: path_provider_windows
sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7
url: "https://pub.flutter-io.cn"
url: "https://pub.dev"
source: hosted
version: "2.3.0"
petitparser:
@@ -573,7 +581,7 @@ packages:
description:
name: petitparser
sha256: "91bd59303e9f769f108f8df05e371341b15d59e995e6806aefab827b58336675"
url: "https://pub.flutter-io.cn"
url: "https://pub.dev"
source: hosted
version: "7.0.2"
platform:
@@ -581,7 +589,7 @@ packages:
description:
name: platform
sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984"
url: "https://pub.flutter-io.cn"
url: "https://pub.dev"
source: hosted
version: "3.1.6"
plugin_platform_interface:
@@ -589,7 +597,7 @@ packages:
description:
name: plugin_platform_interface
sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02"
url: "https://pub.flutter-io.cn"
url: "https://pub.dev"
source: hosted
version: "2.1.8"
pool:
@@ -597,7 +605,7 @@ packages:
description:
name: pool
sha256: "978783255c543aa3586a1b3c21f6e9d720eb315376a915872c61ef8b5c20177d"
url: "https://pub.flutter-io.cn"
url: "https://pub.dev"
source: hosted
version: "1.5.2"
pub_semver:
@@ -605,23 +613,23 @@ packages:
description:
name: pub_semver
sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585"
url: "https://pub.flutter-io.cn"
url: "https://pub.dev"
source: hosted
version: "2.2.0"
riverpod:
dependency: transitive
description:
name: riverpod
sha256: "17100416c51db7810c71a7bb2c34d1f881faa0074fd452afb0c4db6f8f126c76"
url: "https://pub.flutter-io.cn"
sha256: "84351b3472a447f7b89f961f95c73287009d58c59d8cc1267425d995f78e50f1"
url: "https://pub.dev"
source: hosted
version: "3.3.2"
version: "3.4.2"
shared_preferences:
dependency: "direct main"
description:
name: shared_preferences
sha256: c3025c5534b01739267eb7d76959bbc25a6d10f6988e1c2a3036940133dd10bf
url: "https://pub.flutter-io.cn"
url: "https://pub.dev"
source: hosted
version: "2.5.5"
shared_preferences_android:
@@ -629,7 +637,7 @@ packages:
description:
name: shared_preferences_android
sha256: "0634e64bd719f89c012f392938e173521f535d3ecaf66558fa94a056d22b5cc7"
url: "https://pub.flutter-io.cn"
url: "https://pub.dev"
source: hosted
version: "2.4.27"
shared_preferences_foundation:
@@ -637,7 +645,7 @@ packages:
description:
name: shared_preferences_foundation
sha256: "4e7eaffc2b17ba398759f1151415869a34771ba11ebbccd1b0145472a619a64f"
url: "https://pub.flutter-io.cn"
url: "https://pub.dev"
source: hosted
version: "2.5.6"
shared_preferences_linux:
@@ -645,7 +653,7 @@ packages:
description:
name: shared_preferences_linux
sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f"
url: "https://pub.flutter-io.cn"
url: "https://pub.dev"
source: hosted
version: "2.4.1"
shared_preferences_platform_interface:
@@ -653,7 +661,7 @@ packages:
description:
name: shared_preferences_platform_interface
sha256: "649dc798a33931919ea356c4305c2d1f81619ea6e92244070b520187b5140ef9"
url: "https://pub.flutter-io.cn"
url: "https://pub.dev"
source: hosted
version: "2.4.2"
shared_preferences_web:
@@ -661,7 +669,7 @@ packages:
description:
name: shared_preferences_web
sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019
url: "https://pub.flutter-io.cn"
url: "https://pub.dev"
source: hosted
version: "2.4.3"
shared_preferences_windows:
@@ -669,7 +677,7 @@ packages:
description:
name: shared_preferences_windows
sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1"
url: "https://pub.flutter-io.cn"
url: "https://pub.dev"
source: hosted
version: "2.4.1"
shelf:
@@ -677,7 +685,7 @@ packages:
description:
name: shelf
sha256: e7dd780a7ffb623c57850b33f43309312fc863fb6aa3d276a754bb299839ef12
url: "https://pub.flutter-io.cn"
url: "https://pub.dev"
source: hosted
version: "1.4.2"
shelf_packages_handler:
@@ -685,7 +693,7 @@ packages:
description:
name: shelf_packages_handler
sha256: "89f967eca29607c933ba9571d838be31d67f53f6e4ee15147d5dc2934fee1b1e"
url: "https://pub.flutter-io.cn"
url: "https://pub.dev"
source: hosted
version: "3.0.2"
shelf_static:
@@ -693,7 +701,7 @@ packages:
description:
name: shelf_static
sha256: c87c3875f91262785dade62d135760c2c69cb217ac759485334c5857ad89f6e3
url: "https://pub.flutter-io.cn"
url: "https://pub.dev"
source: hosted
version: "1.1.3"
shelf_web_socket:
@@ -701,7 +709,7 @@ packages:
description:
name: shelf_web_socket
sha256: "3632775c8e90d6c9712f883e633716432a27758216dfb61bd86a8321c0580925"
url: "https://pub.flutter-io.cn"
url: "https://pub.dev"
source: hosted
version: "3.0.0"
sky_engine:
@@ -714,7 +722,7 @@ packages:
description:
name: source_map_stack_trace
sha256: c0713a43e323c3302c2abe2a1cc89aa057a387101ebd280371d6a6c9fa68516b
url: "https://pub.flutter-io.cn"
url: "https://pub.dev"
source: hosted
version: "2.1.2"
source_maps:
@@ -722,7 +730,7 @@ packages:
description:
name: source_maps
sha256: "190222579a448b03896e0ca6eca5998fa810fda630c1d65e2f78b3f638f54812"
url: "https://pub.flutter-io.cn"
url: "https://pub.dev"
source: hosted
version: "0.10.13"
source_span:
@@ -730,7 +738,7 @@ packages:
description:
name: source_span
sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab"
url: "https://pub.flutter-io.cn"
url: "https://pub.dev"
source: hosted
version: "1.10.2"
stack_trace:
@@ -738,7 +746,7 @@ packages:
description:
name: stack_trace
sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1"
url: "https://pub.flutter-io.cn"
url: "https://pub.dev"
source: hosted
version: "1.12.1"
state_notifier:
@@ -746,7 +754,7 @@ packages:
description:
name: state_notifier
sha256: b8677376aa54f2d7c58280d5a007f9e8774f1968d1fb1c096adcb4792fba29bb
url: "https://pub.flutter-io.cn"
url: "https://pub.dev"
source: hosted
version: "1.0.0"
stream_channel:
@@ -754,7 +762,7 @@ packages:
description:
name: stream_channel
sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d"
url: "https://pub.flutter-io.cn"
url: "https://pub.dev"
source: hosted
version: "2.1.4"
string_scanner:
@@ -762,7 +770,7 @@ packages:
description:
name: string_scanner
sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43"
url: "https://pub.flutter-io.cn"
url: "https://pub.dev"
source: hosted
version: "1.4.1"
term_glyph:
@@ -770,7 +778,7 @@ packages:
description:
name: term_glyph
sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e"
url: "https://pub.flutter-io.cn"
url: "https://pub.dev"
source: hosted
version: "1.2.2"
test:
@@ -778,7 +786,7 @@ packages:
description:
name: test
sha256: "8d9ceddbab833f180fbefed08afa76d7c03513dfdba87ffcec2718b02bbcbf20"
url: "https://pub.flutter-io.cn"
url: "https://pub.dev"
source: hosted
version: "1.31.0"
test_api:
@@ -786,7 +794,7 @@ packages:
description:
name: test_api
sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e"
url: "https://pub.flutter-io.cn"
url: "https://pub.dev"
source: hosted
version: "0.7.11"
test_core:
@@ -794,7 +802,7 @@ packages:
description:
name: test_core
sha256: "1991d4cfe85d5043241acac92962c3977c8d2f2add1ee73130c7b286417d1d34"
url: "https://pub.flutter-io.cn"
url: "https://pub.dev"
source: hosted
version: "0.6.17"
three_dart:
@@ -802,7 +810,7 @@ packages:
description:
name: three_dart
sha256: "102ff2cc65c2dcb805166c7dc5fa00a9a3252b5aec34fb435cc558a7487dc40b"
url: "https://pub.flutter-io.cn"
url: "https://pub.dev"
source: hosted
version: "0.0.16"
three_dart_jsm:
@@ -810,7 +818,7 @@ packages:
description:
name: three_dart_jsm
sha256: "26a71aff4aa842ac8178d2ba8e64b997c434ce9f604fa4fa3ee49f343d2c84b4"
url: "https://pub.flutter-io.cn"
url: "https://pub.dev"
source: hosted
version: "0.0.10"
typed_data:
@@ -818,7 +826,7 @@ packages:
description:
name: typed_data
sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006
url: "https://pub.flutter-io.cn"
url: "https://pub.dev"
source: hosted
version: "1.4.0"
typr_dart:
@@ -826,7 +834,7 @@ packages:
description:
name: typr_dart
sha256: e8aa717c1445ceccd77bd6ba471683c8e17a9b14797ac09db3bd52d6fbd2fe7b
url: "https://pub.flutter-io.cn"
url: "https://pub.dev"
source: hosted
version: "0.0.2"
universal_html:
@@ -834,7 +842,7 @@ packages:
description:
name: universal_html
sha256: c0bcae5c733c60f26c7dfc88b10b0fd27cbcc45cb7492311cdaa6067e21c9cd4
url: "https://pub.flutter-io.cn"
url: "https://pub.dev"
source: hosted
version: "2.3.0"
universal_io:
@@ -842,7 +850,7 @@ packages:
description:
name: universal_io
sha256: f63cbc48103236abf48e345e07a03ce5757ea86285ed313a6a032596ed9301e2
url: "https://pub.flutter-io.cn"
url: "https://pub.dev"
source: hosted
version: "2.3.1"
uuid:
@@ -850,7 +858,7 @@ packages:
description:
name: uuid
sha256: "9b129329f58692f6e6578329498a8fe9fbe98f090beb764ffbb8ee2eadd01dcd"
url: "https://pub.flutter-io.cn"
url: "https://pub.dev"
source: hosted
version: "4.6.0"
vector_math:
@@ -858,7 +866,7 @@ packages:
description:
name: vector_math
sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b
url: "https://pub.flutter-io.cn"
url: "https://pub.dev"
source: hosted
version: "2.2.0"
vm_service:
@@ -866,7 +874,7 @@ packages:
description:
name: vm_service
sha256: "0016aef94fc66495ac78af5859181e3f3bf2026bd8eecc72b9565601e19ab360"
url: "https://pub.flutter-io.cn"
url: "https://pub.dev"
source: hosted
version: "15.2.0"
watcher:
@@ -874,7 +882,7 @@ packages:
description:
name: watcher
sha256: "1398c9f081a753f9226febe8900fce8f7d0a67163334e1c94a2438339d79d635"
url: "https://pub.flutter-io.cn"
url: "https://pub.dev"
source: hosted
version: "1.2.1"
web:
@@ -882,7 +890,7 @@ packages:
description:
name: web
sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a"
url: "https://pub.flutter-io.cn"
url: "https://pub.dev"
source: hosted
version: "1.1.1"
web_socket:
@@ -890,7 +898,7 @@ packages:
description:
name: web_socket
sha256: "34d64019aa8e36bf9842ac014bb5d2f5586ca73df5e4d9bf5c936975cae6982c"
url: "https://pub.flutter-io.cn"
url: "https://pub.dev"
source: hosted
version: "1.0.1"
web_socket_channel:
@@ -898,7 +906,7 @@ packages:
description:
name: web_socket_channel
sha256: d645757fb0f4773d602444000a8131ff5d48c9e47adfe9772652dd1a4f2d45c8
url: "https://pub.flutter-io.cn"
url: "https://pub.dev"
source: hosted
version: "3.0.3"
webkit_inspection_protocol:
@@ -906,7 +914,7 @@ packages:
description:
name: webkit_inspection_protocol
sha256: "87d3f2333bb240704cd3f1c6b5b7acd8a10e7f0bc28c28dcf14e782014f4a572"
url: "https://pub.flutter-io.cn"
url: "https://pub.dev"
source: hosted
version: "1.2.1"
xdg_directories:
@@ -914,7 +922,7 @@ packages:
description:
name: xdg_directories
sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15"
url: "https://pub.flutter-io.cn"
url: "https://pub.dev"
source: hosted
version: "1.1.0"
xml:
@@ -922,7 +930,7 @@ packages:
description:
name: xml
sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025"
url: "https://pub.flutter-io.cn"
url: "https://pub.dev"
source: hosted
version: "6.6.1"
yaml:
@@ -930,7 +938,7 @@ packages:
description:
name: yaml
sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce
url: "https://pub.flutter-io.cn"
url: "https://pub.dev"
source: hosted
version: "3.1.3"
sdks:
+1
View File
@@ -42,6 +42,7 @@ dependencies:
fluttertoast: ^9.1.0
three_dart: ^0.0.16
three_dart_jsm: ^0.0.10
flutter_gl: ^0.0.20
dev_dependencies:
flutter_test: