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 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 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 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 _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 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 post( String endpoint, Map 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 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> fetchSheets() async { final response = await get('/api/sheets/list'); final data = jsonDecode(response.body); return (data['sheets'] as List) .map((sheet) => Sheet.fromJson(sheet as Map)) .toList(); } /// Downloads PDF data for a sheet. /// /// Returns the raw PDF bytes. For caching, use [getPdfFileCached] instead. Future 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 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> fetchAnnotations(String sheetUuid) async { final response = await get('/api/sheets/$sheetUuid/annotations'); final data = jsonDecode(response.body) as List; return data .map((item) => ServerAnnotation.fromJson(item as Map)) .toList(); } /// Uploads annotations for a specific page of a sheet. /// /// The [lastModified] should be the current time when the annotation was saved. Future 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> uploadChanges(List 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).cast(); _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 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 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'; }