From 3020973ae6e481da445b336abd2a0071faee75f8 Mon Sep 17 00:00:00 2001 From: Krishna lokhande <87197325+krishna3554@users.noreply.github.com> Date: Thu, 14 May 2026 19:49:25 +0530 Subject: [PATCH] Implement geo-tagged photo overlay pipeline --- android/app/src/main/AndroidManifest.xml | 3 + android/app/src/main/res/values/strings.xml | 4 + lib/core/utils/location_utils.dart | 45 ++- lib/features/camera/screen/camera_screen.dart | 88 +++++- lib/features/result/screen/result_screen.dart | 174 ++++++++--- lib/models/geo_photo_model.dart | 117 +++++++ lib/models/location_info.dart | 58 ++++ lib/services/gallery_service.dart | 46 +++ lib/services/google_map_service.dart | 72 +++++ lib/services/image_overlay_service.dart | 287 ++++++++++++++++++ lib/services/location_service.dart | 94 ++++++ lib/utils/image_utils.dart | 93 ++++++ lib/widgets/geo_info_overlay.dart | 57 ++++ lib/widgets/map_preview_widget.dart | 39 +++ pubspec.yaml | 4 + 15 files changed, 1109 insertions(+), 72 deletions(-) create mode 100644 android/app/src/main/res/values/strings.xml create mode 100644 lib/models/geo_photo_model.dart create mode 100644 lib/services/gallery_service.dart create mode 100644 lib/services/google_map_service.dart create mode 100644 lib/services/image_overlay_service.dart create mode 100644 lib/services/location_service.dart create mode 100644 lib/utils/image_utils.dart create mode 100644 lib/widgets/geo_info_overlay.dart create mode 100644 lib/widgets/map_preview_widget.dart diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index a41a382..8b02f2a 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -48,6 +48,9 @@ + diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..19b08b3 --- /dev/null +++ b/android/app/src/main/res/values/strings.xml @@ -0,0 +1,4 @@ + + + YOUR_API_KEY + diff --git a/lib/core/utils/location_utils.dart b/lib/core/utils/location_utils.dart index 5b53934..38f02a9 100644 --- a/lib/core/utils/location_utils.dart +++ b/lib/core/utils/location_utils.dart @@ -1,37 +1,30 @@ import 'dart:async'; -import 'package:geocoding/geocoding.dart'; -import 'package:geolocator/geolocator.dart'; - import '../../models/location_info.dart'; +import '../../services/location_service.dart'; class LocationUtils { + static const LocationService _locationService = LocationService(); + static Future getCurrentLocationInfo() async { try { - final enabled = await Geolocator.isLocationServiceEnabled(); - if (!enabled) return null; - - final pos = await Geolocator.getCurrentPosition( - desiredAccuracy: LocationAccuracy.high, - timeLimit: const Duration(seconds: 10), + final geoPhoto = await _locationService.getCurrentGeoPhoto(); + return LocationInfo( + address: geoPhoto.address, + date: geoPhoto.capturedAt.toLocal().toString().split(' ').first, + time: geoPhoto.formattedDateTime.split(' ').last, + latitude: geoPhoto.latitude, + longitude: geoPhoto.longitude, + placeName: geoPhoto.placeName, + locality: geoPhoto.locality, + administrativeArea: geoPhoto.administrativeArea, + country: geoPhoto.country, + postalCode: geoPhoto.postalCode, + altitude: geoPhoto.altitude, + speedMetersPerSecond: geoPhoto.speedMetersPerSecond, + heading: geoPhoto.heading, + accuracy: geoPhoto.accuracy, ); - - final placemarks = - await placemarkFromCoordinates(pos.latitude, pos.longitude); - final p = placemarks.isNotEmpty ? placemarks.first : null; - - final address = [ - if ((p?.subThoroughfare ?? '').isNotEmpty) p?.subThoroughfare, - if ((p?.thoroughfare ?? '').isNotEmpty) p?.thoroughfare, - if ((p?.subLocality ?? '').isNotEmpty) p?.subLocality, - if ((p?.locality ?? '').isNotEmpty) p?.locality, - if ((p?.administrativeArea ?? '').isNotEmpty) p?.administrativeArea, - if ((p?.postalCode ?? '').isNotEmpty) p?.postalCode, - if ((p?.country ?? '').isNotEmpty) p?.country, - ].whereType().join(', '); - - return LocationInfo.fromPosition( - pos, address.isEmpty ? 'Location unavailable' : address); } catch (_) { return null; } diff --git a/lib/features/camera/screen/camera_screen.dart b/lib/features/camera/screen/camera_screen.dart index ace039f..c910269 100644 --- a/lib/features/camera/screen/camera_screen.dart +++ b/lib/features/camera/screen/camera_screen.dart @@ -11,7 +11,9 @@ import '../../../core/utils/location_utils.dart'; import '../../../core/utils/permission_handler.dart'; import '../../../core/widgets/location_stamp_card.dart'; import '../../../models/captured_media.dart'; +import '../../../models/geo_photo_model.dart'; import '../../../models/location_info.dart'; +import '../../../services/location_service.dart'; class CameraScreen extends StatefulWidget { const CameraScreen({super.key, this.permissionsGranted = true}); @@ -41,6 +43,8 @@ class _CameraScreenState extends State Duration _recordDuration = Duration.zero; late final AnimationController _captureAnim; bool _flashOverlay = false; + bool _isGeoProcessing = false; + final LocationService _locationService = const LocationService(); @override void initState() { @@ -171,19 +175,18 @@ class _CameraScreenState extends State }); final file = await _controller!.takePicture(); if (!mounted) return; + setState(() => _isGeoProcessing = true); + + final locationInfo = await _resolveCaptureLocation(); + if (!mounted) return; + setState(() => _isGeoProcessing = false); + Navigator.pushNamed( context, AppConstants.routeResult, arguments: ResultScreenArgs( filePath: file.path, - locationInfo: _locationInfo ?? - const LocationInfo( - address: 'Location unavailable', - date: '', - time: '', - latitude: 0, - longitude: 0, - ), + locationInfo: locationInfo, type: MediaType.photo, ), ); @@ -201,6 +204,53 @@ class _CameraScreenState extends State } } + Future _resolveCaptureLocation() async { + try { + final geoPhoto = await _locationService.getCurrentGeoPhoto(); + return _locationInfoFromGeoPhoto(geoPhoto); + } on LocationServiceException catch (error) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(error.message)), + ); + } + } catch (error) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Location unavailable: $error')), + ); + } + } + + return _locationInfo ?? + const LocationInfo( + address: 'Location unavailable', + date: '', + time: '', + latitude: 0, + longitude: 0, + ); + } + + LocationInfo _locationInfoFromGeoPhoto(GeoPhotoModel geoPhoto) { + return LocationInfo( + address: geoPhoto.address, + date: geoPhoto.capturedAt.toLocal().toString().split(' ').first, + time: geoPhoto.formattedDateTime.split(' ').last, + latitude: geoPhoto.latitude, + longitude: geoPhoto.longitude, + placeName: geoPhoto.placeName, + locality: geoPhoto.locality, + administrativeArea: geoPhoto.administrativeArea, + country: geoPhoto.country, + postalCode: geoPhoto.postalCode, + altitude: geoPhoto.altitude, + speedMetersPerSecond: geoPhoto.speedMetersPerSecond, + heading: geoPhoto.heading, + accuracy: geoPhoto.accuracy, + ); + } + Future _startRecording() async { await _controller!.startVideoRecording(); setState(() { @@ -291,6 +341,28 @@ class _CameraScreenState extends State child: Container(color: Colors.white), ), ), + if (_isGeoProcessing) + Positioned.fill( + child: ColoredBox( + color: Colors.black.withValues(alpha: 0.45), + child: const Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + CircularProgressIndicator(color: Colors.white), + SizedBox(height: 16), + Text( + 'Getting GPS and address...', + style: TextStyle( + color: Colors.white, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ), + ), + ), _buildTopToolbar(), if (_isRecording) Positioned( diff --git a/lib/features/result/screen/result_screen.dart b/lib/features/result/screen/result_screen.dart index 8bea6f4..5e62a22 100644 --- a/lib/features/result/screen/result_screen.dart +++ b/lib/features/result/screen/result_screen.dart @@ -1,14 +1,18 @@ import 'dart:async'; import 'dart:io'; +import 'dart:typed_data'; import 'package:flutter/material.dart'; import 'package:share_plus/share_plus.dart'; import 'package:video_player/video_player.dart'; import '../../../core/theme/app_theme.dart'; -import '../../../core/utils/media_saver.dart'; import '../../../models/captured_media.dart'; +import '../../../models/geo_photo_model.dart'; import '../../../models/location_info.dart'; +import '../../../services/gallery_service.dart'; +import '../../../services/google_map_service.dart'; +import '../../../services/image_overlay_service.dart'; class ResultScreen extends StatefulWidget { const ResultScreen({ @@ -27,47 +31,117 @@ class ResultScreen extends StatefulWidget { } class _ResultScreenState extends State { + final GalleryService _galleryService = const GalleryService(); + final GoogleMapService _googleMapService = const GoogleMapService(); + final ImageOverlayService _imageOverlayService = const ImageOverlayService(); + VideoPlayerController? _video; bool _showBanner = false; + bool _processing = true; + String? _displayFilePath; + String? _savedGalleryPath; @override void initState() { super.initState(); + _displayFilePath = widget.filePath; _init(); } Future _init() async { - if (widget.type == MediaType.video) { - _video = VideoPlayerController.file(File(widget.filePath)); - await _video!.initialize(); - _video! - ..setLooping(true) - ..setVolume(0) - ..play(); + try { + if (widget.type == MediaType.video) { + await _initVideo(); + _savedGalleryPath = await _galleryService.saveFile(widget.filePath); + } else { + _savedGalleryPath = await _generateAndSaveGeoTaggedPhoto(); + } + + if (!mounted) return; + if (_savedGalleryPath != null) { + _showSavedBanner(); + } else { + _showSaveError(); + } + } catch (error) { + if (!mounted) return; + _showSaveError('Failed to generate geo-tagged photo: $error'); + } finally { + if (mounted) setState(() => _processing = false); } + } - final ok = widget.type == MediaType.photo - ? await MediaSaver.savePhoto( - filePath: widget.filePath, locationInfo: widget.locationInfo) - : await MediaSaver.saveVideo( - filePath: widget.filePath, locationInfo: widget.locationInfo); - - if (!mounted) return; - if (ok) { - setState(() => _showBanner = true); - Timer(const Duration(milliseconds: 2500), () { - if (mounted) setState(() => _showBanner = false); - }); - } else { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - backgroundColor: Colors.red, - content: Text('Failed to save — tap Share to save manually'), - ), + Future _initVideo() async { + _video = VideoPlayerController.file(File(widget.filePath)); + await _video!.initialize(); + _video! + ..setLooping(true) + ..setVolume(0) + ..play(); + } + + /// Builds the permanent overlay image, saves the final pixels to gallery, + /// and returns the platform gallery path/content URI when available. + Future _generateAndSaveGeoTaggedPhoto() async { + final geoPhoto = _geoPhotoFromLocationInfo(widget.locationInfo); + Uint8List? mapBytes; + try { + mapBytes = await _googleMapService.fetchThumbnail( + latitude: geoPhoto.latitude, + longitude: geoPhoto.longitude, ); + } catch (_) { + // Keep saving functional if the static map request fails offline or by API quota. + mapBytes = null; } - setState(() {}); + final finalResult = await _imageOverlayService.composeGeoTaggedImage( + capturedImagePath: widget.filePath, + geoPhoto: geoPhoto, + mapThumbnailBytes: mapBytes, + ); + + if (mounted) setState(() => _displayFilePath = finalResult.filePath); + return _galleryService.saveImageBytes( + bytes: finalResult.bytes, + name: 'GPS_Map_Camera_${DateTime.now().millisecondsSinceEpoch}', + ); + } + + GeoPhotoModel _geoPhotoFromLocationInfo(LocationInfo info) { + return GeoPhotoModel( + latitude: info.latitude, + longitude: info.longitude, + address: info.address, + capturedAt: DateTime.now(), + placeName: info.placeName, + locality: info.locality, + administrativeArea: info.administrativeArea, + country: info.country, + postalCode: info.postalCode, + altitude: info.altitude, + speedMetersPerSecond: info.speedMetersPerSecond, + heading: info.heading, + accuracy: info.accuracy, + weatherLabel: 'Weather --', + compassLabel: 'Compass --', + ); + } + + void _showSavedBanner() { + setState(() => _showBanner = true); + Timer(const Duration(milliseconds: 2500), () { + if (mounted) setState(() => _showBanner = false); + }); + } + + void _showSaveError([String? message]) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + backgroundColor: Colors.red, + content: Text(message ?? 'Failed to save — tap Share to save manually'), + ), + ); } @override @@ -82,6 +156,8 @@ class _ResultScreenState extends State { @override Widget build(BuildContext context) { + final displayFile = File(_displayFilePath ?? widget.filePath); + return PopScope( canPop: false, onPopInvokedWithResult: (didPop, result) { @@ -93,7 +169,7 @@ class _ResultScreenState extends State { children: [ Positioned.fill( child: widget.type == MediaType.photo - ? Image.file(File(widget.filePath), fit: BoxFit.cover) + ? Image.file(displayFile, fit: BoxFit.cover) : (_video != null && _video!.value.isInitialized) ? FittedBox( fit: BoxFit.cover, @@ -105,6 +181,25 @@ class _ResultScreenState extends State { ) : const Center(child: CircularProgressIndicator()), ), + if (_processing) + Positioned.fill( + child: ColoredBox( + color: Colors.black.withValues(alpha: 0.45), + child: const Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + CircularProgressIndicator(color: Colors.white), + SizedBox(height: 16), + Text( + 'Generating geo-tagged photo...', + style: TextStyle(color: Colors.white, fontWeight: FontWeight.w600), + ), + ], + ), + ), + ), + ), Positioned( top: 44, left: 8, @@ -125,8 +220,7 @@ class _ResultScreenState extends State { opacity: _showBanner ? 1 : 0, duration: const Duration(milliseconds: 400), child: Container( - padding: const EdgeInsets.symmetric( - horizontal: 12, vertical: 6), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), decoration: BoxDecoration( color: AppColors.success, borderRadius: BorderRadius.circular(20), @@ -136,8 +230,10 @@ class _ResultScreenState extends State { children: [ Icon(Icons.check, color: Colors.white, size: 14), SizedBox(width: 6), - Text('Saved to camera roll', - style: TextStyle(color: Colors.white)), + Text( + 'Geo-tagged photo saved', + style: TextStyle(color: Colors.white), + ), ], ), ), @@ -157,12 +253,14 @@ class _ResultScreenState extends State { width: MediaQuery.of(context).size.width * 0.85, height: 50, child: FilledButton( - onPressed: () => Share.shareXFiles( - [XFile(widget.filePath)], - text: - 'šŸ“ ${widget.locationInfo.address}\nšŸ“… ${widget.locationInfo.date} ${widget.locationInfo.time}\nCaptured with GPS Camera', - ), - child: const Text('Share now'), + onPressed: _processing + ? null + : () => Share.shareXFiles( + [XFile(_displayFilePath ?? widget.filePath)], + text: + 'šŸ“ ${widget.locationInfo.address}\nšŸ“… ${widget.locationInfo.date} ${widget.locationInfo.time}\nCaptured with GPS Camera', + ), + child: Text(_processing ? 'Saving...' : 'Share now'), ), ), ), diff --git a/lib/models/geo_photo_model.dart b/lib/models/geo_photo_model.dart new file mode 100644 index 0000000..ba97a2f --- /dev/null +++ b/lib/models/geo_photo_model.dart @@ -0,0 +1,117 @@ +import 'package:geolocator/geolocator.dart'; +import 'package:intl/intl.dart'; + +/// Immutable metadata used to permanently burn a geo-tag overlay into a photo. +class GeoPhotoModel { + const GeoPhotoModel({ + required this.latitude, + required this.longitude, + required this.address, + required this.capturedAt, + this.placeName, + this.locality, + this.administrativeArea, + this.country, + this.postalCode, + this.altitude, + this.speedMetersPerSecond, + this.heading, + this.accuracy, + this.weatherLabel = 'Weather --', + this.compassLabel = 'Compass --', + this.note = 'Captured by GPS Map Camera', + }); + + final double latitude; + final double longitude; + final String address; + final DateTime capturedAt; + final String? placeName; + final String? locality; + final String? administrativeArea; + final String? country; + final String? postalCode; + final double? altitude; + final double? speedMetersPerSecond; + final double? heading; + final double? accuracy; + final String weatherLabel; + final String compassLabel; + final String note; + + String get formattedDateTime => + DateFormat('EEEE, dd/MM/yyyy hh:mm a').format(capturedAt.toLocal()); + + String get shortTitle { + final candidates = [ + placeName, + locality, + administrativeArea, + country, + ].where((value) => (value ?? '').trim().isNotEmpty).cast().toList(); + + if (candidates.isEmpty) return 'GPS Map Camera'; + return candidates.take(3).join(', '); + } + + String get formattedLatitude => latitude.toStringAsFixed(6); + String get formattedLongitude => longitude.toStringAsFixed(6); + + String get altitudeLabel => altitude == null + ? 'Alt --' + : '${altitude!.round().toString()} m'; + + String get speedLabel => speedMetersPerSecond == null + ? 'Speed --' + : '${(speedMetersPerSecond! * 3.6).round()} km/h'; + + String get headingLabel => heading == null + ? compassLabel + : '${heading!.round()}°'; + + factory GeoPhotoModel.fromPosition({ + required Position position, + required String address, + String? placeName, + String? locality, + String? administrativeArea, + String? country, + String? postalCode, + DateTime? capturedAt, + }) { + return GeoPhotoModel( + latitude: position.latitude, + longitude: position.longitude, + address: address, + capturedAt: capturedAt ?? DateTime.now(), + placeName: placeName, + locality: locality, + administrativeArea: administrativeArea, + country: country, + postalCode: postalCode, + altitude: position.altitude.isFinite ? position.altitude : null, + speedMetersPerSecond: position.speed.isFinite ? position.speed : null, + heading: position.heading.isFinite ? position.heading : null, + accuracy: position.accuracy.isFinite ? position.accuracy : null, + ); + } + + Map toJson() => { + 'latitude': latitude, + 'longitude': longitude, + 'address': address, + 'capturedAt': capturedAt.toIso8601String(), + 'placeName': placeName, + 'locality': locality, + 'administrativeArea': administrativeArea, + 'country': country, + 'postalCode': postalCode, + 'altitude': altitude, + 'speedMetersPerSecond': speedMetersPerSecond, + 'heading': heading, + 'accuracy': accuracy, + 'weatherLabel': weatherLabel, + 'compassLabel': compassLabel, + 'note': note, + }; +} diff --git a/lib/models/location_info.dart b/lib/models/location_info.dart index 262afcf..00c6b03 100644 --- a/lib/models/location_info.dart +++ b/lib/models/location_info.dart @@ -8,6 +8,15 @@ class LocationInfo { required this.time, required this.latitude, required this.longitude, + this.placeName, + this.locality, + this.administrativeArea, + this.country, + this.postalCode, + this.altitude, + this.speedMetersPerSecond, + this.heading, + this.accuracy, }); final String address; @@ -15,6 +24,15 @@ class LocationInfo { final String time; final double latitude; final double longitude; + final String? placeName; + final String? locality; + final String? administrativeArea; + final String? country; + final String? postalCode; + final double? altitude; + final double? speedMetersPerSecond; + final double? heading; + final double? accuracy; factory LocationInfo.fromPosition(Position pos, String resolvedAddress) { final now = DateTime.now(); @@ -24,6 +42,10 @@ class LocationInfo { time: DateFormat('hh:mm a').format(now), latitude: pos.latitude, longitude: pos.longitude, + altitude: pos.altitude.isFinite ? pos.altitude : null, + speedMetersPerSecond: pos.speed.isFinite ? pos.speed : null, + heading: pos.heading.isFinite ? pos.heading : null, + accuracy: pos.accuracy.isFinite ? pos.accuracy : null, ); } @@ -33,6 +55,15 @@ class LocationInfo { String? time, double? latitude, double? longitude, + String? placeName, + String? locality, + String? administrativeArea, + String? country, + String? postalCode, + double? altitude, + double? speedMetersPerSecond, + double? heading, + double? accuracy, }) { return LocationInfo( address: address ?? this.address, @@ -40,6 +71,15 @@ class LocationInfo { time: time ?? this.time, latitude: latitude ?? this.latitude, longitude: longitude ?? this.longitude, + placeName: placeName ?? this.placeName, + locality: locality ?? this.locality, + administrativeArea: administrativeArea ?? this.administrativeArea, + country: country ?? this.country, + postalCode: postalCode ?? this.postalCode, + altitude: altitude ?? this.altitude, + speedMetersPerSecond: speedMetersPerSecond ?? this.speedMetersPerSecond, + heading: heading ?? this.heading, + accuracy: accuracy ?? this.accuracy, ); } @@ -49,6 +89,15 @@ class LocationInfo { 'time': time, 'latitude': latitude, 'longitude': longitude, + 'placeName': placeName, + 'locality': locality, + 'administrativeArea': administrativeArea, + 'country': country, + 'postalCode': postalCode, + 'altitude': altitude, + 'speedMetersPerSecond': speedMetersPerSecond, + 'heading': heading, + 'accuracy': accuracy, }; factory LocationInfo.fromJson(Map json) { @@ -58,6 +107,15 @@ class LocationInfo { time: json['time'] as String? ?? '', latitude: (json['latitude'] as num?)?.toDouble() ?? 0, longitude: (json['longitude'] as num?)?.toDouble() ?? 0, + placeName: json['placeName'] as String?, + locality: json['locality'] as String?, + administrativeArea: json['administrativeArea'] as String?, + country: json['country'] as String?, + postalCode: json['postalCode'] as String?, + altitude: (json['altitude'] as num?)?.toDouble(), + speedMetersPerSecond: (json['speedMetersPerSecond'] as num?)?.toDouble(), + heading: (json['heading'] as num?)?.toDouble(), + accuracy: (json['accuracy'] as num?)?.toDouble(), ); } } diff --git a/lib/services/gallery_service.dart b/lib/services/gallery_service.dart new file mode 100644 index 0000000..25ea91f --- /dev/null +++ b/lib/services/gallery_service.dart @@ -0,0 +1,46 @@ +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:image_gallery_saver_plus/image_gallery_saver_plus.dart'; +import 'package:permission_handler/permission_handler.dart'; + +/// Persists generated media into the platform gallery and returns the saved URI/path. +class GalleryService { + const GalleryService(); + + Future saveImageBytes({ + required Uint8List bytes, + required String name, + int quality = 95, + }) async { + await _ensureGalleryPermission(); + final result = await ImageGallerySaverPlus.saveImage( + bytes, + quality: quality, + name: name, + ); + if (result is Map && result['isSuccess'] == true) { + return (result['filePath'] ?? result['filepath'] ?? result['path'])?.toString(); + } + return null; + } + + Future saveFile(String filePath) async { + await _ensureGalleryPermission(); + final result = await ImageGallerySaverPlus.saveFile(filePath); + if (result is Map && result['isSuccess'] == true) { + return (result['filePath'] ?? result['filepath'] ?? result['path'] ?? filePath)?.toString(); + } + return null; + } + + Future _ensureGalleryPermission() async { + if (Platform.isAndroid) { + final photos = await Permission.photos.request(); + if (photos.isGranted || photos.isLimited) return; + + final storage = await Permission.storage.request(); + if (storage.isGranted || storage.isLimited) return; + } + } +} diff --git a/lib/services/google_map_service.dart b/lib/services/google_map_service.dart new file mode 100644 index 0000000..0642a07 --- /dev/null +++ b/lib/services/google_map_service.dart @@ -0,0 +1,72 @@ +import 'dart:typed_data'; + +import 'package:flutter/material.dart'; +import 'package:google_static_maps_controller/google_static_maps_controller.dart' as static_maps; +import 'package:http/http.dart' as http; + +/// Generates and downloads Google Static Maps thumbnails for the burned overlay. +class GoogleMapService { + const GoogleMapService({http.Client? client, String? apiKey}) + : _client = client, + _apiKey = apiKey ?? const String.fromEnvironment('GOOGLE_MAPS_API_KEY'); + + final http.Client? _client; + final String _apiKey; + + Uri staticMapUri({ + required double latitude, + required double longitude, + int width = 640, + int height = 420, + int zoom = 16, + }) { + final controller = static_maps.StaticMapController( + googleApiKey: _apiKey, + width: width, + height: height, + zoom: zoom, + center: static_maps.Location(latitude, longitude), + markers: [ + static_maps.Marker( + color: Colors.red, + locations: [static_maps.Location(latitude, longitude)], + ), + ], + ); + + final url = controller.url; + return url.replace( + queryParameters: { + ...url.queryParameters, + 'scale': '2', + 'maptype': 'hybrid', + }, + ); + } + + Future fetchThumbnail({ + required double latitude, + required double longitude, + int width = 640, + int height = 420, + }) async { + if (_apiKey.trim().isEmpty || _apiKey == 'YOUR_API_KEY') { + return null; + } + + final client = _client ?? http.Client(); + final response = await client.get( + staticMapUri( + latitude: latitude, + longitude: longitude, + width: width, + height: height, + ), + ); + + if (response.statusCode < 200 || response.statusCode >= 300) { + return null; + } + return response.bodyBytes; + } +} diff --git a/lib/services/image_overlay_service.dart b/lib/services/image_overlay_service.dart new file mode 100644 index 0000000..5cde356 --- /dev/null +++ b/lib/services/image_overlay_service.dart @@ -0,0 +1,287 @@ +import 'dart:io'; +import 'dart:math' as math; +import 'dart:typed_data'; + +import 'package:image/image.dart' as img; +import 'package:path/path.dart' as p; +import 'package:path_provider/path_provider.dart'; + +import '../models/geo_photo_model.dart'; +import '../utils/image_utils.dart'; + +/// Composites the static map, metadata card, and watermark directly into pixels. +class ImageOverlayService { + const ImageOverlayService(); + + Future composeGeoTaggedImage({ + required String capturedImagePath, + required GeoPhotoModel geoPhoto, + Uint8List? mapThumbnailBytes, + String appName = 'GPS Map Camera', + }) async { + final sourceFile = File(capturedImagePath); + final sourceBytes = await sourceFile.readAsBytes(); + final image = img.decodeImage(sourceBytes); + if (image == null) { + throw StateError('Unable to decode captured photo.'); + } + + final normalized = img.bakeOrientation(image); + img.Image? mapImage; + if (mapThumbnailBytes != null) { + mapImage = img.decodeImage(mapThumbnailBytes); + } + + _drawWatermark(normalized, appName); + _drawOverlayCard(normalized, geoPhoto, mapImage); + + final outputBytes = Uint8List.fromList(img.encodeJpg(normalized, quality: 95)); + final directory = await getTemporaryDirectory(); + final name = 'geo_photo_${DateTime.now().millisecondsSinceEpoch}.jpg'; + final outputFile = File(p.join(directory.path, name)); + await outputFile.writeAsBytes(outputBytes, flush: true); + + return GeoTaggedImageResult( + filePath: outputFile.path, + bytes: outputBytes, + originalFilePath: capturedImagePath, + ); + } + + void _drawOverlayCard( + img.Image image, + GeoPhotoModel geoPhoto, + img.Image? mapImage, + ) { + final width = image.width; + final height = image.height; + final margin = (width * 0.045).round().clamp(28, 96); + final cardWidth = width - (margin * 2); + final cardHeight = (height * 0.23).round().clamp(310, 620); + final cardTop = height - cardHeight - (height * 0.035).round(); + final radius = (cardHeight * 0.08).round().clamp(20, 44); + + ImageUtils.fillRoundedRect( + image, + x: margin, + y: cardTop, + width: cardWidth, + height: cardHeight, + radius: radius, + color: img.ColorRgba8(0, 0, 0, 178), + ); + + final inner = (cardHeight * 0.08).round().clamp(18, 44); + final mapWidth = (cardWidth * 0.32).round().clamp(220, 520); + final mapHeight = cardHeight - (inner * 2); + final mapLeft = margin + inner; + final mapTop = cardTop + inner; + + _drawMapPreview( + image, + mapImage, + x: mapLeft, + y: mapTop, + width: mapWidth, + height: mapHeight, + ); + + final textLeft = mapLeft + mapWidth + inner; + final textTop = mapTop + (mapHeight * 0.03).round(); + final textWidth = margin + cardWidth - inner - textLeft; + final titleFont = width >= 2200 ? img.arial48 : img.arial24; + final bodyFont = width >= 2200 ? img.arial24 : img.arial14; + final titleColor = img.ColorRgb8(255, 255, 255); + final bodyColor = img.ColorRgb8(235, 238, 242); + final mutedColor = img.ColorRgb8(210, 215, 222); + final accentColor = img.ColorRgb8(255, 204, 0); + final lineHeight = (cardHeight * 0.13).round().clamp(28, 62); + + var cursorY = textTop; + ImageUtils.drawWrappedText( + image, + geoPhoto.shortTitle, + font: titleFont, + x: textLeft, + y: cursorY, + maxWidth: textWidth, + color: titleColor, + maxLines: 1, + ); + cursorY += lineHeight; + + ImageUtils.drawWrappedText( + image, + geoPhoto.address, + font: bodyFont, + x: textLeft, + y: cursorY, + maxWidth: textWidth, + color: bodyColor, + maxLines: 2, + ); + cursorY += lineHeight * 2; + + img.drawString( + image, + 'Lat ${geoPhoto.formattedLatitude}° Long ${geoPhoto.formattedLongitude}°', + font: bodyFont, + x: textLeft, + y: cursorY, + color: bodyColor, + ); + cursorY += lineHeight; + + img.drawString( + image, + geoPhoto.formattedDateTime, + font: bodyFont, + x: textLeft, + y: cursorY, + color: bodyColor, + ); + cursorY += lineHeight; + + ImageUtils.drawWrappedText( + image, + 'Note: ${geoPhoto.note}', + font: bodyFont, + x: textLeft, + y: cursorY, + maxWidth: textWidth, + color: mutedColor, + maxLines: 1, + ); + + final footerY = cardTop + cardHeight - inner - bodyFont.lineHeight; + _drawFooterMetric(image, 'W', geoPhoto.weatherLabel, textLeft, footerY, accentColor, bodyColor, bodyFont); + _drawFooterMetric(image, 'C', geoPhoto.headingLabel, textLeft + (textWidth * 0.25).round(), footerY, img.ColorRgb8(130, 220, 255), bodyColor, bodyFont); + _drawFooterMetric(image, 'S', geoPhoto.speedLabel, textLeft + (textWidth * 0.50).round(), footerY, img.ColorRgb8(0, 210, 255), bodyColor, bodyFont); + _drawFooterMetric(image, 'A', geoPhoto.altitudeLabel, textLeft + (textWidth * 0.75).round(), footerY, img.ColorRgb8(255, 160, 80), bodyColor, bodyFont); + } + + void _drawMapPreview( + img.Image image, + img.Image? mapImage, { + required int x, + required int y, + required int width, + required int height, + }) { + final radius = (height * 0.08).round().clamp(14, 32); + ImageUtils.fillRoundedRect( + image, + x: x, + y: y, + width: width, + height: height, + radius: radius, + color: img.ColorRgb8(38, 48, 56), + ); + + if (mapImage == null) { + ImageUtils.drawWrappedText( + image, + 'Google map preview unavailable', + font: img.arial24, + x: x + 22, + y: y + (height ~/ 2) - 20, + maxWidth: width - 44, + color: img.ColorRgb8(255, 255, 255), + maxLines: 2, + ); + } else { + final resized = img.copyResizeCropSquare(mapImage, size: math.min(width, height)); + final fitted = img.copyResize(resized, width: width, height: height); + img.compositeImage(image, fitted, dstX: x, dstY: y); + } + + img.drawRect( + image, + x1: x, + y1: y, + x2: x + width, + y2: y + height, + color: img.ColorRgba8(255, 255, 255, 80), + thickness: 3, + ); + img.drawString( + image, + 'Google', + font: width > 360 ? img.arial48 : img.arial24, + x: x + 24, + y: y + height - 64, + color: img.ColorRgb8(255, 255, 255), + ); + } + + void _drawWatermark(img.Image image, String appName) { + final width = image.width; + final height = image.height; + final font = width >= 2200 ? img.arial24 : img.arial14; + final label = appName; + final boxWidth = (width * 0.31).round().clamp(300, 700); + final boxHeight = (height * 0.045).round().clamp(54, 100); + final x = width - boxWidth - (width * 0.05).round(); + final y = height - (height * 0.32).round(); + + ImageUtils.fillRoundedRect( + image, + x: x, + y: y, + width: boxWidth, + height: boxHeight, + radius: boxHeight ~/ 2, + color: img.ColorRgba8(0, 0, 0, 145), + ); + img.fillCircle( + image, + x: x + (boxHeight ~/ 2), + y: y + (boxHeight ~/ 2), + radius: (boxHeight * 0.28).round(), + color: img.ColorRgb8(255, 204, 0), + ); + img.drawString( + image, + 'G', + font: font, + x: x + (boxHeight * 0.28).round(), + y: y + (boxHeight * 0.18).round(), + color: img.ColorRgb8(30, 120, 255), + ); + img.drawString( + image, + label, + font: font, + x: x + boxHeight, + y: y + ((boxHeight - font.lineHeight) ~/ 2), + color: img.ColorRgb8(255, 255, 255), + ); + } + + void _drawFooterMetric( + img.Image image, + String icon, + String label, + int x, + int y, + img.Color iconColor, + img.Color textColor, + img.BitmapFont font, + ) { + img.drawString(image, icon, font: font, x: x, y: y, color: iconColor); + img.drawString(image, label, font: font, x: x + (font.lineHeight * 1.3).round(), y: y, color: textColor); + } +} + +class GeoTaggedImageResult { + const GeoTaggedImageResult({ + required this.filePath, + required this.bytes, + required this.originalFilePath, + }); + + final String filePath; + final Uint8List bytes; + final String originalFilePath; +} diff --git a/lib/services/location_service.dart b/lib/services/location_service.dart new file mode 100644 index 0000000..290fdac --- /dev/null +++ b/lib/services/location_service.dart @@ -0,0 +1,94 @@ +import 'dart:async'; + +import 'package:geocoding/geocoding.dart'; +import 'package:geolocator/geolocator.dart'; + +import '../models/geo_photo_model.dart'; + +class LocationServiceException implements Exception { + const LocationServiceException(this.message); + + final String message; + + @override + String toString() => message; +} + +/// Handles high-accuracy GPS lookup and reverse geocoding for geo-tagged photos. +class LocationService { + const LocationService(); + + Future getCurrentGeoPhoto({ + Duration timeout = const Duration(seconds: 12), + }) async { + final serviceEnabled = await Geolocator.isLocationServiceEnabled(); + if (!serviceEnabled) { + throw const LocationServiceException('GPS is disabled. Turn on Location Services and try again.'); + } + + LocationPermission permission = await Geolocator.checkPermission(); + if (permission == LocationPermission.denied) { + permission = await Geolocator.requestPermission(); + } + + if (permission == LocationPermission.denied) { + throw const LocationServiceException('Location permission was denied.'); + } + if (permission == LocationPermission.deniedForever) { + throw const LocationServiceException('Location permission is permanently denied. Enable it in app settings.'); + } + + try { + final position = await Geolocator.getCurrentPosition( + desiredAccuracy: LocationAccuracy.bestForNavigation, + timeLimit: timeout, + ); + + final placemarks = await placemarkFromCoordinates( + position.latitude, + position.longitude, + ).timeout(timeout); + final place = placemarks.isNotEmpty ? placemarks.first : null; + final address = _formatAddress(place); + + return GeoPhotoModel.fromPosition( + position: position, + address: address.isEmpty ? 'Address unavailable' : address, + placeName: _firstNonEmpty([ + place?.name, + place?.subLocality, + place?.locality, + ]), + locality: place?.locality, + administrativeArea: place?.administrativeArea, + country: place?.country, + postalCode: place?.postalCode, + ); + } on TimeoutException { + throw const LocationServiceException('Location request timed out. Move outdoors and try again.'); + } catch (error) { + throw LocationServiceException('Unable to get current location: $error'); + } + } + + String _formatAddress(Placemark? place) { + if (place == null) return ''; + return [ + place.subThoroughfare, + place.thoroughfare, + place.subLocality, + place.locality, + place.administrativeArea, + place.postalCode, + place.country, + ].where((value) => (value ?? '').trim().isNotEmpty).cast().join(', '); + } + + String? _firstNonEmpty(List values) { + for (final value in values) { + final trimmed = value?.trim() ?? ''; + if (trimmed.isNotEmpty) return trimmed; + } + return null; + } +} diff --git a/lib/utils/image_utils.dart b/lib/utils/image_utils.dart new file mode 100644 index 0000000..46a2378 --- /dev/null +++ b/lib/utils/image_utils.dart @@ -0,0 +1,93 @@ +import 'dart:math' as math; + +import 'package:image/image.dart' as img; + +class ImageUtils { + const ImageUtils._(); + + static void fillRoundedRect( + img.Image image, { + required int x, + required int y, + required int width, + required int height, + required int radius, + required img.Color color, + }) { + final safeRadius = math.min(radius, math.min(width, height) ~/ 2); + img.fillRect( + image, + x1: x + safeRadius, + y1: y, + x2: x + width - safeRadius, + y2: y + height, + color: color, + ); + img.fillRect( + image, + x1: x, + y1: y + safeRadius, + x2: x + width, + y2: y + height - safeRadius, + color: color, + ); + img.fillCircle(image, x: x + safeRadius, y: y + safeRadius, radius: safeRadius, color: color); + img.fillCircle(image, x: x + width - safeRadius, y: y + safeRadius, radius: safeRadius, color: color); + img.fillCircle(image, x: x + safeRadius, y: y + height - safeRadius, radius: safeRadius, color: color); + img.fillCircle(image, x: x + width - safeRadius, y: y + height - safeRadius, radius: safeRadius, color: color); + } + + static void drawWrappedText( + img.Image image, + String text, { + required img.BitmapFont font, + required int x, + required int y, + required int maxWidth, + required img.Color color, + int maxLines = 2, + }) { + final words = text.trim().split(RegExp(r'\s+')); + final lines = []; + var current = ''; + + for (final word in words) { + final candidate = current.isEmpty ? word : '$current $word'; + if (_estimateTextWidth(candidate, font) <= maxWidth) { + current = candidate; + } else { + if (current.isNotEmpty) lines.add(current); + current = word; + } + if (lines.length == maxLines) break; + } + if (current.isNotEmpty && lines.length < maxLines) lines.add(current); + + for (var i = 0; i < lines.length; i++) { + var line = lines[i]; + if (i == maxLines - 1 && words.join(' ').length > lines.join(' ').length) { + line = _ellipsize(line, font, maxWidth); + } + img.drawString( + image, + line, + font: font, + x: x, + y: y + (i * font.lineHeight), + color: color, + ); + } + } + + static int _estimateTextWidth(String value, img.BitmapFont font) { + return (value.length * font.lineHeight * 0.56).round(); + } + + static String _ellipsize(String value, img.BitmapFont font, int maxWidth) { + var output = value; + while (output.isNotEmpty && _estimateTextWidth('$output...', font) > maxWidth) { + output = output.substring(0, output.length - 1).trimRight(); + } + return output.isEmpty ? '...' : '$output...'; + } +} diff --git a/lib/widgets/geo_info_overlay.dart b/lib/widgets/geo_info_overlay.dart new file mode 100644 index 0000000..4ebdaee --- /dev/null +++ b/lib/widgets/geo_info_overlay.dart @@ -0,0 +1,57 @@ +import 'package:flutter/material.dart'; + +import '../models/geo_photo_model.dart'; +import 'map_preview_widget.dart'; + +/// On-screen preview equivalent of the geo metadata that is burned into output images. +class GeoInfoOverlay extends StatelessWidget { + const GeoInfoOverlay({required this.geoPhoto, super.key}); + + final GeoPhotoModel geoPhoto; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.68), + borderRadius: BorderRadius.circular(22), + ), + child: Row( + children: [ + Expanded( + flex: 4, + child: MapPreviewWidget( + latitude: geoPhoto.latitude, + longitude: geoPhoto.longitude, + height: 132, + ), + ), + const SizedBox(width: 12), + Expanded( + flex: 7, + child: DefaultTextStyle( + style: const TextStyle(color: Colors.white, height: 1.25), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + geoPhoto.shortTitle, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle(fontWeight: FontWeight.w700, fontSize: 16), + ), + Text(geoPhoto.address, maxLines: 2, overflow: TextOverflow.ellipsis), + Text('Lat ${geoPhoto.formattedLatitude}° Long ${geoPhoto.formattedLongitude}°'), + Text(geoPhoto.formattedDateTime), + Text('${geoPhoto.weatherLabel} ${geoPhoto.speedLabel} ${geoPhoto.altitudeLabel}'), + ], + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/widgets/map_preview_widget.dart b/lib/widgets/map_preview_widget.dart new file mode 100644 index 0000000..18692da --- /dev/null +++ b/lib/widgets/map_preview_widget.dart @@ -0,0 +1,39 @@ +import 'package:flutter/material.dart'; +import 'package:google_maps_flutter/google_maps_flutter.dart'; + +/// Lightweight Google Maps preview widget for UI screens that want to mirror +/// the burned-in static-map thumbnail before/after capture. +class MapPreviewWidget extends StatelessWidget { + const MapPreviewWidget({ + required this.latitude, + required this.longitude, + this.height = 140, + super.key, + }); + + final double latitude; + final double longitude; + final double height; + + @override + Widget build(BuildContext context) { + final position = LatLng(latitude, longitude); + return ClipRRect( + borderRadius: BorderRadius.circular(18), + child: SizedBox( + height: height, + child: GoogleMap( + initialCameraPosition: CameraPosition(target: position, zoom: 16), + markers: { + Marker(markerId: const MarkerId('current-location'), position: position), + }, + compassEnabled: false, + mapToolbarEnabled: false, + myLocationButtonEnabled: false, + zoomControlsEnabled: false, + liteModeEnabled: true, + ), + ), + ); + } +} diff --git a/pubspec.yaml b/pubspec.yaml index be21f32..acd7a87 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -34,6 +34,8 @@ dependencies: camera: ^0.11.2+1 geolocator: ^12.0.0 geocoding: ^3.0.0 + google_maps_flutter: ^2.10.0 + google_static_maps_controller: ^1.1.0 flutter_map: ^7.0.2 latlong2: ^0.9.1 photo_manager: ^3.7.1 @@ -46,6 +48,8 @@ dependencies: exif: ^3.3.0 path: ^1.9.1 path_provider: ^2.1.5 + image: ^4.5.2 + http: ^1.2.2 dev_dependencies: flutter_test: