mirror of
https://github.com/immich-app/immich.git
synced 2026-06-23 07:06:43 -07:00
Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 025e71cfef | |||
| d56ee21e8c | |||
| 7dd02ffbad | |||
| e51c4cb355 | |||
| d4102c0489 | |||
| 30a73c1105 | |||
| ec7c0f9ec8 | |||
| a5198e23a8 | |||
| 51f2905fcc | |||
| 3b7d75c18a |
@@ -103,7 +103,7 @@ jobs:
|
||||
working-directory: ./mobile
|
||||
run: printf "%s" $KEY_JKS | base64 -d > android/key.jks
|
||||
|
||||
- uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
|
||||
- uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5.3.0
|
||||
with:
|
||||
distribution: 'zulu'
|
||||
java-version: '17'
|
||||
|
||||
@@ -25,11 +25,12 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Check for breaking API changes
|
||||
uses: oasdiff/oasdiff-action/breaking@3530478ec30f84adedbfeb28f0d9527a290f50a9 # v0.0.57
|
||||
uses: oasdiff/oasdiff-action/breaking@e24529087d93f837b28b50bb66ba9016380a7fcc # v0.1.2
|
||||
with:
|
||||
base: https://raw.githubusercontent.com/${{ github.repository }}/main/open-api/immich-openapi-specs.json
|
||||
revision: open-api/immich-openapi-specs.json
|
||||
fail-on: ERR
|
||||
review: false
|
||||
|
||||
check-mobile-patches:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
@@ -406,7 +406,7 @@ jobs:
|
||||
token: ${{ steps.token.outputs.token }}
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8
|
||||
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
@@ -483,7 +483,7 @@ jobs:
|
||||
token: ${{ steps.token.outputs.token }}
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8
|
||||
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
|
||||
+4
-4
@@ -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?",
|
||||
@@ -1548,7 +1548,7 @@
|
||||
"map_location_picker_page_use_location": "Use this location",
|
||||
"map_location_service_disabled_content": "Location service needs to be enabled to display assets from your current location. Do you want to enable it now?",
|
||||
"map_location_service_disabled_title": "Location Service disabled",
|
||||
"map_marker_for_images": "Map marker for images taken in {city}, {country}",
|
||||
"map_marker_for_image": "Map marker for image taken in {city}, {country}",
|
||||
"map_marker_with_image": "Map marker with image",
|
||||
"map_no_location_permission_content": "Location permission is needed to display assets from your current location. Do you want to allow it now?",
|
||||
"map_no_location_permission_title": "Location Permission denied",
|
||||
|
||||
@@ -263,6 +263,7 @@ class ImmichAppState extends ConsumerState<ImmichApp> with WidgetsBindingObserve
|
||||
child: MaterialApp.router(
|
||||
title: 'Immich',
|
||||
debugShowCheckedModeBanner: true,
|
||||
scaffoldMessengerKey: scaffoldMessengerKey,
|
||||
localizationsDelegates: context.localizationDelegates,
|
||||
supportedLocales: context.supportedLocales,
|
||||
locale: context.locale,
|
||||
|
||||
@@ -1,23 +1,13 @@
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/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) {
|
||||
@@ -30,28 +20,6 @@ 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});
|
||||
@@ -65,20 +33,10 @@ class PartnerPage extends ConsumerWidget {
|
||||
title: Text(context.t.partners),
|
||||
elevation: 0,
|
||||
centerTitle: false,
|
||||
actions: [
|
||||
IconButton(
|
||||
onPressed: () => _addPartner(context, ref),
|
||||
icon: const Icon(Icons.person_add),
|
||||
tooltip: context.t.add_partner,
|
||||
),
|
||||
],
|
||||
actions: const [ActionIconButtonWidget(action: PartnerAddAction())],
|
||||
),
|
||||
body: sharedByAsync.when(
|
||||
data: (partners) => PartnerSharedByList(
|
||||
partners: partners.toList(growable: false),
|
||||
onAdd: () => _addPartner(context, ref),
|
||||
onRemove: (partner) => _removePartner(context, ref, partner),
|
||||
),
|
||||
data: (partners) => PartnerSharedByList(partners: partners.toList(growable: false)),
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (error, _) => Center(child: Text(context.t.error_loading_partners(error: error))),
|
||||
),
|
||||
@@ -87,9 +45,7 @@ class PartnerPage extends ConsumerWidget {
|
||||
}
|
||||
|
||||
class _EmptyPartners extends StatelessWidget {
|
||||
const _EmptyPartners({required this.onAdd});
|
||||
|
||||
final VoidCallback onAdd;
|
||||
const _EmptyPartners();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -102,13 +58,9 @@ class _EmptyPartners extends StatelessWidget {
|
||||
padding: const .symmetric(vertical: 8),
|
||||
child: Text(context.t.partner_page_empty_message, style: const TextStyle(fontSize: 14)),
|
||||
),
|
||||
Align(
|
||||
const Align(
|
||||
alignment: .center,
|
||||
child: ElevatedButton.icon(
|
||||
onPressed: onAdd,
|
||||
icon: const Icon(Icons.person_add),
|
||||
label: Text(context.t.add_partner),
|
||||
),
|
||||
child: ActionButtonWidget(action: PartnerAddAction()),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -118,16 +70,14 @@ class _EmptyPartners extends StatelessWidget {
|
||||
|
||||
@visibleForTesting
|
||||
class PartnerSharedByList extends StatelessWidget {
|
||||
const PartnerSharedByList({super.key, required this.partners, required this.onAdd, required this.onRemove});
|
||||
const PartnerSharedByList({super.key, required this.partners});
|
||||
|
||||
final List<Partner> partners;
|
||||
final VoidCallback onAdd;
|
||||
final ValueChanged<Partner> onRemove;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (partners.isEmpty) {
|
||||
return _EmptyPartners(onAdd: onAdd);
|
||||
return const _EmptyPartners();
|
||||
}
|
||||
|
||||
return ListView.builder(
|
||||
@@ -138,63 +88,11 @@ class PartnerSharedByList extends StatelessWidget {
|
||||
leading: PartnerUserAvatar(userId: partner.id, name: partner.name),
|
||||
title: Text(partner.name),
|
||||
subtitle: Text(partner.email),
|
||||
trailing: IconButton(icon: const Icon(Icons.person_remove), onPressed: () => onRemove(partner)),
|
||||
trailing: ActionIconButtonWidget(
|
||||
action: PartnerRemoveAction(sharedWithId: partner.id, partnerName: partner.name),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@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)),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
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);
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
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);
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
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);
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
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;
|
||||
@@ -1,7 +1,16 @@
|
||||
String? getVersionCompatibilityMessage(int _, int appMinor, int _, int serverMinor) {
|
||||
import 'package:immich_mobile/utils/semver.dart';
|
||||
|
||||
String? getVersionCompatibilityMessage(SemVer serverVersion, SemVer appVersion) {
|
||||
// Add latest compat info up top
|
||||
if (serverMinor < 106 && appMinor >= 106) {
|
||||
return 'Your app minor version is not compatible with the server! Please update your server to version v1.106.0 or newer to login';
|
||||
|
||||
// ensure mobile app major version is not behind server major version
|
||||
if (appVersion.major < serverVersion.major) {
|
||||
return 'Your mobile app version is not compatible with the server! Please update your mobile app to the latest version.';
|
||||
}
|
||||
|
||||
// ensure mobile app major version is not ahead of server major version by more than 1 major version
|
||||
if (appVersion.major > serverVersion.major + 1) {
|
||||
return 'Your server version is not compatible with the mobile app! Please update your server to the latest version.';
|
||||
}
|
||||
|
||||
return 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,
|
||||
required this.onOk,
|
||||
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();
|
||||
onOk?.call();
|
||||
context.pop(true);
|
||||
}
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@ import 'package:immich_mobile/providers/websocket.provider.dart';
|
||||
import 'package:immich_mobile/repositories/permission.repository.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
import 'package:immich_mobile/utils/provider_utils.dart';
|
||||
import 'package:immich_mobile/utils/semver.dart';
|
||||
import 'package:immich_mobile/utils/url_helper.dart';
|
||||
import 'package:immich_mobile/utils/version_compatibility.dart';
|
||||
import 'package:immich_mobile/widgets/common/immich_logo.dart';
|
||||
@@ -88,18 +89,9 @@ class LoginForm extends HookConsumerWidget {
|
||||
checkVersionMismatch() async {
|
||||
try {
|
||||
final packageInfo = await PackageInfo.fromPlatform();
|
||||
final appVersion = packageInfo.version;
|
||||
final appMajorVersion = int.parse(appVersion.split('.')[0]);
|
||||
final appMinorVersion = int.parse(appVersion.split('.')[1]);
|
||||
final serverMajorVersion = serverInfo.serverVersion.major;
|
||||
final serverMinorVersion = serverInfo.serverVersion.minor;
|
||||
|
||||
warningMessage.value = getVersionCompatibilityMessage(
|
||||
appMajorVersion,
|
||||
appMinorVersion,
|
||||
serverMajorVersion,
|
||||
serverMinorVersion,
|
||||
);
|
||||
final appSemVer = SemVer.fromString(packageInfo.version);
|
||||
final serverSemVer = serverInfo.serverVersion;
|
||||
warningMessage.value = getVersionCompatibilityMessage(appSemVer, serverSemVer);
|
||||
} catch (error) {
|
||||
warningMessage.value = 'Error checking version compatibility';
|
||||
}
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
export 'src/components/close_button.dart';
|
||||
export 'src/components/column_button.dart';
|
||||
export 'src/components/form.dart';
|
||||
export 'src/components/formatted_text.dart';
|
||||
export 'src/components/icon_button.dart';
|
||||
export 'src/components/menu_item.dart';
|
||||
export 'src/components/password_input.dart';
|
||||
export 'src/components/text_button.dart';
|
||||
export 'src/components/text_input.dart';
|
||||
export 'src/components/url_input.dart';
|
||||
export 'src/constants.dart';
|
||||
export 'src/snackbar.dart';
|
||||
export 'src/theme.dart';
|
||||
export 'src/translation.dart';
|
||||
export 'src/types.dart';
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
class ImmichColorOverride extends InheritedWidget {
|
||||
const ImmichColorOverride({super.key, required this.color, required super.child});
|
||||
|
||||
final Color color;
|
||||
|
||||
static Color? maybeOf(BuildContext context) =>
|
||||
context.dependOnInheritedWidgetOfExactType<ImmichColorOverride>()?.color;
|
||||
|
||||
@override
|
||||
bool updateShouldNotify(ImmichColorOverride oldWidget) => color != oldWidget.color;
|
||||
}
|
||||
@@ -16,10 +16,9 @@ class ImmichCloseButton extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => ImmichIconButton(
|
||||
key: key,
|
||||
icon: Icons.close,
|
||||
color: color,
|
||||
variant: variant,
|
||||
onPressed: onPressed ?? () => Navigator.of(context).pop(),
|
||||
);
|
||||
icon: Icons.close,
|
||||
color: color,
|
||||
variant: variant,
|
||||
onPressed: onPressed ?? () => Navigator.of(context).pop(),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:immich_ui/src/constants.dart';
|
||||
import 'package:immich_ui/src/internal.dart';
|
||||
|
||||
class ImmichColumnButton extends StatefulWidget {
|
||||
final IconData icon;
|
||||
final String label;
|
||||
final FutureOr<void> Function() onPressed;
|
||||
final bool disabled;
|
||||
final bool? loading;
|
||||
|
||||
const ImmichColumnButton({
|
||||
super.key,
|
||||
required this.icon,
|
||||
required this.label,
|
||||
required this.onPressed,
|
||||
this.disabled = false,
|
||||
this.loading,
|
||||
});
|
||||
|
||||
@override
|
||||
State<ImmichColumnButton> createState() => _ImmichColumnButtonState();
|
||||
}
|
||||
|
||||
class _ImmichColumnButtonState extends State<ImmichColumnButton> {
|
||||
bool _loading = false;
|
||||
bool get _isLoading => widget.loading ?? _loading;
|
||||
|
||||
Future<void> _onPressed() async {
|
||||
setState(() => _loading = true);
|
||||
try {
|
||||
await widget.onPressed();
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() => _loading = false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final foreground = context.colorOverride ?? Theme.of(context).colorScheme.onSurface;
|
||||
|
||||
return TextButton(
|
||||
onPressed: widget.disabled || _isLoading ? null : _onPressed,
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: foreground,
|
||||
padding: const .symmetric(horizontal: ImmichSpacing.sm, vertical: ImmichSpacing.md),
|
||||
tapTargetSize: .shrinkWrap,
|
||||
shape: const RoundedRectangleBorder(borderRadius: .all(.circular(ImmichRadius.xl))),
|
||||
),
|
||||
child: ConstrainedBox(
|
||||
constraints: const .new(maxWidth: 90),
|
||||
child: Column(
|
||||
mainAxisSize: .min,
|
||||
children: [
|
||||
_isLoading
|
||||
? const SizedBox.square(
|
||||
dimension: ImmichIconSize.md,
|
||||
child: CircularProgressIndicator(strokeWidth: ImmichBorderWidth.lg),
|
||||
)
|
||||
: Icon(widget.icon, size: ImmichIconSize.md),
|
||||
const SizedBox(height: ImmichSpacing.sm),
|
||||
Text(
|
||||
widget.label,
|
||||
maxLines: 2,
|
||||
textAlign: .center,
|
||||
overflow: .ellipsis,
|
||||
style: const .new(fontSize: ImmichTextSize.label, fontWeight: .w500),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -88,7 +88,7 @@ class _ImmichFormState extends State<ImmichForm> {
|
||||
builder: (context, _) => ImmichTextButton(
|
||||
labelText: submitText,
|
||||
icon: widget.submitIcon,
|
||||
variant: ImmichVariant.filled,
|
||||
variant: .filled,
|
||||
loading: _controller.isLoading,
|
||||
onPressed: _controller.submit,
|
||||
disabled: _controller.onSubmit == null,
|
||||
|
||||
@@ -94,12 +94,12 @@ class _ImmichFormattedTextState extends State<ImmichFormattedText> {
|
||||
|
||||
final tag = match.group(1)!.toLowerCase();
|
||||
final content = match.group(2)!;
|
||||
final formattedSpan = (widget.spanBuilder ?? _defaultSpanBuilder)(tag);
|
||||
final style = formattedSpan.style ?? _defaultTextStyle(tag);
|
||||
final span = widget.spanBuilder?.call(tag);
|
||||
final style = span?.style ?? _defaultTextStyle(tag);
|
||||
|
||||
GestureRecognizer? recognizer;
|
||||
if (formattedSpan.onTap != null) {
|
||||
recognizer = TapGestureRecognizer()..onTap = formattedSpan.onTap;
|
||||
if (span?.onTap != null) {
|
||||
recognizer = TapGestureRecognizer()..onTap = span!.onTap;
|
||||
_recognizers.add(recognizer);
|
||||
}
|
||||
spans.add(TextSpan(text: content, style: style, recognizer: recognizer));
|
||||
@@ -114,19 +114,12 @@ class _ImmichFormattedTextState extends State<ImmichFormattedText> {
|
||||
return spans;
|
||||
}
|
||||
|
||||
FormattedSpan _defaultSpanBuilder(String tag) => switch (tag) {
|
||||
'b' => const FormattedSpan(style: TextStyle(fontWeight: FontWeight.bold)),
|
||||
'link' => const FormattedSpan(style: TextStyle(decoration: TextDecoration.underline)),
|
||||
_ when tag.endsWith('-link') => const FormattedSpan(style: TextStyle(decoration: TextDecoration.underline)),
|
||||
_ => const FormattedSpan(),
|
||||
};
|
||||
|
||||
TextStyle? _defaultTextStyle(String tag) => switch (tag) {
|
||||
'b' => const TextStyle(fontWeight: FontWeight.bold),
|
||||
'link' => const TextStyle(decoration: TextDecoration.underline),
|
||||
_ when tag.endsWith('-link') => const TextStyle(decoration: TextDecoration.underline),
|
||||
_ => null,
|
||||
};
|
||||
'b' => const TextStyle(fontWeight: FontWeight.bold),
|
||||
'link' => const TextStyle(decoration: TextDecoration.underline),
|
||||
_ when tag.endsWith('-link') => const TextStyle(decoration: TextDecoration.underline),
|
||||
_ => null,
|
||||
};
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
|
||||
@@ -1,54 +1,80 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:immich_ui/src/types.dart';
|
||||
import 'dart:async';
|
||||
|
||||
class ImmichIconButton extends StatelessWidget {
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:immich_ui/immich_ui.dart';
|
||||
import 'package:immich_ui/src/internal.dart';
|
||||
|
||||
class ImmichIconButton extends StatefulWidget {
|
||||
final IconData icon;
|
||||
final VoidCallback onPressed;
|
||||
final FutureOr<void> Function() onPressed;
|
||||
final ImmichVariant variant;
|
||||
final ImmichColor color;
|
||||
final bool disabled;
|
||||
final bool? loading;
|
||||
|
||||
const ImmichIconButton({
|
||||
super.key,
|
||||
required this.icon,
|
||||
required this.onPressed,
|
||||
this.color = ImmichColor.primary,
|
||||
this.variant = ImmichVariant.filled,
|
||||
this.color = .primary,
|
||||
this.variant = .filled,
|
||||
this.disabled = false,
|
||||
this.loading,
|
||||
});
|
||||
|
||||
@override
|
||||
State<ImmichIconButton> createState() => _ImmichIconButtonState();
|
||||
}
|
||||
|
||||
class _ImmichIconButtonState extends State<ImmichIconButton> {
|
||||
bool _loading = false;
|
||||
bool get _isLoading => widget.loading ?? _loading;
|
||||
|
||||
Future<void> _onPressed() async {
|
||||
setState(() => _loading = true);
|
||||
try {
|
||||
await widget.onPressed();
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() => _loading = false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
final background = switch (variant) {
|
||||
ImmichVariant.filled => switch (color) {
|
||||
ImmichColor.primary => colorScheme.primary,
|
||||
ImmichColor.secondary => colorScheme.secondary,
|
||||
},
|
||||
ImmichVariant.ghost => Colors.transparent,
|
||||
final background = switch (widget.variant) {
|
||||
.filled => switch (widget.color) {
|
||||
.primary => colorScheme.primary,
|
||||
.secondary => colorScheme.secondary,
|
||||
},
|
||||
.ghost => Colors.transparent,
|
||||
};
|
||||
|
||||
final foreground = switch (variant) {
|
||||
ImmichVariant.filled => switch (color) {
|
||||
ImmichColor.primary => colorScheme.onPrimary,
|
||||
ImmichColor.secondary => colorScheme.onSecondary,
|
||||
},
|
||||
ImmichVariant.ghost => switch (color) {
|
||||
ImmichColor.primary => colorScheme.primary,
|
||||
ImmichColor.secondary => colorScheme.secondary,
|
||||
},
|
||||
};
|
||||
|
||||
final effectiveOnPressed = disabled ? null : onPressed;
|
||||
final foreground =
|
||||
context.colorOverride ??
|
||||
switch (widget.variant) {
|
||||
.filled => switch (widget.color) {
|
||||
.primary => colorScheme.onPrimary,
|
||||
.secondary => colorScheme.onSecondary,
|
||||
},
|
||||
.ghost => switch (widget.color) {
|
||||
.primary => colorScheme.primary,
|
||||
.secondary => colorScheme.secondary,
|
||||
},
|
||||
};
|
||||
|
||||
return IconButton(
|
||||
icon: Icon(icon),
|
||||
onPressed: effectiveOnPressed,
|
||||
style: IconButton.styleFrom(
|
||||
backgroundColor: background,
|
||||
foregroundColor: foreground,
|
||||
),
|
||||
icon: _isLoading
|
||||
? const SizedBox.square(
|
||||
dimension: ImmichIconSize.sm,
|
||||
child: CircularProgressIndicator(strokeWidth: ImmichBorderWidth.md),
|
||||
)
|
||||
: Icon(widget.icon),
|
||||
onPressed: widget.disabled || _isLoading ? null : _onPressed,
|
||||
style: IconButton.styleFrom(backgroundColor: background, foregroundColor: foreground),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:immich_ui/src/constants.dart';
|
||||
import 'package:immich_ui/src/internal.dart';
|
||||
|
||||
class ImmichMenu extends StatefulWidget {
|
||||
final List<Widget> children;
|
||||
final MenuAnchorChildBuilder builder;
|
||||
final MenuStyle? style;
|
||||
final bool consumeOutsideTap;
|
||||
final Widget? child;
|
||||
|
||||
const ImmichMenu({
|
||||
super.key,
|
||||
required this.children,
|
||||
required this.builder,
|
||||
this.style,
|
||||
this.consumeOutsideTap = false,
|
||||
this.child,
|
||||
});
|
||||
|
||||
@override
|
||||
State<ImmichMenu> createState() => _ImmichMenuState();
|
||||
}
|
||||
|
||||
class _ImmichMenuState extends State<ImmichMenu> {
|
||||
final _controller = MenuController();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return _ImmichMenuScope(
|
||||
controller: _controller,
|
||||
child: MenuAnchor(
|
||||
controller: _controller,
|
||||
style: widget.style,
|
||||
consumeOutsideTap: widget.consumeOutsideTap,
|
||||
menuChildren: widget.children,
|
||||
builder: widget.builder,
|
||||
child: widget.child,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ImmichMenuScope extends InheritedWidget {
|
||||
final MenuController controller;
|
||||
|
||||
const _ImmichMenuScope({required this.controller, required super.child});
|
||||
|
||||
static MenuController? maybeOf(BuildContext context) =>
|
||||
context.dependOnInheritedWidgetOfExactType<_ImmichMenuScope>()?.controller;
|
||||
|
||||
@override
|
||||
bool updateShouldNotify(_ImmichMenuScope oldWidget) => controller != oldWidget.controller;
|
||||
}
|
||||
|
||||
class ImmichMenuItem extends StatefulWidget {
|
||||
final IconData icon;
|
||||
final String label;
|
||||
final FutureOr<void> Function() onPressed;
|
||||
final bool disabled;
|
||||
|
||||
const ImmichMenuItem({
|
||||
super.key,
|
||||
required this.icon,
|
||||
required this.label,
|
||||
required this.onPressed,
|
||||
this.disabled = false,
|
||||
});
|
||||
|
||||
@override
|
||||
State<ImmichMenuItem> createState() => _ImmichMenuItemState();
|
||||
}
|
||||
|
||||
class _ImmichMenuItemState extends State<ImmichMenuItem> {
|
||||
Future<void> _onPressed(MenuController? controller) async {
|
||||
try {
|
||||
await widget.onPressed();
|
||||
} finally {
|
||||
controller?.close();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final controller = _ImmichMenuScope.maybeOf(context);
|
||||
return MenuItemButton(
|
||||
onPressed: widget.disabled ? null : () => _onPressed(controller),
|
||||
closeOnActivate: controller == null,
|
||||
style: MenuItemButton.styleFrom(
|
||||
foregroundColor: context.colorOverride,
|
||||
alignment: .centerLeft,
|
||||
padding: const .symmetric(horizontal: ImmichSpacing.lg, vertical: ImmichSpacing.md),
|
||||
),
|
||||
leadingIcon: Icon(widget.icon, size: ImmichIconSize.sm),
|
||||
child: Text(widget.label, style: const .new(fontSize: ImmichTextSize.body)),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -52,7 +52,6 @@ class _ImmichPasswordInputState extends State<ImmichPasswordInput> {
|
||||
icon: Icon(_visible ? Icons.visibility_off_rounded : Icons.visibility_rounded),
|
||||
),
|
||||
autofillHints: [AutofillHints.password],
|
||||
keyboardType: TextInputType.text,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,85 +1,72 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:immich_ui/src/constants.dart';
|
||||
import 'package:immich_ui/src/types.dart';
|
||||
import 'package:immich_ui/immich_ui.dart';
|
||||
|
||||
class ImmichTextButton extends StatelessWidget {
|
||||
class ImmichTextButton extends StatefulWidget {
|
||||
final String labelText;
|
||||
final IconData? icon;
|
||||
final FutureOr<void> Function() onPressed;
|
||||
final ImmichVariant variant;
|
||||
final ImmichColor color;
|
||||
final bool expanded;
|
||||
final bool loading;
|
||||
final bool disabled;
|
||||
final bool? loading;
|
||||
|
||||
const ImmichTextButton({
|
||||
super.key,
|
||||
required this.labelText,
|
||||
this.icon,
|
||||
required this.onPressed,
|
||||
this.variant = ImmichVariant.filled,
|
||||
this.color = ImmichColor.primary,
|
||||
this.variant = .filled,
|
||||
this.expanded = true,
|
||||
this.loading = false,
|
||||
|
||||
this.disabled = false,
|
||||
this.loading,
|
||||
});
|
||||
|
||||
Widget _buildButton(ImmichVariant variant) {
|
||||
final Widget? effectiveIcon = loading
|
||||
? const SizedBox.square(
|
||||
dimension: ImmichIconSize.md,
|
||||
child: CircularProgressIndicator(strokeWidth: ImmichBorderWidth.lg),
|
||||
)
|
||||
: icon != null
|
||||
? Icon(icon, fontWeight: FontWeight.w600)
|
||||
: null;
|
||||
final hasIcon = effectiveIcon != null;
|
||||
@override
|
||||
State<ImmichTextButton> createState() => _ImmichTextButtonState();
|
||||
}
|
||||
|
||||
final label = Text(labelText, style: const TextStyle(fontSize: ImmichTextSize.body, fontWeight: FontWeight.bold));
|
||||
final style = ElevatedButton.styleFrom(padding: const EdgeInsets.symmetric(vertical: ImmichSpacing.md));
|
||||
class _ImmichTextButtonState extends State<ImmichTextButton> {
|
||||
bool _loading = false;
|
||||
bool get _isLoading => widget.loading ?? _loading;
|
||||
|
||||
final effectiveOnPressed = disabled || loading ? null : onPressed;
|
||||
|
||||
switch (variant) {
|
||||
case ImmichVariant.filled:
|
||||
if (hasIcon) {
|
||||
return ElevatedButton.icon(
|
||||
style: style,
|
||||
onPressed: effectiveOnPressed,
|
||||
icon: effectiveIcon,
|
||||
label: label,
|
||||
);
|
||||
}
|
||||
|
||||
return ElevatedButton(
|
||||
style: style,
|
||||
onPressed: effectiveOnPressed,
|
||||
child: label,
|
||||
);
|
||||
case ImmichVariant.ghost:
|
||||
if (hasIcon) {
|
||||
return TextButton.icon(
|
||||
style: style,
|
||||
onPressed: effectiveOnPressed,
|
||||
icon: effectiveIcon,
|
||||
label: label,
|
||||
);
|
||||
}
|
||||
|
||||
return TextButton(
|
||||
style: style,
|
||||
onPressed: effectiveOnPressed,
|
||||
child: label,
|
||||
);
|
||||
Future<void> _onPressed() async {
|
||||
setState(() => _loading = true);
|
||||
try {
|
||||
await widget.onPressed();
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() => _loading = false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final button = _buildButton(variant);
|
||||
if (expanded) {
|
||||
final Widget? icon = _isLoading
|
||||
? const SizedBox.square(
|
||||
dimension: ImmichIconSize.md,
|
||||
child: CircularProgressIndicator(strokeWidth: ImmichBorderWidth.lg),
|
||||
)
|
||||
: widget.icon != null
|
||||
? Icon(widget.icon, fontWeight: .w600)
|
||||
: null;
|
||||
|
||||
final label = Text(
|
||||
widget.labelText,
|
||||
style: const .new(fontSize: ImmichTextSize.body, fontWeight: .bold),
|
||||
);
|
||||
final style = ElevatedButton.styleFrom(padding: const .symmetric(vertical: ImmichSpacing.md));
|
||||
final onPressed = widget.disabled || _isLoading ? null : _onPressed;
|
||||
|
||||
final button = switch (widget.variant) {
|
||||
ImmichVariant.filled => ElevatedButton.icon(style: style, onPressed: onPressed, icon: icon, label: label),
|
||||
ImmichVariant.ghost => TextButton.icon(style: style, onPressed: onPressed, icon: icon, label: label),
|
||||
};
|
||||
|
||||
if (widget.expanded) {
|
||||
return SizedBox(width: double.infinity, child: button);
|
||||
}
|
||||
return button;
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:immich_ui/src/color_override.dart';
|
||||
import 'package:immich_ui/src/translation.dart';
|
||||
|
||||
extension TranslationHelper on BuildContext {
|
||||
ImmichTranslations get translations => ImmichTranslationProvider.of(this);
|
||||
}
|
||||
|
||||
extension ColorHelper on BuildContext {
|
||||
Color? get colorOverride => ImmichColorOverride.maybeOf(this);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:immich_ui/src/components/column_button.dart';
|
||||
import 'package:immich_ui/src/previews.dart';
|
||||
|
||||
void _previewNoop() {}
|
||||
|
||||
@ImmichPreview(group: 'ColumnButton', name: 'Default')
|
||||
Widget previewColumnButtonDefault() => const Wrap(
|
||||
spacing: 12,
|
||||
runSpacing: 12,
|
||||
children: [
|
||||
ImmichColumnButton(onPressed: _previewNoop, icon: Icons.favorite_border_rounded, label: 'Favorite'),
|
||||
ImmichColumnButton(onPressed: _previewNoop, icon: Icons.archive_outlined, label: 'Archive'),
|
||||
ImmichColumnButton(onPressed: _previewNoop, icon: Icons.delete_outline_rounded, label: 'Delete'),
|
||||
],
|
||||
);
|
||||
|
||||
@ImmichPreview(group: 'ColumnButton', name: 'Loading')
|
||||
Widget previewColumnButtonLoading() => ImmichColumnButton(
|
||||
onPressed: () => Future<void>.delayed(const .new(seconds: 2)),
|
||||
icon: Icons.download,
|
||||
label: 'Download',
|
||||
);
|
||||
|
||||
@ImmichPreview(group: 'ColumnButton', name: 'Disabled')
|
||||
Widget previewColumnButtonDisabled() =>
|
||||
const ImmichColumnButton(onPressed: _previewNoop, icon: Icons.ios_share_rounded, label: 'Share', disabled: true);
|
||||
@@ -0,0 +1,19 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:immich_ui/src/components/menu_item.dart';
|
||||
import 'package:immich_ui/src/previews.dart';
|
||||
|
||||
void _previewNoop() {}
|
||||
|
||||
@ImmichPreview(group: 'MenuItem', name: 'Default')
|
||||
Widget previewMenuItemDefault() => const Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
ImmichMenuItem(onPressed: _previewNoop, icon: Icons.info_outline, label: 'Info'),
|
||||
ImmichMenuItem(onPressed: _previewNoop, icon: Icons.help_outline_rounded, label: 'Troubleshoot'),
|
||||
ImmichMenuItem(onPressed: _previewNoop, icon: Icons.cast_rounded, label: 'Cast'),
|
||||
],
|
||||
);
|
||||
|
||||
@ImmichPreview(group: 'MenuItem', name: 'Disabled')
|
||||
Widget previewMenuItemDisabled() =>
|
||||
const ImmichMenuItem(onPressed: _previewNoop, icon: Icons.delete_outline_rounded, label: 'Delete', disabled: true);
|
||||
@@ -0,0 +1,32 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:immich_ui/src/constants.dart';
|
||||
import 'package:immich_ui/src/previews.dart';
|
||||
import 'package:immich_ui/src/snackbar.dart';
|
||||
|
||||
@ImmichPreview(group: 'Snackbar', name: 'Types')
|
||||
Widget previewSnackbarTypes() => const _SnackbarDemo();
|
||||
|
||||
class _SnackbarDemo extends StatelessWidget {
|
||||
const _SnackbarDemo();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ScaffoldMessenger(
|
||||
key: scaffoldMessengerKey,
|
||||
child: Scaffold(
|
||||
backgroundColor: Colors.transparent,
|
||||
body: Center(
|
||||
child: Wrap(
|
||||
spacing: ImmichSpacing.md,
|
||||
runSpacing: ImmichSpacing.md,
|
||||
children: [
|
||||
ElevatedButton(onPressed: () => snackbar.info('Info message'), child: const Text('Info')),
|
||||
ElevatedButton(onPressed: () => snackbar.success('Saved'), child: const Text('Success')),
|
||||
ElevatedButton(onPressed: () => snackbar.error('Something failed'), child: const Text('Error')),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -15,16 +15,6 @@ Widget previewTextButtonVariants() => const Wrap(
|
||||
],
|
||||
);
|
||||
|
||||
@ImmichPreview(group: 'TextButton', name: 'Colors')
|
||||
Widget previewTextButtonColors() => const Wrap(
|
||||
spacing: 12,
|
||||
runSpacing: 12,
|
||||
children: [
|
||||
ImmichTextButton(onPressed: _previewNoop, labelText: 'Primary', expanded: false),
|
||||
ImmichTextButton(onPressed: _previewNoop, labelText: 'Secondary', color: ImmichColor.secondary, expanded: false),
|
||||
],
|
||||
);
|
||||
|
||||
@ImmichPreview(group: 'TextButton', name: 'With Icons')
|
||||
Widget previewTextButtonWithIcons() => const Wrap(
|
||||
spacing: 12,
|
||||
@@ -42,7 +32,11 @@ Widget previewTextButtonWithIcons() => const Wrap(
|
||||
);
|
||||
|
||||
@ImmichPreview(group: 'TextButton', name: 'Loading')
|
||||
Widget previewTextButtonLoading() => const _PreviewLoadingDemo();
|
||||
Widget previewTextButtonLoading() => ImmichTextButton(
|
||||
onPressed: () => Future<void>.delayed(const Duration(seconds: 2)),
|
||||
labelText: 'Click me',
|
||||
expanded: false,
|
||||
);
|
||||
|
||||
@ImmichPreview(group: 'TextButton', name: 'Disabled')
|
||||
Widget previewTextButtonDisabled() => const Wrap(
|
||||
@@ -59,30 +53,3 @@ Widget previewTextButtonDisabled() => const Wrap(
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
class _PreviewLoadingDemo extends StatefulWidget {
|
||||
const _PreviewLoadingDemo();
|
||||
|
||||
@override
|
||||
State<_PreviewLoadingDemo> createState() => _PreviewLoadingDemoState();
|
||||
}
|
||||
|
||||
class _PreviewLoadingDemoState extends State<_PreviewLoadingDemo> {
|
||||
bool _isLoading = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ImmichTextButton(
|
||||
onPressed: () async {
|
||||
setState(() => _isLoading = true);
|
||||
await Future<void>.delayed(const Duration(seconds: 2));
|
||||
if (mounted) {
|
||||
setState(() => _isLoading = false);
|
||||
}
|
||||
},
|
||||
labelText: _isLoading ? 'Loading...' : 'Click Me',
|
||||
loading: _isLoading,
|
||||
expanded: false,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:immich_ui/immich_ui.dart';
|
||||
|
||||
final scaffoldMessengerKey = GlobalKey<ScaffoldMessengerState>();
|
||||
|
||||
class SnackbarManager {
|
||||
const SnackbarManager();
|
||||
|
||||
ScaffoldFeatureController<SnackBar, SnackBarClosedReason>? show(String message, SnackbarType type) {
|
||||
final messenger = scaffoldMessengerKey.currentState;
|
||||
final context = scaffoldMessengerKey.currentContext;
|
||||
if (messenger == null || context == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
messenger.hideCurrentSnackBar();
|
||||
return messenger.showSnackBar(_build(context, message, type));
|
||||
}
|
||||
|
||||
SnackBar _build(BuildContext context, String message, SnackbarType type) {
|
||||
final theme = Theme.of(context);
|
||||
final colors = theme.extension<ImmichColors>() ?? ImmichColors.harmonized(theme.colorScheme);
|
||||
final (IconData icon, Color background, Color foreground) = switch (type) {
|
||||
.info => (Icons.info_rounded, colors.info, colors.onInfo),
|
||||
.success => (Icons.check_circle_rounded, colors.success, colors.onSuccess),
|
||||
.error => (Icons.warning_rounded, colors.error, colors.onError),
|
||||
};
|
||||
|
||||
return SnackBar(
|
||||
behavior: .floating,
|
||||
backgroundColor: background,
|
||||
duration: const .new(seconds: 4),
|
||||
shape: const RoundedRectangleBorder(borderRadius: .all(.circular(ImmichRadius.sm))),
|
||||
content: Row(
|
||||
children: [
|
||||
Icon(icon, color: foreground, size: ImmichIconSize.sm),
|
||||
const SizedBox(width: ImmichSpacing.md),
|
||||
Expanded(
|
||||
child: Text(
|
||||
message,
|
||||
maxLines: 2,
|
||||
overflow: .ellipsis,
|
||||
style: .new(color: foreground, fontWeight: .w600, fontSize: ImmichTextSize.body),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
ScaffoldFeatureController<SnackBar, SnackBarClosedReason>? info(String message) => show(message, .info);
|
||||
|
||||
ScaffoldFeatureController<SnackBar, SnackBarClosedReason>? success(String message) => show(message, .success);
|
||||
|
||||
ScaffoldFeatureController<SnackBar, SnackBarClosedReason>? error(String message) => show(message, .error);
|
||||
}
|
||||
|
||||
const snackbar = SnackbarManager();
|
||||
@@ -1,5 +1,8 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:immich_ui/src/constants.dart';
|
||||
import 'package:material_color_utilities/blend/blend.dart';
|
||||
import 'package:material_color_utilities/hct/hct.dart';
|
||||
import 'package:material_color_utilities/palettes/tonal_palette.dart';
|
||||
|
||||
class ImmichThemeProvider extends StatelessWidget {
|
||||
final ColorScheme colorScheme;
|
||||
@@ -11,6 +14,7 @@ class ImmichThemeProvider extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
return Theme(
|
||||
data: Theme.of(context).copyWith(
|
||||
extensions: [ImmichColors.harmonized(colorScheme)],
|
||||
colorScheme: colorScheme,
|
||||
brightness: colorScheme.brightness,
|
||||
inputDecorationTheme: InputDecorationTheme(
|
||||
@@ -19,8 +23,8 @@ class ImmichThemeProvider extends StatelessWidget {
|
||||
final color = states.contains(WidgetState.error)
|
||||
? colorScheme.error
|
||||
: states.contains(WidgetState.focused)
|
||||
? colorScheme.primary
|
||||
: colorScheme.outline;
|
||||
? colorScheme.primary
|
||||
: colorScheme.outline;
|
||||
return OutlineInputBorder(
|
||||
borderSide: BorderSide(color: color),
|
||||
borderRadius: const BorderRadius.all(Radius.circular(ImmichRadius.md)),
|
||||
@@ -38,3 +42,71 @@ class ImmichThemeProvider extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class ImmichColors extends ThemeExtension<ImmichColors> {
|
||||
final Color info;
|
||||
final Color onInfo;
|
||||
final Color success;
|
||||
final Color onSuccess;
|
||||
final Color error;
|
||||
final Color onError;
|
||||
|
||||
const ImmichColors({
|
||||
required this.info,
|
||||
required this.onInfo,
|
||||
required this.success,
|
||||
required this.onSuccess,
|
||||
required this.error,
|
||||
required this.onError,
|
||||
});
|
||||
|
||||
factory ImmichColors.harmonized(ColorScheme scheme) {
|
||||
final (info, onInfo) = scheme.harmonized(const Color(0xFF1984E9));
|
||||
final (success, onSuccess) = scheme.harmonized(const Color(0xFF10C14D));
|
||||
final (error, onError) = scheme.harmonized(const Color(0xFFFA2921));
|
||||
return ImmichColors(
|
||||
info: info,
|
||||
onInfo: onInfo,
|
||||
success: success,
|
||||
onSuccess: onSuccess,
|
||||
error: error,
|
||||
onError: onError,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
ImmichColors copyWith({Color? info, Color? onInfo, Color? success, Color? onSuccess, Color? error, Color? onError}) {
|
||||
return ImmichColors(
|
||||
info: info ?? this.info,
|
||||
onInfo: onInfo ?? this.onInfo,
|
||||
success: success ?? this.success,
|
||||
onSuccess: onSuccess ?? this.onSuccess,
|
||||
error: error ?? this.error,
|
||||
onError: onError ?? this.onError,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
ImmichColors lerp(ImmichColors? other, double t) {
|
||||
if (other == null) {
|
||||
return this;
|
||||
}
|
||||
return ImmichColors(
|
||||
info: Color.lerp(info, other.info, t)!,
|
||||
onInfo: Color.lerp(onInfo, other.onInfo, t)!,
|
||||
success: Color.lerp(success, other.success, t)!,
|
||||
onSuccess: Color.lerp(onSuccess, other.onSuccess, t)!,
|
||||
error: Color.lerp(error, other.error, t)!,
|
||||
onError: Color.lerp(onError, other.onError, t)!,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
extension on ColorScheme {
|
||||
(Color container, Color onContainer) harmonized(Color seed) {
|
||||
final hct = Hct.fromInt(Blend.harmonize(seed.toARGB32(), primary.toARGB32()));
|
||||
final tones = TonalPalette.of(hct.hue, hct.chroma);
|
||||
final isDark = brightness == Brightness.dark;
|
||||
return (Color(tones.get(isDark ? 30 : 90)), Color(tones.get(isDark ? 90 : 10)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
enum ImmichVariant {
|
||||
filled,
|
||||
ghost,
|
||||
}
|
||||
enum ImmichVariant { filled, ghost }
|
||||
|
||||
enum ImmichColor {
|
||||
primary,
|
||||
secondary,
|
||||
}
|
||||
enum ImmichColor { primary, secondary }
|
||||
|
||||
enum SnackbarType { info, success, error }
|
||||
|
||||
@@ -92,7 +92,7 @@ packages:
|
||||
source: hosted
|
||||
version: "0.12.19"
|
||||
material_color_utilities:
|
||||
dependency: transitive
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: material_color_utilities
|
||||
sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b"
|
||||
|
||||
@@ -7,6 +7,7 @@ environment:
|
||||
dependencies:
|
||||
flutter:
|
||||
sdk: flutter
|
||||
material_color_utilities: any
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:immich_ui/src/color_override.dart';
|
||||
import 'package:immich_ui/src/components/icon_button.dart';
|
||||
|
||||
import 'test_utils.dart';
|
||||
|
||||
void main() {
|
||||
group('ImmichColorOverride', () {
|
||||
testWidgets('exposes the override color to descendants', (tester) async {
|
||||
Color? captured;
|
||||
await tester.pumpTestWidget(
|
||||
ImmichColorOverride(
|
||||
color: Colors.green,
|
||||
child: Builder(
|
||||
builder: (context) {
|
||||
captured = ImmichColorOverride.maybeOf(context);
|
||||
return const SizedBox.shrink();
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
expect(captured, Colors.green);
|
||||
});
|
||||
|
||||
testWidgets('maybeOf returns null when there is no override', (tester) async {
|
||||
Color? captured = Colors.black;
|
||||
await tester.pumpTestWidget(
|
||||
Builder(
|
||||
builder: (context) {
|
||||
captured = ImmichColorOverride.maybeOf(context);
|
||||
return const SizedBox.shrink();
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
expect(captured, isNull);
|
||||
});
|
||||
|
||||
testWidgets('a descendant component adopts the override as its foreground', (tester) async {
|
||||
await tester.pumpTestWidget(
|
||||
ImmichColorOverride(
|
||||
color: Colors.green,
|
||||
child: ImmichIconButton(icon: Icons.add, onPressed: () {}),
|
||||
),
|
||||
);
|
||||
|
||||
final button = tester.widget<IconButton>(find.byType(IconButton));
|
||||
expect(button.style?.foregroundColor?.resolve(<WidgetState>{}), Colors.green);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:immich_ui/src/snackbar.dart';
|
||||
|
||||
import 'test_utils.dart';
|
||||
|
||||
void main() {
|
||||
group('SnackbarManager', () {
|
||||
testWidgets('shows the message', (tester) async {
|
||||
await tester.pumpTestWidget(const SizedBox());
|
||||
|
||||
snackbar.success('hello');
|
||||
await tester.pump();
|
||||
|
||||
expect(find.text('hello'), findsOneWidget);
|
||||
expect(find.byType(SnackBar), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('replaces the current snackbar', (tester) async {
|
||||
await tester.pumpTestWidget(const SizedBox());
|
||||
|
||||
snackbar.info('first');
|
||||
await tester.pump();
|
||||
snackbar.error('second');
|
||||
await tester.pump();
|
||||
|
||||
expect(find.text('first'), findsNothing);
|
||||
expect(find.text('second'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('no-ops when the messenger is unmounted', (tester) async {
|
||||
expect(snackbar.show('x', .info), isNull);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,9 +1,14 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:immich_ui/src/snackbar.dart';
|
||||
|
||||
extension WidgetTesterExtension on WidgetTester {
|
||||
/// Pumps a widget wrapped in MaterialApp and Scaffold for testing.
|
||||
Future<void> pumpTestWidget(Widget widget) {
|
||||
return pumpWidget(MaterialApp(home: Scaffold(body: widget)));
|
||||
return pumpWidget(
|
||||
MaterialApp(
|
||||
scaffoldMessengerKey: scaffoldMessengerKey,
|
||||
home: Scaffold(body: widget),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
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';
|
||||
@@ -14,3 +15,5 @@ class MockNativeSyncApi extends Mock implements NativeSyncApi {}
|
||||
class MockAppSettingsService extends Mock implements AppSettingsService {}
|
||||
|
||||
class MockPartnerService extends Mock implements PartnerService {}
|
||||
|
||||
class MockUserService extends Mock implements UserService {}
|
||||
|
||||
@@ -1,29 +1,47 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:immich_mobile/utils/semver.dart';
|
||||
import 'package:immich_mobile/utils/version_compatibility.dart';
|
||||
|
||||
void main() {
|
||||
test('getVersionCompatibilityMessage', () {
|
||||
String? result;
|
||||
group('app major version behind server', () {
|
||||
const message =
|
||||
'Your mobile app version is not compatible with the server! Please update your mobile app to the latest version.';
|
||||
|
||||
result = getVersionCompatibilityMessage(1, 106, 1, 105);
|
||||
expect(
|
||||
result,
|
||||
'Your app minor version is not compatible with the server! Please update your server to version v1.106.0 or newer to login',
|
||||
);
|
||||
test('returns message when app major is behind server major', () {
|
||||
final result = getVersionCompatibilityMessage(
|
||||
const SemVer(major: 2, minor: 0, patch: 0),
|
||||
const SemVer(major: 1, minor: 200, patch: 0),
|
||||
);
|
||||
expect(result, message);
|
||||
});
|
||||
|
||||
result = getVersionCompatibilityMessage(1, 107, 1, 105);
|
||||
expect(
|
||||
result,
|
||||
'Your app minor version is not compatible with the server! Please update your server to version v1.106.0 or newer to login',
|
||||
);
|
||||
test('returns null when app major matches server major', () {
|
||||
final result = getVersionCompatibilityMessage(
|
||||
const SemVer(major: 2, minor: 0, patch: 0),
|
||||
const SemVer(major: 2, minor: 0, patch: 0),
|
||||
);
|
||||
expect(result, null);
|
||||
});
|
||||
});
|
||||
|
||||
result = getVersionCompatibilityMessage(1, 106, 1, 106);
|
||||
expect(result, null);
|
||||
group('app major version too far ahead of server', () {
|
||||
const message =
|
||||
'Your server version is not compatible with the mobile app! Please update your server to the latest version.';
|
||||
|
||||
result = getVersionCompatibilityMessage(1, 107, 1, 106);
|
||||
expect(result, null);
|
||||
test('returns message when app major is more than one ahead of server', () {
|
||||
final result = getVersionCompatibilityMessage(
|
||||
const SemVer(major: 1, minor: 200, patch: 0),
|
||||
const SemVer(major: 3, minor: 0, patch: 0),
|
||||
);
|
||||
expect(result, message);
|
||||
});
|
||||
|
||||
result = getVersionCompatibilityMessage(1, 107, 1, 108);
|
||||
expect(result, null);
|
||||
test('returns null when app major is exactly one ahead of server', () {
|
||||
final result = getVersionCompatibilityMessage(
|
||||
const SemVer(major: 1, minor: 200, patch: 0),
|
||||
const SemVer(major: 2, minor: 0, patch: 0),
|
||||
);
|
||||
expect(result, null);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -23,4 +23,23 @@ 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+97
-28
@@ -1,24 +1,15 @@
|
||||
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 '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 '../domain/service.mock.dart';
|
||||
import '../infrastructure/repository.mock.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,
|
||||
),
|
||||
);
|
||||
}
|
||||
import 'factories/local_album_factory.dart';
|
||||
import 'factories/local_asset_factory.dart';
|
||||
import 'factories/user_factory.dart';
|
||||
|
||||
class RepositoryMocks {
|
||||
final localAlbum = MockLocalAlbumRepository();
|
||||
@@ -28,25 +19,103 @@ class RepositoryMocks {
|
||||
final nativeApi = MockNativeSyncApi();
|
||||
|
||||
RepositoryMocks() {
|
||||
_registerFallbacks();
|
||||
resetAll();
|
||||
}
|
||||
|
||||
void reset() {
|
||||
mocktail.reset(localAlbum);
|
||||
mocktail.reset(localAsset);
|
||||
mocktail.reset(trashedAsset);
|
||||
mocktail.reset(nativeApi);
|
||||
void resetAll() {
|
||||
_registerFallbacks();
|
||||
reset(localAlbum);
|
||||
reset(localAsset);
|
||||
reset(trashedAsset);
|
||||
reset(nativeApi);
|
||||
}
|
||||
}
|
||||
|
||||
class ServiceMocks {
|
||||
final partner = MockPartnerService();
|
||||
final partner = PartnerStub(MockPartnerService());
|
||||
final user = UserStub(MockUserService());
|
||||
|
||||
ServiceMocks() {
|
||||
_registerFallbacks();
|
||||
resetAll();
|
||||
}
|
||||
|
||||
void reset() {
|
||||
mocktail.reset(partner);
|
||||
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 _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());
|
||||
}
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
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,6 +3,7 @@ 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';
|
||||
@@ -16,16 +17,18 @@ void main() {
|
||||
|
||||
group('PartnerSharedByList', () {
|
||||
testWidgets('shows the empty-state add button when there are no partners', (tester) async {
|
||||
await tester.pumpTestWidget(PartnerSharedByList(partners: const [], onAdd: () {}, onRemove: (_) {}));
|
||||
final action = const PartnerAddAction();
|
||||
|
||||
await tester.pumpTestWidget(const PartnerSharedByList(partners: []));
|
||||
|
||||
expect(find.byType(ListView), findsNothing);
|
||||
expect(find.widgetWithIcon(ElevatedButton, Icons.person_add), findsOneWidget);
|
||||
expect(find.widgetWithIcon(TextButton, action.icon), 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], onAdd: () {}, onRemove: (_) {}));
|
||||
await tester.pumpTestWidget(PartnerSharedByList(partners: [partner1, partner2]));
|
||||
|
||||
expect(find.byType(ListTile), findsNWidgets(2));
|
||||
expect(find.text(partner1.name), findsOneWidget);
|
||||
@@ -34,18 +37,12 @@ void main() {
|
||||
expect(find.text(partner2.email), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('invokes onRemovePartner with the tapped partner', (tester) async {
|
||||
testWidgets('renders a remove action for each partner', (tester) async {
|
||||
final partner1 = PartnerFactory.create(inTimeline: true);
|
||||
final partner2 = PartnerFactory.create();
|
||||
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);
|
||||
final action = const PartnerRemoveAction(sharedWithId: '', partnerName: '');
|
||||
await tester.pumpTestWidget(PartnerSharedByList(partners: [partner1, partner2]));
|
||||
expect(find.byIcon(action.icon), findsNWidgets(2));
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -10,6 +10,9 @@ 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';
|
||||
|
||||
@@ -54,6 +57,7 @@ extension PumpPresentationWidget on WidgetTester {
|
||||
child: Builder(
|
||||
builder: (context) => MaterialApp(
|
||||
debugShowCheckedModeBanner: false,
|
||||
scaffoldMessengerKey: scaffoldMessengerKey,
|
||||
localizationsDelegates: context.localizationDelegates,
|
||||
supportedLocales: context.supportedLocales,
|
||||
locale: context.locale,
|
||||
@@ -65,4 +69,23 @@ 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.reset();
|
||||
mocks.resetAll();
|
||||
});
|
||||
|
||||
group('HashService', () {
|
||||
|
||||
@@ -88,7 +88,7 @@ from
|
||||
where
|
||||
"album_asset"."updateId" < $3
|
||||
and "album_asset"."updateId" <= $4
|
||||
and "album_asset"."updateId" >= $5
|
||||
and "album_asset"."updateId" > $5
|
||||
and "album_asset"."albumId" = $6
|
||||
order by
|
||||
"album_asset"."updateId" asc
|
||||
@@ -202,7 +202,7 @@ from
|
||||
where
|
||||
"album_asset"."updateId" < $1
|
||||
and "album_asset"."updateId" <= $2
|
||||
and "album_asset"."updateId" >= $3
|
||||
and "album_asset"."updateId" > $3
|
||||
and "album_asset"."albumId" = $4
|
||||
order by
|
||||
"album_asset"."updateId" asc
|
||||
@@ -297,7 +297,7 @@ from
|
||||
where
|
||||
"album_asset"."updateId" < $1
|
||||
and "album_asset"."updateId" <= $2
|
||||
and "album_asset"."updateId" >= $3
|
||||
and "album_asset"."updateId" > $3
|
||||
and "album_asset"."albumId" = $4
|
||||
order by
|
||||
"album_asset"."updateId" asc
|
||||
@@ -349,7 +349,7 @@ from
|
||||
where
|
||||
"album_user"."updateId" < $1
|
||||
and "album_user"."updateId" <= $2
|
||||
and "album_user"."updateId" >= $3
|
||||
and "album_user"."updateId" > $3
|
||||
and "albumId" = $4
|
||||
order by
|
||||
"album_user"."updateId" asc
|
||||
@@ -810,7 +810,7 @@ from
|
||||
where
|
||||
"asset"."updateId" < $2
|
||||
and "asset"."updateId" <= $3
|
||||
and "asset"."updateId" >= $4
|
||||
and "asset"."updateId" > $4
|
||||
and "ownerId" = $5
|
||||
order by
|
||||
"asset"."updateId" asc
|
||||
@@ -908,7 +908,7 @@ from
|
||||
where
|
||||
"asset_exif"."updateId" < $1
|
||||
and "asset_exif"."updateId" <= $2
|
||||
and "asset_exif"."updateId" >= $3
|
||||
and "asset_exif"."updateId" > $3
|
||||
and "asset"."ownerId" = $4
|
||||
order by
|
||||
"asset_exif"."updateId" asc
|
||||
@@ -997,7 +997,7 @@ from
|
||||
where
|
||||
"stack"."updateId" < $1
|
||||
and "stack"."updateId" <= $2
|
||||
and "stack"."updateId" >= $3
|
||||
and "stack"."updateId" > $3
|
||||
and "ownerId" = $4
|
||||
order by
|
||||
"stack"."updateId" asc
|
||||
|
||||
@@ -106,7 +106,7 @@ export class BaseSync {
|
||||
.selectFrom(table(t).as(t))
|
||||
.where(updateIdRef, '<', nowId)
|
||||
.where(updateIdRef, '<=', beforeUpdateId)
|
||||
.$if(!!afterUpdateId, (qb) => qb.where(updateIdRef, '>=', afterUpdateId!))
|
||||
.$if(!!afterUpdateId, (qb) => qb.where(updateIdRef, '>', afterUpdateId!))
|
||||
.orderBy(updateIdRef, 'asc');
|
||||
}
|
||||
|
||||
|
||||
@@ -155,6 +155,57 @@ describe(SyncRequestType.AlbumToAssetsV1, () => {
|
||||
await ctx.assertSyncIsComplete(auth, [SyncRequestType.AlbumToAssetsV1]);
|
||||
});
|
||||
|
||||
it('should not resend an already-acked item when backfill resumes', async () => {
|
||||
const { auth, ctx } = await setup();
|
||||
const { user: user2 } = await ctx.newUser();
|
||||
|
||||
// backfill needs assets with an older updateId
|
||||
const { asset: sharedAsset1 } = await ctx.newAsset({ ownerId: user2.id });
|
||||
const { asset: sharedAsset2 } = await ctx.newAsset({ ownerId: user2.id });
|
||||
|
||||
await wait(2);
|
||||
|
||||
const { album: sharedAlbum } = await ctx.newAlbum({ ownerId: user2.id });
|
||||
await ctx.newAlbumAsset({ albumId: sharedAlbum.id, assetId: sharedAsset1.id });
|
||||
await ctx.newAlbumAsset({ albumId: sharedAlbum.id, assetId: sharedAsset2.id });
|
||||
|
||||
await wait(2);
|
||||
|
||||
// backfill needs an initial ack, otherwise it syncs everything
|
||||
const { asset: ownedAsset } = await ctx.newAsset({ ownerId: auth.user.id });
|
||||
const { album: ownedAlbum } = await ctx.newAlbum({ ownerId: auth.user.id });
|
||||
await ctx.newAlbumAsset({ albumId: ownedAlbum.id, assetId: ownedAsset.id });
|
||||
|
||||
const setupResponse = await ctx.syncStream(auth, [SyncRequestType.AlbumToAssetsV1]);
|
||||
await ctx.syncAckAll(auth, setupResponse);
|
||||
|
||||
// share album to trigger backfill
|
||||
await ctx.newAlbumUser({ albumId: sharedAlbum.id, userId: auth.user.id, role: AlbumUserRole.Editor });
|
||||
|
||||
const response1 = await ctx.syncStream(auth, [SyncRequestType.AlbumToAssetsV1]);
|
||||
expect(response1).toEqual([
|
||||
// receive both
|
||||
expect.objectContaining({ data: { albumId: sharedAlbum.id, assetId: sharedAsset1.id } }),
|
||||
expect.objectContaining({ data: { albumId: sharedAlbum.id, assetId: sharedAsset2.id } }),
|
||||
expect.objectContaining({ type: SyncEntityType.SyncAckV1 }),
|
||||
expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }),
|
||||
]);
|
||||
|
||||
// ack 1st
|
||||
await ctx.sut.setAcks(auth, { acks: [response1[0].ack] });
|
||||
|
||||
const response2 = await ctx.syncStream(auth, [SyncRequestType.AlbumToAssetsV1]);
|
||||
expect(response2).toEqual([
|
||||
// receive 2nd
|
||||
expect.objectContaining({ data: { albumId: sharedAlbum.id, assetId: sharedAsset2.id } }),
|
||||
expect.objectContaining({ type: SyncEntityType.SyncAckV1 }),
|
||||
expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }),
|
||||
]);
|
||||
|
||||
await ctx.syncAckAll(auth, response2);
|
||||
await ctx.assertSyncIsComplete(auth, [SyncRequestType.AlbumToAssetsV1]);
|
||||
});
|
||||
|
||||
it('should detect and sync a deleted album to asset relation', async () => {
|
||||
const { auth, ctx } = await setup();
|
||||
const albumRepo = ctx.get(AlbumRepository);
|
||||
|
||||
@@ -279,6 +279,68 @@ describe(SyncRequestType.PartnerAssetsV2, () => {
|
||||
await ctx.assertSyncIsComplete(auth, [SyncRequestType.PartnerAssetsV2]);
|
||||
});
|
||||
|
||||
it('should not resend an already-acked item when backfill resumes', async () => {
|
||||
const { auth, ctx } = await setup();
|
||||
const { user: user2 } = await ctx.newUser();
|
||||
const { user: user3 } = await ctx.newUser();
|
||||
|
||||
// backfill needs assets with an older updateId
|
||||
const { asset: partnerAsset1 } = await ctx.newAsset({ ownerId: user3.id });
|
||||
await wait(2);
|
||||
const { asset: partnerAsset2 } = await ctx.newAsset({ ownerId: user3.id });
|
||||
|
||||
await wait(2);
|
||||
|
||||
// backfill needs an initial ack, otherwise it syncs everything
|
||||
const { asset: initialAsset } = await ctx.newAsset({ ownerId: user2.id });
|
||||
await ctx.newPartner({ sharedById: user2.id, sharedWithId: auth.user.id });
|
||||
|
||||
const setupResponse = await ctx.syncStream(auth, [SyncRequestType.PartnerAssetsV2]);
|
||||
expect(setupResponse).toEqual([
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({ id: initialAsset.id }),
|
||||
type: SyncEntityType.PartnerAssetV2,
|
||||
}),
|
||||
expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }),
|
||||
]);
|
||||
await ctx.syncAckAll(auth, setupResponse);
|
||||
|
||||
// partner share to trigger backfill
|
||||
await ctx.newPartner({ sharedById: user3.id, sharedWithId: auth.user.id });
|
||||
|
||||
const response1 = await ctx.syncStream(auth, [SyncRequestType.PartnerAssetsV2]);
|
||||
expect(response1).toEqual([
|
||||
// receive both
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({ id: partnerAsset1.id }),
|
||||
type: SyncEntityType.PartnerAssetBackfillV2,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({ id: partnerAsset2.id }),
|
||||
type: SyncEntityType.PartnerAssetBackfillV2,
|
||||
}),
|
||||
expect.objectContaining({ type: SyncEntityType.SyncAckV1 }),
|
||||
expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }),
|
||||
]);
|
||||
|
||||
// ack 1st
|
||||
await ctx.sut.setAcks(auth, { acks: [response1[0].ack] });
|
||||
|
||||
const response2 = await ctx.syncStream(auth, [SyncRequestType.PartnerAssetsV2]);
|
||||
expect(response2).toEqual([
|
||||
// receive 2nd
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({ id: partnerAsset2.id }),
|
||||
type: SyncEntityType.PartnerAssetBackfillV2,
|
||||
}),
|
||||
expect.objectContaining({ type: SyncEntityType.SyncAckV1 }),
|
||||
expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }),
|
||||
]);
|
||||
|
||||
await ctx.syncAckAll(auth, response2);
|
||||
await ctx.assertSyncIsComplete(auth, [SyncRequestType.PartnerAssetsV2]);
|
||||
});
|
||||
|
||||
it('should hide isFavorite for partner assets', async () => {
|
||||
const { auth, ctx } = await setup();
|
||||
const { user: user2 } = await ctx.newUser();
|
||||
|
||||
+3
-1
@@ -159,7 +159,9 @@
|
||||
}
|
||||
|
||||
.text-white-shadow {
|
||||
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.8);
|
||||
text-shadow:
|
||||
0 0 4px rgba(0, 0, 0, 0.9),
|
||||
0 1px 3px rgba(0, 0, 0, 0.8);
|
||||
}
|
||||
|
||||
.icon-white-drop-shadow {
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
type AlbumResponseDto,
|
||||
type AssetResponseDto,
|
||||
} from '@immich/sdk';
|
||||
import { Icon, IconButton, LoadingSpinner, Text } from '@immich/ui';
|
||||
import { Icon, IconButton, Link, LoadingSpinner, Text } from '@immich/ui';
|
||||
import { mdiCamera, mdiCameraIris, mdiClose, mdiImageOutline, mdiInformationOutline } from '@mdi/js';
|
||||
import { onDestroy } from 'svelte';
|
||||
import { t } from 'svelte-i18n';
|
||||
@@ -310,14 +310,13 @@
|
||||
{#snippet popup({ marker })}
|
||||
{@const { lat, lon } = marker}
|
||||
<div class="flex flex-col items-center gap-1">
|
||||
<p class="font-bold">{lat.toPrecision(6)}, {lon.toPrecision(6)}</p>
|
||||
<a
|
||||
<Text fontWeight="bold">{lat.toPrecision(6)}, {lon.toPrecision(6)}</Text>
|
||||
<Link
|
||||
href="https://www.openstreetmap.org/?mlat={lat}&mlon={lon}&zoom=13#map=15/{lat}/{lon}"
|
||||
target="_blank"
|
||||
class="font-medium text-primary underline focus:outline-none"
|
||||
class="text-primary"
|
||||
>
|
||||
{$t('open_in_openstreetmap')}
|
||||
</a>
|
||||
</Link>
|
||||
</div>
|
||||
{/snippet}
|
||||
</Map>
|
||||
|
||||
@@ -342,7 +342,7 @@
|
||||
|
||||
{#if !!assetOwner}
|
||||
<div class="absolute inset-e-2 bottom-1 z-2 max-w-[50%]">
|
||||
<p class="max-w-full truncate text-xs font-medium text-white drop-shadow-lg">
|
||||
<p class="text-white-shadow max-w-full truncate p-1 text-xs font-medium text-white">
|
||||
{assetOwner.name}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -38,7 +38,6 @@
|
||||
Control,
|
||||
ControlButton,
|
||||
ControlGroup,
|
||||
FullscreenControl,
|
||||
GeoJSON,
|
||||
GeolocateControl,
|
||||
MapLibre,
|
||||
@@ -343,7 +342,6 @@
|
||||
|
||||
{#if !simplified}
|
||||
<GeolocateControl position="top-left" />
|
||||
<FullscreenControl position="top-left" />
|
||||
<ScaleControl />
|
||||
<AttributionControl compact={false} />
|
||||
{/if}
|
||||
@@ -401,13 +399,13 @@
|
||||
>
|
||||
{#snippet children({ feature }: { feature: Feature })}
|
||||
{#if useLocationPin}
|
||||
<Icon icon={mdiMapMarker} size="50px" class="translate-y-[-50%] text-primary" />
|
||||
<Icon icon={mdiMapMarker} size="50px" class="translate-y-[calc(5px-50%)] text-primary" />
|
||||
{:else}
|
||||
<img
|
||||
src={getAssetMediaUrl({ id: feature.properties?.id })}
|
||||
class="size-15 rounded-full border-2 border-immich-primary bg-immich-primary object-cover shadow-lg transition-all duration-200 hover:scale-150 hover:border-immich-dark-primary"
|
||||
alt={feature.properties?.city && feature.properties.country
|
||||
? $t('map_marker_for_images', {
|
||||
? $t('map_marker_for_image', {
|
||||
values: { city: feature.properties.city, country: feature.properties.country },
|
||||
})
|
||||
: $t('map_marker_with_image')}
|
||||
@@ -415,7 +413,7 @@
|
||||
{/if}
|
||||
{#if popup}
|
||||
<Popup offset={[0, -30]} openOn="click" closeOnClickOutside>
|
||||
{@render popup?.({ marker: asMarker(feature) })}
|
||||
{@render popup({ marker: asMarker(feature) })}
|
||||
</Popup>
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
@@ -169,7 +169,9 @@
|
||||
preload={false}
|
||||
/>
|
||||
{#if person.name}
|
||||
<span class="absolute inset-s-0 bottom-2 w-full px-1 text-center font-medium text-white select-text">
|
||||
<span
|
||||
class="text-white-shadow absolute inset-s-0 bottom-2 w-full px-1 text-center font-medium text-white select-text"
|
||||
>
|
||||
{person.name}
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
Reference in New Issue
Block a user