feat(mobile): custom date range for map (#26205)

* feat(mobile): custom date range for map

* refactor: rename timerange & remove isvalid

* refactor: rename customtimerange variables

* refactor: add back setRelativeTime

* refactor: implement suggestions

* refactor: suggestions

* fix: ifPresent

* fix: context.locale

* chore: restrict selection

* refactor: move options to mapconfig

* refactor: move model to domain

* chore: locale toLanguageTag

* add map codec tests

* rebase

---------

Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
This commit is contained in:
Yaros
2026-07-23 20:48:15 +05:30
committed by GitHub
co-authored by shenlong-tanwen
parent 20ae07e228
commit 792d88961a
13 changed files with 349 additions and 15 deletions
+1
View File
@@ -1524,6 +1524,7 @@
"not_available": "N/A", "not_available": "N/A",
"not_in_any_album": "Not in any album", "not_in_any_album": "Not in any album",
"not_selected": "Not selected", "not_selected": "Not selected",
"not_set": "Not set",
"notes": "Notes", "notes": "Notes",
"nothing_here_yet": "Nothing here yet", "nothing_here_yet": "Nothing here yet",
"notification_backup_reliability": "Enable notifications to improve background backup reliability", "notification_backup_reliability": "Enable notifications to improve background backup reliability",
@@ -153,6 +153,8 @@ class AppConfig {
.timelineStorageIndicator => timeline.storageIndicator, .timelineStorageIndicator => timeline.storageIndicator,
.mapShowFavoriteOnly => map.favoritesOnly, .mapShowFavoriteOnly => map.favoritesOnly,
.mapRelativeDate => map.relativeDays, .mapRelativeDate => map.relativeDays,
.mapCustomFrom => map.customFrom,
.mapCustomTo => map.customTo,
.mapIncludeArchived => map.includeArchived, .mapIncludeArchived => map.includeArchived,
.mapThemeMode => map.themeMode, .mapThemeMode => map.themeMode,
.mapWithPartners => map.withPartners, .mapWithPartners => map.withPartners,
@@ -207,6 +209,8 @@ class AppConfig {
.timelineStorageIndicator => copyWith(timeline: timeline.copyWith(storageIndicator: value as bool)), .timelineStorageIndicator => copyWith(timeline: timeline.copyWith(storageIndicator: value as bool)),
.mapShowFavoriteOnly => copyWith(map: map.copyWith(favoritesOnly: value as bool)), .mapShowFavoriteOnly => copyWith(map: map.copyWith(favoritesOnly: value as bool)),
.mapRelativeDate => copyWith(map: map.copyWith(relativeDays: value as int)), .mapRelativeDate => copyWith(map: map.copyWith(relativeDays: value as int)),
.mapCustomFrom => copyWith(map: map.copyWith(customFrom: .fromNullable(value as DateTime?))),
.mapCustomTo => copyWith(map: map.copyWith(customTo: .fromNullable(value as DateTime?))),
.mapIncludeArchived => copyWith(map: map.copyWith(includeArchived: value as bool)), .mapIncludeArchived => copyWith(map: map.copyWith(includeArchived: value as bool)),
.mapThemeMode => copyWith(map: map.copyWith(themeMode: value as ThemeMode)), .mapThemeMode => copyWith(map: map.copyWith(themeMode: value as ThemeMode)),
.mapWithPartners => copyWith(map: map.copyWith(withPartners: value as bool)), .mapWithPartners => copyWith(map: map.copyWith(withPartners: value as bool)),
@@ -1,4 +1,5 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:immich_mobile/utils/option.dart';
class MapConfig { class MapConfig {
final int relativeDays; final int relativeDays;
@@ -6,13 +7,17 @@ class MapConfig {
final bool includeArchived; final bool includeArchived;
final ThemeMode themeMode; final ThemeMode themeMode;
final bool withPartners; final bool withPartners;
final DateTime? customFrom;
final DateTime? customTo;
const MapConfig({ const MapConfig({
this.relativeDays = 0, this.relativeDays = 0,
this.favoritesOnly = false, this.favoritesOnly = false,
this.includeArchived = false, this.includeArchived = false,
this.themeMode = ThemeMode.system, this.themeMode = .system,
this.withPartners = false, this.withPartners = false,
this.customFrom,
this.customTo,
}); });
MapConfig copyWith({ MapConfig copyWith({
@@ -21,12 +26,16 @@ class MapConfig {
bool? includeArchived, bool? includeArchived,
ThemeMode? themeMode, ThemeMode? themeMode,
bool? withPartners, bool? withPartners,
Option<DateTime>? customFrom,
Option<DateTime>? customTo,
}) => MapConfig( }) => MapConfig(
relativeDays: relativeDays ?? this.relativeDays, relativeDays: relativeDays ?? this.relativeDays,
favoritesOnly: favoritesOnly ?? this.favoritesOnly, favoritesOnly: favoritesOnly ?? this.favoritesOnly,
includeArchived: includeArchived ?? this.includeArchived, includeArchived: includeArchived ?? this.includeArchived,
themeMode: themeMode ?? this.themeMode, themeMode: themeMode ?? this.themeMode,
withPartners: withPartners ?? this.withPartners, withPartners: withPartners ?? this.withPartners,
customFrom: customFrom.patch(this.customFrom),
customTo: customTo.patch(this.customTo),
); );
@override @override
@@ -37,12 +46,15 @@ class MapConfig {
other.favoritesOnly == favoritesOnly && other.favoritesOnly == favoritesOnly &&
other.includeArchived == includeArchived && other.includeArchived == includeArchived &&
other.themeMode == themeMode && other.themeMode == themeMode &&
other.withPartners == withPartners); other.withPartners == withPartners &&
other.customFrom == customFrom &&
other.customTo == customTo);
@override @override
int get hashCode => Object.hash(relativeDays, favoritesOnly, includeArchived, themeMode, withPartners); int get hashCode =>
Object.hash(relativeDays, favoritesOnly, includeArchived, themeMode, withPartners, customFrom, customTo);
@override @override
String toString() => String toString() =>
'MapConfig(relativeDays: $relativeDays, favoritesOnly: $favoritesOnly, includeArchived: $includeArchived, themeMode: $themeMode, withPartners: $withPartners)'; 'MapConfig(relativeDays: $relativeDays, favoritesOnly: $favoritesOnly, includeArchived: $includeArchived, themeMode: $themeMode, withPartners: $withPartners, customFrom: $customFrom, customTo: $customTo)';
} }
@@ -55,6 +55,8 @@ enum SettingsKey<T> {
// Map // Map
mapShowFavoriteOnly<bool>(), mapShowFavoriteOnly<bool>(),
mapRelativeDate<int>(), mapRelativeDate<int>(),
mapCustomFrom<DateTime?>(),
mapCustomTo<DateTime?>(),
mapIncludeArchived<bool>(), mapIncludeArchived<bool>(),
mapThemeMode<ThemeMode>(codec: EnumCodec(ThemeMode.values)), mapThemeMode<ThemeMode>(codec: EnumCodec(ThemeMode.values)),
mapWithPartners<bool>(), mapWithPartners<bool>(),
@@ -0,0 +1,15 @@
import 'package:immich_mobile/utils/option.dart';
class TimeRange {
final DateTime? from;
final DateTime? to;
const TimeRange({this.from, this.to});
TimeRange copyWith({Option<DateTime>? from, Option<DateTime>? to}) {
return TimeRange(from: from.patch(this.from), to: to.patch(this.to));
}
TimeRange clearFrom() => TimeRange(to: to);
TimeRange clearTo() => TimeRange(from: from);
}
@@ -27,7 +27,18 @@ class DriftMapRepository extends DriftDatabaseRepository {
condition = condition & _db.remoteAssetEntity.isFavorite.equals(true); condition = condition & _db.remoteAssetEntity.isFavorite.equals(true);
} }
if (options.relativeDays != 0) { final timeRange = options.timeRange;
final hasCustomRange = timeRange.from != null || timeRange.to != null;
if (hasCustomRange) {
if (timeRange.from != null) {
condition = condition & _db.remoteAssetEntity.createdAt.isBiggerOrEqualValue(timeRange.from!);
}
if (timeRange.to != null) {
condition = condition & _db.remoteAssetEntity.createdAt.isSmallerOrEqualValue(timeRange.to!);
}
} else if (options.relativeDays > 0) {
final cutoffDate = DateTime.now().toUtc().subtract(Duration(days: options.relativeDays)); final cutoffDate = DateTime.now().toUtc().subtract(Duration(days: options.relativeDays));
condition = condition & _db.remoteAssetEntity.createdAt.isBiggerOrEqualValue(cutoffDate); condition = condition & _db.remoteAssetEntity.createdAt.isBiggerOrEqualValue(cutoffDate);
} }
@@ -4,6 +4,7 @@ import 'package:drift/drift.dart';
import 'package:easy_localization/easy_localization.dart'; import 'package:easy_localization/easy_localization.dart';
import 'package:immich_mobile/domain/models/album/album.model.dart'; import 'package:immich_mobile/domain/models/album/album.model.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/time_range.model.dart';
import 'package:immich_mobile/domain/models/timeline.model.dart'; import 'package:immich_mobile/domain/models/timeline.model.dart';
import 'package:immich_mobile/domain/services/timeline.service.dart'; import 'package:immich_mobile/domain/services/timeline.service.dart';
import 'package:immich_mobile/infrastructure/entities/local_asset.entity.dart'; import 'package:immich_mobile/infrastructure/entities/local_asset.entity.dart';
@@ -20,6 +21,7 @@ class TimelineMapOptions {
final bool includeArchived; final bool includeArchived;
final bool withPartners; final bool withPartners;
final int relativeDays; final int relativeDays;
final TimeRange timeRange;
const TimelineMapOptions({ const TimelineMapOptions({
required this.bounds, required this.bounds,
@@ -27,6 +29,7 @@ class TimelineMapOptions {
this.includeArchived = false, this.includeArchived = false,
this.withPartners = false, this.withPartners = false,
this.relativeDays = 0, this.relativeDays = 0,
this.timeRange = const TimeRange(),
}); });
} }
@@ -552,8 +555,21 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
query.where(_db.remoteAssetEntity.isFavorite.equals(true)); query.where(_db.remoteAssetEntity.isFavorite.equals(true));
} }
if (options.relativeDays != 0) { final timeRange = options.timeRange;
final hasCustomRange = timeRange.from != null || timeRange.to != null;
if (hasCustomRange) {
if (timeRange.from != null) {
query.where(_db.remoteAssetEntity.createdAt.isBiggerOrEqualValue(timeRange.from!));
}
if (timeRange.to != null) {
query.where(_db.remoteAssetEntity.createdAt.isSmallerOrEqualValue(timeRange.to!));
}
} else if (options.relativeDays > 0) {
final cutoffDate = DateTime.now().toUtc().subtract(Duration(days: options.relativeDays)); final cutoffDate = DateTime.now().toUtc().subtract(Duration(days: options.relativeDays));
query.where(_db.remoteAssetEntity.createdAt.isBiggerOrEqualValue(cutoffDate)); query.where(_db.remoteAssetEntity.createdAt.isBiggerOrEqualValue(cutoffDate));
} }
@@ -594,8 +610,21 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
query.where(_db.remoteAssetEntity.isFavorite.equals(true)); query.where(_db.remoteAssetEntity.isFavorite.equals(true));
} }
if (options.relativeDays != 0) { final timeRange = options.timeRange;
final hasCustomRange = timeRange.from != null || timeRange.to != null;
if (hasCustomRange) {
if (timeRange.from != null) {
query.where(_db.remoteAssetEntity.createdAt.isBiggerOrEqualValue(timeRange.from!));
}
if (timeRange.to != null) {
query.where(_db.remoteAssetEntity.createdAt.isSmallerOrEqualValue(timeRange.to!));
}
} else if (options.relativeDays > 0) {
final cutoffDate = DateTime.now().toUtc().subtract(Duration(days: options.relativeDays)); final cutoffDate = DateTime.now().toUtc().subtract(Duration(days: options.relativeDays));
query.where(_db.remoteAssetEntity.createdAt.isBiggerOrEqualValue(cutoffDate)); query.where(_db.remoteAssetEntity.createdAt.isBiggerOrEqualValue(cutoffDate));
} }
@@ -1,11 +1,13 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/events.model.dart'; import 'package:immich_mobile/domain/models/events.model.dart';
import 'package:immich_mobile/domain/models/time_range.model.dart';
import 'package:immich_mobile/domain/utils/event_stream.dart'; import 'package:immich_mobile/domain/utils/event_stream.dart';
import 'package:immich_mobile/infrastructure/repositories/timeline.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/timeline.repository.dart';
import 'package:immich_mobile/providers/infrastructure/map.provider.dart'; import 'package:immich_mobile/providers/infrastructure/map.provider.dart';
import 'package:immich_mobile/providers/infrastructure/settings.provider.dart'; import 'package:immich_mobile/providers/infrastructure/settings.provider.dart';
import 'package:immich_mobile/providers/map/map_state.provider.dart'; import 'package:immich_mobile/providers/map/map_state.provider.dart';
import 'package:immich_mobile/utils/option.dart';
import 'package:maplibre_gl/maplibre_gl.dart'; import 'package:maplibre_gl/maplibre_gl.dart';
class MapState { class MapState {
@@ -15,6 +17,7 @@ class MapState {
final bool includeArchived; final bool includeArchived;
final bool withPartners; final bool withPartners;
final int relativeDays; final int relativeDays;
final TimeRange timeRange;
const MapState({ const MapState({
this.themeMode = ThemeMode.system, this.themeMode = ThemeMode.system,
@@ -23,6 +26,7 @@ class MapState {
this.includeArchived = false, this.includeArchived = false,
this.withPartners = false, this.withPartners = false,
this.relativeDays = 0, this.relativeDays = 0,
this.timeRange = const TimeRange(),
}); });
@override @override
@@ -40,6 +44,7 @@ class MapState {
bool? includeArchived, bool? includeArchived,
bool? withPartners, bool? withPartners,
int? relativeDays, int? relativeDays,
TimeRange? timeRange,
}) { }) {
return MapState( return MapState(
bounds: bounds ?? this.bounds, bounds: bounds ?? this.bounds,
@@ -48,6 +53,7 @@ class MapState {
includeArchived: includeArchived ?? this.includeArchived, includeArchived: includeArchived ?? this.includeArchived,
withPartners: withPartners ?? this.withPartners, withPartners: withPartners ?? this.withPartners,
relativeDays: relativeDays ?? this.relativeDays, relativeDays: relativeDays ?? this.relativeDays,
timeRange: timeRange ?? this.timeRange,
); );
} }
@@ -57,6 +63,7 @@ class MapState {
includeArchived: includeArchived, includeArchived: includeArchived,
withPartners: withPartners, withPartners: withPartners,
relativeDays: relativeDays, relativeDays: relativeDays,
timeRange: timeRange,
); );
} }
@@ -103,6 +110,24 @@ class MapStateNotifier extends Notifier<MapState> {
EventStream.shared.emit(const MapMarkerReloadEvent()); EventStream.shared.emit(const MapMarkerReloadEvent());
} }
void setCustomTimeRange(TimeRange range) {
ref.read(settingsProvider).write(.mapCustomFrom, range.from);
ref.read(settingsProvider).write(.mapCustomTo, range.to);
state = state.copyWith(timeRange: range);
EventStream.shared.emit(const MapMarkerReloadEvent());
}
Option<DateTime> parseDateOption(String s) {
try {
if (s.trim().isEmpty) {
return const Option.none();
}
return Option.some(DateTime.parse(s));
} catch (_) {
return const Option.none();
}
}
@override @override
MapState build() { MapState build() {
final mapConfig = ref.read(appConfigProvider.select((config) => config.map)); final mapConfig = ref.read(appConfigProvider.select((config) => config.map));
@@ -113,6 +138,7 @@ class MapStateNotifier extends Notifier<MapState> {
withPartners: mapConfig.withPartners, withPartners: mapConfig.withPartners,
relativeDays: mapConfig.relativeDays, relativeDays: mapConfig.relativeDays,
bounds: LatLngBounds(northeast: const LatLng(0, 0), southwest: const LatLng(0, 0)), bounds: LatLngBounds(northeast: const LatLng(0, 0), southwest: const LatLng(0, 0)),
timeRange: TimeRange(from: mapConfig.customFrom, to: mapConfig.customTo),
); );
} }
} }
@@ -1,21 +1,39 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/time_range.model.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart'; import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/generated/translations.g.dart';
import 'package:immich_mobile/presentation/widgets/map/map.state.dart'; import 'package:immich_mobile/presentation/widgets/map/map.state.dart';
import 'package:immich_mobile/widgets/map/map_settings/map_custom_time_range.dart';
import 'package:immich_mobile/widgets/map/map_settings/map_settings_list_tile.dart'; import 'package:immich_mobile/widgets/map/map_settings/map_settings_list_tile.dart';
import 'package:immich_mobile/widgets/map/map_settings/map_settings_time_dropdown.dart'; import 'package:immich_mobile/widgets/map/map_settings/map_settings_time_dropdown.dart';
import 'package:immich_mobile/widgets/map/map_settings/map_theme_picker.dart'; import 'package:immich_mobile/widgets/map/map_settings/map_theme_picker.dart';
class DriftMapSettingsSheet extends HookConsumerWidget { class DriftMapSettingsSheet extends ConsumerStatefulWidget {
const DriftMapSettingsSheet({super.key}); const DriftMapSettingsSheet({super.key});
@override @override
Widget build(BuildContext context, WidgetRef ref) { ConsumerState<DriftMapSettingsSheet> createState() => _DriftMapSettingsSheetState();
}
class _DriftMapSettingsSheetState extends ConsumerState<DriftMapSettingsSheet> {
late bool useCustomRange;
@override
void initState() {
super.initState();
final mapState = ref.read(mapStateProvider);
final timeRange = mapState.timeRange;
useCustomRange = timeRange.from != null || timeRange.to != null;
}
@override
Widget build(BuildContext context) {
final mapState = ref.watch(mapStateProvider); final mapState = ref.watch(mapStateProvider);
return DraggableScrollableSheet( return DraggableScrollableSheet(
expand: false, expand: false,
initialChildSize: 0.6, initialChildSize: useCustomRange ? 0.7 : 0.6,
builder: (ctx, scrollController) => SingleChildScrollView( builder: (ctx, scrollController) => SingleChildScrollView(
controller: scrollController, controller: scrollController,
child: Card( child: Card(
@@ -47,10 +65,41 @@ class DriftMapSettingsSheet extends HookConsumerWidget {
selected: mapState.withPartners, selected: mapState.withPartners,
onChanged: (withPartners) => ref.read(mapStateProvider.notifier).switchWithPartners(withPartners), onChanged: (withPartners) => ref.read(mapStateProvider.notifier).switchWithPartners(withPartners),
), ),
MapTimeDropDown( if (useCustomRange) ...[
relativeTime: mapState.relativeDays, MapTimeRange(
onTimeChange: (time) => ref.read(mapStateProvider.notifier).setRelativeTime(time), timeRange: mapState.timeRange,
), onChanged: (range) {
ref.read(mapStateProvider.notifier).setCustomTimeRange(range);
},
),
Align(
alignment: Alignment.centerLeft,
child: TextButton(
onPressed: () => setState(() {
useCustomRange = false;
ref.read(mapStateProvider.notifier).setRelativeTime(0);
ref.read(mapStateProvider.notifier).setCustomTimeRange(const TimeRange());
}),
child: Text(context.t.remove_custom_date_range),
),
),
] else ...[
MapTimeDropDown(
relativeTime: mapState.relativeDays,
onTimeChange: (time) => ref.read(mapStateProvider.notifier).setRelativeTime(time),
),
Align(
alignment: Alignment.centerLeft,
child: TextButton(
onPressed: () => setState(() {
useCustomRange = true;
ref.read(mapStateProvider.notifier).setRelativeTime(0);
ref.read(mapStateProvider.notifier).setCustomTimeRange(const TimeRange());
}),
child: Text(context.t.use_custom_date_range),
),
),
],
const SizedBox(height: 20), const SizedBox(height: 20),
], ],
), ),
+11
View File
@@ -32,6 +32,17 @@ sealed class Option<T> {
None() => onNone(), None() => onNone(),
}; };
Option<U> flatMap<U>(Option<U> Function(T value) f) => switch (this) {
Some(:final value) => f(value),
None() => const Option.none(),
};
void ifPresent(void Function(T value) f) {
if (this case Some(:final value)) {
f(value);
}
}
@override @override
String toString() => switch (this) { String toString() => switch (this) {
Some(:final value) => 'Some($value)', Some(:final value) => 'Some($value)',
@@ -0,0 +1,73 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:immich_mobile/domain/models/time_range.model.dart';
import 'package:immich_mobile/generated/translations.g.dart';
import 'package:immich_mobile/utils/option.dart';
class MapTimeRange extends StatelessWidget {
const MapTimeRange({super.key, required this.timeRange, required this.onChanged});
final TimeRange timeRange;
final Function(TimeRange) onChanged;
@override
Widget build(BuildContext context) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
title: Text(context.t.date_after),
subtitle: Text(
timeRange.from != null
? DateFormat.yMMMd(context.locale.toLanguageTag()).add_jm().format(timeRange.from!)
: context.t.not_set,
),
trailing: timeRange.from != null
? IconButton(icon: const Icon(Icons.close), onPressed: () => onChanged(timeRange.clearFrom()))
: null,
onTap: () async {
final initial = timeRange.from ?? DateTime.now();
final currentTo = timeRange.to;
final picked = await showDatePicker(
context: context,
initialDate: currentTo != null && initial.isAfter(currentTo) ? currentTo : initial,
firstDate: DateTime(1970),
lastDate: currentTo ?? DateTime.now(),
);
if (picked != null) {
onChanged(timeRange.copyWith(from: Option.some(picked)));
}
},
),
ListTile(
title: Text(context.t.date_before),
subtitle: Text(
timeRange.to != null
? DateFormat.yMMMd(context.locale.toLanguageTag()).add_jm().format(timeRange.to!)
: context.t.not_set,
),
trailing: timeRange.to != null
? IconButton(icon: const Icon(Icons.close), onPressed: () => onChanged(timeRange.clearTo()))
: null,
onTap: () async {
final initial = timeRange.to ?? DateTime.now();
final currentFrom = timeRange.from;
final picked = await showDatePicker(
context: context,
initialDate: currentFrom != null && initial.isBefore(currentFrom) ? currentFrom : initial,
firstDate: currentFrom ?? DateTime(1970),
lastDate: DateTime.now(),
);
if (picked != null) {
onChanged(timeRange.copyWith(to: Option.some(picked)));
}
},
),
],
);
}
}
@@ -25,6 +25,8 @@ class _FrozenBucketService implements TimelineService {
} }
class _EmptyBucketService implements TimelineService { class _EmptyBucketService implements TimelineService {
const _EmptyBucketService();
@override @override
Stream<List<Bucket>> Function() get watchBuckets => Stream<List<Bucket>> Function() get watchBuckets =>
() => Stream.value(const []); () => Stream.value(const []);
@@ -109,7 +111,7 @@ void main() {
await tester.pumpWidget( await tester.pumpWidget(
ProviderScope( ProviderScope(
overrides: [ overrides: [
timelineServiceProvider.overrideWithValue(_EmptyBucketService()), timelineServiceProvider.overrideWithValue(const _EmptyBucketService()),
appConfigProvider.overrideWithValue(const AppConfig()), appConfigProvider.overrideWithValue(const AppConfig()),
], ],
child: MaterialApp( child: MaterialApp(
@@ -0,0 +1,99 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:immich_mobile/domain/models/value_codec.dart';
enum _Fruit { apple, banana, cherry }
void main() {
group('MapCodec', () {
group('encode', () {
test('serializes an empty map to an empty JSON object', () {
const codec = MapCodec<String, String>(PrimitiveCodec.string, PrimitiveCodec.string);
expect(codec.encode({}), '{}');
});
test('encodes a string-to-string map as a JSON object', () {
const codec = MapCodec<String, String>(PrimitiveCodec.string, PrimitiveCodec.string);
expect(codec.encode({'a': '1', 'b': '2'}), '{"a":"1","b":"2"}');
});
test('stringifies non-string values via the value codec', () {
const codec = MapCodec<String, int>(PrimitiveCodec.string, PrimitiveCodec.integer);
expect(codec.encode({'x': 10, 'y': 20}), '{"x":"10","y":"20"}');
});
test('stringifies non-string keys via the key codec', () {
const codec = MapCodec<int, String>(PrimitiveCodec.integer, PrimitiveCodec.string);
expect(codec.encode({1: 'one', 2: 'two'}), '{"1":"one","2":"two"}');
});
});
group('decode', () {
test('reconstructs a string-to-string map', () {
const codec = MapCodec<String, String>(PrimitiveCodec.string, PrimitiveCodec.string);
expect(codec.decode('{"a":"1","b":"2"}'), {'a': '1', 'b': '2'});
});
test('parses values back to their domain type', () {
const codec = MapCodec<String, int>(PrimitiveCodec.string, PrimitiveCodec.integer);
expect(codec.decode('{"x":"10","y":"20"}'), {'x': 10, 'y': 20});
});
test('parses keys back to their domain type', () {
const codec = MapCodec<int, String>(PrimitiveCodec.integer, PrimitiveCodec.string);
expect(codec.decode('{"1":"one","2":"two"}'), {1: 'one', 2: 'two'});
});
test('returns an empty map for an empty JSON object', () {
const codec = MapCodec<String, String>(PrimitiveCodec.string, PrimitiveCodec.string);
expect(codec.decode('{}'), isEmpty);
});
test('returns an empty map when the payload is not valid JSON', () {
const codec = MapCodec<String, String>(PrimitiveCodec.string, PrimitiveCodec.string);
expect(codec.decode('not json'), isEmpty);
});
test('returns an empty map when the JSON root is not an object', () {
const codec = MapCodec<String, String>(PrimitiveCodec.string, PrimitiveCodec.string);
expect(codec.decode('[]'), isEmpty);
expect(codec.decode('"a string"'), isEmpty);
expect(codec.decode('42'), isEmpty);
});
test('skips entries whose value is not a JSON string, keeping the rest', () {
const codec = MapCodec<String, int>(PrimitiveCodec.string, PrimitiveCodec.integer);
expect(codec.decode('{"x":1,"y":"20"}'), {'y': 20});
});
test('skips entries whose value is a nested object, keeping the rest', () {
const codec = MapCodec<String, String>(PrimitiveCodec.string, PrimitiveCodec.string);
expect(codec.decode('{"a":{"nested":"value"},"b":"ok"}'), {'b': 'ok'});
});
test('returns an empty map when every entry is malformed', () {
const codec = MapCodec<String, int>(PrimitiveCodec.string, PrimitiveCodec.integer);
expect(codec.decode('{"x":1,"y":2}'), isEmpty);
});
});
group('round trip', () {
test('preserves a primitive map through encode then decode', () {
const codec = MapCodec<String, int>(PrimitiveCodec.string, PrimitiveCodec.integer);
const original = {'one': 1, 'two': 2, 'three': 3};
expect(codec.decode(codec.encode(original)), original);
});
test('preserves an enum-valued map by composing with EnumCodec', () {
const codec = MapCodec<String, _Fruit>(PrimitiveCodec.string, EnumCodec(_Fruit.values));
const original = {'breakfast': _Fruit.banana, 'snack': _Fruit.apple};
expect(codec.decode(codec.encode(original)), original);
});
test('preserves a DateTime-valued map by composing with DateTimeCodec', () {
const codec = MapCodec<String, DateTime>(PrimitiveCodec.string, DateTimeCodec());
final original = {'created': DateTime.utc(2024, 1, 1, 12, 30)};
expect(codec.decode(codec.encode(original)), original);
});
});
});
}