mirror of
https://github.com/immich-app/immich.git
synced 2026-07-23 21:35:55 +03:00
Compare commits
1 Commits
fcast
...
refactor/m
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1f0c662965 |
14
.github/workflows/build-mobile.yml
vendored
14
.github/workflows/build-mobile.yml
vendored
@@ -127,15 +127,6 @@ jobs:
|
||||
with:
|
||||
packages: ''
|
||||
|
||||
- name: Install Rust and protoc for cargokit
|
||||
run: |
|
||||
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal
|
||||
echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
|
||||
"$HOME/.cargo/bin/rustup" target add aarch64-linux-android armv7-linux-androideabi x86_64-linux-android
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y protobuf-compiler
|
||||
echo "PROTOC=$(command -v protoc)" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Get Packages
|
||||
working-directory: ./mobile
|
||||
run: flutter pub get
|
||||
@@ -235,11 +226,6 @@ jobs:
|
||||
github_token: ${{ steps.token.outputs.token }}
|
||||
working_directory: ./mobile
|
||||
|
||||
- name: Install protoc
|
||||
run: |
|
||||
brew install protobuf
|
||||
echo "PROTOC=$(brew --prefix)/bin/protoc" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Install Flutter dependencies
|
||||
working-directory: ./mobile
|
||||
run: flutter pub get
|
||||
|
||||
@@ -409,7 +409,7 @@ To decrease Redis logs, you can add the following line to the `redis:` section o
|
||||
|
||||
You can change the user in the container by setting the `user` argument in `docker-compose.yml` for each service.
|
||||
|
||||
[Example docker-compose.rootless.yml file](https://github.com/immich-app/immich/blob/main/docker/docker-compose.rootless.yml)
|
||||
[Example docker-compose.yml file](https://github.com/immich-app/immich/blob/main/docker/docker-compose.rootless.yml)
|
||||
|
||||
You may need to add mount points or docker volumes for the following internal container paths:
|
||||
|
||||
|
||||
@@ -6,10 +6,6 @@ Immich supports the Google's Cast protocol so that photos and videos can be cast
|
||||
|
||||
Google Cast support is disabled by default. The web UI uses Google-provided scripts and must retrieve them from Google servers when the page loads. This is a privacy concern for some and is thus opt-in.
|
||||
|
||||
## Mobile Fcast support
|
||||
|
||||
The mobile app also supports FCast for casting media to both FCast receivers and Google Cast devices such as a Google TV or Nest Hub. Devices are automatically discovered over your local network.
|
||||
|
||||
You can enable Google Cast support through `Account Settings > Features > Cast > Google Cast`
|
||||
|
||||
<img src={require('./img/gcast-enable.webp').default} width="70%" title='Enable Google Cast Support' />
|
||||
|
||||
@@ -1524,7 +1524,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",
|
||||
|
||||
@@ -27,30 +27,12 @@ require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelpe
|
||||
|
||||
flutter_ios_podfile_setup
|
||||
|
||||
def ensure_fcast_sender_sdk_podspec
|
||||
plugin_ios_dir = File.expand_path(File.join('.symlinks', 'plugins', 'fcast_sender_sdk', 'ios'), __dir__)
|
||||
original_podspec = File.join(plugin_ios_dir, 'fcast_sender_sdk_flutter_plugin.podspec')
|
||||
expected_podspec = File.join(plugin_ios_dir, 'fcast_sender_sdk.podspec')
|
||||
|
||||
return unless File.exist?(original_podspec)
|
||||
|
||||
contents = File.read(original_podspec)
|
||||
fixed_contents = contents
|
||||
.sub("s.name = 'fcast_sender_sdk_flutter_plugin'", "s.name = 'fcast_sender_sdk'")
|
||||
.gsub('libfcast_sender_sdk_flutter_plugin.a', 'libfcast_sender_sdk.a')
|
||||
|
||||
return if File.exist?(expected_podspec) && File.read(expected_podspec) == fixed_contents
|
||||
|
||||
File.write(expected_podspec, fixed_contents)
|
||||
end
|
||||
|
||||
target 'Runner' do
|
||||
use_frameworks!
|
||||
use_modular_headers!
|
||||
|
||||
flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__))
|
||||
ensure_fcast_sender_sdk_podspec
|
||||
|
||||
|
||||
# share_handler addition start
|
||||
target 'ShareExtension' do
|
||||
inherit! :search_paths
|
||||
@@ -67,7 +49,7 @@ post_install do |installer|
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
installer.pods_project.targets.each do |target|
|
||||
flutter_additional_ios_build_settings(target)
|
||||
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
PODS:
|
||||
- bonsoir_darwin (0.0.1):
|
||||
- Flutter
|
||||
- FlutterMacOS
|
||||
- cupertino_http (0.0.1):
|
||||
- Flutter
|
||||
- FlutterMacOS
|
||||
- fcast_sender_sdk (0.0.1):
|
||||
- Flutter
|
||||
- Flutter (1.0.0)
|
||||
- flutter_local_notifications (0.0.1):
|
||||
- Flutter
|
||||
@@ -23,8 +24,8 @@ PODS:
|
||||
- share_handler_ios_models (0.0.9)
|
||||
|
||||
DEPENDENCIES:
|
||||
- bonsoir_darwin (from `.symlinks/plugins/bonsoir_darwin/darwin`)
|
||||
- cupertino_http (from `.symlinks/plugins/cupertino_http/darwin`)
|
||||
- fcast_sender_sdk (from `.symlinks/plugins/fcast_sender_sdk/ios`)
|
||||
- Flutter (from `Flutter`)
|
||||
- flutter_local_notifications (from `.symlinks/plugins/flutter_local_notifications/ios`)
|
||||
- flutter_secure_storage (from `.symlinks/plugins/flutter_secure_storage/ios`)
|
||||
@@ -34,10 +35,10 @@ DEPENDENCIES:
|
||||
- share_handler_ios_models (from `.symlinks/plugins/share_handler_ios/ios/Models`)
|
||||
|
||||
EXTERNAL SOURCES:
|
||||
bonsoir_darwin:
|
||||
:path: ".symlinks/plugins/bonsoir_darwin/darwin"
|
||||
cupertino_http:
|
||||
:path: ".symlinks/plugins/cupertino_http/darwin"
|
||||
fcast_sender_sdk:
|
||||
:path: ".symlinks/plugins/fcast_sender_sdk/ios"
|
||||
Flutter:
|
||||
:path: Flutter
|
||||
flutter_local_notifications:
|
||||
@@ -54,8 +55,8 @@ EXTERNAL SOURCES:
|
||||
:path: ".symlinks/plugins/share_handler_ios/ios/Models"
|
||||
|
||||
SPEC CHECKSUMS:
|
||||
bonsoir_darwin: 29c7ccf356646118844721f36e1de4b61f6cbd0e
|
||||
cupertino_http: 94ac07f5ff090b8effa6c5e2c47871d48ab7c86c
|
||||
fcast_sender_sdk: 8bf227a5cbcedaea2e580a633ff0f706f1e90328
|
||||
Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467
|
||||
flutter_local_notifications: ad39620c743ea4c15127860f4b5641649a988100
|
||||
flutter_secure_storage: 1ed9476fba7e7a782b22888f956cce43e2c62f13
|
||||
@@ -64,6 +65,6 @@ SPEC CHECKSUMS:
|
||||
share_handler_ios: e2244e990f826b2c8eaa291ac3831569438ba0fb
|
||||
share_handler_ios_models: fc638c9b4330dc7f082586c92aee9dfa0b87b871
|
||||
|
||||
PODFILE CHECKSUM: 44895462563291c3e4328a856c6482a25165b507
|
||||
PODFILE CHECKSUM: 3c43a700a4bffb4120bf696cad263aefd4bb3c8c
|
||||
|
||||
COCOAPODS: 1.17.0
|
||||
COCOAPODS: 1.16.2
|
||||
|
||||
@@ -32,8 +32,8 @@
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/maplibre/maplibre-gl-native-distribution.git",
|
||||
"state" : {
|
||||
"revision" : "84a79bc375a301169390ac110c868f06c857b83f",
|
||||
"version" : "6.27.0"
|
||||
"revision" : "60d9bb85c94ce6e7fc4406cd32529fd12bdb7809",
|
||||
"version" : "6.14.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
@@ -127,7 +127,6 @@
|
||||
<array>
|
||||
<string>_googlecast._tcp</string>
|
||||
<string>_CC1AD845._googlecast._tcp</string>
|
||||
<string>_fcast._tcp</string>
|
||||
</array>
|
||||
<key>NSCameraUsageDescription</key>
|
||||
<string>We need to access the camera to let you take beautiful video using this app</string>
|
||||
|
||||
@@ -9,6 +9,8 @@ enum SortOrder {
|
||||
|
||||
enum TextSearchType { context, filename, description, ocr }
|
||||
|
||||
enum AssetVisibilityEnum { timeline, hidden, archive, locked }
|
||||
|
||||
enum ActionSource { timeline, viewer }
|
||||
|
||||
enum ShareAssetType { original, preview }
|
||||
|
||||
@@ -6,7 +6,6 @@ const Map<String, Locale> locales = {
|
||||
// Additional locales
|
||||
'Arabic (ar)': Locale('ar'),
|
||||
'Basque (eu)': Locale('eu'),
|
||||
'Belarusian (be)': Locale('be'),
|
||||
'Bosnian (bl)': Locale('bn'),
|
||||
'Brazilian Portuguese (pt_BR)': Locale('pt', 'BR'),
|
||||
'Bulgarian (bg)': Locale('bg'),
|
||||
|
||||
@@ -153,8 +153,6 @@ class AppConfig {
|
||||
.timelineStorageIndicator => timeline.storageIndicator,
|
||||
.mapShowFavoriteOnly => map.favoritesOnly,
|
||||
.mapRelativeDate => map.relativeDays,
|
||||
.mapCustomFrom => map.customFrom,
|
||||
.mapCustomTo => map.customTo,
|
||||
.mapIncludeArchived => map.includeArchived,
|
||||
.mapThemeMode => map.themeMode,
|
||||
.mapWithPartners => map.withPartners,
|
||||
@@ -209,8 +207,6 @@ class AppConfig {
|
||||
.timelineStorageIndicator => copyWith(timeline: timeline.copyWith(storageIndicator: value as bool)),
|
||||
.mapShowFavoriteOnly => copyWith(map: map.copyWith(favoritesOnly: value as bool)),
|
||||
.mapRelativeDate => copyWith(map: map.copyWith(relativeDays: value as int)),
|
||||
.mapCustomFrom => copyWith(map: map.copyWith(customFrom: .fromNullable(value as DateTime?))),
|
||||
.mapCustomTo => copyWith(map: map.copyWith(customTo: .fromNullable(value as DateTime?))),
|
||||
.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)),
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:immich_mobile/utils/option.dart';
|
||||
|
||||
class MapConfig {
|
||||
final int relativeDays;
|
||||
@@ -7,17 +6,13 @@ class MapConfig {
|
||||
final bool includeArchived;
|
||||
final ThemeMode themeMode;
|
||||
final bool withPartners;
|
||||
final DateTime? customFrom;
|
||||
final DateTime? customTo;
|
||||
|
||||
const MapConfig({
|
||||
this.relativeDays = 0,
|
||||
this.favoritesOnly = false,
|
||||
this.includeArchived = false,
|
||||
this.themeMode = .system,
|
||||
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)';
|
||||
}
|
||||
|
||||
@@ -55,8 +55,6 @@ enum SettingsKey<T> {
|
||||
// Map
|
||||
mapShowFavoriteOnly<bool>(),
|
||||
mapRelativeDate<int>(),
|
||||
mapCustomFrom<DateTime?>(),
|
||||
mapCustomTo<DateTime?>(),
|
||||
mapIncludeArchived<bool>(),
|
||||
mapThemeMode<ThemeMode>(codec: EnumCodec(ThemeMode.values)),
|
||||
mapWithPartners<bool>(),
|
||||
|
||||
@@ -1,15 +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));
|
||||
}
|
||||
|
||||
TimeRange clearFrom() => TimeRange(to: to);
|
||||
TimeRange clearTo() => TimeRange(from: from);
|
||||
}
|
||||
@@ -6,10 +6,7 @@ import 'package:background_downloader/background_downloader.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/constants/constants.dart';
|
||||
import 'package:immich_mobile/domain/services/hash.service.dart';
|
||||
import 'package:immich_mobile/domain/services/local_sync.service.dart';
|
||||
import 'package:immich_mobile/domain/services/log.service.dart';
|
||||
import 'package:immich_mobile/domain/services/sync_stream.service.dart';
|
||||
import 'package:immich_mobile/entities/store.entity.dart';
|
||||
import 'package:immich_mobile/extensions/platform_extensions.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
|
||||
@@ -17,16 +14,11 @@ import 'package:immich_mobile/infrastructure/repositories/logger_db.repository.d
|
||||
import 'package:immich_mobile/infrastructure/repositories/settings.repository.dart';
|
||||
import 'package:immich_mobile/platform/background_worker_api.g.dart';
|
||||
import 'package:immich_mobile/platform/background_worker_lock_api.g.dart';
|
||||
import 'package:immich_mobile/providers/api.provider.dart';
|
||||
import 'package:immich_mobile/providers/background_sync.provider.dart';
|
||||
import 'package:immich_mobile/providers/backup/drift_backup.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/platform.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/sync.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/platform.provider.dart' show nativeSyncApiProvider;
|
||||
import 'package:immich_mobile/providers/user.provider.dart';
|
||||
import 'package:immich_mobile/repositories/asset_media.repository.dart';
|
||||
import 'package:immich_mobile/repositories/permission.repository.dart';
|
||||
import 'package:immich_mobile/services/auth.service.dart';
|
||||
import 'package:immich_mobile/services/foreground_upload.service.dart';
|
||||
import 'package:immich_mobile/services/localization.service.dart';
|
||||
@@ -66,43 +58,12 @@ class BackgroundWorkerBgService extends BackgroundWorkerFlutterApi {
|
||||
final BackgroundWorkerBgHostApi _backgroundHostApi;
|
||||
final _cancellationToken = Completer<void>();
|
||||
final Logger _logger = Logger('BackgroundWorkerBgService');
|
||||
late LocalSyncService _localSyncService;
|
||||
late SyncStreamService _remoteSyncService;
|
||||
late HashService _hashService;
|
||||
|
||||
bool _isCleanedUp = false;
|
||||
|
||||
BackgroundWorkerBgService({required this._drift, required this._driftLogger})
|
||||
: _backgroundHostApi = BackgroundWorkerBgHostApi() {
|
||||
final ref = ProviderContainer(overrides: [driftProvider.overrideWith(driftOverride(_drift))]);
|
||||
_ref = ref;
|
||||
_localSyncService = LocalSyncService(
|
||||
localAlbumRepository: ref.read(localAlbumRepository),
|
||||
localAssetRepository: ref.read(localAssetRepository),
|
||||
nativeSyncApi: ref.read(nativeSyncApiProvider),
|
||||
trashedLocalAssetRepository: ref.read(trashedLocalAssetRepository),
|
||||
assetMediaRepository: ref.read(assetMediaRepositoryProvider),
|
||||
permissionRepository: ref.read(permissionRepositoryProvider),
|
||||
cancellation: _cancellationToken,
|
||||
);
|
||||
_remoteSyncService = SyncStreamService(
|
||||
syncApiRepository: ref.read(syncApiRepositoryProvider),
|
||||
syncStreamRepository: ref.read(syncStreamRepositoryProvider),
|
||||
localAssetRepository: ref.read(localAssetRepository),
|
||||
trashedLocalAssetRepository: ref.read(trashedLocalAssetRepository),
|
||||
assetMediaRepository: ref.read(assetMediaRepositoryProvider),
|
||||
permissionRepository: ref.read(permissionRepositoryProvider),
|
||||
syncMigrationRepository: ref.read(syncMigrationRepositoryProvider),
|
||||
api: ref.read(apiServiceProvider),
|
||||
cancellation: _cancellationToken,
|
||||
);
|
||||
_hashService = HashService(
|
||||
localAlbumRepository: ref.read(localAlbumRepository),
|
||||
localAssetRepository: ref.read(localAssetRepository),
|
||||
nativeSyncApi: ref.read(nativeSyncApiProvider),
|
||||
trashedLocalAssetRepository: ref.read(trashedLocalAssetRepository),
|
||||
cancellation: _cancellationToken,
|
||||
);
|
||||
_ref = ProviderContainer(overrides: [driftProvider.overrideWith(driftOverride(_drift))]);
|
||||
BackgroundWorkerFlutterApi.setUp(this);
|
||||
}
|
||||
|
||||
@@ -158,6 +119,11 @@ class BackgroundWorkerBgService extends BackgroundWorkerFlutterApi {
|
||||
try {
|
||||
final budget = maxSeconds != null ? Duration(seconds: maxSeconds - 1) : null;
|
||||
|
||||
final sync = _ref?.read(backgroundSyncProvider);
|
||||
if (sync == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Only for Background Processing tasks
|
||||
if (maxSeconds == null) {
|
||||
await _optimizeDB();
|
||||
@@ -169,23 +135,9 @@ class BackgroundWorkerBgService extends BackgroundWorkerFlutterApi {
|
||||
// hash and handle_backup read drift state and tolerate stale reads
|
||||
// (server-side dedup catches the rare race). The single budget caps the
|
||||
// whole batch; no phase needs its own timeout.
|
||||
final all = Future.wait<dynamic>([
|
||||
_localSyncService.sync(),
|
||||
_remoteSyncService.sync(),
|
||||
_hashService.hashAssets(),
|
||||
_handleBackup(),
|
||||
]);
|
||||
final all = Future.wait<dynamic>([sync.syncLocal(), sync.syncRemote(), sync.hashAssets(), _handleBackup()]);
|
||||
if (budget != null) {
|
||||
await all.timeout(
|
||||
budget,
|
||||
onTimeout: () {
|
||||
if (!_cancellationToken.isCompleted) {
|
||||
_logger.warning("iOS background upload timed out after ${budget.inSeconds}s, cancelling tasks");
|
||||
_cancellationToken.complete();
|
||||
}
|
||||
return <dynamic>[];
|
||||
},
|
||||
);
|
||||
await all.timeout(budget, onTimeout: () => <dynamic>[]);
|
||||
} else {
|
||||
await all;
|
||||
}
|
||||
@@ -269,6 +221,7 @@ class BackgroundWorkerBgService extends BackgroundWorkerFlutterApi {
|
||||
|
||||
try {
|
||||
_isCleanedUp = true;
|
||||
final backgroundSyncManager = _ref?.read(backgroundSyncProvider);
|
||||
final nativeSyncApi = _ref?.read(nativeSyncApiProvider);
|
||||
|
||||
_logger.info("Cleaning up background worker");
|
||||
@@ -277,7 +230,10 @@ class BackgroundWorkerBgService extends BackgroundWorkerFlutterApi {
|
||||
}
|
||||
|
||||
// Workers share one sqlite connection, so DB teardown must wait until every worker has stopped using it.
|
||||
await Future.wait([if (nativeSyncApi != null) nativeSyncApi.cancelHashing()]);
|
||||
await Future.wait([
|
||||
if (backgroundSyncManager != null) backgroundSyncManager.cancel(),
|
||||
if (nativeSyncApi != null) nativeSyncApi.cancelHashing(),
|
||||
]);
|
||||
await workerManagerPatch.dispose().catchError((_) async {});
|
||||
await Future.wait([LogService.I.dispose(), Store.dispose()]);
|
||||
await _drift.close();
|
||||
@@ -323,18 +279,18 @@ class BackgroundWorkerBgService extends BackgroundWorkerFlutterApi {
|
||||
}
|
||||
|
||||
Future<bool> _syncAssets({Duration? hashTimeout}) async {
|
||||
await _localSyncService.sync();
|
||||
await _ref?.read(backgroundSyncProvider).syncLocal();
|
||||
if (_isCleanedUp) {
|
||||
return false;
|
||||
}
|
||||
|
||||
final isSuccess = await _remoteSyncService.sync();
|
||||
final isSuccess = await _ref?.read(backgroundSyncProvider).syncRemote() ?? false;
|
||||
if (_isCleanedUp) {
|
||||
return isSuccess;
|
||||
}
|
||||
|
||||
var hashFuture = _hashService.hashAssets();
|
||||
if (hashTimeout != null) {
|
||||
var hashFuture = _ref?.read(backgroundSyncProvider).hashAssets();
|
||||
if (hashTimeout != null && hashFuture != null) {
|
||||
hashFuture = hashFuture.timeout(
|
||||
hashTimeout,
|
||||
onTimeout: () {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -10,7 +10,6 @@ import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.drift.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/stack.entity.drift.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
|
||||
import 'package:immich_mobile/utils/option.dart';
|
||||
import 'package:maplibre_gl/maplibre_gl.dart';
|
||||
|
||||
class RemoteAssetRepository extends DriftDatabaseRepository {
|
||||
@@ -293,20 +292,4 @@ class RemoteAssetRepository extends DriftDatabaseRepository {
|
||||
..orderBy([(row) => OrderingTerm.asc(row.sequence)]);
|
||||
return query.map((row) => row.toDto()!).get();
|
||||
}
|
||||
|
||||
Future<void> update(
|
||||
List<String> remoteIds, {
|
||||
Option<bool> isFavorite = const .none(),
|
||||
Option<AssetVisibility> visibility = const .none(),
|
||||
}) {
|
||||
final companion = RemoteAssetEntityCompanion(
|
||||
visibility: visibility.toDriftValue(),
|
||||
isFavorite: isFavorite.toDriftValue(),
|
||||
);
|
||||
return _db.batch((batch) {
|
||||
for (final remoteId in remoteIds) {
|
||||
batch.update(_db.remoteAssetEntity, companion, where: (e) => e.id.equals(remoteId));
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ import 'package:drift/drift.dart';
|
||||
import 'package:easy_localization/easy_localization.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';
|
||||
@@ -21,7 +20,6 @@ class TimelineMapOptions {
|
||||
final bool includeArchived;
|
||||
final bool withPartners;
|
||||
final int relativeDays;
|
||||
final TimeRange timeRange;
|
||||
|
||||
const TimelineMapOptions({
|
||||
required this.bounds,
|
||||
@@ -29,7 +27,6 @@ class TimelineMapOptions {
|
||||
this.includeArchived = false,
|
||||
this.withPartners = false,
|
||||
this.relativeDays = 0,
|
||||
this.timeRange = const TimeRange(),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -555,21 +552,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));
|
||||
}
|
||||
|
||||
@@ -610,21 +594,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));
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import 'dart:convert';
|
||||
|
||||
enum CastDestinationType { googleCast, fCast }
|
||||
enum CastDestinationType { googleCast }
|
||||
|
||||
enum CastState { idle, playing, paused, buffering }
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/domain/models/user.model.dart';
|
||||
import 'package:immich_mobile/generated/translations.g.dart';
|
||||
import 'package:immich_mobile/presentation/actions/action.widget.dart';
|
||||
import 'package:immich_mobile/presentation/actions/action.dart';
|
||||
import 'package:immich_mobile/presentation/actions/partner.action.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/people/partner_user_avatar.widget.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/user.provider.dart';
|
||||
@@ -33,7 +33,7 @@ class PartnerPage extends ConsumerWidget {
|
||||
title: Text(context.t.partners),
|
||||
elevation: 0,
|
||||
centerTitle: false,
|
||||
actions: const [ActionIconButtonWidget(action: PartnerAddAction())],
|
||||
actions: const [PartnerAddAction(display: ActionDisplay.iconButton)],
|
||||
),
|
||||
body: sharedByAsync.when(
|
||||
data: (partners) => PartnerSharedByList(partners: partners.toList(growable: false)),
|
||||
@@ -60,7 +60,7 @@ class _EmptyPartners extends StatelessWidget {
|
||||
),
|
||||
const Align(
|
||||
alignment: .center,
|
||||
child: ActionButtonWidget(action: PartnerAddAction()),
|
||||
child: PartnerAddAction(),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -88,8 +88,10 @@ class PartnerSharedByList extends StatelessWidget {
|
||||
leading: PartnerUserAvatar(userId: partner.id, name: partner.name),
|
||||
title: Text(partner.name),
|
||||
subtitle: Text(partner.email),
|
||||
trailing: ActionIconButtonWidget(
|
||||
action: PartnerRemoveAction(sharedWithId: partner.id, partnerName: partner.name),
|
||||
trailing: PartnerRemoveAction(
|
||||
sharedWithId: partner.id,
|
||||
partnerName: partner.name,
|
||||
display: ActionDisplay.iconButton,
|
||||
),
|
||||
);
|
||||
},
|
||||
|
||||
@@ -1,32 +1,17 @@
|
||||
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/domain/models/user.model.dart';
|
||||
import 'package:flutter/widgets.dart' hide Action;
|
||||
|
||||
class ActionScope {
|
||||
final BuildContext context;
|
||||
final WidgetRef ref;
|
||||
final UserDto authUser;
|
||||
enum ActionDisplay { button, iconButton, menuItem, columnButton }
|
||||
|
||||
const ActionScope({required this.context, required this.ref, required this.authUser});
|
||||
}
|
||||
|
||||
abstract class BaseAction {
|
||||
const BaseAction();
|
||||
|
||||
IconData get icon;
|
||||
|
||||
String label(ActionScope scope);
|
||||
|
||||
bool isVisible(ActionScope scope) => true;
|
||||
|
||||
Future<void> onAction(ActionScope scope);
|
||||
}
|
||||
|
||||
abstract class AssetAction<T extends BaseAsset> extends BaseAction {
|
||||
final Iterable<BaseAsset> assets;
|
||||
|
||||
const AssetAction({required this.assets});
|
||||
|
||||
Iterable<T> filter(ActionScope scope) => assets.whereType<T>();
|
||||
class Action {
|
||||
final IconData icon;
|
||||
final bool isVisible;
|
||||
final String label;
|
||||
final Future<void> Function() onAction;
|
||||
|
||||
const Action({
|
||||
required this.icon,
|
||||
this.isVisible = true,
|
||||
required this.label,
|
||||
required this.onAction,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,95 +1,30 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter/material.dart' hide Action;
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/presentation/actions/action.dart';
|
||||
import 'package:immich_mobile/providers/user.provider.dart';
|
||||
import 'package:immich_mobile/utils/error_handler.dart';
|
||||
import 'package:immich_ui/immich_ui.dart';
|
||||
|
||||
class _ActionWidgetScope {
|
||||
final String label;
|
||||
final VoidCallback onAction;
|
||||
abstract class ActionWidget extends ConsumerWidget {
|
||||
final ActionDisplay display;
|
||||
|
||||
const _ActionWidgetScope({required this.label, required this.onAction});
|
||||
}
|
||||
const ActionWidget({super.key, this.display = ActionDisplay.button});
|
||||
|
||||
class _ActionWidget extends ConsumerWidget {
|
||||
final BaseAction action;
|
||||
final Widget Function(_ActionWidgetScope context) builder;
|
||||
|
||||
const _ActionWidget({required this.action, required this.builder});
|
||||
|
||||
Future<void> _onAction(ActionScope scope) async {
|
||||
try {
|
||||
await action.onAction(scope);
|
||||
} catch (error, stackTrace) {
|
||||
handleError(scope.context, error, stack: stackTrace, description: 'Action failed: ${action.runtimeType}');
|
||||
}
|
||||
}
|
||||
Action resolve(BuildContext context, WidgetRef ref);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final authUser = ref.watch(currentUserProvider);
|
||||
if (authUser == null) {
|
||||
final action = resolve(context, ref);
|
||||
if (!action.isVisible) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
final scope = ActionScope(context: context, ref: ref, authUser: authUser);
|
||||
if (!action.isVisible(scope)) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
return builder(.new(label: action.label(scope), onAction: () => _onAction(scope)));
|
||||
return switch (display) {
|
||||
ActionDisplay.button =>
|
||||
ImmichTextButton(labelText: action.label, icon: action.icon, onPressed: action.onAction),
|
||||
ActionDisplay.iconButton => ImmichIconButton(icon: action.icon, onPressed: action.onAction),
|
||||
ActionDisplay.menuItem =>
|
||||
ImmichMenuItem(icon: action.icon, label: action.label, onPressed: action.onAction),
|
||||
ActionDisplay.columnButton =>
|
||||
ImmichColumnButton(icon: action.icon, label: action.label, onPressed: action.onAction),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
class ActionIconButtonWidget extends StatelessWidget {
|
||||
final BaseAction action;
|
||||
final ImmichVariant variant;
|
||||
|
||||
const ActionIconButtonWidget({super.key, required this.action, this.variant = .ghost});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => _ActionWidget(
|
||||
action: action,
|
||||
builder: (ctx) => ImmichIconButton(icon: action.icon, onPressed: ctx.onAction, variant: variant),
|
||||
);
|
||||
}
|
||||
|
||||
class ActionButtonWidget extends StatelessWidget {
|
||||
final BaseAction action;
|
||||
final ImmichVariant variant;
|
||||
|
||||
const ActionButtonWidget({super.key, required this.action, this.variant = .ghost});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => _ActionWidget(
|
||||
action: action,
|
||||
builder: (ctx) =>
|
||||
ImmichTextButton(labelText: ctx.label, icon: action.icon, onPressed: ctx.onAction, variant: variant),
|
||||
);
|
||||
}
|
||||
|
||||
class ActionColumnButtonWidget extends StatelessWidget {
|
||||
final BaseAction action;
|
||||
|
||||
const ActionColumnButtonWidget({super.key, required this.action});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => _ActionWidget(
|
||||
action: action,
|
||||
builder: (ctx) => ImmichColumnButton(icon: action.icon, label: ctx.label, onPressed: ctx.onAction),
|
||||
);
|
||||
}
|
||||
|
||||
class ActionMenuItemWidget extends StatelessWidget {
|
||||
final BaseAction action;
|
||||
|
||||
const ActionMenuItemWidget({super.key, required this.action});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => _ActionWidget(
|
||||
action: action,
|
||||
builder: (ctx) => ImmichMenuItem(icon: action.icon, label: ctx.label, onPressed: ctx.onAction),
|
||||
);
|
||||
}
|
||||
|
||||
33
mobile/lib/presentation/actions/asset_action.dart
Normal file
33
mobile/lib/presentation/actions/asset_action.dart
Normal file
@@ -0,0 +1,33 @@
|
||||
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/presentation/actions/action.widget.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
|
||||
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
|
||||
|
||||
typedef AssetActionSelection = ({Set<BaseAsset> assets, void Function() clearSelect});
|
||||
|
||||
final assetsActionProvider = Provider.family<AssetActionSelection, ActionSource>((ref, source) {
|
||||
final assets = switch (source) {
|
||||
ActionSource.timeline => ref.watch(multiSelectProvider.select((s) => s.selectedAssets)),
|
||||
ActionSource.viewer => switch (ref.watch(assetViewerProvider.select((s) => s.currentAsset))) {
|
||||
BaseAsset asset => {asset},
|
||||
null => const <BaseAsset>{},
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
assets: assets,
|
||||
clearSelect: () {
|
||||
if (source == ActionSource.timeline) {
|
||||
ref.read(multiSelectProvider.notifier).reset();
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
abstract class AssetAction extends ActionWidget {
|
||||
final ActionSource source;
|
||||
|
||||
const AssetAction({super.key, super.display, this.source = ActionSource.timeline});
|
||||
}
|
||||
@@ -1,27 +1,30 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:flutter/material.dart' hide Action;
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/generated/translations.g.dart';
|
||||
import 'package:immich_mobile/presentation/actions/action.dart';
|
||||
import 'package:immich_mobile/presentation/actions/asset_action.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/setting.provider.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
|
||||
class AssetDebugAction extends AssetAction<BaseAsset> {
|
||||
const AssetDebugAction({required super.assets});
|
||||
class AssetDebugAction extends AssetAction {
|
||||
const AssetDebugAction({super.key, super.display, super.source});
|
||||
|
||||
@override
|
||||
IconData get icon => Icons.help_outline_rounded;
|
||||
Action resolve(BuildContext context, WidgetRef ref) {
|
||||
final (:assets, :clearSelect) = ref.watch(assetsActionProvider(source));
|
||||
final advancedTroubleshooting = ref.watch(settingsProvider.notifier).get(.advancedTroubleshooting);
|
||||
|
||||
@override
|
||||
String label(ActionScope scope) => scope.context.t.troubleshoot;
|
||||
|
||||
@override
|
||||
bool isVisible(ActionScope scope) =>
|
||||
assets.length == 1 && scope.ref.watch(settingsProvider.notifier).get(.advancedTroubleshooting);
|
||||
|
||||
@override
|
||||
Future<void> onAction(ActionScope scope) async =>
|
||||
unawaited(scope.context.pushRoute(AssetTroubleshootRoute(asset: assets.first)));
|
||||
return Action(
|
||||
icon: Icons.help_outline_rounded,
|
||||
isVisible: assets.length == 1 && advancedTroubleshooting,
|
||||
label: context.t.troubleshoot,
|
||||
onAction: () async {
|
||||
unawaited(context.pushRoute(AssetTroubleshootRoute(asset: assets.first)));
|
||||
clearSelect();
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,38 +1,60 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/material.dart' hide Action;
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/generated/translations.g.dart';
|
||||
import 'package:immich_mobile/presentation/actions/action.dart';
|
||||
import 'package:immich_mobile/presentation/actions/asset_action.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
|
||||
import 'package:immich_mobile/providers/user.provider.dart';
|
||||
import 'package:immich_mobile/utils/asset_filter.dart';
|
||||
import 'package:immich_ui/immich_ui.dart';
|
||||
|
||||
class FavoriteAction extends AssetAction<RemoteAsset> {
|
||||
final bool favorite;
|
||||
|
||||
FavoriteAction({required super.assets}) : favorite = assets.any((asset) => !asset.isFavorite);
|
||||
class FavoriteAction extends AssetAction {
|
||||
const FavoriteAction({super.key, super.display, super.source});
|
||||
|
||||
@override
|
||||
IconData get icon => favorite ? Icons.favorite_border_rounded : Icons.favorite_rounded;
|
||||
Action resolve(BuildContext context, WidgetRef ref) {
|
||||
final (:assets, :clearSelect) = ref.watch(assetsActionProvider(source));
|
||||
final userId = ref.read(currentUserProvider)?.id;
|
||||
final targets = userId == null
|
||||
? const <RemoteAsset>[]
|
||||
: AssetFilter(assets).owned(userId).favorite(isFavorite: false).toList(growable: false);
|
||||
|
||||
@override
|
||||
String label(ActionScope scope) => favorite ? scope.context.t.favorite : scope.context.t.unfavorite;
|
||||
|
||||
@override
|
||||
Iterable<RemoteAsset> filter(ActionScope scope) =>
|
||||
AssetFilter(assets).owned(scope.authUser.id).favorite(isFavorite: !favorite);
|
||||
|
||||
@override
|
||||
bool isVisible(ActionScope scope) => filter(scope).isNotEmpty;
|
||||
|
||||
@override
|
||||
Future<void> onAction(ActionScope scope) async {
|
||||
final ActionScope(:ref) = scope;
|
||||
final assets = filter(scope).map((asset) => asset.id).toList(growable: false);
|
||||
|
||||
await ref.read(assetServiceProvider).updateFavorite(assets, favorite);
|
||||
final message = favorite
|
||||
? StaticTranslations.instance.favorite_action_prompt(count: assets.length)
|
||||
: StaticTranslations.instance.unfavorite_action_prompt(count: assets.length);
|
||||
snackbar.success(message);
|
||||
return Action(
|
||||
icon: Icons.favorite_border_rounded,
|
||||
isVisible: targets.isNotEmpty,
|
||||
label: context.t.favorite,
|
||||
onAction: () async {
|
||||
final ids = targets.map((asset) => asset.id).toList(growable: false);
|
||||
await ref.read(assetServiceProvider).updateFavorite(ids, true);
|
||||
snackbar.success(StaticTranslations.instance.favorite_action_prompt(count: ids.length));
|
||||
clearSelect();
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class UnfavoriteAction extends AssetAction {
|
||||
const UnfavoriteAction({super.key, super.display, super.source});
|
||||
|
||||
@override
|
||||
Action resolve(BuildContext context, WidgetRef ref) {
|
||||
final (:assets, :clearSelect) = ref.watch(assetsActionProvider(source));
|
||||
final userId = ref.read(currentUserProvider)?.id;
|
||||
final targets = userId == null
|
||||
? const <RemoteAsset>[]
|
||||
: AssetFilter(assets).owned(userId).favorite(isFavorite: true).toList(growable: false);
|
||||
|
||||
return Action(
|
||||
icon: Icons.favorite_rounded,
|
||||
isVisible: targets.isNotEmpty,
|
||||
label: context.t.unfavorite,
|
||||
onAction: () async {
|
||||
final ids = targets.map((asset) => asset.id).toList(growable: false);
|
||||
await ref.read(assetServiceProvider).updateFavorite(ids, false);
|
||||
snackbar.success(StaticTranslations.instance.unfavorite_action_prompt(count: ids.length));
|
||||
clearSelect();
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,69 +1,76 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/material.dart' hide Action;
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/domain/models/user.model.dart';
|
||||
import 'package:immich_mobile/generated/translations.g.dart';
|
||||
import 'package:immich_mobile/presentation/actions/action.dart';
|
||||
import 'package:immich_mobile/presentation/actions/action.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/people/partner_user_avatar.widget.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/user.provider.dart';
|
||||
import 'package:immich_mobile/providers/user.provider.dart';
|
||||
import 'package:immich_mobile/widgets/common/confirm_dialog.dart';
|
||||
|
||||
class PartnerAddAction extends BaseAction {
|
||||
const PartnerAddAction();
|
||||
class PartnerAddAction extends ActionWidget {
|
||||
const PartnerAddAction({super.key, super.display});
|
||||
|
||||
@override
|
||||
IconData get icon => Icons.person_add_rounded;
|
||||
Action resolve(BuildContext context, WidgetRef ref) {
|
||||
return Action(
|
||||
icon: Icons.person_add_rounded,
|
||||
label: context.t.add_partner,
|
||||
onAction: () async {
|
||||
final authUser = ref.read(currentUserProvider);
|
||||
if (authUser == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
@override
|
||||
String label(ActionScope scope) => scope.context.t.add_partner;
|
||||
final selected = await showDialog<User>(context: context, builder: (_) => const PartnerSelectionDialog());
|
||||
if (selected == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> onAction(ActionScope scope) async {
|
||||
final ActionScope(:context, :ref, :authUser) = scope;
|
||||
final selected = await showDialog<User>(context: context, builder: (_) => const PartnerSelectionDialog());
|
||||
if (selected == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
await ref.read(partnerServiceProvider).create(sharedById: authUser.id, sharedWithId: selected.id);
|
||||
await ref.read(partnerServiceProvider).create(sharedById: authUser.id, sharedWithId: selected.id);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class PartnerRemoveAction extends BaseAction {
|
||||
const PartnerRemoveAction({required this.sharedWithId, required this.partnerName});
|
||||
|
||||
class PartnerRemoveAction extends ActionWidget {
|
||||
final String sharedWithId;
|
||||
final String partnerName;
|
||||
|
||||
@override
|
||||
IconData get icon => Icons.person_remove_rounded;
|
||||
const PartnerRemoveAction({super.key, super.display, required this.sharedWithId, required this.partnerName});
|
||||
|
||||
@override
|
||||
String label(ActionScope scope) => scope.context.t.remove;
|
||||
Action resolve(BuildContext context, WidgetRef ref) {
|
||||
return Action(
|
||||
icon: Icons.person_remove_rounded,
|
||||
label: context.t.remove,
|
||||
onAction: () async {
|
||||
final authUser = ref.read(currentUserProvider);
|
||||
if (authUser == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> onAction(ActionScope scope) async {
|
||||
final ActionScope(:context, :ref, :authUser) = scope;
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (_) => ConfirmDialog(
|
||||
title: context.t.stop_photo_sharing,
|
||||
content: context.t.partner_page_stop_sharing_content(partner: partnerName),
|
||||
),
|
||||
);
|
||||
if (confirmed != true) {
|
||||
return;
|
||||
}
|
||||
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (_) => ConfirmDialog(
|
||||
title: context.t.stop_photo_sharing,
|
||||
content: context.t.partner_page_stop_sharing_content(partner: partnerName),
|
||||
),
|
||||
await ref.read(partnerServiceProvider).delete(sharedById: authUser.id, sharedWithId: sharedWithId);
|
||||
},
|
||||
);
|
||||
if (confirmed != true) {
|
||||
return;
|
||||
}
|
||||
|
||||
await ref.read(partnerServiceProvider).delete(sharedById: authUser.id, sharedWithId: sharedWithId);
|
||||
}
|
||||
}
|
||||
|
||||
@visibleForTesting
|
||||
final candidatesStateProvider = StreamProvider.autoDispose<Iterable<User>>((ref) {
|
||||
final currentUser = ref.watch(currentUserProvider);
|
||||
// TODO: Refactor with a route guard to avoid this check in every provider
|
||||
if (currentUser == null) {
|
||||
return const Stream.empty();
|
||||
}
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:immich_mobile/presentation/actions/action.dart';
|
||||
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
|
||||
|
||||
class TimelineAction extends BaseAction {
|
||||
final BaseAction action;
|
||||
|
||||
const TimelineAction({required this.action});
|
||||
|
||||
@override
|
||||
IconData get icon => action.icon;
|
||||
|
||||
@override
|
||||
String label(ActionScope scope) => action.label(scope);
|
||||
|
||||
@override
|
||||
bool isVisible(ActionScope scope) => action.isVisible(scope);
|
||||
|
||||
@override
|
||||
Future<void> onAction(ActionScope scope) async {
|
||||
await action.onAction(scope);
|
||||
scope.ref.read(multiSelectProvider.notifier).reset();
|
||||
}
|
||||
}
|
||||
@@ -886,6 +886,7 @@ class _QuickLinkList extends StatelessWidget {
|
||||
_QuickLink(
|
||||
title: context.t.recently_added,
|
||||
icon: Icons.upload_outlined,
|
||||
isTop: true,
|
||||
onTap: () => context.pushRoute(const DriftRecentlyAddedRoute()),
|
||||
),
|
||||
_QuickLink(
|
||||
|
||||
@@ -2,9 +2,10 @@ 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/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/presentation/actions/action.widget.dart';
|
||||
import 'package:immich_mobile/presentation/actions/action.dart';
|
||||
import 'package:immich_mobile/presentation/actions/favorite.action.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/motion_photo_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/viewer_kebab_menu.widget.dart';
|
||||
@@ -43,7 +44,6 @@ class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget {
|
||||
double opacity = ref.watch(assetViewerProvider.select((s) => s.backgroundOpacity)) * (showingControls ? 1 : 0);
|
||||
|
||||
final originalTheme = context.themeData;
|
||||
final assetForAction = [asset];
|
||||
|
||||
final actions = <Widget>[
|
||||
if (asset.isMotionPhoto) const MotionPhotoActionButton(iconOnly: true),
|
||||
@@ -61,7 +61,8 @@ class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget {
|
||||
},
|
||||
),
|
||||
|
||||
ActionIconButtonWidget(action: FavoriteAction(assets: assetForAction)),
|
||||
const FavoriteAction(source: ActionSource.viewer, display: ActionDisplay.iconButton),
|
||||
const UnfavoriteAction(source: ActionSource.viewer, display: ActionDisplay.iconButton),
|
||||
|
||||
ImmichColorOverride(color: null, child: ViewerKebabMenu(originalTheme: originalTheme)),
|
||||
];
|
||||
|
||||
@@ -3,9 +3,8 @@ 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/presentation/actions/action.widget.dart';
|
||||
import 'package:immich_mobile/presentation/actions/action.dart';
|
||||
import 'package:immich_mobile/presentation/actions/favorite.action.dart';
|
||||
import 'package:immich_mobile/presentation/actions/timeline.action.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/download_action_button.widget.dart';
|
||||
@@ -76,9 +75,6 @@ class _ArchiveBottomSheetState extends ConsumerState<ArchiveBottomSheet> {
|
||||
return sheetController.animateTo(0.85, duration: const Duration(milliseconds: 200), curve: Curves.easeInOut);
|
||||
}
|
||||
|
||||
final assets = multiselect.selectedAssets.toList(growable: false);
|
||||
final actions = [FavoriteAction(assets: assets)];
|
||||
|
||||
return BaseBottomSheet(
|
||||
controller: sheetController,
|
||||
initialChildSize: 0.25,
|
||||
@@ -89,7 +85,7 @@ class _ArchiveBottomSheetState extends ConsumerState<ArchiveBottomSheet> {
|
||||
if (multiselect.hasRemote) ...[
|
||||
const ShareLinkActionButton(source: ActionSource.timeline),
|
||||
const UnArchiveActionButton(source: ActionSource.timeline),
|
||||
...actions.map((action) => ActionColumnButtonWidget(action: TimelineAction(action: action))),
|
||||
const FavoriteAction(source: ActionSource.timeline, display: ActionDisplay.columnButton),
|
||||
if (multiselect.onlyRemote) const DownloadActionButton(source: ActionSource.timeline),
|
||||
isTrashEnable
|
||||
? const TrashActionButton(source: ActionSource.timeline)
|
||||
|
||||
@@ -4,9 +4,8 @@ import 'package:immich_mobile/constants/enums.dart';
|
||||
import 'package:immich_mobile/domain/models/album/album.model.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||
import 'package:immich_mobile/presentation/actions/action.widget.dart';
|
||||
import 'package:immich_mobile/presentation/actions/action.dart';
|
||||
import 'package:immich_mobile/presentation/actions/favorite.action.dart';
|
||||
import 'package:immich_mobile/presentation/actions/timeline.action.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/archive_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_local_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_permanent_action_button.widget.dart';
|
||||
@@ -74,9 +73,6 @@ class FavoriteBottomSheet extends ConsumerWidget {
|
||||
ref.read(multiSelectProvider.notifier).reset();
|
||||
}
|
||||
|
||||
final assets = multiselect.selectedAssets.toList(growable: false);
|
||||
final actions = [FavoriteAction(assets: assets)];
|
||||
|
||||
return BaseBottomSheet(
|
||||
initialChildSize: 0.4,
|
||||
maxChildSize: 0.7,
|
||||
@@ -85,7 +81,7 @@ class FavoriteBottomSheet extends ConsumerWidget {
|
||||
const ShareActionButton(source: ActionSource.timeline),
|
||||
if (multiselect.hasRemote) ...[
|
||||
const ShareLinkActionButton(source: ActionSource.timeline),
|
||||
...actions.map((action) => ActionColumnButtonWidget(action: TimelineAction(action: action))),
|
||||
const FavoriteAction(source: ActionSource.timeline, display: ActionDisplay.columnButton),
|
||||
const ArchiveActionButton(source: ActionSource.timeline),
|
||||
if (multiselect.onlyRemote) const DownloadActionButton(source: ActionSource.timeline),
|
||||
isTrashEnable
|
||||
|
||||
@@ -3,9 +3,8 @@ 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/presentation/actions/action.widget.dart';
|
||||
import 'package:immich_mobile/presentation/actions/action.dart';
|
||||
import 'package:immich_mobile/presentation/actions/asset_debug.action.dart';
|
||||
import 'package:immich_mobile/presentation/actions/timeline.action.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/archive_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/bulk_tag_assets_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_action_button.widget.dart';
|
||||
@@ -83,9 +82,6 @@ class _GeneralBottomSheetState extends ConsumerState<GeneralBottomSheet> {
|
||||
return sheetController.animateTo(0.85, duration: const Duration(milliseconds: 200), curve: Curves.easeInOut);
|
||||
}
|
||||
|
||||
final assets = multiselect.selectedAssets.toList(growable: false);
|
||||
final actions = [AssetDebugAction(assets: assets)];
|
||||
|
||||
return BaseBottomSheet(
|
||||
controller: sheetController,
|
||||
initialChildSize: widget.minChildSize ?? 0.15,
|
||||
@@ -93,7 +89,7 @@ class _GeneralBottomSheetState extends ConsumerState<GeneralBottomSheet> {
|
||||
maxChildSize: 0.85,
|
||||
shouldCloseOnMinExtent: false,
|
||||
actions: [
|
||||
...actions.map((action) => ActionColumnButtonWidget(action: TimelineAction(action: action))),
|
||||
const AssetDebugAction(source: ActionSource.timeline, display: ActionDisplay.columnButton),
|
||||
const ShareActionButton(source: ActionSource.timeline),
|
||||
if (multiselect.hasRemote) ...[
|
||||
const ShareLinkActionButton(source: ActionSource.timeline),
|
||||
|
||||
@@ -3,9 +3,8 @@ 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/extensions/translate_extensions.dart';
|
||||
import 'package:immich_mobile/presentation/actions/action.widget.dart';
|
||||
import 'package:immich_mobile/presentation/actions/action.dart';
|
||||
import 'package:immich_mobile/presentation/actions/favorite.action.dart';
|
||||
import 'package:immich_mobile/presentation/actions/timeline.action.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/archive_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_local_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_permanent_action_button.widget.dart';
|
||||
@@ -85,9 +84,6 @@ class _RemoteAlbumBottomSheetState extends ConsumerState<RemoteAlbumBottomSheet>
|
||||
return sheetController.animateTo(0.85, duration: const Duration(milliseconds: 200), curve: Curves.easeInOut);
|
||||
}
|
||||
|
||||
final assets = multiselect.selectedAssets.toList(growable: false);
|
||||
final actions = [FavoriteAction(assets: assets)];
|
||||
|
||||
return BaseBottomSheet(
|
||||
controller: sheetController,
|
||||
initialChildSize: 0.22,
|
||||
@@ -101,7 +97,7 @@ class _RemoteAlbumBottomSheetState extends ConsumerState<RemoteAlbumBottomSheet>
|
||||
|
||||
if (ownsAlbum) ...[
|
||||
const ArchiveActionButton(source: ActionSource.timeline),
|
||||
...actions.map((action) => ActionColumnButtonWidget(action: TimelineAction(action: action))),
|
||||
const FavoriteAction(source: ActionSource.timeline, display: ActionDisplay.columnButton),
|
||||
],
|
||||
const DownloadActionButton(source: ActionSource.timeline),
|
||||
if (ownsAlbum) ...[
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
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';
|
||||
import 'package:immich_mobile/providers/infrastructure/settings.provider.dart';
|
||||
import 'package:immich_mobile/providers/map/map_state.provider.dart';
|
||||
import 'package:immich_mobile/utils/option.dart';
|
||||
import 'package:maplibre_gl/maplibre_gl.dart';
|
||||
|
||||
class MapState {
|
||||
@@ -17,7 +15,6 @@ class MapState {
|
||||
final bool includeArchived;
|
||||
final bool withPartners;
|
||||
final int relativeDays;
|
||||
final TimeRange timeRange;
|
||||
|
||||
const MapState({
|
||||
this.themeMode = ThemeMode.system,
|
||||
@@ -26,7 +23,6 @@ class MapState {
|
||||
this.includeArchived = false,
|
||||
this.withPartners = false,
|
||||
this.relativeDays = 0,
|
||||
this.timeRange = const TimeRange(),
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -44,7 +40,6 @@ class MapState {
|
||||
bool? includeArchived,
|
||||
bool? withPartners,
|
||||
int? relativeDays,
|
||||
TimeRange? timeRange,
|
||||
}) {
|
||||
return MapState(
|
||||
bounds: bounds ?? this.bounds,
|
||||
@@ -53,7 +48,6 @@ class MapState {
|
||||
includeArchived: includeArchived ?? this.includeArchived,
|
||||
withPartners: withPartners ?? this.withPartners,
|
||||
relativeDays: relativeDays ?? this.relativeDays,
|
||||
timeRange: timeRange ?? this.timeRange,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -63,7 +57,6 @@ class MapState {
|
||||
includeArchived: includeArchived,
|
||||
withPartners: withPartners,
|
||||
relativeDays: relativeDays,
|
||||
timeRange: timeRange,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -110,24 +103,6 @@ class MapStateNotifier extends Notifier<MapState> {
|
||||
EventStream.shared.emit(const MapMarkerReloadEvent());
|
||||
}
|
||||
|
||||
void setCustomTimeRange(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());
|
||||
}
|
||||
|
||||
Option<DateTime> parseDateOption(String s) {
|
||||
try {
|
||||
if (s.trim().isEmpty) {
|
||||
return const Option.none();
|
||||
}
|
||||
return Option.some(DateTime.parse(s));
|
||||
} catch (_) {
|
||||
return const Option.none();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
MapState build() {
|
||||
final mapConfig = ref.read(appConfigProvider.select((config) => config.map));
|
||||
@@ -138,7 +113,6 @@ class MapStateNotifier extends Notifier<MapState> {
|
||||
withPartners: mapConfig.withPartners,
|
||||
relativeDays: mapConfig.relativeDays,
|
||||
bounds: LatLngBounds(northeast: const LatLng(0, 0), southwest: const LatLng(0, 0)),
|
||||
timeRange: TimeRange(from: mapConfig.customFrom, to: mapConfig.customTo),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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).setCustomTimeRange(range);
|
||||
},
|
||||
),
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: TextButton(
|
||||
onPressed: () => setState(() {
|
||||
useCustomRange = false;
|
||||
ref.read(mapStateProvider.notifier).setRelativeTime(0);
|
||||
ref.read(mapStateProvider.notifier).setCustomTimeRange(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).setCustomTimeRange(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),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -65,7 +65,6 @@ class _DriftPersonNameEditFormState extends ConsumerState<DriftPersonBirthdayEdi
|
||||
child: ClipRRect(
|
||||
borderRadius: const BorderRadius.all(Radius.circular(16.0)),
|
||||
child: ScrollDatePicker(
|
||||
viewType: datePickerColumnOrder(DateFormat.yMd(context.locale.toLanguageTag()).pattern),
|
||||
options: DatePickerOptions(
|
||||
backgroundColor: context.colorScheme.surfaceContainerHigh,
|
||||
itemExtent: 50,
|
||||
@@ -119,18 +118,3 @@ class _DriftPersonNameEditFormState extends ConsumerState<DriftPersonBirthdayEdi
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
List<DatePickerViewType>? datePickerColumnOrder(String? pattern) {
|
||||
if (pattern == null) {
|
||||
return null;
|
||||
}
|
||||
final positions = {
|
||||
DatePickerViewType.year: pattern.indexOf('y'),
|
||||
DatePickerViewType.month: pattern.indexOf('M'),
|
||||
DatePickerViewType.day: pattern.indexOf('d'),
|
||||
};
|
||||
if (positions.values.any((position) => position < 0)) {
|
||||
return null;
|
||||
}
|
||||
return positions.keys.toList()..sort((a, b) => positions[a]!.compareTo(positions[b]!));
|
||||
}
|
||||
|
||||
@@ -1,17 +1,19 @@
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/models/cast/cast_manager_state.dart';
|
||||
import 'package:immich_mobile/services/cast.service.dart';
|
||||
import 'package:immich_mobile/services/gcast.service.dart';
|
||||
|
||||
final castProvider = StateNotifierProvider<CastNotifier, CastManagerState>(
|
||||
(ref) => CastNotifier(ref.watch(castServiceProvider)),
|
||||
(ref) => CastNotifier(ref.watch(gCastServiceProvider)),
|
||||
);
|
||||
|
||||
class CastNotifier extends StateNotifier<CastManagerState> {
|
||||
// more cast providers can be added here (ie Fcast)
|
||||
final CastService _castService;
|
||||
final GCastService _gCastService;
|
||||
|
||||
CastNotifier(this._castService)
|
||||
List<(String, CastDestinationType, dynamic)> discovered = List.empty();
|
||||
|
||||
CastNotifier(this._gCastService)
|
||||
: super(
|
||||
const CastManagerState(
|
||||
isCasting: false,
|
||||
@@ -21,11 +23,11 @@ class CastNotifier extends StateNotifier<CastManagerState> {
|
||||
castState: CastState.idle,
|
||||
),
|
||||
) {
|
||||
_castService.onConnectionState = _onConnectionState;
|
||||
_castService.onCurrentTime = _onCurrentTime;
|
||||
_castService.onDuration = _onDuration;
|
||||
_castService.onReceiverName = _onReceiverName;
|
||||
_castService.onCastState = _onCastState;
|
||||
_gCastService.onConnectionState = _onConnectionState;
|
||||
_gCastService.onCurrentTime = _onCurrentTime;
|
||||
_gCastService.onDuration = _onDuration;
|
||||
_gCastService.onReceiverName = _onReceiverName;
|
||||
_gCastService.onCastState = _onCastState;
|
||||
}
|
||||
|
||||
void _onConnectionState(bool isCasting) {
|
||||
@@ -49,15 +51,23 @@ class CastNotifier extends StateNotifier<CastManagerState> {
|
||||
}
|
||||
|
||||
void loadMedia(RemoteAsset asset, bool reload) {
|
||||
_castService.loadMedia(asset, reload);
|
||||
_gCastService.loadMedia(asset, reload);
|
||||
}
|
||||
|
||||
Future<void> connect(dynamic device) async {
|
||||
await _castService.connect(device);
|
||||
Future<void> connect(CastDestinationType type, dynamic device) async {
|
||||
switch (type) {
|
||||
case CastDestinationType.googleCast:
|
||||
await _gCastService.connect(device);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
Future<List<(String, CastDestinationType, dynamic)>> getDevices() {
|
||||
return _castService.getDevices();
|
||||
Future<List<(String, CastDestinationType, dynamic)>> getDevices() async {
|
||||
if (discovered.isEmpty) {
|
||||
discovered = await _gCastService.getDevices();
|
||||
}
|
||||
|
||||
return discovered;
|
||||
}
|
||||
|
||||
void toggle() {
|
||||
@@ -71,22 +81,22 @@ class CastNotifier extends StateNotifier<CastManagerState> {
|
||||
}
|
||||
|
||||
void play() {
|
||||
_castService.play();
|
||||
_gCastService.play();
|
||||
}
|
||||
|
||||
void pause() {
|
||||
_castService.pause();
|
||||
_gCastService.pause();
|
||||
}
|
||||
|
||||
void seekTo(Duration position) {
|
||||
_castService.seekTo(position);
|
||||
_gCastService.seekTo(position);
|
||||
}
|
||||
|
||||
void stop() {
|
||||
_castService.stop();
|
||||
_gCastService.stop();
|
||||
}
|
||||
|
||||
Future<void> disconnect() async {
|
||||
await _castService.disconnect();
|
||||
await _gCastService.disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/repositories/toast.repository.dart';
|
||||
|
||||
final toastRepositoryProvider = Provider<ToastRepository>((ref) => const .new());
|
||||
@@ -1,14 +1,12 @@
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:http/http.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/constants/enums.dart';
|
||||
import 'package:immich_mobile/domain/models/asset_edit.model.dart' hide AssetEditAction;
|
||||
import 'package:immich_mobile/domain/models/stack.model.dart';
|
||||
import 'package:immich_mobile/providers/api.provider.dart';
|
||||
import 'package:immich_mobile/repositories/api.repository.dart';
|
||||
import 'package:immich_mobile/utils/option.dart';
|
||||
import 'package:maplibre_gl/maplibre_gl.dart';
|
||||
import 'package:openapi/api.dart' as api show AssetVisibility;
|
||||
import 'package:openapi/api.dart' hide AssetVisibility;
|
||||
import 'package:openapi/api.dart';
|
||||
|
||||
final assetApiRepositoryProvider = Provider(
|
||||
(ref) => AssetApiRepository(
|
||||
@@ -43,7 +41,7 @@ class AssetApiRepository extends ApiRepository {
|
||||
return response?.count ?? 0;
|
||||
}
|
||||
|
||||
Future<void> updateVisibility(List<String> ids, AssetVisibility visibility) async {
|
||||
Future<void> updateVisibility(List<String> ids, AssetVisibilityEnum visibility) async {
|
||||
return _api.updateAssets(AssetBulkUpdateDto(ids: ids, visibility: Optional.present(_mapVisibility(visibility))));
|
||||
}
|
||||
|
||||
@@ -79,11 +77,11 @@ class AssetApiRepository extends ApiRepository {
|
||||
return _api.downloadAssetWithHttpInfo(id, edited: edited);
|
||||
}
|
||||
|
||||
api.AssetVisibility _mapVisibility(AssetVisibility visibility) => switch (visibility) {
|
||||
AssetVisibility.timeline => api.AssetVisibility.timeline,
|
||||
AssetVisibility.hidden => api.AssetVisibility.hidden,
|
||||
AssetVisibility.locked => api.AssetVisibility.locked,
|
||||
AssetVisibility.archive => api.AssetVisibility.archive,
|
||||
_mapVisibility(AssetVisibilityEnum visibility) => switch (visibility) {
|
||||
AssetVisibilityEnum.timeline => AssetVisibility.timeline,
|
||||
AssetVisibilityEnum.hidden => AssetVisibility.hidden,
|
||||
AssetVisibilityEnum.locked => AssetVisibility.locked,
|
||||
AssetVisibilityEnum.archive => AssetVisibility.archive,
|
||||
};
|
||||
|
||||
Future<String?> getAssetMIMEType(String assetId) async {
|
||||
@@ -108,20 +106,6 @@ class AssetApiRepository extends ApiRepository {
|
||||
Future<void> removeEdits(String assetId) async {
|
||||
return _api.removeAssetEdits(assetId);
|
||||
}
|
||||
|
||||
Future<void> update(
|
||||
List<String> remoteIds, {
|
||||
Option<bool> isFavorite = const .none(),
|
||||
Option<AssetVisibility> visibility = const .none(),
|
||||
}) {
|
||||
return _api.updateAssets(
|
||||
AssetBulkUpdateDto(
|
||||
ids: remoteIds,
|
||||
isFavorite: isFavorite.toOptional(),
|
||||
visibility: visibility.map(_mapVisibility).toOptional(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
extension on StackResponseDto {
|
||||
|
||||
@@ -1,99 +0,0 @@
|
||||
import 'dart:async';
|
||||
import 'package:fcast_sender_sdk/fcast_sender_sdk.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
|
||||
final castRepositoryProvider = Provider((_) => CastRepository());
|
||||
|
||||
class CastRepository {
|
||||
CastContext? _castContext;
|
||||
CastingDevice? _device;
|
||||
|
||||
void Function(DeviceConnectionState)? onConnectionState;
|
||||
void Function(DeviceEvent)? onDeviceEvent;
|
||||
|
||||
final Map<(String, ProtocolType), (DeviceInfo, int?)> _discoveredDevices = {};
|
||||
int _currentDeviceGeneration = 0;
|
||||
Future<void>? _initialized;
|
||||
|
||||
Future<void> connect(DeviceInfo deviceInfo) async {
|
||||
await _ensureInitialized();
|
||||
|
||||
_device?.disconnect();
|
||||
final device = _castContext!.createDeviceFromInfo(info: deviceInfo);
|
||||
_device = device;
|
||||
|
||||
final thisDeviceGeneration = ++_currentDeviceGeneration;
|
||||
device.connect(
|
||||
eventHandler: DeviceEventHandler(
|
||||
onEvent: (event) {
|
||||
if (thisDeviceGeneration != _currentDeviceGeneration) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (event is DeviceEvent_ConnectionStateChanged) {
|
||||
onConnectionState?.call(event.newState);
|
||||
}
|
||||
|
||||
onDeviceEvent?.call(event);
|
||||
},
|
||||
),
|
||||
reconnectIntervalMillis: 1000,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> disconnect() async {
|
||||
final device = _device;
|
||||
if (device == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
_device = null;
|
||||
_currentDeviceGeneration++;
|
||||
|
||||
if (device.isReady()) {
|
||||
device.stopPlayback();
|
||||
|
||||
await Future.delayed(const Duration(milliseconds: 500));
|
||||
}
|
||||
|
||||
device.disconnect();
|
||||
onConnectionState?.call(const DeviceConnectionState.disconnected());
|
||||
}
|
||||
|
||||
void loadMedia(LoadRequest request) => _device?.load(request: request);
|
||||
void play() => _device?.resumePlayback();
|
||||
void pause() => _device?.pausePlayback();
|
||||
void stop() => _device?.stopPlayback();
|
||||
void seekTo(Duration position) => _device?.seek(timeSeconds: position.inMilliseconds / 1000);
|
||||
|
||||
Future<List<(DeviceInfo, int?)>> listDestinations() async {
|
||||
final isFirstScan = _initialized == null;
|
||||
await _ensureInitialized();
|
||||
|
||||
if (isFirstScan) {
|
||||
await Future.delayed(const Duration(seconds: 3));
|
||||
}
|
||||
|
||||
return _discoveredDevices.values.toList(growable: false);
|
||||
}
|
||||
|
||||
Future<void> _ensureInitialized() => _initialized ??= _initialize();
|
||||
|
||||
Future<void> _initialize() async {
|
||||
await FCastSenderSdkLib.init();
|
||||
_castContext = CastContext();
|
||||
|
||||
final discoverer = DeviceDiscoverer();
|
||||
discoverer.eventStreamController.stream.listen((event) {
|
||||
switch (event) {
|
||||
case DiscoveryEventDeviceAdded(:final deviceInfo, :final gcastCaps) ||
|
||||
DiscoveryEventDeviceUpdated(:final deviceInfo, :final gcastCaps):
|
||||
_discoveredDevices[(deviceInfo.name, deviceInfo.protocol)] = (deviceInfo, gcastCaps);
|
||||
case DiscoveryEventDeviceRemoved():
|
||||
_discoveredDevices.removeWhere((key, _) => key.$1 == event.name);
|
||||
}
|
||||
});
|
||||
|
||||
await discoverer.init();
|
||||
}
|
||||
}
|
||||
68
mobile/lib/repositories/gcast.repository.dart
Normal file
68
mobile/lib/repositories/gcast.repository.dart
Normal file
@@ -0,0 +1,68 @@
|
||||
import 'package:cast/device.dart';
|
||||
import 'package:cast/session.dart';
|
||||
import 'package:cast/session_manager.dart';
|
||||
import 'package:cast/discovery_service.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
|
||||
final gCastRepositoryProvider = Provider((_) {
|
||||
return GCastRepository();
|
||||
});
|
||||
|
||||
class GCastRepository {
|
||||
CastSession? _castSession;
|
||||
|
||||
void Function(CastSessionState)? onCastStatus;
|
||||
void Function(Map<String, dynamic>)? onCastMessage;
|
||||
|
||||
Map<String, dynamic>? _receiverStatus;
|
||||
|
||||
GCastRepository();
|
||||
|
||||
Future<void> connect(CastDevice device) async {
|
||||
_castSession = await CastSessionManager().startSession(device);
|
||||
|
||||
_castSession?.stateStream.listen((state) {
|
||||
onCastStatus?.call(state);
|
||||
});
|
||||
|
||||
_castSession?.messageStream.listen((message) {
|
||||
onCastMessage?.call(message);
|
||||
if (message['type'] == 'RECEIVER_STATUS') {
|
||||
_receiverStatus = message;
|
||||
}
|
||||
});
|
||||
|
||||
// open the default receiver
|
||||
sendMessage(CastSession.kNamespaceReceiver, {'type': 'LAUNCH', 'appId': 'CC1AD845'});
|
||||
}
|
||||
|
||||
Future<void> disconnect() async {
|
||||
final sessionID = getSessionId();
|
||||
|
||||
sendMessage(CastSession.kNamespaceReceiver, {'type': "STOP", "sessionId": sessionID});
|
||||
|
||||
// wait 500ms to ensure the stop command is processed
|
||||
await Future.delayed(const Duration(milliseconds: 500));
|
||||
|
||||
await _castSession?.close();
|
||||
}
|
||||
|
||||
String? getSessionId() {
|
||||
if (_receiverStatus == null) {
|
||||
return null;
|
||||
}
|
||||
return _receiverStatus!['status']['applications'][0]['sessionId'];
|
||||
}
|
||||
|
||||
void sendMessage(String namespace, Map<String, dynamic> message) {
|
||||
if (_castSession == null) {
|
||||
throw Exception("Cast session is not established");
|
||||
}
|
||||
|
||||
_castSession!.sendMessage(namespace, message);
|
||||
}
|
||||
|
||||
Future<List<CastDevice>> listDestinations() async {
|
||||
return await CastDiscoveryService().search(timeout: const Duration(seconds: 3));
|
||||
}
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:immich_ui/immich_ui.dart';
|
||||
|
||||
class ToastOption {
|
||||
final Duration? timeout;
|
||||
final FutureOr<void> Function()? onUndo;
|
||||
|
||||
const ToastOption({this.timeout, this.onUndo});
|
||||
}
|
||||
|
||||
class ToastRepository {
|
||||
const ToastRepository();
|
||||
|
||||
FutureOr<void> success(String message, {ToastOption? toast}) {
|
||||
snackbar.success(message, duration: toast?.timeout);
|
||||
}
|
||||
|
||||
FutureOr<void> info(String message, {ToastOption? toast}) {
|
||||
snackbar.info(message, duration: toast?.timeout);
|
||||
}
|
||||
|
||||
FutureOr<void> error(String message, {ToastOption? toast}) {
|
||||
snackbar.error(message, duration: toast?.timeout);
|
||||
}
|
||||
}
|
||||
@@ -79,17 +79,17 @@ class ActionService {
|
||||
}
|
||||
|
||||
Future<void> archive(List<String> remoteIds) async {
|
||||
await _assetApiRepository.updateVisibility(remoteIds, .archive);
|
||||
await _assetApiRepository.updateVisibility(remoteIds, AssetVisibilityEnum.archive);
|
||||
await _remoteAssetRepository.updateVisibility(remoteIds, AssetVisibility.archive);
|
||||
}
|
||||
|
||||
Future<void> unArchive(List<String> remoteIds) async {
|
||||
await _assetApiRepository.updateVisibility(remoteIds, .timeline);
|
||||
await _assetApiRepository.updateVisibility(remoteIds, AssetVisibilityEnum.timeline);
|
||||
await _remoteAssetRepository.updateVisibility(remoteIds, AssetVisibility.timeline);
|
||||
}
|
||||
|
||||
Future<void> moveToLockFolder(List<String> remoteIds, List<String> localIds) async {
|
||||
await _assetApiRepository.updateVisibility(remoteIds, .locked);
|
||||
await _assetApiRepository.updateVisibility(remoteIds, AssetVisibilityEnum.locked);
|
||||
await _remoteAssetRepository.updateVisibility(remoteIds, AssetVisibility.locked);
|
||||
|
||||
// Ask user if they want to delete local copies
|
||||
@@ -99,7 +99,7 @@ class ActionService {
|
||||
}
|
||||
|
||||
Future<void> removeFromLockFolder(List<String> remoteIds) async {
|
||||
await _assetApiRepository.updateVisibility(remoteIds, .timeline);
|
||||
await _assetApiRepository.updateVisibility(remoteIds, AssetVisibilityEnum.timeline);
|
||||
await _remoteAssetRepository.updateVisibility(remoteIds, AssetVisibility.timeline);
|
||||
}
|
||||
|
||||
|
||||
@@ -96,12 +96,12 @@ class ApiService {
|
||||
/// port - optional (default: based on schema)
|
||||
/// path - optional
|
||||
Future<String> resolveEndpoint(String serverUrl) async {
|
||||
String url = normalizeServerUrl(serverUrl);
|
||||
String url = sanitizeUrl(serverUrl);
|
||||
|
||||
// Check for /.well-known/immich
|
||||
final wellKnownEndpoint = await _getWellKnownEndpoint(url);
|
||||
if (wellKnownEndpoint.isNotEmpty) {
|
||||
url = normalizeServerUrl(wellKnownEndpoint);
|
||||
url = sanitizeUrl(wellKnownEndpoint);
|
||||
}
|
||||
|
||||
if (!await _isEndpointAvailable(url)) {
|
||||
|
||||
@@ -1,205 +0,0 @@
|
||||
import 'package:fcast_sender_sdk/fcast_sender_sdk.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/models/cast/cast_manager_state.dart';
|
||||
import 'package:immich_mobile/models/sessions/session_create_response.model.dart';
|
||||
import 'package:immich_mobile/repositories/asset_api.repository.dart';
|
||||
import 'package:immich_mobile/repositories/cast.repository.dart';
|
||||
import 'package:immich_mobile/repositories/sessions_api.repository.dart';
|
||||
import 'package:immich_mobile/utils/image_url_builder.dart';
|
||||
// ignore: import_rule_openapi, we are only using the AssetMediaSize enum
|
||||
import 'package:openapi/api.dart';
|
||||
|
||||
final castServiceProvider = Provider(
|
||||
(ref) => CastService(
|
||||
ref.watch(castRepositoryProvider),
|
||||
ref.watch(sessionsAPIRepositoryProvider),
|
||||
ref.watch(assetApiRepositoryProvider),
|
||||
),
|
||||
);
|
||||
|
||||
class CastService {
|
||||
final CastRepository _castRepository;
|
||||
final SessionsAPIRepository _sessionsApiService;
|
||||
final AssetApiRepository _assetApiRepository;
|
||||
|
||||
SessionCreateResponse? sessionKey;
|
||||
String? currentAssetId;
|
||||
bool isConnected = false;
|
||||
|
||||
void Function(bool)? onConnectionState;
|
||||
|
||||
void Function(Duration)? onCurrentTime;
|
||||
|
||||
void Function(Duration)? onDuration;
|
||||
|
||||
void Function(String)? onReceiverName;
|
||||
|
||||
void Function(CastState)? onCastState;
|
||||
|
||||
CastService(this._castRepository, this._sessionsApiService, this._assetApiRepository) {
|
||||
_castRepository.onConnectionState = _onCastStatusCallback;
|
||||
_castRepository.onDeviceEvent = _onDeviceEventCallback;
|
||||
}
|
||||
|
||||
void _onCastStatusCallback(DeviceConnectionState state) {
|
||||
if (state is DeviceConnectionState_Connected) {
|
||||
onConnectionState?.call(true);
|
||||
isConnected = true;
|
||||
} else if (state is DeviceConnectionState_Disconnected) {
|
||||
onConnectionState?.call(false);
|
||||
isConnected = false;
|
||||
onReceiverName?.call("");
|
||||
currentAssetId = null;
|
||||
}
|
||||
}
|
||||
|
||||
void _onDeviceEventCallback(DeviceEvent event) {
|
||||
switch (event) {
|
||||
case DeviceEvent_PlaybackStateChanged():
|
||||
_handlePlaybackState(event.newPlaybackState);
|
||||
break;
|
||||
case DeviceEvent_TimeChanged():
|
||||
onCurrentTime?.call(Duration(milliseconds: (event.newTime * 1000).toInt()));
|
||||
break;
|
||||
case DeviceEvent_DurationChanged():
|
||||
onDuration?.call(Duration(milliseconds: (event.newDuration * 1000).toInt()));
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
void _handlePlaybackState(PlaybackState state) {
|
||||
switch (state) {
|
||||
case PlaybackState.playing:
|
||||
onCastState?.call(CastState.playing);
|
||||
break;
|
||||
case PlaybackState.paused:
|
||||
onCastState?.call(CastState.paused);
|
||||
break;
|
||||
case PlaybackState.buffering:
|
||||
onCastState?.call(CastState.buffering);
|
||||
break;
|
||||
case PlaybackState.idle:
|
||||
onCastState?.call(CastState.idle);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> connect(dynamic device) async {
|
||||
await _castRepository.connect(device);
|
||||
|
||||
onReceiverName?.call(device.name);
|
||||
}
|
||||
|
||||
Future<void> disconnect() async {
|
||||
onReceiverName?.call("");
|
||||
currentAssetId = null;
|
||||
await _castRepository.disconnect();
|
||||
}
|
||||
|
||||
bool isSessionValid() {
|
||||
// check if we already have a session token
|
||||
// we should always have a expiration date
|
||||
if (sessionKey == null || sessionKey?.expiresAt == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
final tokenExpiration = DateTime.parse(sessionKey!.expiresAt!);
|
||||
|
||||
// we want to make sure we have at least 10 seconds remaining in the session
|
||||
// this is to account for network latency and other delays when sending the request
|
||||
final bufferedExpiration = tokenExpiration.subtract(const Duration(seconds: 10));
|
||||
|
||||
return bufferedExpiration.isAfter(DateTime.now());
|
||||
}
|
||||
|
||||
void loadMedia(RemoteAsset asset, bool reload) async {
|
||||
if (!isConnected) {
|
||||
return;
|
||||
} else if (asset.id == currentAssetId && !reload) {
|
||||
return;
|
||||
}
|
||||
|
||||
// create a session key
|
||||
if (!isSessionValid()) {
|
||||
sessionKey = await _sessionsApiService.createSession(
|
||||
"Cast",
|
||||
"Cast",
|
||||
duration: const Duration(minutes: 15).inSeconds,
|
||||
);
|
||||
}
|
||||
|
||||
final unauthenticatedUrl = asset.isVideo
|
||||
? getPlaybackUrlForRemoteId(asset.id)
|
||||
: getThumbnailUrlForRemoteId(asset.id, type: AssetMediaSize.fullsize);
|
||||
|
||||
final authenticatedURL = "$unauthenticatedUrl&sessionKey=${sessionKey?.token}";
|
||||
|
||||
// get image mime type
|
||||
final mimeType = await _assetApiRepository.getAssetMIMEType(asset.id);
|
||||
|
||||
if (mimeType == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
final request = asset.isVideo
|
||||
? LoadRequest.video(contentType: mimeType, url: authenticatedURL, resumePosition: 0.0)
|
||||
: LoadRequest.image(contentType: mimeType, url: authenticatedURL);
|
||||
|
||||
_castRepository.loadMedia(request);
|
||||
|
||||
currentAssetId = asset.id;
|
||||
}
|
||||
|
||||
void play() {
|
||||
_castRepository.play();
|
||||
}
|
||||
|
||||
void pause() {
|
||||
_castRepository.pause();
|
||||
}
|
||||
|
||||
void seekTo(Duration position) {
|
||||
_castRepository.seekTo(position);
|
||||
}
|
||||
|
||||
void stop() {
|
||||
_castRepository.stop();
|
||||
|
||||
currentAssetId = null;
|
||||
}
|
||||
|
||||
// 0x01 is display capability bitmask
|
||||
bool isDisplay(int ca) => (ca & 0x01) != 0;
|
||||
|
||||
Future<List<(String, CastDestinationType, dynamic)>> getDevices() async {
|
||||
final dests = await _castRepository.listDestinations();
|
||||
|
||||
final fCastNames = dests
|
||||
.where((dest) => dest.$1.protocol == ProtocolType.fCast)
|
||||
.map((dest) => dest.$1.name)
|
||||
.toSet();
|
||||
|
||||
return dests
|
||||
.where((dest) {
|
||||
final (device, gcastCaps) = dest;
|
||||
|
||||
if (device.protocol == ProtocolType.fCast) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return isDisplay(gcastCaps ?? 0) && !fCastNames.contains(device.name);
|
||||
})
|
||||
.map((dest) {
|
||||
final device = dest.$1;
|
||||
final type = device.protocol == ProtocolType.fCast
|
||||
? CastDestinationType.fCast
|
||||
: CastDestinationType.googleCast;
|
||||
|
||||
return (device.name, type, device as dynamic);
|
||||
})
|
||||
.toList(growable: false);
|
||||
}
|
||||
}
|
||||
250
mobile/lib/services/gcast.service.dart
Normal file
250
mobile/lib/services/gcast.service.dart
Normal file
@@ -0,0 +1,250 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:cast/session.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/models/cast/cast_manager_state.dart';
|
||||
import 'package:immich_mobile/models/sessions/session_create_response.model.dart';
|
||||
import 'package:immich_mobile/repositories/asset_api.repository.dart';
|
||||
import 'package:immich_mobile/repositories/gcast.repository.dart';
|
||||
import 'package:immich_mobile/repositories/sessions_api.repository.dart';
|
||||
import 'package:immich_mobile/utils/image_url_builder.dart';
|
||||
// ignore: import_rule_openapi, we are only using the AssetMediaSize enum
|
||||
import 'package:openapi/api.dart';
|
||||
|
||||
final gCastServiceProvider = Provider(
|
||||
(ref) => GCastService(
|
||||
ref.watch(gCastRepositoryProvider),
|
||||
ref.watch(sessionsAPIRepositoryProvider),
|
||||
ref.watch(assetApiRepositoryProvider),
|
||||
),
|
||||
);
|
||||
|
||||
class GCastService {
|
||||
final GCastRepository _gCastRepository;
|
||||
final SessionsAPIRepository _sessionsApiService;
|
||||
final AssetApiRepository _assetApiRepository;
|
||||
|
||||
SessionCreateResponse? sessionKey;
|
||||
String? currentAssetId;
|
||||
bool isConnected = false;
|
||||
int? _sessionId;
|
||||
Timer? _mediaStatusPollingTimer;
|
||||
|
||||
void Function(bool)? onConnectionState;
|
||||
|
||||
void Function(Duration)? onCurrentTime;
|
||||
|
||||
void Function(Duration)? onDuration;
|
||||
|
||||
void Function(String)? onReceiverName;
|
||||
|
||||
void Function(CastState)? onCastState;
|
||||
|
||||
GCastService(this._gCastRepository, this._sessionsApiService, this._assetApiRepository) {
|
||||
_gCastRepository.onCastStatus = _onCastStatusCallback;
|
||||
_gCastRepository.onCastMessage = _onCastMessageCallback;
|
||||
}
|
||||
|
||||
void _onCastStatusCallback(CastSessionState state) {
|
||||
if (state == CastSessionState.connected) {
|
||||
onConnectionState?.call(true);
|
||||
isConnected = true;
|
||||
} else if (state == CastSessionState.closed) {
|
||||
onConnectionState?.call(false);
|
||||
isConnected = false;
|
||||
onReceiverName?.call("");
|
||||
currentAssetId = null;
|
||||
}
|
||||
}
|
||||
|
||||
void _onCastMessageCallback(Map<String, dynamic> message) {
|
||||
switch (message['type']) {
|
||||
case "MEDIA_STATUS":
|
||||
_handleMediaStatus(message);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
void _handleMediaStatus(Map<String, dynamic> message) {
|
||||
final statusList = (message['status'] as List).whereType<Map<String, dynamic>>().toList();
|
||||
|
||||
if (statusList.isEmpty) {
|
||||
return;
|
||||
}
|
||||
|
||||
final status = statusList[0];
|
||||
switch (status['playerState']) {
|
||||
case "PLAYING":
|
||||
onCastState?.call(CastState.playing);
|
||||
break;
|
||||
case "PAUSED":
|
||||
onCastState?.call(CastState.paused);
|
||||
break;
|
||||
case "BUFFERING":
|
||||
onCastState?.call(CastState.buffering);
|
||||
break;
|
||||
case "IDLE":
|
||||
onCastState?.call(CastState.idle);
|
||||
|
||||
// stop polling for media status if the video finished playing
|
||||
if (status["idleReason"] == "FINISHED") {
|
||||
_mediaStatusPollingTimer?.cancel();
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
if (status["media"] != null && status["media"]["duration"] != null) {
|
||||
final duration = Duration(milliseconds: (status["media"]["duration"] * 1000 ?? 0).toInt());
|
||||
onDuration?.call(duration);
|
||||
}
|
||||
|
||||
if (status["mediaSessionId"] != null) {
|
||||
_sessionId = status["mediaSessionId"];
|
||||
}
|
||||
|
||||
if (status["currentTime"] != null) {
|
||||
final currentTime = Duration(milliseconds: (status["currentTime"] * 1000 ?? 0).toInt());
|
||||
onCurrentTime?.call(currentTime);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> connect(dynamic device) async {
|
||||
await _gCastRepository.connect(device);
|
||||
|
||||
onReceiverName?.call(device.extras["fn"] ?? "Google Cast");
|
||||
}
|
||||
|
||||
CastDestinationType getType() {
|
||||
return CastDestinationType.googleCast;
|
||||
}
|
||||
|
||||
Future<bool> initialize() async {
|
||||
// there is nothing blocking us from using Google Cast that we can check for
|
||||
return true;
|
||||
}
|
||||
|
||||
Future<void> disconnect() async {
|
||||
onReceiverName?.call("");
|
||||
currentAssetId = null;
|
||||
await _gCastRepository.disconnect();
|
||||
}
|
||||
|
||||
bool isSessionValid() {
|
||||
// check if we already have a session token
|
||||
// we should always have a expiration date
|
||||
if (sessionKey == null || sessionKey?.expiresAt == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
final tokenExpiration = DateTime.parse(sessionKey!.expiresAt!);
|
||||
|
||||
// we want to make sure we have at least 10 seconds remaining in the session
|
||||
// this is to account for network latency and other delays when sending the request
|
||||
final bufferedExpiration = tokenExpiration.subtract(const Duration(seconds: 10));
|
||||
|
||||
return bufferedExpiration.isAfter(DateTime.now());
|
||||
}
|
||||
|
||||
void loadMedia(RemoteAsset asset, bool reload) async {
|
||||
if (!isConnected) {
|
||||
return;
|
||||
} else if (asset.id == currentAssetId && !reload) {
|
||||
return;
|
||||
}
|
||||
|
||||
// create a session key
|
||||
if (!isSessionValid()) {
|
||||
sessionKey = await _sessionsApiService.createSession(
|
||||
"Cast",
|
||||
"Google Cast",
|
||||
duration: const Duration(minutes: 15).inSeconds,
|
||||
);
|
||||
}
|
||||
|
||||
final unauthenticatedUrl = asset.isVideo
|
||||
? getPlaybackUrlForRemoteId(asset.id)
|
||||
: getThumbnailUrlForRemoteId(asset.id, type: AssetMediaSize.fullsize);
|
||||
|
||||
final authenticatedURL = "$unauthenticatedUrl&sessionKey=${sessionKey?.token}";
|
||||
|
||||
// get image mime type
|
||||
final mimeType = await _assetApiRepository.getAssetMIMEType(asset.id);
|
||||
|
||||
if (mimeType == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
_gCastRepository.sendMessage(CastSession.kNamespaceMedia, {
|
||||
"type": "LOAD",
|
||||
"media": {
|
||||
"contentId": authenticatedURL,
|
||||
"streamType": "BUFFERED",
|
||||
"contentType": mimeType,
|
||||
"contentUrl": authenticatedURL,
|
||||
},
|
||||
"autoplay": true,
|
||||
});
|
||||
|
||||
currentAssetId = asset.id;
|
||||
|
||||
// we need to poll for media status since the cast device does not
|
||||
// send a message when the media is loaded for whatever reason
|
||||
// only do this on videos
|
||||
_mediaStatusPollingTimer?.cancel();
|
||||
|
||||
if (asset.isVideo) {
|
||||
_mediaStatusPollingTimer = Timer.periodic(const Duration(milliseconds: 500), (timer) {
|
||||
if (isConnected) {
|
||||
_gCastRepository.sendMessage(CastSession.kNamespaceMedia, {
|
||||
"type": "GET_STATUS",
|
||||
"mediaSessionId": _sessionId,
|
||||
});
|
||||
} else {
|
||||
timer.cancel();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void play() {
|
||||
_gCastRepository.sendMessage(CastSession.kNamespaceMedia, {"type": "PLAY", "mediaSessionId": _sessionId});
|
||||
}
|
||||
|
||||
void pause() {
|
||||
_gCastRepository.sendMessage(CastSession.kNamespaceMedia, {"type": "PAUSE", "mediaSessionId": _sessionId});
|
||||
}
|
||||
|
||||
void seekTo(Duration position) {
|
||||
_gCastRepository.sendMessage(CastSession.kNamespaceMedia, {
|
||||
"type": "SEEK",
|
||||
"mediaSessionId": _sessionId,
|
||||
"currentTime": position.inSeconds,
|
||||
});
|
||||
}
|
||||
|
||||
void stop() {
|
||||
_gCastRepository.sendMessage(CastSession.kNamespaceMedia, {"type": "STOP", "mediaSessionId": _sessionId});
|
||||
_mediaStatusPollingTimer?.cancel();
|
||||
|
||||
currentAssetId = null;
|
||||
}
|
||||
|
||||
// 0x01 is display capability bitmask
|
||||
bool isDisplay(int ca) => (ca & 0x01) != 0;
|
||||
|
||||
Future<List<(String, CastDestinationType, dynamic)>> getDevices() async {
|
||||
final dests = await _gCastRepository.listDestinations();
|
||||
|
||||
return dests
|
||||
.map((device) => (device.extras["fn"] ?? "Google Cast", CastDestinationType.googleCast, device))
|
||||
.where((device) {
|
||||
final caString = device.$3.extras["ca"];
|
||||
final caNumber = int.tryParse(caString ?? "0") ?? 0;
|
||||
|
||||
return isDisplay(caNumber);
|
||||
})
|
||||
.toList(growable: false);
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,7 @@ 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/presentation/actions/action.widget.dart';
|
||||
import 'package:immich_mobile/presentation/actions/action.dart';
|
||||
import 'package:immich_mobile/presentation/actions/asset_debug.action.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';
|
||||
@@ -192,7 +192,7 @@ enum ActionButtonType {
|
||||
bool menuItem = false,
|
||||
]) {
|
||||
return switch (this) {
|
||||
ActionButtonType.advancedInfo => ActionMenuItemWidget(action: AssetDebugAction(assets: [context.asset])),
|
||||
ActionButtonType.advancedInfo => AssetDebugAction(source: context.source, display: ActionDisplay.menuItem),
|
||||
ActionButtonType.share => ShareActionButton(source: context.source, iconOnly: iconOnly, menuItem: menuItem),
|
||||
ActionButtonType.shareLink => ShareLinkActionButton(
|
||||
source: context.source,
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import 'package:drift/drift.dart';
|
||||
import 'package:openapi/api.dart' show Optional;
|
||||
|
||||
sealed class Option<T> {
|
||||
@@ -22,27 +21,11 @@ sealed class Option<T> {
|
||||
None() => null,
|
||||
};
|
||||
|
||||
Option<U> map<U>(U Function(T value) f) => switch (this) {
|
||||
Some(:final value) => Some(f(value)),
|
||||
None() => None<U>(),
|
||||
};
|
||||
|
||||
U fold<U>(U Function(T value) onSome, U Function() onNone) => switch (this) {
|
||||
Some(:final value) => onSome(value),
|
||||
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)',
|
||||
@@ -82,10 +65,3 @@ extension OptionToOptional<T> on Option<T> {
|
||||
Some(:final value) => Optional.present(value),
|
||||
};
|
||||
}
|
||||
|
||||
extension OptionToDriftValue<T> on Option<T> {
|
||||
Value<T> toDriftValue() => switch (this) {
|
||||
Some(:final value) => Value(value),
|
||||
None() => const Value.absent(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2,31 +2,12 @@ import 'package:immich_mobile/domain/models/store.model.dart';
|
||||
import 'package:immich_mobile/entities/store.entity.dart';
|
||||
import 'package:punycode/punycode.dart';
|
||||
|
||||
/// Normalizes a server URL, guaranteeing that it has a schema and no trailing slashes
|
||||
String normalizeServerUrl(String url) {
|
||||
final trimmedUrl = url.trim();
|
||||
|
||||
String sanitizeUrl(String url) {
|
||||
// Add schema if none is set
|
||||
final urlWithSchema = trimmedUrl.contains('://') ? trimmedUrl : "https://$trimmedUrl";
|
||||
final urlWithSchema = url.trimLeft().startsWith(RegExp(r"https?://")) ? url : "https://$url";
|
||||
|
||||
// Remove trailing slash(es)
|
||||
return urlWithSchema.replaceFirst(RegExp(r"/+$"), "");
|
||||
}
|
||||
|
||||
/// Validates a user-entered server URL
|
||||
bool _validateServerUrl(String url) {
|
||||
final parsedUrl = Uri.tryParse(url);
|
||||
return parsedUrl != null && parsedUrl.scheme.startsWith(RegExp(r'^https?$')) && parsedUrl.host.isNotEmpty;
|
||||
}
|
||||
|
||||
/// Normalizes and validates that a server URL is supported
|
||||
bool normalizeAndValidateServerUrl(String? url) {
|
||||
if (url == null || url.isEmpty) {
|
||||
return true;
|
||||
}
|
||||
|
||||
final normalizedUrl = normalizeServerUrl(url);
|
||||
return _validateServerUrl(normalizedUrl);
|
||||
return urlWithSchema.trimRight().replaceFirst(RegExp(r"/+$"), "");
|
||||
}
|
||||
|
||||
String? getServerUrl() {
|
||||
|
||||
@@ -69,7 +69,7 @@ class CastDialog extends ConsumerWidget {
|
||||
child: Text(item, style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16)).tr(),
|
||||
);
|
||||
} else {
|
||||
final (deviceName, _, deviceObj) = item as (String, CastDestinationType, dynamic);
|
||||
final (deviceName, type, deviceObj) = item as (String, CastDestinationType, dynamic);
|
||||
|
||||
return ListTile(
|
||||
title: Text(
|
||||
@@ -77,7 +77,7 @@ class CastDialog extends ConsumerWidget {
|
||||
style: TextStyle(color: isCurrentDevice(deviceName) ? context.colorScheme.primary : null),
|
||||
),
|
||||
leading: Icon(
|
||||
isCurrentDevice(deviceName) ? Icons.cast_connected : Icons.cast,
|
||||
type == CastDestinationType.googleCast ? Icons.cast : Icons.cast_connected,
|
||||
color: isCurrentDevice(deviceName) ? context.colorScheme.primary : null,
|
||||
),
|
||||
trailing: isCurrentDevice(deviceName)
|
||||
@@ -95,7 +95,7 @@ class CastDialog extends ConsumerWidget {
|
||||
}
|
||||
|
||||
if (!isCurrentDevice(deviceName)) {
|
||||
unawaited(ref.read(castProvider.notifier).connect(deviceObj));
|
||||
unawaited(ref.read(castProvider.notifier).connect(type, deviceObj));
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
@@ -43,7 +43,18 @@ class LoginForm extends HookConsumerWidget {
|
||||
|
||||
final log = Logger('LoginForm');
|
||||
|
||||
String? _validateUrl(String? url) => normalizeAndValidateServerUrl(url) ? null : 'login_form_err_invalid_url'.tr();
|
||||
String? _validateUrl(String? url) {
|
||||
if (url == null || url.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final parsedUrl = Uri.tryParse(url);
|
||||
if (parsedUrl == null || !parsedUrl.isAbsolute || !parsedUrl.scheme.startsWith("http") || parsedUrl.host.isEmpty) {
|
||||
return 'login_form_err_invalid_url'.tr();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
String? _validateEmail(String? email) {
|
||||
if (email == null || email == '') {
|
||||
@@ -90,7 +101,7 @@ class LoginForm extends HookConsumerWidget {
|
||||
/// Fetch the server login credential and enables oAuth login if necessary
|
||||
/// Returns true if successful, false otherwise
|
||||
Future<void> getServerAuthSettings() async {
|
||||
final sanitizeServerUrl = normalizeServerUrl(serverEndpointController.text);
|
||||
final sanitizeServerUrl = sanitizeUrl(serverEndpointController.text);
|
||||
final serverUrl = punycodeEncodeUrl(sanitizeServerUrl);
|
||||
|
||||
// Guard empty URL
|
||||
@@ -293,7 +304,7 @@ class LoginForm extends HookConsumerWidget {
|
||||
|
||||
try {
|
||||
oAuthServerUrl = await oAuthService.getOAuthServerUrl(
|
||||
normalizeServerUrl(serverEndpointController.text),
|
||||
sanitizeUrl(serverEndpointController.text),
|
||||
state,
|
||||
codeChallenge,
|
||||
);
|
||||
@@ -425,7 +436,7 @@ class LoginForm extends HookConsumerWidget {
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: ImmichSpacing.md),
|
||||
child: Text(
|
||||
normalizeServerUrl(serverEndpointController.text),
|
||||
sanitizeUrl(serverEndpointController.text),
|
||||
style: context.textTheme.displaySmall,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
|
||||
@@ -1,73 +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.clearFrom()))
|
||||
: 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.clearTo()))
|
||||
: 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)));
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -6,23 +6,18 @@ final scaffoldMessengerKey = GlobalKey<ScaffoldMessengerState>();
|
||||
class SnackbarManager {
|
||||
const SnackbarManager();
|
||||
|
||||
ScaffoldFeatureController<SnackBar, SnackBarClosedReason>? show(
|
||||
String message,
|
||||
SnackbarType type, {
|
||||
Duration? duration,
|
||||
}) {
|
||||
ScaffoldFeatureController<SnackBar, SnackBarClosedReason>? show(String message, SnackbarType type) {
|
||||
final messenger = scaffoldMessengerKey.currentState;
|
||||
final context = scaffoldMessengerKey.currentContext;
|
||||
if (messenger == null || context == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
duration ??= const .new(seconds: 4);
|
||||
messenger.hideCurrentSnackBar();
|
||||
return messenger.showSnackBar(_build(context, message, type, duration));
|
||||
return messenger.showSnackBar(_build(context, message, type));
|
||||
}
|
||||
|
||||
SnackBar _build(BuildContext context, String message, SnackbarType type, Duration duration) {
|
||||
SnackBar _build(BuildContext context, String message, SnackbarType type) {
|
||||
final theme = Theme.of(context);
|
||||
final colors = theme.extension<ImmichColors>() ?? ImmichColors.harmonized(theme.colorScheme);
|
||||
final (IconData icon, Color background, Color foreground) = switch (type) {
|
||||
@@ -34,7 +29,7 @@ class SnackbarManager {
|
||||
return SnackBar(
|
||||
behavior: .floating,
|
||||
backgroundColor: background,
|
||||
duration: duration,
|
||||
duration: const .new(seconds: 4),
|
||||
shape: const RoundedRectangleBorder(borderRadius: .all(.circular(ImmichRadius.sm))),
|
||||
content: Row(
|
||||
children: [
|
||||
@@ -53,14 +48,11 @@ class SnackbarManager {
|
||||
);
|
||||
}
|
||||
|
||||
ScaffoldFeatureController<SnackBar, SnackBarClosedReason>? info(String message, {Duration? duration}) =>
|
||||
show(message, .info, duration: duration);
|
||||
ScaffoldFeatureController<SnackBar, SnackBarClosedReason>? info(String message) => show(message, .info);
|
||||
|
||||
ScaffoldFeatureController<SnackBar, SnackBarClosedReason>? success(String message, {Duration? duration}) =>
|
||||
show(message, .success, duration: duration);
|
||||
ScaffoldFeatureController<SnackBar, SnackBarClosedReason>? success(String message) => show(message, .success);
|
||||
|
||||
ScaffoldFeatureController<SnackBar, SnackBarClosedReason>? error(String message, {Duration? duration}) =>
|
||||
show(message, .error, duration: duration);
|
||||
ScaffoldFeatureController<SnackBar, SnackBarClosedReason>? error(String message) => show(message, .error);
|
||||
}
|
||||
|
||||
const snackbar = SnackbarManager();
|
||||
|
||||
@@ -74,53 +74,53 @@ packages:
|
||||
source: hosted
|
||||
version: "9.5.5"
|
||||
bonsoir:
|
||||
dependency: transitive
|
||||
dependency: "direct overridden"
|
||||
description:
|
||||
name: bonsoir
|
||||
sha256: "1b112a966302a739d253c8dbc7ea4c1cb3eca6d8110dc59e36d76f7cf0b6edf2"
|
||||
sha256: "2e2cf3be580deccad9a48dcaddddf90de092e74b7de2015ef58fb24e11d66496"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.1.0"
|
||||
version: "5.1.11"
|
||||
bonsoir_android:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: bonsoir_android
|
||||
sha256: bfa3ab7e2f65473cb369bce639dbdbdc75064848c01ac11b3c2bc3e16031ff3a
|
||||
sha256: "9a65b6e50c5718c3f1a7ed6ff57ab9ed8ae990ff9c36d2b1ab3d1b90f28f7d1b"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.0.2"
|
||||
version: "5.1.6"
|
||||
bonsoir_darwin:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: bonsoir_darwin
|
||||
sha256: d62fd62ed433aa09ec99f71f95dae53ffa0adf788c9051ff3d6b903463045d3c
|
||||
sha256: "2d25c70f0d09260be1c2ab583b80dd89cbbfd59997579dadf789c5af00c7b2e4"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.1.0"
|
||||
version: "5.1.3"
|
||||
bonsoir_linux:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: bonsoir_linux
|
||||
sha256: a49d5f328a197b27a3901b833f92c93366f2cd7085dcb495fa11ee6d9f2509a9
|
||||
sha256: f2639aded6e15943a9822de98a663a1056f37cbfd0a74d72c9eaa941965945c2
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.0.3"
|
||||
version: "5.1.3"
|
||||
bonsoir_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: bonsoir_platform_interface
|
||||
sha256: ba1cc30daaa172dfc76f88e4fee8d090674179439201997ba2b3bd9e1cca84c0
|
||||
sha256: "08bb8b35d0198168b3bce87dbc718e4e510336cff1d97e43762e030c01636d45"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.1.0"
|
||||
version: "5.1.3"
|
||||
bonsoir_windows:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: bonsoir_windows
|
||||
sha256: "01aba2516b776eb1deb68845124dc0a41095da108276d4b307bf19c4c3e2d9b1"
|
||||
sha256: d4a0ca479d4f3679487a61f3174fb9fe1651e323c778b02dfa630490366be65d
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.0.3"
|
||||
version: "5.1.5"
|
||||
boolean_selector:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -137,14 +137,6 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.0.6"
|
||||
build_cli_annotations:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: build_cli_annotations
|
||||
sha256: e563c2e01de8974566a1998410d3f6f03521788160a02503b0b1f1a46c7b3d95
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.1"
|
||||
build_config:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -185,6 +177,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "8.12.6"
|
||||
cast:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: cast
|
||||
sha256: de1856e1a31aa60a6fed627f827921f7ec6539c67c60d0c899e89646dcbe773e
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.0"
|
||||
characters:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -418,14 +418,6 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.3.3"
|
||||
fcast_sender_sdk:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: fcast_sender_sdk
|
||||
sha256: "4c4e0f51749a0930e26e42e6a3f456293c7f5f0ffed2ecd1e283c28de5e11b33"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.0.3"
|
||||
ffi:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -577,14 +569,6 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.6.1"
|
||||
flutter_rust_bridge:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_rust_bridge
|
||||
sha256: "37ef40bc6f863652e865f0b2563ea07f0d3c58d8efad803cc01933a4b2ee067e"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.11.1"
|
||||
flutter_secure_storage:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -683,14 +667,6 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "8.2.14"
|
||||
freezed_annotation:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: freezed_annotation
|
||||
sha256: "7294967ff0a6d98638e7acb774aac3af2550777accd8149c90af5b014e6d44d8"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.1.0"
|
||||
frontend_server_client:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -1422,6 +1398,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "5.0.5"
|
||||
protobuf:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: protobuf
|
||||
sha256: "68645b24e0716782e58948f8467fd42a880f255096a821f9e7d0ec625b00c84d"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.1.0"
|
||||
pub_semver:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
||||
@@ -12,6 +12,7 @@ dependencies:
|
||||
async: ^2.13.1
|
||||
auto_route: ^11.1.0
|
||||
background_downloader: ^9.5.4
|
||||
cast: ^2.1.0
|
||||
collection: ^1.19.1
|
||||
connectivity_plus: ^7.0.0
|
||||
crop_image: ^1.0.17
|
||||
@@ -90,7 +91,6 @@ dependencies:
|
||||
url: https://github.com/mertalev/http
|
||||
ref: '549c24b0a4d3881a9a44b70f4873450d43c1c4af' # https://github.com/dart-lang/http/pull/1877
|
||||
path: pkgs/ok_http/
|
||||
fcast_sender_sdk: ^0.0.3
|
||||
|
||||
dev_dependencies:
|
||||
auto_route_generator: ^10.5.0
|
||||
@@ -113,6 +113,7 @@ dev_dependencies:
|
||||
# cast 2.1.0 declares a loose bonsoir range but its code targets the 5.x API.
|
||||
# Pin bonsoir to 5.x until cast releases a version compatible with bonsoir 6.x.
|
||||
dependency_overrides:
|
||||
bonsoir: ^5.1.11
|
||||
objective_c:
|
||||
git:
|
||||
url: https://github.com/mertalev/native
|
||||
|
||||
@@ -77,40 +77,6 @@ void main() {
|
||||
});
|
||||
});
|
||||
|
||||
group('normalizeAndValidateServerUrl', () {
|
||||
test('should treat null as valid', () {
|
||||
expect(normalizeAndValidateServerUrl(null), isTrue);
|
||||
});
|
||||
|
||||
test('should treat empty string as valid', () {
|
||||
expect(normalizeAndValidateServerUrl(''), isTrue);
|
||||
});
|
||||
|
||||
test('should accept a bare host', () {
|
||||
expect(normalizeAndValidateServerUrl('demo.immich.app'), isTrue);
|
||||
});
|
||||
|
||||
test('should accept a bare host with a port', () {
|
||||
expect(normalizeAndValidateServerUrl('192.168.1.1:2283'), isTrue);
|
||||
});
|
||||
|
||||
test('should accept an http URL', () {
|
||||
expect(normalizeAndValidateServerUrl('http://demo.immich.app'), isTrue);
|
||||
});
|
||||
|
||||
test('should accept an https URL', () {
|
||||
expect(normalizeAndValidateServerUrl('https://demo.immich.app:2283/api'), isTrue);
|
||||
});
|
||||
|
||||
test('should reject a non-http scheme', () {
|
||||
expect(normalizeAndValidateServerUrl('ftp://demo.immich.app'), isFalse);
|
||||
});
|
||||
|
||||
test('should reject scheme only input', () {
|
||||
expect(normalizeAndValidateServerUrl('https://'), isFalse);
|
||||
});
|
||||
});
|
||||
|
||||
group('punycodeDecodeUrl', () {
|
||||
test('should return null for null input', () {
|
||||
expect(punycodeDecodeUrl(null), isNull);
|
||||
|
||||
@@ -1,69 +0,0 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/people/person_edit_birthday_modal.widget.dart';
|
||||
import 'package:intl/date_symbol_data_local.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:scroll_date_picker/scroll_date_picker.dart';
|
||||
|
||||
void main() {
|
||||
group('datePickerColumnOrder', () {
|
||||
test('month first (en_US)', () {
|
||||
expect(
|
||||
datePickerColumnOrder('M/d/y'),
|
||||
orderedEquals([DatePickerViewType.month, DatePickerViewType.day, DatePickerViewType.year]),
|
||||
);
|
||||
});
|
||||
|
||||
test('day first (pl)', () {
|
||||
expect(
|
||||
datePickerColumnOrder('dd.MM.y'),
|
||||
orderedEquals([DatePickerViewType.day, DatePickerViewType.month, DatePickerViewType.year]),
|
||||
);
|
||||
});
|
||||
|
||||
test('year first (ko)', () {
|
||||
expect(
|
||||
datePickerColumnOrder('y. M. d.'),
|
||||
orderedEquals([DatePickerViewType.year, DatePickerViewType.month, DatePickerViewType.day]),
|
||||
);
|
||||
});
|
||||
|
||||
test('null pattern falls back to package default', () {
|
||||
expect(datePickerColumnOrder(null), isNull);
|
||||
});
|
||||
|
||||
test('missing field falls back to package default', () {
|
||||
expect(datePickerColumnOrder('M/y'), isNull);
|
||||
});
|
||||
});
|
||||
|
||||
group('datePickerColumnOrder with real locale patterns', () {
|
||||
setUpAll(() async {
|
||||
await initializeDateFormatting();
|
||||
});
|
||||
|
||||
for (final (locales, order, name) in const [
|
||||
(
|
||||
['en', 'en-US', 'en-PH'],
|
||||
[DatePickerViewType.month, DatePickerViewType.day, DatePickerViewType.year],
|
||||
'month/day/year',
|
||||
),
|
||||
(
|
||||
['en-GB', 'fr', 'fr-FR', 'de', 'de-DE', 'pl'],
|
||||
[DatePickerViewType.day, DatePickerViewType.month, DatePickerViewType.year],
|
||||
'day/month/year',
|
||||
),
|
||||
(
|
||||
['ja', 'ja-JP', 'zh', 'zh-CN', 'ko', 'ko-KR'],
|
||||
[DatePickerViewType.year, DatePickerViewType.month, DatePickerViewType.day],
|
||||
'year/month/day',
|
||||
),
|
||||
(['ky'], [DatePickerViewType.year, DatePickerViewType.day, DatePickerViewType.month], 'year/day/month'),
|
||||
]) {
|
||||
for (final locale in locales) {
|
||||
test('$locale uses $name', () {
|
||||
expect(datePickerColumnOrder(DateFormat.yMd(locale).pattern), orderedEquals(order));
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -25,8 +25,6 @@ class _FrozenBucketService implements TimelineService {
|
||||
}
|
||||
|
||||
class _EmptyBucketService implements TimelineService {
|
||||
const _EmptyBucketService();
|
||||
|
||||
@override
|
||||
Stream<List<Bucket>> Function() get watchBuckets =>
|
||||
() => Stream.value(const []);
|
||||
@@ -111,7 +109,7 @@ void main() {
|
||||
await tester.pumpWidget(
|
||||
ProviderScope(
|
||||
overrides: [
|
||||
timelineServiceProvider.overrideWithValue(const _EmptyBucketService()),
|
||||
timelineServiceProvider.overrideWithValue(_EmptyBucketService()),
|
||||
appConfigProvider.overrideWithValue(const AppConfig()),
|
||||
],
|
||||
child: MaterialApp(
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/domain/models/store.model.dart';
|
||||
import 'package:immich_mobile/domain/services/store.service.dart';
|
||||
import 'package:immich_mobile/presentation/actions/action.widget.dart';
|
||||
import 'package:immich_mobile/presentation/actions/action.dart';
|
||||
import 'package:immich_mobile/presentation/actions/asset_debug.action.dart';
|
||||
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
|
||||
import 'package:immich_ui/immich_ui.dart';
|
||||
|
||||
import '../../factories/remote_asset_factory.dart';
|
||||
@@ -20,11 +23,18 @@ void main() {
|
||||
context.dispose();
|
||||
});
|
||||
|
||||
List<Override> selection(Set<BaseAsset> assets) => [
|
||||
multiSelectProvider.overrideWith(
|
||||
() => MultiSelectNotifier(MultiSelectState(selectedAssets: assets, lockedSelectionAssets: const {})),
|
||||
),
|
||||
];
|
||||
|
||||
group('AssetDebugAction', () {
|
||||
testWidgets('visible for a single asset when advanced troubleshooting is on', (tester) async {
|
||||
await tester.pumpTestWidget(
|
||||
context,
|
||||
ActionIconButtonWidget(action: AssetDebugAction(assets: [RemoteAssetFactory.create()])),
|
||||
const AssetDebugAction(display: ActionDisplay.iconButton),
|
||||
overrides: selection({RemoteAssetFactory.create()}),
|
||||
);
|
||||
|
||||
expect(find.byType(ImmichIconButton), findsOneWidget);
|
||||
@@ -33,9 +43,8 @@ void main() {
|
||||
testWidgets('hidden for multiple assets', (tester) async {
|
||||
await tester.pumpTestWidget(
|
||||
context,
|
||||
ActionIconButtonWidget(
|
||||
action: AssetDebugAction(assets: [RemoteAssetFactory.create(), RemoteAssetFactory.create()]),
|
||||
),
|
||||
const AssetDebugAction(display: ActionDisplay.iconButton),
|
||||
overrides: selection({RemoteAssetFactory.create(), RemoteAssetFactory.create()}),
|
||||
);
|
||||
|
||||
expect(find.byType(ImmichIconButton), findsNothing);
|
||||
@@ -45,7 +54,8 @@ void main() {
|
||||
await StoreService.I.put(StoreKey.advancedTroubleshooting, false);
|
||||
await tester.pumpTestWidget(
|
||||
context,
|
||||
ActionIconButtonWidget(action: AssetDebugAction(assets: [RemoteAssetFactory.create()])),
|
||||
const AssetDebugAction(display: ActionDisplay.iconButton),
|
||||
overrides: selection({RemoteAssetFactory.create()}),
|
||||
);
|
||||
|
||||
expect(find.byType(ImmichIconButton), findsNothing);
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/presentation/actions/action.dart';
|
||||
import 'package:immich_mobile/presentation/actions/favorite.action.dart';
|
||||
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
|
||||
import 'package:mocktail/mocktail.dart';
|
||||
|
||||
import '../../../domain/service.mock.dart';
|
||||
@@ -24,28 +27,34 @@ void main() {
|
||||
RemoteAsset owned({bool isFavorite = false}) =>
|
||||
RemoteAssetFactory.create(ownerId: context.currentUser.id, isFavorite: isFavorite);
|
||||
|
||||
List<Override> selection(Set<BaseAsset> assets) => [
|
||||
multiSelectProvider.overrideWith(
|
||||
() => MultiSelectNotifier(MultiSelectState(selectedAssets: assets, lockedSelectionAssets: const {})),
|
||||
),
|
||||
];
|
||||
|
||||
group('FavoriteAction', () {
|
||||
testWidgets('favorites the eligible owned assets', (tester) async {
|
||||
final asset = owned();
|
||||
|
||||
await tester.pumpTestAction(context, FavoriteAction(assets: [asset]));
|
||||
await tester.pumpTestAction(
|
||||
context,
|
||||
const FavoriteAction(display: ActionDisplay.iconButton),
|
||||
overrides: selection({asset}),
|
||||
);
|
||||
|
||||
verify(() => assetService.updateFavorite([asset.id], true)).called(1);
|
||||
});
|
||||
|
||||
testWidgets('unfavorite the eligible owned assets', (tester) async {
|
||||
final asset = owned(isFavorite: true);
|
||||
|
||||
await tester.pumpTestAction(context, FavoriteAction(assets: [asset]));
|
||||
|
||||
verify(() => assetService.updateFavorite([asset.id], false)).called(1);
|
||||
});
|
||||
|
||||
testWidgets('ignores assets owned by someone else', (tester) async {
|
||||
final mine = owned();
|
||||
final theirs = RemoteAssetFactory.create();
|
||||
|
||||
await tester.pumpTestAction(context, FavoriteAction(assets: [mine, theirs]));
|
||||
await tester.pumpTestAction(
|
||||
context,
|
||||
const FavoriteAction(display: ActionDisplay.iconButton),
|
||||
overrides: selection({mine, theirs}),
|
||||
);
|
||||
|
||||
verify(() => assetService.updateFavorite([mine.id], true)).called(1);
|
||||
});
|
||||
@@ -54,25 +63,51 @@ void main() {
|
||||
final first = owned();
|
||||
final second = owned();
|
||||
|
||||
await tester.pumpTestAction(context, FavoriteAction(assets: [first, second]));
|
||||
await tester.pumpTestAction(
|
||||
context,
|
||||
const FavoriteAction(display: ActionDisplay.iconButton),
|
||||
overrides: selection({first, second}),
|
||||
);
|
||||
|
||||
verify(() => assetService.updateFavorite([first.id, second.id], true)).called(1);
|
||||
});
|
||||
|
||||
testWidgets('skips owned assets already in the target state', (tester) async {
|
||||
testWidgets('skips owned assets already favorited', (tester) async {
|
||||
final stale = owned();
|
||||
final alreadyFavorite = owned(isFavorite: true);
|
||||
|
||||
await tester.pumpTestAction(context, FavoriteAction(assets: [stale, alreadyFavorite]));
|
||||
await tester.pumpTestAction(
|
||||
context,
|
||||
const FavoriteAction(display: ActionDisplay.iconButton),
|
||||
overrides: selection({stale, alreadyFavorite}),
|
||||
);
|
||||
|
||||
verify(() => assetService.updateFavorite([stale.id], true)).called(1);
|
||||
});
|
||||
|
||||
testWidgets('shows a confirmation snackbar on success', (tester) async {
|
||||
await tester.pumpTestAction(context, FavoriteAction(assets: [owned()]));
|
||||
await tester.pumpTestAction(
|
||||
context,
|
||||
const FavoriteAction(display: ActionDisplay.iconButton),
|
||||
overrides: selection({owned()}),
|
||||
);
|
||||
await tester.pumpUntilFound(find.byType(SnackBar));
|
||||
|
||||
expect(find.byType(SnackBar), findsOneWidget);
|
||||
});
|
||||
});
|
||||
|
||||
group('UnfavoriteAction', () {
|
||||
testWidgets('unfavorites the eligible owned assets', (tester) async {
|
||||
final asset = owned(isFavorite: true);
|
||||
|
||||
await tester.pumpTestAction(
|
||||
context,
|
||||
const UnfavoriteAction(display: ActionDisplay.iconButton),
|
||||
overrides: selection({asset}),
|
||||
);
|
||||
|
||||
verify(() => assetService.updateFavorite([asset.id], false)).called(1);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/domain/models/user.model.dart';
|
||||
import 'package:immich_mobile/presentation/actions/action.dart';
|
||||
import 'package:immich_mobile/presentation/actions/partner.action.dart';
|
||||
import 'package:mocktail/mocktail.dart';
|
||||
|
||||
@@ -31,7 +32,7 @@ void main() {
|
||||
testWidgets('creates a partner for the selected candidate', (tester) async {
|
||||
final candidate = UserFactory.create();
|
||||
|
||||
await tester.pumpTestAction(context, const PartnerAddAction(), overrides: overrides(candidates: [candidate]));
|
||||
await tester.pumpTestAction(context, const PartnerAddAction(display: ActionDisplay.iconButton), overrides: overrides(candidates: [candidate]));
|
||||
await tester.pumpUntilFound(find.text(candidate.name));
|
||||
await tester.tap(find.text(candidate.name));
|
||||
await tester.pumpAndSettle();
|
||||
@@ -42,7 +43,7 @@ void main() {
|
||||
testWidgets('creates nothing when the selection dialog is dismissed', (tester) async {
|
||||
await tester.pumpTestAction(
|
||||
context,
|
||||
const PartnerAddAction(),
|
||||
const PartnerAddAction(display: ActionDisplay.iconButton),
|
||||
overrides: overrides(candidates: [UserFactory.create()]),
|
||||
);
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.escape); // dismiss without selecting
|
||||
@@ -57,7 +58,7 @@ void main() {
|
||||
final partner = UserFactory.create();
|
||||
await tester.pumpTestAction(
|
||||
context,
|
||||
PartnerRemoveAction(sharedWithId: partner.id, partnerName: partner.name),
|
||||
PartnerRemoveAction(sharedWithId: partner.id, partnerName: partner.name, display: ActionDisplay.iconButton),
|
||||
overrides: overrides(),
|
||||
);
|
||||
await tester.tap(find.byType(TextButton).last); // confirm
|
||||
@@ -70,7 +71,7 @@ void main() {
|
||||
final partner = UserFactory.create();
|
||||
await tester.pumpTestAction(
|
||||
context,
|
||||
PartnerRemoveAction(sharedWithId: partner.id, partnerName: partner.name),
|
||||
PartnerRemoveAction(sharedWithId: partner.id, partnerName: partner.name, display: ActionDisplay.iconButton),
|
||||
overrides: overrides(),
|
||||
);
|
||||
await tester.tap(find.byType(TextButton).first); // cancel
|
||||
|
||||
@@ -1,108 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/presentation/actions/action.dart';
|
||||
import 'package:immich_mobile/presentation/actions/action.widget.dart';
|
||||
import 'package:immich_mobile/presentation/actions/timeline.action.dart';
|
||||
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
|
||||
|
||||
import '../../factories/remote_asset_factory.dart';
|
||||
import '../presentation_context.dart';
|
||||
|
||||
class _FakeAction extends BaseAction {
|
||||
_FakeAction({this.visible = true, this.error});
|
||||
|
||||
final bool visible;
|
||||
final Object? error;
|
||||
|
||||
bool ran = false;
|
||||
bool? selectionDuringOnAction;
|
||||
|
||||
@override
|
||||
IconData get icon => Icons.bolt;
|
||||
|
||||
@override
|
||||
String label(ActionScope scope) => 'fake';
|
||||
|
||||
@override
|
||||
bool isVisible(ActionScope scope) => visible;
|
||||
|
||||
@override
|
||||
Future<void> onAction(ActionScope scope) async {
|
||||
ran = true;
|
||||
selectionDuringOnAction = scope.ref.read(multiSelectProvider).isEnabled;
|
||||
if (error != null) {
|
||||
throw error!;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void main() {
|
||||
late PresentationContext context;
|
||||
|
||||
setUp(() async {
|
||||
context = await PresentationContext.create();
|
||||
});
|
||||
|
||||
tearDown(() {
|
||||
context.dispose();
|
||||
});
|
||||
|
||||
List<Override> overrides() => [
|
||||
multiSelectProvider.overrideWith(
|
||||
() => MultiSelectNotifier(
|
||||
MultiSelectState(selectedAssets: {RemoteAssetFactory.create()}, lockedSelectionAssets: const {}),
|
||||
),
|
||||
),
|
||||
];
|
||||
|
||||
Future<(ActionScope, ProviderContainer)> pumpScope(WidgetTester tester) async {
|
||||
late ActionScope scope;
|
||||
late ProviderContainer container;
|
||||
await tester.pumpTestWidget(
|
||||
context,
|
||||
Consumer(
|
||||
builder: (innerContext, ref, _) {
|
||||
scope = ActionScope(context: innerContext, ref: ref, authUser: context.currentUser);
|
||||
container = ProviderScope.containerOf(innerContext, listen: false);
|
||||
return const SizedBox.shrink();
|
||||
},
|
||||
),
|
||||
overrides: overrides(),
|
||||
);
|
||||
return (scope, container);
|
||||
}
|
||||
|
||||
group('TimelineAction', () {
|
||||
testWidgets('runs the wrapped action and then clears the selection', (tester) async {
|
||||
final inner = _FakeAction();
|
||||
final (scope, container) = await pumpScope(tester);
|
||||
await TimelineAction(action: inner).onAction(scope);
|
||||
|
||||
expect(inner.ran, isTrue);
|
||||
expect(inner.selectionDuringOnAction, isTrue, reason: 'reset must run after the inner action, not before');
|
||||
expect(container.read(multiSelectProvider).isEnabled, isFalse);
|
||||
});
|
||||
|
||||
testWidgets('rethrows and keeps the selection when the wrapped action throws', (tester) async {
|
||||
final error = Exception('boom');
|
||||
final inner = _FakeAction(error: error);
|
||||
final (scope, container) = await pumpScope(tester);
|
||||
|
||||
await expectLater(TimelineAction(action: inner).onAction(scope), throwsA(same(error)));
|
||||
|
||||
expect(inner.ran, isTrue);
|
||||
expect(container.read(multiSelectProvider).isEnabled, isTrue);
|
||||
});
|
||||
|
||||
testWidgets('delegates visibility to the wrapped action', (tester) async {
|
||||
await tester.pumpTestWidget(
|
||||
context,
|
||||
ActionIconButtonWidget(action: TimelineAction(action: _FakeAction(visible: false))),
|
||||
);
|
||||
|
||||
expect(find.byType(ActionIconButtonWidget), findsOneWidget);
|
||||
expect(find.byIcon(Icons.bolt), findsNothing);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -17,12 +17,10 @@ void main() {
|
||||
|
||||
group('PartnerSharedByList', () {
|
||||
testWidgets('shows the empty-state add button when there are no partners', (tester) async {
|
||||
final action = const PartnerAddAction();
|
||||
|
||||
await tester.pumpTestWidget(context, const PartnerSharedByList(partners: []));
|
||||
|
||||
expect(find.byType(ListView), findsNothing);
|
||||
expect(find.widgetWithIcon(TextButton, action.icon), findsOneWidget);
|
||||
expect(find.widgetWithIcon(TextButton, Icons.person_add_rounded), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('renders a tile per partner with name and email', (tester) async {
|
||||
@@ -39,9 +37,8 @@ void main() {
|
||||
testWidgets('renders a remove action for each partner', (tester) async {
|
||||
final partner1 = PartnerFactory.create(inTimeline: true);
|
||||
final partner2 = PartnerFactory.create();
|
||||
final action = const PartnerRemoveAction(sharedWithId: '', partnerName: '');
|
||||
await tester.pumpTestWidget(context, PartnerSharedByList(partners: [partner1, partner2]));
|
||||
expect(find.byIcon(action.icon), findsNWidgets(2));
|
||||
expect(find.byIcon(Icons.person_remove_rounded), findsNWidgets(2));
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -11,7 +11,6 @@ import 'package:immich_mobile/domain/services/store.service.dart';
|
||||
import 'package:immich_mobile/generated/codegen_loader.g.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/store.repository.dart';
|
||||
import 'package:immich_mobile/presentation/actions/action.dart';
|
||||
import 'package:immich_mobile/presentation/actions/action.widget.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/user.provider.dart';
|
||||
@@ -98,10 +97,10 @@ extension PumpPresentationWidget on WidgetTester {
|
||||
|
||||
Future<void> pumpTestAction(
|
||||
PresentationContext context,
|
||||
BaseAction action, {
|
||||
ActionWidget action, {
|
||||
List<Override> overrides = const [],
|
||||
}) async {
|
||||
await pumpTestWidget(context, ActionIconButtonWidget(action: action), overrides: overrides);
|
||||
await pumpTestWidget(context, action, overrides: overrides);
|
||||
await tap(find.byType(ImmichIconButton));
|
||||
await pump();
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -106,11 +106,7 @@ describe(MemoryController.name, () => {
|
||||
it('should require at least one field', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer()).put(`/memories/${factory.uuid()}`).send({});
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(
|
||||
errorDto.validationError([
|
||||
{ path: [], message: 'At least one of the following fields is required: isSaved, seenAt, memoryAt' },
|
||||
]),
|
||||
);
|
||||
expect(body).toEqual(errorDto.validationError([{ path: [], message: 'At least one field must be provided' }]));
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -304,36 +304,6 @@ export const columns = {
|
||||
'asset.height',
|
||||
'asset.isEdited',
|
||||
],
|
||||
searchAsset: [
|
||||
'asset.id',
|
||||
'asset.updateId',
|
||||
'asset.createdAt',
|
||||
'asset.updatedAt',
|
||||
'asset.deletedAt',
|
||||
'asset.status',
|
||||
'asset.checksum',
|
||||
'asset.checksumAlgorithm',
|
||||
'asset.duplicateId',
|
||||
'asset.duration',
|
||||
'asset.fileCreatedAt',
|
||||
'asset.fileModifiedAt',
|
||||
'asset.isExternal',
|
||||
'asset.isFavorite',
|
||||
'asset.isOffline',
|
||||
'asset.isEdited',
|
||||
'asset.visibility',
|
||||
'asset.libraryId',
|
||||
'asset.livePhotoVideoId',
|
||||
'asset.localDateTime',
|
||||
'asset.originalFileName',
|
||||
'asset.originalPath',
|
||||
'asset.ownerId',
|
||||
'asset.stackId',
|
||||
'asset.thumbhash',
|
||||
'asset.type',
|
||||
'asset.width',
|
||||
'asset.height',
|
||||
],
|
||||
workflowAssetV1: [
|
||||
'asset.id',
|
||||
'asset.ownerId',
|
||||
|
||||
@@ -108,11 +108,9 @@ export function ChunkedSet(options?: { paramIndex?: number; chunkSize?: number }
|
||||
}
|
||||
|
||||
const UUID = '00000000-0000-4000-a000-000000000000';
|
||||
const UUID_1 = '00000000-0000-4000-a000-000000000001';
|
||||
|
||||
export const DummyValue = {
|
||||
UUID,
|
||||
UUID_1,
|
||||
UUID_SET: new Set([UUID]),
|
||||
PAGINATION: { take: 10, skip: 0 },
|
||||
EMAIL: 'user@immich.app',
|
||||
|
||||
@@ -3,15 +3,8 @@ import { Place } from 'src/database';
|
||||
import { HistoryBuilder } from 'src/decorators';
|
||||
import { AlbumResponseSchema } from 'src/dtos/album.dto';
|
||||
import { AssetResponseSchema } from 'src/dtos/asset-response.dto';
|
||||
import {
|
||||
AssetOrder,
|
||||
AssetOrderSchema,
|
||||
AssetTypeSchema,
|
||||
AssetVisibilitySchema,
|
||||
SearchOrderField,
|
||||
SearchOrderFieldSchema,
|
||||
} from 'src/enum';
|
||||
import { isoDatetimeToDate, nonEmptyPartial, stringToBool } from 'src/validation';
|
||||
import { AssetOrder, AssetOrderSchema, AssetTypeSchema, AssetVisibilitySchema } from 'src/enum';
|
||||
import { isoDatetimeToDate, stringToBool } from 'src/validation';
|
||||
import z from 'zod';
|
||||
|
||||
const BaseSearchSchema = z.object({
|
||||
@@ -149,176 +142,6 @@ const SearchSuggestionRequestSchema = z
|
||||
})
|
||||
.meta({ id: 'SearchSuggestionRequestDto' });
|
||||
|
||||
const IdFilterSchema = nonEmptyPartial({
|
||||
eq: z.uuidv4(),
|
||||
ne: z.uuidv4(),
|
||||
}).meta({ id: 'IdFilter' });
|
||||
|
||||
const IdFilterNullableSchema = nonEmptyPartial({
|
||||
eq: z.uuidv4().nullable(),
|
||||
ne: z.uuidv4().nullable(),
|
||||
}).meta({ id: 'IdFilterNullable' });
|
||||
|
||||
const IdsFilterSchema = nonEmptyPartial({
|
||||
any: z.array(z.uuidv4()).min(1),
|
||||
all: z.array(z.uuidv4()).min(1),
|
||||
none: z.array(z.uuidv4()).min(1),
|
||||
}).meta({ id: 'IdsFilter' });
|
||||
|
||||
const stringListShape = {
|
||||
in: z.array(z.string()).min(1),
|
||||
notIn: z.array(z.string()).min(1),
|
||||
};
|
||||
|
||||
const StringFilterSchema = nonEmptyPartial({
|
||||
eq: z.string(),
|
||||
ne: z.string(),
|
||||
...stringListShape,
|
||||
}).meta({ id: 'StringFilter' });
|
||||
|
||||
const stringNullableShape = {
|
||||
eq: z.string().nullable(),
|
||||
ne: z.string().nullable(),
|
||||
...stringListShape,
|
||||
};
|
||||
|
||||
const StringFilterNullableSchema = nonEmptyPartial(stringNullableShape).meta({ id: 'StringFilterNullable' });
|
||||
|
||||
const StringPatternFilterSchema = nonEmptyPartial({
|
||||
...stringNullableShape,
|
||||
like: z.string().min(1),
|
||||
notLike: z.string().min(1),
|
||||
startsWith: z.string().min(1),
|
||||
endsWith: z.string().min(1),
|
||||
}).meta({ id: 'StringPatternFilter' });
|
||||
|
||||
const numberRangeShape = {
|
||||
lt: z.number(),
|
||||
lte: z.number(),
|
||||
gt: z.number(),
|
||||
gte: z.number(),
|
||||
in: z.array(z.number()).min(1),
|
||||
notIn: z.array(z.number()).min(1),
|
||||
};
|
||||
|
||||
const NumberFilterSchema = nonEmptyPartial({
|
||||
eq: z.number(),
|
||||
ne: z.number(),
|
||||
...numberRangeShape,
|
||||
}).meta({ id: 'NumberFilter' });
|
||||
|
||||
const NumberFilterNullableSchema = nonEmptyPartial({
|
||||
eq: z.number().nullable(),
|
||||
ne: z.number().nullable(),
|
||||
...numberRangeShape,
|
||||
}).meta({ id: 'NumberFilterNullable' });
|
||||
|
||||
const dateRangeShape = {
|
||||
gt: isoDatetimeToDate,
|
||||
gte: isoDatetimeToDate,
|
||||
lt: isoDatetimeToDate,
|
||||
lte: isoDatetimeToDate,
|
||||
};
|
||||
|
||||
const DateFilterSchema = nonEmptyPartial({
|
||||
eq: isoDatetimeToDate,
|
||||
ne: isoDatetimeToDate,
|
||||
...dateRangeShape,
|
||||
}).meta({ id: 'DateFilter' });
|
||||
|
||||
const DateFilterNullableSchema = nonEmptyPartial({
|
||||
eq: isoDatetimeToDate.nullable(),
|
||||
ne: isoDatetimeToDate.nullable(),
|
||||
...dateRangeShape,
|
||||
}).meta({ id: 'DateFilterNullable' });
|
||||
|
||||
const BoolFilterSchema = z.object({ eq: z.boolean() }).meta({ id: 'BoolFilter' });
|
||||
|
||||
const enumFilterSchema = <T extends z.core.util.EnumLike>(values: z.ZodEnum<T>, id: string) =>
|
||||
nonEmptyPartial({
|
||||
eq: values,
|
||||
ne: values,
|
||||
in: z.array(values).min(1),
|
||||
notIn: z.array(values).min(1),
|
||||
}).meta({ id });
|
||||
|
||||
const EnumFilterAssetTypeSchema = enumFilterSchema(AssetTypeSchema, 'EnumFilterAssetType');
|
||||
const EnumFilterAssetVisibilitySchema = enumFilterSchema(AssetVisibilitySchema, 'EnumFilterAssetVisibility');
|
||||
|
||||
const StringSimilarityFilterSchema = z
|
||||
.object({
|
||||
matches: z.string().min(1),
|
||||
})
|
||||
.meta({ id: 'StringSimilarityFilter' });
|
||||
|
||||
export const DEFAULT_SEARCH_ORDER = {
|
||||
field: SearchOrderField.FileCreatedAt,
|
||||
direction: AssetOrder.Desc,
|
||||
};
|
||||
|
||||
export const SearchOrderSchema = z
|
||||
.object({
|
||||
field: SearchOrderFieldSchema.default(DEFAULT_SEARCH_ORDER.field),
|
||||
direction: AssetOrderSchema.default(DEFAULT_SEARCH_ORDER.direction),
|
||||
})
|
||||
.meta({ id: 'SearchOrder' });
|
||||
|
||||
const SearchFilterBranchSchema = z
|
||||
.object({
|
||||
id: IdFilterSchema,
|
||||
libraryId: IdFilterNullableSchema,
|
||||
type: EnumFilterAssetTypeSchema,
|
||||
visibility: EnumFilterAssetVisibilitySchema,
|
||||
isFavorite: BoolFilterSchema,
|
||||
isMotion: BoolFilterSchema,
|
||||
isOffline: BoolFilterSchema,
|
||||
isEncoded: BoolFilterSchema,
|
||||
hasAlbums: BoolFilterSchema,
|
||||
hasPeople: BoolFilterSchema,
|
||||
hasTags: BoolFilterSchema,
|
||||
city: StringFilterNullableSchema,
|
||||
state: StringFilterNullableSchema,
|
||||
country: StringFilterNullableSchema,
|
||||
make: StringFilterNullableSchema,
|
||||
model: StringFilterNullableSchema,
|
||||
lensModel: StringFilterNullableSchema,
|
||||
description: StringPatternFilterSchema,
|
||||
originalFileName: StringPatternFilterSchema,
|
||||
originalPath: StringPatternFilterSchema,
|
||||
ocr: StringSimilarityFilterSchema,
|
||||
rating: NumberFilterNullableSchema,
|
||||
fileSizeInBytes: NumberFilterSchema,
|
||||
takenAt: DateFilterSchema,
|
||||
createdAt: DateFilterSchema,
|
||||
updatedAt: DateFilterSchema,
|
||||
trashedAt: DateFilterNullableSchema,
|
||||
personIds: IdsFilterSchema,
|
||||
tagIds: IdsFilterSchema,
|
||||
albumIds: IdsFilterSchema,
|
||||
checksum: StringFilterSchema,
|
||||
encodedVideoPath: StringFilterSchema,
|
||||
})
|
||||
.partial()
|
||||
.meta({ id: 'SearchFilterBranch' });
|
||||
|
||||
export const SearchFilterSchema = SearchFilterBranchSchema.extend({
|
||||
or: z.array(SearchFilterBranchSchema).min(1).optional(),
|
||||
}).meta({ id: 'SearchFilter' });
|
||||
|
||||
export type IdFilter = z.infer<typeof IdFilterSchema>;
|
||||
export type IdFilterNullable = z.infer<typeof IdFilterNullableSchema>;
|
||||
export type IdsFilter = z.infer<typeof IdsFilterSchema>;
|
||||
export type StringFilter = z.infer<typeof StringFilterSchema>;
|
||||
export type StringFilterNullable = z.infer<typeof StringFilterNullableSchema>;
|
||||
export type StringPatternFilter = z.infer<typeof StringPatternFilterSchema>;
|
||||
export type NumberFilter = z.infer<typeof NumberFilterSchema>;
|
||||
export type NumberFilterNullable = z.infer<typeof NumberFilterNullableSchema>;
|
||||
export type DateFilter = z.infer<typeof DateFilterSchema>;
|
||||
export type DateFilterNullable = z.infer<typeof DateFilterNullableSchema>;
|
||||
export type SearchOrder = z.infer<typeof SearchOrderSchema>;
|
||||
export type SearchFilter = z.infer<typeof SearchFilterSchema>;
|
||||
export type SearchFilterBranch = z.infer<typeof SearchFilterBranchSchema>;
|
||||
|
||||
export class RandomSearchDto extends createZodDto(RandomSearchSchema) {}
|
||||
export class LargeAssetSearchDto extends createZodDto(LargeAssetSearchSchema) {}
|
||||
export class MetadataSearchDto extends createZodDto(MetadataSearchSchema) {}
|
||||
|
||||
@@ -1234,12 +1234,3 @@ export enum CalendarHeatmapType {
|
||||
Upload = 'Upload',
|
||||
Taken = 'Taken',
|
||||
}
|
||||
|
||||
export enum SearchOrderField {
|
||||
FileCreatedAt = 'fileCreatedAt',
|
||||
LocalDateTime = 'localDateTime',
|
||||
FileSizeInBytes = 'fileSizeInBytes',
|
||||
Rating = 'rating',
|
||||
}
|
||||
|
||||
export const SearchOrderFieldSchema = z.enum(SearchOrderField).meta({ id: 'SearchOrderField' });
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,23 +1,12 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Kysely, OrderByDirection, Selectable, ShallowDehydrateObject, sql } from 'kysely';
|
||||
import { InjectKysely } from 'nestjs-kysely';
|
||||
import { columns } from 'src/database';
|
||||
import { DummyValue, GenerateSql } from 'src/decorators';
|
||||
import { MapAsset } from 'src/dtos/asset-response.dto';
|
||||
import { SearchFilter, SearchOrder } from 'src/dtos/search.dto';
|
||||
import { AssetStatus, AssetType, AssetVisibility, VectorIndex } from 'src/enum';
|
||||
import { probes } from 'src/repositories/database.repository';
|
||||
import { DB } from 'src/schema';
|
||||
import { AssetExifTable } from 'src/schema/tables/asset-exif.table';
|
||||
import {
|
||||
anyUuid,
|
||||
searchAssetBuilder,
|
||||
searchAssetBuilderLegacy,
|
||||
searchMetadataV3Examples,
|
||||
searchStatisticsV3Examples,
|
||||
withExifInner,
|
||||
withSearchOrder,
|
||||
} from 'src/utils/database';
|
||||
import { anyUuid, searchAssetBuilder, withExifInner } from 'src/utils/database';
|
||||
import { paginationHelper } from 'src/utils/pagination';
|
||||
import z from 'zod';
|
||||
|
||||
@@ -133,21 +122,6 @@ export type AssetSearchOptions = Omit<BaseAssetSearchOptions, 'visibility'> &
|
||||
|
||||
export type AssetSearchBuilderOptions = Omit<AssetSearchOptions, 'orderDirection'>;
|
||||
|
||||
export interface AssetSearchBuilderV3Options {
|
||||
filter?: SearchFilter;
|
||||
/** Server-derived ownership scope. Never client-controlled. */
|
||||
userIds?: string[];
|
||||
withExif?: boolean;
|
||||
withFaces?: boolean;
|
||||
withPeople?: boolean;
|
||||
withStacked?: boolean;
|
||||
order?: SearchOrder;
|
||||
}
|
||||
|
||||
export interface AssetSearchPaginationV3Options {
|
||||
size: number;
|
||||
}
|
||||
|
||||
export type SmartSearchOptions = SearchDateOptions &
|
||||
SearchEmbeddingOptions &
|
||||
SearchExifOptions &
|
||||
@@ -222,10 +196,9 @@ export class SearchRepository {
|
||||
})
|
||||
async searchMetadata(pagination: SearchPaginationOptions, options: AssetSearchOptions) {
|
||||
const orderDirection = (options.orderDirection?.toLowerCase() || 'desc') as OrderByDirection;
|
||||
const items = await searchAssetBuilderLegacy(this.db, options)
|
||||
.select(columns.searchAsset)
|
||||
const items = await searchAssetBuilder(this.db, options)
|
||||
.selectAll('asset')
|
||||
.orderBy('asset.fileCreatedAt', orderDirection)
|
||||
.orderBy('asset.id', orderDirection)
|
||||
.limit(pagination.size + 1)
|
||||
.offset((pagination.page - 1) * pagination.size)
|
||||
.execute();
|
||||
@@ -244,7 +217,7 @@ export class SearchRepository {
|
||||
],
|
||||
})
|
||||
searchStatistics(options: AssetSearchOptions) {
|
||||
return searchAssetBuilderLegacy(this.db, options)
|
||||
return searchAssetBuilder(this.db, options)
|
||||
.select((qb) => qb.fn.countAll<number>().as('total'))
|
||||
.executeTakeFirstOrThrow();
|
||||
}
|
||||
@@ -262,8 +235,8 @@ export class SearchRepository {
|
||||
],
|
||||
})
|
||||
async searchRandom(size: number, options: AssetSearchOptions) {
|
||||
return searchAssetBuilderLegacy(this.db, options)
|
||||
.select(columns.searchAsset)
|
||||
return searchAssetBuilder(this.db, options)
|
||||
.selectAll('asset')
|
||||
.orderBy(sql`random()`)
|
||||
.limit(size)
|
||||
.execute();
|
||||
@@ -283,8 +256,8 @@ export class SearchRepository {
|
||||
})
|
||||
searchLargeAssets(size: number, options: LargeAssetSearchOptions) {
|
||||
const orderDirection = (options.orderDirection?.toLowerCase() || 'desc') as OrderByDirection;
|
||||
return searchAssetBuilderLegacy(this.db, options)
|
||||
.select(columns.searchAsset)
|
||||
return searchAssetBuilder(this.db, options)
|
||||
.selectAll('asset')
|
||||
.$call(withExifInner)
|
||||
.where('asset_exif.fileSizeInByte', '>', options.minFileSize || 0)
|
||||
.orderBy('asset_exif.fileSizeInByte', orderDirection)
|
||||
@@ -312,11 +285,10 @@ export class SearchRepository {
|
||||
|
||||
return this.db.transaction().execute(async (trx) => {
|
||||
await sql`set local vchordrq.probes = ${sql.lit(probes[VectorIndex.Clip])}`.execute(trx);
|
||||
const items = await searchAssetBuilderLegacy(trx, options)
|
||||
.select(columns.searchAsset)
|
||||
const items = await searchAssetBuilder(trx, options)
|
||||
.selectAll('asset')
|
||||
.innerJoin('smart_search', 'asset.id', 'smart_search.assetId')
|
||||
.orderBy(sql`smart_search.embedding <=> ${options.embedding}`)
|
||||
.orderBy('asset.id', 'asc')
|
||||
.limit(pagination.size + 1)
|
||||
.offset((pagination.page - 1) * pagination.size)
|
||||
.execute();
|
||||
@@ -445,7 +417,7 @@ export class SearchRepository {
|
||||
.selectFrom('asset')
|
||||
.innerJoin('asset_exif', 'asset.id', 'asset_exif.assetId')
|
||||
.innerJoin('cte', 'asset.id', 'cte.assetId')
|
||||
.select(columns.searchAsset)
|
||||
.selectAll('asset')
|
||||
.select((eb) =>
|
||||
eb
|
||||
.fn('to_jsonb', [eb.table('asset_exif')])
|
||||
@@ -518,24 +490,6 @@ export class SearchRepository {
|
||||
return res.map((row) => row.lensModel!);
|
||||
}
|
||||
|
||||
@GenerateSql(...searchMetadataV3Examples)
|
||||
searchMetadataV3(
|
||||
pagination: AssetSearchPaginationV3Options,
|
||||
options: AssetSearchBuilderV3Options,
|
||||
): Promise<MapAsset[]> {
|
||||
return withSearchOrder(searchAssetBuilder(this.db, options), options.order)
|
||||
.select(columns.searchAsset)
|
||||
.limit(pagination.size)
|
||||
.execute();
|
||||
}
|
||||
|
||||
@GenerateSql(...searchStatisticsV3Examples)
|
||||
searchStatisticsV3(options: AssetSearchBuilderV3Options) {
|
||||
return searchAssetBuilder(this.db, options)
|
||||
.select((qb) => qb.fn.countAll<number>().as('total'))
|
||||
.executeTakeFirstOrThrow();
|
||||
}
|
||||
|
||||
private getExifField(field: 'city' | 'state' | 'country' | 'make' | 'model' | 'lensModel', userIds: string[]) {
|
||||
return this.db
|
||||
.selectFrom('asset_exif')
|
||||
|
||||
@@ -7,42 +7,21 @@ import {
|
||||
Kysely,
|
||||
KyselyConfig,
|
||||
NotNull,
|
||||
OperandValueExpression,
|
||||
ReferenceExpression,
|
||||
Selectable,
|
||||
SelectQueryBuilder,
|
||||
ShallowDehydrateObject,
|
||||
sql,
|
||||
SqlBool,
|
||||
} from 'kysely';
|
||||
import { PostgresJSDialect } from 'kysely-postgres-js';
|
||||
import { jsonArrayFrom, jsonObjectFrom } from 'kysely/helpers/postgres';
|
||||
import { Notice, PostgresError } from 'postgres';
|
||||
import { columns, lockableProperties, LockableProperty, Person } from 'src/database';
|
||||
import { DummyValue, GenerateSqlQueries } from 'src/decorators';
|
||||
import { AssetEditActionItem } from 'src/dtos/editing.dto';
|
||||
import {
|
||||
DEFAULT_SEARCH_ORDER,
|
||||
IdsFilter,
|
||||
SearchFilterBranch,
|
||||
SearchOrder,
|
||||
StringFilter,
|
||||
StringPatternFilter,
|
||||
} from 'src/dtos/search.dto';
|
||||
import {
|
||||
AssetFileType,
|
||||
AssetOrder,
|
||||
AssetOrderBy,
|
||||
AssetVisibility,
|
||||
DatabaseExtension,
|
||||
ExifOrientation,
|
||||
SearchOrderField,
|
||||
} from 'src/enum';
|
||||
import { AssetSearchBuilderOptions, AssetSearchBuilderV3Options } from 'src/repositories/search.repository';
|
||||
import { AssetFileType, AssetOrderBy, AssetVisibility, DatabaseExtension, ExifOrientation } from 'src/enum';
|
||||
import { AssetSearchBuilderOptions } from 'src/repositories/search.repository';
|
||||
import { DB } from 'src/schema';
|
||||
import { AssetExifTable } from 'src/schema/tables/asset-exif.table';
|
||||
import { AudioStreamInfo, VectorExtension, VideoFormat, VideoPacketInfo, VideoStreamInfo } from 'src/types';
|
||||
import { fromChecksum } from 'src/utils/request';
|
||||
|
||||
export const getKyselyConfig = (connection: DatabaseConnectionParams): KyselyConfig => {
|
||||
return {
|
||||
@@ -75,8 +54,6 @@ export const getKyselyConfig = (connection: DatabaseConnectionParams): KyselyCon
|
||||
};
|
||||
};
|
||||
|
||||
const uniqueIds = (ids: string[]) => [...new Set(ids)];
|
||||
|
||||
export const asUuid = (id: string | Expression<string>) => sql<string>`${id}::uuid`;
|
||||
|
||||
export const anyUuid = (ids: string[]) => sql<string>`any(${`{${ids}}`}::uuid[])`;
|
||||
@@ -108,15 +85,16 @@ export function withDefaultVisibility<O>(qb: SelectQueryBuilder<DB, 'asset', O>)
|
||||
return qb.where('asset.visibility', 'in', [sql.lit(AssetVisibility.Archive), sql.lit(AssetVisibility.Timeline)]);
|
||||
}
|
||||
|
||||
const selectExifInfo = (eb: AssetExpressionBuilder) =>
|
||||
eb.fn
|
||||
.toJson(eb.table('asset_exif'))
|
||||
.$castTo<ShallowDehydrateObject<Selectable<AssetExifTable>> | null>()
|
||||
.as('exifInfo');
|
||||
|
||||
// TODO come up with a better query that only selects the fields we need
|
||||
export function withExif<O>(qb: SelectQueryBuilder<DB, 'asset', O>) {
|
||||
return qb.leftJoin('asset_exif', 'asset.id', 'asset_exif.assetId').select(selectExifInfo);
|
||||
return qb
|
||||
.leftJoin('asset_exif', 'asset.id', 'asset_exif.assetId')
|
||||
.select((eb) =>
|
||||
eb.fn
|
||||
.toJson(eb.table('asset_exif'))
|
||||
.$castTo<ShallowDehydrateObject<Selectable<AssetExifTable>> | null>()
|
||||
.as('exifInfo'),
|
||||
);
|
||||
}
|
||||
|
||||
export function withExifInner<O>(qb: SelectQueryBuilder<DB, 'asset', O>) {
|
||||
@@ -395,7 +373,7 @@ export function withEdits(eb: ExpressionBuilder<DB, 'asset'>): AliasedEditAction
|
||||
const joinDeduplicationPlugin = new DeduplicateJoinsPlugin();
|
||||
/** TODO: This should only be used for search-related queries, not as a general purpose query builder */
|
||||
|
||||
export function searchAssetBuilderLegacy(kysely: Kysely<DB>, options: AssetSearchBuilderOptions) {
|
||||
export function searchAssetBuilder(kysely: Kysely<DB>, options: AssetSearchBuilderOptions) {
|
||||
options.withDeleted ||= !!(options.trashedAfter || options.trashedBefore || options.isOffline);
|
||||
|
||||
return kysely
|
||||
@@ -515,458 +493,6 @@ export function searchAssetBuilderLegacy(kysely: Kysely<DB>, options: AssetSearc
|
||||
.$if(!options.withDeleted, (qb) => qb.where('asset.deletedAt', 'is', null));
|
||||
}
|
||||
|
||||
type AssetExpressionBuilder = ExpressionBuilder<DB, 'asset' | 'asset_exif'>;
|
||||
|
||||
const albumAssets = (eb: AssetExpressionBuilder) =>
|
||||
eb.selectFrom('album_asset').whereRef('album_asset.assetId', '=', 'asset.id');
|
||||
|
||||
const visibleFaces = (eb: AssetExpressionBuilder) =>
|
||||
eb
|
||||
.selectFrom('asset_face')
|
||||
.whereRef('asset_face.assetId', '=', 'asset.id')
|
||||
.where('asset_face.deletedAt', 'is', null)
|
||||
.where('asset_face.isVisible', '=', true);
|
||||
|
||||
const tagAssets = (eb: AssetExpressionBuilder) =>
|
||||
eb.selectFrom('tag_asset').whereRef('tag_asset.assetId', '=', 'asset.id');
|
||||
|
||||
// shared any/all/none mechanics; `matchesAll` only receives deduplicated multi-id lists,
|
||||
// so its `count(distinct id) = ids.length` check stays satisfiable
|
||||
function idsPredicates(
|
||||
eb: AssetExpressionBuilder,
|
||||
{ any, all, none }: IdsFilter = {},
|
||||
ops: {
|
||||
matchesAny: (ids: string[]) => Expression<SqlBool>;
|
||||
matchesAll: (ids: string[]) => Expression<SqlBool>;
|
||||
},
|
||||
) {
|
||||
const predicates: Expression<SqlBool>[] = [];
|
||||
if (any) {
|
||||
predicates.push(ops.matchesAny(any));
|
||||
}
|
||||
if (all) {
|
||||
const ids = uniqueIds(all);
|
||||
predicates.push(ids.length === 1 ? ops.matchesAny(ids) : ops.matchesAll(ids));
|
||||
}
|
||||
if (none) {
|
||||
predicates.push(eb.not(ops.matchesAny(none)));
|
||||
}
|
||||
return predicates;
|
||||
}
|
||||
|
||||
function albumIdsPredicates(eb: AssetExpressionBuilder, filter?: IdsFilter) {
|
||||
const matching = (ids: string[]) => albumAssets(eb).where('album_asset.albumId', '=', anyUuid(ids));
|
||||
return idsPredicates(eb, filter, {
|
||||
matchesAny: (ids) => eb.exists(matching(ids)),
|
||||
matchesAll: (ids) =>
|
||||
eb.exists(
|
||||
matching(ids)
|
||||
.select('album_asset.assetId')
|
||||
.groupBy('album_asset.assetId')
|
||||
.having((eb) => eb.fn.count('album_asset.albumId').distinct(), '=', ids.length),
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
function personIdsPredicates(eb: AssetExpressionBuilder, filter?: IdsFilter) {
|
||||
const matching = (ids: string[]) => visibleFaces(eb).where('asset_face.personId', '=', anyUuid(ids));
|
||||
return idsPredicates(eb, filter, {
|
||||
matchesAny: (ids) => eb.exists(matching(ids)),
|
||||
matchesAll: (ids) =>
|
||||
eb.exists(
|
||||
matching(ids)
|
||||
.select('asset_face.assetId')
|
||||
.groupBy('asset_face.assetId')
|
||||
.having((eb) => eb.fn.count('asset_face.personId').distinct(), '=', ids.length),
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
function tagIdsPredicates(eb: AssetExpressionBuilder, filter?: IdsFilter) {
|
||||
const matching = (ids: string[]) =>
|
||||
tagAssets(eb)
|
||||
.innerJoin('tag_closure', 'tag_asset.tagId', 'tag_closure.id_descendant')
|
||||
.where('tag_closure.id_ancestor', '=', anyUuid(ids));
|
||||
return idsPredicates(eb, filter, {
|
||||
matchesAny: (ids) => eb.exists(matching(ids)),
|
||||
matchesAll: (ids) =>
|
||||
eb.exists(
|
||||
matching(ids)
|
||||
.select('tag_asset.assetId')
|
||||
.groupBy('tag_asset.assetId')
|
||||
.having((eb) => eb.fn.count('tag_closure.id_ancestor').distinct(), '=', ids.length),
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
type ComparisonFilter<T> = {
|
||||
eq?: T | null;
|
||||
ne?: T | null;
|
||||
lt?: T;
|
||||
lte?: T;
|
||||
gt?: T;
|
||||
gte?: T;
|
||||
in?: T[];
|
||||
notIn?: T[];
|
||||
};
|
||||
|
||||
// one operator dispatch for every filter shape; the DTO schemas constrain which
|
||||
// operators (and null literals) each filter can actually carry
|
||||
function comparisonPredicates<TB extends keyof DB, RE extends ReferenceExpression<DB, TB>>(
|
||||
eb: ExpressionBuilder<DB, TB>,
|
||||
column: RE,
|
||||
filter: ComparisonFilter<OperandValueExpression<DB, TB, RE>> = {},
|
||||
) {
|
||||
const predicates: Expression<SqlBool>[] = [];
|
||||
if (filter.eq !== undefined) {
|
||||
predicates.push(filter.eq === null ? eb(column, 'is', null) : eb(column, '=', filter.eq));
|
||||
}
|
||||
if (filter.ne !== undefined) {
|
||||
predicates.push(filter.ne === null ? eb(column, 'is not', null) : eb(column, '!=', filter.ne));
|
||||
}
|
||||
if (filter.lt !== undefined) {
|
||||
predicates.push(eb(column, '<', filter.lt));
|
||||
}
|
||||
if (filter.lte !== undefined) {
|
||||
predicates.push(eb(column, '<=', filter.lte));
|
||||
}
|
||||
if (filter.gt !== undefined) {
|
||||
predicates.push(eb(column, '>', filter.gt));
|
||||
}
|
||||
if (filter.gte !== undefined) {
|
||||
predicates.push(eb(column, '>=', filter.gte));
|
||||
}
|
||||
if (filter.in !== undefined) {
|
||||
predicates.push(eb(column, 'in', filter.in));
|
||||
}
|
||||
if (filter.notIn !== undefined) {
|
||||
predicates.push(eb(column, 'not in', filter.notIn));
|
||||
}
|
||||
return predicates;
|
||||
}
|
||||
|
||||
type StringColumn =
|
||||
| 'asset_exif.city'
|
||||
| 'asset_exif.state'
|
||||
| 'asset_exif.country'
|
||||
| 'asset_exif.make'
|
||||
| 'asset_exif.model'
|
||||
| 'asset_exif.lensModel'
|
||||
| 'asset_exif.description'
|
||||
| 'asset.originalFileName'
|
||||
| 'asset.originalPath';
|
||||
|
||||
function stringPatternPredicates(eb: AssetExpressionBuilder, column: StringColumn, filter: StringPatternFilter = {}) {
|
||||
const ref = sql.ref(column);
|
||||
const predicates = comparisonPredicates(eb, column, filter);
|
||||
if (filter.like !== undefined) {
|
||||
predicates.push(sql<SqlBool>`f_unaccent(${ref}) ilike ('%' || f_unaccent(${filter.like}) || '%')`);
|
||||
}
|
||||
if (filter.notLike !== undefined) {
|
||||
predicates.push(sql<SqlBool>`f_unaccent(${ref}) not ilike ('%' || f_unaccent(${filter.notLike}) || '%')`);
|
||||
}
|
||||
if (filter.startsWith !== undefined) {
|
||||
predicates.push(sql<SqlBool>`f_unaccent(${ref}) ilike (f_unaccent(${filter.startsWith}) || '%')`);
|
||||
}
|
||||
if (filter.endsWith !== undefined) {
|
||||
predicates.push(sql<SqlBool>`f_unaccent(${ref}) ilike ('%' || f_unaccent(${filter.endsWith}))`);
|
||||
}
|
||||
return predicates;
|
||||
}
|
||||
|
||||
function checksumPredicates(eb: AssetExpressionBuilder, filter: StringFilter = {}) {
|
||||
return comparisonPredicates(eb, 'asset.checksum', {
|
||||
eq: filter.eq === undefined ? undefined : fromChecksum(filter.eq),
|
||||
ne: filter.ne === undefined ? undefined : fromChecksum(filter.ne),
|
||||
in: filter.in?.map((checksum) => fromChecksum(checksum)),
|
||||
notIn: filter.notIn?.map((checksum) => fromChecksum(checksum)),
|
||||
});
|
||||
}
|
||||
|
||||
const encodedVideoFiles = (eb: AssetExpressionBuilder) =>
|
||||
eb
|
||||
.selectFrom('asset_file')
|
||||
.whereRef('asset_file.assetId', '=', 'asset.id')
|
||||
.where('asset_file.type', '=', AssetFileType.EncodedVideo);
|
||||
|
||||
function existsPredicates(
|
||||
eb: AssetExpressionBuilder,
|
||||
filter: { eq: boolean } | undefined,
|
||||
subquery: () => Expression<unknown>,
|
||||
): Expression<SqlBool>[] {
|
||||
if (!filter) {
|
||||
return [];
|
||||
}
|
||||
const exists = eb.exists(subquery());
|
||||
return [filter.eq ? exists : eb.not(exists)];
|
||||
}
|
||||
|
||||
// predicates are collected as expressions rather than chained `where` calls so the same
|
||||
// helpers can build each `or` branch, which must compose into eb.and/eb.or
|
||||
function branchPredicates(eb: AssetExpressionBuilder, branch: SearchFilterBranch) {
|
||||
const { encodedVideoPath } = branch;
|
||||
return [
|
||||
...comparisonPredicates(eb, 'asset.id', branch.id),
|
||||
...comparisonPredicates(eb, 'asset.libraryId', branch.libraryId),
|
||||
...comparisonPredicates(eb, 'asset.type', branch.type),
|
||||
...comparisonPredicates(eb, 'asset.visibility', branch.visibility),
|
||||
...(branch.isFavorite ? [eb('asset.isFavorite', '=', branch.isFavorite.eq)] : []),
|
||||
...(branch.isOffline ? [eb('asset.isOffline', '=', branch.isOffline.eq)] : []),
|
||||
...(branch.isMotion ? [eb('asset.livePhotoVideoId', branch.isMotion.eq ? 'is not' : 'is', null)] : []),
|
||||
...existsPredicates(eb, branch.isEncoded, () => encodedVideoFiles(eb)),
|
||||
...existsPredicates(eb, branch.hasAlbums, () => albumAssets(eb)),
|
||||
...existsPredicates(eb, branch.hasPeople, () => visibleFaces(eb)),
|
||||
...existsPredicates(eb, branch.hasTags, () => tagAssets(eb)),
|
||||
...comparisonPredicates(eb, 'asset_exif.city', branch.city),
|
||||
...comparisonPredicates(eb, 'asset_exif.state', branch.state),
|
||||
...comparisonPredicates(eb, 'asset_exif.country', branch.country),
|
||||
...comparisonPredicates(eb, 'asset_exif.make', branch.make),
|
||||
...comparisonPredicates(eb, 'asset_exif.model', branch.model),
|
||||
...comparisonPredicates(eb, 'asset_exif.lensModel', branch.lensModel),
|
||||
...stringPatternPredicates(eb, 'asset_exif.description', branch.description),
|
||||
...stringPatternPredicates(eb, 'asset.originalFileName', branch.originalFileName),
|
||||
...stringPatternPredicates(eb, 'asset.originalPath', branch.originalPath),
|
||||
...(branch.ocr
|
||||
? [
|
||||
eb.exists(
|
||||
eb
|
||||
.selectFrom('ocr_search')
|
||||
.whereRef('ocr_search.assetId', '=', 'asset.id')
|
||||
.where(
|
||||
sql<SqlBool>`f_unaccent(ocr_search.text) %>> f_unaccent(${tokenizeForSearch(branch.ocr.matches).join(' ')})`,
|
||||
),
|
||||
),
|
||||
]
|
||||
: []),
|
||||
...comparisonPredicates(eb, 'asset_exif.rating', branch.rating),
|
||||
...comparisonPredicates(eb, 'asset_exif.fileSizeInByte', branch.fileSizeInBytes),
|
||||
...comparisonPredicates(eb, 'asset.fileCreatedAt', branch.takenAt),
|
||||
...comparisonPredicates(eb, 'asset.createdAt', branch.createdAt),
|
||||
...comparisonPredicates(eb, 'asset.updatedAt', branch.updatedAt),
|
||||
...comparisonPredicates(eb, 'asset.deletedAt', branch.trashedAt),
|
||||
...albumIdsPredicates(eb, branch.albumIds),
|
||||
...personIdsPredicates(eb, branch.personIds),
|
||||
...tagIdsPredicates(eb, branch.tagIds),
|
||||
...checksumPredicates(eb, branch.checksum),
|
||||
...(encodedVideoPath
|
||||
? [
|
||||
eb.exists(
|
||||
encodedVideoFiles(eb)
|
||||
.where('asset_file.isEdited', '=', false)
|
||||
.where((eb) => eb.and(comparisonPredicates(eb, 'asset_file.path', encodedVideoPath))),
|
||||
),
|
||||
]
|
||||
: []),
|
||||
];
|
||||
}
|
||||
|
||||
// ordering is deliberately left to the caller so aggregate-only consumers (counts, stats)
|
||||
// can compose the same filters without stripping an order by
|
||||
export function searchAssetBuilder(kysely: Kysely<DB>, options: AssetSearchBuilderV3Options) {
|
||||
const filter = options.filter ?? {};
|
||||
|
||||
return (
|
||||
kysely
|
||||
.withPlugin(joinDeduplicationPlugin)
|
||||
.selectFrom('asset')
|
||||
// postgres eliminates the left join when no exif column is referenced, so unused joins are free
|
||||
.leftJoin('asset_exif', 'asset.id', 'asset_exif.assetId')
|
||||
.$if(!!options.withExif, (qb) => qb.select(selectExifInfo))
|
||||
.$if(!!options.userIds && options.userIds.length > 0, (qb) =>
|
||||
qb.where('asset.ownerId', '=', anyUuid(options.userIds!)),
|
||||
)
|
||||
.$if(!!(options.withFaces || options.withPeople), (qb) => qb.select(withFacesAndPeople))
|
||||
.$if(options.withStacked === false, (qb) => qb.where('asset.stackId', 'is', null))
|
||||
.where((eb) => {
|
||||
const predicates = branchPredicates(eb, filter);
|
||||
if (filter.or && filter.or.length > 0) {
|
||||
predicates.push(eb.or(filter.or.map((branch) => eb.and(branchPredicates(eb, branch)))));
|
||||
}
|
||||
return predicates.length > 0 ? eb.and(predicates) : eb.lit(true);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
const searchOrderColumns = {
|
||||
[SearchOrderField.FileCreatedAt]: { column: 'asset.fileCreatedAt', nullable: false },
|
||||
[SearchOrderField.LocalDateTime]: { column: 'asset.localDateTime', nullable: false },
|
||||
[SearchOrderField.FileSizeInBytes]: { column: 'asset_exif.fileSizeInByte', nullable: true },
|
||||
[SearchOrderField.Rating]: { column: 'asset_exif.rating', nullable: true },
|
||||
} as const;
|
||||
|
||||
export function withSearchOrder(qb: ReturnType<typeof searchAssetBuilder>, order?: SearchOrder) {
|
||||
const { field, direction } = order ?? DEFAULT_SEARCH_ORDER;
|
||||
const { column, nullable } = searchOrderColumns[field];
|
||||
return (
|
||||
qb
|
||||
.orderBy(column, (ob) => {
|
||||
const ordered = direction === AssetOrder.Asc ? ob.asc() : ob.desc();
|
||||
// nulls last: assets without an asset_exif row would otherwise lead descending results
|
||||
return nullable ? ordered.nullsLast() : ordered;
|
||||
})
|
||||
// id tie-break for deterministic pagination
|
||||
.orderBy('asset.id', direction)
|
||||
);
|
||||
}
|
||||
|
||||
export const searchMetadataV3Examples: GenerateSqlQueries[] = [
|
||||
{ name: 'baseline', params: [{ size: 100 }, { userIds: [DummyValue.UUID] }] },
|
||||
{ name: 'empty', params: [{ size: 100 }, {}] },
|
||||
{
|
||||
name: 'or-exif-only',
|
||||
params: [{ size: 100 }, { userIds: [DummyValue.UUID], filter: { or: [{ city: { eq: DummyValue.STRING } }] } }],
|
||||
},
|
||||
{
|
||||
name: 'string-eq-null',
|
||||
params: [{ size: 100 }, { userIds: [DummyValue.UUID], filter: { city: { eq: null } } }],
|
||||
},
|
||||
{
|
||||
name: 'string-pattern-like',
|
||||
params: [{ size: 100 }, { userIds: [DummyValue.UUID], filter: { description: { like: DummyValue.STRING } } }],
|
||||
},
|
||||
{
|
||||
name: 'string-pattern-notLike',
|
||||
params: [{ size: 100 }, { userIds: [DummyValue.UUID], filter: { description: { notLike: DummyValue.STRING } } }],
|
||||
},
|
||||
{
|
||||
name: 'string-pattern-startsWith',
|
||||
params: [
|
||||
{ size: 100 },
|
||||
{ userIds: [DummyValue.UUID], filter: { originalFileName: { startsWith: DummyValue.STRING } } },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'string-similarity-ocr',
|
||||
params: [{ size: 100 }, { userIds: [DummyValue.UUID], filter: { ocr: { matches: DummyValue.STRING } } }],
|
||||
},
|
||||
{
|
||||
name: 'ids-any',
|
||||
params: [{ size: 100 }, { userIds: [DummyValue.UUID], filter: { albumIds: { any: [DummyValue.UUID] } } }],
|
||||
},
|
||||
{
|
||||
name: 'ids-all',
|
||||
params: [
|
||||
{ size: 100 },
|
||||
{ userIds: [DummyValue.UUID], filter: { personIds: { all: [DummyValue.UUID, DummyValue.UUID_1] } } },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'ids-all-single',
|
||||
params: [{ size: 100 }, { userIds: [DummyValue.UUID], filter: { albumIds: { all: [DummyValue.UUID] } } }],
|
||||
},
|
||||
{
|
||||
name: 'ids-none',
|
||||
params: [{ size: 100 }, { userIds: [DummyValue.UUID], filter: { tagIds: { none: [DummyValue.UUID] } } }],
|
||||
},
|
||||
{
|
||||
name: 'ids-tags-all',
|
||||
params: [
|
||||
{ size: 100 },
|
||||
{ userIds: [DummyValue.UUID], filter: { tagIds: { all: [DummyValue.UUID, DummyValue.UUID_1] } } },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'has-albums-false',
|
||||
params: [{ size: 100 }, { userIds: [DummyValue.UUID], filter: { hasAlbums: { eq: false } } }],
|
||||
},
|
||||
{
|
||||
name: 'is-encoded',
|
||||
params: [{ size: 100 }, { userIds: [DummyValue.UUID], filter: { isEncoded: { eq: true } } }],
|
||||
},
|
||||
{
|
||||
name: 'number-range',
|
||||
params: [{ size: 100 }, { userIds: [DummyValue.UUID], filter: { fileSizeInBytes: { gte: 100, lte: 1000 } } }],
|
||||
},
|
||||
{
|
||||
name: 'date-eq',
|
||||
params: [{ size: 100 }, { userIds: [DummyValue.UUID], filter: { takenAt: { eq: DummyValue.DATE } } }],
|
||||
},
|
||||
{
|
||||
name: 'date-range',
|
||||
params: [
|
||||
{ size: 100 },
|
||||
{
|
||||
userIds: [DummyValue.UUID],
|
||||
filter: { takenAt: { gte: DummyValue.DATE, lt: DummyValue.DATE } },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'order-fileSize-noExif',
|
||||
params: [
|
||||
{ size: 100 },
|
||||
{
|
||||
userIds: [DummyValue.UUID],
|
||||
order: { field: SearchOrderField.FileSizeInBytes, direction: AssetOrder.Desc },
|
||||
withExif: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'order-rating-withExif',
|
||||
params: [
|
||||
{ size: 100 },
|
||||
{
|
||||
userIds: [DummyValue.UUID],
|
||||
order: { field: SearchOrderField.Rating, direction: AssetOrder.Asc },
|
||||
withExif: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'or-branches',
|
||||
params: [
|
||||
{ size: 100 },
|
||||
{
|
||||
userIds: [DummyValue.UUID],
|
||||
filter: {
|
||||
or: [{ isFavorite: { eq: true } }, { personIds: { any: [DummyValue.UUID] } }],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'or-with-top-level',
|
||||
params: [
|
||||
{ size: 100 },
|
||||
{
|
||||
userIds: [DummyValue.UUID],
|
||||
filter: {
|
||||
takenAt: { gte: DummyValue.DATE, lt: DummyValue.DATE },
|
||||
or: [{ isFavorite: { eq: true } }, { albumIds: { any: [DummyValue.UUID] } }],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export const searchStatisticsV3Examples: GenerateSqlQueries[] = [
|
||||
{ name: 'baseline', params: [{ userIds: [DummyValue.UUID] }] },
|
||||
{
|
||||
name: 'with-filter',
|
||||
params: [
|
||||
{
|
||||
userIds: [DummyValue.UUID],
|
||||
filter: {
|
||||
takenAt: { gte: DummyValue.DATE, lt: DummyValue.DATE },
|
||||
fileSizeInBytes: { gte: 100 },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'with-or',
|
||||
params: [
|
||||
{
|
||||
userIds: [DummyValue.UUID],
|
||||
filter: {
|
||||
or: [{ isFavorite: { eq: true } }, { hasAlbums: { eq: false } }],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export type ReindexVectorIndexOptions = { indexName: string; lists?: number };
|
||||
|
||||
type VectorIndexQueryOptions = { table: string; vectorExtension: VectorExtension } & ReindexVectorIndexOptions;
|
||||
|
||||
@@ -42,7 +42,7 @@ export function nonEmptyPartial<T extends z.ZodRawShape>(shape: T) {
|
||||
.object(shape)
|
||||
.partial()
|
||||
.refine((data) => Object.values(data as Record<string, unknown>).some((value) => value !== undefined), {
|
||||
message: `At least one of the following fields is required: ${Object.keys(shape).join(', ')}`,
|
||||
message: 'At least one field must be provided',
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
<script lang="ts">
|
||||
import { shortcuts } from '$lib/actions/shortcut';
|
||||
import { Icon } from '@immich/ui';
|
||||
import { mdiChevronRight, mdiChevronLeft } from '@mdi/js';
|
||||
import { mdiChevronRight } from '@mdi/js';
|
||||
import { t } from 'svelte-i18n';
|
||||
import NavigationArea from '../NavigationArea.svelte';
|
||||
import { languageManager } from '$lib/managers/language-manager.svelte';
|
||||
|
||||
interface Props {
|
||||
onNextAsset: () => void;
|
||||
@@ -21,5 +20,5 @@
|
||||
/>
|
||||
|
||||
<NavigationArea onClick={onNextAsset} label={$t('view_next_asset')}>
|
||||
<Icon icon={languageManager.rtl ? mdiChevronLeft : mdiChevronRight} size="36" aria-hidden />
|
||||
<Icon icon={mdiChevronRight} size="36" aria-hidden />
|
||||
</NavigationArea>
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
<script lang="ts">
|
||||
import { shortcuts } from '$lib/actions/shortcut';
|
||||
import { Icon } from '@immich/ui';
|
||||
import { mdiChevronLeft, mdiChevronRight } from '@mdi/js';
|
||||
import { mdiChevronLeft } from '@mdi/js';
|
||||
import { t } from 'svelte-i18n';
|
||||
import NavigationArea from '../NavigationArea.svelte';
|
||||
import { languageManager } from '$lib/managers/language-manager.svelte';
|
||||
|
||||
interface Props {
|
||||
onPreviousAsset: () => void;
|
||||
@@ -21,5 +20,5 @@
|
||||
/>
|
||||
|
||||
<NavigationArea onClick={onPreviousAsset} label={$t('view_previous_asset')}>
|
||||
<Icon icon={languageManager.rtl ? mdiChevronRight : mdiChevronLeft} size="36" aria-hidden />
|
||||
<Icon icon={mdiChevronLeft} size="36" aria-hidden />
|
||||
</NavigationArea>
|
||||
|
||||
@@ -95,8 +95,6 @@
|
||||
const getPreviousRoute = $page.url.searchParams.get(QueryParameter.PREVIOUS_ROUTE);
|
||||
if (getPreviousRoute && !isExternalUrl(getPreviousRoute)) {
|
||||
previousRoute = getPreviousRoute;
|
||||
} else if ($page.params.assetId) {
|
||||
previousRoute = Route.viewPerson(data.person);
|
||||
}
|
||||
if (action == 'merge') {
|
||||
viewMode = PersonPageViewMode.MERGE_PEOPLE;
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
<script lang="ts">
|
||||
import { languageManager } from '$lib/managers/language-manager.svelte';
|
||||
import { Button, Icon } from '@immich/ui';
|
||||
import { mdiArrowLeft, mdiArrowRight, mdiCheck } from '@mdi/js';
|
||||
import type { Snippet } from 'svelte';
|
||||
@@ -53,7 +52,7 @@
|
||||
<div class="flex w-full place-content-start">
|
||||
<Button
|
||||
shape="round"
|
||||
leadingIcon={languageManager.rtl ? mdiArrowRight : mdiArrowLeft}
|
||||
leadingIcon={mdiArrowLeft}
|
||||
class="flex place-content-center gap-2"
|
||||
onclick={() => {
|
||||
onLeave?.();
|
||||
@@ -68,7 +67,7 @@
|
||||
<div class="flex w-full place-content-end">
|
||||
<Button
|
||||
shape="round"
|
||||
trailingIcon={nextTitle ? (languageManager.rtl ? mdiArrowLeft : mdiArrowRight) : mdiCheck}
|
||||
trailingIcon={nextTitle ? mdiArrowRight : mdiCheck}
|
||||
onclick={() => {
|
||||
onLeave?.();
|
||||
onNext?.();
|
||||
|
||||
Reference in New Issue
Block a user