Compare commits

..

5 Commits

Author SHA1 Message Date
timonrieger f15a595759 lint 2026-06-11 17:16:50 +02:00
timonrieger c5110c02fd feat(mobile): consolidate asset viewer action buttons
---------

Co-authored-by: idubnori <i.dub.nori@gmail.com>
2026-06-11 16:26:16 +02:00
renovate[bot] 0fb18ed241 chore(deps): update dependency commander to v15 (#28936) 2026-06-11 12:18:25 +02:00
renovate[bot] c0b3b08ce6 chore(deps): update exiftool to v35.21.0 (#28933) 2026-06-11 12:16:13 +02:00
Mees Frensel e8a1084e5b fix(web): heatmap layout and date formatting (#28976)
* fix(web): heatmap layout and date formatting

* chore

---------

Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
2026-06-11 08:36:34 +00:00
41 changed files with 909 additions and 15066 deletions
-1
View File
@@ -1707,7 +1707,6 @@
"not_available": "N/A",
"not_in_any_album": "Not in any album",
"not_selected": "Not selected",
"not_set": "Not set",
"notes": "Notes",
"nothing_here_yet": "Nothing here yet",
"notification_backup_reliability": "Enable notifications to improve background backup reliability",
-3
View File
@@ -82,9 +82,6 @@ url_api = "https://api.github.com/repos/extism/js-pdk/releases/assets/353224133"
version = "7.1.3-6"
backend = "github:jellyfin/jellyfin-ffmpeg"
[tools."github:jellyfin/jellyfin-ffmpeg".options]
asset_pattern = "jellyfin-ffmpeg_*_portable_linuxarm64-gpl.tar.xz"
[[tools."github:webassembly/binaryen"]]
version = "version_124"
backend = "github:webassembly/binaryen"
File diff suppressed because it is too large Load Diff
+2
View File
@@ -15,6 +15,8 @@ enum ActionSource { timeline, viewer }
enum ShareAssetType { original, preview }
enum ButtonPosition { bottomBar, kebabMenu }
enum CleanupStep { selectDate, scan, delete }
enum AssetKeepType { none, photosOnly, videosOnly }
@@ -101,7 +101,7 @@ class AppConfig {
String toString() =>
'AppConfig(logLevel: $logLevel, theme: $theme, cleanup: $cleanup, map: $map, timeline: $timeline, image: $image, viewer: $viewer, slideshow: $slideshow, album: $album, backup: $backup, network: $network, share: $share)';
T read<T>(SettingsKey<T> key) =>
T read<T extends Object>(SettingsKey<T> key) =>
(switch (key) {
.logLevel => logLevel,
.themePrimaryColor => theme.primaryColor,
@@ -136,8 +136,6 @@ class AppConfig {
.mapIncludeArchived => map.includeArchived,
.mapThemeMode => map.themeMode,
.mapWithPartners => map.withPartners,
.mapCustomFrom => map.customFrom,
.mapCustomTo => map.customTo,
.cleanupKeepFavorites => cleanup.keepFavorites,
.cleanupKeepMediaType => cleanup.keepMediaType,
.cleanupKeepAlbumIds => cleanup.keepAlbumIds,
@@ -152,10 +150,10 @@ class AppConfig {
})
as T;
factory AppConfig.fromEntries(Map<SettingsKey, Object?> overrides) =>
factory AppConfig.fromEntries(Map<SettingsKey<Object>, Object> overrides) =>
overrides.entries.fold(const AppConfig(), (config, entry) => config.write(entry.key, entry.value));
AppConfig write<T, U extends T>(SettingsKey<T> key, U value) {
AppConfig write<T extends Object>(SettingsKey<T> key, T value) {
return switch (key) {
.logLevel => copyWith(logLevel: value as LogLevel),
.themePrimaryColor => copyWith(theme: theme.copyWith(primaryColor: value as ImmichColorPreset)),
@@ -169,10 +167,8 @@ class AppConfig {
.viewerAutoPlayVideo => copyWith(viewer: viewer.copyWith(autoPlayVideo: value as bool)),
.viewerTapToNavigate => copyWith(viewer: viewer.copyWith(tapToNavigate: value as bool)),
.networkAutoEndpointSwitching => copyWith(network: network.copyWith(autoEndpointSwitching: value as bool)),
.networkPreferredWifiName => copyWith(
network: network.copyWith(preferredWifiName: .fromNullable((value as String?))),
),
.networkLocalEndpoint => copyWith(network: network.copyWith(localEndpoint: .fromNullable((value as String?)))),
.networkPreferredWifiName => copyWith(network: network.copyWith(preferredWifiName: (value as String))),
.networkLocalEndpoint => copyWith(network: network.copyWith(localEndpoint: (value as String))),
.networkExternalEndpointList => copyWith(network: network.copyWith(externalEndpointList: value as List<String>)),
.networkCustomHeaders => copyWith(network: network.copyWith(customHeaders: value as Map<String, String>)),
.albumSortMode => copyWith(album: album.copyWith(sortMode: value as AlbumSortMode)),
@@ -192,8 +188,6 @@ class AppConfig {
.mapIncludeArchived => copyWith(map: map.copyWith(includeArchived: value as bool)),
.mapThemeMode => copyWith(map: map.copyWith(themeMode: value as ThemeMode)),
.mapWithPartners => copyWith(map: map.copyWith(withPartners: value as bool)),
.mapCustomFrom => copyWith(map: map.copyWith(customFrom: .fromNullable(value as DateTime?))),
.mapCustomTo => copyWith(map: map.copyWith(customTo: .fromNullable(value as DateTime?))),
.cleanupKeepFavorites => copyWith(cleanup: cleanup.copyWith(keepFavorites: value as bool)),
.cleanupKeepMediaType => copyWith(cleanup: cleanup.copyWith(keepMediaType: value as AssetKeepType)),
.cleanupKeepAlbumIds => copyWith(cleanup: cleanup.copyWith(keepAlbumIds: value as List<String>)),
@@ -1,5 +1,4 @@
import 'package:flutter/material.dart';
import 'package:immich_mobile/utils/option.dart';
class MapConfig {
final int relativeDays;
@@ -7,8 +6,6 @@ class MapConfig {
final bool includeArchived;
final ThemeMode themeMode;
final bool withPartners;
final DateTime? customFrom;
final DateTime? customTo;
const MapConfig({
this.relativeDays = 0,
@@ -16,8 +13,6 @@ class MapConfig {
this.includeArchived = false,
this.themeMode = ThemeMode.system,
this.withPartners = false,
this.customFrom,
this.customTo,
});
MapConfig copyWith({
@@ -26,16 +21,12 @@ class MapConfig {
bool? includeArchived,
ThemeMode? themeMode,
bool? withPartners,
Option<DateTime>? customFrom,
Option<DateTime>? customTo,
}) => MapConfig(
relativeDays: relativeDays ?? this.relativeDays,
favoritesOnly: favoritesOnly ?? this.favoritesOnly,
includeArchived: includeArchived ?? this.includeArchived,
themeMode: themeMode ?? this.themeMode,
withPartners: withPartners ?? this.withPartners,
customFrom: customFrom.patch(this.customFrom),
customTo: customTo.patch(this.customTo),
);
@override
@@ -46,15 +37,12 @@ class MapConfig {
other.favoritesOnly == favoritesOnly &&
other.includeArchived == includeArchived &&
other.themeMode == themeMode &&
other.withPartners == withPartners &&
other.customFrom == customFrom &&
other.customTo == customTo);
other.withPartners == withPartners);
@override
int get hashCode =>
Object.hash(relativeDays, favoritesOnly, includeArchived, themeMode, withPartners, customFrom, customTo);
int get hashCode => Object.hash(relativeDays, favoritesOnly, includeArchived, themeMode, withPartners);
@override
String toString() =>
'MapConfig(relativeDays: $relativeDays, favoritesOnly: $favoritesOnly, includeArchived: $includeArchived, themeMode: $themeMode, withPartners: $withPartners, customFrom: $customFrom, customTo: $customTo)';
'MapConfig(relativeDays: $relativeDays, favoritesOnly: $favoritesOnly, includeArchived: $includeArchived, themeMode: $themeMode, withPartners: $withPartners)';
}
@@ -1,31 +1,30 @@
import 'package:flutter/foundation.dart';
import 'package:immich_mobile/utils/option.dart';
class NetworkConfig {
final bool autoEndpointSwitching;
final String? preferredWifiName;
final String? localEndpoint;
final String preferredWifiName;
final String localEndpoint;
final List<String> externalEndpointList;
final Map<String, String> customHeaders;
const NetworkConfig({
this.autoEndpointSwitching = false,
this.preferredWifiName,
this.localEndpoint,
this.preferredWifiName = '',
this.localEndpoint = '',
this.externalEndpointList = const [],
this.customHeaders = const {},
});
NetworkConfig copyWith({
bool? autoEndpointSwitching,
Option<String>? preferredWifiName,
Option<String>? localEndpoint,
String? preferredWifiName,
String? localEndpoint,
List<String>? externalEndpointList,
Map<String, String>? customHeaders,
}) => NetworkConfig(
autoEndpointSwitching: autoEndpointSwitching ?? this.autoEndpointSwitching,
preferredWifiName: preferredWifiName.patch(this.preferredWifiName),
localEndpoint: localEndpoint.patch(this.localEndpoint),
preferredWifiName: preferredWifiName ?? this.preferredWifiName,
localEndpoint: localEndpoint ?? this.localEndpoint,
externalEndpointList: externalEndpointList ?? this.externalEndpointList,
customHeaders: customHeaders ?? this.customHeaders,
);
+153 -22
View File
@@ -1,15 +1,16 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:immich_mobile/constants/colors.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/log.model.dart';
import 'package:immich_mobile/domain/models/timeline.model.dart';
import 'package:immich_mobile/domain/models/value_codec.dart';
import 'package:immich_mobile/providers/album/album_sort_by_options.provider.dart';
enum SettingsKey<T> {
enum SettingsKey<T extends Object> {
// Theme
themePrimaryColor<ImmichColorPreset>(codec: EnumCodec(ImmichColorPreset.values)),
themeMode<ThemeMode>(codec: EnumCodec(ThemeMode.values)),
themePrimaryColor<ImmichColorPreset>(codec: _EnumCodec(ImmichColorPreset.values)),
themeMode<ThemeMode>(codec: _EnumCodec(ThemeMode.values)),
themeDynamic<bool>(),
themeColorfulInterface<bool>(),
@@ -25,13 +26,13 @@ enum SettingsKey<T> {
// Network
networkAutoEndpointSwitching<bool>(),
networkExternalEndpointList<List<String>>(codec: ListCodec(PrimitiveCodec.string)),
networkCustomHeaders<Map<String, String>>(codec: MapCodec(PrimitiveCodec.string, PrimitiveCodec.string)),
networkPreferredWifiName<String?>(),
networkLocalEndpoint<String?>(),
networkPreferredWifiName<String>(),
networkLocalEndpoint<String>(),
networkExternalEndpointList<List<String>>(codec: _ListCodec(_PrimitiveCodec.string)),
networkCustomHeaders<Map<String, String>>(codec: _MapCodec(_PrimitiveCodec.string, _PrimitiveCodec.string)),
// Album
albumSortMode<AlbumSortMode>(codec: EnumCodec(AlbumSortMode.values)),
albumSortMode<AlbumSortMode>(codec: _EnumCodec(AlbumSortMode.values)),
albumIsReverse<bool>(),
albumIsGrid<bool>(),
@@ -45,45 +46,175 @@ enum SettingsKey<T> {
// Timeline
timelineTilesPerRow<int>(),
timelineGroupAssetsBy<GroupAssetsBy>(codec: EnumCodec(GroupAssetsBy.values)),
timelineGroupAssetsBy<GroupAssetsBy>(codec: _EnumCodec(GroupAssetsBy.values)),
timelineStorageIndicator<bool>(),
// Log
logLevel<LogLevel>(codec: EnumCodec(LogLevel.values)),
logLevel<LogLevel>(codec: _EnumCodec(LogLevel.values)),
// Map
mapShowFavoriteOnly<bool>(),
mapRelativeDate<int>(),
mapIncludeArchived<bool>(),
mapThemeMode<ThemeMode>(codec: EnumCodec(ThemeMode.values)),
mapThemeMode<ThemeMode>(codec: _EnumCodec(ThemeMode.values)),
mapWithPartners<bool>(),
mapCustomFrom<DateTime?>(),
mapCustomTo<DateTime?>(),
// Cleanup
cleanupKeepFavorites<bool>(),
cleanupKeepMediaType<AssetKeepType>(codec: EnumCodec(AssetKeepType.values)),
cleanupKeepAlbumIds<List<String>>(codec: ListCodec(PrimitiveCodec.string)),
cleanupKeepMediaType<AssetKeepType>(codec: _EnumCodec(AssetKeepType.values)),
cleanupKeepAlbumIds<List<String>>(codec: _ListCodec(_PrimitiveCodec.string)),
cleanupCutoffDaysAgo<int>(),
cleanupDefaultsInitialized<bool>(),
// Share
shareFileType<ShareAssetType>(codec: EnumCodec(ShareAssetType.values)),
shareFileType<ShareAssetType>(codec: _EnumCodec(ShareAssetType.values)),
// Slideshow
slideshowTransition<bool>(),
slideshowRepeat<bool>(),
slideshowDuration<int>(),
slideshowLook<SlideshowLook>(codec: EnumCodec(SlideshowLook.values)),
slideshowDirection<SlideshowDirection>(codec: EnumCodec(SlideshowDirection.values));
slideshowLook<SlideshowLook>(codec: _EnumCodec(SlideshowLook.values)),
slideshowDirection<SlideshowDirection>(codec: _EnumCodec(SlideshowDirection.values));
final ValueCodec<T>? _codecOverride;
final _SettingsCodec<T>? _codecOverride;
const SettingsKey({ValueCodec<T>? codec}) : _codecOverride = codec;
const SettingsKey({_SettingsCodec<T>? codec}) : _codecOverride = codec;
ValueCodec<T> get _codec => _codecOverride ?? ValueCodec.forType(T);
_SettingsCodec<T> get _codec => _codecOverride ?? _SettingsCodec.forType(T);
String encode(T value) => _codec.encode(value);
T decode(String raw) => _codec.decode(raw);
}
sealed class _SettingsCodec<T extends Object> {
const _SettingsCodec();
String encode(T value);
T decode(String raw);
static const Map<Type, _SettingsCodec<Object>> _primitives = {
int: _PrimitiveCodec.integer,
double: _PrimitiveCodec.real,
bool: _PrimitiveCodec.boolean,
String: _PrimitiveCodec.string,
DateTime: _DateTimeCodec(),
};
static _SettingsCodec<T> forType<T extends Object>(Type runtimeType) {
final codec = _primitives[runtimeType];
if (codec == null) {
throw StateError('No primitive codec for $runtimeType. Provide an explicit codec when defining the SettingsKey.');
}
return codec as _SettingsCodec<T>;
}
}
final class _EnumCodec<T extends Enum> extends _SettingsCodec<T> {
final List<T> values;
const _EnumCodec(this.values);
@override
String encode(T value) => value.name;
@override
T decode(String raw) => values.firstWhere((v) => v.name == raw);
}
final class _DateTimeCodec extends _SettingsCodec<DateTime> {
const _DateTimeCodec();
@override
String encode(DateTime value) => value.toIso8601String();
@override
DateTime decode(String raw) => DateTime.parse(raw);
}
final class _MapCodec<K extends Object, V extends Object> extends _SettingsCodec<Map<K, V>> {
final _SettingsCodec<K> _keyCodec;
final _SettingsCodec<V> _valueCodec;
const _MapCodec(this._keyCodec, this._valueCodec);
@override
String encode(Map<K, V> value) {
final entries = <String, String>{};
value.forEach((k, v) => entries[_keyCodec.encode(k)] = _valueCodec.encode(v));
return jsonEncode(entries);
}
@override
Map<K, V> decode(String raw) {
try {
final decoded = jsonDecode(raw);
if (decoded is! Map) {
return {};
}
final result = <K, V>{};
for (final entry in decoded.entries) {
final rawKey = entry.key;
final rawValue = entry.value;
if (rawKey is! String || rawValue is! String) {
return {};
}
final k = _keyCodec.decode(rawKey);
final v = _valueCodec.decode(rawValue);
result[k] = v;
}
return result;
} on FormatException {
return {};
}
}
}
final class _ListCodec<T extends Object> extends _SettingsCodec<List<T>> {
final _SettingsCodec<T> _elementCodec;
const _ListCodec(this._elementCodec);
@override
String encode(List<T> value) => jsonEncode(value.map(_elementCodec.encode).toList());
@override
List<T> decode(String raw) {
try {
final decoded = jsonDecode(raw);
if (decoded is! List) {
return [];
}
final result = <T>[];
for (final item in decoded) {
if (item is! String) {
return [];
}
final element = _elementCodec.decode(item);
result.add(element);
}
return result;
} on FormatException {
return [];
}
}
}
final class _PrimitiveCodec<T extends Object> extends _SettingsCodec<T> {
final T Function(String) _parse;
const _PrimitiveCodec._(this._parse);
@override
String encode(T value) => value.toString();
@override
T decode(String raw) => _parse(raw);
static const integer = _PrimitiveCodec<int>._(int.parse);
static const real = _PrimitiveCodec<double>._(double.parse);
static const boolean = _PrimitiveCodec<bool>._(bool.parse);
static const string = _PrimitiveCodec<String>._(_identity);
static String _identity(String s) => s;
}
@@ -1,12 +0,0 @@
import 'package:immich_mobile/utils/option.dart';
class TimeRange {
final DateTime? from;
final DateTime? to;
const TimeRange({this.from, this.to});
TimeRange copyWith({Option<DateTime>? from, Option<DateTime>? to}) {
return TimeRange(from: from.patch(this.from), to: to.patch(this.to));
}
}
-141
View File
@@ -1,141 +0,0 @@
import 'dart:convert';
sealed class ValueCodec<T> {
const ValueCodec();
String encode(T value);
T decode(String raw);
static final Map<Type, ValueCodec<Object>> _primitives = {
..._register<int>(PrimitiveCodec.integer),
..._register<double>(PrimitiveCodec.real),
..._register<bool>(PrimitiveCodec.boolean),
..._register<String>(PrimitiveCodec.string),
..._register<DateTime>(const DateTimeCodec()),
};
static Map<Type, ValueCodec<Object>> _register<T>(ValueCodec<Object> codec) => {
T: codec,
// Reifies the nullable type T so it can be used as a key in the _primitives map
_typeOf<T?>(): codec,
};
static Type _typeOf<T>() => T;
static ValueCodec<T> forType<T>(Type runtimeType) {
final codec = _primitives[runtimeType];
if (codec == null) {
throw StateError('No primitive codec for $runtimeType. Provide an explicit codec when defining the key.');
}
return codec as ValueCodec<T>;
}
}
final class EnumCodec<T extends Enum> extends ValueCodec<T> {
final List<T> values;
const EnumCodec(this.values);
@override
String encode(T value) => value.name;
@override
T decode(String raw) => values.firstWhere((v) => v.name == raw);
}
final class DateTimeCodec extends ValueCodec<DateTime> {
const DateTimeCodec();
@override
String encode(DateTime value) => value.toIso8601String();
@override
DateTime decode(String raw) => DateTime.parse(raw);
}
final class MapCodec<K extends Object, V extends Object> extends ValueCodec<Map<K, V>> {
final ValueCodec<K> _keyCodec;
final ValueCodec<V> _valueCodec;
const MapCodec(this._keyCodec, this._valueCodec);
@override
String encode(Map<K, V> value) {
final entries = <String, String>{};
value.forEach((k, v) => entries[_keyCodec.encode(k)] = _valueCodec.encode(v));
return jsonEncode(entries);
}
@override
Map<K, V> decode(String raw) {
try {
final decoded = jsonDecode(raw);
if (decoded is! Map) {
return {};
}
final result = <K, V>{};
for (final entry in decoded.entries) {
final rawKey = entry.key;
final rawValue = entry.value;
if (rawKey is! String || rawValue is! String) {
continue;
}
final k = _keyCodec.decode(rawKey);
final v = _valueCodec.decode(rawValue);
result[k] = v;
}
return result;
} on FormatException {
return {};
}
}
}
final class ListCodec<T extends Object> extends ValueCodec<List<T>> {
final ValueCodec<T> _elementCodec;
const ListCodec(this._elementCodec);
@override
String encode(List<T> value) => jsonEncode(value.map(_elementCodec.encode).toList());
@override
List<T> decode(String raw) {
try {
final decoded = jsonDecode(raw);
if (decoded is! List) {
return [];
}
final result = <T>[];
for (final item in decoded) {
if (item is! String) {
return [];
}
final element = _elementCodec.decode(item);
result.add(element);
}
return result;
} on FormatException {
return [];
}
}
}
final class PrimitiveCodec<T extends Object> extends ValueCodec<T> {
final T Function(String) _parse;
const PrimitiveCodec._(this._parse);
@override
String encode(T value) => value.toString();
@override
T decode(String raw) => _parse(raw);
static const integer = PrimitiveCodec<int>._(int.parse);
static const real = PrimitiveCodec<double>._(double.parse);
static const boolean = PrimitiveCodec<bool>._(bool.parse);
static const string = PrimitiveCodec<String>._(_identity);
static String _identity(String s) => s;
}
@@ -6,7 +6,7 @@ class SettingsEntity extends Table with DriftDefaultsMixin {
TextColumn get key => text()();
TextColumn get value => text().nullable()();
TextColumn get value => text()();
DateTimeColumn get updatedAt => dateTime().withDefault(currentDateAndTime)();
+21 -20
View File
@@ -10,13 +10,13 @@ import 'package:drift/src/runtime/query_builder/query_builder.dart' as i3;
typedef $$SettingsEntityTableCreateCompanionBuilder =
i1.SettingsEntityCompanion Function({
required String key,
i0.Value<String?> value,
required String value,
i0.Value<DateTime> updatedAt,
});
typedef $$SettingsEntityTableUpdateCompanionBuilder =
i1.SettingsEntityCompanion Function({
i0.Value<String> key,
i0.Value<String?> value,
i0.Value<String> value,
i0.Value<DateTime> updatedAt,
});
@@ -127,7 +127,7 @@ class $$SettingsEntityTableTableManager
updateCompanionCallback:
({
i0.Value<String> key = const i0.Value.absent(),
i0.Value<String?> value = const i0.Value.absent(),
i0.Value<String> value = const i0.Value.absent(),
i0.Value<DateTime> updatedAt = const i0.Value.absent(),
}) => i1.SettingsEntityCompanion(
key: key,
@@ -137,7 +137,7 @@ class $$SettingsEntityTableTableManager
createCompanionCallback:
({
required String key,
i0.Value<String?> value = const i0.Value.absent(),
required String value,
i0.Value<DateTime> updatedAt = const i0.Value.absent(),
}) => i1.SettingsEntityCompanion.insert(
key: key,
@@ -196,9 +196,9 @@ class $SettingsEntityTable extends i2.SettingsEntity
late final i0.GeneratedColumn<String> value = i0.GeneratedColumn<String>(
'value',
aliasedName,
true,
false,
type: i0.DriftSqlType.string,
requiredDuringInsert: false,
requiredDuringInsert: true,
);
static const i0.VerificationMeta _updatedAtMeta = const i0.VerificationMeta(
'updatedAt',
@@ -240,6 +240,8 @@ class $SettingsEntityTable extends i2.SettingsEntity
_valueMeta,
value.isAcceptableOrUnknown(data['value']!, _valueMeta),
);
} else if (isInserting) {
context.missing(_valueMeta);
}
if (data.containsKey('updated_at')) {
context.handle(
@@ -263,7 +265,7 @@ class $SettingsEntityTable extends i2.SettingsEntity
value: attachedDatabase.typeMapping.read(
i0.DriftSqlType.string,
data['${effectivePrefix}value'],
),
)!,
updatedAt: attachedDatabase.typeMapping.read(
i0.DriftSqlType.dateTime,
data['${effectivePrefix}updated_at'],
@@ -285,20 +287,18 @@ class $SettingsEntityTable extends i2.SettingsEntity
class SettingsEntityData extends i0.DataClass
implements i0.Insertable<i1.SettingsEntityData> {
final String key;
final String? value;
final String value;
final DateTime updatedAt;
const SettingsEntityData({
required this.key,
this.value,
required this.value,
required this.updatedAt,
});
@override
Map<String, i0.Expression> toColumns(bool nullToAbsent) {
final map = <String, i0.Expression>{};
map['key'] = i0.Variable<String>(key);
if (!nullToAbsent || value != null) {
map['value'] = i0.Variable<String>(value);
}
map['value'] = i0.Variable<String>(value);
map['updated_at'] = i0.Variable<DateTime>(updatedAt);
return map;
}
@@ -310,7 +310,7 @@ class SettingsEntityData extends i0.DataClass
serializer ??= i0.driftRuntimeOptions.defaultSerializer;
return SettingsEntityData(
key: serializer.fromJson<String>(json['key']),
value: serializer.fromJson<String?>(json['value']),
value: serializer.fromJson<String>(json['value']),
updatedAt: serializer.fromJson<DateTime>(json['updatedAt']),
);
}
@@ -319,18 +319,18 @@ class SettingsEntityData extends i0.DataClass
serializer ??= i0.driftRuntimeOptions.defaultSerializer;
return <String, dynamic>{
'key': serializer.toJson<String>(key),
'value': serializer.toJson<String?>(value),
'value': serializer.toJson<String>(value),
'updatedAt': serializer.toJson<DateTime>(updatedAt),
};
}
i1.SettingsEntityData copyWith({
String? key,
i0.Value<String?> value = const i0.Value.absent(),
String? value,
DateTime? updatedAt,
}) => i1.SettingsEntityData(
key: key ?? this.key,
value: value.present ? value.value : this.value,
value: value ?? this.value,
updatedAt: updatedAt ?? this.updatedAt,
);
SettingsEntityData copyWithCompanion(i1.SettingsEntityCompanion data) {
@@ -365,7 +365,7 @@ class SettingsEntityData extends i0.DataClass
class SettingsEntityCompanion
extends i0.UpdateCompanion<i1.SettingsEntityData> {
final i0.Value<String> key;
final i0.Value<String?> value;
final i0.Value<String> value;
final i0.Value<DateTime> updatedAt;
const SettingsEntityCompanion({
this.key = const i0.Value.absent(),
@@ -374,9 +374,10 @@ class SettingsEntityCompanion
});
SettingsEntityCompanion.insert({
required String key,
this.value = const i0.Value.absent(),
required String value,
this.updatedAt = const i0.Value.absent(),
}) : key = i0.Value(key);
}) : key = i0.Value(key),
value = i0.Value(value);
static i0.Insertable<i1.SettingsEntityData> custom({
i0.Expression<String>? key,
i0.Expression<String>? value,
@@ -391,7 +392,7 @@ class SettingsEntityCompanion
i1.SettingsEntityCompanion copyWith({
i0.Value<String>? key,
i0.Value<String?>? value,
i0.Value<String>? value,
i0.Value<DateTime>? updatedAt,
}) {
return i1.SettingsEntityCompanion(
@@ -1,41 +0,0 @@
import 'package:collection/collection.dart';
import 'package:drift/drift.dart';
// ignore: depend_on_referenced_packages
import 'package:meta/meta.dart';
abstract class CachedKeyValueRepository<K extends Enum, S> {
CachedKeyValueRepository(this._snapshot);
S _snapshot;
S get snapshot => _snapshot;
@protected
set snapshot(S value) => _snapshot = value;
List<K> get keys;
Object decodeValue(K key, String raw);
S buildSnapshot(Map<K, Object?> overrides);
Selectable<({String key, String? value})> selectable();
Future<void> refresh() async => _snapshot = _build(await selectable().get());
Stream<S> watchSnapshot() => selectable().watch().map((rows) => _snapshot = _build(rows));
S _build(List<({String key, String? value})> rows) => buildSnapshot(
rows.fold({}, (overrides, row) {
final key = keys.firstWhereOrNull((key) => key.name == row.key);
if (key == null) {
return overrides;
}
Object? decodedValue;
if (row.value != null) {
decodedValue = decodeValue(key, row.value!);
}
return {...overrides, key: decodedValue};
}),
);
}
@@ -120,7 +120,7 @@ class Drift extends $Drift {
}
@override
int get schemaVersion => 30;
int get schemaVersion => 29;
@override
MigrationStrategy get migration => MigrationStrategy(
@@ -308,9 +308,6 @@ class Drift extends $Drift {
await m.createTable(v29.assetOcrEntity);
await m.createIndex(v29.idxAssetOcrAssetId);
},
from29To30: (m, v30) async {
await m.alterTable(TableMigration(v30.settings));
},
),
);
@@ -15331,595 +15331,6 @@ i1.GeneratedColumn<String> _column_223(String aliasedName) =>
type: i1.DriftSqlType.string,
$customConstraints: 'NOT NULL',
);
final class Schema30 extends i0.VersionedSchema {
Schema30({required super.database}) : super(version: 30);
@override
late final List<i1.DatabaseSchemaEntity> entities = [
userEntity,
remoteAssetEntity,
stackEntity,
localAssetEntity,
remoteAlbumEntity,
localAlbumEntity,
localAlbumAssetEntity,
idxLocalAlbumAssetAlbumAsset,
idxLocalAssetChecksum,
idxLocalAssetCloudId,
idxLocalAssetCreatedAt,
idxStackPrimaryAssetId,
uQRemoteAssetsOwnerChecksum,
uQRemoteAssetsOwnerLibraryChecksum,
idxRemoteAssetChecksum,
idxRemoteAssetStackId,
idxRemoteAssetOwnerVisibilityDeletedCreated,
authUserEntity,
userMetadataEntity,
partnerEntity,
remoteExifEntity,
remoteAlbumAssetEntity,
remoteAlbumUserEntity,
remoteAssetCloudIdEntity,
memoryEntity,
memoryAssetEntity,
personEntity,
assetFaceEntity,
storeEntity,
trashedLocalAssetEntity,
assetEditEntity,
settings,
assetOcrEntity,
idxPartnerSharedWithId,
idxLatLng,
idxRemoteExifCity,
idxRemoteAlbumAssetAlbumAsset,
idxRemoteAssetCloudId,
idxPersonOwnerId,
idxAssetFacePersonId,
idxAssetFaceAssetId,
idxAssetFaceVisiblePerson,
idxTrashedLocalAssetChecksum,
idxTrashedLocalAssetAlbum,
idxAssetEditAssetId,
idxAssetOcrAssetId,
];
late final Shape33 userEntity = Shape33(
source: i0.VersionedTable(
entityName: 'user_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_107,
_column_108,
_column_109,
_column_110,
_column_111,
_column_112,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape50 remoteAssetEntity = Shape50(
source: i0.VersionedTable(
entityName: 'remote_asset_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_108,
_column_113,
_column_114,
_column_115,
_column_116,
_column_117,
_column_118,
_column_107,
_column_119,
_column_120,
_column_121,
_column_122,
_column_123,
_column_124,
_column_212,
_column_125,
_column_126,
_column_127,
_column_128,
_column_129,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape35 stackEntity = Shape35(
source: i0.VersionedTable(
entityName: 'stack_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_107,
_column_114,
_column_115,
_column_121,
_column_130,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape36 localAssetEntity = Shape36(
source: i0.VersionedTable(
entityName: 'local_asset_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_108,
_column_113,
_column_114,
_column_115,
_column_116,
_column_117,
_column_118,
_column_107,
_column_131,
_column_120,
_column_132,
_column_133,
_column_134,
_column_135,
_column_136,
_column_137,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape48 remoteAlbumEntity = Shape48(
source: i0.VersionedTable(
entityName: 'remote_album_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_107,
_column_108,
_column_138,
_column_114,
_column_115,
_column_139,
_column_140,
_column_141,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape38 localAlbumEntity = Shape38(
source: i0.VersionedTable(
entityName: 'local_album_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_107,
_column_108,
_column_115,
_column_142,
_column_143,
_column_144,
_column_145,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape39 localAlbumAssetEntity = Shape39(
source: i0.VersionedTable(
entityName: 'local_album_asset_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(asset_id, album_id)'],
columns: [_column_146, _column_147, _column_145],
attachedDatabase: database,
),
alias: null,
);
final i1.Index idxLocalAlbumAssetAlbumAsset = i1.Index(
'idx_local_album_asset_album_asset',
'CREATE INDEX IF NOT EXISTS idx_local_album_asset_album_asset ON local_album_asset_entity (album_id, asset_id)',
);
final i1.Index idxLocalAssetChecksum = i1.Index(
'idx_local_asset_checksum',
'CREATE INDEX IF NOT EXISTS idx_local_asset_checksum ON local_asset_entity (checksum)',
);
final i1.Index idxLocalAssetCloudId = i1.Index(
'idx_local_asset_cloud_id',
'CREATE INDEX IF NOT EXISTS idx_local_asset_cloud_id ON local_asset_entity (i_cloud_id)',
);
final i1.Index idxLocalAssetCreatedAt = i1.Index(
'idx_local_asset_created_at',
'CREATE INDEX IF NOT EXISTS idx_local_asset_created_at ON local_asset_entity (created_at)',
);
final i1.Index idxStackPrimaryAssetId = i1.Index(
'idx_stack_primary_asset_id',
'CREATE INDEX IF NOT EXISTS idx_stack_primary_asset_id ON stack_entity (primary_asset_id)',
);
final i1.Index uQRemoteAssetsOwnerChecksum = i1.Index(
'UQ_remote_assets_owner_checksum',
'CREATE UNIQUE INDEX IF NOT EXISTS UQ_remote_assets_owner_checksum ON remote_asset_entity (owner_id, checksum) WHERE(library_id IS NULL)',
);
final i1.Index uQRemoteAssetsOwnerLibraryChecksum = i1.Index(
'UQ_remote_assets_owner_library_checksum',
'CREATE UNIQUE INDEX IF NOT EXISTS UQ_remote_assets_owner_library_checksum ON remote_asset_entity (owner_id, library_id, checksum) WHERE(library_id IS NOT NULL)',
);
final i1.Index idxRemoteAssetChecksum = i1.Index(
'idx_remote_asset_checksum',
'CREATE INDEX IF NOT EXISTS idx_remote_asset_checksum ON remote_asset_entity (checksum)',
);
final i1.Index idxRemoteAssetStackId = i1.Index(
'idx_remote_asset_stack_id',
'CREATE INDEX IF NOT EXISTS idx_remote_asset_stack_id ON remote_asset_entity (stack_id)',
);
final i1.Index idxRemoteAssetOwnerVisibilityDeletedCreated = i1.Index(
'idx_remote_asset_owner_visibility_deleted_created',
'CREATE INDEX IF NOT EXISTS idx_remote_asset_owner_visibility_deleted_created ON remote_asset_entity (owner_id, visibility, deleted_at, created_at DESC)',
);
late final Shape40 authUserEntity = Shape40(
source: i0.VersionedTable(
entityName: 'auth_user_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_107,
_column_108,
_column_109,
_column_148,
_column_110,
_column_111,
_column_149,
_column_150,
_column_151,
_column_152,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape4 userMetadataEntity = Shape4(
source: i0.VersionedTable(
entityName: 'user_metadata_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(user_id, "key")'],
columns: [_column_153, _column_154, _column_155],
attachedDatabase: database,
),
alias: null,
);
late final Shape41 partnerEntity = Shape41(
source: i0.VersionedTable(
entityName: 'partner_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(shared_by_id, shared_with_id)'],
columns: [_column_156, _column_157, _column_158],
attachedDatabase: database,
),
alias: null,
);
late final Shape42 remoteExifEntity = Shape42(
source: i0.VersionedTable(
entityName: 'remote_exif_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(asset_id)'],
columns: [
_column_159,
_column_160,
_column_161,
_column_162,
_column_163,
_column_164,
_column_117,
_column_116,
_column_165,
_column_166,
_column_167,
_column_168,
_column_135,
_column_136,
_column_169,
_column_170,
_column_171,
_column_172,
_column_173,
_column_174,
_column_175,
_column_176,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape7 remoteAlbumAssetEntity = Shape7(
source: i0.VersionedTable(
entityName: 'remote_album_asset_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(asset_id, album_id)'],
columns: [_column_159, _column_177],
attachedDatabase: database,
),
alias: null,
);
late final Shape10 remoteAlbumUserEntity = Shape10(
source: i0.VersionedTable(
entityName: 'remote_album_user_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(album_id, user_id)'],
columns: [_column_177, _column_153, _column_178],
attachedDatabase: database,
),
alias: null,
);
late final Shape43 remoteAssetCloudIdEntity = Shape43(
source: i0.VersionedTable(
entityName: 'remote_asset_cloud_id_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(asset_id)'],
columns: [
_column_159,
_column_179,
_column_180,
_column_134,
_column_135,
_column_136,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape44 memoryEntity = Shape44(
source: i0.VersionedTable(
entityName: 'memory_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_107,
_column_114,
_column_115,
_column_124,
_column_121,
_column_113,
_column_181,
_column_182,
_column_183,
_column_184,
_column_185,
_column_186,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape12 memoryAssetEntity = Shape12(
source: i0.VersionedTable(
entityName: 'memory_asset_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(asset_id, memory_id)'],
columns: [_column_159, _column_187],
attachedDatabase: database,
),
alias: null,
);
late final Shape45 personEntity = Shape45(
source: i0.VersionedTable(
entityName: 'person_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_107,
_column_114,
_column_115,
_column_121,
_column_108,
_column_188,
_column_189,
_column_190,
_column_191,
_column_192,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape46 assetFaceEntity = Shape46(
source: i0.VersionedTable(
entityName: 'asset_face_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_107,
_column_159,
_column_193,
_column_194,
_column_195,
_column_196,
_column_197,
_column_198,
_column_199,
_column_200,
_column_201,
_column_124,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape18 storeEntity = Shape18(
source: i0.VersionedTable(
entityName: 'store_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [_column_202, _column_203, _column_204],
attachedDatabase: database,
),
alias: null,
);
late final Shape47 trashedLocalAssetEntity = Shape47(
source: i0.VersionedTable(
entityName: 'trashed_local_asset_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id, album_id)'],
columns: [
_column_108,
_column_113,
_column_114,
_column_115,
_column_116,
_column_117,
_column_118,
_column_107,
_column_205,
_column_131,
_column_120,
_column_132,
_column_206,
_column_137,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape32 assetEditEntity = Shape32(
source: i0.VersionedTable(
entityName: 'asset_edit_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_107,
_column_159,
_column_207,
_column_208,
_column_209,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape49 settings = Shape49(
source: i0.VersionedTable(
entityName: 'settings',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY("key")'],
columns: [_column_210, _column_224, _column_115],
attachedDatabase: database,
),
alias: null,
);
late final Shape51 assetOcrEntity = Shape51(
source: i0.VersionedTable(
entityName: 'asset_ocr_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_107,
_column_159,
_column_213,
_column_214,
_column_215,
_column_216,
_column_217,
_column_218,
_column_219,
_column_220,
_column_221,
_column_222,
_column_223,
_column_201,
],
attachedDatabase: database,
),
alias: null,
);
final i1.Index idxPartnerSharedWithId = i1.Index(
'idx_partner_shared_with_id',
'CREATE INDEX IF NOT EXISTS idx_partner_shared_with_id ON partner_entity (shared_with_id)',
);
final i1.Index idxLatLng = i1.Index(
'idx_lat_lng',
'CREATE INDEX IF NOT EXISTS idx_lat_lng ON remote_exif_entity (latitude, longitude)',
);
final i1.Index idxRemoteExifCity = i1.Index(
'idx_remote_exif_city',
'CREATE INDEX IF NOT EXISTS idx_remote_exif_city ON remote_exif_entity (city) WHERE city IS NOT NULL',
);
final i1.Index idxRemoteAlbumAssetAlbumAsset = i1.Index(
'idx_remote_album_asset_album_asset',
'CREATE INDEX IF NOT EXISTS idx_remote_album_asset_album_asset ON remote_album_asset_entity (album_id, asset_id)',
);
final i1.Index idxRemoteAssetCloudId = i1.Index(
'idx_remote_asset_cloud_id',
'CREATE INDEX IF NOT EXISTS idx_remote_asset_cloud_id ON remote_asset_cloud_id_entity (cloud_id)',
);
final i1.Index idxPersonOwnerId = i1.Index(
'idx_person_owner_id',
'CREATE INDEX IF NOT EXISTS idx_person_owner_id ON person_entity (owner_id)',
);
final i1.Index idxAssetFacePersonId = i1.Index(
'idx_asset_face_person_id',
'CREATE INDEX IF NOT EXISTS idx_asset_face_person_id ON asset_face_entity (person_id)',
);
final i1.Index idxAssetFaceAssetId = i1.Index(
'idx_asset_face_asset_id',
'CREATE INDEX IF NOT EXISTS idx_asset_face_asset_id ON asset_face_entity (asset_id)',
);
final i1.Index idxAssetFaceVisiblePerson = i1.Index(
'idx_asset_face_visible_person',
'CREATE INDEX IF NOT EXISTS idx_asset_face_visible_person ON asset_face_entity (person_id, asset_id) WHERE is_visible = 1 AND deleted_at IS NULL',
);
final i1.Index idxTrashedLocalAssetChecksum = i1.Index(
'idx_trashed_local_asset_checksum',
'CREATE INDEX IF NOT EXISTS idx_trashed_local_asset_checksum ON trashed_local_asset_entity (checksum)',
);
final i1.Index idxTrashedLocalAssetAlbum = i1.Index(
'idx_trashed_local_asset_album',
'CREATE INDEX IF NOT EXISTS idx_trashed_local_asset_album ON trashed_local_asset_entity (album_id)',
);
final i1.Index idxAssetEditAssetId = i1.Index(
'idx_asset_edit_asset_id',
'CREATE INDEX IF NOT EXISTS idx_asset_edit_asset_id ON asset_edit_entity (asset_id)',
);
final i1.Index idxAssetOcrAssetId = i1.Index(
'idx_asset_ocr_asset_id',
'CREATE INDEX IF NOT EXISTS idx_asset_ocr_asset_id ON asset_ocr_entity (asset_id)',
);
}
i1.GeneratedColumn<String> _column_224(String aliasedName) =>
i1.GeneratedColumn<String>(
'value',
aliasedName,
true,
type: i1.DriftSqlType.string,
$customConstraints: 'NULL',
);
i0.MigrationStepWithVersion migrationSteps({
required Future<void> Function(i1.Migrator m, Schema2 schema) from1To2,
required Future<void> Function(i1.Migrator m, Schema3 schema) from2To3,
@@ -15949,7 +15360,6 @@ i0.MigrationStepWithVersion migrationSteps({
required Future<void> Function(i1.Migrator m, Schema27 schema) from26To27,
required Future<void> Function(i1.Migrator m, Schema28 schema) from27To28,
required Future<void> Function(i1.Migrator m, Schema29 schema) from28To29,
required Future<void> Function(i1.Migrator m, Schema30 schema) from29To30,
}) {
return (currentVersion, database) async {
switch (currentVersion) {
@@ -16093,11 +15503,6 @@ i0.MigrationStepWithVersion migrationSteps({
final migrator = i1.Migrator(database, schema);
await from28To29(migrator, schema);
return 29;
case 29:
final schema = Schema30(database: database);
final migrator = i1.Migrator(database, schema);
await from29To30(migrator, schema);
return 30;
default:
throw ArgumentError.value('Unknown migration from $currentVersion');
}
@@ -16133,7 +15538,6 @@ i1.OnUpgrade stepByStep({
required Future<void> Function(i1.Migrator m, Schema27 schema) from26To27,
required Future<void> Function(i1.Migrator m, Schema28 schema) from27To28,
required Future<void> Function(i1.Migrator m, Schema29 schema) from28To29,
required Future<void> Function(i1.Migrator m, Schema30 schema) from29To30,
}) => i0.VersionedSchema.stepByStepHelper(
step: migrationSteps(
from1To2: from1To2,
@@ -16164,6 +15568,5 @@ i1.OnUpgrade stepByStep({
from26To27: from26To27,
from27To28: from27To28,
from28To29: from28To29,
from29To30: from29To30,
),
);
@@ -27,18 +27,7 @@ class DriftMapRepository extends DriftDatabaseRepository {
condition = condition & _db.remoteAssetEntity.isFavorite.equals(true);
}
final timeRange = options.timeRange;
final hasCustomRange = timeRange.from != null || timeRange.to != null;
if (hasCustomRange) {
if (timeRange.from != null) {
condition = condition & _db.remoteAssetEntity.createdAt.isBiggerOrEqualValue(timeRange.from!);
}
if (timeRange.to != null) {
condition = condition & _db.remoteAssetEntity.createdAt.isSmallerOrEqualValue(timeRange.to!);
}
} else if (options.relativeDays > 0) {
if (options.relativeDays != 0) {
final cutoffDate = DateTime.now().toUtc().subtract(Duration(days: options.relativeDays));
condition = condition & _db.remoteAssetEntity.createdAt.isBiggerOrEqualValue(cutoffDate);
}
@@ -1,14 +1,14 @@
import 'package:collection/collection.dart';
import 'package:drift/drift.dart';
import 'package:immich_mobile/domain/models/config/app_config.dart';
import 'package:immich_mobile/domain/models/settings_key.dart';
import 'package:immich_mobile/infrastructure/entities/settings.entity.drift.dart';
import 'package:immich_mobile/infrastructure/repositories/cached_key_value_repository.dart';
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
class SettingsRepository extends CachedKeyValueRepository<SettingsKey, AppConfig> {
class SettingsRepository extends DriftDatabaseRepository {
final Drift _db;
SettingsRepository._(this._db) : super(const .new());
SettingsRepository._(this._db) : super(_db);
static SettingsRepository? _instance;
@@ -20,6 +20,9 @@ class SettingsRepository extends CachedKeyValueRepository<SettingsKey, AppConfig
return instance;
}
AppConfig _appConfig = const .new();
AppConfig get appConfig => _appConfig;
static Future<SettingsRepository> ensureInitialized(Drift db) async {
if (_instance == null) {
final instance = SettingsRepository._(db);
@@ -29,20 +32,7 @@ class SettingsRepository extends CachedKeyValueRepository<SettingsKey, AppConfig
return _instance!;
}
@override
List<SettingsKey> get keys => SettingsKey.values;
@override
Object decodeValue(SettingsKey key, String raw) => key.decode(raw);
@override
AppConfig buildSnapshot(Map<SettingsKey, Object?> overrides) => AppConfig.fromEntries(overrides);
@override
Selectable<({String key, String? value})> selectable() =>
_db.select(_db.settingsEntity).map((row) => (key: row.key, value: row.value));
AppConfig get appConfig => snapshot;
Future<void> refresh() async => _applyOverrides(await _db.select(_db.settingsEntity).get());
Future<void> clear(Iterable<SettingsKey> keys) async {
if (keys.isEmpty) {
@@ -52,15 +42,13 @@ class SettingsRepository extends CachedKeyValueRepository<SettingsKey, AppConfig
final names = keys.map((key) => key.name).toList();
await (_db.delete(_db.settingsEntity)..where((row) => row.key.isIn(names))).go();
var config = snapshot;
for (final key in keys) {
config = config.write(key, defaultConfig.read(key));
_appConfig = _appConfig.write(key, defaultConfig.read(key));
}
snapshot = config;
}
Future<void> write<T, U extends T>(SettingsKey<T> key, U value) async {
if (value == snapshot.read(key)) {
Future<void> write<T extends Object, U extends T>(SettingsKey<T> key, U value) async {
if (value == _appConfig.read(key)) {
return;
}
@@ -68,18 +56,29 @@ class SettingsRepository extends CachedKeyValueRepository<SettingsKey, AppConfig
return clear([key]);
}
String? resolvedValue;
if (value != null) {
resolvedValue = key.encode(value);
}
await _db
.into(_db.settingsEntity)
.insertOnConflictUpdate(
SettingsEntityCompanion.insert(key: key.name, value: .new(resolvedValue), updatedAt: .new(DateTime.now())),
SettingsEntityCompanion.insert(key: key.name, value: key.encode(value), updatedAt: Value(DateTime.now())),
);
snapshot = snapshot.write(key, value);
_appConfig = _appConfig.write(key, value);
}
Stream<AppConfig> watchConfig() => watchSnapshot();
Stream<AppConfig> watchConfig() => _db.select(_db.settingsEntity).watch().map((rows) {
_applyOverrides(rows);
return _appConfig;
});
void _applyOverrides(List<SettingsEntityData> rows) {
_appConfig = AppConfig.fromEntries(
rows.fold({}, (overrides, row) {
final metadataKey = SettingsKey.values.firstWhereOrNull((key) => key.name == row.key);
if (metadataKey == null) {
return overrides;
}
return {...overrides, metadataKey: metadataKey.decode(row.value)};
}),
);
}
}
@@ -5,7 +5,6 @@ import 'package:easy_localization/easy_localization.dart';
import 'package:immich_mobile/constants/constants.dart';
import 'package:immich_mobile/domain/models/album/album.model.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/time_range.model.dart';
import 'package:immich_mobile/domain/models/timeline.model.dart';
import 'package:immich_mobile/domain/services/timeline.service.dart';
import 'package:immich_mobile/infrastructure/entities/local_asset.entity.dart';
@@ -22,7 +21,6 @@ class TimelineMapOptions {
final bool includeArchived;
final bool withPartners;
final int relativeDays;
final TimeRange timeRange;
const TimelineMapOptions({
required this.bounds,
@@ -30,7 +28,6 @@ class TimelineMapOptions {
this.includeArchived = false,
this.withPartners = false,
this.relativeDays = 0,
this.timeRange = const TimeRange(),
});
}
@@ -556,21 +553,8 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
query.where(_db.remoteAssetEntity.isFavorite.equals(true));
}
final timeRange = options.timeRange;
final hasCustomRange = timeRange.from != null || timeRange.to != null;
if (hasCustomRange) {
if (timeRange.from != null) {
query.where(_db.remoteAssetEntity.createdAt.isBiggerOrEqualValue(timeRange.from!));
}
if (timeRange.to != null) {
query.where(_db.remoteAssetEntity.createdAt.isSmallerOrEqualValue(timeRange.to!));
}
} else if (options.relativeDays > 0) {
if (options.relativeDays != 0) {
final cutoffDate = DateTime.now().toUtc().subtract(Duration(days: options.relativeDays));
query.where(_db.remoteAssetEntity.createdAt.isBiggerOrEqualValue(cutoffDate));
}
@@ -611,21 +595,8 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
query.where(_db.remoteAssetEntity.isFavorite.equals(true));
}
final timeRange = options.timeRange;
final hasCustomRange = timeRange.from != null || timeRange.to != null;
if (hasCustomRange) {
if (timeRange.from != null) {
query.where(_db.remoteAssetEntity.createdAt.isBiggerOrEqualValue(timeRange.from!));
}
if (timeRange.to != null) {
query.where(_db.remoteAssetEntity.createdAt.isSmallerOrEqualValue(timeRange.to!));
}
} else if (options.relativeDays > 0) {
if (options.relativeDays != 0) {
final cutoffDate = DateTime.now().toUtc().subtract(Duration(days: options.relativeDays));
query.where(_db.remoteAssetEntity.createdAt.isBiggerOrEqualValue(cutoffDate));
}
@@ -15,7 +15,10 @@ import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
import 'package:immich_mobile/routing/router.dart';
class EditImageActionButton extends ConsumerWidget {
const EditImageActionButton({super.key});
final bool iconOnly;
final bool menuItem;
const EditImageActionButton({super.key, this.iconOnly = false, this.menuItem = false});
@override
Widget build(BuildContext context, WidgetRef ref) {
@@ -54,6 +57,8 @@ class EditImageActionButton extends ConsumerWidget {
iconData: Icons.tune,
label: "edit".t(context: context),
onPressed: onPress,
iconOnly: iconOnly,
menuItem: menuItem,
);
}
}
@@ -0,0 +1,36 @@
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
import 'package:immich_mobile/providers/infrastructure/current_album.provider.dart';
import 'package:immich_mobile/routing/router.dart';
class OpenActivityActionButton extends ConsumerWidget {
const OpenActivityActionButton({super.key, this.iconOnly = false, this.menuItem = false});
final bool iconOnly;
final bool menuItem;
void _onTap(BuildContext context, WidgetRef ref) {
final album = ref.read(currentRemoteAlbumProvider);
final asset = ref.read(assetViewerProvider).currentAsset;
if (album == null || asset == null) {
return;
}
context.router.push(
DriftActivitiesRoute(album: album, assetId: asset is RemoteAsset ? asset.id : null, assetName: asset.name),
);
}
@override
Widget build(BuildContext context, WidgetRef ref) => BaseActionButton(
iconData: Icons.chat_outlined,
label: "activity".t(context: context),
onPressed: () => _onTap(context, ref),
iconOnly: iconOnly,
menuItem: menuItem,
);
}
@@ -2,24 +2,18 @@ import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/services/timeline.service.dart';
import 'package:immich_mobile/domain/models/setting.model.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/add_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_local_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_permanent_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/edit_image_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/restore_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/share_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/upload_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/ocr_toggle_button.widget.dart';
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
import 'package:immich_mobile/providers/infrastructure/current_album.provider.dart';
import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.dart';
import 'package:immich_mobile/providers/infrastructure/setting.provider.dart';
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
import 'package:immich_mobile/providers/routes.provider.dart';
import 'package:immich_mobile/providers/server_info.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/utils/semver.dart';
import 'package:immich_mobile/utils/action_button.utils.dart';
import 'package:immich_mobile/widgets/asset_viewer/video_controls.dart';
class ViewerBottomBar extends ConsumerWidget {
@@ -37,35 +31,31 @@ class ViewerBottomBar extends ConsumerWidget {
final isOwner = asset is RemoteAsset && asset.ownerId == user?.id;
final showingDetails = ref.watch(assetViewerProvider.select((s) => s.showingDetails));
final isInLockedView = ref.watch(inLockedViewProvider);
final serverInfo = ref.watch(serverInfoProvider);
final isInTrash = ref.read(timelineServiceProvider).origin == TimelineOrigin.trash;
final album = ref.watch(currentRemoteAlbumProvider);
final isArchived = asset is RemoteAsset && asset.visibility == AssetVisibility.archive;
final advancedTroubleshooting = ref.watch(settingsProvider.notifier).get(Setting.advancedTroubleshooting);
final timelineOrigin = ref.read(timelineServiceProvider).origin;
final isTrashEnabled = ref.watch(serverInfoProvider.select((state) => state.serverFeatures.trash));
final serverVersion = ref.watch(serverInfoProvider.select((state) => state.serverVersion));
final originalTheme = context.themeData;
final actions = <Widget>[
if (isInTrash && isOwner && asset.hasRemote)
const RestoreActionButton(source: ActionSource.viewer)
else
const ShareActionButton(source: ActionSource.viewer),
final buttonContext = ActionButtonContext(
asset: asset,
isOwner: isOwner,
isArchived: isArchived,
isTrashEnabled: isTrashEnabled,
isStacked: asset is RemoteAsset && asset.stackId != null,
isInLockedView: isInLockedView,
currentAlbum: album,
advancedTroubleshooting: advancedTroubleshooting,
source: ActionSource.viewer,
timelineOrigin: timelineOrigin,
originalTheme: originalTheme,
serverVersion: serverVersion,
);
if (!isInLockedView) ...[
if (!isInTrash) ...[
if (asset.isLocalOnly) const UploadActionButton(source: ActionSource.viewer),
// edit sync was added in 2.6.0
if (asset.isEditable && serverInfo.serverVersion >= const SemVer(major: 2, minor: 6, patch: 0))
const EditImageActionButton(),
if (asset.hasRemote) AddActionButton(originalTheme: originalTheme),
],
if (isOwner) ...[
if (asset.isLocalOnly)
const DeleteLocalActionButton(source: ActionSource.viewer)
else if (asset.isTrashed)
const DeletePermanentActionButton(source: ActionSource.viewer, useShortLabel: true)
else
const DeleteActionButton(source: ActionSource.viewer, showConfirmation: true),
],
],
];
final actions = ActionButtonBuilder.buildViewerBottomBar(buttonContext, context);
return AnimatedSwitcher(
duration: Durations.short4,
@@ -30,6 +30,7 @@ class ViewerKebabMenu extends ConsumerWidget {
final isCasting = ref.watch(castProvider.select((c) => c.isCasting));
final timelineOrigin = ref.read(timelineServiceProvider).origin;
final isTrashEnable = ref.watch(serverInfoProvider.select((state) => state.serverFeatures.trash));
final serverVersion = ref.watch(serverInfoProvider.select((state) => state.serverVersion));
final isInLockedView = ref.watch(inLockedViewProvider);
final currentAlbum = ref.watch(currentRemoteAlbumProvider);
final isArchived = asset is RemoteAsset && asset.visibility == AssetVisibility.archive;
@@ -47,9 +48,10 @@ class ViewerKebabMenu extends ConsumerWidget {
source: ActionSource.viewer,
isCasting: isCasting,
timelineOrigin: timelineOrigin,
serverVersion: serverVersion,
);
final menuChildren = ActionButtonBuilder.buildViewerKebabMenu(actionContext, context, ref);
final menuChildren = ActionButtonBuilder.buildViewerKebabMenu(actionContext, context);
return MenuAnchor(
consumeOutsideTap: true,
@@ -16,7 +16,6 @@ import 'package:immich_mobile/providers/infrastructure/current_album.provider.da
import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.dart';
import 'package:immich_mobile/providers/routes.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/utils/timezone.dart';
class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget {
@@ -49,20 +48,6 @@ class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget {
final actions = <Widget>[
if (asset.isMotionPhoto) const MotionPhotoActionButton(iconOnly: true),
if (album != null && album.isActivityEnabled && album.isShared)
IconButton(
icon: const Icon(Icons.chat_outlined),
onPressed: () {
context.router.push(
DriftActivitiesRoute(
album: album,
assetId: asset is RemoteAsset ? asset.id : null,
assetName: asset.name,
),
);
},
),
if (asset.hasRemote && isOwner && !asset.isFavorite)
const FavoriteActionButton(source: ActionSource.viewer, iconOnly: true),
if (asset.hasRemote && isOwner && asset.isFavorite)
@@ -1,7 +1,6 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/events.model.dart';
import 'package:immich_mobile/domain/models/time_range.model.dart';
import 'package:immich_mobile/domain/utils/event_stream.dart';
import 'package:immich_mobile/infrastructure/repositories/timeline.repository.dart';
import 'package:immich_mobile/providers/infrastructure/map.provider.dart';
@@ -16,7 +15,6 @@ class MapState {
final bool includeArchived;
final bool withPartners;
final int relativeDays;
final TimeRange timeRange;
const MapState({
this.themeMode = ThemeMode.system,
@@ -25,7 +23,6 @@ class MapState {
this.includeArchived = false,
this.withPartners = false,
this.relativeDays = 0,
this.timeRange = const TimeRange(),
});
@override
@@ -43,7 +40,6 @@ class MapState {
bool? includeArchived,
bool? withPartners,
int? relativeDays,
TimeRange? timeRange,
}) {
return MapState(
bounds: bounds ?? this.bounds,
@@ -52,7 +48,6 @@ class MapState {
includeArchived: includeArchived ?? this.includeArchived,
withPartners: withPartners ?? this.withPartners,
relativeDays: relativeDays ?? this.relativeDays,
timeRange: timeRange ?? this.timeRange,
);
}
@@ -62,7 +57,6 @@ class MapState {
includeArchived: includeArchived,
withPartners: withPartners,
relativeDays: relativeDays,
timeRange: timeRange,
);
}
@@ -109,13 +103,6 @@ class MapStateNotifier extends Notifier<MapState> {
EventStream.shared.emit(const MapMarkerReloadEvent());
}
void setTimeRange(TimeRange range) {
ref.read(settingsProvider).write(.mapCustomFrom, range.from);
ref.read(settingsProvider).write(.mapCustomTo, range.to);
state = state.copyWith(timeRange: range);
EventStream.shared.emit(const MapMarkerReloadEvent());
}
@override
MapState build() {
final mapConfig = ref.read(appConfigProvider.select((config) => config.map));
@@ -124,9 +111,8 @@ class MapStateNotifier extends Notifier<MapState> {
onlyFavorites: mapConfig.favoritesOnly,
includeArchived: mapConfig.includeArchived,
withPartners: mapConfig.withPartners,
bounds: LatLngBounds(northeast: const LatLng(0, 0), southwest: const LatLng(0, 0)),
relativeDays: mapConfig.relativeDays,
timeRange: TimeRange(from: mapConfig.customFrom, to: mapConfig.customTo),
bounds: LatLngBounds(northeast: const LatLng(0, 0), southwest: const LatLng(0, 0)),
);
}
}
@@ -1,39 +1,21 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/time_range.model.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/generated/translations.g.dart';
import 'package:immich_mobile/presentation/widgets/map/map.state.dart';
import 'package:immich_mobile/widgets/map/map_settings/map_custom_time_range.dart';
import 'package:immich_mobile/widgets/map/map_settings/map_settings_list_tile.dart';
import 'package:immich_mobile/widgets/map/map_settings/map_settings_time_dropdown.dart';
import 'package:immich_mobile/widgets/map/map_settings/map_theme_picker.dart';
class DriftMapSettingsSheet extends ConsumerStatefulWidget {
class DriftMapSettingsSheet extends HookConsumerWidget {
const DriftMapSettingsSheet({super.key});
@override
ConsumerState<DriftMapSettingsSheet> createState() => _DriftMapSettingsSheetState();
}
class _DriftMapSettingsSheetState extends ConsumerState<DriftMapSettingsSheet> {
late bool useCustomRange;
@override
void initState() {
super.initState();
final mapState = ref.read(mapStateProvider);
final timeRange = mapState.timeRange;
useCustomRange = timeRange.from != null || timeRange.to != null;
}
@override
Widget build(BuildContext context) {
Widget build(BuildContext context, WidgetRef ref) {
final mapState = ref.watch(mapStateProvider);
return DraggableScrollableSheet(
expand: false,
initialChildSize: useCustomRange ? 0.7 : 0.6,
initialChildSize: 0.6,
builder: (ctx, scrollController) => SingleChildScrollView(
controller: scrollController,
child: Card(
@@ -65,41 +47,10 @@ class _DriftMapSettingsSheetState extends ConsumerState<DriftMapSettingsSheet> {
selected: mapState.withPartners,
onChanged: (withPartners) => ref.read(mapStateProvider.notifier).switchWithPartners(withPartners),
),
if (useCustomRange) ...[
MapTimeRange(
timeRange: mapState.timeRange,
onChanged: (range) {
ref.read(mapStateProvider.notifier).setTimeRange(range);
},
),
Align(
alignment: Alignment.centerLeft,
child: TextButton(
onPressed: () => setState(() {
useCustomRange = false;
ref.read(mapStateProvider.notifier).setRelativeTime(0);
ref.read(mapStateProvider.notifier).setTimeRange(const TimeRange());
}),
child: Text(context.t.remove_custom_date_range),
),
),
] else ...[
MapTimeDropDown(
relativeTime: mapState.relativeDays,
onTimeChange: (time) => ref.read(mapStateProvider.notifier).setRelativeTime(time),
),
Align(
alignment: Alignment.centerLeft,
child: TextButton(
onPressed: () => setState(() {
useCustomRange = true;
ref.read(mapStateProvider.notifier).setRelativeTime(0);
ref.read(mapStateProvider.notifier).setTimeRange(const TimeRange());
}),
child: Text(context.t.use_custom_date_range),
),
),
],
MapTimeDropDown(
relativeTime: mapState.relativeDays,
onTimeChange: (time) => ref.read(mapStateProvider.notifier).setRelativeTime(time),
),
const SizedBox(height: 20),
],
),
+2 -2
View File
@@ -5,8 +5,8 @@ import 'dart:io';
import 'package:device_info_plus/device_info_plus.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/infrastructure/repositories/network.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/settings.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/network.repository.dart';
import 'package:immich_mobile/utils/debug_print.dart';
import 'package:immich_mobile/utils/url_helper.dart';
import 'package:logging/logging.dart';
@@ -179,7 +179,7 @@ class ApiService {
}
final network = SettingsRepository.instance.appConfig.network;
final localEndpoint = network.localEndpoint;
if (localEndpoint != null && localEndpoint.isNotEmpty) {
if (localEndpoint.isNotEmpty) {
urls.add(localEndpoint);
}
for (final url in network.externalEndpointList) {
+89 -20
View File
@@ -1,13 +1,13 @@
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/constants/enums.dart';
import 'package:immich_mobile/domain/models/album/album.model.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/events.model.dart';
import 'package:immich_mobile/domain/services/timeline.service.dart';
import 'package:immich_mobile/domain/utils/event_stream.dart';
import 'package:immich_mobile/utils/semver.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/advanced_info_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/archive_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
@@ -16,8 +16,11 @@ import 'package:immich_mobile/presentation/widgets/action_buttons/delete_action_
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_local_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_permanent_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/download_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/add_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/edit_image_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/like_activity_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/move_to_lock_folder_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/open_activity_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/open_in_browser_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/remove_from_album_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/remove_from_lock_folder_action_button.widget.dart';
@@ -47,6 +50,8 @@ class ActionButtonContext {
final bool isCasting;
final TimelineOrigin timelineOrigin;
final int selectedCount;
final ThemeData? originalTheme;
final SemVer serverVersion;
const ActionButtonContext({
required this.asset,
@@ -61,13 +66,17 @@ class ActionButtonContext {
this.isCasting = false,
this.timelineOrigin = TimelineOrigin.main,
this.selectedCount = 1,
this.originalTheme,
this.serverVersion = const SemVer(major: 0, minor: 0, patch: 0),
});
}
enum ActionButtonType {
openInfo,
openActivity,
likeActivity,
share,
editImage,
shareLink,
cast,
setAlbumCover,
@@ -89,8 +98,16 @@ enum ActionButtonType {
deleteLocal,
deletePermanent,
delete,
addTo,
advancedInfo;
bool _isInActivityAlbum(ActionButtonContext context) {
return !context.isInLockedView &&
context.currentAlbum != null &&
context.currentAlbum!.isActivityEnabled &&
context.currentAlbum!.isShared;
}
bool shouldShow(ActionButtonContext context) {
return switch (this) {
ActionButtonType.advancedInfo => context.advancedTroubleshooting,
@@ -159,11 +176,7 @@ enum ActionButtonType {
!context.isInLockedView && //
context.isStacked,
ActionButtonType.openInBrowser => context.asset.hasRemote && !context.isInLockedView,
ActionButtonType.likeActivity =>
!context.isInLockedView &&
context.currentAlbum != null &&
context.currentAlbum!.isActivityEnabled &&
context.currentAlbum!.isShared,
ActionButtonType.likeActivity => _isInActivityAlbum(context),
ActionButtonType.similarPhotos =>
!context.isInLockedView && //
context.asset is RemoteAsset,
@@ -181,11 +194,26 @@ enum ActionButtonType {
context.timelineOrigin != TimelineOrigin.localAlbum &&
context.isOwner,
ActionButtonType.cast => context.isCasting || context.asset.hasRemote,
ActionButtonType.editImage =>
!context.isInLockedView &&
context.asset.isEditable &&
context.serverVersion >= const SemVer(major: 2, minor: 6, patch: 0),
ActionButtonType.addTo =>
!context.isInLockedView && //
context.asset.hasRemote,
ActionButtonType.openActivity => _isInActivityAlbum(context),
ActionButtonType.slideshow => true,
};
}
ConsumerWidget buildButton(
bool showsAt(ButtonPosition position, ActionButtonContext context) => switch (this) {
ActionButtonType.openActivity || ActionButtonType.likeActivity =>
position != ButtonPosition.bottomBar || context.timelineOrigin != TimelineOrigin.trash,
ActionButtonType.editImage => position != ButtonPosition.bottomBar || context.currentAlbum?.isShared != true,
_ => true,
};
Widget buildButton(
ActionButtonContext context, [
BuildContext? buildContext,
bool iconOnly = false,
@@ -221,8 +249,14 @@ enum ActionButtonType {
source: context.source,
iconOnly: iconOnly,
menuItem: menuItem,
useShortLabel: false,
),
ActionButtonType.delete => DeleteActionButton(
source: context.source,
iconOnly: iconOnly,
menuItem: menuItem,
showConfirmation: true,
),
ActionButtonType.delete => DeleteActionButton(source: context.source, iconOnly: iconOnly, menuItem: menuItem),
ActionButtonType.moveToLockFolder => MoveToLockFolderActionButton(
source: context.source,
iconOnly: iconOnly,
@@ -288,6 +322,9 @@ enum ActionButtonType {
},
),
ActionButtonType.cast => CastActionButton(iconOnly: iconOnly, menuItem: menuItem),
ActionButtonType.editImage => EditImageActionButton(iconOnly: iconOnly, menuItem: menuItem),
ActionButtonType.addTo => AddActionButton(originalTheme: context.originalTheme),
ActionButtonType.openActivity => OpenActivityActionButton(iconOnly: iconOnly, menuItem: menuItem),
};
}
@@ -319,41 +356,73 @@ enum ActionButtonType {
class ActionButtonBuilder {
static const List<ActionButtonType> _actionTypes = ActionButtonType.values;
static const List<ActionButtonType> defaultViewerKebabMenuOrder = _actionTypes;
static const Set<ActionButtonType> defaultViewerBottomBarButtons = {
static const List<ActionButtonType> _defaultViewerBottomBarOrder = [
ActionButtonType.share,
ActionButtonType.moveToLockFolder,
ActionButtonType.upload,
ActionButtonType.delete,
ActionButtonType.archive,
ActionButtonType.unarchive,
ActionButtonType.editImage,
ActionButtonType.addTo,
ActionButtonType.openActivity,
ActionButtonType.likeActivity,
ActionButtonType.removeFromLockFolder,
ActionButtonType.restoreTrash,
ActionButtonType.deletePermanent,
};
ActionButtonType.delete,
ActionButtonType.deleteLocal,
];
static List<Widget> build(ActionButtonContext context) {
return _actionTypes.where((type) => type.shouldShow(context)).map((type) => type.buildButton(context)).toList();
}
static List<Widget> buildViewerKebabMenu(ActionButtonContext context, BuildContext buildContext, WidgetRef ref) {
final visibleButtons = defaultViewerKebabMenuOrder
.where((type) => !defaultViewerBottomBarButtons.contains(type) && type.shouldShow(context))
static List<ActionButtonType> getViewerBottomBarTypes(ActionButtonContext context) {
return _defaultViewerBottomBarOrder
.where((type) => type.shouldShow(context) && type.showsAt(ButtonPosition.bottomBar, context))
.take(4)
.toList();
}
if (visibleButtons.isEmpty) {
static List<ActionButtonType> getViewerKebabMenuTypes(ActionButtonContext context) {
final inBottomBar = getViewerBottomBarTypes(context).toSet();
return defaultViewerKebabMenuOrder
.where(
(type) =>
!inBottomBar.contains(type) &&
type.shouldShow(context) &&
type.showsAt(ButtonPosition.kebabMenu, context),
)
.toList();
}
static List<Widget> buildViewerKebabMenu(ActionButtonContext context, BuildContext buildContext) {
return getViewerKebabMenuTypes(context).toKebabMenuWidgets(context, buildContext);
}
static List<Widget> buildViewerBottomBar(ActionButtonContext context, BuildContext buildContext) {
return getViewerBottomBarTypes(context).toBottomBarWidgets(context, buildContext);
}
}
extension ActionButtonTypeListExtension on List<ActionButtonType> {
List<Widget> toKebabMenuWidgets(ActionButtonContext context, BuildContext buildContext) {
if (isEmpty) {
return [];
}
final List<Widget> result = [];
int? lastGroup;
for (final type in visibleButtons) {
for (final type in this) {
if (lastGroup != null && type.kebabMenuGroup != lastGroup) {
result.add(const Divider(height: 1));
}
result.add(type.buildButton(context, buildContext, false, true).build(buildContext, ref));
result.add(type.buildButton(context, buildContext, false, true));
lastGroup = type.kebabMenuGroup;
}
return result;
}
List<Widget> toBottomBarWidgets(ActionButtonContext context, BuildContext buildContext) {
return map((type) => type.buildButton(context, buildContext, false, false)).toList();
}
}
+5 -13
View File
@@ -118,10 +118,8 @@ Future<void> _migrateTo26(Drift drift) async {
await migrator.migrateBool(StoreKey.legacyTapToNavigate, SettingsKey.viewerTapToNavigate);
// Network
await migrator.migrateBool(StoreKey.legacyAutoEndpointSwitching, SettingsKey.networkAutoEndpointSwitching);
final preferredWifiName = await migrator.readLegacyStoreString(StoreKey.legacyPreferredWifiName.id);
migrator.stage(StoreKey.legacyPreferredWifiName, SettingsKey.networkPreferredWifiName, preferredWifiName);
final localEndpoint = await migrator.readLegacyStoreString(StoreKey.legacyLocalEndpoint.id);
migrator.stage(StoreKey.legacyLocalEndpoint, SettingsKey.networkLocalEndpoint, localEndpoint);
await migrator.migrateString(StoreKey.legacyPreferredWifiName, SettingsKey.networkPreferredWifiName);
await migrator.migrateString(StoreKey.legacyLocalEndpoint, SettingsKey.networkLocalEndpoint);
await _migrateExternalEndpointList(migrator);
await _migrateCustomHeaders(migrator);
// Album
@@ -197,7 +195,7 @@ Future<void> _migrateCustomHeaders(_StoreMigrator migrator) async {
class _StoreMigrator {
final Drift _db;
final Map<SettingsKey, Object?> _cache = {};
final Map<SettingsKey<Object>, Object> _cache = {};
final List<int> _migratedStoreIds = [];
_StoreMigrator(this._db);
@@ -267,7 +265,7 @@ class _StoreMigrator {
_migratedStoreIds.add(legacyKey.id);
}
void stage<T, U extends T>(StoreKey legacyKey, SettingsKey<T> newKey, U value) {
void stage<T extends Object>(StoreKey legacyKey, SettingsKey<T> newKey, T value) {
_cache[newKey] = value;
_migratedStoreIds.add(legacyKey.id);
}
@@ -278,15 +276,9 @@ class _StoreMigrator {
if (entry.value == defaultConfig.read(entry.key)) {
continue;
}
String? resolvedValue;
if (entry.value != null) {
resolvedValue = entry.key.encode(entry.value);
}
batch.insert(
_db.settingsEntity,
SettingsEntityCompanion(key: Value(entry.key.name), value: Value(resolvedValue)),
SettingsEntityCompanion(key: Value(entry.key.name), value: Value(entry.key.encode(entry.value))),
mode: InsertMode.insertOrReplace,
);
}
+2 -13
View File
@@ -26,17 +26,6 @@ sealed class Option<T> {
None() => onNone(),
};
Option<U> flatMap<U>(Option<U> Function(T value) f) => switch (this) {
Some(:final value) => f(value),
None() => const Option.none(),
};
void ifPresent(void Function(T value) f) {
if (this case Some(:final value)) {
f(value);
}
}
@override
String toString() => switch (this) {
Some(:final value) => 'Some($value)',
@@ -66,8 +55,8 @@ final class None<T> extends Option<T> {
int get hashCode => 0;
}
extension NullableOptionExtension<T> on Option<T>? {
T? patch(T? current) => this == null ? current : this!.unwrapOrNull;
extension ObjectOptionExtension<T> on T? {
Option<T> toOption() => Option.fromNullable(this);
}
extension OptionToOptional<T> on Option<T> {
@@ -1,79 +0,0 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:immich_mobile/domain/models/time_range.model.dart';
import 'package:immich_mobile/generated/translations.g.dart';
import 'package:immich_mobile/utils/option.dart';
class MapTimeRange extends StatelessWidget {
const MapTimeRange({super.key, required this.timeRange, required this.onChanged});
final TimeRange timeRange;
final Function(TimeRange) onChanged;
@override
Widget build(BuildContext context) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
title: Text(context.t.date_after),
subtitle: Text(
timeRange.from != null
? DateFormat.yMMMd(context.locale.toLanguageTag()).add_jm().format(timeRange.from!)
: context.t.not_set,
),
trailing: timeRange.from != null
? IconButton(
icon: const Icon(Icons.close),
onPressed: () => onChanged(timeRange.copyWith(from: const .none())),
)
: null,
onTap: () async {
final initial = timeRange.from ?? DateTime.now();
final currentTo = timeRange.to;
final picked = await showDatePicker(
context: context,
initialDate: currentTo != null && initial.isAfter(currentTo) ? currentTo : initial,
firstDate: DateTime(1970),
lastDate: currentTo ?? DateTime.now(),
);
if (picked != null) {
onChanged(timeRange.copyWith(from: Option.some(picked)));
}
},
),
ListTile(
title: Text(context.t.date_before),
subtitle: Text(
timeRange.to != null
? DateFormat.yMMMd(context.locale.toLanguageTag()).add_jm().format(timeRange.to!)
: context.t.not_set,
),
trailing: timeRange.to != null
? IconButton(
icon: const Icon(Icons.close),
onPressed: () => onChanged(timeRange.copyWith(to: const .none())),
)
: null,
onTap: () async {
final initial = timeRange.to ?? DateTime.now();
final currentFrom = timeRange.from;
final picked = await showDatePicker(
context: context,
initialDate: currentFrom != null && initial.isBefore(currentFrom) ? currentFrom : initial,
firstDate: currentFrom ?? DateTime(1970),
lastDate: DateTime.now(),
);
if (picked != null) {
onChanged(timeRange.copyWith(to: Option.some(picked)));
}
},
),
],
);
}
}
-4
View File
@@ -33,7 +33,6 @@ import 'schema_v26.dart' as v26;
import 'schema_v27.dart' as v27;
import 'schema_v28.dart' as v28;
import 'schema_v29.dart' as v29;
import 'schema_v30.dart' as v30;
class GeneratedHelper implements SchemaInstantiationHelper {
@override
@@ -97,8 +96,6 @@ class GeneratedHelper implements SchemaInstantiationHelper {
return v28.DatabaseAtV28(db);
case 29:
return v29.DatabaseAtV29(db);
case 30:
return v30.DatabaseAtV30(db);
default:
throw MissingSchemaException(version, versions);
}
@@ -134,6 +131,5 @@ class GeneratedHelper implements SchemaInstantiationHelper {
27,
28,
29,
30,
];
}
File diff suppressed because it is too large Load Diff
@@ -456,7 +456,7 @@ void main() {
test('does not update when longitude does not match', () async {
final remoteAsset = await ctx.newRemoteAsset(ownerId: userId);
final cloudIdAsset = await ctx.newRemoteAssetCloudId(id: remoteAsset.id, longitude: .fromNullable((-74.006)));
final cloudIdAsset = await ctx.newRemoteAssetCloudId(id: remoteAsset.id, longitude: (-74.006).toOption());
final localAsset = await ctx.newLocalAsset(
checksumOption: const Option.none(),
iCloudId: cloudIdAsset.cloudId,
@@ -1,3 +1,4 @@
import 'package:drift/drift.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:immich_mobile/domain/models/log.model.dart';
@@ -60,39 +61,6 @@ void main() {
});
});
group('null values', () {
test('writing null to a nullable key clears the row and reverts the cache to null', () async {
await sut.write(SettingsKey.networkPreferredWifiName, 'home-wifi');
expect(sut.appConfig.network.preferredWifiName, 'home-wifi');
expect(await ctx.db.select(ctx.db.settingsEntity).get(), hasLength(1));
await sut.write(SettingsKey.networkPreferredWifiName, null);
expect(await ctx.db.select(ctx.db.settingsEntity).get(), isEmpty);
expect(sut.appConfig.network.preferredWifiName, isNull);
});
test('writing null to an already-null key is a no-op', () async {
await sut.write(SettingsKey.networkPreferredWifiName, null);
expect(await ctx.db.select(ctx.db.settingsEntity).get(), isEmpty);
});
test('a stored NULL value column decodes to null on refresh', () async {
await ctx.db
.into(ctx.db.settingsEntity)
.insert(
SettingsEntityCompanion.insert(
key: SettingsKey.networkPreferredWifiName.name,
value: const .new(null),
updatedAt: .new(DateTime.now()),
),
);
await SettingsRepository.instance.refresh();
expect(sut.appConfig.network.preferredWifiName, isNull);
});
});
group('delete', () {});
group('sync', () {
@@ -102,8 +70,8 @@ void main() {
.insert(
SettingsEntityCompanion.insert(
key: SettingsKey.themeMode.name,
value: .new(ThemeMode.dark.name),
updatedAt: .new(DateTime.now()),
value: ThemeMode.dark.name,
updatedAt: Value(DateTime.now()),
),
);
@@ -130,8 +98,8 @@ void main() {
.insert(
SettingsEntityCompanion.insert(
key: 'app-config.unknown.future-key',
value: const .new('unknown'),
updatedAt: .new(DateTime.now()),
value: 'whatever',
updatedAt: Value(DateTime.now()),
),
);
@@ -5,26 +5,12 @@ import 'package:immich_mobile/domain/models/settings_key.dart';
void main() {
group('SettingsKey', () {
for (final key in SettingsKey.values) {
final defaultValue = defaultConfig.read(key);
// null is a valid value for some keys but we don't use the codec in that case
if (defaultValue == null) {
continue;
}
test('verify codec for $key', () {
final defaultValue = defaultConfig.read(key);
final encoded = key.encode(defaultValue);
final decoded = key.decode(encoded);
expect(decoded, defaultValue, reason: 'round-trip failed for ${key.name}');
});
}
});
group('AppConfig.fromEntries', () {
for (final key in SettingsKey.values) {
test('routes the default value for $key through write/read', () {
final value = defaultConfig.read(key);
final config = AppConfig.fromEntries({key: value});
expect(config.read(key), value, reason: 'write/read mismatch for ${key.name}');
});
}
});
}
+9 -15
View File
@@ -100,23 +100,17 @@ void main() {
});
});
group('NullableOptionExtension', () {
test('patch returns the current value when the option is null', () {
const Option<String>? omitted = null;
expect(omitted.patch('existing'), 'existing');
group('ObjectOptionExtension', () {
test('non-null value.toOption() returns Some', () {
final option = 'hello'.toOption();
expect(option, isA<Some<String>>());
expect((option as Some).value, 'hello');
});
test('patch preserves a null current when the option is null', () {
const Option<String>? omitted = null;
expect(omitted.patch(null), isNull);
});
test('patch returns the wrapped value for Some, overriding current', () {
expect(const Option.some('new').patch('existing'), 'new');
});
test('patch returns null for None, clearing current', () {
expect(const Option<String>.none().patch('existing'), isNull);
test('null value.toOption() returns None', () {
const String? value = null;
final option = value.toOption();
expect(option, isA<None<String>>());
});
});
}
@@ -1,99 +0,0 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:immich_mobile/domain/models/value_codec.dart';
enum _Fruit { apple, banana, cherry }
void main() {
group('MapCodec', () {
group('encode', () {
test('serializes an empty map to an empty JSON object', () {
const codec = MapCodec<String, String>(PrimitiveCodec.string, PrimitiveCodec.string);
expect(codec.encode({}), '{}');
});
test('encodes a string-to-string map as a JSON object', () {
const codec = MapCodec<String, String>(PrimitiveCodec.string, PrimitiveCodec.string);
expect(codec.encode({'a': '1', 'b': '2'}), '{"a":"1","b":"2"}');
});
test('stringifies non-string values via the value codec', () {
const codec = MapCodec<String, int>(PrimitiveCodec.string, PrimitiveCodec.integer);
expect(codec.encode({'x': 10, 'y': 20}), '{"x":"10","y":"20"}');
});
test('stringifies non-string keys via the key codec', () {
const codec = MapCodec<int, String>(PrimitiveCodec.integer, PrimitiveCodec.string);
expect(codec.encode({1: 'one', 2: 'two'}), '{"1":"one","2":"two"}');
});
});
group('decode', () {
test('reconstructs a string-to-string map', () {
const codec = MapCodec<String, String>(PrimitiveCodec.string, PrimitiveCodec.string);
expect(codec.decode('{"a":"1","b":"2"}'), {'a': '1', 'b': '2'});
});
test('parses values back to their domain type', () {
const codec = MapCodec<String, int>(PrimitiveCodec.string, PrimitiveCodec.integer);
expect(codec.decode('{"x":"10","y":"20"}'), {'x': 10, 'y': 20});
});
test('parses keys back to their domain type', () {
const codec = MapCodec<int, String>(PrimitiveCodec.integer, PrimitiveCodec.string);
expect(codec.decode('{"1":"one","2":"two"}'), {1: 'one', 2: 'two'});
});
test('returns an empty map for an empty JSON object', () {
const codec = MapCodec<String, String>(PrimitiveCodec.string, PrimitiveCodec.string);
expect(codec.decode('{}'), isEmpty);
});
test('returns an empty map when the payload is not valid JSON', () {
const codec = MapCodec<String, String>(PrimitiveCodec.string, PrimitiveCodec.string);
expect(codec.decode('not json'), isEmpty);
});
test('returns an empty map when the JSON root is not an object', () {
const codec = MapCodec<String, String>(PrimitiveCodec.string, PrimitiveCodec.string);
expect(codec.decode('[]'), isEmpty);
expect(codec.decode('"a string"'), isEmpty);
expect(codec.decode('42'), isEmpty);
});
test('skips entries whose value is not a JSON string, keeping the rest', () {
const codec = MapCodec<String, int>(PrimitiveCodec.string, PrimitiveCodec.integer);
expect(codec.decode('{"x":1,"y":"20"}'), {'y': 20});
});
test('skips entries whose value is a nested object, keeping the rest', () {
const codec = MapCodec<String, String>(PrimitiveCodec.string, PrimitiveCodec.string);
expect(codec.decode('{"a":{"nested":"value"},"b":"ok"}'), {'b': 'ok'});
});
test('returns an empty map when every entry is malformed', () {
const codec = MapCodec<String, int>(PrimitiveCodec.string, PrimitiveCodec.integer);
expect(codec.decode('{"x":1,"y":2}'), isEmpty);
});
});
group('round trip', () {
test('preserves a primitive map through encode then decode', () {
const codec = MapCodec<String, int>(PrimitiveCodec.string, PrimitiveCodec.integer);
const original = {'one': 1, 'two': 2, 'three': 3};
expect(codec.decode(codec.encode(original)), original);
});
test('preserves an enum-valued map by composing with EnumCodec', () {
const codec = MapCodec<String, _Fruit>(PrimitiveCodec.string, EnumCodec(_Fruit.values));
const original = {'breakfast': _Fruit.banana, 'snack': _Fruit.apple};
expect(codec.decode(codec.encode(original)), original);
});
test('preserves a DateTime-valued map by composing with DateTimeCodec', () {
const codec = MapCodec<String, DateTime>(PrimitiveCodec.string, DateTimeCodec());
final original = {'created': DateTime.utc(2024, 1, 1, 12, 30)};
expect(codec.decode(codec.encode(original)), original);
});
});
});
}
@@ -5,6 +5,7 @@ import 'package:immich_mobile/domain/models/album/album.model.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/services/timeline.service.dart';
import 'package:immich_mobile/utils/action_button.utils.dart';
import 'package:immich_mobile/utils/semver.dart';
LocalAsset createLocalAsset({
String? remoteId,
@@ -1238,4 +1239,438 @@ void main() {
expect(nonArchivedWidgets, isNotEmpty);
});
});
group('ActionButtonBuilder.getViewerBottomBarTypes', () {
test('should return correct button types for shared album with activity', () {
final remoteAsset = createRemoteAsset();
final album = createRemoteAlbum(isActivityEnabled: true, isShared: true);
final context = ActionButtonContext(
asset: remoteAsset,
isOwner: true,
isArchived: false,
isTrashEnabled: true,
isInLockedView: false,
currentAlbum: album,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.viewer,
);
const expectedTypes = [
ActionButtonType.share,
ActionButtonType.addTo,
ActionButtonType.openActivity,
ActionButtonType.likeActivity,
];
final bottomBarTypes = ActionButtonBuilder.getViewerBottomBarTypes(context);
final kebabTypes = ActionButtonBuilder.getViewerKebabMenuTypes(context);
expect(bottomBarTypes, expectedTypes);
expect(bottomBarTypes.any(kebabTypes.contains), isFalse);
});
test('should return correct button types for local only asset', () {
final localAsset = createLocalAsset();
final context = ActionButtonContext(
asset: localAsset,
isOwner: true,
isArchived: false,
isTrashEnabled: true,
isInLockedView: false,
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.viewer,
serverVersion: const SemVer(major: 2, minor: 6, patch: 0),
);
const expectedTypes = [ActionButtonType.share, ActionButtonType.upload, ActionButtonType.deleteLocal];
final bottomBarTypes = ActionButtonBuilder.getViewerBottomBarTypes(context);
final kebabTypes = ActionButtonBuilder.getViewerKebabMenuTypes(context);
expect(bottomBarTypes, expectedTypes);
expect(bottomBarTypes.any(kebabTypes.contains), isFalse);
});
test('should return correct button types for locked view', () {
final remoteAsset = createRemoteAsset();
final context = ActionButtonContext(
asset: remoteAsset,
isOwner: true,
isArchived: false,
isTrashEnabled: false,
isInLockedView: true,
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.viewer,
);
const expectedTypes = [
ActionButtonType.share,
ActionButtonType.removeFromLockFolder,
ActionButtonType.deletePermanent,
];
final bottomBarTypes = ActionButtonBuilder.getViewerBottomBarTypes(context);
final kebabTypes = ActionButtonBuilder.getViewerKebabMenuTypes(context);
expect(bottomBarTypes, expectedTypes);
expect(bottomBarTypes.any(kebabTypes.contains), isFalse);
});
test('should return correct button types for remote only asset', () {
final remoteAsset = createRemoteAsset();
final context = ActionButtonContext(
asset: remoteAsset,
isOwner: true,
isArchived: false,
isTrashEnabled: true,
isInLockedView: false,
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.viewer,
serverVersion: const SemVer(major: 2, minor: 6, patch: 0),
);
const expectedTypes = [
ActionButtonType.share,
ActionButtonType.editImage,
ActionButtonType.addTo,
ActionButtonType.delete,
];
final bottomBarTypes = ActionButtonBuilder.getViewerBottomBarTypes(context);
final kebabTypes = ActionButtonBuilder.getViewerKebabMenuTypes(context);
expect(bottomBarTypes, expectedTypes);
expect(bottomBarTypes.any(kebabTypes.contains), isFalse);
});
test('trashed asset (no album) keeps restore and permanent delete in bottom bar', () {
final remoteAsset = createRemoteAsset(deletedAt: DateTime.now());
final context = ActionButtonContext(
asset: remoteAsset,
isOwner: true,
isArchived: false,
isTrashEnabled: true,
isInLockedView: false,
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.viewer,
timelineOrigin: TimelineOrigin.trash,
);
const expectedTypes = [
ActionButtonType.share,
ActionButtonType.addTo,
ActionButtonType.restoreTrash,
ActionButtonType.deletePermanent,
];
expect(ActionButtonBuilder.getViewerBottomBarTypes(context), expectedTypes);
});
test('trashed asset in shared activity album still surfaces restore and permanent delete', () {
final remoteAsset = createRemoteAsset(deletedAt: DateTime.now());
final album = createRemoteAlbum(isActivityEnabled: true, isShared: true);
final context = ActionButtonContext(
asset: remoteAsset,
isOwner: true,
isArchived: false,
isTrashEnabled: true,
isInLockedView: false,
currentAlbum: album,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.viewer,
timelineOrigin: TimelineOrigin.trash,
);
final bottomBarTypes = ActionButtonBuilder.getViewerBottomBarTypes(context);
expect(bottomBarTypes, contains(ActionButtonType.restoreTrash));
expect(bottomBarTypes, contains(ActionButtonType.deletePermanent));
expect(bottomBarTypes, isNot(contains(ActionButtonType.openActivity)));
expect(bottomBarTypes, isNot(contains(ActionButtonType.likeActivity)));
});
test('5th bottom-bar candidate overflows into the kebab', () {
final remoteAsset = createRemoteAsset();
final album = createRemoteAlbum(isActivityEnabled: true, isShared: true);
final context = ActionButtonContext(
asset: remoteAsset,
isOwner: true,
isArchived: false,
isTrashEnabled: true,
isInLockedView: false,
currentAlbum: album,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.viewer,
serverVersion: const SemVer(major: 2, minor: 6, patch: 0),
);
final bottomBarTypes = ActionButtonBuilder.getViewerBottomBarTypes(context);
final kebabTypes = ActionButtonBuilder.getViewerKebabMenuTypes(context);
expect(bottomBarTypes.length, 4);
expect(bottomBarTypes, isNot(contains(ActionButtonType.delete)));
expect(kebabTypes, contains(ActionButtonType.delete));
});
});
group('ActionButtonType.shouldShow editImage button', () {
final editableRemote = createRemoteAsset();
test('should show for editable remote asset on a recent server', () {
final context = ActionButtonContext(
asset: editableRemote,
isOwner: true,
isArchived: false,
isTrashEnabled: true,
isInLockedView: false,
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.viewer,
serverVersion: const SemVer(major: 2, minor: 6, patch: 0),
);
expect(ActionButtonType.editImage.shouldShow(context), isTrue);
});
test('should not show when local-only (LocalAsset is not editable)', () {
final context = ActionButtonContext(
asset: createLocalAsset(),
isOwner: true,
isArchived: false,
isTrashEnabled: true,
isInLockedView: false,
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.viewer,
serverVersion: const SemVer(major: 2, minor: 6, patch: 0),
);
expect(ActionButtonType.editImage.shouldShow(context), isFalse);
});
test('should not show when server version is below 2.6', () {
final context = ActionButtonContext(
asset: editableRemote,
isOwner: true,
isArchived: false,
isTrashEnabled: true,
isInLockedView: false,
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.viewer,
serverVersion: const SemVer(major: 2, minor: 5, patch: 9),
);
expect(ActionButtonType.editImage.shouldShow(context), isFalse);
});
test('should not show in locked view', () {
final context = ActionButtonContext(
asset: editableRemote,
isOwner: true,
isArchived: false,
isTrashEnabled: true,
isInLockedView: true,
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.viewer,
serverVersion: const SemVer(major: 2, minor: 6, patch: 0),
);
expect(ActionButtonType.editImage.shouldShow(context), isFalse);
});
});
group('ActionButtonType.shouldShow addTo button', () {
test('should show when not locked and asset has remote', () {
final context = ActionButtonContext(
asset: createRemoteAsset(),
isOwner: true,
isArchived: false,
isTrashEnabled: true,
isInLockedView: false,
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.viewer,
);
expect(ActionButtonType.addTo.shouldShow(context), isTrue);
});
test('should not show in locked view', () {
final context = ActionButtonContext(
asset: createRemoteAsset(),
isOwner: true,
isArchived: false,
isTrashEnabled: true,
isInLockedView: true,
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.viewer,
);
expect(ActionButtonType.addTo.shouldShow(context), isFalse);
});
test('should not show for local-only asset', () {
final context = ActionButtonContext(
asset: createLocalAsset(),
isOwner: true,
isArchived: false,
isTrashEnabled: true,
isInLockedView: false,
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.viewer,
);
expect(ActionButtonType.addTo.shouldShow(context), isFalse);
});
});
group('ActionButtonType.shouldShow openActivity button', () {
final asset = createRemoteAsset();
test('should show in a shared activity-enabled album', () {
final context = ActionButtonContext(
asset: asset,
isOwner: true,
isArchived: false,
isTrashEnabled: true,
isInLockedView: false,
currentAlbum: createRemoteAlbum(isActivityEnabled: true, isShared: true),
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.viewer,
);
expect(ActionButtonType.openActivity.shouldShow(context), isTrue);
});
test('should not show when no album', () {
final context = ActionButtonContext(
asset: asset,
isOwner: true,
isArchived: false,
isTrashEnabled: true,
isInLockedView: false,
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.viewer,
);
expect(ActionButtonType.openActivity.shouldShow(context), isFalse);
});
test('should not show when album not shared', () {
final context = ActionButtonContext(
asset: asset,
isOwner: true,
isArchived: false,
isTrashEnabled: true,
isInLockedView: false,
currentAlbum: createRemoteAlbum(isActivityEnabled: true, isShared: false),
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.viewer,
);
expect(ActionButtonType.openActivity.shouldShow(context), isFalse);
});
test('should not show when activity disabled', () {
final context = ActionButtonContext(
asset: asset,
isOwner: true,
isArchived: false,
isTrashEnabled: true,
isInLockedView: false,
currentAlbum: createRemoteAlbum(isActivityEnabled: false, isShared: true),
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.viewer,
);
expect(ActionButtonType.openActivity.shouldShow(context), isFalse);
});
test('should not show in locked view', () {
final context = ActionButtonContext(
asset: asset,
isOwner: true,
isArchived: false,
isTrashEnabled: true,
isInLockedView: true,
currentAlbum: createRemoteAlbum(isActivityEnabled: true, isShared: true),
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.viewer,
);
expect(ActionButtonType.openActivity.shouldShow(context), isFalse);
});
});
group('ActionButtonType.showsAt', () {
ActionButtonContext ctx({RemoteAlbum? album, TimelineOrigin origin = TimelineOrigin.main}) => ActionButtonContext(
asset: createRemoteAsset(),
isOwner: true,
isArchived: false,
isTrashEnabled: true,
isInLockedView: false,
currentAlbum: album,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.viewer,
timelineOrigin: origin,
);
group('editImage', () {
test('hidden from bottom bar when album is shared', () {
final context = ctx(album: createRemoteAlbum(isShared: true));
expect(ActionButtonType.editImage.showsAt(ButtonPosition.bottomBar, context), isFalse);
});
test('shown in bottom bar when album is not shared', () {
final context = ctx(album: createRemoteAlbum(isShared: false));
expect(ActionButtonType.editImage.showsAt(ButtonPosition.bottomBar, context), isTrue);
});
test('shown in bottom bar when there is no album', () {
expect(ActionButtonType.editImage.showsAt(ButtonPosition.bottomBar, ctx()), isTrue);
});
});
group('openActivity / likeActivity', () {
test('hidden from bottom bar when viewing from trash', () {
final context = ctx(origin: TimelineOrigin.trash);
expect(ActionButtonType.openActivity.showsAt(ButtonPosition.bottomBar, context), isFalse);
expect(ActionButtonType.likeActivity.showsAt(ButtonPosition.bottomBar, context), isFalse);
});
test('shown in bottom bar outside trash', () {
expect(ActionButtonType.openActivity.showsAt(ButtonPosition.bottomBar, ctx()), isTrue);
expect(ActionButtonType.likeActivity.showsAt(ButtonPosition.bottomBar, ctx()), isTrue);
});
});
});
}
+1 -1
View File
@@ -29,7 +29,7 @@
"@vitest/coverage-v8": "^4.0.0",
"byte-size": "^9.0.0",
"cli-progress": "^3.12.0",
"commander": "^12.0.0",
"commander": "^15.0.0",
"eslint": "^10.0.0",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-prettier": "^5.1.3",
+21 -21
View File
@@ -164,7 +164,7 @@ importers:
version: 64.0.0(eslint@10.4.0(jiti@2.7.0))
exiftool-vendored:
specifier: ^35.0.0
version: 35.20.0
version: 35.21.0
globals:
specifier: ^17.0.0
version: 17.6.0
@@ -260,8 +260,8 @@ importers:
specifier: ^3.12.0
version: 3.12.0
commander:
specifier: ^12.0.0
version: 12.1.0
specifier: ^15.0.0
version: 15.0.0
eslint:
specifier: ^10.0.0
version: 10.4.0(jiti@2.7.0)
@@ -481,7 +481,7 @@ importers:
version: 4.4.0
exiftool-vendored:
specifier: ^35.20.0
version: 35.20.0
version: 35.21.0
express:
specifier: ^5.1.0
version: 5.2.1
@@ -6596,10 +6596,6 @@ packages:
resolution: {integrity: sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ==}
engines: {node: '>=16'}
commander@12.1.0:
resolution: {integrity: sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==}
engines: {node: '>=18'}
commander@13.1.0:
resolution: {integrity: sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==}
engines: {node: '>=18'}
@@ -6608,6 +6604,10 @@ packages:
resolution: {integrity: sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==}
engines: {node: '>=20'}
commander@15.0.0:
resolution: {integrity: sha512-z67u4ZhzCL/Tydu1lJARtEZYWbWaN7oYLHbsuzocr6y4N6WZAagG3RQ4FW61V1/0+jImpj293XfrcYnd1qxtPg==}
engines: {node: '>=22.12.0'}
commander@2.20.3:
resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==}
@@ -7746,17 +7746,17 @@ packages:
resolution: {integrity: sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==}
engines: {node: '>=10'}
exiftool-vendored.exe@13.58.0:
resolution: {integrity: sha512-pV7SjQeOu4Q77DWuyF+hlRYWVlRcSAqfqTTujBZeGUy/Q9+RPAy877YgSZIxKOYW1TxmmL8KyBGxaG0JKYG8BQ==}
exiftool-vendored.exe@13.59.0:
resolution: {integrity: sha512-d+Glvl77sKAt3ybMyE2o9eLMtublnZIlii7QGFej5CwcPwvq9v440CmKnLvufzdUV3RGXU1q1/rZepONUNXJIQ==}
os: [win32]
exiftool-vendored.pl@13.58.0:
resolution: {integrity: sha512-+Z2xhZrYLMu/anO/s14AaS/K5HMJ5Cw9C3KefIeYNpkZRN4RRBJHm7R34yjj9Pv+elqYRZrQV9NcqvkBLn/68w==}
exiftool-vendored.pl@13.59.0:
resolution: {integrity: sha512-6YlBpi4pcfSmIYcsied6g4+XlDe7KSKMf5WPsLBpUCiD5PyO2fd1v/Ob37VZDj36+9XCRnXY98fqVfCfNIq8ug==}
os: ['!win32']
hasBin: true
exiftool-vendored@35.20.0:
resolution: {integrity: sha512-Yn66dSBaWGcUaSbm5Nl4G28rxtceLlWf4PstqJMbLix9sN7w0okWHPEvdudiP56Q5Cjl7v3TLyKKwowUFlbD8g==}
exiftool-vendored@35.21.0:
resolution: {integrity: sha512-yZE8E8ZrRwAiD0z+MaAH5n6FdGsc2dbXbGHTJOaMWsQFsNOuKOkHgpgFzS9neB4MB8MirNlKDfEOEmg1KzdeTw==}
engines: {node: '>=20.0.0'}
expand-template@2.0.3:
@@ -19483,12 +19483,12 @@ snapshots:
commander@11.1.0: {}
commander@12.1.0: {}
commander@13.1.0: {}
commander@14.0.3: {}
commander@15.0.0: {}
commander@2.20.3: {}
commander@4.1.1: {}
@@ -20826,21 +20826,21 @@ snapshots:
signal-exit: 3.0.7
strip-final-newline: 2.0.0
exiftool-vendored.exe@13.58.0:
exiftool-vendored.exe@13.59.0:
optional: true
exiftool-vendored.pl@13.58.0: {}
exiftool-vendored.pl@13.59.0: {}
exiftool-vendored@35.20.0:
exiftool-vendored@35.21.0:
dependencies:
'@photostructure/tz-lookup': 11.5.0
'@types/luxon': 3.7.1
batch-cluster: 17.3.1
exiftool-vendored.pl: 13.58.0
exiftool-vendored.pl: 13.59.0
he: 1.2.0
luxon: 3.7.2
optionalDependencies:
exiftool-vendored.exe: 13.58.0
exiftool-vendored.exe: 13.59.0
expand-template@2.0.3:
optional: true
+34 -43
View File
@@ -1,7 +1,8 @@
<script lang="ts">
import { locale } from '$lib/stores/preferences.store';
import type { CalendarHeatmapResponseDto } from '@immich/sdk';
import { DateTime } from 'luxon';
import { Text } from '@immich/ui';
import { DateTime, Info } from 'luxon';
import { t } from 'svelte-i18n';
type Props = {
@@ -12,20 +13,8 @@
const { data, itemLabel, totalLabel }: Props = $props();
const { rows } = $derived.by(() => {
const weeks = Array.from({ length: Math.ceil(data.series.length / 7) }, (_, index) =>
data.series.slice(index * 7, index * 7 + 7),
);
const rows = Array.from({ length: 7 }, (_, dayIndex) => weeks.map((week) => week[dayIndex]).filter(Boolean));
const endDate = DateTime.fromISO(data.to, { zone: 'utc' });
const months = Array.from({ length: 4 }, (_, index) =>
endDate.minus({ months: 11 - index * 4 }).toLocaleString({ month: 'short' }, { locale: $locale }),
);
return { rows, months };
});
const startDate = $derived(DateTime.fromISO(data.from, { zone: 'utc' }));
const padding = $derived(startDate.diff(startDate.startOf('week', { useLocaleWeeks: true })).as('days'));
const maxCount = $derived(Math.max(...data.series.map((item) => item.count), 0));
@@ -49,45 +38,47 @@
return 'bg-immich-primary';
};
// const dayLabels = $derived([
// '',
// dayOfWeek('monday', { locale: $locale, style: 'short' }),
// '',
// dayOfWeek('wednesday', { locale: $locale, style: 'short' }),
// '',
// dayOfWeek('friday', { locale: $locale, style: 'short' }),
// '',
// ]);
const weekdays = $derived([
Info.weekdays('short', { locale: $locale })[0],
Info.weekdays('short', { locale: $locale })[2],
Info.weekdays('short', { locale: $locale })[4],
Info.weekdays('short', { locale: $locale })[6],
]);
</script>
<div class="mt-4 w-full">
<div class="relative w-full">
<!-- TODO -->
<!-- <div class="absolute top-4 left-0 flex flex-col gap-0.5">
{#each dayLabels as dayLabel, i (i)}
<div class="relative flex h-3 w-6 items-center text-xs text-gray-500 dark:text-gray-400">
{dayLabel}
</div>
{/each}
</div> -->
<!-- <div class="mb-1 flex justify-between text-xs text-gray-500 dark:text-gray-400">
{#each getUploadActivityMonths() as month (month)}
<div>{month}</div>
{/each}
</div> -->
<div class="grid grid-rows-7 gap-0.5">
{#each rows as row, dayIndex (dayIndex)}
<div class="grid grid-cols-52 gap-0.5">
{#each row as day (day.date)}
<div
class="aspect-square w-full min-w-0 rounded-sm {itemColors(day.count)}"
title={itemLabel({ date: day.date, count: day.count })}
aria-label={itemLabel({ date: day.date, count: day.count })}
></div>
{/each}
</div>
<div class="grid grid-flow-col grid-rows-7 gap-0.5">
<div class="row-span-7 grid grid-rows-subgrid">
{#if Info.getStartOfWeek({ locale: $locale }) === 7}
<div></div>
{/if}
<div class="row-span-2 -mt-1"><Text size="tiny" class="mr-0.5 font-mono">{weekdays[0]}</Text></div>
<div class="row-span-2 -mt-1"><Text size="tiny" class="mr-0.5 font-mono">{weekdays[1]}</Text></div>
<div class="row-span-2 -mt-1"><Text size="tiny" class="mr-0.5 font-mono">{weekdays[2]}</Text></div>
{#if Info.getStartOfWeek({ locale: $locale }) === 1}
<div class="-my-1"><Text size="tiny" class="mr-0.5 font-mono">{weekdays[3]}</Text></div>
{/if}
</div>
{#each data.series as day, idx (day.date)}
{@const date = DateTime.fromISO(day.date, { zone: 'utc' }).toLocaleString(
{ month: 'short', day: 'numeric' },
{ locale: $locale },
)}
<div
class="aspect-square size-full rounded-sm {itemColors(day.count)} row-start-(--heatmap-row-start)"
style:--heatmap-row-start={idx === 0 ? padding + 1 : undefined}
title={itemLabel({ date, count: day.count })}
aria-label={itemLabel({ date, count: day.count })}
></div>
{/each}
</div>