Compare commits

..

5 Commits

Author SHA1 Message Date
shenlong-tanwen
74f9a5cbad refactor: asset update method 2026-07-24 19:50:24 +05:30
NOBOIKE
5fa920a5f0 fix(web): use RTL transform origin in AdaptiveImage (#30182) 2026-07-24 10:44:33 +02:00
Jason Rasmussen
acc7e6b299 fix: min faces user preference (#30177) 2026-07-23 17:06:09 -04:00
Adam Gastineau
3af94a4805 fix(mobile): properly align iOS widget loading state (#30173) 2026-07-23 12:31:33 -07:00
Adam Gastineau
f488a28018 chore(mobile): force swift-structured-queries lock to 0.34.0 (#30166) 2026-07-23 17:04:01 +00:00
51 changed files with 1147 additions and 581 deletions

View File

@@ -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

View File

@@ -1,23 +1,19 @@
# Casting support
# Chromecast support
Immich supports both the FCast and Google Cast protocols, meaning photos and videos in Immich can be cast to devices such as a Chromecast, Nest Hub, or an FCast receiver. This feature is considered experimental and has several important limitations listed below. This feature is supported by the web client, as well as the mobile app via FCast.
Immich supports the Google's Cast protocol so that photos and videos can be cast to devices such as a Chromecast and a Nest Hub. This feature is considered experimental and has several important limitations listed below. Currently, this feature is only supported by the web client, support on Android and iOS is planned for the future.
## Mobile FCast support
## Enable Google Cast 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.
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.
## Enable web support
Casting from the web client is currently only possible via Google Cast, so enabling web support means enabling Google Cast. Google Cast support is disabled by default, since it requires the web UI to load Google-provided scripts from Google's servers when the page loads. This is a privacy concern for some and is thus opt-in.
You can enable Google Cast support through `Account Settings > Features > Cast > Google Cast`.
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' />
## Limitations
To use Google Cast with Immich, there are a few prerequisites:
To use casting with Immich, there are a few prerequisites:
1. Your instance must be accessed via an HTTPS connection in order for the casting menu to show.
2. Your instance must be publicly accessible via HTTPS and a DNS record for the server must be accessible via Google's DNS servers (`8.8.8.8` and `8.8.4.4`).
3. Videos must be in a format that is compatible with Google Cast. For more info, check out [Google's documentation](https://developers.google.com/cast/docs/media).
2. Your instance must be publicly accessible via HTTPS and a DNS record for the server must be accessible via Google's DNS servers (`8.8.8.8` and `8.8.4.4`)
3. Videos must be in a format that is compatible with Google Cast. For more info, check out [Google's documentation](https://developers.google.com/cast/docs/media)

View File

@@ -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)

View File

@@ -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

View File

@@ -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"
}
},
{

View File

@@ -140,8 +140,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/pointfreeco/swift-structured-queries",
"state" : {
"revision" : "8da8818fccd9959bd683934ddc62cf45bb65b3c8",
"version" : "0.31.1"
"revision" : "dafddd843f10630aa6b5be47c1b6a59cc4ab6586",
"version" : "0.34.0"
}
},
{

View File

@@ -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>

View File

@@ -18,15 +18,21 @@ struct ImmichWidgetView: View {
var body: some View {
if entry.image == nil {
VStack {
Image("LaunchImage")
.tintedWidgetImageModifier()
Text(entry.metadata.error?.errorDescription ?? "")
.minimumScaleFactor(0.25)
.multilineTextAlignment(.center)
.foregroundStyle(.secondary)
}
.padding(16)
Image("LaunchImage")
.tintedWidgetImageModifier()
.overlay(alignment: .bottom) {
if let error = entry.metadata.error?.errorDescription {
Text(error)
.minimumScaleFactor(0.25)
.multilineTextAlignment(.center)
.foregroundStyle(.secondary)
.fixedSize()
.alignmentGuide(.bottom) { dimensions in
// Place the text below the bottom of the image
dimensions[.top] - 8
}
}
}
} else {
ZStack(alignment: .leading) {
Color.clear.overlay(

View File

@@ -60,6 +60,7 @@ sealed class BaseAsset {
bool get hasLocal => storage == AssetState.local || storage == AssetState.merged;
bool get isLocalOnly => storage == AssetState.local;
bool get isRemoteOnly => storage == AssetState.remote;
bool get isMerged => storage == .merged;
// Same asset even if localId is known on one side but not the other (heroTag isn't stable then)
bool refersToSameAsset(BaseAsset other) {

View File

@@ -74,6 +74,8 @@ class RemoteAsset extends BaseAsset {
bool get isArchived => visibility == .archive;
bool get isLocked => visibility == .locked;
@override
String toString() {
return '''Asset {

View File

@@ -1,16 +1,26 @@
import 'package:immich_mobile/domain/models/album/local_album.model.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/asset_edit.model.dart';
import 'package:immich_mobile/domain/models/exif.model.dart';
import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/remote_asset.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/remote_exif.repository.dart';
import 'package:immich_mobile/repositories/asset_api.repository.dart';
import 'package:immich_mobile/utils/option.dart';
import 'package:maplibre_gl/maplibre_gl.dart';
class AssetService {
final RemoteAssetRepository _remoteRepository;
final RemoteExifRepository _exifRepository;
final DriftLocalAssetRepository _localRepository;
final AssetApiRepository _apiRepository;
const AssetService({required this._remoteRepository, required this._localRepository, required this._apiRepository});
const AssetService({
required this._remoteRepository,
required this._exifRepository,
required this._localRepository,
required this._apiRepository,
});
Future<BaseAsset?> getAsset(BaseAsset asset) {
final id = asset is LocalAsset ? asset.id : (asset as RemoteAsset).id;
@@ -26,10 +36,6 @@ class AssetService {
return _localRepository.getByChecksum(checksum);
}
Future<LocalAsset?> getLocalAsset(String id) {
return _localRepository.get(id);
}
Future<RemoteAsset?> getRemoteAssetByChecksum(String checksum) {
return _remoteRepository.getByChecksum(checksum);
}
@@ -73,6 +79,99 @@ class AssetService {
return _localRepository.getSourceAlbums(localAssetId, backupSelection: backupSelection);
}
Future<void> restoreTrash(List<String> remoteIds) async {
if (remoteIds.isEmpty) {
return;
}
await _apiRepository.restoreTrash(remoteIds);
await _remoteRepository.restoreTrash(remoteIds);
}
Future<void> stack(String userId, List<String> remoteIds) async {
if (remoteIds.isEmpty) {
return;
}
final stack = await _apiRepository.stack(remoteIds);
await _remoteRepository.stack(userId, stack);
}
Future<void> unstack(List<String> stackIds) async {
if (stackIds.isEmpty) {
return;
}
await _remoteRepository.unStack(stackIds);
await _apiRepository.unStack(stackIds);
}
Future<void> update(
List<String> remoteIds, {
Option<bool> isFavorite = const .none(),
Option<AssetVisibility> visibility = const .none(),
Option<LatLng> location = const .none(),
Option<String> dateTime = const .none(),
}) async {
if (remoteIds.isEmpty) {
return;
}
final parsedDateTime = dateTime.map((dt) => DateTime.parse(dt));
final offset = RegExp(r'[+-]\d{2}:\d{2}$').firstMatch(dateTime.unwrapOrNull ?? '')?.group(0);
await _apiRepository.update(
remoteIds,
isFavorite: isFavorite,
visibility: visibility,
location: location,
dateTimeOriginal: dateTime,
);
await _remoteRepository.update(
remoteIds,
isFavorite: isFavorite,
visibility: visibility,
createdAt: parsedDateTime,
);
await _exifRepository.update(
remoteIds,
location: location,
dateTimeOriginal: parsedDateTime,
timeZone: .fromNullable(offset).map((o) => 'UTC$o'),
);
}
Future<void> trash(List<String> remoteIds) async {
if (remoteIds.isEmpty) {
return;
}
await _apiRepository.delete(remoteIds, false);
await _remoteRepository.trash(remoteIds);
}
Future<void> delete(List<String> remoteIds) async {
if (remoteIds.isEmpty) {
return;
}
await _apiRepository.delete(remoteIds, true);
await _remoteRepository.delete(remoteIds);
}
Future<void> applyEdits(String remoteId, List<AssetEdit> edits) async {
if (edits.isEmpty) {
await _apiRepository.removeEdits(remoteId);
} else {
await _apiRepository.editAsset(remoteId, edits);
}
}
// TODO(shenlong): remove after action migration
Future<LocalAsset?> getLocalAsset(String id) {
return _localRepository.get(id);
}
Future<void> updateFavorite(List<String> remoteIds, bool isFavorite) async {
if (remoteIds.isEmpty) {
return;

View File

@@ -341,4 +341,10 @@ class RemoteAlbumService {
return sortedAlbums;
}
Future<int> removeAssets({required String albumId, required List<String> assetIds}) async {
final result = await _albumApiRepository.removeAssets(albumId, assetIds);
await _repository.removeAssets(albumId, result.removed);
return result.removed.length;
}
}

View File

@@ -72,13 +72,7 @@ class RemoteAssetRepository extends DriftDatabaseRepository {
}
final query = _db.remoteAssetEntity.select()
..where(
(row) =>
row.stackId.equals(stackId) &
row.id.equals(asset.id).not() &
row.deletedAt.isNull() &
row.visibility.equalsValue(AssetVisibility.timeline),
)
..where((row) => row.stackId.equals(stackId) & row.id.equals(asset.id).not())
..orderBy([(row) => OrderingTerm.desc(row.createdAt)]);
return query.map((row) => row.toDto()).get();
@@ -189,38 +183,6 @@ class RemoteAssetRepository extends DriftDatabaseRepository {
});
}
Future<void> updateLocation(List<String> ids, LatLng location) {
return _db.batch((batch) async {
for (final id in ids) {
batch.update(
_db.remoteExifEntity,
RemoteExifEntityCompanion(latitude: Value(location.latitude), longitude: Value(location.longitude)),
where: (e) => e.assetId.equals(id),
);
}
});
}
Future<void> updateDateTime(List<String> ids, DateTime dateTime, {String? timeZone}) {
return _db.batch((batch) async {
for (final id in ids) {
batch.update(
_db.remoteExifEntity,
RemoteExifEntityCompanion(
dateTimeOriginal: Value(dateTime),
timeZone: timeZone == null ? const Value.absent() : Value(timeZone),
),
where: (e) => e.assetId.equals(id),
);
batch.update(
_db.remoteAssetEntity,
RemoteAssetEntityCompanion(createdAt: Value(dateTime)),
where: (e) => e.id.equals(id),
);
}
});
}
Future<void> stack(String userId, StackResponse stack) {
return _db.transaction(() async {
final stackIds = await _db.managers.stackEntity
@@ -298,10 +260,16 @@ class RemoteAssetRepository extends DriftDatabaseRepository {
List<String> remoteIds, {
Option<bool> isFavorite = const .none(),
Option<AssetVisibility> visibility = const .none(),
}) {
Option<DateTime> createdAt = const .none(),
}) async {
if ([isFavorite, visibility, createdAt].every((option) => option.isNone)) {
return;
}
final companion = RemoteAssetEntityCompanion(
visibility: visibility.toDriftValue(),
isFavorite: isFavorite.toDriftValue(),
createdAt: createdAt.toDriftValue(),
);
return _db.batch((batch) {
for (final remoteId in remoteIds) {
@@ -309,4 +277,37 @@ class RemoteAssetRepository extends DriftDatabaseRepository {
}
});
}
// TODO(shenlong): remove after action migration
Future<void> updateLocation(List<String> ids, LatLng location) {
return _db.batch((batch) async {
for (final id in ids) {
batch.update(
_db.remoteExifEntity,
RemoteExifEntityCompanion(latitude: Value(location.latitude), longitude: Value(location.longitude)),
where: (e) => e.assetId.equals(id),
);
}
});
}
Future<void> updateDateTime(List<String> ids, DateTime dateTime, {String? timeZone}) {
return _db.batch((batch) async {
for (final id in ids) {
batch.update(
_db.remoteExifEntity,
RemoteExifEntityCompanion(
dateTimeOriginal: Value(dateTime),
timeZone: timeZone == null ? const Value.absent() : Value(timeZone),
),
where: (e) => e.assetId.equals(id),
);
batch.update(
_db.remoteAssetEntity,
RemoteAssetEntityCompanion(createdAt: Value(dateTime)),
where: (e) => e.id.equals(id),
);
}
});
}
}

View File

@@ -0,0 +1,34 @@
import 'package:immich_mobile/infrastructure/entities/exif.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 RemoteExifRepository extends DriftDatabaseRepository {
final Drift _db;
const RemoteExifRepository(this._db) : super(_db);
Future<void> update(
List<String> ids, {
Option<DateTime> dateTimeOriginal = const .none(),
Option<String> timeZone = const .none(),
Option<LatLng> location = const .none(),
}) async {
if ([dateTimeOriginal, timeZone, location].every((option) => option.isNone)) {
return;
}
final companion = RemoteExifEntityCompanion(
dateTimeOriginal: dateTimeOriginal.toDriftValue(),
timeZone: timeZone.toDriftValue(),
latitude: location.map((loc) => loc.latitude).toDriftValue(),
longitude: location.map((loc) => loc.longitude).toDriftValue(),
);
return _db.batch((batch) {
for (final id in ids) {
batch.update(_db.remoteExifEntity, companion, where: (a) => a.assetId.equals(id));
}
});
}
}

View File

@@ -1,6 +1,6 @@
import 'dart:convert';
enum CastDestinationType { googleCast, fCast }
enum CastDestinationType { googleCast }
enum CastState { idle, playing, paused, buffering }

View File

@@ -23,7 +23,7 @@ class _ActionWidget extends ConsumerWidget {
try {
await action.onAction(scope);
} catch (error, stackTrace) {
handleError(scope.context, error, stack: stackTrace, description: 'Action failed: ${action.runtimeType}');
handleError(scope.context, stack: stackTrace, description: 'Action failed: ${action.runtimeType}');
}
}

View File

@@ -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();
}
}

View File

@@ -2,6 +2,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/services/asset.service.dart';
import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/remote_asset.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/remote_exif.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/trashed_local_asset.repository.dart';
import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
@@ -15,6 +16,8 @@ final remoteAssetRepositoryProvider = Provider<RemoteAssetRepository>(
(ref) => RemoteAssetRepository(ref.watch(driftProvider)),
);
final remoteExifRepositoryProvider = Provider((ref) => RemoteExifRepository(ref.watch(driftProvider)));
final trashedLocalAssetRepository = Provider<DriftTrashedLocalAssetRepository>(
(ref) => DriftTrashedLocalAssetRepository(ref.watch(driftProvider)),
);
@@ -22,6 +25,7 @@ final trashedLocalAssetRepository = Provider<DriftTrashedLocalAssetRepository>(
final assetServiceProvider = Provider(
(ref) => AssetService(
remoteRepository: ref.watch(remoteAssetRepositoryProvider),
exifRepository: ref.watch(remoteExifRepositoryProvider),
localRepository: ref.watch(localAssetRepository),
apiRepository: ref.watch(assetApiRepositoryProvider),
),

View File

@@ -43,28 +43,11 @@ class AssetApiRepository extends ApiRepository {
return response?.count ?? 0;
}
// TODO(shenlong): remove after action migration
Future<void> updateVisibility(List<String> ids, AssetVisibility visibility) async {
return _api.updateAssets(AssetBulkUpdateDto(ids: ids, visibility: Optional.present(_mapVisibility(visibility))));
}
Future<void> updateFavorite(List<String> ids, bool isFavorite) async {
return _api.updateAssets(AssetBulkUpdateDto(ids: ids, isFavorite: Optional.present(isFavorite)));
}
Future<void> updateLocation(List<String> ids, LatLng location) async {
return _api.updateAssets(
AssetBulkUpdateDto(
ids: ids,
latitude: Optional.present(location.latitude),
longitude: Optional.present(location.longitude),
),
);
}
Future<void> updateDateTime(List<String> ids, String dateTime) async {
return _api.updateAssets(AssetBulkUpdateDto(ids: ids, dateTimeOriginal: Optional.present(dateTime)));
}
Future<StackResponse> stack(List<String> ids) async {
final responseDto = await checkNull(_stacksApi.createStack(StackCreateDto(assetIds: ids)));
@@ -113,15 +96,39 @@ class AssetApiRepository extends ApiRepository {
List<String> remoteIds, {
Option<bool> isFavorite = const .none(),
Option<AssetVisibility> visibility = const .none(),
Option<String> dateTimeOriginal = const .none(),
Option<LatLng> location = const .none(),
}) {
return _api.updateAssets(
AssetBulkUpdateDto(
ids: remoteIds,
isFavorite: isFavorite.toOptional(),
visibility: visibility.map(_mapVisibility).toOptional(),
dateTimeOriginal: dateTimeOriginal.toOptional(),
latitude: location.map((loc) => loc.latitude).toOptional(),
longitude: location.map((loc) => loc.longitude).toOptional(),
),
);
}
// TODO(shenlong): remove after action migration
Future<void> updateFavorite(List<String> ids, bool isFavorite) async {
return _api.updateAssets(AssetBulkUpdateDto(ids: ids, isFavorite: Optional.present(isFavorite)));
}
Future<void> updateLocation(List<String> ids, LatLng location) async {
return _api.updateAssets(
AssetBulkUpdateDto(
ids: ids,
latitude: Optional.present(location.latitude),
longitude: Optional.present(location.longitude),
),
);
}
Future<void> updateDateTime(List<String> ids, String dateTime) async {
return _api.updateAssets(AssetBulkUpdateDto(ids: ids, dateTimeOriginal: Optional.present(dateTime)));
}
}
extension on StackResponseDto {

View File

@@ -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();
}
}

View 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));
}
}

View File

@@ -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);
}
}

View 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);
}
}

View File

@@ -15,8 +15,10 @@ extension type const AssetFilter<T extends BaseAsset>(Iterable<T> assets) implem
remote().where((asset) => asset.visibility != visibility);
AssetFilter<RemoteAsset> archived({bool isArchived = true}) =>
remote().where((asset) => asset.isArchived == isArchived);
AssetFilter<RemoteAsset> locked({bool isLocked = true}) => remote().where((asset) => asset.isLocked == isLocked);
AssetFilter<RemoteAsset> stacked({bool isStacked = true}) => remote().where((asset) => asset.isStacked == isStacked);
AssetFilter<RemoteAsset> trashed({bool isTrashed = true}) => remote().where((asset) => asset.isTrashed == isTrashed);
AssetFilter<LocalAsset> local() => AssetFilter(assets.whereType<LocalAsset>());
AssetFilter<LocalAsset> backedUp() => local().where((asset) => asset.remoteAssetId != null);
AssetFilter<BaseAsset> backedUp({bool isBackedUp = true}) => where((asset) => asset.isMerged == isBackedUp);
}

View File

@@ -8,7 +8,7 @@ import 'package:openapi/api.dart';
// ignore: depend_on_referenced_packages
import 'package:stack_trace/stack_trace.dart';
void handleError(BuildContext context, Object error, {StackTrace? stack, String? description}) {
void handleError(Object error, {StackTrace? stack, String? description}) {
String? stackTrace;
if (stack != null) {
final trace = Trace.from(stack);
@@ -23,17 +23,13 @@ void handleError(BuildContext context, Object error, {StackTrace? stack, String?
() => 'Error${description != null ? ' ($description)' : ''}: $error${stackTrace != null ? '\n$stackTrace' : ''}',
);
if (!context.mounted) {
return;
}
final String message;
if (serverErrorMessage(error) case String serverMessage) {
message = serverMessage;
} else if (isConnectionError(error)) {
message = context.t.login_form_server_error;
message = StaticTranslations.instance.login_form_server_error;
} else {
message = context.t.scaffold_body_error_occurred;
message = StaticTranslations.instance.scaffold_body_error_occurred;
}
snackbar.error(message);

View File

@@ -32,17 +32,6 @@ sealed class Option<T> {
None() => onNone(),
};
Option<U> flatMap<U>(Option<U> Function(T value) f) => switch (this) {
Some(:final value) => f(value),
None() => const Option.none(),
};
void ifPresent(void Function(T value) f) {
if (this case Some(:final value)) {
f(value);
}
}
@override
String toString() => switch (this) {
Some(:final value) => 'Some($value)',

View File

@@ -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));
}
},
);

View File

@@ -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:

View File

@@ -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,11 +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_darwin:
git:
url: https://github.com/Skyost/Bonsoir
ref: ce155549130e # fix(darwin): add missing Foundation import; still v6.1.0
path: packages/bonsoir_darwin
bonsoir: ^5.1.11
objective_c:
git:
url: https://github.com/mertalev/native

View File

@@ -1,22 +0,0 @@
import 'package:immich_mobile/domain/services/asset.service.dart';
import 'package:immich_mobile/domain/services/partner.service.dart';
import 'package:immich_mobile/domain/services/store.service.dart';
import 'package:immich_mobile/domain/services/user.service.dart';
import 'package:immich_mobile/domain/utils/background_sync.dart';
import 'package:immich_mobile/platform/native_sync_api.g.dart';
import 'package:immich_mobile/services/app_settings.service.dart';
import 'package:mocktail/mocktail.dart';
class MockStoreService extends Mock implements StoreService {}
class MockBackgroundSyncManager extends Mock implements BackgroundSyncManager {}
class MockNativeSyncApi extends Mock implements NativeSyncApi {}
class MockAppSettingsService extends Mock implements AppSettingsService {}
class MockPartnerService extends Mock implements PartnerService {}
class MockAssetService extends Mock implements AssetService {}
class MockUserService extends Mock implements UserService {}

View File

@@ -16,10 +16,10 @@ import 'package:immich_mobile/platform/native_sync_api.g.dart';
import 'package:immich_mobile/repositories/asset_media.repository.dart';
import 'package:mocktail/mocktail.dart';
import '../../domain/service.mock.dart';
import '../../fixtures/asset.stub.dart';
import '../../infrastructure/repository.mock.dart';
import '../../repository.mocks.dart';
import '../../service.mocks.dart';
void main() {
late LocalSyncService sut;

View File

@@ -6,7 +6,7 @@ import 'package:immich_mobile/providers/infrastructure/store.provider.dart';
import 'package:immich_mobile/repositories/drift_album_api_repository.dart';
import '../../infrastructure/repository.mock.dart';
import '../service.mock.dart';
import '../../service.mocks.dart';
void main() {
// A container with the service's deps overridden but cancellationProvider left

View File

@@ -9,7 +9,7 @@ import 'package:mocktail/mocktail.dart';
import '../../fixtures/user.stub.dart';
import '../../infrastructure/repository.mock.dart';
import '../service.mock.dart';
import '../../service.mocks.dart';
void main() {
late UserService sut;

View File

@@ -14,6 +14,7 @@ import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart'
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
import '../../../fixtures/asset.stub.dart';
import '../../../unit/presentation/presentation_context.dart';
class _SeededAssetViewerNotifier extends AssetViewerStateNotifier {
@override
@@ -32,6 +33,12 @@ TimelineService _stubTimelineService() {
}
void main() {
late PresentationContext context;
setUp(() async {
context = await PresentationContext.create();
});
testWidgets('status bar icons are light while the asset viewer is open in light mode', (tester) async {
// Emulate arriving from a light-themed page whose AppBar set dark status
// bar icons (the state the viewer is opened from in light mode).
@@ -48,6 +55,7 @@ void main() {
assetLoader: const CodegenLoader(),
child: ProviderScope(
overrides: [
...context.overrides,
timelineServiceProvider.overrideWithValue(_stubTimelineService()),
assetViewerProvider.overrideWith(_SeededAssetViewerNotifier.new),
],

View File

@@ -7,7 +7,7 @@ import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart'
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
import 'package:mocktail/mocktail.dart';
import '../../domain/service.mock.dart';
import '../../service.mocks.dart';
import '../../unit/factories/remote_asset_factory.dart';
void main() {

View File

@@ -1,9 +1,12 @@
import 'package:immich_mobile/domain/services/tag.service.dart';
import 'package:immich_mobile/infrastructure/repositories/remote_exif.repository.dart';
import 'package:immich_mobile/repositories/asset_api.repository.dart';
import 'package:immich_mobile/repositories/asset_media.repository.dart';
import 'package:immich_mobile/repositories/auth.repository.dart';
import 'package:immich_mobile/repositories/auth_api.repository.dart';
import 'package:immich_mobile/domain/services/tag.service.dart';
import 'package:immich_mobile/repositories/download.repository.dart';
import 'package:immich_mobile/repositories/permission.repository.dart';
import 'package:immich_mobile/repositories/toast.repository.dart';
import 'package:mocktail/mocktail.dart';
class MockAssetApiRepository extends Mock implements AssetApiRepository {}
@@ -17,3 +20,9 @@ class MockAuthApiRepository extends Mock implements AuthApiRepository {}
class MockAuthRepository extends Mock implements AuthRepository {}
class MockTagService extends Mock implements TagService {}
class MockDownloadRepository extends Mock implements DownloadRepository {}
class MockRemoteExifRepository extends Mock implements RemoteExifRepository {}
class MockToastRepository extends Mock implements ToastRepository {}

View File

@@ -1,6 +1,17 @@
import 'package:immich_mobile/domain/services/asset.service.dart';
import 'package:immich_mobile/domain/services/partner.service.dart';
import 'package:immich_mobile/domain/services/remote_album.service.dart';
import 'package:immich_mobile/domain/services/store.service.dart';
import 'package:immich_mobile/domain/services/user.service.dart';
import 'package:immich_mobile/domain/utils/background_sync.dart';
import 'package:immich_mobile/platform/native_sync_api.g.dart';
import 'package:immich_mobile/services/api.service.dart';
import 'package:immich_mobile/services/app_settings.service.dart';
import 'package:immich_mobile/services/cleanup.service.dart';
import 'package:immich_mobile/services/foreground_upload.service.dart';
import 'package:immich_mobile/services/gcast.service.dart';
import 'package:immich_mobile/services/network.service.dart';
import 'package:immich_mobile/services/server_info.service.dart';
import 'package:mocktail/mocktail.dart';
class MockApiService extends Mock implements ApiService {}
@@ -8,3 +19,27 @@ class MockApiService extends Mock implements ApiService {}
class MockNetworkService extends Mock implements NetworkService {}
class MockAppSettingService extends Mock implements AppSettingsService {}
class MockStoreService extends Mock implements StoreService {}
class MockNativeSyncApi extends Mock implements NativeSyncApi {}
class MockAppSettingsService extends Mock implements AppSettingsService {}
class MockPartnerService extends Mock implements PartnerService {}
class MockAssetService extends Mock implements AssetService {}
class MockUserService extends Mock implements UserService {}
class MockRemoteAlbumService extends Mock implements RemoteAlbumService {}
class MockGCastService extends Mock implements GCastService {}
class MockForegroundUploadService extends Mock implements ForegroundUploadService {}
class MockServerInfoService extends Mock implements ServerInfoService {}
class MockCleanupService extends Mock implements CleanupService {}
class MockBackgroundSyncManager extends Mock implements BackgroundSyncManager {}

View File

@@ -7,15 +7,12 @@ import 'package:immich_mobile/domain/services/store.service.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/store.repository.dart';
import 'package:immich_mobile/repositories/download.repository.dart';
import 'package:immich_mobile/services/action.service.dart';
import 'package:mocktail/mocktail.dart';
import '../infrastructure/repository.mock.dart';
import '../repository.mocks.dart';
class MockDownloadRepository extends Mock implements DownloadRepository {}
void main() {
late ActionService sut;

View File

@@ -10,7 +10,6 @@ import 'package:immich_mobile/services/auth.service.dart';
import 'package:mocktail/mocktail.dart';
import 'package:openapi/api.dart';
import '../domain/service.mock.dart';
import '../repository.mocks.dart';
import '../service.mocks.dart';
@@ -29,13 +28,7 @@ void main() {
apiService = MockApiService();
networkService = MockNetworkService();
backgroundSyncManager = MockBackgroundSyncManager();
sut = AuthService(
authApiRepository,
authRepository,
apiService,
networkService,
backgroundSyncManager,
);
sut = AuthService(authApiRepository, authRepository, apiService, networkService, backgroundSyncManager);
registerFallbackValue(Uri());
});

View File

@@ -5,12 +5,13 @@ import '../../utils.dart';
class LocalAssetFactory {
const LocalAssetFactory();
static LocalAsset create({String? id, String? name}) {
static LocalAsset create({String? id, String? name, String? remoteId}) {
id = TestUtils.uuid(id);
return LocalAsset(
id: id,
name: name ?? 'local_$id.jpg',
remoteId: remoteId,
type: AssetType.image,
createdAt: TestUtils.yesterday(),
updatedAt: TestUtils.now(),

View File

@@ -0,0 +1,38 @@
import 'package:immich_mobile/domain/models/album/album.model.dart';
import '../../utils.dart';
class RemoteAlbumFactory {
const RemoteAlbumFactory();
static RemoteAlbum create({
String? id,
String? name,
String? ownerId,
String? description,
DateTime? createdAt,
DateTime? updatedAt,
String? thumbnailAssetId,
bool isActivityEnabled = false,
AlbumAssetOrder order = AlbumAssetOrder.desc,
int assetCount = 0,
String? ownerName,
bool isShared = false,
}) {
id = TestUtils.uuid(id);
return RemoteAlbum(
id: id,
name: name ?? 'remote_album_$id',
ownerId: TestUtils.uuid(ownerId),
description: description ?? '',
createdAt: TestUtils.date(createdAt),
updatedAt: TestUtils.date(updatedAt),
thumbnailAssetId: thumbnailAssetId,
isActivityEnabled: isActivityEnabled,
order: order,
assetCount: assetCount,
ownerName: ownerName ?? 'owner_$id',
isShared: isShared,
);
}
}

View File

@@ -10,8 +10,11 @@ class RemoteAssetFactory {
String? name,
String? ownerId,
bool isFavorite = false,
AssetVisibility visibility = AssetVisibility.timeline,
AssetVisibility visibility = .timeline,
AssetType type = .image,
String? stackId,
DateTime? deletedAt,
String? localId,
}) {
id = TestUtils.uuid(id);
@@ -20,13 +23,15 @@ class RemoteAssetFactory {
name: name ?? 'remote_$id.jpg',
ownerId: TestUtils.uuid(ownerId),
checksum: 'checksum-$id',
type: .image,
type: type,
createdAt: TestUtils.yesterday(),
updatedAt: TestUtils.now(),
isFavorite: isFavorite,
visibility: visibility,
stackId: stackId,
isEdited: false,
deletedAt: deletedAt,
localId: localId,
);
}
}

View File

@@ -1,25 +1,43 @@
import 'dart:typed_data';
import 'package:flutter/widgets.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/album/album.model.dart';
import 'package:immich_mobile/domain/models/album/local_album.model.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/asset_edit.model.dart';
import 'package:immich_mobile/domain/models/exif.model.dart';
import 'package:immich_mobile/domain/models/tag.model.dart';
import 'package:immich_mobile/domain/models/user.model.dart';
import 'package:immich_mobile/platform/native_sync_api.g.dart';
import 'package:immich_mobile/services/foreground_upload.service.dart';
import 'package:immich_mobile/utils/option.dart';
import 'package:maplibre_gl/maplibre_gl.dart';
import 'package:mocktail/mocktail.dart' as mock;
import 'package:mocktail/mocktail.dart';
import '../domain/service.mock.dart';
import '../infrastructure/repository.mock.dart';
import '../repository.mocks.dart';
import '../service.mocks.dart';
import 'factories/local_album_factory.dart';
import 'factories/local_asset_factory.dart';
import 'factories/remote_album_factory.dart';
import 'factories/user_factory.dart';
class RepositoryMocks {
final localAlbum = LocalAlbumRepositoryStub(MockLocalAlbumRepository());
final localAsset = LocalAssetRepositoryStub(MockDriftLocalAssetRepository());
final remoteAsset = RemoteAssetRepositoryStub(MockRemoteAssetRepository());
final remoteExif = RemoteExifRepositoryStub(MockRemoteExifRepository());
final trashedAsset = MockTrashedLocalAssetRepository();
final toast = MockToastRepository();
final remoteAlbum = MockRemoteAlbumRepository();
final albumApi = MockDriftAlbumApiRepository();
final nativeApi = NativeSyncApiStub(MockNativeSyncApi());
final assetApi = AssetApiRepositoryStub(MockAssetApiRepository());
final assetMedia = AssetMediaRepositoryStub(MockAssetMediaRepository());
final download = DownloadRepositoryStub(MockDownloadRepository());
RepositoryMocks() {
resetAll();
@@ -29,11 +47,34 @@ class RepositoryMocks {
_registerFallbacks();
localAlbum.reset();
localAsset.reset();
remoteAsset.reset();
remoteExif.reset();
reset(trashedAsset);
reset(remoteAlbum);
reset(albumApi);
nativeApi.reset();
assetApi.reset();
assetMedia.reset();
download.reset();
reset(toast);
_stubLocalAlbumRepository();
_stubLocalAssetRepository();
_stubRemoteAssetRepository();
_stubRemoteExifRepository();
_stubNativeSyncApi();
_stubAssetApiRepository();
_stubAssetMediaRepository();
_stubDownloadRepository();
}
void _stubRemoteAssetRepository() {
when(remoteAsset.getExif).thenAnswer((_) async => null);
when(remoteAsset.getAssetEdits).thenAnswer((_) async => const []);
when(remoteAsset.update).thenAnswer((_) async {});
}
void _stubRemoteExifRepository() {
when(remoteExif.update).thenAnswer((_) async {});
}
void _stubLocalAlbumRepository() {
@@ -49,12 +90,31 @@ class RepositoryMocks {
void _stubNativeSyncApi() {
when(nativeApi.hashAssets).thenAnswer((_) async => []);
}
void _stubAssetApiRepository() {
when(assetApi.update).thenAnswer((_) async => {});
}
void _stubAssetMediaRepository() {
when(assetMedia.shareAssets).thenAnswer((_) async => 1);
}
void _stubDownloadRepository() {
when(download.downloadAllAssets).thenAnswer((_) async => const []);
}
}
class ServiceMocks {
final partner = PartnerServiceStub(MockPartnerService());
final user = UserServiceStub(MockUserService());
final asset = AssetServiceStub(MockAssetService());
final album = RemoteAlbumServiceStub(MockRemoteAlbumService());
final cleanup = CleanupServiceStub(MockCleanupService());
final tag = TagServiceStub(MockTagService());
final backgroundSync = MockBackgroundSyncManager();
final upload = MockForegroundUploadService();
final cast = MockGCastService();
final serverInfo = MockServerInfoService();
ServiceMocks() {
resetAll();
@@ -65,9 +125,21 @@ class ServiceMocks {
partner.reset();
user.reset();
asset.reset();
album.reset();
cleanup.reset();
tag.reset();
reset(cast);
reset(serverInfo);
reset(backgroundSync);
reset(upload);
_stubUserService();
_stubPartnerService();
_stubAssetService();
_stubRemoteAlbumService();
_stubCleanupService();
_stubTagService();
_stubBackgroundSync();
_stubForegroundUpload();
}
void _stubUserService() {
@@ -88,7 +160,43 @@ class ServiceMocks {
}
void _stubAssetService() {
when(asset.updateFavorite).thenAnswer((_) async {});
when(asset.update).thenAnswer((_) async {});
when(asset.stack).thenAnswer((_) async {});
when(asset.unstack).thenAnswer((_) async {});
when(asset.restoreTrash).thenAnswer((_) async {});
when(asset.trash).thenAnswer((_) async {});
when(asset.delete).thenAnswer((_) async {});
when(asset.applyEdits).thenAnswer((_) async {});
}
void _stubRemoteAlbumService() {
when(album.removeAssets).thenAnswer((_) async => 0);
when(album.updateAlbum).thenAnswer((_) async => RemoteAlbumFactory.create());
}
void _stubCleanupService() {
when(cleanup.deleteLocalAssets).thenAnswer((_) async => 0);
}
void _stubTagService() {
when(tag.bulkTagAssets).thenAnswer((_) async => 0);
when(tag.upsertTags).thenAnswer((_) async => const []);
when(tag.getAllTags).thenAnswer((_) async => const {});
}
void _stubBackgroundSync() {
when(() => backgroundSync.syncLocal()).thenAnswer((_) async {});
when(() => backgroundSync.hashAssets()).thenAnswer((_) async {});
}
void _stubForegroundUpload() {
when(
() => upload.uploadManual(
any(),
cancelToken: any(named: 'cancelToken'),
callbacks: any(named: 'callbacks'),
),
).thenAnswer((_) async {});
}
}
@@ -96,8 +204,24 @@ void _registerFallbacks() {
registerFallbackValue(LocalAlbumFactory.create());
registerFallbackValue(LocalAssetFactory.create());
registerFallbackValue(Uint8List(0));
registerFallbackValue(AssetVisibility.timeline);
registerFallbackValue(const LatLng(0, 0));
registerFallbackValue(<AssetEdit>[]);
registerFallbackValue(const Option<bool>.none());
registerFallbackValue(const Option<AssetVisibility>.none());
registerFallbackValue(const Option<LatLng>.none());
registerFallbackValue(const Option<String>.none());
registerFallbackValue(const Option<DateTime>.none());
registerFallbackValue(<BaseAsset>[]);
registerFallbackValue(<RemoteAsset>[]);
registerFallbackValue(<LocalAsset>[]);
registerFallbackValue(ShareAssetType.original);
registerFallbackValue(const UploadCallbacks());
registerFallbackValue(_FakeBuildContext());
}
class _FakeBuildContext extends Fake implements BuildContext {}
extension type const Stub<T extends Mock>(T mockedClass) {
void reset() => mock.reset(mockedClass);
}
@@ -119,6 +243,33 @@ extension type const LocalAssetRepositoryStub(MockDriftLocalAssetRepository repo
() => repo.updateHashes(any());
}
extension type const RemoteAssetRepositoryStub(MockRemoteAssetRepository repo)
implements Stub<MockRemoteAssetRepository> {
Future<ExifInfo?> Function() get getExif =>
() => repo.getExif(any());
Future<List<AssetEdit>> Function() get getAssetEdits =>
() => repo.getAssetEdits(any());
Future<void> Function() get update =>
() => repo.update(
any(),
isFavorite: any(named: 'isFavorite'),
visibility: any(named: 'visibility'),
createdAt: any(named: 'createdAt'),
);
}
extension type const RemoteExifRepositoryStub(MockRemoteExifRepository repo) implements Stub<MockRemoteExifRepository> {
Future<void> Function() get update =>
() => repo.update(
any(),
dateTimeOriginal: any(named: 'dateTimeOriginal'),
timeZone: any(named: 'timeZone'),
location: any(named: 'location'),
);
}
extension type const PartnerServiceStub(MockPartnerService service) implements Stub<MockPartnerService> {
Stream<Iterable<User>> Function() get getCandidates =>
() => service.getCandidates(any());
@@ -165,11 +316,89 @@ extension type const UserServiceStub(MockUserService service) implements Stub<Mo
}
extension type const AssetServiceStub(MockAssetService service) implements Stub<MockAssetService> {
Future<void> Function() get updateFavorite =>
() => service.updateFavorite(any(), any());
Future<void> Function() get update =>
() => service.update(
any(),
isFavorite: any(named: 'isFavorite'),
visibility: any(named: 'visibility'),
dateTime: any(named: 'dateTime'),
location: any(named: 'location'),
);
Future<void> Function() get stack =>
() => service.stack(any(), any());
Future<void> Function() get unstack =>
() => service.unstack(any());
Future<void> Function() get restoreTrash =>
() => service.restoreTrash(any());
Future<void> Function() get trash =>
() => service.trash(any());
Future<void> Function() get delete =>
() => service.delete(any());
Future<void> Function() get applyEdits =>
() => service.applyEdits(any(), any());
}
extension type const RemoteAlbumServiceStub(MockRemoteAlbumService service) implements Stub<MockRemoteAlbumService> {
Future<int> Function() get removeAssets =>
() => service.removeAssets(
albumId: any(named: 'albumId'),
assetIds: any(named: 'assetIds'),
);
Future<RemoteAlbum> Function() get updateAlbum =>
() => service.updateAlbum(any(), thumbnailAssetId: any(named: 'thumbnailAssetId'));
}
extension type const CleanupServiceStub(MockCleanupService service) implements Stub<MockCleanupService> {
Future<int> Function() get deleteLocalAssets =>
() => service.deleteLocalAssets(any());
}
extension type const NativeSyncApiStub(MockNativeSyncApi api) implements Stub<MockNativeSyncApi> {
Future<List<HashResult>> Function() get hashAssets =>
() => api.hashAssets(any(), allowNetworkAccess: any(named: 'allowNetworkAccess'));
}
extension type const AssetApiRepositoryStub(MockAssetApiRepository api) implements Stub<MockAssetApiRepository> {
Future<void> Function() get update =>
() => api.update(
any(),
isFavorite: any(named: 'isFavorite'),
visibility: any(named: 'visibility'),
dateTimeOriginal: any(named: 'dateTimeOriginal'),
location: any(named: 'location'),
);
}
extension type const AssetMediaRepositoryStub(MockAssetMediaRepository api) implements Stub<MockAssetMediaRepository> {
Future<int> Function() get shareAssets =>
() => api.shareAssets(
any(),
any(),
fileType: any(named: 'fileType'),
cancelCompleter: any(named: 'cancelCompleter'),
onAssetDownloadProgress: any(named: 'onAssetDownloadProgress'),
);
}
extension type const DownloadRepositoryStub(MockDownloadRepository repo) implements Stub<MockDownloadRepository> {
Future<List<bool>> Function() get downloadAllAssets =>
() => repo.downloadAllAssets(any());
}
extension type const TagServiceStub(MockTagService service) implements Stub<MockTagService> {
Future<int> Function() get bulkTagAssets =>
() => service.bulkTagAssets(any(), any());
Future<List<Tag>> Function() get upsertTags =>
() => service.upsertTags(any());
Future<Set<Tag>> Function() get getAllTags =>
() => service.getAllTags();
}

View File

@@ -4,7 +4,7 @@ import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/presentation/actions/favorite.action.dart';
import 'package:mocktail/mocktail.dart';
import '../../../domain/service.mock.dart';
import '../../../service.mocks.dart';
import '../../factories/remote_asset_factory.dart';
import '../presentation_context.dart';

View File

@@ -6,7 +6,7 @@ import 'package:immich_mobile/domain/models/user.model.dart';
import 'package:immich_mobile/presentation/actions/partner.action.dart';
import 'package:mocktail/mocktail.dart';
import '../../../domain/service.mock.dart';
import '../../../service.mocks.dart';
import '../../factories/user_factory.dart';
import '../presentation_context.dart';

View File

@@ -15,7 +15,10 @@ 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';
import 'package:immich_mobile/providers/routes.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/services/gcast.service.dart';
import 'package:immich_mobile/services/server_info.service.dart';
import 'package:immich_ui/immich_ui.dart';
import 'package:mocktail/mocktail.dart';
@@ -29,6 +32,7 @@ class PresentationContext {
service = ServiceMocks(),
repository = RepositoryMocks() {
setup();
addTearDown(dispose);
}
static const String serverEndpoint = 'http://localhost:3000';
@@ -43,6 +47,9 @@ class PresentationContext {
currentUserProvider.overrideWith((ref) => CurrentUserProvider(service.user.service)),
assetServiceProvider.overrideWithValue(service.asset.service),
partnerServiceProvider.overrideWithValue(service.partner.service),
gCastServiceProvider.overrideWithValue(service.cast),
serverInfoServiceProvider.overrideWithValue(service.serverInfo),
inLockedViewProvider.overrideWithValue(false),
];
static Future<PresentationContext> create() async {
@@ -61,9 +68,7 @@ class PresentationContext {
}
void dispose() {
addTearDown(() {
service.resetAll();
});
service.resetAll();
}
}

View File

@@ -0,0 +1,79 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:immich_mobile/domain/services/asset.service.dart';
import 'package:mocktail/mocktail.dart';
import '../../infrastructure/repository.mock.dart';
import '../../repository.mocks.dart';
import '../mocks.dart';
void main() {
late AssetService sut;
late RepositoryMocks mocks;
late MockAssetApiRepository apiRepository;
late MockRemoteAssetRepository remoteRepository;
late MockRemoteExifRepository exifRepository;
setUp(() {
mocks = RepositoryMocks();
apiRepository = mocks.assetApi.api;
remoteRepository = mocks.remoteAsset.repo;
exifRepository = mocks.remoteExif.repo;
sut = AssetService(
remoteRepository: remoteRepository,
exifRepository: exifRepository,
localRepository: MockDriftLocalAssetRepository(),
apiRepository: apiRepository,
);
});
group('AssetService.updateDateTime', () {
const ids = ['asset_id_1'];
test('sends the picked value to the api with its offset intact', () async {
const picked = '2026-06-10T19:15:00.000+06:00';
await sut.update(ids, dateTime: const .some(picked));
verify(() => apiRepository.update(ids, dateTimeOriginal: const .some(picked))).called(1);
verify(() => remoteRepository.update(ids, createdAt: .some(DateTime.parse(picked)))).called(1);
verify(
() => exifRepository.update(
ids,
dateTimeOriginal: .some(DateTime.parse(picked)),
timeZone: const .some('UTC+06:00'),
),
).called(1);
});
test('handles negative offsets', () async {
const picked = '2026-01-05T08:00:00.000-05:30';
await sut.update(ids, dateTime: const .some(picked));
verify(() => remoteRepository.update(ids, createdAt: .some(DateTime.parse(picked)))).called(1);
verify(
() => exifRepository.update(
ids,
dateTimeOriginal: .some(DateTime.parse(picked)),
timeZone: const .some('UTC-05:30'),
),
).called(1);
});
test('writes no timezone when the value has no offset', () async {
const picked = '2026-06-10T13:15:00.000Z';
await sut.update(ids, dateTime: const .some(picked));
verify(() => remoteRepository.update(ids, createdAt: .some(DateTime.parse(picked)))).called(1);
verify(
() => exifRepository.update(ids, dateTimeOriginal: .some(DateTime.parse(picked)), timeZone: const .none()),
).called(1);
});
test('is a no-op when there are no asset ids', () async {
await sut.update(const [], dateTime: const .some('2026-06-10T19:15:00.000+06:00'));
verifyZeroInteractions(apiRepository);
verifyZeroInteractions(remoteRepository);
});
});
}

View File

@@ -0,0 +1,48 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:immich_mobile/domain/services/remote_album.service.dart';
import 'package:mocktail/mocktail.dart';
import '../../service.mocks.dart';
import '../mocks.dart';
void main() {
late RemoteAlbumService sut;
final mocks = RepositoryMocks();
setUpAll(() {
registerFallbackValue(<String>[]);
});
setUp(() {
sut = RemoteAlbumService(mocks.remoteAlbum, mocks.albumApi, MockForegroundUploadService());
});
tearDown(() {
mocks.resetAll();
});
group('RemoteAlbumService', () {
group('removeAssets', () {
test('persists only the assets the server actually removed, not the whole request', () async {
const albumId = 'album-1';
const requested = ['asset-1', 'asset-2', 'asset-3'];
const removed = ['asset-1', 'asset-3'];
// The server rejected 'asset-2'
when(
() => mocks.albumApi.removeAssets(albumId, requested),
).thenAnswer((_) async => (removed: removed, failed: ['asset-2']));
when(() => mocks.remoteAlbum.removeAssets(albumId, any())).thenAnswer((_) async {});
final count = await sut.removeAssets(albumId: albumId, assetIds: requested);
final persisted =
verify(() => mocks.remoteAlbum.removeAssets(albumId, captureAny())).captured.single as List<String>;
expect(persisted, removed);
expect(persisted, isNot(contains('asset-2')));
expect(count, removed.length);
});
});
});
}

View File

@@ -52,7 +52,7 @@ void main() {
final offlinePhoto = LocalAssetFactory.create();
final remotePhoto = RemoteAssetFactory.create();
final AssetFilter<LocalAsset> syncedPhotos = AssetFilter(<BaseAsset>[
final AssetFilter<BaseAsset> syncedPhotos = AssetFilter(<BaseAsset>[
syncedPhoto,
offlinePhoto,
remotePhoto,

View File

@@ -0,0 +1,25 @@
import { Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
await sql`
INSERT INTO "user_metadata" ("userId", "key", "value")
SELECT "user"."id", 'preferences', jsonb_build_object('people', jsonb_build_object('minimumFaces', "config"."minFaces"))
FROM "user"
CROSS JOIN (
SELECT "value"->'machineLearning'->'facialRecognition'->'minFaces' AS "minFaces"
FROM "system_metadata"
WHERE "key" = 'system-config'
AND "value"->'machineLearning'->'facialRecognition'->'minFaces' IS NOT NULL
) AS "config"
ON CONFLICT ("userId", "key") DO UPDATE
SET "value" = "user_metadata"."value" || jsonb_build_object(
'people',
COALESCE("user_metadata"."value"->'people', '{}'::jsonb) || (EXCLUDED."value"->'people')
)
WHERE "user_metadata"."value"->'people'->'minimumFaces' IS NULL
`.execute(db);
}
export async function down(): Promise<void> {
// not supported
}

View File

@@ -63,6 +63,7 @@
import { toTimelineAsset } from '$lib/utils/timeline-util';
import type { AssetResponseDto, SharedLinkResponseDto } from '@immich/sdk';
import { untrack, type Snippet } from 'svelte';
import { languageManager } from '$lib/managers/language-manager.svelte';
type Props = {
asset: AssetResponseDto;
@@ -232,7 +233,7 @@
style:width={rasterWidth}
style:height={rasterHeight}
style:transform="scale({rasterScale})"
style:transform-origin="0 0"
style:transform-origin={languageManager.rtl ? 'right top' : 'left top'}
style:will-change={maxRasterPixels > 0 ? 'transform' : undefined}
>
{#if show.alphaBackground}