From 319f6f9f054b9e84bbf37a965cb36771f204f9f0 Mon Sep 17 00:00:00 2001 From: Lukas Reim Date: Thu, 26 Mar 2026 10:50:42 +0100 Subject: [PATCH 1/2] Fix logout screen and camera --- docker/.dockerignore => .dockerignore | 4 + docker/nginx.conf | 14 ++-- .../auth/presentation/logout_screen.dart | 29 ++++--- lib/features/camera/presentation/camera.dart | 62 ++++++++------ .../settings/presentation/settings.dart | 83 +------------------ lib/util/routing/routing.dart | 2 +- .../presentation/like_button_animated.dart | 2 +- 7 files changed, 68 insertions(+), 128 deletions(-) rename docker/.dockerignore => .dockerignore (69%) diff --git a/docker/.dockerignore b/.dockerignore similarity index 69% rename from docker/.dockerignore rename to .dockerignore index 3ece515b..153764f7 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 63bef35d..a9e14a56 100644 --- a/docker/nginx.conf +++ b/docker/nginx.conf @@ -3,9 +3,15 @@ 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; + } + server { listen 80; server_name localhost; @@ -19,11 +25,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/features/auth/presentation/logout_screen.dart b/lib/features/auth/presentation/logout_screen.dart index 61a86e41..e1be62df 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 767c5acd..78a6813e 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/settings/presentation/settings.dart b/lib/features/settings/presentation/settings.dart index ceb876b3..b0871eea 100644 --- a/lib/features/settings/presentation/settings.dart +++ b/lib/features/settings/presentation/settings.dart @@ -176,89 +176,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/util/routing/routing.dart b/lib/util/routing/routing.dart index 59fe4eef..cbbe434e 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 203042f7..d9dfe219 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'; From a2b8a6f8787218868380146f045fc3aa22cc0635 Mon Sep 17 00:00:00 2001 From: Lukas Reim Date: Tue, 7 Apr 2026 12:01:21 +0200 Subject: [PATCH 2/2] Fix web and version2 migration --- docker/nginx.conf | 3 + lib/data/config/openapi_config.dart | 88 +---- lib/data/database/database.dart | 3 +- lib/data/database/database.g.dart | 180 +++------- lib/data/dto/global_data_dto.dart | 2 +- lib/data/entity/image_entity.dart | 10 +- lib/data/repository/group_repository.dart | 2 +- lib/data/repository/image_repository.dart | 323 ++++++++++-------- lib/data/repository/image_repository.g.dart | 104 +++--- lib/data/repository/member_repository.dart | 2 +- lib/data/repository/pin_repository.dart | 4 +- lib/data/repository/user_pins_repository.dart | 2 +- lib/data/repository/user_repository.dart | 4 +- .../map_home/presentation/map_home.dart | 5 +- .../settings/presentation/settings.dart | 12 - lib/main.dart | 2 +- lib/util/core/cache_impl.dart | 2 +- lib/util/core/cache_migrator.dart | 36 +- .../presentation/custom_image_picker.dart | 7 +- 19 files changed, 351 insertions(+), 440 deletions(-) diff --git a/docker/nginx.conf b/docker/nginx.conf index a9e14a56..ed0cdc0f 100644 --- a/docker/nginx.conf +++ b/docker/nginx.conf @@ -10,6 +10,9 @@ http { types { application/wasm wasm; application/javascript js; + image/png png; + image/jpeg jpg; + image/gif gif; } server { diff --git a/lib/data/config/openapi_config.dart b/lib/data/config/openapi_config.dart index 9c5aca01..724cb9d7 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 f84d47fe..f20d0cf2 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 b8774f9f..874673b7 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 afa423e1..710fb83d 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 dfc3fcd6..c50edad7 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 9f4e0c15..33208e6c 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 60d1eb0f..aadda6ca 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 a32e11a2..33661dad 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 2fb1b8f2..0c4030e3 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 703334c4..abe58a38 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 d4870a76..fe4347ad 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 221c1623..8f3fb824 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/map_home/presentation/map_home.dart b/lib/features/map_home/presentation/map_home.dart index 0dab3b4b..0ff6627c 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 b0871eea..3dacd4b2 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'; diff --git a/lib/main.dart b/lib/main.dart index 3a8d6e9d..e1d2baf3 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 69a5f4e5..55604ee9 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 48a717ce..6f16137e 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/widgets/round_image/presentation/custom_image_picker.dart b/lib/widgets/round_image/presentation/custom_image_picker.dart index fad3a00c..26014d9e 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