You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
258 lines
8.3 KiB
258 lines
8.3 KiB
import 'dart:convert';
|
|
|
|
import 'package:flutter/foundation.dart';
|
|
import 'package:http/http.dart' as http;
|
|
import 'package:shared_preferences/shared_preferences.dart';
|
|
|
|
import '../env.dart';
|
|
import '../utils/connection_error.dart';
|
|
|
|
/// Logged-in user from the API.
|
|
class ApiUser {
|
|
final String id;
|
|
final String? email;
|
|
final String? displayName;
|
|
final String? avatarUrl;
|
|
|
|
const ApiUser({
|
|
required this.id,
|
|
this.email,
|
|
this.displayName,
|
|
this.avatarUrl,
|
|
});
|
|
|
|
ApiUser copyWith({
|
|
String? id,
|
|
String? email,
|
|
String? displayName,
|
|
String? avatarUrl,
|
|
}) {
|
|
return ApiUser(
|
|
id: id ?? this.id,
|
|
email: email ?? this.email,
|
|
displayName: displayName ?? this.displayName,
|
|
avatarUrl: avatarUrl ?? this.avatarUrl,
|
|
);
|
|
}
|
|
|
|
factory ApiUser.fromJson(Map<String, dynamic> json) {
|
|
final metadata = json['user_metadata'] as Map<String, dynamic>?;
|
|
final avatar = json['avatar_url'] as String?;
|
|
final avatarTrimmed = avatar?.trim();
|
|
return ApiUser(
|
|
id: json['id'] as String? ?? '',
|
|
email: json['email'] as String?,
|
|
displayName: json['display_name'] as String? ??
|
|
metadata?['display_name'] as String?,
|
|
avatarUrl: (avatarTrimmed != null && avatarTrimmed.isNotEmpty)
|
|
? avatarTrimmed
|
|
: null,
|
|
);
|
|
}
|
|
|
|
Map<String, dynamic> toJson() {
|
|
return {
|
|
'id': id,
|
|
if (email != null) 'email': email,
|
|
if (displayName != null) 'display_name': displayName,
|
|
if (avatarUrl != null) 'avatar_url': avatarUrl,
|
|
};
|
|
}
|
|
}
|
|
|
|
/// Auth service that uses the backend REST API (login, token storage).
|
|
class ApiAuthService {
|
|
ApiAuthService._();
|
|
static final ApiAuthService _instance = ApiAuthService._();
|
|
static ApiAuthService get instance => _instance;
|
|
|
|
static const String _tokenKey = 'api_auth_token';
|
|
static const String _userKey = 'api_auth_user';
|
|
|
|
final ValueNotifier<ApiUser?> currentUser = ValueNotifier<ApiUser?>(null);
|
|
String? _token;
|
|
|
|
Future<SharedPreferences> get _prefs async =>
|
|
await SharedPreferences.getInstance();
|
|
|
|
/// Call after app start to restore session from stored token.
|
|
Future<void> init() async {
|
|
final prefs = await _prefs;
|
|
_token = prefs.getString(_tokenKey);
|
|
final userJson = prefs.getString(_userKey);
|
|
if (_token != null && userJson != null) {
|
|
try {
|
|
final map = jsonDecode(userJson) as Map<String, dynamic>;
|
|
currentUser.value = ApiUser.fromJson(map);
|
|
} catch (_) {
|
|
await logout();
|
|
}
|
|
}
|
|
if (_token != null && currentUser.value == null) {
|
|
final ok = await _fetchSession();
|
|
if (!ok) await logout();
|
|
}
|
|
if (_token != null && currentUser.value != null) {
|
|
await _refreshProfile();
|
|
}
|
|
}
|
|
|
|
Future<bool> _fetchSession() async {
|
|
if (_token == null) return false;
|
|
try {
|
|
final uri = Uri.parse('$apiBaseUrl/api/auth/session');
|
|
final res = await http.get(
|
|
uri,
|
|
headers: {'Authorization': 'Bearer $_token'},
|
|
);
|
|
if (res.statusCode != 200) return false;
|
|
final data = jsonDecode(res.body) as Map<String, dynamic>;
|
|
final userJson = data['user'] as Map<String, dynamic>?;
|
|
if (userJson == null) return false;
|
|
currentUser.value = ApiUser.fromJson(userJson);
|
|
final prefs = await _prefs;
|
|
await prefs.setString(_userKey, jsonEncode(userJson));
|
|
return true;
|
|
} catch (_) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/// GET /api/auth/profile — fetches profile (display_name, email, avatar_url) and updates currentUser.
|
|
Future<void> _refreshProfile() async {
|
|
if (_token == null) return;
|
|
try {
|
|
final uri = Uri.parse('$apiBaseUrl/api/auth/profile');
|
|
final res = await http.get(
|
|
uri,
|
|
headers: {'Authorization': 'Bearer $_token'},
|
|
);
|
|
if (res.statusCode != 200) return;
|
|
final data = jsonDecode(res.body) as Map<String, dynamic>;
|
|
final id = data['id'] as String? ?? currentUser.value?.id ?? '';
|
|
final displayName = data['display_name'] as String?;
|
|
final email = data['email'] as String?;
|
|
final avatarUrl = data['avatar_url'] as String?;
|
|
final trimmedAvatar = avatarUrl?.trim();
|
|
final newUser = (currentUser.value ?? ApiUser(id: id)).copyWith(
|
|
displayName: displayName ?? currentUser.value?.displayName,
|
|
email: email ?? currentUser.value?.email,
|
|
avatarUrl: (trimmedAvatar != null && trimmedAvatar.isNotEmpty)
|
|
? trimmedAvatar
|
|
: currentUser.value?.avatarUrl,
|
|
);
|
|
currentUser.value = newUser;
|
|
final prefs = await _prefs;
|
|
await prefs.setString(_userKey, jsonEncode(newUser.toJson()));
|
|
} catch (_) {
|
|
// Keep existing user if profile fetch fails
|
|
}
|
|
}
|
|
|
|
/// Returns the current Bearer token for API requests, or null if not logged in.
|
|
String? get token => _token;
|
|
|
|
/// Login with email or username and password.
|
|
/// Returns null on success, or an error message string.
|
|
Future<String?> login(String emailOrUsername, String password) async {
|
|
final trimmed = emailOrUsername.trim();
|
|
if (trimmed.isEmpty) return 'Enter your email or username.';
|
|
if (password.isEmpty) return 'Enter your password.';
|
|
|
|
try {
|
|
final uri = Uri.parse('$apiBaseUrl/api/auth/login');
|
|
final res = await http.post(
|
|
uri,
|
|
headers: {'Content-Type': 'application/json'},
|
|
body: jsonEncode({
|
|
'email_or_username': trimmed,
|
|
'password': password,
|
|
}),
|
|
);
|
|
|
|
if (res.statusCode == 401) {
|
|
final data = jsonDecode(res.body) as Map<String, dynamic>?;
|
|
return data?['error'] as String? ??
|
|
data?['message'] as String? ??
|
|
'Invalid email or password.';
|
|
}
|
|
if (res.statusCode != 200) {
|
|
final data = jsonDecode(res.body) as Map<String, dynamic>?;
|
|
return data?['error'] as String? ??
|
|
data?['message'] as String? ??
|
|
'Login failed. Please try again.';
|
|
}
|
|
|
|
final data = jsonDecode(res.body) as Map<String, dynamic>;
|
|
_token = data['access_token'] as String?;
|
|
final userJson = data['user'] as Map<String, dynamic>?;
|
|
if (_token == null || userJson == null) {
|
|
return 'Invalid response from server.';
|
|
}
|
|
|
|
currentUser.value = ApiUser.fromJson(userJson);
|
|
final prefs = await _prefs;
|
|
await prefs.setString(_tokenKey, _token!);
|
|
await prefs.setString(_userKey, jsonEncode(userJson));
|
|
await _refreshProfile();
|
|
return null;
|
|
} catch (e) {
|
|
return connectionErrorMessage(e);
|
|
}
|
|
}
|
|
|
|
/// Register with email, password, and optional display name.
|
|
/// Returns null on success, or an error message string.
|
|
Future<String?> register(String email, String password, String displayName) async {
|
|
final trimmedEmail = email.trim();
|
|
if (trimmedEmail.isEmpty) return 'Enter your email.';
|
|
if (password.isEmpty) return 'Enter your password.';
|
|
|
|
try {
|
|
final uri = Uri.parse('$apiBaseUrl/api/auth/register');
|
|
final res = await http.post(
|
|
uri,
|
|
headers: {'Content-Type': 'application/json'},
|
|
body: jsonEncode({
|
|
'email': trimmedEmail,
|
|
'password': password,
|
|
'displayName': displayName.trim().isEmpty ? null : displayName.trim(),
|
|
}),
|
|
);
|
|
|
|
if (res.statusCode == 400 || res.statusCode == 422) {
|
|
final data = jsonDecode(res.body) as Map<String, dynamic>?;
|
|
return data?['message'] as String? ?? 'Registration failed.';
|
|
}
|
|
if (res.statusCode != 200) {
|
|
return 'Registration failed. Please try again.';
|
|
}
|
|
|
|
final data = jsonDecode(res.body) as Map<String, dynamic>;
|
|
_token = data['access_token'] as String?;
|
|
final userJson = data['user'] as Map<String, dynamic>?;
|
|
if (_token == null || userJson == null) {
|
|
return 'Invalid response from server.';
|
|
}
|
|
|
|
currentUser.value = ApiUser.fromJson(userJson);
|
|
final prefs = await _prefs;
|
|
await prefs.setString(_tokenKey, _token!);
|
|
await prefs.setString(_userKey, jsonEncode(userJson));
|
|
await _refreshProfile();
|
|
return null;
|
|
} catch (e) {
|
|
return connectionErrorMessage(e);
|
|
}
|
|
}
|
|
|
|
/// Clears session (token + user) and persists. Call this to log out.
|
|
Future<void> logout() async {
|
|
final prefs = await _prefs;
|
|
await prefs.remove(_tokenKey);
|
|
await prefs.remove(_userKey);
|
|
_token = null;
|
|
currentUser.value = null;
|
|
}
|
|
}
|