Add share to app, add requesting permission, add FAQ to about view, minor UI improvements to history list

This commit is contained in:
Varakh 2021-02-03 01:57:42 +01:00
parent 0c5384441a
commit 9f082f768a
16 changed files with 339 additions and 29 deletions

View file

@ -4,4 +4,5 @@
to allow setting breakpoints, to provide hot reload, etc. to allow setting breakpoints, to provide hot reload, etc.
--> -->
<uses-permission android:name="android.permission.INTERNET"/> <uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
</manifest> </manifest>

View file

@ -11,7 +11,7 @@
android:icon="@mipmap/ic_launcher"> android:icon="@mipmap/ic_launcher">
<activity <activity
android:name=".MainActivity" android:name=".MainActivity"
android:launchMode="singleTop" android:launchMode="singleTask"
android:theme="@style/LaunchTheme" android:theme="@style/LaunchTheme"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode" android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true" android:hardwareAccelerated="true"
@ -20,6 +20,46 @@
<action android:name="android.intent.action.MAIN"/> <action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/> <category android:name="android.intent.category.LAUNCHER"/>
</intent-filter> </intent-filter>
<intent-filter>
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="text/*" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="image/*" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.SEND_MULTIPLE" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="image/*" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="video/*" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.SEND_MULTIPLE" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="video/*" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="*/*" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.SEND_MULTIPLE" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="*/*" />
</intent-filter>
</activity> </activity>
<!-- Don't delete the meta-data below. <!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java --> This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
@ -28,4 +68,5 @@
android:value="2" /> android:value="2" />
</application> </application>
<uses-permission android:name="android.permission.INTERNET"/> <uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
</manifest> </manifest>

View file

@ -4,4 +4,5 @@
to allow setting breakpoints, to provide hot reload, etc. to allow setting breakpoints, to provide hot reload, etc.
--> -->
<uses-permission android:name="android.permission.INTERNET"/> <uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
</manifest> </manifest>

View file

