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_module_visuals.dart'; import '../../core/app_theme.dart'; import '../../core/navigation_provider.dart'; import '../../providers/auth_provider.dart'; import '../../providers/data_providers.dart'; import '../../widgets/app_empty_state.dart'; import '../../widgets/app_error_state.dart'; import '../../widgets/app_toast.dart'; class MedicationCheckInPage extends ConsumerStatefulWidget { final String? medId; const MedicationCheckInPage({super.key, this.medId}); @override ConsumerState createState() => _MedicationCheckInPageState(); } class _MedicationCheckInPageState extends ConsumerState { Future>>? _future; final Set _busyDoses = {}; @override void initState() { super.initState(); _load(); } void _load() => setState(() { _future = ref.read(medicationReminderProvider.future); }); Future _refresh() async { ref.invalidate(medicationReminderProvider); _load(); await _future; } Future _toggleDose(String medId, String time, bool taken) async { final doseKey = '$medId-$time'; if (medId.isEmpty || time.isEmpty || _busyDoses.contains(doseKey)) return; setState(() => _busyDoses.add(doseKey)); try { final api = ref.read(apiClientProvider); if (taken) { await api.delete('/api/medications/$medId/confirm-dose/$time'); } else { final response = await api.post( '/api/medications/$medId/confirm-dose', data: {'scheduledTime': time, 'status': 'taken'}, ); final data = response.data is Map ? response.data['data'] : null; if (data is Map && data['success'] == false) { throw const ApiException('该次服药已经打卡'); } } ref.invalidate(medicationReminderProvider); ref.invalidate(medicationListProvider); _load(); } catch (error) { if (mounted) { AppToast.show( context, error is ApiException ? error.message : '打卡失败,请稍后重试', type: AppToastType.error, ); } } finally { if (mounted) setState(() => _busyDoses.remove(doseKey)); } } @override Widget build(BuildContext context) { return GradientScaffold( appBar: AppBar( leading: IconButton( icon: const Icon(Icons.arrow_back), onPressed: () => popRoute(ref), ), title: const Text('服药打卡'), ), body: FutureBuilder>>( future: _future, builder: (context, snapshot) { if (snapshot.connectionState == ConnectionState.waiting && !snapshot.hasData) { return const Center(child: CircularProgressIndicator()); } if (snapshot.hasError) { return AppErrorState(title: '用药信息加载失败', onRetry: _load); } final reminders = _filterReminders(snapshot.data ?? const []); if (reminders.isEmpty) { return RefreshIndicator( onRefresh: _refresh, child: ListView( physics: const AlwaysScrollableScrollPhysics(), children: [ const SizedBox(height: 92), AppEmptyState( icon: AppModuleVisuals.medication.icon, title: '今天没有服药安排', subtitle: '今日可以安心休息,下拉可刷新最新计划', iconColor: AppColors.medication, ), ], ), ); } final sorted = [...reminders] ..sort( (a, b) => _formatTime( a['scheduledTime']?.toString() ?? '', ).compareTo(_formatTime(b['scheduledTime']?.toString() ?? '')), ); final taken = sorted.where(_isTaken).length; return RefreshIndicator( onRefresh: _refresh, child: ListView( physics: const AlwaysScrollableScrollPhysics(), padding: AppSpacing.page, children: [ _DoseProgressHeader(total: sorted.length, taken: taken), const SizedBox(height: 18), Container( clipBehavior: Clip.antiAlias, decoration: BoxDecoration( color: Colors.white, borderRadius: AppRadius.lgBorder, ), child: Column( children: List.generate(sorted.length, (index) { final dose = sorted[index]; final key = '${dose['id']?.toString() ?? ''}-${dose['scheduledTime']?.toString() ?? ''}'; return _DoseTimelineRow( dose: dose, isFirst: index == 0, isLast: index == sorted.length - 1, isBusy: _busyDoses.contains(key), onToggle: _toggleDose, ); }), ), ), const SizedBox(height: 18), const Row( mainAxisAlignment: MainAxisAlignment.center, children: [ Icon( LucideIcons.shield, size: 16, color: AppColors.textHint, ), SizedBox(width: 6), Text( '请按医嘱服药,如有不适及时咨询医生', style: TextStyle(fontSize: 12, color: AppColors.textHint), ), ], ), ], ), ); }, ), ); } List> _filterReminders( List> reminders, ) { if (widget.medId == null || widget.medId!.isEmpty) return reminders; return reminders .where((item) => item['id']?.toString() == widget.medId) .toList(); } } Map>> groupMedicationReminders( List> reminders, ) { final grouped = >>{}; for (var index = 0; index < reminders.length; index++) { final reminder = reminders[index]; final medicationId = reminder['id']?.toString().trim(); final key = medicationId?.isNotEmpty == true ? medicationId! : 'unknown-$index'; grouped.putIfAbsent(key, () => >[]).add(reminder); } for (final doses in grouped.values) { doses.sort( (a, b) => _formatTime( a['scheduledTime']?.toString() ?? '', ).compareTo(_formatTime(b['scheduledTime']?.toString() ?? '')), ); } return grouped; } class _DoseProgressHeader extends StatelessWidget { final int total; final int taken; const _DoseProgressHeader({required this.total, required this.taken}); @override Widget build(BuildContext context) { final now = DateTime.now(); final progress = total == 0 ? 0.0 : taken / total; return Container( padding: const EdgeInsets.fromLTRB(16, 16, 16, 15), decoration: BoxDecoration( color: Colors.white, borderRadius: AppRadius.lgBorder, ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( '${now.month}月${now.day}日 · 今日用药', style: AppTextStyles.sectionTitle, ), const SizedBox(height: 3), Text( '$taken / $total 已完成', style: AppTextStyles.listSubtitle, ), ], ), ), Text( '${(progress * 100).round()}%', style: AppTextStyles.summaryTitle.copyWith( color: AppColors.medication, ), ), ], ), const SizedBox(height: 12), ClipRRect( borderRadius: AppRadius.pillBorder, child: LinearProgressIndicator( minHeight: 5, value: progress, backgroundColor: AppColors.cardInner, valueColor: const AlwaysStoppedAnimation(AppColors.medication), ), ), ], ), ); } } class _DoseTimelineRow extends StatelessWidget { final Map dose; final bool isFirst; final bool isLast; final bool isBusy; final Future Function(String, String, bool) onToggle; const _DoseTimelineRow({ required this.dose, required this.isFirst, required this.isLast, required this.isBusy, required this.onToggle, }); @override Widget build(BuildContext context) { final medId = dose['id']?.toString() ?? ''; final rawTime = dose['scheduledTime']?.toString() ?? ''; final time = _formatTime(rawTime); final name = dose['name']?.toString().trim().isNotEmpty == true ? dose['name'].toString().trim() : '未命名药品'; final dosage = dose['dosage']?.toString().trim() ?? ''; final status = dose['status']?.toString().toLowerCase() ?? 'scheduled'; final taken = status == 'taken'; final scheduled = status == 'scheduled'; final color = _statusColor(status); return ConstrainedBox( constraints: const BoxConstraints(minHeight: 92), child: Stack( children: [ Padding( padding: const EdgeInsets.symmetric(horizontal: 14), child: Row( children: [ SizedBox( width: 54, child: Text( time, style: AppTextStyles.listTitle.copyWith(fontSize: 18), ), ), SizedBox( width: 28, height: 92, child: Stack( alignment: Alignment.center, children: [ if (!isFirst) Positioned( top: 0, bottom: 46, child: Container(width: 2, color: AppColors.divider), ), if (!isLast) Positioned( top: 46, bottom: 0, child: Container(width: 2, color: AppColors.divider), ), Container( width: 20, height: 20, decoration: BoxDecoration( color: taken ? color : Colors.white, shape: BoxShape.circle, border: Border.all(color: color, width: 2), ), child: taken ? const Icon( Icons.check, size: 13, color: Colors.white, ) : null, ), ], ), ), const SizedBox(width: 10), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisAlignment: MainAxisAlignment.center, children: [ Text( name, maxLines: 1, overflow: TextOverflow.ellipsis, style: AppTextStyles.listTitle.copyWith(fontSize: 16), ), const SizedBox(height: 3), Text( [ dosage, _statusLabel(status), ].where((text) => text.isNotEmpty).join(' · '), style: AppTextStyles.listSubtitle.copyWith( fontSize: 13, color: color, ), ), ], ), ), const SizedBox(width: 8), SizedBox( height: 36, child: OutlinedButton( onPressed: isBusy || scheduled ? null : () => onToggle(medId, rawTime, taken), style: OutlinedButton.styleFrom( padding: const EdgeInsets.symmetric(horizontal: 13), backgroundColor: taken ? AppColors.successLight : Colors.white, foregroundColor: taken ? AppColors.successText : AppColors.medication, disabledForegroundColor: AppColors.textHint, side: BorderSide( color: scheduled ? AppColors.borderLight : (taken ? AppColors.successText : AppColors.medication), ), shape: RoundedRectangleBorder( borderRadius: AppRadius.smBorder, ), ), child: isBusy ? const SizedBox( width: 14, height: 14, child: CircularProgressIndicator(strokeWidth: 2), ) : Text( scheduled ? '未到时间' : (taken ? '撤销' : '打卡'), style: AppTextStyles.miniButton, ), ), ), ], ), ), if (!isLast) const Positioned( left: 96, right: 0, bottom: 0, child: Divider( height: 1, thickness: 0.7, color: AppColors.divider, ), ), ], ), ); } } bool _isTaken(Map dose) => dose['status']?.toString().toLowerCase() == 'taken'; String _formatTime(String raw) { final value = raw.trim(); return value.length >= 5 ? value.substring(0, 5) : value; } String _statusLabel(String status) => switch (status) { 'taken' => '已完成', 'overdue' => '已超时', 'upcoming' => '待完成', 'skipped' => '已跳过', _ => '未到时间', }; Color _statusColor(String status) => switch (status) { 'taken' => AppColors.successText, 'overdue' => AppColors.errorText, 'upcoming' => AppColors.blueMeasure, 'skipped' => AppColors.textHint, _ => AppColors.textHint, };