import 'package:dio/dio.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_web_auth_2/flutter_web_auth_2.dart'; import '../core/api_client.dart'; import '../core/env.dart'; import 'session_store.dart'; class AuthState { const AuthState({required this.status, this.user, this.profile, this.streakCount = 0}); final AuthStatus status; final User? user; final Profile? profile; final int streakCount; AuthState copyWith({AuthStatus? status, User? user, Profile? profile, int? streakCount}) => AuthState( status: status ?? this.status, user: user ?? this.user, profile: profile ?? this.profile, streakCount: streakCount ?? this.streakCount, ); } enum AuthStatus { unknown, signedOut, noProfile, ready } class User { const User({required this.id, required this.hfUsername, this.email, this.avatarUrl}); final String id; final String hfUsername; final String? email; final String? avatarUrl; factory User.fromJson(Map j) => User( id: (j['id'] ?? j['hfId']) as String, hfUsername: j['hfUsername'] as String, email: j['email'] as String?, avatarUrl: j['avatarUrl'] as String?, ); } class Profile { const Profile({ required this.nativeLang, required this.targetLang, required this.targetLangs, required this.level, }); final String nativeLang; final String targetLang; final List targetLangs; final String level; factory Profile.fromJson(Map j) { final tg = j['targetLang'] as String; final list = (j['targetLangs'] as List?)?.map((e) => e.toString()).toList(); return Profile( nativeLang: j['nativeLang'] as String, targetLang: tg, targetLangs: (list == null || list.isEmpty) ? [tg] : list, level: j['level'] as String, ); } Map toJson() => { 'nativeLang': nativeLang, 'targetLang': targetLang, 'targetLangs': targetLangs, 'level': level, }; Profile copyWith({String? nativeLang, String? targetLang, List? targetLangs, String? level}) => Profile( nativeLang: nativeLang ?? this.nativeLang, targetLang: targetLang ?? this.targetLang, targetLangs: targetLangs ?? this.targetLangs, level: level ?? this.level, ); } class AuthController extends StateNotifier { AuthController(this._api, this._store) : super(const AuthState(status: AuthStatus.unknown)) { _initFromCache(); } final ApiClient _api; final SessionStore _store; /// On startup: immediately restore any cached profile so the UI is not blank, /// then kick off a background refresh from the server. Future _initFromCache() async { final token = await _store.read(); if (token == null) { state = const AuthState(status: AuthStatus.signedOut); return; } // Try to restore profile from local cache for instant UI. final cached = await _store.readProfile(); if (cached != null) { final profile = Profile.fromJson(cached); state = AuthState(status: AuthStatus.ready, profile: profile); } // Always refresh from server in the background. await refresh(); } Future refresh() async { final token = await _store.read(); if (token == null) { state = const AuthState(status: AuthStatus.signedOut); return; } try { final res = await _api.dio.get>('/api/me'); if (res.statusCode == 200 && res.data != null) { final user = User.fromJson(res.data!['user'] as Map); final pjson = res.data!['profile'] as Map?; final profile = pjson == null ? null : Profile.fromJson(pjson); final streak = (res.data!['streakCount'] as num?)?.toInt() ?? 0; // Persist locally. if (profile != null) { await _store.writeProfile(profile.toJson()); } else { await _store.clearProfile(); } state = AuthState( status: profile == null ? AuthStatus.noProfile : AuthStatus.ready, user: user, profile: profile, streakCount: streak, ); } else { await _store.clear(); state = const AuthState(status: AuthStatus.signedOut); } } catch (e) { debugPrint('auth refresh failed: $e'); // Keep the cached state if we already have one; only fall back to signedOut // if there is nothing cached. if (state.status == AuthStatus.unknown) { state = const AuthState(status: AuthStatus.signedOut); } } } Future signInWithHuggingFace() async { final loginUrl = '${Env.apiBaseUrl}/api/auth/login?client=mobile'; debugPrint('[auth] launching OAuth via $loginUrl (callback scheme=${Env.oauthCallbackScheme})'); try { final result = await FlutterWebAuth2.authenticate( url: loginUrl, callbackUrlScheme: Env.oauthCallbackScheme, ); debugPrint('[auth] received callback: $result'); final token = Uri.parse(result).queryParameters['token']; if (token == null || token.isEmpty) { throw Exception('OAuth callback missing token: $result'); } await _store.write(token); await refresh(); } on DioException catch (e) { debugPrint('[auth] dio error during refresh: $e'); rethrow; } catch (e, st) { debugPrint('[auth] sign-in failed: $e\n$st'); rethrow; } } Future signOut() async { await _store.clear(); // clears JWT + profile cache state = const AuthState(status: AuthStatus.signedOut); } Future saveProfile({ required String nativeLang, required String targetLang, required String level, List? targetLangs, }) async { final res = await _api.dio.put>( '/api/me/profile', data: { 'nativeLang': nativeLang, 'targetLang': targetLang, 'level': level, if (targetLangs != null) 'targetLangs': targetLangs, }, ); if (res.statusCode == 200 && res.data != null) { final token = res.data!['token'] as String?; if (token != null && token.isNotEmpty) { await _store.write(token); } final p = Profile.fromJson(res.data!['profile'] as Map); await _store.writeProfile(p.toJson()); state = state.copyWith(status: AuthStatus.ready, profile: p); } else { throw Exception((res.data?['message'] ?? res.data?['error'] ?? 'save failed').toString()); } } /// Change only the CEFR level without a full profile re-save form. Future updateLevel(String level) async { final current = state.profile; if (current == null) return; await saveProfile( nativeLang: current.nativeLang, targetLang: current.targetLang, targetLangs: current.targetLangs, level: level, ); } /// Update the list of languages the user is actively learning. /// The first entry becomes the primary `targetLang`. Future setLearningLanguages(List langs) async { final current = state.profile; if (current == null || langs.isEmpty) return; await saveProfile( nativeLang: current.nativeLang, targetLang: langs.first, targetLangs: langs, level: current.level, ); } /// Ping the server to register today's activity and update the streak. Future pingStreak() async { try { final res = await _api.dio.post>('/api/me/streak'); if (res.statusCode == 200 && res.data != null) { final count = (res.data!['streakCount'] as num?)?.toInt() ?? state.streakCount; state = state.copyWith(streakCount: count); } } catch (_) { // streak ping is best-effort; ignore failures } } } final authControllerProvider = StateNotifierProvider((ref) { final api = ref.watch(apiClientProvider); final store = ref.watch(sessionStoreProvider); return AuthController(api, store); });