diff --git a/mobile/lib/presentation/actions/action.dart b/mobile/lib/presentation/actions/action.dart new file mode 100644 index 0000000000..419f97e9cb --- /dev/null +++ b/mobile/lib/presentation/actions/action.dart @@ -0,0 +1,14 @@ +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +abstract class BaseAction { + final IconData icon; + + const BaseAction({required this.icon}); + + String label(BuildContext context); + + bool isVisible(BuildContext context, WidgetRef ref); + + Future onAction(BuildContext context, WidgetRef ref); +} diff --git a/mobile/lib/presentation/actions/action.widget.dart b/mobile/lib/presentation/actions/action.widget.dart new file mode 100644 index 0000000000..6c0f513808 --- /dev/null +++ b/mobile/lib/presentation/actions/action.widget.dart @@ -0,0 +1,81 @@ +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'; + +class _ActionWidget extends ConsumerWidget { + final BaseAction action; + final Widget Function(Future Function() onAction) builder; + + const _ActionWidget({required this.action, required this.builder}); + + Future _onAction(BuildContext context, WidgetRef ref) async { + try { + await action.onAction(context, ref); + } catch (error, stackTrace) { + handleError(context, error, stack: stackTrace, description: 'Action failed: ${action.runtimeType}'); + } + } + + @override + Widget build(BuildContext context, WidgetRef ref) { + if (!action.isVisible(context, ref)) { + return const SizedBox.shrink(); + } + + return builder(() => _onAction(context, ref)); + } +} + +class ActionIconButtonWidget extends StatelessWidget { + final BaseAction action; + final ImmichVariant variant; + + const ActionIconButtonWidget({super.key, required this.action, this.variant = .ghost}); + + @override + Widget build(BuildContext context) => _ActionWidget( + action: action, + builder: (onAction) => ImmichIconButton(icon: action.icon, onPressed: onAction, variant: variant), + ); +} + +class ActionButtonWidget extends StatelessWidget { + final BaseAction action; + final ImmichVariant variant; + + const ActionButtonWidget({super.key, required this.action, this.variant = .ghost}); + + @override + Widget build(BuildContext context) => _ActionWidget( + action: action, + builder: (onAction) => + ImmichTextButton(labelText: action.label(context), icon: action.icon, onPressed: onAction, variant: variant), + ); +} + +class ActionColumnButtonWidget extends StatelessWidget { + final BaseAction action; + + const ActionColumnButtonWidget({super.key, required this.action}); + + @override + Widget build(BuildContext context) => _ActionWidget( + action: action, + builder: (onAction) => ImmichColumnButton(icon: action.icon, label: action.label(context), onPressed: onAction), + ); +} + +class ActionMenuItemWidget extends StatelessWidget { + final BaseAction action; + + const ActionMenuItemWidget({super.key, required this.action}); + + @override + Widget build(BuildContext context) => _ActionWidget( + action: action, + builder: (onAction) => ImmichMenuItem(icon: action.icon, label: action.label(context), onPressed: onAction), + ); +} diff --git a/mobile/lib/utils/error_handler.dart b/mobile/lib/utils/error_handler.dart new file mode 100644 index 0000000000..24387b8002 --- /dev/null +++ b/mobile/lib/utils/error_handler.dart @@ -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;