- 饮食: 新增 DietCommentaryPolicy + diet_record_logic, 饮食记录逻辑抽取复用 - 通知管理: 分隔线改为仿通知中心样式, 跳过图标区域只留文字下方 - 侧边栏: 对话记录操作栏浮层修复 + 常用功能间距收紧 - 智能体: 欢迎卡片去 400ms 延迟 + 胶囊去阴影 - 后端: AI 会话仓库新增方法 + 饮食评论策略 + 健康记录规则调整 - 其他: api_client IP 适配 + 多页面 UI 微调 + 新增测试
421 lines
12 KiB
Dart
421 lines
12 KiB
Dart
import 'package:flutter/material.dart';
|
||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||
|
||
import '../../core/app_colors.dart';
|
||
import '../../core/app_design_tokens.dart';
|
||
import '../../core/app_theme.dart';
|
||
import '../../core/navigation_provider.dart';
|
||
import '../../providers/data_providers.dart';
|
||
import '../../widgets/app_toast.dart';
|
||
|
||
class MedicationEditPage extends ConsumerStatefulWidget {
|
||
final String? id;
|
||
|
||
const MedicationEditPage({super.key, this.id});
|
||
|
||
@override
|
||
ConsumerState<MedicationEditPage> createState() => _MedicationEditPageState();
|
||
}
|
||
|
||
class _MedicationEditPageState extends ConsumerState<MedicationEditPage> {
|
||
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();
|
||
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(() {});
|
||
}
|
||
|
||
Future<void> _save() async {
|
||
final name = _nameCtrl.text.trim();
|
||
if (name.isEmpty) {
|
||
AppToast.show(context, '请输入药品名称', type: AppToastType.warning);
|
||
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 {
|
||
if (widget.id != null) {
|
||
await srv.update(widget.id!, data);
|
||
} else {
|
||
await srv.create(data);
|
||
}
|
||
ref.invalidate(medicationListProvider);
|
||
ref.invalidate(medicationReminderProvider);
|
||
if (mounted) popRoute(ref);
|
||
} catch (_) {
|
||
if (mounted) {
|
||
AppToast.show(context, '保存失败', type: AppToastType.error);
|
||
}
|
||
}
|
||
if (mounted) setState(() => _loading = false);
|
||
}
|
||
|
||
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) {
|
||
return GradientScaffold(
|
||
appBar: AppBar(
|
||
leading: IconButton(
|
||
icon: const Icon(Icons.arrow_back),
|
||
onPressed: () => popRoute(ref),
|
||
),
|
||
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),
|
||
GestureDetector(
|
||
onTap: () async {
|
||
final n = await showAppCountPicker(
|
||
context,
|
||
initialValue: _timesPerDay,
|
||
min: 1,
|
||
max: 4,
|
||
label: '次',
|
||
);
|
||
if (n != null) _updateTimes(n);
|
||
},
|
||
child: _PickerBox(
|
||
child: Text('$_timesPerDay 次/天', style: AppTextStyles.formValue),
|
||
),
|
||
),
|
||
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 showAppTimePicker(
|
||
context,
|
||
initialTime: _times[i],
|
||
);
|
||
if (t != null) setState(() => _times[i] = t);
|
||
},
|
||
child: Container(
|
||
padding: const EdgeInsets.symmetric(
|
||
horizontal: 14,
|
||
vertical: 10,
|
||
),
|
||
decoration: BoxDecoration(
|
||
color: Colors.white,
|
||
borderRadius: AppRadius.mdBorder,
|
||
border: Border.all(color: AppColors.border, width: 1),
|
||
),
|
||
child: Text(
|
||
'${_times[i].hour.toString().padLeft(2, '0')}:${_times[i].minute.toString().padLeft(2, '0')}',
|
||
style: AppTextStyles.formValue,
|
||
),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
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),
|
||
_GradientOutlineButton(
|
||
onPressed: _loading ? null : _save,
|
||
label: _loading ? '保存中...' : '保存',
|
||
),
|
||
const SizedBox(height: 20),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _label(String text) => Text(text, style: AppTextStyles.formLabel);
|
||
|
||
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: AppTheme.surface,
|
||
border: _inputBorder(AppColors.border),
|
||
enabledBorder: _inputBorder(AppColors.border),
|
||
focusedBorder: _inputBorder(AppColors.primary),
|
||
contentPadding: const EdgeInsets.symmetric(
|
||
horizontal: 12,
|
||
vertical: 12,
|
||
),
|
||
),
|
||
style: AppTextStyles.formValue,
|
||
),
|
||
],
|
||
);
|
||
|
||
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 showAppDatePicker(
|
||
context,
|
||
initialDate: val,
|
||
firstDate: DateTime(2024),
|
||
lastDate: DateTime(2030),
|
||
);
|
||
if (d != null) cb(d);
|
||
},
|
||
child: _PickerBox(
|
||
child: Text(_displayDate(val), style: AppTextStyles.formValue),
|
||
),
|
||
),
|
||
],
|
||
);
|
||
|
||
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 showAppDatePicker(
|
||
context,
|
||
initialDate: val ?? DateTime.now(),
|
||
firstDate: DateTime(2024),
|
||
lastDate: DateTime(2030),
|
||
);
|
||
if (d != null) cb(d);
|
||
},
|
||
child: _PickerBox(
|
||
child: Row(
|
||
children: [
|
||
Expanded(
|
||
child: Text(
|
||
val != null ? _displayDate(val) : '不设置',
|
||
style: AppTextStyles.formValue.copyWith(
|
||
color: val != null ? null : AppTheme.textHint,
|
||
),
|
||
),
|
||
),
|
||
if (val != null)
|
||
GestureDetector(
|
||
onTap: () => cb(null),
|
||
child: const Icon(
|
||
Icons.close,
|
||
size: 19,
|
||
color: AppColors.textHint,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
],
|
||
);
|
||
|
||
OutlineInputBorder _inputBorder(Color color) => OutlineInputBorder(
|
||
borderRadius: AppRadius.mdBorder,
|
||
borderSide: BorderSide(color: color),
|
||
);
|
||
}
|
||
|
||
class _PickerBox extends StatelessWidget {
|
||
final Widget child;
|
||
|
||
const _PickerBox({required this.child});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return Container(
|
||
width: double.infinity,
|
||
padding: const EdgeInsets.all(12),
|
||
decoration: BoxDecoration(
|
||
color: AppTheme.surface,
|
||
borderRadius: AppRadius.mdBorder,
|
||
border: Border.all(color: AppColors.border),
|
||
),
|
||
child: child,
|
||
);
|
||
}
|
||
}
|
||
|
||
class _GradientOutlineButton extends StatelessWidget {
|
||
final VoidCallback? onPressed;
|
||
final String label;
|
||
|
||
const _GradientOutlineButton({required this.onPressed, required this.label});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final enabled = onPressed != null;
|
||
return Container(
|
||
width: double.infinity,
|
||
decoration: BoxDecoration(
|
||
gradient: enabled ? AppColors.actionOutlineGradient : null,
|
||
color: enabled ? null : AppColors.border,
|
||
borderRadius: AppRadius.lgBorder,
|
||
),
|
||
padding: const EdgeInsets.all(1.4),
|
||
child: Material(
|
||
color: Colors.white,
|
||
borderRadius: AppRadius.lgBorder,
|
||
child: InkWell(
|
||
onTap: onPressed,
|
||
borderRadius: AppRadius.lgBorder,
|
||
child: SizedBox(
|
||
height: 46,
|
||
child: Center(
|
||
child: enabled
|
||
? ShaderMask(
|
||
shaderCallback: (bounds) =>
|
||
AppColors.actionOutlineGradient.createShader(bounds),
|
||
child: Text(
|
||
label,
|
||
style: AppTextStyles.button.copyWith(
|
||
color: Colors.white,
|
||
),
|
||
),
|
||
)
|
||
: Text(
|
||
label,
|
||
style: AppTextStyles.button.copyWith(
|
||
color: AppColors.textHint,
|
||
),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
String _displayDate(DateTime date) {
|
||
return '${date.year}/${date.month.toString().padLeft(2, '0')}/${date.day.toString().padLeft(2, '0')}';
|
||
}
|