187 lines
5.5 KiB
Dart
187 lines
5.5 KiB
Dart
import 'package:flutter/foundation.dart';
|
|
import 'package:http/http.dart' as http;
|
|
import 'package:please_pay_me/core/config/app_config.dart';
|
|
import 'package:please_pay_me/core/storage/session_storage.dart';
|
|
import 'package:please_pay_me/data/api/api_client.dart';
|
|
import 'package:please_pay_me/data/demo/demo_backend.dart';
|
|
import 'package:please_pay_me/data/models/auth_user.dart';
|
|
import 'package:please_pay_me/data/repositories/api_repositories.dart';
|
|
import 'package:please_pay_me/data/repositories/repositories.dart';
|
|
|
|
enum SessionStatus { restoring, signedOut, signedIn }
|
|
|
|
/// Owns authentication and hands out repositories bound to the current
|
|
/// session, so the rest of the app never sees tokens or base URLs.
|
|
class SessionController extends ChangeNotifier {
|
|
SessionController({
|
|
required AppConfig config,
|
|
required SessionStorage storage,
|
|
http.Client? httpClient,
|
|
DemoBackend? demoBackend,
|
|
}) : _config = config,
|
|
_storage = storage,
|
|
_httpClient = httpClient,
|
|
_demo = demoBackend ?? DemoBackend();
|
|
|
|
final SessionStorage _storage;
|
|
final http.Client? _httpClient;
|
|
final DemoBackend _demo;
|
|
|
|
AppConfig _config;
|
|
SessionStatus _status = SessionStatus.restoring;
|
|
AuthUser? _user;
|
|
String? _token;
|
|
String _baseUrl = '';
|
|
bool _isDemo = false;
|
|
String? _lastError;
|
|
|
|
SessionStatus get status => _status;
|
|
AuthUser? get user => _user;
|
|
String get baseUrl => _baseUrl.isEmpty ? _config.apiBaseUrl : _baseUrl;
|
|
String get webCabinetUrl => _config.webCabinetUrl;
|
|
|
|
/// API address baked in at build time; empty when it must be derived from
|
|
/// the cabinet URL the user typed.
|
|
String get configuredApiBaseUrl => _config.apiBaseUrl;
|
|
bool get isDemo => _isDemo;
|
|
String? get lastError => _lastError;
|
|
http.Client? get httpClient => _httpClient;
|
|
|
|
/// Changes whenever the backing data source changes, so feature controllers
|
|
/// can be rebuilt from scratch on login / logout.
|
|
String get sessionKey => '${_isDemo ? 'demo' : baseUrl}:${_user?.userId ?? 0}';
|
|
|
|
BudgetRepository get budgets => _repositories.budgets;
|
|
ExpenseRepository get expenses => _repositories.expenses;
|
|
JobRepository get jobs => _repositories.jobs;
|
|
UserRepository get users => _repositories.users;
|
|
|
|
_Repositories get _repositories {
|
|
if (_isDemo || _token == null) return _demoRepositories;
|
|
return _apiRepositories ??= _buildApiRepositories();
|
|
}
|
|
|
|
_Repositories? _apiRepositories;
|
|
|
|
late final _Repositories _demoRepositories = _Repositories(
|
|
budgets: _demo.budgets,
|
|
expenses: _demo.expenses,
|
|
jobs: _demo.jobs,
|
|
users: _demo.users,
|
|
);
|
|
|
|
_Repositories _buildApiRepositories() {
|
|
final client = ApiClient(
|
|
baseUrl: baseUrl,
|
|
tokenProvider: () => _token,
|
|
httpClient: _httpClient,
|
|
onUnauthorized: signOut,
|
|
);
|
|
return _Repositories(
|
|
budgets: ApiBudgetRepository(client),
|
|
expenses: ApiExpenseRepository(client),
|
|
jobs: ApiJobRepository(client),
|
|
users: ApiUserRepository(client),
|
|
);
|
|
}
|
|
|
|
Future<void> restore() async {
|
|
final stored = await _storage.readAll();
|
|
final token = stored['token'];
|
|
final baseUrl = stored['baseUrl'];
|
|
|
|
if (token != null && token.isNotEmpty && baseUrl != null && baseUrl.isNotEmpty) {
|
|
_token = token;
|
|
_baseUrl = baseUrl;
|
|
_isDemo = false;
|
|
_apiRepositories = null;
|
|
_user = AuthUser.tryDecode(stored['user']);
|
|
_status = SessionStatus.signedIn;
|
|
notifyListeners();
|
|
return;
|
|
}
|
|
|
|
if (_config.demoMode && _config.apiBaseUrl.isEmpty) {
|
|
_status = SessionStatus.signedOut;
|
|
notifyListeners();
|
|
return;
|
|
}
|
|
|
|
_status = SessionStatus.signedOut;
|
|
notifyListeners();
|
|
}
|
|
|
|
/// Signs in with a JWT issued by Telegram or Yandex (`POST /api/auth/*`).
|
|
Future<bool> signInWithToken({required String baseUrl, required String token}) async {
|
|
final normalizedUrl = baseUrl.trim().replaceAll(RegExp(r'/$'), '');
|
|
final normalizedToken = token.trim();
|
|
|
|
if (normalizedUrl.isEmpty || normalizedToken.isEmpty) {
|
|
_lastError = 'Укажите адрес кабинета и токен';
|
|
notifyListeners();
|
|
return false;
|
|
}
|
|
|
|
_lastError = null;
|
|
_baseUrl = normalizedUrl;
|
|
_token = normalizedToken;
|
|
_isDemo = false;
|
|
_apiRepositories = null;
|
|
|
|
try {
|
|
final user = await _repositories.users.me();
|
|
_user = user;
|
|
_status = SessionStatus.signedIn;
|
|
_config = _config.copyWith(apiBaseUrl: normalizedUrl, demoMode: false);
|
|
await _storage.write(
|
|
token: normalizedToken,
|
|
baseUrl: normalizedUrl,
|
|
user: user.encode(),
|
|
);
|
|
notifyListeners();
|
|
return true;
|
|
} on ApiException catch (error) {
|
|
_token = null;
|
|
_apiRepositories = null;
|
|
_lastError = error.message;
|
|
_status = SessionStatus.signedOut;
|
|
notifyListeners();
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/// Runs the app against [DemoBackend] — no server required.
|
|
void startDemo() {
|
|
_isDemo = true;
|
|
_token = null;
|
|
_user = DemoBackend.user;
|
|
_lastError = null;
|
|
_status = SessionStatus.signedIn;
|
|
notifyListeners();
|
|
}
|
|
|
|
Future<void> signOut() async {
|
|
await _storage.clear();
|
|
_token = null;
|
|
_user = null;
|
|
_isDemo = false;
|
|
_apiRepositories = null;
|
|
_status = SessionStatus.signedOut;
|
|
notifyListeners();
|
|
}
|
|
}
|
|
|
|
class _Repositories {
|
|
const _Repositories({
|
|
required this.budgets,
|
|
required this.expenses,
|
|
required this.jobs,
|
|
required this.users,
|
|
});
|
|
|
|
final BudgetRepository budgets;
|
|
final ExpenseRepository expenses;
|
|
final JobRepository jobs;
|
|
final UserRepository users;
|
|
}
|