feat: add device authentication, place gates support, and PiP navigation features

This commit is contained in:
Hamza-Ayed
2026-09-19 12:34:26 +03:00
parent 55830beee8
commit ce896df30a
86 changed files with 3414 additions and 341 deletions
@@ -13,6 +13,7 @@ class ApiConstants {
static const String mapSaasPlaces = 'https://map-saas.intaleqapp.com/api/geocoding/places';
static const String mapSaasTelemetry = 'https://map-saas.intaleqapp.com/api/telemetry';
static const String mapSaasStyleBase = 'https://map-saas.intaleqapp.com/api/maps/style.json';
static const String mapSaasDeviceProvision = 'https://map-saas.intaleqapp.com/api/auth/device-provision';
static const String googlePlacesNearby = 'https://maps.googleapis.com/maps/api/place/nearbysearch/json';
// 50 km Radius Threshold (Strictly as specified)
@@ -20,6 +20,10 @@ class AppColors {
static const Color tacticalNavy = Color(0xFF0B192C);
static const Color tacticalEmerald = Color(0xFF059669);
static const Color sovereignGold = Color(0xFFD97706);
static const Color urukGold = Color(0xFFD4AF37);
static const Color urukGoldLight = Color(0xFFFFF9E6);
static const Color urukGoldDark = Color(0xFF8C6B1C);
static const Color urukGoldBorder = Color(0x40D4AF37);
static const Color coralDanger = Color(0xFFDC2626);
// Borders & Dividers
@@ -0,0 +1,290 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:intaleq_maps/intaleq_maps.dart';
/// Parsed destination target from an external deep link or navigation intent
class DeepLinkTarget {
final LatLng destination;
final String title;
final bool autoStartNavigation;
DeepLinkTarget({
required this.destination,
required this.title,
this.autoStartNavigation = false,
});
@override
String toString() =>
'DeepLinkTarget(dest: ${destination.latitude},${destination.longitude}, title: $title, autoNav: $autoStartNavigation)';
}
class DeepLinkService {
DeepLinkService._();
static final DeepLinkService instance = DeepLinkService._();
static const _channel = MethodChannel('com.siro.siro_maps/deep_link');
final _targetController = StreamController<DeepLinkTarget>.broadcast();
Stream<DeepLinkTarget> get targetStream => _targetController.stream;
DeepLinkTarget? _initialTarget;
DeepLinkTarget? get initialTarget => _initialTarget;
bool _initialized = false;
void init() {
if (_initialized) return;
_initialized = true;
_channel.setMethodCallHandler(_handleMethodCall);
_checkInitialLink();
}
void clearInitialTarget() {
_initialTarget = null;
}
Future<void> _checkInitialLink() async {
try {
final String? initialLink = await _channel.invokeMethod<String>('getInitialLink');
if (initialLink != null && initialLink.isNotEmpty) {
final target = parseUri(initialLink);
if (target != null) {
_initialTarget = target;
_targetController.add(target);
}
}
} catch (e) {
debugPrint('⚠️ [DeepLinkService] Failed to get initial link: $e');
}
}
Future<dynamic> _handleMethodCall(MethodCall call) async {
if (call.method == 'onDeepLink') {
final String? url = call.arguments['url']?.toString();
if (url != null && url.isNotEmpty) {
final target = parseUri(url);
if (target != null) {
_targetController.add(target);
}
}
}
}
/// Universal parser supporting:
/// - geo:31.95,35.91
/// - geo:31.95,35.91?q=31.95,35.91(City%20Mall)
/// - geo:0,0?q=31.95,35.91
/// - siromaps://navigate?lat=31.95&lng=35.91&title=...
/// - siromaps://route?dlat=31.95&dlng=35.91&title=...
/// - google.navigation:q=31.95,35.91
/// - https://maps.google.com/?q=31.95,35.91
/// - https://www.google.com/maps/dir/?destination=31.95,35.91
/// - https://maps.siro.app/navigate?lat=31.95&lng=35.91
DeepLinkTarget? parseUri(String uriString) {
try {
final raw = uriString.trim();
if (raw.isEmpty) return null;
// ── 1. Standard "geo:" URI (Android / WhatsApp / SMS / taxi apps) ──
if (raw.startsWith('geo:')) {
return _parseGeoUri(raw);
}
// ── 2. "google.navigation:" URI ──
if (raw.startsWith('google.navigation:')) {
return _parseGoogleNavigationUri(raw);
}
// ── 3. Custom Scheme "siromaps://" or "https://" ──
final uri = _safeParseUri(raw);
if (uri == null) return null;
final scheme = uri.scheme.toLowerCase();
if (scheme == 'siromaps') {
final bool isNavigate = uri.host == 'navigate' || uri.path == '/navigate';
final latStr = uri.queryParameters['lat'] ?? uri.queryParameters['dlat'];
final lngStr = uri.queryParameters['lng'] ?? uri.queryParameters['dlng'];
final title = uri.queryParameters['title'] ??
uri.queryParameters['label'] ??
uri.queryParameters['dname'] ??
'وجهة محددة';
if (latStr != null && lngStr != null) {
final lat = double.tryParse(latStr);
final lng = double.tryParse(lngStr);
if (lat != null && lng != null && _isValidCoordinate(lat, lng)) {
return DeepLinkTarget(
destination: LatLng(lat, lng),
title: _safeDecode(title),
autoStartNavigation: isNavigate,
);
}
}
}
// ── 4. Web URLs (Google Maps / Siro Web Links) ──
if (scheme == 'http' || scheme == 'https') {
// e.g. maps.siro.app or map-saas.intaleqapp.com
if (uri.host.contains('siro') || uri.host.contains('intaleqapp')) {
final isNavigate = uri.path.contains('navigate');
final latStr = uri.queryParameters['lat'] ?? uri.queryParameters['dlat'];
final lngStr = uri.queryParameters['lng'] ?? uri.queryParameters['dlng'];
final title = uri.queryParameters['title'] ?? uri.queryParameters['label'] ?? 'وجهة محددة';
if (latStr != null && lngStr != null) {
final lat = double.tryParse(latStr);
final lng = double.tryParse(lngStr);
if (lat != null && lng != null && _isValidCoordinate(lat, lng)) {
return DeepLinkTarget(
destination: LatLng(lat, lng),
title: _safeDecode(title),
autoStartNavigation: isNavigate,
);
}
}
}
// e.g. maps.google.com or google.com/maps
if (uri.host.contains('google.com') || uri.host.contains('goo.gl')) {
final q = uri.queryParameters['q'] ??
uri.queryParameters['destination'] ??
uri.queryParameters['daddr'];
if (q != null) {
final coords = _extractLatLngFromString(q);
if (coords != null) {
return DeepLinkTarget(
destination: coords,
title: 'وجهة من خرائط جوجل',
autoStartNavigation: uri.queryParameters.containsKey('dirflg') ||
uri.path.contains('dir'),
);
}
}
}
}
} catch (e) {
debugPrint('⚠️ [DeepLinkService] Parse error on "$uriString": $e');
}
return null;
}
DeepLinkTarget? _parseGeoUri(String raw) {
final withoutScheme = raw.substring(4); // remove "geo:"
final parts = withoutScheme.split('?');
final baseCoords = parts[0].trim();
String? queryPart = parts.length > 1 ? parts[1] : null;
LatLng? destination;
String title = 'وجهة محددة';
// 1. Check if queryPart has q=...
if (queryPart != null) {
final qIndex = queryPart.indexOf('q=');
if (qIndex != -1) {
String qVal = queryPart.substring(qIndex + 2);
final ampIndex = qVal.indexOf('&');
if (ampIndex != -1) qVal = qVal.substring(0, ampIndex);
// Check if label is in parentheses: e.g. 31.95,35.91(City Mall)
final parenStart = qVal.indexOf('(');
final parenEnd = qVal.lastIndexOf(')');
if (parenStart != -1 && parenEnd != -1 && parenEnd > parenStart) {
final label = qVal.substring(parenStart + 1, parenEnd);
title = _safeDecode(label);
qVal = qVal.substring(0, parenStart);
}
final coords = _extractLatLngFromString(qVal);
if (coords != null) {
destination = coords;
}
}
}
// 2. If no valid coordinates in query, check base coords (e.g. geo:31.95,35.91)
if (destination == null && baseCoords.isNotEmpty) {
final coords = _extractLatLngFromString(baseCoords);
if (coords != null && (coords.latitude != 0.0 || coords.longitude != 0.0)) {
destination = coords;
}
}
if (destination != null) {
return DeepLinkTarget(
destination: destination,
title: title,
autoStartNavigation: false,
);
}
return null;
}
DeepLinkTarget? _parseGoogleNavigationUri(String raw) {
// google.navigation:q=31.95,35.91&mode=d
final qIndex = raw.indexOf('q=');
if (qIndex == -1) return null;
var qVal = raw.substring(qIndex + 2);
final ampIndex = qVal.indexOf('&');
if (ampIndex != -1) qVal = qVal.substring(0, ampIndex);
final coords = _extractLatLngFromString(qVal);
if (coords != null) {
return DeepLinkTarget(
destination: coords,
title: 'وجهة ملاحة',
autoStartNavigation: true, // navigation intent explicitly requests starting directions
);
}
return null;
}
LatLng? _extractLatLngFromString(String text) {
try {
final clean = text.trim();
final split = clean.split(',');
if (split.length >= 2) {
final lat = double.tryParse(split[0].trim());
final lng = double.tryParse(split[1].trim());
if (lat != null && lng != null && _isValidCoordinate(lat, lng)) {
return LatLng(lat, lng);
}
}
} catch (_) {}
return null;
}
bool _isValidCoordinate(double lat, double lng) {
return lat >= -90.0 && lat <= 90.0 && lng >= -180.0 && lng <= 180.0;
}
static String _safeDecode(String text) {
var s = text.replaceAll('+', ' ').replaceAll('%20', ' ');
try {
return Uri.decodeComponent(s);
} catch (_) {
try {
return Uri.decodeFull(s);
} catch (_) {
return s;
}
}
}
static Uri? _safeParseUri(String raw) {
final clean = raw.trim();
if (clean.isEmpty) return null;
try {
return Uri.parse(clean);
} catch (_) {
try {
return Uri.parse(Uri.encodeFull(clean));
} catch (_) {
return null;
}
}
}
}
@@ -0,0 +1,253 @@
import 'dart:convert';
import 'dart:io';
import 'package:crypto/crypto.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:http/http.dart' as http;
import 'package:shared_preferences/shared_preferences.dart';
import '../constants/api_constants.dart';
/// Frictionless User Identity & Authentication via Hardware Device Fingerprint.
/// Generates a deterministic, hardware-backed identity and provisions a dedicated,
/// isolated consumer API key from the MapSaaS backend.
/// Saves credentials securely in FlutterSecureStorage (Keychain / Keystore).
class DeviceFingerprintService {
DeviceFingerprintService._();
static final DeviceFingerprintService instance = DeviceFingerprintService._();
static const MethodChannel _hardwareChannel =
MethodChannel('com.siro.siro_maps/device_hardware');
static const String _secureKeyApiKey = 'siro_provisioned_api_key';
static const String _secureKeyFingerprint = 'siro_hardware_fingerprint';
static const String _secureKeyPlan = 'siro_provisioned_plan';
final FlutterSecureStorage _secureStorage = const FlutterSecureStorage(
aOptions: AndroidOptions(),
iOptions: IOSOptions(accessibility: KeychainAccessibility.first_unlock),
);
String? _fingerprintId;
String? _provisionedApiKey;
String? _plan;
int _rateLimit = 60;
String? _hardwareId;
String? _deviceModel;
String? _deviceBrand;
String? _osVersion;
bool _isInitialized = false;
String get fingerprintId =>
_fingerprintId ?? 'siro_${Platform.operatingSystem}_anonymous';
String get shortFingerprint {
if (_fingerprintId == null) return 'GUEST';
final parts = _fingerprintId!.split('_');
final raw = parts.length > 2 ? parts.last : _fingerprintId!;
return raw.length > 8 ? raw.substring(0, 8).toUpperCase() : raw.toUpperCase();
}
/// Active API Key: Dedicated per-device key if provisioned, or the embedded fallback key.
String get activeApiKey => _provisionedApiKey ?? ApiConstants.mapSaasKey;
bool get isDedicatedKeyActive => _provisionedApiKey != null && _provisionedApiKey!.isNotEmpty;
String? get deviceModel => _deviceModel;
String? get deviceBrand => _deviceBrand;
String? get osVersion => _osVersion;
String? get plan => _plan;
int get rateLimit => _rateLimit;
/// Initializes hardware device extraction, reads secure storage, and provisions dedicated key.
Future<void> init({http.Client? httpClient}) async {
if (_isInitialized) return;
try {
// 1. Extract physical hardware telemetry
await _extractHardwareTelemetry();
// 2. Read stored API Key & Fingerprint from secure storage (with SharedPreferences fallback)
await _loadPersistedCredentials();
// 3. If no dedicated API key exists, provision one from MapSaaS backend
if (_provisionedApiKey == null || _provisionedApiKey!.isEmpty) {
await provisionDedicatedApiKey(httpClient: httpClient);
}
_isInitialized = true;
debugPrint('🔑 [DeviceFingerprintService] Hardware Fingerprint: $fingerprintId');
debugPrint('🛡️ [DeviceFingerprintService] Active Key: ${activeApiKey.substring(0, 10)}... (Dedicated: $isDedicatedKeyActive)');
} catch (e) {
debugPrint('⚠️ [DeviceFingerprintService] Initialization error: $e');
_fingerprintId ??= 'siro_${Platform.operatingSystem}_fallback_${DateTime.now().millisecondsSinceEpoch}';
}
}
/// Extracts deterministic physical device information (Hardware ID, Board, Brand, Model)
Future<void> _extractHardwareTelemetry() async {
String hardwareSeed = '';
try {
if (Platform.isAndroid || Platform.isIOS) {
final dynamic rawInfo =
await _hardwareChannel.invokeMethod('getHardwareInfo');
if (rawInfo is Map) {
final nativeInfo = Map<String, dynamic>.from(rawInfo);
_hardwareId = nativeInfo['hardwareId']?.toString();
_deviceModel = nativeInfo['model']?.toString();
_deviceBrand = nativeInfo['brand']?.toString();
final manufacturer = nativeInfo['manufacturer']?.toString() ?? '';
final board = nativeInfo['board']?.toString() ?? '';
final hardware = nativeInfo['hardware']?.toString() ?? '';
if (Platform.isAndroid) {
// Android Pure Hardware Seed: ANDROID_ID + Brand + Manufacturer + Model + Hardware + Board
hardwareSeed = '${_hardwareId}_${_deviceBrand}_${manufacturer}_${_deviceModel}_${hardware}_$board';
} else {
// iOS Pure Hardware Seed: identifierForVendor + Machine Architecture
hardwareSeed = '${_hardwareId}_$_deviceModel';
}
}
}
} catch (e) {
debugPrint('ℹ️ [DeviceFingerprintService] Native hardware channel: $e');
}
if (hardwareSeed.isEmpty) {
// Fallback for macOS, desktop, or headless unit tests
hardwareSeed = 'device_${Platform.operatingSystem}_${Platform.localHostname}';
}
final digest = sha256.convert(utf8.encode(hardwareSeed)).toString();
_fingerprintId = 'siro_${Platform.operatingSystem}_hw_${digest.substring(0, 24)}';
}
/// Loads credentials from FlutterSecureStorage with fallback to SharedPreferences
Future<void> _loadPersistedCredentials() async {
try {
final savedFp = await _readSecure(_secureKeyFingerprint);
final savedKey = await _readSecure(_secureKeyApiKey);
final savedPlan = await _readSecure(_secureKeyPlan);
if (savedFp != null && savedFp.isNotEmpty) {
_fingerprintId = savedFp;
}
if (savedKey != null && savedKey.isNotEmpty) {
_provisionedApiKey = savedKey;
}
if (savedPlan != null && savedPlan.isNotEmpty) {
_plan = savedPlan;
}
} catch (e) {
debugPrint('⚠️ [DeviceFingerprintService] SecureStorage read error, using local fallback: $e');
}
}
/// Contacts MapSaaS Backend to provision a dedicated mobile consumer API key
Future<bool> provisionDedicatedApiKey({http.Client? httpClient}) async {
final client = httpClient ?? http.Client();
final url = Uri.parse(ApiConstants.mapSaasDeviceProvision);
final payload = {
'deviceFingerprint': fingerprintId,
'hardwareId': _hardwareId,
'brand': _deviceBrand,
'model': _deviceModel,
'platform': Platform.operatingSystem,
'osVersion': _osVersion,
'appVersion': '2.4.0-pro',
};
try {
debugPrint('🛰️ [DeviceFingerprintService] Provisioning dedicated key from $url...');
final response = await client
.post(
url,
headers: {'Content-Type': 'application/json'},
body: jsonEncode(payload),
)
.timeout(const Duration(seconds: 8));
if (response.statusCode == 200 || response.statusCode == 201) {
final data = jsonDecode(response.body) as Map<String, dynamic>;
final key = data['apiKey'] as String?;
final plan = data['plan'] as String?;
final rate = data['rateLimit'] as int?;
if (key != null && key.isNotEmpty) {
_provisionedApiKey = key;
_plan = plan ?? 'FREE';
_rateLimit = rate ?? 60;
// Save into FlutterSecureStorage
await _writeSecure(_secureKeyApiKey, key);
await _writeSecure(_secureKeyFingerprint, fingerprintId);
await _writeSecure(_secureKeyPlan, _plan!);
debugPrint('✅ [DeviceFingerprintService] Dedicated key provisioned successfully: $key (Rate: $_rateLimit req/min)');
return true;
}
} else {
debugPrint('⚠️ [DeviceFingerprintService] Provisioning returned status ${response.statusCode}: ${response.body}');
}
} catch (e) {
debugPrint('⚠️ [DeviceFingerprintService] Provisioning failed (offline or network error): $e');
debugPrint('ℹ️ [DeviceFingerprintService] Operating with resilient embedded key fallback.');
} finally {
if (httpClient == null) {
client.close();
}
}
return false;
}
/// Safe helper to read from FlutterSecureStorage with fallback
Future<String?> _readSecure(String key) async {
try {
return await _secureStorage.read(key: key);
} catch (_) {
final prefs = await SharedPreferences.getInstance();
return prefs.getString(key);
}
}
/// Safe helper to write to FlutterSecureStorage with fallback
Future<void> _writeSecure(String key, String value) async {
try {
await _secureStorage.write(key: key, value: value);
} catch (_) {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(key, value);
}
}
/// Resets or overrides key for testing purposes
@visibleForTesting
void setMockState({
String? fingerprint,
String? apiKey,
String? plan,
int? rateLimit,
bool clearApiKey = false,
}) {
if (fingerprint != null) _fingerprintId = fingerprint;
if (clearApiKey) {
_provisionedApiKey = null;
} else if (apiKey != null) {
_provisionedApiKey = apiKey;
}
if (plan != null) _plan = plan;
if (rateLimit != null) _rateLimit = rateLimit;
_isInitialized = true;
}
/// Headers automatically injected into every MapSaaS request
Map<String, String> get headers => {
'x-device-fingerprint': fingerprintId,
'x-device-platform': Platform.operatingSystem,
'x-device-model': _deviceModel ?? 'Unknown',
'x-client-version': '2.4.0-pro',
'x-api-key': activeApiKey,
};
}
@@ -0,0 +1,92 @@
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
class PipService {
PipService._();
static final PipService instance = PipService._();
static const _channel = MethodChannel('com.siro.siro_maps/pip');
final ValueNotifier<bool> isInPipMode = ValueNotifier<bool>(false);
bool _initialized = false;
void init() {
if (_initialized) return;
_initialized = true;
if (Platform.isAndroid) {
_channel.setMethodCallHandler(_handleMethodCall);
}
}
Future<dynamic> _handleMethodCall(MethodCall call) async {
if (call.method == 'onPipChanged') {
final bool inPip = call.arguments['isInPip'] as bool? ?? false;
isInPipMode.value = inPip;
debugPrint('📺 [PipService] Picture-in-Picture mode changed: $inPip');
}
}
/// Check if Picture-in-Picture mode is supported (native on Android, in-app floating HUD on iOS)
Future<bool> isPipSupported() async {
if (Platform.isAndroid) {
try {
final bool? supported = await _channel.invokeMethod<bool>('isPipSupported');
return supported ?? true;
} catch (_) {
return true;
}
}
// iOS and other platforms support in-app floating PiP HUD
return true;
}
/// Programmatically enter Picture-in-Picture mode
/// On Android: triggers native OS activity PiP window.
/// On iOS / others: triggers responsive in-app floating Mini-HUD.
Future<bool> enterPictureInPicture() async {
if (Platform.isAndroid) {
try {
final bool? success = await _channel.invokeMethod<bool>('enterPictureInPicture');
if (success == true) {
isInPipMode.value = true;
return true;
}
} catch (e) {
debugPrint('⚠️ [PipService] Native Android PiP failed, falling back to In-App PiP: $e');
}
}
// On iOS and graceful fallback: activate In-App PiP mode
isInPipMode.value = true;
debugPrint('📺 [PipService] In-App PiP mode activated');
return true;
}
/// Exit Picture-in-Picture mode and restore full HUD
Future<void> exitPictureInPicture() async {
isInPipMode.value = false;
debugPrint('📺 [PipService] Exited PiP mode');
}
/// Toggle Picture-in-Picture mode
Future<void> togglePip() async {
if (isInPipMode.value) {
await exitPictureInPicture();
} else {
await enterPictureInPicture();
}
}
/// Inform native platform whether turn-by-turn navigation is currently active
/// (enables automatic PiP on home swipe in Android 12+)
Future<void> setNavigating(bool isNavigating) async {
if (Platform.isAndroid) {
try {
await _channel.invokeMethod('setNavigating', {'isNavigating': isNavigating});
} catch (_) {}
}
}
}
@@ -48,4 +48,39 @@ class ArabicSearchNormalizer {
return false;
}
static final Set<String> _categoryKeywords = {
// Single-word categories
'مسجد', 'جامع', 'مصلى', 'صلاة',
'مطعم', 'مطاعم', 'شاورما', 'مشاوي', 'سناك', 'فلافل', 'برغر',
'دكان', 'دكاكين', 'سوبرماركت', 'ماركت', 'بقالة', 'بقال', 'تموين', 'هايبر',
'مخبز', 'مخابز', 'افران', 'فرن', 'معجنات', 'حلويات', 'كعك',
'كافيه', 'كوفي', 'مقهى', 'قهوة', 'كافتيريا',
'صيدلية', 'صيدليات', 'دواء',
'مستشفى', 'مستشفيات', 'عيادة', 'عيادات',
'كازية', 'بنزين', 'وقود', 'غاز',
'بنك', 'بنوك', 'صراف', 'مصرف',
'مدرسة', 'مدارس', 'جامعة', 'جامعات', 'كلية', 'روضة',
'فندق', 'فنادق', 'منتجع',
// Multi-word compound category phrases
'محطة بنزين',
'محطة وقود',
'محطة غاز',
'سوبر ماركت',
'ميني ماركت',
'شقق فندقية',
'مركز صحي',
'وجبات سريعة',
'صراف الي',
'كوفي شوب',
}.map((k) => normalize(k)).toSet();
/// Returns true if the query is asking for a general POI category
/// (e.g. "مطعم", "مخبز", "سوبرماركت", "محطة بنزين") rather than a specific proper name (e.g. "مطعم هاشم", "سيتي مول").
static bool isCategoryQuery(String query) {
final norm = normalize(query);
if (norm.isEmpty) return false;
return _categoryKeywords.contains(norm);
}
}
@@ -0,0 +1,39 @@
class PlaceGate {
final String nameAr;
final String? nameEn;
final double latitude;
final double longitude;
final bool isMainGate;
const PlaceGate({
required this.nameAr,
this.nameEn,
required this.latitude,
required this.longitude,
this.isMainGate = false,
});
factory PlaceGate.fromJson(Map<String, dynamic> json) {
return PlaceGate(
nameAr: json['name_ar']?.toString() ?? json['gate_name_ar']?.toString() ?? 'بوابة',
nameEn: json['name_en']?.toString() ?? json['gate_name_en']?.toString(),
latitude: (json['latitude'] as num?)?.toDouble() ??
(json['lat'] as num?)?.toDouble() ??
double.tryParse(json['latitude']?.toString() ?? '0.0') ??
0.0,
longitude: (json['longitude'] as num?)?.toDouble() ??
(json['lng'] as num?)?.toDouble() ??
double.tryParse(json['longitude']?.toString() ?? '0.0') ??
0.0,
isMainGate: json['is_main_gate'] as bool? ?? false,
);
}
Map<String, dynamic> toJson() => {
'name_ar': nameAr,
if (nameEn != null) 'name_en': nameEn,
'latitude': latitude,
'longitude': longitude,
'is_main_gate': isMainGate,
};
}
@@ -1,3 +1,5 @@
import 'place_gate.dart';
class PlaceModel {
final String id;
final String name;
@@ -7,6 +9,7 @@ class PlaceModel {
final double elevationMeters; // GPS Altitude AMSL in meters (defaults to 0.0)
final double? distanceKm;
final String? address;
final List<PlaceGate> gates;
PlaceModel({
required this.id,
@@ -17,8 +20,11 @@ class PlaceModel {
this.elevationMeters = 0.0,
this.distanceKm,
this.address,
this.gates = const [],
});
bool get hasGates => gates.isNotEmpty;
factory PlaceModel.fromJson(Map<String, dynamic> json) {
final rawElev = json['elevation_meters'] ?? json['elevation'] ?? json['altitude'] ?? 0.0;
double elev = 0.0;
@@ -28,6 +34,14 @@ class PlaceModel {
elev = double.tryParse(rawElev.toString()) ?? 0.0;
}
final rawGates = json['gates'];
List<PlaceGate> parsedGates = [];
if (rawGates is List) {
parsedGates = rawGates
.map((g) => PlaceGate.fromJson(Map<String, dynamic>.from(g)))
.toList();
}
return PlaceModel(
id: json['id']?.toString() ?? '',
name: json['name']?.toString() ?? '',
@@ -39,6 +53,7 @@ class PlaceModel {
? (json['distanceKm'] as num).toDouble()
: null,
address: json['address']?.toString() ?? json['neighborhood']?.toString(),
gates: parsedGates,
);
}
@@ -52,6 +67,7 @@ class PlaceModel {
'elevation_meters': elevationMeters,
'altitude': elevationMeters,
if (address != null) 'address': address,
if (gates.isNotEmpty) 'gates': gates.map((g) => g.toJson()).toList(),
};
}
}
@@ -3,6 +3,7 @@ import 'package:flutter/foundation.dart';
import 'package:http/http.dart' as http;
import 'package:intaleq_maps/intaleq_maps.dart';
import '../../core/constants/api_constants.dart';
import '../../core/services/device_fingerprint_service.dart';
import '../../core/utils/polyline_decoder.dart';
import '../../core/utils/arabic_search_normalizer.dart';
import '../../core/services/location_service.dart';
@@ -15,6 +16,12 @@ class MapSaasRepository {
MapSaasRepository({http.Client? client}) : client = client ?? http.Client();
Map<String, String> get _baseHeaders => {
'Content-Type': 'application/json',
'x-api-key': DeviceFingerprintService.instance.activeApiKey,
...DeviceFingerprintService.instance.headers,
};
/// Fetch primary and alternative routes from MapSaaS
Future<List<RouteData>> getRoute({
required LatLng origin,
@@ -43,7 +50,7 @@ class MapSaasRepository {
try {
final response = await client.get(
saasUri,
headers: {'x-api-key': ApiConstants.mapSaasKey},
headers: _baseHeaders,
);
print("📥 [MapSaasRepo] Route response HTTP status: ${response.statusCode} (${response.body.length} bytes)");
@@ -165,7 +172,7 @@ class MapSaasRepository {
final uri = Uri.parse(ApiConstants.mapSaasSearch).replace(queryParameters: queryParams);
final response = await client.get(
uri,
headers: {'x-api-key': ApiConstants.mapSaasKey},
headers: _baseHeaders,
);
if (response.statusCode == 200) {
@@ -232,14 +239,12 @@ class MapSaasRepository {
return distM <= ApiConstants.maxSearchRadiusMeters;
}).toList();
// 4. Smart Ranking: exact text match priority + proximity ascending
final bool categorySearch = ArabicSearchNormalizer.isCategoryQuery(query);
// 4. Smart Ranking:
// - For category searches (e.g. مطعم, مخبز, دكان, مسجد, سوبرماركت), PROXIMITY dominates (closest first).
// - For landmark/name searches (e.g. المدينة الطبية, سيتي مول), exact name match takes precedence, then proximity.
filteredPlaces.sort((a, b) {
final matchA = ArabicSearchNormalizer.matches(a.name, normalizedQuery);
final matchB = ArabicSearchNormalizer.matches(b.name, normalizedQuery);
if (matchA && !matchB) return -1;
if (!matchA && matchB) return 1;
final distA = LocationService.instance.calculateDistance(
center,
LatLng(a.latitude, a.longitude),
@@ -248,6 +253,17 @@ class MapSaasRepository {
center,
LatLng(b.latitude, b.longitude),
);
if (categorySearch) {
return distA.compareTo(distB);
}
final matchA = ArabicSearchNormalizer.matches(a.name, normalizedQuery);
final matchB = ArabicSearchNormalizer.matches(b.name, normalizedQuery);
if (matchA && !matchB) return -1;
if (!matchA && matchB) return 1;
return distA.compareTo(distB);
});
@@ -267,7 +283,7 @@ class MapSaasRepository {
final response = await client.post(
uri,
headers: {
'x-api-key': ApiConstants.mapSaasKey,
..._baseHeaders,
'Content-Type': 'application/json',
},
body: jsonEncode({
@@ -308,15 +324,15 @@ class MapSaasRepository {
'driver_id': driverId,
'latitude': latitude,
'longitude': longitude,
'speed': speed,
'heading': heading,
'speed': speed < 0 ? 0.0 : speed,
'heading': heading < 0 ? 0.0 : heading,
'distance': distance,
'elevation': elevation,
};
final response = await client.post(
uri,
headers: {
'x-api-key': ApiConstants.mapSaasKey,
..._baseHeaders,
'Content-Type': 'application/json',
},
body: jsonEncode(payload),
@@ -12,6 +12,7 @@ import '../../../core/constants/app_colors.dart';
import '../../../core/services/car_platform_bridge.dart';
import '../../../core/services/connectivity_service.dart';
import '../../../core/services/location_service.dart';
import '../../../core/services/pip_service.dart';
import '../../../core/services/tts_service.dart';
import '../../../core/services/vehicle_icon_generator.dart';
import 'package:shared_preferences/shared_preferences.dart';
@@ -770,6 +771,7 @@ class NavigationCubit extends Cubit<NavigationState> {
maneuver: initialModifier,
isNavigating: true,
);
PipService.instance.setNavigating(true);
}
void stopNavigation() {
@@ -778,6 +780,7 @@ class NavigationCubit extends Cubit<NavigationState> {
_movementInterpolationTimer = null;
ttsService.stop();
CarPlatformBridge.stopNavigation();
PipService.instance.setNavigating(false);
_lastTraveledIndexInFullRoute = 0;
_offRouteStartTime = null;
_hasAnnouncedEarlyStepIndex = null;
@@ -118,6 +118,16 @@ class NavigationState extends Equatable {
return '$minutes دقيقة';
}
String get formattedDistanceToStep {
if (distanceToNextStep >= 1000) {
return '${(distanceToNextStep / 1000).toStringAsFixed(1)} كم';
}
if (distanceToNextStep > 0) {
return '${distanceToNextStep.round()} م';
}
return '';
}
NavigationState copyWith({
NavigationStatus? status,
LatLng? myLocation,
+10 -2
View File
@@ -3,14 +3,22 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:intaleq_maps/intaleq_maps.dart';
import 'core/services/deep_link_service.dart';
import 'core/services/device_fingerprint_service.dart';
import 'core/services/pip_service.dart';
import 'core/theme/app_theme.dart';
import 'data/repositories/map_saas_repository.dart';
import 'logic/cubits/navigation/navigation_cubit.dart';
import 'views/splash/splash_view.dart';
void main() {
void main() async {
WidgetsFlutterBinding.ensureInitialized();
// Initialize native bridges (Deep Linking, Picture-in-Picture & Device Fingerprint)
DeepLinkService.instance.init();
PipService.instance.init();
await DeviceFingerprintService.instance.init();
// Purge any stuck background offline download tasks from device cache
unawaited(IntaleqOfflineService.instance.clearCache());
@@ -47,7 +55,7 @@ class SiroMapsApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'خرائط سيرو - Siro Maps',
title: 'خرائط أوروك - Uruk Map',
debugShowCheckedModeBanner: false,
theme: AppTheme.lightTheme,
locale: const Locale('ar', 'JO'),
+161 -23
View File
@@ -1,3 +1,4 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
@@ -5,15 +6,21 @@ import 'package:intaleq_maps/intaleq_maps.dart';
import '../../core/constants/api_constants.dart';
import '../../core/constants/app_colors.dart';
import '../../core/services/deep_link_service.dart';
import '../../core/services/pip_service.dart';
import '../../logic/cubits/navigation/navigation_cubit.dart';
import '../../logic/cubits/navigation/navigation_state.dart';
import '../../data/models/place_model.dart';
import 'widgets/search_bar_widget.dart';
import 'widgets/explore_panel_widget.dart';
import 'widgets/active_nav_hud_widget.dart';
import 'widgets/pip_nav_hud_widget.dart';
import 'widgets/place_gates_sheet.dart';
import 'widgets/layer_selector_sheet.dart';
import 'widgets/report_hazard_sheet.dart';
import 'widgets/add_place_sheet.dart';
import 'widgets/vehicle_customizer_sheet.dart';
import 'widgets/about_awards_sheet.dart';
class MapView extends StatefulWidget {
const MapView({super.key});
@@ -26,6 +33,7 @@ class _MapViewState extends State<MapView> {
final TextEditingController _searchController = TextEditingController();
final FocusNode _searchFocusNode = FocusNode();
bool _isSearchFocused = false;
StreamSubscription<DeepLinkTarget>? _deepLinkSub;
@override
void initState() {
@@ -34,6 +42,21 @@ class _MapViewState extends State<MapView> {
_searchFocusNode.addListener(() {
if (mounted) setState(() => _isSearchFocused = _searchFocusNode.hasFocus);
});
// Listen to deep links from external apps (geo:, siromaps://, google.navigation, etc.)
_deepLinkSub = DeepLinkService.instance.targetStream.listen((target) {
if (mounted) _handleDeepLinkTarget(target);
});
// Check if a deep link arrived while the app was cold starting
final initial = DeepLinkService.instance.initialTarget;
if (initial != null) {
DeepLinkService.instance.clearInitialTarget();
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) _handleDeepLinkTarget(initial);
});
}
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) {
print("📌 [MapView] PostFrameCallback: Triggering relockCameraToUser");
@@ -43,8 +66,26 @@ class _MapViewState extends State<MapView> {
});
}
Future<void> _handleDeepLinkTarget(DeepLinkTarget target) async {
print("🔗 [MapView] Handling DeepLinkTarget: $target");
final cubit = context.read<NavigationCubit>();
int retries = 0;
while (cubit.state.myLocation == null && retries < 15 && mounted) {
await Future.delayed(const Duration(milliseconds: 200));
retries++;
}
if (!mounted) return;
if (cubit.state.myLocation != null) {
await cubit.calculateRouteTo(target.destination, title: target.title);
if (target.autoStartNavigation && mounted) {
cubit.startNavigation();
}
}
}
@override
void dispose() {
_deepLinkSub?.cancel();
_searchController.dispose();
_searchFocusNode.dispose();
super.dispose();
@@ -66,6 +107,15 @@ class _MapViewState extends State<MapView> {
);
}
void _showAboutAwardsSheet(BuildContext context) {
showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (_) => const AboutAwardsSheet(),
);
}
void _showLayerSelector(BuildContext context, NavigationCubit cubit, MapThemeType current) {
showModalBottomSheet(
context: context,
@@ -80,6 +130,10 @@ class _MapViewState extends State<MapView> {
Navigator.of(context).pop();
_showVehicleCustomizer(context, cubit, cubit.state);
},
onOpenAboutAwards: () {
Navigator.of(context).pop();
_showAboutAwardsSheet(context);
},
),
);
}
@@ -151,6 +205,32 @@ class _MapViewState extends State<MapView> {
});
}
void _showPlaceGatesSheet(BuildContext context, NavigationCubit cubit, PlaceModel place) {
showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (_) => PlaceGatesSheet(
place: place,
userLocation: cubit.state.myLocation,
onSelectGate: (gate) {
Navigator.of(context).pop();
cubit.calculateRouteTo(
LatLng(gate.latitude, gate.longitude),
title: '${place.name} - ${gate.nameAr}',
);
},
onSelectMainPlace: () {
Navigator.of(context).pop();
cubit.calculateRouteTo(
LatLng(place.latitude, place.longitude),
title: place.name,
);
},
),
);
}
@override
Widget build(BuildContext context) {
final cubit = context.read<NavigationCubit>();
@@ -212,21 +292,24 @@ class _MapViewState extends State<MapView> {
}
},
builder: (context, state) {
return Scaffold(
resizeToAvoidBottomInset: false,
backgroundColor: AppColors.canvasLight,
body: SizedBox.expand(
child: Stack(
fit: StackFit.expand,
children: [
// ── 1. REAL INTERACTIVE MAP ENGINE (Siro Real Tiles) ──
Positioned.fill(
child: _buildRealMapEngine(context, cubit, state),
),
return ValueListenableBuilder<bool>(
valueListenable: PipService.instance.isInPipMode,
builder: (context, isInPip, _) {
return Scaffold(
resizeToAvoidBottomInset: false,
backgroundColor: AppColors.canvasLight,
body: SizedBox.expand(
child: Stack(
fit: StackFit.expand,
children: [
// ── 1. REAL INTERACTIVE MAP ENGINE (Siro Real Tiles) ──
Positioned.fill(
child: _buildRealMapEngine(context, cubit, state),
),
// ── 2. TOP SEARCH BAR, OFFLINE BANNER & EXPLORE CHIPS ──
if (!state.isNavigating)
Positioned(
// ── 2. TOP SEARCH BAR, OFFLINE BANNER & EXPLORE CHIPS ──
if (!state.isNavigating && !isInPip)
Positioned(
top: 0,
left: 0,
right: 0,
@@ -426,6 +509,35 @@ class _MapViewState extends State<MapView> {
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
if (place.hasGates) ...[
Container(
margin: const EdgeInsets.only(bottom: 4),
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: AppColors.appleBlue.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(4),
border: Border.all(
color: AppColors.appleBlue.withValues(alpha: 0.3),
width: 0.5,
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.meeting_room_rounded, size: 10, color: AppColors.appleBlue),
const SizedBox(width: 3),
Text(
'${place.gates.length} بوابات',
style: const TextStyle(
fontSize: 9,
fontWeight: FontWeight.w700,
color: AppColors.appleBlue,
),
),
],
),
),
],
if (distStr != null)
Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
@@ -460,10 +572,14 @@ class _MapViewState extends State<MapView> {
_searchController.clear();
cubit.clearSearch();
_searchFocusNode.unfocus();
cubit.calculateRouteTo(
LatLng(place.latitude, place.longitude),
title: place.name,
);
if (place.hasGates) {
_showPlaceGatesSheet(context, cubit, place);
} else {
cubit.calculateRouteTo(
LatLng(place.latitude, place.longitude),
title: place.name,
);
}
},
);
},
@@ -909,8 +1025,20 @@ class _MapViewState extends State<MapView> {
),
),
// ── 5. ACTIVE TURN-BY-TURN HUD & BANNER ──
if (state.isNavigating)
// ── 5. ACTIVE TURN-BY-TURN HUD & BANNER (OR PIP HUD) ──
if (state.isNavigating && isInPip)
Positioned(
top: 0,
left: 0,
right: 0,
child: PipNavHudWidget(
state: state,
onExpand: () => PipService.instance.exitPictureInPicture(),
onStopNavigation: cubit.stopNavigation,
),
),
if (state.isNavigating && !isInPip)
Positioned.fill(
child: SafeArea(
child: ActiveNavHudWidget(
@@ -925,7 +1053,7 @@ class _MapViewState extends State<MapView> {
),
// ── 5b. INTERACTIVE LOCATION PIN PICKER HUD (Add Place & Hazard) ──
if (state.isSelectingLocationOnMap) ...[
if (!isInPip && state.isSelectingLocationOnMap) ...[
// Centered Floating Target Pin
IgnorePointer(
child: Center(
@@ -1114,7 +1242,7 @@ class _MapViewState extends State<MapView> {
],
// ── 6. FLOATING ACTION BUTTONS (Right side, anchored to bottom) ──
if (!state.isNavigating && state.status != NavigationStatus.routePreview && !state.isSelectingLocationOnMap)
if (!isInPip && !state.isNavigating && state.status != NavigationStatus.routePreview && !state.isSelectingLocationOnMap)
Positioned(
right: 16,
bottom: 28,
@@ -1131,6 +1259,14 @@ class _MapViewState extends State<MapView> {
onTap: () => _showLayerSelector(context, cubit, state.mapTheme),
),
const SizedBox(height: 12),
// Uruk International Prize & Institutional Credentials Button
_buildFloatingCircle(
icon: Icons.workspace_premium_rounded,
color: AppColors.urukGoldDark,
tooltip: 'جائزة أوروك الدولية والسيادة المكانية',
onTap: () => _showAboutAwardsSheet(context),
),
const SizedBox(height: 12),
// Add Place Button
_buildFloatingCircle(
icon: Icons.add_location_alt_rounded,
@@ -1160,7 +1296,7 @@ class _MapViewState extends State<MapView> {
),
// ── 6b. LIVE FLOATING SPEEDOMETER (Bottom left, when driving) ──
if (!state.isNavigating && state.status != NavigationStatus.routePreview && state.speed > 3.0)
if (!isInPip && !state.isNavigating && state.status != NavigationStatus.routePreview && state.speed > 3.0)
Positioned(
left: 16,
bottom: 32,
@@ -1225,6 +1361,8 @@ class _MapViewState extends State<MapView> {
),
),
);
},
);
},
);
}
@@ -0,0 +1,503 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:google_fonts/google_fonts.dart';
import '../../../../core/constants/app_colors.dart';
class AboutAwardsSheet extends StatelessWidget {
const AboutAwardsSheet({super.key});
@override
Widget build(BuildContext context) {
return DraggableScrollableSheet(
initialChildSize: 0.88,
minChildSize: 0.5,
maxChildSize: 0.96,
builder: (context, scrollController) {
return Container(
decoration: const BoxDecoration(
color: AppColors.canvasLight,
borderRadius: BorderRadius.vertical(top: Radius.circular(28)),
boxShadow: [
BoxShadow(
color: Color(0x33000000),
blurRadius: 30,
offset: Offset(0, -6),
),
],
),
child: Column(
children: [
// Top drag bar
Center(
child: Container(
margin: const EdgeInsets.only(top: 12, bottom: 8),
width: 44,
height: 4.5,
decoration: BoxDecoration(
color: AppColors.borderGlass,
borderRadius: BorderRadius.circular(3),
),
),
),
// Scrollable content
Expanded(
child: ListView(
controller: scrollController,
padding: const EdgeInsets.fromLTRB(20, 8, 20, 36),
children: [
// ── 1. HEADER EMBLEM & TITLE ──
Center(
child: Column(
children: [
Container(
width: 110,
height: 110,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: AppColors.pureWhite,
border: Border.all(
color: AppColors.urukGold.withValues(alpha: 0.45),
width: 2,
),
boxShadow: [
BoxShadow(
color: AppColors.urukGold.withValues(alpha: 0.2),
blurRadius: 28,
offset: const Offset(0, 8),
),
],
),
padding: const EdgeInsets.all(6),
child: ClipOval(
child: Image.asset(
'assets/images/uruk_prize_logo.png',
fit: BoxFit.contain,
),
),
),
const SizedBox(height: 14),
Text(
'جائزة أوروك الدولية',
style: GoogleFonts.alexandria(
fontSize: 22,
fontWeight: FontWeight.w800,
color: AppColors.textPrimary,
),
),
const SizedBox(height: 2),
Text(
'URUK INTERNATIONAL PRIZE',
style: GoogleFonts.alexandria(
fontSize: 12,
fontWeight: FontWeight.w700,
color: AppColors.urukGoldDark,
letterSpacing: 1.5,
),
),
const SizedBox(height: 10),
Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 6),
decoration: BoxDecoration(
color: AppColors.urukGoldLight,
borderRadius: BorderRadius.circular(20),
border: Border.all(color: AppColors.urukGoldBorder),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(
Icons.workspace_premium_rounded,
size: 16,
color: AppColors.urukGoldDark,
),
const SizedBox(width: 6),
Text(
'تكريم التميز والسيادة التكنولوجية',
style: GoogleFonts.alexandria(
fontSize: 11.5,
fontWeight: FontWeight.w700,
color: AppColors.urukGoldDark,
),
),
],
),
),
],
),
),
const SizedBox(height: 24),
// ── 2. ORIGIN & GENESIS STORY ──
_buildSectionCard(
icon: Icons.history_edu_rounded,
iconColor: AppColors.urukGoldDark,
title: 'قصة المنظومة: من أوروك إلى سيادة البيانات',
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'تستلهم جائزة أوروك الدولية إرثها من حضارة أوروك العظيمة — مهد أول تدوين للرموز والتخطيط العمراني في فجر الحضارة البشرية — لتكريم المشروعات التي تصنع فارقاً استراتيجياً في العالم العربي.',
style: GoogleFonts.alexandria(
fontSize: 12.5,
height: 1.65,
fontWeight: FontWeight.w500,
color: AppColors.textPrimary,
),
),
const SizedBox(height: 10),
Text(
'من رحم هذا البرنامج وتتويجاً لتكريم جائزة أوروك، وُلدت منظومة «خرائط أوروك - Uruk Map» كبنية تحتية سيادية بديلة ومستقلة تماماً، مصممة لحماية السيادة المكانية وتوفير خرائط وملاحة ذكية مخصصة لمنطقة الشرق الأوسط وشمال أفريقيا دون الارتهان للشركات العالمية.',
style: GoogleFonts.alexandria(
fontSize: 12.5,
height: 1.65,
fontWeight: FontWeight.w500,
color: AppColors.textSecondary,
),
),
],
),
),
const SizedBox(height: 16),
// ── 3. TECH ARCHITECT & FOUNDER ──
_buildSectionCard(
icon: Icons.person_pin_circle_rounded,
iconColor: AppColors.appleBlue,
title: 'القيادة التقنية والمعمارية للمنظومة',
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
width: 44,
height: 44,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: AppColors.appleBlue.withValues(alpha: 0.1),
border: Border.all(color: AppColors.appleBlue.withValues(alpha: 0.3)),
),
child: const Icon(Icons.architecture_rounded, color: AppColors.appleBlue, size: 24),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'م. حمزة عايد | Hamza Ayed',
style: GoogleFonts.alexandria(
fontSize: 14,
fontWeight: FontWeight.w700,
color: AppColors.textPrimary,
),
),
Text(
'Founding Tech Architect & Mobility Strategist',
style: GoogleFonts.alexandria(
fontSize: 11,
fontWeight: FontWeight.w600,
color: AppColors.appleBlue,
),
),
],
),
),
],
),
const SizedBox(height: 12),
Text(
'خبير استراتيجي في حركية النقل الذكي وتأسيس منظومات البيانات الحساسة بخبرة قيادية تتجاوز 20 عاماً في إدارة العمليات والأنظمة الحرجة، والمؤسس التقني المشارك لمنصات انطلق (Intaleq)، تريبز (Tripz)، ومنظومة سيرو (Siro Platform).',
style: GoogleFonts.alexandria(
fontSize: 12,
height: 1.6,
fontWeight: FontWeight.w500,
color: AppColors.textSecondary,
),
),
],
),
),
const SizedBox(height: 16),
// ── 4. ECONOMIC MOAT & UNIT ECONOMICS ──
_buildSectionCard(
icon: Icons.account_balance_wallet_rounded,
iconColor: AppColors.tacticalEmerald,
title: 'الأثر الاقتصادي والتوسع الإقليمي',
child: Column(
children: [
_buildStatRow(
metric: '0.30\$+',
metricLabel: 'وفر مباشر في كل رحلة وطلب',
desc: 'استبدال فواتير Google Maps الباهظة بنظام ذاتي الاستضافة والتحكم الكامل.',
color: AppColors.tacticalEmerald,
),
const Divider(height: 20, color: AppColors.borderSubtle),
_buildStatRow(
metric: 'MENA',
metricLabel: 'استهداف الأسواق المحرومة من خرائط موثوقة',
desc: 'توفير ملاحة ذكية وبنية خرائط مستقلة في العراق، السودان، اليمن، سوريا، وإيران.',
color: AppColors.urukGoldDark,
),
const Divider(height: 20, color: AppColors.borderSubtle),
_buildStatRow(
metric: '100%',
metricLabel: 'سيادة رقمية وعزل جغرافي',
desc: 'خوادم متجهات وتوجيه محلي (Self-Hosted OSRM + Vector Tiles) مستقلة عن أي قيود أو واجهات أجنبية.',
color: AppColors.appleBlue,
),
const Divider(height: 20, color: AppColors.borderSubtle),
_buildStatRow(
metric: 'OFFLINE',
metricLabel: 'ملاحة ذكية كاملة دون اتصال',
desc: 'مواصلة التوجيه والانعطاف حتى في مناطق انعدام التغطية الخلوية والصحراوية.',
color: AppColors.sovereignGold,
),
],
),
),
const SizedBox(height: 16),
// ── 5. URUK EDITION APP ICON SHOWCASE ──
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: const Color(0xFF131722),
borderRadius: BorderRadius.circular(22),
border: Border.all(color: AppColors.urukGoldBorder),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.18),
blurRadius: 18,
offset: const Offset(0, 6),
),
],
),
child: Row(
children: [
Container(
width: 60,
height: 60,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(16),
border: Border.all(color: AppColors.urukGold.withValues(alpha: 0.4)),
),
child: ClipRRect(
borderRadius: BorderRadius.circular(15),
child: Image.asset(
'assets/images/siro_uruk_logo.png',
fit: BoxFit.cover,
),
),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'شعار إصدار أوروك الخاص',
style: GoogleFonts.alexandria(
fontSize: 13,
fontWeight: FontWeight.w700,
color: AppColors.pureWhite,
),
),
const SizedBox(height: 4),
Text(
'دمج جناح تمثال أوروك الذهبي مع سهم الملاحة الفضائي وخطوط الارتفاع الطبوغرافية.',
style: GoogleFonts.alexandria(
fontSize: 10.5,
color: const Color(0xFFB0B5C0),
height: 1.4,
),
),
],
),
),
],
),
),
const SizedBox(height: 24),
// ── 6. ACTIONS & SHARE ──
ElevatedButton.icon(
onPressed: () {
Clipboard.setData(const ClipboardData(text: 'https://intaleqapp.com/hamza.html'));
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text(
'تم نسخ رابط الملف التنفيذي ودراسة المنظومة بنجاح',
style: TextStyle(fontSize: 12),
),
backgroundColor: AppColors.tacticalEmerald,
duration: Duration(seconds: 2),
),
);
},
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.textPrimary,
foregroundColor: AppColors.pureWhite,
padding: const EdgeInsets.symmetric(vertical: 14),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
elevation: 0,
),
icon: const Icon(Icons.link_rounded, size: 18, color: AppColors.urukGold),
label: Text(
'نسخ رابط ملف المشروع ودراسة الحالة التنفيذية',
style: GoogleFonts.alexandria(
fontSize: 12,
fontWeight: FontWeight.w600,
),
),
),
const SizedBox(height: 10),
OutlinedButton(
onPressed: () => Navigator.of(context).pop(),
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 13),
side: const BorderSide(color: AppColors.borderSubtle),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
),
child: Text(
'إغلاق والعودة للخريطة',
style: GoogleFonts.alexandria(
fontSize: 12,
fontWeight: FontWeight.w600,
color: AppColors.textSecondary,
),
),
),
],
),
),
],
),
);
},
);
}
Widget _buildSectionCard({
required IconData icon,
required Color iconColor,
required String title,
required Widget child,
}) {
return Container(
padding: const EdgeInsets.all(18),
decoration: BoxDecoration(
color: AppColors.pureWhite,
borderRadius: BorderRadius.circular(22),
border: Border.all(color: AppColors.borderSubtle),
boxShadow: const [
BoxShadow(
color: Color(0x0A000000),
blurRadius: 16,
offset: Offset(0, 4),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
width: 32,
height: 32,
decoration: BoxDecoration(
color: iconColor.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(10),
),
child: Icon(icon, size: 18, color: iconColor),
),
const SizedBox(width: 10),
Expanded(
child: Text(
title,
style: GoogleFonts.alexandria(
fontSize: 13.5,
fontWeight: FontWeight.w700,
color: AppColors.textPrimary,
),
),
),
],
),
const SizedBox(height: 12),
child,
],
),
);
}
Widget _buildStatRow({
required String metric,
required String metricLabel,
required String desc,
required Color color,
}) {
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
constraints: const BoxConstraints(minWidth: 70),
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(10),
),
child: Center(
child: Text(
metric,
style: GoogleFonts.alexandria(
fontSize: 14,
fontWeight: FontWeight.w800,
color: color,
),
),
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
metricLabel,
style: GoogleFonts.alexandria(
fontSize: 12,
fontWeight: FontWeight.w700,
color: AppColors.textPrimary,
),
),
const SizedBox(height: 2),
Text(
desc,
style: GoogleFonts.alexandria(
fontSize: 11,
color: AppColors.textSecondary,
height: 1.4,
),
),
],
),
),
],
);
}
}
@@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import '../../../../core/constants/app_colors.dart';
import '../../../../core/services/pip_service.dart';
import '../../../../logic/cubits/navigation/navigation_state.dart';
class ActiveNavHudWidget extends StatelessWidget {
@@ -235,32 +236,39 @@ class ActiveNavHudWidget extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Row(
children: [
Text(
state.formattedRemainingDuration,
style: const TextStyle(
fontFamily: '.SF Pro Text',
fontSize: 22,
fontWeight: FontWeight.w800,
color: AppColors.tacticalEmerald,
FittedBox(
fit: BoxFit.scaleDown,
alignment: Alignment.centerRight,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
state.formattedRemainingDuration,
style: const TextStyle(
fontFamily: '.SF Pro Text',
fontSize: 20,
fontWeight: FontWeight.w800,
color: AppColors.tacticalEmerald,
),
),
),
const SizedBox(width: 8),
Text(
'• ${state.formattedRemainingDistance}',
style: const TextStyle(
fontFamily: '.SF Pro Text',
fontSize: 14,
fontWeight: FontWeight.w600,
color: AppColors.textSecondary,
const SizedBox(width: 6),
Text(
'• ${state.formattedRemainingDistance}',
style: const TextStyle(
fontFamily: '.SF Pro Text',
fontSize: 13,
fontWeight: FontWeight.w600,
color: AppColors.textSecondary,
),
),
),
],
],
),
),
const SizedBox(height: 2),
Text(
'وصول متوقع: ${state.arrivalTime}',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontFamily: '.SF Pro Text',
fontSize: 11,
@@ -273,30 +281,58 @@ class ActiveNavHudWidget extends StatelessWidget {
),
// Mute Button
IconButton(
visualDensity: VisualDensity.compact,
padding: const EdgeInsets.all(4),
constraints: const BoxConstraints(),
onPressed: onToggleMute,
icon: Icon(
state.isMuted ? Icons.volume_off_rounded : Icons.volume_up_rounded,
color: state.isMuted ? AppColors.textMuted : AppColors.appleBlue,
size: 24,
size: 22,
),
),
const SizedBox(width: 4),
// Vehicle Customizer
if (onOpenVehicleCustomizer != null)
if (onOpenVehicleCustomizer != null) ...[
IconButton(
visualDensity: VisualDensity.compact,
padding: const EdgeInsets.all(4),
constraints: const BoxConstraints(),
onPressed: onOpenVehicleCustomizer,
icon: const Icon(
Icons.directions_car_filled_rounded,
color: AppColors.appleBlue,
size: 22,
size: 21,
),
),
const SizedBox(width: 4),
],
// Picture-in-Picture Mode
IconButton(
visualDensity: VisualDensity.compact,
padding: const EdgeInsets.all(4),
constraints: const BoxConstraints(),
onPressed: () {
PipService.instance.enterPictureInPicture();
},
icon: const Icon(
Icons.picture_in_picture_alt_rounded,
color: AppColors.appleBlue,
size: 21,
),
tooltip: 'تصغير النافذة (Picture-in-Picture)',
),
const SizedBox(width: 4),
// Recenter Map
IconButton(
visualDensity: VisualDensity.compact,
padding: const EdgeInsets.all(4),
constraints: const BoxConstraints(),
onPressed: onRecenter,
icon: const Icon(
Icons.my_location_rounded,
color: AppColors.textSecondary,
size: 22,
size: 21,
),
),
const SizedBox(width: 6),
@@ -55,7 +55,7 @@ class _AddPlaceSheetState extends State<AddPlaceSheet> {
),
const SizedBox(height: 18),
const Text(
'إضافة مكان جديد إلى خرائط سيرو',
'إضافة مكان جديد إلى خرائط أوروك',
textAlign: TextAlign.center,
style: TextStyle(fontSize: 16, fontWeight: FontWeight.w700),
),
@@ -7,6 +7,8 @@ class ExplorePanelWidget extends StatelessWidget {
const ExplorePanelWidget({super.key, required this.onCategorySelected});
static const List<Map<String, dynamic>> _categories = [
{'title': 'مولات', 'query': 'مول', 'icon': Icons.local_mall_rounded},
{'title': 'مستشفيات', 'query': 'مستشفى', 'icon': Icons.local_hospital_rounded},
{'title': 'مطاعم', 'query': 'مطعم', 'icon': Icons.restaurant_rounded},
{'title': 'كافيهات', 'query': 'مقهى', 'icon': Icons.local_cafe_rounded},
{'title': 'وقود', 'query': 'محطة وقود', 'icon': Icons.local_gas_station_rounded},
@@ -6,12 +6,14 @@ class LayerSelectorSheet extends StatelessWidget {
final MapThemeType currentTheme;
final ValueChanged<MapThemeType> onThemeChanged;
final VoidCallback? onOpenVehicleCustomizer;
final VoidCallback? onOpenAboutAwards;
const LayerSelectorSheet({
super.key,
required this.currentTheme,
required this.onThemeChanged,
this.onOpenVehicleCustomizer,
this.onOpenAboutAwards,
});
@override
@@ -67,9 +69,9 @@ class LayerSelectorSheet extends StatelessWidget {
],
),
if (onOpenVehicleCustomizer != null) ...[
const SizedBox(height: 20),
const SizedBox(height: 18),
const Divider(height: 1, color: AppColors.borderSubtle),
const SizedBox(height: 16),
const SizedBox(height: 14),
ListTile(
onTap: onOpenVehicleCustomizer,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
@@ -98,6 +100,45 @@ class LayerSelectorSheet extends StatelessWidget {
trailing: const Icon(Icons.arrow_forward_ios_rounded, size: 14, color: AppColors.textMuted),
),
],
if (onOpenAboutAwards != null) ...[
const SizedBox(height: 10),
ListTile(
onTap: onOpenAboutAwards,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
side: const BorderSide(color: AppColors.urukGoldBorder),
),
tileColor: AppColors.urukGoldLight,
leading: Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: AppColors.pureWhite,
shape: BoxShape.circle,
border: Border.all(color: AppColors.urukGold.withValues(alpha: 0.4)),
),
child: ClipOval(
child: Image.asset(
'assets/images/uruk_prize_logo.png',
fit: BoxFit.contain,
),
),
),
title: const Text(
'جائزة أوروك الدولية واعتماد المنظومة',
style: TextStyle(
fontSize: 13.5,
fontWeight: FontWeight.w700,
color: AppColors.urukGoldDark,
),
),
subtitle: const Text(
'قصة نشأة المشروع، السيادة الرقمية، والمعمار التقني',
style: TextStyle(fontSize: 11, color: AppColors.textSecondary),
),
trailing: const Icon(Icons.workspace_premium_rounded, size: 20, color: AppColors.urukGoldDark),
),
],
],
),
);
@@ -0,0 +1,236 @@
import 'package:flutter/material.dart';
import '../../../../core/constants/app_colors.dart';
import '../../../../logic/cubits/navigation/navigation_state.dart';
/// Ultra-compact, luxury floating Navigation HUD tailored for Picture-in-Picture (PiP)
/// and In-App floating mini-navigation mode on iOS & Android.
class PipNavHudWidget extends StatelessWidget {
final NavigationState state;
final VoidCallback? onExpand;
final VoidCallback? onStopNavigation;
const PipNavHudWidget({
super.key,
required this.state,
this.onExpand,
this.onStopNavigation,
});
IconData _getManeuverIcon(int sign) {
switch (sign) {
case -3:
return Icons.turn_sharp_left_rounded;
case -2:
return Icons.turn_left_rounded;
case -1:
return Icons.turn_slight_left_rounded;
case 1:
return Icons.turn_slight_right_rounded;
case 2:
return Icons.turn_right_rounded;
case 3:
return Icons.turn_sharp_right_rounded;
case 4:
return Icons.flag_rounded;
case 6:
return Icons.roundabout_right_rounded;
case -7:
case 7:
return Icons.u_turn_left_rounded;
case 8:
return Icons.u_turn_right_rounded;
case 0:
default:
return Icons.straight_rounded;
}
}
@override
Widget build(BuildContext context) {
final int sign = state.currentManeuverModifier;
final String instruction = state.currentInstruction.isNotEmpty
? state.currentInstruction
: 'تابع السير على المسار';
final int speed = state.speed.clamp(0, 260).round();
return Material(
color: Colors.transparent,
child: SafeArea(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
child: Container(
decoration: BoxDecoration(
color: const Color(0xF518181A), // Deep luxury iOS & Android dark glass
borderRadius: BorderRadius.circular(16),
border: Border.all(color: Colors.white.withValues(alpha: 0.15), width: 1),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.45),
blurRadius: 14,
offset: const Offset(0, 4),
),
],
),
child: InkWell(
onTap: onExpand,
borderRadius: BorderRadius.circular(16),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// ── 1. TOP TIER: MANEUVER BADGE + DISTANCE + INSTRUCTION + ACTIONS ──
Row(
children: [
// Turn Maneuver Icon Badge
Container(
width: 40,
height: 40,
decoration: BoxDecoration(
gradient: const LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
AppColors.tacticalEmerald,
Color(0xFF009624),
],
),
borderRadius: BorderRadius.circular(10),
boxShadow: [
BoxShadow(
color: AppColors.tacticalEmerald.withValues(alpha: 0.4),
blurRadius: 6,
offset: const Offset(0, 2),
),
],
),
child: Icon(
_getManeuverIcon(sign),
color: Colors.white,
size: 24,
),
),
const SizedBox(width: 8),
// Distance & Instruction
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
FittedBox(
fit: BoxFit.scaleDown,
alignment: Alignment.centerRight,
child: Text(
state.formattedDistanceToStep,
style: const TextStyle(
fontSize: 17,
fontWeight: FontWeight.w900,
color: Colors.white,
height: 1.1,
),
),
),
const SizedBox(height: 2),
Text(
instruction,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w600,
color: Colors.white.withValues(alpha: 0.9),
),
),
],
),
),
const SizedBox(width: 6),
// Expand Button
IconButton(
visualDensity: VisualDensity.compact,
padding: const EdgeInsets.all(4),
constraints: const BoxConstraints(),
onPressed: onExpand,
icon: const Icon(
Icons.open_in_full_rounded,
color: AppColors.appleBlue,
size: 19,
),
tooltip: 'تكبير للشاشة الكاملة',
),
// Stop Navigation Button
if (onStopNavigation != null) ...[
const SizedBox(width: 4),
IconButton(
visualDensity: VisualDensity.compact,
padding: const EdgeInsets.all(4),
constraints: const BoxConstraints(),
onPressed: onStopNavigation,
icon: const Icon(
Icons.close_rounded,
color: AppColors.coralDanger,
size: 19,
),
tooltip: 'إنهاء الملاحة',
),
],
],
),
const SizedBox(height: 6),
// ── 2. BOTTOM TIER: TRIP ETA & SPEED BAR (OVERFLOW-PROOF) ──
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3.5),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.08),
borderRadius: BorderRadius.circular(8),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Flexible(
child: Text(
'${state.formattedRemainingDuration} • ${state.formattedRemainingDistance}',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 10.5,
fontWeight: FontWeight.w700,
color: AppColors.textSecondary,
),
),
),
const SizedBox(width: 6),
Container(
padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 1.5),
decoration: BoxDecoration(
color: (speed > 100 ? AppColors.coralDanger : AppColors.tacticalEmerald)
.withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(4),
),
child: Text(
'$speed كم/س',
style: TextStyle(
fontSize: 9.5,
fontWeight: FontWeight.w800,
color: speed > 100 ? AppColors.coralDanger : AppColors.tacticalEmerald,
),
),
),
],
),
),
],
),
),
),
),
),
),
);
}
}
@@ -0,0 +1,328 @@
import 'package:flutter/material.dart';
import 'package:intaleq_maps/intaleq_maps.dart';
import '../../../../core/constants/app_colors.dart';
import '../../../../core/services/location_service.dart';
import '../../../../data/models/place_gate.dart';
import '../../../../data/models/place_model.dart';
class PlaceGatesSheet extends StatelessWidget {
final PlaceModel place;
final LatLng? userLocation;
final Function(PlaceGate gate) onSelectGate;
final VoidCallback onSelectMainPlace;
const PlaceGatesSheet({
super.key,
required this.place,
this.userLocation,
required this.onSelectGate,
required this.onSelectMainPlace,
});
IconData _getGateIcon(PlaceGate gate) {
final name = gate.nameAr.toLowerCase();
if (name.contains('طوارئ') || name.contains('طوارئ')) {
return Icons.emergency_rounded;
}
if (name.contains('كارفور') || name.contains('سوق') || name.contains('تسوق')) {
return Icons.shopping_bag_rounded;
}
if (name.contains('زوار') || name.contains('مراجعين')) {
return Icons.people_alt_rounded;
}
if (name.contains('مواقف') || name.contains('كراج') || name.contains('سفلي')) {
return Icons.local_parking_rounded;
}
return Icons.door_sliding_rounded;
}
String? _getFormattedDistance(PlaceGate gate) {
if (userLocation == null) return null;
final distM = LocationService.instance.calculateDistance(
userLocation!,
LatLng(gate.latitude, gate.longitude),
);
if (distM >= 1000) {
return '${(distM / 1000).toStringAsFixed(1)} كم';
}
return '${distM.round()} م';
}
@override
Widget build(BuildContext context) {
final sortedGates = List<PlaceGate>.from(place.gates)
..sort((a, b) {
if (a.isMainGate && !b.isMainGate) return -1;
if (!a.isMainGate && b.isMainGate) return 1;
return 0;
});
return Container(
constraints: BoxConstraints(
maxHeight: MediaQuery.of(context).size.height * 0.65,
),
decoration: const BoxDecoration(
color: AppColors.pureWhite,
borderRadius: BorderRadius.vertical(top: Radius.circular(28)),
boxShadow: [
BoxShadow(
color: Color(0x24000000),
blurRadius: 32,
offset: Offset(0, -8),
),
],
),
child: SafeArea(
top: false,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Top Drag Handle
Center(
child: Container(
margin: const EdgeInsets.only(top: 12, bottom: 8),
width: 44,
height: 5,
decoration: BoxDecoration(
color: Colors.black12,
borderRadius: BorderRadius.circular(10),
),
),
),
// Header Section
Padding(
padding: const EdgeInsets.fromLTRB(20, 6, 20, 16),
child: Row(
children: [
Container(
width: 48,
height: 48,
decoration: BoxDecoration(
color: AppColors.appleBlue.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(16),
),
child: const Icon(
Icons.meeting_room_rounded,
color: AppColors.appleBlue,
size: 26,
),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Flexible(
child: Text(
place.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 17,
fontWeight: FontWeight.w800,
color: AppColors.textPrimary,
),
),
),
const SizedBox(width: 8),
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration(
color: AppColors.appleBlue.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(10),
),
child: Text(
'${place.gates.length} بوابات',
style: const TextStyle(
fontSize: 11,
fontWeight: FontWeight.w700,
color: AppColors.appleBlue,
),
),
),
],
),
const SizedBox(height: 2),
const Text(
'اختر البوابة أو المدخل الأقرب لوجهتك المحددة',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500,
color: AppColors.textSecondary,
),
),
],
),
),
],
),
),
const Divider(height: 1, color: AppColors.borderSubtle),
// Gates List
Flexible(
child: ListView.separated(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
shrinkWrap: true,
itemCount: sortedGates.length,
separatorBuilder: (_, __) => const SizedBox(height: 8),
itemBuilder: (context, index) {
final gate = sortedGates[index];
final distStr = _getFormattedDistance(gate);
final isEmergency = gate.nameAr.contains('طوارئ');
return InkWell(
onTap: () {
Navigator.of(context).pop();
onSelectGate(gate);
},
borderRadius: BorderRadius.circular(18),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
decoration: BoxDecoration(
color: gate.isMainGate
? AppColors.appleBlue.withValues(alpha: 0.05)
: AppColors.canvasLight,
borderRadius: BorderRadius.circular(18),
border: Border.all(
color: gate.isMainGate
? AppColors.appleBlue.withValues(alpha: 0.3)
: AppColors.borderSubtle,
width: gate.isMainGate ? 1.5 : 1.0,
),
),
child: Row(
children: [
// Gate Icon
Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: isEmergency
? AppColors.coralDanger.withValues(alpha: 0.12)
: (gate.isMainGate
? AppColors.tacticalEmerald.withValues(alpha: 0.12)
: Colors.white),
borderRadius: BorderRadius.circular(12),
),
child: Icon(
_getGateIcon(gate),
color: isEmergency
? AppColors.coralDanger
: (gate.isMainGate ? AppColors.tacticalEmerald : AppColors.textSecondary),
size: 20,
),
),
const SizedBox(width: 14),
// Gate Name
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Flexible(
child: Text(
gate.nameAr,
style: TextStyle(
fontSize: 14,
fontWeight: gate.isMainGate ? FontWeight.w800 : FontWeight.w700,
color: isEmergency ? AppColors.coralDanger : AppColors.textPrimary,
),
),
),
if (gate.isMainGate) ...[
const SizedBox(width: 8),
Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: AppColors.tacticalEmerald.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(8),
),
child: const Text(
'رئيسية',
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.w700,
color: AppColors.tacticalEmerald,
),
),
),
],
],
),
if (gate.nameEn != null && gate.nameEn!.isNotEmpty)
Padding(
padding: const EdgeInsets.only(top: 2),
child: Text(
gate.nameEn!,
style: const TextStyle(
fontSize: 11,
color: AppColors.textMuted,
fontWeight: FontWeight.w500,
),
),
),
],
),
),
// Distance badge
if (distStr != null) ...[
Text(
distStr,
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w700,
color: AppColors.textSecondary,
),
),
const SizedBox(width: 8),
],
const Icon(
Icons.arrow_forward_ios_rounded,
size: 14,
color: AppColors.textMuted,
),
],
),
),
);
},
),
),
// General Destination Option
Padding(
padding: const EdgeInsets.fromLTRB(16, 6, 16, 12),
child: TextButton.icon(
onPressed: () {
Navigator.of(context).pop();
onSelectMainPlace();
},
icon: const Icon(Icons.place_outlined, size: 18, color: AppColors.textSecondary),
label: const Text(
'التوجه إلى الموقع العام للمجمع',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: AppColors.textSecondary,
),
),
style: TextButton.styleFrom(
minimumSize: const Size(double.infinity, 44),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)),
),
),
),
],
),
),
);
}
}
@@ -18,20 +18,20 @@ class _OnboardingViewState extends State<OnboardingView> {
final List<Map<String, dynamic>> _slides = [
{
'icon': Icons.public_rounded,
'title': 'خريطة بلدنا تعمل عندنا',
'desc': 'أول بنية خرائط سيادية أردنية متكاملة؛ ننهي التبعية لمزودي الخرائط الأجانب ونحمي بيانات حركة الوطن.',
'badge': 'سيادة وطنية',
'title': 'سيادة مكانية وملاحة عربية مستقلة',
'desc': 'بنية خرائط وملاحة سيادية متكاملة؛ ننهي التبعية لمزودي الخرائط الأجانب ونخدم الأسواق الإقليمية المحرومة من خرائط موثوقة.',
'badge': 'سيادة إقليمية',
},
{
'icon': Icons.alt_route_rounded,
'title': 'توجيه محلي فائق السرعة',
'desc': 'محرك ملاحة متطور يفهم طبيعة شوارع عمّان والمحافظات، بزمن استجابة أقل من 40 ملي ثانية.',
'desc': 'محرك ملاحة متطور يفهم طبيعة شبكات الطرق والمدن العربية، بزمن استجابة فائق ودقة توجيه عالية.',
'badge': 'أداء فائق',
},
{
'icon': Icons.security_rounded,
'title': 'أمان وخصوصية مطلقة',
'desc': 'لا تتبع عشوائي ولا تسريب للمعلومات، مع جاهزية كاملة للعمل عند انقطاع الإنترنت الدولي.',
'title': 'أمان واستقلالية مطلقة',
'desc': 'لا تتبع عشوائي ولا ارتهان لعقوبات أو واجهات أجنبية، مع جاهزية كاملة للعمل عند انقطاع الإنترنت الدولي.',
'badge': 'حماية مشفرة',
},
];
@@ -101,69 +101,74 @@ class _OnboardingViewState extends State<OnboardingView> {
itemBuilder: (context, index) {
final slide = _slides[index];
return Padding(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
// Icon Container
Container(
width: 110,
height: 110,
decoration: BoxDecoration(
color: AppColors.appleBlue.withValues(alpha: 0.08),
shape: BoxShape.circle,
),
child: Center(
child: Icon(
slide['icon'] as IconData,
size: 54,
color: AppColors.appleBlue,
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16),
child: Center(
child: SingleChildScrollView(
physics: const BouncingScrollPhysics(),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
// Icon Container
Container(
width: 90,
height: 90,
decoration: BoxDecoration(
color: AppColors.appleBlue.withValues(alpha: 0.08),
shape: BoxShape.circle,
),
child: Center(
child: Icon(
slide['icon'] as IconData,
size: 46,
color: AppColors.appleBlue,
),
),
),
),
),
const SizedBox(height: 36),
// Badge
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
decoration: BoxDecoration(
color: AppColors.surfaceMuted,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: AppColors.borderSubtle),
),
child: Text(
slide['badge'] as String,
style: GoogleFonts.alexandria(
fontSize: 11,
fontWeight: FontWeight.w600,
color: AppColors.appleBlue,
const SizedBox(height: 24),
// Badge
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
decoration: BoxDecoration(
color: AppColors.surfaceMuted,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: AppColors.borderSubtle),
),
child: Text(
slide['badge'] as String,
style: GoogleFonts.alexandria(
fontSize: 11,
fontWeight: FontWeight.w600,
color: AppColors.appleBlue,
),
),
),
),
const SizedBox(height: 14),
// Title
Text(
slide['title'] as String,
textAlign: TextAlign.center,
style: GoogleFonts.alexandria(
fontSize: 22,
fontWeight: FontWeight.w800,
color: AppColors.textPrimary,
height: 1.3,
),
),
const SizedBox(height: 12),
// Description
Text(
slide['desc'] as String,
textAlign: TextAlign.center,
style: GoogleFonts.alexandria(
fontSize: 13.5,
fontWeight: FontWeight.w400,
color: AppColors.textSecondary,
height: 1.5,
),
),
],
),
const SizedBox(height: 16),
// Title
Text(
slide['title'] as String,
textAlign: TextAlign.center,
style: GoogleFonts.alexandria(
fontSize: 24,
fontWeight: FontWeight.w800,
color: AppColors.textPrimary,
height: 1.3,
),
),
const SizedBox(height: 16),
// Description
Text(
slide['desc'] as String,
textAlign: TextAlign.center,
style: GoogleFonts.alexandria(
fontSize: 14,
fontWeight: FontWeight.w400,
color: AppColors.textSecondary,
height: 1.6,
),
),
],
),
),
);
},
+177 -79
View File
@@ -22,10 +22,10 @@ class _SplashViewState extends State<SplashView> with SingleTickerProviderStateM
super.initState();
_animController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 1400),
duration: const Duration(milliseconds: 1500),
);
_scaleAnimation = Tween<double>(begin: 0.85, end: 1.0).animate(
_scaleAnimation = Tween<double>(begin: 0.88, end: 1.0).animate(
CurvedAnimation(parent: _animController, curve: Curves.easeOutCubic),
);
@@ -35,7 +35,7 @@ class _SplashViewState extends State<SplashView> with SingleTickerProviderStateM
_animController.forward();
Future.delayed(const Duration(milliseconds: 2400), () async {
Future.delayed(const Duration(milliseconds: 2800), () async {
if (mounted) {
final prefs = await SharedPreferences.getInstance();
final hasSeen = prefs.getBool('has_seen_onboarding') ?? false;
@@ -70,91 +70,189 @@ class _SplashViewState extends State<SplashView> with SingleTickerProviderStateM
opacity: _fadeAnimation,
child: ScaleTransition(
scale: _scaleAnimation,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
// Luxury Emblem Container
Container(
width: 108,
height: 108,
decoration: BoxDecoration(
color: AppColors.pureWhite,
borderRadius: BorderRadius.circular(30),
boxShadow: const [
BoxShadow(
color: Color(0x1F0071E3),
blurRadius: 36,
offset: Offset(0, 12),
child: SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 20),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
// Luxury Uruk Emblem Container
Container(
width: 114,
height: 114,
decoration: BoxDecoration(
color: const Color(0xFF131722),
borderRadius: BorderRadius.circular(32),
border: Border.all(
color: AppColors.urukGold.withValues(alpha: 0.35),
width: 1.5,
),
BoxShadow(
color: Color(0x14000000),
blurRadius: 20,
offset: Offset(0, 4),
boxShadow: [
BoxShadow(
color: AppColors.urukGold.withValues(alpha: 0.22),
blurRadius: 36,
offset: const Offset(0, 14),
),
const BoxShadow(
color: Color(0x24000000),
blurRadius: 20,
offset: Offset(0, 6),
),
],
),
child: ClipRRect(
borderRadius: BorderRadius.circular(30),
child: Image.asset(
'assets/images/siro_uruk_logo.png',
fit: BoxFit.cover,
),
],
),
child: ClipRRect(
borderRadius: BorderRadius.circular(30),
child: Image.asset(
'assets/images/siro_maps_logo.png',
fit: BoxFit.cover,
),
),
),
const SizedBox(height: 24),
// App Title
Text(
'خرائط سيرو',
style: GoogleFonts.alexandria(
fontSize: 28,
fontWeight: FontWeight.w800,
color: AppColors.textPrimary,
letterSpacing: -0.5,
const SizedBox(height: 24),
// App Title
Text(
'خرائط أوروك',
style: GoogleFonts.alexandria(
fontSize: 28,
fontWeight: FontWeight.w800,
color: AppColors.textPrimary,
letterSpacing: -0.5,
),
),
),
const SizedBox(height: 6),
// Subtitle
Text(
'Siro Maps • منظومة السيادة المكانية',
style: GoogleFonts.alexandria(
fontSize: 13,
fontWeight: FontWeight.w500,
color: AppColors.textMuted,
const SizedBox(height: 6),
// Subtitle
Text(
'Uruk Map • منظومة الملاحة والسيادة المكانية',
style: GoogleFonts.alexandria(
fontSize: 13,
fontWeight: FontWeight.w600,
color: AppColors.textSecondary,
),
),
),
const SizedBox(height: 32),
// Pill Version Badge
Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 6),
decoration: BoxDecoration(
color: AppColors.surfaceMuted,
borderRadius: BorderRadius.circular(20),
border: Border.all(color: AppColors.borderSubtle),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 7,
height: 7,
decoration: const BoxDecoration(
color: AppColors.tacticalEmerald,
shape: BoxShape.circle,
),
const SizedBox(height: 26),
// Official Uruk International Prize Award Badge
Container(
constraints: const BoxConstraints(maxWidth: 340),
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 11),
decoration: BoxDecoration(
gradient: const LinearGradient(
colors: [
Color(0xFFFFFDF7),
Color(0xFFFFF6D8),
],
begin: Alignment.topRight,
end: Alignment.bottomLeft,
),
const SizedBox(width: 8),
Text(
'🇯🇴 سيادة مكانية 100% • v2.4 PRO',
style: GoogleFonts.alexandria(
fontSize: 11,
fontWeight: FontWeight.w600,
color: AppColors.textSecondary,
borderRadius: BorderRadius.circular(18),
border: Border.all(color: AppColors.urukGoldBorder, width: 1.2),
boxShadow: [
BoxShadow(
color: AppColors.urukGold.withValues(alpha: 0.14),
blurRadius: 22,
offset: const Offset(0, 8),
),
),
],
],
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 44,
height: 44,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: AppColors.pureWhite,
border: Border.all(
color: AppColors.urukGold.withValues(alpha: 0.4),
width: 1.2,
),
),
child: ClipOval(
child: Image.asset(
'assets/images/uruk_prize_logo.png',
fit: BoxFit.contain,
),
),
),
const SizedBox(width: 12),
Flexible(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(
Icons.workspace_premium_rounded,
size: 16,
color: AppColors.urukGoldDark,
),
const SizedBox(width: 4),
Flexible(
child: Text(
'الحائز على جائزة أوروك الدولية',
style: GoogleFonts.alexandria(
fontSize: 12,
fontWeight: FontWeight.w700,
color: AppColors.urukGoldDark,
),
),
),
],
),
const SizedBox(height: 2),
Text(
'مشروع منبثق عن برنامج جوائز أوروك للسيادة الرقمية',
style: GoogleFonts.alexandria(
fontSize: 10,
fontWeight: FontWeight.w500,
color: AppColors.textSecondary,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
],
),
),
],
),
),
),
],
const SizedBox(height: 28),
// Regional Sovereignty Pill Badge
Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 6),
decoration: BoxDecoration(
color: AppColors.surfaceMuted,
borderRadius: BorderRadius.circular(20),
border: Border.all(color: AppColors.borderSubtle),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 7,
height: 7,
decoration: const BoxDecoration(
color: AppColors.tacticalEmerald,
shape: BoxShape.circle,
),
),
const SizedBox(width: 8),
Text(
'🌍 شبكة ملاحة إقليمية مستقلة • v2.4 PRO',
style: GoogleFonts.alexandria(
fontSize: 11,
fontWeight: FontWeight.w600,
color: AppColors.textSecondary,
),
),
],
),
),
],
),
),
),
),