PraxaLing / mobile /lib /auth /auth_provider.dart
Reubencf's picture
Deploy PraxaLing: component refactor, bug fixes, unified neo-brutal design, Qwen3.5 everywhere
2992997
Raw
History Blame Contribute Delete
8.19 kB
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;
/// On startup: immediately restore any cached profile so the UI is not blank,
/// then kick off a background refresh from the server.
Future<void> _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<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;
// 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<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(); // clears JWT + profile cache
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());
}
}
/// Change only the CEFR level without a full profile re-save form.
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,
);
}
/// Update the list of languages the user is actively learning.
/// The first entry becomes the primary `targetLang`.
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,
);
}
/// Ping the server to register today's activity and update the streak.
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 (_) {
// streak ping is best-effort; ignore failures
}
}
}
final authControllerProvider = StateNotifierProvider<AuthController, AuthState>((ref) {
final api = ref.watch(apiClientProvider);
final store = ref.watch(sessionStoreProvider);
return AuthController(api, store);
});