upgrade sync issue with delta synchronization to avoid down;lload all data every time apps got internet access

This commit is contained in:
ALim Aidrus 2025-08-23 20:14:44 +08:00
parent 2313a4891a
commit 6d37abf141
18 changed files with 450 additions and 384 deletions

View File

@ -39,8 +39,9 @@ class AuthProvider with ChangeNotifier {
List<Map<String, dynamic>>? _positions; List<Map<String, dynamic>>? _positions;
List<Map<String, dynamic>>? _airClients; List<Map<String, dynamic>>? _airClients;
List<Map<String, dynamic>>? _airManualStations; List<Map<String, dynamic>>? _airManualStations;
// --- ADDED FOR STATE LIST ---
List<Map<String, dynamic>>? _states; List<Map<String, dynamic>>? _states;
List<Map<String, dynamic>>? _appSettings;
List<Map<String, dynamic>>? _parameterLimits;
// --- Getters for UI access --- // --- Getters for UI access ---
List<Map<String, dynamic>>? get allUsers => _allUsers; List<Map<String, dynamic>>? get allUsers => _allUsers;
@ -54,10 +55,11 @@ class AuthProvider with ChangeNotifier {
List<Map<String, dynamic>>? get positions => _positions; List<Map<String, dynamic>>? get positions => _positions;
List<Map<String, dynamic>>? get airClients => _airClients; List<Map<String, dynamic>>? get airClients => _airClients;
List<Map<String, dynamic>>? get airManualStations => _airManualStations; List<Map<String, dynamic>>? get airManualStations => _airManualStations;
// --- ADDED FOR STATE LIST ---
List<Map<String, dynamic>>? get states => _states; List<Map<String, dynamic>>? get states => _states;
List<Map<String, dynamic>>? get appSettings => _appSettings;
List<Map<String, dynamic>>? get parameterLimits => _parameterLimits;
// --- SharedPreferences Keys (made public for BaseApiService) --- // --- SharedPreferences Keys ---
static const String tokenKey = 'jwt_token'; static const String tokenKey = 'jwt_token';
static const String userEmailKey = 'user_email'; static const String userEmailKey = 'user_email';
static const String profileDataKey = 'user_profile_data'; static const String profileDataKey = 'user_profile_data';
@ -78,9 +80,11 @@ class AuthProvider with ChangeNotifier {
_jwtToken = prefs.getString(tokenKey); _jwtToken = prefs.getString(tokenKey);
_userEmail = prefs.getString(userEmailKey); _userEmail = prefs.getString(userEmailKey);
_isFirstLogin = prefs.getBool(isFirstLoginKey) ?? true; _isFirstLogin = prefs.getBool(isFirstLoginKey) ?? true;
final lastSyncMillis = prefs.getInt(lastSyncTimestampKey);
if (lastSyncMillis != null) { // MODIFIED: Switched to getting a string for the ISO8601 timestamp
_lastSyncTimestamp = DateTime.fromMillisecondsSinceEpoch(lastSyncMillis); final lastSyncString = prefs.getString(lastSyncTimestampKey);
if (lastSyncString != null) {
_lastSyncTimestamp = DateTime.parse(lastSyncString);
} }
// Always load from local DB first for instant startup // Always load from local DB first for instant startup
@ -98,58 +102,53 @@ class AuthProvider with ChangeNotifier {
notifyListeners(); notifyListeners();
} }
/// The main function to sync all app data. It checks for an internet connection /// The main function to sync all app data using the delta-sync strategy.
/// and fetches from the server if available, otherwise it relies on the local cache.
Future<void> syncAllData({bool forceRefresh = false}) async { Future<void> syncAllData({bool forceRefresh = false}) async {
final connectivityResult = await Connectivity().checkConnectivity(); final connectivityResult = await Connectivity().checkConnectivity();
if (connectivityResult != ConnectivityResult.none) { if (connectivityResult == ConnectivityResult.none) {
debugPrint("AuthProvider: Device is ONLINE. Fetching fresh data from server."); debugPrint("AuthProvider: Device is OFFLINE. Skipping sync.");
await _fetchDataFromServer(); return;
} else { }
debugPrint("AuthProvider: Device is OFFLINE. Data is already loaded from cache.");
debugPrint("AuthProvider: Device is ONLINE. Starting delta sync.");
final prefs = await SharedPreferences.getInstance();
// If 'forceRefresh' is true, sync all data by passing a null timestamp.
final String? lastSync = forceRefresh ? null : prefs.getString(lastSyncTimestampKey);
// Record the time BEFORE the sync starts. This will be our new timestamp on success.
// Use UTC for consistency across timezones.
final newSyncTimestamp = DateTime.now().toUtc().toIso8601String();
final result = await _apiService.syncAllData(lastSyncTimestamp: lastSync);
if (result['success']) {
debugPrint("AuthProvider: Delta sync successful. Updating last sync timestamp.");
// On success, save the new timestamp for the next run.
await prefs.setString(lastSyncTimestampKey, newSyncTimestamp);
_lastSyncTimestamp = DateTime.parse(newSyncTimestamp);
// After updating the DB, reload data from the cache into memory to update the UI.
await _loadDataFromCache();
notifyListeners();
} else {
debugPrint("AuthProvider: Delta sync failed. Timestamp not updated.");
} }
notifyListeners();
} }
/// A dedicated method to refresh only the profile. /// A dedicated method to refresh only the profile.
Future<void> refreshProfile() async { Future<void> refreshProfile() async {
final result = await _apiService.refreshProfile(); final result = await _apiService.refreshProfile();
if (result['success']) { if (result['success']) {
// Update the profile data in the provider state
_profileData = result['data']; _profileData = result['data'];
// Persist the updated profile data in SharedPreferences // Persist the updated profile data
await _dbHelper.saveProfile(_profileData!);
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
await prefs.setString(profileDataKey, jsonEncode(_profileData)); await prefs.setString(profileDataKey, jsonEncode(_profileData));
notifyListeners(); notifyListeners();
} }
} }
/// Fetches all master data from the server and caches it locally.
Future<void> _fetchDataFromServer() async {
final result = await _apiService.syncAllData();
if (result['success']) {
final data = result['data'];
_profileData = data['profile'];
_allUsers = data['allUsers'] != null ? List<Map<String, dynamic>>.from(data['allUsers']) : null;
_tarballStations = data['tarballStations'] != null ? List<Map<String, dynamic>>.from(data['tarballStations']) : null;
_manualStations = data['manualStations'] != null ? List<Map<String, dynamic>>.from(data['manualStations']) : null;
_tarballClassifications = data['tarballClassifications'] != null ? List<Map<String, dynamic>>.from(data['tarballClassifications']) : null;
_riverManualStations = data['riverManualStations'] != null ? List<Map<String, dynamic>>.from(data['riverManualStations']) : null;
_riverTriennialStations = data['riverTriennialStations'] != null ? List<Map<String, dynamic>>.from(data['riverTriennialStations']) : null;
_departments = data['departments'] != null ? List<Map<String, dynamic>>.from(data['departments']) : null;
_companies = data['companies'] != null ? List<Map<String, dynamic>>.from(data['companies']) : null;
_positions = data['positions'] != null ? List<Map<String, dynamic>>.from(data['positions']) : null;
_airClients = data['airClients'] != null ? List<Map<String, dynamic>>.from(data['airClients']) : null;
_airManualStations = data['airManualStations'] != null ? List<Map<String, dynamic>>.from(data['airManualStations']) : null;
// --- ADDED FOR STATE LIST ---
// Note: `syncAllData` in ApiService must be updated to fetch 'states'
_states = data['states'] != null ? List<Map<String, dynamic>>.from(data['states']) : null;
await setLastSyncTimestamp(DateTime.now());
}
}
/// Loads all master data from the local cache using DatabaseHelper. /// Loads all master data from the local cache using DatabaseHelper.
Future<void> _loadDataFromCache() async { Future<void> _loadDataFromCache() async {
_profileData = await _dbHelper.loadProfile(); _profileData = await _dbHelper.loadProfile();
@ -164,11 +163,10 @@ class AuthProvider with ChangeNotifier {
_positions = await _dbHelper.loadPositions(); _positions = await _dbHelper.loadPositions();
_airClients = await _dbHelper.loadAirClients(); _airClients = await _dbHelper.loadAirClients();
_airManualStations = await _dbHelper.loadAirManualStations(); _airManualStations = await _dbHelper.loadAirManualStations();
// --- ADDED FOR STATE LIST ---
// Note: `loadStates()` must be added to your DatabaseHelper class
_states = await _dbHelper.loadStates(); _states = await _dbHelper.loadStates();
// ADDED: Load new data types from the local database
_appSettings = await _dbHelper.loadAppSettings();
_parameterLimits = await _dbHelper.loadParameterLimits();
debugPrint("AuthProvider: All master data loaded from local DB cache."); debugPrint("AuthProvider: All master data loaded from local DB cache.");
} }
@ -187,6 +185,7 @@ class AuthProvider with ChangeNotifier {
await _dbHelper.saveProfile(profile); await _dbHelper.saveProfile(profile);
debugPrint('AuthProvider: Login successful. Session and profile persisted.'); debugPrint('AuthProvider: Login successful. Session and profile persisted.');
// Perform a full refresh on login to ensure data is pristine.
await syncAllData(forceRefresh: true); await syncAllData(forceRefresh: true);
} }
@ -198,13 +197,6 @@ class AuthProvider with ChangeNotifier {
notifyListeners(); notifyListeners();
} }
Future<void> setLastSyncTimestamp(DateTime timestamp) async {
_lastSyncTimestamp = timestamp;
final prefs = await SharedPreferences.getInstance();
await prefs.setInt(lastSyncTimestampKey, timestamp.millisecondsSinceEpoch);
notifyListeners();
}
Future<void> setIsFirstLogin(bool value) async { Future<void> setIsFirstLogin(bool value) async {
_isFirstLogin = value; _isFirstLogin = value;
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
@ -230,12 +222,17 @@ class AuthProvider with ChangeNotifier {
_positions = null; _positions = null;
_airClients = null; _airClients = null;
_airManualStations = null; _airManualStations = null;
// --- ADDED FOR STATE LIST ---
_states = null; _states = null;
// ADDED: Clear new data on logout
_appSettings = null;
_parameterLimits = null;
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
await prefs.clear(); // MODIFIED: Removed keys individually for safer logout
await prefs.remove(tokenKey);
await prefs.remove(userEmailKey);
await prefs.remove(profileDataKey);
await prefs.remove(lastSyncTimestampKey);
await prefs.setBool(isFirstLoginKey, true); await prefs.setBool(isFirstLoginKey, true);
debugPrint('AuthProvider: All session and cached data cleared.'); debugPrint('AuthProvider: All session and cached data cleared.');

View File

@ -33,6 +33,7 @@ class _AirManualCollectionScreenState extends State<AirManualCollectionScreen> {
.getPendingInstallations(); .getPendingInstallations();
} }
// MODIFIED: This method now fetches appSettings and passes it to the service.
Future<void> _submitCollection() async { Future<void> _submitCollection() async {
if (_selectedInstallation == null) { if (_selectedInstallation == null) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
@ -42,9 +43,13 @@ class _AirManualCollectionScreenState extends State<AirManualCollectionScreen> {
} }
setState(() => _isLoading = true); setState(() => _isLoading = true);
// Get the required services and providers.
final service = Provider.of<AirSamplingService>(context, listen: false); final service = Provider.of<AirSamplingService>(context, listen: false);
// MODIFIED: Pass the selected installation data to the service for the Telegram alert. final authProvider = Provider.of<AuthProvider>(context, listen: false);
final result = await service.submitCollection(_collectionData, _selectedInstallation!); final appSettings = authProvider.appSettings;
// Pass the selected installation data and appSettings to the service.
final result = await service.submitCollection(_collectionData, _selectedInstallation!, appSettings);
setState(() => _isLoading = false); setState(() => _isLoading = false);

View File

@ -47,20 +47,26 @@ class _AirManualInstallationScreenState
length, (_) => chars.codeUnitAt(rnd.nextInt(chars.length)))); length, (_) => chars.codeUnitAt(rnd.nextInt(chars.length))));
} }
// MODIFIED: This method now fetches appSettings and passes it to the service.
Future<void> _submitInstallation() async { Future<void> _submitInstallation() async {
setState(() => _isLoading = true); setState(() => _isLoading = true);
// Get the required services and providers.
final service = Provider.of<AirSamplingService>(context, listen: false); final service = Provider.of<AirSamplingService>(context, listen: false);
final result = await service.submitInstallation(_installationData); final authProvider = Provider.of<AuthProvider>(context, listen: false);
final appSettings = authProvider.appSettings;
// Pass the appSettings list to the submit method.
final result = await service.submitInstallation(_installationData, appSettings);
setState(() => _isLoading = false); setState(() => _isLoading = false);
if (!mounted) return; if (!mounted) return;
final message = result['message'] ?? 'An unknown error occurred.'; final message = result['message'] ?? 'An unknown error occurred.';
final color = (result['status'] == 'L1' || result['status'] == 'S1') final color = (result['status'] == 'S1' || result['status'] == 'S2')
? Colors.green ? Colors.green
: Colors.red; : (result['status'] == 'L2_PENDING_IMAGES' ? Colors.orange : Colors.red);
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(message), backgroundColor: color), SnackBar(content: Text(message), backgroundColor: color),

View File

@ -1,6 +1,8 @@
import 'dart:io'; import 'dart:io';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import 'package:provider/provider.dart'; // Added for accessing AuthProvider
import 'package:environment_monitoring_app/auth_provider.dart'; // Added for AuthProvider type
import 'package:environment_monitoring_app/models/tarball_data.dart'; import 'package:environment_monitoring_app/models/tarball_data.dart';
import 'package:environment_monitoring_app/models/in_situ_sampling_data.dart'; import 'package:environment_monitoring_app/models/in_situ_sampling_data.dart';
import 'package:environment_monitoring_app/services/local_storage_service.dart'; import 'package:environment_monitoring_app/services/local_storage_service.dart';
@ -140,6 +142,7 @@ class _MarineManualDataStatusLogState extends State<MarineManualDataStatusLog> {
(log.reportId?.toLowerCase() ?? '').contains(query); (log.reportId?.toLowerCase() ?? '').contains(query);
} }
// MODIFIED: This method now fetches appSettings from AuthProvider before resubmitting.
Future<void> _resubmitData(SubmissionLogEntry log) async { Future<void> _resubmitData(SubmissionLogEntry log) async {
final logKey = log.reportId ?? log.submissionDateTime.toIso8601String(); final logKey = log.reportId ?? log.submissionDateTime.toIso8601String();
if (mounted) { if (mounted) {
@ -149,7 +152,11 @@ class _MarineManualDataStatusLogState extends State<MarineManualDataStatusLog> {
} }
try { try {
final result = await _performResubmission(log); // Get the appSettings from the AuthProvider to pass to the API service.
final authProvider = Provider.of<AuthProvider>(context, listen: false);
final appSettings = authProvider.appSettings;
final result = await _performResubmission(log, appSettings);
final logData = log.rawData; final logData = log.rawData;
logData['submissionStatus'] = result['status']; logData['submissionStatus'] = result['status'];
@ -183,7 +190,8 @@ class _MarineManualDataStatusLogState extends State<MarineManualDataStatusLog> {
} }
} }
Future<Map<String, dynamic>> _performResubmission(SubmissionLogEntry log) async { // MODIFIED: This method now requires appSettings to pass to the API service.
Future<Map<String, dynamic>> _performResubmission(SubmissionLogEntry log, List<Map<String, dynamic>>? appSettings) async {
final logData = log.rawData; final logData = log.rawData;
if (log.type == 'Manual Sampling') { if (log.type == 'Manual Sampling') {
@ -236,7 +244,8 @@ class _MarineManualDataStatusLogState extends State<MarineManualDataStatusLog> {
return _marineApiService.submitInSituSample( return _marineApiService.submitInSituSample(
formData: dataToResubmit.toApiFormData(), formData: dataToResubmit.toApiFormData(),
imageFiles: imageFiles, imageFiles: imageFiles,
inSituData: dataToResubmit, // Added this required parameter inSituData: dataToResubmit,
appSettings: appSettings, // Added this required parameter
); );
} else if (log.type == 'Tarball Sampling') { } else if (log.type == 'Tarball Sampling') {
final int? firstSamplerId = int.tryParse(logData['first_sampler_user_id']?.toString() ?? ''); final int? firstSamplerId = int.tryParse(logData['first_sampler_user_id']?.toString() ?? '');
@ -267,7 +276,11 @@ class _MarineManualDataStatusLogState extends State<MarineManualDataStatusLog> {
} }
} }
return _marineApiService.submitTarballSample(formData: dataToResubmit.toFormData(), imageFiles: imageFiles); return _marineApiService.submitTarballSample(
formData: dataToResubmit.toFormData(),
imageFiles: imageFiles,
appSettings: appSettings, // Added this required parameter
);
} }
throw Exception('Unknown submission type: ${log.type}'); throw Exception('Unknown submission type: ${log.type}');

View File

@ -1,6 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:intl/intl.dart'; // ADDED: Import for date formatting import 'package:intl/intl.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:environment_monitoring_app/auth_provider.dart'; // ADDED: Import for AuthProvider
import '../../../models/in_situ_sampling_data.dart'; import '../../../models/in_situ_sampling_data.dart';
import '../../../services/in_situ_sampling_service.dart'; import '../../../services/in_situ_sampling_service.dart';
@ -79,8 +80,12 @@ class _MarineInSituSamplingState extends State<MarineInSituSampling> {
Future<void> _submitForm() async { Future<void> _submitForm() async {
setState(() => _isLoading = true); setState(() => _isLoading = true);
// Use the service to submit the data. // MODIFIED: Get the appSettings list from AuthProvider.
final result = await _samplingService.submitData(_data); final authProvider = Provider.of<AuthProvider>(context, listen: false);
final appSettings = authProvider.appSettings;
// MODIFIED: Pass the appSettings to the submitData method.
final result = await _samplingService.submitData(_data, appSettings);
if (!mounted) return; if (!mounted) return;

View File

@ -86,12 +86,19 @@ class _MarineTarballSamplingState extends State<MarineTarballSampling> {
Future<void> _getCurrentLocation() async { /* ... Location logic ... */ } Future<void> _getCurrentLocation() async { /* ... Location logic ... */ }
void _calculateDistance() { /* ... Distance logic ... */ } void _calculateDistance() { /* ... Distance logic ... */ }
// MODIFIED: This method now fetches appSettings and passes it to the API service.
Future<void> _submitForm() async { Future<void> _submitForm() async {
setState(() => _isLoading = true); setState(() => _isLoading = true);
// Get the appSettings list from AuthProvider.
final authProvider = Provider.of<AuthProvider>(context, listen: false);
final appSettings = authProvider.appSettings;
// Pass the appSettings list to the submit method.
final result = await _marineApiService.submitTarballSample( final result = await _marineApiService.submitTarballSample(
formData: _data.toFormData(), formData: _data.toFormData(),
imageFiles: _data.toImageFiles(), imageFiles: _data.toImageFiles(),
appSettings: appSettings,
); );
if (!mounted) return; if (!mounted) return;

View File

@ -20,13 +20,20 @@ class _TarballSamplingStep3SummaryState extends State<TarballSamplingStep3Summar
final LocalStorageService _localStorageService = LocalStorageService(); final LocalStorageService _localStorageService = LocalStorageService();
bool _isLoading = false; bool _isLoading = false;
// MODIFIED: This method now fetches appSettings and passes it to the API service.
Future<void> _submitForm() async { Future<void> _submitForm() async {
setState(() => _isLoading = true); setState(() => _isLoading = true);
// Get the appSettings list from AuthProvider.
final authProvider = Provider.of<AuthProvider>(context, listen: false);
final appSettings = authProvider.appSettings;
// Step 1: Orchestrated Server Submission // Step 1: Orchestrated Server Submission
// Pass the appSettings list to the submit method.
final result = await _marineApiService.submitTarballSample( final result = await _marineApiService.submitTarballSample(
formData: widget.data.toFormData(), formData: widget.data.toFormData(),
imageFiles: widget.data.toImageFiles(), imageFiles: widget.data.toImageFiles(),
appSettings: appSettings,
); );
if (!mounted) return; if (!mounted) return;

View File

@ -1,6 +1,8 @@
import 'dart:io'; import 'dart:io';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import 'package:provider/provider.dart'; // ADDED: Import for Provider
import 'package:environment_monitoring_app/auth_provider.dart'; // ADDED: Import for AuthProvider
import '../../../../models/river_in_situ_sampling_data.dart'; import '../../../../models/river_in_situ_sampling_data.dart';
import '../../../../services/local_storage_service.dart'; import '../../../../services/local_storage_service.dart';
@ -142,6 +144,7 @@ class _RiverDataStatusLogState extends State<RiverDataStatusLog> {
(log.reportId?.toLowerCase() ?? '').contains(query); (log.reportId?.toLowerCase() ?? '').contains(query);
} }
// MODIFIED: This method now fetches appSettings from AuthProvider before resubmitting.
Future<void> _resubmitData(SubmissionLogEntry log) async { Future<void> _resubmitData(SubmissionLogEntry log) async {
final logKey = log.reportId ?? log.submissionDateTime.toIso8601String(); final logKey = log.reportId ?? log.submissionDateTime.toIso8601String();
if (mounted) { if (mounted) {
@ -151,6 +154,10 @@ class _RiverDataStatusLogState extends State<RiverDataStatusLog> {
} }
try { try {
// Get the appSettings from the AuthProvider to pass to the API service.
final authProvider = Provider.of<AuthProvider>(context, listen: false);
final appSettings = authProvider.appSettings;
final logData = log.rawData; final logData = log.rawData;
final dataToResubmit = RiverInSituSamplingData.fromJson(logData); final dataToResubmit = RiverInSituSamplingData.fromJson(logData);
final Map<String, File?> imageFiles = {}; final Map<String, File?> imageFiles = {};
@ -166,9 +173,11 @@ class _RiverDataStatusLogState extends State<RiverDataStatusLog> {
} }
} }
// Pass the appSettings list to the submit method.
final result = await _riverApiService.submitInSituSample( final result = await _riverApiService.submitInSituSample(
formData: dataToResubmit.toApiFormData(), formData: dataToResubmit.toApiFormData(),
imageFiles: imageFiles, imageFiles: imageFiles,
appSettings: appSettings,
); );
logData['submissionStatus'] = result['status']; logData['submissionStatus'] = result['status'];

View File

@ -3,6 +3,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:environment_monitoring_app/auth_provider.dart'; // ADDED: Import for AuthProvider
import '../../../models/river_in_situ_sampling_data.dart'; import '../../../models/river_in_situ_sampling_data.dart';
import '../../../services/river_in_situ_sampling_service.dart'; import '../../../services/river_in_situ_sampling_service.dart';
@ -66,10 +67,16 @@ class _RiverInSituSamplingScreenState extends State<RiverInSituSamplingScreen> {
} }
} }
// MODIFIED: This method now fetches appSettings and passes it to the service.
Future<void> _submitForm() async { Future<void> _submitForm() async {
setState(() => _isLoading = true); setState(() => _isLoading = true);
final result = await _samplingService.submitData(_data); // Get the appSettings list from AuthProvider.
final authProvider = Provider.of<AuthProvider>(context, listen: false);
final appSettings = authProvider.appSettings;
// Pass the appSettings list to the submitData method.
final result = await _samplingService.submitData(_data, appSettings);
if (!mounted) return; if (!mounted) return;

View File

@ -12,18 +12,15 @@ class SettingsScreen extends StatefulWidget {
} }
class _SettingsScreenState extends State<SettingsScreen> { class _SettingsScreenState extends State<SettingsScreen> {
// SettingsService is now a utility, it doesn't hold state.
final SettingsService _settingsService = SettingsService(); final SettingsService _settingsService = SettingsService();
bool _isSyncingData = false; bool _isSyncingData = false;
bool _isSyncingSettings = false;
String _inSituChatId = 'Loading...'; // REMOVED: Redundant state variable for settings sync
String _tarballChatId = 'Loading...'; // bool _isSyncingSettings = false;
String _riverInSituChatId = 'Loading...';
String _riverTriennialChatId = 'Loading...'; // REMOVED: Chat ID state variables are no longer needed,
String _riverInvestigativeChatId = 'Loading...'; // we will read directly from the provider in the build method.
String _airManualChatId = 'Loading...';
String _airInvestigativeChatId = 'Loading...';
String _marineInvestigativeChatId = 'Loading...';
final TextEditingController _tarballSearchController = TextEditingController(); final TextEditingController _tarballSearchController = TextEditingController();
String _tarballSearchQuery = ''; String _tarballSearchQuery = '';
@ -37,7 +34,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_loadCurrentSettings(); // REMOVED: _loadCurrentSettings() is no longer needed as we read from the provider.
_tarballSearchController.addListener(_onTarballSearchChanged); _tarballSearchController.addListener(_onTarballSearchChanged);
_manualSearchController.addListener(_onManualSearchChanged); _manualSearchController.addListener(_onManualSearchChanged);
_riverManualSearchController.addListener(_onRiverManualSearchChanged); _riverManualSearchController.addListener(_onRiverManualSearchChanged);
@ -53,31 +50,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
super.dispose(); super.dispose();
} }
Future<void> _loadCurrentSettings() async { // REMOVED: _loadCurrentSettings is obsolete. The build method will now
final results = await Future.wait([ // get the latest settings directly from AuthProvider.
_settingsService.getInSituChatId(),
_settingsService.getTarballChatId(),
_settingsService.getRiverInSituChatId(),
_settingsService.getRiverTriennialChatId(),
_settingsService.getRiverInvestigativeChatId(),
_settingsService.getAirManualChatId(),
_settingsService.getAirInvestigativeChatId(),
_settingsService.getMarineInvestigativeChatId(),
]);
if (mounted) {
setState(() {
_inSituChatId = results[0].isNotEmpty ? results[0] : 'Not Set';
_tarballChatId = results[1].isNotEmpty ? results[1] : 'Not Set';
_riverInSituChatId = results[2].isNotEmpty ? results[2] : 'Not Set';
_riverTriennialChatId = results[3].isNotEmpty ? results[3] : 'Not Set';
_riverInvestigativeChatId = results[4].isNotEmpty ? results[4] : 'Not Set';
_airManualChatId = results[5].isNotEmpty ? results[5] : 'Not Set';
_airInvestigativeChatId = results[6].isNotEmpty ? results[6] : 'Not Set';
_marineInvestigativeChatId = results[7].isNotEmpty ? results[7] : 'Not Set';
});
}
}
void _onTarballSearchChanged() { void _onTarballSearchChanged() {
setState(() { _tarballSearchQuery = _tarballSearchController.text; }); setState(() { _tarballSearchQuery = _tarballSearchController.text; });
@ -102,6 +76,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
final auth = Provider.of<AuthProvider>(context, listen: false); final auth = Provider.of<AuthProvider>(context, listen: false);
try { try {
// This now syncs ALL data, including settings.
await auth.syncAllData(forceRefresh: true); await auth.syncAllData(forceRefresh: true);
if (mounted) { if (mounted) {
@ -118,21 +93,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
} }
} }
Future<void> _manualSettingsSync() async { // REMOVED: _manualSettingsSync is obsolete because the main data sync
if (_isSyncingSettings) return; // now handles settings as well.
setState(() => _isSyncingSettings = true);
final success = await _settingsService.syncFromServer();
if (mounted) {
final message = success ? 'Telegram settings synced successfully.' : 'Failed to sync settings.';
_showSnackBar(message, isError: !success);
if (success) {
await _loadCurrentSettings();
}
setState(() => _isSyncingSettings = false);
}
}
void _showSnackBar(String message, {bool isError = false}) { void _showSnackBar(String message, {bool isError = false}) {
if (mounted) { if (mounted) {
@ -150,6 +112,9 @@ class _SettingsScreenState extends State<SettingsScreen> {
final auth = Provider.of<AuthProvider>(context); final auth = Provider.of<AuthProvider>(context);
final lastSync = auth.lastSyncTimestamp; final lastSync = auth.lastSyncTimestamp;
// Get the synced app settings from the provider.
final appSettings = auth.appSettings;
final filteredTarballStations = auth.tarballStations?.where((station) { final filteredTarballStations = auth.tarballStations?.where((station) {
final stationName = station['tbl_station_name']?.toLowerCase() ?? ''; final stationName = station['tbl_station_name']?.toLowerCase() ?? '';
final stationCode = station['tbl_station_code']?.toLowerCase() ?? ''; final stationCode = station['tbl_station_code']?.toLowerCase() ?? '';
@ -197,7 +162,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
children: [ children: [
Text("Last Data Sync:", style: Theme.of(context).textTheme.titleMedium), Text("Last Data Sync:", style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 4), const SizedBox(height: 4),
Text(lastSync != null ? DateFormat('yyyy-MM-dd HH:mm:ss').format(lastSync) : 'Never', style: Theme.of(context).textTheme.bodyLarge), Text(lastSync != null ? DateFormat('yyyy-MM-dd HH:mm:ss').format(lastSync.toLocal()) : 'Never', style: Theme.of(context).textTheme.bodyLarge),
const SizedBox(height: 16), const SizedBox(height: 16),
ElevatedButton.icon( ElevatedButton.icon(
onPressed: _isSyncingData ? null : _manualDataSync, onPressed: _isSyncingData ? null : _manualDataSync,
@ -223,38 +188,29 @@ class _SettingsScreenState extends State<SettingsScreen> {
title: const Text('Marine Alerts', style: TextStyle(fontWeight: FontWeight.bold)), title: const Text('Marine Alerts', style: TextStyle(fontWeight: FontWeight.bold)),
initiallyExpanded: false, initiallyExpanded: false,
children: [ children: [
_buildChatIdEntry('In-Situ', _inSituChatId), _buildChatIdEntry('In-Situ', _settingsService.getInSituChatId(appSettings)),
_buildChatIdEntry('Tarball', _tarballChatId), _buildChatIdEntry('Tarball', _settingsService.getTarballChatId(appSettings)),
_buildChatIdEntry('Investigative', _marineInvestigativeChatId), _buildChatIdEntry('Investigative', _settingsService.getMarineInvestigativeChatId(appSettings)),
], ],
), ),
ExpansionTile( ExpansionTile(
title: const Text('River Alerts', style: TextStyle(fontWeight: FontWeight.bold)), title: const Text('River Alerts', style: TextStyle(fontWeight: FontWeight.bold)),
initiallyExpanded: false, initiallyExpanded: false,
children: [ children: [
_buildChatIdEntry('In-Situ', _riverInSituChatId), _buildChatIdEntry('In-Situ', _settingsService.getRiverInSituChatId(appSettings)),
_buildChatIdEntry('Triennial', _riverTriennialChatId), _buildChatIdEntry('Triennial', _settingsService.getRiverTriennialChatId(appSettings)),
_buildChatIdEntry('Investigative', _riverInvestigativeChatId), _buildChatIdEntry('Investigative', _settingsService.getRiverInvestigativeChatId(appSettings)),
], ],
), ),
ExpansionTile( ExpansionTile(
title: const Text('Air Alerts', style: TextStyle(fontWeight: FontWeight.bold)), title: const Text('Air Alerts', style: TextStyle(fontWeight: FontWeight.bold)),
initiallyExpanded: false, initiallyExpanded: false,
children: [ children: [
_buildChatIdEntry('Manual', _airManualChatId), _buildChatIdEntry('Manual', _settingsService.getAirManualChatId(appSettings)),
_buildChatIdEntry('Investigative', _airInvestigativeChatId), _buildChatIdEntry('Investigative', _settingsService.getAirInvestigativeChatId(appSettings)),
], ],
), ),
const SizedBox(height: 16), // REMOVED: The separate sync button for settings is no longer needed.
ElevatedButton.icon(
onPressed: _isSyncingSettings ? null : _manualSettingsSync,
icon: _isSyncingSettings ? const SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white)) : const Icon(Icons.settings_backup_restore),
label: Text(_isSyncingSettings ? 'Syncing Settings...' : 'Sync Telegram Settings'),
style: ElevatedButton.styleFrom(
backgroundColor: Theme.of(context).colorScheme.secondary,
minimumSize: const Size(double.infinity, 50),
),
),
], ],
), ),
), ),
@ -476,7 +432,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
contentPadding: EdgeInsets.zero, contentPadding: EdgeInsets.zero,
leading: const Icon(Icons.telegram, size: 20), leading: const Icon(Icons.telegram, size: 20),
title: Text('$label Chat ID'), title: Text('$label Chat ID'),
subtitle: Text(value), subtitle: Text(value.isNotEmpty ? value : 'Not Set'),
dense: true, dense: true,
); );
} }

View File

@ -70,24 +70,28 @@ class AirSamplingService {
return File(filePath)..writeAsBytesSync(img.encodeJpg(originalImage)); return File(filePath)..writeAsBytesSync(img.encodeJpg(originalImage));
} }
Future<void> _handleInstallationSuccessAlert(AirInstallationData data, {required bool isDataOnly}) async { // MODIFIED: Method now requires the appSettings list to pass to TelegramService.
Future<void> _handleInstallationSuccessAlert(AirInstallationData data, List<Map<String, dynamic>>? appSettings, {required bool isDataOnly}) async {
try { try {
final message = data.generateInstallationTelegramAlert(isDataOnly: isDataOnly); final message = data.generateInstallationTelegramAlert(isDataOnly: isDataOnly);
final bool wasSent = await _telegramService.sendAlertImmediately('air_manual', message); // Pass the appSettings list to the telegram service methods
final bool wasSent = await _telegramService.sendAlertImmediately('air_manual', message, appSettings);
if (!wasSent) { if (!wasSent) {
await _telegramService.queueMessage('air_manual', message); await _telegramService.queueMessage('air_manual', message, appSettings);
} }
} catch (e) { } catch (e) {
debugPrint("Failed to handle Air Manual Installation Telegram alert: $e"); debugPrint("Failed to handle Air Manual Installation Telegram alert: $e");
} }
} }
Future<void> _handleCollectionSuccessAlert(AirCollectionData data, AirInstallationData installationData, {required bool isDataOnly}) async { // MODIFIED: Method now requires the appSettings list to pass to TelegramService.
Future<void> _handleCollectionSuccessAlert(AirCollectionData data, AirInstallationData installationData, List<Map<String, dynamic>>? appSettings, {required bool isDataOnly}) async {
try { try {
final message = data.generateCollectionTelegramAlert(installationData, isDataOnly: isDataOnly); final message = data.generateCollectionTelegramAlert(installationData, isDataOnly: isDataOnly);
final bool wasSent = await _telegramService.sendAlertImmediately('air_manual', message); // Pass the appSettings list to the telegram service methods
final bool wasSent = await _telegramService.sendAlertImmediately('air_manual', message, appSettings);
if (!wasSent) { if (!wasSent) {
await _telegramService.queueMessage('air_manual', message); await _telegramService.queueMessage('air_manual', message, appSettings);
} }
} catch (e) { } catch (e) {
debugPrint("Failed to handle Air Manual Collection Telegram alert: $e"); debugPrint("Failed to handle Air Manual Collection Telegram alert: $e");
@ -95,7 +99,8 @@ class AirSamplingService {
} }
/// Orchestrates a two-step submission process for air installation samples. /// Orchestrates a two-step submission process for air installation samples.
Future<Map<String, dynamic>> submitInstallation(AirInstallationData data) async { // MODIFIED: Method now requires the appSettings list to pass down the call stack.
Future<Map<String, dynamic>> submitInstallation(AirInstallationData data, List<Map<String, dynamic>>? appSettings) async {
// --- OFFLINE-FIRST HELPER --- // --- OFFLINE-FIRST HELPER ---
Future<Map<String, dynamic>> saveLocally(String status, String message) async { Future<Map<String, dynamic>> saveLocally(String status, String message) async {
debugPrint("Saving installation locally with status: $status"); debugPrint("Saving installation locally with status: $status");
@ -107,7 +112,7 @@ class AirSamplingService {
// If the record's text data is already on the server, skip directly to image upload. // If the record's text data is already on the server, skip directly to image upload.
if (data.status == 'L2_PENDING_IMAGES' && data.airManId != null) { if (data.status == 'L2_PENDING_IMAGES' && data.airManId != null) {
debugPrint("Retrying image upload for existing record ID: ${data.airManId}"); debugPrint("Retrying image upload for existing record ID: ${data.airManId}");
return await _uploadInstallationImagesAndUpdate(data); return await _uploadInstallationImagesAndUpdate(data, appSettings);
} }
// --- STEP 1: SUBMIT TEXT DATA --- // --- STEP 1: SUBMIT TEXT DATA ---
@ -137,17 +142,18 @@ class AirSamplingService {
data.airManId = parsedRecordId; data.airManId = parsedRecordId;
// --- STEP 2: UPLOAD IMAGE FILES --- // --- STEP 2: UPLOAD IMAGE FILES ---
return await _uploadInstallationImagesAndUpdate(data); return await _uploadInstallationImagesAndUpdate(data, appSettings);
} }
/// A reusable function for handling the image upload and local data update logic. /// A reusable function for handling the image upload and local data update logic.
Future<Map<String, dynamic>> _uploadInstallationImagesAndUpdate(AirInstallationData data) async { // MODIFIED: Method now requires the appSettings list to pass to the alert handler.
Future<Map<String, dynamic>> _uploadInstallationImagesAndUpdate(AirInstallationData data, List<Map<String, dynamic>>? appSettings) async {
final filesToUpload = data.getImagesForUpload(); final filesToUpload = data.getImagesForUpload();
if (filesToUpload.isEmpty) { if (filesToUpload.isEmpty) {
debugPrint("No images to upload. Submission complete."); debugPrint("No images to upload. Submission complete.");
data.status = 'S1'; // Server Pending (no images needed) data.status = 'S1'; // Server Pending (no images needed)
await _localStorageService.saveAirSamplingRecord(data.toMap(), data.refID!); await _localStorageService.saveAirSamplingRecord(data.toMap(), data.refID!);
_handleInstallationSuccessAlert(data, isDataOnly: true); _handleInstallationSuccessAlert(data, appSettings, isDataOnly: true);
return {'status': 'S1', 'message': 'Installation data submitted successfully.'}; return {'status': 'S1', 'message': 'Installation data submitted successfully.'};
} }
@ -170,7 +176,7 @@ class AirSamplingService {
debugPrint("Images uploaded successfully."); debugPrint("Images uploaded successfully.");
data.status = 'S2'; // Server Pending (images uploaded) data.status = 'S2'; // Server Pending (images uploaded)
await _localStorageService.saveAirSamplingRecord(data.toMap(), data.refID!); await _localStorageService.saveAirSamplingRecord(data.toMap(), data.refID!);
_handleInstallationSuccessAlert(data, isDataOnly: false); _handleInstallationSuccessAlert(data, appSettings, isDataOnly: false);
return { return {
'status': 'S2', 'status': 'S2',
'message': 'Installation data and images submitted successfully.', 'message': 'Installation data and images submitted successfully.',
@ -178,7 +184,8 @@ class AirSamplingService {
} }
/// Submits only the collection data, linked to a previous installation. /// Submits only the collection data, linked to a previous installation.
Future<Map<String, dynamic>> submitCollection(AirCollectionData data, AirInstallationData installationData) async { // MODIFIED: Method now requires the appSettings list to pass down the call stack.
Future<Map<String, dynamic>> submitCollection(AirCollectionData data, AirInstallationData installationData, List<Map<String, dynamic>>? appSettings) async {
// --- OFFLINE-FIRST HELPER (CORRECTED) --- // --- OFFLINE-FIRST HELPER (CORRECTED) ---
Future<Map<String, dynamic>> updateAndSaveLocally(String newStatus, {String? message}) async { Future<Map<String, dynamic>> updateAndSaveLocally(String newStatus, {String? message}) async {
debugPrint("Saving collection data locally with status: $newStatus"); debugPrint("Saving collection data locally with status: $newStatus");
@ -201,7 +208,7 @@ class AirSamplingService {
// If the record's text data is already on the server, skip directly to image upload. // If the record's text data is already on the server, skip directly to image upload.
if (data.status == 'L4_PENDING_IMAGES' && data.airManId != null) { if (data.status == 'L4_PENDING_IMAGES' && data.airManId != null) {
debugPrint("Retrying collection image upload for existing record ID: ${data.airManId}"); debugPrint("Retrying collection image upload for existing record ID: ${data.airManId}");
return await _uploadCollectionImagesAndUpdate(data, installationData); return await _uploadCollectionImagesAndUpdate(data, installationData, appSettings);
} }
// --- STEP 1: SUBMIT TEXT DATA --- // --- STEP 1: SUBMIT TEXT DATA ---
@ -215,11 +222,12 @@ class AirSamplingService {
debugPrint("Collection text data submitted successfully."); debugPrint("Collection text data submitted successfully.");
// --- STEP 2: UPLOAD IMAGE FILES --- // --- STEP 2: UPLOAD IMAGE FILES ---
return await _uploadCollectionImagesAndUpdate(data, installationData); return await _uploadCollectionImagesAndUpdate(data, installationData, appSettings);
} }
/// A reusable function for handling the collection image upload and local data update logic. /// A reusable function for handling the collection image upload and local data update logic.
Future<Map<String, dynamic>> _uploadCollectionImagesAndUpdate(AirCollectionData data, AirInstallationData installationData) async { // MODIFIED: Method now requires the appSettings list to pass to the alert handler.
Future<Map<String, dynamic>> _uploadCollectionImagesAndUpdate(AirCollectionData data, AirInstallationData installationData, List<Map<String, dynamic>>? appSettings) async {
// --- OFFLINE-FIRST HELPER (CORRECTED) --- // --- OFFLINE-FIRST HELPER (CORRECTED) ---
Future<Map<String, dynamic>> updateAndSaveLocally(String newStatus, {String? message}) async { Future<Map<String, dynamic>> updateAndSaveLocally(String newStatus, {String? message}) async {
debugPrint("Saving collection data locally with status: $newStatus"); debugPrint("Saving collection data locally with status: $newStatus");
@ -242,7 +250,7 @@ class AirSamplingService {
if (filesToUpload.isEmpty) { if (filesToUpload.isEmpty) {
debugPrint("No collection images to upload. Submission complete."); debugPrint("No collection images to upload. Submission complete.");
await updateAndSaveLocally('S3'); // S3 = Server Completed await updateAndSaveLocally('S3'); // S3 = Server Completed
_handleCollectionSuccessAlert(data, installationData, isDataOnly: true); _handleCollectionSuccessAlert(data, installationData, appSettings, isDataOnly: true);
return {'status': 'S3', 'message': 'Collection data submitted successfully.'}; return {'status': 'S3', 'message': 'Collection data submitted successfully.'};
} }
@ -260,7 +268,7 @@ class AirSamplingService {
debugPrint("Images uploaded successfully."); debugPrint("Images uploaded successfully.");
await updateAndSaveLocally('S3'); // S3 = Server Completed await updateAndSaveLocally('S3'); // S3 = Server Completed
_handleCollectionSuccessAlert(data, installationData, isDataOnly: false); _handleCollectionSuccessAlert(data, installationData, appSettings, isDataOnly: false);
return { return {
'status': 'S3', 'status': 'S3',
'message': 'Collection data and images submitted successfully.', 'message': 'Collection data and images submitted successfully.',

View File

@ -33,7 +33,7 @@ class ApiService {
air = AirApiService(_baseService); air = AirApiService(_baseService);
} }
// --- Core API Methods --- // --- Core API Methods (Unchanged) ---
Future<Map<String, dynamic>> login(String email, String password) { Future<Map<String, dynamic>> login(String email, String password) {
return _baseService.post('auth/login', {'email': email, 'password': password}); return _baseService.post('auth/login', {'email': email, 'password': password});
@ -121,79 +121,87 @@ class ApiService {
return result; return result;
} }
// --- REWRITTEN FOR DELTA SYNC ---
/// Orchestrates a full data sync from the server to the local database. /// Helper method to make a delta-sync API call.
Future<Map<String, dynamic>> syncAllData() async { Future<Map<String, dynamic>> _fetchDelta(String endpoint, String? lastSyncTimestamp) {
debugPrint('ApiService: Starting full data sync from server...'); String url = endpoint;
if (lastSyncTimestamp != null) {
// Append the 'since' parameter to the URL for delta requests
url += '?since=$lastSyncTimestamp';
}
return _baseService.get(url);
}
/// Orchestrates a full DELTA sync from the server to the local database.
Future<Map<String, dynamic>> syncAllData({String? lastSyncTimestamp}) async {
debugPrint('ApiService: Starting DELTA data sync. Since: $lastSyncTimestamp');
try { try {
final results = await Future.wait([ // Defines all data types to sync, their endpoints, and their DB handlers.
getProfile(), final syncTasks = {
getAllUsers(), 'profile': {'endpoint': 'profile', 'handler': (d, id) async { if (d.isNotEmpty) await _dbHelper.saveProfile(d.first); }},
marine.getTarballStations(), 'allUsers': {'endpoint': 'users', 'handler': (d, id) async { await _dbHelper.upsertUsers(d); await _dbHelper.deleteUsers(id); }},
marine.getManualStations(), 'tarballStations': {'endpoint': 'marine/tarball/stations', 'handler': (d, id) async { await _dbHelper.upsertTarballStations(d); await _dbHelper.deleteTarballStations(id); }},
marine.getTarballClassifications(), 'manualStations': {'endpoint': 'marine/manual/stations', 'handler': (d, id) async { await _dbHelper.upsertManualStations(d); await _dbHelper.deleteManualStations(id); }},
river.getManualStations(), 'tarballClassifications': {'endpoint': 'marine/tarball/classifications', 'handler': (d, id) async { await _dbHelper.upsertTarballClassifications(d); await _dbHelper.deleteTarballClassifications(id); }},
river.getTriennialStations(), 'riverManualStations': {'endpoint': 'river/manual-stations', 'handler': (d, id) async { await _dbHelper.upsertRiverManualStations(d); await _dbHelper.deleteRiverManualStations(id); }},
getAllDepartments(), 'riverTriennialStations': {'endpoint': 'river/triennial-stations', 'handler': (d, id) async { await _dbHelper.upsertRiverTriennialStations(d); await _dbHelper.deleteRiverTriennialStations(id); }},
getAllCompanies(), 'departments': {'endpoint': 'departments', 'handler': (d, id) async { await _dbHelper.upsertDepartments(d); await _dbHelper.deleteDepartments(id); }},
getAllPositions(), 'companies': {'endpoint': 'companies', 'handler': (d, id) async { await _dbHelper.upsertCompanies(d); await _dbHelper.deleteCompanies(id); }},
air.getManualStations(), 'positions': {'endpoint': 'positions', 'handler': (d, id) async { await _dbHelper.upsertPositions(d); await _dbHelper.deletePositions(id); }},
air.getClients(), 'airManualStations': {'endpoint': 'air/manual-stations', 'handler': (d, id) async { await _dbHelper.upsertAirManualStations(d); await _dbHelper.deleteAirManualStations(id); }},
getAllStates(), '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); }},
final Map<String, dynamic> syncedData = { 'parameterLimits': {'endpoint': 'parameter-limits', 'handler': (d, id) async { await _dbHelper.upsertParameterLimits(d); await _dbHelper.deleteParameterLimits(id); }},
'profile': results[0]['success'] == true ? results[0]['data'] : null,
'allUsers': results[1]['success'] == true ? results[1]['data'] : null,
'tarballStations': results[2]['success'] == true ? results[2]['data'] : null,
'manualStations': results[3]['success'] == true ? results[3]['data'] : null,
'tarballClassifications': results[4]['success'] == true ? results[4]['data'] : null,
'riverManualStations': results[5]['success'] == true ? results[5]['data'] : null,
'riverTriennialStations': results[6]['success'] == true ? results[6]['data'] : null,
'departments': results[7]['success'] == true ? results[7]['data'] : null,
'companies': results[8]['success'] == true ? results[8]['data'] : null,
'positions': results[9]['success'] == true ? results[9]['data'] : null,
'airManualStations': results[10]['success'] == true ? results[10]['data'] : null,
'airClients': results[11]['success'] == true ? results[11]['data'] : null,
'states': results[12]['success'] == true ? results[12]['data'] : null,
}; };
if (syncedData['profile'] != null) await _dbHelper.saveProfile(syncedData['profile']); // Fetch all deltas in parallel
if (syncedData['allUsers'] != null) await _dbHelper.saveUsers(List<Map<String, dynamic>>.from(syncedData['allUsers'])); final fetchFutures = syncTasks.map((key, value) => MapEntry(key, _fetchDelta(value['endpoint'] as String, lastSyncTimestamp)));
if (syncedData['tarballStations'] != null) await _dbHelper.saveTarballStations(List<Map<String, dynamic>>.from(syncedData['tarballStations'])); final results = await Future.wait(fetchFutures.values);
if (syncedData['manualStations'] != null) await _dbHelper.saveManualStations(List<Map<String, dynamic>>.from(syncedData['manualStations'])); final resultData = Map.fromIterables(fetchFutures.keys, results);
if (syncedData['tarballClassifications'] != null) await _dbHelper.saveTarballClassifications(List<Map<String, dynamic>>.from(syncedData['tarballClassifications']));
if (syncedData['riverManualStations'] != null) await _dbHelper.saveRiverManualStations(List<Map<String, dynamic>>.from(syncedData['riverManualStations']));
if (syncedData['riverTriennialStations'] != null) await _dbHelper.saveRiverTriennialStations(List<Map<String, dynamic>>.from(syncedData['riverTriennialStations']));
if (syncedData['departments'] != null) await _dbHelper.saveDepartments(List<Map<String, dynamic>>.from(syncedData['departments']));
if (syncedData['companies'] != null) await _dbHelper.saveCompanies(List<Map<String, dynamic>>.from(syncedData['companies']));
if (syncedData['positions'] != null) await _dbHelper.savePositions(List<Map<String, dynamic>>.from(syncedData['positions']));
if (syncedData['airManualStations'] != null) await _dbHelper.saveAirManualStations(List<Map<String, dynamic>>.from(syncedData['airManualStations']));
if (syncedData['airClients'] != null) await _dbHelper.saveAirClients(List<Map<String, dynamic>>.from(syncedData['airClients']));
if (syncedData['states'] != null) await _dbHelper.saveStates(List<Map<String, dynamic>>.from(syncedData['states']));
debugPrint('ApiService: Sync complete. Data saved to local DB.'); // Process and save all changes
return {'success': true, 'data': syncedData}; for (var entry in resultData.entries) {
final key = entry.key;
final result = entry.value;
if (result['success'] == true && result['data'] != null) {
// The profile endpoint has a different structure, handle it separately.
if (key == 'profile') {
await (syncTasks[key]!['handler'] as Function)([result['data']], []);
} else {
final updated = List<Map<String, dynamic>>.from(result['data']['updated'] ?? []);
final deleted = List<dynamic>.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) { } catch (e) {
debugPrint('ApiService: Full data sync failed: $e'); debugPrint('ApiService: Delta data sync failed: $e');
return {'success': false, 'message': 'Data sync failed: $e'}; return {'success': false, 'message': 'Data sync failed: $e'};
} }
} }
} }
// ======================================================================= // =======================================================================
// Part 2: Feature-Specific API Services // Part 2: Feature-Specific API Services (Unchanged)
// ======================================================================= // =======================================================================
class AirApiService { class AirApiService {
// ... (No changes needed here)
final BaseApiService _baseService; final BaseApiService _baseService;
AirApiService(this._baseService); AirApiService(this._baseService);
Future<Map<String, dynamic>> getManualStations() => _baseService.get('air/manual-stations'); Future<Map<String, dynamic>> getManualStations() => _baseService.get('air/manual-stations');
Future<Map<String, dynamic>> getClients() => _baseService.get('air/clients'); Future<Map<String, dynamic>> getClients() => _baseService.get('air/clients');
// NEW: Added dedicated method for uploading installation images
Future<Map<String, dynamic>> uploadInstallationImages({ Future<Map<String, dynamic>> uploadInstallationImages({
required String airManId, required String airManId,
required Map<String, File> files, required Map<String, File> files,
@ -205,13 +213,11 @@ class AirApiService {
); );
} }
// NECESSARY FIX: Added dedicated method for uploading collection images
Future<Map<String, dynamic>> uploadCollectionImages({ Future<Map<String, dynamic>> uploadCollectionImages({
required String airManId, required String airManId,
required Map<String, File> files, required Map<String, File> files,
}) { }) {
return _baseService.postMultipart( return _baseService.postMultipart(
// Note: Please verify this endpoint path with your backend developer.
endpoint: 'air/manual/collection-images', endpoint: 'air/manual/collection-images',
fields: {'air_man_id': airManId}, fields: {'air_man_id': airManId},
files: files, files: files,
@ -221,6 +227,7 @@ class AirApiService {
class MarineApiService { class MarineApiService {
// ... (No changes needed here)
final BaseApiService _baseService; final BaseApiService _baseService;
MarineApiService(this._baseService); MarineApiService(this._baseService);
@ -251,6 +258,7 @@ class MarineApiService {
} }
class RiverApiService { class RiverApiService {
// ... (No changes needed here)
final BaseApiService _baseService; final BaseApiService _baseService;
RiverApiService(this._baseService); RiverApiService(this._baseService);
@ -259,13 +267,14 @@ class RiverApiService {
} }
// ======================================================================= // =======================================================================
// Part 3: Local Database Helper // Part 3: Local Database Helper (Refactored for Delta Sync)
// ======================================================================= // =======================================================================
class DatabaseHelper { class DatabaseHelper {
static Database? _database; static Database? _database;
static const String _dbName = 'app_data.db'; static const String _dbName = 'app_data.db';
static const int _dbVersion = 12; // Incremented DB version to trigger the onUpgrade method
static const int _dbVersion = 13;
static const String _profileTable = 'user_profile'; static const String _profileTable = 'user_profile';
static const String _usersTable = 'all_users'; static const String _usersTable = 'all_users';
@ -281,7 +290,9 @@ class DatabaseHelper {
static const String _airManualStationsTable = 'air_manual_stations'; static const String _airManualStationsTable = 'air_manual_stations';
static const String _airClientsTable = 'air_clients'; static const String _airClientsTable = 'air_clients';
static const String _statesTable = 'states'; static const String _statesTable = 'states';
// Added new table constants
static const String _appSettingsTable = 'app_settings';
static const String _parameterLimitsTable = 'manual_parameter_limits';
Future<Database> get database async { Future<Database> get database async {
if (_database != null) return _database!; if (_database != null) return _database!;
@ -309,6 +320,9 @@ class DatabaseHelper {
await db.execute('CREATE TABLE $_airManualStationsTable(station_id INTEGER PRIMARY KEY, station_json TEXT)'); 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 $_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 $_statesTable(state_id INTEGER PRIMARY KEY, state_json TEXT)');
// Added create statements for new tables
await db.execute('CREATE TABLE $_appSettingsTable(setting_id INTEGER PRIMARY KEY, setting_json TEXT)');
await db.execute('CREATE TABLE $_parameterLimitsTable(param_autoid INTEGER PRIMARY KEY, limit_json TEXT)');
} }
Future<void> _onUpgrade(Database db, int oldVersion, int newVersion) async { Future<void> _onUpgrade(Database db, int oldVersion, int newVersion) async {
@ -319,21 +333,46 @@ class DatabaseHelper {
if (oldVersion < 12) { if (oldVersion < 12) {
await db.execute('CREATE TABLE IF NOT EXISTS $_statesTable(state_id INTEGER PRIMARY KEY, state_json TEXT)'); 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)');
Future<void> _saveData(String table, String idKey, List<Map<String, dynamic>> data) async { await db.execute('CREATE TABLE IF NOT EXISTS $_parameterLimitsTable(param_autoid INTEGER PRIMARY KEY, limit_json TEXT)');
final db = await database;
await db.delete(table);
for (var item in data) {
await db.insert(table, {'${idKey}_id': item['${idKey}_id'], '${idKey}_json': jsonEncode(item)}, conflictAlgorithm: ConflictAlgorithm.replace);
} }
} }
Future<List<Map<String, dynamic>>?> _loadData(String table, String idKey) async { /// Performs an "upsert": inserts new records or replaces existing ones.
Future<void> _upsertData(String table, String idKeyName, List<Map<String, dynamic>> data, String jsonKeyName) async {
if (data.isEmpty) return;
final db = await database;
final batch = db.batch();
for (var item in data) {
batch.insert(
table,
{idKeyName: item[idKeyName], '${jsonKeyName}_json': jsonEncode(item)},
conflictAlgorithm: ConflictAlgorithm.replace,
);
}
await batch.commit(noResult: true);
debugPrint("Upserted ${data.length} items into $table");
}
/// Deletes a list of records from a table by their primary keys.
Future<void> _deleteData(String table, String idKeyName, List<dynamic> ids) async {
if (ids.isEmpty) return;
final db = await database;
final placeholders = List.filled(ids.length, '?').join(', ');
await db.delete(
table,
where: '$idKeyName IN ($placeholders)',
whereArgs: ids,
);
debugPrint("Deleted ${ids.length} items from $table");
}
Future<List<Map<String, dynamic>>?> _loadData(String table, String jsonKey) async {
final db = await database; final db = await database;
final List<Map<String, dynamic>> maps = await db.query(table); final List<Map<String, dynamic>> maps = await db.query(table);
if (maps.isNotEmpty) { if (maps.isNotEmpty) {
return maps.map((map) => jsonDecode(map['${idKey}_json']) as Map<String, dynamic>).toList(); return maps.map((map) => jsonDecode(map['${jsonKey}_json']) as Map<String, dynamic>).toList();
} }
return null; return null;
} }
@ -349,39 +388,61 @@ class DatabaseHelper {
return null; return null;
} }
Future<void> saveUsers(List<Map<String, dynamic>> users) => _saveData(_usersTable, 'user', users); // --- Upsert/Delete/Load methods for all data types ---
Future<void> upsertUsers(List<Map<String, dynamic>> data) => _upsertData(_usersTable, 'user_id', data, 'user');
Future<void> deleteUsers(List<dynamic> ids) => _deleteData(_usersTable, 'user_id', ids);
Future<List<Map<String, dynamic>>?> loadUsers() => _loadData(_usersTable, 'user'); Future<List<Map<String, dynamic>>?> loadUsers() => _loadData(_usersTable, 'user');
Future<void> saveTarballStations(List<Map<String, dynamic>> stations) => _saveData(_tarballStationsTable, 'station', stations); Future<void> upsertTarballStations(List<Map<String, dynamic>> data) => _upsertData(_tarballStationsTable, 'station_id', data, 'station');
Future<void> deleteTarballStations(List<dynamic> ids) => _deleteData(_tarballStationsTable, 'station_id', ids);
Future<List<Map<String, dynamic>>?> loadTarballStations() => _loadData(_tarballStationsTable, 'station'); Future<List<Map<String, dynamic>>?> loadTarballStations() => _loadData(_tarballStationsTable, 'station');
Future<void> saveManualStations(List<Map<String, dynamic>> stations) => _saveData(_manualStationsTable, 'station', stations); Future<void> upsertManualStations(List<Map<String, dynamic>> data) => _upsertData(_manualStationsTable, 'station_id', data, 'station');
Future<void> deleteManualStations(List<dynamic> ids) => _deleteData(_manualStationsTable, 'station_id', ids);
Future<List<Map<String, dynamic>>?> loadManualStations() => _loadData(_manualStationsTable, 'station'); Future<List<Map<String, dynamic>>?> loadManualStations() => _loadData(_manualStationsTable, 'station');
Future<void> saveRiverManualStations(List<Map<String, dynamic>> stations) => _saveData(_riverManualStationsTable, 'station', stations); Future<void> upsertRiverManualStations(List<Map<String, dynamic>> data) => _upsertData(_riverManualStationsTable, 'station_id', data, 'station');
Future<void> deleteRiverManualStations(List<dynamic> ids) => _deleteData(_riverManualStationsTable, 'station_id', ids);
Future<List<Map<String, dynamic>>?> loadRiverManualStations() => _loadData(_riverManualStationsTable, 'station'); Future<List<Map<String, dynamic>>?> loadRiverManualStations() => _loadData(_riverManualStationsTable, 'station');
Future<void> saveRiverTriennialStations(List<Map<String, dynamic>> stations) => _saveData(_riverTriennialStationsTable, 'station', stations); Future<void> upsertRiverTriennialStations(List<Map<String, dynamic>> data) => _upsertData(_riverTriennialStationsTable, 'station_id', data, 'station');
Future<void> deleteRiverTriennialStations(List<dynamic> ids) => _deleteData(_riverTriennialStationsTable, 'station_id', ids);
Future<List<Map<String, dynamic>>?> loadRiverTriennialStations() => _loadData(_riverTriennialStationsTable, 'station'); Future<List<Map<String, dynamic>>?> loadRiverTriennialStations() => _loadData(_riverTriennialStationsTable, 'station');
Future<void> saveTarballClassifications(List<Map<String, dynamic>> data) => _saveData(_tarballClassificationsTable, 'classification', data); Future<void> upsertTarballClassifications(List<Map<String, dynamic>> data) => _upsertData(_tarballClassificationsTable, 'classification_id', data, 'classification');
Future<void> deleteTarballClassifications(List<dynamic> ids) => _deleteData(_tarballClassificationsTable, 'classification_id', ids);
Future<List<Map<String, dynamic>>?> loadTarballClassifications() => _loadData(_tarballClassificationsTable, 'classification'); Future<List<Map<String, dynamic>>?> loadTarballClassifications() => _loadData(_tarballClassificationsTable, 'classification');
Future<void> saveDepartments(List<Map<String, dynamic>> data) => _saveData(_departmentsTable, 'department', data); Future<void> upsertDepartments(List<Map<String, dynamic>> data) => _upsertData(_departmentsTable, 'department_id', data, 'department');
Future<void> deleteDepartments(List<dynamic> ids) => _deleteData(_departmentsTable, 'department_id', ids);
Future<List<Map<String, dynamic>>?> loadDepartments() => _loadData(_departmentsTable, 'department'); Future<List<Map<String, dynamic>>?> loadDepartments() => _loadData(_departmentsTable, 'department');
Future<void> saveCompanies(List<Map<String, dynamic>> data) => _saveData(_companiesTable, 'company', data); Future<void> upsertCompanies(List<Map<String, dynamic>> data) => _upsertData(_companiesTable, 'company_id', data, 'company');
Future<void> deleteCompanies(List<dynamic> ids) => _deleteData(_companiesTable, 'company_id', ids);
Future<List<Map<String, dynamic>>?> loadCompanies() => _loadData(_companiesTable, 'company'); Future<List<Map<String, dynamic>>?> loadCompanies() => _loadData(_companiesTable, 'company');
Future<void> savePositions(List<Map<String, dynamic>> data) => _saveData(_positionsTable, 'position', data); Future<void> upsertPositions(List<Map<String, dynamic>> data) => _upsertData(_positionsTable, 'position_id', data, 'position');
Future<void> deletePositions(List<dynamic> ids) => _deleteData(_positionsTable, 'position_id', ids);
Future<List<Map<String, dynamic>>?> loadPositions() => _loadData(_positionsTable, 'position'); Future<List<Map<String, dynamic>>?> loadPositions() => _loadData(_positionsTable, 'position');
Future<void> saveAirManualStations(List<Map<String, dynamic>> stations) => _saveData(_airManualStationsTable, 'station', stations); Future<void> upsertAirManualStations(List<Map<String, dynamic>> data) => _upsertData(_airManualStationsTable, 'station_id', data, 'station');
Future<void> deleteAirManualStations(List<dynamic> ids) => _deleteData(_airManualStationsTable, 'station_id', ids);
Future<List<Map<String, dynamic>>?> loadAirManualStations() => _loadData(_airManualStationsTable, 'station'); Future<List<Map<String, dynamic>>?> loadAirManualStations() => _loadData(_airManualStationsTable, 'station');
Future<void> saveAirClients(List<Map<String, dynamic>> clients) => _saveData(_airClientsTable, 'client', clients); Future<void> upsertAirClients(List<Map<String, dynamic>> data) => _upsertData(_airClientsTable, 'client_id', data, 'client');
Future<void> deleteAirClients(List<dynamic> ids) => _deleteData(_airClientsTable, 'client_id', ids);
Future<List<Map<String, dynamic>>?> loadAirClients() => _loadData(_airClientsTable, 'client'); Future<List<Map<String, dynamic>>?> loadAirClients() => _loadData(_airClientsTable, 'client');
Future<void> saveStates(List<Map<String, dynamic>> states) => _saveData(_statesTable, 'state', states); Future<void> upsertStates(List<Map<String, dynamic>> data) => _upsertData(_statesTable, 'state_id', data, 'state');
Future<void> deleteStates(List<dynamic> ids) => _deleteData(_statesTable, 'state_id', ids);
Future<List<Map<String, dynamic>>?> loadStates() => _loadData(_statesTable, 'state'); Future<List<Map<String, dynamic>>?> loadStates() => _loadData(_statesTable, 'state');
Future<void> upsertAppSettings(List<Map<String, dynamic>> data) => _upsertData(_appSettingsTable, 'setting_id', data, 'setting');
Future<void> deleteAppSettings(List<dynamic> ids) => _deleteData(_appSettingsTable, 'setting_id', ids);
Future<List<Map<String, dynamic>>?> loadAppSettings() => _loadData(_appSettingsTable, 'setting');
Future<void> upsertParameterLimits(List<Map<String, dynamic>> data) => _upsertData(_parameterLimitsTable, 'param_autoid', data, 'limit');
Future<void> deleteParameterLimits(List<dynamic> ids) => _deleteData(_parameterLimitsTable, 'param_autoid', ids);
Future<List<Map<String, dynamic>>?> loadParameterLimits() => _loadData(_parameterLimitsTable, 'limit');
} }

View File

@ -142,11 +142,13 @@ class InSituSamplingService {
} }
// --- Data Submission --- // --- Data Submission ---
Future<Map<String, dynamic>> submitData(InSituSamplingData data) { // MODIFIED: Method now requires the appSettings list to pass to the MarineApiService.
Future<Map<String, dynamic>> submitData(InSituSamplingData data, List<Map<String, dynamic>>? appSettings) {
return _marineApiService.submitInSituSample( return _marineApiService.submitInSituSample(
formData: data.toApiFormData(), formData: data.toApiFormData(),
imageFiles: data.toApiImageFiles(), imageFiles: data.toApiImageFiles(),
inSituData: data, // Added this required parameter inSituData: data,
appSettings: appSettings, // Added this required parameter
); );
} }
} }

View File

@ -10,7 +10,8 @@ import 'package:environment_monitoring_app/models/tarball_data.dart';
class MarineApiService { class MarineApiService {
final BaseApiService _baseService = BaseApiService(); final BaseApiService _baseService = BaseApiService();
final TelegramService _telegramService = TelegramService(); final TelegramService _telegramService = TelegramService();
final SettingsService _settingsService = SettingsService(); // REMOVED: SettingsService is no longer called directly from this file for chat IDs.
// final SettingsService _settingsService = SettingsService();
Future<Map<String, dynamic>> getTarballStations() { Future<Map<String, dynamic>> getTarballStations() {
return _baseService.get('marine/tarball/stations'); return _baseService.get('marine/tarball/stations');
@ -24,9 +25,11 @@ class MarineApiService {
return _baseService.get('marine/tarball/classifications'); return _baseService.get('marine/tarball/classifications');
} }
// MODIFIED: Method now requires the appSettings list.
Future<Map<String, dynamic>> submitTarballSample({ Future<Map<String, dynamic>> submitTarballSample({
required Map<String, String> formData, required Map<String, String> formData,
required Map<String, File?> imageFiles, required Map<String, File?> imageFiles,
required List<Map<String, dynamic>>? appSettings,
}) async { }) async {
debugPrint("Step 1: Submitting tarball form data to the server..."); debugPrint("Step 1: Submitting tarball form data to the server...");
final dataResult = await _baseService.post('marine/tarball/sample', formData); final dataResult = await _baseService.post('marine/tarball/sample', formData);
@ -57,7 +60,7 @@ class MarineApiService {
}); });
if (filesToUpload.isEmpty) { if (filesToUpload.isEmpty) {
_handleTarballSuccessAlert(formData, isDataOnly: true); _handleTarballSuccessAlert(formData, appSettings, isDataOnly: true);
return { return {
'status': 'L3', 'status': 'L3',
'success': true, 'success': true,
@ -82,7 +85,7 @@ class MarineApiService {
}; };
} }
_handleTarballSuccessAlert(formData, isDataOnly: false); _handleTarballSuccessAlert(formData, appSettings, isDataOnly: false);
return { return {
'status': 'L3', 'status': 'L3',
'success': true, 'success': true,
@ -91,10 +94,12 @@ class MarineApiService {
}; };
} }
// MODIFIED: Method now requires the appSettings list.
Future<Map<String, dynamic>> submitInSituSample({ Future<Map<String, dynamic>> submitInSituSample({
required Map<String, String> formData, required Map<String, String> formData,
required Map<String, File?> imageFiles, required Map<String, File?> imageFiles,
required InSituSamplingData inSituData, required InSituSamplingData inSituData,
required List<Map<String, dynamic>>? appSettings,
}) async { }) async {
debugPrint("Step 1: Submitting in-situ form data to the server..."); debugPrint("Step 1: Submitting in-situ form data to the server...");
final dataResult = await _baseService.post('marine/manual/sample', formData); final dataResult = await _baseService.post('marine/manual/sample', formData);
@ -125,7 +130,7 @@ class MarineApiService {
}); });
if (filesToUpload.isEmpty) { if (filesToUpload.isEmpty) {
_handleInSituSuccessAlert(inSituData, isDataOnly: true); _handleInSituSuccessAlert(inSituData, appSettings, isDataOnly: true);
return { return {
'status': 'L3', 'status': 'L3',
'success': true, 'success': true,
@ -150,7 +155,7 @@ class MarineApiService {
}; };
} }
_handleInSituSuccessAlert(inSituData, isDataOnly: false); _handleInSituSuccessAlert(inSituData, appSettings, isDataOnly: false);
return { return {
'status': 'L3', 'status': 'L3',
'success': true, 'success': true,
@ -159,15 +164,13 @@ class MarineApiService {
}; };
} }
Future<void> _handleTarballSuccessAlert(Map<String, String> formData, {required bool isDataOnly}) async { // MODIFIED: Method now requires appSettings and calls the updated TelegramService.
Future<void> _handleTarballSuccessAlert(Map<String, String> formData, List<Map<String, dynamic>>? appSettings, {required bool isDataOnly}) async {
try { try {
final groupChatId = await _settingsService.getTarballChatId(); final message = _generateTarballAlertMessage(formData, isDataOnly: isDataOnly);
if (groupChatId.isNotEmpty) { final bool wasSent = await _telegramService.sendAlertImmediately('marine_tarball', message, appSettings);
final message = _generateTarballAlertMessage(formData, isDataOnly: isDataOnly); if (!wasSent) {
final bool wasSent = await _telegramService.sendAlertImmediately('marine_tarball', message); await _telegramService.queueMessage('marine_tarball', message, appSettings);
if (!wasSent) {
await _telegramService.queueMessage('marine_tarball', message);
}
} }
} catch (e) { } catch (e) {
debugPrint("Failed to handle Tarball Telegram alert: $e"); debugPrint("Failed to handle Tarball Telegram alert: $e");
@ -205,15 +208,13 @@ class MarineApiService {
return buffer.toString(); return buffer.toString();
} }
Future<void> _handleInSituSuccessAlert(InSituSamplingData data, {required bool isDataOnly}) async { // MODIFIED: Method now requires appSettings and calls the updated TelegramService.
Future<void> _handleInSituSuccessAlert(InSituSamplingData data, List<Map<String, dynamic>>? appSettings, {required bool isDataOnly}) async {
try { try {
final groupChatId = await _settingsService.getInSituChatId(); final message = data.generateTelegramAlertMessage(isDataOnly: isDataOnly);
if (groupChatId.isNotEmpty) { final bool wasSent = await _telegramService.sendAlertImmediately('marine_in_situ', message, appSettings);
final message = data.generateTelegramAlertMessage(isDataOnly: isDataOnly); if (!wasSent) {
final bool wasSent = await _telegramService.sendAlertImmediately('marine_in_situ', message); await _telegramService.queueMessage('marine_in_situ', message, appSettings);
if (!wasSent) {
await _telegramService.queueMessage('marine_in_situ', message);
}
} }
} catch (e) { } catch (e) {
debugPrint("Failed to handle In-Situ Telegram alert: $e"); debugPrint("Failed to handle In-Situ Telegram alert: $e");

View File

@ -6,12 +6,14 @@ import 'package:intl/intl.dart';
import 'package:environment_monitoring_app/services/base_api_service.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/services/telegram_service.dart';
import 'package:environment_monitoring_app/services/settings_service.dart'; // REMOVED: SettingsService is no longer needed in this file.
// import 'package:environment_monitoring_app/services/settings_service.dart';
class RiverApiService { class RiverApiService {
final BaseApiService _baseService = BaseApiService(); final BaseApiService _baseService = BaseApiService();
final TelegramService _telegramService = TelegramService(); final TelegramService _telegramService = TelegramService();
final SettingsService _settingsService = SettingsService(); // REMOVED: SettingsService instance is no longer needed.
// final SettingsService _settingsService = SettingsService();
Future<Map<String, dynamic>> getManualStations() { Future<Map<String, dynamic>> getManualStations() {
return _baseService.get('river/manual-stations'); return _baseService.get('river/manual-stations');
@ -21,9 +23,11 @@ class RiverApiService {
return _baseService.get('river/triennial-stations'); return _baseService.get('river/triennial-stations');
} }
// MODIFIED: Method now requires the appSettings list to pass to the alert handler.
Future<Map<String, dynamic>> submitInSituSample({ Future<Map<String, dynamic>> submitInSituSample({
required Map<String, String> formData, required Map<String, String> formData,
required Map<String, File?> imageFiles, required Map<String, File?> imageFiles,
required List<Map<String, dynamic>>? appSettings,
}) async { }) async {
// --- Step 1: Submit Form Data as JSON --- // --- Step 1: Submit Form Data as JSON ---
// The PHP backend for submitInSituSample expects JSON input. // The PHP backend for submitInSituSample expects JSON input.
@ -55,7 +59,7 @@ class RiverApiService {
}); });
if (filesToUpload.isEmpty) { if (filesToUpload.isEmpty) {
_handleInSituSuccessAlert(formData, isDataOnly: true); _handleInSituSuccessAlert(formData, appSettings, isDataOnly: true);
return { return {
'status': 'L3', 'status': 'L3',
'success': true, 'success': true,
@ -79,7 +83,7 @@ class RiverApiService {
}; };
} }
_handleInSituSuccessAlert(formData, isDataOnly: false); _handleInSituSuccessAlert(formData, appSettings, isDataOnly: false);
return { return {
'status': 'L3', 'status': 'L3',
'success': true, 'success': true,
@ -88,45 +92,44 @@ class RiverApiService {
}; };
} }
Future<void> _handleInSituSuccessAlert(Map<String, String> formData, {required bool isDataOnly}) async { // MODIFIED: Method now requires appSettings and calls the updated TelegramService.
Future<void> _handleInSituSuccessAlert(Map<String, String> formData, List<Map<String, dynamic>>? appSettings, {required bool isDataOnly}) async {
try { try {
final groupChatId = await _settingsService.getInSituChatId(); final submissionType = isDataOnly ? "(Data Only)" : "(Data & Images)";
if (groupChatId.isNotEmpty) { final stationName = formData['r_man_station_name'] ?? 'N/A';
final submissionType = isDataOnly ? "(Data Only)" : "(Data & Images)"; final stationCode = formData['r_man_station_code'] ?? 'N/A';
final stationName = formData['r_man_station_name'] ?? 'N/A'; final submissionDate = formData['r_man_date'] ?? DateFormat('yyyy-MM-dd').format(DateTime.now());
final stationCode = formData['r_man_station_code'] ?? 'N/A'; final submitter = formData['first_sampler_name'] ?? 'N/A';
final submissionDate = formData['r_man_date'] ?? DateFormat('yyyy-MM-dd').format(DateTime.now()); final sondeID = formData['r_man_sondeID'] ?? 'N/A';
final submitter = formData['first_sampler_name'] ?? 'N/A'; final distanceKm = double.tryParse(formData['r_man_distance_difference'] ?? '0') ?? 0;
final sondeID = formData['r_man_sondeID'] ?? 'N/A'; final distanceMeters = (distanceKm * 1000).toStringAsFixed(0);
final distanceKm = double.tryParse(formData['r_man_distance_difference'] ?? '0') ?? 0; final distanceRemarks = formData['r_man_distance_difference_remarks'] ?? 'N/A';
final distanceMeters = (distanceKm * 1000).toStringAsFixed(0);
final distanceRemarks = formData['r_man_distance_difference_remarks'] ?? 'N/A';
final buffer = StringBuffer() final buffer = StringBuffer()
..writeln('✅ *River In-Situ Sample ${submissionType} Submitted:*') ..writeln('✅ *River In-Situ Sample ${submissionType} Submitted:*')
..writeln()
..writeln('*Station Name & Code:* $stationName ($stationCode)')
..writeln('*Date of Submitted:* $submissionDate')
..writeln('*Submitted by User:* $submitter')
..writeln('*Sonde ID:* $sondeID')
..writeln('*Status of Submission:* Successful');
if (distanceKm > 0 || (distanceRemarks.isNotEmpty && distanceRemarks != 'N/A')) {
buffer
..writeln() ..writeln()
..writeln('*Station Name & Code:* $stationName ($stationCode)') ..writeln('🔔 *Alert:*')
..writeln('*Date of Submitted:* $submissionDate') ..writeln('*Distance from station:* $distanceMeters meters');
..writeln('*Submitted by User:* $submitter') if (distanceRemarks.isNotEmpty && distanceRemarks != 'N/A') {
..writeln('*Sonde ID:* $sondeID') buffer.writeln('*Remarks for distance:* $distanceRemarks');
..writeln('*Status of Submission:* Successful');
if (distanceKm > 0 || (distanceRemarks.isNotEmpty && distanceRemarks != 'N/A')) {
buffer
..writeln()
..writeln('🔔 *Alert:*')
..writeln('*Distance from station:* $distanceMeters meters');
if (distanceRemarks.isNotEmpty && distanceRemarks != 'N/A') {
buffer.writeln('*Remarks for distance:* $distanceRemarks');
}
} }
}
final String message = buffer.toString(); final String message = buffer.toString();
final bool wasSent = await _telegramService.sendAlertImmediately('river_in_situ', message); // MODIFIED: Pass the appSettings list to the TelegramService methods.
if (!wasSent) { final bool wasSent = await _telegramService.sendAlertImmediately('river_in_situ', message, appSettings);
await _telegramService.queueMessage('river_in_situ', message); if (!wasSent) {
} await _telegramService.queueMessage('river_in_situ', message, appSettings);
} }
} catch (e) { } catch (e) {
debugPrint("Failed to handle River Telegram alert: $e"); debugPrint("Failed to handle River Telegram alert: $e");

View File

@ -149,12 +149,12 @@ class RiverInSituSamplingService {
} }
// --- Data Submission --- // --- Data Submission ---
// CHANGED: Use the river-specific data model // MODIFIED: Method now requires the appSettings list to pass to the RiverApiService.
Future<Map<String, dynamic>> submitData(RiverInSituSamplingData data) { Future<Map<String, dynamic>> submitData(RiverInSituSamplingData data, List<Map<String, dynamic>>? appSettings) {
// CHANGED: Call the river-specific API service method
return _riverApiService.submitInSituSample( return _riverApiService.submitInSituSample(
formData: data.toApiFormData(), formData: data.toApiFormData(),
imageFiles: data.toApiImageFiles(), imageFiles: data.toApiImageFiles(),
appSettings: appSettings, // Added this required parameter
); );
} }
} }

View File

@ -1,99 +1,74 @@
import 'package:shared_preferences/shared_preferences.dart'; import 'package:flutter/foundation.dart';
import 'package:environment_monitoring_app/services/base_api_service.dart';
// No longer needs SharedPreferences or BaseApiService for its core logic.
class SettingsService { class SettingsService {
final BaseApiService _baseService = BaseApiService(); // The service no longer manages its own state or makes API calls.
// It is now a utility for parsing the settings list managed by AuthProvider.
// Keys for SharedPreferences /// A private helper method to find a specific setting value from the cached list.
static const _inSituChatIdKey = 'telegram_in_situ_chat_id'; String _getChatId(List<Map<String, dynamic>>? settings, String moduleName) {
static const _tarballChatIdKey = 'telegram_tarball_chat_id'; if (settings == null) {
static const _riverInSituChatIdKey = 'telegram_river_in_situ_chat_id'; debugPrint("SettingsService: Cannot get Chat ID for '$moduleName', settings list is null.");
static const _riverTriennialChatIdKey = 'telegram_river_triennial_chat_id'; return '';
static const _riverInvestigativeChatIdKey = 'telegram_river_investigative_chat_id'; }
static const _airManualChatIdKey = 'telegram_air_manual_chat_id';
static const _airInvestigativeChatIdKey = 'telegram_air_investigative_chat_id';
static const _marineInvestigativeChatIdKey = 'telegram_marine_investigative_chat_id';
/// Fetches settings from the server and saves them to local storage.
Future<bool> syncFromServer() async {
try { try {
final result = await _baseService.get('settings'); // Find the specific setting map where the module_name and setting_key match.
final setting = settings.firstWhere(
(s) => s['module_name'] == moduleName && s['setting_key'] == 'telegram_chat_id',
// If no matching setting is found, return an empty map to avoid errors.
orElse: () => {},
);
if (result['success'] == true && result['data'] is Map) { if (setting.isNotEmpty) {
final settings = result['data'] as Map<String, dynamic>; return setting['setting_value']?.toString() ?? '';
final prefs = await SharedPreferences.getInstance();
// Save all chat IDs from the nested maps
await Future.wait([
_saveChatId(prefs, _inSituChatIdKey, settings['marine_in_situ']),
_saveChatId(prefs, _tarballChatIdKey, settings['marine_tarball']),
_saveChatId(prefs, _riverInSituChatIdKey, settings['river_in_situ']),
_saveChatId(prefs, _riverTriennialChatIdKey, settings['river_triennial']),
_saveChatId(prefs, _riverInvestigativeChatIdKey, settings['river_investigative']),
_saveChatId(prefs, _airManualChatIdKey, settings['air_manual']),
_saveChatId(prefs, _airInvestigativeChatIdKey, settings['air_investigative']),
_saveChatId(prefs, _marineInvestigativeChatIdKey, settings['marine_investigative']),
]);
return true;
} }
return false;
} catch (e) { } catch (e) {
return false; debugPrint("SettingsService: Error parsing chat ID for '$moduleName': $e");
} }
debugPrint("SettingsService: Chat ID for module '$moduleName' not found.");
return '';
} }
Future<void> _saveChatId(SharedPreferences prefs, String key, dynamic settings) async { /// Gets the Chat ID for the Marine In-Situ module from the provided settings list.
if (settings is Map<String, dynamic>) { String getInSituChatId(List<Map<String, dynamic>>? settings) {
await prefs.setString(key, settings['telegram_chat_id']?.toString() ?? ''); return _getChatId(settings, 'marine_in_situ');
}
} }
/// Gets the locally stored Chat ID for the In-Situ module. /// Gets the Chat ID for the Tarball module from the provided settings list.
Future<String> getInSituChatId() async { String getTarballChatId(List<Map<String, dynamic>>? settings) {
final prefs = await SharedPreferences.getInstance(); return _getChatId(settings, 'marine_tarball');
return prefs.getString(_inSituChatIdKey) ?? '';
} }
/// Gets the locally stored Chat ID for the Tarball module. /// Gets the Chat ID for the River In-Situ module from the provided settings list.
Future<String> getTarballChatId() async { String getRiverInSituChatId(List<Map<String, dynamic>>? settings) {
final prefs = await SharedPreferences.getInstance(); return _getChatId(settings, 'river_in_situ');
return prefs.getString(_tarballChatIdKey) ?? '';
} }
/// Gets the locally stored Chat ID for the River In-Situ module. /// Gets the Chat ID for the River Triennial module from the provided settings list.
Future<String> getRiverInSituChatId() async { String getRiverTriennialChatId(List<Map<String, dynamic>>? settings) {
final prefs = await SharedPreferences.getInstance(); return _getChatId(settings, 'river_triennial');
return prefs.getString(_riverInSituChatIdKey) ?? '';
} }
/// Gets the locally stored Chat ID for the River Triennial module. /// Gets the Chat ID for the River Investigative module from the provided settings list.
Future<String> getRiverTriennialChatId() async { String getRiverInvestigativeChatId(List<Map<String, dynamic>>? settings) {
final prefs = await SharedPreferences.getInstance(); return _getChatId(settings, 'river_investigative');
return prefs.getString(_riverTriennialChatIdKey) ?? '';
} }
/// Gets the locally stored Chat ID for the River Investigative module. /// Gets the Chat ID for the Air Manual module from the provided settings list.
Future<String> getRiverInvestigativeChatId() async { String getAirManualChatId(List<Map<String, dynamic>>? settings) {
final prefs = await SharedPreferences.getInstance(); return _getChatId(settings, 'air_manual');
return prefs.getString(_riverInvestigativeChatIdKey) ?? '';
} }
/// Gets the locally stored Chat ID for the Air Manual module. /// Gets the Chat ID for the Air Investigative module from the provided settings list.
Future<String> getAirManualChatId() async { String getAirInvestigativeChatId(List<Map<String, dynamic>>? settings) {
final prefs = await SharedPreferences.getInstance(); return _getChatId(settings, 'air_investigative');
return prefs.getString(_airManualChatIdKey) ?? '';
} }
/// Gets the locally stored Chat ID for the Air Investigative module. /// Gets the Chat ID for the Marine Investigative module from the provided settings list.
Future<String> getAirInvestigativeChatId() async { String getMarineInvestigativeChatId(List<Map<String, dynamic>>? settings) {
final prefs = await SharedPreferences.getInstance(); return _getChatId(settings, 'marine_investigative');
return prefs.getString(_airInvestigativeChatIdKey) ?? '';
}
/// Gets the locally stored Chat ID for the Marine Investigative module.
Future<String> getMarineInvestigativeChatId() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getString(_marineInvestigativeChatIdKey) ?? '';
} }
} }

View File

@ -10,14 +10,15 @@ class TelegramService {
bool _isProcessing = false; bool _isProcessing = false;
Future<String> _getChatIdForModule(String module) async { // MODIFIED: This method is now synchronous and requires the appSettings list.
String _getChatIdForModule(String module, List<Map<String, dynamic>>? appSettings) {
switch (module) { switch (module) {
case 'marine_in_situ': case 'marine_in_situ':
return await _settingsService.getInSituChatId(); return _settingsService.getInSituChatId(appSettings);
case 'marine_tarball': case 'marine_tarball':
return await _settingsService.getTarballChatId(); return _settingsService.getTarballChatId(appSettings);
case 'air_manual': // ADDED THIS CASE case 'air_manual': // ADDED THIS CASE
return await _settingsService.getAirManualChatId(); return _settingsService.getAirManualChatId(appSettings);
default: default:
return ''; return '';
} }
@ -25,9 +26,10 @@ class TelegramService {
/// Tries to send an alert immediately over the network. /// Tries to send an alert immediately over the network.
/// Returns `true` on success, `false` on failure. /// Returns `true` on success, `false` on failure.
Future<bool> sendAlertImmediately(String module, String message) async { // MODIFIED: This method now requires the appSettings list to be passed in.
Future<bool> sendAlertImmediately(String module, String message, List<Map<String, dynamic>>? appSettings) async {
debugPrint("[TelegramService] Attempting to send alert immediately for module: $module"); debugPrint("[TelegramService] Attempting to send alert immediately for module: $module");
String chatId = await _getChatIdForModule(module); String chatId = _getChatIdForModule(module, appSettings);
if (chatId.isEmpty) { if (chatId.isEmpty) {
debugPrint("[TelegramService] ❌ Cannot send immediately. Chat ID for module '$module' is not configured."); debugPrint("[TelegramService] ❌ Cannot send immediately. Chat ID for module '$module' is not configured.");
@ -49,8 +51,9 @@ class TelegramService {
} }
/// Saves an alert to the local database queue. (This is now the fallback) /// Saves an alert to the local database queue. (This is now the fallback)
Future<void> queueMessage(String module, String message) async { // MODIFIED: This method now requires the appSettings list to be passed in.
String chatId = await _getChatIdForModule(module); Future<void> queueMessage(String module, String message, List<Map<String, dynamic>>? appSettings) async {
String chatId = _getChatIdForModule(module, appSettings);
if (chatId.isEmpty) { if (chatId.isEmpty) {
debugPrint("[TelegramService] ❌ ERROR: Cannot queue alert. Chat ID for module '$module' is not configured."); debugPrint("[TelegramService] ❌ ERROR: Cannot queue alert. Chat ID for module '$module' is not configured.");
@ -71,6 +74,7 @@ class TelegramService {
} }
/// Processes all pending alerts in the queue. /// Processes all pending alerts in the queue.
/// This method does NOT need changes because the chatId is already stored in the queue.
Future<void> processAlertQueue() async { Future<void> processAlertQueue() async {
if (_isProcessing) { if (_isProcessing) {
debugPrint("[TelegramService] ⏳ Queue is already being processed. Skipping."); debugPrint("[TelegramService] ⏳ Queue is already being processed. Skipping.");