318 lines
11 KiB
Dart
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),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|