feat: add device authentication, place gates support, and PiP navigation features
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user