diff --git a/backend/src/Health.Domain/Entities/medication.cs b/backend/src/Health.Domain/Entities/medication.cs index d3f5fdc..751745a 100644 --- a/backend/src/Health.Domain/Entities/medication.cs +++ b/backend/src/Health.Domain/Entities/medication.cs @@ -17,6 +17,7 @@ public sealed class Medication public DateOnly? EndDate { get; set; } public bool IsActive { get; set; } = true; public MedicationSource Source { get; set; } + public string? Notes { get; set; } public DateTime CreatedAt { get; set; } = DateTime.UtcNow; public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; diff --git a/backend/src/Health.WebApi/Endpoints/exercise_endpoints.cs b/backend/src/Health.WebApi/Endpoints/exercise_endpoints.cs index b81f120..905702f 100644 --- a/backend/src/Health.WebApi/Endpoints/exercise_endpoints.cs +++ b/backend/src/Health.WebApi/Endpoints/exercise_endpoints.cs @@ -21,7 +21,8 @@ public static class ExerciseEndpoints plan.Id, plan.WeekStartDate, plan.CreatedAt, plan.UpdatedAt, items = plan.Items.Select(i => new { - i.Id, i.DayOfWeek, i.ExerciseType, i.DurationMinutes, + i.Id, i.DayOfWeek, + i.ExerciseType, i.DurationMinutes, i.IsCompleted, i.CompletedAt, i.IsRestDay }) }, @@ -38,34 +39,85 @@ public static class ExerciseEndpoints request.Body.Position = 0; using var json = JsonDocument.Parse(body); var root = json.RootElement; - var ws = root.GetProperty("weekStartDate").GetString()!; - var plan = new ExercisePlan { Id = Guid.NewGuid(), UserId = userId, WeekStartDate = DateOnly.Parse(ws) }; - if (root.TryGetProperty("items", out var items)) + var exerciseType = root.TryGetProperty("exerciseType", out var et) ? et.GetString()! : "运动"; + var duration = root.TryGetProperty("durationMinutes", out var dm) ? dm.GetInt32() : 30; + var startDate = DateOnly.Parse((root.TryGetProperty("startDate", out var sd) ? sd.GetString()! : DateTime.Now.ToString("yyyy-MM-dd"))); + var endDate = DateOnly.Parse((root.TryGetProperty("endDate", out var ed) ? ed.GetString()! : DateTime.Now.AddDays(6).ToString("yyyy-MM-dd"))); + var plan = new ExercisePlan { Id = Guid.NewGuid(), UserId = userId, WeekStartDate = startDate }; + for (var d = startDate; d <= endDate; d = d.AddDays(1)) { - foreach (var item in items.EnumerateArray()) + plan.Items.Add(new ExercisePlanItem { - plan.Items.Add(new ExercisePlanItem - { - Id = Guid.NewGuid(), - DayOfWeek = item.GetProperty("dayOfWeek").GetInt32(), - ExerciseType = item.TryGetProperty("exerciseType", out var et) ? et.GetString()! : "休息", - DurationMinutes = item.TryGetProperty("durationMinutes", out var dm) ? dm.GetInt32() : 0, - IsRestDay = item.TryGetProperty("isRestDay", out var rd) && rd.GetBoolean(), - }); - } + Id = Guid.NewGuid(), + DayOfWeek = (int)d.DayOfWeek, + ExerciseType = exerciseType, + DurationMinutes = duration, + IsRestDay = false, + }); } db.ExercisePlans.Add(plan); await db.SaveChangesAsync(ct); return Results.Ok(new { code = 0, data = new { plan.Id }, message = (string?)null }); }); + group.MapGet("/", async (HttpContext http, AppDbContext db) => + { + var userId = GetUserId(http); + var plans = await db.ExercisePlans.Where(p => p.UserId == userId) + .OrderByDescending(p => p.WeekStartDate).Take(20) + .Select(p => new + { + p.Id, p.WeekStartDate, p.CreatedAt, + totalDays = p.Items.Count, + completedDays = p.Items.Count(i => i.IsCompleted), + items = p.Items.OrderBy(i => i.DayOfWeek).Select(i => new + { + i.Id, i.DayOfWeek, + i.ExerciseType, i.DurationMinutes, + i.IsCompleted, i.IsRestDay + }) + }).ToListAsync(); + return Results.Ok(new { code = 0, data = plans, message = (string?)null }); + }); + + group.MapGet("/{id:guid}", async (Guid id, HttpContext http, AppDbContext db) => + { + var userId = GetUserId(http); + var plan = await db.ExercisePlans.Include(p => p.Items) + .FirstOrDefaultAsync(p => p.Id == id && p.UserId == userId); + if (plan == null) return Results.Ok(new { code = 40004, message = "不存在" }); + return Results.Ok(new + { + code = 0, + data = new + { + plan.Id, plan.WeekStartDate, plan.CreatedAt, + items = plan.Items.OrderBy(i => i.DayOfWeek).Select(i => new + { + i.Id, i.DayOfWeek, i.ExerciseType, i.DurationMinutes, + i.IsCompleted, i.CompletedAt, i.IsRestDay + }) + }, + message = (string?)null + }); + }); + + group.MapDelete("/{id:guid}", async (Guid id, HttpContext http, AppDbContext db, CancellationToken ct) => + { + var userId = GetUserId(http); + var plan = await db.ExercisePlans.Include(p => p.Items).FirstOrDefaultAsync(p => p.Id == id && p.UserId == userId, ct); + if (plan != null) { db.ExercisePlans.Remove(plan); await db.SaveChangesAsync(ct); } + return Results.Ok(new { code = 0, data = new { success = true }, message = (string?)null }); + }); + group.MapPost("/items/{itemId:guid}/checkin", async (Guid itemId, HttpContext http, AppDbContext db, CancellationToken ct) => { var item = await db.ExercisePlanItems.FindAsync([itemId], ct); if (item == null) return Results.Ok(new { code = 40004, message = "不存在" }); - item.IsCompleted = true; item.CompletedAt = DateTime.UtcNow; + item.IsCompleted = !item.IsCompleted; + item.CompletedAt = item.IsCompleted ? DateTime.UtcNow : null; await db.SaveChangesAsync(ct); - return Results.Ok(new { code = 0, data = new { success = true }, message = (string?)null }); + return Results.Ok(new { code = 0, data = new { completed = item.IsCompleted }, message = (string?)null }); }); } diff --git a/backend/src/Health.WebApi/Endpoints/medication_endpoints.cs b/backend/src/Health.WebApi/Endpoints/medication_endpoints.cs index f61971e..cc5f2ce 100644 --- a/backend/src/Health.WebApi/Endpoints/medication_endpoints.cs +++ b/backend/src/Health.WebApi/Endpoints/medication_endpoints.cs @@ -6,10 +6,19 @@ public static class MedicationEndpoints { var group = app.MapGroup("/api/medications").RequireAuthorization(); - group.MapGet("/", async (HttpContext http, AppDbContext db, CancellationToken ct) => + group.MapGet("/", async (string? filter, HttpContext http, AppDbContext db, CancellationToken ct) => { var userId = GetUserId(http); - var meds = await db.Medications.Where(m => m.UserId == userId).OrderByDescending(m => m.CreatedAt).ToListAsync(ct); + var query = db.Medications.Where(m => m.UserId == userId); + if (filter == "active") query = query.Where(m => m.IsActive); + else if (filter == "inactive") query = query.Where(m => !m.IsActive); + var meds = await query.OrderByDescending(m => m.CreatedAt).Select(m => new + { + m.Id, m.Name, m.Dosage, Frequency = m.Frequency.ToString(), + m.TimeOfDay, m.StartDate, m.EndDate, m.IsActive, m.Notes, + Source = m.Source.ToString(), m.CreatedAt, m.UpdatedAt, + todayTaken = m.Logs.Any(l => l.CreatedAt.Date == DateTime.UtcNow.Date && l.Status == MedicationLogStatus.Taken) + }).ToListAsync(ct); return Results.Ok(new { code = 0, data = meds, message = (string?)null }); }); @@ -37,6 +46,7 @@ public static class MedicationEndpoints med.StartDate = DateOnly.TryParse(sd.GetString(), out var d) ? d : null; if (root.TryGetProperty("endDate", out var ed)) med.EndDate = DateOnly.TryParse(ed.GetString(), out var d2) ? d2 : null; + if (root.TryGetProperty("notes", out var nt)) med.Notes = nt.GetString(); db.Medications.Add(med); await db.SaveChangesAsync(ct); return Results.Ok(new { code = 0, data = new { med.Id }, message = (string?)null }); @@ -59,6 +69,7 @@ public static class MedicationEndpoints if (root.TryGetProperty("timeOfDay", out var tod2)) med.TimeOfDay = tod2.EnumerateArray().Select(t => TimeOnly.Parse(t.GetString()!)).ToList(); if (root.TryGetProperty("startDate", out var sd2) && DateOnly.TryParse(sd2.GetString(), out var d3)) med.StartDate = d3; if (root.TryGetProperty("endDate", out var ed2) && DateOnly.TryParse(ed2.GetString(), out var d4)) med.EndDate = d4; + if (root.TryGetProperty("notes", out var nt2)) med.Notes = nt2.GetString(); med.UpdatedAt = DateTime.UtcNow; await db.SaveChangesAsync(ct); return Results.Ok(new { code = 0, data = new { success = true }, message = (string?)null }); @@ -75,6 +86,13 @@ public static class MedicationEndpoints group.MapPost("/{id:guid}/confirm", async (Guid id, HttpContext http, AppDbContext db, CancellationToken ct) => { var userId = GetUserId(http); + var today = DateOnly.FromDateTime(DateTime.UtcNow); + var existing = await db.MedicationLogs.FirstOrDefaultAsync(l => l.MedicationId == id && l.CreatedAt.Date == DateTime.UtcNow.Date && l.Status == MedicationLogStatus.Taken, ct); + if (existing != null) { + db.MedicationLogs.Remove(existing); + await db.SaveChangesAsync(ct); + return Results.Ok(new { code = 0, data = new { taken = false }, message = (string?)null }); + } var log = new MedicationLog { Id = Guid.NewGuid(), MedicationId = id, UserId = userId, @@ -82,7 +100,7 @@ public static class MedicationEndpoints }; db.MedicationLogs.Add(log); await db.SaveChangesAsync(ct); - return Results.Ok(new { code = 0, data = new { success = true }, message = (string?)null }); + return Results.Ok(new { code = 0, data = new { taken = true }, message = (string?)null }); }); group.MapGet("/reminders", async (HttpContext http, AppDbContext db, CancellationToken ct) => diff --git a/health_app/lib/core/app_router.dart b/health_app/lib/core/app_router.dart index e7d94e9..903d13b 100644 --- a/health_app/lib/core/app_router.dart +++ b/health_app/lib/core/app_router.dart @@ -44,6 +44,8 @@ Widget buildPage(RouteInfo route) { return DoctorChatPage(id: params['id']!); case 'exercisePlan': return const ExercisePlanPage(); + case 'exerciseCreate': + return const ExercisePlanCreatePage(); case 'dietRecords': return const DietRecordListPage(); case 'dietCapture': @@ -68,6 +70,8 @@ Widget buildPage(RouteInfo route) { return StaticTextPage(type: params['type']!); case 'servicePackageDetail': return ServicePackageDetailPage(packageId: params['id']!); + case 'dietDetail': + return DietRecordDetailPage(id: params['id']!); default: return const LoginPage(); } diff --git a/health_app/lib/pages/diet/diet_capture_page.dart b/health_app/lib/pages/diet/diet_capture_page.dart index 6566d91..7be9db1 100644 --- a/health_app/lib/pages/diet/diet_capture_page.dart +++ b/health_app/lib/pages/diet/diet_capture_page.dart @@ -5,6 +5,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:image_picker/image_picker.dart'; import '../../core/navigation_provider.dart'; +import '../../core/app_theme.dart'; import '../../providers/auth_provider.dart'; import '../../utils/sse_handler.dart'; @@ -164,6 +165,11 @@ class DietNotifier extends Notifier { state = state.copyWith(foods: foods); } + void updateFoodPortion(String id, String portion) { + final foods = state.foods.map((f) => f.id == id ? FoodItem(id: f.id, name: f.name, portion: portion, calories: f.calories, selected: f.selected) : f).toList(); + state = state.copyWith(foods: foods); + } + void updateFoodCalories(String id, int calories) { final foods = state.foods.map((f) => f.id == id ? FoodItem(id: f.id, name: f.name, portion: f.portion, calories: calories, selected: f.selected) : f).toList(); state = state.copyWith(foods: foods); @@ -304,300 +310,425 @@ class _DietCapturePageState extends ConsumerState { } } + // ─────────── 设计常量(与项目整体色调一致)─────────── + static const _kPrimary = AppTheme.primary; // #6C5CE7 + static const _kPrimaryLight = AppTheme.primaryLight; // #EDEAFF + static const _kPageBg = AppTheme.bg; // #F8F9FC + static const _kSurface = AppTheme.surface; // white + static const _kText = AppTheme.text; // #2D2B32 + static const _kSubText = AppTheme.textSub; // #8A8892 + static const _kBorder = AppTheme.border; // #EAEAF0 + static const _kWarning = AppTheme.warning; // #F5A623 + Widget _buildResultView(BuildContext context, WidgetRef ref) { final state = ref.watch(dietProvider); final totalCalories = state.foods.where((f) => f.selected).fold(0, (sum, f) => sum + f.calories); - return SingleChildScrollView( - padding: const EdgeInsets.all(16), - child: Column(children: [ - _buildImagePreview(state.imagePath!), - const SizedBox(height: 16), - _buildMealSelector(context, ref), - const SizedBox(height: 16), - if (state.isAnalyzing) _buildAnalyzingIndicator(state) else ...[ - _buildFoodList(context, ref), - if (state.foods.isNotEmpty) ...[ - const SizedBox(height: 16), - _buildNutritionSummary(totalCalories), - if (state.commentary != null && state.commentary!.isNotEmpty) ...[ + return Container( + color: _kPageBg, + child: SingleChildScrollView( + child: Column(children: [ + _buildImagePreview(state.imagePath!), + const SizedBox(height: 20), + _buildMealSelector(ref), + const SizedBox(height: 16), + if (state.isAnalyzing) + _buildAnalyzingIndicator(state) + else ...[ + if (state.foods.isNotEmpty) ...[ + _buildFoodList(ref), const SizedBox(height: 16), - _buildAiCommentary(state.commentary!), + _buildNutritionSummary(totalCalories), + if (state.commentary != null && state.commentary!.isNotEmpty) ...[ + const SizedBox(height: 16), + _buildAiCommentary(state.commentary!), + ], ], const SizedBox(height: 24), _buildSubmitButton(context, ref), + const SizedBox(height: 16), ], - ], - ]), - ); - } - - Widget _buildImagePreview(String path) { - return Container( - height: 200, - decoration: BoxDecoration( - color: const Color(0xFFF5F5F5), - borderRadius: BorderRadius.circular(20), - image: DecorationImage(image: FileImage(File(path)), fit: BoxFit.cover), + ]), ), ); } - Widget _buildMealSelector(BuildContext context, WidgetRef ref) { - final state = ref.watch(dietProvider); - final meals = [ - {'type': 'breakfast', 'label': '早餐', 'icon': '🌅'}, - {'type': 'lunch', 'label': '午餐', 'icon': '☀️'}, - {'type': 'dinner', 'label': '晚餐', 'icon': '🌙'}, - {'type': 'snack', 'label': '加餐', 'icon': '🍪'}, - ]; - - return Column(children: [ - const Text('选择餐次', style: TextStyle(fontSize: 14, color: Color(0xFF666666))), - const SizedBox(height: 12), - Row(children: meals.map((meal) { - final isSelected = state.mealType == meal['type']; - return Expanded( - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 4), - child: ElevatedButton( - onPressed: () => ref.read(dietProvider.notifier).setMealType(meal['type']!), - child: Column(children: [ - Text(meal['icon']!, style: const TextStyle(fontSize: 20)), - const SizedBox(height: 4), - Text(meal['label']!, style: TextStyle(fontSize: 12, color: isSelected ? Colors.white : const Color(0xFF8B9CF7))), - ]), - style: ElevatedButton.styleFrom( - backgroundColor: isSelected ? const Color(0xFF8B9CF7) : const Color(0xFFF0F2FF), - foregroundColor: isSelected ? Colors.white : const Color(0xFF8B9CF7), - elevation: 0, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), - padding: const EdgeInsets.symmetric(vertical: 12), + // ─────────── 图片预览 ─────────── + Widget _buildImagePreview(String path) { + return Stack( + children: [ + Container( + height: 220, + decoration: BoxDecoration( + color: const Color(0xFFE0E0E0), + image: DecorationImage(image: FileImage(File(path)), fit: BoxFit.cover), + ), + ), + Positioned( + left: 0, right: 0, bottom: 0, + child: Container( + height: 60, + decoration: const BoxDecoration( + gradient: LinearGradient( + colors: [Colors.transparent, Color(0x40000000)], + begin: Alignment.topCenter, + end: Alignment.bottomCenter, ), ), ), - ); - }).toList()), + ), + ], + ); + } + + // ─────────── 餐次选择器 ─────────── + Widget _buildMealSelector(WidgetRef ref) { + final state = ref.watch(dietProvider); + final meals = [ + _MealData(Icons.wb_sunny_outlined, '早餐', 'breakfast'), + _MealData(Icons.wb_cloudy_outlined, '午餐', 'lunch'), + _MealData(Icons.nightlight_round, '晚餐', 'dinner'), + _MealData(Icons.local_cafe_outlined, '加餐', 'snack'), + ]; + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Row( + children: meals.map((m) { + final isSelected = state.mealType == m.type; + return Expanded( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 4), + child: GestureDetector( + onTap: () => ref.read(dietProvider.notifier).setMealType(m.type), + child: AnimatedContainer( + duration: const Duration(milliseconds: 200), + padding: const EdgeInsets.symmetric(vertical: 10), + decoration: BoxDecoration( + color: isSelected ? _kPrimary : _kSurface, + borderRadius: BorderRadius.circular(14), + border: Border.all(color: isSelected ? _kPrimary : _kBorder), + boxShadow: isSelected + ? [BoxShadow(color: _kPrimary.withAlpha(30), blurRadius: 6, offset: const Offset(0, 2))] + : null, + ), + child: Column(mainAxisSize: MainAxisSize.min, children: [ + Icon(m.icon, size: 20, color: isSelected ? Colors.white : _kSubText), + const SizedBox(height: 4), + Text(m.label, style: TextStyle(fontSize: 12, fontWeight: FontWeight.w600, color: isSelected ? Colors.white : _kSubText)), + ]), + ), + ), + ), + ); + }).toList(), + ), + ); + } + + // ─────────── 分析中指示器 ─────────── + Widget _buildAnalyzingIndicator(DietState state) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 40), + child: Center( + child: Column(children: [ + SizedBox( + width: 56, height: 56, + child: Stack( + alignment: Alignment.center, + children: [ + const CircularProgressIndicator(strokeWidth: 3, color: _kPrimary), + const Icon(Icons.restaurant, size: 22, color: _kPrimary), + ], + ), + ), + const SizedBox(height: 20), + const Text('正在识别食物...', style: TextStyle(fontSize: 15, color: _kSubText)), + if (state.errorMessage != null) ...[ + const SizedBox(height: 8), + Text(state.errorMessage!, style: const TextStyle(fontSize: 13, color: AppTheme.error), textAlign: TextAlign.center), + ], + ]), + ), + ); + } + + // ─────────── 食物列表 ─────────── + Widget _buildFoodList(WidgetRef ref) { + final state = ref.watch(dietProvider); + final totalCal = state.foods.where((f) => f.selected).fold(0, (s, f) => s + f.calories); + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Container( + decoration: BoxDecoration( + color: _kSurface, + borderRadius: BorderRadius.circular(16), + boxShadow: [AppTheme.shadowLight], + ), + child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Padding( + padding: const EdgeInsets.fromLTRB(16, 14, 8, 0), + child: Row(children: [ + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration(color: _kPrimaryLight, borderRadius: BorderRadius.circular(8)), + child: const Text('识别结果', style: TextStyle(fontSize: 13, fontWeight: FontWeight.w600, color: _kPrimary)), + ), + const Spacer(), + Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + decoration: BoxDecoration(color: const Color(0xFFFFF8EE), borderRadius: BorderRadius.circular(8)), + child: Text('共 $totalCal kcal', style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w600, color: _kWarning)), + ), + IconButton( + icon: const Icon(Icons.add_circle_outline, size: 22, color: _kPrimary), + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + onPressed: () => ref.read(dietProvider.notifier).addFood(), + ), + ]), + ), + const SizedBox(height: 8), + ...state.foods.map((food) => _buildFoodItem(ref, food)), + const SizedBox(height: 12), + ]), + ), + ); + } + + Widget _buildFoodItem(WidgetRef ref, FoodItem food) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), + child: AnimatedContainer( + duration: const Duration(milliseconds: 200), + decoration: BoxDecoration( + color: food.selected ? const Color(0xFFF6F4FF) : const Color(0xFFFAFAFA), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: food.selected ? _kPrimary.withAlpha(30) : const Color(0xFFEEEEEE)), + ), + child: Padding( + padding: const EdgeInsets.fromLTRB(12, 10, 6, 10), + child: Row(children: [ + GestureDetector( + onTap: () => ref.read(dietProvider.notifier).toggleFood(food.id), + child: Container( + width: 22, height: 22, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: food.selected ? _kPrimary : Colors.white, + border: Border.all(color: food.selected ? _kPrimary : const Color(0xFFCCCCCC), width: 2), + ), + child: food.selected ? const Icon(Icons.check, size: 14, color: Colors.white) : null, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + TextField( + controller: TextEditingController(text: food.name), + onChanged: (v) => ref.read(dietProvider.notifier).updateFoodName(food.id, v), + style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w600, color: _kText), + decoration: const InputDecoration( + isDense: true, + contentPadding: EdgeInsets.zero, + border: InputBorder.none, + ), + ), + const SizedBox(height: 2), + Row(children: [ + Expanded( + flex: 3, + child: TextField( + controller: TextEditingController(text: food.portion), + onChanged: (v) => ref.read(dietProvider.notifier).updateFoodPortion(food.id, v), + style: const TextStyle(fontSize: 12, color: _kSubText), + decoration: const InputDecoration( + isDense: true, + contentPadding: EdgeInsets.zero, + border: InputBorder.none, + hintText: '份量', + hintStyle: TextStyle(fontSize: 12, color: AppTheme.textHint), + ), + ), + ), + const SizedBox(width: 8), + Expanded( + flex: 2, + child: TextField( + controller: TextEditingController(text: food.calories.toString()), + onChanged: (v) => ref.read(dietProvider.notifier).updateFoodCalories(food.id, int.tryParse(v) ?? 0), + keyboardType: TextInputType.number, + style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w600, color: _kWarning), + textAlign: TextAlign.right, + decoration: const InputDecoration( + isDense: true, + contentPadding: EdgeInsets.zero, + border: InputBorder.none, + hintText: '0', + hintStyle: TextStyle(fontSize: 12), + ), + ), + ), + const SizedBox(width: 2), + const Text('kcal', style: TextStyle(fontSize: 11, color: _kSubText)), + ]), + ]), + ), + IconButton( + icon: const Icon(Icons.close, size: 16, color: Color(0xFFBBBBBB)), + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + onPressed: () => ref.read(dietProvider.notifier).removeFood(food.id), + ), + ]), + ), + ), + ); + } + + // ─────────── 营养摘要 ─────────── + Widget _buildNutritionSummary(int totalCalories) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Container( + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + gradient: const LinearGradient(colors: [_kPrimary, Color(0xFF8B7CF0)], begin: Alignment.topLeft, end: Alignment.bottomRight), + borderRadius: BorderRadius.circular(16), + boxShadow: [BoxShadow(color: _kPrimary.withAlpha(40), blurRadius: 10, offset: const Offset(0, 4))], + ), + child: Row(children: [ + SizedBox( + width: 56, height: 56, + child: Stack( + alignment: Alignment.center, + children: [ + SizedBox( + width: 56, height: 56, + child: CircularProgressIndicator( + value: (totalCalories / 700).clamp(0.0, 1.0), + strokeWidth: 4, + backgroundColor: Colors.white24, + color: Colors.white, + ), + ), + Column(mainAxisSize: MainAxisSize.min, children: [ + Text('$totalCalories', style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w800, color: Colors.white)), + Text('kcal', style: const TextStyle(fontSize: 10, color: Colors.white70)), + ]), + ], + ), + ), + const SizedBox(width: 16), + Expanded( + child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + const Text('本餐热量', style: TextStyle(fontSize: 14, color: Colors.white70)), + const SizedBox(height: 4), + Row(children: [ + Expanded(child: _macroBar('碳水', 0.55, const Color(0xFFFFF9C4))), + const SizedBox(width: 8), + Expanded(child: _macroBar('蛋白', 0.25, const Color(0xFFD4C8FC))), + const SizedBox(width: 8), + Expanded(child: _macroBar('脂肪', 0.20, const Color(0xFFFFCDD2))), + ]), + ]), + ), + ]), + ), + ); + } + + Widget _macroBar(String label, double ratio, Color color) { + return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Row(children: [ + Container(width: 6, height: 6, decoration: BoxDecoration(color: color, shape: BoxShape.circle)), + const SizedBox(width: 4), + Text(label, style: const TextStyle(fontSize: 10, color: Colors.white70)), + ]), + const SizedBox(height: 3), + ClipRRect( + borderRadius: BorderRadius.circular(2), + child: LinearProgressIndicator(value: ratio, minHeight: 4, backgroundColor: Colors.white24, color: color), + ), ]); } - Widget _buildAnalyzingIndicator(DietState state) { - return Center( - child: Column(children: [ - Container( - width: 60, - height: 60, - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: const Color(0xFFEDEAFF), - borderRadius: BorderRadius.circular(30), - ), - child: const CircularProgressIndicator(strokeWidth: 3, color: Color(0xFF6C5CE7)), - ), - const SizedBox(height: 16), - const Text('AI 正在识别食物...', style: TextStyle(fontSize: 16, color: Color(0xFF666666))), - if (state.errorMessage != null) ...[ - const SizedBox(height: 8), - Text(state.errorMessage!, style: const TextStyle(fontSize: 13, color: Color(0xFFE53935)), textAlign: TextAlign.center), - ], - ]), - ); - } - - Widget _buildFoodList(BuildContext context, WidgetRef ref) { - final state = ref.watch(dietProvider); - - return Container( - decoration: BoxDecoration( - color: const Color(0xFFFEFEFF), - borderRadius: BorderRadius.circular(20), - border: Border.all(color: const Color(0xFFD8DCFD), width: 1.5), - ), - child: Column(children: [ - Padding( - padding: const EdgeInsets.all(16), - child: Row(children: [ - const Text('🍽️', style: TextStyle(fontSize: 20)), - const SizedBox(width: 8), - const Text('识别结果', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600)), - const Spacer(), - IconButton( - icon: const Icon(Icons.add, size: 20, color: Color(0xFF8B9CF7)), - onPressed: () => ref.read(dietProvider.notifier).addFood(), - ), - ]), - ), - ...state.foods.map((food) => _buildFoodItem(context, ref, food)), - ]), - ); - } - - Widget _buildFoodItem(BuildContext context, WidgetRef ref, FoodItem food) { - return Container( - margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: food.selected ? const Color(0xFFF0F2FF) : const Color(0xFFF5F5F5), - borderRadius: BorderRadius.circular(16), - ), - child: Row(children: [ - Checkbox( - value: food.selected, - onChanged: (v) => ref.read(dietProvider.notifier).toggleFood(food.id), - activeColor: const Color(0xFF8B9CF7), - ), - const SizedBox(width: 8), - Expanded( - child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ - TextField( - decoration: const InputDecoration(border: InputBorder.none, hintText: '食物名称'), - controller: TextEditingController(text: food.name), - onChanged: (v) => ref.read(dietProvider.notifier).updateFoodName(food.id, v), - style: const TextStyle(fontSize: 16), - ), - if (food.portion.isNotEmpty) - Text(food.portion, style: const TextStyle(fontSize: 12, color: Color(0xFF999999))), - Row(children: [ - const Text('热量:', style: TextStyle(fontSize: 12, color: Color(0xFF999999))), - SizedBox( - width: 60, - child: TextField( - decoration: const InputDecoration(border: InputBorder.none, hintText: '0'), - controller: TextEditingController(text: food.calories.toString()), - keyboardType: TextInputType.number, - onChanged: (v) => ref.read(dietProvider.notifier).updateFoodCalories(food.id, int.tryParse(v) ?? 0), - style: TextStyle(fontSize: 12, color: const Color(0xFF8B9CF7)), - ), - ), - const Text('kcal', style: TextStyle(fontSize: 12, color: Color(0xFF999999))), - ]), - ]), - ), - IconButton( - icon: const Icon(Icons.delete, size: 18, color: Color(0xFFCCCCCC)), - onPressed: () => ref.read(dietProvider.notifier).removeFood(food.id), - ), - ]), - ); - } - - Widget _buildNutritionSummary(int totalCalories) { - return Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: const Color(0xFFF0F2FF), - borderRadius: BorderRadius.circular(16), - ), - child: Row(children: [ - const Icon(Icons.fireplace, size: 28, color: Color(0xFFFF6B35)), - const SizedBox(width: 12), - Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ - const Text('总热量', style: TextStyle(fontSize: 14, color: Color(0xFF666666))), - Text('$totalCalories kcal', style: const TextStyle(fontSize: 24, fontWeight: FontWeight.w600)), - ]), - const Spacer(), - Column(crossAxisAlignment: CrossAxisAlignment.end, children: [ - const Text('推荐摄入量', style: TextStyle(fontSize: 12, color: Color(0xFF999999))), - const Text('午餐约 500-700 kcal', style: TextStyle(fontSize: 12, color: Color(0xFF999999))), - ]), - ]), - ); - } - - Widget _buildHealthScore(int score) { - return Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: const Color(0xFFFEFEFF), - borderRadius: BorderRadius.circular(20), - border: Border.all(color: const Color(0xFFD8DCFD), width: 1.5), - ), - child: Column(children: [ - const Text('🥗 健康评分', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600)), - const SizedBox(height: 12), - Row( - mainAxisAlignment: MainAxisAlignment.center, - children: List.generate(5, (i) => Icon( - Icons.star, - size: 36, - color: i < score ? const Color(0xFFFFB800) : Colors.grey[200], - )), - ), - const SizedBox(height: 12), - Text(_getScoreComment(score), style: TextStyle(fontSize: 14, color: _getScoreColor(score))), - ]), - ); - } - + // ─────────── AI 点评 ─────────── Widget _buildAiCommentary(String text) { - return Container( - padding: const EdgeInsets.all(14), - decoration: BoxDecoration( - gradient: const LinearGradient(colors: [Color(0xFFEDEAFF), Color(0xFFF5F3FF)], begin: Alignment.topLeft, end: Alignment.bottomRight), - borderRadius: BorderRadius.circular(16), + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Container( + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: _kSurface, + borderRadius: BorderRadius.circular(14), + border: Border.all(color: _kBorder), + ), + child: Row(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Container( + width: 32, height: 32, + decoration: BoxDecoration(color: _kPrimaryLight, borderRadius: BorderRadius.circular(8)), + child: const Icon(Icons.auto_awesome, size: 16, color: _kPrimary), + ), + const SizedBox(width: 10), + Expanded(child: Text(text, style: const TextStyle(fontSize: 13, color: _kText, height: 1.6))), + ]), ), - child: Row(crossAxisAlignment: CrossAxisAlignment.start, children: [ - Container(width: 32, height: 32, decoration: BoxDecoration(color: const Color(0xFF6C5CE7).withAlpha(20), borderRadius: BorderRadius.circular(10)), child: const Icon(Icons.auto_awesome, size: 16, color: Color(0xFF6C5CE7))), - const SizedBox(width: 10), - Expanded(child: Text(text, style: const TextStyle(fontSize: 14, color: Color(0xFF444444), height: 1.5))), - ]), ); } - String _getScoreComment(int score) { - switch (score) { - case 1: return '饮食不太健康,建议多吃蔬菜'; - case 2: return '需要改善,减少油腻食物'; - case 3: return '还不错,继续保持均衡饮食'; - case 4: return '很健康!营养搭配合理'; - case 5: return '非常健康!饮食管理很棒'; - default: return '请完善食物信息'; - } - } - - Color _getScoreColor(int score) { - switch (score) { - case 1: return const Color(0xFFE53935); - case 2: return const Color(0xFFF9A825); - case 3: return const Color(0xFF8B9CF7); - case 4: return const Color(0xFF43A047); - case 5: return const Color(0xFF00C853); - default: return Colors.grey[400]!; - } - } - + // ─────────── 保存按钮 ─────────── Widget _buildSubmitButton(BuildContext context, WidgetRef ref) { - return SizedBox( - width: double.infinity, - child: ElevatedButton( - onPressed: () async { - try { - await ref.read(dietProvider.notifier).saveRecord(); - if (context.mounted) { - ScaffoldMessenger.of(context).showSnackBar(const SnackBar( - content: Text('饮食记录已保存 ✅'), - backgroundColor: Color(0xFF43A047), - )); - popRoute(ref); + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: SizedBox( + width: double.infinity, + height: 50, + child: ElevatedButton( + onPressed: () async { + try { + await ref.read(dietProvider.notifier).saveRecord(); + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar(SnackBar( + content: const Text('饮食记录已保存'), + backgroundColor: _kPrimary, + behavior: SnackBarBehavior.floating, + duration: const Duration(seconds: 2), + )); + popRoute(ref); + } + } catch (e) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar(SnackBar( + content: Text('保存失败: $e'), + backgroundColor: AppTheme.error, + behavior: SnackBarBehavior.floating, + )); + } } - } catch (e) { - if (context.mounted) { - ScaffoldMessenger.of(context).showSnackBar(SnackBar( - content: Text('保存失败: $e'), - backgroundColor: const Color(0xFFE53935), - )); - } - } - }, - child: const Text('保存记录'), - style: ElevatedButton.styleFrom( - backgroundColor: const Color(0xFF8B9CF7), - foregroundColor: Colors.white, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)), - padding: const EdgeInsets.symmetric(vertical: 16), - textStyle: const TextStyle(fontSize: 16), + }, + style: ElevatedButton.styleFrom( + backgroundColor: _kPrimary, + foregroundColor: Colors.white, + elevation: 0, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(AppTheme.radiusMd)), + textStyle: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600), + ), + child: const Row(mainAxisAlignment: MainAxisAlignment.center, children: [ + Icon(Icons.check_circle_outline, size: 20), + SizedBox(width: 8), + Text('保存记录'), + ]), ), ), ); } +} + +class _MealData { + final IconData icon; + final String label; + final String type; + const _MealData(this.icon, this.label, this.type); } \ No newline at end of file diff --git a/health_app/lib/pages/home/widgets/chat_messages_view.dart b/health_app/lib/pages/home/widgets/chat_messages_view.dart index 675cfab..f14ffb7 100644 --- a/health_app/lib/pages/home/widgets/chat_messages_view.dart +++ b/health_app/lib/pages/home/widgets/chat_messages_view.dart @@ -175,11 +175,7 @@ class ChatMessagesView extends ConsumerWidget { Widget _agentActionBtn(_AgentAction a, double screenWidth, BuildContext context, WidgetRef ref, _AgentColors colors) { return InkWell( onTap: () { - if (a.action == 'createPlan') { - _createExercisePlan(ref, context); - } else if (a.action == 'checkIn') { - _exerciseCheckIn(ref, context); - } else if (a.label == '服药打卡') { + if (a.label == '服药打卡') { _medicationCheckIn(ref, context); } else if (a.route != null) { if (a.route == 'camera' || a.route == 'gallery') { @@ -1200,73 +1196,6 @@ class ChatMessagesView extends ConsumerWidget { } } - static void _createExercisePlan(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)), - ); - pushRoute(ref, 'exercisePlan'); - } catch (e) { - if (!context.mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('创建失败:$e'), backgroundColor: Colors.red), - ); - } - } - - static void _exerciseCheckIn(WidgetRef ref, BuildContext context) async { - try { - final plan = await ref.read(currentExercisePlanProvider.future); - if (plan == null || plan.isEmpty) { - if (!context.mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('请先创建运动计划'), backgroundColor: Color(0xFFFF9800)), - ); - return; - } - final items = (plan['items'] as List?)?.cast>() ?? []; - final today = DateTime.now().weekday - 1; - final todayItem = items.cast?>().firstWhere( - (i) => i?['dayOfWeek'] == today && i?['isRestDay'] != true, - orElse: () => null, - ); - if (todayItem == null) { - if (!context.mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('今天休息日'), backgroundColor: Color(0xFFFF9800)), - ); - return; - } - final service = ref.read(exerciseServiceProvider); - await service.checkIn(todayItem['id']?.toString() ?? ''); - ref.invalidate(currentExercisePlanProvider); - if (!context.mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('打卡成功'), backgroundColor: Color(0xFF43A047)), - ); - } catch (e) { - if (!context.mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('打卡失败:$e'), backgroundColor: Colors.red), - ); - } - } - static _AgentColors _agentColors(ActiveAgent agent) { return switch (agent) { ActiveAgent.consultation => _AgentColors( @@ -1401,13 +1330,27 @@ class ChatMessagesView extends ConsumerWidget { tasks.add(_taskRow(context, Icons.medication_rounded, '暂无用药提醒', status: 'pending')); } - // 3. 运动 - final exOverdue = now.hour >= 18; - tasks.add(_taskRow( - context, Icons.directions_run, '今日待运动:散步 30 分钟', - status: exOverdue ? 'overdue' : 'pending', - onTap: () => pushRoute(ref, 'exercisePlan'), - )); + // 3. 运动 — 从所有计划中找今日待打卡项 + final plans = ref.read(exerciseServiceProvider).getPlans(); + plans.then((list) { + for (final p in list) { + final items = (p['items'] as List?)?.cast>() ?? []; + final today = now.weekday - 1; + final todayItem = items.cast?>().firstWhere( + (i) => i?['dayOfWeek'] == today, orElse: () => null); + if (todayItem != null) { + final done = todayItem['isCompleted'] == true; + final name = items.isNotEmpty ? (items.first['exerciseType']?.toString() ?? '运动') : '运动'; + final dur = items.isNotEmpty ? (items.first['durationMinutes']?.toString() ?? '30') : '30'; + tasks.add(_taskRow(context, Icons.directions_run, + '$name $dur分钟', + status: done ? 'done' : (now.hour >= 18 ? 'overdue' : 'pending'), + onTap: () => pushRoute(ref, 'exercisePlan'), + )); + break; + } + } + }); // 4. 测量 tasks.add(_taskRow( @@ -1529,8 +1472,7 @@ final _agentActions = >{ ], ActiveAgent.exercise: [ _AgentAction(label: '查看计划', icon: Icons.calendar_month_outlined, isWide: true, route: 'exercisePlan'), - _AgentAction(label: '新建计划', icon: Icons.add_task_outlined, isWide: true, action: 'createPlan'), - _AgentAction(label: '今日打卡', icon: Icons.fact_check_outlined, isWide: true, action: 'checkIn'), + _AgentAction(label: '新建计划', icon: Icons.add_task_outlined, isWide: true, route: 'exerciseCreate'), ], }; diff --git a/health_app/lib/pages/medication/medication_edit_page.dart b/health_app/lib/pages/medication/medication_edit_page.dart index 7f0b844..0e62645 100644 --- a/health_app/lib/pages/medication/medication_edit_page.dart +++ b/health_app/lib/pages/medication/medication_edit_page.dart @@ -3,526 +3,133 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../core/navigation_provider.dart'; import '../../providers/data_providers.dart'; -class _MedicationItem { - String name = ''; - String dosage = ''; - String frequency = '每日1次'; - List times = [const TimeOfDay(hour: 8, minute: 0)]; - DateTime startDate = DateTime.now(); - DateTime? endDate; - int weekday = 1; -} - -const _frequencies = ['每日1次', '每日2次', '每日3次', '每周1次', '按需服用']; -const _weekdays = ['周一', '周二', '周三', '周四', '周五', '周六', '周日']; - class MedicationEditPage extends ConsumerStatefulWidget { - final String? medicationId; - const MedicationEditPage({super.key, this.medicationId}); - - @override - ConsumerState createState() => _MedicationEditPageState(); + final String? id; + const MedicationEditPage({super.key, this.id}); + @override ConsumerState createState() => _MedicationEditPageState(); } - class _MedicationEditPageState extends ConsumerState { - final _items = <_MedicationItem>[]; - final _nameCtrls = []; - final _doseCtrls = []; + final _nameCtrl = TextEditingController(); + final _dosageCtrl = TextEditingController(); + final _notesCtrl = TextEditingController(); + int _timesPerDay = 2; + List _times = [const TimeOfDay(hour: 8, minute: 0), const TimeOfDay(hour: 18, minute: 0)]; + DateTime _start = DateTime.now(); + DateTime? _end; + bool _loading = false; - @override - void initState() { - super.initState(); - _addItem(); + @override void initState() { super.initState(); if (widget.id != null) _load(); } + @override void dispose() { _nameCtrl.dispose(); _dosageCtrl.dispose(); _notesCtrl.dispose(); super.dispose(); } + + Future _load() async { + final srv = ref.read(medicationServiceProvider); + final list = await srv.getList(); + final m = list.cast?>().firstWhere((x) => x?['id']?.toString() == widget.id, orElse: () => null); + if (m == null || !mounted) return; + _nameCtrl.text = m['name']?.toString() ?? ''; + _dosageCtrl.text = m['dosage']?.toString() ?? ''; + _notesCtrl.text = m['notes']?.toString() ?? ''; + final tod = (m['timeOfDay'] as List?)?.map((t) { + final parts = t.toString().split(':'); + return TimeOfDay(hour: int.tryParse(parts[0]) ?? 8, minute: int.tryParse(parts.length > 1 ? parts[1] : '0') ?? 0); + }).toList() ?? [const TimeOfDay(hour: 8, minute: 0), const TimeOfDay(hour: 18, minute: 0)]; + _timesPerDay = tod.length; + _times = tod; + if (m['startDate'] != null) _start = DateTime.tryParse(m['startDate'].toString()) ?? DateTime.now(); + if (m['endDate'] != null) _end = DateTime.tryParse(m['endDate'].toString()); + setState(() {}); } - @override - void dispose() { - for (final c in _nameCtrls) { - c.dispose(); - } - for (final c in _doseCtrls) { - c.dispose(); - } - super.dispose(); - } - - void _addItem() { - setState(() { - _items.add(_MedicationItem()); - _nameCtrls.add(TextEditingController()); - _doseCtrls.add(TextEditingController()); - }); - } - - void _removeItem(int index) { - setState(() { - _nameCtrls[index].dispose(); - _doseCtrls[index].dispose(); - _nameCtrls.removeAt(index); - _doseCtrls.removeAt(index); - _items.removeAt(index); - }); - } - - void _onSave() async { - for (int i = 0; i < _items.length; i++) { - _items[i].name = _nameCtrls[i].text.trim(); - _items[i].dosage = _doseCtrls[i].text.trim(); - } - final allValid = _items.every( - (item) => item.name.isNotEmpty && item.dosage.isNotEmpty, - ); - if (!allValid) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('请填写所有药品的名称和剂量')), - ); - return; - } - final service = ref.read(medicationServiceProvider); + Future _save() async { + final name = _nameCtrl.text.trim(); + if (name.isEmpty) { ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('请输入药品名称'))); return; } + setState(() => _loading = true); + final data = { + 'name': name, 'dosage': _dosageCtrl.text.trim(), 'frequency': 'Daily', + 'notes': _notesCtrl.text.trim(), + 'timeOfDay': _times.map((t) => '${t.hour.toString().padLeft(2,'0')}:${t.minute.toString().padLeft(2,'0')}').toList(), + 'startDate': '${_start.year}-${_start.month.toString().padLeft(2,'0')}-${_start.day.toString().padLeft(2,'0')}', + if (_end != null) 'endDate': '${_end!.year}-${_end!.month.toString().padLeft(2,'0')}-${_end!.day.toString().padLeft(2,'0')}', + 'source': 'Manual', + }; + final srv = ref.read(medicationServiceProvider); try { - for (final item in _items) { - final timesStr = item.frequency == '按需服用' - ? [] - : item.times.map((t) => t.format(context)).toList(); - await service.create({ - 'name': item.name, - 'dosage': item.dosage, - 'frequency': 'Daily', - 'timeOfDay': timesStr, - 'startDate': item.startDate.toIso8601String().split('T')[0], - if (item.endDate != null) - 'endDate': item.endDate!.toIso8601String().split('T')[0], - 'source': 'Manual', - }); - } - if (!mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text('已添加 ${_items.length} 种药品'), - backgroundColor: const Color(0xFF8B9CF7), - ), - ); - ref.invalidate(medicationListProvider); - ref.invalidate(medicationReminderProvider); - popRoute(ref); - } catch (e) { - if (!mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text('保存失败:$e'), - backgroundColor: Colors.red, - ), - ); - // 仍然返回上一页,避免卡在黑屏 - popRoute(ref); - } + if (widget.id != null) { await srv.update(widget.id!, data); } else { await srv.create(data); } + ref.invalidate(medicationListProvider); ref.invalidate(medicationReminderProvider); + if (mounted) { ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('已保存'), backgroundColor: Color(0xFF43A047))); popRoute(ref); } + } catch (_) { if (mounted) ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('保存失败'), backgroundColor: Color(0xFFE53935))); } + if (mounted) setState(() => _loading = false); } - int _timeCount(String frequency) { - switch (frequency) { - case '每日1次': - return 1; - case '每日2次': - return 2; - case '每日3次': - return 3; - case '每周1次': - return 1; - default: - return 0; - } + void _updateTimes(int n) { + setState(() { + _timesPerDay = n; + while (_times.length < n) _times.add(const TimeOfDay(hour: 12, minute: 0)); + while (_times.length > n) _times.removeLast(); + }); } - @override - Widget build(BuildContext context) { + @override Widget build(BuildContext context) { return Scaffold( backgroundColor: const Color(0xFFF8F9FC), - appBar: AppBar( - backgroundColor: Colors.white, - elevation: 0, - leading: IconButton( - icon: const Icon(Icons.chevron_left), - onPressed: () => popRoute(ref), - ), - title: const Text( - '添加用药', - style: TextStyle( - color: Color(0xFF1A1A1A), - fontWeight: FontWeight.w600, - ), - ), - centerTitle: true, - actions: [ - TextButton( - onPressed: _onSave, - child: const Text( - '保存', - style: TextStyle( - color: Color(0xFF8B9CF7), - fontWeight: FontWeight.w600, - ), - ), - ), - ], - ), - body: SingleChildScrollView( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - ...List.generate(_items.length, (i) => _buildCard(i)), - const SizedBox(height: 12), - _buildAddButton(), - const SizedBox(height: 40), - ], - ), - ), + appBar: AppBar(title: Text(widget.id != null ? '编辑用药' : '添加用药')), + body: ListView(padding: const EdgeInsets.all(16), children: [ + // 名称+剂量 一行 + Row(children: [ + Expanded(flex: 3, child: _field('药品名称', _nameCtrl, hint: '如: 阿司匹林')), + const SizedBox(width: 12), + Expanded(flex: 2, child: _field('剂量', _dosageCtrl, hint: '如: 100mg')), + ]), + const SizedBox(height: 16), + // 频率选择 + _label('每日服药次数'), const SizedBox(height: 8), + Wrap(spacing: 8, children: List.generate(4, (i) => _chip('${i + 1}次', _timesPerDay == i + 1, () => _updateTimes(i + 1)))), + const SizedBox(height: 16), + // 时间选择 + _label('服药时间'), const SizedBox(height: 8), + Wrap(spacing: 8, runSpacing: 8, children: List.generate(_timesPerDay, (i) => GestureDetector( + onTap: () async { + final t = await showTimePicker(context: context, initialTime: _times[i]); + if (t != null) setState(() => _times[i] = t); + }, + child: Container(padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), + decoration: BoxDecoration(color: const Color(0xFFEDEAFF), borderRadius: BorderRadius.circular(12)), + child: Text('${_times[i].hour.toString().padLeft(2,'0')}:${_times[i].minute.toString().padLeft(2,'0')}', style: const TextStyle(fontSize: 15, color: Color(0xFF6C5CE7), fontWeight: FontWeight.w600))), + ))), + const SizedBox(height: 16), + // 开始+结束 一行 + Row(children: [ + Expanded(child: _dateField('开始日期', _start, (d) => setState(() => _start = d))), + const SizedBox(width: 12), + Expanded(child: _dateFieldOpt('结束日期(可选)', _end, (d) => setState(() => _end = d))), + ]), + const SizedBox(height: 16), + _field('备注', _notesCtrl, hint: '如: 饭后服用、睡前'), + const SizedBox(height: 32), + SizedBox(width: double.infinity, child: ElevatedButton(onPressed: _loading ? null : _save, style: ElevatedButton.styleFrom(backgroundColor: const Color(0xFF6C5CE7), foregroundColor: Colors.white, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), padding: const EdgeInsets.symmetric(vertical: 14)), child: Text(_loading ? '保存中...' : '保存', style: const TextStyle(fontSize: 16)))), + const SizedBox(height: 20), + ]), ); } - Widget _buildCard(int index) { - final item = _items[index]; - final count = _timeCount(item.frequency); - - return Container( - margin: const EdgeInsets.only(bottom: 12), - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(12), - border: Border.all(color: const Color(0xFFEEEEEE)), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Header - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - '药品 ${index + 1}', - style: const TextStyle( - fontSize: 13, - fontWeight: FontWeight.w600, - color: Color(0xFF8B9CF7), - ), - ), - if (_items.length > 1) - GestureDetector( - onTap: () => _removeItem(index), - child: const Icon(Icons.close, size: 18, color: Color(0xFFBDBDBD)), - ), - ], - ), - const SizedBox(height: 8), - Divider(height: 1, color: const Color(0xFFF0F0F0)), - const SizedBox(height: 8), - - // Name - _buildLabel('药品名称'), - const SizedBox(height: 4), - TextField( - controller: _nameCtrls[index], - style: const TextStyle(fontSize: 14), - decoration: _inputDecoration('请输入药品名称'), - ), - const SizedBox(height: 8), - - // Dosage - _buildLabel('剂量'), - const SizedBox(height: 4), - TextField( - controller: _doseCtrls[index], - style: const TextStyle(fontSize: 14), - decoration: _inputDecoration('如:100mg'), - ), - const SizedBox(height: 8), - - // Frequency - _buildLabel('服用频率'), - const SizedBox(height: 4), - GestureDetector( - onTap: () => _pickFrequency(index), - child: Container( - width: double.infinity, - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(8), - border: Border.all(color: const Color(0xFFE0E0E0)), - color: const Color(0xFFFAFAFA), - ), - child: Row( - children: [ - Text(item.frequency, style: const TextStyle(fontSize: 14, color: Color(0xFF1A1A1A))), - const Spacer(), - const Icon(Icons.keyboard_arrow_down, size: 20, color: Color(0xFF9E9E9E)), - ], - ), - ), - ), - const SizedBox(height: 8), - - // Times (dynamic) - if (count > 0) ...[ - _buildLabel('服药时间'), - const SizedBox(height: 4), - Wrap( - spacing: 8, - runSpacing: 6, - children: List.generate(count, (t) => _buildTimePicker(index, t)), - ), - if (item.frequency == '每周1次') ...[ - const SizedBox(height: 8), - _buildLabel('选择星期'), - const SizedBox(height: 4), - GestureDetector( - onTap: () => _pickWeekday(index), - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(8), - border: Border.all(color: const Color(0xFFE0E0E0)), - color: const Color(0xFFFAFAFA), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Text(_weekdays[item.weekday - 1], style: const TextStyle(fontSize: 14, color: Color(0xFF1A1A1A))), - const SizedBox(width: 4), - const Icon(Icons.keyboard_arrow_down, size: 18, color: Color(0xFF9E9E9E)), - ], - ), - ), - ), - ], - const SizedBox(height: 8), - ], - - // Start date - _buildLabel('开始日期'), - const SizedBox(height: 4), - GestureDetector( - onTap: () => _pickDate(index, isStart: true), - child: Container( - width: double.infinity, - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(8), - border: Border.all(color: const Color(0xFFE0E0E0)), - color: const Color(0xFFFAFAFA), - ), - child: Row( - children: [ - Text( - '${item.startDate.year}-${item.startDate.month.toString().padLeft(2, '0')}-${item.startDate.day.toString().padLeft(2, '0')}', - style: const TextStyle(fontSize: 14, color: Color(0xFF1A1A1A)), - ), - const Spacer(), - const Icon(Icons.calendar_today, size: 18, color: Color(0xFF9E9E9E)), - ], - ), - ), - ), - const SizedBox(height: 8), - - // End date (optional) - _buildLabel('结束日期(可选)'), - const SizedBox(height: 4), - GestureDetector( - onTap: () => _pickDate(index, isStart: false), - child: Container( - width: double.infinity, - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(8), - border: Border.all(color: const Color(0xFFE0E0E0)), - color: const Color(0xFFFAFAFA), - ), - child: Row( - children: [ - Text( - item.endDate != null - ? '${item.endDate!.year}-${item.endDate!.month.toString().padLeft(2, '0')}-${item.endDate!.day.toString().padLeft(2, '0')}' - : '不设置', - style: TextStyle( - fontSize: 14, - color: item.endDate != null ? const Color(0xFF1A1A1A) : const Color(0xFFBDBDBD), - ), - ), - const Spacer(), - GestureDetector( - onTap: item.endDate != null ? () => setState(() => item.endDate = null) : null, - child: Icon( - item.endDate != null ? Icons.close : Icons.calendar_today, - size: 18, - color: const Color(0xFF9E9E9E), - ), - ), - ], - ), - ), - ), - ], - ), - ); - } - - Widget _buildLabel(String text) { - return Text( - text, - style: const TextStyle(fontSize: 12, color: Color(0xFF757575)), - ); - } - - Widget _buildTimePicker(int itemIndex, int timeIndex) { - final item = _items[itemIndex]; - final time = item.times[timeIndex]; - - return GestureDetector( - onTap: () => _pickTime(itemIndex, timeIndex), - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(8), - border: Border.all(color: const Color(0xFFE0E0E0)), - color: const Color(0xFFFAFAFA), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - const Icon(Icons.access_time, size: 16, color: Color(0xFF8B9CF7)), - const SizedBox(width: 6), - Text( - time.format(context), - style: const TextStyle(fontSize: 14, color: Color(0xFF1A1A1A)), - ), - ], - ), - ), - ); - } - - Widget _buildAddButton() { - return SizedBox( - width: double.infinity, - child: OutlinedButton.icon( - onPressed: _addItem, - icon: const Icon(Icons.add, size: 18), - label: const Text('添加', style: TextStyle(fontSize: 14)), - style: OutlinedButton.styleFrom( - foregroundColor: const Color(0xFF8B9CF7), - side: const BorderSide(color: Color(0xFFD0D5FC)), - padding: const EdgeInsets.symmetric(vertical: 12), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), - backgroundColor: const Color(0xFFF0F2FF), - ), - ), - ); - } - - InputDecoration _inputDecoration(String hint) { - return InputDecoration( - hintText: hint, - hintStyle: const TextStyle(color: Color(0xFFBDBDBD), fontSize: 14), - filled: true, - fillColor: const Color(0xFFFAFAFA), - contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), - isDense: true, - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(8), - borderSide: const BorderSide(color: Color(0xFFE0E0E0)), - ), - enabledBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(8), - borderSide: const BorderSide(color: Color(0xFFE0E0E0)), - ), - focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(8), - borderSide: const BorderSide(color: Color(0xFF8B9CF7)), - ), - ); - } - - void _pickFrequency(int index) async { - final selected = await showModalBottomSheet( - context: context, - builder: (ctx) => SafeArea( - child: Column( - mainAxisSize: MainAxisSize.min, - children: _frequencies - .map((f) => ListTile( - title: Text(f), - onTap: () => Navigator.pop(ctx, f), - )) - .toList(), - ), - ), - ); - if (selected != null && mounted) { - setState(() { - final item = _items[index]; - item.frequency = selected; - final newCount = _timeCount(selected); - if (newCount > 0 && item.times.length != newCount) { - item.times = List.generate( - newCount, - (i) => TimeOfDay(hour: 8 + i * 4, minute: 0), - ); - } - }); - } - } - - void _pickWeekday(int index) async { - final item = _items[index]; - final selected = await showModalBottomSheet( - context: context, - builder: (ctx) => SafeArea( - child: Column( - mainAxisSize: MainAxisSize.min, - children: List.generate(7, (i) { - return ListTile( - title: Text(_weekdays[i]), - selected: item.weekday == i + 1, - onTap: () => Navigator.pop(ctx, i + 1), - ); - }), - ), - ), - ); - if (selected != null && mounted) { - setState(() => _items[index].weekday = selected); - } - } - - void _pickTime(int itemIndex, int timeIndex) async { - final item = _items[itemIndex]; - final time = await showTimePicker( - context: context, - initialTime: item.times[timeIndex], - ); - if (time != null && mounted) { - setState(() => item.times[timeIndex] = time); - } - } - - void _pickDate(int index, {required bool isStart}) async { - final item = _items[index]; - final initial = isStart ? item.startDate : (item.endDate ?? DateTime.now()); - final date = await showDatePicker( - context: context, - firstDate: DateTime(2020), - lastDate: DateTime(2030), - initialDate: initial, - ); - if (date != null && mounted) { - setState(() { - if (isStart) { - item.startDate = date; - } else { - item.endDate = date; - } - }); - } - } + Widget _label(String text) => Text(text, style: const TextStyle(fontSize: 14, color: Color(0xFF666666))); + Widget _field(String label, TextEditingController ctrl, {String? hint}) => Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + _label(label), const SizedBox(height: 6), + TextField(controller: ctrl, decoration: InputDecoration(hintText: hint, filled: true, fillColor: Colors.white, border: OutlineInputBorder(borderRadius: BorderRadius.circular(12), borderSide: BorderSide.none), contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12)), style: const TextStyle(fontSize: 16)), + ]); + Widget _chip(String label, bool sel, VoidCallback onTap) => GestureDetector(onTap: onTap, child: Container(padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), decoration: BoxDecoration(color: sel ? const Color(0xFF6C5CE7) : const Color(0xFFEDEAFF), borderRadius: BorderRadius.circular(16)), child: Text(label, style: TextStyle(fontSize: 13, color: sel ? Colors.white : const Color(0xFF6C5CE7), fontWeight: FontWeight.w500)))); + Widget _dateField(String label, DateTime val, ValueChanged cb) => Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + _label(label), const SizedBox(height: 6), + GestureDetector(onTap: () async { final d = await showDatePicker(context: context, initialDate: val, firstDate: DateTime(2024), lastDate: DateTime(2030)); if (d != null) cb(d); }, + child: Container(width: double.infinity, padding: const EdgeInsets.all(12), decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(12)), child: Text('${val.month}/${val.day}', style: const TextStyle(fontSize: 15)))), + ]); + Widget _dateFieldOpt(String label, DateTime? val, ValueChanged cb) => Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + _label(label), const SizedBox(height: 6), + GestureDetector(onTap: () async { final d = await showDatePicker(context: context, initialDate: val ?? DateTime.now(), firstDate: DateTime(2024), lastDate: DateTime(2030)); if (d != null) cb(d); }, + child: Container(width: double.infinity, padding: const EdgeInsets.all(12), decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(12)), child: Row(children: [ + Text(val != null ? '${val.month}/${val.day}' : '不设置', style: TextStyle(fontSize: 15, color: val != null ? null : Colors.grey[400])), + if (val != null) const Spacer(), + if (val != null) GestureDetector(onTap: () => cb(null), child: const Icon(Icons.close, size: 16, color: Color(0xFFBBBBBB))), + ]))), + ]); } diff --git a/health_app/lib/pages/medication/medication_list_page.dart b/health_app/lib/pages/medication/medication_list_page.dart index 93e0fb5..b64e12a 100644 --- a/health_app/lib/pages/medication/medication_list_page.dart +++ b/health_app/lib/pages/medication/medication_list_page.dart @@ -3,160 +3,158 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../core/navigation_provider.dart'; import '../../providers/data_providers.dart'; -class MedicationListPage extends ConsumerWidget { +class MedicationListPage extends ConsumerStatefulWidget { const MedicationListPage({super.key}); + @override ConsumerState createState() => _MedicationListPageState(); +} +class _MedicationListPageState extends ConsumerState { + String _filter = ''; + Future>>? _future; + @override void initState() { super.initState(); _load(); } + void _load() { setState(() { _future = ref.read(medicationServiceProvider).getMedications(_filter); }); } - @override Widget build(BuildContext context, WidgetRef ref) { - final meds = ref.watch(medicationListProvider); + Future _checkIn(String id) async { + await ref.read(medicationServiceProvider).confirm(id); + ref.invalidate(medicationReminderProvider); + _load(); + } + Future _delete(String id) async { + await ref.read(medicationServiceProvider).deleteMedication(id); + _load(); + } + Future _toggleActive(Map m) async { + await ref.read(medicationServiceProvider).update(m['id']?.toString() ?? '', {'isActive': !(m['isActive'] == true), 'name': m['name'] ?? '', 'dosage': m['dosage'] ?? '', 'frequency': 'Daily'}); + _load(); + } + + void _showDetail(Map m) { + final times = (m['timeOfDay'] as List?)?.map((t) => t.toString().substring(0, 5)).join('、') ?? ''; + final isActive = m['isActive'] == true; + showModalBottomSheet(context: context, shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(20))), builder: (ctx) => Padding(padding: const EdgeInsets.all(20), child: Column(mainAxisSize: MainAxisSize.min, children: [ + Row(children: [ + Container(width: 48, height: 48, decoration: BoxDecoration(color: isActive ? const Color(0xFFEDEAFF) : const Color(0xFFF5F5F5), borderRadius: BorderRadius.circular(14)), child: Icon(Icons.medication_outlined, size: 24, color: isActive ? const Color(0xFF6C5CE7) : Colors.grey)), + const SizedBox(width: 14), + Expanded(child: Text(m['name']?.toString() ?? '', style: const TextStyle(fontSize: 20, fontWeight: FontWeight.w700))), + if (!isActive) const Text(' (已停用)', style: TextStyle(color: Color(0xFF999999))), + ]), + const SizedBox(height: 16), + if (m['dosage']?.toString().isNotEmpty == true) _dRow(ctx, '剂量', m['dosage'].toString()), + _dRow(ctx, '服用时间', times), + _dRow(ctx, '频率', '每天${(m['timeOfDay'] as List?)?.length ?? 0}次'), + if (m['startDate'] != null) _dRow(ctx, '开始', m['startDate'].toString().substring(0, 10)), + if (m['endDate'] != null) _dRow(ctx, '结束', m['endDate'].toString().substring(0, 10)), + if (m['notes']?.toString().isNotEmpty == true) _dRow(ctx, '备注', m['notes'].toString()), + const SizedBox(height: 20), + Row(children: [ + Expanded(child: OutlinedButton(onPressed: () { Navigator.pop(ctx); pushRoute(ref, 'medicationEdit', params: {'id': m['id']?.toString() ?? ''}); }, child: const Text('编辑'))), + const SizedBox(width: 12), + Expanded(child: OutlinedButton(onPressed: () { Navigator.pop(ctx); _toggleActive(m); }, child: Text(isActive ? '停药' : '恢复服用'), style: OutlinedButton.styleFrom(foregroundColor: isActive ? const Color(0xFFFF9800) : const Color(0xFF43A047)))), + ]), + const SizedBox(height: 8), + ]))); + } + + Widget _dRow(BuildContext ctx, String label, String value) => Padding(padding: const EdgeInsets.only(bottom: 8), child: Row(crossAxisAlignment: CrossAxisAlignment.start, children: [ + SizedBox(width: 72, child: Text(label, style: const TextStyle(fontSize: 14, color: Color(0xFF888888)))), + Expanded(child: Text(value, style: const TextStyle(fontSize: 14))), + ])); + + @override Widget build(BuildContext context) { return Scaffold( backgroundColor: const Color(0xFFF8F9FC), - appBar: AppBar( - backgroundColor: Colors.white, - elevation: 0, - title: const Text('我的用药', style: TextStyle(color: Color(0xFF1A1A1A), fontWeight: FontWeight.w600)), - centerTitle: true, - actions: [ - TextButton( - onPressed: () => pushRoute(ref, 'medicationEdit'), - child: Row(mainAxisSize: MainAxisSize.min, children: [ - const Icon(Icons.add_circle_outline, size: 18, color: Color(0xFF8B9CF7)), - const SizedBox(width: 4), - const Text('添加新药', style: TextStyle(color: Color(0xFF8B9CF7), fontSize: 14)), - ]), - ), - ], - ), + appBar: AppBar(title: const Text('用药管理'), actions: [ + TextButton(onPressed: () { pushRoute(ref, 'medicationEdit'); }, child: const Text('添加', style: TextStyle(fontSize: 15, color: Color(0xFF6C5CE7)))), + ]), body: Column(children: [ - Container(padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), child: Row(children: [ - _TabChip(label: '全部', active: true), - const SizedBox(width: 8), - _TabChip(label: '服用中'), - const SizedBox(width: 8), - _TabChip(label: '已停药'), + Padding(padding: const EdgeInsets.all(12), child: Row(children: [ + _tab('全部', ''), _tab('服用中', 'active'), _tab('已停药', 'inactive'), ])), - Expanded(child: meds.when( - data: (list) { - if (list.isEmpty) return _empty(context); - return ListView.builder( - padding: const EdgeInsets.all(16), - itemCount: list.length + 1, - itemBuilder: (ctx, i) { - if (i == list.length) return const SizedBox(height: 80); - final m = list[i]; - return _MedicationCard(data: m); - }, - ); + Expanded(child: FutureBuilder>>( + future: _future, + builder: (ctx, snap) { + final list = snap.data ?? []; + if (list.isEmpty) return Center(child: Column(mainAxisSize: MainAxisSize.min, children: [ + Icon(Icons.medication_outlined, size: 64, color: Colors.grey[300]), + const SizedBox(height: 12), Text('暂无用药', style: TextStyle(color: Colors.grey[500])), + ])); + return ListView.builder(padding: const EdgeInsets.fromLTRB(0, 0, 0, 12), itemCount: list.length + 1, itemBuilder: (ctx, i) { + if (i == list.length) return const SizedBox(height: 80); + final m = list[i]; + final times = (m['timeOfDay'] as List?)?.map((t) => t.toString().substring(0, 5)).join(' ') ?? ''; + final todayTaken = m['todayTaken'] == true; + final isActive = m['isActive'] == true; + return _SwipeDeleteTile( + key: Key(m['id']?.toString() ?? '$i'), + onDelete: () => _delete(m['id']?.toString() ?? ''), + onTap: () => _showDetail(m), + child: Container( + margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), + padding: const EdgeInsets.all(14), + decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(16)), + child: Row(children: [ + Container(width: 44, height: 44, decoration: BoxDecoration(color: isActive ? const Color(0xFFEDEAFF) : const Color(0xFFF5F5F5), borderRadius: BorderRadius.circular(12)), child: Icon(Icons.medication_outlined, size: 22, color: isActive ? const Color(0xFF6C5CE7) : Colors.grey)), + const SizedBox(width: 12), + Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Row(children: [ + Text(m['name']?.toString() ?? '', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: isActive ? const Color(0xFF1A1A1A) : Colors.grey)), + if (!isActive) ...[const SizedBox(width: 6), Container(padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1), decoration: BoxDecoration(color: Colors.grey[200], borderRadius: BorderRadius.circular(4)), child: const Text('已停', style: TextStyle(fontSize: 10, color: Color(0xFF999999))))], + ]), + const SizedBox(height: 2), + Text('${m['dosage'] ?? ''} $times', style: const TextStyle(fontSize: 13, color: Color(0xFF888888))), + if (m['notes']?.toString().isNotEmpty == true) + Text(m['notes'].toString(), style: const TextStyle(fontSize: 12, color: Color(0xFFBBBBBB)), maxLines: 1, overflow: TextOverflow.ellipsis), + ])), + if (isActive) GestureDetector( + onTap: () => _checkIn(m['id']?.toString() ?? ''), + child: Container(width: 40, height: 40, + decoration: BoxDecoration(color: todayTaken ? const Color(0xFFDCFCE7) : const Color(0xFFEDEAFF), borderRadius: BorderRadius.circular(12)), + child: Icon(todayTaken ? Icons.check_circle : Icons.check_circle_outline, size: 24, color: todayTaken ? const Color(0xFF43A047) : const Color(0xFFBBBBBB)), + ), + ), + ]), + ), + ); + }); }, - loading: () => const Center(child: CircularProgressIndicator(color: Color(0xFF8B9CF7))), - error: (_, e) => _empty(context), )), - _buildReminderBar(), ]), ); } - Widget _buildReminderBar() { - return Container( - width: double.infinity, - padding: const EdgeInsets.all(16), - decoration: BoxDecoration(color: Colors.white, boxShadow: [BoxShadow(color: Colors.grey.withAlpha(30), blurRadius: 8)]), - child: Row(children: [ - Container( - width: 40, - height: 40, - decoration: BoxDecoration( - color: const Color(0xFFF0F2FF), - borderRadius: BorderRadius.circular(20), - border: Border.all(color: const Color(0xFF8B9CF7).withAlpha(50)), - ), - child: const Icon(Icons.notifications_active_outlined, size: 20, color: Color(0xFF8B9CF7)), - ), - const SizedBox(width: 12), - const Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text('用药提醒已开启', style: TextStyle(fontSize: 14, fontWeight: FontWeight.w500, color: Color(0xFF1A1A1A))), - Text('按时服药,守护心脏健康一天', style: TextStyle(fontSize: 12, color: Color(0xFF9E9E9E))), - ])), - const Icon(Icons.chevron_right, size: 18, color: Color(0xFFBDBDBD)), - ]), - ); - } - - Widget _empty(BuildContext context) { - return Center(child: Column(mainAxisSize: MainAxisSize.min, children: [ - const Icon(Icons.medication_outlined, size: 64, color: Color(0xFFE0E0E0)), - const SizedBox(height: 12), - const Text('暂无用药计划', style: TextStyle(fontSize: 15, color: Color(0xFF9E9E9E))), - const SizedBox(height: 8), - const Text('可通过 AI 对话或手动添加', style: TextStyle(fontSize: 13, color: Color(0xFFBDBDBD))), - ])); - } -} - -class _TabChip extends StatelessWidget { - final String label; - final bool active; - const _TabChip({required this.label, this.active = false}); - - @override - Widget build(BuildContext context) { - return Container( - padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 7), - decoration: BoxDecoration( - color: active ? const Color(0xFF8B9CF7) : Colors.white, - borderRadius: BorderRadius.circular(16), - border: Border.all(color: active ? const Color(0xFF8B9CF7) : const Color(0xFFE0E0E0)), - ), - child: Text( - label, - style: TextStyle( - fontSize: 13, - fontWeight: active ? FontWeight.w600 : FontWeight.normal, - color: active ? Colors.white : const Color(0xFF757575), - ), - ), + Widget _tab(String label, String value) { + final sel = _filter == value; + return GestureDetector(onTap: () { _filter = value; _load(); }, + child: Container(margin: const EdgeInsets.only(right: 8), padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6), + decoration: BoxDecoration(color: sel ? const Color(0xFF6C5CE7) : const Color(0xFFEDEAFF), borderRadius: BorderRadius.circular(16)), + child: Text(label, style: TextStyle(fontSize: 13, color: sel ? Colors.white : const Color(0xFF6C5CE7), fontWeight: FontWeight.w500))), ); } } -class _MedicationCard extends StatelessWidget { - final Map data; - const _MedicationCard({required this.data}); - - @override - Widget build(BuildContext context) { - return Container( - margin: const EdgeInsets.only(bottom: 12), - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(16), - boxShadow: [BoxShadow(color: const Color(0xFF8B9CF7).withAlpha(10), blurRadius: 4, offset: const Offset(0, 2))], - ), - child: Row(children: [ - Container( - width: 48, - height: 48, - decoration: BoxDecoration( - color: const Color(0xFFF0F2FF), - borderRadius: BorderRadius.circular(14), - ), - child: const Center(child: Text('💊', style: TextStyle(fontSize: 24))), - ), - const SizedBox(width: 12), - Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text('${data['name'] ?? ''}', style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: Color(0xFF1A1A1A))), - const SizedBox(height: 4), - Text('${data['dosage'] ?? ''} · 每日1次', style: const TextStyle(fontSize: 13, color: Color(0xFF9E9E9E))), - const SizedBox(height: 2), - Text('08:00 · 剩余 1 片', style: const TextStyle(fontSize: 12, color: Color(0xFFBDBDBD))), - ])), - Container( - width: 28, - height: 28, - decoration: const BoxDecoration(color: Color(0xFFDCFCE7), shape: BoxShape.circle), - child: const Icon(Icons.check, size: 16, color: Color(0xFF43A047)), - ), - ]), +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 _max = 80.0, _threshold = 40.0; + void _onUpdate(DragUpdateDetails d) => setState(() => _dx = (_dx + d.delta.dx).clamp(-_max, 0.0)); + void _onEnd(DragEndDetails d) => setState(() => _dx = _dx < -_threshold ? -_max : 0.0); + void _doDelete() { widget.onDelete(); setState(() => _dx = 0); } + @override Widget build(BuildContext context) { + final w = GestureDetector( + onHorizontalDragUpdate: _onUpdate, + onHorizontalDragEnd: _onEnd, + child: Transform.translate(offset: Offset(_dx, 0), child: widget.child), + ); + final red = Container( + margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), + decoration: const BoxDecoration(color: Color(0xFFE53935), borderRadius: BorderRadius.all(Radius.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))), + ); + return GestureDetector( + onTap: _dx < 0 ? _doDelete : widget.onTap, + child: Stack(children: [Positioned.fill(child: red), w]), ); } } diff --git a/health_app/lib/pages/remaining_pages.dart b/health_app/lib/pages/remaining_pages.dart index 48e3792..8b2562d 100644 --- a/health_app/lib/pages/remaining_pages.dart +++ b/health_app/lib/pages/remaining_pages.dart @@ -13,7 +13,7 @@ class _DietRecordListPageState extends ConsumerState { Future>>? _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 { 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 { } } -/// 运动计划页 -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>() ?? []; - 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>>( + 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: () => {}); + if (d.isEmpty) return const Center(child: Text('记录不存在')); + final items = (d['foodItems'] as List?)?.cast>() ?? []; + 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 createState() => _ExercisePlanPageState(); +} +class _ExercisePlanPageState extends ConsumerState { + Future>>? _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 _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 _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>>( + 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>() ?? []; + 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 createState() => _ExercisePlanCreatePageState(); +} +class _ExercisePlanCreatePageState extends ConsumerState { + 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 _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 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]), diff --git a/health_app/lib/services/health_service.dart b/health_app/lib/services/health_service.dart index 55b19dc..64b1785 100644 --- a/health_app/lib/services/health_service.dart +++ b/health_app/lib/services/health_service.dart @@ -80,6 +80,18 @@ class MedicationService { await _api.delete('/api/medications/$id'); } + Future>> getMedications(String filter) async { + final params = {}; + if (filter.isNotEmpty) params['filter'] = filter; + final res = await _api.get('/api/medications', queryParameters: params.isNotEmpty ? params : null); + final list = res.data['data'] as List? ?? []; + return list.cast>(); + } + + Future deleteMedication(String id) async { + await _api.delete('/api/medications/$id'); + } + Future confirm(String id) async { await _api.post('/api/medications/$id/confirm'); } @@ -120,31 +132,31 @@ class ConsultationService { ConsultationService(this._api); Future>> getDoctors() async { - final res = await _api.get('/doctors'); + final res = await _api.get('/api/doctors'); final list = res.data['data'] as List? ?? []; return list.cast>(); } Future> getQuota() async { - final res = await _api.get('/user/consultation-quota'); + final res = await _api.get('/api/user/consultation-quota'); return res.data['data'] ?? {}; } Future> createConsultation(String doctorId) async { - final res = await _api.post('/consultations', data: {'doctorId': doctorId}); + final res = await _api.post('/api/consultations', data: {'doctorId': doctorId}); return res.data['data']; } Future>> getMessages(String consultationId, {String? after}) async { final params = {}; if (after != null) params['after'] = after; - final res = await _api.get('/consultations/$consultationId/messages', queryParameters: params); + final res = await _api.get('/api/consultations/$consultationId/messages', queryParameters: params); final list = res.data['data'] as List? ?? []; return list.cast>(); } Future sendMessage(String consultationId, String content) async { - await _api.post('/consultations/$consultationId/messages', data: {'content': content}); + await _api.post('/api/consultations/$consultationId/messages', data: {'content': content}); } } @@ -154,15 +166,33 @@ class ExerciseService { ExerciseService(this._api); Future?> getCurrentPlan() async { - final res = await _api.get('/exercise-plans/current'); + final res = await _api.get('/api/exercise-plans/current'); return res.data['data']; } Future createPlan(Map data) async { - await _api.post('/exercise-plans', data: data); + await _api.post('/api/exercise-plans', data: data); + } + + Future createPlanSimple(Map data) async { + await _api.post('/api/exercise-plans', data: data); + } + + Future deletePlan(String id) async { + await _api.delete('/api/exercise-plans/$id'); } Future checkIn(String itemId) async { - await _api.post('/exercise-plans/items/$itemId/checkin'); + await _api.post('/api/exercise-plans/items/$itemId/checkin'); + } + + Future>> getPlans() async { + final res = await _api.get('/api/exercise-plans'); + return (res.data['data'] as List?)?.cast>() ?? []; + } + + Future?> getPlanDetail(String id) async { + final res = await _api.get('/api/exercise-plans/$id'); + return res.data['data']; } } diff --git a/health_app/lib/widgets/health_drawer.dart b/health_app/lib/widgets/health_drawer.dart index 75d98b3..8d60b29 100644 --- a/health_app/lib/widgets/health_drawer.dart +++ b/health_app/lib/widgets/health_drawer.dart @@ -187,8 +187,14 @@ class HealthDrawer extends ConsumerWidget { ), const Spacer(), GestureDetector( - onTap: () => ref.invalidate(conversationListProvider), - child: const Padding(padding: EdgeInsets.all(4), child: Icon(Icons.refresh, size: 15, color: Color(0xFFAAAAAA))), + onTap: () async { + final api = ref.read(apiClientProvider); + for (final c in conversations.value ?? []) { + try { await api.delete('/api/ai/conversations/${c.id}'); } catch (_) {} + } + ref.invalidate(conversationListProvider); + }, + child: const Padding(padding: EdgeInsets.all(4), child: Text('清理', style: TextStyle(fontSize: 11, color: Color(0xFFAAAAAA)))), ), ]), ), @@ -268,7 +274,13 @@ class HealthDrawer extends ConsumerWidget { item: item, onTap: () { ref.read(chatProvider.notifier).loadConversation(item.id); - Navigator.of(context).pop(); // 关闭抽屉 + Navigator.of(context).pop(); + }, + onDelete: () async { + try { + await ref.read(apiClientProvider).delete('/api/ai/conversations/${item.id}'); + ref.invalidate(conversationListProvider); + } catch (_) {} }, )).toList(), ), @@ -424,8 +436,9 @@ class _FeatureChip extends StatelessWidget { class _ConversationItem extends StatelessWidget { final ConversationItem item; final VoidCallback? onTap; + final VoidCallback? onDelete; - const _ConversationItem({required this.item, this.onTap}); + const _ConversationItem({required this.item, this.onTap, this.onDelete}); @override Widget build(BuildContext context) { @@ -449,14 +462,12 @@ class _ConversationItem extends StatelessWidget { ), title: Text(item.title, maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w600, color: Color(0xFF333333))), subtitle: Text(item.lastMessage, maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle(fontSize: 11, color: Colors.grey[500])), - trailing: Column( - mainAxisAlignment: MainAxisAlignment.center, - mainAxisSize: MainAxisSize.min, - children: [ - Text(_formatTime(item.updatedAt), style: TextStyle(fontSize: 9, color: Colors.grey[400])), - const SizedBox(height: 2), - Icon(Icons.chevron_right, size: 12, color: Colors.grey[300]), - ], + trailing: GestureDetector( + onTap: onDelete, + child: const Padding( + padding: EdgeInsets.all(8), + child: Icon(Icons.close, size: 16, color: Color(0xFFBBBBBB)), + ), ), dense: true, visualDensity: VisualDensity.compact,