Complete refactor to clean up project

This commit is contained in:
2026-02-04 11:36:02 +01:00
parent 4f380b5444
commit 704bd0b928
29 changed files with 2057 additions and 1464 deletions

View File

@@ -0,0 +1,202 @@
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/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;
}
}
/// 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';
}

View File

@@ -0,0 +1,119 @@
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:hive/hive.dart';
import 'package:sheetless/core/models/change.dart';
import 'package:sheetless/core/models/config.dart';
/// Keys for secure storage (credentials and tokens).
enum SecureStorageKey { url, jwt, email }
/// Service for managing local storage operations.
///
/// Uses [FlutterSecureStorage] for sensitive data (credentials, tokens)
/// and [Hive] for general app data (config, sheet access times, change queue).
class StorageService {
// Hive box names
static const String _sheetAccessTimesBox = 'sheetAccessTimes';
static const String _configBox = 'config';
static const String _changeQueueBox = 'changeQueue';
late final FlutterSecureStorage _secureStorage;
StorageService() {
_secureStorage = FlutterSecureStorage(
aOptions: const AndroidOptions(encryptedSharedPreferences: true),
);
}
// ---------------------------------------------------------------------------
// Secure Storage (Credentials & Tokens)
// ---------------------------------------------------------------------------
/// Reads a value from secure storage.
Future<String?> readSecure(SecureStorageKey key) {
return _secureStorage.read(key: key.name);
}
/// Writes a value to secure storage.
///
/// Pass `null` to delete the key.
Future<void> writeSecure(SecureStorageKey key, String? value) {
return _secureStorage.write(key: key.name, value: value);
}
/// Clears the JWT token from secure storage.
Future<void> clearToken() {
return writeSecure(SecureStorageKey.jwt, null);
}
// ---------------------------------------------------------------------------
// Config
// ---------------------------------------------------------------------------
/// Reads the app configuration from storage.
Future<Config> readConfig() async {
final box = await Hive.openBox(_configBox);
return Config(
twoPageMode: box.get(Config.keyTwoPageMode) ?? false,
fullscreen: box.get(Config.keyFullscreen) ?? false,
);
}
/// Writes the app configuration to storage.
Future<void> writeConfig(Config config) async {
final box = await Hive.openBox(_configBox);
await box.put(Config.keyTwoPageMode, config.twoPageMode);
await box.put(Config.keyFullscreen, config.fullscreen);
}
// ---------------------------------------------------------------------------
// Sheet Access Times (for sorting by recency)
// ---------------------------------------------------------------------------
/// Reads all sheet access times.
///
/// Returns a map of sheet UUID to last access time.
Future<Map<String, DateTime>> readSheetAccessTimes() async {
final box = await Hive.openBox(_sheetAccessTimesBox);
return box.toMap().map(
(key, value) => MapEntry(key as String, DateTime.parse(value as String)),
);
}
/// Records when a sheet was last accessed.
Future<void> writeSheetAccessTime(String uuid, DateTime datetime) async {
final box = await Hive.openBox(_sheetAccessTimesBox);
await box.put(uuid, datetime.toIso8601String());
}
// ---------------------------------------------------------------------------
// Change Queue (Offline Sync)
// ---------------------------------------------------------------------------
/// Adds a change to the offline queue.
Future<void> writeChange(Change change) async {
final box = await Hive.openBox(_changeQueueBox);
await box.add(change.toMap());
}
/// Reads all pending changes from the queue.
Future<ChangeQueue> readChangeQueue() async {
final box = await Hive.openBox(_changeQueueBox);
final queue = ChangeQueue();
for (final map in box.values) {
queue.addChange(Change.fromMap(map as Map<dynamic, dynamic>));
}
return queue;
}
/// Removes the oldest change from the queue.
///
/// Call this after successfully syncing a change to the server.
Future<void> deleteOldestChange() async {
final box = await Hive.openBox(_changeQueueBox);
if (box.isNotEmpty) {
await box.deleteAt(0);
}
}
}