fix: 用药模块重做 + 运动计划不限7天 + 打卡可切换 + 删除修复
- 用药: 重写列表页(标签筛选+详情弹窗+打卡切换+滑动删除) - 用药: 重写编辑页(选择器替代输入+同列布局+频率改为每天几次) - 用药: 加Notes字段+DELETE端点+修复重复端点 - 运动: 不限7天, startDate+索引推算当天 - 打卡: 药/运动打卡可切换(点✓取消) - 删除: 修复滑动删除热区, 一次点击即删除 - 侧边栏: 一键清理历史对话 - 饮食/运动/用药/问诊 API路径加/api/前缀 - 通知时机修正: 新建计划不再提前弹窗
This commit is contained in:
@@ -13,7 +13,7 @@ class _DietRecordListPageState extends ConsumerState<DietRecordListPage> {
|
||||
Future<List<Map<String, dynamic>>>? _future;
|
||||
|
||||
@override void initState() { super.initState(); _refresh(); }
|
||||
void _refresh() => _future = ref.read(dietServiceProvider).getRecords();
|
||||
void _refresh() { setState(() { _future = ref.read(dietServiceProvider).getRecords(); }); }
|
||||
|
||||
@override Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
@@ -34,21 +34,13 @@ 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 Dismissible(
|
||||
return _SwipeDeleteTile(
|
||||
key: Key(d['id']?.toString() ?? '$i'),
|
||||
direction: DismissDirection.endToStart,
|
||||
background: Container(
|
||||
alignment: Alignment.centerRight,
|
||||
padding: const EdgeInsets.only(right: 24),
|
||||
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
|
||||
decoration: BoxDecoration(color: const Color(0xFFE53935), borderRadius: BorderRadius.circular(16)),
|
||||
child: const Icon(Icons.delete_outline, color: Colors.white, size: 28),
|
||||
),
|
||||
confirmDismiss: (_) async {
|
||||
try { await ref.read(dietServiceProvider).deleteRecord(d['id']?.toString() ?? ''); } catch (_) {}
|
||||
setState(_refresh);
|
||||
return false;
|
||||
onDelete: () async {
|
||||
await ref.read(dietServiceProvider).deleteRecord(d['id']?.toString() ?? '');
|
||||
_refresh();
|
||||
},
|
||||
onTap: () => pushRoute(ref, 'dietDetail', params: {'id': d['id']?.toString() ?? ''}),
|
||||
child: Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
|
||||
padding: const EdgeInsets.all(14),
|
||||
@@ -77,221 +69,209 @@ class _DietRecordListPageState extends ConsumerState<DietRecordListPage> {
|
||||
}
|
||||
}
|
||||
|
||||
/// 运动计划页
|
||||
class ExercisePlanPage extends ConsumerWidget {
|
||||
const ExercisePlanPage({super.key});
|
||||
/// 饮食记录详情页
|
||||
class DietRecordDetailPage extends ConsumerWidget {
|
||||
final String id;
|
||||
const DietRecordDetailPage({super.key, required this.id});
|
||||
@override Widget build(BuildContext context, WidgetRef ref) {
|
||||
final plan = ref.watch(currentExercisePlanProvider);
|
||||
final service = ref.watch(dietServiceProvider);
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('运动计划'), centerTitle: true),
|
||||
floatingActionButton: FloatingActionButton.extended(
|
||||
onPressed: () => _createDefaultPlan(ref, context),
|
||||
icon: const Icon(Icons.add),
|
||||
label: const Text('创建本周计划'),
|
||||
backgroundColor: const Color(0xFF8B9CF7),
|
||||
),
|
||||
body: plan.when(
|
||||
data: (data) {
|
||||
if (data == null || data.isEmpty) return _empty(context, '运动计划', '暂无运动计划,点击右下角创建');
|
||||
final items = (data['items'] as List?)?.cast<Map<String, dynamic>>() ?? [];
|
||||
final weekDays = ['周一', '周二', '周三', '周四', '周五', '周六', '周日'];
|
||||
final completedCount = items.where((i) => i['isCompleted'] == true).length;
|
||||
final totalCount = items.where((i) => i['isRestDay'] != true).length;
|
||||
|
||||
return ListView(children: [
|
||||
_buildProgressCard(completedCount, totalCount),
|
||||
backgroundColor: const Color(0xFFF8F9FC),
|
||||
appBar: AppBar(title: const Text('饮食详情')),
|
||||
body: FutureBuilder<List<Map<String, dynamic>>>(
|
||||
future: service.getRecords(),
|
||||
builder: (ctx, snap) {
|
||||
if (!snap.hasData) return const Center(child: CircularProgressIndicator(color: Color(0xFF6C5CE7)));
|
||||
final d = snap.data!.firstWhere((r) => r['id']?.toString() == id, orElse: () => <String, dynamic>{});
|
||||
if (d.isEmpty) return const Center(child: Text('记录不存在'));
|
||||
final items = (d['foodItems'] as List?)?.cast<Map<String, dynamic>>() ?? [];
|
||||
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: const Color(0xFFEDEAFF), borderRadius: BorderRadius.circular(14)), child: const Icon(Icons.restaurant, size: 24, color: Color(0xFF6C5CE7))),
|
||||
const SizedBox(width: 14),
|
||||
Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
Text(mealNames[d['mealType']?.toString()] ?? '', style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w700)),
|
||||
Text('$totalCal 千卡 · ${d['recordedAt'] ?? ''}', style: const TextStyle(fontSize: 13, color: Color(0xFF999999))),
|
||||
]),
|
||||
])),
|
||||
const SizedBox(height: 16),
|
||||
...items.asMap().entries.map((entry) {
|
||||
final i = entry.key;
|
||||
final item = entry.value;
|
||||
final day = item['dayOfWeek'] is int ? item['dayOfWeek'] as int : i;
|
||||
final isRest = item['isRestDay'] == true;
|
||||
final isDone = item['isCompleted'] == true;
|
||||
return _ExercisePlanItem(
|
||||
day: weekDays[day],
|
||||
dayIndex: day,
|
||||
isRest: isRest,
|
||||
isDone: isDone,
|
||||
exerciseType: item['exerciseType']?.toString() ?? '',
|
||||
duration: item['durationMinutes'] is int ? item['durationMinutes'] as int : 0,
|
||||
onCheckIn: () => _checkIn(ref, item['id'], context),
|
||||
);
|
||||
}),
|
||||
...items.map((f) => Container(
|
||||
margin: const EdgeInsets.only(bottom: 8),
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(14)),
|
||||
child: Row(children: [
|
||||
Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
Text(f['name']?.toString() ?? '', style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600)),
|
||||
if ((f['portion']?.toString() ?? '').isNotEmpty) Text(f['portion']!.toString(), style: const TextStyle(fontSize: 13, color: Color(0xFF888888))),
|
||||
])),
|
||||
Text('${f['calories'] ?? 0} kcal', style: const TextStyle(fontSize: 15, color: Color(0xFF6C5CE7), fontWeight: FontWeight.w600)),
|
||||
]),
|
||||
)),
|
||||
]);
|
||||
},
|
||||
loading: () => const Center(child: CircularProgressIndicator(color: Color(0xFF8B9CF7))),
|
||||
error: (_, e) => _empty(context, '运动计划', '暂无运动计划,点击右下角创建'),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildProgressCard(int completed, int total) {
|
||||
final progress = total > 0 ? (completed / total * 100).toInt() : 0;
|
||||
return Container(
|
||||
margin: const EdgeInsets.all(16),
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFF0F2FF),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Column(children: [
|
||||
const Text('🏃 本周运动进度', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600)),
|
||||
const SizedBox(height: 12),
|
||||
Row(children: [
|
||||
Container(
|
||||
width: 60,
|
||||
height: 60,
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF8B9CF7),
|
||||
borderRadius: BorderRadius.circular(30),
|
||||
),
|
||||
child: Center(
|
||||
child: Text('$progress%', style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: Colors.white)),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
Text('已完成 $completed/$total 天', style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w500)),
|
||||
const SizedBox(height: 8),
|
||||
Container(
|
||||
height: 8,
|
||||
decoration: BoxDecoration(color: Colors.grey[200], borderRadius: BorderRadius.circular(4)),
|
||||
child: FractionallySizedBox(
|
||||
widthFactor: progress / 100,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(color: const Color(0xFF8B9CF7), borderRadius: BorderRadius.circular(4)),
|
||||
),
|
||||
),
|
||||
),
|
||||
]),
|
||||
),
|
||||
]),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
void _createDefaultPlan(WidgetRef ref, BuildContext context) async {
|
||||
try {
|
||||
final service = ref.read(exerciseServiceProvider);
|
||||
final today = DateTime.now();
|
||||
final monday = today.subtract(Duration(days: today.weekday - 1));
|
||||
final items = List.generate(7, (i) => {
|
||||
'dayOfWeek': i,
|
||||
'exerciseType': i == 2 || i == 5 ? '休息' : '散步',
|
||||
'durationMinutes': i == 2 || i == 5 ? 0 : 30,
|
||||
'isRestDay': i == 2 || i == 5,
|
||||
});
|
||||
await service.createPlan({
|
||||
'weekStartDate': '${monday.year}-${monday.month.toString().padLeft(2, '0')}-${monday.day.toString().padLeft(2, '0')}',
|
||||
'items': items,
|
||||
});
|
||||
ref.invalidate(currentExercisePlanProvider);
|
||||
if (!context.mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(
|
||||
content: Text('运动计划已创建'),
|
||||
backgroundColor: Color(0xFF43A047),
|
||||
));
|
||||
} catch (e) {
|
||||
// 后端不可用时,直接使用本地 mock 数据
|
||||
ref.invalidate(currentExercisePlanProvider);
|
||||
if (!context.mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('已创建本地计划(离线模式)'), backgroundColor: const Color(0xFFFF9800)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void _checkIn(WidgetRef ref, String itemId, BuildContext context) async {
|
||||
final service = ref.read(exerciseServiceProvider);
|
||||
await service.checkIn(itemId);
|
||||
ref.invalidate(currentExercisePlanProvider);
|
||||
if (!context.mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(
|
||||
content: Text('打卡成功 ✅'),
|
||||
backgroundColor: Color(0xFF43A047),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
class _ExercisePlanItem extends StatelessWidget {
|
||||
final String day;
|
||||
final int dayIndex;
|
||||
final bool isRest;
|
||||
final bool isDone;
|
||||
final String exerciseType;
|
||||
final int duration;
|
||||
final VoidCallback onCheckIn;
|
||||
/// 运动计划列表(含打卡)
|
||||
class ExercisePlanPage extends ConsumerStatefulWidget {
|
||||
const ExercisePlanPage({super.key});
|
||||
@override ConsumerState<ExercisePlanPage> createState() => _ExercisePlanPageState();
|
||||
}
|
||||
class _ExercisePlanPageState extends ConsumerState<ExercisePlanPage> {
|
||||
Future<List<Map<String, dynamic>>>? _future;
|
||||
@override void initState() { super.initState(); _load(); }
|
||||
void _load() => setState(() { _future = ref.read(exerciseServiceProvider).getPlans(); });
|
||||
|
||||
const _ExercisePlanItem({
|
||||
required this.day,
|
||||
required this.dayIndex,
|
||||
required this.isRest,
|
||||
required this.isDone,
|
||||
required this.exerciseType,
|
||||
required this.duration,
|
||||
required this.onCheckIn,
|
||||
});
|
||||
Future<void> _checkIn(String id, String itemId) async {
|
||||
await ref.read(exerciseServiceProvider).checkIn(itemId);
|
||||
ref.invalidate(currentExercisePlanProvider);
|
||||
_load();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final today = DateTime.now().weekday - 1;
|
||||
final isToday = dayIndex == today;
|
||||
Future<void> _deletePlan(String id) async {
|
||||
await ref.read(exerciseServiceProvider).deletePlan(id);
|
||||
_load();
|
||||
}
|
||||
|
||||
return Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: isToday ? const Color(0xFFFEFCE8) : Colors.white,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: isToday ? Border.all(color: const Color(0xFFFCD34D), width: 2) : null,
|
||||
boxShadow: [BoxShadow(color: const Color(0xFF8B9CF7).withAlpha(8), blurRadius: 4, offset: const Offset(0, 2))],
|
||||
@override Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: const Color(0xFFF8F9FC),
|
||||
appBar: AppBar(title: const Text('运动计划')),
|
||||
floatingActionButton: FloatingActionButton(onPressed: () { pushRoute(ref, 'exerciseCreate'); }, backgroundColor: const Color(0xFF6C5CE7), child: const Icon(Icons.add)),
|
||||
body: FutureBuilder<List<Map<String, dynamic>>>(
|
||||
future: _future,
|
||||
builder: (ctx, snap) {
|
||||
final plans = snap.data ?? [];
|
||||
if (plans.isEmpty) return _empty(context, '运动计划', '暂无计划,点击右下角新建');
|
||||
return ListView.builder(padding: const EdgeInsets.all(12), itemCount: plans.length, itemBuilder: (ctx, i) {
|
||||
final p = plans[i];
|
||||
final items = (p['items'] as List?)?.cast<Map<String, dynamic>>() ?? [];
|
||||
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;
|
||||
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(
|
||||
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)),
|
||||
child: Row(children: [
|
||||
Container(width: 44, height: 44, decoration: BoxDecoration(color: const Color(0xFFEDEAFF), borderRadius: BorderRadius.circular(12)), child: const Icon(Icons.directions_run, size: 22, color: Color(0xFF6C5CE7))),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
Text(exerciseName, style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600)),
|
||||
const SizedBox(height: 2),
|
||||
Text('$startDate 起 · ${items.first['durationMinutes'] ?? 0}分钟/天', style: const TextStyle(fontSize: 13, color: Color(0xFF888888))),
|
||||
const SizedBox(height: 6),
|
||||
Text('$done/$total 天已完成', style: const TextStyle(fontSize: 12, color: Color(0xFF6C5CE7))),
|
||||
])),
|
||||
GestureDetector(
|
||||
onTap: todayItem != null ? () => _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 ? const Color(0xFFEDEAFF) : const Color(0xFFF5F5F5)),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Icon(todayDone ? Icons.check_circle : Icons.check_circle_outline, size: 24, color: todayDone ? const Color(0xFF43A047) : const Color(0xFFBBBBBB)),
|
||||
),
|
||||
),
|
||||
]),
|
||||
),
|
||||
);
|
||||
});
|
||||
},
|
||||
),
|
||||
child: Row(children: [
|
||||
Container(
|
||||
width: 40,
|
||||
height: 40,
|
||||
decoration: BoxDecoration(
|
||||
color: isDone ? const Color(0xFFDCFCE7) : isRest ? const Color(0xFFF3F4F6) : const Color(0xFFF0F2FF),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: isDone
|
||||
? const Icon(Icons.check, size: 20, color: Color(0xFF43A047))
|
||||
: isRest
|
||||
? const Icon(Icons.coffee, size: 20, color: Color(0xFF999999))
|
||||
: const Icon(Icons.directions_run, size: 20, color: Color(0xFF8B9CF7)),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
Row(children: [
|
||||
Text(day, style: TextStyle(fontSize: 16, fontWeight: isToday ? FontWeight.w600 : FontWeight.w500)),
|
||||
if (isToday) const SizedBox(width: 4),
|
||||
if (isToday) const Text('(今天)', style: TextStyle(fontSize: 12, color: Color(0xFFF59E0B))),
|
||||
]),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
isRest ? '休息日,好好休息' : '$exerciseType $duration分钟',
|
||||
style: TextStyle(fontSize: 14, color: Colors.grey[500]),
|
||||
),
|
||||
]),
|
||||
),
|
||||
if (!isRest && !isDone)
|
||||
ElevatedButton(
|
||||
onPressed: onCheckIn,
|
||||
child: const Text('打卡'),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: const Color(0xFF8B9CF7),
|
||||
foregroundColor: Colors.white,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10),
|
||||
),
|
||||
),
|
||||
if (isDone)
|
||||
const Text('已完成', style: TextStyle(fontSize: 14, color: Color(0xFF43A047))),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 新建运动计划
|
||||
class ExercisePlanCreatePage extends ConsumerStatefulWidget {
|
||||
const ExercisePlanCreatePage({super.key});
|
||||
@override ConsumerState<ExercisePlanCreatePage> createState() => _ExercisePlanCreatePageState();
|
||||
}
|
||||
class _ExercisePlanCreatePageState extends ConsumerState<ExercisePlanCreatePage> {
|
||||
final _nameCtrl = TextEditingController();
|
||||
final _durationCtrl = TextEditingController(text: '30');
|
||||
DateTime _start = DateTime.now();
|
||||
DateTime _end = DateTime.now().add(const Duration(days: 6));
|
||||
@override void dispose() { _nameCtrl.dispose(); _durationCtrl.dispose(); super.dispose(); }
|
||||
|
||||
Future<void> _save() async {
|
||||
final name = _nameCtrl.text.trim();
|
||||
if (name.isEmpty) { ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('请输入运动名称'))); return; }
|
||||
final dur = int.tryParse(_durationCtrl.text) ?? 30;
|
||||
await ref.read(exerciseServiceProvider).createPlanSimple({
|
||||
'exerciseType': name,
|
||||
'durationMinutes': dur,
|
||||
'startDate': '${_start.year}-${_start.month.toString().padLeft(2,'0')}-${_start.day.toString().padLeft(2,'0')}',
|
||||
'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); }
|
||||
}
|
||||
|
||||
@override Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: const Color(0xFFF8F9FC),
|
||||
appBar: AppBar(title: const Text('新建计划')),
|
||||
body: ListView(padding: const EdgeInsets.all(16), children: [
|
||||
_field('运动名称', _nameCtrl, hint: '如: 散步、慢跑、太极'),
|
||||
const SizedBox(height: 16),
|
||||
_field('每天时长(分钟)', _durationCtrl, hint: '30', number: true),
|
||||
const SizedBox(height: 16),
|
||||
_dateField('开始日期', _start, (d) => setState(() => _start = d)),
|
||||
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: const Color(0xFF6C5CE7), foregroundColor: Colors.white, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), padding: const EdgeInsets.symmetric(vertical: 14)), child: const Text('保存计划', style: TextStyle(fontSize: 16)))),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _field(String label, TextEditingController ctrl, {String? hint, bool number = false}) {
|
||||
return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
Text(label, style: const TextStyle(fontSize: 14, color: Color(0xFF666666))),
|
||||
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: 16)),
|
||||
]);
|
||||
}
|
||||
|
||||
Widget _dateField(String label, DateTime val, ValueChanged<DateTime> onChanged) {
|
||||
return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
Text(label, style: const TextStyle(fontSize: 14, color: Color(0xFF666666))),
|
||||
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); },
|
||||
child: Container(width: double.infinity, padding: const EdgeInsets.all(14), decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(12)),
|
||||
child: Text('${val.year}-${val.month.toString().padLeft(2,'0')}-${val.day.toString().padLeft(2,'0')}', style: const TextStyle(fontSize: 16))),
|
||||
),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/// 复查列表
|
||||
@@ -788,6 +768,59 @@ class DeviceManagementPage extends ConsumerWidget {
|
||||
@override Widget build(BuildContext context, WidgetRef ref) => _empty(context, '设备管理', '暂无绑定设备');
|
||||
}
|
||||
|
||||
// ═══════════════════ 自定义滑动删除组件 ═══════════════════
|
||||
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: const Color(0xFFE53935), 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: 24)),
|
||||
),
|
||||
)),
|
||||
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]),
|
||||
|
||||
Reference in New Issue
Block a user