| 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<String, dynamic> 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<String> targetLangs; |
| final String level; |
|
|
| factory Profile.fromJson(Map<String, dynamic> 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<String, dynamic> toJson() => { |
| 'nativeLang': nativeLang, |
| 'targetLang': targetLang, |
| 'targetLangs': targetLangs, |
| 'level': level, |
| }; |
|
|
| Profile copyWith({String? nativeLang, String? targetLang, List<String>? targetLangs, String? level}) => |
| Profile( |
| nativeLang: nativeLang ?? this.nativeLang, |
| targetLang: targetLang ?? this.targetLang, |
| targetLangs: targetLangs ?? this.targetLangs, |
| level: level ?? this.level, |
| ); |
| } |
|
|
| class AuthController extends StateNotifier<AuthState> { |
| AuthController(this._api, this._store) : super(const AuthState(status: AuthStatus.unknown)) { |
| _initFromCache(); |
| } |
|
|
| final ApiClient _api; |
| final SessionStore _store; |
|
|
| |
| |
| Future<void> _initFromCache() async { |
| final token = await _store.read(); |
| if (token == null) { |
| state = const AuthState(status: AuthStatus.signedOut); |
| return; |
| } |
| |
| final cached = await _store.readProfile(); |
| if (cached != null) { |
| final profile = Profile.fromJson(cached); |
| state = AuthState(status: AuthStatus.ready, profile: profile); |
| } |
| |
| await refresh(); |
| } |
|
|
| Future<void> refresh() async { |
| final token = await _store.read(); |
| if (token == null) { |
| state = const AuthState(status: AuthStatus.signedOut); |
| return; |
| } |
| try { |
| final res = await _api.dio.get<Map<String, dynamic>>('/api/me'); |
| if (res.statusCode == 200 && res.data != null) { |
| final user = User.fromJson(res.data!['user'] as Map<String, dynamic>); |
| final pjson = res.data!['profile'] as Map<String, dynamic>?; |
| final profile = pjson == null ? null : Profile.fromJson(pjson); |
| final streak = (res.data!['streakCount'] as num?)?.toInt() ?? 0; |
| |
| 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'); |
| |
| |
| if (state.status == AuthStatus.unknown) { |
| state = const AuthState(status: AuthStatus.signedOut); |
| } |
| } |
| } |
|
|
| Future<void> 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<void> signOut() async { |
| await _store.clear(); |
| state = const AuthState(status: AuthStatus.signedOut); |
| } |
|
|
| Future<void> saveProfile({ |
| required String nativeLang, |
| required String targetLang, |
| required String level, |
| List<String>? targetLangs, |
| }) async { |
| final res = await _api.dio.put<Map<String, dynamic>>( |
| '/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<String, dynamic>); |
| 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()); |
| } |
| } |
|
|
| |
| Future<void> 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, |
| ); |
| } |
|
|
| |
| |
| Future<void> setLearningLanguages(List<String> 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, |
| ); |
| } |
|
|
| |
| Future<void> pingStreak() async { |
| try { |
| final res = await _api.dio.post<Map<String, dynamic>>('/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 (_) { |
| |
| } |
| } |
| } |
|
|
| final authControllerProvider = StateNotifierProvider<AuthController, AuthState>((ref) { |
| final api = ref.watch(apiClientProvider); |
| final store = ref.watch(sessionStoreProvider); |
| return AuthController(api, store); |
| }); |
|
|