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: