- UI 系统: 新增 design_tokens / module_visuals / app_buttons / app_status_badge / app_toast / ai_content 六个共享模块; 28 个页面接入, SnackBar 全部替换为 AppToast - 趋势图: 直线折线 + 渐变填充 + 阴影; 去网格线; X 轴日+月份分隔; Y 轴整数刻度最多 4 个; 点击数据点/录入记录显示上方浮层, 选中点变实心 - 法律文档 H5: 后端 wwwroot 托管隐私政策/服务协议/关于/个人信息收集清单/第三方 SDK 清单 + 索引页; Program.cs 启用 UseDefaultFiles + UseStaticFiles - 其他: auth_provider 登录流程调整; api_client IP 适配; 主页智能体栏间距优化
383 lines
12 KiB
Dart
383 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_module_visuals.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> {
|
||
static const _visual = AppModuleVisuals.medication;
|
||
|
||
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: _visual.borderColor, 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),
|
||
SizedBox(
|
||
width: double.infinity,
|
||
child: ElevatedButton(
|
||
onPressed: _loading ? null : _save,
|
||
style: ElevatedButton.styleFrom(
|
||
backgroundColor: Colors.white,
|
||
foregroundColor: _visual.color,
|
||
side: BorderSide(color: _visual.color, width: 1.5),
|
||
shape: RoundedRectangleBorder(borderRadius: AppRadius.lgBorder),
|
||
padding: const EdgeInsets.symmetric(vertical: 14),
|
||
),
|
||
child: Text(_loading ? '保存中...' : '保存', style: AppTextStyles.button),
|
||
),
|
||
),
|
||
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(_visual.color),
|
||
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,
|
||
);
|
||
}
|
||
}
|
||
|
||
String _displayDate(DateTime date) {
|
||
return '${date.year}/${date.month.toString().padLeft(2, '0')}/${date.day.toString().padLeft(2, '0')}';
|
||
}
|