added clear message for denied camera or gallery access

This commit is contained in:
2026-09-02 20:31:44 +02:00
parent f19f212fb4
commit 43dfc52bc7
8 changed files with 146 additions and 21 deletions
+41
View File
@@ -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,
};
}
@@ -38,7 +38,7 @@ void showAddFileSheet(
leading: const Icon(Icons.add_a_photo_outlined), leading: const Icon(Icons.add_a_photo_outlined),
title: const Text('Aus Galerie hochladen'), title: const Text('Aus Galerie hochladen'),
onTap: () { onTap: () {
FilePick.multipleGalleryPick().then((value) { FilePick.multipleGalleryPick(context).then((value) {
if (value != null) onPickedFiles(value.map((e) => e.path).toList()); if (value != null) onPickedFiles(value.map((e) => e.path).toList());
}); });
Navigator.of(sheetCtx).pop(); Navigator.of(sheetCtx).pop();
@@ -48,7 +48,7 @@ void showAddFileSheet(
leading: const Icon(Icons.camera_alt_outlined), leading: const Icon(Icons.camera_alt_outlined),
title: const Text('Foto aufnehmen'), title: const Text('Foto aufnehmen'),
onTap: () { onTap: () {
FilePick.cameraPick().then((image) { FilePick.cameraPick(context).then((image) {
if (image != null) onPickedFiles([image.path]); if (image != null) onPickedFiles([image.path]);
}); });
Navigator.of(sheetCtx).pop(); Navigator.of(sheetCtx).pop();
@@ -43,7 +43,7 @@ class _FeedbackDialogState extends State<FeedbackDialog> {
} }
Future<void> _pickImage() async { Future<void> _pickImage() async {
final picked = await FilePick.singleGalleryPick(); final picked = await FilePick.singleGalleryPick(context);
if (picked == null) return; if (picked == null) return;
final data = await picked.readAsBytes(); final data = await picked.readAsBytes();
if (!mounted) return; if (!mounted) return;
@@ -164,7 +164,7 @@ class _ChatTextfieldState extends State<ChatTextfield> {
leading: const Icon(Icons.image), leading: const Icon(Icons.image),
title: const Text('Aus Galerie auswählen'), title: const Text('Aus Galerie auswählen'),
onTap: () { onTap: () {
FilePick.multipleGalleryPick().then((value) { FilePick.multipleGalleryPick(context).then((value) {
if (value != null) { if (value != null) {
mediaUpload(value.map((e) => e.path).toList()); mediaUpload(value.map((e) => e.path).toList());
} }
@@ -176,7 +176,7 @@ class _ChatTextfieldState extends State<ChatTextfield> {
leading: const Icon(Icons.camera_alt_outlined), leading: const Icon(Icons.camera_alt_outlined),
title: const Text('Foto aufnehmen'), title: const Text('Foto aufnehmen'),
onTap: () { onTap: () {
FilePick.cameraPick().then((image) { FilePick.cameraPick(context).then((image) {
if (image != null) mediaUpload([image.path]); if (image != null) mediaUpload([image.path]);
}); });
Navigator.of(sheetCtx).pop(); Navigator.of(sheetCtx).pop();
+5 -2
View File
@@ -42,7 +42,7 @@ Future<AvatarSheetResult?> showAvatarActionsSheet(
onTap: () async { onTap: () async {
final bytes = await _pickAndCrop( final bytes = await _pickAndCrop(
sheetContext, sheetContext,
FilePick.singleGalleryPick, () => FilePick.singleGalleryPick(sheetContext),
); );
if (bytes == null || !sheetContext.mounted) return; if (bytes == null || !sheetContext.mounted) return;
result = AvatarUploadResult(bytes); result = AvatarUploadResult(bytes);
@@ -53,7 +53,10 @@ Future<AvatarSheetResult?> showAvatarActionsSheet(
leading: const Icon(Icons.photo_camera_outlined), leading: const Icon(Icons.photo_camera_outlined),
title: const Text('Foto aufnehmen'), title: const Text('Foto aufnehmen'),
onTap: () async { onTap: () async {
final bytes = await _pickAndCrop(sheetContext, FilePick.cameraPick); final bytes = await _pickAndCrop(
sheetContext,
() => FilePick.cameraPick(sheetContext),
);
if (bytes == null || !sheetContext.mounted) return; if (bytes == null || !sheetContext.mounted) return;
result = AvatarUploadResult(bytes); result = AvatarUploadResult(bytes);
Navigator.of(sheetContext).pop(); Navigator.of(sheetContext).pop();
+4 -2
View File
@@ -21,7 +21,9 @@ Future<Uint8List?> showChatBackgroundPickerSheet(BuildContext context) async {
leading: const Icon(Icons.photo_library_outlined), leading: const Icon(Icons.photo_library_outlined),
title: const Text('Aus Galerie wählen'), title: const Text('Aus Galerie wählen'),
onTap: () async { onTap: () async {
final bytes = await _pickRaw(FilePick.singleGalleryPick); final bytes = await _pickRaw(
() => FilePick.singleGalleryPick(sheetContext),
);
if (bytes == null || !sheetContext.mounted) return; if (bytes == null || !sheetContext.mounted) return;
result = bytes; result = bytes;
Navigator.of(sheetContext).pop(); Navigator.of(sheetContext).pop();
@@ -31,7 +33,7 @@ Future<Uint8List?> showChatBackgroundPickerSheet(BuildContext context) async {
leading: const Icon(Icons.photo_camera_outlined), leading: const Icon(Icons.photo_camera_outlined),
title: const Text('Foto aufnehmen'), title: const Text('Foto aufnehmen'),
onTap: () async { onTap: () async {
final bytes = await _pickRaw(FilePick.cameraPick); final bytes = await _pickRaw(() => FilePick.cameraPick(sheetContext));
if (bytes == null || !sheetContext.mounted) return; if (bytes == null || !sheetContext.mounted) return;
result = bytes; result = bytes;
Navigator.of(sheetContext).pop(); Navigator.of(sheetContext).pop();
+61 -6
View File
@@ -1,22 +1,77 @@
import 'package:app_settings/app_settings.dart';
import 'package:file_picker/file_picker.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 '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 { class FilePick {
static final _picker = ImagePicker(); static final _picker = ImagePicker();
static Future<List<XFile>?> multipleGalleryPick() async { static Future<List<XFile>?> multipleGalleryPick(BuildContext context) =>
_guarded(context, () async {
final pickedImages = await _picker.pickMultiImage(); final pickedImages = await _picker.pickMultiImage();
return pickedImages.isNotEmpty ? pickedImages : null; return pickedImages.isNotEmpty ? pickedImages : null;
} });
static Future<XFile?> singleGalleryPick() => static Future<XFile?> singleGalleryPick(BuildContext context) =>
_picker.pickImage(source: ImageSource.gallery); _guarded(context, () => _picker.pickImage(source: ImageSource.gallery));
static Future<XFile?> cameraPick() => static Future<XFile?> cameraPick(BuildContext context) =>
_picker.pickImage(source: ImageSource.camera); _guarded(context, () => _picker.pickImage(source: ImageSource.camera));
static Future<List<String>?> documentPick() async { static Future<List<String>?> documentPick() async {
final result = await FilePicker.pickFiles(allowMultiple: true); final result = await FilePicker.pickFiles(allowMultiple: true);
return result?.files.map((e) => e.path).nonNulls.toList(); return result?.files.map((e) => e.path).nonNulls.toList();
} }
static Future<T?> _guarded<T>(
BuildContext context,
Future<T?> 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');
}
} }
+28 -4
View File
@@ -2,6 +2,7 @@ import 'dart:async';
import 'dart:io'; import 'dart:io';
import 'package:dio/dio.dart'; import 'package:dio/dio.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart' as http; import 'package:http/http.dart' as http;
import 'package:marianum_mobile/api/api_error.dart'; 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/network_exception.dart';
import 'package:marianum_mobile/api/errors/not_found_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/parse_exception.dart';
import 'package:marianum_mobile/api/errors/permission_exception.dart';
import 'package:nextcloud/nextcloud.dart'; import 'package:nextcloud/nextcloud.dart';
void main() { void main() {
@@ -142,10 +144,7 @@ void main() {
test('401 maps to the unauthorized AuthException', () { test('401 maps to the unauthorized AuthException', () {
const ex = DynamiteApiException(401, {}, ''); const ex = DynamiteApiException(401, {}, '');
expect( expect(errorToUserMessage(ex), AuthException.unauthorized().userMessage);
errorToUserMessage(ex),
AuthException.unauthorized().userMessage,
);
expect(errorAllowsRetry(ex), isFalse); expect(errorAllowsRetry(ex), isFalse);
}); });
@@ -205,4 +204,29 @@ void main() {
expect(errorAllowsRetry(StateError('x')), isTrue); 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,
);
});
});
} }