新增静态演示界面

This commit is contained in:
2026-07-21 17:41:01 +08:00
parent 030b11fe67
commit b9d7c9f2ee
23 changed files with 4215 additions and 144 deletions
+723
View File
@@ -0,0 +1,723 @@
import 'package:flutter/material.dart';
import 'package:patbond_flutter/core/theme/app_theme.dart';
import 'package:patbond_flutter/data/demo_data.dart';
import 'package:patbond_flutter/models/models.dart';
import 'package:patbond_flutter/state/app_state.dart';
import 'package:patbond_flutter/widgets/common.dart';
class PetsPage extends StatelessWidget {
const PetsPage({required this.appState, super.key});
final AppState appState;
String ageLabel(String value) {
final birthday = DateTime.tryParse(value);
if (birthday == null) return '年龄未知';
final today = DateTime.now();
var age = today.year - birthday.year;
if (today.month < birthday.month ||
(today.month == birthday.month && today.day < birthday.day)) {
age--;
}
return '${age < 0 ? 0 : age}';
}
Future<void> editPet(BuildContext context) async {
final value = await showModalBottomSheet<PetProfile>(
context: context,
isScrollControlled: true,
useSafeArea: true,
builder: (context) => EditPetSheet(pet: appState.pet),
);
if (value != null) await appState.updatePet(value);
}
Future<void> editVaccines(BuildContext context) async {
final value = await showModalBottomSheet<VaccineRecord>(
context: context,
isScrollControlled: true,
useSafeArea: true,
builder: (context) => VaccineSheet(record: appState.vaccines),
);
if (value != null) await appState.updateVaccines(value);
}
@override
Widget build(BuildContext context) {
final pet = appState.pet;
final vaccines = appState.vaccines;
final progress = vaccines.totalDoses == 0
? 0.0
: vaccines.completedDoses / vaccines.totalDoses;
return ListView(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 30),
children: [
Column(
children: [
Stack(
children: [
InkWell(
onTap: () => editPet(context),
borderRadius: BorderRadius.circular(54),
child: RemoteImage(
url: pet.avatarUrl,
width: 104,
height: 104,
borderRadius: BorderRadius.circular(52),
),
),
Positioned(
right: 0,
bottom: 0,
child: CircleAvatar(
radius: 16,
backgroundColor: AppColors.primary,
child: IconButton(
padding: EdgeInsets.zero,
tooltip: '编辑资料',
onPressed: () => editPet(context),
icon: const Icon(
Icons.edit,
color: Colors.white,
size: 15,
),
),
),
),
],
),
const SizedBox(height: 12),
Text(pet.name, style: Theme.of(context).textTheme.headlineSmall),
const SizedBox(height: 4),
Text(
'${pet.breed} · ${pet.gender == PetGender.male ? '男孩' : '女孩'} · ${ageLabel(pet.birthday)} · 活泼',
style: Theme.of(context).textTheme.bodySmall,
),
const SizedBox(height: 10),
OutlinedButton.icon(
onPressed: () => editPet(context),
icon: const Icon(Icons.settings_outlined, size: 17),
label: const Text('编辑资料'),
),
],
),
const SizedBox(height: 24),
Row(
children: [
Text('宠物数据', style: Theme.of(context).textTheme.titleLarge),
const Spacer(),
TextButton.icon(
onPressed: () => editPet(context),
icon: const Icon(Icons.edit_outlined, size: 16),
label: const Text('编辑'),
),
],
),
const SizedBox(height: 8),
Row(
children: [
Expanded(
child: _StatCard(
icon: Icons.monitor_weight_outlined,
color: AppColors.primary,
value: '${pet.weight.toStringAsFixed(1)}kg',
label: '体重',
),
),
const SizedBox(width: 10),
Expanded(
child: _StatCard(
icon: Icons.vaccines_outlined,
color: AppColors.success,
value: '${vaccines.completedDoses}/${vaccines.totalDoses}',
label: '疫苗进度',
onTap: () => editVaccines(context),
),
),
const SizedBox(width: 10),
const Expanded(
child: _StatCard(
icon: Icons.payments_outlined,
color: AppColors.warning,
value: '¥328',
label: '本月花费',
),
),
],
),
const SizedBox(height: 18),
SectionCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text('健康概览', style: Theme.of(context).textTheme.titleMedium),
const Spacer(),
Text('更新于今日', style: Theme.of(context).textTheme.bodySmall),
],
),
const SizedBox(height: 18),
Row(
children: [
SizedBox.square(
dimension: 62,
child: Stack(
fit: StackFit.expand,
children: [
CircularProgressIndicator(
value: progress,
strokeWidth: 7,
backgroundColor: const Color(0xFFE2E8F0),
color: AppColors.success,
),
Center(
child: Text(
'${(progress * 100).round()}%',
style: const TextStyle(
color: AppColors.success,
fontWeight: FontWeight.w800,
),
),
),
],
),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
progress >= 1 ? '疫苗接种完成' : '疫苗接种进行中',
style: const TextStyle(fontWeight: FontWeight.w800),
),
const SizedBox(height: 5),
Text(
'下一针:${vaccines.reminderVaccine}\n预计 ${vaccines.reminderDate}',
style: Theme.of(context).textTheme.bodySmall,
),
],
),
),
IconButton(
tooltip: '管理疫苗',
onPressed: () => editVaccines(context),
icon: const Icon(Icons.edit_outlined),
),
],
),
],
),
),
const SizedBox(height: 16),
Container(
padding: const EdgeInsets.all(18),
decoration: BoxDecoration(
color: const Color(0xFFECFDF5),
borderRadius: BorderRadius.circular(24),
border: Border.all(color: const Color(0xFFA7F3D0)),
),
child: const Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(Icons.auto_awesome, color: AppColors.success),
SizedBox(width: 10),
Expanded(
child: Text(
'健康提醒:已经半年没有进行体内外驱虫,建议本周安排一次。',
style: TextStyle(color: Color(0xFF047857), height: 1.5),
),
),
],
),
),
const SizedBox(height: 22),
Text('成长足迹', style: Theme.of(context).textTheme.titleLarge),
const SizedBox(height: 10),
const _TimelineTile(
icon: Icons.medical_services_outlined,
title: '医疗 · 狂犬疫苗接种',
subtitle: '2025-06-12 · 瑞派宠物医院',
status: '已完成',
),
const _TimelineTile(
icon: Icons.restaurant_outlined,
title: '喂养 · 更换幼犬粮',
subtitle: '2025-05-20 · 体重增长稳定',
status: '已记录',
),
],
);
}
}
class _StatCard extends StatelessWidget {
const _StatCard({
required this.icon,
required this.color,
required this.value,
required this.label,
this.onTap,
});
final IconData icon;
final Color color;
final String value;
final String label;
final VoidCallback? onTap;
@override
Widget build(BuildContext context) {
return Card(
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(24),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 8),
child: Column(
children: [
Icon(icon, color: color),
const SizedBox(height: 6),
Text(value, style: const TextStyle(fontWeight: FontWeight.w800)),
const SizedBox(height: 2),
Text(label, style: Theme.of(context).textTheme.bodySmall),
],
),
),
),
);
}
}
class _TimelineTile extends StatelessWidget {
const _TimelineTile({
required this.icon,
required this.title,
required this.subtitle,
required this.status,
});
final IconData icon;
final String title;
final String subtitle;
final String status;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(bottom: 10),
child: SectionCard(
child: Row(
children: [
CircleAvatar(child: Icon(icon, color: AppColors.primary, size: 20)),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: const TextStyle(fontWeight: FontWeight.w800),
),
const SizedBox(height: 3),
Text(subtitle, style: Theme.of(context).textTheme.bodySmall),
],
),
),
TagPill(status, color: AppColors.success),
],
),
),
);
}
}
class EditPetSheet extends StatefulWidget {
const EditPetSheet({required this.pet, super.key});
final PetProfile pet;
@override
State<EditPetSheet> createState() => _EditPetSheetState();
}
class _EditPetSheetState extends State<EditPetSheet> {
late final TextEditingController nameController;
late final TextEditingController weightController;
late String breed;
late PetGender gender;
late DateTime birthday;
late String avatar;
static const breeds = ['柴犬', '金毛寻回犬', '柯基', '哈士奇', '英国短毛猫', '其他'];
static const avatars = [
petAvatar,
'https://images.unsplash.com/photo-1517849845537-4d257902454a?auto=format&fit=crop&w=600&q=85',
'https://images.unsplash.com/photo-1543466835-00a7907e9de1?auto=format&fit=crop&w=600&q=85',
];
@override
void initState() {
super.initState();
nameController = TextEditingController(text: widget.pet.name);
weightController = TextEditingController(text: '${widget.pet.weight}');
breed = breeds.contains(widget.pet.breed) ? widget.pet.breed : '其他';
gender = widget.pet.gender;
birthday = DateTime.tryParse(widget.pet.birthday) ?? DateTime(2024, 5, 15);
avatar = widget.pet.avatarUrl;
}
@override
void dispose() {
nameController.dispose();
weightController.dispose();
super.dispose();
}
String dateText(DateTime value) {
return '${value.year}-${value.month.toString().padLeft(2, '0')}-${value.day.toString().padLeft(2, '0')}';
}
@override
Widget build(BuildContext context) {
return Padding(
padding: EdgeInsets.fromLTRB(
20,
10,
20,
MediaQuery.viewInsetsOf(context).bottom + 20,
),
child: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const _SheetHandle(),
Row(
children: [
Text('编辑宠物资料', style: Theme.of(context).textTheme.titleLarge),
const Spacer(),
IconButton(
onPressed: () => Navigator.pop(context),
icon: const Icon(Icons.close),
),
],
),
const SizedBox(height: 14),
Center(
child: InkWell(
onTap: () {
final index = avatars.indexOf(avatar);
setState(
() => avatar = avatars[(index + 1) % avatars.length],
);
},
borderRadius: BorderRadius.circular(48),
child: Stack(
children: [
RemoteImage(
url: avatar,
width: 96,
height: 96,
borderRadius: BorderRadius.circular(48),
),
const Positioned(
right: 0,
bottom: 0,
child: CircleAvatar(
radius: 15,
child: Icon(Icons.edit, size: 15),
),
),
],
),
),
),
const SizedBox(height: 18),
TextField(
controller: nameController,
decoration: const InputDecoration(labelText: '宠物昵称'),
),
const SizedBox(height: 12),
DropdownButtonFormField<String>(
initialValue: breed,
decoration: const InputDecoration(labelText: '品种'),
items: breeds
.map(
(value) =>
DropdownMenuItem(value: value, child: Text(value)),
)
.toList(),
onChanged: (value) {
if (value != null) setState(() => breed = value);
},
),
const SizedBox(height: 12),
SegmentedButton<PetGender>(
segments: const [
ButtonSegment(
value: PetGender.male,
icon: Icon(Icons.male),
label: Text('男孩'),
),
ButtonSegment(
value: PetGender.female,
icon: Icon(Icons.female),
label: Text('女孩'),
),
],
selected: {gender},
onSelectionChanged: (value) =>
setState(() => gender = value.first),
),
const SizedBox(height: 12),
ListTile(
shape: RoundedRectangleBorder(
side: const BorderSide(color: AppColors.border),
borderRadius: BorderRadius.circular(18),
),
leading: const Icon(Icons.cake_outlined),
title: const Text('生日'),
subtitle: Text(dateText(birthday)),
trailing: const Icon(Icons.calendar_month_outlined),
onTap: () async {
final value = await showDatePicker(
context: context,
initialDate: birthday,
firstDate: DateTime(2000),
lastDate: DateTime.now(),
);
if (value != null) setState(() => birthday = value);
},
),
const SizedBox(height: 12),
TextField(
controller: weightController,
keyboardType: const TextInputType.numberWithOptions(
decimal: true,
),
decoration: const InputDecoration(labelText: '体重(kg'),
),
const SizedBox(height: 20),
FilledButton.icon(
style: FilledButton.styleFrom(
minimumSize: const Size.fromHeight(50),
),
onPressed: () {
final name = nameController.text.trim();
final weight = double.tryParse(weightController.text);
if (name.isEmpty || weight == null || weight <= 0) {
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('请填写有效的昵称和体重')));
return;
}
Navigator.pop(
context,
widget.pet.copyWith(
name: name,
breed: breed,
gender: gender,
birthday: dateText(birthday),
weight: weight,
avatarUrl: avatar,
),
);
},
icon: const Icon(Icons.save_outlined),
label: const Text('保存修改'),
),
],
),
),
);
}
}
class VaccineSheet extends StatefulWidget {
const VaccineSheet({required this.record, super.key});
final VaccineRecord record;
@override
State<VaccineSheet> createState() => _VaccineSheetState();
}
class _VaccineSheetState extends State<VaccineSheet> {
late List<VaccineItem> items;
late int totalDoses;
late final TextEditingController reminderController;
late final TextEditingController dateController;
int get completed =>
items.where((item) => item.status == VaccineStatus.completed).length;
@override
void initState() {
super.initState();
items = List<VaccineItem>.from(widget.record.items);
totalDoses = widget.record.totalDoses;
reminderController = TextEditingController(
text: widget.record.reminderVaccine,
);
dateController = TextEditingController(text: widget.record.reminderDate);
}
@override
void dispose() {
reminderController.dispose();
dateController.dispose();
super.dispose();
}
void changeTotal(int delta) {
final minimum = completed > items.length ? completed : items.length;
setState(() => totalDoses = (totalDoses + delta).clamp(minimum, 20));
}
@override
Widget build(BuildContext context) {
final progress = totalDoses == 0 ? 0.0 : completed / totalDoses;
return Padding(
padding: EdgeInsets.fromLTRB(
20,
10,
20,
MediaQuery.viewInsetsOf(context).bottom + 20,
),
child: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const _SheetHandle(),
Row(
children: [
const CircleAvatar(child: Icon(Icons.vaccines_outlined)),
const SizedBox(width: 10),
Text('疫苗接种管理', style: Theme.of(context).textTheme.titleLarge),
const Spacer(),
IconButton(
onPressed: () => Navigator.pop(context),
icon: const Icon(Icons.close),
),
],
),
const SizedBox(height: 16),
SectionCard(
child: Column(
children: [
Row(
children: [
const Text('已接种 / 总规划'),
const Spacer(),
IconButton.filledTonal(
onPressed: () => changeTotal(-1),
icon: const Icon(Icons.remove),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 12),
child: Text(
'$completed / $totalDoses',
style: const TextStyle(fontWeight: FontWeight.w800),
),
),
IconButton.filled(
onPressed: () => changeTotal(1),
icon: const Icon(Icons.add),
),
],
),
const SizedBox(height: 12),
LinearProgressIndicator(value: progress, minHeight: 9),
],
),
),
const SizedBox(height: 16),
Text('接种详情', style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 8),
...items.asMap().entries.map((entry) {
final index = entry.key;
final item = entry.value;
final done = item.status == VaccineStatus.completed;
return Card(
margin: const EdgeInsets.only(bottom: 8),
child: CheckboxListTile(
value: done,
title: Text(item.name),
subtitle: Text(
done ? '接种时间:${item.date}' : '计划接种:${item.date}',
),
secondary: Icon(
done ? Icons.check_circle : Icons.radio_button_unchecked,
color: done ? AppColors.success : AppColors.muted,
),
onChanged: (_) {
final today = DateTime.now();
final date =
'${today.year}-${today.month.toString().padLeft(2, '0')}-${today.day.toString().padLeft(2, '0')}';
setState(() {
items[index] = item.copyWith(
status: done
? VaccineStatus.pending
: VaccineStatus.completed,
date: done ? '待定' : date,
);
});
},
),
);
}),
const SizedBox(height: 10),
TextField(
controller: reminderController,
decoration: const InputDecoration(labelText: '下一针提醒'),
),
const SizedBox(height: 12),
TextField(
controller: dateController,
decoration: const InputDecoration(labelText: '预计日期(YYYY-MM-DD'),
),
const SizedBox(height: 20),
FilledButton.icon(
style: FilledButton.styleFrom(
minimumSize: const Size.fromHeight(50),
),
onPressed: () => Navigator.pop(
context,
VaccineRecord(
completedDoses: completed,
totalDoses: totalDoses,
items: items,
reminderVaccine: reminderController.text.trim(),
reminderDate: dateController.text.trim(),
),
),
icon: const Icon(Icons.save_outlined),
label: const Text('保存疫苗记录'),
),
],
),
),
);
}
}
class _SheetHandle extends StatelessWidget {
const _SheetHandle();
@override
Widget build(BuildContext context) {
return Center(
child: Container(
width: 44,
height: 5,
margin: const EdgeInsets.only(bottom: 12),
decoration: BoxDecoration(
color: AppColors.border,
borderRadius: BorderRadius.circular(99),
),
),
);
}
}