repair api n ftp settings to be automatically selected during installation

This commit is contained in:
ALim Aidrus 2025-11-06 15:21:19 +08:00
parent 0a0c31b405
commit da1de869ed
15 changed files with 731 additions and 152 deletions

View File

@ -57,6 +57,9 @@ class AuthProvider with ChangeNotifier {
List<Map<String, dynamic>>? _tarballClassifications;
List<Map<String, dynamic>>? _riverManualStations;
List<Map<String, dynamic>>? _riverTriennialStations;
// --- ADDED: River Investigative Stations ---
List<Map<String, dynamic>>? _riverInvestigativeStations;
// --- END ADDED ---
List<Map<String, dynamic>>? _departments;
List<Map<String, dynamic>>? _companies;
List<Map<String, dynamic>>? _positions;
@ -79,6 +82,9 @@ class AuthProvider with ChangeNotifier {
List<Map<String, dynamic>>? get tarballClassifications => _tarballClassifications;
List<Map<String, dynamic>>? get riverManualStations => _riverManualStations;
List<Map<String, dynamic>>? get riverTriennialStations => _riverTriennialStations;
// --- ADDED: Getter for River Investigative Stations ---
List<Map<String, dynamic>>? get riverInvestigativeStations => _riverInvestigativeStations;
// --- END ADDED ---
List<Map<String, dynamic>>? get departments => _departments;
List<Map<String, dynamic>>? get companies => _companies;
List<Map<String, dynamic>>? get positions => _positions;
@ -141,17 +147,39 @@ class AuthProvider with ChangeNotifier {
}
}
// --- START MODIFIED: Load server config with is_active check ---
// Load server config early
final activeApiConfig = await _serverConfigService.getActiveApiConfig();
Map<String, dynamic>? activeApiConfig = await _serverConfigService.getActiveApiConfig();
if (activeApiConfig == null) {
debugPrint("AuthProvider: No active API config found. Setting default bootstrap URL.");
debugPrint("AuthProvider: No active config in SharedPreferences. Checking local DB for 'is_active' flag...");
// Load configs directly from DB helper (since cache isn't loaded yet)
final allApiConfigs = await _dbHelper.loadApiConfigs();
if (allApiConfigs != null && allApiConfigs.isNotEmpty) {
try {
// Find the first config marked as active. Note: 'is_active' might be 1 or true.
activeApiConfig = allApiConfigs.firstWhere(
(c) => c['is_active'] == 1 || c['is_active'] == true,
orElse: () => allApiConfigs.first, // Fallback to just the first config if none are active
);
debugPrint("AuthProvider: Found active config in DB: ${activeApiConfig['config_name']}");
await _serverConfigService.setActiveApiConfig(activeApiConfig);
} catch (e) {
debugPrint("AuthProvider: Error finding active config in DB. $e");
}
}
}
// If still no config (empty DB, first launch), set hardcoded default
if (activeApiConfig == null) {
debugPrint("AuthProvider: No active config found in DB. Setting default bootstrap URL.");
final initialConfig = {
'api_config_id': 0,
'config_name': 'Default Server',
'api_url': 'https://mms-apiv4.pstw.com.my/v1', // Use actual default if needed
'api_url': 'https://mms-apiv4.pstw.com.my/v1', // This is your bootstrap URL
};
await _serverConfigService.setActiveApiConfig(initialConfig);
}
// --- END MODIFIED ---
final lastSyncString = prefs.getString(lastSyncTimestampKey);
if (lastSyncString != null) {
@ -499,6 +527,9 @@ class AuthProvider with ChangeNotifier {
_tarballClassifications = await _dbHelper.loadTarballClassifications();
_riverManualStations = await _dbHelper.loadRiverManualStations();
_riverTriennialStations = await _dbHelper.loadRiverTriennialStations();
// --- MODIFIED: Load River Investigative Stations ---
_riverInvestigativeStations = await _dbHelper.loadRiverInvestigativeStations();
// --- END MODIFIED ---
_departments = await _dbHelper.loadDepartments();
_companies = await _dbHelper.loadCompanies();
_positions = await _dbHelper.loadPositions();
@ -528,7 +559,9 @@ class AuthProvider with ChangeNotifier {
// Now proceed with post-login actions that *don't* belong in the helper
debugPrint('AuthProvider: Login successful. Session and profile persisted.');
// --- THIS IS THE LINE THAT FIXES THE TICKING ---
await _userPreferencesService.applyAndSaveDefaultPreferencesIfNeeded();
// ---
// The main sync triggered by a direct user login
await syncAllData(forceRefresh: true);
// Notify listeners *after* sync is attempted (or throws)
@ -625,6 +658,9 @@ class AuthProvider with ChangeNotifier {
_tarballClassifications = null;
_riverManualStations = null;
_riverTriennialStations = null;
// --- MODIFIED: Clear River Investigative Stations ---
_riverInvestigativeStations = null;
// --- END MODIFIED ---
_departments = null;
_companies = null;
_positions = null;

View File

@ -29,7 +29,7 @@ class _HomePageState extends State<HomePage> {
});
},
),
title: const Text("MMS Version 3.7.01"),
title: const Text("MMS Version 3.8.01"),
actions: [
IconButton(
icon: const Icon(Icons.person),

View File

@ -205,13 +205,28 @@ class RootApp extends StatefulWidget {
}
class _RootAppState extends State<RootApp> {
// --- START: MODIFICATION FOR HOURLY SYNC ---
Timer? _configSyncTimer;
// --- END: MODIFICATION ---
@override
void initState() {
super.initState();
_initializeConnectivityListener();
_performInitialSessionCheck();
// --- START: MODIFICATION FOR HOURLY SYNC ---
_initializePeriodicSync(); // Start the hourly sync timer
// --- END: MODIFICATION ---
}
// --- START: MODIFICATION FOR HOURLY SYNC ---
@override
void dispose() {
_configSyncTimer?.cancel(); // Cancel the timer when the app closes
super.dispose();
}
// --- END: MODIFICATION ---
/// Initial check when app loads to see if we need to transition from offline to online.
void _performInitialSessionCheck() async {
// Wait a moment for providers to be fully available.
@ -259,6 +274,33 @@ class _RootAppState extends State<RootApp> {
});
}
// --- START: MODIFICATION FOR HOURLY SYNC ---
/// Initializes a recurring timer to sync data periodically.
void _initializePeriodicSync() {
// Start a timer for 1 hour (as requested). You can change this duration.
_configSyncTimer = Timer.periodic(const Duration(hours: 1), (timer) {
debugPrint("[Main] Periodic 1-hour sync triggered.");
// Use 'context.read' for a safe, one-time read inside a timer
if (mounted) {
final authProvider = context.read<AuthProvider>();
// Only sync if the user is logged in and the session isn't expired
if (authProvider.isLoggedIn && !authProvider.isSessionExpired) {
debugPrint("[Main] User is logged in. Starting periodic data sync...");
// Run syncAllData but don't block. Catch errors to prevent the timer from stopping.
authProvider.syncAllData().catchError((e) {
debugPrint("[Main] Error during periodic 1-hour sync: $e");
});
} else {
debugPrint("[Main] Skipping periodic sync: User not logged in or session expired.");
}
}
});
}
// --- END: MODIFICATION ---
@override
Widget build(BuildContext context) {
return Consumer<AuthProvider>(

View File

@ -147,13 +147,32 @@ class _LoginScreenState extends State<LoginScreen> {
),
),
const SizedBox(height: 32),
Center(
child: Image.asset(
'assets/icon_3_512x512.png',
height: 120,
width: 120,
// --- MODIFICATION START ---
// Replaced the original `Center(child: Image.asset(...))`
// with this `Container` to make the icon circular and add a shadow.
Container(
width: 130,
height: 130,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: Colors.white, // Background color in case icon has transparency
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.2), // Shadow color
spreadRadius: 2, // How far the shadow spreads
blurRadius: 8, // The blurriness of the shadow
offset: const Offset(0, 4), // Position of shadow (x, y)
),
],
image: const DecorationImage(
image: AssetImage('assets/icon_3_512x512.png'),
fit: BoxFit.cover, // Ensures the image fills the circle
),
),
),
// --- MODIFICATION END ---
const SizedBox(height: 48),
TextFormField(
controller: _emailController,

View File

@ -95,12 +95,29 @@ class _MarineInvesManualStep3DataCaptureState extends State<MarineInvesManualSte
super.dispose();
}
// --- START: MODIFIED LIFECYCLE METHOD ---
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.resumed) {
if (mounted) setState(() {});
if (mounted) {
// Use the member variable, not context
final service = _samplingService;
final btConnecting = service.bluetoothConnectionState.value == BluetoothConnectionState.connecting;
final serialConnecting = service.serialConnectionState.value == SerialConnectionState.connecting;
// If the widget's local state is loading OR the service's state is stuck connecting
if (_isLoading || btConnecting || serialConnecting) {
// Force-call disconnect to reset both the service's state
// and the local _isLoading flag (inside _disconnect).
_disconnectFromAll();
} else {
// If not stuck, just a normal refresh
setState(() {});
}
}
}
}
// --- END: MODIFIED LIFECYCLE METHOD ---
void _initializeControllers() {
widget.data.dataCaptureDate = widget.data.samplingDate;
@ -282,8 +299,10 @@ class _MarineInvesManualStep3DataCaptureState extends State<MarineInvesManualSte
});
}
// --- START: MODIFIED DISCONNECT METHOD ---
void _disconnect(String type) {
final service = context.read<MarineInvestigativeSamplingService>();
// Use the member variable, not context, for lifecycle safety
final service = _samplingService;
if (type == 'bluetooth') {
service.disconnectFromBluetooth();
} else {
@ -296,12 +315,16 @@ class _MarineInvesManualStep3DataCaptureState extends State<MarineInvesManualSte
setState(() {
_isAutoReading = false;
_isLockedOut = false;
_isLoading = false; // <-- CRITICAL: Also reset the loading flag
});
}
}
// --- END: MODIFIED DISCONNECT METHOD ---
// --- START: MODIFIED DISCONNECT_ALL METHOD ---
void _disconnectFromAll() {
final service = context.read<MarineInvestigativeSamplingService>();
// Use the member variable, not context, for lifecycle safety
final service = _samplingService;
if (service.bluetoothConnectionState.value != BluetoothConnectionState.disconnected) {
_disconnect('bluetooth');
}
@ -309,6 +332,7 @@ class _MarineInvesManualStep3DataCaptureState extends State<MarineInvesManualSte
_disconnect('serial');
}
}
// --- END: MODIFIED DISCONNECT_ALL METHOD ---
void _updateTextFields(Map<String, double> readings) {
const defaultValue = -999.0;

View File

@ -7,6 +7,37 @@ import 'package:image_picker/image_picker.dart';
import '../../../auth_provider.dart';
import '../../../services/api_service.dart';
// --- FIX: Import the new dedicated service ---
import '../../../services/marine_api_service.dart';
// --- FIX: Define image key lists ---
const List<String> _inSituImageKeys = [
'man_left_side_land_view_path',
'man_right_side_land_view_path',
'man_filling_water_into_sample_bottle_path',
'man_seawater_in_clear_glass_bottle_path',
'man_examine_preservative_ph_paper_path',
'man_optional_photo_01_path',
'man_optional_photo_02_path',
'man_optional_photo_03_path',
'man_optional_photo_04_path',
];
// --- START: MODIFIED TO FIX 0 IMAGE URLS ---
// These keys are now an exact match for the keys in your server log
const List<String> _tarballImageKeys = [
'left_side_coastal_view',
'right_side_coastal_view',
'drawing_vertical_lines',
'drawing_horizontal_line',
'optional_photo_01',
'optional_photo_02',
'optional_photo_03',
'optional_photo_04',
];
// --- END: MODIFIED TO FIX 0 IMAGE URLS ---
class MarineImageRequestScreen extends StatefulWidget {
const MarineImageRequestScreen({super.key});
@ -19,8 +50,9 @@ class _MarineImageRequestScreenState extends State<MarineImageRequestScreen> {
final _formKey = GlobalKey<FormState>();
final _dateController = TextEditingController();
String? _selectedSamplingType = 'All Manual Sampling';
final List<String> _samplingTypes = ['All Manual Sampling', 'In-Situ Sampling', 'Tarball Sampling'];
// --- FIX: Default to a specific type, not 'All' ---
String? _selectedSamplingType = 'In-Situ Sampling';
final List<String> _samplingTypes = ['In-Situ Sampling', 'Tarball Sampling', 'All Manual Sampling'];
String? _selectedStateName;
String? _selectedCategoryName;
@ -38,7 +70,12 @@ class _MarineImageRequestScreenState extends State<MarineImageRequestScreen> {
@override
void initState() {
super.initState();
_initializeStationFilters();
// --- FIX: Use addPostFrameCallback to ensure provider is ready ---
WidgetsBinding.instance.addPostFrameCallback((_) {
if(mounted) {
_initializeStationFilters();
}
});
}
@override
@ -47,14 +84,85 @@ class _MarineImageRequestScreenState extends State<MarineImageRequestScreen> {
super.dispose();
}
// --- FIX: New function to get the correct station list from AuthProvider ---
List<Map<String, dynamic>> _getStationsForType(AuthProvider auth) {
switch (_selectedSamplingType) {
case 'In-Situ Sampling':
return auth.manualStations ?? [];
case 'Tarball Sampling':
return auth.tarballStations ?? [];
case 'All Manual Sampling':
// 'All' is complex. Defaulting to manual stations as a fallback.
return auth.manualStations ?? [];
default:
return [];
}
}
// --- FIX: New function to get the correct station ID key ---
String _getStationIdKey() {
switch (_selectedSamplingType) {
case 'In-Situ Sampling':
return 'station_id'; // from manualStations
case 'Tarball Sampling':
return 'station_id'; // from tarballStations (assuming 'station_id')
default:
return 'station_id';
}
}
// --- FIX: New function to get the correct station code key ---
String _getStationCodeKey() {
switch (_selectedSamplingType) {
case 'In-Situ Sampling':
return 'man_station_code';
case 'Tarball Sampling':
return 'tbl_station_code'; // Please verify this key
default:
return 'man_station_code';
}
}
// --- FIX: New function to get the correct station name key ---
String _getStationNameKey() {
switch (_selectedSamplingType) {
case 'In-Situ Sampling':
return 'man_station_name';
case 'Tarball Sampling':
return 'tbl_station_name'; // Please verify this key
default:
return 'man_station_name';
}
}
// --- FIX: Function modified to be dynamic ---
void _initializeStationFilters() {
final auth = Provider.of<AuthProvider>(context, listen: false);
final allStations = auth.manualStations ?? [];
// --- FIX: Use helper to get dynamic station list ---
final allStations = _getStationsForType(auth);
if (allStations.isNotEmpty) {
// Assuming 'state_name' exists on all station types
final states = allStations.map((s) => s['state_name'] as String?).whereType<String>().toSet().toList();
states.sort();
setState(() {
_statesList = states;
// --- FIX: Reset dependent fields on change ---
_selectedStateName = null;
_selectedCategoryName = null;
_selectedStation = null;
_categoriesForState = [];
_stationsForCategory = [];
});
} else {
// --- FIX: Handle empty list ---
setState(() {
_statesList = [];
_selectedStateName = null;
_selectedCategoryName = null;
_selectedStation = null;
_categoriesForState = [];
_stationsForCategory = [];
});
}
}
@ -102,7 +210,10 @@ class _MarineImageRequestScreenState extends State<MarineImageRequestScreen> {
return;
}
final stationId = _selectedStation!['station_id'];
// --- FIX: Get the correct station ID key ---
final stationIdKey = _getStationIdKey();
final stationId = _selectedStation![stationIdKey];
if (stationId == null) {
debugPrint("[Image Request] ERROR: Station ID is null.");
if (mounted) {
@ -121,33 +232,47 @@ class _MarineImageRequestScreenState extends State<MarineImageRequestScreen> {
try {
debugPrint("[Image Request] All checks passed. Calling API with Station ID: $stationId, Type: $_selectedSamplingType");
// This call is correct, as apiService.marine holds the MarineApiService
final result = await apiService.marine.getManualSamplingImages(
stationId: stationId,
stationId: stationId as int, // Ensure it's passed as int
samplingDate: _selectedDate!,
samplingType: _selectedSamplingType!,
samplingType: _selectedSamplingType!, // This is now used by the API service
);
if (mounted && result['success'] == true) {
// --- FIX: The data key from your new log is just 'data' ---
final List<Map<String, dynamic>> records = List<Map<String, dynamic>>.from(result['data'] ?? []);
final List<String> fetchedUrls = [];
const imageKeys = [
'man_left_side_land_view_path',
'man_right_side_land_view_path',
'man_filling_water_into_sample_bottle_path',
'man_seawater_in_clear_glass_bottle_path',
'man_examine_preservative_ph_paper_path',
'man_optional_photo_01_path',
'man_optional_photo_02_path',
'man_optional_photo_03_path',
'man_optional_photo_04_path',
];
// --- FIX: Determine which set of keys to use ---
List<String> imageKeys;
switch (_selectedSamplingType) {
case 'In-Situ Sampling':
imageKeys = _inSituImageKeys;
break;
case 'Tarball Sampling':
imageKeys = _tarballImageKeys; // Now uses the corrected list
break;
case 'All Manual Sampling':
default:
// Default to In-Situ keys as a fallback
imageKeys = _inSituImageKeys;
break;
}
// If 'All Manual Sampling' is selected, you could merge keys:
// imageKeys = [..._inSituImageKeys, ..._tarballImageKeys];
for (final record in records) {
for (final key in imageKeys) {
if (record[key] != null && (record[key] as String).isNotEmpty) {
final String imagePathFromServer = record[key];
final fullUrl = ApiService.imageBaseUrl + imagePathFromServer;
// --- FIX: Make URL construction robust ---
final fullUrl = imagePathFromServer.startsWith('http')
? imagePathFromServer
: ApiService.imageBaseUrl + imagePathFromServer;
fetchedUrls.add(fullUrl);
debugPrint("[Image Request] Found and constructed URL: $fullUrl");
}
@ -155,7 +280,8 @@ class _MarineImageRequestScreenState extends State<MarineImageRequestScreen> {
}
setState(() {
_imageUrls = fetchedUrls;
// --- FIX: Use toSet() to remove potential duplicates ---
_imageUrls = fetchedUrls.toSet().toList();
});
debugPrint("[Image Request] Successfully processed and constructed ${_imageUrls.length} image URLs.");
} else if (mounted) {
@ -261,8 +387,9 @@ class _MarineImageRequestScreenState extends State<MarineImageRequestScreen> {
try {
debugPrint("[Image Request] Sending email request to server for recipient: $toEmail");
final stationCode = _selectedStation?['man_station_code'] ?? 'N/A';
final stationName = _selectedStation?['man_station_name'] ?? 'N/A';
// --- FIX: Use dynamic keys ---
final stationCode = _selectedStation?[_getStationCodeKey()] ?? 'N/A';
final stationName = _selectedStation?[_getStationNameKey()] ?? 'N/A';
final fullStationIdentifier = '$stationCode - $stationName';
final result = await apiService.marine.sendImageRequestEmail(
@ -318,7 +445,11 @@ class _MarineImageRequestScreenState extends State<MarineImageRequestScreen> {
DropdownButtonFormField<String>(
value: _selectedSamplingType,
items: _samplingTypes.map((type) => DropdownMenuItem(value: type, child: Text(type))).toList(),
onChanged: (value) => setState(() => _selectedSamplingType = value),
onChanged: (value) => setState(() {
_selectedSamplingType = value;
// --- FIX: Re-initialize filters when type changes ---
_initializeStationFilters();
}),
decoration: const InputDecoration(labelText: 'Sampling Type *', border: OutlineInputBorder()),
validator: (value) => value == null ? 'Please select a type' : null,
),
@ -334,7 +465,8 @@ class _MarineImageRequestScreenState extends State<MarineImageRequestScreen> {
_selectedCategoryName = null;
_selectedStation = null;
final auth = Provider.of<AuthProvider>(context, listen: false);
final allStations = auth.manualStations ?? [];
// --- FIX: Use helper to get dynamic station list ---
final allStations = _getStationsForType(auth);
final categories = state != null ? allStations.where((s) => s['state_name'] == state).map((s) => s['category_name'] as String?).whereType<String>().toSet().toList() : <String>[];
categories.sort();
_categoriesForState = categories;
@ -355,8 +487,11 @@ class _MarineImageRequestScreenState extends State<MarineImageRequestScreen> {
_selectedCategoryName = category;
_selectedStation = null;
final auth = Provider.of<AuthProvider>(context, listen: false);
final allStations = auth.manualStations ?? [];
_stationsForCategory = category != null ? (allStations.where((s) => s['state_name'] == _selectedStateName && s['category_name'] == category).toList()..sort((a, b) => (a['man_station_code'] ?? '').compareTo(b['man_station_code'] ?? ''))) : [];
// --- FIX: Use helper to get dynamic station list ---
final allStations = _getStationsForType(auth);
// --- FIX: Use dynamic key for sorting ---
final stationCodeKey = _getStationCodeKey();
_stationsForCategory = category != null ? (allStations.where((s) => s['state_name'] == _selectedStateName && s['category_name'] == category).toList()..sort((a, b) => (a[stationCodeKey] ?? '').compareTo(b[stationCodeKey] ?? ''))) : [];
});
},
validator: (val) => _selectedStateName != null && val == null ? "Category is required" : null,
@ -366,7 +501,12 @@ class _MarineImageRequestScreenState extends State<MarineImageRequestScreen> {
items: _stationsForCategory,
selectedItem: _selectedStation,
enabled: _selectedCategoryName != null,
itemAsString: (station) => "${station['man_station_code']} - ${station['man_station_name']}",
// --- FIX: Use dynamic keys for display string ---
itemAsString: (station) {
final code = station[_getStationCodeKey()] ?? 'N/A';
final name = station[_getStationNameKey()] ?? 'N/A';
return "$code - $name";
},
popupProps: const PopupProps.menu(showSearchBox: true, searchFieldProps: TextFieldProps(decoration: InputDecoration(hintText: "Search Station..."))),
dropdownDecoratorProps: const DropDownDecoratorProps(dropdownSearchDecoration: InputDecoration(labelText: "Select Station *", border: OutlineInputBorder())),
onChanged: (station) => setState(() => _selectedStation = station),

View File

@ -302,7 +302,7 @@ class _TarballSamplingStep3SummaryState extends State<TarballSamplingStep3Summar
if (remark != null && remark.isNotEmpty)
Padding(
padding: const EdgeInsets.only(top: 8.0),
child: Text('Remark: $remark', style: const TextStyle(fontStyle: FontStyle.italic, color: Colors.black54)),
child: Text('Remark: $remark', style: const TextStyle(fontStyle: FontStyle.italic, color: Colors.white70)),
),
],
),

View File

@ -107,14 +107,29 @@ class _InSituStep3DataCaptureState extends State<InSituStep3DataCapture> with Wi
super.dispose();
}
// --- START: MODIFIED LIFECYCLE METHOD ---
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.resumed) {
if (mounted) {
setState(() {});
// Use the member variable, not context
final service = _samplingService;
final btConnecting = service.bluetoothConnectionState.value == BluetoothConnectionState.connecting;
final serialConnecting = service.serialConnectionState.value == SerialConnectionState.connecting;
// If the widget's local state is loading OR the service's state is stuck connecting
if (_isLoading || btConnecting || serialConnecting) {
// Force-call disconnect to reset both the service's state
// and the local _isLoading flag (inside _disconnect).
_disconnectFromAll();
} else {
// If not stuck, just a normal refresh
setState(() {});
}
}
}
}
// --- END: MODIFIED LIFECYCLE METHOD ---
void _initializeControllers() {
widget.data.dataCaptureDate = widget.data.samplingDate;
@ -298,8 +313,10 @@ class _InSituStep3DataCaptureState extends State<InSituStep3DataCapture> with Wi
});
}
// --- START: MODIFIED DISCONNECT METHOD ---
void _disconnect(String type) {
final service = context.read<MarineInSituSamplingService>();
// Use the member variable, not context, for lifecycle safety
final service = _samplingService;
if (type == 'bluetooth') {
service.disconnectFromBluetooth();
} else {
@ -312,12 +329,16 @@ class _InSituStep3DataCaptureState extends State<InSituStep3DataCapture> with Wi
setState(() {
_isAutoReading = false;
_isLockedOut = false; // --- MODIFICATION: Reset lockout state ---
_isLoading = false; // <-- CRITICAL: Also reset the loading flag
});
}
}
// --- END: MODIFIED DISCONNECT METHOD ---
// --- START: MODIFIED DISCONNECT_ALL METHOD ---
void _disconnectFromAll() {
final service = context.read<MarineInSituSamplingService>();
// Use the member variable, not context, for lifecycle safety
final service = _samplingService;
if (service.bluetoothConnectionState.value != BluetoothConnectionState.disconnected) {
_disconnect('bluetooth');
}
@ -325,6 +346,7 @@ class _InSituStep3DataCaptureState extends State<InSituStep3DataCapture> with Wi
_disconnect('serial');
}
}
// --- END: MODIFIED DISCONNECT_ALL METHOD ---
void _updateTextFields(Map<String, double> readings) {
const defaultValue = -999.0;

View File

@ -114,7 +114,21 @@ class _RiverInvesStep3DataCaptureState extends State<RiverInvesStep3DataCapture>
void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.resumed) {
if (mounted) {
setState(() {}); // Refresh UI, e.g., to re-check connection state
// --- START MODIFICATION ---
final service = _samplingService;
final btConnecting = service.bluetoothConnectionState.value == BluetoothConnectionState.connecting;
final serialConnecting = service.serialConnectionState.value == SerialConnectionState.connecting;
// If the widget's local state is loading OR the service's state is stuck connecting
if (_isLoading || btConnecting || serialConnecting) {
// Force-call disconnect to reset both the service's state
// and the local _isLoading flag (inside _disconnect).
_disconnectFromAll();
} else {
// If not stuck, just a normal refresh
setState(() {});
}
// --- END MODIFICATION ---
}
}
}
@ -393,10 +407,13 @@ class _RiverInvesStep3DataCaptureState extends State<RiverInvesStep3DataCapture>
void _disconnect(String type) {
// Logic copied from RiverInSituStep3DataCaptureState._disconnect
// Uses the correct _samplingService instance
// --- START MODIFICATION ---
final service = _samplingService; // NEW: Use the member variable
// --- END MODIFICATION ---
if (type == 'bluetooth') {
_samplingService.disconnectFromBluetooth();
service.disconnectFromBluetooth();
} else {
_samplingService.disconnectFromSerial();
service.disconnectFromSerial();
}
_dataSubscription?.cancel();
_dataSubscription = null;
@ -405,16 +422,20 @@ class _RiverInvesStep3DataCaptureState extends State<RiverInvesStep3DataCapture>
setState(() {
_isAutoReading = false;
_isLockedOut = false; // Reset lockout state
_isLoading = false; // --- NEW: Also reset the loading flag ---
});
}
}
void _disconnectFromAll() {
// Logic copied from RiverInSituStep3DataCaptureState._disconnectFromAll
if (_samplingService.bluetoothConnectionState.value != BluetoothConnectionState.disconnected) {
// --- START MODIFICATION ---
final service = _samplingService; // NEW: Use the member variable
// --- END MODIFICATION ---
if (service.bluetoothConnectionState.value != BluetoothConnectionState.disconnected) {
_disconnect('bluetooth');
}
if (_samplingService.serialConnectionState.value != SerialConnectionState.disconnected) {
if (service.serialConnectionState.value != SerialConnectionState.disconnected) {
_disconnect('serial');
}
}

View File

@ -120,7 +120,21 @@ class _RiverManualTriennialStep3DataCaptureState extends State<RiverManualTrienn
void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.resumed) {
if (mounted) {
setState(() {});
// --- START MODIFICATION ---
final service = _samplingService;
final btConnecting = service.bluetoothConnectionState.value == BluetoothConnectionState.connecting;
final serialConnecting = service.serialConnectionState.value == SerialConnectionState.connecting;
// If the widget's local state is loading OR the service's state is stuck connecting
if (_isLoading || btConnecting || serialConnecting) {
// Force-call disconnect to reset both the service's state
// and the local _isLoading flag (inside _disconnect).
_disconnectFromAll();
} else {
// If not stuck, just a normal refresh
setState(() {});
}
// --- END MODIFICATION ---
}
}
}
@ -347,7 +361,9 @@ class _RiverManualTriennialStep3DataCaptureState extends State<RiverManualTrienn
}
void _disconnect(String type) {
final service = context.read<RiverInSituSamplingService>();
// --- START MODIFICATION ---
final service = _samplingService; // NEW: Use the member variable
// --- END MODIFICATION ---
if (type == 'bluetooth') {
service.disconnectFromBluetooth();
} else {
@ -360,12 +376,15 @@ class _RiverManualTriennialStep3DataCaptureState extends State<RiverManualTrienn
setState(() {
_isAutoReading = false;
_isLockedOut = false; // --- MODIFICATION: Reset lockout state ---
_isLoading = false; // --- NEW: Also reset the loading flag ---
});
}
}
void _disconnectFromAll() {
final service = context.read<RiverInSituSamplingService>();
// --- START MODIFICATION ---
final service = _samplingService; // NEW: Use the member variable
// --- END MODIFICATION ---
if (service.bluetoothConnectionState.value != BluetoothConnectionState.disconnected) {
_disconnect('bluetooth');
}
@ -519,6 +538,8 @@ class _RiverManualTriennialStep3DataCaptureState extends State<RiverManualTrienn
widget.onNext();
}
void _showSnackBar(String message, {bool isError = false}) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(

View File

@ -120,7 +120,21 @@ class _RiverInSituStep3DataCaptureState extends State<RiverInSituStep3DataCaptur
void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.resumed) {
if (mounted) {
setState(() {});
// --- START MODIFICATION ---
final service = _samplingService;
final btConnecting = service.bluetoothConnectionState.value == BluetoothConnectionState.connecting;
final serialConnecting = service.serialConnectionState.value == SerialConnectionState.connecting;
// If the widget's local state is loading OR the service's state is stuck connecting
if (_isLoading || btConnecting || serialConnecting) {
// Force-call disconnect to reset both the service's state
// and the local _isLoading flag (inside _disconnect).
_disconnectFromAll();
} else {
// If not stuck, just a normal refresh
setState(() {});
}
// --- END MODIFICATION ---
}
}
}
@ -347,7 +361,9 @@ class _RiverInSituStep3DataCaptureState extends State<RiverInSituStep3DataCaptur
}
void _disconnect(String type) {
final service = context.read<RiverInSituSamplingService>();
// --- START MODIFICATION ---
final service = _samplingService; // NEW: Use the member variable
// --- END MODIFICATION ---
if (type == 'bluetooth') {
service.disconnectFromBluetooth();
} else {
@ -360,12 +376,15 @@ class _RiverInSituStep3DataCaptureState extends State<RiverInSituStep3DataCaptur
setState(() {
_isAutoReading = false;
_isLockedOut = false; // --- MODIFICATION: Reset lockout state ---
_isLoading = false; // --- NEW: Also reset the loading flag ---
});
}
}
void _disconnectFromAll() {
final service = context.read<RiverInSituSamplingService>();
// --- START MODIFICATION ---
final service = _samplingService; // NEW: Use the member variable
// --- END MODIFICATION ---
if (service.bluetoothConnectionState.value != BluetoothConnectionState.disconnected) {
_disconnect('bluetooth');
}

View File

@ -41,10 +41,14 @@ class _SettingsScreenState extends State<SettingsScreen> {
final Map<String, _ModuleSettings> _moduleSettings = {};
// This list seems correct based on your file
final List<Map<String, String>> _configurableModules = [
{'key': 'marine_tarball', 'name': 'Marine Tarball'},
{'key': 'marine_in_situ', 'name': 'Marine In-Situ'},
{'key': 'marine_investigative', 'name': 'Marine Investigative'},
{'key': 'river_in_situ', 'name': 'River In-Situ'},
{'key': 'river_triennial', 'name': 'River Triennial'},
{'key': 'river_investigative', 'name': 'River Investigative'},
{'key': 'air_installation', 'name': 'Air Installation'},
{'key': 'air_collection', 'name': 'Air Collection'},
];
@ -70,9 +74,16 @@ class _SettingsScreenState extends State<SettingsScreen> {
String _airLimitsSearchQuery = '';
final TextEditingController _riverLimitsSearchController = TextEditingController();
String _riverLimitsSearchQuery = '';
// --- MODIFICATION: Added missing controller ---
final TextEditingController _marineLimitsSearchController = TextEditingController();
// --- END MODIFICATION ---
String _marineLimitsSearchQuery = '';
// --- START: PAGINATION STATE FOR MARINE LIMITS ---
int _marineLimitsCurrentPage = 1;
final int _marineLimitsItemsPerPage = 15;
// --- END: PAGINATION STATE FOR MARINE LIMITS ---
@override
void initState() {
super.initState();
@ -88,7 +99,13 @@ class _SettingsScreenState extends State<SettingsScreen> {
_npeMarineLimitsSearchController.addListener(() => setState(() => _npeMarineLimitsSearchQuery = _npeMarineLimitsSearchController.text));
_airLimitsSearchController.addListener(() => setState(() => _airLimitsSearchQuery = _airLimitsSearchController.text));
_riverLimitsSearchController.addListener(() => setState(() => _riverLimitsSearchQuery = _riverLimitsSearchController.text));
_marineLimitsSearchController.addListener(() => setState(() => _marineLimitsSearchQuery = _marineLimitsSearchController.text));
_marineLimitsSearchController.addListener(() {
setState(() {
_marineLimitsSearchQuery = _marineLimitsSearchController.text;
_marineLimitsCurrentPage = 1; // Reset to page 1 when search query changes
});
});
}
@override
@ -566,7 +583,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
labelText: 'Search Marine Limits',
hintText: 'Search by parameter or station',
),
_buildInfoList(filteredMarineLimits, (item) => _buildParameterLimitEntry(item, stations: allManualStations)),
_buildPaginatedMarineLimitsList(filteredMarineLimits, allManualStations),
],
),
),
@ -748,7 +765,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
ListTile(
leading: const Icon(Icons.info_outline),
title: const Text('App Version'),
subtitle: const Text('MMS Version 3.7.01'),
subtitle: const Text('MMS Version 3.8.01'),
dense: true,
),
ListTile(
@ -791,6 +808,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
);
}
// --- START MODIFICATION: This widget is updated to show ftp_module ---
Widget _buildDestinationList(String title, List<Map<String, dynamic>> configs, String idKey) {
if (configs.isEmpty) {
return const ListTile(
@ -808,9 +826,19 @@ class _SettingsScreenState extends State<SettingsScreen> {
child: Text(title, style: Theme.of(context).textTheme.titleMedium),
),
...configs.map((config) {
// Check if this is an FTP config by looking for the 'ftp_module' key
bool isFtp = config.containsKey('ftp_module');
String subtitleText;
if (isFtp) {
subtitleText = 'Module: ${config['ftp_module'] ?? 'N/A'} | Host: ${config['ftp_host'] ?? 'N/A'}';
} else {
subtitleText = config['api_url'] ?? 'No URL';
}
return CheckboxListTile(
title: Text(config['config_name'] ?? 'Unnamed'),
subtitle: Text(config['api_url'] ?? config['ftp_host'] ?? 'No URL/Host'),
subtitle: Text(subtitleText, style: const TextStyle(fontSize: 12)),
value: config['is_enabled'] ?? false,
onChanged: (bool? value) {
setState(() {
@ -824,6 +852,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
),
);
}
// --- END MODIFICATION ---
Widget _buildSectionHeader(BuildContext context, String title) {
return Padding(
@ -864,6 +893,91 @@ class _SettingsScreenState extends State<SettingsScreen> {
);
}
Widget _buildPaginatedMarineLimitsList(
List<Map<String, dynamic>>? filteredList,
List<Map<String, dynamic>>? allStations,
) {
// 1. Handle null or empty list after filtering
if (filteredList == null || filteredList.isEmpty) {
// Check if the original list (pre-filter) was also empty
final originalList = context.read<AuthProvider>().marineParameterLimits;
String message = 'No matching parameter limits found.';
if (originalList == null || originalList.isEmpty) {
message = 'No data available. Sync to download.';
}
return ListTile(
title: Text(message, textAlign: TextAlign.center),
dense: true,
);
}
// 2. Pagination Calculations
final totalItems = filteredList.length;
final totalPages = (totalItems / _marineLimitsItemsPerPage).ceil();
if (totalPages > 0 && _marineLimitsCurrentPage > totalPages) {
_marineLimitsCurrentPage = totalPages;
}
// 3. Get items for the current page
final startIndex = (_marineLimitsCurrentPage - 1) * _marineLimitsItemsPerPage;
final endIndex = (startIndex + _marineLimitsItemsPerPage > totalItems)
? totalItems
: startIndex + _marineLimitsItemsPerPage;
final paginatedItems = filteredList.sublist(startIndex, endIndex);
// 4. Build the UI
return Column(
children: [
// 4.1. The list of items for the current page
Column(
children: paginatedItems
.map((item) => _buildParameterLimitEntry(item, stations: allStations))
.toList(),
),
// 4.2. The pagination controls (only show if more than one page)
if (totalPages > 1) ...[
const Divider(),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
IconButton(
icon: const Icon(Icons.arrow_back_ios),
iconSize: 18.0,
tooltip: 'Previous Page',
onPressed: _marineLimitsCurrentPage > 1
? () {
setState(() {
_marineLimitsCurrentPage--;
});
}
: null, // Disable button if on first page
),
Text(
'Page $_marineLimitsCurrentPage of $totalPages',
style: Theme.of(context).textTheme.bodySmall,
),
IconButton(
icon: const Icon(Icons.arrow_forward_ios),
iconSize: 18.0,
tooltip: 'Next Page',
onPressed: _marineLimitsCurrentPage < totalPages
? () {
setState(() {
_marineLimitsCurrentPage++;
});
}
: null, // Disable button if on last page
),
],
),
]
],
);
}
Widget _buildChatIdEntry(String label, String value) {
return ListTile(
contentPadding: EdgeInsets.zero,
@ -905,11 +1019,9 @@ class _SettingsScreenState extends State<SettingsScreen> {
final stationId = item['station_id'];
final station = stations.firstWhere((s) => s['station_id'] == stationId, orElse: () => {});
if (station.isNotEmpty) {
// --- START: MODIFICATION ---
final stationCode = station['man_station_code'] ?? 'N/A';
final stationName = station['man_station_name'] ?? 'N/A';
contextSubtitle = 'Station: $stationCode - $stationName';
// --- END: MODIFICATION ---
}
}
@ -983,6 +1095,9 @@ class _SettingsScreenState extends State<SettingsScreen> {
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// --- MODIFICATION: Added ftp_module display ---
Text('Module: ${item['ftp_module']?.toString() ?? 'N/A'}', style: const TextStyle(fontSize: 12, fontWeight: FontWeight.bold)),
// ---
Text('Host: ${item['ftp_host']?.toString() ?? 'N/A'}', style: const TextStyle(fontSize: 12)),
Text('User: ${item['ftp_user']?.toString() ?? 'N/A'}', style: const TextStyle(fontSize: 12)),
Text('Pass: ${item['ftp_pass']?.toString() ?? 'N/A'}', style: const TextStyle(fontSize: 12)),

View File

@ -226,6 +226,16 @@ class ApiService {
await dbHelper.deleteRiverTriennialStations(id);
}
},
// --- ADDED: River Investigative Stations Sync ---
'riverInvestigativeStations': {
// IMPORTANT: Make sure this endpoint matches your server's route
'endpoint': 'river/investigative-stations',
'handler': (d, id) async {
await dbHelper.upsertRiverInvestigativeStations(d);
await dbHelper.deleteRiverInvestigativeStations(id);
}
},
// --- END ADDED ---
'departments': {
'endpoint': 'departments',
'handler': (d, id) async {
@ -485,15 +495,79 @@ class AirApiService {
}
}
// =======================================================================
// --- START OF MODIFIED SECTION ---
// The entire MarineApiService class is replaced with the corrected version.
// =======================================================================
class MarineApiService {
final BaseApiService _baseService;
final TelegramService _telegramService; // Still needed if _handleAlerts were here
final TelegramService _telegramService;
final ServerConfigService _serverConfigService;
final DatabaseHelper _dbHelper; // Still needed for parameter limit lookups if alerts were here
final DatabaseHelper _dbHelper; // Kept to match constructor
MarineApiService(this._baseService, this._telegramService, this._serverConfigService, this._dbHelper);
// --- KEPT METHODS ---
// --- KEPT METHODS (Unchanged) ---
Future<Map<String, dynamic>> getTarballStations() async {
final baseUrl = await _serverConfigService.getActiveApiUrl();
return _baseService.get(baseUrl, 'marine/tarball/stations');
}
Future<Map<String, dynamic>> getManualStations() async {
final baseUrl = await _serverConfigService.getActiveApiUrl();
return _baseService.get(baseUrl, 'marine/manual/stations');
}
Future<Map<String, dynamic>> getTarballClassifications() async {
final baseUrl = await _serverConfigService.getActiveApiUrl();
return _baseService.get(baseUrl, 'marine/tarball/classifications');
}
// --- REPLACED/FIXED METHOD ---
Future<Map<String, dynamic>> getManualSamplingImages({
required int stationId,
required DateTime samplingDate,
required String samplingType, // This parameter is NOW USED
}) async {
final baseUrl = await _serverConfigService.getActiveApiUrl();
final String dateStr = DateFormat('yyyy-MM-dd').format(samplingDate);
String endpoint;
// Determine the correct endpoint based on the sampling type
switch (samplingType) {
case 'In-Situ Sampling':
endpoint = 'marine/manual/records-by-station?station_id=$stationId&date=$dateStr';
break;
case 'Tarball Sampling':
// **IMPORTANT**: Please verify this is the correct endpoint for tarball records
endpoint = 'marine/tarball/records-by-station?station_id=$stationId&date=$dateStr';
break;
case 'All Manual Sampling':
default:
// 'All' is complex. Defaulting to 'manual' (in-situ) as a fallback.
endpoint = 'marine/manual/records-by-station?station_id=$stationId&date=$dateStr';
break;
}
// This new debug print will help you confirm the fix is working
debugPrint("MarineApiService: Calling API endpoint: $endpoint");
final response = await _baseService.get(baseUrl, endpoint);
// Adjusting response parsing based on observed structure
if (response['success'] == true && response['data'] is Map && response['data']['data'] is List) {
return {
'success': true,
'data': response['data']['data'], // Return the inner 'data' list
'message': response['message'],
};
}
// Return original response if structure doesn't match
return response;
}
// --- ADDED METHOD ---
Future<Map<String, dynamic>> sendImageRequestEmail({
required String recipientEmail,
required List<String> imageUrls,
@ -511,65 +585,13 @@ class MarineApiService {
return _baseService.postMultipart(
baseUrl: baseUrl,
endpoint: 'marine/images/send-email',
endpoint: 'marine/images/send-email', // **IMPORTANT**: Verify this endpoint
fields: fields,
files: {},
);
}
Future<Map<String, dynamic>> getManualSamplingImages({
required int stationId,
required DateTime samplingDate,
required String samplingType, // This parameter seems unused in the current endpoint
}) async {
final baseUrl = await _serverConfigService.getActiveApiUrl();
final String dateStr = DateFormat('yyyy-MM-dd').format(samplingDate);
// Endpoint seems specific to 'manual' records, might need adjustment if other types needed
final String endpoint = 'marine/manual/records-by-station?station_id=$stationId&date=$dateStr';
debugPrint("ApiService: Calling real API endpoint: $endpoint");
final response = await _baseService.get(baseUrl, endpoint);
// Adjusting response parsing based on observed structure
if (response['success'] == true && response['data'] is Map && response['data']['data'] is List) {
return {
'success': true,
'data': response['data']['data'], // Return the inner 'data' list
'message': response['message'],
};
}
// Return original response if structure doesn't match
return response;
}
Future<Map<String, dynamic>> getTarballStations() async {
final baseUrl = await _serverConfigService.getActiveApiUrl();
return _baseService.get(baseUrl, 'marine/tarball/stations');
}
Future<Map<String, dynamic>> getManualStations() async {
final baseUrl = await _serverConfigService.getActiveApiUrl();
return _baseService.get(baseUrl, 'marine/manual/stations');
}
Future<Map<String, dynamic>> getTarballClassifications() async {
final baseUrl = await _serverConfigService.getActiveApiUrl();
return _baseService.get(baseUrl, 'marine/tarball/classifications');
}
// --- REMOVED METHODS (Logic moved to feature services) ---
// - submitInSituSample
// - _handleInSituSuccessAlert
// - _generateInSituAlertMessage
// - _getOutOfBoundsAlertSection (InSitu version)
// - submitTarballSample
// - _handleTarballSuccessAlert
// - _generateTarballAlertMessage
// --- KEPT METHODS (Simple POSTs called by specific services) ---
// --- KEPT METHODS (Unchanged) ---
Future<Map<String, dynamic>> submitPreDepartureChecklist(MarineManualPreDepartureChecklistData data) async {
final baseUrl = await _serverConfigService.getActiveApiUrl();
return _baseService.post(baseUrl, 'marine/checklist', data.toApiFormData());
@ -590,6 +612,9 @@ class MarineApiService {
return _baseService.get(baseUrl, 'marine/maintenance/previous');
}
}
// =======================================================================
// --- END OF MODIFIED SECTION ---
// =======================================================================
class RiverApiService {
final BaseApiService _baseService;
@ -665,7 +690,9 @@ class RiverApiService {
class DatabaseHelper {
static Database? _database;
static const String _dbName = 'app_data.db';
static const int _dbVersion = 23; // Keep version updated if schema changes
// --- MODIFIED: Incremented DB version ---
static const int _dbVersion = 24; // Keep version updated if schema changes
// --- END MODIFIED ---
// compute-related static variables/methods REMOVED
@ -675,6 +702,9 @@ class DatabaseHelper {
static const String _manualStationsTable = 'marine_manual_stations';
static const String _riverManualStationsTable = 'river_manual_stations';
static const String _riverTriennialStationsTable = 'river_triennial_stations';
// --- ADDED: River Investigative Stations Table Name ---
static const String _riverInvestigativeStationsTable = 'river_investigative_stations';
// --- END ADDED ---
static const String _tarballClassificationsTable = 'marine_tarball_classifications';
static const String _departmentsTable = 'departments';
static const String _companiesTable = 'companies';
@ -726,6 +756,9 @@ class DatabaseHelper {
await db.execute('CREATE TABLE $_manualStationsTable(station_id INTEGER PRIMARY KEY, station_json TEXT)');
await db.execute('CREATE TABLE $_riverManualStationsTable(station_id INTEGER PRIMARY KEY, station_json TEXT)');
await db.execute('CREATE TABLE $_riverTriennialStationsTable(station_id INTEGER PRIMARY KEY, station_json TEXT)');
// --- ADDED: River Investigative Stations Table Create ---
await db.execute('CREATE TABLE $_riverInvestigativeStationsTable(station_id INTEGER PRIMARY KEY, station_json TEXT)');
// --- END ADDED ---
await db.execute('CREATE TABLE $_tarballClassificationsTable(classification_id INTEGER PRIMARY KEY, classification_json TEXT)');
await db.execute('CREATE TABLE $_departmentsTable(department_id INTEGER PRIMARY KEY, department_json TEXT)');
await db.execute('CREATE TABLE $_companiesTable(company_id INTEGER PRIMARY KEY, company_json TEXT)');
@ -894,6 +927,11 @@ class DatabaseHelper {
debugPrint("Upgrade warning: Failed to drop old parameter limits table (may not exist): $e");
}
}
// --- ADDED: Upgrade step for new table ---
if (oldVersion < 24) {
await db.execute('CREATE TABLE IF NOT EXISTS $_riverInvestigativeStationsTable(station_id INTEGER PRIMARY KEY, station_json TEXT)');
}
// --- END ADDED ---
}
// --- Data Handling Methods ---
@ -1074,6 +1112,13 @@ class DatabaseHelper {
Future<void> deleteRiverTriennialStations(List<dynamic> ids) => _deleteData(_riverTriennialStationsTable, 'station_id', ids);
Future<List<Map<String, dynamic>>?> loadRiverTriennialStations() => _loadData(_riverTriennialStationsTable, 'station');
// --- ADDED: River Investigative Stations DB Methods ---
Future<void> upsertRiverInvestigativeStations(List<Map<String, dynamic>> data) =>
_upsertData(_riverInvestigativeStationsTable, 'station_id', data, 'station');
Future<void> deleteRiverInvestigativeStations(List<dynamic> ids) => _deleteData(_riverInvestigativeStationsTable, 'station_id', ids);
Future<List<Map<String, dynamic>>?> loadRiverInvestigativeStations() => _loadData(_riverInvestigativeStationsTable, 'station');
// --- END ADDED ---
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);

View File

@ -1,8 +1,15 @@
// lib/services/marine_api_service.dart
import 'dart:convert'; // Added: Necessary for jsonEncode
import 'package:flutter/foundation.dart'; // Added: Necessary for debugPrint
import 'package:intl/intl.dart'; // Added: Necessary for DateFormat
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/server_config_service.dart';
// Added: Necessary for ApiService.imageBaseUrl, assuming it's defined there.
// If not, you may need to adjust this.
import 'package:environment_monitoring_app/services/api_service.dart';
class MarineApiService {
final BaseApiService _baseService;
@ -25,4 +32,75 @@ class MarineApiService {
final baseUrl = await _serverConfigService.getActiveApiUrl();
return _baseService.get(baseUrl, 'marine/tarball/classifications');
}
// --- ADDED: Method to fetch images (Fixes the issue) ---
/// Fetches image records for either In-Situ or Tarball sampling.
Future<Map<String, dynamic>> getManualSamplingImages({
required int stationId,
required DateTime samplingDate,
required String samplingType, // This parameter is now used
}) async {
final baseUrl = await _serverConfigService.getActiveApiUrl();
final String dateStr = DateFormat('yyyy-MM-dd').format(samplingDate);
String endpoint;
// Determine the correct endpoint based on the sampling type
switch (samplingType) {
case 'In-Situ Sampling':
endpoint = 'marine/manual/records-by-station?station_id=$stationId&date=$dateStr';
break;
case 'Tarball Sampling':
// **IMPORTANT**: Please verify this is the correct endpoint for tarball records
endpoint = 'marine/tarball/records-by-station?station_id=$stationId&date=$dateStr';
break;
case 'All Manual Sampling':
default:
// 'All' is complex. Defaulting to 'manual' (in-situ) as a fallback.
endpoint = 'marine/manual/records-by-station?station_id=$stationId&date=$dateStr';
break;
}
debugPrint("MarineApiService: Calling API endpoint: $endpoint");
final response = await _baseService.get(baseUrl, endpoint);
// This parsing logic assumes the server nests the list inside {'data': {'data': [...]}}
// Adjust if your API response is different
if (response['success'] == true && response['data'] is Map && response['data']['data'] is List) {
return {
'success': true,
'data': response['data']['data'], // Return the inner 'data' list
'message': response['message'],
};
}
// Return original response if structure doesn't match
return response;
}
// --- ADDED: Method to send email request (Fixes the issue) ---
/// Sends the selected image URLs to the server for emailing.
Future<Map<String, dynamic>> sendImageRequestEmail({
required String recipientEmail,
required List<String> imageUrls,
required String stationName,
required String samplingDate,
}) async {
final baseUrl = await _serverConfigService.getActiveApiUrl();
final Map<String, String> fields = {
'recipientEmail': recipientEmail,
'imageUrls': jsonEncode(imageUrls), // Encode list as JSON string
'stationName': stationName,
'samplingDate': samplingDate,
};
// Use postMultipart (even with no files) as it's common for this kind of endpoint
return _baseService.postMultipart(
baseUrl: baseUrl,
endpoint: 'marine/images/send-email', // **IMPORTANT**: Verify this endpoint
fields: fields,
files: {}, // No files being uploaded, just data
);
}
}

View File

@ -11,10 +11,14 @@ class UserPreferencesService {
static const _defaultPrefsSavedKey = 'default_preferences_saved';
// Moved from settings.dart for central access
// This list now includes all your modules
final List<Map<String, String>> _configurableModules = [
{'key': 'marine_tarball', 'name': 'Marine Tarball'},
{'key': 'marine_in_situ', 'name': 'Marine In-Situ'},
{'key': 'marine_investigative', 'name': 'Marine Investigative'},
{'key': 'river_in_situ', 'name': 'River In-Situ'},
{'key': 'river_triennial', 'name': 'River Triennial'},
{'key': 'river_investigative', 'name': 'River Investigative'},
{'key': 'air_installation', 'name': 'Air Installation'},
{'key': 'air_collection', 'name': 'Air Collection'},
];
@ -47,43 +51,24 @@ class UserPreferencesService {
);
// 2. Determine default API links
// This is correct: Tick any API server marked as 'is_active' by default.
final defaultApiLinks = allApiConfigs.map((config) {
bool isEnabled = config['config_name'] == 'PSTW_HQ';
bool isEnabled = (config['is_active'] == 1 || config['is_active'] == true);
return {...config, 'is_enabled': isEnabled};
}).toList();
// 3. Determine default FTP links
// --- START MODIFICATION: Simplified logic using ftp_module ---
final defaultFtpLinks = allFtpConfigs.map((config) {
bool isEnabled = false; // Disable all by default
switch (moduleKey) {
case 'marine_tarball':
if (config['config_name'] == 'pstw_marine_tarball' || config['config_name'] == 'tes_marine_tarball') {
isEnabled = true;
}
break;
case 'marine_in_situ':
if (config['config_name'] == 'pstw_marine_manual' || config['config_name'] == 'tes_marine_manual') {
isEnabled = true;
}
break;
case 'river_in_situ':
if (config['config_name'] == 'pstw_river_manual' || config['config_name'] == 'tes_river_manual') {
isEnabled = true;
}
break;
case 'air_collection':
if (config['config_name'] == 'pstw_air_collect' || config['config_name'] == 'tes_air_collect') {
isEnabled = true;
}
break;
case 'air_installation':
if (config['config_name'] == 'pstw_air_install' || config['config_name'] == 'tes_air_install') {
isEnabled = true;
}
break;
}
final String configModule = config['ftp_module'] ?? '';
final bool isActive = (config['is_active'] == 1 || config['is_active'] == true);
// Enable if the config's module matches the current moduleKey AND it's active
bool isEnabled = (configModule == moduleKey) && isActive;
return {...config, 'is_enabled': isEnabled};
}).toList();
// --- END MODIFICATION ---
// 4. Save the default links to the database.
await saveApiLinksForModule(moduleKey, defaultApiLinks);
@ -139,7 +124,7 @@ class UserPreferencesService {
// 3. Merge the two lists.
return allApiConfigs.map((config) {
final configId = config['api_config_id'];
bool isEnabled = false; // Default to disabled
bool isEnabled; // Default to disabled
try {
// Find if a link exists for this config ID in the user's saved preferences.
@ -147,11 +132,15 @@ class UserPreferencesService {
(link) => link['api_config_id'] == configId,
// If no link is found, 'orElse' is not triggered, it throws.
);
// A link was found, use the user's saved preference
isEnabled = matchingLink['is_enabled'] as bool? ?? false;
} catch (e) {
// --- THIS IS THE FIX for API post-sync ---
// A 'firstWhere' with no match throws an error. We catch it here.
// This means no link was saved for this config, so it remains disabled.
isEnabled = false;
// This means no link was saved (e.g., new config).
// Default to the 'is_active' flag from the server.
isEnabled = (config['is_active'] == 1 || config['is_active'] == true);
// --- END ---
}
// Return a new map containing the original config details plus the 'is_enabled' flag.
@ -172,14 +161,22 @@ class UserPreferencesService {
return allFtpConfigs.map((config) {
final configId = config['ftp_config_id'];
bool isEnabled = false;
bool isEnabled;
try {
final matchingLink = savedLinks.firstWhere(
(link) => link['ftp_config_id'] == configId,
);
// A link was found, use the user's saved preference
isEnabled = matchingLink['is_enabled'] as bool? ?? false;
} catch (e) {
isEnabled = false;
// --- START MODIFICATION: Use ftp_module for defaults ---
// No matching link was found (e.g., new FTP config synced).
// Default to 'enabled' if its module matches the one we're checking
// and the config is marked 'is_active' from the server.
final String configModule = config['ftp_module'] ?? '';
final bool isActive = (config['is_active'] == 1 || config['is_active'] == true);
isEnabled = (configModule == moduleName) && isActive;
// --- END MODIFICATION ---
}
return {
...config,