From 359ed768c5b97b51c40e7ec3a3c7e524956e211b Mon Sep 17 00:00:00 2001 From: Myzel394 <50424412+Myzel394@users.noreply.github.com> Date: Thu, 18 Aug 2022 22:31:34 +0200 Subject: [PATCH] added location functionality to memories --- android/app/src/main/AndroidManifest.xml | 9 +- ios/Podfile | 3 + ios/Runner/Info.plist | 3 + lib/constants/values.dart | 1 + lib/foreign_types/memory.dart | 29 +-- lib/foreign_types/memory_location.dart | 32 +++ lib/locale/l10n/app_de.arb | 1 + lib/locale/l10n/app_en.arb | 3 + lib/managers/file_manager.dart | 23 +- lib/managers/global_values_manager.dart | 6 + .../permissions_required_page.dart | 31 ++- lib/screens/login_screen.dart | 13 ++ lib/screens/main_screen.dart | 12 +- .../main_screen/settings_button_overlay.dart | 4 +- lib/screens/server_loading_screen.dart | 12 + .../timeline_screen/memory_location_view.dart | 149 ++++++++++++ lib/screens/timeline_screen/memory_sheet.dart | 213 +++++++++++------- lib/utils/tag_location_to_image.dart | 18 ++ lib/widgets/key_value_info.dart | 75 ++++++ pubspec.lock | 58 ++++- pubspec.yaml | 4 + 21 files changed, 597 insertions(+), 102 deletions(-) create mode 100644 lib/foreign_types/memory_location.dart create mode 100644 lib/screens/timeline_screen/memory_location_view.dart create mode 100644 lib/utils/tag_location_to_image.dart create mode 100644 lib/widgets/key_value_info.dart diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 53c7526..c233f0a 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,5 +1,12 @@ + + + + + + + - - diff --git a/ios/Podfile b/ios/Podfile index f5d7214..13cdd7b 100644 --- a/ios/Podfile +++ b/ios/Podfile @@ -55,6 +55,9 @@ post_install do |installer| ## dart: PermissionGroup.microphone 'PERMISSION_MICROPHONE=1', + + ## dart: [PermissionGroup.location, PermissionGroup.locationAlways, PermissionGroup.locationWhenInUse] + 'PERMISSION_LOCATION=1', ] end diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index c163fa0..9e55a95 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -72,5 +72,8 @@ NSPhotoLibraryUsageDescription Accessing your gallery allows you to save your memories + + NSLocationUsageDescription + Accessing your location allows you to tag your memories diff --git a/lib/constants/values.dart b/lib/constants/values.dart index a2552c2..00800e1 100644 --- a/lib/constants/values.dart +++ b/lib/constants/values.dart @@ -17,3 +17,4 @@ const WELCOME_SCREEN_PHOTOS_QUERIES = [ 'friends', 'romantic', ]; +const ACCURACY_IN_METERS_FOR_PINPOINT = 20; diff --git a/lib/foreign_types/memory.dart b/lib/foreign_types/memory.dart index 7f23ecd..943eb19 100644 --- a/lib/foreign_types/memory.dart +++ b/lib/foreign_types/memory.dart @@ -4,38 +4,41 @@ import 'package:path/path.dart'; import 'package:quid_faciam_hodie/enums.dart'; import 'package:quid_faciam_hodie/managers/file_manager.dart'; +import 'memory_location.dart'; + class Memory { final String id; final DateTime creationDate; - final String location; + final String filePath; final bool isPublic; final String userID; + final MemoryLocation? location; const Memory({ required this.id, required this.creationDate, - required this.location, + required this.filePath, required this.isPublic, required this.userID, + this.location, }); - static parse(Map jsonData) { - return Memory( - id: jsonData['id'], - creationDate: DateTime.parse(jsonData['created_at']), - location: jsonData['location'], - isPublic: jsonData['is_public'], - userID: jsonData['user_id'], - ); - } + static parse(final Map jsonData) => Memory( + id: jsonData['id'], + creationDate: DateTime.parse(jsonData['created_at']), + filePath: jsonData['location'], + isPublic: jsonData['is_public'], + userID: jsonData['user_id'], + location: MemoryLocation.parse(jsonData), + ); - String get filename => basename(location); + String get filename => basename(filePath); MemoryType get type => filename.split('.').last == 'jpg' ? MemoryType.photo : MemoryType.video; Future downloadToFile() => FileManager.downloadFile( 'memories', - location, + filePath, ); } diff --git a/lib/foreign_types/memory_location.dart b/lib/foreign_types/memory_location.dart new file mode 100644 index 0000000..7b16b75 --- /dev/null +++ b/lib/foreign_types/memory_location.dart @@ -0,0 +1,32 @@ +class MemoryLocation { + final double latitude; + final double longitude; + final double speed; + final double accuracy; + final double altitude; + final double heading; + + const MemoryLocation({ + required this.latitude, + required this.longitude, + required this.speed, + required this.accuracy, + required this.altitude, + required this.heading, + }); + + static MemoryLocation? parse(final Map jsonData) { + try { + return MemoryLocation( + latitude: (jsonData['location_latitude'] as num).toDouble(), + longitude: (jsonData['location_longitude'] as num).toDouble(), + speed: (jsonData['location_speed'] as num).toDouble(), + accuracy: (jsonData['location_accuracy'] as num).toDouble(), + altitude: (jsonData['location_altitude'] as num).toDouble(), + heading: (jsonData['location_heading'] as num).toDouble(), + ); + } catch (error) { + return null; + } + } +} diff --git a/lib/locale/l10n/app_de.arb b/lib/locale/l10n/app_de.arb index b2f23a2..5eeb6f4 100644 --- a/lib/locale/l10n/app_de.arb +++ b/lib/locale/l10n/app_de.arb @@ -47,6 +47,7 @@ "permissionsRequiredPageOpenSettings": "Einstellungen öffnen", "permissionsRequiredPageGrantCameraPermission": "Kamera-Berechtigung erteilen", "permissionsRequiredPageGrantMicrophonePermission": "Mikrofon-Berechtigung erteilen", + "permissionsRequiredPageGrantLocationPermission": "Standort-Berechtigung erteilen", "memoryViewIsDownloading": "Erinnerung wird heruntergeladen", diff --git a/lib/locale/l10n/app_en.arb b/lib/locale/l10n/app_en.arb index b590ba8..dc34770 100644 --- a/lib/locale/l10n/app_en.arb +++ b/lib/locale/l10n/app_en.arb @@ -65,6 +65,7 @@ "permissionsRequiredPageOpenSettings": "Open Settings", "permissionsRequiredPageGrantCameraPermission": "Grant camera permission", "permissionsRequiredPageGrantMicrophonePermission": "Grant microphone permission", + "permissionsRequiredPageGrantLocationPermission": "Grant location permission", "memoryViewIsDownloading": "Downloading memory", @@ -89,6 +90,8 @@ } } }, + "memorySheetMapEstimatedAddressLabel": "Estimated Address", + "memorySheetMapOpenNavigation": "Open Navigation", "emptyScreenTitle": "Houston, we have a problem", diff --git a/lib/managers/file_manager.dart b/lib/managers/file_manager.dart index e40359a..9e03c5d 100644 --- a/lib/managers/file_manager.dart +++ b/lib/managers/file_manager.dart @@ -2,6 +2,7 @@ import 'dart:io'; import 'dart:typed_data'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:location/location.dart'; import 'package:path_provider/path_provider.dart'; import 'package:quid_faciam_hodie/foreign_types/memory.dart'; import 'package:quid_faciam_hodie/managers/cache_manager.dart'; @@ -32,7 +33,11 @@ class FileManager { return Memory.parse(response.data); } - static uploadFile(final User user, final File file) async { + static uploadFile( + final User user, + final File file, { + LocationData? locationData, + }) async { await GlobalValuesManager.waitForServerInitialization(); final basename = uuid.v4(); @@ -46,10 +51,22 @@ class FileManager { throw Exception('Error uploading file: ${response.error!.message}'); } - final memoryResponse = await supabase.from('memories').insert({ + final Map data = { 'user_id': user.id, 'location': path, - }).execute(); + }; + + if (locationData != null) { + data['location_latitude'] = locationData.latitude!; + data['location_longitude'] = locationData.longitude!; + data['location_speed'] = locationData.speed!; + data['location_accuracy'] = locationData.accuracy!; + data['location_altitude'] = locationData.altitude!; + data['location_heading'] = locationData.heading!; + } + + final memoryResponse = + await supabase.from('memories').insert(data).execute(); if (memoryResponse.error != null) { throw Exception('Error creating memory: ${response.error!.message}'); diff --git a/lib/managers/global_values_manager.dart b/lib/managers/global_values_manager.dart index d9202ff..d1fce63 100644 --- a/lib/managers/global_values_manager.dart +++ b/lib/managers/global_values_manager.dart @@ -1,5 +1,6 @@ import 'package:camera/camera.dart'; import 'package:flutter/foundation.dart'; +import 'package:permission_handler/permission_handler.dart'; import 'package:quid_faciam_hodie/constants/apis.dart'; import 'package:supabase_flutter/supabase_flutter.dart'; @@ -45,4 +46,9 @@ class GlobalValuesManager { await _serverInitializationFuture; } + + static Future hasGrantedPermissions() async => + (await Permission.camera.isGranted) && + (await Permission.microphone.isGranted) && + (await Permission.location.isGranted); } diff --git a/lib/screens/grant_permission_screen/permissions_required_page.dart b/lib/screens/grant_permission_screen/permissions_required_page.dart index 6fc5a11..e19137c 100644 --- a/lib/screens/grant_permission_screen/permissions_required_page.dart +++ b/lib/screens/grant_permission_screen/permissions_required_page.dart @@ -22,6 +22,7 @@ class _PermissionsRequiredPageState extends State { bool hasDeniedForever = false; bool hasGrantedCameraPermission = false; bool hasGrantedMicrophonePermission = false; + bool hasGrantedLocationPermission = false; @override void initState() { @@ -33,12 +34,15 @@ class _PermissionsRequiredPageState extends State { Future checkPermissions() async { final cameraStatus = await Permission.camera.status; final microphoneStatus = await Permission.microphone.status; + final locationStatus = await Permission.location.status; setState(() { hasGrantedCameraPermission = cameraStatus.isGranted; hasGrantedMicrophonePermission = microphoneStatus.isGranted; + hasGrantedLocationPermission = locationStatus.isGranted; }); + // These permissions are crucially required for the app to work if (cameraStatus.isPermanentlyDenied || microphoneStatus.isPermanentlyDenied) { setState(() { @@ -48,7 +52,9 @@ class _PermissionsRequiredPageState extends State { return; } - if (cameraStatus.isGranted && microphoneStatus.isGranted) { + if (cameraStatus.isGranted && + microphoneStatus.isGranted && + locationStatus.isGranted) { widget.onPermissionsGranted(); } } @@ -136,6 +142,29 @@ class _PermissionsRequiredPageState extends State { ), ), ), + PlatformTextButton( + onPressed: hasGrantedLocationPermission + ? null + : () async { + await Permission.location.request(); + await checkPermissions(); + }, + child: IconButtonChild( + icon: Icon(context.platformIcons.location), + label: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + localizations + .permissionsRequiredPageGrantMicrophonePermission, + ), + if (hasGrantedLocationPermission) + Icon(context.platformIcons.checkMark), + if (!hasGrantedLocationPermission) const SizedBox(), + ], + ), + ), + ), ], ], ); diff --git a/lib/screens/login_screen.dart b/lib/screens/login_screen.dart index 040513e..50dbbdd 100644 --- a/lib/screens/login_screen.dart +++ b/lib/screens/login_screen.dart @@ -10,6 +10,8 @@ import 'package:quid_faciam_hodie/utils/loadable.dart'; import 'package:quid_faciam_hodie/widgets/icon_button_child.dart'; import 'package:supabase_flutter/supabase_flutter.dart'; +import 'main_screen.dart'; + final supabase = Supabase.instance.client; class LoginScreen extends StatefulWidget { @@ -25,6 +27,17 @@ class _LoginScreenState extends AuthState with Loadable { final emailController = TextEditingController(); final passwordController = TextEditingController(); + @override + void onAuthenticated(Session session) { + if (session.user != null) { + Navigator.pushNamedAndRemoveUntil( + context, + MainScreen.ID, + (_) => false, + ); + } + } + @override void dispose() { emailController.dispose(); diff --git a/lib/screens/main_screen.dart b/lib/screens/main_screen.dart index 91ebbc0..0e4956d 100644 --- a/lib/screens/main_screen.dart +++ b/lib/screens/main_screen.dart @@ -7,6 +7,8 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:flutter_platform_widgets/flutter_platform_widgets.dart'; +import 'package:location/location.dart'; +import 'package:permission_handler/permission_handler.dart'; import 'package:quid_faciam_hodie/constants/spacing.dart'; import 'package:quid_faciam_hodie/constants/values.dart'; import 'package:quid_faciam_hodie/extensions/snackbar.dart'; @@ -15,6 +17,7 @@ import 'package:quid_faciam_hodie/managers/global_values_manager.dart'; import 'package:quid_faciam_hodie/screens/main_screen/settings_button_overlay.dart'; import 'package:quid_faciam_hodie/utils/auth_required.dart'; import 'package:quid_faciam_hodie/utils/loadable.dart'; +import 'package:quid_faciam_hodie/utils/tag_location_to_image.dart'; import 'package:quid_faciam_hodie/widgets/animate_in_builder.dart'; import 'package:quid_faciam_hodie/widgets/fade_and_move_in_animation.dart'; import 'package:quid_faciam_hodie/widgets/icon_button_child.dart'; @@ -189,6 +192,13 @@ class _MainScreenState extends AuthRequiredState with Loadable { } final file = File((await controller!.takePicture()).path); + LocationData? locationData; + + if (Platform.isAndroid && (await Permission.location.isGranted)) { + locationData = await Location().getLocation(); + + await tagLocationToImage(file, locationData); + } setState(() { uploadingPhotoAnimation = file.readAsBytesSync(); @@ -200,7 +210,7 @@ class _MainScreenState extends AuthRequiredState with Loadable { ); try { - await FileManager.uploadFile(_user, file); + await FileManager.uploadFile(_user, file, locationData: locationData); } catch (error) { if (isMaterial(context)) context.showErrorSnackBar(message: error.toString()); diff --git a/lib/screens/main_screen/settings_button_overlay.dart b/lib/screens/main_screen/settings_button_overlay.dart index b5fec54..1378b65 100644 --- a/lib/screens/main_screen/settings_button_overlay.dart +++ b/lib/screens/main_screen/settings_button_overlay.dart @@ -11,8 +11,8 @@ class SettingsButtonOverlay extends StatelessWidget { return Positioned( left: SMALL_SPACE, top: SMALL_SPACE, - child: PlatformTextButton( - child: Icon( + child: PlatformIconButton( + icon: Icon( context.platformIcons.settings, color: Colors.white, ), diff --git a/lib/screens/server_loading_screen.dart b/lib/screens/server_loading_screen.dart index 6cdfecb..93aca73 100644 --- a/lib/screens/server_loading_screen.dart +++ b/lib/screens/server_loading_screen.dart @@ -5,6 +5,7 @@ import 'package:provider/provider.dart'; import 'package:quid_faciam_hodie/constants/spacing.dart'; import 'package:quid_faciam_hodie/managers/global_values_manager.dart'; import 'package:quid_faciam_hodie/models/memories.dart'; +import 'package:quid_faciam_hodie/screens/grant_permission_screen.dart'; import 'package:supabase_flutter/supabase_flutter.dart'; import 'empty_screen.dart'; @@ -35,6 +36,13 @@ class _ServerLoadingScreenState extends State { } Future load() async { + if (!(await GlobalValuesManager.hasGrantedPermissions())) { + Navigator.pushReplacementNamed( + context, + GrantPermissionScreen.ID, + ); + } + await GlobalValuesManager.waitForServerInitialization(); final memories = context.read(); @@ -45,6 +53,10 @@ class _ServerLoadingScreenState extends State { await memories.initialize(); } + if (!mounted) { + return; + } + if (widget.nextScreen == null) { Navigator.pushNamed( context, diff --git a/lib/screens/timeline_screen/memory_location_view.dart b/lib/screens/timeline_screen/memory_location_view.dart new file mode 100644 index 0000000..cb7bab2 --- /dev/null +++ b/lib/screens/timeline_screen/memory_location_view.dart @@ -0,0 +1,149 @@ +import 'dart:convert'; + +import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:flutter_osm_plugin/flutter_osm_plugin.dart'; +import 'package:flutter_platform_widgets/flutter_platform_widgets.dart'; +import 'package:http/http.dart' as http; +import 'package:quid_faciam_hodie/constants/spacing.dart'; +import 'package:quid_faciam_hodie/constants/values.dart'; +import 'package:quid_faciam_hodie/foreign_types/memory_location.dart'; +import 'package:quid_faciam_hodie/widgets/fade_and_move_in_animation.dart'; +import 'package:quid_faciam_hodie/widgets/icon_button_child.dart'; +import 'package:quid_faciam_hodie/widgets/key_value_info.dart'; +import 'package:url_launcher/url_launcher.dart'; + +class MemoryLocationView extends StatefulWidget { + final MemoryLocation location; + + const MemoryLocationView({ + Key? key, + required this.location, + }) : super(key: key); + + @override + State createState() => _MemoryLocationViewState(); +} + +class _MemoryLocationViewState extends State { + late final MapController controller; + String address = ''; + + @override + void initState() { + super.initState(); + + lookupAddress(); + + controller = MapController( + initPosition: GeoPoint( + latitude: widget.location.latitude, + longitude: widget.location.longitude, + ), + ); + } + + void lookupAddress() async { + final url = + 'https://geocode.maps.co/reverse?lat=${widget.location.latitude}&lon=${widget.location.longitude}'; + final uri = Uri.parse(url); + + final response = await http.get(uri); + + if (response.statusCode != 200) { + setState(() { + address = ''; + }); + } else { + setState(() { + address = jsonDecode(response.body)['display_name']; + }); + } + } + + void drawCircle() => controller.drawCircle( + CircleOSM( + key: 'accuracy', + color: Colors.blue, + centerPoint: GeoPoint( + latitude: widget.location.latitude, + longitude: widget.location.longitude, + ), + radius: widget.location.accuracy, + strokeWidth: 4, + ), + ); + + List get staticPoints { + if (widget.location.accuracy <= ACCURACY_IN_METERS_FOR_PINPOINT) { + return [ + StaticPositionGeoPoint( + 'position', + const MarkerIcon( + icon: Icon(Icons.location_on, size: 150, color: Colors.blue), + ), + [ + GeoPoint( + latitude: widget.location.latitude, + longitude: widget.location.longitude, + ) + ], + ) + ]; + } else { + return []; + } + } + + @override + Widget build(BuildContext context) { + final localizations = AppLocalizations.of(context)!; + + return Column( + children: [ + SizedBox( + width: double.infinity, + height: 400, + child: OSMFlutter( + controller: controller, + initZoom: 14, + stepZoom: 1.0, + staticPoints: staticPoints, + onMapIsReady: (_) { + drawCircle(); + }, + ), + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: MEDIUM_SPACE), + child: Column( + children: [ + const SizedBox(height: MEDIUM_SPACE), + if (address.isNotEmpty) ...[ + FadeAndMoveInAnimation( + child: KeyValueInfo( + title: localizations.memorySheetMapEstimatedAddressLabel, + value: address, + icon: context.platformIcons.location, + ), + ), + const SizedBox(height: MEDIUM_SPACE), + ], + PlatformTextButton( + onPressed: () { + final url = + 'geo:0,0?q=${widget.location.latitude},${widget.location.longitude} ($address)'; + launchUrl(Uri.parse(url)); + }, + child: IconButtonChild( + icon: Icon(context.platformIcons.location), + label: Text(localizations.memorySheetMapOpenNavigation), + ), + ), + ], + ), + ), + ], + ); + } +} diff --git a/lib/screens/timeline_screen/memory_sheet.dart b/lib/screens/timeline_screen/memory_sheet.dart index 683b1b3..8f1af12 100644 --- a/lib/screens/timeline_screen/memory_sheet.dart +++ b/lib/screens/timeline_screen/memory_sheet.dart @@ -1,3 +1,4 @@ +import 'package:expandable_bottom_sheet/expandable_bottom_sheet.dart'; import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; @@ -11,9 +12,11 @@ import 'package:quid_faciam_hodie/foreign_types/memory.dart'; import 'package:quid_faciam_hodie/managers/file_manager.dart'; import 'package:quid_faciam_hodie/utils/loadable.dart'; import 'package:quid_faciam_hodie/utils/theme.dart'; -import 'package:quid_faciam_hodie/widgets/modal_sheet.dart'; +import 'package:quid_faciam_hodie/widgets/sheet_indicator.dart'; import 'package:supabase_flutter/supabase_flutter.dart'; +import 'memory_location_view.dart'; + class MemorySheet extends StatefulWidget { final Memory memory; final BuildContext sheetContext; @@ -32,7 +35,7 @@ final supabase = Supabase.instance.client; class _MemorySheetState extends State with Loadable { Future deleteFile() async { - await FileManager.deleteFile(widget.memory.location); + await FileManager.deleteFile(widget.memory.filePath); if (mounted) { Navigator.pop(context); @@ -62,7 +65,8 @@ class _MemorySheetState extends State with Loadable { if (isMaterial(context)) context.showSuccessSnackBar( - message: localizations.memorySheetSavedToGallery); + message: localizations.memorySheetSavedToGallery, + ); } catch (error) { if (isMaterial(context)) context.showErrorSnackBar(message: localizations.generalError); @@ -90,11 +94,13 @@ class _MemorySheetState extends State with Loadable { if (isNowPublic) { if (isMaterial(context)) context.showSuccessSnackBar( - message: localizations.memorySheetMemoryUpdatedToPublic); + message: localizations.memorySheetMemoryUpdatedToPublic, + ); } else { if (isMaterial(context)) context.showSuccessSnackBar( - message: localizations.memorySheetMemoryUpdatedToPrivate); + message: localizations.memorySheetMemoryUpdatedToPrivate, + ); } } catch (error) { if (isMaterial(context)) @@ -123,88 +129,137 @@ class _MemorySheetState extends State with Loadable { @override Widget build(BuildContext context) { final localizations = AppLocalizations.of(context)!; + final backgroundColor = platformThemeData( + context, + material: (data) => + data.bottomSheetTheme.modalBackgroundColor ?? data.bottomAppBarColor, + cupertino: (data) => data.barBackgroundColor, + ); - return ModalSheet( - child: Column( - children: [ - Text( - localizations.memorySheetTitle, - style: getTitleTextStyle(context), + return ExpandableBottomSheet( + background: GestureDetector( + onTap: () => Navigator.pop(context), + ), + persistentHeader: Container( + padding: const EdgeInsets.all(MEDIUM_SPACE), + decoration: BoxDecoration( + color: backgroundColor, + borderRadius: const BorderRadius.only( + topLeft: Radius.circular(LARGE_SPACE), + topRight: Radius.circular(LARGE_SPACE), ), - const SizedBox(height: MEDIUM_SPACE), - ListTile( - leading: PlatformWidget( - cupertino: (_, __) => Icon( - CupertinoIcons.down_arrow, + ), + child: Column( + children: [ + const Padding( + padding: EdgeInsets.symmetric( + vertical: MEDIUM_SPACE, + horizontal: MEDIUM_SPACE, + ), + child: SheetIndicator(), + ), + Text( + localizations.memorySheetTitle, + style: getTitleTextStyle(context), + ), + const SizedBox(height: MEDIUM_SPACE), + ListTile( + leading: PlatformWidget( + cupertino: (_, __) => Icon( + CupertinoIcons.down_arrow, + color: getBodyTextColor(context), + ), + material: (_, __) => Icon( + Icons.download, + color: getBodyTextColor(context), + ), + ), + title: Text( + localizations.memorySheetDownloadMemory, + style: getBodyTextTextStyle(context), + ), + enabled: !getIsLoadingSpecificID('download'), + onTap: getIsLoadingSpecificID('download') + ? null + : () => callWithLoading(downloadFile, 'download'), + trailing: getIsLoadingSpecificID('download') + ? buildLoadingIndicator() + : null, + ), + ListTile( + leading: Icon( + widget.memory.isPublic + ? Icons.public_off_rounded + : Icons.public, color: getBodyTextColor(context), ), - material: (_, __) => Icon( - Icons.download, + title: Text( + widget.memory.isPublic + ? localizations.memorySheetUpdateMemoryMakePrivate + : localizations.memorySheetUpdateMemoryMakePublic, + style: getBodyTextTextStyle(context), + ), + enabled: !getIsLoadingSpecificID('public'), + onTap: getIsLoadingSpecificID('public') + ? null + : () => callWithLoading(changeVisibility, 'public'), + trailing: getIsLoadingSpecificID('public') + ? buildLoadingIndicator() + : null, + ), + ListTile( + leading: Icon( + context.platformIcons.delete, color: getBodyTextColor(context), ), + title: Text( + localizations.memorySheetDeleteMemory, + style: getBodyTextTextStyle(context), + ), + enabled: !getIsLoadingSpecificID('delete'), + onTap: getIsLoadingSpecificID('delete') + ? null + : () => callWithLoading(deleteFile, 'delete'), + trailing: getIsLoadingSpecificID('delete') + ? buildLoadingIndicator() + : null, ), - title: Text( - localizations.memorySheetDownloadMemory, - style: getBodyTextTextStyle(context), + ], + ), + ), + expandableContent: Container( + width: double.infinity, + color: backgroundColor, + child: Column( + children: [ + widget.memory.location == null + ? const SizedBox.shrink() + : MemoryLocationView( + location: widget.memory.location!, + ), + Padding( + padding: const EdgeInsets.all(MEDIUM_SPACE), + child: Column( + children: [ + Text( + localizations.memorySheetCreatedAtDataKey( + DateFormat.jms().format( + widget.memory.creationDate, + ), + ), + style: getBodyTextTextStyle(context), + ), + const SizedBox(height: SMALL_SPACE), + Text( + widget.memory.id, + textAlign: TextAlign.center, + style: getBodyTextTextStyle(context), + ), + ], + ), ), - enabled: !getIsLoadingSpecificID('download'), - onTap: getIsLoadingSpecificID('download') - ? null - : () => callWithLoading(downloadFile, 'download'), - trailing: getIsLoadingSpecificID('download') - ? buildLoadingIndicator() - : null, - ), - ListTile( - leading: Icon( - widget.memory.isPublic ? Icons.public_off_rounded : Icons.public, - color: getBodyTextColor(context), - ), - title: Text( - widget.memory.isPublic - ? localizations.memorySheetUpdateMemoryMakePrivate - : localizations.memorySheetUpdateMemoryMakePublic, - style: getBodyTextTextStyle(context), - ), - enabled: !getIsLoadingSpecificID('public'), - onTap: getIsLoadingSpecificID('public') - ? null - : () => callWithLoading(changeVisibility, 'public'), - trailing: getIsLoadingSpecificID('public') - ? buildLoadingIndicator() - : null, - ), - ListTile( - leading: Icon( - context.platformIcons.delete, - color: getBodyTextColor(context), - ), - title: Text( - localizations.memorySheetDeleteMemory, - style: getBodyTextTextStyle(context), - ), - enabled: !getIsLoadingSpecificID('delete'), - onTap: getIsLoadingSpecificID('delete') - ? null - : () => callWithLoading(deleteFile, 'delete'), - trailing: getIsLoadingSpecificID('delete') - ? buildLoadingIndicator() - : null, - ), - const SizedBox(height: MEDIUM_SPACE), - Text( - localizations.memorySheetCreatedAtDataKey(DateFormat.jms().format( - widget.memory.creationDate, - )), - style: getBodyTextTextStyle(context), - ), - const SizedBox(height: SMALL_SPACE), - Text( - widget.memory.id, - textAlign: TextAlign.center, - style: getBodyTextTextStyle(context), - ) - ], + ], + ), ), ); } diff --git a/lib/utils/tag_location_to_image.dart b/lib/utils/tag_location_to_image.dart new file mode 100644 index 0000000..33108ae --- /dev/null +++ b/lib/utils/tag_location_to_image.dart @@ -0,0 +1,18 @@ +import 'dart:io'; + +import 'package:flutter_exif_plugin/flutter_exif_plugin.dart'; +import 'package:location/location.dart'; + +Future tagLocationToImage( + final File file, + final LocationData locationData, +) async { + final exif = FlutterExif.fromPath(file.absolute.path); + + await exif.setLatLong(locationData.latitude!, locationData.longitude!); + await exif.setAltitude(locationData.altitude!); + await exif.setAttribute('accuracy', locationData.accuracy!.toString()); + await exif.setAttribute('speed', locationData.speed!.toString()); + + await exif.saveAttributes(); +} diff --git a/lib/widgets/key_value_info.dart b/lib/widgets/key_value_info.dart new file mode 100644 index 0000000..e6ba821 --- /dev/null +++ b/lib/widgets/key_value_info.dart @@ -0,0 +1,75 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_platform_widgets/flutter_platform_widgets.dart'; +import 'package:quid_faciam_hodie/constants/spacing.dart'; +import 'package:quid_faciam_hodie/extensions/snackbar.dart'; +import 'package:quid_faciam_hodie/utils/theme.dart'; + +class KeyValueInfo extends StatelessWidget { + final String title; + final String value; + final bool valueCopyable; + final IconData? icon; + final String? disclaimer; + + const KeyValueInfo({ + Key? key, + required this.title, + required this.value, + this.valueCopyable = true, + this.icon, + this.disclaimer, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + return Card( + child: Padding( + padding: const EdgeInsets.symmetric( + vertical: MEDIUM_SPACE, + horizontal: SMALL_SPACE, + ), + child: ListTile( + title: Row( + children: [ + if (icon != null) + Padding( + padding: const EdgeInsets.only(right: SMALL_SPACE), + child: Icon(icon), + ), + Expanded( + flex: 2, + child: Text( + title, + style: getSubTitleTextStyle(context), + ), + ), + Expanded( + flex: 3, + child: Text( + value, + style: getBodyTextTextStyle(context), + ), + ), + ], + ), + trailing: valueCopyable + ? PlatformIconButton( + icon: const Icon(Icons.content_copy), + onPressed: () { + HapticFeedback.lightImpact(); + Clipboard.setData(ClipboardData(text: value)); + + if (isMaterial(context)) { + context.showSuccessSnackBar( + message: 'Copied to clipboard!', + ); + } + }, + ) + : null, + ), + ), + ); + } +} diff --git a/pubspec.lock b/pubspec.lock index bc5bc3b..b807c6a 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -120,6 +120,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "1.0.5" + dio: + dependency: transitive + description: + name: dio + url: "https://pub.dartlang.org" + source: hosted + version: "4.0.6" expandable_bottom_sheet: dependency: "direct main" description: @@ -160,6 +167,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "0.0.2" + flutter_exif_plugin: + dependency: "direct main" + description: + name: flutter_exif_plugin + url: "https://pub.dartlang.org" + source: hosted + version: "1.1.0" flutter_lints: dependency: "direct dev" description: @@ -172,6 +186,20 @@ packages: description: flutter source: sdk version: "0.0.0" + flutter_osm_interface: + dependency: transitive + description: + name: flutter_osm_interface + url: "https://pub.dartlang.org" + source: hosted + version: "0.1.26" + flutter_osm_plugin: + dependency: "direct main" + description: + name: flutter_osm_plugin + url: "https://pub.dartlang.org" + source: hosted + version: "0.39.0" flutter_platform_widgets: dependency: "direct main" description: @@ -273,6 +301,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "2.3.2" + google_polyline_algorithm: + dependency: transitive + description: + name: google_polyline_algorithm + url: "https://pub.dartlang.org" + source: hosted + version: "3.1.0" gotrue: dependency: transitive description: @@ -343,6 +378,27 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "2.0.0" + location: + dependency: "direct main" + description: + name: location + url: "https://pub.dartlang.org" + source: hosted + version: "4.4.0" + location_platform_interface: + dependency: transitive + description: + name: location_platform_interface + url: "https://pub.dartlang.org" + source: hosted + version: "2.3.0" + location_web: + dependency: transitive + description: + name: location_web + url: "https://pub.dartlang.org" + source: hosted + version: "3.1.1" lottie: dependency: "direct main" description: @@ -671,7 +727,7 @@ packages: source: hosted version: "2.0.4" url_launcher: - dependency: transitive + dependency: "direct main" description: name: url_launcher url: "https://pub.dartlang.org" diff --git a/pubspec.yaml b/pubspec.yaml index 5466173..c7bc0c8 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -58,6 +58,10 @@ dependencies: settings_ui: ^2.0.2 coast: ^2.0.2 http: ^0.13.5 + location: ^4.4.0 + flutter_exif_plugin: ^1.1.0 + flutter_osm_plugin: ^0.39.0 + url_launcher: ^6.1.5 dev_dependencies: flutter_test: