fix: 用药模块重做 + 运动计划不限7天 + 打卡可切换 + 删除修复
- 用药: 重写列表页(标签筛选+详情弹窗+打卡切换+滑动删除) - 用药: 重写编辑页(选择器替代输入+同列布局+频率改为每天几次) - 用药: 加Notes字段+DELETE端点+修复重复端点 - 运动: 不限7天, startDate+索引推算当天 - 打卡: 药/运动打卡可切换(点✓取消) - 删除: 修复滑动删除热区, 一次点击即删除 - 侧边栏: 一键清理历史对话 - 饮食/运动/用药/问诊 API路径加/api/前缀 - 通知时机修正: 新建计划不再提前弹窗
This commit is contained in:
@@ -17,6 +17,7 @@ public sealed class Medication
|
|||||||
public DateOnly? EndDate { get; set; }
|
public DateOnly? EndDate { get; set; }
|
||||||
public bool IsActive { get; set; } = true;
|
public bool IsActive { get; set; } = true;
|
||||||
public MedicationSource Source { get; set; }
|
public MedicationSource Source { get; set; }
|
||||||
|
public string? Notes { get; set; }
|
||||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||||
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
|
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
|
||||||
|
|
||||||
|
|||||||
@@ -21,7 +21,8 @@ public static class ExerciseEndpoints
|
|||||||
plan.Id, plan.WeekStartDate, plan.CreatedAt, plan.UpdatedAt,
|
plan.Id, plan.WeekStartDate, plan.CreatedAt, plan.UpdatedAt,
|
||||||
items = plan.Items.Select(i => new
|
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
|
i.IsCompleted, i.CompletedAt, i.IsRestDay
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
@@ -38,34 +39,85 @@ public static class ExerciseEndpoints
|
|||||||
request.Body.Position = 0;
|
request.Body.Position = 0;
|
||||||
using var json = JsonDocument.Parse(body);
|
using var json = JsonDocument.Parse(body);
|
||||||
var root = json.RootElement;
|
var root = json.RootElement;
|
||||||
var ws = root.GetProperty("weekStartDate").GetString()!;
|
var exerciseType = root.TryGetProperty("exerciseType", out var et) ? et.GetString()! : "运动";
|
||||||
var plan = new ExercisePlan { Id = Guid.NewGuid(), UserId = userId, WeekStartDate = DateOnly.Parse(ws) };
|
var duration = root.TryGetProperty("durationMinutes", out var dm) ? dm.GetInt32() : 30;
|
||||||
if (root.TryGetProperty("items", out var items))
|
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 = (int)d.DayOfWeek,
|
||||||
Id = Guid.NewGuid(),
|
ExerciseType = exerciseType,
|
||||||
DayOfWeek = item.GetProperty("dayOfWeek").GetInt32(),
|
DurationMinutes = duration,
|
||||||
ExerciseType = item.TryGetProperty("exerciseType", out var et) ? et.GetString()! : "休息",
|
IsRestDay = false,
|
||||||
DurationMinutes = item.TryGetProperty("durationMinutes", out var dm) ? dm.GetInt32() : 0,
|
});
|
||||||
IsRestDay = item.TryGetProperty("isRestDay", out var rd) && rd.GetBoolean(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
db.ExercisePlans.Add(plan);
|
db.ExercisePlans.Add(plan);
|
||||||
await db.SaveChangesAsync(ct);
|
await db.SaveChangesAsync(ct);
|
||||||
return Results.Ok(new { code = 0, data = new { plan.Id }, message = (string?)null });
|
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) =>
|
group.MapPost("/items/{itemId:guid}/checkin", async (Guid itemId, HttpContext http, AppDbContext db, CancellationToken ct) =>
|
||||||
{
|
{
|
||||||
var item = await db.ExercisePlanItems.FindAsync([itemId], ct);
|
var item = await db.ExercisePlanItems.FindAsync([itemId], ct);
|
||||||
if (item == null) return Results.Ok(new { code = 40004, message = "不存在" });
|
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);
|
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 });
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,10 +6,19 @@ public static class MedicationEndpoints
|
|||||||
{
|
{
|
||||||
var group = app.MapGroup("/api/medications").RequireAuthorization();
|
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 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 });
|
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;
|
med.StartDate = DateOnly.TryParse(sd.GetString(), out var d) ? d : null;
|
||||||
if (root.TryGetProperty("endDate", out var ed))
|
if (root.TryGetProperty("endDate", out var ed))
|
||||||
med.EndDate = DateOnly.TryParse(ed.GetString(), out var d2) ? d2 : null;
|
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);
|
db.Medications.Add(med);
|
||||||
await db.SaveChangesAsync(ct);
|
await db.SaveChangesAsync(ct);
|
||||||
return Results.Ok(new { code = 0, data = new { med.Id }, message = (string?)null });
|
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("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("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("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;
|
med.UpdatedAt = DateTime.UtcNow;
|
||||||
await db.SaveChangesAsync(ct);
|
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 { 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) =>
|
group.MapPost("/{id:guid}/confirm", async (Guid id, HttpContext http, AppDbContext db, CancellationToken ct) =>
|
||||||
{
|
{
|
||||||
var userId = GetUserId(http);
|
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
|
var log = new MedicationLog
|
||||||
{
|
{
|
||||||
Id = Guid.NewGuid(), MedicationId = id, UserId = userId,
|
Id = Guid.NewGuid(), MedicationId = id, UserId = userId,
|
||||||
@@ -82,7 +100,7 @@ public static class MedicationEndpoints
|
|||||||
};
|
};
|
||||||
db.MedicationLogs.Add(log);
|
db.MedicationLogs.Add(log);
|
||||||
await db.SaveChangesAsync(ct);
|
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) =>
|
group.MapGet("/reminders", async (HttpContext http, AppDbContext db, CancellationToken ct) =>
|
||||||
|
|||||||
@@ -44,6 +44,8 @@ Widget buildPage(RouteInfo route) {
|
|||||||
return DoctorChatPage(id: params['id']!);
|
return DoctorChatPage(id: params['id']!);
|
||||||
case 'exercisePlan':
|
case 'exercisePlan':
|
||||||
return const ExercisePlanPage();
|
return const ExercisePlanPage();
|
||||||
|
case 'exerciseCreate':
|
||||||
|
return const ExercisePlanCreatePage();
|
||||||
case 'dietRecords':
|
case 'dietRecords':
|
||||||
return const DietRecordListPage();
|
return const DietRecordListPage();
|
||||||
case 'dietCapture':
|
case 'dietCapture':
|
||||||
@@ -68,6 +70,8 @@ Widget buildPage(RouteInfo route) {
|
|||||||
return StaticTextPage(type: params['type']!);
|
return StaticTextPage(type: params['type']!);
|
||||||
case 'servicePackageDetail':
|
case 'servicePackageDetail':
|
||||||
return ServicePackageDetailPage(packageId: params['id']!);
|
return ServicePackageDetailPage(packageId: params['id']!);
|
||||||
|
case 'dietDetail':
|
||||||
|
return DietRecordDetailPage(id: params['id']!);
|
||||||
default:
|
default:
|
||||||
return const LoginPage();
|
return const LoginPage();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:image_picker/image_picker.dart';
|
import 'package:image_picker/image_picker.dart';
|
||||||
import '../../core/navigation_provider.dart';
|
import '../../core/navigation_provider.dart';
|
||||||
|
import '../../core/app_theme.dart';
|
||||||
import '../../providers/auth_provider.dart';
|
import '../../providers/auth_provider.dart';
|
||||||
import '../../utils/sse_handler.dart';
|
import '../../utils/sse_handler.dart';
|
||||||
|
|
||||||
@@ -164,6 +165,11 @@ class DietNotifier extends Notifier<DietState> {
|
|||||||
state = state.copyWith(foods: foods);
|
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) {
|
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();
|
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);
|
state = state.copyWith(foods: foods);
|
||||||
@@ -304,300 +310,425 @@ class _DietCapturePageState extends ConsumerState<DietCapturePage> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─────────── 设计常量(与项目整体色调一致)───────────
|
||||||
|
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) {
|
Widget _buildResultView(BuildContext context, WidgetRef ref) {
|
||||||
final state = ref.watch(dietProvider);
|
final state = ref.watch(dietProvider);
|
||||||
final totalCalories = state.foods.where((f) => f.selected).fold(0, (sum, f) => sum + f.calories);
|
final totalCalories = state.foods.where((f) => f.selected).fold(0, (sum, f) => sum + f.calories);
|
||||||
|
|
||||||
return SingleChildScrollView(
|
return Container(
|
||||||
padding: const EdgeInsets.all(16),
|
color: _kPageBg,
|
||||||
child: Column(children: [
|
child: SingleChildScrollView(
|
||||||
_buildImagePreview(state.imagePath!),
|
child: Column(children: [
|
||||||
const SizedBox(height: 16),
|
_buildImagePreview(state.imagePath!),
|
||||||
_buildMealSelector(context, ref),
|
const SizedBox(height: 20),
|
||||||
const SizedBox(height: 16),
|
_buildMealSelector(ref),
|
||||||
if (state.isAnalyzing) _buildAnalyzingIndicator(state) else ...[
|
const SizedBox(height: 16),
|
||||||
_buildFoodList(context, ref),
|
if (state.isAnalyzing)
|
||||||
if (state.foods.isNotEmpty) ...[
|
_buildAnalyzingIndicator(state)
|
||||||
const SizedBox(height: 16),
|
else ...[
|
||||||
_buildNutritionSummary(totalCalories),
|
if (state.foods.isNotEmpty) ...[
|
||||||
if (state.commentary != null && state.commentary!.isNotEmpty) ...[
|
_buildFoodList(ref),
|
||||||
const SizedBox(height: 16),
|
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),
|
const SizedBox(height: 24),
|
||||||
_buildSubmitButton(context, ref),
|
_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);
|
Widget _buildImagePreview(String path) {
|
||||||
final meals = [
|
return Stack(
|
||||||
{'type': 'breakfast', 'label': '早餐', 'icon': '🌅'},
|
children: [
|
||||||
{'type': 'lunch', 'label': '午餐', 'icon': '☀️'},
|
Container(
|
||||||
{'type': 'dinner', 'label': '晚餐', 'icon': '🌙'},
|
height: 220,
|
||||||
{'type': 'snack', 'label': '加餐', 'icon': '🍪'},
|
decoration: BoxDecoration(
|
||||||
];
|
color: const Color(0xFFE0E0E0),
|
||||||
|
image: DecorationImage(image: FileImage(File(path)), fit: BoxFit.cover),
|
||||||
return Column(children: [
|
),
|
||||||
const Text('选择餐次', style: TextStyle(fontSize: 14, color: Color(0xFF666666))),
|
),
|
||||||
const SizedBox(height: 12),
|
Positioned(
|
||||||
Row(children: meals.map((meal) {
|
left: 0, right: 0, bottom: 0,
|
||||||
final isSelected = state.mealType == meal['type'];
|
child: Container(
|
||||||
return Expanded(
|
height: 60,
|
||||||
child: Padding(
|
decoration: const BoxDecoration(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 4),
|
gradient: LinearGradient(
|
||||||
child: ElevatedButton(
|
colors: [Colors.transparent, Color(0x40000000)],
|
||||||
onPressed: () => ref.read(dietProvider.notifier).setMealType(meal['type']!),
|
begin: Alignment.topCenter,
|
||||||
child: Column(children: [
|
end: Alignment.bottomCenter,
|
||||||
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),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
),
|
||||||
}).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) {
|
// ─────────── AI 点评 ───────────
|
||||||
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))),
|
|
||||||
]),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildAiCommentary(String text) {
|
Widget _buildAiCommentary(String text) {
|
||||||
return Container(
|
return Padding(
|
||||||
padding: const EdgeInsets.all(14),
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
decoration: BoxDecoration(
|
child: Container(
|
||||||
gradient: const LinearGradient(colors: [Color(0xFFEDEAFF), Color(0xFFF5F3FF)], begin: Alignment.topLeft, end: Alignment.bottomRight),
|
padding: const EdgeInsets.all(14),
|
||||||
borderRadius: BorderRadius.circular(16),
|
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) {
|
Widget _buildSubmitButton(BuildContext context, WidgetRef ref) {
|
||||||
return SizedBox(
|
return Padding(
|
||||||
width: double.infinity,
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
child: ElevatedButton(
|
child: SizedBox(
|
||||||
onPressed: () async {
|
width: double.infinity,
|
||||||
try {
|
height: 50,
|
||||||
await ref.read(dietProvider.notifier).saveRecord();
|
child: ElevatedButton(
|
||||||
if (context.mounted) {
|
onPressed: () async {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(
|
try {
|
||||||
content: Text('饮食记录已保存 ✅'),
|
await ref.read(dietProvider.notifier).saveRecord();
|
||||||
backgroundColor: Color(0xFF43A047),
|
if (context.mounted) {
|
||||||
));
|
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
|
||||||
popRoute(ref);
|
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) {
|
style: ElevatedButton.styleFrom(
|
||||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
|
backgroundColor: _kPrimary,
|
||||||
content: Text('保存失败: $e'),
|
foregroundColor: Colors.white,
|
||||||
backgroundColor: const Color(0xFFE53935),
|
elevation: 0,
|
||||||
));
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(AppTheme.radiusMd)),
|
||||||
}
|
textStyle: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600),
|
||||||
}
|
),
|
||||||
},
|
child: const Row(mainAxisAlignment: MainAxisAlignment.center, children: [
|
||||||
child: const Text('保存记录'),
|
Icon(Icons.check_circle_outline, size: 20),
|
||||||
style: ElevatedButton.styleFrom(
|
SizedBox(width: 8),
|
||||||
backgroundColor: const Color(0xFF8B9CF7),
|
Text('保存记录'),
|
||||||
foregroundColor: Colors.white,
|
]),
|
||||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)),
|
|
||||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
|
||||||
textStyle: const TextStyle(fontSize: 16),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _MealData {
|
||||||
|
final IconData icon;
|
||||||
|
final String label;
|
||||||
|
final String type;
|
||||||
|
const _MealData(this.icon, this.label, this.type);
|
||||||
}
|
}
|
||||||
@@ -175,11 +175,7 @@ class ChatMessagesView extends ConsumerWidget {
|
|||||||
Widget _agentActionBtn(_AgentAction a, double screenWidth, BuildContext context, WidgetRef ref, _AgentColors colors) {
|
Widget _agentActionBtn(_AgentAction a, double screenWidth, BuildContext context, WidgetRef ref, _AgentColors colors) {
|
||||||
return InkWell(
|
return InkWell(
|
||||||
onTap: () {
|
onTap: () {
|
||||||
if (a.action == 'createPlan') {
|
if (a.label == '服药打卡') {
|
||||||
_createExercisePlan(ref, context);
|
|
||||||
} else if (a.action == 'checkIn') {
|
|
||||||
_exerciseCheckIn(ref, context);
|
|
||||||
} else if (a.label == '服药打卡') {
|
|
||||||
_medicationCheckIn(ref, context);
|
_medicationCheckIn(ref, context);
|
||||||
} else if (a.route != null) {
|
} else if (a.route != null) {
|
||||||
if (a.route == 'camera' || a.route == 'gallery') {
|
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<Map<String, dynamic>>() ?? [];
|
|
||||||
final today = DateTime.now().weekday - 1;
|
|
||||||
final todayItem = items.cast<Map<String, dynamic>?>().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) {
|
static _AgentColors _agentColors(ActiveAgent agent) {
|
||||||
return switch (agent) {
|
return switch (agent) {
|
||||||
ActiveAgent.consultation => _AgentColors(
|
ActiveAgent.consultation => _AgentColors(
|
||||||
@@ -1401,13 +1330,27 @@ class ChatMessagesView extends ConsumerWidget {
|
|||||||
tasks.add(_taskRow(context, Icons.medication_rounded, '暂无用药提醒', status: 'pending'));
|
tasks.add(_taskRow(context, Icons.medication_rounded, '暂无用药提醒', status: 'pending'));
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. 运动
|
// 3. 运动 — 从所有计划中找今日待打卡项
|
||||||
final exOverdue = now.hour >= 18;
|
final plans = ref.read(exerciseServiceProvider).getPlans();
|
||||||
tasks.add(_taskRow(
|
plans.then((list) {
|
||||||
context, Icons.directions_run, '今日待运动:散步 30 分钟',
|
for (final p in list) {
|
||||||
status: exOverdue ? 'overdue' : 'pending',
|
final items = (p['items'] as List?)?.cast<Map<String, dynamic>>() ?? [];
|
||||||
onTap: () => pushRoute(ref, 'exercisePlan'),
|
final today = now.weekday - 1;
|
||||||
));
|
final todayItem = items.cast<Map<String, dynamic>?>().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. 测量
|
// 4. 测量
|
||||||
tasks.add(_taskRow(
|
tasks.add(_taskRow(
|
||||||
@@ -1529,8 +1472,7 @@ final _agentActions = <ActiveAgent, List<_AgentAction>>{
|
|||||||
],
|
],
|
||||||
ActiveAgent.exercise: [
|
ActiveAgent.exercise: [
|
||||||
_AgentAction(label: '查看计划', icon: Icons.calendar_month_outlined, isWide: true, route: 'exercisePlan'),
|
_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.add_task_outlined, isWide: true, route: 'exerciseCreate'),
|
||||||
_AgentAction(label: '今日打卡', icon: Icons.fact_check_outlined, isWide: true, action: 'checkIn'),
|
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -3,526 +3,133 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|||||||
import '../../core/navigation_provider.dart';
|
import '../../core/navigation_provider.dart';
|
||||||
import '../../providers/data_providers.dart';
|
import '../../providers/data_providers.dart';
|
||||||
|
|
||||||
class _MedicationItem {
|
|
||||||
String name = '';
|
|
||||||
String dosage = '';
|
|
||||||
String frequency = '每日1次';
|
|
||||||
List<TimeOfDay> 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 {
|
class MedicationEditPage extends ConsumerStatefulWidget {
|
||||||
final String? medicationId;
|
final String? id;
|
||||||
const MedicationEditPage({super.key, this.medicationId});
|
const MedicationEditPage({super.key, this.id});
|
||||||
|
@override ConsumerState<MedicationEditPage> createState() => _MedicationEditPageState();
|
||||||
@override
|
|
||||||
ConsumerState<MedicationEditPage> createState() => _MedicationEditPageState();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
class _MedicationEditPageState extends ConsumerState<MedicationEditPage> {
|
class _MedicationEditPageState extends ConsumerState<MedicationEditPage> {
|
||||||
final _items = <_MedicationItem>[];
|
final _nameCtrl = TextEditingController();
|
||||||
final _nameCtrls = <TextEditingController>[];
|
final _dosageCtrl = TextEditingController();
|
||||||
final _doseCtrls = <TextEditingController>[];
|
final _notesCtrl = TextEditingController();
|
||||||
|
int _timesPerDay = 2;
|
||||||
|
List<TimeOfDay> _times = [const TimeOfDay(hour: 8, minute: 0), const TimeOfDay(hour: 18, minute: 0)];
|
||||||
|
DateTime _start = DateTime.now();
|
||||||
|
DateTime? _end;
|
||||||
|
bool _loading = false;
|
||||||
|
|
||||||
@override
|
@override void initState() { super.initState(); if (widget.id != null) _load(); }
|
||||||
void initState() {
|
@override void dispose() { _nameCtrl.dispose(); _dosageCtrl.dispose(); _notesCtrl.dispose(); super.dispose(); }
|
||||||
super.initState();
|
|
||||||
_addItem();
|
Future<void> _load() async {
|
||||||
|
final srv = ref.read(medicationServiceProvider);
|
||||||
|
final list = await srv.getList();
|
||||||
|
final m = list.cast<Map<String, dynamic>?>().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
|
Future<void> _save() async {
|
||||||
void dispose() {
|
final name = _nameCtrl.text.trim();
|
||||||
for (final c in _nameCtrls) {
|
if (name.isEmpty) { ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('请输入药品名称'))); return; }
|
||||||
c.dispose();
|
setState(() => _loading = true);
|
||||||
}
|
final data = {
|
||||||
for (final c in _doseCtrls) {
|
'name': name, 'dosage': _dosageCtrl.text.trim(), 'frequency': 'Daily',
|
||||||
c.dispose();
|
'notes': _notesCtrl.text.trim(),
|
||||||
}
|
'timeOfDay': _times.map((t) => '${t.hour.toString().padLeft(2,'0')}:${t.minute.toString().padLeft(2,'0')}').toList(),
|
||||||
super.dispose();
|
'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',
|
||||||
void _addItem() {
|
};
|
||||||
setState(() {
|
final srv = ref.read(medicationServiceProvider);
|
||||||
_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);
|
|
||||||
try {
|
try {
|
||||||
for (final item in _items) {
|
if (widget.id != null) { await srv.update(widget.id!, data); } else { await srv.create(data); }
|
||||||
final timesStr = item.frequency == '按需服用'
|
ref.invalidate(medicationListProvider); ref.invalidate(medicationReminderProvider);
|
||||||
? []
|
if (mounted) { ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('已保存'), backgroundColor: Color(0xFF43A047))); popRoute(ref); }
|
||||||
: item.times.map((t) => t.format(context)).toList();
|
} catch (_) { if (mounted) ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('保存失败'), backgroundColor: Color(0xFFE53935))); }
|
||||||
await service.create({
|
if (mounted) setState(() => _loading = false);
|
||||||
'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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
int _timeCount(String frequency) {
|
void _updateTimes(int n) {
|
||||||
switch (frequency) {
|
setState(() {
|
||||||
case '每日1次':
|
_timesPerDay = n;
|
||||||
return 1;
|
while (_times.length < n) _times.add(const TimeOfDay(hour: 12, minute: 0));
|
||||||
case '每日2次':
|
while (_times.length > n) _times.removeLast();
|
||||||
return 2;
|
});
|
||||||
case '每日3次':
|
|
||||||
return 3;
|
|
||||||
case '每周1次':
|
|
||||||
return 1;
|
|
||||||
default:
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override Widget build(BuildContext context) {
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: const Color(0xFFF8F9FC),
|
backgroundColor: const Color(0xFFF8F9FC),
|
||||||
appBar: AppBar(
|
appBar: AppBar(title: Text(widget.id != null ? '编辑用药' : '添加用药')),
|
||||||
backgroundColor: Colors.white,
|
body: ListView(padding: const EdgeInsets.all(16), children: [
|
||||||
elevation: 0,
|
// 名称+剂量 一行
|
||||||
leading: IconButton(
|
Row(children: [
|
||||||
icon: const Icon(Icons.chevron_left),
|
Expanded(flex: 3, child: _field('药品名称', _nameCtrl, hint: '如: 阿司匹林')),
|
||||||
onPressed: () => popRoute(ref),
|
const SizedBox(width: 12),
|
||||||
),
|
Expanded(flex: 2, child: _field('剂量', _dosageCtrl, hint: '如: 100mg')),
|
||||||
title: const Text(
|
]),
|
||||||
'添加用药',
|
const SizedBox(height: 16),
|
||||||
style: TextStyle(
|
// 频率选择
|
||||||
color: Color(0xFF1A1A1A),
|
_label('每日服药次数'), const SizedBox(height: 8),
|
||||||
fontWeight: FontWeight.w600,
|
Wrap(spacing: 8, children: List.generate(4, (i) => _chip('${i + 1}次', _timesPerDay == i + 1, () => _updateTimes(i + 1)))),
|
||||||
),
|
const SizedBox(height: 16),
|
||||||
),
|
// 时间选择
|
||||||
centerTitle: true,
|
_label('服药时间'), const SizedBox(height: 8),
|
||||||
actions: [
|
Wrap(spacing: 8, runSpacing: 8, children: List.generate(_timesPerDay, (i) => GestureDetector(
|
||||||
TextButton(
|
onTap: () async {
|
||||||
onPressed: _onSave,
|
final t = await showTimePicker(context: context, initialTime: _times[i]);
|
||||||
child: const Text(
|
if (t != null) setState(() => _times[i] = t);
|
||||||
'保存',
|
},
|
||||||
style: TextStyle(
|
child: Container(padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
|
||||||
color: Color(0xFF8B9CF7),
|
decoration: BoxDecoration(color: const Color(0xFFEDEAFF), borderRadius: BorderRadius.circular(12)),
|
||||||
fontWeight: FontWeight.w600,
|
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))),
|
||||||
body: SingleChildScrollView(
|
const SizedBox(width: 12),
|
||||||
padding: const EdgeInsets.all(16),
|
Expanded(child: _dateFieldOpt('结束日期(可选)', _end, (d) => setState(() => _end = d))),
|
||||||
child: Column(
|
]),
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
const SizedBox(height: 16),
|
||||||
children: [
|
_field('备注', _notesCtrl, hint: '如: 饭后服用、睡前'),
|
||||||
...List.generate(_items.length, (i) => _buildCard(i)),
|
const SizedBox(height: 32),
|
||||||
const SizedBox(height: 12),
|
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)))),
|
||||||
_buildAddButton(),
|
const SizedBox(height: 20),
|
||||||
const SizedBox(height: 40),
|
]),
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildCard(int index) {
|
Widget _label(String text) => Text(text, style: const TextStyle(fontSize: 14, color: Color(0xFF666666)));
|
||||||
final item = _items[index];
|
Widget _field(String label, TextEditingController ctrl, {String? hint}) => Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||||
final count = _timeCount(item.frequency);
|
_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)),
|
||||||
return Container(
|
]);
|
||||||
margin: const EdgeInsets.only(bottom: 12),
|
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))));
|
||||||
padding: const EdgeInsets.all(12),
|
Widget _dateField(String label, DateTime val, ValueChanged<DateTime> cb) => Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||||
decoration: BoxDecoration(
|
_label(label), const SizedBox(height: 6),
|
||||||
color: Colors.white,
|
GestureDetector(onTap: () async { final d = await showDatePicker(context: context, initialDate: val, firstDate: DateTime(2024), lastDate: DateTime(2030)); if (d != null) cb(d); },
|
||||||
borderRadius: BorderRadius.circular(12),
|
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)))),
|
||||||
border: Border.all(color: const Color(0xFFEEEEEE)),
|
]);
|
||||||
),
|
Widget _dateFieldOpt(String label, DateTime? val, ValueChanged<DateTime?> cb) => Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||||
child: Column(
|
_label(label), const SizedBox(height: 6),
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
GestureDetector(onTap: () async { final d = await showDatePicker(context: context, initialDate: val ?? DateTime.now(), firstDate: DateTime(2024), lastDate: DateTime(2030)); if (d != null) cb(d); },
|
||||||
children: [
|
child: Container(width: double.infinity, padding: const EdgeInsets.all(12), decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(12)), child: Row(children: [
|
||||||
// Header
|
Text(val != null ? '${val.month}/${val.day}' : '不设置', style: TextStyle(fontSize: 15, color: val != null ? null : Colors.grey[400])),
|
||||||
Row(
|
if (val != null) const Spacer(),
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
if (val != null) GestureDetector(onTap: () => cb(null), child: const Icon(Icons.close, size: 16, color: Color(0xFFBBBBBB))),
|
||||||
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<String>(
|
|
||||||
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<int>(
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,160 +3,158 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|||||||
import '../../core/navigation_provider.dart';
|
import '../../core/navigation_provider.dart';
|
||||||
import '../../providers/data_providers.dart';
|
import '../../providers/data_providers.dart';
|
||||||
|
|
||||||
class MedicationListPage extends ConsumerWidget {
|
class MedicationListPage extends ConsumerStatefulWidget {
|
||||||
const MedicationListPage({super.key});
|
const MedicationListPage({super.key});
|
||||||
|
@override ConsumerState<MedicationListPage> createState() => _MedicationListPageState();
|
||||||
|
}
|
||||||
|
class _MedicationListPageState extends ConsumerState<MedicationListPage> {
|
||||||
|
String _filter = '';
|
||||||
|
Future<List<Map<String, dynamic>>>? _future;
|
||||||
|
@override void initState() { super.initState(); _load(); }
|
||||||
|
void _load() { setState(() { _future = ref.read(medicationServiceProvider).getMedications(_filter); }); }
|
||||||
|
|
||||||
@override Widget build(BuildContext context, WidgetRef ref) {
|
Future<void> _checkIn(String id) async {
|
||||||
final meds = ref.watch(medicationListProvider);
|
await ref.read(medicationServiceProvider).confirm(id);
|
||||||
|
ref.invalidate(medicationReminderProvider);
|
||||||
|
_load();
|
||||||
|
}
|
||||||
|
Future<void> _delete(String id) async {
|
||||||
|
await ref.read(medicationServiceProvider).deleteMedication(id);
|
||||||
|
_load();
|
||||||
|
}
|
||||||
|
Future<void> _toggleActive(Map<String, dynamic> 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<String, dynamic> 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(
|
return Scaffold(
|
||||||
backgroundColor: const Color(0xFFF8F9FC),
|
backgroundColor: const Color(0xFFF8F9FC),
|
||||||
appBar: AppBar(
|
appBar: AppBar(title: const Text('用药管理'), actions: [
|
||||||
backgroundColor: Colors.white,
|
TextButton(onPressed: () { pushRoute(ref, 'medicationEdit'); }, child: const Text('添加', style: TextStyle(fontSize: 15, color: Color(0xFF6C5CE7)))),
|
||||||
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)),
|
|
||||||
]),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
body: Column(children: [
|
body: Column(children: [
|
||||||
Container(padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), child: Row(children: [
|
Padding(padding: const EdgeInsets.all(12), child: Row(children: [
|
||||||
_TabChip(label: '全部', active: true),
|
_tab('全部', ''), _tab('服用中', 'active'), _tab('已停药', 'inactive'),
|
||||||
const SizedBox(width: 8),
|
|
||||||
_TabChip(label: '服用中'),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
_TabChip(label: '已停药'),
|
|
||||||
])),
|
])),
|
||||||
Expanded(child: meds.when(
|
Expanded(child: FutureBuilder<List<Map<String, dynamic>>>(
|
||||||
data: (list) {
|
future: _future,
|
||||||
if (list.isEmpty) return _empty(context);
|
builder: (ctx, snap) {
|
||||||
return ListView.builder(
|
final list = snap.data ?? [];
|
||||||
padding: const EdgeInsets.all(16),
|
if (list.isEmpty) return Center(child: Column(mainAxisSize: MainAxisSize.min, children: [
|
||||||
itemCount: list.length + 1,
|
Icon(Icons.medication_outlined, size: 64, color: Colors.grey[300]),
|
||||||
itemBuilder: (ctx, i) {
|
const SizedBox(height: 12), Text('暂无用药', style: TextStyle(color: Colors.grey[500])),
|
||||||
if (i == list.length) return const SizedBox(height: 80);
|
]));
|
||||||
final m = list[i];
|
return ListView.builder(padding: const EdgeInsets.fromLTRB(0, 0, 0, 12), itemCount: list.length + 1, itemBuilder: (ctx, i) {
|
||||||
return _MedicationCard(data: m);
|
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() {
|
Widget _tab(String label, String value) {
|
||||||
return Container(
|
final sel = _filter == value;
|
||||||
width: double.infinity,
|
return GestureDetector(onTap: () { _filter = value; _load(); },
|
||||||
padding: const EdgeInsets.all(16),
|
child: Container(margin: const EdgeInsets.only(right: 8), padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
|
||||||
decoration: BoxDecoration(color: Colors.white, boxShadow: [BoxShadow(color: Colors.grey.withAlpha(30), blurRadius: 8)]),
|
decoration: BoxDecoration(color: sel ? const Color(0xFF6C5CE7) : const Color(0xFFEDEAFF), borderRadius: BorderRadius.circular(16)),
|
||||||
child: Row(children: [
|
child: Text(label, style: TextStyle(fontSize: 13, color: sel ? Colors.white : const Color(0xFF6C5CE7), fontWeight: FontWeight.w500))),
|
||||||
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),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _MedicationCard extends StatelessWidget {
|
class _SwipeDeleteTile extends StatefulWidget {
|
||||||
final Map<String, dynamic> data;
|
final Widget child; final VoidCallback onDelete; final VoidCallback onTap;
|
||||||
const _MedicationCard({required this.data});
|
const _SwipeDeleteTile({super.key, required this.child, required this.onDelete, required this.onTap});
|
||||||
|
@override State<_SwipeDeleteTile> createState() => _SwipeDeleteTileState();
|
||||||
@override
|
}
|
||||||
Widget build(BuildContext context) {
|
class _SwipeDeleteTileState extends State<_SwipeDeleteTile> with SingleTickerProviderStateMixin {
|
||||||
return Container(
|
double _dx = 0; static const _max = 80.0, _threshold = 40.0;
|
||||||
margin: const EdgeInsets.only(bottom: 12),
|
void _onUpdate(DragUpdateDetails d) => setState(() => _dx = (_dx + d.delta.dx).clamp(-_max, 0.0));
|
||||||
padding: const EdgeInsets.all(16),
|
void _onEnd(DragEndDetails d) => setState(() => _dx = _dx < -_threshold ? -_max : 0.0);
|
||||||
decoration: BoxDecoration(
|
void _doDelete() { widget.onDelete(); setState(() => _dx = 0); }
|
||||||
color: Colors.white,
|
@override Widget build(BuildContext context) {
|
||||||
borderRadius: BorderRadius.circular(16),
|
final w = GestureDetector(
|
||||||
boxShadow: [BoxShadow(color: const Color(0xFF8B9CF7).withAlpha(10), blurRadius: 4, offset: const Offset(0, 2))],
|
onHorizontalDragUpdate: _onUpdate,
|
||||||
),
|
onHorizontalDragEnd: _onEnd,
|
||||||
child: Row(children: [
|
child: Transform.translate(offset: Offset(_dx, 0), child: widget.child),
|
||||||
Container(
|
);
|
||||||
width: 48,
|
final red = Container(
|
||||||
height: 48,
|
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
|
||||||
decoration: BoxDecoration(
|
decoration: const BoxDecoration(color: Color(0xFFE53935), borderRadius: BorderRadius.all(Radius.circular(16))),
|
||||||
color: const Color(0xFFF0F2FF),
|
child: const Align(alignment: Alignment.centerRight, child: Padding(padding: EdgeInsets.only(right: 20), child: Icon(Icons.delete_outline, color: Colors.white, size: 24))),
|
||||||
borderRadius: BorderRadius.circular(14),
|
);
|
||||||
),
|
return GestureDetector(
|
||||||
child: const Center(child: Text('💊', style: TextStyle(fontSize: 24))),
|
onTap: _dx < 0 ? _doDelete : widget.onTap,
|
||||||
),
|
child: Stack(children: [Positioned.fill(child: red), w]),
|
||||||
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)),
|
|
||||||
),
|
|
||||||
]),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ class _DietRecordListPageState extends ConsumerState<DietRecordListPage> {
|
|||||||
Future<List<Map<String, dynamic>>>? _future;
|
Future<List<Map<String, dynamic>>>? _future;
|
||||||
|
|
||||||
@override void initState() { super.initState(); _refresh(); }
|
@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) {
|
@override Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
@@ -34,21 +34,13 @@ class _DietRecordListPageState extends ConsumerState<DietRecordListPage> {
|
|||||||
final mealNames = {'Breakfast':'早餐','Lunch':'午餐','Dinner':'晚餐','Snack':'加餐'};
|
final mealNames = {'Breakfast':'早餐','Lunch':'午餐','Dinner':'晚餐','Snack':'加餐'};
|
||||||
final mealLabel = mealNames[d['mealType']?.toString()] ?? d['mealType']?.toString() ?? '';
|
final mealLabel = mealNames[d['mealType']?.toString()] ?? d['mealType']?.toString() ?? '';
|
||||||
final mealIcons = {'Breakfast':'🌅','Lunch':'☀️','Dinner':'🌙','Snack':'🍪'};
|
final mealIcons = {'Breakfast':'🌅','Lunch':'☀️','Dinner':'🌙','Snack':'🍪'};
|
||||||
return Dismissible(
|
return _SwipeDeleteTile(
|
||||||
key: Key(d['id']?.toString() ?? '$i'),
|
key: Key(d['id']?.toString() ?? '$i'),
|
||||||
direction: DismissDirection.endToStart,
|
onDelete: () async {
|
||||||
background: Container(
|
await ref.read(dietServiceProvider).deleteRecord(d['id']?.toString() ?? '');
|
||||||
alignment: Alignment.centerRight,
|
_refresh();
|
||||||
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;
|
|
||||||
},
|
},
|
||||||
|
onTap: () => pushRoute(ref, 'dietDetail', params: {'id': d['id']?.toString() ?? ''}),
|
||||||
child: Container(
|
child: Container(
|
||||||
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
|
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
|
||||||
padding: const EdgeInsets.all(14),
|
padding: const EdgeInsets.all(14),
|
||||||
@@ -77,221 +69,209 @@ class _DietRecordListPageState extends ConsumerState<DietRecordListPage> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 运动计划页
|
/// 饮食记录详情页
|
||||||
class ExercisePlanPage extends ConsumerWidget {
|
class DietRecordDetailPage extends ConsumerWidget {
|
||||||
const ExercisePlanPage({super.key});
|
final String id;
|
||||||
|
const DietRecordDetailPage({super.key, required this.id});
|
||||||
@override Widget build(BuildContext context, WidgetRef ref) {
|
@override Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final plan = ref.watch(currentExercisePlanProvider);
|
final service = ref.watch(dietServiceProvider);
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(title: const Text('运动计划'), centerTitle: true),
|
backgroundColor: const Color(0xFFF8F9FC),
|
||||||
floatingActionButton: FloatingActionButton.extended(
|
appBar: AppBar(title: const Text('饮食详情')),
|
||||||
onPressed: () => _createDefaultPlan(ref, context),
|
body: FutureBuilder<List<Map<String, dynamic>>>(
|
||||||
icon: const Icon(Icons.add),
|
future: service.getRecords(),
|
||||||
label: const Text('创建本周计划'),
|
builder: (ctx, snap) {
|
||||||
backgroundColor: const Color(0xFF8B9CF7),
|
if (!snap.hasData) return const Center(child: CircularProgressIndicator(color: Color(0xFF6C5CE7)));
|
||||||
),
|
final d = snap.data!.firstWhere((r) => r['id']?.toString() == id, orElse: () => <String, dynamic>{});
|
||||||
body: plan.when(
|
if (d.isEmpty) return const Center(child: Text('记录不存在'));
|
||||||
data: (data) {
|
final items = (d['foodItems'] as List?)?.cast<Map<String, dynamic>>() ?? [];
|
||||||
if (data == null || data.isEmpty) return _empty(context, '运动计划', '暂无运动计划,点击右下角创建');
|
final totalCal = d['totalCalories'] ?? 0;
|
||||||
final items = (data['items'] as List?)?.cast<Map<String, dynamic>>() ?? [];
|
final mealNames = {'Breakfast':'早餐','Lunch':'午餐','Dinner':'晚餐','Snack':'加餐'};
|
||||||
final weekDays = ['周一', '周二', '周三', '周四', '周五', '周六', '周日'];
|
return ListView(padding: const EdgeInsets.all(16), children: [
|
||||||
final completedCount = items.where((i) => i['isCompleted'] == true).length;
|
Container(padding: const EdgeInsets.all(16), decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(16)), child: Row(children: [
|
||||||
final totalCount = items.where((i) => i['isRestDay'] != true).length;
|
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),
|
||||||
return ListView(children: [
|
Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||||
_buildProgressCard(completedCount, totalCount),
|
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),
|
const SizedBox(height: 16),
|
||||||
...items.asMap().entries.map((entry) {
|
...items.map((f) => Container(
|
||||||
final i = entry.key;
|
margin: const EdgeInsets.only(bottom: 8),
|
||||||
final item = entry.value;
|
padding: const EdgeInsets.all(14),
|
||||||
final day = item['dayOfWeek'] is int ? item['dayOfWeek'] as int : i;
|
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(14)),
|
||||||
final isRest = item['isRestDay'] == true;
|
child: Row(children: [
|
||||||
final isDone = item['isCompleted'] == true;
|
Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||||
return _ExercisePlanItem(
|
Text(f['name']?.toString() ?? '', style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600)),
|
||||||
day: weekDays[day],
|
if ((f['portion']?.toString() ?? '').isNotEmpty) Text(f['portion']!.toString(), style: const TextStyle(fontSize: 13, color: Color(0xFF888888))),
|
||||||
dayIndex: day,
|
])),
|
||||||
isRest: isRest,
|
Text('${f['calories'] ?? 0} kcal', style: const TextStyle(fontSize: 15, color: Color(0xFF6C5CE7), fontWeight: FontWeight.w600)),
|
||||||
isDone: isDone,
|
]),
|
||||||
exerciseType: item['exerciseType']?.toString() ?? '',
|
)),
|
||||||
duration: item['durationMinutes'] is int ? item['durationMinutes'] as int : 0,
|
|
||||||
onCheckIn: () => _checkIn(ref, item['id'], context),
|
|
||||||
);
|
|
||||||
}),
|
|
||||||
]);
|
]);
|
||||||
},
|
},
|
||||||
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;
|
class ExercisePlanPage extends ConsumerStatefulWidget {
|
||||||
final int dayIndex;
|
const ExercisePlanPage({super.key});
|
||||||
final bool isRest;
|
@override ConsumerState<ExercisePlanPage> createState() => _ExercisePlanPageState();
|
||||||
final bool isDone;
|
}
|
||||||
final String exerciseType;
|
class _ExercisePlanPageState extends ConsumerState<ExercisePlanPage> {
|
||||||
final int duration;
|
Future<List<Map<String, dynamic>>>? _future;
|
||||||
final VoidCallback onCheckIn;
|
@override void initState() { super.initState(); _load(); }
|
||||||
|
void _load() => setState(() { _future = ref.read(exerciseServiceProvider).getPlans(); });
|
||||||
|
|
||||||
const _ExercisePlanItem({
|
Future<void> _checkIn(String id, String itemId) async {
|
||||||
required this.day,
|
await ref.read(exerciseServiceProvider).checkIn(itemId);
|
||||||
required this.dayIndex,
|
ref.invalidate(currentExercisePlanProvider);
|
||||||
required this.isRest,
|
_load();
|
||||||
required this.isDone,
|
}
|
||||||
required this.exerciseType,
|
|
||||||
required this.duration,
|
|
||||||
required this.onCheckIn,
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
Future<void> _deletePlan(String id) async {
|
||||||
Widget build(BuildContext context) {
|
await ref.read(exerciseServiceProvider).deletePlan(id);
|
||||||
final today = DateTime.now().weekday - 1;
|
_load();
|
||||||
final isToday = dayIndex == today;
|
}
|
||||||
|
|
||||||
return Container(
|
@override Widget build(BuildContext context) {
|
||||||
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
return Scaffold(
|
||||||
padding: const EdgeInsets.all(16),
|
backgroundColor: const Color(0xFFF8F9FC),
|
||||||
decoration: BoxDecoration(
|
appBar: AppBar(title: const Text('运动计划')),
|
||||||
color: isToday ? const Color(0xFFFEFCE8) : Colors.white,
|
floatingActionButton: FloatingActionButton(onPressed: () { pushRoute(ref, 'exerciseCreate'); }, backgroundColor: const Color(0xFF6C5CE7), child: const Icon(Icons.add)),
|
||||||
borderRadius: BorderRadius.circular(16),
|
body: FutureBuilder<List<Map<String, dynamic>>>(
|
||||||
border: isToday ? Border.all(color: const Color(0xFFFCD34D), width: 2) : null,
|
future: _future,
|
||||||
boxShadow: [BoxShadow(color: const Color(0xFF8B9CF7).withAlpha(8), blurRadius: 4, offset: const Offset(0, 2))],
|
builder: (ctx, snap) {
|
||||||
|
final plans = snap.data ?? [];
|
||||||
|
if (plans.isEmpty) return _empty(context, '运动计划', '暂无计划,点击右下角新建');
|
||||||
|
return ListView.builder(padding: const EdgeInsets.all(12), itemCount: plans.length, itemBuilder: (ctx, i) {
|
||||||
|
final p = plans[i];
|
||||||
|
final items = (p['items'] as List?)?.cast<Map<String, dynamic>>() ?? [];
|
||||||
|
final total = items.length;
|
||||||
|
final done = items.where((it) => it['isCompleted'] == true).length;
|
||||||
|
// 基于weekStartDate推算今天对应的条目索引
|
||||||
|
final weekStart = p['weekStartDate']?.toString() ?? '';
|
||||||
|
int? todayIdx;
|
||||||
|
if (weekStart.isNotEmpty) {
|
||||||
|
final ws = DateTime.tryParse(weekStart);
|
||||||
|
if (ws != null) {
|
||||||
|
final diff = DateTime.now().difference(ws).inDays;
|
||||||
|
if (diff >= 0 && diff < items.length) todayIdx = diff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
final todayItem = todayIdx != null ? items[todayIdx] : null;
|
||||||
|
final todayDone = todayItem?['isCompleted'] == true;
|
||||||
|
final exerciseName = items.isNotEmpty ? (items.first['exerciseType']?.toString() ?? '运动') : '运动';
|
||||||
|
final startDate = weekStart;
|
||||||
|
final endIdx = items.length - 1;
|
||||||
|
final endDay = items.isNotEmpty ? items.last['dayOfWeek'] as int? : 6;
|
||||||
|
|
||||||
|
return _SwipeDeleteTile(
|
||||||
|
key: Key(p['id']?.toString() ?? '$i'),
|
||||||
|
onDelete: () => _deletePlan(p['id']?.toString() ?? ''),
|
||||||
|
onTap: () {},
|
||||||
|
child: Container(
|
||||||
|
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(16)),
|
||||||
|
child: Row(children: [
|
||||||
|
Container(width: 44, height: 44, decoration: BoxDecoration(color: const Color(0xFFEDEAFF), borderRadius: BorderRadius.circular(12)), child: const Icon(Icons.directions_run, size: 22, color: Color(0xFF6C5CE7))),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||||
|
Text(exerciseName, style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600)),
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Text('$startDate 起 · ${items.first['durationMinutes'] ?? 0}分钟/天', style: const TextStyle(fontSize: 13, color: Color(0xFF888888))),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
Text('$done/$total 天已完成', style: const TextStyle(fontSize: 12, color: Color(0xFF6C5CE7))),
|
||||||
|
])),
|
||||||
|
GestureDetector(
|
||||||
|
onTap: todayItem != null ? () => _checkIn(p['id']?.toString() ?? '', todayItem['id']?.toString() ?? '') : null,
|
||||||
|
child: AnimatedContainer(
|
||||||
|
duration: const Duration(milliseconds: 300),
|
||||||
|
width: 40, height: 40,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: todayDone ? const Color(0xFFDCFCE7) : (todayItem != null ? const Color(0xFFEDEAFF) : const Color(0xFFF5F5F5)),
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
child: Icon(todayDone ? Icons.check_circle : Icons.check_circle_outline, size: 24, color: todayDone ? const Color(0xFF43A047) : const Color(0xFFBBBBBB)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
},
|
||||||
),
|
),
|
||||||
child: Row(children: [
|
);
|
||||||
Container(
|
}
|
||||||
width: 40,
|
}
|
||||||
height: 40,
|
|
||||||
decoration: BoxDecoration(
|
/// 新建运动计划
|
||||||
color: isDone ? const Color(0xFFDCFCE7) : isRest ? const Color(0xFFF3F4F6) : const Color(0xFFF0F2FF),
|
class ExercisePlanCreatePage extends ConsumerStatefulWidget {
|
||||||
borderRadius: BorderRadius.circular(12),
|
const ExercisePlanCreatePage({super.key});
|
||||||
),
|
@override ConsumerState<ExercisePlanCreatePage> createState() => _ExercisePlanCreatePageState();
|
||||||
child: isDone
|
}
|
||||||
? const Icon(Icons.check, size: 20, color: Color(0xFF43A047))
|
class _ExercisePlanCreatePageState extends ConsumerState<ExercisePlanCreatePage> {
|
||||||
: isRest
|
final _nameCtrl = TextEditingController();
|
||||||
? const Icon(Icons.coffee, size: 20, color: Color(0xFF999999))
|
final _durationCtrl = TextEditingController(text: '30');
|
||||||
: const Icon(Icons.directions_run, size: 20, color: Color(0xFF8B9CF7)),
|
DateTime _start = DateTime.now();
|
||||||
),
|
DateTime _end = DateTime.now().add(const Duration(days: 6));
|
||||||
const SizedBox(width: 12),
|
@override void dispose() { _nameCtrl.dispose(); _durationCtrl.dispose(); super.dispose(); }
|
||||||
Expanded(
|
|
||||||
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
Future<void> _save() async {
|
||||||
Row(children: [
|
final name = _nameCtrl.text.trim();
|
||||||
Text(day, style: TextStyle(fontSize: 16, fontWeight: isToday ? FontWeight.w600 : FontWeight.w500)),
|
if (name.isEmpty) { ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('请输入运动名称'))); return; }
|
||||||
if (isToday) const SizedBox(width: 4),
|
final dur = int.tryParse(_durationCtrl.text) ?? 30;
|
||||||
if (isToday) const Text('(今天)', style: TextStyle(fontSize: 12, color: Color(0xFFF59E0B))),
|
await ref.read(exerciseServiceProvider).createPlanSimple({
|
||||||
]),
|
'exerciseType': name,
|
||||||
const SizedBox(height: 4),
|
'durationMinutes': dur,
|
||||||
Text(
|
'startDate': '${_start.year}-${_start.month.toString().padLeft(2,'0')}-${_start.day.toString().padLeft(2,'0')}',
|
||||||
isRest ? '休息日,好好休息' : '$exerciseType $duration分钟',
|
'endDate': '${_end.year}-${_end.month.toString().padLeft(2,'0')}-${_end.day.toString().padLeft(2,'0')}',
|
||||||
style: TextStyle(fontSize: 14, color: Colors.grey[500]),
|
});
|
||||||
),
|
ref.invalidate(currentExercisePlanProvider);
|
||||||
]),
|
if (mounted) { ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('计划已创建'), backgroundColor: Color(0xFF43A047))); popRoute(ref); }
|
||||||
),
|
}
|
||||||
if (!isRest && !isDone)
|
|
||||||
ElevatedButton(
|
@override Widget build(BuildContext context) {
|
||||||
onPressed: onCheckIn,
|
return Scaffold(
|
||||||
child: const Text('打卡'),
|
backgroundColor: const Color(0xFFF8F9FC),
|
||||||
style: ElevatedButton.styleFrom(
|
appBar: AppBar(title: const Text('新建计划')),
|
||||||
backgroundColor: const Color(0xFF8B9CF7),
|
body: ListView(padding: const EdgeInsets.all(16), children: [
|
||||||
foregroundColor: Colors.white,
|
_field('运动名称', _nameCtrl, hint: '如: 散步、慢跑、太极'),
|
||||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
|
const SizedBox(height: 16),
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10),
|
_field('每天时长(分钟)', _durationCtrl, hint: '30', number: true),
|
||||||
),
|
const SizedBox(height: 16),
|
||||||
),
|
_dateField('开始日期', _start, (d) => setState(() => _start = d)),
|
||||||
if (isDone)
|
const SizedBox(height: 16),
|
||||||
const Text('已完成', style: TextStyle(fontSize: 14, color: Color(0xFF43A047))),
|
_dateField('结束日期', _end, (d) => setState(() => _end = d)),
|
||||||
|
const SizedBox(height: 32),
|
||||||
|
SizedBox(width: double.infinity, child: ElevatedButton(onPressed: _save, style: ElevatedButton.styleFrom(backgroundColor: const Color(0xFF6C5CE7), foregroundColor: Colors.white, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), padding: const EdgeInsets.symmetric(vertical: 14)), child: const Text('保存计划', style: TextStyle(fontSize: 16)))),
|
||||||
]),
|
]),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Widget _field(String label, TextEditingController ctrl, {String? hint, bool number = false}) {
|
||||||
|
return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||||
|
Text(label, style: const TextStyle(fontSize: 14, color: Color(0xFF666666))),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
TextField(controller: ctrl, keyboardType: number ? TextInputType.number : null, decoration: InputDecoration(hintText: hint, filled: true, fillColor: Colors.white, border: OutlineInputBorder(borderRadius: BorderRadius.circular(12), borderSide: BorderSide.none)), style: const TextStyle(fontSize: 16)),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _dateField(String label, DateTime val, ValueChanged<DateTime> onChanged) {
|
||||||
|
return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||||
|
Text(label, style: const TextStyle(fontSize: 14, color: Color(0xFF666666))),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
GestureDetector(
|
||||||
|
onTap: () async { final d = await showDatePicker(context: context, initialDate: val, firstDate: DateTime(2024), lastDate: DateTime(2030)); if (d != null) onChanged(d); },
|
||||||
|
child: Container(width: double.infinity, padding: const EdgeInsets.all(14), decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(12)),
|
||||||
|
child: Text('${val.year}-${val.month.toString().padLeft(2,'0')}-${val.day.toString().padLeft(2,'0')}', style: const TextStyle(fontSize: 16))),
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 复查列表
|
/// 复查列表
|
||||||
@@ -788,6 +768,59 @@ class DeviceManagementPage extends ConsumerWidget {
|
|||||||
@override Widget build(BuildContext context, WidgetRef ref) => _empty(context, '设备管理', '暂无绑定设备');
|
@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) =>
|
Widget _empty(BuildContext context, String title, String subtitle) =>
|
||||||
Center(child: Column(mainAxisSize: MainAxisSize.min, children: [
|
Center(child: Column(mainAxisSize: MainAxisSize.min, children: [
|
||||||
Icon(Icons.inbox_outlined, size: 64, color: Colors.grey[300]),
|
Icon(Icons.inbox_outlined, size: 64, color: Colors.grey[300]),
|
||||||
|
|||||||
@@ -80,6 +80,18 @@ class MedicationService {
|
|||||||
await _api.delete('/api/medications/$id');
|
await _api.delete('/api/medications/$id');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<List<Map<String, dynamic>>> getMedications(String filter) async {
|
||||||
|
final params = <String, dynamic>{};
|
||||||
|
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<Map<String, dynamic>>();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> deleteMedication(String id) async {
|
||||||
|
await _api.delete('/api/medications/$id');
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> confirm(String id) async {
|
Future<void> confirm(String id) async {
|
||||||
await _api.post('/api/medications/$id/confirm');
|
await _api.post('/api/medications/$id/confirm');
|
||||||
}
|
}
|
||||||
@@ -120,31 +132,31 @@ class ConsultationService {
|
|||||||
ConsultationService(this._api);
|
ConsultationService(this._api);
|
||||||
|
|
||||||
Future<List<Map<String, dynamic>>> getDoctors() async {
|
Future<List<Map<String, dynamic>>> getDoctors() async {
|
||||||
final res = await _api.get('/doctors');
|
final res = await _api.get('/api/doctors');
|
||||||
final list = res.data['data'] as List? ?? [];
|
final list = res.data['data'] as List? ?? [];
|
||||||
return list.cast<Map<String, dynamic>>();
|
return list.cast<Map<String, dynamic>>();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<Map<String, dynamic>> getQuota() async {
|
Future<Map<String, dynamic>> getQuota() async {
|
||||||
final res = await _api.get('/user/consultation-quota');
|
final res = await _api.get('/api/user/consultation-quota');
|
||||||
return res.data['data'] ?? {};
|
return res.data['data'] ?? {};
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<Map<String, dynamic>> createConsultation(String doctorId) async {
|
Future<Map<String, dynamic>> 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'];
|
return res.data['data'];
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<List<Map<String, dynamic>>> getMessages(String consultationId, {String? after}) async {
|
Future<List<Map<String, dynamic>>> getMessages(String consultationId, {String? after}) async {
|
||||||
final params = <String, dynamic>{};
|
final params = <String, dynamic>{};
|
||||||
if (after != null) params['after'] = after;
|
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? ?? [];
|
final list = res.data['data'] as List? ?? [];
|
||||||
return list.cast<Map<String, dynamic>>();
|
return list.cast<Map<String, dynamic>>();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> sendMessage(String consultationId, String content) async {
|
Future<void> 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);
|
ExerciseService(this._api);
|
||||||
|
|
||||||
Future<Map<String, dynamic>?> getCurrentPlan() async {
|
Future<Map<String, dynamic>?> getCurrentPlan() async {
|
||||||
final res = await _api.get('/exercise-plans/current');
|
final res = await _api.get('/api/exercise-plans/current');
|
||||||
return res.data['data'];
|
return res.data['data'];
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> createPlan(Map<String, dynamic> data) async {
|
Future<void> createPlan(Map<String, dynamic> data) async {
|
||||||
await _api.post('/exercise-plans', data: data);
|
await _api.post('/api/exercise-plans', data: data);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> createPlanSimple(Map<String, dynamic> data) async {
|
||||||
|
await _api.post('/api/exercise-plans', data: data);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> deletePlan(String id) async {
|
||||||
|
await _api.delete('/api/exercise-plans/$id');
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> checkIn(String itemId) async {
|
Future<void> checkIn(String itemId) async {
|
||||||
await _api.post('/exercise-plans/items/$itemId/checkin');
|
await _api.post('/api/exercise-plans/items/$itemId/checkin');
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<List<Map<String, dynamic>>> getPlans() async {
|
||||||
|
final res = await _api.get('/api/exercise-plans');
|
||||||
|
return (res.data['data'] as List?)?.cast<Map<String, dynamic>>() ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Map<String, dynamic>?> getPlanDetail(String id) async {
|
||||||
|
final res = await _api.get('/api/exercise-plans/$id');
|
||||||
|
return res.data['data'];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -187,8 +187,14 @@ class HealthDrawer extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
const Spacer(),
|
const Spacer(),
|
||||||
GestureDetector(
|
GestureDetector(
|
||||||
onTap: () => ref.invalidate(conversationListProvider),
|
onTap: () async {
|
||||||
child: const Padding(padding: EdgeInsets.all(4), child: Icon(Icons.refresh, size: 15, color: Color(0xFFAAAAAA))),
|
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,
|
item: item,
|
||||||
onTap: () {
|
onTap: () {
|
||||||
ref.read(chatProvider.notifier).loadConversation(item.id);
|
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(),
|
)).toList(),
|
||||||
),
|
),
|
||||||
@@ -424,8 +436,9 @@ class _FeatureChip extends StatelessWidget {
|
|||||||
class _ConversationItem extends StatelessWidget {
|
class _ConversationItem extends StatelessWidget {
|
||||||
final ConversationItem item;
|
final ConversationItem item;
|
||||||
final VoidCallback? onTap;
|
final VoidCallback? onTap;
|
||||||
|
final VoidCallback? onDelete;
|
||||||
|
|
||||||
const _ConversationItem({required this.item, this.onTap});
|
const _ConversationItem({required this.item, this.onTap, this.onDelete});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
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))),
|
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])),
|
subtitle: Text(item.lastMessage, maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle(fontSize: 11, color: Colors.grey[500])),
|
||||||
trailing: Column(
|
trailing: GestureDetector(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
onTap: onDelete,
|
||||||
mainAxisSize: MainAxisSize.min,
|
child: const Padding(
|
||||||
children: [
|
padding: EdgeInsets.all(8),
|
||||||
Text(_formatTime(item.updatedAt), style: TextStyle(fontSize: 9, color: Colors.grey[400])),
|
child: Icon(Icons.close, size: 16, color: Color(0xFFBBBBBB)),
|
||||||
const SizedBox(height: 2),
|
),
|
||||||
Icon(Icons.chevron_right, size: 12, color: Colors.grey[300]),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
dense: true,
|
dense: true,
|
||||||
visualDensity: VisualDensity.compact,
|
visualDensity: VisualDensity.compact,
|
||||||
|
|||||||
Reference in New Issue
Block a user