fix: 用药模块重做 + 运动计划不限7天 + 打卡可切换 + 删除修复

- 用药: 重写列表页(标签筛选+详情弹窗+打卡切换+滑动删除)
- 用药: 重写编辑页(选择器替代输入+同列布局+频率改为每天几次)
- 用药: 加Notes字段+DELETE端点+修复重复端点
- 运动: 不限7天, startDate+索引推算当天
- 打卡: 药/运动打卡可切换(点✓取消)
- 删除: 修复滑动删除热区, 一次点击即删除
- 侧边栏: 一键清理历史对话
- 饮食/运动/用药/问诊 API路径加/api/前缀
- 通知时机修正: 新建计划不再提前弹窗
This commit is contained in:
MingNian
2026-06-04 21:15:23 +08:00
parent b944a31983
commit cd65bc4d65
11 changed files with 1069 additions and 1242 deletions

View File

@@ -3,526 +3,133 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/navigation_provider.dart';
import '../../providers/data_providers.dart';
class _MedicationItem {
String name = '';
String dosage = '';
String frequency = '每日1次';
List<TimeOfDay> times = [const TimeOfDay(hour: 8, minute: 0)];
DateTime startDate = DateTime.now();
DateTime? endDate;
int weekday = 1;
}
const _frequencies = ['每日1次', '每日2次', '每日3次', '每周1次', '按需服用'];
const _weekdays = ['周一', '周二', '周三', '周四', '周五', '周六', '周日'];
class MedicationEditPage extends ConsumerStatefulWidget {
final String? medicationId;
const MedicationEditPage({super.key, this.medicationId});
@override
ConsumerState<MedicationEditPage> createState() => _MedicationEditPageState();
final String? id;
const MedicationEditPage({super.key, this.id});
@override ConsumerState<MedicationEditPage> createState() => _MedicationEditPageState();
}
class _MedicationEditPageState extends ConsumerState<MedicationEditPage> {
final _items = <_MedicationItem>[];
final _nameCtrls = <TextEditingController>[];
final _doseCtrls = <TextEditingController>[];
final _nameCtrl = TextEditingController();
final _dosageCtrl = TextEditingController();
final _notesCtrl = TextEditingController();
int _timesPerDay = 2;
List<TimeOfDay> _times = [const TimeOfDay(hour: 8, minute: 0), const TimeOfDay(hour: 18, minute: 0)];
DateTime _start = DateTime.now();
DateTime? _end;
bool _loading = false;
@override
void initState() {
super.initState();
_addItem();
@override void initState() { super.initState(); if (widget.id != null) _load(); }
@override void dispose() { _nameCtrl.dispose(); _dosageCtrl.dispose(); _notesCtrl.dispose(); super.dispose(); }
Future<void> _load() async {
final srv = ref.read(medicationServiceProvider);
final list = await srv.getList();
final m = list.cast<Map<String, dynamic>?>().firstWhere((x) => x?['id']?.toString() == widget.id, orElse: () => null);
if (m == null || !mounted) return;
_nameCtrl.text = m['name']?.toString() ?? '';
_dosageCtrl.text = m['dosage']?.toString() ?? '';
_notesCtrl.text = m['notes']?.toString() ?? '';
final tod = (m['timeOfDay'] as List?)?.map((t) {
final parts = t.toString().split(':');
return TimeOfDay(hour: int.tryParse(parts[0]) ?? 8, minute: int.tryParse(parts.length > 1 ? parts[1] : '0') ?? 0);
}).toList() ?? [const TimeOfDay(hour: 8, minute: 0), const TimeOfDay(hour: 18, minute: 0)];
_timesPerDay = tod.length;
_times = tod;
if (m['startDate'] != null) _start = DateTime.tryParse(m['startDate'].toString()) ?? DateTime.now();
if (m['endDate'] != null) _end = DateTime.tryParse(m['endDate'].toString());
setState(() {});
}
@override
void dispose() {
for (final c in _nameCtrls) {
c.dispose();
}
for (final c in _doseCtrls) {
c.dispose();
}
super.dispose();
}
void _addItem() {
setState(() {
_items.add(_MedicationItem());
_nameCtrls.add(TextEditingController());
_doseCtrls.add(TextEditingController());
});
}
void _removeItem(int index) {
setState(() {
_nameCtrls[index].dispose();
_doseCtrls[index].dispose();
_nameCtrls.removeAt(index);
_doseCtrls.removeAt(index);
_items.removeAt(index);
});
}
void _onSave() async {
for (int i = 0; i < _items.length; i++) {
_items[i].name = _nameCtrls[i].text.trim();
_items[i].dosage = _doseCtrls[i].text.trim();
}
final allValid = _items.every(
(item) => item.name.isNotEmpty && item.dosage.isNotEmpty,
);
if (!allValid) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('请填写所有药品的名称和剂量')),
);
return;
}
final service = ref.read(medicationServiceProvider);
Future<void> _save() async {
final name = _nameCtrl.text.trim();
if (name.isEmpty) { ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('请输入药品名称'))); return; }
setState(() => _loading = true);
final data = {
'name': name, 'dosage': _dosageCtrl.text.trim(), 'frequency': 'Daily',
'notes': _notesCtrl.text.trim(),
'timeOfDay': _times.map((t) => '${t.hour.toString().padLeft(2,'0')}:${t.minute.toString().padLeft(2,'0')}').toList(),
'startDate': '${_start.year}-${_start.month.toString().padLeft(2,'0')}-${_start.day.toString().padLeft(2,'0')}',
if (_end != null) 'endDate': '${_end!.year}-${_end!.month.toString().padLeft(2,'0')}-${_end!.day.toString().padLeft(2,'0')}',
'source': 'Manual',
};
final srv = ref.read(medicationServiceProvider);
try {
for (final item in _items) {
final timesStr = item.frequency == '按需服用'
? []
: item.times.map((t) => t.format(context)).toList();
await service.create({
'name': item.name,
'dosage': item.dosage,
'frequency': 'Daily',
'timeOfDay': timesStr,
'startDate': item.startDate.toIso8601String().split('T')[0],
if (item.endDate != null)
'endDate': item.endDate!.toIso8601String().split('T')[0],
'source': 'Manual',
});
}
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('已添加 ${_items.length} 种药品'),
backgroundColor: const Color(0xFF8B9CF7),
),
);
ref.invalidate(medicationListProvider);
ref.invalidate(medicationReminderProvider);
popRoute(ref);
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('保存失败:$e'),
backgroundColor: Colors.red,
),
);
// 仍然返回上一页,避免卡在黑屏
popRoute(ref);
}
if (widget.id != null) { await srv.update(widget.id!, data); } else { await srv.create(data); }
ref.invalidate(medicationListProvider); ref.invalidate(medicationReminderProvider);
if (mounted) { ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('已保存'), backgroundColor: Color(0xFF43A047))); popRoute(ref); }
} catch (_) { if (mounted) ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('保存失败'), backgroundColor: Color(0xFFE53935))); }
if (mounted) setState(() => _loading = false);
}
int _timeCount(String frequency) {
switch (frequency) {
case '每日1次':
return 1;
case '每日2次':
return 2;
case '每日3次':
return 3;
case '每周1次':
return 1;
default:
return 0;
}
void _updateTimes(int n) {
setState(() {
_timesPerDay = n;
while (_times.length < n) _times.add(const TimeOfDay(hour: 12, minute: 0));
while (_times.length > n) _times.removeLast();
});
}
@override
Widget build(BuildContext context) {
@override Widget build(BuildContext context) {
return Scaffold(
backgroundColor: const Color(0xFFF8F9FC),
appBar: AppBar(
backgroundColor: Colors.white,
elevation: 0,
leading: IconButton(
icon: const Icon(Icons.chevron_left),
onPressed: () => popRoute(ref),
),
title: const Text(
'添加用药',
style: TextStyle(
color: Color(0xFF1A1A1A),
fontWeight: FontWeight.w600,
),
),
centerTitle: true,
actions: [
TextButton(
onPressed: _onSave,
child: const Text(
'保存',
style: TextStyle(
color: Color(0xFF8B9CF7),
fontWeight: FontWeight.w600,
),
),
),
],
),
body: SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
...List.generate(_items.length, (i) => _buildCard(i)),
const SizedBox(height: 12),
_buildAddButton(),
const SizedBox(height: 40),
],
),
),
appBar: AppBar(title: Text(widget.id != null ? '编辑用药' : '添加用药')),
body: ListView(padding: const EdgeInsets.all(16), children: [
// 名称+剂量 一行
Row(children: [
Expanded(flex: 3, child: _field('药品名称', _nameCtrl, hint: '如: 阿司匹林')),
const SizedBox(width: 12),
Expanded(flex: 2, child: _field('剂量', _dosageCtrl, hint: '如: 100mg')),
]),
const SizedBox(height: 16),
// 频率选择
_label('每日服药次数'), const SizedBox(height: 8),
Wrap(spacing: 8, children: List.generate(4, (i) => _chip('${i + 1}', _timesPerDay == i + 1, () => _updateTimes(i + 1)))),
const SizedBox(height: 16),
// 时间选择
_label('服药时间'), const SizedBox(height: 8),
Wrap(spacing: 8, runSpacing: 8, children: List.generate(_timesPerDay, (i) => GestureDetector(
onTap: () async {
final t = await showTimePicker(context: context, initialTime: _times[i]);
if (t != null) setState(() => _times[i] = t);
},
child: Container(padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
decoration: BoxDecoration(color: const Color(0xFFEDEAFF), borderRadius: BorderRadius.circular(12)),
child: Text('${_times[i].hour.toString().padLeft(2,'0')}:${_times[i].minute.toString().padLeft(2,'0')}', style: const TextStyle(fontSize: 15, color: Color(0xFF6C5CE7), fontWeight: FontWeight.w600))),
))),
const SizedBox(height: 16),
// 开始+结束 一行
Row(children: [
Expanded(child: _dateField('开始日期', _start, (d) => setState(() => _start = d))),
const SizedBox(width: 12),
Expanded(child: _dateFieldOpt('结束日期(可选)', _end, (d) => setState(() => _end = d))),
]),
const SizedBox(height: 16),
_field('备注', _notesCtrl, hint: '如: 饭后服用、睡前'),
const SizedBox(height: 32),
SizedBox(width: double.infinity, child: ElevatedButton(onPressed: _loading ? null : _save, style: ElevatedButton.styleFrom(backgroundColor: const Color(0xFF6C5CE7), foregroundColor: Colors.white, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), padding: const EdgeInsets.symmetric(vertical: 14)), child: Text(_loading ? '保存中...' : '保存', style: const TextStyle(fontSize: 16)))),
const SizedBox(height: 20),
]),
);
}
Widget _buildCard(int index) {
final item = _items[index];
final count = _timeCount(item.frequency);
return Container(
margin: const EdgeInsets.only(bottom: 12),
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: const Color(0xFFEEEEEE)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Header
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'药品 ${index + 1}',
style: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: Color(0xFF8B9CF7),
),
),
if (_items.length > 1)
GestureDetector(
onTap: () => _removeItem(index),
child: const Icon(Icons.close, size: 18, color: Color(0xFFBDBDBD)),
),
],
),
const SizedBox(height: 8),
Divider(height: 1, color: const Color(0xFFF0F0F0)),
const SizedBox(height: 8),
// Name
_buildLabel('药品名称'),
const SizedBox(height: 4),
TextField(
controller: _nameCtrls[index],
style: const TextStyle(fontSize: 14),
decoration: _inputDecoration('请输入药品名称'),
),
const SizedBox(height: 8),
// Dosage
_buildLabel('剂量'),
const SizedBox(height: 4),
TextField(
controller: _doseCtrls[index],
style: const TextStyle(fontSize: 14),
decoration: _inputDecoration('100mg'),
),
const SizedBox(height: 8),
// Frequency
_buildLabel('服用频率'),
const SizedBox(height: 4),
GestureDetector(
onTap: () => _pickFrequency(index),
child: Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8),
border: Border.all(color: const Color(0xFFE0E0E0)),
color: const Color(0xFFFAFAFA),
),
child: Row(
children: [
Text(item.frequency, style: const TextStyle(fontSize: 14, color: Color(0xFF1A1A1A))),
const Spacer(),
const Icon(Icons.keyboard_arrow_down, size: 20, color: Color(0xFF9E9E9E)),
],
),
),
),
const SizedBox(height: 8),
// Times (dynamic)
if (count > 0) ...[
_buildLabel('服药时间'),
const SizedBox(height: 4),
Wrap(
spacing: 8,
runSpacing: 6,
children: List.generate(count, (t) => _buildTimePicker(index, t)),
),
if (item.frequency == '每周1次') ...[
const SizedBox(height: 8),
_buildLabel('选择星期'),
const SizedBox(height: 4),
GestureDetector(
onTap: () => _pickWeekday(index),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8),
border: Border.all(color: const Color(0xFFE0E0E0)),
color: const Color(0xFFFAFAFA),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(_weekdays[item.weekday - 1], style: const TextStyle(fontSize: 14, color: Color(0xFF1A1A1A))),
const SizedBox(width: 4),
const Icon(Icons.keyboard_arrow_down, size: 18, color: Color(0xFF9E9E9E)),
],
),
),
),
],
const SizedBox(height: 8),
],
// Start date
_buildLabel('开始日期'),
const SizedBox(height: 4),
GestureDetector(
onTap: () => _pickDate(index, isStart: true),
child: Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8),
border: Border.all(color: const Color(0xFFE0E0E0)),
color: const Color(0xFFFAFAFA),
),
child: Row(
children: [
Text(
'${item.startDate.year}-${item.startDate.month.toString().padLeft(2, '0')}-${item.startDate.day.toString().padLeft(2, '0')}',
style: const TextStyle(fontSize: 14, color: Color(0xFF1A1A1A)),
),
const Spacer(),
const Icon(Icons.calendar_today, size: 18, color: Color(0xFF9E9E9E)),
],
),
),
),
const SizedBox(height: 8),
// End date (optional)
_buildLabel('结束日期(可选)'),
const SizedBox(height: 4),
GestureDetector(
onTap: () => _pickDate(index, isStart: false),
child: Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8),
border: Border.all(color: const Color(0xFFE0E0E0)),
color: const Color(0xFFFAFAFA),
),
child: Row(
children: [
Text(
item.endDate != null
? '${item.endDate!.year}-${item.endDate!.month.toString().padLeft(2, '0')}-${item.endDate!.day.toString().padLeft(2, '0')}'
: '不设置',
style: TextStyle(
fontSize: 14,
color: item.endDate != null ? const Color(0xFF1A1A1A) : const Color(0xFFBDBDBD),
),
),
const Spacer(),
GestureDetector(
onTap: item.endDate != null ? () => setState(() => item.endDate = null) : null,
child: Icon(
item.endDate != null ? Icons.close : Icons.calendar_today,
size: 18,
color: const Color(0xFF9E9E9E),
),
),
],
),
),
),
],
),
);
}
Widget _buildLabel(String text) {
return Text(
text,
style: const TextStyle(fontSize: 12, color: Color(0xFF757575)),
);
}
Widget _buildTimePicker(int itemIndex, int timeIndex) {
final item = _items[itemIndex];
final time = item.times[timeIndex];
return GestureDetector(
onTap: () => _pickTime(itemIndex, timeIndex),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8),
border: Border.all(color: const Color(0xFFE0E0E0)),
color: const Color(0xFFFAFAFA),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.access_time, size: 16, color: Color(0xFF8B9CF7)),
const SizedBox(width: 6),
Text(
time.format(context),
style: const TextStyle(fontSize: 14, color: Color(0xFF1A1A1A)),
),
],
),
),
);
}
Widget _buildAddButton() {
return SizedBox(
width: double.infinity,
child: OutlinedButton.icon(
onPressed: _addItem,
icon: const Icon(Icons.add, size: 18),
label: const Text('添加', style: TextStyle(fontSize: 14)),
style: OutlinedButton.styleFrom(
foregroundColor: const Color(0xFF8B9CF7),
side: const BorderSide(color: Color(0xFFD0D5FC)),
padding: const EdgeInsets.symmetric(vertical: 12),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
backgroundColor: const Color(0xFFF0F2FF),
),
),
);
}
InputDecoration _inputDecoration(String hint) {
return InputDecoration(
hintText: hint,
hintStyle: const TextStyle(color: Color(0xFFBDBDBD), fontSize: 14),
filled: true,
fillColor: const Color(0xFFFAFAFA),
contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
isDense: true,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: const BorderSide(color: Color(0xFFE0E0E0)),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: const BorderSide(color: Color(0xFFE0E0E0)),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: const BorderSide(color: Color(0xFF8B9CF7)),
),
);
}
void _pickFrequency(int index) async {
final selected = await showModalBottomSheet<String>(
context: context,
builder: (ctx) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: _frequencies
.map((f) => ListTile(
title: Text(f),
onTap: () => Navigator.pop(ctx, f),
))
.toList(),
),
),
);
if (selected != null && mounted) {
setState(() {
final item = _items[index];
item.frequency = selected;
final newCount = _timeCount(selected);
if (newCount > 0 && item.times.length != newCount) {
item.times = List.generate(
newCount,
(i) => TimeOfDay(hour: 8 + i * 4, minute: 0),
);
}
});
}
}
void _pickWeekday(int index) async {
final item = _items[index];
final selected = await showModalBottomSheet<int>(
context: context,
builder: (ctx) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: List.generate(7, (i) {
return ListTile(
title: Text(_weekdays[i]),
selected: item.weekday == i + 1,
onTap: () => Navigator.pop(ctx, i + 1),
);
}),
),
),
);
if (selected != null && mounted) {
setState(() => _items[index].weekday = selected);
}
}
void _pickTime(int itemIndex, int timeIndex) async {
final item = _items[itemIndex];
final time = await showTimePicker(
context: context,
initialTime: item.times[timeIndex],
);
if (time != null && mounted) {
setState(() => item.times[timeIndex] = time);
}
}
void _pickDate(int index, {required bool isStart}) async {
final item = _items[index];
final initial = isStart ? item.startDate : (item.endDate ?? DateTime.now());
final date = await showDatePicker(
context: context,
firstDate: DateTime(2020),
lastDate: DateTime(2030),
initialDate: initial,
);
if (date != null && mounted) {
setState(() {
if (isStart) {
item.startDate = date;
} else {
item.endDate = date;
}
});
}
}
Widget _label(String text) => Text(text, style: const TextStyle(fontSize: 14, color: Color(0xFF666666)));
Widget _field(String label, TextEditingController ctrl, {String? hint}) => Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
_label(label), const SizedBox(height: 6),
TextField(controller: ctrl, decoration: InputDecoration(hintText: hint, filled: true, fillColor: Colors.white, border: OutlineInputBorder(borderRadius: BorderRadius.circular(12), borderSide: BorderSide.none), contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12)), style: const TextStyle(fontSize: 16)),
]);
Widget _chip(String label, bool sel, VoidCallback onTap) => GestureDetector(onTap: onTap, child: Container(padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), decoration: BoxDecoration(color: sel ? const Color(0xFF6C5CE7) : const Color(0xFFEDEAFF), borderRadius: BorderRadius.circular(16)), child: Text(label, style: TextStyle(fontSize: 13, color: sel ? Colors.white : const Color(0xFF6C5CE7), fontWeight: FontWeight.w500))));
Widget _dateField(String label, DateTime val, ValueChanged<DateTime> cb) => Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
_label(label), const SizedBox(height: 6),
GestureDetector(onTap: () async { final d = await showDatePicker(context: context, initialDate: val, firstDate: DateTime(2024), lastDate: DateTime(2030)); if (d != null) cb(d); },
child: Container(width: double.infinity, padding: const EdgeInsets.all(12), decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(12)), child: Text('${val.month}/${val.day}', style: const TextStyle(fontSize: 15)))),
]);
Widget _dateFieldOpt(String label, DateTime? val, ValueChanged<DateTime?> cb) => Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
_label(label), const SizedBox(height: 6),
GestureDetector(onTap: () async { final d = await showDatePicker(context: context, initialDate: val ?? DateTime.now(), firstDate: DateTime(2024), lastDate: DateTime(2030)); if (d != null) cb(d); },
child: Container(width: double.infinity, padding: const EdgeInsets.all(12), decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(12)), child: Row(children: [
Text(val != null ? '${val.month}/${val.day}' : '不设置', style: TextStyle(fontSize: 15, color: val != null ? null : Colors.grey[400])),
if (val != null) const Spacer(),
if (val != null) GestureDetector(onTap: () => cb(null), child: const Icon(Icons.close, size: 16, color: Color(0xFFBBBBBB))),
]))),
]);
}