From 39d2e14d3aa46345135b311b244509b1655f6443 Mon Sep 17 00:00:00 2001 From: Yaros Date: Sat, 14 Feb 2026 09:56:09 +0100 Subject: [PATCH] feat(mobile): custom date range for map --- i18n/en.json | 1 + mobile/lib/domain/models/store.model.dart | 4 ++ .../repositories/map.repository.dart | 13 +++- .../repositories/timeline.repository.dart | 25 +++++++- .../presentation/widgets/map/map.state.dart | 38 +++++++++++ .../widgets/map/map_settings_sheet.dart | 60 +++++++++++++++-- mobile/lib/services/app_settings.service.dart | 2 + .../map_settings/map_custom_time_range.dart | 64 +++++++++++++++++++ 8 files changed, 195 insertions(+), 12 deletions(-) create mode 100644 mobile/lib/widgets/map/map_settings/map_custom_time_range.dart diff --git a/i18n/en.json b/i18n/en.json index 6e35085be8..93a4fc57c6 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -1619,6 +1619,7 @@ "not_available": "N/A", "not_in_any_album": "Not in any album", "not_selected": "Not selected", + "not_set": "Not set", "notes": "Notes", "nothing_here_yet": "Nothing here yet", "notification_permission_dialog_content": "To enable notifications, go to Settings and select allow.", diff --git a/mobile/lib/domain/models/store.model.dart b/mobile/lib/domain/models/store.model.dart index f6bed7cf61..a657fe333f 100644 --- a/mobile/lib/domain/models/store.model.dart +++ b/mobile/lib/domain/models/store.model.dart @@ -73,6 +73,10 @@ enum StoreKey { autoPlayVideo._(139), albumGridView._(140), + // Map custom time range settings + mapCustomFrom._(141), + mapCustomTo._(142), + // Experimental stuff photoManagerCustomFilter._(1000), betaPromptShown._(1001), diff --git a/mobile/lib/infrastructure/repositories/map.repository.dart b/mobile/lib/infrastructure/repositories/map.repository.dart index 95e42337fc..fbac8de02c 100644 --- a/mobile/lib/infrastructure/repositories/map.repository.dart +++ b/mobile/lib/infrastructure/repositories/map.repository.dart @@ -27,9 +27,16 @@ class DriftMapRepository extends DriftDatabaseRepository { condition = condition & _db.remoteAssetEntity.isFavorite.equals(true); } - if (options.relativeDays != 0) { - final cutoffDate = DateTime.now().toUtc().subtract(Duration(days: options.relativeDays)); - condition = condition & _db.remoteAssetEntity.createdAt.isBiggerOrEqualValue(cutoffDate); + if (options.customTimeRange.isValid) { + if (options.customTimeRange.from != null) { + condition = condition & _db.remoteAssetEntity.createdAt.isBiggerOrEqualValue(options.customTimeRange.from!); + } + if (options.customTimeRange.to != null) { + condition = condition & _db.remoteAssetEntity.createdAt.isSmallerOrEqualValue(options.customTimeRange.to!); + } + } else if (options.relativeDays > 0) { + final fromDate = DateTime.now().subtract(Duration(days: options.relativeDays)); + condition = condition & _db.remoteAssetEntity.createdAt.isBiggerOrEqualValue(fromDate); } return condition; diff --git a/mobile/lib/infrastructure/repositories/timeline.repository.dart b/mobile/lib/infrastructure/repositories/timeline.repository.dart index 7544b4b2ac..b0fcc503c3 100644 --- a/mobile/lib/infrastructure/repositories/timeline.repository.dart +++ b/mobile/lib/infrastructure/repositories/timeline.repository.dart @@ -12,6 +12,7 @@ import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.dart'; import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.drift.dart'; import 'package:immich_mobile/infrastructure/repositories/db.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/map.repository.dart'; +import 'package:immich_mobile/presentation/widgets/map/map.state.dart'; import 'package:maplibre_gl/maplibre_gl.dart'; import 'package:stream_transform/stream_transform.dart'; @@ -21,6 +22,7 @@ class TimelineMapOptions { final bool includeArchived; final bool withPartners; final int relativeDays; + final CustomTimeRange customTimeRange; const TimelineMapOptions({ required this.bounds, @@ -28,6 +30,7 @@ class TimelineMapOptions { this.includeArchived = false, this.withPartners = false, this.relativeDays = 0, + this.customTimeRange = const CustomTimeRange(), }); } @@ -528,7 +531,16 @@ class DriftTimelineRepository extends DriftDatabaseRepository { query.where(_db.remoteAssetEntity.isFavorite.equals(true)); } - if (options.relativeDays != 0) { + if (options.customTimeRange.isValid) { + // Use custom from/to filters + if (options.customTimeRange.from != null) { + query.where(_db.remoteAssetEntity.createdAt.isBiggerOrEqualValue(options.customTimeRange.from!)); + } + if (options.customTimeRange.to != null) { + query.where(_db.remoteAssetEntity.createdAt.isSmallerOrEqualValue(options.customTimeRange.to!)); + } + } else if (options.relativeDays > 0) { + // Use relative days final cutoffDate = DateTime.now().toUtc().subtract(Duration(days: options.relativeDays)); query.where(_db.remoteAssetEntity.createdAt.isBiggerOrEqualValue(cutoffDate)); } @@ -570,7 +582,16 @@ class DriftTimelineRepository extends DriftDatabaseRepository { query.where(_db.remoteAssetEntity.isFavorite.equals(true)); } - if (options.relativeDays != 0) { + if (options.customTimeRange.isValid) { + // Use custom from/to filters + if (options.customTimeRange.from != null) { + query.where(_db.remoteAssetEntity.createdAt.isBiggerOrEqualValue(options.customTimeRange.from!)); + } + if (options.customTimeRange.to != null) { + query.where(_db.remoteAssetEntity.createdAt.isSmallerOrEqualValue(options.customTimeRange.to!)); + } + } else if (options.relativeDays > 0) { + // Use relative days final cutoffDate = DateTime.now().toUtc().subtract(Duration(days: options.relativeDays)); query.where(_db.remoteAssetEntity.createdAt.isBiggerOrEqualValue(cutoffDate)); } diff --git a/mobile/lib/presentation/widgets/map/map.state.dart b/mobile/lib/presentation/widgets/map/map.state.dart index bfd3011050..3b5103fcb3 100644 --- a/mobile/lib/presentation/widgets/map/map.state.dart +++ b/mobile/lib/presentation/widgets/map/map.state.dart @@ -9,6 +9,22 @@ import 'package:immich_mobile/providers/map/map_state.provider.dart'; import 'package:immich_mobile/services/app_settings.service.dart'; import 'package:maplibre_gl/maplibre_gl.dart'; +class CustomTimeRange { + final DateTime? from; + final DateTime? to; + + const CustomTimeRange({this.from, this.to}); + + bool get isValid => from != null || to != null; + + CustomTimeRange copyWith({DateTime? from, DateTime? to}) { + return CustomTimeRange(from: from ?? this.from, to: to ?? this.to); + } + + CustomTimeRange clearFrom() => CustomTimeRange(to: to); + CustomTimeRange clearTo() => CustomTimeRange(from: from); +} + class MapState { final ThemeMode themeMode; final LatLngBounds bounds; @@ -16,6 +32,7 @@ class MapState { final bool includeArchived; final bool withPartners; final int relativeDays; + final CustomTimeRange customTimeRange; const MapState({ this.themeMode = ThemeMode.system, @@ -24,6 +41,7 @@ class MapState { this.includeArchived = false, this.withPartners = false, this.relativeDays = 0, + this.customTimeRange = const CustomTimeRange(), }); @override @@ -41,6 +59,7 @@ class MapState { bool? includeArchived, bool? withPartners, int? relativeDays, + CustomTimeRange? customTimeRange, }) { return MapState( bounds: bounds ?? this.bounds, @@ -49,6 +68,7 @@ class MapState { includeArchived: includeArchived ?? this.includeArchived, withPartners: withPartners ?? this.withPartners, relativeDays: relativeDays ?? this.relativeDays, + customTimeRange: customTimeRange ?? this.customTimeRange, ); } @@ -58,6 +78,7 @@ class MapState { includeArchived: includeArchived, withPartners: withPartners, relativeDays: relativeDays, + customTimeRange: customTimeRange, ); } @@ -104,9 +125,22 @@ class MapStateNotifier extends Notifier { EventStream.shared.emit(const MapMarkerReloadEvent()); } + void setCustomTimeRange(CustomTimeRange range) { + ref + .read(appSettingsServiceProvider) + .setSetting(AppSettingsEnum.mapCustomFrom, range.from == null ? "" : range.from!.toIso8601String()); + ref + .read(appSettingsServiceProvider) + .setSetting(AppSettingsEnum.mapCustomTo, range.to == null ? "" : range.to!.toIso8601String()); + state = state.copyWith(customTimeRange: range); + EventStream.shared.emit(const MapMarkerReloadEvent()); + } + @override MapState build() { final appSettingsService = ref.read(appSettingsServiceProvider); + final customFrom = appSettingsService.getSetting(AppSettingsEnum.mapCustomFrom); + final customTo = appSettingsService.getSetting(AppSettingsEnum.mapCustomTo); return MapState( themeMode: ThemeMode.values[appSettingsService.getSetting(AppSettingsEnum.mapThemeMode)], onlyFavorites: appSettingsService.getSetting(AppSettingsEnum.mapShowFavoriteOnly), @@ -114,6 +148,10 @@ class MapStateNotifier extends Notifier { withPartners: appSettingsService.getSetting(AppSettingsEnum.mapwithPartners), relativeDays: appSettingsService.getSetting(AppSettingsEnum.mapRelativeDate), bounds: LatLngBounds(northeast: const LatLng(0, 0), southwest: const LatLng(0, 0)), + customTimeRange: CustomTimeRange( + from: customFrom.isNotEmpty ? DateTime.parse(customFrom) : null, + to: customTo.isNotEmpty ? DateTime.parse(customTo) : null, + ), ); } } diff --git a/mobile/lib/presentation/widgets/map/map_settings_sheet.dart b/mobile/lib/presentation/widgets/map/map_settings_sheet.dart index c581dd6292..e14349993f 100644 --- a/mobile/lib/presentation/widgets/map/map_settings_sheet.dart +++ b/mobile/lib/presentation/widgets/map/map_settings_sheet.dart @@ -2,20 +2,35 @@ import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/extensions/translate_extensions.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_time_dropdown.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}); @override - Widget build(BuildContext context, WidgetRef ref) { + ConsumerState createState() => _DriftMapSettingsSheetState(); +} + +class _DriftMapSettingsSheetState extends ConsumerState { + late bool useCustomRange; + + @override + void initState() { + super.initState(); + final mapState = ref.read(mapStateProvider); + useCustomRange = mapState.customTimeRange.isValid; + } + + @override + Widget build(BuildContext context) { final mapState = ref.watch(mapStateProvider); return DraggableScrollableSheet( expand: false, - initialChildSize: 0.6, + initialChildSize: useCustomRange ? 0.7 : 0.6, builder: (ctx, scrollController) => SingleChildScrollView( controller: scrollController, child: Card( @@ -47,10 +62,41 @@ class DriftMapSettingsSheet extends HookConsumerWidget { selected: mapState.withPartners, onChanged: (withPartners) => ref.read(mapStateProvider.notifier).switchWithPartners(withPartners), ), - MapTimeDropDown( - relativeTime: mapState.relativeDays, - onTimeChange: (time) => ref.read(mapStateProvider.notifier).setRelativeTime(time), - ), + if (useCustomRange) ...[ + MapCustomTimeRange( + customTimeRange: mapState.customTimeRange, + 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 CustomTimeRange()); + }), + child: Text("remove_custom_date_range".t(context: context)), + ), + ), + ] 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 CustomTimeRange()); + }), + child: Text("use_custom_date_range".t(context: context)), + ), + ), + ], const SizedBox(height: 20), ], ), diff --git a/mobile/lib/services/app_settings.service.dart b/mobile/lib/services/app_settings.service.dart index 4e740ebfe5..bdd897b2d9 100644 --- a/mobile/lib/services/app_settings.service.dart +++ b/mobile/lib/services/app_settings.service.dart @@ -40,6 +40,8 @@ enum AppSettingsEnum { mapIncludeArchived(StoreKey.mapIncludeArchived, null, false), mapwithPartners(StoreKey.mapwithPartners, null, false), mapRelativeDate(StoreKey.mapRelativeDate, null, 0), + mapCustomFrom(StoreKey.mapCustomFrom, null, ""), + mapCustomTo(StoreKey.mapCustomTo, null, ""), allowSelfSignedSSLCert(StoreKey.selfSignedCert, null, false), ignoreIcloudAssets(StoreKey.ignoreIcloudAssets, null, false), selectedAlbumSortReverse(StoreKey.selectedAlbumSortReverse, null, true), diff --git a/mobile/lib/widgets/map/map_settings/map_custom_time_range.dart b/mobile/lib/widgets/map/map_settings/map_custom_time_range.dart new file mode 100644 index 0000000000..1f41e13ce8 --- /dev/null +++ b/mobile/lib/widgets/map/map_settings/map_custom_time_range.dart @@ -0,0 +1,64 @@ +import 'package:flutter/material.dart'; +import 'package:immich_mobile/extensions/translate_extensions.dart'; +import 'package:immich_mobile/presentation/widgets/map/map.state.dart'; +import 'package:intl/intl.dart'; + +class MapCustomTimeRange extends StatelessWidget { + const MapCustomTimeRange({super.key, required this.customTimeRange, required this.onChanged}); + + final CustomTimeRange customTimeRange; + final Function(CustomTimeRange) onChanged; + + @override + Widget build(BuildContext context) { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + ListTile( + title: Text("date_after".t(context: context)), + subtitle: Text( + customTimeRange.from != null + ? DateFormat.yMMMd().add_jm().format(customTimeRange.from!) + : "not_set".t(context: context), + ), + trailing: customTimeRange.from != null + ? IconButton(icon: const Icon(Icons.close), onPressed: () => onChanged(customTimeRange.clearFrom())) + : null, + onTap: () async { + final picked = await showDatePicker( + context: context, + initialDate: customTimeRange.from ?? DateTime.now(), + firstDate: DateTime(1970), + lastDate: DateTime.now(), + ); + if (picked != null) { + onChanged(customTimeRange.copyWith(from: picked)); + } + }, + ), + ListTile( + title: Text("date_before".t(context: context)), + subtitle: Text( + customTimeRange.to != null + ? DateFormat.yMMMd().add_jm().format(customTimeRange.to!) + : "not_set".t(context: context), + ), + trailing: customTimeRange.to != null + ? IconButton(icon: const Icon(Icons.close), onPressed: () => onChanged(customTimeRange.clearTo())) + : null, + onTap: () async { + final picked = await showDatePicker( + context: context, + initialDate: customTimeRange.to ?? DateTime.now(), + firstDate: DateTime(1970), + lastDate: DateTime.now(), + ); + if (picked != null) { + onChanged(customTimeRange.copyWith(to: picked)); + } + }, + ), + ], + ); + } +}