- 报告模块:重写AI解读页,统一白色卡片+三栏布局(指标→解读→医生审核);加删除功能+后端删接口;VLM自动识别报告类型生成中文标题;去五颜六色
- 饮食分析:全面重设计,暖橙主题配色,图片自适应,餐次emoji选择器,去紫色
- 运动确认卡片:修复duration_minutes JSON类型转换,支持中文数字识别(三十分钟/半小时→30);主区域只显示运动名,字段行改为横向排列
- 流式输出:简化为最基础逐字追加,去buffer+Timer+淡入动画
- 欢迎卡片:每智能体独立渐变色(青/橙/蓝/紫/绿/粉),加卡片入场滑入+淡入动画(600ms)
- 确认卡片:头部去紫色背景改浅灰白,字段行去图标改纯信息横排,编辑框去双重边框
- 用药管理:胶囊改TabBar,添加按钮改右下FAB;打卡按钮改对号圆圈形式;药丸图标白底
- 侧边栏:加VIP服务/保险栏+图标,标题字体放大;服务包去查看更多+去VIP金色标签
- 胶囊:首页智能体胶囊白底深色字; side胶囊加阴影
- 对话流:reverse=false,今日健康出现在顶部; 对话页input bar匹配主页面样式
- 其他:个人资料去紫色+去多余入口;设置页白底图标;蓝牙页加返回按钮;报告管理改名
- 后端:加DELETE /api/reports/{id}; 修复manage_exercise数据类型转换;AI提示优化中文数字
137 lines
8.9 KiB
Dart
137 lines
8.9 KiB
Dart
import 'package:flutter/material.dart';
|
|
import '../../core/app_colors.dart';
|
|
import '../../core/app_theme.dart';
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
import '../../core/navigation_provider.dart';
|
|
import '../../providers/data_providers.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) { 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 {
|
|
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) ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('保存失败'), backgroundColor: AppTheme.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(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: Colors.white, borderRadius: BorderRadius.circular(12), 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: const TextStyle(fontSize: 18, color: AppColors.textPrimary, 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: Colors.white, foregroundColor: AppColors.primary, side: const BorderSide(color: AppColors.primary, width: 1.5), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), padding: const EdgeInsets.symmetric(vertical: 14)), child: Text(_loading ? '保存中...' : '保存', style: const TextStyle(fontSize: 19, fontWeight: FontWeight.w600)))),
|
|
const SizedBox(height: 20),
|
|
]),
|
|
);
|
|
}
|
|
|
|
Widget _label(String text) => Text(text, style: TextStyle(fontSize: 17, color: AppColors.textSecondary));
|
|
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: OutlineInputBorder(borderRadius: BorderRadius.circular(AppTheme.rMd), borderSide: BorderSide.none), contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12)), style: const TextStyle(fontSize: 19)),
|
|
]);
|
|
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 ? Colors.white : AppColors.cardInner, borderRadius: BorderRadius.circular(16), border: sel ? Border.all(color: AppColors.primary, width: 1.5) : null), child: Text(label, style: TextStyle(fontSize: 16, color: sel ? AppColors.primary : AppColors.textSecondary, 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: AppTheme.surface, borderRadius: BorderRadius.circular(AppTheme.rMd)), child: Text('${val.month}/${val.day}', style: const TextStyle(fontSize: 18)))),
|
|
]);
|
|
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: AppTheme.surface, borderRadius: BorderRadius.circular(AppTheme.rMd)), child: Row(children: [
|
|
Text(val != null ? '${val.month}/${val.day}' : '不设置', style: TextStyle(fontSize: 18, color: val != null ? null : AppTheme.textHint)),
|
|
if (val != null) const Spacer(),
|
|
if (val != null) GestureDetector(onTap: () => cb(null), child: const Icon(Icons.close, size: 19, color: AppColors.textHint)),
|
|
]))),
|
|
]);
|
|
}
|