Files
slogan/lib/features/outfit/outfit_page.dart
T

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),
],
),
),
),
);
}
}