diff --git a/lib/core/config/app_config.dart b/lib/core/config/app_config.dart index fa6213d..65ba6bf 100644 --- a/lib/core/config/app_config.dart +++ b/lib/core/config/app_config.dart @@ -2,4 +2,10 @@ class AppConfig { /// 后端地址:iOS 模拟器用 127.0.0.1;Android 模拟器用 10.0.2.2;真机填局域网 IP static const String baseUrl = 'http://127.0.0.1:3007'; + + /// 后端返回的相对路径(/workspace/...)拼成完整 URL + static String resolveUrl(String path) { + if (path.startsWith('http')) return path; + return '$baseUrl$path'; + } } diff --git a/lib/features/profile/avatar_provider.dart b/lib/features/profile/avatar_provider.dart new file mode 100644 index 0000000..4113bd0 --- /dev/null +++ b/lib/features/profile/avatar_provider.dart @@ -0,0 +1,81 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../core/auth/auth_provider.dart'; + +class AvatarState { + final int faceTemplateId; + final int bodyTemplateId; + final int skinToneIndex; + final String glbUrl; + final String buildStatus; // pending / processing / done / failed + final String error; + + const AvatarState({ + this.faceTemplateId = 0, + this.bodyTemplateId = 0, + this.skinToneIndex = 0, + this.glbUrl = '', + this.buildStatus = '', + this.error = '', + }); + + bool get built => buildStatus == 'done' && glbUrl.isNotEmpty; +} + +class AvatarNotifier extends AsyncNotifier { + @override + Future build() async { + final api = ref.read(apiClientProvider); + final data = await api.get>('/avatar/get'); + return _fromData(data); + } + + AvatarState _fromData(Map data) => AvatarState( + faceTemplateId: (data['face_template_id'] as num?)?.toInt() ?? 0, + bodyTemplateId: (data['body_template_id'] as num?)?.toInt() ?? 0, + skinToneIndex: (data['skin_tone_index'] as num?)?.toInt() ?? 0, + glbUrl: data['glb_url'] as String? ?? '', + buildStatus: data['build_status'] as String? ?? '', + error: data['error'] as String? ?? '', + ); + + Future refresh() async { + state = const AsyncLoading(); + state = await AsyncValue.guard(build); + } + + /// 触发构建;若服务端异步则轮询直到 done/failed + Future buildAvatar() async { + state = const AsyncLoading(); + try { + final api = ref.read(apiClientProvider); + final data = + await api.post>('/avatar/build', {}); + final status = data['status'] as String? ?? ''; + if (status == 'pending' || status == 'processing') { + await _poll(); + } else { + await refresh(); + } + } catch (e) { + state = AsyncError(e, StackTrace.current); + } + } + + Future _poll() async { + for (var i = 0; i < 30; i++) { + await Future.delayed(const Duration(seconds: 2)); + final api = ref.read(apiClientProvider); + final data = await api.get>('/avatar/get'); + final status = data['build_status'] as String? ?? ''; + if (status == 'done' || status == 'failed') { + state = AsyncData(_fromData(data)); + return; + } + } + state = AsyncError(StateError('化身构建超时,请稍后重试'), StackTrace.empty); + } +} + +final avatarProvider = + AsyncNotifierProvider(AvatarNotifier.new); diff --git a/lib/features/profile/body_provider.dart b/lib/features/profile/body_provider.dart new file mode 100644 index 0000000..8bf324f --- /dev/null +++ b/lib/features/profile/body_provider.dart @@ -0,0 +1,38 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../core/auth/auth_provider.dart'; + +class BodyState { + final int height; + final int weight; + final int skinTone; + + const BodyState({this.height = 170, this.weight = 60, this.skinTone = 3}); +} + +class BodyNotifier extends AsyncNotifier { + @override + Future build() async { + final api = ref.read(apiClientProvider); + final data = await api.get>('/body-measurement/get'); + return BodyState( + height: (data['height'] as num?)?.toInt() ?? 170, + weight: (data['weight'] as num?)?.toInt() ?? 60, + skinTone: (data['skin_tone'] as num?)?.toInt() ?? 3, + ); + } + + Future save(int height, int weight, int skinTone) async { + final api = ref.read(apiClientProvider); + await api.post('/body-measurement/save', { + 'height': height, + 'weight': weight, + 'skin_tone': skinTone, + }); + state = AsyncData( + BodyState(height: height, weight: weight, skinTone: skinTone)); + } +} + +final bodyProvider = + AsyncNotifierProvider(BodyNotifier.new); diff --git a/lib/features/profile/body_tune_page.dart b/lib/features/profile/body_tune_page.dart new file mode 100644 index 0000000..8512bda --- /dev/null +++ b/lib/features/profile/body_tune_page.dart @@ -0,0 +1,180 @@ +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 '../../shared/widgets/loading_view.dart'; +import 'body_provider.dart'; + +/// 肤色选项(1-5,与后端 SkinTone 对应) +const skinToneColors = { + 1: Color(0xFFF6E3D4), + 2: Color(0xFFEAC9A8), + 3: Color(0xFFD9A87C), + 4: Color(0xFFB97E55), + 5: Color(0xFF8D5B38), +}; + +class BodyTunePage extends ConsumerStatefulWidget { + const BodyTunePage({super.key}); + + @override + ConsumerState createState() => _BodyTunePageState(); +} + +class _BodyTunePageState extends ConsumerState { + int _height = 170; + int _weight = 60; + int _skinTone = 3; + bool _saving = false; + + @override + void initState() { + super.initState(); + final cur = ref.read(bodyProvider).value; + if (cur != null) { + _height = cur.height; + _weight = cur.weight; + _skinTone = cur.skinTone; + } + } + + Future _save() async { + setState(() => _saving = true); + try { + await ref + .read(bodyProvider.notifier) + .save(_height, _weight, _skinTone); + if (!mounted) return; + Fluttertoast.showToast(msg: '身形已保存'); + context.go('/home'); + } catch (e) { + if (!mounted) return; + Fluttertoast.showToast( + msg: '保存失败:${e.toString().replaceFirst('Exception: ', '')}'); + } finally { + if (mounted) setState(() => _saving = false); + } + } + + @override + Widget build(BuildContext context) { + final body = ref.watch(bodyProvider); + return Scaffold( + appBar: AppBar(title: const Text('身形设置')), + body: body.when( + loading: () => const LoadingView(text: '加载身形参数...'), + error: (e, _) => Center(child: Text('加载失败:$e')), + data: (_) => ListView( + padding: const EdgeInsets.all(16), + children: [ + _SliderRow( + label: '身高', + value: '$_height cm', + min: 145, + max: 200, + current: _height.toDouble(), + onChanged: (v) => setState(() => _height = v.round()), + ), + _SliderRow( + label: '体重', + value: '$_weight kg', + min: 40, + max: 120, + current: _weight.toDouble(), + onChanged: (v) => setState(() => _weight = v.round()), + ), + const SizedBox(height: 16), + const Text('肤色', style: TextStyle(fontWeight: FontWeight.bold)), + const SizedBox(height: 8), + Row( + children: [ + for (var i = 1; i <= 5; i++) + Expanded( + child: GestureDetector( + onTap: () => setState(() => _skinTone = i), + child: Container( + height: 48, + margin: const EdgeInsets.symmetric(horizontal: 3), + decoration: BoxDecoration( + color: skinToneColors[i], + borderRadius: BorderRadius.circular(10), + border: Border.all( + width: _skinTone == i ? 3 : 1, + color: _skinTone == i + ? Theme.of(context).colorScheme.primary + : Colors.grey.shade300, + ), + ), + child: _skinTone == i + ? const Icon(Icons.check, color: Colors.white) + : null, + ), + ), + ), + ], + ), + const SizedBox(height: 32), + FilledButton( + onPressed: _saving ? null : _save, + style: FilledButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 14)), + child: _saving + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2)) + : const Text('保存并生成我的 3D 化身'), + ), + ], + ), + ), + ); + } +} + +class _SliderRow extends StatelessWidget { + final String label; + final String value; + final double min; + final double max; + final double current; + final ValueChanged onChanged; + + const _SliderRow({ + required this.label, + required this.value, + required this.min, + required this.max, + required this.current, + required this.onChanged, + }); + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Text(label, + style: const TextStyle(fontWeight: FontWeight.bold)), + const Spacer(), + Text(value, + style: TextStyle( + color: Theme.of(context).colorScheme.primary, + fontWeight: FontWeight.bold)), + ], + ), + Slider( + value: current.clamp(min, max), + min: min, + max: max, + divisions: (max - min).round(), + label: value, + onChanged: onChanged, + ), + ], + ); + } +} diff --git a/lib/features/profile/photo_guide_page.dart b/lib/features/profile/photo_guide_page.dart new file mode 100644 index 0000000..ba6b3b2 --- /dev/null +++ b/lib/features/profile/photo_guide_page.dart @@ -0,0 +1,151 @@ +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 'package:image_picker/image_picker.dart'; + +import '../../shared/widgets/loading_view.dart'; +import 'photo_upload_provider.dart'; + +/// 拍照引导:4 类照片(大头照/正面/侧面/背面)逐一上传 +class PhotoGuidePage extends ConsumerStatefulWidget { + const PhotoGuidePage({super.key}); + + @override + ConsumerState createState() => _PhotoGuidePageState(); +} + +class _PhotoGuidePageState extends ConsumerState { + final _picker = ImagePicker(); + int? _uploadingType; + + Future _pickAndUpload(int type) async { + final source = await showModalBottomSheet( + context: context, + builder: (ctx) => SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ListTile( + leading: const Icon(Icons.photo_camera_outlined), + title: const Text('拍照'), + onTap: () => Navigator.pop(ctx, ImageSource.camera), + ), + ListTile( + leading: const Icon(Icons.photo_library_outlined), + title: const Text('从相册选择'), + onTap: () => Navigator.pop(ctx, ImageSource.gallery), + ), + ], + ), + ), + ); + if (source == null || !mounted) return; + + final file = await _picker.pickImage( + source: source, maxWidth: 2048, imageQuality: 85); + if (file == null) return; + + setState(() => _uploadingType = type); + try { + await ref.read(photoProvider.notifier).upload(type, file.path); + if (!mounted) return; + Fluttertoast.showToast(msg: '${PhotoType.labels[type]}上传成功'); + } catch (e) { + if (!mounted) return; + Fluttertoast.showToast( + msg: '上传失败:${e.toString().replaceFirst('Exception: ', '')}'); + } finally { + if (mounted) setState(() => _uploadingType = null); + } + } + + @override + Widget build(BuildContext context) { + final photos = ref.watch(photoProvider); + return Scaffold( + appBar: AppBar(title: const Text('拍摄形象照片')), + body: photos.when( + loading: () => const LoadingView(text: '加载照片状态...'), + error: (e, _) => Center(child: Text('加载失败:$e')), + data: (list) { + final allDone = PhotoType.all.every( + (t) => list.any((p) => p.type == t)); + return ListView( + padding: const EdgeInsets.all(16), + children: [ + const Text('按提示拍摄 4 张照片,用于构建你的 3D 形象', + style: TextStyle(color: Colors.grey, fontSize: 13)), + const SizedBox(height: 12), + for (final type in PhotoType.all) ...[ + _PhotoCard( + type: type, + uploaded: list.any((p) => p.type == type), + uploading: _uploadingType == type, + onTap: _uploadingType == null + ? () => _pickAndUpload(type) + : null, + ), + const SizedBox(height: 12), + ], + const SizedBox(height: 8), + FilledButton.icon( + onPressed: allDone && _uploadingType == null + ? () => context.go('/body-tune') + : null, + icon: const Icon(Icons.arrow_forward), + label: Text(allDone ? '照片已完成,设置身形' : '还需拍摄剩余照片'), + ), + ], + ); + }, + ), + ); + } +} + +class _PhotoCard extends StatelessWidget { + final int type; + final bool uploaded; + final bool uploading; + final VoidCallback? onTap; + + const _PhotoCard({ + required this.type, + required this.uploaded, + required this.uploading, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + return Card( + clipBehavior: Clip.antiAlias, + child: ListTile( + onTap: onTap, + leading: CircleAvatar( + backgroundColor: + uploaded ? Colors.green.shade100 : scheme.primaryContainer, + child: uploading + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2)) + : Icon(uploaded + ? Icons.check + : Icons.add_a_photo_outlined), + ), + title: Text(PhotoType.labels[type]!), + subtitle: Text(PhotoType.descs[type]!), + trailing: uploaded + ? const Chip( + label: Text('已上传'), + backgroundColor: Colors.green, + labelStyle: TextStyle(color: Colors.white, fontSize: 12), + ) + : null, + ), + ); + } +} diff --git a/lib/features/profile/photo_upload_provider.dart b/lib/features/profile/photo_upload_provider.dart new file mode 100644 index 0000000..de1d8fb --- /dev/null +++ b/lib/features/profile/photo_upload_provider.dart @@ -0,0 +1,77 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../core/auth/auth_provider.dart'; + +/// 照片类型(与后端 consts.PhotoType 对应) +class PhotoType { + static const int headshot = 1; + static const int fullFront = 2; + static const int fullSide = 3; + static const int fullBack = 4; + + static const Map labels = { + headshot: '大头照', + fullFront: '全身正面', + fullSide: '全身侧面', + fullBack: '全身背面', + }; + + static const Map descs = { + headshot: '清晰正脸、光线充足', + fullFront: '站立正面全身,拍全脚底', + fullSide: '站立侧面全身,自然放松', + fullBack: '站立背面全身,露出轮廓', + }; + + static const List all = [headshot, fullFront, fullSide, fullBack]; +} + +class UserPhotoInfo { + final int id; + final int type; + final String url; + + const UserPhotoInfo({ + required this.id, + required this.type, + required this.url, + }); +} + +class PhotoNotifier extends AsyncNotifier> { + @override + Future> build() async { + final api = ref.read(apiClientProvider); + final list = await api.get>('/user-photo/list'); + return list + .map((e) => UserPhotoInfo( + id: (e['id'] as num).toInt(), + type: (e['type'] as num).toInt(), + url: e['url'] as String? ?? '', + )) + .toList(); + } + + Future upload(int type, String filePath) async { + final api = ref.read(apiClientProvider); + await api.upload('/user-photo/upload', {'type': type}, 'file', filePath); + await refresh(); + } + + Future delete(int id) async { + final api = ref.read(apiClientProvider); + await api.post('/user-photo/delete', {'id': id}); + await refresh(); + } + + Future refresh() async { + state = const AsyncLoading(); + state = await AsyncValue.guard(build); + } + + /// 某类型是否已上传 + bool hasType(int type) => state.value?.any((p) => p.type == type) ?? false; +} + +final photoProvider = + AsyncNotifierProvider>(PhotoNotifier.new); diff --git a/lib/features/profile/profile_page.dart b/lib/features/profile/profile_page.dart index 0931567..808166e 100644 --- a/lib/features/profile/profile_page.dart +++ b/lib/features/profile/profile_page.dart @@ -1,10 +1,208 @@ 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 ProfilePage extends StatelessWidget { +import '../../core/auth/auth_provider.dart'; +import 'avatar_provider.dart'; +import 'body_provider.dart'; +import 'photo_upload_provider.dart'; + +/// 我的形象:化身 + 照片 + 身形 + 登出 +class ProfilePage extends ConsumerWidget { const ProfilePage({super.key}); @override + Widget build(BuildContext context, WidgetRef ref) { + final avatar = ref.watch(avatarProvider); + final photos = ref.watch(photoProvider); + final body = ref.watch(bodyProvider); + + return Scaffold( + appBar: AppBar(title: const Text('我的形象')), + body: ListView( + padding: const EdgeInsets.all(16), + children: [ + _buildAvatarCard(context, ref, avatar), + const SizedBox(height: 12), + _buildPhotoCard(context, ref, photos), + const SizedBox(height: 12), + _buildBodyCard(context, ref, body), + const SizedBox(height: 24), + OutlinedButton.icon( + onPressed: () async { + await ref.read(authProvider.notifier).logout(); + if (context.mounted) context.go('/login'); + }, + icon: const Icon(Icons.logout), + label: const Text('退出登录'), + style: OutlinedButton.styleFrom(foregroundColor: Colors.red), + ), + ], + ), + ); + } + + Widget _buildAvatarCard(BuildContext context, WidgetRef ref, + AsyncValue avatar) { + return _SectionCard( + title: '3D 化身', + icon: Icons.face_retouching_natural, + onActionText: null, + child: avatar.when( + loading: () => const Padding( + padding: EdgeInsets.all(16), + child: Center( + child: SizedBox( + width: 24, + height: 24, + child: CircularProgressIndicator(strokeWidth: 2))), + ), + error: (e, _) => _ActionRow( + text: '构建失败:${e.toString().replaceFirst('Exception: ', '')}', + actionText: '重试', + onAction: () => ref.read(avatarProvider.notifier).buildAvatar(), + ), + data: (a) => _ActionRow( + text: a.built + ? '已构建(模板 ${a.faceTemplateId}/${a.bodyTemplateId})' + : '尚未构建化身,需要先完成照片与身形', + actionText: a.built ? '重新构建' : '立即构建', + onAction: () async { + await ref.read(avatarProvider.notifier).buildAvatar(); + if (context.mounted) { + Fluttertoast.showToast(msg: '化身构建完成'); + } + }, + ), + ), + ); + } + + Widget _buildPhotoCard( + BuildContext context, WidgetRef ref, AsyncValue> photos) { + return _SectionCard( + title: '形象照片', + icon: Icons.photo_camera_outlined, + onActionText: '去拍摄', + onAction: () => context.go('/photo-guide'), + child: photos.when( + loading: () => const _InfoText('加载中...'), + error: (e, _) => _InfoText('加载失败:$e'), + data: (list) => Column( + children: [ + for (final type in PhotoType.all) + _InfoText( + '${PhotoType.labels[type]}:' + '${list.any((p) => p.type == type) ? '已上传' : '未上传'}'), + ], + ), + ), + ); + } + + Widget _buildBodyCard( + BuildContext context, WidgetRef ref, AsyncValue body) { + return _SectionCard( + title: '身形参数', + icon: Icons.accessibility_new, + onActionText: '去设置', + onAction: () => context.go('/body-tune'), + child: body.when( + loading: () => const _InfoText('加载中...'), + error: (e, _) => _InfoText('加载失败:$e'), + data: (b) => _InfoText( + '身高 ${b.height}cm · 体重 ${b.weight}kg · 肤色 ${b.skinTone}'), + ), + ); + } +} + +class _SectionCard extends StatelessWidget { + final String title; + final IconData icon; + final Widget child; + final String? onActionText; + final VoidCallback? onAction; + + const _SectionCard({ + required this.title, + required this.icon, + required this.child, + this.onActionText, + this.onAction, + }); + + @override Widget build(BuildContext context) { - return const Center(child: Text('我的形象(开发中)')); + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(icon, + size: 20, color: Theme.of(context).colorScheme.primary), + const SizedBox(width: 8), + Text(title, + style: const TextStyle( + fontSize: 16, fontWeight: FontWeight.bold)), + ], + ), + const SizedBox(height: 12), + child, + if (onActionText != null) ...[ + const SizedBox(height: 12), + Align( + alignment: Alignment.centerRight, + child: TextButton( + onPressed: onAction, + child: Text(onActionText!), + ), + ), + ], + ], + ), + ), + ); + } +} + +class _ActionRow extends StatelessWidget { + final String text; + final String actionText; + final VoidCallback? onAction; + + const _ActionRow({ + required this.text, + required this.actionText, + required this.onAction, + }); + + @override + Widget build(BuildContext context) { + return Row( + children: [ + Expanded(child: Text(text, style: const TextStyle(fontSize: 13))), + if (onAction != null) + TextButton(onPressed: onAction, child: Text(actionText)), + ], + ); + } +} + +class _InfoText extends StatelessWidget { + final String text; + + const _InfoText(this.text); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 2), + child: Text(text, style: const TextStyle(fontSize: 13)), + ); } } diff --git a/lib/features/wardrobe/wardrobe_page.dart b/lib/features/wardrobe/wardrobe_page.dart index b32b09e..1d83900 100644 --- a/lib/features/wardrobe/wardrobe_page.dart +++ b/lib/features/wardrobe/wardrobe_page.dart @@ -1,10 +1,145 @@ import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; -class WardrobePage extends StatelessWidget { +import '../../core/config/app_config.dart'; +import '../../shared/widgets/empty_view.dart'; +import '../../shared/widgets/error_view.dart'; +import '../../shared/widgets/loading_view.dart'; +import 'wardrobe_provider.dart'; + +/// 衣橱:服装网格 + 长按删除 + 上传入口 +class WardrobePage extends ConsumerStatefulWidget { const WardrobePage({super.key}); + @override + ConsumerState createState() => _WardrobePageState(); +} + +class _WardrobePageState extends ConsumerState { + String? _filter; // 当前分类筛选,null = 全部 + + Future _confirmDelete(WardrobeItemInfo item) async { + final ok = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('删除这件服装?'), + content: Text('「${item.category}」删除后不可恢复'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx, false), + child: const Text('取消')), + FilledButton( + onPressed: () => Navigator.pop(ctx, true), + child: const Text('删除')), + ], + ), + ); + if (ok == true) { + await ref.read(wardrobeProvider.notifier).delete(item.id); + } + } + @override Widget build(BuildContext context) { - return const Center(child: Text('我的衣橱(开发中)')); + final items = ref.watch(wardrobeProvider); + return Scaffold( + appBar: AppBar(title: const Text('我的衣橱')), + body: Column( + children: [ + SizedBox( + height: 48, + child: ListView( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric(horizontal: 12), + children: [ + for (final c in [null, ...wardrobeCategories]) + Padding( + padding: const EdgeInsets.only(right: 8), + child: ChoiceChip( + label: Text(c ?? '全部'), + selected: _filter == c, + onSelected: (_) => setState(() => _filter = c), + ), + ), + ], + ), + ), + Expanded( + child: items.when( + loading: () => const LoadingView(text: '加载衣橱...'), + error: (e, _) => ErrorView( + message: e.toString().replaceFirst('Exception: ', ''), + onRetry: () => ref.read(wardrobeProvider.notifier).refresh(), + ), + data: (list) { + final shown = _filter == null + ? list + : list.where((i) => i.category == _filter).toList(); + if (shown.isEmpty) { + return EmptyView( + message: '衣橱还是空的,上传你的服装吧', + actionText: '上传服装', + onAction: () => context.go('/wardrobe-upload')); + } + return GridView.builder( + padding: const EdgeInsets.all(12), + gridDelegate: + const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 2, + mainAxisSpacing: 12, + crossAxisSpacing: 12, + childAspectRatio: 0.8, + ), + itemCount: shown.length, + itemBuilder: (ctx, i) { + final item = shown[i]; + return GestureDetector( + onLongPress: () => _confirmDelete(item), + child: Card( + clipBehavior: Clip.antiAlias, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Expanded( + child: item.photoUrl.isEmpty + ? const Icon(Icons.checkroom, + size: 48, color: Colors.grey) + : Image.network( + AppConfig.resolveUrl(item.photoUrl), + fit: BoxFit.cover, + errorBuilder: (_, _, _) => const Icon( + Icons.broken_image_outlined, + size: 48, + color: Colors.grey), + ), + ), + Padding( + padding: const EdgeInsets.all(8), + child: Text( + '${item.category}${item.season.isNotEmpty ? ' · ${item.season}' : ''}', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontSize: 13, fontWeight: FontWeight.bold), + ), + ), + ], + ), + ), + ); + }, + ); + }, + ), + ), + ], + ), + floatingActionButton: FloatingActionButton.extended( + onPressed: () => context.go('/wardrobe-upload'), + icon: const Icon(Icons.add), + label: const Text('上传'), + ), + ); } } diff --git a/lib/features/wardrobe/wardrobe_provider.dart b/lib/features/wardrobe/wardrobe_provider.dart new file mode 100644 index 0000000..09b4a10 --- /dev/null +++ b/lib/features/wardrobe/wardrobe_provider.dart @@ -0,0 +1,76 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../core/auth/auth_provider.dart'; + +/// 服装分类(与后端校验一致) +const wardrobeCategories = ['上衣', '下装', '鞋', '配饰']; + +const wardrobeSeasons = ['春', '夏', '秋', '冬', '四季']; + +class WardrobeItemInfo { + final int id; + final String photoUrl; + final String category; + final String season; + final String styleTags; + final String colorInfo; + + const WardrobeItemInfo({ + required this.id, + required this.photoUrl, + required this.category, + required this.season, + required this.styleTags, + required this.colorInfo, + }); +} + +class WardrobeNotifier extends AsyncNotifier> { + @override + Future> build() async { + final api = ref.read(apiClientProvider); + final list = await api.get>('/wardrobe/list'); + return list + .map((e) => WardrobeItemInfo( + id: (e['id'] as num).toInt(), + photoUrl: e['photo_url'] as String? ?? '', + category: e['category'] as String? ?? '', + season: e['season'] as String? ?? '', + styleTags: e['style_tags'] as String? ?? '', + colorInfo: e['color_info'] as String? ?? '', + )) + .toList(); + } + + Future upload( + String filePath, { + required String category, + String season = '', + String styleTags = '', + String colorInfo = '', + }) async { + final api = ref.read(apiClientProvider); + await api.upload('/wardrobe/upload', { + 'category': category, + 'season': season, + 'style_tags': styleTags, + 'color_info': colorInfo, + }, 'file', filePath); + await refresh(); + } + + Future delete(int id) async { + final api = ref.read(apiClientProvider); + await api.post('/wardrobe/delete', {'id': id}); + await refresh(); + } + + Future refresh() async { + state = const AsyncLoading(); + state = await AsyncValue.guard(build); + } +} + +final wardrobeProvider = + AsyncNotifierProvider>( + WardrobeNotifier.new); diff --git a/lib/features/wardrobe/wardrobe_upload_page.dart b/lib/features/wardrobe/wardrobe_upload_page.dart new file mode 100644 index 0000000..842479b --- /dev/null +++ b/lib/features/wardrobe/wardrobe_upload_page.dart @@ -0,0 +1,163 @@ +import 'dart:io'; + +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 'package:image_picker/image_picker.dart'; + +import 'wardrobe_provider.dart'; + +/// 服装上传:选图 + 分类/季节/风格/颜色信息 +class WardrobeUploadPage extends ConsumerStatefulWidget { + const WardrobeUploadPage({super.key}); + + @override + ConsumerState createState() => _WardrobeUploadPageState(); +} + +class _WardrobeUploadPageState extends ConsumerState { + final _picker = ImagePicker(); + final _styleCtrl = TextEditingController(); + final _colorCtrl = TextEditingController(); + + String? _imagePath; + String? _category; + String? _season; + bool _uploading = false; + + @override + void dispose() { + _styleCtrl.dispose(); + _colorCtrl.dispose(); + super.dispose(); + } + + Future _pickImage() async { + final file = await _picker.pickImage( + source: ImageSource.gallery, maxWidth: 2048, imageQuality: 85); + if (file == null) return; + setState(() => _imagePath = file.path); + } + + Future _submit() async { + if (_imagePath == null) { + Fluttertoast.showToast(msg: '请先选择服装照片'); + return; + } + if (_category == null) { + Fluttertoast.showToast(msg: '请选择分类'); + return; + } + setState(() => _uploading = true); + try { + await ref.read(wardrobeProvider.notifier).upload( + _imagePath!, + category: _category!, + season: _season ?? '', + styleTags: _styleCtrl.text.trim(), + colorInfo: _colorCtrl.text.trim(), + ); + if (!mounted) return; + Fluttertoast.showToast(msg: '上传成功'); + context.go('/wardrobe'); + } catch (e) { + if (!mounted) return; + Fluttertoast.showToast( + msg: '上传失败:${e.toString().replaceFirst('Exception: ', '')}'); + } finally { + if (mounted) setState(() => _uploading = false); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('上传服装')), + body: ListView( + padding: const EdgeInsets.all(16), + children: [ + GestureDetector( + onTap: _uploading ? null : _pickImage, + child: Container( + height: 220, + decoration: BoxDecoration( + color: Colors.grey.shade100, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: Colors.grey.shade300), + ), + clipBehavior: Clip.antiAlias, + child: _imagePath == null + ? Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.add_photo_alternate_outlined, + size: 48, color: Colors.grey.shade500), + const SizedBox(height: 8), + const Text('点击选择服装照片', + style: TextStyle(color: Colors.grey)), + ], + ) + : Image.file(File(_imagePath!), fit: BoxFit.cover), + ), + ), + const SizedBox(height: 16), + DropdownButtonFormField( + initialValue: _category, + decoration: const InputDecoration( + labelText: '分类', border: OutlineInputBorder()), + items: [ + for (final c in wardrobeCategories) + DropdownMenuItem(value: c, child: Text(c)), + ], + onChanged: _uploading + ? null + : (v) => setState(() => _category = v), + ), + const SizedBox(height: 12), + DropdownButtonFormField( + initialValue: _season, + decoration: const InputDecoration( + labelText: '适用季节(选填)', border: OutlineInputBorder()), + items: [ + for (final s in wardrobeSeasons) + DropdownMenuItem(value: s, child: Text(s)), + ], + onChanged: + _uploading ? null : (v) => setState(() => _season = v), + ), + const SizedBox(height: 12), + TextField( + controller: _styleCtrl, + enabled: !_uploading, + decoration: const InputDecoration( + labelText: '风格标签(选填,如:通勤、休闲)', + border: OutlineInputBorder(), + ), + ), + const SizedBox(height: 12), + TextField( + controller: _colorCtrl, + enabled: !_uploading, + decoration: const InputDecoration( + labelText: '颜色描述(选填,如:黑色)', + border: OutlineInputBorder(), + ), + ), + const SizedBox(height: 24), + FilledButton( + onPressed: _uploading ? null : _submit, + style: FilledButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 14)), + child: _uploading + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2)) + : const Text('上传到衣橱'), + ), + ], + ), + ); + } +} diff --git a/lib/main.dart b/lib/main.dart index 65e7a33..ef439af 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -6,8 +6,11 @@ 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/profile/body_tune_page.dart'; +import 'features/profile/photo_guide_page.dart'; import 'features/profile/profile_page.dart'; import 'features/wardrobe/wardrobe_page.dart'; +import 'features/wardrobe/wardrobe_upload_page.dart'; void main() { runApp(const ProviderScope(child: SloganApp())); @@ -48,10 +51,22 @@ final routerProvider = Provider((ref) { path: '/profile', builder: (ctx, state) => const ProfilePage(), ), + GoRoute( + path: '/photo-guide', + builder: (ctx, state) => const PhotoGuidePage(), + ), + GoRoute( + path: '/body-tune', + builder: (ctx, state) => const BodyTunePage(), + ), GoRoute( path: '/wardrobe', builder: (ctx, state) => const WardrobePage(), ), + GoRoute( + path: '/wardrobe-upload', + builder: (ctx, state) => const WardrobeUploadPage(), + ), GoRoute( path: '/outfit', builder: (ctx, state) => const OutfitPage(), diff --git a/lib/shared/widgets/empty_view.dart b/lib/shared/widgets/empty_view.dart new file mode 100644 index 0000000..47f5aa3 --- /dev/null +++ b/lib/shared/widgets/empty_view.dart @@ -0,0 +1,33 @@ +import 'package:flutter/material.dart'; + +/// 空态 + 引导动作 +class EmptyView extends StatelessWidget { + final String message; + final String? actionText; + final VoidCallback? onAction; + + const EmptyView({ + super.key, + required this.message, + this.actionText, + this.onAction, + }); + + @override + Widget build(BuildContext context) { + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.inbox_outlined, size: 48, color: Colors.grey.shade400), + const SizedBox(height: 12), + Text(message, style: const TextStyle(color: Colors.grey)), + if (actionText != null && onAction != null) ...[ + const SizedBox(height: 16), + FilledButton(onPressed: onAction, child: Text(actionText!)), + ], + ], + ), + ); + } +} diff --git a/lib/shared/widgets/error_view.dart b/lib/shared/widgets/error_view.dart new file mode 100644 index 0000000..cd08195 --- /dev/null +++ b/lib/shared/widgets/error_view.dart @@ -0,0 +1,33 @@ +import 'package:flutter/material.dart'; + +/// 错误态 + 重试 +class ErrorView extends StatelessWidget { + final String message; + final VoidCallback? onRetry; + + const ErrorView({super.key, required this.message, this.onRetry}); + + @override + Widget build(BuildContext context) { + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.error_outline, size: 48, color: Colors.grey.shade400), + const SizedBox(height: 12), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 32), + child: Text(message, + textAlign: TextAlign.center, + style: const TextStyle(color: Colors.grey)), + ), + if (onRetry != null) ...[ + const SizedBox(height: 16), + OutlinedButton.icon( + onPressed: onRetry, icon: const Icon(Icons.refresh), label: const Text('重试')), + ], + ], + ), + ); + } +} diff --git a/lib/shared/widgets/loading_view.dart b/lib/shared/widgets/loading_view.dart new file mode 100644 index 0000000..cde48b0 --- /dev/null +++ b/lib/shared/widgets/loading_view.dart @@ -0,0 +1,24 @@ +import 'package:flutter/material.dart'; + +/// 加载骨架屏 +class LoadingView extends StatelessWidget { + final String? text; + + const LoadingView({super.key, this.text}); + + @override + Widget build(BuildContext context) { + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const CircularProgressIndicator(), + if (text != null) ...[ + const SizedBox(height: 12), + Text(text!, style: const TextStyle(color: Colors.grey)), + ], + ], + ), + ); + } +}