Merge branch 'main' into fix/notification-from-native

This commit is contained in:
Alex
2025-10-05 09:39:17 -05:00
committed by GitHub
127 changed files with 3069 additions and 765 deletions
+1 -1
View File
@@ -84,4 +84,4 @@ Below is how your code needs to be structured:
## Contributing
Please refer to the [architecture](https://immich.app/docs/developer/architecture/) for contributing to the mobile app!
Please refer to the [architecture](https://docs.immich.app/developer/architecture/) for contributing to the mobile app!
@@ -1,8 +1,11 @@
package app.alextran.immich
import android.app.Application
import android.os.Handler
import android.os.Looper
import androidx.work.Configuration
import androidx.work.WorkManager
import app.alextran.immich.background.BackgroundEngineLock
import app.alextran.immich.background.BackgroundWorkerApiImpl
class ImmichApp : Application() {
@@ -17,6 +20,11 @@ class ImmichApp : Application() {
// As a workaround, we also run a backup check when initializing the application
ContentObserverWorker.startBackupWorker(context = this, delayMilliseconds = 0)
BackgroundWorkerApiImpl.enqueueBackgroundWorker(this)
Handler(Looper.getMainLooper()).postDelayed({
// We can only check the engine count and not the status of the lock here,
// as the previous start might have been killed without unlocking.
if (BackgroundEngineLock.connectEngines > 0) return@postDelayed
BackgroundWorkerApiImpl.enqueueBackgroundWorker(this)
}, 5000)
}
}
@@ -8,43 +8,46 @@ import java.util.concurrent.atomic.AtomicInteger
private const val TAG = "BackgroundEngineLock"
class BackgroundEngineLock(context: Context) : BackgroundWorkerLockApi, FlutterPlugin {
private val ctx: Context = context.applicationContext
private val ctx: Context = context.applicationContext
companion object {
companion object {
private var engineCount = AtomicInteger(0)
private var engineCount = AtomicInteger(0)
private fun checkAndEnforceBackgroundLock(ctx: Context) {
// work manager task is running while the main app is opened, cancel the worker
if (BackgroundWorkerPreferences(ctx).isLocked() &&
engineCount.get() > 1 &&
BackgroundWorkerApiImpl.isBackgroundWorkerRunning()
) {
Log.i(TAG, "Background worker is locked, cancelling the background worker")
BackgroundWorkerApiImpl.cancelBackgroundWorker(ctx)
}
}
val connectEngines: Int
get() = engineCount.get()
private fun checkAndEnforceBackgroundLock(ctx: Context) {
// work manager task is running while the main app is opened, cancel the worker
if (BackgroundWorkerPreferences(ctx).isLocked() &&
connectEngines > 1 &&
BackgroundWorkerApiImpl.isBackgroundWorkerRunning()
) {
Log.i(TAG, "Background worker is locked, cancelling the background worker")
BackgroundWorkerApiImpl.cancelBackgroundWorker(ctx)
}
}
}
override fun lock() {
BackgroundWorkerPreferences(ctx).setLocked(true)
checkAndEnforceBackgroundLock(ctx)
Log.i(TAG, "Background worker is locked")
}
override fun lock() {
BackgroundWorkerPreferences(ctx).setLocked(true)
checkAndEnforceBackgroundLock(ctx)
Log.i(TAG, "Background worker is locked")
}
override fun unlock() {
BackgroundWorkerPreferences(ctx).setLocked(false)
Log.i(TAG, "Background worker is unlocked")
}
override fun unlock() {
BackgroundWorkerPreferences(ctx).setLocked(false)
Log.i(TAG, "Background worker is unlocked")
}
override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) {
checkAndEnforceBackgroundLock(binding.applicationContext)
engineCount.incrementAndGet()
Log.i(TAG, "Flutter engine attached. Attached Engines count: $engineCount")
}
override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) {
checkAndEnforceBackgroundLock(binding.applicationContext)
engineCount.incrementAndGet()
Log.i(TAG, "Flutter engine attached. Attached Engines count: $engineCount")
}
override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {
engineCount.decrementAndGet()
Log.i(TAG, "Flutter engine detached. Attached Engines count: $engineCount")
}
override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {
engineCount.decrementAndGet()
Log.i(TAG, "Flutter engine detached. Attached Engines count: $engineCount")
}
}
+2 -2
View File
@@ -35,8 +35,8 @@ platform :android do
task: 'bundle',
build_type: 'Release',
properties: {
"android.injected.version.code" => 3019,
"android.injected.version.name" => "1.144.1",
"android.injected.version.code" => 3021,
"android.injected.version.name" => "2.0.1",
}
)
upload_to_play_store(skip_upload_apk: true, skip_upload_images: true, skip_upload_screenshots: true, aab: '../build/app/outputs/bundle/release/app-release.aab')
+9 -9
View File
@@ -705,7 +705,7 @@
CODE_SIGN_ENTITLEMENTS = Runner/RunnerProfile.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 227;
CURRENT_PROJECT_VERSION = 230;
CUSTOM_GROUP_ID = group.app.immich.share;
DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_BITCODE = NO;
@@ -849,7 +849,7 @@
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 227;
CURRENT_PROJECT_VERSION = 230;
CUSTOM_GROUP_ID = group.app.immich.share;
DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_BITCODE = NO;
@@ -879,7 +879,7 @@
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 227;
CURRENT_PROJECT_VERSION = 230;
CUSTOM_GROUP_ID = group.app.immich.share;
DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_BITCODE = NO;
@@ -913,7 +913,7 @@
CODE_SIGN_ENTITLEMENTS = WidgetExtension/WidgetExtension.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 227;
CURRENT_PROJECT_VERSION = 230;
DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
@@ -956,7 +956,7 @@
CODE_SIGN_ENTITLEMENTS = WidgetExtension/WidgetExtension.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 227;
CURRENT_PROJECT_VERSION = 230;
DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
@@ -996,7 +996,7 @@
CODE_SIGN_ENTITLEMENTS = WidgetExtension/WidgetExtension.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 227;
CURRENT_PROJECT_VERSION = 230;
DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
@@ -1035,7 +1035,7 @@
CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 227;
CURRENT_PROJECT_VERSION = 230;
CUSTOM_GROUP_ID = group.app.immich.share;
DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
@@ -1079,7 +1079,7 @@
CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 227;
CURRENT_PROJECT_VERSION = 230;
CUSTOM_GROUP_ID = group.app.immich.share;
DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
@@ -1120,7 +1120,7 @@
CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 227;
CURRENT_PROJECT_VERSION = 230;
CUSTOM_GROUP_ID = group.app.immich.share;
DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
+2 -2
View File
@@ -80,7 +80,7 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>1.144.0</string>
<string>2.0.0</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleURLTypes</key>
@@ -107,7 +107,7 @@
</dict>
</array>
<key>CFBundleVersion</key>
<string>227</string>
<string>230</string>
<key>FLTEnableImpeller</key>
<true/>
<key>ITSAppUsesNonExemptEncryption</key>
+1 -1
View File
@@ -22,7 +22,7 @@ platform :ios do
path: "./Runner.xcodeproj",
)
increment_version_number(
version_number: "1.144.1"
version_number: "2.0.1"
)
increment_build_number(
build_number: latest_testflight_build_number + 1,
@@ -84,8 +84,8 @@ class AssetService {
return 1.0;
}
Future<List<(String, String)>> getPlaces() {
return _remoteAssetRepository.getPlaces();
Future<List<(String, String)>> getPlaces(String userId) {
return _remoteAssetRepository.getPlaces(userId);
}
Future<(int local, int remote)> getAssetCounts() async {
@@ -59,7 +59,8 @@ class TimelineFactory {
TimelineService fromAssets(List<BaseAsset> assets) => TimelineService(_timelineRepository.fromAssets(assets));
TimelineService map(LatLngBounds bounds) => TimelineService(_timelineRepository.map(bounds, groupBy));
TimelineService map(String userId, LatLngBounds bounds) =>
TimelineService(_timelineRepository.map(userId, bounds, groupBy));
}
class TimelineService {
+9 -5
View File
@@ -6,11 +6,12 @@ import 'package:immich_mobile/utils/isolate.dart';
import 'package:worker_manager/worker_manager.dart';
typedef SyncCallback = void Function();
typedef SyncCallbackWithResult<T> = void Function(T result);
typedef SyncErrorCallback = void Function(String error);
class BackgroundSyncManager {
final SyncCallback? onRemoteSyncStart;
final SyncCallback? onRemoteSyncComplete;
final SyncCallbackWithResult<bool?>? onRemoteSyncComplete;
final SyncErrorCallback? onRemoteSyncError;
final SyncCallback? onLocalSyncStart;
@@ -156,15 +157,18 @@ class BackgroundSyncManager {
debugLabel: 'remote-sync',
);
return _syncTask!
.then((result) => result ?? false)
.whenComplete(() {
onRemoteSyncComplete?.call();
_syncTask = null;
.then((result) {
final success = result ?? false;
onRemoteSyncComplete?.call(success);
return success;
})
.catchError((error) {
onRemoteSyncError?.call(error.toString());
_syncTask = null;
return false;
})
.whenComplete(() {
_syncTask = null;
});
}
@@ -62,7 +62,7 @@ class DriftRemoteAlbumRepository extends DriftDatabaseRepository {
.toDto(
assetCount: row.read(assetCount) ?? 0,
ownerName: row.read(_db.userEntity.name)!,
isShared: row.read(_db.remoteAlbumUserEntity.userId.count(distinct: true))! > 2,
isShared: row.read(_db.remoteAlbumUserEntity.userId.count(distinct: true))! > 0,
),
)
.get();
@@ -107,7 +107,7 @@ class DriftRemoteAlbumRepository extends DriftDatabaseRepository {
.toDto(
assetCount: row.read(assetCount) ?? 0,
ownerName: row.read(_db.userEntity.name)!,
isShared: row.read(_db.remoteAlbumUserEntity.userId.count(distinct: true))! > 2,
isShared: row.read(_db.remoteAlbumUserEntity.userId.count(distinct: true))! > 0,
),
)
.getSingleOrNull();
@@ -305,8 +305,9 @@ class DriftRemoteAlbumRepository extends DriftDatabaseRepository {
.readTable(_db.remoteAlbumEntity)
.toDto(
ownerName: row.read(_db.userEntity.name)!,
isShared: row.read(_db.remoteAlbumUserEntity.userId.count(distinct: true))! > 2,
isShared: row.read(_db.remoteAlbumUserEntity.userId.count(distinct: true))! > 0,
);
return album;
}).watchSingleOrNull();
}
@@ -81,9 +81,11 @@ class RemoteAssetRepository extends DriftDatabaseRepository {
.getSingleOrNull();
}
Future<List<(String, String)>> getPlaces() {
Future<List<(String, String)>> getPlaces(String userId) {
final asset = Subquery(
_db.remoteAssetEntity.select()..orderBy([(row) => OrderingTerm.desc(row.createdAt)]),
_db.remoteAssetEntity.select()
..where((row) => row.ownerId.equals(userId))
..orderBy([(row) => OrderingTerm.desc(row.createdAt)]),
"asset",
);
@@ -51,6 +51,7 @@ class SyncStreamRepository extends DriftDatabaseRepository {
await _db.remoteAssetEntity.deleteAll();
await _db.remoteExifEntity.deleteAll();
await _db.stackEntity.deleteAll();
await _db.authUserEntity.deleteAll();
await _db.userEntity.deleteAll();
await _db.userMetadataEntity.deleteAll();
});
@@ -431,12 +431,16 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
return query.map((row) => row.readTable(_db.remoteAssetEntity).toDto()).get();
}
TimelineQuery map(LatLngBounds bounds, GroupAssetsBy groupBy) => (
bucketSource: () => _watchMapBucket(bounds, groupBy: groupBy),
assetSource: (offset, count) => _getMapBucketAssets(bounds, offset: offset, count: count),
TimelineQuery map(String userId, LatLngBounds bounds, GroupAssetsBy groupBy) => (
bucketSource: () => _watchMapBucket(userId, bounds, groupBy: groupBy),
assetSource: (offset, count) => _getMapBucketAssets(userId, bounds, offset: offset, count: count),
);
Stream<List<Bucket>> _watchMapBucket(LatLngBounds bounds, {GroupAssetsBy groupBy = GroupAssetsBy.day}) {
Stream<List<Bucket>> _watchMapBucket(
String userId,
LatLngBounds bounds, {
GroupAssetsBy groupBy = GroupAssetsBy.day,
}) {
if (groupBy == GroupAssetsBy.none) {
// TODO: Support GroupAssetsBy.none
throw UnsupportedError("GroupAssetsBy.none is not supported for _watchMapBucket");
@@ -455,7 +459,8 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
),
])
..where(
_db.remoteExifEntity.inBounds(bounds) &
_db.remoteAssetEntity.ownerId.equals(userId) &
_db.remoteExifEntity.inBounds(bounds) &
_db.remoteAssetEntity.visibility.equalsValue(AssetVisibility.timeline) &
_db.remoteAssetEntity.deletedAt.isNull(),
)
@@ -469,7 +474,12 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
}).watch();
}
Future<List<BaseAsset>> _getMapBucketAssets(LatLngBounds bounds, {required int offset, required int count}) {
Future<List<BaseAsset>> _getMapBucketAssets(
String userId,
LatLngBounds bounds, {
required int offset,
required int count,
}) {
final query =
_db.remoteAssetEntity.select().join([
innerJoin(
@@ -479,7 +489,8 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
),
])
..where(
_db.remoteExifEntity.inBounds(bounds) &
_db.remoteAssetEntity.ownerId.equals(userId) &
_db.remoteExifEntity.inBounds(bounds) &
_db.remoteAssetEntity.visibility.equalsValue(AssetVisibility.timeline) &
_db.remoteAssetEntity.deletedAt.isNull(),
)
@@ -49,9 +49,6 @@ class _DriftBackupPageState extends ConsumerState<DriftBackupPage> {
ref.read(driftBackupProvider.notifier).updateSyncing(true);
syncSuccess = await ref.read(backgroundSyncProvider).syncRemote();
ref
.read(driftBackupProvider.notifier)
.updateError(syncSuccess == true ? BackupError.none : BackupError.syncFailed);
ref.read(driftBackupProvider.notifier).updateSyncing(false);
if (mounted) {
@@ -94,7 +91,6 @@ class _DriftBackupPageState extends ConsumerState<DriftBackupPage> {
if (syncSuccess == false) {
Logger("DriftBackupPage").warning("Remote sync did not complete successfully, skipping backup");
backupNotifier.updateError(BackupError.syncFailed);
return;
}
await backupNotifier.startBackup(currentUser.id);
@@ -120,7 +120,6 @@ class _DriftBackupAlbumSelectionPageState extends ConsumerState<DriftBackupAlbum
return backupNotifier.startBackup(user.id);
} else {
Logger('DriftBackupAlbumSelectionPage').warning('Background sync failed, not starting backup');
backupNotifier.updateError(BackupError.syncFailed);
}
}),
),
@@ -66,7 +66,6 @@ class DriftBackupOptionsPage extends ConsumerWidget {
return backupNotifier.startBackup(currentUser.id);
} else {
Logger('DriftBackupOptionsPage').warning('Background sync failed, not starting backup');
backupNotifier.updateError(BackupError.syncFailed);
}
}),
),
+3 -8
View File
@@ -141,14 +141,9 @@ class SettingsSubPage extends StatelessWidget {
@override
Widget build(BuildContext context) {
context.locale;
return SafeArea(
bottom: true,
top: false,
right: true,
child: Scaffold(
appBar: AppBar(centerTitle: false, title: Text(section.title).tr()),
body: section.widget,
),
return Scaffold(
appBar: AppBar(centerTitle: false, title: Text(section.title).tr()),
body: section.widget,
);
}
}
@@ -64,7 +64,7 @@ class SplashScreenPageState extends ConsumerState<SplashScreenPage> {
if (Store.isBetaTimelineEnabled) {
bool syncSuccess = false;
await Future.wait([
backgroundManager.syncLocal(full: true),
backgroundManager.syncLocal(),
backgroundManager.syncRemote().then((success) => syncSuccess = success),
]);
@@ -76,7 +76,6 @@ class SplashScreenPageState extends ConsumerState<SplashScreenPage> {
_resumeBackup(backupProvider),
]);
} else {
backupProvider.updateError(BackupError.syncFailed);
await backgroundManager.hashAssets();
}
@@ -512,7 +512,7 @@ class _AlbumList extends ConsumerWidget {
}
return SliverPadding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
padding: const EdgeInsets.only(left: 16.0, right: 16, bottom: 64),
sliver: SliverList.builder(
itemBuilder: (_, index) {
final album = albums[index];
@@ -186,6 +186,7 @@ class _AssetDetailBottomSheet extends ConsumerWidget {
color: context.textTheme.bodyMedium?.color?.withAlpha(155),
),
),
const SizedBox(height: 64),
],
);
}
@@ -11,8 +11,8 @@ import 'package:immich_mobile/providers/infrastructure/people.provider.dart';
import 'package:immich_mobile/providers/routes.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/services/api.service.dart';
import 'package:immich_mobile/utils/people.utils.dart';
import 'package:immich_mobile/utils/image_url_builder.dart';
import 'package:immich_mobile/utils/people.utils.dart';
class SheetPeopleDetails extends ConsumerStatefulWidget {
const SheetPeopleDetails({super.key});
@@ -158,11 +158,14 @@ class _PeopleAvatar extends StatelessWidget {
maxLines: 1,
),
if (person.birthDate != null)
Text(
formatAge(person.birthDate!, assetFileCreatedAt),
textAlign: TextAlign.center,
style: context.textTheme.bodyMedium?.copyWith(
color: context.textTheme.bodyMedium?.color?.withAlpha(175),
FittedBox(
fit: BoxFit.scaleDown,
child: Text(
formatAge(person.birthDate!, assetFileCreatedAt),
textAlign: TextAlign.center,
style: context.textTheme.bodyMedium?.copyWith(
color: context.textTheme.bodyMedium?.color?.withAlpha(175),
),
),
),
],
@@ -1,6 +1,9 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.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/extensions/translate_extensions.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/archive_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_local_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_permanent_action_button.widget.dart';
@@ -15,9 +18,12 @@ import 'package:immich_mobile/presentation/widgets/action_buttons/trash_action_b
import 'package:immich_mobile/presentation/widgets/action_buttons/unfavorite_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/unstack_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/upload_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/album/album_selector.widget.dart';
import 'package:immich_mobile/presentation/widgets/bottom_sheet/base_bottom_sheet.widget.dart';
import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
import 'package:immich_mobile/providers/server_info.provider.dart';
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart';
class FavoriteBottomSheet extends ConsumerWidget {
const FavoriteBottomSheet({super.key});
@@ -27,9 +33,42 @@ class FavoriteBottomSheet extends ConsumerWidget {
final multiselect = ref.watch(multiSelectProvider);
final isTrashEnable = ref.watch(serverInfoProvider.select((state) => state.serverFeatures.trash));
Future<void> addAssetsToAlbum(RemoteAlbum album) async {
final selectedAssets = multiselect.selectedAssets;
if (selectedAssets.isEmpty) {
return;
}
final remoteAssets = selectedAssets.whereType<RemoteAsset>();
final addedCount = await ref
.read(remoteAlbumProvider.notifier)
.addAssets(album.id, remoteAssets.map((e) => e.id).toList());
if (selectedAssets.length != remoteAssets.length) {
ImmichToast.show(
context: context,
msg: 'add_to_album_bottom_sheet_some_local_assets'.t(context: context),
);
}
if (addedCount != remoteAssets.length) {
ImmichToast.show(
context: context,
msg: 'add_to_album_bottom_sheet_already_exists'.t(args: {"album": album.name}),
);
} else {
ImmichToast.show(
context: context,
msg: 'add_to_album_bottom_sheet_added'.t(args: {"album": album.name}),
);
}
ref.read(multiSelectProvider.notifier).reset();
}
return BaseBottomSheet(
initialChildSize: 0.25,
maxChildSize: 0.4,
initialChildSize: 0.4,
maxChildSize: 0.7,
shouldCloseOnMinExtent: false,
actions: [
const ShareActionButton(source: ActionSource.timeline),
@@ -52,6 +91,9 @@ class FavoriteBottomSheet extends ConsumerWidget {
const UploadActionButton(source: ActionSource.timeline),
],
],
slivers: multiselect.hasRemote
? [const AddToAlbumHeader(), AlbumSelector(onAlbumSelected: addAssetsToAlbum)]
: [],
);
}
}
@@ -5,6 +5,7 @@ import 'package:immich_mobile/presentation/widgets/bottom_sheet/base_bottom_shee
import 'package:immich_mobile/presentation/widgets/map/map.state.dart';
import 'package:immich_mobile/presentation/widgets/timeline/timeline.widget.dart';
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
class MapBottomSheet extends StatelessWidget {
const MapBottomSheet({super.key});
@@ -32,8 +33,13 @@ class _ScopedMapTimeline extends StatelessWidget {
return ProviderScope(
overrides: [
timelineServiceProvider.overrideWith((ref) {
final user = ref.watch(currentUserProvider);
if (user == null) {
throw Exception('User must be logged in to access archive');
}
final bounds = ref.watch(mapStateProvider).bounds;
final timelineService = ref.watch(timelineFactoryProvider).map(bounds);
final timelineService = ref.watch(timelineFactoryProvider).map(user.id, bounds);
ref.onDispose(timelineService.dispose);
return timelineService;
}),
@@ -16,7 +16,7 @@ class ThumbnailTile extends ConsumerWidget {
this.asset, {
this.size = kThumbnailResolution,
this.fit = BoxFit.cover,
this.showStorageIndicator,
this.showStorageIndicator = false,
this.lockSelection = false,
this.heroOffset,
super.key,
@@ -25,7 +25,7 @@ class ThumbnailTile extends ConsumerWidget {
final BaseAsset? asset;
final Size size;
final BoxFit fit;
final bool? showStorageIndicator;
final bool showStorageIndicator;
final bool lockSelection;
final int? heroOffset;
@@ -55,7 +55,7 @@ class ThumbnailTile extends ConsumerWidget {
: const BoxDecoration();
final bool storageIndicator =
showStorageIndicator ?? ref.watch(settingsProvider.select((s) => s.get(Setting.showStorageIndicator)));
ref.watch(settingsProvider.select((s) => s.get(Setting.showStorageIndicator))) && showStorageIndicator;
return Stack(
children: [
@@ -185,6 +185,8 @@ class _Map extends StatelessWidget {
initialCameraPosition: initialLocation == null
? const CameraPosition(target: LatLng(0, 0), zoom: 0)
: CameraPosition(target: initialLocation, zoom: MapUtils.mapZoomToAssetLevel),
compassEnabled: false,
rotateGesturesEnabled: false,
styleString: style,
onMapCreated: onMapCreated,
onStyleLoadedCallback: onMapReady,
@@ -14,7 +14,7 @@ class TimelineArgs {
final double maxHeight;
final double spacing;
final int columnCount;
final bool? showStorageIndicator;
final bool showStorageIndicator;
final bool withStack;
final GroupAssetsBy? groupBy;
@@ -23,7 +23,7 @@ class TimelineArgs {
required this.maxHeight,
this.spacing = kTimelineSpacing,
this.columnCount = kTimelineColumnCount,
this.showStorageIndicator,
this.showStorageIndicator = false,
this.withStack = false,
this.groupBy,
});
@@ -36,7 +36,7 @@ class Timeline extends StatelessWidget {
this.showStorageIndicator = false,
this.withStack = false,
this.appBar = const ImmichSliverAppBar(floating: true, pinned: false, snap: false),
this.bottomSheet = const GeneralBottomSheet(minChildSize: 0.18),
this.bottomSheet = const GeneralBottomSheet(minChildSize: 0.23),
this.groupBy,
this.withScrubber = true,
this.snapToMonth = true,
@@ -161,7 +161,6 @@ class AppLifeCycleNotifier extends StateNotifier<AppLifeCycleEnum> {
_resumeBackup(),
]);
} else {
_ref.read(driftBackupProvider.notifier).updateError(BackupError.syncFailed);
await _safeRun(backgroundManager.hashAssets(), "hashAssets");
}
@@ -1,12 +1,21 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/utils/background_sync.dart';
import 'package:immich_mobile/providers/backup/drift_backup.provider.dart';
import 'package:immich_mobile/providers/sync_status.provider.dart';
final backgroundSyncProvider = Provider<BackgroundSyncManager>((ref) {
final syncStatusNotifier = ref.read(syncStatusProvider.notifier);
final backupProvider = ref.read(driftBackupProvider.notifier);
final manager = BackgroundSyncManager(
onRemoteSyncStart: syncStatusNotifier.startRemoteSync,
onRemoteSyncComplete: syncStatusNotifier.completeRemoteSync,
onRemoteSyncStart: () {
syncStatusNotifier.startRemoteSync();
backupProvider.updateError(BackupError.none);
},
onRemoteSyncComplete: (isSuccess) {
syncStatusNotifier.completeRemoteSync();
backupProvider.updateError(isSuccess == true ? BackupError.none : BackupError.syncFailed);
},
onRemoteSyncError: syncStatusNotifier.errorRemoteSync,
onLocalSyncStart: syncStatusNotifier.startLocalSync,
onLocalSyncComplete: syncStatusNotifier.completeLocalSync,
@@ -3,6 +3,7 @@ import 'package:immich_mobile/domain/services/asset.service.dart';
import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/remote_asset.repository.dart';
import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
final localAssetRepository = Provider<DriftLocalAssetRepository>(
(ref) => DriftLocalAssetRepository(ref.watch(driftProvider)),
@@ -19,9 +20,13 @@ final assetServiceProvider = Provider(
),
);
final placesProvider = FutureProvider<List<(String, String)>>(
(ref) => AssetService(
remoteAssetRepository: ref.watch(remoteAssetRepositoryProvider),
localAssetRepository: ref.watch(localAssetRepository),
).getPlaces(),
);
final placesProvider = FutureProvider<List<(String, String)>>((ref) {
final assetService = ref.watch(assetServiceProvider);
final auth = ref.watch(currentUserProvider);
if (auth == null) {
return Future.value(const []);
}
return assetService.getPlaces(auth.id);
});
@@ -8,6 +8,7 @@ import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/entities/asset.entity.dart' as asset_entity;
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/platform_extensions.dart';
import 'package:immich_mobile/extensions/response_extensions.dart';
import 'package:immich_mobile/repositories/asset_api.repository.dart';
import 'package:immich_mobile/utils/hash.dart';
@@ -72,6 +73,7 @@ class AssetMediaRepository {
// TODO: make this more efficient
Future<int> shareAssets(List<BaseAsset> assets, BuildContext context) async {
final downloadedXFiles = <XFile>[];
final tempFiles = <File>[];
for (var asset in assets) {
final localId = (asset is LocalAsset)
@@ -82,6 +84,9 @@ class AssetMediaRepository {
if (localId != null) {
File? f = await AssetEntity(id: localId, width: 1, height: 1, typeInt: 0).originFile;
downloadedXFiles.add(XFile(f!.path));
if (CurrentPlatform.isIOS) {
tempFiles.add(f);
}
} else if (asset is RemoteAsset) {
final tempDir = await getTemporaryDirectory();
final name = asset.name;
@@ -95,6 +100,7 @@ class AssetMediaRepository {
await tempFile.writeAsBytes(res.bodyBytes);
downloadedXFiles.add(XFile(tempFile.path));
tempFiles.add(tempFile);
} else {
_log.warning("Asset type not supported for sharing: $asset");
continue;
@@ -113,9 +119,9 @@ class AssetMediaRepository {
downloadedXFiles,
sharePositionOrigin: Rect.fromPoints(Offset.zero, Offset(size.width / 3, size.height)),
).then((result) async {
for (var file in downloadedXFiles) {
for (var file in tempFiles) {
try {
await File(file.path).delete();
await file.delete();
} catch (e) {
_log.warning("Failed to delete temporary file: ${file.path}", e);
}
@@ -162,48 +162,32 @@ class _ProfileIndicator extends ConsumerWidget {
}
}
const double _kBadgeWidgetSize = 30.0;
class _BackupIndicator extends ConsumerWidget {
const _BackupIndicator();
@override
Widget build(BuildContext context, WidgetRef ref) {
const widgetSize = 30.0;
final hasError = ref.watch(driftBackupProvider.select((state) => state.error != BackupError.none));
final indicatorIcon = hasError
? Icon(
Icons.warning_rounded,
size: 12,
color: context.colorScheme.error,
semanticLabel: 'backup_controller_page_backup'.tr(),
)
: _getBackupBadgeIcon(context, ref);
final badgeBackground = hasError ? context.colorScheme.errorContainer : context.colorScheme.surfaceContainer;
final indicatorIcon = _getBackupBadgeIcon(context, ref);
return InkWell(
onTap: () => context.pushRoute(const DriftBackupRoute()),
borderRadius: const BorderRadius.all(Radius.circular(12)),
child: Badge(
label: Container(
width: widgetSize / 2,
height: widgetSize / 2,
decoration: BoxDecoration(
color: badgeBackground,
border: Border.all(color: context.colorScheme.outline.withValues(alpha: .3)),
borderRadius: BorderRadius.circular(widgetSize / 2),
),
child: indicatorIcon,
),
label: indicatorIcon,
backgroundColor: Colors.transparent,
alignment: Alignment.bottomRight,
isLabelVisible: indicatorIcon != null,
offset: const Offset(-2, -12),
child: Icon(Icons.backup_rounded, size: widgetSize, color: context.primaryColor),
child: Icon(Icons.backup_rounded, size: _kBadgeWidgetSize, color: context.primaryColor),
),
);
}
Widget? _getBackupBadgeIcon(BuildContext context, WidgetRef ref) {
final backupStateStream = ref.watch(settingsProvider).watch(Setting.enableBackup);
final hasError = ref.watch(driftBackupProvider.select((state) => state.error != BackupError.none));
final isDarkTheme = context.isDarkTheme;
final iconColor = isDarkTheme ? Colors.white : Colors.black;
final isUploading = ref.watch(driftBackupProvider.select((state) => state.uploadItems.isNotEmpty));
@@ -215,42 +199,76 @@ class _BackupIndicator extends ConsumerWidget {
final backupEnabled = snapshot.data ?? false;
if (!backupEnabled) {
return Icon(
Icons.cloud_off_rounded,
size: 9,
color: iconColor,
semanticLabel: 'backup_controller_page_backup'.tr(),
return _BadgeLabel(
Icon(
Icons.cloud_off_rounded,
size: 9,
color: iconColor,
semanticLabel: 'backup_controller_page_backup'.tr(),
),
);
}
if (hasError) {
return _BadgeLabel(
Icon(
Icons.warning_rounded,
size: 12,
color: context.colorScheme.error,
semanticLabel: 'backup_controller_page_backup'.tr(),
),
backgroundColor: context.colorScheme.errorContainer,
);
}
if (isUploading) {
return Container(
padding: const EdgeInsets.all(3.5),
child: Theme(
data: context.themeData.copyWith(
progressIndicatorTheme: context.themeData.progressIndicatorTheme.copyWith(year2023: true),
),
child: CircularProgressIndicator(
strokeWidth: 2,
strokeCap: StrokeCap.round,
valueColor: AlwaysStoppedAnimation<Color>(iconColor),
semanticsLabel: 'backup_controller_page_backup'.tr(),
return _BadgeLabel(
Container(
padding: const EdgeInsets.all(3.5),
child: Theme(
data: context.themeData.copyWith(
progressIndicatorTheme: context.themeData.progressIndicatorTheme.copyWith(year2023: true),
),
child: CircularProgressIndicator(
strokeWidth: 2,
strokeCap: StrokeCap.round,
valueColor: AlwaysStoppedAnimation<Color>(iconColor),
semanticsLabel: 'backup_controller_page_backup'.tr(),
),
),
),
);
}
return Icon(
Icons.check_outlined,
size: 9,
color: iconColor,
semanticLabel: 'backup_controller_page_backup'.tr(),
return _BadgeLabel(
Icon(Icons.check_outlined, size: 9, color: iconColor, semanticLabel: 'backup_controller_page_backup'.tr()),
);
},
);
}
}
class _BadgeLabel extends StatelessWidget {
final Widget indicator;
final Color? backgroundColor;
const _BadgeLabel(this.indicator, {this.backgroundColor});
@override
Widget build(BuildContext context) {
return Container(
width: _kBadgeWidgetSize / 2,
height: _kBadgeWidgetSize / 2,
decoration: BoxDecoration(
color: backgroundColor ?? context.colorScheme.surfaceContainer,
border: Border.all(color: context.colorScheme.outline.withValues(alpha: .3)),
borderRadius: BorderRadius.circular(_kBadgeWidgetSize / 2),
),
child: indicator,
);
}
}
class _SyncStatusIndicator extends ConsumerStatefulWidget {
const _SyncStatusIndicator();
@@ -1,12 +1,10 @@
import 'package:auto_route/auto_route.dart';
import 'package:flutter/foundation.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/providers/app_settings.provider.dart';
import 'package:immich_mobile/providers/auth.provider.dart';
import 'package:immich_mobile/providers/server_info.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/services/app_settings.service.dart';
@@ -16,10 +14,9 @@ class BetaTimelineListTile extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final betaTimelineValue = ref.watch(appSettingsServiceProvider).getSetting<bool>(AppSettingsEnum.betaTimeline);
final serverInfo = ref.watch(serverInfoProvider);
final auth = ref.watch(authProvider);
if (!auth.isAuthenticated || (serverInfo.serverVersion.minor < 136 && kReleaseMode)) {
if (!auth.isAuthenticated) {
return const SizedBox.shrink();
}
+1 -1
View File
@@ -3,7 +3,7 @@ Immich API
This Dart package is automatically generated by the [OpenAPI Generator](https://openapi-generator.tech) project:
- API version: 1.144.1
- API version: 2.0.1
- Generator version: 7.8.0
- Build package: org.openapitools.codegen.languages.DartClientCodegen
+1 -1
View File
@@ -2,7 +2,7 @@ name: immich_mobile
description: Immich - selfhosted backup media file on mobile phone
publish_to: 'none'
version: 1.144.1+3019
version: 2.0.1+3021
environment:
sdk: '>=3.8.0 <4.0.0'