557 lines
18 KiB
Dart
557 lines
18 KiB
Dart
import 'dart:convert';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter/services.dart';
|
|
import 'package:maplibre_gl/maplibre_gl.dart';
|
|
import 'package:http/http.dart' as http;
|
|
import 'package:flutter/foundation.dart' show kIsWeb;
|
|
|
|
void main() {
|
|
runApp(
|
|
const MaterialApp(home: MapScreen(), debugShowCheckedModeBanner: false),
|
|
);
|
|
}
|
|
|
|
class MapScreen extends StatefulWidget {
|
|
const MapScreen({super.key});
|
|
@override
|
|
State<MapScreen> createState() => _MapScreenState();
|
|
}
|
|
|
|
class _MapScreenState extends State<MapScreen> {
|
|
MapLibreMapController? mapController;
|
|
bool isLoading = false;
|
|
String info = '📍 نظام انطلق المتكامل';
|
|
bool styleLoaded = false;
|
|
bool isSyncing = false;
|
|
bool isContinuousSyncEnabled = true; // Default to ON for premium experience
|
|
double syncProgress = 0.0;
|
|
|
|
// --- Feature: Custom Car Marker ---
|
|
Future<void> _addCarMarker() async {
|
|
if (!styleLoaded || mapController == null) return;
|
|
|
|
try {
|
|
// Load the car icon image (from network for quick demo)
|
|
final ByteData bytes = await NetworkAssetBundle(
|
|
Uri.parse(
|
|
'https://upload.wikimedia.org/wikipedia/commons/thumb/d/d1/Car_icon_blue.png/64px-Car_icon_blue.png',
|
|
),
|
|
).load('');
|
|
final Uint8List list = bytes.buffer.asUint8List();
|
|
|
|
await mapController!.addImage("car-icon", list);
|
|
|
|
await mapController!.addSymbol(
|
|
SymbolOptions(
|
|
geometry: const LatLng(33.513, 36.276), // Damascus city center
|
|
iconImage: "car-icon",
|
|
iconSize: 0.5,
|
|
textField: "سيارة انطلاقة #101",
|
|
textOffset: const Offset(0, 2),
|
|
textColor: "#3b82f6",
|
|
textHaloColor: "#ffffff",
|
|
textHaloWidth: 2,
|
|
),
|
|
);
|
|
setState(() => info = '✅ تمت إضافة علامة السيارة');
|
|
} catch (e) {
|
|
setState(() => info = 'خطأ في إضافة العلامة: $e');
|
|
}
|
|
}
|
|
|
|
// --- Feature: Polylines (Lines) ---
|
|
Future<void> _addSampleLine() async {
|
|
if (!styleLoaded || mapController == null) return;
|
|
await mapController!.addLine(
|
|
LineOptions(
|
|
geometry: [
|
|
const LatLng(33.515, 36.270),
|
|
const LatLng(33.520, 36.280),
|
|
const LatLng(33.510, 36.290),
|
|
],
|
|
lineColor: "#f59e0b",
|
|
lineWidth: 5.0,
|
|
lineOpacity: 0.8,
|
|
),
|
|
);
|
|
setState(() => info = '✅ تمت إضافة خط مسار');
|
|
}
|
|
|
|
// --- Feature: Polygons (Geofence) ---
|
|
Future<void> _addSamplePolygon() async {
|
|
if (!styleLoaded || mapController == null) return;
|
|
await mapController!.addFill(
|
|
FillOptions(
|
|
geometry: [
|
|
[
|
|
const LatLng(33.518, 36.273),
|
|
const LatLng(33.522, 36.273),
|
|
const LatLng(33.522, 36.3),
|
|
const LatLng(33.518, 36.4),
|
|
const LatLng(33.518, 36.273),
|
|
],
|
|
],
|
|
fillColor: "#10b981",
|
|
fillOpacity: 0.3,
|
|
fillOutlineColor: "#065f46",
|
|
),
|
|
);
|
|
setState(() => info = '✅ تمت إضافة سياج جغرافي');
|
|
}
|
|
|
|
// --- Feature: Circles ---
|
|
Future<void> _addSampleCircle() async {
|
|
if (!styleLoaded || mapController == null) return;
|
|
await mapController!.addCircle(
|
|
CircleOptions(
|
|
geometry: const LatLng(33.512, 36.280),
|
|
circleColor: "#ef4444",
|
|
circleRadius: 15.0,
|
|
circleOpacity: 0.6,
|
|
circleStrokeColor: "#ffffff",
|
|
circleStrokeWidth: 2.0,
|
|
),
|
|
);
|
|
setState(() => info = '✅ تمت إضافة نقطة محيطية');
|
|
}
|
|
|
|
// --- Feature: Routing ---
|
|
Future<void> computeRoute() async {
|
|
if (!styleLoaded || mapController == null) return;
|
|
setState(() {
|
|
isLoading = true;
|
|
info = '⚡ جاري حساب المسار...';
|
|
});
|
|
try {
|
|
final res = await http.get(
|
|
Uri.parse(
|
|
'https://map-saas.intaleqapp.com/api/maps/route?fromLat=33.513&fromLng=36.276&toLat=33.530&toLng=36.310',
|
|
),
|
|
headers: {'x-api-key': 'intaleq_secret_2026'},
|
|
);
|
|
final data = json.decode(res.body);
|
|
final pts = _decodePoly(data['points']);
|
|
|
|
await mapController?.clearLines();
|
|
await mapController?.addLine(
|
|
LineOptions(geometry: pts, lineColor: "#3b82f6", lineWidth: 6.0),
|
|
);
|
|
|
|
final latList = pts.map((p) => p.latitude).toList()..sort();
|
|
final lngList = pts.map((p) => p.longitude).toList()..sort();
|
|
mapController?.animateCamera(
|
|
CameraUpdate.newLatLngBounds(
|
|
LatLngBounds(
|
|
southwest: LatLng(latList.first, lngList.first),
|
|
northeast: LatLng(latList.last, lngList.last),
|
|
),
|
|
left: 50,
|
|
right: 50,
|
|
top: 100,
|
|
bottom: 100,
|
|
),
|
|
);
|
|
setState(
|
|
() => info =
|
|
'🏁 ${(data['distance'] / 1000).toStringAsFixed(1)} كم | ${(data['duration'] / 60).toStringAsFixed(0)} دقيقة',
|
|
);
|
|
} catch (e) {
|
|
setState(() => info = 'خطأ: $e');
|
|
} finally {
|
|
setState(() => isLoading = false);
|
|
}
|
|
}
|
|
|
|
// --- Feature: Fetch and Display Saved Places ---
|
|
Future<void> _fetchUserPlaces() async {
|
|
if (!styleLoaded || mapController == null) return;
|
|
try {
|
|
final res = await http.get(
|
|
Uri.parse('https://map-saas.intaleqapp.com/api/geocoding/places'),
|
|
headers: {'x-api-key': 'intaleq_secret_2026'},
|
|
);
|
|
if (res.statusCode == 200) {
|
|
final List<dynamic> data = json.decode(res.body);
|
|
await mapController?.clearSymbols();
|
|
|
|
for (var place in data) {
|
|
final lat = double.tryParse(place['latitude'].toString());
|
|
final lng = double.tryParse(place['longitude'].toString());
|
|
|
|
if (lat != null && lng != null) {
|
|
await mapController?.addSymbol(
|
|
SymbolOptions(
|
|
geometry: LatLng(lat, lng),
|
|
iconImage:
|
|
"marker-15", // Default MapLibre icon if using standard style
|
|
iconSize: 1.5,
|
|
textField: place['name_ar'] ?? place['name'],
|
|
textOffset: const Offset(0, 1.5),
|
|
textColor: "#1e293b",
|
|
textHaloColor: "#ffffff",
|
|
textHaloWidth: 2,
|
|
),
|
|
);
|
|
}
|
|
}
|
|
setState(() => info = '📍 تم تحميل ${data.length} موقع من النظام');
|
|
}
|
|
} catch (e) {
|
|
debugPrint('Error fetching places: $e');
|
|
}
|
|
}
|
|
|
|
// --- Feature: Offline Map Caching (Mobile Only) ---
|
|
Future<void> _downloadCurrentRegion() async {
|
|
if (kIsWeb) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
const SnackBar(
|
|
content: Text(
|
|
'التحميل اليدوي غير متاح على المتصفح (المتصفح يستخدم التخزين المؤقت للمتصفح تلقائياً)',
|
|
),
|
|
),
|
|
);
|
|
return;
|
|
}
|
|
|
|
if (mapController == null) return;
|
|
|
|
final region = await mapController!.getVisibleRegion();
|
|
final zoom = mapController!.cameraPosition?.zoom ?? 14.0;
|
|
|
|
setState(() {
|
|
isLoading = true;
|
|
info = '⬇️ جاري بدء التحميل...';
|
|
});
|
|
|
|
try {
|
|
// Create an offline region for current view
|
|
// Zoom levels from current zoom -1 to +2
|
|
await downloadOfflineRegion(
|
|
OfflineRegionDefinition(
|
|
bounds: region,
|
|
// تم التعديل هنا: محرك iOS يتطلب رابط إنترنت (URL) حقيقي لعملية الـ Offline
|
|
mapStyleUrl:
|
|
"https://map-saas.intaleqapp.com/styles/style.json", // يرجى التأكد من رفع ملف الستايل الخاص بك على هذا الرابط
|
|
minZoom: zoom.floor() - 1.0,
|
|
maxZoom: zoom.floor() + 2.0,
|
|
),
|
|
metadata: {
|
|
'name': 'Manual Download ${DateTime.now().toIso8601String()}',
|
|
},
|
|
);
|
|
|
|
setState(() => info = '✅ بدأ تحميل الخرائط لجهازك (Mobile Only)');
|
|
} catch (e) {
|
|
setState(() => info = '❌ خطأ في التحميل: $e');
|
|
} finally {
|
|
setState(() => isLoading = false);
|
|
}
|
|
}
|
|
|
|
Future<void> _clearMapCache() async {
|
|
if (kIsWeb) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
const SnackBar(
|
|
content: Text(
|
|
'الرجاء مسح بيانات المتصفح (Browser Cache) لإفراغ الذاكرة',
|
|
),
|
|
),
|
|
);
|
|
return;
|
|
}
|
|
|
|
// On mobile, we can list and delete regions
|
|
// For now, simple info update
|
|
setState(() => info = '🗑️ تم إرسال طلب مسح الذاكرة المؤقتة');
|
|
}
|
|
|
|
// --- Feature: Automatic Damascus Sync (Mobile Only) ---
|
|
Future<void> _initAutoOfflineSync() async {
|
|
if (kIsWeb) return;
|
|
|
|
try {
|
|
// Damascus City Center Bounds roughly +/- 0.1 deg
|
|
final damascusBounds = LatLngBounds(
|
|
southwest: const LatLng(33.473, 36.226),
|
|
northeast: const LatLng(33.583, 36.356),
|
|
);
|
|
|
|
setState(() {
|
|
isSyncing = true;
|
|
info = '🔄 جاري تأمين خريطة دمشق (Offline)...';
|
|
});
|
|
|
|
await downloadOfflineRegion(
|
|
OfflineRegionDefinition(
|
|
bounds: damascusBounds,
|
|
mapStyleUrl: "https://map-saas.intaleqapp.com/styles/style.json",
|
|
minZoom: 0.0,
|
|
maxZoom: 16.0, // High detail for Damascus
|
|
),
|
|
metadata: {'name': 'Damascus_Auto_Sync'},
|
|
// onHttpError: (error) => debugPrint('Sync HTTP Error: $error'),
|
|
// onTileError: (error) => debugPrint('Sync Tile Error: $error'),
|
|
);
|
|
|
|
setState(() {
|
|
isSyncing = false;
|
|
info = '✅ تم تأمين دمشق بالكامل للعمل أوفلاين';
|
|
});
|
|
} catch (e) {
|
|
debugPrint('Sync failed: $e');
|
|
setState(() => isSyncing = false);
|
|
}
|
|
}
|
|
|
|
// --- Feature: Continuous Background Sync (on move) ---
|
|
Future<void> _handleCameraIdleSync() async {
|
|
if (kIsWeb || !isContinuousSyncEnabled || mapController == null) return;
|
|
|
|
final bounds = await mapController!.getVisibleRegion();
|
|
final zoom = mapController!.cameraPosition?.zoom ?? 14.0;
|
|
|
|
debugPrint('Continuous Sync: Triggering for new view at zoom $zoom');
|
|
|
|
// Silent download for the current region
|
|
// We don't update 'info' here to keep it subtle, or just use a small indicator
|
|
try {
|
|
await downloadOfflineRegion(
|
|
OfflineRegionDefinition(
|
|
bounds: bounds,
|
|
mapStyleUrl: "https://map-saas.intaleqapp.com/styles/style.json",
|
|
minZoom: zoom.floor() - 1.0,
|
|
maxZoom: zoom.floor() + 2.0,
|
|
),
|
|
metadata: {'name': 'Auto_Area_${DateTime.now().millisecondsSinceEpoch}'},
|
|
);
|
|
} catch (e) {
|
|
debugPrint('Silent sync error (normal if redundant): $e');
|
|
}
|
|
}
|
|
|
|
List<LatLng> _decodePoly(dynamic p) {
|
|
if (p is String) {
|
|
var l = <LatLng>[];
|
|
int index = 0, lat = 0, lng = 0;
|
|
while (index < p.length) {
|
|
int b, shift = 0, res = 0;
|
|
do {
|
|
b = p.codeUnitAt(index++) - 63;
|
|
res |= (b & 0x1f) << shift;
|
|
shift += 5;
|
|
} while (b >= 0x20);
|
|
lat += (res & 1) != 0 ? ~(res >> 1) : (res >> 1);
|
|
shift = 0;
|
|
res = 0;
|
|
do {
|
|
b = p.codeUnitAt(index++) - 63;
|
|
res |= (b & 0x1f) << shift;
|
|
shift += 5;
|
|
} while (b >= 0x20);
|
|
lng += (res & 1) != 0 ? ~(res >> 1) : (res >> 1);
|
|
l.add(LatLng(lat / 1e5, lng / 1e5));
|
|
}
|
|
return l;
|
|
} else if (p is List) {
|
|
return (p as List)
|
|
.map<LatLng>((e) => LatLng(e[1].toDouble(), e[0].toDouble()))
|
|
.toList();
|
|
}
|
|
return [];
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Scaffold(
|
|
appBar: AppBar(
|
|
title: const Text(
|
|
'Intaleq Advanced Maps',
|
|
style: TextStyle(fontWeight: FontWeight.bold, color: Colors.white),
|
|
),
|
|
centerTitle: true,
|
|
backgroundColor: const Color(0xFF0f172a),
|
|
),
|
|
drawer: Drawer(
|
|
child: ListView(
|
|
padding: EdgeInsets.zero,
|
|
children: [
|
|
const DrawerHeader(
|
|
decoration: BoxDecoration(color: Color(0xFF0f172a)),
|
|
child: Center(
|
|
child: Text(
|
|
'أدوات انطلاقة المتقدمة',
|
|
style: TextStyle(
|
|
color: Colors.white,
|
|
fontSize: 18,
|
|
fontWeight: FontWeight.bold,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
ListTile(
|
|
leading: const Icon(Icons.location_on, color: Colors.blue),
|
|
title: const Text('إضافة علامة سيارة'),
|
|
onTap: () {
|
|
Navigator.pop(context);
|
|
_addCarMarker();
|
|
},
|
|
),
|
|
ListTile(
|
|
leading: const Icon(Icons.timeline, color: Colors.orange),
|
|
title: const Text('رسم خط مسار (Line)'),
|
|
onTap: () {
|
|
Navigator.pop(context);
|
|
_addSampleLine();
|
|
},
|
|
),
|
|
ListTile(
|
|
leading: const Icon(Icons.layers, color: Colors.green),
|
|
title: const Text('رسم سياج جغرافي (Polygon)'),
|
|
onTap: () {
|
|
Navigator.pop(context);
|
|
_addSamplePolygon();
|
|
},
|
|
),
|
|
ListTile(
|
|
leading: const Icon(
|
|
Icons.radio_button_checked,
|
|
color: Colors.red,
|
|
),
|
|
title: const Text('رسم دائرة (Circle)'),
|
|
onTap: () {
|
|
Navigator.pop(context);
|
|
_addSampleCircle();
|
|
},
|
|
),
|
|
const Divider(),
|
|
ListTile(
|
|
leading: const Icon(Icons.refresh, color: Colors.blue),
|
|
title: const Text('تحديث المواقع المسجلة'),
|
|
onTap: () {
|
|
Navigator.pop(context);
|
|
_fetchUserPlaces();
|
|
},
|
|
),
|
|
ListTile(
|
|
leading: const Icon(Icons.directions, color: Colors.blueAccent),
|
|
title: const Text('اختبار المسار التلقائي'),
|
|
onTap: () {
|
|
Navigator.pop(context);
|
|
computeRoute();
|
|
},
|
|
),
|
|
const Divider(),
|
|
const Padding(
|
|
padding: EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
|
child: Text(
|
|
'إدارة الخرائط (Offline)',
|
|
style: TextStyle(
|
|
fontWeight: FontWeight.bold,
|
|
color: Colors.grey,
|
|
),
|
|
),
|
|
),
|
|
ListTile(
|
|
leading: Icon(
|
|
Icons.download_for_offline,
|
|
color: kIsWeb ? Colors.grey : Colors.green,
|
|
),
|
|
title: const Text('تحميل النطاق الحالي للجهاز'),
|
|
onTap: () {
|
|
Navigator.pop(context);
|
|
_downloadCurrentRegion();
|
|
},
|
|
),
|
|
SwitchListTile(
|
|
secondary: Icon(Icons.sync, color: kIsWeb ? Colors.grey : Colors.blue),
|
|
title: const Text('المزامنة المستمرة (Dynamic Sync)'),
|
|
subtitle: const Text('حفظ أي منطقة يتم استكشافها تلقائياً', style: TextStyle(fontSize: 10)),
|
|
value: isContinuousSyncEnabled,
|
|
onChanged: kIsWeb ? null : (val) => setState(() => isContinuousSyncEnabled = val),
|
|
),
|
|
ListTile(
|
|
leading: Icon(
|
|
Icons.delete_sweep,
|
|
color: kIsWeb ? Colors.grey : Colors.redAccent,
|
|
),
|
|
title: const Text('مسح الذاكرة المؤقتة'),
|
|
onTap: () {
|
|
Navigator.pop(context);
|
|
_clearMapCache();
|
|
},
|
|
),
|
|
const Divider(),
|
|
ListTile(
|
|
leading: const Icon(Icons.delete_forever, color: Colors.grey),
|
|
title: const Text('مسح كافة البيانات (UI)'),
|
|
onTap: () {
|
|
Navigator.pop(context);
|
|
mapController?.clearLines();
|
|
mapController?.clearSymbols();
|
|
mapController?.clearFills();
|
|
mapController?.clearCircles();
|
|
},
|
|
),
|
|
],
|
|
),
|
|
),
|
|
body: Stack(
|
|
children: [
|
|
MapLibreMap(
|
|
styleString: "assets/style.json",
|
|
initialCameraPosition: const CameraPosition(
|
|
target: LatLng(33.513, 36.276),
|
|
zoom: 14,
|
|
),
|
|
onMapCreated: (MapLibreMapController c) => mapController = c,
|
|
onStyleLoadedCallback: () async {
|
|
setState(() => styleLoaded = true);
|
|
|
|
// Load Premium Icons into the Map Controller
|
|
final icons = ['hospital', 'police', 'pharmacy', 'restaurant', 'cafe', 'shop', 'tourist', 'train', 'arrow'];
|
|
for (final icon in icons) {
|
|
try {
|
|
final ByteData bytes = await rootBundle.load('assets/icons/$icon.svg');
|
|
await mapController?.addImage(icon, bytes.buffer.asUint8List());
|
|
} catch (e) {
|
|
debugPrint('Error loading asset icon $icon: $e');
|
|
}
|
|
}
|
|
|
|
_fetchUserPlaces();
|
|
_initAutoOfflineSync();
|
|
},
|
|
onCameraIdle: _handleCameraIdleSync, // Trigger continuous sync when movement stops
|
|
),
|
|
if (!styleLoaded)
|
|
Container(
|
|
color: const Color(0xFFf8f9fa),
|
|
child: const Center(child: CircularProgressIndicator()),
|
|
),
|
|
Positioned(
|
|
top: 10,
|
|
left: 10,
|
|
child: Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8),
|
|
decoration: BoxDecoration(
|
|
color: Colors.black.withOpacity(0.7),
|
|
borderRadius: BorderRadius.circular(30),
|
|
),
|
|
child: Text(
|
|
info,
|
|
style: const TextStyle(
|
|
color: Colors.white,
|
|
fontSize: 13,
|
|
fontWeight: FontWeight.bold,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
if (isLoading) const Center(child: CircularProgressIndicator()),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|