added clear message for denied camera or gallery access
This commit is contained in:
@@ -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();
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user