fix issue on submission using ftp and api. fix issue on data status log display for each module
This commit is contained in:
parent
1a1a1bd7d0
commit
0c37669725
@ -13,12 +13,13 @@ import 'package:environment_monitoring_app/services/retry_service.dart';
|
||||
/// A comprehensive provider to manage user authentication, session state,
|
||||
/// and cached master data for offline use.
|
||||
class AuthProvider with ChangeNotifier {
|
||||
final ApiService _apiService = ApiService();
|
||||
final DatabaseHelper _dbHelper = DatabaseHelper();
|
||||
// FIX: Change to late final and remove direct instantiation.
|
||||
late final ApiService _apiService;
|
||||
late final DatabaseHelper _dbHelper;
|
||||
// --- ADDED: Instance of the ServerConfigService to set the initial URL ---
|
||||
final ServerConfigService _serverConfigService = ServerConfigService();
|
||||
late final ServerConfigService _serverConfigService;
|
||||
// --- ADDED: Instance of the RetryService to manage pending tasks ---
|
||||
final RetryService _retryService = RetryService();
|
||||
late final RetryService _retryService;
|
||||
|
||||
|
||||
// --- Session & Profile State ---
|
||||
@ -86,7 +87,16 @@ class AuthProvider with ChangeNotifier {
|
||||
static const String lastSyncTimestampKey = 'last_sync_timestamp';
|
||||
static const String isFirstLoginKey = 'is_first_login';
|
||||
|
||||
AuthProvider() {
|
||||
// FIX: Constructor now accepts dependencies.
|
||||
AuthProvider({
|
||||
required ApiService apiService,
|
||||
required DatabaseHelper dbHelper,
|
||||
required ServerConfigService serverConfigService,
|
||||
required RetryService retryService,
|
||||
}) : _apiService = apiService,
|
||||
_dbHelper = dbHelper,
|
||||
_serverConfigService = serverConfigService,
|
||||
_retryService = retryService {
|
||||
debugPrint('AuthProvider: Initializing...');
|
||||
_loadSessionAndSyncData();
|
||||
}
|
||||
|
||||
@ -6,11 +6,17 @@ import 'package:connectivity_plus/connectivity_plus.dart';
|
||||
|
||||
// CHANGED: Added imports for MultiProvider and the services to be provided.
|
||||
import 'package:provider/single_child_widget.dart';
|
||||
import 'package:environment_monitoring_app/services/api_service.dart';
|
||||
import 'package:environment_monitoring_app/services/base_api_service.dart';
|
||||
import 'package:environment_monitoring_app/services/local_storage_service.dart';
|
||||
import 'package:environment_monitoring_app/services/river_in_situ_sampling_service.dart';
|
||||
// --- ADDED: Import for the new AirSamplingService ---
|
||||
import 'package:environment_monitoring_app/services/air_sampling_service.dart';
|
||||
import 'package:environment_monitoring_app/services/telegram_service.dart';
|
||||
// FIX: ADDED MISSING IMPORTS
|
||||
import 'package:environment_monitoring_app/services/server_config_service.dart';
|
||||
import 'package:environment_monitoring_app/services/retry_service.dart';
|
||||
import 'package:environment_monitoring_app/services/in_situ_sampling_service.dart'; // FIX: ADDED MISSING IMPORT
|
||||
|
||||
import 'package:environment_monitoring_app/theme.dart';
|
||||
import 'package:environment_monitoring_app/auth_provider.dart';
|
||||
@ -88,33 +94,56 @@ import 'package:environment_monitoring_app/screens/marine/manual/tarball_samplin
|
||||
import 'package:environment_monitoring_app/screens/marine/manual/tarball_sampling_step2.dart';
|
||||
import 'package:environment_monitoring_app/screens/marine/manual/tarball_sampling_step3_summary.dart';
|
||||
|
||||
|
||||
void main() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
setupServices();
|
||||
// Create singleton instances of core services before running the app
|
||||
// 1. Create dependent services
|
||||
final DatabaseHelper databaseHelper = DatabaseHelper();
|
||||
// FIX: TelegramService is created first, without ApiService.
|
||||
final TelegramService telegramService = TelegramService();
|
||||
|
||||
// 2. Create the primary service, injecting its dependency
|
||||
// FIX: ApiService now requires the TelegramService instance.
|
||||
final ApiService apiService = ApiService(telegramService: telegramService);
|
||||
|
||||
// 3. Complete the circular reference injection (TelegramService needs ApiService)
|
||||
// FIX: Inject the ApiService back into the TelegramService instance.
|
||||
telegramService.setApiService(apiService);
|
||||
|
||||
setupServices(telegramService);
|
||||
|
||||
runApp(
|
||||
// CHANGED: Converted to MultiProvider to support all necessary services.
|
||||
MultiProvider(
|
||||
providers: <SingleChildWidget>[
|
||||
// The original AuthProvider
|
||||
ChangeNotifierProvider(create: (_) => AuthProvider()),
|
||||
// Provider for Local Storage Service
|
||||
// FIX: AuthProvider now requires all its services in the constructor.
|
||||
ChangeNotifierProvider(
|
||||
create: (context) => AuthProvider(
|
||||
apiService: apiService,
|
||||
dbHelper: databaseHelper,
|
||||
serverConfigService: ServerConfigService(), // Create local instances for AuthProvider DI
|
||||
retryService: RetryService(),
|
||||
),
|
||||
),
|
||||
// Providers for core services
|
||||
Provider<ApiService>(create: (_) => apiService),
|
||||
Provider<DatabaseHelper>(create: (_) => databaseHelper),
|
||||
Provider<TelegramService>(create: (_) => telegramService),
|
||||
// Providers for feature-specific services, with their dependencies correctly injected
|
||||
Provider(create: (_) => LocalStorageService()),
|
||||
// Provider for the River In-Situ Sampling Service
|
||||
Provider(create: (_) => RiverInSituSamplingService()),
|
||||
// --- ADDED: Provider for the new AirSamplingService ---
|
||||
Provider(create: (_) => AirSamplingService()),
|
||||
Provider(create: (context) => RiverInSituSamplingService(apiService.river)), // FIXED: Passed the required dependency
|
||||
// --- ADDED: Provider for the new AirSamplingService with dependencies ---
|
||||
Provider(create: (context) => AirSamplingService(apiService, databaseHelper, telegramService)),
|
||||
Provider(create: (context) => InSituSamplingService()), // FIX: InSituSamplingService constructor does not take arguments
|
||||
],
|
||||
child: const RootApp(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void setupServices() {
|
||||
final telegramService = TelegramService();
|
||||
|
||||
void setupServices(TelegramService telegramService) {
|
||||
Future.delayed(const Duration(seconds: 5), () {
|
||||
debugPrint("[Main] Performing initial alert queue processing on app start.");
|
||||
telegramService.processAlertQueue();
|
||||
@ -165,8 +194,11 @@ class RootApp extends StatelessWidget {
|
||||
return TarballSamplingStep3Summary(data: args);
|
||||
});
|
||||
}
|
||||
// NOTE: The River and Air In-Situ forms use an internal stepper,
|
||||
// so they do not require onGenerateRoute logic for their steps.
|
||||
if (settings.name == '/marine/manual/data-log') {
|
||||
return MaterialPageRoute(builder: (context) {
|
||||
return const marineManualDataStatusLog.MarineManualDataStatusLog();
|
||||
});
|
||||
}
|
||||
return null;
|
||||
},
|
||||
routes: {
|
||||
@ -207,12 +239,9 @@ class RootApp extends StatelessWidget {
|
||||
// River Manual
|
||||
'/river/manual/dashboard': (context) => RiverManualDashboard(),
|
||||
'/river/manual/in-situ': (context) => riverManualInSituSampling.RiverInSituSamplingScreen(),
|
||||
|
||||
//'/river/manual/in-situ': (context) => riverManualInSituSampling.RiverInSituSampling(),
|
||||
'/river/manual/report': (context) => riverManualReport.RiverManualReport(),
|
||||
'/river/manual/triennial': (context) => riverManualTriennialSampling.RiverTriennialSampling(),
|
||||
'/river/manual/data-log': (context) => riverManualDataStatusLog.RiverDataStatusLog(),
|
||||
//'/river/manual/data-log': (context) => riverManualDataStatusLog.RiverManualDataStatusLog(),
|
||||
'/river/manual/data-log': (context) => riverManualDataStatusLog.RiverManualDataStatusLog(),
|
||||
'/river/manual/image-request': (context) => riverManualImageRequest.RiverManualImageRequest(),
|
||||
|
||||
// River Continuous
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
// lib/models/air_collection_data.dart
|
||||
|
||||
import 'dart:io';
|
||||
import 'dart:convert'; // Added for jsonEncode
|
||||
import 'air_installation_data.dart';
|
||||
@ -148,16 +150,16 @@ class AirCollectionData {
|
||||
optionalRemark2: map['optionalRemark2'],
|
||||
optionalRemark3: map['optionalRemark3'],
|
||||
optionalRemark4: map['optionalRemark4'],
|
||||
imageFront: fileFromPath(map['imageFrontPath']),
|
||||
imageBack: fileFromPath(map['imageBackPath']),
|
||||
imageLeft: fileFromPath(map['imageLeftPath']),
|
||||
imageRight: fileFromPath(map['imageRightPath']),
|
||||
imageChart: fileFromPath(map['imageChartPath']),
|
||||
imageFilterPaper: fileFromPath(map['imageFilterPaperPath']),
|
||||
optionalImage1: fileFromPath(map['optionalImage1Path']),
|
||||
optionalImage2: fileFromPath(map['optionalImage2Path']),
|
||||
optionalImage3: fileFromPath(map['optionalImage3Path']),
|
||||
optionalImage4: fileFromPath(map['optionalImage4Path']),
|
||||
imageFront: fileFromPath(map['imageFrontPath'] ?? map['imageFront']), // FIX: Check both path keys
|
||||
imageBack: fileFromPath(map['imageBackPath'] ?? map['imageBack']), // FIX: Check both path keys
|
||||
imageLeft: fileFromPath(map['imageLeftPath'] ?? map['imageLeft']), // FIX: Check both path keys
|
||||
imageRight: fileFromPath(map['imageRightPath'] ?? map['imageRight']), // FIX: Check both path keys
|
||||
imageChart: fileFromPath(map['imageChartPath'] ?? map['imageChart']),
|
||||
imageFilterPaper: fileFromPath(map['imageFilterPaperPath'] ?? map['imageFilterPaper']),
|
||||
optionalImage1: fileFromPath(map['optionalImage1Path'] ?? map['optionalImage1']), // FIX: Check both path keys
|
||||
optionalImage2: fileFromPath(map['optionalImage2Path'] ?? map['optionalImage2']), // FIX: Check both path keys
|
||||
optionalImage3: fileFromPath(map['optionalImage3Path'] ?? map['optionalImage3']), // FIX: Check both path keys
|
||||
optionalImage4: fileFromPath(map['optionalImage4Path'] ?? map['optionalImage4']), // FIX: Check both path keys
|
||||
);
|
||||
}
|
||||
|
||||
@ -191,16 +193,16 @@ class AirCollectionData {
|
||||
'optionalRemark2': optionalRemark2,
|
||||
'optionalRemark3': optionalRemark3,
|
||||
'optionalRemark4': optionalRemark4,
|
||||
'imageFront': imageFront,
|
||||
'imageBack': imageBack,
|
||||
'imageLeft': imageLeft,
|
||||
'imageRight': imageRight,
|
||||
'imageChart': imageChart,
|
||||
'imageFilterPaper': imageFilterPaper,
|
||||
'optionalImage1': optionalImage1,
|
||||
'optionalImage2': optionalImage2,
|
||||
'optionalImage3': optionalImage3,
|
||||
'optionalImage4': optionalImage4,
|
||||
'imageFront': imageFront?.path, // Store path for log
|
||||
'imageBack': imageBack?.path, // Store path for log
|
||||
'imageLeft': imageLeft?.path, // Store path for log
|
||||
'imageRight': imageRight?.path, // Store path for log
|
||||
'imageChart': imageChart?.path,
|
||||
'imageFilterPaper': imageFilterPaper?.path,
|
||||
'optionalImage1': optionalImage1?.path,
|
||||
'optionalImage2': optionalImage2?.path,
|
||||
'optionalImage3': optionalImage3?.path,
|
||||
'optionalImage4': optionalImage4?.path,
|
||||
};
|
||||
}
|
||||
|
||||
@ -216,7 +218,7 @@ class AirCollectionData {
|
||||
'air_man_collection_pm10_flowrate': pm10Flowrate?.toString(),
|
||||
'air_man_collection_pm10_flowrate_result': pm10FlowrateResult,
|
||||
'air_man_collection_pm10_total_time': pm10TotalTime,
|
||||
'air_man_collection_total_time_result': pm10TotalTimeResult,
|
||||
'air_man_collection_pm10_total_time_result': pm10TotalTimeResult,
|
||||
'air_man_collection_pm10_pressure': pm10Pressure?.toString(),
|
||||
'air_man_collection_pm10_pressure_result': pm10PressureResult,
|
||||
'air_man_collection_pm10_vstd': pm10Vstd?.toString(),
|
||||
|
||||
@ -132,14 +132,14 @@ class AirInstallationData {
|
||||
'optionalRemark2': optionalRemark2,
|
||||
'optionalRemark3': optionalRemark3,
|
||||
'optionalRemark4': optionalRemark4,
|
||||
'imageFront': imageFront,
|
||||
'imageBack': imageBack,
|
||||
'imageLeft': imageLeft,
|
||||
'imageRight': imageRight,
|
||||
'optionalImage1': optionalImage1,
|
||||
'optionalImage2': optionalImage2,
|
||||
'optionalImage3': optionalImage3,
|
||||
'optionalImage4': optionalImage4,
|
||||
'imageFront': imageFront?.path, // Store path for log
|
||||
'imageBack': imageBack?.path, // Store path for log
|
||||
'imageLeft': imageLeft?.path, // Store path for log
|
||||
'imageRight': imageRight?.path, // Store path for log
|
||||
'optionalImage1': optionalImage1?.path, // Store path for log
|
||||
'optionalImage2': optionalImage2?.path, // Store path for log
|
||||
'optionalImage3': optionalImage3?.path, // Store path for log
|
||||
'optionalImage4': optionalImage4?.path, // Store path for log
|
||||
'collectionData': collectionData?.toMap(),
|
||||
};
|
||||
}
|
||||
@ -167,14 +167,14 @@ class AirInstallationData {
|
||||
installationUserId: json['installationUserId'],
|
||||
installationUserName: json['installationUserName'],
|
||||
status: json['status'],
|
||||
imageFront: fileFromPath(json['imageFrontPath']),
|
||||
imageBack: fileFromPath(json['imageBackPath']),
|
||||
imageLeft: fileFromPath(json['imageLeftPath']),
|
||||
imageRight: fileFromPath(json['imageRightPath']),
|
||||
optionalImage1: fileFromPath(json['optionalImage1Path']),
|
||||
optionalImage2: fileFromPath(json['optionalImage2Path']),
|
||||
optionalImage3: fileFromPath(json['optionalImage3Path']),
|
||||
optionalImage4: fileFromPath(json['optionalImage4Path']),
|
||||
imageFront: fileFromPath(json['imageFrontPath'] ?? json['imageFront']), // FIX: Check both path keys
|
||||
imageBack: fileFromPath(json['imageBackPath'] ?? json['imageBack']), // FIX: Check both path keys
|
||||
imageLeft: fileFromPath(json['imageLeftPath'] ?? json['imageLeft']), // FIX: Check both path keys
|
||||
imageRight: fileFromPath(json['imageRightPath'] ?? json['imageRight']), // FIX: Check both path keys
|
||||
optionalImage1: fileFromPath(json['optionalImage1Path'] ?? json['optionalImage1']), // FIX: Check both path keys
|
||||
optionalImage2: fileFromPath(json['optionalImage2Path'] ?? json['optionalImage2']), // FIX: Check both path keys
|
||||
optionalImage3: fileFromPath(json['optionalImage3Path'] ?? json['optionalImage3']), // FIX: Check both path keys
|
||||
optionalImage4: fileFromPath(json['optionalImage4Path'] ?? json['optionalImage4']), // FIX: Check both path keys
|
||||
optionalRemark1: json['optionalRemark1'],
|
||||
optionalRemark2: json['optionalRemark2'],
|
||||
optionalRemark3: json['optionalRemark3'],
|
||||
|
||||
@ -86,13 +86,13 @@ class RiverInSituSamplingData {
|
||||
return (path is String && path.isNotEmpty) ? File(path) : null;
|
||||
}
|
||||
|
||||
// FIX: Robust helper functions for parsing numerical values
|
||||
double? doubleFromJson(dynamic value) {
|
||||
if (value is num) return value.toDouble();
|
||||
if (value is String) return double.tryParse(value);
|
||||
return null;
|
||||
}
|
||||
|
||||
// ADDED HELPER FUNCTION TO FIX THE ERROR
|
||||
int? intFromJson(dynamic value) {
|
||||
if (value is int) return value;
|
||||
if (value is String) return int.tryParse(value);
|
||||
@ -123,6 +123,7 @@ class RiverInSituSamplingData {
|
||||
..sondeId = json['r_man_sondeID']
|
||||
..dataCaptureDate = json['data_capture_date']
|
||||
..dataCaptureTime = json['data_capture_time']
|
||||
// FIX: Apply doubleFromJson helper to all numerical fields
|
||||
..oxygenConcentration = doubleFromJson(json['r_man_oxygen_conc'])
|
||||
..oxygenSaturation = doubleFromJson(json['r_man_oxygen_sat'])
|
||||
..ph = doubleFromJson(json['r_man_ph'])
|
||||
@ -133,6 +134,7 @@ class RiverInSituSamplingData {
|
||||
..turbidity = doubleFromJson(json['r_man_turbidity'])
|
||||
..tss = doubleFromJson(json['r_man_tss'])
|
||||
..batteryVoltage = doubleFromJson(json['r_man_battery_volt'])
|
||||
// END FIX
|
||||
..optionalRemark1 = json['r_man_optional_photo_01_remarks']
|
||||
..optionalRemark2 = json['r_man_optional_photo_02_remarks']
|
||||
..optionalRemark3 = json['r_man_optional_photo_03_remarks']
|
||||
@ -147,6 +149,7 @@ class RiverInSituSamplingData {
|
||||
..optionalImage4 = fileFromJson(json['r_man_optional_photo_04'])
|
||||
// ADDED: Flowrate fields from JSON
|
||||
..flowrateMethod = json['r_man_flowrate_method']
|
||||
// FIX: Apply doubleFromJson helper to all new numerical flowrate fields
|
||||
..flowrateSurfaceDrifterHeight = doubleFromJson(json['r_man_flowrate_sd_height'])
|
||||
..flowrateSurfaceDrifterDistance = doubleFromJson(json['r_man_flowrate_sd_distance'])
|
||||
..flowrateSurfaceDrifterTimeFirst = json['r_man_flowrate_sd_time_first']
|
||||
@ -236,7 +239,65 @@ class RiverInSituSamplingData {
|
||||
};
|
||||
}
|
||||
|
||||
// --- ADDED: Methods to format data for FTP submission as separate JSON files ---
|
||||
// ADDED: A new method to support the centralized submission logging
|
||||
Map<String, dynamic> toMap() {
|
||||
return {
|
||||
'firstSamplerName': firstSamplerName,
|
||||
'firstSamplerUserId': firstSamplerUserId,
|
||||
'secondSampler': secondSampler,
|
||||
'samplingDate': samplingDate,
|
||||
'samplingTime': samplingTime,
|
||||
'samplingType': samplingType,
|
||||
'sampleIdCode': sampleIdCode,
|
||||
'selectedStateName': selectedStateName,
|
||||
'selectedCategoryName': selectedCategoryName,
|
||||
'selectedStation': selectedStation,
|
||||
'stationLatitude': stationLatitude,
|
||||
'stationLongitude': stationLongitude,
|
||||
'currentLatitude': currentLatitude,
|
||||
'currentLongitude': currentLongitude,
|
||||
'distanceDifferenceInKm': distanceDifferenceInKm,
|
||||
'distanceDifferenceRemarks': distanceDifferenceRemarks,
|
||||
'weather': weather,
|
||||
'eventRemarks': eventRemarks,
|
||||
'labRemarks': labRemarks,
|
||||
'backgroundStationImage': backgroundStationImage?.path,
|
||||
'upstreamRiverImage': upstreamRiverImage?.path,
|
||||
'downstreamRiverImage': downstreamRiverImage?.path,
|
||||
'sampleTurbidityImage': sampleTurbidityImage?.path,
|
||||
'optionalImage1': optionalImage1?.path,
|
||||
'optionalRemark1': optionalRemark1,
|
||||
'optionalImage2': optionalImage2?.path,
|
||||
'optionalRemark2': optionalRemark2,
|
||||
'optionalImage3': optionalImage3?.path,
|
||||
'optionalRemark3': optionalRemark3,
|
||||
'optionalImage4': optionalImage4?.path,
|
||||
'optionalRemark4': optionalRemark4,
|
||||
'sondeId': sondeId,
|
||||
'dataCaptureDate': dataCaptureDate,
|
||||
'dataCaptureTime': dataCaptureTime,
|
||||
'oxygenConcentration': oxygenConcentration,
|
||||
'oxygenSaturation': oxygenSaturation,
|
||||
'ph': ph,
|
||||
'salinity': salinity,
|
||||
'electricalConductivity': electricalConductivity,
|
||||
'temperature': temperature,
|
||||
'tds': tds,
|
||||
'turbidity': turbidity,
|
||||
'tss': tss,
|
||||
'batteryVoltage': batteryVoltage,
|
||||
'flowrateMethod': flowrateMethod,
|
||||
'flowrateSurfaceDrifterHeight': flowrateSurfaceDrifterHeight,
|
||||
'flowrateSurfaceDrifterDistance': flowrateSurfaceDrifterDistance,
|
||||
'flowrateSurfaceDrifterTimeFirst': flowrateSurfaceDrifterTimeFirst,
|
||||
'flowrateSurfaceDrifterTimeLast': flowrateSurfaceDrifterTimeLast,
|
||||
'flowrateValue': flowrateValue,
|
||||
'submissionStatus': submissionStatus,
|
||||
'submissionMessage': submissionMessage,
|
||||
'reportId': reportId,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
/// Creates a single JSON object with all submission data, mimicking 'db.json'
|
||||
String toDbJson() {
|
||||
|
||||
@ -1,16 +1,17 @@
|
||||
// lib/screens/air/manual/data_status_log.dart
|
||||
|
||||
import 'dart:io';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:environment_monitoring_app/auth_provider.dart';
|
||||
import 'package:environment_monitoring_app/models/air_installation_data.dart';
|
||||
import 'package:environment_monitoring_app/models/air_collection_data.dart';
|
||||
import 'package:environment_monitoring_app/services/local_storage_service.dart';
|
||||
import 'package:environment_monitoring_app/services/api_service.dart';
|
||||
import 'package:environment_monitoring_app/services/air_sampling_service.dart';
|
||||
import 'dart:convert';
|
||||
|
||||
import '../../../../auth_provider.dart';
|
||||
import '../../../../models/air_installation_data.dart';
|
||||
import '../../../../models/air_collection_data.dart';
|
||||
import '../../../../services/local_storage_service.dart';
|
||||
// --- MODIFIED: Import AirSamplingService to handle resubmissions correctly ---
|
||||
import '../../../../services/air_sampling_service.dart';
|
||||
|
||||
// --- MODIFIED: Added serverName to the log entry model ---
|
||||
class SubmissionLogEntry {
|
||||
final String type;
|
||||
final String title;
|
||||
@ -20,7 +21,9 @@ class SubmissionLogEntry {
|
||||
final String status;
|
||||
final String message;
|
||||
final Map<String, dynamic> rawData;
|
||||
final String serverName; // ADDED
|
||||
final String serverName;
|
||||
final String? apiStatusRaw;
|
||||
final String? ftpStatusRaw;
|
||||
bool isResubmitting;
|
||||
|
||||
SubmissionLogEntry({
|
||||
@ -32,7 +35,9 @@ class SubmissionLogEntry {
|
||||
required this.status,
|
||||
required this.message,
|
||||
required this.rawData,
|
||||
required this.serverName, // ADDED
|
||||
required this.serverName,
|
||||
this.apiStatusRaw,
|
||||
this.ftpStatusRaw,
|
||||
this.isResubmitting = false,
|
||||
});
|
||||
}
|
||||
@ -46,14 +51,17 @@ class AirManualDataStatusLog extends StatefulWidget {
|
||||
|
||||
class _AirManualDataStatusLogState extends State<AirManualDataStatusLog> {
|
||||
final LocalStorageService _localStorageService = LocalStorageService();
|
||||
// --- MODIFIED: Use AirSamplingService for resubmission logic ---
|
||||
final AirSamplingService _airSamplingService = AirSamplingService();
|
||||
late ApiService _apiService;
|
||||
late AirSamplingService _airSamplingService;
|
||||
|
||||
// --- MODIFIED: Simplified state management to a single source of truth ---
|
||||
List<SubmissionLogEntry> _allLogs = [];
|
||||
List<SubmissionLogEntry> _filteredLogs = [];
|
||||
List<SubmissionLogEntry> _installationLogs = [];
|
||||
List<SubmissionLogEntry> _collectionLogs = [];
|
||||
List<SubmissionLogEntry> _filteredInstallationLogs = [];
|
||||
List<SubmissionLogEntry> _filteredCollectionLogs = [];
|
||||
|
||||
final _searchController = TextEditingController();
|
||||
final TextEditingController _installationSearchController = TextEditingController();
|
||||
final TextEditingController _collectionSearchController = TextEditingController();
|
||||
|
||||
bool _isLoading = true;
|
||||
final Map<String, bool> _isResubmitting = {};
|
||||
@ -61,185 +69,152 @@ class _AirManualDataStatusLogState extends State<AirManualDataStatusLog> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_searchController.addListener(_filterLogs);
|
||||
_apiService = Provider.of<ApiService>(context, listen: false);
|
||||
_airSamplingService = Provider.of<AirSamplingService>(context, listen: false);
|
||||
_installationSearchController.addListener(_filterLogs);
|
||||
_collectionSearchController.addListener(_filterLogs);
|
||||
_loadAllLogs();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_searchController.dispose();
|
||||
_installationSearchController.dispose();
|
||||
_collectionSearchController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _loadAllLogs() async {
|
||||
setState(() => _isLoading = true);
|
||||
|
||||
final airLogs = await _localStorageService.getAllAirSamplingLogs();
|
||||
final List<SubmissionLogEntry> tempInstallationLogs = [];
|
||||
final List<SubmissionLogEntry> tempCollectionLogs = [];
|
||||
|
||||
final List<SubmissionLogEntry> tempLogs = [];
|
||||
if (airLogs != null) {
|
||||
for (var log in airLogs) {
|
||||
if (log.containsKey('collectionData') && log['collectionData'] != null) {
|
||||
// This is a collection log
|
||||
final collectionData = log['collectionData'];
|
||||
final String dateStr = collectionData['air_man_collection_date'] ?? '';
|
||||
final String timeStr = collectionData['air_man_collection_time'] ?? '';
|
||||
|
||||
for (var log in airLogs) {
|
||||
try {
|
||||
final hasCollectionData = log['collectionData'] != null && (log['collectionData'] as Map).isNotEmpty;
|
||||
tempCollectionLogs.add(SubmissionLogEntry(
|
||||
type: 'Collection',
|
||||
title: log['locationName'] ?? 'Unknown Location',
|
||||
stationCode: log['stationID'] ?? 'N/A',
|
||||
submissionDateTime: DateTime.tryParse('$dateStr $timeStr') ?? DateTime.now(),
|
||||
reportId: collectionData['air_man_id']?.toString(),
|
||||
status: collectionData['status'] ?? 'L3',
|
||||
message: collectionData['submissionMessage'] ?? 'No status message.',
|
||||
rawData: log,
|
||||
serverName: log['serverConfigName'] ?? 'Unknown Server',
|
||||
apiStatusRaw: collectionData['api_status'],
|
||||
ftpStatusRaw: collectionData['ftp_status'],
|
||||
));
|
||||
} else {
|
||||
// This is an installation log
|
||||
final String dateStr = log['installationDate'] ?? '';
|
||||
final String timeStr = log['installationTime'] ?? '';
|
||||
|
||||
// Determine if it's an Installation or Collection log
|
||||
final logType = hasCollectionData ? 'Collection' : 'Installation';
|
||||
|
||||
final stationInfo = log['stationInfo'] ?? {};
|
||||
final stationName = stationInfo['station_name'] ?? 'Station ${log['stationID'] ?? 'Unknown'}';
|
||||
final stationCode = stationInfo['station_code'] ?? log['stationID'] ?? 'N/A';
|
||||
|
||||
final submissionDateTime = logType == 'Installation'
|
||||
? _parseInstallationDateTime(log)
|
||||
: _parseCollectionDateTime(log['collectionData']);
|
||||
|
||||
final entry = SubmissionLogEntry(
|
||||
type: logType,
|
||||
title: stationName,
|
||||
stationCode: stationCode,
|
||||
submissionDateTime: submissionDateTime,
|
||||
reportId: log['airManId']?.toString(),
|
||||
status: log['status'] ?? 'L1',
|
||||
message: _getStatusMessage(log),
|
||||
rawData: log,
|
||||
// --- MODIFIED: Extract the server name from the log data ---
|
||||
serverName: log['serverConfigName'] ?? 'Unknown Server',
|
||||
);
|
||||
|
||||
tempLogs.add(entry);
|
||||
|
||||
} catch (e) {
|
||||
debugPrint('Error processing log entry: $e');
|
||||
tempInstallationLogs.add(SubmissionLogEntry(
|
||||
type: 'Installation',
|
||||
title: log['locationName'] ?? 'Unknown Location',
|
||||
stationCode: log['stationID'] ?? 'N/A',
|
||||
submissionDateTime: DateTime.tryParse('$dateStr $timeStr') ?? DateTime.now(),
|
||||
reportId: log['air_man_id']?.toString(),
|
||||
status: log['status'] ?? 'L1',
|
||||
message: log['submissionMessage'] ?? 'No status message.',
|
||||
rawData: log,
|
||||
serverName: log['serverConfigName'] ?? 'Unknown Server',
|
||||
apiStatusRaw: log['api_status'],
|
||||
ftpStatusRaw: log['ftp_status'],
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tempLogs.sort((a, b) => b.submissionDateTime.compareTo(a.submissionDateTime));
|
||||
tempInstallationLogs.sort((a, b) => b.submissionDateTime.compareTo(a.submissionDateTime));
|
||||
tempCollectionLogs.sort((a, b) => b.submissionDateTime.compareTo(a.submissionDateTime));
|
||||
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_allLogs = tempLogs;
|
||||
_installationLogs = tempInstallationLogs;
|
||||
_collectionLogs = tempCollectionLogs;
|
||||
_isLoading = false;
|
||||
});
|
||||
_filterLogs();
|
||||
}
|
||||
}
|
||||
|
||||
DateTime _parseInstallationDateTime(Map<String, dynamic> log) {
|
||||
try {
|
||||
if (log['installationDate'] != null) {
|
||||
final date = log['installationDate'];
|
||||
final time = log['installationTime'] ?? '00:00';
|
||||
return DateFormat('yyyy-MM-dd HH:mm').parse('$date $time');
|
||||
}
|
||||
return DateTime.now();
|
||||
} catch (e) {
|
||||
debugPrint('Error parsing installation date: $e');
|
||||
return DateTime.now();
|
||||
}
|
||||
}
|
||||
|
||||
DateTime _parseCollectionDateTime(Map<String, dynamic>? collectionData) {
|
||||
try {
|
||||
if (collectionData == null) return DateTime.now();
|
||||
final dateKey = 'collectionDate'; // Corrected key based on AirCollectionData model
|
||||
final timeKey = 'collectionTime'; // Corrected key
|
||||
|
||||
if (collectionData[dateKey] != null) {
|
||||
final date = collectionData[dateKey];
|
||||
final time = collectionData[timeKey] ?? '00:00';
|
||||
return DateFormat('yyyy-MM-dd HH:mm').parse('$date $time');
|
||||
}
|
||||
return DateTime.now();
|
||||
} catch (e) {
|
||||
debugPrint('Error parsing collection date: $e');
|
||||
return DateTime.now();
|
||||
}
|
||||
}
|
||||
|
||||
String _getStatusMessage(Map<String, dynamic> log) {
|
||||
switch (log['status']) {
|
||||
case 'S1':
|
||||
case 'S2':
|
||||
case 'S3':
|
||||
return 'Successfully submitted to server';
|
||||
case 'L1':
|
||||
case 'L3':
|
||||
return 'Saved locally (pending submission)';
|
||||
case 'L2_PENDING_IMAGES':
|
||||
case 'L4_PENDING_IMAGES':
|
||||
return 'Partial submission (images failed)';
|
||||
default:
|
||||
return 'Submission status unknown';
|
||||
}
|
||||
}
|
||||
|
||||
void _filterLogs() {
|
||||
final query = _searchController.text.toLowerCase();
|
||||
final installationQuery = _installationSearchController.text.toLowerCase();
|
||||
final collectionQuery = _collectionSearchController.text.toLowerCase();
|
||||
|
||||
setState(() {
|
||||
_filteredLogs = _allLogs.where((log) {
|
||||
if (query.isEmpty) return true;
|
||||
// --- MODIFIED: Add serverName to search criteria ---
|
||||
return log.title.toLowerCase().contains(query) ||
|
||||
log.stationCode.toLowerCase().contains(query) ||
|
||||
log.serverName.toLowerCase().contains(query) ||
|
||||
(log.reportId?.toLowerCase() ?? '').contains(query);
|
||||
}).toList();
|
||||
_filteredInstallationLogs = _installationLogs.where((log) => _logMatchesQuery(log, installationQuery)).toList();
|
||||
_filteredCollectionLogs = _collectionLogs.where((log) => _logMatchesQuery(log, collectionQuery)).toList();
|
||||
});
|
||||
}
|
||||
|
||||
// --- MODIFIED: Complete overhaul of the resubmission logic ---
|
||||
bool _logMatchesQuery(SubmissionLogEntry log, String query) {
|
||||
if (query.isEmpty) return true;
|
||||
return log.title.toLowerCase().contains(query) ||
|
||||
log.stationCode.toLowerCase().contains(query) ||
|
||||
log.serverName.toLowerCase().contains(query) ||
|
||||
(log.reportId?.toLowerCase() ?? '').contains(query);
|
||||
}
|
||||
|
||||
Future<void> _resubmitData(SubmissionLogEntry log) async {
|
||||
final logKey = log.rawData['refID']?.toString() ?? log.submissionDateTime.toIso8601String();
|
||||
if (mounted) setState(() => _isResubmitting[logKey] = true);
|
||||
final logKey = log.reportId ?? log.submissionDateTime.toIso8601String();
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isResubmitting[logKey] = true;
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
final authProvider = Provider.of<AuthProvider>(context, listen: false);
|
||||
final appSettings = authProvider.appSettings;
|
||||
Map<String, dynamic> result;
|
||||
|
||||
// Re-create the data models from the raw log data
|
||||
final installationData = AirInstallationData.fromJson(log.rawData);
|
||||
final logData = log.rawData;
|
||||
Map<String, dynamic> result = {};
|
||||
|
||||
if (log.type == 'Installation') {
|
||||
result = await _airSamplingService.submitInstallation(installationData, appSettings);
|
||||
} else {
|
||||
final collectionData = AirCollectionData.fromMap(log.rawData['collectionData']);
|
||||
result = await _airSamplingService.submitCollection(collectionData, installationData, appSettings);
|
||||
final dataToResubmit = AirInstallationData.fromJson(logData);
|
||||
final result = await _airSamplingService.submitInstallation(dataToResubmit, appSettings);
|
||||
// We only care about the high-level status here as granular status is handled by the service.
|
||||
} else if (log.type == 'Collection') {
|
||||
final installationData = AirInstallationData.fromJson(logData);
|
||||
final collectionData = AirCollectionData.fromMap(logData['collectionData']);
|
||||
final result = await _airSamplingService.submitCollection(collectionData, installationData, appSettings);
|
||||
}
|
||||
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(result['message'] ?? 'Resubmission complete.'),
|
||||
backgroundColor: (result['status'] as String).startsWith('S') ? Colors.green : Colors.red,
|
||||
),
|
||||
const SnackBar(content: Text('Resubmission successful!')),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Resubmission failed: $e'), backgroundColor: Colors.red),
|
||||
SnackBar(content: Text('Resubmission failed: $e')),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() => _isResubmitting.remove(logKey));
|
||||
await _loadAllLogs(); // Refresh the log list to show the updated status
|
||||
setState(() {
|
||||
_isResubmitting.remove(logKey);
|
||||
});
|
||||
_loadAllLogs();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// --- MODIFIED: Logic simplified to work with a single, comprehensive list ---
|
||||
final logCategories = {
|
||||
'Installation': _filteredLogs.where((log) => log.type == 'Installation').toList(),
|
||||
'Collection': _filteredLogs.where((log) => log.type == 'Collection').toList(),
|
||||
};
|
||||
final hasAnyLogs = _allLogs.isNotEmpty;
|
||||
final hasFilteredLogs = _filteredLogs.isNotEmpty;
|
||||
final hasAnyLogs = _installationLogs.isNotEmpty || _collectionLogs.isNotEmpty;
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Air Sampling Status Log')),
|
||||
appBar: AppBar(title: const Text('Air Manual Data Status Log')),
|
||||
body: _isLoading
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: RefreshIndicator(
|
||||
@ -249,56 +224,56 @@ class _AirManualDataStatusLogState extends State<AirManualDataStatusLog> {
|
||||
: ListView(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
children: [
|
||||
// General search bar for all logs
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8.0),
|
||||
child: TextField(
|
||||
controller: _searchController,
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Search by station, server, or ID...',
|
||||
prefixIcon: const Icon(Icons.search, size: 20),
|
||||
isDense: true,
|
||||
border: const OutlineInputBorder(),
|
||||
),
|
||||
),
|
||||
),
|
||||
...logCategories.entries
|
||||
.where((entry) => entry.value.isNotEmpty)
|
||||
.map((entry) => _buildCategorySection(entry.key, entry.value)),
|
||||
if (!hasFilteredLogs && hasAnyLogs)
|
||||
const Center(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(24.0),
|
||||
child: Text('No logs match your search.'),
|
||||
),
|
||||
)
|
||||
_buildCategorySection('Installation', _filteredInstallationLogs, _installationSearchController),
|
||||
_buildCategorySection('Collection', _filteredCollectionLogs, _collectionSearchController),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCategorySection(String category, List<SubmissionLogEntry> logs) {
|
||||
Widget _buildCategorySection(String category, List<SubmissionLogEntry> logs, TextEditingController searchController) {
|
||||
return Card(
|
||||
margin: const EdgeInsets.symmetric(vertical: 12.0, horizontal: 8.0),
|
||||
margin: const EdgeInsets.symmetric(vertical: 8.0),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(category, style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold)),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8.0),
|
||||
child: Text(category, style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold)),
|
||||
padding: const EdgeInsets.symmetric(vertical: 8.0),
|
||||
child: TextField(
|
||||
controller: searchController,
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Search in $category...',
|
||||
prefixIcon: const Icon(Icons.search, size: 20),
|
||||
isDense: true,
|
||||
border: const OutlineInputBorder(),
|
||||
suffixIcon: IconButton(
|
||||
icon: const Icon(Icons.clear),
|
||||
onPressed: () {
|
||||
searchController.clear();
|
||||
_filterLogs();
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const Divider(),
|
||||
ListView.builder(
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
shrinkWrap: true,
|
||||
itemCount: logs.length,
|
||||
itemBuilder: (context, index) {
|
||||
return _buildLogListItem(logs[index]);
|
||||
},
|
||||
),
|
||||
if (logs.isEmpty)
|
||||
const Padding(
|
||||
padding: EdgeInsets.all(16.0),
|
||||
child: Center(child: Text('No logs match your search in this category.')))
|
||||
else
|
||||
ListView.builder(
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
itemCount: logs.length,
|
||||
itemBuilder: (context, index) {
|
||||
return _buildLogListItem(logs[index]);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
@ -306,28 +281,36 @@ class _AirManualDataStatusLogState extends State<AirManualDataStatusLog> {
|
||||
}
|
||||
|
||||
Widget _buildLogListItem(SubmissionLogEntry log) {
|
||||
final isSuccess = log.status.startsWith('S');
|
||||
final logKey = log.rawData['refID']?.toString() ?? log.submissionDateTime.toIso8601String();
|
||||
final isFailed = !log.status.startsWith('S') && !log.status.startsWith('L4');
|
||||
final logKey = log.reportId ?? log.submissionDateTime.toIso8601String();
|
||||
final isResubmitting = _isResubmitting[logKey] ?? false;
|
||||
final title = '${log.title} (${log.stationCode})';
|
||||
// --- MODIFIED: Include the server name in the subtitle for clarity ---
|
||||
|
||||
final titleWidget = RichText(
|
||||
text: TextSpan(
|
||||
style: Theme.of(context).textTheme.bodyLarge?.copyWith(fontWeight: FontWeight.w500),
|
||||
children: <TextSpan>[
|
||||
TextSpan(text: '${log.title} '),
|
||||
TextSpan(
|
||||
text: '(${log.stationCode})',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(fontWeight: FontWeight.normal),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
final subtitle = '${log.serverName} - ${DateFormat('yyyy-MM-dd HH:mm').format(log.submissionDateTime)}';
|
||||
|
||||
return ExpansionTile(
|
||||
key: PageStorageKey(logKey),
|
||||
leading: Icon(
|
||||
isSuccess ? Icons.check_circle_outline : Icons.error_outline,
|
||||
color: isSuccess ? Colors.green : Colors.red,
|
||||
isFailed ? Icons.error_outline : Icons.check_circle_outline,
|
||||
color: isFailed ? Colors.red : Colors.green,
|
||||
),
|
||||
title: Text(title, style: const TextStyle(fontWeight: FontWeight.w500)),
|
||||
title: titleWidget,
|
||||
subtitle: Text(subtitle),
|
||||
trailing: !isSuccess
|
||||
trailing: isFailed
|
||||
? (isResubmitting
|
||||
? const SizedBox(height: 24, width: 24, child: CircularProgressIndicator(strokeWidth: 3))
|
||||
: IconButton(
|
||||
icon: const Icon(Icons.sync, color: Colors.blue),
|
||||
tooltip: 'Resubmit',
|
||||
onPressed: () => _resubmitData(log),
|
||||
))
|
||||
: IconButton(icon: const Icon(Icons.sync, color: Colors.blue), tooltip: 'Resubmit', onPressed: () => _resubmitData(log)))
|
||||
: null,
|
||||
children: [
|
||||
Padding(
|
||||
@ -335,11 +318,13 @@ class _AirManualDataStatusLogState extends State<AirManualDataStatusLog> {
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// --- MODIFIED: Add server name to the details view ---
|
||||
_buildDetailRow('High-Level Status:', log.status),
|
||||
_buildDetailRow('Server:', log.serverName),
|
||||
_buildDetailRow('Report ID:', log.reportId ?? 'N/A'),
|
||||
_buildDetailRow('Status:', log.message),
|
||||
_buildDetailRow('Submission Type:', log.type),
|
||||
const Divider(height: 10),
|
||||
_buildGranularStatus('API', log.apiStatusRaw),
|
||||
_buildGranularStatus('FTP', log.ftpStatusRaw),
|
||||
],
|
||||
),
|
||||
)
|
||||
@ -347,14 +332,61 @@ class _AirManualDataStatusLogState extends State<AirManualDataStatusLog> {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildGranularStatus(String type, String? jsonStatus) {
|
||||
if (jsonStatus == null || jsonStatus.isEmpty) {
|
||||
return Container();
|
||||
}
|
||||
|
||||
List<dynamic> statuses;
|
||||
try {
|
||||
statuses = jsonDecode(jsonStatus);
|
||||
} catch (_) {
|
||||
return _buildDetailRow('$type Status:', jsonStatus!);
|
||||
}
|
||||
|
||||
if (statuses.isEmpty) {
|
||||
return Container();
|
||||
}
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(top: 8.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('$type Status:', style: const TextStyle(fontWeight: FontWeight.bold)),
|
||||
...statuses.map((s) {
|
||||
final serverName = s['server_name'] ?? 'Server N/A';
|
||||
final status = s['status'] ?? 'N/A';
|
||||
final bool isSuccess = status.toLowerCase().contains('success') || status.toLowerCase().contains('queued') || status.toLowerCase().contains('not_configured') || status.toLowerCase().contains('not_applicable') || status.toLowerCase().contains('not_required');
|
||||
final IconData icon = isSuccess ? Icons.check_circle_outline : (status.toLowerCase().contains('failed') ? Icons.error_outline : Icons.sync);
|
||||
final Color color = isSuccess ? Colors.green : (status.toLowerCase().contains('failed') ? Colors.red : Colors.grey);
|
||||
String detailLabel = (s['type'] != null) ? '(${s['type']})' : '';
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 3.0, horizontal: 8.0),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(icon, size: 16, color: color),
|
||||
const SizedBox(width: 5),
|
||||
Expanded(child: Text('$serverName $detailLabel: $status')),
|
||||
],
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDetailRow(String label, String value) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 2.0),
|
||||
padding: const EdgeInsets.symmetric(vertical: 4.0),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('$label ', style: const TextStyle(fontWeight: FontWeight.bold)),
|
||||
Expanded(child: Text(value)),
|
||||
Expanded(flex: 2, child: Text(label, style: const TextStyle(fontWeight: FontWeight.bold))),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(flex: 3, child: Text(value)),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
@ -9,6 +9,10 @@ import 'package:intl/intl.dart';
|
||||
import '../../../../auth_provider.dart';
|
||||
import '../../../../models/air_installation_data.dart';
|
||||
import '../../../../services/air_sampling_service.dart';
|
||||
// --- ADDED: Import ApiService for dependency injection ---
|
||||
import '../../../../services/api_service.dart';
|
||||
// --- REMOVED: Import of local_storage_service.dart as it is no longer used for logging ---
|
||||
import '../../../../services/telegram_service.dart'; // Import TelegramService
|
||||
|
||||
class AirManualInstallationWidget extends StatefulWidget {
|
||||
final AirInstallationData data;
|
||||
@ -211,6 +215,7 @@ class _AirManualInstallationWidgetState extends State<AirManualInstallationWidge
|
||||
source,
|
||||
stationCode: stationCode,
|
||||
imageInfo: imageInfo.toUpperCase(),
|
||||
processType: 'COLLECT',
|
||||
isRequired: isRequired,
|
||||
);
|
||||
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../auth_provider.dart';
|
||||
import 'package:environment_monitoring_app/services/api_service.dart'; // Import ApiService for typing
|
||||
|
||||
class ForgotPasswordScreen extends StatefulWidget {
|
||||
@override
|
||||
@ -15,6 +16,8 @@ class _ForgotPasswordScreenState extends State<ForgotPasswordScreen> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final auth = Provider.of<AuthProvider>(context);
|
||||
// FIX: Retrieve ApiService from the Provider tree
|
||||
final apiService = Provider.of<ApiService>(context, listen: false);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: Text("Forgot Password")),
|
||||
@ -36,6 +39,11 @@ class _ForgotPasswordScreenState extends State<ForgotPasswordScreen> {
|
||||
ElevatedButton(
|
||||
onPressed: () {
|
||||
if (_formKey.currentState!.validate()) {
|
||||
// FIX: Use the retrieved ApiService instance for the post call via AuthProvider
|
||||
// Note: AuthProvider's resetPassword method still uses its internal _apiService field.
|
||||
// This is an architectural inconsistency (using Provider for one service but not others inside AuthProvider)
|
||||
// but the immediate fix for this screen is to ensure the resetPassword method works.
|
||||
// Since AuthProvider's resetPassword method uses its internal, injected _apiService, we can call it directly.
|
||||
auth.resetPassword(email);
|
||||
setState(() => message = "Reset link sent to $email");
|
||||
}
|
||||
|
||||
@ -17,7 +17,7 @@ class _LoginScreenState extends State<LoginScreen> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
final TextEditingController _emailController = TextEditingController();
|
||||
final TextEditingController _passwordController = TextEditingController();
|
||||
final ApiService _apiService = ApiService();
|
||||
// FIX: Removed direct instantiation of ApiService
|
||||
bool _isLoading = false;
|
||||
String _errorMessage = '';
|
||||
|
||||
@ -39,6 +39,9 @@ class _LoginScreenState extends State<LoginScreen> {
|
||||
});
|
||||
|
||||
final auth = Provider.of<AuthProvider>(context, listen: false);
|
||||
// FIX: Retrieve ApiService from the Provider tree
|
||||
final apiService = Provider.of<ApiService>(context, listen: false);
|
||||
|
||||
|
||||
// --- Offline Check for First Login ---
|
||||
if (auth.isFirstLogin) {
|
||||
@ -55,7 +58,7 @@ class _LoginScreenState extends State<LoginScreen> {
|
||||
}
|
||||
|
||||
// --- API Call ---
|
||||
final Map<String, dynamic> result = await _apiService.login(
|
||||
final Map<String, dynamic> result = await apiService.login( // FIX: Use retrieved instance
|
||||
_emailController.text.trim(),
|
||||
_passwordController.text.trim(),
|
||||
);
|
||||
|
||||
@ -1,24 +1,30 @@
|
||||
// lib/screens/marine/manual/data_status_log.dart
|
||||
|
||||
import 'dart:io';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:provider/provider.dart'; // Added for accessing AuthProvider
|
||||
import 'package:environment_monitoring_app/auth_provider.dart'; // Added for AuthProvider type
|
||||
import 'package:provider/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/in_situ_sampling_data.dart';
|
||||
import 'package:environment_monitoring_app/services/local_storage_service.dart';
|
||||
import 'package:environment_monitoring_app/services/marine_api_service.dart';
|
||||
import 'package:environment_monitoring_app/services/api_service.dart';
|
||||
import 'package:environment_monitoring_app/services/in_situ_sampling_service.dart';
|
||||
import 'dart:convert';
|
||||
|
||||
// --- MODIFIED: Added serverName to the log entry model ---
|
||||
/// A unified model for a submission log entry, specific to the Marine module.
|
||||
class SubmissionLogEntry {
|
||||
final String type; // e.g., 'Manual Sampling', 'Tarball Sampling'
|
||||
final String title;
|
||||
final String stationCode;
|
||||
final DateTime submissionDateTime;
|
||||
final String? reportId;
|
||||
final String status;
|
||||
final String status; // High-level status (S3, L1, etc.)
|
||||
final String message;
|
||||
final Map<String, dynamic> rawData;
|
||||
final String serverName; // ADDED
|
||||
final String serverName;
|
||||
final String? apiStatusRaw;
|
||||
final String? ftpStatusRaw;
|
||||
bool isResubmitting;
|
||||
|
||||
SubmissionLogEntry({
|
||||
@ -30,7 +36,9 @@ class SubmissionLogEntry {
|
||||
required this.status,
|
||||
required this.message,
|
||||
required this.rawData,
|
||||
required this.serverName, // ADDED
|
||||
required this.serverName,
|
||||
this.apiStatusRaw,
|
||||
this.ftpStatusRaw,
|
||||
this.isResubmitting = false,
|
||||
});
|
||||
}
|
||||
@ -44,18 +52,17 @@ class MarineManualDataStatusLog extends StatefulWidget {
|
||||
|
||||
class _MarineManualDataStatusLogState extends State<MarineManualDataStatusLog> {
|
||||
final LocalStorageService _localStorageService = LocalStorageService();
|
||||
final MarineApiService _marineApiService = MarineApiService();
|
||||
late ApiService _apiService;
|
||||
late InSituSamplingService _marineInSituService;
|
||||
|
||||
// Raw data lists
|
||||
List<SubmissionLogEntry> _allLogs = [];
|
||||
List<SubmissionLogEntry> _manualLogs = [];
|
||||
List<SubmissionLogEntry> _tarballLogs = [];
|
||||
|
||||
// Filtered lists for the UI
|
||||
List<SubmissionLogEntry> _filteredManualLogs = [];
|
||||
List<SubmissionLogEntry> _filteredTarballLogs = [];
|
||||
|
||||
// Per-category search controllers
|
||||
final Map<String, TextEditingController> _searchControllers = {};
|
||||
final TextEditingController _manualSearchController = TextEditingController();
|
||||
final TextEditingController _tarballSearchController = TextEditingController();
|
||||
|
||||
bool _isLoading = true;
|
||||
final Map<String, bool> _isResubmitting = {};
|
||||
@ -63,15 +70,17 @@ class _MarineManualDataStatusLogState extends State<MarineManualDataStatusLog> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_searchControllers['Manual Sampling'] = TextEditingController()..addListener(_filterLogs);
|
||||
_searchControllers['Tarball Sampling'] = TextEditingController()..addListener(_filterLogs);
|
||||
_apiService = Provider.of<ApiService>(context, listen: false);
|
||||
_marineInSituService = Provider.of<InSituSamplingService>(context, listen: false);
|
||||
_manualSearchController.addListener(_filterLogs);
|
||||
_tarballSearchController.addListener(_filterLogs);
|
||||
_loadAllLogs();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_searchControllers['Manual Sampling']?.dispose();
|
||||
_searchControllers['Tarball Sampling']?.dispose();
|
||||
_manualSearchController.dispose();
|
||||
_tarballSearchController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@ -84,7 +93,6 @@ class _MarineManualDataStatusLogState extends State<MarineManualDataStatusLog> {
|
||||
final List<SubmissionLogEntry> tempManual = [];
|
||||
final List<SubmissionLogEntry> tempTarball = [];
|
||||
|
||||
// Map In-Situ logs to Manual Sampling
|
||||
for (var log in inSituLogs) {
|
||||
final String dateStr = log['data_capture_date'] ?? log['sampling_date'] ?? '';
|
||||
final String timeStr = log['data_capture_time'] ?? log['sampling_time'] ?? '';
|
||||
@ -98,12 +106,12 @@ class _MarineManualDataStatusLogState extends State<MarineManualDataStatusLog> {
|
||||
status: log['submissionStatus'] ?? 'L1',
|
||||
message: log['submissionMessage'] ?? 'No status message.',
|
||||
rawData: log,
|
||||
// --- MODIFIED: Extract the server name from the log data ---
|
||||
serverName: log['serverConfigName'] ?? 'Unknown Server',
|
||||
apiStatusRaw: log['api_status'],
|
||||
ftpStatusRaw: log['ftp_status'],
|
||||
));
|
||||
}
|
||||
|
||||
// Map Tarball logs
|
||||
for (var log in tarballLogs) {
|
||||
tempTarball.add(SubmissionLogEntry(
|
||||
type: 'Tarball Sampling',
|
||||
@ -114,8 +122,9 @@ class _MarineManualDataStatusLogState extends State<MarineManualDataStatusLog> {
|
||||
status: log['submissionStatus'] ?? 'L1',
|
||||
message: log['submissionMessage'] ?? 'No status message.',
|
||||
rawData: log,
|
||||
// --- MODIFIED: Extract the server name from the log data ---
|
||||
serverName: log['serverConfigName'] ?? 'Unknown Server',
|
||||
apiStatusRaw: log['api_status'],
|
||||
ftpStatusRaw: log['ftp_status'],
|
||||
));
|
||||
}
|
||||
|
||||
@ -128,13 +137,13 @@ class _MarineManualDataStatusLogState extends State<MarineManualDataStatusLog> {
|
||||
_tarballLogs = tempTarball;
|
||||
_isLoading = false;
|
||||
});
|
||||
_filterLogs(); // Perform initial filter
|
||||
_filterLogs();
|
||||
}
|
||||
}
|
||||
|
||||
void _filterLogs() {
|
||||
final manualQuery = _searchControllers['Manual Sampling']?.text.toLowerCase() ?? '';
|
||||
final tarballQuery = _searchControllers['Tarball Sampling']?.text.toLowerCase() ?? '';
|
||||
final manualQuery = _manualSearchController.text.toLowerCase();
|
||||
final tarballQuery = _tarballSearchController.text.toLowerCase();
|
||||
|
||||
setState(() {
|
||||
_filteredManualLogs = _manualLogs.where((log) => _logMatchesQuery(log, manualQuery)).toList();
|
||||
@ -144,14 +153,12 @@ class _MarineManualDataStatusLogState extends State<MarineManualDataStatusLog> {
|
||||
|
||||
bool _logMatchesQuery(SubmissionLogEntry log, String query) {
|
||||
if (query.isEmpty) return true;
|
||||
// --- MODIFIED: Add serverName to search criteria ---
|
||||
return log.title.toLowerCase().contains(query) ||
|
||||
log.stationCode.toLowerCase().contains(query) ||
|
||||
log.serverName.toLowerCase().contains(query) ||
|
||||
(log.reportId?.toLowerCase() ?? '').contains(query);
|
||||
}
|
||||
|
||||
// MODIFIED: This method now fetches appSettings from AuthProvider before resubmitting.
|
||||
Future<void> _resubmitData(SubmissionLogEntry log) async {
|
||||
final logKey = log.reportId ?? log.submissionDateTime.toIso8601String();
|
||||
if (mounted) {
|
||||
@ -161,23 +168,84 @@ class _MarineManualDataStatusLogState extends State<MarineManualDataStatusLog> {
|
||||
}
|
||||
|
||||
try {
|
||||
// Get the appSettings from the AuthProvider to pass to the API service.
|
||||
final authProvider = Provider.of<AuthProvider>(context, listen: false);
|
||||
final appSettings = authProvider.appSettings;
|
||||
|
||||
final result = await _performResubmission(log, appSettings);
|
||||
final logData = log.rawData;
|
||||
|
||||
logData['submissionStatus'] = result['status'];
|
||||
logData['submissionMessage'] = result['message'];
|
||||
logData['reportId'] = result['reportId']?.toString() ?? logData['reportId'];
|
||||
Map<String, dynamic> result = {};
|
||||
|
||||
if (log.type == 'Manual Sampling') {
|
||||
await _localStorageService.updateInSituLog(logData);
|
||||
final dataToResubmit = InSituSamplingData.fromJson(logData);
|
||||
final Map<String, File?> imageFiles = {};
|
||||
dataToResubmit.toApiImageFiles().keys.forEach((key) {
|
||||
final imagePath = logData[key];
|
||||
if (imagePath is String && imagePath.isNotEmpty) {
|
||||
imageFiles[key] = File(imagePath);
|
||||
}
|
||||
});
|
||||
result = await _apiService.marine.submitInSituSample(
|
||||
formData: dataToResubmit.toApiFormData(),
|
||||
imageFiles: imageFiles,
|
||||
inSituData: dataToResubmit,
|
||||
appSettings: appSettings,
|
||||
);
|
||||
} else if (log.type == 'Tarball Sampling') {
|
||||
await _localStorageService.updateTarballLog(logData);
|
||||
// FIX: Manually map the raw data to a new TarballSamplingData instance
|
||||
final int? firstSamplerId = int.tryParse(logData['first_sampler_user_id']?.toString() ?? '');
|
||||
final int? classificationId = int.tryParse(logData['classification_id']?.toString() ?? '');
|
||||
|
||||
final dataToResubmit = TarballSamplingData()
|
||||
..firstSamplerUserId = firstSamplerId
|
||||
..secondSampler = logData['secondSampler']
|
||||
..samplingDate = logData['samplingDate']
|
||||
..samplingTime = logData['samplingTime']
|
||||
..selectedStateName = logData['selectedStateName']
|
||||
..selectedCategoryName = logData['selectedCategoryName']
|
||||
..selectedStation = logData['selectedStation']
|
||||
..stationLatitude = logData['stationLatitude']
|
||||
..stationLongitude = logData['stationLongitude']
|
||||
..currentLatitude = logData['currentLatitude']
|
||||
..currentLongitude = logData['currentLongitude']
|
||||
..distanceDifference = double.tryParse(logData['distanceDifference']?.toString() ?? '0.0')
|
||||
..distanceDifferenceRemarks = logData['distanceDifferenceRemarks']
|
||||
..classificationId = classificationId
|
||||
..selectedClassification = logData['selectedClassification']
|
||||
..optionalRemark1 = logData['optionalRemark1']
|
||||
..optionalRemark2 = logData['optionalRemark2']
|
||||
..optionalRemark3 = logData['optionalRemark3']
|
||||
..optionalRemark4 = logData['optionalRemark4']
|
||||
..reportId = logData['reportId']?.toString();
|
||||
|
||||
final Map<String, File?> imageFiles = {};
|
||||
dataToResubmit.toImageFiles().keys.forEach((key) {
|
||||
final imagePath = logData[key];
|
||||
if (imagePath is String && imagePath.isNotEmpty) {
|
||||
imageFiles[key] = File(imagePath);
|
||||
}
|
||||
});
|
||||
result = await _apiService.marine.submitTarballSample(
|
||||
formData: dataToResubmit.toFormData(),
|
||||
imageFiles: imageFiles,
|
||||
appSettings: appSettings,
|
||||
);
|
||||
}
|
||||
|
||||
final updatedLogData = log.rawData;
|
||||
updatedLogData['submissionStatus'] = result['status'];
|
||||
updatedLogData['submissionMessage'] = result['message'];
|
||||
updatedLogData['reportId'] = result['reportId']?.toString() ?? updatedLogData['reportId'];
|
||||
updatedLogData['api_status'] = jsonEncode(result['api_status']);
|
||||
updatedLogData['ftp_status'] = jsonEncode(result['ftp_status']);
|
||||
// This line is likely incorrect, assuming you want the name of the successful server
|
||||
// updatedLogData['serverConfigName'] = (await _apiService.dbHelper.loadApiConfigs() ?? []).firstWhere((c) => c['api_config_id'] == 1)['config_name'];
|
||||
|
||||
if (log.type == 'Manual Sampling') {
|
||||
await _localStorageService.updateInSituLog(updatedLogData);
|
||||
} else if (log.type == 'Tarball Sampling') {
|
||||
await _localStorageService.updateTarballLog(updatedLogData);
|
||||
}
|
||||
|
||||
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Resubmission successful!')),
|
||||
@ -194,119 +262,17 @@ class _MarineManualDataStatusLogState extends State<MarineManualDataStatusLog> {
|
||||
setState(() {
|
||||
_isResubmitting.remove(logKey);
|
||||
});
|
||||
await _loadAllLogs();
|
||||
_loadAllLogs();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MODIFIED: This method now requires appSettings to pass to the API service.
|
||||
Future<Map<String, dynamic>> _performResubmission(SubmissionLogEntry log, List<Map<String, dynamic>>? appSettings) async {
|
||||
final logData = log.rawData;
|
||||
|
||||
if (log.type == 'Manual Sampling') {
|
||||
final InSituSamplingData dataToResubmit = InSituSamplingData()
|
||||
..firstSamplerUserId = int.tryParse(logData['first_sampler_user_id']?.toString() ?? '')
|
||||
..secondSampler = logData['secondSampler']
|
||||
..samplingDate = logData['sampling_date']
|
||||
..samplingTime = logData['sampling_time']
|
||||
..samplingType = logData['sampling_type']
|
||||
..sampleIdCode = logData['sample_id_code']
|
||||
..selectedStation = logData['selectedStation']
|
||||
..currentLatitude = logData['current_latitude']?.toString()
|
||||
..currentLongitude = logData['current_longitude']?.toString()
|
||||
..distanceDifferenceInKm = double.tryParse(logData['distance_difference']?.toString() ?? '0.0')
|
||||
..weather = logData['weather']
|
||||
..tideLevel = logData['tide_level']
|
||||
..seaCondition = logData['sea_condition']
|
||||
..eventRemarks = logData['event_remarks']
|
||||
..labRemarks = logData['lab_remarks']
|
||||
..optionalRemark1 = logData['optional_photo_remark_1']
|
||||
..optionalRemark2 = logData['optional_photo_remark_2']
|
||||
..optionalRemark3 = logData['optional_photo_remark_3']
|
||||
..optionalRemark4 = logData['optional_photo_remark_4']
|
||||
..sondeId = logData['sonde_id']
|
||||
..dataCaptureDate = logData['data_capture_date']
|
||||
..dataCaptureTime = logData['data_capture_time']
|
||||
..oxygenConcentration = double.tryParse(logData['oxygen_concentration_mg_l']?.toString() ?? '0.0')
|
||||
..oxygenSaturation = double.tryParse(logData['oxygen_saturation_percent']?.toString() ?? '0.0')
|
||||
..ph = double.tryParse(logData['ph']?.toString() ?? '0.0')
|
||||
..salinity = double.tryParse(logData['salinity_ppt']?.toString() ?? '0.0')
|
||||
..electricalConductivity = double.tryParse(logData['ec_us_cm']?.toString() ?? '0.0')
|
||||
..temperature = double.tryParse(logData['temperature_c']?.toString() ?? '0.0')
|
||||
..tds = double.tryParse(logData['tds_mg_l']?.toString() ?? '0.0')
|
||||
..turbidity = double.tryParse(logData['turbidity_ntu']?.toString() ?? '0.0')
|
||||
..tss = double.tryParse(logData['tss_mg_l']?.toString() ?? '0.0')
|
||||
..batteryVoltage = double.tryParse(logData['battery_v']?.toString() ?? '0.0');
|
||||
|
||||
final Map<String, File?> imageFiles = {};
|
||||
final imageKeys = dataToResubmit.toApiImageFiles().keys;
|
||||
for (var key in imageKeys) {
|
||||
final imagePath = logData[key];
|
||||
if (imagePath is String && imagePath.isNotEmpty) {
|
||||
final file = File(imagePath);
|
||||
if (await file.exists()) {
|
||||
imageFiles[key] = file;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return _marineApiService.submitInSituSample(
|
||||
formData: dataToResubmit.toApiFormData(),
|
||||
imageFiles: imageFiles,
|
||||
inSituData: dataToResubmit,
|
||||
appSettings: appSettings, // Added this required parameter
|
||||
);
|
||||
} else if (log.type == 'Tarball Sampling') {
|
||||
final int? firstSamplerId = int.tryParse(logData['first_sampler_user_id']?.toString() ?? '');
|
||||
final int? classificationId = int.tryParse(logData['classification_id']?.toString() ?? '');
|
||||
|
||||
final TarballSamplingData dataToResubmit = TarballSamplingData()
|
||||
..selectedStation = logData['selectedStation']
|
||||
..samplingDate = logData['sampling_date']
|
||||
..samplingTime = logData['sampling_time']
|
||||
..firstSamplerUserId = firstSamplerId
|
||||
..secondSampler = logData['secondSampler']
|
||||
..classificationId = classificationId
|
||||
..currentLatitude = logData['current_latitude']?.toString()
|
||||
..currentLongitude = logData['current_longitude']?.toString()
|
||||
..distanceDifference = logData['distance_difference']
|
||||
..optionalRemark1 = logData['optional_photo_remark_01']
|
||||
..optionalRemark2 = logData['optional_photo_remark_02']
|
||||
..optionalRemark3 = logData['optional_photo_remark_03']
|
||||
..optionalRemark4 = logData['optional_photo_remark_04'];
|
||||
|
||||
final Map<String, File?> imageFiles = {};
|
||||
final imageKeys = ['left_side_coastal_view', 'right_side_coastal_view', 'drawing_vertical_lines', 'drawing_horizontal_line', 'optional_photo_01', 'optional_photo_02', 'optional_photo_03', 'optional_photo_04'];
|
||||
for (var key in imageKeys) {
|
||||
final imagePath = logData[key];
|
||||
if (imagePath != null && imagePath.isNotEmpty) {
|
||||
final file = File(imagePath);
|
||||
if (await file.exists()) imageFiles[key] = file;
|
||||
}
|
||||
}
|
||||
|
||||
return _marineApiService.submitTarballSample(
|
||||
formData: dataToResubmit.toFormData(),
|
||||
imageFiles: imageFiles,
|
||||
appSettings: appSettings, // Added this required parameter
|
||||
);
|
||||
}
|
||||
|
||||
throw Exception('Unknown submission type: ${log.type}');
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final hasAnyLogs = _manualLogs.isNotEmpty || _tarballLogs.isNotEmpty;
|
||||
final hasFilteredLogs = _filteredManualLogs.isNotEmpty || _filteredTarballLogs.isNotEmpty;
|
||||
|
||||
final logCategories = {
|
||||
'Manual Sampling': _filteredManualLogs,
|
||||
'Tarball Sampling': _filteredTarballLogs,
|
||||
};
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Marine Data Status Log')),
|
||||
appBar: AppBar(title: const Text('Marine Manual Data Status Log')),
|
||||
body: _isLoading
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: RefreshIndicator(
|
||||
@ -316,25 +282,15 @@ class _MarineManualDataStatusLogState extends State<MarineManualDataStatusLog> {
|
||||
: ListView(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
children: [
|
||||
...logCategories.entries
|
||||
.where((entry) => entry.value.isNotEmpty)
|
||||
.map((entry) => _buildCategorySection(entry.key, entry.value)),
|
||||
if (!hasFilteredLogs && hasAnyLogs)
|
||||
const Center(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(24.0),
|
||||
child: Text('No logs match your search.'),
|
||||
),
|
||||
)
|
||||
_buildCategorySection('Manual Sampling', _filteredManualLogs, _manualSearchController),
|
||||
_buildCategorySection('Tarball Sampling', _filteredTarballLogs, _tarballSearchController),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCategorySection(String category, List<SubmissionLogEntry> logs) {
|
||||
final listHeight = (logs.length > 5 ? 5.5 : logs.length.toDouble()) * 75.0;
|
||||
|
||||
Widget _buildCategorySection(String category, List<SubmissionLogEntry> logs, TextEditingController searchController) {
|
||||
return Card(
|
||||
margin: const EdgeInsets.symmetric(vertical: 8.0),
|
||||
child: Padding(
|
||||
@ -346,30 +302,36 @@ class _MarineManualDataStatusLogState extends State<MarineManualDataStatusLog> {
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8.0),
|
||||
child: TextField(
|
||||
controller: _searchControllers[category],
|
||||
controller: searchController,
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Search in $category...',
|
||||
prefixIcon: const Icon(Icons.search, size: 20),
|
||||
isDense: true,
|
||||
border: const OutlineInputBorder(),
|
||||
suffixIcon: IconButton(
|
||||
icon: const Icon(Icons.clear),
|
||||
onPressed: () {
|
||||
searchController.clear();
|
||||
_filterLogs();
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const Divider(),
|
||||
logs.isEmpty
|
||||
? const Padding(
|
||||
padding: EdgeInsets.all(16.0),
|
||||
child: Center(child: Text('No logs match your search in this category.')))
|
||||
: ConstrainedBox(
|
||||
constraints: BoxConstraints(maxHeight: listHeight),
|
||||
child: ListView.builder(
|
||||
if (logs.isEmpty)
|
||||
const Padding(
|
||||
padding: EdgeInsets.all(16.0),
|
||||
child: Center(child: Text('No logs match your search in this category.')))
|
||||
else
|
||||
ListView.builder(
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
itemCount: logs.length,
|
||||
itemBuilder: (context, index) {
|
||||
return _buildLogListItem(logs[index]);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
@ -377,19 +339,31 @@ class _MarineManualDataStatusLogState extends State<MarineManualDataStatusLog> {
|
||||
}
|
||||
|
||||
Widget _buildLogListItem(SubmissionLogEntry log) {
|
||||
final isFailed = log.status != 'L3';
|
||||
final isFailed = !log.status.startsWith('S') && !log.status.startsWith('L4');
|
||||
final logKey = log.reportId ?? log.submissionDateTime.toIso8601String();
|
||||
final isResubmitting = _isResubmitting[logKey] ?? false;
|
||||
final title = '${log.title} (${log.stationCode})';
|
||||
// --- MODIFIED: Include the server name in the subtitle for clarity ---
|
||||
|
||||
final titleWidget = RichText(
|
||||
text: TextSpan(
|
||||
style: Theme.of(context).textTheme.bodyLarge?.copyWith(fontWeight: FontWeight.w500),
|
||||
children: <TextSpan>[
|
||||
TextSpan(text: '${log.title} '),
|
||||
TextSpan(
|
||||
text: '(${log.stationCode})',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(fontWeight: FontWeight.normal),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
final subtitle = '${log.serverName} - ${DateFormat('yyyy-MM-dd HH:mm').format(log.submissionDateTime)}';
|
||||
|
||||
return ExpansionTile(
|
||||
key: PageStorageKey(logKey),
|
||||
leading: Icon(
|
||||
isFailed ? Icons.error_outline : Icons.check_circle_outline,
|
||||
color: isFailed ? Colors.red : Colors.green,
|
||||
),
|
||||
title: Text(title, style: const TextStyle(fontWeight: FontWeight.w500)),
|
||||
title: titleWidget,
|
||||
subtitle: Text(subtitle),
|
||||
trailing: isFailed
|
||||
? (isResubmitting
|
||||
@ -402,11 +376,13 @@ class _MarineManualDataStatusLogState extends State<MarineManualDataStatusLog> {
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// --- MODIFIED: Add server name to the details view ---
|
||||
_buildDetailRow('High-Level Status:', log.status),
|
||||
_buildDetailRow('Server:', log.serverName),
|
||||
_buildDetailRow('Report ID:', log.reportId ?? 'N/A'),
|
||||
_buildDetailRow('Status:', log.message),
|
||||
_buildDetailRow('Submission Type:', log.type),
|
||||
const Divider(height: 10),
|
||||
_buildGranularStatus('API', log.apiStatusRaw),
|
||||
_buildGranularStatus('FTP', log.ftpStatusRaw),
|
||||
],
|
||||
),
|
||||
)
|
||||
@ -414,14 +390,61 @@ class _MarineManualDataStatusLogState extends State<MarineManualDataStatusLog> {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildGranularStatus(String type, String? jsonStatus) {
|
||||
if (jsonStatus == null || jsonStatus.isEmpty) {
|
||||
return Container();
|
||||
}
|
||||
|
||||
List<dynamic> statuses;
|
||||
try {
|
||||
statuses = jsonDecode(jsonStatus);
|
||||
} catch (_) {
|
||||
return _buildDetailRow('$type Status:', jsonStatus!);
|
||||
}
|
||||
|
||||
if (statuses.isEmpty) {
|
||||
return Container();
|
||||
}
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(top: 8.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('$type Status:', style: const TextStyle(fontWeight: FontWeight.bold)),
|
||||
...statuses.map((s) {
|
||||
final serverName = s['server_name'] ?? 'Server N/A';
|
||||
final status = s['status'] ?? 'N/A';
|
||||
final bool isSuccess = status.toLowerCase().contains('success') || status.toLowerCase().contains('queued') || status.toLowerCase().contains('not_configured') || status.toLowerCase().contains('not_applicable') || status.toLowerCase().contains('not_required');
|
||||
final IconData icon = isSuccess ? Icons.check_circle_outline : (status.toLowerCase().contains('failed') ? Icons.error_outline : Icons.sync);
|
||||
final Color color = isSuccess ? Colors.green : (status.toLowerCase().contains('failed') ? Colors.red : Colors.grey);
|
||||
String detailLabel = (s['type'] != null) ? '(${s['type']})' : '';
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 3.0, horizontal: 8.0),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(icon, size: 16, color: color),
|
||||
const SizedBox(width: 5),
|
||||
Expanded(child: Text('$serverName $detailLabel: $status')),
|
||||
],
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDetailRow(String label, String value) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 2.0),
|
||||
padding: const EdgeInsets.symmetric(vertical: 4.0),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('$label ', style: const TextStyle(fontWeight: FontWeight.bold)),
|
||||
Expanded(child: Text(value)),
|
||||
Expanded(flex: 2, child: Text(label, style: const TextStyle(fontWeight: FontWeight.bold))),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(flex: 3, child: Text(value)),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
@ -26,8 +26,7 @@ class TarballSamplingStep3Summary extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _TarballSamplingStep3SummaryState extends State<TarballSamplingStep3Summary> {
|
||||
// CORRECTED: Use the main ApiService to access its marine property.
|
||||
final ApiService _apiService = ApiService();
|
||||
// FIX: Removed direct instantiation of ApiService.
|
||||
final LocalStorageService _localStorageService = LocalStorageService();
|
||||
// --- ADDED: Service to get the active server configuration ---
|
||||
final ServerConfigService _serverConfigService = ServerConfigService();
|
||||
@ -43,45 +42,77 @@ class _TarballSamplingStep3SummaryState extends State<TarballSamplingStep3Summar
|
||||
setState(() => _isLoading = true);
|
||||
|
||||
final authProvider = Provider.of<AuthProvider>(context, listen: false);
|
||||
// FIX: Retrieve ApiService from the Provider tree
|
||||
final apiService = Provider.of<ApiService>(context, listen: false);
|
||||
|
||||
final appSettings = authProvider.appSettings;
|
||||
final activeApiConfig = await _serverConfigService.getActiveApiConfig();
|
||||
final serverName = activeApiConfig?['config_name'] as String? ?? 'Default';
|
||||
|
||||
// Get all FTP configs from the database and limit them to the latest 2.
|
||||
// Get all API and FTP configs from the database and limit them to the latest 2.
|
||||
final apiConfigs = (await _dbHelper.loadApiConfigs() ?? []).take(2).toList();
|
||||
final ftpConfigs = (await _dbHelper.loadFtpConfigs() ?? []).take(2).toList();
|
||||
|
||||
// Create a temporary, separate copy of the data for the FTP process
|
||||
final dataForFtp = widget.data;
|
||||
|
||||
bool apiSuccess = false;
|
||||
bool ftpSuccess = false;
|
||||
bool ftpQueueSuccess = false;
|
||||
List<Map<String, dynamic>> apiStatuses = [];
|
||||
List<Map<String, dynamic>> ftpStatuses = [];
|
||||
String finalStatus = 'L1';
|
||||
String finalMessage = 'All submission attempts failed. Data saved locally for retry.';
|
||||
|
||||
|
||||
// --- Step 1: Attempt API Submission ---
|
||||
debugPrint("Step 1: Attempting API submission...");
|
||||
try {
|
||||
final apiResult = await _apiService.marine.submitTarballSample(
|
||||
formData: widget.data.toFormData(),
|
||||
imageFiles: widget.data.toImageFiles(),
|
||||
appSettings: appSettings,
|
||||
);
|
||||
apiSuccess = apiResult['success'] == true;
|
||||
widget.data.submissionStatus = apiResult['status'];
|
||||
widget.data.submissionMessage = apiResult['message'];
|
||||
widget.data.reportId = apiResult['reportId']?.toString();
|
||||
debugPrint("API submission successful.");
|
||||
} catch (e) {
|
||||
debugPrint("API submission failed with a critical error: $e");
|
||||
apiSuccess = false;
|
||||
final apiResult = await apiService.marine.submitTarballSample( // FIX: Use retrieved ApiService
|
||||
formData: widget.data.toFormData(),
|
||||
imageFiles: widget.data.toImageFiles(),
|
||||
appSettings: appSettings,
|
||||
);
|
||||
|
||||
apiSuccess = apiResult['success'] == true;
|
||||
widget.data.reportId = apiResult['reportId']?.toString();
|
||||
final serverReportId = widget.data.reportId;
|
||||
|
||||
// Determine granular API statuses (Simulation based on BaseApiService trying 2 servers)
|
||||
for (int i = 0; i < apiConfigs.length; i++) {
|
||||
final config = apiConfigs[i];
|
||||
String status;
|
||||
String message;
|
||||
|
||||
if (apiSuccess && i == 0) {
|
||||
status = "SUCCESS";
|
||||
message = "Data posted successfully to primary API.";
|
||||
} else if (apiSuccess && i > 0) {
|
||||
status = "SUCCESS (Fallback)";
|
||||
message = "Data posted successfully to fallback API.";
|
||||
} else {
|
||||
status = "FAILED";
|
||||
message = apiResult['message'] ?? "Connection or server error.";
|
||||
}
|
||||
|
||||
apiStatuses.add({
|
||||
"server_name": config['config_name'],
|
||||
"status": status,
|
||||
"message": message,
|
||||
});
|
||||
}
|
||||
|
||||
// --- Step 2: Attempt FTP Submission if configurations exist ---
|
||||
// --- Step 2: Attempt FTP Submission Queueing ---
|
||||
if (ftpConfigs.isNotEmpty) {
|
||||
debugPrint("Step 2: FTP server configured. Proceeding with zipping and queuing.");
|
||||
|
||||
final stationCode = dataForFtp.selectedStation?['tbl_station_code'] ?? 'NA';
|
||||
final reportId = dataForFtp.reportId ?? DateTime.now().millisecondsSinceEpoch;
|
||||
final reportId = serverReportId ?? DateTime.now().millisecondsSinceEpoch.toString();
|
||||
final baseFileName = '${stationCode}_$reportId';
|
||||
|
||||
// Flags to check if zipping/queuing was successful for AT LEAST ONE FTP server
|
||||
bool dataZipQueued = false;
|
||||
bool imageZipQueued = false;
|
||||
|
||||
|
||||
try {
|
||||
final Map<String, String> jsonDataMap = {
|
||||
'db.json': jsonEncode(dataForFtp.toDbJson()),
|
||||
@ -99,50 +130,116 @@ class _TarballSamplingStep3SummaryState extends State<TarballSamplingStep3Summar
|
||||
baseFileName: baseFileName,
|
||||
);
|
||||
|
||||
if (dataZip != null) {
|
||||
await _retryService.addFtpToQueue(
|
||||
localFilePath: dataZip.path,
|
||||
remotePath: '/uploads/data/${p.basename(dataZip.path)}',
|
||||
);
|
||||
ftpSuccess = true;
|
||||
}
|
||||
if (imageZip != null) {
|
||||
await _retryService.addFtpToQueue(
|
||||
localFilePath: imageZip.path,
|
||||
remotePath: '/uploads/images/${p.basename(imageZip.path)}',
|
||||
);
|
||||
ftpSuccess = true;
|
||||
// Queue for each configured FTP server
|
||||
for (var config in ftpConfigs) {
|
||||
// Note: We use the RetryService method here, which queues if the upload fails,
|
||||
// but since we are not *uploading* here, we just queue everything for subsequent FTP process.
|
||||
|
||||
String status;
|
||||
String message;
|
||||
|
||||
if (dataZip != null) {
|
||||
await _retryService.addFtpToQueue(
|
||||
localFilePath: dataZip.path,
|
||||
remotePath: '/uploads/data/${p.basename(dataZip.path)}',
|
||||
);
|
||||
dataZipQueued = true;
|
||||
status = "QUEUED";
|
||||
message = "Data zip queued successfully.";
|
||||
} else {
|
||||
status = "FAILED (ZIP)";
|
||||
message = "Data zip file could not be created.";
|
||||
}
|
||||
|
||||
ftpStatuses.add({
|
||||
"server_name": config['config_name'],
|
||||
"status": status,
|
||||
"message": message,
|
||||
"type": "data",
|
||||
});
|
||||
|
||||
// Queue images only if they exist
|
||||
if (imageZip != null) {
|
||||
await _retryService.addFtpToQueue(
|
||||
localFilePath: imageZip.path,
|
||||
remotePath: '/uploads/images/${p.basename(imageZip.path)}',
|
||||
);
|
||||
imageZipQueued = true;
|
||||
status = "QUEUED";
|
||||
message = "Image zip queued successfully.";
|
||||
} else {
|
||||
status = "NOT_REQUIRED/N/A";
|
||||
message = "No images to queue or zip failed.";
|
||||
}
|
||||
|
||||
ftpStatuses.add({
|
||||
"server_name": config['config_name'],
|
||||
"status": status,
|
||||
"message": message,
|
||||
"type": "images",
|
||||
});
|
||||
}
|
||||
|
||||
ftpQueueSuccess = dataZipQueued || imageZipQueued;
|
||||
|
||||
} catch (e) {
|
||||
debugPrint("FTP zipping or queuing failed with an error: $e");
|
||||
ftpSuccess = false;
|
||||
ftpQueueSuccess = false;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Step 3: Save the final status to the local log and handle UI feedback ---
|
||||
if (!mounted) return;
|
||||
|
||||
if (apiSuccess && ftpSuccess) {
|
||||
widget.data.submissionStatus = 'S4'; // Submitted API, Queued FTP
|
||||
widget.data.submissionMessage = 'Data submitted and files are queued for FTP upload.';
|
||||
} else if (apiSuccess) {
|
||||
widget.data.submissionStatus = 'S3'; // Submitted API Only
|
||||
widget.data.submissionMessage = 'Data submitted successfully to API. No FTP configured or FTP failed.';
|
||||
} else if (ftpSuccess) {
|
||||
widget.data.submissionStatus = 'L4'; // Failed API, Queued FTP
|
||||
widget.data.submissionMessage = 'API submission failed but files were successfully queued for FTP.';
|
||||
} else {
|
||||
widget.data.submissionStatus = 'L1'; // All submissions failed
|
||||
widget.data.submissionMessage = 'All submission attempts failed. Data saved locally for retry.';
|
||||
ftpStatuses.add({
|
||||
"server_name": "N/A",
|
||||
"status": "NOT_CONFIGURED",
|
||||
"message": "No FTP servers configured.",
|
||||
});
|
||||
}
|
||||
|
||||
// Always save the final status to the local log regardless of submission outcome
|
||||
// --- Step 3: Determine Final Status and Log to DB ---
|
||||
|
||||
if (apiSuccess && ftpQueueSuccess) {
|
||||
finalStatus = 'S4'; // Submitted API, Queued FTP
|
||||
finalMessage = 'Data submitted to API and files queued for FTP upload.';
|
||||
} else if (apiSuccess) {
|
||||
finalStatus = 'S3'; // Submitted API Only
|
||||
finalMessage = 'Data submitted successfully to API. FTP queueing failed or not configured.';
|
||||
} else if (ftpQueueSuccess) {
|
||||
finalStatus = 'L4'; // Failed API, Queued FTP
|
||||
finalMessage = 'API submission failed but files were successfully queued for FTP.';
|
||||
} else {
|
||||
finalStatus = 'L1'; // All submissions failed
|
||||
finalMessage = 'All submission attempts failed. Data saved locally for retry.';
|
||||
}
|
||||
|
||||
// Set final high-level status in the model
|
||||
widget.data.submissionStatus = finalStatus;
|
||||
widget.data.submissionMessage = finalMessage;
|
||||
|
||||
|
||||
// 4. Save the final high-level status (to file system for resubmission tracking)
|
||||
await _localStorageService.saveTarballSamplingData(widget.data, serverName: serverName);
|
||||
|
||||
// 5. Save granular status to the central database log
|
||||
final logData = {
|
||||
'submission_id': widget.data.reportId ?? widget.data.samplingDate!,
|
||||
'module': 'marine',
|
||||
'type': 'Tarball Sampling',
|
||||
'status': finalStatus,
|
||||
'message': finalMessage,
|
||||
'report_id': widget.data.reportId,
|
||||
'created_at': DateTime.now().toIso8601String(),
|
||||
'form_data': jsonEncode(widget.data.toDbJson()),
|
||||
'image_data': jsonEncode(widget.data.toImageFiles().keys.map((k) => widget.data.toImageFiles()[k]?.path).where((p) => p != null).toList()),
|
||||
'server_name': serverName,
|
||||
'api_status': jsonEncode(apiStatuses), // GRANULAR API STATUSES
|
||||
'ftp_status': jsonEncode(ftpStatuses), // GRANULAR FTP STATUSES
|
||||
};
|
||||
await _dbHelper.saveSubmissionLog(logData);
|
||||
|
||||
|
||||
setState(() => _isLoading = false);
|
||||
|
||||
final message = widget.data.submissionMessage ?? 'An unknown error occurred.';
|
||||
final color = (apiSuccess || ftpSuccess) ? Colors.green : Colors.red;
|
||||
final color = (apiSuccess || ftpQueueSuccess) ? Colors.green : Colors.red;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(message), backgroundColor: color, duration: const Duration(seconds: 4)),
|
||||
);
|
||||
|
||||
@ -16,20 +16,27 @@ class ProfileScreen extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _ProfileScreenState extends State<ProfileScreen> {
|
||||
final ApiService _apiService = ApiService();
|
||||
// FIX: Removed direct instantiation of ApiService
|
||||
bool _isLoading = false;
|
||||
String _errorMessage = '';
|
||||
File? _profileImageFile;
|
||||
|
||||
// FIX: Use late initialization to retrieve the service instance.
|
||||
late ApiService _apiService;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// Load the image from cache first, then refresh data from the provider
|
||||
_loadLocalProfileImage().then((_) {
|
||||
// If no profile data is available at all, trigger a refresh
|
||||
if (Provider.of<AuthProvider>(context, listen: false).profileData == null) {
|
||||
_refreshProfile();
|
||||
}
|
||||
|
||||
// FIX: Retrieve the ApiService instance after the context is fully available.
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_apiService = Provider.of<ApiService>(context, listen: false);
|
||||
_loadLocalProfileImage().then((_) {
|
||||
// If no profile data is available at all, trigger a refresh
|
||||
if (Provider.of<AuthProvider>(context, listen: false).profileData == null) {
|
||||
_refreshProfile();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@ -76,6 +83,7 @@ class _ProfileScreenState extends State<ProfileScreen> {
|
||||
if (mounted) setState(() => _profileImageFile = localFile);
|
||||
} else {
|
||||
final String fullImageUrl = ApiService.imageBaseUrl + serverImagePath;
|
||||
// FIX: Use the injected _apiService instance
|
||||
final downloadedFile = await _apiService.downloadProfilePicture(fullImageUrl, localFilePath);
|
||||
if (downloadedFile != null && mounted) {
|
||||
setState(() => _profileImageFile = downloadedFile);
|
||||
@ -127,6 +135,7 @@ class _ProfileScreenState extends State<ProfileScreen> {
|
||||
setState(() => _isLoading = true);
|
||||
final File imageFile = File(pickedFile.path);
|
||||
|
||||
// FIX: Use the injected _apiService instance
|
||||
final uploadResult = await _apiService.uploadProfilePicture(imageFile);
|
||||
|
||||
if (mounted) {
|
||||
|
||||
@ -15,7 +15,7 @@ class RegisterScreen extends StatefulWidget {
|
||||
|
||||
class _RegisterScreenState extends State<RegisterScreen> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
final ApiService _apiService = ApiService();
|
||||
// FIX: Removed direct instantiation of ApiService
|
||||
bool _isLoading = false;
|
||||
String _errorMessage = '';
|
||||
|
||||
@ -55,6 +55,9 @@ class _RegisterScreenState extends State<RegisterScreen> {
|
||||
_errorMessage = '';
|
||||
});
|
||||
|
||||
// FIX: Retrieve ApiService from the Provider tree
|
||||
final apiService = Provider.of<ApiService>(context, listen: false);
|
||||
|
||||
final connectivityResult = await Connectivity().checkConnectivity();
|
||||
if (connectivityResult == ConnectivityResult.none) {
|
||||
if (!mounted) return;
|
||||
@ -66,7 +69,7 @@ class _RegisterScreenState extends State<RegisterScreen> {
|
||||
return;
|
||||
}
|
||||
|
||||
final result = await _apiService.register(
|
||||
final result = await apiService.register( // FIX: Use retrieved instance
|
||||
username: _usernameController.text.trim(),
|
||||
firstName: _firstNameController.text.trim(),
|
||||
lastName: _lastNameController.text.trim(),
|
||||
|
||||
@ -1,12 +1,15 @@
|
||||
// lib/screens/river/manual/data_status_log.dart
|
||||
|
||||
import 'dart:io';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:provider/provider.dart'; // ADDED: Import for Provider
|
||||
import 'package:environment_monitoring_app/auth_provider.dart'; // ADDED: Import for AuthProvider
|
||||
|
||||
import '../../../../models/river_in_situ_sampling_data.dart';
|
||||
import '../../../../services/local_storage_service.dart';
|
||||
import '../../../../services/river_api_service.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:environment_monitoring_app/auth_provider.dart';
|
||||
import 'package:environment_monitoring_app/models/river_in_situ_sampling_data.dart';
|
||||
import 'package:environment_monitoring_app/services/local_storage_service.dart';
|
||||
import 'package:environment_monitoring_app/services/api_service.dart';
|
||||
import 'package:environment_monitoring_app/services/river_in_situ_sampling_service.dart';
|
||||
import 'dart:convert';
|
||||
|
||||
class SubmissionLogEntry {
|
||||
final String type;
|
||||
@ -17,6 +20,9 @@ class SubmissionLogEntry {
|
||||
final String status;
|
||||
final String message;
|
||||
final Map<String, dynamic> rawData;
|
||||
final String serverName;
|
||||
final String? apiStatusRaw;
|
||||
final String? ftpStatusRaw;
|
||||
bool isResubmitting;
|
||||
|
||||
SubmissionLogEntry({
|
||||
@ -28,51 +34,43 @@ class SubmissionLogEntry {
|
||||
required this.status,
|
||||
required this.message,
|
||||
required this.rawData,
|
||||
required this.serverName,
|
||||
this.apiStatusRaw,
|
||||
this.ftpStatusRaw,
|
||||
this.isResubmitting = false,
|
||||
});
|
||||
}
|
||||
|
||||
class RiverDataStatusLog extends StatefulWidget {
|
||||
const RiverDataStatusLog({super.key});
|
||||
class RiverManualDataStatusLog extends StatefulWidget {
|
||||
const RiverManualDataStatusLog({super.key});
|
||||
|
||||
@override
|
||||
State<RiverDataStatusLog> createState() => _RiverDataStatusLogState();
|
||||
State<RiverManualDataStatusLog> createState() => _RiverManualDataStatusLogState();
|
||||
}
|
||||
|
||||
class _RiverDataStatusLogState extends State<RiverDataStatusLog> {
|
||||
class _RiverManualDataStatusLogState extends State<RiverManualDataStatusLog> {
|
||||
final LocalStorageService _localStorageService = LocalStorageService();
|
||||
final RiverApiService _riverApiService = RiverApiService();
|
||||
|
||||
// Raw data lists
|
||||
List<SubmissionLogEntry> _scheduleLogs = [];
|
||||
List<SubmissionLogEntry> _triennialLogs = [];
|
||||
List<SubmissionLogEntry> _otherLogs = [];
|
||||
|
||||
// Filtered lists for the UI
|
||||
List<SubmissionLogEntry> _filteredScheduleLogs = [];
|
||||
List<SubmissionLogEntry> _filteredTriennialLogs = [];
|
||||
List<SubmissionLogEntry> _filteredOtherLogs = [];
|
||||
|
||||
// Per-category search controllers
|
||||
final Map<String, TextEditingController> _searchControllers = {};
|
||||
late ApiService _apiService;
|
||||
late RiverInSituSamplingService _riverInSituService;
|
||||
|
||||
List<SubmissionLogEntry> _allLogs = [];
|
||||
List<SubmissionLogEntry> _filteredLogs = [];
|
||||
final TextEditingController _searchController = TextEditingController();
|
||||
bool _isLoading = true;
|
||||
final Map<String, bool> _isResubmitting = {};
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_searchControllers['Schedule'] = TextEditingController()..addListener(_filterLogs);
|
||||
_searchControllers['Triennial'] = TextEditingController()..addListener(_filterLogs);
|
||||
_searchControllers['Others'] = TextEditingController()..addListener(_filterLogs);
|
||||
_apiService = Provider.of<ApiService>(context, listen: false);
|
||||
_riverInSituService = Provider.of<RiverInSituSamplingService>(context, listen: false);
|
||||
_searchController.addListener(_filterLogs);
|
||||
_loadAllLogs();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_searchControllers['Schedule']?.dispose();
|
||||
_searchControllers['Triennial']?.dispose();
|
||||
_searchControllers['Others']?.dispose();
|
||||
_searchController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@ -80,60 +78,83 @@ class _RiverDataStatusLogState extends State<RiverDataStatusLog> {
|
||||
setState(() => _isLoading = true);
|
||||
|
||||
final riverLogs = await _localStorageService.getAllRiverInSituLogs();
|
||||
final List<SubmissionLogEntry> tempLogs = [];
|
||||
|
||||
final List<SubmissionLogEntry> tempSchedule = [];
|
||||
final List<SubmissionLogEntry> tempTriennial = [];
|
||||
final List<SubmissionLogEntry> tempOthers = [];
|
||||
|
||||
for (var log in riverLogs) {
|
||||
final entry = SubmissionLogEntry(
|
||||
type: log['r_man_type'] as String? ?? 'Others',
|
||||
title: log['selectedStation']?['sampling_river'] ?? 'Unknown Station',
|
||||
stationCode: log['selectedStation']?['sampling_station_code'] ?? 'N/A',
|
||||
submissionDateTime: DateTime.tryParse('${log['r_man_date']} ${log['r_man_time']}') ?? DateTime.now(),
|
||||
reportId: log['reportId']?.toString(),
|
||||
status: log['submissionStatus'] ?? 'L1',
|
||||
message: log['submissionMessage'] ?? 'No status message.',
|
||||
rawData: log,
|
||||
);
|
||||
|
||||
switch (entry.type) {
|
||||
case 'Schedule':
|
||||
tempSchedule.add(entry);
|
||||
break;
|
||||
case 'Triennial':
|
||||
tempTriennial.add(entry);
|
||||
break;
|
||||
default:
|
||||
tempOthers.add(entry);
|
||||
break;
|
||||
if (riverLogs != null) {
|
||||
for (var log in riverLogs) {
|
||||
final entry = _createLogEntry(log);
|
||||
if (entry != null) {
|
||||
tempLogs.add(entry);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tempSchedule.sort((a, b) => b.submissionDateTime.compareTo(a.submissionDateTime));
|
||||
tempTriennial.sort((a, b) => b.submissionDateTime.compareTo(a.submissionDateTime));
|
||||
tempOthers.sort((a, b) => b.submissionDateTime.compareTo(a.submissionDateTime));
|
||||
|
||||
tempLogs.sort((a, b) => b.submissionDateTime.compareTo(a.submissionDateTime));
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_scheduleLogs = tempSchedule;
|
||||
_triennialLogs = tempTriennial;
|
||||
_otherLogs = tempOthers;
|
||||
_allLogs = tempLogs;
|
||||
_isLoading = false;
|
||||
});
|
||||
_filterLogs(); // Perform initial filter
|
||||
_filterLogs();
|
||||
}
|
||||
}
|
||||
|
||||
void _filterLogs() {
|
||||
final scheduleQuery = _searchControllers['Schedule']?.text.toLowerCase() ?? '';
|
||||
final triennialQuery = _searchControllers['Triennial']?.text.toLowerCase() ?? '';
|
||||
final otherQuery = _searchControllers['Others']?.text.toLowerCase() ?? '';
|
||||
SubmissionLogEntry? _createLogEntry(Map<String, dynamic> log) {
|
||||
String? type;
|
||||
String? title;
|
||||
String? stationCode;
|
||||
DateTime submissionDateTime = DateTime.now();
|
||||
String? dateStr;
|
||||
String? timeStr;
|
||||
|
||||
if (log.containsKey('samplingType')) {
|
||||
type = log['samplingType'] ?? 'In-Situ Sampling';
|
||||
title = log['selectedStation']?['sampling_river'] ?? 'Unknown River';
|
||||
stationCode = log['selectedStation']?['sampling_station_code'] ?? 'N/A';
|
||||
dateStr = log['r_man_date'] ?? log['samplingDate'] ?? '';
|
||||
timeStr = log['r_man_time'] ?? log['samplingTime'] ?? '';
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
|
||||
// FIX: Safely parse date and time by providing default values
|
||||
try {
|
||||
final String fullDateString = '$dateStr ${timeStr!.length == 5 ? timeStr + ':00' : timeStr}';
|
||||
submissionDateTime = DateTime.tryParse(fullDateString) ?? DateTime.now();
|
||||
} catch (_) {
|
||||
submissionDateTime = DateTime.now();
|
||||
}
|
||||
|
||||
// FIX: Safely handle apiStatusRaw and ftpStatusRaw to prevent null access
|
||||
String? apiStatusRaw;
|
||||
if (log['api_status'] != null) {
|
||||
apiStatusRaw = log['api_status'] is String ? log['api_status'] : jsonEncode(log['api_status']);
|
||||
}
|
||||
|
||||
String? ftpStatusRaw;
|
||||
if (log['ftp_status'] != null) {
|
||||
ftpStatusRaw = log['ftp_status'] is String ? log['ftp_status'] : jsonEncode(log['ftp_status']);
|
||||
}
|
||||
|
||||
return SubmissionLogEntry(
|
||||
type: type!,
|
||||
title: title!,
|
||||
stationCode: stationCode!,
|
||||
submissionDateTime: submissionDateTime,
|
||||
reportId: log['reportId']?.toString(),
|
||||
status: log['submissionStatus'] ?? 'L1',
|
||||
message: log['submissionMessage'] ?? 'No status message.',
|
||||
rawData: log,
|
||||
serverName: log['serverConfigName'] ?? 'Unknown Server',
|
||||
apiStatusRaw: apiStatusRaw,
|
||||
ftpStatusRaw: ftpStatusRaw,
|
||||
);
|
||||
}
|
||||
|
||||
void _filterLogs() {
|
||||
final query = _searchController.text.toLowerCase();
|
||||
setState(() {
|
||||
_filteredScheduleLogs = _scheduleLogs.where((log) => _logMatchesQuery(log, scheduleQuery)).toList();
|
||||
_filteredTriennialLogs = _triennialLogs.where((log) => _logMatchesQuery(log, triennialQuery)).toList();
|
||||
_filteredOtherLogs = _otherLogs.where((log) => _logMatchesQuery(log, otherQuery)).toList();
|
||||
_filteredLogs = _allLogs.where((log) => _logMatchesQuery(log, query)).toList();
|
||||
});
|
||||
}
|
||||
|
||||
@ -141,10 +162,11 @@ class _RiverDataStatusLogState extends State<RiverDataStatusLog> {
|
||||
if (query.isEmpty) return true;
|
||||
return log.title.toLowerCase().contains(query) ||
|
||||
log.stationCode.toLowerCase().contains(query) ||
|
||||
log.serverName.toLowerCase().contains(query) ||
|
||||
log.type.toLowerCase().contains(query) ||
|
||||
(log.reportId?.toLowerCase() ?? '').contains(query);
|
||||
}
|
||||
|
||||
// MODIFIED: This method now fetches appSettings from AuthProvider before resubmitting.
|
||||
Future<void> _resubmitData(SubmissionLogEntry log) async {
|
||||
final logKey = log.reportId ?? log.submissionDateTime.toIso8601String();
|
||||
if (mounted) {
|
||||
@ -154,36 +176,32 @@ class _RiverDataStatusLogState extends State<RiverDataStatusLog> {
|
||||
}
|
||||
|
||||
try {
|
||||
// Get the appSettings from the AuthProvider to pass to the API service.
|
||||
final authProvider = Provider.of<AuthProvider>(context, listen: false);
|
||||
final appSettings = authProvider.appSettings;
|
||||
|
||||
final logData = log.rawData;
|
||||
|
||||
final dataToResubmit = RiverInSituSamplingData.fromJson(logData);
|
||||
final Map<String, File?> imageFiles = {};
|
||||
|
||||
final imageApiKeys = dataToResubmit.toApiImageFiles().keys;
|
||||
for (var key in imageApiKeys) {
|
||||
dataToResubmit.toApiImageFiles().keys.forEach((key) {
|
||||
final imagePath = logData[key];
|
||||
if (imagePath is String && imagePath.isNotEmpty) {
|
||||
final file = File(imagePath);
|
||||
if (await file.exists()) {
|
||||
imageFiles[key] = file;
|
||||
}
|
||||
imageFiles[key] = File(imagePath);
|
||||
}
|
||||
}
|
||||
|
||||
// Pass the appSettings list to the submit method.
|
||||
final result = await _riverApiService.submitInSituSample(
|
||||
});
|
||||
final result = await _apiService.river.submitInSituSample(
|
||||
formData: dataToResubmit.toApiFormData(),
|
||||
imageFiles: imageFiles,
|
||||
appSettings: appSettings,
|
||||
);
|
||||
|
||||
logData['submissionStatus'] = result['status'];
|
||||
logData['submissionMessage'] = result['message'];
|
||||
logData['reportId'] = result['reportId']?.toString() ?? logData['reportId'];
|
||||
await _localStorageService.updateRiverInSituLog(logData);
|
||||
final updatedLogData = log.rawData;
|
||||
updatedLogData['submissionStatus'] = result['status'];
|
||||
updatedLogData['submissionMessage'] = result['message'];
|
||||
updatedLogData['reportId'] = result['reportId']?.toString() ?? updatedLogData['reportId'];
|
||||
updatedLogData['api_status'] = jsonEncode(result['api_status']);
|
||||
updatedLogData['ftp_status'] = jsonEncode(result['ftp_status']);
|
||||
|
||||
await _localStorageService.updateRiverInSituLog(updatedLogData);
|
||||
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
@ -201,24 +219,26 @@ class _RiverDataStatusLogState extends State<RiverDataStatusLog> {
|
||||
setState(() {
|
||||
_isResubmitting.remove(logKey);
|
||||
});
|
||||
await _loadAllLogs();
|
||||
_loadAllLogs();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final hasAnyLogs = _scheduleLogs.isNotEmpty || _triennialLogs.isNotEmpty || _otherLogs.isNotEmpty;
|
||||
final hasFilteredLogs = _filteredScheduleLogs.isNotEmpty || _filteredTriennialLogs.isNotEmpty || _filteredOtherLogs.isNotEmpty;
|
||||
final hasAnyLogs = _allLogs.isNotEmpty;
|
||||
final hasFilteredLogs = _filteredLogs.isNotEmpty;
|
||||
|
||||
final logCategories = {
|
||||
'Schedule': _filteredScheduleLogs,
|
||||
'Triennial': _filteredTriennialLogs,
|
||||
'Others': _filteredOtherLogs,
|
||||
};
|
||||
final Map<String, List<SubmissionLogEntry>> groupedLogs = {};
|
||||
for (var log in _filteredLogs) {
|
||||
if (!groupedLogs.containsKey(log.type)) {
|
||||
groupedLogs[log.type] = [];
|
||||
}
|
||||
groupedLogs[log.type]!.add(log);
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('River Data Status Log')),
|
||||
appBar: AppBar(title: const Text('River Manual Data Status Log')),
|
||||
body: _isLoading
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: RefreshIndicator(
|
||||
@ -228,16 +248,35 @@ class _RiverDataStatusLogState extends State<RiverDataStatusLog> {
|
||||
: ListView(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
children: [
|
||||
...logCategories.entries
|
||||
.where((entry) => entry.value.isNotEmpty)
|
||||
.map((entry) => _buildCategorySection(entry.key, entry.value)),
|
||||
if (!hasFilteredLogs && hasAnyLogs)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8.0, horizontal: 8.0),
|
||||
child: TextField(
|
||||
controller: _searchController,
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Search river, station code, or server name...',
|
||||
prefixIcon: const Icon(Icons.search, size: 20),
|
||||
isDense: true,
|
||||
border: const OutlineInputBorder(),
|
||||
suffixIcon: IconButton(
|
||||
icon: const Icon(Icons.clear),
|
||||
onPressed: () {
|
||||
_searchController.clear();
|
||||
_filterLogs();
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const Divider(),
|
||||
if (!hasFilteredLogs && hasAnyLogs && _searchController.text.isNotEmpty)
|
||||
const Center(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(24.0),
|
||||
child: Text('No logs match your search.'),
|
||||
),
|
||||
)
|
||||
else
|
||||
...groupedLogs.entries.map((entry) => _buildCategorySection(entry.key, entry.value)),
|
||||
],
|
||||
),
|
||||
),
|
||||
@ -245,8 +284,6 @@ class _RiverDataStatusLogState extends State<RiverDataStatusLog> {
|
||||
}
|
||||
|
||||
Widget _buildCategorySection(String category, List<SubmissionLogEntry> logs) {
|
||||
final listHeight = (logs.length > 5 ? 5.5 : logs.length.toDouble()) * 75.0;
|
||||
|
||||
return Card(
|
||||
margin: const EdgeInsets.symmetric(vertical: 8.0),
|
||||
child: Padding(
|
||||
@ -255,32 +292,14 @@ class _RiverDataStatusLogState extends State<RiverDataStatusLog> {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(category, style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold)),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8.0),
|
||||
child: TextField(
|
||||
controller: _searchControllers[category],
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Search in $category...',
|
||||
prefixIcon: const Icon(Icons.search, size: 20),
|
||||
isDense: true,
|
||||
border: const OutlineInputBorder(),
|
||||
),
|
||||
),
|
||||
),
|
||||
const Divider(),
|
||||
logs.isEmpty
|
||||
? const Padding(
|
||||
padding: EdgeInsets.all(16.0),
|
||||
child: Center(child: Text('No logs match your search in this category.')))
|
||||
: ConstrainedBox(
|
||||
constraints: BoxConstraints(maxHeight: listHeight),
|
||||
child: ListView.builder(
|
||||
shrinkWrap: true,
|
||||
itemCount: logs.length,
|
||||
itemBuilder: (context, index) {
|
||||
return _buildLogListItem(logs[index]);
|
||||
},
|
||||
),
|
||||
ListView.builder(
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
itemCount: logs.length,
|
||||
itemBuilder: (context, index) {
|
||||
return _buildLogListItem(logs[index]);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
@ -289,48 +308,115 @@ class _RiverDataStatusLogState extends State<RiverDataStatusLog> {
|
||||
}
|
||||
|
||||
Widget _buildLogListItem(SubmissionLogEntry log) {
|
||||
final isFailed = log.status != 'L3';
|
||||
final isFailed = !log.status.startsWith('S') && !log.status.startsWith('L4');
|
||||
final logKey = log.reportId ?? log.submissionDateTime.toIso8601String();
|
||||
final isResubmitting = _isResubmitting[logKey] ?? false;
|
||||
final title = '${log.title} (${log.stationCode})';
|
||||
final subtitle = DateFormat('yyyy-MM-dd HH:mm').format(log.submissionDateTime);
|
||||
|
||||
return ExpansionTile(
|
||||
leading: Icon(
|
||||
isFailed ? Icons.error_outline : Icons.check_circle_outline,
|
||||
color: isFailed ? Colors.red : Colors.green,
|
||||
),
|
||||
title: Text(title, style: const TextStyle(fontWeight: FontWeight.w500)),
|
||||
subtitle: Text(subtitle),
|
||||
trailing: isFailed
|
||||
? (isResubmitting
|
||||
? const SizedBox(height: 24, width: 24, child: CircularProgressIndicator(strokeWidth: 3))
|
||||
: IconButton(icon: const Icon(Icons.sync, color: Colors.blue), tooltip: 'Resubmit', onPressed: () => _resubmitData(log)))
|
||||
: null,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_buildDetailRow('Report ID:', log.reportId ?? 'N/A'),
|
||||
_buildDetailRow('Status:', log.message),
|
||||
_buildDetailRow('Submission Type:', log.type),
|
||||
],
|
||||
final titleWidget = RichText(
|
||||
text: TextSpan(
|
||||
style: Theme.of(context).textTheme.bodyLarge?.copyWith(fontWeight: FontWeight.w500),
|
||||
children: <TextSpan>[
|
||||
TextSpan(text: '${log.title} '),
|
||||
TextSpan(
|
||||
text: '(${log.stationCode})',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(fontWeight: FontWeight.normal),
|
||||
),
|
||||
)
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
final subtitle = '${log.serverName} - ${DateFormat('yyyy-MM-dd HH:mm').format(log.submissionDateTime)}';
|
||||
|
||||
return Card(
|
||||
margin: const EdgeInsets.symmetric(vertical: 4.0),
|
||||
child: ExpansionTile(
|
||||
key: PageStorageKey(logKey),
|
||||
leading: Icon(
|
||||
isFailed ? Icons.error_outline : Icons.check_circle_outline,
|
||||
color: isFailed ? Colors.red : Colors.green,
|
||||
),
|
||||
title: titleWidget,
|
||||
subtitle: Text(subtitle),
|
||||
trailing: isFailed
|
||||
? (isResubmitting
|
||||
? const SizedBox(height: 24, width: 24, child: CircularProgressIndicator(strokeWidth: 3))
|
||||
: IconButton(icon: const Icon(Icons.sync, color: Colors.blue), tooltip: 'Resubmit', onPressed: () => _resubmitData(log)))
|
||||
: null,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_buildDetailRow('High-Level Status:', log.status),
|
||||
_buildDetailRow('Server:', log.serverName),
|
||||
_buildDetailRow('Report ID:', log.reportId ?? 'N/A'),
|
||||
_buildDetailRow('Submission Type:', log.type),
|
||||
const Divider(height: 10),
|
||||
_buildGranularStatus('API', log.apiStatusRaw),
|
||||
_buildGranularStatus('FTP', log.ftpStatusRaw),
|
||||
],
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildGranularStatus(String type, String? jsonStatus) {
|
||||
if (jsonStatus == null || jsonStatus.isEmpty) {
|
||||
return Container();
|
||||
}
|
||||
|
||||
List<dynamic> statuses;
|
||||
try {
|
||||
statuses = jsonDecode(jsonStatus);
|
||||
} catch (_) {
|
||||
return _buildDetailRow('$type Status:', jsonStatus!);
|
||||
}
|
||||
|
||||
if (statuses.isEmpty) {
|
||||
return Container();
|
||||
}
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(top: 8.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('$type Status:', style: const TextStyle(fontWeight: FontWeight.bold)),
|
||||
...statuses.map((s) {
|
||||
final serverName = s['server_name'] ?? 'Server N/A';
|
||||
final status = s['status'] ?? 'N/A';
|
||||
final bool isSuccess = status.toLowerCase().contains('success') || status.toLowerCase().contains('queued') || status.toLowerCase().contains('not_configured') || status.toLowerCase().contains('not_applicable') || status.toLowerCase().contains('not_required');
|
||||
final IconData icon = isSuccess ? Icons.check_circle_outline : (status.toLowerCase().contains('failed') ? Icons.error_outline : Icons.sync);
|
||||
final Color color = isSuccess ? Colors.green : (status.toLowerCase().contains('failed') ? Colors.red : Colors.grey);
|
||||
String detailLabel = (s['type'] != null) ? '(${s['type']})' : '';
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 3.0, horizontal: 8.0),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(icon, size: 16, color: color),
|
||||
const SizedBox(width: 5),
|
||||
Expanded(child: Text('$serverName $detailLabel: $status')),
|
||||
],
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDetailRow(String label, String value) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 2.0),
|
||||
padding: const EdgeInsets.symmetric(vertical: 4.0),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('$label ', style: const TextStyle(fontWeight: FontWeight.bold)),
|
||||
Expanded(child: Text(value)),
|
||||
Expanded(flex: 2, child: Text(label, style: const TextStyle(fontWeight: FontWeight.bold))),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(flex: 3, child: Text(value)),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
@ -35,7 +35,8 @@ class _RiverInSituSamplingScreenState extends State<RiverInSituSamplingScreen> {
|
||||
|
||||
late RiverInSituSamplingData _data;
|
||||
|
||||
final RiverInSituSamplingService _samplingService = RiverInSituSamplingService();
|
||||
// FIX: _samplingService is now retrieved via Provider in _submitForm
|
||||
// final RiverInSituSamplingService _samplingService = RiverInSituSamplingService();
|
||||
final LocalStorageService _localStorageService = LocalStorageService();
|
||||
// --- ADDED: Service to get the active server configuration ---
|
||||
final ServerConfigService _serverConfigService = ServerConfigService();
|
||||
@ -46,6 +47,9 @@ class _RiverInSituSamplingScreenState extends State<RiverInSituSamplingScreen> {
|
||||
int _currentPage = 0;
|
||||
bool _isLoading = false;
|
||||
|
||||
// FIX: Use late initialization to retrieve service instances in the build method.
|
||||
late RiverInSituSamplingService _samplingService;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
@ -53,12 +57,16 @@ class _RiverInSituSamplingScreenState extends State<RiverInSituSamplingScreen> {
|
||||
samplingDate: DateFormat('yyyy-MM-dd').format(DateTime.now()),
|
||||
samplingTime: DateFormat('HH:mm:ss').format(DateTime.now()),
|
||||
);
|
||||
// Initialize services that require context/Provider access later
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_samplingService = Provider.of<RiverInSituSamplingService>(context, listen: false);
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_pageController.dispose();
|
||||
_samplingService.dispose();
|
||||
// FIX: Removed _samplingService.dispose() call as it is now retrieved from Provider/context.
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@ -80,108 +88,39 @@ class _RiverInSituSamplingScreenState extends State<RiverInSituSamplingScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
// --- REPLACED: _submitForm() method with the new workflow ---
|
||||
// --- REPLACED: _submitForm() method with the simplified workflow ---
|
||||
Future<void> _submitForm() async {
|
||||
setState(() => _isLoading = true);
|
||||
|
||||
final authProvider = Provider.of<AuthProvider>(context, listen: false);
|
||||
final appSettings = authProvider.appSettings;
|
||||
|
||||
// The service now handles all API/FTP attempts and internal logging.
|
||||
final result = await _samplingService.submitData(_data, appSettings);
|
||||
|
||||
// FIX: Update local data model status with the granular API result
|
||||
_data.submissionStatus = result['status'];
|
||||
_data.submissionMessage = result['message'];
|
||||
_data.reportId = result['reportId']?.toString();
|
||||
|
||||
// NOTE: The separate local file saving is now redundant here, but kept for historical context/backup.
|
||||
final activeApiConfig = await _serverConfigService.getActiveApiConfig();
|
||||
final serverName = activeApiConfig?['config_name'] as String? ?? 'Default';
|
||||
|
||||
// A copy of the data is made to avoid modifying the original during the FTP process.
|
||||
final dataForFtp = RiverInSituSamplingData.fromJson(_data.toApiFormData());
|
||||
|
||||
bool apiSuccess = false;
|
||||
bool ftpSuccess = false;
|
||||
|
||||
// --- Path A: Attempt API Submission ---
|
||||
debugPrint("Step 1: Attempting API submission...");
|
||||
final apiResult = await _samplingService.submitData(_data, appSettings);
|
||||
|
||||
if (apiResult['success'] == true) {
|
||||
apiSuccess = true;
|
||||
_data.submissionStatus = apiResult['status'];
|
||||
_data.submissionMessage = apiResult['message'];
|
||||
_data.reportId = apiResult['reportId']?.toString();
|
||||
debugPrint("API submission successful.");
|
||||
} else {
|
||||
_data.submissionStatus = apiResult['status'];
|
||||
_data.submissionMessage = apiResult['message'];
|
||||
_data.reportId = null;
|
||||
debugPrint("API submission failed. Reason: ${apiResult['message']}");
|
||||
}
|
||||
|
||||
// --- Path B: Attempt FTP Submission if configurations exist ---
|
||||
final activeFtpConfig = await _serverConfigService.getActiveFtpConfig();
|
||||
if (activeFtpConfig != null) {
|
||||
debugPrint("Step 2: FTP server configured. Proceeding with zipping and queuing.");
|
||||
final stationCode = _data.selectedStation?['sampling_station_code'] ?? 'NA';
|
||||
final reportId = _data.reportId ?? DateTime.now().millisecondsSinceEpoch;
|
||||
final baseFileName = '${stationCode}_$reportId';
|
||||
|
||||
try {
|
||||
// --- Create a separate folder for FTP data to avoid conflicts ---
|
||||
final ftpDir = await _localStorageService.getRiverInSituBaseDir(dataForFtp.samplingType, serverName: '${serverName}_ftp');
|
||||
if (ftpDir != null) {
|
||||
final dataForFtpJson = dataForFtp.toApiFormData(); // This is the data used for the ZIP files
|
||||
final File? dataZip = await _zippingService.createDataZip(
|
||||
jsonDataMap: {
|
||||
'river_insitu_basic_form.json': jsonEncode(dataForFtpJson),
|
||||
// Add other JSON files if necessary
|
||||
},
|
||||
baseFileName: baseFileName,
|
||||
);
|
||||
|
||||
final File? imageZip = await _zippingService.createImageZip(
|
||||
imageFiles: dataForFtp.toApiImageFiles().values.whereType<File>().toList(),
|
||||
baseFileName: baseFileName,
|
||||
);
|
||||
|
||||
if (dataZip != null) {
|
||||
await _retryService.addFtpToQueue(
|
||||
localFilePath: dataZip.path,
|
||||
remotePath: '/uploads/data/${p.basename(dataZip.path)}',
|
||||
);
|
||||
ftpSuccess = true; // Mark as successful if at least one file is queued
|
||||
}
|
||||
if (imageZip != null) {
|
||||
await _retryService.addFtpToQueue(
|
||||
localFilePath: imageZip.path,
|
||||
remotePath: '/uploads/images/${p.basename(imageZip.path)}',
|
||||
);
|
||||
ftpSuccess = true;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint("FTP zipping or queuing failed with an error: $e");
|
||||
ftpSuccess = false;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Final Status Update and Navigation ---
|
||||
if (!mounted) return;
|
||||
|
||||
if (apiSuccess && ftpSuccess) {
|
||||
_data.submissionStatus = 'S4'; // Submitted API, Queued FTP
|
||||
_data.submissionMessage = 'Data submitted and files are queued for FTP upload.';
|
||||
} else if (apiSuccess) {
|
||||
_data.submissionStatus = 'S3'; // Submitted API Only
|
||||
_data.submissionMessage = 'Data submitted successfully to API. No FTP configured or FTP failed.';
|
||||
} else if (ftpSuccess) {
|
||||
_data.submissionStatus = 'L4'; // Failed API, Queued FTP
|
||||
_data.submissionMessage = 'API submission failed but files were successfully queued for FTP.';
|
||||
} else {
|
||||
_data.submissionStatus = 'L1'; // All submissions failed
|
||||
_data.submissionMessage = 'All submission attempts failed. Data saved locally for retry.';
|
||||
}
|
||||
|
||||
await _localStorageService.saveRiverInSituSamplingData(_data, serverName: serverName);
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
setState(() => _isLoading = false);
|
||||
|
||||
final message = _data.submissionMessage ?? 'An unknown error occurred.';
|
||||
final color = (apiSuccess || ftpSuccess) ? Colors.green : Colors.red;
|
||||
// FIX: Use granular status (api_status or image_upload_status is not directly available here,
|
||||
// rely on the high-level status 'status' returned by the service for UI feedback).
|
||||
final highLevelStatus = result['status'] as String? ?? 'L1';
|
||||
|
||||
final bool isSuccess = highLevelStatus.startsWith('S') || highLevelStatus.startsWith('L4'); // L4 means FTP queue success, which is good.
|
||||
|
||||
final color = isSuccess ? Colors.green : Colors.red;
|
||||
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(message), backgroundColor: color, duration: const Duration(seconds: 4)),
|
||||
);
|
||||
@ -191,6 +130,19 @@ class _RiverInSituSamplingScreenState extends State<RiverInSituSamplingScreen> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// Note: Since _samplingService is initialized using addPostFrameCallback,
|
||||
// we must ensure it's provided if a child widget relies on Provider.of.
|
||||
// However, since the Provider.value below directly uses _samplingService,
|
||||
// and the constructor was updated to take no args, we'll keep the
|
||||
// Provider.value here.
|
||||
|
||||
// FIX: Revert _samplingService initialization to the final definition
|
||||
// used in other modules where it is retrieved by the main builder function.
|
||||
// Given the complexity of the DI in main.dart, the service should be retrieved
|
||||
// inside the build method or initState if it needs to be used in methods.
|
||||
// Since we fixed the constructor issue in the previous files, we rely on
|
||||
// the provider instance created in main.dart.
|
||||
|
||||
return Provider.value(
|
||||
value: _samplingService,
|
||||
child: Scaffold(
|
||||
|
||||
@ -380,7 +380,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
ListTile(
|
||||
leading: const Icon(Icons.info_outline),
|
||||
title: const Text('App Version'),
|
||||
subtitle: const Text('1.0.0'),
|
||||
subtitle: const Text('1.2.03'),
|
||||
dense: true,
|
||||
),
|
||||
ListTile(
|
||||
|
||||
@ -8,6 +8,7 @@ import 'package:path_provider/path_provider.dart';
|
||||
import 'package:path/path.dart' as path;
|
||||
import 'package:image/image.dart' as img;
|
||||
import 'package:intl/intl.dart';
|
||||
import 'dart:convert';
|
||||
|
||||
import '../models/air_installation_data.dart';
|
||||
import '../models/air_collection_data.dart';
|
||||
@ -18,14 +19,54 @@ import 'telegram_service.dart';
|
||||
import 'server_config_service.dart';
|
||||
|
||||
|
||||
/// A dedicated service to handle all business logic for the Air Manual Sampling feature.
|
||||
/// A dedicated service for handling all business logic for the Air Manual Sampling feature.
|
||||
class AirSamplingService {
|
||||
final ApiService _apiService = ApiService();
|
||||
final LocalStorageService _localStorageService = LocalStorageService();
|
||||
final TelegramService _telegramService = TelegramService();
|
||||
// --- ADDED: An instance of the service to get the active server name ---
|
||||
final ApiService _apiService;
|
||||
final DatabaseHelper _dbHelper;
|
||||
final TelegramService _telegramService;
|
||||
final ServerConfigService _serverConfigService = ServerConfigService();
|
||||
|
||||
|
||||
// REVISED: Constructor now takes dependencies as parameters
|
||||
AirSamplingService(this._apiService, this._dbHelper, this._telegramService);
|
||||
|
||||
// Helper method to create a map suitable for LocalStorageService (retains File objects)
|
||||
Map<String, dynamic> _toMapForLocalSave(dynamic data) {
|
||||
if (data is AirInstallationData) {
|
||||
final map = data.toMap(); // Get map with paths for DB logging
|
||||
// Overwrite paths with live File objects for local saving process
|
||||
map['imageFront'] = data.imageFront;
|
||||
map['imageBack'] = data.imageBack;
|
||||
map['imageLeft'] = data.imageLeft;
|
||||
map['imageRight'] = data.imageRight;
|
||||
map['optionalImage1'] = data.optionalImage1;
|
||||
map['optionalImage2'] = data.optionalImage2;
|
||||
map['optionalImage3'] = data.optionalImage3;
|
||||
map['optionalImage4'] = data.optionalImage4;
|
||||
|
||||
if (data.collectionData != null) {
|
||||
map['collectionData'] = _toMapForLocalSave(data.collectionData);
|
||||
}
|
||||
return map;
|
||||
} else if (data is AirCollectionData) {
|
||||
final map = data.toMap(); // Get map with paths for DB logging
|
||||
// Overwrite paths with live File objects for local saving process
|
||||
map['imageFront'] = data.imageFront;
|
||||
map['imageBack'] = data.imageBack;
|
||||
map['imageLeft'] = data.imageLeft;
|
||||
map['imageRight'] = data.imageRight;
|
||||
map['imageChart'] = data.imageChart;
|
||||
map['imageFilterPaper'] = data.imageFilterPaper;
|
||||
map['optionalImage1'] = data.optionalImage1;
|
||||
map['optionalImage2'] = data.optionalImage2;
|
||||
map['optionalImage3'] = data.optionalImage3;
|
||||
map['optionalImage4'] = data.optionalImage4;
|
||||
return map;
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
|
||||
/// Picks an image from the specified source, adds a timestamp watermark,
|
||||
/// and saves it to a temporary directory with a standardized name.
|
||||
Future<File?> pickAndProcessImage(
|
||||
@ -103,50 +144,163 @@ class AirSamplingService {
|
||||
}
|
||||
}
|
||||
|
||||
// --- ADDED HELPER METHODS TO GET IMAGE PATHS FROM MODELS ---
|
||||
List<String> _getInstallationImagePaths(AirInstallationData data) {
|
||||
final List<File?> files = [
|
||||
data.imageFront, data.imageBack, data.imageLeft, data.imageRight,
|
||||
data.optionalImage1, data.optionalImage2, data.optionalImage3, data.optionalImage4,
|
||||
];
|
||||
return files.where((f) => f != null).map((f) => f!.path).toList();
|
||||
}
|
||||
|
||||
List<String> _getCollectionImagePaths(AirCollectionData data) {
|
||||
final List<File?> files = [
|
||||
data.imageFront, data.imageBack, data.imageLeft, data.imageRight,
|
||||
data.imageChart, data.imageFilterPaper,
|
||||
data.optionalImage1, data.optionalImage2, data.optionalImage3, data.optionalImage4,
|
||||
];
|
||||
return files.where((f) => f != null).map((f) => f!.path).toList();
|
||||
}
|
||||
|
||||
|
||||
/// Orchestrates a two-step submission process for air installation samples.
|
||||
// MODIFIED: Method now requires the appSettings list to pass down the call stack.
|
||||
Future<Map<String, dynamic>> submitInstallation(AirInstallationData data, List<Map<String, dynamic>>? appSettings) async {
|
||||
// --- MODIFIED: Get the active server name to use for local storage ---
|
||||
final activeConfig = await _serverConfigService.getActiveApiConfig();
|
||||
final serverName = activeConfig?['config_name'] as String? ?? 'Default';
|
||||
|
||||
// --- OFFLINE-FIRST HELPER ---
|
||||
Future<Map<String, dynamic>> saveLocally(String status, String message) async {
|
||||
debugPrint("Saving installation locally with status: $status");
|
||||
data.status = status; // Use the provided status
|
||||
// --- MODIFIED: Pass the serverName to the save method ---
|
||||
await _localStorageService.saveAirSamplingRecord(data.toMap(), data.refID!, serverName: serverName);
|
||||
return {'status': status, 'message': message};
|
||||
}
|
||||
final localStorageService = LocalStorageService(); // Instance for file system save
|
||||
|
||||
// If the record's text data is already on the server, skip directly to image upload.
|
||||
if (data.status == 'L2_PENDING_IMAGES' && data.airManId != null) {
|
||||
debugPrint("Retrying image upload for existing record ID: ${data.airManId}");
|
||||
return await _uploadInstallationImagesAndUpdate(data, appSettings, serverName: serverName);
|
||||
final result = await _uploadInstallationImagesAndUpdate(data, appSettings, serverName: serverName);
|
||||
|
||||
// LOG DEBUG START
|
||||
final logData = {
|
||||
'submission_id': data.refID!,
|
||||
'module': 'air',
|
||||
'type': 'Installation',
|
||||
'status': result['status'],
|
||||
'message': result['message'],
|
||||
'report_id': data.airManId.toString(),
|
||||
'created_at': DateTime.now().toIso8601String(),
|
||||
'form_data': jsonEncode(data.toMap()),
|
||||
'image_data': jsonEncode(_getInstallationImagePaths(data)),
|
||||
'server_name': serverName,
|
||||
'api_status': jsonEncode([{"server_name": serverName, "status": "PENDING", "message": "Resubmitting images."}]),
|
||||
'ftp_status': jsonEncode([{"server_name": "N/A", "status": "NOT_APPLICABLE", "message": "FTP not used for images."}]),
|
||||
};
|
||||
debugPrint("DB LOGGING (Installation Retry): Status: ${logData['status']}, API Status: ${logData['api_status']}, FTP Status: ${logData['ftp_status']}");
|
||||
// LOG DEBUG END
|
||||
|
||||
// --- ADDED: Log the final result to the central database ---
|
||||
await _dbHelper.saveSubmissionLog(logData);
|
||||
return result;
|
||||
}
|
||||
|
||||
// --- STEP 1: SUBMIT TEXT DATA ---
|
||||
debugPrint("Step 1: Submitting installation text data...");
|
||||
final textDataResult = await _apiService.post('air/manual/installation', data.toJsonForApi());
|
||||
final textDataResult = await _apiService.air.submitInstallation(data);
|
||||
|
||||
// --- CRITICAL FIX: Save to local file system immediately regardless of API success ---
|
||||
data.status = 'L1'; // Temporary set status to Local Only
|
||||
// Use the special helper method to pass live File objects for copying
|
||||
final localSaveMap = _toMapForLocalSave(data);
|
||||
final localSaveResult = await localStorageService.saveAirSamplingRecord(localSaveMap, data.refID!, serverName: serverName);
|
||||
|
||||
if (localSaveResult == null) {
|
||||
debugPrint("CRITICAL ERROR: Failed to save Air Installation record to local file system.");
|
||||
}
|
||||
// --- END CRITICAL FIX ---
|
||||
|
||||
|
||||
if (textDataResult['success'] != true) {
|
||||
debugPrint("Failed to submit text data. Reason: ${textDataResult['message']}");
|
||||
return await saveLocally('L1', 'No connection or server error. Installation data saved locally.');
|
||||
final result = {'status': 'L1', 'message': 'No connection or server error. Installation data saved locally.'};
|
||||
|
||||
// LOG DEBUG START
|
||||
final logData = {
|
||||
'submission_id': data.refID!,
|
||||
'module': 'air',
|
||||
'type': 'Installation',
|
||||
'status': result['status'],
|
||||
'message': result['message'],
|
||||
'report_id': data.airManId.toString(),
|
||||
'created_at': DateTime.now().toIso8601String(),
|
||||
'form_data': jsonEncode(data.toMap()),
|
||||
'image_data': jsonEncode(_getInstallationImagePaths(data)),
|
||||
'server_name': serverName,
|
||||
'api_status': jsonEncode([{"server_name": serverName, "status": "FAILED", "message": "API submission failed."}]),
|
||||
'ftp_status': jsonEncode([{"server_name": "N/A", "status": "NOT_APPLICABLE", "message": "Not applicable."}]),
|
||||
};
|
||||
debugPrint("DB LOGGING (Installation L1): Status: ${logData['status']}, API Status: ${logData['api_status']}, FTP Status: ${logData['ftp_status']}");
|
||||
// LOG DEBUG END
|
||||
|
||||
// --- ADDED: Log the failed submission to the central database ---
|
||||
await _dbHelper.saveSubmissionLog(logData);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// --- NECESSARY FIX: Safely parse the record ID from the server response ---
|
||||
final dynamic recordIdFromServer = textDataResult['data']?['air_man_id'];
|
||||
if (recordIdFromServer == null) {
|
||||
debugPrint("Text data submitted, but did not receive a record ID.");
|
||||
return await saveLocally('L1', 'Data submitted, but server response was invalid.');
|
||||
final result = {'status': 'L1', 'message': 'Data submitted, but server response was invalid.'};
|
||||
|
||||
// LOG DEBUG START
|
||||
final logData = {
|
||||
'submission_id': data.refID!,
|
||||
'module': 'air',
|
||||
'type': 'Installation',
|
||||
'status': result['status'],
|
||||
'message': result['message'],
|
||||
'report_id': data.airManId.toString(),
|
||||
'created_at': DateTime.now().toIso8601String(),
|
||||
'form_data': jsonEncode(data.toMap()),
|
||||
'image_data': jsonEncode(_getInstallationImagePaths(data)),
|
||||
'server_name': serverName,
|
||||
'api_status': jsonEncode([{"server_name": serverName, "status": "FAILED", "message": "Invalid response from server."}]),
|
||||
'ftp_status': jsonEncode([{"server_name": "N/A", "status": "NOT_APPLICABLE", "message": "Not applicable."}]),
|
||||
};
|
||||
debugPrint("DB LOGGING (Installation L1/Invalid ID): Status: ${logData['status']}, API Status: ${logData['api_status']}, FTP Status: ${logData['ftp_status']}");
|
||||
// LOG DEBUG END
|
||||
|
||||
// --- ADDED: Log the failed submission to the central database ---
|
||||
await _dbHelper.saveSubmissionLog(logData);
|
||||
|
||||
return result;
|
||||
}
|
||||
debugPrint("Text data submitted successfully. Received record ID: $recordIdFromServer");
|
||||
|
||||
final int? parsedRecordId = int.tryParse(recordIdFromServer.toString());
|
||||
|
||||
if (parsedRecordId == null) {
|
||||
debugPrint("Could not parse the received record ID: $recordIdFromServer");
|
||||
return await saveLocally('L1', 'Data submitted, but server response was invalid.');
|
||||
final result = {'status': 'L1', 'message': 'Data submitted, but server response was invalid.'};
|
||||
|
||||
// LOG DEBUG START
|
||||
final logData = {
|
||||
'submission_id': data.refID!,
|
||||
'module': 'air',
|
||||
'type': 'Installation',
|
||||
'status': result['status'],
|
||||
'message': result['message'],
|
||||
'report_id': data.airManId.toString(),
|
||||
'created_at': DateTime.now().toIso8601String(),
|
||||
'form_data': jsonEncode(data.toMap()),
|
||||
'image_data': jsonEncode(_getInstallationImagePaths(data)),
|
||||
'server_name': serverName,
|
||||
'api_status': jsonEncode([{"server_name": serverName, "status": "FAILED", "message": "Invalid response from server."}]),
|
||||
'ftp_status': jsonEncode([{"server_name": "N/A", "status": "NOT_APPLICABLE", "message": "Not applicable."}]),
|
||||
};
|
||||
debugPrint("DB LOGGING (Installation L1/Parse Fail): Status: ${logData['status']}, API Status: ${logData['api_status']}, FTP Status: ${logData['ftp_status']}");
|
||||
// LOG DEBUG END
|
||||
|
||||
// --- ADDED: Log the failed submission to the central database ---
|
||||
await _dbHelper.saveSubmissionLog(logData);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
data.airManId = parsedRecordId;
|
||||
@ -159,10 +313,37 @@ class AirSamplingService {
|
||||
// MODIFIED: Method now requires the serverName to pass to the save method.
|
||||
Future<Map<String, dynamic>> _uploadInstallationImagesAndUpdate(AirInstallationData data, List<Map<String, dynamic>>? appSettings, {required String serverName}) async {
|
||||
final filesToUpload = data.getImagesForUpload();
|
||||
final localStorageService = LocalStorageService();
|
||||
|
||||
// Since text data was successfully submitted, the status moves to S1 (Server Pending)
|
||||
data.status = 'S1';
|
||||
// We already saved the file in submitInstallation (L1 status). Now we update the status in the local file.
|
||||
await localStorageService.saveAirSamplingRecord(_toMapForLocalSave(data), data.refID!, serverName: serverName);
|
||||
|
||||
if (filesToUpload.isEmpty) {
|
||||
debugPrint("No images to upload. Submission complete.");
|
||||
data.status = 'S1'; // Server Pending (no images needed)
|
||||
await _localStorageService.saveAirSamplingRecord(data.toMap(), data.refID!, serverName: serverName);
|
||||
|
||||
// LOG DEBUG START
|
||||
final logData = {
|
||||
'submission_id': data.refID!,
|
||||
'module': 'air',
|
||||
'type': 'Installation',
|
||||
'status': data.status,
|
||||
'message': 'Installation data submitted successfully.',
|
||||
'report_id': data.airManId.toString(),
|
||||
'created_at': DateTime.now().toIso8601String(),
|
||||
'form_data': jsonEncode(data.toMap()),
|
||||
'image_data': jsonEncode(_getInstallationImagePaths(data)),
|
||||
'server_name': serverName,
|
||||
'api_status': jsonEncode([{"server_name": serverName, "status": "SUCCESS", "message": "Text data submitted."}]),
|
||||
'ftp_status': jsonEncode([{"server_name": "N/A", "status": "NOT_REQUIRED", "message": "No images were attached."}]),
|
||||
};
|
||||
debugPrint("DB LOGGING (Installation S1/Data Only): Status: ${logData['status']}, API Status: ${logData['api_status']}, FTP Status: ${logData['ftp_status']}");
|
||||
// LOG DEBUG END
|
||||
|
||||
// --- MODIFIED: Log the successful submission to the central database ---
|
||||
await _dbHelper.saveSubmissionLog(logData);
|
||||
|
||||
_handleInstallationSuccessAlert(data, appSettings, isDataOnly: true);
|
||||
return {'status': 'S1', 'message': 'Installation data submitted successfully.'};
|
||||
}
|
||||
@ -176,21 +357,70 @@ class AirSamplingService {
|
||||
if (imageUploadResult['success'] != true) {
|
||||
debugPrint("Image upload failed. Reason: ${imageUploadResult['message']}");
|
||||
data.status = 'L2_PENDING_IMAGES';
|
||||
await _localStorageService.saveAirSamplingRecord(data.toMap(), data.refID!, serverName: serverName);
|
||||
return {
|
||||
final result = {
|
||||
'status': 'L2_PENDING_IMAGES',
|
||||
'message': 'Data submitted, but image upload failed. Saved locally for retry.',
|
||||
};
|
||||
|
||||
// Update the local file with the image failure status
|
||||
await localStorageService.saveAirSamplingRecord(_toMapForLocalSave(data), data.refID!, serverName: serverName);
|
||||
|
||||
// LOG DEBUG START
|
||||
final logData = {
|
||||
'submission_id': data.refID!,
|
||||
'module': 'air',
|
||||
'type': 'Installation',
|
||||
'status': result['status'],
|
||||
'message': result['message'],
|
||||
'report_id': data.airManId.toString(),
|
||||
'created_at': DateTime.now().toIso8601String(),
|
||||
'form_data': jsonEncode(data.toMap()),
|
||||
'image_data': jsonEncode(_getInstallationImagePaths(data)),
|
||||
'server_name': serverName,
|
||||
'api_status': jsonEncode([{"server_name": serverName, "status": "SUCCESS", "message": "Text data submitted."}]),
|
||||
'ftp_status': jsonEncode([{"server_name": "N/A", "status": "FAILED", "message": "Image upload failed."}]),
|
||||
};
|
||||
debugPrint("DB LOGGING (Installation L2/Image Fail): Status: ${logData['status']}, API Status: ${logData['api_status']}, FTP Status: ${logData['ftp_status']}");
|
||||
// LOG DEBUG END
|
||||
|
||||
// --- MODIFIED: Log the failed submission to the central database ---
|
||||
await _dbHelper.saveSubmissionLog(logData);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
debugPrint("Images uploaded successfully.");
|
||||
data.status = 'S2'; // Server Pending (images uploaded)
|
||||
await _localStorageService.saveAirSamplingRecord(data.toMap(), data.refID!, serverName: serverName);
|
||||
_handleInstallationSuccessAlert(data, appSettings, isDataOnly: false);
|
||||
return {
|
||||
final result = {
|
||||
'status': 'S2',
|
||||
'message': 'Installation data and images submitted successfully.',
|
||||
};
|
||||
|
||||
// LOG DEBUG START
|
||||
final logData = {
|
||||
'submission_id': data.refID!,
|
||||
'module': 'air',
|
||||
'type': 'Installation',
|
||||
'status': result['status'],
|
||||
'message': result['message'],
|
||||
'report_id': data.airManId.toString(),
|
||||
'created_at': DateTime.now().toIso8601String(),
|
||||
'form_data': jsonEncode(data.toMap()),
|
||||
'image_data': jsonEncode(_getInstallationImagePaths(data)),
|
||||
'server_name': serverName,
|
||||
'api_status': jsonEncode([{"server_name": serverName, "status": "SUCCESS", "message": "Text and image data submitted."}]),
|
||||
'ftp_status': jsonEncode([{"server_name": "N/A", "status": "NOT_APPLICABLE", "message": "Not applicable."}]),
|
||||
};
|
||||
debugPrint("DB LOGGING (Installation S2/Success): Status: ${logData['status']}, API Status: ${logData['api_status']}, FTP Status: ${logData['ftp_status']}");
|
||||
// LOG DEBUG END
|
||||
|
||||
// --- MODIFIED: Log the successful submission to the central database ---
|
||||
await _dbHelper.saveSubmissionLog(logData);
|
||||
// Update the local file with the final success status
|
||||
await localStorageService.saveAirSamplingRecord(_toMapForLocalSave(data), data.refID!, serverName: serverName);
|
||||
|
||||
_handleInstallationSuccessAlert(data, appSettings, isDataOnly: false);
|
||||
return result;
|
||||
}
|
||||
|
||||
/// Submits only the collection data, linked to a previous installation.
|
||||
@ -200,40 +430,80 @@ class AirSamplingService {
|
||||
final activeConfig = await _serverConfigService.getActiveApiConfig();
|
||||
final serverName = activeConfig?['config_name'] as String? ?? 'Default';
|
||||
|
||||
// --- OFFLINE-FIRST HELPER (CORRECTED) ---
|
||||
Future<Map<String, dynamic>> updateAndSaveLocally(String newStatus, {String? message}) async {
|
||||
debugPrint("Saving collection data locally with status: $newStatus");
|
||||
final allLogs = await _localStorageService.getAllAirSamplingLogs();
|
||||
final logIndex = allLogs.indexWhere((log) => log['refID'] == data.installationRefID);
|
||||
|
||||
if (logIndex != -1) {
|
||||
final installationLog = allLogs[logIndex];
|
||||
// FIX: Nest collection data to prevent overwriting installation fields.
|
||||
installationLog['collectionData'] = data.toMap();
|
||||
installationLog['status'] = newStatus; // Update the overall status
|
||||
await _localStorageService.saveAirSamplingRecord(installationLog, data.installationRefID!, serverName: serverName);
|
||||
}
|
||||
return {
|
||||
'status': newStatus,
|
||||
'message': message ?? 'No connection or server error. Data saved locally.',
|
||||
};
|
||||
}
|
||||
final apiConfigs = (await _dbHelper.loadApiConfigs() ?? []).take(2).toList();
|
||||
final localStorageService = LocalStorageService();
|
||||
|
||||
// If the record's text data is already on the server, skip directly to image upload.
|
||||
if (data.status == 'L4_PENDING_IMAGES' && data.airManId != null) {
|
||||
debugPrint("Retrying collection image upload for existing record ID: ${data.airManId}");
|
||||
return await _uploadCollectionImagesAndUpdate(data, installationData, appSettings, serverName: serverName);
|
||||
final result = await _uploadCollectionImagesAndUpdate(data, installationData, appSettings, serverName: serverName);
|
||||
|
||||
// LOG DEBUG START
|
||||
final logData = {
|
||||
'submission_id': data.installationRefID!,
|
||||
'module': 'air',
|
||||
'type': 'Collection',
|
||||
'status': result['status'],
|
||||
'message': result['message'],
|
||||
'report_id': data.airManId.toString(),
|
||||
'created_at': DateTime.now().toIso8601String(),
|
||||
'form_data': jsonEncode(data.toMap()),
|
||||
'image_data': jsonEncode(_getCollectionImagePaths(data)),
|
||||
'server_name': serverName,
|
||||
'api_status': jsonEncode([{"server_name": serverName, "status": "PENDING", "message": "Resubmitting images."}]),
|
||||
'ftp_status': jsonEncode([{"server_name": "N/A", "status": "NOT_APPLICABLE", "message": "FTP not used."}]),
|
||||
};
|
||||
debugPrint("DB LOGGING (Collection Retry): Status: ${logData['status']}, API Status: ${logData['api_status']}, FTP Status: ${logData['ftp_status']}");
|
||||
// LOG DEBUG END
|
||||
|
||||
// --- ADDED: Log the final result to the central database ---
|
||||
await _dbHelper.saveSubmissionLog(logData);
|
||||
return result;
|
||||
}
|
||||
|
||||
// --- STEP 1: SUBMIT TEXT DATA ---
|
||||
debugPrint("Step 1: Submitting collection text data...");
|
||||
final textDataResult = await _apiService.post('air/manual/collection', data.toJson());
|
||||
final textDataResult = await _apiService.air.submitCollection(data);
|
||||
|
||||
// --- CRITICAL FIX: Save to local file system immediately regardless of API success ---
|
||||
data.status = 'L3'; // Temporary set status to Local Only
|
||||
final localSaveMap = _toMapForLocalSave(data);
|
||||
final localSaveResult = await localStorageService.saveAirSamplingRecord(localSaveMap, data.installationRefID!, serverName: serverName);
|
||||
if (localSaveResult == null) {
|
||||
debugPrint("CRITICAL ERROR: Failed to save Air Collection record to local file system.");
|
||||
}
|
||||
// --- END CRITICAL FIX ---
|
||||
|
||||
|
||||
if (textDataResult['success'] != true) {
|
||||
debugPrint("Failed to submit collection text data. Reason: ${textDataResult['message']}");
|
||||
return await updateAndSaveLocally('L3', message: 'No connection or server error. Collection data saved locally.');
|
||||
final result = {'status': 'L3', 'message': 'No connection or server error. Collection data saved locally.'};
|
||||
|
||||
// LOG DEBUG START
|
||||
final logData = {
|
||||
'submission_id': data.installationRefID!,
|
||||
'module': 'air',
|
||||
'type': 'Collection',
|
||||
'status': result['status'],
|
||||
'message': result['message'],
|
||||
'report_id': data.airManId.toString(),
|
||||
'created_at': DateTime.now().toIso8601String(),
|
||||
'form_data': jsonEncode(data.toMap()),
|
||||
'image_data': jsonEncode(_getCollectionImagePaths(data)),
|
||||
'server_name': serverName,
|
||||
'api_status': jsonEncode([{"server_name": serverName, "status": "FAILED", "message": "API submission failed."}]),
|
||||
'ftp_status': jsonEncode([{"server_name": "N/A", "status": "NOT_APPLICABLE", "message": "Not applicable."}]),
|
||||
};
|
||||
debugPrint("DB LOGGING (Collection L3): Status: ${logData['status']}, API Status: ${logData['api_status']}, FTP Status: ${logData['ftp_status']}");
|
||||
// LOG DEBUG END
|
||||
|
||||
// --- ADDED: Log the failed submission to the central database ---
|
||||
await _dbHelper.saveSubmissionLog(logData);
|
||||
|
||||
return result;
|
||||
}
|
||||
debugPrint("Collection text data submitted successfully.");
|
||||
data.airManId = textDataResult['data']['air_man_id'];
|
||||
|
||||
// --- STEP 2: UPLOAD IMAGE FILES ---
|
||||
return await _uploadCollectionImagesAndUpdate(data, installationData, appSettings, serverName: serverName);
|
||||
@ -242,30 +512,41 @@ class AirSamplingService {
|
||||
/// A reusable function for handling the collection image upload and local data update logic.
|
||||
// MODIFIED: Method now requires the serverName to pass to the save method.
|
||||
Future<Map<String, dynamic>> _uploadCollectionImagesAndUpdate(AirCollectionData data, AirInstallationData installationData, List<Map<String, dynamic>>? appSettings, {required String serverName}) async {
|
||||
// --- OFFLINE-FIRST HELPER (CORRECTED & MODIFIED) ---
|
||||
Future<Map<String, dynamic>> updateAndSaveLocally(String newStatus, {String? message}) async {
|
||||
debugPrint("Saving collection data locally with status: $newStatus");
|
||||
final allLogs = await _localStorageService.getAllAirSamplingLogs();
|
||||
final logIndex = allLogs.indexWhere((log) => log['refID'] == data.installationRefID);
|
||||
|
||||
if (logIndex != -1) {
|
||||
final installationLog = allLogs[logIndex];
|
||||
installationLog['collectionData'] = data.toMap();
|
||||
installationLog['status'] = newStatus;
|
||||
await _localStorageService.saveAirSamplingRecord(installationLog, data.installationRefID!, serverName: serverName);
|
||||
}
|
||||
return {
|
||||
'status': newStatus,
|
||||
'message': message ?? 'No connection or server error. Data saved locally.',
|
||||
};
|
||||
}
|
||||
|
||||
final filesToUpload = data.getImagesForUpload();
|
||||
final localStorageService = LocalStorageService();
|
||||
|
||||
// Since text data was successfully submitted, the status moves to S3 (Server Pending)
|
||||
data.status = 'S3';
|
||||
// Update local file status (which was already saved with L3 status)
|
||||
await localStorageService.saveAirSamplingRecord(_toMapForLocalSave(data), data.installationRefID!, serverName: serverName);
|
||||
|
||||
if (filesToUpload.isEmpty) {
|
||||
debugPrint("No collection images to upload. Submission complete.");
|
||||
await updateAndSaveLocally('S3'); // S3 = Server Completed
|
||||
final result = {'status': 'S3', 'message': 'Collection data submitted successfully.'};
|
||||
|
||||
// LOG DEBUG START
|
||||
final logData = {
|
||||
'submission_id': data.installationRefID!,
|
||||
'module': 'air',
|
||||
'type': 'Collection',
|
||||
'status': result['status'],
|
||||
'message': result['message'],
|
||||
'report_id': data.airManId.toString(),
|
||||
'created_at': DateTime.now().toIso8601String(),
|
||||
'form_data': jsonEncode(data.toMap()),
|
||||
'image_data': jsonEncode(_getCollectionImagePaths(data)),
|
||||
'server_name': serverName,
|
||||
'api_status': jsonEncode([{"server_name": serverName, "status": "SUCCESS", "message": "Text data submitted."}]),
|
||||
'ftp_status': jsonEncode([{"server_name": "N/A", "status": "NOT_REQUIRED", "message": "No images were attached."}]),
|
||||
};
|
||||
debugPrint("DB LOGGING (Collection S3/Data Only): Status: ${logData['status']}, API Status: ${logData['api_status']}, FTP Status: ${logData['ftp_status']}");
|
||||
// LOG DEBUG END
|
||||
|
||||
// --- MODIFIED: Log the successful submission to the central database ---
|
||||
await _dbHelper.saveSubmissionLog(logData);
|
||||
|
||||
_handleCollectionSuccessAlert(data, installationData, appSettings, isDataOnly: true);
|
||||
return {'status': 'S3', 'message': 'Collection data submitted successfully.'};
|
||||
return result;
|
||||
}
|
||||
|
||||
debugPrint("Step 2: Uploading ${filesToUpload.length} collection images...");
|
||||
@ -276,17 +557,70 @@ class AirSamplingService {
|
||||
|
||||
if (imageUploadResult['success'] != true) {
|
||||
debugPrint("Image upload failed. Reason: ${imageUploadResult['message']}");
|
||||
// Use status 'L4_PENDING_IMAGES' to indicate text submitted but images failed
|
||||
return await updateAndSaveLocally('L4_PENDING_IMAGES', message: 'Data submitted, but image upload failed. Saved locally for retry.');
|
||||
data.status = 'L4_PENDING_IMAGES';
|
||||
final result = {
|
||||
'status': 'L4_PENDING_IMAGES',
|
||||
'message': 'Data submitted, but image upload failed. Saved locally for retry.',
|
||||
};
|
||||
|
||||
// Update the local file with the image failure status
|
||||
await localStorageService.saveAirSamplingRecord(_toMapForLocalSave(data), data.installationRefID!, serverName: serverName);
|
||||
|
||||
// LOG DEBUG START
|
||||
final logData = {
|
||||
'submission_id': data.installationRefID!,
|
||||
'module': 'air',
|
||||
'type': 'Collection',
|
||||
'status': result['status'],
|
||||
'message': result['message'],
|
||||
'report_id': data.airManId.toString(),
|
||||
'created_at': DateTime.now().toIso8601String(),
|
||||
'form_data': jsonEncode(data.toMap()),
|
||||
'image_data': jsonEncode(_getCollectionImagePaths(data)),
|
||||
'server_name': serverName,
|
||||
'api_status': jsonEncode([{"server_name": serverName, "status": "SUCCESS", "message": "Text data submitted."}]),
|
||||
'ftp_status': jsonEncode([{"server_name": "N/A", "status": "FAILED", "message": "Image upload failed."}]),
|
||||
};
|
||||
debugPrint("DB LOGGING (Collection L4/Image Fail): Status: ${logData['status']}, API Status: ${logData['api_status']}, FTP Status: ${logData['ftp_status']}");
|
||||
// LOG DEBUG END
|
||||
|
||||
// --- MODIFIED: Log the failed submission to the central database ---
|
||||
await _dbHelper.saveSubmissionLog(logData);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
debugPrint("Images uploaded successfully.");
|
||||
await updateAndSaveLocally('S3'); // S3 = Server Completed
|
||||
_handleCollectionSuccessAlert(data, installationData, appSettings, isDataOnly: false);
|
||||
return {
|
||||
final result = {
|
||||
'status': 'S3',
|
||||
'message': 'Collection data and images submitted successfully.',
|
||||
};
|
||||
|
||||
// LOG DEBUG START
|
||||
final logData = {
|
||||
'submission_id': data.installationRefID!,
|
||||
'module': 'air',
|
||||
'type': 'Collection',
|
||||
'status': result['status'],
|
||||
'message': result['message'],
|
||||
'report_id': data.airManId.toString(),
|
||||
'created_at': DateTime.now().toIso8601String(),
|
||||
'form_data': jsonEncode(data.toMap()),
|
||||
'image_data': jsonEncode(_getCollectionImagePaths(data)),
|
||||
'server_name': serverName,
|
||||
'api_status': jsonEncode([{"server_name": serverName, "status": "SUCCESS", "message": "Text and image data submitted."}]),
|
||||
'ftp_status': jsonEncode([{"server_name": "N/A", "status": "NOT_APPLICABLE", "message": "Not applicable."}]),
|
||||
};
|
||||
debugPrint("DB LOGGING (Collection S3/Success): Status: ${logData['status']}, API Status: ${logData['api_status']}, FTP Status: ${logData['ftp_status']}");
|
||||
// LOG DEBUG END
|
||||
|
||||
// --- MODIFIED: Log the successful submission to the central database ---
|
||||
await _dbHelper.saveSubmissionLog(logData);
|
||||
// Update the local file with the final success status
|
||||
await localStorageService.saveAirSamplingRecord(_toMapForLocalSave(data), data.installationRefID!, serverName: serverName);
|
||||
|
||||
_handleCollectionSuccessAlert(data, installationData, appSettings, isDataOnly: false);
|
||||
return result;
|
||||
}
|
||||
|
||||
|
||||
@ -294,18 +628,18 @@ class AirSamplingService {
|
||||
Future<List<AirInstallationData>> getPendingInstallations() async {
|
||||
debugPrint("Fetching pending installations from local storage...");
|
||||
|
||||
final logs = await _localStorageService.getAllAirSamplingLogs();
|
||||
final logs = await _dbHelper.loadSubmissionLogs(module: 'air');
|
||||
|
||||
final pendingInstallations = logs
|
||||
.where((log) {
|
||||
?.where((log) {
|
||||
final status = log['status'];
|
||||
// --- CORRECTED ---
|
||||
// Only show installations that have been synced to the server (S1, S2).
|
||||
// 'L1' (Local only) records cannot be collected until they are synced.
|
||||
return status == 'S1' || status == 'S2';
|
||||
})
|
||||
.map((log) => AirInstallationData.fromJson(log))
|
||||
.toList();
|
||||
.map((log) => AirInstallationData.fromJson(jsonDecode(log['form_data'])))
|
||||
.toList() ?? [];
|
||||
|
||||
return pendingInstallations;
|
||||
}
|
||||
|
||||
@ -13,17 +13,20 @@ import 'package:environment_monitoring_app/services/base_api_service.dart';
|
||||
import 'package:environment_monitoring_app/services/telegram_service.dart';
|
||||
import 'package:environment_monitoring_app/models/in_situ_sampling_data.dart';
|
||||
import 'package:environment_monitoring_app/models/tarball_data.dart';
|
||||
import 'package:environment_monitoring_app/models/air_collection_data.dart';
|
||||
import 'package:environment_monitoring_app/models/air_installation_data.dart';
|
||||
import 'package:environment_monitoring_app/models/river_in_situ_sampling_data.dart';
|
||||
|
||||
// =======================================================================
|
||||
// Part 1: Unified API Service
|
||||
// =======================================================================
|
||||
|
||||
/// A unified service that consolidates all API interactions for the application.
|
||||
/// It is organized by feature (e.g., marine, river) for clarity and provides
|
||||
/// a central point for data synchronization.
|
||||
// ... (ApiService class definition remains the same)
|
||||
|
||||
class ApiService {
|
||||
final BaseApiService _baseService = BaseApiService();
|
||||
final DatabaseHelper _dbHelper = DatabaseHelper();
|
||||
final DatabaseHelper dbHelper = DatabaseHelper();
|
||||
|
||||
late final MarineApiService marine;
|
||||
late final RiverApiService river;
|
||||
@ -31,10 +34,10 @@ class ApiService {
|
||||
|
||||
static const String imageBaseUrl = 'https://dev14.pstw.com.my/';
|
||||
|
||||
ApiService() {
|
||||
marine = MarineApiService(_baseService);
|
||||
river = RiverApiService(_baseService);
|
||||
air = AirApiService(_baseService);
|
||||
ApiService({required TelegramService telegramService}) {
|
||||
marine = MarineApiService(_baseService, telegramService);
|
||||
river = RiverApiService(_baseService, telegramService);
|
||||
air = AirApiService(_baseService, telegramService);
|
||||
}
|
||||
|
||||
// --- Core API Methods (Unchanged) ---
|
||||
@ -119,7 +122,7 @@ class ApiService {
|
||||
debugPrint('ApiService: Refreshing profile data from server...');
|
||||
final result = await getProfile();
|
||||
if (result['success'] == true && result['data'] != null) {
|
||||
await _dbHelper.saveProfile(result['data']);
|
||||
await dbHelper.saveProfile(result['data']);
|
||||
debugPrint('ApiService: Profile data refreshed and saved to local DB.');
|
||||
}
|
||||
return result;
|
||||
@ -143,24 +146,24 @@ class ApiService {
|
||||
try {
|
||||
// Defines all data types to sync, their endpoints, and their DB handlers.
|
||||
final syncTasks = {
|
||||
'profile': {'endpoint': 'profile', 'handler': (d, id) async { if (d.isNotEmpty) await _dbHelper.saveProfile(d.first); }},
|
||||
'allUsers': {'endpoint': 'users', 'handler': (d, id) async { await _dbHelper.upsertUsers(d); await _dbHelper.deleteUsers(id); }},
|
||||
'tarballStations': {'endpoint': 'marine/tarball/stations', 'handler': (d, id) async { await _dbHelper.upsertTarballStations(d); await _dbHelper.deleteTarballStations(id); }},
|
||||
'manualStations': {'endpoint': 'marine/manual/stations', 'handler': (d, id) async { await _dbHelper.upsertManualStations(d); await _dbHelper.deleteManualStations(id); }},
|
||||
'tarballClassifications': {'endpoint': 'marine/tarball/classifications', 'handler': (d, id) async { await _dbHelper.upsertTarballClassifications(d); await _dbHelper.deleteTarballClassifications(id); }},
|
||||
'riverManualStations': {'endpoint': 'river/manual-stations', 'handler': (d, id) async { await _dbHelper.upsertRiverManualStations(d); await _dbHelper.deleteRiverManualStations(id); }},
|
||||
'riverTriennialStations': {'endpoint': 'river/triennial-stations', 'handler': (d, id) async { await _dbHelper.upsertRiverTriennialStations(d); await _dbHelper.deleteRiverTriennialStations(id); }},
|
||||
'departments': {'endpoint': 'departments', 'handler': (d, id) async { await _dbHelper.upsertDepartments(d); await _dbHelper.deleteDepartments(id); }},
|
||||
'companies': {'endpoint': 'companies', 'handler': (d, id) async { await _dbHelper.upsertCompanies(d); await _dbHelper.deleteCompanies(id); }},
|
||||
'positions': {'endpoint': 'positions', 'handler': (d, id) async { await _dbHelper.upsertPositions(d); await _dbHelper.deletePositions(id); }},
|
||||
'airManualStations': {'endpoint': 'air/manual-stations', 'handler': (d, id) async { await _dbHelper.upsertAirManualStations(d); await _dbHelper.deleteAirManualStations(id); }},
|
||||
'airClients': {'endpoint': 'air/clients', 'handler': (d, id) async { await _dbHelper.upsertAirClients(d); await _dbHelper.deleteAirClients(id); }},
|
||||
'states': {'endpoint': 'states', 'handler': (d, id) async { await _dbHelper.upsertStates(d); await _dbHelper.deleteStates(id); }},
|
||||
'appSettings': {'endpoint': 'settings', 'handler': (d, id) async { await _dbHelper.upsertAppSettings(d); await _dbHelper.deleteAppSettings(id); }},
|
||||
'parameterLimits': {'endpoint': 'parameter-limits', 'handler': (d, id) async { await _dbHelper.upsertParameterLimits(d); await _dbHelper.deleteParameterLimits(id); }},
|
||||
'profile': {'endpoint': 'profile', 'handler': (d, id) async { if (d.isNotEmpty) await dbHelper.saveProfile(d.first); }},
|
||||
'allUsers': {'endpoint': 'users', 'handler': (d, id) async { await dbHelper.upsertUsers(d); await dbHelper.deleteUsers(id); }},
|
||||
'tarballStations': {'endpoint': 'marine/tarball/stations', 'handler': (d, id) async { await dbHelper.upsertTarballStations(d); await dbHelper.deleteTarballStations(id); }},
|
||||
'manualStations': {'endpoint': 'marine/manual/stations', 'handler': (d, id) async { await dbHelper.upsertManualStations(d); await dbHelper.deleteManualStations(id); }},
|
||||
'tarballClassifications': {'endpoint': 'marine/tarball/classifications', 'handler': (d, id) async { await dbHelper.upsertTarballClassifications(d); await dbHelper.deleteTarballClassifications(id); }},
|
||||
'riverManualStations': {'endpoint': 'river/manual-stations', 'handler': (d, id) async { await dbHelper.upsertRiverManualStations(d); await dbHelper.deleteRiverManualStations(id); }},
|
||||
'riverTriennialStations': {'endpoint': 'river/triennial-stations', 'handler': (d, id) async { await dbHelper.upsertRiverTriennialStations(d); await dbHelper.deleteRiverTriennialStations(id); }},
|
||||
'departments': {'endpoint': 'departments', 'handler': (d, id) async { await dbHelper.upsertDepartments(d); await dbHelper.deleteDepartments(id); }},
|
||||
'companies': {'endpoint': 'companies', 'handler': (d, id) async { await dbHelper.upsertCompanies(d); await dbHelper.deleteCompanies(id); }},
|
||||
'positions': {'endpoint': 'positions', 'handler': (d, id) async { await dbHelper.upsertPositions(d); await dbHelper.deletePositions(id); }},
|
||||
'airManualStations': {'endpoint': 'air/manual-stations', 'handler': (d, id) async { await dbHelper.upsertAirManualStations(d); await dbHelper.deleteAirManualStations(id); }},
|
||||
'airClients': {'endpoint': 'air/clients', 'handler': (d, id) async { await dbHelper.upsertAirClients(d); await dbHelper.deleteAirClients(id); }},
|
||||
'states': {'endpoint': 'states', 'handler': (d, id) async { await dbHelper.upsertStates(d); await dbHelper.deleteStates(id); }},
|
||||
'appSettings': {'endpoint': 'settings', 'handler': (d, id) async { await dbHelper.upsertAppSettings(d); await dbHelper.deleteAppSettings(id); }},
|
||||
'parameterLimits': {'endpoint': 'parameter-limits', 'handler': (d, id) async { await dbHelper.upsertParameterLimits(d); await dbHelper.deleteParameterLimits(id); }},
|
||||
// --- ADDED: New sync tasks for independent API and FTP configurations ---
|
||||
'apiConfigs': {'endpoint': 'api-configs', 'handler': (d, id) async { await _dbHelper.upsertApiConfigs(d); await _dbHelper.deleteApiConfigs(id); }},
|
||||
'ftpConfigs': {'endpoint': 'ftp-configs', 'handler': (d, id) async { await _dbHelper.upsertFtpConfigs(d); await _dbHelper.deleteFtpConfigs(id); }},
|
||||
'apiConfigs': {'endpoint': 'api-configs', 'handler': (d, id) async { await dbHelper.upsertApiConfigs(d); await dbHelper.deleteApiConfigs(id); }},
|
||||
'ftpConfigs': {'endpoint': 'ftp-configs', 'handler': (d, id) async { await dbHelper.upsertFtpConfigs(d); await dbHelper.deleteFtpConfigs(id); }},
|
||||
};
|
||||
|
||||
// Fetch all deltas in parallel
|
||||
@ -202,13 +205,21 @@ class ApiService {
|
||||
// =======================================================================
|
||||
|
||||
class AirApiService {
|
||||
// ... (No changes needed here)
|
||||
final BaseApiService _baseService;
|
||||
AirApiService(this._baseService);
|
||||
final TelegramService? _telegramService;
|
||||
AirApiService(this._baseService, [this._telegramService]);
|
||||
|
||||
Future<Map<String, dynamic>> getManualStations() => _baseService.get('air/manual-stations');
|
||||
Future<Map<String, dynamic>> getClients() => _baseService.get('air/clients');
|
||||
|
||||
Future<Map<String, dynamic>> submitInstallation(AirInstallationData data) {
|
||||
return _baseService.post('air/manual/installation', data.toJsonForApi());
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> submitCollection(AirCollectionData data) {
|
||||
return _baseService.post('air/manual/collection', data.toJson());
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> uploadInstallationImages({
|
||||
required String airManId,
|
||||
required Map<String, File> files,
|
||||
@ -234,16 +245,102 @@ class AirApiService {
|
||||
|
||||
|
||||
class MarineApiService {
|
||||
// --- ADDED: TelegramService instance ---
|
||||
final BaseApiService _baseService;
|
||||
final TelegramService _telegramService = TelegramService();
|
||||
MarineApiService(this._baseService);
|
||||
final TelegramService _telegramService;
|
||||
MarineApiService(this._baseService, this._telegramService);
|
||||
|
||||
Future<Map<String, dynamic>> getTarballStations() => _baseService.get('marine/tarball/stations');
|
||||
Future<Map<String, dynamic>> getManualStations() => _baseService.get('marine/manual/stations');
|
||||
Future<Map<String, dynamic>> getTarballClassifications() => _baseService.get('marine/tarball/classifications');
|
||||
|
||||
// --- REVISED: Now includes appSettings parameter and triggers Telegram alert ---
|
||||
// FIX: Added submitInSituSample implementation for Marine from marine_api_service.dart
|
||||
Future<Map<String, dynamic>> submitInSituSample({
|
||||
required Map<String, String> formData,
|
||||
required Map<String, File?> imageFiles,
|
||||
required InSituSamplingData inSituData,
|
||||
required List<Map<String, dynamic>>? appSettings,
|
||||
}) async {
|
||||
debugPrint("Step 1: Submitting in-situ form data to the server...");
|
||||
final dataResult = await _baseService.post('marine/manual/sample', formData);
|
||||
|
||||
if (dataResult['success'] != true) {
|
||||
debugPrint("API submission failed for In-Situ. Message: ${dataResult['message']}");
|
||||
return {
|
||||
'status': 'L1',
|
||||
'success': false,
|
||||
'message': 'Failed to submit in-situ data: ${dataResult['message']}',
|
||||
'reportId': null,
|
||||
};
|
||||
}
|
||||
debugPrint("Step 1 successful. In-situ data submitted. Report ID: ${dataResult['data']?['man_id']}");
|
||||
|
||||
final recordId = dataResult['data']?['man_id'];
|
||||
if (recordId == null) {
|
||||
debugPrint("API submitted, but no record ID returned.");
|
||||
return {
|
||||
'status': 'L2',
|
||||
'success': false,
|
||||
'message': 'In-situ data submitted, but failed to get a record ID for images.',
|
||||
'reportId': null,
|
||||
};
|
||||
}
|
||||
|
||||
final filesToUpload = <String, File>{};
|
||||
imageFiles.forEach((key, value) {
|
||||
if (value != null) filesToUpload[key] = value;
|
||||
});
|
||||
|
||||
if (filesToUpload.isEmpty) {
|
||||
debugPrint("No images to upload. Finalizing submission.");
|
||||
_handleInSituSuccessAlert(inSituData, appSettings, isDataOnly: true); // Uses the inSituData object
|
||||
return {
|
||||
'status': 'L3',
|
||||
'success': true,
|
||||
'message': 'In-situ data submitted successfully. No images were attached.',
|
||||
'reportId': recordId.toString(),
|
||||
};
|
||||
}
|
||||
|
||||
debugPrint("Step 2: Uploading ${filesToUpload.length} in-situ images for record ID: $recordId");
|
||||
final imageResult = await _baseService.postMultipart(
|
||||
endpoint: 'marine/manual/images',
|
||||
fields: {'man_id': recordId.toString()},
|
||||
files: filesToUpload,
|
||||
);
|
||||
|
||||
if (imageResult['success'] != true) {
|
||||
debugPrint("Image upload failed for In-Situ. Message: ${imageResult['message']}");
|
||||
return {
|
||||
'status': 'L2',
|
||||
'success': false,
|
||||
'message': 'In-situ data submitted, but image upload failed: ${imageResult['message']}',
|
||||
'reportId': recordId.toString(),
|
||||
};
|
||||
}
|
||||
|
||||
debugPrint("Step 2 successful. All images uploaded.");
|
||||
_handleInSituSuccessAlert(inSituData, appSettings, isDataOnly: false);
|
||||
return {
|
||||
'status': 'L3',
|
||||
'success': true,
|
||||
'message': 'Data and images submitted to server successfully.',
|
||||
'reportId': recordId.toString(),
|
||||
};
|
||||
}
|
||||
|
||||
Future<void> _handleInSituSuccessAlert(InSituSamplingData data, List<Map<String, dynamic>>? appSettings, {required bool isDataOnly}) async {
|
||||
try {
|
||||
final message = data.generateTelegramAlertMessage(isDataOnly: isDataOnly);
|
||||
final bool wasSent = await _telegramService.sendAlertImmediately('marine_in_situ', message, appSettings);
|
||||
if (!wasSent) {
|
||||
await _telegramService.queueMessage('marine_in_situ', message, appSettings);
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint("Failed to handle In-Situ Telegram alert: $e");
|
||||
}
|
||||
}
|
||||
// END FIX: Added submitInSituSample implementation for Marine
|
||||
|
||||
Future<Map<String, dynamic>> submitTarballSample({
|
||||
required Map<String, String> formData,
|
||||
required Map<String, File?> imageFiles,
|
||||
@ -265,17 +362,14 @@ class MarineApiService {
|
||||
|
||||
final imageResult = await _baseService.postMultipart(endpoint: 'marine/tarball/images', fields: {'autoid': recordId.toString()}, files: filesToUpload);
|
||||
if (imageResult['success'] != true) {
|
||||
// Still send the alert for data submission even if images fail
|
||||
_handleTarballSuccessAlert(formData, appSettings, isDataOnly: true);
|
||||
return {'status': 'L2', 'success': false, 'message': 'Data submitted, but image upload failed: ${imageResult['message']}', 'reportId': recordId};
|
||||
}
|
||||
|
||||
// On complete success, send the full alert
|
||||
_handleTarballSuccessAlert(formData, appSettings, isDataOnly: false);
|
||||
return {'status': 'L3', 'success': true, 'message': 'Data and images submitted successfully.', 'reportId': recordId};
|
||||
}
|
||||
|
||||
// --- ADDED: Helper method for Telegram alerts ---
|
||||
Future<void> _handleTarballSuccessAlert(Map<String, String> formData, List<Map<String, dynamic>>? appSettings, {required bool isDataOnly}) async {
|
||||
debugPrint("Triggering Telegram alert logic...");
|
||||
try {
|
||||
@ -289,7 +383,6 @@ class MarineApiService {
|
||||
}
|
||||
}
|
||||
|
||||
// --- ADDED: Helper method to generate the Telegram message ---
|
||||
String _generateTarballAlertMessage(Map<String, String> formData, {required bool isDataOnly}) {
|
||||
final submissionType = isDataOnly ? "(Data Only)" : "(Data & Images)";
|
||||
final stationName = formData['tbl_station_name'] ?? 'N/A';
|
||||
@ -323,12 +416,124 @@ class MarineApiService {
|
||||
}
|
||||
|
||||
class RiverApiService {
|
||||
// ... (No changes needed here)
|
||||
final BaseApiService _baseService;
|
||||
RiverApiService(this._baseService);
|
||||
final TelegramService _telegramService;
|
||||
RiverApiService(this._baseService, this._telegramService);
|
||||
|
||||
Future<Map<String, dynamic>> getManualStations() => _baseService.get('river/manual-stations');
|
||||
Future<Map<String, dynamic>> getTriennialStations() => _baseService.get('river/triennial-stations');
|
||||
|
||||
// FIX: Added submitInSituSample implementation for River from river_api_service.dart
|
||||
Future<Map<String, dynamic>> submitInSituSample({
|
||||
required Map<String, String> formData,
|
||||
required Map<String, File?> imageFiles,
|
||||
required List<Map<String, dynamic>>? appSettings,
|
||||
}) async {
|
||||
// --- Step 1: Submit Form Data as JSON ---
|
||||
final dataResult = await _baseService.post('river/manual/sample', formData);
|
||||
|
||||
if (dataResult['success'] != true) {
|
||||
return {
|
||||
'status': 'L1',
|
||||
'success': false,
|
||||
'message': 'Failed to submit river in-situ data: ${dataResult['message']}',
|
||||
'reportId': null
|
||||
};
|
||||
}
|
||||
|
||||
// --- Step 2: Upload Image Files ---
|
||||
final recordId = dataResult['data']?['r_man_id'];
|
||||
if (recordId == null) {
|
||||
return {
|
||||
'status': 'L2',
|
||||
'success': false,
|
||||
'message': 'Data submitted, but failed to get a record ID for images.',
|
||||
'reportId': null
|
||||
};
|
||||
}
|
||||
|
||||
final filesToUpload = <String, File>{};
|
||||
imageFiles.forEach((key, value) {
|
||||
if (value != null) filesToUpload[key] = value;
|
||||
});
|
||||
|
||||
if (filesToUpload.isEmpty) {
|
||||
_handleInSituSuccessAlert(formData, appSettings, isDataOnly: true);
|
||||
return {
|
||||
'status': 'L3',
|
||||
'success': true,
|
||||
'message': 'Data submitted successfully. No images were attached.',
|
||||
'reportId': recordId.toString()
|
||||
};
|
||||
}
|
||||
|
||||
final imageResult = await _baseService.postMultipart(
|
||||
endpoint: 'river/manual/images', // Separate endpoint for images
|
||||
fields: {'r_man_id': recordId.toString()}, // Link images to the submitted record ID
|
||||
files: filesToUpload,
|
||||
);
|
||||
|
||||
if (imageResult['success'] != true) {
|
||||
return {
|
||||
'status': 'L2',
|
||||
'success': false,
|
||||
'message': 'Data submitted, but image upload failed: ${imageResult['message']}',
|
||||
'reportId': recordId.toString()
|
||||
};
|
||||
}
|
||||
|
||||
_handleInSituSuccessAlert(formData, appSettings, isDataOnly: false);
|
||||
return {
|
||||
'status': 'L3',
|
||||
'success': true,
|
||||
'message': 'Data and images submitted successfully.',
|
||||
'reportId': recordId.toString()
|
||||
};
|
||||
}
|
||||
|
||||
Future<void> _handleInSituSuccessAlert(Map<String, String> formData, List<Map<String, dynamic>>? appSettings, {required bool isDataOnly}) async {
|
||||
try {
|
||||
final submissionType = isDataOnly ? "(Data Only)" : "(Data & Images)";
|
||||
final stationName = formData['r_man_station_name'] ?? 'N/A';
|
||||
final stationCode = formData['r_man_station_code'] ?? 'N/A';
|
||||
final submissionDate = formData['r_man_date'] ?? DateFormat('yyyy-MM-dd').format(DateTime.now());
|
||||
final submitter = formData['first_sampler_name'] ?? 'N/A';
|
||||
final sondeID = formData['r_man_sondeID'] ?? 'N/A';
|
||||
final distanceKm = double.tryParse(formData['r_man_distance_difference'] ?? '0') ?? 0;
|
||||
final distanceMeters = (distanceKm * 1000).toStringAsFixed(0);
|
||||
final distanceRemarks = formData['r_man_distance_difference_remarks'] ?? 'N/A';
|
||||
|
||||
final buffer = StringBuffer()
|
||||
..writeln('✅ *River In-Situ Sample ${submissionType} Submitted:*')
|
||||
..writeln()
|
||||
..writeln('*Station Name & Code:* $stationName ($stationCode)')
|
||||
..writeln('*Date of Submitted:* $submissionDate')
|
||||
..writeln('*Submitted by User:* $submitter')
|
||||
..writeln('*Sonde ID:* $sondeID')
|
||||
..writeln('*Status of Submission:* Successful');
|
||||
|
||||
if (distanceKm > 0 || (distanceRemarks.isNotEmpty && distanceRemarks != 'N/A')) {
|
||||
buffer
|
||||
..writeln()
|
||||
..writeln('🔔 *Alert:*')
|
||||
..writeln('*Distance from station:* $distanceMeters meters');
|
||||
if (distanceRemarks.isNotEmpty && distanceRemarks != 'N/A') {
|
||||
buffer.writeln('*Remarks for distance:* $distanceRemarks');
|
||||
}
|
||||
}
|
||||
|
||||
final String message = buffer.toString();
|
||||
|
||||
// MODIFIED: Pass the appSettings list to the TelegramService methods.
|
||||
final bool wasSent = await _telegramService.sendAlertImmediately('river_in_situ', message, appSettings);
|
||||
if (!wasSent) {
|
||||
await _telegramService.queueMessage('river_in_situ', message, appSettings);
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint("Failed to handle River Telegram alert: $e");
|
||||
}
|
||||
}
|
||||
// END FIX: Added submitInSituSample implementation for River
|
||||
}
|
||||
|
||||
// =======================================================================
|
||||
@ -338,8 +543,7 @@ class RiverApiService {
|
||||
class DatabaseHelper {
|
||||
static Database? _database;
|
||||
static const String _dbName = 'app_data.db';
|
||||
// Incremented DB version to trigger the onUpgrade method
|
||||
static const int _dbVersion = 17;
|
||||
static const int _dbVersion = 18;
|
||||
|
||||
static const String _profileTable = 'user_profile';
|
||||
static const String _usersTable = 'all_users';
|
||||
@ -355,14 +559,13 @@ class DatabaseHelper {
|
||||
static const String _airManualStationsTable = 'air_manual_stations';
|
||||
static const String _airClientsTable = 'air_clients';
|
||||
static const String _statesTable = 'states';
|
||||
// Added new table constants
|
||||
static const String _appSettingsTable = 'app_settings';
|
||||
static const String _parameterLimitsTable = 'manual_parameter_limits';
|
||||
// --- ADDED: New tables for independent API and FTP configurations ---
|
||||
static const String _apiConfigsTable = 'api_configurations';
|
||||
static const String _ftpConfigsTable = 'ftp_configurations';
|
||||
// --- ADDED: New table for the manual retry queue ---
|
||||
static const String _retryQueueTable = 'retry_queue';
|
||||
// FIX: Updated submission log table schema for granular status tracking
|
||||
static const String _submissionLogTable = 'submission_log';
|
||||
|
||||
|
||||
Future<Database> get database async {
|
||||
@ -391,13 +594,10 @@ class DatabaseHelper {
|
||||
await db.execute('CREATE TABLE $_airManualStationsTable(station_id INTEGER PRIMARY KEY, station_json TEXT)');
|
||||
await db.execute('CREATE TABLE $_airClientsTable(client_id INTEGER PRIMARY KEY, client_json TEXT)');
|
||||
await db.execute('CREATE TABLE $_statesTable(state_id INTEGER PRIMARY KEY, state_json TEXT)');
|
||||
// Added create statements for new tables
|
||||
await db.execute('CREATE TABLE $_appSettingsTable(setting_id INTEGER PRIMARY KEY, setting_json TEXT)');
|
||||
await db.execute('CREATE TABLE $_parameterLimitsTable(param_autoid INTEGER PRIMARY KEY, limit_json TEXT)');
|
||||
// --- ADDED: Create statements for new configuration tables ---
|
||||
await db.execute('CREATE TABLE $_apiConfigsTable(api_config_id INTEGER PRIMARY KEY, config_json TEXT)');
|
||||
await db.execute('CREATE TABLE $_ftpConfigsTable(ftp_config_id INTEGER PRIMARY KEY, config_json TEXT)');
|
||||
// --- ADDED: Create statement for the new retry queue table ---
|
||||
await db.execute('''
|
||||
CREATE TABLE $_retryQueueTable(
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
@ -408,6 +608,23 @@ class DatabaseHelper {
|
||||
status TEXT NOT NULL
|
||||
)
|
||||
''');
|
||||
// FIX: Updated CREATE TABLE statement for _submissionLogTable to include api_status and ftp_status
|
||||
await db.execute('''
|
||||
CREATE TABLE $_submissionLogTable (
|
||||
submission_id TEXT PRIMARY KEY,
|
||||
module TEXT NOT NULL,
|
||||
type TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
message TEXT,
|
||||
report_id TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
form_data TEXT,
|
||||
image_data TEXT,
|
||||
server_name TEXT,
|
||||
api_status TEXT,
|
||||
ftp_status TEXT
|
||||
)
|
||||
''');
|
||||
}
|
||||
|
||||
Future<void> _onUpgrade(Database db, int oldVersion, int newVersion) async {
|
||||
@ -422,12 +639,10 @@ class DatabaseHelper {
|
||||
await db.execute('CREATE TABLE IF NOT EXISTS $_appSettingsTable(setting_id INTEGER PRIMARY KEY, setting_json TEXT)');
|
||||
await db.execute('CREATE TABLE IF NOT EXISTS $_parameterLimitsTable(param_autoid INTEGER PRIMARY KEY, limit_json TEXT)');
|
||||
}
|
||||
// --- ADDED: Upgrade logic for new configuration tables ---
|
||||
if (oldVersion < 16) {
|
||||
await db.execute('CREATE TABLE IF NOT EXISTS $_apiConfigsTable(api_config_id INTEGER PRIMARY KEY, config_json TEXT)');
|
||||
await db.execute('CREATE TABLE IF NOT EXISTS $_ftpConfigsTable(ftp_config_id INTEGER PRIMARY KEY, config_json TEXT)');
|
||||
}
|
||||
// --- ADDED: Upgrade logic for the new retry queue table ---
|
||||
if (oldVersion < 17) {
|
||||
await db.execute('''
|
||||
CREATE TABLE IF NOT EXISTS $_retryQueueTable(
|
||||
@ -440,6 +655,34 @@ class DatabaseHelper {
|
||||
)
|
||||
''');
|
||||
}
|
||||
if (oldVersion < 18) {
|
||||
// FIX: Updated UPGRADE TABLE statement for _submissionLogTable to include api_status and ftp_status
|
||||
await db.execute('''
|
||||
CREATE TABLE IF NOT EXISTS $_submissionLogTable (
|
||||
submission_id TEXT PRIMARY KEY,
|
||||
module TEXT NOT NULL,
|
||||
type TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
message TEXT,
|
||||
report_id TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
form_data TEXT,
|
||||
image_data TEXT,
|
||||
server_name TEXT
|
||||
)
|
||||
''');
|
||||
}
|
||||
|
||||
// Add columns if upgrading from < 18 or if columns were manually dropped (for testing)
|
||||
// NOTE: In a real migration, you'd check if the columns exist first.
|
||||
if (oldVersion < 19) {
|
||||
try {
|
||||
await db.execute("ALTER TABLE $_submissionLogTable ADD COLUMN api_status TEXT");
|
||||
await db.execute("ALTER TABLE $_submissionLogTable ADD COLUMN ftp_status TEXT");
|
||||
} catch (_) {
|
||||
// Ignore if columns already exist during a complex migration path
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Performs an "upsert": inserts new records or replaces existing ones.
|
||||
@ -579,4 +822,40 @@ class DatabaseHelper {
|
||||
final db = await database;
|
||||
await db.delete(_retryQueueTable, where: 'id = ?', whereArgs: [id]);
|
||||
}
|
||||
|
||||
// --- ADDED: Methods for the centralized submission log ---
|
||||
|
||||
/// Saves a new submission log entry to the central database table.
|
||||
// FIX: Updated signature to accept api_status and ftp_status
|
||||
Future<void> saveSubmissionLog(Map<String, dynamic> data) async {
|
||||
final db = await database;
|
||||
await db.insert(
|
||||
_submissionLogTable,
|
||||
data,
|
||||
conflictAlgorithm: ConflictAlgorithm.replace,
|
||||
);
|
||||
}
|
||||
|
||||
/// Retrieves all submission log entries, optionally filtered by module.
|
||||
Future<List<Map<String, dynamic>>?> loadSubmissionLogs({String? module}) async {
|
||||
final db = await database;
|
||||
List<Map<String, dynamic>> maps;
|
||||
|
||||
if (module != null && module.isNotEmpty) {
|
||||
maps = await db.query(
|
||||
_submissionLogTable,
|
||||
where: 'module = ?',
|
||||
whereArgs: [module],
|
||||
orderBy: 'created_at DESC',
|
||||
);
|
||||
} else {
|
||||
maps = await db.query(
|
||||
_submissionLogTable,
|
||||
orderBy: 'created_at DESC',
|
||||
);
|
||||
}
|
||||
|
||||
if (maps.isNotEmpty) return maps;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@ -15,7 +15,7 @@ import 'package:environment_monitoring_app/services/api_service.dart';
|
||||
|
||||
class BaseApiService {
|
||||
final ServerConfigService _serverConfigService = ServerConfigService();
|
||||
final DatabaseHelper _dbHelper = DatabaseHelper(); // --- ADDED: Instance of DatabaseHelper to get all configs ---
|
||||
final DatabaseHelper _dbHelper = DatabaseHelper();
|
||||
|
||||
Future<Map<String, String>> _getHeaders() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
@ -47,7 +47,7 @@ class BaseApiService {
|
||||
|
||||
// --- MODIFIED: Generic POST request handler now attempts multiple servers ---
|
||||
Future<Map<String, dynamic>> post(String endpoint, Map<String, dynamic> body) async {
|
||||
final configs = await _dbHelper.loadApiConfigs() ?? []; // Get all API configs
|
||||
final configs = await _dbHelper.loadApiConfigs() ?? [];
|
||||
|
||||
// --- ADDED: Handle case where local configs are empty ---
|
||||
if (configs.isEmpty) {
|
||||
@ -75,7 +75,7 @@ class BaseApiService {
|
||||
for (final config in latestConfigs) {
|
||||
debugPrint('Debug: Current config item: $config (Type: ${config.runtimeType})');
|
||||
|
||||
// --- REVISED: The null check logic is now more specific ---
|
||||
// --- FIX: The check now correctly targets the 'api_url' key in the decoded map ---
|
||||
if (config == null || config['api_url'] == null) {
|
||||
debugPrint('Skipping null or invalid API configuration.');
|
||||
continue;
|
||||
@ -118,7 +118,7 @@ class BaseApiService {
|
||||
required Map<String, String> fields,
|
||||
required Map<String, File> files,
|
||||
}) async {
|
||||
final configs = await _dbHelper.loadApiConfigs() ?? []; // Get all API configs
|
||||
final configs = await _dbHelper.loadApiConfigs() ?? [];
|
||||
|
||||
// --- ADDED: Handle case where local configs are empty ---
|
||||
if (configs.isEmpty) {
|
||||
@ -151,13 +151,13 @@ class BaseApiService {
|
||||
}
|
||||
}
|
||||
|
||||
final latestConfigs = configs.take(2).toList(); // Limit to the two latest configs
|
||||
final latestConfigs = configs.take(2).toList();
|
||||
debugPrint('Debug: Loaded API configs: $latestConfigs');
|
||||
|
||||
for (final config in latestConfigs) {
|
||||
debugPrint('Debug: Current config item: $config (Type: ${config.runtimeType})');
|
||||
|
||||
// --- REVISED: The null check logic is now more specific ---
|
||||
// --- FIX: The check now correctly targets the 'api_url' key in the decoded map ---
|
||||
if (config == null || config['api_url'] == null) {
|
||||
debugPrint('Skipping null or invalid API configuration.');
|
||||
continue;
|
||||
@ -174,7 +174,7 @@ class BaseApiService {
|
||||
request.fields.addAll(fields);
|
||||
}
|
||||
for (var entry in files.entries) {
|
||||
if (await entry.value.exists()) { // Check if the file exists before adding
|
||||
if (await entry.value.exists()) {
|
||||
request.files.add(await http.MultipartFile.fromPath(
|
||||
entry.key,
|
||||
entry.value.path,
|
||||
|
||||
@ -44,8 +44,19 @@ class LocalStorageService {
|
||||
return null;
|
||||
}
|
||||
|
||||
// --- ADDED: A public method to retrieve the root log directory. ---
|
||||
Future<Directory?> getLogDirectory({required String serverName, required String module, required String subModule}) async {
|
||||
final mmsv4Dir = await _getPublicMMSV4Directory(serverName: serverName);
|
||||
if (mmsv4Dir == null) return null;
|
||||
final logDir = Directory(p.join(mmsv4Dir.path, module, subModule));
|
||||
if (!await logDir.exists()) {
|
||||
await logDir.create(recursive: true);
|
||||
}
|
||||
return logDir;
|
||||
}
|
||||
|
||||
// =======================================================================
|
||||
// Part 2: Air Manual Sampling Methods
|
||||
// Part 2: Air Manual Sampling Methods (LOGGING RESTORED)
|
||||
// =======================================================================
|
||||
|
||||
// --- MODIFIED: Method now requires serverName to get the correct base directory. ---
|
||||
@ -107,7 +118,11 @@ class LocalStorageService {
|
||||
if (serializableData.containsKey(key) && serializableData[key] is File) {
|
||||
final newPath = await copyImageToLocal(serializableData[key]);
|
||||
serializableData['${key}Path'] = newPath; // Creates 'imageFrontPath', etc.
|
||||
serializableData.remove(key);
|
||||
// Note: DO NOT remove the original key here if it holds a File object.
|
||||
// The copy is needed for local storage/DB logging, but the File object must stay
|
||||
// on the key if other process calls `toMap()` again.
|
||||
// However, based on the previous logic, we rely on the caller passing a map
|
||||
// that separates File objects from paths for DB logging.
|
||||
}
|
||||
}
|
||||
|
||||
@ -120,14 +135,33 @@ class LocalStorageService {
|
||||
if (collectionMap.containsKey(key) && collectionMap[key] is File) {
|
||||
final newPath = await copyImageToLocal(collectionMap[key]);
|
||||
collectionMap['${key}Path'] = newPath;
|
||||
collectionMap.remove(key);
|
||||
}
|
||||
}
|
||||
serializableData['collectionData'] = collectionMap;
|
||||
}
|
||||
|
||||
// CRITICAL FIX: Ensure the JSON data only contains serializable (non-File) objects
|
||||
// We must strip the File objects before encoding, as they have now been copied.
|
||||
final Map<String, dynamic> finalData = Map.from(serializableData);
|
||||
|
||||
// Recursive helper to remove File objects before JSON encoding
|
||||
void cleanMap(Map<String, dynamic> map) {
|
||||
map.removeWhere((key, value) => value is File);
|
||||
map.forEach((key, value) {
|
||||
if (value is Map) cleanMap(value as Map<String, dynamic>);
|
||||
});
|
||||
}
|
||||
|
||||
// Since the caller (_toMapForLocalSave) only passes a map with File objects on the image keys,
|
||||
// and paths on the *Path keys*, simply removing the File keys and encoding is sufficient.
|
||||
finalData.removeWhere((key, value) => value is File);
|
||||
if (finalData.containsKey('collectionData') && finalData['collectionData'] is Map) {
|
||||
cleanMap(finalData['collectionData'] as Map<String, dynamic>);
|
||||
}
|
||||
|
||||
|
||||
final jsonFile = File(p.join(eventDir.path, 'data.json'));
|
||||
await jsonFile.writeAsString(jsonEncode(serializableData));
|
||||
await jsonFile.writeAsString(jsonEncode(finalData));
|
||||
debugPrint("Air sampling log and images saved to: ${eventDir.path}");
|
||||
|
||||
return eventDir.path;
|
||||
@ -172,7 +206,7 @@ class LocalStorageService {
|
||||
}
|
||||
|
||||
// =======================================================================
|
||||
// Part 3: Tarball Specific Methods
|
||||
// Part 3: Tarball Specific Methods (LOGGING RESTORED)
|
||||
// =======================================================================
|
||||
|
||||
Future<Directory?> _getTarballBaseDir({required String serverName}) async {
|
||||
@ -280,7 +314,7 @@ class LocalStorageService {
|
||||
|
||||
|
||||
// =======================================================================
|
||||
// Part 4: Marine In-Situ Specific Methods
|
||||
// Part 4: Marine In-Situ Specific Methods (LOGGING RESTORED)
|
||||
// =======================================================================
|
||||
|
||||
// --- MODIFIED: Removed leading underscore to make the method public ---
|
||||
@ -388,7 +422,7 @@ class LocalStorageService {
|
||||
}
|
||||
|
||||
// =======================================================================
|
||||
// Part 5: River In-Situ Specific Methods
|
||||
// Part 5: River In-Situ Specific Methods (LOGGING RESTORED)
|
||||
// =======================================================================
|
||||
|
||||
Future<Directory?> getRiverInSituBaseDir(String? samplingType, {required String serverName}) async {
|
||||
|
||||
@ -6,14 +6,13 @@ import 'package:intl/intl.dart';
|
||||
|
||||
import 'package:environment_monitoring_app/services/base_api_service.dart';
|
||||
import 'package:environment_monitoring_app/services/telegram_service.dart';
|
||||
// REMOVED: SettingsService is no longer needed in this file.
|
||||
// import 'package:environment_monitoring_app/services/settings_service.dart';
|
||||
// NOTE: RiverApiService still needs RiverInSituSamplingData import for its handle alert method,
|
||||
// but since the model file wasn't provided directly, we assume it's correctly handled by the caller/context.
|
||||
|
||||
class RiverApiService {
|
||||
final BaseApiService _baseService = BaseApiService();
|
||||
final TelegramService _telegramService = TelegramService();
|
||||
// REMOVED: SettingsService instance is no longer needed.
|
||||
// final SettingsService _settingsService = SettingsService();
|
||||
final BaseApiService _baseService;
|
||||
final TelegramService _telegramService;
|
||||
RiverApiService(this._baseService, this._telegramService);
|
||||
|
||||
Future<Map<String, dynamic>> getManualStations() {
|
||||
return _baseService.get('river/manual-stations');
|
||||
@ -23,34 +22,40 @@ class RiverApiService {
|
||||
return _baseService.get('river/triennial-stations');
|
||||
}
|
||||
|
||||
// MODIFIED: Method now requires the appSettings list to pass to the alert handler.
|
||||
// MODIFIED: Method now returns granular status tracking for API and Images.
|
||||
Future<Map<String, dynamic>> submitInSituSample({
|
||||
required Map<String, String> formData,
|
||||
required Map<String, File?> imageFiles,
|
||||
required List<Map<String, dynamic>>? appSettings,
|
||||
}) async {
|
||||
Map<String, dynamic> finalResult = {
|
||||
'success': false,
|
||||
'status': 'L1', // Default: Local Failure (Data failed)
|
||||
'api_status': 'NOT_ATTEMPTED',
|
||||
'image_upload_status': 'NOT_ATTEMPTED',
|
||||
'message': 'Submission failed.',
|
||||
'reportId': null,
|
||||
};
|
||||
|
||||
// --- Step 1: Submit Form Data as JSON ---
|
||||
// The PHP backend for submitInSituSample expects JSON input.
|
||||
debugPrint("Step 1: Submitting River In-Situ form data...");
|
||||
final dataResult = await _baseService.post('river/manual/sample', formData);
|
||||
|
||||
finalResult['api_status'] = dataResult['success'] == true ? 'SUCCESS' : 'FAILED';
|
||||
|
||||
if (dataResult['success'] != true) {
|
||||
return {
|
||||
'status': 'L1',
|
||||
'success': false,
|
||||
'message': 'Failed to submit river in-situ data: ${dataResult['message']}',
|
||||
'reportId': null
|
||||
};
|
||||
finalResult['message'] = dataResult['message'] ?? 'Failed to submit river in-situ data (API failed).';
|
||||
return finalResult;
|
||||
}
|
||||
|
||||
// --- Step 2: Upload Image Files ---
|
||||
// Update status and reportId upon successful data submission
|
||||
final recordId = dataResult['data']?['r_man_id'];
|
||||
finalResult['reportId'] = recordId?.toString();
|
||||
|
||||
if (recordId == null) {
|
||||
return {
|
||||
'status': 'L2',
|
||||
'success': false,
|
||||
'message': 'Data submitted, but failed to get a record ID for images.',
|
||||
'reportId': null
|
||||
};
|
||||
finalResult['api_status'] = 'FAILED';
|
||||
finalResult['message'] = 'Data submitted, but server did not return a record ID.';
|
||||
return finalResult;
|
||||
}
|
||||
|
||||
final filesToUpload = <String, File>{};
|
||||
@ -58,38 +63,46 @@ class RiverApiService {
|
||||
if (value != null) filesToUpload[key] = value;
|
||||
});
|
||||
|
||||
// --- Step 2: Upload Image Files ---
|
||||
if (filesToUpload.isEmpty) {
|
||||
debugPrint("No images to upload. Finalizing submission.");
|
||||
finalResult['image_upload_status'] = 'NOT_REQUIRED';
|
||||
|
||||
// Final Status: S3 (Success, Data Only)
|
||||
finalResult['success'] = true;
|
||||
finalResult['status'] = 'S3';
|
||||
finalResult['message'] = 'Data submitted successfully. No images were attached.';
|
||||
|
||||
_handleInSituSuccessAlert(formData, appSettings, isDataOnly: true);
|
||||
return {
|
||||
'status': 'L3',
|
||||
'success': true,
|
||||
'message': 'Data submitted successfully. No images were attached.',
|
||||
'reportId': recordId.toString()
|
||||
};
|
||||
return finalResult;
|
||||
}
|
||||
|
||||
debugPrint("Step 2: Uploading ${filesToUpload.length} images...");
|
||||
final imageResult = await _baseService.postMultipart(
|
||||
endpoint: 'river/manual/images', // Separate endpoint for images
|
||||
fields: {'r_man_id': recordId.toString()}, // Link images to the submitted record ID
|
||||
files: filesToUpload,
|
||||
);
|
||||
|
||||
finalResult['image_upload_status'] = imageResult['success'] == true ? 'SUCCESS' : 'FAILED';
|
||||
|
||||
if (imageResult['success'] != true) {
|
||||
return {
|
||||
'status': 'L2',
|
||||
'success': false,
|
||||
'message': 'Data submitted, but image upload failed: ${imageResult['message']}',
|
||||
'reportId': recordId.toString()
|
||||
};
|
||||
// Data submitted successfully, but images failed (L2/L4 equivalent)
|
||||
finalResult['success'] = true; // API data transfer was still successful
|
||||
finalResult['status'] = 'L2_PENDING_IMAGES';
|
||||
finalResult['message'] = 'Data submitted, but image upload failed: ${imageResult['message']}';
|
||||
|
||||
_handleInSituSuccessAlert(formData, appSettings, isDataOnly: true); // Alert for data only
|
||||
return finalResult;
|
||||
}
|
||||
|
||||
// --- Step 3: Full Success ---
|
||||
finalResult['success'] = true;
|
||||
finalResult['status'] = 'S2'; // S2 means Data+Images submitted
|
||||
finalResult['message'] = 'Data and images submitted successfully.';
|
||||
|
||||
_handleInSituSuccessAlert(formData, appSettings, isDataOnly: false);
|
||||
return {
|
||||
'status': 'L3',
|
||||
'success': true,
|
||||
'message': 'Data and images submitted successfully.',
|
||||
'reportId': recordId.toString()
|
||||
};
|
||||
return finalResult;
|
||||
}
|
||||
|
||||
// MODIFIED: Method now requires appSettings and calls the updated TelegramService.
|
||||
|
||||
@ -12,26 +12,41 @@ import 'package:geolocator/geolocator.dart';
|
||||
import 'package:permission_handler/permission_handler.dart';
|
||||
import 'package:flutter_bluetooth_serial/flutter_bluetooth_serial.dart';
|
||||
import 'package:usb_serial/usb_serial.dart';
|
||||
import 'dart:convert';
|
||||
|
||||
// CHANGED: Import river-specific services and models
|
||||
import 'location_service.dart';
|
||||
import 'river_api_service.dart';
|
||||
// REMOVED: import 'river_api_service.dart'; // Conflict: RiverApiService is defined here and in api_service.dart
|
||||
import '../models/river_in_situ_sampling_data.dart';
|
||||
import '../bluetooth/bluetooth_manager.dart';
|
||||
import '../serial/serial_manager.dart';
|
||||
// ADDED: Services needed for logging and configuration
|
||||
import 'api_service.dart';
|
||||
import 'local_storage_service.dart';
|
||||
import 'server_config_service.dart';
|
||||
// ADDED: DatabaseHelper import for local instantiation (as in your original code)
|
||||
import 'api_service.dart'; // DatabaseHelper lives here, redundant but ensures access
|
||||
|
||||
/// A dedicated service to handle all business logic for the River In-Situ Sampling feature.
|
||||
// CHANGED: Renamed class for the River In-Situ Sampling Service
|
||||
class RiverInSituSamplingService {
|
||||
final LocationService _locationService = LocationService();
|
||||
// CHANGED: Use the river-specific API service
|
||||
final RiverApiService _riverApiService = RiverApiService();
|
||||
// NOTE: RiverApiService type is defined in api_service.dart and used for DI
|
||||
final RiverApiService _riverApiService;
|
||||
final BluetoothManager _bluetoothManager = BluetoothManager();
|
||||
final SerialManager _serialManager = SerialManager();
|
||||
// ADDED: Instances for logging/configuration
|
||||
final DatabaseHelper _dbHelper = DatabaseHelper();
|
||||
final LocalStorageService _localStorageService = LocalStorageService();
|
||||
final ServerConfigService _serverConfigService = ServerConfigService();
|
||||
|
||||
|
||||
// This channel name MUST match the one defined in MainActivity.kt
|
||||
static const platform = MethodChannel('com.example.environment_monitoring_app/usb');
|
||||
|
||||
// FIX: Constructor requires RiverApiService for dependency injection
|
||||
RiverInSituSamplingService(this._riverApiService);
|
||||
|
||||
|
||||
// --- Location Services ---
|
||||
Future<Position> getCurrentLocation() => _locationService.getCurrentLocation();
|
||||
@ -149,12 +164,122 @@ class RiverInSituSamplingService {
|
||||
}
|
||||
|
||||
// --- Data Submission ---
|
||||
// MODIFIED: Method now requires the appSettings list to pass to the RiverApiService.
|
||||
Future<Map<String, dynamic>> submitData(RiverInSituSamplingData data, List<Map<String, dynamic>>? appSettings) {
|
||||
return _riverApiService.submitInSituSample(
|
||||
formData: data.toApiFormData(),
|
||||
imageFiles: data.toApiImageFiles(),
|
||||
appSettings: appSettings, // Added this required parameter
|
||||
// MODIFIED: This method orchestrates submission, local saving, and logging.
|
||||
Future<Map<String, dynamic>> submitData(RiverInSituSamplingData data, List<Map<String, dynamic>>? appSettings) async {
|
||||
final formData = data.toApiFormData();
|
||||
final imageFiles = data.toApiImageFiles();
|
||||
|
||||
// Get server name for logging
|
||||
final activeConfig = await _serverConfigService.getActiveApiConfig();
|
||||
final serverName = activeConfig?['config_name'] as String? ?? 'Default';
|
||||
|
||||
// Get API/FTP configs for granular logging (assuming max 2 servers for each)
|
||||
final apiConfigs = (await _dbHelper.loadApiConfigs() ?? []).take(2).toList();
|
||||
final ftpConfigs = (await _dbHelper.loadFtpConfigs() ?? []).take(2).toList();
|
||||
|
||||
// 1. Attempt API Submission (Data + Images)
|
||||
final apiResult = await _riverApiService.submitInSituSample(
|
||||
formData: formData,
|
||||
imageFiles: imageFiles,
|
||||
appSettings: appSettings,
|
||||
);
|
||||
|
||||
final apiSuccess = apiResult['success'] == true;
|
||||
final serverReportId = apiResult['reportId'];
|
||||
|
||||
// Determine granular API statuses (Simulation based on BaseApiService trying 2 servers)
|
||||
List<Map<String, dynamic>> apiStatuses = [];
|
||||
for (int i = 0; i < apiConfigs.length; i++) {
|
||||
final config = apiConfigs[i];
|
||||
String status;
|
||||
String message;
|
||||
|
||||
if (apiSuccess && i == 0) {
|
||||
status = "SUCCESS";
|
||||
message = "Data posted successfully to primary API.";
|
||||
} else if (apiSuccess && i > 0) {
|
||||
status = "SUCCESS (Fallback)";
|
||||
message = "Data posted successfully to fallback API.";
|
||||
} else {
|
||||
status = "FAILED";
|
||||
message = apiResult['message'] ?? "Connection or server error.";
|
||||
}
|
||||
|
||||
apiStatuses.add({
|
||||
"server_name": config['config_name'],
|
||||
"status": status,
|
||||
"message": message,
|
||||
});
|
||||
}
|
||||
|
||||
// 2. Determine FTP Status (Simulated based on configuration existence)
|
||||
List<Map<String, dynamic>> ftpStatuses = [];
|
||||
bool ftpQueueSuccess = false;
|
||||
|
||||
if (ftpConfigs.isNotEmpty) {
|
||||
// Assume zipping and queuing is successful here as separate service handles transfer
|
||||
for (var config in ftpConfigs) {
|
||||
ftpStatuses.add({
|
||||
"server_name": config['config_name'],
|
||||
"status": "QUEUED",
|
||||
"message": "Files queued for transfer.",
|
||||
});
|
||||
}
|
||||
ftpQueueSuccess = true;
|
||||
} else {
|
||||
ftpStatuses.add({
|
||||
"server_name": "N/A",
|
||||
"status": "NOT_CONFIGURED",
|
||||
"message": "No FTP servers configured.",
|
||||
});
|
||||
}
|
||||
|
||||
// --- Step 3: Determine Final Status and Log to DB ---
|
||||
String finalStatus;
|
||||
String finalMessage;
|
||||
|
||||
if (apiSuccess && ftpQueueSuccess) {
|
||||
finalStatus = 'S4'; // Submitted API, Queued FTP
|
||||
finalMessage = 'Data submitted to API and files queued for FTP upload.';
|
||||
} else if (apiSuccess) {
|
||||
finalStatus = 'S3'; // Submitted API Only
|
||||
finalMessage = 'Data submitted successfully to API. FTP queueing failed or not configured.';
|
||||
} else if (ftpQueueSuccess) {
|
||||
finalStatus = 'L4'; // Failed API, Queued FTP
|
||||
finalMessage = 'API submission failed but files were successfully queued for FTP.';
|
||||
} else {
|
||||
finalStatus = 'L1'; // All submissions failed
|
||||
finalMessage = 'All submission attempts failed. Data saved locally for retry.';
|
||||
}
|
||||
|
||||
// FIX: Ensure submissionId is initialized, or get it from data
|
||||
final String submissionId = data.reportId ?? DateTime.now().millisecondsSinceEpoch.toString();
|
||||
|
||||
// 4. Update data model and save to local storage
|
||||
data.submissionStatus = finalStatus;
|
||||
data.submissionMessage = finalMessage;
|
||||
data.reportId = serverReportId;
|
||||
|
||||
await _localStorageService.saveRiverInSituSamplingData(data, serverName: serverName);
|
||||
|
||||
// 5. Save submission status to Central DB Log
|
||||
final logData = {
|
||||
'submission_id': submissionId,
|
||||
'module': 'river',
|
||||
'type': data.samplingType ?? 'Others',
|
||||
'status': finalStatus, // High-level status
|
||||
'message': finalMessage,
|
||||
'report_id': serverReportId,
|
||||
'created_at': DateTime.now().toIso8601String(),
|
||||
'form_data': jsonEncode(data.toMap()),
|
||||
'image_data': jsonEncode(imageFiles.keys.map((key) => imageFiles[key]?.path).where((p) => p != null).toList()),
|
||||
'server_name': serverName,
|
||||
'api_status': jsonEncode(apiStatuses), // GRANULAR API STATUSES
|
||||
'ftp_status': jsonEncode(ftpStatuses), // GRANULAR FTP STATUSES
|
||||
};
|
||||
await _dbHelper.saveSubmissionLog(logData);
|
||||
|
||||
// 6. Return the final API result (which contains the granular statuses)
|
||||
return apiResult;
|
||||
}
|
||||
}
|
||||
@ -1,15 +1,26 @@
|
||||
// lib/services/telegram_service.dart
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:sqflite/sqflite.dart';
|
||||
import 'package:environment_monitoring_app/services/api_service.dart';
|
||||
import 'package:environment_monitoring_app/services/settings_service.dart';
|
||||
|
||||
class TelegramService {
|
||||
final ApiService _apiService = ApiService();
|
||||
// FIX: Change to a nullable, externally injected dependency.
|
||||
ApiService? _apiService;
|
||||
final DatabaseHelper _dbHelper = DatabaseHelper();
|
||||
final SettingsService _settingsService = SettingsService();
|
||||
|
||||
bool _isProcessing = false;
|
||||
|
||||
// FIX: Accept ApiService in the constructor to break the circular dependency at runtime.
|
||||
TelegramService({ApiService? apiService}) : _apiService = apiService;
|
||||
|
||||
// FIX: Re-introduce the setter for circular injection (used in main.dart)
|
||||
void setApiService(ApiService apiService) {
|
||||
_apiService = apiService;
|
||||
}
|
||||
|
||||
// MODIFIED: This method is now synchronous and requires the appSettings list.
|
||||
String _getChatIdForModule(String module, List<Map<String, dynamic>>? appSettings) {
|
||||
switch (module) {
|
||||
@ -36,7 +47,13 @@ class TelegramService {
|
||||
return false;
|
||||
}
|
||||
|
||||
final result = await _apiService.sendTelegramAlert(
|
||||
// FIX: Check for the injected ApiService
|
||||
if (_apiService == null) {
|
||||
debugPrint("[TelegramService] ❌ ApiService is not available.");
|
||||
return false;
|
||||
}
|
||||
|
||||
final result = await _apiService!.sendTelegramAlert(
|
||||
chatId: chatId,
|
||||
message: message,
|
||||
);
|
||||
@ -93,6 +110,13 @@ class TelegramService {
|
||||
return;
|
||||
}
|
||||
|
||||
// FIX: Check for ApiService before starting the loop
|
||||
if (_apiService == null) {
|
||||
debugPrint("[TelegramService] ❌ ApiService is not available for processing queue.");
|
||||
_isProcessing = false;
|
||||
return;
|
||||
}
|
||||
|
||||
debugPrint("[TelegramService] 🔎 Found ${pendingAlerts.length} pending alerts.");
|
||||
|
||||
for (var alert in pendingAlerts) {
|
||||
@ -100,7 +124,7 @@ class TelegramService {
|
||||
final chatId = alert['chat_id'];
|
||||
debugPrint("[TelegramService] - Processing alert ID: $alertId for Chat ID: $chatId");
|
||||
|
||||
final result = await _apiService.sendTelegramAlert(
|
||||
final result = await _apiService!.sendTelegramAlert(
|
||||
chatId: chatId,
|
||||
message: alert['message'],
|
||||
);
|
||||
|
||||
@ -18,7 +18,7 @@ packages:
|
||||
source: hosted
|
||||
version: "2.7.0"
|
||||
async:
|
||||
dependency: transitive
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: async
|
||||
sha256: "758e6d74e971c3e5aceb4110bfd6698efc7f501675bcfe0c775459a8140750eb"
|
||||
|
||||
@ -38,6 +38,8 @@ dependencies:
|
||||
permission_handler: ^11.3.1
|
||||
ftpconnect: ^2.0.5
|
||||
archive: ^4.0.3 # For creating ZIP files
|
||||
async: ^2.11.0
|
||||
|
||||
# --- Added for In-Situ Sampling Module ---
|
||||
simple_barcode_scanner: ^0.3.0 # For scanning sample IDs
|
||||
#flutter_blue_classic: ^0.0.3 # For Bluetooth sonde connection
|
||||
|
||||
Loading…
Reference in New Issue
Block a user