Files
patrickandClaude Sonnet 5 0b980d73bc feat(mobile): Flutter-App Grundgerüst (Login + Stempeln)
Employee-only App gegen bestehende Backend-API: Login inkl. TOTP-Schritt,
Ein-/Ausstempeln, Pause, Status "heute". Refresh-Token läuft nur als
HttpOnly-Cookie (Backend gibt ihn nie im Body zurück) → persistente
Cookie-Jar statt Secure-Storage-Refresh-Token. android/-Projektordner via
flutter create auf root@192.168.1.236 generiert, INTERNET-Permission
nachgetragen. Ungetestet auf echtem Gerät.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dzdm3EWeNdYFUuXFRJryA1
2026-09-09 22:59:22 +02:00

103 lines
3.4 KiB
Dart

import 'package:dio/dio.dart';
import 'package:cookie_jar/cookie_jar.dart';
import 'package:dio_cookie_manager/dio_cookie_manager.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:path_provider/path_provider.dart';
/// Zentraler HTTP-Client. Access-Token liegt in Secure Storage, Refresh-Token
/// kommt vom Backend AUSSCHLIESSLICH als HttpOnly-Cookie (siehe auth.py:
/// TokenResponse.refresh_token wird bei jedem Login/Refresh serverseitig auf
/// None gesetzt) → persistente Cookie-Jar nötig, kein Body-Refresh-Token.
class ApiClient {
ApiClient._internal();
static final ApiClient instance = ApiClient._internal();
static const _storage = FlutterSecureStorage();
static const _keyBaseUrl = 'api_base_url';
static const _keyAccessToken = 'access_token';
late final Dio dio;
late final PersistCookieJar _cookieJar;
void Function()? onUnauthorized;
String _baseUrl = 'https://timemaster.example.com/api/v1';
Future<void> init() async {
final saved = await _storage.read(key: _keyBaseUrl);
if (saved != null && saved.isNotEmpty) _baseUrl = saved;
final dir = await getApplicationSupportDirectory();
_cookieJar = PersistCookieJar(storage: FileStorage('${dir.path}/cookies'));
dio = Dio(BaseOptions(
baseUrl: _baseUrl,
connectTimeout: const Duration(seconds: 10),
receiveTimeout: const Duration(seconds: 15),
));
dio.interceptors.add(CookieManager(_cookieJar));
dio.interceptors.add(InterceptorsWrapper(
onRequest: (options, handler) async {
final token = await _storage.read(key: _keyAccessToken);
if (token != null) {
options.headers['Authorization'] = 'Bearer $token';
}
handler.next(options);
},
onError: (error, handler) async {
if (error.response?.statusCode == 401 &&
error.requestOptions.path != '/auth/refresh') {
final refreshed = await _tryRefresh();
if (refreshed) {
final opts = error.requestOptions;
final token = await _storage.read(key: _keyAccessToken);
opts.headers['Authorization'] = 'Bearer $token';
try {
final response = await dio.fetch(opts);
return handler.resolve(response);
} catch (_) {
// fällt durch zu logout unten
}
}
await clearSession();
onUnauthorized?.call();
}
handler.next(error);
},
));
}
Future<bool> _tryRefresh() async {
try {
// Cookie wird von CookieManager automatisch mitgeschickt.
final response = await dio.post('/auth/refresh');
final data = response.data as Map<String, dynamic>;
await _storage.write(key: _keyAccessToken, value: data['access_token'] as String);
return true;
} catch (_) {
return false;
}
}
Future<void> setBaseUrl(String url) async {
_baseUrl = url;
dio.options.baseUrl = url;
await _storage.write(key: _keyBaseUrl, value: url);
}
String get baseUrl => _baseUrl;
Future<void> saveAccessToken(String accessToken) async {
await _storage.write(key: _keyAccessToken, value: accessToken);
}
Future<void> clearSession() async {
await _storage.delete(key: _keyAccessToken);
await _cookieJar.deleteAll();
}
Future<bool> hasSession() async {
return (await _storage.read(key: _keyAccessToken)) != null;
}
}