feat: add device authentication, place gates support, and PiP navigation features
@@ -6,6 +6,7 @@ import { ApiKey } from './entities/api-key.entity';
|
|||||||
import { RedisModule } from '../common/redis.module';
|
import { RedisModule } from '../common/redis.module';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
import { TenantController } from './tenant.controller';
|
import { TenantController } from './tenant.controller';
|
||||||
|
import { DeviceAuthController } from './device-auth.controller';
|
||||||
import { FirebaseAdminService } from './firebase-admin.service';
|
import { FirebaseAdminService } from './firebase-admin.service';
|
||||||
import { FirebaseAuthGuard } from './guards/firebase-auth.guard';
|
import { FirebaseAuthGuard } from './guards/firebase-auth.guard';
|
||||||
|
|
||||||
@@ -15,7 +16,7 @@ import { FirebaseAuthGuard } from './guards/firebase-auth.guard';
|
|||||||
TypeOrmModule.forFeature([Tenant, ApiKey]),
|
TypeOrmModule.forFeature([Tenant, ApiKey]),
|
||||||
RedisModule,
|
RedisModule,
|
||||||
],
|
],
|
||||||
controllers: [TenantController],
|
controllers: [TenantController, DeviceAuthController],
|
||||||
providers: [AuthService, FirebaseAdminService, FirebaseAuthGuard],
|
providers: [AuthService, FirebaseAdminService, FirebaseAuthGuard],
|
||||||
exports: [AuthService, FirebaseAdminService, FirebaseAuthGuard],
|
exports: [AuthService, FirebaseAdminService, FirebaseAuthGuard],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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 { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { Repository } from 'typeorm';
|
import { Repository } from 'typeorm';
|
||||||
import { createHash } from 'crypto';
|
import { createHash } from 'crypto';
|
||||||
import { ApiKey } from './entities/api-key.entity';
|
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';
|
import { RedisService } from '../common/redis.service';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -227,4 +227,78 @@ export class AuthService {
|
|||||||
|
|
||||||
return tenant;
|
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;
|
||||||
|
}
|
||||||
@@ -111,7 +111,9 @@ export class GeocodingService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let regionCondition = '';
|
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')`;
|
regionCondition = `AND (region = '${targetRegion}' OR region = 'global')`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -121,9 +123,34 @@ export class GeocodingService {
|
|||||||
'' as neighbourhood, '' as district, '' as governorate,
|
'' as neighbourhood, '' as district, '' as governorate,
|
||||||
latitude, longitude, address, region, source, popularity_score,
|
latitude, longitude, address, region, source, popularity_score,
|
||||||
${hasLocation ? 'ST_DistanceSphere(location, ST_SetSRID(ST_MakePoint($3::float, $2::float), 4326))' : '0'} as distance,
|
${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
|
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}
|
${locationCondition}
|
||||||
${regionCondition}
|
${regionCondition}
|
||||||
ORDER BY ${hasLocation ? 'distance ASC, (normalized_name <-> $1) ASC' : '(normalized_name <-> $1) ASC'}
|
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 distKm = hasLocation ? (Number(r.distance) / 1000) : 0;
|
||||||
const proximityScore = hasLocation ? (1.0 / (1.0 + distKm * 0.5)) : 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%)
|
// When location is available, proximity is heavily prioritized (60%)
|
||||||
const totalScore = hasLocation
|
let totalScore = hasLocation
|
||||||
? (proximityScore * 0.60) + (textScore * 0.30) + (popularityScore * 0.10)
|
? (proximityScore * 0.60) + (textScore * 0.30) + (popularityScore * 0.10)
|
||||||
: (textScore * 0.65) + (popularityScore * 0.35);
|
: (textScore * 0.65) + (popularityScore * 0.35);
|
||||||
|
|
||||||
|
totalScore *= sourceMultiplier;
|
||||||
|
|
||||||
return { ...r, totalScore };
|
return { ...r, totalScore };
|
||||||
})
|
})
|
||||||
.sort((a, b) => b.totalScore - a.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 { Cron, CronExpression } from '@nestjs/schedule';
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { Repository } from 'typeorm';
|
import { Repository } from 'typeorm';
|
||||||
import { OsmPointWithArea } from './entities/osm-point-with-area.entity';
|
import { OsmPointWithArea } from './entities/osm-point-with-area.entity';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class IndexRefreshService {
|
export class IndexRefreshService implements OnApplicationBootstrap {
|
||||||
private readonly logger = new Logger(IndexRefreshService.name);
|
private readonly logger = new Logger(IndexRefreshService.name);
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@@ -13,6 +13,85 @@ export class IndexRefreshService {
|
|||||||
private readonly repo: Repository<OsmPointWithArea>, // Use any repository to execute raw SQL
|
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
|
// Run every 5 minutes
|
||||||
@Cron(CronExpression.EVERY_5_MINUTES)
|
@Cron(CronExpression.EVERY_5_MINUTES)
|
||||||
async handleCron() {
|
async handleCron() {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import java.io.FileInputStream
|
|||||||
|
|
||||||
plugins {
|
plugins {
|
||||||
id("com.android.application")
|
id("com.android.application")
|
||||||
|
id("kotlin-android")
|
||||||
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
|
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
|
||||||
id("dev.flutter.flutter-gradle-plugin")
|
id("dev.flutter.flutter-gradle-plugin")
|
||||||
}
|
}
|
||||||
@@ -14,7 +15,7 @@ if (keystorePropertiesFile.exists()) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
android {
|
android {
|
||||||
namespace = "com.siro_map.siro_maps"
|
namespace = "com.urukmap.app"
|
||||||
compileSdk = flutter.compileSdkVersion
|
compileSdk = flutter.compileSdkVersion
|
||||||
ndkVersion = flutter.ndkVersion
|
ndkVersion = flutter.ndkVersion
|
||||||
|
|
||||||
@@ -23,9 +24,13 @@ android {
|
|||||||
targetCompatibility = JavaVersion.VERSION_17
|
targetCompatibility = JavaVersion.VERSION_17
|
||||||
}
|
}
|
||||||
|
|
||||||
|
kotlinOptions {
|
||||||
|
jvmTarget = JavaVersion.VERSION_17.toString()
|
||||||
|
}
|
||||||
|
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
applicationId = "com.siro_map.siro_maps"
|
applicationId = "com.urukmap.app"
|
||||||
minSdk = 23
|
minSdk = flutter.minSdkVersion
|
||||||
targetSdk = flutter.targetSdkVersion
|
targetSdk = flutter.targetSdkVersion
|
||||||
versionCode = flutter.versionCode
|
versionCode = flutter.versionCode
|
||||||
versionName = flutter.versionName
|
versionName = flutter.versionName
|
||||||
@@ -71,12 +76,6 @@ dependencies {
|
|||||||
implementation("androidx.car.app:app-projected:1.4.0")
|
implementation("androidx.car.app:app-projected:1.4.0")
|
||||||
}
|
}
|
||||||
|
|
||||||
kotlin {
|
|
||||||
compilerOptions {
|
|
||||||
jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
flutter {
|
flutter {
|
||||||
source = "../.."
|
source = "../.."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,8 +29,8 @@
|
|||||||
-keep class androidx.car.app.validation.** { *; }
|
-keep class androidx.car.app.validation.** { *; }
|
||||||
|
|
||||||
# Keep our custom CarAppService, Session, Screen and State models
|
# Keep our custom CarAppService, Session, Screen and State models
|
||||||
-keep class com.siro_map.siro_maps.car.** { *; }
|
-keep class com.urukmap.app.car.** { *; }
|
||||||
-keepclassmembers class com.siro_map.siro_maps.car.** { *; }
|
-keepclassmembers class com.urukmap.app.car.** { *; }
|
||||||
|
|
||||||
-dontwarn androidx.car.app.**
|
-dontwarn androidx.car.app.**
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
<uses-permission android:name="android.permission.WAKE_LOCK"/>
|
<uses-permission android:name="android.permission.WAKE_LOCK"/>
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:label="خرائط سيرو"
|
android:label="Uruk Map"
|
||||||
android:name="${applicationName}"
|
android:name="${applicationName}"
|
||||||
android:icon="@mipmap/ic_launcher">
|
android:icon="@mipmap/ic_launcher">
|
||||||
<activity
|
<activity
|
||||||
@@ -51,6 +51,23 @@
|
|||||||
<category android:name="android.intent.category.BROWSABLE" />
|
<category android:name="android.intent.category.BROWSABLE" />
|
||||||
<data android:scheme="geo" />
|
<data android:scheme="geo" />
|
||||||
</intent-filter>
|
</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>
|
</activity>
|
||||||
<!-- Don't delete the meta-data below.
|
<!-- Don't delete the meta-data below.
|
||||||
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
|
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(
|
data class CarNavigationState(
|
||||||
val lat: Double = 0.0,
|
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.CarAppService
|
||||||
import androidx.car.app.Session
|
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 android.content.Intent
|
||||||
import androidx.car.app.Screen
|
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.CarContext
|
||||||
import androidx.car.app.Screen
|
import androidx.car.app.Screen
|
||||||
|
Before Width: | Height: | Size: 7.1 KiB After Width: | Height: | Size: 9.7 KiB |
|
Before Width: | Height: | Size: 4.0 KiB After Width: | Height: | Size: 5.1 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 23 KiB After Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 40 KiB After Width: | Height: | Size: 56 KiB |
@@ -2,4 +2,5 @@ distributionBase=GRADLE_USER_HOME
|
|||||||
distributionPath=wrapper/dists
|
distributionPath=wrapper/dists
|
||||||
zipStoreBase=GRADLE_USER_HOME
|
zipStoreBase=GRADLE_USER_HOME
|
||||||
zipStorePath=wrapper/dists
|
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
|
||||||
|
|
||||||
|
|||||||
@@ -19,8 +19,8 @@ pluginManagement {
|
|||||||
|
|
||||||
plugins {
|
plugins {
|
||||||
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
|
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
|
||||||
id("com.android.application") version "9.0.1" apply false
|
id("com.android.application") version "8.11.1" apply false
|
||||||
id("org.jetbrains.kotlin.android") version "2.3.20" apply false
|
id("org.jetbrains.kotlin.android") version "2.2.20" apply false
|
||||||
}
|
}
|
||||||
|
|
||||||
include(":app")
|
include(":app")
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 3.9 KiB After Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 4.1 KiB After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 392 KiB |
@@ -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:
|
||||||
@@ -2,6 +2,9 @@ PODS:
|
|||||||
- connectivity_plus (0.0.1):
|
- connectivity_plus (0.0.1):
|
||||||
- Flutter
|
- Flutter
|
||||||
- Flutter (1.0.0)
|
- Flutter (1.0.0)
|
||||||
|
- flutter_secure_storage_darwin (10.0.0):
|
||||||
|
- Flutter
|
||||||
|
- FlutterMacOS
|
||||||
- flutter_tts (0.0.1):
|
- flutter_tts (0.0.1):
|
||||||
- Flutter
|
- Flutter
|
||||||
- geolocator_apple (1.2.0):
|
- geolocator_apple (1.2.0):
|
||||||
@@ -22,6 +25,7 @@ PODS:
|
|||||||
DEPENDENCIES:
|
DEPENDENCIES:
|
||||||
- connectivity_plus (from `.symlinks/plugins/connectivity_plus/ios`)
|
- connectivity_plus (from `.symlinks/plugins/connectivity_plus/ios`)
|
||||||
- Flutter (from `Flutter`)
|
- Flutter (from `Flutter`)
|
||||||
|
- flutter_secure_storage_darwin (from `.symlinks/plugins/flutter_secure_storage_darwin/darwin`)
|
||||||
- flutter_tts (from `.symlinks/plugins/flutter_tts/ios`)
|
- flutter_tts (from `.symlinks/plugins/flutter_tts/ios`)
|
||||||
- geolocator_apple (from `.symlinks/plugins/geolocator_apple/darwin`)
|
- geolocator_apple (from `.symlinks/plugins/geolocator_apple/darwin`)
|
||||||
- maplibre_gl (from `.symlinks/plugins/maplibre_gl/ios`)
|
- maplibre_gl (from `.symlinks/plugins/maplibre_gl/ios`)
|
||||||
@@ -38,6 +42,8 @@ EXTERNAL SOURCES:
|
|||||||
:path: ".symlinks/plugins/connectivity_plus/ios"
|
:path: ".symlinks/plugins/connectivity_plus/ios"
|
||||||
Flutter:
|
Flutter:
|
||||||
:path: Flutter
|
:path: Flutter
|
||||||
|
flutter_secure_storage_darwin:
|
||||||
|
:path: ".symlinks/plugins/flutter_secure_storage_darwin/darwin"
|
||||||
flutter_tts:
|
flutter_tts:
|
||||||
:path: ".symlinks/plugins/flutter_tts/ios"
|
:path: ".symlinks/plugins/flutter_tts/ios"
|
||||||
geolocator_apple:
|
geolocator_apple:
|
||||||
@@ -54,6 +60,7 @@ EXTERNAL SOURCES:
|
|||||||
SPEC CHECKSUMS:
|
SPEC CHECKSUMS:
|
||||||
connectivity_plus: cb623214f4e1f6ef8fe7403d580fdad517d2f7dd
|
connectivity_plus: cb623214f4e1f6ef8fe7403d580fdad517d2f7dd
|
||||||
Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467
|
Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467
|
||||||
|
flutter_secure_storage_darwin: 46e401699982ee74142909676535e0f6a7321e58
|
||||||
flutter_tts: 35ac3c7d42412733e795ea96ad2d7e05d0a75113
|
flutter_tts: 35ac3c7d42412733e795ea96ad2d7e05d0a75113
|
||||||
geolocator_apple: ab36aa0e8b7d7a2d7639b3b4e48308394e8cef5e
|
geolocator_apple: ab36aa0e8b7d7a2d7639b3b4e48308394e8cef5e
|
||||||
MapLibre: 7f24faba45439f80ccb0f83393c29fa32cb81952
|
MapLibre: 7f24faba45439f80ccb0f83393c29fa32cb81952
|
||||||
|
|||||||
@@ -500,7 +500,7 @@
|
|||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.siromap.siroMaps;
|
PRODUCT_BUNDLE_IDENTIFIER = com.urukmap.app;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 5.0;
|
||||||
@@ -517,7 +517,7 @@
|
|||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
MARKETING_VERSION = 1.0;
|
MARKETING_VERSION = 1.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.siromap.siroMaps.RunnerTests;
|
PRODUCT_BUNDLE_IDENTIFIER = com.urukmap.app.RunnerTests;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
|
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
|
||||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||||
@@ -535,7 +535,7 @@
|
|||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
MARKETING_VERSION = 1.0;
|
MARKETING_VERSION = 1.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.siromap.siroMaps.RunnerTests;
|
PRODUCT_BUNDLE_IDENTIFIER = com.urukmap.app.RunnerTests;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 5.0;
|
||||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
|
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
|
||||||
@@ -551,7 +551,7 @@
|
|||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
MARKETING_VERSION = 1.0;
|
MARKETING_VERSION = 1.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.siromap.siroMaps.RunnerTests;
|
PRODUCT_BUNDLE_IDENTIFIER = com.urukmap.app.RunnerTests;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 5.0;
|
||||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
|
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
|
||||||
@@ -683,7 +683,7 @@
|
|||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.siromap.siroMaps;
|
PRODUCT_BUNDLE_IDENTIFIER = com.urukmap.app;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
||||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||||
@@ -706,7 +706,7 @@
|
|||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.siromap.siroMaps;
|
PRODUCT_BUNDLE_IDENTIFIER = com.urukmap.app;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 5.0;
|
||||||
|
|||||||
@@ -7,7 +7,71 @@ import UIKit
|
|||||||
_ application: UIApplication,
|
_ application: UIApplication,
|
||||||
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
|
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
|
||||||
) -> Bool {
|
) -> 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) {
|
func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) {
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 1.0 MiB After Width: | Height: | Size: 1.1 MiB |
|
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 1.8 KiB |
|
Before Width: | Height: | Size: 3.2 KiB After Width: | Height: | Size: 3.9 KiB |
|
Before Width: | Height: | Size: 5.4 KiB After Width: | Height: | Size: 7.2 KiB |
|
Before Width: | Height: | Size: 2.2 KiB After Width: | Height: | Size: 2.6 KiB |
|
Before Width: | Height: | Size: 5.2 KiB After Width: | Height: | Size: 6.8 KiB |
|
Before Width: | Height: | Size: 9.7 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 3.2 KiB After Width: | Height: | Size: 3.9 KiB |
|
Before Width: | Height: | Size: 8.4 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 35 KiB After Width: | Height: | Size: 50 KiB |
|
Before Width: | Height: | Size: 7.8 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 37 KiB |
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 43 KiB |
@@ -7,7 +7,7 @@
|
|||||||
<key>CFBundleDevelopmentRegion</key>
|
<key>CFBundleDevelopmentRegion</key>
|
||||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||||
<key>CFBundleDisplayName</key>
|
<key>CFBundleDisplayName</key>
|
||||||
<string>خرائط سيرو</string>
|
<string>Uruk Map</string>
|
||||||
<key>CFBundleExecutable</key>
|
<key>CFBundleExecutable</key>
|
||||||
<string>$(EXECUTABLE_NAME)</string>
|
<string>$(EXECUTABLE_NAME)</string>
|
||||||
<key>CFBundleIdentifier</key>
|
<key>CFBundleIdentifier</key>
|
||||||
@@ -15,7 +15,7 @@
|
|||||||
<key>CFBundleInfoDictionaryVersion</key>
|
<key>CFBundleInfoDictionaryVersion</key>
|
||||||
<string>6.0</string>
|
<string>6.0</string>
|
||||||
<key>CFBundleName</key>
|
<key>CFBundleName</key>
|
||||||
<string>siro_maps</string>
|
<string>uruk_map</string>
|
||||||
<key>CFBundlePackageType</key>
|
<key>CFBundlePackageType</key>
|
||||||
<string>APPL</string>
|
<string>APPL</string>
|
||||||
<key>CFBundleShortVersionString</key>
|
<key>CFBundleShortVersionString</key>
|
||||||
@@ -27,9 +27,9 @@
|
|||||||
<key>LSRequiresIPhoneOS</key>
|
<key>LSRequiresIPhoneOS</key>
|
||||||
<true/>
|
<true/>
|
||||||
<key>NSLocationWhenInUseUsageDescription</key>
|
<key>NSLocationWhenInUseUsageDescription</key>
|
||||||
<string>يستخدم تطبيق خرائط سيرو موقعك لعرض خريطة تفاعلية وتوفير الملاحة الحية الدقيقة.</string>
|
<string>يستخدم تطبيق خرائط أوروك (Uruk Map) موقعك لعرض خريطة تفاعلية وتوفير الملاحة الحية الدقيقة.</string>
|
||||||
<key>NSLocationAlwaysAndWhenInUseUsageDescription</key>
|
<key>NSLocationAlwaysAndWhenInUseUsageDescription</key>
|
||||||
<string>يستخدم تطبيق خرائط سيرو موقعك في الخلفية لتقديم التوجيهات الصوتية الحية وتنبيهات الطريق أثناء القيادة.</string>
|
<string>يستخدم تطبيق خرائط أوروك (Uruk Map) موقعك في الخلفية لتقديم التوجيهات الصوتية الحية وتنبيهات الطريق أثناء القيادة.</string>
|
||||||
<key>UIBackgroundModes</key>
|
<key>UIBackgroundModes</key>
|
||||||
<array>
|
<array>
|
||||||
<string>location</string>
|
<string>location</string>
|
||||||
@@ -50,6 +50,7 @@
|
|||||||
<key>CFBundleURLSchemes</key>
|
<key>CFBundleURLSchemes</key>
|
||||||
<array>
|
<array>
|
||||||
<string>siromaps</string>
|
<string>siromaps</string>
|
||||||
|
<string>geo</string>
|
||||||
</array>
|
</array>
|
||||||
</dict>
|
</dict>
|
||||||
</array>
|
</array>
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ class ApiConstants {
|
|||||||
static const String mapSaasPlaces = 'https://map-saas.intaleqapp.com/api/geocoding/places';
|
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 mapSaasTelemetry = 'https://map-saas.intaleqapp.com/api/telemetry';
|
||||||
static const String mapSaasStyleBase = 'https://map-saas.intaleqapp.com/api/maps/style.json';
|
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';
|
static const String googlePlacesNearby = 'https://maps.googleapis.com/maps/api/place/nearbysearch/json';
|
||||||
|
|
||||||
// 50 km Radius Threshold (Strictly as specified)
|
// 50 km Radius Threshold (Strictly as specified)
|
||||||
|
|||||||
@@ -20,6 +20,10 @@ class AppColors {
|
|||||||
static const Color tacticalNavy = Color(0xFF0B192C);
|
static const Color tacticalNavy = Color(0xFF0B192C);
|
||||||
static const Color tacticalEmerald = Color(0xFF059669);
|
static const Color tacticalEmerald = Color(0xFF059669);
|
||||||
static const Color sovereignGold = Color(0xFFD97706);
|
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);
|
static const Color coralDanger = Color(0xFFDC2626);
|
||||||
|
|
||||||
// Borders & Dividers
|
// 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;
|
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 {
|
class PlaceModel {
|
||||||
final String id;
|
final String id;
|
||||||
final String name;
|
final String name;
|
||||||
@@ -7,6 +9,7 @@ class PlaceModel {
|
|||||||
final double elevationMeters; // GPS Altitude AMSL in meters (defaults to 0.0)
|
final double elevationMeters; // GPS Altitude AMSL in meters (defaults to 0.0)
|
||||||
final double? distanceKm;
|
final double? distanceKm;
|
||||||
final String? address;
|
final String? address;
|
||||||
|
final List<PlaceGate> gates;
|
||||||
|
|
||||||
PlaceModel({
|
PlaceModel({
|
||||||
required this.id,
|
required this.id,
|
||||||
@@ -17,8 +20,11 @@ class PlaceModel {
|
|||||||
this.elevationMeters = 0.0,
|
this.elevationMeters = 0.0,
|
||||||
this.distanceKm,
|
this.distanceKm,
|
||||||
this.address,
|
this.address,
|
||||||
|
this.gates = const [],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
bool get hasGates => gates.isNotEmpty;
|
||||||
|
|
||||||
factory PlaceModel.fromJson(Map<String, dynamic> json) {
|
factory PlaceModel.fromJson(Map<String, dynamic> json) {
|
||||||
final rawElev = json['elevation_meters'] ?? json['elevation'] ?? json['altitude'] ?? 0.0;
|
final rawElev = json['elevation_meters'] ?? json['elevation'] ?? json['altitude'] ?? 0.0;
|
||||||
double elev = 0.0;
|
double elev = 0.0;
|
||||||
@@ -28,6 +34,14 @@ class PlaceModel {
|
|||||||
elev = double.tryParse(rawElev.toString()) ?? 0.0;
|
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(
|
return PlaceModel(
|
||||||
id: json['id']?.toString() ?? '',
|
id: json['id']?.toString() ?? '',
|
||||||
name: json['name']?.toString() ?? '',
|
name: json['name']?.toString() ?? '',
|
||||||
@@ -39,6 +53,7 @@ class PlaceModel {
|
|||||||
? (json['distanceKm'] as num).toDouble()
|
? (json['distanceKm'] as num).toDouble()
|
||||||
: null,
|
: null,
|
||||||
address: json['address']?.toString() ?? json['neighborhood']?.toString(),
|
address: json['address']?.toString() ?? json['neighborhood']?.toString(),
|
||||||
|
gates: parsedGates,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52,6 +67,7 @@ class PlaceModel {
|
|||||||
'elevation_meters': elevationMeters,
|
'elevation_meters': elevationMeters,
|
||||||
'altitude': elevationMeters,
|
'altitude': elevationMeters,
|
||||||
if (address != null) 'address': address,
|
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:http/http.dart' as http;
|
||||||
import 'package:intaleq_maps/intaleq_maps.dart';
|
import 'package:intaleq_maps/intaleq_maps.dart';
|
||||||
import '../../core/constants/api_constants.dart';
|
import '../../core/constants/api_constants.dart';
|
||||||
|
import '../../core/services/device_fingerprint_service.dart';
|
||||||
import '../../core/utils/polyline_decoder.dart';
|
import '../../core/utils/polyline_decoder.dart';
|
||||||
import '../../core/utils/arabic_search_normalizer.dart';
|
import '../../core/utils/arabic_search_normalizer.dart';
|
||||||
import '../../core/services/location_service.dart';
|
import '../../core/services/location_service.dart';
|
||||||
@@ -15,6 +16,12 @@ class MapSaasRepository {
|
|||||||
|
|
||||||
MapSaasRepository({http.Client? client}) : client = client ?? http.Client();
|
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
|
/// Fetch primary and alternative routes from MapSaaS
|
||||||
Future<List<RouteData>> getRoute({
|
Future<List<RouteData>> getRoute({
|
||||||
required LatLng origin,
|
required LatLng origin,
|
||||||
@@ -43,7 +50,7 @@ class MapSaasRepository {
|
|||||||
try {
|
try {
|
||||||
final response = await client.get(
|
final response = await client.get(
|
||||||
saasUri,
|
saasUri,
|
||||||
headers: {'x-api-key': ApiConstants.mapSaasKey},
|
headers: _baseHeaders,
|
||||||
);
|
);
|
||||||
print("📥 [MapSaasRepo] Route response HTTP status: ${response.statusCode} (${response.body.length} bytes)");
|
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 uri = Uri.parse(ApiConstants.mapSaasSearch).replace(queryParameters: queryParams);
|
||||||
final response = await client.get(
|
final response = await client.get(
|
||||||
uri,
|
uri,
|
||||||
headers: {'x-api-key': ApiConstants.mapSaasKey},
|
headers: _baseHeaders,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (response.statusCode == 200) {
|
if (response.statusCode == 200) {
|
||||||
@@ -232,14 +239,12 @@ class MapSaasRepository {
|
|||||||
return distM <= ApiConstants.maxSearchRadiusMeters;
|
return distM <= ApiConstants.maxSearchRadiusMeters;
|
||||||
}).toList();
|
}).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) {
|
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(
|
final distA = LocationService.instance.calculateDistance(
|
||||||
center,
|
center,
|
||||||
LatLng(a.latitude, a.longitude),
|
LatLng(a.latitude, a.longitude),
|
||||||
@@ -248,6 +253,17 @@ class MapSaasRepository {
|
|||||||
center,
|
center,
|
||||||
LatLng(b.latitude, b.longitude),
|
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);
|
return distA.compareTo(distB);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -267,7 +283,7 @@ class MapSaasRepository {
|
|||||||
final response = await client.post(
|
final response = await client.post(
|
||||||
uri,
|
uri,
|
||||||
headers: {
|
headers: {
|
||||||
'x-api-key': ApiConstants.mapSaasKey,
|
..._baseHeaders,
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
},
|
},
|
||||||
body: jsonEncode({
|
body: jsonEncode({
|
||||||
@@ -308,15 +324,15 @@ class MapSaasRepository {
|
|||||||
'driver_id': driverId,
|
'driver_id': driverId,
|
||||||
'latitude': latitude,
|
'latitude': latitude,
|
||||||
'longitude': longitude,
|
'longitude': longitude,
|
||||||
'speed': speed,
|
'speed': speed < 0 ? 0.0 : speed,
|
||||||
'heading': heading,
|
'heading': heading < 0 ? 0.0 : heading,
|
||||||
'distance': distance,
|
'distance': distance,
|
||||||
'elevation': elevation,
|
'elevation': elevation,
|
||||||
};
|
};
|
||||||
final response = await client.post(
|
final response = await client.post(
|
||||||
uri,
|
uri,
|
||||||
headers: {
|
headers: {
|
||||||
'x-api-key': ApiConstants.mapSaasKey,
|
..._baseHeaders,
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
},
|
},
|
||||||
body: jsonEncode(payload),
|
body: jsonEncode(payload),
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import '../../../core/constants/app_colors.dart';
|
|||||||
import '../../../core/services/car_platform_bridge.dart';
|
import '../../../core/services/car_platform_bridge.dart';
|
||||||
import '../../../core/services/connectivity_service.dart';
|
import '../../../core/services/connectivity_service.dart';
|
||||||
import '../../../core/services/location_service.dart';
|
import '../../../core/services/location_service.dart';
|
||||||
|
import '../../../core/services/pip_service.dart';
|
||||||
import '../../../core/services/tts_service.dart';
|
import '../../../core/services/tts_service.dart';
|
||||||
import '../../../core/services/vehicle_icon_generator.dart';
|
import '../../../core/services/vehicle_icon_generator.dart';
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
@@ -770,6 +771,7 @@ class NavigationCubit extends Cubit<NavigationState> {
|
|||||||
maneuver: initialModifier,
|
maneuver: initialModifier,
|
||||||
isNavigating: true,
|
isNavigating: true,
|
||||||
);
|
);
|
||||||
|
PipService.instance.setNavigating(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
void stopNavigation() {
|
void stopNavigation() {
|
||||||
@@ -778,6 +780,7 @@ class NavigationCubit extends Cubit<NavigationState> {
|
|||||||
_movementInterpolationTimer = null;
|
_movementInterpolationTimer = null;
|
||||||
ttsService.stop();
|
ttsService.stop();
|
||||||
CarPlatformBridge.stopNavigation();
|
CarPlatformBridge.stopNavigation();
|
||||||
|
PipService.instance.setNavigating(false);
|
||||||
_lastTraveledIndexInFullRoute = 0;
|
_lastTraveledIndexInFullRoute = 0;
|
||||||
_offRouteStartTime = null;
|
_offRouteStartTime = null;
|
||||||
_hasAnnouncedEarlyStepIndex = null;
|
_hasAnnouncedEarlyStepIndex = null;
|
||||||
|
|||||||
@@ -118,6 +118,16 @@ class NavigationState extends Equatable {
|
|||||||
return '$minutes دقيقة';
|
return '$minutes دقيقة';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
String get formattedDistanceToStep {
|
||||||
|
if (distanceToNextStep >= 1000) {
|
||||||
|
return '${(distanceToNextStep / 1000).toStringAsFixed(1)} كم';
|
||||||
|
}
|
||||||
|
if (distanceToNextStep > 0) {
|
||||||
|
return '${distanceToNextStep.round()} م';
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
NavigationState copyWith({
|
NavigationState copyWith({
|
||||||
NavigationStatus? status,
|
NavigationStatus? status,
|
||||||
LatLng? myLocation,
|
LatLng? myLocation,
|
||||||
|
|||||||
@@ -3,14 +3,22 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
import 'package:intaleq_maps/intaleq_maps.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 'core/theme/app_theme.dart';
|
||||||
import 'data/repositories/map_saas_repository.dart';
|
import 'data/repositories/map_saas_repository.dart';
|
||||||
import 'logic/cubits/navigation/navigation_cubit.dart';
|
import 'logic/cubits/navigation/navigation_cubit.dart';
|
||||||
import 'views/splash/splash_view.dart';
|
import 'views/splash/splash_view.dart';
|
||||||
|
|
||||||
void main() {
|
void main() async {
|
||||||
WidgetsFlutterBinding.ensureInitialized();
|
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
|
// Purge any stuck background offline download tasks from device cache
|
||||||
unawaited(IntaleqOfflineService.instance.clearCache());
|
unawaited(IntaleqOfflineService.instance.clearCache());
|
||||||
|
|
||||||
@@ -47,7 +55,7 @@ class SiroMapsApp extends StatelessWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return MaterialApp(
|
return MaterialApp(
|
||||||
title: 'خرائط سيرو - Siro Maps',
|
title: 'خرائط أوروك - Uruk Map',
|
||||||
debugShowCheckedModeBanner: false,
|
debugShowCheckedModeBanner: false,
|
||||||
theme: AppTheme.lightTheme,
|
theme: AppTheme.lightTheme,
|
||||||
locale: const Locale('ar', 'JO'),
|
locale: const Locale('ar', 'JO'),
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import 'dart:async';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter_bloc/flutter_bloc.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/api_constants.dart';
|
||||||
import '../../core/constants/app_colors.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_cubit.dart';
|
||||||
import '../../logic/cubits/navigation/navigation_state.dart';
|
import '../../logic/cubits/navigation/navigation_state.dart';
|
||||||
|
import '../../data/models/place_model.dart';
|
||||||
import 'widgets/search_bar_widget.dart';
|
import 'widgets/search_bar_widget.dart';
|
||||||
import 'widgets/explore_panel_widget.dart';
|
import 'widgets/explore_panel_widget.dart';
|
||||||
import 'widgets/active_nav_hud_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/layer_selector_sheet.dart';
|
||||||
import 'widgets/report_hazard_sheet.dart';
|
import 'widgets/report_hazard_sheet.dart';
|
||||||
import 'widgets/add_place_sheet.dart';
|
import 'widgets/add_place_sheet.dart';
|
||||||
import 'widgets/vehicle_customizer_sheet.dart';
|
import 'widgets/vehicle_customizer_sheet.dart';
|
||||||
|
import 'widgets/about_awards_sheet.dart';
|
||||||
|
|
||||||
class MapView extends StatefulWidget {
|
class MapView extends StatefulWidget {
|
||||||
const MapView({super.key});
|
const MapView({super.key});
|
||||||
@@ -26,6 +33,7 @@ class _MapViewState extends State<MapView> {
|
|||||||
final TextEditingController _searchController = TextEditingController();
|
final TextEditingController _searchController = TextEditingController();
|
||||||
final FocusNode _searchFocusNode = FocusNode();
|
final FocusNode _searchFocusNode = FocusNode();
|
||||||
bool _isSearchFocused = false;
|
bool _isSearchFocused = false;
|
||||||
|
StreamSubscription<DeepLinkTarget>? _deepLinkSub;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@@ -34,6 +42,21 @@ class _MapViewState extends State<MapView> {
|
|||||||
_searchFocusNode.addListener(() {
|
_searchFocusNode.addListener(() {
|
||||||
if (mounted) setState(() => _isSearchFocused = _searchFocusNode.hasFocus);
|
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((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
print("📌 [MapView] PostFrameCallback: Triggering relockCameraToUser");
|
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
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
|
_deepLinkSub?.cancel();
|
||||||
_searchController.dispose();
|
_searchController.dispose();
|
||||||
_searchFocusNode.dispose();
|
_searchFocusNode.dispose();
|
||||||
super.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) {
|
void _showLayerSelector(BuildContext context, NavigationCubit cubit, MapThemeType current) {
|
||||||
showModalBottomSheet(
|
showModalBottomSheet(
|
||||||
context: context,
|
context: context,
|
||||||
@@ -80,6 +130,10 @@ class _MapViewState extends State<MapView> {
|
|||||||
Navigator.of(context).pop();
|
Navigator.of(context).pop();
|
||||||
_showVehicleCustomizer(context, cubit, cubit.state);
|
_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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final cubit = context.read<NavigationCubit>();
|
final cubit = context.read<NavigationCubit>();
|
||||||
@@ -212,21 +292,24 @@ class _MapViewState extends State<MapView> {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
builder: (context, state) {
|
builder: (context, state) {
|
||||||
return Scaffold(
|
return ValueListenableBuilder<bool>(
|
||||||
resizeToAvoidBottomInset: false,
|
valueListenable: PipService.instance.isInPipMode,
|
||||||
backgroundColor: AppColors.canvasLight,
|
builder: (context, isInPip, _) {
|
||||||
body: SizedBox.expand(
|
return Scaffold(
|
||||||
child: Stack(
|
resizeToAvoidBottomInset: false,
|
||||||
fit: StackFit.expand,
|
backgroundColor: AppColors.canvasLight,
|
||||||
children: [
|
body: SizedBox.expand(
|
||||||
// ── 1. REAL INTERACTIVE MAP ENGINE (Siro Real Tiles) ──
|
child: Stack(
|
||||||
Positioned.fill(
|
fit: StackFit.expand,
|
||||||
child: _buildRealMapEngine(context, cubit, state),
|
children: [
|
||||||
),
|
// ── 1. REAL INTERACTIVE MAP ENGINE (Siro Real Tiles) ──
|
||||||
|
Positioned.fill(
|
||||||
|
child: _buildRealMapEngine(context, cubit, state),
|
||||||
|
),
|
||||||
|
|
||||||
// ── 2. TOP SEARCH BAR, OFFLINE BANNER & EXPLORE CHIPS ──
|
// ── 2. TOP SEARCH BAR, OFFLINE BANNER & EXPLORE CHIPS ──
|
||||||
if (!state.isNavigating)
|
if (!state.isNavigating && !isInPip)
|
||||||
Positioned(
|
Positioned(
|
||||||
top: 0,
|
top: 0,
|
||||||
left: 0,
|
left: 0,
|
||||||
right: 0,
|
right: 0,
|
||||||
@@ -426,6 +509,35 @@ class _MapViewState extends State<MapView> {
|
|||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
crossAxisAlignment: CrossAxisAlignment.end,
|
crossAxisAlignment: CrossAxisAlignment.end,
|
||||||
children: [
|
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)
|
if (distStr != null)
|
||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||||
@@ -460,10 +572,14 @@ class _MapViewState extends State<MapView> {
|
|||||||
_searchController.clear();
|
_searchController.clear();
|
||||||
cubit.clearSearch();
|
cubit.clearSearch();
|
||||||
_searchFocusNode.unfocus();
|
_searchFocusNode.unfocus();
|
||||||
cubit.calculateRouteTo(
|
if (place.hasGates) {
|
||||||
LatLng(place.latitude, place.longitude),
|
_showPlaceGatesSheet(context, cubit, place);
|
||||||
title: place.name,
|
} 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 ──
|
// ── 5. ACTIVE TURN-BY-TURN HUD & BANNER (OR PIP HUD) ──
|
||||||
if (state.isNavigating)
|
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(
|
Positioned.fill(
|
||||||
child: SafeArea(
|
child: SafeArea(
|
||||||
child: ActiveNavHudWidget(
|
child: ActiveNavHudWidget(
|
||||||
@@ -925,7 +1053,7 @@ class _MapViewState extends State<MapView> {
|
|||||||
),
|
),
|
||||||
|
|
||||||
// ── 5b. INTERACTIVE LOCATION PIN PICKER HUD (Add Place & Hazard) ──
|
// ── 5b. INTERACTIVE LOCATION PIN PICKER HUD (Add Place & Hazard) ──
|
||||||
if (state.isSelectingLocationOnMap) ...[
|
if (!isInPip && state.isSelectingLocationOnMap) ...[
|
||||||
// Centered Floating Target Pin
|
// Centered Floating Target Pin
|
||||||
IgnorePointer(
|
IgnorePointer(
|
||||||
child: Center(
|
child: Center(
|
||||||
@@ -1114,7 +1242,7 @@ class _MapViewState extends State<MapView> {
|
|||||||
],
|
],
|
||||||
|
|
||||||
// ── 6. FLOATING ACTION BUTTONS (Right side, anchored to bottom) ──
|
// ── 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(
|
Positioned(
|
||||||
right: 16,
|
right: 16,
|
||||||
bottom: 28,
|
bottom: 28,
|
||||||
@@ -1131,6 +1259,14 @@ class _MapViewState extends State<MapView> {
|
|||||||
onTap: () => _showLayerSelector(context, cubit, state.mapTheme),
|
onTap: () => _showLayerSelector(context, cubit, state.mapTheme),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
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
|
// Add Place Button
|
||||||
_buildFloatingCircle(
|
_buildFloatingCircle(
|
||||||
icon: Icons.add_location_alt_rounded,
|
icon: Icons.add_location_alt_rounded,
|
||||||
@@ -1160,7 +1296,7 @@ class _MapViewState extends State<MapView> {
|
|||||||
),
|
),
|
||||||
|
|
||||||
// ── 6b. LIVE FLOATING SPEEDOMETER (Bottom left, when driving) ──
|
// ── 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(
|
Positioned(
|
||||||
left: 16,
|
left: 16,
|
||||||
bottom: 32,
|
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 'package:flutter/material.dart';
|
||||||
import '../../../../core/constants/app_colors.dart';
|
import '../../../../core/constants/app_colors.dart';
|
||||||
|
import '../../../../core/services/pip_service.dart';
|
||||||
import '../../../../logic/cubits/navigation/navigation_state.dart';
|
import '../../../../logic/cubits/navigation/navigation_state.dart';
|
||||||
|
|
||||||
class ActiveNavHudWidget extends StatelessWidget {
|
class ActiveNavHudWidget extends StatelessWidget {
|
||||||
@@ -235,32 +236,39 @@ class ActiveNavHudWidget extends StatelessWidget {
|
|||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
Row(
|
FittedBox(
|
||||||
children: [
|
fit: BoxFit.scaleDown,
|
||||||
Text(
|
alignment: Alignment.centerRight,
|
||||||
state.formattedRemainingDuration,
|
child: Row(
|
||||||
style: const TextStyle(
|
mainAxisSize: MainAxisSize.min,
|
||||||
fontFamily: '.SF Pro Text',
|
children: [
|
||||||
fontSize: 22,
|
Text(
|
||||||
fontWeight: FontWeight.w800,
|
state.formattedRemainingDuration,
|
||||||
color: AppColors.tacticalEmerald,
|
style: const TextStyle(
|
||||||
|
fontFamily: '.SF Pro Text',
|
||||||
|
fontSize: 20,
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
color: AppColors.tacticalEmerald,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
const SizedBox(width: 6),
|
||||||
const SizedBox(width: 8),
|
Text(
|
||||||
Text(
|
'• ${state.formattedRemainingDistance}',
|
||||||
'• ${state.formattedRemainingDistance}',
|
style: const TextStyle(
|
||||||
style: const TextStyle(
|
fontFamily: '.SF Pro Text',
|
||||||
fontFamily: '.SF Pro Text',
|
fontSize: 13,
|
||||||
fontSize: 14,
|
fontWeight: FontWeight.w600,
|
||||||
fontWeight: FontWeight.w600,
|
color: AppColors.textSecondary,
|
||||||
color: AppColors.textSecondary,
|
),
|
||||||
),
|
),
|
||||||
),
|
],
|
||||||
],
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 2),
|
const SizedBox(height: 2),
|
||||||
Text(
|
Text(
|
||||||
'وصول متوقع: ${state.arrivalTime}',
|
'وصول متوقع: ${state.arrivalTime}',
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
fontFamily: '.SF Pro Text',
|
fontFamily: '.SF Pro Text',
|
||||||
fontSize: 11,
|
fontSize: 11,
|
||||||
@@ -273,30 +281,58 @@ class ActiveNavHudWidget extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
// Mute Button
|
// Mute Button
|
||||||
IconButton(
|
IconButton(
|
||||||
|
visualDensity: VisualDensity.compact,
|
||||||
|
padding: const EdgeInsets.all(4),
|
||||||
|
constraints: const BoxConstraints(),
|
||||||
onPressed: onToggleMute,
|
onPressed: onToggleMute,
|
||||||
icon: Icon(
|
icon: Icon(
|
||||||
state.isMuted ? Icons.volume_off_rounded : Icons.volume_up_rounded,
|
state.isMuted ? Icons.volume_off_rounded : Icons.volume_up_rounded,
|
||||||
color: state.isMuted ? AppColors.textMuted : AppColors.appleBlue,
|
color: state.isMuted ? AppColors.textMuted : AppColors.appleBlue,
|
||||||
size: 24,
|
size: 22,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
const SizedBox(width: 4),
|
||||||
// Vehicle Customizer
|
// Vehicle Customizer
|
||||||
if (onOpenVehicleCustomizer != null)
|
if (onOpenVehicleCustomizer != null) ...[
|
||||||
IconButton(
|
IconButton(
|
||||||
|
visualDensity: VisualDensity.compact,
|
||||||
|
padding: const EdgeInsets.all(4),
|
||||||
|
constraints: const BoxConstraints(),
|
||||||
onPressed: onOpenVehicleCustomizer,
|
onPressed: onOpenVehicleCustomizer,
|
||||||
icon: const Icon(
|
icon: const Icon(
|
||||||
Icons.directions_car_filled_rounded,
|
Icons.directions_car_filled_rounded,
|
||||||
color: AppColors.appleBlue,
|
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
|
// Recenter Map
|
||||||
IconButton(
|
IconButton(
|
||||||
|
visualDensity: VisualDensity.compact,
|
||||||
|
padding: const EdgeInsets.all(4),
|
||||||
|
constraints: const BoxConstraints(),
|
||||||
onPressed: onRecenter,
|
onPressed: onRecenter,
|
||||||
icon: const Icon(
|
icon: const Icon(
|
||||||
Icons.my_location_rounded,
|
Icons.my_location_rounded,
|
||||||
color: AppColors.textSecondary,
|
color: AppColors.textSecondary,
|
||||||
size: 22,
|
size: 21,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 6),
|
const SizedBox(width: 6),
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ class _AddPlaceSheetState extends State<AddPlaceSheet> {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 18),
|
const SizedBox(height: 18),
|
||||||
const Text(
|
const Text(
|
||||||
'إضافة مكان جديد إلى خرائط سيرو',
|
'إضافة مكان جديد إلى خرائط أوروك',
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
style: TextStyle(fontSize: 16, fontWeight: FontWeight.w700),
|
style: TextStyle(fontSize: 16, fontWeight: FontWeight.w700),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ class ExplorePanelWidget extends StatelessWidget {
|
|||||||
const ExplorePanelWidget({super.key, required this.onCategorySelected});
|
const ExplorePanelWidget({super.key, required this.onCategorySelected});
|
||||||
|
|
||||||
static const List<Map<String, dynamic>> _categories = [
|
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.restaurant_rounded},
|
||||||
{'title': 'كافيهات', 'query': 'مقهى', 'icon': Icons.local_cafe_rounded},
|
{'title': 'كافيهات', 'query': 'مقهى', 'icon': Icons.local_cafe_rounded},
|
||||||
{'title': 'وقود', 'query': 'محطة وقود', 'icon': Icons.local_gas_station_rounded},
|
{'title': 'وقود', 'query': 'محطة وقود', 'icon': Icons.local_gas_station_rounded},
|
||||||
|
|||||||
@@ -6,12 +6,14 @@ class LayerSelectorSheet extends StatelessWidget {
|
|||||||
final MapThemeType currentTheme;
|
final MapThemeType currentTheme;
|
||||||
final ValueChanged<MapThemeType> onThemeChanged;
|
final ValueChanged<MapThemeType> onThemeChanged;
|
||||||
final VoidCallback? onOpenVehicleCustomizer;
|
final VoidCallback? onOpenVehicleCustomizer;
|
||||||
|
final VoidCallback? onOpenAboutAwards;
|
||||||
|
|
||||||
const LayerSelectorSheet({
|
const LayerSelectorSheet({
|
||||||
super.key,
|
super.key,
|
||||||
required this.currentTheme,
|
required this.currentTheme,
|
||||||
required this.onThemeChanged,
|
required this.onThemeChanged,
|
||||||
this.onOpenVehicleCustomizer,
|
this.onOpenVehicleCustomizer,
|
||||||
|
this.onOpenAboutAwards,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -67,9 +69,9 @@ class LayerSelectorSheet extends StatelessWidget {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
if (onOpenVehicleCustomizer != null) ...[
|
if (onOpenVehicleCustomizer != null) ...[
|
||||||
const SizedBox(height: 20),
|
const SizedBox(height: 18),
|
||||||
const Divider(height: 1, color: AppColors.borderSubtle),
|
const Divider(height: 1, color: AppColors.borderSubtle),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 14),
|
||||||
ListTile(
|
ListTile(
|
||||||
onTap: onOpenVehicleCustomizer,
|
onTap: onOpenVehicleCustomizer,
|
||||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
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),
|
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 = [
|
final List<Map<String, dynamic>> _slides = [
|
||||||
{
|
{
|
||||||
'icon': Icons.public_rounded,
|
'icon': Icons.public_rounded,
|
||||||
'title': 'خريطة بلدنا تعمل عندنا',
|
'title': 'سيادة مكانية وملاحة عربية مستقلة',
|
||||||
'desc': 'أول بنية خرائط سيادية أردنية متكاملة؛ ننهي التبعية لمزودي الخرائط الأجانب ونحمي بيانات حركة الوطن.',
|
'desc': 'بنية خرائط وملاحة سيادية متكاملة؛ ننهي التبعية لمزودي الخرائط الأجانب ونخدم الأسواق الإقليمية المحرومة من خرائط موثوقة.',
|
||||||
'badge': 'سيادة وطنية',
|
'badge': 'سيادة إقليمية',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
'icon': Icons.alt_route_rounded,
|
'icon': Icons.alt_route_rounded,
|
||||||
'title': 'توجيه محلي فائق السرعة',
|
'title': 'توجيه محلي فائق السرعة',
|
||||||
'desc': 'محرك ملاحة متطور يفهم طبيعة شوارع عمّان والمحافظات، بزمن استجابة أقل من 40 ملي ثانية.',
|
'desc': 'محرك ملاحة متطور يفهم طبيعة شبكات الطرق والمدن العربية، بزمن استجابة فائق ودقة توجيه عالية.',
|
||||||
'badge': 'أداء فائق',
|
'badge': 'أداء فائق',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
'icon': Icons.security_rounded,
|
'icon': Icons.security_rounded,
|
||||||
'title': 'أمان وخصوصية مطلقة',
|
'title': 'أمان واستقلالية مطلقة',
|
||||||
'desc': 'لا تتبع عشوائي ولا تسريب للمعلومات، مع جاهزية كاملة للعمل عند انقطاع الإنترنت الدولي.',
|
'desc': 'لا تتبع عشوائي ولا ارتهان لعقوبات أو واجهات أجنبية، مع جاهزية كاملة للعمل عند انقطاع الإنترنت الدولي.',
|
||||||
'badge': 'حماية مشفرة',
|
'badge': 'حماية مشفرة',
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
@@ -101,69 +101,74 @@ class _OnboardingViewState extends State<OnboardingView> {
|
|||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
final slide = _slides[index];
|
final slide = _slides[index];
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.all(32),
|
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16),
|
||||||
child: Column(
|
child: Center(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
child: SingleChildScrollView(
|
||||||
children: [
|
physics: const BouncingScrollPhysics(),
|
||||||
// Icon Container
|
child: Column(
|
||||||
Container(
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
width: 110,
|
children: [
|
||||||
height: 110,
|
// Icon Container
|
||||||
decoration: BoxDecoration(
|
Container(
|
||||||
color: AppColors.appleBlue.withValues(alpha: 0.08),
|
width: 90,
|
||||||
shape: BoxShape.circle,
|
height: 90,
|
||||||
),
|
decoration: BoxDecoration(
|
||||||
child: Center(
|
color: AppColors.appleBlue.withValues(alpha: 0.08),
|
||||||
child: Icon(
|
shape: BoxShape.circle,
|
||||||
slide['icon'] as IconData,
|
),
|
||||||
size: 54,
|
child: Center(
|
||||||
color: AppColors.appleBlue,
|
child: Icon(
|
||||||
|
slide['icon'] as IconData,
|
||||||
|
size: 46,
|
||||||
|
color: AppColors.appleBlue,
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
const SizedBox(height: 24),
|
||||||
),
|
// Badge
|
||||||
const SizedBox(height: 36),
|
Container(
|
||||||
// Badge
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
|
||||||
Container(
|
decoration: BoxDecoration(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
|
color: AppColors.surfaceMuted,
|
||||||
decoration: BoxDecoration(
|
borderRadius: BorderRadius.circular(16),
|
||||||
color: AppColors.surfaceMuted,
|
border: Border.all(color: AppColors.borderSubtle),
|
||||||
borderRadius: BorderRadius.circular(16),
|
),
|
||||||
border: Border.all(color: AppColors.borderSubtle),
|
child: Text(
|
||||||
),
|
slide['badge'] as String,
|
||||||
child: Text(
|
style: GoogleFonts.alexandria(
|
||||||
slide['badge'] as String,
|
fontSize: 11,
|
||||||
style: GoogleFonts.alexandria(
|
fontWeight: FontWeight.w600,
|
||||||
fontSize: 11,
|
color: AppColors.appleBlue,
|
||||||
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,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -22,10 +22,10 @@ class _SplashViewState extends State<SplashView> with SingleTickerProviderStateM
|
|||||||
super.initState();
|
super.initState();
|
||||||
_animController = AnimationController(
|
_animController = AnimationController(
|
||||||
vsync: this,
|
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),
|
CurvedAnimation(parent: _animController, curve: Curves.easeOutCubic),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -35,7 +35,7 @@ class _SplashViewState extends State<SplashView> with SingleTickerProviderStateM
|
|||||||
|
|
||||||
_animController.forward();
|
_animController.forward();
|
||||||
|
|
||||||
Future.delayed(const Duration(milliseconds: 2400), () async {
|
Future.delayed(const Duration(milliseconds: 2800), () async {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
final hasSeen = prefs.getBool('has_seen_onboarding') ?? false;
|
final hasSeen = prefs.getBool('has_seen_onboarding') ?? false;
|
||||||
@@ -70,91 +70,189 @@ class _SplashViewState extends State<SplashView> with SingleTickerProviderStateM
|
|||||||
opacity: _fadeAnimation,
|
opacity: _fadeAnimation,
|
||||||
child: ScaleTransition(
|
child: ScaleTransition(
|
||||||
scale: _scaleAnimation,
|
scale: _scaleAnimation,
|
||||||
child: Column(
|
child: SingleChildScrollView(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||||
children: [
|
child: Column(
|
||||||
// Luxury Emblem Container
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
Container(
|
children: [
|
||||||
width: 108,
|
// Luxury Uruk Emblem Container
|
||||||
height: 108,
|
Container(
|
||||||
decoration: BoxDecoration(
|
width: 114,
|
||||||
color: AppColors.pureWhite,
|
height: 114,
|
||||||
borderRadius: BorderRadius.circular(30),
|
decoration: BoxDecoration(
|
||||||
boxShadow: const [
|
color: const Color(0xFF131722),
|
||||||
BoxShadow(
|
borderRadius: BorderRadius.circular(32),
|
||||||
color: Color(0x1F0071E3),
|
border: Border.all(
|
||||||
blurRadius: 36,
|
color: AppColors.urukGold.withValues(alpha: 0.35),
|
||||||
offset: Offset(0, 12),
|
width: 1.5,
|
||||||
),
|
),
|
||||||
BoxShadow(
|
boxShadow: [
|
||||||
color: Color(0x14000000),
|
BoxShadow(
|
||||||
blurRadius: 20,
|
color: AppColors.urukGold.withValues(alpha: 0.22),
|
||||||
offset: Offset(0, 4),
|
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),
|
||||||
const SizedBox(height: 24),
|
// App Title
|
||||||
// App Title
|
Text(
|
||||||
Text(
|
'خرائط أوروك',
|
||||||
'خرائط سيرو',
|
style: GoogleFonts.alexandria(
|
||||||
style: GoogleFonts.alexandria(
|
fontSize: 28,
|
||||||
fontSize: 28,
|
fontWeight: FontWeight.w800,
|
||||||
fontWeight: FontWeight.w800,
|
color: AppColors.textPrimary,
|
||||||
color: AppColors.textPrimary,
|
letterSpacing: -0.5,
|
||||||
letterSpacing: -0.5,
|
),
|
||||||
),
|
),
|
||||||
),
|
const SizedBox(height: 6),
|
||||||
const SizedBox(height: 6),
|
// Subtitle
|
||||||
// Subtitle
|
Text(
|
||||||
Text(
|
'Uruk Map • منظومة الملاحة والسيادة المكانية',
|
||||||
'Siro Maps • منظومة السيادة المكانية',
|
style: GoogleFonts.alexandria(
|
||||||
style: GoogleFonts.alexandria(
|
fontSize: 13,
|
||||||
fontSize: 13,
|
fontWeight: FontWeight.w600,
|
||||||
fontWeight: FontWeight.w500,
|
color: AppColors.textSecondary,
|
||||||
color: AppColors.textMuted,
|
),
|
||||||
),
|
),
|
||||||
),
|
const SizedBox(height: 26),
|
||||||
const SizedBox(height: 32),
|
|
||||||
// Pill Version Badge
|
// Official Uruk International Prize Award Badge
|
||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 6),
|
constraints: const BoxConstraints(maxWidth: 340),
|
||||||
decoration: BoxDecoration(
|
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 11),
|
||||||
color: AppColors.surfaceMuted,
|
decoration: BoxDecoration(
|
||||||
borderRadius: BorderRadius.circular(20),
|
gradient: const LinearGradient(
|
||||||
border: Border.all(color: AppColors.borderSubtle),
|
colors: [
|
||||||
),
|
Color(0xFFFFFDF7),
|
||||||
child: Row(
|
Color(0xFFFFF6D8),
|
||||||
mainAxisSize: MainAxisSize.min,
|
],
|
||||||
children: [
|
begin: Alignment.topRight,
|
||||||
Container(
|
end: Alignment.bottomLeft,
|
||||||
width: 7,
|
|
||||||
height: 7,
|
|
||||||
decoration: const BoxDecoration(
|
|
||||||
color: AppColors.tacticalEmerald,
|
|
||||||
shape: BoxShape.circle,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
borderRadius: BorderRadius.circular(18),
|
||||||
Text(
|
border: Border.all(color: AppColors.urukGoldBorder, width: 1.2),
|
||||||
'🇯🇴 سيادة مكانية 100% • v2.4 PRO',
|
boxShadow: [
|
||||||
style: GoogleFonts.alexandria(
|
BoxShadow(
|
||||||
fontSize: 11,
|
color: AppColors.urukGold.withValues(alpha: 0.14),
|
||||||
fontWeight: FontWeight.w600,
|
blurRadius: 22,
|
||||||
color: AppColors.textSecondary,
|
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 "generated_plugin_registrant.h"
|
||||||
|
|
||||||
|
#include <flutter_secure_storage_linux/flutter_secure_storage_linux_plugin.h>
|
||||||
|
|
||||||
void fl_register_plugins(FlPluginRegistry* registry) {
|
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
|
list(APPEND FLUTTER_PLUGIN_LIST
|
||||||
|
flutter_secure_storage_linux
|
||||||
)
|
)
|
||||||
|
|
||||||
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import FlutterMacOS
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
import connectivity_plus
|
import connectivity_plus
|
||||||
|
import flutter_secure_storage_darwin
|
||||||
import flutter_tts
|
import flutter_tts
|
||||||
import geolocator_apple
|
import geolocator_apple
|
||||||
import package_info_plus
|
import package_info_plus
|
||||||
@@ -13,6 +14,7 @@ import shared_preferences_foundation
|
|||||||
|
|
||||||
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
||||||
ConnectivityPlusPlugin.register(with: registry.registrar(forPlugin: "ConnectivityPlusPlugin"))
|
ConnectivityPlusPlugin.register(with: registry.registrar(forPlugin: "ConnectivityPlusPlugin"))
|
||||||
|
FlutterSecureStorageDarwinPlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStorageDarwinPlugin"))
|
||||||
FlutterTtsPlugin.register(with: registry.registrar(forPlugin: "FlutterTtsPlugin"))
|
FlutterTtsPlugin.register(with: registry.registrar(forPlugin: "FlutterTtsPlugin"))
|
||||||
GeolocatorPlugin.register(with: registry.registrar(forPlugin: "GeolocatorPlugin"))
|
GeolocatorPlugin.register(with: registry.registrar(forPlugin: "GeolocatorPlugin"))
|
||||||
FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin"))
|
FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin"))
|
||||||
|
|||||||
@@ -5,10 +5,10 @@
|
|||||||
// 'flutter create' template.
|
// 'flutter create' template.
|
||||||
|
|
||||||
// The application's name. By default this is also the title of the Flutter window.
|
// 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
|
// The application's bundle identifier
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.siromap.siroMaps
|
PRODUCT_BUNDLE_IDENTIFIER = com.urukmap.app
|
||||||
|
|
||||||
// The copyright displayed in application information
|
// 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.
|
||||||
|
|||||||
@@ -178,7 +178,7 @@ packages:
|
|||||||
source: hosted
|
source: hosted
|
||||||
version: "3.1.2"
|
version: "3.1.2"
|
||||||
crypto:
|
crypto:
|
||||||
dependency: transitive
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: crypto
|
name: crypto
|
||||||
sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf
|
sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf
|
||||||
@@ -294,6 +294,54 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "6.0.0"
|
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:
|
flutter_svg:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -728,13 +776,13 @@ packages:
|
|||||||
source: hosted
|
source: hosted
|
||||||
version: "13.0.2"
|
version: "13.0.2"
|
||||||
permission_handler_android:
|
permission_handler_android:
|
||||||
dependency: transitive
|
dependency: "direct overridden"
|
||||||
description:
|
description:
|
||||||
name: permission_handler_android
|
name: permission_handler_android
|
||||||
sha256: b1ce660f4d0dcaffdf2605a19544c129fb5adda4e6fd039992ae356c4e437039
|
sha256: "1e3bc410ca1bf84662104b100eb126e066cb55791b7451307f9708d4007350e6"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "14.1.0"
|
version: "13.0.1"
|
||||||
permission_handler_apple:
|
permission_handler_apple:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|||||||
@@ -49,6 +49,8 @@ dependencies:
|
|||||||
equatable: ^2.1.0
|
equatable: ^2.1.0
|
||||||
envied: ^1.3.9
|
envied: ^1.3.9
|
||||||
connectivity_plus: ^7.3.1
|
connectivity_plus: ^7.3.1
|
||||||
|
flutter_secure_storage: ^11.1.1
|
||||||
|
crypto: ^3.0.6
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
@@ -63,6 +65,10 @@ dev_dependencies:
|
|||||||
envied_generator: ^1.3.9
|
envied_generator: ^1.3.9
|
||||||
build_runner: ^2.15.1
|
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
|
# For information on the generic Dart part of this file, see the
|
||||||
# following page: https://dart.dev/tools/pub/pubspec
|
# 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/material.dart';
|
||||||
import 'package:flutter_test/flutter_test.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/services/vehicle_icon_generator.dart';
|
||||||
import 'package:siro_maps/core/utils/arabic_search_normalizer.dart';
|
import 'package:siro_maps/core/utils/arabic_search_normalizer.dart';
|
||||||
|
|
||||||
@@ -43,6 +45,23 @@ void main() {
|
|||||||
isTrue,
|
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', () {
|
group('VehicleIconGenerator Tests', () {
|
||||||
@@ -85,4 +104,106 @@ void main() {
|
|||||||
expect(VehicleIconGenerator.availableColors.any((c) => c.colorValue == 0xFF007AFF), isTrue);
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ void main() {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(find.text('خرائط سيرو'), findsOneWidget);
|
expect(find.text('خرائط أوروك'), findsOneWidget);
|
||||||
expect(find.textContaining('v2.4 PRO'), findsOneWidget);
|
expect(find.textContaining('v2.4 PRO'), findsOneWidget);
|
||||||
|
|
||||||
await tester.pump(const Duration(milliseconds: 3000));
|
await tester.pump(const Duration(milliseconds: 3000));
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
#include "generated_plugin_registrant.h"
|
#include "generated_plugin_registrant.h"
|
||||||
|
|
||||||
#include <connectivity_plus/connectivity_plus_windows_plugin.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 <flutter_tts/flutter_tts_plugin.h>
|
||||||
#include <geolocator_windows/geolocator_windows.h>
|
#include <geolocator_windows/geolocator_windows.h>
|
||||||
#include <permission_handler_windows/permission_handler_windows_plugin.h>
|
#include <permission_handler_windows/permission_handler_windows_plugin.h>
|
||||||
@@ -14,6 +15,8 @@
|
|||||||
void RegisterPlugins(flutter::PluginRegistry* registry) {
|
void RegisterPlugins(flutter::PluginRegistry* registry) {
|
||||||
ConnectivityPlusWindowsPluginRegisterWithRegistrar(
|
ConnectivityPlusWindowsPluginRegisterWithRegistrar(
|
||||||
registry->GetRegistrarForPlugin("ConnectivityPlusWindowsPlugin"));
|
registry->GetRegistrarForPlugin("ConnectivityPlusWindowsPlugin"));
|
||||||
|
FlutterSecureStorageWindowsPluginRegisterWithRegistrar(
|
||||||
|
registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin"));
|
||||||
FlutterTtsPluginRegisterWithRegistrar(
|
FlutterTtsPluginRegisterWithRegistrar(
|
||||||
registry->GetRegistrarForPlugin("FlutterTtsPlugin"));
|
registry->GetRegistrarForPlugin("FlutterTtsPlugin"));
|
||||||
GeolocatorWindowsRegisterWithRegistrar(
|
GeolocatorWindowsRegisterWithRegistrar(
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
|
|
||||||
list(APPEND FLUTTER_PLUGIN_LIST
|
list(APPEND FLUTTER_PLUGIN_LIST
|
||||||
connectivity_plus
|
connectivity_plus
|
||||||
|
flutter_secure_storage_windows
|
||||||
flutter_tts
|
flutter_tts
|
||||||
geolocator_windows
|
geolocator_windows
|
||||||
permission_handler_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}}
|
||||||
@@ -163,10 +163,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: matcher
|
name: matcher
|
||||||
sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861
|
sha256: "12956d0ad8390bbcc63ca2e1469c0619946ccb52809807067a7020d57e647aa6"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.12.19"
|
version: "0.12.18"
|
||||||
material_color_utilities:
|
material_color_utilities:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -179,10 +179,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: meta
|
name: meta
|
||||||
sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349"
|
sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.18.0"
|
version: "1.17.0"
|
||||||
path:
|
path:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -256,10 +256,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: test_api
|
name: test_api
|
||||||
sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e"
|
sha256: "93167629bfc610f71560ab9312acdda4959de4df6fac7492c89ff0d3886f6636"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.7.11"
|
version: "0.7.9"
|
||||||
typed_data:
|
typed_data:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -301,5 +301,5 @@ packages:
|
|||||||
source: hosted
|
source: hosted
|
||||||
version: "6.6.1"
|
version: "6.6.1"
|
||||||
sdks:
|
sdks:
|
||||||
dart: ">=3.10.0-0 <4.0.0"
|
dart: ">=3.9.0-0 <4.0.0"
|
||||||
flutter: ">=3.22.0"
|
flutter: ">=3.22.0"
|
||||||
|
|||||||
@@ -164,10 +164,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: matcher
|
name: matcher
|
||||||
sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861
|
sha256: "12956d0ad8390bbcc63ca2e1469c0619946ccb52809807067a7020d57e647aa6"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.12.19"
|
version: "0.12.18"
|
||||||
material_color_utilities:
|
material_color_utilities:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -180,10 +180,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: meta
|
name: meta
|
||||||
sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349"
|
sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.18.0"
|
version: "1.17.0"
|
||||||
path:
|
path:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -257,10 +257,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: test_api
|
name: test_api
|
||||||
sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e"
|
sha256: "93167629bfc610f71560ab9312acdda4959de4df6fac7492c89ff0d3886f6636"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.7.11"
|
version: "0.7.9"
|
||||||
typed_data:
|
typed_data:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -302,5 +302,5 @@ packages:
|
|||||||
source: hosted
|
source: hosted
|
||||||
version: "6.6.1"
|
version: "6.6.1"
|
||||||
sdks:
|
sdks:
|
||||||
dart: ">=3.10.0-0 <4.0.0"
|
dart: ">=3.9.0-0 <4.0.0"
|
||||||
flutter: ">=3.22.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:
|
||||||
@@ -584,10 +584,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: matcher
|
name: matcher
|
||||||
sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861
|
sha256: "12956d0ad8390bbcc63ca2e1469c0619946ccb52809807067a7020d57e647aa6"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.12.19"
|
version: "0.12.18"
|
||||||
material_color_utilities:
|
material_color_utilities:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -600,10 +600,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: meta
|
name: meta
|
||||||
sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349"
|
sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.18.0"
|
version: "1.17.0"
|
||||||
mime:
|
mime:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -973,10 +973,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: test_api
|
name: test_api
|
||||||
sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e"
|
sha256: "93167629bfc610f71560ab9312acdda4959de4df6fac7492c89ff0d3886f6636"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.7.11"
|
version: "0.7.9"
|
||||||
typed_data:
|
typed_data:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|||||||