import 'dart:async'; import 'dart:convert'; import 'package:dio/dio.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import '../../../../core/config/api_endpoints.dart'; import '../../../../core/config/app_config.dart'; import '../../../../core/errors/error_handler.dart'; import '../../../../core/telemetry/telemetry.dart'; import '../../../notifications/presentation/providers/notification_providers.dart'; import '../models/auth_response.dart'; import 'tenant_provider.dart'; const _keyAccessToken = 'access_token'; const _keyRefreshToken = 'refresh_token'; final secureStorageProvider = Provider((ref) { return const FlutterSecureStorage(); }); final authStateProvider = StateNotifierProvider((ref) { return AuthNotifier(ref); }); final accessTokenProvider = FutureProvider((ref) async { final storage = ref.watch(secureStorageProvider); return storage.read(key: _keyAccessToken); }); class AuthState { final bool isAuthenticated; final bool isLoading; final String? error; final AuthUser? user; const AuthState({ this.isAuthenticated = false, this.isLoading = false, this.error, this.user, }); AuthState copyWith({ bool? isAuthenticated, bool? isLoading, String? error, AuthUser? user, }) { return AuthState( isAuthenticated: isAuthenticated ?? this.isAuthenticated, isLoading: isLoading ?? this.isLoading, error: error, user: user ?? this.user, ); } } class AuthNotifier extends StateNotifier { final Ref _ref; StreamSubscription? _notificationSubscription; AuthNotifier(this._ref) : super(const AuthState()); /// Try to restore session from stored tokens on app startup. /// Returns true if a valid session was restored. Future tryRestoreSession() async { final storage = _ref.read(secureStorageProvider); final accessToken = await storage.read(key: _keyAccessToken); if (accessToken == null) return false; // Decode JWT payload to get user info final user = _decodeUserFromJwt(accessToken); if (user == null) { // Token is malformed, try refresh return refreshToken(); } // Check if token is expired if (_isTokenExpired(accessToken)) { // Try to refresh return refreshToken(); } // Restore auth state state = state.copyWith( isAuthenticated: true, user: user, ); // Restore tenant context if (user.tenantId != null) { _ref.read(currentTenantIdProvider.notifier).state = user.tenantId; } // Reconnect notifications _connectNotifications(user.tenantId); // Restore telemetry session TelemetryService().setUserId(user.id); TelemetryService().setAccessToken(accessToken); TelemetryService().resumeAfterLogin(); return true; } /// Decode user info from JWT payload without verification. AuthUser? _decodeUserFromJwt(String token) { try { final parts = token.split('.'); if (parts.length != 3) return null; final payload = json.decode( utf8.decode(base64Url.decode(base64Url.normalize(parts[1]))), ) as Map; return AuthUser( id: payload['sub'] as String, email: payload['email'] as String? ?? '', name: payload['name'] as String? ?? payload['email'] as String? ?? '', roles: (payload['roles'] as List?)?.cast() ?? [], tenantId: payload['tenantId'] as String?, ); } catch (_) { return null; } } /// Check if JWT is expired (with 60s buffer). bool _isTokenExpired(String token) { try { final parts = token.split('.'); if (parts.length != 3) return true; final payload = json.decode( utf8.decode(base64Url.decode(base64Url.normalize(parts[1]))), ) as Map; final exp = payload['exp'] as int?; if (exp == null) return true; final expiry = DateTime.fromMillisecondsSinceEpoch(exp * 1000); return DateTime.now().isAfter(expiry.subtract(const Duration(seconds: 60))); } catch (_) { return true; } } /// Send SMS OTP to the given phone number. Future sendSmsCode(String phone) async { final config = _ref.read(appConfigProvider); final dio = Dio(BaseOptions(baseUrl: config.apiBaseUrl)); await dio.post(ApiEndpoints.smsSend, data: {'phone': phone, 'purpose': 'login'}); } /// Login with phone number + OTP (passwordless). Future loginWithOtp(String phone, String smsCode) async { state = state.copyWith(isLoading: true, error: null); try { final config = _ref.read(appConfigProvider); final dio = Dio(BaseOptions(baseUrl: config.apiBaseUrl)); final response = await dio.post( ApiEndpoints.loginOtp, data: {'phone': phone, 'smsCode': smsCode}, ); final authResponse = AuthResponse.fromJson(response.data as Map); final storage = _ref.read(secureStorageProvider); await storage.write(key: _keyAccessToken, value: authResponse.accessToken); await storage.write(key: _keyRefreshToken, value: authResponse.refreshToken); state = state.copyWith(isAuthenticated: true, isLoading: false, user: authResponse.user); _ref.invalidate(accessTokenProvider); if (authResponse.user.tenantId != null) { _ref.read(currentTenantIdProvider.notifier).state = authResponse.user.tenantId; } _connectNotifications(authResponse.user.tenantId); TelemetryService().setUserId(authResponse.user.id); TelemetryService().setAccessToken(authResponse.accessToken); TelemetryService().resumeAfterLogin(); return true; } on DioException catch (e) { final message = (e.response?.data is Map) ? e.response?.data['message'] : null; state = state.copyWith(isLoading: false, error: message?.toString() ?? '验证码登录失败'); return false; } catch (e) { state = state.copyWith(isLoading: false, error: ErrorHandler.friendlyMessage(e)); return false; } } Future login(String email, String password) async { state = state.copyWith(isLoading: true, error: null); try { final config = _ref.read(appConfigProvider); final dio = Dio(BaseOptions(baseUrl: config.apiBaseUrl)); final response = await dio.post( ApiEndpoints.login, data: {'email': email, 'password': password}, ); final authResponse = AuthResponse.fromJson(response.data as Map); final storage = _ref.read(secureStorageProvider); await storage.write( key: _keyAccessToken, value: authResponse.accessToken); await storage.write( key: _keyRefreshToken, value: authResponse.refreshToken); state = state.copyWith( isAuthenticated: true, isLoading: false, user: authResponse.user, ); _ref.invalidate(accessTokenProvider); // Set tenant context for all subsequent API calls if (authResponse.user.tenantId != null) { _ref.read(currentTenantIdProvider.notifier).state = authResponse.user.tenantId; } // Connect notification service after login _connectNotifications(authResponse.user.tenantId); TelemetryService().setUserId(authResponse.user.id); TelemetryService().setAccessToken(authResponse.accessToken); TelemetryService().resumeAfterLogin(); return true; } on DioException catch (e) { final message = (e.response?.data is Map) ? e.response?.data['message'] : null; state = state.copyWith( isLoading: false, error: message?.toString() ?? '登录失败', ); return false; } catch (e) { state = state.copyWith( isLoading: false, error: ErrorHandler.friendlyMessage(e), ); return false; } } Future logout() async { // Disconnect notification service _notificationSubscription?.cancel(); _notificationSubscription = null; _ref.read(notificationServiceProvider).disconnect(); // Clear tenant context _ref.read(currentTenantIdProvider.notifier).state = null; // Pause telemetry before clearing tokens await TelemetryService().pauseForLogout(); TelemetryService().clearUserId(); TelemetryService().clearAccessToken(); final storage = _ref.read(secureStorageProvider); await storage.delete(key: _keyAccessToken); await storage.delete(key: _keyRefreshToken); _ref.invalidate(accessTokenProvider); state = const AuthState(); } Future refreshToken() async { try { final storage = _ref.read(secureStorageProvider); final storedRefreshToken = await storage.read(key: _keyRefreshToken); if (storedRefreshToken == null) return false; final config = _ref.read(appConfigProvider); final dio = Dio(BaseOptions(baseUrl: config.apiBaseUrl)); final response = await dio.post( ApiEndpoints.refreshToken, data: {'refreshToken': storedRefreshToken}, ); final data = response.data as Map; final newAccessToken = data['accessToken'] as String; await storage.write(key: _keyAccessToken, value: newAccessToken); await storage.write( key: _keyRefreshToken, value: data['refreshToken'] as String); _ref.invalidate(accessTokenProvider); // Restore user state from new token if not already authenticated if (!state.isAuthenticated) { final user = _decodeUserFromJwt(newAccessToken); if (user != null) { state = state.copyWith(isAuthenticated: true, user: user); if (user.tenantId != null) { _ref.read(currentTenantIdProvider.notifier).state = user.tenantId; } _connectNotifications(user.tenantId); } } return true; } catch (_) { await logout(); return false; } } void _connectNotifications(String? tenantId) { if (tenantId == null) return; try { final notificationService = _ref.read(notificationServiceProvider); final notificationList = _ref.read(notificationListProvider.notifier); notificationService.connect(tenantId); // Forward incoming notifications to the list _notificationSubscription = notificationService.notifications.listen((notification) { notificationList.add(notification); }); } catch (_) { // Non-critical — app works without push notifications } } }