724 lines
24 KiB
Dart
724 lines
24 KiB
Dart
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),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|