记录项目的文档,记录开发时的思路,方便查询。
CatTodo项目 02
目标:
- 完成主页(index)主要包含导航栏用于跳转其他页面 将界面改为未登录也可以使用 在个人中心登录 ✅ 2026-01-17
- 导航:首页、目标、团队、商店、个人 ✅ 2026-01-17
- 完成Todo数据库的设计-包括目标里的情况 团队的情况 个人的情况 需要考虑各种情况 考虑性能是否能优化 ✅ 2026-01-19
- 思考本地优先同步功能的具体实现 ✅ 2026-01-19
- 设计清单数据库结构 ✅ 2026-01-20
- 前端完成创建任务的界面和功能 ✅ 2026-01-25
- 完成创建任务底部弹出列表 ✅ 2026-01-22
- 完成选择清单底部列表 完成新建清单弹窗 ✅ 2026-01-22
- 完成选择重复方式的列表 ✅ 2026-01-23
- 完成选择提醒时间列表 ✅ 2026-01-22
- 完成选择优先级功能 ✅ 2026-01-22
- 完成添加任务奖励功能 ✅ 2026-01-23
- 完成实际添加子任务操作 ✅ 2026-01-23
- 前端完成根据任务母版生成任务实例的方法 ✅ 2026-01-25
- 前端完成TODO项的显示 ✅ 2026-01-25
- 前端完成任务编辑 ✅ 2026-01-25
- 前端完成个人中心界面 ✅ 2026-01-25
- 可以修改用户名 修改密码(先不做) 头像(先从几个固定的里面选) ✅ 2026-01-25
- 完成前端设置页面 ✅ 2026-01-25
- 主题设置-随便搞几个颜色主题、动态取色 ✅ 2026-01-25
- 后端实现同步接口 ✅ 2026-01-25
- 前端对接同步接口 思考什么情况下需要同步 实现多端同步 ✅ 2026-01-25
- 确认需要同步的情况 ✅ 2026-01-25
- 前端完成自动同步功能 ✅ 2026-01-25
前端修改:
- 修改默认路由指向首页
- 添加认证工具类 用于判断是否登录
import 'package:get/get.dart';import '../../common/services/user_service.dart';import '../constants/routes_constants.dart';
/// 认证工具类class AuthUtils { /// 检查是否登录,未登录则跳转登录页 /// return: true-已登录, false-未登录(并跳转) static bool checkLogin({bool toLogin = true}) { // 检查是否有 token final hasToken = Get.find<UserService>().hasToken;
if (!hasToken && toLogin) { Get.toNamed(RoutesConstants.login); }
return hasToken; }}- 添加没有内容的首页 目标 团队 商店 个人这五个页面
- 书写AuthUtil工具类用于判断是否登录
无线调试
在测试时发现出现了无线调试连接不上手机的情况,使用android studio怎么都连不上。 解决办法:手动连接 找到abd.exe的位置,执行指令:
.\adb kill-server.\adb start-server# 这里是匹配码那边的端口号.\adb pair 192.168.99.32:25315# 这里要看设备无线调试的端口号.\adb connect 192.168.99.32:12345任务数据库设计
需求分析:
为了高效率使用两个表:
- 任务母版:用于存放规则
- 任务实例:通过规则渲染某一天的任务实例
循环规则使用RRULE,方便计算某一天 次数规则使用自定义的JSON格式:
- 值为空:表示单次任务
QUOTA: 定量模式,参数target: n(如需要完成 3 次)。INFINITE: 无限模式,无上限,永远不会自动变成 Done。
运行流程:
- 打开app时自动查询符合要求的任务母版,创建对应的任务实例。
- 通过规则计算下一天是否需要生成,修改日期。
- 修改某一个任务母版,删除当天生成的这个母版的所有任务实例然后重新根据新母版创建任务实例。
- 排除某天的功能暂时不做(todo)
个人的任务项
1. 任务基础属性
- 专注模式:正计时/倒计时(未来可扩展)
- 时间设定:
- 开始时间(可选)
- 截止时间(可选)
- 支持不限时任务
- 视觉标识:
- 清单分类
- 任务结构:
- 支持子任务(用户最多一层嵌套 ai可以多层)
- 激励机制:可设置任务奖励
- 经验值
- 金币
- 物品:
- 本地优先:必须单独在本地可以使用
- 支持排序:使用字符串实现o(1)的排序
- 是否删除:任务是否被删除
2. 任务循环模式
可以选择开始日期,不选择则自动根据选择的模式设置。
- 次数:
- 单次任务(只需要完成1次)
- 固定次数循环(需要完成n次才算完成这个任务)
- 循环:
- 无限任务(一天内可完成多次)
- 每N天循环(每多少天出现一次)
- 艾宾浩斯曲线循环
- 按周循环(选择哪天出现,周一至周日可选)
- 结束循环
- 指定日期
- 无限循环
- 指定次数
3. 任务提醒
使用reminder_config的json字段:
{ "json":"exact", // "relative" "offsetDays":1, //提前几天 "time":"09:00" //几点提醒}4. 任务循环规则(RRULE)
使用rrule的json字段,按rrule的格式来书写。
5. 任务母版数据库设计
create table task_masters( id char(36) not null comment '主键ID(UUID)' primary key, title varchar(255) default '' not null comment '任务母版标题', description text null comment '任务母版描述', start_date date null comment '任务母版开始日期', due_date date null comment '任务母版期限日期 生成实例后会修改 null表示无期限 需要完成任务或者放弃任务才能到下个周期 ', total_cycles int default 0 not null comment '总循环次数 0表示无限循环', completed_cycles int default 0 not null comment '已完成循环次数', repeat_until date null comment '重复到某个日期结束 null表示不设结束日期', predecessor_ids json null comment '前置任务母版ID列表 (JSON数组) null表示无前置任务', remind_config json null comment '提醒规则 (JSON) null表示不提醒 time_before表示提前多少分钟提醒 custom_times表示自定义提醒时间', current_quantitative int default 0 not null comment '当前量化进度', quantitative_weight int default 1 not null comment '量化权重 百分比表示任务占用父任务进度的多少 按比例计算', user_id varchar(36) null comment '所属用户ID(UUID), NULL表示团队任务', goal_id varchar(36) null comment '关联目标ID(UUID)', project_id varchar(36) null comment '关联项目ID(UUID), NULL表示不属于任何项目', parent_id varchar(36) null comment '父任务母版ID(UUID), NULL表示无父任务, 子任务大多参数继承父任务', list_id varchar(36) default '' not null comment '所属清单ID(UUID)', rrule varchar(255) null comment '循环规则 (RRULE)', execution_config json null comment '执行/量化规则 (JSON) ,null表示1次性任务', next_due_date date null, status tinyint(1) default 0 not null comment '任务母版状态 (0使用中, 1已结束, 2归档)', sort_order varchar(255) collate utf8mb4_bin not null, priority tinyint(1) default 1 not null comment '任务优先级 0-3 (0表示低优先级 1表示中等优先级 2表示高优先级 3表示紧急)', created_at datetime default CURRENT_TIMESTAMP null, updated_at datetime default CURRENT_TIMESTAMP null on update CURRENT_TIMESTAMP, deleted tinyint(1) default 0 null comment '逻辑删除 0表示存在 1表示删除', synced tinyint(1) default 0 not null comment '是否已同步到服务器 0表示未同步 1表示已同步', last_synced_at datetime default CURRENT_TIMESTAMP null comment '最后同步时间') comment '任务母版表';6. 任务实例数据库设计:
create table task_instances( id char(36) not null comment '主键ID(UUID)' primary key, title varchar(255) default '' not null comment '任务母版标题', description text null comment '任务母版描述', task_master_id varchar(36) not null comment '关联任务母版ID(UUID)', due_date date null comment '任务期限日期 null表示无期限 需要完成任务或者放弃任务才能到下个周期', list_id varchar(36) default '' not null comment '所属清单ID(UUID)', sort_order varchar(255) collate utf8mb4_bin not null, priority tinyint(1) default 1 not null comment '任务优先级 0-3 (0表示低优先级 1表示中等优先级 2表示高优先级 3表示紧急)', user_id varchar(36) null comment '所属用户ID(UUID), NULL表示团队任务', project_id varchar(36) null comment '关联项目ID(UUID), NULL表示不属于任何项目', current_quantitative int default 0 not null comment '当前量化进度', quantitative_weight int default 1 not null comment '量化权重 百分比表示任务占用父任务进度的多少 按比例计算', current_progress int default 0 not null comment '当前完成了几次', target_progress int default 1 not null comment '当天的目标次数 (-1 代表无限)', display_date date not null comment '这个任务属于哪一天', status tinyint(1) default 0 not null comment '任务实例状态 (0未完成,1完成,-1放弃或失败)', created_at datetime default CURRENT_TIMESTAMP null, updated_at datetime default CURRENT_TIMESTAMP null on update CURRENT_TIMESTAMP, deleted tinyint(1) default 0 null comment '逻辑删除 0表示存在 1表示删除', synced tinyint(1) default 0 not null comment '是否已同步到服务器 0表示未同步 1表示已同步', last_synced_at datetime default CURRENT_TIMESTAMP null comment '最后同步时间') comment '任务实例表';Java后端模型生成
直接使用Mybatis生成插件一键生成。 接着创建几个json配置模型
package top.shineacz.cattodo.domain.task;
import lombok.Data;import java.io.Serializable;
@Datapublic class TaskExecutionConfig implements Serializable { /** * 模式类型: QUOTA (定量), INFINITE (无限) */ private TaskExecutionType type;
/** * 目标次数 (仅在 QUOTA 模式下有�? */ private Integer target;}
package top.shineacz.cattodo.domain.task;
import lombok.AllArgsConstructor;import lombok.Getter;
@Getter@AllArgsConstructorpublic enum TaskExecutionType { QUOTA("QUOTA"), INFINITE("INFINITE");
private final String code;}
package top.shineacz.cattodo.domain.task;
import lombok.Data;import java.io.Serializable;
@Datapublic class TaskRemindConfig implements Serializable { /** * 提前几天提醒 (0表示当天) */ private Integer offsetDays;
/** * 提醒时间 (格式 HH:mm; "time":"09:00") */ private String time;}修改mapper:
<result property="predecessorIds" column="predecessor_ids" typeHandler="com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler" /><result property="remindConfig" column="remind_config" typeHandler="com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler" /><result property="executionConfig" column="execution_config" typeHandler="com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler" />Flutter模型书写
修改代码使用Result:
发现之前都是直接动态类型 下一步之前先修改项目使用上Result模型 完整代码:
import 'dart:async';import 'package:cattodo_flutter/common/api/user_api.dart';import 'package:cattodo_flutter/common/constants/global_constants.dart';import 'package:cattodo_flutter/common/models/Result/result.dart';import 'package:cattodo_flutter/common/services/user_service.dart';import 'package:dio/dio.dart';import 'package:flutter_smart_dialog/flutter_smart_dialog.dart';import 'package:get/get.dart' as getx; // 避免冲突import 'package:logger/logger.dart';
class HttpUtils { static final HttpUtils _instance = HttpUtils._internal(); factory HttpUtils() => _instance;
late Dio _dio; final Logger _logger = getx.Get.find<Logger>();
// 刷新 Token 标志位和 Completer bool _isRefreshing = false; Completer<void>? _refreshCompleter;
HttpUtils._internal() { _dio = Dio( BaseOptions( baseUrl: GlobalConstants.baseUrl, sendTimeout: const Duration(seconds: GlobalConstants.sendTimeout), connectTimeout: const Duration(seconds: GlobalConstants.connectTimeout), receiveTimeout: const Duration(seconds: GlobalConstants.receiveTimeout), ), );
// 拦截器 _dio.interceptors.add( InterceptorsWrapper( onRequest: (options, handler) { // 这里加 Token if (getx.Get.isRegistered<UserService>() && UserService.to.hasToken) { options.headers['Authorization'] = 'Bearer ${UserService.to.token?.accessToken}'; } return handler.next(options); }, onResponse: (response, handler) async { // 可以在这里统一处理 {code: 200} final data = response.data; // 假设后端返回结构为:code, message, data if (data is Map<String, dynamic>) { final int? code = data['code']; final String? message = data['message']; final String? description = data['description']; // 输出日志 _logger.i( 'Response Code: $code, Message: $message, Description: $description', );
// 处理 Token 过期 / 无效 / 未授权 (10001, 20301, 20302, 20303) if (code == 10001 || code == 20301 || code == 20302 || code == 20303) { // 如果是刷新 Token 接口本身报错,直接抛出,不进行重试以免死循环 if (response.requestOptions.path.contains('/refresh-token')) { return handler.reject( DioException( requestOptions: response.requestOptions, error: message, type: DioExceptionType.badResponse, response: response, ), ); }
if (_isRefreshing) { // 正在刷新中,等待完成 try { await _refreshCompleter?.future; // 刷新成功后重试 final retryResponse = await _retry(response.requestOptions); return handler.resolve(retryResponse); } catch (e) { // 刷新失败,跟随失败 return handler.reject( DioException( requestOptions: response.requestOptions, error: 'Token refresh failed', type: DioExceptionType.badResponse, response: response, ), ); } }
// 开始刷新 _isRefreshing = true; _refreshCompleter = Completer<void>();
try { final refreshToken = UserService.to.token?.refreshToken; if (refreshToken == null) { throw Exception("No refresh token available"); }
// 调用刷新接口 final newToken = await UserApi.refreshToken(refreshToken); // 保存新 Token await UserService.to.saveToken(newToken);
_refreshCompleter?.complete(); _isRefreshing = false;
// 重试当前请求 final retryResponse = await _retry(response.requestOptions); return handler.resolve(retryResponse); } catch (e) { _logger.e("Auto refresh token failed: $e"); _refreshCompleter?.completeError(e); _isRefreshing = false;
// 刷新失败,强制退出 UserService.to.logout();
return handler.reject( DioException( requestOptions: response.requestOptions, error: 'Session expired, please login again.', type: DioExceptionType.badResponse, response: response, ), ); } }
// 200 表示成功,视后端定义而定。 if (code != null && code != GlobalConstants.successCode) { // 抛出业务异常 // 优先显示 description, 如果没有才显示 message final errorMsg = (description != null && description.isNotEmpty) ? description : (message ?? 'common_error'.tr);
SmartDialog.showToast(errorMsg); // 构造一个 DioException 抛给上层或者直接 reject return handler.reject( DioException( requestOptions: response.requestOptions, error: errorMsg, type: DioExceptionType.badResponse, response: response, ), ); } else if (code == GlobalConstants.successCode) { // 不再自动解包 data,而是返回完整 Result 结构给上层 // 由 HttpUtils.get/post 根据泛型 T 解析 } } return handler.next(response); }, onError: (DioException e, handler) { _handleError(e); return handler.next(e); }, ), ); }
/// 重试请求 Future<Response<dynamic>> _retry(RequestOptions requestOptions) async { final options = Options( method: requestOptions.method, headers: requestOptions.headers, ); // 重试时,Token 会在 onRequest 中重新获取最新的 return _dio.request<dynamic>( requestOptions.path, data: requestOptions.data, queryParameters: requestOptions.queryParameters, options: options, ); }
/// GET 请求 Future<Result<T>> get<T>( String path, { Map<String, dynamic>? params, bool showLoading = true, T Function(Object? json)? fromJson, }) async { if (showLoading) { SmartDialog.showLoading(msg: 'common_loading'.tr); // 结合 i18n } try { final response = await _dio.get(path, queryParameters: params); return Result.fromJson( response.data, fromJson ?? (json) => json as T, ); } catch (e) { rethrow; } finally { if (showLoading) SmartDialog.dismiss(); } }
/// POST 请求 Future<Result<T>> post<T>( String path, { dynamic data, Map<String, dynamic>? params, bool showLoading = true, T Function(Object? json)? fromJson, }) async { if (showLoading) SmartDialog.showLoading(msg: 'common_loading'.tr); try { final response = await _dio.post( path, data: data, queryParameters: params, ); return Result.fromJson( response.data, fromJson ?? (json) => json as T, ); } catch (e) { rethrow; } finally { if (showLoading) SmartDialog.dismiss(); } }
// 错误处理 void _handleError(DioException e) { String msg = "common_error".tr; if (e.type == DioExceptionType.connectionTimeout) { msg = "Connect Timeout"; } else if (e.response != null) { // 这里的 response.data 可能是后端返回的 Result JSON,也可能是一个 error page // 如果 onResponse 已经 reject 了,这里会接收到 // 如果是业务异常,msg 已经在 onResponse 中取过了,但 DioException 中 error 属性可能就是那个 msg if (e.error is String) { msg = e.error as String; } else { msg = "Server Error: ${e.response?.statusCode}"; } } else if (e.error != null) { msg = e.error.toString(); } _logger.e(msg); SmartDialog.showToast(msg); }}定义api部分的修改:
static Future<Token> login(UserLoginDTO params) async { var result = await HttpUtils().post<Token>( '/user/login', data: params.toJson(), fromJson: (json) => Token.fromJson(json as Map<String, dynamic>), ); // HttpUtils 内部拦截器会在 code!=200 时抛出异常 // 所以这里的 data 一定不为 null return result.data!; }
static Future<Token> refreshToken(String refreshToken) async { var result = await HttpUtils().post<Token>( '/user/refresh-token', data: RefreshTokenDTO(refreshToken: refreshToken).toJson(), showLoading: false, // 静默刷新 fromJson: (json) => Token.fromJson(json as Map<String, dynamic>), ); return result.data!; }定义各种内容
定义枚举:
import 'package:hive/hive.dart';import 'package:json_annotation/json_annotation.dart';
part 'enums.g.dart';
/// 任务母版状态/// 0:使用中, 1:已结束, 2:归档@HiveType(typeId: 2)@JsonEnum(valueField: 'code')enum TaskMasterStatus { @HiveField(0) inUse(0), // 使用中 @HiveField(1) ended(1), // 已结束 @HiveField(2) archived(2); // 归档
final int code; const TaskMasterStatus(this.code);}
/// 任务优先级/// 0:低, 1:中, 2:高, 3:紧急@HiveType(typeId: 3)@JsonEnum(valueField: 'code')enum TaskPriority { @HiveField(0) low(0), // 低优先级 @HiveField(1) medium(1), // 中等优先级 @HiveField(2) high(2), // 高优先级 @HiveField(3) emergency(3); // 紧急
final int code; const TaskPriority(this.code);}
/// 任务实例状态/// 0:未完成, 1:完成, -1:放弃或失败@HiveType(typeId: 4)@JsonEnum(valueField: 'code')enum TaskInstanceStatus { @HiveField(0) unfinished(0), // 未完成 @HiveField(1) finished(1), // 完成 @HiveField(2) abandoned(-1); // 放弃或失败
final int code; const TaskInstanceStatus(this.code);}定义任务母版模型:
import 'package:freezed_annotation/freezed_annotation.dart';import 'package:hive/hive.dart';import 'enums.dart';
part 'task_master.freezed.dart';part 'task_master.g.dart';
/// 任务母版 (TaskMaster)/// 定义任务的属性、循环规则、执行配置等@freezedclass TaskMaster with _$TaskMaster { @HiveType(typeId: 0) const factory TaskMaster({ /// 主键ID(UUID) @HiveField(0) required String id,
/// 任务母版标题 @HiveField(1) String? title,
/// 任务母版描述 @HiveField(2) String? description,
/// 所属用户ID(UUID), NULL表示团队任务 @HiveField(3) String? userId,
/// 关联目标ID(UUID) @HiveField(4) String? goalId,
/// 关联项目ID(UUID), NULL表示不属于任何项目 @HiveField(5) String? projectId,
/// 父任务母版ID(UUID), NULL表示无父任务, 子任务大多参数继承父任务 @HiveField(6) String? parentId,
/// 循环规则 (RRULE) @HiveField(7) String? rrule,
/// 执行/量化规则 (JSON对象) /// null表示1次性任务 /// 建议存储 Map<String, dynamic> 以兼容 JSON 结构 @HiveField(8) Map<String, dynamic>? executionConfig,
/// 下次执行日期 @HiveField(9) DateTime? nextDueDate,
/// 任务母版状态 (默认: 使用中) @HiveField(10) @Default(TaskMasterStatus.inUse) TaskMasterStatus status,
/// 所属清单ID(UUID), 空字符串表示默认清单 @HiveField(11) String? listId,
/// 排序字段 @HiveField(12) String? sortOrder,
/// 任务优先级 (默认: 低) @HiveField(13) @Default(TaskPriority.low) TaskPriority priority,
/// 创建时间 @HiveField(14) DateTime? createdAt,
/// 更新时间 @HiveField(15) DateTime? updatedAt,
/// 逻辑删除 (false:存在, true:已删除) @HiveField(16) @Default(false) bool deleted,
/// 是否已同步到服务器 (false:未同步, true:已同步) @HiveField(17) @Default(false) bool synced,
/// 最后同步时间 @HiveField(18) DateTime? lastSyncedAt,
/// 任务母版开始日期 @HiveField(19) DateTime? startDate,
/// 任务母版期限日期 /// 生成实例后会修改 null表示无期限 需要完成任务或者放弃任务才能到下个周期 @HiveField(20) DateTime? dueDate,
/// 总循环次数 0表示无限循环 @HiveField(21) int? totalCycles,
/// 已完成循环次数 @HiveField(22) int? completedCycles,
/// 重复到某个日期结束 null表示不设结束日期 @HiveField(23) DateTime? repeatUntil,
/// 前置任务母版ID列表 (JSON数组) null表示无前置任务 @HiveField(24) List<String>? predecessorIds,
/// 提醒规则 (JSON对象) /// null表示不提醒 time_before表示提前多少分钟提醒 custom_times表示自定义提醒时间 @HiveField(25) Map<String, dynamic>? remindConfig,
/// 当前量化进度 @HiveField(26) int? currentQuantitative,
/// 量化权重 /// 百分比表示任务占用父任务进度的多少 按比例计算 @HiveField(27) int? quantitativeWeight, }) = _TaskMaster;
factory TaskMaster.fromJson(Map<String, dynamic> json) => _$TaskMasterFromJson(json);}定义任务实例模型:
import 'package:freezed_annotation/freezed_annotation.dart';import 'package:hive/hive.dart';import 'enums.dart';
part 'task_instance.freezed.dart';part 'task_instance.g.dart';
/// 任务实例 (TaskInstance)/// 某一天产生的具体任务,记录当日的完成情况@freezedclass TaskInstance with _$TaskInstance { @HiveType(typeId: 1) const factory TaskInstance({ /// 主键ID(UUID) @HiveField(0) required String id,
/// 任务实例标题 (通常继承自母版) @HiveField(1) String? title,
/// 任务实例描述 (通常继承自母版) @HiveField(2) String? description,
/// 关联任务母版ID(UUID) @HiveField(3) String? taskMasterId,
/// 所属清单ID(UUID), 空字符串表示默认清单 @HiveField(4) String? listId,
/// 当前完成了几次 @HiveField(5) @Default(0) int currentProgress,
/// 当天的目标次数 (-1 代表无限) /// 默认为1, 适用于普通的打钩任务 @HiveField(6) @Default(1) int targetProgress,
/// 这个任务属于哪一天 @HiveField(7) DateTime? displayDate,
/// 任务实例状态 (默认: 未完成) @HiveField(8) @Default(TaskInstanceStatus.unfinished) TaskInstanceStatus status,
/// 创建时间 @HiveField(9) DateTime? createdAt,
/// 更新时间 @HiveField(10) DateTime? updatedAt,
/// 逻辑删除 (false:存在, true:已删除) @HiveField(11) @Default(false) bool deleted,
/// 是否已同步到服务器 (false:未同步, true:已同步) @HiveField(12) @Default(false) bool synced,
/// 最后同步时间 @HiveField(13) DateTime? lastSyncedAt,
/// 任务期限日期 null表示无期限 @HiveField(14) DateTime? dueDate,
/// 排序字段 @HiveField(15) String? sortOrder,
/// 任务优先级 (默认: 低) @HiveField(16) @Default(TaskPriority.low) TaskPriority priority,
/// 所属用户ID(UUID), NULL表示团队任务 @HiveField(17) String? userId,
/// 关联项目ID(UUID), NULL表示不属于任何项目 @HiveField(18) String? projectId,
/// 当前量化进度 @HiveField(19) int? currentQuantitative,
/// 量化权重 /// 百分比表示任务占用父任务进度的多少 按比例计算 @HiveField(20) int? quantitativeWeight, }) = _TaskInstance;
factory TaskInstance.fromJson(Map<String, dynamic> json) => _$TaskInstanceFromJson(json);}定义各种Json配置
predecessor_ids
/// 前置任务母版ID列表 (JSON数组) null表示无前置任务 @HiveField(24) List<String>? predecessorIds,remind_config execution_config
import 'package:freezed_annotation/freezed_annotation.dart';import 'package:hive/hive.dart';
part 'task_configs.freezed.dart';part 'task_configs.g.dart';
/// 任务执行模式类型/// QUOTA: 定量模式/// INFINITE: 无限模式@HiveType(typeId: 5)enum TaskExecutionType { @HiveField(0) @JsonValue('QUOTA') quota,
@HiveField(1) @JsonValue('INFINITE') infinite,}
/// 任务执行/量化规则配置@freezedclass TaskExecutionConfig with _$TaskExecutionConfig { @HiveType(typeId: 6) const factory TaskExecutionConfig({ /// 模式类型 @HiveField(0) required TaskExecutionType type,
/// 目标次数. 仅在 QUOTA 模式下有效. @HiveField(1) int? target, }) = _TaskExecutionConfig;
factory TaskExecutionConfig.fromJson(Map<String, dynamic> json) => _$TaskExecutionConfigFromJson(json);}
/// 任务提醒规则配置@freezedclass TaskRemindConfig with _$TaskRemindConfig { @HiveType(typeId: 7) const factory TaskRemindConfig({ /// 提前几天提醒 (0表示当天) @HiveField(0) @Default(0) int offsetDays,
/// 提醒时间 (格式 HH:mm, 如 "09:00") @HiveField(1) required String time, }) = _TaskRemindConfig;
factory TaskRemindConfig.fromJson(Map<String, dynamic> json) => _$TaskRemindConfigFromJson(json);}使用定义的Flutter任务模型(Ai)
1. 前置准备:注册 Adapter
在使用 Hive 存储这些对象之前,必须在 main.dart 或 global.dart 的初始化逻辑中注册生成的 Adapter。否则 Hive 不知道如何读写这些自定义对象。
// 在 Global.init() 或 main() 中 // 注意:Freezed 生成的 Adapter 名称通常带有 Impl 后缀 Hive.registerAdapter(TaskMasterImplAdapter()); Hive.registerAdapter(TaskInstanceImplAdapter()); Hive.registerAdapter(TaskMasterStatusAdapter()); Hive.registerAdapter(TaskPriorityAdapter()); Hive.registerAdapter(TaskInstanceStatusAdapter()); Hive.registerAdapter(TaskExecutionTypeAdapter()); Hive.registerAdapter(TaskExecutionConfigImplAdapter()); Hive.registerAdapter(TaskRemindConfigImplAdapter());
// 然后打开盒子 // 注意:存入时使用具体类型,或者确保 Hive 能识别 await Hive.openBox<TaskMaster>('task_masters'); await Hive.openBox<TaskInstance>('task_instances');2. 如何创建并存储到本地 (Create & Save)
通常会在 TaskService (全局服务) 或 TaskController (页面控制器) 中处理。
场景:用户在“新建任务”页面填写了标题和描述,点击“保存”。
// 1. 收集页面数据 (通常在 Controller 中)final String title = titleController.text;final String uuid = const Uuid().v7(); // 生成唯一ID
// 2. 构建对象 (使用 Freezed 的构造函数)final newTask = TaskMaster( id: uuid, title: title, description: "这是描述...", status: TaskMasterStatus.inUse, createdAt: DateTime.now(), updatedAt: DateTime.now(), // 如果有配置对象 executionConfig: const TaskExecutionConfig( type: TaskExecutionType.quota, target: 5, ), // ... 其他字段);
// 3. 存入 Hivefinal box = Hive.box<TaskMaster>('task_masters');// 使用 id 作为 key,方便根据 ID 快速查找和覆盖更新await box.put(newTask.id, newTask);3. 如何取用和筛选数据 (Read & Filter)
因为 Hive 是 Key-Value 数据库,简单的筛选通常是在内存中完成的(因为数据量通常在几万条以内,Dart 在内存中过滤非常快)。
场景:筛选出所有“使用中”且“高优先级”的任务母版。
final box = Hive.box<TaskMaster>('task_masters');
// box.values 获取所有对象final List<TaskMaster> highPriorityTasks = box.values .where((task) => task.status == TaskMasterStatus.inUse && task.priority == TaskPriority.high ) .toList();
// 如果需要排序,例如按创建时间倒序highPriorityTasks.sort((a, b) => b.createdAt!.compareTo(a.createdAt!));4. 如何在页面中使用 (UI Integration)
结合 GetX 的响应式状态管理。
Controller 层:
class TaskController extends GetxController { // 定义一个可观察的列表 final RxList<TaskMaster> tasks = <TaskMaster>[].obs;
@override void onInit() { super.onInit(); loadTasks(); }
void loadTasks() { final box = Hive.box<TaskMaster>('task_masters'); // 转换 values 为 list 并赋值给 obs tasks.value = box.values.toList(); }
void addTask(TaskMaster task) async { final box = Hive.box<TaskMaster>('task_masters'); await box.put(task.id, task); loadTasks(); // 重新加载刷新列表 // 或者直接 tasks.add(task); 进行局部刷新 }}View 层:
// 在页面中使用 Obx 监听Obx(() { if (controller.tasks.isEmpty) return EmptyView();
return ListView.builder( itemCount: controller.tasks.length, itemBuilder: (context, index) { final task = controller.tasks[index]; return ListTile( title: Text(task.title ?? "无标题"), subtitle: Text("类型: ${task.executionConfig?.type}"), ); }, );})5. 如何发请求传给后端 (Network Request)
由于我们使用了 Freezed 加上 @JsonSerializable (通过 json_annotation),模型自带了 toJson() 方法。
场景:将本地新建的任务同步到服务器。
// 1. 定义 API (在 user_api.dart 或新建 task_api.dart)class TaskApi { static Future<void> createTask(TaskMaster task) async { // 自动调用 task.toJson() 转换为 Map await HttpUtils().post('/task/master', data: task.toJson()); }}
// 2. 调用 API (在 Controller 或 Service 中)Future<void> syncTask(TaskMaster task) async { try { await TaskApi.createTask(task);
// 3. 成功后更新本地状态 // 将 synced 标记为 true,更新最后同步时间 final syncedTask = task.copyWith( synced: true, lastSyncedAt: DateTime.now(), );
// 更新本地数据库 final box = Hive.box<TaskMaster>('task_masters'); await box.put(syncedTask.id, syncedTask);
} catch (e) { // 处理同步失败(例如网络不通),本地依旧保存,但 synced 为 false // 等网络恢复后再次同步 }}总结流程
- 用户输入 -> Controller 构建对象 (
TaskMaster(...)) - 本地存储 -> Hive Put (
box.put(id, obj)) -> UI 更新 (Obx) - 网络同步 -> API 调用 (
HttpUtils.post(data: obj.toJson())) -> 更新本地状态 (copyWith(synced: true))
清单数据库设计
属性
- 颜色
- 名字
- 各种id关联
- 是否归档
- 同步相关字段
- 逻辑删除
表:
create table task_lists( id varchar(36) not null comment '主键ID(UUID)' primary key, user_id varchar(36) not null comment '所属用户ID(UUID), NULL表示不属于某个用户', project_id varchar(36) null comment '关联项目ID(UUID), NULL表示不属于任何项目', goal_id varchar(36) null comment '关联目标ID(UUID), NULL表示不关联目标', name varchar(100) not null comment '清单名称', color varchar(20) default '#FFFFFF' null comment '清单颜色', is_archived tinyint(1) default 0 null comment '是否归档 0表示未归档 1表示已归档', created_at datetime default CURRENT_TIMESTAMP null, updated_at datetime default CURRENT_TIMESTAMP null on update CURRENT_TIMESTAMP, deleted tinyint(1) default 0 null comment '逻辑删除 0表示存在 1表示删除', synced tinyint(1) default 0 not null comment '是否已同步到服务器 0表示未同步 1表示已同步', last_synced_at datetime default CURRENT_TIMESTAMP null comment '最后同步时间') comment '任务清单表';后端Mybatis插件自动生成模型
前端让ai帮忙写一下模型
在首页添加显示清单和切换清单
暂时先使用固定数据 如果清单id为空 则表示是默认清单
在全部那里会显示出所有的清单和全部清单的任务
import 'package:flutter/material.dart';import 'package:get/get.dart';import 'home_controller.dart';
class HomeView extends GetView<HomeController> { const HomeView({super.key});
@override Widget build(BuildContext context) { return Scaffold( // 使用 MD3 风格的 AppBar appBar: AppBar( centerTitle: true, title: Obx( () => Text( controller.currentListName.value, style: const TextStyle(fontWeight: FontWeight.bold), ), ), leading: Builder( builder: (context) { return IconButton( icon: const Icon(Icons.menu), onPressed: () { Scaffold.of(context).openDrawer(); }, ); }, ), ), // 侧边栏 (MD3 NavigationDrawer) drawer: const NavigationDrawer( children: [ NavigationDrawerDestination( icon: Icon(Icons.folder_open), label: Text('暂无内容'), ), ], ), body: Column( children: [ // 顶部横向滚动的清单列表区域 SizedBox( height: 60, child: Obx(() { // 显式捕获 selectedIndex,确保 Obx 能监听到变化 final currentIndex = controller.selectedIndex.value; return ListView.separated( padding: const EdgeInsets.symmetric( horizontal: 16, vertical: 8, ), scrollDirection: Axis.horizontal, itemCount: controller.taskLists.length, separatorBuilder: (_, _) => const SizedBox(width: 12), itemBuilder: (context, index) { final isSelected = currentIndex == index; final name = controller.taskLists[index];
// 使用 FilterChip 或即兴的 Material Card 来表现选中状态 // 这里使用 Material 3 风格的 InputChip/FilterChip 样式自定义容器 final colorScheme = Theme.of(context).colorScheme;
return GestureDetector( onTap: () => controller.selectList(index, name), child: Container( padding: const EdgeInsets.symmetric( horizontal: 20, vertical: 8, ), alignment: Alignment.center, decoration: BoxDecoration( color: isSelected ? colorScheme.primaryContainer : colorScheme.surfaceContainerLow, borderRadius: BorderRadius.circular(12), // 稍微圆润的矩形 border: Border.all( color: isSelected ? colorScheme.primary : colorScheme.outlineVariant, width: 1, ), ), child: Text( name, style: TextStyle( color: isSelected ? colorScheme.onPrimaryContainer : colorScheme.onSurface, fontWeight: isSelected ? FontWeight.bold : FontWeight.normal, ), ), ), ); }, ); }), ),
// 下方主体内容区域 (占位) const Expanded(child: Center(child: Text('任务列表区域'))), ], ), // 浮动按钮 (圆角矩形) floatingActionButton: FloatingActionButton( onPressed: () { // TODO: 添加任务功能 }, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(16), // 圆角矩形 ), child: const Icon(Icons.add), ), ); }}首页右下角添加按钮:
- 打开一个创建任务组件(task_create_sheet)
- 选择清单
- 弹出悬浮列表显示默认和最近清单
- 点击其它打开完整的清单选择列表
- 可以在这个地方删除和添加清单
- 点击添加需要输入清单名称和选择颜色
- 选择优先级 弹窗选择1-4 使用四象限优先级
- 选择时间和重复规则
- 选择时间和重复规则合并到一起
- 选择不重复,则创建单次任务无期限,开始时间为当前
- 选择重复
- 每天:开始时间为当前,期限为当天,如果没有完成,则会像微软TODO一样,显示红色的截止日期在下方,不会自动跳到下个周期。
- 每周:同上,期限变成当周,也就是下周之前。
- 每月:同上。
- 每年:同上。
- 每周几:弹出窗口用于从周一到周日中多选,开始时间为下一个最近的时间,截止时间为那一天,过期规则同上。
- 自定义:弹出选择时间的列表,可以选择开始和截止时间。
- 设置提醒时间
- 任务开始日期的当天9:00 如果任务开始日期被设置在之前 则不提醒 如果是重复任务 则每个周期都会提醒一次 每个任务只提醒一次 过期后不会再提醒
- 任务开始日期的晚上7:00 同上
- 提前一天的9:00
- 提前一天的晚上7:00
使用Ai实现前端添加任务的功能:
下面记录了ai添加和修改的内容 方便理解优化
1. 添加国际化配置 使依赖的组件能根据当前语言改变语言
localizationsDelegates: const [ GlobalMaterialLocalizations.delegate, GlobalWidgetsLocalizations.delegate, GlobalCupertinoLocalizations.delegate,],supportedLocales: const [Locale('zh', 'CN'), Locale('en', 'US')],2. 新增字典序分数索引工具类 用O(1)复杂度实现排序
这里ai直接手动实现了一个 但是目前默认值的生成做得并不是很好
使用起来是这样子的:直接使用默认排序值 也就是n
// 获取当前清单中的所有任务,计算新的排序值 // TODO: 从数据库获取当前清单的任务列表 // 暂时使用默认排序值 final sortOrder = LexoRankUtils.getInitialRank();接着继续插入新的值会在末尾加一个z
/// 获取在指定排序值之后的新排序值 static String _getRankAfter(String rank) { // 如果rank是最大值,返回一个更大的值 if (rank == maxRank) { return maxRank + "z"; }
// 计算rank和最大值的中间值 return _getRankMiddle(rank, maxRank); }如果在中间插入值 使用的是 _getRankMiddle 方法
具体逻辑如下:
- 找到第一个不同的字符
- 如果都相同 则在末尾添加i字符(中间字符)
- 如果找到了不同的:
- 两边的这个不同的字符只相差1 需要延伸到下一位 则在这个字符后面加中间字符
- 如果不止差1,则插入把这个字符变成中间字符的字符串 比如 abc 和 abe 我要插入到中间就返回 abd
- 最后移除尾部的0来做优化
目前这样子主要的问题是一直插入会导致字符串过长
后面需要优化为预留位置方式的插入
/// 字典序分数索引工具类////// 基于 LexoRank 算法实现O(1)复杂度的排序功能/// 原理:使用字符串字典序作为排序依据,在两个元素之间插入新元素时,/// 通过计算两个字符串的中间值来实现,无需重新计算所有元素的位置////// 示例:/// - 初始元素: "n"/// - 在 "n" 之前插入: "g" (n的一半)/// - 在 "g" 和 "n" 之间插入: "k" (g和n的中间值)class LexoRankUtils { /// 最小排序值(用于第一个元素) static const String minRank = "0000000000";
/// 最大排序值(用于最后一个元素) static const String maxRank = "zzzzzzzzzz";
/// 中间排序值(用于初始元素) static const String middleRank = "n";
/// 基础字符集(0-9, a-z) static const String _chars = "0123456789abcdefghijklmnopqrstuvwxyz"; static const int _base = 36; // 字符集长度
/// 获取初始排序值(用于第一个元素) static String getInitialRank() { return middleRank; }
/// 在指定元素之前插入,生成新的排序值 /// /// [prevRank] 前一个元素的排序值(null表示在最前面) /// [nextRank] 后一个元素的排序值(null表示在最后面) /// 返回介于两者之间的排序值 static String getRankBetween(String? prevRank, String? nextRank) { // 情况1: 在最前面插入 if (prevRank == null || prevRank.isEmpty) { if (nextRank == null || nextRank.isEmpty) { return middleRank; } return _getRankBefore(nextRank); }
// 情况2: 在最后面插入 if (nextRank == null || nextRank.isEmpty) { return _getRankAfter(prevRank); }
// 情况3: 在两个元素之间插入 return _getRankMiddle(prevRank, nextRank); }
/// 获取在指定排序值之前的新排序值 static String _getRankBefore(String rank) { // 如果rank是最小值,返回一个更小的值 if (rank == minRank) { return "00000000000"; // 添加一位 }
// 计算rank的一半 return _getRankMiddle(minRank, rank); }
/// 获取在指定排序值之后的新排序值 static String _getRankAfter(String rank) { // 如果rank是最大值,返回一个更大的值 if (rank == maxRank) { return maxRank + "z"; }
// 计算rank和最大值的中间值 return _getRankMiddle(rank, maxRank); }
/// 计算两个排序值的中间值 static String _getRankMiddle(String prev, String next) { // 确保两个字符串长度相同(补齐短的字符串) final maxLen = prev.length > next.length ? prev.length : next.length; final paddedPrev = prev.padRight(maxLen, '0'); final paddedNext = next.padRight(maxLen, '0');
// 逐字符比较,找到第一个不同的位置 int pos = 0; while (pos < maxLen && paddedPrev[pos] == paddedNext[pos]) { pos++; }
// 如果所有字符都相同,在末尾添加中间字符 if (pos == maxLen) { return prev + _chars[_base ~/ 2]; // 添加 'i' (第18个字符) }
// 获取不同位置的字符索引 final prevCharIndex = _getCharIndex(paddedPrev[pos]); final nextCharIndex = _getCharIndex(paddedNext[pos]);
// 如果相邻字符(差值为1),需要延伸到下一位 if (nextCharIndex - prevCharIndex == 1) { // 取prev的这一位,然后在后面添加中间字符 return paddedPrev.substring(0, pos + 1) + _chars[_base ~/ 2]; }
// 计算中间字符的索引 final middleIndex = (prevCharIndex + nextCharIndex) ~/ 2;
// 构建结果:前面相同的部分 + 中间字符 final result = paddedPrev.substring(0, pos) + _chars[middleIndex];
// 移除尾部的0(优化存储) return result.replaceAll(RegExp(r'0+$'), '').isEmpty ? '0' : result.replaceAll(RegExp(r'0+$'), ''); }
/// 获取字符在字符集中的索引 static int _getCharIndex(String char) { final index = _chars.indexOf(char); return index == -1 ? 0 : index; }
/// 批量生成排序值(用于初始化多个元素) /// /// [count] 需要生成的排序值数量 /// 返回按顺序排列的排序值列表 static List<String> generateRanks(int count) { if (count <= 0) return []; if (count == 1) return [middleRank];
final ranks = <String>[]; String? prevRank;
for (int i = 0; i < count; i++) { final nextRank = getRankBetween(prevRank, null); ranks.add(nextRank); prevRank = nextRank; }
return ranks; }
/// 比较两个排序值 /// /// 返回值: /// - 负数:rank1 < rank2 /// - 0:rank1 == rank2 /// - 正数:rank1 > rank2 static int compare(String rank1, String rank2) { return rank1.compareTo(rank2); }
/// 验证排序值是否有效 static bool isValidRank(String? rank) { if (rank == null || rank.isEmpty) return false;
// 检查是否只包含合法字符 for (int i = 0; i < rank.length; i++) { if (!_chars.contains(rank[i])) { return false; } }
return true; }}3. 新增模型sub_task 和 task_draft 用于存储任务草稿
子任务:
import 'package:freezed_annotation/freezed_annotation.dart';import 'package:hive/hive.dart';
part 'sub_task.freezed.dart';part 'sub_task.g.dart';
/// 子任务模型////// 使用 Freezed 生成不可变数据类,使用 Hive 进行本地存储////// 字段说明:/// - [id] 子任务唯一标识符/// - [title] 子任务标题/内容/// - [sortOrder] 排序值(使用字典序分数索引,支持O(1)插入和排序)/// - [isCompleted] 是否已完成/// - [createdAt] 创建时间@freezed@HiveType(typeId: 10) // SubTask使用typeId 10class SubTask with _$SubTask { const factory SubTask({ @HiveField(0) required String id, @HiveField(1) required String title, @HiveField(2) required String sortOrder, @HiveField(3) @Default(false) bool isCompleted, @HiveField(4) required DateTime createdAt, }) = _SubTask;
factory SubTask.fromJson(Map<String, dynamic> json) => _$SubTaskFromJson(json);}任务草稿:
import 'package:freezed_annotation/freezed_annotation.dart';import 'package:hive/hive.dart';import 'package:cattodo_flutter/common/models/task/task_configs.dart';import 'package:cattodo_flutter/common/models/task/sub_task.dart';
part 'task_draft.freezed.dart';part 'task_draft.g.dart';
/// 任务创建草稿模型/// 用于临时存储创建任务时的数据,直到任务被创建或草稿被清除@freezed@HiveType(typeId: 11) // TaskDraft使用typeId 11class TaskDraft with _$TaskDraft { const factory TaskDraft({ /// 任务标题 @HiveField(0) String? title,
/// 任务描述(备注) @HiveField(1) String? description,
/// 所属清单ID @HiveField(2) String? listId,
/// 清单名称(用于显示) @HiveField(3) String? listName,
/// 重复规则(RRule字符串) @HiveField(4) String? rrule,
/// 重复规则显示文本 @HiveField(5) String? repeatDisplay,
/// 开始时间显示文本 @HiveField(6) String? startDateDisplay,
/// 期限时间显示文本 @HiveField(7) String? dueDateDisplay,
/// 提醒配置 @HiveField(8) TaskRemindConfig? remindConfig,
/// 提醒显示文本 @HiveField(9) String? remindDisplay,
/// 优先级等级 (1-4) @HiveField(10) @Default(2) int priorityLevel,
/// 需要完成的次数 @HiveField(11) @Default(1) int completionCount,
/// 子任务列表 @HiveField(12) @Default([]) List<SubTask> subTasks,
/// 是否显示子任务输入框 @HiveField(13) @Default(false) bool showSubTaskInput,
/// 草稿创建时间 @HiveField(14) DateTime? createdAt,
/// 草稿更新时间 @HiveField(15) DateTime? updatedAt, }) = _TaskDraft;
factory TaskDraft.fromJson(Map<String, dynamic> json) => _$TaskDraftFromJson(json);}这里还写了一个task_draft_service用于快速操作草稿 方法都很简单
import 'package:cattodo_flutter/common/models/task/task_draft.dart';import 'package:hive/hive.dart';
/// 任务草稿服务/// 管理任务创建时的草稿数据存储和读取class TaskDraftService { static const String _boxName = 'task_draft_box'; static const String _draftKey = 'current_draft';
late Box<TaskDraft> _box;
/// 初始化服务 Future<void> init() async { _box = await Hive.openBox<TaskDraft>(_boxName); }
/// 获取当前草稿 TaskDraft? getDraft() { return _box.get(_draftKey); }
/// 保存草稿 Future<void> saveDraft(TaskDraft draft) async { await _box.put(_draftKey, draft.copyWith( updatedAt: DateTime.now(), )); }
/// 清除草稿 Future<void> clearDraft() async { await _box.delete(_draftKey); }
/// 检查是否有草稿 bool hasDraft() { return _box.containsKey(_draftKey); }
/// 关闭服务 Future<void> close() async { await _box.close(); }}global.dart添加适配器 这里不知道为啥一些有impl一些没有 后续再了解一下)
4. 书写创建任务的弹出列表组件
这里ai虽然写得效果很好,但是代码几乎堆在一起了,非常的乱,需要优化一下。
使用ai优化后的目录结构:
lib/├── common/│ ├── services/│ │ ├── task_draft_service.dart # 增强:DraftInitResult, initAndLoadDraft(), saveDraftFromState()│ │ ├── sub_task_service.dart # 新建:子任务增删改查、排序逻辑│ │ └── task_master_service.dart # 新建:任务母版创建和保存│ └── utils/│ └── priority_utils.dart # 新建:优先级等级转换、图标、颜色│└── modules/home/widgets/ ├── task_create_sheet.dart # 重构后主文件 (~600行) └── task_create/ ├── components/ │ ├── dynamic_option_button.dart # 动态选项按钮组件 │ ├── overlay_menu.dart # Overlay悬浮菜单组件 │ ├── sub_task_item.dart # 单个子任务项组件 │ └── sub_task_list.dart # 子任务列表组件 ├── models/ │ ├── menu_option.dart # 菜单选项配置类 │ └── task_create_state.dart # 任务创建状态模型 └── pickers/ ├── list_picker_menu.dart # 清单选择菜单 ├── repeat_picker_menu.dart # 重复规则选择菜单 ├── remind_picker_menu.dart # 提醒选择菜单 ├── priority_picker_menu.dart # 优先级选择菜单 ├── description_dialog.dart # 描述输入对话框 ├── completion_count_dialog.dart # 完成次数对话框 └── week_day_picker_dialog.dart # 星期选择对话框优化要点
- 状态集中管理:
TaskCreateState模型集中管理所有创建状态,支持copyWith不可变更新 - 服务层分离:
SubTaskService:子任务业务逻辑TaskMasterService:任务母版创建TaskDraftService:草稿持久化(增强)
- 组件化UI:
DynamicOptionButton、SubTaskList等独立可复用组件 - 选择器模块化:各个选择器/对话框独立文件,便于维护
- 工具类抽取:
PriorityUtils集中优先级相关逻辑 - 主文件清晰:
build()方法简洁,逻辑分层明确
5. 时间和重复的详细设置列表
从detail文件分出四个弹窗分别用于设置内容
import 'package:cattodo_flutter/modules/home/widgets/dialogs/due_date_time_picker.dart';import 'package:cattodo_flutter/modules/home/widgets/dialogs/remind_time_picker.dart';import 'package:cattodo_flutter/modules/home/widgets/dialogs/repeat_rule_picker.dart';import 'package:cattodo_flutter/modules/home/widgets/dialogs/start_date_time_picker.dart';import 'package:flutter/material.dart';import 'package:flutter_screenutil/flutter_screenutil.dart';
class TaskDetailSettingsSheet extends StatefulWidget { final String? startDateDisplay; final String? dueDateDisplay; final String? remindDisplay; final String? repeatDisplay; final VoidCallback? onChanged; // 添加变化回调用于保存草稿
const TaskDetailSettingsSheet({ super.key, this.startDateDisplay = "自动调整", this.dueDateDisplay = "自动调整", this.remindDisplay = "不提醒", this.repeatDisplay = "不重复", this.onChanged, });
@override State<TaskDetailSettingsSheet> createState() => _TaskDetailSettingsSheetState();}
class _TaskDetailSettingsSheetState extends State<TaskDetailSettingsSheet> { late String _startDateDisplay; late String _dueDateDisplay; late String _remindDisplay; late String _repeatDisplay;
@override void initState() { super.initState(); _startDateDisplay = widget.startDateDisplay ?? "自动调整"; _dueDateDisplay = widget.dueDateDisplay ?? "自动调整"; _remindDisplay = widget.remindDisplay ?? "不提醒"; _repeatDisplay = widget.repeatDisplay ?? "不重复"; }
@override Widget build(BuildContext context) { return Container( decoration: BoxDecoration( color: Theme.of(context).colorScheme.surface, borderRadius: BorderRadius.vertical(top: Radius.circular(24.r)), ), padding: EdgeInsets.only( left: 16.w, right: 16.w, top: 16.h, bottom: MediaQuery.of(context).viewInsets.bottom + 16.h, ), child: SafeArea( child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ // 顶部标题栏 Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ // 标题文本 (左上角) Text( "任务设置", style: TextStyle( fontSize: 20.sp, fontWeight: FontWeight.w600, color: Theme.of(context).colorScheme.onSurface, ), ), // 重置按钮 (右上角) IconButton( onPressed: () { setState(() { _startDateDisplay = "自动调整"; _dueDateDisplay = "自动调整"; _remindDisplay = "不提醒"; _repeatDisplay = "不重复"; }); widget.onChanged?.call(); }, icon: Icon(Icons.restart_alt), tooltip: "重置", color: Theme.of(context).colorScheme.onSurfaceVariant, ), ], ),
SizedBox(height: 8.h), const Divider(), SizedBox(height: 8.h),
// 4个设置行 _buildSettingRow( icon: Icons.calendar_today_outlined, label: "开始时间", value: _startDateDisplay, onTap: () async { final result = await showStartDateTimePicker(context); if (result != null) { setState(() { if (result['auto'] == true) { _startDateDisplay = "自动调整"; } else { final dt = result['dateTime'] as DateTime; _startDateDisplay = "${dt.year}-${dt.month}-${dt.day} ${dt.hour}:${dt.minute.toString().padLeft(2, '0')}"; } }); widget.onChanged?.call(); // 通知变化 } }, ), _buildSettingRow( icon: Icons.event_busy_outlined, label: "期限时间", value: _dueDateDisplay, onTap: () async { final result = await showDueDateTimePicker(context); if (result != null) { setState(() { if (result['none'] == true) { _dueDateDisplay = "自动调整"; } else { final dt = result['dateTime'] as DateTime; final isAllDay = result['isAllDay'] as bool; if (isAllDay) { _dueDateDisplay = "${dt.year}-${dt.month}-${dt.day} (全天)"; } else { _dueDateDisplay = "${dt.year}-${dt.month}-${dt.day} ${dt.hour}:${dt.minute.toString().padLeft(2, '0')}"; } } }); widget.onChanged?.call(); } }, ), _buildSettingRow( icon: Icons.notifications_none_outlined, label: "提醒时间", value: _remindDisplay, onTap: () async { final result = await showRemindTimePicker(context); if (result != null) { setState(() { if (result['noRemind'] == true) { _remindDisplay = "不提醒"; } else { final offsetDays = result['offsetDays'] as int; final time = result['time'] as String; if (offsetDays == 0) { _remindDisplay = "当天 $time"; } else { _remindDisplay = "提前${offsetDays}天 $time"; } } }); widget.onChanged?.call(); } }, ), _buildSettingRow( icon: Icons.repeat, label: "重复规则", value: _repeatDisplay, onTap: () async { final result = await showRepeatRulePicker(context); if (result != null) { setState(() { _repeatDisplay = result['displayText'] as String; }); widget.onChanged?.call(); } }, ),
SizedBox(height: 24.h),
// 完成按钮 (右下角) Align( alignment: Alignment.centerRight, child: FilledButton( onPressed: () { // 返回所有设置的显示文本 Navigator.pop(context, { 'startDateDisplay': _startDateDisplay, 'dueDateDisplay': _dueDateDisplay, 'remindDisplay': _remindDisplay, 'repeatDisplay': _repeatDisplay, }); }, style: FilledButton.styleFrom(minimumSize: Size(100.w, 40.h)), child: const Text("完成"), ), ),
SizedBox(height: 16.h), ], ), ), ); }
Widget _buildSettingRow({ required IconData icon, required String label, required String value, required VoidCallback onTap, }) { return InkWell( onTap: onTap, borderRadius: BorderRadius.circular(12.r), child: Padding( padding: EdgeInsets.symmetric(vertical: 12.h, horizontal: 8.w), child: Row( children: [ Icon( icon, size: 24.r, color: Theme.of(context).colorScheme.onSurfaceVariant, ), SizedBox(width: 16.w), Text( label, style: TextStyle( fontSize: 16.sp, color: Theme.of(context).colorScheme.onSurface, ), ), const Spacer(), Text( value, style: TextStyle( fontSize: 14.sp, color: Theme.of(context).colorScheme.primary, ), ), SizedBox(width: 4.w), Icon( Icons.chevron_right, size: 20.r, color: Theme.of(context).colorScheme.outline, ), ], ), ), ); }}6. 点击添加后的逻辑:
调用_taskMasterService.createFromState执行创建并输出日志。
/// 从创建状态创建任务母版和子任务母版 Future<TaskCreateResult> createFromState({ required String title, required TaskCreateState state, String? existingSortOrder, }) async { final now = DateTime.now();
// 计算开始时间和截止时间 final dates = _calculateDatesFromRule( repeatRuleType: state.repeatRuleType, selectedWeekDays: state.selectedWeekDays, startDateDisplay: state.startDateDisplay, dueDateDisplay: state.dueDateDisplay, );
// 获取排序值 final sortOrder = existingSortOrder ?? await calculateNewSortOrder(state.listId);
// 转换优先级 final priority = PriorityUtils.levelToEnum(state.priorityLevel);
// 创建执行配置 final executionConfig = state.completionCount > 1 ? TaskExecutionConfig( type: TaskExecutionType.quota, target: state.completionCount, ) : null;
// 计算下一个周期的开始时间(dueDate + 1秒) final DateTime? nextDueDate = dates['dueDate']?.add( const Duration(seconds: 1), );
// 创建主任务母版 final mainTask = TaskMaster( id: _uuid.v7(), title: title, description: state.description, listId: state.listId, rrule: state.rrule, executionConfig: executionConfig, startDate: dates['startDate'], dueDate: dates['dueDate'], nextDueDate: nextDueDate, priority: priority, remindConfig: state.remindConfig, sortOrder: sortOrder, currentQuantitative: 0, quantitativeWeight: 1, createdAt: now, updatedAt: now, userId: null, goalId: null, projectId: null, parentId: null, repeatUntil: null, totalCycles: 0, // 默认值0表示无限循环 completedCycles: 0, // 默认值0 );
// 创建子任务母版列表 final subTaskMasters = _createSubTaskMasters( subTasks: state.subTasks, parentId: mainTask.id, listId: state.listId, priority: priority, createdAt: now, );生成任务实例逻辑:
1. 生成今日任务实例 (generateTodayInstances)
查询条件:
- 状态为
TaskMasterStatus.inUse - 未删除
- 是主任务(不是子任务)
startDate <= 当前时间nextDueDate <= 今日 23:59:59
生成流程:
- 查询符合条件的母版
- 检查是否已存在未完成的实例,若有则跳过
- 生成新实例(
displayDate=nextDueDate,dueDate= 母版的dueDate) - 同时生成子任务实例
- 判断母版是否完成(单次任务/循环次数用尽/超过重复结束日期)
- 若完成:状态改为
completed - 若需继续重复:更新
nextDueDate和dueDate到下个周期
2. 完成任务 (completeInstance)
完成后逻辑:
- 增加母版的
completedCycles - 判断是否已完成所有循环
- 计算下一个周期的
nextDueDate和dueDate - 判断是否需要立即生成下一个实例:
- 若原
dueDate没超过当前 且 新nextDueDate <= 今日:立即生成 - 否则等待下次
generateTodayInstances
- 若原
3. 举例验证
每周三、五重复(创建于周二):
| 时间 | 操作 | 母版状态 |
|---|---|---|
| 周二创建 | 创建母版 | nextDueDate=周三00:00, dueDate=周四23:59:59 |
| 周二打开app | 不生成(nextDueDate > 今日) | 无变化 |
| 周三打开app | 生成实例,更新母版 | nextDueDate=周五00:00, dueDate=下周三前23:59:59 |
| 周五未完成周三任务 | 不生成新实例(已有未完成) | 无变化 |
| 周五完成周三任务 | 完成,判断需要立即生成周五任务 | 再次更新nextDueDate |
| 周六完成周五任务 | 原dueDate已过,立即生成下周三任务 | 更新到下周三 |
| 下周四完成周三任务 | dueDate已过很久,计算最近的下个周三 | 跳到下下周三 |
离线使用与同步与登录的问题
离线要求能正常使用大部分功能,但是为了数据同步服务器需要存储用户id 如果不登录一开始就不知道用户的id,解决方案: 同步时才使用用户id 创建不需要写入用户id。
实现同步功能
后端实现同步接口 要分着来同步
请帮我完成同步功能: 我的想法如下: 后端实现同步接口(多个内容分开来同步)
- 任务母版同步接口
- 任务实例同步接口
- 任务清单同步接口
前端: 打开应用时自动检查是否登录 以及是否开启同步开关 如果开启同步开关且登录了 就执行同步(异步方式同步 在后台进行)
后端要校验传过来的数据是否真的需要同步 后端同步时根据id将数据存在服务器本地 删除了也要同步 后端之后会添加一个功能 用于删除时间过久的 并且已删除的任务实例和母版(是逻辑删除)
在执行同步的时候 需要多个同步接口都完成了才可以 同步时在某一个地方出问题了就全部回退(后端使用事务) 全完成了才能提醒同步成功
注意考虑我项目结构 后端的代码要符合当前项目格式
先检查当前代码看是否能顺利的直接实现 如果有什么问题 请先别进行实现 先告诉我
最终的任务母版和任务实例逻辑(待写)
生成逻辑
删除逻辑
完成逻辑
撤回逻辑
编辑逻辑
同步逻辑
多选逻辑
实现个人中心界面和设置功能
请帮我实现个人中心界面: 要求: 显示头像和用户名 使用 card_settings_ui 来实现设置页面
待优化细节(先开发使其能正常使用 后面在考虑优化)
清单:
- 删除清单后要有弹窗出来 可以点击撤回删除 ✅ 2026-01-25
- 删除清单后要把这个清单下面的所有任务母版都移动到默认清单 ✅ 2026-01-25
- 选择清单列表要提示能左滑和右滑
- 选择清单列表点击外部要收起 ✅ 2026-01-22
- 添加编辑清单弹窗点击外部不要消失 容易误触 ✅ 2026-01-22
- 实现清单的归档操作
颜色:
- color.value已经要弃用了 改成比较新的方式
登录:
- 为了安全token存储使用
flutter_secure_storage
其它
- TODO 之后要有一个历史记录在侧边栏 可以撤回删除的东西
- 历史清单会越存越多 要记得优化一下
任务:
- 任务母版中的当前量化进度没有意义 后面得去掉
- 优化点击完成任务的动画
- 优化有子任务时的进度显示(前面加个图标)
- 优化可以选择不显示已完成
- 优化把已完成改成全部显示 而不是只显示一点
- 优化已完成任务的顺序改成看完成时间
- 删除无用的母版字段
- 删除团队任务无用字段
other
-- 团队表 (Module 11)CREATE TABLE teams ( team_id varchar(36) NOT NULL comment '主键ID(UUID)' PRIMARY KEY, -- UUID owner_id varchar(36) NOT NULL comment '团队所有者主键ID(UUID)', name VARCHAR(100) NOT NULL comment '团队名称', description TEXT default '' comment '团队描述', created_at datetime default CURRENT_TIMESTAMP null, updated_at datetime default CURRENT_TIMESTAMP null on update CURRENT_TIMESTAMP, deleted tinyint(1) default 0 null comment '逻辑删除 0表示存在 1表示删除',) comment '团队信息表';
-- 团队成员与权限表 (Module 12)CREATE TABLE team_members ( tm_id varchar(36) NOT NULL PRIMARY KEY, -- 关联表也建议用UUID方便管理 team_id varchar(36) NOT NULL, user_id varchar(36) NOT NULL, role tinyint(1) DEFAULT 0, joined_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, UNIQUE(team_id, user_id)) comment '团队成员表';