mirror of
https://github.com/immich-app/immich.git
synced 2026-05-15 20:12:13 -04:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 20c8bbd35f |
@@ -116,6 +116,7 @@ jobs:
|
||||
~/.gradle/wrapper
|
||||
~/.android/sdk
|
||||
mobile/android/.gradle
|
||||
mobile/.dart_tool
|
||||
key: build-mobile-gradle-${{ runner.os }}-main
|
||||
|
||||
- name: Setup Android SDK
|
||||
@@ -188,6 +189,7 @@ jobs:
|
||||
~/.gradle/wrapper
|
||||
~/.android/sdk
|
||||
mobile/android/.gradle
|
||||
mobile/.dart_tool
|
||||
key: ${{ steps.cache-gradle-restore.outputs.cache-primary-key }}
|
||||
|
||||
build-sign-ios:
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
# Release Candidate (RC)
|
||||
|
||||
The Release Candidate channel is an opt-in track for the next Immich version, published roughly one week ahead of the official release. RC builds are labeled `vX.Y.Z-rc.N` and may contain bugs — testers help us catch them before everyone else gets the update.
|
||||
|
||||
## Why participate
|
||||
|
||||
Joining the RC channel lets you preview the next version, surface regressions that are easier to fix before release, and shape the build that lands for everyone. Feedback you give here makes it into the final cut.
|
||||
|
||||
## iOS — Public TestFlight
|
||||
|
||||
1. Install Apple's [TestFlight](https://apps.apple.com/app/testflight/id899247664) app.
|
||||
2. Open the public RC TestFlight link: `<TESTFLIGHT_LINK_PLACEHOLDER>`.
|
||||
3. Tap **Accept**, then **Install**.
|
||||
|
||||
:::info Separate app on your device
|
||||
The RC build is a distinct app — "Immich RC" — that installs alongside your production Immich. Your data is not shared between the two. Sign in to your server in the RC app the same way you would on a fresh install.
|
||||
:::
|
||||
|
||||
## Android — Open Testing
|
||||
|
||||
1. Open the Play Store opt-in link: `<PLAY_STORE_OPT_IN_PLACEHOLDER>`.
|
||||
2. Tap **Become a tester**.
|
||||
|
||||
:::warning RC replaces your production install
|
||||
Android RC builds use the same package name as production Immich, so the Play Store delivers them as updates on top of your existing install. This is a one-way change until you opt out and reinstall — there is no separate "Immich RC" app on Android.
|
||||
:::
|
||||
|
||||
## Server, web, CLI
|
||||
|
||||
RC server images are not part of this initial rollout. For now, if you want to test an RC backend alongside an RC mobile build, build the server from the `vX.Y.Z-rc.N` git tag yourself. We may publish `:rc` Docker tags later.
|
||||
|
||||
## Reporting bugs
|
||||
|
||||
Open a GitHub issue at the [Immich issue tracker](https://github.com/immich-app/immich/issues). Mention that you are on an RC build and include the version string (`vX.Y.Z-rc.N`) so we can correlate reports across testers.
|
||||
|
||||
:::note
|
||||
Test against a non-critical library or a staging instance — not your only copy of family photos. RCs are pre-release software and may have bugs that affect data.
|
||||
:::
|
||||
|
||||
## Leaving the RC channel
|
||||
|
||||
- **iOS**: Open TestFlight → Immich RC → **Stop Testing**. The RC app stays installed until you delete it; deleting it does not affect your production Immich install.
|
||||
- **Android**: Open the Play Store → Immich → scroll to **You're a tester** → leave the program. Then uninstall and reinstall Immich to drop back to the production track.
|
||||
|
||||
## Cadence
|
||||
|
||||
We typically publish one to three RCs in the ~1 week before each minor release. Patch releases usually skip the RC stage and ship straight to production.
|
||||
@@ -52,7 +52,7 @@ Scroll to the bottom of the "**Details**" section and find the `IP Address` list
|
||||
|
||||
## Step 4 - Configure Firewall Settings
|
||||
|
||||
Once your project completes the build process, your containers will start. In order to be able to access Immich from your browser, you need to configure the firewall settings for your Synology NAS to allow communication between the Immich containers.
|
||||
Once your project completes the build process, your containers will start. In order to be able to access Immich from your browser, you need to configure the firewall settings for your Synology NAS.
|
||||
|
||||
Open "**Control Panel**" on your Synology NAS, and select "**Security**". Navigate to "**Firewall**"
|
||||
|
||||
@@ -74,7 +74,6 @@ Read the [Post Installation](/install/post-install.mdx) steps and [upgrade instr
|
||||
|
||||
<details>
|
||||
<summary>Updating Immich using Container Manager</summary>
|
||||
|
||||
Check the post installation and upgrade instructions at the links above before proceeding with this section.
|
||||
|
||||
## Step 1. Backup
|
||||
@@ -111,7 +110,7 @@ Go to **Project**, select **Action** then **Build**. This will download, unpack,
|
||||
|
||||
## Step 5. Update firewall rule
|
||||
|
||||
Without a fixed subnet, the default behavior is to automatically start the containers once installed. If `immich_server` runs for a few seconds and then stops, it may be because the firewall rule no longer matches the server IP address.
|
||||
The default behavior is to automatically start the containers once installed. If `immich_server` runs for a few seconds and then stops, it may be because the firewall rule no longer matches the server IP address.
|
||||
|
||||
Go to the **Container** section. Click on `immich_server` and scroll down on **General** to find the IP address.
|
||||

|
||||
@@ -124,67 +123,4 @@ In this example, the IP addresses mismatch and the firewall rule needs to be edi
|
||||
|
||||

|
||||
|
||||
To prevent future firewall issues, you may set a fixed subnet. [See Set Fixed Subnet](#set-fixed-subnet) for instructions.
|
||||
|
||||
</details>
|
||||
|
||||
<details id="set-fixed-subnet">
|
||||
<summary>Set Fixed Subnet</summary>
|
||||
|
||||
Docker by default assigns dynamic subnets to bridge networks which can change when rebuilding containers and can cause firewall rules to break. To avoid this, define a fixed subnet in your `docker-compose.yml`:
|
||||
|
||||
## Step 1. Determine current subnet
|
||||
|
||||
Go to the **Container** section. Click on `immich_server` and scroll down on **General** to find the IP address.
|
||||

|
||||
|
||||
## Step 2. Add network configuration
|
||||
|
||||
Add the following network configuration at the end of your `docker-compose.yml` file:
|
||||
|
||||
```yaml
|
||||
networks:
|
||||
immich-network:
|
||||
driver: bridge
|
||||
ipam:
|
||||
config:
|
||||
- subnet: 172.20.0.0/16
|
||||
gateway: 172.20.0.1
|
||||
```
|
||||
|
||||
If your docker container is running on a different subnet then update accordingly.
|
||||
|
||||
## Step 3. Add network to each service
|
||||
|
||||
Add the network to each service (immich-server, immich-machine-learning, redis, database):
|
||||
|
||||
```yaml
|
||||
services:
|
||||
immich-server:
|
||||
# other config options
|
||||
networks:
|
||||
- immich-network
|
||||
|
||||
immich-machine-learning:
|
||||
# other config options
|
||||
networks:
|
||||
- immich-network
|
||||
|
||||
redis:
|
||||
# other config options
|
||||
networks:
|
||||
- immich-network
|
||||
|
||||
database:
|
||||
# other config options
|
||||
networks:
|
||||
- immich-network
|
||||
```
|
||||
|
||||
Save your changes. Synology will ask if you want to save changes only or rebuild containers. Select rebuild containers.
|
||||
|
||||
## Step 4. Update Firewall Rules, if necessary
|
||||
|
||||
If your firewall rules were not already set for this subnet, the firewall rules will need to be updated. See [Step 4 - Configure Firewall Settings](#step-4---configure-firewall-settings).
|
||||
|
||||
</details>
|
||||
|
||||
@@ -5,3 +5,5 @@ The mobile app can be downloaded from the following places:
|
||||
- [Apple App Store](https://apps.apple.com/us/app/immich/id1613945652)
|
||||
- [F-Droid](https://f-droid.org/packages/app.alextran.immich)
|
||||
- [GitHub Releases (apk)](https://github.com/immich-app/immich/releases)
|
||||
|
||||
Want to help test the next release before it ships? Join the [Release Candidate channel](/features/release-candidate).
|
||||
|
||||
@@ -2,7 +2,7 @@ import { LoginResponseDto, ManualJobName } from '@immich/sdk';
|
||||
import { errorDto } from 'src/responses';
|
||||
import { app, utils } from 'src/utils';
|
||||
import request from 'supertest';
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
|
||||
describe('/admin/database-backups', () => {
|
||||
let cookie: string | undefined;
|
||||
@@ -13,9 +13,6 @@ describe('/admin/database-backups', () => {
|
||||
admin = await utils.adminSetup({
|
||||
onboarding: false,
|
||||
});
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await utils.resetBackups(admin.accessToken);
|
||||
});
|
||||
|
||||
|
||||
@@ -568,8 +568,6 @@ export const utils = {
|
||||
name: ManualJobName.BackupDatabase,
|
||||
});
|
||||
|
||||
await utils.waitForQueueFinish(accessToken, 'backupDatabase');
|
||||
|
||||
return utils.poll(
|
||||
() => request(app).get('/admin/database-backups').set('Authorization', `Bearer ${accessToken}`),
|
||||
({ status, body }) => status === 200 && body.backups.length === 1,
|
||||
|
||||
@@ -11,7 +11,6 @@ class RemoteAsset extends BaseAsset {
|
||||
final String ownerId;
|
||||
final String? stackId;
|
||||
final DateTime? uploadedAt;
|
||||
final DateTime? deletedAt;
|
||||
|
||||
const RemoteAsset({
|
||||
required this.id,
|
||||
@@ -32,7 +31,6 @@ class RemoteAsset extends BaseAsset {
|
||||
super.livePhotoVideoId,
|
||||
this.stackId,
|
||||
required super.isEdited,
|
||||
this.deletedAt,
|
||||
}) : localAssetId = localId;
|
||||
|
||||
@override
|
||||
@@ -50,8 +48,6 @@ class RemoteAsset extends BaseAsset {
|
||||
@override
|
||||
bool get isEditable => isImage && !isMotionPhoto && !isAnimatedImage;
|
||||
|
||||
bool get isTrashed => deletedAt != null;
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return '''Asset {
|
||||
@@ -90,8 +86,7 @@ class RemoteAsset extends BaseAsset {
|
||||
thumbHash == other.thumbHash &&
|
||||
visibility == other.visibility &&
|
||||
stackId == other.stackId &&
|
||||
uploadedAt == other.uploadedAt &&
|
||||
deletedAt == other.deletedAt;
|
||||
uploadedAt == other.uploadedAt;
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -103,8 +98,7 @@ class RemoteAsset extends BaseAsset {
|
||||
thumbHash.hashCode ^
|
||||
visibility.hashCode ^
|
||||
stackId.hashCode ^
|
||||
uploadedAt.hashCode ^
|
||||
deletedAt.hashCode;
|
||||
uploadedAt.hashCode;
|
||||
|
||||
RemoteAsset copyWith({
|
||||
String? id,
|
||||
@@ -125,7 +119,6 @@ class RemoteAsset extends BaseAsset {
|
||||
String? livePhotoVideoId,
|
||||
String? stackId,
|
||||
bool? isEdited,
|
||||
DateTime? deletedAt,
|
||||
}) {
|
||||
return RemoteAsset(
|
||||
id: id ?? this.id,
|
||||
@@ -146,7 +139,6 @@ class RemoteAsset extends BaseAsset {
|
||||
livePhotoVideoId: livePhotoVideoId ?? this.livePhotoVideoId,
|
||||
stackId: stackId ?? this.stackId,
|
||||
isEdited: isEdited ?? this.isEdited,
|
||||
deletedAt: deletedAt ?? this.deletedAt,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -164,7 +156,6 @@ class RemoteAssetExif extends RemoteAsset {
|
||||
required super.createdAt,
|
||||
required super.updatedAt,
|
||||
super.uploadedAt,
|
||||
super.deletedAt,
|
||||
super.width,
|
||||
super.height,
|
||||
super.durationMs,
|
||||
@@ -202,7 +193,6 @@ class RemoteAssetExif extends RemoteAsset {
|
||||
DateTime? createdAt,
|
||||
DateTime? updatedAt,
|
||||
DateTime? uploadedAt,
|
||||
DateTime? deletedAt,
|
||||
int? width,
|
||||
int? height,
|
||||
int? durationMs,
|
||||
@@ -224,7 +214,6 @@ class RemoteAssetExif extends RemoteAsset {
|
||||
createdAt: createdAt ?? this.createdAt,
|
||||
updatedAt: updatedAt ?? this.updatedAt,
|
||||
uploadedAt: uploadedAt ?? this.uploadedAt,
|
||||
deletedAt: deletedAt ?? this.deletedAt,
|
||||
width: width ?? this.width,
|
||||
height: height ?? this.height,
|
||||
durationMs: durationMs ?? this.durationMs,
|
||||
|
||||
@@ -4,15 +4,25 @@ import 'package:immich_mobile/domain/models/user.model.dart';
|
||||
/// Defines the data type for each value
|
||||
enum StoreKey<T> {
|
||||
version<int>._(0),
|
||||
assetETag<String>._(1),
|
||||
currentUser<UserDto>._(2),
|
||||
deviceIdHash<int>._(3),
|
||||
deviceId<String>._(4),
|
||||
backupFailedSince<DateTime>._(5),
|
||||
backupRequireWifi<bool>._(6),
|
||||
backupRequireCharging<bool>._(7),
|
||||
backupTriggerDelay<int>._(8),
|
||||
serverUrl<String>._(10),
|
||||
accessToken<String>._(11),
|
||||
serverEndpoint<String>._(12),
|
||||
autoBackup<bool>._(13),
|
||||
backgroundBackup<bool>._(14),
|
||||
sslClientCertData<String>._(15),
|
||||
sslClientPasswd<String>._(16),
|
||||
uploadErrorNotificationGracePeriod<int>._(106),
|
||||
selectedAlbumSortOrder<int>._(113),
|
||||
advancedTroubleshooting<bool>._(114),
|
||||
selfSignedCert<bool>._(120),
|
||||
selectedAlbumSortReverse<bool>._(123),
|
||||
enableHapticFeedback<bool>._(126),
|
||||
customHeaders<String>._(127),
|
||||
@@ -28,6 +38,7 @@ enum StoreKey<T> {
|
||||
// Read-only Mode settings
|
||||
readonlyModeEnabled<bool>._(138),
|
||||
albumGridView<bool>._(140),
|
||||
loadOriginal<bool>._(101),
|
||||
|
||||
// Experimental stuff
|
||||
enableBackup<bool>._(1003),
|
||||
|
||||
@@ -74,6 +74,5 @@ extension RemoteAssetEntityDataDomainEx on RemoteAssetEntityData {
|
||||
localId: localId,
|
||||
stackId: stackId,
|
||||
isEdited: isEdited,
|
||||
deletedAt: deletedAt,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:drift/drift.dart';
|
||||
import 'package:drift_sqlite_async/drift_sqlite_async.dart';
|
||||
import 'package:drift_flutter/drift_flutter.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/asset_edit.entity.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/asset_face.entity.dart';
|
||||
@@ -32,10 +31,6 @@ import 'package:immich_mobile/infrastructure/entities/user_metadata.entity.dart'
|
||||
import 'package:immich_mobile/infrastructure/repositories/db.repository.drift.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/db.repository.steps.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:sqlite3/sqlite3.dart';
|
||||
import 'package:sqlite_async/sqlite_async.dart';
|
||||
|
||||
@DriftDatabase(
|
||||
tables: [
|
||||
@@ -65,9 +60,8 @@ import 'package:sqlite_async/sqlite_async.dart';
|
||||
include: {'package:immich_mobile/infrastructure/entities/merged_asset.drift'},
|
||||
)
|
||||
class Drift extends $Drift {
|
||||
Drift(super.executor);
|
||||
|
||||
Drift.sqlite(SqliteConnection db) : super(SqliteAsyncDriftConnection(db));
|
||||
Drift([QueryExecutor? executor])
|
||||
: super(executor ?? driftDatabase(name: 'immich', native: const DriftNativeOptions(shareAcrossIsolates: true)));
|
||||
|
||||
Future<void> reset() async {
|
||||
// https://github.com/simolus3/drift/commit/bd80a46264b6dd833ef4fd87fffc03f5a832ab41#diff-3f879e03b4a35779344ef16170b9353608dd9c42385f5402ec6035aac4dd8a04R76-R94
|
||||
@@ -311,18 +305,3 @@ class DriftDatabaseRepository {
|
||||
|
||||
Future<T> transaction<T>(Future<T> Function() callback) => _db.transaction(callback);
|
||||
}
|
||||
|
||||
Future<SqliteConnection> openSqliteConnection({required String name}) async {
|
||||
final dbFolder = await getApplicationDocumentsDirectory();
|
||||
final file = File(p.join(dbFolder.path, '$name.sqlite'));
|
||||
return SqliteDatabase(path: file.path);
|
||||
}
|
||||
|
||||
Future<void> configureSqliteCache() async {
|
||||
// Make sqlite3 pick a more suitable location for temporary files - the
|
||||
// one from the system may be inaccessible due to sand-boxing.
|
||||
final cacheBase = (await getTemporaryDirectory()).path;
|
||||
// We can't access /tmp on Android, which sqlite3 would try by default.
|
||||
// Explicitly tell it about the correct temporary directory.
|
||||
sqlite3.tempDirectory = cacheBase;
|
||||
}
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import 'package:drift/drift.dart';
|
||||
import 'package:drift_sqlite_async/drift_sqlite_async.dart';
|
||||
import 'package:drift_flutter/drift_flutter.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/log.entity.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/logger_db.repository.drift.dart';
|
||||
import 'package:sqlite_async/sqlite_async.dart';
|
||||
|
||||
@DriftDatabase(tables: [LogMessageEntity])
|
||||
class DriftLogger extends $DriftLogger {
|
||||
DriftLogger.fromExecutor(super.executor);
|
||||
|
||||
DriftLogger.sqlite(SqliteConnection db) : super(SqliteAsyncDriftConnection(db));
|
||||
DriftLogger([QueryExecutor? executor])
|
||||
: super(
|
||||
executor ?? driftDatabase(name: 'immich_logs', native: const DriftNativeOptions(shareAcrossIsolates: true)),
|
||||
);
|
||||
|
||||
@override
|
||||
int get schemaVersion => 1;
|
||||
@@ -19,8 +19,7 @@ class DriftLogger extends $DriftLogger {
|
||||
await customStatement('PRAGMA foreign_keys = ON');
|
||||
await customStatement('PRAGMA synchronous = NORMAL');
|
||||
await customStatement('PRAGMA journal_mode = WAL');
|
||||
await customStatement('PRAGMA busy_timeout = 30000'); // 30s
|
||||
await customStatement('PRAGMA cache_size = -32000'); // 32MB
|
||||
await customStatement('PRAGMA busy_timeout = 500');
|
||||
await customStatement('PRAGMA temp_store = MEMORY');
|
||||
},
|
||||
);
|
||||
|
||||
@@ -35,11 +35,10 @@ class BaseActionButton extends ConsumerWidget {
|
||||
final miniWidth = minWidth ?? (context.isMobile ? context.width / 4.5 : 75.0);
|
||||
final iconTheme = IconTheme.of(context);
|
||||
final iconSize = iconTheme.size ?? 24.0;
|
||||
final iconColor = this.iconColor ?? iconTheme.color ?? context.themeData.iconTheme.color;
|
||||
final textColor = context.themeData.textTheme.labelLarge?.color;
|
||||
|
||||
if (iconOnly) {
|
||||
final iconColor = this.iconColor ?? iconTheme.color ?? context.themeData.iconTheme.color;
|
||||
|
||||
return IconButton(
|
||||
onPressed: onPressed,
|
||||
icon: Icon(iconData, size: iconSize, color: iconColor),
|
||||
@@ -47,18 +46,17 @@ class BaseActionButton extends ConsumerWidget {
|
||||
}
|
||||
|
||||
if (menuItem) {
|
||||
final iconColor = this.iconColor;
|
||||
final theme = context.themeData;
|
||||
final effectiveIconColor = iconColor ?? theme.iconTheme.color ?? theme.colorScheme.onSurfaceVariant;
|
||||
|
||||
return MenuItemButton(
|
||||
style: MenuItemButton.styleFrom(alignment: Alignment.centerLeft, padding: const EdgeInsets.all(16)),
|
||||
leadingIcon: Icon(iconData, color: iconColor),
|
||||
leadingIcon: Icon(iconData, color: effectiveIconColor),
|
||||
onPressed: onPressed,
|
||||
child: Text(label, style: TextStyle(fontSize: 16, color: iconColor)),
|
||||
child: Text(label, style: theme.textTheme.labelLarge?.copyWith(fontSize: 16, color: iconColor)),
|
||||
);
|
||||
}
|
||||
|
||||
final iconColor = this.iconColor ?? iconTheme.color ?? context.themeData.iconTheme.color;
|
||||
|
||||
return ConstrainedBox(
|
||||
constraints: BoxConstraints(maxWidth: maxWidth),
|
||||
child: MaterialButton(
|
||||
|
||||
+2
-9
@@ -18,15 +18,8 @@ class DeletePermanentActionButton extends ConsumerWidget {
|
||||
final ActionSource source;
|
||||
final bool iconOnly;
|
||||
final bool menuItem;
|
||||
final bool useShortLabel;
|
||||
|
||||
const DeletePermanentActionButton({
|
||||
super.key,
|
||||
required this.source,
|
||||
this.iconOnly = false,
|
||||
this.menuItem = false,
|
||||
this.useShortLabel = false,
|
||||
});
|
||||
const DeletePermanentActionButton({super.key, required this.source, this.iconOnly = false, this.menuItem = false});
|
||||
|
||||
void _onTap(BuildContext context, WidgetRef ref) async {
|
||||
if (!context.mounted) {
|
||||
@@ -71,7 +64,7 @@ class DeletePermanentActionButton extends ConsumerWidget {
|
||||
return BaseActionButton(
|
||||
maxWidth: 110.0,
|
||||
iconData: Icons.delete_forever,
|
||||
label: useShortLabel ? "delete".t(context: context) : "delete_permanently".t(context: context),
|
||||
label: "delete_permanently".t(context: context),
|
||||
iconOnly: iconOnly,
|
||||
menuItem: menuItem,
|
||||
onPressed: () => _onTap(context, ref),
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:fluttertoast/fluttertoast.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/constants/enums.dart';
|
||||
import 'package:immich_mobile/domain/models/events.model.dart';
|
||||
import 'package:immich_mobile/domain/utils/event_stream.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/providers/infrastructure/action.provider.dart';
|
||||
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
|
||||
import 'package:immich_mobile/widgets/common/immich_toast.dart';
|
||||
|
||||
class RestoreActionButton extends ConsumerWidget {
|
||||
final ActionSource source;
|
||||
final bool iconOnly;
|
||||
final bool menuItem;
|
||||
|
||||
const RestoreActionButton({super.key, required this.source, this.iconOnly = false, this.menuItem = false});
|
||||
|
||||
void _onTap(BuildContext context, WidgetRef ref) async {
|
||||
if (!context.mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
final result = await ref.read(actionProvider.notifier).restoreTrash(source);
|
||||
ref.read(multiSelectProvider.notifier).reset();
|
||||
|
||||
if (source == ActionSource.viewer) {
|
||||
EventStream.shared.emit(const ViewerReloadAssetEvent());
|
||||
}
|
||||
|
||||
final successMessage = 'assets_restored_count'.t(context: context, args: {'count': result.count.toString()});
|
||||
|
||||
if (context.mounted) {
|
||||
ImmichToast.show(
|
||||
context: context,
|
||||
msg: result.success ? successMessage : 'scaffold_body_error_occurred'.t(context: context),
|
||||
gravity: ToastGravity.BOTTOM,
|
||||
toastType: result.success ? ToastType.success : ToastType.error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
return BaseActionButton(
|
||||
iconData: Icons.history_rounded,
|
||||
label: 'restore'.t(context: context),
|
||||
iconOnly: iconOnly,
|
||||
menuItem: menuItem,
|
||||
onPressed: () => _onTap(context, ref),
|
||||
maxWidth: 100.0,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -2,19 +2,15 @@ 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/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/domain/services/timeline.service.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/add_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_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';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/edit_image_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/restore_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/share_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/upload_action_button.widget.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
|
||||
import 'package:immich_mobile/providers/routes.provider.dart';
|
||||
import 'package:immich_mobile/providers/server_info.provider.dart';
|
||||
import 'package:immich_mobile/providers/user.provider.dart';
|
||||
@@ -37,31 +33,23 @@ class ViewerBottomBar extends ConsumerWidget {
|
||||
final showingDetails = ref.watch(assetViewerProvider.select((s) => s.showingDetails));
|
||||
final isInLockedView = ref.watch(inLockedViewProvider);
|
||||
final serverInfo = ref.watch(serverInfoProvider);
|
||||
final isInTrash = ref.read(timelineServiceProvider).origin == TimelineOrigin.trash;
|
||||
|
||||
final originalTheme = context.themeData;
|
||||
|
||||
final actions = <Widget>[
|
||||
if (isInTrash && isOwner && asset.hasRemote)
|
||||
const RestoreActionButton(source: ActionSource.viewer)
|
||||
else
|
||||
const ShareActionButton(source: ActionSource.viewer),
|
||||
const ShareActionButton(source: ActionSource.viewer),
|
||||
|
||||
if (!isInLockedView) ...[
|
||||
if (!isInTrash) ...[
|
||||
if (asset.isLocalOnly) const UploadActionButton(source: ActionSource.viewer),
|
||||
// edit sync was added in 2.6.0
|
||||
if (asset.isEditable && serverInfo.serverVersion >= const SemVer(major: 2, minor: 6, patch: 0))
|
||||
const EditImageActionButton(),
|
||||
if (asset.hasRemote) AddActionButton(originalTheme: originalTheme),
|
||||
],
|
||||
if (asset.isLocalOnly) const UploadActionButton(source: ActionSource.viewer),
|
||||
// edit sync was added in 2.6.0
|
||||
if (asset.isEditable && serverInfo.serverVersion >= const SemVer(major: 2, minor: 6, patch: 0))
|
||||
const EditImageActionButton(),
|
||||
if (asset.hasRemote) AddActionButton(originalTheme: originalTheme),
|
||||
|
||||
if (isOwner) ...[
|
||||
if (asset.isLocalOnly)
|
||||
const DeleteLocalActionButton(source: ActionSource.viewer)
|
||||
else if (asset.isTrashed)
|
||||
const DeletePermanentActionButton(source: ActionSource.viewer, useShortLabel: true)
|
||||
else
|
||||
const DeleteActionButton(source: ActionSource.viewer, showConfirmation: true),
|
||||
asset.isLocalOnly
|
||||
? const DeleteLocalActionButton(source: ActionSource.viewer)
|
||||
: const DeleteActionButton(source: ActionSource.viewer, showConfirmation: true),
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
@@ -50,7 +50,7 @@ class ViewerKebabMenu extends ConsumerWidget {
|
||||
timelineOrigin: timelineOrigin,
|
||||
);
|
||||
|
||||
final menuChildren = ActionButtonBuilder.buildViewerKebabMenu(actionContext, context, ref);
|
||||
final menuChildren = ActionButtonBuilder.buildViewerKebabMenu(actionContext, context);
|
||||
|
||||
return MenuAnchor(
|
||||
consumeOutsideTap: true,
|
||||
|
||||
@@ -11,11 +11,12 @@ import 'package:immich_mobile/providers/api.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/user.provider.dart';
|
||||
import 'package:immich_mobile/services/api.service.dart';
|
||||
import 'package:immich_mobile/services/auth.service.dart';
|
||||
import 'package:immich_mobile/services/background_upload.service.dart';
|
||||
import 'package:immich_mobile/services/foreground_upload.service.dart';
|
||||
import 'package:immich_mobile/services/secure_storage.service.dart';
|
||||
import 'package:immich_mobile/services/background_upload.service.dart';
|
||||
import 'package:immich_mobile/services/widget.service.dart';
|
||||
import 'package:immich_mobile/utils/debug_print.dart';
|
||||
import 'package:immich_mobile/utils/hash.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:openapi/api.dart';
|
||||
|
||||
@@ -143,6 +144,7 @@ class AuthNotifier extends StateNotifier<AuthState> {
|
||||
// Due to the flow of the code, this will always happen on first login
|
||||
user = serverUser;
|
||||
await Store.put(StoreKey.deviceId, deviceId);
|
||||
await Store.put(StoreKey.deviceIdHash, fastHash(deviceId));
|
||||
}
|
||||
} on ApiException catch (error, stackTrace) {
|
||||
if (error.code == 401) {
|
||||
|
||||
@@ -2,9 +2,15 @@ import 'package:immich_mobile/domain/models/store.model.dart';
|
||||
import 'package:immich_mobile/entities/store.entity.dart';
|
||||
|
||||
enum AppSettingsEnum<T> {
|
||||
uploadErrorNotificationGracePeriod<int>(
|
||||
StoreKey.uploadErrorNotificationGracePeriod,
|
||||
"uploadErrorNotificationGracePeriod",
|
||||
2,
|
||||
),
|
||||
selectedAlbumSortOrder<int>(StoreKey.selectedAlbumSortOrder, "selectedAlbumSortOrder", 2),
|
||||
advancedTroubleshooting<bool>(StoreKey.advancedTroubleshooting, null, false),
|
||||
manageLocalMediaAndroid<bool>(StoreKey.manageLocalMediaAndroid, null, false),
|
||||
allowSelfSignedSSLCert<bool>(StoreKey.selfSignedCert, null, false),
|
||||
selectedAlbumSortReverse<bool>(StoreKey.selectedAlbumSortReverse, null, true),
|
||||
enableHapticFeedback<bool>(StoreKey.enableHapticFeedback, null, true),
|
||||
syncAlbums<bool>(StoreKey.syncAlbums, null, false),
|
||||
|
||||
@@ -123,6 +123,7 @@ class AuthService {
|
||||
_authRepository.clearLocalData(),
|
||||
Store.delete(StoreKey.currentUser),
|
||||
Store.delete(StoreKey.accessToken),
|
||||
Store.delete(StoreKey.assetETag),
|
||||
Store.delete(StoreKey.autoEndpointSwitching),
|
||||
Store.delete(StoreKey.preferredWifiName),
|
||||
Store.delete(StoreKey.localEndpoint),
|
||||
|
||||
@@ -21,7 +21,6 @@ import 'package:immich_mobile/presentation/widgets/action_buttons/move_to_lock_f
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/open_in_browser_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/remove_from_album_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/remove_from_lock_folder_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/restore_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/set_album_cover.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/set_profile_picture_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/share_action_button.widget.dart';
|
||||
@@ -82,7 +81,6 @@ enum ActionButtonType {
|
||||
moveToLockFolder,
|
||||
removeFromLockFolder,
|
||||
removeFromAlbum,
|
||||
restoreTrash,
|
||||
trash,
|
||||
deleteLocal,
|
||||
deletePermanent,
|
||||
@@ -114,17 +112,12 @@ enum ActionButtonType {
|
||||
context.isOwner && //
|
||||
!context.isInLockedView && //
|
||||
context.asset.hasRemote && //
|
||||
context.isTrashEnabled && //
|
||||
context.timelineOrigin != TimelineOrigin.trash,
|
||||
ActionButtonType.restoreTrash =>
|
||||
context.isOwner && //
|
||||
!context.isInLockedView && //
|
||||
context.asset.hasRemote && //
|
||||
context.timelineOrigin == TimelineOrigin.trash,
|
||||
context.isTrashEnabled,
|
||||
ActionButtonType.deletePermanent =>
|
||||
context.isOwner && //
|
||||
context.asset.hasRemote && //
|
||||
(!context.isTrashEnabled || context.timelineOrigin == TimelineOrigin.trash || context.isInLockedView),
|
||||
context.asset.hasRemote && //
|
||||
!context.isTrashEnabled ||
|
||||
context.isInLockedView,
|
||||
ActionButtonType.delete =>
|
||||
context.isOwner && //
|
||||
!context.isInLockedView && //
|
||||
@@ -208,11 +201,6 @@ enum ActionButtonType {
|
||||
),
|
||||
ActionButtonType.download => DownloadActionButton(source: context.source, iconOnly: iconOnly, menuItem: menuItem),
|
||||
ActionButtonType.trash => TrashActionButton(source: context.source, iconOnly: iconOnly, menuItem: menuItem),
|
||||
ActionButtonType.restoreTrash => RestoreActionButton(
|
||||
source: context.source,
|
||||
iconOnly: iconOnly,
|
||||
menuItem: menuItem,
|
||||
),
|
||||
ActionButtonType.deletePermanent => DeletePermanentActionButton(
|
||||
source: context.source,
|
||||
iconOnly: iconOnly,
|
||||
@@ -304,7 +292,6 @@ enum ActionButtonType {
|
||||
ActionButtonType.moveToLockFolder => 10,
|
||||
ActionButtonType.deleteLocal => 10,
|
||||
ActionButtonType.delete => 10,
|
||||
ActionButtonType.restoreTrash => 10,
|
||||
// 90: advancedInfo
|
||||
ActionButtonType.advancedInfo => 90,
|
||||
// 1: others
|
||||
@@ -322,15 +309,13 @@ class ActionButtonBuilder {
|
||||
ActionButtonType.delete,
|
||||
ActionButtonType.archive,
|
||||
ActionButtonType.unarchive,
|
||||
ActionButtonType.restoreTrash,
|
||||
ActionButtonType.deletePermanent,
|
||||
};
|
||||
|
||||
static List<Widget> build(ActionButtonContext context) {
|
||||
return _actionTypes.where((type) => type.shouldShow(context)).map((type) => type.buildButton(context)).toList();
|
||||
}
|
||||
|
||||
static List<Widget> buildViewerKebabMenu(ActionButtonContext context, BuildContext buildContext, WidgetRef ref) {
|
||||
static List<Widget> buildViewerKebabMenu(ActionButtonContext context, BuildContext buildContext) {
|
||||
final visibleButtons = defaultViewerKebabMenuOrder
|
||||
.where((type) => !defaultViewerBottomBarButtons.contains(type) && type.shouldShow(context))
|
||||
.toList();
|
||||
@@ -346,7 +331,7 @@ class ActionButtonBuilder {
|
||||
if (lastGroup != null && type.kebabMenuGroup != lastGroup) {
|
||||
result.add(const Divider(height: 1));
|
||||
}
|
||||
result.add(type.buildButton(context, buildContext, false, true).build(buildContext, ref));
|
||||
result.add(type.buildButton(context, buildContext, false, true));
|
||||
lastGroup = type.kebabMenuGroup;
|
||||
}
|
||||
|
||||
|
||||
@@ -43,9 +43,8 @@ void configureFileDownloaderNotifications() {
|
||||
|
||||
abstract final class Bootstrap {
|
||||
static Future<(Drift, DriftLogger)> initDomain({bool listenStoreUpdates = true, bool shouldBufferLogs = true}) async {
|
||||
await configureSqliteCache();
|
||||
final drift = Drift.sqlite(await openSqliteConnection(name: 'immich'));
|
||||
final logDb = DriftLogger.sqlite(await openSqliteConnection(name: 'immich_logs'));
|
||||
final drift = Drift();
|
||||
final logDb = DriftLogger();
|
||||
final DriftStoreRepository storeRepo = DriftStoreRepository(drift);
|
||||
|
||||
await StoreService.init(storeRepository: storeRepo, listenUpdates: listenStoreUpdates);
|
||||
|
||||
@@ -3,7 +3,10 @@ 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/providers/notification_permission.provider.dart';
|
||||
import 'package:immich_mobile/services/app_settings.service.dart';
|
||||
import 'package:immich_mobile/utils/hooks/app_settings_update_hook.dart';
|
||||
import 'package:immich_mobile/widgets/settings/settings_button_list_tile.dart';
|
||||
import 'package:immich_mobile/widgets/settings/settings_slider_list_tile.dart';
|
||||
import 'package:immich_mobile/widgets/settings/settings_sub_page_scaffold.dart';
|
||||
import 'package:permission_handler/permission_handler.dart';
|
||||
|
||||
@@ -13,6 +16,9 @@ class NotificationSetting extends HookConsumerWidget {
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final permissionService = ref.watch(notificationPermissionProvider);
|
||||
|
||||
final sliderValue = useAppSettingsState(AppSettingsEnum.uploadErrorNotificationGracePeriod);
|
||||
|
||||
final hasPermission = permissionService == PermissionStatus.granted;
|
||||
|
||||
openAppNotificationSettings(BuildContext ctx) {
|
||||
@@ -35,6 +41,8 @@ class NotificationSetting extends HookConsumerWidget {
|
||||
);
|
||||
}
|
||||
|
||||
final String formattedValue = _formatSliderValue(sliderValue.value.toDouble());
|
||||
|
||||
final notificationSettings = [
|
||||
if (!hasPermission)
|
||||
SettingsButtonListTile(
|
||||
@@ -49,8 +57,32 @@ class NotificationSetting extends HookConsumerWidget {
|
||||
}
|
||||
}),
|
||||
),
|
||||
SettingsSliderListTile(
|
||||
enabled: hasPermission,
|
||||
valueNotifier: sliderValue,
|
||||
text: 'setting_notifications_notify_failures_grace_period'.tr(namedArgs: {'duration': formattedValue}),
|
||||
maxValue: 5.0,
|
||||
noDivisons: 5,
|
||||
label: formattedValue,
|
||||
),
|
||||
];
|
||||
|
||||
return SettingsSubPageScaffold(settings: notificationSettings);
|
||||
}
|
||||
}
|
||||
|
||||
String _formatSliderValue(double v) {
|
||||
if (v == 0.0) {
|
||||
return 'setting_notifications_notify_immediately'.tr();
|
||||
} else if (v == 1.0) {
|
||||
return 'setting_notifications_notify_minutes'.tr(namedArgs: {'count': '30'});
|
||||
} else if (v == 2.0) {
|
||||
return 'setting_notifications_notify_hours'.tr(namedArgs: {'count': '2'});
|
||||
} else if (v == 3.0) {
|
||||
return 'setting_notifications_notify_hours'.tr(namedArgs: {'count': '8'});
|
||||
} else if (v == 4.0) {
|
||||
return 'setting_notifications_notify_hours'.tr(namedArgs: {'count': '24'});
|
||||
} else {
|
||||
return 'setting_notifications_notify_never'.tr();
|
||||
}
|
||||
}
|
||||
|
||||
+16
-24
@@ -370,11 +370,11 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.32.1"
|
||||
drift_sqlite_async:
|
||||
drift_flutter:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: drift_sqlite_async
|
||||
sha256: "1b6e99562fc5d35fe5e3696741720a8aca47f4c3eee35d4b9b94be819f53a6f6"
|
||||
name: drift_flutter
|
||||
sha256: "887fdec622174dc7eaefd0048403e34ee07cc18626ac8a7544cc3b8a4a172166"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.3.0"
|
||||
@@ -1619,38 +1619,30 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.10.2"
|
||||
sqlcipher_flutter_libs:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: sqlcipher_flutter_libs
|
||||
sha256: "38d62d659d2fb8739bf25a42c9a350d1fdd6c29a5a61f13a946778ec75d27929"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.7.0+eol"
|
||||
sqlite3:
|
||||
dependency: "direct main"
|
||||
dependency: transitive
|
||||
description:
|
||||
name: sqlite3
|
||||
sha256: "56da3e13ed7d28a66f930aa2b2b29db6736a233f08283326e96321dd812030f5"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.3.1"
|
||||
sqlite3_connection_pool:
|
||||
sqlite3_flutter_libs:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: sqlite3_connection_pool
|
||||
sha256: "90b25972c7699d84da97df1c5919804275560b4ab8a158bbec890434b9718f65"
|
||||
name: sqlite3_flutter_libs
|
||||
sha256: "3ed7553eee7bb368f8950f58ba29f634e06e813c029aff6a0d60862b96de8454"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.2.4"
|
||||
sqlite3_web:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: sqlite3_web
|
||||
sha256: d876398a9f2cbf115d93fc34901f8fa129b58b13b5fa9377156ed3a9a05695e3
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.7.1"
|
||||
sqlite_async:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: sqlite_async
|
||||
sha256: "4c243c5386eba3a7102f98999388a7e0a7f2632e4e06dafb3b4f5a44170a26f6"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.14.1"
|
||||
version: "0.6.0+eol"
|
||||
sqlparser:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
||||
+1
-3
@@ -19,7 +19,7 @@ dependencies:
|
||||
crypto: ^3.0.7
|
||||
device_info_plus: ^12.4.0
|
||||
drift: ^2.32.1
|
||||
drift_sqlite_async: 0.3.0
|
||||
drift_flutter: ^0.3.0
|
||||
dynamic_color: ^1.8.1
|
||||
easy_localization: ^3.0.8
|
||||
ffi: ^2.2.0
|
||||
@@ -66,8 +66,6 @@ dependencies:
|
||||
share_plus: ^10.1.4
|
||||
sliver_tools: ^0.2.12
|
||||
stream_transform: ^2.1.1
|
||||
sqlite3: ^3.3.1
|
||||
sqlite_async: 0.14.1
|
||||
thumbhash: 0.1.0+1
|
||||
timezone: ^0.9.4
|
||||
url_launcher: ^6.3.2
|
||||
|
||||
@@ -131,7 +131,7 @@ void main() {
|
||||
durationMs: 0,
|
||||
orientation: 0,
|
||||
isFavorite: false,
|
||||
playbackStyle: PlatformAssetPlaybackStyle.image,
|
||||
playbackStyle: PlatformAssetPlaybackStyle.image
|
||||
);
|
||||
|
||||
final assetsToRestore = [LocalAssetStub.image1];
|
||||
@@ -215,7 +215,7 @@ void main() {
|
||||
isFavorite: false,
|
||||
createdAt: 1700000000,
|
||||
updatedAt: 1732000000,
|
||||
playbackStyle: PlatformAssetPlaybackStyle.image,
|
||||
playbackStyle: PlatformAssetPlaybackStyle.image
|
||||
);
|
||||
|
||||
final localAsset = platformAsset.toLocalAsset();
|
||||
|
||||
@@ -9,8 +9,9 @@ import 'package:mocktail/mocktail.dart';
|
||||
import '../../infrastructure/repository.mock.dart';
|
||||
|
||||
const _kAccessToken = '#ThisIsAToken';
|
||||
const _kEnableBackup = false;
|
||||
const _kBackgroundBackup = false;
|
||||
const _kVersion = 2;
|
||||
final _kBackupFailedSince = DateTime.utc(2023);
|
||||
|
||||
void main() {
|
||||
late StoreService sut;
|
||||
@@ -23,13 +24,15 @@ void main() {
|
||||
// For generics, we need to provide fallback to each concrete type to avoid runtime errors
|
||||
registerFallbackValue(StoreKey.accessToken);
|
||||
registerFallbackValue(StoreKey.backupTriggerDelay);
|
||||
registerFallbackValue(StoreKey.enableBackup);
|
||||
registerFallbackValue(StoreKey.backgroundBackup);
|
||||
registerFallbackValue(StoreKey.backupFailedSince);
|
||||
|
||||
when(() => mockDriftStoreRepo.getAll()).thenAnswer(
|
||||
(_) async => [
|
||||
const StoreDto(StoreKey.accessToken, _kAccessToken),
|
||||
const StoreDto(StoreKey.enableBackup, _kEnableBackup),
|
||||
const StoreDto(StoreKey.backgroundBackup, _kBackgroundBackup),
|
||||
const StoreDto(StoreKey.version, _kVersion),
|
||||
StoreDto(StoreKey.backupFailedSince, _kBackupFailedSince),
|
||||
],
|
||||
);
|
||||
when(() => mockDriftStoreRepo.watchAll()).thenAnswer((_) => controller.stream);
|
||||
@@ -46,8 +49,9 @@ void main() {
|
||||
test('Populates the internal cache on init', () {
|
||||
verify(() => mockDriftStoreRepo.getAll()).called(1);
|
||||
expect(sut.tryGet(StoreKey.accessToken), _kAccessToken);
|
||||
expect(sut.tryGet(StoreKey.enableBackup), _kEnableBackup);
|
||||
expect(sut.tryGet(StoreKey.backgroundBackup), _kBackgroundBackup);
|
||||
expect(sut.tryGet(StoreKey.version), _kVersion);
|
||||
expect(sut.tryGet(StoreKey.backupFailedSince), _kBackupFailedSince);
|
||||
// Other keys should be null
|
||||
expect(sut.tryGet(StoreKey.currentUser), isNull);
|
||||
});
|
||||
@@ -147,8 +151,9 @@ void main() {
|
||||
await sut.clear();
|
||||
verify(() => mockDriftStoreRepo.deleteAll()).called(1);
|
||||
expect(sut.tryGet(StoreKey.accessToken), isNull);
|
||||
expect(sut.tryGet(StoreKey.enableBackup), isNull);
|
||||
expect(sut.tryGet(StoreKey.backgroundBackup), isNull);
|
||||
expect(sut.tryGet(StoreKey.version), isNull);
|
||||
expect(sut.tryGet(StoreKey.backupFailedSince), isNull);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -12,8 +12,9 @@ import 'package:immich_mobile/infrastructure/repositories/store.repository.dart'
|
||||
import '../../fixtures/user.stub.dart';
|
||||
|
||||
const _kTestAccessToken = "#TestToken";
|
||||
final _kTestBackupFailed = DateTime(2025, 2, 20, 11, 45);
|
||||
const _kTestVersion = 10;
|
||||
const _kTestBackupRequireCharging = false;
|
||||
const _kTestBackupRequireWifi = false;
|
||||
final _kTestUser = UserStub.admin;
|
||||
|
||||
Future<void> _populateStore(Drift db) async {
|
||||
@@ -21,8 +22,16 @@ Future<void> _populateStore(Drift db) async {
|
||||
batch.insert(
|
||||
db.storeEntity,
|
||||
StoreEntityCompanion(
|
||||
id: Value(StoreKey.backupRequireCharging.id),
|
||||
intValue: const Value(_kTestBackupRequireCharging ? 1 : 0),
|
||||
id: Value(StoreKey.backupRequireWifi.id),
|
||||
intValue: const Value(_kTestBackupRequireWifi ? 1 : 0),
|
||||
stringValue: const Value(null),
|
||||
),
|
||||
);
|
||||
batch.insert(
|
||||
db.storeEntity,
|
||||
StoreEntityCompanion(
|
||||
id: Value(StoreKey.backupFailedSince.id),
|
||||
intValue: Value(_kTestBackupFailed.millisecondsSinceEpoch),
|
||||
stringValue: const Value(null),
|
||||
),
|
||||
);
|
||||
@@ -75,12 +84,20 @@ void main() {
|
||||
expect(accessToken, _kTestAccessToken);
|
||||
});
|
||||
|
||||
test('converts datetime', () async {
|
||||
DateTime? backupFailedSince = await sut.tryGet(StoreKey.backupFailedSince);
|
||||
expect(backupFailedSince, isNull);
|
||||
await sut.upsert(StoreKey.backupFailedSince, _kTestBackupFailed);
|
||||
backupFailedSince = await sut.tryGet(StoreKey.backupFailedSince);
|
||||
expect(backupFailedSince, _kTestBackupFailed);
|
||||
});
|
||||
|
||||
test('converts bool', () async {
|
||||
bool? backupRequireCharging = await sut.tryGet(StoreKey.backupRequireCharging);
|
||||
expect(backupRequireCharging, isNull);
|
||||
await sut.upsert(StoreKey.backupRequireCharging, _kTestBackupRequireCharging);
|
||||
backupRequireCharging = await sut.tryGet(StoreKey.backupRequireCharging);
|
||||
expect(backupRequireCharging, _kTestBackupRequireCharging);
|
||||
bool? backupRequireWifi = await sut.tryGet(StoreKey.backupRequireWifi);
|
||||
expect(backupRequireWifi, isNull);
|
||||
await sut.upsert(StoreKey.backupRequireWifi, _kTestBackupRequireWifi);
|
||||
backupRequireWifi = await sut.tryGet(StoreKey.backupRequireWifi);
|
||||
expect(backupRequireWifi, _kTestBackupRequireWifi);
|
||||
});
|
||||
|
||||
test('converts user', () async {
|
||||
@@ -98,11 +115,11 @@ void main() {
|
||||
});
|
||||
|
||||
test('delete()', () async {
|
||||
bool? backupRequireCharging = await sut.tryGet(StoreKey.backupRequireCharging);
|
||||
expect(backupRequireCharging, isFalse);
|
||||
await sut.delete(StoreKey.backupRequireCharging);
|
||||
backupRequireCharging = await sut.tryGet(StoreKey.backupRequireCharging);
|
||||
expect(backupRequireCharging, isNull);
|
||||
bool? backupRequireWifi = await sut.tryGet(StoreKey.backupRequireWifi);
|
||||
expect(backupRequireWifi, isFalse);
|
||||
await sut.delete(StoreKey.backupRequireWifi);
|
||||
backupRequireWifi = await sut.tryGet(StoreKey.backupRequireWifi);
|
||||
expect(backupRequireWifi, isNull);
|
||||
});
|
||||
|
||||
test('deleteAll()', () async {
|
||||
@@ -147,12 +164,14 @@ void main() {
|
||||
emitsInOrder([
|
||||
[
|
||||
const StoreDto<Object>(StoreKey.version, _kTestVersion),
|
||||
const StoreDto<Object>(StoreKey.backupRequireCharging, _kTestBackupRequireCharging),
|
||||
StoreDto<Object>(StoreKey.backupFailedSince, _kTestBackupFailed),
|
||||
const StoreDto<Object>(StoreKey.backupRequireWifi, _kTestBackupRequireWifi),
|
||||
const StoreDto<Object>(StoreKey.accessToken, _kTestAccessToken),
|
||||
],
|
||||
[
|
||||
const StoreDto<Object>(StoreKey.version, _kTestVersion + 10),
|
||||
const StoreDto<Object>(StoreKey.backupRequireCharging, _kTestBackupRequireCharging),
|
||||
StoreDto<Object>(StoreKey.backupFailedSince, _kTestBackupFailed),
|
||||
const StoreDto<Object>(StoreKey.backupRequireWifi, _kTestBackupRequireWifi),
|
||||
const StoreDto<Object>(StoreKey.accessToken, _kTestAccessToken),
|
||||
],
|
||||
]),
|
||||
|
||||
@@ -3,7 +3,6 @@ import 'package:flutter_test/flutter_test.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/domain/services/timeline.service.dart';
|
||||
import 'package:immich_mobile/utils/action_button.utils.dart';
|
||||
|
||||
LocalAsset createLocalAsset({
|
||||
@@ -38,7 +37,6 @@ RemoteAsset createRemoteAsset({
|
||||
DateTime? updatedAt,
|
||||
DateTime? uploadedAt,
|
||||
bool isFavorite = false,
|
||||
DateTime? deletedAt,
|
||||
}) {
|
||||
return RemoteAsset(
|
||||
id: 'remote-id',
|
||||
@@ -52,7 +50,6 @@ RemoteAsset createRemoteAsset({
|
||||
uploadedAt: uploadedAt ?? DateTime.now(),
|
||||
isFavorite: isFavorite,
|
||||
isEdited: false,
|
||||
deletedAt: deletedAt,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -461,62 +458,6 @@ void main() {
|
||||
|
||||
expect(ActionButtonType.trash.shouldShow(context), isFalse);
|
||||
});
|
||||
|
||||
test('should not show when asset is already trashed', () {
|
||||
final remoteAsset = createRemoteAsset(deletedAt: DateTime(2024));
|
||||
final context = ActionButtonContext(
|
||||
asset: remoteAsset,
|
||||
isOwner: true,
|
||||
isArchived: false,
|
||||
isTrashEnabled: true,
|
||||
isInLockedView: false,
|
||||
currentAlbum: null,
|
||||
advancedTroubleshooting: false,
|
||||
isStacked: false,
|
||||
source: ActionSource.viewer,
|
||||
timelineOrigin: TimelineOrigin.trash,
|
||||
);
|
||||
|
||||
expect(ActionButtonType.trash.shouldShow(context), isFalse);
|
||||
});
|
||||
});
|
||||
|
||||
group('restoreTrash button', () {
|
||||
test('should show when owner, not locked, has remote, and is in trash timeline', () {
|
||||
final remoteAsset = createRemoteAsset();
|
||||
final context = ActionButtonContext(
|
||||
asset: remoteAsset,
|
||||
isOwner: true,
|
||||
isArchived: false,
|
||||
isTrashEnabled: true,
|
||||
isInLockedView: false,
|
||||
currentAlbum: null,
|
||||
advancedTroubleshooting: false,
|
||||
isStacked: false,
|
||||
source: ActionSource.timeline,
|
||||
timelineOrigin: TimelineOrigin.trash,
|
||||
);
|
||||
|
||||
expect(ActionButtonType.restoreTrash.shouldShow(context), isTrue);
|
||||
});
|
||||
|
||||
test('should not show when not in trash timeline', () {
|
||||
final remoteAsset = createRemoteAsset();
|
||||
final context = ActionButtonContext(
|
||||
asset: remoteAsset,
|
||||
isOwner: true,
|
||||
isArchived: false,
|
||||
isTrashEnabled: false,
|
||||
isInLockedView: false,
|
||||
currentAlbum: null,
|
||||
advancedTroubleshooting: false,
|
||||
isStacked: false,
|
||||
source: ActionSource.timeline,
|
||||
timelineOrigin: TimelineOrigin.main,
|
||||
);
|
||||
|
||||
expect(ActionButtonType.restoreTrash.shouldShow(context), isFalse);
|
||||
});
|
||||
});
|
||||
|
||||
group('deletePermanent button', () {
|
||||
@@ -553,24 +494,6 @@ void main() {
|
||||
|
||||
expect(ActionButtonType.deletePermanent.shouldShow(context), isFalse);
|
||||
});
|
||||
|
||||
test('should show when asset is trashed even with trash enabled', () {
|
||||
final remoteAsset = createRemoteAsset(deletedAt: DateTime(2024));
|
||||
final context = ActionButtonContext(
|
||||
asset: remoteAsset,
|
||||
isOwner: true,
|
||||
isArchived: false,
|
||||
isTrashEnabled: true,
|
||||
isInLockedView: false,
|
||||
currentAlbum: null,
|
||||
advancedTroubleshooting: false,
|
||||
isStacked: false,
|
||||
source: ActionSource.viewer,
|
||||
timelineOrigin: TimelineOrigin.trash,
|
||||
);
|
||||
|
||||
expect(ActionButtonType.deletePermanent.shouldShow(context), isTrue);
|
||||
});
|
||||
});
|
||||
|
||||
group('delete button', () {
|
||||
|
||||
@@ -171,8 +171,8 @@ export class JobRepository {
|
||||
options: this.getJobOptions(item) || undefined,
|
||||
} as JobItem & { data: any; options: JobsOptions | undefined };
|
||||
|
||||
if (job.options?.jobId || job.options?.deduplication) {
|
||||
// need to use add() instead of addBulk() for jobId/deduplication to take effect
|
||||
if (job.options?.jobId) {
|
||||
// need to use add() instead of addBulk() for jobId deduplication
|
||||
promises.push(this.getQueue(queueName).add(item.name, item.data, job.options));
|
||||
} else {
|
||||
itemsByQueue[queueName] = itemsByQueue[queueName] || [];
|
||||
@@ -230,13 +230,10 @@ export class JobRepository {
|
||||
return { priority: 1 };
|
||||
}
|
||||
case JobName.FacialRecognitionQueueAll: {
|
||||
return { deduplication: { id: JobName.FacialRecognitionQueueAll } };
|
||||
return { jobId: JobName.FacialRecognitionQueueAll };
|
||||
}
|
||||
case JobName.VersionCheck: {
|
||||
return { deduplication: { id: JobName.VersionCheck } };
|
||||
}
|
||||
case JobName.DatabaseBackup: {
|
||||
return { deduplication: { id: JobName.DatabaseBackup } };
|
||||
return { jobId: JobName.VersionCheck };
|
||||
}
|
||||
default: {
|
||||
return null;
|
||||
|
||||
@@ -132,7 +132,7 @@ export class MachineLearningRepository {
|
||||
private async check(url: string) {
|
||||
let healthy = false;
|
||||
try {
|
||||
const response = await fetch(new URL('ping', url), {
|
||||
const response = await fetch(new URL('/ping', url), {
|
||||
signal: AbortSignal.timeout(this.config.availabilityChecks.timeout),
|
||||
});
|
||||
if (response.ok) {
|
||||
@@ -170,7 +170,7 @@ export class MachineLearningRepository {
|
||||
...this.config.urls.filter((url) => !this.isHealthy(url)),
|
||||
]) {
|
||||
try {
|
||||
const response = await fetch(new URL('predict', url), { method: 'POST', body: formData });
|
||||
const response = await fetch(new URL('/predict', url), { method: 'POST', body: formData });
|
||||
if (response.ok) {
|
||||
this.setHealthy(url, true);
|
||||
return response.json();
|
||||
|
||||
@@ -4,18 +4,15 @@
|
||||
import OnEvents from '$lib/components/OnEvents.svelte';
|
||||
import EmptyPlaceholder from '$lib/components/shared-components/EmptyPlaceholder.svelte';
|
||||
import SingleGridRow from '$lib/components/shared-components/SingleGridRow.svelte';
|
||||
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
|
||||
import { Route } from '$lib/route';
|
||||
import { getAssetMediaUrl, getPeopleThumbnailUrl } from '$lib/utils';
|
||||
import { getAssetInfo, AssetMediaSize, type SearchExploreResponseDto } from '@immich/sdk';
|
||||
import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||
import { AssetMediaSize, type SearchExploreResponseDto } from '@immich/sdk';
|
||||
import { Icon } from '@immich/ui';
|
||||
import { mdiHeart } from '@mdi/js';
|
||||
import { t } from 'svelte-i18n';
|
||||
import type { PageData } from './$types';
|
||||
import { toTimelineAsset } from '$lib/utils/timeline-util';
|
||||
import { getAltText } from '$lib/utils/thumbnail-util';
|
||||
import Portal from '$lib/elements/Portal.svelte';
|
||||
|
||||
interface Props {
|
||||
data: PageData;
|
||||
@@ -43,15 +40,6 @@
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const onViewAsset = async (id: string) => {
|
||||
const asset = await getAssetInfo({ ...authManager.params, id });
|
||||
assetViewerManager.setAsset(asset);
|
||||
};
|
||||
|
||||
const assetCursor = $derived({
|
||||
current: assetViewerManager.asset!,
|
||||
});
|
||||
</script>
|
||||
|
||||
<OnEvents {onPersonThumbnailReady} />
|
||||
@@ -134,20 +122,15 @@
|
||||
draggable="false">{$t('view_all')}</a
|
||||
>
|
||||
</div>
|
||||
<div class="flex h-24 max-w-fit flex-wrap gap-x-1 overflow-hidden md:h-42">
|
||||
<div class="flex h-24 flex-wrap gap-x-1 overflow-hidden md:h-42">
|
||||
{#each recents as item (item.data.id)}
|
||||
<button
|
||||
type="button"
|
||||
class="relative h-full flex-auto"
|
||||
onclick={() => onViewAsset(item.data.id)}
|
||||
draggable="false"
|
||||
>
|
||||
<a class="relative h-full flex-auto" href={Route.viewAsset({ id: item.data.id })} draggable="false">
|
||||
<img
|
||||
src={getAssetMediaUrl({ id: item.data.id, size: AssetMediaSize.Thumbnail })}
|
||||
alt={$getAltText(toTimelineAsset(item.data))}
|
||||
class="size-full min-w-max rounded-xl object-cover"
|
||||
/>
|
||||
</button>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
@@ -157,15 +140,3 @@
|
||||
<EmptyPlaceholder text={$t('no_explore_results_message')} class="mx-auto mt-10" />
|
||||
{/if}
|
||||
</UserPageLayout>
|
||||
|
||||
{#if assetViewerManager.isViewing}
|
||||
{#await import('$lib/components/asset-viewer/AssetViewer.svelte') then { default: AssetViewer }}
|
||||
<Portal target="body">
|
||||
<AssetViewer
|
||||
cursor={assetCursor}
|
||||
showNavigation={false}
|
||||
onClose={() => assetViewerManager.showAssetViewer(false)}
|
||||
/>
|
||||
</Portal>
|
||||
{/await}
|
||||
{/if}
|
||||
|
||||
Reference in New Issue
Block a user