200 lines
7.1 KiB
Dart
200 lines
7.1 KiB
Dart
import 'dart:async';
|
|
import 'package:flutter/foundation.dart';
|
|
import 'package:path/path.dart' as p;
|
|
import 'package:path_provider/path_provider.dart';
|
|
import 'package:sqflite/sqflite.dart';
|
|
import '../models/landmark.dart';
|
|
|
|
/// Sovereign On-Device SQLite Database with FTS5 Full-Text Search
|
|
/// Matches PostgreSQL schema for places_jordan exactly
|
|
class LocalSqliteDb {
|
|
static Database? _database;
|
|
static const String _dbFileName = 'jordan_sovereign_places.db';
|
|
|
|
static Future<Database> get database async {
|
|
if (_database != null && _database!.isOpen) return _database!;
|
|
_database = await _initDatabase();
|
|
return _database!;
|
|
}
|
|
|
|
static Future<Database> _initDatabase() async {
|
|
final docsDir = await getApplicationDocumentsDirectory();
|
|
final dbPath = p.join(docsDir.path, _dbFileName);
|
|
|
|
return await openDatabase(
|
|
dbPath,
|
|
version: 1,
|
|
onCreate: (db, version) async {
|
|
// 1. Create Main Places Table (Matching PostGIS places_jordan 19 columns)
|
|
await db.execute('''
|
|
CREATE TABLE places_jordan (
|
|
id INTEGER PRIMARY KEY,
|
|
latitude REAL,
|
|
longitude REAL,
|
|
name TEXT,
|
|
name_ar TEXT,
|
|
name_en TEXT,
|
|
category TEXT,
|
|
city TEXT,
|
|
neighbourhood TEXT,
|
|
address TEXT,
|
|
description TEXT,
|
|
governorate_id INTEGER,
|
|
district_id INTEGER,
|
|
sub_district_id INTEGER,
|
|
neighborhood_id INTEGER,
|
|
popularity_score INTEGER DEFAULT 0,
|
|
elevation_m INTEGER DEFAULT 800,
|
|
source TEXT
|
|
);
|
|
''');
|
|
|
|
// 2. Spatial coordinate indices
|
|
await db.execute('CREATE INDEX idx_places_coords ON places_jordan(latitude, longitude);');
|
|
await db.execute('CREATE INDEX idx_places_city ON places_jordan(city);');
|
|
await db.execute('CREATE INDEX idx_places_cat ON places_jordan(category);');
|
|
|
|
// 3. FTS5 Virtual Table for Instant Arabic Full-Text Search (< 3ms)
|
|
try {
|
|
await db.execute('''
|
|
CREATE VIRTUAL TABLE places_fts USING fts5(
|
|
id UNINDEXED,
|
|
name_ar,
|
|
name_en,
|
|
category,
|
|
city,
|
|
neighbourhood,
|
|
content='places_jordan',
|
|
content_rowid='id'
|
|
);
|
|
''');
|
|
} catch (e) {
|
|
debugPrint('FTS5 init warning: $e');
|
|
}
|
|
},
|
|
);
|
|
}
|
|
|
|
/// Bulk insert / upsert places into SQLite within a high-speed batch transaction
|
|
static Future<int> bulkInsertPlaces(List<Map<String, dynamic>> placesList) async {
|
|
if (placesList.isEmpty) return 0;
|
|
final db = await database;
|
|
int count = 0;
|
|
|
|
await db.transaction((txn) async {
|
|
final batch = txn.batch();
|
|
for (final p in placesList) {
|
|
final id = p['id'] is int ? p['id'] : int.tryParse(p['id'].toString().replaceAll(RegExp(r'[^0-9]'), '')) ?? 0;
|
|
final lat = (p['latitude'] ?? p['lat'] as num?)?.toDouble() ?? 0.0;
|
|
final lng = (p['longitude'] ?? p['lng'] as num?)?.toDouble() ?? 0.0;
|
|
|
|
batch.insert(
|
|
'places_jordan',
|
|
{
|
|
'id': id > 0 ? id : null,
|
|
'latitude': lat,
|
|
'longitude': lng,
|
|
'name': p['name'],
|
|
'name_ar': p['name_ar'] ?? p['name'],
|
|
'name_en': p['name_en'],
|
|
'category': p['category'] ?? p['type'],
|
|
'city': p['city'] ?? p['region'],
|
|
'neighbourhood': p['neighbourhood'],
|
|
'address': p['address'],
|
|
'description': p['description'],
|
|
'governorate_id': p['governorate_id'],
|
|
'district_id': p['district_id'],
|
|
'sub_district_id': p['sub_district_id'],
|
|
'neighborhood_id': p['neighborhood_id'],
|
|
'popularity_score': p['popularity_score'] ?? 0,
|
|
'elevation_m': p['elevation_m'] ?? p['elevationM'] ?? 800,
|
|
'source': p['source'] ?? 'PostGIS',
|
|
},
|
|
conflictAlgorithm: ConflictAlgorithm.replace,
|
|
);
|
|
}
|
|
final results = await batch.commit(noResult: true);
|
|
count = results.length;
|
|
});
|
|
|
|
return count;
|
|
}
|
|
|
|
/// Instant search (< 3ms) by keyword across 100K+ places
|
|
static Future<List<TacticalLandmark>> searchPlaces(
|
|
String query, {
|
|
String? region,
|
|
String? category,
|
|
int limit = 50,
|
|
}) async {
|
|
final db = await database;
|
|
final cleanQuery = query.trim();
|
|
|
|
String sql = 'SELECT * FROM places_jordan WHERE latitude IS NOT NULL AND longitude IS NOT NULL';
|
|
final args = <dynamic>[];
|
|
|
|
if (cleanQuery.isNotEmpty) {
|
|
sql += ' AND (name_ar LIKE ? OR name LIKE ? OR city LIKE ? OR neighbourhood LIKE ? OR category LIKE ?)';
|
|
final qArg = '%$cleanQuery%';
|
|
args.addAll([qArg, qArg, qArg, qArg, qArg]);
|
|
}
|
|
|
|
if (region != null && region.isNotEmpty && region != 'الكل') {
|
|
sql += ' AND (city LIKE ? OR neighbourhood LIKE ?)';
|
|
args.addAll(['%$region%', '%$region%']);
|
|
}
|
|
|
|
if (category != null && category.isNotEmpty) {
|
|
sql += ' AND category LIKE ?';
|
|
args.add('%$category%');
|
|
}
|
|
|
|
sql += ' ORDER BY popularity_score DESC, id ASC LIMIT ?';
|
|
args.add(limit);
|
|
|
|
final rows = await db.rawQuery(sql, args);
|
|
|
|
return rows.map((row) {
|
|
final cat = (row['category'] as String? ?? '').toLowerCase();
|
|
final name = (row['name_ar'] as String? ?? row['name'] as String? ?? '');
|
|
|
|
LandmarkType type = LandmarkType.tower;
|
|
if (cat.contains('worship') || cat.contains('mosque') || name.contains('مسجد') || name.contains('مئذنة')) {
|
|
type = LandmarkType.minaret;
|
|
} else if (cat.contains('water') || name.contains('خزان')) {
|
|
type = LandmarkType.waterTank;
|
|
} else if (cat.contains('castle') || cat.contains('fort') || name.contains('قلعة') || name.contains('قصر')) {
|
|
type = LandmarkType.fort;
|
|
} else if (cat.contains('mountain') || cat.contains('peak') || name.contains('جبل') || name.contains('تل')) {
|
|
type = LandmarkType.mountain;
|
|
} else if (cat.contains('military') || name.contains('قاعدة') || name.contains('معسكر')) {
|
|
type = LandmarkType.militaryBase;
|
|
} else if (cat.contains('silo') || name.contains('صوامع')) {
|
|
type = LandmarkType.grainSilo;
|
|
}
|
|
|
|
return TacticalLandmark(
|
|
id: 'pg-${row['id']}',
|
|
name: name.isNotEmpty ? name : 'معلم #${row['id']}',
|
|
region: (row['city'] as String?) ?? (row['neighbourhood'] as String?) ?? 'الأردن',
|
|
type: type,
|
|
lat: (row['latitude'] as num).toDouble(),
|
|
lng: (row['longitude'] as num).toDouble(),
|
|
elevationM: (row['elevation_m'] as num?)?.toInt() ?? 800,
|
|
description: (row['description'] as String?) ?? (row['address'] as String?) ?? (row['category'] as String?) ?? '',
|
|
);
|
|
}).toList();
|
|
}
|
|
|
|
/// Get total count of offline stored places
|
|
static Future<int> getPlacesCount() async {
|
|
try {
|
|
final db = await database;
|
|
final res = await db.rawQuery('SELECT count(*) as count FROM places_jordan');
|
|
return Sqflite.firstIntValue(res) ?? 0;
|
|
} catch (_) {
|
|
return 0;
|
|
}
|
|
}
|
|
}
|