295 lines
9.0 KiB
Dart
295 lines
9.0 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.
|
|
///
|
|
/// Handles authentication, sheet listing, and PDF downloads.
|
|
/// Provides caching for PDF files on native platforms.
|
|
class ApiClient {
|
|
final _log = Logger('ApiClient');
|
|
final String baseUrl;
|
|
String? token;
|
|
|
|
ApiClient({required this.baseUrl, this.token});
|
|
|
|
/// Whether the client has an authentication token set.
|
|
bool get isAuthenticated => token != null;
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Authentication
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Authenticates with the server and stores the JWT token.
|
|
///
|
|
/// Throws an [Exception] if login fails.
|
|
Future<void> login(String username, String password) async {
|
|
_log.info('Logging in...');
|
|
final url = Uri.parse('$baseUrl/auth/login');
|
|
|
|
final response = await http.post(
|
|
url,
|
|
headers: {'Content-Type': 'application/json'},
|
|
body: jsonEncode({'username': username, 'password': password}),
|
|
);
|
|
|
|
if (response.statusCode == 200) {
|
|
final responseData = jsonDecode(response.body);
|
|
token = responseData['token'];
|
|
_log.info('Login successful');
|
|
} else {
|
|
throw ApiException(
|
|
'Login failed',
|
|
statusCode: response.statusCode,
|
|
body: response.body,
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Clears the authentication token.
|
|
void logout() {
|
|
token = null;
|
|
_log.info('Logged out successfully');
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// HTTP Helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
Map<String, String> _buildHeaders({bool isBinary = false}) {
|
|
return {
|
|
'Authorization': 'Bearer $token',
|
|
if (!isBinary) 'Content-Type': 'application/json',
|
|
};
|
|
}
|
|
|
|
/// Performs a GET request to the given endpoint.
|
|
Future<http.Response> get(String endpoint, {bool isBinary = false}) async {
|
|
final url = Uri.parse('$baseUrl$endpoint');
|
|
final response = await http.get(
|
|
url,
|
|
headers: _buildHeaders(isBinary: isBinary),
|
|
);
|
|
|
|
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) async {
|
|
final url = Uri.parse('$baseUrl$endpoint');
|
|
|
|
final response = await http.post(
|
|
url,
|
|
headers: _buildHeaders(),
|
|
body: jsonEncode(body),
|
|
);
|
|
|
|
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) async {
|
|
final url = Uri.parse('$baseUrl$endpoint');
|
|
|
|
final response = await http.post(
|
|
url,
|
|
headers: {
|
|
'Authorization': 'Bearer $token',
|
|
'Content-Type': 'application/x-www-form-urlencoded',
|
|
},
|
|
body: body,
|
|
);
|
|
|
|
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 url = Uri.parse('$baseUrl/api/health');
|
|
final response = await http
|
|
.get(url, headers: _buildHeaders())
|
|
.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';
|
|
}
|