Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .config
Original file line number Diff line number Diff line change
@@ -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
6 changes: 6 additions & 0 deletions .config.dev
Original file line number Diff line number Diff line change
@@ -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
9 changes: 1 addition & 8 deletions .github/workflows/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
77 changes: 71 additions & 6 deletions lib/data/repository/image_repository.dart
Original file line number Diff line number Diff line change
Expand Up @@ -45,9 +45,38 @@ class ImageRepository extends CacheImpl<ImageEntity> implements IImageRepository
@override
final ImageType type;

@override
Future<void> 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<String?> _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';
}

Expand Down Expand Up @@ -103,10 +132,7 @@ class ImageRepository extends CacheImpl<ImageEntity> 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) {
Expand Down Expand Up @@ -162,7 +188,46 @@ class ImageRepository extends CacheImpl<ImageEntity> implements IImageRepository

@override
Future<void> 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<void> 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++;
}
}
}
}

Expand Down
2 changes: 1 addition & 1 deletion lib/data/service/pin_service.g.dart

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion lib/features/camera/data/camera_state.dart
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ Future<CameraController> cameraController(Ref ref) async {

final controller = CameraController(
cameras[cameraIndex],
ResolutionPreset.medium,
ResolutionPreset.high,
enableAudio: false,
);

Expand Down
2 changes: 1 addition & 1 deletion lib/features/camera/data/camera_state.g.dart

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

120 changes: 92 additions & 28 deletions lib/features/camera/presentation/camera.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -76,24 +77,29 @@ class _CameraState extends ConsumerState<Camera> 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),
),
),
),
);
},
);
},
),
Expand Down Expand Up @@ -256,31 +262,89 @@ class _CameraState extends ConsumerState<Camera> with WidgetsBindingObserver {

Future<void> 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);
}

}
61 changes: 61 additions & 0 deletions lib/features/map_home/presentation/join_group_hint.dart
Original file line number Diff line number Diff line change
@@ -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"
),
],
),
),
),
);
}
}
Loading
Loading