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 ---
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<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.
Map<String, String> toApiFormData() {
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
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,29 +183,22 @@ 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<String, File?> for the multipart API request.
Map<String, File?> 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,

View File

@ -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<RiverDataStatusLog> createState() => _RiverDataStatusLogState();
}
// CHANGED: Renamed state class
class _RiverDataStatusLogState extends State<RiverDataStatusLog> {
final LocalStorageService _localStorageService = LocalStorageService();
// CHANGED: Use RiverApiService
final RiverApiService _riverApiService = RiverApiService();
Map<String, List<SubmissionLogEntry>> _groupedLogs = {};
Map<String, List<SubmissionLogEntry>> _filteredLogs = {};
final Map<String, bool> _isCategoryExpanded = {};
// Raw data lists
List<SubmissionLogEntry> _scheduleLogs = [];
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;
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<void> _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<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.
// Map In-Situ logs for River
final List<SubmissionLogEntry> 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,
));
);
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) {
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<String, List<SubmissionLogEntry>> tempFiltered = {};
final scheduleQuery = _searchControllers['Schedule']?.text.toLowerCase() ?? '';
final triennialQuery = _searchControllers['Triennial']?.text.toLowerCase() ?? '';
final otherQuery = _searchControllers['Others']?.text.toLowerCase() ?? '';
_groupedLogs.forEach((category, logs) {
final filtered = logs.where((log) {
setState(() {
_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) ||
log.stationCode.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 {
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;
// 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<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];
if (imagePath is String && imagePath.isNotEmpty) {
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(
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
body: _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.'))
child: !hasAnyLogs
? const Center(child: Text('No submission logs found.'))
: ListView(
children: _filteredLogs.entries.map((entry) {
return _buildCategorySection(entry.key, entry.value);
}).toList(),
),
),
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<SubmissionLogEntry> 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)),
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(),
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,
physics: const NeverScrollableScrollPhysics(),
itemCount: itemCount,
itemCount: logs.length,
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)'),
),
],
),
@ -309,20 +274,12 @@ class _RiverDataStatusLogState extends State<RiverDataStatusLog> {
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: [

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_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<RiverInSituSamplingScreen> {
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<RiverInSituSamplingScreen> {
@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<RiverInSituSamplingScreen> {
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<RiverInSituSamplingScreen> {
}
}
/// Navigates to the previous page in the form.
void _previousPage() {
if (_currentPage > 0) {
_pageController.previousPage(
@ -74,7 +66,6 @@ class _RiverInSituSamplingScreenState extends State<RiverInSituSamplingScreen> {
}
}
/// Handles the final submission process.
Future<void> _submitForm() async {
setState(() => _isLoading = true);
@ -86,7 +77,6 @@ class _RiverInSituSamplingScreenState extends State<RiverInSituSamplingScreen> {
_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<RiverInSituSamplingScreen> {
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);
}
}
@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<RiverInSituSamplingScreen> {
});
},
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),
],
),
),

View File

@ -36,10 +36,12 @@ class _RiverInSituStep1SamplingInfoState extends State<RiverInSituStep1SamplingI
late final TextEditingController _stationLonController;
late final TextEditingController _currentLatController;
late final TextEditingController _currentLonController;
// REMOVED: Controllers for weather and remarks.
List<String> _statesList = [];
List<Map<String, dynamic>> _stationsForState = [];
final List<String> _samplingTypes = ['Schedule', 'Ad-Hoc', 'Complaint'];
final List<String> _samplingTypes = ['Schedule', 'Triennial'];
// REMOVED: Weather options list.
@override
void initState() {
@ -58,6 +60,7 @@ class _RiverInSituStep1SamplingInfoState extends State<RiverInSituStep1SamplingI
_stationLonController.dispose();
_currentLatController.dispose();
_currentLonController.dispose();
// REMOVED: Dispose controllers for remarks.
super.dispose();
}
@ -70,6 +73,7 @@ class _RiverInSituStep1SamplingInfoState extends State<RiverInSituStep1SamplingI
_stationLonController = TextEditingController(text: widget.data.stationLongitude);
_currentLatController = TextEditingController(text: widget.data.currentLatitude);
_currentLonController = TextEditingController(text: widget.data.currentLongitude);
// REMOVED: Initialize controllers for remarks.
}
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),
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(
onPressed: _goToNextStep,
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 'package:flutter/material.dart';
import 'package:image_picker/image_picker.dart';
@ -26,33 +28,19 @@ class _RiverInSituStep2SiteInfoState extends State<RiverInSituStep2SiteInfo> {
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<String> _weatherOptions = ['Clear', 'Rainy', 'Cloudy', 'Windy', 'Sunny', 'Drizzle'];
// MODIFIED: Removed _waterLevelOptions and _riverConditionOptions
final List<String> _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<RiverInSituStep2SiteInfo> {
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<RiverInSituStep2SiteInfo> {
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<String>(
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<RiverInSituStep2SiteInfo> {
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,

View File

@ -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<RiverInSituStep3DataCapture> createState() => _RiverInSituStep3DataCaptureState();
}
// UPDATED: State class name
class _RiverInSituStep3DataCaptureState extends State<RiverInSituStep3DataCapture> {
final _formKey = GlobalKey<FormState>();
bool _isLoading = false;
@ -48,7 +51,6 @@ class _RiverInSituStep3DataCaptureState extends State<RiverInSituStep3DataCaptur
final _turbidityController = TextEditingController();
final _tssController = TextEditingController();
final _batteryController = TextEditingController();
// NOTE: If you add river-specific parameters, add their controllers here.
@override
void initState() {
@ -81,7 +83,6 @@ class _RiverInSituStep3DataCaptureState extends State<RiverInSituStep3DataCaptur
widget.data.turbidity ??= -999.0;
widget.data.tss ??= -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();
_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.filter_alt_outlined, 'label': 'TSS', 'unit': 'mg/L', 'controller': _tssController},
{'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();
_tssController.dispose();
_batteryController.dispose();
// NOTE: Dispose your river-specific controllers here.
}
Future<void> _handleConnectionAttempt(String type) async {
final service = context.read<RiverInSituSamplingService>();
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<RiverInSituStep3DataCaptur
setState(() => _isLoading = true);
final service = context.read<RiverInSituSamplingService>();
bool success = false;
try {
if (type == 'bluetooth') {
final devices = await service.getPairedBluetoothDevices();
@ -255,22 +248,14 @@ class _RiverInSituStep3DataCaptureState extends State<RiverInSituStep3DataCaptur
title: const Text('Data Collection Active'),
content: const Text('Please stop the live data collection before proceeding.'),
actions: <Widget>[
TextButton(
child: const Text('OK'),
onPressed: () {
Navigator.of(context).pop();
},
),
],
);
},
);
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<RiverInSituStep3DataCaptur
_showSnackBar("Could not save parameters due to a data format error.", isError: true);
return;
}
widget.onNext();
}
}
@ -321,7 +305,6 @@ class _RiverInSituStep3DataCaptureState extends State<RiverInSituStep3DataCaptur
return Form(
key: _formKey,
child: ListView(
// CORRECTED: Scrolling is enabled by removing the physics property.
padding: const EdgeInsets.all(24.0),
children: [
Text("Data Capture", style: Theme.of(context).textTheme.headlineSmall),
@ -370,10 +353,14 @@ class _RiverInSituStep3DataCaptureState extends State<RiverInSituStep3DataCaptur
valueListenable: service.sondeId,
builder: (context, sondeId, child) {
final newSondeId = sondeId ?? '';
if (_sondeIdController.text != newSondeId) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted && _sondeIdController.text != newSondeId) {
_sondeIdController.text = newSondeId;
widget.data.sondeId = newSondeId;
}
});
return TextFormField(
controller: _sondeIdController,
decoration: const InputDecoration(
@ -447,14 +434,12 @@ class _RiverInSituStep3DataCaptureState extends State<RiverInSituStep3DataCaptur
Widget _buildConnectionCard({required String type, required dynamic connectionState, String? deviceName}) {
final isConnected = connectionState == BluetoothConnectionState.connected || connectionState == SerialConnectionState.connected;
final isConnecting = connectionState == BluetoothConnectionState.connecting || connectionState == SerialConnectionState.connecting;
Color statusColor = isConnected ? Colors.green : Colors.red;
String statusText = isConnected ? 'Connected to ${deviceName ?? 'device'}' : 'Disconnected';
if (isConnecting) {
statusColor = Colors.orange;
statusText = 'Connecting...';
}
return Card(
elevation: 2,
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 '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(

View File

@ -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<bool> _requestPermissions() async {
var status = await Permission.manageExternalStorage.request();
return status.isGranted;
}
/// Gets the public external storage directory and creates the base MMSV4 folder.
Future<Directory?> _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<Directory?> _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<String?> 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<List<Map<String, dynamic>>> 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<String, dynamic>;
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<void> updateTarballLog(Map<String, dynamic> 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<Directory?> _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<String?> 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<List<Map<String, dynamic>>> 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<void> updateInSituLog(Map<String, dynamic> 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<Directory?> _getRiverInSituBaseDir() async {
/// Gets the base directory for storing river in-situ sampling data logs, organized by sampling type.
Future<Directory?> _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<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) {
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,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 {
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<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) {
if (entity is Directory) {
final jsonFile = File(p.join(entity.path, 'data.json'));
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<String, dynamic>;
data['logDirectory'] = entity.path;
data['logDirectory'] = eventFolder.path;
logs.add(data);
}
}
}
}
}
return logs;
} catch (e) {
debugPrint("Error getting all river in-situ logs: $e");