import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../core/app_colors.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 { static const _visual = AppModuleVisuals.medication; static const _softGreen = Color(0xFFEAF8EF); 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 (_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 { await api.post( '/api/medications/$medId/confirm-dose', data: {'scheduledTime': time, 'status': 'taken'}, ); } ref.invalidate(medicationReminderProvider); ref.invalidate(medicationListProvider); _load(); } catch (e) { debugPrint('[MedCheckIn] 打卡失败: $e'); if (mounted) { AppToast.show(context, '打卡失败,请稍后重试', 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('服药打卡'), centerTitle: true, ), body: FutureBuilder>>( future: _future, builder: (ctx, snap) { if (snap.connectionState == ConnectionState.waiting && !snap.hasData) { return const Center( child: CircularProgressIndicator(color: AppTheme.primary), ); } if (snap.hasError) { return AppErrorState(title: '用药信息加载失败', onRetry: _load); } final reminders = _filterReminders(snap.data ?? []); if (reminders.isEmpty) { return RefreshIndicator( onRefresh: _refresh, child: ListView( physics: const AlwaysScrollableScrollPhysics(), children: const [ SizedBox(height: 92), AppEmptyState( icon: Icons.task_alt_outlined, title: '今天的用药已完成', subtitle: '下拉可刷新最新用药提醒', ), ], ), ); } final grouped = _groupByMedication(reminders); final total = reminders.length; final taken = reminders.where(_isTaken).length; final pending = total - taken; return RefreshIndicator( onRefresh: _refresh, child: ListView( physics: const AlwaysScrollableScrollPhysics(), padding: const EdgeInsets.fromLTRB(16, 12, 16, 28), children: [ _CheckInSummary(total: total, taken: taken, pending: pending), const SizedBox(height: 14), ...grouped.entries.map( (entry) => _MedicationDoseCard( name: entry.key, doses: entry.value, busyDoses: _busyDoses, onToggle: _toggleDose, ), ), ], ), ); }, ), ); } List> _filterReminders( List> reminders, ) { if (widget.medId == null) return reminders; return reminders.where((r) => r['id']?.toString() == widget.medId).toList(); } Map>> _groupByMedication( List> reminders, ) { final grouped = >>{}; for (final reminder in reminders) { final name = reminder['name']?.toString().trim(); grouped .putIfAbsent(name?.isNotEmpty == true ? name! : '未命名药品', () { return >[]; }) .add(reminder); } for (final doses in grouped.values) { doses.sort( (a, b) => _formatTime( a['scheduledTime']?.toString() ?? '', ).compareTo(_formatTime(b['scheduledTime']?.toString() ?? '')), ); } return grouped; } } class _CheckInSummary extends StatelessWidget { final int total; final int taken; final int pending; const _CheckInSummary({ required this.total, required this.taken, required this.pending, }); @override Widget build(BuildContext context) { final progress = total == 0 ? 0.0 : taken / total; return Container( padding: const EdgeInsets.all(16), decoration: BoxDecoration( color: Colors.white, borderRadius: BorderRadius.circular(16), border: Border.all(color: AppColors.border, width: 1.1), boxShadow: [AppTheme.shadowLight], ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Container( width: 42, height: 42, decoration: BoxDecoration( gradient: _MedicationCheckInPageState._visual.gradient, borderRadius: BorderRadius.circular(14), ), child: const Icon( Icons.medication_outlined, color: Colors.white, size: 24, ), ), const SizedBox(width: 12), const Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( '今日服药进度', style: TextStyle( fontSize: 18, fontWeight: FontWeight.w800, color: AppColors.textPrimary, ), ), SizedBox(height: 2), Text( '按提醒时间逐项确认,保持用药节奏', style: TextStyle( fontSize: 13, color: AppColors.textSecondary, ), ), ], ), ), Text( '${(progress * 100).round()}%', style: TextStyle( fontSize: 22, fontWeight: FontWeight.w900, color: _MedicationCheckInPageState._visual.color, ), ), ], ), const SizedBox(height: 16), ClipRRect( borderRadius: BorderRadius.circular(999), child: LinearProgressIndicator( minHeight: 9, value: progress, backgroundColor: AppColors.cardInner, valueColor: AlwaysStoppedAnimation( _MedicationCheckInPageState._visual.color, ), ), ), const SizedBox(height: 14), Row( children: [ Expanded( child: _SummaryStat( label: '待完成', value: '$pending', icon: Icons.schedule_outlined, color: AppColors.warning, ), ), const SizedBox(width: 8), Expanded( child: _SummaryStat( label: '已打卡', value: '$taken', icon: Icons.check_circle_outline, color: AppTheme.success, ), ), const SizedBox(width: 8), Expanded( child: _SummaryStat( label: '总次数', value: '$total', icon: Icons.format_list_numbered_outlined, color: _MedicationCheckInPageState._visual.color, ), ), ], ), ], ), ); } } class _SummaryStat extends StatelessWidget { final String label; final String value; final IconData icon; final Color color; const _SummaryStat({ required this.label, required this.value, required this.icon, required this.color, }); @override Widget build(BuildContext context) { return Container( constraints: const BoxConstraints(minHeight: 58), padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 9), decoration: BoxDecoration( color: color.withValues(alpha: 0.08), borderRadius: BorderRadius.circular(10), border: Border.all(color: color.withValues(alpha: 0.14)), ), child: Row( children: [ Icon(icon, color: color, size: 17), const SizedBox(width: 7), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( value, maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle( fontSize: 16, fontWeight: FontWeight.w800, color: AppColors.textPrimary, ), ), Text( label, maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle( fontSize: 11, fontWeight: FontWeight.w700, color: AppColors.textSecondary, ), ), ], ), ), ], ), ); } } class _MedicationDoseCard extends StatelessWidget { final String name; final List> doses; final Set busyDoses; final Future Function(String medId, String time, bool taken) onToggle; const _MedicationDoseCard({ required this.name, required this.doses, required this.busyDoses, required this.onToggle, }); @override Widget build(BuildContext context) { final dosage = doses.first['dosage']?.toString().trim() ?? ''; final takenCount = doses.where(_isTaken).length; final allTaken = takenCount == doses.length; return Container( margin: const EdgeInsets.only(bottom: 12), padding: const EdgeInsets.all(16), decoration: BoxDecoration( color: Colors.white, borderRadius: BorderRadius.circular(16), border: Border.all( color: allTaken ? AppTheme.success.withValues(alpha: 0.26) : AppColors.border, width: 1.1, ), boxShadow: [AppTheme.shadowLight], ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Container( width: 44, height: 44, decoration: BoxDecoration( color: allTaken ? _MedicationCheckInPageState._softGreen : _MedicationCheckInPageState._visual.lightColor, borderRadius: BorderRadius.circular(14), border: Border.all(color: AppColors.borderLight), ), child: Icon( allTaken ? Icons.done_all_rounded : Icons.medication_liquid, color: allTaken ? AppTheme.success : _MedicationCheckInPageState._visual.color, size: 24, ), ), const SizedBox(width: 12), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( name, maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle( fontSize: 18, fontWeight: FontWeight.w800, color: AppColors.textPrimary, ), ), if (dosage.isNotEmpty) ...[ const SizedBox(height: 2), Text( dosage, maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle( fontSize: 14, color: AppColors.textSecondary, ), ), ], ], ), ), Container( padding: const EdgeInsets.symmetric(horizontal: 9, vertical: 5), decoration: BoxDecoration( color: allTaken ? _MedicationCheckInPageState._softGreen : AppColors.cardInner, borderRadius: BorderRadius.circular(999), ), child: Text( '$takenCount/${doses.length}', style: TextStyle( fontSize: 13, fontWeight: FontWeight.w800, color: allTaken ? AppTheme.success : AppColors.textSecondary, ), ), ), ], ), const SizedBox(height: 14), for (var i = 0; i < doses.length; i++) ...[ _DoseRow( dose: doses[i], isLast: i == doses.length - 1, isBusy: busyDoses.contains( '${doses[i]['id']?.toString() ?? ''}-${doses[i]['scheduledTime']?.toString() ?? ''}', ), onToggle: onToggle, ), ], ], ), ); } } class _DoseRow extends StatelessWidget { final Map dose; final bool isLast; final bool isBusy; final Future Function(String medId, String time, bool taken) onToggle; const _DoseRow({ required this.dose, required this.isLast, required this.isBusy, required this.onToggle, }); @override Widget build(BuildContext context) { final time = dose['scheduledTime']?.toString() ?? ''; final medId = dose['id']?.toString() ?? ''; final isTaken = _isTaken(dose); final displayTime = _formatTime(time); final statusColor = isTaken ? AppTheme.success : AppColors.warning; return IntrinsicHeight( child: Row( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ SizedBox( width: 28, child: Column( children: [ Container( width: 22, height: 22, decoration: BoxDecoration( color: isTaken ? AppTheme.success : Colors.white, shape: BoxShape.circle, border: Border.all( color: isTaken ? AppTheme.success : AppColors.border, width: 2, ), ), child: isTaken ? const Icon(Icons.check, size: 14, color: Colors.white) : null, ), if (!isLast) Expanded( child: Container( width: 2, margin: const EdgeInsets.symmetric(vertical: 4), color: isTaken ? AppTheme.success.withValues(alpha: 0.34) : AppColors.borderLight, ), ), ], ), ), const SizedBox(width: 10), Expanded( child: Padding( padding: EdgeInsets.only(bottom: isLast ? 0 : 12), child: Container( padding: const EdgeInsets.fromLTRB(12, 10, 10, 10), decoration: BoxDecoration( color: isTaken ? _MedicationCheckInPageState._softGreen : const Color(0xFFF8FAFC), borderRadius: BorderRadius.circular(12), border: Border.all( color: isTaken ? AppTheme.success.withValues(alpha: 0.18) : AppColors.borderLight, ), ), child: Row( children: [ Icon(Icons.access_time, size: 18, color: statusColor), const SizedBox(width: 8), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( displayTime, maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: 18, fontWeight: FontWeight.w800, color: isTaken ? AppTheme.textSub : AppTheme.text, ), ), const SizedBox(height: 1), Text( isTaken ? '已完成打卡' : '等待确认服用', maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: 12, fontWeight: FontWeight.w600, color: isTaken ? AppTheme.success : AppColors.textSecondary, ), ), ], ), ), const SizedBox(width: 8), SizedBox( height: 36, child: FilledButton.icon( onPressed: isBusy || medId.isEmpty || time.isEmpty ? null : () => onToggle(medId, time, isTaken), style: FilledButton.styleFrom( backgroundColor: isTaken ? Colors.white : _MedicationCheckInPageState._visual.color, foregroundColor: isTaken ? AppTheme.success : Colors.white, disabledBackgroundColor: AppColors.cardInner, disabledForegroundColor: AppColors.textHint, padding: const EdgeInsets.symmetric(horizontal: 12), minimumSize: const Size(0, 36), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(10), side: BorderSide( color: isTaken ? AppTheme.success.withValues(alpha: 0.34) : Colors.transparent, ), ), ), icon: isBusy ? const SizedBox( width: 14, height: 14, child: CircularProgressIndicator( strokeWidth: 2, color: AppColors.textHint, ), ) : Icon( isTaken ? Icons.undo_rounded : Icons.check_rounded, size: 17, ), label: Text( isTaken ? '撤销' : '打卡', style: const TextStyle( fontSize: 14, fontWeight: FontWeight.w800, ), ), ), ), ], ), ), ), ), ], ), ); } } bool _isTaken(Map dose) { return dose['status']?.toString().toLowerCase() == 'taken'; } String _formatTime(String raw) { final value = raw.trim(); if (value.length >= 5 && value[2] == ':') return value.substring(0, 5); final parsed = DateTime.tryParse(value); if (parsed == null) return value; final hour = parsed.hour.toString().padLeft(2, '0'); final minute = parsed.minute.toString().padLeft(2, '0'); return '$hour:$minute'; }