From fc14740b01d4776938cc259b312ffdf1f45b9ddc Mon Sep 17 00:00:00 2001 From: ALim Aidrus Date: Sat, 4 Oct 2025 00:36:36 +0800 Subject: [PATCH] repair separate department parameter limit for river and marine --- lib/main.dart | 5 +- lib/screens/marine/manual/image_request.dart | 61 --- .../marine/manual/marine_image_request.dart | 493 ++++++++++++++++++ .../widgets/in_situ_step_1_sampling_info.dart | 17 + .../widgets/in_situ_step_3_data_capture.dart | 42 +- .../widgets/in_situ_step_4_summary.dart | 16 +- lib/screens/marine/marine_home_page.dart | 2 +- lib/services/api_service.dart | 338 +++++++++--- .../marine_in_situ_sampling_service.dart | 4 +- 9 files changed, 837 insertions(+), 141 deletions(-) delete mode 100644 lib/screens/marine/manual/image_request.dart create mode 100644 lib/screens/marine/manual/marine_image_request.dart diff --git a/lib/main.dart b/lib/main.dart index 0bac92e..1746828 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -70,7 +70,7 @@ import 'package:environment_monitoring_app/screens/marine/manual/pre_sampling.da import 'package:environment_monitoring_app/screens/marine/manual/in_situ_sampling.dart' as marineManualInSituSampling; import 'package:environment_monitoring_app/screens/marine/manual/report.dart' as marineManualReport; import 'package:environment_monitoring_app/screens/marine/manual/data_status_log.dart' as marineManualDataStatusLog; -import 'package:environment_monitoring_app/screens/marine/manual/image_request.dart' as marineManualImageRequest; +import 'package:environment_monitoring_app/screens/marine/manual/marine_image_request.dart' as marineManualImageRequest; import 'package:environment_monitoring_app/screens/marine/continuous/marine_continuous_info_centre_document.dart'; import 'package:environment_monitoring_app/screens/marine/continuous/overview.dart' as marineContinuousOverview; import 'package:environment_monitoring_app/screens/marine/continuous/entry.dart' as marineContinuousEntry; @@ -281,7 +281,8 @@ class _RootAppState extends State { '/marine/manual/tarball': (context) => const TarballSamplingStep1(), '/marine/manual/report': (context) => marineManualReport.MarineManualReport(), //'/marine/manual/data-log': (context) => marineManualDataStatusLog.MarineManualDataStatusLog(), // This is handled in onGenerateRoute - '/marine/manual/image-request': (context) => marineManualImageRequest.MarineManualImageRequest(), + '/marine/manual/image-request': (context) => const marineManualImageRequest.MarineImageRequestScreen(), + // Marine Continuous '/marine/continuous/info': (context) => const MarineContinuousInfoCentreDocument(), diff --git a/lib/screens/marine/manual/image_request.dart b/lib/screens/marine/manual/image_request.dart deleted file mode 100644 index 30073ae..0000000 --- a/lib/screens/marine/manual/image_request.dart +++ /dev/null @@ -1,61 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:image_picker/image_picker.dart'; -import 'dart:io'; // Add this line at the top of these files - - -class MarineManualImageRequest extends StatefulWidget { - @override - State createState() => _MarineManualImageRequestState(); -} - -class _MarineManualImageRequestState extends State { - XFile? _image; - final picker = ImagePicker(); - final _descriptionController = TextEditingController(); - - Future _pickImage() async { - final pickedFile = await picker.pickImage(source: ImageSource.camera); - setState(() => _image = pickedFile); - } - - @override - Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar(title: Text("Marine Manual Image Request")), - body: Padding( - padding: const EdgeInsets.all(24), - child: Column( - children: [ - ElevatedButton.icon( - icon: Icon(Icons.camera_alt), - label: Text("Capture Image"), - onPressed: _pickImage, - ), - SizedBox(height: 16), - if (_image != null) - Image.file( - File(_image!.path), - height: 200, - ), - SizedBox(height: 16), - TextField( - controller: _descriptionController, - decoration: InputDecoration(labelText: "Description"), - maxLines: 3, - ), - SizedBox(height: 24), - ElevatedButton( - onPressed: () { - // Submit logic here - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text("Image request submitted")), - ); - }, - child: Text("Submit Request"), - ), - ], - ), - ), - ); - } -} \ No newline at end of file diff --git a/lib/screens/marine/manual/marine_image_request.dart b/lib/screens/marine/manual/marine_image_request.dart new file mode 100644 index 0000000..45c59af --- /dev/null +++ b/lib/screens/marine/manual/marine_image_request.dart @@ -0,0 +1,493 @@ +import 'dart:io'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:dropdown_search/dropdown_search.dart'; +import 'package:intl/intl.dart'; +import 'package:image_picker/image_picker.dart'; + +import '../../../auth_provider.dart'; +import '../../../services/api_service.dart'; + +class MarineImageRequestScreen extends StatefulWidget { + const MarineImageRequestScreen({super.key}); + + @override + State createState() => _MarineImageRequestScreenState(); +} + +class _MarineImageRequestScreenState extends State { + final _formKey = GlobalKey(); + final _dateController = TextEditingController(); + + String? _selectedSamplingType = 'All Manual Sampling'; + final List _samplingTypes = ['All Manual Sampling', 'In-Situ Sampling', 'Tarball Sampling']; + + String? _selectedStateName; + String? _selectedCategoryName; + Map? _selectedStation; + DateTime? _selectedDate; + + List _statesList = []; + List _categoriesForState = []; + List> _stationsForCategory = []; + + bool _isLoading = false; + List _imageUrls = []; + final Set _selectedImageUrls = {}; + + @override + void initState() { + super.initState(); + _initializeStationFilters(); + } + + @override + void dispose() { + _dateController.dispose(); + super.dispose(); + } + + void _initializeStationFilters() { + final auth = Provider.of(context, listen: false); + final allStations = auth.manualStations ?? []; + if (allStations.isNotEmpty) { + final states = allStations.map((s) => s['state_name'] as String?).whereType().toSet().toList(); + states.sort(); + setState(() { + _statesList = states; + }); + } + } + + Future _selectDate() async { + final picked = await showDatePicker( + context: context, + initialDate: _selectedDate ?? DateTime.now(), + firstDate: DateTime(2020), + lastDate: DateTime.now(), + ); + + if (picked != null && picked != _selectedDate) { + setState(() { + _selectedDate = picked; + _dateController.text = DateFormat('yyyy-MM-dd').format(_selectedDate!); + }); + } + } + + Future _searchImages() async { + if (_formKey.currentState!.validate()) { + setState(() { + _isLoading = true; + _imageUrls = []; + _selectedImageUrls.clear(); + }); + + debugPrint("[Image Request] Search button pressed. Starting validation."); + debugPrint("[Image Request] Selected Station: ${_selectedStation}"); + debugPrint("[Image Request] Date: ${_selectedDate}"); + debugPrint("[Image Request] Sampling Type: $_selectedSamplingType"); + + if (_selectedStation == null || _selectedDate == null || _selectedSamplingType == null) { + debugPrint("[Image Request] ERROR: Station, date, or sampling type is missing. Aborting search."); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Error: Station, date, or sampling type is missing.'), + backgroundColor: Colors.red, + ), + ); + setState(() => _isLoading = false); + } + return; + } + + final stationId = _selectedStation!['station_id']; + if (stationId == null) { + debugPrint("[Image Request] ERROR: Station ID is null."); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Error: Invalid station data.'), + backgroundColor: Colors.red, + ), + ); + setState(() => _isLoading = false); + } + return; + } + + final apiService = Provider.of(context, listen: false); + + try { + debugPrint("[Image Request] All checks passed. Calling API with Station ID: $stationId, Type: $_selectedSamplingType"); + final result = await apiService.marine.getManualSamplingImages( + stationId: stationId, + samplingDate: _selectedDate!, + samplingType: _selectedSamplingType!, + ); + + if (mounted && result['success'] == true) { + final List> records = List>.from(result['data'] ?? []); + final List fetchedUrls = []; + + const imageKeys = [ + 'man_left_side_land_view_path', + 'man_right_side_land_view_path', + 'man_filling_water_into_sample_bottle_path', + 'man_seawater_in_clear_glass_bottle_path', + 'man_examine_preservative_ph_paper_path', + 'man_optional_photo_01_path', + 'man_optional_photo_02_path', + 'man_optional_photo_03_path', + 'man_optional_photo_04_path', + ]; + + for (final record in records) { + for (final key in imageKeys) { + if (record[key] != null && (record[key] as String).isNotEmpty) { + final String imagePathFromServer = record[key]; + final fullUrl = ApiService.imageBaseUrl + imagePathFromServer; + fetchedUrls.add(fullUrl); + debugPrint("[Image Request] Found and constructed URL: $fullUrl"); + } + } + } + + setState(() { + _imageUrls = fetchedUrls; + }); + debugPrint("[Image Request] Successfully processed and constructed ${_imageUrls.length} image URLs."); + } else if (mounted) { + debugPrint("[Image Request] API call failed. Message: ${result['message']}"); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(result['message'] ?? 'Failed to fetch images.')), + ); + } + } catch (e) { + debugPrint("[Image Request] An exception occurred during API call: $e"); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('An error occurred: $e')), + ); + } + } finally { + if (mounted) { + setState(() { + _isLoading = false; + }); + } + } + } else { + debugPrint("[Image Request] Form validation failed."); + } + } + + Future _showEmailDialog() async { + final emailController = TextEditingController(); + final dialogFormKey = GlobalKey(); + + return showDialog( + context: context, + barrierDismissible: false, + builder: (BuildContext context) { + return StatefulBuilder( + builder: (context, setDialogState) { + bool isSending = false; + + return AlertDialog( + title: const Text('Send Images via Email'), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (isSending) + const Padding( + padding: EdgeInsets.all(16.0), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + CircularProgressIndicator(), + SizedBox(width: 24), + Text("Sending..."), + ], + ), + ) + else + Form( + key: dialogFormKey, + child: TextFormField( + controller: emailController, + keyboardType: TextInputType.emailAddress, + decoration: const InputDecoration( + labelText: 'Recipient Email Address', + hintText: 'user@example.com', + ), + validator: (value) { + if (value == null || value.isEmpty || !RegExp(r'\S+@\S+\.\S+').hasMatch(value)) { + return 'Please enter a valid email address.'; + } + return null; + }, + ), + ), + ], + ), + actions: [ + TextButton( + onPressed: isSending ? null : () => Navigator.of(context).pop(), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: isSending ? null : () async { + if (dialogFormKey.currentState!.validate()) { + setDialogState(() => isSending = true); + await _sendEmailRequestToServer(emailController.text); + if (mounted) Navigator.of(context).pop(); + } + }, + child: const Text('Send'), + ), + ], + ); + }, + ); + }, + ); + } + + Future _sendEmailRequestToServer(String toEmail) async { + final apiService = Provider.of(context, listen: false); + + try { + debugPrint("[Image Request] Sending email request to server for recipient: $toEmail"); + + final stationCode = _selectedStation?['man_station_code'] ?? 'N/A'; + final stationName = _selectedStation?['man_station_name'] ?? 'N/A'; + final fullStationIdentifier = '$stationCode - $stationName'; + + final result = await apiService.marine.sendImageRequestEmail( + recipientEmail: toEmail, + imageUrls: _selectedImageUrls.toList(), + stationName: fullStationIdentifier, + samplingDate: _dateController.text, + ); + + if(mounted) { + if (result['success'] == true) { + debugPrint("[Image Request] Server responded with success for email request."); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Success! Email is being sent by the server.'), + backgroundColor: Colors.green, + ), + ); + } else { + debugPrint("[Image Request] Server responded with failure for email request. Message: ${result['message']}"); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Error: ${result['message']}'), + backgroundColor: Colors.red, + ), + ); + } + } + } catch (e) { + debugPrint("[Image Request] An exception occurred while sending email request: $e"); + if(mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('An error occurred: $e'), + backgroundColor: Colors.red, + ), + ); + } + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text("Marine Image Request")), + body: Form( + key: _formKey, + child: ListView( + padding: const EdgeInsets.all(24.0), + children: [ + Text("Image Search Filters", style: Theme.of(context).textTheme.headlineSmall), + const SizedBox(height: 24), + DropdownButtonFormField( + value: _selectedSamplingType, + items: _samplingTypes.map((type) => DropdownMenuItem(value: type, child: Text(type))).toList(), + onChanged: (value) => setState(() => _selectedSamplingType = value), + decoration: const InputDecoration(labelText: 'Sampling Type *', border: OutlineInputBorder()), + validator: (value) => value == null ? 'Please select a type' : null, + ), + const SizedBox(height: 16), + DropdownSearch( + items: _statesList, + selectedItem: _selectedStateName, + popupProps: const PopupProps.menu(showSearchBox: true, searchFieldProps: TextFieldProps(decoration: InputDecoration(hintText: "Search State..."))), + dropdownDecoratorProps: const DropDownDecoratorProps(dropdownSearchDecoration: InputDecoration(labelText: "Select State *", border: OutlineInputBorder())), + onChanged: (state) { + setState(() { + _selectedStateName = state; + _selectedCategoryName = null; + _selectedStation = null; + final auth = Provider.of(context, listen: false); + final allStations = auth.manualStations ?? []; + final categories = state != null ? allStations.where((s) => s['state_name'] == state).map((s) => s['category_name'] as String?).whereType().toSet().toList() : []; + categories.sort(); + _categoriesForState = categories; + _stationsForCategory = []; + }); + }, + validator: (val) => val == null ? "State is required" : null, + ), + const SizedBox(height: 16), + DropdownSearch( + items: _categoriesForState, + selectedItem: _selectedCategoryName, + enabled: _selectedStateName != null, + popupProps: const PopupProps.menu(showSearchBox: true, searchFieldProps: TextFieldProps(decoration: InputDecoration(hintText: "Search Category..."))), + dropdownDecoratorProps: const DropDownDecoratorProps(dropdownSearchDecoration: InputDecoration(labelText: "Select Category *", border: OutlineInputBorder())), + onChanged: (category) { + setState(() { + _selectedCategoryName = category; + _selectedStation = null; + final auth = Provider.of(context, listen: false); + final allStations = auth.manualStations ?? []; + _stationsForCategory = category != null ? (allStations.where((s) => s['state_name'] == _selectedStateName && s['category_name'] == category).toList()..sort((a, b) => (a['man_station_code'] ?? '').compareTo(b['man_station_code'] ?? ''))) : []; + }); + }, + validator: (val) => _selectedStateName != null && val == null ? "Category is required" : null, + ), + const SizedBox(height: 16), + DropdownSearch>( + items: _stationsForCategory, + selectedItem: _selectedStation, + enabled: _selectedCategoryName != null, + itemAsString: (station) => "${station['man_station_code']} - ${station['man_station_name']}", + popupProps: const PopupProps.menu(showSearchBox: true, searchFieldProps: TextFieldProps(decoration: InputDecoration(hintText: "Search Station..."))), + dropdownDecoratorProps: const DropDownDecoratorProps(dropdownSearchDecoration: InputDecoration(labelText: "Select Station *", border: OutlineInputBorder())), + onChanged: (station) => setState(() => _selectedStation = station), + validator: (val) => _selectedCategoryName != null && val == null ? "Station is required" : null, + ), + const SizedBox(height: 16), + TextFormField( + controller: _dateController, + readOnly: true, + decoration: InputDecoration( + labelText: 'Select Date *', + hintText: 'Tap to pick a date', + border: const OutlineInputBorder(), + suffixIcon: IconButton( + icon: const Icon(Icons.calendar_today), + onPressed: _selectDate, + ), + ), + onTap: _selectDate, + validator: (val) => val == null || val.isEmpty ? "Date is required" : null, + ), + const SizedBox(height: 32), + ElevatedButton.icon( + icon: const Icon(Icons.search), + label: const Text('Search Images'), + onPressed: _isLoading ? null : _searchImages, + style: ElevatedButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 16), + textStyle: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold), + ), + ), + const SizedBox(height: 24), + const Divider(thickness: 1), + const SizedBox(height: 16), + Text("Results", style: Theme.of(context).textTheme.headlineSmall), + const SizedBox(height: 16), + _buildResults(), + if (_selectedImageUrls.isNotEmpty) ...[ + const SizedBox(height: 24), + ElevatedButton.icon( + icon: const Icon(Icons.email_outlined), + label: Text('Send (${_selectedImageUrls.length}) Selected Image(s)'), + onPressed: _showEmailDialog, + style: ElevatedButton.styleFrom( + backgroundColor: Theme.of(context).colorScheme.secondary, + foregroundColor: Theme.of(context).colorScheme.onSecondary, + padding: const EdgeInsets.symmetric(vertical: 16), + ), + ), + ], + ], + ), + ), + ); + } + + Widget _buildResults() { + if (_isLoading) { + return const Center(child: CircularProgressIndicator()); + } + if (_imageUrls.isEmpty) { + return const Center( + child: Text( + 'No images found. Please adjust your filters and search again.', + textAlign: TextAlign.center, + ), + ); + } + return GridView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 4, + crossAxisSpacing: 8, + mainAxisSpacing: 8, + childAspectRatio: 1.0, + ), + itemCount: _imageUrls.length, + itemBuilder: (context, index) { + final imageUrl = _imageUrls[index]; + final isSelected = _selectedImageUrls.contains(imageUrl); + + return GestureDetector( + onTap: () { + setState(() { + if (isSelected) { + _selectedImageUrls.remove(imageUrl); + } else { + _selectedImageUrls.add(imageUrl); + } + }); + }, + child: Card( + clipBehavior: Clip.antiAlias, + elevation: 2.0, + child: GridTile( + child: Stack( + fit: StackFit.expand, + children: [ + Image.network( + imageUrl, + fit: BoxFit.cover, + loadingBuilder: (context, child, loadingProgress) { + if (loadingProgress == null) return child; + return const Center(child: CircularProgressIndicator(strokeWidth: 2)); + }, + errorBuilder: (context, error, stackTrace) { + return const Icon(Icons.broken_image, color: Colors.grey, size: 40); + }, + ), + if (isSelected) + Container( + color: Colors.black.withOpacity(0.6), + child: const Icon(Icons.check_circle, color: Colors.white, size: 40), + ), + ], + ), + ), + ), + ); + }, + ); + } +} \ No newline at end of file diff --git a/lib/screens/marine/manual/widgets/in_situ_step_1_sampling_info.dart b/lib/screens/marine/manual/widgets/in_situ_step_1_sampling_info.dart index c8ca92f..5000432 100644 --- a/lib/screens/marine/manual/widgets/in_situ_step_1_sampling_info.dart +++ b/lib/screens/marine/manual/widgets/in_situ_step_1_sampling_info.dart @@ -262,6 +262,12 @@ class _InSituStep1SamplingInfoState extends State { _stationLatController.text = widget.data.stationLatitude ?? ''; _stationLonController.text = widget.data.stationLongitude ?? ''; + // --- START DEBUG --- + debugPrint("--- Nearby Station Selected in Step 1 ---"); + debugPrint("Selected Station Data: $station"); + debugPrint("Selected Station ID (man_station_id): ${station['man_station_id']}"); + // --- END DEBUG --- + // Recalculate distance _calculateDistance(); }); @@ -459,6 +465,17 @@ class _InSituStep1SamplingInfoState extends State { widget.data.stationLongitude = station?['man_longitude']?.toString(); _stationLatController.text = widget.data.stationLatitude ?? ''; _stationLonController.text = widget.data.stationLongitude ?? ''; + + // --- START DEBUG --- + if (station != null) { + debugPrint("--- Station Selected in Step 1 ---"); + debugPrint("Selected Station Data: $station"); + debugPrint("Selected Station ID (man_station_id): ${station['man_station_id']}"); + } else { + debugPrint("--- Station Deselected in Step 1 ---"); + } + // --- END DEBUG --- + _calculateDistance(); }), validator: (val) => widget.data.selectedCategoryName != null && val == null ? "Station is required" : null, diff --git a/lib/screens/marine/manual/widgets/in_situ_step_3_data_capture.dart b/lib/screens/marine/manual/widgets/in_situ_step_3_data_capture.dart index a6daca9..509e495 100644 --- a/lib/screens/marine/manual/widgets/in_situ_step_3_data_capture.dart +++ b/lib/screens/marine/manual/widgets/in_situ_step_3_data_capture.dart @@ -315,11 +315,7 @@ class _InSituStep3DataCaptureState extends State with Wi final currentReadings = _captureReadingsToMap(); final authProvider = Provider.of(context, listen: false); - // --- START: MODIFICATION --- - // The `parameterLimits` getter was removed from AuthProvider. - // This now correctly uses the new `marineParameterLimits` getter. final marineLimits = authProvider.marineParameterLimits ?? []; - // --- END: MODIFICATION --- final outOfBoundsParams = _validateParameters(currentReadings, marineLimits); setState(() { @@ -345,6 +341,19 @@ class _InSituStep3DataCaptureState extends State with Wi List> _validateParameters(Map readings, List> limits) { final List> invalidParams = []; + final int? stationId = widget.data.selectedStation?['station_id']; + + debugPrint("--- Parameter Validation Start ---"); + debugPrint("Selected Station ID: $stationId"); + debugPrint("Total Marine Limits Loaded: ${limits.length}"); + + // --- START DEBUG: Add type inspection --- + if (limits.isNotEmpty && stationId != null) { + debugPrint("Inspecting the first loaded limit record: ${limits.first}"); + debugPrint("Type of Selected Station ID ($stationId): ${stationId.runtimeType}"); + debugPrint("Type of man_station_id in first limit record: ${limits.first['man_station_id']?.runtimeType}"); + } + // --- END DEBUG --- double? _parseLimitValue(dynamic value) { if (value == null) return null; @@ -359,10 +368,24 @@ class _InSituStep3DataCaptureState extends State with Wi final limitName = _parameterKeyToLimitName[key]; if (limitName == null) return; - final limitData = limits.firstWhere( - (l) => l['param_parameter_list'] == limitName, - orElse: () => {}, - ); + debugPrint("Checking parameter: '$limitName' (key: '$key')"); + + Map limitData = {}; + + if (stationId != null) { + // --- START FIX: Use type-safe comparison --- + limitData = limits.firstWhere( + (l) => l['param_parameter_list'] == limitName && l['station_id']?.toString() == stationId.toString(), + orElse: () => {}, + ); + // --- END FIX --- + } + + if (limitData.isNotEmpty) { + debugPrint(" > Found station-specific limit for Station ID $stationId: $limitData"); + } else { + debugPrint(" > No station-specific limit found for Station ID $stationId. Skipping check for this parameter."); + } if (limitData.isNotEmpty) { final lowerLimit = _parseLimitValue(limitData['param_lower_limit']); @@ -379,6 +402,9 @@ class _InSituStep3DataCaptureState extends State with Wi } } }); + + debugPrint("--- Parameter Validation End ---"); + return invalidParams; } diff --git a/lib/screens/marine/manual/widgets/in_situ_step_4_summary.dart b/lib/screens/marine/manual/widgets/in_situ_step_4_summary.dart index dcaa2de..9dbc4dd 100644 --- a/lib/screens/marine/manual/widgets/in_situ_step_4_summary.dart +++ b/lib/screens/marine/manual/widgets/in_situ_step_4_summary.dart @@ -39,12 +39,9 @@ class InSituStep4Summary extends StatelessWidget { /// Re-validates the final parameters against the defined limits. Set _getOutOfBoundsKeys(BuildContext context) { final authProvider = Provider.of(context, listen: false); - // --- START MODIFICATION --- - // The `parameterLimits` getter was removed from AuthProvider. - // This now correctly uses the new `marineParameterLimits` getter. final marineLimits = authProvider.marineParameterLimits ?? []; - // --- END MODIFICATION --- final Set invalidKeys = {}; + final int? stationId = data.selectedStation?['station_id']; final readings = { 'oxygenConcentration': data.oxygenConcentration, 'oxygenSaturation': data.oxygenSaturation, @@ -66,7 +63,16 @@ class InSituStep4Summary extends StatelessWidget { final limitName = _parameterKeyToLimitName[key]; if (limitName == null) return; - final limitData = marineLimits.firstWhere((l) => l['param_parameter_list'] == limitName, orElse: () => {}); + // START MODIFICATION: Only check for station-specific limits + Map limitData = {}; + + if (stationId != null) { + limitData = marineLimits.firstWhere( + (l) => l['param_parameter_list'] == limitName && l['station_id'] == stationId, + orElse: () => {}, + ); + } + // END MODIFICATION if (limitData.isNotEmpty) { final lowerLimit = parseLimitValue(limitData['param_lower_limit']); diff --git a/lib/screens/marine/marine_home_page.dart b/lib/screens/marine/marine_home_page.dart index b09d487..c3d8dd7 100644 --- a/lib/screens/marine/marine_home_page.dart +++ b/lib/screens/marine/marine_home_page.dart @@ -37,7 +37,7 @@ class MarineHomePage extends StatelessWidget { SidebarItem(icon: Icons.waves, label: "Tarball Sampling", route: '/marine/manual/tarball'), SidebarItem(icon: Icons.article, label: "Data Log", route: '/marine/manual/data-log'), - //SidebarItem(icon: Icons.image, label: "Image Request", route: '/marine/manual/image-request'), + SidebarItem(icon: Icons.image, label: "Image Request", route: '/marine/manual/image-request'), //SidebarItem(icon: Icons.receipt_long, label: "Report", route: '/marine/manual/report'), ], ), diff --git a/lib/services/api_service.dart b/lib/services/api_service.dart index fd03c4d..b06e8db 100644 --- a/lib/services/api_service.dart +++ b/lib/services/api_service.dart @@ -34,10 +34,11 @@ class ApiService { static const String imageBaseUrl = 'https://mms-apiv4.pstw.com.my/'; ApiService({required TelegramService telegramService}) { - marine = MarineApiService(_baseService, telegramService, _serverConfigService); - river = RiverApiService(_baseService, telegramService, _serverConfigService); + marine = MarineApiService(_baseService, telegramService, _serverConfigService, dbHelper); + river = RiverApiService(_baseService, telegramService, _serverConfigService, dbHelper); air = AirApiService(_baseService, telegramService, _serverConfigService); } + // --- END: FIX FOR CONSTRUCTOR ERROR --- // --- Core API Methods --- @@ -470,8 +471,63 @@ class MarineApiService { final BaseApiService _baseService; final TelegramService _telegramService; final ServerConfigService _serverConfigService; + final DatabaseHelper _dbHelper; - MarineApiService(this._baseService, this._telegramService, this._serverConfigService); + MarineApiService(this._baseService, this._telegramService, this._serverConfigService, this._dbHelper); + + Future> sendImageRequestEmail({ + required String recipientEmail, + required List imageUrls, + required String stationName, + required String samplingDate, + }) async { + final baseUrl = await _serverConfigService.getActiveApiUrl(); + + final Map fields = { + 'recipientEmail': recipientEmail, + 'imageUrls': jsonEncode(imageUrls), + 'stationName': stationName, + 'samplingDate': samplingDate, + }; + + return _baseService.postMultipart( + baseUrl: baseUrl, + endpoint: 'marine/images/send-email', + fields: fields, + files: {}, + ); + } + + // --- START: FIX - Replaced mock with a real API call --- + Future> getManualSamplingImages({ + required int stationId, + required DateTime samplingDate, + required String samplingType, + }) async { + final baseUrl = await _serverConfigService.getActiveApiUrl(); + final String dateStr = DateFormat('yyyy-MM-dd').format(samplingDate); + + final String endpoint = 'marine/manual/records-by-station?station_id=$stationId&date=$dateStr'; + + debugPrint("ApiService: Calling real API endpoint: $endpoint"); + + final response = await _baseService.get(baseUrl, endpoint); + + // The backend now returns a root 'data' key which the base service handles. + // However, the PHP controller wraps the results again in a 'data' key inside the main data object. + // We need to extract this nested list. + if (response['success'] == true && response['data'] is Map && response['data']['data'] is List) { + return { + 'success': true, + 'data': response['data']['data'], + 'message': response['message'], + }; + } + + // Return the original response if the structure isn't as expected, or if it's an error. + return response; + } + // --- END: FIX --- Future> getTarballStations() async { final baseUrl = await _serverConfigService.getActiveApiUrl(); @@ -567,7 +623,7 @@ class MarineApiService { Future _handleInSituSuccessAlert(InSituSamplingData data, List>? appSettings, {required bool isDataOnly}) async { try { - final message = data.generateTelegramAlertMessage(isDataOnly: isDataOnly); + final message = await _generateInSituAlertMessage(data, isDataOnly: isDataOnly); final bool wasSent = await _telegramService.sendAlertImmediately('marine_in_situ', message, appSettings); if (!wasSent) { await _telegramService.queueMessage('marine_in_situ', message, appSettings); @@ -577,6 +633,111 @@ class MarineApiService { } } + Future _generateInSituAlertMessage(InSituSamplingData data, {required bool isDataOnly}) async { + final submissionType = isDataOnly ? "(Data Only)" : "(Data & Images)"; + final stationName = data.selectedStation?['man_station_name'] ?? 'N/A'; + final stationCode = data.selectedStation?['man_station_code'] ?? 'N/A'; + final distanceKm = data.distanceDifferenceInKm ?? 0; + final distanceMeters = (distanceKm * 1000).toStringAsFixed(0); + final distanceRemarks = data.distanceDifferenceRemarks ?? 'N/A'; + + final buffer = StringBuffer() + ..writeln('✅ *Marine In-Situ Sample ${submissionType} Submitted:*') + ..writeln() + ..writeln('*Station Name & Code:* $stationName ($stationCode)') + ..writeln('*Date of Submitted:* ${data.samplingDate}') + ..writeln('*Submitted by User:* ${data.firstSamplerName}') + ..writeln('*Sonde ID:* ${data.sondeId ?? 'N/A'}') + ..writeln('*Status of Submission:* Successful'); + + if (distanceKm > 0 || (distanceRemarks.isNotEmpty && distanceRemarks != 'N/A')) { + buffer + ..writeln() + ..writeln('🔔 *Distance Alert:*') + ..writeln('*Distance from station:* $distanceMeters meters'); + if (distanceRemarks.isNotEmpty && distanceRemarks != 'N/A') { + buffer.writeln('*Remarks for distance:* $distanceRemarks'); + } + } + + final outOfBoundsAlert = await _getOutOfBoundsAlertSection(data); + if (outOfBoundsAlert.isNotEmpty) { + buffer.write(outOfBoundsAlert); + } + + return buffer.toString(); + } + + Future _getOutOfBoundsAlertSection(InSituSamplingData data) async { + const Map _parameterKeyToLimitName = { + 'oxygenConcentration': 'Oxygen Conc', 'oxygenSaturation': 'Oxygen Sat', 'ph': 'pH', + 'salinity': 'Salinity', 'electricalConductivity': 'Conductivity', 'temperature': 'Temperature', + 'tds': 'TDS', 'turbidity': 'Turbidity', 'tss': 'TSS', 'batteryVoltage': 'Battery', + }; + + final allLimits = await _dbHelper.loadMarineParameterLimits() ?? []; + if (allLimits.isEmpty) return ""; + + final int? stationId = data.selectedStation?['station_id']; + final readings = { + 'oxygenConcentration': data.oxygenConcentration, 'oxygenSaturation': data.oxygenSaturation, + 'ph': data.ph, 'salinity': data.salinity, 'electricalConductivity': data.electricalConductivity, + 'temperature': data.temperature, 'tds': data.tds, 'turbidity': data.turbidity, + 'tss': data.tss, 'batteryVoltage': data.batteryVoltage, + }; + + final List outOfBoundsMessages = []; + + double? parseLimitValue(dynamic value) { + if (value == null) return null; + if (value is num) return value.toDouble(); + if (value is String) return double.tryParse(value); + return null; + } + + readings.forEach((key, value) { + if (value == null || value == -999.0) return; + + final limitName = _parameterKeyToLimitName[key]; + if (limitName == null) return; + + // START MODIFICATION: Only check for station-specific limits + Map limitData = {}; + + if (stationId != null) { + limitData = allLimits.firstWhere( + (l) => l['param_parameter_list'] == limitName && l['station_id'] == stationId, + orElse: () => {}, + ); + } + // END MODIFICATION + + if (limitData.isNotEmpty) { + final lowerLimit = parseLimitValue(limitData['param_lower_limit']); + final upperLimit = parseLimitValue(limitData['param_upper_limit']); + + if ((lowerLimit != null && value < lowerLimit) || (upperLimit != null && value > upperLimit)) { + final valueStr = value.toStringAsFixed(5); + final lowerStr = lowerLimit?.toStringAsFixed(5) ?? 'N/A'; + final upperStr = upperLimit?.toStringAsFixed(5) ?? 'N/A'; + outOfBoundsMessages.add('- *$limitName*: `$valueStr` (Limit: `$lowerStr` - `$upperStr`)'); + } + } + }); + + if (outOfBoundsMessages.isEmpty) { + return ""; + } + + final buffer = StringBuffer() + ..writeln() + ..writeln('⚠️ *Parameter Limit Alert:*') + ..writeln('The following parameters were outside their defined limits:'); + buffer.writeAll(outOfBoundsMessages, '\n'); + + return buffer.toString(); + } + Future> submitTarballSample({ required Map formData, required Map imageFiles, @@ -666,8 +827,9 @@ class RiverApiService { final BaseApiService _baseService; final TelegramService _telegramService; final ServerConfigService _serverConfigService; + final DatabaseHelper _dbHelper; - RiverApiService(this._baseService, this._telegramService, this._serverConfigService); + RiverApiService(this._baseService, this._telegramService, this._serverConfigService, this._dbHelper); Future> getManualStations() async { final baseUrl = await _serverConfigService.getActiveApiUrl(); @@ -749,37 +911,7 @@ class RiverApiService { Future _handleInSituSuccessAlert( Map formData, List>? appSettings, {required bool isDataOnly}) async { try { - final submissionType = isDataOnly ? "(Data Only)" : "(Data & Images)"; - final stationName = formData['r_man_station_name'] ?? 'N/A'; - final stationCode = formData['r_man_station_code'] ?? 'N/A'; - final submissionDate = formData['r_man_date'] ?? DateFormat('yyyy-MM-dd').format(DateTime.now()); - final submitter = formData['first_sampler_name'] ?? 'N/A'; - final sondeID = formData['r_man_sondeID'] ?? 'N/A'; - final distanceKm = double.tryParse(formData['r_man_distance_difference'] ?? '0') ?? 0; - final distanceMeters = (distanceKm * 1000).toStringAsFixed(0); - final distanceRemarks = formData['r_man_distance_difference_remarks'] ?? 'N/A'; - - final buffer = StringBuffer() - ..writeln('✅ *River In-Situ Sample ${submissionType} Submitted:*') - ..writeln() - ..writeln('*Station Name & Code:* $stationName ($stationCode)') - ..writeln('*Date of Submitted:* $submissionDate') - ..writeln('*Submitted by User:* $submitter') - ..writeln('*Sonde ID:* $sondeID') - ..writeln('*Status of Submission:* Successful'); - - if (distanceKm > 0 || (distanceRemarks.isNotEmpty && distanceRemarks != 'N/A')) { - buffer - ..writeln() - ..writeln('🔔 *Alert:*') - ..writeln('*Distance from station:* $distanceMeters meters'); - if (distanceRemarks.isNotEmpty && distanceRemarks != 'N/A') { - buffer.writeln('*Remarks for distance:* $distanceRemarks'); - } - } - - final String message = buffer.toString(); - + final String message = await _generateInSituAlertMessage(formData, isDataOnly: isDataOnly); final bool wasSent = await _telegramService.sendAlertImmediately('river_in_situ', message, appSettings); if (!wasSent) { await _telegramService.queueMessage('river_in_situ', message, appSettings); @@ -788,6 +920,114 @@ class RiverApiService { debugPrint("Failed to handle River Telegram alert: $e"); } } + + Future _generateInSituAlertMessage(Map formData, {required bool isDataOnly}) async { + final submissionType = isDataOnly ? "(Data Only)" : "(Data & Images)"; + final stationName = formData['r_man_station_name'] ?? 'N/A'; + final stationCode = formData['r_man_station_code'] ?? 'N/A'; + final submissionDate = formData['r_man_date'] ?? DateFormat('yyyy-MM-dd').format(DateTime.now()); + final submitter = formData['first_sampler_name'] ?? 'N/A'; + final sondeID = formData['r_man_sondeID'] ?? 'N/A'; + final distanceKm = double.tryParse(formData['r_man_distance_difference'] ?? '0') ?? 0; + final distanceMeters = (distanceKm * 1000).toStringAsFixed(0); + final distanceRemarks = formData['r_man_distance_difference_remarks'] ?? 'N/A'; + + final buffer = StringBuffer() + ..writeln('✅ *River In-Situ Sample ${submissionType} Submitted:*') + ..writeln() + ..writeln('*Station Name & Code:* $stationName ($stationCode)') + ..writeln('*Date of Submitted:* $submissionDate') + ..writeln('*Submitted by User:* $submitter') + ..writeln('*Sonde ID:* $sondeID') + ..writeln('*Status of Submission:* Successful'); + + if (distanceKm > 0 || (distanceRemarks.isNotEmpty && distanceRemarks != 'N/A')) { + buffer + ..writeln() + ..writeln('🔔 *Distance Alert:*') + ..writeln('*Distance from station:* $distanceMeters meters'); + if (distanceRemarks.isNotEmpty && distanceRemarks != 'N/A') { + buffer.writeln('*Remarks for distance:* $distanceRemarks'); + } + } + + final outOfBoundsAlert = await _getOutOfBoundsAlertSection(formData); + if (outOfBoundsAlert.isNotEmpty) { + buffer.write(outOfBoundsAlert); + } + + return buffer.toString(); + } + + Future _getOutOfBoundsAlertSection(Map formData) async { + const Map _formKeyToLimitName = { + 'r_man_ph': 'pH', + 'r_man_temperature': 'Temperature', + 'r_man_dissolved_oxygen': 'Dissolved Oxygen', + 'r_man_conductivity': 'Conductivity', + 'r_man_salinity': 'Salinity', + 'r_man_turbidity': 'Turbidity', + 'r_man_tds': 'TDS', + 'r_man_sonde_battery': 'Sonde Battery', + }; + + final allLimits = await _dbHelper.loadRiverParameterLimits() ?? []; + if (allLimits.isEmpty) return ""; + + final int? stationId = int.tryParse(formData['r_man_station_id'] ?? ''); + final List outOfBoundsMessages = []; + + double? parseLimitValue(dynamic value) { + if (value == null) return null; + if (value is num) return value.toDouble(); + if (value is String) return double.tryParse(value); + return null; + } + + formData.forEach((key, valueStr) { + final double? value = double.tryParse(valueStr); + if (value == null || value == -999.0) return; + + final limitName = _formKeyToLimitName[key]; + if (limitName == null) return; + + Map limitData = {}; + if (stationId != null) { + limitData = allLimits.firstWhere( + (l) => l['param_parameter_list'] == limitName && l['r_man_station_id'] == stationId, + orElse: () => {}, + ); + } + if (limitData.isEmpty) { + limitData = allLimits.firstWhere( + (l) => l['param_parameter_list'] == limitName && l['r_man_station_id'] == null, + orElse: () => {}, + ); + } + + if (limitData.isNotEmpty) { + final lowerLimit = parseLimitValue(limitData['param_lower_limit']); + final upperLimit = parseLimitValue(limitData['param_upper_limit']); + + if ((lowerLimit != null && value < lowerLimit) || (upperLimit != null && value > upperLimit)) { + final valueFmt = value.toStringAsFixed(5); + final lowerFmt = lowerLimit?.toStringAsFixed(5) ?? 'N/A'; + final upperFmt = upperLimit?.toStringAsFixed(5) ?? 'N/A'; + outOfBoundsMessages.add('- *$limitName*: `$valueFmt` (Limit: `$lowerFmt` - `$upperFmt`)'); + } + } + }); + + if (outOfBoundsMessages.isEmpty) return ""; + + final buffer = StringBuffer() + ..writeln() + ..writeln('⚠️ *Parameter Limit Alert:*') + ..writeln('The following parameters were outside their defined limits:'); + buffer.writeAll(outOfBoundsMessages, '\n'); + + return buffer.toString(); + } } // ======================================================================= @@ -797,10 +1037,7 @@ class RiverApiService { class DatabaseHelper { static Database? _database; static const String _dbName = 'app_data.db'; - // --- START: INCREMENTED DB VERSION --- static const int _dbVersion = 23; - // --- END: INCREMENTED DB VERSION --- - static const String _profileTable = 'user_profile'; static const String _usersTable = 'all_users'; static const String _tarballStationsTable = 'marine_tarball_stations'; @@ -817,11 +1054,9 @@ class DatabaseHelper { static const String _statesTable = 'states'; static const String _appSettingsTable = 'app_settings'; static const String _parameterLimitsTable = 'manual_parameter_limits'; - // --- START: ADDED NEW TABLE CONSTANTS --- static const String _npeParameterLimitsTable = 'npe_parameter_limits'; static const String _marineParameterLimitsTable = 'marine_parameter_limits'; static const String _riverParameterLimitsTable = 'river_parameter_limits'; - // --- END: ADDED NEW TABLE CONSTANTS --- static const String _apiConfigsTable = 'api_configurations'; static const String _ftpConfigsTable = 'ftp_configurations'; static const String _retryQueueTable = 'retry_queue'; @@ -867,11 +1102,9 @@ class DatabaseHelper { await db.execute('CREATE TABLE $_statesTable(state_id INTEGER PRIMARY KEY, state_json TEXT)'); await db.execute('CREATE TABLE $_appSettingsTable(setting_id INTEGER PRIMARY KEY, setting_json TEXT)'); await db.execute('CREATE TABLE $_parameterLimitsTable(param_autoid INTEGER PRIMARY KEY, limit_json TEXT)'); - // --- START: ADDED CREATE TABLE FOR NEW LIMITS --- await db.execute('CREATE TABLE $_npeParameterLimitsTable(param_autoid INTEGER PRIMARY KEY, limit_json TEXT)'); await db.execute('CREATE TABLE $_marineParameterLimitsTable(param_autoid INTEGER PRIMARY KEY, limit_json TEXT)'); await db.execute('CREATE TABLE $_riverParameterLimitsTable(param_autoid INTEGER PRIMARY KEY, limit_json TEXT)'); - // --- END: ADDED CREATE TABLE FOR NEW LIMITS --- await db.execute('CREATE TABLE $_apiConfigsTable(api_config_id INTEGER PRIMARY KEY, config_json TEXT)'); await db.execute('CREATE TABLE $_ftpConfigsTable(ftp_config_id INTEGER PRIMARY KEY, config_json TEXT)'); await db.execute(''' @@ -1015,16 +1248,13 @@ class DatabaseHelper { debugPrint("Upgrade warning: Failed to add password_hash column to users table (may already exist): $e"); } } - // --- START: ADDED UPGRADE LOGIC FOR NEW TABLES --- if (oldVersion < 23) { await db.execute('CREATE TABLE IF NOT EXISTS $_npeParameterLimitsTable(param_autoid INTEGER PRIMARY KEY, limit_json TEXT)'); await db.execute('CREATE TABLE IF NOT EXISTS $_marineParameterLimitsTable(param_autoid INTEGER PRIMARY KEY, limit_json TEXT)'); await db.execute('CREATE TABLE IF NOT EXISTS $_riverParameterLimitsTable(param_autoid INTEGER PRIMARY KEY, limit_json TEXT)'); } - // --- END: ADDED UPGRADE LOGIC FOR NEW TABLES --- } - /// Performs an "upsert": inserts new records or replaces existing ones. Future _upsertData(String table, String idKeyName, List> data, String jsonKeyName) async { if (data.isEmpty) return; final db = await database; @@ -1040,7 +1270,6 @@ class DatabaseHelper { debugPrint("Upserted ${data.length} items into $table"); } - /// Deletes a list of records from a table by their primary keys. Future _deleteData(String table, String idKeyName, List ids) async { if (ids.isEmpty) return; final db = await database; @@ -1075,9 +1304,6 @@ class DatabaseHelper { return null; } - // --- START: Offline Authentication and User Upsert Methods --- - - /// Retrieves a user's profile JSON from the users table by email. Future?> loadProfileByEmail(String email) async { final db = await database; final List> maps = await db.query( @@ -1097,8 +1323,6 @@ class DatabaseHelper { return null; } - /// Inserts or replaces a user's profile and credentials. - /// This ensures the record exists when caching credentials during login. Future upsertUserWithCredentials({ required Map profile, required String passwordHash, @@ -1117,7 +1341,6 @@ class DatabaseHelper { debugPrint("Upserted user credentials for ${profile['email']}"); } - /// Retrieves the stored password hash for a user by email. Future getUserPasswordHashByEmail(String email) async { final db = await database; final List> result = await db.query( @@ -1132,18 +1355,14 @@ class DatabaseHelper { return null; } - // --- START: Custom upsert method for user sync to prevent hash overwrite --- - /// Upserts user data from sync without overwriting the local password hash. Future upsertUsers(List> data) async { if (data.isEmpty) return; final db = await database; for (var item in data) { final updateData = { - //'email': item['email'], 'user_json': jsonEncode(item), }; - // Try to update existing record first, preserving other columns like password_hash int count = await db.update( _usersTable, updateData, @@ -1151,7 +1370,6 @@ class DatabaseHelper { whereArgs: [item['user_id']], ); - // If no record was updated (count == 0), insert a new record. if (count == 0) { await db.insert( _usersTable, @@ -1159,7 +1377,6 @@ class DatabaseHelper { 'user_id': item['user_id'], 'email': item['email'], 'user_json': jsonEncode(item), - // password_hash will be null for a new user until they log in on this device. }, conflictAlgorithm: ConflictAlgorithm.ignore, ); @@ -1167,7 +1384,6 @@ class DatabaseHelper { } debugPrint("Upserted ${data.length} user items in custom upsert method."); } - // --- END: Custom upsert method --- Future deleteUsers(List ids) => _deleteData(_usersTable, 'user_id', ids); Future>?> loadUsers() => _loadData(_usersTable, 'user'); @@ -1234,7 +1450,6 @@ class DatabaseHelper { Future deleteParameterLimits(List ids) => _deleteData(_parameterLimitsTable, 'param_autoid', ids); Future>?> loadParameterLimits() => _loadData(_parameterLimitsTable, 'limit'); - // --- START: ADDED NEW DB METHODS FOR PARAMETER LIMITS --- Future upsertNpeParameterLimits(List> data) => _upsertData(_npeParameterLimitsTable, 'param_autoid', data, 'limit'); Future deleteNpeParameterLimits(List ids) => _deleteData(_npeParameterLimitsTable, 'param_autoid', ids); Future>?> loadNpeParameterLimits() => _loadData(_npeParameterLimitsTable, 'limit'); @@ -1246,7 +1461,6 @@ class DatabaseHelper { Future upsertRiverParameterLimits(List> data) => _upsertData(_riverParameterLimitsTable, 'param_autoid', data, 'limit'); Future deleteRiverParameterLimits(List ids) => _deleteData(_riverParameterLimitsTable, 'param_autoid', ids); Future>?> loadRiverParameterLimits() => _loadData(_riverParameterLimitsTable, 'limit'); - // --- END: ADDED NEW DB METHODS FOR PARAMETER LIMITS --- Future upsertApiConfigs(List> data) => _upsertData(_apiConfigsTable, 'api_config_id', data, 'config'); Future deleteApiConfigs(List ids) => _deleteData(_apiConfigsTable, 'api_config_id', ids); diff --git a/lib/services/marine_in_situ_sampling_service.dart b/lib/services/marine_in_situ_sampling_service.dart index 89c27b4..5e8e5df 100644 --- a/lib/services/marine_in_situ_sampling_service.dart +++ b/lib/services/marine_in_situ_sampling_service.dart @@ -405,8 +405,8 @@ class MarineInSituSamplingService { return { 'statuses': >[ - ...(ftpDataResult['statuses'] as List), - ...(ftpImageResult['statuses'] as List), + ...(ftpDataResult['statuses'] as List? ?? []), + ...(ftpImageResult['statuses'] as List? ?? []), ], }; }