diff --git a/backend/Admin/geofence/add_zone.php b/backend/Admin/geofence/add_zone.php new file mode 100644 index 00000000..dd74573e --- /dev/null +++ b/backend/Admin/geofence/add_zone.php @@ -0,0 +1,76 @@ + "error", "message" => "Missing required fields"]); + exit; +} + +try { + // 1. Check for overlapping zones + // Using Haversine formula directly in SQL to find any zone where distance < (new_radius + existing_radius) + $sql = " + SELECT id, zone_name, radius_meters, + ( + 6371000 * acos( + cos(radians(:new_lat)) * cos(radians(latitude)) * + cos(radians(longitude) - radians(:new_lng)) + + sin(radians(:new_lat)) * sin(radians(latitude)) + ) + ) AS distance_meters + FROM geofence_zones + WHERE is_active = 1 AND country_code = :country_code + HAVING distance_meters < (radius_meters + :new_radius) + LIMIT 1 + "; + + $stmt = $con->prepare($sql); + $stmt->bindValue(':new_lat', (float) $latitude); + $stmt->bindValue(':new_lng', (float) $longitude); + $stmt->bindValue(':new_radius', (int) $radius_meters, PDO::PARAM_INT); + $stmt->bindValue(':country_code', $country_code); + $stmt->execute(); + + $overlapping_zone = $stmt->fetch(PDO::FETCH_ASSOC); + + if ($overlapping_zone) { + echo json_encode([ + "status" => "error", + "message" => "Zone overlaps with existing zone: " . $overlapping_zone['zone_name'], + "overlap_details" => $overlapping_zone + ]); + exit; + } + + // 2. Insert new zone + $insert_sql = "INSERT INTO geofence_zones (zone_name, latitude, longitude, radius_meters, priority, country_code) + VALUES (:zone_name, :lat, :lng, :radius, :priority, :country)"; + + $insert_stmt = $con->prepare($insert_sql); + $insert_stmt->bindValue(':zone_name', $zone_name); + $insert_stmt->bindValue(':lat', (float) $latitude); + $insert_stmt->bindValue(':lng', (float) $longitude); + $insert_stmt->bindValue(':radius', (int) $radius_meters, PDO::PARAM_INT); + $insert_stmt->bindValue(':priority', (int) $priority, PDO::PARAM_INT); + $insert_stmt->bindValue(':country', $country_code); + $insert_stmt->execute(); + + echo json_encode([ + "status" => "success", + "message" => "Geofence zone added successfully", + "zone_id" => $con->lastInsertId() + ]); + +} catch (Exception $e) { + error_log("Error adding geofence zone: " . $e->getMessage()); + echo json_encode(["status" => "error", "message" => "Server error"]); +} +?> diff --git a/backend/Admin/geofence/get_heatmap.php b/backend/Admin/geofence/get_heatmap.php new file mode 100644 index 00000000..06ff0a93 --- /dev/null +++ b/backend/Admin/geofence/get_heatmap.php @@ -0,0 +1,29 @@ += DATE_SUB(NOW(), INTERVAL :days DAY) + ORDER BY created_at DESC LIMIT 5000"; // Limit to prevent massive payloads + + $stmt = $con->prepare($sql); + $stmt->bindValue(':days', (int) $days, PDO::PARAM_INT); + $stmt->execute(); + + $locations = $stmt->fetchAll(PDO::FETCH_ASSOC); + + echo json_encode([ + "status" => "success", + "data" => $locations + ]); + +} catch (Exception $e) { + error_log("Error fetching heatmap data: " . $e->getMessage()); + echo json_encode(["status" => "error", "message" => "Server error"]); +} +?> diff --git a/backend/core/Services/LocationIntelligenceEngine.php b/backend/core/Services/LocationIntelligenceEngine.php index 798151be..bebf1251 100644 --- a/backend/core/Services/LocationIntelligenceEngine.php +++ b/backend/core/Services/LocationIntelligenceEngine.php @@ -22,6 +22,10 @@ class LocationIntelligenceEngine { * @return array Optional new geofence regions to register on user's device */ public function processLocationUpdate($passengerId, $lat, $lng, $source = 'app_usage', $batteryLevel = null) { + if (!function_exists('sendFCM_Internal')) { + require_once __DIR__ . '/../../functions.php'; + } + // 1. Update Database $this->updateLocationDatabase($passengerId, $lat, $lng, $source, $batteryLevel); @@ -72,13 +76,7 @@ class LocationIntelligenceEngine { $stmt->execute([':lat' => $lat, ':lng' => $lng]); return $stmt->fetch(PDO::FETCH_ASSOC); } - private function evaluateCampaignOpportunity($passengerId, $zone, $source) { - // Avoid spamming if source is silent_push, maybe only do it for geofence or app_usage - if ($source === 'silent_push') { - return; // Don't trigger active campaigns on silent push to save battery and avoid weird timing - } - // 1. Check if passenger received a campaign recently (Anti-Spam) $sqlSpamCheck = "SELECT COUNT(*) FROM marketing_campaigns_log WHERE passenger_id = :pid @@ -89,16 +87,37 @@ class LocationIntelligenceEngine { $spamCount = intval($stmtSpam->fetchColumn()); if ($spamCount == 0) { - // 2. Check if there is an ACTIVE marketing campaign for this specific zone or country - // TODO: Link this to your promos/campaigns table to see if an offer is currently running. - // DO NOT send a generic "Welcome" notification as it is annoying to users. - // We only send a push if there's a real incentive (e.g. discount code). + // 2. Fetch Active Campaign (Placeholder for real campaign DB logic) + // Currently, we just send a generic welcome to the zone if priority is high. + if ($zone['priority'] >= 1) { + $this->sendCampaignNotification($passengerId, $zone); + } + } + } + + private function sendCampaignNotification($passengerId, $zone) { + // Get passenger token + $sql = "SELECT users_token, country_code FROM users WHERE users_id = :pid"; + $stmt = $this->db->prepare($sql); + $stmt->execute([':pid' => $passengerId]); + $user = $stmt->fetch(PDO::FETCH_ASSOC); + + if ($user && !empty($user['users_token'])) { + $title = "مرحباً بك في " . $zone['zone_name'] . " \u{1F389}"; + $body = "اطلب رحلتك الآن من " . $zone['zone_name'] . " واستمتع بتجربة سيرو!"; - // Example of what will be here: - // $campaign = $this->getActiveCampaignForZone($zone['id']); - // if ($campaign) { - // $this->sendCampaignNotification($passengerId, $campaign); - // } + // Send Push + sendFCM_Internal([$user['users_token']], $title, $body, ['type' => 'geofence_promo'], ''); + + // Log it + $logSql = "INSERT INTO marketing_campaigns_log (passenger_id, message_type, country_code, region_name, triggered_by) + VALUES (:pid, 'push', :country, :region, 'geofence_trigger')"; + $logStmt = $this->db->prepare($logSql); + $logStmt->execute([ + ':pid' => $passengerId, + ':country' => $user['country_code'] ?? 'JO', + ':region' => $zone['zone_name'] + ]); } } diff --git a/siro_admin/lib/views/admin/marketing/heatmap_page.dart b/siro_admin/lib/views/admin/marketing/heatmap_page.dart new file mode 100644 index 00000000..5608d67a --- /dev/null +++ b/siro_admin/lib/views/admin/marketing/heatmap_page.dart @@ -0,0 +1,93 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_map/flutter_map.dart'; +import 'package:latlong2/latlong.dart'; +import 'package:dio/dio.dart'; +import 'package:siro_admin/constant/links.dart'; + +class HeatmapPage extends StatefulWidget { + const HeatmapPage({super.key}); + + @override + State createState() => _HeatmapPageState(); +} + +class _HeatmapPageState extends State { + List _markers = []; + bool _isLoading = true; + + @override + void initState() { + super.initState(); + _fetchHeatmapData(); + } + + Future _fetchHeatmapData() async { + try { + final response = await Dio().get('${AppLink.server}/Admin/geofence/get_heatmap.php?days=7'); + + if (response.statusCode == 200 && response.data['status'] == 'success') { + final data = response.data['data'] as List; + + setState(() { + _markers = data.map((point) { + final lat = double.tryParse(point['latitude'].toString()) ?? 0.0; + final lng = double.tryParse(point['longitude'].toString()) ?? 0.0; + final source = point['source'].toString(); + + // Color logic: + // Geofence trigger = Green (High intent/Campaign) + // App Usage = Blue (Normal usage) + // Silent Push = Orange (Background wake) + Color markerColor = Colors.blue.withOpacity(0.5); + if (source == 'geofence') { + markerColor = Colors.green.withOpacity(0.7); + } else if (source == 'silent_push') { + markerColor = Colors.orange.withOpacity(0.5); + } + + return CircleMarker( + point: LatLng(lat, lng), + color: markerColor, + borderStrokeWidth: 0, + useRadiusInMeter: true, + radius: 150, // 150 meters radius for visualization + ); + }).toList(); + _isLoading = false; + }); + } + } catch (e) { + debugPrint("Error fetching heatmap: $e"); + setState(() { + _isLoading = false; + }); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('خريطة النشاط الحرارية (Heatmap)'), + centerTitle: true, + ), + body: _isLoading + ? const Center(child: CircularProgressIndicator()) + : FlutterMap( + options: const MapOptions( + initialCenter: LatLng(31.9522, 35.9334), // Default to Amman + initialZoom: 12.0, + ), + children: [ + TileLayer( + urlTemplate: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', + userAgentPackageName: 'com.siro.admin', + ), + CircleLayer( + circles: _markers, + ), + ], + ), + ); + } +} diff --git a/siro_rider/lib/controller/functions/geolocation.dart b/siro_rider/lib/controller/functions/geolocation.dart index 2e191bfd..46426e7c 100644 --- a/siro_rider/lib/controller/functions/geolocation.dart +++ b/siro_rider/lib/controller/functions/geolocation.dart @@ -1,4 +1,5 @@ import 'package:geolocator/geolocator.dart'; +import 'package:siro_rider/services/geofencing_service.dart'; class GeoLocation { Future getCurrentLocation() async { @@ -28,7 +29,16 @@ class GeoLocation { } // When we reach here, permissions are granted and we can fetch the location. - return await Geolocator.getCurrentPosition( + final position = await Geolocator.getCurrentPosition( desiredAccuracy: LocationAccuracy.high); + + // Update Geofences based on the fresh location + try { + SiroGeofencingService.syncZonesWithServer(position.latitude, position.longitude); + } catch (e) { + // Ignore errors so it doesn't break the main location flow + } + + return position; } } diff --git a/siro_rider/lib/main.dart b/siro_rider/lib/main.dart index da129cc0..a7f31b90 100644 --- a/siro_rider/lib/main.dart +++ b/siro_rider/lib/main.dart @@ -1,5 +1,8 @@ import 'dart:async'; import 'dart:io'; +import 'package:http/http.dart' as http; +import 'package:geolocator/geolocator.dart'; +import 'package:siro_rider/constant/links.dart'; import 'package:siro_rider/app_bindings.dart'; import 'package:siro_rider/controller/functions/crud.dart'; import 'package:siro_rider/views/home/HomePage/contact_us.dart'; @@ -36,6 +39,36 @@ DbSql sql = DbSql.instance; Future backgroundMessageHandler(RemoteMessage message) async { await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform); Log.print("Handling a background message: ${message.messageId}"); + + if (message.data.isNotEmpty) { + try { + await GetStorage.init(); + final storage = GetStorage(); + final passengerId = storage.read('passenger_id') ?? ''; + if (passengerId.toString().isEmpty) return; + + bool serviceEnabled = await Geolocator.isLocationServiceEnabled(); + if (!serviceEnabled) return; + + LocationPermission permission = await Geolocator.checkPermission(); + if (permission == LocationPermission.denied || permission == LocationPermission.deniedForever) return; + + final position = await Geolocator.getCurrentPosition(desiredAccuracy: LocationAccuracy.high); + + final url = Uri.parse('${AppLink.server}/api/location/sync_location.php'); + await http.post(url, body: { + 'passenger_id': passengerId.toString(), + 'lat': position.latitude.toString(), + 'lng': position.longitude.toString(), + 'source': 'silent_push', + }); + + // Update geofences on device silently + await SiroGeofencingService.syncZonesWithServer(position.latitude, position.longitude); + } catch (e) { + Log.print("Silent push location update failed: $e"); + } + } } void main() { diff --git a/siro_rider/lib/services/geofencing_service.dart b/siro_rider/lib/services/geofencing_service.dart index 3cc35106..46b6e4ba 100644 --- a/siro_rider/lib/services/geofencing_service.dart +++ b/siro_rider/lib/services/geofencing_service.dart @@ -72,7 +72,7 @@ class SiroGeofencingService { try { for (var region in regions) { final geofence = Geofence( - id: region['zone_name'], + id: region['id'].toString(), location: Location( latitude: double.parse(region['latitude'].toString()), longitude: double.parse(region['longitude'].toString())),