461 lines
15 KiB
Dart
461 lines
15 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_module_visuals.dart';
|
|
import '../../core/app_theme.dart';
|
|
import '../../core/navigation_provider.dart';
|
|
import '../../providers/auth_provider.dart';
|
|
import '../../providers/data_providers.dart';
|
|
import '../../providers/data_refresh_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<MedicationCheckInPage> createState() =>
|
|
_MedicationCheckInPageState();
|
|
}
|
|
|
|
class _MedicationCheckInPageState extends ConsumerState<MedicationCheckInPage> {
|
|
Future<List<Map<String, dynamic>>>? _future;
|
|
final Set<String> _busyDoses = {};
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_load();
|
|
}
|
|
|
|
void _load() => setState(() {
|
|
_future = ref.read(medicationReminderProvider.future);
|
|
});
|
|
|
|
Future<void> _refresh() async {
|
|
ref.invalidate(medicationReminderProvider);
|
|
_load();
|
|
await _future;
|
|
}
|
|
|
|
Future<void> _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.read(medicationDataRefreshSignalProvider.notifier).trigger();
|
|
_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<List<Map<String, dynamic>>>(
|
|
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<Map<String, dynamic>> _filterReminders(
|
|
List<Map<String, dynamic>> reminders,
|
|
) {
|
|
if (widget.medId == null || widget.medId!.isEmpty) return reminders;
|
|
return reminders
|
|
.where((item) => item['id']?.toString() == widget.medId)
|
|
.toList();
|
|
}
|
|
}
|
|
|
|
Map<String, List<Map<String, dynamic>>> groupMedicationReminders(
|
|
List<Map<String, dynamic>> reminders,
|
|
) {
|
|
final grouped = <String, List<Map<String, dynamic>>>{};
|
|
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, () => <Map<String, dynamic>>[]).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<String, dynamic> dose;
|
|
final bool isFirst;
|
|
final bool isLast;
|
|
final bool isBusy;
|
|
final Future<void> 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<String, dynamic> 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,
|
|
};
|