feat: Domain Entities — Task, PetState, User

This commit is contained in:
TrochtaOndrej
2026-03-18 04:35:30 +01:00
parent 03e1ab1d24
commit 4ea96f0803
9 changed files with 658 additions and 0 deletions
View File
+69
View File
@@ -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<String> 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<String>? 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)';
}
+99
View File
@@ -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)';
}
+51
View File
@@ -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)';
}
View File
+79
View File
@@ -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<bool> 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];
}
}
+123
View File
@@ -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'));
});
});
}
+150
View File
@@ -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'));
});
});
}
+87
View File
@@ -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'));
});
});
}