diff --git a/docker/.dockerignore b/.dockerignore similarity index 69% rename from docker/.dockerignore rename to .dockerignore index 3ece515..153764f 100644 --- a/docker/.dockerignore +++ b/.dockerignore @@ -9,3 +9,7 @@ windows/ android/ ios/ *.iml +.pub-cache/ +.pub/ +coverage/ +.github \ No newline at end of file diff --git a/docker/nginx.conf b/docker/nginx.conf index 63bef35..ed0cdc0 100644 --- a/docker/nginx.conf +++ b/docker/nginx.conf @@ -3,9 +3,18 @@ events { } http { - include mime.types; + include /etc/nginx/mime.types; default_type application/octet-stream; + # Ensure correct MIME types for WASM and JS + types { + application/wasm wasm; + application/javascript js; + image/png png; + image/jpeg jpg; + image/gif gif; + } + server { listen 80; server_name localhost; @@ -19,11 +28,5 @@ http { add_header Cross-Origin-Embedder-Policy require-corp; add_header Cross-Origin-Opener-Policy same-origin; } - - # Ensure correct MIME types for WASM and JS - types { - application/wasm wasm; - application/javascript js; - } } } diff --git a/lib/data/config/openapi_config.dart b/lib/data/config/openapi_config.dart index 9c5aca0..724cb9d 100644 --- a/lib/data/config/openapi_config.dart +++ b/lib/data/config/openapi_config.dart @@ -26,6 +26,7 @@ class OpenApiConfig extends _$OpenApiConfig { _authentication.accessToken = () => _accessToken; final ApiClient apiClient = ApiClient(basePath: data.host, authentication: _authentication); + // 1. Create our custom client that handles Rate Limiting & Pre-Request Auth final interceptorClient = _RateLimitedAuthClient( inner: http.Client(), ensureToken: _ensureTokenExists, @@ -88,17 +89,8 @@ class OpenApiConfig extends _$OpenApiConfig { } } -class _CacheEntry { - final Future responseFuture; - final DateTime timestamp; - - _CacheEntry(this.responseFuture, this.timestamp); - - bool get isExpired => DateTime.now().difference(timestamp) > const Duration(minutes: 1); -} - -/// Custom HTTP Client that forces single-file execution (CrowdSec fix), -/// verifies tokens BEFORE the request is sent, and deduplicates identical GET requests. +/// Custom HTTP Client that forces single-file execution (CrowdSec fix) +/// and verifies tokens BEFORE the request is sent. class _RateLimitedAuthClient extends http.BaseClient { final http.Client inner; final Future Function() ensureToken; @@ -107,9 +99,6 @@ class _RateLimitedAuthClient extends http.BaseClient { // Mutex specifically for the request queue (Rate Limiting) final Mutex _requestMutex = Mutex(); - // Deduplicator Cache - final Map _cache = {}; - _RateLimitedAuthClient({ required this.inner, required this.ensureToken, @@ -118,84 +107,29 @@ class _RateLimitedAuthClient extends http.BaseClient { @override Future send(http.BaseRequest request) async { - // 1. We ONLY want to deduplicate/cache GET requests. - // Caching POST/PUT/DELETE can lead to severe data integrity issues. - if (request.method != 'GET') { - return await _executeNetworkCall(request); - } - - // 2. The URL contains both path and query parameters, making it a perfect cache key. - final cacheKey = request.url.toString(); - - // 3. Clean up expired entries to free memory - _cache.removeWhere((key, entry) => entry.isExpired); - - // 4. CACHE HIT: Return the result of the already running (or completed) request - if (_cache.containsKey(cacheKey)) { - debugPrint("DEDUPLICATOR: Cache hit for $cacheKey"); - final cachedResponse = await _cache[cacheKey]!.responseFuture; - return _toStreamedResponse(cachedResponse); - } - - debugPrint("DEDUPLICATOR: Cache miss for $cacheKey, executing..."); - - // 5. CACHE MISS: Create the Future that performs the request and reads it into memory. - final responseFuture = _executeAndReadResponse(request); - - // 6. Instantly put the Future into the cache. - // If another duplicate request fires 10ms from now, it will grab THIS future - // and wait for it to finish, rather than starting a new network call. - _cache[cacheKey] = _CacheEntry(responseFuture, DateTime.now()); - - try { - final response = await responseFuture; - return _toStreamedResponse(response); - } catch (e) { - // If the network call fails, wipe it from the cache so the next attempt can retry safely. - _cache.remove(cacheKey); - rethrow; - } - } - - /// Extracts the original Mutex/Auth logic into its own method - Future _executeNetworkCall(http.BaseRequest request) async { return await _requestMutex.protect(() async { + + // 1. Await token generation if it's missing. await ensureToken(); + // 2. Overwrite the header natively. + // If `ensureToken` just fetched a new token, we MUST inject it here + // because OpenAPI already built this request object with the old header. final currentToken = getToken(); if (currentToken.isNotEmpty) { request.headers['Authorization'] = 'Bearer $currentToken'; } - debugPrint("---------------------->>>>>>> $request"); + // 3. Send the request + debugPrint("---------------------->>>>>>> ${request}"); final response = await inner.send(request); - // Rate-limit delay (50ms) to bypass CrowdSec probing heuristics + // 4. Rate-limit delay (150ms) to bypass CrowdSec probing heuristics await Future.delayed(const Duration(milliseconds: 50)); return response; }); } - - /// Executes the request and buffers the body stream into memory so it can be shared - Future _executeAndReadResponse(http.BaseRequest request) async { - final streamedResponse = await _executeNetworkCall(request); - return await http.Response.fromStream(streamedResponse); - } - - /// Rebuilds a fresh StreamedResponse from the cached memory buffer - http.StreamedResponse _toStreamedResponse(http.Response response) { - return http.StreamedResponse( - Stream.value(response.bodyBytes), - response.statusCode, - contentLength: response.bodyBytes.length, - request: response.request, - headers: response.headers, - isRedirect: response.isRedirect, - persistentConnection: response.persistentConnection, - reasonPhrase: response.reasonPhrase, - ); - } } @Riverpod(keepAlive: true) diff --git a/lib/data/database/database.dart b/lib/data/database/database.dart index f84d47f..f20d0cf 100644 --- a/lib/data/database/database.dart +++ b/lib/data/database/database.dart @@ -91,8 +91,7 @@ class GroupEntities extends Table with CacheTable { class ImageEntities extends Table with CacheTable { TextColumn get id => text()(); IntColumn get type => intEnum()(); - TextColumn get filePath => text()(); - BoolColumn get isEmptyVal => boolean().withDefault(const Constant(false))(); + BlobColumn get image => blob().nullable()(); } @DataClassName('MemberDb') diff --git a/lib/data/database/database.g.dart b/lib/data/database/database.g.dart index b8774f9..874673b 100644 --- a/lib/data/database/database.g.dart +++ b/lib/data/database/database.g.dart @@ -1004,31 +1004,14 @@ class $ImageEntitiesTable extends ImageEntities type: DriftSqlType.int, requiredDuringInsert: true, ).withConverter($ImageEntitiesTable.$convertertype); - static const VerificationMeta _filePathMeta = const VerificationMeta( - 'filePath', - ); + static const VerificationMeta _imageMeta = const VerificationMeta('image'); @override - late final GeneratedColumn filePath = GeneratedColumn( - 'file_path', + late final GeneratedColumn image = GeneratedColumn( + 'image', aliasedName, - false, - type: DriftSqlType.string, - requiredDuringInsert: true, - ); - static const VerificationMeta _isEmptyValMeta = const VerificationMeta( - 'isEmptyVal', - ); - @override - late final GeneratedColumn isEmptyVal = GeneratedColumn( - 'is_empty_val', - aliasedName, - false, - type: DriftSqlType.bool, + true, + type: DriftSqlType.blob, requiredDuringInsert: false, - defaultConstraints: GeneratedColumn.constraintIsAlways( - 'CHECK ("is_empty_val" IN (0, 1))', - ), - defaultValue: const Constant(false), ); @override List get $columns => [ @@ -1039,8 +1022,7 @@ class $ImageEntitiesTable extends ImageEntities onlySession, id, type, - filePath, - isEmptyVal, + image, ]; @override String get aliasedName => _alias ?? actualTableName; @@ -1094,21 +1076,10 @@ class $ImageEntitiesTable extends ImageEntities } else if (isInserting) { context.missing(_idMeta); } - if (data.containsKey('file_path')) { - context.handle( - _filePathMeta, - filePath.isAcceptableOrUnknown(data['file_path']!, _filePathMeta), - ); - } else if (isInserting) { - context.missing(_filePathMeta); - } - if (data.containsKey('is_empty_val')) { + if (data.containsKey('image')) { context.handle( - _isEmptyValMeta, - isEmptyVal.isAcceptableOrUnknown( - data['is_empty_val']!, - _isEmptyValMeta, - ), + _imageMeta, + image.isAcceptableOrUnknown(data['image']!, _imageMeta), ); } return context; @@ -1150,14 +1121,10 @@ class $ImageEntitiesTable extends ImageEntities data['${effectivePrefix}type'], )!, ), - filePath: attachedDatabase.typeMapping.read( - DriftSqlType.string, - data['${effectivePrefix}file_path'], - )!, - isEmptyVal: attachedDatabase.typeMapping.read( - DriftSqlType.bool, - data['${effectivePrefix}is_empty_val'], - )!, + image: attachedDatabase.typeMapping.read( + DriftSqlType.blob, + data['${effectivePrefix}image'], + ), ); } @@ -1178,8 +1145,7 @@ class ImageDb extends DataClass implements Insertable { final bool onlySession; final String id; final ImageType type; - final String filePath; - final bool isEmptyVal; + final Uint8List? image; const ImageDb({ required this.isarId, required this.ttl, @@ -1188,8 +1154,7 @@ class ImageDb extends DataClass implements Insertable { required this.onlySession, required this.id, required this.type, - required this.filePath, - required this.isEmptyVal, + this.image, }); @override Map toColumns(bool nullToAbsent) { @@ -1205,8 +1170,9 @@ class ImageDb extends DataClass implements Insertable { $ImageEntitiesTable.$convertertype.toSql(type), ); } - map['file_path'] = Variable(filePath); - map['is_empty_val'] = Variable(isEmptyVal); + if (!nullToAbsent || image != null) { + map['image'] = Variable(image); + } return map; } @@ -1219,8 +1185,9 @@ class ImageDb extends DataClass implements Insertable { onlySession: Value(onlySession), id: Value(id), type: Value(type), - filePath: Value(filePath), - isEmptyVal: Value(isEmptyVal), + image: image == null && nullToAbsent + ? const Value.absent() + : Value(image), ); } @@ -1239,8 +1206,7 @@ class ImageDb extends DataClass implements Insertable { type: $ImageEntitiesTable.$convertertype.fromJson( serializer.fromJson(json['type']), ), - filePath: serializer.fromJson(json['filePath']), - isEmptyVal: serializer.fromJson(json['isEmptyVal']), + image: serializer.fromJson(json['image']), ); } @override @@ -1256,8 +1222,7 @@ class ImageDb extends DataClass implements Insertable { 'type': serializer.toJson( $ImageEntitiesTable.$convertertype.toJson(type), ), - 'filePath': serializer.toJson(filePath), - 'isEmptyVal': serializer.toJson(isEmptyVal), + 'image': serializer.toJson(image), }; } @@ -1269,8 +1234,7 @@ class ImageDb extends DataClass implements Insertable { bool? onlySession, String? id, ImageType? type, - String? filePath, - bool? isEmptyVal, + Value image = const Value.absent(), }) => ImageDb( isarId: isarId ?? this.isarId, ttl: ttl ?? this.ttl, @@ -1279,8 +1243,7 @@ class ImageDb extends DataClass implements Insertable { onlySession: onlySession ?? this.onlySession, id: id ?? this.id, type: type ?? this.type, - filePath: filePath ?? this.filePath, - isEmptyVal: isEmptyVal ?? this.isEmptyVal, + image: image.present ? image.value : this.image, ); ImageDb copyWithCompanion(ImageEntitiesCompanion data) { return ImageDb( @@ -1293,10 +1256,7 @@ class ImageDb extends DataClass implements Insertable { : this.onlySession, id: data.id.present ? data.id.value : this.id, type: data.type.present ? data.type.value : this.type, - filePath: data.filePath.present ? data.filePath.value : this.filePath, - isEmptyVal: data.isEmptyVal.present - ? data.isEmptyVal.value - : this.isEmptyVal, + image: data.image.present ? data.image.value : this.image, ); } @@ -1310,8 +1270,7 @@ class ImageDb extends DataClass implements Insertable { ..write('onlySession: $onlySession, ') ..write('id: $id, ') ..write('type: $type, ') - ..write('filePath: $filePath, ') - ..write('isEmptyVal: $isEmptyVal') + ..write('image: $image') ..write(')')) .toString(); } @@ -1325,8 +1284,7 @@ class ImageDb extends DataClass implements Insertable { onlySession, id, type, - filePath, - isEmptyVal, + $driftBlobEquality.hash(image), ); @override bool operator ==(Object other) => @@ -1339,8 +1297,7 @@ class ImageDb extends DataClass implements Insertable { other.onlySession == this.onlySession && other.id == this.id && other.type == this.type && - other.filePath == this.filePath && - other.isEmptyVal == this.isEmptyVal); + $driftBlobEquality.equals(other.image, this.image)); } class ImageEntitiesCompanion extends UpdateCompanion { @@ -1351,8 +1308,7 @@ class ImageEntitiesCompanion extends UpdateCompanion { final Value onlySession; final Value id; final Value type; - final Value filePath; - final Value isEmptyVal; + final Value image; const ImageEntitiesCompanion({ this.isarId = const Value.absent(), this.ttl = const Value.absent(), @@ -1361,8 +1317,7 @@ class ImageEntitiesCompanion extends UpdateCompanion { this.onlySession = const Value.absent(), this.id = const Value.absent(), this.type = const Value.absent(), - this.filePath = const Value.absent(), - this.isEmptyVal = const Value.absent(), + this.image = const Value.absent(), }); ImageEntitiesCompanion.insert({ this.isarId = const Value.absent(), @@ -1372,12 +1327,10 @@ class ImageEntitiesCompanion extends UpdateCompanion { this.onlySession = const Value.absent(), required String id, required ImageType type, - required String filePath, - this.isEmptyVal = const Value.absent(), + this.image = const Value.absent(), }) : ttl = Value(ttl), id = Value(id), - type = Value(type), - filePath = Value(filePath); + type = Value(type); static Insertable custom({ Expression? isarId, Expression? ttl, @@ -1386,8 +1339,7 @@ class ImageEntitiesCompanion extends UpdateCompanion { Expression? onlySession, Expression? id, Expression? type, - Expression? filePath, - Expression? isEmptyVal, + Expression? image, }) { return RawValuesInsertable({ if (isarId != null) 'isar_id': isarId, @@ -1397,8 +1349,7 @@ class ImageEntitiesCompanion extends UpdateCompanion { if (onlySession != null) 'only_session': onlySession, if (id != null) 'id': id, if (type != null) 'type': type, - if (filePath != null) 'file_path': filePath, - if (isEmptyVal != null) 'is_empty_val': isEmptyVal, + if (image != null) 'image': image, }); } @@ -1410,8 +1361,7 @@ class ImageEntitiesCompanion extends UpdateCompanion { Value? onlySession, Value? id, Value? type, - Value? filePath, - Value? isEmptyVal, + Value? image, }) { return ImageEntitiesCompanion( isarId: isarId ?? this.isarId, @@ -1421,8 +1371,7 @@ class ImageEntitiesCompanion extends UpdateCompanion { onlySession: onlySession ?? this.onlySession, id: id ?? this.id, type: type ?? this.type, - filePath: filePath ?? this.filePath, - isEmptyVal: isEmptyVal ?? this.isEmptyVal, + image: image ?? this.image, ); } @@ -1452,11 +1401,8 @@ class ImageEntitiesCompanion extends UpdateCompanion { $ImageEntitiesTable.$convertertype.toSql(type.value), ); } - if (filePath.present) { - map['file_path'] = Variable(filePath.value); - } - if (isEmptyVal.present) { - map['is_empty_val'] = Variable(isEmptyVal.value); + if (image.present) { + map['image'] = Variable(image.value); } return map; } @@ -1471,8 +1417,7 @@ class ImageEntitiesCompanion extends UpdateCompanion { ..write('onlySession: $onlySession, ') ..write('id: $id, ') ..write('type: $type, ') - ..write('filePath: $filePath, ') - ..write('isEmptyVal: $isEmptyVal') + ..write('image: $image') ..write(')')) .toString(); } @@ -5699,8 +5644,7 @@ typedef $$ImageEntitiesTableCreateCompanionBuilder = Value onlySession, required String id, required ImageType type, - required String filePath, - Value isEmptyVal, + Value image, }); typedef $$ImageEntitiesTableUpdateCompanionBuilder = ImageEntitiesCompanion Function({ @@ -5711,8 +5655,7 @@ typedef $$ImageEntitiesTableUpdateCompanionBuilder = Value onlySession, Value id, Value type, - Value filePath, - Value isEmptyVal, + Value image, }); class $$ImageEntitiesTableFilterComposer @@ -5760,13 +5703,8 @@ class $$ImageEntitiesTableFilterComposer builder: (column) => ColumnWithTypeConverterFilters(column), ); - ColumnFilters get filePath => $composableBuilder( - column: $table.filePath, - builder: (column) => ColumnFilters(column), - ); - - ColumnFilters get isEmptyVal => $composableBuilder( - column: $table.isEmptyVal, + ColumnFilters get image => $composableBuilder( + column: $table.image, builder: (column) => ColumnFilters(column), ); } @@ -5815,13 +5753,8 @@ class $$ImageEntitiesTableOrderingComposer builder: (column) => ColumnOrderings(column), ); - ColumnOrderings get filePath => $composableBuilder( - column: $table.filePath, - builder: (column) => ColumnOrderings(column), - ); - - ColumnOrderings get isEmptyVal => $composableBuilder( - column: $table.isEmptyVal, + ColumnOrderings get image => $composableBuilder( + column: $table.image, builder: (column) => ColumnOrderings(column), ); } @@ -5858,13 +5791,8 @@ class $$ImageEntitiesTableAnnotationComposer GeneratedColumnWithTypeConverter get type => $composableBuilder(column: $table.type, builder: (column) => column); - GeneratedColumn get filePath => - $composableBuilder(column: $table.filePath, builder: (column) => column); - - GeneratedColumn get isEmptyVal => $composableBuilder( - column: $table.isEmptyVal, - builder: (column) => column, - ); + GeneratedColumn get image => + $composableBuilder(column: $table.image, builder: (column) => column); } class $$ImageEntitiesTableTableManager @@ -5905,8 +5833,7 @@ class $$ImageEntitiesTableTableManager Value onlySession = const Value.absent(), Value id = const Value.absent(), Value type = const Value.absent(), - Value filePath = const Value.absent(), - Value isEmptyVal = const Value.absent(), + Value image = const Value.absent(), }) => ImageEntitiesCompanion( isarId: isarId, ttl: ttl, @@ -5915,8 +5842,7 @@ class $$ImageEntitiesTableTableManager onlySession: onlySession, id: id, type: type, - filePath: filePath, - isEmptyVal: isEmptyVal, + image: image, ), createCompanionCallback: ({ @@ -5927,8 +5853,7 @@ class $$ImageEntitiesTableTableManager Value onlySession = const Value.absent(), required String id, required ImageType type, - required String filePath, - Value isEmptyVal = const Value.absent(), + Value image = const Value.absent(), }) => ImageEntitiesCompanion.insert( isarId: isarId, ttl: ttl, @@ -5937,8 +5862,7 @@ class $$ImageEntitiesTableTableManager onlySession: onlySession, id: id, type: type, - filePath: filePath, - isEmptyVal: isEmptyVal, + image: image, ), withReferenceMapper: (p0) => p0 .map((e) => (e.readTable(table), BaseReferences(db, table, e))) diff --git a/lib/data/dto/global_data_dto.dart b/lib/data/dto/global_data_dto.dart index afa423e..710fb83 100644 --- a/lib/data/dto/global_data_dto.dart +++ b/lib/data/dto/global_data_dto.dart @@ -11,7 +11,7 @@ class GlobalDataDto { String get host => dotenv.env['API_HOST'] ?? "https://stick-it.lr-projects.de"; final List cameras; - GlobalDataDto({ + const GlobalDataDto({ required this.userId, required this.refreshToken, required this.cameras, diff --git a/lib/data/entity/image_entity.dart b/lib/data/entity/image_entity.dart index dfc3fcd..c50edad 100644 --- a/lib/data/entity/image_entity.dart +++ b/lib/data/entity/image_entity.dart @@ -1,3 +1,5 @@ +import 'dart:typed_data'; + import 'package:buff_lisa/data/entity/cache_entity.dart'; import 'package:buff_lisa/util/core/fast_hash.dart'; @@ -20,14 +22,12 @@ class ImageEntity extends CacheEntity { final ImageType type; - final String filePath; - final bool isEmpty; + final Uint8List? image; ImageEntity({ required this.id, required this.type, // Require the type in the constructor - required this.filePath, - this.isEmpty = false, + required this.image, super.keepAlive = false, super.hits, required super.ttl, @@ -39,7 +39,7 @@ class ImageEntity extends CacheEntity { return ImageEntity( id: id, type: type, // Ensure type is preserved on copy - filePath: filePath, + image: image, keepAlive: keepAlive ?? this.keepAlive, hits: hits ?? this.hits, ttl: ttl ?? this.ttl, diff --git a/lib/data/repository/group_repository.dart b/lib/data/repository/group_repository.dart index 9f4e0c1..33208e6 100644 --- a/lib/data/repository/group_repository.dart +++ b/lib/data/repository/group_repository.dart @@ -106,7 +106,7 @@ class GroupRepository extends CacheImpl implements IGroupRepository @override Future> doGetSortedByHits() async { - final res = await (db.select(db.groupEntities)..orderBy([(t) => OrderingTerm(expression: t.hits, mode: OrderingMode.asc)])).get(); + final res = await (db.select(db.groupEntities)..orderBy([(t) => OrderingTerm(expression: t.hits)])).get(); return res.map(_fromDb).toList(); } diff --git a/lib/data/repository/image_repository.dart b/lib/data/repository/image_repository.dart index 60d1eb0..aadda6c 100644 --- a/lib/data/repository/image_repository.dart +++ b/lib/data/repository/image_repository.dart @@ -1,4 +1,5 @@ -import 'dart:io'; +import 'dart:collection'; +import 'dart:typed_data'; import 'package:buff_lisa/data/config/openapi_config.dart'; import 'package:buff_lisa/data/database/database.dart'; @@ -12,7 +13,6 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/painting.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:http/http.dart' as http; -import 'package:path_provider/path_provider.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; part 'image_repository.g.dart'; @@ -28,42 +28,58 @@ abstract class IImageRepository implements CacheApi { class ImageRepository extends CacheImpl implements IImageRepository { final AppDatabase db; final Future Function(String) getImageUrl; - final String urlFileName; - final String urlSubFolder; @override final ImageType type; + // In-memory caching for ultra-fast UI rendering final Map> _activeRequests = {}; - final Map _bytesCache = {}; + + // Use a LinkedHashMap to maintain insertion order for a simple LRU memory cache + final LinkedHashMap _bytesCache = LinkedHashMap(); + final int _maxMemoryCacheItems = 50; // Prevents OOM crashes ImageRepository({ required this.db, required this.getImageUrl, - required this.urlFileName, - required this.urlSubFolder, required this.type, super.maxItems, super.ttlDuration, }); + // --- FLUTTER MEMORY CACHE MANAGEMENT --- + void _evictFromFlutterCache(String id) { if (_bytesCache.containsKey(id)) { MemoryImage(_bytesCache[id]!).evict(); + _bytesCache.remove(id); } } - void _precacheInFlutter(String id) { - if (_bytesCache.containsKey(id)) { - MemoryImage(_bytesCache[id]!).resolve(ImageConfiguration.empty); + void _precacheInFlutter(String id, Uint8List bytes) { + // Don't precache empty bytes (used for 404/empty states) + if (bytes.isEmpty) return; + + if (_bytesCache.length >= _maxMemoryCacheItems && !_bytesCache.containsKey(id)) { + final oldestKey = _bytesCache.keys.first; + _evictFromFlutterCache(oldestKey); } + + _bytesCache[id] = bytes; + + // Move to end (mark as recently used) + _bytesCache.remove(id); + _bytesCache[id] = bytes; + + MemoryImage(bytes).resolve(ImageConfiguration.empty); } + // --- DRIFT DB MAPPERS --- + ImageEntitiesCompanion _toCompanion(ImageEntity entity) { return ImageEntitiesCompanion( id: Value(entity.id), type: Value(entity.type), - filePath: Value(entity.filePath), - isEmptyVal: Value(entity.isEmpty), + image: Value(entity.image), isarId: Value(entity.isarId), ttl: Value(entity.ttl), hits: Value(entity.hits), @@ -76,8 +92,7 @@ class ImageRepository extends CacheImpl implements IImageRepository return ImageEntity( id: data.id, type: data.type, - filePath: data.filePath, - isEmpty: data.isEmptyVal, + image: data.image, keepAlive: data.keepAlive, hits: data.hits, ttl: data.ttl, @@ -85,47 +100,21 @@ class ImageRepository extends CacheImpl implements IImageRepository ); } + // --- CORE CACHE API IMPLEMENTATIONS --- + @override Future doDelete(int isarId) async { - final cachedImage = await doGet(isarId); - if (cachedImage != null) { - if (cachedImage.filePath.isNotEmpty) { - if (!kIsWeb) { - final file = File(cachedImage.filePath); - if (await file.exists()) { - await file.delete(); - } - } - } - _evictFromFlutterCache(cachedImage.id); - _bytesCache.remove(cachedImage.id); - } await (db.delete(db.imageEntities)..where((tbl) => tbl.isarId.equals(isarId))).go(); } @override Future doDeleteAll() async { - final items = await doGetAll(); - for (final item in items) { - if (item.filePath.isNotEmpty) { - if (!kIsWeb) { - final file = File(item.filePath); - if (await file.exists()) { - await file.delete(); - } - } - } - _evictFromFlutterCache(item.id); - } - _bytesCache.clear(); await (db.delete(db.imageEntities)..where((tbl) => tbl.type.equalsValue(type))).go(); } @override Future doDeleteMultiple(List isarIds) async { - for (final id in isarIds) { - await doDelete(id); - } + await (db.delete(db.imageEntities)..where((tbl) => tbl.isarId.isIn(isarIds))).go(); } @override @@ -149,14 +138,18 @@ class ImageRepository extends CacheImpl implements IImageRepository @override Future doGetSize() async { final countExp = db.imageEntities.isarId.count(); - final query = db.selectOnly(db.imageEntities)..where(db.imageEntities.type.equalsValue(type))..addColumns([countExp]); + final query = db.selectOnly(db.imageEntities) + ..where(db.imageEntities.type.equalsValue(type)) + ..addColumns([countExp]); final result = await query.getSingle(); return result.read(countExp) ?? 0; } @override Future> doGetSortedByHits() async { - final res = await (db.select(db.imageEntities)..where((tbl) => tbl.type.equalsValue(type))..orderBy([(t) => OrderingTerm(expression: t.hits, mode: OrderingMode.asc)])).get(); + final res = await (db.select(db.imageEntities) + ..where((tbl) => tbl.type.equalsValue(type)) + ..orderBy([(t) => OrderingTerm(expression: t.hits)])).get(); return res.map(_fromDb).toList(); } @@ -174,37 +167,57 @@ class ImageRepository extends CacheImpl implements IImageRepository @override Stream doWatchById(int isarId) { - return (db.select(db.imageEntities)..where((tbl) => tbl.isarId.equals(isarId))).watchSingleOrNull().map((res) => res == null ? null : _fromDb(res)); + return (db.select(db.imageEntities)..where((tbl) => tbl.isarId.equals(isarId))) + .watchSingleOrNull() + .map((res) => res == null ? null : _fromDb(res)) + .asBroadcastStream(); } - Future _getImagePath(String id) async { - if (kIsWeb) return ""; // No local files on web - final directory = await getApplicationDocumentsDirectory(); - return '${directory.path}/${urlSubFolder}_${type.name}_${id}_$urlFileName'; + @override + Stream watchImageBytes(String id) { + return doWatchById(fastHash('${type.name}_$id')).asyncMap((entity) async { + // Treat null or an explicitly empty Uint8List as no image + if (entity == null || entity.image == null || entity.image!.isEmpty) return null; + + if (_bytesCache.containsKey(id)) { + return _bytesCache[id]; + } + + _precacheInFlutter(id, entity.image!); + return entity.image; + }).asBroadcastStream(); } + // --- NETWORK AND DB CACHE OPERATIONS --- + @override Future fetchImage(String id, bool keepAlive) async { final isarId = fastHash('${type.name}_$id'); + + // 1. Check DB Cache First final cachedImage = await doGet(isarId); - if (cachedImage?.isEmpty == true) { - return null; - } else if (_bytesCache.containsKey(id)) { - _precacheInFlutter(id); - return _bytesCache[id]; - } else if (cachedImage?.filePath != null && cachedImage!.filePath.isNotEmpty) { - if (!kIsWeb) { - final file = File(cachedImage.filePath); - if (await file.exists()) { - final bytes = await file.readAsBytes(); - _bytesCache[id] = bytes; - _precacheInFlutter(id); - return bytes; - } + if (cachedImage != null) { + // Return null immediately if we previously cached an "empty" state for this ID + if (cachedImage.image != null && cachedImage.image!.isEmpty) { + return null; } + + // Fast In-Memory Cache + if (_bytesCache.containsKey(id)) { + _incrementHits(cachedImage); + return _bytesCache[id]; + } + + // Load from DB Blob + if (cachedImage.image != null && cachedImage.image!.isNotEmpty) { + _precacheInFlutter(id, cachedImage.image!); + _incrementHits(cachedImage); + return cachedImage.image; + } } + // 2. Network Fetch (with deduplication) if (_activeRequests.containsKey(id)) { return _activeRequests[id]; } @@ -224,73 +237,35 @@ class ImageRepository extends CacheImpl implements IImageRepository final imageUrl = await getImageUrl(id); if (imageUrl == null) { - await put(ImageEntity(id: id, type: type, filePath: "", isEmpty: true, keepAlive: keepAlive, ttl: DateTime.now(), onlySession: false)); + await _saveEmptyState(id, keepAlive); return null; } - final image = await http.get(Uri.parse(imageUrl)); - final filePath = await _getImagePath(id); - _bytesCache[id] = image.bodyBytes; - _precacheInFlutter(id); - - if (filePath != null && filePath.isNotEmpty) { - if (!kIsWeb) { - await File(filePath).writeAsBytes(image.bodyBytes); - } - await put(ImageEntity(id: id, type: type, filePath: filePath, keepAlive: keepAlive, ttl: DateTime.now(), onlySession: false)); + final response = await http.get(Uri.parse(imageUrl)); + + if (response.statusCode >= 200 && response.statusCode < 300) { + return await _saveAndPrecacheImage(id, response.bodyBytes, keepAlive); } else { - await put(ImageEntity(id: id, type: type, filePath: "", keepAlive: keepAlive, ttl: DateTime.now(), onlySession: false)); + debugPrint("HTTP Error fetching image $id: ${response.statusCode}"); + return null; } - - return image.bodyBytes; } catch (e) { - rethrow; + debugPrint("Network/Offline exception fetching image $id: $e"); + return null; } } - @override - Stream watchImageBytes(String id) { - return doWatchById(fastHash('${type.name}_$id')).asyncMap((entity) async { - if (entity == null || entity.isEmpty) { - return null; - } - if (_bytesCache.containsKey(id)) { - return _bytesCache[id]; - } - if (entity.filePath.isNotEmpty) { - if (!kIsWeb) { - final file = File(entity.filePath); - if (await file.exists()) { - final bytes = await file.readAsBytes(); - _bytesCache[id] = bytes; - _precacheInFlutter(id); - return bytes; - } - } - } - return null; - }); - } + @override Future overrideUrl(String id, String url, bool keepAlive) async { try { - final image = await http.get(Uri.parse(url)); - final filePath = await _getImagePath(id); - - _evictFromFlutterCache(id); - _bytesCache[id] = image.bodyBytes; - _precacheInFlutter(id); - - if (filePath != null && filePath.isNotEmpty) { - if (!kIsWeb) { - await File(filePath).writeAsBytes(image.bodyBytes); - } - await put(ImageEntity(id: id, type: type, filePath: filePath, keepAlive: keepAlive, ttl: DateTime.now(), onlySession: false)); + final response = await http.get(Uri.parse(url)); + if (response.statusCode >= 200 && response.statusCode < 300) { + return await _saveAndPrecacheImage(id, response.bodyBytes, keepAlive); } else { - await put(ImageEntity(id: id, type: type, filePath: "", keepAlive: keepAlive, ttl: DateTime.now(), onlySession: false)); + throw Exception("Failed to override image. Status: ${response.statusCode}"); } - return image.bodyBytes; } catch (e) { rethrow; } @@ -298,93 +273,149 @@ class ImageRepository extends CacheImpl implements IImageRepository @override Future addImage(String id, Uint8List image, bool keepAlive) async { - final filePath = await _getImagePath(id); - + await _saveAndPrecacheImage(id, image, keepAlive); + } + + // --- INTERNAL UTILITIES & PRUNING --- + + Future _incrementHits(ImageEntity entity) async { + await (db.update(db.imageEntities)..where((t) => t.isarId.equals(entity.isarId))) + .write(ImageEntitiesCompanion(hits: Value(entity.hits + 1))); + } + + Future _saveEmptyState(String id, bool keepAlive) async { + // We cache a 0-length Uint8List to signify that we know this image doesn't exist on the server. + // This prevents us from spamming the server with 404 requests. + final entity = ImageEntity( + id: id, + type: type, + image: Uint8List(0), + keepAlive: keepAlive, + ttl: _calculateTtl(), + onlySession: false + ); + await put(entity); + } + + Future _saveAndPrecacheImage(String id, Uint8List bytes, bool keepAlive) async { _evictFromFlutterCache(id); - _bytesCache[id] = image; - _precacheInFlutter(id); + _precacheInFlutter(id, bytes); + + final entity = ImageEntity( + id: id, + type: type, + image: bytes, + keepAlive: keepAlive, + ttl: _calculateTtl(), + onlySession: false + ); + + await put(entity); + + // Fire and forget pruning + _pruneCacheLimits().ignore(); + + return bytes; + } + + DateTime _calculateTtl() { + return DateTime.now().add(ttlDuration ?? const Duration(days: 7)); + } + + Future _pruneCacheLimits() async { + final now = DateTime.now(); - if (filePath != null && filePath.isNotEmpty) { - if (!kIsWeb) { - await File(filePath).writeAsBytes(image); + await (db.delete(db.imageEntities) + ..where((t) => t.type.equalsValue(type) & t.ttl.isSmallerThanValue(now) & t.keepAlive.equals(false))) + .go(); + + if (maxItems != null) { + final count = await doGetSize(); + + if (count > maxItems!) { + final excessCount = count - maxItems!; + + final toDeleteQuery = db.selectOnly(db.imageEntities) + ..addColumns([db.imageEntities.isarId]) + ..where(db.imageEntities.type.equalsValue(type) & db.imageEntities.keepAlive.equals(false)) + ..orderBy([OrderingTerm(expression: db.imageEntities.hits)]) + ..limit(excessCount); + + final rows = await toDeleteQuery.get(); + final idsToDelete = rows.map((row) => row.read(db.imageEntities.isarId)!).toList(); + + if (idsToDelete.isNotEmpty) { + await doDeleteMultiple(idsToDelete); + } } - await put(ImageEntity(id: id, type: type, filePath: filePath, keepAlive: keepAlive, ttl: DateTime.now(), onlySession: false)); - } else { - await put(ImageEntity(id: id, type: type, filePath: "", keepAlive: keepAlive, ttl: DateTime.now(), onlySession: false)); } } } // --- PROVIDERS --- -@Riverpod(keepAlive: true) +@riverpod IImageRepository groupProfileRepo(Ref ref) { return ImageRepository( db: ref.watch(driftRepoProvider), type: ImageType.group, getImageUrl: ref.watch(groupApiProvider).getGroupProfileImage, - urlSubFolder: "groups", - urlFileName: "group_profile.png", - maxItems: 20 + maxItems: 10, + ttlDuration: const Duration(days: 7), ); } -@Riverpod(keepAlive: true) +@riverpod IImageRepository groupProfileSmallRepo(Ref ref) { return ImageRepository( db: ref.watch(driftRepoProvider), type: ImageType.groupSmall, getImageUrl: ref.watch(groupApiProvider).getGroupProfileImageSmall, - urlSubFolder: "groups", - urlFileName: "group_profile_small.png", - maxItems: 500, + maxItems: 10, + ttlDuration: const Duration(days: 7), ); } -@Riverpod(keepAlive: true) +@riverpod IImageRepository groupPinImageRepo(Ref ref) { return ImageRepository( db: ref.watch(driftRepoProvider), type: ImageType.groupPin, getImageUrl: ref.watch(groupApiProvider).getGroupPinImage, - urlSubFolder: "groups", - urlFileName: "group_pin.png", maxItems: 50, + ttlDuration: const Duration(days: 30), ); } -@Riverpod(keepAlive: true) +@riverpod IImageRepository userImageSmallRepo(Ref ref) { return ImageRepository( db: ref.watch(driftRepoProvider), type: ImageType.userSmall, getImageUrl: ref.watch(userApiProvider).getUserProfileImageSmall, - urlSubFolder: "users", - urlFileName: "profile_small.png", maxItems: 500, + ttlDuration: const Duration(days: 7), ); } -@Riverpod(keepAlive: true) +@riverpod IImageRepository userImageRepo(Ref ref) { return ImageRepository( db: ref.watch(driftRepoProvider), type: ImageType.user, getImageUrl: ref.watch(userApiProvider).getUserProfileImage, - urlSubFolder: "users", - urlFileName: "profile.png", maxItems: 50, + ttlDuration: const Duration(days: 7), ); } -@Riverpod(keepAlive: true) +@riverpod IImageRepository pinImageRepository(Ref ref) { return ImageRepository( db: ref.watch(driftRepoProvider), type: ImageType.pin, getImageUrl: ref.watch(pinApiProvider).getPinImage, - urlSubFolder: "pins", - urlFileName: "pin.png", maxItems: 200, + ttlDuration: const Duration(days: 14), ); } diff --git a/lib/data/repository/image_repository.g.dart b/lib/data/repository/image_repository.g.dart index a32e11a..33661da 100644 --- a/lib/data/repository/image_repository.g.dart +++ b/lib/data/repository/image_repository.g.dart @@ -6,11 +6,11 @@ part of 'image_repository.dart'; // RiverpodGenerator // ************************************************************************** -String _$groupProfileRepoHash() => r'ef07ccba3485d0b6ca2bb7082fa643d097dd7663'; +String _$groupProfileRepoHash() => r'7e64cb1a9373a34a77db765bb682ec67a90c0fac'; /// See also [groupProfileRepo]. @ProviderFor(groupProfileRepo) -final groupProfileRepoProvider = Provider.internal( +final groupProfileRepoProvider = AutoDisposeProvider.internal( groupProfileRepo, name: r'groupProfileRepoProvider', debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') @@ -22,65 +22,68 @@ final groupProfileRepoProvider = Provider.internal( @Deprecated('Will be removed in 3.0. Use Ref instead') // ignore: unused_element -typedef GroupProfileRepoRef = ProviderRef; +typedef GroupProfileRepoRef = AutoDisposeProviderRef; String _$groupProfileSmallRepoHash() => - r'301e97967ce00d7650b1aa0a72076294e956ed51'; + r'0a6dbd96793526ff4e7aca27ac4c5e9dd4aacfbd'; /// See also [groupProfileSmallRepo]. @ProviderFor(groupProfileSmallRepo) -final groupProfileSmallRepoProvider = Provider.internal( - groupProfileSmallRepo, - name: r'groupProfileSmallRepoProvider', - debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') - ? null - : _$groupProfileSmallRepoHash, - dependencies: null, - allTransitiveDependencies: null, -); +final groupProfileSmallRepoProvider = + AutoDisposeProvider.internal( + groupProfileSmallRepo, + name: r'groupProfileSmallRepoProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$groupProfileSmallRepoHash, + dependencies: null, + allTransitiveDependencies: null, + ); @Deprecated('Will be removed in 3.0. Use Ref instead') // ignore: unused_element -typedef GroupProfileSmallRepoRef = ProviderRef; -String _$groupPinImageRepoHash() => r'2975d789398e68647d8ce465a6a89afaf39dd425'; +typedef GroupProfileSmallRepoRef = AutoDisposeProviderRef; +String _$groupPinImageRepoHash() => r'e440c4ed35767ccdab87bd78036946e9ab5a8f01'; /// See also [groupPinImageRepo]. @ProviderFor(groupPinImageRepo) -final groupPinImageRepoProvider = Provider.internal( - groupPinImageRepo, - name: r'groupPinImageRepoProvider', - debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') - ? null - : _$groupPinImageRepoHash, - dependencies: null, - allTransitiveDependencies: null, -); +final groupPinImageRepoProvider = + AutoDisposeProvider.internal( + groupPinImageRepo, + name: r'groupPinImageRepoProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$groupPinImageRepoHash, + dependencies: null, + allTransitiveDependencies: null, + ); @Deprecated('Will be removed in 3.0. Use Ref instead') // ignore: unused_element -typedef GroupPinImageRepoRef = ProviderRef; +typedef GroupPinImageRepoRef = AutoDisposeProviderRef; String _$userImageSmallRepoHash() => - r'18f438859496438725d3d2cff3d8992e6c34a0ed'; + r'd340486d559df46f0bc78c9c63f5f9805a6485ab'; /// See also [userImageSmallRepo]. @ProviderFor(userImageSmallRepo) -final userImageSmallRepoProvider = Provider.internal( - userImageSmallRepo, - name: r'userImageSmallRepoProvider', - debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') - ? null - : _$userImageSmallRepoHash, - dependencies: null, - allTransitiveDependencies: null, -); +final userImageSmallRepoProvider = + AutoDisposeProvider.internal( + userImageSmallRepo, + name: r'userImageSmallRepoProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$userImageSmallRepoHash, + dependencies: null, + allTransitiveDependencies: null, + ); @Deprecated('Will be removed in 3.0. Use Ref instead') // ignore: unused_element -typedef UserImageSmallRepoRef = ProviderRef; -String _$userImageRepoHash() => r'a14bb90d3ce7c937228a39ecaf9468d5f3494875'; +typedef UserImageSmallRepoRef = AutoDisposeProviderRef; +String _$userImageRepoHash() => r'450d079b8f3acc54bd748e56cf39ec65eb1b2be0'; /// See also [userImageRepo]. @ProviderFor(userImageRepo) -final userImageRepoProvider = Provider.internal( +final userImageRepoProvider = AutoDisposeProvider.internal( userImageRepo, name: r'userImageRepoProvider', debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') @@ -92,24 +95,25 @@ final userImageRepoProvider = Provider.internal( @Deprecated('Will be removed in 3.0. Use Ref instead') // ignore: unused_element -typedef UserImageRepoRef = ProviderRef; +typedef UserImageRepoRef = AutoDisposeProviderRef; String _$pinImageRepositoryHash() => - r'77ae4b04a8a1f4de84e0aacaea1716cc91cfbb77'; + r'aa2c562c929136d664b9cac83b1c133c18c46414'; /// See also [pinImageRepository]. @ProviderFor(pinImageRepository) -final pinImageRepositoryProvider = Provider.internal( - pinImageRepository, - name: r'pinImageRepositoryProvider', - debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') - ? null - : _$pinImageRepositoryHash, - dependencies: null, - allTransitiveDependencies: null, -); +final pinImageRepositoryProvider = + AutoDisposeProvider.internal( + pinImageRepository, + name: r'pinImageRepositoryProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$pinImageRepositoryHash, + dependencies: null, + allTransitiveDependencies: null, + ); @Deprecated('Will be removed in 3.0. Use Ref instead') // ignore: unused_element -typedef PinImageRepositoryRef = ProviderRef; +typedef PinImageRepositoryRef = AutoDisposeProviderRef; // ignore_for_file: type=lint // ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package diff --git a/lib/data/repository/member_repository.dart b/lib/data/repository/member_repository.dart index 2fb1b8f..0c4030e 100644 --- a/lib/data/repository/member_repository.dart +++ b/lib/data/repository/member_repository.dart @@ -93,7 +93,7 @@ class MemberRepository extends CacheImpl implements IMemberReposi @override Future> doGetSortedByHits() async { - final res = await (db.select(db.memberEntities)..orderBy([(t) => OrderingTerm(expression: t.hits, mode: OrderingMode.asc)])).get(); + final res = await (db.select(db.memberEntities)..orderBy([(t) => OrderingTerm(expression: t.hits)])).get(); return res.map(_fromDb).toList(); } diff --git a/lib/data/repository/pin_repository.dart b/lib/data/repository/pin_repository.dart index 703334c..abe58a3 100644 --- a/lib/data/repository/pin_repository.dart +++ b/lib/data/repository/pin_repository.dart @@ -108,7 +108,7 @@ class PinRepository extends CacheImpl implements IPinRepository { @override Future> doGetSortedByHits() async { - final res = await (db.select(db.pinEntities)..orderBy([(t) => OrderingTerm(expression: t.hits, mode: OrderingMode.asc)])).get(); + final res = await (db.select(db.pinEntities)..orderBy([(t) => OrderingTerm(expression: t.hits)])).get(); return res.map(_fromDb).toList(); } @@ -245,7 +245,7 @@ class PinLikeRepository extends CacheImpl implements IPinLikeRepo @override Future> doGetSortedByHits() async { - final res = await (db.select(db.pinLikeEntities)..orderBy([(t) => OrderingTerm(expression: t.hits, mode: OrderingMode.asc)])).get(); + final res = await (db.select(db.pinLikeEntities)..orderBy([(t) => OrderingTerm(expression: t.hits)])).get(); return res.map(_fromDb).toList(); } diff --git a/lib/data/repository/user_pins_repository.dart b/lib/data/repository/user_pins_repository.dart index d4870a7..fe4347a 100644 --- a/lib/data/repository/user_pins_repository.dart +++ b/lib/data/repository/user_pins_repository.dart @@ -82,7 +82,7 @@ class UserPinsRepository extends CacheImpl implements IUserPinsR @override Future> doGetSortedByHits() async { - final res = await (db.select(db.userPinsEntities)..orderBy([(t) => OrderingTerm(expression: t.hits, mode: OrderingMode.asc)])).get(); + final res = await (db.select(db.userPinsEntities)..orderBy([(t) => OrderingTerm(expression: t.hits)])).get(); return res.map(_fromDb).toList(); } diff --git a/lib/data/repository/user_repository.dart b/lib/data/repository/user_repository.dart index 221c162..8f3fb82 100644 --- a/lib/data/repository/user_repository.dart +++ b/lib/data/repository/user_repository.dart @@ -90,7 +90,7 @@ class UserRepository extends CacheImpl implements IUserRepository { @override Future> doGetSortedByHits() async { - final res = await (db.select(db.userEntities)..orderBy([(t) => OrderingTerm(expression: t.hits, mode: OrderingMode.asc)])).get(); + final res = await (db.select(db.userEntities)..orderBy([(t) => OrderingTerm(expression: t.hits)])).get(); return res.map(_fromDb).toList(); } @@ -187,7 +187,7 @@ class UserLikeRepository extends CacheImpl implements IUserLikeR @override Future> doGetSortedByHits() async { - final res = await (db.select(db.userLikeEntities)..orderBy([(t) => OrderingTerm(expression: t.hits, mode: OrderingMode.asc)])).get(); + final res = await (db.select(db.userLikeEntities)..orderBy([(t) => OrderingTerm(expression: t.hits)])).get(); return res.map(_fromDb).toList(); } diff --git a/lib/features/auth/presentation/logout_screen.dart b/lib/features/auth/presentation/logout_screen.dart index 61a86e4..e1be62d 100644 --- a/lib/features/auth/presentation/logout_screen.dart +++ b/lib/features/auth/presentation/logout_screen.dart @@ -6,6 +6,7 @@ import 'package:buff_lisa/data/repository/user_pins_repository.dart'; import 'package:buff_lisa/data/repository/user_repository.dart'; import 'package:buff_lisa/data/service/global_data_service.dart'; import 'package:buff_lisa/data/service/shared_preferences_service.dart'; +import 'package:buff_lisa/data/service/syncing_service.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_cache_manager/flutter_cache_manager.dart'; @@ -14,7 +15,8 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; class LogoutScreen extends ConsumerStatefulWidget { - const LogoutScreen({super.key}); + final bool isCacheOnly; + const LogoutScreen({super.key, this.isCacheOnly = false}); @override ConsumerState createState() => _LogoutScreenState(); @@ -67,32 +69,39 @@ class _LogoutScreenState extends ConsumerState { await mgmt.reset(); } - await sharedPreferences.clear(); + if (!widget.isCacheOnly) { + await sharedPreferences.clear(); + } await DefaultCacheManager().emptyCache(); // 4. Invalidate the syncing service last (it depends on userId) ref.invalidate(lastSeenProvider); - // 5. Finally, logout in GlobalDataService - await ref.read(globalDataServiceProvider.notifier).logout(); + if (widget.isCacheOnly) { + ref.read(syncingServiceProvider.notifier).toInit(); + await ref.read(syncingServiceProvider.notifier).syncToBackend(); + } else { + // 5. Finally, logout in GlobalDataService + await ref.read(globalDataServiceProvider.notifier).logout(); + } if (!mounted) return; - context.goNamed("login"); + context.goNamed(widget.isCacheOnly ? "home" : "login"); } @override Widget build(BuildContext context) { - return const Scaffold( + return Scaffold( body: Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - CircularProgressIndicator(), - SizedBox(height: 15), + const CircularProgressIndicator(), + const SizedBox(height: 15), Text( - "Logging out... Please wait.", + widget.isCacheOnly ? "Deleting cache... Please wait." : "Logging out... Please wait.", textAlign: TextAlign.center, - style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold), + style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold), ), ], ), diff --git a/lib/features/camera/presentation/camera.dart b/lib/features/camera/presentation/camera.dart index 767c5ac..78a6813 100644 --- a/lib/features/camera/presentation/camera.dart +++ b/lib/features/camera/presentation/camera.dart @@ -68,35 +68,42 @@ class _CameraState extends ConsumerState with WidgetsBindingObserver { final cameras = ref.watch(globalDataServiceProvider.select((t) => t.cameras)); final cameraFlashMode = ref.watch(cameraTorchProvider); final groupIds = ref.watch(groupOrderServiceProvider); - return Scaffold(body: Column( - children: [ - Expanded( - child: Stack( - children: [ - Align( - alignment: Alignment.bottomCenter, - // We handle the AsyncValue of the CONTROLLER here - child: controllerAsync.when( - loading: () => const Center(child: CircularProgressIndicator()), - error: (err, stack) => Center(child: Text("Camera Error: $err")), - data: (controller) { - // Once controller is ready, we check the Values state - return cameraStateAsync.when( - loading: () => const Center(child: CircularProgressIndicator()), - error: (err, stack) => Text(err.toString()), - data: (cameraState) => GestureDetector( - onDoubleTap: ref.read(cameraIndexProvider.notifier).increment, - onScaleStart: (_) => basScaleFactor = scaleFactor, - onScaleUpdate: (details) => handleZoom(details, controller, cameraState), - child: Padding( - padding: const EdgeInsets.all(5.0), - child: CameraPreview(controller), + return Scaffold( + body: SafeArea( + child: Column( + children: [ + Expanded( + child: Stack( + children: [ + Positioned.fill( + // We handle the AsyncValue of the CONTROLLER here + child: controllerAsync.when( + loading: () => const Center(child: CircularProgressIndicator()), + error: (err, stack) => Center(child: Text("Camera Error: $err")), + data: (controller) { + // Once controller is ready, we check the Values state + return cameraStateAsync.when( + loading: () => const Center(child: CircularProgressIndicator()), + error: (err, stack) => Text(err.toString()), + data: (cameraState) => GestureDetector( + onDoubleTap: ref.read(cameraIndexProvider.notifier).increment, + onScaleStart: (_) => basScaleFactor = scaleFactor, + onScaleUpdate: (details) => handleZoom(details, controller, cameraState), + child: LayoutBuilder( + builder: (context, constraints) { + return Center( + child: AspectRatio( + aspectRatio: controller.value.aspectRatio, + child: CameraPreview(controller), + ), + ); + }, + ), ), - ), - ); - }, + ); + }, + ), ), - ), Align( alignment: FractionalOffset.bottomCenter, child: Padding( @@ -194,6 +201,7 @@ class _CameraState extends ConsumerState with WidgetsBindingObserver { const SizedBox(height: 5,), ], ), + ), ); } diff --git a/lib/features/map_home/presentation/map_home.dart b/lib/features/map_home/presentation/map_home.dart index 0dab3b4..0ff6627 100644 --- a/lib/features/map_home/presentation/map_home.dart +++ b/lib/features/map_home/presentation/map_home.dart @@ -76,8 +76,9 @@ class _MapHomeState extends ConsumerState ref.read(districtServiceProvider.notifier).updateLatLong(position.center.latitude, position.center.longitude, position.zoom); }, interactionOptions: const InteractionOptions( - flags: InteractiveFlag.all, - ), ), + flags: InteractiveFlag.pinchZoom | InteractiveFlag.drag, + ), + ), children: [ CustomTileLayer(), const CurrentLocationLayer(), diff --git a/lib/features/settings/presentation/settings.dart b/lib/features/settings/presentation/settings.dart index ceb876b..3dacd4b 100644 --- a/lib/features/settings/presentation/settings.dart +++ b/lib/features/settings/presentation/settings.dart @@ -1,22 +1,10 @@ -import 'package:buff_lisa/data/repository/group_repository.dart'; -import 'package:buff_lisa/data/repository/image_repository.dart'; -import 'package:buff_lisa/data/repository/member_repository.dart'; -import 'package:buff_lisa/data/repository/pin_repository.dart'; -import 'package:buff_lisa/data/repository/user_pins_repository.dart'; -import 'package:buff_lisa/data/repository/user_repository.dart'; import 'package:buff_lisa/data/service/global_data_service.dart'; -import 'package:buff_lisa/data/service/shared_preferences_service.dart'; -import 'package:buff_lisa/data/service/syncing_service.dart'; -import 'package:buff_lisa/features/navigation/data/navigation_provider.dart'; import 'package:buff_lisa/features/settings/presentation/state/notification_state.dart'; import 'package:buff_lisa/util/routing/routing.dart'; import 'package:buff_lisa/util/theme/service/theme_state.dart'; import 'package:buff_lisa/widgets/custom_interaction/presentation/custom_dialog.dart'; -import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; -import 'package:flutter_cache_manager/flutter_cache_manager.dart'; import 'package:flutter_dotenv/flutter_dotenv.dart'; -import 'package:flutter_map_tile_caching/flutter_map_tile_caching.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:font_awesome_flutter/font_awesome_flutter.dart'; import 'package:go_router/go_router.dart'; @@ -176,89 +164,8 @@ class _SettingsState extends ConsumerState { child: const Text( "Deleting the cache can fix wrong states of the app caused by outdated data. This does not log you out and an automatic refresh of all deleted data is performed. IMPORTANT: Posts that are not synced to the server will be lost forever.", maxLines: 10,), onPressed: () async { - showLoading(); - await invalidateCache(); - ref.read(syncingServiceProvider.notifier).toInit(); - ref.read(syncingServiceProvider.notifier).syncToBackend(); - if (!context.mounted) return; - Navigator.of(context).pop(); - Navigator.of(context).pop(); + context.goNamed("logout", extra: true); }, ); } - - void showLoading() { - showDialog( - context: context, - barrierDismissible: false, - builder: (context) => Dialog( - child: SizedBox( - width: MediaQuery.of(context).size.width - 40, - child: const Padding( - padding: EdgeInsets.all(20.0), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - CircularProgressIndicator(), - SizedBox(height: 15), - Text( - "Please don't close this screen, this can take a few seconds", - textAlign: TextAlign.center, - ), - ], - ), - ), - ), - ), - ); - } - - Future invalidateCache() async { - // 1. Safely read all repositories and dependencies BEFORE any async gap - final pinImageRepo = ref.read(pinImageRepositoryProvider); - final groupRepo = ref.read(groupRepositoryProvider); - final groupProfileRepo = ref.read(groupProfileRepoProvider); - final groupProfileSmallRepo = ref.read(groupProfileSmallRepoProvider); - final groupPinImageRepo = ref.read(groupPinImageRepoProvider); - final memberRepo = ref.read(memberRepositoryProvider); - final pinRepo = ref.read(pinRepositoryProvider); - final userImageRepo = ref.read(userImageRepoProvider); - final userImageSmallRepo = ref.read(userImageSmallRepoProvider); - final userLikeRepo = ref.read(userLikeRepositoryProvider); - final userRepo = ref.read(userRepositoryProvider); - final userPinsRepo = ref.read(userPinsRepositoryProvider); - - // Changed to read() to prevent Riverpod crashes - final sharedPreferences = ref.read(sharedPreferencesProvider); - - await Future.wait([ - pinImageRepo.deleteAll(), - groupRepo.deleteAll(), - groupProfileRepo.deleteAll(), - groupProfileSmallRepo.deleteAll(), - groupPinImageRepo.deleteAll(), - memberRepo.deleteAll(), - pinRepo.deleteAll(), - userImageRepo.deleteAll(), - userImageSmallRepo.deleteAll(), - userLikeRepo.deleteAll(), - userRepo.deleteAll(), - userPinsRepo.deleteAll(), - ]); - - // 3. Clear remaining external caches - if (!kIsWeb) { - final mgmt = const FMTCStore('tileStore').manage; - await mgmt.reset(); - } - - // 4. Clear shared preferences and DefaultCacheManager - // sharedPreferences.clear(); // We don't want to clear EVERYTHING for just a cache delete, but the original code did it. - // Actually, deleteCache should probably NOT clear sharedPreferences if it's just a cache delete. - // But I'll keep the logic consistent with what was there if possible, or improve it. - await DefaultCacheManager().emptyCache(); - - // 5. Invalidate the syncing service last - ref.invalidate(lastSeenProvider); - } } diff --git a/lib/main.dart b/lib/main.dart index 3a8d6e9..e1d2baf 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -121,7 +121,7 @@ class MyApp extends ConsumerWidget { routerConfig: router, builder: (context, child) { if (!kIsWeb) return child!; - return Container( + return ColoredBox( color: Colors.black, // Background color for web outside the app child: Center( child: ConstrainedBox( diff --git a/lib/util/core/cache_impl.dart b/lib/util/core/cache_impl.dart index 69a5f4e..55604ee 100644 --- a/lib/util/core/cache_impl.dart +++ b/lib/util/core/cache_impl.dart @@ -76,7 +76,7 @@ abstract class CacheImpl implements CacheApi { return await doGetList(ids.map(fastHash).toList()); } - void startup() async { + Future startup() async { DateTime? ttlTime; if (ttlDuration != null) { ttlTime = DateTime.now().subtract(ttlDuration!); diff --git a/lib/util/core/cache_migrator.dart b/lib/util/core/cache_migrator.dart index 48a717c..6f16137 100644 --- a/lib/util/core/cache_migrator.dart +++ b/lib/util/core/cache_migrator.dart @@ -1,6 +1,5 @@ import 'dart:io'; -import 'package:buff_lisa/data/repository/global_data_repository.dart'; import 'package:flutter/foundation.dart'; import 'package:path_provider/path_provider.dart'; import 'package:shared_preferences/shared_preferences.dart'; @@ -38,14 +37,39 @@ class CacheMigrator { } Future _version2() async { - // clearing existing database data - await prefs.remove(GlobalDataRepository.lastSeenKey); + // 1. Clear ALL standard SharedPreferences. + // Note: This does NOT affect flutter_secure_storage. + final prefs = await SharedPreferences.getInstance(); + await prefs.clear(); + if (!kIsWeb) { - final dir =( await getApplicationDocumentsDirectory()).path; - for (final file in Directory(dir).listSync()) { - await file.delete(recursive: true); + final docDir = await getApplicationDocumentsDirectory(); + final docPath = docDir.path; + + // 2. Delete specific custom directories + final directoriesToDelete = ['groups', 'pins', 'users']; + for (final dirName in directoriesToDelete) { + final dir = Directory('$docPath/$dirName'); + if (dir.existsSync()) { + await dir.delete(recursive: true); + } } + // 3. Delete Hive and Isar database files explicitly + // Ideally, ensure your Isar and Hive instances are closed before doing this + // to prevent memory leaks or crashes (e.g., await Hive.close();) + for (final entity in docDir.listSync()) { + if (entity is File) { + final fileName = entity.uri.pathSegments.last; + + // Target specifically the extensions used by Hive and Isar + if (fileName.endsWith('.hive') || + fileName.endsWith('.lock') || + fileName.endsWith('.isar')) { + await entity.delete(); + } + } + } } } diff --git a/lib/util/routing/routing.dart b/lib/util/routing/routing.dart index 59fe4ee..cbbe434 100644 --- a/lib/util/routing/routing.dart +++ b/lib/util/routing/routing.dart @@ -70,7 +70,7 @@ final routerProvider = Provider((ref) => GoRouter( GoRoute( path: '/logout', name: 'logout', - builder: (context, state) => const LogoutScreen(), + builder: (context, state) => LogoutScreen(isCacheOnly: state.extra as bool? ?? false), ), GoRoute( diff --git a/lib/widgets/custom_feed/presentation/like_button_animated.dart b/lib/widgets/custom_feed/presentation/like_button_animated.dart index 203042f..d9dfe21 100644 --- a/lib/widgets/custom_feed/presentation/like_button_animated.dart +++ b/lib/widgets/custom_feed/presentation/like_button_animated.dart @@ -1,6 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -// ignore: implementation_imports + // ignore: implementation_imports import 'package:like_button/src/painter/bubbles_painter.dart'; // ignore: implementation_imports import 'package:like_button/src/painter/circle_painter.dart'; diff --git a/lib/widgets/round_image/presentation/custom_image_picker.dart b/lib/widgets/round_image/presentation/custom_image_picker.dart index fad3a00..26014d9 100644 --- a/lib/widgets/round_image/presentation/custom_image_picker.dart +++ b/lib/widgets/round_image/presentation/custom_image_picker.dart @@ -45,14 +45,17 @@ class CustomImagePicker { }) async { if (res == null) return null; final Uint8List bytes = await res.readAsBytes(); - img.Image? image = img.decodeImage(bytes); + final img.Image? image = img.decodeImage(bytes); if (image == null) return null; // Target aspect ratio 3:4 (width/height = 0.75) const double targetRatio = 3 / 4; final double currentRatio = image.width / image.height; - int newWidth, newHeight, offsetX, offsetY; + int newWidth; + int newHeight; + int offsetX; + int offsetY; if (currentRatio > targetRatio) { // Image is too wide, crop the sides