From 4ea96f080328a8ea40f3c523182a8f2458a4c3ba Mon Sep 17 00:00:00 2001 From: TrochtaOndrej Date: Wed, 18 Mar 2026 04:35:30 +0100 Subject: [PATCH] =?UTF-8?q?feat:=20Domain=20Entities=20=E2=80=94=20Task,?= =?UTF-8?q?=20PetState,=20User?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/domain/entities/.gitkeep | 0 lib/domain/entities/pet_state.dart | 69 +++++++++++ lib/domain/entities/task.dart | 99 +++++++++++++++ lib/domain/entities/user.dart | 51 ++++++++ lib/shared/utils/.gitkeep | 0 lib/shared/utils/kittie_date_utils.dart | 79 ++++++++++++ test/domain/entities/pet_state_test.dart | 123 +++++++++++++++++++ test/domain/entities/task_test.dart | 150 +++++++++++++++++++++++ test/domain/entities/user_test.dart | 87 +++++++++++++ 9 files changed, 658 insertions(+) delete mode 100644 lib/domain/entities/.gitkeep create mode 100644 lib/domain/entities/pet_state.dart create mode 100644 lib/domain/entities/task.dart create mode 100644 lib/domain/entities/user.dart delete mode 100644 lib/shared/utils/.gitkeep create mode 100644 lib/shared/utils/kittie_date_utils.dart create mode 100644 test/domain/entities/pet_state_test.dart create mode 100644 test/domain/entities/task_test.dart create mode 100644 test/domain/entities/user_test.dart diff --git a/lib/domain/entities/.gitkeep b/lib/domain/entities/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/lib/domain/entities/pet_state.dart b/lib/domain/entities/pet_state.dart new file mode 100644 index 0000000..1227001 --- /dev/null +++ b/lib/domain/entities/pet_state.dart @@ -0,0 +1,69 @@ +enum PetType { dog, cat, capybara } + +enum PetMood { happy, neutral, tired, excited } + +class PetState { + PetState({ + this.energyToday, + this.energyDate, + this.streakDays = 0, + this.totalTasksDone = 0, + this.unlockedItems = const [], + required this.updatedAt, + }); + + final int? energyToday; + final DateTime? energyDate; + final int streakDays; + final int totalTasksDone; + final List unlockedItems; + final DateTime updatedAt; + + PetMood get mood { + if (energyToday == null) return PetMood.neutral; + if (streakDays >= 7 && energyToday! >= 2) return PetMood.excited; + if (energyToday! >= 2) return PetMood.happy; + return PetMood.tired; + } + + PetState copyWith({ + int? Function()? energyToday, + DateTime? Function()? energyDate, + int? streakDays, + int? totalTasksDone, + List? unlockedItems, + DateTime? updatedAt, + }) { + return PetState( + energyToday: energyToday != null ? energyToday() : this.energyToday, + energyDate: energyDate != null ? energyDate() : this.energyDate, + streakDays: streakDays ?? this.streakDays, + totalTasksDone: totalTasksDone ?? this.totalTasksDone, + unlockedItems: unlockedItems ?? this.unlockedItems, + updatedAt: updatedAt ?? this.updatedAt, + ); + } + + @override + bool operator ==(Object other) => + identical(this, other) || + other is PetState && + energyToday == other.energyToday && + energyDate == other.energyDate && + streakDays == other.streakDays && + totalTasksDone == other.totalTasksDone && + updatedAt == other.updatedAt; + + @override + int get hashCode => Object.hash( + energyToday, + energyDate, + streakDays, + totalTasksDone, + updatedAt, + ); + + @override + String toString() => + 'PetState(energy: $energyToday, streak: $streakDays, mood: $mood)'; +} diff --git a/lib/domain/entities/task.dart b/lib/domain/entities/task.dart new file mode 100644 index 0000000..ababc98 --- /dev/null +++ b/lib/domain/entities/task.dart @@ -0,0 +1,99 @@ +enum EnergyLevel { + low(1), + medium(2), + high(3); + + const EnergyLevel(this.value); + final int value; +} + +enum TaskStatus { pending, done, snoozed, someday, archived } + +class Task { + Task({ + required this.id, + required this.userId, + required this.title, + this.description, + this.energyLevel = EnergyLevel.medium, + this.status = TaskStatus.pending, + this.isSelfCare = false, + this.deadline, + this.photoPath, + this.snoozeUntil, + this.sortOrder = 0, + required this.createdAt, + required this.updatedAt, + this.syncedAt, + this.localId, + }); + + final String id; + final String userId; + final String title; + final String? description; + final EnergyLevel energyLevel; + final TaskStatus status; + final bool isSelfCare; + final DateTime? deadline; + final String? photoPath; + final DateTime? snoozeUntil; + final int sortOrder; + final DateTime createdAt; + final DateTime updatedAt; + final DateTime? syncedAt; + final String? localId; + + bool get isDone => status == TaskStatus.done; + bool get isPending => status == TaskStatus.pending; + bool get isSnoozed => status == TaskStatus.snoozed; + bool get isOverdue => + deadline != null && deadline!.isBefore(DateTime.now()) && isPending; + bool get isSynced => syncedAt != null; + + Task copyWith({ + String? id, + String? userId, + String? title, + String? Function()? description, + EnergyLevel? energyLevel, + TaskStatus? status, + bool? isSelfCare, + DateTime? Function()? deadline, + String? Function()? photoPath, + DateTime? Function()? snoozeUntil, + int? sortOrder, + DateTime? createdAt, + DateTime? updatedAt, + DateTime? Function()? syncedAt, + String? Function()? localId, + }) { + return Task( + id: id ?? this.id, + userId: userId ?? this.userId, + title: title ?? this.title, + description: description != null ? description() : this.description, + energyLevel: energyLevel ?? this.energyLevel, + status: status ?? this.status, + isSelfCare: isSelfCare ?? this.isSelfCare, + deadline: deadline != null ? deadline() : this.deadline, + photoPath: photoPath != null ? photoPath() : this.photoPath, + snoozeUntil: snoozeUntil != null ? snoozeUntil() : this.snoozeUntil, + sortOrder: sortOrder ?? this.sortOrder, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + syncedAt: syncedAt != null ? syncedAt() : this.syncedAt, + localId: localId != null ? localId() : this.localId, + ); + } + + @override + bool operator ==(Object other) => + identical(this, other) || other is Task && id == other.id; + + @override + int get hashCode => id.hashCode; + + @override + String toString() => 'Task(id: $id, title: $title, status: $status)'; +} diff --git a/lib/domain/entities/user.dart b/lib/domain/entities/user.dart new file mode 100644 index 0000000..ee65bd5 --- /dev/null +++ b/lib/domain/entities/user.dart @@ -0,0 +1,51 @@ +import 'package:kittie_mobile/domain/entities/pet_state.dart'; + +class User { + User({ + required this.id, + required this.deviceId, + this.name = 'Friend', + this.petType = PetType.cat, + this.petName = 'Yoshi', + required this.createdAt, + required this.updatedAt, + }); + + final String id; + final String deviceId; + final String name; + final PetType petType; + final String petName; + final DateTime createdAt; + final DateTime updatedAt; + + User copyWith({ + String? id, + String? deviceId, + String? name, + PetType? petType, + String? petName, + DateTime? createdAt, + DateTime? updatedAt, + }) { + return User( + id: id ?? this.id, + deviceId: deviceId ?? this.deviceId, + name: name ?? this.name, + petType: petType ?? this.petType, + petName: petName ?? this.petName, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + ); + } + + @override + bool operator ==(Object other) => + identical(this, other) || other is User && id == other.id; + + @override + int get hashCode => id.hashCode; + + @override + String toString() => 'User(id: $id, name: $name, pet: $petName)'; +} diff --git a/lib/shared/utils/.gitkeep b/lib/shared/utils/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/lib/shared/utils/kittie_date_utils.dart b/lib/shared/utils/kittie_date_utils.dart new file mode 100644 index 0000000..447b9c2 --- /dev/null +++ b/lib/shared/utils/kittie_date_utils.dart @@ -0,0 +1,79 @@ +class KittieDateUtils { + KittieDateUtils._(); + + static bool isToday(DateTime? date) { + if (date == null) return false; + final now = DateTime.now(); + return date.year == now.year && + date.month == now.month && + date.day == now.day; + } + + static bool isTomorrow(DateTime? date) { + if (date == null) return false; + final tomorrow = DateTime.now().add(const Duration(days: 1)); + return date.year == tomorrow.year && + date.month == tomorrow.month && + date.day == tomorrow.day; + } + + static bool isOverdue(DateTime? date) { + if (date == null) return false; + final now = DateTime.now(); + final today = DateTime(now.year, now.month, now.day); + return date.isBefore(today); + } + + static String formatRelativeDate(DateTime? date) { + if (date == null) return ''; + if (isToday(date)) return 'Dnes'; + if (isTomorrow(date)) return 'Zitra'; + + final yesterday = DateTime.now().subtract(const Duration(days: 1)); + if (date.year == yesterday.year && + date.month == yesterday.month && + date.day == yesterday.day) { + return 'Vcera'; + } + + return '${date.day}. ${_monthName(date.month)}'; + } + + static String getWeekdayLabel(int weekday) { + const labels = ['Po', 'Ut', 'St', 'Ct', 'Pa', 'So', 'Ne']; + return labels[(weekday - 1) % 7]; + } + + static List getStreakDays(DateTime startOfWeek, {DateTime? now}) { + final today = now ?? DateTime.now(); + final todayDate = DateTime(today.year, today.month, today.day); + final start = DateTime( + startOfWeek.year, + startOfWeek.month, + startOfWeek.day, + ); + + return List.generate(7, (i) { + final day = start.add(Duration(days: i)); + return !day.isAfter(todayDate); + }); + } + + static String _monthName(int month) { + const months = [ + 'ledna', + 'unora', + 'brezna', + 'dubna', + 'kvetna', + 'cervna', + 'cervence', + 'srpna', + 'zari', + 'rijna', + 'listopadu', + 'prosince', + ]; + return months[month - 1]; + } +} diff --git a/test/domain/entities/pet_state_test.dart b/test/domain/entities/pet_state_test.dart new file mode 100644 index 0000000..f953f29 --- /dev/null +++ b/test/domain/entities/pet_state_test.dart @@ -0,0 +1,123 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:kittie_mobile/domain/entities/pet_state.dart'; + +void main() { + final now = DateTime(2025, 6, 15, 10, 0); + + PetState createState({ + int? energyToday, + int streakDays = 0, + int totalTasksDone = 0, + }) { + return PetState( + energyToday: energyToday, + streakDays: streakDays, + totalTasksDone: totalTasksDone, + updatedAt: now, + ); + } + + group('PetState defaults', () { + test('has correct defaults', () { + final state = createState(); + expect(state.energyToday, isNull); + expect(state.streakDays, 0); + expect(state.totalTasksDone, 0); + expect(state.unlockedItems, isEmpty); + }); + }); + + group('PetState mood algorithm', () { + test('returns neutral when energy is null', () { + final state = createState(energyToday: null); + expect(state.mood, PetMood.neutral); + }); + + test('returns excited when streak >= 7 and energy >= 2', () { + final state = createState(energyToday: 2, streakDays: 7); + expect(state.mood, PetMood.excited); + }); + + test('returns excited when streak >= 7 and energy is 3', () { + final state = createState(energyToday: 3, streakDays: 10); + expect(state.mood, PetMood.excited); + }); + + test('returns happy when energy >= 2 and streak < 7', () { + final state = createState(energyToday: 2, streakDays: 3); + expect(state.mood, PetMood.happy); + }); + + test('returns happy when energy is 3 and streak < 7', () { + final state = createState(energyToday: 3, streakDays: 0); + expect(state.mood, PetMood.happy); + }); + + test('returns tired when energy is 1', () { + final state = createState(energyToday: 1, streakDays: 0); + expect(state.mood, PetMood.tired); + }); + + test('returns tired when energy is 1 even with high streak', () { + final state = createState(energyToday: 1, streakDays: 10); + expect(state.mood, PetMood.tired); + }); + }); + + group('PetState copyWith', () { + test('copies with new energy', () { + final state = createState(); + final copy = state.copyWith(energyToday: () => 3); + expect(copy.energyToday, 3); + expect(copy.streakDays, 0); + }); + + test('copies energy to null', () { + final state = createState(energyToday: 2); + final copy = state.copyWith(energyToday: () => null); + expect(copy.energyToday, isNull); + }); + + test('preserves energy when not specified', () { + final state = createState(energyToday: 2); + final copy = state.copyWith(streakDays: 5); + expect(copy.energyToday, 2); + }); + + test('copies with new streak', () { + final state = createState(); + final copy = state.copyWith(streakDays: 5); + expect(copy.streakDays, 5); + }); + + test('copies with unlocked items', () { + final state = createState(); + final copy = state.copyWith(unlockedItems: ['hat', 'scarf']); + expect(copy.unlockedItems, ['hat', 'scarf']); + }); + }); + + group('PetState equality', () { + test('equal states are equal', () { + final a = createState(energyToday: 2, streakDays: 3); + final b = createState(energyToday: 2, streakDays: 3); + expect(a, equals(b)); + expect(a.hashCode, b.hashCode); + }); + + test('different states are not equal', () { + final a = createState(energyToday: 2); + final b = createState(energyToday: 3); + expect(a, isNot(equals(b))); + }); + }); + + group('PetState toString', () { + test('includes energy, streak, and mood', () { + final state = createState(energyToday: 2, streakDays: 3); + expect(state.toString(), contains('2')); + expect(state.toString(), contains('3')); + expect(state.toString(), contains('happy')); + }); + }); +} diff --git a/test/domain/entities/task_test.dart b/test/domain/entities/task_test.dart new file mode 100644 index 0000000..99182d3 --- /dev/null +++ b/test/domain/entities/task_test.dart @@ -0,0 +1,150 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:kittie_mobile/domain/entities/task.dart'; + +void main() { + final now = DateTime(2025, 6, 15, 10, 0); + + Task createTask({ + String id = 'task-1', + TaskStatus status = TaskStatus.pending, + DateTime? deadline, + DateTime? syncedAt, + }) { + return Task( + id: id, + userId: 'user-1', + title: 'Test task', + status: status, + deadline: deadline, + syncedAt: syncedAt, + createdAt: now, + updatedAt: now, + ); + } + + group('EnergyLevel', () { + test('has correct int values', () { + expect(EnergyLevel.low.value, 1); + expect(EnergyLevel.medium.value, 2); + expect(EnergyLevel.high.value, 3); + }); + }); + + group('Task defaults', () { + test('uses medium energy and pending status by default', () { + final task = createTask(); + expect(task.energyLevel, EnergyLevel.medium); + expect(task.status, TaskStatus.pending); + expect(task.isSelfCare, false); + expect(task.sortOrder, 0); + }); + }); + + group('Task computed getters', () { + test('isDone returns true when status is done', () { + final task = createTask(status: TaskStatus.done); + expect(task.isDone, true); + expect(task.isPending, false); + }); + + test('isPending returns true when status is pending', () { + final task = createTask(); + expect(task.isPending, true); + expect(task.isDone, false); + }); + + test('isSnoozed returns true when status is snoozed', () { + final task = createTask(status: TaskStatus.snoozed); + expect(task.isSnoozed, true); + }); + + test('isOverdue returns true when deadline is past and status is pending', + () { + final task = createTask( + deadline: DateTime.now().subtract(const Duration(days: 1)), + ); + expect(task.isOverdue, true); + }); + + test('isOverdue returns false when deadline is in the future', () { + final task = createTask( + deadline: DateTime.now().add(const Duration(days: 1)), + ); + expect(task.isOverdue, false); + }); + + test('isOverdue returns false when status is done', () { + final task = createTask( + status: TaskStatus.done, + deadline: DateTime.now().subtract(const Duration(days: 1)), + ); + expect(task.isOverdue, false); + }); + + test('isOverdue returns false when deadline is null', () { + final task = createTask(); + expect(task.isOverdue, false); + }); + + test('isSynced returns true when syncedAt is set', () { + final task = createTask(syncedAt: now); + expect(task.isSynced, true); + }); + + test('isSynced returns false when syncedAt is null', () { + final task = createTask(); + expect(task.isSynced, false); + }); + }); + + group('Task copyWith', () { + test('copies with new title', () { + final task = createTask(); + final copy = task.copyWith(title: 'New title'); + expect(copy.title, 'New title'); + expect(copy.id, task.id); + }); + + test('copies nullable field to null', () { + final task = createTask(deadline: now); + final copy = task.copyWith(deadline: () => null); + expect(copy.deadline, isNull); + }); + + test('preserves nullable field when not specified', () { + final task = createTask(deadline: now); + final copy = task.copyWith(title: 'Changed'); + expect(copy.deadline, now); + }); + + test('copies with new status', () { + final task = createTask(); + final copy = task.copyWith(status: TaskStatus.done); + expect(copy.isDone, true); + }); + }); + + group('Task equality', () { + test('tasks with same id are equal', () { + final a = createTask(id: 'same'); + final b = createTask(id: 'same'); + expect(a, equals(b)); + expect(a.hashCode, b.hashCode); + }); + + test('tasks with different ids are not equal', () { + final a = createTask(id: 'a'); + final b = createTask(id: 'b'); + expect(a, isNot(equals(b))); + }); + }); + + group('Task toString', () { + test('includes id, title, and status', () { + final task = createTask(); + expect(task.toString(), contains('task-1')); + expect(task.toString(), contains('Test task')); + expect(task.toString(), contains('pending')); + }); + }); +} diff --git a/test/domain/entities/user_test.dart b/test/domain/entities/user_test.dart new file mode 100644 index 0000000..277c96a --- /dev/null +++ b/test/domain/entities/user_test.dart @@ -0,0 +1,87 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:kittie_mobile/domain/entities/pet_state.dart'; +import 'package:kittie_mobile/domain/entities/user.dart'; + +void main() { + final now = DateTime(2025, 6, 15, 10, 0); + + User createUser({ + String id = 'user-1', + String name = 'Friend', + PetType petType = PetType.cat, + String petName = 'Yoshi', + }) { + return User( + id: id, + deviceId: 'device-1', + name: name, + petType: petType, + petName: petName, + createdAt: now, + updatedAt: now, + ); + } + + group('User defaults', () { + test('has correct defaults', () { + final user = User( + id: 'user-1', + deviceId: 'device-1', + createdAt: now, + updatedAt: now, + ); + expect(user.name, 'Friend'); + expect(user.petType, PetType.cat); + expect(user.petName, 'Yoshi'); + }); + }); + + group('User copyWith', () { + test('copies with new name', () { + final user = createUser(); + final copy = user.copyWith(name: 'Alice'); + expect(copy.name, 'Alice'); + expect(copy.id, user.id); + expect(copy.petName, user.petName); + }); + + test('copies with new pet type and name', () { + final user = createUser(); + final copy = user.copyWith(petType: PetType.dog, petName: 'Buddy'); + expect(copy.petType, PetType.dog); + expect(copy.petName, 'Buddy'); + }); + + test('preserves all fields when no arguments given', () { + final user = createUser(name: 'Bob', petName: 'Luna'); + final copy = user.copyWith(); + expect(copy.name, 'Bob'); + expect(copy.petName, 'Luna'); + expect(copy.id, user.id); + }); + }); + + group('User equality', () { + test('users with same id are equal', () { + final a = createUser(id: 'same'); + final b = createUser(id: 'same', name: 'Different'); + expect(a, equals(b)); + expect(a.hashCode, b.hashCode); + }); + + test('users with different ids are not equal', () { + final a = createUser(id: 'a'); + final b = createUser(id: 'b'); + expect(a, isNot(equals(b))); + }); + }); + + group('User toString', () { + test('includes id, name, and pet name', () { + final user = createUser(); + expect(user.toString(), contains('user-1')); + expect(user.toString(), contains('Friend')); + expect(user.toString(), contains('Yoshi')); + }); + }); +}