mirror of
https://github.com/immich-app/immich.git
synced 2026-06-23 07:06:43 -07:00
Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 99f94a363d | |||
| c3092b1c2c | |||
| 0656e7e231 | |||
| 1692b81b7c | |||
| ff2028c4c8 | |||
| f22836e1bf |
@@ -85,7 +85,7 @@ services:
|
||||
container_name: immich_prometheus
|
||||
ports:
|
||||
- 9090:9090
|
||||
image: prom/prometheus@sha256:69f5241418838263316593f7274a304b095c40bcf22e57272865da91bd60a8ac
|
||||
image: prom/prometheus@sha256:a75c5a35bc21d7afe69551eefa3cb1e1fb1775fe759408007a66b54ec3de1f29
|
||||
volumes:
|
||||
- ./prometheus.yml:/etc/prometheus/prometheus.yml
|
||||
- prometheus-data:/prometheus
|
||||
|
||||
+3
-3
@@ -1491,9 +1491,9 @@
|
||||
"login_form_handshake_exception": "There was an Handshake Exception with the server. Enable self-signed certificate support in the settings if you are using a self-signed certificate.",
|
||||
"login_form_password_hint": "password",
|
||||
"login_form_save_login": "Stay logged in",
|
||||
"login_form_server_empty": "Enter a server URL",
|
||||
"login_form_server_error": "Could not connect to server",
|
||||
"login_has_been_disabled": "Login has been disabled",
|
||||
"login_form_server_empty": "Enter a server URL.",
|
||||
"login_form_server_error": "Could not connect to server.",
|
||||
"login_has_been_disabled": "Login has been disabled.",
|
||||
"login_password_changed_error": "There was an error updating your password",
|
||||
"login_password_changed_success": "Password updated successfully",
|
||||
"logout_all_device_confirmation": "Are you sure you want to log out all devices?",
|
||||
|
||||
@@ -5,6 +5,7 @@ const Map<String, Locale> locales = {
|
||||
'English (en)': Locale('en'),
|
||||
// Additional locales
|
||||
'Arabic (ar)': Locale('ar'),
|
||||
'Basque (eu)': Locale('eu'),
|
||||
'Bosnian (bl)': Locale('bn'),
|
||||
'Brazilian Portuguese (pt_BR)': Locale('pt', 'BR'),
|
||||
'Bulgarian (bg)': Locale('bg'),
|
||||
|
||||
@@ -1,13 +1,23 @@
|
||||
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/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/partner.action.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';
|
||||
|
||||
@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();
|
||||
}
|
||||
return ref.watch(partnerServiceProvider).getCandidates(currentUser.id);
|
||||
});
|
||||
|
||||
@visibleForTesting
|
||||
final partnersStateProvider = StreamProvider.autoDispose<Iterable<Partner>>((ref) {
|
||||
@@ -20,6 +30,28 @@ final partnersStateProvider = StreamProvider.autoDispose<Iterable<Partner>>((ref
|
||||
return ref.watch(partnerServiceProvider).search(currentUser.id, .sharedBy);
|
||||
});
|
||||
|
||||
Future<void> _addPartner(BuildContext context, WidgetRef ref) async {
|
||||
final selected = await showDialog<User>(context: context, builder: (_) => const PartnerSelectionDialog());
|
||||
final currentUser = ref.read(currentUserProvider);
|
||||
if (selected != null && currentUser != null) {
|
||||
await ref.read(partnerServiceProvider).create(sharedById: currentUser.id, sharedWithId: selected.id);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _removePartner(BuildContext context, WidgetRef ref, Partner partner) => showDialog(
|
||||
context: context,
|
||||
builder: (_) => ConfirmDialog(
|
||||
title: "stop_photo_sharing",
|
||||
content: context.t.partner_page_stop_sharing_content(partner: partner.name),
|
||||
onOk: () {
|
||||
final currentUser = ref.read(currentUserProvider);
|
||||
if (currentUser != null) {
|
||||
ref.read(partnerServiceProvider).delete(sharedById: currentUser.id, sharedWithId: partner.id);
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
@RoutePage()
|
||||
class PartnerPage extends ConsumerWidget {
|
||||
const PartnerPage({super.key});
|
||||
@@ -33,10 +65,20 @@ class PartnerPage extends ConsumerWidget {
|
||||
title: Text(context.t.partners),
|
||||
elevation: 0,
|
||||
centerTitle: false,
|
||||
actions: const [ActionIconButtonWidget(action: PartnerAddAction())],
|
||||
actions: [
|
||||
IconButton(
|
||||
onPressed: () => _addPartner(context, ref),
|
||||
icon: const Icon(Icons.person_add),
|
||||
tooltip: context.t.add_partner,
|
||||
),
|
||||
],
|
||||
),
|
||||
body: sharedByAsync.when(
|
||||
data: (partners) => PartnerSharedByList(partners: partners.toList(growable: false)),
|
||||
data: (partners) => PartnerSharedByList(
|
||||
partners: partners.toList(growable: false),
|
||||
onAdd: () => _addPartner(context, ref),
|
||||
onRemove: (partner) => _removePartner(context, ref, partner),
|
||||
),
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (error, _) => Center(child: Text(context.t.error_loading_partners(error: error))),
|
||||
),
|
||||
@@ -45,7 +87,9 @@ class PartnerPage extends ConsumerWidget {
|
||||
}
|
||||
|
||||
class _EmptyPartners extends StatelessWidget {
|
||||
const _EmptyPartners();
|
||||
const _EmptyPartners({required this.onAdd});
|
||||
|
||||
final VoidCallback onAdd;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -58,9 +102,13 @@ class _EmptyPartners extends StatelessWidget {
|
||||
padding: const .symmetric(vertical: 8),
|
||||
child: Text(context.t.partner_page_empty_message, style: const TextStyle(fontSize: 14)),
|
||||
),
|
||||
const Align(
|
||||
Align(
|
||||
alignment: .center,
|
||||
child: ActionButtonWidget(action: PartnerAddAction()),
|
||||
child: ElevatedButton.icon(
|
||||
onPressed: onAdd,
|
||||
icon: const Icon(Icons.person_add),
|
||||
label: Text(context.t.add_partner),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -70,14 +118,16 @@ class _EmptyPartners extends StatelessWidget {
|
||||
|
||||
@visibleForTesting
|
||||
class PartnerSharedByList extends StatelessWidget {
|
||||
const PartnerSharedByList({super.key, required this.partners});
|
||||
const PartnerSharedByList({super.key, required this.partners, required this.onAdd, required this.onRemove});
|
||||
|
||||
final List<Partner> partners;
|
||||
final VoidCallback onAdd;
|
||||
final ValueChanged<Partner> onRemove;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (partners.isEmpty) {
|
||||
return const _EmptyPartners();
|
||||
return _EmptyPartners(onAdd: onAdd);
|
||||
}
|
||||
|
||||
return ListView.builder(
|
||||
@@ -88,11 +138,63 @@ 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: IconButton(icon: const Icon(Icons.person_remove), onPressed: () => onRemove(partner)),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@visibleForTesting
|
||||
class PartnerSelectionDialog extends ConsumerWidget {
|
||||
const PartnerSelectionDialog({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final candidatesAsync = ref.watch(candidatesStateProvider);
|
||||
|
||||
return SimpleDialog(
|
||||
title: const Text("partner_page_select_partner").tr(),
|
||||
children: candidatesAsync.when(
|
||||
data: (candidates) {
|
||||
final users = candidates.toList();
|
||||
if (users.isEmpty) {
|
||||
return [
|
||||
Padding(
|
||||
padding: const .symmetric(horizontal: 24, vertical: 8),
|
||||
child: const Text("partner_page_no_more_users").tr(),
|
||||
),
|
||||
];
|
||||
}
|
||||
return [
|
||||
for (final candidate in users)
|
||||
SimpleDialogOption(
|
||||
onPressed: () => Navigator.of(context).pop(candidate),
|
||||
child: Row(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const .only(right: 8),
|
||||
child: PartnerUserAvatar(userId: candidate.id, name: candidate.name),
|
||||
),
|
||||
Text(candidate.name),
|
||||
],
|
||||
),
|
||||
),
|
||||
];
|
||||
},
|
||||
loading: () => const [
|
||||
Padding(
|
||||
padding: .all(24),
|
||||
child: Center(child: CircularProgressIndicator()),
|
||||
),
|
||||
],
|
||||
error: (error, _) => [
|
||||
Padding(
|
||||
padding: const .symmetric(horizontal: 24, vertical: 8),
|
||||
child: Text(context.t.error_loading_partners(error: error)),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
|
||||
abstract class BaseAction {
|
||||
final IconData icon;
|
||||
|
||||
const BaseAction({required this.icon});
|
||||
|
||||
String label(BuildContext context);
|
||||
|
||||
bool isVisible(BuildContext context, WidgetRef ref);
|
||||
|
||||
Future<void> onAction(BuildContext context, WidgetRef ref);
|
||||
}
|
||||
|
||||
abstract class AssetAction<T extends BaseAsset> extends BaseAction {
|
||||
final List<T> assets;
|
||||
|
||||
const AssetAction({required super.icon, required this.assets});
|
||||
|
||||
List<T> assetsForAction(BuildContext context, WidgetRef ref);
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/presentation/actions/action.dart';
|
||||
import 'package:immich_mobile/utils/error_handler.dart';
|
||||
import 'package:immich_ui/immich_ui.dart';
|
||||
|
||||
abstract class BaseActionWidget extends ConsumerWidget {
|
||||
final BaseAction action;
|
||||
final void Function(BuildContext _, WidgetRef _)? postAction;
|
||||
|
||||
const BaseActionWidget({super.key, required this.action, this.postAction});
|
||||
|
||||
Widget buildAction(BuildContext context, Future<void> Function() onPressed);
|
||||
|
||||
Future<void> _onPressed(BuildContext context, WidgetRef ref) async {
|
||||
try {
|
||||
await action.onAction(context, ref);
|
||||
} catch (error, stackTrace) {
|
||||
handleError(context, error, stack: stackTrace, description: 'Action failed: ${action.runtimeType}');
|
||||
}
|
||||
|
||||
if (context.mounted) {
|
||||
postAction?.call(context, ref);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
if (!action.isVisible(context, ref)) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
return buildAction(context, () => _onPressed(context, ref));
|
||||
}
|
||||
}
|
||||
|
||||
class ActionIconButtonWidget extends BaseActionWidget {
|
||||
final ImmichVariant variant;
|
||||
|
||||
const ActionIconButtonWidget({super.key, required super.action, this.variant = .ghost, super.postAction});
|
||||
|
||||
@override
|
||||
Widget buildAction(BuildContext context, Future<void> Function() onPressed) =>
|
||||
ImmichIconButton(icon: action.icon, onPressed: onPressed, variant: variant);
|
||||
}
|
||||
|
||||
class ActionButtonWidget extends BaseActionWidget {
|
||||
final ImmichVariant variant;
|
||||
|
||||
const ActionButtonWidget({super.key, required super.action, this.variant = .ghost, super.postAction});
|
||||
|
||||
@override
|
||||
Widget buildAction(BuildContext context, Future<void> Function() onPressed) =>
|
||||
ImmichTextButton(labelText: action.label(context), icon: action.icon, onPressed: onPressed, variant: variant);
|
||||
}
|
||||
|
||||
class ActionColumnButtonWidget extends BaseActionWidget {
|
||||
const ActionColumnButtonWidget({super.key, required super.action, super.postAction});
|
||||
|
||||
@override
|
||||
Widget buildAction(BuildContext context, Future<void> Function() onPressed) =>
|
||||
ImmichColumnButton(icon: action.icon, label: action.label(context), onPressed: onPressed);
|
||||
}
|
||||
|
||||
class ActionMenuItemWidget extends BaseActionWidget {
|
||||
const ActionMenuItemWidget({super.key, required super.action, super.postAction});
|
||||
|
||||
@override
|
||||
Widget buildAction(BuildContext context, Future<void> Function() onPressed) =>
|
||||
ImmichMenuItem(icon: action.icon, label: action.label(context), onPressed: onPressed);
|
||||
}
|
||||
@@ -1,125 +0,0 @@
|
||||
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.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() : super(icon: Icons.person_add_rounded);
|
||||
|
||||
@override
|
||||
String label(BuildContext context) => context.t.add_partner;
|
||||
|
||||
@override
|
||||
bool isVisible(BuildContext context, WidgetRef ref) => true;
|
||||
|
||||
@override
|
||||
Future<void> onAction(BuildContext context, WidgetRef ref) async {
|
||||
final selected = await showDialog<User>(context: context, builder: (_) => const PartnerSelectionDialog());
|
||||
final currentUser = ref.read(currentUserProvider);
|
||||
if (selected == null || currentUser == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
await ref.read(partnerServiceProvider).create(sharedById: currentUser.id, sharedWithId: selected.id);
|
||||
}
|
||||
}
|
||||
|
||||
class PartnerRemoveAction extends BaseAction {
|
||||
const PartnerRemoveAction({required this.sharedWithId, required this.partnerName})
|
||||
: super(icon: Icons.person_remove_rounded);
|
||||
|
||||
final String sharedWithId;
|
||||
final String partnerName;
|
||||
|
||||
@override
|
||||
String label(BuildContext context) => context.t.remove;
|
||||
|
||||
@override
|
||||
bool isVisible(BuildContext context, WidgetRef ref) => true;
|
||||
|
||||
@override
|
||||
Future<void> onAction(BuildContext context, WidgetRef ref) async {
|
||||
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),
|
||||
),
|
||||
);
|
||||
final currentUser = ref.read(currentUserProvider);
|
||||
if (confirmed != true || currentUser == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
await ref.read(partnerServiceProvider).delete(sharedById: currentUser.id, sharedWithId: sharedWithId);
|
||||
}
|
||||
}
|
||||
|
||||
@visibleForTesting
|
||||
class PartnerSelectionDialog extends ConsumerWidget {
|
||||
const PartnerSelectionDialog({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final candidatesAsync = ref.watch(candidatesStateProvider);
|
||||
|
||||
return SimpleDialog(
|
||||
title: Text(context.t.partner_page_select_partner),
|
||||
children: candidatesAsync.when(
|
||||
data: (candidates) {
|
||||
final users = candidates.toList();
|
||||
if (users.isEmpty) {
|
||||
return [
|
||||
Padding(
|
||||
padding: const .symmetric(horizontal: 24, vertical: 8),
|
||||
child: Text(context.t.partner_page_no_more_users),
|
||||
),
|
||||
];
|
||||
}
|
||||
return [
|
||||
for (final candidate in users)
|
||||
SimpleDialogOption(
|
||||
onPressed: () => Navigator.of(context).pop(candidate),
|
||||
child: Row(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const .only(right: 8),
|
||||
child: PartnerUserAvatar(userId: candidate.id, name: candidate.name),
|
||||
),
|
||||
Text(candidate.name),
|
||||
],
|
||||
),
|
||||
),
|
||||
];
|
||||
},
|
||||
loading: () => const [
|
||||
Padding(
|
||||
padding: .all(24),
|
||||
child: Center(child: CircularProgressIndicator()),
|
||||
),
|
||||
],
|
||||
error: (error, _) => [
|
||||
Padding(
|
||||
padding: const .symmetric(horizontal: 24, vertical: 8),
|
||||
child: Text(context.t.error_loading_partners(error: error)),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@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();
|
||||
}
|
||||
return ref.watch(partnerServiceProvider).getCandidates(currentUser.id);
|
||||
});
|
||||
@@ -1,61 +0,0 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:immich_mobile/generated/translations.g.dart';
|
||||
import 'package:immich_mobile/utils/debug_print.dart';
|
||||
import 'package:immich_ui/immich_ui.dart';
|
||||
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}) {
|
||||
String? stackTrace;
|
||||
if (stack != null) {
|
||||
final trace = Trace.from(stack);
|
||||
final clean = trace.foldFrames(
|
||||
(frame) => frame.package == 'flutter' || frame.package == 'flutter_test' || frame.isCore,
|
||||
terse: true,
|
||||
);
|
||||
stackTrace = clean.toString();
|
||||
}
|
||||
|
||||
dPrint(
|
||||
() => '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;
|
||||
} else {
|
||||
message = context.t.scaffold_body_error_occurred;
|
||||
}
|
||||
|
||||
snackbar.error(message);
|
||||
}
|
||||
|
||||
@visibleForTesting
|
||||
String? serverErrorMessage(Object error) {
|
||||
if (error is! ApiException || error.innerException != null || error.message == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
final body = jsonDecode(error.message!);
|
||||
if (body is Map && body['message'] != null) {
|
||||
final message = body['message'];
|
||||
return message is List ? message.join(', ') : message.toString();
|
||||
}
|
||||
} catch (_) {
|
||||
// The body was not JSON; fall back to the raw payload below.
|
||||
}
|
||||
return error.message;
|
||||
}
|
||||
|
||||
@visibleForTesting
|
||||
bool isConnectionError(Object error) => error is ApiException && error.innerException != null;
|
||||
@@ -3,7 +3,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
|
||||
class ConfirmDialog extends StatelessWidget {
|
||||
final Function? onOk;
|
||||
final Function onOk;
|
||||
final String title;
|
||||
final String content;
|
||||
final String cancel;
|
||||
@@ -11,7 +11,7 @@ class ConfirmDialog extends StatelessWidget {
|
||||
|
||||
const ConfirmDialog({
|
||||
super.key,
|
||||
this.onOk,
|
||||
required this.onOk,
|
||||
required this.title,
|
||||
required this.content,
|
||||
this.cancel = "cancel",
|
||||
@@ -21,7 +21,7 @@ class ConfirmDialog extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
void onOkPressed() {
|
||||
onOk?.call();
|
||||
onOk();
|
||||
context.pop(true);
|
||||
}
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@ class AssetBulkUploadCheckResult {
|
||||
///
|
||||
Optional<String?> assetId;
|
||||
|
||||
/// Asset ID
|
||||
/// Client-side identifier echoed from the request to match results to inputs
|
||||
String id;
|
||||
|
||||
/// Whether existing asset is trashed
|
||||
|
||||
+34
-34
@@ -69,10 +69,10 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: background_downloader
|
||||
sha256: "4cb23d9ad4f5060944f38164e7b90d4bf99b57b2472a3bd4676e59b2db4afd06"
|
||||
sha256: aceacec2b2a72ec3a8862ab5895fcbbc71ab33765f3619d57963f3110dd268e3
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "9.5.4"
|
||||
version: "9.5.5"
|
||||
bonsoir:
|
||||
dependency: "direct overridden"
|
||||
description:
|
||||
@@ -229,10 +229,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: code_assets
|
||||
sha256: "83ccdaa064c980b5596c35dd64a8d3ecc68620174ab9b90b6343b753aa721687"
|
||||
sha256: bf394f466ba9205f1812a0433b392d6af280f155f56651eda7c18cc32ed493b8
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.0"
|
||||
version: "1.2.1"
|
||||
code_builder:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -326,18 +326,18 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: dbus
|
||||
sha256: d0c98dcd4f5169878b6cf8f6e0a52403a9dff371a3e2f019697accbf6f44a270
|
||||
sha256: "792974a4007974fbc5c1b5433eb2330a9db3e368c3f906253af4c007d0f49a91"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.7.12"
|
||||
version: "0.7.13"
|
||||
desktop_webview_window:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: desktop_webview_window
|
||||
sha256: "57cf20d81689d5cbb1adfd0017e96b669398a669d927906073b0e42fc64111c0"
|
||||
sha256: b6fdae2cbf9571879b1761c12f27facaf82e22d0bdc74d049907c2a09a432957
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.2.3"
|
||||
version: "0.3.0"
|
||||
device_info_plus:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -549,18 +549,18 @@ packages:
|
||||
dependency: "direct dev"
|
||||
description:
|
||||
name: flutter_native_splash
|
||||
sha256: "4fb9f4113350d3a80841ce05ebf1976a36de622af7d19aca0ca9a9911c7ff002"
|
||||
sha256: "9db4b80b044e9af17cc4b1272137fc7ace0054d879ef8210a76adc34aaf4cdff"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.4.7"
|
||||
version: "2.4.8"
|
||||
flutter_plugin_android_lifecycle:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_plugin_android_lifecycle
|
||||
sha256: "38d1c268de9097ff59cf0e844ac38759fc78f76836d37edad06fa21e182055a0"
|
||||
sha256: "3854fe5e3bff0b113c658f260b90c95dea17c92db0f2addeac2e343dd9969785"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.0.34"
|
||||
version: "2.0.35"
|
||||
flutter_riverpod:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -642,10 +642,10 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: flutter_web_auth_2
|
||||
sha256: d354998934ddc338e69b999b2abaeb33c6fd09999d3a5f92ead1a6b49b49712e
|
||||
sha256: "8f9303471dcd96670878c9b7c0c4e14c37595b2add67465f6a868f17a5872dfc"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "5.0.2"
|
||||
version: "5.0.3"
|
||||
flutter_web_auth_2_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -780,10 +780,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: hooks
|
||||
sha256: "025f060e86d2d4c3c47b56e33caf7f93bf9283340f26d23424ebcfccf34f621e"
|
||||
sha256: "9a62a50b50b769a737bc0a8ff381f333529df3ab746b2f6b02e83760231455ba"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.3"
|
||||
version: "2.0.2"
|
||||
hooks_riverpod:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -844,10 +844,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: image
|
||||
sha256: f9881ff4998044947ec38d098bc7c8316ae1186fa786eddffdb867b9bc94dfce
|
||||
sha256: "6300175e00616bbc832e2fc91bfa4d776af5402c81c7151bee6905bb08473c52"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.8.0"
|
||||
version: "4.9.1"
|
||||
image_picker:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -860,10 +860,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: image_picker_android
|
||||
sha256: d5b3e1774af29c9ab00103afb0d4614070f924d2e0057ac867ec98800114793f
|
||||
sha256: "6f3a1995eafb000333174fae92202622033b0ee7fd917a6cd3730295264df84a"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.8.13+17"
|
||||
version: "0.8.13+19"
|
||||
image_picker_for_web:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -1120,10 +1120,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: native_toolchain_c
|
||||
sha256: "6ba77bb18063eebe9de401f5e6437e95e1438af0a87a3a39084fbd37c90df572"
|
||||
sha256: f59351d28f49520cd3a74eb1f41c5f19ae15e53c65a3231d14af672e46510a96
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.17.6"
|
||||
version: "0.19.1"
|
||||
native_video_player:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -1161,10 +1161,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: objective_c
|
||||
sha256: "100a1c87616ab6ed41ec263b083c0ef3261ee6cd1dc3b0f35f8ddfa4f996fe52"
|
||||
sha256: "6cb691c686fa2838c6deb34980d426145c2a5d537491cb83d463c33cdbc726ed"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "9.3.0"
|
||||
version: "9.4.1"
|
||||
octo_image:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -1297,10 +1297,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: permission_handler_apple
|
||||
sha256: f000131e755c54cf4d84a5d8bd6e4149e262cc31c5a8b1d698de1ac85fa41023
|
||||
sha256: e20daf680eef1ca62ffe8c8c526b778cc386d50137c77ac71c8ec9c88c13fb9d
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "9.4.7"
|
||||
version: "9.4.9"
|
||||
permission_handler_html:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -1529,10 +1529,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: shared_preferences_android
|
||||
sha256: e8d4762b1e2e8578fc4d0fd548cebf24afd24f49719c08974df92834565e2c53
|
||||
sha256: a2c49fc1fed7140cadd892d765bd47edbe4ac0b9c7e7e3c493dcb58126f99cf0
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.4.23"
|
||||
version: "2.4.25"
|
||||
shared_preferences_foundation:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -1791,10 +1791,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: url_launcher_android
|
||||
sha256: "17bc677f0b301615530dd1d67e0a9828cafa2d0b6b6eae4cd3679b7eac4a273c"
|
||||
sha256: b413d49b73867ac08dd2f9890efd3cc11f2a0e577618d50843440a1fb3776c32
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.3.30"
|
||||
version: "6.3.32"
|
||||
url_launcher_ios:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -1871,10 +1871,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: vector_graphics_compiler
|
||||
sha256: b9b3f391857781aa96acacef96066f2f49b4cd03cf9fce3ca4d8da2ef5ea129e
|
||||
sha256: "7ee12e6dffe0fc8e755179d6d91b3b34f5924223fc104d85572ef9180d73d172"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.2.3"
|
||||
version: "1.2.5"
|
||||
vector_math:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -1991,10 +1991,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: xml
|
||||
sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025"
|
||||
sha256: "67f0aff7be013d107995e9b75bf4e7f2c3ef2dfdb2c8e68024bba0a7fd5756a4"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.6.1"
|
||||
version: "7.0.1"
|
||||
xxh3:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
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';
|
||||
@@ -15,5 +14,3 @@ class MockNativeSyncApi extends Mock implements NativeSyncApi {}
|
||||
class MockAppSettingsService extends Mock implements AppSettingsService {}
|
||||
|
||||
class MockPartnerService extends Mock implements PartnerService {}
|
||||
|
||||
class MockUserService extends Mock implements UserService {}
|
||||
|
||||
@@ -23,23 +23,4 @@ class UserFactory {
|
||||
avatarColor: avatarColor ?? .primary,
|
||||
);
|
||||
}
|
||||
|
||||
static UserDto createDto({
|
||||
String? id,
|
||||
String? name,
|
||||
String? email,
|
||||
DateTime? profileChangedAt,
|
||||
bool? hasProfileImage,
|
||||
AvatarColor? avatarColor,
|
||||
}) {
|
||||
id = TestUtils.uuid(id);
|
||||
return UserDto(
|
||||
id: id,
|
||||
name: name ?? 'user_$id',
|
||||
email: email ?? '$id@test.com',
|
||||
profileChangedAt: TestUtils.date(profileChangedAt),
|
||||
hasProfileImage: hasProfileImage ?? false,
|
||||
avatarColor: avatarColor ?? .primary,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+27
-96
@@ -1,15 +1,24 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:immich_mobile/constants/enums.dart';
|
||||
import 'package:immich_mobile/domain/models/user.model.dart';
|
||||
import 'package:mocktail/mocktail.dart' as mock;
|
||||
import 'package:mocktail/mocktail.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:mocktail/mocktail.dart' as mocktail;
|
||||
|
||||
import '../domain/service.mock.dart';
|
||||
import '../infrastructure/repository.mock.dart';
|
||||
import 'factories/local_album_factory.dart';
|
||||
import 'factories/local_asset_factory.dart';
|
||||
import 'factories/user_factory.dart';
|
||||
|
||||
void _registerFallbacks() {
|
||||
mocktail.registerFallbackValue(LocalAlbum(id: '', name: '', updatedAt: DateTime.now()));
|
||||
mocktail.registerFallbackValue(
|
||||
LocalAsset(
|
||||
id: '',
|
||||
name: '',
|
||||
type: AssetType.image,
|
||||
createdAt: DateTime.now(),
|
||||
updatedAt: DateTime.now(),
|
||||
playbackStyle: AssetPlaybackStyle.image,
|
||||
isEdited: false,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
class RepositoryMocks {
|
||||
final localAlbum = MockLocalAlbumRepository();
|
||||
@@ -19,103 +28,25 @@ class RepositoryMocks {
|
||||
final nativeApi = MockNativeSyncApi();
|
||||
|
||||
RepositoryMocks() {
|
||||
resetAll();
|
||||
_registerFallbacks();
|
||||
}
|
||||
|
||||
void resetAll() {
|
||||
_registerFallbacks();
|
||||
reset(localAlbum);
|
||||
reset(localAsset);
|
||||
reset(trashedAsset);
|
||||
reset(nativeApi);
|
||||
void reset() {
|
||||
mocktail.reset(localAlbum);
|
||||
mocktail.reset(localAsset);
|
||||
mocktail.reset(trashedAsset);
|
||||
mocktail.reset(nativeApi);
|
||||
}
|
||||
}
|
||||
|
||||
class ServiceMocks {
|
||||
final partner = PartnerStub(MockPartnerService());
|
||||
final user = UserStub(MockUserService());
|
||||
final partner = MockPartnerService();
|
||||
|
||||
ServiceMocks() {
|
||||
resetAll();
|
||||
}
|
||||
|
||||
void resetAll() {
|
||||
_registerFallbacks();
|
||||
partner.reset();
|
||||
user.reset();
|
||||
_stubUserService();
|
||||
_stubPartnerService();
|
||||
}
|
||||
|
||||
void _stubUserService() {
|
||||
when(user.getMyUser).thenReturn(UserFactory.createDto());
|
||||
when(user.tryGetMyUser).thenReturn(null);
|
||||
when(user.watchMyUser).thenAnswer((_) => const Stream.empty());
|
||||
when(user.refreshMyUser).thenAnswer((_) async => null);
|
||||
when(user.createProfileImage).thenAnswer((_) async => null);
|
||||
}
|
||||
|
||||
void _stubPartnerService() {
|
||||
registerFallbackValue(PartnerDirection.sharedBy);
|
||||
when(partner.getCandidates).thenAnswer((_) => const Stream.empty());
|
||||
when(partner.search).thenAnswer((_) => const Stream.empty());
|
||||
when(partner.update).thenAnswer((_) async {});
|
||||
when(partner.create).thenAnswer((_) async {});
|
||||
when(partner.delete).thenAnswer((_) async {});
|
||||
void reset() {
|
||||
mocktail.reset(partner);
|
||||
}
|
||||
}
|
||||
|
||||
void _registerFallbacks() {
|
||||
registerFallbackValue(LocalAlbumFactory.create());
|
||||
registerFallbackValue(LocalAssetFactory.create());
|
||||
registerFallbackValue(Uint8List(0));
|
||||
}
|
||||
|
||||
extension type const Stub<T extends Mock>(T mockedService) {
|
||||
void reset() => mock.reset(mockedService);
|
||||
}
|
||||
|
||||
extension type const PartnerStub(MockPartnerService service) implements Stub<MockPartnerService> {
|
||||
Stream<Iterable<User>> Function() get getCandidates =>
|
||||
() => service.getCandidates(any());
|
||||
|
||||
Stream<Iterable<Partner>> Function() get search =>
|
||||
() => service.search(any(), any());
|
||||
|
||||
Future<void> Function() get create =>
|
||||
() => service.create(
|
||||
sharedById: any(named: 'sharedById'),
|
||||
sharedWithId: any(named: 'sharedWithId'),
|
||||
inTimeline: any(named: 'inTimeline'),
|
||||
);
|
||||
|
||||
Future<void> Function() get update =>
|
||||
() => service.update(
|
||||
sharedById: any(named: 'sharedById'),
|
||||
sharedWithId: any(named: 'sharedWithId'),
|
||||
inTimeline: any(named: 'inTimeline'),
|
||||
);
|
||||
|
||||
Future<void> Function() get delete =>
|
||||
() => service.delete(
|
||||
sharedById: any(named: 'sharedById'),
|
||||
sharedWithId: any(named: 'sharedWithId'),
|
||||
);
|
||||
}
|
||||
|
||||
extension type const UserStub(MockUserService service) implements Stub<MockUserService> {
|
||||
UserDto Function() get getMyUser =>
|
||||
() => service.getMyUser();
|
||||
|
||||
UserDto? Function() get tryGetMyUser =>
|
||||
() => service.tryGetMyUser();
|
||||
|
||||
Stream<UserDto?> Function() get watchMyUser =>
|
||||
() => service.watchMyUser();
|
||||
|
||||
Future<UserDto?> Function() get refreshMyUser =>
|
||||
() => service.refreshMyUser();
|
||||
|
||||
Future<String?> Function() get createProfileImage =>
|
||||
() => service.createProfileImage(any(), any());
|
||||
}
|
||||
|
||||
@@ -1,83 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
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/partner.action.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/user.provider.dart';
|
||||
import 'package:immich_mobile/providers/user.provider.dart';
|
||||
import 'package:mocktail/mocktail.dart';
|
||||
|
||||
import '../../factories/user_factory.dart';
|
||||
import '../../mocks.dart';
|
||||
import '../../presentation_context.dart';
|
||||
|
||||
void main() {
|
||||
late PresentationContext context;
|
||||
late UserDto currentUser;
|
||||
final mocks = ServiceMocks();
|
||||
|
||||
setUp(() async {
|
||||
currentUser = UserFactory.createDto();
|
||||
context = await PresentationContext.create();
|
||||
when(mocks.user.tryGetMyUser).thenReturn(currentUser);
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
mocks.resetAll();
|
||||
await context.dispose();
|
||||
});
|
||||
|
||||
List<Override> overrides({List<User> candidates = const []}) => [
|
||||
currentUserProvider.overrideWith((ref) => CurrentUserProvider(mocks.user.service)),
|
||||
partnerServiceProvider.overrideWithValue(mocks.partner.service),
|
||||
candidatesStateProvider.overrideWith((ref) => Stream<Iterable<User>>.value(candidates)),
|
||||
];
|
||||
|
||||
group('PartnerAddAction', () {
|
||||
testWidgets('creates a partner for the selected candidate', (tester) async {
|
||||
final candidate = UserFactory.create();
|
||||
|
||||
await tester.pumpTestAction(const PartnerAddAction(), overrides: overrides(candidates: [candidate]));
|
||||
await tester.pumpUntilFound(find.text(candidate.name));
|
||||
await tester.tap(find.text(candidate.name));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
verify(() => mocks.partner.service.create(sharedById: currentUser.id, sharedWithId: candidate.id)).called(1);
|
||||
});
|
||||
|
||||
testWidgets('creates nothing when the selection dialog is dismissed', (tester) async {
|
||||
await tester.pumpTestAction(const PartnerAddAction(), overrides: overrides(candidates: [UserFactory.create()]));
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.escape); // dismiss without selecting
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
verifyNever(mocks.partner.create);
|
||||
});
|
||||
});
|
||||
|
||||
group('PartnerRemoveAction', () {
|
||||
testWidgets('deletes the partner after confirmation', (tester) async {
|
||||
final partner = UserFactory.create();
|
||||
await tester.pumpTestAction(
|
||||
PartnerRemoveAction(sharedWithId: partner.id, partnerName: partner.name),
|
||||
overrides: overrides(),
|
||||
);
|
||||
await tester.tap(find.byType(TextButton).last); // confirm
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
verify(() => mocks.partner.service.delete(sharedById: currentUser.id, sharedWithId: partner.id)).called(1);
|
||||
});
|
||||
|
||||
testWidgets('deletes nothing when the confirmation is cancelled', (tester) async {
|
||||
final partner = UserFactory.create();
|
||||
await tester.pumpTestAction(
|
||||
PartnerRemoveAction(sharedWithId: partner.id, partnerName: partner.name),
|
||||
overrides: overrides(),
|
||||
);
|
||||
await tester.tap(find.byType(TextButton).first); // cancel
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
verifyNever(mocks.partner.delete);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -3,7 +3,6 @@ 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/pages/library/partner/partner.page.dart';
|
||||
import 'package:immich_mobile/presentation/actions/partner.action.dart';
|
||||
|
||||
import '../factories/partner_user_factory.dart';
|
||||
import '../factories/user_factory.dart';
|
||||
@@ -17,18 +16,16 @@ 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(const PartnerSharedByList(partners: []));
|
||||
await tester.pumpTestWidget(PartnerSharedByList(partners: const [], onAdd: () {}, onRemove: (_) {}));
|
||||
|
||||
expect(find.byType(ListView), findsNothing);
|
||||
expect(find.widgetWithIcon(TextButton, action.icon), findsOneWidget);
|
||||
expect(find.widgetWithIcon(ElevatedButton, Icons.person_add), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('renders a tile per partner with name and email', (tester) async {
|
||||
final partner1 = PartnerFactory.create();
|
||||
final partner2 = PartnerFactory.create();
|
||||
await tester.pumpTestWidget(PartnerSharedByList(partners: [partner1, partner2]));
|
||||
await tester.pumpTestWidget(PartnerSharedByList(partners: [partner1, partner2], onAdd: () {}, onRemove: (_) {}));
|
||||
|
||||
expect(find.byType(ListTile), findsNWidgets(2));
|
||||
expect(find.text(partner1.name), findsOneWidget);
|
||||
@@ -37,12 +34,18 @@ void main() {
|
||||
expect(find.text(partner2.email), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('renders a remove action for each partner', (tester) async {
|
||||
testWidgets('invokes onRemovePartner with the tapped partner', (tester) async {
|
||||
final partner1 = PartnerFactory.create(inTimeline: true);
|
||||
final partner2 = PartnerFactory.create();
|
||||
final action = const PartnerRemoveAction(sharedWithId: '', partnerName: '');
|
||||
await tester.pumpTestWidget(PartnerSharedByList(partners: [partner1, partner2]));
|
||||
expect(find.byIcon(action.icon), findsNWidgets(2));
|
||||
Partner? removed;
|
||||
await tester.pumpTestWidget(
|
||||
PartnerSharedByList(partners: [partner1, partner2], onAdd: () {}, onRemove: (p) => removed = p),
|
||||
);
|
||||
|
||||
await tester.tap(find.byIcon(Icons.person_remove).first);
|
||||
await tester.pump();
|
||||
|
||||
expect(removed, partner1);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -10,9 +10,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_ui/immich_ui.dart';
|
||||
|
||||
import '../test_utils.dart';
|
||||
|
||||
@@ -57,7 +54,6 @@ extension PumpPresentationWidget on WidgetTester {
|
||||
child: Builder(
|
||||
builder: (context) => MaterialApp(
|
||||
debugShowCheckedModeBanner: false,
|
||||
scaffoldMessengerKey: scaffoldMessengerKey,
|
||||
localizationsDelegates: context.localizationDelegates,
|
||||
supportedLocales: context.supportedLocales,
|
||||
locale: context.locale,
|
||||
@@ -69,23 +65,4 @@ extension PumpPresentationWidget on WidgetTester {
|
||||
);
|
||||
await pumpAndSettle();
|
||||
}
|
||||
|
||||
Future<void> pumpTestAction(BaseAction action, {List<Override> overrides = const []}) async {
|
||||
await pumpTestWidget(
|
||||
Scaffold(body: ActionIconButtonWidget(action: action)),
|
||||
overrides: overrides,
|
||||
);
|
||||
await tap(find.byType(ImmichIconButton));
|
||||
await pump();
|
||||
}
|
||||
|
||||
Future<void> pumpUntilFound(Finder finder, {int maxFrames = 10}) async {
|
||||
for (var i = 0; i < maxFrames; i++) {
|
||||
await pump();
|
||||
if (finder.evaluate().isNotEmpty) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
throw StateError('pumpUntilFound: $finder not found within $maxFrames frames');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@ void main() {
|
||||
});
|
||||
|
||||
tearDown(() {
|
||||
mocks.resetAll();
|
||||
mocks.reset();
|
||||
});
|
||||
|
||||
group('HashService', () {
|
||||
|
||||
@@ -17012,12 +17012,12 @@
|
||||
},
|
||||
"assetId": {
|
||||
"description": "Existing asset ID if duplicate",
|
||||
"format": "uuid",
|
||||
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$",
|
||||
"type": "string"
|
||||
},
|
||||
"id": {
|
||||
"description": "Asset ID",
|
||||
"format": "uuid",
|
||||
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$",
|
||||
"description": "Client-side identifier echoed from the request to match results to inputs",
|
||||
"type": "string"
|
||||
},
|
||||
"isTrashed": {
|
||||
|
||||
@@ -278,7 +278,9 @@
|
||||
"title": "Album IDs",
|
||||
"array": true,
|
||||
"description": "Target album IDs",
|
||||
"uiHint": "AlbumId"
|
||||
"uiHint": {
|
||||
"type": "AlbumId"
|
||||
}
|
||||
},
|
||||
"albumName": {
|
||||
"type": "string",
|
||||
@@ -368,14 +370,20 @@
|
||||
"type": "string",
|
||||
"title": "Album ID",
|
||||
"description": "Target album ID",
|
||||
"uiHint": "AlbumId"
|
||||
"uiHint": {
|
||||
"type": "AlbumId",
|
||||
"order": 1
|
||||
}
|
||||
},
|
||||
"albumIds": {
|
||||
"type": "string",
|
||||
"title": "Album IDs",
|
||||
"description": "Target album IDs",
|
||||
"array": true,
|
||||
"uiHint": "AlbumId"
|
||||
"uiHint": {
|
||||
"type": "AlbumId",
|
||||
"order": 2
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
"@types/node": "^24.13.2",
|
||||
"esbuild": "^0.28.0",
|
||||
"tsc-alias": "^1.8.16",
|
||||
"typescript": "^5.9.3"
|
||||
"typescript": "^6.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@extism/js-pdk": "^1.1.1"
|
||||
|
||||
@@ -8,7 +8,7 @@ import type {
|
||||
} from 'src/types.js';
|
||||
|
||||
export const wrapper = <
|
||||
T extends WorkflowType = WorkflowType,
|
||||
T extends WorkflowType,
|
||||
TConfig extends ConfigValue = ConfigValue,
|
||||
>(
|
||||
fn: (
|
||||
|
||||
@@ -11,7 +11,7 @@ type DeepPartial<T> = T extends Date
|
||||
export type WorkflowEventMap = {
|
||||
[WorkflowType.AssetV1]: AssetV1;
|
||||
// [WorkflowType.AssetPersonV1]: AssetPersonV1;
|
||||
};
|
||||
} & { [K in WorkflowType]: unknown };
|
||||
|
||||
export type WorkflowEventData<T extends WorkflowType> = WorkflowEventMap[T];
|
||||
|
||||
@@ -22,7 +22,7 @@ export enum WorkflowTrigger {
|
||||
}
|
||||
|
||||
export type WorkflowEventPayload<
|
||||
T extends WorkflowType = WorkflowType,
|
||||
T extends WorkflowType,
|
||||
TConfig = WorkflowStepConfig,
|
||||
> = {
|
||||
trigger: WorkflowTrigger;
|
||||
@@ -37,10 +37,11 @@ export type WorkflowEventPayload<
|
||||
};
|
||||
};
|
||||
|
||||
export type WorkflowChanges<T extends WorkflowType = WorkflowType> =
|
||||
DeepPartial<WorkflowEventData<T>>;
|
||||
export type WorkflowChanges<T extends WorkflowType> = DeepPartial<
|
||||
WorkflowEventData<T>
|
||||
>;
|
||||
|
||||
export type WorkflowResponse<T extends WorkflowType = WorkflowType> = {
|
||||
export type WorkflowResponse<T extends WorkflowType> = {
|
||||
workflow?: {
|
||||
/** stop the workflow */
|
||||
continue?: boolean;
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
"sourceMap": false,
|
||||
"strict": true,
|
||||
"target": "esnext",
|
||||
"typeRoots": ["./node_modules/@types", "./node_modules"],
|
||||
"types": ["node", "@extism/js-pdk"],
|
||||
"verbatimModuleSyntax": true
|
||||
}
|
||||
|
||||
@@ -707,7 +707,7 @@ export type AssetBulkUploadCheckResult = {
|
||||
action: AssetUploadAction;
|
||||
/** Existing asset ID if duplicate */
|
||||
assetId?: string;
|
||||
/** Asset ID */
|
||||
/** Client-side identifier echoed from the request to match results to inputs */
|
||||
id: string;
|
||||
/** Whether existing asset is trashed */
|
||||
isTrashed?: boolean;
|
||||
|
||||
Generated
+2
-2
@@ -353,8 +353,8 @@ importers:
|
||||
specifier: ^1.8.16
|
||||
version: 1.8.17
|
||||
typescript:
|
||||
specifier: ^5.9.3
|
||||
version: 5.9.3
|
||||
specifier: ^6.0.0
|
||||
version: 6.0.3
|
||||
|
||||
packages/sdk:
|
||||
dependencies:
|
||||
|
||||
@@ -34,10 +34,10 @@ const AssetRejectReasonSchema = z
|
||||
|
||||
const AssetBulkUploadCheckResultSchema = z
|
||||
.object({
|
||||
id: z.uuidv4().describe('Asset ID'),
|
||||
id: z.string().describe('Client-side identifier echoed from the request to match results to inputs'),
|
||||
action: AssetUploadActionSchema,
|
||||
reason: AssetRejectReasonSchema.optional(),
|
||||
assetId: z.string().optional().describe('Existing asset ID if duplicate'),
|
||||
assetId: z.uuidv4().optional().describe('Existing asset ID if duplicate'),
|
||||
isTrashed: z.boolean().optional().describe('Whether existing asset is trashed'),
|
||||
})
|
||||
.meta({ id: 'AssetBulkUploadCheckResult' });
|
||||
|
||||
@@ -14,7 +14,12 @@ const JsonSchemaPropertySchema = z
|
||||
enum: z.array(z.string()).optional().describe('Valid choices for enum types'),
|
||||
array: z.boolean().optional().describe('Type is an array type'),
|
||||
required: z.array(z.string()).optional().describe('A list of required properties'),
|
||||
uiHint: z.string().optional(),
|
||||
uiHint: z
|
||||
.object({
|
||||
type: z.string().optional(),
|
||||
order: z.int().optional(),
|
||||
})
|
||||
.optional(),
|
||||
get properties() {
|
||||
return z.record(z.string(), JsonSchemaPropertySchema).optional();
|
||||
},
|
||||
|
||||
@@ -369,7 +369,7 @@ export class WorkflowExecutionService extends BaseService {
|
||||
const readResult = await read(type);
|
||||
let data = readResult.data;
|
||||
for (const step of workflow.steps) {
|
||||
const payload: WorkflowEventPayload = {
|
||||
const payload: WorkflowEventPayload<typeof type> = {
|
||||
trigger: workflow.trigger,
|
||||
type,
|
||||
config: step.config ?? {},
|
||||
|
||||
@@ -51,6 +51,8 @@
|
||||
};
|
||||
|
||||
const setUiHintValue = (values: string[]) => setValue(schema.array ? values : values[0]);
|
||||
const getSchemaProperties = (schema: JSONSchemaProperty) =>
|
||||
Object.entries(schema.properties ?? {}).sort((a, b) => (a[1].uiHint?.order ?? 0) - (b[1].uiHint?.order ?? 0));
|
||||
|
||||
const getBoolean = (defaultValue = false) => getValue<boolean>(defaultValue);
|
||||
const getString = () => getValue<string>();
|
||||
@@ -72,11 +74,11 @@
|
||||
</div>
|
||||
{/if}
|
||||
<div class="flex flex-col gap-4 {root ? '' : 'border-l-4 border-gray-200 ps-2'}">
|
||||
{#each Object.entries(schema.properties ?? {}) as [childKey, childSchema] (childKey)}
|
||||
{#each getSchemaProperties(schema) as [childKey, childSchema] (childKey)}
|
||||
<Self schema={childSchema} key={childKey} bind:config={getValue, setValue} />
|
||||
{/each}
|
||||
</div>
|
||||
{:else if schema.uiHint === 'AlbumId'}
|
||||
{:else if schema.uiHint?.type === 'AlbumId'}
|
||||
<SchemaAlbumPicker {label} {description} array={schema.array} bind:albumIds={getUiHintValue, setUiHintValue} />
|
||||
{:else if schema.enum && schema.array}
|
||||
<Field {label} {description}>
|
||||
|
||||
@@ -96,7 +96,10 @@ export type JSONSchemaProperty = {
|
||||
array?: boolean;
|
||||
properties?: Record<string, JSONSchemaProperty>;
|
||||
required?: string[];
|
||||
uiHint?: 'AlbumId' | 'AssetId' | 'PersonId';
|
||||
uiHint?: {
|
||||
type?: 'AlbumId' | 'AssetId' | 'PersonId';
|
||||
order?: number;
|
||||
};
|
||||
};
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
|
||||
@@ -55,7 +55,7 @@
|
||||
);
|
||||
const isGhost = $derived(step.id === 'ghost');
|
||||
|
||||
const getUiHint = (key: string) => schema?.properties?.[key]?.uiHint;
|
||||
const getUiHint = (key: string) => schema?.properties?.[key]?.uiHint?.type;
|
||||
const toIds = (value: unknown): string[] => (Array.isArray(value) ? value.map(String) : [String(value)]);
|
||||
let dragImage = $state<Element>();
|
||||
let isDropTarget = $state(false);
|
||||
|
||||
Reference in New Issue
Block a user