561 lines
18 KiB
Dart
561 lines
18 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
import 'package:shadcn_ui/shadcn_ui.dart';
|
|
|
|
import '../../core/api_client.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 '../../providers/data_refresh_providers.dart';
|
|
import '../../widgets/app_error_state.dart';
|
|
import '../../widgets/app_toast.dart';
|
|
import '../../widgets/common_widgets.dart';
|
|
import '../care_plan_ui_logic.dart';
|
|
import 'medication_ui_logic.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 _nameController = TextEditingController();
|
|
final _dosageController = TextEditingController();
|
|
final _notesController = TextEditingController();
|
|
List<TimeOfDay> _times = [
|
|
const TimeOfDay(hour: 8, minute: 0),
|
|
const TimeOfDay(hour: 18, minute: 0),
|
|
];
|
|
DateTime _startDate = DateTime.now();
|
|
DateTime? _endDate;
|
|
bool _saving = false;
|
|
bool _loading = false;
|
|
String? _loadError;
|
|
|
|
bool get _editing => widget.id?.isNotEmpty == true;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
if (_editing) _load();
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_nameController.dispose();
|
|
_dosageController.dispose();
|
|
_notesController.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
Future<void> _load() async {
|
|
setState(() {
|
|
_loading = true;
|
|
_loadError = null;
|
|
});
|
|
try {
|
|
final medications = await ref.read(medicationServiceProvider).getList();
|
|
final medication = medications.cast<Map<String, dynamic>?>().firstWhere(
|
|
(item) => item?['id']?.toString() == widget.id,
|
|
orElse: () => null,
|
|
);
|
|
if (medication == null) throw const ApiException('该用药记录不存在');
|
|
_nameController.text = medication['name']?.toString() ?? '';
|
|
_dosageController.text = medication['dosage']?.toString() ?? '';
|
|
_notesController.text = medication['notes']?.toString() ?? '';
|
|
final loadedTimes =
|
|
(medication['timeOfDay'] as List?)
|
|
?.map((value) => _parseTime(value))
|
|
.whereType<TimeOfDay>()
|
|
.toList() ??
|
|
[];
|
|
if (loadedTimes.isNotEmpty) _times = loadedTimes;
|
|
_startDate =
|
|
DateTime.tryParse(medication['startDate']?.toString() ?? '') ??
|
|
DateTime.now();
|
|
_endDate = DateTime.tryParse(medication['endDate']?.toString() ?? '');
|
|
} catch (error) {
|
|
_loadError = error is ApiException ? error.message : '用药信息加载失败';
|
|
} finally {
|
|
if (mounted) setState(() => _loading = false);
|
|
}
|
|
}
|
|
|
|
Future<void> _save() async {
|
|
final timeValues = _times.map(_timeValue).toList();
|
|
final validation = validateMedicationForm(
|
|
name: _nameController.text,
|
|
dosage: _dosageController.text,
|
|
times: timeValues,
|
|
startDate: _startDate,
|
|
endDate: _endDate,
|
|
);
|
|
if (validation != null) {
|
|
AppToast.show(context, validation, type: AppToastType.warning);
|
|
return;
|
|
}
|
|
setState(() => _saving = true);
|
|
final payload = <String, dynamic>{
|
|
'name': _nameController.text.trim(),
|
|
'dosage': _dosageController.text.trim(),
|
|
'frequency': _frequencyForTimes(_times.length),
|
|
'notes': _notesController.text.trim(),
|
|
'timeOfDay': timeValues,
|
|
'startDate': _dateValue(_startDate),
|
|
'endDate': _endDate == null ? null : _dateValue(_endDate!),
|
|
'source': 'Manual',
|
|
};
|
|
try {
|
|
final service = ref.read(medicationServiceProvider);
|
|
if (_editing) {
|
|
await service.update(widget.id!, payload);
|
|
} else {
|
|
await service.create(payload);
|
|
}
|
|
ref.read(medicationDataRefreshSignalProvider.notifier).trigger();
|
|
if (mounted) {
|
|
AppToast.show(
|
|
context,
|
|
_editing ? '用药已更新' : '用药已添加',
|
|
type: AppToastType.success,
|
|
);
|
|
popRoute(ref);
|
|
}
|
|
} catch (error) {
|
|
if (mounted) {
|
|
AppToast.show(
|
|
context,
|
|
error is ApiException ? error.message : '保存失败,请稍后重试',
|
|
type: AppToastType.error,
|
|
);
|
|
}
|
|
} finally {
|
|
if (mounted) setState(() => _saving = false);
|
|
}
|
|
}
|
|
|
|
Future<void> _deleteMedication() async {
|
|
if (!_editing || _saving) return;
|
|
final confirmed = await showDialog<bool>(
|
|
context: context,
|
|
builder: (dialogContext) => AlertDialog(
|
|
title: const Text('删除用药'),
|
|
content: Text('确定删除“${_nameController.text.trim()}”吗?相关服药记录也将一并删除。'),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.pop(dialogContext, false),
|
|
child: const Text('取消'),
|
|
),
|
|
TextButton(
|
|
onPressed: () => Navigator.pop(dialogContext, true),
|
|
style: TextButton.styleFrom(foregroundColor: AppColors.errorText),
|
|
child: const Text('删除'),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
if (confirmed != true || !mounted) return;
|
|
setState(() => _saving = true);
|
|
try {
|
|
await ref.read(medicationServiceProvider).deleteMedication(widget.id!);
|
|
ref.read(medicationDataRefreshSignalProvider.notifier).trigger();
|
|
if (mounted) {
|
|
AppToast.show(context, '用药已删除', type: AppToastType.success);
|
|
popRoute(ref);
|
|
}
|
|
} catch (error) {
|
|
if (mounted) {
|
|
AppToast.show(
|
|
context,
|
|
error is ApiException ? error.message : '删除失败,请稍后重试',
|
|
type: AppToastType.error,
|
|
);
|
|
}
|
|
} finally {
|
|
if (mounted) setState(() => _saving = false);
|
|
}
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return GradientScaffold(
|
|
appBar: AppBar(
|
|
leading: IconButton(
|
|
icon: const Icon(Icons.arrow_back),
|
|
onPressed: () => popRoute(ref),
|
|
),
|
|
title: Text(_editing ? '编辑用药' : '添加用药'),
|
|
),
|
|
bottomNavigationBar: _loading || _loadError != null
|
|
? null
|
|
: SafeArea(
|
|
top: false,
|
|
child: Container(
|
|
color: Colors.white,
|
|
padding: const EdgeInsets.fromLTRB(16, 12, 16, 12),
|
|
child: AppGradientOutlineButton(
|
|
label: _editing ? '保存修改' : '添加用药',
|
|
onPressed: _saving ? null : _save,
|
|
loading: _saving,
|
|
),
|
|
),
|
|
),
|
|
body: _loading
|
|
? const Center(child: CircularProgressIndicator())
|
|
: _loadError != null
|
|
? AppErrorState(title: _loadError!, onRetry: _load)
|
|
: ListView(
|
|
keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag,
|
|
padding: const EdgeInsets.fromLTRB(16, 12, 16, 28),
|
|
children: [
|
|
_FormSection(
|
|
title: '药品信息',
|
|
children: [
|
|
_TextFieldRow(
|
|
label: '药品名称',
|
|
controller: _nameController,
|
|
hint: '请输入药品名称',
|
|
),
|
|
const _SectionDivider(),
|
|
_TextFieldRow(
|
|
label: '剂量',
|
|
controller: _dosageController,
|
|
hint: '例如 100 mg',
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 16),
|
|
_FormSection(
|
|
title: '服用安排',
|
|
children: [
|
|
_PickerRow(
|
|
label: '每日次数',
|
|
value: '${_times.length}次',
|
|
onTap: _pickCount,
|
|
),
|
|
const _SectionDivider(),
|
|
Padding(
|
|
padding: const EdgeInsets.fromLTRB(16, 11, 12, 11),
|
|
child: Row(
|
|
children: [
|
|
const Text('服药时间', style: AppTextStyles.formLabel),
|
|
const SizedBox(width: 12),
|
|
Expanded(
|
|
child: Wrap(
|
|
alignment: WrapAlignment.end,
|
|
spacing: 8,
|
|
runSpacing: 8,
|
|
children: List.generate(
|
|
_times.length,
|
|
(index) => _TimeButton(
|
|
value: _timeValue(_times[index]),
|
|
onTap: () => _pickTime(index),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 16),
|
|
_FormSection(
|
|
title: '用药周期',
|
|
children: [
|
|
_PickerRow(
|
|
label: '开始日期',
|
|
value: _displayDate(_startDate),
|
|
onTap: _pickStartDate,
|
|
),
|
|
const _SectionDivider(),
|
|
_PickerRow(
|
|
label: '结束日期',
|
|
value: _endDate == null ? '长期' : _displayDate(_endDate!),
|
|
onTap: _pickEndDate,
|
|
onClear: _endDate == null
|
|
? null
|
|
: () => setState(() => _endDate = null),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 16),
|
|
_FormSection(
|
|
title: '备注',
|
|
children: [
|
|
Padding(
|
|
padding: const EdgeInsets.all(16),
|
|
child: TextField(
|
|
controller: _notesController,
|
|
minLines: 3,
|
|
maxLines: 5,
|
|
maxLength: 200,
|
|
decoration: const InputDecoration(
|
|
hintText: '可填写饭前、饭后或其他用药要求(选填)',
|
|
border: InputBorder.none,
|
|
enabledBorder: InputBorder.none,
|
|
focusedBorder: InputBorder.none,
|
|
filled: false,
|
|
contentPadding: EdgeInsets.zero,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
if (_editing) ...[
|
|
const SizedBox(height: 16),
|
|
Material(
|
|
color: Colors.white,
|
|
borderRadius: AppRadius.lgBorder,
|
|
clipBehavior: Clip.antiAlias,
|
|
child: InkWell(
|
|
onTap: _saving ? null : _deleteMedication,
|
|
child: const SizedBox(
|
|
height: 54,
|
|
child: Center(
|
|
child: Text(
|
|
'删除用药',
|
|
style: TextStyle(
|
|
color: AppColors.errorText,
|
|
fontSize: 15,
|
|
fontWeight: FontWeight.w700,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Future<void> _pickCount() async {
|
|
final count = await showAppCountPicker(
|
|
context,
|
|
initialValue: _times.length,
|
|
min: 1,
|
|
max: 4,
|
|
label: '次',
|
|
);
|
|
if (count == null || !mounted) return;
|
|
setState(() {
|
|
while (_times.length < count) {
|
|
final hour = (8 + _times.length * 5).clamp(0, 23);
|
|
_times.add(TimeOfDay(hour: hour, minute: 0));
|
|
}
|
|
while (_times.length > count) {
|
|
_times.removeLast();
|
|
}
|
|
});
|
|
}
|
|
|
|
Future<void> _pickTime(int index) async {
|
|
final value = await showAppTimePicker(context, initialTime: _times[index]);
|
|
if (value != null && mounted) setState(() => _times[index] = value);
|
|
}
|
|
|
|
Future<void> _pickStartDate() async {
|
|
final value = await showAppDatePicker(
|
|
context,
|
|
initialDate: _startDate,
|
|
firstDate: DateTime(2020),
|
|
lastDate: DateTime(2035),
|
|
);
|
|
if (value != null && mounted) setState(() => _startDate = value);
|
|
}
|
|
|
|
Future<void> _pickEndDate() async {
|
|
final value = await showAppDatePicker(
|
|
context,
|
|
initialDate: _endDate ?? _startDate,
|
|
firstDate: DateTime(2020),
|
|
lastDate: DateTime(2035),
|
|
);
|
|
if (value != null && mounted) setState(() => _endDate = value);
|
|
}
|
|
}
|
|
|
|
class _FormSection extends StatelessWidget {
|
|
final String title;
|
|
final List<Widget> children;
|
|
|
|
const _FormSection({required this.title, required this.children});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Padding(
|
|
padding: const EdgeInsets.only(left: 2, bottom: 8),
|
|
child: Text(
|
|
title,
|
|
style: AppTextStyles.sectionTitle.copyWith(fontSize: 16),
|
|
),
|
|
),
|
|
Container(
|
|
clipBehavior: Clip.antiAlias,
|
|
decoration: BoxDecoration(
|
|
color: Colors.white,
|
|
borderRadius: AppRadius.lgBorder,
|
|
),
|
|
child: Column(children: children),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
class _TextFieldRow extends StatelessWidget {
|
|
final String label;
|
|
final TextEditingController controller;
|
|
final String hint;
|
|
|
|
const _TextFieldRow({
|
|
required this.label,
|
|
required this.controller,
|
|
required this.hint,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
|
|
child: Row(
|
|
children: [
|
|
SizedBox(
|
|
width: 82,
|
|
child: Text(label, style: AppTextStyles.formLabel),
|
|
),
|
|
Expanded(
|
|
child: TextField(
|
|
controller: controller,
|
|
textAlign: TextAlign.right,
|
|
decoration: InputDecoration(
|
|
hintText: hint,
|
|
border: InputBorder.none,
|
|
enabledBorder: InputBorder.none,
|
|
focusedBorder: InputBorder.none,
|
|
filled: false,
|
|
contentPadding: const EdgeInsets.symmetric(vertical: 14),
|
|
),
|
|
style: AppTextStyles.formValue,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _PickerRow extends StatelessWidget {
|
|
final String label;
|
|
final String value;
|
|
final VoidCallback onTap;
|
|
final VoidCallback? onClear;
|
|
|
|
const _PickerRow({
|
|
required this.label,
|
|
required this.value,
|
|
required this.onTap,
|
|
this.onClear,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return InkWell(
|
|
onTap: onTap,
|
|
child: Padding(
|
|
padding: const EdgeInsets.fromLTRB(16, 15, 10, 15),
|
|
child: Row(
|
|
children: [
|
|
Expanded(child: Text(label, style: AppTextStyles.formLabel)),
|
|
Text(value, style: AppTextStyles.formValue),
|
|
if (onClear != null)
|
|
IconButton(
|
|
tooltip: '清除结束日期',
|
|
visualDensity: VisualDensity.compact,
|
|
onPressed: onClear,
|
|
icon: const Icon(Icons.close, size: 18),
|
|
)
|
|
else
|
|
const Padding(
|
|
padding: EdgeInsets.only(left: 6),
|
|
child: Icon(
|
|
LucideIcons.chevronRight,
|
|
color: AppColors.textHint,
|
|
size: 20,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _TimeButton extends StatelessWidget {
|
|
final String value;
|
|
final VoidCallback onTap;
|
|
|
|
const _TimeButton({required this.value, required this.onTap});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return OutlinedButton.icon(
|
|
onPressed: onTap,
|
|
icon: const Icon(LucideIcons.clock, size: 18),
|
|
label: Text(value),
|
|
style: OutlinedButton.styleFrom(
|
|
foregroundColor: AppColors.primary,
|
|
side: BorderSide(color: AppColors.primary.withValues(alpha: 0.38)),
|
|
shape: RoundedRectangleBorder(borderRadius: AppRadius.smBorder),
|
|
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 11),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _SectionDivider extends StatelessWidget {
|
|
const _SectionDivider();
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return const Padding(
|
|
padding: EdgeInsets.only(left: 16),
|
|
child: Divider(height: 1, thickness: 0.7, color: AppColors.divider),
|
|
);
|
|
}
|
|
}
|
|
|
|
TimeOfDay? _parseTime(Object value) {
|
|
final parts = value.toString().split(':');
|
|
if (parts.length < 2) return null;
|
|
final hour = int.tryParse(parts[0]);
|
|
final minute = int.tryParse(parts[1]);
|
|
if (hour == null || minute == null) return null;
|
|
return TimeOfDay(hour: hour, minute: minute);
|
|
}
|
|
|
|
String _timeValue(TimeOfDay value) =>
|
|
'${value.hour.toString().padLeft(2, '0')}:${value.minute.toString().padLeft(2, '0')}';
|
|
|
|
String _dateValue(DateTime value) =>
|
|
'${value.year}-${value.month.toString().padLeft(2, '0')}-${value.day.toString().padLeft(2, '0')}';
|
|
|
|
String _displayDate(DateTime value) => formatMedicationFormDate(value);
|
|
|
|
String _frequencyForTimes(int count) => switch (count) {
|
|
2 => 'TwiceDaily',
|
|
3 => 'ThreeTimesDaily',
|
|
_ => 'Daily',
|
|
};
|