diff --git a/lib/home_page.dart b/lib/home_page.dart index fc4bb75..f15cf45 100644 --- a/lib/home_page.dart +++ b/lib/home_page.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import 'package:environment_monitoring_app/auth_provider.dart'; -import 'package:environment_monitoring_app/collapsible_sidebar.dart'; // Import your sidebar widget +import 'package:environment_monitoring_app/collapsible_sidebar.dart'; class HomePage extends StatefulWidget { const HomePage({super.key}); @@ -17,6 +17,7 @@ class _HomePageState extends State { @override Widget build(BuildContext context) { final auth = Provider.of(context); + final colorScheme = Theme.of(context).colorScheme; return Scaffold( appBar: AppBar( @@ -56,31 +57,60 @@ class _HomePageState extends State { ), Expanded( child: Padding( - padding: const EdgeInsets.all(24), + padding: const EdgeInsets.all(12), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( "Welcome, ${auth.userEmail ?? 'User'}", - style: Theme.of(context).textTheme.headlineSmall?.copyWith( + style: Theme.of(context).textTheme.titleMedium?.copyWith( fontWeight: FontWeight.bold, + color: colorScheme.onBackground, ), ), - const SizedBox(height: 32), + const SizedBox(height: 8), Text( "Select a Department:", - style: Theme.of(context).textTheme.titleMedium, + style: Theme.of(context).textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w600, + color: colorScheme.onBackground, + ), ), - const SizedBox(height: 16), - Wrap( - spacing: 16, - runSpacing: 16, - children: [ - // Updated navigation to the new department home pages - _buildNavButton(context, "Air", Icons.cloud, '/air/home'), - _buildNavButton(context, "River", Icons.water, '/river/home'), - _buildNavButton(context, "Marine", Icons.sailing, '/marine/home'), - ], + const SizedBox(height: 8), + Expanded( + child: GridView.count( + crossAxisCount: 2, + mainAxisSpacing: 8, + crossAxisSpacing: 8, + childAspectRatio: 1.6, // Wider and much shorter boxes + padding: EdgeInsets.zero, // No extra padding + shrinkWrap: true, + physics: const ClampingScrollPhysics(), + children: [ + _buildMiniCategoryCard( + context, + title: "Air", + icon: Icons.air, + color: Colors.blue.shade700, + route: '/air/home', + ), + _buildMiniCategoryCard( + context, + title: "River", + icon: Icons.water, + color: Colors.teal.shade700, + route: '/river/home', + ), + _buildMiniCategoryCard( + context, + title: "Marine", + icon: Icons.sailing, + color: Colors.indigo.shade700, + route: '/marine/home', + ), + _buildMiniSettingsCard(context), + ], + ), ), ], ), @@ -91,15 +121,89 @@ class _HomePageState extends State { ); } - Widget _buildNavButton(BuildContext context, String label, IconData icon, String route) { - return ElevatedButton.icon( - onPressed: () => Navigator.pushNamed(context, route), - icon: Icon(icon, size: 24), - label: Text(label), - style: ElevatedButton.styleFrom( - padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16), - textStyle: const TextStyle(fontSize: 16), + Widget _buildMiniCategoryCard( + BuildContext context, { + required String title, + required IconData icon, + required Color color, + required String route, + }) { + return Card( + elevation: 1, + margin: EdgeInsets.zero, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(6), + ), + child: InkWell( + borderRadius: BorderRadius.circular(6), + onTap: () => Navigator.pushNamed(context, route), + child: Container( + padding: const EdgeInsets.symmetric(vertical: 6, horizontal: 8), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(6), + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [color.withOpacity(0.9), color], + ), + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(icon, size: 26, color: Colors.white), + const SizedBox(height: 4), + Text( + title, + style: const TextStyle( + color: Colors.white, + fontSize: 13, + fontWeight: FontWeight.bold, + ), + ), + ], + ), + ), ), ); } -} + + Widget _buildMiniSettingsCard(BuildContext context) { + return Card( + elevation: 1, + margin: EdgeInsets.zero, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(6), + ), + child: InkWell( + borderRadius: BorderRadius.circular(6), + onTap: () => Navigator.pushNamed(context, '/settings'), + child: Container( + padding: const EdgeInsets.symmetric(vertical: 6, horizontal: 8), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(6), + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [Colors.grey.shade700, Colors.grey.shade800], + ), + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.settings, size: 26, color: Colors.white), + const SizedBox(height: 4), + const Text( + "Settings", + style: TextStyle( + color: Colors.white, + fontSize: 13, + fontWeight: FontWeight.bold, + ), + ), + ], + ), + ), + ), + ); + } +} \ No newline at end of file diff --git a/lib/models/in_situ_sampling_data.dart b/lib/models/in_situ_sampling_data.dart index 9c38ff3..ee2ea39 100644 --- a/lib/models/in_situ_sampling_data.dart +++ b/lib/models/in_situ_sampling_data.dart @@ -71,6 +71,36 @@ class InSituSamplingData { this.samplingTime, }); + /// Generates a formatted Telegram alert message for successful submissions. + String generateTelegramAlertMessage({required bool isDataOnly}) { + final submissionType = isDataOnly ? "(Data Only)" : "(Data & Images)"; + final stationName = selectedStation?['man_station_name'] ?? 'N/A'; + final stationCode = selectedStation?['man_station_code'] ?? 'N/A'; + + final buffer = StringBuffer() + ..writeln('✅ *In-Situ Sample $submissionType Submitted:*') + ..writeln() + ..writeln('*Station Name & Code:* $stationName ($stationCode)') + ..writeln('*Date of Submission:* $samplingDate') + ..writeln('*Submitted by User:* $firstSamplerName') + ..writeln('*Sonde ID:* ${sondeId ?? "N/A"}') + ..writeln('*Status of Submission:* Successful'); + + // Add distance alert if relevant + if (distanceDifferenceInKm != null && distanceDifferenceInKm! > 0) { + buffer + ..writeln() + ..writeln('🔔 *Alert:*') + ..writeln('*Distance from station:* ${(distanceDifferenceInKm! * 1000).toStringAsFixed(0)} meters'); + + if (distanceDifferenceRemarks != null && distanceDifferenceRemarks!.isNotEmpty) { + buffer.writeln('*Remarks for distance:* $distanceDifferenceRemarks'); + } + } + + return buffer.toString(); + } + /// Converts the data model into a Map for the API form data. Map toApiFormData() { final Map map = {}; diff --git a/lib/models/tarball_data.dart b/lib/models/tarball_data.dart index f73807a..d1cc171 100644 --- a/lib/models/tarball_data.dart +++ b/lib/models/tarball_data.dart @@ -17,9 +17,12 @@ class TarballSamplingData { String? currentLatitude; String? currentLongitude; double? distanceDifference; + String? distanceDifferenceRemarks; // --- Step 2 Data: Collected in TarballSamplingStep2 --- - int? classificationId; // CORRECTED: Only the ID is needed. + int? classificationId; + // NECESSARY CHANGE: Add property to hold the full classification object. + Map? selectedClassification; File? leftCoastalViewImage; File? rightCoastalViewImage; File? verticalLinesImage; @@ -38,6 +41,38 @@ class TarballSamplingData { String? submissionStatus; String? submissionMessage; + /// Generates a formatted Telegram alert message for successful submissions. + String generateTelegramAlertMessage({required bool isDataOnly}) { + final submissionType = isDataOnly ? "(Data Only)" : "(Data & Images)"; + final stationName = selectedStation?['tbl_station_name'] ?? 'N/A'; + final stationCode = selectedStation?['tbl_station_code'] ?? 'N/A'; + // This logic now correctly uses the full classification object if available. + final classification = selectedClassification?['classification_name'] ?? classificationId?.toString() ?? 'N/A'; + + final buffer = StringBuffer() + ..writeln('✅ *Tarball Sample $submissionType Submitted:*') + ..writeln() + ..writeln('*Station Name & Code:* $stationName ($stationCode)') + ..writeln('*Date of Submission:* $samplingDate') + ..writeln('*Submitted by User:* $firstSampler') + ..writeln('*Classification:* $classification') + ..writeln('*Status of Submission:* Successful'); + + // Add distance alert if relevant + if (distanceDifference != null && distanceDifference! > 0) { + buffer + ..writeln() + ..writeln('🔔 *Alert:*') + ..writeln('*Distance from station:* ${(distanceDifference! * 1000).toStringAsFixed(0)} meters'); + + if (distanceDifferenceRemarks != null && distanceDifferenceRemarks!.isNotEmpty) { + buffer.writeln('*Remarks for distance:* $distanceDifferenceRemarks'); + } + } + + return buffer.toString(); + } + /// Converts the form's text and selection data into a Map suitable for JSON encoding. /// This map will be sent as the body of the first API request. Map toFormData() { @@ -62,6 +97,15 @@ class TarballSamplingData { 'optional_photo_remark_02': optionalRemark2 ?? '', 'optional_photo_remark_03': optionalRemark3 ?? '', 'optional_photo_remark_04': optionalRemark4 ?? '', + 'distance_difference_remarks': distanceDifferenceRemarks ?? '', + + // Human-readable names for the Telegram alert + 'tbl_station_name': selectedStation?['tbl_station_name']?.toString() ?? '', + 'tbl_station_code': selectedStation?['tbl_station_code']?.toString() ?? '', + 'first_sampler_name': firstSampler ?? '', + + // NECESSARY CHANGE: Add the classification name for the alert. + 'classification_name': selectedClassification?['classification_name']?.toString() ?? '', }; return data; } @@ -80,4 +124,4 @@ class TarballSamplingData { 'optional_photo_04': optionalImage4, }; } -} +} \ No newline at end of file diff --git a/lib/screens/air/air_home_page.dart b/lib/screens/air/air_home_page.dart index aeb44eb..ca2dae4 100644 --- a/lib/screens/air/air_home_page.dart +++ b/lib/screens/air/air_home_page.dart @@ -24,15 +24,13 @@ class AirHomePage extends StatelessWidget { const AirHomePage({super.key}); // Define Air's sub-menu structure (Manual, Continuous, Investigative) - // This mirrors the structure from collapsible_sidebar.dart for consistency. final List _airSubMenus = const [ SidebarItem( - icon: Icons.handshake, // Example icon for Manual + icon: Icons.handshake, label: "Manual", isParent: true, children: [ SidebarItem(icon: Icons.dashboard, label: "Dashboard", route: '/air/manual/dashboard'), - // --- UPDATED: Replaced 'Manual Sampling' with 'Installation' and 'Collection' --- SidebarItem(icon: Icons.construction, label: "Installation", route: '/air/manual/installation'), SidebarItem(icon: Icons.inventory_2, label: "Collection", route: '/air/manual/collection'), SidebarItem(icon: Icons.receipt_long, label: "Report", route: '/air/manual/report'), @@ -114,18 +112,16 @@ class AirHomePage extends StatelessWidget { ], ), ), - const Divider(height: 24, thickness: 1, color: Colors.white24), // Divider below category title - // Grid of sub-menu items + const Divider(height: 24, thickness: 1, color: Colors.white24), + // Grid of sub-menu items - changed to 2 columns GridView.builder( shrinkWrap: true, physics: const NeverScrollableScrollPhysics(), gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( - // --- UPDATED: Changed from 3 columns to 2 --- - crossAxisCount: 2, // 2 columns for sub-menu items - crossAxisSpacing: 0.0, // Removed horizontal spacing - mainAxisSpacing: 0.0, // Removed vertical spacing - // --- UPDATED: Adjusted aspect ratio for a 2-column layout --- - childAspectRatio: 3.5, // Adjusted for a 2-column horizontal layout + crossAxisCount: 2, // Changed from 3 to 2 columns + crossAxisSpacing: 0.0, + mainAxisSpacing: 0.0, + childAspectRatio: 4.0, // Adjusted aspect ratio for better 2-column layout ), itemCount: category.children?.length ?? 0, itemBuilder: (context, index) { @@ -136,33 +132,42 @@ class AirHomePage extends StatelessWidget { Navigator.pushNamed(context, subItem.route!); } }, - borderRadius: BorderRadius.circular(0), // Removed border radius for seamless grid - child: Padding( - padding: const EdgeInsets.all(8.0), // Padding around the row content - child: Row( // Changed from Column to Row - mainAxisAlignment: MainAxisAlignment.start, // Align content to start - children: [ - subItem.icon != null - ? Icon(subItem.icon, color: Colors.white70, size: 24) // Increased icon size from 22 to 24 - : const SizedBox.shrink(), - const SizedBox(width: 8), // Space between icon and text (horizontal) - Expanded( // Allow text to take remaining space - child: Text( - subItem.label, - style: Theme.of(context).textTheme.bodySmall?.copyWith(color: Colors.white70, fontSize: 11), // Increased text size from 10 to 11 - textAlign: TextAlign.left, // Align text to left - overflow: TextOverflow.ellipsis, - maxLines: 1, // Single line for label + borderRadius: BorderRadius.circular(0), + child: Container( + margin: const EdgeInsets.all(4.0), // Added margin for better spacing + decoration: BoxDecoration( + border: Border.all(color: Colors.white24, width: 0.5), // Optional: subtle border + ), + child: Padding( + padding: const EdgeInsets.all(8.0), + child: Row( + mainAxisAlignment: MainAxisAlignment.start, + children: [ + subItem.icon != null + ? Icon(subItem.icon, color: Colors.white70, size: 24) + : const SizedBox.shrink(), + const SizedBox(width: 8), + Expanded( + child: Text( + subItem.label, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Colors.white70, + fontSize: 12, // Slightly increased font size + ), + textAlign: TextAlign.left, + overflow: TextOverflow.ellipsis, + maxLines: 2, // Allow for two lines if needed + ), ), - ), - ], + ], + ), ), ), ); }, ), - const SizedBox(height: 16), // Reduced gap after each category group + const SizedBox(height: 16), ], ); } -} +} \ No newline at end of file diff --git a/lib/screens/marine/manual/data_status_log.dart b/lib/screens/marine/manual/data_status_log.dart index 1ccd411..0243b3e 100644 --- a/lib/screens/marine/manual/data_status_log.dart +++ b/lib/screens/marine/manual/data_status_log.dart @@ -236,6 +236,7 @@ class _MarineManualDataStatusLogState extends State { return _marineApiService.submitInSituSample( formData: dataToResubmit.toApiFormData(), imageFiles: imageFiles, + inSituData: dataToResubmit, // Added this required parameter ); } else if (log.type == 'Tarball Sampling') { final int? firstSamplerId = int.tryParse(logData['first_sampler_user_id']?.toString() ?? ''); diff --git a/lib/screens/marine/manual/tarball_sampling_step2.dart b/lib/screens/marine/manual/tarball_sampling_step2.dart index 8b1dd61..2d73b3a 100644 --- a/lib/screens/marine/manual/tarball_sampling_step2.dart +++ b/lib/screens/marine/manual/tarball_sampling_step2.dart @@ -1,10 +1,11 @@ import 'dart:io'; import 'package:flutter/material.dart'; -import 'package:image_picker/image_picker.dart'; import 'package:provider/provider.dart'; +import 'package:image_picker/image_picker.dart'; import 'package:path_provider/path_provider.dart'; import 'package:path/path.dart' as path; import 'package:image/image.dart' as img; +import 'package:dropdown_search/dropdown_search.dart'; import 'package:environment_monitoring_app/auth_provider.dart'; import 'package:environment_monitoring_app/models/tarball_data.dart'; @@ -22,6 +23,7 @@ class _TarballSamplingStep2State extends State { final _formKey = GlobalKey(); bool _isPickingImage = false; + // This will hold the user's selection in the UI Map? _selectedClassification; late final TextEditingController _remark1Controller; @@ -38,25 +40,32 @@ class _TarballSamplingStep2State extends State { _remark3Controller = TextEditingController(text: widget.data.optionalRemark3); _remark4Controller = TextEditingController(text: widget.data.optionalRemark4); + // This block ensures the dropdown shows the correct value if the user comes back to this screen WidgetsBinding.instance.addPostFrameCallback((_) { - if (widget.data.classificationId != null) { - final auth = Provider.of(context, listen: false); - final classifications = auth.tarballClassifications ?? []; - if (classifications.isNotEmpty) { - try { - final foundClassification = classifications.firstWhere( - (c) => c['classification_id'] == widget.data.classificationId, - ); - if (mounted) { - setState(() { - _selectedClassification = foundClassification; - }); - } - } catch (e) { - debugPrint("Could not find pre-selected classification with ID: ${widget.data.classificationId}"); - } + final auth = Provider.of(context, listen: false); + + // Restore the selected value from the data model using the cached list in the provider + if (widget.data.classificationId != null && auth.tarballClassifications != null) { + try { + final foundClassification = auth.tarballClassifications!.firstWhere( + (c) => c['classification_id'] == widget.data.classificationId, + ); + setState(() { + _selectedClassification = foundClassification; + // Also restore the full object to the data model + widget.data.selectedClassification = foundClassification; + }); + } catch (e) { + debugPrint("Could not find pre-selected classification in the cached list."); } } + + // **OFFLINE-FIRST SYNC**: + // Attempt to sync all master data with the server in the background. + // The UI will build instantly using existing local data from AuthProvider. + // If the sync is successful, the Consumer widget will automatically rebuild the dropdown with fresh data. + // If offline, this will fail gracefully and the user will see the data from the last successful sync. + auth.syncAllData(); }); } @@ -69,7 +78,6 @@ class _TarballSamplingStep2State extends State { super.dispose(); } - /// Shows a dialog to the user informing them about the image orientation requirement. void _showOrientationDialog() { showDialog( context: context, @@ -88,7 +96,6 @@ class _TarballSamplingStep2State extends State { ); } - /// Picks an image, processes it (checks orientation, adds watermark), and returns the file. Future _pickAndProcessImage(ImageSource source, String imageInfo, {required bool isRequired}) async { if (_isPickingImage) return null; setState(() => _isPickingImage = true); @@ -108,24 +115,24 @@ class _TarballSamplingStep2State extends State { return null; } - // --- NEW: Validate image orientation for required photos --- if (isRequired && originalImage.height > originalImage.width) { _showOrientationDialog(); setState(() => _isPickingImage = false); - return null; // Reject the vertical image + return null; } - // --- MODIFIED: Reduced watermark font size --- final String watermarkTimestamp = "${widget.data.samplingDate} ${widget.data.samplingTime}"; - final font = img.arial24; // Reduced from arial48 + final font = img.arial24; const int padding = 10; final textWidth = watermarkTimestamp.length * 12; final textHeight = 24; img.fillRect( originalImage, - x1: padding - 5, y1: padding - 5, - x2: padding + textWidth + 5, y2: padding + textHeight + 5, + x1: padding - 5, + y1: padding - 5, + x2: padding + textWidth + 5, + y2: padding + textHeight + 5, color: img.ColorRgb8(255, 255, 255), ); @@ -133,7 +140,8 @@ class _TarballSamplingStep2State extends State { originalImage, watermarkTimestamp, font: font, - x: padding, y: padding, + x: padding, + y: padding, color: img.ColorRgb8(0, 0, 0), ); @@ -161,7 +169,6 @@ class _TarballSamplingStep2State extends State { void _goToNextStep() { if (_formKey.currentState!.validate()) { - // --- NEW: Validate that a classification has been selected --- if (widget.data.classificationId == null) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar( @@ -172,7 +179,6 @@ class _TarballSamplingStep2State extends State { return; } - // --- NEW: Validate that all required photos have been attached --- if (widget.data.leftCoastalViewImage == null || widget.data.rightCoastalViewImage == null || widget.data.verticalLinesImage == null || @@ -183,7 +189,7 @@ class _TarballSamplingStep2State extends State { backgroundColor: Colors.red, ), ); - return; // Stop the function if validation fails + return; } widget.data.optionalRemark1 = _remark1Controller.text; @@ -210,31 +216,35 @@ class _TarballSamplingStep2State extends State { Text("On-Site Information", style: Theme.of(context).textTheme.headlineSmall), const SizedBox(height: 24), + // This dropdown now correctly consumes data from AuthProvider Consumer( builder: (context, auth, child) { - if (auth.tarballClassifications == null || auth.tarballClassifications!.isEmpty) { - return DropdownButtonFormField( - decoration: const InputDecoration( - labelText: 'Tarball Classification *', - hintText: 'Loading or no classifications found...', - ), - items: const [], - onChanged: null, - ); - } + final classifications = auth.tarballClassifications; + // The dropdown is enabled only when the classification list is available from the local cache. + final bool isEnabled = classifications != null; - return DropdownButtonFormField>( - decoration: const InputDecoration(labelText: 'Tarball Classification *'), - value: _selectedClassification, - items: auth.tarballClassifications!.map((classification) { - return DropdownMenuItem>( - value: classification, - child: Text(classification['classification_name']?.toString() ?? 'Unnamed'), - ); - }).toList(), + return DropdownSearch>( + items: classifications ?? [], // Use local data from provider + selectedItem: _selectedClassification, + enabled: isEnabled, + itemAsString: (item) => item['classification_name'] as String, + dropdownDecoratorProps: DropDownDecoratorProps( + dropdownSearchDecoration: InputDecoration( + labelText: "Tarball Classification *", + hintText: isEnabled ? "Select a classification" : "Loading classifications...", + ), + ), + popupProps: const PopupProps.menu( + showSearchBox: true, + searchFieldProps: TextFieldProps( + decoration: InputDecoration(hintText: "Search Classification..."), + ), + ), onChanged: (value) { setState(() { _selectedClassification = value; + // NECESSARY CHANGE: Save both the full object and the ID to the data model. + widget.data.selectedClassification = value; widget.data.classificationId = value?['classification_id']; }); }, @@ -244,7 +254,6 @@ class _TarballSamplingStep2State extends State { ), const SizedBox(height: 24), - // --- MODIFIED: Added asterisk to indicate required section --- Text("Required Photos *", style: Theme.of(context).textTheme.titleLarge), _buildImagePicker('Left Side Coastal View', 'LEFTSIDECOASTALVIEW', widget.data.leftCoastalViewImage, (file) => widget.data.leftCoastalViewImage = file, isRequired: true), _buildImagePicker('Right Side Coastal View', 'RIGHTSIDECOASTALVIEW', widget.data.rightCoastalViewImage, (file) => widget.data.rightCoastalViewImage = file, isRequired: true), @@ -271,14 +280,12 @@ class _TarballSamplingStep2State extends State { ); } - // --- MODIFIED: Added isRequired flag --- 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: [ - // --- MODIFIED: Add asterisk to title if required --- Text(title + (isRequired ? ' *' : ''), style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w500)), const SizedBox(height: 8), if (imageFile != null) @@ -287,8 +294,7 @@ class _TarballSamplingStep2State extends State { children: [ ClipRRect( borderRadius: BorderRadius.circular(8.0), - child: Image.file(imageFile, key: UniqueKey(), height: 150, width: double.infinity, fit: BoxFit.cover) - ), + child: Image.file(imageFile, key: UniqueKey(), height: 150, width: double.infinity, fit: BoxFit.cover)), Container( margin: const EdgeInsets.all(4), decoration: BoxDecoration( @@ -327,4 +333,4 @@ class _TarballSamplingStep2State extends State { ), ); } -} +} \ No newline at end of file diff --git a/lib/screens/marine/marine_home_page.dart b/lib/screens/marine/marine_home_page.dart index 53b7fd0..6f971d1 100644 --- a/lib/screens/marine/marine_home_page.dart +++ b/lib/screens/marine/marine_home_page.dart @@ -114,16 +114,16 @@ class MarineHomePage extends StatelessWidget { ], ), ), - const Divider(height: 24, thickness: 1, color: Colors.white24), // Divider below category title - // Grid of sub-menu items + const Divider(height: 24, thickness: 1, color: Colors.white24), + // Grid of sub-menu items - changed to 2 columns GridView.builder( shrinkWrap: true, physics: const NeverScrollableScrollPhysics(), gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: 3, // 3 columns for sub-menu items - crossAxisSpacing: 0.0, // Removed horizontal spacing - mainAxisSpacing: 0.0, // Removed vertical spacing - childAspectRatio: 2.8, // Adjusted aspect ratio for horizontal icon-label layout with bigger content + crossAxisCount: 2, // Changed from 3 to 2 columns + crossAxisSpacing: 0.0, + mainAxisSpacing: 0.0, + childAspectRatio: 4.0, // Adjusted aspect ratio for better 2-column layout ), itemCount: category.children?.length ?? 0, itemBuilder: (context, index) { @@ -134,33 +134,42 @@ class MarineHomePage extends StatelessWidget { Navigator.pushNamed(context, subItem.route!); } }, - borderRadius: BorderRadius.circular(0), // Removed border radius for seamless grid - child: Padding( - padding: const EdgeInsets.all(8.0), // Padding around the row content - child: Row( // Changed from Column to Row - mainAxisAlignment: MainAxisAlignment.start, // Align content to start - children: [ - subItem.icon != null - ? Icon(subItem.icon, color: Colors.white70, size: 24) // Increased icon size from 22 to 24 - : const SizedBox.shrink(), - const SizedBox(width: 8), // Space between icon and text (horizontal) - Expanded( // Allow text to take remaining space - child: Text( - subItem.label, - style: Theme.of(context).textTheme.bodySmall?.copyWith(color: Colors.white70, fontSize: 11), // Increased text size from 10 to 11 - textAlign: TextAlign.left, // Align text to left - overflow: TextOverflow.ellipsis, - maxLines: 1, // Single line for label + borderRadius: BorderRadius.circular(0), + child: Container( + margin: const EdgeInsets.all(4.0), // Added margin for better spacing + decoration: BoxDecoration( + border: Border.all(color: Colors.white24, width: 0.5), // Optional: subtle border + ), + child: Padding( + padding: const EdgeInsets.all(8.0), + child: Row( + mainAxisAlignment: MainAxisAlignment.start, + children: [ + subItem.icon != null + ? Icon(subItem.icon, color: Colors.white70, size: 24) + : const SizedBox.shrink(), + const SizedBox(width: 8), + Expanded( + child: Text( + subItem.label, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Colors.white70, + fontSize: 12, // Slightly increased font size + ), + textAlign: TextAlign.left, + overflow: TextOverflow.ellipsis, + maxLines: 2, // Allow for two lines if needed + ), ), - ), - ], + ], + ), ), ), ); }, ), - const SizedBox(height: 16), // Reduced gap after each category group + const SizedBox(height: 16), ], ); } -} +} \ No newline at end of file diff --git a/lib/screens/river/river_home_page.dart b/lib/screens/river/river_home_page.dart index 2094ecf..27e1db0 100644 --- a/lib/screens/river/river_home_page.dart +++ b/lib/screens/river/river_home_page.dart @@ -112,16 +112,16 @@ class RiverHomePage extends StatelessWidget { ], ), ), - const Divider(height: 24, thickness: 1, color: Colors.white24), // Divider below category title - // Grid of sub-menu items + const Divider(height: 24, thickness: 1, color: Colors.white24), + // Grid of sub-menu items - changed to 2 columns GridView.builder( shrinkWrap: true, physics: const NeverScrollableScrollPhysics(), gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: 3, // 3 columns for sub-menu items - crossAxisSpacing: 0.0, // Removed horizontal spacing - mainAxisSpacing: 0.0, // Removed vertical spacing - childAspectRatio: 2.8, // Adjusted aspect ratio for horizontal icon-label layout with bigger content + crossAxisCount: 2, // Changed from 3 to 2 columns + crossAxisSpacing: 0.0, + mainAxisSpacing: 0.0, + childAspectRatio: 4.0, // Adjusted aspect ratio for better 2-column layout ), itemCount: category.children?.length ?? 0, itemBuilder: (context, index) { @@ -132,33 +132,42 @@ class RiverHomePage extends StatelessWidget { Navigator.pushNamed(context, subItem.route!); } }, - borderRadius: BorderRadius.circular(0), // Removed border radius for seamless grid - child: Padding( - padding: const EdgeInsets.all(8.0), // Padding around the row content - child: Row( // Changed from Column to Row - mainAxisAlignment: MainAxisAlignment.start, // Align content to start - children: [ - subItem.icon != null - ? Icon(subItem.icon, color: Colors.white70, size: 24) // Increased icon size from 22 to 24 - : const SizedBox.shrink(), - const SizedBox(width: 8), // Space between icon and text (horizontal) - Expanded( // Allow text to take remaining space - child: Text( - subItem.label, - style: Theme.of(context).textTheme.bodySmall?.copyWith(color: Colors.white70, fontSize: 11), // Increased text size from 10 to 11 - textAlign: TextAlign.left, // Align text to left - overflow: TextOverflow.ellipsis, - maxLines: 1, // Single line for label + borderRadius: BorderRadius.circular(0), + child: Container( + margin: const EdgeInsets.all(4.0), // Added margin for better spacing + decoration: BoxDecoration( + border: Border.all(color: Colors.white24, width: 0.5), // Optional: subtle border + ), + child: Padding( + padding: const EdgeInsets.all(8.0), + child: Row( + mainAxisAlignment: MainAxisAlignment.start, + children: [ + subItem.icon != null + ? Icon(subItem.icon, color: Colors.white70, size: 24) + : const SizedBox.shrink(), + const SizedBox(width: 8), + Expanded( + child: Text( + subItem.label, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Colors.white70, + fontSize: 12, // Slightly increased font size + ), + textAlign: TextAlign.left, + overflow: TextOverflow.ellipsis, + maxLines: 2, // Allow for two lines if needed + ), ), - ), - ], + ], + ), ), ), ); }, ), - const SizedBox(height: 16), // Reduced gap after each category group + const SizedBox(height: 16), ], ); } -} +} \ No newline at end of file diff --git a/lib/screens/settings.dart b/lib/screens/settings.dart index ce42afe..335fe33 100644 --- a/lib/screens/settings.dart +++ b/lib/screens/settings.dart @@ -18,6 +18,12 @@ class _SettingsScreenState extends State { String _inSituChatId = 'Loading...'; String _tarballChatId = 'Loading...'; + String _riverInSituChatId = 'Loading...'; + String _riverTriennialChatId = 'Loading...'; + String _riverInvestigativeChatId = 'Loading...'; + String _airManualChatId = 'Loading...'; + String _airInvestigativeChatId = 'Loading...'; + String _marineInvestigativeChatId = 'Loading...'; final TextEditingController _tarballSearchController = TextEditingController(); String _tarballSearchQuery = ''; @@ -48,12 +54,27 @@ class _SettingsScreenState extends State { } Future _loadCurrentSettings() async { - final inSituId = await _settingsService.getInSituChatId(); - final tarballId = await _settingsService.getTarballChatId(); + final results = await Future.wait([ + _settingsService.getInSituChatId(), + _settingsService.getTarballChatId(), + _settingsService.getRiverInSituChatId(), + _settingsService.getRiverTriennialChatId(), + _settingsService.getRiverInvestigativeChatId(), + _settingsService.getAirManualChatId(), + _settingsService.getAirInvestigativeChatId(), + _settingsService.getMarineInvestigativeChatId(), + ]); + if (mounted) { setState(() { - _inSituChatId = inSituId.isNotEmpty ? inSituId : 'Not Set'; - _tarballChatId = tarballId.isNotEmpty ? tarballId : 'Not Set'; + _inSituChatId = results[0].isNotEmpty ? results[0] : 'Not Set'; + _tarballChatId = results[1].isNotEmpty ? results[1] : 'Not Set'; + _riverInSituChatId = results[2].isNotEmpty ? results[2] : 'Not Set'; + _riverTriennialChatId = results[3].isNotEmpty ? results[3] : 'Not Set'; + _riverInvestigativeChatId = results[4].isNotEmpty ? results[4] : 'Not Set'; + _airManualChatId = results[5].isNotEmpty ? results[5] : 'Not Set'; + _airInvestigativeChatId = results[6].isNotEmpty ? results[6] : 'Not Set'; + _marineInvestigativeChatId = results[7].isNotEmpty ? results[7] : 'Not Set'; }); } } @@ -74,7 +95,6 @@ class _SettingsScreenState extends State { setState(() { _riverTriennialSearchQuery = _riverTriennialSearchController.text; }); } - // --- FIXED: This method now uses try/catch to handle success and failure --- Future _manualDataSync() async { if (_isSyncingData) return; setState(() => _isSyncingData = true); @@ -82,20 +102,16 @@ class _SettingsScreenState extends State { final auth = Provider.of(context, listen: false); try { - // This function doesn't return a value, so we don't assign it to a variable. await auth.syncAllData(forceRefresh: true); - // If no error was thrown, the sync was successful. if (mounted) { _showSnackBar('Data synced successfully.', isError: false); } } catch (e) { - // If an error was thrown during the sync, we catch it here. if (mounted) { _showSnackBar('Data sync failed. Please check your connection.', isError: true); } } finally { - // This will run whether the sync succeeded or failed. if (mounted) { setState(() => _isSyncingData = false); } @@ -134,7 +150,6 @@ class _SettingsScreenState extends State { final auth = Provider.of(context); final lastSync = auth.lastSyncTimestamp; - // Filtering logic is unchanged final filteredTarballStations = auth.tarballStations?.where((station) { final stationName = station['tbl_station_name']?.toLowerCase() ?? ''; final stationCode = station['tbl_station_code']?.toLowerCase() ?? ''; @@ -203,19 +218,32 @@ class _SettingsScreenState extends State { child: Padding( padding: const EdgeInsets.all(16.0), child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - ListTile( - contentPadding: EdgeInsets.zero, - leading: const Icon(Icons.telegram), - title: const Text('Marine In-Situ Chat ID'), - subtitle: Text(_inSituChatId), + ExpansionTile( + title: const Text('Marine Alerts', style: TextStyle(fontWeight: FontWeight.bold)), + initiallyExpanded: false, + children: [ + _buildChatIdEntry('In-Situ', _inSituChatId), + _buildChatIdEntry('Tarball', _tarballChatId), + _buildChatIdEntry('Investigative', _marineInvestigativeChatId), + ], ), - ListTile( - contentPadding: EdgeInsets.zero, - leading: const Icon(Icons.telegram), - title: const Text('Marine Tarball Chat ID'), - subtitle: Text(_tarballChatId), + ExpansionTile( + title: const Text('River Alerts', style: TextStyle(fontWeight: FontWeight.bold)), + initiallyExpanded: false, + children: [ + _buildChatIdEntry('In-Situ', _riverInSituChatId), + _buildChatIdEntry('Triennial', _riverTriennialChatId), + _buildChatIdEntry('Investigative', _riverInvestigativeChatId), + ], + ), + ExpansionTile( + title: const Text('Air Alerts', style: TextStyle(fontWeight: FontWeight.bold)), + initiallyExpanded: false, + children: [ + _buildChatIdEntry('Manual', _airManualChatId), + _buildChatIdEntry('Investigative', _airInvestigativeChatId), + ], ), const SizedBox(height: 16), ElevatedButton.icon( @@ -233,7 +261,7 @@ class _SettingsScreenState extends State { ), const SizedBox(height: 32), - Text("Marine Tarball Stations (${filteredTarballStations?.length ?? 0} found)", style: Theme.of(context).textTheme.headlineSmall?.copyWith(fontWeight: FontWeight.bold)), + Text("Marine Tarball Stations (${filteredTarballStations?.length ?? 0})", style: Theme.of(context).textTheme.headlineSmall?.copyWith(fontWeight: FontWeight.bold)), const SizedBox(height: 16), Card( margin: EdgeInsets.zero, @@ -241,16 +269,34 @@ class _SettingsScreenState extends State { padding: const EdgeInsets.all(16.0), child: Column( children: [ - TextField(controller: _tarballSearchController, decoration: InputDecoration(labelText: 'Search Tarball Stations', hintText: 'Search by name or code', prefixIcon: const Icon(Icons.search), border: OutlineInputBorder(borderRadius: BorderRadius.circular(8.0)), suffixIcon: _tarballSearchController.text.isNotEmpty ? IconButton(icon: const Icon(Icons.clear), onPressed: () => _tarballSearchController.clear()) : null)), + TextField( + controller: _tarballSearchController, + decoration: InputDecoration( + labelText: 'Search Tarball Stations', + hintText: 'Search by name or code', + prefixIcon: const Icon(Icons.search), + border: OutlineInputBorder(borderRadius: BorderRadius.circular(8.0)), + suffixIcon: _tarballSearchController.text.isNotEmpty ? IconButton(icon: const Icon(Icons.clear), onPressed: () => _tarballSearchController.clear()) : null, + ), + ), const SizedBox(height: 16), - _buildStationList(filteredTarballStations, 'No matching tarball stations found.', 'No tarball stations available. Sync to download.', (station) => ListTile(title: Text(station['tbl_station_name'] ?? 'N/A'), subtitle: Text('Code: ${station['tbl_station_code'] ?? 'N/A'}'))), + _buildStationList( + filteredTarballStations, + 'No matching tarball stations found.', + 'No tarball stations available. Sync to download.', + (station) => ListTile( + title: Text(station['tbl_station_name'] ?? 'N/A'), + subtitle: Text('Code: ${station['tbl_station_code'] ?? 'N/A'}'), + dense: true, + ), + ), ], ), ), ), const SizedBox(height: 32), - Text("Marine Manual Stations (${filteredManualStations?.length ?? 0} found)", style: Theme.of(context).textTheme.headlineSmall?.copyWith(fontWeight: FontWeight.bold)), + Text("Marine Manual Stations (${filteredManualStations?.length ?? 0})", style: Theme.of(context).textTheme.headlineSmall?.copyWith(fontWeight: FontWeight.bold)), const SizedBox(height: 16), Card( margin: EdgeInsets.zero, @@ -258,16 +304,34 @@ class _SettingsScreenState extends State { padding: const EdgeInsets.all(16.0), child: Column( children: [ - TextField(controller: _manualSearchController, decoration: InputDecoration(labelText: 'Search Manual Stations', hintText: 'Search by name or code', prefixIcon: const Icon(Icons.search), border: OutlineInputBorder(borderRadius: BorderRadius.circular(8.0)), suffixIcon: _manualSearchController.text.isNotEmpty ? IconButton(icon: const Icon(Icons.clear), onPressed: () => _manualSearchController.clear()) : null)), + TextField( + controller: _manualSearchController, + decoration: InputDecoration( + labelText: 'Search Manual Stations', + hintText: 'Search by name or code', + prefixIcon: const Icon(Icons.search), + border: OutlineInputBorder(borderRadius: BorderRadius.circular(8.0)), + suffixIcon: _manualSearchController.text.isNotEmpty ? IconButton(icon: const Icon(Icons.clear), onPressed: () => _manualSearchController.clear()) : null, + ), + ), const SizedBox(height: 16), - _buildStationList(filteredManualStations, 'No matching manual stations found.', 'No manual stations available. Sync to download.', (station) => ListTile(title: Text(station['man_station_name'] ?? 'N/A'), subtitle: Text('Code: ${station['man_station_code'] ?? 'N/A'}'))), + _buildStationList( + filteredManualStations, + 'No matching manual stations found.', + 'No manual stations available. Sync to download.', + (station) => ListTile( + title: Text(station['man_station_name'] ?? 'N/A'), + subtitle: Text('Code: ${station['man_station_code'] ?? 'N/A'}'), + dense: true, + ), + ), ], ), ), ), const SizedBox(height: 32), - Text("River Manual Stations (${filteredRiverManualStations?.length ?? 0} found)", style: Theme.of(context).textTheme.headlineSmall?.copyWith(fontWeight: FontWeight.bold)), + Text("River Manual Stations (${filteredRiverManualStations?.length ?? 0})", style: Theme.of(context).textTheme.headlineSmall?.copyWith(fontWeight: FontWeight.bold)), const SizedBox(height: 16), Card( margin: EdgeInsets.zero, @@ -275,16 +339,41 @@ class _SettingsScreenState extends State { padding: const EdgeInsets.all(16.0), child: Column( children: [ - TextField(controller: _riverManualSearchController, decoration: InputDecoration(labelText: 'Search River Manual Stations', hintText: 'Search by river, basin, or code', prefixIcon: const Icon(Icons.search), border: OutlineInputBorder(borderRadius: BorderRadius.circular(8.0)), suffixIcon: _riverManualSearchController.text.isNotEmpty ? IconButton(icon: const Icon(Icons.clear), onPressed: () => _riverManualSearchController.clear()) : null)), + TextField( + controller: _riverManualSearchController, + decoration: InputDecoration( + labelText: 'Search River Manual Stations', + hintText: 'Search by river, basin, or code', + prefixIcon: const Icon(Icons.search), + border: OutlineInputBorder(borderRadius: BorderRadius.circular(8.0)), + suffixIcon: _riverManualSearchController.text.isNotEmpty ? IconButton(icon: const Icon(Icons.clear), onPressed: () => _riverManualSearchController.clear()) : null, + ), + ), const SizedBox(height: 16), - _buildStationList(filteredRiverManualStations, 'No matching river manual stations found.', 'No river manual stations available. Sync to download.', (station) => ListTile(title: Text(station['sampling_river'] ?? 'N/A'), subtitle: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [Text('Code: ${station['sampling_station_code'] ?? 'N/A'}'), Text('Basin: ${station['sampling_basin'] ?? 'N/A'}'), Text('State: ${station['state_name'] ?? 'N/A'}')]))), + _buildStationList( + filteredRiverManualStations, + 'No matching river manual stations found.', + 'No river manual stations available. Sync to download.', + (station) => ListTile( + title: Text(station['sampling_river'] ?? 'N/A'), + subtitle: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Code: ${station['sampling_station_code'] ?? 'N/A'}'), + Text('Basin: ${station['sampling_basin'] ?? 'N/A'}'), + Text('State: ${station['state_name'] ?? 'N/A'}'), + ], + ), + dense: true, + ), + ), ], ), ), ), const SizedBox(height: 32), - Text("River Triennial Stations (${filteredRiverTriennialStations?.length ?? 0} found)", style: Theme.of(context).textTheme.headlineSmall?.copyWith(fontWeight: FontWeight.bold)), + Text("River Triennial Stations (${filteredRiverTriennialStations?.length ?? 0})", style: Theme.of(context).textTheme.headlineSmall?.copyWith(fontWeight: FontWeight.bold)), const SizedBox(height: 16), Card( margin: EdgeInsets.zero, @@ -292,9 +381,34 @@ class _SettingsScreenState extends State { padding: const EdgeInsets.all(16.0), child: Column( children: [ - TextField(controller: _riverTriennialSearchController, decoration: InputDecoration(labelText: 'Search River Triennial Stations', hintText: 'Search by river, basin, or code', prefixIcon: const Icon(Icons.search), border: OutlineInputBorder(borderRadius: BorderRadius.circular(8.0)), suffixIcon: _riverTriennialSearchController.text.isNotEmpty ? IconButton(icon: const Icon(Icons.clear), onPressed: () => _riverTriennialSearchController.clear()) : null)), + TextField( + controller: _riverTriennialSearchController, + decoration: InputDecoration( + labelText: 'Search River Triennial Stations', + hintText: 'Search by river, basin, or code', + prefixIcon: const Icon(Icons.search), + border: OutlineInputBorder(borderRadius: BorderRadius.circular(8.0)), + suffixIcon: _riverTriennialSearchController.text.isNotEmpty ? IconButton(icon: const Icon(Icons.clear), onPressed: () => _riverTriennialSearchController.clear()) : null, + ), + ), const SizedBox(height: 16), - _buildStationList(filteredRiverTriennialStations, 'No matching river triennial stations found.', 'No river triennial stations available. Sync to download.', (station) => ListTile(title: Text(station['triennial_river'] ?? 'N/A'), subtitle: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [Text('Code: ${station['triennial_station_code'] ?? 'N/A'}'), Text('Basin: ${station['triennial_basin'] ?? 'N/A'}'), Text('State: ${station['state_name'] ?? 'N/A'}')]))), + _buildStationList( + filteredRiverTriennialStations, + 'No matching river triennial stations found.', + 'No river triennial stations available. Sync to download.', + (station) => ListTile( + title: Text(station['triennial_river'] ?? 'N/A'), + subtitle: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Code: ${station['triennial_station_code'] ?? 'N/A'}'), + Text('Basin: ${station['triennial_basin'] ?? 'N/A'}'), + Text('State: ${station['state_name'] ?? 'N/A'}'), + ], + ), + dense: true, + ), + ), ], ), ), @@ -307,8 +421,18 @@ class _SettingsScreenState extends State { margin: EdgeInsets.zero, child: Column( children: [ - ListTile(leading: const Icon(Icons.info_outline), title: const Text('App Version'), subtitle: const Text('1.0.0')), - ListTile(leading: const Icon(Icons.privacy_tip_outlined), title: const Text('Privacy Policy'), onTap: () {}), + ListTile( + leading: const Icon(Icons.info_outline), + title: const Text('App Version'), + subtitle: const Text('1.0.0'), + dense: true, + ), + ListTile( + leading: const Icon(Icons.privacy_tip_outlined), + title: const Text('Privacy Policy'), + onTap: () {}, + dense: true, + ), ], ), ), @@ -318,7 +442,12 @@ class _SettingsScreenState extends State { ); } - Widget _buildStationList(List>? stations, String noMatchText, String noDataText, Widget Function(Map) itemBuilder) { + Widget _buildStationList( + List>? stations, + String noMatchText, + String noDataText, + Widget Function(Map) itemBuilder, + ) { if (stations == null || stations.isEmpty) { return Center( child: Padding( @@ -341,4 +470,14 @@ class _SettingsScreenState extends State { ), ); } + + Widget _buildChatIdEntry(String label, String value) { + return ListTile( + contentPadding: EdgeInsets.zero, + leading: const Icon(Icons.telegram, size: 20), + title: Text('$label Chat ID'), + subtitle: Text(value), + dense: true, + ); + } } \ No newline at end of file diff --git a/lib/services/in_situ_sampling_service.dart b/lib/services/in_situ_sampling_service.dart index 6dd04a0..a2fc8b0 100644 --- a/lib/services/in_situ_sampling_service.dart +++ b/lib/services/in_situ_sampling_service.dart @@ -111,7 +111,6 @@ class InSituSamplingService { void startBluetoothAutoReading({Duration? interval}) => _bluetoothManager.startAutoReading(interval: interval ?? const Duration(seconds: 5)); void stopBluetoothAutoReading() => _bluetoothManager.stopAutoReading(); - // --- USB Serial Methods --- Future> getAvailableSerialDevices() => _serialManager.getAvailableDevices(); @@ -137,7 +136,6 @@ class InSituSamplingService { void startSerialAutoReading({Duration? interval}) => _serialManager.startAutoReading(interval: interval ?? const Duration(seconds: 5)); void stopSerialAutoReading() => _serialManager.stopAutoReading(); - void dispose() { _bluetoothManager.dispose(); _serialManager.dispose(); @@ -148,6 +146,7 @@ class InSituSamplingService { return _marineApiService.submitInSituSample( formData: data.toApiFormData(), imageFiles: data.toApiImageFiles(), + inSituData: data, // Added this required parameter ); } } \ No newline at end of file diff --git a/lib/services/marine_api_service.dart b/lib/services/marine_api_service.dart index df5d75c..4e0834b 100644 --- a/lib/services/marine_api_service.dart +++ b/lib/services/marine_api_service.dart @@ -4,6 +4,8 @@ import 'package:intl/intl.dart'; import 'package:environment_monitoring_app/services/base_api_service.dart'; import 'package:environment_monitoring_app/services/telegram_service.dart'; import 'package:environment_monitoring_app/services/settings_service.dart'; +import 'package:environment_monitoring_app/models/in_situ_sampling_data.dart'; +import 'package:environment_monitoring_app/models/tarball_data.dart'; class MarineApiService { final BaseApiService _baseService = BaseApiService(); @@ -22,18 +24,14 @@ class MarineApiService { return _baseService.get('marine/tarball/classifications'); } - /// Orchestrates a two-step submission process for tarball samples. (Unchanged) - /// Returns a detailed status code and the report ID upon success. Future> submitTarballSample({ required Map formData, required Map imageFiles, }) async { - // --- Step 1: Submit Text Data Only --- debugPrint("Step 1: Submitting tarball form data to the server..."); final dataResult = await _baseService.post('marine/tarball/sample', formData); if (dataResult['success'] != true) { - // Data submission failed. This is an L1 failure. return { 'status': 'L1', 'success': false, @@ -43,10 +41,8 @@ class MarineApiService { } debugPrint("Step 1 successful. Tarball data submitted."); - // --- Step 2: Upload Image Files --- final recordId = dataResult['data']?['autoid']; if (recordId == null) { - // Data was saved, but we can't link the images. This is an L2 failure. return { 'status': 'L2', 'success': false, @@ -61,7 +57,7 @@ class MarineApiService { }); if (filesToUpload.isEmpty) { - // If there are no images, the process is complete. + _handleTarballSuccessAlert(formData, isDataOnly: true); return { 'status': 'L3', 'success': true, @@ -78,7 +74,6 @@ class MarineApiService { ); if (imageResult['success'] != true) { - // Image upload failed. This is an L2 failure. return { 'status': 'L2', 'success': false, @@ -87,7 +82,7 @@ class MarineApiService { }; } - // Both steps were successful. + _handleTarballSuccessAlert(formData, isDataOnly: false); return { 'status': 'L3', 'success': true, @@ -96,12 +91,11 @@ class MarineApiService { }; } - /// Orchestrates a two-step submission process for in-situ samples. Future> submitInSituSample({ required Map formData, required Map imageFiles, + required InSituSamplingData inSituData, }) async { - // --- Step 1: Submit Form Data --- debugPrint("Step 1: Submitting in-situ form data to the server..."); final dataResult = await _baseService.post('marine/manual/sample', formData); @@ -115,7 +109,6 @@ class MarineApiService { } debugPrint("Step 1 successful. In-situ data submitted."); - // --- Step 2: Upload Image Files --- final recordId = dataResult['data']?['man_id']; if (recordId == null) { return { @@ -132,9 +125,7 @@ class MarineApiService { }); if (filesToUpload.isEmpty) { - // Handle alert for successful data-only submission. - _handleInSituSuccessAlert(formData, isDataOnly: true); - + _handleInSituSuccessAlert(inSituData, isDataOnly: true); return { 'status': 'L3', 'success': true, @@ -159,9 +150,7 @@ class MarineApiService { }; } - // Handle alert for successful data and image submission. - _handleInSituSuccessAlert(formData, isDataOnly: false); - + _handleInSituSuccessAlert(inSituData, isDataOnly: false); return { 'status': 'L3', 'success': true, @@ -170,57 +159,64 @@ class MarineApiService { }; } - /// A private helper method to build and send the detailed in-situ alert. - Future _handleInSituSuccessAlert(Map formData, {required bool isDataOnly}) async { + Future _handleTarballSuccessAlert(Map formData, {required bool isDataOnly}) async { + try { + final groupChatId = await _settingsService.getTarballChatId(); + if (groupChatId.isNotEmpty) { + final message = _generateTarballAlertMessage(formData, isDataOnly: isDataOnly); + final bool wasSent = await _telegramService.sendAlertImmediately('marine_tarball', message); + if (!wasSent) { + await _telegramService.queueMessage('marine_tarball', message); + } + } + } catch (e) { + debugPrint("Failed to handle Tarball Telegram alert: $e"); + } + } + + String _generateTarballAlertMessage(Map formData, {required bool isDataOnly}) { + final submissionType = isDataOnly ? "(Data Only)" : "(Data & Images)"; + final stationName = formData['tbl_station_name'] ?? 'N/A'; + final stationCode = formData['tbl_station_code'] ?? 'N/A'; + final classification = formData['classification_name'] ?? formData['classification_id'] ?? 'N/A'; + + final buffer = StringBuffer() + ..writeln('✅ *Tarball Sample $submissionType Submitted:*') + ..writeln() + ..writeln('*Station Name & Code:* $stationName ($stationCode)') + ..writeln('*Date of Submission:* ${formData['sampling_date']}') + ..writeln('*Submitted by User:* ${formData['first_sampler_name'] ?? 'N/A'}') + ..writeln('*Classification:* $classification') + ..writeln('*Status of Submission:* Successful'); + + if (formData['distance_difference'] != null && + double.tryParse(formData['distance_difference']!) != null && + double.parse(formData['distance_difference']!) > 0) { + buffer + ..writeln() + ..writeln('🔔 *Alert:*') + ..writeln('*Distance from station:* ${(double.parse(formData['distance_difference']!) * 1000).toStringAsFixed(0)} meters'); + + if (formData['distance_difference_remarks'] != null && formData['distance_difference_remarks']!.isNotEmpty) { + buffer.writeln('*Remarks for distance:* ${formData['distance_difference_remarks']}'); + } + } + + return buffer.toString(); + } + + Future _handleInSituSuccessAlert(InSituSamplingData data, {required bool isDataOnly}) async { try { final groupChatId = await _settingsService.getInSituChatId(); if (groupChatId.isNotEmpty) { - // Extract data from the formData map with fallbacks - final submissionType = isDataOnly ? "(Data Only)" : "(Data & Images)"; - final stationName = formData['man_station_name'] ?? 'N/A'; - final stationCode = formData['man_station_code'] ?? 'N/A'; - final submissionDate = formData['sampling_date'] ?? DateFormat('yyyy-MM-dd').format(DateTime.now()); - final submitter = formData['first_sampler_name'] ?? 'N/A'; - final manualsondeID = formData['man_sondeID'] ?? 'N/A'; - //final distanceKm = double.tryParse(formData['distance_difference_km'] ?? '0') ?? 0; - //final distanceMeters = (distanceKm * 1000).toStringAsFixed(0); - //final distanceRemarks = formData['distance_difference_remarks']; - - final distanceKm = double.tryParse(formData['man_distance_difference'] ?? '0') ?? 0; - final distanceMeters = (distanceKm * 1000).toStringAsFixed(0); - final distanceRemarks = formData['man_distance_difference_remarks'] ?? 'N/A'; - - - // Build the message using a StringBuffer for clarity - final buffer = StringBuffer(); - buffer.writeln('✅ *In-Situ Sample ${submissionType} Submitted:*'); - buffer.writeln(); // Blank line - buffer.writeln('*Station Name & Code:* $stationName ($stationCode)'); - buffer.writeln('*Date of Submitted:* $submissionDate'); - buffer.writeln('*Submitted by User:* $submitter'); - buffer.writeln('*Sonde ID:* $manualsondeID'); - buffer.writeln('*Status of Submission:* Successful'); - - // Only include the Alert section if distance or remarks are relevant - if (distanceKm > 0 || (distanceRemarks != null && distanceRemarks.isNotEmpty)) { - buffer.writeln(); // Blank line - buffer.writeln('🔔 *Alert:*'); - buffer.writeln('*Distance from station:* $distanceMeters meters'); - if (distanceRemarks != null && distanceRemarks.isNotEmpty) { - buffer.writeln('*Remarks for distance:* $distanceRemarks'); - } - } - - final String message = buffer.toString(); - - // Try to send immediately, or queue on failure + final message = data.generateTelegramAlertMessage(isDataOnly: isDataOnly); final bool wasSent = await _telegramService.sendAlertImmediately('marine_in_situ', message); if (!wasSent) { await _telegramService.queueMessage('marine_in_situ', message); } } } catch (e) { - debugPrint("Failed to handle Telegram alert: $e"); + debugPrint("Failed to handle In-Situ Telegram alert: $e"); } } } \ No newline at end of file diff --git a/lib/services/settings_service.dart b/lib/services/settings_service.dart index 2a0c3b3..586e15f 100644 --- a/lib/services/settings_service.dart +++ b/lib/services/settings_service.dart @@ -1,31 +1,52 @@ -// lib/services/settings_service.dart - import 'package:shared_preferences/shared_preferences.dart'; import 'package:environment_monitoring_app/services/base_api_service.dart'; class SettingsService { final BaseApiService _baseService = BaseApiService(); + + // Keys for SharedPreferences static const _inSituChatIdKey = 'telegram_in_situ_chat_id'; static const _tarballChatIdKey = 'telegram_tarball_chat_id'; + static const _riverInSituChatIdKey = 'telegram_river_in_situ_chat_id'; + static const _riverTriennialChatIdKey = 'telegram_river_triennial_chat_id'; + static const _riverInvestigativeChatIdKey = 'telegram_river_investigative_chat_id'; + static const _airManualChatIdKey = 'telegram_air_manual_chat_id'; + static const _airInvestigativeChatIdKey = 'telegram_air_investigative_chat_id'; + static const _marineInvestigativeChatIdKey = 'telegram_marine_investigative_chat_id'; /// Fetches settings from the server and saves them to local storage. Future syncFromServer() async { - final result = await _baseService.get('settings'); + try { + final result = await _baseService.get('settings'); - if (result['success'] == true && result['data'] is Map) { - final settings = result['data'] as Map; - final prefs = await SharedPreferences.getInstance(); + if (result['success'] == true && result['data'] is Map) { + final settings = result['data'] as Map; + final prefs = await SharedPreferences.getInstance(); - // Save the chat IDs from the nested map - final inSituSettings = settings['marine_in_situ'] as Map?; - await prefs.setString(_inSituChatIdKey, inSituSettings?['telegram_chat_id'] ?? ''); + // Save all chat IDs from the nested maps + await Future.wait([ + _saveChatId(prefs, _inSituChatIdKey, settings['marine_in_situ']), + _saveChatId(prefs, _tarballChatIdKey, settings['marine_tarball']), + _saveChatId(prefs, _riverInSituChatIdKey, settings['river_in_situ']), + _saveChatId(prefs, _riverTriennialChatIdKey, settings['river_triennial']), + _saveChatId(prefs, _riverInvestigativeChatIdKey, settings['river_investigative']), + _saveChatId(prefs, _airManualChatIdKey, settings['air_manual']), + _saveChatId(prefs, _airInvestigativeChatIdKey, settings['air_investigative']), + _saveChatId(prefs, _marineInvestigativeChatIdKey, settings['marine_investigative']), + ]); - final tarballSettings = settings['marine_tarball'] as Map?; - await prefs.setString(_tarballChatIdKey, tarballSettings?['telegram_chat_id'] ?? ''); - - return true; + return true; + } + return false; + } catch (e) { + return false; + } + } + + Future _saveChatId(SharedPreferences prefs, String key, dynamic settings) async { + if (settings is Map) { + await prefs.setString(key, settings['telegram_chat_id']?.toString() ?? ''); } - return false; } /// Gets the locally stored Chat ID for the In-Situ module. @@ -39,4 +60,40 @@ class SettingsService { final prefs = await SharedPreferences.getInstance(); return prefs.getString(_tarballChatIdKey) ?? ''; } + + /// Gets the locally stored Chat ID for the River In-Situ module. + Future getRiverInSituChatId() async { + final prefs = await SharedPreferences.getInstance(); + return prefs.getString(_riverInSituChatIdKey) ?? ''; + } + + /// Gets the locally stored Chat ID for the River Triennial module. + Future getRiverTriennialChatId() async { + final prefs = await SharedPreferences.getInstance(); + return prefs.getString(_riverTriennialChatIdKey) ?? ''; + } + + /// Gets the locally stored Chat ID for the River Investigative module. + Future getRiverInvestigativeChatId() async { + final prefs = await SharedPreferences.getInstance(); + return prefs.getString(_riverInvestigativeChatIdKey) ?? ''; + } + + /// Gets the locally stored Chat ID for the Air Manual module. + Future getAirManualChatId() async { + final prefs = await SharedPreferences.getInstance(); + return prefs.getString(_airManualChatIdKey) ?? ''; + } + + /// Gets the locally stored Chat ID for the Air Investigative module. + Future getAirInvestigativeChatId() async { + final prefs = await SharedPreferences.getInstance(); + return prefs.getString(_airInvestigativeChatIdKey) ?? ''; + } + + /// Gets the locally stored Chat ID for the Marine Investigative module. + Future getMarineInvestigativeChatId() async { + final prefs = await SharedPreferences.getInstance(); + return prefs.getString(_marineInvestigativeChatIdKey) ?? ''; + } } \ No newline at end of file diff --git a/lib/services/telegram_service.dart b/lib/services/telegram_service.dart index 6bef8ee..a7fd416 100644 --- a/lib/services/telegram_service.dart +++ b/lib/services/telegram_service.dart @@ -10,7 +10,6 @@ class TelegramService { bool _isProcessing = false; - // --- ADDED: New method to attempt immediate sending --- /// Tries to send an alert immediately over the network. /// Returns `true` on success, `false` on failure. Future sendAlertImmediately(String module, String message) async { @@ -24,7 +23,7 @@ class TelegramService { if (chatId.isEmpty) { debugPrint("[TelegramService] ❌ Cannot send immediately. Chat ID for module '$module' is not configured."); - return false; // Cannot succeed if no chat ID is set. + return false; } final result = await _apiService.sendTelegramAlert( @@ -68,7 +67,7 @@ class TelegramService { debugPrint("[TelegramService] ✅ Alert queued for module: $module"); } - /// Processes all pending alerts in the queue. (Unchanged) + /// Processes all pending alerts in the queue. Future processAlertQueue() async { if (_isProcessing) { debugPrint("[TelegramService] ⏳ Queue is already being processed. Skipping.");