Files
slogan/app/lib/features/outfit/outfit_page.dart
T
2026-08-04 17:04:18 +08:00

318 lines
11 KiB
Dart

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../shared/app_toast.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> {
DateTime? _startDate;
DateTime? _endDate;
final _locationCtrl = TextEditingController();
String _occasion = '通勤';
bool _submitting = false;
static const _occasions = ['通勤', '约会', '聚会', '运动'];
@override
void dispose() {
_locationCtrl.dispose();
super.dispose();
}
Future<void> _pickStartDate() async {
final now = DateTime.now();
final date = await showDatePicker(
context: context,
firstDate: now,
lastDate: now.add(const Duration(days: 30)),
initialDate: _startDate ?? now,
);
if (date == null) return;
setState(() {
_startDate = date;
if (_endDate != null && _endDate!.isBefore(date)) {
_endDate = date;
}
});
}
Future<void> _pickEndDate() async {
final now = DateTime.now();
final first = _startDate ?? now;
final date = await showDatePicker(
context: context,
firstDate: first,
lastDate: first.add(const Duration(days: 30)),
initialDate: _endDate ?? first,
);
if (date != null) setState(() => _endDate = date);
}
String _fmt(DateTime d) =>
'${d.year}-${d.month.toString().padLeft(2, '0')}-${d.day.toString().padLeft(2, '0')}';
Future<void> _generate() async {
final start = _startDate;
final end = _endDate;
final location = _locationCtrl.text.trim();
if (start == null) {
showToast('请选择开始日期');
return;
}
if (end == null) {
showToast('请选择结束日期');
return;
}
if (location.isEmpty) {
showToast('请输入地点');
return;
}
if (end.difference(start).inDays < 1) {
showToast('日期范围至少 1 天');
return;
}
setState(() => _submitting = true);
final ok = await ref.read(generateProvider.notifier).generate(
startDate: _fmt(start),
endDate: _fmt(end),
location: location,
occasion: _occasion,
);
if (!mounted) return;
setState(() => _submitting = false);
if (ok) {
showToast('生成完成');
ref.read(generateProvider.notifier).reset();
context.push('/plan-viewer');
}
}
@override
Widget build(BuildContext context) {
final plans = ref.watch(outfitPlanProvider);
final gen = ref.watch(generateProvider);
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: [
Row(
children: [
Expanded(
child: OutlinedButton.icon(
onPressed: _submitting ? null : _pickStartDate,
icon: const Icon(Icons.date_range_outlined),
label: Text(_startDate == null
? '开始日期'
: _fmt(_startDate!)),
),
),
const SizedBox(width: 8),
Expanded(
child: OutlinedButton.icon(
onPressed: _submitting ? null : _pickEndDate,
icon: const Icon(Icons.date_range_outlined),
label: Text(_endDate == null
? '结束日期'
: _fmt(_endDate!)),
),
),
],
),
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)),
),
],
),
),
SizedBox(
width: double.infinity,
child: FilledButton(
onPressed: _submitting ? null : _generate,
style: FilledButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16),
textStyle: const TextStyle(
fontSize: 16, fontWeight: FontWeight.w600),
),
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.push('/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),
],
),
),
),
);
}
}