feat: UI全面改造 + 后端多项修复
【后端修复】 - 删除diet/consultation agent假工具声明 - 修复DayOfWeek实体注释 - 修复Vision API content序列化 - cleanup_service级联删除修复 - 用药提醒时区偏差修复 - 统一DateTime处理(UtcNow+8) - 新增UTC DateTime JSON转换器 【前端UI重构】 - 配色体系全面更新(#8B5CF6淡紫+#F0ECFF背景) - 登录页重设计 - 首页重设计(透明顶栏、渐变背景、胶囊输入区) - 聊天卡片加白蓝边框、渐变标题 - 侧边栏重构(渐变背景、合并顶部、删除底部设置) - 确认卡片可编辑字段恢复 - 所有子页面加返回按钮 - catch异常加日志 - 删除后refresh provider缓存
This commit is contained in:
@@ -7,6 +7,7 @@ import '../core/navigation_provider.dart';
|
||||
import '../providers/auth_provider.dart';
|
||||
import '../providers/data_providers.dart';
|
||||
import '../providers/omron_device_provider.dart';
|
||||
import '../widgets/common_widgets.dart';
|
||||
|
||||
/// 饮食记录列表(左滑删除)
|
||||
class DietRecordListPage extends ConsumerStatefulWidget {
|
||||
@@ -22,7 +23,7 @@ class _DietRecordListPageState extends ConsumerState<DietRecordListPage> {
|
||||
@override Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: AppTheme.bg,
|
||||
appBar: AppBar(title: const Text('饮食记录')),
|
||||
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: (ctx, snap) {
|
||||
@@ -38,7 +39,7 @@ class _DietRecordListPageState extends ConsumerState<DietRecordListPage> {
|
||||
final mealNames = {'Breakfast':'早餐','Lunch':'午餐','Dinner':'晚餐','Snack':'加餐'};
|
||||
final mealLabel = mealNames[d['mealType']?.toString()] ?? d['mealType']?.toString() ?? '';
|
||||
final mealIcons = {'Breakfast':'🌅','Lunch':'☀️','Dinner':'🌙','Snack':'🍪'};
|
||||
return _SwipeDeleteTile(
|
||||
return SwipeDeleteTile(
|
||||
key: Key(d['id']?.toString() ?? '$i'),
|
||||
onDelete: () async {
|
||||
await ref.read(dietServiceProvider).deleteRecord(d['id']?.toString() ?? '');
|
||||
@@ -46,11 +47,10 @@ class _DietRecordListPageState extends ConsumerState<DietRecordListPage> {
|
||||
},
|
||||
onTap: () => pushRoute(ref, 'dietDetail', params: {'id': d['id']?.toString() ?? ''}),
|
||||
child: Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(16)),
|
||||
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(AppTheme.rMd)),
|
||||
child: Row(children: [
|
||||
Container(width: 44, height: 44, decoration: BoxDecoration(color: AppTheme.primaryLight, borderRadius: BorderRadius.circular(12)), child: Center(child: Text(mealIcons[d['mealType']?.toString()] ?? '🍽️', style: const TextStyle(fontSize: 23)))),
|
||||
Container(width: 44, height: 44, decoration: BoxDecoration(color: AppColors.iconBg, borderRadius: BorderRadius.circular(AppTheme.rMd)), child: Center(child: Text(mealIcons[d['mealType']?.toString()] ?? '🍽️', style: const TextStyle(fontSize: 23)))),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
Row(children: [
|
||||
@@ -59,9 +59,9 @@ class _DietRecordListPageState extends ConsumerState<DietRecordListPage> {
|
||||
Text('${d['totalCalories'] ?? 0}千卡', style: const TextStyle(fontSize: 16, color: AppTheme.primary)),
|
||||
]),
|
||||
const SizedBox(height: 4),
|
||||
Text(items.map((f) => f['name']).join(' · '), style: const TextStyle(fontSize: 16, color: Color(0xFF888888)), maxLines: 1, overflow: TextOverflow.ellipsis),
|
||||
Text(items.map((f) => f['name']).join(' · '), style: const TextStyle(fontSize: 16, color: AppColors.textSecondary), maxLines: 1, overflow: TextOverflow.ellipsis),
|
||||
])),
|
||||
const Icon(Icons.chevron_right, size: 21, color: Color(0xFFCCCCCC)),
|
||||
Icon(Icons.chevron_right, size: 21, color: AppColors.textHint),
|
||||
]),
|
||||
),
|
||||
);
|
||||
@@ -81,7 +81,7 @@ class DietRecordDetailPage extends ConsumerWidget {
|
||||
final service = ref.watch(dietServiceProvider);
|
||||
return Scaffold(
|
||||
backgroundColor: AppTheme.bg,
|
||||
appBar: AppBar(title: const Text('饮食详情')),
|
||||
appBar: AppBar(leading: IconButton(icon: const Icon(Icons.arrow_back), onPressed: () => popRoute(ref)), title: const Text('饮食详情')),
|
||||
body: FutureBuilder<List<Map<String, dynamic>>>(
|
||||
future: service.getRecords(),
|
||||
builder: (ctx, snap) {
|
||||
@@ -92,23 +92,23 @@ class DietRecordDetailPage extends ConsumerWidget {
|
||||
final totalCal = d['totalCalories'] ?? 0;
|
||||
final mealNames = {'Breakfast':'早餐','Lunch':'午餐','Dinner':'晚餐','Snack':'加餐'};
|
||||
return ListView(padding: const EdgeInsets.all(16), children: [
|
||||
Container(padding: const EdgeInsets.all(16), decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(16)), child: Row(children: [
|
||||
Container(width: 48, height: 48, decoration: BoxDecoration(color: AppTheme.primaryLight, borderRadius: BorderRadius.circular(14)), child: const Icon(Icons.restaurant, size: 28, color: AppTheme.primary)),
|
||||
Container(padding: const EdgeInsets.all(16), decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(AppTheme.rMd)), child: Row(children: [
|
||||
Container(width: 48, height: 48, decoration: BoxDecoration(color: AppColors.iconBg, borderRadius: BorderRadius.circular(AppTheme.rMd)), child: const Icon(Icons.restaurant, size: 28, color: AppTheme.primary)),
|
||||
const SizedBox(width: 14),
|
||||
Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
Text(mealNames[d['mealType']?.toString()] ?? '', style: const TextStyle(fontSize: 21, fontWeight: FontWeight.w700)),
|
||||
Text('$totalCal 千卡 · ${d['recordedAt'] ?? ''}', style: const TextStyle(fontSize: 16, color: Color(0xFF999999))),
|
||||
Text('$totalCal 千卡 · ${d['recordedAt'] ?? ''}', style: const TextStyle(fontSize: 16, color: AppColors.textHint)),
|
||||
]),
|
||||
])),
|
||||
const SizedBox(height: 16),
|
||||
...items.map((f) => Container(
|
||||
margin: const EdgeInsets.only(bottom: 8),
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(14)),
|
||||
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(AppTheme.rMd)),
|
||||
child: Row(children: [
|
||||
Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
Text(f['name']?.toString() ?? '', style: const TextStyle(fontSize: 19, fontWeight: FontWeight.w600)),
|
||||
if ((f['portion']?.toString() ?? '').isNotEmpty) Text(f['portion']!.toString(), style: const TextStyle(fontSize: 16, color: Color(0xFF888888))),
|
||||
if ((f['portion']?.toString() ?? '').isNotEmpty) Text(f['portion']!.toString(), style: const TextStyle(fontSize: 16, color: AppColors.textSecondary)),
|
||||
])),
|
||||
Text('${f['calories'] ?? 0} kcal', style: const TextStyle(fontSize: 18, color: AppTheme.primary, fontWeight: FontWeight.w600)),
|
||||
]),
|
||||
@@ -138,13 +138,14 @@ class _ExercisePlanPageState extends ConsumerState<ExercisePlanPage> {
|
||||
|
||||
Future<void> _deletePlan(String id) async {
|
||||
await ref.read(exerciseServiceProvider).deletePlan(id);
|
||||
ref.invalidate(currentExercisePlanProvider);
|
||||
_load();
|
||||
}
|
||||
|
||||
@override Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: AppTheme.bg,
|
||||
appBar: AppBar(title: const Text('运动计划')),
|
||||
appBar: AppBar(leading: IconButton(icon: const Icon(Icons.arrow_back), onPressed: () => popRoute(ref)), title: const Text('运动计划')),
|
||||
floatingActionButton: FloatingActionButton(onPressed: () { pushRoute(ref, 'exerciseCreate'); }, backgroundColor: AppTheme.primary, child: const Icon(Icons.add)),
|
||||
body: FutureBuilder<List<Map<String, dynamic>>>(
|
||||
future: _future,
|
||||
@@ -157,51 +158,44 @@ class _ExercisePlanPageState extends ConsumerState<ExercisePlanPage> {
|
||||
if (items.isEmpty) return const SizedBox.shrink();
|
||||
final total = items.length;
|
||||
final done = items.where((it) => it['isCompleted'] == true).length;
|
||||
// 基于weekStartDate推算今天对应的条目索引
|
||||
final weekStart = p['weekStartDate']?.toString() ?? '';
|
||||
int? todayIdx;
|
||||
if (weekStart.isNotEmpty) {
|
||||
final ws = DateTime.tryParse(weekStart);
|
||||
if (ws != null) {
|
||||
final diff = DateTime.now().difference(ws).inDays;
|
||||
if (diff >= 0 && diff < items.length) todayIdx = diff;
|
||||
}
|
||||
}
|
||||
final todayItem = todayIdx != null ? items[todayIdx] : null;
|
||||
final todayDone = todayItem?['isCompleted'] == true;
|
||||
// 用 DayOfWeek 匹配今天(C#: 0=Sun, 6=Sat; Dart weekday%7 对齐)
|
||||
final todayCsDow = DateTime.now().weekday % 7;
|
||||
final todayItem = items.cast<Map<String, dynamic>>().firstWhere(
|
||||
(it) => it['dayOfWeek'] == todayCsDow,
|
||||
orElse: () => <String, dynamic>{},
|
||||
);
|
||||
final hasTodayItem = todayItem.isNotEmpty;
|
||||
final todayDone = todayItem['isCompleted'] == true;
|
||||
final exerciseName = items.isNotEmpty ? (items.first['exerciseType']?.toString() ?? '运动') : '运动';
|
||||
final startDate = weekStart;
|
||||
final endIdx = items.length - 1;
|
||||
final endDay = items.isNotEmpty ? items.last['dayOfWeek'] as int? : 6;
|
||||
|
||||
return _SwipeDeleteTile(
|
||||
return SwipeDeleteTile(
|
||||
key: Key(p['id']?.toString() ?? '$i'),
|
||||
onDelete: () => _deletePlan(p['id']?.toString() ?? ''),
|
||||
onTap: () {},
|
||||
child: Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(16)),
|
||||
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(AppTheme.rMd)),
|
||||
child: Row(children: [
|
||||
Container(width: 44, height: 44, decoration: BoxDecoration(color: AppTheme.primaryLight, borderRadius: BorderRadius.circular(12)), child: const Icon(Icons.directions_run, size: 25, color: AppTheme.primary)),
|
||||
Container(width: 44, height: 44, decoration: BoxDecoration(color: AppColors.iconBg, borderRadius: BorderRadius.circular(AppTheme.rMd)), child: const Icon(Icons.directions_run, size: 25, color: AppTheme.primary)),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
Text(exerciseName, style: const TextStyle(fontSize: 19, fontWeight: FontWeight.w600)),
|
||||
const SizedBox(height: 2),
|
||||
Text('$startDate 起 · ${items.first['durationMinutes'] ?? 0}分钟/天', style: const TextStyle(fontSize: 16, color: Color(0xFF888888))),
|
||||
Text('$weekStart 起 · ${items.first['durationMinutes'] ?? 0}分钟/天', style: const TextStyle(fontSize: 16, color: AppColors.textSecondary)),
|
||||
const SizedBox(height: 6),
|
||||
Text('$done/$total 天已完成', style: const TextStyle(fontSize: 15, color: AppTheme.primary)),
|
||||
])),
|
||||
GestureDetector(
|
||||
onTap: todayItem != null ? () => _checkIn(p['id']?.toString() ?? '', todayItem['id']?.toString() ?? '') : null,
|
||||
onTap: hasTodayItem ? () => _checkIn(p['id']?.toString() ?? '', todayItem['id']?.toString() ?? '') : null,
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 300),
|
||||
width: 40, height: 40,
|
||||
decoration: BoxDecoration(
|
||||
color: todayDone ? const Color(0xFFDCFCE7) : (todayItem != null ? AppTheme.primaryLight : const Color(0xFFF5F5F5)),
|
||||
color: todayDone ? AppColors.successLight : (hasTodayItem ? AppTheme.primaryLight : AppColors.cardInner),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Icon(todayDone ? Icons.check_circle : Icons.check_circle_outline, size: 28, color: todayDone ? AppTheme.success : const Color(0xFFBBBBBB)),
|
||||
child: Icon(todayDone ? Icons.check_circle : Icons.check_circle_outline, size: 28, color: todayDone ? AppTheme.success : AppColors.textHint),
|
||||
),
|
||||
),
|
||||
]),
|
||||
@@ -237,13 +231,13 @@ class _ExercisePlanCreatePageState extends ConsumerState<ExercisePlanCreatePage>
|
||||
'endDate': '${_end.year}-${_end.month.toString().padLeft(2,'0')}-${_end.day.toString().padLeft(2,'0')}',
|
||||
});
|
||||
ref.invalidate(currentExercisePlanProvider);
|
||||
if (mounted) { ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('计划已创建'), backgroundColor: Color(0xFF43A047))); popRoute(ref); }
|
||||
if (mounted) { popRoute(ref); }
|
||||
}
|
||||
|
||||
@override Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: AppTheme.bg,
|
||||
appBar: AppBar(title: const Text('新建计划')),
|
||||
appBar: AppBar(leading: IconButton(icon: const Icon(Icons.arrow_back), onPressed: () => popRoute(ref)), title: const Text('新建计划')),
|
||||
body: ListView(padding: const EdgeInsets.all(16), children: [
|
||||
_field('运动名称', _nameCtrl, hint: '如: 散步、慢跑、太极'),
|
||||
const SizedBox(height: 16),
|
||||
@@ -253,22 +247,22 @@ class _ExercisePlanCreatePageState extends ConsumerState<ExercisePlanCreatePage>
|
||||
const SizedBox(height: 16),
|
||||
_dateField('结束日期', _end, (d) => setState(() => _end = d)),
|
||||
const SizedBox(height: 32),
|
||||
SizedBox(width: double.infinity, child: ElevatedButton(onPressed: _save, style: ElevatedButton.styleFrom(backgroundColor: AppTheme.primary, foregroundColor: Colors.white, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), padding: const EdgeInsets.symmetric(vertical: 14)), child: const Text('保存计划', style: TextStyle(fontSize: 19)))),
|
||||
SizedBox(width: double.infinity, child: ElevatedButton(onPressed: _save, style: ElevatedButton.styleFrom(backgroundColor: AppTheme.primary, foregroundColor: Colors.white, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(AppTheme.rMd)), padding: const EdgeInsets.symmetric(vertical: 14)), child: const Text('保存计划', style: TextStyle(fontSize: 19)))),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _field(String label, TextEditingController ctrl, {String? hint, bool number = false}) {
|
||||
return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
Text(label, style: const TextStyle(fontSize: 17, color: Color(0xFF666666))),
|
||||
Text(label, style: TextStyle(fontSize: 17, color: AppColors.textSecondary)),
|
||||
const SizedBox(height: 6),
|
||||
TextField(controller: ctrl, keyboardType: number ? TextInputType.number : null, decoration: InputDecoration(hintText: hint, filled: true, fillColor: Colors.white, border: OutlineInputBorder(borderRadius: BorderRadius.circular(12), borderSide: BorderSide.none)), style: const TextStyle(fontSize: 19)),
|
||||
TextField(controller: ctrl, keyboardType: number ? TextInputType.number : null, decoration: InputDecoration(hintText: hint, filled: true, fillColor: AppColors.backgroundSoft, border: OutlineInputBorder(borderRadius: BorderRadius.circular(AppTheme.rMd), borderSide: BorderSide.none)), style: const TextStyle(fontSize: 19)),
|
||||
]);
|
||||
}
|
||||
|
||||
Widget _dateField(String label, DateTime val, ValueChanged<DateTime> onChanged) {
|
||||
return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
Text(label, style: const TextStyle(fontSize: 17, color: Color(0xFF666666))),
|
||||
Text(label, style: const TextStyle(fontSize: 17, color: AppColors.textSecondary)),
|
||||
const SizedBox(height: 6),
|
||||
GestureDetector(
|
||||
onTap: () async { final d = await showDatePicker(context: context, initialDate: val, firstDate: DateTime(2024), lastDate: DateTime(2030)); if (d != null) onChanged(d); },
|
||||
@@ -294,7 +288,7 @@ class _FollowUpListPageState extends ConsumerState<FollowUpListPage> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('复查随访'), centerTitle: true),
|
||||
appBar: AppBar(leading: IconButton(icon: const Icon(Icons.arrow_back), onPressed: () => popRoute(ref)), title: const Text('复查随访'), centerTitle: true),
|
||||
body: FutureBuilder<List<Map<String, dynamic>>>(
|
||||
future: _future,
|
||||
builder: (ctx, snap) {
|
||||
@@ -305,11 +299,11 @@ class _FollowUpListPageState extends ConsumerState<FollowUpListPage> {
|
||||
if (list.isEmpty) {
|
||||
return Center(
|
||||
child: Column(mainAxisSize: MainAxisSize.min, children: [
|
||||
Icon(Icons.event_note_outlined, size: 64, color: Colors.grey[300]),
|
||||
Icon(Icons.event_note_outlined, size: 64, color: AppColors.textHint),
|
||||
const SizedBox(height: 12),
|
||||
Text('暂无随访安排', style: Theme.of(context).textTheme.bodyMedium),
|
||||
const SizedBox(height: 4),
|
||||
const Text('医生创建随访后将显示在这里', style: TextStyle(fontSize: 16, color: Color(0xFF999999))),
|
||||
const Text('医生创建随访后将显示在这里', style: TextStyle(fontSize: 16, color: AppColors.textHint)),
|
||||
]),
|
||||
);
|
||||
}
|
||||
@@ -338,19 +332,19 @@ class _FollowUpItem extends StatelessWidget {
|
||||
|
||||
Color _statusColor(String? status) {
|
||||
switch (status) {
|
||||
case 'Upcoming': return const Color(0xFF4F6EF7);
|
||||
case 'Upcoming': return AppColors.primary;
|
||||
case 'Completed': return AppTheme.success;
|
||||
case 'Cancelled': return AppTheme.error;
|
||||
default: return const Color(0xFF999999);
|
||||
default: return AppColors.textHint;
|
||||
}
|
||||
}
|
||||
|
||||
Color _statusBg(String? status) {
|
||||
switch (status) {
|
||||
case 'Upcoming': return const Color(0xFFEDF2FF);
|
||||
case 'Completed': return const Color(0xFFDCFCE7);
|
||||
case 'Cancelled': return const Color(0xFFFFF5F5);
|
||||
default: return const Color(0xFFF5F5F5);
|
||||
case 'Upcoming': return AppColors.iconBg;
|
||||
case 'Completed': return AppColors.successLight;
|
||||
case 'Cancelled': return AppColors.errorLight;
|
||||
default: return AppColors.backgroundSoft;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -366,28 +360,28 @@ class _FollowUpItem extends StatelessWidget {
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
boxShadow: [BoxShadow(color: AppTheme.primary.withAlpha(10), blurRadius: 4, offset: const Offset(0, 2))],
|
||||
borderRadius: BorderRadius.circular(AppTheme.rMd),
|
||||
boxShadow: [AppTheme.shadowCard],
|
||||
),
|
||||
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
Row(children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(color: bg, borderRadius: BorderRadius.circular(8)),
|
||||
decoration: BoxDecoration(color: bg, borderRadius: BorderRadius.circular(AppTheme.rSm)),
|
||||
child: Text(label, style: TextStyle(fontSize: 15, color: color, fontWeight: FontWeight.w500)),
|
||||
),
|
||||
const Spacer(),
|
||||
if (item['doctorName'] != null)
|
||||
Text('👨⚕️ ${item['doctorName']}', style: const TextStyle(fontSize: 15, color: Color(0xFF999999))),
|
||||
Text('👨⚕️ ${item['doctorName']}', style: const TextStyle(fontSize: 15, color: AppColors.textHint)),
|
||||
]),
|
||||
const SizedBox(height: 12),
|
||||
Text(item['title']?.toString() ?? '', style: const TextStyle(fontSize: 19, fontWeight: FontWeight.w600)),
|
||||
const SizedBox(height: 4),
|
||||
if (item['scheduledAt'] != null)
|
||||
Text(_formatDateTime(item['scheduledAt']?.toString()), style: TextStyle(fontSize: 17, color: Colors.grey[500])),
|
||||
Text(_formatDateTime(item['scheduledAt']?.toString()), style: const TextStyle(fontSize: 17, color: AppColors.textHint)),
|
||||
if ((item['notes']?.toString() ?? '').isNotEmpty) ...[
|
||||
const SizedBox(height: 8),
|
||||
Text(item['notes']?.toString() ?? '', style: TextStyle(fontSize: 16, color: Colors.grey[600])),
|
||||
Text(item['notes']?.toString() ?? '', style: const TextStyle(fontSize: 16, color: AppColors.textSecondary)),
|
||||
],
|
||||
]),
|
||||
);
|
||||
@@ -419,6 +413,7 @@ class _HealthArchivePageState extends ConsumerState<HealthArchivePage> {
|
||||
final _dietCtrl = TextEditingController();
|
||||
final _familyCtrl = TextEditingController();
|
||||
bool _loading = true;
|
||||
bool _saving = false;
|
||||
|
||||
@override void initState() { super.initState(); _load(); }
|
||||
@override void dispose() {
|
||||
@@ -452,8 +447,9 @@ class _HealthArchivePageState extends ConsumerState<HealthArchivePage> {
|
||||
}
|
||||
|
||||
Future<void> _save() async {
|
||||
final srv = ref.read(userServiceProvider);
|
||||
setState(() => _saving = true);
|
||||
try {
|
||||
final srv = ref.read(userServiceProvider);
|
||||
await srv.updateProfile(name: _nameCtrl.text, gender: _genderCtrl.text, birthDate: _birthCtrl.text);
|
||||
await srv.updateHealthArchive({
|
||||
'diagnosis': _diagnosisCtrl.text,
|
||||
@@ -464,63 +460,145 @@ class _HealthArchivePageState extends ConsumerState<HealthArchivePage> {
|
||||
'dietRestrictions': _dietCtrl.text.split('、').where((s) => s.isNotEmpty).toList(),
|
||||
'familyHistory': _familyCtrl.text,
|
||||
});
|
||||
if (mounted) ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('已保存'), backgroundColor: Color(0xFF43A047)));
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('保存成功'), backgroundColor: AppColors.success),
|
||||
);
|
||||
}
|
||||
} catch (_) {
|
||||
if (mounted) ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('保存失败'), backgroundColor: Color(0xFFE53935)));
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('保存失败,请重试'), backgroundColor: AppColors.error),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (mounted) setState(() => _saving = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override Widget build(BuildContext context) {
|
||||
if (_loading) return Scaffold(appBar: AppBar(title: const Text('健康档案')), body: const Center(child: CircularProgressIndicator(color: AppTheme.primary)));
|
||||
if (_loading) {
|
||||
return Scaffold(
|
||||
backgroundColor: AppColors.background,
|
||||
appBar: AppBar(leading: IconButton(icon: const Icon(Icons.arrow_back), onPressed: () => popRoute(ref)), title: const Text('健康档案')),
|
||||
body: const Center(child: CircularProgressIndicator(color: AppColors.primary)),
|
||||
);
|
||||
}
|
||||
return Scaffold(
|
||||
backgroundColor: AppTheme.bg,
|
||||
appBar: AppBar(title: const Text('健康档案'), actions: [
|
||||
TextButton(onPressed: _save, child: const Text('保存', style: TextStyle(color: AppTheme.primary, fontWeight: FontWeight.w600))),
|
||||
]),
|
||||
body: ListView(padding: const EdgeInsets.all(16), children: [
|
||||
_sectionTitle('个人资料'),
|
||||
_editableCard(children: [
|
||||
_field('姓名', _nameCtrl),
|
||||
Row(children: [Expanded(child: _field('性别', _genderCtrl)), const SizedBox(width: 12), Expanded(child: _field('出生日期', _birthCtrl, hint: 'YYYY-MM-DD'))]),
|
||||
backgroundColor: AppColors.background,
|
||||
appBar: AppBar(
|
||||
backgroundColor: AppColors.cardBackground,
|
||||
title: const Text('健康档案'),
|
||||
),
|
||||
body: ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
_buildSection('个人资料', Icons.person_outline, [
|
||||
_buildField('姓名', _nameCtrl, hint: '请输入姓名'),
|
||||
const SizedBox(height: 12),
|
||||
Row(children: [
|
||||
Expanded(child: _buildField('性别', _genderCtrl, hint: '男/女')),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(child: _buildField('出生日期', _birthCtrl, hint: '如 1970-03-15')),
|
||||
]),
|
||||
]),
|
||||
const SizedBox(height: 16),
|
||||
_buildSection('医疗信息', Icons.local_hospital_outlined, [
|
||||
_buildField('主要诊断', _diagnosisCtrl, hint: '如: 冠心病'),
|
||||
const SizedBox(height: 12),
|
||||
Row(children: [
|
||||
Expanded(child: _buildField('手术类型', _surgeryCtrl, hint: '如: PCI支架植入术')),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(child: _buildField('手术日期', _surgeryDateCtrl, hint: '如 2026-03-15')),
|
||||
]),
|
||||
]),
|
||||
const SizedBox(height: 16),
|
||||
_buildSection('病史与限制', Icons.warning_amber_outlined, [
|
||||
_buildField('过敏史', _allergiesCtrl, hint: '多个用、分隔,如: 青霉素、海鲜'),
|
||||
const SizedBox(height: 12),
|
||||
_buildField('慢性病史', _chronicCtrl, hint: '多个用、分隔,如: 高血压、高血脂'),
|
||||
const SizedBox(height: 12),
|
||||
_buildField('饮食限制', _dietCtrl, hint: '多个用、分隔,如: 低盐、低脂'),
|
||||
const SizedBox(height: 12),
|
||||
_buildField('家族病史', _familyCtrl, hint: '如: 父亲冠心病'),
|
||||
]),
|
||||
const SizedBox(height: 24),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
height: 52,
|
||||
child: ElevatedButton(
|
||||
onPressed: _saving ? null : _save,
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: AppColors.primary,
|
||||
foregroundColor: Colors.white,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(AppTheme.rMd)),
|
||||
elevation: 0,
|
||||
),
|
||||
child: Text(_saving ? '保存中...' : '保存档案', style: const TextStyle(fontSize: 17, fontWeight: FontWeight.w600)),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 40),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSection(String title, IconData icon, List<Widget> fields) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(18),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.cardBackground,
|
||||
borderRadius: BorderRadius.circular(AppTheme.rMd),
|
||||
boxShadow: [AppTheme.shadowCard],
|
||||
),
|
||||
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
Row(children: [
|
||||
Container(
|
||||
width: 36, height: 36,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.iconBg,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: Icon(icon, size: 20, color: AppColors.primary),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Text(title, style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w700, color: AppColors.textPrimary)),
|
||||
]),
|
||||
_sectionTitle('医疗信息'),
|
||||
_editableCard(children: [
|
||||
_field('主要诊断', _diagnosisCtrl, hint: '如: 冠心病'),
|
||||
Row(children: [Expanded(child: _field('手术类型', _surgeryCtrl, hint: '如: PCI支架植入术')), const SizedBox(width: 12), Expanded(child: _field('手术日期', _surgeryDateCtrl, hint: 'YYYY-MM-DD'))]),
|
||||
]),
|
||||
_sectionTitle('病史与限制'),
|
||||
_editableCard(children: [
|
||||
_field('过敏史', _allergiesCtrl, hint: '多个用、分隔 如: 青霉素、海鲜'),
|
||||
_field('慢性病史', _chronicCtrl, hint: '多个用、分隔 如: 高血压、高血脂'),
|
||||
_field('饮食限制', _dietCtrl, hint: '多个用、分隔 如: 低盐、低脂'),
|
||||
_field('家族病史', _familyCtrl, hint: '如: 父亲冠心病'),
|
||||
]),
|
||||
const SizedBox(height: 20),
|
||||
SizedBox(width: double.infinity, child: ElevatedButton(onPressed: _save, child: const Text('保存档案')),),
|
||||
const SizedBox(height: 40),
|
||||
const SizedBox(height: 16),
|
||||
...fields,
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _sectionTitle(String title) => Padding(
|
||||
padding: const EdgeInsets.only(left: 4, top: 16, bottom: 8),
|
||||
child: Text(title, style: const TextStyle(fontSize: 19, fontWeight: FontWeight.w700, color: AppTheme.primary)),
|
||||
);
|
||||
|
||||
Widget _editableCard({required List<Widget> children}) => Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(16), boxShadow: [BoxShadow(color: AppTheme.primary.withAlpha(8), blurRadius: 8, offset: const Offset(0, 2))]),
|
||||
child: Column(children: children),
|
||||
);
|
||||
|
||||
Widget _field(String label, TextEditingController ctrl, {String? hint}) => Padding(
|
||||
padding: const EdgeInsets.only(bottom: 12),
|
||||
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
Text(label, style: const TextStyle(fontSize: 16, color: Color(0xFF888888))),
|
||||
const SizedBox(height: 4),
|
||||
TextField(controller: ctrl, decoration: InputDecoration(hintText: hint, filled: true, fillColor: const Color(0xFFF4F5FA), contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), border: OutlineInputBorder(borderRadius: BorderRadius.circular(10), borderSide: BorderSide.none)), style: const TextStyle(fontSize: 18)),
|
||||
]),
|
||||
);
|
||||
Widget _buildField(String label, TextEditingController ctrl, {String? hint}) {
|
||||
return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
Text(label, style: const TextStyle(fontSize: 14, color: AppColors.textHint)),
|
||||
const SizedBox(height: 6),
|
||||
TextField(
|
||||
controller: ctrl,
|
||||
style: const TextStyle(fontSize: 17, color: AppColors.textPrimary),
|
||||
decoration: InputDecoration(
|
||||
hintText: hint,
|
||||
hintStyle: const TextStyle(fontSize: 15, color: AppColors.textHint),
|
||||
filled: true,
|
||||
fillColor: AppColors.backgroundSoft,
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(AppTheme.rSm),
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(AppTheme.rSm),
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(AppTheme.rSm),
|
||||
borderSide: const BorderSide(color: AppColors.primary, width: 1.5),
|
||||
),
|
||||
),
|
||||
),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/// 健康日历
|
||||
@@ -552,12 +630,12 @@ class _HealthCalendarPageState extends ConsumerState<HealthCalendarPage> {
|
||||
events[map['date'] as String] = List<String>.from(map['events'] ?? []);
|
||||
}
|
||||
if (mounted) setState(() => _events = events);
|
||||
} catch (_) {}
|
||||
} catch (e) { debugPrint('[Calendar] 加载日历失败: $e'); }
|
||||
}
|
||||
|
||||
@override Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('健康日历'), centerTitle: true),
|
||||
appBar: AppBar(leading: IconButton(icon: const Icon(Icons.arrow_back), onPressed: () => popRoute(ref)), title: const Text('健康日历'), centerTitle: true),
|
||||
body: Column(children: [
|
||||
_buildMonthHeader(),
|
||||
_buildWeekdayHeader(),
|
||||
@@ -591,7 +669,7 @@ class _HealthCalendarPageState extends ConsumerState<HealthCalendarPage> {
|
||||
Widget _buildWeekdayHeader() {
|
||||
const weekdays = ['日', '一', '二', '三', '四', '五', '六'];
|
||||
return Row(children: weekdays.map((day) => Expanded(
|
||||
child: Center(child: Text(day, style: TextStyle(fontSize: 17, color: Colors.grey[500]))),
|
||||
child: Center(child: Text(day, style: const TextStyle(fontSize: 17, color: AppColors.textHint))),
|
||||
)).toList());
|
||||
}
|
||||
|
||||
@@ -638,7 +716,7 @@ class _HealthCalendarPageState extends ConsumerState<HealthCalendarPage> {
|
||||
'$day',
|
||||
style: TextStyle(
|
||||
fontSize: 19,
|
||||
color: isToday ? Colors.white : Colors.black,
|
||||
color: isToday ? Colors.white : AppColors.textPrimary,
|
||||
fontWeight: isToday ? FontWeight.w600 : FontWeight.normal,
|
||||
),
|
||||
),
|
||||
@@ -669,8 +747,8 @@ class _HealthCalendarPageState extends ConsumerState<HealthCalendarPage> {
|
||||
switch (type) {
|
||||
case 'medication': return AppTheme.primary;
|
||||
case 'exercise': return AppTheme.success;
|
||||
case 'followup': return const Color(0xFFF59E0B);
|
||||
default: return Colors.grey;
|
||||
case 'followup': return AppColors.warning;
|
||||
default: return AppColors.textHint;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -678,14 +756,14 @@ class _HealthCalendarPageState extends ConsumerState<HealthCalendarPage> {
|
||||
final items = [
|
||||
{'color': AppTheme.primary, 'label': '用药提醒'},
|
||||
{'color': AppTheme.success, 'label': '运动计划'},
|
||||
{'color': const Color(0xFFF59E0B), 'label': '复查随访'},
|
||||
{'color': AppColors.warning, 'label': '复查随访'},
|
||||
];
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Row(mainAxisAlignment: MainAxisAlignment.center, children: items.map((item) => Row(children: [
|
||||
Container(width: 10, height: 10, decoration: BoxDecoration(color: item['color'] as Color, borderRadius: BorderRadius.circular(5))),
|
||||
const SizedBox(width: 4),
|
||||
Text(item['label'] as String, style: TextStyle(fontSize: 15, color: Colors.grey[600])),
|
||||
Text(item['label'] as String, style: const TextStyle(fontSize: 15, color: AppColors.textSecondary)),
|
||||
const SizedBox(width: 20),
|
||||
])).toList()),
|
||||
);
|
||||
@@ -774,12 +852,12 @@ class StaticTextPage extends ConsumerWidget {
|
||||
backgroundColor: Colors.white,
|
||||
elevation: 0,
|
||||
leading: IconButton(icon: const Icon(Icons.chevron_left), onPressed: () => popRoute(ref)),
|
||||
title: Text(titles[type] ?? '', style: const TextStyle(color: Color(0xFF1A1A1A), fontWeight: FontWeight.w600)),
|
||||
title: Text(titles[type] ?? '', style: TextStyle(color: AppColors.textPrimary, fontWeight: FontWeight.w600)),
|
||||
centerTitle: true,
|
||||
),
|
||||
body: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Text(contents[type] ?? '内容加载中...', style: const TextStyle(fontSize: 17, height: 1.8, color: Color(0xFF333333))),
|
||||
child: Text(contents[type] ?? '内容加载中...', style: TextStyle(fontSize: 17, height: 1.8, color: AppColors.textPrimary)),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -808,7 +886,7 @@ class DeviceManagementPage extends ConsumerWidget {
|
||||
Container(
|
||||
width: 88, height: 88,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.iconBackground,
|
||||
color: AppColors.iconBg,
|
||||
borderRadius: BorderRadius.circular(28),
|
||||
),
|
||||
child: Icon(Icons.bluetooth_disabled, size: 44, color: AppColors.textHint),
|
||||
@@ -826,9 +904,9 @@ class DeviceManagementPage extends ConsumerWidget {
|
||||
icon: const Icon(Icons.add, size: 22),
|
||||
label: const Text('添加设备', style: TextStyle(fontSize: 17, fontWeight: FontWeight.w600)),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: AppColors.iconColor,
|
||||
backgroundColor: AppColors.primary,
|
||||
foregroundColor: Colors.white,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(AppTheme.rMd)),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -845,15 +923,15 @@ class DeviceManagementPage extends ConsumerWidget {
|
||||
padding: const EdgeInsets.all(24),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.cardBackground,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
borderRadius: BorderRadius.circular(AppTheme.rMd),
|
||||
boxShadow: AppColors.cardShadow,
|
||||
),
|
||||
child: Column(children: [
|
||||
Container(
|
||||
width: 72, height: 72,
|
||||
decoration: BoxDecoration(
|
||||
gradient: AppColors.purpleBlueGradient,
|
||||
borderRadius: BorderRadius.circular(22),
|
||||
gradient: AppColors.primaryGradient,
|
||||
borderRadius: BorderRadius.circular(AppTheme.rMd),
|
||||
),
|
||||
child: const Icon(Icons.bluetooth_connected, size: 36, color: Colors.white),
|
||||
),
|
||||
@@ -874,7 +952,7 @@ class DeviceManagementPage extends ConsumerWidget {
|
||||
label: const Text('解绑', style: TextStyle(color: AppColors.error)),
|
||||
style: OutlinedButton.styleFrom(
|
||||
side: const BorderSide(color: AppColors.error),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(AppTheme.rMd)),
|
||||
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||
),
|
||||
),
|
||||
@@ -886,9 +964,9 @@ class DeviceManagementPage extends ConsumerWidget {
|
||||
icon: const Icon(Icons.swap_horiz, size: 18),
|
||||
label: const Text('更换设备'),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: AppColors.iconColor,
|
||||
backgroundColor: AppColors.primary,
|
||||
foregroundColor: Colors.white,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(AppTheme.rMd)),
|
||||
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||
),
|
||||
),
|
||||
@@ -905,7 +983,7 @@ class DeviceManagementPage extends ConsumerWidget {
|
||||
padding: const EdgeInsets.all(20),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.cardBackground,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
borderRadius: BorderRadius.circular(AppTheme.rMd),
|
||||
boxShadow: AppColors.cardShadow,
|
||||
),
|
||||
child: Column(children: [
|
||||
@@ -913,7 +991,7 @@ class DeviceManagementPage extends ConsumerWidget {
|
||||
Container(
|
||||
width: 6, height: 18,
|
||||
decoration: BoxDecoration(
|
||||
gradient: AppColors.purpleBlueGradient,
|
||||
gradient: AppColors.primaryGradient,
|
||||
borderRadius: BorderRadius.circular(3),
|
||||
),
|
||||
),
|
||||
@@ -925,8 +1003,8 @@ class DeviceManagementPage extends ConsumerWidget {
|
||||
Container(
|
||||
width: 56, height: 56,
|
||||
decoration: BoxDecoration(
|
||||
gradient: AppColors.lightGradient,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
color: AppColors.iconBg,
|
||||
borderRadius: BorderRadius.circular(AppTheme.rMd),
|
||||
),
|
||||
child: const Text('🩺', style: TextStyle(fontSize: 28)),
|
||||
),
|
||||
@@ -938,7 +1016,7 @@ class DeviceManagementPage extends ConsumerWidget {
|
||||
const Spacer(),
|
||||
if (device.lastReading!.pulse != null)
|
||||
Column(crossAxisAlignment: CrossAxisAlignment.end, children: [
|
||||
Text('${device.lastReading!.pulse}', style: const TextStyle(fontSize: 24, fontWeight: FontWeight.w700, color: AppColors.iconColor)),
|
||||
Text('${device.lastReading!.pulse}', style: const TextStyle(fontSize: 24, fontWeight: FontWeight.w700, color: AppColors.primary)),
|
||||
const Text('bpm', style: TextStyle(fontSize: 13, color: AppColors.textHint)),
|
||||
]),
|
||||
]),
|
||||
@@ -952,12 +1030,12 @@ class DeviceManagementPage extends ConsumerWidget {
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(20),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.iconBackground,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
color: AppColors.iconBg,
|
||||
borderRadius: BorderRadius.circular(AppTheme.rMd),
|
||||
),
|
||||
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
Row(children: [
|
||||
Icon(Icons.lightbulb_outline, size: 18, color: AppColors.iconColor),
|
||||
Icon(Icons.lightbulb_outline, size: 18, color: AppColors.primary),
|
||||
const SizedBox(width: 8),
|
||||
const Text('使用说明', style: TextStyle(fontSize: 15, fontWeight: FontWeight.w600, color: AppColors.textPrimary)),
|
||||
]),
|
||||
@@ -974,7 +1052,7 @@ class DeviceManagementPage extends ConsumerWidget {
|
||||
Widget _guideItem(String num, String text) => Padding(
|
||||
padding: const EdgeInsets.only(bottom: 6),
|
||||
child: Row(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
Text('$num.', style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: AppColors.iconColor)),
|
||||
Text('$num.', style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: AppColors.primary)),
|
||||
const SizedBox(width: 6),
|
||||
Expanded(child: Text(text, style: const TextStyle(fontSize: 14, color: AppColors.textSecondary))),
|
||||
]),
|
||||
@@ -995,61 +1073,8 @@ class DeviceManagementPage extends ConsumerWidget {
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════ 自定义滑动删除组件 ═══════════════════
|
||||
class _SwipeDeleteTile extends StatefulWidget {
|
||||
final Widget child;
|
||||
final VoidCallback onDelete;
|
||||
final VoidCallback onTap;
|
||||
const _SwipeDeleteTile({super.key, required this.child, required this.onDelete, required this.onTap});
|
||||
@override State<_SwipeDeleteTile> createState() => _SwipeDeleteTileState();
|
||||
}
|
||||
|
||||
class _SwipeDeleteTileState extends State<_SwipeDeleteTile> with SingleTickerProviderStateMixin {
|
||||
double _dx = 0;
|
||||
static const _maxSlide = 80.0;
|
||||
static const _threshold = 40.0;
|
||||
|
||||
void _onDragUpdate(DragUpdateDetails d) {
|
||||
setState(() => _dx = (_dx + d.delta.dx).clamp(-_maxSlide, 0.0));
|
||||
}
|
||||
|
||||
void _onDragEnd(DragEndDetails d) {
|
||||
if (_dx < -_threshold) {
|
||||
setState(() => _dx = -_maxSlide);
|
||||
} else {
|
||||
setState(() => _dx = 0);
|
||||
}
|
||||
}
|
||||
|
||||
void _onDelete() {
|
||||
widget.onDelete();
|
||||
setState(() => _dx = 0);
|
||||
}
|
||||
|
||||
void _closeSwipe() => setState(() => _dx = 0);
|
||||
|
||||
@override Widget build(BuildContext context) {
|
||||
return Stack(children: [
|
||||
Positioned.fill(child: Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
|
||||
decoration: BoxDecoration(color: AppTheme.error, borderRadius: BorderRadius.circular(16)),
|
||||
child: const Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: Padding(padding: EdgeInsets.only(right: 20), child: Icon(Icons.delete_outline, color: Colors.white, size: 28)),
|
||||
),
|
||||
)),
|
||||
GestureDetector(
|
||||
onTap: _dx < 0 ? () { widget.onDelete(); setState(() => _dx = 0); } : widget.onTap,
|
||||
onHorizontalDragUpdate: _onDragUpdate,
|
||||
onHorizontalDragEnd: _onDragEnd,
|
||||
child: Transform.translate(offset: Offset(_dx, 0), child: widget.child),
|
||||
),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
Widget _empty(BuildContext context, String title, String subtitle) =>
|
||||
Center(child: Column(mainAxisSize: MainAxisSize.min, children: [
|
||||
Icon(Icons.inbox_outlined, size: 64, color: Colors.grey[300]),
|
||||
Icon(Icons.inbox_outlined, size: 64, color: AppColors.textHint),
|
||||
const SizedBox(height: 12), Text(subtitle, style: Theme.of(context).textTheme.bodyMedium),
|
||||
]));
|
||||
|
||||
Reference in New Issue
Block a user