add telegram alert in manual tarball. fix all module homepage layout. fix homepage layout.
This commit is contained in:
parent
a2d8b372e6
commit
e3b58bf74e
@ -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,31 +57,60 @@ 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,
|
||||
children: [
|
||||
// Updated navigation to the new department home pages
|
||||
_buildNavButton(context, "Air", Icons.cloud, '/air/home'),
|
||||
_buildNavButton(context, "River", Icons.water, '/river/home'),
|
||||
_buildNavButton(context, "Marine", Icons.sailing, '/marine/home'),
|
||||
],
|
||||
const SizedBox(height: 8),
|
||||
Expanded(
|
||||
child: GridView.count(
|
||||
crossAxisCount: 2,
|
||||
mainAxisSpacing: 8,
|
||||
crossAxisSpacing: 8,
|
||||
childAspectRatio: 1.6, // Wider and much shorter boxes
|
||||
padding: EdgeInsets.zero, // No extra padding
|
||||
shrinkWrap: true,
|
||||
physics: const ClampingScrollPhysics(),
|
||||
children: [
|
||||
_buildMiniCategoryCard(
|
||||
context,
|
||||
title: "Air",
|
||||
icon: Icons.air,
|
||||
color: Colors.blue.shade700,
|
||||
route: '/air/home',
|
||||
),
|
||||
_buildMiniCategoryCard(
|
||||
context,
|
||||
title: "River",
|
||||
icon: Icons.water,
|
||||
color: Colors.teal.shade700,
|
||||
route: '/river/home',
|
||||
),
|
||||
_buildMiniCategoryCard(
|
||||
context,
|
||||
title: "Marine",
|
||||
icon: Icons.sailing,
|
||||
color: Colors.indigo.shade700,
|
||||
route: '/marine/home',
|
||||
),
|
||||
_buildMiniSettingsCard(context),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
@ -91,15 +121,89 @@ class _HomePageState extends State<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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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 = {};
|
||||
|
||||
@ -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;
|
||||
}
|
||||
@ -80,4 +124,4 @@ class TarballSamplingData {
|
||||
'optional_photo_04': optionalImage4,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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,33 +132,42 @@ class AirHomePage extends StatelessWidget {
|
||||
Navigator.pushNamed(context, subItem.route!);
|
||||
}
|
||||
},
|
||||
borderRadius: BorderRadius.circular(0), // Removed border radius for seamless grid
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(8.0), // Padding around the row content
|
||||
child: Row( // Changed from Column to Row
|
||||
mainAxisAlignment: MainAxisAlignment.start, // Align content to start
|
||||
children: [
|
||||
subItem.icon != null
|
||||
? Icon(subItem.icon, color: Colors.white70, size: 24) // Increased icon size from 22 to 24
|
||||
: const SizedBox.shrink(),
|
||||
const SizedBox(width: 8), // Space between icon and text (horizontal)
|
||||
Expanded( // Allow text to take remaining space
|
||||
child: Text(
|
||||
subItem.label,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(color: Colors.white70, fontSize: 11), // Increased text size from 10 to 11
|
||||
textAlign: TextAlign.left, // Align text to left
|
||||
overflow: TextOverflow.ellipsis,
|
||||
maxLines: 1, // Single line for label
|
||||
borderRadius: BorderRadius.circular(0),
|
||||
child: Container(
|
||||
margin: const EdgeInsets.all(4.0), // Added margin for better spacing
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: Colors.white24, width: 0.5), // Optional: subtle border
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.start,
|
||||
children: [
|
||||
subItem.icon != null
|
||||
? Icon(subItem.icon, color: Colors.white70, size: 24)
|
||||
: const SizedBox.shrink(),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
subItem.label,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: Colors.white70,
|
||||
fontSize: 12, // Slightly increased font size
|
||||
),
|
||||
textAlign: TextAlign.left,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
maxLines: 2, // Allow for two lines if needed
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16), // Reduced gap after each category group
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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() ?? '');
|
||||
|
||||
@ -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) {
|
||||
try {
|
||||
final foundClassification = classifications.firstWhere(
|
||||
(c) => c['classification_id'] == widget.data.classificationId,
|
||||
);
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_selectedClassification = foundClassification;
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint("Could not find pre-selected classification with ID: ${widget.data.classificationId}");
|
||||
}
|
||||
final auth = Provider.of<AuthProvider>(context, listen: false);
|
||||
|
||||
// Restore the selected value from the data model using the cached list in the provider
|
||||
if (widget.data.classificationId != null && auth.tarballClassifications != null) {
|
||||
try {
|
||||
final foundClassification = auth.tarballClassifications!.firstWhere(
|
||||
(c) => c['classification_id'] == widget.data.classificationId,
|
||||
);
|
||||
setState(() {
|
||||
_selectedClassification = foundClassification;
|
||||
// Also restore the full object to the data model
|
||||
widget.data.selectedClassification = foundClassification;
|
||||
});
|
||||
} catch (e) {
|
||||
debugPrint("Could not find pre-selected classification in the cached list.");
|
||||
}
|
||||
}
|
||||
|
||||
// **OFFLINE-FIRST SYNC**:
|
||||
// Attempt to sync all master data with the server in the background.
|
||||
// The UI will build instantly using existing local data from AuthProvider.
|
||||
// If the sync is successful, the Consumer widget will automatically rebuild the dropdown with fresh data.
|
||||
// If offline, this will fail gracefully and the user will see the data from the last successful sync.
|
||||
auth.syncAllData();
|
||||
});
|
||||
}
|
||||
|
||||
@ -69,7 +78,6 @@ class _TarballSamplingStep2State extends State<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(
|
||||
@ -327,4 +333,4 @@ class _TarballSamplingStep2State extends State<TarballSamplingStep2> {
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -114,16 +114,16 @@ class MarineHomePage extends StatelessWidget {
|
||||
],
|
||||
),
|
||||
),
|
||||
const Divider(height: 24, thickness: 1, color: Colors.white24), // Divider below category title
|
||||
// Grid of sub-menu items
|
||||
const Divider(height: 24, thickness: 1, color: Colors.white24),
|
||||
// Grid of sub-menu items - changed to 2 columns
|
||||
GridView.builder(
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: 3, // 3 columns for sub-menu items
|
||||
crossAxisSpacing: 0.0, // Removed horizontal spacing
|
||||
mainAxisSpacing: 0.0, // Removed vertical spacing
|
||||
childAspectRatio: 2.8, // Adjusted aspect ratio for horizontal icon-label layout with bigger content
|
||||
crossAxisCount: 2, // Changed from 3 to 2 columns
|
||||
crossAxisSpacing: 0.0,
|
||||
mainAxisSpacing: 0.0,
|
||||
childAspectRatio: 4.0, // Adjusted aspect ratio for better 2-column layout
|
||||
),
|
||||
itemCount: category.children?.length ?? 0,
|
||||
itemBuilder: (context, index) {
|
||||
@ -134,33 +134,42 @@ class MarineHomePage extends StatelessWidget {
|
||||
Navigator.pushNamed(context, subItem.route!);
|
||||
}
|
||||
},
|
||||
borderRadius: BorderRadius.circular(0), // Removed border radius for seamless grid
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(8.0), // Padding around the row content
|
||||
child: Row( // Changed from Column to Row
|
||||
mainAxisAlignment: MainAxisAlignment.start, // Align content to start
|
||||
children: [
|
||||
subItem.icon != null
|
||||
? Icon(subItem.icon, color: Colors.white70, size: 24) // Increased icon size from 22 to 24
|
||||
: const SizedBox.shrink(),
|
||||
const SizedBox(width: 8), // Space between icon and text (horizontal)
|
||||
Expanded( // Allow text to take remaining space
|
||||
child: Text(
|
||||
subItem.label,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(color: Colors.white70, fontSize: 11), // Increased text size from 10 to 11
|
||||
textAlign: TextAlign.left, // Align text to left
|
||||
overflow: TextOverflow.ellipsis,
|
||||
maxLines: 1, // Single line for label
|
||||
borderRadius: BorderRadius.circular(0),
|
||||
child: Container(
|
||||
margin: const EdgeInsets.all(4.0), // Added margin for better spacing
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: Colors.white24, width: 0.5), // Optional: subtle border
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.start,
|
||||
children: [
|
||||
subItem.icon != null
|
||||
? Icon(subItem.icon, color: Colors.white70, size: 24)
|
||||
: const SizedBox.shrink(),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
subItem.label,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: Colors.white70,
|
||||
fontSize: 12, // Slightly increased font size
|
||||
),
|
||||
textAlign: TextAlign.left,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
maxLines: 2, // Allow for two lines if needed
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16), // Reduced gap after each category group
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -112,16 +112,16 @@ class RiverHomePage extends StatelessWidget {
|
||||
],
|
||||
),
|
||||
),
|
||||
const Divider(height: 24, thickness: 1, color: Colors.white24), // Divider below category title
|
||||
// Grid of sub-menu items
|
||||
const Divider(height: 24, thickness: 1, color: Colors.white24),
|
||||
// Grid of sub-menu items - changed to 2 columns
|
||||
GridView.builder(
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: 3, // 3 columns for sub-menu items
|
||||
crossAxisSpacing: 0.0, // Removed horizontal spacing
|
||||
mainAxisSpacing: 0.0, // Removed vertical spacing
|
||||
childAspectRatio: 2.8, // Adjusted aspect ratio for horizontal icon-label layout with bigger content
|
||||
crossAxisCount: 2, // Changed from 3 to 2 columns
|
||||
crossAxisSpacing: 0.0,
|
||||
mainAxisSpacing: 0.0,
|
||||
childAspectRatio: 4.0, // Adjusted aspect ratio for better 2-column layout
|
||||
),
|
||||
itemCount: category.children?.length ?? 0,
|
||||
itemBuilder: (context, index) {
|
||||
@ -132,33 +132,42 @@ class RiverHomePage extends StatelessWidget {
|
||||
Navigator.pushNamed(context, subItem.route!);
|
||||
}
|
||||
},
|
||||
borderRadius: BorderRadius.circular(0), // Removed border radius for seamless grid
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(8.0), // Padding around the row content
|
||||
child: Row( // Changed from Column to Row
|
||||
mainAxisAlignment: MainAxisAlignment.start, // Align content to start
|
||||
children: [
|
||||
subItem.icon != null
|
||||
? Icon(subItem.icon, color: Colors.white70, size: 24) // Increased icon size from 22 to 24
|
||||
: const SizedBox.shrink(),
|
||||
const SizedBox(width: 8), // Space between icon and text (horizontal)
|
||||
Expanded( // Allow text to take remaining space
|
||||
child: Text(
|
||||
subItem.label,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(color: Colors.white70, fontSize: 11), // Increased text size from 10 to 11
|
||||
textAlign: TextAlign.left, // Align text to left
|
||||
overflow: TextOverflow.ellipsis,
|
||||
maxLines: 1, // Single line for label
|
||||
borderRadius: BorderRadius.circular(0),
|
||||
child: Container(
|
||||
margin: const EdgeInsets.all(4.0), // Added margin for better spacing
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: Colors.white24, width: 0.5), // Optional: subtle border
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.start,
|
||||
children: [
|
||||
subItem.icon != null
|
||||
? Icon(subItem.icon, color: Colors.white70, size: 24)
|
||||
: const SizedBox.shrink(),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
subItem.label,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: Colors.white70,
|
||||
fontSize: 12, // Slightly increased font size
|
||||
),
|
||||
textAlign: TextAlign.left,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
maxLines: 2, // Allow for two lines if needed
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16), // Reduced gap after each category group
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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 {
|
||||
final result = await _baseService.get('settings');
|
||||
try {
|
||||
final result = await _baseService.get('settings');
|
||||
|
||||
if (result['success'] == true && result['data'] is Map) {
|
||||
final settings = result['data'] as Map<String, dynamic>;
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
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'] ?? '');
|
||||
// Save all chat IDs from the nested maps
|
||||
await Future.wait([
|
||||
_saveChatId(prefs, _inSituChatIdKey, settings['marine_in_situ']),
|
||||
_saveChatId(prefs, _tarballChatIdKey, settings['marine_tarball']),
|
||||
_saveChatId(prefs, _riverInSituChatIdKey, settings['river_in_situ']),
|
||||
_saveChatId(prefs, _riverTriennialChatIdKey, settings['river_triennial']),
|
||||
_saveChatId(prefs, _riverInvestigativeChatIdKey, settings['river_investigative']),
|
||||
_saveChatId(prefs, _airManualChatIdKey, settings['air_manual']),
|
||||
_saveChatId(prefs, _airInvestigativeChatIdKey, settings['air_investigative']),
|
||||
_saveChatId(prefs, _marineInvestigativeChatIdKey, settings['marine_investigative']),
|
||||
]);
|
||||
|
||||
final tarballSettings = settings['marine_tarball'] as Map<String, dynamic>?;
|
||||
await prefs.setString(_tarballChatIdKey, tarballSettings?['telegram_chat_id'] ?? '');
|
||||
|
||||
return true;
|
||||
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() ?? '');
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// Gets the locally stored Chat ID for the In-Situ module.
|
||||
@ -39,4 +60,40 @@ class SettingsService {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
return prefs.getString(_tarballChatIdKey) ?? '';
|
||||
}
|
||||
|
||||
/// Gets the locally stored Chat ID for the River In-Situ module.
|
||||
Future<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) ?? '';
|
||||
}
|
||||
}
|
||||
@ -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.");
|
||||
|
||||
Loading…
Reference in New Issue
Block a user