diff --git a/lib/models/river_in_situ_sampling_data.dart b/lib/models/river_in_situ_sampling_data.dart index bdc1540..47526ea 100644 --- a/lib/models/river_in_situ_sampling_data.dart +++ b/lib/models/river_in_situ_sampling_data.dart @@ -27,18 +27,15 @@ class RiverInSituSamplingData { // --- Step 2: Site Info & Photos --- String? weather; - // CHANGED: Renamed for river context - String? waterLevel; - String? riverCondition; String? eventRemarks; String? labRemarks; - // CHANGED: Image descriptions adapted for river context - File? leftBankViewImage; - File? rightBankViewImage; - File? waterFillingImage; - File? waterColorImage; - File? phPaperImage; + File? backgroundStationImage; + File? upstreamRiverImage; + File? downstreamRiverImage; + + // --- Step 4: Additional Photos --- + File? sampleTurbidityImage; File? optionalImage1; String? optionalRemark1; @@ -64,12 +61,6 @@ class RiverInSituSamplingData { double? tss; double? batteryVoltage; - // --- START: Add your river-specific parameters here --- - // Example: - // double? flowRate; - // --- END: Add your river-specific parameters here --- - - // --- Post-Submission Status --- String? submissionStatus; String? submissionMessage; @@ -80,6 +71,69 @@ class RiverInSituSamplingData { this.samplingTime, }); + /// ADDED: Factory constructor to create an instance from a map (JSON). + /// This is the required fix for the "fromJson isn't defined" error. + factory RiverInSituSamplingData.fromJson(Map json) { + // Helper function to safely create a File object from a path string + File? fileFromJson(dynamic path) { + return (path is String && path.isNotEmpty) ? File(path) : null; + } + + // Helper function to safely parse numbers + double? doubleFromJson(dynamic value) { + if (value is num) return value.toDouble(); + if (value is String) return double.tryParse(value); + return null; + } + + return RiverInSituSamplingData() + ..firstSamplerName = json['first_sampler_name'] + ..firstSamplerUserId = json['first_sampler_user_id'] + ..secondSampler = json['secondSampler'] + ..samplingDate = json['r_man_date'] + ..samplingTime = json['r_man_time'] + ..samplingType = json['r_man_type'] + ..sampleIdCode = json['r_man_sample_id_code'] + ..selectedStateName = json['selectedStateName'] + ..selectedCategoryName = json['selectedCategoryName'] + ..selectedStation = json['selectedStation'] + ..stationLatitude = json['stationLatitude'] + ..stationLongitude = json['stationLongitude'] + ..currentLatitude = json['r_man_current_latitude']?.toString() + ..currentLongitude = json['r_man_current_longitude']?.toString() + ..distanceDifferenceInKm = doubleFromJson(json['r_man_distance_difference']) + ..distanceDifferenceRemarks = json['r_man_distance_difference_remarks'] + ..weather = json['r_man_weather'] + ..eventRemarks = json['r_man_event_remark'] + ..labRemarks = json['r_man_lab_remark'] + ..sondeId = json['r_man_sondeID'] + ..dataCaptureDate = json['data_capture_date'] + ..dataCaptureTime = json['data_capture_time'] + ..oxygenConcentration = doubleFromJson(json['r_man_oxygen_conc']) + ..oxygenSaturation = doubleFromJson(json['r_man_oxygen_sat']) + ..ph = doubleFromJson(json['r_man_ph']) + ..salinity = doubleFromJson(json['r_man_salinity']) + ..electricalConductivity = doubleFromJson(json['r_man_conductivity']) + ..temperature = doubleFromJson(json['r_man_temperature']) + ..tds = doubleFromJson(json['r_man_tds']) + ..turbidity = doubleFromJson(json['r_man_turbidity']) + ..tss = doubleFromJson(json['r_man_tss']) + ..batteryVoltage = doubleFromJson(json['r_man_battery_volt']) + ..optionalRemark1 = json['r_man_optional_photo_01_remarks'] + ..optionalRemark2 = json['r_man_optional_photo_02_remarks'] + ..optionalRemark3 = json['r_man_optional_photo_03_remarks'] + ..optionalRemark4 = json['r_man_optional_photo_04_remarks'] + ..backgroundStationImage = fileFromJson(json['r_man_background_station']) + ..upstreamRiverImage = fileFromJson(json['r_man_upstream_river']) + ..downstreamRiverImage = fileFromJson(json['r_man_downstream_river']) + ..sampleTurbidityImage = fileFromJson(json['r_man_sample_turbidity']) + ..optionalImage1 = fileFromJson(json['r_man_optional_photo_01']) + ..optionalImage2 = fileFromJson(json['r_man_optional_photo_02']) + ..optionalImage3 = fileFromJson(json['r_man_optional_photo_03']) + ..optionalImage4 = fileFromJson(json['r_man_optional_photo_04']); + } + + /// Converts the data model into a Map for the API form data. Map toApiFormData() { final Map map = {}; @@ -90,9 +144,6 @@ class RiverInSituSamplingData { } } - // IMPORTANT: The keys below are prefixed with 'r_man_' for river manual sampling. - // Ensure these match your backend API requirements. - // Step 1 Data add('first_sampler_user_id', firstSamplerUserId); add('r_man_second_sampler_id', secondSampler?['user_id']); @@ -108,10 +159,10 @@ class RiverInSituSamplingData { // Step 2 Data add('r_man_weather', weather); - add('r_man_water_level', waterLevel); - add('r_man_river_condition', riverCondition); add('r_man_event_remark', eventRemarks); add('r_man_lab_remark', labRemarks); + + // Step 4 Data add('r_man_optional_photo_01_remarks', optionalRemark1); add('r_man_optional_photo_02_remarks', optionalRemark2); add('r_man_optional_photo_03_remarks', optionalRemark3); @@ -132,33 +183,26 @@ class RiverInSituSamplingData { add('r_man_tss', tss); add('r_man_battery_volt', batteryVoltage); - // --- START: Add your new river parameters to the form data map --- - // Example: - // add('r_man_flow_rate', flowRate); - // --- END: Add your new river parameters to the form data map --- - - // Additional data for display or logging, adapted from the original model + // Additional data for display or logging add('first_sampler_name', firstSamplerName); - // Assuming river station keys are prefixed with 'r_man_' - add('r_man_station_code', selectedStation?['r_man_station_code']); - add('r_man_station_name', selectedStation?['r_man_station_name']); + add('r_man_station_code', selectedStation?['sampling_station_code']); + add('r_man_station_name', selectedStation?['sampling_river']); + return map; } /// Converts the image properties into a Map for the multipart API request. Map toApiImageFiles() { - // IMPORTANT: Keys adapted for river context. return { - 'r_man_left_bank_view': leftBankViewImage, - 'r_man_right_bank_view': rightBankViewImage, - 'r_man_filling_water_into_sample_bottle': waterFillingImage, - 'r_man_water_in_clear_glass_bottle': waterColorImage, - 'r_man_examine_preservative_ph_paper': phPaperImage, + 'r_man_background_station': backgroundStationImage, + 'r_man_upstream_river': upstreamRiverImage, + 'r_man_downstream_river': downstreamRiverImage, + 'r_man_sample_turbidity': sampleTurbidityImage, 'r_man_optional_photo_01': optionalImage1, 'r_man_optional_photo_02': optionalImage2, 'r_man_optional_photo_03': optionalImage3, 'r_man_optional_photo_04': optionalImage4, }; } -} \ No newline at end of file +} diff --git a/lib/screens/river/manual/data_status_log.dart b/lib/screens/river/manual/data_status_log.dart index 0c691f8..821f2f3 100644 --- a/lib/screens/river/manual/data_status_log.dart +++ b/lib/screens/river/manual/data_status_log.dart @@ -1,17 +1,13 @@ -// lib/screens/river/manual/widgets/data_status_log.dart - import 'dart:io'; import 'package:flutter/material.dart'; import 'package:intl/intl.dart'; -// CHANGED: Import River-specific models and services import '../../../../models/river_in_situ_sampling_data.dart'; import '../../../../services/local_storage_service.dart'; import '../../../../services/river_api_service.dart'; -// A unified model to represent any type of submission log entry. class SubmissionLogEntry { - final String type; // e.g., 'in-situ' + final String type; final String title; final String stationCode; final DateTime submissionDateTime; @@ -34,169 +30,134 @@ class SubmissionLogEntry { }); } -// CHANGED: Renamed widget for River context class RiverDataStatusLog extends StatefulWidget { const RiverDataStatusLog({super.key}); @override - // CHANGED: Renamed state class State createState() => _RiverDataStatusLogState(); } -// CHANGED: Renamed state class class _RiverDataStatusLogState extends State { final LocalStorageService _localStorageService = LocalStorageService(); - // CHANGED: Use RiverApiService final RiverApiService _riverApiService = RiverApiService(); - Map> _groupedLogs = {}; - Map> _filteredLogs = {}; - final Map _isCategoryExpanded = {}; + // Raw data lists + List _scheduleLogs = []; + List _triennialLogs = []; + List _otherLogs = []; + + // Filtered lists for the UI + List _filteredScheduleLogs = []; + List _filteredTriennialLogs = []; + List _filteredOtherLogs = []; + + // Per-category search controllers + final Map _searchControllers = {}; bool _isLoading = true; - final TextEditingController _searchController = TextEditingController(); @override void initState() { super.initState(); _loadAllLogs(); - _searchController.addListener(_filterLogs); } @override void dispose() { - _searchController.dispose(); + for (var controller in _searchControllers.values) { + controller.dispose(); + } super.dispose(); } - /// Loads logs for the river in-situ module. Future _loadAllLogs() async { setState(() => _isLoading = true); - // NOTE: Assumes a method exists in your local storage service for river logs. - final inSituLogs = await _localStorageService.getAllRiverInSituLogs(); + final riverLogs = await _localStorageService.getAllRiverInSituLogs(); - final Map> tempGroupedLogs = {}; + final List tempSchedule = []; + final List tempTriennial = []; + final List tempOthers = []; - // REMOVED: The entire block for fetching and mapping Tarball logs was here. - - // Map In-Situ logs for River - final List inSituEntries = []; - for (var log in inSituLogs) { - final String dateStr = log['data_capture_date'] ?? log['sampling_date'] ?? ''; - final String timeStr = log['data_capture_time'] ?? log['sampling_time'] ?? ''; - - inSituEntries.add(SubmissionLogEntry( - type: 'In-Situ Sampling', - // CHANGED: Use river-specific station keys - title: log['selectedStation']?['r_man_station_name'] ?? 'Unknown Station', - stationCode: log['selectedStation']?['r_man_station_code'] ?? 'N/A', - submissionDateTime: DateTime.tryParse('$dateStr $timeStr') ?? DateTime.now(), + for (var log in riverLogs) { + final entry = SubmissionLogEntry( + type: log['r_man_type'] as String? ?? 'Others', + title: log['selectedStation']?['sampling_river'] ?? 'Unknown Station', + stationCode: log['selectedStation']?['sampling_station_code'] ?? 'N/A', + submissionDateTime: DateTime.tryParse('${log['r_man_date']} ${log['r_man_time']}') ?? DateTime.now(), reportId: log['reportId']?.toString(), status: log['submissionStatus'] ?? 'L1', message: log['submissionMessage'] ?? 'No status message.', rawData: log, - )); - } - if (inSituEntries.isNotEmpty) { - inSituEntries.sort((a, b) => b.submissionDateTime.compareTo(a.submissionDateTime)); - tempGroupedLogs['In-Situ Sampling'] = inSituEntries; + ); + + switch (entry.type) { + case 'Schedule': + tempSchedule.add(entry); + break; + case 'Triennial': + tempTriennial.add(entry); + break; + default: + tempOthers.add(entry); + break; + } } + tempSchedule.sort((a, b) => b.submissionDateTime.compareTo(a.submissionDateTime)); + tempTriennial.sort((a, b) => b.submissionDateTime.compareTo(a.submissionDateTime)); + tempOthers.sort((a, b) => b.submissionDateTime.compareTo(a.submissionDateTime)); + + // Initialize search controllers for categories that have data + final categories = {'Schedule': tempSchedule, 'Triennial': tempTriennial, 'Others': tempOthers}; + categories.forEach((key, value) { + if (value.isNotEmpty) { + _searchControllers.putIfAbsent(key, () { + final controller = TextEditingController(); + controller.addListener(() => _filterLogs()); + return controller; + }); + } + }); + if (mounted) { setState(() { - _groupedLogs = tempGroupedLogs; - _filteredLogs = tempGroupedLogs; + _scheduleLogs = tempSchedule; + _triennialLogs = tempTriennial; + _otherLogs = tempOthers; _isLoading = false; }); + _filterLogs(); // Perform initial filter } } void _filterLogs() { - final query = _searchController.text.toLowerCase(); - final Map> tempFiltered = {}; - - _groupedLogs.forEach((category, logs) { - final filtered = logs.where((log) { - return log.title.toLowerCase().contains(query) || - log.stationCode.toLowerCase().contains(query) || - (log.reportId?.toLowerCase() ?? '').contains(query); - }).toList(); - - if (filtered.isNotEmpty) { - tempFiltered[category] = filtered; - } - }); + final scheduleQuery = _searchControllers['Schedule']?.text.toLowerCase() ?? ''; + final triennialQuery = _searchControllers['Triennial']?.text.toLowerCase() ?? ''; + final otherQuery = _searchControllers['Others']?.text.toLowerCase() ?? ''; setState(() { - _filteredLogs = tempFiltered; + _filteredScheduleLogs = _scheduleLogs.where((log) => _logMatchesQuery(log, scheduleQuery)).toList(); + _filteredTriennialLogs = _triennialLogs.where((log) => _logMatchesQuery(log, triennialQuery)).toList(); + _filteredOtherLogs = _otherLogs.where((log) => _logMatchesQuery(log, otherQuery)).toList(); }); } - /// Main router for resubmitting data based on its type. - Future _resubmitData(SubmissionLogEntry log) async { - setState(() => log.isResubmitting = true); - - switch (log.type) { - // REMOVED: The case for 'Tarball Sampling' was here. - case 'In-Situ Sampling': - await _resubmitInSituData(log); - break; - default: - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text("Resubmission for '${log.type}' is not implemented."), backgroundColor: Colors.orange), - ); - setState(() => log.isResubmitting = false); - } - } + bool _logMatchesQuery(SubmissionLogEntry log, String query) { + if (query.isEmpty) return true; + return log.title.toLowerCase().contains(query) || + log.stationCode.toLowerCase().contains(query) || + (log.reportId?.toLowerCase() ?? '').contains(query); } - // REMOVED: The entire _resubmitTarballData method was here. - - /// Handles resubmission for River In-Situ data. - Future _resubmitInSituData(SubmissionLogEntry log) async { + Future _resubmitData(SubmissionLogEntry log) async { + setState(() => log.isResubmitting = true); final logData = log.rawData; - - // CHANGED: Reconstruct the RiverInSituSamplingData object - final RiverInSituSamplingData dataToResubmit = RiverInSituSamplingData() - ..firstSamplerUserId = int.tryParse(logData['first_sampler_user_id']?.toString() ?? '') - ..secondSampler = logData['secondSampler'] - ..samplingDate = logData['sampling_date'] - ..samplingTime = logData['sampling_time'] - ..samplingType = logData['sampling_type'] - ..sampleIdCode = logData['sample_id_code'] - ..selectedStation = logData['selectedStation'] - ..currentLatitude = logData['current_latitude']?.toString() - ..currentLongitude = logData['current_longitude']?.toString() - ..distanceDifferenceInKm = double.tryParse(logData['distance_difference']?.toString() ?? '0.0') - ..weather = logData['weather'] - // CHANGED: Use river-specific fields - ..waterLevel = logData['water_level'] - ..riverCondition = logData['river_condition'] - ..eventRemarks = logData['event_remarks'] - ..labRemarks = logData['lab_remarks'] - ..optionalRemark1 = logData['optional_photo_remark_1'] - ..optionalRemark2 = logData['optional_photo_remark_2'] - ..optionalRemark3 = logData['optional_photo_remark_3'] - ..optionalRemark4 = logData['optional_photo_remark_4'] - ..sondeId = logData['sonde_id'] - ..dataCaptureDate = logData['data_capture_date'] - ..dataCaptureTime = logData['data_capture_time'] - ..oxygenConcentration = double.tryParse(logData['oxygen_concentration_mg_l']?.toString() ?? '0.0') - ..oxygenSaturation = double.tryParse(logData['oxygen_saturation_percent']?.toString() ?? '0.0') - ..ph = double.tryParse(logData['ph']?.toString() ?? '0.0') - ..salinity = double.tryParse(logData['salinity_ppt']?.toString() ?? '0.0') - ..electricalConductivity = double.tryParse(logData['ec_us_cm']?.toString() ?? '0.0') - ..temperature = double.tryParse(logData['temperature_c']?.toString() ?? '0.0') - ..tds = double.tryParse(logData['tds_mg_l']?.toString() ?? '0.0') - ..turbidity = double.tryParse(logData['turbidity_ntu']?.toString() ?? '0.0') - ..tss = double.tryParse(logData['tss_mg_l']?.toString() ?? '0.0') - ..batteryVoltage = double.tryParse(logData['battery_v']?.toString() ?? '0.0'); - + final dataToResubmit = RiverInSituSamplingData.fromJson(logData); final Map imageFiles = {}; - final imageKeys = dataToResubmit.toApiImageFiles().keys; - for (var key in imageKeys) { + + final imageApiKeys = dataToResubmit.toApiImageFiles().keys; + for (var key in imageApiKeys) { final imagePath = logData[key]; if (imagePath is String && imagePath.isNotEmpty) { final file = File(imagePath); @@ -206,89 +167,93 @@ class _RiverDataStatusLogState extends State { } } - // CHANGED: Submit the data via the RiverApiService final result = await _riverApiService.submitInSituSample( formData: dataToResubmit.toApiFormData(), imageFiles: imageFiles, ); - logData['submissionStatus'] = result['status']; logData['submissionMessage'] = result['message']; logData['reportId'] = result['reportId']?.toString() ?? logData['reportId']; - - // NOTE: Assumes a method exists to update river logs. await _localStorageService.updateRiverInSituLog(logData); - if (mounted) await _loadAllLogs(); } @override Widget build(BuildContext context) { + final hasAnyLogs = _scheduleLogs.isNotEmpty || _triennialLogs.isNotEmpty || _otherLogs.isNotEmpty; + final hasFilteredLogs = _filteredScheduleLogs.isNotEmpty || _filteredTriennialLogs.isNotEmpty || _filteredOtherLogs.isNotEmpty; + return Scaffold( - // CHANGED: Updated AppBar title appBar: AppBar(title: const Text('River Data Status Log')), - body: Column( - children: [ - Padding( - padding: const EdgeInsets.all(8.0), - child: TextField( - controller: _searchController, - decoration: InputDecoration( - labelText: 'Search Logs...', - prefixIcon: const Icon(Icons.search), - border: OutlineInputBorder(borderRadius: BorderRadius.circular(12.0)), - ), - ), - ), - Expanded( - child: _isLoading - ? const Center(child: CircularProgressIndicator()) - : RefreshIndicator( - onRefresh: _loadAllLogs, - child: _filteredLogs.isEmpty - ? Center(child: Text(_groupedLogs.isEmpty ? 'No submission logs found.' : 'No logs match your search.')) - : ListView( - children: _filteredLogs.entries.map((entry) { - return _buildCategorySection(entry.key, entry.value); - }).toList(), - ), - ), - ), - ], + body: _isLoading + ? const Center(child: CircularProgressIndicator()) + : RefreshIndicator( + onRefresh: _loadAllLogs, + child: !hasAnyLogs + ? const Center(child: Text('No submission logs found.')) + : ListView( + padding: const EdgeInsets.all(8.0), + children: [ + // No global search bar here + if (_scheduleLogs.isNotEmpty) + _buildCategorySection('Schedule', _filteredScheduleLogs), + if (_triennialLogs.isNotEmpty) + _buildCategorySection('Triennial', _filteredTriennialLogs), + if (_otherLogs.isNotEmpty) + _buildCategorySection('Others', _filteredOtherLogs), + if (!hasFilteredLogs && hasAnyLogs) + const Center( + child: Padding( + padding: EdgeInsets.all(24.0), + child: Text('No logs match your search.'), + ), + ) + ], + ), ), ); } Widget _buildCategorySection(String category, List logs) { - final bool isExpanded = _isCategoryExpanded[category] ?? false; - final int itemCount = isExpanded ? logs.length : (logs.length > 5 ? 5 : logs.length); + // Calculate the height for the scrollable list. + // Each item is approx 75px high. Limit to 5 items height. + final listHeight = (logs.length > 5 ? 5.5 : logs.length.toDouble()) * 75.0; return Card( - margin: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 6.0), + margin: const EdgeInsets.symmetric(vertical: 8.0), child: Padding( padding: const EdgeInsets.all(8.0), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(category, style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold)), - const Divider(), - ListView.builder( - shrinkWrap: true, - physics: const NeverScrollableScrollPhysics(), - itemCount: itemCount, - itemBuilder: (context, index) { - return _buildLogListItem(logs[index]); - }, - ), - if (logs.length > 5) - TextButton( - onPressed: () { - setState(() { - _isCategoryExpanded[category] = !isExpanded; - }); - }, - child: Text(isExpanded ? 'Show Less' : 'Show More (${logs.length - 5} more)'), + Padding( + padding: const EdgeInsets.symmetric(vertical: 8.0), + child: TextField( + controller: _searchControllers[category], + decoration: InputDecoration( + hintText: 'Search in $category...', + prefixIcon: const Icon(Icons.search, size: 20), + isDense: true, + border: const OutlineInputBorder(), + ), ), + ), + const Divider(), + logs.isEmpty + ? const Padding( + padding: EdgeInsets.all(16.0), + child: Center(child: Text('No logs match your search in this category.'))) + : ConstrainedBox( + constraints: BoxConstraints(maxHeight: listHeight), + child: ListView.builder( + shrinkWrap: true, + itemCount: logs.length, + itemBuilder: (context, index) { + return _buildLogListItem(logs[index]); + }, + ), + ), ], ), ), @@ -309,20 +274,12 @@ class _RiverDataStatusLogState extends State { subtitle: Text(subtitle), trailing: isFailed ? (log.isResubmitting - ? const SizedBox( - height: 24, - width: 24, - child: CircularProgressIndicator(strokeWidth: 3), - ) - : IconButton( - icon: const Icon(Icons.sync, color: Colors.blue), - tooltip: 'Resubmit', - onPressed: () => _resubmitData(log), - )) + ? const SizedBox(height: 24, width: 24, child: CircularProgressIndicator(strokeWidth: 3)) + : IconButton(icon: const Icon(Icons.sync, color: Colors.blue), tooltip: 'Resubmit', onPressed: () => _resubmitData(log))) : null, children: [ Padding( - padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0), + padding: const EdgeInsets.fromLTRB(16, 8, 16, 16), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ diff --git a/lib/screens/river/manual/in_situ_sampling.dart b/lib/screens/river/manual/in_situ_sampling.dart index 792cb7c..ca10051 100644 --- a/lib/screens/river/manual/in_situ_sampling.dart +++ b/lib/screens/river/manual/in_situ_sampling.dart @@ -10,11 +10,10 @@ import '../../../services/local_storage_service.dart'; import 'widgets/river_in_situ_step_1_sampling_info.dart'; import 'widgets/river_in_situ_step_2_site_info.dart'; import 'widgets/river_in_situ_step_3_data_capture.dart'; -import 'widgets/river_in_situ_step_4_summary.dart'; +import 'widgets/river_in_situ_step_4_additional_info.dart'; +import 'widgets/river_in_situ_step_5_summary.dart'; + -/// The main screen for the River In-Situ Sampling feature. -/// This stateful widget orchestrates the multi-step process using a PageView. -/// It manages the overall data model and the service layer for the entire workflow. class RiverInSituSamplingScreen extends StatefulWidget { const RiverInSituSamplingScreen({super.key}); @@ -27,10 +26,7 @@ class _RiverInSituSamplingScreenState extends State { late RiverInSituSamplingData _data; - // A single instance of the service to be used by all child widgets. final RiverInSituSamplingService _samplingService = RiverInSituSamplingService(); - - // Service for saving submission logs locally. final LocalStorageService _localStorageService = LocalStorageService(); int _currentPage = 0; @@ -39,8 +35,6 @@ class _RiverInSituSamplingScreenState extends State { @override void initState() { super.initState(); - // Creates a NEW data object with the CURRENT date and time - // every time the user starts a new sampling. _data = RiverInSituSamplingData( samplingDate: DateFormat('yyyy-MM-dd').format(DateTime.now()), samplingTime: DateFormat('HH:mm:ss').format(DateTime.now()), @@ -54,9 +48,8 @@ class _RiverInSituSamplingScreenState extends State { super.dispose(); } - /// Navigates to the next page in the form. void _nextPage() { - if (_currentPage < 3) { + if (_currentPage < 4) { _pageController.nextPage( duration: const Duration(milliseconds: 300), curve: Curves.easeInOut, @@ -64,7 +57,6 @@ class _RiverInSituSamplingScreenState extends State { } } - /// Navigates to the previous page in the form. void _previousPage() { if (_currentPage > 0) { _pageController.previousPage( @@ -74,7 +66,6 @@ class _RiverInSituSamplingScreenState extends State { } } - /// Handles the final submission process. Future _submitForm() async { setState(() => _isLoading = true); @@ -86,7 +77,6 @@ class _RiverInSituSamplingScreenState extends State { _data.submissionMessage = result['message']; _data.reportId = result['reportId']?.toString(); - // Save a log of the submission locally using the river-specific method. await _localStorageService.saveRiverInSituSamplingData(_data); setState(() => _isLoading = false); @@ -100,19 +90,18 @@ class _RiverInSituSamplingScreenState extends State { SnackBar(content: Text(message), backgroundColor: color, duration: const Duration(seconds: 4)), ); - if (result['status'] == 'L3') { - Navigator.of(context).popUntil((route) => route.isFirst); - } + // ✅ CORRECTED: The navigation now happens regardless of the submission status. + // This ensures the form closes even after a failed (offline) submission. + Navigator.of(context).popUntil((route) => route.isFirst); } @override Widget build(BuildContext context) { - // Use Provider.value to provide the existing river service instance to all child widgets. return Provider.value( value: _samplingService, child: Scaffold( appBar: AppBar( - title: Text('In-Situ Sampling (${_currentPage + 1}/4)'), + title: Text('In-Situ Sampling (${_currentPage + 1}/5)'), leading: _currentPage > 0 ? IconButton( icon: const Icon(Icons.arrow_back), @@ -129,11 +118,11 @@ class _RiverInSituSamplingScreenState extends State { }); }, children: [ - // Each step is a separate river-specific widget. RiverInSituStep1SamplingInfo(data: _data, onNext: _nextPage), RiverInSituStep2SiteInfo(data: _data, onNext: _nextPage), RiverInSituStep3DataCapture(data: _data, onNext: _nextPage), - RiverInSituStep4Summary(data: _data, onSubmit: _submitForm, isLoading: _isLoading), + RiverInSituStep4AdditionalInfo(data: _data, onNext: _nextPage), + RiverInSituStep5Summary(data: _data, onSubmit: _submitForm, isLoading: _isLoading), ], ), ), diff --git a/lib/screens/river/manual/widgets/river_in_situ_step_1_sampling_info.dart b/lib/screens/river/manual/widgets/river_in_situ_step_1_sampling_info.dart index 0ba1b61..60abec7 100644 --- a/lib/screens/river/manual/widgets/river_in_situ_step_1_sampling_info.dart +++ b/lib/screens/river/manual/widgets/river_in_situ_step_1_sampling_info.dart @@ -36,10 +36,12 @@ class _RiverInSituStep1SamplingInfoState extends State _statesList = []; List> _stationsForState = []; - final List _samplingTypes = ['Schedule', 'Ad-Hoc', 'Complaint']; + final List _samplingTypes = ['Schedule', 'Triennial']; + // REMOVED: Weather options list. @override void initState() { @@ -58,6 +60,7 @@ class _RiverInSituStep1SamplingInfoState extends State { late final TextEditingController _eventRemarksController; late final TextEditingController _labRemarksController; - late final TextEditingController _optionalRemark1Controller; - late final TextEditingController _optionalRemark2Controller; - late final TextEditingController _optionalRemark3Controller; - late final TextEditingController _optionalRemark4Controller; - - final List _weatherOptions = ['Clear', 'Rainy', 'Cloudy', 'Windy', 'Sunny', 'Drizzle']; - // MODIFIED: Removed _waterLevelOptions and _riverConditionOptions + final List _weatherOptions = ['Clear', 'Rainy', 'Cloudy']; @override void initState() { super.initState(); _eventRemarksController = TextEditingController(text: widget.data.eventRemarks); _labRemarksController = TextEditingController(text: widget.data.labRemarks); - _optionalRemark1Controller = TextEditingController(text: widget.data.optionalRemark1); - _optionalRemark2Controller = TextEditingController(text: widget.data.optionalRemark2); - _optionalRemark3Controller = TextEditingController(text: widget.data.optionalRemark3); - _optionalRemark4Controller = TextEditingController(text: widget.data.optionalRemark4); } @override void dispose() { _eventRemarksController.dispose(); _labRemarksController.dispose(); - _optionalRemark1Controller.dispose(); - _optionalRemark2Controller.dispose(); - _optionalRemark3Controller.dispose(); - _optionalRemark4Controller.dispose(); super.dispose(); } @@ -79,20 +67,16 @@ class _RiverInSituStep2SiteInfoState extends State { return; } - if (widget.data.leftBankViewImage == null || - widget.data.rightBankViewImage == null || - widget.data.waterFillingImage == null || - widget.data.waterColorImage == null || - widget.data.phPaperImage == null) { - _showSnackBar('Please attach all 5 required photos before proceeding.', isError: true); + _formKey.currentState!.save(); + + // UPDATED: Validation now checks for 3 required photos. + if (widget.data.backgroundStationImage == null || + widget.data.upstreamRiverImage == null || + widget.data.downstreamRiverImage == null) { + _showSnackBar('Please attach all 3 required photos before proceeding.', isError: true); return; } - _formKey.currentState!.save(); - widget.data.optionalRemark1 = _optionalRemark1Controller.text; - widget.data.optionalRemark2 = _optionalRemark2Controller.text; - widget.data.optionalRemark3 = _optionalRemark3Controller.text; - widget.data.optionalRemark4 = _optionalRemark4Controller.text; widget.onNext(); } @@ -112,39 +96,17 @@ class _RiverInSituStep2SiteInfoState extends State { child: ListView( padding: const EdgeInsets.all(24.0), children: [ - Text("On-Site Information", style: Theme.of(context).textTheme.headlineSmall), - const SizedBox(height: 24), + Text("On-Site Information", style: Theme.of(context).textTheme.titleLarge), + const SizedBox(height: 16), DropdownButtonFormField( value: widget.data.weather, items: _weatherOptions.map((item) => DropdownMenuItem(value: item, child: Text(item))).toList(), onChanged: (value) => setState(() => widget.data.weather = value), decoration: const InputDecoration(labelText: 'Weather *'), validator: (value) => value == null ? 'Weather is required' : null, + onSaved: (value) => widget.data.weather = value, ), const SizedBox(height: 16), - // MODIFIED: The DropdownButtonFormField for 'Water Level' has been removed. - // MODIFIED: The DropdownButtonFormField for 'River Condition' has been removed. - - Text("Required Photos *", style: Theme.of(context).textTheme.titleLarge), - const Text("All photos must be taken in landscape (horizontal) orientation.", style: TextStyle(color: Colors.grey)), - const SizedBox(height: 8), - _buildImagePicker('Left Bank View', 'LEFT_BANK_VIEW', widget.data.leftBankViewImage, (file) => widget.data.leftBankViewImage = file, isRequired: true), - _buildImagePicker('Right Bank View', 'RIGHT_BANK_VIEW', widget.data.rightBankViewImage, (file) => widget.data.rightBankViewImage = file, isRequired: true), - _buildImagePicker('Filling Water into Sample Bottle', 'WATER_FILLING', widget.data.waterFillingImage, (file) => widget.data.waterFillingImage = file, isRequired: true), - _buildImagePicker('Water in Clear Glass Bottle', 'WATER_COLOR', widget.data.waterColorImage, (file) => widget.data.waterColorImage = file, isRequired: true), - _buildImagePicker('Examine Preservative (pH paper)', 'PH_PAPER', widget.data.phPaperImage, (file) => widget.data.phPaperImage = file, isRequired: true), - const SizedBox(height: 24), - - Text("Optional Photos & Remarks", style: Theme.of(context).textTheme.titleLarge), - const SizedBox(height: 8), - _buildImagePicker('Optional Photo 1', 'OPTIONAL_1', widget.data.optionalImage1, (file) => widget.data.optionalImage1 = file, remarkController: _optionalRemark1Controller, isRequired: false), - _buildImagePicker('Optional Photo 2', 'OPTIONAL_2', widget.data.optionalImage2, (file) => widget.data.optionalImage2 = file, remarkController: _optionalRemark2Controller, isRequired: false), - _buildImagePicker('Optional Photo 3', 'OPTIONAL_3', widget.data.optionalImage3, (file) => widget.data.optionalImage3 = file, remarkController: _optionalRemark3Controller, isRequired: false), - _buildImagePicker('Optional Photo 4', 'OPTIONAL_4', widget.data.optionalImage4, (file) => widget.data.optionalImage4 = file, remarkController: _optionalRemark4Controller, isRequired: false), - const SizedBox(height: 24), - - Text("Remarks", style: Theme.of(context).textTheme.titleLarge), - const SizedBox(height: 16), TextFormField( controller: _eventRemarksController, decoration: const InputDecoration(labelText: 'Event Remarks (Optional)', hintText: 'e.g., unusual smells, colors, etc.'), @@ -158,6 +120,20 @@ class _RiverInSituStep2SiteInfoState extends State { onSaved: (value) => widget.data.labRemarks = value, maxLines: 3, ), + const Divider(height: 32), + + Text("Required Photos *", style: Theme.of(context).textTheme.titleLarge), + const Text("All photos must be taken in landscape (horizontal) orientation.", style: TextStyle(color: Colors.grey)), + const SizedBox(height: 8), + + _buildImagePicker('Background Station', 'BACKGROUND_STATION', widget.data.backgroundStationImage, (file) => widget.data.backgroundStationImage = file, isRequired: true), + _buildImagePicker('Upstream River', 'UPSTREAM_RIVER', widget.data.upstreamRiverImage, (file) => widget.data.upstreamRiverImage = file, isRequired: true), + _buildImagePicker('Downstream River', 'DOWNSTREAM_RIVER', widget.data.downstreamRiverImage, (file) => widget.data.downstreamRiverImage = file, isRequired: true), + + // REMOVED: The "Sample Turbidity" image picker was here. + + const SizedBox(height: 24), + const SizedBox(height: 32), ElevatedButton( onPressed: _goToNextStep, diff --git a/lib/screens/river/manual/widgets/river_in_situ_step_3_data_capture.dart b/lib/screens/river/manual/widgets/river_in_situ_step_3_data_capture.dart index b0598a7..887d266 100644 --- a/lib/screens/river/manual/widgets/river_in_situ_step_3_data_capture.dart +++ b/lib/screens/river/manual/widgets/river_in_situ_step_3_data_capture.dart @@ -8,11 +8,12 @@ import 'package:usb_serial/usb_serial.dart'; import '../../../../models/river_in_situ_sampling_data.dart'; import '../../../../services/river_in_situ_sampling_service.dart'; -import '../../../../bluetooth/bluetooth_manager.dart'; // For connection state enum -import '../../../../serial/serial_manager.dart'; // For connection state enum +import '../../../../bluetooth/bluetooth_manager.dart'; +import '../../../../serial/serial_manager.dart'; import '../../../../bluetooth/widgets/bluetooth_device_list_dialog.dart'; import '../../../../serial/widget/serial_port_list_dialog.dart'; +// UPDATED: Class name changed from RiverInSituStep2DataCapture to RiverInSituStep3DataCapture class RiverInSituStep3DataCapture extends StatefulWidget { final RiverInSituSamplingData data; final VoidCallback onNext; @@ -24,9 +25,11 @@ class RiverInSituStep3DataCapture extends StatefulWidget { }); @override + // UPDATED: State class reference State createState() => _RiverInSituStep3DataCaptureState(); } +// UPDATED: State class name class _RiverInSituStep3DataCaptureState extends State { final _formKey = GlobalKey(); bool _isLoading = false; @@ -48,7 +51,6 @@ class _RiverInSituStep3DataCaptureState extends State _handleConnectionAttempt(String type) async { final service = context.read(); - final bool hasPermissions = await service.requestDevicePermissions(); if (!hasPermissions && mounted) { _showSnackBar("Bluetooth & Location permissions are required to connect.", isError: true); return; } - _disconnectFromAll(); await Future.delayed(const Duration(milliseconds: 250)); - final bool connectionSuccess = await _connectToDevice(type); - if (connectionSuccess && mounted) { _dataSubscription?.cancel(); final stream = type == 'bluetooth' ? service.bluetoothDataStream : service.serialDataStream; - _dataSubscription = stream.listen((readings) { if (mounted) { _updateTextFields(readings); @@ -158,7 +152,6 @@ class _RiverInSituStep3DataCaptureState extends State _isLoading = true); final service = context.read(); bool success = false; - try { if (type == 'bluetooth') { final devices = await service.getPairedBluetoothDevices(); @@ -249,28 +242,20 @@ class _RiverInSituStep3DataCaptureState extends State[ - TextButton( - child: const Text('OK'), - onPressed: () { - Navigator.of(context).pop(); - }, - ), - ], - ); - }, - ); + context: context, + builder: (BuildContext context) { + return AlertDialog( + title: const Text('Data Collection Active'), + content: const Text('Please stop the live data collection before proceeding.'), + actions: [ + TextButton(child: const Text('OK'), onPressed: () => Navigator.of(context).pop()) + ]); + }); return; } if (_formKey.currentState!.validate()){ _formKey.currentState!.save(); - try { const defaultValue = -999.0; widget.data.temperature = double.tryParse(_tempController.text) ?? defaultValue; @@ -287,7 +272,6 @@ class _RiverInSituStep3DataCaptureState extends State createState() => + _RiverInSituStep4AdditionalInfoState(); +} + +class _RiverInSituStep4AdditionalInfoState + extends State { + final _formKey = GlobalKey(); + bool _isPickingImage = false; + + late final TextEditingController _optionalRemark1Controller; + late final TextEditingController _optionalRemark2Controller; + late final TextEditingController _optionalRemark3Controller; + late final TextEditingController _optionalRemark4Controller; + + @override + void initState() { + super.initState(); + _optionalRemark1Controller = TextEditingController(text: widget.data.optionalRemark1); + _optionalRemark2Controller = TextEditingController(text: widget.data.optionalRemark2); + _optionalRemark3Controller = TextEditingController(text: widget.data.optionalRemark3); + _optionalRemark4Controller = TextEditingController(text: widget.data.optionalRemark4); + } + + @override + void dispose() { + _optionalRemark1Controller.dispose(); + _optionalRemark2Controller.dispose(); + _optionalRemark3Controller.dispose(); + _optionalRemark4Controller.dispose(); + super.dispose(); + } + + void _setImage(Function(File?) setImageCallback, ImageSource source, String imageInfo, {required bool isRequired}) async { + if (_isPickingImage) return; + setState(() => _isPickingImage = true); + + final service = Provider.of(context, listen: false); + final file = await service.pickAndProcessImage(source, data: widget.data, imageInfo: imageInfo, isRequired: isRequired); + + if (file != null) { + setState(() => setImageCallback(file)); + } else if (mounted) { + _showSnackBar('Image selection failed. Please ensure all photos are taken in landscape mode.', isError: true); + } + + if (mounted) { + setState(() => _isPickingImage = false); + } + } + + void _goToNextStep() { + if (_formKey.currentState!.validate()) { + _formKey.currentState!.save(); + + // ADDED: Validation for the moved required photo. + if (widget.data.sampleTurbidityImage == null) { + _showSnackBar('Please attach the Sample Turbidity photo before proceeding.', isError: true); + return; + } + + widget.data.optionalRemark1 = _optionalRemark1Controller.text; + widget.data.optionalRemark2 = _optionalRemark2Controller.text; + widget.data.optionalRemark3 = _optionalRemark3Controller.text; + widget.data.optionalRemark4 = _optionalRemark4Controller.text; + widget.onNext(); + } + } + + void _showSnackBar(String message, {bool isError = false}) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar(SnackBar( + content: Text(message), + backgroundColor: isError ? Colors.red : null, + )); + } + } + + @override + Widget build(BuildContext context) { + return Form( + key: _formKey, + child: ListView( + padding: const EdgeInsets.all(24.0), + children: [ + Text("Additional Photos", + style: Theme.of(context).textTheme.headlineSmall), + const SizedBox(height: 24), + + // ADDED: The required "Sample Turbidity" photo moved from Step 2. + Text("Required Photo *", style: Theme.of(context).textTheme.titleLarge), + const SizedBox(height: 8), + _buildImagePicker('Sample Turbidity', 'SAMPLE_TURBIDITY', widget.data.sampleTurbidityImage, (file) => widget.data.sampleTurbidityImage = file, isRequired: true), + + const Divider(height: 32), + + Text("Optional Photos & Remarks", style: Theme.of(context).textTheme.titleLarge), + const SizedBox(height: 8), + _buildImagePicker('Optional Photo 1', 'OPTIONAL_1', widget.data.optionalImage1, (file) => widget.data.optionalImage1 = file, remarkController: _optionalRemark1Controller, isRequired: false), + _buildImagePicker('Optional Photo 2', 'OPTIONAL_2', widget.data.optionalImage2, (file) => widget.data.optionalImage2 = file, remarkController: _optionalRemark2Controller, isRequired: false), + _buildImagePicker('Optional Photo 3', 'OPTIONAL_3', widget.data.optionalImage3, (file) => widget.data.optionalImage3 = file, remarkController: _optionalRemark3Controller, isRequired: false), + _buildImagePicker('Optional Photo 4', 'OPTIONAL_4', widget.data.optionalImage4, (file) => widget.data.optionalImage4 = file, remarkController: _optionalRemark4Controller, isRequired: false), + const SizedBox(height: 24), + + const SizedBox(height: 32), + ElevatedButton( + onPressed: _goToNextStep, + style: ElevatedButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 16)), + child: const Text('Next'), + ), + ], + ), + ); + } + + Widget _buildImagePicker(String title, String imageInfo, File? imageFile, Function(File?) setImageCallback, {TextEditingController? remarkController, bool isRequired = false}) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 8.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(title + (isRequired ? ' *' : ''), style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w500)), + const SizedBox(height: 8), + if (imageFile != null) + Stack( + alignment: Alignment.topRight, + children: [ + ClipRRect(borderRadius: BorderRadius.circular(8.0), child: Image.file(imageFile, key: UniqueKey(), height: 150, width: double.infinity, fit: BoxFit.cover)), + Container( + margin: const EdgeInsets.all(4), + decoration: BoxDecoration(color: Colors.black.withOpacity(0.6), shape: BoxShape.circle), + child: IconButton( + visualDensity: VisualDensity.compact, + icon: const Icon(Icons.close, color: Colors.white, size: 20), + onPressed: () => setState(() => setImageCallback(null)), + ), + ), + ], + ) + else + Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + ElevatedButton.icon(onPressed: _isPickingImage ? null : () => _setImage(setImageCallback, ImageSource.camera, imageInfo, isRequired: isRequired), icon: const Icon(Icons.camera_alt), label: const Text("Camera")), + ElevatedButton.icon(onPressed: _isPickingImage ? null : () => _setImage(setImageCallback, ImageSource.gallery, imageInfo, isRequired: isRequired), icon: const Icon(Icons.photo_library), label: const Text("Gallery")), + ], + ), + if (remarkController != null) + Padding( + padding: const EdgeInsets.only(top: 8.0), + child: TextFormField( + controller: remarkController, + decoration: InputDecoration( + labelText: 'Remarks for $title', + hintText: 'Add an optional remark...', + border: const OutlineInputBorder(), + ), + ), + ), + ], + ), + ); + } +} \ No newline at end of file diff --git a/lib/screens/river/manual/widgets/river_in_situ_step_4_summary.dart b/lib/screens/river/manual/widgets/river_in_situ_step_5_summary.dart similarity index 82% rename from lib/screens/river/manual/widgets/river_in_situ_step_4_summary.dart rename to lib/screens/river/manual/widgets/river_in_situ_step_5_summary.dart index 4094d34..1dc18a0 100644 --- a/lib/screens/river/manual/widgets/river_in_situ_step_4_summary.dart +++ b/lib/screens/river/manual/widgets/river_in_situ_step_5_summary.dart @@ -1,19 +1,16 @@ -// lib/screens/river/manual/widgets/river_in_situ_step_4_summary.dart +// lib/screens/river/manual/widgets/river_in_situ_step_5_summary.dart import 'dart:io'; import 'package:flutter/material.dart'; -// CHANGED: Import river-specific data model import '../../../../models/river_in_situ_sampling_data.dart'; -// CHANGED: Renamed class for river context -class RiverInSituStep4Summary extends StatelessWidget { - // CHANGED: Expects river-specific data model +class RiverInSituStep5Summary extends StatelessWidget { final RiverInSituSamplingData data; final VoidCallback onSubmit; final bool isLoading; - const RiverInSituStep4Summary({ + const RiverInSituStep5Summary({ super.key, required this.data, required this.onSubmit, @@ -44,45 +41,45 @@ class RiverInSituStep4Summary extends StatelessWidget { _buildDetailRow("Sample ID Code:", data.sampleIdCode), const Divider(height: 20), _buildDetailRow("State:", data.selectedStateName), - _buildDetailRow("Category:", data.selectedCategoryName), - // CHANGED: Use river-specific station keys - _buildDetailRow("Station Code:", data.selectedStation?['r_man_station_code']?.toString()), - _buildDetailRow("Station Name:", data.selectedStation?['r_man_station_name']?.toString()), + _buildDetailRow( + "Station:", + "${data.selectedStation?['sampling_station_code']} | ${data.selectedStation?['sampling_river']} | ${data.selectedStation?['sampling_basin']}" + ), _buildDetailRow("Station Location:", "${data.stationLatitude}, ${data.stationLongitude}"), + // REMOVED: Weather and remarks moved to the next section. ], ), _buildSectionCard( context, - "Location & On-Site Info", + "Site Info & Required Photos", [ _buildDetailRow("Current Location:", "${data.currentLatitude}, ${data.currentLongitude}"), _buildDetailRow("Distance Difference:", data.distanceDifferenceInKm != null ? "${(data.distanceDifferenceInKm! * 1000).toStringAsFixed(0)} meters" : "N/A"), if (data.distanceDifferenceRemarks != null && data.distanceDifferenceRemarks!.isNotEmpty) _buildDetailRow("Distance Remarks:", data.distanceDifferenceRemarks), const Divider(height: 20), + + // ADDED: Display for Weather and Remarks. _buildDetailRow("Weather:", data.weather), - // CHANGED: Use river-specific fields - _buildDetailRow("Water Level:", data.waterLevel), - _buildDetailRow("River Condition:", data.riverCondition), _buildDetailRow("Event Remarks:", data.eventRemarks), _buildDetailRow("Lab Remarks:", data.labRemarks), + const Divider(height: 20), + + // UPDATED: Image cards reflect new names and data properties. + _buildImageCard("Background Station", data.backgroundStationImage), + _buildImageCard("Upstream River", data.upstreamRiverImage), + _buildImageCard("Downstream River", data.downstreamRiverImage), + _buildImageCard("Sample Turbidity", data.sampleTurbidityImage), + + // REMOVED: pH paper image card. ], ), _buildSectionCard( context, - "Attached Photos", + "Optional Photos & Remarks", [ - // CHANGED: Use river-specific image properties and labels - _buildImageCard("Left Bank View", data.leftBankViewImage), - _buildImageCard("Right Bank View", data.rightBankViewImage), - _buildImageCard("Filling Water into Bottle", data.waterFillingImage), - _buildImageCard("Water Color in Bottle", data.waterColorImage), - _buildImageCard("Examine Preservative (pH paper)", data.phPaperImage), - const Divider(height: 24), - Text("Optional Photos", style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold)), - const SizedBox(height: 8), _buildImageCard("Optional Photo 1", data.optionalImage1, remark: data.optionalRemark1), _buildImageCard("Optional Photo 2", data.optionalImage2, remark: data.optionalRemark2), _buildImageCard("Optional Photo 3", data.optionalImage3, remark: data.optionalRemark3), @@ -107,7 +104,6 @@ class RiverInSituStep4Summary extends StatelessWidget { _buildParameterListItem(context, icon: Icons.opacity, label: "Turbidity", unit: "NTU", value: data.turbidity?.toStringAsFixed(2)), _buildParameterListItem(context, icon: Icons.filter_alt_outlined, label: "TSS", unit: "mg/L", value: data.tss?.toStringAsFixed(2)), _buildParameterListItem(context, icon: Icons.battery_charging_full, label: "Battery", unit: "V", value: data.batteryVoltage?.toStringAsFixed(2)), - // NOTE: If you add river-specific parameters, display them here. ], ), @@ -155,6 +151,11 @@ class RiverInSituStep4Summary extends StatelessWidget { } Widget _buildDetailRow(String label, String? value) { + String displayValue = value?.replaceAll('null - null', '').replaceAll('null |', '').replaceAll('| null', '').trim() ?? 'N/A'; + if (displayValue.isEmpty || displayValue == "-") { + displayValue = 'N/A'; + } + return Padding( padding: const EdgeInsets.symmetric(vertical: 6.0), child: Row( @@ -167,7 +168,7 @@ class RiverInSituStep4Summary extends StatelessWidget { const SizedBox(width: 8), Expanded( flex: 3, - child: Text(value != null && value.isNotEmpty ? value : 'N/A', style: const TextStyle(fontSize: 16)), + child: Text(displayValue, style: const TextStyle(fontSize: 16)), ), ], ), @@ -175,7 +176,7 @@ class RiverInSituStep4Summary extends StatelessWidget { } Widget _buildParameterListItem(BuildContext context, {required IconData icon, required String label, required String unit, required String? value}) { - final bool isMissing = value == null; + final bool isMissing = value == null || value.contains('-999'); final String displayValue = isMissing ? 'N/A' : '$value ${unit}'.trim(); return ListTile( diff --git a/lib/services/local_storage_service.dart b/lib/services/local_storage_service.dart index f50571f..4be885d 100644 --- a/lib/services/local_storage_service.dart +++ b/lib/services/local_storage_service.dart @@ -9,7 +9,6 @@ import 'package:path/path.dart' as p; import '../models/tarball_data.dart'; import '../models/in_situ_sampling_data.dart'; -// ADDED: Import the river-specific data model import '../models/river_in_situ_sampling_data.dart'; /// A comprehensive service for handling all local data storage for offline submissions. @@ -19,18 +18,15 @@ class LocalStorageService { // Part 1: Public Storage Setup // ======================================================================= - /// Checks for and requests necessary storage permissions for public storage. Future _requestPermissions() async { var status = await Permission.manageExternalStorage.request(); return status.isGranted; } - /// Gets the public external storage directory and creates the base MMSV4 folder. Future _getPublicMMSV4Directory() async { if (await _requestPermissions()) { final Directory? externalDir = await getExternalStorageDirectory(); if (externalDir != null) { - // Navigates up from the app-specific folder to the public root final publicRootPath = externalDir.path.split('/Android/')[0]; final mmsv4Dir = Directory(p.join(publicRootPath, 'MMSV4')); if (!await mmsv4Dir.exists()) { @@ -47,7 +43,6 @@ class LocalStorageService { // Part 2: Tarball Specific Methods // ======================================================================= - /// Gets the base directory for storing tarball sampling data logs. Future _getTarballBaseDir() async { final mmsv4Dir = await _getPublicMMSV4Directory(); if (mmsv4Dir == null) return null; @@ -59,7 +54,6 @@ class LocalStorageService { return tarballDir; } - /// Saves a single tarball sampling record to a unique folder in public storage. Future saveTarballSamplingData(TarballSamplingData data) async { final baseDir = await _getTarballBaseDir(); if (baseDir == null) { @@ -104,7 +98,6 @@ class LocalStorageService { } } - /// Retrieves all saved tarball submission logs from public storage. Future>> getAllTarballLogs() async { final baseDir = await _getTarballBaseDir(); if (baseDir == null || !await baseDir.exists()) return []; @@ -119,7 +112,7 @@ class LocalStorageService { if (await jsonFile.exists()) { final content = await jsonFile.readAsString(); final data = jsonDecode(content) as Map; - data['logDirectory'] = entity.path; // Add directory path for resubmission/update + data['logDirectory'] = entity.path; logs.add(data); } } @@ -131,7 +124,6 @@ class LocalStorageService { } } - /// Updates an existing log file with new submission status. Future updateTarballLog(Map updatedLogData) async { final logDir = updatedLogData['logDirectory']; if (logDir == null) { @@ -156,7 +148,6 @@ class LocalStorageService { // Part 3: Marine In-Situ Specific Methods // ======================================================================= - /// Gets the base directory for storing marine in-situ sampling data logs. Future _getInSituBaseDir() async { final mmsv4Dir = await _getPublicMMSV4Directory(); if (mmsv4Dir == null) return null; @@ -168,7 +159,6 @@ class LocalStorageService { return inSituDir; } - /// Saves a single marine in-situ sampling record to a unique folder in public storage. Future saveInSituSamplingData(InSituSamplingData data) async { final baseDir = await _getInSituBaseDir(); if (baseDir == null) { @@ -212,7 +202,6 @@ class LocalStorageService { } } - /// Retrieves all saved marine in-situ submission logs from public storage. Future>> getAllInSituLogs() async { final baseDir = await _getInSituBaseDir(); if (baseDir == null || !await baseDir.exists()) return []; @@ -239,7 +228,6 @@ class LocalStorageService { } } - /// Updates an existing marine in-situ log file with new submission status. Future updateInSituLog(Map updatedLogData) async { final logDir = updatedLogData['logDirectory']; if (logDir == null) { @@ -260,15 +248,22 @@ class LocalStorageService { } // ======================================================================= - // ADDED: Part 4: River In-Situ Specific Methods + // UPDATED: Part 4: River In-Situ Specific Methods // ======================================================================= - /// Gets the base directory for storing river in-situ sampling data logs. - Future _getRiverInSituBaseDir() async { + /// Gets the base directory for storing river in-situ sampling data logs, organized by sampling type. + Future _getRiverInSituBaseDir(String? samplingType) async { final mmsv4Dir = await _getPublicMMSV4Directory(); if (mmsv4Dir == null) return null; - final inSituDir = Directory(p.join(mmsv4Dir.path, 'river', 'river_in_situ_sampling')); + String subfolderName; + if (samplingType == 'Schedule' || samplingType == 'Triennial') { + subfolderName = samplingType!; + } else { + subfolderName = 'Others'; + } + + final inSituDir = Directory(p.join(mmsv4Dir.path, 'river', 'river_in_situ_sampling', subfolderName)); if (!await inSituDir.exists()) { await inSituDir.create(recursive: true); } @@ -277,14 +272,15 @@ class LocalStorageService { /// Saves a single river in-situ sampling record to a unique folder in public storage. Future saveRiverInSituSamplingData(RiverInSituSamplingData data) async { - final baseDir = await _getRiverInSituBaseDir(); + // UPDATED: Pass the samplingType to get the correct subdirectory. + final baseDir = await _getRiverInSituBaseDir(data.samplingType); if (baseDir == null) { debugPrint("Could not get public storage directory for River In-Situ. Check permissions."); return null; } try { - final stationCode = data.selectedStation?['r_man_station_code'] ?? 'UNKNOWN_STATION'; + final stationCode = data.selectedStation?['sampling_station_code'] ?? 'UNKNOWN_STATION'; final timestamp = "${data.samplingDate}_${data.samplingTime?.replaceAll(':', '-')}"; final eventFolderName = "${stationCode}_$timestamp"; final eventDir = Directory(p.join(baseDir.path, eventFolderName)); @@ -319,23 +315,33 @@ class LocalStorageService { } } - /// Retrieves all saved river in-situ submission logs from public storage. + /// Retrieves all saved river in-situ submission logs from all subfolders. Future>> getAllRiverInSituLogs() async { - final baseDir = await _getRiverInSituBaseDir(); - if (baseDir == null || !await baseDir.exists()) return []; + final mmsv4Dir = await _getPublicMMSV4Directory(); + if (mmsv4Dir == null) return []; + + final topLevelDir = Directory(p.join(mmsv4Dir.path, 'river', 'river_in_situ_sampling')); + if (!await topLevelDir.exists()) return []; try { final List> logs = []; - final entities = baseDir.listSync(); + // List all subdirectories (e.g., 'Schedule', 'Triennial', 'Others') + final typeSubfolders = topLevelDir.listSync(); - for (var entity in entities) { - if (entity is Directory) { - final jsonFile = File(p.join(entity.path, 'data.json')); - if (await jsonFile.exists()) { - final content = await jsonFile.readAsString(); - final data = jsonDecode(content) as Map; - data['logDirectory'] = entity.path; - logs.add(data); + for (var typeSubfolder in typeSubfolders) { + if (typeSubfolder is Directory) { + // List all event directories inside the type subfolder + final eventFolders = typeSubfolder.listSync(); + for (var eventFolder in eventFolders) { + if (eventFolder is Directory) { + final jsonFile = File(p.join(eventFolder.path, 'data.json')); + if (await jsonFile.exists()) { + final content = await jsonFile.readAsString(); + final data = jsonDecode(content) as Map; + data['logDirectory'] = eventFolder.path; + logs.add(data); + } + } } } }