@ -35,7 +35,8 @@
"dismiss": "Dismiss", "dismiss": "Dismiss",
"multipaste": "multipaste", "multipaste": "multipaste",
"errors": { "errors": {
"not_found": "Not found" "not_found": "Not found",
"retrieval_intent": "An error occurred while retrieving shared data"
} }
}, },
"startup": { "startup": {
@ -67,7 +68,7 @@
"filesize": "Filesize", "filesize": "Filesize",
"link": "Link", "link": "Link",
"date": "Date", "date": "Date",
"open_link": "Open in browser", "mimetype": "Mimetype",
"delete": "Delete permanently", "delete": "Delete permanently",
"multipaste_element": "Included as multipaste item", "multipaste_element": "Included as multipaste item",
"errors": { "errors": {
@ -82,7 +83,9 @@
}, },
"about": { "about": {
"headline": "Welcome to FileBin mobile!", "headline": "Welcome to FileBin mobile!",
"description": "This application is a mobile client for FileBin and it's open source. It helps you to manage your pastes.\n\nIn order to use the application, you need access to a FileBin instance and an API key to sign in. It's recommended that the API key has at least 'apikey' access-level.", "description": "This application is a mobile client for FileBin and it's open source. It helps you to manage your pastes.\n\nIn order to use the application, you need access to a FileBin instance.",
"faq_headline": "F.A.Q",
"faq": "- How do I login?\nInsert your instance URL and an API key which you can generate in the web interface of FileBin. You should use 'apikey' access-level in order to show history.\n\n- Why is storage permission required?\nIt's not required, but highly advised to grant it. Otherwise sharing files with the app won't work correctly and you might think that sharing has no effect.\n\n- When I am logged out, sharing files via share with the app won't list all files I selected after I login.\nPlease login before you start using the app. Account information are persisted. You only need to do it once.",
"contact_us": "Feedback? Issues?", "contact_us": "Feedback? Issues?",
"website": "Main application: https://github.com/Bluewind/filebin\n\nMobile: https://github.com/v4rakh/fbmobile" "website": "Main application: https://github.com/Bluewind/filebin\n\nMobile: https://github.com/v4rakh/fbmobile"
}, },
@ -108,6 +111,14 @@
"description": "Could not open '{link}'. Please ensure that you have an application installed which handles opening such link types." "description": "Could not open '{link}'. Please ensure that you have an application installed which handles opening such link types."
} }
}, },
"permission_service": {
"dialog": {
"title": "Storage permission",
"description": "Storage permission should be granted to the app so that it can work properly. Do you want to grant permission or ignore this message permanently in the future?",
"grant": "Grant",
"ignore": "Ignore"
}
},
"dialog": { "dialog": {
"confirm": "OK", "confirm": "OK",
"cancel": "Cancel" "cancel": "Cancel"

View file

@ -48,6 +48,19 @@
</array> </array>
<key>NSAppleMusicUsageDescription</key> <key>NSAppleMusicUsageDescription</key>
<string>Allow to select music files and upload them via the app</string> <string>Allow to select music files and upload them via the app</string>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleTypeRole</key>
<string>Editor</string>
<key>CFBundleURLSchemes</key>
<array>
<string>ShareMedia</string>
</array>
</dict>
<dict/>
</array>
// TODO follow steps 2) on create share extension (https://pub.dev/packages/receive_sharing_intent)
<key>NSPhotoLibraryUsageDescription</key> <key>NSPhotoLibraryUsageDescription</key>
<string>Allow to select photos and upload them via the app</string> <string>Allow to select photos and upload them via the app</string>
</dict> </dict>

View file

@ -1,4 +1,6 @@
class Constants { class Constants {
static const int apiRequestTimeoutLimit = 8; static const int apiRequestTimeoutLimit = 8;
static const String apiUrlSuffix = '/api/v2.2.0'; static const String apiUrlSuffix = '/api/v2.2.0';
static const int mediaPermissionCheckInterval = 120 * 1000;
} }

View file

@ -3,6 +3,7 @@ import 'package:logger/logger.dart';
import '../../core/services/session_service.dart'; import '../../core/services/session_service.dart';
import '../../locator.dart'; import '../../locator.dart';
import '../services/permission_service.dart';
import '../services/stoppable_service.dart'; import '../services/stoppable_service.dart';
import '../util/logger.dart'; import '../util/logger.dart';
@ -18,7 +19,7 @@ class LifeCycleManager extends StatefulWidget {
class _LifeCycleManagerState extends State<LifeCycleManager> with WidgetsBindingObserver { class _LifeCycleManagerState extends State<LifeCycleManager> with WidgetsBindingObserver {
final Logger logger = getLogger(); final Logger logger = getLogger();
List<StoppableService> servicesToManage = [locator<SessionService>()]; List<StoppableService> servicesToManage = [locator<SessionService>(), locator<PermissionService>()];
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {

View file

@ -0,0 +1,132 @@
import 'dart:async';
import 'package:flutter_translate/flutter_translate.dart';
import 'package:logger/logger.dart';
import 'package:permission_handler/permission_handler.dart';
import '../../constants.dart';
import '../../core/datamodels/dialog_response.dart';
import '../../core/services/dialog_service.dart';
import '../../core/services/stoppable_service.dart';
import '../../core/util/logger.dart';
import '../../locator.dart';
import 'storage_service.dart';
class PermissionService extends StoppableService {
final Logger _logger = getLogger();
final DialogService _dialogService = locator<DialogService>();
final StorageService _storageService = locator<StorageService>();
Timer _serviceCheckTimer;
PermissionStatus _permissionStatus;
bool _permanentlyIgnored = false;
bool _devicePermissionDialogActive = false;
bool _ownPermissionDialogActive = false;
PermissionService() {
_devicePermissionDialogActive = true;
Permission.storage.request().then((status) {
_permissionStatus = status;
if (PermissionStatus.permanentlyDenied == status) {
_permanentlyIgnored = true;
}
}).whenComplete(() {
_logger.d('Initial device request permission finished');
_devicePermissionDialogActive = false;
});
}
Future checkEnabledAndPermission() async {
if (_permanentlyIgnored) {
await _storageService.storeStoragePermissionDialogIgnored();
_permanentlyIgnored = false;
_logger.d('Set permanently ignored permission request');
stop();
}
if (_devicePermissionDialogActive) {
_logger.d('Device permission dialog active, skipping');
return;
}
if (_ownPermissionDialogActive) {
_logger.d('Own permission dialog already active, skipping');
return;
}
var ignoredDialog = await _storageService.hasStoragePermissionDialogIgnored();
if (ignoredDialog) {
_logger.d('Permanently ignored permission request, skipping');
stop();
return;
}
_permissionStatus = await Permission.storage.status;
if (_permissionStatus != PermissionStatus.granted) {
if (_permissionStatus == PermissionStatus.permanentlyDenied) {
await _storageService.storeStoragePermissionDialogIgnored();
return;
}
_ownPermissionDialogActive = true;
DialogResponse response = await _dialogService.showConfirmationDialog(
title: translate('permission_service.dialog.title'),
description: translate('permission_service.dialog.description'),
buttonTitleAccept: translate('permission_service.dialog.grant'),
buttonTitleDeny: translate('permission_service.dialog.ignore'));
if (!response.confirmed) {
await _storageService.storeStoragePermissionDialogIgnored();
} else {
_devicePermissionDialogActive = true;
Permission.storage.request().then((status) async {
if (PermissionStatus.permanentlyDenied == status) {
await _storageService.storeStoragePermissionDialogIgnored();
}
}).whenComplete(() {
_logger.d('Device request permission finished');
_devicePermissionDialogActive = false;
});
}
_ownPermissionDialogActive = false;
} else {
await _storageService.storeStoragePermissionDialogIgnored();
}
}
@override
Future start() async {
super.start();
await checkEnabledAndPermission();
_serviceCheckTimer =
Timer.periodic(Duration(milliseconds: Constants.mediaPermissionCheckInterval), (_serviceTimer) async {
if (!super.serviceStopped) {
await checkEnabledAndPermission();
} else {
_serviceTimer.cancel();
}
});
_logger.d('PermissionService started');
}
@override
void stop() {
_removeServiceCheckTimer();
super.stop();
_logger.d('PermissionService stopped');
}
void _removeServiceCheckTimer() {
if (_serviceCheckTimer != null) {
_serviceCheckTimer.cancel();
_serviceCheckTimer = null;
_logger.d('Removed service check timer');
}
}
}

View file

@ -7,6 +7,7 @@ import '../models/session.dart';
class StorageService { class StorageService {
static const _SESSION_KEY = 'session'; static const _SESSION_KEY = 'session';
static const _LAST_URL_KEY = 'last_url'; static const _LAST_URL_KEY = 'last_url';
static const _STORAGE_PERMISSION_DIALOG_IGNORED = 'storage_permission_ignored';
Future<bool> storeLastUrl(String url) { Future<bool> storeLastUrl(String url) {
return _store(_LAST_URL_KEY, url); return _store(_LAST_URL_KEY, url);
@ -37,6 +38,14 @@ class StorageService {
return _remove(_SESSION_KEY); return _remove(_SESSION_KEY);
} }
Future<bool> storeStoragePermissionDialogIgnored() {
return _store(_STORAGE_PERMISSION_DIALOG_IGNORED, true.toString());
}
Future<bool> hasStoragePermissionDialogIgnored() {
return _exists(_STORAGE_PERMISSION_DIALOG_IGNORED);
}
Future<bool> _exists(String key) async { Future<bool> _exists(String key) async {
final SharedPreferences prefs = await SharedPreferences.getInstance(); final SharedPreferences prefs = await SharedPreferences.getInstance();
return prefs.containsKey(key); return prefs.containsKey(key);

View file

@ -14,11 +14,11 @@ class StartUpViewModel extends BaseModel {
Future handleStartUpLogic() async { Future handleStartUpLogic() async {
setState(ViewState.Busy); setState(ViewState.Busy);
setStateMessage(translate('startup.init')); setStateMessage(translate('startup.init'));
await Future.delayed(Duration(milliseconds: 500)); await Future.delayed(Duration(milliseconds: 150));
setStateMessage(translate('startup.start_services')); setStateMessage(translate('startup.start_services'));
await _sessionService.start(); await _sessionService.start();
await Future.delayed(Duration(milliseconds: 500)); await Future.delayed(Duration(milliseconds: 150));
_navigationService.navigateAndReplaceTo(HomeView.routeName); _navigationService.navigateAndReplaceTo(HomeView.routeName);

View file

@ -1,3 +1,4 @@
import 'dart:async';
import 'dart:io'; import 'dart:io';
import 'package:file_picker/file_picker.dart'; import 'package:file_picker/file_picker.dart';
@ -5,6 +6,8 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter_translate/flutter_translate.dart'; import 'package:flutter_translate/flutter_translate.dart';
import 'package:logger/logger.dart'; import 'package:logger/logger.dart';
import 'package:path/path.dart';
import 'package:receive_sharing_intent/receive_sharing_intent.dart';
import '../../locator.dart'; import '../../locator.dart';
import '../enums/error_code.dart'; import '../enums/error_code.dart';
@ -15,15 +18,19 @@ import '../models/rest/rest_error.dart';
import '../models/rest/uploaded_multi_response.dart'; import '../models/rest/uploaded_multi_response.dart';
import '../models/rest/uploaded_response.dart'; import '../models/rest/uploaded_response.dart';
import '../services/file_service.dart'; import '../services/file_service.dart';
import '../services/link_service.dart';
import '../util/logger.dart'; import '../util/logger.dart';
import 'base_model.dart'; import 'base_model.dart';
class UploadModel extends BaseModel { class UploadModel extends BaseModel {
final Logger _logger = getLogger(); final Logger _logger = getLogger();
final FileService _fileService = locator<FileService>(); final FileService _fileService = locator<FileService>();
TextEditingController _pasteTextController = TextEditingController(); final LinkService _linkService = locator<LinkService>();
bool createMulti = false;
TextEditingController _pasteTextController = TextEditingController();
StreamSubscription _intentDataStreamSubscription;
bool createMulti = false;
String fileName; String fileName;
List<PlatformFile> paths; List<PlatformFile> paths;
String _extension; String _extension;
@ -32,6 +39,68 @@ class UploadModel extends BaseModel {
TextEditingController get pasteTextController => _pasteTextController; TextEditingController get pasteTextController => _pasteTextController;
void init() {
// For sharing images coming from outside the app while the app is in the memory
_intentDataStreamSubscription = ReceiveSharingIntent.getMediaStream().listen((List<SharedMediaFile> value) {
if (value != null && value.length > 0) {
setState(ViewState.Busy);
paths = value.map((sharedFile) {
return PlatformFile.fromMap({
'path': sharedFile.path,
'name': basename(sharedFile.path),
'size': File(sharedFile.path).lengthSync(),
'bytes': null
});
}).toList();
setState(ViewState.Idle);
}
}, onError: (err) {
setState(ViewState.Busy);
errorMessage = translate('upload.retrieval_intent');
_logger.e('Error while retrieving shared data: $err');
setState(ViewState.Idle);
});
// For sharing images coming from outside the app while the app is closed
ReceiveSharingIntent.getInitialMedia().then((List<SharedMediaFile> value) {
if (value != null && value.length > 0) {
setState(ViewState.Busy);
paths = value.map((sharedFile) {
return PlatformFile.fromMap({
'path': sharedFile.path,
'name': basename(sharedFile.path),
'size': File(sharedFile.path).lengthSync(),
'bytes': null
});
}).toList();
setState(ViewState.Idle);
}
});
// For sharing or opening urls/text coming from outside the app while the app is in the memory
_intentDataStreamSubscription = ReceiveSharingIntent.getTextStream().listen((String value) {
if (value != null && value.isNotEmpty) {
setState(ViewState.Busy);
pasteTextController.text = value;
setState(ViewState.Idle);
}
}, onError: (err) {
setState(ViewState.Busy);
errorMessage = translate('upload.retrieval_intent');
_logger.e('Error while retrieving shared data: $err');
setState(ViewState.Idle);
});
// For sharing or opening urls/text coming from outside the app while the app is closed
ReceiveSharingIntent.getInitialText().then((String value) {
if (value != null && value.isNotEmpty) {
setState(ViewState.Busy);
pasteTextController.text = value;
setState(ViewState.Idle);
}
});
}
void toggleCreateMulti() { void toggleCreateMulti() {
setState(ViewState.Busy); setState(ViewState.Busy);
createMulti = !createMulti; createMulti = !createMulti;
@ -140,9 +209,14 @@ class UploadModel extends BaseModel {
return null; return null;
} }
void openLink(String link) {
_linkService.open(link);
}
@override @override
void dispose() { void dispose() {
_pasteTextController.dispose(); _pasteTextController.dispose();
_intentDataStreamSubscription.cancel();
super.dispose(); super.dispose();
} }
} }

View file

@ -6,6 +6,7 @@ import 'core/services/dialog_service.dart';
import 'core/services/file_service.dart'; import 'core/services/file_service.dart';
import 'core/services/link_service.dart'; import 'core/services/link_service.dart';
import 'core/services/navigation_service.dart'; import 'core/services/navigation_service.dart';
import 'core/services/permission_service.dart';
import 'core/services/session_service.dart'; import 'core/services/session_service.dart';
import 'core/services/storage_service.dart'; import 'core/services/storage_service.dart';
import 'core/viewmodels/about_model.dart'; import 'core/viewmodels/about_model.dart';
@ -33,6 +34,7 @@ void setupLocator() {
locator.registerLazySingleton(() => SessionService()); locator.registerLazySingleton(() => SessionService());
locator.registerLazySingleton(() => FileService()); locator.registerLazySingleton(() => FileService());
locator.registerLazySingleton(() => LinkService()); locator.registerLazySingleton(() => LinkService());
locator.registerLazySingleton(() => PermissionService());
/// view models /// view models
locator.registerFactory(() => StartUpViewModel()); locator.registerFactory(() => StartUpViewModel());

View file

@ -19,7 +19,7 @@ class AboutView extends StatelessWidget {
tag: 'hero', tag: 'hero',
child: CircleAvatar( child: CircleAvatar(
backgroundColor: Colors.transparent, backgroundColor: Colors.transparent,
radius: 96.0, radius: 30.0,
child: Image.asset('assets/logo_caption.png'), child: Image.asset('assets/logo_caption.png'),
), ),
); );
@ -39,12 +39,24 @@ class AboutView extends StatelessWidget {
shrinkWrap: true, shrinkWrap: true,
padding: EdgeInsets.only(left: 24.0, right: 24.0), padding: EdgeInsets.only(left: 24.0, right: 24.0),
children: <Widget>[ children: <Widget>[
UIHelper.verticalSpaceMedium(),
Center(child: logo), Center(child: logo),
UIHelper.verticalSpaceMedium(),
Center( Center(
child: Text( child: Text(
translate(('about.description')), translate(('about.description')),
)), )),
UIHelper.verticalSpaceMedium(), UIHelper.verticalSpaceMedium(),
Center(
child: Text(
translate(('about.faq_headline')),
style: subHeaderStyle,
)),
Center(
child: Text(
translate(('about.faq')),
)),
UIHelper.verticalSpaceMedium(),
Center( Center(
child: Text( child: Text(
translate(('about.contact_us')), translate(('about.contact_us')),

View file

@ -89,11 +89,15 @@ class HistoryView extends StatelessWidget {
title: Text(paste.id), title: Text(paste.id),
subtitle: Text(translate('history.id')), subtitle: Text(translate('history.id')),
); );
var mimeTypeWidget = ListTile(
title: Text(paste.mimetype),
subtitle: Text(translate('history.mimetype')),
);
widgets.add(titleWidget); widgets.add(titleWidget);
widgets.add(fileSizeWidget);
widgets.add(idWidget); widgets.add(idWidget);
widgets.add(fileSizeWidget);
widgets.add(mimeTypeWidget);
} else { } else {
paste.items.forEach((element) { paste.items.forEach((element) {
widgets.add(ListTile( widgets.add(ListTile(
@ -110,18 +114,19 @@ class HistoryView extends StatelessWidget {
var expandable = ExpandableTheme( var expandable = ExpandableTheme(
data: ExpandableThemeData( data: ExpandableThemeData(
iconColor: Colors.blue,
tapHeaderToExpand: true,
iconPlacement: ExpandablePanelIconPlacement.right, iconPlacement: ExpandablePanelIconPlacement.right,
headerAlignment: ExpandablePanelHeaderAlignment.center, headerAlignment: ExpandablePanelHeaderAlignment.center,
hasIcon: true, hasIcon: true,
), iconColor: Colors.blue,
tapHeaderToExpand: true),
child: ExpandablePanel( child: ExpandablePanel(
header: Text( header: InkWell(
onLongPress: () => model.deletePaste(paste.id),
child: Text(
paste.id, paste.id,
style: TextStyle(color: Colors.blue),
textAlign: TextAlign.left, textAlign: TextAlign.left,
style: TextStyle(color: primaryAccentColor), )),
),
expanded: Column( expanded: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: widgets, children: widgets,
@ -132,12 +137,15 @@ class HistoryView extends StatelessWidget {
cards.add(Card( cards.add(Card(
child: ListTile( child: ListTile(
title: expandable, title: expandable,
trailing: IconButton( trailing: Wrap(children: [
openInBrowserButton,
IconButton(
icon: Icon(Icons.share, color: Colors.blue, textDirection: TextDirection.ltr), icon: Icon(Icons.share, color: Colors.blue, textDirection: TextDirection.ltr),
onPressed: () { onPressed: () {
return Share.share(fullPasteUrl); return Share.share(fullPasteUrl);
}), })
subtitle: Text(!paste.isMulti ? paste.filename : ''), ]),
subtitle: Text(!paste.isMulti ? paste.filename : '', style: TextStyle(fontStyle: FontStyle.italic)),
), ),
)); ));
}); });

View file

@ -19,6 +19,7 @@ class UploadView extends StatelessWidget {
var url = Provider.of<Session>(context).url; var url = Provider.of<Session>(context).url;
return BaseView<UploadModel>( return BaseView<UploadModel>(
onModelReady: (model) => model.init(),
builder: (context, model, child) => Scaffold( builder: (context, model, child) => Scaffold(
appBar: MyAppBar(title: Text(translate('titles.upload'))), appBar: MyAppBar(title: Text(translate('titles.upload'))),
backgroundColor: backgroundColor, backgroundColor: backgroundColor,

View file

@ -37,6 +37,8 @@ dependencies:
share: 0.6.5+4 share: 0.6.5+4
file_picker: 2.1.6 file_picker: 2.1.6
clipboard: 0.1.2+8 clipboard: 0.1.2+8
receive_sharing_intent: 1.4.3
permission_handler: 5.0.1+1
dev_dependencies: dev_dependencies:
flutter_test: flutter_test: