From bc128cef3d805559dd3c2e6aba58b535907531d9 Mon Sep 17 00:00:00 2001 From: zeyus Date: Mon, 21 Jul 2025 22:52:31 +0200 Subject: [PATCH] just a concept. --- assets/translations/da.json | 49 ++ assets/translations/de.json | 49 ++ assets/translations/en.json | 49 ++ lib/attributes/serializable.dart | 19 + lib/game/components/neighborhood.dart | 236 +++++++++ lib/game/components/player.dart | 193 ++++++++ lib/game/components/poop_bag.dart | 160 ++++++ lib/game/components/target_house.dart | 52 ++ lib/game/shitman_game.dart | 144 ++++++ lib/main.dart | 137 ++--- lib/settings/app_settings.dart | 16 + lib/settings/colors.dart | 15 + lib/settings/exceptions.dart | 104 ++++ lib/settings/game.dart | 11 + lib/settings/setting.dart | 343 +++++++++++++ lib/settings/settings.dart | 45 ++ lib/settings/settings_group.dart | 468 ++++++++++++++++++ lib/settings/settings_manager.dart | 350 +++++++++++++ lib/settings/settings_store.dart | 143 ++++++ lib/ui/in_game_ui.dart | 314 ++++++++++++ macos/Flutter/GeneratedPluginRegistrant.swift | 2 + pubspec.lock | 106 ++++ pubspec.yaml | 73 +-- test/widget_test.dart | 2 +- 24 files changed, 2921 insertions(+), 159 deletions(-) create mode 100644 assets/translations/da.json create mode 100644 assets/translations/de.json create mode 100644 assets/translations/en.json create mode 100644 lib/attributes/serializable.dart create mode 100644 lib/game/components/neighborhood.dart create mode 100644 lib/game/components/player.dart create mode 100644 lib/game/components/poop_bag.dart create mode 100644 lib/game/components/target_house.dart create mode 100644 lib/game/shitman_game.dart create mode 100644 lib/settings/app_settings.dart create mode 100644 lib/settings/colors.dart create mode 100644 lib/settings/exceptions.dart create mode 100644 lib/settings/game.dart create mode 100644 lib/settings/setting.dart create mode 100644 lib/settings/settings.dart create mode 100644 lib/settings/settings_group.dart create mode 100644 lib/settings/settings_manager.dart create mode 100644 lib/settings/settings_store.dart create mode 100644 lib/ui/in_game_ui.dart diff --git a/assets/translations/da.json b/assets/translations/da.json new file mode 100644 index 0000000..5059418 --- /dev/null +++ b/assets/translations/da.json @@ -0,0 +1,49 @@ +{ + "game": { + "title": "SHITMAN", + "subtitle": "Lorteprank Mesteren", + "description": "Snig rundt og efterlad brændende lorteposer\nudea at blive opdaget!" + }, + "menu": { + "start_mission": "START MISSION", + "infinite_mode": "UENDELIG TILSTAND", + "settings": "INDSTILLINGER", + "main_menu": "HOVEDMENU", + "resume": "FORTSÆT", + "back_to_menu": "TILBAGE TIL MENU" + }, + "gameplay": { + "hidden": "Skjult", + "visible": "Synlig", + "detected": "OPDAGET!", + "find_target": "Find målhus", + "place_poop": "Placer lortepose", + "ring_doorbell": "Ring på døren", + "escape": "FLYGT!", + "mission_complete": "Mission Fuldført!", + "mission_failed": "Mission Mislykkedes!" + }, + "controls": { + "move": "WASD/Piletaster: Bevæg", + "place_bag": "MELLEMRUM: Placer lortepose", + "ring_bell": "E: Ring på døren", + "pause": "ESC: Pause" + }, + "ui": { + "paused": "PAUSERET", + "debug_mode": "Debug Tilstand:", + "close": "Luk" + }, + "messages": { + "placing_poop": "Placerer lortepose på position", + "attempting_doorbell": "Forsøger at ringe på døren", + "poop_lit": "Ding dong! Lorteposen brænder! LØB!", + "need_poop_first": "Skal placere lortepose først!", + "not_near_door": "Ikke tæt på målhusets dør", + "near_target": "Tæt på målhus med lortepose placeret!", + "player_detected": "Spiller opdaget! Mission mislykkedes!", + "new_target": "Nyt mål valgt", + "poop_burning": "Lorteposen brænder nu!", + "poop_extinguished": "Lorteposen er brændt ud" + } +} diff --git a/assets/translations/de.json b/assets/translations/de.json new file mode 100644 index 0000000..1080788 --- /dev/null +++ b/assets/translations/de.json @@ -0,0 +1,49 @@ +{ + "game": { + "title": "SHITMAN", + "subtitle": "Der Kackhaufen-Streich-Meister", + "description": "Schleich herum und hinterlasse brennende Kacktüten\nohne erwischt zu werden!" + }, + "menu": { + "start_mission": "MISSION STARTEN", + "infinite_mode": "ENDLOS MODUS", + "settings": "EINSTELLUNGEN", + "main_menu": "HAUPTMENÜ", + "resume": "FORTSETZEN", + "back_to_menu": "ZURÜCK ZUM MENÜ" + }, + "gameplay": { + "hidden": "Versteckt", + "visible": "Sichtbar", + "detected": "ENTDECKT!", + "find_target": "Zielhaus finden", + "place_poop": "Kacktüte platzieren", + "ring_doorbell": "Klingeln", + "escape": "FLUCHT!", + "mission_complete": "Mission Erfolgreich!", + "mission_failed": "Mission Gescheitert!" + }, + "controls": { + "move": "WASD/Pfeiltasten: Bewegen", + "place_bag": "LEERTASTE: Kacktüte platzieren", + "ring_bell": "E: Klingeln", + "pause": "ESC: Pause" + }, + "ui": { + "paused": "PAUSIERT", + "debug_mode": "Debug Modus:", + "close": "Schließen" + }, + "messages": { + "placing_poop": "Kacktüte wird platziert an Position", + "attempting_doorbell": "Versuche zu klingeln", + "poop_lit": "Ding dong! Kacktüte brennt! LAUF!", + "need_poop_first": "Kacktüte muss zuerst platziert werden!", + "not_near_door": "Nicht in der Nähe der Zielhau­stür", + "near_target": "In der Nähe des Zielhauses mit platzierter Kacktüte!", + "player_detected": "Spieler entdeckt! Mission gescheitert!", + "new_target": "Neues Ziel ausgewählt", + "poop_burning": "Kacktüte brennt jetzt!", + "poop_extinguished": "Kacktüte ist ausgebrannt" + } +} \ No newline at end of file diff --git a/assets/translations/en.json b/assets/translations/en.json new file mode 100644 index 0000000..3b232e4 --- /dev/null +++ b/assets/translations/en.json @@ -0,0 +1,49 @@ +{ + "game": { + "title": "SHITMAN", + "subtitle": "The Poop Prank Master", + "description": "Sneak around and leave flaming poop bags\nwithout getting caught!" + }, + "menu": { + "start_mission": "START MISSION", + "infinite_mode": "INFINITE MODE", + "settings": "SETTINGS", + "main_menu": "MAIN MENU", + "resume": "RESUME", + "back_to_menu": "BACK TO MENU" + }, + "gameplay": { + "hidden": "Hidden", + "visible": "Visible", + "detected": "DETECTED!", + "find_target": "Find target house", + "place_poop": "Place poop bag", + "ring_doorbell": "Ring doorbell", + "escape": "ESCAPE!", + "mission_complete": "Mission Complete!", + "mission_failed": "Mission Failed!" + }, + "controls": { + "move": "WASD/Arrow Keys: Move", + "place_bag": "SPACE: Place poop bag", + "ring_bell": "E: Ring doorbell", + "pause": "ESC: Pause" + }, + "ui": { + "paused": "PAUSED", + "debug_mode": "Debug Mode:", + "close": "Close" + }, + "messages": { + "placing_poop": "Placing poop bag at position", + "attempting_doorbell": "Attempting to ring doorbell", + "poop_lit": "Ding dong! Poop bag is lit! RUN!", + "need_poop_first": "Need to place poop bag first!", + "not_near_door": "Not near target house door", + "near_target": "Near target house with poop bag placed!", + "player_detected": "Player detected! Mission failed!", + "new_target": "New target selected", + "poop_burning": "Poop bag is now on fire!", + "poop_extinguished": "Poop bag has burned out" + } +} diff --git a/lib/attributes/serializable.dart b/lib/attributes/serializable.dart new file mode 100644 index 0000000..7c262cc --- /dev/null +++ b/lib/attributes/serializable.dart @@ -0,0 +1,19 @@ +abstract interface class Serializable { + /// Converts the object to a JSON string representation. + /// This method should be implemented by all classes that mixin Serializable. + String toJson(); + + /// Creates an object from a JSON string representation. + /// This method should be implemented by all classes that mixin Serializable. + Serializable.fromJson(String json); + + /// Converts the object to a map representation. + /// This method is useful for converting the object to a format that can be + /// easily serialized or stored. + Map toMap(); + + /// Creates an object from a map representation. + /// This method is useful for converting the object from a format that can be + /// easily serialized or stored. + Serializable.fromMap(Map map); +} diff --git a/lib/game/components/neighborhood.dart b/lib/game/components/neighborhood.dart new file mode 100644 index 0000000..9639c6a --- /dev/null +++ b/lib/game/components/neighborhood.dart @@ -0,0 +1,236 @@ +import 'package:flame/components.dart'; +import 'package:flutter/material.dart'; +import 'dart:math'; + +class Neighborhood extends Component { + static const double streetWidth = 60.0; + static const double houseSize = 80.0; + static const double yardSize = 40.0; + + List houses = []; + late List streetPaths; + + @override + Future onLoad() async { + await super.onLoad(); + generateNeighborhood(); + } + + void generateNeighborhood() { + houses.clear(); + removeAll(children); + + // Create a simple 3x3 grid of houses + final random = Random(); + + for (int row = 0; row < 3; row++) { + for (int col = 0; col < 3; col++) { + // Skip center for street intersection + if (row == 1 && col == 1) continue; + + final housePosition = Vector2( + col * (houseSize + streetWidth) + streetWidth, + row * (houseSize + streetWidth) + streetWidth, + ); + + final house = House( + position: housePosition, + isTarget: false, // Target will be set separately + houseType: random.nextInt(3), // 3 different house types + ); + + houses.add(house); + add(house); + } + } + + // Generate street paths + generateStreetPaths(); + } + + void generateStreetPaths() { + streetPaths = []; + + // Horizontal streets + for (int i = 0; i < 4; i++) { + streetPaths.add(Vector2(0, i * (houseSize + streetWidth))); + streetPaths.add(Vector2(800, i * (houseSize + streetWidth))); + } + + // Vertical streets + for (int i = 0; i < 4; i++) { + streetPaths.add(Vector2(i * (houseSize + streetWidth), 0)); + streetPaths.add(Vector2(i * (houseSize + streetWidth), 600)); + } + } + + House? getRandomHouse() { + if (houses.isEmpty) return null; + final random = Random(); + return houses[random.nextInt(houses.length)]; + } + + @override + void render(Canvas canvas) { + super.render(canvas); + + // Draw streets + final streetPaint = Paint() + ..color = const Color(0xFF333333); + + // Horizontal streets + for (int row = 0; row <= 3; row++) { + canvas.drawRect( + Rect.fromLTWH( + 0, + row * (houseSize + streetWidth) - streetWidth / 2, + 800, + streetWidth + ), + streetPaint, + ); + } + + // Vertical streets + for (int col = 0; col <= 3; col++) { + canvas.drawRect( + Rect.fromLTWH( + col * (houseSize + streetWidth) - streetWidth / 2, + 0, + streetWidth, + 600 + ), + streetPaint, + ); + } + } +} + +class House extends RectangleComponent { + bool isTarget; + int houseType; + bool hasLights = false; + bool hasSecurityCamera = false; + bool hasWatchDog = false; + + Vector2? doorPosition; + Vector2? yardCenter; + + House({ + required Vector2 position, + required this.isTarget, + required this.houseType, + }) : super( + position: position, + size: Vector2.all(Neighborhood.houseSize), + ); + + @override + Future onLoad() async { + await super.onLoad(); + + // Set house color based on type + paint = Paint()..color = _getHouseColor(); + + // Calculate door and yard positions + doorPosition = position + Vector2(size.x / 2, size.y); + yardCenter = position + size / 2; + + // Randomly add security features + final random = Random(); + hasLights = random.nextBool(); + hasSecurityCamera = random.nextDouble() < 0.3; + hasWatchDog = random.nextDouble() < 0.2; + } + + Color _getHouseColor() { + switch (houseType) { + case 0: + return isTarget ? const Color(0xFFFF6B6B) : const Color(0xFF8B4513); // Brown/Red if target + case 1: + return isTarget ? const Color(0xFFFF6B6B) : const Color(0xFF4682B4); // Blue/Red if target + case 2: + return isTarget ? const Color(0xFFFF6B6B) : const Color(0xFF228B22); // Green/Red if target + default: + return const Color(0xFF696969); + } + } + + @override + void render(Canvas canvas) { + super.render(canvas); + + // Draw door + final doorPaint = Paint() + ..color = const Color(0xFF654321); + canvas.drawRect( + Rect.fromLTWH(size.x / 2 - 8, size.y - 4, 16, 4), + doorPaint, + ); + + // Draw windows + final windowPaint = Paint() + ..color = hasLights ? const Color(0xFFFFFF00) : const Color(0xFF87CEEB); + + // Left window + canvas.drawRect( + Rect.fromLTWH(size.x * 0.2, size.y * 0.3, 12, 12), + windowPaint, + ); + + // Right window + canvas.drawRect( + Rect.fromLTWH(size.x * 0.7, size.y * 0.3, 12, 12), + windowPaint, + ); + + // Draw security features + if (hasSecurityCamera) { + final cameraPaint = Paint() + ..color = const Color(0xFF000000); + canvas.drawCircle( + Offset(size.x * 0.9, size.y * 0.1), + 4, + cameraPaint, + ); + } + + if (hasWatchDog) { + // Draw dog house in yard + final dogHousePaint = Paint() + ..color = const Color(0xFF8B4513); + canvas.drawRect( + Rect.fromLTWH(-20, size.y + 10, 15, 15), + dogHousePaint, + ); + } + + // Draw target indicator + if (isTarget) { + final targetPaint = Paint() + ..color = const Color(0xFFFF0000) + ..style = PaintingStyle.stroke + ..strokeWidth = 3.0; + canvas.drawCircle( + Offset(size.x / 2, size.y / 2), + size.x / 2 + 10, + targetPaint, + ); + } + } + + double getDetectionRadius() { + double radius = 50.0; + if (hasLights) radius += 20.0; + if (hasSecurityCamera) radius += 40.0; + if (hasWatchDog) radius += 30.0; + return radius; + } + + bool canDetectPlayer(Vector2 playerPosition, double playerStealthLevel) { + final distance = (playerPosition - yardCenter!).length; + final detectionRadius = getDetectionRadius() * (1.0 - playerStealthLevel); + + return distance < detectionRadius; + } +} \ No newline at end of file diff --git a/lib/game/components/player.dart b/lib/game/components/player.dart new file mode 100644 index 0000000..deaefbc --- /dev/null +++ b/lib/game/components/player.dart @@ -0,0 +1,193 @@ +import 'package:flame/components.dart'; +import 'package:flame/events.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/foundation.dart'; +import 'package:shitman/game/shitman_game.dart'; +import 'package:shitman/game/components/poop_bag.dart'; +import 'package:shitman/game/components/neighborhood.dart'; + +class Player extends RectangleComponent with HasGameReference { + static const double speed = 100.0; + static const double playerSize = 32.0; + + Vector2 velocity = Vector2.zero(); + bool hasPoopBag = true; + bool isHidden = false; + double stealthLevel = 0.0; // 0.0 = fully visible, 1.0 = completely hidden + PoopBag? placedPoopBag; + + @override + Future onLoad() async { + await super.onLoad(); + + // Create a simple colored rectangle as player + size = Vector2.all(playerSize); + position = Vector2(400, 300); // Start in center + + // Set player color + paint = Paint()..color = const Color(0xFF0000FF); // Blue player + } + + + void handleInput(Set keysPressed) { + velocity = Vector2.zero(); + + // Movement controls + if (keysPressed.contains(LogicalKeyboardKey.arrowUp) || + keysPressed.contains(LogicalKeyboardKey.keyW)) { + velocity.y -= speed; + } + if (keysPressed.contains(LogicalKeyboardKey.arrowDown) || + keysPressed.contains(LogicalKeyboardKey.keyS)) { + velocity.y += speed; + } + if (keysPressed.contains(LogicalKeyboardKey.arrowLeft) || + keysPressed.contains(LogicalKeyboardKey.keyA)) { + velocity.x -= speed; + } + if (keysPressed.contains(LogicalKeyboardKey.arrowRight) || + keysPressed.contains(LogicalKeyboardKey.keyD)) { + velocity.x += speed; + } + } + + void handleAction(LogicalKeyboardKey key) { + // Action controls + if (key == LogicalKeyboardKey.space) { + placePoop(); + } + if (key == LogicalKeyboardKey.keyE) { + ringDoorbell(); + } + } + + + void updateStealthLevel(double dt) { + // Simple stealth calculation - can be enhanced later + // For now, player is more hidden when moving slowly or not at all + if (velocity.length < 50) { + stealthLevel = (stealthLevel + dt * 0.5).clamp(0.0, 1.0); + } else { + stealthLevel = (stealthLevel - dt * 1.5).clamp(0.0, 1.0); + } + + isHidden = stealthLevel > 0.7; + } + + void placePoop() { + if (!hasPoopBag) return; + + debugPrint('Placing poop bag at $position'); + + // Create and place the poop bag + placedPoopBag = PoopBag(); + placedPoopBag!.position = position + Vector2(playerSize / 2, playerSize + 10); + game.world.add(placedPoopBag!); + + hasPoopBag = false; + + // Check if near target house + checkMissionProgress(); + } + + void ringDoorbell() { + debugPrint('Attempting to ring doorbell'); + + // Check if near target house door + if (game.targetHouse.isPlayerNearTarget(position)) { + if (placedPoopBag != null) { + // Light the poop bag on fire + placedPoopBag!.lightOnFire(); + debugPrint('Ding dong! Poop bag is lit! RUN!'); + + // Start escape timer - player has limited time to escape + startEscapeSequence(); + } else { + debugPrint('Need to place poop bag first!'); + } + } else { + debugPrint('Not near target house door'); + } + } + + void startEscapeSequence() { + // TODO: Implement escape mechanics + // For now, automatically complete mission after a delay + Future.delayed(Duration(seconds: 3), () { + if (game.gameState == GameState.playing) { + game.completeCurrentMission(); + } + }); + } + + void checkMissionProgress() { + // Check if near target house and has placed poop bag + final targetPos = game.targetHouse.getTargetPosition(); + if (targetPos != null && placedPoopBag != null) { + final distance = (position - targetPos).length; + if (distance < 80) { + debugPrint('Near target house with poop bag placed!'); + } + } + } + + void getDetected() { + debugPrint('Player detected! Mission failed!'); + game.failMission(); + } + + @override + void update(double dt) { + super.update(dt); + + // Apply movement + if (velocity.length > 0) { + velocity = velocity.normalized() * speed; + position += velocity * dt; + + // Keep player on screen (basic bounds checking) + position.x = position.x.clamp(0, 800 - size.x); + position.y = position.y.clamp(0, 600 - size.y); + } + + // Update stealth level based on environment + updateStealthLevel(dt); + + // Check for detection by houses + checkForDetection(); + } + + void checkForDetection() { + final neighborhood = game.world.children.whereType().firstOrNull; + if (neighborhood == null) return; + + for (final house in neighborhood.houses) { + if (house.canDetectPlayer(position, stealthLevel)) { + getDetected(); + break; + } + } + } + + @override + void render(Canvas canvas) { + // Update paint color based on stealth level + paint = Paint() + ..color = isHidden ? + const Color(0xFF00FF00).withOpacity(0.7) : // Green when hidden + const Color(0xFF0000FF).withOpacity(0.9); // Blue when visible + + super.render(canvas); + + // Draw stealth indicator in debug mode + if (game.debugMode) { + final stealthPaint = Paint() + ..color = Color.lerp(const Color(0xFFFF0000), const Color(0xFF00FF00), stealthLevel)!; + canvas.drawRect( + Rect.fromLTWH(-5, -10, size.x + 10, 5), + stealthPaint, + ); + } + } +} \ No newline at end of file diff --git a/lib/game/components/poop_bag.dart b/lib/game/components/poop_bag.dart new file mode 100644 index 0000000..2160337 --- /dev/null +++ b/lib/game/components/poop_bag.dart @@ -0,0 +1,160 @@ +import 'package:flame/components.dart'; +import 'package:flame/effects.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/foundation.dart'; +import 'dart:math'; + +enum PoopBagState { placed, lit, burning, extinguished } + +class PoopBag extends CircleComponent { + PoopBagState state = PoopBagState.placed; + double burnTimer = 0.0; + static const double burnDuration = 3.0; // seconds to burn + static const double bagSize = 16.0; + + late Vector2 smokeOffset; + List smokeParticles = []; + + @override + Future onLoad() async { + await super.onLoad(); + + radius = bagSize / 2; + paint = Paint()..color = const Color(0xFF8B4513); // Brown color + + smokeOffset = Vector2(0, -radius - 5); + } + + void lightOnFire() { + if (state == PoopBagState.placed) { + state = PoopBagState.lit; + burnTimer = 0.0; + + // Add flame effect + add( + ScaleEffect.to( + Vector2.all(1.2), + EffectController(duration: 0.5, infinite: true, reverseDuration: 0.5), + ), + ); + + debugPrint('Poop bag is now on fire!'); + } + } + + @override + void update(double dt) { + super.update(dt); + + if (state == PoopBagState.lit) { + burnTimer += dt; + + // Generate smoke particles + if (burnTimer % 0.2 < dt) { // Every 0.2 seconds + generateSmokeParticle(); + } + + // Check if fully burned + if (burnTimer >= burnDuration) { + state = PoopBagState.burning; + extinguish(); + } + } + + // Update smoke particles + smokeParticles.removeWhere((particle) { + particle.update(dt); + return particle.shouldRemove; + }); + } + + void generateSmokeParticle() { + final random = Random(); + final particle = SmokeParticle( + position: position + smokeOffset + Vector2( + random.nextDouble() * 10 - 5, + random.nextDouble() * 5, + ), + ); + smokeParticles.add(particle); + } + + void extinguish() { + state = PoopBagState.extinguished; + removeAll(children.whereType()); + + // Change to burnt color + paint = Paint()..color = const Color(0xFF2F2F2F); + + debugPrint('Poop bag has burned out'); + } + + @override + void render(Canvas canvas) { + super.render(canvas); + + // Draw flame effect when lit + if (state == PoopBagState.lit) { + final flamePaint = Paint() + ..color = Color.lerp( + const Color(0xFFFF4500), + const Color(0xFFFFD700), + sin(burnTimer * 10) * 0.5 + 0.5, + )!; + + // Draw flickering flame + canvas.drawCircle( + Offset(0, -radius - 5), + radius * 0.6 + sin(burnTimer * 15) * 2, + flamePaint, + ); + } + + // Render smoke particles + for (final particle in smokeParticles) { + particle.render(canvas); + } + } + + bool isNearPosition(Vector2 targetPosition, {double threshold = 30.0}) { + return (position - targetPosition).length < threshold; + } +} + +class SmokeParticle { + Vector2 position; + Vector2 velocity; + double life; + double maxLife; + bool shouldRemove = false; + + SmokeParticle({required this.position}) + : velocity = Vector2( + Random().nextDouble() * 20 - 10, + -Random().nextDouble() * 30 - 20, + ), + life = 2.0, + maxLife = 2.0; + + void update(double dt) { + position += velocity * dt; + velocity *= 0.98; // Slight air resistance + life -= dt; + + if (life <= 0) { + shouldRemove = true; + } + } + + void render(Canvas canvas) { + final alpha = (life / maxLife).clamp(0.0, 1.0); + final smokePaint = Paint() + ..color = Color(0xFF666666).withOpacity(alpha * 0.3); + + canvas.drawCircle( + Offset(position.x, position.y), + 6.0 * (1.0 - life / maxLife), + smokePaint, + ); + } +} \ No newline at end of file diff --git a/lib/game/components/target_house.dart b/lib/game/components/target_house.dart new file mode 100644 index 0000000..371d286 --- /dev/null +++ b/lib/game/components/target_house.dart @@ -0,0 +1,52 @@ +import 'package:flame/components.dart'; +import 'package:flutter/foundation.dart'; +import 'package:shitman/game/components/neighborhood.dart'; + +class TargetHouse extends Component { + House? currentTarget; + bool missionActive = false; + + @override + Future onLoad() async { + await super.onLoad(); + selectNewTarget(); + } + + void selectNewTarget() { + // Find the neighborhood component + final neighborhood = parent?.children.whereType().firstOrNull; + if (neighborhood == null) return; + + // Clear previous target + if (currentTarget != null) { + currentTarget!.isTarget = false; + } + + // Select random house as new target + currentTarget = neighborhood.getRandomHouse(); + if (currentTarget != null) { + currentTarget!.isTarget = true; + missionActive = true; + debugPrint('New target selected at ${currentTarget!.position}'); + } + } + + void completeMission() { + if (currentTarget != null) { + currentTarget!.isTarget = false; + currentTarget = null; + } + missionActive = false; + } + + bool isPlayerNearTarget(Vector2 playerPosition, {double threshold = 50.0}) { + if (currentTarget?.doorPosition == null) return false; + + final distance = (playerPosition - currentTarget!.doorPosition!).length; + return distance < threshold; + } + + Vector2? getTargetPosition() { + return currentTarget?.doorPosition; + } +} \ No newline at end of file diff --git a/lib/game/shitman_game.dart b/lib/game/shitman_game.dart new file mode 100644 index 0000000..818b43a --- /dev/null +++ b/lib/game/shitman_game.dart @@ -0,0 +1,144 @@ +import 'package:flame/game.dart'; +import 'package:flame/components.dart'; +import 'package:flame/events.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; +import 'package:shitman/game/components/player.dart'; +import 'package:shitman/game/components/neighborhood.dart'; +import 'package:shitman/game/components/target_house.dart'; +import 'package:shitman/settings/app_settings.dart'; + +/// Shitman Game +/// A 2D top-down "hitman" style game, but instead of assassinating people, +/// your objective is to place flaming bags of dog poop on the doorsteps of +/// your targets without getting caught. + +enum GameState { mainMenu, playing, paused, gameOver, missionComplete } + +class ShitmanGame extends FlameGame with HasKeyboardHandlerComponents, HasCollisionDetection, AppSettings { + late Player player; + late Neighborhood neighborhood; + late TargetHouse targetHouse; + late CameraComponent gameCamera; + + GameState gameState = GameState.mainMenu; + @override + bool debugMode = false; + int missionScore = 0; + int totalMissions = 0; + bool infiniteMode = false; + + @override + Future onLoad() async { + await super.onLoad(); + await initSettings(); + + // Setup camera + gameCamera = CameraComponent.withFixedResolution( + world: world, + width: 800, + height: 600, + ); + addAll([gameCamera, world]); + + // Initialize debug mode from settings + debugMode = appSettings.getBool('game.debug_mode'); + } + + void startGame() { + gameState = GameState.playing; + initializeLevel(); + } + + void startInfiniteMode() { + infiniteMode = true; + overlays.remove('MainMenu'); + overlays.add('InGameUI'); + startGame(); + } + + void stopGame() { + gameState = GameState.mainMenu; + world.removeAll(world.children); + missionScore = 0; + } + + void pauseGame() { + gameState = GameState.paused; + } + + void resumeGame() { + gameState = GameState.playing; + } + + void initializeLevel() { + // Clear previous level + world.removeAll(world.children); + + // Create neighborhood + neighborhood = Neighborhood(); + world.add(neighborhood); + + // Create target house + targetHouse = TargetHouse(); + world.add(targetHouse); + + // Create player + player = Player(); + world.add(player); + + // Setup camera to follow player + gameCamera.follow(player); + } + + void completeCurrentMission() { + gameState = GameState.missionComplete; + missionScore += 100; + totalMissions++; + + if (infiniteMode) { + // Generate new mission after delay + Future.delayed(Duration(seconds: 2), () { + initializeLevel(); + gameState = GameState.playing; + }); + } + } + + void failMission() { + gameState = GameState.gameOver; + // TODO: Show game over screen + } + + @override + KeyEventResult onKeyEvent(KeyEvent event, Set keysPressed) { + if (gameState != GameState.playing) return KeyEventResult.ignored; + + // Handle pause + if (keysPressed.contains(LogicalKeyboardKey.escape)) { + pauseGame(); + overlays.add('PauseMenu'); + return KeyEventResult.handled; + } + + // Handle player input + player.handleInput(keysPressed); + + // Handle action keys on key down + if (event is KeyDownEvent) { + player.handleAction(event.logicalKey); + } + + return KeyEventResult.handled; + } + + @override + void update(double dt) { + super.update(dt); + + // Only update game logic when playing + if (gameState != GameState.playing) return; + + // Game-specific update logic here + } +} diff --git a/lib/main.dart b/lib/main.dart index a97340b..9573d2f 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,7 +1,23 @@ +import 'dart:ui'; + +import 'package:flame/game.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:nes_ui/nes_ui.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:shitman/game/shitman_game.dart'; +import 'package:shitman/ui/in_game_ui.dart'; + +class AnyInputScrollBehavior extends MaterialScrollBehavior { + // Override behavior methods and getters like dragDevices + @override + Set get dragDevices => { + PointerDeviceKind.touch, + PointerDeviceKind.mouse, + PointerDeviceKind.trackpad, + }; +} void main() { LicenseRegistry.addLicense(() async* { @@ -9,105 +25,46 @@ void main() { yield LicenseEntryWithLineBreaks(['google_fonts'], license); }); - runApp(const MyApp()); + runApp( + EasyLocalization( + supportedLocales: [Locale('en'), Locale('da'), Locale('de')], + path: 'assets/translations', + fallbackLocale: Locale('en'), + startLocale: Locale('en'), + useOnlyLangCode: true, + useFallbackTranslations: true, + child: Shitman(), + ), + ); } -class MyApp extends StatelessWidget { - const MyApp({super.key}); +class Shitman extends StatelessWidget { + const Shitman({super.key}); // This widget is the root of your application. @override Widget build(BuildContext context) { return MaterialApp( + localizationsDelegates: context.localizationDelegates, + supportedLocales: context.supportedLocales, + scrollBehavior: AnyInputScrollBehavior(), + locale: context.locale, theme: flutterNesTheme(brightness: Brightness.dark), themeMode: ThemeMode.dark, - home: const MyHomePage(title: 'Flutter Demo Home Page'), - ); - } -} - -class MyHomePage extends StatefulWidget { - const MyHomePage({super.key, required this.title}); - - // This widget is the home page of your application. It is stateful, meaning - // that it has a State object (defined below) that contains fields that affect - // how it looks. - - // This class is the configuration for the state. It holds the values (in this - // case the title) provided by the parent (in this case the App widget) and - // used by the build method of the State. Fields in a Widget subclass are - // always marked "final". - - final String title; - - @override - State createState() => _MyHomePageState(); -} - -class _MyHomePageState extends State { - int _counter = 0; - - void _incrementCounter() { - setState(() { - // This call to setState tells the Flutter framework that something has - // changed in this State, which causes it to rerun the build method below - // so that the display can reflect the updated values. If we changed - // _counter without calling setState(), then the build method would not be - // called again, and so nothing would appear to happen. - _counter++; - }); - } - - @override - Widget build(BuildContext context) { - // This method is rerun every time setState is called, for instance as done - // by the _incrementCounter method above. - // - // The Flutter framework has been optimized to make rerunning build methods - // fast, so that you can just rebuild anything that needs updating rather - // than having to individually change instances of widgets. - return Scaffold( - appBar: AppBar( - // TRY THIS: Try changing the color here to a specific color (to - // Colors.amber, perhaps?) and trigger a hot reload to see the AppBar - // change color while the other colors stay the same. - backgroundColor: Theme.of(context).colorScheme.inversePrimary, - // Here we take the value from the MyHomePage object that was created by - // the App.build method, and use it to set our appbar title. - title: Text(widget.title), - ), - body: Center( - // Center is a layout widget. It takes a single child and positions it - // in the middle of the parent. - child: Column( - // Column is also a layout widget. It takes a list of children and - // arranges them vertically. By default, it sizes itself to fit its - // children horizontally, and tries to be as tall as its parent. - // - // Column has various properties to control how it sizes itself and - // how it positions its children. Here we use mainAxisAlignment to - // center the children vertically; the main axis here is the vertical - // axis because Columns are vertical (the cross axis would be - // horizontal). - // - // TRY THIS: Invoke "debug painting" (choose the "Toggle Debug Paint" - // action in the IDE, or press "p" in the console), to see the - // wireframe for each widget. - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Text('You have pushed the button this many times:'), - Text( - '$_counter', - style: Theme.of(context).textTheme.headlineMedium, - ), - ], - ), - ), - floatingActionButton: FloatingActionButton( - onPressed: _incrementCounter, - tooltip: 'Increment', - child: const Icon(Icons.add), - ), // This trailing comma makes auto-formatting nicer for build methods. + home: GameWidget( + game: ShitmanGame(), + initialActiveOverlays: const ['MainMenu'], + overlayBuilderMap: { + InGameUI.overlayID: (context, game) => + InGameUI(game as ShitmanGame), + MainMenuUI.overlayID: (context, game) => + MainMenuUI(game as ShitmanGame), + SettingsUI.overlayID: (context, game) => + SettingsUI(game as ShitmanGame), + PauseMenuUI.overlayID: (context, game) => + PauseMenuUI(game as ShitmanGame), + }, + ), ); } } diff --git a/lib/settings/app_settings.dart b/lib/settings/app_settings.dart new file mode 100644 index 0000000..53428ff --- /dev/null +++ b/lib/settings/app_settings.dart @@ -0,0 +1,16 @@ +import 'game.dart'; +import 'colors.dart'; +import 'settings_manager.dart'; + +mixin class AppSettings { + final Settings appSettings = Settings(); + static bool _isInitialized = false; + + Future initSettings() async { + if (_isInitialized) return; + _isInitialized = true; + appSettings.register(gameSettings); + appSettings.register(colorSettings); + await appSettings.init(); + } +} diff --git a/lib/settings/colors.dart b/lib/settings/colors.dart new file mode 100644 index 0000000..ac985a1 --- /dev/null +++ b/lib/settings/colors.dart @@ -0,0 +1,15 @@ +import 'setting.dart'; +import 'settings_group.dart'; + +/// Color settings, themes, etc. +final colorSettings = SettingsGroup( + key: 'colors', + items: [ + /// Base color for game UI + IntSetting( + key: 'team_a_color', + defaultValue: 0xFF0000FF, // Blue + userConfigurable: true, + ), + ], +); diff --git a/lib/settings/exceptions.dart b/lib/settings/exceptions.dart new file mode 100644 index 0000000..ae7348f --- /dev/null +++ b/lib/settings/exceptions.dart @@ -0,0 +1,104 @@ +/// Exception thrown when a requested setting is not found. +/// +/// This occurs when: +/// - Accessing a setting that doesn't exist in the group +/// - Using an invalid storage key format +/// - Referencing a setting before it's been registered +/// +/// Example: +/// ```dart +/// try { +/// Settings.getBool('nonexistent.setting'); +/// } catch (e) { +/// if (e is SettingNotFoundException) { +/// print('Setting not found: ${e.message}'); +/// } +/// } +/// ``` +class SettingNotFoundException implements Exception { + /// Descriptive error message explaining what setting was not found. + final String message; + + /// Creates a new [SettingNotFoundException] with the given [message]. + const SettingNotFoundException(this.message); + + @override + String toString() => 'SettingNotFoundException: $message'; +} + +/// Exception thrown when attempting to modify a non-configurable setting. +/// +/// Settings can be marked as non-configurable by setting `userConfigurable: false`. +/// This is useful for system settings or read-only configuration values. +/// +/// Example: +/// ```dart +/// final systemSetting = BoolSetting( +/// key: 'systemFlag', +/// defaultValue: true, +/// userConfigurable: false, // This setting cannot be modified by users +/// ); +/// ``` +class SettingNotConfigurableException implements Exception { + /// Descriptive error message explaining which setting cannot be configured. + final String message; + + /// Creates a new [SettingNotConfigurableException] with the given [message]. + const SettingNotConfigurableException(this.message); + + @override + String toString() => 'SettingNotConfigurableException: $message'; +} + +/// Exception thrown when a setting value fails validation. +/// +/// This occurs when a validator function returns false for a given value. +/// Validators are useful for ensuring data integrity and business rules. +/// +/// Example: +/// ```dart +/// final volumeSetting = DoubleSetting( +/// key: 'volume', +/// defaultValue: 0.5, +/// validator: (value) => value >= 0.0 && value <= 1.0, +/// ); +/// +/// // This will throw SettingValidationException +/// await Settings.setDouble('audio.volume', 1.5); +/// ``` +class SettingValidationException implements Exception { + /// Descriptive error message explaining the validation failure. + final String message; + + /// Creates a new [SettingValidationException] with the given [message]. + const SettingValidationException(this.message); + + @override + String toString() => 'SettingValidationException: $message'; +} + +/// Exception thrown when attempting to access settings before initialization. +/// +/// The settings framework requires asynchronous initialization before use. +/// Always await `Settings.init()` or individual `readyFuture` properties +/// before accessing setting values. +/// +/// Example: +/// ```dart +/// // Wrong - may throw SettingsNotReadyException +/// bool value = Settings.getBool('game.sound'); +/// +/// // Correct - wait for initialization +/// await Settings.init(); +/// bool value = Settings.getBool('game.sound'); +/// ``` +class SettingsNotReadyException implements Exception { + /// Descriptive error message explaining the readiness issue. + final String message; + + /// Creates a new [SettingsNotReadyException] with the given [message]. + const SettingsNotReadyException(this.message); + + @override + String toString() => 'SettingsNotReadyException: $message'; +} diff --git a/lib/settings/game.dart b/lib/settings/game.dart new file mode 100644 index 0000000..7bd0bc1 --- /dev/null +++ b/lib/settings/game.dart @@ -0,0 +1,11 @@ +import 'setting.dart'; +import 'settings_group.dart'; + +/// Game settings group containing all game-related preferences. +final gameSettings = SettingsGroup( + key: 'game', + items: [ + /// Debug mode, additional elements to help with development + BoolSetting(key: 'debug_mode', defaultValue: false), + ], +); diff --git a/lib/settings/setting.dart b/lib/settings/setting.dart new file mode 100644 index 0000000..b0192ba --- /dev/null +++ b/lib/settings/setting.dart @@ -0,0 +1,343 @@ +import 'dart:convert'; +import 'dart:async'; +import 'package:shitman/attributes/serializable.dart'; + +/// Enum for supported setting value types. +/// +/// This enum is used internally to track the type of each setting +/// and ensure proper type casting during storage and retrieval operations. +enum SettingType { + /// Boolean true/false values + bool, + + /// Integer numeric values + int, + + /// Double-precision floating point values + double, + + /// String text values + string, +} + +/// Abstract base class for all setting types. +/// +/// This class defines the common interface and functionality for all settings, +/// including type safety, validation, change notifications, and metadata. +/// +/// Type parameter [T] ensures compile-time type safety for setting values. +/// +/// Example usage: +/// ```dart +/// // Create a validated volume setting +/// final volumeSetting = DoubleSetting( +/// key: 'volume', +/// defaultValue: 0.5, +/// validator: (value) => value >= 0.0 && value <= 1.0, +/// ); +/// +/// // Listen for changes +/// volumeSetting.stream.listen((newValue) { +/// print('Volume changed to: $newValue'); +/// }); +/// ``` +/// +/// Concrete implementations: +/// - [BoolSetting] for boolean values +/// - [IntSetting] for integer values +/// - [DoubleSetting] for floating-point values +/// - [StringSetting] for text values +abstract class Setting implements Serializable { + /// Internal stream controller for broadcasting value changes. + /// Uses broadcast to allow multiple listeners. + final StreamController _controller = StreamController.broadcast(); + + /// Unique identifier for this setting within its group. + /// + /// Keys should be descriptive and follow camelCase convention. + /// Examples: 'soundEnabled', 'maxRetries', 'serverUrl' + final String key; + + /// The data type of this setting's value. + /// + /// Used internally for type checking and storage operations. + /// Automatically set by concrete implementations. + final SettingType type; + + /// The default value used when the setting hasn't been explicitly set. + /// + /// This value is used during initialization and reset operations. + /// Must match the generic type parameter [T]. + final T defaultValue; + + /// Whether this setting can be modified by user code. + /// + /// When false, attempts to modify the setting will throw + /// [SettingNotConfigurableException]. Useful for system settings + /// or read-only configuration values. + /// + /// Defaults to true. + final bool userConfigurable; + + /// Optional function to validate setting values before storage. + /// + /// The validator receives the new value and should return: + /// - `true` if the value is valid + /// - `false` if the value should be rejected + /// + /// When validation fails, [SettingValidationException] is thrown. + /// + /// Example: + /// ```dart + /// validator: (value) => value >= 0 && value <= 100 + /// ``` + final bool Function(T)? validator; + + /// Stream that emits new values when the setting changes. + /// + /// This stream uses broadcast semantics, allowing multiple listeners. + /// The stream emits the new value immediately after it's stored. + /// + /// Example: + /// ```dart + /// setting.stream.listen((newValue) { + /// print('Setting changed to: $newValue'); + /// updateUI(newValue); + /// }); + /// ``` + Stream get stream => _controller.stream; + + /// Creates a new setting with the specified configuration. + /// + /// Parameters: + /// - [key]: Unique identifier within the settings group + /// - [type]: Data type of the setting value + /// - [defaultValue]: Initial/reset value for the setting + /// - [userConfigurable]: Whether the setting can be modified (default: true) + /// - [validator]: Optional validation function for new values + Setting({ + required this.key, + required this.type, + required this.defaultValue, + this.userConfigurable = true, + this.validator, + }); + + /// Internal method to notify all stream listeners of a value change. + /// + /// This method is called automatically by the settings framework + /// after a value has been successfully stored. Application code + /// should not call this method directly. + /// + /// Parameters: + /// - [value]: The new value that was stored + void notifyChange(T value) { + _controller.add(value); + } + + /// Internal method to validate a value using the validator function. + /// + /// Returns true if no validator is provided or if the validator + /// function returns true. Returns false if validation fails. + /// + /// Parameters: + /// - [value]: The value to validate + /// + /// Returns: true if valid, false if invalid + bool validate(T value) { + return validator?.call(value) ?? true; + } + + /// Dispose of the stream controller and release resources. + /// + /// This method should be called when the setting is no longer needed + /// to prevent memory leaks. It's automatically called by the settings + /// framework when disposing of setting groups. + /// + /// After calling dispose, the [stream] will no longer emit events. + void dispose() { + _controller.close(); + } + + /// Converts the setting to a map. + @override + Map toMap() { + return { + 'key': key, + 'type': type.name, + 'defaultValue': defaultValue, + 'userConfigurable': userConfigurable, + // Todo: convert validator to use validation classes (e.g. RangeValidator) + 'validator': null, + }; + } + + /// Creates a setting from a map representation. + Setting.fromMap(Map map) + : key = map['key'] as String, + type = SettingType.values.firstWhere( + (e) => e.name == map['type'], + orElse: + () => throw ArgumentError('Invalid setting type: ${map['type']}'), + ), + defaultValue = map['defaultValue'] as T, + userConfigurable = map['userConfigurable'] as bool? ?? true, + validator = null; + + /// Converts the setting to a JSON string representation. + @override + String toJson() { + return jsonEncode(toMap()); + } + + /// Creates a setting from a JSON string representation. + Setting.fromJson(String json) + : this.fromMap(jsonDecode(json) as Map); +} + +/// A setting that stores boolean (true/false) values. +/// +/// This is a concrete implementation of [Setting] specialized for boolean values. +/// Commonly used for feature flags, toggles, and binary preferences. +/// +/// Example: +/// ```dart +/// final soundEnabled = BoolSetting( +/// key: 'soundEnabled', +/// defaultValue: true, +/// ); +/// +/// final debugMode = BoolSetting( +/// key: 'debugMode', +/// defaultValue: false, +/// userConfigurable: false, // System setting +/// ); +/// ``` +class BoolSetting extends Setting { + /// Creates a new boolean setting. + /// + /// Parameters: + /// - [key]: Unique identifier for this setting + /// - [defaultValue]: Initial boolean value (true or false) + /// - [userConfigurable]: Whether users can modify this setting (default: true) + /// - [validator]: Optional validation function for boolean values + BoolSetting({ + required super.key, + required super.defaultValue, + super.userConfigurable, + super.validator, + }) : super(type: SettingType.bool); + + /// Converts the boolean value to a JSON string representation. + @override + String toJson() { + return defaultValue.toString(); + } + + /// Creates a boolean setting from a JSON string representation. +} + +/// A setting that stores integer numeric values. +/// +/// This is a concrete implementation of [Setting] specialized for integer values. +/// Useful for counts, limits, indices, and whole number preferences. +/// +/// Example: +/// ```dart +/// final maxRetries = IntSetting( +/// key: 'maxRetries', +/// defaultValue: 3, +/// validator: (value) => value >= 0 && value <= 10, +/// ); +/// +/// final fontSize = IntSetting( +/// key: 'fontSize', +/// defaultValue: 14, +/// validator: (value) => value >= 8 && value <= 72, +/// ); +/// ``` +class IntSetting extends Setting { + /// Creates a new integer setting. + /// + /// Parameters: + /// - [key]: Unique identifier for this setting + /// - [defaultValue]: Initial integer value + /// - [userConfigurable]: Whether users can modify this setting (default: true) + /// - [validator]: Optional validation function (e.g., range checking) + IntSetting({ + required super.key, + required super.defaultValue, + super.userConfigurable, + super.validator, + }) : super(type: SettingType.int); +} + +/// A setting that stores double-precision floating-point values. +/// +/// This is a concrete implementation of [Setting] specialized for decimal values. +/// Perfect for percentages, ratios, measurements, and precise numeric settings. +/// +/// Example: +/// ```dart +/// final volume = DoubleSetting( +/// key: 'volume', +/// defaultValue: 0.8, +/// validator: (value) => value >= 0.0 && value <= 1.0, +/// ); +/// +/// final animationSpeed = DoubleSetting( +/// key: 'animationSpeed', +/// defaultValue: 1.0, +/// validator: (value) => value > 0.0 && value <= 5.0, +/// ); +/// ``` +class DoubleSetting extends Setting { + /// Creates a new double setting. + /// + /// Parameters: + /// - [key]: Unique identifier for this setting + /// - [defaultValue]: Initial floating-point value + /// - [userConfigurable]: Whether users can modify this setting (default: true) + /// - [validator]: Optional validation function (e.g., range checking) + DoubleSetting({ + required super.key, + required super.defaultValue, + super.userConfigurable, + super.validator, + }) : super(type: SettingType.double); +} + +/// A setting that stores string text values. +/// +/// This is a concrete implementation of [Setting] specialized for text values. +/// Ideal for names, URLs, file paths, themes, and textual preferences. +/// +/// Example: +/// ```dart +/// final theme = StringSetting( +/// key: 'theme', +/// defaultValue: 'light', +/// validator: (value) => ['light', 'dark', 'auto'].contains(value), +/// ); +/// +/// final serverUrl = StringSetting( +/// key: 'serverUrl', +/// defaultValue: 'https://api.example.com', +/// validator: (value) => Uri.tryParse(value) != null, +/// ); +/// ``` +class StringSetting extends Setting { + /// Creates a new string setting. + /// + /// Parameters: + /// - [key]: Unique identifier for this setting + /// - [defaultValue]: Initial text value + /// - [userConfigurable]: Whether users can modify this setting (default: true) + /// - [validator]: Optional validation function (e.g., format checking) + StringSetting({ + required super.key, + required super.defaultValue, + super.userConfigurable, + super.validator, + }) : super(type: SettingType.string); +} diff --git a/lib/settings/settings.dart b/lib/settings/settings.dart new file mode 100644 index 0000000..043096a --- /dev/null +++ b/lib/settings/settings.dart @@ -0,0 +1,45 @@ +/// ============================================================================= +/// MODULAR SETTINGS FRAMEWORK +/// ============================================================================= +/// +/// A comprehensive, type-safe settings management framework for Flutter/Dart +/// applications. +/// +/// Usage Example: +/// ```dart +/// // Define your settings +/// final gameSettings = SettingsBase( +/// key: 'game', +/// items: SettingsGroup(items: [ +/// BoolSetting(key: 'soundEnabled', defaultValue: true), +/// DoubleSetting( +/// key: 'volume', +/// defaultValue: 0.8, +/// validator: (v) => v >= 0.0 && v <= 1.0, +/// ), +/// ]), +/// ); +/// +/// // Register and initialize +/// Settings.register(gameSettings); +/// await Settings.init(); +/// +/// // Use settings +/// bool sound = Settings.getBool('game.soundEnabled'); +/// await Settings.setBool('game.soundEnabled', false); +/// +/// // Listen for changes +/// gameSettings.items['soundEnabled']!.stream.listen((value) { +/// print('Sound enabled changed to: $value'); +/// }); +/// ``` +/// +/// ============================================================================= +library; + +export 'settings_manager.dart'; +export 'exceptions.dart'; +export 'setting.dart'; +export 'settings_group.dart'; +export 'settings_store.dart'; +export 'game.dart'; diff --git a/lib/settings/settings_group.dart b/lib/settings/settings_group.dart new file mode 100644 index 0000000..06d2a65 --- /dev/null +++ b/lib/settings/settings_group.dart @@ -0,0 +1,468 @@ +import 'dart:async'; +import 'dart:collection'; + +import 'exceptions.dart'; +import 'settings_store.dart'; +import 'setting.dart'; + +/// A comprehensive settings group that manages related settings with persistence, +/// initialization, and type-safe access. +/// +/// [SettingsGroup] extends [UnmodifiableMapBase] to provide convenient +/// map-like access to settings while managing their persistence and validation. +/// Each group has a unique key namespace and handles its own initialization. +/// +/// Usage pattern: +/// ```dart +/// // 1. Define your settings group +/// final gameSettings = SettingsGroup( +/// key: 'game', +/// items: [ +/// BoolSetting(key: 'soundEnabled', defaultValue: true), +/// DoubleSetting(key: 'volume', defaultValue: 0.8), +/// ], +/// ); +/// +/// // 2. Register with the global settings manager +/// Settings.register(gameSettings); +/// +/// // 3. Wait for initialization +/// await gameSettings.readyFuture; +/// +/// // 4. Use the settings +/// bool soundEnabled = gameSettings.get('soundEnabled'); +/// await gameSettings.setValue('volume', 0.5); +/// ``` +class SettingsGroup extends UnmodifiableMapBase { + /// Reference to the singleton settings store for persistence. + /// + /// This store handles the actual reading and writing of values + /// to SharedPreferences with caching for performance. + late final SettingsStore _store; + + /// Unique identifier for this settings group. + /// + /// This key is used as a namespace prefix for all settings in this group. + /// For example, if key is 'game' and a setting key is 'volume', + /// the stored key becomes 'game.volume'. + /// + /// Should be descriptive and unique across your application. + final String key; + + /// Immutable set of all settings contained in this group. + /// + /// This set is created during construction and cannot be modified afterward. + /// It contains all the setting objects that belong to this group. + late final Set> items; + + /// Internal cache of setting keys for efficient lookups. + /// + /// This set contains the string keys of all settings in the group, + /// providing O(1) key existence checks and fast iteration. + late final Set _keys; + + /// Internal flag tracking initialization status. + bool _ready = false; + + /// Public property indicating whether this settings group is ready for use. + /// + /// When false, accessing setting values will throw [SettingsNotReadyException]. + /// When true, all settings have been loaded and are available synchronously. + bool get ready => _ready; + + /// Internal completer that completes when initialization finishes. + late Completer _readyCompleter; + + /// Future that completes when all settings in this group are initialized. + /// + /// Await this future before accessing setting values to ensure they've + /// been loaded from storage. The future completes with true on success + /// or throws an exception if initialization fails. + /// + /// Example: + /// ```dart + /// await gameSettings.readyFuture; + /// // Now safe to access settings synchronously + /// bool soundEnabled = gameSettings.get('soundEnabled'); + /// ``` + Future get readyFuture => _readyCompleter.future; + + /// Creates a new settings group with the given key and settings. + /// + /// The provided [items] are converted to an immutable set, and their + /// keys are extracted for efficient access. Duplicate keys within + /// the same group are not allowed and will cause undefined behavior. + /// + /// Parameters: + /// - [key]: Unique identifier for this settings group + /// - [items]: Collection of settings to include in this group + /// - [forceRegularSharedPreferences]: Whether to force regular SharedPreferences (for testing) + /// + /// Example: + /// ```dart + /// final group = SettingsGroup( + /// key: 'game', + /// items: [ + /// BoolSetting(key: 'notifications', defaultValue: true), + /// IntSetting(key: 'timeout', defaultValue: 30), + /// ], + /// ); + /// ``` + SettingsGroup({ + required this.key, + required Iterable items, + bool forceRegularSharedPreferences = false, + }) { + this.items = Set.from(items); + _keys = items.map((item) => item.key).toSet(); + _store = SettingsStore( + forceRegularSharedPreferences: forceRegularSharedPreferences, + ); + _readyCompleter = Completer(); + // Initialize the settings in the storage if they haven't been set yet. + _init(); + } + + /// Creates a new settings group optimized for testing. + /// This constructor forces the use of regular SharedPreferences instead + /// of SharedPreferencesWithCache to avoid test compatibility issues. + SettingsGroup.forTesting({ + required this.key, + required Iterable items, + }) { + this.items = Set.from(items); + _keys = items.map((item) => item.key).toSet(); + _store = SettingsStore(forceRegularSharedPreferences: true); + _readyCompleter = Completer(); + // Initialize the settings in the storage if they haven't been set yet. + _init(); + } + + /// Retrieves a setting by its key. + /// + /// This operator provides map-like access to settings within the group. + /// The return type is [Setting] to accommodate different setting types. + /// + /// Parameters: + /// - [key]: The string key of the setting to retrieve + /// + /// Returns: The setting object with the specified key or null if not found. + /// + /// Example: + /// ```dart + /// Setting volumeSetting = audioGroup['volume']; + /// BoolSetting enabledSetting = audioGroup['enabled'] as BoolSetting; + /// ``` + @override + Setting? operator [](Object? key) { + try { + return items.firstWhere((item) => item.key == key); + } catch (_) { + return null; + } + } + + /// Returns an iterable of all setting keys in this group. + /// + /// This property provides the keys needed for map-like iteration + /// and key existence checking. + /// + /// Returns: Iterable containing all setting keys as strings + @override + Iterable get keys => _keys; + + /// Returns the number of settings in this group. + /// + /// This count includes all settings regardless of their type + /// or configurability status. + /// + /// Returns: Integer count of settings in the group + @override + int get length => _keys.length; + + /// Initializes the settings by checking if they are set in the storage. + /// If not, it sets them with their default values. + /// This is called in the constructor to ensure settings are ready to use. + /// It waits for the store to be ready before proceeding, but there is no + /// guarantee that the settings are initialized before the first access. + /// If you need to ensure settings are initialized before use, you should + /// await the [readyFuture] before accessing any settings. + Future _init() async { + try { + if (!_store.ready) { + await _store.readyFuture; + } + for (final Setting setting in items) { + final storageKey = _storageKey(setting.key); + if (!_store.prefs.containsKey(storageKey)) { + // If the setting is not set, initialize it with the default value. + await _set(storageKey, setting, null, force: true); + } else { + // Validate existing value and reset to default if invalid + try { + final currentValue = _get(setting); + if (setting.validator != null && !setting.validate(currentValue)) { + await _set(storageKey, setting, null, force: true); + } + } catch (e) { + // If there's an error reading the current value, reset to default + await _set(storageKey, setting, null, force: true); + } + } + } + _ready = true; + _readyCompleter.complete(true); + } catch (error) { + _ready = false; + _readyCompleter.completeError(error); + rethrow; + } + } + + /// Sets the value of a setting by its key. + Future setValue(String key, T value) async { + await _waitUntilReady(); + final setting = this[key]; + if (setting == null) { + throw SettingNotFoundException( + 'No setting in ${this.key} found for key: $key', + ); + } + final storageKey = _storageKey(setting.key); + if (!setting.userConfigurable) { + throw SettingNotConfigurableException( + 'Setting $storageKey is not user configurable', + ); + } + + // Validate the value if a validator is provided + if (setting is Setting && !setting.validate(value)) { + throw SettingValidationException( + 'Invalid value for setting $storageKey: $value', + ); + } + + await _set(storageKey, setting, value); + + // Notify change listeners + if (setting is Setting) { + setting.notifyChange(value); + } + } + + /// Convenience method to get a typed value of a setting by its key. + /// Throws an error if the setting is not found or if the type does not match. + T get(String key) { + _readySync(); + if (T == dynamic) { + return getValue(key); + } + final setting = this[key]; + if (setting == null) { + throw SettingNotFoundException( + 'No setting in ${this.key} found for key: $key', + ); + } + if (setting is! Setting) { + throw ArgumentError( + 'Setting $key is not of type ${T.runtimeType}, but ${setting.type}', + ); + } + + return _get(setting); + } + + /// Gets the value of a setting by its key. + dynamic getValue(String key) { + _readySync(); + final setting = this[key]; + if (setting == null) { + throw SettingNotFoundException( + 'No setting in ${this.key} found for key: $key', + ); + } + + return _get(setting); + } + + /// Ensures that the settings are ready before accessing them. + /// Throws a [SettingsNotReadyException] if the settings are not ready. + void _readySync() { + if (!_ready) { + throw SettingsNotReadyException( + 'Settings are not ready. Please await readyFuture.', + ); + } + } + + /// Waits until the settings are ready. + /// This is useful for asynchronous operations that need to ensure + /// settings are initialized. + Future _waitUntilReady() async { + if (!_ready) { + await _readyCompleter.future; + } + } + + /// Constructs a storage key for the given key in this settings group. + /// This is used to namespace the settings keys to avoid conflicts. + /// For example, if the group key is "game" and the setting key is + /// "fullscreen", the storage key will be "game.fullscreen". + String _storageKey(String key) { + return "${this.key}.$key"; + } + + T _validateOrDefault(Setting setting, T? value) { + if (value == null) return setting.defaultValue; + if (setting.validator != null && !setting.validate(value)) { + // return default value if validation fails + return setting.defaultValue; + } + return value; + } + + /// Gets the value of a setting by its key and type. + /// Throws an error if the setting is not found or if the type does not match. + /// This method is used internally to retrieve the value of a setting. + T _get(Setting setting) { + final storageKey = _storageKey(setting.key); + if (!_store.prefs.containsKey(storageKey)) { + // If not found in storage, return default value + return setting.defaultValue; + } + + try { + switch (T) { + case const (bool): + final value = _store.prefs.getBool(storageKey); + // validate the value if a validator is provided + return _validateOrDefault(setting, value as T); + case const (int): + final value = _store.prefs.getInt(storageKey); + return _validateOrDefault(setting, value as T); + case const (double): + final value = _store.prefs.getDouble(storageKey); + return _validateOrDefault(setting, value as T); + case const (String): + final value = _store.prefs.getString(storageKey); + return _validateOrDefault(setting, value as T); + default: + throw ArgumentError('Unsupported setting type: ${T.runtimeType}'); + } + } catch (e) { + // If there's a type mismatch or other error, return default value + return setting.defaultValue; + } + } + + /// Sets the value of a setting by its key and type. + /// Throws an error if the setting is not found or if the type does not match. + /// This method is used internally to set the value of a setting. + /// If [force] is true, it will set the value even if the setting is + /// not user configurable. + /// If [value] is null, it will use the default value of the setting. + /// If the setting is not user configurable and [force] is false, + /// it will throw an error. + Future _set( + String storageKey, + Setting setting, + T? value, { + bool force = false, + }) async { + if (!force && !setting.userConfigurable) { + throw SettingNotConfigurableException( + 'Setting $storageKey is not user configurable', + ); + } + if (!force && !_store.prefs.containsKey(storageKey)) { + throw SettingNotFoundException('No setting found for: $storageKey'); + } + + switch (T) { + case const (bool): + value ??= setting.defaultValue; + return _setBool(storageKey, value as bool); + case const (int): + value ??= setting.defaultValue; + return _setInt(storageKey, value as int); + case const (double): + value ??= setting.defaultValue; + return _setDouble(storageKey, value as double); + case const (String): + value ??= setting.defaultValue; + return _setString(storageKey, value as String); + case const (dynamic): + // If the type is dynamic, we can return any value. + // This is a fallback for when the type is not known at compile time. + // it is less efficient, but let's face it, you probably should not be + // updating settings 1000s of times per second. + value ??= setting.defaultValue; + + switch (setting.type) { + case SettingType.bool: + return _setBool(storageKey, value as bool); + case SettingType.int: + return _setInt(storageKey, value as int); + case SettingType.double: + return _setDouble(storageKey, value as double); + case SettingType.string: + return _setString(storageKey, value as String); + } + default: + throw ArgumentError('Unsupported setting type: ${T.runtimeType}'); + } + } + + /// Sets a boolean value for the given storage key. + Future _setBool(String storageKey, bool value) async { + await _store.prefs.setBool(storageKey, value); + } + + /// Sets an integer value for the given storage key. + Future _setInt(String storageKey, int value) async { + await _store.prefs.setInt(storageKey, value); + } + + /// Sets a double value for the given storage key. + Future _setDouble(String storageKey, double value) async { + await _store.prefs.setDouble(storageKey, value); + } + + /// Sets a string value for the given storage key. + Future _setString(String storageKey, String value) async { + await _store.prefs.setString(storageKey, value); + } + + /// Reset a setting to its default value. + Future reset(String key) async { + await _waitUntilReady(); + final setting = this[key]; + if (setting == null) { + throw SettingNotFoundException( + 'No setting in ${this.key} found for key: $key', + ); + } + final storageKey = _storageKey(setting.key); + await _set(storageKey, setting, null, force: true); + + // Notify change listeners + setting.notifyChange(setting.defaultValue); + } + + /// Reset all settings in this group to their default values. + Future resetAll() async { + await _waitUntilReady(); + for (final setting in items) { + final storageKey = _storageKey(setting.key); + await _set(storageKey, setting, null, force: true); + setting.notifyChange(setting.defaultValue); + } + } + + /// Dispose all stream controllers for settings in this group. + void dispose() { + for (final setting in items) { + setting.dispose(); + } + } +} diff --git a/lib/settings/settings_manager.dart b/lib/settings/settings_manager.dart new file mode 100644 index 0000000..355fcc1 --- /dev/null +++ b/lib/settings/settings_manager.dart @@ -0,0 +1,350 @@ +import 'dart:async'; +import 'settings_group.dart'; +import 'exceptions.dart'; + +/// Global settings manager providing centralized access to all setting groups. +/// +/// The [Settings] class serves as the main entry point for the settings framework, +/// offering static methods for registration, initialization, and access to settings +/// across your entire application. It manages multiple [SettingsGroup] instances and +/// provides both individual and batch operations. +/// +/// ## Overview +/// +/// The settings framework follows a hierarchical structure: +/// ``` +/// Settings (Global Manager) +/// │ +/// ├── SettingsGroup (Group: "game") +/// │ ├── BoolSetting ("soundEnabled") +/// │ └── DoubleSetting ("volume") +/// │ +/// └── SettingsGroup (Group: "ui") +/// ├── StringSetting ("theme") +/// └── IntSetting ("fontSize") +/// ``` +/// +/// ## Usage Pattern +/// +/// ```dart +/// // 1. Define your setting groups +/// final gameSettings = SettingsGroup( +/// key: 'game', +/// items: [ +/// BoolSetting(key: 'soundEnabled', defaultValue: true), +/// DoubleSetting(key: 'volume', defaultValue: 0.8), +/// ], +/// ); +/// +/// final uiSettings = SettingsGroup( +/// key: 'ui', +/// items: [ +/// StringSetting(key: 'theme', defaultValue: 'light'), +/// IntSetting(key: 'fontSize', defaultValue: 14), +/// ], +/// ); +/// +/// // 2. Register all groups +/// Settings.register(gameSettings); +/// Settings.register(uiSettings); +/// +/// // 3. Initialize the entire settings system +/// await Settings.init(); +/// +/// // 4. Access settings using dot notation +/// bool soundEnabled = Settings.getBool('game.soundEnabled'); +/// String theme = Settings.getString('ui.theme'); +/// +/// // 5. Modify settings with automatic validation +/// await Settings.setBool('game.soundEnabled', false); +/// await Settings.setString('ui.theme', 'dark'); +/// +/// // 6. Batch operations for efficiency +/// await Settings.setMultiple({ +/// 'game.volume': 0.5, +/// 'ui.fontSize': 16, +/// }); +/// +/// // 7. Reset operations +/// await Settings.resetSetting('game.volume'); // Reset single setting +/// await Settings.resetGroup('ui'); // Reset entire group +/// await Settings.resetAll(); // Reset everything +/// ``` +/// +/// ## Storage Key Format +/// +/// Settings are stored using a namespaced key format: `groupKey.settingKey` +/// - `game.soundEnabled` → boolean setting in the game group +/// - `ui.theme` → string setting in the ui group +/// - `network.timeout` → integer setting in the network group +/// +/// This prevents key conflicts between different setting groups and provides +/// logical organization of related settings. +class Settings { + /// Internal registry of all settings groups keyed by their group names. + /// + /// This map stores all registered [SettingsGroup] instances, providing + /// fast lookup by group key. Groups must be registered before use. + static final Map _settings = {}; + + /// Initializes all registered settings groups concurrently. + /// + /// This method waits for all registered settings groups to complete their + /// asynchronous initialization. It's essential to call this method before + /// accessing any setting values to ensure they've been loaded from storage. + /// + /// The initialization process: + /// 1. Waits for the underlying SharedPreferences to be ready + /// 2. Loads existing values from storage for each setting + /// 3. Creates default values for settings that don't exist yet + /// 4. Marks all groups as ready for synchronous access + /// + /// Returns: Future that completes when all settings are initialized + /// + /// Throws: Exception if any settings group fails to initialize + /// + /// Example: + /// ```dart + /// // Register your settings groups first + /// Settings.register(gameSettings); + /// Settings.register(uiSettings); + /// + /// // Then initialize everything + /// await Settings.init(); + /// + /// // Now safe to use settings synchronously + /// bool soundEnabled = Settings.getBool('game.soundEnabled'); + /// ``` + Future init() async { + final futures = _settings.values.map((settings) => settings.readyFuture); + await Future.wait(futures); + } + + /// Returns a map of all registered settings groups. + Map get groups => _settings; + + /// Returns a list of all registered settings groups keys. + List get groupKeys => _settings.keys.toList(); + + /// Allow access to settings by key using dynamic getters. + /// This allows you to access settings like: + /// Settings.game.fullscreen, Settings.game.soundVolume, etc. + @override + SettingsGroup noSuchMethod(Invocation invocation) { + if (invocation.isGetter) { + final key = invocation.memberName.toString(); + if (_settings.containsKey(key)) { + return _settings[key]!; + } + } + throw NoSuchMethodError.withInvocation(this, invocation); + } + + /// Validate and get the parts of a storage key. + /// The [storageKey] should be in the format "groupKey.settingKey". + /// Throws an [ArgumentError] if the storage key is invalid. + static ({String group, String setting}) _parseStorageKey(String storageKey) { + final parts = storageKey.split('.'); + if (parts.length < 2) { + throw ArgumentError('Invalid storage key: $storageKey'); + } + return (group: parts.first, setting: parts.sublist(1).join('.')); + } + + // ===== Getters ===== + + /// Override the accessor to allow dynamic access to settings + /// using the `[]` operator. + dynamic operator [](String key) { + return get(key); + } + + /// Registers a settings group with the global settings manager. + /// + /// Each settings group must be registered before the system can be initialized. + /// Groups are identified by their unique key, and duplicate keys are not allowed. + /// + /// This method should be called during application startup, before calling [init]. + /// + /// Parameters: + /// - [settings]: The SettingsGroup instance to register + /// + /// Throws: [ArgumentError] if a group with the same key already exists + /// + /// Example: + /// ```dart + /// final gameSettings = SettingsGroup(key: 'game', items: [...]); + /// final uiSettings = SettingsGroup(key: 'ui', items: [...]); + /// + /// Settings.register(gameSettings); + /// Settings.register(uiSettings); + /// + /// await Settings.init(); // Initialize after all groups are registered + /// ``` + void register(SettingsGroup settings) { + if (_settings.containsKey(settings.key)) { + throw ArgumentError('Settings with key ${settings.key} already exists'); + } + _settings[settings.key] = settings; + } + + /// Gets a settings group by its key. + SettingsGroup getGroup(String key) { + if (!_settings.containsKey(key)) { + throw SettingNotFoundException('No settings group found for key: $key'); + } + return _settings[key]!; + } + + /// Get a setting by its storage key and type. + /// The [storageKey] should be in the format "groupKey.settingKey". + /// Throws an [ArgumentError] if the storage key is invalid or + /// if the setting is not found. + T get(String storageKey) { + // Split the storage key to get the group key and setting key. + final id = _parseStorageKey(storageKey); + + final group = getGroup(id.group); + return group.get(id.setting); + } + + // Helpers for typed access to settings. + // These methods are for convenience to access settings without + // ending up with a dynamic value. + + /// Gets a boolean setting by its storage key. + /// The [storageKey] should be in the format "groupKey.settingKey". + /// Throws an [ArgumentError] if the storage key is invalid or + /// if the setting is not found or is not of type bool. + bool getBool(String storageKey) { + return get(storageKey); + } + + /// Gets a double setting by its storage key. + /// The [storageKey] should be in the format "groupKey.settingKey". + /// Throws an [ArgumentError] if the storage key is invalid or + /// if the setting is not found or is not of type int. + int getInt(String storageKey) { + return get(storageKey); + } + + /// Gets a double setting by its storage key. + /// The [storageKey] should be in the format "groupKey.settingKey". + /// Throws an [ArgumentError] if the storage key is invalid or + /// if the setting is not found or is not of type double. + double getDouble(String storageKey) { + return get(storageKey); + } + + /// Gets a string setting by its storage key. + /// The [storageKey] should be in the format "groupKey.settingKey". + /// Throws an [ArgumentError] if the storage key is invalid or + /// if the setting is not found or is not of type string. + String getString(String storageKey) { + return get(storageKey); + } + + // ===== Setters ===== + + /// Sets a setting value by its storage key. + /// The [storageKey] should be in the format "groupKey.settingKey". + /// Throws an [ArgumentError] if the storage key is invalid or + /// if the setting is not found or is not user configurable. + Future setValue(String storageKey, dynamic value) async { + final id = _parseStorageKey(storageKey); + final group = getGroup(id.group); + await group.setValue(id.setting, value); + } + + /// Sets a setting value by its storage key and type. + /// The [storageKey] should be in the format "groupKey.settingKey". + /// Throws an [ArgumentError] if the storage key is invalid or + /// if the setting is not found or is not user configurable. + Future set(String storageKey, T value) async { + final id = _parseStorageKey(storageKey); + final group = getGroup(id.group); + await group.setValue(id.setting, value); + } + + /// Sets a boolean setting value by its storage key. + /// The [storageKey] should be in the format "groupKey.settingKey". + /// Throws an [ArgumentError] if the storage key is invalid or + /// if the setting is not found or is not user configurable. + Future setBool(String storageKey, bool value) async { + await set(storageKey, value); + } + + /// Sets an integer setting value by its storage key. + /// The [storageKey] should be in the format "groupKey.settingKey". + /// Throws an [ArgumentError] if the storage key is invalid or + /// if the setting is not found or is not user configurable. + Future setInt(String storageKey, int value) async { + await set(storageKey, value); + } + + /// Sets a double setting value by its storage key. + /// The [storageKey] should be in the format "groupKey.settingKey". + /// Throws an [ArgumentError] if the storage key is invalid or + /// if the setting is not found or is not user configurable. + Future setDouble(String storageKey, double value) async { + await set(storageKey, value); + } + + /// Sets a string setting value by its storage key. + /// The [storageKey] should be in the format "groupKey.settingKey". + /// Throws an [ArgumentError] if the storage key is invalid or + /// if the setting is not found or is not user configurable. + Future setString(String storageKey, String value) async { + await set(storageKey, value); + } + + /// Sets multiple settings values in a batch operation. + /// The [settings] map should contain storage keys as keys and values as values. + /// This is more efficient than setting values individually. + Future setMultiple(Map settings) async { + final futures = >[]; + for (final entry in settings.entries) { + futures.add(setValue(entry.key, entry.value)); + } + await Future.wait(futures); + } + + /// Reset a setting to its default value by storage key. + /// The [storageKey] should be in the format "groupKey.settingKey". + Future resetSetting(String storageKey) async { + final id = _parseStorageKey(storageKey); + final group = getGroup(id.group); + await group.reset(id.setting); + } + + /// Reset all settings in a group to their default values. + /// The [groupKey] should be the key of the settings group. + Future resetGroup(String groupKey) async { + final group = getGroup(groupKey); + await group.resetAll(); + } + + /// Reset all settings across all groups to their default values. + Future resetAll() async { + final futures = _settings.values.map((group) => group.resetAll()); + await Future.wait(futures); + } + + /// Dispose all settings groups and their stream controllers. + void dispose() { + for (final group in _settings.values) { + group.dispose(); + } + _settings.clear(); + } + + /// Clear all registered settings groups (for testing purposes). + void clearAll() { + dispose(); + } + + @override + String toString() { + return 'Settings{groups: ${_settings.keys.join(', ')}}'; + } +} diff --git a/lib/settings/settings_store.dart b/lib/settings/settings_store.dart new file mode 100644 index 0000000..676ad3b --- /dev/null +++ b/lib/settings/settings_store.dart @@ -0,0 +1,143 @@ +import 'dart:async'; +import 'package:flutter/foundation.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'exceptions.dart'; + +/// A singleton store that manages the underlying SharedPreferences with caching. +/// +/// This class provides a centralized, cached interface to SharedPreferences, +/// eliminating the need for repeated async calls during normal operation. +/// The store initializes asynchronously but provides synchronous access +/// once ready, improving performance for frequent setting access. +/// +/// The store is used internally by the settings framework and typically +/// doesn't need to be accessed directly by application code. +/// +/// In test environments, it automatically falls back to regular SharedPreferences +/// to ensure compatibility with test mocking frameworks. +/// +/// Example internal usage: +/// ```dart +/// final store = SettingsStore(); +/// await store.readyFuture; // Wait for initialization +/// bool value = store.prefs.getBool('some.key') ?? false; +/// ``` +class SettingsStore { + /// The singleton instance of the settings store. + static SettingsStore? _instance; + + /// Internal flag tracking whether the store is ready for use. + bool _ready = false; + + /// Public getter indicating if the store has been initialized and is ready. + /// When true, the [prefs] getter can be used synchronously. + bool get ready => _ready; + + /// Future that completes when the store is fully initialized. + /// Await this future before accessing settings to ensure proper initialization. + late final Future readyFuture; + + /// Factory constructor that returns the singleton instance. + /// Multiple calls to this constructor return the same instance. + factory SettingsStore({bool forceRegularSharedPreferences = false}) { + _instance ??= SettingsStore._internal(forceRegularSharedPreferences); + return _instance!; + } + + /// The SharedPreferences instance (cached or regular depending on environment). + /// Only accessible after initialization is complete. + late final dynamic _prefs; + + /// Whether we're using the cached version or regular SharedPreferences. + bool _isUsingCache = true; + + /// Private constructor that initializes SharedPreferences. + /// + /// In debug mode or when [forceRegularSharedPreferences] is true, uses regular + /// SharedPreferences for better test compatibility. In release mode, uses + /// SharedPreferencesWithCache for better performance. + /// + /// This constructor: + /// 1. Creates a completer for the ready future + /// 2. Chooses appropriate SharedPreferences implementation based on environment + /// 3. Sets up success and error handling + /// 4. Marks the store as ready when initialization completes + SettingsStore._internal(bool forceRegularSharedPreferences) { + final completer = Completer(); + readyFuture = completer.future; + + // In debug mode or when forced, use regular SharedPreferences for better test compatibility + // In release mode, use SharedPreferencesWithCache for better performance + final useRegularSharedPreferences = + forceRegularSharedPreferences || kDebugMode; + + if (useRegularSharedPreferences) { + _isUsingCache = false; + SharedPreferences.getInstance() + .then((prefs) { + _prefs = prefs; + _ready = true; + completer.complete(true); + }) + .catchError((error) { + _ready = false; + completer.completeError(error); + throw Exception('Failed to initialize SharedPreferences: $error'); + }); + } else { + _isUsingCache = true; + SharedPreferencesWithCache.create( + cacheOptions: const SharedPreferencesWithCacheOptions(), + ) + .then((prefs) { + _prefs = prefs; + _ready = true; + completer.complete(true); + }) + .catchError((error) { + // If SharedPreferencesWithCache fails, fall back to regular SharedPreferences + _isUsingCache = false; + SharedPreferences.getInstance() + .then((fallbackPrefs) { + _prefs = fallbackPrefs; + _ready = true; + completer.complete(true); + }) + .catchError((fallbackError) { + _ready = false; + completer.completeError(fallbackError); + throw Exception( + 'Failed to initialize any SharedPreferences: $fallbackError', + ); + }); + }); + } + } + + /// Reset the singleton instance (useful for testing). + static void reset() { + _instance = null; + } + + /// Provides access to the SharedPreferences instance. + /// + /// This getter should only be called after the store is ready. + /// Use [ready] to check readiness or await [readyFuture] to ensure + /// the store is initialized before accessing this property. + /// + /// Returns either SharedPreferencesWithCache (production) or + /// SharedPreferences (test environment) depending on initialization. + /// + /// Throws: SettingsNotReadyException if accessed before initialization completes. + dynamic get prefs { + if (!_ready) { + throw SettingsNotReadyException( + 'SettingsStore is not ready. Please await readyFuture first.', + ); + } + return _prefs; + } + + /// Returns true if using SharedPreferencesWithCache, false if using regular SharedPreferences. + bool get isUsingCache => _isUsingCache; +} diff --git a/lib/ui/in_game_ui.dart b/lib/ui/in_game_ui.dart new file mode 100644 index 0000000..79860fd --- /dev/null +++ b/lib/ui/in_game_ui.dart @@ -0,0 +1,314 @@ +import 'package:flutter/material.dart'; +import 'package:nes_ui/nes_ui.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:shitman/game/shitman_game.dart'; +import 'package:shitman/settings/app_settings.dart'; + +class InGameUI extends StatelessWidget with AppSettings { + static const String overlayID = 'InGameUI'; + final ShitmanGame game; + + InGameUI(this.game, {super.key}); + + @override + Widget build(BuildContext context) { + return Material( + color: Colors.transparent, + child: Stack( + children: [ + // Top HUD + Positioned( + top: 20, + left: 20, + right: 20, + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + // Stealth indicator + NesContainer( + backgroundColor: Colors.black87, + child: Padding( + padding: const EdgeInsets.all(8.0), + child: Row( + children: [ + Icon(Icons.visibility_off, size: 16), + SizedBox(width: 8), + Text('gameplay.hidden'.tr(), style: TextStyle(color: Colors.green)), + ], + ), + ), + ), + // Mission objective + NesContainer( + backgroundColor: Colors.black87, + child: Padding( + padding: const EdgeInsets.all(8.0), + child: Text('gameplay.find_target'.tr(), style: TextStyle(color: Colors.white)), + ), + ), + // Pause button + IconButton( + icon: Icon(Icons.pause, color: Colors.white), + onPressed: () => game.overlays.add(PauseMenuUI.overlayID), + ), + ], + ), + ), + + // Bottom controls hint + Positioned( + bottom: 20, + left: 20, + right: 20, + child: NesContainer( + backgroundColor: Colors.black54, + child: Padding( + padding: const EdgeInsets.all(12.0), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text('controls.move'.tr(), style: TextStyle(color: Colors.white)), + Text('controls.place_bag'.tr(), style: TextStyle(color: Colors.white)), + Text('controls.ring_bell'.tr(), style: TextStyle(color: Colors.white)), + ], + ), + ), + ), + ), + ], + ), + ); + } +} + +class MainMenuUI extends StatelessWidget with AppSettings { + static const String overlayID = 'MainMenu'; + final ShitmanGame game; + + MainMenuUI(this.game, {super.key}); + + @override + Widget build(BuildContext context) { + return Material( + color: Colors.black, + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + // Game title + Text( + 'game.title'.tr(), + style: Theme.of(context).textTheme.headlineLarge?.copyWith( + color: Colors.orange, + fontSize: 48, + fontWeight: FontWeight.bold, + ), + ), + SizedBox(height: 8), + Text( + 'game.subtitle'.tr(), + style: TextStyle(color: Colors.white70, fontSize: 16), + ), + SizedBox(height: 40), + + // Menu buttons + Column( + children: [ + NesButton( + type: NesButtonType.primary, + onPressed: () { + game.overlays.remove(MainMenuUI.overlayID); + game.overlays.add(InGameUI.overlayID); + game.startGame(); + }, + child: Text('menu.start_mission'.tr()), + ), + SizedBox(height: 16), + NesButton( + type: NesButtonType.normal, + onPressed: () { + game.overlays.remove(MainMenuUI.overlayID); + game.overlays.add(SettingsUI.overlayID); + }, + child: Text('menu.settings'.tr()), + ), + SizedBox(height: 16), + NesButton( + type: NesButtonType.normal, + onPressed: () => game.startInfiniteMode(), + child: Text('menu.infinite_mode'.tr()), + ), + ], + ), + + SizedBox(height: 40), + Text( + 'game.description'.tr(), + textAlign: TextAlign.center, + style: TextStyle(color: Colors.white54, fontSize: 12), + ), + ], + ), + ), + ); + } +} + +class SettingsUI extends StatelessWidget with AppSettings { + static const String overlayID = 'Settings'; + final ShitmanGame game; + + SettingsUI(this.game, {super.key}); + + @override + Widget build(BuildContext context) { + return Material( + color: Colors.black87, + child: Center( + child: NesContainer( + backgroundColor: Colors.black, + child: Padding( + padding: const EdgeInsets.all(24.0), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + 'menu.settings'.tr(), + style: Theme.of(context).textTheme.headlineMedium?.copyWith( + color: Colors.white, + ), + ), + IconButton( + icon: Icon(Icons.close, color: Colors.white), + onPressed: () { + game.overlays.remove(SettingsUI.overlayID); + game.overlays.add(MainMenuUI.overlayID); + }, + ), + ], + ), + SizedBox(height: 24), + + // Language selector + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text('Language / Sprog / Sprache:', style: TextStyle(color: Colors.white)), + DropdownButton( + value: context.locale.languageCode, + dropdownColor: Colors.black, + style: TextStyle(color: Colors.white), + items: [ + DropdownMenuItem(value: 'en', child: Text('English', style: TextStyle(color: Colors.white))), + DropdownMenuItem(value: 'da', child: Text('Dansk', style: TextStyle(color: Colors.white))), + DropdownMenuItem(value: 'de', child: Text('Deutsch', style: TextStyle(color: Colors.white))), + ], + onChanged: (String? newValue) { + if (newValue != null) { + context.setLocale(Locale(newValue)); + } + }, + ), + ], + ), + + SizedBox(height: 16), + + // Debug mode toggle + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text('ui.debug_mode'.tr(), style: TextStyle(color: Colors.white)), + NesCheckBox( + value: false, // TODO: Connect to settings + onChange: (value) { + // TODO: Update settings + }, + ), + ], + ), + + SizedBox(height: 40), + Center( + child: NesButton( + type: NesButtonType.primary, + onPressed: () { + game.overlays.remove(SettingsUI.overlayID); + game.overlays.add(MainMenuUI.overlayID); + }, + child: Text('menu.back_to_menu'.tr()), + ), + ), + ], + ), + ), + ), + ), + ); + } +} + +class PauseMenuUI extends StatelessWidget { + static const String overlayID = 'PauseMenu'; + final ShitmanGame game; + + const PauseMenuUI(this.game, {super.key}); + + @override + Widget build(BuildContext context) { + return Material( + color: Colors.black54, + child: Center( + child: NesContainer( + backgroundColor: Colors.black, + child: Padding( + padding: const EdgeInsets.all(24.0), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + 'ui.paused'.tr(), + style: Theme.of(context).textTheme.headlineMedium?.copyWith( + color: Colors.white, + ), + ), + SizedBox(height: 24), + + NesButton( + type: NesButtonType.primary, + onPressed: () => game.overlays.remove(PauseMenuUI.overlayID), + child: Text('menu.resume'.tr()), + ), + SizedBox(height: 12), + NesButton( + type: NesButtonType.normal, + onPressed: () { + game.overlays.remove(PauseMenuUI.overlayID); + game.overlays.remove(InGameUI.overlayID); + game.overlays.add(SettingsUI.overlayID); + }, + child: Text('menu.settings'.tr()), + ), + SizedBox(height: 12), + NesButton( + type: NesButtonType.warning, + onPressed: () { + game.overlays.remove(PauseMenuUI.overlayID); + game.overlays.remove(InGameUI.overlayID); + game.overlays.add(MainMenuUI.overlayID); + game.stopGame(); + }, + child: Text('menu.main_menu'.tr()), + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index e777c67..b8e2b22 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -6,7 +6,9 @@ import FlutterMacOS import Foundation import path_provider_foundation +import shared_preferences_foundation func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) + SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) } diff --git a/pubspec.lock b/pubspec.lock index 400d034..143a1f8 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1,6 +1,14 @@ # Generated by pub # See https://dart.dev/tools/pub/glossary#lockfile packages: + args: + dependency: transitive + description: + name: args + sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04 + url: "https://pub.dev" + source: hosted + version: "2.7.0" async: dependency: transitive description: @@ -57,6 +65,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.8" + easy_localization: + dependency: "direct main" + description: + name: easy_localization + sha256: "0f5239c7b8ab06c66440cfb0e9aa4b4640429c6668d5a42fe389c5de42220b12" + url: "https://pub.dev" + source: hosted + version: "3.0.7+1" + easy_logger: + dependency: transitive + description: + name: easy_logger + sha256: c764a6e024846f33405a2342caf91c62e357c24b02c04dbc712ef232bf30ffb7 + url: "https://pub.dev" + source: hosted + version: "0.0.2" equatable: dependency: transitive description: @@ -81,6 +105,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.4" + file: + dependency: transitive + description: + name: file + sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 + url: "https://pub.dev" + source: hosted + version: "7.0.1" flame: dependency: "direct main" description: @@ -102,6 +134,11 @@ packages: url: "https://pub.dev" source: hosted version: "5.0.0" + flutter_localizations: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" flutter_mini_sprite: dependency: transitive description: @@ -115,6 +152,11 @@ packages: description: flutter source: sdk version: "0.0.0" + flutter_web_plugins: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" google_fonts: dependency: "direct main" description: @@ -139,6 +181,14 @@ packages: url: "https://pub.dev" source: hosted version: "4.1.2" + intl: + dependency: transitive + description: + name: intl + sha256: "3df61194eb431efc39c4ceba583b95633a403f46c9fd341e550ce0bfa50e9aa5" + url: "https://pub.dev" + source: hosted + version: "0.20.2" leak_tracker: dependency: transitive description: @@ -299,6 +349,62 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.8" + shared_preferences: + dependency: "direct main" + description: + name: shared_preferences + sha256: "6e8bf70b7fef813df4e9a36f658ac46d107db4b4cfe1048b477d4e453a8159f5" + url: "https://pub.dev" + source: hosted + version: "2.5.3" + shared_preferences_android: + dependency: transitive + description: + name: shared_preferences_android + sha256: "20cbd561f743a342c76c151d6ddb93a9ce6005751e7aa458baad3858bfbfb6ac" + url: "https://pub.dev" + source: hosted + version: "2.4.10" + shared_preferences_foundation: + dependency: transitive + description: + name: shared_preferences_foundation + sha256: "6a52cfcdaeac77cad8c97b539ff688ccfc458c007b4db12be584fbe5c0e49e03" + url: "https://pub.dev" + source: hosted + version: "2.5.4" + shared_preferences_linux: + dependency: transitive + description: + name: shared_preferences_linux + sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + shared_preferences_platform_interface: + dependency: transitive + description: + name: shared_preferences_platform_interface + sha256: "57cbf196c486bc2cf1f02b85784932c6094376284b3ad5779d1b1c6c6a816b80" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + shared_preferences_web: + dependency: transitive + description: + name: shared_preferences_web + sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019 + url: "https://pub.dev" + source: hosted + version: "2.4.3" + shared_preferences_windows: + dependency: transitive + description: + name: shared_preferences_windows + sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1" + url: "https://pub.dev" + source: hosted + version: "2.4.1" sky_engine: dependency: transitive description: flutter diff --git a/pubspec.yaml b/pubspec.yaml index fc60b60..fc21293 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,92 +1,29 @@ name: shitman description: "Hitman, but with shit." -# The following line prevents the package from being accidentally published to -# pub.dev using `flutter pub publish`. This is preferred for private packages. -publish_to: 'none' # Remove this line if you wish to publish to pub.dev +publish_to: 'none' -# The following defines the version and build number for your application. -# A version number is three numbers separated by dots, like 1.2.43 -# followed by an optional build number separated by a +. -# Both the version and the builder number may be overridden in flutter -# build by specifying --build-name and --build-number, respectively. -# In Android, build-name is used as versionName while build-number used as versionCode. -# Read more about Android versioning at https://developer.android.com/studio/publish/versioning -# In iOS, build-name is used as CFBundleShortVersionString while build-number is used as CFBundleVersion. -# Read more about iOS versioning at -# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html -# In Windows, build-name is used as the major, minor, and patch parts -# of the product and file versions while build-number is used as the build suffix. version: 1.0.0+1 environment: sdk: ^3.7.0 -# Dependencies specify other packages that your package needs in order to work. -# To automatically upgrade your package dependencies to the latest versions -# consider running `flutter pub upgrade --major-versions`. Alternatively, -# dependencies can be manually updated by changing the version numbers below to -# the latest version available on pub.dev. To see which dependencies have newer -# versions available, run `flutter pub outdated`. dependencies: flutter: sdk: flutter - - # The following adds the Cupertino Icons font to your application. - # Use with the CupertinoIcons class for iOS style icons. cupertino_icons: ^1.0.8 flame: ^1.30.1 nes_ui: ^0.25.0 google_fonts: ^6.2.1 + easy_localization: ^3.0.7+1 + shared_preferences: ^2.2.2 dev_dependencies: flutter_test: sdk: flutter - - # The "flutter_lints" package below contains a set of recommended lints to - # encourage good coding practices. The lint set provided by the package is - # activated in the `analysis_options.yaml` file located at the root of your - # package. See that file for information about deactivating specific lint - # rules and activating additional ones. flutter_lints: ^5.0.0 -# For information on the generic Dart part of this file, see the -# following page: https://dart.dev/tools/pub/pubspec - -# The following section is specific to Flutter packages. flutter: - - # The following line ensures that the Material Icons font is - # included with your application, so that you can use the icons in - # the material Icons class. - uses-material-design: false - - # To add assets to your application, add an assets section, like this: + uses-material-design: true assets: - assets/google_fonts/ - # - images/a_dot_ham.jpeg - - # An image asset can refer to one or more resolution-specific "variants", see - # https://flutter.dev/to/resolution-aware-images - - # For details regarding adding assets from package dependencies, see - # https://flutter.dev/to/asset-from-package - - # To add custom fonts to your application, add a fonts section here, - # in this "flutter" section. Each entry in this list should have a - # "family" key with the font family name, and a "fonts" key with a - # list giving the asset and other descriptors for the font. For - # example: - # fonts: - # - family: Schyler - # fonts: - # - asset: fonts/Schyler-Regular.ttf - # - asset: fonts/Schyler-Italic.ttf - # style: italic - # - family: Trajan Pro - # fonts: - # - asset: fonts/TrajanPro.ttf - # - asset: fonts/TrajanPro_Bold.ttf - # weight: 700 - # - # For details regarding fonts from package dependencies, - # see https://flutter.dev/to/font-from-package + - assets/translations/ diff --git a/test/widget_test.dart b/test/widget_test.dart index a1afd82..0929d39 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -13,7 +13,7 @@ import 'package:shitman/main.dart'; void main() { testWidgets('Counter increments smoke test', (WidgetTester tester) async { // Build our app and trigger a frame. - await tester.pumpWidget(const MyApp()); + await tester.pumpWidget(const Shitman()); // Verify that our counter starts at 0. expect(find.text('0'), findsOneWidget);