// lib/services/api_service.dart import 'dart:io'; import 'dart:convert'; import 'package:flutter/foundation.dart'; import 'package:http/http.dart' as http; import 'package:path/path.dart' as p; import 'package:sqflite/sqflite.dart'; import 'package:path_provider/path_provider.dart'; import 'package:intl/intl.dart'; import 'package:environment_monitoring_app/services/base_api_service.dart'; import 'package:environment_monitoring_app/services/telegram_service.dart'; import 'package:environment_monitoring_app/models/air_collection_data.dart'; import 'package:environment_monitoring_app/models/air_installation_data.dart'; import 'package:environment_monitoring_app/services/server_config_service.dart'; import 'package:environment_monitoring_app/models/marine_manual_pre_departure_checklist_data.dart'; import 'package:environment_monitoring_app/models/marine_manual_sonde_calibration_data.dart'; import 'package:environment_monitoring_app/models/marine_manual_equipment_maintenance_data.dart'; // ======================================================================= // Part 1: Unified API Service // ======================================================================= class ApiService { final BaseApiService _baseService = BaseApiService(); final DatabaseHelper dbHelper = DatabaseHelper(); final ServerConfigService _serverConfigService = ServerConfigService(); late final MarineApiService marine; late final RiverApiService river; late final AirApiService air; static const String imageBaseUrl = 'https://mms-apiv4.pstw.com.my/'; ApiService({required TelegramService telegramService}) { marine = MarineApiService(_baseService, telegramService, _serverConfigService, dbHelper); river = RiverApiService(_baseService, telegramService, _serverConfigService, dbHelper); air = AirApiService(_baseService, telegramService, _serverConfigService); } // --- Core API Methods --- Future> login(String email, String password) async { final baseUrl = await _serverConfigService.getActiveApiUrl(); return _baseService.post(baseUrl, 'auth/login', {'email': email, 'password': password}); } Future> register({ required String username, String? firstName, String? lastName, required String email, required String password, String? phoneNumber, int? departmentId, int? companyId, int? positionId, }) async { final baseUrl = await _serverConfigService.getActiveApiUrl(); final Map body = { 'username': username, 'email': email, 'password': password, 'first_name': firstName ?? '', 'last_name': lastName ?? '', 'phone_number': phoneNumber ?? '', 'department_id': departmentId, 'company_id': companyId, 'position_id': positionId, }; body.removeWhere((key, value) => value == null); return _baseService.post(baseUrl, 'auth/register', body); } Future> post(String endpoint, Map data) async { final baseUrl = await _serverConfigService.getActiveApiUrl(); return _baseService.post(baseUrl, endpoint, data); } Future> getProfile() async { final baseUrl = await _serverConfigService.getActiveApiUrl(); return _baseService.get(baseUrl, 'profile'); } Future> getAllUsers() async { final baseUrl = await _serverConfigService.getActiveApiUrl(); return _baseService.get(baseUrl, 'users'); } Future> getAllDepartments() async { final baseUrl = await _serverConfigService.getActiveApiUrl(); return _baseService.get(baseUrl, 'departments'); } Future> getAllCompanies() async { final baseUrl = await _serverConfigService.getActiveApiUrl(); return _baseService.get(baseUrl, 'companies'); } Future> getAllPositions() async { final baseUrl = await _serverConfigService.getActiveApiUrl(); return _baseService.get(baseUrl, 'positions'); } Future> getAllStates() async { final baseUrl = await _serverConfigService.getActiveApiUrl(); return _baseService.get(baseUrl, 'states'); } Future> sendTelegramAlert({ required String chatId, required String message, }) async { final baseUrl = await _serverConfigService.getActiveApiUrl(); return _baseService.post(baseUrl, 'marine/telegram-alert', { // Note: Endpoint might need generalization if used by other modules 'chat_id': chatId, 'message': message, }); } Future downloadProfilePicture(String imageUrl, String localPath) async { try { final response = await http.get(Uri.parse(imageUrl)); if (response.statusCode == 200) { final File file = File(localPath); await file.parent.create(recursive: true); await file.writeAsBytes(response.bodyBytes); return file; } } catch (e) { debugPrint('Error downloading profile picture: $e'); } return null; } Future> uploadProfilePicture(File imageFile) async { final baseUrl = await _serverConfigService.getActiveApiUrl(); return _baseService.postMultipart( baseUrl: baseUrl, endpoint: 'profile/upload-picture', fields: {}, files: {'profile_picture': imageFile}); } Future> refreshProfile() async { debugPrint('ApiService: Refreshing profile data from server...'); final result = await getProfile(); if (result['success'] == true && result['data'] != null) { await dbHelper.saveProfile(result['data']); debugPrint('ApiService: Profile data refreshed and saved to local DB.'); } return result; } Future validateToken() async { final baseUrl = await _serverConfigService.getActiveApiUrl(); await _baseService.get(baseUrl, 'profile'); } Future> _fetchDelta(String endpoint, String? lastSyncTimestamp) async { final baseUrl = await _serverConfigService.getActiveApiUrl(); String url = endpoint; if (lastSyncTimestamp != null) { url += '?since=$lastSyncTimestamp'; } return _baseService.get(baseUrl, url); } Future> syncAllData({String? lastSyncTimestamp}) async { debugPrint('ApiService: Starting DELTA data sync. Since: $lastSyncTimestamp'); try { final syncTasks = { 'profile': { 'endpoint': 'profile', 'handler': (d, id) async { if (d.isNotEmpty) await dbHelper.saveProfile(d.first); } }, 'allUsers': { 'endpoint': 'users', 'handler': (d, id) async { await dbHelper.upsertUsers(d); await dbHelper.deleteUsers(id); } }, 'documents': { 'endpoint': 'documents', 'handler': (d, id) async { await dbHelper.upsertDocuments(d); await dbHelper.deleteDocuments(id); } }, 'tarballStations': { 'endpoint': 'marine/tarball/stations', 'handler': (d, id) async { await dbHelper.upsertTarballStations(d); await dbHelper.deleteTarballStations(id); } }, 'manualStations': { 'endpoint': 'marine/manual/stations', 'handler': (d, id) async { await dbHelper.upsertManualStations(d); await dbHelper.deleteManualStations(id); } }, 'tarballClassifications': { 'endpoint': 'marine/tarball/classifications', 'handler': (d, id) async { await dbHelper.upsertTarballClassifications(d); await dbHelper.deleteTarballClassifications(id); } }, 'riverManualStations': { 'endpoint': 'river/manual-stations', 'handler': (d, id) async { await dbHelper.upsertRiverManualStations(d); await dbHelper.deleteRiverManualStations(id); } }, 'riverTriennialStations': { 'endpoint': 'river/triennial-stations', 'handler': (d, id) async { await dbHelper.upsertRiverTriennialStations(d); await dbHelper.deleteRiverTriennialStations(id); } }, // --- ADDED: River Investigative Stations Sync --- 'riverInvestigativeStations': { // IMPORTANT: Make sure this endpoint matches your server's route 'endpoint': 'river/investigative-stations', 'handler': (d, id) async { await dbHelper.upsertRiverInvestigativeStations(d); await dbHelper.deleteRiverInvestigativeStations(id); } }, // --- END ADDED --- 'departments': { 'endpoint': 'departments', 'handler': (d, id) async { await dbHelper.upsertDepartments(d); await dbHelper.deleteDepartments(id); } }, 'companies': { 'endpoint': 'companies', 'handler': (d, id) async { await dbHelper.upsertCompanies(d); await dbHelper.deleteCompanies(id); } }, 'positions': { 'endpoint': 'positions', 'handler': (d, id) async { await dbHelper.upsertPositions(d); await dbHelper.deletePositions(id); } }, 'airManualStations': { 'endpoint': 'air/manual-stations', 'handler': (d, id) async { await dbHelper.upsertAirManualStations(d); await dbHelper.deleteAirManualStations(id); } }, 'airClients': { 'endpoint': 'air/clients', 'handler': (d, id) async { await dbHelper.upsertAirClients(d); await dbHelper.deleteAirClients(id); } }, 'states': { 'endpoint': 'states', 'handler': (d, id) async { await dbHelper.upsertStates(d); await dbHelper.deleteStates(id); } }, 'appSettings': { 'endpoint': 'settings', 'handler': (d, id) async { await dbHelper.upsertAppSettings(d); await dbHelper.deleteAppSettings(id); } }, 'npeParameterLimits': { 'endpoint': 'npe-parameter-limits', 'handler': (d, id) async { await dbHelper.upsertNpeParameterLimits(d); await dbHelper.deleteNpeParameterLimits(id); } }, 'marineParameterLimits': { 'endpoint': 'marine-parameter-limits', 'handler': (d, id) async { await dbHelper.upsertMarineParameterLimits(d); await dbHelper.deleteMarineParameterLimits(id); } }, 'riverParameterLimits': { 'endpoint': 'river-parameter-limits', 'handler': (d, id) async { await dbHelper.upsertRiverParameterLimits(d); await dbHelper.deleteRiverParameterLimits(id); } }, 'apiConfigs': { 'endpoint': 'api-configs', 'handler': (d, id) async { await dbHelper.upsertApiConfigs(d); await dbHelper.deleteApiConfigs(id); } }, 'ftpConfigs': { 'endpoint': 'ftp-configs', 'handler': (d, id) async { await dbHelper.upsertFtpConfigs(d); await dbHelper.deleteFtpConfigs(id); } }, }; // Fetch all deltas in parallel final fetchFutures = syncTasks.map((key, value) => MapEntry(key, _fetchDelta(value['endpoint'] as String, lastSyncTimestamp))); final results = await Future.wait(fetchFutures.values); final resultData = Map.fromIterables(fetchFutures.keys, results); // Process and save all changes for (var entry in resultData.entries) { final key = entry.key; final result = entry.value; if (result['success'] == true && result['data'] != null) { if (key == 'profile') { // Handle potential non-list response for profile final profileData = result['data']; if (profileData is Map) { await (syncTasks[key]!['handler'] as Function)([profileData], []); } else if (profileData is List && profileData.isNotEmpty) { await (syncTasks[key]!['handler'] as Function)([profileData.first], []); } } else { final updated = List>.from(result['data']['updated'] ?? []); final deleted = List.from(result['data']['deleted'] ?? []); await (syncTasks[key]!['handler'] as Function)(updated, deleted); } } else { debugPrint('ApiService: Failed to sync $key. Message: ${result['message']}'); } } debugPrint('ApiService: Delta sync complete.'); return {'success': true, 'message': 'Delta sync successful.'}; } catch (e) { debugPrint('ApiService: Delta data sync failed: $e'); rethrow; } } Future> syncRegistrationData() async { debugPrint('ApiService: Starting registration data sync...'); try { final syncTasks = { 'departments': { 'endpoint': 'departments', 'handler': (d, id) async { await dbHelper.upsertDepartments(d); await dbHelper.deleteDepartments(id); } }, 'companies': { 'endpoint': 'companies', 'handler': (d, id) async { await dbHelper.upsertCompanies(d); await dbHelper.deleteCompanies(id); } }, 'positions': { 'endpoint': 'positions', 'handler': (d, id) async { await dbHelper.upsertPositions(d); await dbHelper.deletePositions(id); } }, }; final fetchFutures = syncTasks.map((key, value) => MapEntry(key, _fetchDelta(value['endpoint'] as String, null))); // Fetch all data final results = await Future.wait(fetchFutures.values); final resultData = Map.fromIterables(fetchFutures.keys, results); for (var entry in resultData.entries) { final key = entry.key; final result = entry.value; // Assuming the full list is returned in 'data' when lastSyncTimestamp is null if (result['success'] == true && result['data'] != null) { // Ensure 'data' is treated as a list, even if API might sometimes return a map for single results List> allData = []; if (result['data'] is List) { allData = List>.from(result['data']); } else if (result['data'] is Map) { // Handle cases where the API might return just a map if only one item exists // Or if the structure is like {'data': [...]} incorrectly var potentialList = (result['data'] as Map).values.firstWhere((v) => v is List, orElse: () => null); if (potentialList != null) { allData = List>.from(potentialList); } else { debugPrint('ApiService: Unexpected data format for $key. Expected List, got Map.'); } } // Since it's a full sync, we just upsert everything and don't delete if (allData.isNotEmpty) { await (syncTasks[key]!['handler'] as Function)(allData, []); } else if (result['data'] is Map && allData.isEmpty) { // If it was a map and we couldn't extract a list, log it. debugPrint('ApiService: Data for $key was a map, but could not extract list for handler.'); } } else { debugPrint('ApiService: Failed to sync $key. Message: ${result['message']}'); } } debugPrint('ApiService: Registration data sync complete.'); return {'success': true, 'message': 'Registration data sync successful.'}; } catch (e) { debugPrint('ApiService: Registration data sync failed: $e'); return {'success': false, 'message': 'Registration data sync failed: $e'}; } } } // ======================================================================= // Part 2: Feature-Specific API Services (Refactored to include Telegram) // ======================================================================= class AirApiService { final BaseApiService _baseService; final TelegramService? _telegramService; // Kept optional for now final ServerConfigService _serverConfigService; AirApiService(this._baseService, this._telegramService, this._serverConfigService); Future> getManualStations() async { final baseUrl = await _serverConfigService.getActiveApiUrl(); return _baseService.get(baseUrl, 'air/manual-stations'); } Future> getClients() async { final baseUrl = await _serverConfigService.getActiveApiUrl(); return _baseService.get(baseUrl, 'air/clients'); } // NOTE: Air submission logic is likely in AirSamplingService and might use generic services. // These specific methods might be legacy or used differently. Keep them for now. Future> submitInstallation(AirInstallationData data) async { final baseUrl = await _serverConfigService.getActiveApiUrl(); return _baseService.post(baseUrl, 'air/manual/installation', data.toJsonForApi()); } Future> submitCollection(AirCollectionData data) async { final baseUrl = await _serverConfigService.getActiveApiUrl(); return _baseService.post(baseUrl, 'air/manual/collection', data.toJson()); } Future> uploadInstallationImages({ required String airManId, required Map files, }) async { final baseUrl = await _serverConfigService.getActiveApiUrl(); return _baseService.postMultipart( baseUrl: baseUrl, endpoint: 'air/manual/installation-images', fields: {'air_man_id': airManId}, files: files, ); } Future> uploadCollectionImages({ required String airManId, required Map files, }) async { final baseUrl = await _serverConfigService.getActiveApiUrl(); return _baseService.postMultipart( baseUrl: baseUrl, endpoint: 'air/manual/collection-images', fields: {'air_man_id': airManId}, files: files, ); } } // ======================================================================= // --- START OF MODIFIED SECTION --- // The entire MarineApiService class is replaced with the corrected version. // ======================================================================= class MarineApiService { final BaseApiService _baseService; final TelegramService _telegramService; final ServerConfigService _serverConfigService; final DatabaseHelper _dbHelper; // Kept to match constructor MarineApiService(this._baseService, this._telegramService, this._serverConfigService, this._dbHelper); // --- KEPT METHODS (Unchanged) --- Future> getTarballStations() async { final baseUrl = await _serverConfigService.getActiveApiUrl(); return _baseService.get(baseUrl, 'marine/tarball/stations'); } Future> getManualStations() async { final baseUrl = await _serverConfigService.getActiveApiUrl(); return _baseService.get(baseUrl, 'marine/manual/stations'); } Future> getTarballClassifications() async { final baseUrl = await _serverConfigService.getActiveApiUrl(); return _baseService.get(baseUrl, 'marine/tarball/classifications'); } // --- REPLACED/FIXED METHOD --- Future> getManualSamplingImages({ required int stationId, required DateTime samplingDate, required String samplingType, // This parameter is NOW USED }) async { final baseUrl = await _serverConfigService.getActiveApiUrl(); final String dateStr = DateFormat('yyyy-MM-dd').format(samplingDate); String endpoint; // Determine the correct endpoint based on the sampling type switch (samplingType) { case 'In-Situ Sampling': endpoint = 'marine/manual/records-by-station?station_id=$stationId&date=$dateStr'; break; case 'Tarball Sampling': // **IMPORTANT**: Please verify this is the correct endpoint for tarball records endpoint = 'marine/tarball/records-by-station?station_id=$stationId&date=$dateStr'; break; case 'All Manual Sampling': default: // 'All' is complex. Defaulting to 'manual' (in-situ) as a fallback. endpoint = 'marine/manual/records-by-station?station_id=$stationId&date=$dateStr'; break; } // This new debug print will help you confirm the fix is working debugPrint("MarineApiService: Calling API endpoint: $endpoint"); final response = await _baseService.get(baseUrl, endpoint); // Adjusting response parsing based on observed structure if (response['success'] == true && response['data'] is Map && response['data']['data'] is List) { return { 'success': true, 'data': response['data']['data'], // Return the inner 'data' list 'message': response['message'], }; } // Return original response if structure doesn't match return response; } // --- ADDED METHOD --- Future> sendImageRequestEmail({ required String recipientEmail, required List imageUrls, required String stationName, required String samplingDate, }) async { final baseUrl = await _serverConfigService.getActiveApiUrl(); final Map fields = { 'recipientEmail': recipientEmail, 'imageUrls': jsonEncode(imageUrls), 'stationName': stationName, 'samplingDate': samplingDate, }; return _baseService.postMultipart( baseUrl: baseUrl, endpoint: 'marine/images/send-email', // **IMPORTANT**: Verify this endpoint fields: fields, files: {}, ); } // --- KEPT METHODS (Unchanged) --- Future> submitPreDepartureChecklist(MarineManualPreDepartureChecklistData data) async { final baseUrl = await _serverConfigService.getActiveApiUrl(); return _baseService.post(baseUrl, 'marine/checklist', data.toApiFormData()); } Future> submitSondeCalibration(MarineManualSondeCalibrationData data) async { final baseUrl = await _serverConfigService.getActiveApiUrl(); return _baseService.post(baseUrl, 'marine/calibration', data.toApiFormData()); } Future> submitMaintenanceLog(MarineManualEquipmentMaintenanceData data) async { final baseUrl = await _serverConfigService.getActiveApiUrl(); return _baseService.post(baseUrl, 'marine/maintenance', data.toApiFormData()); } Future> getPreviousMaintenanceLogs() async { final baseUrl = await _serverConfigService.getActiveApiUrl(); return _baseService.get(baseUrl, 'marine/maintenance/previous'); } } // ======================================================================= // --- END OF MODIFIED SECTION --- // ======================================================================= class RiverApiService { final BaseApiService _baseService; final TelegramService _telegramService; // Still needed if _handleAlerts were here final ServerConfigService _serverConfigService; final DatabaseHelper _dbHelper; // Still needed for parameter limit lookups if alerts were here RiverApiService(this._baseService, this._telegramService, this._serverConfigService, this._dbHelper); // --- KEPT METHODS --- Future> getManualStations() async { final baseUrl = await _serverConfigService.getActiveApiUrl(); return _baseService.get(baseUrl, 'river/manual-stations'); } Future> getTriennialStations() async { final baseUrl = await _serverConfigService.getActiveApiUrl(); return _baseService.get(baseUrl, 'river/triennial-stations'); } Future> getRiverSamplingImages({ required int stationId, required DateTime samplingDate, required String samplingType, // Parameter likely unused by current endpoint }) async { final baseUrl = await _serverConfigService.getActiveApiUrl(); final String dateStr = DateFormat('yyyy-MM-dd').format(samplingDate); // Endpoint seems specific to 'manual', adjust if needed for 'triennial' or others final String endpoint = 'river/manual/images-by-station?station_id=$stationId&date=$dateStr'; debugPrint("ApiService: Calling river image request API endpoint: $endpoint"); final response = await _baseService.get(baseUrl, endpoint); return response; // Pass the raw response along } Future> sendImageRequestEmail({ required String recipientEmail, required List imageUrls, required String stationName, required String samplingDate, }) async { final baseUrl = await _serverConfigService.getActiveApiUrl(); final Map fields = { 'recipientEmail': recipientEmail, 'imageUrls': jsonEncode(imageUrls), 'stationName': stationName, 'samplingDate': samplingDate, }; return _baseService.postMultipart( baseUrl: baseUrl, endpoint: 'river/images/send-email', // Endpoint for river email requests fields: fields, files: {}, ); } // --- REMOVED METHODS (Logic moved to feature services) --- // - submitInSituSample // - submitTriennialSample // - _handleTriennialSuccessAlert // - _handleInSituSuccessAlert // - _generateInSituAlertMessage // - _getOutOfBoundsAlertSection (River version) } // ======================================================================= // Part 3: Local Database Helper (Original version - no compute mods) // ======================================================================= class DatabaseHelper { static Database? _database; static const String _dbName = 'app_data.db'; // --- MODIFIED: Incremented DB version --- static const int _dbVersion = 24; // Keep version updated if schema changes // --- END MODIFIED --- // compute-related static variables/methods REMOVED static const String _profileTable = 'user_profile'; static const String _usersTable = 'all_users'; static const String _tarballStationsTable = 'marine_tarball_stations'; static const String _manualStationsTable = 'marine_manual_stations'; static const String _riverManualStationsTable = 'river_manual_stations'; static const String _riverTriennialStationsTable = 'river_triennial_stations'; // --- ADDED: River Investigative Stations Table Name --- static const String _riverInvestigativeStationsTable = 'river_investigative_stations'; // --- END ADDED --- static const String _tarballClassificationsTable = 'marine_tarball_classifications'; static const String _departmentsTable = 'departments'; static const String _companiesTable = 'companies'; static const String _positionsTable = 'positions'; static const String _alertQueueTable = 'alert_queue'; static const String _airManualStationsTable = 'air_manual_stations'; static const String _airClientsTable = 'air_clients'; static const String _statesTable = 'states'; static const String _appSettingsTable = 'app_settings'; // static const String _parameterLimitsTable = 'manual_parameter_limits'; // REMOVED static const String _npeParameterLimitsTable = 'npe_parameter_limits'; static const String _marineParameterLimitsTable = 'marine_parameter_limits'; static const String _riverParameterLimitsTable = 'river_parameter_limits'; static const String _apiConfigsTable = 'api_configurations'; static const String _ftpConfigsTable = 'ftp_configurations'; static const String _retryQueueTable = 'retry_queue'; static const String _submissionLogTable = 'submission_log'; static const String _documentsTable = 'documents'; static const String _modulePreferencesTable = 'module_preferences'; static const String _moduleApiLinksTable = 'module_api_links'; static const String _moduleFtpLinksTable = 'module_ftp_links'; Future get database async { if (_database != null) return _database!; _database = await _initDB(); return _database!; } Future _initDB() async { // Standard path retrieval String dbPath = p.join(await getDatabasesPath(), _dbName); return await openDatabase(dbPath, version: _dbVersion, onCreate: _onCreate, onUpgrade: _onUpgrade); } Future _onCreate(Database db, int version) async { // Create all tables as defined in version 23 await db.execute('CREATE TABLE $_profileTable(user_id INTEGER PRIMARY KEY, profile_json TEXT)'); await db.execute(''' CREATE TABLE $_usersTable( user_id INTEGER PRIMARY KEY, email TEXT UNIQUE, password_hash TEXT, user_json TEXT ) '''); await db.execute('CREATE TABLE $_tarballStationsTable(station_id INTEGER PRIMARY KEY, station_json TEXT)'); await db.execute('CREATE TABLE $_manualStationsTable(station_id INTEGER PRIMARY KEY, station_json TEXT)'); await db.execute('CREATE TABLE $_riverManualStationsTable(station_id INTEGER PRIMARY KEY, station_json TEXT)'); await db.execute('CREATE TABLE $_riverTriennialStationsTable(station_id INTEGER PRIMARY KEY, station_json TEXT)'); // --- ADDED: River Investigative Stations Table Create --- await db.execute('CREATE TABLE $_riverInvestigativeStationsTable(station_id INTEGER PRIMARY KEY, station_json TEXT)'); // --- END ADDED --- await db.execute('CREATE TABLE $_tarballClassificationsTable(classification_id INTEGER PRIMARY KEY, classification_json TEXT)'); await db.execute('CREATE TABLE $_departmentsTable(department_id INTEGER PRIMARY KEY, department_json TEXT)'); await db.execute('CREATE TABLE $_companiesTable(company_id INTEGER PRIMARY KEY, company_json TEXT)'); await db.execute('CREATE TABLE $_positionsTable(position_id INTEGER PRIMARY KEY, position_json TEXT)'); await db.execute('''CREATE TABLE $_alertQueueTable (id INTEGER PRIMARY KEY AUTOINCREMENT, chat_id TEXT NOT NULL, message TEXT NOT NULL, created_at TEXT NOT NULL)'''); await db.execute('CREATE TABLE $_airManualStationsTable(station_id INTEGER PRIMARY KEY, station_json TEXT)'); await db.execute('CREATE TABLE $_airClientsTable(client_id INTEGER PRIMARY KEY, client_json TEXT)'); await db.execute('CREATE TABLE $_statesTable(state_id INTEGER PRIMARY KEY, state_json TEXT)'); await db.execute('CREATE TABLE $_appSettingsTable(setting_id INTEGER PRIMARY KEY, setting_json TEXT)'); // No generic _parameterLimitsTable creation await db.execute('CREATE TABLE $_npeParameterLimitsTable(param_autoid INTEGER PRIMARY KEY, limit_json TEXT)'); await db.execute('CREATE TABLE $_marineParameterLimitsTable(param_autoid INTEGER PRIMARY KEY, limit_json TEXT)'); await db.execute('CREATE TABLE $_riverParameterLimitsTable(param_autoid INTEGER PRIMARY KEY, limit_json TEXT)'); await db.execute('CREATE TABLE $_apiConfigsTable(api_config_id INTEGER PRIMARY KEY, config_json TEXT)'); await db.execute('CREATE TABLE $_ftpConfigsTable(ftp_config_id INTEGER PRIMARY KEY, config_json TEXT)'); await db.execute(''' CREATE TABLE $_retryQueueTable( id INTEGER PRIMARY KEY AUTOINCREMENT, type TEXT NOT NULL, endpoint_or_path TEXT NOT NULL, payload TEXT, timestamp TEXT NOT NULL, status TEXT NOT NULL ) '''); await db.execute(''' CREATE TABLE $_submissionLogTable ( submission_id TEXT PRIMARY KEY, module TEXT NOT NULL, type TEXT NOT NULL, status TEXT NOT NULL, message TEXT, report_id TEXT, created_at TEXT NOT NULL, form_data TEXT, image_data TEXT, server_name TEXT, api_status TEXT, ftp_status TEXT ) '''); await db.execute(''' CREATE TABLE $_modulePreferencesTable ( module_name TEXT PRIMARY KEY, is_api_enabled INTEGER NOT NULL DEFAULT 1, is_ftp_enabled INTEGER NOT NULL DEFAULT 1 ) '''); await db.execute(''' CREATE TABLE $_moduleApiLinksTable ( id INTEGER PRIMARY KEY AUTOINCREMENT, module_name TEXT NOT NULL, api_config_id INTEGER NOT NULL, is_enabled INTEGER NOT NULL DEFAULT 1 ) '''); await db.execute(''' CREATE TABLE $_moduleFtpLinksTable ( id INTEGER PRIMARY KEY AUTOINCREMENT, module_name TEXT NOT NULL, ftp_config_id INTEGER NOT NULL, is_enabled INTEGER NOT NULL DEFAULT 1 ) '''); await db.execute('CREATE TABLE $_documentsTable(id INTEGER PRIMARY KEY, document_json TEXT)'); } Future _onUpgrade(Database db, int oldVersion, int newVersion) async { // Apply upgrades sequentially if (oldVersion < 11) { await db.execute('CREATE TABLE IF NOT EXISTS $_airManualStationsTable(station_id INTEGER PRIMARY KEY, station_json TEXT)'); await db.execute('CREATE TABLE IF NOT EXISTS $_airClientsTable(client_id INTEGER PRIMARY KEY, client_json TEXT)'); } if (oldVersion < 12) { await db.execute('CREATE TABLE IF NOT EXISTS $_statesTable(state_id INTEGER PRIMARY KEY, state_json TEXT)'); } if (oldVersion < 13) { await db.execute('CREATE TABLE IF NOT EXISTS $_appSettingsTable(setting_id INTEGER PRIMARY KEY, setting_json TEXT)'); } if (oldVersion < 16) { await db.execute('CREATE TABLE IF NOT EXISTS $_apiConfigsTable(api_config_id INTEGER PRIMARY KEY, config_json TEXT)'); await db.execute('CREATE TABLE IF NOT EXISTS $_ftpConfigsTable(ftp_config_id INTEGER PRIMARY KEY, config_json TEXT)'); } if (oldVersion < 17) { await db.execute(''' CREATE TABLE IF NOT EXISTS $_retryQueueTable( id INTEGER PRIMARY KEY AUTOINCREMENT, type TEXT NOT NULL, endpoint_or_path TEXT NOT NULL, payload TEXT, timestamp TEXT NOT NULL, status TEXT NOT NULL ) '''); } if (oldVersion < 18) { await db.execute(''' CREATE TABLE IF NOT EXISTS $_submissionLogTable ( submission_id TEXT PRIMARY KEY, module TEXT NOT NULL, type TEXT NOT NULL, status TEXT NOT NULL, message TEXT, report_id TEXT, created_at TEXT NOT NULL, form_data TEXT, image_data TEXT, server_name TEXT ) '''); } if (oldVersion < 19) { try { await db.execute("ALTER TABLE $_submissionLogTable ADD COLUMN api_status TEXT"); await db.execute("ALTER TABLE $_submissionLogTable ADD COLUMN ftp_status TEXT"); } catch (_) {} await db.execute(''' CREATE TABLE IF NOT EXISTS $_modulePreferencesTable ( module_name TEXT PRIMARY KEY, is_api_enabled INTEGER NOT NULL DEFAULT 1, is_ftp_enabled INTEGER NOT NULL DEFAULT 1 ) '''); await db.execute(''' CREATE TABLE IF NOT EXISTS $_moduleApiLinksTable ( id INTEGER PRIMARY KEY AUTOINCREMENT, module_name TEXT NOT NULL, api_config_id INTEGER NOT NULL, is_enabled INTEGER NOT NULL DEFAULT 1 ) '''); await db.execute(''' CREATE TABLE IF NOT EXISTS $_moduleFtpLinksTable ( id INTEGER PRIMARY KEY AUTOINCREMENT, module_name TEXT NOT NULL, ftp_config_id INTEGER NOT NULL, is_enabled INTEGER NOT NULL DEFAULT 1 ) '''); } if (oldVersion < 20) { await db.execute('CREATE TABLE IF NOT EXISTS $_documentsTable(id INTEGER PRIMARY KEY, document_json TEXT)'); } if (oldVersion < 21) { try { await db.execute("ALTER TABLE $_usersTable ADD COLUMN email TEXT"); await db.execute("CREATE UNIQUE INDEX IF NOT EXISTS idx_user_email ON $_usersTable (email)"); } catch (e) { debugPrint("Upgrade warning: Failed to add email column/index to users table (may already exist): $e"); } try { await db.execute("ALTER TABLE $_usersTable ADD COLUMN password_hash TEXT"); } catch (e) { debugPrint("Upgrade warning: Failed to add password_hash column to users table (may already exist): $e"); } } if (oldVersion < 23) { await db.execute('CREATE TABLE IF NOT EXISTS $_npeParameterLimitsTable(param_autoid INTEGER PRIMARY KEY, limit_json TEXT)'); await db.execute('CREATE TABLE IF NOT EXISTS $_marineParameterLimitsTable(param_autoid INTEGER PRIMARY KEY, limit_json TEXT)'); await db.execute('CREATE TABLE IF NOT EXISTS $_riverParameterLimitsTable(param_autoid INTEGER PRIMARY KEY, limit_json TEXT)'); try { // await db.execute('DROP TABLE IF EXISTS $_parameterLimitsTable'); // Keep commented debugPrint("Old generic parameter limits table check/drop logic executed (if applicable)."); } catch (e) { debugPrint("Upgrade warning: Failed to drop old parameter limits table (may not exist): $e"); } } // --- ADDED: Upgrade step for new table --- if (oldVersion < 24) { await db.execute('CREATE TABLE IF NOT EXISTS $_riverInvestigativeStationsTable(station_id INTEGER PRIMARY KEY, station_json TEXT)'); } // --- END ADDED --- } // --- Data Handling Methods --- Future _upsertData(String table, String idKeyName, List> data, String jsonKeyName) async { if (data.isEmpty) return; final db = await database; final batch = db.batch(); for (var item in data) { if (item[idKeyName] != null) { batch.insert( table, {idKeyName: item[idKeyName], '${jsonKeyName}_json': jsonEncode(item)}, conflictAlgorithm: ConflictAlgorithm.replace, ); } else { debugPrint("Skipping upsert for item in $table due to null ID: $item"); } } await batch.commit(noResult: true); debugPrint("Upserted items into $table (skipped items with null IDs if any)"); } Future _deleteData(String table, String idKeyName, List ids) async { if (ids.isEmpty) return; final db = await database; final validIds = ids.where((id) => id != null).toList(); if (validIds.isEmpty) return; final placeholders = List.filled(validIds.length, '?').join(', '); await db.delete( table, where: '$idKeyName IN ($placeholders)', whereArgs: validIds, ); debugPrint("Deleted ${validIds.length} items from $table"); } Future>?> _loadData(String table, String jsonKey) async { final db = await database; final List> maps = await db.query(table); if (maps.isNotEmpty) { try { return maps.map((map) { try { return jsonDecode(map['${jsonKey}_json']) as Map; } catch (e) { final idKey = maps.first.keys.firstWhere((k) => k.endsWith('_id') || k == 'id' || k.endsWith('autoid'), orElse: () => 'unknown_id'); debugPrint("Error decoding JSON from $table, ID ${map[idKey]}: $e"); return {}; } }).where((item) => item.isNotEmpty).toList(); } catch (e) { debugPrint("General error loading data from $table: $e"); return null; } } return null; // Return null if table is empty } Future saveProfile(Map profile) async { final db = await database; await db.insert(_profileTable, {'user_id': profile['user_id'], 'profile_json': jsonEncode(profile)}, conflictAlgorithm: ConflictAlgorithm.replace); } Future?> loadProfile() async { final db = await database; final List> maps = await db.query(_profileTable); if (maps.isNotEmpty) { try { return jsonDecode(maps.first['profile_json']); } catch (e) { debugPrint("Error decoding profile: $e"); return null; } } return null; } Future?> loadProfileByEmail(String email) async { final db = await database; final List> maps = await db.query( _usersTable, columns: ['user_json'], where: 'email = ?', whereArgs: [email], ); if (maps.isNotEmpty) { try { return jsonDecode(maps.first['user_json']) as Map; } catch (e) { debugPrint("Error decoding profile for email $email: $e"); return null; } } return null; } Future upsertUserWithCredentials({ required Map profile, required String passwordHash, }) async { final db = await database; await db.insert( _usersTable, { 'user_id': profile['user_id'], 'email': profile['email'], 'password_hash': passwordHash, 'user_json': jsonEncode(profile) }, conflictAlgorithm: ConflictAlgorithm.replace, ); debugPrint("Upserted user credentials for ${profile['email']}"); } Future getUserPasswordHashByEmail(String email) async { final db = await database; final List> result = await db.query( _usersTable, columns: ['password_hash'], where: 'email = ?', whereArgs: [email], ); if (result.isNotEmpty && result.first['password_hash'] != null) { return result.first['password_hash'] as String; } return null; } Future upsertUsers(List> data) async { if (data.isEmpty) return; final db = await database; final batch = db.batch(); for (var item in data) { String email = item['email'] ?? 'missing_email_${item['user_id']}@placeholder.com'; if (item['email'] == null) { debugPrint("Warning: User ID ${item['user_id']} is missing email during upsert."); } batch.insert( _usersTable, { 'user_id': item['user_id'], 'email': email, 'user_json': jsonEncode(item), }, conflictAlgorithm: ConflictAlgorithm.replace, ); } await batch.commit(noResult: true); debugPrint("Upserted ${data.length} user items using batch."); } Future deleteUsers(List ids) => _deleteData(_usersTable, 'user_id', ids); Future>?> loadUsers() => _loadData(_usersTable, 'user'); Future upsertDocuments(List> data) => _upsertData(_documentsTable, 'id', data, 'document'); Future deleteDocuments(List ids) => _deleteData(_documentsTable, 'id', ids); Future>?> loadDocuments() => _loadData(_documentsTable, 'document'); Future upsertTarballStations(List> data) => _upsertData(_tarballStationsTable, 'station_id', data, 'station'); Future deleteTarballStations(List ids) => _deleteData(_tarballStationsTable, 'station_id', ids); Future>?> loadTarballStations() => _loadData(_tarballStationsTable, 'station'); Future upsertManualStations(List> data) => _upsertData(_manualStationsTable, 'station_id', data, 'station'); Future deleteManualStations(List ids) => _deleteData(_manualStationsTable, 'station_id', ids); Future>?> loadManualStations() => _loadData(_manualStationsTable, 'station'); Future upsertRiverManualStations(List> data) => _upsertData(_riverManualStationsTable, 'station_id', data, 'station'); Future deleteRiverManualStations(List ids) => _deleteData(_riverManualStationsTable, 'station_id', ids); Future>?> loadRiverManualStations() => _loadData(_riverManualStationsTable, 'station'); Future upsertRiverTriennialStations(List> data) => _upsertData(_riverTriennialStationsTable, 'station_id', data, 'station'); Future deleteRiverTriennialStations(List ids) => _deleteData(_riverTriennialStationsTable, 'station_id', ids); Future>?> loadRiverTriennialStations() => _loadData(_riverTriennialStationsTable, 'station'); // --- ADDED: River Investigative Stations DB Methods --- Future upsertRiverInvestigativeStations(List> data) => _upsertData(_riverInvestigativeStationsTable, 'station_id', data, 'station'); Future deleteRiverInvestigativeStations(List ids) => _deleteData(_riverInvestigativeStationsTable, 'station_id', ids); Future>?> loadRiverInvestigativeStations() => _loadData(_riverInvestigativeStationsTable, 'station'); // --- END ADDED --- Future upsertTarballClassifications(List> data) => _upsertData(_tarballClassificationsTable, 'classification_id', data, 'classification'); Future deleteTarballClassifications(List ids) => _deleteData(_tarballClassificationsTable, 'classification_id', ids); Future>?> loadTarballClassifications() => _loadData(_tarballClassificationsTable, 'classification'); Future upsertDepartments(List> data) => _upsertData(_departmentsTable, 'department_id', data, 'department'); Future deleteDepartments(List ids) => _deleteData(_departmentsTable, 'department_id', ids); Future>?> loadDepartments() => _loadData(_departmentsTable, 'department'); Future upsertCompanies(List> data) => _upsertData(_companiesTable, 'company_id', data, 'company'); Future deleteCompanies(List ids) => _deleteData(_companiesTable, 'company_id', ids); Future>?> loadCompanies() => _loadData(_companiesTable, 'company'); Future upsertPositions(List> data) => _upsertData(_positionsTable, 'position_id', data, 'position'); Future deletePositions(List ids) => _deleteData(_positionsTable, 'position_id', ids); Future>?> loadPositions() => _loadData(_positionsTable, 'position'); Future upsertAirManualStations(List> data) => _upsertData(_airManualStationsTable, 'station_id', data, 'station'); Future deleteAirManualStations(List ids) => _deleteData(_airManualStationsTable, 'station_id', ids); Future>?> loadAirManualStations() => _loadData(_airManualStationsTable, 'station'); Future upsertAirClients(List> data) => _upsertData(_airClientsTable, 'client_id', data, 'client'); Future deleteAirClients(List ids) => _deleteData(_airClientsTable, 'client_id', ids); Future>?> loadAirClients() => _loadData(_airClientsTable, 'client'); Future upsertStates(List> data) => _upsertData(_statesTable, 'state_id', data, 'state'); Future deleteStates(List ids) => _deleteData(_statesTable, 'state_id', ids); Future>?> loadStates() => _loadData(_statesTable, 'state'); Future upsertAppSettings(List> data) => _upsertData(_appSettingsTable, 'setting_id', data, 'setting'); Future deleteAppSettings(List ids) => _deleteData(_appSettingsTable, 'setting_id', ids); Future>?> loadAppSettings() => _loadData(_appSettingsTable, 'setting'); Future upsertNpeParameterLimits(List> data) => _upsertData(_npeParameterLimitsTable, 'param_autoid', data, 'limit'); Future deleteNpeParameterLimits(List ids) => _deleteData(_npeParameterLimitsTable, 'param_autoid', ids); Future>?> loadNpeParameterLimits() => _loadData(_npeParameterLimitsTable, 'limit'); Future upsertMarineParameterLimits(List> data) => _upsertData(_marineParameterLimitsTable, 'param_autoid', data, 'limit'); Future deleteMarineParameterLimits(List ids) => _deleteData(_marineParameterLimitsTable, 'param_autoid', ids); Future>?> loadMarineParameterLimits() => _loadData(_marineParameterLimitsTable, 'limit'); Future upsertRiverParameterLimits(List> data) => _upsertData(_riverParameterLimitsTable, 'param_autoid', data, 'limit'); Future deleteRiverParameterLimits(List ids) => _deleteData(_riverParameterLimitsTable, 'param_autoid', ids); Future>?> loadRiverParameterLimits() => _loadData(_riverParameterLimitsTable, 'limit'); Future upsertApiConfigs(List> data) => _upsertData(_apiConfigsTable, 'api_config_id', data, 'config'); Future deleteApiConfigs(List ids) => _deleteData(_apiConfigsTable, 'api_config_id', ids); Future>?> loadApiConfigs() => _loadData(_apiConfigsTable, 'config'); Future upsertFtpConfigs(List> data) => _upsertData(_ftpConfigsTable, 'ftp_config_id', data, 'config'); Future deleteFtpConfigs(List ids) => _deleteData(_ftpConfigsTable, 'ftp_config_id', ids); Future>?> loadFtpConfigs() => _loadData(_ftpConfigsTable, 'config'); Future queueFailedRequest(Map data) async { final db = await database; return await db.insert(_retryQueueTable, data, conflictAlgorithm: ConflictAlgorithm.replace); } Future>> getPendingRequests() async { final db = await database; return await db.query(_retryQueueTable, where: 'status = ?', whereArgs: ['pending'], orderBy: 'timestamp ASC'); // Order by timestamp } Future?> getRequestById(int id) async { final db = await database; final results = await db.query(_retryQueueTable, where: 'id = ?', whereArgs: [id]); return results.isNotEmpty ? results.first : null; } Future deleteRequestFromQueue(int id) async { final db = await database; await db.delete(_retryQueueTable, where: 'id = ?', whereArgs: [id]); } Future saveSubmissionLog(Map data) async { final db = await database; await db.insert( _submissionLogTable, data, conflictAlgorithm: ConflictAlgorithm.replace, // Replace if same ID exists ); } Future>?> loadSubmissionLogs({String? module}) async { final db = await database; List> maps; try { // Add try-catch for robustness if (module != null && module.isNotEmpty) { maps = await db.query( _submissionLogTable, where: 'module = ?', whereArgs: [module], orderBy: 'created_at DESC', ); } else { maps = await db.query( _submissionLogTable, orderBy: 'created_at DESC', ); } return maps.isNotEmpty ? maps : null; // Return null if empty } catch (e) { debugPrint("Error loading submission logs: $e"); return null; } } Future saveModulePreference({ required String moduleName, required bool isApiEnabled, required bool isFtpEnabled, }) async { final db = await database; await db.insert( _modulePreferencesTable, { 'module_name': moduleName, 'is_api_enabled': isApiEnabled ? 1 : 0, 'is_ftp_enabled': isFtpEnabled ? 1 : 0, }, conflictAlgorithm: ConflictAlgorithm.replace, ); } Future?> getModulePreference(String moduleName) async { final db = await database; final result = await db.query( _modulePreferencesTable, where: 'module_name = ?', whereArgs: [moduleName], ); if (result.isNotEmpty) { final row = result.first; return { 'module_name': row['module_name'], 'is_api_enabled': (row['is_api_enabled'] as int) == 1, 'is_ftp_enabled': (row['is_ftp_enabled'] as int) == 1, }; } // Return default values if no preference found return {'module_name': moduleName, 'is_api_enabled': true, 'is_ftp_enabled': true}; } Future saveApiLinksForModule(String moduleName, List> links) async { final db = await database; await db.transaction((txn) async { await txn.delete(_moduleApiLinksTable, where: 'module_name = ?', whereArgs: [moduleName]); for (final link in links) { if (link['api_config_id'] != null) { // Ensure ID is not null await txn.insert(_moduleApiLinksTable, { 'module_name': moduleName, 'api_config_id': link['api_config_id'], 'is_enabled': (link['is_enabled'] as bool? ?? true) ? 1 : 0, }); } } }); } Future saveFtpLinksForModule(String moduleName, List> links) async { final db = await database; await db.transaction((txn) async { await txn.delete(_moduleFtpLinksTable, where: 'module_name = ?', whereArgs: [moduleName]); for (final link in links) { if (link['ftp_config_id'] != null) { // Ensure ID is not null await txn.insert(_moduleFtpLinksTable, { 'module_name': moduleName, 'ftp_config_id': link['ftp_config_id'], 'is_enabled': (link['is_enabled'] as bool? ?? true) ? 1 : 0, }); } } }); } Future>> getAllApiLinksForModule(String moduleName) async { final db = await database; final result = await db.query(_moduleApiLinksTable, where: 'module_name = ?', whereArgs: [moduleName]); return result.map((row) => { 'api_config_id': row['api_config_id'], 'is_enabled': (row['is_enabled'] as int) == 1, }).toList(); } Future>> getAllFtpLinksForModule(String moduleName) async { final db = await database; final result = await db.query(_moduleFtpLinksTable, where: 'module_name = ?', whereArgs: [moduleName]); return result.map((row) => { 'ftp_config_id': row['ftp_config_id'], 'is_enabled': (row['is_enabled'] as int) == 1, }).toList(); } }