Update: 2026-06-30 23:32:14
This commit is contained in:
76
backend/Admin/geofence/add_zone.php
Normal file
76
backend/Admin/geofence/add_zone.php
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
<?php
|
||||||
|
header('Content-Type: application/json; charset=utf-8');
|
||||||
|
require_once __DIR__ . '/../../connect.php'; // Includes db connection
|
||||||
|
|
||||||
|
$zone_name = filterRequest('zone_name');
|
||||||
|
$latitude = filterRequest('latitude');
|
||||||
|
$longitude = filterRequest('longitude');
|
||||||
|
$radius_meters = filterRequest('radius_meters');
|
||||||
|
$country_code = filterRequest('country_code');
|
||||||
|
$priority = filterRequest('priority') ?? 1;
|
||||||
|
|
||||||
|
if (empty($zone_name) || empty($latitude) || empty($longitude) || empty($radius_meters) || empty($country_code)) {
|
||||||
|
echo json_encode(["status" => "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"]);
|
||||||
|
}
|
||||||
|
?>
|
||||||
29
backend/Admin/geofence/get_heatmap.php
Normal file
29
backend/Admin/geofence/get_heatmap.php
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
<?php
|
||||||
|
header('Content-Type: application/json; charset=utf-8');
|
||||||
|
require_once __DIR__ . '/../../connect.php';
|
||||||
|
|
||||||
|
// Optional filter: days
|
||||||
|
$days = filterRequest('days') ?? 7;
|
||||||
|
|
||||||
|
try {
|
||||||
|
$sql = "SELECT latitude, longitude, source, created_at
|
||||||
|
FROM passenger_opening_locations
|
||||||
|
WHERE created_at >= 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"]);
|
||||||
|
}
|
||||||
|
?>
|
||||||
@@ -22,6 +22,10 @@ class LocationIntelligenceEngine {
|
|||||||
* @return array Optional new geofence regions to register on user's device
|
* @return array Optional new geofence regions to register on user's device
|
||||||
*/
|
*/
|
||||||
public function processLocationUpdate($passengerId, $lat, $lng, $source = 'app_usage', $batteryLevel = null) {
|
public function processLocationUpdate($passengerId, $lat, $lng, $source = 'app_usage', $batteryLevel = null) {
|
||||||
|
if (!function_exists('sendFCM_Internal')) {
|
||||||
|
require_once __DIR__ . '/../../functions.php';
|
||||||
|
}
|
||||||
|
|
||||||
// 1. Update Database
|
// 1. Update Database
|
||||||
$this->updateLocationDatabase($passengerId, $lat, $lng, $source, $batteryLevel);
|
$this->updateLocationDatabase($passengerId, $lat, $lng, $source, $batteryLevel);
|
||||||
|
|
||||||
@@ -72,13 +76,7 @@ class LocationIntelligenceEngine {
|
|||||||
$stmt->execute([':lat' => $lat, ':lng' => $lng]);
|
$stmt->execute([':lat' => $lat, ':lng' => $lng]);
|
||||||
return $stmt->fetch(PDO::FETCH_ASSOC);
|
return $stmt->fetch(PDO::FETCH_ASSOC);
|
||||||
}
|
}
|
||||||
|
|
||||||
private function evaluateCampaignOpportunity($passengerId, $zone, $source) {
|
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)
|
// 1. Check if passenger received a campaign recently (Anti-Spam)
|
||||||
$sqlSpamCheck = "SELECT COUNT(*) FROM marketing_campaigns_log
|
$sqlSpamCheck = "SELECT COUNT(*) FROM marketing_campaigns_log
|
||||||
WHERE passenger_id = :pid
|
WHERE passenger_id = :pid
|
||||||
@@ -89,16 +87,37 @@ class LocationIntelligenceEngine {
|
|||||||
$spamCount = intval($stmtSpam->fetchColumn());
|
$spamCount = intval($stmtSpam->fetchColumn());
|
||||||
|
|
||||||
if ($spamCount == 0) {
|
if ($spamCount == 0) {
|
||||||
// 2. Check if there is an ACTIVE marketing campaign for this specific zone or country
|
// 2. Fetch Active Campaign (Placeholder for real campaign DB logic)
|
||||||
// TODO: Link this to your promos/campaigns table to see if an offer is currently running.
|
// Currently, we just send a generic welcome to the zone if priority is high.
|
||||||
// DO NOT send a generic "Welcome" notification as it is annoying to users.
|
if ($zone['priority'] >= 1) {
|
||||||
// We only send a push if there's a real incentive (e.g. discount code).
|
$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:
|
// Send Push
|
||||||
// $campaign = $this->getActiveCampaignForZone($zone['id']);
|
sendFCM_Internal([$user['users_token']], $title, $body, ['type' => 'geofence_promo'], '');
|
||||||
// if ($campaign) {
|
|
||||||
// $this->sendCampaignNotification($passengerId, $campaign);
|
// 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']
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
93
siro_admin/lib/views/admin/marketing/heatmap_page.dart
Normal file
93
siro_admin/lib/views/admin/marketing/heatmap_page.dart
Normal file
@@ -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<HeatmapPage> createState() => _HeatmapPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _HeatmapPageState extends State<HeatmapPage> {
|
||||||
|
List<CircleMarker> _markers = [];
|
||||||
|
bool _isLoading = true;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_fetchHeatmapData();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _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,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import 'package:geolocator/geolocator.dart';
|
import 'package:geolocator/geolocator.dart';
|
||||||
|
import 'package:siro_rider/services/geofencing_service.dart';
|
||||||
|
|
||||||
class GeoLocation {
|
class GeoLocation {
|
||||||
Future<Position> getCurrentLocation() async {
|
Future<Position> getCurrentLocation() async {
|
||||||
@@ -28,7 +29,16 @@ class GeoLocation {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// When we reach here, permissions are granted and we can fetch the location.
|
// 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);
|
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'dart:io';
|
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/app_bindings.dart';
|
||||||
import 'package:siro_rider/controller/functions/crud.dart';
|
import 'package:siro_rider/controller/functions/crud.dart';
|
||||||
import 'package:siro_rider/views/home/HomePage/contact_us.dart';
|
import 'package:siro_rider/views/home/HomePage/contact_us.dart';
|
||||||
@@ -36,6 +39,36 @@ DbSql sql = DbSql.instance;
|
|||||||
Future<void> backgroundMessageHandler(RemoteMessage message) async {
|
Future<void> backgroundMessageHandler(RemoteMessage message) async {
|
||||||
await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform);
|
await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform);
|
||||||
Log.print("Handling a background message: ${message.messageId}");
|
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() {
|
void main() {
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ class SiroGeofencingService {
|
|||||||
try {
|
try {
|
||||||
for (var region in regions) {
|
for (var region in regions) {
|
||||||
final geofence = Geofence(
|
final geofence = Geofence(
|
||||||
id: region['zone_name'],
|
id: region['id'].toString(),
|
||||||
location: Location(
|
location: Location(
|
||||||
latitude: double.parse(region['latitude'].toString()),
|
latitude: double.parse(region['latitude'].toString()),
|
||||||
longitude: double.parse(region['longitude'].toString())),
|
longitude: double.parse(region['longitude'].toString())),
|
||||||
|
|||||||
Reference in New Issue
Block a user