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
17 changed files with 65 additions and 269 deletions
@@ -175,12 +175,12 @@ class RemoteAlbumService {
return _repository.getAssets(albumId);
}
Future<({int added, int failed})> addAssets({required String albumId, required List<String> assetIds}) async {
Future<int> addAssets({required String albumId, required List<String> assetIds}) async {
final album = await _albumApiRepository.addAssets(albumId, assetIds);
await _repository.addAssets(albumId, album.added);
return (added: album.added.length, failed: album.failed.length);
return album.added.length;
}
/// !TODO The name here is not clear as we have addAssets method above,
@@ -196,7 +196,7 @@ class RemoteAlbumService {
}) async {
int addedCount = 0;
if (candidates.remoteAssetIds.isNotEmpty) {
addedCount += (await addAssets(albumId: albumId, assetIds: candidates.remoteAssetIds)).added;
addedCount += await addAssets(albumId: albumId, assetIds: candidates.remoteAssetIds);
}
if (candidates.localAssetsToUpload.isNotEmpty) {
addedCount += await _uploadAndAddLocals(
@@ -85,8 +85,12 @@ class TimelineFactory {
TimelineService fromAssetsWithBuckets(List<BaseAsset> assets, TimelineOrigin type) =>
TimelineService(_timelineRepository.fromAssetsWithBuckets(assets, type));
TimelineService map(List<String> userIds, TimelineMapOptions options) =>
TimelineService(_timelineRepository.map(userIds, options, groupBy));
/// Creates a TimelineService for serving geographical map queries, such assets within bounded locations
TimelineService geographicMap(
List<String> userIds,
TimelineMapOptions Function() currentOptions,
Stream<TimelineMapOptions> optionsStream,
) => TimelineService(_timelineRepository.geographicMap(userIds, currentOptions, optionsStream, groupBy));
}
class TimelineService {
@@ -509,9 +509,21 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
return query.map((row) => row.toDto()).get();
}
TimelineQuery map(List<String> userIds, TimelineMapOptions options, GroupAssetsBy groupBy) => (
bucketSource: () => _watchMapBucket(userIds, options, groupBy: groupBy),
assetSource: (offset, count) => _getMapBucketAssets(userIds, options, offset: offset, count: count),
/// Creates a geographic map query that can dynamically filter on changing [TimelineMapOptions]
/// (most notably the active map bounds)
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,
);
@@ -2,7 +2,6 @@ import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/unarchive_action_button.widget.dart';
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
@@ -155,13 +154,7 @@ class _AddActionButtonState extends ConsumerState<AddActionButton> {
return;
}
if (result.count == 0 && result.failedCount > 0) {
ImmichToast.show(
context: context,
msg: 'assets_cannot_be_added_to_album_count'.t(context: context, args: {'count': result.failedCount}),
toastType: ToastType.error,
);
} else if (result.count == 0) {
if (result.count == 0) {
ImmichToast.show(
context: context,
msg: 'add_to_album_bottom_sheet_already_exists'.tr(namedArgs: {'album': album.name}),
@@ -41,7 +41,7 @@ class FavoriteBottomSheet extends ConsumerWidget {
}
final remoteAssets = selectedAssets.whereType<RemoteAsset>();
final result = await ref
final addedCount = await ref
.read(remoteAlbumProvider.notifier)
.addAssets(album.id, remoteAssets.map((e) => e.id).toList());
@@ -52,13 +52,7 @@ class FavoriteBottomSheet extends ConsumerWidget {
);
}
if (result.added == 0 && result.failed > 0) {
ImmichToast.show(
context: context,
msg: 'assets_cannot_be_added_to_album_count'.t(context: context, args: {'count': result.failed}),
toastType: ToastType.error,
);
} else if (result.added != remoteAssets.length) {
if (addedCount != remoteAssets.length) {
ImmichToast.show(
context: context,
msg: 'add_to_album_bottom_sheet_already_exists'.t(args: {"album": album.name}),
@@ -35,7 +35,6 @@ class _ScopedMapTimeline extends StatelessWidget {
@override
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(
overrides: [
timelineServiceProvider.overrideWith((ref) {
@@ -44,13 +43,16 @@ class _ScopedMapTimeline extends StatelessWidget {
throw Exception('User must be logged in to access archive');
}
final users = ref.watch(mapStateProvider).withPartners
? ref.watch(timelineUsersProvider).valueOrNull ?? [user.id]
: [user.id];
final withPartners = ref.watch(mapStateProvider.select((s) => s.withPartners));
final users = withPartners ? ref.watch(timelineUsersProvider).valueOrNull ?? [user.id] : [user.id];
final timelineService = ref
.watch(timelineFactoryProvider)
.map(users, ref.watch(mapStateProvider).toOptions());
.geographicMap(
users,
() => ref.read(mapStateProvider).toOptions(),
ref.read(mapStateProvider.notifier).optionsStream,
);
ref.onDispose(timelineService.dispose);
return timelineService;
}),
@@ -1,3 +1,5 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/events.model.dart';
@@ -63,11 +65,16 @@ class MapState {
class MapStateNotifier extends Notifier<MapState> {
MapStateNotifier();
final StreamController<TimelineMapOptions> _optionsController = StreamController.broadcast();
Stream<TimelineMapOptions> get optionsStream => _optionsController.stream;
bool setBounds(LatLngBounds bounds) {
if (state.bounds == bounds) {
return false;
}
state = state.copyWith(bounds: bounds);
_optionsController.add(state.toOptions());
return true;
}
@@ -82,12 +89,14 @@ class MapStateNotifier extends Notifier<MapState> {
void switchFavoriteOnly(bool isFavoriteOnly) {
ref.read(settingsProvider).write(.mapShowFavoriteOnly, isFavoriteOnly);
state = state.copyWith(onlyFavorites: isFavoriteOnly);
_optionsController.add(state.toOptions());
EventStream.shared.emit(const MapMarkerReloadEvent());
}
void switchIncludeArchived(bool isIncludeArchived) {
ref.read(settingsProvider).write(.mapIncludeArchived, isIncludeArchived);
state = state.copyWith(includeArchived: isIncludeArchived);
_optionsController.add(state.toOptions());
EventStream.shared.emit(const MapMarkerReloadEvent());
}
@@ -100,11 +109,13 @@ class MapStateNotifier extends Notifier<MapState> {
void setRelativeTime(int relativeDays) {
ref.read(settingsProvider).write(.mapRelativeDate, relativeDays);
state = state.copyWith(relativeDays: relativeDays);
_optionsController.add(state.toOptions());
EventStream.shared.emit(const MapMarkerReloadEvent());
}
@override
MapState build() {
ref.onDispose(_optionsController.close);
final mapConfig = ref.read(appConfigProvider.select((config) => config.map));
return MapState(
themeMode: mapConfig.themeMode,
-1
View File
@@ -183,7 +183,6 @@ class AuthNotifier extends StateNotifier<AuthState> {
Future<void> saveLocalEndpoint(String url) async {
await _ref.read(settingsProvider).write(.networkLocalEndpoint, url);
await _apiService.updateHeaders();
}
String? getSavedWifiName() {
@@ -37,19 +37,11 @@ class ActionResult {
final bool success;
final String? error;
final List<String> remoteAssetIds;
final int failedCount;
const ActionResult({
required this.count,
required this.success,
this.error,
this.remoteAssetIds = const [],
this.failedCount = 0,
});
const ActionResult({required this.count, required this.success, this.error, this.remoteAssetIds = const []});
@override
String toString() =>
'ActionResult(count: $count, success: $success, error: $error, remoteAssetIds: $remoteAssetIds, failedCount: $failedCount)';
String toString() => 'ActionResult(count: $count, success: $success, error: $error, remoteAssetIds: $remoteAssetIds)';
}
class ActionNotifier extends Notifier<void> {
@@ -401,12 +393,9 @@ class ActionNotifier extends Notifier<void> {
final albumNotifier = ref.read(remoteAlbumProvider.notifier);
int addedRemote = 0;
int failedRemote = 0;
if (remoteIds.isNotEmpty) {
try {
final result = await albumNotifier.addAssets(album.id, remoteIds);
addedRemote = result.added;
failedRemote = result.failed;
addedRemote = await albumNotifier.addAssets(album.id, remoteIds);
} catch (error, stack) {
_logger.severe('Failed to add assets to album ${album.id}', error, stack);
return ActionResult(count: 0, success: false, error: error.toString());
@@ -420,7 +409,7 @@ class ActionNotifier extends Notifier<void> {
}
if (localAssets.isEmpty) {
return ActionResult(count: addedRemote, success: true, failedCount: failedRemote);
return ActionResult(count: addedRemote, success: true);
}
final uploadResult = await upload(
@@ -435,7 +424,6 @@ class ActionNotifier extends Notifier<void> {
count: addedRemote + uploadResult.count,
success: uploadResult.success,
error: uploadResult.error,
failedCount: failedRemote,
);
}
@@ -200,12 +200,12 @@ class RemoteAlbumNotifier extends Notifier<RemoteAlbumState> {
return _remoteAlbumService.getAssets(albumId);
}
Future<({int added, int failed})> addAssets(String albumId, List<String> assetIds) async {
final result = await _remoteAlbumService.addAssets(albumId: albumId, assetIds: assetIds);
if (result.added > 0) {
Future<int> addAssets(String albumId, List<String> assetIds) async {
final added = await _remoteAlbumService.addAssets(albumId: albumId, assetIds: assetIds);
if (added > 0) {
await _refreshAlbumInState(albumId);
}
return result;
return added;
}
/// Links a freshly-uploaded local asset to an album using its new remote ID,
@@ -59,7 +59,7 @@ class DriftAlbumApiRepository extends ApiRepository {
for (final dto in response) {
if (dto.success) {
added.add(dto.id);
} else if (dto.error.orElse(null) != BulkIdErrorReason.duplicate) {
} else {
failed.add(dto.id);
}
}
@@ -5,7 +5,6 @@ import 'package:immich_mobile/domain/models/settings_key.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/models/auth/auxilary_endpoint.model.dart';
import 'package:immich_mobile/providers/api.provider.dart';
import 'package:immich_mobile/providers/infrastructure/settings.provider.dart';
import 'package:immich_mobile/widgets/settings/networking_settings/endpoint_input.dart';
@@ -27,16 +26,13 @@ class ExternalNetworkPreference extends HookConsumerWidget {
.map((e) => e.url)
.toList();
return ref.read(settingsProvider).write(SettingsKey.networkExternalEndpointList, urls);
ref.read(settingsProvider).write(SettingsKey.networkExternalEndpointList, urls);
}
updateValidationStatus(String url, int index, AuxCheckStatus status) async {
updateValidationStatus(String url, int index, AuxCheckStatus status) {
entries.value[index] = entries.value[index].copyWith(url: url, status: status);
await saveEndpointList();
if (status == AuxCheckStatus.valid) {
await ref.read(apiServiceProvider).updateHeaders();
}
saveEndpointList();
}
handleReorder(int oldIndex, int newIndex) {
+7 -8
View File
@@ -309,8 +309,8 @@ packages:
dependency: "direct main"
description:
path: "pkgs/cupertino_http"
ref: "58b03c756b81d16b3975a8ae4b91d25360123bb5"
resolved-ref: "58b03c756b81d16b3975a8ae4b91d25360123bb5"
ref: a0a933358517c6d01cff37fc2a2752ee2d744a3c
resolved-ref: a0a933358517c6d01cff37fc2a2752ee2d744a3c
url: "https://github.com/mertalev/http"
source: git
version: "3.0.0-wip"
@@ -1158,13 +1158,12 @@ packages:
source: hosted
version: "0.5.0"
objective_c:
dependency: "direct overridden"
dependency: transitive
description:
path: "pkgs/objective_c"
ref: "2915556701f4734a784222f290a82adb1b101682"
resolved-ref: "2915556701f4734a784222f290a82adb1b101682"
url: "https://github.com/mertalev/native"
source: git
name: objective_c
sha256: "6cb691c686fa2838c6deb34980d426145c2a5d537491cb83d463c33cdbc726ed"
url: "https://pub.dev"
source: hosted
version: "9.4.1"
octo_image:
dependency: "direct main"
+1 -6
View File
@@ -84,7 +84,7 @@ dependencies:
cupertino_http:
git:
url: https://github.com/mertalev/http
ref: '58b03c756b81d16b3975a8ae4b91d25360123bb5'
ref: 'a0a933358517c6d01cff37fc2a2752ee2d744a3c' # https://github.com/dart-lang/http/pull/1876
path: pkgs/cupertino_http/
ok_http:
git:
@@ -114,11 +114,6 @@ dev_dependencies:
# Pin bonsoir to 5.x until cast releases a version compatible with bonsoir 6.x.
dependency_overrides:
bonsoir: ^5.1.11
objective_c:
git:
url: https://github.com/mertalev/native
ref: '2915556701f4734a784222f290a82adb1b101682' # https://github.com/dart-lang/native/pull/3458
path: pkgs/objective_c/
flutter:
uses-material-design: true
@@ -1,82 +0,0 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:immich_mobile/repositories/drift_album_api_repository.dart';
import 'package:mocktail/mocktail.dart';
import 'package:openapi/api.dart';
class _MockAlbumsApi extends Mock implements AlbumsApi {}
void main() {
late _MockAlbumsApi api;
late DriftAlbumApiRepository repo;
setUpAll(() {
registerFallbackValue(BulkIdsDto(ids: const []));
});
setUp(() {
api = _MockAlbumsApi();
repo = DriftAlbumApiRepository(api);
});
void stubResponse(List<BulkIdResponseDto> response) {
when(
() => api.addAssetsToAlbum(any(), any(), abortTrigger: any(named: 'abortTrigger')),
).thenAnswer((_) async => response);
}
test('no_permission failure surfaces as failed, not added (the #22342 bug)', () async {
stubResponse([
BulkIdResponseDto(id: 'a1', success: false, error: const Optional.present(BulkIdErrorReason.noPermission)),
]);
final result = await repo.addAssets('album1', ['a1']);
expect(result.added, isEmpty);
expect(result.failed, ['a1']);
});
test('duplicate is neither added nor failed (genuinely already in album)', () async {
stubResponse([
BulkIdResponseDto(id: 'a1', success: false, error: const Optional.present(BulkIdErrorReason.duplicate)),
]);
final result = await repo.addAssets('album1', ['a1']);
expect(result.added, isEmpty);
expect(result.failed, isEmpty);
});
test('success is added', () async {
stubResponse([BulkIdResponseDto(id: 'a1', success: true)]);
final result = await repo.addAssets('album1', ['a1']);
expect(result.added, ['a1']);
expect(result.failed, isEmpty);
});
test('not_found and unknown count as failures', () async {
stubResponse([
BulkIdResponseDto(id: 'a1', success: false, error: const Optional.present(BulkIdErrorReason.notFound)),
BulkIdResponseDto(id: 'a2', success: false, error: const Optional.present(BulkIdErrorReason.unknown)),
]);
final result = await repo.addAssets('album1', ['a1', 'a2']);
expect(result.added, isEmpty);
expect(result.failed, ['a1', 'a2']);
});
test('mixed: added kept, no_permission failed, duplicate dropped', () async {
stubResponse([
BulkIdResponseDto(id: 'ok', success: true),
BulkIdResponseDto(id: 'perm', success: false, error: const Optional.present(BulkIdErrorReason.noPermission)),
BulkIdResponseDto(id: 'dup', success: false, error: const Optional.present(BulkIdErrorReason.duplicate)),
]);
final result = await repo.addAssets('album1', ['ok', 'perm', 'dup']);
expect(result.added, ['ok']);
expect(result.failed, ['perm']);
});
}
-92
View File
@@ -183,98 +183,6 @@
},
"uiHints": ["Filter"]
},
{
"name": "assetDateFilter",
"title": "Filter by date",
"description": "Filter assets by date taken",
"types": ["AssetV1"],
"schema": {
"type": "object",
"properties": {
"startDate": {
"type": "object",
"title": "Start date",
"description": "Earliest date of assets to include",
"properties": {
"day": {
"type": "number",
"title": "Day",
"description": "Day of the year to match",
"uiHint": {
"order": 1
}
},
"month": {
"type": "number",
"title": "Month",
"description": "Month of the year to match",
"uiHint": {
"order": 2
}
},
"year": {
"type": "number",
"title": "Year",
"description": "Year to match",
"uiHint": {
"order": 3
}
}
},
"uiHint": {
"order": 1
},
"required": ["day", "month", "year"]
},
"endDate": {
"type": "object",
"title": "End date",
"description": "Latest date of assets to include",
"properties": {
"day": {
"type": "number",
"title": "Day",
"description": "Day of the year to match",
"uiHint": {
"order": 1
}
},
"month": {
"type": "number",
"title": "Month",
"description": "Month of the year to match",
"uiHint": {
"order": 2
}
},
"year": {
"type": "number",
"title": "Year",
"description": "Year to match",
"uiHint": {
"order": 3
}
}
},
"uiHint": {
"order": 2
},
"required": ["day", "month", "year"]
},
"recurring": {
"type": "boolean",
"default": false,
"title": "Match recurring dates",
"description": "Allow any assets with matching months/days regardless of the year",
"uiHint": {
"order": 3
}
}
},
"required": ["recurring", "startDate", "endDate"]
},
"uiHints": ["Filter"]
},
{
"name": "assetTypeFilter",
"title": "Filter by asset type",
-23
View File
@@ -124,27 +124,6 @@ const methods = wrapper<Manifest>({
return { workflow: { continue: earthDiameter * delta <= (config.coordinate?.radius ?? 0) } };
},
assetDateFilter: ({ config, data }) => {
const assetDate = new Date(data.asset.localDateTime);
let startDate = new Date(config.startDate.year, config.startDate.month - 1, config.startDate.day);
let endDate = new Date(config.endDate.year, config.endDate.month - 1, config.endDate.day);
if (config.recurring) {
startDate.setFullYear(assetDate.getFullYear());
endDate.setFullYear(assetDate.getFullYear());
if (endDate < startDate) {
if (assetDate > endDate) {
endDate.setFullYear(endDate.getFullYear() + 1);
} else {
startDate.setFullYear(startDate.getFullYear() - 1);
}
}
}
return { workflow: { continue: assetDate >= startDate && assetDate <= endDate } };
},
assetLock: ({ config, data }) => {
if (!config.inverse && data.asset.visibility !== AssetVisibility.Locked) {
return { changes: { asset: { visibility: AssetVisibility.Locked } } };
@@ -200,7 +179,6 @@ const {
assetFavorite,
assetFileFilter,
assetLocationFilter,
assetDateFilter,
assetLock,
assetMissingTimeZoneFilter,
assetTypeFilter,
@@ -217,7 +195,6 @@ export {
assetFavorite,
assetFileFilter,
assetLocationFilter,
assetDateFilter,
assetLock,
assetMissingTimeZoneFilter,
assetTypeFilter,