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:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:environment_monitoring_app/auth_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 { class HomePage extends StatefulWidget {
const HomePage({super.key}); const HomePage({super.key});
@ -17,6 +17,7 @@ class _HomePageState extends State<HomePage> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final auth = Provider.of<AuthProvider>(context); final auth = Provider.of<AuthProvider>(context);
final colorScheme = Theme.of(context).colorScheme;
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
@ -56,32 +57,61 @@ class _HomePageState extends State<HomePage> {
), ),
Expanded( Expanded(
child: Padding( child: Padding(
padding: const EdgeInsets.all(24), padding: const EdgeInsets.all(12),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text( Text(
"Welcome, ${auth.userEmail ?? 'User'}", "Welcome, ${auth.userEmail ?? 'User'}",
style: Theme.of(context).textTheme.headlineSmall?.copyWith( style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
color: colorScheme.onBackground,
), ),
), ),
const SizedBox(height: 32), const SizedBox(height: 8),
Text( Text(
"Select a Department:", "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( const SizedBox(height: 8),
spacing: 16, Expanded(
runSpacing: 16, 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: [ children: [
// Updated navigation to the new department home pages _buildMiniCategoryCard(
_buildNavButton(context, "Air", Icons.cloud, '/air/home'), context,
_buildNavButton(context, "River", Icons.water, '/river/home'), title: "Air",
_buildNavButton(context, "Marine", Icons.sailing, '/marine/home'), 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) { Widget _buildMiniCategoryCard(
return ElevatedButton.icon( BuildContext context, {
onPressed: () => Navigator.pushNamed(context, route), required String title,
icon: Icon(icon, size: 24), required IconData icon,
label: Text(label), required Color color,
style: ElevatedButton.styleFrom( required String route,
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16), }) {
textStyle: const TextStyle(fontSize: 16), 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, 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. /// Converts the data model into a Map<String, String> for the API form data.
Map<String, String> toApiFormData() { Map<String, String> toApiFormData() {
final Map<String, String> map = {}; final Map<String, String> map = {};

View File

@ -17,9 +17,12 @@ class TarballSamplingData {
String? currentLatitude; String? currentLatitude;
String? currentLongitude; String? currentLongitude;
double? distanceDifference; double? distanceDifference;
String? distanceDifferenceRemarks;
// --- Step 2 Data: Collected in TarballSamplingStep2 --- // --- 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? leftCoastalViewImage;
File? rightCoastalViewImage; File? rightCoastalViewImage;
File? verticalLinesImage; File? verticalLinesImage;
@ -38,6 +41,38 @@ class TarballSamplingData {
String? submissionStatus; String? submissionStatus;
String? submissionMessage; 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. /// 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. /// This map will be sent as the body of the first API request.
Map<String, String> toFormData() { Map<String, String> toFormData() {
@ -62,6 +97,15 @@ class TarballSamplingData {
'optional_photo_remark_02': optionalRemark2 ?? '', 'optional_photo_remark_02': optionalRemark2 ?? '',
'optional_photo_remark_03': optionalRemark3 ?? '', 'optional_photo_remark_03': optionalRemark3 ?? '',
'optional_photo_remark_04': optionalRemark4 ?? '', '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; return data;
} }

View File

@ -24,15 +24,13 @@ class AirHomePage extends StatelessWidget {
const AirHomePage({super.key}); const AirHomePage({super.key});
// Define Air's sub-menu structure (Manual, Continuous, Investigative) // Define Air's sub-menu structure (Manual, Continuous, Investigative)
// This mirrors the structure from collapsible_sidebar.dart for consistency.
final List<SidebarItem> _airSubMenus = const [ final List<SidebarItem> _airSubMenus = const [
SidebarItem( SidebarItem(
icon: Icons.handshake, // Example icon for Manual icon: Icons.handshake,
label: "Manual", label: "Manual",
isParent: true, isParent: true,
children: [ children: [
SidebarItem(icon: Icons.dashboard, label: "Dashboard", route: '/air/manual/dashboard'), 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.construction, label: "Installation", route: '/air/manual/installation'),
SidebarItem(icon: Icons.inventory_2, label: "Collection", route: '/air/manual/collection'), SidebarItem(icon: Icons.inventory_2, label: "Collection", route: '/air/manual/collection'),
SidebarItem(icon: Icons.receipt_long, label: "Report", route: '/air/manual/report'), 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 const Divider(height: 24, thickness: 1, color: Colors.white24),
// Grid of sub-menu items // Grid of sub-menu items - changed to 2 columns
GridView.builder( GridView.builder(
shrinkWrap: true, shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(), physics: const NeverScrollableScrollPhysics(),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
// --- UPDATED: Changed from 3 columns to 2 --- crossAxisCount: 2, // Changed from 3 to 2 columns
crossAxisCount: 2, // 2 columns for sub-menu items crossAxisSpacing: 0.0,
crossAxisSpacing: 0.0, // Removed horizontal spacing mainAxisSpacing: 0.0,
mainAxisSpacing: 0.0, // Removed vertical spacing childAspectRatio: 4.0, // Adjusted aspect ratio for better 2-column layout
// --- UPDATED: Adjusted aspect ratio for a 2-column layout ---
childAspectRatio: 3.5, // Adjusted for a 2-column horizontal layout
), ),
itemCount: category.children?.length ?? 0, itemCount: category.children?.length ?? 0,
itemBuilder: (context, index) { itemBuilder: (context, index) {
@ -136,32 +132,41 @@ class AirHomePage extends StatelessWidget {
Navigator.pushNamed(context, subItem.route!); 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( child: Padding(
padding: const EdgeInsets.all(8.0), // Padding around the row content padding: const EdgeInsets.all(8.0),
child: Row( // Changed from Column to Row child: Row(
mainAxisAlignment: MainAxisAlignment.start, // Align content to start mainAxisAlignment: MainAxisAlignment.start,
children: [ children: [
subItem.icon != null 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.shrink(),
const SizedBox(width: 8), // Space between icon and text (horizontal) const SizedBox(width: 8),
Expanded( // Allow text to take remaining space Expanded(
child: Text( child: Text(
subItem.label, subItem.label,
style: Theme.of(context).textTheme.bodySmall?.copyWith(color: Colors.white70, fontSize: 11), // Increased text size from 10 to 11 style: Theme.of(context).textTheme.bodySmall?.copyWith(
textAlign: TextAlign.left, // Align text to left color: Colors.white70,
fontSize: 12, // Slightly increased font size
),
textAlign: TextAlign.left,
overflow: TextOverflow.ellipsis, 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( return _marineApiService.submitInSituSample(
formData: dataToResubmit.toApiFormData(), formData: dataToResubmit.toApiFormData(),
imageFiles: imageFiles, imageFiles: imageFiles,
inSituData: dataToResubmit, // Added this required parameter
); );
} else if (log.type == 'Tarball Sampling') { } else if (log.type == 'Tarball Sampling') {
final int? firstSamplerId = int.tryParse(logData['first_sampler_user_id']?.toString() ?? ''); final int? firstSamplerId = int.tryParse(logData['first_sampler_user_id']?.toString() ?? '');

View File

@ -1,10 +1,11 @@
import 'dart:io'; import 'dart:io';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:image_picker/image_picker.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:image_picker/image_picker.dart';
import 'package:path_provider/path_provider.dart'; import 'package:path_provider/path_provider.dart';
import 'package:path/path.dart' as path; import 'package:path/path.dart' as path;
import 'package:image/image.dart' as img; 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/auth_provider.dart';
import 'package:environment_monitoring_app/models/tarball_data.dart'; import 'package:environment_monitoring_app/models/tarball_data.dart';
@ -22,6 +23,7 @@ class _TarballSamplingStep2State extends State<TarballSamplingStep2> {
final _formKey = GlobalKey<FormState>(); final _formKey = GlobalKey<FormState>();
bool _isPickingImage = false; bool _isPickingImage = false;
// This will hold the user's selection in the UI
Map<String, dynamic>? _selectedClassification; Map<String, dynamic>? _selectedClassification;
late final TextEditingController _remark1Controller; late final TextEditingController _remark1Controller;
@ -38,25 +40,32 @@ class _TarballSamplingStep2State extends State<TarballSamplingStep2> {
_remark3Controller = TextEditingController(text: widget.data.optionalRemark3); _remark3Controller = TextEditingController(text: widget.data.optionalRemark3);
_remark4Controller = TextEditingController(text: widget.data.optionalRemark4); _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((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
if (widget.data.classificationId != null) {
final auth = Provider.of<AuthProvider>(context, listen: false); 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 { try {
final foundClassification = classifications.firstWhere( final foundClassification = auth.tarballClassifications!.firstWhere(
(c) => c['classification_id'] == widget.data.classificationId, (c) => c['classification_id'] == widget.data.classificationId,
); );
if (mounted) {
setState(() { setState(() {
_selectedClassification = foundClassification; _selectedClassification = foundClassification;
// Also restore the full object to the data model
widget.data.selectedClassification = foundClassification;
}); });
}
} catch (e) { } 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(); super.dispose();
} }
/// Shows a dialog to the user informing them about the image orientation requirement.
void _showOrientationDialog() { void _showOrientationDialog() {
showDialog( showDialog(
context: context, 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 { Future<File?> _pickAndProcessImage(ImageSource source, String imageInfo, {required bool isRequired}) async {
if (_isPickingImage) return null; if (_isPickingImage) return null;
setState(() => _isPickingImage = true); setState(() => _isPickingImage = true);
@ -108,24 +115,24 @@ class _TarballSamplingStep2State extends State<TarballSamplingStep2> {
return null; return null;
} }
// --- NEW: Validate image orientation for required photos ---
if (isRequired && originalImage.height > originalImage.width) { if (isRequired && originalImage.height > originalImage.width) {
_showOrientationDialog(); _showOrientationDialog();
setState(() => _isPickingImage = false); 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 String watermarkTimestamp = "${widget.data.samplingDate} ${widget.data.samplingTime}";
final font = img.arial24; // Reduced from arial48 final font = img.arial24;
const int padding = 10; const int padding = 10;
final textWidth = watermarkTimestamp.length * 12; final textWidth = watermarkTimestamp.length * 12;
final textHeight = 24; final textHeight = 24;
img.fillRect( img.fillRect(
originalImage, originalImage,
x1: padding - 5, y1: padding - 5, x1: padding - 5,
x2: padding + textWidth + 5, y2: padding + textHeight + 5, y1: padding - 5,
x2: padding + textWidth + 5,
y2: padding + textHeight + 5,
color: img.ColorRgb8(255, 255, 255), color: img.ColorRgb8(255, 255, 255),
); );
@ -133,7 +140,8 @@ class _TarballSamplingStep2State extends State<TarballSamplingStep2> {
originalImage, originalImage,
watermarkTimestamp, watermarkTimestamp,
font: font, font: font,
x: padding, y: padding, x: padding,
y: padding,
color: img.ColorRgb8(0, 0, 0), color: img.ColorRgb8(0, 0, 0),
); );
@ -161,7 +169,6 @@ class _TarballSamplingStep2State extends State<TarballSamplingStep2> {
void _goToNextStep() { void _goToNextStep() {
if (_formKey.currentState!.validate()) { if (_formKey.currentState!.validate()) {
// --- NEW: Validate that a classification has been selected ---
if (widget.data.classificationId == null) { if (widget.data.classificationId == null) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
const SnackBar( const SnackBar(
@ -172,7 +179,6 @@ class _TarballSamplingStep2State extends State<TarballSamplingStep2> {
return; return;
} }
// --- NEW: Validate that all required photos have been attached ---
if (widget.data.leftCoastalViewImage == null || if (widget.data.leftCoastalViewImage == null ||
widget.data.rightCoastalViewImage == null || widget.data.rightCoastalViewImage == null ||
widget.data.verticalLinesImage == null || widget.data.verticalLinesImage == null ||
@ -183,7 +189,7 @@ class _TarballSamplingStep2State extends State<TarballSamplingStep2> {
backgroundColor: Colors.red, backgroundColor: Colors.red,
), ),
); );
return; // Stop the function if validation fails return;
} }
widget.data.optionalRemark1 = _remark1Controller.text; widget.data.optionalRemark1 = _remark1Controller.text;
@ -210,31 +216,35 @@ class _TarballSamplingStep2State extends State<TarballSamplingStep2> {
Text("On-Site Information", style: Theme.of(context).textTheme.headlineSmall), Text("On-Site Information", style: Theme.of(context).textTheme.headlineSmall),
const SizedBox(height: 24), const SizedBox(height: 24),
// This dropdown now correctly consumes data from AuthProvider
Consumer<AuthProvider>( Consumer<AuthProvider>(
builder: (context, auth, child) { builder: (context, auth, child) {
if (auth.tarballClassifications == null || auth.tarballClassifications!.isEmpty) { final classifications = auth.tarballClassifications;
return DropdownButtonFormField<String>( // The dropdown is enabled only when the classification list is available from the local cache.
decoration: const InputDecoration( final bool isEnabled = classifications != null;
labelText: 'Tarball Classification *',
hintText: 'Loading or no classifications found...',
),
items: const [],
onChanged: null,
);
}
return DropdownButtonFormField<Map<String, dynamic>>( return DropdownSearch<Map<String, dynamic>>(
decoration: const InputDecoration(labelText: 'Tarball Classification *'), items: classifications ?? [], // Use local data from provider
value: _selectedClassification, selectedItem: _selectedClassification,
items: auth.tarballClassifications!.map((classification) { enabled: isEnabled,
return DropdownMenuItem<Map<String, dynamic>>( itemAsString: (item) => item['classification_name'] as String,
value: classification, dropdownDecoratorProps: DropDownDecoratorProps(
child: Text(classification['classification_name']?.toString() ?? 'Unnamed'), dropdownSearchDecoration: InputDecoration(
); labelText: "Tarball Classification *",
}).toList(), hintText: isEnabled ? "Select a classification" : "Loading classifications...",
),
),
popupProps: const PopupProps.menu(
showSearchBox: true,
searchFieldProps: TextFieldProps(
decoration: InputDecoration(hintText: "Search Classification..."),
),
),
onChanged: (value) { onChanged: (value) {
setState(() { setState(() {
_selectedClassification = value; _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']; widget.data.classificationId = value?['classification_id'];
}); });
}, },
@ -244,7 +254,6 @@ class _TarballSamplingStep2State extends State<TarballSamplingStep2> {
), ),
const SizedBox(height: 24), const SizedBox(height: 24),
// --- MODIFIED: Added asterisk to indicate required section ---
Text("Required Photos *", style: Theme.of(context).textTheme.titleLarge), 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('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), _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}) { Widget _buildImagePicker(String title, String imageInfo, File? imageFile, Function(File?) setImageCallback, {TextEditingController? remarkController, bool isRequired = false}) {
return Padding( return Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0), padding: const EdgeInsets.symmetric(vertical: 8.0),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
// --- MODIFIED: Add asterisk to title if required ---
Text(title + (isRequired ? ' *' : ''), style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w500)), Text(title + (isRequired ? ' *' : ''), style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w500)),
const SizedBox(height: 8), const SizedBox(height: 8),
if (imageFile != null) if (imageFile != null)
@ -287,8 +294,7 @@ class _TarballSamplingStep2State extends State<TarballSamplingStep2> {
children: [ children: [
ClipRRect( ClipRRect(
borderRadius: BorderRadius.circular(8.0), 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( Container(
margin: const EdgeInsets.all(4), margin: const EdgeInsets.all(4),
decoration: BoxDecoration( 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 const Divider(height: 24, thickness: 1, color: Colors.white24),
// Grid of sub-menu items // Grid of sub-menu items - changed to 2 columns
GridView.builder( GridView.builder(
shrinkWrap: true, shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(), physics: const NeverScrollableScrollPhysics(),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 3, // 3 columns for sub-menu items crossAxisCount: 2, // Changed from 3 to 2 columns
crossAxisSpacing: 0.0, // Removed horizontal spacing crossAxisSpacing: 0.0,
mainAxisSpacing: 0.0, // Removed vertical spacing mainAxisSpacing: 0.0,
childAspectRatio: 2.8, // Adjusted aspect ratio for horizontal icon-label layout with bigger content childAspectRatio: 4.0, // Adjusted aspect ratio for better 2-column layout
), ),
itemCount: category.children?.length ?? 0, itemCount: category.children?.length ?? 0,
itemBuilder: (context, index) { itemBuilder: (context, index) {
@ -134,32 +134,41 @@ class MarineHomePage extends StatelessWidget {
Navigator.pushNamed(context, subItem.route!); 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( child: Padding(
padding: const EdgeInsets.all(8.0), // Padding around the row content padding: const EdgeInsets.all(8.0),
child: Row( // Changed from Column to Row child: Row(
mainAxisAlignment: MainAxisAlignment.start, // Align content to start mainAxisAlignment: MainAxisAlignment.start,
children: [ children: [
subItem.icon != null 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.shrink(),
const SizedBox(width: 8), // Space between icon and text (horizontal) const SizedBox(width: 8),
Expanded( // Allow text to take remaining space Expanded(
child: Text( child: Text(
subItem.label, subItem.label,
style: Theme.of(context).textTheme.bodySmall?.copyWith(color: Colors.white70, fontSize: 11), // Increased text size from 10 to 11 style: Theme.of(context).textTheme.bodySmall?.copyWith(
textAlign: TextAlign.left, // Align text to left color: Colors.white70,
fontSize: 12, // Slightly increased font size
),
textAlign: TextAlign.left,
overflow: TextOverflow.ellipsis, 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 const Divider(height: 24, thickness: 1, color: Colors.white24),
// Grid of sub-menu items // Grid of sub-menu items - changed to 2 columns
GridView.builder( GridView.builder(
shrinkWrap: true, shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(), physics: const NeverScrollableScrollPhysics(),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 3, // 3 columns for sub-menu items crossAxisCount: 2, // Changed from 3 to 2 columns
crossAxisSpacing: 0.0, // Removed horizontal spacing crossAxisSpacing: 0.0,
mainAxisSpacing: 0.0, // Removed vertical spacing mainAxisSpacing: 0.0,
childAspectRatio: 2.8, // Adjusted aspect ratio for horizontal icon-label layout with bigger content childAspectRatio: 4.0, // Adjusted aspect ratio for better 2-column layout
), ),
itemCount: category.children?.length ?? 0, itemCount: category.children?.length ?? 0,
itemBuilder: (context, index) { itemBuilder: (context, index) {
@ -132,32 +132,41 @@ class RiverHomePage extends StatelessWidget {
Navigator.pushNamed(context, subItem.route!); 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( child: Padding(
padding: const EdgeInsets.all(8.0), // Padding around the row content padding: const EdgeInsets.all(8.0),
child: Row( // Changed from Column to Row child: Row(
mainAxisAlignment: MainAxisAlignment.start, // Align content to start mainAxisAlignment: MainAxisAlignment.start,
children: [ children: [
subItem.icon != null 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.shrink(),
const SizedBox(width: 8), // Space between icon and text (horizontal) const SizedBox(width: 8),
Expanded( // Allow text to take remaining space Expanded(
child: Text( child: Text(
subItem.label, subItem.label,
style: Theme.of(context).textTheme.bodySmall?.copyWith(color: Colors.white70, fontSize: 11), // Increased text size from 10 to 11 style: Theme.of(context).textTheme.bodySmall?.copyWith(
textAlign: TextAlign.left, // Align text to left color: Colors.white70,
fontSize: 12, // Slightly increased font size
),
textAlign: TextAlign.left,
overflow: TextOverflow.ellipsis, 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 _inSituChatId = 'Loading...';
String _tarballChatId = '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(); final TextEditingController _tarballSearchController = TextEditingController();
String _tarballSearchQuery = ''; String _tarballSearchQuery = '';
@ -48,12 +54,27 @@ class _SettingsScreenState extends State<SettingsScreen> {
} }
Future<void> _loadCurrentSettings() async { Future<void> _loadCurrentSettings() async {
final inSituId = await _settingsService.getInSituChatId(); final results = await Future.wait([
final tarballId = await _settingsService.getTarballChatId(); _settingsService.getInSituChatId(),
_settingsService.getTarballChatId(),
_settingsService.getRiverInSituChatId(),
_settingsService.getRiverTriennialChatId(),
_settingsService.getRiverInvestigativeChatId(),
_settingsService.getAirManualChatId(),
_settingsService.getAirInvestigativeChatId(),
_settingsService.getMarineInvestigativeChatId(),
]);
if (mounted) { if (mounted) {
setState(() { setState(() {
_inSituChatId = inSituId.isNotEmpty ? inSituId : 'Not Set'; _inSituChatId = results[0].isNotEmpty ? results[0] : 'Not Set';
_tarballChatId = tarballId.isNotEmpty ? tarballId : '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; }); setState(() { _riverTriennialSearchQuery = _riverTriennialSearchController.text; });
} }
// --- FIXED: This method now uses try/catch to handle success and failure ---
Future<void> _manualDataSync() async { Future<void> _manualDataSync() async {
if (_isSyncingData) return; if (_isSyncingData) return;
setState(() => _isSyncingData = true); setState(() => _isSyncingData = true);
@ -82,20 +102,16 @@ class _SettingsScreenState extends State<SettingsScreen> {
final auth = Provider.of<AuthProvider>(context, listen: false); final auth = Provider.of<AuthProvider>(context, listen: false);
try { try {
// This function doesn't return a value, so we don't assign it to a variable.
await auth.syncAllData(forceRefresh: true); await auth.syncAllData(forceRefresh: true);
// If no error was thrown, the sync was successful.
if (mounted) { if (mounted) {
_showSnackBar('Data synced successfully.', isError: false); _showSnackBar('Data synced successfully.', isError: false);
} }
} catch (e) { } catch (e) {
// If an error was thrown during the sync, we catch it here.
if (mounted) { if (mounted) {
_showSnackBar('Data sync failed. Please check your connection.', isError: true); _showSnackBar('Data sync failed. Please check your connection.', isError: true);
} }
} finally { } finally {
// This will run whether the sync succeeded or failed.
if (mounted) { if (mounted) {
setState(() => _isSyncingData = false); setState(() => _isSyncingData = false);
} }
@ -134,7 +150,6 @@ class _SettingsScreenState extends State<SettingsScreen> {
final auth = Provider.of<AuthProvider>(context); final auth = Provider.of<AuthProvider>(context);
final lastSync = auth.lastSyncTimestamp; final lastSync = auth.lastSyncTimestamp;
// Filtering logic is unchanged
final filteredTarballStations = auth.tarballStations?.where((station) { final filteredTarballStations = auth.tarballStations?.where((station) {
final stationName = station['tbl_station_name']?.toLowerCase() ?? ''; final stationName = station['tbl_station_name']?.toLowerCase() ?? '';
final stationCode = station['tbl_station_code']?.toLowerCase() ?? ''; final stationCode = station['tbl_station_code']?.toLowerCase() ?? '';
@ -203,19 +218,32 @@ class _SettingsScreenState extends State<SettingsScreen> {
child: Padding( child: Padding(
padding: const EdgeInsets.all(16.0), padding: const EdgeInsets.all(16.0),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
ListTile( ExpansionTile(
contentPadding: EdgeInsets.zero, title: const Text('Marine Alerts', style: TextStyle(fontWeight: FontWeight.bold)),
leading: const Icon(Icons.telegram), initiallyExpanded: false,
title: const Text('Marine In-Situ Chat ID'), children: [
subtitle: Text(_inSituChatId), _buildChatIdEntry('In-Situ', _inSituChatId),
_buildChatIdEntry('Tarball', _tarballChatId),
_buildChatIdEntry('Investigative', _marineInvestigativeChatId),
],
), ),
ListTile( ExpansionTile(
contentPadding: EdgeInsets.zero, title: const Text('River Alerts', style: TextStyle(fontWeight: FontWeight.bold)),
leading: const Icon(Icons.telegram), initiallyExpanded: false,
title: const Text('Marine Tarball Chat ID'), children: [
subtitle: Text(_tarballChatId), _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), const SizedBox(height: 16),
ElevatedButton.icon( ElevatedButton.icon(
@ -233,7 +261,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
), ),
const SizedBox(height: 32), 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), const SizedBox(height: 16),
Card( Card(
margin: EdgeInsets.zero, margin: EdgeInsets.zero,
@ -241,16 +269,34 @@ class _SettingsScreenState extends State<SettingsScreen> {
padding: const EdgeInsets.all(16.0), padding: const EdgeInsets.all(16.0),
child: Column( child: Column(
children: [ 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), 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), 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), const SizedBox(height: 16),
Card( Card(
margin: EdgeInsets.zero, margin: EdgeInsets.zero,
@ -258,16 +304,34 @@ class _SettingsScreenState extends State<SettingsScreen> {
padding: const EdgeInsets.all(16.0), padding: const EdgeInsets.all(16.0),
child: Column( child: Column(
children: [ 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), 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), 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), const SizedBox(height: 16),
Card( Card(
margin: EdgeInsets.zero, margin: EdgeInsets.zero,
@ -275,16 +339,41 @@ class _SettingsScreenState extends State<SettingsScreen> {
padding: const EdgeInsets.all(16.0), padding: const EdgeInsets.all(16.0),
child: Column( child: Column(
children: [ 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), 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), 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), const SizedBox(height: 16),
Card( Card(
margin: EdgeInsets.zero, margin: EdgeInsets.zero,
@ -292,9 +381,34 @@ class _SettingsScreenState extends State<SettingsScreen> {
padding: const EdgeInsets.all(16.0), padding: const EdgeInsets.all(16.0),
child: Column( child: Column(
children: [ 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), 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, margin: EdgeInsets.zero,
child: Column( child: Column(
children: [ children: [
ListTile(leading: const Icon(Icons.info_outline), title: const Text('App Version'), subtitle: const Text('1.0.0')), ListTile(
ListTile(leading: const Icon(Icons.privacy_tip_outlined), title: const Text('Privacy Policy'), onTap: () {}), 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) { if (stations == null || stations.isEmpty) {
return Center( return Center(
child: Padding( 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 startBluetoothAutoReading({Duration? interval}) => _bluetoothManager.startAutoReading(interval: interval ?? const Duration(seconds: 5));
void stopBluetoothAutoReading() => _bluetoothManager.stopAutoReading(); void stopBluetoothAutoReading() => _bluetoothManager.stopAutoReading();
// --- USB Serial Methods --- // --- USB Serial Methods ---
Future<List<UsbDevice>> getAvailableSerialDevices() => _serialManager.getAvailableDevices(); 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 startSerialAutoReading({Duration? interval}) => _serialManager.startAutoReading(interval: interval ?? const Duration(seconds: 5));
void stopSerialAutoReading() => _serialManager.stopAutoReading(); void stopSerialAutoReading() => _serialManager.stopAutoReading();
void dispose() { void dispose() {
_bluetoothManager.dispose(); _bluetoothManager.dispose();
_serialManager.dispose(); _serialManager.dispose();
@ -148,6 +146,7 @@ class InSituSamplingService {
return _marineApiService.submitInSituSample( return _marineApiService.submitInSituSample(
formData: data.toApiFormData(), formData: data.toApiFormData(),
imageFiles: data.toApiImageFiles(), 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/base_api_service.dart';
import 'package:environment_monitoring_app/services/telegram_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/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 { class MarineApiService {
final BaseApiService _baseService = BaseApiService(); final BaseApiService _baseService = BaseApiService();
@ -22,18 +24,14 @@ class MarineApiService {
return _baseService.get('marine/tarball/classifications'); 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({ Future<Map<String, dynamic>> submitTarballSample({
required Map<String, String> formData, required Map<String, String> formData,
required Map<String, File?> imageFiles, required Map<String, File?> imageFiles,
}) async { }) async {
// --- Step 1: Submit Text Data Only ---
debugPrint("Step 1: Submitting tarball form data to the server..."); debugPrint("Step 1: Submitting tarball form data to the server...");
final dataResult = await _baseService.post('marine/tarball/sample', formData); final dataResult = await _baseService.post('marine/tarball/sample', formData);
if (dataResult['success'] != true) { if (dataResult['success'] != true) {
// Data submission failed. This is an L1 failure.
return { return {
'status': 'L1', 'status': 'L1',
'success': false, 'success': false,
@ -43,10 +41,8 @@ class MarineApiService {
} }
debugPrint("Step 1 successful. Tarball data submitted."); debugPrint("Step 1 successful. Tarball data submitted.");
// --- Step 2: Upload Image Files ---
final recordId = dataResult['data']?['autoid']; final recordId = dataResult['data']?['autoid'];
if (recordId == null) { if (recordId == null) {
// Data was saved, but we can't link the images. This is an L2 failure.
return { return {
'status': 'L2', 'status': 'L2',
'success': false, 'success': false,
@ -61,7 +57,7 @@ class MarineApiService {
}); });
if (filesToUpload.isEmpty) { if (filesToUpload.isEmpty) {
// If there are no images, the process is complete. _handleTarballSuccessAlert(formData, isDataOnly: true);
return { return {
'status': 'L3', 'status': 'L3',
'success': true, 'success': true,
@ -78,7 +74,6 @@ class MarineApiService {
); );
if (imageResult['success'] != true) { if (imageResult['success'] != true) {
// Image upload failed. This is an L2 failure.
return { return {
'status': 'L2', 'status': 'L2',
'success': false, 'success': false,
@ -87,7 +82,7 @@ class MarineApiService {
}; };
} }
// Both steps were successful. _handleTarballSuccessAlert(formData, isDataOnly: false);
return { return {
'status': 'L3', 'status': 'L3',
'success': true, 'success': true,
@ -96,12 +91,11 @@ class MarineApiService {
}; };
} }
/// Orchestrates a two-step submission process for in-situ samples.
Future<Map<String, dynamic>> submitInSituSample({ Future<Map<String, dynamic>> submitInSituSample({
required Map<String, String> formData, required Map<String, String> formData,
required Map<String, File?> imageFiles, required Map<String, File?> imageFiles,
required InSituSamplingData inSituData,
}) async { }) async {
// --- Step 1: Submit Form Data ---
debugPrint("Step 1: Submitting in-situ form data to the server..."); debugPrint("Step 1: Submitting in-situ form data to the server...");
final dataResult = await _baseService.post('marine/manual/sample', formData); final dataResult = await _baseService.post('marine/manual/sample', formData);
@ -115,7 +109,6 @@ class MarineApiService {
} }
debugPrint("Step 1 successful. In-situ data submitted."); debugPrint("Step 1 successful. In-situ data submitted.");
// --- Step 2: Upload Image Files ---
final recordId = dataResult['data']?['man_id']; final recordId = dataResult['data']?['man_id'];
if (recordId == null) { if (recordId == null) {
return { return {
@ -132,9 +125,7 @@ class MarineApiService {
}); });
if (filesToUpload.isEmpty) { if (filesToUpload.isEmpty) {
// Handle alert for successful data-only submission. _handleInSituSuccessAlert(inSituData, isDataOnly: true);
_handleInSituSuccessAlert(formData, isDataOnly: true);
return { return {
'status': 'L3', 'status': 'L3',
'success': true, 'success': true,
@ -159,9 +150,7 @@ class MarineApiService {
}; };
} }
// Handle alert for successful data and image submission. _handleInSituSuccessAlert(inSituData, isDataOnly: false);
_handleInSituSuccessAlert(formData, isDataOnly: false);
return { return {
'status': 'L3', 'status': 'L3',
'success': true, 'success': true,
@ -170,57 +159,64 @@ class MarineApiService {
}; };
} }
/// A private helper method to build and send the detailed in-situ alert. Future<void> _handleTarballSuccessAlert(Map<String, String> formData, {required bool isDataOnly}) async {
Future<void> _handleInSituSuccessAlert(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 { try {
final groupChatId = await _settingsService.getInSituChatId(); final groupChatId = await _settingsService.getInSituChatId();
if (groupChatId.isNotEmpty) { if (groupChatId.isNotEmpty) {
// Extract data from the formData map with fallbacks final message = data.generateTelegramAlertMessage(isDataOnly: isDataOnly);
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 bool wasSent = await _telegramService.sendAlertImmediately('marine_in_situ', message); final bool wasSent = await _telegramService.sendAlertImmediately('marine_in_situ', message);
if (!wasSent) { if (!wasSent) {
await _telegramService.queueMessage('marine_in_situ', message); await _telegramService.queueMessage('marine_in_situ', message);
} }
} }
} catch (e) { } 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:shared_preferences/shared_preferences.dart';
import 'package:environment_monitoring_app/services/base_api_service.dart'; import 'package:environment_monitoring_app/services/base_api_service.dart';
class SettingsService { class SettingsService {
final BaseApiService _baseService = BaseApiService(); final BaseApiService _baseService = BaseApiService();
// Keys for SharedPreferences
static const _inSituChatIdKey = 'telegram_in_situ_chat_id'; static const _inSituChatIdKey = 'telegram_in_situ_chat_id';
static const _tarballChatIdKey = 'telegram_tarball_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. /// Fetches settings from the server and saves them to local storage.
Future<bool> syncFromServer() async { Future<bool> syncFromServer() async {
try {
final result = await _baseService.get('settings'); final result = await _baseService.get('settings');
if (result['success'] == true && result['data'] is Map) { if (result['success'] == true && result['data'] is Map) {
final settings = result['data'] as Map<String, dynamic>; final settings = result['data'] as Map<String, dynamic>;
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
// Save the chat IDs from the nested map // Save all chat IDs from the nested maps
final inSituSettings = settings['marine_in_situ'] as Map<String, dynamic>?; await Future.wait([
await prefs.setString(_inSituChatIdKey, inSituSettings?['telegram_chat_id'] ?? ''); _saveChatId(prefs, _inSituChatIdKey, settings['marine_in_situ']),
_saveChatId(prefs, _tarballChatIdKey, settings['marine_tarball']),
final tarballSettings = settings['marine_tarball'] as Map<String, dynamic>?; _saveChatId(prefs, _riverInSituChatIdKey, settings['river_in_situ']),
await prefs.setString(_tarballChatIdKey, tarballSettings?['telegram_chat_id'] ?? ''); _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 true;
} }
return false; 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. /// Gets the locally stored Chat ID for the In-Situ module.
@ -39,4 +60,40 @@ class SettingsService {
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
return prefs.getString(_tarballChatIdKey) ?? ''; 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; bool _isProcessing = false;
// --- ADDED: New method to attempt immediate sending ---
/// Tries to send an alert immediately over the network. /// Tries to send an alert immediately over the network.
/// Returns `true` on success, `false` on failure. /// Returns `true` on success, `false` on failure.
Future<bool> sendAlertImmediately(String module, String message) async { Future<bool> sendAlertImmediately(String module, String message) async {
@ -24,7 +23,7 @@ class TelegramService {
if (chatId.isEmpty) { if (chatId.isEmpty) {
debugPrint("[TelegramService] ❌ Cannot send immediately. Chat ID for module '$module' is not configured."); 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( final result = await _apiService.sendTelegramAlert(
@ -68,7 +67,7 @@ class TelegramService {
debugPrint("[TelegramService] ✅ Alert queued for module: $module"); 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 { Future<void> processAlertQueue() async {
if (_isProcessing) { if (_isProcessing) {
debugPrint("[TelegramService] ⏳ Queue is already being processed. Skipping."); debugPrint("[TelegramService] ⏳ Queue is already being processed. Skipping.");