mirror of
https://github.com/immich-app/immich.git
synced 2025-07-09 03:04:16 -04:00
feat: full local assets / album sync
This commit is contained in:
parent
a09710ec7b
commit
c91a2878dc
@ -2,6 +2,7 @@ plugins {
|
|||||||
id "com.android.application"
|
id "com.android.application"
|
||||||
id "kotlin-android"
|
id "kotlin-android"
|
||||||
id "dev.flutter.flutter-gradle-plugin"
|
id "dev.flutter.flutter-gradle-plugin"
|
||||||
|
id 'com.google.devtools.ksp'
|
||||||
}
|
}
|
||||||
|
|
||||||
def localProperties = new Properties()
|
def localProperties = new Properties()
|
||||||
@ -25,15 +26,14 @@ if (flutterVersionName == null) {
|
|||||||
android {
|
android {
|
||||||
namespace "com.alextran.immich"
|
namespace "com.alextran.immich"
|
||||||
compileSdkVersion 34
|
compileSdkVersion 34
|
||||||
ndkVersion flutter.ndkVersion
|
|
||||||
|
|
||||||
compileOptions {
|
compileOptions {
|
||||||
sourceCompatibility JavaVersion.VERSION_1_8
|
sourceCompatibility JavaVersion.VERSION_17
|
||||||
targetCompatibility JavaVersion.VERSION_1_8
|
targetCompatibility JavaVersion.VERSION_17
|
||||||
}
|
}
|
||||||
|
|
||||||
kotlinOptions {
|
kotlinOptions {
|
||||||
jvmTarget = '1.8'
|
jvmTarget = '17'
|
||||||
}
|
}
|
||||||
|
|
||||||
sourceSets {
|
sourceSets {
|
||||||
@ -64,4 +64,11 @@ flutter {
|
|||||||
source '../..'
|
source '../..'
|
||||||
}
|
}
|
||||||
|
|
||||||
dependencies {}
|
dependencies {
|
||||||
|
def glide_version = '4.16.0'
|
||||||
|
def kotlin_version = '2.0.20'
|
||||||
|
|
||||||
|
implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version"
|
||||||
|
api "com.github.bumptech.glide:glide:$glide_version"
|
||||||
|
ksp "com.github.bumptech.glide:ksp:$glide_version"
|
||||||
|
}
|
@ -2,6 +2,11 @@
|
|||||||
|
|
||||||
<uses-permission android:name="android.permission.INTERNET" />
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
<!-- photo_manager -->
|
<!-- photo_manager -->
|
||||||
|
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"
|
||||||
|
android:maxSdkVersion="32" />
|
||||||
|
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"
|
||||||
|
android:maxSdkVersion="32" />
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_MEDIA_LOCATION" />
|
||||||
<uses-permission android:name="android.permission.READ_MEDIA_VISUAL_USER_SELECTED" />
|
<uses-permission android:name="android.permission.READ_MEDIA_VISUAL_USER_SELECTED" />
|
||||||
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
|
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
|
||||||
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO" />
|
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO" />
|
||||||
|
@ -0,0 +1,7 @@
|
|||||||
|
package com.alextran.immich
|
||||||
|
|
||||||
|
import com.bumptech.glide.annotation.GlideModule
|
||||||
|
import com.bumptech.glide.module.AppGlideModule
|
||||||
|
|
||||||
|
@GlideModule
|
||||||
|
class ImAppGlideModule : AppGlideModule()
|
@ -1,14 +1,14 @@
|
|||||||
package com.alextran.immich
|
package com.alextran.immich
|
||||||
|
|
||||||
import ImmichHostService
|
import ImHostService
|
||||||
import com.alextran.immich.platform.ImmichHostServiceImpl
|
import com.alextran.immich.platform.ImHostServiceImpl
|
||||||
import io.flutter.embedding.android.FlutterActivity
|
import io.flutter.embedding.android.FlutterActivity
|
||||||
import io.flutter.embedding.engine.FlutterEngine
|
import io.flutter.embedding.engine.FlutterEngine
|
||||||
|
|
||||||
class MainActivity: FlutterActivity() {
|
class MainActivity: FlutterActivity() {
|
||||||
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
|
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
|
||||||
// Register piegon handler
|
// Register pigeon handler
|
||||||
ImmichHostService.setUp(flutterEngine.dartExecutor.binaryMessenger, ImmichHostServiceImpl())
|
ImHostService.setUp(flutterEngine.dartExecutor.binaryMessenger, ImHostServiceImpl())
|
||||||
|
|
||||||
super.configureFlutterEngine(flutterEngine)
|
super.configureFlutterEngine(flutterEngine)
|
||||||
}
|
}
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
package com.alextran.immich.platform
|
package com.alextran.immich.platform
|
||||||
|
|
||||||
import ImmichHostService
|
import ImHostService
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import kotlinx.coroutines.DelicateCoroutinesApi
|
import kotlinx.coroutines.DelicateCoroutinesApi
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
@ -9,39 +9,35 @@ import kotlinx.coroutines.launch
|
|||||||
import java.io.FileInputStream
|
import java.io.FileInputStream
|
||||||
import java.security.MessageDigest
|
import java.security.MessageDigest
|
||||||
|
|
||||||
class ImmichHostServiceImpl: ImmichHostService {
|
class ImHostServiceImpl(): ImHostService {
|
||||||
|
|
||||||
@OptIn(DelicateCoroutinesApi::class)
|
@OptIn(DelicateCoroutinesApi::class)
|
||||||
override fun digestFiles(paths: List<String>, callback: (Result<List<ByteArray?>?>) -> Unit) {
|
override fun digestFiles(paths: List<String>, callback: (Result<List<ByteArray?>>) -> Unit) {
|
||||||
GlobalScope.launch(Dispatchers.IO) {
|
GlobalScope.launch(Dispatchers.IO) {
|
||||||
val buf = ByteArray(Companion.BUFFER_SIZE)
|
|
||||||
val digest: MessageDigest = MessageDigest.getInstance("SHA-1")
|
val digest: MessageDigest = MessageDigest.getInstance("SHA-1")
|
||||||
val hashes = arrayOfNulls<ByteArray>(paths.size)
|
val buffer = ByteArray(BUFFER_SIZE)
|
||||||
for (i in paths.indices) {
|
|
||||||
val path = paths[i]
|
val hashes = paths.map { path ->
|
||||||
var len = 0
|
|
||||||
try {
|
try {
|
||||||
val file = FileInputStream(path)
|
FileInputStream(path).use { inputStream ->
|
||||||
file.use { assetFile ->
|
digest.reset()
|
||||||
while (true) {
|
var bytesRead: Int
|
||||||
len = assetFile.read(buf)
|
while (inputStream.read(buffer).also { bytesRead = it } != -1) {
|
||||||
if (len != Companion.BUFFER_SIZE) break
|
digest.update(buffer, 0, bytesRead)
|
||||||
digest.update(buf)
|
|
||||||
}
|
}
|
||||||
|
digest.digest()
|
||||||
}
|
}
|
||||||
digest.update(buf, 0, len)
|
|
||||||
hashes[i] = digest.digest()
|
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
// skip this file
|
Log.e(TAG, "Failed to hash file $path", e)
|
||||||
Log.w(TAG, "Failed to hash file ${paths[i]}: $e")
|
null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
callback(Result.success(hashes.asList()))
|
callback(Result.success(hashes))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private const val BUFFER_SIZE = 2 * 1024 * 1024;
|
private const val BUFFER_SIZE = 8192 // 8KB buffer
|
||||||
private const val TAG = "ImmichHostServiceImpl"
|
private const val TAG = "ImHostServiceImpl"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,3 +1,5 @@
|
|||||||
org.gradle.jvmargs=-Xmx4G
|
org.gradle.jvmargs=-Xmx4G
|
||||||
android.useAndroidX=true
|
android.useAndroidX=true
|
||||||
android.enableJetifier=true
|
android.enableJetifier=true
|
||||||
|
android.nonTransitiveRClass=false
|
||||||
|
android.nonFinalResIds=false
|
||||||
|
@ -2,4 +2,4 @@ distributionBase=GRADLE_USER_HOME
|
|||||||
distributionPath=wrapper/dists
|
distributionPath=wrapper/dists
|
||||||
zipStoreBase=GRADLE_USER_HOME
|
zipStoreBase=GRADLE_USER_HOME
|
||||||
zipStorePath=wrapper/dists
|
zipStorePath=wrapper/dists
|
||||||
distributionUrl=https\://services.gradle.org/distributions/gradle-7.6.3-all.zip
|
distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-all.zip
|
||||||
|
@ -19,8 +19,9 @@ pluginManagement {
|
|||||||
|
|
||||||
plugins {
|
plugins {
|
||||||
id "dev.flutter.flutter-plugin-loader" version "1.0.0"
|
id "dev.flutter.flutter-plugin-loader" version "1.0.0"
|
||||||
id "com.android.application" version "7.3.0" apply false
|
id "com.android.application" version '8.7.1' apply false
|
||||||
id "org.jetbrains.kotlin.android" version "1.7.10" apply false
|
id "org.jetbrains.kotlin.android" version "2.0.20" apply false
|
||||||
|
id 'com.google.devtools.ksp' version '2.0.20-1.0.24' apply false
|
||||||
}
|
}
|
||||||
|
|
||||||
include ":app"
|
include ":app"
|
||||||
|
@ -210,7 +210,7 @@
|
|||||||
isa = PBXProject;
|
isa = PBXProject;
|
||||||
attributes = {
|
attributes = {
|
||||||
BuildIndependentTargetsInParallel = YES;
|
BuildIndependentTargetsInParallel = YES;
|
||||||
LastUpgradeCheck = 1540;
|
LastUpgradeCheck = 1510;
|
||||||
ORGANIZATIONNAME = "";
|
ORGANIZATIONNAME = "";
|
||||||
TargetAttributes = {
|
TargetAttributes = {
|
||||||
331C8080294A63A400263BE5 = {
|
331C8080294A63A400263BE5 = {
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<Scheme
|
<Scheme
|
||||||
LastUpgradeVersion = "1540"
|
LastUpgradeVersion = "1510"
|
||||||
version = "1.3">
|
version = "1.3">
|
||||||
<BuildAction
|
<BuildAction
|
||||||
parallelizeBuildables = "YES"
|
parallelizeBuildables = "YES"
|
||||||
|
@ -11,7 +11,7 @@ import Flutter
|
|||||||
|
|
||||||
// Register piegon handler
|
// Register piegon handler
|
||||||
let controller: FlutterViewController = window?.rootViewController as! FlutterViewController
|
let controller: FlutterViewController = window?.rootViewController as! FlutterViewController
|
||||||
ImmichHostServiceSetup.setUp(binaryMessenger: controller.binaryMessenger, api: ImmichHostServiceImpl())
|
ImHostServiceSetup.setUp(binaryMessenger: controller.binaryMessenger, api: ImHostServiceImpl())
|
||||||
|
|
||||||
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
|
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
|
||||||
}
|
}
|
||||||
|
@ -1,38 +1,37 @@
|
|||||||
import CryptoKit
|
import CryptoKit
|
||||||
|
|
||||||
class ImmichHostServiceImpl: ImmichHostService {
|
class ImHostServiceImpl: ImHostService {
|
||||||
func digestFiles(paths: [String], completion: @escaping (Result<[FlutterStandardTypedData?]?, Error>) -> Void) {
|
func digestFiles(paths: [String], completion: @escaping (Result<[FlutterStandardTypedData?], Error>) -> Void) {
|
||||||
let bufsize = 2 * 1024 * 1024
|
let bufsize = 2 * 1024 * 1024
|
||||||
// Private error to throw if file cannot be read
|
|
||||||
enum DigestError: String, LocalizedError {
|
|
||||||
case NoFileHandle = "Cannot Open File Handle"
|
|
||||||
|
|
||||||
public var errorDescription: String? { self.rawValue }
|
|
||||||
}
|
|
||||||
|
|
||||||
// Compute hash in background thread
|
// Compute hash in background thread
|
||||||
DispatchQueue.global(qos: .background).async {
|
DispatchQueue.global(qos: .background).async {
|
||||||
var hashes: [FlutterStandardTypedData?] = Array(repeating: nil, count: paths.count)
|
let hashes = paths.map { path -> FlutterStandardTypedData? in
|
||||||
for i in (0 ..< paths.count) {
|
|
||||||
do {
|
do {
|
||||||
guard let file = FileHandle(forReadingAtPath: paths[i]) else { throw DigestError.NoFileHandle }
|
guard let file = FileHandle(forReadingAtPath: path) else {
|
||||||
var hasher = Insecure.SHA1.init();
|
throw NSError(domain: "ImHostServiceImpl", code: 1, userInfo: [NSLocalizedDescriptionKey: "Cannot Open File Handle"])
|
||||||
|
}
|
||||||
|
defer { file.closeFile() }
|
||||||
|
|
||||||
|
var hasher = Insecure.SHA1()
|
||||||
while autoreleasepool(invoking: {
|
while autoreleasepool(invoking: {
|
||||||
let chunk = file.readData(ofLength: bufsize)
|
let chunk = file.readData(ofLength: bufsize)
|
||||||
guard !chunk.isEmpty else { return false } // EOF
|
guard !chunk.isEmpty else { return false } // EOF
|
||||||
hasher.update(data: chunk)
|
hasher.update(data: chunk)
|
||||||
return true // continue
|
return true // continue
|
||||||
}) { }
|
}) { }
|
||||||
|
|
||||||
let digest = hasher.finalize()
|
let digest = hasher.finalize()
|
||||||
hashes[i] = FlutterStandardTypedData(bytes: Data(Array(digest.makeIterator())))
|
return FlutterStandardTypedData(bytes: Data(digest))
|
||||||
} catch {
|
} catch {
|
||||||
print("Cannot calculate the digest of the file \(paths[i]) due to \(error.localizedDescription)")
|
print("Cannot calculate the digest of the file \(path) due to \(error.localizedDescription)")
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Return result in main thread
|
// Return result in main thread
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
completion(.success(Array(hashes)))
|
completion(.success(hashes))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,11 +1,21 @@
|
|||||||
import 'package:drift/drift.dart';
|
import 'package:drift/drift.dart';
|
||||||
|
import 'package:immich_mobile/domain/entities/asset.entity.dart';
|
||||||
|
|
||||||
class LocalAlbum extends Table {
|
class Album extends Table {
|
||||||
const LocalAlbum();
|
const Album();
|
||||||
|
|
||||||
IntColumn get id => integer().autoIncrement()();
|
IntColumn get id => integer().autoIncrement()();
|
||||||
TextColumn get localId => text().unique()();
|
|
||||||
TextColumn get name => text()();
|
TextColumn get name => text()();
|
||||||
DateTimeColumn get modifiedTime =>
|
DateTimeColumn get modifiedTime =>
|
||||||
dateTime().withDefault(currentDateAndTime)();
|
dateTime().withDefault(currentDateAndTime)();
|
||||||
|
|
||||||
|
IntColumn get thumbnailAssetId => integer()
|
||||||
|
.references(Asset, #id, onDelete: KeyAction.setNull)
|
||||||
|
.nullable()();
|
||||||
|
|
||||||
|
// Local only
|
||||||
|
TextColumn get localId => text().nullable().unique()();
|
||||||
|
|
||||||
|
// Remote only
|
||||||
|
TextColumn get remoteId => text().nullable().unique()();
|
||||||
}
|
}
|
||||||
|
16
mobile-v2/lib/domain/entities/album_asset.entity.dart
Normal file
16
mobile-v2/lib/domain/entities/album_asset.entity.dart
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
import 'package:drift/drift.dart';
|
||||||
|
import 'package:immich_mobile/domain/entities/album.entity.dart';
|
||||||
|
import 'package:immich_mobile/domain/entities/asset.entity.dart';
|
||||||
|
|
||||||
|
class AlbumToAsset extends Table {
|
||||||
|
const AlbumToAsset();
|
||||||
|
|
||||||
|
IntColumn get assetId =>
|
||||||
|
integer().references(Asset, #id, onDelete: KeyAction.cascade)();
|
||||||
|
|
||||||
|
IntColumn get albumId =>
|
||||||
|
integer().references(Album, #id, onDelete: KeyAction.cascade)();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Set<Column> get primaryKey => {assetId, albumId};
|
||||||
|
}
|
14
mobile-v2/lib/domain/entities/album_etag.entity.dart
Normal file
14
mobile-v2/lib/domain/entities/album_etag.entity.dart
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
import 'package:drift/drift.dart';
|
||||||
|
import 'package:immich_mobile/domain/entities/album.entity.dart';
|
||||||
|
|
||||||
|
class AlbumETag extends Table {
|
||||||
|
const AlbumETag();
|
||||||
|
|
||||||
|
IntColumn get id => integer().autoIncrement()();
|
||||||
|
|
||||||
|
IntColumn get albumId =>
|
||||||
|
integer().references(Album, #id, onDelete: KeyAction.cascade).unique()();
|
||||||
|
DateTimeColumn get modifiedTime =>
|
||||||
|
dateTime().withDefault(currentDateAndTime)();
|
||||||
|
IntColumn get assetCount => integer().withDefault(const Constant(0))();
|
||||||
|
}
|
14
mobile-v2/lib/domain/entities/device_asset_hash.entity.dart
Normal file
14
mobile-v2/lib/domain/entities/device_asset_hash.entity.dart
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
import 'package:drift/drift.dart';
|
||||||
|
|
||||||
|
@TableIndex(name: 'deviceassethash_localId', columns: {#localId})
|
||||||
|
@TableIndex(name: 'deviceassethash_hash', columns: {#hash})
|
||||||
|
class DeviceAssetToHash extends Table {
|
||||||
|
const DeviceAssetToHash();
|
||||||
|
|
||||||
|
IntColumn get id => integer().autoIncrement()();
|
||||||
|
|
||||||
|
TextColumn get localId => text().unique()();
|
||||||
|
TextColumn get hash => text()();
|
||||||
|
DateTimeColumn get modifiedTime =>
|
||||||
|
dateTime().withDefault(currentDateAndTime)();
|
||||||
|
}
|
14
mobile-v2/lib/domain/interfaces/album.interface.dart
Normal file
14
mobile-v2/lib/domain/interfaces/album.interface.dart
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:immich_mobile/domain/models/album.model.dart';
|
||||||
|
|
||||||
|
abstract interface class IAlbumRepository {
|
||||||
|
/// Inserts a new album into the DB or updates if existing and returns the updated data
|
||||||
|
FutureOr<Album?> upsert(Album album);
|
||||||
|
|
||||||
|
/// Fetch all albums
|
||||||
|
FutureOr<List<Album>> getAll({bool localOnly, bool remoteOnly});
|
||||||
|
|
||||||
|
/// Removes album with the given [id]
|
||||||
|
FutureOr<void> deleteId(int id);
|
||||||
|
}
|
17
mobile-v2/lib/domain/interfaces/album_asset.interface.dart
Normal file
17
mobile-v2/lib/domain/interfaces/album_asset.interface.dart
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:immich_mobile/domain/models/asset.model.dart';
|
||||||
|
|
||||||
|
abstract interface class IAlbumToAssetRepository {
|
||||||
|
/// Link a list of assetIds to the given albumId
|
||||||
|
FutureOr<bool> addAssetIds(int albumId, Iterable<int> assetIds);
|
||||||
|
|
||||||
|
/// Returns assets that are only part of the given album and nothing else
|
||||||
|
FutureOr<List<int>> getAssetIdsOnlyInAlbum(int albumId);
|
||||||
|
|
||||||
|
/// Returns the assets for the given [albumId]
|
||||||
|
FutureOr<List<Asset>> getAssetsForAlbum(int albumId);
|
||||||
|
|
||||||
|
/// Removes album with the given [albumId]
|
||||||
|
FutureOr<void> deleteAlbumId(int albumId);
|
||||||
|
}
|
11
mobile-v2/lib/domain/interfaces/album_etag.interface.dart
Normal file
11
mobile-v2/lib/domain/interfaces/album_etag.interface.dart
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:immich_mobile/domain/models/album_etag.model.dart';
|
||||||
|
|
||||||
|
abstract interface class IAlbumETagRepository {
|
||||||
|
/// Inserts or updates the album etag for the given [albumId]
|
||||||
|
FutureOr<bool> upsert(AlbumETag albumETag);
|
||||||
|
|
||||||
|
/// Fetches the album etag for the given [albumId]
|
||||||
|
FutureOr<AlbumETag?> get(int albumId);
|
||||||
|
}
|
@ -7,16 +7,19 @@ abstract interface class IAssetRepository {
|
|||||||
FutureOr<bool> upsertAll(Iterable<Asset> assets);
|
FutureOr<bool> upsertAll(Iterable<Asset> assets);
|
||||||
|
|
||||||
/// Removes assets with the [localIds]
|
/// Removes assets with the [localIds]
|
||||||
FutureOr<List<Asset>> getForLocalIds(List<String> localIds);
|
FutureOr<List<Asset>> getForLocalIds(Iterable<String> localIds);
|
||||||
|
|
||||||
/// Removes assets with the [remoteIds]
|
/// Removes assets with the [remoteIds]
|
||||||
FutureOr<List<Asset>> getForRemoteIds(List<String> remoteIds);
|
FutureOr<List<Asset>> getForRemoteIds(Iterable<String> remoteIds);
|
||||||
|
|
||||||
|
/// Get assets with the [hashes]
|
||||||
|
FutureOr<List<Asset>> getForHashes(Iterable<String> hashes);
|
||||||
|
|
||||||
/// Fetch assets from the [offset] with the [limit]
|
/// Fetch assets from the [offset] with the [limit]
|
||||||
FutureOr<List<Asset>> getAll({int? offset, int? limit});
|
FutureOr<List<Asset>> getAll({int? offset, int? limit});
|
||||||
|
|
||||||
/// Removes assets with the given [ids]
|
/// Removes assets with the given [ids]
|
||||||
FutureOr<void> deleteIds(List<int> ids);
|
FutureOr<void> deleteIds(Iterable<int> ids);
|
||||||
|
|
||||||
/// Removes all assets
|
/// Removes all assets
|
||||||
FutureOr<bool> deleteAll();
|
FutureOr<bool> deleteAll();
|
||||||
|
4
mobile-v2/lib/domain/interfaces/database.interface.dart
Normal file
4
mobile-v2/lib/domain/interfaces/database.interface.dart
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
abstract interface class IDatabaseRepository {
|
||||||
|
/// Runs the [action] in a transaction
|
||||||
|
Future<T> txn<T>(Future<T> Function() action);
|
||||||
|
}
|
22
mobile-v2/lib/domain/interfaces/device_album.interface.dart
Normal file
22
mobile-v2/lib/domain/interfaces/device_album.interface.dart
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:immich_mobile/domain/models/album.model.dart';
|
||||||
|
import 'package:immich_mobile/domain/models/asset.model.dart';
|
||||||
|
|
||||||
|
abstract interface class IDeviceAlbumRepository {
|
||||||
|
/// Fetches all [Album] from device
|
||||||
|
FutureOr<List<Album>> getAll();
|
||||||
|
|
||||||
|
/// Returns the number of asset in the album
|
||||||
|
FutureOr<int> getAssetCount(String albumId);
|
||||||
|
|
||||||
|
/// Fetches assets belong to the albumId
|
||||||
|
FutureOr<List<Asset>> getAssetsForAlbum(
|
||||||
|
String albumId, {
|
||||||
|
int start = 0,
|
||||||
|
int end = 0x7fffffffffffffff,
|
||||||
|
DateTime? modifiedFrom,
|
||||||
|
DateTime? modifiedUntil,
|
||||||
|
bool orderByModificationDate = false,
|
||||||
|
});
|
||||||
|
}
|
24
mobile-v2/lib/domain/interfaces/device_asset.interface.dart
Normal file
24
mobile-v2/lib/domain/interfaces/device_asset.interface.dart
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'dart:io';
|
||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
import 'package:immich_mobile/domain/models/asset.model.dart';
|
||||||
|
import 'package:immich_mobile/domain/models/device_asset_download.model.dart';
|
||||||
|
import 'package:immich_mobile/utils/constants/globals.dart';
|
||||||
|
|
||||||
|
abstract interface class IDeviceAssetRepository<T> {
|
||||||
|
/// Fetches the [File] for the given [assetId]
|
||||||
|
FutureOr<File?> getOriginalFile(String assetId);
|
||||||
|
|
||||||
|
/// Fetches the thumbnail for the given [assetId]
|
||||||
|
FutureOr<Uint8List?> getThumbnail(
|
||||||
|
String assetId, {
|
||||||
|
int width = kGridThumbnailSize,
|
||||||
|
int height = kGridThumbnailSize,
|
||||||
|
int quality = kGridThumbnailQuality,
|
||||||
|
DeviceAssetDownloadHandler? downloadHandler,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Converts the given [entity] to an [Asset]
|
||||||
|
Future<Asset> toAsset(T entity);
|
||||||
|
}
|
@ -0,0 +1,14 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:immich_mobile/domain/models/device_asset_hash.model.dart';
|
||||||
|
|
||||||
|
abstract interface class IDeviceAssetToHashRepository {
|
||||||
|
/// Add a new device asset to hash entry
|
||||||
|
FutureOr<bool> upsertAll(Iterable<DeviceAssetToHash> assetHash);
|
||||||
|
|
||||||
|
// Gets the asset with the local ID from the device
|
||||||
|
FutureOr<List<DeviceAssetToHash>> getForIds(Iterable<String> localIds);
|
||||||
|
|
||||||
|
/// Removes assets with the given [ids]
|
||||||
|
FutureOr<void> deleteIds(Iterable<int> ids);
|
||||||
|
}
|
@ -7,7 +7,7 @@ abstract interface class ILogRepository {
|
|||||||
FutureOr<bool> create(LogMessage log);
|
FutureOr<bool> create(LogMessage log);
|
||||||
|
|
||||||
/// Bulk insert logs into DB
|
/// Bulk insert logs into DB
|
||||||
FutureOr<bool> createAll(List<LogMessage> log);
|
FutureOr<bool> createAll(Iterable<LogMessage> log);
|
||||||
|
|
||||||
/// Fetches all logs
|
/// Fetches all logs
|
||||||
FutureOr<List<LogMessage>> getAll();
|
FutureOr<List<LogMessage>> getAll();
|
||||||
|
@ -1,31 +1,82 @@
|
|||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:immich_mobile/utils/collection_util.dart';
|
||||||
|
|
||||||
@immutable
|
@immutable
|
||||||
class LocalAlbum {
|
class Album {
|
||||||
final int id;
|
final int? id;
|
||||||
final String localId;
|
final String? localId;
|
||||||
|
final String? remoteId;
|
||||||
final String name;
|
final String name;
|
||||||
final DateTime modifiedTime;
|
final DateTime modifiedTime;
|
||||||
|
final int? thumbnailAssetId;
|
||||||
|
|
||||||
const LocalAlbum({
|
bool get isRemote => remoteId != null;
|
||||||
required this.id,
|
bool get isLocal => localId != null;
|
||||||
required this.localId,
|
|
||||||
|
const Album({
|
||||||
|
this.id,
|
||||||
|
this.localId,
|
||||||
|
this.remoteId,
|
||||||
required this.name,
|
required this.name,
|
||||||
required this.modifiedTime,
|
required this.modifiedTime,
|
||||||
|
this.thumbnailAssetId,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
bool operator ==(covariant LocalAlbum other) {
|
bool operator ==(covariant Album other) {
|
||||||
if (identical(this, other)) return true;
|
if (identical(this, other)) return true;
|
||||||
|
|
||||||
return other.hashCode == hashCode;
|
return other.id == id &&
|
||||||
|
other.localId == localId &&
|
||||||
|
other.remoteId == remoteId &&
|
||||||
|
other.name == name &&
|
||||||
|
other.modifiedTime == modifiedTime &&
|
||||||
|
other.thumbnailAssetId == thumbnailAssetId;
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
int get hashCode {
|
int get hashCode {
|
||||||
return id.hashCode ^
|
return id.hashCode ^
|
||||||
localId.hashCode ^
|
localId.hashCode ^
|
||||||
|
remoteId.hashCode ^
|
||||||
name.hashCode ^
|
name.hashCode ^
|
||||||
modifiedTime.hashCode;
|
modifiedTime.hashCode ^
|
||||||
|
thumbnailAssetId.hashCode;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Album copyWith({
|
||||||
|
int? id,
|
||||||
|
String? localId,
|
||||||
|
String? remoteId,
|
||||||
|
String? name,
|
||||||
|
DateTime? modifiedTime,
|
||||||
|
int? thumbnailAssetId,
|
||||||
|
}) {
|
||||||
|
return Album(
|
||||||
|
id: id ?? this.id,
|
||||||
|
localId: localId ?? this.localId,
|
||||||
|
remoteId: remoteId ?? this.remoteId,
|
||||||
|
name: name ?? this.name,
|
||||||
|
modifiedTime: modifiedTime ?? this.modifiedTime,
|
||||||
|
thumbnailAssetId: thumbnailAssetId ?? this.thumbnailAssetId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() => """
|
||||||
|
{
|
||||||
|
id: ${id ?? "-"},
|
||||||
|
localId: "${localId ?? "-"}",
|
||||||
|
remoteId: "${remoteId ?? "-"}",
|
||||||
|
name: $name,
|
||||||
|
modifiedTime:
|
||||||
|
$modifiedTime,
|
||||||
|
thumbnailAssetId: "${thumbnailAssetId ?? "-"}",
|
||||||
|
}""";
|
||||||
|
|
||||||
|
static int compareByLocalId(Album a, Album b) =>
|
||||||
|
CollectionUtil.compareToNullable(a.localId, b.localId);
|
||||||
|
|
||||||
|
static int compareByRemoteId(Album a, Album b) =>
|
||||||
|
CollectionUtil.compareToNullable(a.remoteId, b.remoteId);
|
||||||
}
|
}
|
||||||
|
38
mobile-v2/lib/domain/models/album_etag.model.dart
Normal file
38
mobile-v2/lib/domain/models/album_etag.model.dart
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
class AlbumETag {
|
||||||
|
final int? id;
|
||||||
|
final int albumId;
|
||||||
|
final int assetCount;
|
||||||
|
final DateTime modifiedTime;
|
||||||
|
|
||||||
|
const AlbumETag({
|
||||||
|
this.id,
|
||||||
|
required this.albumId,
|
||||||
|
required this.assetCount,
|
||||||
|
required this.modifiedTime,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory AlbumETag.empty() {
|
||||||
|
return AlbumETag(
|
||||||
|
albumId: -1,
|
||||||
|
assetCount: 0,
|
||||||
|
modifiedTime: DateTime.now(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(covariant AlbumETag other) {
|
||||||
|
if (identical(this, other)) return true;
|
||||||
|
|
||||||
|
return other.id == id &&
|
||||||
|
other.albumId == albumId &&
|
||||||
|
other.assetCount == assetCount &&
|
||||||
|
other.modifiedTime == modifiedTime;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get hashCode =>
|
||||||
|
id.hashCode ^
|
||||||
|
albumId.hashCode ^
|
||||||
|
assetCount.hashCode ^
|
||||||
|
modifiedTime.hashCode;
|
||||||
|
}
|
@ -6,11 +6,11 @@ import 'package:immich_mobile/presentation/modules/theme/models/app_theme.model.
|
|||||||
// This model is the only exclusion which refers to entities from the presentation layer
|
// This model is the only exclusion which refers to entities from the presentation layer
|
||||||
// as well as the domain layer
|
// as well as the domain layer
|
||||||
enum AppSetting<T> {
|
enum AppSetting<T> {
|
||||||
appTheme<AppTheme>(StoreKey.appTheme, AppTheme.blue),
|
appTheme<AppTheme>._(StoreKey.appTheme, AppTheme.blue),
|
||||||
themeMode<ThemeMode>(StoreKey.themeMode, ThemeMode.system),
|
themeMode<ThemeMode>._(StoreKey.themeMode, ThemeMode.system),
|
||||||
darkMode<bool>(StoreKey.darkMode, false);
|
darkMode<bool>._(StoreKey.darkMode, false);
|
||||||
|
|
||||||
const AppSetting(this.storeKey, this.defaultValue);
|
const AppSetting._(this.storeKey, this.defaultValue);
|
||||||
|
|
||||||
// ignore: avoid-dynamic
|
// ignore: avoid-dynamic
|
||||||
final StoreKey<T, dynamic> storeKey;
|
final StoreKey<T, dynamic> storeKey;
|
||||||
|
@ -12,7 +12,7 @@ enum AssetType {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class Asset {
|
class Asset {
|
||||||
final int id;
|
final int? id;
|
||||||
final String name;
|
final String name;
|
||||||
final String hash;
|
final String hash;
|
||||||
final int? height;
|
final int? height;
|
||||||
@ -32,9 +32,10 @@ class Asset {
|
|||||||
bool get isRemote => remoteId != null;
|
bool get isRemote => remoteId != null;
|
||||||
bool get isLocal => localId != null;
|
bool get isLocal => localId != null;
|
||||||
bool get isMerged => isRemote && isLocal;
|
bool get isMerged => isRemote && isLocal;
|
||||||
|
bool get isImage => type == AssetType.image;
|
||||||
|
|
||||||
const Asset({
|
const Asset({
|
||||||
required this.id,
|
this.id,
|
||||||
required this.name,
|
required this.name,
|
||||||
required this.hash,
|
required this.hash,
|
||||||
this.height,
|
this.height,
|
||||||
@ -49,7 +50,6 @@ class Asset {
|
|||||||
});
|
});
|
||||||
|
|
||||||
factory Asset.remote(AssetResponseDto dto) => Asset(
|
factory Asset.remote(AssetResponseDto dto) => Asset(
|
||||||
id: 0, // assign a temporary auto gen ID
|
|
||||||
remoteId: dto.id,
|
remoteId: dto.id,
|
||||||
createdTime: dto.fileCreatedAt,
|
createdTime: dto.fileCreatedAt,
|
||||||
duration: dto.duration.tryParseInt() ?? 0,
|
duration: dto.duration.tryParseInt() ?? 0,
|
||||||
@ -93,29 +93,38 @@ class Asset {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Asset merge(Asset newAsset) {
|
Asset merge(Asset newAsset) {
|
||||||
if (newAsset.modifiedTime.isAfter(modifiedTime)) {
|
final existingAsset = this;
|
||||||
|
assert(existingAsset.id != null, "Existing asset must be from the db");
|
||||||
|
|
||||||
|
final oldestCreationTime =
|
||||||
|
existingAsset.createdTime.isBefore(newAsset.createdTime)
|
||||||
|
? existingAsset.createdTime
|
||||||
|
: newAsset.createdTime;
|
||||||
|
|
||||||
|
if (newAsset.modifiedTime.isAfter(existingAsset.modifiedTime)) {
|
||||||
return newAsset.copyWith(
|
return newAsset.copyWith(
|
||||||
height: newAsset.height ?? height,
|
id: newAsset.id ?? existingAsset.id,
|
||||||
width: newAsset.width ?? width,
|
localId: () => existingAsset.localId ?? newAsset.localId,
|
||||||
localId: () => newAsset.localId ?? localId,
|
remoteId: () => existingAsset.remoteId ?? newAsset.remoteId,
|
||||||
remoteId: () => newAsset.remoteId ?? remoteId,
|
width: newAsset.width ?? existingAsset.width,
|
||||||
livePhotoVideoId: newAsset.livePhotoVideoId ?? livePhotoVideoId,
|
height: newAsset.height ?? existingAsset.height,
|
||||||
|
createdTime: oldestCreationTime,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return copyWith(
|
return existingAsset.copyWith(
|
||||||
height: height ?? newAsset.height,
|
localId: () => existingAsset.localId ?? newAsset.localId,
|
||||||
width: width ?? newAsset.width,
|
remoteId: () => existingAsset.remoteId ?? newAsset.remoteId,
|
||||||
localId: () => localId ?? newAsset.localId,
|
width: existingAsset.width ?? newAsset.width,
|
||||||
remoteId: () => remoteId ?? newAsset.remoteId,
|
height: existingAsset.height ?? newAsset.height,
|
||||||
livePhotoVideoId: livePhotoVideoId ?? newAsset.livePhotoVideoId,
|
createdTime: oldestCreationTime,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String toString() => """
|
String toString() => """
|
||||||
{
|
{
|
||||||
"id": "$id",
|
"id": "${id ?? "-"}",
|
||||||
"remoteId": "${remoteId ?? "-"}",
|
"remoteId": "${remoteId ?? "-"}",
|
||||||
"localId": "${localId ?? "-"}",
|
"localId": "${localId ?? "-"}",
|
||||||
"name": "$name",
|
"name": "$name",
|
||||||
@ -163,8 +172,7 @@ class Asset {
|
|||||||
livePhotoVideoId.hashCode;
|
livePhotoVideoId.hashCode;
|
||||||
}
|
}
|
||||||
|
|
||||||
static int compareByRemoteId(Asset a, Asset b) =>
|
static int compareByHash(Asset a, Asset b) => a.hash.compareTo(b.hash);
|
||||||
CollectionUtil.compareToNullable(a.remoteId, b.remoteId);
|
|
||||||
|
|
||||||
static int compareByLocalId(Asset a, Asset b) =>
|
static int compareByLocalId(Asset a, Asset b) =>
|
||||||
CollectionUtil.compareToNullable(a.localId, b.localId);
|
CollectionUtil.compareToNullable(a.localId, b.localId);
|
||||||
|
56
mobile-v2/lib/domain/models/device_asset_download.model.dart
Normal file
56
mobile-v2/lib/domain/models/device_asset_download.model.dart
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
enum DeviceAssetRequestStatus {
|
||||||
|
preparing,
|
||||||
|
downloading,
|
||||||
|
success,
|
||||||
|
failed,
|
||||||
|
}
|
||||||
|
|
||||||
|
class DeviceAssetDownloadHandler {
|
||||||
|
DeviceAssetDownloadHandler() : stream = const Stream.empty() {
|
||||||
|
assert(
|
||||||
|
Platform.isIOS || Platform.isMacOS,
|
||||||
|
'$runtimeType should only be used on iOS or macOS.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A stream that provides information about the download status and progress of the asset being downloaded.
|
||||||
|
Stream<DeviceAssetDownloadState> stream;
|
||||||
|
}
|
||||||
|
|
||||||
|
class DeviceAssetDownloadState {
|
||||||
|
final double progress;
|
||||||
|
final DeviceAssetRequestStatus status;
|
||||||
|
|
||||||
|
const DeviceAssetDownloadState({
|
||||||
|
required this.progress,
|
||||||
|
required this.status,
|
||||||
|
});
|
||||||
|
|
||||||
|
DeviceAssetDownloadState copyWith({
|
||||||
|
double? progress,
|
||||||
|
DeviceAssetRequestStatus? status,
|
||||||
|
}) {
|
||||||
|
return DeviceAssetDownloadState(
|
||||||
|
progress: progress ?? this.progress,
|
||||||
|
status: status ?? this.status,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() {
|
||||||
|
return 'DeviceAssetDownloadState(progress: $progress, status: $status)';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(covariant DeviceAssetDownloadState other) {
|
||||||
|
return other.progress == progress && other.status == status;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get hashCode {
|
||||||
|
return progress.hashCode ^ status.hashCode;
|
||||||
|
}
|
||||||
|
}
|
57
mobile-v2/lib/domain/models/device_asset_hash.model.dart
Normal file
57
mobile-v2/lib/domain/models/device_asset_hash.model.dart
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:immich_mobile/utils/collection_util.dart';
|
||||||
|
|
||||||
|
@immutable
|
||||||
|
class DeviceAssetToHash {
|
||||||
|
final int? id;
|
||||||
|
final String localId;
|
||||||
|
final String hash;
|
||||||
|
final DateTime modifiedTime;
|
||||||
|
|
||||||
|
const DeviceAssetToHash({
|
||||||
|
this.id,
|
||||||
|
required this.localId,
|
||||||
|
required this.hash,
|
||||||
|
required this.modifiedTime,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(covariant DeviceAssetToHash other) {
|
||||||
|
if (identical(this, other)) return true;
|
||||||
|
|
||||||
|
return other.id == id &&
|
||||||
|
other.localId == localId &&
|
||||||
|
other.hash == hash &&
|
||||||
|
other.modifiedTime == modifiedTime;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get hashCode {
|
||||||
|
return id.hashCode ^
|
||||||
|
localId.hashCode ^
|
||||||
|
hash.hashCode ^
|
||||||
|
modifiedTime.hashCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
DeviceAssetToHash copyWith({
|
||||||
|
int? id,
|
||||||
|
String? localId,
|
||||||
|
String? hash,
|
||||||
|
DateTime? modifiedTime,
|
||||||
|
}) {
|
||||||
|
return DeviceAssetToHash(
|
||||||
|
id: id ?? this.id,
|
||||||
|
localId: localId ?? this.localId,
|
||||||
|
hash: hash ?? this.hash,
|
||||||
|
modifiedTime: modifiedTime ?? this.modifiedTime,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() {
|
||||||
|
return 'DeviceAssetToHash(id: ${id ?? "-"}, localId: $localId, hash: $hash, modifiedTime: $modifiedTime)';
|
||||||
|
}
|
||||||
|
|
||||||
|
static int compareByLocalId(DeviceAssetToHash a, DeviceAssetToHash b) =>
|
||||||
|
CollectionUtil.compareToNullable(a.localId, b.localId);
|
||||||
|
}
|
@ -16,7 +16,6 @@ extension LevelExtension on Level {
|
|||||||
LogLevel toLogLevel() => switch (this) {
|
LogLevel toLogLevel() => switch (this) {
|
||||||
Level.FINEST => LogLevel.verbose,
|
Level.FINEST => LogLevel.verbose,
|
||||||
Level.FINE => LogLevel.debug,
|
Level.FINE => LogLevel.debug,
|
||||||
Level.INFO => LogLevel.info,
|
|
||||||
Level.WARNING => LogLevel.warning,
|
Level.WARNING => LogLevel.warning,
|
||||||
Level.SEVERE => LogLevel.error,
|
Level.SEVERE => LogLevel.error,
|
||||||
Level.SHOUT => LogLevel.wtf,
|
Level.SHOUT => LogLevel.wtf,
|
||||||
|
@ -33,35 +33,35 @@ class StoreKeyNotFoundException implements Exception {
|
|||||||
/// Key for each possible value in the `Store`.
|
/// Key for each possible value in the `Store`.
|
||||||
/// Also stores the converter to convert the value to and from the store and the type of value stored in the Store
|
/// Also stores the converter to convert the value to and from the store and the type of value stored in the Store
|
||||||
enum StoreKey<T, U> {
|
enum StoreKey<T, U> {
|
||||||
serverEndpoint<String, String>(
|
serverEndpoint<String, String>._(
|
||||||
0,
|
0,
|
||||||
converter: StoreStringConverter(),
|
converter: StoreStringConverter(),
|
||||||
type: String,
|
type: String,
|
||||||
),
|
),
|
||||||
accessToken<String, String>(
|
accessToken<String, String>._(
|
||||||
1,
|
1,
|
||||||
converter: StoreStringConverter(),
|
converter: StoreStringConverter(),
|
||||||
type: String,
|
type: String,
|
||||||
),
|
),
|
||||||
currentUser<User, String>(
|
currentUser<User, String>._(
|
||||||
2,
|
2,
|
||||||
converter: StoreUserConverter(),
|
converter: StoreUserConverter(),
|
||||||
type: String,
|
type: String,
|
||||||
),
|
),
|
||||||
// App settings
|
// App settings
|
||||||
appTheme<AppTheme, int>(
|
appTheme<AppTheme, int>._(
|
||||||
1000,
|
1000,
|
||||||
converter: StoreEnumConverter(AppTheme.values),
|
converter: StoreEnumConverter(AppTheme.values),
|
||||||
type: int,
|
type: int,
|
||||||
),
|
),
|
||||||
themeMode<ThemeMode, int>(
|
themeMode<ThemeMode, int>._(
|
||||||
1001,
|
1001,
|
||||||
converter: StoreEnumConverter(ThemeMode.values),
|
converter: StoreEnumConverter(ThemeMode.values),
|
||||||
type: int,
|
type: int,
|
||||||
),
|
),
|
||||||
darkMode<bool, int>(1002, converter: StoreBooleanConverter(), type: int);
|
darkMode<bool, int>._(1002, converter: StoreBooleanConverter(), type: int);
|
||||||
|
|
||||||
const StoreKey(this.id, {required this.converter, required this.type});
|
const StoreKey._(this.id, {required this.converter, required this.type});
|
||||||
final int id;
|
final int id;
|
||||||
|
|
||||||
/// Primitive Type is also stored here to easily fetch it during runtime
|
/// Primitive Type is also stored here to easily fetch it during runtime
|
||||||
|
76
mobile-v2/lib/domain/repositories/album.repository.dart
Normal file
76
mobile-v2/lib/domain/repositories/album.repository.dart
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:drift/drift.dart';
|
||||||
|
import 'package:immich_mobile/domain/entities/album.entity.drift.dart';
|
||||||
|
import 'package:immich_mobile/domain/interfaces/album.interface.dart';
|
||||||
|
import 'package:immich_mobile/domain/models/album.model.dart';
|
||||||
|
import 'package:immich_mobile/domain/repositories/database.repository.dart';
|
||||||
|
import 'package:immich_mobile/utils/mixins/log.mixin.dart';
|
||||||
|
|
||||||
|
class AlbumRepository with LogMixin implements IAlbumRepository {
|
||||||
|
final DriftDatabaseRepository _db;
|
||||||
|
|
||||||
|
const AlbumRepository(this._db);
|
||||||
|
|
||||||
|
@override
|
||||||
|
FutureOr<Album?> upsert(Album album) async {
|
||||||
|
try {
|
||||||
|
final albumData = _toEntity(album);
|
||||||
|
final data = await _db.into(_db.album).insertReturningOrNull(
|
||||||
|
albumData,
|
||||||
|
onConflict: DoUpdate((_) => albumData, target: [_db.album.localId]),
|
||||||
|
);
|
||||||
|
if (data != null) {
|
||||||
|
return _toModel(data);
|
||||||
|
}
|
||||||
|
} catch (e, s) {
|
||||||
|
log.e("Error while adding an album to the DB", e, s);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
FutureOr<List<Album>> getAll({
|
||||||
|
bool localOnly = false,
|
||||||
|
bool remoteOnly = false,
|
||||||
|
}) async {
|
||||||
|
final query = _db.album.select();
|
||||||
|
|
||||||
|
if (localOnly == true) {
|
||||||
|
query.where((album) => album.localId.isNotNull());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (remoteOnly == true) {
|
||||||
|
query.where((album) => album.remoteId.isNotNull());
|
||||||
|
}
|
||||||
|
|
||||||
|
query.orderBy([(album) => OrderingTerm.asc(album.name)]);
|
||||||
|
return await query.map(_toModel).get();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
FutureOr<void> deleteId(int id) async {
|
||||||
|
await _db.asset.deleteWhere((row) => row.id.equals(id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
AlbumCompanion _toEntity(Album album) {
|
||||||
|
return AlbumCompanion.insert(
|
||||||
|
id: Value.absentIfNull(album.id),
|
||||||
|
localId: Value(album.localId),
|
||||||
|
remoteId: Value(album.remoteId),
|
||||||
|
name: album.name,
|
||||||
|
modifiedTime: Value(album.modifiedTime),
|
||||||
|
thumbnailAssetId: Value(album.thumbnailAssetId),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Album _toModel(AlbumData album) {
|
||||||
|
return Album(
|
||||||
|
id: album.id,
|
||||||
|
localId: album.localId,
|
||||||
|
remoteId: album.remoteId,
|
||||||
|
name: album.name,
|
||||||
|
modifiedTime: album.modifiedTime,
|
||||||
|
);
|
||||||
|
}
|
@ -0,0 +1,78 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:drift/drift.dart';
|
||||||
|
import 'package:immich_mobile/domain/entities/album_asset.entity.drift.dart';
|
||||||
|
import 'package:immich_mobile/domain/interfaces/album_asset.interface.dart';
|
||||||
|
import 'package:immich_mobile/domain/models/asset.model.dart';
|
||||||
|
import 'package:immich_mobile/domain/repositories/database.repository.dart';
|
||||||
|
import 'package:immich_mobile/domain/utils/drift_model_converters.dart';
|
||||||
|
import 'package:immich_mobile/utils/mixins/log.mixin.dart';
|
||||||
|
|
||||||
|
class AlbumToAssetRepository with LogMixin implements IAlbumToAssetRepository {
|
||||||
|
final DriftDatabaseRepository _db;
|
||||||
|
|
||||||
|
const AlbumToAssetRepository(this._db);
|
||||||
|
|
||||||
|
@override
|
||||||
|
FutureOr<bool> addAssetIds(int albumId, Iterable<int> assetIds) async {
|
||||||
|
try {
|
||||||
|
await _db.albumToAsset.insertAll(
|
||||||
|
assetIds.map((a) => AlbumToAssetCompanion.insert(
|
||||||
|
assetId: a,
|
||||||
|
albumId: albumId,
|
||||||
|
)),
|
||||||
|
onConflict: DoNothing(
|
||||||
|
target: [_db.albumToAsset.assetId, _db.albumToAsset.albumId],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
} catch (e, s) {
|
||||||
|
log.e("Error while adding assets to albumId - $albumId", e, s);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
FutureOr<List<int>> getAssetIdsOnlyInAlbum(int albumId) async {
|
||||||
|
final assetId = _db.asset.id;
|
||||||
|
final query = _db.asset.selectOnly()
|
||||||
|
..addColumns([assetId])
|
||||||
|
..join([
|
||||||
|
innerJoin(
|
||||||
|
_db.albumToAsset,
|
||||||
|
_db.albumToAsset.assetId.equalsExp(_db.asset.id) &
|
||||||
|
_db.asset.remoteId.isNull(),
|
||||||
|
useColumns: false,
|
||||||
|
),
|
||||||
|
])
|
||||||
|
..groupBy(
|
||||||
|
[assetId],
|
||||||
|
having: _db.albumToAsset.albumId.count().equals(1) &
|
||||||
|
_db.albumToAsset.albumId.max().equals(albumId),
|
||||||
|
);
|
||||||
|
|
||||||
|
return await query.map((row) => row.read(assetId)!).get();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
FutureOr<List<Asset>> getAssetsForAlbum(int albumId) async {
|
||||||
|
final query = _db.asset.select().join([
|
||||||
|
innerJoin(
|
||||||
|
_db.albumToAsset,
|
||||||
|
_db.albumToAsset.assetId.equalsExp(_db.asset.id) &
|
||||||
|
_db.albumToAsset.albumId.equals(albumId),
|
||||||
|
useColumns: false,
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return await query
|
||||||
|
.map((row) =>
|
||||||
|
DriftModelConverters.toAssetModel(row.readTable(_db.asset)))
|
||||||
|
.get();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
FutureOr<void> deleteAlbumId(int albumId) async {
|
||||||
|
await _db.albumToAsset.deleteWhere((row) => row.albumId.equals(albumId));
|
||||||
|
}
|
||||||
|
}
|
56
mobile-v2/lib/domain/repositories/album_etag.repository.dart
Normal file
56
mobile-v2/lib/domain/repositories/album_etag.repository.dart
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:drift/drift.dart';
|
||||||
|
import 'package:immich_mobile/domain/entities/album_etag.entity.drift.dart';
|
||||||
|
import 'package:immich_mobile/domain/interfaces/album_etag.interface.dart';
|
||||||
|
import 'package:immich_mobile/domain/models/album_etag.model.dart';
|
||||||
|
import 'package:immich_mobile/domain/repositories/database.repository.dart';
|
||||||
|
import 'package:immich_mobile/utils/mixins/log.mixin.dart';
|
||||||
|
|
||||||
|
class AlbumETagRepository with LogMixin implements IAlbumETagRepository {
|
||||||
|
final DriftDatabaseRepository _db;
|
||||||
|
|
||||||
|
const AlbumETagRepository(this._db);
|
||||||
|
|
||||||
|
@override
|
||||||
|
FutureOr<bool> upsert(AlbumETag albumETag) async {
|
||||||
|
try {
|
||||||
|
final entity = _toEntity(albumETag);
|
||||||
|
await _db.into(_db.albumETag).insert(
|
||||||
|
entity,
|
||||||
|
onConflict:
|
||||||
|
DoUpdate((_) => entity, target: [_db.albumETag.albumId]),
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
} catch (e, s) {
|
||||||
|
log.e("Error while adding an album etag to the DB", e, s);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
FutureOr<AlbumETag?> get(int albumId) async {
|
||||||
|
return await _db.managers.albumETag
|
||||||
|
.filter((r) => r.albumId.id.equals(albumId))
|
||||||
|
.map(_toModel)
|
||||||
|
.getSingleOrNull();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
AlbumETagCompanion _toEntity(AlbumETag albumETag) {
|
||||||
|
return AlbumETagCompanion.insert(
|
||||||
|
id: Value.absentIfNull(albumETag.id),
|
||||||
|
modifiedTime: Value(albumETag.modifiedTime),
|
||||||
|
albumId: albumETag.albumId,
|
||||||
|
assetCount: Value(albumETag.assetCount),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
AlbumETag _toModel(AlbumETagData albumETag) {
|
||||||
|
return AlbumETag(
|
||||||
|
albumId: albumETag.albumId,
|
||||||
|
assetCount: albumETag.assetCount,
|
||||||
|
modifiedTime: albumETag.modifiedTime,
|
||||||
|
id: albumETag.id,
|
||||||
|
);
|
||||||
|
}
|
@ -5,24 +5,31 @@ import 'package:immich_mobile/domain/entities/asset.entity.drift.dart';
|
|||||||
import 'package:immich_mobile/domain/interfaces/asset.interface.dart';
|
import 'package:immich_mobile/domain/interfaces/asset.interface.dart';
|
||||||
import 'package:immich_mobile/domain/models/asset.model.dart';
|
import 'package:immich_mobile/domain/models/asset.model.dart';
|
||||||
import 'package:immich_mobile/domain/repositories/database.repository.dart';
|
import 'package:immich_mobile/domain/repositories/database.repository.dart';
|
||||||
|
import 'package:immich_mobile/domain/utils/drift_model_converters.dart';
|
||||||
import 'package:immich_mobile/utils/mixins/log.mixin.dart';
|
import 'package:immich_mobile/utils/mixins/log.mixin.dart';
|
||||||
|
|
||||||
class AssetDriftRepository with LogMixin implements IAssetRepository {
|
class AssetRepository with LogMixin implements IAssetRepository {
|
||||||
final DriftDatabaseRepository _db;
|
final DriftDatabaseRepository _db;
|
||||||
|
|
||||||
const AssetDriftRepository(this._db);
|
const AssetRepository(this._db);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<bool> upsertAll(Iterable<Asset> assets) async {
|
Future<bool> upsertAll(Iterable<Asset> assets) async {
|
||||||
try {
|
try {
|
||||||
await _db.batch((batch) => batch.insertAllOnConflictUpdate(
|
await _db.batch((batch) {
|
||||||
|
final rows = assets.map(_toEntity);
|
||||||
|
for (final row in rows) {
|
||||||
|
batch.insert(
|
||||||
_db.asset,
|
_db.asset,
|
||||||
assets.map(_toEntity),
|
row,
|
||||||
));
|
onConflict: DoUpdate((_) => row, target: [_db.asset.hash]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
} catch (e, s) {
|
} catch (e, s) {
|
||||||
log.e("Cannot insert remote assets into table", e, s);
|
log.e("Cannot insert assets into table", e, s);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -33,7 +40,7 @@ class AssetDriftRepository with LogMixin implements IAssetRepository {
|
|||||||
await _db.asset.deleteAll();
|
await _db.asset.deleteAll();
|
||||||
return true;
|
return true;
|
||||||
} catch (e, s) {
|
} catch (e, s) {
|
||||||
log.e("Cannot clear remote assets", e, s);
|
log.e("Cannot clear assets", e, s);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -47,35 +54,45 @@ class AssetDriftRepository with LogMixin implements IAssetRepository {
|
|||||||
query.limit(limit, offset: offset);
|
query.limit(limit, offset: offset);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (await query.get()).map(_toModel).toList();
|
return (await query.map(DriftModelConverters.toAssetModel).get()).toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<List<Asset>> getForLocalIds(List<String> localIds) async {
|
Future<List<Asset>> getForLocalIds(Iterable<String> localIds) async {
|
||||||
final query = _db.asset.select()
|
final query = _db.asset.select()
|
||||||
..where((row) => row.localId.isIn(localIds))
|
..where((row) => row.localId.isIn(localIds))
|
||||||
..orderBy([(asset) => OrderingTerm.asc(asset.localId)]);
|
..orderBy([(asset) => OrderingTerm.asc(asset.hash)]);
|
||||||
|
|
||||||
return (await query.get()).map(_toModel).toList();
|
return (await query.get()).map(DriftModelConverters.toAssetModel).toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<List<Asset>> getForRemoteIds(List<String> remoteIds) async {
|
Future<List<Asset>> getForRemoteIds(Iterable<String> remoteIds) async {
|
||||||
final query = _db.asset.select()
|
final query = _db.asset.select()
|
||||||
..where((row) => row.remoteId.isIn(remoteIds))
|
..where((row) => row.remoteId.isIn(remoteIds))
|
||||||
..orderBy([(asset) => OrderingTerm.asc(asset.remoteId)]);
|
..orderBy([(asset) => OrderingTerm.asc(asset.hash)]);
|
||||||
|
|
||||||
return (await query.get()).map(_toModel).toList();
|
return (await query.get()).map(DriftModelConverters.toAssetModel).toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
FutureOr<void> deleteIds(List<int> ids) async {
|
Future<List<Asset>> getForHashes(Iterable<String> hashes) async {
|
||||||
|
final query = _db.asset.select()
|
||||||
|
..where((row) => row.hash.isIn(hashes))
|
||||||
|
..orderBy([(asset) => OrderingTerm.asc(asset.hash)]);
|
||||||
|
|
||||||
|
return (await query.get()).map(DriftModelConverters.toAssetModel).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
FutureOr<void> deleteIds(Iterable<int> ids) async {
|
||||||
await _db.asset.deleteWhere((row) => row.id.isIn(ids));
|
await _db.asset.deleteWhere((row) => row.id.isIn(ids));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
AssetCompanion _toEntity(Asset asset) {
|
AssetCompanion _toEntity(Asset asset) {
|
||||||
return AssetCompanion.insert(
|
return AssetCompanion.insert(
|
||||||
|
id: Value.absentIfNull(asset.id),
|
||||||
localId: Value(asset.localId),
|
localId: Value(asset.localId),
|
||||||
remoteId: Value(asset.remoteId),
|
remoteId: Value(asset.remoteId),
|
||||||
name: asset.name,
|
name: asset.name,
|
||||||
@ -89,20 +106,3 @@ AssetCompanion _toEntity(Asset asset) {
|
|||||||
livePhotoVideoId: Value(asset.livePhotoVideoId),
|
livePhotoVideoId: Value(asset.livePhotoVideoId),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Asset _toModel(AssetData asset) {
|
|
||||||
return Asset(
|
|
||||||
id: asset.id,
|
|
||||||
localId: asset.localId,
|
|
||||||
remoteId: asset.remoteId,
|
|
||||||
name: asset.name,
|
|
||||||
type: asset.type,
|
|
||||||
hash: asset.hash,
|
|
||||||
createdTime: asset.createdTime,
|
|
||||||
modifiedTime: asset.modifiedTime,
|
|
||||||
height: asset.height,
|
|
||||||
width: asset.width,
|
|
||||||
livePhotoVideoId: asset.livePhotoVideoId,
|
|
||||||
duration: asset.duration,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
@ -1,18 +1,36 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:drift/drift.dart';
|
import 'package:drift/drift.dart';
|
||||||
// ignore: depend_on_referenced_packages
|
// ignore: depend_on_referenced_packages
|
||||||
import 'package:drift_dev/api/migrations.dart';
|
import 'package:drift_dev/api/migrations.dart';
|
||||||
import 'package:drift_flutter/drift_flutter.dart';
|
import 'package:drift_flutter/drift_flutter.dart';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:immich_mobile/domain/entities/album.entity.dart';
|
import 'package:immich_mobile/domain/entities/album.entity.dart';
|
||||||
|
import 'package:immich_mobile/domain/entities/album_asset.entity.dart';
|
||||||
|
import 'package:immich_mobile/domain/entities/album_etag.entity.dart';
|
||||||
import 'package:immich_mobile/domain/entities/asset.entity.dart';
|
import 'package:immich_mobile/domain/entities/asset.entity.dart';
|
||||||
|
import 'package:immich_mobile/domain/entities/device_asset_hash.entity.dart';
|
||||||
import 'package:immich_mobile/domain/entities/log.entity.dart';
|
import 'package:immich_mobile/domain/entities/log.entity.dart';
|
||||||
import 'package:immich_mobile/domain/entities/store.entity.dart';
|
import 'package:immich_mobile/domain/entities/store.entity.dart';
|
||||||
import 'package:immich_mobile/domain/entities/user.entity.dart';
|
import 'package:immich_mobile/domain/entities/user.entity.dart';
|
||||||
|
import 'package:immich_mobile/domain/interfaces/database.interface.dart';
|
||||||
|
|
||||||
import 'database.repository.drift.dart';
|
import 'database.repository.drift.dart';
|
||||||
|
|
||||||
@DriftDatabase(tables: [Logs, Store, LocalAlbum, Asset, User])
|
@DriftDatabase(
|
||||||
class DriftDatabaseRepository extends $DriftDatabaseRepository {
|
tables: [
|
||||||
|
Logs,
|
||||||
|
Store,
|
||||||
|
User,
|
||||||
|
Asset,
|
||||||
|
DeviceAssetToHash,
|
||||||
|
Album,
|
||||||
|
AlbumToAsset,
|
||||||
|
AlbumETag,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
class DriftDatabaseRepository extends $DriftDatabaseRepository
|
||||||
|
implements IDatabaseRepository {
|
||||||
DriftDatabaseRepository([QueryExecutor? executor])
|
DriftDatabaseRepository([QueryExecutor? executor])
|
||||||
: super(executor ??
|
: super(executor ??
|
||||||
driftDatabase(
|
driftDatabase(
|
||||||
@ -37,4 +55,7 @@ class DriftDatabaseRepository extends $DriftDatabaseRepository {
|
|||||||
// ignore: no-empty-block
|
// ignore: no-empty-block
|
||||||
onUpgrade: (m, from, to) async {},
|
onUpgrade: (m, from, to) async {},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<T> txn<T>(Future<T> Function() action) => transaction(action);
|
||||||
}
|
}
|
||||||
|
@ -0,0 +1,83 @@
|
|||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:immich_mobile/domain/interfaces/device_album.interface.dart';
|
||||||
|
import 'package:immich_mobile/domain/interfaces/device_asset.interface.dart';
|
||||||
|
import 'package:immich_mobile/domain/models/album.model.dart';
|
||||||
|
import 'package:immich_mobile/domain/models/asset.model.dart';
|
||||||
|
import 'package:immich_mobile/service_locator.dart';
|
||||||
|
import 'package:immich_mobile/utils/mixins/log.mixin.dart';
|
||||||
|
import 'package:photo_manager/photo_manager.dart';
|
||||||
|
|
||||||
|
class DeviceAlbumRepository with LogMixin implements IDeviceAlbumRepository {
|
||||||
|
const DeviceAlbumRepository();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<Album>> getAll() async {
|
||||||
|
final List<AssetPathEntity> assetPathEntities =
|
||||||
|
await PhotoManager.getAssetPathList(
|
||||||
|
hasAll: Platform.isIOS,
|
||||||
|
filterOption: FilterOptionGroup(containsPathModified: true),
|
||||||
|
);
|
||||||
|
return assetPathEntities.map(_toModel).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<Asset>> getAssetsForAlbum(
|
||||||
|
String albumId, {
|
||||||
|
int start = 0,
|
||||||
|
int end = 0x7fffffffffffffff,
|
||||||
|
DateTime? modifiedFrom,
|
||||||
|
DateTime? modifiedUntil,
|
||||||
|
bool orderByModificationDate = false,
|
||||||
|
}) async {
|
||||||
|
final album = await _getDeviceAlbum(
|
||||||
|
albumId,
|
||||||
|
modifiedFrom: modifiedFrom,
|
||||||
|
modifiedUntil: modifiedUntil,
|
||||||
|
orderByModificationDate: orderByModificationDate,
|
||||||
|
);
|
||||||
|
final List<AssetEntity> assets =
|
||||||
|
await album.getAssetListRange(start: start, end: end);
|
||||||
|
return await Future.wait(
|
||||||
|
assets.map((a) async => await di<IDeviceAssetRepository>().toAsset(a)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<AssetPathEntity> _getDeviceAlbum(
|
||||||
|
String albumId, {
|
||||||
|
DateTime? modifiedFrom,
|
||||||
|
DateTime? modifiedUntil,
|
||||||
|
bool orderByModificationDate = false,
|
||||||
|
}) async {
|
||||||
|
return await AssetPathEntity.fromId(
|
||||||
|
albumId,
|
||||||
|
filterOption: FilterOptionGroup(
|
||||||
|
containsPathModified: true,
|
||||||
|
orders: orderByModificationDate
|
||||||
|
? [const OrderOption(type: OrderOptionType.updateDate)]
|
||||||
|
: [],
|
||||||
|
imageOption: const FilterOption(needTitle: true),
|
||||||
|
videoOption: const FilterOption(needTitle: true),
|
||||||
|
updateTimeCond: DateTimeCond(
|
||||||
|
min: modifiedFrom ?? DateTime.utc(-271820),
|
||||||
|
max: modifiedUntil ?? DateTime.utc(275760),
|
||||||
|
ignore: modifiedFrom != null || modifiedUntil != null,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<int> getAssetCount(String albumId) async {
|
||||||
|
final album = await _getDeviceAlbum(albumId);
|
||||||
|
return await album.assetCountAsync;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Album _toModel(AssetPathEntity album) {
|
||||||
|
return Album(
|
||||||
|
localId: album.id,
|
||||||
|
name: album.name,
|
||||||
|
modifiedTime: album.lastModified ?? DateTime.now(),
|
||||||
|
);
|
||||||
|
}
|
100
mobile-v2/lib/domain/repositories/device_asset.repository.dart
Normal file
100
mobile-v2/lib/domain/repositories/device_asset.repository.dart
Normal file
@ -0,0 +1,100 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'dart:io';
|
||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
import 'package:immich_mobile/domain/interfaces/device_asset.interface.dart';
|
||||||
|
import 'package:immich_mobile/domain/models/asset.model.dart';
|
||||||
|
import 'package:immich_mobile/domain/models/device_asset_download.model.dart';
|
||||||
|
import 'package:immich_mobile/utils/constants/globals.dart';
|
||||||
|
import 'package:immich_mobile/utils/mixins/log.mixin.dart';
|
||||||
|
import 'package:photo_manager/photo_manager.dart' as ph;
|
||||||
|
|
||||||
|
class DeviceAssetRepository
|
||||||
|
with LogMixin
|
||||||
|
implements IDeviceAssetRepository<ph.AssetEntity> {
|
||||||
|
const DeviceAssetRepository();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<Asset> toAsset(ph.AssetEntity entity) async {
|
||||||
|
var asset = Asset(
|
||||||
|
hash: '',
|
||||||
|
name: await entity.titleAsync,
|
||||||
|
type: _toAssetType(entity.type),
|
||||||
|
createdTime: entity.createDateTime,
|
||||||
|
modifiedTime: entity.modifiedDateTime,
|
||||||
|
duration: entity.duration,
|
||||||
|
height: entity.height,
|
||||||
|
width: entity.width,
|
||||||
|
localId: entity.id,
|
||||||
|
);
|
||||||
|
if (asset.createdTime.year == 1970) {
|
||||||
|
asset = asset.copyWith(createdTime: asset.modifiedTime);
|
||||||
|
}
|
||||||
|
return asset;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
FutureOr<File?> getOriginalFile(String localId) async {
|
||||||
|
try {
|
||||||
|
final entity = await ph.AssetEntity.fromId(localId);
|
||||||
|
if (entity == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return await entity.originFile;
|
||||||
|
} catch (e, s) {
|
||||||
|
log.e("Exception while fetching file for localId - $localId", e, s);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
FutureOr<Uint8List?> getThumbnail(
|
||||||
|
String assetId, {
|
||||||
|
int width = kGridThumbnailSize,
|
||||||
|
int height = kGridThumbnailSize,
|
||||||
|
int quality = kGridThumbnailQuality,
|
||||||
|
DeviceAssetDownloadHandler? downloadHandler,
|
||||||
|
}) async {
|
||||||
|
try {
|
||||||
|
final entity = await ph.AssetEntity.fromId(assetId);
|
||||||
|
if (entity == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
ph.PMProgressHandler? progressHandler;
|
||||||
|
if (downloadHandler != null) {
|
||||||
|
progressHandler = ph.PMProgressHandler();
|
||||||
|
downloadHandler.stream = progressHandler.stream.map(_toDownloadState);
|
||||||
|
}
|
||||||
|
|
||||||
|
return await entity.thumbnailDataWithSize(
|
||||||
|
ph.ThumbnailSize(width, height),
|
||||||
|
quality: quality,
|
||||||
|
progressHandler: progressHandler,
|
||||||
|
);
|
||||||
|
} catch (e, s) {
|
||||||
|
log.e("Exception while fetching thumbnail for assetId - $assetId", e, s);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
AssetType _toAssetType(ph.AssetType type) => switch (type) {
|
||||||
|
ph.AssetType.audio => AssetType.audio,
|
||||||
|
ph.AssetType.image => AssetType.image,
|
||||||
|
ph.AssetType.video => AssetType.video,
|
||||||
|
ph.AssetType.other => AssetType.other,
|
||||||
|
};
|
||||||
|
|
||||||
|
DeviceAssetDownloadState _toDownloadState(ph.PMProgressState state) {
|
||||||
|
return DeviceAssetDownloadState(
|
||||||
|
progress: state.progress,
|
||||||
|
status: switch (state.state) {
|
||||||
|
ph.PMRequestState.prepare => DeviceAssetRequestStatus.preparing,
|
||||||
|
ph.PMRequestState.loading => DeviceAssetRequestStatus.downloading,
|
||||||
|
ph.PMRequestState.success => DeviceAssetRequestStatus.success,
|
||||||
|
ph.PMRequestState.failed => DeviceAssetRequestStatus.failed,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
@ -0,0 +1,62 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:drift/drift.dart';
|
||||||
|
import 'package:immich_mobile/domain/entities/device_asset_hash.entity.drift.dart';
|
||||||
|
import 'package:immich_mobile/domain/interfaces/device_asset_hash.interface.dart';
|
||||||
|
import 'package:immich_mobile/domain/models/device_asset_hash.model.dart';
|
||||||
|
import 'package:immich_mobile/domain/repositories/database.repository.dart';
|
||||||
|
import 'package:immich_mobile/utils/mixins/log.mixin.dart';
|
||||||
|
|
||||||
|
class DeviceAssetToHashRepository
|
||||||
|
with LogMixin
|
||||||
|
implements IDeviceAssetToHashRepository {
|
||||||
|
final DriftDatabaseRepository _db;
|
||||||
|
|
||||||
|
const DeviceAssetToHashRepository(this._db);
|
||||||
|
|
||||||
|
@override
|
||||||
|
FutureOr<bool> upsertAll(Iterable<DeviceAssetToHash> assetHash) async {
|
||||||
|
try {
|
||||||
|
await _db.batch((batch) => batch.insertAllOnConflictUpdate(
|
||||||
|
_db.deviceAssetToHash,
|
||||||
|
assetHash.map(_toEntity),
|
||||||
|
));
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (e, s) {
|
||||||
|
log.e("Cannot add device assets to hash entry", e, s);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<DeviceAssetToHash>> getForIds(Iterable<String> localIds) async {
|
||||||
|
return await _db.managers.deviceAssetToHash
|
||||||
|
.filter((f) => f.localId.isIn(localIds))
|
||||||
|
.map(_toModel)
|
||||||
|
.get();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
FutureOr<void> deleteIds(Iterable<int> ids) async {
|
||||||
|
await _db.deviceAssetToHash.deleteWhere((row) => row.id.isIn(ids));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
DeviceAssetToHashCompanion _toEntity(DeviceAssetToHash asset) {
|
||||||
|
return DeviceAssetToHashCompanion.insert(
|
||||||
|
id: Value.absentIfNull(asset.id),
|
||||||
|
localId: asset.localId,
|
||||||
|
hash: asset.hash,
|
||||||
|
modifiedTime: Value(asset.modifiedTime),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
DeviceAssetToHash _toModel(DeviceAssetToHashData asset) {
|
||||||
|
return DeviceAssetToHash(
|
||||||
|
id: asset.id,
|
||||||
|
localId: asset.localId,
|
||||||
|
hash: asset.hash,
|
||||||
|
modifiedTime: asset.modifiedTime,
|
||||||
|
);
|
||||||
|
}
|
@ -7,10 +7,10 @@ import 'package:immich_mobile/domain/interfaces/log.interface.dart';
|
|||||||
import 'package:immich_mobile/domain/models/log.model.dart';
|
import 'package:immich_mobile/domain/models/log.model.dart';
|
||||||
import 'package:immich_mobile/domain/repositories/database.repository.dart';
|
import 'package:immich_mobile/domain/repositories/database.repository.dart';
|
||||||
|
|
||||||
class LogDriftRepository implements ILogRepository {
|
class LogRepository implements ILogRepository {
|
||||||
final DriftDatabaseRepository _db;
|
final DriftDatabaseRepository _db;
|
||||||
|
|
||||||
const LogDriftRepository(this._db);
|
const LogRepository(this._db);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<List<LogMessage>> getAll() async {
|
Future<List<LogMessage>> getAll() async {
|
||||||
@ -32,14 +32,7 @@ class LogDriftRepository implements ILogRepository {
|
|||||||
@override
|
@override
|
||||||
FutureOr<bool> create(LogMessage log) async {
|
FutureOr<bool> create(LogMessage log) async {
|
||||||
try {
|
try {
|
||||||
await _db.into(_db.logs).insert(LogsCompanion.insert(
|
await _db.into(_db.logs).insert(_toEntity(log));
|
||||||
content: log.content,
|
|
||||||
level: log.level,
|
|
||||||
createdAt: Value(log.createdAt),
|
|
||||||
error: Value(log.error),
|
|
||||||
logger: Value(log.logger),
|
|
||||||
stack: Value(log.stack),
|
|
||||||
));
|
|
||||||
return true;
|
return true;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint("Error while adding a log to the DB - $e");
|
debugPrint("Error while adding a log to the DB - $e");
|
||||||
@ -48,20 +41,10 @@ class LogDriftRepository implements ILogRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
FutureOr<bool> createAll(List<LogMessage> logs) async {
|
FutureOr<bool> createAll(Iterable<LogMessage> logs) async {
|
||||||
try {
|
try {
|
||||||
await _db.batch((b) {
|
await _db.batch((b) {
|
||||||
b.insertAll(
|
b.insertAll(_db.logs, logs.map(_toEntity));
|
||||||
_db.logs,
|
|
||||||
logs.map((log) => LogsCompanion.insert(
|
|
||||||
content: log.content,
|
|
||||||
level: log.level,
|
|
||||||
createdAt: Value(log.createdAt),
|
|
||||||
error: Value(log.error),
|
|
||||||
logger: Value(log.logger),
|
|
||||||
stack: Value(log.stack),
|
|
||||||
)),
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
return true;
|
return true;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@ -82,6 +65,17 @@ class LogDriftRepository implements ILogRepository {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
LogsCompanion _toEntity(LogMessage log) {
|
||||||
|
return LogsCompanion.insert(
|
||||||
|
content: log.content,
|
||||||
|
level: log.level,
|
||||||
|
createdAt: Value(log.createdAt),
|
||||||
|
error: Value(log.error),
|
||||||
|
logger: Value(log.logger),
|
||||||
|
stack: Value(log.stack),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
LogMessage _toModel(Log log) {
|
LogMessage _toModel(Log log) {
|
||||||
return LogMessage(
|
return LogMessage(
|
||||||
content: log.content,
|
content: log.content,
|
||||||
|
@ -6,10 +6,10 @@ import 'package:immich_mobile/domain/repositories/database.repository.dart';
|
|||||||
import 'package:immich_mobile/utils/extensions/drift.extension.dart';
|
import 'package:immich_mobile/utils/extensions/drift.extension.dart';
|
||||||
import 'package:immich_mobile/utils/mixins/log.mixin.dart';
|
import 'package:immich_mobile/utils/mixins/log.mixin.dart';
|
||||||
|
|
||||||
class RenderListDriftRepository with LogMixin implements IRenderListRepository {
|
class RenderListRepository with LogMixin implements IRenderListRepository {
|
||||||
final DriftDatabaseRepository _db;
|
final DriftDatabaseRepository _db;
|
||||||
|
|
||||||
const RenderListDriftRepository(this._db);
|
const RenderListRepository(this._db);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Stream<RenderList> watchAll() {
|
Stream<RenderList> watchAll() {
|
||||||
|
@ -7,10 +7,10 @@ import 'package:immich_mobile/domain/models/store.model.dart';
|
|||||||
import 'package:immich_mobile/domain/repositories/database.repository.dart';
|
import 'package:immich_mobile/domain/repositories/database.repository.dart';
|
||||||
import 'package:immich_mobile/utils/mixins/log.mixin.dart';
|
import 'package:immich_mobile/utils/mixins/log.mixin.dart';
|
||||||
|
|
||||||
class StoreDriftRepository with LogMixin implements IStoreRepository {
|
class StoreRepository with LogMixin implements IStoreRepository {
|
||||||
final DriftDatabaseRepository _db;
|
final DriftDatabaseRepository _db;
|
||||||
|
|
||||||
const StoreDriftRepository(this._db);
|
const StoreRepository(this._db);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
FutureOr<T?> tryGet<T, U>(StoreKey<T, U> key) async {
|
FutureOr<T?> tryGet<T, U>(StoreKey<T, U> key) async {
|
||||||
|
@ -7,10 +7,10 @@ import 'package:immich_mobile/domain/models/user.model.dart';
|
|||||||
import 'package:immich_mobile/domain/repositories/database.repository.dart';
|
import 'package:immich_mobile/domain/repositories/database.repository.dart';
|
||||||
import 'package:immich_mobile/utils/mixins/log.mixin.dart';
|
import 'package:immich_mobile/utils/mixins/log.mixin.dart';
|
||||||
|
|
||||||
class UserDriftRepository with LogMixin implements IUserRepository {
|
class UserRepository with LogMixin implements IUserRepository {
|
||||||
final DriftDatabaseRepository _db;
|
final DriftDatabaseRepository _db;
|
||||||
|
|
||||||
const UserDriftRepository(this._db);
|
const UserRepository(this._db);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
FutureOr<User?> getForId(String userId) async {
|
FutureOr<User?> getForId(String userId) async {
|
||||||
|
131
mobile-v2/lib/domain/services/album_sync.service.dart
Normal file
131
mobile-v2/lib/domain/services/album_sync.service.dart
Normal file
@ -0,0 +1,131 @@
|
|||||||
|
import 'package:immich_mobile/domain/interfaces/album.interface.dart';
|
||||||
|
import 'package:immich_mobile/domain/interfaces/album_asset.interface.dart';
|
||||||
|
import 'package:immich_mobile/domain/interfaces/album_etag.interface.dart';
|
||||||
|
import 'package:immich_mobile/domain/interfaces/asset.interface.dart';
|
||||||
|
import 'package:immich_mobile/domain/interfaces/database.interface.dart';
|
||||||
|
import 'package:immich_mobile/domain/interfaces/device_album.interface.dart';
|
||||||
|
import 'package:immich_mobile/domain/models/album.model.dart';
|
||||||
|
import 'package:immich_mobile/domain/models/album_etag.model.dart';
|
||||||
|
import 'package:immich_mobile/domain/services/asset_sync.service.dart';
|
||||||
|
import 'package:immich_mobile/domain/services/hash.service.dart';
|
||||||
|
import 'package:immich_mobile/service_locator.dart';
|
||||||
|
import 'package:immich_mobile/utils/collection_util.dart';
|
||||||
|
import 'package:immich_mobile/utils/isolate_helper.dart';
|
||||||
|
import 'package:immich_mobile/utils/mixins/log.mixin.dart';
|
||||||
|
|
||||||
|
class AlbumSyncService with LogMixin {
|
||||||
|
const AlbumSyncService();
|
||||||
|
|
||||||
|
Future<bool> performFullDeviceSyncIsolate() async {
|
||||||
|
return await IsolateHelper.run(performFullDeviceSync);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<bool> performFullDeviceSync() async {
|
||||||
|
try {
|
||||||
|
final deviceAlbums = await di<IDeviceAlbumRepository>().getAll();
|
||||||
|
final dbAlbums = await di<IAlbumRepository>().getAll(localOnly: true);
|
||||||
|
final hasChange = await CollectionUtil.diffLists(
|
||||||
|
dbAlbums,
|
||||||
|
deviceAlbums,
|
||||||
|
compare: Album.compareByLocalId,
|
||||||
|
both: _syncDeviceAlbum,
|
||||||
|
// Album is in DB but not anymore in device. Remove album and album specific assets
|
||||||
|
onlyFirst: _removeDeviceAlbum,
|
||||||
|
onlySecond: _addDeviceAlbum,
|
||||||
|
);
|
||||||
|
|
||||||
|
return hasChange;
|
||||||
|
} catch (e, s) {
|
||||||
|
log.e("Error performing full device sync", e, s);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<bool> _syncDeviceAlbum(
|
||||||
|
Album dbAlbum,
|
||||||
|
Album deviceAlbum, {
|
||||||
|
DateTime? modifiedUntil,
|
||||||
|
}) async {
|
||||||
|
assert(dbAlbum.id != null, "Album ID from DB is null");
|
||||||
|
final albumEtag =
|
||||||
|
await di<IAlbumETagRepository>().get(dbAlbum.id!) ?? AlbumETag.empty();
|
||||||
|
final assetCountInDevice =
|
||||||
|
await di<IDeviceAlbumRepository>().getAssetCount(deviceAlbum.localId!);
|
||||||
|
|
||||||
|
final albumNotUpdated = deviceAlbum.name == dbAlbum.name &&
|
||||||
|
dbAlbum.modifiedTime.isAtSameMomentAs(deviceAlbum.modifiedTime) &&
|
||||||
|
assetCountInDevice == albumEtag.assetCount;
|
||||||
|
if (albumNotUpdated) {
|
||||||
|
log.i("Device Album ${deviceAlbum.name} not updated. Skipping sync.");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
await _addDeviceAlbum(dbAlbum, modifiedUntil: modifiedUntil);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _addDeviceAlbum(Album album, {DateTime? modifiedUntil}) async {
|
||||||
|
try {
|
||||||
|
final albumId = (await di<IAlbumRepository>().upsert(album))?.id;
|
||||||
|
// break fast if we cannot add an album
|
||||||
|
if (albumId == null) {
|
||||||
|
log.d("Failed creating device album. Skipped assets from album");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final assets = await di<HashService>().getHashedAssetsForAlbum(
|
||||||
|
album.localId!,
|
||||||
|
modifiedUntil: modifiedUntil,
|
||||||
|
);
|
||||||
|
|
||||||
|
await di<IDatabaseRepository>().txn(() async {
|
||||||
|
final albumAssetsInDB =
|
||||||
|
await di<IAlbumToAssetRepository>().getAssetsForAlbum(albumId);
|
||||||
|
|
||||||
|
await di<AssetSyncService>().upsertAssetsToDb(
|
||||||
|
assets,
|
||||||
|
albumAssetsInDB,
|
||||||
|
isRemoteSync: false,
|
||||||
|
);
|
||||||
|
|
||||||
|
// This is needed to get the updated assets for device album with valid db id field
|
||||||
|
final albumAssets = await di<IAssetRepository>()
|
||||||
|
.getForLocalIds(assets.map((a) => a.localId!));
|
||||||
|
|
||||||
|
await di<IAlbumToAssetRepository>().addAssetIds(
|
||||||
|
albumId,
|
||||||
|
albumAssets.map((a) => a.id!),
|
||||||
|
);
|
||||||
|
await di<IAlbumRepository>().upsert(
|
||||||
|
album.copyWith(thumbnailAssetId: albumAssets.firstOrNull?.id),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Update ETag
|
||||||
|
final albumETag = AlbumETag(
|
||||||
|
albumId: albumId,
|
||||||
|
assetCount: assets.length,
|
||||||
|
modifiedTime: album.modifiedTime,
|
||||||
|
);
|
||||||
|
await di<IAlbumETagRepository>().upsert(albumETag);
|
||||||
|
});
|
||||||
|
} catch (e, s) {
|
||||||
|
log.w("Error while adding device album", e, s);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _removeDeviceAlbum(Album album) async {
|
||||||
|
assert(album.id != null, "Album ID from DB is null");
|
||||||
|
final albumId = album.id!;
|
||||||
|
try {
|
||||||
|
await di<IDatabaseRepository>().txn(() async {
|
||||||
|
final toRemove =
|
||||||
|
await di<IAlbumToAssetRepository>().getAssetIdsOnlyInAlbum(albumId);
|
||||||
|
await di<IAlbumRepository>().deleteId(albumId);
|
||||||
|
await di<IAlbumToAssetRepository>().deleteAlbumId(albumId);
|
||||||
|
await di<IAssetRepository>().deleteIds(toRemove);
|
||||||
|
});
|
||||||
|
} catch (e, s) {
|
||||||
|
log.w("Error while removing device album", e, s);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -2,6 +2,7 @@ import 'dart:async';
|
|||||||
|
|
||||||
import 'package:collection/collection.dart';
|
import 'package:collection/collection.dart';
|
||||||
import 'package:immich_mobile/domain/interfaces/asset.interface.dart';
|
import 'package:immich_mobile/domain/interfaces/asset.interface.dart';
|
||||||
|
import 'package:immich_mobile/domain/interfaces/database.interface.dart';
|
||||||
import 'package:immich_mobile/domain/models/asset.model.dart';
|
import 'package:immich_mobile/domain/models/asset.model.dart';
|
||||||
import 'package:immich_mobile/domain/models/user.model.dart';
|
import 'package:immich_mobile/domain/models/user.model.dart';
|
||||||
import 'package:immich_mobile/service_locator.dart';
|
import 'package:immich_mobile/service_locator.dart';
|
||||||
@ -9,76 +10,87 @@ import 'package:immich_mobile/utils/collection_util.dart';
|
|||||||
import 'package:immich_mobile/utils/constants/globals.dart';
|
import 'package:immich_mobile/utils/constants/globals.dart';
|
||||||
import 'package:immich_mobile/utils/immich_api_client.dart';
|
import 'package:immich_mobile/utils/immich_api_client.dart';
|
||||||
import 'package:immich_mobile/utils/isolate_helper.dart';
|
import 'package:immich_mobile/utils/isolate_helper.dart';
|
||||||
import 'package:immich_mobile/utils/log_manager.dart';
|
|
||||||
import 'package:immich_mobile/utils/mixins/log.mixin.dart';
|
import 'package:immich_mobile/utils/mixins/log.mixin.dart';
|
||||||
import 'package:openapi/api.dart';
|
import 'package:openapi/api.dart';
|
||||||
|
|
||||||
class AssetSyncService with LogMixin {
|
class AssetSyncService with LogMixin {
|
||||||
const AssetSyncService();
|
const AssetSyncService();
|
||||||
|
|
||||||
Future<bool> performFullRemoteSyncForUser(
|
Future<bool> performFullRemoteSyncIsolate(
|
||||||
User user, {
|
User user, {
|
||||||
DateTime? updatedUtil,
|
DateTime? updatedUtil,
|
||||||
int? limit,
|
int? limit,
|
||||||
}) async {
|
}) async {
|
||||||
return await IsolateHelper.run(() async {
|
return await IsolateHelper.run(() async {
|
||||||
try {
|
return await performFullRemoteSync(
|
||||||
final logger = LogManager.I.get("SyncService <Isolate>");
|
user,
|
||||||
final syncClient = di<ImmichApiClient>().getSyncApi();
|
updatedUtil: updatedUtil,
|
||||||
|
limit: limit,
|
||||||
final chunkSize = limit ?? kFullSyncChunkSize;
|
);
|
||||||
final updatedTill = updatedUtil ?? DateTime.now().toUtc();
|
|
||||||
updatedUtil ??= DateTime.now().toUtc();
|
|
||||||
String? lastAssetId;
|
|
||||||
|
|
||||||
while (true) {
|
|
||||||
logger.d(
|
|
||||||
"Requesting more chunks from lastId - ${lastAssetId ?? "<initial_fetch>"}",
|
|
||||||
);
|
|
||||||
|
|
||||||
final assets = await syncClient.getFullSyncForUser(AssetFullSyncDto(
|
|
||||||
limit: chunkSize,
|
|
||||||
updatedUntil: updatedTill,
|
|
||||||
lastId: lastAssetId,
|
|
||||||
userId: user.id,
|
|
||||||
));
|
|
||||||
if (assets == null) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
final assetsFromServer =
|
|
||||||
assets.map(Asset.remote).sorted(Asset.compareByRemoteId);
|
|
||||||
|
|
||||||
final assetsInDb = await di<IAssetRepository>().getForRemoteIds(
|
|
||||||
assetsFromServer.map((a) => a.remoteId!).toList(),
|
|
||||||
);
|
|
||||||
|
|
||||||
await _syncAssetsToDb(
|
|
||||||
assetsFromServer,
|
|
||||||
assetsInDb,
|
|
||||||
Asset.compareByRemoteId,
|
|
||||||
isRemoteSync: true,
|
|
||||||
);
|
|
||||||
|
|
||||||
lastAssetId = assets.lastOrNull?.id;
|
|
||||||
if (assets.length != chunkSize) break;
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
} catch (e, s) {
|
|
||||||
log.e("Error performing full sync for user - ${user.name}", e, s);
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _syncAssetsToDb(
|
Future<bool> performFullRemoteSync(
|
||||||
List<Asset> newAssets,
|
User user, {
|
||||||
List<Asset> existingAssets,
|
DateTime? updatedUtil,
|
||||||
Comparator<Asset> compare, {
|
int? limit,
|
||||||
bool? isRemoteSync,
|
|
||||||
}) async {
|
}) async {
|
||||||
final (toAdd, toUpdate, assetsToRemove) = _diffAssets(
|
try {
|
||||||
|
final syncClient = di<ImApiClient>().getSyncApi();
|
||||||
|
final db = di<IDatabaseRepository>();
|
||||||
|
final assetRepo = di<IAssetRepository>();
|
||||||
|
|
||||||
|
final chunkSize = limit ?? kFullSyncChunkSize;
|
||||||
|
final updatedTill = updatedUtil ?? DateTime.now().toUtc();
|
||||||
|
updatedUtil ??= DateTime.now().toUtc();
|
||||||
|
String? lastAssetId;
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
log.d(
|
||||||
|
"Requesting more chunks from lastId - ${lastAssetId ?? "<initial_fetch>"}",
|
||||||
|
);
|
||||||
|
|
||||||
|
final assets = await syncClient.getFullSyncForUser(AssetFullSyncDto(
|
||||||
|
limit: chunkSize,
|
||||||
|
updatedUntil: updatedTill,
|
||||||
|
lastId: lastAssetId,
|
||||||
|
userId: user.id,
|
||||||
|
));
|
||||||
|
if (assets == null) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
final assetsFromServer = assets.map(Asset.remote).toList();
|
||||||
|
|
||||||
|
await db.txn(() async {
|
||||||
|
final assetsInDb =
|
||||||
|
await assetRepo.getForHashes(assetsFromServer.map((a) => a.hash));
|
||||||
|
|
||||||
|
await upsertAssetsToDb(
|
||||||
|
assetsFromServer,
|
||||||
|
assetsInDb,
|
||||||
|
isRemoteSync: true,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
lastAssetId = assets.lastOrNull?.id;
|
||||||
|
if (assets.length != chunkSize) break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (e, s) {
|
||||||
|
log.e("Error performing full remote sync for user - ${user.name}", e, s);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> upsertAssetsToDb(
|
||||||
|
List<Asset> newAssets,
|
||||||
|
List<Asset> existingAssets, {
|
||||||
|
bool? isRemoteSync,
|
||||||
|
Comparator<Asset> compare = Asset.compareByHash,
|
||||||
|
}) async {
|
||||||
|
final (toAdd, toUpdate, toRemove) = await _diffAssets(
|
||||||
newAssets,
|
newAssets,
|
||||||
existingAssets,
|
existingAssets,
|
||||||
compare: compare,
|
compare: compare,
|
||||||
@ -88,37 +100,36 @@ class AssetSyncService with LogMixin {
|
|||||||
final assetsToAdd = toAdd.followedBy(toUpdate);
|
final assetsToAdd = toAdd.followedBy(toUpdate);
|
||||||
|
|
||||||
await di<IAssetRepository>().upsertAll(assetsToAdd);
|
await di<IAssetRepository>().upsertAll(assetsToAdd);
|
||||||
await di<IAssetRepository>()
|
await di<IAssetRepository>().deleteIds(toRemove.map((a) => a.id!).toList());
|
||||||
.deleteIds(assetsToRemove.map((a) => a.id).toList());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns a triple (toAdd, toUpdate, toRemove)
|
/// Returns a triple (toAdd, toUpdate, toRemove)
|
||||||
(List<Asset>, List<Asset>, List<Asset>) _diffAssets(
|
FutureOr<(List<Asset>, List<Asset>, List<Asset>)> _diffAssets(
|
||||||
List<Asset> newAssets,
|
List<Asset> newAssets,
|
||||||
List<Asset> inDb, {
|
List<Asset> inDb, {
|
||||||
bool? isRemoteSync,
|
bool? isRemoteSync,
|
||||||
required Comparator<Asset> compare,
|
Comparator<Asset> compare = Asset.compareByHash,
|
||||||
}) {
|
}) async {
|
||||||
// fast paths for trivial cases: reduces memory usage during initial sync etc.
|
// fast paths for trivial cases: reduces memory usage during initial sync etc.
|
||||||
if (newAssets.isEmpty && inDb.isEmpty) {
|
if (newAssets.isEmpty && inDb.isEmpty) {
|
||||||
return const ([], [], []);
|
return const (<Asset>[], <Asset>[], <Asset>[]);
|
||||||
} else if (newAssets.isEmpty && isRemoteSync == null) {
|
} else if (newAssets.isEmpty && isRemoteSync == null) {
|
||||||
// remove all from database
|
// remove all from database
|
||||||
return (const [], const [], inDb);
|
return (const <Asset>[], const <Asset>[], inDb);
|
||||||
} else if (inDb.isEmpty) {
|
} else if (inDb.isEmpty) {
|
||||||
// add all assets
|
// add all assets
|
||||||
return (newAssets, const [], const []);
|
return (newAssets, const <Asset>[], const <Asset>[]);
|
||||||
}
|
}
|
||||||
|
|
||||||
final List<Asset> toAdd = [];
|
final List<Asset> toAdd = [];
|
||||||
final List<Asset> toUpdate = [];
|
final List<Asset> toUpdate = [];
|
||||||
final List<Asset> toRemove = [];
|
final List<Asset> toRemove = [];
|
||||||
CollectionUtil.diffSortedLists(
|
await CollectionUtil.diffLists(
|
||||||
inDb,
|
inDb,
|
||||||
newAssets,
|
newAssets,
|
||||||
compare: compare,
|
compare: compare,
|
||||||
both: (Asset a, Asset b) {
|
both: (Asset a, Asset b) {
|
||||||
if (a == b) {
|
if (a != b) {
|
||||||
toUpdate.add(a.merge(b));
|
toUpdate.add(a.merge(b));
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@ -140,7 +151,7 @@ class AssetSyncService with LogMixin {
|
|||||||
toRemove.add(a);
|
toRemove.add(a);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
// Only in remote (new asset)
|
// Only in new assets
|
||||||
onlySecond: (Asset b) => toAdd.add(b),
|
onlySecond: (Asset b) => toAdd.add(b),
|
||||||
);
|
);
|
||||||
return (toAdd, toUpdate, toRemove);
|
return (toAdd, toUpdate, toRemove);
|
||||||
|
149
mobile-v2/lib/domain/services/hash.service.dart
Normal file
149
mobile-v2/lib/domain/services/hash.service.dart
Normal file
@ -0,0 +1,149 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:io';
|
||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:immich_mobile/domain/interfaces/device_album.interface.dart';
|
||||||
|
import 'package:immich_mobile/domain/interfaces/device_asset.interface.dart';
|
||||||
|
import 'package:immich_mobile/domain/interfaces/device_asset_hash.interface.dart';
|
||||||
|
import 'package:immich_mobile/domain/models/asset.model.dart';
|
||||||
|
import 'package:immich_mobile/domain/models/device_asset_hash.model.dart';
|
||||||
|
import 'package:immich_mobile/platform/messages.g.dart';
|
||||||
|
import 'package:immich_mobile/utils/constants/globals.dart';
|
||||||
|
import 'package:immich_mobile/utils/extensions/file.extension.dart';
|
||||||
|
import 'package:immich_mobile/utils/mixins/log.mixin.dart';
|
||||||
|
|
||||||
|
class HashService with LogMixin {
|
||||||
|
final ImHostService _hostService;
|
||||||
|
final IDeviceAssetRepository _deviceAssetRepository;
|
||||||
|
final IDeviceAlbumRepository _deviceAlbumRepository;
|
||||||
|
final IDeviceAssetToHashRepository _assetHashRepository;
|
||||||
|
|
||||||
|
const HashService({
|
||||||
|
required ImHostService hostService,
|
||||||
|
required IDeviceAssetRepository deviceAssetRepo,
|
||||||
|
required IDeviceAlbumRepository deviceAlbumRepo,
|
||||||
|
required IDeviceAssetToHashRepository assetToHashRepo,
|
||||||
|
}) : _hostService = hostService,
|
||||||
|
_deviceAssetRepository = deviceAssetRepo,
|
||||||
|
_deviceAlbumRepository = deviceAlbumRepo,
|
||||||
|
_assetHashRepository = assetToHashRepo;
|
||||||
|
|
||||||
|
Future<List<Asset>> getHashedAssetsForAlbum(
|
||||||
|
String albumId, {
|
||||||
|
DateTime? modifiedUntil,
|
||||||
|
}) async {
|
||||||
|
final assets = await _deviceAlbumRepository.getAssetsForAlbum(
|
||||||
|
albumId,
|
||||||
|
modifiedUntil: modifiedUntil,
|
||||||
|
);
|
||||||
|
assets.sort(Asset.compareByLocalId);
|
||||||
|
|
||||||
|
final assetIds = assets.map((a) => a.localId!);
|
||||||
|
final hashesInDB = await _assetHashRepository.getForIds(assetIds);
|
||||||
|
hashesInDB.sort(DeviceAssetToHash.compareByLocalId);
|
||||||
|
|
||||||
|
final hashedAssets = <Asset>[];
|
||||||
|
final orphanedHashes = <DeviceAssetToHash>[];
|
||||||
|
int bytesToBeProcessed = 0;
|
||||||
|
final filesToBeCleaned = <File>[];
|
||||||
|
final toBeHashed = <_AssetPath>[];
|
||||||
|
|
||||||
|
for (final asset in assets) {
|
||||||
|
if (hashesInDB.isNotEmpty && hashesInDB.first.localId == asset.localId) {
|
||||||
|
final hashed = hashesInDB.removeAt(0);
|
||||||
|
if (hashed.modifiedTime.isAtSameMomentAs(asset.modifiedTime)) {
|
||||||
|
hashedAssets.add(asset.copyWith(hash: hashed.hash));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// localID is matching, but the asset is modified. Discard the DeviceAssetToHash row
|
||||||
|
orphanedHashes.add(hashed);
|
||||||
|
}
|
||||||
|
|
||||||
|
final file = await _deviceAssetRepository.getOriginalFile(asset.localId!);
|
||||||
|
if (file == null) {
|
||||||
|
log.w("Cannot obtain file for localId ${asset.localId!}. Skipping");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
filesToBeCleaned.add(file);
|
||||||
|
|
||||||
|
bytesToBeProcessed += await file.length();
|
||||||
|
toBeHashed.add(_AssetPath(asset: asset, path: file.path));
|
||||||
|
|
||||||
|
if (toBeHashed.length == kHashAssetsFileLimit ||
|
||||||
|
bytesToBeProcessed >= kHashAssetsSizeLimit) {
|
||||||
|
hashedAssets.addAll(await _processAssetBatch(toBeHashed));
|
||||||
|
// Clear file cache
|
||||||
|
await Future.wait(filesToBeCleaned.map((f) => f.deleteDarwinCache()));
|
||||||
|
toBeHashed.clear();
|
||||||
|
filesToBeCleaned.clear();
|
||||||
|
bytesToBeProcessed = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (toBeHashed.isNotEmpty) {
|
||||||
|
hashedAssets.addAll(await _processAssetBatch(toBeHashed));
|
||||||
|
// Clear file cache
|
||||||
|
await Future.wait(filesToBeCleaned.map((f) => f.deleteDarwinCache()));
|
||||||
|
}
|
||||||
|
|
||||||
|
assert(hashesInDB.isEmpty, "All hashes should be processed at this point");
|
||||||
|
_assetHashRepository.deleteIds(orphanedHashes.map((e) => e.id!).toList());
|
||||||
|
|
||||||
|
return hashedAssets;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Processes a batch of files and returns a list of successfully hashed assets after saving
|
||||||
|
/// them in [DeviceAssetToHash] for future retrieval
|
||||||
|
Future<List<Asset>> _processAssetBatch(List<_AssetPath> toBeHashed) async {
|
||||||
|
final hashes = await _hashFiles(toBeHashed.map((e) => e.path).toList());
|
||||||
|
assert(hashes.length == toBeHashed.length,
|
||||||
|
"Number of Hashes returned from platform should be the same as the input");
|
||||||
|
|
||||||
|
final hashedAssets = <Asset>[];
|
||||||
|
|
||||||
|
for (final (index, hash) in hashes.indexed) {
|
||||||
|
// ignore: avoid-unsafe-collection-methods
|
||||||
|
final asset = toBeHashed.elementAt(index).asset;
|
||||||
|
if (hash?.length == 20) {
|
||||||
|
hashedAssets.add(asset.copyWith(hash: base64.encode(hash!)));
|
||||||
|
} else {
|
||||||
|
log.w("Failed to hash file ${asset.localId ?? '<null>'}, skipping");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store the cache for future retrieval
|
||||||
|
_assetHashRepository.upsertAll(hashedAssets.map((a) => DeviceAssetToHash(
|
||||||
|
localId: a.localId!,
|
||||||
|
hash: a.hash,
|
||||||
|
modifiedTime: a.modifiedTime,
|
||||||
|
)));
|
||||||
|
|
||||||
|
log.v("Hashed ${hashedAssets.length}/${toBeHashed.length} assets");
|
||||||
|
return hashedAssets;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Hashes the given files and returns a list of the same length.
|
||||||
|
/// Files that could not be hashed will have a `null` value
|
||||||
|
Future<List<Uint8List?>> _hashFiles(List<String> paths) async {
|
||||||
|
try {
|
||||||
|
final hashes = await _hostService.digestFiles(paths);
|
||||||
|
return hashes;
|
||||||
|
} catch (e, s) {
|
||||||
|
log.e("Error occured while hashing assets", e, s);
|
||||||
|
}
|
||||||
|
|
||||||
|
return paths.map((p) => null).toList();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AssetPath {
|
||||||
|
final Asset asset;
|
||||||
|
final String path;
|
||||||
|
|
||||||
|
const _AssetPath({required this.asset, required this.path});
|
||||||
|
|
||||||
|
_AssetPath copyWith({Asset? asset, String? path}) {
|
||||||
|
return _AssetPath(asset: asset ?? this.asset, path: path ?? this.path);
|
||||||
|
}
|
||||||
|
}
|
@ -15,15 +15,15 @@ import 'package:openapi/api.dart';
|
|||||||
class LoginService with LogMixin {
|
class LoginService with LogMixin {
|
||||||
const LoginService();
|
const LoginService();
|
||||||
|
|
||||||
Future<bool> isEndpointAvailable(Uri uri, {ImmichApiClient? client}) async {
|
Future<bool> isEndpointAvailable(Uri uri, {ImApiClient? client}) async {
|
||||||
String baseUrl = uri.toString();
|
String baseUrl = uri.toString();
|
||||||
|
|
||||||
if (!baseUrl.endsWith('/api')) {
|
if (!baseUrl.endsWith('/api')) {
|
||||||
baseUrl += '/api';
|
baseUrl += '/api';
|
||||||
}
|
}
|
||||||
|
|
||||||
final serverAPI = client?.getServerApi() ??
|
final serverAPI =
|
||||||
ImmichApiClient(endpoint: baseUrl).getServerApi();
|
client?.getServerApi() ?? ImApiClient(endpoint: baseUrl).getServerApi();
|
||||||
try {
|
try {
|
||||||
await serverAPI.pingServer();
|
await serverAPI.pingServer();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@ -35,7 +35,7 @@ class LoginService with LogMixin {
|
|||||||
|
|
||||||
Future<String> resolveEndpoint(Uri uri, {Client? client}) async {
|
Future<String> resolveEndpoint(Uri uri, {Client? client}) async {
|
||||||
String baseUrl = uri.toString();
|
String baseUrl = uri.toString();
|
||||||
final d = client ?? ImmichApiClient(endpoint: baseUrl).client;
|
final d = client ?? ImApiClient(endpoint: baseUrl).client;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Check for well-known endpoint
|
// Check for well-known endpoint
|
||||||
@ -62,7 +62,7 @@ class LoginService with LogMixin {
|
|||||||
Future<String?> passwordLogin(String email, String password) async {
|
Future<String?> passwordLogin(String email, String password) async {
|
||||||
try {
|
try {
|
||||||
final loginResponse =
|
final loginResponse =
|
||||||
await di<ImmichApiClient>().getAuthenticationApi().login(
|
await di<ImApiClient>().getAuthenticationApi().login(
|
||||||
LoginCredentialDto(email: email, password: password),
|
LoginCredentialDto(email: email, password: password),
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -76,7 +76,7 @@ class LoginService with LogMixin {
|
|||||||
Future<String?> oAuthLogin() async {
|
Future<String?> oAuthLogin() async {
|
||||||
const String oAuthCallbackSchema = 'app.immich';
|
const String oAuthCallbackSchema = 'app.immich';
|
||||||
|
|
||||||
final oAuthApi = di<ImmichApiClient>().getOAuthApi();
|
final oAuthApi = di<ImApiClient>().getOAuthApi();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final oAuthUrl = await oAuthApi.startOAuth(
|
final oAuthUrl = await oAuthApi.startOAuth(
|
||||||
@ -125,7 +125,7 @@ class LoginService with LogMixin {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Set token to interceptor
|
/// Set token to interceptor
|
||||||
await di<ImmichApiClient>().init(accessToken: accessToken);
|
await di<ImApiClient>().init(accessToken: accessToken);
|
||||||
|
|
||||||
final user = await di<UserService>().getMyUser().timeout(
|
final user = await di<UserService>().getMyUser().timeout(
|
||||||
const Duration(seconds: 10),
|
const Duration(seconds: 10),
|
||||||
|
21
mobile-v2/lib/domain/utils/drift_model_converters.dart
Normal file
21
mobile-v2/lib/domain/utils/drift_model_converters.dart
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
import 'package:immich_mobile/domain/entities/asset.entity.drift.dart';
|
||||||
|
import 'package:immich_mobile/domain/models/asset.model.dart';
|
||||||
|
|
||||||
|
class DriftModelConverters {
|
||||||
|
static Asset toAssetModel(AssetData asset) {
|
||||||
|
return Asset(
|
||||||
|
id: asset.id,
|
||||||
|
localId: asset.localId,
|
||||||
|
remoteId: asset.remoteId,
|
||||||
|
name: asset.name,
|
||||||
|
type: asset.type,
|
||||||
|
hash: asset.hash,
|
||||||
|
createdTime: asset.createdTime,
|
||||||
|
modifiedTime: asset.modifiedTime,
|
||||||
|
height: asset.height,
|
||||||
|
width: asset.width,
|
||||||
|
livePhotoVideoId: asset.livePhotoVideoId,
|
||||||
|
duration: asset.duration,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
@ -3,6 +3,7 @@ import 'package:immich_mobile/i18n/strings.g.dart';
|
|||||||
import 'package:immich_mobile/immich_app.dart';
|
import 'package:immich_mobile/immich_app.dart';
|
||||||
import 'package:immich_mobile/service_locator.dart';
|
import 'package:immich_mobile/service_locator.dart';
|
||||||
import 'package:immich_mobile/utils/log_manager.dart';
|
import 'package:immich_mobile/utils/log_manager.dart';
|
||||||
|
import 'package:photo_manager/photo_manager.dart';
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
WidgetsFlutterBinding.ensureInitialized();
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
@ -13,6 +14,8 @@ void main() {
|
|||||||
LogManager.setGlobalErrorCallbacks();
|
LogManager.setGlobalErrorCallbacks();
|
||||||
// Init localization
|
// Init localization
|
||||||
LocaleSettings.useDeviceLocale();
|
LocaleSettings.useDeviceLocale();
|
||||||
|
// Clear photo_manager cache
|
||||||
|
PhotoManager.clearFileCache();
|
||||||
|
|
||||||
runApp(const ImApp());
|
runApp(const ImApp());
|
||||||
}
|
}
|
||||||
|
@ -11,7 +11,7 @@ import 'package:pigeon/pigeon.dart';
|
|||||||
swiftOptions: SwiftOptions(),
|
swiftOptions: SwiftOptions(),
|
||||||
))
|
))
|
||||||
@HostApi()
|
@HostApi()
|
||||||
abstract class ImmichHostService {
|
abstract class ImHostService {
|
||||||
@async
|
@async
|
||||||
List<Uint8List?>? digestFiles(List<String> paths);
|
List<Uint8List?> digestFiles(List<String> paths);
|
||||||
}
|
}
|
||||||
|
@ -13,7 +13,7 @@ typedef RenderListAssetProvider = FutureOr<List<Asset>> Function({
|
|||||||
int? limit,
|
int? limit,
|
||||||
});
|
});
|
||||||
|
|
||||||
class ImmichAssetGridCubit extends Cubit<RenderList> {
|
class AssetGridCubit extends Cubit<RenderList> {
|
||||||
final Stream<RenderList> _renderStream;
|
final Stream<RenderList> _renderStream;
|
||||||
final RenderListAssetProvider _assetProvider;
|
final RenderListAssetProvider _assetProvider;
|
||||||
late final StreamSubscription _renderListSubscription;
|
late final StreamSubscription _renderListSubscription;
|
||||||
@ -24,7 +24,7 @@ class ImmichAssetGridCubit extends Cubit<RenderList> {
|
|||||||
/// assets cache loaded from DB with offset [_bufOffset]
|
/// assets cache loaded from DB with offset [_bufOffset]
|
||||||
List<Asset> _buf = [];
|
List<Asset> _buf = [];
|
||||||
|
|
||||||
ImmichAssetGridCubit({
|
AssetGridCubit({
|
||||||
required Stream<RenderList> renderStream,
|
required Stream<RenderList> renderStream,
|
||||||
required RenderListAssetProvider assetProvider,
|
required RenderListAssetProvider assetProvider,
|
||||||
}) : _renderStream = renderStream,
|
}) : _renderStream = renderStream,
|
||||||
|
@ -6,14 +6,13 @@ import 'package:immich_mobile/domain/models/render_list_element.model.dart';
|
|||||||
import 'package:immich_mobile/presentation/components/grid/draggable_scrollbar.dart';
|
import 'package:immich_mobile/presentation/components/grid/draggable_scrollbar.dart';
|
||||||
import 'package:immich_mobile/presentation/components/grid/immich_asset_grid.state.dart';
|
import 'package:immich_mobile/presentation/components/grid/immich_asset_grid.state.dart';
|
||||||
import 'package:immich_mobile/presentation/components/image/immich_image.widget.dart';
|
import 'package:immich_mobile/presentation/components/image/immich_image.widget.dart';
|
||||||
|
import 'package:immich_mobile/presentation/components/image/immich_thumbnail.widget.dart';
|
||||||
import 'package:immich_mobile/utils/extensions/async_snapshot.extension.dart';
|
import 'package:immich_mobile/utils/extensions/async_snapshot.extension.dart';
|
||||||
import 'package:immich_mobile/utils/extensions/build_context.extension.dart';
|
import 'package:immich_mobile/utils/extensions/build_context.extension.dart';
|
||||||
import 'package:immich_mobile/utils/extensions/color.extension.dart';
|
|
||||||
import 'package:intl/intl.dart';
|
import 'package:intl/intl.dart';
|
||||||
import 'package:material_symbols_icons/symbols.dart';
|
import 'package:material_symbols_icons/symbols.dart';
|
||||||
|
|
||||||
part 'immich_asset_grid_header.widget.dart';
|
part 'immich_asset_grid_header.widget.dart';
|
||||||
part 'immich_grid_asset_placeholder.widget.dart';
|
|
||||||
|
|
||||||
class ImAssetGrid extends StatefulWidget {
|
class ImAssetGrid extends StatefulWidget {
|
||||||
const ImAssetGrid({super.key});
|
const ImAssetGrid({super.key});
|
||||||
@ -56,8 +55,7 @@ class _ImAssetGridState extends State<ImAssetGrid> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) =>
|
Widget build(BuildContext context) => BlocBuilder<AssetGridCubit, RenderList>(
|
||||||
BlocBuilder<ImmichAssetGridCubit, RenderList>(
|
|
||||||
builder: (_, renderList) {
|
builder: (_, renderList) {
|
||||||
final elements = renderList.elements;
|
final elements = renderList.elements;
|
||||||
final grid = FlutterListView(
|
final grid = FlutterListView(
|
||||||
@ -72,7 +70,7 @@ class _ImAssetGridState extends State<ImAssetGrid> {
|
|||||||
_MonthHeader(text: section.header),
|
_MonthHeader(text: section.header),
|
||||||
RenderListDayHeaderElement() => Text(section.header),
|
RenderListDayHeaderElement() => Text(section.header),
|
||||||
RenderListAssetElement() => FutureBuilder(
|
RenderListAssetElement() => FutureBuilder(
|
||||||
future: context.read<ImmichAssetGridCubit>().loadAssets(
|
future: context.read<AssetGridCubit>().loadAssets(
|
||||||
section.assetOffset,
|
section.assetOffset,
|
||||||
section.assetCount,
|
section.assetCount,
|
||||||
),
|
),
|
||||||
@ -83,6 +81,7 @@ class _ImAssetGridState extends State<ImAssetGrid> {
|
|||||||
shrinkWrap: true,
|
shrinkWrap: true,
|
||||||
addAutomaticKeepAlives: false,
|
addAutomaticKeepAlives: false,
|
||||||
cacheExtent: 100,
|
cacheExtent: 100,
|
||||||
|
padding: const EdgeInsets.all(0),
|
||||||
gridDelegate:
|
gridDelegate:
|
||||||
const SliverGridDelegateWithFixedCrossAxisCount(
|
const SliverGridDelegateWithFixedCrossAxisCount(
|
||||||
crossAxisCount: 4,
|
crossAxisCount: 4,
|
||||||
@ -97,8 +96,8 @@ class _ImAssetGridState extends State<ImAssetGrid> {
|
|||||||
dimension: 200,
|
dimension: 200,
|
||||||
// Show Placeholder when drag scrolled
|
// Show Placeholder when drag scrolled
|
||||||
child: asset == null || _isDragScrolling
|
child: asset == null || _isDragScrolling
|
||||||
? const _ImImagePlaceholder()
|
? const ImImagePlaceholder()
|
||||||
: ImImage(asset),
|
: ImThumbnail(asset),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
itemCount: section.assetCount,
|
itemCount: section.assetCount,
|
||||||
|
@ -9,7 +9,12 @@ class _HeaderText extends StatelessWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.only(top: 32.0, left: 16.0, right: 24.0),
|
padding: const EdgeInsets.only(
|
||||||
|
top: 32.0,
|
||||||
|
left: 16.0,
|
||||||
|
right: 24.0,
|
||||||
|
bottom: 16.0,
|
||||||
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
crossAxisAlignment: CrossAxisAlignment.center,
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
|
@ -1,25 +0,0 @@
|
|||||||
part of 'immich_asset_grid.widget.dart';
|
|
||||||
|
|
||||||
class _ImImagePlaceholder extends StatelessWidget {
|
|
||||||
const _ImImagePlaceholder();
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
var gradientColors = [
|
|
||||||
context.colorScheme.surfaceContainer,
|
|
||||||
context.colorScheme.surfaceContainer.darken(amount: .1),
|
|
||||||
];
|
|
||||||
|
|
||||||
return Container(
|
|
||||||
width: 200,
|
|
||||||
height: 200,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
gradient: LinearGradient(
|
|
||||||
colors: gradientColors,
|
|
||||||
begin: Alignment.topCenter,
|
|
||||||
end: Alignment.bottomCenter,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
40
mobile-v2/lib/presentation/components/image/cache/cache_manager.dart
vendored
Normal file
40
mobile-v2/lib/presentation/components/image/cache/cache_manager.dart
vendored
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
|
||||||
|
import 'package:immich_mobile/utils/constants/globals.dart';
|
||||||
|
|
||||||
|
/// The cache manager for thumbnail images [ImRemoteThumbnailProvider]
|
||||||
|
class ImRemoteThumbnailCacheManager extends CacheManager {
|
||||||
|
static final ImRemoteThumbnailCacheManager _instance =
|
||||||
|
ImRemoteThumbnailCacheManager._();
|
||||||
|
|
||||||
|
factory ImRemoteThumbnailCacheManager() {
|
||||||
|
return _instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
ImRemoteThumbnailCacheManager._()
|
||||||
|
: super(
|
||||||
|
Config(
|
||||||
|
kCacheThumbnailsKey,
|
||||||
|
maxNrOfCacheObjects: kCacheMaxNrOfThumbnails,
|
||||||
|
stalePeriod: const Duration(days: kCacheStalePeriod),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The cache manager for full size images [ImRemoteImageProvider]
|
||||||
|
class ImRemoteImageCacheManager extends CacheManager {
|
||||||
|
static final ImRemoteImageCacheManager _instance =
|
||||||
|
ImRemoteImageCacheManager._();
|
||||||
|
|
||||||
|
factory ImRemoteImageCacheManager() {
|
||||||
|
return _instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
ImRemoteImageCacheManager._()
|
||||||
|
: super(
|
||||||
|
Config(
|
||||||
|
kCacheFullImagesKey,
|
||||||
|
maxNrOfCacheObjects: kCacheMaxNrOfFullImages,
|
||||||
|
stalePeriod: const Duration(days: kCacheStalePeriod),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
55
mobile-v2/lib/presentation/components/image/cache/image_loader.dart
vendored
Normal file
55
mobile-v2/lib/presentation/components/image/cache/image_loader.dart
vendored
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'dart:ui' as ui;
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
|
||||||
|
import 'package:immich_mobile/service_locator.dart';
|
||||||
|
import 'package:immich_mobile/utils/immich_api_client.dart';
|
||||||
|
|
||||||
|
/// An exception for the [ImageLoader] and the Immich image providers
|
||||||
|
class ImageLoadingException implements Exception {
|
||||||
|
final String message;
|
||||||
|
const ImageLoadingException(this.message);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() => 'ImageLoadingException: $message';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Loads the codec from the URI and sends the events to the [chunkEvents] stream
|
||||||
|
///
|
||||||
|
/// Credit to [flutter_cached_network_image](https://github.com/Baseflow/flutter_cached_network_image/blob/develop/cached_network_image/lib/src/image_provider/_image_loader.dart)
|
||||||
|
/// for this wonderful implementation of their image loader
|
||||||
|
class ImageLoader {
|
||||||
|
static Future<ui.Codec> loadImageFromCache(
|
||||||
|
String uri, {
|
||||||
|
required CacheManager cache,
|
||||||
|
required ImageDecoderCallback decode,
|
||||||
|
StreamController<ImageChunkEvent>? chunkEvents,
|
||||||
|
}) async {
|
||||||
|
final stream = cache.getFileStream(
|
||||||
|
uri,
|
||||||
|
withProgress: chunkEvents != null,
|
||||||
|
headers: di<ImApiClient>().headers,
|
||||||
|
);
|
||||||
|
|
||||||
|
await for (final result in stream) {
|
||||||
|
if (result is DownloadProgress) {
|
||||||
|
// We are downloading the file, so update the [chunkEvents]
|
||||||
|
chunkEvents?.add(
|
||||||
|
ImageChunkEvent(
|
||||||
|
cumulativeBytesLoaded: result.downloaded,
|
||||||
|
expectedTotalBytes: result.totalSize,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} else if (result is FileInfo) {
|
||||||
|
// We have the file
|
||||||
|
final buffer = await ui.ImmutableBuffer.fromFilePath(result.file.path);
|
||||||
|
final decoded = await decode(buffer);
|
||||||
|
return decoded;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we get here, the image failed to load from the cache stream
|
||||||
|
throw const ImageLoadingException('Could not load image from stream');
|
||||||
|
}
|
||||||
|
}
|
@ -1,48 +1,89 @@
|
|||||||
import 'package:cached_network_image/cached_network_image.dart';
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:immich_mobile/domain/models/asset.model.dart';
|
import 'package:immich_mobile/domain/models/asset.model.dart';
|
||||||
import 'package:immich_mobile/service_locator.dart';
|
import 'package:immich_mobile/presentation/components/image/provider/immich_local_image_provider.dart';
|
||||||
import 'package:immich_mobile/utils/immich_api_client.dart';
|
import 'package:immich_mobile/presentation/components/image/provider/immich_remote_image_provider.dart';
|
||||||
import 'package:immich_mobile/utils/immich_image_url_helper.dart';
|
import 'package:immich_mobile/utils/extensions/build_context.extension.dart';
|
||||||
import 'package:material_symbols_icons/symbols.dart';
|
import 'package:octo_image/octo_image.dart';
|
||||||
|
|
||||||
|
class ImImagePlaceholder extends StatelessWidget {
|
||||||
|
const ImImagePlaceholder({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Container(
|
||||||
|
width: 200,
|
||||||
|
height: 200,
|
||||||
|
color: context.colorScheme.surfaceContainerHighest,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ignore: prefer-single-widget-per-file
|
||||||
class ImImage extends StatelessWidget {
|
class ImImage extends StatelessWidget {
|
||||||
final Asset asset;
|
final Asset asset;
|
||||||
final double? width;
|
final double? width;
|
||||||
final double? height;
|
final double? height;
|
||||||
|
final Widget placeholder;
|
||||||
|
|
||||||
const ImImage(this.asset, {this.width, this.height, super.key});
|
const ImImage(
|
||||||
|
this.asset, {
|
||||||
|
this.width,
|
||||||
|
this.height,
|
||||||
|
this.placeholder = const ImImagePlaceholder(),
|
||||||
|
super.key,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Helper function to return the image provider for the asset
|
||||||
|
// either by using the asset ID or the asset itself
|
||||||
|
/// [asset] is the Asset to request, or else use [assetId] to get a remote
|
||||||
|
/// image provider
|
||||||
|
/// Use [isThumbnail] and [thumbnailSize] if you'd like to request a thumbnail
|
||||||
|
/// The size of the square thumbnail to request. Ignored if isThumbnail
|
||||||
|
/// is not true
|
||||||
|
static ImageProvider imageProvider({Asset? asset, String? assetId}) {
|
||||||
|
if (asset == null && assetId == null) {
|
||||||
|
throw Exception('Must supply either asset or assetId');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (asset == null) {
|
||||||
|
return ImRemoteImageProvider(assetId: assetId!);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Whether to use the local asset image provider or a remote one
|
||||||
|
final useLocal = !asset.isRemote || asset.isLocal;
|
||||||
|
|
||||||
|
if (useLocal) {
|
||||||
|
return ImLocalImageProvider(asset: asset);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ImRemoteImageProvider(assetId: asset.remoteId!);
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return CachedNetworkImage(
|
return OctoImage(
|
||||||
imageUrl: ImImageUrlHelper.getThumbnailUrl(asset),
|
fadeInDuration: const Duration(milliseconds: 0),
|
||||||
httpHeaders: di<ImmichApiClient>().headers,
|
fadeOutDuration: const Duration(milliseconds: 200),
|
||||||
cacheKey: ImImageUrlHelper.getThumbnailUrl(asset),
|
placeholderBuilder: (_) => placeholder,
|
||||||
|
image: ImImage.imageProvider(asset: asset),
|
||||||
width: width,
|
width: width,
|
||||||
height: height,
|
height: height,
|
||||||
// keeping memCacheWidth, memCacheHeight, maxWidthDiskCache and
|
|
||||||
// maxHeightDiskCache = null allows to simply store the webp thumbnail
|
|
||||||
// from the server and use it for all rendered thumbnail sizes
|
|
||||||
fit: BoxFit.cover,
|
fit: BoxFit.cover,
|
||||||
fadeInDuration: const Duration(milliseconds: 250),
|
errorBuilder: (_, error, stackTrace) {
|
||||||
progressIndicatorBuilder: (_, url, downloadProgress) {
|
if (error is PlatformException &&
|
||||||
// Show loading if desired
|
error.code == "The asset not found!") {
|
||||||
return const SizedBox.square(
|
debugPrint(
|
||||||
dimension: 250,
|
"Asset ${asset.localId ?? asset.id ?? "-"} does not exist anymore on device!",
|
||||||
child: DecoratedBox(decoration: BoxDecoration(color: Colors.grey)),
|
);
|
||||||
);
|
} else {
|
||||||
},
|
debugPrint(
|
||||||
errorWidget: (_, url, error) {
|
"Error getting thumb for assetId=${asset.localId ?? asset.id ?? "-"}: $error",
|
||||||
if (error is HttpExceptionWithStatus &&
|
);
|
||||||
error.statusCode >= 400 &&
|
|
||||||
error.statusCode < 500) {
|
|
||||||
CachedNetworkImage.evictFromCache(url);
|
|
||||||
}
|
}
|
||||||
return Icon(
|
return Icon(
|
||||||
Symbols.image_not_supported_rounded,
|
Icons.image_not_supported_outlined,
|
||||||
color: Theme.of(context).primaryColor,
|
color: context.colorScheme.primary,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
@ -27,6 +27,7 @@ class ImLogo extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ignore: prefer-single-widget-per-file
|
||||||
class ImLogoText extends StatelessWidget {
|
class ImLogoText extends StatelessWidget {
|
||||||
const ImLogoText({
|
const ImLogoText({
|
||||||
super.key,
|
super.key,
|
||||||
|
@ -0,0 +1,87 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:immich_mobile/domain/models/asset.model.dart';
|
||||||
|
import 'package:immich_mobile/presentation/components/image/immich_image.widget.dart';
|
||||||
|
import 'package:material_symbols_icons/symbols.dart';
|
||||||
|
|
||||||
|
IconData _getStorageIcon(Asset asset) {
|
||||||
|
if (asset.isMerged) {
|
||||||
|
return Symbols.cloud_done_rounded;
|
||||||
|
} else if (asset.isRemote) {
|
||||||
|
return Symbols.cloud_rounded;
|
||||||
|
}
|
||||||
|
return Symbols.cloud_off_rounded;
|
||||||
|
}
|
||||||
|
|
||||||
|
class ImThumbnail extends StatelessWidget {
|
||||||
|
final Asset asset;
|
||||||
|
final double? width;
|
||||||
|
final double? height;
|
||||||
|
|
||||||
|
const ImThumbnail(this.asset, {this.width, this.height, super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Stack(
|
||||||
|
children: [
|
||||||
|
Positioned.fill(child: ImImage(asset, width: width, height: height)),
|
||||||
|
_PadAlignedIcon(
|
||||||
|
alignment: Alignment.bottomRight,
|
||||||
|
icon: _getStorageIcon(asset),
|
||||||
|
),
|
||||||
|
if (!asset.isImage)
|
||||||
|
const _PadAlignedIcon(
|
||||||
|
alignment: Alignment.topLeft,
|
||||||
|
icon: Symbols.play_circle_rounded,
|
||||||
|
filled: true,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _PadAlignedIcon extends StatelessWidget {
|
||||||
|
final Alignment alignment;
|
||||||
|
final IconData icon;
|
||||||
|
final bool? filled;
|
||||||
|
|
||||||
|
const _PadAlignedIcon({
|
||||||
|
required this.alignment,
|
||||||
|
required this.icon,
|
||||||
|
this.filled,
|
||||||
|
});
|
||||||
|
|
||||||
|
double _calculateLeft(Alignment align) {
|
||||||
|
return align.x == -1 ? 5 : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
double _calculateTop(Alignment align) {
|
||||||
|
return align.y == -1 ? 4 : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
double _calculateRight(Alignment align) {
|
||||||
|
return align.x == 1 ? 5 : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
double _calculateBottom(Alignment align) {
|
||||||
|
return align.y == 1 ? 4 : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Positioned(
|
||||||
|
left: _calculateLeft(alignment),
|
||||||
|
top: _calculateTop(alignment),
|
||||||
|
right: _calculateRight(alignment),
|
||||||
|
bottom: _calculateBottom(alignment),
|
||||||
|
child: Align(
|
||||||
|
alignment: alignment,
|
||||||
|
child: Icon(
|
||||||
|
icon,
|
||||||
|
color: Colors.white,
|
||||||
|
size: 20,
|
||||||
|
fill: (filled != null && filled!) ? 1 : null,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,109 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'dart:io';
|
||||||
|
import 'dart:ui' as ui;
|
||||||
|
|
||||||
|
import 'package:cached_network_image/cached_network_image.dart';
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:flutter/painting.dart';
|
||||||
|
import 'package:immich_mobile/domain/interfaces/device_asset.interface.dart';
|
||||||
|
import 'package:immich_mobile/domain/models/asset.model.dart';
|
||||||
|
import 'package:immich_mobile/service_locator.dart';
|
||||||
|
import 'package:immich_mobile/utils/extensions/file.extension.dart';
|
||||||
|
|
||||||
|
/// The local image provider for an asset
|
||||||
|
class ImLocalImageProvider extends ImageProvider<ImLocalImageProvider> {
|
||||||
|
final Asset asset;
|
||||||
|
|
||||||
|
ImLocalImageProvider({required this.asset})
|
||||||
|
: assert(asset.localId != null, 'Only usable when asset.local is set');
|
||||||
|
|
||||||
|
/// Converts an [ImageProvider]'s settings plus an [ImageConfiguration] to a key
|
||||||
|
/// that describes the precise image to load.
|
||||||
|
@override
|
||||||
|
Future<ImLocalImageProvider> obtainKey(ImageConfiguration configuration) {
|
||||||
|
return SynchronousFuture(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
ImageStreamCompleter loadImage(
|
||||||
|
ImLocalImageProvider key,
|
||||||
|
ImageDecoderCallback decode,
|
||||||
|
) {
|
||||||
|
final chunkEvents = StreamController<ImageChunkEvent>();
|
||||||
|
return MultiImageStreamCompleter(
|
||||||
|
codec: _codec(key.asset, decode, chunkEvents),
|
||||||
|
scale: 1.0,
|
||||||
|
chunkEvents: chunkEvents.stream,
|
||||||
|
informationCollector: () sync* {
|
||||||
|
yield ErrorDescription(asset.name);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Streams in each stage of the image as we ask for it
|
||||||
|
Stream<ui.Codec> _codec(
|
||||||
|
Asset a,
|
||||||
|
ImageDecoderCallback decode,
|
||||||
|
StreamController<ImageChunkEvent> chunkEvents,
|
||||||
|
) async* {
|
||||||
|
// Load a small thumbnail
|
||||||
|
final thumbBytes =
|
||||||
|
await di<IDeviceAssetRepository>().getThumbnail(a.localId!);
|
||||||
|
if (thumbBytes != null) {
|
||||||
|
final buffer = await ui.ImmutableBuffer.fromUint8List(thumbBytes);
|
||||||
|
final codec = await decode(buffer);
|
||||||
|
yield codec;
|
||||||
|
} else {
|
||||||
|
debugPrint("Loading thumb for ${a.name} failed");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (asset.isImage) {
|
||||||
|
/// Using 2K thumbnail for local iOS image to avoid double swiping issue
|
||||||
|
if (Platform.isIOS) {
|
||||||
|
final largeImageBytes = await di<IDeviceAssetRepository>()
|
||||||
|
.getThumbnail(a.localId!, width: 3840, height: 2160);
|
||||||
|
|
||||||
|
if (largeImageBytes == null) {
|
||||||
|
throw StateError("Loading thumb for local photo ${a.name} failed");
|
||||||
|
}
|
||||||
|
final buffer = await ui.ImmutableBuffer.fromUint8List(largeImageBytes);
|
||||||
|
final codec = await decode(buffer);
|
||||||
|
yield codec;
|
||||||
|
} else {
|
||||||
|
// Use the original file for Android
|
||||||
|
final File? file =
|
||||||
|
await di<IDeviceAssetRepository>().getOriginalFile(a.localId!);
|
||||||
|
if (file == null) {
|
||||||
|
throw StateError("Opening file for asset ${a.name} failed");
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
final buffer = await ui.ImmutableBuffer.fromFilePath(file.path);
|
||||||
|
final codec = await decode(buffer);
|
||||||
|
yield codec;
|
||||||
|
} catch (error, stack) {
|
||||||
|
Error.throwWithStackTrace(
|
||||||
|
StateError("Loading asset ${a.name} failed"),
|
||||||
|
stack,
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
await file.deleteDarwinCache();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await chunkEvents.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) {
|
||||||
|
if (identical(this, other)) return true;
|
||||||
|
if (other is ImLocalImageProvider) {
|
||||||
|
return asset == other.asset;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get hashCode => asset.hashCode;
|
||||||
|
}
|
@ -0,0 +1,88 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'dart:ui' as ui;
|
||||||
|
|
||||||
|
import 'package:cached_network_image/cached_network_image.dart';
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:flutter/painting.dart';
|
||||||
|
import 'package:immich_mobile/domain/interfaces/device_asset.interface.dart';
|
||||||
|
import 'package:immich_mobile/domain/models/asset.model.dart';
|
||||||
|
import 'package:immich_mobile/service_locator.dart';
|
||||||
|
import 'package:immich_mobile/utils/constants/globals.dart';
|
||||||
|
|
||||||
|
/// The local image provider for an asset
|
||||||
|
/// Only viable
|
||||||
|
class ImLocalThumbnailProvider extends ImageProvider<ImLocalThumbnailProvider> {
|
||||||
|
final Asset asset;
|
||||||
|
final int height;
|
||||||
|
final int width;
|
||||||
|
|
||||||
|
ImLocalThumbnailProvider({
|
||||||
|
required this.asset,
|
||||||
|
this.height = kGridThumbnailSize,
|
||||||
|
this.width = kGridThumbnailSize,
|
||||||
|
}) : assert(asset.localId != null, 'Only usable when asset.local is set');
|
||||||
|
|
||||||
|
/// Converts an [ImageProvider]'s settings plus an [ImageConfiguration] to a key
|
||||||
|
/// that describes the precise image to load.
|
||||||
|
@override
|
||||||
|
Future<ImLocalThumbnailProvider> obtainKey(
|
||||||
|
ImageConfiguration configuration,
|
||||||
|
) {
|
||||||
|
return SynchronousFuture(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
ImageStreamCompleter loadImage(
|
||||||
|
ImLocalThumbnailProvider key,
|
||||||
|
ImageDecoderCallback decode,
|
||||||
|
) {
|
||||||
|
final chunkEvents = StreamController<ImageChunkEvent>();
|
||||||
|
return MultiImageStreamCompleter(
|
||||||
|
codec: _codec(key.asset, decode, chunkEvents),
|
||||||
|
scale: 1.0,
|
||||||
|
chunkEvents: chunkEvents.stream,
|
||||||
|
informationCollector: () sync* {
|
||||||
|
yield ErrorDescription(asset.name);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Streams in each stage of the image as we ask for it
|
||||||
|
Stream<ui.Codec> _codec(
|
||||||
|
Asset a,
|
||||||
|
ImageDecoderCallback decode,
|
||||||
|
StreamController<ImageChunkEvent> chunkEvents,
|
||||||
|
) async* {
|
||||||
|
// Load a small thumbnail
|
||||||
|
final thumbBytes = await di<IDeviceAssetRepository>()
|
||||||
|
.getThumbnail(a.localId!, width: 32, height: 32, quality: 75);
|
||||||
|
if (thumbBytes != null) {
|
||||||
|
final buffer = await ui.ImmutableBuffer.fromUint8List(thumbBytes);
|
||||||
|
final codec = await decode(buffer);
|
||||||
|
yield codec;
|
||||||
|
} else {
|
||||||
|
debugPrint("Loading thumb for ${a.name} failed");
|
||||||
|
}
|
||||||
|
|
||||||
|
final normalThumbBytes = await di<IDeviceAssetRepository>()
|
||||||
|
.getThumbnail(a.localId!, width: width, height: height);
|
||||||
|
if (normalThumbBytes == null) {
|
||||||
|
throw StateError("Loading thumb for local photo ${a.name} failed");
|
||||||
|
}
|
||||||
|
final buffer = await ui.ImmutableBuffer.fromUint8List(normalThumbBytes);
|
||||||
|
final codec = await decode(buffer);
|
||||||
|
yield codec;
|
||||||
|
|
||||||
|
await chunkEvents.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) {
|
||||||
|
if (other is! ImLocalThumbnailProvider) return false;
|
||||||
|
if (identical(this, other)) return true;
|
||||||
|
return asset == other.asset;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get hashCode => asset.hashCode;
|
||||||
|
}
|
@ -0,0 +1,88 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'dart:ui' as ui;
|
||||||
|
|
||||||
|
import 'package:cached_network_image/cached_network_image.dart';
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:flutter/painting.dart';
|
||||||
|
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
|
||||||
|
import 'package:immich_mobile/presentation/components/image/cache/cache_manager.dart';
|
||||||
|
import 'package:immich_mobile/presentation/components/image/cache/image_loader.dart';
|
||||||
|
import 'package:immich_mobile/utils/immich_image_url_helper.dart';
|
||||||
|
|
||||||
|
/// The remote image provider for full size remote images
|
||||||
|
class ImRemoteImageProvider extends ImageProvider<ImRemoteImageProvider> {
|
||||||
|
/// The [Asset.remoteId] of the asset to fetch
|
||||||
|
final String assetId;
|
||||||
|
|
||||||
|
/// The image cache manager
|
||||||
|
final CacheManager? cacheManager;
|
||||||
|
|
||||||
|
const ImRemoteImageProvider({required this.assetId, this.cacheManager});
|
||||||
|
|
||||||
|
/// Converts an [ImageProvider]'s settings plus an [ImageConfiguration] to a key
|
||||||
|
/// that describes the precise image to load.
|
||||||
|
@override
|
||||||
|
Future<ImRemoteImageProvider> obtainKey(ImageConfiguration configuration) {
|
||||||
|
return SynchronousFuture(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
ImageStreamCompleter loadImage(
|
||||||
|
ImRemoteImageProvider key,
|
||||||
|
ImageDecoderCallback decode,
|
||||||
|
) {
|
||||||
|
final cache = cacheManager ?? ImRemoteImageCacheManager();
|
||||||
|
final chunkEvents = StreamController<ImageChunkEvent>();
|
||||||
|
return MultiImageStreamCompleter(
|
||||||
|
codec: _codec(key, cache, decode, chunkEvents),
|
||||||
|
scale: 1.0,
|
||||||
|
chunkEvents: chunkEvents.stream,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Streams in each stage of the image as we ask for it
|
||||||
|
Stream<ui.Codec> _codec(
|
||||||
|
ImRemoteImageProvider key,
|
||||||
|
CacheManager cache,
|
||||||
|
ImageDecoderCallback decode,
|
||||||
|
StreamController<ImageChunkEvent> chunkEvents,
|
||||||
|
) async* {
|
||||||
|
// Load a preview to the chunk events
|
||||||
|
final preview = ImImageUrlHelper.getThumbnailUrlForRemoteId(key.assetId);
|
||||||
|
|
||||||
|
yield await ImageLoader.loadImageFromCache(
|
||||||
|
preview,
|
||||||
|
cache: cache,
|
||||||
|
decode: decode,
|
||||||
|
chunkEvents: chunkEvents,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Load the higher resolution version of the image
|
||||||
|
final url = ImImageUrlHelper.getThumbnailUrlForRemoteId(
|
||||||
|
key.assetId,
|
||||||
|
type: AssetMediaSize.preview,
|
||||||
|
);
|
||||||
|
final codec = await ImageLoader.loadImageFromCache(
|
||||||
|
url,
|
||||||
|
cache: cache,
|
||||||
|
decode: decode,
|
||||||
|
chunkEvents: chunkEvents,
|
||||||
|
);
|
||||||
|
yield codec;
|
||||||
|
|
||||||
|
await chunkEvents.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) {
|
||||||
|
if (identical(this, other)) return true;
|
||||||
|
if (other is ImRemoteImageProvider) {
|
||||||
|
return assetId == other.assetId;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get hashCode => assetId.hashCode;
|
||||||
|
}
|
@ -0,0 +1,80 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'dart:ui' as ui;
|
||||||
|
|
||||||
|
import 'package:cached_network_image/cached_network_image.dart';
|
||||||
|
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
|
||||||
|
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:flutter/painting.dart';
|
||||||
|
import 'package:immich_mobile/presentation/components/image/cache/cache_manager.dart';
|
||||||
|
import 'package:immich_mobile/presentation/components/image/cache/image_loader.dart';
|
||||||
|
import 'package:immich_mobile/utils/immich_image_url_helper.dart';
|
||||||
|
|
||||||
|
/// The remote image provider
|
||||||
|
class ImmichRemoteThumbnailProvider
|
||||||
|
extends ImageProvider<ImmichRemoteThumbnailProvider> {
|
||||||
|
/// The [Asset.remoteId] of the asset to fetch
|
||||||
|
final String assetId;
|
||||||
|
|
||||||
|
final int? height;
|
||||||
|
final int? width;
|
||||||
|
|
||||||
|
/// The image cache manager
|
||||||
|
final CacheManager? cacheManager;
|
||||||
|
|
||||||
|
const ImmichRemoteThumbnailProvider({
|
||||||
|
required this.assetId,
|
||||||
|
this.height,
|
||||||
|
this.width,
|
||||||
|
this.cacheManager,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Converts an [ImageProvider]'s settings plus an [ImageConfiguration] to a key
|
||||||
|
/// that describes the precise image to load.
|
||||||
|
@override
|
||||||
|
Future<ImmichRemoteThumbnailProvider> obtainKey(
|
||||||
|
ImageConfiguration configuration,
|
||||||
|
) {
|
||||||
|
return SynchronousFuture(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
ImageStreamCompleter loadImage(
|
||||||
|
ImmichRemoteThumbnailProvider key,
|
||||||
|
ImageDecoderCallback decode,
|
||||||
|
) {
|
||||||
|
final cache = cacheManager ?? ImRemoteThumbnailCacheManager();
|
||||||
|
return MultiImageStreamCompleter(
|
||||||
|
codec: _codec(key, cache, decode),
|
||||||
|
scale: 1.0,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Streams in each stage of the image as we ask for it
|
||||||
|
Stream<ui.Codec> _codec(
|
||||||
|
ImmichRemoteThumbnailProvider key,
|
||||||
|
CacheManager cache,
|
||||||
|
ImageDecoderCallback decode,
|
||||||
|
) async* {
|
||||||
|
final preview = ImImageUrlHelper.getThumbnailUrlForRemoteId(key.assetId);
|
||||||
|
|
||||||
|
yield await ImageLoader.loadImageFromCache(
|
||||||
|
preview,
|
||||||
|
cache: cache,
|
||||||
|
decode: decode,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) {
|
||||||
|
if (identical(this, other)) return true;
|
||||||
|
if (other is ImmichRemoteThumbnailProvider) {
|
||||||
|
return assetId == other.assetId;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get hashCode => assetId.hashCode;
|
||||||
|
}
|
@ -17,6 +17,7 @@ class ImAdaptiveRoutePrimaryAppBar extends StatelessWidget
|
|||||||
Size get preferredSize => const Size.fromHeight(kToolbarHeight);
|
Size get preferredSize => const Size.fromHeight(kToolbarHeight);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ignore: prefer-single-widget-per-file
|
||||||
class ImAdaptiveRouteSecondaryAppBar extends StatelessWidget
|
class ImAdaptiveRouteSecondaryAppBar extends StatelessWidget
|
||||||
implements PreferredSizeWidget {
|
implements PreferredSizeWidget {
|
||||||
const ImAdaptiveRouteSecondaryAppBar({super.key});
|
const ImAdaptiveRouteSecondaryAppBar({super.key});
|
||||||
|
@ -15,7 +15,7 @@ class HomePage extends StatelessWidget {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
body: BlocProvider(
|
body: BlocProvider(
|
||||||
create: (_) => ImmichAssetGridCubit(
|
create: (_) => AssetGridCubit(
|
||||||
renderStream: di<IRenderListRepository>().watchAll(),
|
renderStream: di<IRenderListRepository>().watchAll(),
|
||||||
assetProvider: di<IAssetRepository>().getAll,
|
assetProvider: di<IAssetRepository>().getAll,
|
||||||
),
|
),
|
||||||
|
@ -5,12 +5,14 @@ import 'package:immich_mobile/domain/interfaces/asset.interface.dart';
|
|||||||
import 'package:immich_mobile/domain/interfaces/store.interface.dart';
|
import 'package:immich_mobile/domain/interfaces/store.interface.dart';
|
||||||
import 'package:immich_mobile/domain/interfaces/user.interface.dart';
|
import 'package:immich_mobile/domain/interfaces/user.interface.dart';
|
||||||
import 'package:immich_mobile/domain/models/store.model.dart';
|
import 'package:immich_mobile/domain/models/store.model.dart';
|
||||||
|
import 'package:immich_mobile/domain/services/album_sync.service.dart';
|
||||||
import 'package:immich_mobile/domain/services/asset_sync.service.dart';
|
import 'package:immich_mobile/domain/services/asset_sync.service.dart';
|
||||||
import 'package:immich_mobile/domain/services/login.service.dart';
|
import 'package:immich_mobile/domain/services/login.service.dart';
|
||||||
import 'package:immich_mobile/domain/services/user.service.dart';
|
import 'package:immich_mobile/domain/services/user.service.dart';
|
||||||
import 'package:immich_mobile/i18n/strings.g.dart';
|
import 'package:immich_mobile/i18n/strings.g.dart';
|
||||||
import 'package:immich_mobile/presentation/modules/common/states/server_info/server_feature_config.state.dart';
|
|
||||||
import 'package:immich_mobile/presentation/modules/login/models/login_page.model.dart';
|
import 'package:immich_mobile/presentation/modules/login/models/login_page.model.dart';
|
||||||
|
import 'package:immich_mobile/presentation/states/gallery_permission.state.dart';
|
||||||
|
import 'package:immich_mobile/presentation/states/server_info/server_feature_config.state.dart';
|
||||||
import 'package:immich_mobile/service_locator.dart';
|
import 'package:immich_mobile/service_locator.dart';
|
||||||
import 'package:immich_mobile/utils/immich_api_client.dart';
|
import 'package:immich_mobile/utils/immich_api_client.dart';
|
||||||
import 'package:immich_mobile/utils/mixins/log.mixin.dart';
|
import 'package:immich_mobile/utils/mixins/log.mixin.dart';
|
||||||
@ -126,7 +128,7 @@ class LoginPageCubit extends Cubit<LoginPageState> with LogMixin {
|
|||||||
await di<IStoreRepository>().upsert(StoreKey.accessToken, accessToken);
|
await di<IStoreRepository>().upsert(StoreKey.accessToken, accessToken);
|
||||||
|
|
||||||
/// Set token to interceptor
|
/// Set token to interceptor
|
||||||
await di<ImmichApiClient>().init(accessToken: accessToken);
|
await di<ImApiClient>().init(accessToken: accessToken);
|
||||||
|
|
||||||
final user = await di<UserService>().getMyUser();
|
final user = await di<UserService>().getMyUser();
|
||||||
if (user == null) {
|
if (user == null) {
|
||||||
@ -139,7 +141,9 @@ class LoginPageCubit extends Cubit<LoginPageState> with LogMixin {
|
|||||||
await di<IUserRepository>().upsert(user);
|
await di<IUserRepository>().upsert(user);
|
||||||
// Remove and Sync assets in background
|
// Remove and Sync assets in background
|
||||||
await di<IAssetRepository>().deleteAll();
|
await di<IAssetRepository>().deleteAll();
|
||||||
unawaited(di<AssetSyncService>().performFullRemoteSyncForUser(user));
|
await di<GalleryPermissionNotifier>().requestPermission();
|
||||||
|
unawaited(di<AssetSyncService>().performFullRemoteSyncIsolate(user));
|
||||||
|
unawaited(di<AlbumSyncService>().performFullDeviceSyncIsolate());
|
||||||
|
|
||||||
emit(state.copyWith(
|
emit(state.copyWith(
|
||||||
isValidationInProgress: false,
|
isValidationInProgress: false,
|
||||||
|
@ -10,9 +10,9 @@ import 'package:immich_mobile/presentation/components/input/filled_button.widget
|
|||||||
import 'package:immich_mobile/presentation/components/input/password_form_field.widget.dart';
|
import 'package:immich_mobile/presentation/components/input/password_form_field.widget.dart';
|
||||||
import 'package:immich_mobile/presentation/components/input/text_button.widget.dart';
|
import 'package:immich_mobile/presentation/components/input/text_button.widget.dart';
|
||||||
import 'package:immich_mobile/presentation/components/input/text_form_field.widget.dart';
|
import 'package:immich_mobile/presentation/components/input/text_form_field.widget.dart';
|
||||||
import 'package:immich_mobile/presentation/modules/common/states/server_info/server_feature_config.state.dart';
|
|
||||||
import 'package:immich_mobile/presentation/modules/login/models/login_page.model.dart';
|
import 'package:immich_mobile/presentation/modules/login/models/login_page.model.dart';
|
||||||
import 'package:immich_mobile/presentation/modules/login/states/login_page.state.dart';
|
import 'package:immich_mobile/presentation/modules/login/states/login_page.state.dart';
|
||||||
|
import 'package:immich_mobile/presentation/states/server_info/server_feature_config.state.dart';
|
||||||
import 'package:immich_mobile/service_locator.dart';
|
import 'package:immich_mobile/service_locator.dart';
|
||||||
import 'package:material_symbols_icons/symbols.dart';
|
import 'package:material_symbols_icons/symbols.dart';
|
||||||
|
|
||||||
|
@ -4,17 +4,17 @@ import 'package:immich_mobile/presentation/router/router.dart';
|
|||||||
import 'package:material_symbols_icons/symbols.dart';
|
import 'package:material_symbols_icons/symbols.dart';
|
||||||
|
|
||||||
enum SettingSection {
|
enum SettingSection {
|
||||||
general(
|
general._(
|
||||||
icon: Symbols.interests_rounded,
|
icon: Symbols.interests_rounded,
|
||||||
labelKey: 'settings.sections.general',
|
labelKey: 'settings.sections.general',
|
||||||
destination: GeneralSettingsRoute(),
|
destination: GeneralSettingsRoute(),
|
||||||
),
|
),
|
||||||
advance(
|
advance._(
|
||||||
icon: Symbols.build_rounded,
|
icon: Symbols.build_rounded,
|
||||||
labelKey: 'settings.sections.advance',
|
labelKey: 'settings.sections.advance',
|
||||||
destination: AdvanceSettingsRoute(),
|
destination: AdvanceSettingsRoute(),
|
||||||
),
|
),
|
||||||
about(
|
about._(
|
||||||
icon: Symbols.help_rounded,
|
icon: Symbols.help_rounded,
|
||||||
labelKey: 'settings.sections.about',
|
labelKey: 'settings.sections.about',
|
||||||
destination: AboutSettingsRoute(),
|
destination: AboutSettingsRoute(),
|
||||||
@ -24,7 +24,7 @@ enum SettingSection {
|
|||||||
final String labelKey;
|
final String labelKey;
|
||||||
final IconData icon;
|
final IconData icon;
|
||||||
|
|
||||||
const SettingSection({
|
const SettingSection._({
|
||||||
required this.labelKey,
|
required this.labelKey,
|
||||||
required this.icon,
|
required this.icon,
|
||||||
required this.destination,
|
required this.destination,
|
||||||
|
@ -3,6 +3,7 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:immich_mobile/i18n/strings.g.dart';
|
import 'package:immich_mobile/i18n/strings.g.dart';
|
||||||
import 'package:immich_mobile/presentation/components/image/immich_logo.widget.dart';
|
import 'package:immich_mobile/presentation/components/image/immich_logo.widget.dart';
|
||||||
import 'package:immich_mobile/presentation/components/scaffold/adaptive_route_appbar.widget.dart';
|
import 'package:immich_mobile/presentation/components/scaffold/adaptive_route_appbar.widget.dart';
|
||||||
|
import 'package:immich_mobile/utils/constants/globals.dart';
|
||||||
import 'package:immich_mobile/utils/constants/size_constants.dart';
|
import 'package:immich_mobile/utils/constants/size_constants.dart';
|
||||||
|
|
||||||
@RoutePage()
|
@RoutePage()
|
||||||
@ -17,9 +18,10 @@ class AboutSettingsPage extends StatelessWidget {
|
|||||||
title: Text(context.t.settings.about.third_party_title),
|
title: Text(context.t.settings.about.third_party_title),
|
||||||
subtitle: Text(context.t.settings.about.third_party_sub_title),
|
subtitle: Text(context.t.settings.about.third_party_sub_title),
|
||||||
onTap: () => showLicensePage(
|
onTap: () => showLicensePage(
|
||||||
context: context,
|
context: context,
|
||||||
applicationName: "Immich",
|
applicationName: kImmichAppName,
|
||||||
applicationIcon: const ImLogo(width: SizeConstants.xl)),
|
applicationIcon: const ImLogo(width: SizeConstants.xl),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -22,6 +22,7 @@ class SettingsWrapperPage extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@RoutePage()
|
@RoutePage()
|
||||||
|
// ignore: prefer-single-widget-per-file
|
||||||
class SettingsPage extends StatelessWidget {
|
class SettingsPage extends StatelessWidget {
|
||||||
const SettingsPage({super.key});
|
const SettingsPage({super.key});
|
||||||
|
|
||||||
|
@ -3,14 +3,14 @@ import 'package:immich_mobile/presentation/modules/theme/models/app_colors.model
|
|||||||
import 'package:immich_mobile/utils/extensions/material_state.extension.dart';
|
import 'package:immich_mobile/utils/extensions/material_state.extension.dart';
|
||||||
|
|
||||||
enum AppTheme {
|
enum AppTheme {
|
||||||
blue(AppColors.blueLight, AppColors.blueDark),
|
blue._(AppColors.blueLight, AppColors.blueDark),
|
||||||
// Fallback color for dynamic theme for non-supported platforms
|
// Fallback color for dynamic theme for non-supported platforms
|
||||||
dynamic(AppColors.blueLight, AppColors.blueDark);
|
dynamic._(AppColors.blueLight, AppColors.blueDark);
|
||||||
|
|
||||||
final ColorScheme lightSchema;
|
final ColorScheme lightSchema;
|
||||||
final ColorScheme darkSchema;
|
final ColorScheme darkSchema;
|
||||||
|
|
||||||
const AppTheme(this.lightSchema, this.darkSchema);
|
const AppTheme._(this.lightSchema, this.darkSchema);
|
||||||
|
|
||||||
static ThemeData generateThemeData(ColorScheme color) {
|
static ThemeData generateThemeData(ColorScheme color) {
|
||||||
return ThemeData(
|
return ThemeData(
|
||||||
@ -51,6 +51,14 @@ enum AppTheme {
|
|||||||
borderSide: BorderSide(color: color.outlineVariant),
|
borderSide: BorderSide(color: color.outlineVariant),
|
||||||
borderRadius: const BorderRadius.all(Radius.circular(15)),
|
borderRadius: const BorderRadius.all(Radius.circular(15)),
|
||||||
),
|
),
|
||||||
|
errorBorder: OutlineInputBorder(
|
||||||
|
borderRadius: const BorderRadius.all(Radius.circular(15)),
|
||||||
|
borderSide: BorderSide(color: color.error),
|
||||||
|
),
|
||||||
|
focusedErrorBorder: OutlineInputBorder(
|
||||||
|
borderRadius: const BorderRadius.all(Radius.circular(15)),
|
||||||
|
borderSide: BorderSide(color: color.error),
|
||||||
|
),
|
||||||
hintStyle: const TextStyle(
|
hintStyle: const TextStyle(
|
||||||
fontSize: 16.0,
|
fontSize: 16.0,
|
||||||
fontWeight: FontWeight.normal,
|
fontWeight: FontWeight.normal,
|
||||||
|
@ -3,12 +3,13 @@ import 'dart:async';
|
|||||||
import 'package:auto_route/auto_route.dart';
|
import 'package:auto_route/auto_route.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
|
import 'package:immich_mobile/domain/services/album_sync.service.dart';
|
||||||
import 'package:immich_mobile/domain/services/asset_sync.service.dart';
|
import 'package:immich_mobile/domain/services/asset_sync.service.dart';
|
||||||
import 'package:immich_mobile/domain/services/login.service.dart';
|
import 'package:immich_mobile/domain/services/login.service.dart';
|
||||||
import 'package:immich_mobile/presentation/components/image/immich_logo.widget.dart';
|
import 'package:immich_mobile/presentation/components/image/immich_logo.widget.dart';
|
||||||
import 'package:immich_mobile/presentation/modules/common/states/current_user.state.dart';
|
|
||||||
import 'package:immich_mobile/presentation/modules/login/states/login_page.state.dart';
|
import 'package:immich_mobile/presentation/modules/login/states/login_page.state.dart';
|
||||||
import 'package:immich_mobile/presentation/router/router.dart';
|
import 'package:immich_mobile/presentation/router/router.dart';
|
||||||
|
import 'package:immich_mobile/presentation/states/current_user.state.dart';
|
||||||
import 'package:immich_mobile/service_locator.dart';
|
import 'package:immich_mobile/service_locator.dart';
|
||||||
import 'package:immich_mobile/utils/mixins/log.mixin.dart';
|
import 'package:immich_mobile/utils/mixins/log.mixin.dart';
|
||||||
|
|
||||||
@ -53,7 +54,8 @@ class _SplashScreenState extends State<SplashScreenPage>
|
|||||||
Future<void> _tryLogin() async {
|
Future<void> _tryLogin() async {
|
||||||
if (await di<LoginService>().tryAutoLogin() && mounted) {
|
if (await di<LoginService>().tryAutoLogin() && mounted) {
|
||||||
unawaited(di<AssetSyncService>()
|
unawaited(di<AssetSyncService>()
|
||||||
.performFullRemoteSyncForUser(di<CurrentUserCubit>().state));
|
.performFullRemoteSyncIsolate(di<CurrentUserCubit>().state));
|
||||||
|
unawaited(di<AlbumSyncService>().performFullDeviceSyncIsolate());
|
||||||
unawaited(context.replaceRoute(const TabControllerRoute()));
|
unawaited(context.replaceRoute(const TabControllerRoute()));
|
||||||
} else {
|
} else {
|
||||||
unawaited(context.replaceRoute(const LoginRoute()));
|
unawaited(context.replaceRoute(const LoginRoute()));
|
||||||
|
123
mobile-v2/lib/presentation/states/gallery_permission.state.dart
Normal file
123
mobile-v2/lib/presentation/states/gallery_permission.state.dart
Normal file
@ -0,0 +1,123 @@
|
|||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:device_info_plus/device_info_plus.dart';
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:permission_handler/permission_handler.dart';
|
||||||
|
|
||||||
|
enum GalleryPermissionStatus {
|
||||||
|
yetToRequest,
|
||||||
|
granted,
|
||||||
|
limited,
|
||||||
|
denied,
|
||||||
|
permanentlyDenied;
|
||||||
|
|
||||||
|
bool get isGranted => this == GalleryPermissionStatus.granted;
|
||||||
|
bool get isLimited => this == GalleryPermissionStatus.limited;
|
||||||
|
}
|
||||||
|
|
||||||
|
class GalleryPermissionNotifier extends ValueNotifier<GalleryPermissionStatus> {
|
||||||
|
GalleryPermissionNotifier() : super(GalleryPermissionStatus.yetToRequest) {
|
||||||
|
checkPermission();
|
||||||
|
}
|
||||||
|
|
||||||
|
bool get hasPermission => value.isGranted || value.isLimited;
|
||||||
|
|
||||||
|
/// Requests the gallery permission
|
||||||
|
Future<GalleryPermissionStatus> requestPermission() async {
|
||||||
|
PermissionStatus result;
|
||||||
|
// Android 32 and below uses Permission.storage
|
||||||
|
if (Platform.isAndroid) {
|
||||||
|
final androidInfo = await DeviceInfoPlugin().androidInfo;
|
||||||
|
if (androidInfo.version.sdkInt <= 32) {
|
||||||
|
// Android 32 and below need storage
|
||||||
|
final permission = await Permission.storage.request();
|
||||||
|
result = permission;
|
||||||
|
} else {
|
||||||
|
// Android 33 need photo & video
|
||||||
|
final photos = await Permission.photos.request();
|
||||||
|
if (!photos.isGranted) {
|
||||||
|
final state = _toGalleryPermissionStatus(photos);
|
||||||
|
// Don't ask twice for the same permission
|
||||||
|
value = state;
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
final videos = await Permission.videos.request();
|
||||||
|
// Return the joint result of those two permissions
|
||||||
|
if ((photos.isGranted && videos.isGranted) ||
|
||||||
|
(photos.isLimited && videos.isLimited)) {
|
||||||
|
result = PermissionStatus.granted;
|
||||||
|
} else if (photos.isDenied || videos.isDenied) {
|
||||||
|
result = PermissionStatus.denied;
|
||||||
|
} else if (photos.isPermanentlyDenied || videos.isPermanentlyDenied) {
|
||||||
|
result = PermissionStatus.permanentlyDenied;
|
||||||
|
} else {
|
||||||
|
result = PermissionStatus.denied;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (result == PermissionStatus.granted &&
|
||||||
|
androidInfo.version.sdkInt >= 29) {
|
||||||
|
result = await Permission.accessMediaLocation.request();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// iOS can use photos
|
||||||
|
result = await Permission.photos.request();
|
||||||
|
}
|
||||||
|
value = _toGalleryPermissionStatus(result);
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Checks the current state of the gallery permissions without
|
||||||
|
/// requesting them again
|
||||||
|
Future<GalleryPermissionStatus> checkPermission() async {
|
||||||
|
PermissionStatus result;
|
||||||
|
// Android 32 and below uses Permission.storage
|
||||||
|
if (Platform.isAndroid) {
|
||||||
|
final androidInfo = await DeviceInfoPlugin().androidInfo;
|
||||||
|
if (androidInfo.version.sdkInt <= 32) {
|
||||||
|
// Android 32 and below need storage
|
||||||
|
final permission = await Permission.storage.status;
|
||||||
|
result = permission;
|
||||||
|
} else {
|
||||||
|
// Android 33 needs photo & video
|
||||||
|
final photos = await Permission.photos.status;
|
||||||
|
final videos = await Permission.videos.status;
|
||||||
|
|
||||||
|
// Return the joint result of those two permissions
|
||||||
|
final PermissionStatus status;
|
||||||
|
if ((photos.isGranted && videos.isGranted) ||
|
||||||
|
(photos.isLimited && videos.isLimited)) {
|
||||||
|
status = PermissionStatus.granted;
|
||||||
|
} else if (photos.isDenied || videos.isDenied) {
|
||||||
|
status = PermissionStatus.denied;
|
||||||
|
} else if (photos.isPermanentlyDenied || videos.isPermanentlyDenied) {
|
||||||
|
status = PermissionStatus.permanentlyDenied;
|
||||||
|
} else {
|
||||||
|
status = PermissionStatus.denied;
|
||||||
|
}
|
||||||
|
|
||||||
|
result = status;
|
||||||
|
}
|
||||||
|
if (result == PermissionStatus.granted &&
|
||||||
|
androidInfo.version.sdkInt >= 29) {
|
||||||
|
result = await Permission.accessMediaLocation.status;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// iOS can use photos
|
||||||
|
result = await Permission.photos.status;
|
||||||
|
}
|
||||||
|
value = _toGalleryPermissionStatus(result);
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
GalleryPermissionStatus _toGalleryPermissionStatus(PermissionStatus status) =>
|
||||||
|
switch (status) {
|
||||||
|
PermissionStatus.granted => GalleryPermissionStatus.granted,
|
||||||
|
PermissionStatus.limited => GalleryPermissionStatus.limited,
|
||||||
|
PermissionStatus.denied => GalleryPermissionStatus.denied,
|
||||||
|
PermissionStatus.restricted ||
|
||||||
|
PermissionStatus.permanentlyDenied ||
|
||||||
|
PermissionStatus.provisional =>
|
||||||
|
GalleryPermissionStatus.permanentlyDenied,
|
||||||
|
};
|
@ -1,25 +1,42 @@
|
|||||||
import 'package:get_it/get_it.dart';
|
import 'package:get_it/get_it.dart';
|
||||||
|
import 'package:immich_mobile/domain/interfaces/album.interface.dart';
|
||||||
|
import 'package:immich_mobile/domain/interfaces/album_asset.interface.dart';
|
||||||
|
import 'package:immich_mobile/domain/interfaces/album_etag.interface.dart';
|
||||||
import 'package:immich_mobile/domain/interfaces/asset.interface.dart';
|
import 'package:immich_mobile/domain/interfaces/asset.interface.dart';
|
||||||
|
import 'package:immich_mobile/domain/interfaces/database.interface.dart';
|
||||||
|
import 'package:immich_mobile/domain/interfaces/device_album.interface.dart';
|
||||||
|
import 'package:immich_mobile/domain/interfaces/device_asset.interface.dart';
|
||||||
|
import 'package:immich_mobile/domain/interfaces/device_asset_hash.interface.dart';
|
||||||
import 'package:immich_mobile/domain/interfaces/log.interface.dart';
|
import 'package:immich_mobile/domain/interfaces/log.interface.dart';
|
||||||
import 'package:immich_mobile/domain/interfaces/renderlist.interface.dart';
|
import 'package:immich_mobile/domain/interfaces/renderlist.interface.dart';
|
||||||
import 'package:immich_mobile/domain/interfaces/store.interface.dart';
|
import 'package:immich_mobile/domain/interfaces/store.interface.dart';
|
||||||
import 'package:immich_mobile/domain/interfaces/user.interface.dart';
|
import 'package:immich_mobile/domain/interfaces/user.interface.dart';
|
||||||
import 'package:immich_mobile/domain/models/user.model.dart';
|
import 'package:immich_mobile/domain/models/user.model.dart';
|
||||||
|
import 'package:immich_mobile/domain/repositories/album.repository.dart';
|
||||||
|
import 'package:immich_mobile/domain/repositories/album_asset.repository.dart';
|
||||||
|
import 'package:immich_mobile/domain/repositories/album_etag.repository.dart';
|
||||||
import 'package:immich_mobile/domain/repositories/asset.repository.dart';
|
import 'package:immich_mobile/domain/repositories/asset.repository.dart';
|
||||||
import 'package:immich_mobile/domain/repositories/database.repository.dart';
|
import 'package:immich_mobile/domain/repositories/database.repository.dart';
|
||||||
|
import 'package:immich_mobile/domain/repositories/device_album.repository.dart';
|
||||||
|
import 'package:immich_mobile/domain/repositories/device_asset.repository.dart';
|
||||||
|
import 'package:immich_mobile/domain/repositories/device_asset_hash.repository.dart';
|
||||||
import 'package:immich_mobile/domain/repositories/log.repository.dart';
|
import 'package:immich_mobile/domain/repositories/log.repository.dart';
|
||||||
import 'package:immich_mobile/domain/repositories/renderlist.repository.dart';
|
import 'package:immich_mobile/domain/repositories/renderlist.repository.dart';
|
||||||
import 'package:immich_mobile/domain/repositories/store.repository.dart';
|
import 'package:immich_mobile/domain/repositories/store.repository.dart';
|
||||||
import 'package:immich_mobile/domain/repositories/user.repository.dart';
|
import 'package:immich_mobile/domain/repositories/user.repository.dart';
|
||||||
|
import 'package:immich_mobile/domain/services/album_sync.service.dart';
|
||||||
import 'package:immich_mobile/domain/services/app_setting.service.dart';
|
import 'package:immich_mobile/domain/services/app_setting.service.dart';
|
||||||
import 'package:immich_mobile/domain/services/asset_sync.service.dart';
|
import 'package:immich_mobile/domain/services/asset_sync.service.dart';
|
||||||
|
import 'package:immich_mobile/domain/services/hash.service.dart';
|
||||||
import 'package:immich_mobile/domain/services/login.service.dart';
|
import 'package:immich_mobile/domain/services/login.service.dart';
|
||||||
import 'package:immich_mobile/domain/services/server_info.service.dart';
|
import 'package:immich_mobile/domain/services/server_info.service.dart';
|
||||||
import 'package:immich_mobile/domain/services/user.service.dart';
|
import 'package:immich_mobile/domain/services/user.service.dart';
|
||||||
import 'package:immich_mobile/presentation/modules/common/states/current_user.state.dart';
|
import 'package:immich_mobile/platform/messages.g.dart';
|
||||||
import 'package:immich_mobile/presentation/modules/common/states/server_info/server_feature_config.state.dart';
|
|
||||||
import 'package:immich_mobile/presentation/modules/theme/states/app_theme.state.dart';
|
import 'package:immich_mobile/presentation/modules/theme/states/app_theme.state.dart';
|
||||||
import 'package:immich_mobile/presentation/router/router.dart';
|
import 'package:immich_mobile/presentation/router/router.dart';
|
||||||
|
import 'package:immich_mobile/presentation/states/current_user.state.dart';
|
||||||
|
import 'package:immich_mobile/presentation/states/gallery_permission.state.dart';
|
||||||
|
import 'package:immich_mobile/presentation/states/server_info/server_feature_config.state.dart';
|
||||||
import 'package:immich_mobile/utils/immich_api_client.dart';
|
import 'package:immich_mobile/utils/immich_api_client.dart';
|
||||||
|
|
||||||
final di = GetIt.I;
|
final di = GetIt.I;
|
||||||
@ -55,7 +72,7 @@ class ServiceLocator {
|
|||||||
|
|
||||||
static void configureServicesForIsolate({
|
static void configureServicesForIsolate({
|
||||||
required DriftDatabaseRepository database,
|
required DriftDatabaseRepository database,
|
||||||
required ImmichApiClient apiClient,
|
required ImApiClient apiClient,
|
||||||
}) {
|
}) {
|
||||||
_registerSingleton(database);
|
_registerSingleton(database);
|
||||||
_registerSingleton(apiClient);
|
_registerSingleton(apiClient);
|
||||||
@ -66,36 +83,58 @@ class ServiceLocator {
|
|||||||
|
|
||||||
static void _registerRepositories() {
|
static void _registerRepositories() {
|
||||||
/// Repositories
|
/// Repositories
|
||||||
_registerFactory<IStoreRepository>(() => StoreDriftRepository(di()));
|
_registerSingleton<IDatabaseRepository>(di<DriftDatabaseRepository>());
|
||||||
_registerFactory<ILogRepository>(() => LogDriftRepository(di()));
|
_registerFactory<IStoreRepository>(() => StoreRepository(di()));
|
||||||
|
_registerFactory<ILogRepository>(() => LogRepository(di()));
|
||||||
_registerFactory<AppSettingService>(() => AppSettingService(di()));
|
_registerFactory<AppSettingService>(() => AppSettingService(di()));
|
||||||
_registerFactory<IUserRepository>(() => UserDriftRepository(di()));
|
_registerFactory<IUserRepository>(() => UserRepository(di()));
|
||||||
_registerFactory<IAssetRepository>(() => AssetDriftRepository(di()));
|
_registerFactory<IAssetRepository>(() => AssetRepository(di()));
|
||||||
_registerFactory<IRenderListRepository>(
|
_registerFactory<IAlbumRepository>(() => AlbumRepository(di()));
|
||||||
() => RenderListDriftRepository(di()),
|
_registerFactory<IDeviceAssetRepository>(
|
||||||
|
() => const DeviceAssetRepository(),
|
||||||
);
|
);
|
||||||
|
_registerFactory<IRenderListRepository>(() => RenderListRepository(di()));
|
||||||
|
_registerFactory<IDeviceAssetToHashRepository>(
|
||||||
|
() => DeviceAssetToHashRepository(di()),
|
||||||
|
);
|
||||||
|
_registerFactory<IDeviceAlbumRepository>(
|
||||||
|
() => const DeviceAlbumRepository(),
|
||||||
|
);
|
||||||
|
_registerFactory<IAlbumToAssetRepository>(
|
||||||
|
() => AlbumToAssetRepository(di()),
|
||||||
|
);
|
||||||
|
_registerFactory<IAlbumETagRepository>(() => AlbumETagRepository(di()));
|
||||||
|
|
||||||
/// Services
|
/// Services
|
||||||
_registerFactory<LoginService>(() => const LoginService());
|
_registerFactory<LoginService>(() => const LoginService());
|
||||||
|
_registerSingleton(ImHostService());
|
||||||
|
_registerSingleton(const AlbumSyncService());
|
||||||
|
_registerFactory<HashService>(() => HashService(
|
||||||
|
hostService: di(),
|
||||||
|
assetToHashRepo: di(),
|
||||||
|
deviceAlbumRepo: di(),
|
||||||
|
deviceAssetRepo: di(),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
static void _registerPreGlobalStates() {
|
static void _registerPreGlobalStates() {
|
||||||
_registerSingleton(AppRouter());
|
_registerSingleton(AppRouter());
|
||||||
_registerLazySingleton<AppThemeCubit>(() => AppThemeCubit(di()));
|
_registerLazySingleton<AppThemeCubit>(() => AppThemeCubit(di()));
|
||||||
|
_registerSingleton(GalleryPermissionNotifier());
|
||||||
}
|
}
|
||||||
|
|
||||||
static void registerApiClient(String endpoint) {
|
static void registerApiClient(String endpoint) {
|
||||||
_registerSingleton(ImmichApiClient(endpoint: endpoint));
|
_registerSingleton(ImApiClient(endpoint: endpoint));
|
||||||
}
|
}
|
||||||
|
|
||||||
static void registerPostValidationServices() {
|
static void registerPostValidationServices() {
|
||||||
_registerFactory<UserService>(() => UserService(
|
_registerFactory<UserService>(() => UserService(
|
||||||
di<ImmichApiClient>().getUsersApi(),
|
di<ImApiClient>().getUsersApi(),
|
||||||
));
|
));
|
||||||
_registerFactory<ServerInfoService>(() => ServerInfoService(
|
_registerFactory<ServerInfoService>(() => ServerInfoService(
|
||||||
di<ImmichApiClient>().getServerApi(),
|
di<ImApiClient>().getServerApi(),
|
||||||
));
|
));
|
||||||
_registerFactory<AssetSyncService>(() => const AssetSyncService());
|
_registerSingleton(const AssetSyncService());
|
||||||
}
|
}
|
||||||
|
|
||||||
static void registerPostGlobalStates() {
|
static void registerPostGlobalStates() {
|
||||||
|
@ -1,5 +1,7 @@
|
|||||||
// ignore_for_file: avoid-unsafe-collection-methods
|
// ignore_for_file: avoid-unsafe-collection-methods
|
||||||
|
|
||||||
|
import 'dart:async';
|
||||||
|
|
||||||
class CollectionUtil {
|
class CollectionUtil {
|
||||||
const CollectionUtil();
|
const CollectionUtil();
|
||||||
|
|
||||||
@ -13,28 +15,33 @@ class CollectionUtil {
|
|||||||
return a.compareTo(b);
|
return a.compareTo(b);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Find the difference between the two sorted lists [first] and [second]
|
/// Find the difference between the two lists [first] and [second]
|
||||||
/// Results are passed as callbacks back to the caller during the comparison
|
/// Results are passed as callbacks back to the caller during the comparison
|
||||||
static bool diffSortedLists<T>(
|
static FutureOr<bool> diffLists<T>(
|
||||||
List<T> first,
|
List<T> first,
|
||||||
List<T> second, {
|
List<T> second, {
|
||||||
required Comparator<T> compare,
|
required int Function(T a, T b) compare,
|
||||||
required bool Function(T a, T b) both,
|
required FutureOr<bool> Function(T a, T b) both,
|
||||||
required void Function(T a) onlyFirst,
|
required FutureOr<void> Function(T a) onlyFirst,
|
||||||
required void Function(T b) onlySecond,
|
required FutureOr<void> Function(T b) onlySecond,
|
||||||
}) {
|
}) async {
|
||||||
|
first.sort(compare);
|
||||||
|
first.uniqueConsecutive(compare);
|
||||||
|
second.sort(compare);
|
||||||
|
second.uniqueConsecutive(compare);
|
||||||
|
|
||||||
bool diff = false;
|
bool diff = false;
|
||||||
int i = 0, j = 0;
|
int i = 0, j = 0;
|
||||||
|
|
||||||
for (; i < first.length && j < second.length;) {
|
for (; i < first.length && j < second.length;) {
|
||||||
final int order = compare(first[i], second[j]);
|
final int order = compare(first[i], second[j]);
|
||||||
if (order == 0) {
|
if (order == 0) {
|
||||||
diff |= both(first[i++], second[j++]);
|
diff |= await both(first[i++], second[j++]);
|
||||||
} else if (order < 0) {
|
} else if (order < 0) {
|
||||||
onlyFirst(first[i++]);
|
await onlyFirst(first[i++]);
|
||||||
diff = true;
|
diff = true;
|
||||||
} else if (order > 0) {
|
} else if (order > 0) {
|
||||||
onlySecond(second[j++]);
|
await onlySecond(second[j++]);
|
||||||
diff = true;
|
diff = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -50,3 +57,19 @@ class CollectionUtil {
|
|||||||
return diff;
|
return diff;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
extension _ListExtension<T> on List<T> {
|
||||||
|
List<T> uniqueConsecutive(int Function(T a, T b) compare) {
|
||||||
|
int i = 1, j = 1;
|
||||||
|
for (; i < length; i++) {
|
||||||
|
if (compare(this[i - 1], this[i]) != 0) {
|
||||||
|
if (i != j) {
|
||||||
|
this[j] = this[i];
|
||||||
|
}
|
||||||
|
j++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
length = length == 0 ? 0 : j;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@ -1,14 +1,29 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
const String kImmichAppName = "Immich";
|
||||||
|
|
||||||
/// Log messages stored in the DB
|
/// Log messages stored in the DB
|
||||||
const int kLogMessageLimit = 500;
|
const int kLogMessageLimit = 500;
|
||||||
|
|
||||||
|
/// Cache constants
|
||||||
|
const int kCacheStalePeriod = 30; // in days
|
||||||
|
const String kCacheFullImagesKey = 'ImFullImageCacheKey';
|
||||||
|
const int kCacheMaxNrOfFullImages = 500;
|
||||||
|
const String kCacheThumbnailsKey = 'ImThumbnailCacheKey';
|
||||||
|
const int kCacheMaxNrOfThumbnails = 500;
|
||||||
|
|
||||||
|
/// Grid constants
|
||||||
|
const int kGridThumbnailSize = 200;
|
||||||
|
const int kGridThumbnailQuality = 80;
|
||||||
|
|
||||||
/// RenderList constants
|
/// RenderList constants
|
||||||
const int kRenderListBatchSize = 512;
|
const int kRenderListBatchSize = 512;
|
||||||
const int kRenderListOppositeBatchSize = 128;
|
const int kRenderListOppositeBatchSize = 128;
|
||||||
|
|
||||||
/// Chunked asset sync size
|
/// Sync constants
|
||||||
const int kFullSyncChunkSize = 10000;
|
const int kFullSyncChunkSize = 10000;
|
||||||
|
const int kHashAssetsFileLimit = 128;
|
||||||
|
const int kHashAssetsSizeLimit = 1024 * 1024 * 1024; // 1GB
|
||||||
|
|
||||||
/// Headers
|
/// Headers
|
||||||
// Auth header
|
// Auth header
|
||||||
|
11
mobile-v2/lib/utils/extensions/file.extension.dart
Normal file
11
mobile-v2/lib/utils/extensions/file.extension.dart
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
extension ClearPhotoManagerCacheExtension on File {
|
||||||
|
Future<void> deleteDarwinCache() async {
|
||||||
|
if (Platform.isIOS) {
|
||||||
|
try {
|
||||||
|
await delete();
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -10,8 +10,8 @@ import 'package:immich_mobile/utils/constants/globals.dart';
|
|||||||
import 'package:immich_mobile/utils/mixins/log.mixin.dart';
|
import 'package:immich_mobile/utils/mixins/log.mixin.dart';
|
||||||
import 'package:openapi/api.dart';
|
import 'package:openapi/api.dart';
|
||||||
|
|
||||||
class ImmichApiClient extends ApiClient with LogMixin {
|
class ImApiClient extends ApiClient with LogMixin {
|
||||||
ImmichApiClient({required String endpoint}) : super(basePath: endpoint);
|
ImApiClient({required String endpoint}) : super(basePath: endpoint);
|
||||||
|
|
||||||
Map<String, String> get headers => defaultHeaderMap;
|
Map<String, String> get headers => defaultHeaderMap;
|
||||||
|
|
||||||
|
@ -1,28 +1,36 @@
|
|||||||
import 'package:immich_mobile/domain/models/asset.model.dart';
|
import 'package:immich_mobile/domain/models/asset.model.dart';
|
||||||
import 'package:immich_mobile/service_locator.dart';
|
import 'package:immich_mobile/service_locator.dart';
|
||||||
import 'package:immich_mobile/utils/immich_api_client.dart';
|
import 'package:immich_mobile/utils/immich_api_client.dart';
|
||||||
import 'package:openapi/api.dart';
|
|
||||||
|
enum AssetMediaSize {
|
||||||
|
preview._('preview'),
|
||||||
|
thumbnail._('thumbnail');
|
||||||
|
|
||||||
|
const AssetMediaSize._(this.value);
|
||||||
|
|
||||||
|
final String value;
|
||||||
|
}
|
||||||
|
|
||||||
class ImImageUrlHelper {
|
class ImImageUrlHelper {
|
||||||
const ImImageUrlHelper();
|
const ImImageUrlHelper();
|
||||||
|
|
||||||
static String get _serverUrl => di<ImmichApiClient>().basePath;
|
static String get _serverUrl => di<ImApiClient>().basePath;
|
||||||
|
|
||||||
static String getThumbnailUrl(
|
static String getThumbnailUrl(
|
||||||
final Asset asset, {
|
final Asset asset, {
|
||||||
AssetMediaSize type = AssetMediaSize.thumbnail,
|
AssetMediaSize type = AssetMediaSize.thumbnail,
|
||||||
}) {
|
}) {
|
||||||
return _getThumbnailUrlForRemoteId(asset.remoteId!, type: type);
|
return getThumbnailUrlForRemoteId(asset.remoteId!, type: type);
|
||||||
}
|
}
|
||||||
|
|
||||||
static String getThumbnailCacheKey(
|
static String getThumbnailCacheKey(
|
||||||
final Asset asset, {
|
final Asset asset, {
|
||||||
AssetMediaSize type = AssetMediaSize.thumbnail,
|
AssetMediaSize type = AssetMediaSize.thumbnail,
|
||||||
}) {
|
}) {
|
||||||
return _getThumbnailCacheKeyForRemoteId(asset.remoteId!, type: type);
|
return getThumbnailCacheKeyForRemoteId(asset.remoteId!, type: type);
|
||||||
}
|
}
|
||||||
|
|
||||||
static String _getThumbnailCacheKeyForRemoteId(
|
static String getThumbnailCacheKeyForRemoteId(
|
||||||
final String id, {
|
final String id, {
|
||||||
AssetMediaSize type = AssetMediaSize.thumbnail,
|
AssetMediaSize type = AssetMediaSize.thumbnail,
|
||||||
}) {
|
}) {
|
||||||
@ -32,7 +40,7 @@ class ImImageUrlHelper {
|
|||||||
return 'preview-image-$id';
|
return 'preview-image-$id';
|
||||||
}
|
}
|
||||||
|
|
||||||
static String _getThumbnailUrlForRemoteId(
|
static String getThumbnailUrlForRemoteId(
|
||||||
final String id, {
|
final String id, {
|
||||||
AssetMediaSize type = AssetMediaSize.thumbnail,
|
AssetMediaSize type = AssetMediaSize.thumbnail,
|
||||||
}) {
|
}) {
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'dart:isolate';
|
import 'dart:isolate';
|
||||||
|
import 'dart:ui';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
@ -32,7 +33,7 @@ class IsolateHelper {
|
|||||||
IsolateHelper();
|
IsolateHelper();
|
||||||
|
|
||||||
void preIsolateHandling() {
|
void preIsolateHandling() {
|
||||||
final apiClient = di<ImmichApiClient>();
|
final apiClient = di<ImApiClient>();
|
||||||
_clientData = _ImApiClientData(
|
_clientData = _ImApiClientData(
|
||||||
endpoint: apiClient.basePath,
|
endpoint: apiClient.basePath,
|
||||||
headersMap: apiClient.defaultHeaderMap,
|
headersMap: apiClient.defaultHeaderMap,
|
||||||
@ -42,7 +43,7 @@ class IsolateHelper {
|
|||||||
void postIsolateHandling() {
|
void postIsolateHandling() {
|
||||||
assert(_clientData != null);
|
assert(_clientData != null);
|
||||||
// Reconstruct client from cached data
|
// Reconstruct client from cached data
|
||||||
final client = ImmichApiClient(endpoint: _clientData!.endpoint);
|
final client = ImApiClient(endpoint: _clientData!.endpoint);
|
||||||
for (final entry in _clientData.headersMap.entries) {
|
for (final entry in _clientData.headersMap.entries) {
|
||||||
client.addDefaultHeader(entry.key, entry.value);
|
client.addDefaultHeader(entry.key, entry.value);
|
||||||
}
|
}
|
||||||
@ -54,7 +55,7 @@ class IsolateHelper {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Init log manager to continue listening to log events
|
// Init log manager to continue listening to log events
|
||||||
LogManager.I.init();
|
LogManager.I.init(shouldBuffer: false);
|
||||||
}
|
}
|
||||||
|
|
||||||
static Future<T> run<T>(FutureOr<T> Function() computation) async {
|
static Future<T> run<T>(FutureOr<T> Function() computation) async {
|
||||||
@ -66,9 +67,15 @@ class IsolateHelper {
|
|||||||
final helper = IsolateHelper()..preIsolateHandling();
|
final helper = IsolateHelper()..preIsolateHandling();
|
||||||
return await Isolate.run(() async {
|
return await Isolate.run(() async {
|
||||||
BackgroundIsolateBinaryMessenger.ensureInitialized(token);
|
BackgroundIsolateBinaryMessenger.ensureInitialized(token);
|
||||||
|
DartPluginRegistrant.ensureInitialized();
|
||||||
|
// Delay to ensure the isolate is ready
|
||||||
|
await Future.delayed(const Duration(milliseconds: 100));
|
||||||
helper.postIsolateHandling();
|
helper.postIsolateHandling();
|
||||||
try {
|
try {
|
||||||
return await computation();
|
final result = await computation();
|
||||||
|
// Delay to ensure the isolate is not killed prematurely
|
||||||
|
await Future.delayed(const Duration(milliseconds: 100));
|
||||||
|
return result;
|
||||||
} finally {
|
} finally {
|
||||||
// Always close the new database connection on Isolate end
|
// Always close the new database connection on Isolate end
|
||||||
await di<DriftDatabaseRepository>().close();
|
await di<DriftDatabaseRepository>().close();
|
||||||
|
@ -21,6 +21,11 @@ class LogManager {
|
|||||||
|
|
||||||
List<LogMessage> _msgBuffer = [];
|
List<LogMessage> _msgBuffer = [];
|
||||||
Timer? _timer;
|
Timer? _timer;
|
||||||
|
|
||||||
|
/// Whether to buffer logs in memory before writing to the database.
|
||||||
|
/// This is useful when logging in quick succession, as it increases performance
|
||||||
|
/// and reduces NAND wear. However, it may cause the logs to be lost in case of a crash / in isolates.
|
||||||
|
bool _shouldBuffer = true;
|
||||||
late final StreamSubscription<logging.LogRecord> _subscription;
|
late final StreamSubscription<logging.LogRecord> _subscription;
|
||||||
|
|
||||||
void _onLogRecord(logging.LogRecord record) {
|
void _onLogRecord(logging.LogRecord record) {
|
||||||
@ -42,11 +47,14 @@ class LogManager {
|
|||||||
error: record.error?.toString(),
|
error: record.error?.toString(),
|
||||||
stack: record.stackTrace?.toString(),
|
stack: record.stackTrace?.toString(),
|
||||||
);
|
);
|
||||||
_msgBuffer.add(lm);
|
|
||||||
|
|
||||||
// delayed batch writing to database: increases performance when logging
|
if (_shouldBuffer) {
|
||||||
// messages in quick succession and reduces NAND wear
|
_msgBuffer.add(lm);
|
||||||
_timer ??= Timer(const Duration(seconds: 5), _flushBufferToDatabase);
|
_timer ??=
|
||||||
|
Timer(const Duration(seconds: 5), () => _flushBufferToDatabase());
|
||||||
|
} else {
|
||||||
|
di<ILogRepository>().create(lm);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void _flushBufferToDatabase() {
|
void _flushBufferToDatabase() {
|
||||||
@ -56,7 +64,8 @@ class LogManager {
|
|||||||
di<ILogRepository>().createAll(buffer);
|
di<ILogRepository>().createAll(buffer);
|
||||||
}
|
}
|
||||||
|
|
||||||
void init() {
|
void init({bool? shouldBuffer}) {
|
||||||
|
_shouldBuffer = shouldBuffer ?? _shouldBuffer;
|
||||||
_subscription = logging.Logger.root.onRecord.listen(_onLogRecord);
|
_subscription = logging.Logger.root.onRecord.listen(_onLogRecord);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -106,18 +115,24 @@ class Logger {
|
|||||||
|
|
||||||
logging.Logger get _logger => logging.Logger(_loggerName);
|
logging.Logger get _logger => logging.Logger(_loggerName);
|
||||||
|
|
||||||
// Highly detailed
|
/// Finest / Verbose logs. Useful for highly detailed messages
|
||||||
void v(String message) => _logger.finest(message);
|
void v(String message) => _logger.finest(message);
|
||||||
// Troubleshooting
|
|
||||||
|
/// Fine / Debug logs. Useful for troubleshooting
|
||||||
void d(String message) => _logger.fine(message);
|
void d(String message) => _logger.fine(message);
|
||||||
// General purpose
|
|
||||||
|
/// Info logs. Useful for general logging
|
||||||
void i(String message) => _logger.info(message);
|
void i(String message) => _logger.info(message);
|
||||||
// Potential issues
|
|
||||||
void w(String message) => _logger.warning(message);
|
/// Warning logs. Useful to identify potential issues
|
||||||
// Error
|
void w(String message, [Object? error, StackTrace? stack]) =>
|
||||||
|
_logger.warning(message, error, stack);
|
||||||
|
|
||||||
|
/// Error logs. Useful for identifying issues
|
||||||
void e(String message, [Object? error, StackTrace? stack]) =>
|
void e(String message, [Object? error, StackTrace? stack]) =>
|
||||||
_logger.severe(message, error, stack);
|
_logger.severe(message, error, stack);
|
||||||
// Crash / Serious failure
|
|
||||||
|
/// Crash / Serious failure logs. Shouldn't happen
|
||||||
void wtf(String message, [Object? error, StackTrace? stack]) =>
|
void wtf(String message, [Object? error, StackTrace? stack]) =>
|
||||||
_logger.shout(message, error, stack);
|
_logger.shout(message, error, stack);
|
||||||
}
|
}
|
||||||
|
@ -651,7 +651,7 @@ packages:
|
|||||||
source: hosted
|
source: hosted
|
||||||
version: "1.0.0"
|
version: "1.0.0"
|
||||||
octo_image:
|
octo_image:
|
||||||
dependency: transitive
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: octo_image
|
name: octo_image
|
||||||
sha256: "34faa6639a78c7e3cbe79be6f9f96535867e879748ade7d17c9b1ae7536293bd"
|
sha256: "34faa6639a78c7e3cbe79be6f9f96535867e879748ade7d17c9b1ae7536293bd"
|
||||||
@ -753,6 +753,54 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.3.0"
|
version: "2.3.0"
|
||||||
|
permission_handler:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: permission_handler
|
||||||
|
sha256: "18bf33f7fefbd812f37e72091a15575e72d5318854877e0e4035a24ac1113ecb"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "11.3.1"
|
||||||
|
permission_handler_android:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: permission_handler_android
|
||||||
|
sha256: "71bbecfee799e65aff7c744761a57e817e73b738fedf62ab7afd5593da21f9f1"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "12.0.13"
|
||||||
|
permission_handler_apple:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: permission_handler_apple
|
||||||
|
sha256: e6f6d73b12438ef13e648c4ae56bd106ec60d17e90a59c4545db6781229082a0
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "9.4.5"
|
||||||
|
permission_handler_html:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: permission_handler_html
|
||||||
|
sha256: af26edbbb1f2674af65a8f4b56e1a6f526156bc273d0e65dd8075fab51c78851
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.1.3+2"
|
||||||
|
permission_handler_platform_interface:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: permission_handler_platform_interface
|
||||||
|
sha256: e9c8eadee926c4532d0305dff94b85bf961f16759c3af791486613152af4b4f9
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "4.2.3"
|
||||||
|
permission_handler_windows:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: permission_handler_windows
|
||||||
|
sha256: "1a790728016f79a41216d88672dbc5df30e686e811ad4e698bfc51f76ad91f1e"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.2.1"
|
||||||
petitparser:
|
petitparser:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
@ -1,11 +1,11 @@
|
|||||||
name: immich_mobile
|
name: immich_mobile
|
||||||
description: Immich - selfhosted backup media file on mobile phone
|
description: Immich - selfhosted backup media file on mobile phone
|
||||||
|
|
||||||
publish_to: 'none'
|
publish_to: "none"
|
||||||
version: 1.102.0+132
|
version: 1.102.0+132
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: '>=3.3.3 <4.0.0'
|
sdk: ">=3.3.3 <4.0.0"
|
||||||
|
|
||||||
dependencies:
|
dependencies:
|
||||||
flutter:
|
flutter:
|
||||||
@ -20,6 +20,7 @@ dependencies:
|
|||||||
url_launcher: ^6.3.0
|
url_launcher: ^6.3.0
|
||||||
package_info_plus: ^8.0.2
|
package_info_plus: ^8.0.2
|
||||||
device_info_plus: ^10.1.2
|
device_info_plus: ^10.1.2
|
||||||
|
permission_handler: ^11.3.1
|
||||||
# State handling
|
# State handling
|
||||||
flutter_bloc: ^8.1.6
|
flutter_bloc: ^8.1.6
|
||||||
# Database
|
# Database
|
||||||
@ -47,6 +48,7 @@ dependencies:
|
|||||||
# oauth login
|
# oauth login
|
||||||
flutter_web_auth_2: ^3.1.2
|
flutter_web_auth_2: ^3.1.2
|
||||||
# components
|
# components
|
||||||
|
octo_image: ^2.1.0
|
||||||
material_symbols_icons: ^4.2785.1
|
material_symbols_icons: ^4.2785.1
|
||||||
flutter_adaptive_scaffold: ^0.3.1
|
flutter_adaptive_scaffold: ^0.3.1
|
||||||
flutter_list_view: ^1.1.28
|
flutter_list_view: ^1.1.28
|
||||||
|
Loading…
x
Reference in New Issue
Block a user