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
+2 -1
View File
@@ -6,6 +6,7 @@ import { ApiKey } from './entities/api-key.entity';
import { RedisModule } from '../common/redis.module';
import { ConfigService } from '@nestjs/config';
import { TenantController } from './tenant.controller';
import { DeviceAuthController } from './device-auth.controller';
import { FirebaseAdminService } from './firebase-admin.service';
import { FirebaseAuthGuard } from './guards/firebase-auth.guard';
@@ -15,7 +16,7 @@ import { FirebaseAuthGuard } from './guards/firebase-auth.guard';
TypeOrmModule.forFeature([Tenant, ApiKey]),
RedisModule,
],
controllers: [TenantController],
controllers: [TenantController, DeviceAuthController],
providers: [AuthService, FirebaseAdminService, FirebaseAuthGuard],
exports: [AuthService, FirebaseAdminService, FirebaseAuthGuard],
})
+76 -2
View File
@@ -1,9 +1,9 @@
import { Injectable, UnauthorizedException, Logger, NotFoundException, ConflictException, ForbiddenException } from '@nestjs/common';
import { Injectable, UnauthorizedException, Logger, NotFoundException, ConflictException, ForbiddenException, BadRequestException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { createHash } from 'crypto';
import { ApiKey } from './entities/api-key.entity';
import { Tenant, TenantPlan, RATE_LIMITS } from './entities/tenant.entity';
import { Tenant, TenantPlan, TenantRole, RATE_LIMITS } from './entities/tenant.entity';
import { RedisService } from '../common/redis.service';
@Injectable()
@@ -227,4 +227,78 @@ export class AuthService {
return tenant;
}
/**
* Provision or fetch a dedicated API key for a mobile device based on its hardware fingerprint.
* Creates an isolated consumer tenant and a dedicated API key with consumer rate limits.
*/
async getOrProvisionDeviceKey(dto: {
deviceFingerprint: string;
hardwareId?: string;
brand?: string;
model?: string;
platform?: string;
osVersion?: string;
appVersion?: string;
}): Promise<{
apiKey: string;
keyName: string;
rateLimit: number;
plan: string;
deviceFingerprint: string;
isNew: boolean;
}> {
if (!dto.deviceFingerprint || dto.deviceFingerprint.trim().length < 8) {
throw new BadRequestException('Invalid device fingerprint: minimum length is 8 characters');
}
const cleanFingerprint = dto.deviceFingerprint.trim();
const fpHash = createHash('sha256').update(cleanFingerprint).digest('hex').substring(0, 24);
const email = `device_${fpHash}@device.siromaps.internal`;
let tenant = await this.tenantRepository.findOne({ where: { email } });
let isNew = false;
if (!tenant) {
const deviceLabel = [dto.brand, dto.model].filter(Boolean).join(' ') || (dto.platform ? `${dto.platform} device` : 'Mobile Device');
tenant = await this.tenantRepository.save({
name: `Siro User (${deviceLabel})`,
email,
plan: TenantPlan.FREE,
role: TenantRole.USER,
isActive: true,
});
isNew = true;
this.logger.log(`📱 [DeviceAuth] Created consumer tenant for device: ${cleanFingerprint.substring(0, 16)}... (${deviceLabel})`);
}
let apiKey = await this.apiKeyRepository.findOne({
where: { tenantId: tenant.id, isActive: true },
order: { createdAt: 'DESC' },
});
if (!apiKey) {
const keyString = `in_mob_${createHash('sha256').update(tenant.id + cleanFingerprint + 'siro_mobile_key_seed').digest('hex').substring(0, 28)}`;
apiKey = await this.apiKeyRepository.save({
key: keyString,
secretHash: this.hashSecret(keyString),
name: `Siro Mobile - ${dto.model || dto.platform || 'Client'}`,
tenantId: tenant.id,
rateLimit: 60, // 60 requests per minute for consumer navigation
isActive: true,
});
isNew = true;
this.logger.log(`🔑 [DeviceAuth] Provisioned dedicated API key ${apiKey.key} for tenant ${tenant.id}`);
}
return {
apiKey: apiKey.key,
keyName: apiKey.name,
rateLimit: apiKey.rateLimit || 60,
plan: tenant.plan,
deviceFingerprint: cleanFingerprint,
isNew,
};
}
}
@@ -0,0 +1,71 @@
import { Test, TestingModule } from '@nestjs/testing';
import { DeviceAuthController } from './device-auth.controller';
import { AuthService } from './auth.service';
describe('DeviceAuthController', () => {
let controller: DeviceAuthController;
let authService: AuthService;
const mockAuthService = {
getOrProvisionDeviceKey: jest.fn().mockImplementation((dto) =>
Promise.resolve({
apiKey: 'in_mob_9876543210abcdef12345678',
keyName: `Siro Mobile - ${dto.model || 'Client'}`,
rateLimit: 60,
plan: 'FREE',
deviceFingerprint: dto.deviceFingerprint,
isNew: true,
}),
),
};
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [DeviceAuthController],
providers: [
{
provide: AuthService,
useValue: mockAuthService,
},
],
}).compile();
controller = module.get<DeviceAuthController>(DeviceAuthController);
authService = module.get<AuthService>(AuthService);
});
it('should be defined', () => {
expect(controller).toBeDefined();
});
it('should provision a new dedicated mobile key for a device fingerprint', async () => {
const dto = {
deviceFingerprint: 'siro_android_hw_a1b2c3d4e5f6g7h8',
brand: 'Samsung',
model: 'Galaxy S23',
platform: 'android',
osVersion: 'Android 14',
};
const res = await controller.provisionDeviceKey(dto);
expect(authService.getOrProvisionDeviceKey).toHaveBeenCalledWith(dto);
expect(res.apiKey).toBe('in_mob_9876543210abcdef12345678');
expect(res.rateLimit).toBe(60);
expect(res.deviceFingerprint).toBe('siro_android_hw_a1b2c3d4e5f6g7h8');
});
it('should support device-key alias endpoint', async () => {
const dto = {
deviceFingerprint: 'siro_ios_hw_1122334455667788',
brand: 'Apple',
model: 'iPhone 15 Pro',
platform: 'ios',
};
const res = await controller.getDeviceKey(dto);
expect(authService.getOrProvisionDeviceKey).toHaveBeenCalledWith(dto);
expect(res.apiKey).toBeDefined();
});
});
@@ -0,0 +1,28 @@
import { Controller, Post, Body, HttpCode, HttpStatus } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger';
import { AuthService } from './auth.service';
import { ProvisionDeviceKeyDto } from './dto/provision-device-key.dto';
@ApiTags('auth')
@Controller('auth')
export class DeviceAuthController {
constructor(private readonly authService: AuthService) {}
@Post('device-provision')
@HttpCode(HttpStatus.OK)
@ApiOperation({
summary: 'Provision or retrieve a dedicated API key for a mobile device via hardware fingerprint',
description: 'Enables completely frictionless, zero-OTP mobile onboarding by issuing an isolated consumer API key tied directly to physical device telemetry.',
})
@ApiResponse({ status: 200, description: 'Dedicated device API key issued successfully' })
async provisionDeviceKey(@Body() dto: ProvisionDeviceKeyDto) {
return this.authService.getOrProvisionDeviceKey(dto);
}
@Post('device-key')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Intuitive alias for device-provision' })
async getDeviceKey(@Body() dto: ProvisionDeviceKeyDto) {
return this.authService.getOrProvisionDeviceKey(dto);
}
}
@@ -0,0 +1,39 @@
import { IsString, IsOptional, MinLength } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class ProvisionDeviceKeyDto {
@ApiProperty({ description: 'Hardware-derived device fingerprint', example: 'siro_android_hw_9a8b7c6d5e4f' })
@IsString()
@MinLength(8)
deviceFingerprint: string;
@ApiPropertyOptional({ description: 'Unique hardware identifier if available' })
@IsOptional()
@IsString()
hardwareId?: string;
@ApiPropertyOptional({ description: 'Device brand (e.g. Samsung, Apple, Xiaomi)' })
@IsOptional()
@IsString()
brand?: string;
@ApiPropertyOptional({ description: 'Device model (e.g. SM-S908B, iPhone14,2)' })
@IsOptional()
@IsString()
model?: string;
@ApiPropertyOptional({ description: 'Operating system platform (android, ios, macos)' })
@IsOptional()
@IsString()
platform?: string;
@ApiPropertyOptional({ description: 'OS version' })
@IsOptional()
@IsString()
osVersion?: string;
@ApiPropertyOptional({ description: 'App version' })
@IsOptional()
@IsString()
appVersion?: string;
}
+47 -4
View File
@@ -111,7 +111,9 @@ export class GeocodingService {
}
let regionCondition = '';
if (targetRegion && ['syria', 'jordan', 'egypt', 'iraq'].includes(targetRegion)) {
// Only enforce strict textual region if NO GPS location was provided.
// When GPS coordinates are present, ST_DistanceSphere guarantees physical proximity within radius.
if (!hasLocation && targetRegion && ['syria', 'jordan', 'egypt', 'iraq'].includes(targetRegion)) {
regionCondition = `AND (region = '${targetRegion}' OR region = 'global')`;
}
@@ -121,9 +123,34 @@ export class GeocodingService {
'' as neighbourhood, '' as district, '' as governorate,
latitude, longitude, address, region, source, popularity_score,
${hasLocation ? 'ST_DistanceSphere(location, ST_SetSRID(ST_MakePoint($3::float, $2::float), 4326))' : '0'} as distance,
similarity(normalized_name, $1) as relevance
GREATEST(
similarity(normalized_name, $1),
CASE WHEN normalized_name ILIKE $1 || '%' THEN 0.95 ELSE 0.0 END,
CASE WHEN normalized_name ILIKE '%' || $1 || '%' THEN 0.85 ELSE 0.0 END,
0.50
) as relevance
FROM unified_search_index
WHERE normalized_name % $1
WHERE (
normalized_name % $1
OR normalized_name ILIKE '%' || $1 || '%'
-- Token containment (e.g. "المدينة الطبية", "سوبرماركت المدينة", "مخبز جواد")
OR (length($1) > 2 AND EXISTS (
SELECT 1 FROM unnest(string_to_array($1, ' ')) token
WHERE length(token) >= 3 AND normalized_name ILIKE '%' || token || '%'
))
-- Generic Category Keywords Mapping (All major amenities)
OR (category IN ('mosque', 'place_of_worship') AND ($1 ILIKE '%مسجد%' OR $1 ILIKE '%جامع%' OR $1 ILIKE '%مصلى%'))
OR (category IN ('restaurant', 'fast_food', 'food') AND ($1 ILIKE '%مطعم%' OR $1 ILIKE '%شاورما%' OR $1 ILIKE '%وجب%' OR $1 ILIKE '%مشاو%' OR $1 ILIKE '%برغر%'))
OR (category IN ('supermarket', 'convenience', 'grocery', 'shop', 'mall') AND ($1 ILIKE '%سوبر%' OR $1 ILIKE '%ماركت%' OR $1 ILIKE '%دكان%' OR $1 ILIKE '%بقال%' OR $1 ILIKE '%تموين%' OR $1 ILIKE '%مول%'))
OR (category IN ('bakery', 'pastry') AND ($1 ILIKE '%مخبز%' OR $1 ILIKE '%افران%' OR $1 ILIKE '%فرن%' OR $1 ILIKE '%حلويات%' OR $1 ILIKE '%معجنات%'))
OR (category IN ('cafe', 'coffee_shop') AND ($1 ILIKE '%مقهى%' OR $1 ILIKE '%كافيه%' OR $1 ILIKE '%كوفي%' OR $1 ILIKE '%قهوة%'))
OR (category IN ('pharmacy', 'chemist') AND ($1 ILIKE '%صيدل%'))
OR (category IN ('hospital', 'clinic', 'doctors', 'health') AND ($1 ILIKE '%مستشف%' OR $1 ILIKE '%عياد%' OR $1 ILIKE '%مركز صحي%' OR $1 ILIKE '%طبي%'))
OR (category IN ('fuel', 'gas_station', 'car_repair') AND ($1 ILIKE '%وقود%' OR $1 ILIKE '%كازية%' OR $1 ILIKE '%محطة%' OR $1 ILIKE '%بنزين%'))
OR (category IN ('bank', 'atm') AND ($1 ILIKE '%بنك%' OR $1 ILIKE '%صراف%' OR $1 ILIKE '%مصرف%'))
OR (category IN ('school', 'university', 'college') AND ($1 ILIKE '%مدرس%' OR $1 ILIKE '%جامع%' OR $1 ILIKE '%كلي%'))
OR (category IN ('hotel', 'guest_house') AND ($1 ILIKE '%فندق%' OR $1 ILIKE '%شقق فندقية%' OR $1 ILIKE '%منتجع%'))
)
${locationCondition}
${regionCondition}
ORDER BY ${hasLocation ? 'distance ASC, (normalized_name <-> $1) ASC' : '(normalized_name <-> $1) ASC'}
@@ -227,11 +254,27 @@ export class GeocodingService {
const distKm = hasLocation ? (Number(r.distance) / 1000) : 0;
const proximityScore = hasLocation ? (1.0 / (1.0 + distKm * 0.5)) : 0;
// Source Priority Multiplier:
// user_place (manually entered / app-saved / enterprise verified): highest trust (+35% boost)
// approved_road: (+20% boost)
// overture: (+15% boost)
// osm_global: baseline (1.0)
let sourceMultiplier = 1.0;
if (r.source === 'user_place') {
sourceMultiplier = 1.35;
} else if (r.source === 'approved_road') {
sourceMultiplier = 1.20;
} else if (r.source === 'overture') {
sourceMultiplier = 1.15;
}
// When location is available, proximity is heavily prioritized (60%)
const totalScore = hasLocation
let totalScore = hasLocation
? (proximityScore * 0.60) + (textScore * 0.30) + (popularityScore * 0.10)
: (textScore * 0.65) + (popularityScore * 0.35);
totalScore *= sourceMultiplier;
return { ...r, totalScore };
})
.sort((a, b) => b.totalScore - a.totalScore)
@@ -1,11 +1,11 @@
import { Injectable, Logger } from '@nestjs/common';
import { Injectable, Logger, OnApplicationBootstrap } from '@nestjs/common';
import { Cron, CronExpression } from '@nestjs/schedule';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { OsmPointWithArea } from './entities/osm-point-with-area.entity';
@Injectable()
export class IndexRefreshService {
export class IndexRefreshService implements OnApplicationBootstrap {
private readonly logger = new Logger(IndexRefreshService.name);
constructor(
@@ -13,6 +13,85 @@ export class IndexRefreshService {
private readonly repo: Repository<OsmPointWithArea>, // Use any repository to execute raw SQL
) {}
async onApplicationBootstrap() {
this.logger.log('Running startup data validation and index refresh...');
await this.repairMisplacedJordanianPlaces();
await this.handleCron();
}
async repairMisplacedJordanianPlaces() {
try {
this.logger.log('Checking for Jordanian landmarks misplaced in places_egypt...');
// 1. Move any Egyptian places that physically lie within Jordan's bounding box to places_jordan
await this.repo.query(`
INSERT INTO places_jordan (name, name_ar, category, latitude, longitude, address, location, popularity_score)
SELECT
p.name,
p.name_ar,
COALESCE(p.category, 'hospital'),
p.latitude,
p.longitude,
p.address,
p.location,
GREATEST(COALESCE(p.popularity_score, 50), 95)
FROM places_egypt p
WHERE p.latitude BETWEEN 29.0 AND 33.5
AND p.longitude BETWEEN 34.8 AND 39.5
AND NOT EXISTS (
SELECT 1 FROM places_jordan pj
WHERE (pj.name_ar = p.name_ar OR pj.name = p.name)
AND ST_DWithin(pj.location::geography, p.location::geography, 200)
);
`);
await this.repo.query(`
DELETE FROM places_egypt
WHERE latitude BETWEEN 29.0 AND 33.5
AND longitude BETWEEN 34.8 AND 39.5;
`);
// 2. Explicitly ensure "مدينة الحسين الطبية (المدينة الطبية)" is present in places_jordan
await this.repo.query(`
INSERT INTO places_jordan (name, name_ar, category, latitude, longitude, address, location, popularity_score)
SELECT
'King Hussein Medical Center (المدينة الطبية)',
'مدينة الحسين الطبية (المدينة الطبية)',
'hospital',
31.978690,
35.834260,
'شارع الملك عبد الله الثاني، المدينة الطبية، دابوق / صويلح، عمّان، الأردن',
ST_SetSRID(ST_MakePoint(35.834260, 31.978690), 4326),
100
WHERE NOT EXISTS (
SELECT 1 FROM places_jordan
WHERE name_ar ILIKE '%المدينة الطبية%' OR name_ar ILIKE '%مدينة الحسين الطبية%'
);
`);
// Also ensure exact "المدينة الطبية" alias exists
await this.repo.query(`
INSERT INTO places_jordan (name, name_ar, category, latitude, longitude, address, location, popularity_score)
SELECT
'المدينة الطبية',
'المدينة الطبية',
'hospital',
31.978690,
35.834260,
'شارع الملك عبد الله الثاني، دابوق، عمّان، الأردن',
ST_SetSRID(ST_MakePoint(35.834260, 31.978690), 4326),
100
WHERE NOT EXISTS (
SELECT 1 FROM places_jordan WHERE name_ar = 'المدينة الطبية'
);
`);
this.logger.log('Completed check/repair of Jordanian landmarks.');
} catch (err: any) {
this.logger.warn('Failed to repair misplaced places: ' + err.message);
}
}
// Run every 5 minutes
@Cron(CronExpression.EVERY_5_MINUTES)
async handleCron() {
+8 -9
View File
@@ -3,6 +3,7 @@ import java.io.FileInputStream
plugins {
id("com.android.application")
id("kotlin-android")
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
id("dev.flutter.flutter-gradle-plugin")
}
@@ -14,7 +15,7 @@ if (keystorePropertiesFile.exists()) {
}
android {
namespace = "com.siro_map.siro_maps"
namespace = "com.urukmap.app"
compileSdk = flutter.compileSdkVersion
ndkVersion = flutter.ndkVersion
@@ -23,9 +24,13 @@ android {
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = JavaVersion.VERSION_17.toString()
}
defaultConfig {
applicationId = "com.siro_map.siro_maps"
minSdk = 23
applicationId = "com.urukmap.app"
minSdk = flutter.minSdkVersion
targetSdk = flutter.targetSdkVersion
versionCode = flutter.versionCode
versionName = flutter.versionName
@@ -71,12 +76,6 @@ dependencies {
implementation("androidx.car.app:app-projected:1.4.0")
}
kotlin {
compilerOptions {
jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17
}
}
flutter {
source = "../.."
}
+2 -2
View File
@@ -29,8 +29,8 @@
-keep class androidx.car.app.validation.** { *; }
# Keep our custom CarAppService, Session, Screen and State models
-keep class com.siro_map.siro_maps.car.** { *; }
-keepclassmembers class com.siro_map.siro_maps.car.** { *; }
-keep class com.urukmap.app.car.** { *; }
-keepclassmembers class com.urukmap.app.car.** { *; }
-dontwarn androidx.car.app.**
@@ -10,7 +10,7 @@
<uses-permission android:name="android.permission.WAKE_LOCK"/>
<application
android:label="خرائط سيرو"
android:label="Uruk Map"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher">
<activity
@@ -51,6 +51,23 @@
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="geo" />
</intent-filter>
<!-- Google Navigation Scheme: google.navigation:q=lat,lng -->
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="google.navigation" />
</intent-filter>
<!-- Universal Web Deep Links: maps.siro.app & map-saas.intaleqapp.com -->
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="https" android:host="maps.siro.app" />
<data android:scheme="https" android:host="map-saas.intaleqapp.com" />
</intent-filter>
</activity>
<!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
@@ -1,63 +0,0 @@
package com.siro_map.siro_maps
import com.siro_map.siro_maps.car.CarNavigationState
import com.siro_map.siro_maps.car.SiroCarAppService
import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.MethodChannel
class MainActivity : FlutterActivity() {
private val CHANNEL = "com.siro.siro_maps/car_navigation"
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine)
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, CHANNEL).setMethodCallHandler { call, result ->
when (call.method) {
"isCarAppConnected" -> {
result.success(SiroCarAppService.isConnected)
}
"updateNavState" -> {
try {
val lat = call.argument<Double>("lat") ?: 0.0
val lng = call.argument<Double>("lng") ?: 0.0
val bearing = call.argument<Double>("bearing") ?: 0.0
val speed = call.argument<Double>("speed") ?: 0.0
val instruction = call.argument<String>("instruction") ?: ""
val distanceToStep = call.argument<Double>("distanceToStep") ?: 0.0
val totalDistance = call.argument<Double>("totalDistance") ?: 0.0
val eta = call.argument<Double>("eta") ?: 0.0
val maneuver = call.argument<Int>("maneuver") ?: 0
val isNavigating = call.argument<Boolean>("isNavigating") ?: false
val isMapDarkMode = call.argument<Boolean>("isMapDarkMode") ?: false
val newState = CarNavigationState(
lat = lat,
lng = lng,
bearing = bearing,
speed = speed,
instruction = instruction,
distanceToStep = distanceToStep,
totalDistance = totalDistance,
eta = eta,
maneuver = maneuver,
isNavigating = isNavigating,
isMapDarkMode = isMapDarkMode
)
SiroCarAppService.updateNavState(newState)
result.success(true)
} catch (e: Exception) {
result.error("UPDATE_FAILED", e.localizedMessage, null)
}
}
"stopNavigation" -> {
SiroCarAppService.stopNavigation()
result.success(true)
}
else -> {
result.notImplemented()
}
}
}
}
}
@@ -0,0 +1,208 @@
package com.urukmap.app
import android.app.PictureInPictureParams
import android.content.Intent
import android.content.pm.PackageManager
import android.content.res.Configuration
import android.os.Build
import android.os.Bundle
import android.util.Rational
import com.urukmap.app.car.CarNavigationState
import com.urukmap.app.car.SiroCarAppService
import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.MethodChannel
class MainActivity : FlutterActivity() {
private val CAR_CHANNEL = "com.siro.siro_maps/car_navigation"
private val PIP_CHANNEL = "com.siro.siro_maps/pip"
private val DEEP_LINK_CHANNEL = "com.siro.siro_maps/deep_link"
private var pipMethodChannel: MethodChannel? = null
private var deepLinkMethodChannel: MethodChannel? = null
private var pendingDeepLink: String? = null
private var isNavigating: Boolean = false
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
handleIntent(intent)
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
setIntent(intent)
handleIntent(intent)
}
private fun handleIntent(intent: Intent?) {
if (intent?.action == Intent.ACTION_VIEW) {
val dataString = intent.dataString
if (!dataString.isNullOrEmpty()) {
pendingDeepLink = dataString
deepLinkMethodChannel?.invokeMethod("onDeepLink", mapOf("url" to dataString))
}
}
}
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine)
// 1. Car Navigation Channel
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, CAR_CHANNEL).setMethodCallHandler { call, result ->
when (call.method) {
"isCarAppConnected" -> {
result.success(SiroCarAppService.isConnected)
}
"updateNavState" -> {
try {
val lat = call.argument<Double>("lat") ?: 0.0
val lng = call.argument<Double>("lng") ?: 0.0
val bearing = call.argument<Double>("bearing") ?: 0.0
val speed = call.argument<Double>("speed") ?: 0.0
val instruction = call.argument<String>("instruction") ?: ""
val distanceToStep = call.argument<Double>("distanceToStep") ?: 0.0
val totalDistance = call.argument<Double>("totalDistance") ?: 0.0
val eta = call.argument<Double>("eta") ?: 0.0
val maneuver = call.argument<Int>("maneuver") ?: 0
val isNav = call.argument<Boolean>("isNavigating") ?: false
val isMapDarkMode = call.argument<Boolean>("isMapDarkMode") ?: false
updateNavigationState(isNav)
val newState = CarNavigationState(
lat = lat,
lng = lng,
bearing = bearing,
speed = speed,
instruction = instruction,
distanceToStep = distanceToStep,
totalDistance = totalDistance,
eta = eta,
maneuver = maneuver,
isNavigating = isNav,
isMapDarkMode = isMapDarkMode
)
SiroCarAppService.updateNavState(newState)
result.success(true)
} catch (e: Exception) {
result.error("UPDATE_FAILED", e.localizedMessage, null)
}
}
"stopNavigation" -> {
updateNavigationState(false)
SiroCarAppService.stopNavigation()
result.success(true)
}
else -> {
result.notImplemented()
}
}
}
// 2. Deep Link Channel
deepLinkMethodChannel = MethodChannel(flutterEngine.dartExecutor.binaryMessenger, DEEP_LINK_CHANNEL).apply {
setMethodCallHandler { call, result ->
when (call.method) {
"getInitialLink" -> {
val link = pendingDeepLink
pendingDeepLink = null
result.success(link)
}
else -> result.notImplemented()
}
}
}
// Deliver pending deep link if arrived before engine configuration
pendingDeepLink?.let { link ->
deepLinkMethodChannel?.invokeMethod("onDeepLink", mapOf("url" to link))
}
// 3. Picture-in-Picture (PiP) Channel
pipMethodChannel = MethodChannel(flutterEngine.dartExecutor.binaryMessenger, PIP_CHANNEL).apply {
setMethodCallHandler { call, result ->
when (call.method) {
"enterPictureInPicture" -> {
val success = enterPipMode()
result.success(success)
}
"isPipSupported" -> {
val supported = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
packageManager.hasSystemFeature(PackageManager.FEATURE_PICTURE_IN_PICTURE)
} else {
false
}
result.success(supported)
}
"setNavigating" -> {
val navigating = call.argument<Boolean>("isNavigating") ?: false
updateNavigationState(navigating)
result.success(true)
}
else -> result.notImplemented()
}
}
}
// 4. Device Hardware Channel
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, "com.siro.siro_maps/device_hardware").setMethodCallHandler { call, result ->
if (call.method == "getHardwareInfo") {
val androidId = android.provider.Settings.Secure.getString(contentResolver, android.provider.Settings.Secure.ANDROID_ID) ?: ""
val info = mapOf(
"hardwareId" to androidId,
"brand" to Build.BRAND,
"manufacturer" to Build.MANUFACTURER,
"model" to Build.MODEL,
"device" to Build.DEVICE,
"board" to Build.BOARD,
"hardware" to Build.HARDWARE
)
result.success(info)
} else {
result.notImplemented()
}
}
}
private fun updateNavigationState(navigating: Boolean) {
isNavigating = navigating
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
try {
val pipParams = PictureInPictureParams.Builder()
.setAutoEnterEnabled(navigating)
.setAspectRatio(Rational(3, 4))
.build()
setPictureInPictureParams(pipParams)
} catch (_: Exception) {}
}
}
private fun enterPipMode(): Boolean {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
if (packageManager.hasSystemFeature(PackageManager.FEATURE_PICTURE_IN_PICTURE)) {
return try {
val pipParams = PictureInPictureParams.Builder()
.setAspectRatio(Rational(3, 4))
.build()
enterPictureInPictureMode(pipParams)
} catch (e: Exception) {
false
}
}
}
return false
}
override fun onUserLeaveHint() {
super.onUserLeaveHint()
// Auto-enter PiP on Android 8.0 - 11 when user presses Home while navigating
if (isNavigating && Build.VERSION.SDK_INT < Build.VERSION_CODES.S) {
enterPipMode()
}
}
override fun onPictureInPictureModeChanged(isInPictureInPictureMode: Boolean, newConfig: Configuration) {
super.onPictureInPictureModeChanged(isInPictureInPictureMode, newConfig)
pipMethodChannel?.invokeMethod("onPipChanged", mapOf("isInPip" to isInPictureInPictureMode))
}
}
@@ -1,4 +1,4 @@
package com.siro_map.siro_maps.car
package com.urukmap.app.car
data class CarNavigationState(
val lat: Double = 0.0,
@@ -1,4 +1,4 @@
package com.siro_map.siro_maps.car
package com.urukmap.app.car
import androidx.car.app.CarAppService
import androidx.car.app.Session
@@ -1,4 +1,4 @@
package com.siro_map.siro_maps.car
package com.urukmap.app.car
import android.content.Intent
import androidx.car.app.Screen
@@ -1,4 +1,4 @@
package com.siro_map.siro_maps.car
package com.urukmap.app.car
import androidx.car.app.CarContext
import androidx.car.app.Screen
Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.1 KiB

After

Width:  |  Height:  |  Size: 9.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.0 KiB

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

After

Width:  |  Height:  |  Size: 56 KiB

@@ -2,4 +2,5 @@ distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.1.0-all.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-all.zip
+2 -2
View File
@@ -19,8 +19,8 @@ pluginManagement {
plugins {
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
id("com.android.application") version "9.0.1" apply false
id("org.jetbrains.kotlin.android") version "2.3.20" apply false
id("com.android.application") version "8.11.1" apply false
id("org.jetbrains.kotlin.android") version "2.2.20" apply false
}
include(":app")
Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.9 KiB

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.1 KiB

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 392 KiB

+3
View File
@@ -0,0 +1,3 @@
description: This file stores settings for Dart & Flutter DevTools.
documentation: https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states
extensions:
+7
View File
@@ -2,6 +2,9 @@ PODS:
- connectivity_plus (0.0.1):
- Flutter
- Flutter (1.0.0)
- flutter_secure_storage_darwin (10.0.0):
- Flutter
- FlutterMacOS
- flutter_tts (0.0.1):
- Flutter
- geolocator_apple (1.2.0):
@@ -22,6 +25,7 @@ PODS:
DEPENDENCIES:
- connectivity_plus (from `.symlinks/plugins/connectivity_plus/ios`)
- Flutter (from `Flutter`)
- flutter_secure_storage_darwin (from `.symlinks/plugins/flutter_secure_storage_darwin/darwin`)
- flutter_tts (from `.symlinks/plugins/flutter_tts/ios`)
- geolocator_apple (from `.symlinks/plugins/geolocator_apple/darwin`)
- maplibre_gl (from `.symlinks/plugins/maplibre_gl/ios`)
@@ -38,6 +42,8 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/connectivity_plus/ios"
Flutter:
:path: Flutter
flutter_secure_storage_darwin:
:path: ".symlinks/plugins/flutter_secure_storage_darwin/darwin"
flutter_tts:
:path: ".symlinks/plugins/flutter_tts/ios"
geolocator_apple:
@@ -54,6 +60,7 @@ EXTERNAL SOURCES:
SPEC CHECKSUMS:
connectivity_plus: cb623214f4e1f6ef8fe7403d580fdad517d2f7dd
Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467
flutter_secure_storage_darwin: 46e401699982ee74142909676535e0f6a7321e58
flutter_tts: 35ac3c7d42412733e795ea96ad2d7e05d0a75113
geolocator_apple: ab36aa0e8b7d7a2d7639b3b4e48308394e8cef5e
MapLibre: 7f24faba45439f80ccb0f83393c29fa32cb81952
@@ -500,7 +500,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = com.siromap.siroMaps;
PRODUCT_BUNDLE_IDENTIFIER = com.urukmap.app;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_VERSION = 5.0;
@@ -517,7 +517,7 @@
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.siromap.siroMaps.RunnerTests;
PRODUCT_BUNDLE_IDENTIFIER = com.urukmap.app.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
@@ -535,7 +535,7 @@
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.siromap.siroMaps.RunnerTests;
PRODUCT_BUNDLE_IDENTIFIER = com.urukmap.app.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
@@ -551,7 +551,7 @@
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.siromap.siroMaps.RunnerTests;
PRODUCT_BUNDLE_IDENTIFIER = com.urukmap.app.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
@@ -683,7 +683,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = com.siromap.siroMaps;
PRODUCT_BUNDLE_IDENTIFIER = com.urukmap.app;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
@@ -706,7 +706,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = com.siromap.siroMaps;
PRODUCT_BUNDLE_IDENTIFIER = com.urukmap.app;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_VERSION = 5.0;
+65 -1
View File
@@ -7,7 +7,71 @@ import UIKit
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
let result = super.application(application, didFinishLaunchingWithOptions: launchOptions)
if let controller = window?.rootViewController as? FlutterViewController {
// 1. Device Hardware Channel
let deviceChannel = FlutterMethodChannel(
name: "com.siro.siro_maps/device_hardware",
binaryMessenger: controller.binaryMessenger
)
deviceChannel.setMethodCallHandler { (call, callback) in
if call.method == "getHardwareInfo" {
var sysinfo = utsname()
uname(&sysinfo)
let machine = withUnsafePointer(to: &sysinfo.machine) {
$0.withMemoryRebound(to: CChar.self, capacity: 1) { ptr in
String(validatingUTF8: ptr)
}
} ?? UIDevice.current.model
let vendorId = UIDevice.current.identifierForVendor?.uuidString ?? ""
let info: [String: String] = [
"hardwareId": vendorId,
"brand": "Apple",
"manufacturer": "Apple",
"model": machine,
"device": UIDevice.current.model
]
callback(info)
} else {
callback(FlutterMethodNotImplemented)
}
}
// 2. Deep Link Channel
let deepLinkChannel = FlutterMethodChannel(
name: "com.siro.siro_maps/deep_link",
binaryMessenger: controller.binaryMessenger
)
deepLinkChannel.setMethodCallHandler { (call, callback) in
if call.method == "getInitialLink" {
callback(nil)
} else {
callback(FlutterMethodNotImplemented)
}
}
// 3. PiP Channel (Graceful unsupported on iOS)
let pipChannel = FlutterMethodChannel(
name: "com.siro.siro_maps/pip",
binaryMessenger: controller.binaryMessenger
)
pipChannel.setMethodCallHandler { (call, callback) in
switch call.method {
case "isPipSupported":
callback(false)
case "enterPictureInPicture":
callback(false)
case "setNavigating":
callback(true)
default:
callback(FlutterMethodNotImplemented)
}
}
}
return result
}
func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) {
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 MiB

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.4 KiB

After

Width:  |  Height:  |  Size: 7.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 KiB

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 KiB

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.7 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.4 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.8 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 43 KiB

+5 -4
View File
@@ -7,7 +7,7 @@
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>خرائط سيرو</string>
<string>Uruk Map</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
@@ -15,7 +15,7 @@
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>siro_maps</string>
<string>uruk_map</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
@@ -27,9 +27,9 @@
<key>LSRequiresIPhoneOS</key>
<true/>
<key>NSLocationWhenInUseUsageDescription</key>
<string>يستخدم تطبيق خرائط سيرو موقعك لعرض خريطة تفاعلية وتوفير الملاحة الحية الدقيقة.</string>
<string>يستخدم تطبيق خرائط أوروك (Uruk Map) موقعك لعرض خريطة تفاعلية وتوفير الملاحة الحية الدقيقة.</string>
<key>NSLocationAlwaysAndWhenInUseUsageDescription</key>
<string>يستخدم تطبيق خرائط سيرو موقعك في الخلفية لتقديم التوجيهات الصوتية الحية وتنبيهات الطريق أثناء القيادة.</string>
<string>يستخدم تطبيق خرائط أوروك (Uruk Map) موقعك في الخلفية لتقديم التوجيهات الصوتية الحية وتنبيهات الطريق أثناء القيادة.</string>
<key>UIBackgroundModes</key>
<array>
<string>location</string>
@@ -50,6 +50,7 @@
<key>CFBundleURLSchemes</key>
<array>
<string>siromaps</string>
<string>geo</string>
</array>
</dict>
</array>
@@ -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,
),
),
],
),
),
],
),
),
),
),
@@ -6,6 +6,10 @@
#include "generated_plugin_registrant.h"
#include <flutter_secure_storage_linux/flutter_secure_storage_linux_plugin.h>
void fl_register_plugins(FlPluginRegistry* registry) {
g_autoptr(FlPluginRegistrar) flutter_secure_storage_linux_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterSecureStorageLinuxPlugin");
flutter_secure_storage_linux_plugin_register_with_registrar(flutter_secure_storage_linux_registrar);
}
@@ -3,6 +3,7 @@
#
list(APPEND FLUTTER_PLUGIN_LIST
flutter_secure_storage_linux
)
list(APPEND FLUTTER_FFI_PLUGIN_LIST
@@ -6,6 +6,7 @@ import FlutterMacOS
import Foundation
import connectivity_plus
import flutter_secure_storage_darwin
import flutter_tts
import geolocator_apple
import package_info_plus
@@ -13,6 +14,7 @@ import shared_preferences_foundation
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
ConnectivityPlusPlugin.register(with: registry.registrar(forPlugin: "ConnectivityPlusPlugin"))
FlutterSecureStorageDarwinPlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStorageDarwinPlugin"))
FlutterTtsPlugin.register(with: registry.registrar(forPlugin: "FlutterTtsPlugin"))
GeolocatorPlugin.register(with: registry.registrar(forPlugin: "GeolocatorPlugin"))
FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin"))
@@ -5,10 +5,10 @@
// 'flutter create' template.
// The application's name. By default this is also the title of the Flutter window.
PRODUCT_NAME = siro_maps
PRODUCT_NAME = uruk_map
// The application's bundle identifier
PRODUCT_BUNDLE_IDENTIFIER = com.siromap.siroMaps
PRODUCT_BUNDLE_IDENTIFIER = com.urukmap.app
// The copyright displayed in application information
PRODUCT_COPYRIGHT = Copyright © 2026 com.siro_map. All rights reserved.
PRODUCT_COPYRIGHT = Copyright © 2026 com.urukmap. All rights reserved.
+52 -4
View File
@@ -178,7 +178,7 @@ packages:
source: hosted
version: "3.1.2"
crypto:
dependency: transitive
dependency: "direct main"
description:
name: crypto
sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf
@@ -294,6 +294,54 @@ packages:
url: "https://pub.dev"
source: hosted
version: "6.0.0"
flutter_secure_storage:
dependency: "direct main"
description:
name: flutter_secure_storage
sha256: d87713a152ee2f255117bdbbf43da1dea1797e0551e499e0334f0c9dcfafddd2
url: "https://pub.dev"
source: hosted
version: "11.1.1"
flutter_secure_storage_darwin:
dependency: transitive
description:
name: flutter_secure_storage_darwin
sha256: d0b136b1e21fd4081170fc394e64197046ce889e8f63f2370a13e41b832cc044
url: "https://pub.dev"
source: hosted
version: "0.4.2"
flutter_secure_storage_linux:
dependency: transitive
description:
name: flutter_secure_storage_linux
sha256: caa75bd78f017422912e3a904933113b2428eb79f981ac37acd2dd2a700458ea
url: "https://pub.dev"
source: hosted
version: "3.0.3"
flutter_secure_storage_platform_interface:
dependency: transitive
description:
name: flutter_secure_storage_platform_interface
sha256: "4bc033841169d07f690d46d89dbc3f5305b6562820822445384c82e0866e2719"
url: "https://pub.dev"
source: hosted
version: "2.1.0"
flutter_secure_storage_web:
dependency: transitive
description:
name: flutter_secure_storage_web
sha256: "073a62b3aeb866ab4ce795f960413948e51e5a42a9b0c8333b6daf5bb3208a1c"
url: "https://pub.dev"
source: hosted
version: "2.1.1"
flutter_secure_storage_windows:
dependency: transitive
description:
name: flutter_secure_storage_windows
sha256: "471951813a97006d899db4948acc654a4f28c440083ea08178935ce20b173ec1"
url: "https://pub.dev"
source: hosted
version: "4.2.2"
flutter_svg:
dependency: "direct main"
description:
@@ -728,13 +776,13 @@ packages:
source: hosted
version: "13.0.2"
permission_handler_android:
dependency: transitive
dependency: "direct overridden"
description:
name: permission_handler_android
sha256: b1ce660f4d0dcaffdf2605a19544c129fb5adda4e6fd039992ae356c4e437039
sha256: "1e3bc410ca1bf84662104b100eb126e066cb55791b7451307f9708d4007350e6"
url: "https://pub.dev"
source: hosted
version: "14.1.0"
version: "13.0.1"
permission_handler_apple:
dependency: transitive
description:
+6
View File
@@ -49,6 +49,8 @@ dependencies:
equatable: ^2.1.0
envied: ^1.3.9
connectivity_plus: ^7.3.1
flutter_secure_storage: ^11.1.1
crypto: ^3.0.6
dev_dependencies:
flutter_test:
@@ -63,6 +65,10 @@ dev_dependencies:
envied_generator: ^1.3.9
build_runner: ^2.15.1
dependency_overrides:
permission_handler_android: 13.0.1
# For information on the generic Dart part of this file, see the
# following page: https://dart.dev/tools/pub/pubspec
@@ -0,0 +1,158 @@
import 'dart:convert';
import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:siro_maps/core/constants/api_constants.dart';
import 'package:siro_maps/core/services/device_fingerprint_service.dart';
import 'package:siro_maps/data/models/place_gate.dart';
import 'package:siro_maps/data/models/place_model.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
group('PlaceGate & PlaceModel Tests', () {
test('PlaceGate parses json correctly', () {
final json = {
'name_ar': 'بوابة رقم 1 - كارفور',
'name_en': 'Gate 1 - Carrefour',
'latitude': 31.9712,
'longitude': 35.8456,
'is_main_gate': true,
};
final gate = PlaceGate.fromJson(json);
expect(gate.nameAr, equals('بوابة رقم 1 - كارفور'));
expect(gate.nameEn, equals('Gate 1 - Carrefour'));
expect(gate.latitude, equals(31.9712));
expect(gate.longitude, equals(35.8456));
expect(gate.isMainGate, isTrue);
final outJson = gate.toJson();
expect(outJson['name_ar'], equals('بوابة رقم 1 - كارفور'));
expect(outJson['is_main_gate'], isTrue);
});
test('PlaceModel parses gates array from API payload', () {
final rawPlace = {
'id': 'poi_city_mall',
'name': 'سيتي مول عمان',
'category': 'mall',
'latitude': 31.9823,
'longitude': 35.8344,
'elevation_meters': 980.5,
'gates': [
{
'name_ar': 'بوابة 1 - الطوارئ والخدمات',
'latitude': 31.9820,
'longitude': 35.8340,
'is_main_gate': false,
},
{
'name_ar': 'بوابة 2 - المدخل الرئيسي والزوار',
'latitude': 31.9825,
'longitude': 35.8346,
'is_main_gate': true,
}
]
};
final place = PlaceModel.fromJson(rawPlace);
expect(place.id, equals('poi_city_mall'));
expect(place.name, equals('سيتي مول عمان'));
expect(place.hasGates, isTrue);
expect(place.gates.length, equals(2));
expect(place.gates.first.nameAr, equals('بوابة 1 - الطوارئ والخدمات'));
expect(place.gates.last.isMainGate, isTrue);
});
test('PlaceModel handles empty or null gates gracefully', () {
final rawPlace = {
'name': 'كافيه عادي',
'category': 'cafe',
'latitude': 31.95,
'longitude': 35.91,
};
final place = PlaceModel.fromJson(rawPlace);
expect(place.hasGates, isFalse);
expect(place.gates, isEmpty);
});
});
group('DeviceFingerprintService Tests', () {
setUp(() {
SharedPreferences.setMockInitialValues({});
});
test('Generates deterministic hardware fingerprint and initializes headers', () async {
final service = DeviceFingerprintService.instance;
await service.init();
expect(service.fingerprintId, isNotEmpty);
expect(service.fingerprintId.startsWith('siro_'), isTrue);
expect(service.shortFingerprint.isNotEmpty, isTrue);
final headers = service.headers;
expect(headers.containsKey('x-device-fingerprint'), isTrue);
expect(headers['x-device-fingerprint'], equals(service.fingerprintId));
expect(headers.containsKey('x-client-version'), isTrue);
expect(headers.containsKey('x-device-platform'), isTrue);
expect(headers.containsKey('x-api-key'), isTrue);
expect(headers['x-api-key'], isNotEmpty);
});
test('Provisions dedicated consumer API key and switches activeApiKey', () async {
final service = DeviceFingerprintService.instance;
final mockClient = MockClient((request) async {
if (request.url.path.contains('/auth/device-provision')) {
return http.Response(
jsonEncode({
'apiKey': 'in_mob_unit_test_dedicated_key_998877',
'keyName': 'Siro Mobile - Unit Test Device',
'rateLimit': 60,
'plan': 'FREE',
'deviceFingerprint': service.fingerprintId,
'isNew': true,
}),
200,
headers: {'content-type': 'application/json'},
);
}
return http.Response('Not Found', 404);
});
final success = await service.provisionDedicatedApiKey(httpClient: mockClient);
expect(success, isTrue);
expect(service.activeApiKey, equals('in_mob_unit_test_dedicated_key_998877'));
expect(service.isDedicatedKeyActive, isTrue);
expect(service.rateLimit, equals(60));
expect(service.headers['x-api-key'], equals('in_mob_unit_test_dedicated_key_998877'));
});
test('Falls back gracefully to embedded key if provisioning fails or is offline', () async {
final service = DeviceFingerprintService.instance;
final offlineClient = MockClient((request) async {
return http.Response('Internal Server Error', 500);
});
service.setMockState(
fingerprint: 'siro_test_hw_offline_device',
clearApiKey: true,
);
final success = await service.provisionDedicatedApiKey(httpClient: offlineClient);
expect(success, isFalse);
// Falls back to master MapSaaS key without crashing
expect(service.activeApiKey, equals(ApiConstants.mapSaasKey));
expect(service.isDedicatedKeyActive, isFalse);
});
});
}
@@ -1,5 +1,7 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:siro_maps/core/services/deep_link_service.dart';
import 'package:siro_maps/core/services/pip_service.dart';
import 'package:siro_maps/core/services/vehicle_icon_generator.dart';
import 'package:siro_maps/core/utils/arabic_search_normalizer.dart';
@@ -43,6 +45,23 @@ void main() {
isTrue,
);
});
test('Correctly identifies category queries vs specific landmarks', () {
// General categories -> true
expect(ArabicSearchNormalizer.isCategoryQuery('مسجد'), isTrue);
expect(ArabicSearchNormalizer.isCategoryQuery('مطعم'), isTrue);
expect(ArabicSearchNormalizer.isCategoryQuery('مخبز'), isTrue);
expect(ArabicSearchNormalizer.isCategoryQuery('سوبرماركت'), isTrue);
expect(ArabicSearchNormalizer.isCategoryQuery('دكان'), isTrue);
expect(ArabicSearchNormalizer.isCategoryQuery('كافيه'), isTrue);
expect(ArabicSearchNormalizer.isCategoryQuery('صيدلية'), isTrue);
expect(ArabicSearchNormalizer.isCategoryQuery('محطة بنزين'), isTrue);
// Specific landmarks / names -> false
expect(ArabicSearchNormalizer.isCategoryQuery('المدينة الطبية'), isFalse);
expect(ArabicSearchNormalizer.isCategoryQuery('سيتي مول'), isFalse);
expect(ArabicSearchNormalizer.isCategoryQuery('دوار الداخلية'), isFalse);
});
});
group('VehicleIconGenerator Tests', () {
@@ -85,4 +104,106 @@ void main() {
expect(VehicleIconGenerator.availableColors.any((c) => c.colorValue == 0xFF007AFF), isTrue);
});
});
group('DeepLinkService Tests', () {
test('Parses basic geo URI', () {
final target = DeepLinkService.instance.parseUri('geo:31.97869,35.83426');
expect(target, isNotNull);
expect(target!.destination.latitude, closeTo(31.97869, 0.0001));
expect(target.destination.longitude, closeTo(35.83426, 0.0001));
expect(target.autoStartNavigation, isFalse);
});
test('Parses geo URI with label in parenthesis', () {
final target = DeepLinkService.instance.parseUri('geo:31.97869,35.83426?q=31.97869,35.83426(المدينة%20الطبية)');
expect(target, isNotNull);
expect(target!.destination.latitude, closeTo(31.97869, 0.0001));
expect(target.destination.longitude, closeTo(35.83426, 0.0001));
expect(target.title, equals('المدينة الطبية'));
});
test('Parses geo:0,0 query format used by Android apps', () {
final target = DeepLinkService.instance.parseUri('geo:0,0?q=31.9539,35.9106(سيتي%20مول)');
expect(target, isNotNull);
expect(target!.destination.latitude, closeTo(31.9539, 0.0001));
expect(target.destination.longitude, closeTo(35.9106, 0.0001));
expect(target.title, equals('سيتي مول'));
});
test('Parses custom siromaps://navigate with autoStartNavigation', () {
final target = DeepLinkService.instance.parseUri('siromaps://navigate?lat=31.97869&lng=35.83426&title=مدينة_الحسين_الطبية');
expect(target, isNotNull);
expect(target!.destination.latitude, closeTo(31.97869, 0.0001));
expect(target.destination.longitude, closeTo(35.83426, 0.0001));
expect(target.title, equals('مدينة_الحسين_الطبية'));
expect(target.autoStartNavigation, isTrue);
});
test('Parses custom siromaps://route with preview mode', () {
final target = DeepLinkService.instance.parseUri('siromaps://route?dlat=32.01&dlng=35.86&dname=جامعة_اليرموك');
expect(target, isNotNull);
expect(target!.destination.latitude, closeTo(32.01, 0.0001));
expect(target.destination.longitude, closeTo(35.86, 0.0001));
expect(target.title, equals('جامعة_اليرموك'));
expect(target.autoStartNavigation, isFalse);
});
test('Parses google.navigation URI intent', () {
final target = DeepLinkService.instance.parseUri('google.navigation:q=31.97869,35.83426&mode=d');
expect(target, isNotNull);
expect(target!.destination.latitude, closeTo(31.97869, 0.0001));
expect(target.destination.longitude, closeTo(35.83426, 0.0001));
expect(target.autoStartNavigation, isTrue);
});
test('Parses Google Maps URL', () {
final target = DeepLinkService.instance.parseUri('https://www.google.com/maps/dir/?api=1&destination=31.97869,35.83426');
expect(target, isNotNull);
expect(target!.destination.latitude, closeTo(31.97869, 0.0001));
expect(target.destination.longitude, closeTo(35.83426, 0.0001));
});
test('Parses Universal Siro Web Link', () {
final target = DeepLinkService.instance.parseUri('https://maps.siro.app/navigate?lat=31.97869&lng=35.83426&title=المدينة%20الطبية');
expect(target, isNotNull);
expect(target!.destination.latitude, closeTo(31.97869, 0.0001));
expect(target.destination.longitude, closeTo(35.83426, 0.0001));
expect(target.title, equals('المدينة الطبية'));
expect(target.autoStartNavigation, isTrue);
});
test('Returns null on invalid or empty URIs', () {
expect(DeepLinkService.instance.parseUri(''), isNull);
expect(DeepLinkService.instance.parseUri('invalid_link_without_coords'), isNull);
expect(DeepLinkService.instance.parseUri('geo:invalid,coords'), isNull);
});
});
group('PipService Tests', () {
test('PipService initializes and reports supported', () async {
final pip = PipService.instance;
pip.init();
final supported = await pip.isPipSupported();
expect(supported, isTrue);
});
test('PipService toggles and enters/exits PiP mode correctly', () async {
final pip = PipService.instance;
await pip.exitPictureInPicture();
expect(pip.isInPipMode.value, isFalse);
await pip.enterPictureInPicture();
expect(pip.isInPipMode.value, isTrue);
await pip.exitPictureInPicture();
expect(pip.isInPipMode.value, isFalse);
await pip.togglePip();
expect(pip.isInPipMode.value, isTrue);
await pip.togglePip();
expect(pip.isInPipMode.value, isFalse);
});
});
}
+1 -1
View File
@@ -27,7 +27,7 @@ void main() {
),
);
expect(find.text('خرائط سيرو'), findsOneWidget);
expect(find.text('خرائط أوروك'), findsOneWidget);
expect(find.textContaining('v2.4 PRO'), findsOneWidget);
await tester.pump(const Duration(milliseconds: 3000));
@@ -7,6 +7,7 @@
#include "generated_plugin_registrant.h"
#include <connectivity_plus/connectivity_plus_windows_plugin.h>
#include <flutter_secure_storage_windows/flutter_secure_storage_windows_plugin.h>
#include <flutter_tts/flutter_tts_plugin.h>
#include <geolocator_windows/geolocator_windows.h>
#include <permission_handler_windows/permission_handler_windows_plugin.h>
@@ -14,6 +15,8 @@
void RegisterPlugins(flutter::PluginRegistry* registry) {
ConnectivityPlusWindowsPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("ConnectivityPlusWindowsPlugin"));
FlutterSecureStorageWindowsPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin"));
FlutterTtsPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("FlutterTtsPlugin"));
GeolocatorWindowsRegisterWithRegistrar(
@@ -4,6 +4,7 @@
list(APPEND FLUTTER_PLUGIN_LIST
connectivity_plus
flutter_secure_storage_windows
flutter_tts
geolocator_windows
permission_handler_windows
@@ -1 +1 @@
{"info":"This is a generated file; do not edit or check into version control.","plugins":{"ios":[{"name":"maplibre_gl","path":"/Users/hamzaaleghwairyeen/.pub-cache/hosted/pub.dev/maplibre_gl-0.25.0/","native_build":true,"dependencies":[],"dev_dependency":false}],"android":[{"name":"maplibre_gl","path":"/Users/hamzaaleghwairyeen/.pub-cache/hosted/pub.dev/maplibre_gl-0.25.0/","native_build":true,"dependencies":[],"dev_dependency":false}],"macos":[],"linux":[],"windows":[],"web":[{"name":"maplibre_gl_web","path":"/Users/hamzaaleghwairyeen/.pub-cache/hosted/pub.dev/maplibre_gl_web-0.25.0/","dependencies":[],"dev_dependency":false}]},"dependencyGraph":[{"name":"maplibre_gl","dependencies":["maplibre_gl_web"]},{"name":"maplibre_gl_web","dependencies":[]}],"date_created":"2026-07-19 03:20:44.753570","version":"3.32.8","swift_package_manager_enabled":{"ios":false,"macos":false}}
{"info":"This is a generated file; do not edit or check into version control.","plugins":{"ios":[{"name":"maplibre_gl","path":"/Users/hamzaaleghwairyeen/.pub-cache/hosted/pub.dev/maplibre_gl-0.25.0/","native_build":true,"dependencies":[],"dev_dependency":false}],"android":[{"name":"maplibre_gl","path":"/Users/hamzaaleghwairyeen/.pub-cache/hosted/pub.dev/maplibre_gl-0.25.0/","native_build":true,"dependencies":[],"dev_dependency":false}],"macos":[],"linux":[],"windows":[],"web":[{"name":"maplibre_gl_web","path":"/Users/hamzaaleghwairyeen/.pub-cache/hosted/pub.dev/maplibre_gl_web-0.25.0/","dependencies":[],"dev_dependency":false}]},"dependencyGraph":[{"name":"maplibre_gl","dependencies":["maplibre_gl_web"]},{"name":"maplibre_gl_web","dependencies":[]}],"date_created":"2026-09-15 15:40:45.743205","version":"3.41.2","swift_package_manager_enabled":{"ios":false,"macos":false}}
+7 -7
View File
@@ -163,10 +163,10 @@ packages:
dependency: transitive
description:
name: matcher
sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861
sha256: "12956d0ad8390bbcc63ca2e1469c0619946ccb52809807067a7020d57e647aa6"
url: "https://pub.dev"
source: hosted
version: "0.12.19"
version: "0.12.18"
material_color_utilities:
dependency: transitive
description:
@@ -179,10 +179,10 @@ packages:
dependency: transitive
description:
name: meta
sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349"
sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394"
url: "https://pub.dev"
source: hosted
version: "1.18.0"
version: "1.17.0"
path:
dependency: transitive
description:
@@ -256,10 +256,10 @@ packages:
dependency: transitive
description:
name: test_api
sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e"
sha256: "93167629bfc610f71560ab9312acdda4959de4df6fac7492c89ff0d3886f6636"
url: "https://pub.dev"
source: hosted
version: "0.7.11"
version: "0.7.9"
typed_data:
dependency: transitive
description:
@@ -301,5 +301,5 @@ packages:
source: hosted
version: "6.6.1"
sdks:
dart: ">=3.10.0-0 <4.0.0"
dart: ">=3.9.0-0 <4.0.0"
flutter: ">=3.22.0"
+7 -7
View File
@@ -164,10 +164,10 @@ packages:
dependency: transitive
description:
name: matcher
sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861
sha256: "12956d0ad8390bbcc63ca2e1469c0619946ccb52809807067a7020d57e647aa6"
url: "https://pub.dev"
source: hosted
version: "0.12.19"
version: "0.12.18"
material_color_utilities:
dependency: transitive
description:
@@ -180,10 +180,10 @@ packages:
dependency: transitive
description:
name: meta
sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349"
sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394"
url: "https://pub.dev"
source: hosted
version: "1.18.0"
version: "1.17.0"
path:
dependency: transitive
description:
@@ -257,10 +257,10 @@ packages:
dependency: transitive
description:
name: test_api
sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e"
sha256: "93167629bfc610f71560ab9312acdda4959de4df6fac7492c89ff0d3886f6636"
url: "https://pub.dev"
source: hosted
version: "0.7.11"
version: "0.7.9"
typed_data:
dependency: transitive
description:
@@ -302,5 +302,5 @@ packages:
source: hosted
version: "6.6.1"
sdks:
dart: ">=3.10.0-0 <4.0.0"
dart: ">=3.9.0-0 <4.0.0"
flutter: ">=3.22.0"
@@ -0,0 +1,3 @@
description: This file stores settings for Dart & Flutter DevTools.
documentation: https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states
extensions:
+6 -6
View File
@@ -584,10 +584,10 @@ packages:
dependency: transitive
description:
name: matcher
sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861
sha256: "12956d0ad8390bbcc63ca2e1469c0619946ccb52809807067a7020d57e647aa6"
url: "https://pub.dev"
source: hosted
version: "0.12.19"
version: "0.12.18"
material_color_utilities:
dependency: transitive
description:
@@ -600,10 +600,10 @@ packages:
dependency: transitive
description:
name: meta
sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349"
sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394"
url: "https://pub.dev"
source: hosted
version: "1.18.0"
version: "1.17.0"
mime:
dependency: transitive
description:
@@ -973,10 +973,10 @@ packages:
dependency: transitive
description:
name: test_api
sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e"
sha256: "93167629bfc610f71560ab9312acdda4959de4df6fac7492c89ff0d3886f6636"
url: "https://pub.dev"
source: hosted
version: "0.7.11"
version: "0.7.9"
typed_data:
dependency: transitive
description: