add telegram alert in manual tarball. fix all module homepage layout. fix homepage layout.

This commit is contained in:
ALim Aidrus 2025-08-15 16:13:46 +08:00
parent a2d8b372e6
commit e3b58bf74e
13 changed files with 679 additions and 281 deletions

View File

@ -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<HomePage> {
@override
Widget build(BuildContext context) {
final auth = Provider.of<AuthProvider>(context);
final colorScheme = Theme.of(context).colorScheme;
return Scaffold(
appBar: AppBar(
@ -56,32 +57,61 @@ class _HomePageState extends State<HomePage> {
),
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,
),
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: [
// 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'),
_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,14 +121,88 @@ class _HomePageState extends State<HomePage> {
);
}
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,
),
),
],
),
),
),
);
}

View File

@ -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<String, String> for the API form data.
Map<String, String> toApiFormData() {
final Map<String, String> map = {};

View File

@ -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<String, dynamic>? 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<String, String> 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;
}

View File

@ -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<SidebarItem> _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,32 +132,41 @@ class AirHomePage extends StatelessWidget {
Navigator.pushNamed(context, subItem.route!);
}
},
borderRadius: BorderRadius.circular(0), // Removed border radius for seamless grid
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), // Padding around the row content
child: Row( // Changed from Column to Row
mainAxisAlignment: MainAxisAlignment.start, // Align content to start
padding: const EdgeInsets.all(8.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.start,
children: [
subItem.icon != null
? Icon(subItem.icon, color: Colors.white70, size: 24) // Increased icon size from 22 to 24
? Icon(subItem.icon, color: Colors.white70, size: 24)
: const SizedBox.shrink(),
const SizedBox(width: 8), // Space between icon and text (horizontal)
Expanded( // Allow text to take remaining space
const SizedBox(width: 8),
Expanded(
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
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Colors.white70,
fontSize: 12, // Slightly increased font size
),
textAlign: TextAlign.left,
overflow: TextOverflow.ellipsis,
maxLines: 1, // Single line for label
maxLines: 2, // Allow for two lines if needed
),
),
],
),
),
),
);
},
),
const SizedBox(height: 16), // Reduced gap after each category group
const SizedBox(height: 16),
],
);
}

View File

@ -236,6 +236,7 @@ class _MarineManualDataStatusLogState extends State<MarineManualDataStatusLog> {
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() ?? '');

View File

@ -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<TarballSamplingStep2> {
final _formKey = GlobalKey<FormState>();
bool _isPickingImage = false;
// This will hold the user's selection in the UI
Map<String, dynamic>? _selectedClassification;
late final TextEditingController _remark1Controller;
@ -38,25 +40,32 @@ class _TarballSamplingStep2State extends State<TarballSamplingStep2> {
_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<AuthProvider>(context, listen: false);
final classifications = auth.tarballClassifications ?? [];
if (classifications.isNotEmpty) {
// 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 = classifications.firstWhere(
final foundClassification = auth.tarballClassifications!.firstWhere(
(c) => c['classification_id'] == widget.data.classificationId,
);
if (mounted) {
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 with ID: ${widget.data.classificationId}");
}
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<TarballSamplingStep2> {
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<TarballSamplingStep2> {
);
}
/// Picks an image, processes it (checks orientation, adds watermark), and returns the file.
Future<File?> _pickAndProcessImage(ImageSource source, String imageInfo, {required bool isRequired}) async {
if (_isPickingImage) return null;
setState(() => _isPickingImage = true);
@ -108,24 +115,24 @@ class _TarballSamplingStep2State extends State<TarballSamplingStep2> {
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<TarballSamplingStep2> {
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<TarballSamplingStep2> {
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<TarballSamplingStep2> {
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<TarballSamplingStep2> {
backgroundColor: Colors.red,
),
);
return; // Stop the function if validation fails
return;
}
widget.data.optionalRemark1 = _remark1Controller.text;
@ -210,31 +216,35 @@ class _TarballSamplingStep2State extends State<TarballSamplingStep2> {
Text("On-Site Information", style: Theme.of(context).textTheme.headlineSmall),
const SizedBox(height: 24),
// This dropdown now correctly consumes data from AuthProvider
Consumer<AuthProvider>(
builder: (context, auth, child) {
if (auth.tarballClassifications == null || auth.tarballClassifications!.isEmpty) {
return DropdownButtonFormField<String>(
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<Map<String, dynamic>>(
decoration: const InputDecoration(labelText: 'Tarball Classification *'),
value: _selectedClassification,
items: auth.tarballClassifications!.map((classification) {
return DropdownMenuItem<Map<String, dynamic>>(
value: classification,
child: Text(classification['classification_name']?.toString() ?? 'Unnamed'),
);
}).toList(),
return DropdownSearch<Map<String, dynamic>>(
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<TarballSamplingStep2> {
),
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<TarballSamplingStep2> {
);
}
// --- 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<TarballSamplingStep2> {
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(

View File

@ -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,32 +134,41 @@ class MarineHomePage extends StatelessWidget {
Navigator.pushNamed(context, subItem.route!);
}
},
borderRadius: BorderRadius.circular(0), // Removed border radius for seamless grid
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), // Padding around the row content
child: Row( // Changed from Column to Row
mainAxisAlignment: MainAxisAlignment.start, // Align content to start
padding: const EdgeInsets.all(8.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.start,
children: [
subItem.icon != null
? Icon(subItem.icon, color: Colors.white70, size: 24) // Increased icon size from 22 to 24
? Icon(subItem.icon, color: Colors.white70, size: 24)
: const SizedBox.shrink(),
const SizedBox(width: 8), // Space between icon and text (horizontal)
Expanded( // Allow text to take remaining space
const SizedBox(width: 8),
Expanded(
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
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Colors.white70,
fontSize: 12, // Slightly increased font size
),
textAlign: TextAlign.left,
overflow: TextOverflow.ellipsis,
maxLines: 1, // Single line for label
maxLines: 2, // Allow for two lines if needed
),
),
],
),
),
),
);
},
),
const SizedBox(height: 16), // Reduced gap after each category group
const SizedBox(height: 16),
],
);
}

View File

@ -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,32 +132,41 @@ class RiverHomePage extends StatelessWidget {
Navigator.pushNamed(context, subItem.route!);
}
},
borderRadius: BorderRadius.circular(0), // Removed border radius for seamless grid
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), // Padding around the row content
child: Row( // Changed from Column to Row
mainAxisAlignment: MainAxisAlignment.start, // Align content to start
padding: const EdgeInsets.all(8.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.start,
children: [
subItem.icon != null
? Icon(subItem.icon, color: Colors.white70, size: 24) // Increased icon size from 22 to 24
? Icon(subItem.icon, color: Colors.white70, size: 24)
: const SizedBox.shrink(),
const SizedBox(width: 8), // Space between icon and text (horizontal)
Expanded( // Allow text to take remaining space
const SizedBox(width: 8),
Expanded(
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
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Colors.white70,
fontSize: 12, // Slightly increased font size
),
textAlign: TextAlign.left,
overflow: TextOverflow.ellipsis,
maxLines: 1, // Single line for label
maxLines: 2, // Allow for two lines if needed
),
),
],
),
),
),
);
},
),
const SizedBox(height: 16), // Reduced gap after each category group
const SizedBox(height: 16),
],
);
}

View File

@ -18,6 +18,12 @@ class _SettingsScreenState extends State<SettingsScreen> {
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<SettingsScreen> {
}
Future<void> _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<SettingsScreen> {
setState(() { _riverTriennialSearchQuery = _riverTriennialSearchController.text; });
}
// --- FIXED: This method now uses try/catch to handle success and failure ---
Future<void> _manualDataSync() async {
if (_isSyncingData) return;
setState(() => _isSyncingData = true);
@ -82,20 +102,16 @@ class _SettingsScreenState extends State<SettingsScreen> {
final auth = Provider.of<AuthProvider>(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<SettingsScreen> {
final auth = Provider.of<AuthProvider>(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<SettingsScreen> {
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<SettingsScreen> {
),
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<SettingsScreen> {
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<SettingsScreen> {
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<SettingsScreen> {
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<SettingsScreen> {
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<SettingsScreen> {
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<SettingsScreen> {
);
}
Widget _buildStationList(List<Map<String, dynamic>>? stations, String noMatchText, String noDataText, Widget Function(Map<String, dynamic>) itemBuilder) {
Widget _buildStationList(
List<Map<String, dynamic>>? stations,
String noMatchText,
String noDataText,
Widget Function(Map<String, dynamic>) itemBuilder,
) {
if (stations == null || stations.isEmpty) {
return Center(
child: Padding(
@ -341,4 +470,14 @@ class _SettingsScreenState extends State<SettingsScreen> {
),
);
}
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,
);
}
}

View File

@ -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<List<UsbDevice>> 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
);
}
}

View File

@ -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<Map<String, dynamic>> submitTarballSample({
required Map<String, String> formData,
required Map<String, File?> 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<Map<String, dynamic>> submitInSituSample({
required Map<String, String> formData,
required Map<String, File?> 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<void> _handleInSituSuccessAlert(Map<String, String> formData, {required bool isDataOnly}) async {
Future<void> _handleTarballSuccessAlert(Map<String, String> 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<String, String> 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<void> _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");
}
}
}

View File

@ -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<bool> syncFromServer() async {
try {
final result = await _baseService.get('settings');
if (result['success'] == true && result['data'] is Map) {
final settings = result['data'] as Map<String, dynamic>;
final prefs = await SharedPreferences.getInstance();
// Save the chat IDs from the nested map
final inSituSettings = settings['marine_in_situ'] as Map<String, dynamic>?;
await prefs.setString(_inSituChatIdKey, inSituSettings?['telegram_chat_id'] ?? '');
final tarballSettings = settings['marine_tarball'] as Map<String, dynamic>?;
await prefs.setString(_tarballChatIdKey, tarballSettings?['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']),
]);
return true;
}
return false;
} catch (e) {
return false;
}
}
Future<void> _saveChatId(SharedPreferences prefs, String key, dynamic settings) async {
if (settings is Map<String, dynamic>) {
await prefs.setString(key, settings['telegram_chat_id']?.toString() ?? '');
}
}
/// 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<String> getRiverInSituChatId() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getString(_riverInSituChatIdKey) ?? '';
}
/// Gets the locally stored Chat ID for the River Triennial module.
Future<String> getRiverTriennialChatId() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getString(_riverTriennialChatIdKey) ?? '';
}
/// Gets the locally stored Chat ID for the River Investigative module.
Future<String> getRiverInvestigativeChatId() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getString(_riverInvestigativeChatIdKey) ?? '';
}
/// Gets the locally stored Chat ID for the Air Manual module.
Future<String> getAirManualChatId() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getString(_airManualChatIdKey) ?? '';
}
/// Gets the locally stored Chat ID for the Air Investigative module.
Future<String> getAirInvestigativeChatId() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getString(_airInvestigativeChatIdKey) ?? '';
}
/// Gets the locally stored Chat ID for the Marine Investigative module.
Future<String> getMarineInvestigativeChatId() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getString(_marineInvestigativeChatIdKey) ?? '';
}
}

View File

@ -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<bool> 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<void> processAlertQueue() async {
if (_isProcessing) {
debugPrint("[TelegramService] ⏳ Queue is already being processed. Skipping.");