merge main

This commit is contained in:
Alex
2024-10-31 11:14:26 -05:00
1524 changed files with 81920 additions and 46265 deletions
+1
View File
@@ -0,0 +1 @@
const int noDbId = -9223372036854775808; // from Isar
+799
View File
@@ -0,0 +1,799 @@
import 'package:flutter/material.dart';
List<ColorFilter> filters = [
//Original
const ColorFilter.matrix([
1,
0,
0,
0,
0,
0,
1,
0,
0,
0,
0,
0,
1,
0,
0,
0,
0,
0,
1,
0,
]),
//Vintage
const ColorFilter.matrix([
0.8,
0.1,
0.1,
0,
20,
0.1,
0.8,
0.1,
0,
20,
0.1,
0.1,
0.8,
0,
20,
0,
0,
0,
1,
0,
]),
//Mood
const ColorFilter.matrix([
1.2,
0.1,
0.1,
0,
10,
0.1,
1,
0.1,
0,
10,
0.1,
0.1,
1,
0,
10,
0,
0,
0,
1,
0,
]),
//Crisp
const ColorFilter.matrix([
1.2,
0,
0,
0,
0,
0,
1.2,
0,
0,
0,
0,
0,
1.2,
0,
0,
0,
0,
0,
1,
0,
]),
//Cool
const ColorFilter.matrix([
0.9,
0,
0.2,
0,
0,
0,
1,
0.1,
0,
0,
0.1,
0,
1.2,
0,
0,
0,
0,
0,
1,
0,
]),
//Blush
const ColorFilter.matrix([
1.1,
0.1,
0.1,
0,
10,
0.1,
1,
0.1,
0,
10,
0.1,
0.1,
1,
0,
5,
0,
0,
0,
1,
0,
]),
//Sunkissed
const ColorFilter.matrix([
1.3,
0,
0.1,
0,
15,
0,
1.1,
0.1,
0,
10,
0,
0,
0.9,
0,
5,
0,
0,
0,
1,
0,
]),
//Fresh
const ColorFilter.matrix([
1.2,
0,
0,
0,
20,
0,
1.2,
0,
0,
20,
0,
0,
1.1,
0,
20,
0,
0,
0,
1,
0,
]),
//Classic
const ColorFilter.matrix([
1.1,
0,
-0.1,
0,
10,
-0.1,
1.1,
0.1,
0,
5,
0,
-0.1,
1.1,
0,
0,
0,
0,
0,
1,
0,
]),
//Lomo-ish
const ColorFilter.matrix([
1.5,
0,
0.1,
0,
0,
0,
1.45,
0,
0,
0,
0.1,
0,
1.3,
0,
0,
0,
0,
0,
1,
0,
]),
//Nashville
const ColorFilter.matrix([
1.2,
0.15,
-0.15,
0,
15,
0.1,
1.1,
0.1,
0,
10,
-0.05,
0.2,
1.25,
0,
5,
0,
0,
0,
1,
0,
]),
//Valencia
const ColorFilter.matrix([
1.15,
0.1,
0.1,
0,
20,
0.1,
1.1,
0,
0,
10,
0.1,
0.1,
1.2,
0,
5,
0,
0,
0,
1,
0,
]),
//Clarendon
const ColorFilter.matrix([
1.2,
0,
0,
0,
10,
0,
1.25,
0,
0,
10,
0,
0,
1.3,
0,
10,
0,
0,
0,
1,
0,
]),
//Moon
const ColorFilter.matrix([
0.33,
0.33,
0.33,
0,
0,
0.33,
0.33,
0.33,
0,
0,
0.33,
0.33,
0.33,
0,
0,
0,
0,
0,
1,
0,
]),
//Willow
const ColorFilter.matrix([
0.5,
0.5,
0.5,
0,
20,
0.5,
0.5,
0.5,
0,
20,
0.5,
0.5,
0.5,
0,
20,
0,
0,
0,
1,
0,
]),
//Kodak
const ColorFilter.matrix([
1.3,
0.1,
-0.1,
0,
10,
0,
1.25,
0.1,
0,
10,
0,
-0.1,
1.1,
0,
5,
0,
0,
0,
1,
0,
]),
//Frost
const ColorFilter.matrix([
0.8,
0.2,
0.1,
0,
0,
0.2,
1.1,
0.1,
0,
0,
0.1,
0.1,
1.2,
0,
10,
0,
0,
0,
1,
0,
]),
//Night Vision
const ColorFilter.matrix([
0.1,
0.95,
0.2,
0,
0,
0.1,
1.5,
0.1,
0,
0,
0.2,
0.7,
0,
0,
0,
0,
0,
0,
1,
0,
]),
//Sunset
const ColorFilter.matrix([
1.5,
0.2,
0,
0,
0,
0.1,
0.9,
0.1,
0,
0,
-0.1,
-0.2,
1.3,
0,
0,
0,
0,
0,
1,
0,
]),
//Noir
const ColorFilter.matrix([
1.3,
-0.3,
0.1,
0,
0,
-0.1,
1.2,
-0.1,
0,
0,
0.1,
-0.2,
1.3,
0,
0,
0,
0,
0,
1,
0,
]),
//Dreamy
const ColorFilter.matrix([
1.1,
0.1,
0.1,
0,
0,
0.1,
1.1,
0.1,
0,
0,
0.1,
0.1,
1.1,
0,
15,
0,
0,
0,
1,
0,
]),
//Sepia
const ColorFilter.matrix([
0.393,
0.769,
0.189,
0,
0,
0.349,
0.686,
0.168,
0,
0,
0.272,
0.534,
0.131,
0,
0,
0,
0,
0,
1,
0,
]),
//Radium
const ColorFilter.matrix([
1.438,
-0.062,
-0.062,
0,
0,
-0.122,
1.378,
-0.122,
0,
0,
-0.016,
-0.016,
1.483,
0,
0,
0,
0,
0,
1,
0,
]),
//Aqua
const ColorFilter.matrix([
0.2126,
0.7152,
0.0722,
0,
0,
0.2126,
0.7152,
0.0722,
0,
0,
0.7873,
0.2848,
0.9278,
0,
0,
0,
0,
0,
1,
0,
]),
//Purple Haze
const ColorFilter.matrix([
1.3,
0,
1.2,
0,
0,
0,
1.1,
0,
0,
0,
0.2,
0,
1.3,
0,
0,
0,
0,
0,
1,
0,
]),
//Lemonade
const ColorFilter.matrix([
1.2,
0.1,
0,
0,
0,
0,
1.1,
0.2,
0,
0,
0.1,
0,
0.7,
0,
0,
0,
0,
0,
1,
0,
]),
//Caramel
const ColorFilter.matrix([
1.6,
0.2,
0,
0,
0,
0.1,
1.3,
0.1,
0,
0,
0,
0.1,
0.9,
0,
0,
0,
0,
0,
1,
0,
]),
//Peachy
const ColorFilter.matrix([
1.3,
0.5,
0,
0,
0,
0.2,
1.1,
0.3,
0,
0,
0.1,
0.1,
1.2,
0,
0,
0,
0,
0,
1,
0,
]),
//Neon
const ColorFilter.matrix([
1,
0,
1,
0,
0,
0,
2,
0,
0,
0,
0,
0,
3,
0,
0,
0,
0,
0,
1,
0,
]),
//Cold Morning
const ColorFilter.matrix([
0.9,
0.1,
0.2,
0,
0,
0,
1,
0.1,
0,
0,
0.1,
0,
1.2,
0,
0,
0,
0,
0,
1,
0,
]),
//Lush
const ColorFilter.matrix([
0.9,
0.2,
0,
0,
0,
0,
1.2,
0,
0,
0,
0,
0,
1.1,
0,
0,
0,
0,
0,
1,
0,
]),
//Urban Neon
const ColorFilter.matrix([
1.1,
0,
0.3,
0,
0,
0,
0.9,
0.3,
0,
0,
0.3,
0.1,
1.2,
0,
0,
0,
0,
0,
1,
0,
]),
//Monochrome
const ColorFilter.matrix([
0.6,
0.2,
0.2,
0,
0,
0.2,
0.6,
0.2,
0,
0,
0.2,
0.2,
0.7,
0,
0,
0,
0,
0,
1,
0,
]),
];
const List<String> filterNames = [
'Original',
'Vintage',
'Mood',
'Crisp',
'Cool',
'Blush',
'Sunkissed',
'Fresh',
'Classic',
'Lomo-ish',
'Nashville',
'Valencia',
'Clarendon',
'Moon',
'Willow',
'Kodak',
'Frost',
'Night Vision',
'Sunset',
'Noir',
'Dreamy',
'Sepia',
'Radium',
'Aqua',
'Purple Haze',
'Lemonade',
'Caramel',
'Peachy',
'Neon',
'Cold Morning',
'Lush',
'Urban Neon',
'Monochrome',
];
+109 -3
View File
@@ -1,5 +1,111 @@
import 'package:flutter/material.dart';
import 'package:immich_mobile/utils/immich_app_theme.dart';
const Color immichBackgroundColor = Color(0xFFf6f8fe);
const Color immichDarkBackgroundColor = Color.fromARGB(255, 0, 0, 0);
const Color immichDarkThemePrimaryColor = Color.fromARGB(255, 173, 203, 250);
enum ImmichColorPreset {
indigo,
deepPurple,
pink,
red,
orange,
yellow,
lime,
green,
cyan,
slateGray
}
const ImmichColorPreset defaultColorPreset = ImmichColorPreset.indigo;
const String defaultColorPresetName = "indigo";
const Color immichBrandColorLight = Color(0xFF4150AF);
const Color immichBrandColorDark = Color(0xFFACCBFA);
final Map<ImmichColorPreset, ImmichTheme> _themePresetsMap = {
ImmichColorPreset.indigo: ImmichTheme(
light: ColorScheme.fromSeed(
seedColor: immichBrandColorLight,
).copyWith(
primary: immichBrandColorLight,
onSurface: const Color.fromARGB(255, 34, 31, 32),
),
dark: ColorScheme.fromSeed(
seedColor: immichBrandColorDark,
brightness: Brightness.dark,
).copyWith(primary: immichBrandColorDark),
),
ImmichColorPreset.deepPurple: ImmichTheme(
light: ColorScheme.fromSeed(seedColor: const Color(0xFF6F43C0)),
dark: ColorScheme.fromSeed(
seedColor: const Color(0xFFD3BBFF),
brightness: Brightness.dark,
),
),
ImmichColorPreset.pink: ImmichTheme(
light: ColorScheme.fromSeed(seedColor: const Color(0xFFED79B5)),
dark: ColorScheme.fromSeed(
seedColor: const Color(0xFFED79B5),
brightness: Brightness.dark,
),
),
ImmichColorPreset.red: ImmichTheme(
light: ColorScheme.fromSeed(seedColor: const Color(0xFFC51C16)),
dark: ColorScheme.fromSeed(
seedColor: const Color(0xFFD3302F),
brightness: Brightness.dark,
),
),
ImmichColorPreset.orange: ImmichTheme(
light: ColorScheme.fromSeed(
seedColor: const Color(0xffff5b01),
dynamicSchemeVariant: DynamicSchemeVariant.fidelity,
),
dark: ColorScheme.fromSeed(
seedColor: const Color(0xFFCC6D08),
brightness: Brightness.dark,
dynamicSchemeVariant: DynamicSchemeVariant.fidelity,
),
),
ImmichColorPreset.yellow: ImmichTheme(
light: ColorScheme.fromSeed(seedColor: const Color(0xFFFFB400)),
dark: ColorScheme.fromSeed(
seedColor: const Color(0xFFFFB400),
brightness: Brightness.dark,
),
),
ImmichColorPreset.lime: ImmichTheme(
light: ColorScheme.fromSeed(seedColor: const Color(0xFFCDDC39)),
dark: ColorScheme.fromSeed(
seedColor: const Color(0xFFCDDC39),
brightness: Brightness.dark,
),
),
ImmichColorPreset.green: ImmichTheme(
light: ColorScheme.fromSeed(seedColor: const Color(0xFF18C249)),
dark: ColorScheme.fromSeed(
seedColor: const Color(0xFF18C249),
brightness: Brightness.dark,
),
),
ImmichColorPreset.cyan: ImmichTheme(
light: ColorScheme.fromSeed(seedColor: const Color(0xFF00BCD4)),
dark: ColorScheme.fromSeed(
seedColor: const Color(0xFF00BCD4),
brightness: Brightness.dark,
),
),
ImmichColorPreset.slateGray: ImmichTheme(
light: ColorScheme.fromSeed(
seedColor: const Color(0xFF696969),
dynamicSchemeVariant: DynamicSchemeVariant.neutral,
),
dark: ColorScheme.fromSeed(
seedColor: const Color(0xff696969),
brightness: Brightness.dark,
dynamicSchemeVariant: DynamicSchemeVariant.neutral,
),
),
};
extension ImmichColorModeExtension on ImmichColorPreset {
ImmichTheme getTheme() => _themePresetsMap[this]!;
}
+32 -24
View File
@@ -1,11 +1,11 @@
import 'package:flutter/foundation.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/entities/user.entity.dart';
import 'package:immich_mobile/utils/datetime_comparison.dart';
import 'package:isar/isar.dart';
// ignore: implementation_imports
import 'package:isar/src/common/isar_links_common.dart';
import 'package:openapi/api.dart';
import 'package:photo_manager/photo_manager.dart';
part 'album.entity.g.dart';
@@ -25,6 +25,7 @@ class Album {
required this.activityEnabled,
});
// fields stored in DB
Id id = Isar.autoIncrement;
@Index(unique: false, replace: false, type: IndexType.hash)
String? remoteId;
@@ -43,6 +44,17 @@ class Album {
final IsarLinks<User> sharedUsers = IsarLinks<User>();
final IsarLinks<Asset> assets = IsarLinks<Asset>();
// transient fields
@ignore
bool isAll = false;
@ignore
String? remoteThumbnailAssetId;
@ignore
int remoteAssetCount = 0;
// getters
@ignore
bool get isRemote => remoteId != null;
@@ -70,6 +82,21 @@ class Album {
return name.join(' ');
}
@ignore
String get eTagKeyAssetCount => "device-album-$localId-asset-count";
// the following getter are needed because Isar links do not make data
// accessible in an object freshly created (not loaded from DB)
@ignore
Iterable<User> get remoteUsers => sharedUsers.isEmpty
? (sharedUsers as IsarLinksCommon<User>).addedObjects
: sharedUsers;
@ignore
Iterable<Asset> get remoteAssets =>
assets.isEmpty ? (assets as IsarLinksCommon<Asset>).addedObjects : assets;
@override
bool operator ==(other) {
if (other is! Album) return false;
@@ -112,19 +139,6 @@ class Album {
sharedUsers.length.hashCode ^
assets.length.hashCode;
static Album local(AssetPathEntity ape) {
final Album a = Album(
name: ape.name,
createdAt: ape.lastModified?.toUtc() ?? DateTime.now().toUtc(),
modifiedAt: ape.lastModified?.toUtc() ?? DateTime.now().toUtc(),
shared: false,
activityEnabled: false,
);
a.owner.value = Store.get(StoreKey.currentUser);
a.localId = ape.id;
return a;
}
static Future<Album> remote(AlbumResponseDto dto) async {
final Isar db = Isar.getInstance()!;
final Album a = Album(
@@ -138,6 +152,7 @@ class Album {
endDate: dto.endDate,
activityEnabled: dto.isActivityEnabled,
);
a.remoteAssetCount = dto.assetCount;
a.owner.value = await db.users.getById(dto.ownerId);
if (dto.albumThumbnailAssetId != null) {
a.thumbnail.value = await db.assets
@@ -164,19 +179,12 @@ class Album {
}
extension AssetsHelper on IsarCollection<Album> {
Future<void> store(Album a) async {
Future<Album> store(Album a) async {
await put(a);
await a.owner.save();
await a.thumbnail.save();
await a.sharedUsers.save();
await a.assets.save();
return a;
}
}
extension AlbumResponseDtoHelper on AlbumResponseDto {
List<Asset> getAssets() => assets.map(Asset.remote).toList();
}
extension AssetPathEntityHelper on AssetPathEntity {
String get eTagKeyAssetCount => "device-album-$id-asset-count";
}
+64 -54
View File
@@ -1,11 +1,10 @@
import 'dart:convert';
import 'package:immich_mobile/entities/exif_info.entity.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/utils/hash.dart';
import 'package:isar/isar.dart';
import 'package:openapi/api.dart';
import 'package:photo_manager/photo_manager.dart';
import 'package:photo_manager/photo_manager.dart' show AssetEntity;
import 'package:immich_mobile/extensions/string_extensions.dart';
import 'package:path/path.dart' as p;
@@ -23,8 +22,12 @@ class Asset {
durationInSeconds = remote.duration.toDuration()?.inSeconds ?? 0,
type = remote.type.toAssetType(),
fileName = remote.originalFileName,
height = remote.exifInfo?.exifImageHeight?.toInt(),
width = remote.exifInfo?.exifImageWidth?.toInt(),
height = isFlipped(remote)
? remote.exifInfo?.exifImageWidth?.toInt()
: remote.exifInfo?.exifImageHeight?.toInt(),
width = isFlipped(remote)
? remote.exifInfo?.exifImageHeight?.toInt()
: remote.exifInfo?.exifImageWidth?.toInt(),
livePhotoVideoId = remote.livePhotoVideoId,
ownerId = fastHash(remote.ownerId),
exifInfo =
@@ -33,40 +36,15 @@ class Asset {
isArchived = remote.isArchived,
isTrashed = remote.isTrashed,
isOffline = remote.isOffline,
// workaround to nullify stackParentId for the parent asset until we refactor the mobile app
// workaround to nullify stackPrimaryAssetId for the parent asset until we refactor the mobile app
// stack handling to properly handle it
stackParentId =
remote.stackParentId == remote.id ? null : remote.stackParentId,
stackCount = remote.stackCount,
stackPrimaryAssetId = remote.stack?.primaryAssetId == remote.id
? null
: remote.stack?.primaryAssetId,
stackCount = remote.stack?.assetCount ?? 0,
stackId = remote.stack?.id,
thumbhash = remote.thumbhash;
Asset.local(AssetEntity local, List<int> hash)
: localId = local.id,
checksum = base64.encode(hash),
durationInSeconds = local.duration,
type = AssetType.values[local.typeInt],
height = local.height,
width = local.width,
fileName = local.title!,
ownerId = Store.get(StoreKey.currentUser).isarId,
fileModifiedAt = local.modifiedDateTime,
updatedAt = local.modifiedDateTime,
isFavorite = local.isFavorite,
isArchived = false,
isTrashed = false,
isOffline = false,
stackCount = 0,
fileCreatedAt = local.createDateTime {
if (fileCreatedAt.year == 1970) {
fileCreatedAt = fileModifiedAt;
}
if (local.latitude != null) {
exifInfo = ExifInfo(lat: local.latitude, long: local.longitude);
}
_local = local;
assert(hash.length == 20, "invalid SHA1 hash");
}
Asset({
this.id = Isar.autoIncrement,
required this.checksum,
@@ -86,7 +64,8 @@ class Asset {
this.isFavorite = false,
this.isArchived = false,
this.isTrashed = false,
this.stackParentId,
this.stackId,
this.stackPrimaryAssetId,
this.stackCount = 0,
this.isOffline = false,
this.thumbhash,
@@ -112,6 +91,8 @@ class Asset {
return _local;
}
set local(AssetEntity? assetEntity) => _local = assetEntity;
Id id = Isar.autoIncrement;
/// stores the raw SHA1 bytes as a base64 String
@@ -163,12 +144,11 @@ class Asset {
@ignore
ExifInfo? exifInfo;
String? stackParentId;
String? stackId;
@ignore
int get stackChildrenCount => stackCount ?? 0;
String? stackPrimaryAssetId;
int? stackCount;
int stackCount;
/// Aspect ratio of the asset
@ignore
@@ -208,6 +188,10 @@ class Asset {
@ignore
Duration get duration => Duration(seconds: durationInSeconds);
// ignore: invalid_annotation_target
@ignore
set byteHash(List<int> hash) => checksum = base64.encode(hash);
@override
bool operator ==(other) {
if (other is! Asset) return false;
@@ -231,7 +215,8 @@ class Asset {
isArchived == other.isArchived &&
isTrashed == other.isTrashed &&
stackCount == other.stackCount &&
stackParentId == other.stackParentId;
stackPrimaryAssetId == other.stackPrimaryAssetId &&
stackId == other.stackId;
}
@override
@@ -256,7 +241,8 @@ class Asset {
isArchived.hashCode ^
isTrashed.hashCode ^
stackCount.hashCode ^
stackParentId.hashCode;
stackPrimaryAssetId.hashCode ^
stackId.hashCode;
/// Returns `true` if this [Asset] can updated with values from parameter [a]
bool canUpdate(Asset a) {
@@ -269,7 +255,6 @@ class Asset {
width == null && a.width != null ||
height == null && a.height != null ||
livePhotoVideoId == null && a.livePhotoVideoId != null ||
stackParentId == null && a.stackParentId != null ||
isFavorite != a.isFavorite ||
isArchived != a.isArchived ||
isTrashed != a.isTrashed ||
@@ -278,10 +263,9 @@ class Asset {
a.exifInfo?.longitude != exifInfo?.longitude ||
// no local stack count or different count from remote
a.thumbhash != thumbhash ||
((stackCount == null && a.stackCount != null) ||
(stackCount != null &&
a.stackCount != null &&
stackCount != a.stackCount));
stackId != a.stackId ||
stackCount != a.stackCount ||
stackPrimaryAssetId == null && a.stackPrimaryAssetId != null;
}
/// Returns a new [Asset] with values from this and merged & updated with [a]
@@ -311,9 +295,11 @@ class Asset {
id: id,
remoteId: remoteId,
livePhotoVideoId: livePhotoVideoId,
// workaround to nullify stackParentId for the parent asset until we refactor the mobile app
// workaround to nullify stackPrimaryAssetId for the parent asset until we refactor the mobile app
// stack handling to properly handle it
stackParentId: stackParentId == remoteId ? null : stackParentId,
stackId: stackId,
stackPrimaryAssetId:
stackPrimaryAssetId == remoteId ? null : stackPrimaryAssetId,
stackCount: stackCount,
isFavorite: isFavorite,
isArchived: isArchived,
@@ -330,9 +316,12 @@ class Asset {
width: a.width,
height: a.height,
livePhotoVideoId: a.livePhotoVideoId,
// workaround to nullify stackParentId for the parent asset until we refactor the mobile app
// workaround to nullify stackPrimaryAssetId for the parent asset until we refactor the mobile app
// stack handling to properly handle it
stackParentId: a.stackParentId == a.remoteId ? null : a.stackParentId,
stackId: a.stackId,
stackPrimaryAssetId: a.stackPrimaryAssetId == a.remoteId
? null
: a.stackPrimaryAssetId,
stackCount: a.stackCount,
// isFavorite + isArchived are not set by device-only assets
isFavorite: a.isFavorite,
@@ -374,7 +363,8 @@ class Asset {
bool? isTrashed,
bool? isOffline,
ExifInfo? exifInfo,
String? stackParentId,
String? stackId,
String? stackPrimaryAssetId,
int? stackCount,
String? thumbhash,
}) =>
@@ -398,7 +388,8 @@ class Asset {
isTrashed: isTrashed ?? this.isTrashed,
isOffline: isOffline ?? this.isOffline,
exifInfo: exifInfo ?? this.exifInfo,
stackParentId: stackParentId ?? this.stackParentId,
stackId: stackId ?? this.stackId,
stackPrimaryAssetId: stackPrimaryAssetId ?? this.stackPrimaryAssetId,
stackCount: stackCount ?? this.stackCount,
thumbhash: thumbhash ?? this.thumbhash,
);
@@ -445,8 +436,9 @@ class Asset {
"checksum": "$checksum",
"ownerId": $ownerId,
"livePhotoVideoId": "${livePhotoVideoId ?? "N/A"}",
"stackId": "${stackId ?? "N/A"}",
"stackPrimaryAssetId": "${stackPrimaryAssetId ?? "N/A"}",
"stackCount": "$stackCount",
"stackParentId": "${stackParentId ?? "N/A"}",
"fileCreatedAt": "$fileCreatedAt",
"fileModifiedAt": "$fileModifiedAt",
"updatedAt": "$updatedAt",
@@ -519,3 +511,21 @@ extension AssetsHelper on IsarCollection<Asset> {
return where().anyOf(ids, (q, String e) => q.localIdEqualTo(e));
}
}
/// Returns `true` if this [int] is flipped 90° clockwise
bool isRotated90CW(int orientation) {
return [7, 8, -90].contains(orientation);
}
/// Returns `true` if this [int] is flipped 270° clockwise
bool isRotated270CW(int orientation) {
return [5, 6, 90].contains(orientation);
}
/// Returns `true` if this [Asset] is flipped 90° or 270° clockwise
bool isFlipped(AssetResponseDto response) {
final int orientation =
int.tryParse(response.exifInfo?.orientation ?? '0') ?? 0;
return orientation != 0 &&
(isRotated90CW(orientation) || isRotated270CW(orientation));
}
+267 -79
View File
@@ -92,29 +92,34 @@ const AssetSchema = CollectionSchema(
name: r'stackCount',
type: IsarType.long,
),
r'stackParentId': PropertySchema(
r'stackId': PropertySchema(
id: 15,
name: r'stackParentId',
name: r'stackId',
type: IsarType.string,
),
r'stackPrimaryAssetId': PropertySchema(
id: 16,
name: r'stackPrimaryAssetId',
type: IsarType.string,
),
r'thumbhash': PropertySchema(
id: 16,
id: 17,
name: r'thumbhash',
type: IsarType.string,
),
r'type': PropertySchema(
id: 17,
id: 18,
name: r'type',
type: IsarType.byte,
enumMap: _AssettypeEnumValueMap,
),
r'updatedAt': PropertySchema(
id: 18,
id: 19,
name: r'updatedAt',
type: IsarType.dateTime,
),
r'width': PropertySchema(
id: 19,
id: 20,
name: r'width',
type: IsarType.int,
)
@@ -205,7 +210,13 @@ int _assetEstimateSize(
}
}
{
final value = object.stackParentId;
final value = object.stackId;
if (value != null) {
bytesCount += 3 + value.length * 3;
}
}
{
final value = object.stackPrimaryAssetId;
if (value != null) {
bytesCount += 3 + value.length * 3;
}
@@ -240,11 +251,12 @@ void _assetSerialize(
writer.writeLong(offsets[12], object.ownerId);
writer.writeString(offsets[13], object.remoteId);
writer.writeLong(offsets[14], object.stackCount);
writer.writeString(offsets[15], object.stackParentId);
writer.writeString(offsets[16], object.thumbhash);
writer.writeByte(offsets[17], object.type.index);
writer.writeDateTime(offsets[18], object.updatedAt);
writer.writeInt(offsets[19], object.width);
writer.writeString(offsets[15], object.stackId);
writer.writeString(offsets[16], object.stackPrimaryAssetId);
writer.writeString(offsets[17], object.thumbhash);
writer.writeByte(offsets[18], object.type.index);
writer.writeDateTime(offsets[19], object.updatedAt);
writer.writeInt(offsets[20], object.width);
}
Asset _assetDeserialize(
@@ -269,13 +281,14 @@ Asset _assetDeserialize(
localId: reader.readStringOrNull(offsets[11]),
ownerId: reader.readLong(offsets[12]),
remoteId: reader.readStringOrNull(offsets[13]),
stackCount: reader.readLongOrNull(offsets[14]),
stackParentId: reader.readStringOrNull(offsets[15]),
thumbhash: reader.readStringOrNull(offsets[16]),
type: _AssettypeValueEnumMap[reader.readByteOrNull(offsets[17])] ??
stackCount: reader.readLongOrNull(offsets[14]) ?? 0,
stackId: reader.readStringOrNull(offsets[15]),
stackPrimaryAssetId: reader.readStringOrNull(offsets[16]),
thumbhash: reader.readStringOrNull(offsets[17]),
type: _AssettypeValueEnumMap[reader.readByteOrNull(offsets[18])] ??
AssetType.other,
updatedAt: reader.readDateTime(offsets[18]),
width: reader.readIntOrNull(offsets[19]),
updatedAt: reader.readDateTime(offsets[19]),
width: reader.readIntOrNull(offsets[20]),
);
return object;
}
@@ -316,17 +329,19 @@ P _assetDeserializeProp<P>(
case 13:
return (reader.readStringOrNull(offset)) as P;
case 14:
return (reader.readLongOrNull(offset)) as P;
return (reader.readLongOrNull(offset) ?? 0) as P;
case 15:
return (reader.readStringOrNull(offset)) as P;
case 16:
return (reader.readStringOrNull(offset)) as P;
case 17:
return (reader.readStringOrNull(offset)) as P;
case 18:
return (_AssettypeValueEnumMap[reader.readByteOrNull(offset)] ??
AssetType.other) as P;
case 18:
return (reader.readDateTime(offset)) as P;
case 19:
return (reader.readDateTime(offset)) as P;
case 20:
return (reader.readIntOrNull(offset)) as P;
default:
throw IsarError('Unknown property with id $propertyId');
@@ -1859,24 +1874,8 @@ extension AssetQueryFilter on QueryBuilder<Asset, Asset, QFilterCondition> {
});
}
QueryBuilder<Asset, Asset, QAfterFilterCondition> stackCountIsNull() {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(const FilterCondition.isNull(
property: r'stackCount',
));
});
}
QueryBuilder<Asset, Asset, QAfterFilterCondition> stackCountIsNotNull() {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(const FilterCondition.isNotNull(
property: r'stackCount',
));
});
}
QueryBuilder<Asset, Asset, QAfterFilterCondition> stackCountEqualTo(
int? value) {
int value) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.equalTo(
property: r'stackCount',
@@ -1886,7 +1885,7 @@ extension AssetQueryFilter on QueryBuilder<Asset, Asset, QFilterCondition> {
}
QueryBuilder<Asset, Asset, QAfterFilterCondition> stackCountGreaterThan(
int? value, {
int value, {
bool include = false,
}) {
return QueryBuilder.apply(this, (query) {
@@ -1899,7 +1898,7 @@ extension AssetQueryFilter on QueryBuilder<Asset, Asset, QFilterCondition> {
}
QueryBuilder<Asset, Asset, QAfterFilterCondition> stackCountLessThan(
int? value, {
int value, {
bool include = false,
}) {
return QueryBuilder.apply(this, (query) {
@@ -1912,8 +1911,8 @@ extension AssetQueryFilter on QueryBuilder<Asset, Asset, QFilterCondition> {
}
QueryBuilder<Asset, Asset, QAfterFilterCondition> stackCountBetween(
int? lower,
int? upper, {
int lower,
int upper, {
bool includeLower = true,
bool includeUpper = true,
}) {
@@ -1928,36 +1927,36 @@ extension AssetQueryFilter on QueryBuilder<Asset, Asset, QFilterCondition> {
});
}
QueryBuilder<Asset, Asset, QAfterFilterCondition> stackParentIdIsNull() {
QueryBuilder<Asset, Asset, QAfterFilterCondition> stackIdIsNull() {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(const FilterCondition.isNull(
property: r'stackParentId',
property: r'stackId',
));
});
}
QueryBuilder<Asset, Asset, QAfterFilterCondition> stackParentIdIsNotNull() {
QueryBuilder<Asset, Asset, QAfterFilterCondition> stackIdIsNotNull() {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(const FilterCondition.isNotNull(
property: r'stackParentId',
property: r'stackId',
));
});
}
QueryBuilder<Asset, Asset, QAfterFilterCondition> stackParentIdEqualTo(
QueryBuilder<Asset, Asset, QAfterFilterCondition> stackIdEqualTo(
String? value, {
bool caseSensitive = true,
}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.equalTo(
property: r'stackParentId',
property: r'stackId',
value: value,
caseSensitive: caseSensitive,
));
});
}
QueryBuilder<Asset, Asset, QAfterFilterCondition> stackParentIdGreaterThan(
QueryBuilder<Asset, Asset, QAfterFilterCondition> stackIdGreaterThan(
String? value, {
bool include = false,
bool caseSensitive = true,
@@ -1965,14 +1964,14 @@ extension AssetQueryFilter on QueryBuilder<Asset, Asset, QFilterCondition> {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.greaterThan(
include: include,
property: r'stackParentId',
property: r'stackId',
value: value,
caseSensitive: caseSensitive,
));
});
}
QueryBuilder<Asset, Asset, QAfterFilterCondition> stackParentIdLessThan(
QueryBuilder<Asset, Asset, QAfterFilterCondition> stackIdLessThan(
String? value, {
bool include = false,
bool caseSensitive = true,
@@ -1980,14 +1979,14 @@ extension AssetQueryFilter on QueryBuilder<Asset, Asset, QFilterCondition> {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.lessThan(
include: include,
property: r'stackParentId',
property: r'stackId',
value: value,
caseSensitive: caseSensitive,
));
});
}
QueryBuilder<Asset, Asset, QAfterFilterCondition> stackParentIdBetween(
QueryBuilder<Asset, Asset, QAfterFilterCondition> stackIdBetween(
String? lower,
String? upper, {
bool includeLower = true,
@@ -1996,7 +1995,7 @@ extension AssetQueryFilter on QueryBuilder<Asset, Asset, QFilterCondition> {
}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.between(
property: r'stackParentId',
property: r'stackId',
lower: lower,
includeLower: includeLower,
upper: upper,
@@ -2006,69 +2005,221 @@ extension AssetQueryFilter on QueryBuilder<Asset, Asset, QFilterCondition> {
});
}
QueryBuilder<Asset, Asset, QAfterFilterCondition> stackParentIdStartsWith(
QueryBuilder<Asset, Asset, QAfterFilterCondition> stackIdStartsWith(
String value, {
bool caseSensitive = true,
}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.startsWith(
property: r'stackParentId',
property: r'stackId',
value: value,
caseSensitive: caseSensitive,
));
});
}
QueryBuilder<Asset, Asset, QAfterFilterCondition> stackParentIdEndsWith(
QueryBuilder<Asset, Asset, QAfterFilterCondition> stackIdEndsWith(
String value, {
bool caseSensitive = true,
}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.endsWith(
property: r'stackParentId',
property: r'stackId',
value: value,
caseSensitive: caseSensitive,
));
});
}
QueryBuilder<Asset, Asset, QAfterFilterCondition> stackParentIdContains(
QueryBuilder<Asset, Asset, QAfterFilterCondition> stackIdContains(
String value,
{bool caseSensitive = true}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.contains(
property: r'stackParentId',
property: r'stackId',
value: value,
caseSensitive: caseSensitive,
));
});
}
QueryBuilder<Asset, Asset, QAfterFilterCondition> stackParentIdMatches(
QueryBuilder<Asset, Asset, QAfterFilterCondition> stackIdMatches(
String pattern,
{bool caseSensitive = true}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.matches(
property: r'stackParentId',
property: r'stackId',
wildcard: pattern,
caseSensitive: caseSensitive,
));
});
}
QueryBuilder<Asset, Asset, QAfterFilterCondition> stackParentIdIsEmpty() {
QueryBuilder<Asset, Asset, QAfterFilterCondition> stackIdIsEmpty() {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.equalTo(
property: r'stackParentId',
property: r'stackId',
value: '',
));
});
}
QueryBuilder<Asset, Asset, QAfterFilterCondition> stackParentIdIsNotEmpty() {
QueryBuilder<Asset, Asset, QAfterFilterCondition> stackIdIsNotEmpty() {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.greaterThan(
property: r'stackParentId',
property: r'stackId',
value: '',
));
});
}
QueryBuilder<Asset, Asset, QAfterFilterCondition>
stackPrimaryAssetIdIsNull() {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(const FilterCondition.isNull(
property: r'stackPrimaryAssetId',
));
});
}
QueryBuilder<Asset, Asset, QAfterFilterCondition>
stackPrimaryAssetIdIsNotNull() {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(const FilterCondition.isNotNull(
property: r'stackPrimaryAssetId',
));
});
}
QueryBuilder<Asset, Asset, QAfterFilterCondition> stackPrimaryAssetIdEqualTo(
String? value, {
bool caseSensitive = true,
}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.equalTo(
property: r'stackPrimaryAssetId',
value: value,
caseSensitive: caseSensitive,
));
});
}
QueryBuilder<Asset, Asset, QAfterFilterCondition>
stackPrimaryAssetIdGreaterThan(
String? value, {
bool include = false,
bool caseSensitive = true,
}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.greaterThan(
include: include,
property: r'stackPrimaryAssetId',
value: value,
caseSensitive: caseSensitive,
));
});
}
QueryBuilder<Asset, Asset, QAfterFilterCondition> stackPrimaryAssetIdLessThan(
String? value, {
bool include = false,
bool caseSensitive = true,
}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.lessThan(
include: include,
property: r'stackPrimaryAssetId',
value: value,
caseSensitive: caseSensitive,
));
});
}
QueryBuilder<Asset, Asset, QAfterFilterCondition> stackPrimaryAssetIdBetween(
String? lower,
String? upper, {
bool includeLower = true,
bool includeUpper = true,
bool caseSensitive = true,
}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.between(
property: r'stackPrimaryAssetId',
lower: lower,
includeLower: includeLower,
upper: upper,
includeUpper: includeUpper,
caseSensitive: caseSensitive,
));
});
}
QueryBuilder<Asset, Asset, QAfterFilterCondition>
stackPrimaryAssetIdStartsWith(
String value, {
bool caseSensitive = true,
}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.startsWith(
property: r'stackPrimaryAssetId',
value: value,
caseSensitive: caseSensitive,
));
});
}
QueryBuilder<Asset, Asset, QAfterFilterCondition> stackPrimaryAssetIdEndsWith(
String value, {
bool caseSensitive = true,
}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.endsWith(
property: r'stackPrimaryAssetId',
value: value,
caseSensitive: caseSensitive,
));
});
}
QueryBuilder<Asset, Asset, QAfterFilterCondition> stackPrimaryAssetIdContains(
String value,
{bool caseSensitive = true}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.contains(
property: r'stackPrimaryAssetId',
value: value,
caseSensitive: caseSensitive,
));
});
}
QueryBuilder<Asset, Asset, QAfterFilterCondition> stackPrimaryAssetIdMatches(
String pattern,
{bool caseSensitive = true}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.matches(
property: r'stackPrimaryAssetId',
wildcard: pattern,
caseSensitive: caseSensitive,
));
});
}
QueryBuilder<Asset, Asset, QAfterFilterCondition>
stackPrimaryAssetIdIsEmpty() {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.equalTo(
property: r'stackPrimaryAssetId',
value: '',
));
});
}
QueryBuilder<Asset, Asset, QAfterFilterCondition>
stackPrimaryAssetIdIsNotEmpty() {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.greaterThan(
property: r'stackPrimaryAssetId',
value: '',
));
});
@@ -2580,15 +2731,27 @@ extension AssetQuerySortBy on QueryBuilder<Asset, Asset, QSortBy> {
});
}
QueryBuilder<Asset, Asset, QAfterSortBy> sortByStackParentId() {
QueryBuilder<Asset, Asset, QAfterSortBy> sortByStackId() {
return QueryBuilder.apply(this, (query) {
return query.addSortBy(r'stackParentId', Sort.asc);
return query.addSortBy(r'stackId', Sort.asc);
});
}
QueryBuilder<Asset, Asset, QAfterSortBy> sortByStackParentIdDesc() {
QueryBuilder<Asset, Asset, QAfterSortBy> sortByStackIdDesc() {
return QueryBuilder.apply(this, (query) {
return query.addSortBy(r'stackParentId', Sort.desc);
return query.addSortBy(r'stackId', Sort.desc);
});
}
QueryBuilder<Asset, Asset, QAfterSortBy> sortByStackPrimaryAssetId() {
return QueryBuilder.apply(this, (query) {
return query.addSortBy(r'stackPrimaryAssetId', Sort.asc);
});
}
QueryBuilder<Asset, Asset, QAfterSortBy> sortByStackPrimaryAssetIdDesc() {
return QueryBuilder.apply(this, (query) {
return query.addSortBy(r'stackPrimaryAssetId', Sort.desc);
});
}
@@ -2834,15 +2997,27 @@ extension AssetQuerySortThenBy on QueryBuilder<Asset, Asset, QSortThenBy> {
});
}
QueryBuilder<Asset, Asset, QAfterSortBy> thenByStackParentId() {
QueryBuilder<Asset, Asset, QAfterSortBy> thenByStackId() {
return QueryBuilder.apply(this, (query) {
return query.addSortBy(r'stackParentId', Sort.asc);
return query.addSortBy(r'stackId', Sort.asc);
});
}
QueryBuilder<Asset, Asset, QAfterSortBy> thenByStackParentIdDesc() {
QueryBuilder<Asset, Asset, QAfterSortBy> thenByStackIdDesc() {
return QueryBuilder.apply(this, (query) {
return query.addSortBy(r'stackParentId', Sort.desc);
return query.addSortBy(r'stackId', Sort.desc);
});
}
QueryBuilder<Asset, Asset, QAfterSortBy> thenByStackPrimaryAssetId() {
return QueryBuilder.apply(this, (query) {
return query.addSortBy(r'stackPrimaryAssetId', Sort.asc);
});
}
QueryBuilder<Asset, Asset, QAfterSortBy> thenByStackPrimaryAssetIdDesc() {
return QueryBuilder.apply(this, (query) {
return query.addSortBy(r'stackPrimaryAssetId', Sort.desc);
});
}
@@ -2992,10 +3167,17 @@ extension AssetQueryWhereDistinct on QueryBuilder<Asset, Asset, QDistinct> {
});
}
QueryBuilder<Asset, Asset, QDistinct> distinctByStackParentId(
QueryBuilder<Asset, Asset, QDistinct> distinctByStackId(
{bool caseSensitive = true}) {
return QueryBuilder.apply(this, (query) {
return query.addDistinctBy(r'stackParentId',
return query.addDistinctBy(r'stackId', caseSensitive: caseSensitive);
});
}
QueryBuilder<Asset, Asset, QDistinct> distinctByStackPrimaryAssetId(
{bool caseSensitive = true}) {
return QueryBuilder.apply(this, (query) {
return query.addDistinctBy(r'stackPrimaryAssetId',
caseSensitive: caseSensitive);
});
}
@@ -3117,15 +3299,21 @@ extension AssetQueryProperty on QueryBuilder<Asset, Asset, QQueryProperty> {
});
}
QueryBuilder<Asset, int?, QQueryOperations> stackCountProperty() {
QueryBuilder<Asset, int, QQueryOperations> stackCountProperty() {
return QueryBuilder.apply(this, (query) {
return query.addPropertyName(r'stackCount');
});
}
QueryBuilder<Asset, String?, QQueryOperations> stackParentIdProperty() {
QueryBuilder<Asset, String?, QQueryOperations> stackIdProperty() {
return QueryBuilder.apply(this, (query) {
return query.addPropertyName(r'stackParentId');
return query.addPropertyName(r'stackId');
});
}
QueryBuilder<Asset, String?, QQueryOperations> stackPrimaryAssetIdProperty() {
return QueryBuilder.apply(this, (query) {
return query.addPropertyName(r'stackPrimaryAssetId');
});
}
+24
View File
@@ -170,6 +170,30 @@ class ExifInfo {
state.hashCode ^
country.hashCode ^
description.hashCode;
@override
String toString() {
return """
{
id: $id,
fileSize: $fileSize,
dateTimeOriginal: $dateTimeOriginal,
timeZone: $timeZone,
make: $make,
model: $model,
lens: $lens,
f: $f,
mm: $mm,
iso: $iso,
exposureSeconds: $exposureSeconds,
lat: $lat,
long: $long,
city: $city,
state: $state,
country: $country,
description: $description,
}""";
}
}
double? _exposureTimeToSeconds(String? s) {
+7
View File
@@ -229,6 +229,13 @@ enum StoreKey<T> {
mapwithPartners<bool>(125, type: bool),
enableHapticFeedback<bool>(126, type: bool),
customHeaders<String>(127, type: String),
// theme settings
primaryColor<String>(128, type: String),
dynamicTheme<bool>(129, type: bool),
colorfulInterface<bool>(130, type: bool),
syncAlbums<bool>(131, type: bool),
;
const StoreKey(
@@ -20,10 +20,10 @@ extension ContextHelper on BuildContext {
bool get isDarkTheme => themeData.brightness == Brightness.dark;
// Returns the current Primary color of the Theme
Color get primaryColor => themeData.primaryColor;
Color get primaryColor => themeData.colorScheme.primary;
// Returns the Scaffold background color of the Theme
Color get scaffoldBackgroundColor => themeData.scaffoldBackgroundColor;
Color get scaffoldBackgroundColor => colorScheme.surface;
// Returns the current TextTheme
TextTheme get textTheme => themeData.textTheme;
@@ -70,18 +70,6 @@ extension AssetListExtension on Iterable<Asset> {
}
return this;
}
/// Filters out offline assets and returns those that are still accessible by the Immich server
Iterable<Asset> nonOfflineOnly({
void Function()? errorCallback,
}) {
final bool onlyLive = every((e) => !e.isOffline);
if (!onlyLive) {
if (errorCallback != null) errorCallback();
return where((a) => !a.isOffline);
}
return this;
}
}
extension SortedByProperty<T> on Iterable<T> {
@@ -0,0 +1,24 @@
import 'package:flutter/material.dart';
extension ImmichColorSchemeExtensions on ColorScheme {
bool get _isDarkMode => brightness == Brightness.dark;
Color get onSurfaceSecondary => _isDarkMode
? onSurface.darken(amount: .3)
: onSurface.lighten(amount: .3);
}
extension ColorExtensions on Color {
Color lighten({double amount = 0.1}) {
return Color.alphaBlend(
Colors.white.withOpacity(amount),
this,
);
}
Color darken({double amount = 0.1}) {
return Color.alphaBlend(
Colors.black.withOpacity(amount),
this,
);
}
}
@@ -0,0 +1,16 @@
import 'package:immich_mobile/models/activities/activity.model.dart';
abstract interface class IActivityApiRepository {
Future<List<Activity>> getAll(
String albumId, {
String? assetId,
});
Future<Activity> create(
String albumId,
ActivityType type, {
String? assetId,
String? comment,
});
Future<void> delete(String id);
Future<ActivityStats> getStats(String albumId, {String? assetId});
}
@@ -0,0 +1,46 @@
import 'package:immich_mobile/entities/album.entity.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/entities/user.entity.dart';
import 'package:immich_mobile/interfaces/database.interface.dart';
import 'package:immich_mobile/models/albums/album_search.model.dart';
abstract interface class IAlbumRepository implements IDatabaseRepository {
Future<Album> create(Album album);
Future<Album?> get(int id);
Future<Album?> getByName(
String name, {
bool? shared,
bool? remote,
});
Future<List<Album>> getAll({
bool? shared,
bool? remote,
int? ownerId,
AlbumSort? sortBy,
});
Future<Album> update(Album album);
Future<void> delete(int albumId);
Future<void> deleteAllLocal();
Future<int> count({bool? local});
Future<void> addUsers(Album album, List<User> users);
Future<void> removeUsers(Album album, List<User> users);
Future<void> addAssets(Album album, List<Asset> assets);
Future<void> removeAssets(Album album, List<Asset> assets);
Future<Album> recalculateMetadata(Album album);
Future<List<Album>> search(String searchTerm, QuickFilterMode filterMode);
}
enum AlbumSort { remoteId, localId }
@@ -0,0 +1,40 @@
import 'package:immich_mobile/entities/album.entity.dart';
abstract interface class IAlbumApiRepository {
Future<Album> get(String id);
Future<List<Album>> getAll({bool? shared});
Future<Album> create(
String name, {
required Iterable<String> assetIds,
Iterable<String> sharedUserIds = const [],
});
Future<Album> update(
String albumId, {
String? name,
String? thumbnailAssetId,
String? description,
bool? activityEnabled,
});
Future<void> delete(String albumId);
Future<({List<String> added, List<String> duplicates})> addAssets(
String albumId,
Iterable<String> assetIds,
);
Future<({List<String> removed, List<String> failed})> removeAssets(
String albumId,
Iterable<String> assetIds,
);
Future<Album> addUsers(
String albumId,
Iterable<String> userIds,
);
Future<void> removeUser(String albumId, {required String userId});
}
@@ -0,0 +1,21 @@
import 'package:immich_mobile/entities/album.entity.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
abstract interface class IAlbumMediaRepository {
Future<List<Album>> getAll();
Future<List<String>> getAssetIds(String albumId);
Future<int> getAssetCount(String albumId);
Future<List<Asset>> getAssets(
String albumId, {
int start = 0,
int end = 0x7fffffffffffffff,
DateTime? modifiedFrom,
DateTime? modifiedUntil,
bool orderByModificationDate = false,
});
Future<Album> get(String id);
}
@@ -0,0 +1,62 @@
import 'package:immich_mobile/entities/album.entity.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/entities/device_asset.entity.dart';
import 'package:immich_mobile/interfaces/database.interface.dart';
abstract interface class IAssetRepository implements IDatabaseRepository {
Future<Asset?> getByRemoteId(String id);
Future<Asset?> getByOwnerIdChecksum(int ownerId, String checksum);
Future<List<Asset>> getAllByRemoteId(
Iterable<String> ids, {
AssetState? state,
});
Future<List<Asset?>> getAllByOwnerIdChecksum(
List<int> ids,
List<String> checksums,
);
Future<List<Asset>> getAll({
required int ownerId,
AssetState? state,
AssetSort? sortBy,
int? limit,
});
Future<List<Asset>> getAllLocal();
Future<List<Asset>> getByAlbum(
Album album, {
Iterable<int> notOwnedBy = const [],
int? ownerId,
AssetState? state,
AssetSort? sortBy,
});
Future<Asset> update(Asset asset);
Future<List<Asset>> updateAll(List<Asset> assets);
Future<void> deleteAllByRemoteId(List<String> ids, {AssetState? state});
Future<void> deleteById(List<int> ids);
Future<List<Asset>> getMatches({
required List<Asset> assets,
required int ownerId,
AssetState? state,
int limit = 100,
});
Future<List<DeviceAsset?>> getDeviceAssetsById(List<Object> ids);
Future<void> upsertDeviceAssets(List<DeviceAsset> deviceAssets);
Future<void> upsertDuplicatedAssets(Iterable<String> duplicatedAssets);
Future<List<String>> getAllDuplicatedAssetIds();
}
enum AssetSort { checksum, ownerIdChecksum }
@@ -0,0 +1,18 @@
import 'package:immich_mobile/entities/asset.entity.dart';
abstract interface class IAssetApiRepository {
// Future<Asset> get(String id);
// Future<List<Asset>> getAll();
// Future<Asset> create(Asset asset);
Future<Asset> update(
String id, {
String? description,
});
// Future<void> delete(String id);
Future<List<Asset>> search({List<String> personIds = const []});
}
@@ -0,0 +1,10 @@
import 'package:immich_mobile/entities/asset.entity.dart';
abstract interface class IAssetMediaRepository {
Future<List<String>> deleteAll(List<String> ids);
Future<Asset?> get(String id);
/// Obtaining the correct original filename of the asset
Future<String?> getOriginalFilename(String id);
}
@@ -0,0 +1,16 @@
import 'package:immich_mobile/entities/backup_album.entity.dart';
import 'package:immich_mobile/interfaces/database.interface.dart';
abstract interface class IBackupRepository implements IDatabaseRepository {
Future<List<BackupAlbum>> getAll({BackupAlbumSort? sort});
Future<List<String>> getIdsBySelection(BackupSelection backup);
Future<List<BackupAlbum>> getAllBySelection(BackupSelection backup);
Future<void> updateAll(List<BackupAlbum> backupAlbums);
Future<void> deleteAll(List<int> ids);
}
enum BackupAlbumSort { id }
@@ -0,0 +1,3 @@
abstract interface class IDatabaseRepository {
Future<T> transaction<T>(Future<T> Function() callback);
}
@@ -0,0 +1,14 @@
import 'package:background_downloader/background_downloader.dart';
abstract interface class IDownloadRepository {
void Function(TaskStatusUpdate)? onImageDownloadStatus;
void Function(TaskStatusUpdate)? onVideoDownloadStatus;
void Function(TaskStatusUpdate)? onLivePhotoDownloadStatus;
void Function(TaskProgressUpdate)? onTaskProgress;
Future<List<TaskRecord>> getLiveVideoTasks();
Future<bool> download(DownloadTask task);
Future<bool> cancel(String id);
Future<void> deleteAllTrackingRecords();
Future<void> deleteRecordsWithIds(List<String> id);
}
+14
View File
@@ -0,0 +1,14 @@
import 'package:immich_mobile/entities/etag.entity.dart';
import 'package:immich_mobile/interfaces/database.interface.dart';
abstract interface class IETagRepository implements IDatabaseRepository {
Future<ETag?> get(int id);
Future<ETag?> getById(String id);
Future<List<String>> getAllIds();
Future<void> upsertAll(List<ETag> etags);
Future<void> deleteByIds(List<String> ids);
}
@@ -0,0 +1,12 @@
import 'package:immich_mobile/entities/exif_info.entity.dart';
import 'package:immich_mobile/interfaces/database.interface.dart';
abstract interface class IExifInfoRepository implements IDatabaseRepository {
Future<ExifInfo?> get(int id);
Future<ExifInfo> update(ExifInfo exifInfo);
Future<List<ExifInfo>> updateAll(List<ExifInfo> exifInfos);
Future<void> delete(int id);
}
@@ -0,0 +1,36 @@
import 'dart:io';
import 'dart:typed_data';
import 'package:immich_mobile/entities/asset.entity.dart';
abstract interface class IFileMediaRepository {
Future<Asset?> saveImage(
Uint8List data, {
required String title,
String? relativePath,
});
Future<Asset?> saveImageWithFile(
String filePath, {
String? title,
String? relativePath,
});
Future<Asset?> saveVideo(
File file, {
required String title,
String? relativePath,
});
Future<Asset?> saveLivePhoto({
required File image,
required File video,
required String title,
});
Future<void> clearFileCache();
Future<void> enableBackgroundAccess();
Future<void> requestExtendedPermissions();
}
@@ -0,0 +1,13 @@
import 'package:immich_mobile/entities/user.entity.dart';
abstract interface class IPartnerApiRepository {
Future<List<User>> getAll(Direction direction);
Future<User> create(String id);
Future<User> update(String id, {required bool inTimeline});
Future<void> delete(String id);
}
enum Direction {
sharedWithMe,
sharedByMe,
}
@@ -0,0 +1,22 @@
abstract interface class IPersonApiRepository {
Future<List<Person>> getAll();
Future<Person> update(String id, {String? name});
}
class Person {
Person({
required this.id,
required this.isHidden,
required this.name,
required this.thumbnailPath,
this.birthDate,
this.updatedAt,
});
final String id;
final DateTime? birthDate;
final bool isHidden;
final String name;
final String thumbnailPath;
final DateTime? updatedAt;
}
+23
View File
@@ -0,0 +1,23 @@
import 'package:immich_mobile/entities/user.entity.dart';
import 'package:immich_mobile/interfaces/database.interface.dart';
abstract interface class IUserRepository implements IDatabaseRepository {
Future<User?> get(String id);
Future<List<User>> getByIds(List<String> ids);
Future<List<User>> getAll({bool self = true, UserSort? sortBy});
/// Returns all users whose assets can be accessed (self+partners)
Future<List<User>> getAllAccessible();
Future<List<User>> upsertAll(List<User> users);
Future<User> update(User user);
Future<void> deleteById(List<int> ids);
Future<User> me();
}
enum UserSort { id }
@@ -0,0 +1,11 @@
import 'dart:typed_data';
import 'package:immich_mobile/entities/user.entity.dart';
abstract interface class IUserApiRepository {
Future<List<User>> getAll();
Future<({String profileImagePath})> createProfileImage({
required String name,
required Uint8List data,
});
}
+27 -5
View File
@@ -1,6 +1,7 @@
import 'dart:async';
import 'dart:io';
import 'package:background_downloader/background_downloader.dart';
import 'package:device_info_plus/device_info_plus.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/foundation.dart';
@@ -10,6 +11,7 @@ import 'package:flutter_displaymode/flutter_displaymode.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:share_handler/share_handler.dart';
import 'package:immich_mobile/utils/download.dart';
import 'package:timezone/data/latest.dart';
import 'package:immich_mobile/constants/locales.dart';
import 'package:immich_mobile/services/background.service.dart';
@@ -40,7 +42,6 @@ import 'package:path_provider/path_provider.dart';
void main() async {
ImmichWidgetsBinding();
final db = await loadDb();
await initApp();
await migrateDatabaseIfNeeded(db);
@@ -66,6 +67,8 @@ Future<void> initApp() async {
}
}
await fetchSystemPalette();
// Initialize Immich Logger Service
ImmichLogger();
@@ -81,11 +84,29 @@ Future<void> initApp() async {
};
PlatformDispatcher.instance.onError = (error, stack) {
debugPrint("FlutterError - Catch all: $error \n $stack");
log.severe('PlatformDispatcher - Catch all', error, stack);
return true;
};
initializeTimeZones();
FileDownloader().configureNotification(
running: TaskNotification(
'downloading_media'.tr(),
'file: {filename}',
),
complete: TaskNotification(
'download_finished'.tr(),
'file: {filename}',
),
progressBar: true,
);
FileDownloader().trackTasksInGroup(
downloadGroupLivePhoto,
markDownloadedComplete: false,
);
}
Future<Isar> loadDb() async {
@@ -210,7 +231,8 @@ class ImmichAppState extends ConsumerState<ImmichApp>
@override
Widget build(BuildContext context) {
var router = ref.watch(appRouterProvider);
final router = ref.watch(appRouterProvider);
final immichTheme = ref.watch(immichThemeProvider);
return MaterialApp(
localizationsDelegates: context.localizationDelegates,
@@ -220,9 +242,9 @@ class ImmichAppState extends ConsumerState<ImmichApp>
home: MaterialApp.router(
title: 'Immich',
debugShowCheckedModeBanner: false,
themeMode: ref.watch(immichThemeProvider),
darkTheme: immichDarkTheme,
theme: immichLightTheme,
themeMode: ref.watch(immichThemeModeProvider),
darkTheme: getThemeData(colorScheme: immichTheme.dark),
theme: getThemeData(colorScheme: immichTheme.light),
routeInformationParser: router.defaultRouteParser(),
routerDelegate: router.delegate(
navigatorObservers: () => [TabNavigationObserver(ref: ref)],
@@ -1,5 +1,4 @@
import 'package:immich_mobile/entities/user.entity.dart';
import 'package:openapi/api.dart';
enum ActivityType { comment, like }
@@ -38,16 +37,6 @@ class Activity {
);
}
Activity.fromDto(ActivityResponseDto dto)
: id = dto.id,
assetId = dto.assetId,
comment = dto.comment,
createdAt = dto.createdAt,
type = dto.type == ReactionType.comment
? ActivityType.comment
: ActivityType.like,
user = User.fromSimpleUserDto(dto.user);
@override
String toString() {
return 'Activity(id: $id, assetId: $assetId, comment: $comment, createdAt: $createdAt, type: $type, user: $user)';
@@ -75,3 +64,9 @@ class Activity {
user.hashCode;
}
}
class ActivityStats {
final int comments;
const ActivityStats({required this.comments});
}
@@ -0,0 +1,5 @@
enum QuickFilterMode {
all,
sharedWithMe,
myAlbums,
}
@@ -1,55 +0,0 @@
import 'dart:convert';
enum DownloadAssetStatus { idle, loading, success, error }
class AssetViewerPageState {
// enum
final DownloadAssetStatus downloadAssetStatus;
AssetViewerPageState({
required this.downloadAssetStatus,
});
AssetViewerPageState copyWith({
DownloadAssetStatus? downloadAssetStatus,
}) {
return AssetViewerPageState(
downloadAssetStatus: downloadAssetStatus ?? this.downloadAssetStatus,
);
}
Map<String, dynamic> toMap() {
final result = <String, dynamic>{};
result.addAll({'downloadAssetStatus': downloadAssetStatus.index});
return result;
}
factory AssetViewerPageState.fromMap(Map<String, dynamic> map) {
return AssetViewerPageState(
downloadAssetStatus:
DownloadAssetStatus.values[map['downloadAssetStatus'] ?? 0],
);
}
String toJson() => json.encode(toMap());
factory AssetViewerPageState.fromJson(String source) =>
AssetViewerPageState.fromMap(json.decode(source));
@override
String toString() =>
'ImageViewerPageState(downloadAssetStatus: $downloadAssetStatus)';
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
return other is AssetViewerPageState &&
other.downloadAssetStatus == downloadAssetStatus;
}
@override
int get hashCode => downloadAssetStatus.hashCode;
}
@@ -1,45 +1,47 @@
import 'dart:typed_data';
import 'package:photo_manager/photo_manager.dart';
import 'package:immich_mobile/entities/album.entity.dart';
class AvailableAlbum {
final AssetPathEntity albumEntity;
final Album album;
final int assetCount;
final DateTime? lastBackup;
AvailableAlbum({
required this.albumEntity,
required this.album,
required this.assetCount,
this.lastBackup,
});
AvailableAlbum copyWith({
AssetPathEntity? albumEntity,
Album? album,
int? assetCount,
DateTime? lastBackup,
Uint8List? thumbnailData,
}) {
return AvailableAlbum(
albumEntity: albumEntity ?? this.albumEntity,
album: album ?? this.album,
assetCount: assetCount ?? this.assetCount,
lastBackup: lastBackup ?? this.lastBackup,
);
}
String get name => albumEntity.name;
String get name => album.name;
Future<int> get assetCount => albumEntity.assetCountAsync;
String get id => album.localId!;
String get id => albumEntity.id;
bool get isAll => albumEntity.isAll;
bool get isAll => album.isAll;
@override
String toString() =>
'AvailableAlbum(albumEntity: $albumEntity, lastBackup: $lastBackup)';
'AvailableAlbum(albumEntity: $album, lastBackup: $lastBackup)';
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
return other is AvailableAlbum && other.albumEntity == albumEntity;
return other is AvailableAlbum && other.album == album;
}
@override
int get hashCode => albumEntity.hashCode;
int get hashCode => album.hashCode;
}
@@ -0,0 +1,19 @@
import 'package:immich_mobile/entities/asset.entity.dart';
class BackupCandidate {
BackupCandidate({required this.asset, required this.albumNames});
Asset asset;
List<String> albumNames;
@override
int get hashCode => asset.hashCode;
@override
bool operator ==(Object other) {
if (other is! BackupCandidate) {
return false;
}
return asset == other.asset;
}
}
@@ -2,7 +2,7 @@
import 'package:cancellation_token_http/http.dart';
import 'package:collection/collection.dart';
import 'package:photo_manager/photo_manager.dart';
import 'package:immich_mobile/models/backup/backup_candidate.model.dart';
import 'package:immich_mobile/models/backup/available_album.model.dart';
import 'package:immich_mobile/models/backup/current_upload_asset.model.dart';
@@ -41,7 +41,7 @@ class BackUpState {
final Set<AvailableAlbum> excludedBackupAlbums;
/// Assets that are not overlapping in selected backup albums and excluded backup albums
final Set<AssetEntity> allUniqueAssets;
final Set<BackupCandidate> allUniqueAssets;
/// All assets from the selected albums that have been backup
final Set<String> selectedAlbumsBackupAssetsIds;
@@ -94,7 +94,7 @@ class BackUpState {
List<AvailableAlbum>? availableAlbums,
Set<AvailableAlbum>? selectedBackupAlbums,
Set<AvailableAlbum>? excludedBackupAlbums,
Set<AssetEntity>? allUniqueAssets,
Set<BackupCandidate>? allUniqueAssets,
Set<String>? selectedAlbumsBackupAssetsIds,
CurrentUploadAsset? currentUploadAsset,
}) {
@@ -1,11 +1,11 @@
import 'package:photo_manager/photo_manager.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
class ErrorUploadAsset {
final String id;
final DateTime fileCreatedAt;
final String fileName;
final String fileType;
final AssetEntity asset;
final Asset asset;
final String errorMessage;
const ErrorUploadAsset({
@@ -22,7 +22,7 @@ class ErrorUploadAsset {
DateTime? fileCreatedAt,
String? fileName,
String? fileType,
AssetEntity? asset,
Asset? asset,
String? errorMessage,
}) {
return ErrorUploadAsset(
@@ -0,0 +1,42 @@
import 'package:immich_mobile/models/backup/backup_candidate.model.dart';
class SuccessUploadAsset {
final BackupCandidate candidate;
final String remoteAssetId;
final bool isDuplicate;
SuccessUploadAsset({
required this.candidate,
required this.remoteAssetId,
required this.isDuplicate,
});
SuccessUploadAsset copyWith({
BackupCandidate? candidate,
String? remoteAssetId,
bool? isDuplicate,
}) {
return SuccessUploadAsset(
candidate: candidate ?? this.candidate,
remoteAssetId: remoteAssetId ?? this.remoteAssetId,
isDuplicate: isDuplicate ?? this.isDuplicate,
);
}
@override
String toString() =>
'SuccessUploadAsset(asset: $candidate, remoteAssetId: $remoteAssetId, isDuplicate: $isDuplicate)';
@override
bool operator ==(covariant SuccessUploadAsset other) {
if (identical(this, other)) return true;
return other.candidate == candidate &&
other.remoteAssetId == remoteAssetId &&
other.isDuplicate == isDuplicate;
}
@override
int get hashCode =>
candidate.hashCode ^ remoteAssetId.hashCode ^ isDuplicate.hashCode;
}
@@ -0,0 +1,109 @@
// ignore_for_file: public_member_api_docs, sort_constructors_first
import 'dart:convert';
import 'package:background_downloader/background_downloader.dart';
import 'package:collection/collection.dart';
class DownloadInfo {
final String fileName;
final double progress;
// enum
final TaskStatus status;
DownloadInfo({
required this.fileName,
required this.progress,
required this.status,
});
DownloadInfo copyWith({
String? fileName,
double? progress,
TaskStatus? status,
}) {
return DownloadInfo(
fileName: fileName ?? this.fileName,
progress: progress ?? this.progress,
status: status ?? this.status,
);
}
Map<String, dynamic> toMap() {
return <String, dynamic>{
'fileName': fileName,
'progress': progress,
'status': status.index,
};
}
factory DownloadInfo.fromMap(Map<String, dynamic> map) {
return DownloadInfo(
fileName: map['fileName'] as String,
progress: map['progress'] as double,
status: TaskStatus.values[map['status'] as int],
);
}
String toJson() => json.encode(toMap());
factory DownloadInfo.fromJson(String source) =>
DownloadInfo.fromMap(json.decode(source) as Map<String, dynamic>);
@override
String toString() =>
'DownloadInfo(fileName: $fileName, progress: $progress, status: $status)';
@override
bool operator ==(covariant DownloadInfo other) {
if (identical(this, other)) return true;
return other.fileName == fileName &&
other.progress == progress &&
other.status == status;
}
@override
int get hashCode => fileName.hashCode ^ progress.hashCode ^ status.hashCode;
}
class DownloadState {
// enum
final TaskStatus downloadStatus;
final Map<String, DownloadInfo> taskProgress;
final bool showProgress;
DownloadState({
required this.downloadStatus,
required this.taskProgress,
required this.showProgress,
});
DownloadState copyWith({
TaskStatus? downloadStatus,
Map<String, DownloadInfo>? taskProgress,
bool? showProgress,
}) {
return DownloadState(
downloadStatus: downloadStatus ?? this.downloadStatus,
taskProgress: taskProgress ?? this.taskProgress,
showProgress: showProgress ?? this.showProgress,
);
}
@override
String toString() =>
'DownloadState(downloadStatus: $downloadStatus, taskProgress: $taskProgress, showProgress: $showProgress)';
@override
bool operator ==(covariant DownloadState other) {
if (identical(this, other)) return true;
final mapEquals = const DeepCollectionEquality().equals;
return other.downloadStatus == downloadStatus &&
mapEquals(other.taskProgress, taskProgress) &&
other.showProgress == showProgress;
}
@override
int get hashCode =>
downloadStatus.hashCode ^ taskProgress.hashCode ^ showProgress.hashCode;
}
@@ -0,0 +1,60 @@
// ignore_for_file: public_member_api_docs, sort_constructors_first
import 'dart:convert';
enum LivePhotosPart {
video,
image,
}
class LivePhotosMetadata {
// enum
LivePhotosPart part;
String id;
LivePhotosMetadata({
required this.part,
required this.id,
});
LivePhotosMetadata copyWith({
LivePhotosPart? part,
String? id,
}) {
return LivePhotosMetadata(
part: part ?? this.part,
id: id ?? this.id,
);
}
Map<String, dynamic> toMap() {
return <String, dynamic>{
'part': part.index,
'id': id,
};
}
factory LivePhotosMetadata.fromMap(Map<String, dynamic> map) {
return LivePhotosMetadata(
part: LivePhotosPart.values[map['part'] as int],
id: map['id'] as String,
);
}
String toJson() => json.encode(toMap());
factory LivePhotosMetadata.fromJson(String source) =>
LivePhotosMetadata.fromMap(json.decode(source) as Map<String, dynamic>);
@override
String toString() => 'LivePhotosMetadata(part: $part, id: $id)';
@override
bool operator ==(covariant LivePhotosMetadata other) {
if (identical(this, other)) return true;
return other.part == part && other.id == id;
}
@override
int get hashCode => part.hashCode ^ id.hashCode;
}
@@ -2,7 +2,7 @@
import 'dart:convert';
import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:openapi/api.dart';
import 'package:immich_mobile/interfaces/person_api.interface.dart';
class SearchLocationFilter {
String? country;
@@ -235,7 +235,7 @@ class SearchDisplayFilters {
class SearchFilter {
String? context;
String? filename;
Set<PersonResponseDto> people;
Set<Person> people;
SearchLocationFilter location;
SearchCameraFilter camera;
SearchDateFilter date;
@@ -258,7 +258,7 @@ class SearchFilter {
SearchFilter copyWith({
String? context,
String? filename,
Set<PersonResponseDto>? people,
Set<Person>? people,
SearchLocationFilter? location,
SearchCameraFilter? camera,
SearchDateFilter? date,
@@ -266,8 +266,8 @@ class SearchFilter {
AssetType? mediaType,
}) {
return SearchFilter(
context: context ?? this.context,
filename: filename ?? this.filename,
context: context,
filename: filename,
people: people ?? this.people,
location: location ?? this.location,
camera: camera ?? this.camera,
@@ -0,0 +1,37 @@
import 'package:collection/collection.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
class SearchResult {
final List<Asset> assets;
final int? nextPage;
SearchResult({
required this.assets,
this.nextPage,
});
SearchResult copyWith({
List<Asset>? assets,
int? nextPage,
}) {
return SearchResult(
assets: assets ?? this.assets,
nextPage: nextPage ?? this.nextPage,
);
}
@override
String toString() => 'SearchResult(assets: $assets, nextPage: $nextPage)';
@override
bool operator ==(covariant SearchResult other) {
if (identical(this, other)) return true;
final listEquals = const DeepCollectionEquality().equals;
return listEquals(other.assets, assets) && other.nextPage == nextPage;
}
@override
int get hashCode => assets.hashCode ^ nextPage.hashCode;
}
@@ -4,11 +4,15 @@ class ServerConfig {
final int trashDays;
final String oauthButtonText;
final String externalDomain;
final String mapDarkStyleUrl;
final String mapLightStyleUrl;
const ServerConfig({
required this.trashDays,
required this.oauthButtonText,
required this.externalDomain,
required this.mapDarkStyleUrl,
required this.mapLightStyleUrl,
});
ServerConfig copyWith({
@@ -20,6 +24,8 @@ class ServerConfig {
trashDays: trashDays ?? this.trashDays,
oauthButtonText: oauthButtonText ?? this.oauthButtonText,
externalDomain: externalDomain ?? this.externalDomain,
mapDarkStyleUrl: mapDarkStyleUrl,
mapLightStyleUrl: mapLightStyleUrl,
);
}
@@ -30,7 +36,9 @@ class ServerConfig {
ServerConfig.fromDto(ServerConfigDto dto)
: trashDays = dto.trashDays,
oauthButtonText = dto.oauthButtonText,
externalDomain = dto.externalDomain;
externalDomain = dto.externalDomain,
mapDarkStyleUrl = dto.mapDarkStyleUrl,
mapLightStyleUrl = dto.mapLightStyleUrl;
@override
bool operator ==(covariant ServerConfig other) {
+469
View File
@@ -0,0 +1,469 @@
import 'dart:async';
import 'dart:math';
import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/theme_extensions.dart';
import 'package:immich_mobile/models/albums/album_search.model.dart';
import 'package:immich_mobile/pages/common/large_leading_tile.dart';
import 'package:immich_mobile/providers/album/album.provider.dart';
import 'package:immich_mobile/providers/album/album_sort_by_options.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/widgets/album/album_thumbnail_card.dart';
import 'package:immich_mobile/widgets/common/immich_app_bar.dart';
import 'package:immich_mobile/widgets/common/immich_thumbnail.dart';
@RoutePage()
class AlbumsPage extends HookConsumerWidget {
const AlbumsPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final albums =
ref.watch(albumProvider).where((album) => album.isRemote).toList();
final albumSortOption = ref.watch(albumSortByOptionsProvider);
final albumSortIsReverse = ref.watch(albumSortOrderProvider);
final sorted = albumSortOption.sortFn(albums, albumSortIsReverse);
final isGrid = useState(false);
final searchController = useTextEditingController();
final debounceTimer = useRef<Timer?>(null);
final filterMode = useState(QuickFilterMode.all);
final userId = ref.watch(currentUserProvider)?.id;
final searchFocusNode = useFocusNode();
toggleViewMode() {
isGrid.value = !isGrid.value;
}
onSearch(String searchTerm, QuickFilterMode mode) {
debounceTimer.value?.cancel();
debounceTimer.value = Timer(const Duration(milliseconds: 300), () {
ref.read(albumProvider.notifier).searchAlbums(searchTerm, mode);
});
}
changeFilter(QuickFilterMode mode) {
filterMode.value = mode;
}
useEffect(
() {
searchController.addListener(() {
onSearch(searchController.text, filterMode.value);
});
return () {
searchController.removeListener(() {
onSearch(searchController.text, filterMode.value);
});
debounceTimer.value?.cancel();
};
},
[],
);
clearSearch() {
filterMode.value = QuickFilterMode.all;
searchController.clear();
onSearch('', QuickFilterMode.all);
}
return Scaffold(
appBar: ImmichAppBar(
showUploadButton: false,
actions: [
IconButton(
icon: Icon(
Icons.add_rounded,
size: 28,
),
onPressed: () => context.pushRoute(
CreateAlbumRoute(),
),
),
],
),
body: RefreshIndicator(
displacement: 70,
onRefresh: () async {
await ref.read(albumProvider.notifier).refreshRemoteAlbums();
},
child: ListView(
shrinkWrap: true,
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 12),
children: [
Container(
decoration: BoxDecoration(
border: Border.all(
color: context.colorScheme.onSurface.withAlpha(0),
width: 0,
),
borderRadius: BorderRadius.circular(24),
gradient: LinearGradient(
colors: [
context.colorScheme.primary.withOpacity(0.075),
context.colorScheme.primary.withOpacity(0.09),
context.colorScheme.primary.withOpacity(0.075),
],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
transform: GradientRotation(0.5 * pi),
),
),
child: TextField(
autofocus: false,
decoration: InputDecoration(
contentPadding: EdgeInsets.all(16),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(25),
borderSide: BorderSide(
color: context.colorScheme.surfaceDim,
),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(25),
borderSide: BorderSide(
color: context.colorScheme.surfaceContainer,
),
),
disabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(25),
borderSide: BorderSide(
color: context.colorScheme.surfaceDim,
),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(25),
borderSide: BorderSide(
color: context.colorScheme.primary.withAlpha(100),
),
),
hintText: 'search_albums'.tr(),
hintStyle: context.textTheme.bodyLarge?.copyWith(
color: context.colorScheme.onSurfaceSecondary,
),
prefixIcon: const Icon(Icons.search_rounded),
suffixIcon: searchController.text.isNotEmpty
? IconButton(
icon: const Icon(Icons.clear_rounded),
onPressed: clearSearch,
)
: const SizedBox.shrink(),
),
controller: searchController,
onChanged: (_) =>
onSearch(searchController.text, filterMode.value),
focusNode: searchFocusNode,
onTapOutside: (_) => searchFocusNode.unfocus(),
),
),
const SizedBox(height: 8),
Wrap(
spacing: 4,
runSpacing: 4,
children: [
QuickFilterButton(
label: 'all'.tr(),
isSelected: filterMode.value == QuickFilterMode.all,
onTap: () {
changeFilter(QuickFilterMode.all);
onSearch(searchController.text, QuickFilterMode.all);
},
),
QuickFilterButton(
label: 'shared_with_me'.tr(),
isSelected: filterMode.value == QuickFilterMode.sharedWithMe,
onTap: () {
changeFilter(QuickFilterMode.sharedWithMe);
onSearch(
searchController.text,
QuickFilterMode.sharedWithMe,
);
},
),
QuickFilterButton(
label: 'my_albums'.tr(),
isSelected: filterMode.value == QuickFilterMode.myAlbums,
onTap: () {
changeFilter(QuickFilterMode.myAlbums);
onSearch(
searchController.text,
QuickFilterMode.myAlbums,
);
},
),
],
),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const SortButton(),
IconButton(
icon: Icon(
isGrid.value
? Icons.view_list_outlined
: Icons.grid_view_outlined,
size: 24,
),
onPressed: toggleViewMode,
),
],
),
const SizedBox(height: 5),
AnimatedSwitcher(
duration: const Duration(milliseconds: 500),
child: isGrid.value
? GridView.builder(
shrinkWrap: true,
physics: const ClampingScrollPhysics(),
gridDelegate:
const SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent: 250,
mainAxisSpacing: 12,
crossAxisSpacing: 12,
childAspectRatio: .7,
),
itemBuilder: (context, index) {
return AlbumThumbnailCard(
album: sorted[index],
onTap: () => context.pushRoute(
AlbumViewerRoute(albumId: sorted[index].id),
),
showOwner: true,
);
},
itemCount: sorted.length,
)
: ListView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
itemCount: sorted.length,
itemBuilder: (context, index) {
return Padding(
padding: const EdgeInsets.only(bottom: 8.0),
child: LargeLeadingTile(
title: Text(
sorted[index].name,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: context.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w600,
),
),
subtitle: sorted[index].ownerId == userId
? Text(
'${sorted[index].assetCount} items',
overflow: TextOverflow.ellipsis,
style:
context.textTheme.bodyMedium?.copyWith(
color: context
.colorScheme.onSurfaceSecondary,
),
)
: sorted[index].ownerName != null
? Text(
'${sorted[index].assetCount} items • ${'album_thumbnail_shared_by'.tr(
args: [
sorted[index].ownerName!,
],
)}',
overflow: TextOverflow.ellipsis,
style: context.textTheme.bodyMedium
?.copyWith(
color: context
.colorScheme.onSurfaceSecondary,
),
)
: null,
onTap: () => context.pushRoute(
AlbumViewerRoute(albumId: sorted[index].id),
),
leadingPadding: const EdgeInsets.only(
right: 16,
),
leading: ClipRRect(
borderRadius: const BorderRadius.all(
Radius.circular(15),
),
child: ImmichThumbnail(
asset: sorted[index].thumbnail.value,
width: 80,
height: 80,
),
),
// minVerticalPadding: 1,
),
);
},
),
),
],
),
),
);
}
}
class QuickFilterButton extends StatelessWidget {
const QuickFilterButton({
super.key,
required this.isSelected,
required this.onTap,
required this.label,
});
final bool isSelected;
final VoidCallback onTap;
final String label;
@override
Widget build(BuildContext context) {
return TextButton(
onPressed: onTap,
style: ButtonStyle(
backgroundColor: WidgetStateProperty.all(
isSelected ? context.colorScheme.primary : Colors.transparent,
),
shape: WidgetStateProperty.all(
RoundedRectangleBorder(
borderRadius: BorderRadius.circular(20),
side: BorderSide(
color: context.colorScheme.onSurface.withAlpha(25),
width: 1,
),
),
),
),
child: Text(
label,
style: TextStyle(
color: isSelected
? context.colorScheme.onPrimary
: context.colorScheme.onSurface,
fontSize: 14,
),
),
);
}
}
class SortButton extends ConsumerWidget {
const SortButton({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final albumSortOption = ref.watch(albumSortByOptionsProvider);
final albumSortIsReverse = ref.watch(albumSortOrderProvider);
return MenuAnchor(
style: MenuStyle(
elevation: WidgetStatePropertyAll(1),
shape: WidgetStateProperty.all(
RoundedRectangleBorder(
borderRadius: BorderRadius.circular(24),
),
),
padding: WidgetStatePropertyAll(
EdgeInsets.all(4),
),
),
consumeOutsideTap: true,
menuChildren: AlbumSortMode.values
.map(
(mode) => MenuItemButton(
leadingIcon: albumSortOption == mode
? albumSortIsReverse
? Icon(
Icons.keyboard_arrow_down,
color: albumSortOption == mode
? context.colorScheme.onPrimary
: context.colorScheme.onSurface,
)
: Icon(
Icons.keyboard_arrow_up_rounded,
color: albumSortOption == mode
? context.colorScheme.onPrimary
: context.colorScheme.onSurface,
)
: const Icon(Icons.abc, color: Colors.transparent),
onPressed: () {
final selected = albumSortOption == mode;
// Switch direction
if (selected) {
ref
.read(albumSortOrderProvider.notifier)
.changeSortDirection(!albumSortIsReverse);
} else {
ref
.read(albumSortByOptionsProvider.notifier)
.changeSortMode(mode);
}
},
style: ButtonStyle(
padding: WidgetStateProperty.all(
const EdgeInsets.fromLTRB(16, 16, 32, 16),
),
backgroundColor: WidgetStateProperty.all(
albumSortOption == mode
? context.colorScheme.primary
: Colors.transparent,
),
shape: WidgetStateProperty.all(
RoundedRectangleBorder(
borderRadius: BorderRadius.circular(24),
),
),
),
child: Text(
mode.label.tr(),
style: context.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w600,
color: albumSortOption == mode
? context.colorScheme.onPrimary
: context.colorScheme.onSurface.withAlpha(185),
),
),
),
)
.toList(),
builder: (context, controller, child) {
return GestureDetector(
onTap: () {
if (controller.isOpen) {
controller.close();
} else {
controller.open();
}
},
child: Row(
children: [
Padding(
padding: const EdgeInsets.only(right: 5),
child: Transform.rotate(
angle: 90 * pi / 180,
child: Icon(
Icons.compare_arrows_rounded,
size: 18,
color: context.colorScheme.onSurface.withAlpha(225),
),
),
),
Text(
albumSortOption.label.tr(),
style: context.textTheme.bodyLarge?.copyWith(
fontWeight: FontWeight.w500,
color: context.colorScheme.onSurface.withAlpha(225),
),
),
],
),
);
},
);
}
}
+16 -35
View File
@@ -1,26 +1,27 @@
import 'dart:typed_data';
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/widgets/common/immich_loading_indicator.dart';
import 'package:photo_manager/photo_manager.dart';
import 'package:immich_mobile/entities/album.entity.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/theme_extensions.dart';
import 'package:immich_mobile/repositories/album_media.repository.dart';
import 'package:immich_mobile/widgets/common/immich_thumbnail.dart';
@RoutePage()
class AlbumPreviewPage extends HookConsumerWidget {
final AssetPathEntity album;
final Album album;
const AlbumPreviewPage({super.key, required this.album});
@override
Widget build(BuildContext context, WidgetRef ref) {
final assets = useState<List<AssetEntity>>([]);
final assets = useState<List<Asset>>([]);
getAssetsInAlbum() async {
assets.value = await album.getAssetListRange(
start: 0,
end: await album.assetCountAsync,
);
assets.value = await ref
.read(albumMediaRepositoryProvider)
.getAssets(album.localId!);
}
useEffect(
@@ -46,7 +47,7 @@ class AlbumPreviewPage extends HookConsumerWidget {
"ID ${album.id}",
style: TextStyle(
fontSize: 10,
color: Colors.grey[600],
color: context.colorScheme.onSurfaceSecondary,
fontWeight: FontWeight.bold,
),
),
@@ -66,30 +67,10 @@ class AlbumPreviewPage extends HookConsumerWidget {
),
itemCount: assets.value.length,
itemBuilder: (context, index) {
Future<Uint8List?> thumbData =
assets.value[index].thumbnailDataWithSize(
const ThumbnailSize(200, 200),
quality: 50,
);
return FutureBuilder<Uint8List?>(
future: thumbData,
builder: ((context, snapshot) {
if (snapshot.hasData && snapshot.data != null) {
return Image.memory(
snapshot.data!,
width: 100,
height: 100,
fit: BoxFit.cover,
);
}
return const SizedBox(
width: 100,
height: 100,
child: ImmichLoadingIndicator(),
);
}),
return ImmichThumbnail(
asset: assets.value[index],
width: 100,
height: 100,
);
},
),
@@ -3,21 +3,25 @@ import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/immich_colors.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/providers/album/album.provider.dart';
import 'package:immich_mobile/providers/backup/backup.provider.dart';
import 'package:immich_mobile/services/app_settings.service.dart';
import 'package:immich_mobile/utils/hooks/app_settings_update_hook.dart';
import 'package:immich_mobile/widgets/backup/album_info_card.dart';
import 'package:immich_mobile/widgets/backup/album_info_list_tile.dart';
import 'package:immich_mobile/widgets/common/immich_loading_indicator.dart';
import 'package:immich_mobile/widgets/settings/settings_switch_list_tile.dart';
@RoutePage()
class BackupAlbumSelectionPage extends HookConsumerWidget {
const BackupAlbumSelectionPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
// final availableAlbums = ref.watch(backupProvider).availableAlbums;
final selectedBackupAlbums = ref.watch(backupProvider).selectedBackupAlbums;
final excludedBackupAlbums = ref.watch(backupProvider).excludedBackupAlbums;
final enableSyncUploadAlbum =
useAppSettingsState(AppSettingsEnum.syncAlbums);
final isDarkTheme = context.isDarkTheme;
final albums = ref.watch(backupProvider).availableAlbums;
@@ -128,13 +132,12 @@ class BackupAlbumSelectionPage extends HookConsumerWidget {
album.name,
style: TextStyle(
fontSize: 12,
color: isDarkTheme ? Colors.black : immichBackgroundColor,
color: context.scaffoldBackgroundColor,
fontWeight: FontWeight.bold,
),
),
backgroundColor: Colors.red[300],
deleteIconColor:
isDarkTheme ? Colors.black : immichBackgroundColor,
deleteIconColor: context.scaffoldBackgroundColor,
deleteIcon: const Icon(
Icons.cancel_rounded,
size: 15,
@@ -146,47 +149,14 @@ class BackupAlbumSelectionPage extends HookConsumerWidget {
}).toSet();
}
// buildSearchBar() {
// return Padding(
// padding: const EdgeInsets.only(left: 16.0, right: 16, bottom: 8.0),
// child: TextFormField(
// onChanged: (searchValue) {
// // if (searchValue.isEmpty) {
// // albums = availableAlbums;
// // } else {
// // albums.value = availableAlbums
// // .where(
// // (album) => album.name
// // .toLowerCase()
// // .contains(searchValue.toLowerCase()),
// // )
// // .toList();
// // }
// },
// decoration: InputDecoration(
// contentPadding: const EdgeInsets.symmetric(
// horizontal: 8.0,
// vertical: 8.0,
// ),
// hintText: "Search",
// hintStyle: TextStyle(
// color: isDarkTheme ? Colors.white : Colors.grey,
// fontSize: 14.0,
// ),
// prefixIcon: const Icon(
// Icons.search,
// color: Colors.grey,
// ),
// border: OutlineInputBorder(
// borderRadius: BorderRadius.circular(10),
// borderSide: BorderSide.none,
// ),
// filled: true,
// fillColor: isDarkTheme ? Colors.white30 : Colors.grey[200],
// ),
// ),
// );
// }
handleSyncAlbumToggle(bool isEnable) async {
if (isEnable) {
await ref.read(albumProvider.notifier).refreshRemoteAlbums();
for (final album in selectedBackupAlbums) {
await ref.read(albumProvider.notifier).createSyncAlbum(album.name);
}
}
}
return Scaffold(
appBar: AppBar(
@@ -228,6 +198,20 @@ class BackupAlbumSelectionPage extends HookConsumerWidget {
),
),
SettingsSwitchListTile(
valueNotifier: enableSyncUploadAlbum,
title: "sync_albums".tr(),
subtitle: "sync_upload_album_setting_subtitle".tr(),
contentPadding: const EdgeInsets.symmetric(horizontal: 16),
titleStyle: context.textTheme.bodyLarge?.copyWith(
fontWeight: FontWeight.bold,
),
subtitleStyle: context.textTheme.labelLarge?.copyWith(
color: context.colorScheme.primary,
),
onChanged: handleSyncAlbumToggle,
),
ListTile(
title: Text(
"backup_album_selection_page_albums_device".tr(
@@ -1,12 +1,15 @@
import 'dart:async';
import 'dart:io';
import 'dart:math';
import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/theme_extensions.dart';
import 'package:immich_mobile/models/backup/backup_state.model.dart';
import 'package:immich_mobile/providers/album/album.provider.dart';
import 'package:immich_mobile/providers/backup/backup.provider.dart';
@@ -17,6 +20,7 @@ import 'package:immich_mobile/providers/websocket.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/widgets/backup/backup_info_card.dart';
import 'package:immich_mobile/widgets/backup/current_backup_asset_info_box.dart';
import 'package:wakelock_plus/wakelock_plus.dart';
@RoutePage()
class BackupControllerPage extends HookConsumerWidget {
@@ -27,6 +31,9 @@ class BackupControllerPage extends HookConsumerWidget {
BackUpState backupState = ref.watch(backupProvider);
final hasAnyAlbum = backupState.selectedBackupAlbums.isNotEmpty;
final didGetBackupInfo = useState(false);
final isScreenDarkened = useState(false);
final darkenScreenTimer = useRef<Timer?>(null);
bool hasExclusiveAccess =
backupState.backupProgress != BackUpProgressEnum.inBackground;
bool shouldBackup = backupState.allUniqueAssets.length -
@@ -36,6 +43,25 @@ class BackupControllerPage extends HookConsumerWidget {
? false
: true;
void startScreenDarkenTimer() {
darkenScreenTimer.value = Timer(const Duration(seconds: 30), () {
isScreenDarkened.value = true;
SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky);
});
}
void stopScreenDarkenTimer() {
darkenScreenTimer.value?.cancel();
isScreenDarkened.value = false;
SystemChrome.setEnabledSystemUIMode(
SystemUiMode.manual,
overlays: [
SystemUiOverlay.top,
SystemUiOverlay.bottom,
],
);
}
useEffect(
() {
// Update the background settings information just to make sure we
@@ -48,7 +74,12 @@ class BackupControllerPage extends HookConsumerWidget {
ref
.watch(websocketProvider.notifier)
.stopListenToEvent('on_upload_success');
return null;
return () {
WakelockPlus.disable();
darkenScreenTimer.value?.cancel();
isScreenDarkened.value = false;
};
},
[],
);
@@ -65,6 +96,21 @@ class BackupControllerPage extends HookConsumerWidget {
[backupState.backupProgress],
);
useEffect(
() {
if (backupState.backupProgress == BackUpProgressEnum.inProgress) {
startScreenDarkenTimer();
WakelockPlus.enable();
} else {
stopScreenDarkenTimer();
WakelockPlus.disable();
}
return null;
},
[backupState.backupProgress],
);
Widget buildSelectedAlbumName() {
var text = "backup_controller_page_backup_selected".tr();
var albums = ref.watch(backupProvider).selectedBackupAlbums;
@@ -130,9 +176,7 @@ class BackupControllerPage extends HookConsumerWidget {
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(20),
side: BorderSide(
color: context.isDarkTheme
? const Color.fromARGB(255, 56, 56, 56)
: Colors.black12,
color: context.colorScheme.outlineVariant,
width: 1,
),
),
@@ -151,7 +195,9 @@ class BackupControllerPage extends HookConsumerWidget {
children: [
Text(
"backup_controller_page_to_backup",
style: context.textTheme.bodyMedium,
style: context.textTheme.bodyMedium?.copyWith(
color: context.colorScheme.onSurfaceSecondary,
),
).tr(),
buildSelectedAlbumName(),
buildExcludedAlbumName(),
@@ -166,7 +212,7 @@ class BackupControllerPage extends HookConsumerWidget {
.read(backupProvider.notifier)
.backupAlbumSelectionDone();
// waited until backup albums are stored in DB
ref.read(albumProvider.notifier).getDeviceAlbums();
ref.read(albumProvider.notifier).refreshDeviceAlbums();
},
child: const Text(
"backup_controller_page_select",
@@ -251,72 +297,102 @@ class BackupControllerPage extends HookConsumerWidget {
);
}
return Scaffold(
appBar: AppBar(
elevation: 0,
title: const Text(
"backup_controller_page_backup",
).tr(),
leading: IconButton(
onPressed: () {
ref.watch(websocketProvider.notifier).listenUploadEvent();
context.maybePop(true);
},
splashRadius: 24,
icon: const Icon(
Icons.arrow_back_ios_rounded,
),
),
actions: [
Padding(
padding: const EdgeInsets.only(right: 8.0),
child: IconButton(
onPressed: () => context.pushRoute(const BackupOptionsRoute()),
return GestureDetector(
onTap: () {
if (isScreenDarkened.value) {
stopScreenDarkenTimer();
}
if (backupState.backupProgress == BackUpProgressEnum.inProgress) {
startScreenDarkenTimer();
}
},
child: AnimatedOpacity(
opacity: isScreenDarkened.value ? 0.1 : 1.0,
duration: const Duration(seconds: 1),
child: Scaffold(
appBar: AppBar(
elevation: 0,
title: const Text(
"backup_controller_page_backup",
).tr(),
leading: IconButton(
onPressed: () {
ref.watch(websocketProvider.notifier).listenUploadEvent();
context.maybePop(true);
},
splashRadius: 24,
icon: const Icon(
Icons.settings_outlined,
Icons.arrow_back_ios_rounded,
),
),
actions: [
Padding(
padding: const EdgeInsets.only(right: 8.0),
child: IconButton(
onPressed: () =>
context.pushRoute(const BackupOptionsRoute()),
splashRadius: 24,
icon: const Icon(
Icons.settings_outlined,
),
),
),
],
),
body: Stack(
children: [
Padding(
padding:
const EdgeInsets.only(left: 16.0, right: 16, bottom: 32),
child: ListView(
// crossAxisAlignment: CrossAxisAlignment.start,
children: hasAnyAlbum
? [
buildFolderSelectionTile(),
BackupInfoCard(
title: "backup_controller_page_total".tr(),
subtitle: "backup_controller_page_total_sub".tr(),
info: ref
.watch(backupProvider)
.availableAlbums
.isEmpty
? "..."
: "${backupState.allUniqueAssets.length}",
),
BackupInfoCard(
title: "backup_controller_page_backup".tr(),
subtitle: "backup_controller_page_backup_sub".tr(),
info: ref
.watch(backupProvider)
.availableAlbums
.isEmpty
? "..."
: "${backupState.selectedAlbumsBackupAssetsIds.length}",
),
BackupInfoCard(
title: "backup_controller_page_remainder".tr(),
subtitle:
"backup_controller_page_remainder_sub".tr(),
info: ref
.watch(backupProvider)
.availableAlbums
.isEmpty
? "..."
: "${max(0, backupState.allUniqueAssets.length - backupState.selectedAlbumsBackupAssetsIds.length)}",
),
const Divider(),
const CurrentUploadingAssetInfoBox(),
if (!hasExclusiveAccess) buildBackgroundBackupInfo(),
buildBackupButton(),
]
: [
buildFolderSelectionTile(),
if (!didGetBackupInfo.value) buildLoadingIndicator(),
],
),
),
],
),
],
),
body: Padding(
padding: const EdgeInsets.only(left: 16.0, right: 16, bottom: 32),
child: ListView(
// crossAxisAlignment: CrossAxisAlignment.start,
children: hasAnyAlbum
? [
buildFolderSelectionTile(),
BackupInfoCard(
title: "backup_controller_page_total".tr(),
subtitle: "backup_controller_page_total_sub".tr(),
info: ref.watch(backupProvider).availableAlbums.isEmpty
? "..."
: "${backupState.allUniqueAssets.length}",
),
BackupInfoCard(
title: "backup_controller_page_backup".tr(),
subtitle: "backup_controller_page_backup_sub".tr(),
info: ref.watch(backupProvider).availableAlbums.isEmpty
? "..."
: "${backupState.selectedAlbumsBackupAssetsIds.length}",
),
BackupInfoCard(
title: "backup_controller_page_remainder".tr(),
subtitle: "backup_controller_page_remainder_sub".tr(),
info: ref.watch(backupProvider).availableAlbums.isEmpty
? "..."
: "${max(0, backupState.allUniqueAssets.length - backupState.selectedAlbumsBackupAssetsIds.length)}",
),
const Divider(),
const CurrentUploadingAssetInfoBox(),
if (!hasExclusiveAccess) buildBackgroundBackupInfo(),
buildBackupButton(),
]
: [
buildFolderSelectionTile(),
if (!didGetBackupInfo.value) buildLoadingIndicator(),
],
),
),
);
@@ -3,9 +3,8 @@ import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/providers/backup/error_backup_list.provider.dart';
import 'package:immich_mobile/providers/image/immich_local_thumbnail_provider.dart';
import 'package:intl/intl.dart';
import 'package:photo_manager/photo_manager.dart';
import 'package:photo_manager_image_provider/photo_manager_image_provider.dart';
@RoutePage()
class FailedBackupStatusPage extends HookConsumerWidget {
@@ -70,11 +69,10 @@ class FailedBackupStatusPage extends HookConsumerWidget {
clipBehavior: Clip.hardEdge,
child: Image(
fit: BoxFit.cover,
image: AssetEntityImageProvider(
errorAsset.asset,
isOriginal: false,
thumbnailSize: const ThumbnailSize.square(512),
thumbnailFormat: ThumbnailFormat.jpeg,
image: ImmichLocalThumbnailProvider(
asset: errorAsset.asset,
height: 512,
width: 512,
),
),
),
@@ -10,7 +10,7 @@ import 'package:immich_mobile/entities/album.entity.dart';
import 'package:immich_mobile/entities/user.entity.dart';
import 'package:immich_mobile/widgets/common/user_circle_avatar.dart';
@RoutePage<List<String>?>()
@RoutePage()
class AlbumAdditionalSharedUserSelectionPage extends HookConsumerWidget {
final Album album;
@@ -12,7 +12,7 @@ import 'package:immich_mobile/widgets/asset_grid/immich_asset_grid.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:isar/isar.dart';
@RoutePage<AssetSelectionPageResult?>()
@RoutePage()
class AlbumAssetSelectionPage extends HookConsumerWidget {
const AlbumAssetSelectionPage({
super.key,
+13 -12
View File
@@ -5,7 +5,8 @@ import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:fluttertoast/fluttertoast.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/providers/album/shared_album.provider.dart';
import 'package:immich_mobile/extensions/theme_extensions.dart';
import 'package:immich_mobile/providers/album/album.provider.dart';
import 'package:immich_mobile/providers/authentication.provider.dart';
import 'package:immich_mobile/utils/immich_loading_overlay.dart';
import 'package:immich_mobile/routing/router.dart';
@@ -44,11 +45,11 @@ class AlbumOptionsPage extends HookConsumerWidget {
try {
final isSuccess =
await ref.read(sharedAlbumProvider.notifier).leaveAlbum(album);
await ref.read(albumProvider.notifier).leaveAlbum(album);
if (isSuccess) {
context.navigateTo(
const TabControllerRoute(children: [SharingRoute()]),
TabControllerRoute(children: [AlbumsRoute()]),
);
} else {
showErrorMessage();
@@ -64,9 +65,7 @@ class AlbumOptionsPage extends HookConsumerWidget {
isProcessing.value = true;
try {
await ref
.read(sharedAlbumProvider.notifier)
.removeUserFromAlbum(album, user);
await ref.read(albumProvider.notifier).removeUser(album, user);
album.sharedUsers.remove(user);
sharedUsers.value = album.sharedUsers.toList();
} catch (error) {
@@ -102,7 +101,7 @@ class AlbumOptionsPage extends HookConsumerWidget {
}
showModalBottomSheet(
backgroundColor: context.scaffoldBackgroundColor,
backgroundColor: context.colorScheme.surfaceContainer,
isScrollControlled: false,
context: context,
builder: (context) {
@@ -131,7 +130,7 @@ class AlbumOptionsPage extends HookConsumerWidget {
),
subtitle: Text(
album.owner.value?.email ?? "",
style: TextStyle(color: Colors.grey[600]),
style: TextStyle(color: context.colorScheme.onSurfaceSecondary),
),
trailing: Text(
"shared_album_section_people_owner_label",
@@ -160,7 +159,9 @@ class AlbumOptionsPage extends HookConsumerWidget {
),
subtitle: Text(
user.email,
style: TextStyle(color: Colors.grey[600]),
style: TextStyle(
color: context.colorScheme.onSurfaceSecondary,
),
),
trailing: userId == user.id || isOwner
? const Icon(Icons.more_horiz_rounded)
@@ -197,8 +198,8 @@ class AlbumOptionsPage extends HookConsumerWidget {
onChanged: (bool value) async {
activityEnabled.value = value;
if (await ref
.read(sharedAlbumProvider.notifier)
.setActivityEnabled(album, value)) {
.read(albumProvider.notifier)
.setActivitystatus(album, value)) {
album.activityEnabled = value;
}
},
@@ -214,7 +215,7 @@ class AlbumOptionsPage extends HookConsumerWidget {
subtitle: Text(
"shared_album_activity_setting_subtitle",
style: context.textTheme.labelLarge?.copyWith(
color: context.textTheme.labelLarge?.color?.withAlpha(175),
color: context.colorScheme.onSurfaceSecondary,
),
).tr(),
),
@@ -5,15 +5,15 @@ import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/asyncvalue_extensions.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/providers/album/album.provider.dart';
import 'package:immich_mobile/providers/album/album_title.provider.dart';
import 'package:immich_mobile/providers/album/shared_album.provider.dart';
import 'package:immich_mobile/providers/album/suggested_shared_users.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/entities/user.entity.dart';
import 'package:immich_mobile/widgets/common/user_circle_avatar.dart';
@RoutePage<List<String>>()
@RoutePage()
class AlbumSharedUserSelectionPage extends HookConsumerWidget {
const AlbumSharedUserSelectionPage({super.key, required this.assets});
@@ -25,20 +25,15 @@ class AlbumSharedUserSelectionPage extends HookConsumerWidget {
final suggestedShareUsers = ref.watch(otherUsersProvider);
createSharedAlbum() async {
var newAlbum =
await ref.watch(sharedAlbumProvider.notifier).createSharedAlbum(
ref.watch(albumTitleProvider),
assets,
sharedUsersList.value,
);
var newAlbum = await ref.watch(albumProvider.notifier).createAlbum(
ref.watch(albumTitleProvider),
assets,
);
if (newAlbum != null) {
await ref.watch(sharedAlbumProvider.notifier).getAllSharedAlbums();
// ref.watch(assetSelectionProvider.notifier).removeAll();
ref.watch(albumTitleProvider.notifier).clearAlbumTitle();
context.maybePop(true);
context
.navigateTo(const TabControllerRoute(children: [SharingRoute()]));
context.navigateTo(TabControllerRoute(children: [AlbumsRoute()]));
}
ScaffoldMessenger(
+34 -38
View File
@@ -11,10 +11,8 @@ import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/models/albums/asset_selection_page_result.model.dart';
import 'package:immich_mobile/providers/album/album.provider.dart';
import 'package:immich_mobile/providers/album/current_album.provider.dart';
import 'package:immich_mobile/providers/album/shared_album.provider.dart';
import 'package:immich_mobile/utils/immich_loading_overlay.dart';
import 'package:immich_mobile/services/album.service.dart';
import 'package:immich_mobile/widgets/album/album_action_outlined_button.dart';
import 'package:immich_mobile/widgets/album/album_action_filled_button.dart';
import 'package:immich_mobile/widgets/album/album_viewer_editable_title.dart';
import 'package:immich_mobile/providers/multiselect.provider.dart';
import 'package:immich_mobile/providers/authentication.provider.dart';
@@ -50,9 +48,7 @@ class AlbumViewerPage extends HookConsumerWidget {
Future<bool> onRemoveFromAlbumPressed(Iterable<Asset> assets) async {
final a = album.valueOrNull;
final bool isSuccess = a != null &&
await ref
.read(sharedAlbumProvider.notifier)
.removeAssetFromAlbum(a, assets);
await ref.read(albumProvider.notifier).removeAsset(a, assets);
if (!isSuccess) {
ImmichToast.show(
@@ -81,9 +77,9 @@ class AlbumViewerPage extends HookConsumerWidget {
// Check if there is new assets add
isProcessing.value = true;
await ref.watch(albumServiceProvider).addAdditionalAssetToAlbum(
returnPayload.selectedAssets,
await ref.watch(albumProvider.notifier).addAssets(
albumInfo,
returnPayload.selectedAssets,
);
isProcessing.value = false;
@@ -98,9 +94,7 @@ class AlbumViewerPage extends HookConsumerWidget {
if (sharedUserIds != null) {
isProcessing.value = true;
await ref
.watch(albumServiceProvider)
.addAdditionalUserToAlbum(sharedUserIds, album);
await ref.watch(albumProvider.notifier).addUsers(album, sharedUserIds);
isProcessing.value = false;
}
@@ -114,13 +108,13 @@ class AlbumViewerPage extends HookConsumerWidget {
child: ListView(
scrollDirection: Axis.horizontal,
children: [
AlbumActionOutlinedButton(
AlbumActionFilledButton(
iconData: Icons.add_photo_alternate_outlined,
onPressed: () => onAddPhotosPressed(album),
labelText: "share_add_photos".tr(),
),
if (userId == album.ownerId)
AlbumActionOutlinedButton(
AlbumActionFilledButton(
iconData: Icons.person_add_alt_rounded,
onPressed: () => onAddUsersPressed(album),
labelText: "album_viewer_page_share_add_users".tr(),
@@ -184,27 +178,29 @@ class AlbumViewerPage extends HookConsumerWidget {
}
Widget buildSharedUserIconsRow(Album album) {
return GestureDetector(
onTap: () => context.pushRoute(AlbumOptionsRoute(album: album)),
child: SizedBox(
height: 50,
child: ListView.builder(
padding: const EdgeInsets.only(left: 16),
scrollDirection: Axis.horizontal,
itemBuilder: ((context, index) {
return Padding(
padding: const EdgeInsets.only(right: 8.0),
child: UserCircleAvatar(
user: album.sharedUsers.toList()[index],
radius: 18,
size: 36,
return album.sharedUsers.isNotEmpty
? GestureDetector(
onTap: () => context.pushRoute(AlbumOptionsRoute(album: album)),
child: SizedBox(
height: 50,
child: ListView.builder(
padding: const EdgeInsets.only(left: 16),
scrollDirection: Axis.horizontal,
itemBuilder: ((context, index) {
return Padding(
padding: const EdgeInsets.only(right: 8.0),
child: UserCircleAvatar(
user: album.sharedUsers.toList()[index],
radius: 18,
size: 36,
),
);
}),
itemCount: album.sharedUsers.length,
),
);
}),
itemCount: album.sharedUsers.length,
),
),
);
),
)
: const SizedBox.shrink();
}
Widget buildHeader(Album album) {
@@ -214,7 +210,7 @@ class AlbumViewerPage extends HookConsumerWidget {
children: [
buildTitle(album),
if (album.assets.isNotEmpty == true) buildAlbumDateRange(album),
if (album.shared) buildSharedUserIconsRow(album),
buildSharedUserIconsRow(album),
],
);
}
@@ -231,17 +227,17 @@ class AlbumViewerPage extends HookConsumerWidget {
body: Stack(
children: [
album.widgetWhen(
onData: (data) => MultiselectGrid(
onData: (albumInfo) => MultiselectGrid(
renderListProvider: albumRenderlistProvider(albumId),
topWidget: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
buildHeader(data),
if (data.isRemote) buildControlButton(data),
buildHeader(albumInfo),
if (albumInfo.isRemote) buildControlButton(albumInfo),
],
),
onRemoveFromAlbum: onRemoveFromAlbumPressed,
editEnabled: data.ownerId == userId,
editEnabled: albumInfo.ownerId == userId,
),
),
AnimatedPositioned(
+7 -13
View File
@@ -3,6 +3,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/theme_extensions.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/entities/logger_message.entity.dart';
import 'package:immich_mobile/services/immich_logger.service.dart';
@@ -18,7 +19,6 @@ class AppLogPage extends HookConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) {
final immichLogger = ImmichLogger();
final logMessages = useState(immichLogger.messages);
final isDarkTheme = context.isDarkTheme;
Widget colorStatusIndicator(Color color) {
return Column(
@@ -55,13 +55,9 @@ class AppLogPage extends HookConsumerWidget {
case LogLevel.INFO:
return Colors.transparent;
case LogLevel.SEVERE:
return isDarkTheme
? Colors.redAccent.withOpacity(0.25)
: Colors.redAccent.withOpacity(0.075);
return Colors.redAccent.withOpacity(0.25);
case LogLevel.WARNING:
return isDarkTheme
? Colors.orangeAccent.withOpacity(0.25)
: Colors.orangeAccent.withOpacity(0.075);
return Colors.orangeAccent.withOpacity(0.25);
default:
return context.primaryColor.withOpacity(0.1);
}
@@ -120,10 +116,7 @@ class AppLogPage extends HookConsumerWidget {
),
body: ListView.separated(
separatorBuilder: (context, index) {
return Divider(
height: 0,
color: isDarkTheme ? Colors.white70 : Colors.grey[600],
);
return const Divider(height: 0);
},
itemCount: logMessages.value.length,
itemBuilder: (context, index) {
@@ -141,8 +134,9 @@ class AppLogPage extends HookConsumerWidget {
minLeadingWidth: 10,
title: Text(
truncateLogMessage(logMessage.message, 4),
style: const TextStyle(
style: TextStyle(
fontSize: 14.0,
color: context.colorScheme.onSurface,
fontFamily: "Inconsolata",
),
),
@@ -150,7 +144,7 @@ class AppLogPage extends HookConsumerWidget {
"at ${DateFormat("HH:mm:ss.SSS").format(logMessage.createdAt)} in ${logMessage.context1}",
style: TextStyle(
fontSize: 12.0,
color: Colors.grey[600],
color: context.colorScheme.onSurfaceSecondary,
),
),
leading: buildLeadingIcon(logMessage.level),
@@ -13,8 +13,6 @@ class AppLogDetailPage extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
var isDarkTheme = context.isDarkTheme;
buildTextWithCopyButton(String header, String text) {
return Padding(
padding: const EdgeInsets.all(8.0),
@@ -61,7 +59,7 @@ class AppLogDetailPage extends HookConsumerWidget {
),
Container(
decoration: BoxDecoration(
color: isDarkTheme ? Colors.grey[900] : Colors.grey[200],
color: context.colorScheme.surfaceContainerHigh,
borderRadius: BorderRadius.circular(15.0),
),
child: Padding(
@@ -100,7 +98,7 @@ class AppLogDetailPage extends HookConsumerWidget {
),
Container(
decoration: BoxDecoration(
color: isDarkTheme ? Colors.grey[900] : Colors.grey[200],
color: context.colorScheme.surfaceContainerHigh,
borderRadius: BorderRadius.circular(15.0),
),
child: Padding(
+29 -56
View File
@@ -10,20 +10,18 @@ import 'package:immich_mobile/providers/album/album.provider.dart';
import 'package:immich_mobile/providers/album/album_title.provider.dart';
import 'package:immich_mobile/providers/asset.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/widgets/album/album_action_outlined_button.dart';
import 'package:immich_mobile/widgets/album/album_action_filled_button.dart';
import 'package:immich_mobile/widgets/album/album_title_text_field.dart';
import 'package:immich_mobile/widgets/album/shared_album_thumbnail_image.dart';
@RoutePage()
// ignore: must_be_immutable
class CreateAlbumPage extends HookConsumerWidget {
final bool isSharedAlbum;
final List<Asset>? initialAssets;
final List<Asset>? assets;
const CreateAlbumPage({
super.key,
required this.isSharedAlbum,
this.initialAssets,
this.assets,
});
@override
@@ -34,24 +32,16 @@ class CreateAlbumPage extends HookConsumerWidget {
final isAlbumTitleTextFieldFocus = useState(false);
final isAlbumTitleEmpty = useState(true);
final selectedAssets = useState<Set<Asset>>(
initialAssets != null ? Set.from(initialAssets!) : const {},
assets != null ? Set.from(assets!) : const {},
);
showSelectUserPage() async {
final bool? ok = await context.pushRoute<bool?>(
AlbumSharedUserSelectionRoute(assets: selectedAssets.value),
);
if (ok == true) {
selectedAssets.value = {};
}
}
void onBackgroundTapped() {
albumTitleTextFieldFocusNode.unfocus();
isAlbumTitleTextFieldFocus.value = false;
if (albumTitleController.text.isEmpty) {
albumTitleController.text = 'create_album_page_untitled'.tr();
isAlbumTitleEmpty.value = false;
ref
.watch(albumTitleProvider.notifier)
.setAlbumTitle('create_album_page_untitled'.tr());
@@ -109,20 +99,16 @@ class CreateAlbumPage extends HookConsumerWidget {
if (selectedAssets.value.isEmpty) {
return SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.only(top: 16, left: 18, right: 18),
child: OutlinedButton.icon(
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.only(top: 16, left: 16, right: 16),
child: FilledButton.icon(
style: FilledButton.styleFrom(
alignment: Alignment.centerLeft,
padding:
const EdgeInsets.symmetric(vertical: 22, horizontal: 16),
side: BorderSide(
color: context.isDarkTheme
? const Color.fromARGB(255, 63, 63, 63)
: const Color.fromARGB(255, 129, 129, 129),
),
const EdgeInsets.symmetric(vertical: 24, horizontal: 16),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(5),
borderRadius: BorderRadius.circular(10),
),
backgroundColor: context.colorScheme.surfaceContainerHigh,
),
onPressed: onSelectPhotosButtonPressed,
icon: Icon(
@@ -134,6 +120,7 @@ class CreateAlbumPage extends HookConsumerWidget {
child: Text(
'create_shared_album_page_share_select_photos',
style: context.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
color: context.primaryColor,
),
).tr(),
@@ -150,11 +137,11 @@ class CreateAlbumPage extends HookConsumerWidget {
return Padding(
padding: const EdgeInsets.only(left: 12.0, top: 16, bottom: 16),
child: SizedBox(
height: 30,
height: 42,
child: ListView(
scrollDirection: Axis.horizontal,
children: [
AlbumActionOutlinedButton(
AlbumActionFilledButton(
iconData: Icons.add_photo_alternate_outlined,
onPressed: onSelectPhotosButtonPressed,
labelText: "share_add_photos".tr(),
@@ -194,13 +181,14 @@ class CreateAlbumPage extends HookConsumerWidget {
}
createNonSharedAlbum() async {
onBackgroundTapped();
var newAlbum = await ref.watch(albumProvider.notifier).createAlbum(
ref.watch(albumTitleProvider),
selectedAssets.value,
);
if (newAlbum != null) {
ref.watch(albumProvider.notifier).getAllAlbums();
ref.watch(albumProvider.notifier).refreshRemoteAlbums();
selectedAssets.value = {};
ref.watch(albumTitleProvider.notifier).clearAlbumTitle();
@@ -224,35 +212,20 @@ class CreateAlbumPage extends HookConsumerWidget {
'share_create_album',
).tr(),
actions: [
if (isSharedAlbum)
TextButton(
onPressed: albumTitleController.text.isNotEmpty
? showSelectUserPage
: null,
child: Text(
'create_shared_album_page_share'.tr(),
style: TextStyle(
fontWeight: FontWeight.bold,
color: albumTitleController.text.isEmpty
? context.themeData.disabledColor
: context.primaryColor,
),
),
),
if (!isSharedAlbum)
TextButton(
onPressed: albumTitleController.text.isNotEmpty &&
selectedAssets.value.isNotEmpty
? createNonSharedAlbum
: null,
child: Text(
'create_shared_album_page_create'.tr(),
style: TextStyle(
fontWeight: FontWeight.bold,
color: context.primaryColor,
),
TextButton(
onPressed: albumTitleController.text.isNotEmpty
? createNonSharedAlbum
: null,
child: Text(
'create_shared_album_page_create'.tr(),
style: TextStyle(
fontWeight: FontWeight.bold,
color: albumTitleController.text.isNotEmpty
? context.primaryColor
: context.themeData.disabledColor,
),
),
),
],
),
body: GestureDetector(
@@ -266,7 +239,7 @@ class CreateAlbumPage extends HookConsumerWidget {
pinned: true,
floating: false,
bottom: PreferredSize(
preferredSize: const Size.fromHeight(66.0),
preferredSize: const Size.fromHeight(96.0),
child: Column(
children: [
buildTitleInputField(),
+150
View File
@@ -0,0 +1,150 @@
import 'package:background_downloader/background_downloader.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/providers/asset_viewer/download.provider.dart';
class DownloadPanel extends ConsumerWidget {
const DownloadPanel({
super.key,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final showProgress = ref.watch(
downloadStateProvider.select((state) => state.showProgress),
);
final tasks = ref
.watch(
downloadStateProvider.select((state) => state.taskProgress),
)
.entries
.toList();
onCancelDownload(String id) {
ref.watch(downloadStateProvider.notifier).cancelDownload(id);
}
return Positioned(
bottom: 140,
left: 16,
child: AnimatedSwitcher(
duration: const Duration(milliseconds: 300),
child: showProgress
? ConstrainedBox(
constraints:
BoxConstraints.loose(Size(context.width - 32, 300)),
child: ListView.builder(
shrinkWrap: true,
itemCount: tasks.length,
itemBuilder: (context, index) {
final task = tasks[index];
return DownloadTaskTile(
progress: task.value.progress,
fileName: task.value.fileName,
status: task.value.status,
onCancelDownload: () => onCancelDownload(task.key),
);
},
),
)
: const SizedBox.shrink(key: ValueKey('no_progress')),
),
);
}
}
class DownloadTaskTile extends StatelessWidget {
final double progress;
final String fileName;
final TaskStatus status;
final VoidCallback onCancelDownload;
const DownloadTaskTile({
super.key,
required this.progress,
required this.fileName,
required this.status,
required this.onCancelDownload,
});
@override
Widget build(BuildContext context) {
final progressPercent = (progress * 100).round();
getStatusText() {
switch (status) {
case TaskStatus.running:
return 'downloading'.tr();
case TaskStatus.complete:
return 'download_complete'.tr();
case TaskStatus.failed:
return 'download_failed'.tr();
case TaskStatus.canceled:
return 'download_canceled'.tr();
case TaskStatus.paused:
return 'download_paused'.tr();
case TaskStatus.enqueued:
return 'download_enqueue'.tr();
case TaskStatus.notFound:
return 'download_notfound'.tr();
case TaskStatus.waitingToRetry:
return 'download_waiting_to_retry'.tr();
}
}
return SizedBox(
key: const ValueKey('download_progress'),
width: MediaQuery.of(context).size.width - 32,
child: Card(
clipBehavior: Clip.antiAlias,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
child: ListTile(
minVerticalPadding: 18,
leading: const Icon(Icons.video_file_outlined),
title: Text(
getStatusText(),
style: context.textTheme.labelLarge,
),
trailing: IconButton(
icon: Icon(Icons.close, color: context.colorScheme.onError),
onPressed: onCancelDownload,
style: ElevatedButton.styleFrom(
backgroundColor: context.colorScheme.error.withAlpha(200),
),
),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
fileName,
style: context.textTheme.labelMedium,
),
Row(
children: [
Expanded(
child: LinearProgressIndicator(
minHeight: 8.0,
value: progress,
borderRadius:
const BorderRadius.all(Radius.circular(10.0)),
),
),
const SizedBox(width: 8),
Text(
'$progressPercent%',
style: context.textTheme.labelSmall,
),
],
),
],
),
),
),
);
}
}
@@ -8,8 +8,10 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_hooks/flutter_hooks.dart' hide Store;
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/constants.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/pages/common/download_panel.dart';
import 'package:immich_mobile/pages/common/video_viewer.page.dart';
import 'package:immich_mobile/providers/app_settings.provider.dart';
import 'package:immich_mobile/providers/asset_viewer/asset_stack.provider.dart';
@@ -22,7 +24,7 @@ import 'package:immich_mobile/services/app_settings.service.dart';
import 'package:immich_mobile/widgets/asset_grid/asset_grid_data_structure.dart';
import 'package:immich_mobile/widgets/asset_viewer/advanced_bottom_sheet.dart';
import 'package:immich_mobile/widgets/asset_viewer/bottom_gallery_bar.dart';
import 'package:immich_mobile/widgets/asset_viewer/exif_sheet/exif_bottom_sheet.dart';
import 'package:immich_mobile/widgets/asset_viewer/detail_panel/detail_panel.dart';
import 'package:immich_mobile/widgets/asset_viewer/gallery_app_bar.dart';
import 'package:immich_mobile/widgets/common/immich_image.dart';
import 'package:immich_mobile/widgets/common/immich_thumbnail.dart';
@@ -30,7 +32,6 @@ import 'package:immich_mobile/widgets/photo_view/photo_view_gallery.dart';
import 'package:immich_mobile/widgets/photo_view/src/photo_view_computed_scale.dart';
import 'package:immich_mobile/widgets/photo_view/src/photo_view_scale_state.dart';
import 'package:immich_mobile/widgets/photo_view/src/utils/photo_view_hero_attributes.dart';
import 'package:isar/isar.dart';
@RoutePage()
// ignore: must_be_immutable
@@ -55,8 +56,6 @@ class GalleryViewerPage extends HookConsumerWidget {
final settings = ref.watch(appSettingsServiceProvider);
final loadAsset = renderList.loadAsset;
final totalAssets = useState(renderList.totalAssets);
final isLoadPreview = useState(AppSettingsEnum.loadPreview.defaultValue);
final isLoadOriginal = useState(AppSettingsEnum.loadOriginal.defaultValue);
final shouldLoopVideo = useState(AppSettingsEnum.loopVideo.defaultValue);
final isZoomed = useState(false);
final isPlayingVideo = useState(false);
@@ -70,12 +69,12 @@ class GalleryViewerPage extends HookConsumerWidget {
});
final stackIndex = useState(-1);
final stack = showStack && currentAsset.stackChildrenCount > 0
final stack = showStack && currentAsset.stackCount > 0
? ref.watch(assetStackStateProvider(currentAsset))
: <Asset>[];
final stackElements = showStack ? [currentAsset, ...stack] : <Asset>[];
// Assets from response DTOs do not have an isar id, querying which would give us the default autoIncrement id
final isFromDto = currentAsset.id == Isar.autoIncrement;
final isFromDto = currentAsset.id == noDbId;
Asset asset = stackIndex.value == -1
? currentAsset
@@ -97,10 +96,6 @@ class GalleryViewerPage extends HookConsumerWidget {
useEffect(
() {
isLoadPreview.value =
settings.getSetting<bool>(AppSettingsEnum.loadPreview);
isLoadOriginal.value =
settings.getSetting<bool>(AppSettingsEnum.loadOriginal);
shouldLoopVideo.value =
settings.getSetting<bool>(AppSettingsEnum.loopVideo);
return null;
@@ -152,7 +147,7 @@ class GalleryViewerPage extends HookConsumerWidget {
.watch(appSettingsServiceProvider)
.getSetting<bool>(AppSettingsEnum.advancedTroubleshooting)
? AdvancedBottomSheet(assetDetail: asset)
: ExifBottomSheet(asset: asset),
: DetailPanel(asset: asset),
),
);
},
@@ -270,7 +265,7 @@ class GalleryViewerPage extends HookConsumerWidget {
return PopScope(
// Change immersive mode back to normal "edgeToEdge" mode
onPopInvoked: (_) =>
onPopInvokedWithResult: (didPop, _) =>
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge),
child: Scaffold(
backgroundColor: Colors.black,
@@ -324,6 +319,7 @@ class GalleryViewerPage extends HookConsumerWidget {
builder: (context, index) {
final a =
index == currentIndex.value ? asset : loadAsset(index);
final ImageProvider provider =
ImmichImage.imageProvider(asset: a);
@@ -426,6 +422,7 @@ class GalleryViewerPage extends HookConsumerWidget {
],
),
),
const DownloadPanel(),
],
),
),
@@ -74,7 +74,7 @@ class HeaderSettingsPage extends HookConsumerWidget {
],
),
body: PopScope(
onPopInvoked: (_) => saveHeaders(headers.value),
onPopInvokedWithResult: (didPop, _) => saveHeaders(headers.value),
child: ListView.separated(
padding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 16.0),
itemCount: list.length,
@@ -0,0 +1,50 @@
import 'package:flutter/material.dart';
class LargeLeadingTile extends StatelessWidget {
const LargeLeadingTile({
super.key,
required this.leading,
required this.onTap,
required this.title,
this.subtitle,
this.leadingPadding = const EdgeInsets.symmetric(
vertical: 8,
horizontal: 16.0,
),
this.borderRadius = 20.0,
});
final Widget leading;
final VoidCallback onTap;
final Widget title;
final Widget? subtitle;
final EdgeInsetsGeometry leadingPadding;
final double borderRadius;
@override
Widget build(BuildContext context) {
return InkWell(
borderRadius: BorderRadius.circular(borderRadius),
onTap: onTap,
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Padding(
padding: leadingPadding,
child: leading,
),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: MediaQuery.of(context).size.width * 0.6,
child: title,
),
subtitle ?? const SizedBox.shrink(),
],
),
],
),
);
}
}
+12 -11
View File
@@ -49,10 +49,6 @@ class SettingsPage extends StatelessWidget {
return Scaffold(
appBar: AppBar(
centerTitle: false,
bottom: const PreferredSize(
preferredSize: Size.fromHeight(1),
child: Divider(height: 1),
),
title: const Text('setting_pages_app_bar_settings').tr(),
),
body: context.isMobile ? _MobileLayout() : _TabletLayout(),
@@ -67,13 +63,18 @@ class _MobileLayout extends StatelessWidget {
children: SettingSection.values
.map(
(s) => ListTile(
title: Text(
s.title,
style: const TextStyle(
fontWeight: FontWeight.bold,
),
).tr(),
contentPadding:
const EdgeInsets.symmetric(vertical: 2.0, horizontal: 16.0),
leading: Icon(s.icon),
title: Padding(
padding: const EdgeInsets.only(left: 8.0),
child: Text(
s.title,
style: const TextStyle(
fontWeight: FontWeight.bold,
),
).tr(),
),
onTap: () => context.pushRoute(SettingsSubRoute(section: s)),
),
)
@@ -102,7 +103,7 @@ class _TabletLayout extends HookWidget {
leading: Icon(s.icon),
selected: s.index == selectedSection.value.index,
selectedColor: context.primaryColor,
selectedTileColor: context.primaryColor.withAlpha(50),
selectedTileColor: context.themeData.highlightColor,
onTap: () => selectedSection.value = s,
),
),
+51 -111
View File
@@ -3,8 +3,10 @@ import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/providers/album/album.provider.dart';
import 'package:immich_mobile/providers/asset_viewer/scroll_notifier.provider.dart';
import 'package:immich_mobile/providers/multiselect.provider.dart';
import 'package:immich_mobile/providers/search/search_input_focus.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/providers/asset.provider.dart';
import 'package:immich_mobile/providers/haptic_feedback.provider.dart';
@@ -16,10 +18,11 @@ class TabControllerPage extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final refreshing = ref.watch(assetProvider);
final isRefreshingAssets = ref.watch(assetProvider);
final isRefreshingRemoteAlbums = ref.watch(isRefreshingRemoteAlbumProvider);
Widget buildIcon(Widget icon) {
if (!refreshing) return icon;
Widget buildIcon({required Widget icon, required bool isProcessing}) {
if (!isProcessing) return icon;
return Stack(
alignment: Alignment.center,
clipBehavior: Clip.none,
@@ -42,75 +45,27 @@ class TabControllerPage extends HookConsumerWidget {
);
}
navigationRail(TabsRouter tabsRouter) {
return NavigationRail(
labelType: NavigationRailLabelType.all,
selectedIndex: tabsRouter.activeIndex,
onDestinationSelected: (index) {
// Selected Photos while it is active
if (tabsRouter.activeIndex == 0 && index == 0) {
// Scroll to top
scrollToTopNotifierProvider.scrollToTop();
}
onNavigationSelected(TabsRouter router, int index) {
// On Photos page menu tapped
if (router.activeIndex == 0 && index == 0) {
scrollToTopNotifierProvider.scrollToTop();
}
ref.read(hapticFeedbackProvider.notifier).selectionClick();
tabsRouter.setActiveIndex(index);
ref.read(tabProvider.notifier).state = TabEnum.values[index];
},
selectedIconTheme: IconThemeData(
color: context.primaryColor,
),
selectedLabelTextStyle: TextStyle(
color: context.primaryColor,
),
useIndicator: false,
destinations: [
NavigationRailDestination(
padding: EdgeInsets.only(
top: MediaQuery.of(context).padding.top + 4,
left: 4,
right: 4,
bottom: 4,
),
icon: const Icon(Icons.photo_library_outlined),
selectedIcon: const Icon(Icons.photo_library),
label: const Text('tab_controller_nav_photos').tr(),
),
NavigationRailDestination(
padding: const EdgeInsets.all(4),
icon: const Icon(Icons.search_rounded),
selectedIcon: const Icon(Icons.search),
label: const Text('tab_controller_nav_search').tr(),
),
NavigationRailDestination(
padding: const EdgeInsets.all(4),
icon: const Icon(Icons.share_rounded),
selectedIcon: const Icon(Icons.share),
label: const Text('tab_controller_nav_sharing').tr(),
),
NavigationRailDestination(
padding: const EdgeInsets.all(4),
icon: const Icon(Icons.photo_album_outlined),
selectedIcon: const Icon(Icons.photo_album),
label: const Text('tab_controller_nav_library').tr(),
),
],
);
// On Search page tapped
if (router.activeIndex == 1 && index == 1) {
ref.read(searchInputFocusProvider).requestFocus();
}
ref.read(hapticFeedbackProvider.notifier).selectionClick();
router.setActiveIndex(index);
ref.read(tabProvider.notifier).state = TabEnum.values[index];
}
bottomNavigationBar(TabsRouter tabsRouter) {
return NavigationBar(
selectedIndex: tabsRouter.activeIndex,
onDestinationSelected: (index) {
if (tabsRouter.activeIndex == 0 && index == 0) {
// Scroll to top
scrollToTopNotifierProvider.scrollToTop();
}
ref.read(hapticFeedbackProvider.notifier).selectionClick();
tabsRouter.setActiveIndex(index);
ref.read(tabProvider.notifier).state = TabEnum.values[index];
},
onDestinationSelected: (index) =>
onNavigationSelected(tabsRouter, index),
destinations: [
NavigationDestination(
label: 'tab_controller_nav_photos'.tr(),
@@ -118,7 +73,8 @@ class TabControllerPage extends HookConsumerWidget {
Icons.photo_library_outlined,
),
selectedIcon: buildIcon(
Icon(
isProcessing: isRefreshingAssets,
icon: Icon(
Icons.photo_library,
color: context.primaryColor,
),
@@ -135,38 +91,42 @@ class TabControllerPage extends HookConsumerWidget {
),
),
NavigationDestination(
label: 'tab_controller_nav_sharing'.tr(),
icon: const Icon(
Icons.group_outlined,
),
selectedIcon: Icon(
Icons.group,
color: context.primaryColor,
),
),
NavigationDestination(
label: 'tab_controller_nav_library'.tr(),
label: 'albums'.tr(),
icon: const Icon(
Icons.photo_album_outlined,
),
selectedIcon: buildIcon(
Icon(
isProcessing: isRefreshingRemoteAlbums,
icon: Icon(
Icons.photo_album_rounded,
color: context.primaryColor,
),
),
),
NavigationDestination(
label: 'library'.tr(),
icon: const Icon(
Icons.space_dashboard_outlined,
),
selectedIcon: buildIcon(
isProcessing: isRefreshingAssets,
icon: Icon(
Icons.space_dashboard_rounded,
color: context.primaryColor,
),
),
),
],
);
}
final multiselectEnabled = ref.watch(multiselectProvider);
return AutoTabsRouter(
routes: const [
PhotosRoute(),
routes: [
const PhotosRoute(),
SearchRoute(),
SharingRoute(),
LibraryRoute(),
const AlbumsRoute(),
const LibraryRoute(),
],
duration: const Duration(milliseconds: 600),
transitionBuilder: (context, child, animation) => FadeTransition(
@@ -177,35 +137,15 @@ class TabControllerPage extends HookConsumerWidget {
final tabsRouter = AutoTabsRouter.of(context);
return PopScope(
canPop: tabsRouter.activeIndex == 0,
onPopInvoked: (didPop) =>
onPopInvokedWithResult: (didPop, _) =>
!didPop ? tabsRouter.setActiveIndex(0) : null,
child: LayoutBuilder(
builder: (context, constraints) {
const medium = 600;
final Widget? bottom;
final Widget body;
if (constraints.maxWidth < medium) {
// Normal phone width
bottom = bottomNavigationBar(tabsRouter);
body = child;
} else {
// Medium tablet width
bottom = null;
body = Row(
children: [
navigationRail(tabsRouter),
Expanded(child: child),
],
);
}
return Scaffold(
body: HeroControllerScope(
controller: HeroController(),
child: body,
),
bottomNavigationBar: multiselectEnabled ? null : bottom,
);
},
child: Scaffold(
body: HeroControllerScope(
controller: HeroController(),
child: child,
),
bottomNavigationBar:
multiselectEnabled ? null : bottomNavigationBar(tabsRouter),
),
);
},
@@ -123,7 +123,7 @@ class VideoViewerPage extends HookConsumerWidget {
final size = MediaQuery.sizeOf(context);
return PopScope(
onPopInvoked: (pop) {
onPopInvokedWithResult: (didPop, _) {
ref.read(videoPlaybackValueProvider.notifier).value =
VideoPlaybackValue.uninitialized();
},
+113 -99
View File
@@ -1,9 +1,12 @@
import 'package:flutter/material.dart';
import 'package:crop_image/crop_image.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/utils/hooks/crop_controller_hook.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
import 'edit.page.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:auto_route/auto_route.dart';
/// A widget for cropping an image.
@@ -14,7 +17,8 @@ import 'package:auto_route/auto_route.dart';
@RoutePage()
class CropImagePage extends HookWidget {
final Image image;
const CropImagePage({super.key, required this.image});
final Asset asset;
const CropImagePage({super.key, required this.image, required this.asset});
@override
Widget build(BuildContext context) {
@@ -23,123 +27,133 @@ class CropImagePage extends HookWidget {
return Scaffold(
appBar: AppBar(
backgroundColor: Theme.of(context).bottomAppBarTheme.color,
leading: CloseButton(color: Theme.of(context).iconTheme.color),
backgroundColor: context.scaffoldBackgroundColor,
title: Text("crop".tr()),
leading: CloseButton(color: context.primaryColor),
actions: [
IconButton(
icon: Icon(
Icons.done_rounded,
color: Theme.of(context).iconTheme.color,
color: context.primaryColor,
size: 24,
),
onPressed: () async {
final croppedImage = await cropController.croppedImage();
context.pushRoute(EditImageRoute(image: croppedImage));
context.pushRoute(
EditImageRoute(
asset: asset,
image: croppedImage,
isEdited: true,
),
);
},
),
],
),
body: LayoutBuilder(
builder: (BuildContext context, BoxConstraints constraints) {
return Column(
children: [
Container(
padding: const EdgeInsets.only(top: 20),
width: double.infinity,
height: constraints.maxHeight * 0.6,
child: CropImage(
controller: cropController,
image: image,
gridColor: Colors.white,
),
),
Expanded(
child: Container(
width: double.infinity,
decoration: BoxDecoration(
color: Theme.of(context).bottomAppBarTheme.color,
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(20),
topRight: Radius.circular(20),
),
backgroundColor: context.scaffoldBackgroundColor,
body: SafeArea(
child: LayoutBuilder(
builder: (BuildContext context, BoxConstraints constraints) {
return Column(
children: [
Container(
padding: const EdgeInsets.only(top: 20),
width: constraints.maxWidth * 0.9,
height: constraints.maxHeight * 0.6,
child: CropImage(
controller: cropController,
image: image,
gridColor: Colors.white,
),
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Padding(
padding: const EdgeInsets.only(
left: 20,
right: 20,
bottom: 10,
),
Expanded(
child: Container(
width: double.infinity,
decoration: BoxDecoration(
color: context.scaffoldBackgroundColor,
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(20),
topRight: Radius.circular(20),
),
),
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Padding(
padding: const EdgeInsets.only(
left: 20,
right: 20,
bottom: 10,
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
IconButton(
icon: Icon(
Icons.rotate_left,
color: Theme.of(context).iconTheme.color,
),
onPressed: () {
cropController.rotateLeft();
},
),
IconButton(
icon: Icon(
Icons.rotate_right,
color: Theme.of(context).iconTheme.color,
),
onPressed: () {
cropController.rotateRight();
},
),
],
),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
IconButton(
icon: Icon(
Icons.rotate_left,
color: Theme.of(context).iconTheme.color,
),
onPressed: () {
cropController.rotateLeft();
},
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: <Widget>[
_AspectRatioButton(
cropController: cropController,
aspectRatio: aspectRatio,
ratio: null,
label: 'Free',
),
IconButton(
icon: Icon(
Icons.rotate_right,
color: Theme.of(context).iconTheme.color,
),
onPressed: () {
cropController.rotateRight();
},
_AspectRatioButton(
cropController: cropController,
aspectRatio: aspectRatio,
ratio: 1.0,
label: '1:1',
),
_AspectRatioButton(
cropController: cropController,
aspectRatio: aspectRatio,
ratio: 16.0 / 9.0,
label: '16:9',
),
_AspectRatioButton(
cropController: cropController,
aspectRatio: aspectRatio,
ratio: 3.0 / 2.0,
label: '3:2',
),
_AspectRatioButton(
cropController: cropController,
aspectRatio: aspectRatio,
ratio: 7.0 / 5.0,
label: '7:5',
),
],
),
),
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: <Widget>[
_AspectRatioButton(
cropController: cropController,
aspectRatio: aspectRatio,
ratio: null,
label: 'Free',
),
_AspectRatioButton(
cropController: cropController,
aspectRatio: aspectRatio,
ratio: 1.0,
label: '1:1',
),
_AspectRatioButton(
cropController: cropController,
aspectRatio: aspectRatio,
ratio: 16.0 / 9.0,
label: '16:9',
),
_AspectRatioButton(
cropController: cropController,
aspectRatio: aspectRatio,
ratio: 3.0 / 2.0,
label: '3:2',
),
_AspectRatioButton(
cropController: cropController,
aspectRatio: aspectRatio,
ratio: 7.0 / 5.0,
label: '7:5',
),
],
),
],
],
),
),
),
),
),
],
);
},
],
);
},
),
),
);
}
@@ -188,7 +202,7 @@ class _AspectRatioButton extends StatelessWidget {
icon: Icon(
iconData,
color: aspectRatio.value == ratio
? Colors.indigo
? context.primaryColor
: Theme.of(context).iconTheme.color,
),
onPressed: () {
@@ -197,7 +211,7 @@ class _AspectRatioButton extends StatelessWidget {
cropController.aspectRatio = ratio;
},
),
Text(label, style: Theme.of(context).textTheme.bodyMedium),
Text(label, style: context.textTheme.displayMedium),
],
);
}
+122 -78
View File
@@ -1,4 +1,3 @@
import 'dart:io';
import 'dart:typed_data';
import 'dart:async';
import 'dart:ui';
@@ -7,12 +6,14 @@ import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:fluttertoast/fluttertoast.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/widgets/common/immich_image.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/repositories/file_media.repository.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart';
import 'package:auto_route/auto_route.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:photo_manager/photo_manager.dart';
import 'package:immich_mobile/providers/album/album.provider.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:path/path.dart' as p;
/// A stateless widget that provides functionality for editing an image.
///
@@ -24,18 +25,16 @@ import 'package:immich_mobile/providers/album/album.provider.dart';
@immutable
@RoutePage()
class EditImagePage extends ConsumerWidget {
final Asset? asset;
final Image? image;
final Asset asset;
final Image image;
final bool isEdited;
const EditImagePage({
super.key,
this.image,
this.asset,
}) : assert(
(image != null && asset == null) || (image == null && asset != null),
'Must supply one of asset or image',
);
required this.asset,
required this.image,
required this.isEdited,
});
Future<Uint8List> _imageToUint8List(Image image) async {
final Completer<Uint8List> completer = Completer();
image.image.resolve(const ImageConfiguration()).addListener(
@@ -58,98 +57,143 @@ class EditImagePage extends ConsumerWidget {
return completer.future;
}
Future<void> _saveEditedImage(
BuildContext context,
Asset asset,
Image image,
WidgetRef ref,
) async {
try {
final Uint8List imageData = await _imageToUint8List(image);
await ref.read(fileMediaRepositoryProvider).saveImage(
imageData,
title: "${p.withoutExtension(asset.fileName)}_edited.jpg",
);
await ref.read(albumProvider.notifier).refreshDeviceAlbums();
Navigator.of(context).popUntil((route) => route.isFirst);
ImmichToast.show(
durationInSecond: 3,
context: context,
msg: 'Image Saved!',
gravity: ToastGravity.CENTER,
);
} catch (e) {
ImmichToast.show(
durationInSecond: 6,
context: context,
msg: "error_saving_image".tr(args: [e.toString()]),
gravity: ToastGravity.CENTER,
);
}
}
@override
Widget build(BuildContext context, WidgetRef ref) {
final ImageProvider provider = (asset != null)
? ImmichImage.imageProvider(asset: asset!)
: (image != null)
? image!.image
: throw Exception('Invalid image source type');
final Image imageWidget = (asset != null)
? Image(image: ImmichImage.imageProvider(asset: asset!))
: (image != null)
? image!
: throw Exception('Invalid image source type');
return Scaffold(
appBar: AppBar(
backgroundColor: Theme.of(context).appBarTheme.backgroundColor,
title: Text("edit_image_title".tr()),
backgroundColor: context.scaffoldBackgroundColor,
leading: IconButton(
icon: Icon(
Icons.close_rounded,
color: Theme.of(context).iconTheme.color,
color: context.primaryColor,
size: 24,
),
onPressed: () =>
Navigator.of(context).popUntil((route) => route.isFirst),
),
actions: <Widget>[
if (image != null)
TextButton(
onPressed: () async {
try {
final Uint8List imageData = await _imageToUint8List(image!);
ImmichToast.show(
durationInSecond: 3,
context: context,
msg: 'Image Saved!',
gravity: ToastGravity.CENTER,
);
await PhotoManager.editor
.saveImage(imageData, title: "_edited.jpg");
await ref.read(albumProvider.notifier).getDeviceAlbums();
Navigator.of(context).popUntil((route) => route.isFirst);
} catch (e) {
ImmichToast.show(
durationInSecond: 6,
context: context,
msg: 'Error: ${e.toString()}',
gravity: ToastGravity.BOTTOM,
);
}
},
child: Text(
'Save to gallery',
style: Theme.of(context).textTheme.displayMedium,
TextButton(
onPressed: isEdited
? () => _saveEditedImage(context, asset, image, ref)
: null,
child: Text(
"save_to_gallery".tr(),
style: TextStyle(
color: isEdited ? context.primaryColor : Colors.grey,
),
),
),
],
),
body: Column(
children: <Widget>[
Expanded(
child: Image(image: provider),
backgroundColor: context.scaffoldBackgroundColor,
body: Center(
child: ConstrainedBox(
constraints: BoxConstraints(
maxHeight: MediaQuery.of(context).size.height * 0.7,
maxWidth: MediaQuery.of(context).size.width * 0.9,
),
Container(
height: 80,
color: Theme.of(context).bottomAppBarTheme.color,
child: Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(7),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.2),
spreadRadius: 2,
blurRadius: 10,
offset: const Offset(0, 3),
),
],
),
child: ClipRRect(
borderRadius: BorderRadius.circular(7),
child: Image(
image: image.image,
fit: BoxFit.contain,
),
),
),
],
),
),
bottomNavigationBar: Container(
height: 80,
margin: const EdgeInsets.only(bottom: 20, right: 10, left: 10, top: 10),
height: 70,
margin: const EdgeInsets.only(bottom: 60, right: 10, left: 10, top: 10),
decoration: BoxDecoration(
color: Theme.of(context).bottomAppBarTheme.color,
color: context.scaffoldBackgroundColor,
borderRadius: BorderRadius.circular(30),
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: <Widget>[
IconButton(
icon: Icon(
Platform.isAndroid
? Icons.crop_rotate_rounded
: Icons.crop_rotate_rounded,
color: Theme.of(context).iconTheme.color,
),
onPressed: () {
context.pushRoute(CropImageRoute(image: imageWidget));
},
Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
IconButton(
icon: Icon(
Icons.crop_rotate_rounded,
color: Theme.of(context).iconTheme.color,
size: 25,
),
onPressed: () {
context.pushRoute(
CropImageRoute(asset: asset, image: image),
);
},
),
Text("crop".tr(), style: context.textTheme.displayMedium),
],
),
Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
IconButton(
icon: Icon(
Icons.filter,
color: Theme.of(context).iconTheme.color,
size: 25,
),
onPressed: () {
context.pushRoute(
FilterImageRoute(
asset: asset,
image: image,
),
);
},
),
Text("filter".tr(), style: context.textTheme.displayMedium),
],
),
Text('Crop', style: Theme.of(context).textTheme.displayMedium),
],
),
),
+187
View File
@@ -0,0 +1,187 @@
import 'dart:async';
import 'dart:ui' as ui;
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/constants/filters.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:auto_route/auto_route.dart';
import 'package:immich_mobile/routing/router.dart';
/// A widget for filtering an image.
/// This widget uses [HookWidget] to manage its lifecycle and state. It allows
/// users to add filters to an image and then navigate to the [EditImagePage] with the
/// final composition.'
@RoutePage()
class FilterImagePage extends HookWidget {
final Image image;
final Asset asset;
const FilterImagePage({
super.key,
required this.image,
required this.asset,
});
@override
Widget build(BuildContext context) {
final colorFilter = useState<ColorFilter>(filters[0]);
final selectedFilterIndex = useState<int>(0);
Future<ui.Image> createFilteredImage(
ui.Image inputImage,
ColorFilter filter,
) {
final completer = Completer<ui.Image>();
final size =
Size(inputImage.width.toDouble(), inputImage.height.toDouble());
final recorder = ui.PictureRecorder();
final canvas = Canvas(recorder);
final paint = Paint()..colorFilter = filter;
canvas.drawImage(inputImage, Offset.zero, paint);
recorder
.endRecording()
.toImage(size.width.round(), size.height.round())
.then((image) {
completer.complete(image);
});
return completer.future;
}
void applyFilter(ColorFilter filter, int index) {
colorFilter.value = filter;
selectedFilterIndex.value = index;
}
Future<Image> applyFilterAndConvert(ColorFilter filter) async {
final completer = Completer<ui.Image>();
image.image.resolve(ImageConfiguration.empty).addListener(
ImageStreamListener((ImageInfo info, bool _) {
completer.complete(info.image);
}),
);
final uiImage = await completer.future;
final filteredUiImage = await createFilteredImage(uiImage, filter);
final byteData =
await filteredUiImage.toByteData(format: ui.ImageByteFormat.png);
final pngBytes = byteData!.buffer.asUint8List();
return Image.memory(pngBytes, fit: BoxFit.contain);
}
return Scaffold(
appBar: AppBar(
backgroundColor: context.scaffoldBackgroundColor,
title: Text("filter".tr()),
leading: CloseButton(color: context.primaryColor),
actions: [
IconButton(
icon: Icon(
Icons.done_rounded,
color: context.primaryColor,
size: 24,
),
onPressed: () async {
final filteredImage =
await applyFilterAndConvert(colorFilter.value);
context.pushRoute(
EditImageRoute(
asset: asset,
image: filteredImage,
isEdited: true,
),
);
},
),
],
),
backgroundColor: context.scaffoldBackgroundColor,
body: Column(
children: [
SizedBox(
height: MediaQuery.of(context).size.height * 0.7,
child: Center(
child: ColorFiltered(
colorFilter: colorFilter.value,
child: image,
),
),
),
SizedBox(
height: 120,
child: ListView.builder(
scrollDirection: Axis.horizontal,
itemCount: filters.length,
itemBuilder: (context, index) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 8.0),
child: _FilterButton(
image: image,
label: filterNames[index],
filter: filters[index],
isSelected: selectedFilterIndex.value == index,
onTap: () => applyFilter(filters[index], index),
),
);
},
),
),
],
),
);
}
}
class _FilterButton extends StatelessWidget {
final Image image;
final String label;
final ColorFilter filter;
final bool isSelected;
final VoidCallback onTap;
const _FilterButton({
required this.image,
required this.label,
required this.filter,
required this.isSelected,
required this.onTap,
});
@override
Widget build(BuildContext context) {
return Column(
children: [
GestureDetector(
onTap: onTap,
child: Container(
width: 80,
height: 80,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(10),
border: isSelected
? Border.all(color: context.primaryColor, width: 3)
: null,
),
child: ClipRRect(
borderRadius: BorderRadius.circular(10),
child: ColorFiltered(
colorFilter: filter,
child: FittedBox(
fit: BoxFit.cover,
child: image,
),
),
),
),
),
const SizedBox(height: 10),
Text(label, style: Theme.of(context).textTheme.bodyMedium),
],
);
}
}
+1 -1
View File
@@ -2,7 +2,7 @@ import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/providers/favorite_provider.dart';
import 'package:immich_mobile/providers/favorite.provider.dart';
import 'package:immich_mobile/providers/multiselect.provider.dart';
import 'package:immich_mobile/widgets/asset_grid/multiselect_grid.dart';
+391 -295
View File
@@ -1,333 +1,429 @@
import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/entities/user.entity.dart';
import 'package:immich_mobile/extensions/asyncvalue_extensions.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/providers/album/album.provider.dart';
import 'package:immich_mobile/providers/album/album_sort_by_options.provider.dart';
import 'package:immich_mobile/widgets/album/album_thumbnail_card.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/providers/partner.provider.dart';
import 'package:immich_mobile/providers/search/people.provider.dart';
import 'package:immich_mobile/providers/server_info.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/services/api.service.dart';
import 'package:immich_mobile/utils/image_url_builder.dart';
import 'package:immich_mobile/widgets/album/album_thumbnail_card.dart';
import 'package:immich_mobile/widgets/common/immich_app_bar.dart';
import 'package:immich_mobile/widgets/common/user_avatar.dart';
import 'package:immich_mobile/widgets/map/map_thumbnail.dart';
import 'package:maplibre_gl/maplibre_gl.dart';
@RoutePage()
class LibraryPage extends HookConsumerWidget {
class LibraryPage extends ConsumerWidget {
const LibraryPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final trashEnabled =
ref.watch(serverInfoProvider.select((v) => v.serverFeatures.trash));
final albums = ref.watch(albumProvider);
final isDarkTheme = context.isDarkTheme;
final albumSortOption = ref.watch(albumSortByOptionsProvider);
final albumSortIsReverse = ref.watch(albumSortOrderProvider);
useEffect(
() {
ref.read(albumProvider.notifier).getAllAlbums();
return null;
},
[],
);
Widget buildSortButton() {
return PopupMenuButton(
position: PopupMenuPosition.over,
itemBuilder: (BuildContext context) {
return AlbumSortMode.values
.map<PopupMenuEntry<AlbumSortMode>>((option) {
final selected = albumSortOption == option;
return PopupMenuItem(
value: option,
return Scaffold(
appBar: ImmichAppBar(),
body: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: ListView(
shrinkWrap: true,
children: [
Padding(
padding: const EdgeInsets.only(top: 16.0),
child: Row(
children: [
Padding(
padding: const EdgeInsets.only(right: 12.0),
child: Icon(
Icons.check,
color:
selected ? context.primaryColor : Colors.transparent,
),
ActionButton(
onPressed: () => context.pushRoute(const FavoritesRoute()),
icon: Icons.favorite_outline_rounded,
label: 'favorites'.tr(),
),
Text(
option.label.tr(),
style: TextStyle(
color: selected ? context.primaryColor : null,
fontSize: 14.0,
),
const SizedBox(width: 8),
ActionButton(
onPressed: () => context.pushRoute(const ArchiveRoute()),
icon: Icons.archive_outlined,
label: 'archived'.tr(),
),
],
),
);
}).toList();
},
onSelected: (AlbumSortMode value) {
final selected = albumSortOption == value;
// Switch direction
if (selected) {
ref
.read(albumSortOrderProvider.notifier)
.changeSortDirection(!albumSortIsReverse);
} else {
ref.read(albumSortByOptionsProvider.notifier).changeSortMode(value);
}
},
child: Row(
children: [
Padding(
padding: const EdgeInsets.only(right: 5),
child: Icon(
albumSortIsReverse
? Icons.arrow_downward_rounded
: Icons.arrow_upward_rounded,
size: 14,
color: context.primaryColor,
),
),
Text(
albumSortOption.label.tr(),
style: context.textTheme.labelLarge?.copyWith(
color: context.primaryColor,
),
const SizedBox(height: 8),
Row(
children: [
ActionButton(
onPressed: () => context.pushRoute(const SharedLinkRoute()),
icon: Icons.link_outlined,
label: 'shared_links'.tr(),
),
SizedBox(width: trashEnabled ? 8 : 0),
trashEnabled
? ActionButton(
onPressed: () => context.pushRoute(const TrashRoute()),
icon: Icons.delete_outline_rounded,
label: 'trash'.tr(),
)
: const SizedBox.shrink(),
],
),
const SizedBox(height: 12),
const Wrap(
spacing: 8,
runSpacing: 8,
children: [
PeopleCollectionCard(),
PlacesCollectionCard(),
LocalAlbumsCollectionCard(),
],
),
const SizedBox(height: 12),
QuickAccessButtons(),
const SizedBox(
height: 32,
),
],
),
);
}
Widget buildCreateAlbumButton() {
return LayoutBuilder(
builder: (context, constraints) {
var cardSize = constraints.maxWidth;
return GestureDetector(
onTap: () =>
context.pushRoute(CreateAlbumRoute(isSharedAlbum: false)),
child: Padding(
padding:
const EdgeInsets.only(bottom: 32), // Adjust padding to suit
child: Column(
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: cardSize,
height: cardSize,
decoration: BoxDecoration(
border: Border.all(
color: isDarkTheme
? const Color.fromARGB(255, 53, 53, 53)
: const Color.fromARGB(255, 203, 203, 203),
),
color: isDarkTheme ? Colors.grey[900] : Colors.grey[50],
borderRadius: const BorderRadius.all(Radius.circular(20)),
),
child: Center(
child: Icon(
Icons.add_rounded,
size: 28,
color: context.primaryColor,
),
),
),
Padding(
padding: const EdgeInsets.only(
top: 8.0,
bottom: 16,
),
child: Text(
'library_page_new_album',
style: context.textTheme.labelLarge,
).tr(),
),
],
),
),
);
},
);
}
Widget buildLibraryNavButton(
String label,
IconData icon,
Function() onClick,
) {
return Expanded(
child: OutlinedButton.icon(
onPressed: onClick,
label: Padding(
padding: const EdgeInsets.only(left: 8.0),
child: Text(
label,
style: TextStyle(
color: context.isDarkTheme
? Colors.white
: Colors.black.withAlpha(200),
),
),
),
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16),
backgroundColor: isDarkTheme ? Colors.grey[900] : Colors.grey[50],
side: BorderSide(
color: isDarkTheme ? Colors.grey[800]! : Colors.grey[300]!,
),
alignment: Alignment.centerLeft,
),
icon: Icon(
icon,
color: context.primaryColor,
),
),
);
}
final remote = albums.where((a) => a.isRemote).toList();
final sorted = albumSortOption.sortFn(remote, albumSortIsReverse);
final local = albums.where((a) => a.isLocal).toList();
Widget? shareTrashButton() {
return trashEnabled
? InkWell(
onTap: () => context.pushRoute(const TrashRoute()),
borderRadius: const BorderRadius.all(Radius.circular(12)),
child: Icon(
Icons.delete_rounded,
size: 25,
semanticLabel: 'profile_drawer_trash'.tr(),
),
)
: null;
}
return Scaffold(
appBar: ImmichAppBar(
action: shareTrashButton(),
),
body: CustomScrollView(
slivers: [
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.only(
left: 12.0,
right: 12.0,
top: 24.0,
bottom: 12.0,
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
buildLibraryNavButton(
"library_page_favorites".tr(), Icons.favorite_border, () {
context.navigateTo(const FavoritesRoute());
}),
const SizedBox(width: 12.0),
buildLibraryNavButton(
"library_page_archive".tr(), Icons.archive_outlined, () {
context.navigateTo(const ArchiveRoute());
}),
],
),
),
),
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.only(
top: 12.0,
left: 12.0,
right: 12.0,
bottom: 20.0,
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'library_page_albums',
style: context.textTheme.bodyLarge?.copyWith(
fontWeight: FontWeight.w500,
),
).tr(),
buildSortButton(),
],
),
),
),
SliverPadding(
padding: const EdgeInsets.all(12.0),
sliver: SliverGrid(
gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent: 250,
mainAxisSpacing: 12,
crossAxisSpacing: 12,
childAspectRatio: .7,
),
delegate: SliverChildBuilderDelegate(
childCount: sorted.length + 1,
(context, index) {
if (index == 0) {
return buildCreateAlbumButton();
}
);
}
}
return AlbumThumbnailCard(
album: sorted[index - 1],
onTap: () => context.pushRoute(
AlbumViewerRoute(
albumId: sorted[index - 1].id,
),
),
);
},
class QuickAccessButtons extends ConsumerWidget {
const QuickAccessButtons({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final partners = ref.watch(partnerSharedWithProvider);
return Container(
decoration: BoxDecoration(
border: Border.all(
color: context.colorScheme.onSurface.withAlpha(10),
width: 1,
),
borderRadius: BorderRadius.circular(20),
gradient: LinearGradient(
colors: [
context.colorScheme.primary.withAlpha(10),
context.colorScheme.primary.withAlpha(15),
context.colorScheme.primary.withAlpha(20),
],
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
),
),
child: ListView(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
children: [
ListTile(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.only(
topLeft: Radius.circular(20),
topRight: Radius.circular(20),
bottomLeft: Radius.circular(partners.isEmpty ? 20 : 0),
bottomRight: Radius.circular(partners.isEmpty ? 20 : 0),
),
),
),
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.only(
top: 12.0,
left: 12.0,
right: 12.0,
bottom: 20.0,
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'library_page_device_albums',
style: context.textTheme.bodyLarge?.copyWith(
fontWeight: FontWeight.w500,
),
).tr(),
],
),
),
),
SliverPadding(
padding: const EdgeInsets.all(12.0),
sliver: SliverGrid(
gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent: 250,
mainAxisSpacing: 12,
crossAxisSpacing: 12,
childAspectRatio: .7,
),
delegate: SliverChildBuilderDelegate(
childCount: local.length,
(context, index) => AlbumThumbnailCard(
album: local[index],
onTap: () => context.pushRoute(
AlbumViewerRoute(
albumId: local[index].id,
),
),
),
leading: const Icon(
Icons.group_outlined,
size: 26,
),
title: Text(
'partners'.tr(),
style: context.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w500,
),
),
onTap: () => context.pushRoute(const PartnerRoute()),
),
PartnerList(partners: partners),
],
),
);
}
}
class PartnerList extends ConsumerWidget {
const PartnerList({super.key, required this.partners});
final List<User> partners;
@override
Widget build(BuildContext context, WidgetRef ref) {
return ListView.builder(
physics: const NeverScrollableScrollPhysics(),
itemCount: partners.length,
shrinkWrap: true,
itemBuilder: (context, index) {
final partner = partners[index];
final isLastItem = index == partners.length - 1;
return ListTile(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.only(
bottomLeft: Radius.circular(isLastItem ? 20 : 0),
bottomRight: Radius.circular(isLastItem ? 20 : 0),
),
),
contentPadding: const EdgeInsets.only(
left: 12.0,
right: 18.0,
),
leading: userAvatar(context, partner, radius: 16),
title: Text(
"partner_list_user_photos",
style: TextStyle(
fontWeight: FontWeight.w500,
),
).tr(
namedArgs: {
'user': partner.name,
},
),
onTap: () => context.pushRoute(
(PartnerDetailRoute(partner: partner)),
),
);
},
);
}
}
class PeopleCollectionCard extends ConsumerWidget {
const PeopleCollectionCard({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final people = ref.watch(getAllPeopleProvider);
return LayoutBuilder(
builder: (context, constraints) {
final isTablet = constraints.maxWidth > 600;
final widthFactor = isTablet ? 0.25 : 0.5;
final size = MediaQuery.of(context).size.width * widthFactor - 20.0;
return GestureDetector(
onTap: () => context.pushRoute(const PeopleCollectionRoute()),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
height: size,
width: size,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(20),
gradient: LinearGradient(
colors: [
context.colorScheme.primary.withAlpha(30),
context.colorScheme.primary.withAlpha(25),
],
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
),
),
child: people.widgetWhen(
onData: (people) {
return GridView.count(
crossAxisCount: 2,
padding: const EdgeInsets.all(12),
crossAxisSpacing: 8,
mainAxisSpacing: 8,
physics: const NeverScrollableScrollPhysics(),
children: people.take(4).map((person) {
return CircleAvatar(
backgroundImage: NetworkImage(
getFaceThumbnailUrl(person.id),
headers: ApiService.getRequestHeaders(),
),
);
}).toList(),
);
},
),
),
Padding(
padding: const EdgeInsets.all(8.0),
child: Text(
'people'.tr(),
style: context.textTheme.titleSmall?.copyWith(
color: context.colorScheme.onSurface,
fontWeight: FontWeight.w500,
),
),
),
],
),
);
},
);
}
}
class LocalAlbumsCollectionCard extends HookConsumerWidget {
const LocalAlbumsCollectionCard({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final albums = ref.watch(localAlbumsProvider);
return LayoutBuilder(
builder: (context, constraints) {
final isTablet = constraints.maxWidth > 600;
final widthFactor = isTablet ? 0.25 : 0.5;
final size = MediaQuery.of(context).size.width * widthFactor - 20.0;
return GestureDetector(
onTap: () => context.pushRoute(
const LocalAlbumsRoute(),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
height: size,
width: size,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(20),
gradient: LinearGradient(
colors: [
context.colorScheme.primary.withAlpha(30),
context.colorScheme.primary.withAlpha(25),
],
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
),
),
child: GridView.count(
crossAxisCount: 2,
padding: const EdgeInsets.all(12),
crossAxisSpacing: 8,
mainAxisSpacing: 8,
physics: const NeverScrollableScrollPhysics(),
children: albums.take(4).map((album) {
return AlbumThumbnailCard(
album: album,
showTitle: false,
);
}).toList(),
),
),
Padding(
padding: const EdgeInsets.all(8.0),
child: Text(
'on_this_device'.tr(),
style: context.textTheme.titleSmall?.copyWith(
color: context.colorScheme.onSurface,
fontWeight: FontWeight.w500,
),
),
),
],
),
);
},
);
}
}
class PlacesCollectionCard extends StatelessWidget {
const PlacesCollectionCard({super.key});
@override
Widget build(BuildContext context) {
return LayoutBuilder(
builder: (context, constraints) {
final isTablet = constraints.maxWidth > 600;
final widthFactor = isTablet ? 0.25 : 0.5;
final size = MediaQuery.of(context).size.width * widthFactor - 20.0;
return GestureDetector(
onTap: () => context.pushRoute(const PlacesCollectionRoute()),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
height: size,
width: size,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(20),
color: context.colorScheme.secondaryContainer.withAlpha(100),
),
child: IgnorePointer(
child: MapThumbnail(
zoom: 8,
centre: const LatLng(
21.44950,
-157.91959,
),
showAttribution: false,
themeMode:
context.isDarkTheme ? ThemeMode.dark : ThemeMode.light,
),
),
),
Padding(
padding: const EdgeInsets.all(8.0),
child: Text(
'places'.tr(),
style: context.textTheme.titleSmall?.copyWith(
color: context.colorScheme.onSurface,
fontWeight: FontWeight.w500,
),
),
),
],
),
);
},
);
}
}
class ActionButton extends StatelessWidget {
final VoidCallback onPressed;
final IconData icon;
final String label;
const ActionButton({
super.key,
required this.onPressed,
required this.icon,
required this.label,
});
@override
Widget build(BuildContext context) {
return Expanded(
child: FilledButton.icon(
onPressed: onPressed,
label: Padding(
padding: const EdgeInsets.only(left: 4.0),
child: Text(
label,
style: TextStyle(
color: context.colorScheme.onSurface,
fontSize: 15,
),
),
),
style: FilledButton.styleFrom(
elevation: 0,
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16),
backgroundColor: context.colorScheme.surfaceContainerLow,
alignment: Alignment.centerLeft,
shape: RoundedRectangleBorder(
borderRadius: const BorderRadius.all(Radius.circular(25)),
side: BorderSide(
color: context.colorScheme.onSurface.withAlpha(10),
width: 1,
),
),
),
icon: Icon(
icon,
color: context.primaryColor,
),
),
);
}
}
@@ -0,0 +1,55 @@
import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/pages/common/large_leading_tile.dart';
import 'package:immich_mobile/providers/album/album.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/widgets/common/immich_thumbnail.dart';
@RoutePage()
class LocalAlbumsPage extends HookConsumerWidget {
const LocalAlbumsPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final albums = ref.watch(localAlbumsProvider);
return Scaffold(
appBar: AppBar(
title: Text('on_this_device'.tr()),
),
body: ListView.builder(
padding: const EdgeInsets.all(18.0),
itemCount: albums.length,
itemBuilder: (context, index) {
return Padding(
padding: const EdgeInsets.only(bottom: 8.0),
child: LargeLeadingTile(
leadingPadding: const EdgeInsets.only(
right: 16,
),
leading: ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(15)),
child: ImmichThumbnail(
asset: albums[index].thumbnail.value,
width: 80,
height: 80,
),
),
title: Text(
albums[index].name,
style: context.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w600,
),
),
subtitle: Text('${albums[index].assetCount} items'),
onTap: () => context
.pushRoute(AlbumViewerRoute(albumId: albums[index].id)),
),
);
},
),
);
}
}
@@ -86,12 +86,10 @@ class PartnerPage extends HookConsumerWidget {
children: [
Padding(
padding: const EdgeInsets.only(left: 16.0, top: 16.0),
child: const Text(
child: Text(
"partner_page_shared_to_title",
style: TextStyle(
fontSize: 14,
color: Colors.grey,
fontWeight: FontWeight.bold,
style: context.textTheme.titleSmall?.copyWith(
color: context.colorScheme.onSurface.withAlpha(200),
),
).tr(),
),
@@ -104,10 +102,7 @@ class PartnerPage extends HookConsumerWidget {
leading: userAvatar(context, users[index]),
title: Text(
users[index].email,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.bold,
),
style: context.textTheme.bodyLarge,
),
trailing: IconButton(
icon: const Icon(Icons.person_remove),
@@ -148,7 +143,7 @@ class PartnerPage extends HookConsumerWidget {
return Scaffold(
appBar: AppBar(
title: const Text("partner_page_title").tr(),
title: const Text("partners").tr(),
elevation: 0,
centerTitle: false,
actions: [
@@ -2,6 +2,7 @@ import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/providers/multiselect.provider.dart';
import 'package:immich_mobile/providers/partner.provider.dart';
import 'package:immich_mobile/entities/user.entity.dart';
@@ -22,7 +23,11 @@ class PartnerDetailPage extends HookConsumerWidget {
useEffect(
() {
ref.read(assetProvider.notifier).getAllAsset();
Future.microtask(
() async => {
await ref.read(assetProvider.notifier).getAllAsset(),
},
);
return null;
},
[],
@@ -64,19 +69,47 @@ class PartnerDetailPage extends HookConsumerWidget {
title: Text(partner.name),
elevation: 0,
centerTitle: false,
actions: [
IconButton(
onPressed: toggleInTimeline,
icon: Icon(
inTimeline.value
? Icons.collections
: Icons.collections_outlined,
),
tooltip: "Show/hide photos on your main timeline",
),
],
),
body: MultiselectGrid(
topWidget: Padding(
padding: const EdgeInsets.only(left: 8.0, right: 8.0, top: 16.0),
child: Container(
decoration: BoxDecoration(
border: Border.all(
color: context.colorScheme.onSurface.withAlpha(10),
width: 1,
),
borderRadius: BorderRadius.circular(20),
gradient: LinearGradient(
colors: [
context.colorScheme.primary.withAlpha(10),
context.colorScheme.primary.withAlpha(15),
],
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
),
),
child: Padding(
padding: const EdgeInsets.all(8.0),
child: ListTile(
title: Text(
"Show in timeline",
style: context.textTheme.titleSmall?.copyWith(
color: context.colorScheme.primary,
),
),
subtitle: Text(
"Show photos and videos from this user in your timeline",
style: context.textTheme.bodyMedium,
),
trailing: Switch(
value: inTimeline.value,
onChanged: (_) => toggleInTimeline(),
),
),
),
),
),
renderListProvider: assetsProvider(partner.isarId),
onRefresh: () => ref.read(assetProvider.notifier).getAllAsset(),
deleteEnabled: false,
@@ -0,0 +1,114 @@
import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/providers/search/people.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/services/api.service.dart';
import 'package:immich_mobile/utils/image_url_builder.dart';
import 'package:immich_mobile/widgets/search/person_name_edit_form.dart';
@RoutePage()
class PeopleCollectionPage extends HookConsumerWidget {
const PeopleCollectionPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final people = ref.watch(getAllPeopleProvider);
final headers = ApiService.getRequestHeaders();
showNameEditModel(
String personId,
String personName,
) {
return showDialog(
context: context,
builder: (BuildContext context) {
return PersonNameEditForm(personId: personId, personName: personName);
},
);
}
return LayoutBuilder(
builder: (context, constraints) {
final isTablet = constraints.maxWidth > 600;
final isPortrait =
MediaQuery.of(context).orientation == Orientation.portrait;
return Scaffold(
appBar: AppBar(
title: Text('people'.tr()),
),
body: people.when(
data: (people) {
return GridView.builder(
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: isTablet ? 6 : 3,
childAspectRatio: 0.85,
mainAxisSpacing: isPortrait && isTablet ? 36 : 0,
),
padding: const EdgeInsets.symmetric(vertical: 32),
itemCount: people.length,
itemBuilder: (context, index) {
final person = people[index];
return Column(
children: [
GestureDetector(
onTap: () {
context.pushRoute(
PersonResultRoute(
personId: person.id,
personName: person.name,
),
);
},
child: Material(
shape: const CircleBorder(side: BorderSide.none),
elevation: 3,
child: CircleAvatar(
maxRadius: isTablet ? 120 / 2 : 96 / 2,
backgroundImage: NetworkImage(
getFaceThumbnailUrl(person.id),
headers: headers,
),
),
),
),
const SizedBox(height: 12),
GestureDetector(
onTap: () => showNameEditModel(person.id, person.name),
child: person.name.isEmpty
? Text(
'add_a_name'.tr(),
style: context.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w500,
color: context.colorScheme.primary,
),
)
: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 16.0,
),
child: Text(
person.name,
overflow: TextOverflow.ellipsis,
style: context.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w500,
),
),
),
),
],
);
},
);
},
error: (error, stack) => const Text("error"),
loading: () => const CircularProgressIndicator(),
),
);
},
);
}
}
@@ -0,0 +1,125 @@
import 'package:auto_route/auto_route.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/models/search/search_filter.model.dart';
import 'package:immich_mobile/pages/common/large_leading_tile.dart';
import 'package:immich_mobile/providers/search/search_page_state.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/services/api.service.dart';
import 'package:immich_mobile/widgets/map/map_thumbnail.dart';
import 'package:maplibre_gl/maplibre_gl.dart';
@RoutePage()
class PlacesCollectionPage extends HookConsumerWidget {
const PlacesCollectionPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final places = ref.watch(getAllPlacesProvider);
return Scaffold(
appBar: AppBar(
title: Text('places'.tr()),
),
body: ListView(
shrinkWrap: true,
children: [
Padding(
padding: const EdgeInsets.all(16.0),
child: SizedBox(
height: 200,
width: context.width,
child: MapThumbnail(
onTap: (_, __) => context.pushRoute(const MapRoute()),
zoom: 8,
centre: const LatLng(
21.44950,
-157.91959,
),
showAttribution: false,
themeMode:
context.isDarkTheme ? ThemeMode.dark : ThemeMode.light,
),
),
),
places.when(
data: (places) {
return ListView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
itemCount: places.length,
itemBuilder: (context, index) {
final place = places[index];
return PlaceTile(id: place.id, name: place.label);
},
);
},
error: (error, stask) => const Text('Error getting places'),
loading: () => Center(child: const CircularProgressIndicator()),
),
],
),
);
}
}
class PlaceTile extends StatelessWidget {
const PlaceTile({super.key, required this.id, required this.name});
final String id;
final String name;
@override
Widget build(BuildContext context) {
final thumbnailUrl =
'${Store.get(StoreKey.serverEndpoint)}/assets/$id/thumbnail';
void navigateToPlace() {
context.pushRoute(
SearchRoute(
prefilter: SearchFilter(
people: {},
location: SearchLocationFilter(
city: name,
),
camera: SearchCameraFilter(),
date: SearchDateFilter(),
display: SearchDisplayFilters(
isNotInAlbum: false,
isArchive: false,
isFavorite: false,
),
mediaType: AssetType.other,
),
),
);
}
return LargeLeadingTile(
onTap: () => navigateToPlace(),
title: Text(
name,
style: context.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w500,
),
),
leading: ClipRRect(
borderRadius: BorderRadius.circular(20),
child: CachedNetworkImage(
width: 80,
height: 80,
fit: BoxFit.cover,
imageUrl: thumbnailUrl,
httpHeaders: ApiService.getRequestHeaders(),
errorWidget: (context, url, error) =>
const Icon(Icons.image_not_supported_outlined),
),
),
);
}
}
@@ -30,6 +30,7 @@ class SharedLinkEditPage extends HookConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) {
const padding = 20.0;
final themeData = context.themeData;
final colorScheme = context.colorScheme;
final descriptionController =
useTextEditingController(text: existingLink?.description ?? "");
final descriptionFocusNode = useFocusNode();
@@ -58,7 +59,7 @@ class SharedLinkEditPage extends HookConsumerWidget {
Text(
existingLink!.title,
style: TextStyle(
color: themeData.primaryColor,
color: colorScheme.primary,
fontWeight: FontWeight.bold,
),
),
@@ -81,7 +82,7 @@ class SharedLinkEditPage extends HookConsumerWidget {
child: Text(
existingLink!.description ?? "--",
style: TextStyle(
color: themeData.primaryColor,
color: colorScheme.primary,
fontWeight: FontWeight.bold,
),
overflow: TextOverflow.ellipsis,
@@ -109,7 +110,7 @@ class SharedLinkEditPage extends HookConsumerWidget {
labelText: 'shared_link_edit_description'.tr(),
labelStyle: TextStyle(
fontWeight: FontWeight.bold,
color: themeData.primaryColor,
color: colorScheme.primary,
),
floatingLabelBehavior: FloatingLabelBehavior.always,
border: const OutlineInputBorder(),
@@ -135,7 +136,7 @@ class SharedLinkEditPage extends HookConsumerWidget {
labelText: 'shared_link_edit_password'.tr(),
labelStyle: TextStyle(
fontWeight: FontWeight.bold,
color: themeData.primaryColor,
color: colorScheme.primary,
),
floatingLabelBehavior: FloatingLabelBehavior.always,
border: const OutlineInputBorder(),
@@ -157,7 +158,7 @@ class SharedLinkEditPage extends HookConsumerWidget {
onChanged: newShareLink.value.isEmpty
? (value) => showMetadata.value = value
: null,
activeColor: themeData.primaryColor,
activeColor: colorScheme.primary,
dense: true,
title: Text(
"shared_link_edit_show_meta",
@@ -173,7 +174,7 @@ class SharedLinkEditPage extends HookConsumerWidget {
onChanged: newShareLink.value.isEmpty
? (value) => allowDownload.value = value
: null,
activeColor: themeData.primaryColor,
activeColor: colorScheme.primary,
dense: true,
title: Text(
"shared_link_edit_allow_download",
@@ -189,7 +190,7 @@ class SharedLinkEditPage extends HookConsumerWidget {
onChanged: newShareLink.value.isEmpty
? (value) => allowUpload.value = value
: null,
activeColor: themeData.primaryColor,
activeColor: colorScheme.primary,
dense: true,
title: Text(
"shared_link_edit_allow_upload",
@@ -205,7 +206,7 @@ class SharedLinkEditPage extends HookConsumerWidget {
onChanged: newShareLink.value.isEmpty
? (value) => editExpiry.value = value
: null,
activeColor: themeData.primaryColor,
activeColor: colorScheme.primary,
dense: true,
title: Text(
"shared_link_edit_change_expiry",
@@ -221,7 +222,7 @@ class SharedLinkEditPage extends HookConsumerWidget {
"shared_link_edit_expire_after",
style: TextStyle(
fontWeight: FontWeight.bold,
color: themeData.primaryColor,
color: colorScheme.primary,
),
).tr(),
enableSearch: false,
@@ -233,14 +234,6 @@ class SharedLinkEditPage extends HookConsumerWidget {
onSelected: (value) {
expiryAfter.value = value!;
},
inputDecorationTheme: themeData.inputDecorationTheme.copyWith(
disabledBorder: OutlineInputBorder(
borderSide: BorderSide(color: Colors.grey.withOpacity(0.5)),
),
enabledBorder: const OutlineInputBorder(
borderSide: BorderSide(color: Colors.grey),
),
),
dropdownMenuEntries: [
DropdownMenuEntry(
value: 0,
+5 -8
View File
@@ -44,7 +44,7 @@ class TrashPage extends HookConsumerWidget {
if (context.mounted) {
ImmichToast.show(
context: context,
msg: 'Emptied trash',
msg: 'trash_emptied'.tr(),
gravity: ToastGravity.BOTTOM,
);
}
@@ -71,13 +71,11 @@ class TrashPage extends HookConsumerWidget {
.removeAssets(selection.value);
if (isRemoved) {
final assetOrAssets =
selection.value.length > 1 ? 'assets' : 'asset';
if (context.mounted) {
ImmichToast.show(
context: context,
msg:
'${selection.value.length} $assetOrAssets deleted permanently',
msg: 'assets_deleted_permanently'
.tr(args: ["${selection.value.length}"]),
gravity: ToastGravity.BOTTOM,
);
}
@@ -114,12 +112,11 @@ class TrashPage extends HookConsumerWidget {
.read(trashProvider.notifier)
.restoreAssets(selection.value);
final assetOrAssets = selection.value.length > 1 ? 'assets' : 'asset';
if (result && context.mounted) {
ImmichToast.show(
context: context,
msg:
'${selection.value.length} $assetOrAssets restored successfully',
msg: 'assets_restored_successfully'
.tr(args: ["${selection.value.length}"]),
gravity: ToastGravity.BOTTOM,
);
}
+4 -3
View File
@@ -3,6 +3,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/theme_extensions.dart';
import 'package:immich_mobile/widgets/forms/login/login_form.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:package_info_plus/package_info_plus.dart';
@@ -28,7 +29,7 @@ class LoginPage extends HookConsumerWidget {
);
return Scaffold(
body: const LoginForm(),
body: LoginForm(),
bottomNavigationBar: SafeArea(
child: Padding(
padding: const EdgeInsets.only(bottom: 16.0),
@@ -39,8 +40,8 @@ class LoginPage extends HookConsumerWidget {
children: [
Text(
'v${appVersion.value}',
style: const TextStyle(
color: Colors.grey,
style: TextStyle(
color: context.colorScheme.onSurfaceSecondary,
fontWeight: FontWeight.bold,
fontFamily: "Inconsolata",
),
+1 -3
View File
@@ -7,7 +7,6 @@ import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/providers/album/album.provider.dart';
import 'package:immich_mobile/providers/album/shared_album.provider.dart';
import 'package:immich_mobile/providers/multiselect.provider.dart';
import 'package:immich_mobile/widgets/memories/memory_lane.dart';
import 'package:immich_mobile/providers/asset.provider.dart';
@@ -33,8 +32,7 @@ class PhotosPage extends HookConsumerWidget {
() {
ref.read(websocketProvider.notifier).connect();
Future(() => ref.read(assetProvider.notifier).getAllAsset());
ref.read(albumProvider.notifier).getAllAlbums();
ref.read(sharedAlbumProvider.notifier).getAllSharedAlbums();
Future(() => ref.read(albumProvider.notifier).refreshRemoteAlbums());
ref.read(serverInfoProvider.notifier).getServerInfo();
return;
},
+8 -7
View File
@@ -1,4 +1,5 @@
import 'dart:math';
import 'package:auto_route/auto_route.dart';
import 'package:collection/collection.dart';
import 'package:easy_localization/easy_localization.dart';
@@ -7,27 +8,27 @@ import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:fluttertoast/fluttertoast.dart';
import 'package:geolocator/geolocator.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/extensions/asyncvalue_extensions.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/latlngbounds_extension.dart';
import 'package:immich_mobile/extensions/maplibrecontroller_extensions.dart';
import 'package:immich_mobile/models/map/map_event.model.dart';
import 'package:immich_mobile/models/map/map_marker.model.dart';
import 'package:immich_mobile/providers/db.provider.dart';
import 'package:immich_mobile/providers/map/map_marker.provider.dart';
import 'package:immich_mobile/providers/map/map_state.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/utils/debounce.dart';
import 'package:immich_mobile/utils/immich_loading_overlay.dart';
import 'package:immich_mobile/utils/map_utils.dart';
import 'package:immich_mobile/widgets/asset_grid/asset_grid_data_structure.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart';
import 'package:immich_mobile/widgets/map/map_app_bar.dart';
import 'package:immich_mobile/widgets/map/map_asset_grid.dart';
import 'package:immich_mobile/widgets/map/map_bottom_sheet.dart';
import 'package:immich_mobile/widgets/map/map_theme_override.dart';
import 'package:immich_mobile/widgets/map/positioned_asset_marker_icon.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/providers/db.provider.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart';
import 'package:immich_mobile/utils/immich_loading_overlay.dart';
import 'package:immich_mobile/utils/debounce.dart';
import 'package:maplibre_gl/maplibre_gl.dart';
@RoutePage()
@@ -304,7 +305,7 @@ class MapPage extends HookConsumerWidget {
),
Positioned(
right: 0,
bottom: MediaQuery.of(context).padding.bottom + 16,
bottom: MediaQuery.paddingOf(context).bottom + 16,
child: ElevatedButton(
onPressed: onZoomToLocation,
style: ElevatedButton.styleFrom(
@@ -12,7 +12,7 @@ import 'package:immich_mobile/widgets/map/map_theme_override.dart';
import 'package:maplibre_gl/maplibre_gl.dart';
import 'package:immich_mobile/utils/map_utils.dart';
@RoutePage<LatLng?>()
@RoutePage()
class MapLocationPickerPage extends HookConsumerWidget {
final LatLng initialLatLng;
@@ -92,6 +92,7 @@ class PersonResultPage extends HookConsumerWidget {
Text(
name.value,
style: context.textTheme.titleLarge,
overflow: TextOverflow.ellipsis,
),
],
),
@@ -125,9 +126,11 @@ class PersonResultPage extends HookConsumerWidget {
headers: ApiService.getRequestHeaders(),
),
),
Padding(
padding: const EdgeInsets.only(left: 16.0),
child: buildTitleBlock(),
Expanded(
child: Padding(
padding: const EdgeInsets.only(left: 16.0, right: 16.0),
child: buildTitleBlock(),
),
),
],
),
File diff suppressed because it is too large Load Diff
@@ -1,581 +0,0 @@
import 'dart:async';
import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/models/search/search_filter.model.dart';
import 'package:immich_mobile/providers/search/paginated_search.provider.dart';
import 'package:immich_mobile/widgets/asset_grid/multiselect_grid.dart';
import 'package:immich_mobile/widgets/search/search_filter/camera_picker.dart';
import 'package:immich_mobile/widgets/search/search_filter/display_option_picker.dart';
import 'package:immich_mobile/widgets/search/search_filter/filter_bottom_sheet_scaffold.dart';
import 'package:immich_mobile/widgets/search/search_filter/location_picker.dart';
import 'package:immich_mobile/widgets/search/search_filter/media_type_picker.dart';
import 'package:immich_mobile/widgets/search/search_filter/people_picker.dart';
import 'package:immich_mobile/widgets/search/search_filter/search_filter_chip.dart';
import 'package:immich_mobile/widgets/search/search_filter/search_filter_utils.dart';
import 'package:openapi/api.dart';
@RoutePage()
class SearchInputPage extends HookConsumerWidget {
const SearchInputPage({super.key, this.prefilter});
final SearchFilter? prefilter;
@override
Widget build(BuildContext context, WidgetRef ref) {
final isContextualSearch = useState(true);
final textSearchController = useTextEditingController();
final filter = useState<SearchFilter>(
SearchFilter(
people: prefilter?.people ?? {},
location: prefilter?.location ?? SearchLocationFilter(),
camera: prefilter?.camera ?? SearchCameraFilter(),
date: prefilter?.date ?? SearchDateFilter(),
display: prefilter?.display ??
SearchDisplayFilters(
isNotInAlbum: false,
isArchive: false,
isFavorite: false,
),
mediaType: prefilter?.mediaType ?? AssetType.other,
),
);
final previousFilter = useState(filter.value);
final peopleCurrentFilterWidget = useState<Widget?>(null);
final dateRangeCurrentFilterWidget = useState<Widget?>(null);
final cameraCurrentFilterWidget = useState<Widget?>(null);
final locationCurrentFilterWidget = useState<Widget?>(null);
final mediaTypeCurrentFilterWidget = useState<Widget?>(null);
final displayOptionCurrentFilterWidget = useState<Widget?>(null);
final currentPage = useState(1);
final searchProvider = ref.watch(paginatedSearchProvider);
final searchResultCount = useState(0);
search() async {
if (prefilter == null && filter.value == previousFilter.value) return;
ref.watch(paginatedSearchProvider.notifier).clear();
currentPage.value = 1;
final searchResult = await ref
.watch(paginatedSearchProvider.notifier)
.getNextPage(filter.value, currentPage.value);
previousFilter.value = filter.value;
searchResultCount.value = searchResult.length;
}
searchPrefilter() {
if (prefilter != null) {
Future.delayed(
Duration.zero,
() {
search();
if (prefilter!.location.city != null) {
locationCurrentFilterWidget.value = Text(
prefilter!.location.city!,
style: context.textTheme.labelLarge,
);
}
},
);
}
}
useEffect(
() {
searchPrefilter();
return null;
},
[],
);
loadMoreSearchResult() async {
currentPage.value += 1;
final searchResult = await ref
.watch(paginatedSearchProvider.notifier)
.getNextPage(filter.value, currentPage.value);
searchResultCount.value = searchResult.length;
}
showPeoplePicker() {
handleOnSelect(Set<PersonResponseDto> value) {
filter.value = filter.value.copyWith(
people: value,
);
peopleCurrentFilterWidget.value = Text(
value.map((e) => e.name != '' ? e.name : 'no_name'.tr()).join(', '),
style: context.textTheme.labelLarge,
);
}
handleClear() {
filter.value = filter.value.copyWith(
people: {},
);
peopleCurrentFilterWidget.value = null;
search();
}
showFilterBottomSheet(
context: context,
isScrollControlled: true,
child: FractionallySizedBox(
heightFactor: 0.8,
child: FilterBottomSheetScaffold(
title: 'search_filter_people_title'.tr(),
expanded: true,
onSearch: search,
onClear: handleClear,
child: PeoplePicker(
onSelect: handleOnSelect,
filter: filter.value.people,
),
),
),
);
}
showLocationPicker() {
handleOnSelect(Map<String, String?> value) {
filter.value = filter.value.copyWith(
location: SearchLocationFilter(
country: value['country'],
city: value['city'],
state: value['state'],
),
);
final locationText = <String>[];
if (value['country'] != null) {
locationText.add(value['country']!);
}
if (value['state'] != null) {
locationText.add(value['state']!);
}
if (value['city'] != null) {
locationText.add(value['city']!);
}
locationCurrentFilterWidget.value = Text(
locationText.join(', '),
style: context.textTheme.labelLarge,
);
}
handleClear() {
filter.value = filter.value.copyWith(
location: SearchLocationFilter(),
);
locationCurrentFilterWidget.value = null;
search();
}
showFilterBottomSheet(
context: context,
isScrollControlled: true,
isDismissible: false,
child: FilterBottomSheetScaffold(
title: 'search_filter_location_title'.tr(),
onSearch: search,
onClear: handleClear,
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 16.0),
child: Container(
padding: EdgeInsets.only(
bottom: MediaQuery.of(context).viewInsets.bottom,
),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: LocationPicker(
onSelected: handleOnSelect,
filter: filter.value.location,
),
),
),
),
),
);
}
showCameraPicker() {
handleOnSelect(Map<String, String?> value) {
filter.value = filter.value.copyWith(
camera: SearchCameraFilter(
make: value['make'],
model: value['model'],
),
);
cameraCurrentFilterWidget.value = Text(
'${value['make'] ?? ''} ${value['model'] ?? ''}',
style: context.textTheme.labelLarge,
);
}
handleClear() {
filter.value = filter.value.copyWith(
camera: SearchCameraFilter(),
);
cameraCurrentFilterWidget.value = null;
search();
}
showFilterBottomSheet(
context: context,
isScrollControlled: true,
isDismissible: false,
child: FilterBottomSheetScaffold(
title: 'search_filter_camera_title'.tr(),
onSearch: search,
onClear: handleClear,
child: Padding(
padding: const EdgeInsets.all(16.0),
child: CameraPicker(
onSelect: handleOnSelect,
filter: filter.value.camera,
),
),
),
);
}
showDatePicker() async {
final firstDate = DateTime(1900);
final lastDate = DateTime.now();
final date = await showDateRangePicker(
context: context,
firstDate: firstDate,
lastDate: lastDate,
currentDate: DateTime.now(),
initialDateRange: DateTimeRange(
start: filter.value.date.takenAfter ?? lastDate,
end: filter.value.date.takenBefore ?? lastDate,
),
helpText: 'search_filter_date_title'.tr(),
cancelText: 'action_common_cancel'.tr(),
confirmText: 'action_common_select'.tr(),
saveText: 'action_common_save'.tr(),
errorFormatText: 'invalid_date_format'.tr(),
errorInvalidText: 'invalid_date'.tr(),
fieldStartHintText: 'start_date'.tr(),
fieldEndHintText: 'end_date'.tr(),
initialEntryMode: DatePickerEntryMode.input,
);
if (date == null) {
filter.value = filter.value.copyWith(
date: SearchDateFilter(),
);
dateRangeCurrentFilterWidget.value = null;
search();
return;
}
filter.value = filter.value.copyWith(
date: SearchDateFilter(
takenAfter: date.start,
takenBefore: date.end.add(
const Duration(
hours: 23,
minutes: 59,
seconds: 59,
),
),
),
);
// If date range is less than 24 hours, set the end date to the end of the day
if (date.end.difference(date.start).inHours < 24) {
dateRangeCurrentFilterWidget.value = Text(
DateFormat.yMMMd().format(date.start.toLocal()),
style: context.textTheme.labelLarge,
);
} else {
dateRangeCurrentFilterWidget.value = Text(
'search_filter_date_interval'.tr(
namedArgs: {
"start": DateFormat.yMMMd().format(date.start.toLocal()),
"end": DateFormat.yMMMd().format(date.end.toLocal()),
},
),
style: context.textTheme.labelLarge,
);
}
search();
}
// MEDIA PICKER
showMediaTypePicker() {
handleOnSelected(AssetType assetType) {
filter.value = filter.value.copyWith(
mediaType: assetType,
);
mediaTypeCurrentFilterWidget.value = Text(
assetType == AssetType.image
? 'search_filter_media_type_image'.tr()
: assetType == AssetType.video
? 'search_filter_media_type_video'.tr()
: 'search_filter_media_type_all'.tr(),
style: context.textTheme.labelLarge,
);
}
handleClear() {
filter.value = filter.value.copyWith(
mediaType: AssetType.other,
);
mediaTypeCurrentFilterWidget.value = null;
search();
}
showFilterBottomSheet(
context: context,
child: FilterBottomSheetScaffold(
title: 'search_filter_media_type_title'.tr(),
onSearch: search,
onClear: handleClear,
child: MediaTypePicker(
onSelect: handleOnSelected,
filter: filter.value.mediaType,
),
),
);
}
// DISPLAY OPTION
showDisplayOptionPicker() {
handleOnSelect(Map<DisplayOption, bool> value) {
final filterText = <String>[];
value.forEach((key, value) {
switch (key) {
case DisplayOption.notInAlbum:
filter.value = filter.value.copyWith(
display: filter.value.display.copyWith(
isNotInAlbum: value,
),
);
if (value) {
filterText
.add('search_filter_display_option_not_in_album'.tr());
}
break;
case DisplayOption.archive:
filter.value = filter.value.copyWith(
display: filter.value.display.copyWith(
isArchive: value,
),
);
if (value) {
filterText.add('search_filter_display_option_archive'.tr());
}
break;
case DisplayOption.favorite:
filter.value = filter.value.copyWith(
display: filter.value.display.copyWith(
isFavorite: value,
),
);
if (value) {
filterText.add('search_filter_display_option_favorite'.tr());
}
break;
}
});
displayOptionCurrentFilterWidget.value = Text(
filterText.join(', '),
style: context.textTheme.labelLarge,
);
}
handleClear() {
filter.value = filter.value.copyWith(
display: SearchDisplayFilters(
isNotInAlbum: false,
isArchive: false,
isFavorite: false,
),
);
displayOptionCurrentFilterWidget.value = null;
search();
}
showFilterBottomSheet(
context: context,
child: FilterBottomSheetScaffold(
title: 'search_filter_display_options_title'.tr(),
onSearch: search,
onClear: handleClear,
child: DisplayOptionPicker(
onSelect: handleOnSelect,
filter: filter.value.display,
),
),
);
}
handleTextSubmitted(String value) {
if (isContextualSearch.value) {
filter.value = filter.value.copyWith(
context: value,
filename: null,
);
} else {
filter.value = filter.value.copyWith(filename: value, context: null);
}
search();
}
buildSearchResult() {
return switch (searchProvider) {
AsyncData() => Expanded(
child: Padding(
padding: const EdgeInsets.only(top: 8.0),
child: NotificationListener<ScrollEndNotification>(
onNotification: (notification) {
final metrics = notification.metrics;
final shouldLoadMore = searchResultCount.value > 75;
if (metrics.pixels >= metrics.maxScrollExtent &&
shouldLoadMore) {
loadMoreSearchResult();
}
return true;
},
child: MultiselectGrid(
renderListProvider: paginatedSearchRenderListProvider,
archiveEnabled: true,
deleteEnabled: true,
editEnabled: true,
favoriteEnabled: true,
stackEnabled: false,
emptyIndicator: const SizedBox(),
),
),
),
),
AsyncError(:final error) => Text('Error: $error'),
_ => const Expanded(child: Center(child: CircularProgressIndicator())),
};
}
return Scaffold(
resizeToAvoidBottomInset: true,
appBar: AppBar(
automaticallyImplyLeading: true,
actions: [
IconButton(
icon: isContextualSearch.value
? const Icon(Icons.abc_rounded)
: const Icon(Icons.image_search_rounded),
onPressed: () {
isContextualSearch.value = !isContextualSearch.value;
textSearchController.clear();
},
),
],
leading: IconButton(
icon: const Icon(Icons.arrow_back_ios_new_rounded),
onPressed: () => context.router.maybePop(),
),
title: TextField(
controller: textSearchController,
decoration: InputDecoration(
hintText: isContextualSearch.value
? 'contextual_search'.tr()
: 'filename_search'.tr(),
hintStyle: context.textTheme.bodyLarge?.copyWith(
color: context.themeData.colorScheme.onSurface.withOpacity(0.75),
fontWeight: FontWeight.w500,
),
enabledBorder: const UnderlineInputBorder(
borderSide: BorderSide(color: Colors.transparent),
),
focusedBorder: const UnderlineInputBorder(
borderSide: BorderSide(color: Colors.transparent),
),
),
onSubmitted: handleTextSubmitted,
),
),
body: Column(
children: [
Padding(
padding: const EdgeInsets.only(top: 12.0),
child: SizedBox(
height: 50,
child: ListView(
shrinkWrap: true,
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 16),
children: [
SearchFilterChip(
icon: Icons.people_alt_rounded,
onTap: showPeoplePicker,
label: 'search_filter_people'.tr(),
currentFilter: peopleCurrentFilterWidget.value,
),
SearchFilterChip(
icon: Icons.location_pin,
onTap: showLocationPicker,
label: 'search_filter_location'.tr(),
currentFilter: locationCurrentFilterWidget.value,
),
SearchFilterChip(
icon: Icons.camera_alt_rounded,
onTap: showCameraPicker,
label: 'search_filter_camera'.tr(),
currentFilter: cameraCurrentFilterWidget.value,
),
SearchFilterChip(
icon: Icons.date_range_rounded,
onTap: showDatePicker,
label: 'search_filter_date'.tr(),
currentFilter: dateRangeCurrentFilterWidget.value,
),
SearchFilterChip(
icon: Icons.video_collection_outlined,
onTap: showMediaTypePicker,
label: 'search_filter_media_type'.tr(),
currentFilter: mediaTypeCurrentFilterWidget.value,
),
SearchFilterChip(
icon: Icons.display_settings_outlined,
onTap: showDisplayOptionPicker,
label: 'search_filter_display_options'.tr(),
currentFilter: displayOptionCurrentFilterWidget.value,
),
],
),
),
),
buildSearchResult(),
],
),
);
}
}
-276
View File
@@ -1,276 +0,0 @@
import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/providers/album/album_sort_by_options.provider.dart';
import 'package:immich_mobile/providers/album/shared_album.provider.dart';
import 'package:immich_mobile/widgets/album/album_thumbnail_card.dart';
import 'package:immich_mobile/providers/partner.provider.dart';
import 'package:immich_mobile/widgets/partner/partner_list.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/widgets/common/immich_app_bar.dart';
import 'package:immich_mobile/widgets/common/immich_thumbnail.dart';
@RoutePage()
class SharingPage extends HookConsumerWidget {
const SharingPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final albumSortOption = ref.watch(albumSortByOptionsProvider);
final albumSortIsReverse = ref.watch(albumSortOrderProvider);
final albums = ref.watch(sharedAlbumProvider);
final sharedAlbums = albumSortOption.sortFn(albums, albumSortIsReverse);
final userId = ref.watch(currentUserProvider)?.id;
final partner = ref.watch(partnerSharedWithProvider);
useEffect(
() {
ref.read(sharedAlbumProvider.notifier).getAllSharedAlbums();
return null;
},
[],
);
buildAlbumGrid() {
return SliverPadding(
padding: const EdgeInsets.all(18.0),
sliver: SliverGrid(
gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent: 250,
mainAxisSpacing: 12,
crossAxisSpacing: 12,
childAspectRatio: .7,
),
delegate: SliverChildBuilderDelegate(
(context, index) {
return AlbumThumbnailCard(
album: sharedAlbums[index],
showOwner: true,
onTap: () => context.pushRoute(
AlbumViewerRoute(albumId: sharedAlbums[index].id),
),
);
},
childCount: sharedAlbums.length,
),
),
);
}
buildAlbumList() {
return SliverList(
delegate: SliverChildBuilderDelegate(
(BuildContext context, int index) {
final album = sharedAlbums[index];
final isOwner = album.ownerId == userId;
return ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 12),
leading: ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(8)),
child: ImmichThumbnail(
asset: album.thumbnail.value,
width: 60,
height: 60,
),
),
title: Text(
album.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: context.textTheme.bodyMedium?.copyWith(
color: context.primaryColor,
fontWeight: FontWeight.w500,
),
),
subtitle: isOwner
? Text(
'album_thumbnail_owned'.tr(),
style: context.textTheme.bodyMedium,
)
: album.ownerName != null
? Text(
'album_thumbnail_shared_by'
.tr(args: [album.ownerName!]),
style: context.textTheme.bodyMedium,
)
: null,
onTap: () => context
.pushRoute(AlbumViewerRoute(albumId: sharedAlbums[index].id)),
);
},
childCount: sharedAlbums.length,
),
);
}
buildTopBottons() {
return Padding(
padding: const EdgeInsets.only(
left: 12.0,
right: 12.0,
top: 24.0,
bottom: 12.0,
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
Expanded(
child: ElevatedButton.icon(
onPressed: () =>
context.pushRoute(CreateAlbumRoute(isSharedAlbum: true)),
icon: const Icon(
Icons.photo_album_outlined,
size: 20,
),
label: const Text(
"sharing_silver_appbar_create_shared_album",
maxLines: 1,
style: TextStyle(
fontWeight: FontWeight.w500,
fontSize: 12,
),
).tr(),
),
),
const SizedBox(width: 12.0),
Expanded(
child: ElevatedButton.icon(
onPressed: () => context.pushRoute(const SharedLinkRoute()),
icon: const Icon(
Icons.link,
size: 20,
),
label: const Text(
"sharing_silver_appbar_shared_links",
style: TextStyle(
fontWeight: FontWeight.w500,
fontSize: 12,
),
maxLines: 1,
).tr(),
),
),
],
),
);
}
buildEmptyListIndication() {
return SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.all(8.0),
child: Card(
elevation: 0,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.all(Radius.circular(20)),
side: BorderSide(
color: Colors.grey,
width: 0.5,
),
),
child: Padding(
padding: const EdgeInsets.all(18.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.only(left: 5.0, bottom: 5),
child: Icon(
Icons.insert_photo_rounded,
size: 50,
color: context.primaryColor,
),
),
Padding(
padding: const EdgeInsets.all(8.0),
child: Text(
'sharing_page_empty_list',
style: context.textTheme.displaySmall,
).tr(),
),
Padding(
padding: const EdgeInsets.all(8.0),
child: Text(
'sharing_page_description',
style: context.textTheme.bodyMedium,
).tr(),
),
],
),
),
),
),
);
}
Widget sharePartnerButton() {
return InkWell(
onTap: () => context.pushRoute(const PartnerRoute()),
borderRadius: const BorderRadius.all(Radius.circular(12)),
child: Icon(
Icons.swap_horizontal_circle_rounded,
size: 25,
semanticLabel: 'partner_page_title'.tr(),
),
);
}
return RefreshIndicator(
onRefresh: () async {
ref.read(sharedAlbumProvider.notifier).getAllSharedAlbums();
},
child: Scaffold(
appBar: ImmichAppBar(
action: sharePartnerButton(),
),
body: CustomScrollView(
slivers: [
SliverToBoxAdapter(child: buildTopBottons()),
if (partner.isNotEmpty)
SliverPadding(
padding: const EdgeInsets.all(12),
sliver: SliverToBoxAdapter(
child: Text(
"partner_page_title",
style: context.textTheme.bodyLarge?.copyWith(
fontWeight: FontWeight.w500,
),
).tr(),
),
),
if (partner.isNotEmpty) PartnerList(partner: partner),
SliverPadding(
padding: const EdgeInsets.all(12),
sliver: SliverToBoxAdapter(
child: Text(
"sharing_page_album",
style: context.textTheme.bodyLarge?.copyWith(
fontWeight: FontWeight.w500,
),
).tr(),
),
),
SliverLayoutBuilder(
builder: (context, constraints) {
if (sharedAlbums.isEmpty) {
return buildEmptyListIndication();
}
if (constraints.crossAxisExtent < 600) {
return buildAlbumList();
} else {
return buildAlbumGrid();
}
},
),
],
),
),
);
}
}
@@ -1,9 +1,9 @@
import 'package:immich_mobile/repositories/activity_api.repository.dart';
import 'package:immich_mobile/services/activity.service.dart';
import 'package:immich_mobile/providers/api.provider.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'activity_service.provider.g.dart';
@riverpod
ActivityService activityService(ActivityServiceRef ref) =>
ActivityService(ref.watch(apiServiceProvider));
ActivityService(ref.watch(activityApiRepositoryProvider));
+1 -1
View File
@@ -6,7 +6,7 @@ part of 'activity_service.provider.dart';
// RiverpodGenerator
// **************************************************************************
String _$activityServiceHash() => r'5dd4955d14f5bf01c00d7f8750d07e7ace7cc4b0';
String _$activityServiceHash() => r'23a3ee7db71676d2719daa64217a683cc5c7eab0';
/// See also [activityService].
@ProviderFor(activityService)
@@ -11,7 +11,7 @@ class ActivityStatistics extends _$ActivityStatistics {
ref
.watch(activityServiceProvider)
.getStatistics(albumId, assetId: assetId)
.then((comments) => state = comments);
.then((stats) => state = stats.comments);
return 0;
}
+1 -1
View File
@@ -7,7 +7,7 @@ part of 'activity_statistics.provider.dart';
// **************************************************************************
String _$activityStatisticsHash() =>
r'a5f7bbee1891c33b72919a34e632ca9ef9cd8dbf';
r'1f43f0bcb11c754ca3cb586a13570db25023b9a8';
/// Copied from Dart SDK
class _SystemHash {
+105 -11
View File
@@ -1,21 +1,21 @@
import 'dart:async';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/entities/user.entity.dart';
import 'package:immich_mobile/models/albums/album_search.model.dart';
import 'package:immich_mobile/services/album.service.dart';
import 'package:immich_mobile/widgets/asset_grid/asset_grid_data_structure.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/entities/album.entity.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/entities/user.entity.dart';
import 'package:immich_mobile/providers/db.provider.dart';
import 'package:immich_mobile/utils/renderlist_generator.dart';
import 'package:isar/isar.dart';
final isRefreshingRemoteAlbumProvider = StateProvider<bool>((ref) => false);
class AlbumNotifier extends StateNotifier<List<Album>> {
AlbumNotifier(this._albumService, Isar db) : super([]) {
final query = db.albums
.filter()
.owner((q) => q.isarIdEqualTo(Store.get(StoreKey.currentUser).isarId));
AlbumNotifier(this._albumService, this.db, this.ref) : super([]) {
final query = db.albums.filter().remoteIdIsNotNull();
query.findAll().then((value) {
if (mounted) {
state = value;
@@ -23,15 +23,19 @@ class AlbumNotifier extends StateNotifier<List<Album>> {
});
_streamSub = query.watch().listen((data) => state = data);
}
final AlbumService _albumService;
final Isar db;
final Ref ref;
late final StreamSubscription<List<Album>> _streamSub;
Future<void> getAllAlbums() => Future.wait([
_albumService.refreshDeviceAlbums(),
_albumService.refreshRemoteAlbums(isShared: false),
]);
Future<void> refreshRemoteAlbums() async {
ref.read(isRefreshingRemoteAlbumProvider.notifier).state = true;
await _albumService.refreshRemoteAlbums();
ref.read(isRefreshingRemoteAlbumProvider.notifier).state = false;
}
Future<void> getDeviceAlbums() => _albumService.refreshDeviceAlbums();
Future<void> refreshDeviceAlbums() => _albumService.refreshDeviceAlbums();
Future<bool> deleteAlbum(Album album) => _albumService.deleteAlbum(album);
@@ -41,6 +45,67 @@ class AlbumNotifier extends StateNotifier<List<Album>> {
) =>
_albumService.createAlbum(albumTitle, assets, []);
Future<Album?> getAlbumByName(String albumName, {bool remoteOnly = false}) =>
_albumService.getAlbumByName(albumName, remoteOnly);
/// Create an album on the server with the same name as the selected album for backup
/// First this will check if the album already exists on the server with name
/// If it does not exist, it will create the album on the server
Future<void> createSyncAlbum(
String albumName,
) async {
final album = await getAlbumByName(albumName, remoteOnly: true);
if (album != null) {
return;
}
await createAlbum(albumName, {});
}
Future<bool> leaveAlbum(Album album) async {
var res = await _albumService.leaveAlbum(album);
if (res) {
await deleteAlbum(album);
return true;
} else {
return false;
}
}
void searchAlbums(String searchTerm, QuickFilterMode filterMode) async {
state = await _albumService.search(searchTerm, filterMode);
}
Future<void> addUsers(Album album, List<String> userIds) async {
await _albumService.addUsers(album, userIds);
}
Future<bool> removeUser(Album album, User user) async {
final isRemoved = await _albumService.removeUser(album, user);
if (isRemoved && album.sharedUsers.isEmpty) {
state = state.where((element) => element.id != album.id).toList();
}
return isRemoved;
}
Future<void> addAssets(Album album, Iterable<Asset> assets) async {
await _albumService.addAssets(album, assets);
}
Future<bool> removeAsset(Album album, Iterable<Asset> assets) async {
return await _albumService.removeAsset(album, assets);
}
Future<bool> setActivitystatus(
Album album,
bool enabled,
) {
return _albumService.setActivityStatus(album, enabled);
}
@override
void dispose() {
_streamSub.cancel();
@@ -53,6 +118,7 @@ final albumProvider =
return AlbumNotifier(
ref.watch(albumServiceProvider),
ref.watch(dbProvider),
ref,
);
});
@@ -76,3 +142,31 @@ final albumRenderlistProvider =
}
return const Stream.empty();
});
class LocalAlbumsNotifier extends StateNotifier<List<Album>> {
LocalAlbumsNotifier(this.db) : super([]) {
final query = db.albums.where().remoteIdIsNull();
query.findAll().then((value) {
if (mounted) {
state = value;
}
});
_streamSub = query.watch().listen((data) => state = data);
}
final Isar db;
late final StreamSubscription<List<Album>> _streamSub;
@override
void dispose() {
_streamSub.cancel();
super.dispose();
}
}
final localAlbumsProvider =
StateNotifierProvider.autoDispose<LocalAlbumsNotifier, List<Album>>((ref) {
return LocalAlbumsNotifier(ref.watch(dbProvider));
});
@@ -1,6 +1,5 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/models/albums/album_viewer_page_state.model.dart';
import 'package:immich_mobile/providers/album/shared_album.provider.dart';
import 'package:immich_mobile/services/album.service.dart';
import 'package:immich_mobile/entities/album.entity.dart';
@@ -40,7 +39,6 @@ class AlbumViewerNotifier extends StateNotifier<AlbumViewerPageState> {
if (isSuccess) {
state = state.copyWith(editTitleText: "", isEditAlbum: false);
ref.read(sharedAlbumProvider.notifier).getAllSharedAlbums();
return true;
}
@@ -1,90 +0,0 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/services/album.service.dart';
import 'package:immich_mobile/entities/album.entity.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/entities/user.entity.dart';
import 'package:immich_mobile/providers/db.provider.dart';
import 'package:isar/isar.dart';
class SharedAlbumNotifier extends StateNotifier<List<Album>> {
SharedAlbumNotifier(this._albumService, Isar db) : super([]) {
final query = db.albums.filter().sharedEqualTo(true).sortByCreatedAtDesc();
query.findAll().then((value) {
if (mounted) {
state = value;
}
});
_streamSub = query.watch().listen((data) => state = data);
}
final AlbumService _albumService;
late final StreamSubscription<List<Album>> _streamSub;
Future<Album?> createSharedAlbum(
String albumName,
Iterable<Asset> assets,
Iterable<User> sharedUsers,
) async {
try {
return await _albumService.createAlbum(
albumName,
assets,
sharedUsers,
);
} catch (e) {
debugPrint("Error createSharedAlbum ${e.toString()}");
}
return null;
}
Future<void> getAllSharedAlbums() =>
_albumService.refreshRemoteAlbums(isShared: true);
Future<bool> deleteAlbum(Album album) => _albumService.deleteAlbum(album);
Future<bool> leaveAlbum(Album album) async {
var res = await _albumService.leaveAlbum(album);
if (res) {
await deleteAlbum(album);
return true;
} else {
return false;
}
}
Future<bool> removeAssetFromAlbum(Album album, Iterable<Asset> assets) {
return _albumService.removeAssetFromAlbum(album, assets);
}
Future<bool> removeUserFromAlbum(Album album, User user) async {
final result = await _albumService.removeUserFromAlbum(album, user);
if (result && album.sharedUsers.isEmpty) {
state = state.where((element) => element.id != album.id).toList();
}
return result;
}
Future<bool> setActivityEnabled(Album album, bool activityEnabled) {
return _albumService.setActivityEnabled(album, activityEnabled);
}
@override
void dispose() {
_streamSub.cancel();
super.dispose();
}
}
final sharedAlbumProvider =
StateNotifierProvider.autoDispose<SharedAlbumNotifier, List<Album>>((ref) {
return SharedAlbumNotifier(
ref.watch(albumServiceProvider),
ref.watch(dbProvider),
);
});
@@ -5,5 +5,5 @@ import 'package:immich_mobile/services/user.service.dart';
final otherUsersProvider = FutureProvider.autoDispose<List<User>>((ref) {
UserService userService = ref.watch(userServiceProvider);
return userService.getUsersInDb();
return userService.getUsers();
});
@@ -1,6 +1,5 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/providers/album/album.provider.dart';
import 'package:immich_mobile/providers/album/shared_album.provider.dart';
import 'package:immich_mobile/services/background.service.dart';
import 'package:immich_mobile/models/backup/backup_state.model.dart';
import 'package:immich_mobile/providers/backup/backup.provider.dart';
@@ -58,11 +57,10 @@ class AppLifeCycleNotifier extends StateNotifier<AppLifeCycleEnum> {
_ref.read(assetProvider.notifier).getAllAsset();
case TabEnum.search:
// nothing to do
case TabEnum.sharing:
_ref.read(assetProvider.notifier).getAllAsset();
_ref.read(sharedAlbumProvider.notifier).getAllSharedAlbums();
case TabEnum.albums:
_ref.read(albumProvider.notifier).refreshRemoteAlbums();
case TabEnum.library:
_ref.read(albumProvider.notifier).getAllAlbums();
// nothing to do
}
}
@@ -86,12 +84,16 @@ class AppLifeCycleNotifier extends StateNotifier<AppLifeCycleEnum> {
void handleAppPause() {
state = AppLifeCycleEnum.paused;
_wasPaused = true;
// Do not cancel backup if manual upload is in progress
if (_ref.read(backupProvider.notifier).backupProgress !=
BackUpProgressEnum.manualInProgress) {
_ref.read(backupProvider.notifier).cancelBackup();
if (_ref.read(authenticationProvider).isAuthenticated) {
// Do not cancel backup if manual upload is in progress
if (_ref.read(backupProvider.notifier).backupProgress !=
BackUpProgressEnum.manualInProgress) {
_ref.read(backupProvider.notifier).cancelBackup();
}
_ref.read(websocketProvider.notifier).disconnect();
}
_ref.read(websocketProvider.notifier).disconnect();
ImmichLogger().flush();
}
+8 -22
View File
@@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/providers/memory.provider.dart';
import 'package:immich_mobile/repositories/asset_media.repository.dart';
import 'package:immich_mobile/services/album.service.dart';
import 'package:immich_mobile/entities/exif_info.entity.dart';
import 'package:immich_mobile/entities/store.entity.dart';
@@ -15,7 +16,6 @@ import 'package:immich_mobile/utils/db.dart';
import 'package:immich_mobile/utils/renderlist_generator.dart';
import 'package:isar/isar.dart';
import 'package:logging/logging.dart';
import 'package:photo_manager/photo_manager.dart';
class AssetNotifier extends StateNotifier<bool> {
final AssetService _assetService;
@@ -257,7 +257,7 @@ class AssetNotifier extends StateNotifier<bool> {
// Delete asset from device
if (local.isNotEmpty) {
try {
return await PhotoManager.editor.deleteWithIds(local);
return await _ref.read(assetMediaRepositoryProvider).deleteAll(local);
} catch (e, stack) {
log.severe("Failed to delete asset from device", e, stack);
}
@@ -275,28 +275,14 @@ class AssetNotifier extends StateNotifier<bool> {
return isSuccess ? remote.toList() : [];
}
Future<void> toggleFavorite(List<Asset> assets, [bool? status]) async {
Future<void> toggleFavorite(List<Asset> assets, [bool? status]) {
status ??= !assets.every((a) => a.isFavorite);
final newAssets = await _assetService.changeFavoriteStatus(assets, status);
for (Asset? newAsset in newAssets) {
if (newAsset == null) {
log.severe("Change favorite status failed for asset");
continue;
}
}
return _assetService.changeFavoriteStatus(assets, status);
}
Future<void> toggleArchive(List<Asset> assets, [bool? status]) async {
Future<void> toggleArchive(List<Asset> assets, [bool? status]) {
status ??= !assets.every((a) => a.isArchived);
final newAssets = await _assetService.changeArchiveStatus(assets, status);
int i = 0;
for (Asset oldAsset in assets) {
final newAsset = newAssets[i++];
if (newAsset == null) {
log.severe("Change archive status failed for asset ${oldAsset.id}");
continue;
}
}
return _assetService.changeArchiveStatus(assets, status);
}
}
@@ -360,7 +346,7 @@ QueryBuilder<Asset, Asset, QAfterSortBy>? getRemoteAssetQuery(WidgetRef ref) {
.filter()
.ownerIdEqualTo(userId)
.isTrashedEqualTo(false)
.stackParentIdIsNull()
.stackPrimaryAssetIdIsNull()
.sortByFileCreatedAtDesc();
}
@@ -374,6 +360,6 @@ QueryBuilder<Asset, Asset, QAfterSortBy> _commonFilterAndSort(
.filter()
.isArchivedEqualTo(false)
.isTrashedEqualTo(false)
.stackParentIdIsNull()
.stackPrimaryAssetIdIsNull()
.sortByFileCreatedAtDesc();
}
@@ -48,7 +48,7 @@ final assetStackProvider =
.filter()
.isArchivedEqualTo(false)
.isTrashedEqualTo(false)
.stackParentIdEqualTo(asset.remoteId)
.stackPrimaryAssetIdEqualTo(asset.remoteId)
.sortByFileCreatedAtDesc()
.findAll();
});
@@ -0,0 +1,196 @@
import 'package:background_downloader/background_downloader.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:fluttertoast/fluttertoast.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/models/download/download_state.model.dart';
import 'package:immich_mobile/models/download/livephotos_medatada.model.dart';
import 'package:immich_mobile/services/album.service.dart';
import 'package:immich_mobile/services/download.service.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/services/share.service.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart';
import 'package:immich_mobile/widgets/common/share_dialog.dart';
class DownloadStateNotifier extends StateNotifier<DownloadState> {
final DownloadService _downloadService;
final ShareService _shareService;
final AlbumService _albumService;
DownloadStateNotifier(
this._downloadService,
this._shareService,
this._albumService,
) : super(
DownloadState(
downloadStatus: TaskStatus.complete,
showProgress: false,
taskProgress: <String, DownloadInfo>{},
),
) {
_downloadService.onImageDownloadStatus = _downloadImageCallback;
_downloadService.onVideoDownloadStatus = _downloadVideoCallback;
_downloadService.onLivePhotoDownloadStatus = _downloadLivePhotoCallback;
_downloadService.onTaskProgress = _taskProgressCallback;
}
void _updateDownloadStatus(String taskId, TaskStatus status) {
if (status == TaskStatus.canceled) {
return;
}
state = state.copyWith(
taskProgress: <String, DownloadInfo>{}
..addAll(state.taskProgress)
..addAll({
taskId: DownloadInfo(
progress: state.taskProgress[taskId]?.progress ?? 0,
fileName: state.taskProgress[taskId]?.fileName ?? '',
status: status,
),
}),
);
}
// Download live photo callback
void _downloadLivePhotoCallback(TaskStatusUpdate update) {
_updateDownloadStatus(update.task.taskId, update.status);
switch (update.status) {
case TaskStatus.complete:
if (update.task.metaData.isEmpty) {
return;
}
final livePhotosId =
LivePhotosMetadata.fromJson(update.task.metaData).id;
_downloadService.saveLivePhotos(update.task, livePhotosId);
_onDownloadComplete(update.task.taskId);
break;
default:
break;
}
}
// Download image callback
void _downloadImageCallback(TaskStatusUpdate update) {
_updateDownloadStatus(update.task.taskId, update.status);
switch (update.status) {
case TaskStatus.complete:
_downloadService.saveImageWithPath(update.task);
_onDownloadComplete(update.task.taskId);
break;
default:
break;
}
}
// Download video callback
void _downloadVideoCallback(TaskStatusUpdate update) {
_updateDownloadStatus(update.task.taskId, update.status);
switch (update.status) {
case TaskStatus.complete:
_downloadService.saveVideo(update.task);
_onDownloadComplete(update.task.taskId);
break;
default:
break;
}
}
void _taskProgressCallback(TaskProgressUpdate update) {
// Ignore if the task is cancled or completed
if (update.progress == -2 || update.progress == -1) {
return;
}
state = state.copyWith(
showProgress: true,
taskProgress: <String, DownloadInfo>{}
..addAll(state.taskProgress)
..addAll({
update.task.taskId: DownloadInfo(
progress: update.progress,
fileName: update.task.filename,
status: TaskStatus.running,
),
}),
);
}
void _onDownloadComplete(String id) {
Future.delayed(const Duration(seconds: 2), () {
state = state.copyWith(
taskProgress: <String, DownloadInfo>{}
..addAll(state.taskProgress)
..remove(id),
);
if (state.taskProgress.isEmpty) {
state = state.copyWith(
showProgress: false,
);
}
_albumService.refreshDeviceAlbums();
});
}
void downloadAsset(Asset asset, BuildContext context) async {
await _downloadService.download(asset);
}
void cancelDownload(String id) async {
final isCanceled = await _downloadService.cancelDownload(id);
if (isCanceled) {
state = state.copyWith(
taskProgress: <String, DownloadInfo>{}
..addAll(state.taskProgress)
..remove(id),
);
}
if (state.taskProgress.isEmpty) {
state = state.copyWith(
showProgress: false,
);
}
}
void shareAsset(Asset asset, BuildContext context) async {
showDialog(
context: context,
builder: (BuildContext buildContext) {
_shareService.shareAsset(asset, context).then(
(bool status) {
if (!status) {
ImmichToast.show(
context: context,
msg: 'image_viewer_page_state_provider_share_error'.tr(),
toastType: ToastType.error,
gravity: ToastGravity.BOTTOM,
);
}
buildContext.pop();
},
);
return const ShareDialog();
},
barrierDismissible: false,
);
}
}
final downloadStateProvider =
StateNotifierProvider<DownloadStateNotifier, DownloadState>(
((ref) => DownloadStateNotifier(
ref.watch(downloadServiceProvider),
ref.watch(shareServiceProvider),
ref.watch(albumServiceProvider),
)),
);
@@ -1,95 +0,0 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:fluttertoast/fluttertoast.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/services/album.service.dart';
import 'package:immich_mobile/models/asset_viewer/asset_viewer_page_state.model.dart';
import 'package:immich_mobile/services/image_viewer.service.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/services/share.service.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart';
import 'package:immich_mobile/widgets/common/share_dialog.dart';
class ImageViewerStateNotifier extends StateNotifier<AssetViewerPageState> {
final ImageViewerService _imageViewerService;
final ShareService _shareService;
final AlbumService _albumService;
ImageViewerStateNotifier(
this._imageViewerService,
this._shareService,
this._albumService,
) : super(
AssetViewerPageState(
downloadAssetStatus: DownloadAssetStatus.idle,
),
);
void downloadAsset(Asset asset, BuildContext context) async {
state = state.copyWith(downloadAssetStatus: DownloadAssetStatus.loading);
ImmichToast.show(
context: context,
msg: 'image_viewer_page_state_provider_download_started'.tr(),
toastType: ToastType.info,
gravity: ToastGravity.BOTTOM,
);
bool isSuccess = await _imageViewerService.downloadAssetToDevice(asset);
if (isSuccess) {
state = state.copyWith(downloadAssetStatus: DownloadAssetStatus.success);
ImmichToast.show(
context: context,
msg: 'image_viewer_page_state_provider_download_success'.tr(),
toastType: ToastType.success,
gravity: ToastGravity.BOTTOM,
);
_albumService.refreshDeviceAlbums();
} else {
state = state.copyWith(downloadAssetStatus: DownloadAssetStatus.error);
ImmichToast.show(
context: context,
msg: 'image_viewer_page_state_provider_download_error'.tr(),
toastType: ToastType.error,
gravity: ToastGravity.BOTTOM,
);
}
state = state.copyWith(downloadAssetStatus: DownloadAssetStatus.idle);
}
void shareAsset(Asset asset, BuildContext context) async {
showDialog(
context: context,
builder: (BuildContext buildContext) {
_shareService.shareAsset(asset, context).then(
(bool status) {
if (!status) {
ImmichToast.show(
context: context,
msg: 'image_viewer_page_state_provider_share_error'.tr(),
toastType: ToastType.error,
gravity: ToastGravity.BOTTOM,
);
}
buildContext.pop();
},
);
return const ShareDialog();
},
barrierDismissible: false,
);
}
}
final imageViewerStateProvider =
StateNotifierProvider<ImageViewerStateNotifier, AssetViewerPageState>(
((ref) => ImageViewerStateNotifier(
ref.watch(imageViewerServiceProvider),
ref.watch(shareServiceProvider),
ref.watch(albumServiceProvider),
)),
);
@@ -5,7 +5,6 @@ import 'package:flutter/material.dart';
import 'package:flutter_udid/flutter_udid.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/providers/album/album.provider.dart';
import 'package:immich_mobile/providers/album/shared_album.provider.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/models/authentication/authentication_state.model.dart';
import 'package:immich_mobile/entities/user.entity.dart';
@@ -115,7 +114,6 @@ class AuthenticationNotifier extends StateNotifier<AuthenticationState> {
Store.delete(StoreKey.accessToken),
]);
_ref.invalidate(albumProvider);
_ref.invalidate(sharedAlbumProvider);
state = state.copyWith(
deviceId: "",
@@ -170,8 +168,10 @@ class AuthenticationNotifier extends StateNotifier<AuthenticationState> {
UserPreferencesResponseDto? userPreferences;
try {
final responses = await Future.wait([
_apiService.usersApi.getMyUser(),
_apiService.usersApi.getMyPreferences(),
_apiService.usersApi.getMyUser().timeout(const Duration(seconds: 7)),
_apiService.usersApi
.getMyPreferences()
.timeout(const Duration(seconds: 7)),
]);
userResponse = responses[0] as UserAdminResponseDto;
userPreferences = responses[1] as UserPreferencesResponseDto;
@@ -190,6 +190,9 @@ class AuthenticationNotifier extends StateNotifier<AuthenticationState> {
error,
stackTrace,
);
debugPrint(
"Error getting user information from the server [CATCH ALL] $error $stackTrace",
);
}
// If the user information is successfully retrieved, update the store
+117 -55
View File
@@ -2,14 +2,24 @@ import 'dart:io';
import 'package:cancellation_token_http/http.dart';
import 'package:collection/collection.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/entities/album.entity.dart';
import 'package:immich_mobile/interfaces/album_media.interface.dart';
import 'package:immich_mobile/interfaces/backup.interface.dart';
import 'package:immich_mobile/interfaces/file_media.interface.dart';
import 'package:immich_mobile/models/backup/available_album.model.dart';
import 'package:immich_mobile/entities/backup_album.entity.dart';
import 'package:immich_mobile/models/backup/backup_candidate.model.dart';
import 'package:immich_mobile/models/backup/backup_state.model.dart';
import 'package:immich_mobile/models/backup/current_upload_asset.model.dart';
import 'package:immich_mobile/models/backup/error_upload_asset.model.dart';
import 'package:immich_mobile/models/backup/success_upload_asset.model.dart';
import 'package:immich_mobile/providers/backup/error_backup_list.provider.dart';
import 'package:immich_mobile/repositories/album_media.repository.dart';
import 'package:immich_mobile/repositories/backup.repository.dart';
import 'package:immich_mobile/repositories/file_media.repository.dart';
import 'package:immich_mobile/services/background.service.dart';
import 'package:immich_mobile/services/backup.service.dart';
import 'package:immich_mobile/models/authentication/authentication_state.model.dart';
@@ -25,7 +35,7 @@ import 'package:immich_mobile/utils/diff.dart';
import 'package:isar/isar.dart';
import 'package:logging/logging.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:photo_manager/photo_manager.dart';
import 'package:photo_manager/photo_manager.dart' show PMProgressHandler;
class BackupNotifier extends StateNotifier<BackUpState> {
BackupNotifier(
@@ -35,6 +45,9 @@ class BackupNotifier extends StateNotifier<BackUpState> {
this._backgroundService,
this._galleryPermissionNotifier,
this._db,
this._albumMediaRepository,
this._fileMediaRepository,
this._backupRepository,
this.ref,
) : super(
BackUpState(
@@ -83,6 +96,9 @@ class BackupNotifier extends StateNotifier<BackUpState> {
final BackgroundService _backgroundService;
final GalleryPermissionNotifier _galleryPermissionNotifier;
final Isar _db;
final IAlbumMediaRepository _albumMediaRepository;
final IFileMediaRepository _fileMediaRepository;
final IBackupRepository _backupRepository;
final Ref ref;
///
@@ -221,38 +237,44 @@ class BackupNotifier extends StateNotifier<BackUpState> {
Stopwatch stopwatch = Stopwatch()..start();
// Get all albums on the device
List<AvailableAlbum> availableAlbums = [];
List<AssetPathEntity> albums = await PhotoManager.getAssetPathList(
hasAll: true,
type: RequestType.common,
);
List<Album> albums = await _albumMediaRepository.getAll();
// Map of id -> album for quick album lookup later on.
Map<String, AssetPathEntity> albumMap = {};
Map<String, Album> albumMap = {};
log.info('Found ${albums.length} local albums');
for (AssetPathEntity album in albums) {
AvailableAlbum availableAlbum = AvailableAlbum(albumEntity: album);
for (Album album in albums) {
AvailableAlbum availableAlbum = AvailableAlbum(
album: album,
assetCount: await ref
.read(albumMediaRepositoryProvider)
.getAssetCount(album.localId!),
);
availableAlbums.add(availableAlbum);
albumMap[album.id] = album;
albumMap[album.localId!] = album;
}
state = state.copyWith(availableAlbums: availableAlbums);
final List<BackupAlbum> excludedBackupAlbums =
await _backupService.excludedAlbumsQuery().findAll();
await _backupRepository.getAllBySelection(BackupSelection.exclude);
final List<BackupAlbum> selectedBackupAlbums =
await _backupService.selectedAlbumsQuery().findAll();
await _backupRepository.getAllBySelection(BackupSelection.select);
// Generate AssetPathEntity from id to add to local state
final Set<AvailableAlbum> selectedAlbums = {};
for (final BackupAlbum ba in selectedBackupAlbums) {
final albumAsset = albumMap[ba.id];
if (albumAsset != null) {
selectedAlbums.add(
AvailableAlbum(albumEntity: albumAsset, lastBackup: ba.lastBackup),
AvailableAlbum(
album: albumAsset,
assetCount:
await _albumMediaRepository.getAssetCount(albumAsset.localId!),
lastBackup: ba.lastBackup,
),
);
} else {
log.severe('Selected album not found');
@@ -265,7 +287,13 @@ class BackupNotifier extends StateNotifier<BackUpState> {
if (albumAsset != null) {
excludedAlbums.add(
AvailableAlbum(albumEntity: albumAsset, lastBackup: ba.lastBackup),
AvailableAlbum(
album: albumAsset,
assetCount: await ref
.read(albumMediaRepositoryProvider)
.getAssetCount(albumAsset.localId!),
lastBackup: ba.lastBackup,
),
);
} else {
log.severe('Excluded album not found');
@@ -289,40 +317,71 @@ class BackupNotifier extends StateNotifier<BackUpState> {
/// Those assets are unique and are used as the total assets
///
Future<void> _updateBackupAssetCount() async {
// Save to persistent storage
await _updatePersistentAlbumsSelection();
final duplicatedAssetIds = await _backupService.getDuplicatedAssetIds();
final Set<AssetEntity> assetsFromSelectedAlbums = {};
final Set<AssetEntity> assetsFromExcludedAlbums = {};
final Set<BackupCandidate> assetsFromSelectedAlbums = {};
final Set<BackupCandidate> assetsFromExcludedAlbums = {};
for (final album in state.selectedBackupAlbums) {
final assetCount = await album.albumEntity.assetCountAsync;
final assetCount = await ref
.read(albumMediaRepositoryProvider)
.getAssetCount(album.album.localId!);
if (assetCount == 0) {
continue;
}
final assets = await album.albumEntity.getAssetListRange(
start: 0,
end: assetCount,
);
assetsFromSelectedAlbums.addAll(assets);
final assets = await ref
.read(albumMediaRepositoryProvider)
.getAssets(album.album.localId!);
// Add album's name to the asset info
for (final asset in assets) {
List<String> albumNames = [album.name];
final existingAsset = assetsFromSelectedAlbums.firstWhereOrNull(
(a) => a.asset.localId == asset.localId,
);
if (existingAsset != null) {
albumNames.addAll(existingAsset.albumNames);
assetsFromSelectedAlbums.remove(existingAsset);
}
assetsFromSelectedAlbums.add(
BackupCandidate(
asset: asset,
albumNames: albumNames,
),
);
}
}
for (final album in state.excludedBackupAlbums) {
final assetCount = await album.albumEntity.assetCountAsync;
final assetCount = await ref
.read(albumMediaRepositoryProvider)
.getAssetCount(album.album.localId!);
if (assetCount == 0) {
continue;
}
final assets = await album.albumEntity.getAssetListRange(
start: 0,
end: assetCount,
);
assetsFromExcludedAlbums.addAll(assets);
final assets = await ref
.read(albumMediaRepositoryProvider)
.getAssets(album.album.localId!);
for (final asset in assets) {
assetsFromExcludedAlbums.add(
BackupCandidate(asset: asset, albumNames: [album.name]),
);
}
}
final Set<AssetEntity> allUniqueAssets =
final Set<BackupCandidate> allUniqueAssets =
assetsFromSelectedAlbums.difference(assetsFromExcludedAlbums);
final allAssetsInDatabase = await _backupService.getDeviceBackupAsset();
if (allAssetsInDatabase == null) {
@@ -331,14 +390,14 @@ class BackupNotifier extends StateNotifier<BackUpState> {
// Find asset that were backup from selected albums
final Set<String> selectedAlbumsBackupAssets =
Set.from(allUniqueAssets.map((e) => e.id));
Set.from(allUniqueAssets.map((e) => e.asset.localId));
selectedAlbumsBackupAssets
.removeWhere((assetId) => !allAssetsInDatabase.contains(assetId));
// Remove duplicated asset from all unique assets
allUniqueAssets.removeWhere(
(asset) => duplicatedAssetIds.contains(asset.id),
(candidate) => duplicatedAssetIds.contains(candidate.asset.localId),
);
if (allUniqueAssets.isEmpty) {
@@ -356,9 +415,6 @@ class BackupNotifier extends StateNotifier<BackUpState> {
selectedAlbumsBackupAssetsIds: selectedAlbumsBackupAssets,
);
}
// Save to persistent storage
await _updatePersistentAlbumsSelection();
}
/// Get all necessary information for calculating the available albums,
@@ -425,7 +481,7 @@ class BackupNotifier extends StateNotifier<BackUpState> {
final hasPermission = _galleryPermissionNotifier.hasPermission;
if (hasPermission) {
await PhotoManager.clearFileCache();
await _fileMediaRepository.clearFileCache();
if (state.allUniqueAssets.isEmpty) {
log.info("No Asset On Device - Abort Backup Process");
@@ -433,10 +489,10 @@ class BackupNotifier extends StateNotifier<BackUpState> {
return;
}
Set<AssetEntity> assetsWillBeBackup = Set.from(state.allUniqueAssets);
Set<BackupCandidate> assetsWillBeBackup = Set.from(state.allUniqueAssets);
// Remove item that has already been backed up
for (final assetId in state.allAssetsInDatabase) {
assetsWillBeBackup.removeWhere((e) => e.id == assetId);
assetsWillBeBackup.removeWhere((e) => e.asset.localId == assetId);
}
if (assetsWillBeBackup.isEmpty) {
@@ -456,11 +512,11 @@ class BackupNotifier extends StateNotifier<BackUpState> {
await _backupService.backupAsset(
assetsWillBeBackup,
state.cancelToken,
pmProgressHandler,
_onAssetUploaded,
_onUploadProgress,
_onSetCurrentBackupAsset,
_onBackupError,
pmProgressHandler: pmProgressHandler,
onSuccess: _onAssetUploaded,
onProgress: _onUploadProgress,
onCurrentAsset: _onSetCurrentBackupAsset,
onError: _onBackupError,
);
await notifyBackgroundServiceCanRun();
} else {
@@ -497,34 +553,37 @@ class BackupNotifier extends StateNotifier<BackUpState> {
);
}
void _onAssetUploaded(
String deviceAssetId,
String deviceId,
bool isDuplicated,
) {
if (isDuplicated) {
void _onAssetUploaded(SuccessUploadAsset result) async {
if (result.isDuplicate) {
state = state.copyWith(
allUniqueAssets: state.allUniqueAssets
.where((asset) => asset.id != deviceAssetId)
.where(
(candidate) =>
candidate.asset.localId != result.candidate.asset.localId,
)
.toSet(),
);
} else {
state = state.copyWith(
selectedAlbumsBackupAssetsIds: {
...state.selectedAlbumsBackupAssetsIds,
deviceAssetId,
result.candidate.asset.localId!,
},
allAssetsInDatabase: [...state.allAssetsInDatabase, deviceAssetId],
allAssetsInDatabase: [
...state.allAssetsInDatabase,
result.candidate.asset.localId!,
],
);
}
if (state.allUniqueAssets.length -
state.selectedAlbumsBackupAssetsIds.length ==
0) {
final latestAssetBackup =
state.allUniqueAssets.map((e) => e.modifiedDateTime).reduce(
(v, e) => e.isAfter(v) ? e : v,
);
final latestAssetBackup = state.allUniqueAssets
.map((candidate) => candidate.asset.fileModifiedAt)
.reduce(
(v, e) => e.isAfter(v) ? e : v,
);
state = state.copyWith(
selectedBackupAlbums: state.selectedBackupAlbums
.map((e) => e.copyWith(lastBackup: latestAssetBackup))
@@ -710,6 +769,9 @@ final backupProvider =
ref.watch(backgroundServiceProvider),
ref.watch(galleryPermissionNotifier.notifier),
ref.watch(dbProvider),
ref.watch(albumMediaRepositoryProvider),
ref.watch(fileMediaRepositoryProvider),
ref.watch(backupRepositoryProvider),
ref,
);
});
@@ -35,7 +35,7 @@ class BackupVerification extends _$BackupVerification {
return;
}
final connection = await Connectivity().checkConnectivity();
if (connection != ConnectivityResult.wifi) {
if (connection.contains(ConnectivityResult.wifi)) {
if (context.mounted) {
ImmichToast.show(
context: context,
@@ -7,7 +7,7 @@ part of 'backup_verification.provider.dart';
// **************************************************************************
String _$backupVerificationHash() =>
r'b691e0cc27856eef189258d3c102cc73ce4812a4';
r'021dfdf65e1903c932e4a1c14967b786dd3516fb';
/// See also [BackupVerification].
@ProviderFor(BackupVerification)

Some files were not shown because too many files have changed in this diff Show More