280 lines
10 KiB
Dart
280 lines
10 KiB
Dart
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/empty_view.dart';
|
|
import '../../shared/widgets/error_view.dart';
|
|
import '../../shared/widgets/loading_view.dart';
|
|
import 'outfit_provider.dart';
|
|
|
|
/// 穿搭:任务参数(日期范围/地点)→ 生成造型方案;个人信息在「我的形象」管理
|
|
class OutfitPage extends ConsumerStatefulWidget {
|
|
const OutfitPage({super.key});
|
|
|
|
@override
|
|
ConsumerState<OutfitPage> createState() => _OutfitPageState();
|
|
}
|
|
|
|
class _OutfitPageState extends ConsumerState<OutfitPage> {
|
|
DateTimeRange? _dateRange;
|
|
final _locationCtrl = TextEditingController();
|
|
String _occasion = '通勤';
|
|
bool _submitting = false;
|
|
|
|
static const _occasions = ['通勤', '约会', '聚会', '运动'];
|
|
|
|
@override
|
|
void dispose() {
|
|
_locationCtrl.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
Future<void> _pickDateRange() async {
|
|
final now = DateTime.now();
|
|
final range = await showDateRangePicker(
|
|
context: context,
|
|
firstDate: now,
|
|
lastDate: now.add(const Duration(days: 30)),
|
|
initialDateRange: _dateRange ??
|
|
DateTimeRange(start: now, end: now.add(const Duration(days: 2))),
|
|
);
|
|
if (range != null) setState(() => _dateRange = range);
|
|
}
|
|
|
|
String _fmt(DateTime d) =>
|
|
'${d.year}-${d.month.toString().padLeft(2, '0')}-${d.day.toString().padLeft(2, '0')}';
|
|
|
|
Future<void> _generate() async {
|
|
final range = _dateRange;
|
|
final location = _locationCtrl.text.trim();
|
|
if (range == null) {
|
|
Fluttertoast.showToast(msg: '请选择日期范围');
|
|
return;
|
|
}
|
|
if (location.isEmpty) {
|
|
Fluttertoast.showToast(msg: '请输入地点');
|
|
return;
|
|
}
|
|
if (range.duration.inDays < 1) {
|
|
Fluttertoast.showToast(msg: '日期范围至少 1 天');
|
|
return;
|
|
}
|
|
setState(() => _submitting = true);
|
|
final ok = await ref.read(generateProvider.notifier).generate(
|
|
startDate: _fmt(range.start),
|
|
endDate: _fmt(range.end),
|
|
location: location,
|
|
occasion: _occasion,
|
|
);
|
|
if (!mounted) return;
|
|
setState(() => _submitting = false);
|
|
if (ok) {
|
|
Fluttertoast.showToast(msg: '生成完成');
|
|
ref.read(generateProvider.notifier).reset();
|
|
context.go('/plan-viewer');
|
|
}
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final plans = ref.watch(outfitPlanProvider);
|
|
final gen = ref.watch(generateProvider);
|
|
final scheme = Theme.of(context).colorScheme;
|
|
|
|
return Scaffold(
|
|
appBar: AppBar(title: const Text('造型穿搭')),
|
|
body: RefreshIndicator(
|
|
onRefresh: () => ref.read(outfitPlanProvider.notifier).refresh(),
|
|
child: ListView(
|
|
padding: const EdgeInsets.all(16),
|
|
children: [
|
|
Card(
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(16),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text('生成穿搭方案',
|
|
style: TextStyle(
|
|
fontSize: 16,
|
|
fontWeight: FontWeight.bold,
|
|
color: scheme.primary)),
|
|
const SizedBox(height: 12),
|
|
OutlinedButton.icon(
|
|
onPressed: _submitting ? null : _pickDateRange,
|
|
icon: const Icon(Icons.date_range_outlined),
|
|
label: Text(_dateRange == null
|
|
? '选择日期范围'
|
|
: '${_fmt(_dateRange!.start)} ~ ${_fmt(_dateRange!.end)}'),
|
|
),
|
|
const SizedBox(height: 12),
|
|
TextField(
|
|
controller: _locationCtrl,
|
|
enabled: !_submitting,
|
|
decoration: const InputDecoration(
|
|
labelText: '地点(如:上海·外滩)',
|
|
hintText: '影响天气与穿搭建议',
|
|
border: OutlineInputBorder(),
|
|
),
|
|
),
|
|
const SizedBox(height: 16),
|
|
const Text('场景',
|
|
style:
|
|
TextStyle(fontSize: 13, fontWeight: FontWeight.w600)),
|
|
const SizedBox(height: 8),
|
|
Wrap(
|
|
spacing: 8,
|
|
children: [
|
|
for (final o in _occasions)
|
|
ChoiceChip(
|
|
label: Text(o),
|
|
selected: _occasion == o,
|
|
onSelected: _submitting
|
|
? null
|
|
: (_) => setState(() => _occasion = o),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 16),
|
|
if (gen.phase == GeneratePhase.running)
|
|
Padding(
|
|
padding: const EdgeInsets.only(bottom: 12),
|
|
child: Row(
|
|
children: [
|
|
const SizedBox(
|
|
width: 16,
|
|
height: 16,
|
|
child: CircularProgressIndicator(
|
|
strokeWidth: 2)),
|
|
const SizedBox(width: 8),
|
|
Expanded(
|
|
child: Text(gen.statusText,
|
|
style: const TextStyle(fontSize: 13)),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
FilledButton(
|
|
onPressed: _submitting ? null : _generate,
|
|
style: FilledButton.styleFrom(
|
|
padding: const EdgeInsets.symmetric(vertical: 14)),
|
|
child: const Text('生成穿搭方案'),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(height: 16),
|
|
Text('已有方案',
|
|
style: TextStyle(fontSize: 15, fontWeight: FontWeight.bold)),
|
|
const SizedBox(height: 8),
|
|
plans.when(
|
|
loading: () => const Padding(
|
|
padding: EdgeInsets.only(top: 48),
|
|
child: LoadingView(text: '加载方案...')),
|
|
error: (e, _) => Padding(
|
|
padding: const EdgeInsets.only(top: 48),
|
|
child: ErrorView(
|
|
message: e.toString().replaceFirst('Exception: ', ''),
|
|
onRetry: () => ref.read(outfitPlanProvider.notifier).refresh(),
|
|
),
|
|
),
|
|
data: (list) {
|
|
if (list.isEmpty) {
|
|
return const Padding(
|
|
padding: EdgeInsets.only(top: 48),
|
|
child: EmptyView(
|
|
message: '还没有穿搭方案,先设置日期与地点生成吧'),
|
|
);
|
|
}
|
|
return Column(
|
|
children: [
|
|
for (final p in list) ...[
|
|
_PlanCard(plan: p),
|
|
const SizedBox(height: 8),
|
|
],
|
|
],
|
|
);
|
|
},
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _PlanCard extends ConsumerWidget {
|
|
final OutfitPlanInfo plan;
|
|
|
|
const _PlanCard({required this.plan});
|
|
|
|
@override
|
|
Widget build(BuildContext context, WidgetRef ref) {
|
|
final scheme = Theme.of(context).colorScheme;
|
|
return Card(
|
|
child: InkWell(
|
|
borderRadius: BorderRadius.circular(12),
|
|
onTap: () => context.go('/plan-viewer'),
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(12),
|
|
child: Row(
|
|
children: [
|
|
Container(
|
|
width: 44,
|
|
height: 44,
|
|
decoration: BoxDecoration(
|
|
color: scheme.primaryContainer,
|
|
borderRadius: BorderRadius.circular(10),
|
|
),
|
|
child: Icon(Icons.checkroom, color: scheme.primary),
|
|
),
|
|
const SizedBox(width: 12),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
children: [
|
|
Flexible(
|
|
child: Text(plan.title,
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: const TextStyle(
|
|
fontWeight: FontWeight.bold, fontSize: 15)),
|
|
),
|
|
if (plan.isMain) ...[
|
|
const SizedBox(width: 6),
|
|
const Chip(
|
|
label: Text('主方案'),
|
|
labelStyle:
|
|
TextStyle(color: Colors.white, fontSize: 11),
|
|
backgroundColor: Colors.indigo,
|
|
visualDensity: VisualDensity.compact,
|
|
padding: EdgeInsets.zero,
|
|
),
|
|
],
|
|
],
|
|
),
|
|
const SizedBox(height: 4),
|
|
Text(
|
|
'${plan.dateRange} · ${plan.location} · 评分 ${plan.score}'
|
|
'${plan.fromAi ? ' · AI 生成' : ' · 规则生成'}',
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style:
|
|
const TextStyle(color: Colors.grey, fontSize: 12),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
const Icon(Icons.chevron_right, color: Colors.grey),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|