diff --git a/.config b/.config new file mode 100644 index 00000000..8bf7dd12 --- /dev/null +++ b/.config @@ -0,0 +1,5 @@ +APPSTORE_ID=6443754033 +URL_GITHUB_REPO=https://github.com/lr101/stick-it +INSTAGRAM_URL=https://www.instagram.com/stick_it_app +DISCORD_INVITE=https://discord.gg/ReMZ8j6S8X +API_HOST=https://stick-it.lr-projects.de \ No newline at end of file diff --git a/.config.dev b/.config.dev new file mode 100644 index 00000000..4b064c6a --- /dev/null +++ b/.config.dev @@ -0,0 +1,6 @@ +APPSTORE_ID=6443754033 +GITHUB_URL=https://github.com/lr101/stick-it +INSTAGRAM_URL=https://www.instagram.com/stick_it_app +DISCORD_INVITE=https://discord.gg/ReMZ8j6S8X +MINIO_HOST=https://minio.lr-projects.de/stick-it-prod +API_HOST=https://stick-it.lr-projects.de \ No newline at end of file diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index de986ce0..a59e7fc0 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -15,16 +15,9 @@ jobs: - name: Clone repository uses: actions/checkout@v4 - - name: Create .env files + - name: Add google services run: | - touch .env - touch .env.dev touch ./android/app/google-services.json - echo "API_HOST=${{ secrets.API_HOST }}" > .env - echo "DISCORD_INVITE=${{ secrets.DISCORD_INVITE }}" >> .env - echo "INSTAGRAM_URL=${{ secrets.INSTAGRAM_URL }}" >> .env - echo "URL_GITHUB_REPO=${{ secrets.URL_GITHUB_REPO }}" >> .env - echo "APPSTORE_ID=${{ secrets.APPSTORE_ID }}" >> .env echo "${{ secrets.GOOGLE_SERVICES }}" | base64 -d > ./android/app/google-services.json - name: Set up Flutter diff --git a/lib/data/repository/image_repository.dart b/lib/data/repository/image_repository.dart index 3921c175..4cc1b2f1 100644 --- a/lib/data/repository/image_repository.dart +++ b/lib/data/repository/image_repository.dart @@ -45,9 +45,38 @@ class ImageRepository extends CacheImpl implements IImageRepository @override final ImageType type; + @override + Future startup() async { + DateTime? ttlTime; + if (ttlDuration != null) { + ttlTime = DateTime.now().subtract(ttlDuration!); + } + + await isar.writeTxn(() async { + // 1. Fetch only items matching this repository's Type + final all = await getAll(); + + // 2. Filter by session-only or expired TTL + final toDelete = all.where((entry) => + (entry.onlySession && !entry.keepAlive) || + (ttlTime != null && !entry.keepAlive && entry.ttl.isBefore(ttlTime)) + ).toList(); + + // 3. Delete physical files first + for (final entry in toDelete) { + if (entry.filePath.isNotEmpty) { + final file = File(entry.filePath); + if (await file.exists()) { + await file.delete(); + } + } + await box.delete(entry.isarId); + } + }); + } + Future _getImagePath(String id) async { final directory = await getApplicationDocumentsDirectory(); - // Add type.name to path to avoid file name collisions return '${directory.path}/${urlSubFolder}_${type.name}_${id}_$urlFileName'; } @@ -103,10 +132,7 @@ class ImageRepository extends CacheImpl implements IImageRepository final filePath = await _getImagePath(id); if (filePath != null) { await File(filePath).writeAsBytes(image.bodyBytes); - if (keepAlive) { - // 3. Include type in all entity creations - await put(ImageEntity(id: id, type: type, filePath: filePath, keepAlive: keepAlive, ttl: DateTime.now(), onlySession: false)); - } + await put(ImageEntity(id: id, type: type, filePath: filePath, keepAlive: keepAlive, ttl: DateTime.now(), onlySession: false)); } return image.bodyBytes; } catch (e) { @@ -162,7 +188,46 @@ class ImageRepository extends CacheImpl implements IImageRepository @override Future deleteAll() async { - await isar.writeTxn(() async => await box.filter().typeEqualTo(type).deleteAll()); + await isar.writeTxn(() async { + final items = await box.filter().typeEqualTo(type).findAll(); + for (final item in items) { + if (item.filePath.isNotEmpty) { + final file = File(item.filePath); + if (await file.exists()) { + await file.delete(); + } + } + } + await box.filter().typeEqualTo(type).deleteAll(); + }); + } + + @override + Future deleteOldestItems() async { + + final size = await isar.getSize(); + if (maxItems == null || maxItems! >= size) return; + + + final entries = await box.where().findAll(); + + entries.sort((a, b) { + final aHits = a.hits; + final bHits = b.hits; + return aHits.compareTo(bHits); + }); + + final itemsToDelete = size - maxItems!; + int itemsDeleted = 0; + final duration = ttlDuration != null ? (ttlDuration!.inSeconds * 0.1).toInt() : 3600; + final ttlTime = DateTime.now().subtract(Duration(seconds: duration)); + + for (int i = 0; i < entries.length && itemsDeleted < itemsToDelete; i++) { + if (entries[i].keepAlive == false && entries[i].ttl.isBefore(ttlTime)) { + await box.delete(entries[i].isarId); + itemsDeleted++; + } + } } } diff --git a/lib/data/service/pin_service.g.dart b/lib/data/service/pin_service.g.dart index 24c2da08..3f9902df 100644 --- a/lib/data/service/pin_service.g.dart +++ b/lib/data/service/pin_service.g.dart @@ -6,7 +6,7 @@ part of 'pin_service.dart'; // RiverpodGenerator // ************************************************************************** -String _$pinByIdHash() => r'e9a8f482c6f7f1d5dc6885ad4cad5aecedd68c64'; +String _$pinByIdHash() => r'b0696d4be4b207ba641594822b20e7500b1417dc'; /// Copied from Dart SDK class _SystemHash { diff --git a/lib/features/camera/data/camera_state.dart b/lib/features/camera/data/camera_state.dart index 9ccebc25..aa9ff24b 100644 --- a/lib/features/camera/data/camera_state.dart +++ b/lib/features/camera/data/camera_state.dart @@ -72,7 +72,7 @@ Future cameraController(Ref ref) async { final controller = CameraController( cameras[cameraIndex], - ResolutionPreset.medium, + ResolutionPreset.high, enableAudio: false, ); diff --git a/lib/features/camera/data/camera_state.g.dart b/lib/features/camera/data/camera_state.g.dart index a64ce89f..ce094d56 100644 --- a/lib/features/camera/data/camera_state.g.dart +++ b/lib/features/camera/data/camera_state.g.dart @@ -6,7 +6,7 @@ part of 'camera_state.dart'; // RiverpodGenerator // ************************************************************************** -String _$cameraControllerHash() => r'b86905301ef75bfa61c40e6e204b01312925699c'; +String _$cameraControllerHash() => r'babeccea41cd9c8976ba0f674841adbd0548ab87'; /// See also [cameraController]. @ProviderFor(cameraController) diff --git a/lib/features/camera/presentation/camera.dart b/lib/features/camera/presentation/camera.dart index 7f094684..d7c92eb6 100644 --- a/lib/features/camera/presentation/camera.dart +++ b/lib/features/camera/presentation/camera.dart @@ -12,6 +12,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:geolocator/geolocator.dart'; import 'package:go_router/go_router.dart'; +import 'package:image/image.dart' as img; import 'package:image_cropper/image_cropper.dart'; import 'package:latlong2/latlong.dart'; import 'package:mutex/mutex.dart'; @@ -76,24 +77,29 @@ class _CameraState extends ConsumerState with WidgetsBindingObserver { 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), - ), - ), + data: (cameraState) { + return 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: ClipRect( + child: AspectRatio( + aspectRatio: 3/4, + child: CameraPreview(controller), + ), + ), + ), + ); + }, ); }, ), @@ -256,31 +262,89 @@ class _CameraState extends ConsumerState with WidgetsBindingObserver { Future takePicture(String groupId, int index) async { final indexProvider = ref.read(cameraGroupIndexProvider); - if(index != indexProvider) { + if (index != indexProvider) { pageController.animateToPage(index, duration: const Duration(milliseconds: 200), curve: Curves.easeIn); return; } + final controller = ref.read(cameraControllerProvider).value; if (_m.isLocked || controller == null) return; + await _m.acquire(); ref.read(cameraCapturingProvider.notifier).setCapturing(true); - try { - final image = await controller.takePicture(); - final Uint8List bytes = await image.readAsBytes(); - final Position position = await Geolocator.getCurrentPosition(); - final pos = LatLng(position.latitude, position.longitude); - // Pause preview while the ImageUpload screen is on top - if (controller.value.isInitialized) { - await controller.pausePreview().catchError((_) {}); - } - if (!mounted) return; - context.pushNamed('imageUpload', queryParameters: {"lat": pos.latitude.toString(), "long": pos.longitude.toString()}, extra: bytes); - } catch (e) { - if(kDebugMode) print(e); - } finally { - _m.release(); - ref.read(cameraCapturingProvider.notifier).setCapturing(false); + + try { + final image = await controller.takePicture(); + Uint8List bytes = await image.readAsBytes(); + + // Hardcode the target ratio to exactly 3:4 + const double targetRatio = 3 / 4; + + // Crop the image in a background thread using the function from the previous step + bytes = await compute(_cropImageToRatio, (bytes: bytes, targetRatio: targetRatio)); + + final Position position = await Geolocator.getCurrentPosition(); + final pos = LatLng(position.latitude, position.longitude); + + if (controller.value.isInitialized) { + await controller.pausePreview().catchError((_) {}); } + + if (!mounted) return; + context.pushNamed( + 'imageUpload', + queryParameters: {"lat": pos.latitude.toString(), "long": pos.longitude.toString()}, + extra: bytes, + ); + + } catch (e) { + if (kDebugMode) print(e); + } finally { + _m.release(); + ref.read(cameraCapturingProvider.notifier).setCapturing(false); + } + } + /// Crops the image center to match the target aspect ratio + Uint8List _cropImageToRatio(({Uint8List bytes, double targetRatio}) data) { + // Decode the image from bytes + final originalImage = img.decodeImage(data.bytes); + if (originalImage == null) return data.bytes; // Fallback if decode fails + + final int w = originalImage.width; + final int h = originalImage.height; + final double currentRatio = w / h; + + // If the aspect ratios already match, just return the original bytes + if ((currentRatio - data.targetRatio).abs() < 0.05) { + return data.bytes; + } + + int cropW = w; + int cropH = h; + int cropX = 0; + int cropY = 0; + + if (currentRatio > data.targetRatio) { + // The image is wider than the UI. Crop the left and right sides. + cropW = (h * data.targetRatio).round(); + cropX = ((w - cropW) / 2).round(); + } else { + // The image is taller than the UI. Crop the top and bottom. + cropH = (w / data.targetRatio).round(); + cropY = ((h - cropH) / 2).round(); + } + + // Perform the crop + final croppedImage = img.copyCrop( + originalImage, + x: cropX, + y: cropY, + width: cropW, + height: cropH, + ); + + // Encode back to JPG and return + return img.encodeJpg(croppedImage); } } diff --git a/lib/features/map_home/presentation/join_group_hint.dart b/lib/features/map_home/presentation/join_group_hint.dart new file mode 100644 index 00000000..0a4a60cc --- /dev/null +++ b/lib/features/map_home/presentation/join_group_hint.dart @@ -0,0 +1,61 @@ +import 'package:buff_lisa/data/service/group_service.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; + +class JoinGroupHintOverlay extends ConsumerWidget { + // Added super.key which is a standard Flutter best practice + const JoinGroupHintOverlay({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final theme = Theme.of(context); + const double height = 48.0; + const double borderRadius = 24.0; + final currentGroup = ref.watch(userGroupServiceProvider); + + if (currentGroup.value == null || currentGroup.value!.isNotEmpty) { + return const SizedBox.shrink(); + } + + // 3. Render the floating hint + return GestureDetector( + onTap: () => context.pushNamed("groupSearch"), + child: Container( + height: height, + decoration: BoxDecoration( + color: theme.colorScheme.surfaceContainer, + borderRadius: BorderRadius.circular(borderRadius), + border: Border.all( + color: theme.colorScheme.outlineVariant.withValues(alpha: 0.3), + ), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.1), + blurRadius: 4, + offset: const Offset(0, 2), + ), + ], + ), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 8.0), + child: Row( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.search_rounded, + color: theme.colorScheme.primary, + size: 18.0, + ), + const SizedBox(width: 4.0), + const Text( + "Join a Group" + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/features/map_home/presentation/map_home.dart b/lib/features/map_home/presentation/map_home.dart index c4e10913..0ff6627c 100644 --- a/lib/features/map_home/presentation/map_home.dart +++ b/lib/features/map_home/presentation/map_home.dart @@ -3,6 +3,7 @@ import 'package:buff_lisa/data/service/global_data_service.dart'; import 'package:buff_lisa/features/map_home/data/map_state.dart'; import 'package:buff_lisa/features/map_home/data/marker_window_state.dart'; import 'package:buff_lisa/features/map_home/presentation/circle_with_indicator.dart'; +import 'package:buff_lisa/features/map_home/presentation/join_group_hint.dart'; import 'package:buff_lisa/features/map_home/presentation/osm_copyright.dart'; import 'package:buff_lisa/features/map_home/presentation/ranking_panel.dart'; import 'package:buff_lisa/widgets/custom_map_setup/presentation/custom_tile_layer.dart'; @@ -120,7 +121,15 @@ class _MapHomeState extends ConsumerState children: [ Flexible(child: TopStatusBar()), SizedBox(height: 4), - ModeSelector(), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + JoinGroupHintOverlay(), + ModeSelector(), + ], + ) + ], ) ), diff --git a/lib/main.dart b/lib/main.dart index 6fe48007..91e91f07 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -35,7 +35,6 @@ import 'package:shared_preferences/shared_preferences.dart'; /// THIS IS THE START OF THE PROGRAMM /// binding Widgets before initialization is required by multiple packages /// initializes access to env variables -/// checks if user is logged in on this device by checking device storage Future main() async { WidgetsFlutterBinding.ensureInitialized(); final sharedPreferences = await SharedPreferences.getInstance(); @@ -45,9 +44,9 @@ Future main() async { const bool isProduction = bool.fromEnvironment('dart.vm.product'); if (isProduction) { - await dotenv.load(); + await dotenv.load(fileName: ".config"); } else { - await dotenv.load(fileName: ".env.dev"); + await dotenv.load(fileName: ".config.dev"); } Isar? isar; diff --git a/lib/util/core/cache_impl.dart b/lib/util/core/cache_impl.dart index 2fa552dc..0f05c447 100644 --- a/lib/util/core/cache_impl.dart +++ b/lib/util/core/cache_impl.dart @@ -13,7 +13,7 @@ abstract class CacheImpl implements CacheApi { CacheImpl({required this.box, required this.isar, this.maxItems, this.ttlDuration}) { - _startup(); + startup(); } @protected @@ -55,7 +55,7 @@ abstract class CacheImpl implements CacheApi { @override Future> getAll() async { - return box.where().findAll(); + return await box.where().findAll(); } @override @@ -78,7 +78,8 @@ abstract class CacheImpl implements CacheApi { return await box.getAll(ids.map(fastHash).toList()); } - void _startup() { + @protected + void startup() { DateTime? ttlTime; if (ttlDuration != null) { ttlTime = DateTime.now().subtract(ttlDuration!); @@ -93,8 +94,6 @@ abstract class CacheImpl implements CacheApi { } - /// Delete the items with the lowest hit count - /// Not included are items with keepAlive == true and items younger than 10% of ttlDuration @override Future deleteOldestItems() async { diff --git a/lib/util/routing/routing.dart b/lib/util/routing/routing.dart index 567a5f2c..0a2d3fdf 100644 --- a/lib/util/routing/routing.dart +++ b/lib/util/routing/routing.dart @@ -24,7 +24,6 @@ import 'package:buff_lisa/features/settings/presentation/sub_widgets/edit_hidden import 'package:buff_lisa/features/web/presentation/show_web.dart'; import 'package:buff_lisa/widgets/custom_interaction/presentation/custom_error_snack_bar.dart'; import 'package:buff_lisa/widgets/report_issue/presentation/report_issue_page.dart'; -import 'package:flutter/widgets.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import 'package:latlong2/latlong.dart'; diff --git a/lib/widgets/tiles/presentation/member_tile.dart b/lib/widgets/tiles/presentation/member_tile.dart index 98a1c97b..e25855f2 100644 --- a/lib/widgets/tiles/presentation/member_tile.dart +++ b/lib/widgets/tiles/presentation/member_tile.dart @@ -48,7 +48,7 @@ class MemberTile extends ConsumerWidget { return listTile; } else { return GestureDetector( - onTap: () => context.pushNamed("userProfile", pathParameters: {"id": userId}), + onTap: () => context.pushNamed("userProfile", pathParameters: {"id": memberDto.userId}), child: listTile, ); } diff --git a/pubspec.lock b/pubspec.lock index 530c3847..60941298 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1046,7 +1046,7 @@ packages: source: hosted version: "4.1.2" image: - dependency: transitive + dependency: "direct main" description: name: image sha256: f9881ff4998044947ec38d098bc7c8316ae1186fa786eddffdb867b9bc94dfce diff --git a/pubspec.yaml b/pubspec.yaml index 6d91580c..83d61d56 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -3,7 +3,7 @@ description: A new Flutter project. publish_to: 'none' # Remove this line if you wish to publish to pub.dev -version: 3.0.18+64 +version: 3.0.19+64 environment: @@ -76,6 +76,7 @@ dependencies: isar_community_flutter_libs: rxdart: ^0.28.0 go_router: ^17.1.0 + image: ^4.8.0 dev_dependencies: build_runner: @@ -94,8 +95,8 @@ flutter: - assets/image/ - assets/achievements/ - assets/icon/ - - .env - - .env.dev + - .config + - .config.dev uses-material-design: true fonts: - family: Signika diff --git a/web/icons/Icon-192.png b/web/icons/Icon-192.png index 081927fe..436f6beb 100644 Binary files a/web/icons/Icon-192.png and b/web/icons/Icon-192.png differ diff --git a/web/icons/Icon-512.png b/web/icons/Icon-512.png index 2e4e4b4b..89e7a148 100644 Binary files a/web/icons/Icon-512.png and b/web/icons/Icon-512.png differ diff --git a/web/index.html b/web/index.html index 6ed1fc5c..424f871c 100644 --- a/web/index.html +++ b/web/index.html @@ -1,38 +1,91 @@ - - + - - + - - buff_lisa + Stick-It + + + + + + + +
+ +
+
+ + + + - + \ No newline at end of file diff --git a/web/manifest.json b/web/manifest.json index 955c4981..af97742e 100644 --- a/web/manifest.json +++ b/web/manifest.json @@ -3,8 +3,8 @@ "short_name": "Stick-It", "start_url": ".", "display": "standalone", - "background_color": "#0175C2", - "theme_color": "#0175C2", + "background_color": "#e1a064", + "theme_color": "#e1a064", "description": "A sticker app.", "orientation": "portrait-primary", "prefer_related_applications": false,