Files
sheetless/lib/core/services/api_client.dart
T
julian e3ee24e06b
Build and deploy the latest docker container / deploy (push) Successful in 2m6s
Build and release the latest android apk / rolling-release-apk (push) Failing after 18m49s
Remove unnecessary log lines
2026-07-02 22:46:52 +02:00

363 lines
11 KiB
Dart

import 'dart:convert';
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:http/http.dart' as http;
import 'package:logging/logging.dart';
import 'package:path_provider/path_provider.dart';
import '../models/change.dart';
import '../models/sheet.dart';
/// HTTP client for communicating with the Sheetless API server.
class ApiClient {
final _log = Logger('ApiClient');
final String baseUrl;
String? accessToken; // Short-lived jwt
String? refreshToken; // Long lived binary token
ApiClient({required this.baseUrl, this.refreshToken});
// ---------------------------------------------------------------------------
// Authentication
// ---------------------------------------------------------------------------
Future<void> login(String username, String password) async {
_log.info('Logging in...');
final response = await post("/auth/login", {
'username': username,
'password': password,
}, authenticateWithAccessToken: false);
if (response.statusCode == 200) {
final responseData = jsonDecode(response.body);
accessToken = responseData['access_token'];
refreshToken = responseData['refresh_token'];
_log.info('Login successful');
} else {
throw ApiException(
'Login failed',
statusCode: response.statusCode,
body: response.body,
);
}
}
Future<void> refreshAccessToken() async {
_log.info('Refreshing access token...');
final response = await post("/auth/refresh", {
"refresh_token": refreshToken,
}, authenticateWithAccessToken: false);
if (response.statusCode == 200) {
final responseData = jsonDecode(response.body);
accessToken = responseData['access_token'];
_log.info('Token refresh successful');
} else {
throw ApiException(
'Token refresh failed',
statusCode: response.statusCode,
body: response.body,
);
}
}
Future<void> logout() async {
try {
await post(
"/auth/logout",
{},
authenticateWithAccessToken: true,
); // Ignoring response, logging out in any case
} catch (e) {
_log.fine('Logout failed: $e');
}
accessToken = null;
refreshToken = null;
_log.info('Logged out successfully');
}
// ---------------------------------------------------------------------------
// HTTP Helpers
// ---------------------------------------------------------------------------
Map<String, String> _buildHeaders({
bool authenticateWithAccessToken = false,
bool isBinary = false,
}) {
final headers = {
if (authenticateWithAccessToken) 'Authorization': 'Bearer $accessToken',
if (!isBinary) 'Content-Type': 'application/json',
};
return headers;
}
/// Performs a GET request to the given endpoint.
Future<http.Response> get(
String endpoint, {
bool isBinary = false,
bool authenticateWithAccessToken = true,
bool isRetry = false,
}) async {
final url = Uri.parse('$baseUrl$endpoint');
final response = await http.get(
url,
headers: _buildHeaders(
isBinary: isBinary,
authenticateWithAccessToken: authenticateWithAccessToken,
),
);
// Unauthorized, refresh access token
if (authenticateWithAccessToken && response.statusCode == 401 && !isRetry) {
await refreshAccessToken();
return get(
endpoint,
isBinary: isBinary,
authenticateWithAccessToken: authenticateWithAccessToken,
isRetry: true,
);
}
if (response.statusCode != 200) {
_log.warning(
"GET '$endpoint' failed: ${response.statusCode}\n${response.body}",
);
throw ApiException(
'GET request failed',
statusCode: response.statusCode,
body: response.body,
);
}
return response;
}
/// Performs a POST request with JSON body.
Future<http.Response> post(
String endpoint,
Map<String, dynamic> body, {
bool authenticateWithAccessToken = true,
bool isRetry = false,
}) async {
final url = Uri.parse('$baseUrl$endpoint');
final response = await http.post(
url,
headers: _buildHeaders(
authenticateWithAccessToken: authenticateWithAccessToken,
),
body: jsonEncode(body),
);
// Unauthorized, refresh access token
if (authenticateWithAccessToken && response.statusCode == 401 && !isRetry) {
await refreshAccessToken();
return post(
endpoint,
body,
authenticateWithAccessToken: authenticateWithAccessToken,
isRetry: true,
);
}
if (response.statusCode != 200 && response.statusCode != 201) {
_log.warning(
"POST '$endpoint' failed: ${response.statusCode}\n${response.body}",
);
throw ApiException(
'POST request failed',
statusCode: response.statusCode,
body: response.body,
);
}
return response;
}
/// Performs a POST request with form-encoded body.
Future<http.Response> postFormData(
String endpoint,
String body, {
bool isRetry = false,
}) async {
final url = Uri.parse('$baseUrl$endpoint');
final response = await http.post(
url,
headers: {
'Authorization': 'Bearer $accessToken',
'Content-Type': 'application/x-www-form-urlencoded',
},
body: body,
);
// Unauthorized, refresh access token
if (response.statusCode == 401 && !isRetry) {
await refreshAccessToken();
return postFormData(endpoint, body, isRetry: true);
}
if (response.statusCode != 200 && response.statusCode != 201) {
_log.warning(
"POST Form '$endpoint' failed: ${response.statusCode}\n${response.body}",
);
throw ApiException(
'POST Form request failed',
statusCode: response.statusCode,
body: response.body,
);
}
return response;
}
// ---------------------------------------------------------------------------
// Sheet Operations
// ---------------------------------------------------------------------------
/// Fetches the list of all sheets from the server.
Future<List<Sheet>> fetchSheets() async {
final response = await get('/api/sheets/list');
final data = jsonDecode(response.body);
return (data['sheets'] as List<dynamic>)
.map((sheet) => Sheet.fromJson(sheet as Map<String, dynamic>))
.toList();
}
/// Downloads PDF data for a sheet.
///
/// Returns the raw PDF bytes. For caching, use [getPdfFileCached] instead.
Future<Uint8List> fetchPdfData(String sheetUuid) async {
final response = await get('/api/sheets/get/$sheetUuid', isBinary: true);
_log.info('PDF downloaded for sheet $sheetUuid');
return response.bodyBytes;
}
/// Gets a cached PDF file for a sheet, downloading if necessary.
///
/// On web platforms, use [fetchPdfData] instead as file caching
/// is not supported.
Future<File> getPdfFileCached(String sheetUuid) async {
final cacheDir = await getTemporaryDirectory();
final cachedFile = File('${cacheDir.path}/$sheetUuid.pdf');
if (await cachedFile.exists()) {
_log.info('PDF found in cache: ${cachedFile.path}');
return cachedFile;
}
final pdfData = await fetchPdfData(sheetUuid);
await cachedFile.writeAsBytes(pdfData);
_log.info('PDF cached at: ${cachedFile.path}');
return cachedFile;
}
// ---------------------------------------------------------------------------
// Annotation Operations
// ---------------------------------------------------------------------------
/// Fetches all annotations for a sheet from the server.
///
/// Returns a list of [ServerAnnotation] objects containing page number,
/// lastModified timestamp, and the annotations JSON string.
Future<List<ServerAnnotation>> fetchAnnotations(String sheetUuid) async {
final response = await get('/api/sheets/$sheetUuid/annotations');
final data = jsonDecode(response.body) as List<dynamic>;
return data
.map((item) => ServerAnnotation.fromJson(item as Map<String, dynamic>))
.toList();
}
/// Uploads annotations for a specific page of a sheet.
///
/// The [lastModified] should be the current time when the annotation was saved.
Future<void> uploadAnnotation({
required String sheetUuid,
required int page,
required DateTime lastModified,
required String annotationsJson,
}) async {
await post('/api/sheets/$sheetUuid/annotations', {
'page': page,
'lastModified': lastModified.toIso8601String(),
'annotations': annotationsJson,
});
_log.info('Annotation uploaded for sheet $sheetUuid page $page');
}
// ---------------------------------------------------------------------------
// Change Sync Operations
// ---------------------------------------------------------------------------
/// Uploads a batch of changes to the server.
///
/// The server will apply changes based on their [createdAt] timestamps,
/// using the newest change for each field when resolving conflicts.
///
/// Returns the list of change indices that were successfully applied.
/// Throws [ApiException] if the request fails (e.g., offline).
Future<List<int>> uploadChanges(List<Change> changes) async {
if (changes.isEmpty) return [];
final response = await post('/api/changes/sync', {
'changes': changes.map((c) => c.toJson()).toList(),
});
final data = jsonDecode(response.body);
final applied = (data['applied'] as List<dynamic>).cast<int>();
_log.info('Uploaded ${changes.length} changes, ${applied.length} applied');
return applied;
}
/// Checks if the server is reachable.
///
/// Returns true if the server responds, false otherwise.
Future<bool> checkConnection() async {
try {
final response = await get(
"/api/health",
).timeout(const Duration(seconds: 5));
return response.statusCode == 200;
} catch (e) {
_log.fine('Connection check failed: $e');
return false;
}
}
}
/// Represents an annotation from the server.
class ServerAnnotation {
final int page;
final DateTime lastModified;
final String annotationsJson;
ServerAnnotation({
required this.page,
required this.lastModified,
required this.annotationsJson,
});
factory ServerAnnotation.fromJson(Map<String, dynamic> json) {
return ServerAnnotation(
page: json['page'] as int,
lastModified: DateTime.parse(json['lastModified'] as String),
annotationsJson: json['annotations'] as String,
);
}
}
/// Exception thrown when an API request fails.
class ApiException implements Exception {
final String message;
final int statusCode;
final String body;
ApiException(this.message, {required this.statusCode, required this.body});
@override
String toString() => 'ApiException: $message (status: $statusCode)\n$body';
}