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:
@@ -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';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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('上传到衣橱'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
|
||||
@@ -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!)),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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('重试')),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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)),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user