commit 1363f7d12ddcb80045b5bccc8d4412cf248de5d6 Author: Oracle Public Cloud User Date: Wed Mar 4 15:50:26 2026 +0000 Initial scaffold: FocusFlow shared Dart package Models (Task, Streak, StreakEntry, Reward, User, TimeEstimate, CoworkingRoom, ApiResponse), enums (EnergyLevel, TaskStatus, RewardType, RewardStyle), constants (ApiConstants, ErrorCodes, AppLimits), validators, and generated .g.dart files. Co-Authored-By: Claude Opus 4.6 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3cceda5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +# https://dart.dev/guides/libraries/private-files +# Created by `dart pub` +.dart_tool/ + +# Avoid committing pubspec.lock for library packages; see +# https://dart.dev/guides/libraries/private-files#pubspeclock. +pubspec.lock diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..effe43c --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,3 @@ +## 1.0.0 + +- Initial version. diff --git a/README.md b/README.md new file mode 100644 index 0000000..8831761 --- /dev/null +++ b/README.md @@ -0,0 +1,39 @@ + + +TODO: Put a short description of the package here that helps potential users +know whether this package might be useful for them. + +## Features + +TODO: List what your package can do. Maybe include images, gifs, or videos. + +## Getting started + +TODO: List prerequisites and provide or point to information on how to +start using the package. + +## Usage + +TODO: Include short and useful examples for package users. Add longer examples +to `/example` folder. + +```dart +const like = 'sample'; +``` + +## Additional information + +TODO: Tell users more about the package: where to find more information, how to +contribute to the package, how to file issues, what response they can expect +from the package authors, and more. diff --git a/analysis_options.yaml b/analysis_options.yaml new file mode 100644 index 0000000..dee8927 --- /dev/null +++ b/analysis_options.yaml @@ -0,0 +1,30 @@ +# This file configures the static analysis results for your project (errors, +# warnings, and lints). +# +# This enables the 'recommended' set of lints from `package:lints`. +# This set helps identify many issues that may lead to problems when running +# or consuming Dart code, and enforces writing Dart using a single, idiomatic +# style and format. +# +# If you want a smaller set of lints you can change this to specify +# 'package:lints/core.yaml'. These are just the most critical lints +# (the recommended set includes the core lints). +# The core lints are also what is used by pub.dev for scoring packages. + +include: package:lints/recommended.yaml + +# Uncomment the following section to specify additional rules. + +# linter: +# rules: +# - camel_case_types + +# analyzer: +# exclude: +# - path/to/excluded/files/** + +# For more information about the core and recommended set of lints, see +# https://dart.dev/go/core-lints + +# For additional information about configuring this file, see +# https://dart.dev/guides/language/analysis-options diff --git a/example/focusflow_shared_example.dart b/example/focusflow_shared_example.dart new file mode 100644 index 0000000..f2b19b8 --- /dev/null +++ b/example/focusflow_shared_example.dart @@ -0,0 +1,30 @@ +import 'package:focusflow_shared/focusflow_shared.dart'; + +void main() { + // Create a task + final task = Task( + id: 'task-001', + userId: 'user-001', + title: 'Write project proposal', + status: TaskStatus.pending.apiValue, + energyLevel: EnergyLevel.medium.name, + priority: 75, + estimatedMinutes: 30, + tags: ['work', 'writing'], + createdAt: DateTime.now(), + ); + + print('Task: ${task.title} (priority: ${task.priority})'); + print('Status: ${TaskStatus.pending.displayName}'); + print('Energy: ${EnergyLevel.medium.displayName}'); + + // Validate + final titleError = TaskValidator.validateTitle(task.title); + print('Title valid: ${titleError == null}'); + + // API path + print('Task endpoint: ${ApiConstants.task(task.id)}'); + + // Limits + print('Free tier max tasks: ${AppLimits.freeMaxActiveTasks}'); +} diff --git a/lib/focusflow_shared.dart b/lib/focusflow_shared.dart new file mode 100644 index 0000000..7db9230 --- /dev/null +++ b/lib/focusflow_shared.dart @@ -0,0 +1,26 @@ +/// Shared models, enums, and utilities for FocusFlow ADHD app. +library; + +// Models +export 'src/models/task.dart'; +export 'src/models/streak.dart'; +export 'src/models/streak_entry.dart'; +export 'src/models/reward.dart'; +export 'src/models/user.dart'; +export 'src/models/time_estimate.dart'; +export 'src/models/coworking_room.dart'; +export 'src/models/api_response.dart'; + +// Enums +export 'src/enums/energy_level.dart'; +export 'src/enums/task_status.dart'; +export 'src/enums/reward_type.dart'; +export 'src/enums/reward_style.dart'; + +// Constants +export 'src/constants/api_constants.dart'; +export 'src/constants/error_codes.dart'; +export 'src/constants/app_limits.dart'; + +// Validators +export 'src/validators/task_validator.dart'; diff --git a/lib/src/constants/api_constants.dart b/lib/src/constants/api_constants.dart new file mode 100644 index 0000000..3efe4d5 --- /dev/null +++ b/lib/src/constants/api_constants.dart @@ -0,0 +1,47 @@ +class ApiConstants { + ApiConstants._(); + static const String apiVersion = 'v1'; + static const String basePath = '/api/$apiVersion'; + + // Auth + static const String authRegister = '$basePath/auth/register'; + static const String authLogin = '$basePath/auth/login'; + static const String authRefresh = '$basePath/auth/refresh'; + static const String authLogout = '$basePath/auth/logout'; + + // Tasks + static const String tasks = '$basePath/tasks'; + static String task(String id) => '$basePath/tasks/$id'; + static String completeTask(String id) => '$basePath/tasks/$id/complete'; + static String skipTask(String id) => '$basePath/tasks/$id/skip'; + static const String nextTask = '$basePath/tasks/next'; + static const String dopamineOrdered = '$basePath/tasks/dopamine-ordered'; + + // Streaks + static const String streaks = '$basePath/streaks'; + static String streak(String id) => '$basePath/streaks/$id'; + static String forgiveStreak(String id) => '$basePath/streaks/$id/forgive'; + static String streakHistory(String id) => '$basePath/streaks/$id/history'; + + // Rewards + static const String generateReward = '$basePath/rewards/generate'; + static const String rewardHistory = '$basePath/rewards/history'; + + // Time + static const String timeEstimate = '$basePath/time/estimate'; + static const String timeActual = '$basePath/time/actual'; + static const String timeAccuracy = '$basePath/time/accuracy'; + + // Sync + static const String syncPush = '$basePath/sync/push'; + static const String syncPull = '$basePath/sync/pull'; + + // Rooms (body doubling) + static const String rooms = '$basePath/rooms'; + static String room(String id) => '$basePath/rooms/$id'; + static String joinRoom(String id) => '$basePath/rooms/$id/join'; + static String leaveRoom(String id) => '$basePath/rooms/$id/leave'; + + // Subscription + static const String subscriptionStatus = '$basePath/subscription/status'; +} diff --git a/lib/src/constants/app_limits.dart b/lib/src/constants/app_limits.dart new file mode 100644 index 0000000..7acd1e7 --- /dev/null +++ b/lib/src/constants/app_limits.dart @@ -0,0 +1,17 @@ +class AppLimits { + AppLimits._(); + // Free tier + static const int freeMaxActiveTasks = 15; + static const int freeMaxStreaks = 3; + // Premium tier + static const int premiumMaxActiveTasks = -1; // unlimited + static const int premiumMaxStreaks = -1; + // Defaults + static const int defaultGraceDays = 2; + static const int defaultFocusMinutes = 25; + static const int defaultTaskLoad = 5; + static const int maxRoomParticipants = 10; + // Pagination + static const int defaultPageSize = 20; + static const int maxPageSize = 100; +} diff --git a/lib/src/constants/error_codes.dart b/lib/src/constants/error_codes.dart new file mode 100644 index 0000000..dd05b0c --- /dev/null +++ b/lib/src/constants/error_codes.dart @@ -0,0 +1,18 @@ +class ErrorCodes { + ErrorCodes._(); + static const String invalidCredentials = 'INVALID_CREDENTIALS'; + static const String emailAlreadyExists = 'EMAIL_ALREADY_EXISTS'; + static const String tokenExpired = 'TOKEN_EXPIRED'; + static const String tokenInvalid = 'TOKEN_INVALID'; + static const String unauthorized = 'UNAUTHORIZED'; + static const String taskNotFound = 'TASK_NOT_FOUND'; + static const String streakNotFound = 'STREAK_NOT_FOUND'; + static const String noTasksAvailable = 'NO_TASKS_AVAILABLE'; + static const String roomFull = 'ROOM_FULL'; + static const String roomNotFound = 'ROOM_NOT_FOUND'; + static const String validationError = 'VALIDATION_ERROR'; + static const String notFound = 'NOT_FOUND'; + static const String internalError = 'INTERNAL_ERROR'; + static const String rateLimited = 'RATE_LIMITED'; + static const String premiumRequired = 'PREMIUM_REQUIRED'; +} diff --git a/lib/src/enums/energy_level.dart b/lib/src/enums/energy_level.dart new file mode 100644 index 0000000..ebfcfaf --- /dev/null +++ b/lib/src/enums/energy_level.dart @@ -0,0 +1,13 @@ +enum EnergyLevel { + low, + medium, + high; + + String get displayName { + switch (this) { + case EnergyLevel.low: return 'Low Energy'; + case EnergyLevel.medium: return 'Medium Energy'; + case EnergyLevel.high: return 'High Energy'; + } + } +} diff --git a/lib/src/enums/reward_style.dart b/lib/src/enums/reward_style.dart new file mode 100644 index 0000000..759dea5 --- /dev/null +++ b/lib/src/enums/reward_style.dart @@ -0,0 +1,13 @@ +enum RewardStyle { + playful, + minimal, + data; + + String get displayName { + switch (this) { + case RewardStyle.playful: return 'Playful (animations & messages)'; + case RewardStyle.minimal: return 'Minimal (subtle feedback)'; + case RewardStyle.data: return 'Data-driven (stats & charts)'; + } + } +} diff --git a/lib/src/enums/reward_type.dart b/lib/src/enums/reward_type.dart new file mode 100644 index 0000000..e140588 --- /dev/null +++ b/lib/src/enums/reward_type.dart @@ -0,0 +1,19 @@ +enum RewardType { + points, + badge, + animation, + message, + unlock, + surprise; + + String get displayName { + switch (this) { + case RewardType.points: return 'Points'; + case RewardType.badge: return 'Badge'; + case RewardType.animation: return 'Celebration'; + case RewardType.message: return 'Encouragement'; + case RewardType.unlock: return 'Unlock'; + case RewardType.surprise: return 'Surprise!'; + } + } +} diff --git a/lib/src/enums/task_status.dart b/lib/src/enums/task_status.dart new file mode 100644 index 0000000..9a3a89b --- /dev/null +++ b/lib/src/enums/task_status.dart @@ -0,0 +1,27 @@ +enum TaskStatus { + pending, + inProgress, + completed, + skipped, + archived; + + String get displayName { + switch (this) { + case TaskStatus.pending: return 'To Do'; + case TaskStatus.inProgress: return 'In Progress'; + case TaskStatus.completed: return 'Done'; + case TaskStatus.skipped: return 'Skipped'; + case TaskStatus.archived: return 'Archived'; + } + } + + String get apiValue { + switch (this) { + case TaskStatus.pending: return 'pending'; + case TaskStatus.inProgress: return 'in_progress'; + case TaskStatus.completed: return 'completed'; + case TaskStatus.skipped: return 'skipped'; + case TaskStatus.archived: return 'archived'; + } + } +} diff --git a/lib/src/models/api_response.dart b/lib/src/models/api_response.dart new file mode 100644 index 0000000..53ff01d --- /dev/null +++ b/lib/src/models/api_response.dart @@ -0,0 +1,44 @@ +import 'package:json_annotation/json_annotation.dart'; +part 'api_response.g.dart'; + +@JsonSerializable(genericArgumentFactories: true) +class ApiResponse { + final String status; + final T? data; + final ApiError? error; + final PaginationMeta? meta; + + const ApiResponse({required this.status, this.data, this.error, this.meta}); + + factory ApiResponse.success(T data, {PaginationMeta? meta}) => + ApiResponse(status: 'success', data: data, meta: meta); + + factory ApiResponse.error(String code, String message) => + ApiResponse(status: 'error', error: ApiError(code: code, message: message)); + + factory ApiResponse.fromJson(Map json, T Function(Object? json) fromJsonT) => + _$ApiResponseFromJson(json, fromJsonT); + Map toJson(Object? Function(T value) toJsonT) => + _$ApiResponseToJson(this, toJsonT); +} + +@JsonSerializable() +class ApiError { + final String code; + final String message; + final dynamic details; + const ApiError({required this.code, required this.message, this.details}); + factory ApiError.fromJson(Map json) => _$ApiErrorFromJson(json); + Map toJson() => _$ApiErrorToJson(this); +} + +@JsonSerializable() +class PaginationMeta { + final int page; + final int limit; + final int total; + final bool hasMore; + const PaginationMeta({required this.page, required this.limit, required this.total, required this.hasMore}); + factory PaginationMeta.fromJson(Map json) => _$PaginationMetaFromJson(json); + Map toJson() => _$PaginationMetaToJson(this); +} diff --git a/lib/src/models/api_response.g.dart b/lib/src/models/api_response.g.dart new file mode 100644 index 0000000..32e327c --- /dev/null +++ b/lib/src/models/api_response.g.dart @@ -0,0 +1,71 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'api_response.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +ApiResponse _$ApiResponseFromJson( + Map json, + T Function(Object? json) fromJsonT, +) => ApiResponse( + status: json['status'] as String, + data: _$nullableGenericFromJson(json['data'], fromJsonT), + error: + json['error'] == null + ? null + : ApiError.fromJson(json['error'] as Map), + meta: + json['meta'] == null + ? null + : PaginationMeta.fromJson(json['meta'] as Map), +); + +Map _$ApiResponseToJson( + ApiResponse instance, + Object? Function(T value) toJsonT, +) => { + 'status': instance.status, + 'data': _$nullableGenericToJson(instance.data, toJsonT), + 'error': instance.error, + 'meta': instance.meta, +}; + +T? _$nullableGenericFromJson( + Object? input, + T Function(Object? json) fromJson, +) => input == null ? null : fromJson(input); + +Object? _$nullableGenericToJson( + T? input, + Object? Function(T value) toJson, +) => input == null ? null : toJson(input); + +ApiError _$ApiErrorFromJson(Map json) => ApiError( + code: json['code'] as String, + message: json['message'] as String, + details: json['details'], +); + +Map _$ApiErrorToJson(ApiError instance) => { + 'code': instance.code, + 'message': instance.message, + 'details': instance.details, +}; + +PaginationMeta _$PaginationMetaFromJson(Map json) => + PaginationMeta( + page: (json['page'] as num).toInt(), + limit: (json['limit'] as num).toInt(), + total: (json['total'] as num).toInt(), + hasMore: json['hasMore'] as bool, + ); + +Map _$PaginationMetaToJson(PaginationMeta instance) => + { + 'page': instance.page, + 'limit': instance.limit, + 'total': instance.total, + 'hasMore': instance.hasMore, + }; diff --git a/lib/src/models/coworking_room.dart b/lib/src/models/coworking_room.dart new file mode 100644 index 0000000..1244f8b --- /dev/null +++ b/lib/src/models/coworking_room.dart @@ -0,0 +1,34 @@ +import 'package:json_annotation/json_annotation.dart'; +part 'coworking_room.g.dart'; + +@JsonSerializable() +class CoworkingRoom { + final String id; + final String hostUserId; + final String name; + final String? description; + final int maxParticipants; + final bool isPublic; + final String status; // active, ended, scheduled + final String? ambientSound; // cafe, rain, library, silent + final DateTime? scheduledAt; + final DateTime? endedAt; + final DateTime createdAt; + + const CoworkingRoom({ + required this.id, + required this.hostUserId, + required this.name, + this.description, + this.maxParticipants = 10, + this.isPublic = true, + this.status = 'active', + this.ambientSound, + this.scheduledAt, + this.endedAt, + required this.createdAt, + }); + + factory CoworkingRoom.fromJson(Map json) => _$CoworkingRoomFromJson(json); + Map toJson() => _$CoworkingRoomToJson(this); +} diff --git a/lib/src/models/coworking_room.g.dart b/lib/src/models/coworking_room.g.dart new file mode 100644 index 0000000..32efb23 --- /dev/null +++ b/lib/src/models/coworking_room.g.dart @@ -0,0 +1,43 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'coworking_room.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +CoworkingRoom _$CoworkingRoomFromJson(Map json) => + CoworkingRoom( + id: json['id'] as String, + hostUserId: json['hostUserId'] as String, + name: json['name'] as String, + description: json['description'] as String?, + maxParticipants: (json['maxParticipants'] as num?)?.toInt() ?? 10, + isPublic: json['isPublic'] as bool? ?? true, + status: json['status'] as String? ?? 'active', + ambientSound: json['ambientSound'] as String?, + scheduledAt: + json['scheduledAt'] == null + ? null + : DateTime.parse(json['scheduledAt'] as String), + endedAt: + json['endedAt'] == null + ? null + : DateTime.parse(json['endedAt'] as String), + createdAt: DateTime.parse(json['createdAt'] as String), + ); + +Map _$CoworkingRoomToJson(CoworkingRoom instance) => + { + 'id': instance.id, + 'hostUserId': instance.hostUserId, + 'name': instance.name, + 'description': instance.description, + 'maxParticipants': instance.maxParticipants, + 'isPublic': instance.isPublic, + 'status': instance.status, + 'ambientSound': instance.ambientSound, + 'scheduledAt': instance.scheduledAt?.toIso8601String(), + 'endedAt': instance.endedAt?.toIso8601String(), + 'createdAt': instance.createdAt.toIso8601String(), + }; diff --git a/lib/src/models/reward.dart b/lib/src/models/reward.dart new file mode 100644 index 0000000..a86b3a3 --- /dev/null +++ b/lib/src/models/reward.dart @@ -0,0 +1,32 @@ +import 'package:json_annotation/json_annotation.dart'; +part 'reward.g.dart'; + +@JsonSerializable() +class Reward { + final String id; + final String userId; + final String? taskId; + final String rewardType; // points, badge, animation, message, unlock, surprise + final int magnitude; // 1-100 + final String? title; + final String? description; + final String? animationKey; // Lottie animation identifier + final bool isSurprise; + final DateTime createdAt; + + const Reward({ + required this.id, + required this.userId, + this.taskId, + required this.rewardType, + required this.magnitude, + this.title, + this.description, + this.animationKey, + this.isSurprise = false, + required this.createdAt, + }); + + factory Reward.fromJson(Map json) => _$RewardFromJson(json); + Map toJson() => _$RewardToJson(this); +} diff --git a/lib/src/models/reward.g.dart b/lib/src/models/reward.g.dart new file mode 100644 index 0000000..050383a --- /dev/null +++ b/lib/src/models/reward.g.dart @@ -0,0 +1,33 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'reward.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +Reward _$RewardFromJson(Map json) => Reward( + id: json['id'] as String, + userId: json['userId'] as String, + taskId: json['taskId'] as String?, + rewardType: json['rewardType'] as String, + magnitude: (json['magnitude'] as num).toInt(), + title: json['title'] as String?, + description: json['description'] as String?, + animationKey: json['animationKey'] as String?, + isSurprise: json['isSurprise'] as bool? ?? false, + createdAt: DateTime.parse(json['createdAt'] as String), +); + +Map _$RewardToJson(Reward instance) => { + 'id': instance.id, + 'userId': instance.userId, + 'taskId': instance.taskId, + 'rewardType': instance.rewardType, + 'magnitude': instance.magnitude, + 'title': instance.title, + 'description': instance.description, + 'animationKey': instance.animationKey, + 'isSurprise': instance.isSurprise, + 'createdAt': instance.createdAt.toIso8601String(), +}; diff --git a/lib/src/models/streak.dart b/lib/src/models/streak.dart new file mode 100644 index 0000000..c4c6d5b --- /dev/null +++ b/lib/src/models/streak.dart @@ -0,0 +1,40 @@ +import 'package:json_annotation/json_annotation.dart'; +part 'streak.g.dart'; + +@JsonSerializable() +class Streak { + final String id; + final String userId; + final String name; + final String streakType; // daily, weekly, custom + final int currentCount; + final int longestCount; + final int graceDays; + final int graceUsed; + final DateTime? lastCompleted; + final bool isActive; + final DateTime? frozenUntil; + final bool encouragementShown; + final DateTime createdAt; + final DateTime? updatedAt; + + const Streak({ + required this.id, + required this.userId, + required this.name, + this.streakType = 'daily', + this.currentCount = 0, + this.longestCount = 0, + this.graceDays = 2, + this.graceUsed = 0, + this.lastCompleted, + this.isActive = true, + this.frozenUntil, + this.encouragementShown = false, + required this.createdAt, + this.updatedAt, + }); + + factory Streak.fromJson(Map json) => _$StreakFromJson(json); + Map toJson() => _$StreakToJson(this); +} diff --git a/lib/src/models/streak.g.dart b/lib/src/models/streak.g.dart new file mode 100644 index 0000000..77b96d3 --- /dev/null +++ b/lib/src/models/streak.g.dart @@ -0,0 +1,50 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'streak.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +Streak _$StreakFromJson(Map json) => Streak( + id: json['id'] as String, + userId: json['userId'] as String, + name: json['name'] as String, + streakType: json['streakType'] as String? ?? 'daily', + currentCount: (json['currentCount'] as num?)?.toInt() ?? 0, + longestCount: (json['longestCount'] as num?)?.toInt() ?? 0, + graceDays: (json['graceDays'] as num?)?.toInt() ?? 2, + graceUsed: (json['graceUsed'] as num?)?.toInt() ?? 0, + lastCompleted: + json['lastCompleted'] == null + ? null + : DateTime.parse(json['lastCompleted'] as String), + isActive: json['isActive'] as bool? ?? true, + frozenUntil: + json['frozenUntil'] == null + ? null + : DateTime.parse(json['frozenUntil'] as String), + encouragementShown: json['encouragementShown'] as bool? ?? false, + createdAt: DateTime.parse(json['createdAt'] as String), + updatedAt: + json['updatedAt'] == null + ? null + : DateTime.parse(json['updatedAt'] as String), +); + +Map _$StreakToJson(Streak instance) => { + 'id': instance.id, + 'userId': instance.userId, + 'name': instance.name, + 'streakType': instance.streakType, + 'currentCount': instance.currentCount, + 'longestCount': instance.longestCount, + 'graceDays': instance.graceDays, + 'graceUsed': instance.graceUsed, + 'lastCompleted': instance.lastCompleted?.toIso8601String(), + 'isActive': instance.isActive, + 'frozenUntil': instance.frozenUntil?.toIso8601String(), + 'encouragementShown': instance.encouragementShown, + 'createdAt': instance.createdAt.toIso8601String(), + 'updatedAt': instance.updatedAt?.toIso8601String(), +}; diff --git a/lib/src/models/streak_entry.dart b/lib/src/models/streak_entry.dart new file mode 100644 index 0000000..7b3a1ca --- /dev/null +++ b/lib/src/models/streak_entry.dart @@ -0,0 +1,24 @@ +import 'package:json_annotation/json_annotation.dart'; +part 'streak_entry.g.dart'; + +@JsonSerializable() +class StreakEntry { + final String id; + final String streakId; + final DateTime completedDate; + final bool wasForgiven; + final String? note; + final DateTime createdAt; + + const StreakEntry({ + required this.id, + required this.streakId, + required this.completedDate, + this.wasForgiven = false, + this.note, + required this.createdAt, + }); + + factory StreakEntry.fromJson(Map json) => _$StreakEntryFromJson(json); + Map toJson() => _$StreakEntryToJson(this); +} diff --git a/lib/src/models/streak_entry.g.dart b/lib/src/models/streak_entry.g.dart new file mode 100644 index 0000000..32f398c --- /dev/null +++ b/lib/src/models/streak_entry.g.dart @@ -0,0 +1,26 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'streak_entry.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +StreakEntry _$StreakEntryFromJson(Map json) => StreakEntry( + id: json['id'] as String, + streakId: json['streakId'] as String, + completedDate: DateTime.parse(json['completedDate'] as String), + wasForgiven: json['wasForgiven'] as bool? ?? false, + note: json['note'] as String?, + createdAt: DateTime.parse(json['createdAt'] as String), +); + +Map _$StreakEntryToJson(StreakEntry instance) => + { + 'id': instance.id, + 'streakId': instance.streakId, + 'completedDate': instance.completedDate.toIso8601String(), + 'wasForgiven': instance.wasForgiven, + 'note': instance.note, + 'createdAt': instance.createdAt.toIso8601String(), + }; diff --git a/lib/src/models/task.dart b/lib/src/models/task.dart new file mode 100644 index 0000000..857f73f --- /dev/null +++ b/lib/src/models/task.dart @@ -0,0 +1,56 @@ +import 'package:json_annotation/json_annotation.dart'; +part 'task.g.dart'; + +@JsonSerializable() +class Task { + final String id; + final String userId; + final String title; + final String? description; + final String status; // pending, in_progress, completed, skipped, archived + final int priority; // 0-100, ML-adjusted + final String energyLevel; // low, medium, high + final int? estimatedMinutes; + final int? actualMinutes; + final DateTime? dueAt; + final DateTime? completedAt; + final String? parentTaskId; + final double? dopamineScore; // ML-computed 0-100 + final double noveltyFactor; // decays over time + final DateTime? lastInteracted; + final int timesPostponed; + final List tags; + final String? category; + final String? color; + final int version; + final DateTime createdAt; + final DateTime? updatedAt; + + const Task({ + required this.id, + required this.userId, + required this.title, + this.description, + this.status = 'pending', + this.priority = 0, + this.energyLevel = 'medium', + this.estimatedMinutes, + this.actualMinutes, + this.dueAt, + this.completedAt, + this.parentTaskId, + this.dopamineScore, + this.noveltyFactor = 1.0, + this.lastInteracted, + this.timesPostponed = 0, + this.tags = const [], + this.category, + this.color, + this.version = 1, + required this.createdAt, + this.updatedAt, + }); + + factory Task.fromJson(Map json) => _$TaskFromJson(json); + Map toJson() => _$TaskToJson(this); +} diff --git a/lib/src/models/task.g.dart b/lib/src/models/task.g.dart new file mode 100644 index 0000000..1e54488 --- /dev/null +++ b/lib/src/models/task.g.dart @@ -0,0 +1,68 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'task.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +Task _$TaskFromJson(Map json) => Task( + id: json['id'] as String, + userId: json['userId'] as String, + title: json['title'] as String, + description: json['description'] as String?, + status: json['status'] as String? ?? 'pending', + priority: (json['priority'] as num?)?.toInt() ?? 0, + energyLevel: json['energyLevel'] as String? ?? 'medium', + estimatedMinutes: (json['estimatedMinutes'] as num?)?.toInt(), + actualMinutes: (json['actualMinutes'] as num?)?.toInt(), + dueAt: json['dueAt'] == null ? null : DateTime.parse(json['dueAt'] as String), + completedAt: + json['completedAt'] == null + ? null + : DateTime.parse(json['completedAt'] as String), + parentTaskId: json['parentTaskId'] as String?, + dopamineScore: (json['dopamineScore'] as num?)?.toDouble(), + noveltyFactor: (json['noveltyFactor'] as num?)?.toDouble() ?? 1.0, + lastInteracted: + json['lastInteracted'] == null + ? null + : DateTime.parse(json['lastInteracted'] as String), + timesPostponed: (json['timesPostponed'] as num?)?.toInt() ?? 0, + tags: + (json['tags'] as List?)?.map((e) => e as String).toList() ?? + const [], + category: json['category'] as String?, + color: json['color'] as String?, + version: (json['version'] as num?)?.toInt() ?? 1, + createdAt: DateTime.parse(json['createdAt'] as String), + updatedAt: + json['updatedAt'] == null + ? null + : DateTime.parse(json['updatedAt'] as String), +); + +Map _$TaskToJson(Task instance) => { + 'id': instance.id, + 'userId': instance.userId, + 'title': instance.title, + 'description': instance.description, + 'status': instance.status, + 'priority': instance.priority, + 'energyLevel': instance.energyLevel, + 'estimatedMinutes': instance.estimatedMinutes, + 'actualMinutes': instance.actualMinutes, + 'dueAt': instance.dueAt?.toIso8601String(), + 'completedAt': instance.completedAt?.toIso8601String(), + 'parentTaskId': instance.parentTaskId, + 'dopamineScore': instance.dopamineScore, + 'noveltyFactor': instance.noveltyFactor, + 'lastInteracted': instance.lastInteracted?.toIso8601String(), + 'timesPostponed': instance.timesPostponed, + 'tags': instance.tags, + 'category': instance.category, + 'color': instance.color, + 'version': instance.version, + 'createdAt': instance.createdAt.toIso8601String(), + 'updatedAt': instance.updatedAt?.toIso8601String(), +}; diff --git a/lib/src/models/time_estimate.dart b/lib/src/models/time_estimate.dart new file mode 100644 index 0000000..69f6ce5 --- /dev/null +++ b/lib/src/models/time_estimate.dart @@ -0,0 +1,26 @@ +import 'package:json_annotation/json_annotation.dart'; +part 'time_estimate.g.dart'; + +@JsonSerializable() +class TimeEstimate { + final String id; + final String userId; + final String taskId; + final int estimatedMinutes; + final int? actualMinutes; + final double? accuracyRatio; + final DateTime createdAt; + + const TimeEstimate({ + required this.id, + required this.userId, + required this.taskId, + required this.estimatedMinutes, + this.actualMinutes, + this.accuracyRatio, + required this.createdAt, + }); + + factory TimeEstimate.fromJson(Map json) => _$TimeEstimateFromJson(json); + Map toJson() => _$TimeEstimateToJson(this); +} diff --git a/lib/src/models/time_estimate.g.dart b/lib/src/models/time_estimate.g.dart new file mode 100644 index 0000000..3738fd8 --- /dev/null +++ b/lib/src/models/time_estimate.g.dart @@ -0,0 +1,28 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'time_estimate.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +TimeEstimate _$TimeEstimateFromJson(Map json) => TimeEstimate( + id: json['id'] as String, + userId: json['userId'] as String, + taskId: json['taskId'] as String, + estimatedMinutes: (json['estimatedMinutes'] as num).toInt(), + actualMinutes: (json['actualMinutes'] as num?)?.toInt(), + accuracyRatio: (json['accuracyRatio'] as num?)?.toDouble(), + createdAt: DateTime.parse(json['createdAt'] as String), +); + +Map _$TimeEstimateToJson(TimeEstimate instance) => + { + 'id': instance.id, + 'userId': instance.userId, + 'taskId': instance.taskId, + 'estimatedMinutes': instance.estimatedMinutes, + 'actualMinutes': instance.actualMinutes, + 'accuracyRatio': instance.accuracyRatio, + 'createdAt': instance.createdAt.toIso8601String(), + }; diff --git a/lib/src/models/user.dart b/lib/src/models/user.dart new file mode 100644 index 0000000..937d407 --- /dev/null +++ b/lib/src/models/user.dart @@ -0,0 +1,42 @@ +import 'package:json_annotation/json_annotation.dart'; +part 'user.g.dart'; + +@JsonSerializable() +class User { + final String id; + final String email; + final String displayName; + final String? avatarUrl; + final String timezone; + final bool onboardingCompleted; + final int preferredTaskLoad; + final int focusSessionMinutes; + final String rewardStyle; // playful, minimal, data + final bool forgivenessEnabled; + final String subscriptionTier; // free, premium, enterprise + final DateTime? subscriptionExpires; + final DateTime? lastActiveAt; + final DateTime createdAt; + final DateTime? updatedAt; + + const User({ + required this.id, + required this.email, + required this.displayName, + this.avatarUrl, + this.timezone = 'UTC', + this.onboardingCompleted = false, + this.preferredTaskLoad = 5, + this.focusSessionMinutes = 25, + this.rewardStyle = 'playful', + this.forgivenessEnabled = true, + this.subscriptionTier = 'free', + this.subscriptionExpires, + this.lastActiveAt, + required this.createdAt, + this.updatedAt, + }); + + factory User.fromJson(Map json) => _$UserFromJson(json); + Map toJson() => _$UserToJson(this); +} diff --git a/lib/src/models/user.g.dart b/lib/src/models/user.g.dart new file mode 100644 index 0000000..b12f5e2 --- /dev/null +++ b/lib/src/models/user.g.dart @@ -0,0 +1,52 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'user.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +User _$UserFromJson(Map json) => User( + id: json['id'] as String, + email: json['email'] as String, + displayName: json['displayName'] as String, + avatarUrl: json['avatarUrl'] as String?, + timezone: json['timezone'] as String? ?? 'UTC', + onboardingCompleted: json['onboardingCompleted'] as bool? ?? false, + preferredTaskLoad: (json['preferredTaskLoad'] as num?)?.toInt() ?? 5, + focusSessionMinutes: (json['focusSessionMinutes'] as num?)?.toInt() ?? 25, + rewardStyle: json['rewardStyle'] as String? ?? 'playful', + forgivenessEnabled: json['forgivenessEnabled'] as bool? ?? true, + subscriptionTier: json['subscriptionTier'] as String? ?? 'free', + subscriptionExpires: + json['subscriptionExpires'] == null + ? null + : DateTime.parse(json['subscriptionExpires'] as String), + lastActiveAt: + json['lastActiveAt'] == null + ? null + : DateTime.parse(json['lastActiveAt'] as String), + createdAt: DateTime.parse(json['createdAt'] as String), + updatedAt: + json['updatedAt'] == null + ? null + : DateTime.parse(json['updatedAt'] as String), +); + +Map _$UserToJson(User instance) => { + 'id': instance.id, + 'email': instance.email, + 'displayName': instance.displayName, + 'avatarUrl': instance.avatarUrl, + 'timezone': instance.timezone, + 'onboardingCompleted': instance.onboardingCompleted, + 'preferredTaskLoad': instance.preferredTaskLoad, + 'focusSessionMinutes': instance.focusSessionMinutes, + 'rewardStyle': instance.rewardStyle, + 'forgivenessEnabled': instance.forgivenessEnabled, + 'subscriptionTier': instance.subscriptionTier, + 'subscriptionExpires': instance.subscriptionExpires?.toIso8601String(), + 'lastActiveAt': instance.lastActiveAt?.toIso8601String(), + 'createdAt': instance.createdAt.toIso8601String(), + 'updatedAt': instance.updatedAt?.toIso8601String(), +}; diff --git a/lib/src/validators/task_validator.dart b/lib/src/validators/task_validator.dart new file mode 100644 index 0000000..c433739 --- /dev/null +++ b/lib/src/validators/task_validator.dart @@ -0,0 +1,17 @@ +class TaskValidator { + TaskValidator._(); + static String? validateTitle(String? title) { + if (title == null || title.trim().isEmpty) return 'Title is required'; + if (title.length > 500) return 'Title must be 500 characters or less'; + return null; + } + static String? validateEstimatedMinutes(int? minutes) { + if (minutes != null && minutes < 1) return 'Estimate must be at least 1 minute'; + if (minutes != null && minutes > 480) return 'Estimate cannot exceed 8 hours'; + return null; + } + static String? validatePriority(int? priority) { + if (priority != null && (priority < 0 || priority > 100)) return 'Priority must be 0-100'; + return null; + } +} diff --git a/pubspec.yaml b/pubspec.yaml new file mode 100644 index 0000000..42704d4 --- /dev/null +++ b/pubspec.yaml @@ -0,0 +1,17 @@ +name: focusflow_shared +description: Shared models, enums, and utilities for FocusFlow ADHD app +version: 0.1.0 +publish_to: 'none' + +environment: + sdk: ^3.7.0 + +dependencies: + json_annotation: ^4.9.0 + equatable: ^2.0.7 + +dev_dependencies: + build_runner: ^2.4.0 + json_serializable: ^6.8.0 + lints: ^5.0.0 + test: ^1.24.0 diff --git a/test/focusflow_shared_test.dart b/test/focusflow_shared_test.dart new file mode 100644 index 0000000..09644a0 --- /dev/null +++ b/test/focusflow_shared_test.dart @@ -0,0 +1,205 @@ +import 'package:focusflow_shared/focusflow_shared.dart'; +import 'package:test/test.dart'; + +void main() { + group('Task', () { + test('creates with required fields', () { + final task = Task( + id: 'task-1', + userId: 'user-1', + title: 'Test task', + createdAt: DateTime(2024, 1, 1), + ); + expect(task.id, 'task-1'); + expect(task.title, 'Test task'); + expect(task.status, 'pending'); + expect(task.priority, 0); + expect(task.energyLevel, 'medium'); + expect(task.noveltyFactor, 1.0); + expect(task.timesPostponed, 0); + expect(task.tags, isEmpty); + expect(task.version, 1); + }); + + test('serializes to JSON and back', () { + final task = Task( + id: 'task-1', + userId: 'user-1', + title: 'Test task', + priority: 50, + tags: ['focus', 'work'], + createdAt: DateTime(2024, 1, 1), + ); + final json = task.toJson(); + final restored = Task.fromJson(json); + expect(restored.id, task.id); + expect(restored.title, task.title); + expect(restored.priority, task.priority); + expect(restored.tags, task.tags); + }); + }); + + group('Streak', () { + test('creates with defaults', () { + final streak = Streak( + id: 's-1', + userId: 'user-1', + name: 'Morning routine', + createdAt: DateTime(2024, 1, 1), + ); + expect(streak.streakType, 'daily'); + expect(streak.currentCount, 0); + expect(streak.graceDays, 2); + expect(streak.isActive, true); + expect(streak.encouragementShown, false); + }); + + test('serializes to JSON and back', () { + final streak = Streak( + id: 's-1', + userId: 'user-1', + name: 'Morning routine', + currentCount: 7, + longestCount: 14, + createdAt: DateTime(2024, 1, 1), + ); + final json = streak.toJson(); + final restored = Streak.fromJson(json); + expect(restored.name, streak.name); + expect(restored.currentCount, 7); + expect(restored.longestCount, 14); + }); + }); + + group('User', () { + test('creates with defaults', () { + final user = User( + id: 'u-1', + email: 'test@example.com', + displayName: 'Test User', + createdAt: DateTime(2024, 1, 1), + ); + expect(user.timezone, 'UTC'); + expect(user.onboardingCompleted, false); + expect(user.preferredTaskLoad, 5); + expect(user.focusSessionMinutes, 25); + expect(user.rewardStyle, 'playful'); + expect(user.forgivenessEnabled, true); + expect(user.subscriptionTier, 'free'); + }); + }); + + group('ApiResponse', () { + test('creates success response', () { + final response = ApiResponse.success('hello'); + expect(response.status, 'success'); + expect(response.data, 'hello'); + expect(response.error, isNull); + }); + + test('creates error response', () { + final response = ApiResponse.error('NOT_FOUND', 'Task not found'); + expect(response.status, 'error'); + expect(response.data, isNull); + expect(response.error!.code, 'NOT_FOUND'); + expect(response.error!.message, 'Task not found'); + }); + }); + + group('TaskValidator', () { + test('validates title - empty', () { + expect(TaskValidator.validateTitle(null), 'Title is required'); + expect(TaskValidator.validateTitle(''), 'Title is required'); + expect(TaskValidator.validateTitle(' '), 'Title is required'); + }); + + test('validates title - too long', () { + final longTitle = 'x' * 501; + expect(TaskValidator.validateTitle(longTitle), 'Title must be 500 characters or less'); + }); + + test('validates title - valid', () { + expect(TaskValidator.validateTitle('Buy groceries'), isNull); + }); + + test('validates estimated minutes', () { + expect(TaskValidator.validateEstimatedMinutes(null), isNull); + expect(TaskValidator.validateEstimatedMinutes(0), 'Estimate must be at least 1 minute'); + expect(TaskValidator.validateEstimatedMinutes(481), 'Estimate cannot exceed 8 hours'); + expect(TaskValidator.validateEstimatedMinutes(30), isNull); + }); + + test('validates priority', () { + expect(TaskValidator.validatePriority(null), isNull); + expect(TaskValidator.validatePriority(-1), 'Priority must be 0-100'); + expect(TaskValidator.validatePriority(101), 'Priority must be 0-100'); + expect(TaskValidator.validatePriority(50), isNull); + }); + }); + + group('Enums', () { + test('TaskStatus display names', () { + expect(TaskStatus.pending.displayName, 'To Do'); + expect(TaskStatus.inProgress.displayName, 'In Progress'); + expect(TaskStatus.completed.displayName, 'Done'); + }); + + test('TaskStatus API values', () { + expect(TaskStatus.pending.apiValue, 'pending'); + expect(TaskStatus.inProgress.apiValue, 'in_progress'); + }); + + test('EnergyLevel display names', () { + expect(EnergyLevel.low.displayName, 'Low Energy'); + expect(EnergyLevel.medium.displayName, 'Medium Energy'); + expect(EnergyLevel.high.displayName, 'High Energy'); + }); + + test('RewardType display names', () { + expect(RewardType.animation.displayName, 'Celebration'); + expect(RewardType.surprise.displayName, 'Surprise!'); + }); + + test('RewardStyle display names', () { + expect(RewardStyle.playful.displayName, 'Playful (animations & messages)'); + expect(RewardStyle.data.displayName, 'Data-driven (stats & charts)'); + }); + }); + + group('ApiConstants', () { + test('base path', () { + expect(ApiConstants.basePath, '/api/v1'); + }); + + test('task routes', () { + expect(ApiConstants.tasks, '/api/v1/tasks'); + expect(ApiConstants.task('abc'), '/api/v1/tasks/abc'); + expect(ApiConstants.completeTask('abc'), '/api/v1/tasks/abc/complete'); + }); + + test('room routes', () { + expect(ApiConstants.rooms, '/api/v1/rooms'); + expect(ApiConstants.joinRoom('r1'), '/api/v1/rooms/r1/join'); + }); + }); + + group('AppLimits', () { + test('free tier limits', () { + expect(AppLimits.freeMaxActiveTasks, 15); + expect(AppLimits.freeMaxStreaks, 3); + }); + + test('pagination defaults', () { + expect(AppLimits.defaultPageSize, 20); + expect(AppLimits.maxPageSize, 100); + }); + }); + + group('ErrorCodes', () { + test('error code values', () { + expect(ErrorCodes.taskNotFound, 'TASK_NOT_FOUND'); + expect(ErrorCodes.premiumRequired, 'PREMIUM_REQUIRED'); + expect(ErrorCodes.rateLimited, 'RATE_LIMITED'); + }); + }); +}