repair river module screen for in situ

This commit is contained in:
ALim Aidrus 2025-08-07 22:57:41 +08:00
parent d2b3ca2bb0
commit 70bf72feaf
9 changed files with 536 additions and 385 deletions

View File

@ -27,18 +27,15 @@ class RiverInSituSamplingData {
// --- Step 2: Site Info & Photos --- // --- Step 2: Site Info & Photos ---
String? weather; String? weather;
// CHANGED: Renamed for river context
String? waterLevel;
String? riverCondition;
String? eventRemarks; String? eventRemarks;
String? labRemarks; String? labRemarks;
// CHANGED: Image descriptions adapted for river context File? backgroundStationImage;
File? leftBankViewImage; File? upstreamRiverImage;
File? rightBankViewImage; File? downstreamRiverImage;
File? waterFillingImage;
File? waterColorImage; // --- Step 4: Additional Photos ---
File? phPaperImage; File? sampleTurbidityImage;
File? optionalImage1; File? optionalImage1;
String? optionalRemark1; String? optionalRemark1;
@ -64,12 +61,6 @@ class RiverInSituSamplingData {
double? tss; double? tss;
double? batteryVoltage; double? batteryVoltage;
// --- START: Add your river-specific parameters here ---
// Example:
// double? flowRate;
// --- END: Add your river-specific parameters here ---
// --- Post-Submission Status --- // --- Post-Submission Status ---
String? submissionStatus; String? submissionStatus;
String? submissionMessage; String? submissionMessage;
@ -80,6 +71,69 @@ class RiverInSituSamplingData {
this.samplingTime, 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<String, dynamic> 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<String, String> for the API form data. /// Converts the data model into a Map<String, String> for the API form data.
Map<String, String> toApiFormData() { Map<String, String> toApiFormData() {
final Map<String, String> map = {}; final Map<String, String> 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 // Step 1 Data
add('first_sampler_user_id', firstSamplerUserId); add('first_sampler_user_id', firstSamplerUserId);
add('r_man_second_sampler_id', secondSampler?['user_id']); add('r_man_second_sampler_id', secondSampler?['user_id']);
@ -108,10 +159,10 @@ class RiverInSituSamplingData {
// Step 2 Data // Step 2 Data
add('r_man_weather', weather); 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_event_remark', eventRemarks);
add('r_man_lab_remark', labRemarks); add('r_man_lab_remark', labRemarks);
// Step 4 Data
add('r_man_optional_photo_01_remarks', optionalRemark1); add('r_man_optional_photo_01_remarks', optionalRemark1);
add('r_man_optional_photo_02_remarks', optionalRemark2); add('r_man_optional_photo_02_remarks', optionalRemark2);
add('r_man_optional_photo_03_remarks', optionalRemark3); add('r_man_optional_photo_03_remarks', optionalRemark3);
@ -132,29 +183,22 @@ class RiverInSituSamplingData {
add('r_man_tss', tss); add('r_man_tss', tss);
add('r_man_battery_volt', batteryVoltage); add('r_man_battery_volt', batteryVoltage);
// --- START: Add your new river parameters to the form data map --- // Additional data for display or logging
// 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
add('first_sampler_name', firstSamplerName); add('first_sampler_name', firstSamplerName);
// Assuming river station keys are prefixed with 'r_man_' add('r_man_station_code', selectedStation?['sampling_station_code']);
add('r_man_station_code', selectedStation?['r_man_station_code']); add('r_man_station_name', selectedStation?['sampling_river']);
add('r_man_station_name', selectedStation?['r_man_station_name']);
return map; return map;
} }
/// Converts the image properties into a Map<String, File?> for the multipart API request. /// Converts the image properties into a Map<String, File?> for the multipart API request.
Map<String, File?> toApiImageFiles() { Map<String, File?> toApiImageFiles() {
// IMPORTANT: Keys adapted for river context.
return { return {
'r_man_left_bank_view': leftBankViewImage, 'r_man_background_station': backgroundStationImage,
'r_man_right_bank_view': rightBankViewImage, 'r_man_upstream_river': upstreamRiverImage,
'r_man_filling_water_into_sample_bottle': waterFillingImage, 'r_man_downstream_river': downstreamRiverImage,
'r_man_water_in_clear_glass_bottle': waterColorImage, 'r_man_sample_turbidity': sampleTurbidityImage,
'r_man_examine_preservative_ph_paper': phPaperImage,
'r_man_optional_photo_01': optionalImage1, 'r_man_optional_photo_01': optionalImage1,
'r_man_optional_photo_02': optionalImage2, 'r_man_optional_photo_02': optionalImage2,
'r_man_optional_photo_03': optionalImage3, 'r_man_optional_photo_03': optionalImage3,

View File

@ -1,17 +1,13 @@
// lib/screens/river/manual/widgets/data_status_log.dart
import 'dart:io'; import 'dart:io';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
// CHANGED: Import River-specific models and services
import '../../../../models/river_in_situ_sampling_data.dart'; import '../../../../models/river_in_situ_sampling_data.dart';
import '../../../../services/local_storage_service.dart'; import '../../../../services/local_storage_service.dart';
import '../../../../services/river_api_service.dart'; import '../../../../services/river_api_service.dart';
// A unified model to represent any type of submission log entry.
class SubmissionLogEntry { class SubmissionLogEntry {
final String type; // e.g., 'in-situ' final String type;
final String title; final String title;
final String stationCode; final String stationCode;
final DateTime submissionDateTime; final DateTime submissionDateTime;
@ -34,169 +30,134 @@ class SubmissionLogEntry {
}); });
} }
// CHANGED: Renamed widget for River context
class RiverDataStatusLog extends StatefulWidget { class RiverDataStatusLog extends StatefulWidget {
const RiverDataStatusLog({super.key}); const RiverDataStatusLog({super.key});
@override @override
// CHANGED: Renamed state class
State<RiverDataStatusLog> createState() => _RiverDataStatusLogState(); State<RiverDataStatusLog> createState() => _RiverDataStatusLogState();
} }
// CHANGED: Renamed state class
class _RiverDataStatusLogState extends State<RiverDataStatusLog> { class _RiverDataStatusLogState extends State<RiverDataStatusLog> {
final LocalStorageService _localStorageService = LocalStorageService(); final LocalStorageService _localStorageService = LocalStorageService();
// CHANGED: Use RiverApiService
final RiverApiService _riverApiService = RiverApiService(); final RiverApiService _riverApiService = RiverApiService();
Map<String, List<SubmissionLogEntry>> _groupedLogs = {}; // Raw data lists
Map<String, List<SubmissionLogEntry>> _filteredLogs = {}; List<SubmissionLogEntry> _scheduleLogs = [];
final Map<String, bool> _isCategoryExpanded = {}; List<SubmissionLogEntry> _triennialLogs = [];
List<SubmissionLogEntry> _otherLogs = [];
// Filtered lists for the UI
List<SubmissionLogEntry> _filteredScheduleLogs = [];
List<SubmissionLogEntry> _filteredTriennialLogs = [];
List<SubmissionLogEntry> _filteredOtherLogs = [];
// Per-category search controllers
final Map<String, TextEditingController> _searchControllers = {};
bool _isLoading = true; bool _isLoading = true;
final TextEditingController _searchController = TextEditingController();
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_loadAllLogs(); _loadAllLogs();
_searchController.addListener(_filterLogs);
} }
@override @override
void dispose() { void dispose() {
_searchController.dispose(); for (var controller in _searchControllers.values) {
controller.dispose();
}
super.dispose(); super.dispose();
} }
/// Loads logs for the river in-situ module.
Future<void> _loadAllLogs() async { Future<void> _loadAllLogs() async {
setState(() => _isLoading = true); setState(() => _isLoading = true);
// NOTE: Assumes a method exists in your local storage service for river logs. final riverLogs = await _localStorageService.getAllRiverInSituLogs();
final inSituLogs = await _localStorageService.getAllRiverInSituLogs();
final Map<String, List<SubmissionLogEntry>> tempGroupedLogs = {}; final List<SubmissionLogEntry> tempSchedule = [];
final List<SubmissionLogEntry> tempTriennial = [];
final List<SubmissionLogEntry> tempOthers = [];
// REMOVED: The entire block for fetching and mapping Tarball logs was here. for (var log in riverLogs) {
final entry = SubmissionLogEntry(
// Map In-Situ logs for River type: log['r_man_type'] as String? ?? 'Others',
final List<SubmissionLogEntry> inSituEntries = []; title: log['selectedStation']?['sampling_river'] ?? 'Unknown Station',
for (var log in inSituLogs) { stationCode: log['selectedStation']?['sampling_station_code'] ?? 'N/A',
final String dateStr = log['data_capture_date'] ?? log['sampling_date'] ?? ''; submissionDateTime: DateTime.tryParse('${log['r_man_date']} ${log['r_man_time']}') ?? DateTime.now(),
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(),
reportId: log['reportId']?.toString(), reportId: log['reportId']?.toString(),
status: log['submissionStatus'] ?? 'L1', status: log['submissionStatus'] ?? 'L1',
message: log['submissionMessage'] ?? 'No status message.', message: log['submissionMessage'] ?? 'No status message.',
rawData: log, rawData: log,
)); );
switch (entry.type) {
case 'Schedule':
tempSchedule.add(entry);
break;
case 'Triennial':
tempTriennial.add(entry);
break;
default:
tempOthers.add(entry);
break;
} }
if (inSituEntries.isNotEmpty) {
inSituEntries.sort((a, b) => b.submissionDateTime.compareTo(a.submissionDateTime));
tempGroupedLogs['In-Situ Sampling'] = inSituEntries;
} }
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) { if (mounted) {
setState(() { setState(() {
_groupedLogs = tempGroupedLogs; _scheduleLogs = tempSchedule;
_filteredLogs = tempGroupedLogs; _triennialLogs = tempTriennial;
_otherLogs = tempOthers;
_isLoading = false; _isLoading = false;
}); });
_filterLogs(); // Perform initial filter
} }
} }
void _filterLogs() { void _filterLogs() {
final query = _searchController.text.toLowerCase(); final scheduleQuery = _searchControllers['Schedule']?.text.toLowerCase() ?? '';
final Map<String, List<SubmissionLogEntry>> tempFiltered = {}; final triennialQuery = _searchControllers['Triennial']?.text.toLowerCase() ?? '';
final otherQuery = _searchControllers['Others']?.text.toLowerCase() ?? '';
_groupedLogs.forEach((category, logs) { setState(() {
final filtered = logs.where((log) { _filteredScheduleLogs = _scheduleLogs.where((log) => _logMatchesQuery(log, scheduleQuery)).toList();
_filteredTriennialLogs = _triennialLogs.where((log) => _logMatchesQuery(log, triennialQuery)).toList();
_filteredOtherLogs = _otherLogs.where((log) => _logMatchesQuery(log, otherQuery)).toList();
});
}
bool _logMatchesQuery(SubmissionLogEntry log, String query) {
if (query.isEmpty) return true;
return log.title.toLowerCase().contains(query) || return log.title.toLowerCase().contains(query) ||
log.stationCode.toLowerCase().contains(query) || log.stationCode.toLowerCase().contains(query) ||
(log.reportId?.toLowerCase() ?? '').contains(query); (log.reportId?.toLowerCase() ?? '').contains(query);
}).toList();
if (filtered.isNotEmpty) {
tempFiltered[category] = filtered;
}
});
setState(() {
_filteredLogs = tempFiltered;
});
} }
/// Main router for resubmitting data based on its type.
Future<void> _resubmitData(SubmissionLogEntry log) async { Future<void> _resubmitData(SubmissionLogEntry log) async {
setState(() => log.isResubmitting = true); 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);
}
}
}
// REMOVED: The entire _resubmitTarballData method was here.
/// Handles resubmission for River In-Situ data.
Future<void> _resubmitInSituData(SubmissionLogEntry log) async {
final logData = log.rawData; final logData = log.rawData;
final dataToResubmit = RiverInSituSamplingData.fromJson(logData);
// 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 Map<String, File?> imageFiles = {}; final Map<String, File?> 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]; final imagePath = logData[key];
if (imagePath is String && imagePath.isNotEmpty) { if (imagePath is String && imagePath.isNotEmpty) {
final file = File(imagePath); final file = File(imagePath);
@ -206,88 +167,92 @@ class _RiverDataStatusLogState extends State<RiverDataStatusLog> {
} }
} }
// CHANGED: Submit the data via the RiverApiService
final result = await _riverApiService.submitInSituSample( final result = await _riverApiService.submitInSituSample(
formData: dataToResubmit.toApiFormData(), formData: dataToResubmit.toApiFormData(),
imageFiles: imageFiles, imageFiles: imageFiles,
); );
logData['submissionStatus'] = result['status']; logData['submissionStatus'] = result['status'];
logData['submissionMessage'] = result['message']; logData['submissionMessage'] = result['message'];
logData['reportId'] = result['reportId']?.toString() ?? logData['reportId']; logData['reportId'] = result['reportId']?.toString() ?? logData['reportId'];
// NOTE: Assumes a method exists to update river logs.
await _localStorageService.updateRiverInSituLog(logData); await _localStorageService.updateRiverInSituLog(logData);
if (mounted) await _loadAllLogs(); if (mounted) await _loadAllLogs();
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final hasAnyLogs = _scheduleLogs.isNotEmpty || _triennialLogs.isNotEmpty || _otherLogs.isNotEmpty;
final hasFilteredLogs = _filteredScheduleLogs.isNotEmpty || _filteredTriennialLogs.isNotEmpty || _filteredOtherLogs.isNotEmpty;
return Scaffold( return Scaffold(
// CHANGED: Updated AppBar title
appBar: AppBar(title: const Text('River Data Status Log')), appBar: AppBar(title: const Text('River Data Status Log')),
body: Column( body: _isLoading
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()) ? const Center(child: CircularProgressIndicator())
: RefreshIndicator( : RefreshIndicator(
onRefresh: _loadAllLogs, onRefresh: _loadAllLogs,
child: _filteredLogs.isEmpty child: !hasAnyLogs
? Center(child: Text(_groupedLogs.isEmpty ? 'No submission logs found.' : 'No logs match your search.')) ? const Center(child: Text('No submission logs found.'))
: ListView( : ListView(
children: _filteredLogs.entries.map((entry) { padding: const EdgeInsets.all(8.0),
return _buildCategorySection(entry.key, entry.value); children: [
}).toList(), // 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<SubmissionLogEntry> logs) { Widget _buildCategorySection(String category, List<SubmissionLogEntry> logs) {
final bool isExpanded = _isCategoryExpanded[category] ?? false; // Calculate the height for the scrollable list.
final int itemCount = isExpanded ? logs.length : (logs.length > 5 ? 5 : logs.length); // 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( return Card(
margin: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 6.0), margin: const EdgeInsets.symmetric(vertical: 8.0),
child: Padding( child: Padding(
padding: const EdgeInsets.all(8.0), padding: const EdgeInsets.all(8.0),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text(category, style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold)), Text(category, style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold)),
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(), const Divider(),
ListView.builder( 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, shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(), itemCount: logs.length,
itemCount: itemCount,
itemBuilder: (context, index) { itemBuilder: (context, index) {
return _buildLogListItem(logs[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)'),
), ),
], ],
), ),
@ -309,20 +274,12 @@ class _RiverDataStatusLogState extends State<RiverDataStatusLog> {
subtitle: Text(subtitle), subtitle: Text(subtitle),
trailing: isFailed trailing: isFailed
? (log.isResubmitting ? (log.isResubmitting
? const SizedBox( ? const SizedBox(height: 24, width: 24, child: CircularProgressIndicator(strokeWidth: 3))
height: 24, : IconButton(icon: const Icon(Icons.sync, color: Colors.blue), tooltip: 'Resubmit', onPressed: () => _resubmitData(log)))
width: 24,
child: CircularProgressIndicator(strokeWidth: 3),
)
: IconButton(
icon: const Icon(Icons.sync, color: Colors.blue),
tooltip: 'Resubmit',
onPressed: () => _resubmitData(log),
))
: null, : null,
children: [ children: [
Padding( Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0), padding: const EdgeInsets.fromLTRB(16, 8, 16, 16),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [

View File

@ -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_1_sampling_info.dart';
import 'widgets/river_in_situ_step_2_site_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_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 { class RiverInSituSamplingScreen extends StatefulWidget {
const RiverInSituSamplingScreen({super.key}); const RiverInSituSamplingScreen({super.key});
@ -27,10 +26,7 @@ class _RiverInSituSamplingScreenState extends State<RiverInSituSamplingScreen> {
late RiverInSituSamplingData _data; late RiverInSituSamplingData _data;
// A single instance of the service to be used by all child widgets.
final RiverInSituSamplingService _samplingService = RiverInSituSamplingService(); final RiverInSituSamplingService _samplingService = RiverInSituSamplingService();
// Service for saving submission logs locally.
final LocalStorageService _localStorageService = LocalStorageService(); final LocalStorageService _localStorageService = LocalStorageService();
int _currentPage = 0; int _currentPage = 0;
@ -39,8 +35,6 @@ class _RiverInSituSamplingScreenState extends State<RiverInSituSamplingScreen> {
@override @override
void initState() { void initState() {
super.initState(); super.initState();
// Creates a NEW data object with the CURRENT date and time
// every time the user starts a new sampling.
_data = RiverInSituSamplingData( _data = RiverInSituSamplingData(
samplingDate: DateFormat('yyyy-MM-dd').format(DateTime.now()), samplingDate: DateFormat('yyyy-MM-dd').format(DateTime.now()),
samplingTime: DateFormat('HH:mm:ss').format(DateTime.now()), samplingTime: DateFormat('HH:mm:ss').format(DateTime.now()),
@ -54,9 +48,8 @@ class _RiverInSituSamplingScreenState extends State<RiverInSituSamplingScreen> {
super.dispose(); super.dispose();
} }
/// Navigates to the next page in the form.
void _nextPage() { void _nextPage() {
if (_currentPage < 3) { if (_currentPage < 4) {
_pageController.nextPage( _pageController.nextPage(
duration: const Duration(milliseconds: 300), duration: const Duration(milliseconds: 300),
curve: Curves.easeInOut, curve: Curves.easeInOut,
@ -64,7 +57,6 @@ class _RiverInSituSamplingScreenState extends State<RiverInSituSamplingScreen> {
} }
} }
/// Navigates to the previous page in the form.
void _previousPage() { void _previousPage() {
if (_currentPage > 0) { if (_currentPage > 0) {
_pageController.previousPage( _pageController.previousPage(
@ -74,7 +66,6 @@ class _RiverInSituSamplingScreenState extends State<RiverInSituSamplingScreen> {
} }
} }
/// Handles the final submission process.
Future<void> _submitForm() async { Future<void> _submitForm() async {
setState(() => _isLoading = true); setState(() => _isLoading = true);
@ -86,7 +77,6 @@ class _RiverInSituSamplingScreenState extends State<RiverInSituSamplingScreen> {
_data.submissionMessage = result['message']; _data.submissionMessage = result['message'];
_data.reportId = result['reportId']?.toString(); _data.reportId = result['reportId']?.toString();
// Save a log of the submission locally using the river-specific method.
await _localStorageService.saveRiverInSituSamplingData(_data); await _localStorageService.saveRiverInSituSamplingData(_data);
setState(() => _isLoading = false); setState(() => _isLoading = false);
@ -100,19 +90,18 @@ class _RiverInSituSamplingScreenState extends State<RiverInSituSamplingScreen> {
SnackBar(content: Text(message), backgroundColor: color, duration: const Duration(seconds: 4)), SnackBar(content: Text(message), backgroundColor: color, duration: const Duration(seconds: 4)),
); );
if (result['status'] == 'L3') { // 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); Navigator.of(context).popUntil((route) => route.isFirst);
} }
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
// Use Provider.value to provide the existing river service instance to all child widgets.
return Provider.value( return Provider.value(
value: _samplingService, value: _samplingService,
child: Scaffold( child: Scaffold(
appBar: AppBar( appBar: AppBar(
title: Text('In-Situ Sampling (${_currentPage + 1}/4)'), title: Text('In-Situ Sampling (${_currentPage + 1}/5)'),
leading: _currentPage > 0 leading: _currentPage > 0
? IconButton( ? IconButton(
icon: const Icon(Icons.arrow_back), icon: const Icon(Icons.arrow_back),
@ -129,11 +118,11 @@ class _RiverInSituSamplingScreenState extends State<RiverInSituSamplingScreen> {
}); });
}, },
children: [ children: [
// Each step is a separate river-specific widget.
RiverInSituStep1SamplingInfo(data: _data, onNext: _nextPage), RiverInSituStep1SamplingInfo(data: _data, onNext: _nextPage),
RiverInSituStep2SiteInfo(data: _data, onNext: _nextPage), RiverInSituStep2SiteInfo(data: _data, onNext: _nextPage),
RiverInSituStep3DataCapture(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),
], ],
), ),
), ),

View File

@ -36,10 +36,12 @@ class _RiverInSituStep1SamplingInfoState extends State<RiverInSituStep1SamplingI
late final TextEditingController _stationLonController; late final TextEditingController _stationLonController;
late final TextEditingController _currentLatController; late final TextEditingController _currentLatController;
late final TextEditingController _currentLonController; late final TextEditingController _currentLonController;
// REMOVED: Controllers for weather and remarks.
List<String> _statesList = []; List<String> _statesList = [];
List<Map<String, dynamic>> _stationsForState = []; List<Map<String, dynamic>> _stationsForState = [];
final List<String> _samplingTypes = ['Schedule', 'Ad-Hoc', 'Complaint']; final List<String> _samplingTypes = ['Schedule', 'Triennial'];
// REMOVED: Weather options list.
@override @override
void initState() { void initState() {
@ -58,6 +60,7 @@ class _RiverInSituStep1SamplingInfoState extends State<RiverInSituStep1SamplingI
_stationLonController.dispose(); _stationLonController.dispose();
_currentLatController.dispose(); _currentLatController.dispose();
_currentLonController.dispose(); _currentLonController.dispose();
// REMOVED: Dispose controllers for remarks.
super.dispose(); super.dispose();
} }
@ -70,6 +73,7 @@ class _RiverInSituStep1SamplingInfoState extends State<RiverInSituStep1SamplingI
_stationLonController = TextEditingController(text: widget.data.stationLongitude); _stationLonController = TextEditingController(text: widget.data.stationLongitude);
_currentLatController = TextEditingController(text: widget.data.currentLatitude); _currentLatController = TextEditingController(text: widget.data.currentLatitude);
_currentLonController = TextEditingController(text: widget.data.currentLongitude); _currentLonController = TextEditingController(text: widget.data.currentLongitude);
// REMOVED: Initialize controllers for remarks.
} }
void _initializeForm() { void _initializeForm() {
@ -392,7 +396,11 @@ class _RiverInSituStep1SamplingInfoState extends State<RiverInSituStep1SamplingI
icon: _isLoadingLocation ? const SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2)) : const Icon(Icons.location_searching), icon: _isLoadingLocation ? const SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2)) : const Icon(Icons.location_searching),
label: const Text("Get Current Location"), label: const Text("Get Current Location"),
), ),
const SizedBox(height: 32), const SizedBox(height: 16),
// REMOVED: On-Site Information section (Weather, Remarks).
const SizedBox(height: 16),
ElevatedButton( ElevatedButton(
onPressed: _goToNextStep, onPressed: _goToNextStep,
style: ElevatedButton.styleFrom(padding: const EdgeInsets.symmetric(vertical: 16)), style: ElevatedButton.styleFrom(padding: const EdgeInsets.symmetric(vertical: 16)),

View File

@ -1,3 +1,5 @@
// lib/screens/river/manual/widgets/river_in_situ_step_2_site_info.dart
import 'dart:io'; import 'dart:io';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:image_picker/image_picker.dart'; import 'package:image_picker/image_picker.dart';
@ -26,33 +28,19 @@ class _RiverInSituStep2SiteInfoState extends State<RiverInSituStep2SiteInfo> {
late final TextEditingController _eventRemarksController; late final TextEditingController _eventRemarksController;
late final TextEditingController _labRemarksController; late final TextEditingController _labRemarksController;
late final TextEditingController _optionalRemark1Controller; final List<String> _weatherOptions = ['Clear', 'Rainy', 'Cloudy'];
late final TextEditingController _optionalRemark2Controller;
late final TextEditingController _optionalRemark3Controller;
late final TextEditingController _optionalRemark4Controller;
final List<String> _weatherOptions = ['Clear', 'Rainy', 'Cloudy', 'Windy', 'Sunny', 'Drizzle'];
// MODIFIED: Removed _waterLevelOptions and _riverConditionOptions
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_eventRemarksController = TextEditingController(text: widget.data.eventRemarks); _eventRemarksController = TextEditingController(text: widget.data.eventRemarks);
_labRemarksController = TextEditingController(text: widget.data.labRemarks); _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 @override
void dispose() { void dispose() {
_eventRemarksController.dispose(); _eventRemarksController.dispose();
_labRemarksController.dispose(); _labRemarksController.dispose();
_optionalRemark1Controller.dispose();
_optionalRemark2Controller.dispose();
_optionalRemark3Controller.dispose();
_optionalRemark4Controller.dispose();
super.dispose(); super.dispose();
} }
@ -79,20 +67,16 @@ class _RiverInSituStep2SiteInfoState extends State<RiverInSituStep2SiteInfo> {
return; return;
} }
if (widget.data.leftBankViewImage == null || _formKey.currentState!.save();
widget.data.rightBankViewImage == null ||
widget.data.waterFillingImage == null || // UPDATED: Validation now checks for 3 required photos.
widget.data.waterColorImage == null || if (widget.data.backgroundStationImage == null ||
widget.data.phPaperImage == null) { widget.data.upstreamRiverImage == null ||
_showSnackBar('Please attach all 5 required photos before proceeding.', isError: true); widget.data.downstreamRiverImage == null) {
_showSnackBar('Please attach all 3 required photos before proceeding.', isError: true);
return; 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(); widget.onNext();
} }
@ -112,39 +96,17 @@ class _RiverInSituStep2SiteInfoState extends State<RiverInSituStep2SiteInfo> {
child: ListView( child: ListView(
padding: const EdgeInsets.all(24.0), padding: const EdgeInsets.all(24.0),
children: [ children: [
Text("On-Site Information", style: Theme.of(context).textTheme.headlineSmall), Text("On-Site Information", style: Theme.of(context).textTheme.titleLarge),
const SizedBox(height: 24), const SizedBox(height: 16),
DropdownButtonFormField<String>( DropdownButtonFormField<String>(
value: widget.data.weather, value: widget.data.weather,
items: _weatherOptions.map((item) => DropdownMenuItem(value: item, child: Text(item))).toList(), items: _weatherOptions.map((item) => DropdownMenuItem(value: item, child: Text(item))).toList(),
onChanged: (value) => setState(() => widget.data.weather = value), onChanged: (value) => setState(() => widget.data.weather = value),
decoration: const InputDecoration(labelText: 'Weather *'), decoration: const InputDecoration(labelText: 'Weather *'),
validator: (value) => value == null ? 'Weather is required' : null, validator: (value) => value == null ? 'Weather is required' : null,
onSaved: (value) => widget.data.weather = value,
), ),
const SizedBox(height: 16), 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( TextFormField(
controller: _eventRemarksController, controller: _eventRemarksController,
decoration: const InputDecoration(labelText: 'Event Remarks (Optional)', hintText: 'e.g., unusual smells, colors, etc.'), decoration: const InputDecoration(labelText: 'Event Remarks (Optional)', hintText: 'e.g., unusual smells, colors, etc.'),
@ -158,6 +120,20 @@ class _RiverInSituStep2SiteInfoState extends State<RiverInSituStep2SiteInfo> {
onSaved: (value) => widget.data.labRemarks = value, onSaved: (value) => widget.data.labRemarks = value,
maxLines: 3, 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), const SizedBox(height: 32),
ElevatedButton( ElevatedButton(
onPressed: _goToNextStep, onPressed: _goToNextStep,

View File

@ -8,11 +8,12 @@ import 'package:usb_serial/usb_serial.dart';
import '../../../../models/river_in_situ_sampling_data.dart'; import '../../../../models/river_in_situ_sampling_data.dart';
import '../../../../services/river_in_situ_sampling_service.dart'; import '../../../../services/river_in_situ_sampling_service.dart';
import '../../../../bluetooth/bluetooth_manager.dart'; // For connection state enum import '../../../../bluetooth/bluetooth_manager.dart';
import '../../../../serial/serial_manager.dart'; // For connection state enum import '../../../../serial/serial_manager.dart';
import '../../../../bluetooth/widgets/bluetooth_device_list_dialog.dart'; import '../../../../bluetooth/widgets/bluetooth_device_list_dialog.dart';
import '../../../../serial/widget/serial_port_list_dialog.dart'; import '../../../../serial/widget/serial_port_list_dialog.dart';
// UPDATED: Class name changed from RiverInSituStep2DataCapture to RiverInSituStep3DataCapture
class RiverInSituStep3DataCapture extends StatefulWidget { class RiverInSituStep3DataCapture extends StatefulWidget {
final RiverInSituSamplingData data; final RiverInSituSamplingData data;
final VoidCallback onNext; final VoidCallback onNext;
@ -24,9 +25,11 @@ class RiverInSituStep3DataCapture extends StatefulWidget {
}); });
@override @override
// UPDATED: State class reference
State<RiverInSituStep3DataCapture> createState() => _RiverInSituStep3DataCaptureState(); State<RiverInSituStep3DataCapture> createState() => _RiverInSituStep3DataCaptureState();
} }
// UPDATED: State class name
class _RiverInSituStep3DataCaptureState extends State<RiverInSituStep3DataCapture> { class _RiverInSituStep3DataCaptureState extends State<RiverInSituStep3DataCapture> {
final _formKey = GlobalKey<FormState>(); final _formKey = GlobalKey<FormState>();
bool _isLoading = false; bool _isLoading = false;
@ -48,7 +51,6 @@ class _RiverInSituStep3DataCaptureState extends State<RiverInSituStep3DataCaptur
final _turbidityController = TextEditingController(); final _turbidityController = TextEditingController();
final _tssController = TextEditingController(); final _tssController = TextEditingController();
final _batteryController = TextEditingController(); final _batteryController = TextEditingController();
// NOTE: If you add river-specific parameters, add their controllers here.
@override @override
void initState() { void initState() {
@ -81,7 +83,6 @@ class _RiverInSituStep3DataCaptureState extends State<RiverInSituStep3DataCaptur
widget.data.turbidity ??= -999.0; widget.data.turbidity ??= -999.0;
widget.data.tss ??= -999.0; widget.data.tss ??= -999.0;
widget.data.batteryVoltage ??= -999.0; widget.data.batteryVoltage ??= -999.0;
// NOTE: Initialize your river-specific parameters here (e.g., widget.data.flowRate ??= -999.0;)
_oxyConcController.text = widget.data.oxygenConcentration!.toString(); _oxyConcController.text = widget.data.oxygenConcentration!.toString();
_oxySatController.text = widget.data.oxygenSaturation!.toString(); _oxySatController.text = widget.data.oxygenSaturation!.toString();
@ -106,7 +107,6 @@ class _RiverInSituStep3DataCaptureState extends State<RiverInSituStep3DataCaptur
{'icon': Icons.opacity, 'label': 'Turbidity', 'unit': 'NTU', 'controller': _turbidityController}, {'icon': Icons.opacity, 'label': 'Turbidity', 'unit': 'NTU', 'controller': _turbidityController},
{'icon': Icons.filter_alt_outlined, 'label': 'TSS', 'unit': 'mg/L', 'controller': _tssController}, {'icon': Icons.filter_alt_outlined, 'label': 'TSS', 'unit': 'mg/L', 'controller': _tssController},
{'icon': Icons.battery_charging_full, 'label': 'Battery', 'unit': 'V', 'controller': _batteryController}, {'icon': Icons.battery_charging_full, 'label': 'Battery', 'unit': 'V', 'controller': _batteryController},
// NOTE: If you add river-specific parameters, add them to this list.
]); ]);
} }
} }
@ -125,27 +125,21 @@ class _RiverInSituStep3DataCaptureState extends State<RiverInSituStep3DataCaptur
_turbidityController.dispose(); _turbidityController.dispose();
_tssController.dispose(); _tssController.dispose();
_batteryController.dispose(); _batteryController.dispose();
// NOTE: Dispose your river-specific controllers here.
} }
Future<void> _handleConnectionAttempt(String type) async { Future<void> _handleConnectionAttempt(String type) async {
final service = context.read<RiverInSituSamplingService>(); final service = context.read<RiverInSituSamplingService>();
final bool hasPermissions = await service.requestDevicePermissions(); final bool hasPermissions = await service.requestDevicePermissions();
if (!hasPermissions && mounted) { if (!hasPermissions && mounted) {
_showSnackBar("Bluetooth & Location permissions are required to connect.", isError: true); _showSnackBar("Bluetooth & Location permissions are required to connect.", isError: true);
return; return;
} }
_disconnectFromAll(); _disconnectFromAll();
await Future.delayed(const Duration(milliseconds: 250)); await Future.delayed(const Duration(milliseconds: 250));
final bool connectionSuccess = await _connectToDevice(type); final bool connectionSuccess = await _connectToDevice(type);
if (connectionSuccess && mounted) { if (connectionSuccess && mounted) {
_dataSubscription?.cancel(); _dataSubscription?.cancel();
final stream = type == 'bluetooth' ? service.bluetoothDataStream : service.serialDataStream; final stream = type == 'bluetooth' ? service.bluetoothDataStream : service.serialDataStream;
_dataSubscription = stream.listen((readings) { _dataSubscription = stream.listen((readings) {
if (mounted) { if (mounted) {
_updateTextFields(readings); _updateTextFields(readings);
@ -158,7 +152,6 @@ class _RiverInSituStep3DataCaptureState extends State<RiverInSituStep3DataCaptur
setState(() => _isLoading = true); setState(() => _isLoading = true);
final service = context.read<RiverInSituSamplingService>(); final service = context.read<RiverInSituSamplingService>();
bool success = false; bool success = false;
try { try {
if (type == 'bluetooth') { if (type == 'bluetooth') {
final devices = await service.getPairedBluetoothDevices(); final devices = await service.getPairedBluetoothDevices();
@ -255,22 +248,14 @@ class _RiverInSituStep3DataCaptureState extends State<RiverInSituStep3DataCaptur
title: const Text('Data Collection Active'), title: const Text('Data Collection Active'),
content: const Text('Please stop the live data collection before proceeding.'), content: const Text('Please stop the live data collection before proceeding.'),
actions: <Widget>[ actions: <Widget>[
TextButton( TextButton(child: const Text('OK'), onPressed: () => Navigator.of(context).pop())
child: const Text('OK'), ]);
onPressed: () { });
Navigator.of(context).pop();
},
),
],
);
},
);
return; return;
} }
if (_formKey.currentState!.validate()){ if (_formKey.currentState!.validate()){
_formKey.currentState!.save(); _formKey.currentState!.save();
try { try {
const defaultValue = -999.0; const defaultValue = -999.0;
widget.data.temperature = double.tryParse(_tempController.text) ?? defaultValue; widget.data.temperature = double.tryParse(_tempController.text) ?? defaultValue;
@ -287,7 +272,6 @@ class _RiverInSituStep3DataCaptureState extends State<RiverInSituStep3DataCaptur
_showSnackBar("Could not save parameters due to a data format error.", isError: true); _showSnackBar("Could not save parameters due to a data format error.", isError: true);
return; return;
} }
widget.onNext(); widget.onNext();
} }
} }
@ -321,7 +305,6 @@ class _RiverInSituStep3DataCaptureState extends State<RiverInSituStep3DataCaptur
return Form( return Form(
key: _formKey, key: _formKey,
child: ListView( child: ListView(
// CORRECTED: Scrolling is enabled by removing the physics property.
padding: const EdgeInsets.all(24.0), padding: const EdgeInsets.all(24.0),
children: [ children: [
Text("Data Capture", style: Theme.of(context).textTheme.headlineSmall), Text("Data Capture", style: Theme.of(context).textTheme.headlineSmall),
@ -370,10 +353,14 @@ class _RiverInSituStep3DataCaptureState extends State<RiverInSituStep3DataCaptur
valueListenable: service.sondeId, valueListenable: service.sondeId,
builder: (context, sondeId, child) { builder: (context, sondeId, child) {
final newSondeId = sondeId ?? ''; final newSondeId = sondeId ?? '';
if (_sondeIdController.text != newSondeId) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted && _sondeIdController.text != newSondeId) {
_sondeIdController.text = newSondeId; _sondeIdController.text = newSondeId;
widget.data.sondeId = newSondeId; widget.data.sondeId = newSondeId;
} }
});
return TextFormField( return TextFormField(
controller: _sondeIdController, controller: _sondeIdController,
decoration: const InputDecoration( decoration: const InputDecoration(
@ -447,14 +434,12 @@ class _RiverInSituStep3DataCaptureState extends State<RiverInSituStep3DataCaptur
Widget _buildConnectionCard({required String type, required dynamic connectionState, String? deviceName}) { Widget _buildConnectionCard({required String type, required dynamic connectionState, String? deviceName}) {
final isConnected = connectionState == BluetoothConnectionState.connected || connectionState == SerialConnectionState.connected; final isConnected = connectionState == BluetoothConnectionState.connected || connectionState == SerialConnectionState.connected;
final isConnecting = connectionState == BluetoothConnectionState.connecting || connectionState == SerialConnectionState.connecting; final isConnecting = connectionState == BluetoothConnectionState.connecting || connectionState == SerialConnectionState.connecting;
Color statusColor = isConnected ? Colors.green : Colors.red; Color statusColor = isConnected ? Colors.green : Colors.red;
String statusText = isConnected ? 'Connected to ${deviceName ?? 'device'}' : 'Disconnected'; String statusText = isConnected ? 'Connected to ${deviceName ?? 'device'}' : 'Disconnected';
if (isConnecting) { if (isConnecting) {
statusColor = Colors.orange; statusColor = Colors.orange;
statusText = 'Connecting...'; statusText = 'Connecting...';
} }
return Card( return Card(
elevation: 2, elevation: 2,
child: Padding( child: Padding(

View File

@ -0,0 +1,185 @@
// lib/screens/river/manual/widgets/river_in_situ_step_4_additional_info.dart
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:image_picker/image_picker.dart';
import 'package:provider/provider.dart';
import '../../../../models/river_in_situ_sampling_data.dart';
import '../../../../services/river_in_situ_sampling_service.dart';
class RiverInSituStep4AdditionalInfo extends StatefulWidget {
final RiverInSituSamplingData data;
final VoidCallback onNext;
const RiverInSituStep4AdditionalInfo({
super.key,
required this.data,
required this.onNext,
});
@override
State<RiverInSituStep4AdditionalInfo> createState() =>
_RiverInSituStep4AdditionalInfoState();
}
class _RiverInSituStep4AdditionalInfoState
extends State<RiverInSituStep4AdditionalInfo> {
final _formKey = GlobalKey<FormState>();
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<RiverInSituSamplingService>(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(),
),
),
),
],
),
);
}
}

View File

@ -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 'dart:io';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
// CHANGED: Import river-specific data model
import '../../../../models/river_in_situ_sampling_data.dart'; import '../../../../models/river_in_situ_sampling_data.dart';
// CHANGED: Renamed class for river context class RiverInSituStep5Summary extends StatelessWidget {
class RiverInSituStep4Summary extends StatelessWidget {
// CHANGED: Expects river-specific data model
final RiverInSituSamplingData data; final RiverInSituSamplingData data;
final VoidCallback onSubmit; final VoidCallback onSubmit;
final bool isLoading; final bool isLoading;
const RiverInSituStep4Summary({ const RiverInSituStep5Summary({
super.key, super.key,
required this.data, required this.data,
required this.onSubmit, required this.onSubmit,
@ -44,45 +41,45 @@ class RiverInSituStep4Summary extends StatelessWidget {
_buildDetailRow("Sample ID Code:", data.sampleIdCode), _buildDetailRow("Sample ID Code:", data.sampleIdCode),
const Divider(height: 20), const Divider(height: 20),
_buildDetailRow("State:", data.selectedStateName), _buildDetailRow("State:", data.selectedStateName),
_buildDetailRow("Category:", data.selectedCategoryName), _buildDetailRow(
// CHANGED: Use river-specific station keys "Station:",
_buildDetailRow("Station Code:", data.selectedStation?['r_man_station_code']?.toString()), "${data.selectedStation?['sampling_station_code']} | ${data.selectedStation?['sampling_river']} | ${data.selectedStation?['sampling_basin']}"
_buildDetailRow("Station Name:", data.selectedStation?['r_man_station_name']?.toString()), ),
_buildDetailRow("Station Location:", "${data.stationLatitude}, ${data.stationLongitude}"), _buildDetailRow("Station Location:", "${data.stationLatitude}, ${data.stationLongitude}"),
// REMOVED: Weather and remarks moved to the next section.
], ],
), ),
_buildSectionCard( _buildSectionCard(
context, context,
"Location & On-Site Info", "Site Info & Required Photos",
[ [
_buildDetailRow("Current Location:", "${data.currentLatitude}, ${data.currentLongitude}"), _buildDetailRow("Current Location:", "${data.currentLatitude}, ${data.currentLongitude}"),
_buildDetailRow("Distance Difference:", data.distanceDifferenceInKm != null ? "${(data.distanceDifferenceInKm! * 1000).toStringAsFixed(0)} meters" : "N/A"), _buildDetailRow("Distance Difference:", data.distanceDifferenceInKm != null ? "${(data.distanceDifferenceInKm! * 1000).toStringAsFixed(0)} meters" : "N/A"),
if (data.distanceDifferenceRemarks != null && data.distanceDifferenceRemarks!.isNotEmpty) if (data.distanceDifferenceRemarks != null && data.distanceDifferenceRemarks!.isNotEmpty)
_buildDetailRow("Distance Remarks:", data.distanceDifferenceRemarks), _buildDetailRow("Distance Remarks:", data.distanceDifferenceRemarks),
const Divider(height: 20), const Divider(height: 20),
// ADDED: Display for Weather and Remarks.
_buildDetailRow("Weather:", data.weather), _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("Event Remarks:", data.eventRemarks),
_buildDetailRow("Lab Remarks:", data.labRemarks), _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( _buildSectionCard(
context, 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 1", data.optionalImage1, remark: data.optionalRemark1),
_buildImageCard("Optional Photo 2", data.optionalImage2, remark: data.optionalRemark2), _buildImageCard("Optional Photo 2", data.optionalImage2, remark: data.optionalRemark2),
_buildImageCard("Optional Photo 3", data.optionalImage3, remark: data.optionalRemark3), _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.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.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)), _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) { 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( return Padding(
padding: const EdgeInsets.symmetric(vertical: 6.0), padding: const EdgeInsets.symmetric(vertical: 6.0),
child: Row( child: Row(
@ -167,7 +168,7 @@ class RiverInSituStep4Summary extends StatelessWidget {
const SizedBox(width: 8), const SizedBox(width: 8),
Expanded( Expanded(
flex: 3, 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}) { 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(); final String displayValue = isMissing ? 'N/A' : '$value ${unit}'.trim();
return ListTile( return ListTile(

View File

@ -9,7 +9,6 @@ import 'package:path/path.dart' as p;
import '../models/tarball_data.dart'; import '../models/tarball_data.dart';
import '../models/in_situ_sampling_data.dart'; import '../models/in_situ_sampling_data.dart';
// ADDED: Import the river-specific data model
import '../models/river_in_situ_sampling_data.dart'; import '../models/river_in_situ_sampling_data.dart';
/// A comprehensive service for handling all local data storage for offline submissions. /// A comprehensive service for handling all local data storage for offline submissions.
@ -19,18 +18,15 @@ class LocalStorageService {
// Part 1: Public Storage Setup // Part 1: Public Storage Setup
// ======================================================================= // =======================================================================
/// Checks for and requests necessary storage permissions for public storage.
Future<bool> _requestPermissions() async { Future<bool> _requestPermissions() async {
var status = await Permission.manageExternalStorage.request(); var status = await Permission.manageExternalStorage.request();
return status.isGranted; return status.isGranted;
} }
/// Gets the public external storage directory and creates the base MMSV4 folder.
Future<Directory?> _getPublicMMSV4Directory() async { Future<Directory?> _getPublicMMSV4Directory() async {
if (await _requestPermissions()) { if (await _requestPermissions()) {
final Directory? externalDir = await getExternalStorageDirectory(); final Directory? externalDir = await getExternalStorageDirectory();
if (externalDir != null) { if (externalDir != null) {
// Navigates up from the app-specific folder to the public root
final publicRootPath = externalDir.path.split('/Android/')[0]; final publicRootPath = externalDir.path.split('/Android/')[0];
final mmsv4Dir = Directory(p.join(publicRootPath, 'MMSV4')); final mmsv4Dir = Directory(p.join(publicRootPath, 'MMSV4'));
if (!await mmsv4Dir.exists()) { if (!await mmsv4Dir.exists()) {
@ -47,7 +43,6 @@ class LocalStorageService {
// Part 2: Tarball Specific Methods // Part 2: Tarball Specific Methods
// ======================================================================= // =======================================================================
/// Gets the base directory for storing tarball sampling data logs.
Future<Directory?> _getTarballBaseDir() async { Future<Directory?> _getTarballBaseDir() async {
final mmsv4Dir = await _getPublicMMSV4Directory(); final mmsv4Dir = await _getPublicMMSV4Directory();
if (mmsv4Dir == null) return null; if (mmsv4Dir == null) return null;
@ -59,7 +54,6 @@ class LocalStorageService {
return tarballDir; return tarballDir;
} }
/// Saves a single tarball sampling record to a unique folder in public storage.
Future<String?> saveTarballSamplingData(TarballSamplingData data) async { Future<String?> saveTarballSamplingData(TarballSamplingData data) async {
final baseDir = await _getTarballBaseDir(); final baseDir = await _getTarballBaseDir();
if (baseDir == null) { if (baseDir == null) {
@ -104,7 +98,6 @@ class LocalStorageService {
} }
} }
/// Retrieves all saved tarball submission logs from public storage.
Future<List<Map<String, dynamic>>> getAllTarballLogs() async { Future<List<Map<String, dynamic>>> getAllTarballLogs() async {
final baseDir = await _getTarballBaseDir(); final baseDir = await _getTarballBaseDir();
if (baseDir == null || !await baseDir.exists()) return []; if (baseDir == null || !await baseDir.exists()) return [];
@ -119,7 +112,7 @@ class LocalStorageService {
if (await jsonFile.exists()) { if (await jsonFile.exists()) {
final content = await jsonFile.readAsString(); final content = await jsonFile.readAsString();
final data = jsonDecode(content) as Map<String, dynamic>; final data = jsonDecode(content) as Map<String, dynamic>;
data['logDirectory'] = entity.path; // Add directory path for resubmission/update data['logDirectory'] = entity.path;
logs.add(data); logs.add(data);
} }
} }
@ -131,7 +124,6 @@ class LocalStorageService {
} }
} }
/// Updates an existing log file with new submission status.
Future<void> updateTarballLog(Map<String, dynamic> updatedLogData) async { Future<void> updateTarballLog(Map<String, dynamic> updatedLogData) async {
final logDir = updatedLogData['logDirectory']; final logDir = updatedLogData['logDirectory'];
if (logDir == null) { if (logDir == null) {
@ -156,7 +148,6 @@ class LocalStorageService {
// Part 3: Marine In-Situ Specific Methods // Part 3: Marine In-Situ Specific Methods
// ======================================================================= // =======================================================================
/// Gets the base directory for storing marine in-situ sampling data logs.
Future<Directory?> _getInSituBaseDir() async { Future<Directory?> _getInSituBaseDir() async {
final mmsv4Dir = await _getPublicMMSV4Directory(); final mmsv4Dir = await _getPublicMMSV4Directory();
if (mmsv4Dir == null) return null; if (mmsv4Dir == null) return null;
@ -168,7 +159,6 @@ class LocalStorageService {
return inSituDir; return inSituDir;
} }
/// Saves a single marine in-situ sampling record to a unique folder in public storage.
Future<String?> saveInSituSamplingData(InSituSamplingData data) async { Future<String?> saveInSituSamplingData(InSituSamplingData data) async {
final baseDir = await _getInSituBaseDir(); final baseDir = await _getInSituBaseDir();
if (baseDir == null) { if (baseDir == null) {
@ -212,7 +202,6 @@ class LocalStorageService {
} }
} }
/// Retrieves all saved marine in-situ submission logs from public storage.
Future<List<Map<String, dynamic>>> getAllInSituLogs() async { Future<List<Map<String, dynamic>>> getAllInSituLogs() async {
final baseDir = await _getInSituBaseDir(); final baseDir = await _getInSituBaseDir();
if (baseDir == null || !await baseDir.exists()) return []; 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<void> updateInSituLog(Map<String, dynamic> updatedLogData) async { Future<void> updateInSituLog(Map<String, dynamic> updatedLogData) async {
final logDir = updatedLogData['logDirectory']; final logDir = updatedLogData['logDirectory'];
if (logDir == null) { 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. /// Gets the base directory for storing river in-situ sampling data logs, organized by sampling type.
Future<Directory?> _getRiverInSituBaseDir() async { Future<Directory?> _getRiverInSituBaseDir(String? samplingType) async {
final mmsv4Dir = await _getPublicMMSV4Directory(); final mmsv4Dir = await _getPublicMMSV4Directory();
if (mmsv4Dir == null) return null; 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()) { if (!await inSituDir.exists()) {
await inSituDir.create(recursive: true); 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. /// Saves a single river in-situ sampling record to a unique folder in public storage.
Future<String?> saveRiverInSituSamplingData(RiverInSituSamplingData data) async { Future<String?> 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) { if (baseDir == null) {
debugPrint("Could not get public storage directory for River In-Situ. Check permissions."); debugPrint("Could not get public storage directory for River In-Situ. Check permissions.");
return null; return null;
} }
try { 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 timestamp = "${data.samplingDate}_${data.samplingTime?.replaceAll(':', '-')}";
final eventFolderName = "${stationCode}_$timestamp"; final eventFolderName = "${stationCode}_$timestamp";
final eventDir = Directory(p.join(baseDir.path, eventFolderName)); final eventDir = Directory(p.join(baseDir.path, eventFolderName));
@ -319,26 +315,36 @@ 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<List<Map<String, dynamic>>> getAllRiverInSituLogs() async { Future<List<Map<String, dynamic>>> getAllRiverInSituLogs() async {
final baseDir = await _getRiverInSituBaseDir(); final mmsv4Dir = await _getPublicMMSV4Directory();
if (baseDir == null || !await baseDir.exists()) return []; if (mmsv4Dir == null) return [];
final topLevelDir = Directory(p.join(mmsv4Dir.path, 'river', 'river_in_situ_sampling'));
if (!await topLevelDir.exists()) return [];
try { try {
final List<Map<String, dynamic>> logs = []; final List<Map<String, dynamic>> logs = [];
final entities = baseDir.listSync(); // List all subdirectories (e.g., 'Schedule', 'Triennial', 'Others')
final typeSubfolders = topLevelDir.listSync();
for (var entity in entities) { for (var typeSubfolder in typeSubfolders) {
if (entity is Directory) { if (typeSubfolder is Directory) {
final jsonFile = File(p.join(entity.path, 'data.json')); // 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()) { if (await jsonFile.exists()) {
final content = await jsonFile.readAsString(); final content = await jsonFile.readAsString();
final data = jsonDecode(content) as Map<String, dynamic>; final data = jsonDecode(content) as Map<String, dynamic>;
data['logDirectory'] = entity.path; data['logDirectory'] = eventFolder.path;
logs.add(data); logs.add(data);
} }
} }
} }
}
}
return logs; return logs;
} catch (e) { } catch (e) {
debugPrint("Error getting all river in-situ logs: $e"); debugPrint("Error getting all river in-situ logs: $e");