feat(app): 形象照片上传 + 身形设置 + 化身构建 + 衣橱管理

- photo_upload_provider/photo_guide_page:4 类照片(大头照/正面/侧面/背面)拍照或相册上传
- body_provider/body_tune_page:身高 145-200cm / 体重 / 肤色 1-5 滑杆设置
- avatar_provider:化身构建(同步 + 异步轮询兜底)
- profile_page:形象主页聚合化身/照片/身形状态 + 登出
- wardrobe_provider/wardrobe_page/wardrobe_upload_page:网格展示 + 分类筛选 + 长按删除 + 表单上传
- AppConfig.resolveUrl 相对路径拼接;新增 /photo-guide /body-tune /wardrobe-upload 路由
This commit is contained in:
2026-07-31 12:44:10 +08:00
parent acbe60c13c
commit ceef7bc64e
14 changed files with 1214 additions and 4 deletions
+6
View File
@@ -2,4 +2,10 @@
class AppConfig {
/// 后端地址:iOS 模拟器用 127.0.0.1Android 模拟器用 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';
}
}
+81
View File
@@ -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<AvatarState> {
@override
Future<AvatarState> build() async {
final api = ref.read(apiClientProvider);
final data = await api.get<Map<String, dynamic>>('/avatar/get');
return _fromData(data);
}
AvatarState _fromData(Map<String, dynamic> 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<void> refresh() async {
state = const AsyncLoading();
state = await AsyncValue.guard(build);
}
/// 触发构建;若服务端异步则轮询直到 done/failed
Future<void> buildAvatar() async {
state = const AsyncLoading();
try {
final api = ref.read(apiClientProvider);
final data =
await api.post<Map<String, dynamic>>('/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<void> _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<Map<String, dynamic>>('/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, AvatarState>(AvatarNotifier.new);
+38
View File
@@ -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<BodyState> {
@override
Future<BodyState> build() async {
final api = ref.read(apiClientProvider);
final data = await api.get<Map<String, dynamic>>('/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<void> 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, BodyState>(BodyNotifier.new);
+180
View File
@@ -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 = <int, Color>{
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<BodyTunePage> createState() => _BodyTunePageState();
}
class _BodyTunePageState extends ConsumerState<BodyTunePage> {
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<void> _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<double> 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,
),
],
);
}
}
+151
View File
@@ -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<PhotoGuidePage> createState() => _PhotoGuidePageState();
}
class _PhotoGuidePageState extends ConsumerState<PhotoGuidePage> {
final _picker = ImagePicker();
int? _uploadingType;
Future<void> _pickAndUpload(int type) async {
final source = await showModalBottomSheet<ImageSource>(
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,
),
);
}
}
@@ -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<int, String> labels = {
headshot: '大头照',
fullFront: '全身正面',
fullSide: '全身侧面',
fullBack: '全身背面',
};
static const Map<int, String> descs = {
headshot: '清晰正脸、光线充足',
fullFront: '站立正面全身,拍全脚底',
fullSide: '站立侧面全身,自然放松',
fullBack: '站立背面全身,露出轮廓',
};
static const List<int> 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<List<UserPhotoInfo>> {
@override
Future<List<UserPhotoInfo>> build() async {
final api = ref.read(apiClientProvider);
final list = await api.get<List<dynamic>>('/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<void> upload(int type, String filePath) async {
final api = ref.read(apiClientProvider);
await api.upload('/user-photo/upload', {'type': type}, 'file', filePath);
await refresh();
}
Future<void> delete(int id) async {
final api = ref.read(apiClientProvider);
await api.post('/user-photo/delete', {'id': id});
await refresh();
}
Future<void> 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, List<UserPhotoInfo>>(PhotoNotifier.new);
+200 -2
View File
@@ -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<AvatarState> 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<List<UserPhotoInfo>> 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<BodyState> 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)),
);
}
}
+137 -2
View File
@@ -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<WardrobePage> createState() => _WardrobePageState();
}
class _WardrobePageState extends ConsumerState<WardrobePage> {
String? _filter; // 当前分类筛选,null = 全部
Future<void> _confirmDelete(WardrobeItemInfo item) async {
final ok = await showDialog<bool>(
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('上传'),
),
);
}
}
@@ -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<List<WardrobeItemInfo>> {
@override
Future<List<WardrobeItemInfo>> build() async {
final api = ref.read(apiClientProvider);
final list = await api.get<List<dynamic>>('/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<void> 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<void> delete(int id) async {
final api = ref.read(apiClientProvider);
await api.post('/wardrobe/delete', {'id': id});
await refresh();
}
Future<void> refresh() async {
state = const AsyncLoading();
state = await AsyncValue.guard(build);
}
}
final wardrobeProvider =
AsyncNotifierProvider<WardrobeNotifier, List<WardrobeItemInfo>>(
WardrobeNotifier.new);
@@ -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<WardrobeUploadPage> createState() => _WardrobeUploadPageState();
}
class _WardrobeUploadPageState extends ConsumerState<WardrobeUploadPage> {
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<void> _pickImage() async {
final file = await _picker.pickImage(
source: ImageSource.gallery, maxWidth: 2048, imageQuality: 85);
if (file == null) return;
setState(() => _imagePath = file.path);
}
Future<void> _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<String>(
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<String>(
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('上传到衣橱'),
),
],
),
);
}
}
+15
View File
@@ -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<GoRouter>((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(),
+33
View File
@@ -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!)),
],
],
),
);
}
}
+33
View File
@@ -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('重试')),
],
],
),
);
}
}
+24
View File
@@ -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)),
],
],
),
);
}
}