diff --git a/app/ios/Runner/AppDelegate.swift b/app/ios/Runner/AppDelegate.swift index e2200371f8..56416234d7 100644 --- a/app/ios/Runner/AppDelegate.swift +++ b/app/ios/Runner/AppDelegate.swift @@ -4,6 +4,7 @@ import UserNotifications import app_links import WatchConnectivity import AVFoundation +import MapKit extension FlutterError: Error {} @@ -13,6 +14,7 @@ extension FlutterError: Error {} @objc class AppDelegate: FlutterAppDelegate { private var methodChannel: FlutterMethodChannel? private var appleRemindersChannel: FlutterMethodChannel? + private var mapSnapshotChannel: FlutterMethodChannel? private let appleRemindersService = AppleRemindersService() private var notificationTitleOnKill: String? @@ -61,6 +63,12 @@ extension FlutterError: Error {} appleRemindersChannel?.setMethodCallHandler { [weak self] (call, result) in self?.handleAppleRemindersCall(call, result: result) } + + // Create Map Snapshot method channel + mapSnapshotChannel = FlutterMethodChannel(name: "com.omi.map_snapshot", binaryMessenger: controller!.binaryMessenger) + mapSnapshotChannel?.setMethodCallHandler { [weak self] (call, result) in + self?.handleMapSnapshotCall(call, result: result) + } // here, Without this code the task will not work. SwiftFlutterForegroundTaskPlugin.setPluginRegistrantCallback { registry in @@ -95,6 +103,114 @@ extension FlutterError: Error {} private func handleAppleRemindersCall(_ call: FlutterMethodCall, result: @escaping FlutterResult) { appleRemindersService.handleMethodCall(call, result: result) } + + private func handleMapSnapshotCall(_ call: FlutterMethodCall, result: @escaping FlutterResult) { + if call.method == "getMapSnapshot" { + guard let args = call.arguments as? [String: Any], + let latitude = args["latitude"] as? Double, + let longitude = args["longitude"] as? Double, + let width = args["width"] as? Double, + let height = args["height"] as? Double else { + result(FlutterError(code: "INVALID_ARGUMENTS", message: "Invalid arguments", details: nil)) + return + } + + generateMapSnapshot(latitude: latitude, longitude: longitude, width: width, height: height, result: result) + } else { + result(FlutterMethodNotImplemented) + } + } + + private func generateMapSnapshot(latitude: Double, longitude: Double, width: Double, height: Double, result: @escaping FlutterResult) { + let location = CLLocationCoordinate2D(latitude: latitude, longitude: longitude) + let region = MKCoordinateRegion(center: location, latitudinalMeters: 1000, longitudinalMeters: 1000) + + let mapSnapshotOptions = MKMapSnapshotter.Options() + mapSnapshotOptions.region = region + mapSnapshotOptions.size = CGSize(width: width, height: height) + mapSnapshotOptions.scale = UIScreen.main.scale + + let snapshotter = MKMapSnapshotter(options: mapSnapshotOptions) + + snapshotter.start { snapshot, error in + guard let snapshot = snapshot, error == nil else { + result(FlutterError(code: "SNAPSHOT_ERROR", message: error?.localizedDescription, details: nil)) + return + } + + // Create an image with the pin annotation + let image = snapshot.image + let finalImage = UIGraphicsImageRenderer(size: image.size).image { context in + image.draw(at: .zero) + + // Draw a pin at the location + let pinPoint = snapshot.point(for: location) + + // Pin dimensions + let pinWidth: CGFloat = 30 + let pinHeight: CGFloat = 40 + let pinTop = pinPoint.y - pinHeight + let pinLeft = pinPoint.x - pinWidth / 2 + + // Draw shadow first + context.cgContext.saveGState() + context.cgContext.setShadow(offset: CGSize(width: 0, height: 3), blur: 6, color: UIColor.black.withAlphaComponent(0.4).cgColor) + + // Draw the pin shape (teardrop/balloon) + let pinPath = UIBezierPath() + + // Start at the bottom point (where the pin touches the map) + pinPath.move(to: CGPoint(x: pinPoint.x, y: pinPoint.y)) + + // Draw the left curve up + pinPath.addQuadCurve( + to: CGPoint(x: pinLeft, y: pinTop + pinHeight * 0.4), + controlPoint: CGPoint(x: pinLeft + pinWidth * 0.2, y: pinPoint.y - pinHeight * 0.3) + ) + + // Draw the top rounded part (left side of circle) + pinPath.addArc( + withCenter: CGPoint(x: pinPoint.x, y: pinTop + pinHeight * 0.35), + radius: pinWidth / 2, + startAngle: .pi, + endAngle: 0, + clockwise: true + ) + + // Draw the right curve down to the point + pinPath.addQuadCurve( + to: CGPoint(x: pinPoint.x, y: pinPoint.y), + controlPoint: CGPoint(x: pinLeft + pinWidth * 0.8, y: pinPoint.y - pinHeight * 0.3) + ) + + pinPath.close() + + // Fill the pin with red + UIColor.systemRed.setFill() + pinPath.fill() + + context.cgContext.restoreGState() + + // Draw white circle in the center of the pin + let circleDiameter: CGFloat = 8 + let circleRect = CGRect( + x: pinPoint.x - circleDiameter / 2, + y: pinTop + pinHeight * 0.35 - circleDiameter / 2, + width: circleDiameter, + height: circleDiameter + ) + UIColor.white.setFill() + context.cgContext.fillEllipse(in: circleRect) + } + + // Convert to PNG data + if let imageData = finalImage.pngData() { + result(FlutterStandardTypedData(bytes: imageData)) + } else { + result(FlutterError(code: "CONVERSION_ERROR", message: "Failed to convert image to PNG", details: nil)) + } + } + } override func applicationWillTerminate(_ application: UIApplication) { diff --git a/app/lib/pages/conversation_detail/maps_util.dart b/app/lib/pages/conversation_detail/maps_util.dart index c59d077135..f6a24cb15d 100644 --- a/app/lib/pages/conversation_detail/maps_util.dart +++ b/app/lib/pages/conversation_detail/maps_util.dart @@ -10,6 +10,17 @@ class MapsUtil { return "https://maps.googleapis.com/maps/api/staticmap?center=$lat,$lng&zoom=14&size=450x450&markers=color:red%7C$lat,$lng&key=${Env.googleMapsApiKey}&style=element:geometry%7Ccolor:0x212121&style=element:labels.icon%7Cvisibility:off&style=element:labels.text.fill%7Ccolor:0x757575&style=element:labels.text.stroke%7Ccolor:0x212121&style=feature:administrative%7Celement:geometry%7Ccolor:0x757575&style=feature:administrative.country%7Celement:labels.text.fill%7Ccolor:0x9e9e9e&style=feature:administrative.land_parcel%7Cvisibility:off&style=feature:administrative.locality%7Celement:labels.text.fill%7Ccolor:0xbdbdbd&style=feature:poi%7Celement:labels.text.fill%7Ccolor:0x757575&style=feature:poi.park%7Celement:geometry%7Ccolor:0x181818&style=feature:poi.park%7Celement:labels.text.fill%7Ccolor:0x616161&style=feature:poi.park%7Celement:labels.text.stroke%7Ccolor:0x1b1b1b&style=feature:road%7Celement:geometry.fill%7Ccolor:0x2c2c2c&style=feature:road%7Celement:labels.text.fill%7Ccolor:0x8a8a8a&style=feature:road.arterial%7Celement:geometry%7Ccolor:0x373737&style=feature:road.highway%7Celement:geometry%7Ccolor:0x3c3c3c&style=feature:road.highway.controlled_access%7Celement:geometry%7Ccolor:0x4e4e4e&style=feature:road.local%7Celement:labels.text.fill%7Ccolor:0x616161&style=feature:transit%7Celement:labels.text.fill%7Ccolor:0x757575&style=feature:water%7Celement:geometry%7Ccolor:0x000000&style=feature:water%7Celement:labels.text.fill%7Ccolor:0x3d3d3d"; } + static String getStaticMapUrl(double lat, double lng) { + // Using OpenStreetMap's staticmap service (free, no API key required) + // Format: https://staticmap.openstreetmap.de/staticmap.php?center=lat,lng&zoom=14&size=600x400&maptype=mapnik&markers=lat,lng,lightblue + final zoom = 15; + final width = 600; + final height = 400; + + // Using OSM static map generator + return "https://staticmap.openstreetmap.de/staticmap.php?center=$lat,$lng&zoom=$zoom&size=${width}x$height&maptype=mapnik&markers=$lat,$lng,red-pushpin"; + } + static String getGoogleMapsPlaceUrl(String googlePlaceId) { return "https://www.google.com/maps/place/?q=place_id=$googlePlaceId"; } diff --git a/app/lib/pages/conversation_detail/page.dart b/app/lib/pages/conversation_detail/page.dart index 688972dc66..3cc6ef5f4b 100644 --- a/app/lib/pages/conversation_detail/page.dart +++ b/app/lib/pages/conversation_detail/page.dart @@ -946,6 +946,7 @@ class SummaryTab extends StatelessWidget { data.item1 ? const ReprocessDiscardedWidget() : GetAppsWidgets(searchQuery: searchQuery, currentResultIndex: currentResultIndex), + const GetGeolocationWidgets(), const SizedBox(height: 150) ], ), diff --git a/app/lib/pages/conversation_detail/widgets.dart b/app/lib/pages/conversation_detail/widgets.dart index 44c398fe74..9933c780e2 100644 --- a/app/lib/pages/conversation_detail/widgets.dart +++ b/app/lib/pages/conversation_detail/widgets.dart @@ -1,3 +1,6 @@ +import 'dart:io'; +import 'dart:typed_data'; + import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; @@ -691,72 +694,188 @@ class GetAppsWidgets extends StatelessWidget { } } -class GetGeolocationWidgets extends StatelessWidget { +class GetGeolocationWidgets extends StatefulWidget { const GetGeolocationWidgets({super.key}); + @override + State createState() => _GetGeolocationWidgetsState(); +} + +class _GetGeolocationWidgetsState extends State { + static const platform = MethodChannel('com.omi.map_snapshot'); + Uint8List? _mapImageBytes; + bool _isLoading = false; + + Future _loadMapSnapshot(double latitude, double longitude) async { + if (_mapImageBytes != null) return; // Already loaded + + setState(() { + _isLoading = true; + }); + + try { + final Uint8List result = await platform.invokeMethod('getMapSnapshot', { + 'latitude': latitude, + 'longitude': longitude, + 'width': 600.0, + 'height': 400.0, + }); + + if (mounted) { + setState(() { + _mapImageBytes = result; + _isLoading = false; + }); + } + } catch (e) { + print('Error loading map snapshot: $e'); + if (mounted) { + setState(() { + _isLoading = false; + }); + } + } + } + @override Widget build(BuildContext context) { return Selector(selector: (context, provider) { if (provider.conversation.discarded) return null; return provider.conversation.geolocation; }, builder: (context, geolocation, child) { + // Only show on iOS + if (!Platform.isIOS) { + return const SizedBox.shrink(); + } + + // Check if geolocation exists and has valid coordinates + if (geolocation == null || + geolocation.latitude == null || + geolocation.longitude == null || + geolocation.address == null) { + return const SizedBox.shrink(); + } + + // Load map snapshot if not loaded yet - schedule after build + if (_mapImageBytes == null && !_isLoading) { + WidgetsBinding.instance.addPostFrameCallback((_) { + _loadMapSnapshot(geolocation.latitude!, geolocation.longitude!); + }); + } + return Column( crossAxisAlignment: CrossAxisAlignment.start, - children: geolocation == null - ? [] - : [ - Text( - 'Taken at', - style: Theme.of(context).textTheme.titleLarge!.copyWith(fontSize: 20), - ), - const SizedBox(height: 8), - Text( - '${geolocation.address?.decodeString}', - style: TextStyle(color: Colors.grey.shade300), - ), - const SizedBox(height: 8), - GestureDetector( - onTap: () async { - MapsUtil.launchMap(geolocation.latitude!, geolocation.longitude!); - }, - child: CachedNetworkImage( - imageBuilder: (context, imageProvider) { - return Container( - margin: const EdgeInsets.only(top: 10, bottom: 8), - height: 200, - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(16), - image: DecorationImage( - image: imageProvider, - fit: BoxFit.cover, + children: [ + const SizedBox(height: 16), + GestureDetector( + onTap: () async { + MapsUtil.launchMap(geolocation.latitude!, geolocation.longitude!); + }, + child: Container( + height: 200, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.3), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ], + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(16), + child: Stack( + fit: StackFit.expand, + children: [ + // Map image from Apple Maps + if (_mapImageBytes != null) + Image.memory( + _mapImageBytes!, + fit: BoxFit.cover, + ) + else if (_isLoading) + Container( + color: const Color(0xFF2C2C2E), + child: const Center( + child: CircularProgressIndicator( + color: Colors.white70, + ), + ), + ) + else + Container( + color: const Color(0xFF2C2C2E), + child: Center( + child: Icon( + Icons.map, + size: 60, + color: Colors.grey.shade700, ), ), - ); - }, - errorWidget: (context, url, error) { - return Container( - margin: const EdgeInsets.only(top: 10, bottom: 8), - height: 200, + ), + // Bottom info overlay + Positioned( + bottom: 0, + left: 0, + right: 0, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), decoration: BoxDecoration( - borderRadius: BorderRadius.circular(16), - color: Color(0xFF35343B), + color: Colors.blueGrey.withOpacity(0.95), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.1), + blurRadius: 8, + offset: const Offset(0, -2), + ), + ], ), - child: const Center( - child: Text( - 'Could not load Maps. Please check your internet connection.', - textAlign: TextAlign.center, - ), + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + // const Text( + // 'Location', + // style: TextStyle( + // color: Colors.black87, + // fontSize: 13, + // fontWeight: FontWeight.w500, + // ), + // ), + // const SizedBox(height: 2), + Text( + geolocation.address!.decodeString.split(',').first, + style: TextStyle( + color: Colors.white, + fontSize: 15, + fontWeight: FontWeight.w400, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + Icon( + Icons.chevron_right, + color: Colors.white, + size: 24, + ), + ], ), - ); - }, - imageUrl: MapsUtil.getMapImageUrl( - geolocation.latitude!, - geolocation.longitude!, + ), ), - ), + ], ), - const SizedBox(height: 8), - ], + ), + ), + ), + const SizedBox(height: 8), + ], ); }); }