Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion lib/domain/repositories/api_drink_repository.dart
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ class ApiDrinkRepository implements DrinkRepository {
@override
Future<String?> getFavoriteStatus(String festivalId, String drinkId) {
final item = _favoritesService.getFavoriteItem(festivalId, drinkId);
return Future.value(item?.status);
return Future.value(item?.status.value);
}

@override
Expand Down
60 changes: 54 additions & 6 deletions lib/models/favorite_item.dart
Original file line number Diff line number Diff line change
@@ -1,3 +1,25 @@
/// Status values for favorite items in the festival log.
enum FavoriteStatus {
/// Drink is on the 'want to try' list.
wantToTry('want_to_try'),

/// Drink has been tasted at least once.
tasted('tasted');

const FavoriteStatus(this.value);

/// The string value used for JSON serialization.
final String value;

/// Creates a FavoriteStatus from a string value.
static FavoriteStatus fromString(String value) {
return values.firstWhere(
(status) => status.value == value,
orElse: () => FavoriteStatus.wantToTry,
);
}
}

/// Represents a drink in the user's festival log.
///
/// Tracks whether a drink is on the 'want to try' list or has been tasted,
Expand All @@ -16,8 +38,8 @@ class FavoriteItem {
/// Drink ID.
final String id;

/// Status: 'want_to_try' or 'tasted'.
final String status;
/// Current status of this drink in the festival log.
final FavoriteStatus status;

/// List of tasting timestamps (empty if want_to_try).
final List<DateTime> tries;
Expand All @@ -35,7 +57,9 @@ class FavoriteItem {
factory FavoriteItem.fromJson(Map<String, dynamic> json) {
return FavoriteItem(
id: json['id'] as String,
status: json['status'] as String? ?? 'want_to_try',
status: FavoriteStatus.fromString(
json['status'] as String? ?? 'want_to_try',
),
tries: (json['tries'] as List?)
?.map((e) => DateTime.parse(e as String))
.toList() ??
Expand All @@ -50,7 +74,7 @@ class FavoriteItem {
Map<String, dynamic> toJson() {
return {
'id': id,
'status': status,
'status': status.value,
'tries': tries.map((t) => t.toIso8601String()).toList(),
if (notes != null) 'notes': notes,
'createdAt': createdAt.toIso8601String(),
Expand All @@ -61,10 +85,10 @@ class FavoriteItem {
/// Creates a copy with updated fields.
///
/// To explicitly clear notes, pass an empty Optional: `notes: Optional.value(null)`.
/// To keep existing notes, omit the parameter: `copyWith(status: 'tasted')`.
/// To keep existing notes, omit the parameter: `copyWith(status: FavoriteStatus.tasted)`.
FavoriteItem copyWith({
String? id,
String? status,
FavoriteStatus? status,
List<DateTime>? tries,
Optional<String?>? notes,
DateTime? createdAt,
Expand All @@ -80,6 +104,13 @@ class FavoriteItem {
);
}

/// Equality comparison based on drink ID only.
///
/// Two FavoriteItems are considered equal if they have the same id,
/// regardless of status, tries, notes, or timestamps. This design
/// allows FavoriteItem to be used in Sets and as Map keys where
/// uniqueness is determined by the drink being tracked, not its
/// specific state.
@override
bool operator ==(Object other) =>
identical(this, other) ||
Expand All @@ -92,6 +123,23 @@ class FavoriteItem {
}

/// Wrapper class for explicitly passing null values in copyWith methods.
///
/// Used to distinguish between omitting a parameter (keep existing value)
/// and explicitly passing null (clear the value). This is particularly
/// useful for optional fields like notes where both "no change" and
/// "set to null" are valid operations.
///
/// Example usage:
/// ```dart
/// // Keep existing notes
/// item.copyWith(status: FavoriteStatus.tasted);
///
/// // Clear notes (set to null)
/// item.copyWith(notes: Optional.value(null));
///
/// // Set new notes value
/// item.copyWith(notes: Optional.value('Great beer!'));
/// ```
class Optional<T> {
const Optional.value(this.value);

Expand Down
14 changes: 8 additions & 6 deletions lib/services/storage_service.dart
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ class FavoritesService {

favorites[drinkId] = FavoriteItem(
id: drinkId,
status: 'want_to_try',
status: FavoriteStatus.wantToTry,
tries: [],
createdAt: now,
updatedAt: now,
Expand All @@ -78,7 +78,7 @@ class FavoritesService {
final now = DateTime.now();
favorites[drinkId] = FavoriteItem(
id: drinkId,
status: 'want_to_try',
status: FavoriteStatus.wantToTry,
tries: [],
createdAt: now,
updatedAt: now,
Expand Down Expand Up @@ -109,15 +109,15 @@ class FavoritesService {
// Not in log yet, add as tasted
favorites[drinkId] = FavoriteItem(
id: drinkId,
status: 'tasted',
status: FavoriteStatus.tasted,
tries: [now],
createdAt: now,
updatedAt: now,
);
} else {
// Already in log, add timestamp and update status
favorites[drinkId] = existing.copyWith(
status: 'tasted',
status: FavoriteStatus.tasted,
tries: [...existing.tries, now],
updatedAt: now,
);
Expand All @@ -136,12 +136,14 @@ class FavoritesService {
final existing = favorites[drinkId];
if (existing == null) return;

final updatedTries = existing.tries.where((t) => t != timestamp).toList();
final updatedTries = existing.tries
.where((t) => t.millisecondsSinceEpoch != timestamp.millisecondsSinceEpoch)
.toList();

if (updatedTries.isEmpty) {
// No more tries, revert to 'want to try'
favorites[drinkId] = existing.copyWith(
status: 'want_to_try',
status: FavoriteStatus.wantToTry,
tries: [],
updatedAt: DateTime.now(),
);
Expand Down
34 changes: 17 additions & 17 deletions test/models_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -1164,15 +1164,15 @@ void main() {
test('creates favorite item with all fields', () {
final item = FavoriteItem(
id: 'drink-123',
status: 'want_to_try',
status: FavoriteStatus.wantToTry,
tries: [],
notes: 'Looks interesting',
createdAt: now,
updatedAt: now,
);

expect(item.id, 'drink-123');
expect(item.status, 'want_to_try');
expect(item.status, FavoriteStatus.wantToTry);
expect(item.tries, isEmpty);
expect(item.notes, 'Looks interesting');
expect(item.createdAt, now);
Expand All @@ -1182,13 +1182,13 @@ void main() {
test('creates tasted item with tries', () {
final item = FavoriteItem(
id: 'drink-456',
status: 'tasted',
status: FavoriteStatus.tasted,
tries: [now, later],
createdAt: now,
updatedAt: later,
);

expect(item.status, 'tasted');
expect(item.status, FavoriteStatus.tasted);
expect(item.tries.length, 2);
expect(item.tries, contains(now));
expect(item.tries, contains(later));
Expand All @@ -1211,7 +1211,7 @@ void main() {
final item = FavoriteItem.fromJson(json);

expect(item.id, 'drink-789');
expect(item.status, 'tasted');
expect(item.status, FavoriteStatus.tasted);
expect(item.tries.length, 2);
expect(item.notes, 'Excellent beer');
});
Expand All @@ -1226,7 +1226,7 @@ void main() {
final item = FavoriteItem.fromJson(json);

expect(item.id, 'drink-minimal');
expect(item.status, 'want_to_try'); // Default status
expect(item.status, FavoriteStatus.wantToTry); // Default status
expect(item.tries, isEmpty);
expect(item.notes, isNull);
});
Expand All @@ -1250,7 +1250,7 @@ void main() {
test('converts to JSON correctly', () {
final item = FavoriteItem(
id: 'drink-abc',
status: 'tasted',
status: FavoriteStatus.tasted,
tries: [now],
notes: 'Great!',
createdAt: now,
Expand All @@ -1271,7 +1271,7 @@ void main() {
test('excludes null notes from JSON', () {
final item = FavoriteItem(
id: 'drink-no-notes',
status: 'want_to_try',
status: FavoriteStatus.wantToTry,
tries: [],
createdAt: now,
updatedAt: now,
Expand All @@ -1285,7 +1285,7 @@ void main() {
test('roundtrip through JSON maintains data', () {
final original = FavoriteItem(
id: 'drink-roundtrip',
status: 'tasted',
status: FavoriteStatus.tasted,
tries: [now, later],
notes: 'Test notes',
createdAt: now,
Expand All @@ -1311,20 +1311,20 @@ void main() {
test('creates copy with updated fields', () {
final original = FavoriteItem(
id: 'drink-copy',
status: 'want_to_try',
status: FavoriteStatus.wantToTry,
tries: [],
createdAt: now,
updatedAt: now,
);

final updated = original.copyWith(
status: 'tasted',
status: FavoriteStatus.tasted,
tries: [later],
updatedAt: later,
);

expect(updated.id, original.id); // Unchanged
expect(updated.status, 'tasted'); // Changed
expect(updated.status, FavoriteStatus.tasted); // Changed
expect(updated.tries, [later]); // Changed
expect(updated.createdAt, original.createdAt); // Unchanged
expect(updated.updatedAt, later); // Changed
Expand All @@ -1333,7 +1333,7 @@ void main() {
test('preserves unchanged fields', () {
final original = FavoriteItem(
id: 'drink-preserve',
status: 'tasted',
status: FavoriteStatus.tasted,
tries: [now],
notes: 'Original notes',
createdAt: now,
Expand All @@ -1355,15 +1355,15 @@ void main() {
test('equal items have same id', () {
final item1 = FavoriteItem(
id: 'drink-eq',
status: 'want_to_try',
status: FavoriteStatus.wantToTry,
tries: [],
createdAt: now,
updatedAt: now,
);

final item2 = FavoriteItem(
id: 'drink-eq',
status: 'tasted',
status: FavoriteStatus.tasted,
tries: [later],
createdAt: later,
updatedAt: later,
Expand All @@ -1376,15 +1376,15 @@ void main() {
test('different items have different ids', () {
final item1 = FavoriteItem(
id: 'drink-1',
status: 'want_to_try',
status: FavoriteStatus.wantToTry,
tries: [],
createdAt: now,
updatedAt: now,
);

final item2 = FavoriteItem(
id: 'drink-2',
status: 'want_to_try',
status: FavoriteStatus.wantToTry,
tries: [],
createdAt: now,
updatedAt: now,
Expand Down
Loading