environment_monitoring_app/lib/auth_provider.dart

526 lines
21 KiB
Dart

// lib/auth_provider.dart
import 'package:flutter/foundation.dart'; // Import for compute function
import 'package:flutter/material.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:connectivity_plus/connectivity_plus.dart';
import 'dart:convert';
import 'package:bcrypt/bcrypt.dart'; // Import bcrypt
import 'package:environment_monitoring_app/services/api_service.dart';
import 'package:environment_monitoring_app/services/server_config_service.dart';
import 'package:environment_monitoring_app/services/retry_service.dart';
class AuthProvider with ChangeNotifier {
late final ApiService _apiService;
late final DatabaseHelper _dbHelper;
late final ServerConfigService _serverConfigService;
late final RetryService _retryService;
// --- Session & Profile State ---
String? _jwtToken;
String? _userEmail;
Map<String, dynamic>? _profileData;
bool get isLoggedIn => _jwtToken != null;
String? get userEmail => _userEmail;
Map<String, dynamic>? get profileData => _profileData;
// --- ADDED: Temporary password cache for auto-relogin ---
String? _tempOfflinePassword;
// --- App State ---
bool _isLoading = true;
bool _isFirstLogin = true;
DateTime? _lastSyncTimestamp;
bool get isLoading => _isLoading;
bool get isFirstLogin => _isFirstLogin;
DateTime? get lastSyncTimestamp => _lastSyncTimestamp;
// --- Cached Master Data ---
List<Map<String, dynamic>>? _allUsers;
List<Map<String, dynamic>>? _tarballStations;
List<Map<String, dynamic>>? _manualStations;
List<Map<String, dynamic>>? _tarballClassifications;
List<Map<String, dynamic>>? _riverManualStations;
List<Map<String, dynamic>>? _riverTriennialStations;
List<Map<String, dynamic>>? _departments;
List<Map<String, dynamic>>? _companies;
List<Map<String, dynamic>>? _positions;
List<Map<String, dynamic>>? _airClients;
List<Map<String, dynamic>>? _airManualStations;
List<Map<String, dynamic>>? _states;
List<Map<String, dynamic>>? _appSettings;
// --- START: MODIFIED PARAMETER LIMITS PROPERTIES ---
// The old generic list has been removed and replaced with three specific lists.
List<Map<String, dynamic>>? _npeParameterLimits;
List<Map<String, dynamic>>? _marineParameterLimits;
List<Map<String, dynamic>>? _riverParameterLimits;
// --- END: MODIFIED PARAMETER LIMITS PROPERTIES ---
List<Map<String, dynamic>>? _apiConfigs;
List<Map<String, dynamic>>? _ftpConfigs;
List<Map<String, dynamic>>? _documents;
List<Map<String, dynamic>>? _pendingRetries;
// --- Getters for UI access ---
List<Map<String, dynamic>>? get allUsers => _allUsers;
List<Map<String, dynamic>>? get tarballStations => _tarballStations;
List<Map<String, dynamic>>? get manualStations => _manualStations;
List<Map<String, dynamic>>? get tarballClassifications => _tarballClassifications;
List<Map<String, dynamic>>? get riverManualStations => _riverManualStations;
List<Map<String, dynamic>>? get riverTriennialStations => _riverTriennialStations;
List<Map<String, dynamic>>? get departments => _departments;
List<Map<String, dynamic>>? get companies => _companies;
List<Map<String, dynamic>>? get positions => _positions;
List<Map<String, dynamic>>? get airClients => _airClients;
List<Map<String, dynamic>>? get airManualStations => _airManualStations;
List<Map<String, dynamic>>? get states => _states;
List<Map<String, dynamic>>? get appSettings => _appSettings;
// --- START: GETTERS FOR NEW PARAMETER LIMITS ---
List<Map<String, dynamic>>? get npeParameterLimits => _npeParameterLimits;
List<Map<String, dynamic>>? get marineParameterLimits => _marineParameterLimits;
List<Map<String, dynamic>>? get riverParameterLimits => _riverParameterLimits;
// --- END: GETTERS FOR NEW PARAMETER LIMITS ---
List<Map<String, dynamic>>? get apiConfigs => _apiConfigs;
List<Map<String, dynamic>>? get ftpConfigs => _ftpConfigs;
List<Map<String, dynamic>>? get documents => _documents;
List<Map<String, dynamic>>? get pendingRetries => _pendingRetries;
// --- SharedPreferences Keys ---
static const String tokenKey = 'jwt_token';
static const String userEmailKey = 'user_email';
static const String profileDataKey = 'user_profile_data';
static const String lastSyncTimestampKey = 'last_sync_timestamp';
static const String isFirstLoginKey = 'is_first_login';
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();
}
Future<void> _loadSessionAndSyncData() async {
_isLoading = true;
notifyListeners();
final prefs = await SharedPreferences.getInstance();
_jwtToken = prefs.getString(tokenKey);
_userEmail = prefs.getString(userEmailKey);
_isFirstLogin = prefs.getBool(isFirstLoginKey) ?? true;
final activeApiConfig = await _serverConfigService.getActiveApiConfig();
if (activeApiConfig == null) {
debugPrint("AuthProvider: No active API config found. Setting default bootstrap URL.");
final initialConfig = {
'api_config_id': 0,
'config_name': 'Default Server',
'api_url': 'https://mms-apiv4.pstw.com.my/v1',
};
await _serverConfigService.setActiveApiConfig(initialConfig);
}
final lastSyncString = prefs.getString(lastSyncTimestampKey);
if (lastSyncString != null) {
try {
_lastSyncTimestamp = DateTime.parse(lastSyncString);
} catch (e) {
debugPrint("Error parsing last sync timestamp: $e");
prefs.remove(lastSyncTimestampKey);
}
}
await _loadDataFromCache();
if (_jwtToken != null) {
debugPrint('AuthProvider: Session loaded.');
// Sync logic moved to checkAndTransitionToOnlineSession to handle transitions correctly
} else {
debugPrint('AuthProvider: No active session. App is in offline mode.');
}
_isLoading = false;
notifyListeners();
}
/// Checks if the session is offline and attempts to transition to an online session by performing a silent re-login.
/// Returns true if a successful transition occurred.
Future<bool> checkAndTransitionToOnlineSession() async {
// Condition 1: Check connectivity
final connectivityResult = await Connectivity().checkConnectivity();
if (connectivityResult.contains(ConnectivityResult.none)) {
debugPrint("AuthProvider: No internet connection. Skipping transition check.");
return false;
}
// Condition 2: Check if currently in an offline session state
final bool inOfflineSession = _jwtToken != null && _jwtToken!.startsWith("offline-session-");
if (!inOfflineSession) {
// Already online or logged out, no transition needed.
// If online, trigger a normal sync to ensure data freshness on connection restoration.
if(_jwtToken != null) {
debugPrint("AuthProvider: Session is already online. Triggering standard sync.");
syncAllData();
}
return false;
}
// Condition 3: Check if we have the temporary password to attempt re-login.
if (_tempOfflinePassword == null || _userEmail == null) {
debugPrint("AuthProvider: In offline session, but no temporary password available for auto-relogin. Manual login required.");
return false;
}
debugPrint("AuthProvider: Internet detected in offline session. Attempting silent re-login for $_userEmail...");
try {
final result = await _apiService.login(_userEmail!, _tempOfflinePassword!);
if (result['success'] == true) {
debugPrint("AuthProvider: Silent re-login successful. Transitioning to online session.");
final String token = result['data']['token'];
final Map<String, dynamic> profile = result['data']['profile'];
// Use existing login method to set up session and trigger sync.
// Re-pass the password to ensure credentials are fully cached after transition.
await login(token, profile, _tempOfflinePassword!);
// Clear temporary password after successful transition
_tempOfflinePassword = null;
notifyListeners(); // Ensure UI updates after state change
return true;
} else {
// Silent login failed (e.g., password changed on another device).
// Keep user in offline mode for now. They will need to log out and log back in manually.
debugPrint("AuthProvider: Silent re-login failed: ${result['message']}");
return false;
}
} catch (e) {
debugPrint("AuthProvider: Silent re-login exception: $e");
return false;
}
}
/// Attempts to silently re-login to get a new token.
/// This can be called when a 401 Unauthorized error is detected.
Future<bool> attemptSilentRelogin() async {
final connectivityResult = await Connectivity().checkConnectivity();
if (connectivityResult.contains(ConnectivityResult.none)) {
debugPrint("AuthProvider: No internet for silent relogin.");
return false;
}
if (_tempOfflinePassword == null || _userEmail == null) {
debugPrint("AuthProvider: No cached credentials for silent relogin.");
return false;
}
debugPrint("AuthProvider: Session may be expired. Attempting silent re-login for $_userEmail...");
try {
final result = await _apiService.login(_userEmail!, _tempOfflinePassword!);
if (result['success'] == true) {
debugPrint("AuthProvider: Silent re-login successful.");
final String token = result['data']['token'];
final Map<String, dynamic> profile = result['data']['profile'];
await login(token, profile, _tempOfflinePassword!);
return true;
} else {
debugPrint("AuthProvider: Silent re-login failed: ${result['message']}");
return false;
}
} catch (e) {
debugPrint("AuthProvider: Silent re-login exception: $e");
return false;
}
}
Future<void> syncAllData({bool forceRefresh = false}) async {
final connectivityResult = await Connectivity().checkConnectivity();
if (connectivityResult.contains(ConnectivityResult.none)) {
debugPrint("AuthProvider: Device is OFFLINE. Skipping sync.");
return;
}
// Prevent sync attempts if token is an offline placeholder
if (_jwtToken == null || _jwtToken!.startsWith("offline-session-")) {
debugPrint("AuthProvider: Skipping sync, session is offline or null.");
return;
}
debugPrint("AuthProvider: Device is ONLINE. Starting delta sync.");
final prefs = await SharedPreferences.getInstance();
final String? lastSync = forceRefresh ? null : prefs.getString(lastSyncTimestampKey);
final newSyncTimestamp = DateTime.now().toUtc().toIso8601String();
final result = await _apiService.syncAllData(lastSyncTimestamp: lastSync);
if (result['success']) {
debugPrint("AuthProvider: Delta sync successful. Updating last sync timestamp.");
await prefs.setString(lastSyncTimestampKey, newSyncTimestamp);
_lastSyncTimestamp = DateTime.parse(newSyncTimestamp);
if (_isFirstLogin) {
await setIsFirstLogin(false);
debugPrint("AuthProvider: First successful sync complete. isFirstLogin flag set to false.");
}
await _loadDataFromCache();
notifyListeners();
} else {
debugPrint("AuthProvider: Delta sync failed. Timestamp not updated.");
}
}
// --- START: NEW METHOD FOR REGISTRATION SCREEN ---
Future<void> syncRegistrationData() async {
final connectivityResult = await Connectivity().checkConnectivity();
if (connectivityResult.contains(ConnectivityResult.none)) {
debugPrint("AuthProvider: Device is OFFLINE. Skipping registration data sync.");
return;
}
debugPrint("AuthProvider: Fetching data for registration screen...");
final result = await _apiService.syncRegistrationData();
if (result['success']) {
await _loadDataFromCache(); // Reload data from DB into the provider
notifyListeners(); // Notify the UI to rebuild
debugPrint("AuthProvider: Registration data loaded and UI notified.");
} else {
debugPrint("AuthProvider: Registration data sync failed.");
}
}
// --- END: NEW METHOD FOR REGISTRATION SCREEN ---
Future<void> refreshProfile() async {
final connectivityResult = await Connectivity().checkConnectivity();
if (connectivityResult.contains(ConnectivityResult.none)) {
debugPrint("AuthProvider: Device is OFFLINE. Skipping profile refresh.");
return;
}
if (_jwtToken == null || _jwtToken!.startsWith("offline-session-")) {
debugPrint("AuthProvider: Skipping profile refresh, session is offline or null.");
return;
}
final result = await _apiService.refreshProfile();
if (result['success']) {
await setProfileData(result['data']);
}
}
Future<void> _loadDataFromCache() async {
final prefs = await SharedPreferences.getInstance();
final profileJson = prefs.getString(profileDataKey);
if (profileJson != null) {
_profileData = jsonDecode(profileJson);
} else {
_profileData = await _dbHelper.loadProfile();
}
_allUsers = await _dbHelper.loadUsers();
_tarballStations = await _dbHelper.loadTarballStations();
_manualStations = await _dbHelper.loadManualStations();
_tarballClassifications = await _dbHelper.loadTarballClassifications();
_riverManualStations = await _dbHelper.loadRiverManualStations();
_riverTriennialStations = await _dbHelper.loadRiverTriennialStations();
_departments = await _dbHelper.loadDepartments();
_companies = await _dbHelper.loadCompanies();
_positions = await _dbHelper.loadPositions();
_airClients = await _dbHelper.loadAirClients();
_airManualStations = await _dbHelper.loadAirManualStations();
_states = await _dbHelper.loadStates();
_appSettings = await _dbHelper.loadAppSettings();
// --- START: LOAD DATA FROM NEW PARAMETER LIMIT TABLES ---
_npeParameterLimits = await _dbHelper.loadNpeParameterLimits();
_marineParameterLimits = await _dbHelper.loadMarineParameterLimits();
_riverParameterLimits = await _dbHelper.loadRiverParameterLimits();
// --- END: LOAD DATA FROM NEW PARAMETER LIMIT TABLES ---
_documents = await _dbHelper.loadDocuments();
_apiConfigs = await _dbHelper.loadApiConfigs();
_ftpConfigs = await _dbHelper.loadFtpConfigs();
_pendingRetries = await _retryService.getPendingTasks();
debugPrint("AuthProvider: All master data loaded from local DB cache.");
}
Future<void> refreshPendingTasks() async {
_pendingRetries = await _retryService.getPendingTasks();
notifyListeners();
}
Future<void> login(String token, Map<String, dynamic> profile, String password) async {
_jwtToken = token;
_userEmail = profile['email'];
// --- MODIFIED: Cache password on successful ONLINE login ---
_tempOfflinePassword = password;
final Map<String, dynamic> profileWithToken = Map.from(profile);
profileWithToken['token'] = token;
_profileData = profileWithToken;
final prefs = await SharedPreferences.getInstance();
await prefs.setString(tokenKey, token);
await prefs.setString(userEmailKey, _userEmail!);
await prefs.setString(profileDataKey, jsonEncode(_profileData));
await _dbHelper.saveProfile(_profileData!);
try {
debugPrint("AuthProvider: Hashing and caching password for offline login.");
final String hashedPassword = await compute(hashPassword, password);
await _dbHelper.upsertUserWithCredentials(
profile: profile,
passwordHash: hashedPassword,
);
debugPrint("AuthProvider: Credentials cached successfully.");
} catch (e) {
debugPrint("AuthProvider: Failed to cache password hash: $e");
}
debugPrint('AuthProvider: Login successful. Session and profile persisted.');
await syncAllData(forceRefresh: true);
}
Future<bool> loginOffline(String email, String password) async {
debugPrint("AuthProvider: Attempting offline login for user $email.");
try {
// 1. Retrieve stored hash from the local database based on email.
final String? storedHash = await _dbHelper.getUserPasswordHashByEmail(email);
if (storedHash == null || storedHash.isEmpty) {
debugPrint("AuthProvider DEBUG: Offline login failed for $email because NO HASH was found in local storage.");
return false;
}
// 2. Verify the provided password against the stored hash.
debugPrint("AuthProvider: Verifying password against stored hash...");
final bool passwordMatches = await compute(verifyPassword, CheckPasswordParams(password, storedHash));
if (passwordMatches) {
debugPrint("AuthProvider: Offline password verification successful.");
// 3. Load profile data from local storage.
final Map<String, dynamic>? cachedProfile = await _dbHelper.loadProfileByEmail(email);
if (cachedProfile == null) {
debugPrint("AuthProvider DEBUG: Offline login failed because profile data was missing, even though password matched.");
return false;
}
// 4. Initialize session state from cached profile data.
_jwtToken = "offline-session-${DateTime.now().millisecondsSinceEpoch}";
_userEmail = email;
// --- MODIFIED: Cache the password on successful OFFLINE login ---
_tempOfflinePassword = password;
final Map<String, dynamic> profileWithToken = Map.from(cachedProfile);
profileWithToken['token'] = _jwtToken;
_profileData = profileWithToken;
final prefs = await SharedPreferences.getInstance();
await prefs.setString(tokenKey, _jwtToken!);
await prefs.setString(userEmailKey, _userEmail!);
await prefs.setString(profileDataKey, jsonEncode(_profileData));
await _loadDataFromCache();
notifyListeners();
return true;
} else {
debugPrint("AuthProvider DEBUG: Offline login failed because password did not match stored hash (Hash Mismatch).");
return false;
}
} catch (e) {
debugPrint("AuthProvider DEBUG: An unexpected error occurred during offline login process: $e");
return false;
}
}
Future<void> setProfileData(Map<String, dynamic> data) async {
final String? existingToken = _profileData?['token'];
_profileData = data;
if (_profileData != null && existingToken != null) {
_profileData!['token'] = existingToken;
}
final prefs = await SharedPreferences.getInstance();
await prefs.setString(profileDataKey, jsonEncode(_profileData));
await _dbHelper.saveProfile(_profileData!); // Also save to local DB
notifyListeners();
}
Future<void> setIsFirstLogin(bool value) async {
_isFirstLogin = value;
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(isFirstLoginKey, value);
notifyListeners();
}
Future<void> logout() async {
debugPrint('AuthProvider: Initiating logout...');
_jwtToken = null;
_userEmail = null;
_profileData = null;
_lastSyncTimestamp = null;
_isFirstLogin = true;
// --- MODIFIED: Clear temp password on logout ---
_tempOfflinePassword = null;
_allUsers = null;
_tarballStations = null;
_manualStations = null;
_tarballClassifications = null;
_riverManualStations = null;
_riverTriennialStations = null;
_departments = null;
_companies = null;
_positions = null;
_airClients = null;
_airManualStations = null;
_states = null;
_appSettings = null;
// --- START: Clear new parameter limit lists ---
_npeParameterLimits = null;
_marineParameterLimits = null;
_riverParameterLimits = null;
// --- END: Clear new parameter limit lists ---
_documents = null;
_apiConfigs = null;
_ftpConfigs = null;
_pendingRetries = null;
final prefs = await SharedPreferences.getInstance();
await prefs.remove(tokenKey);
await prefs.remove(userEmailKey);
await prefs.remove(profileDataKey);
await prefs.remove(lastSyncTimestampKey);
await prefs.setBool(isFirstLoginKey, true);
debugPrint('AuthProvider: All session and cached data cleared.');
notifyListeners();
}
Future<Map<String, dynamic>> resetPassword(String email) {
return _apiService.post('auth/forgot-password', {'email': email});
}
}
class CheckPasswordParams {
final String password;
final String hash;
CheckPasswordParams(this.password, this.hash);
}
String hashPassword(String password) {
return BCrypt.hashpw(password, BCrypt.gensalt());
}
bool verifyPassword(CheckPasswordParams params) {
return BCrypt.checkpw(params.password, params.hash);
}