Compare commits

...

1 Commits

Author SHA1 Message Date
Adam Gastineau 41a2fa8a44 fix(mobile): handle transient loading states for map timelines 2026-07-07 13:23:12 -07:00
4 changed files with 39 additions and 10 deletions
@@ -85,8 +85,12 @@ class TimelineFactory {
TimelineService fromAssetsWithBuckets(List<BaseAsset> assets, TimelineOrigin type) => TimelineService fromAssetsWithBuckets(List<BaseAsset> assets, TimelineOrigin type) =>
TimelineService(_timelineRepository.fromAssetsWithBuckets(assets, type)); TimelineService(_timelineRepository.fromAssetsWithBuckets(assets, type));
TimelineService map(List<String> userIds, TimelineMapOptions options) => /// Creates a TimelineService for serving geographical map queries, such assets within bounded locations
TimelineService(_timelineRepository.map(userIds, options, groupBy)); TimelineService geographicMap(
List<String> userIds,
TimelineMapOptions Function() currentOptions,
Stream<TimelineMapOptions> optionsStream,
) => TimelineService(_timelineRepository.geographicMap(userIds, currentOptions, optionsStream, groupBy));
} }
class TimelineService { class TimelineService {
@@ -509,9 +509,21 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
return query.map((row) => row.toDto()).get(); return query.map((row) => row.toDto()).get();
} }
TimelineQuery map(List<String> userIds, TimelineMapOptions options, GroupAssetsBy groupBy) => ( /// Creates a geographic map query that can dynamically filter on changing [TimelineMapOptions]
bucketSource: () => _watchMapBucket(userIds, options, groupBy: groupBy), /// (most notably the active map bounds)
assetSource: (offset, count) => _getMapBucketAssets(userIds, options, offset: offset, count: count), TimelineQuery geographicMap(
List<String> userIds,
TimelineMapOptions Function() currentOptions,
Stream<TimelineMapOptions> optionsStream,
GroupAssetsBy groupBy,
) => (
bucketSource: () => Stream.value(currentOptions())
.followedBy(optionsStream)
.switchMap(
// Any error would kill the stream for all options; make sure the stream stays alive
(options) => _watchMapBucket(userIds, options, groupBy: groupBy).handleError((_) {}),
),
assetSource: (offset, count) => _getMapBucketAssets(userIds, currentOptions(), offset: offset, count: count),
origin: TimelineOrigin.map, origin: TimelineOrigin.map,
); );
@@ -35,7 +35,6 @@ class _ScopedMapTimeline extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
// TODO: this causes the timeline to switch to flicker to "loading" state and back. This is both janky and inefficient.
return ProviderScope( return ProviderScope(
overrides: [ overrides: [
timelineServiceProvider.overrideWith((ref) { timelineServiceProvider.overrideWith((ref) {
@@ -44,13 +43,16 @@ class _ScopedMapTimeline extends StatelessWidget {
throw Exception('User must be logged in to access archive'); throw Exception('User must be logged in to access archive');
} }
final users = ref.watch(mapStateProvider).withPartners final withPartners = ref.watch(mapStateProvider.select((s) => s.withPartners));
? ref.watch(timelineUsersProvider).valueOrNull ?? [user.id] final users = withPartners ? ref.watch(timelineUsersProvider).valueOrNull ?? [user.id] : [user.id];
: [user.id];
final timelineService = ref final timelineService = ref
.watch(timelineFactoryProvider) .watch(timelineFactoryProvider)
.map(users, ref.watch(mapStateProvider).toOptions()); .geographicMap(
users,
() => ref.read(mapStateProvider).toOptions(),
ref.read(mapStateProvider.notifier).optionsStream,
);
ref.onDispose(timelineService.dispose); ref.onDispose(timelineService.dispose);
return timelineService; return timelineService;
}), }),
@@ -1,3 +1,5 @@
import 'dart:async';
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';
@@ -63,11 +65,16 @@ class MapState {
class MapStateNotifier extends Notifier<MapState> { class MapStateNotifier extends Notifier<MapState> {
MapStateNotifier(); MapStateNotifier();
final StreamController<TimelineMapOptions> _optionsController = StreamController.broadcast();
Stream<TimelineMapOptions> get optionsStream => _optionsController.stream;
bool setBounds(LatLngBounds bounds) { bool setBounds(LatLngBounds bounds) {
if (state.bounds == bounds) { if (state.bounds == bounds) {
return false; return false;
} }
state = state.copyWith(bounds: bounds); state = state.copyWith(bounds: bounds);
_optionsController.add(state.toOptions());
return true; return true;
} }
@@ -82,12 +89,14 @@ class MapStateNotifier extends Notifier<MapState> {
void switchFavoriteOnly(bool isFavoriteOnly) { void switchFavoriteOnly(bool isFavoriteOnly) {
ref.read(settingsProvider).write(.mapShowFavoriteOnly, isFavoriteOnly); ref.read(settingsProvider).write(.mapShowFavoriteOnly, isFavoriteOnly);
state = state.copyWith(onlyFavorites: isFavoriteOnly); state = state.copyWith(onlyFavorites: isFavoriteOnly);
_optionsController.add(state.toOptions());
EventStream.shared.emit(const MapMarkerReloadEvent()); EventStream.shared.emit(const MapMarkerReloadEvent());
} }
void switchIncludeArchived(bool isIncludeArchived) { void switchIncludeArchived(bool isIncludeArchived) {
ref.read(settingsProvider).write(.mapIncludeArchived, isIncludeArchived); ref.read(settingsProvider).write(.mapIncludeArchived, isIncludeArchived);
state = state.copyWith(includeArchived: isIncludeArchived); state = state.copyWith(includeArchived: isIncludeArchived);
_optionsController.add(state.toOptions());
EventStream.shared.emit(const MapMarkerReloadEvent()); EventStream.shared.emit(const MapMarkerReloadEvent());
} }
@@ -100,11 +109,13 @@ class MapStateNotifier extends Notifier<MapState> {
void setRelativeTime(int relativeDays) { void setRelativeTime(int relativeDays) {
ref.read(settingsProvider).write(.mapRelativeDate, relativeDays); ref.read(settingsProvider).write(.mapRelativeDate, relativeDays);
state = state.copyWith(relativeDays: relativeDays); state = state.copyWith(relativeDays: relativeDays);
_optionsController.add(state.toOptions());
EventStream.shared.emit(const MapMarkerReloadEvent()); EventStream.shared.emit(const MapMarkerReloadEvent());
} }
@override @override
MapState build() { MapState build() {
ref.onDispose(_optionsController.close);
final mapConfig = ref.read(appConfigProvider.select((config) => config.map)); final mapConfig = ref.read(appConfigProvider.select((config) => config.map));
return MapState( return MapState(
themeMode: mapConfig.themeMode, themeMode: mapConfig.themeMode,