From 43dfc52bc7c51664923e82df4030b00aeee2d8a3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elias=20M=C3=BCller?= Date: Wed, 2 Sep 2026 20:31:44 +0200 Subject: [PATCH] added clear message for denied camera or gallery access --- lib/api/errors/permission_exception.dart | 41 +++++++++++ .../pages/files/widgets/add_file_menu.dart | 4 +- .../pages/more/feedback/feedback_dialog.dart | 2 +- .../pages/talk/widgets/chat_textfield.dart | 4 +- lib/widget/avatar_actions_sheet.dart | 7 +- lib/widget/chat_background_picker_sheet.dart | 6 +- lib/widget/file_pick.dart | 71 ++++++++++++++++--- test/api/errors/error_mapper_test.dart | 32 +++++++-- 8 files changed, 146 insertions(+), 21 deletions(-) create mode 100644 lib/api/errors/permission_exception.dart diff --git a/lib/api/errors/permission_exception.dart b/lib/api/errors/permission_exception.dart new file mode 100644 index 0000000..764a924 --- /dev/null +++ b/lib/api/errors/permission_exception.dart @@ -0,0 +1,41 @@ +import 'package:flutter/services.dart'; + +import 'app_exception.dart'; + +/// The user denied a system permission the action needs (camera, photos). +/// Retrying won't help until the permission is granted in the system settings. +class PermissionException extends AppException { + const PermissionException({ + required super.userMessage, + super.technicalDetails, + }) : super(allowRetry: false); + + factory PermissionException.camera({String? technicalDetails}) => + PermissionException( + userMessage: + 'Kein Zugriff auf die Kamera. Bitte erlaube der App den ' + 'Kamerazugriff in den Einstellungen deines Geräts.', + technicalDetails: technicalDetails, + ); + + factory PermissionException.photos({String? technicalDetails}) => + PermissionException( + userMessage: + 'Kein Zugriff auf deine Fotos. Bitte erlaube der App den ' + 'Fotozugriff in den Einstellungen deines Geräts.', + technicalDetails: technicalDetails, + ); + + /// Maps the `*_access_denied` codes thrown by `image_picker`; `null` for any + /// other platform error. + static PermissionException? fromPlatform(PlatformException error) => + switch (error.code) { + 'camera_access_denied' => PermissionException.camera( + technicalDetails: error.message, + ), + 'photo_access_denied' => PermissionException.photos( + technicalDetails: error.message, + ), + _ => null, + }; +} diff --git a/lib/view/pages/files/widgets/add_file_menu.dart b/lib/view/pages/files/widgets/add_file_menu.dart index c87353f..4fef1fd 100644 --- a/lib/view/pages/files/widgets/add_file_menu.dart +++ b/lib/view/pages/files/widgets/add_file_menu.dart @@ -38,7 +38,7 @@ void showAddFileSheet( leading: const Icon(Icons.add_a_photo_outlined), title: const Text('Aus Galerie hochladen'), onTap: () { - FilePick.multipleGalleryPick().then((value) { + FilePick.multipleGalleryPick(context).then((value) { if (value != null) onPickedFiles(value.map((e) => e.path).toList()); }); Navigator.of(sheetCtx).pop(); @@ -48,7 +48,7 @@ void showAddFileSheet( leading: const Icon(Icons.camera_alt_outlined), title: const Text('Foto aufnehmen'), onTap: () { - FilePick.cameraPick().then((image) { + FilePick.cameraPick(context).then((image) { if (image != null) onPickedFiles([image.path]); }); Navigator.of(sheetCtx).pop(); diff --git a/lib/view/pages/more/feedback/feedback_dialog.dart b/lib/view/pages/more/feedback/feedback_dialog.dart index 15b5892..71ca4c5 100644 --- a/lib/view/pages/more/feedback/feedback_dialog.dart +++ b/lib/view/pages/more/feedback/feedback_dialog.dart @@ -43,7 +43,7 @@ class _FeedbackDialogState extends State { } Future _pickImage() async { - final picked = await FilePick.singleGalleryPick(); + final picked = await FilePick.singleGalleryPick(context); if (picked == null) return; final data = await picked.readAsBytes(); if (!mounted) return; diff --git a/lib/view/pages/talk/widgets/chat_textfield.dart b/lib/view/pages/talk/widgets/chat_textfield.dart index f1b05f4..7769b5a 100644 --- a/lib/view/pages/talk/widgets/chat_textfield.dart +++ b/lib/view/pages/talk/widgets/chat_textfield.dart @@ -164,7 +164,7 @@ class _ChatTextfieldState extends State { leading: const Icon(Icons.image), title: const Text('Aus Galerie auswählen'), onTap: () { - FilePick.multipleGalleryPick().then((value) { + FilePick.multipleGalleryPick(context).then((value) { if (value != null) { mediaUpload(value.map((e) => e.path).toList()); } @@ -176,7 +176,7 @@ class _ChatTextfieldState extends State { leading: const Icon(Icons.camera_alt_outlined), title: const Text('Foto aufnehmen'), onTap: () { - FilePick.cameraPick().then((image) { + FilePick.cameraPick(context).then((image) { if (image != null) mediaUpload([image.path]); }); Navigator.of(sheetCtx).pop(); diff --git a/lib/widget/avatar_actions_sheet.dart b/lib/widget/avatar_actions_sheet.dart index 3a712de..5e1a1e0 100644 --- a/lib/widget/avatar_actions_sheet.dart +++ b/lib/widget/avatar_actions_sheet.dart @@ -42,7 +42,7 @@ Future showAvatarActionsSheet( onTap: () async { final bytes = await _pickAndCrop( sheetContext, - FilePick.singleGalleryPick, + () => FilePick.singleGalleryPick(sheetContext), ); if (bytes == null || !sheetContext.mounted) return; result = AvatarUploadResult(bytes); @@ -53,7 +53,10 @@ Future showAvatarActionsSheet( leading: const Icon(Icons.photo_camera_outlined), title: const Text('Foto aufnehmen'), onTap: () async { - final bytes = await _pickAndCrop(sheetContext, FilePick.cameraPick); + final bytes = await _pickAndCrop( + sheetContext, + () => FilePick.cameraPick(sheetContext), + ); if (bytes == null || !sheetContext.mounted) return; result = AvatarUploadResult(bytes); Navigator.of(sheetContext).pop(); diff --git a/lib/widget/chat_background_picker_sheet.dart b/lib/widget/chat_background_picker_sheet.dart index d0291d1..6f29474 100644 --- a/lib/widget/chat_background_picker_sheet.dart +++ b/lib/widget/chat_background_picker_sheet.dart @@ -21,7 +21,9 @@ Future showChatBackgroundPickerSheet(BuildContext context) async { leading: const Icon(Icons.photo_library_outlined), title: const Text('Aus Galerie wählen'), onTap: () async { - final bytes = await _pickRaw(FilePick.singleGalleryPick); + final bytes = await _pickRaw( + () => FilePick.singleGalleryPick(sheetContext), + ); if (bytes == null || !sheetContext.mounted) return; result = bytes; Navigator.of(sheetContext).pop(); @@ -31,7 +33,7 @@ Future showChatBackgroundPickerSheet(BuildContext context) async { leading: const Icon(Icons.photo_camera_outlined), title: const Text('Foto aufnehmen'), onTap: () async { - final bytes = await _pickRaw(FilePick.cameraPick); + final bytes = await _pickRaw(() => FilePick.cameraPick(sheetContext)); if (bytes == null || !sheetContext.mounted) return; result = bytes; Navigator.of(sheetContext).pop(); diff --git a/lib/widget/file_pick.dart b/lib/widget/file_pick.dart index 056e2a3..40be7e0 100644 --- a/lib/widget/file_pick.dart +++ b/lib/widget/file_pick.dart @@ -1,22 +1,77 @@ +import 'package:app_settings/app_settings.dart'; import 'package:file_picker/file_picker.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:image_picker/image_picker.dart'; +import '../api/errors/error_mapper.dart'; +import '../api/errors/permission_exception.dart'; +import 'confirm_dialog.dart'; +import 'info_dialog.dart'; + +/// Thin wrapper around the platform pickers. Every method resolves to `null` +/// when the user cancels **or** when picking failed; failures are reported to +/// the user right here (permission denial with a shortcut to the system +/// settings, everything else as a plain error dialog) so call sites can treat +/// the result like a cancel. class FilePick { static final _picker = ImagePicker(); - static Future?> multipleGalleryPick() async { - final pickedImages = await _picker.pickMultiImage(); - return pickedImages.isNotEmpty ? pickedImages : null; - } + static Future?> multipleGalleryPick(BuildContext context) => + _guarded(context, () async { + final pickedImages = await _picker.pickMultiImage(); + return pickedImages.isNotEmpty ? pickedImages : null; + }); - static Future singleGalleryPick() => - _picker.pickImage(source: ImageSource.gallery); + static Future singleGalleryPick(BuildContext context) => + _guarded(context, () => _picker.pickImage(source: ImageSource.gallery)); - static Future cameraPick() => - _picker.pickImage(source: ImageSource.camera); + static Future cameraPick(BuildContext context) => + _guarded(context, () => _picker.pickImage(source: ImageSource.camera)); static Future?> documentPick() async { final result = await FilePicker.pickFiles(allowMultiple: true); return result?.files.map((e) => e.path).nonNulls.toList(); } + + static Future _guarded( + BuildContext context, + Future Function() pick, + ) async { + try { + return await pick(); + } on PlatformException catch (e) { + final denied = PermissionException.fromPlatform(e); + if (!context.mounted) return null; + if (denied != null) { + _showPermissionDialog(context, denied); + } else { + _showErrorDialog(context, e); + } + return null; + } catch (e) { + if (context.mounted) _showErrorDialog(context, e); + return null; + } + } + + static void _showPermissionDialog( + BuildContext context, + PermissionException denied, + ) => ConfirmDialog( + icon: Icons.block_outlined, + title: 'Zugriff verweigert', + content: denied.userMessage, + confirmButton: 'Einstellungen öffnen', + onConfirm: AppSettings.openAppSettings, + ).asDialog(context); + + static void _showErrorDialog(BuildContext context, Object error) { + final message = errorToUserMessage(error); + final details = errorToTechnicalDetails(error); + final body = details != null && details != message + ? '$message\n\n$details' + : message; + InfoDialog.show(context, body, copyable: true, title: 'Fehler'); + } } diff --git a/test/api/errors/error_mapper_test.dart b/test/api/errors/error_mapper_test.dart index 6d3d39c..36e3b73 100644 --- a/test/api/errors/error_mapper_test.dart +++ b/test/api/errors/error_mapper_test.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'dart:io'; import 'package:dio/dio.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:http/http.dart' as http; import 'package:marianum_mobile/api/api_error.dart'; @@ -10,6 +11,7 @@ import 'package:marianum_mobile/api/errors/error_mapper.dart'; import 'package:marianum_mobile/api/errors/network_exception.dart'; import 'package:marianum_mobile/api/errors/not_found_exception.dart'; import 'package:marianum_mobile/api/errors/parse_exception.dart'; +import 'package:marianum_mobile/api/errors/permission_exception.dart'; import 'package:nextcloud/nextcloud.dart'; void main() { @@ -142,10 +144,7 @@ void main() { test('401 maps to the unauthorized AuthException', () { const ex = DynamiteApiException(401, {}, ''); - expect( - errorToUserMessage(ex), - AuthException.unauthorized().userMessage, - ); + expect(errorToUserMessage(ex), AuthException.unauthorized().userMessage); expect(errorAllowsRetry(ex), isFalse); }); @@ -205,4 +204,29 @@ void main() { expect(errorAllowsRetry(StateError('x')), isTrue); }); }); + + group('PermissionException.fromPlatform', () { + test('camera_access_denied maps to the camera message', () { + final mapped = PermissionException.fromPlatform( + PlatformException(code: 'camera_access_denied', message: 'denied'), + ); + expect(mapped?.userMessage, contains('Kamera')); + expect(mapped?.technicalDetails, 'denied'); + expect(errorAllowsRetry(mapped), isFalse); + }); + + test('photo_access_denied maps to the photos message', () { + final mapped = PermissionException.fromPlatform( + PlatformException(code: 'photo_access_denied'), + ); + expect(mapped?.userMessage, contains('Fotos')); + }); + + test('unrelated platform codes are not mapped', () { + expect( + PermissionException.fromPlatform(PlatformException(code: 'other')), + isNull, + ); + }); + }); }