Compare commits
	
		
			2 commits
		
	
	
		
			d680a4499b
			...
			bc128cef3d
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| bc128cef3d | |||
| aa823338be | 
					 25 changed files with 2922 additions and 160 deletions
				
			
		|  | @ -8,7 +8,7 @@ services: | ||||||
|     restart: 'unless-stopped' |     restart: 'unless-stopped' | ||||||
| 
 | 
 | ||||||
|   gitea: |   gitea: | ||||||
|     image: 'data.forgejo.org/forgejo/runner:4.0.0' |     image: 'data.forgejo.org/forgejo/runner:7' | ||||||
|     links: |     links: | ||||||
|       - docker-in-docker |       - docker-in-docker | ||||||
|     depends_on: |     depends_on: | ||||||
|  |  | ||||||
							
								
								
									
										49
									
								
								assets/translations/da.json
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										49
									
								
								assets/translations/da.json
									
										
									
									
									
										Normal file
									
								
							|  | @ -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" | ||||||
|  |     } | ||||||
|  | } | ||||||
							
								
								
									
										49
									
								
								assets/translations/de.json
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										49
									
								
								assets/translations/de.json
									
										
									
									
									
										Normal file
									
								
							|  | @ -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 Zielhaustü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" | ||||||
|  |     } | ||||||
|  | } | ||||||
							
								
								
									
										49
									
								
								assets/translations/en.json
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										49
									
								
								assets/translations/en.json
									
										
									
									
									
										Normal file
									
								
							|  | @ -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" | ||||||
|  |     } | ||||||
|  | } | ||||||
							
								
								
									
										19
									
								
								lib/attributes/serializable.dart
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										19
									
								
								lib/attributes/serializable.dart
									
										
									
									
									
										Normal file
									
								
							|  | @ -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<String, dynamic> 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<String, dynamic> map); | ||||||
|  | } | ||||||
							
								
								
									
										236
									
								
								lib/game/components/neighborhood.dart
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										236
									
								
								lib/game/components/neighborhood.dart
									
										
									
									
									
										Normal file
									
								
							|  | @ -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<House> houses = []; | ||||||
|  |   late List<Vector2> streetPaths; | ||||||
|  | 
 | ||||||
|  |   @override | ||||||
|  |   Future<void> 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<void> 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; | ||||||
|  |   } | ||||||
|  | } | ||||||
							
								
								
									
										193
									
								
								lib/game/components/player.dart
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										193
									
								
								lib/game/components/player.dart
									
										
									
									
									
										Normal file
									
								
							|  | @ -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<ShitmanGame> { | ||||||
|  |   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<void> 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<LogicalKeyboardKey> 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<Neighborhood>().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, | ||||||
|  |       ); | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  | } | ||||||
							
								
								
									
										160
									
								
								lib/game/components/poop_bag.dart
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										160
									
								
								lib/game/components/poop_bag.dart
									
										
									
									
									
										Normal file
									
								
							|  | @ -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<SmokeParticle> smokeParticles = []; | ||||||
|  | 
 | ||||||
|  |   @override | ||||||
|  |   Future<void> 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<Effect>()); | ||||||
|  |      | ||||||
|  |     // 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, | ||||||
|  |     ); | ||||||
|  |   } | ||||||
|  | } | ||||||
							
								
								
									
										52
									
								
								lib/game/components/target_house.dart
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										52
									
								
								lib/game/components/target_house.dart
									
										
									
									
									
										Normal file
									
								
							|  | @ -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<void> onLoad() async { | ||||||
|  |     await super.onLoad(); | ||||||
|  |     selectNewTarget(); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   void selectNewTarget() { | ||||||
|  |     // Find the neighborhood component | ||||||
|  |     final neighborhood = parent?.children.whereType<Neighborhood>().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; | ||||||
|  |   } | ||||||
|  | } | ||||||
							
								
								
									
										144
									
								
								lib/game/shitman_game.dart
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										144
									
								
								lib/game/shitman_game.dart
									
										
									
									
									
										Normal file
									
								
							|  | @ -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<void> 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<LogicalKeyboardKey> 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 | ||||||
|  |   } | ||||||
|  | } | ||||||
							
								
								
									
										137
									
								
								lib/main.dart
									
										
									
									
									
								
							
							
						
						
									
										137
									
								
								lib/main.dart
									
										
									
									
									
								
							|  | @ -1,7 +1,23 @@ | ||||||
|  | import 'dart:ui'; | ||||||
|  | 
 | ||||||
|  | import 'package:flame/game.dart'; | ||||||
| import 'package:flutter/foundation.dart'; | import 'package:flutter/foundation.dart'; | ||||||
| import 'package:flutter/material.dart'; | import 'package:flutter/material.dart'; | ||||||
| import 'package:flutter/services.dart'; | import 'package:flutter/services.dart'; | ||||||
| import 'package:nes_ui/nes_ui.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<PointerDeviceKind> get dragDevices => { | ||||||
|  |     PointerDeviceKind.touch, | ||||||
|  |     PointerDeviceKind.mouse, | ||||||
|  |     PointerDeviceKind.trackpad, | ||||||
|  |   }; | ||||||
|  | } | ||||||
| 
 | 
 | ||||||
| void main() { | void main() { | ||||||
|   LicenseRegistry.addLicense(() async* { |   LicenseRegistry.addLicense(() async* { | ||||||
|  | @ -9,105 +25,46 @@ void main() { | ||||||
|     yield LicenseEntryWithLineBreaks(['google_fonts'], license); |     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 { | class Shitman extends StatelessWidget { | ||||||
|   const MyApp({super.key}); |   const Shitman({super.key}); | ||||||
| 
 | 
 | ||||||
|   // This widget is the root of your application. |   // This widget is the root of your application. | ||||||
|   @override |   @override | ||||||
|   Widget build(BuildContext context) { |   Widget build(BuildContext context) { | ||||||
|     return MaterialApp( |     return MaterialApp( | ||||||
|  |       localizationsDelegates: context.localizationDelegates, | ||||||
|  |       supportedLocales: context.supportedLocales, | ||||||
|  |       scrollBehavior: AnyInputScrollBehavior(), | ||||||
|  |       locale: context.locale, | ||||||
|       theme: flutterNesTheme(brightness: Brightness.dark), |       theme: flutterNesTheme(brightness: Brightness.dark), | ||||||
|       themeMode: ThemeMode.dark, |       themeMode: ThemeMode.dark, | ||||||
|       home: const MyHomePage(title: 'Flutter Demo Home Page'), |       home: GameWidget( | ||||||
|     ); |         game: ShitmanGame(), | ||||||
|   } |         initialActiveOverlays: const ['MainMenu'], | ||||||
| } |         overlayBuilderMap: { | ||||||
| 
 |           InGameUI.overlayID: (context, game) => | ||||||
| class MyHomePage extends StatefulWidget { |               InGameUI(game as ShitmanGame), | ||||||
|   const MyHomePage({super.key, required this.title}); |           MainMenuUI.overlayID: (context, game) => | ||||||
| 
 |               MainMenuUI(game as ShitmanGame), | ||||||
|   // This widget is the home page of your application. It is stateful, meaning |           SettingsUI.overlayID: (context, game) => | ||||||
|   // that it has a State object (defined below) that contains fields that affect |               SettingsUI(game as ShitmanGame), | ||||||
|   // how it looks. |           PauseMenuUI.overlayID: (context, game) => | ||||||
| 
 |               PauseMenuUI(game as ShitmanGame), | ||||||
|   // 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<MyHomePage> createState() => _MyHomePageState(); |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| class _MyHomePageState extends State<MyHomePage> { |  | ||||||
|   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: <Widget>[ |  | ||||||
|             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. |  | ||||||
|     ); |     ); | ||||||
|   } |   } | ||||||
| } | } | ||||||
|  |  | ||||||
							
								
								
									
										16
									
								
								lib/settings/app_settings.dart
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										16
									
								
								lib/settings/app_settings.dart
									
										
									
									
									
										Normal file
									
								
							|  | @ -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<void> initSettings() async { | ||||||
|  |     if (_isInitialized) return; | ||||||
|  |     _isInitialized = true; | ||||||
|  |     appSettings.register(gameSettings); | ||||||
|  |     appSettings.register(colorSettings); | ||||||
|  |     await appSettings.init(); | ||||||
|  |   } | ||||||
|  | } | ||||||
							
								
								
									
										15
									
								
								lib/settings/colors.dart
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										15
									
								
								lib/settings/colors.dart
									
										
									
									
									
										Normal file
									
								
							|  | @ -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, | ||||||
|  |     ), | ||||||
|  |   ], | ||||||
|  | ); | ||||||
							
								
								
									
										104
									
								
								lib/settings/exceptions.dart
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										104
									
								
								lib/settings/exceptions.dart
									
										
									
									
									
										Normal file
									
								
							|  | @ -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'; | ||||||
|  | } | ||||||
							
								
								
									
										11
									
								
								lib/settings/game.dart
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										11
									
								
								lib/settings/game.dart
									
										
									
									
									
										Normal file
									
								
							|  | @ -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), | ||||||
|  |   ], | ||||||
|  | ); | ||||||
							
								
								
									
										343
									
								
								lib/settings/setting.dart
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										343
									
								
								lib/settings/setting.dart
									
										
									
									
									
										Normal file
									
								
							|  | @ -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<T> implements Serializable { | ||||||
|  |   /// Internal stream controller for broadcasting value changes. | ||||||
|  |   /// Uses broadcast to allow multiple listeners. | ||||||
|  |   final StreamController<T> _controller = StreamController<T>.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<T> 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<String, dynamic> 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<String, dynamic> 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<String, dynamic>); | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | /// 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<bool> { | ||||||
|  |   /// 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<int> { | ||||||
|  |   /// 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<double> { | ||||||
|  |   /// 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<String> { | ||||||
|  |   /// 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); | ||||||
|  | } | ||||||
							
								
								
									
										45
									
								
								lib/settings/settings.dart
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										45
									
								
								lib/settings/settings.dart
									
										
									
									
									
										Normal file
									
								
							|  | @ -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'; | ||||||
							
								
								
									
										468
									
								
								lib/settings/settings_group.dart
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										468
									
								
								lib/settings/settings_group.dart
									
										
									
									
									
										Normal file
									
								
							|  | @ -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<bool>('soundEnabled'); | ||||||
|  | /// await gameSettings.setValue('volume', 0.5); | ||||||
|  | /// ``` | ||||||
|  | class SettingsGroup extends UnmodifiableMapBase<String, Setting> { | ||||||
|  |   /// 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<Setting<dynamic>> 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<String> _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<bool> _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<bool>('soundEnabled'); | ||||||
|  |   /// ``` | ||||||
|  |   Future<bool> 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<Setting> items, | ||||||
|  |     bool forceRegularSharedPreferences = false, | ||||||
|  |   }) { | ||||||
|  |     this.items = Set<Setting>.from(items); | ||||||
|  |     _keys = items.map((item) => item.key).toSet(); | ||||||
|  |     _store = SettingsStore( | ||||||
|  |       forceRegularSharedPreferences: forceRegularSharedPreferences, | ||||||
|  |     ); | ||||||
|  |     _readyCompleter = Completer<bool>(); | ||||||
|  |     // 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<Setting> items, | ||||||
|  |   }) { | ||||||
|  |     this.items = Set<Setting>.from(items); | ||||||
|  |     _keys = items.map((item) => item.key).toSet(); | ||||||
|  |     _store = SettingsStore(forceRegularSharedPreferences: true); | ||||||
|  |     _readyCompleter = Completer<bool>(); | ||||||
|  |     // 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<dynamic>] 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<dynamic>? 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<String> 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<void> _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<void> setValue<T>(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<T> && !setting.validate(value)) { | ||||||
|  |       throw SettingValidationException( | ||||||
|  |         'Invalid value for setting $storageKey: $value', | ||||||
|  |       ); | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     await _set(storageKey, setting, value); | ||||||
|  | 
 | ||||||
|  |     // Notify change listeners | ||||||
|  |     if (setting is Setting<T>) { | ||||||
|  |       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<T>(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<T>) { | ||||||
|  |       throw ArgumentError( | ||||||
|  |         'Setting $key is not of type ${T.runtimeType}, but ${setting.type}', | ||||||
|  |       ); | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     return _get<T>(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<void> _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<T>(Setting<T> 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<T>(Setting<T> 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<void> _set<T>( | ||||||
|  |     String storageKey, | ||||||
|  |     Setting<T> 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<void> _setBool(String storageKey, bool value) async { | ||||||
|  |     await _store.prefs.setBool(storageKey, value); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   /// Sets an integer value for the given storage key. | ||||||
|  |   Future<void> _setInt(String storageKey, int value) async { | ||||||
|  |     await _store.prefs.setInt(storageKey, value); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   /// Sets a double value for the given storage key. | ||||||
|  |   Future<void> _setDouble(String storageKey, double value) async { | ||||||
|  |     await _store.prefs.setDouble(storageKey, value); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   /// Sets a string value for the given storage key. | ||||||
|  |   Future<void> _setString(String storageKey, String value) async { | ||||||
|  |     await _store.prefs.setString(storageKey, value); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   /// Reset a setting to its default value. | ||||||
|  |   Future<void> 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<void> 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(); | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  | } | ||||||
							
								
								
									
										350
									
								
								lib/settings/settings_manager.dart
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										350
									
								
								lib/settings/settings_manager.dart
									
										
									
									
									
										Normal file
									
								
							|  | @ -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<String, SettingsGroup> _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<void> init() async { | ||||||
|  |     final futures = _settings.values.map((settings) => settings.readyFuture); | ||||||
|  |     await Future.wait(futures); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   /// Returns a map of all registered settings groups. | ||||||
|  |   Map<String, SettingsGroup> get groups => _settings; | ||||||
|  | 
 | ||||||
|  |   /// Returns a list of all registered settings groups keys. | ||||||
|  |   List<String> 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<dynamic>(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<T>(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<T>(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<bool>(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<int>(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<double>(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<String>(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<void> 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<void> set<T>(String storageKey, T value) async { | ||||||
|  |     final id = _parseStorageKey(storageKey); | ||||||
|  |     final group = getGroup(id.group); | ||||||
|  |     await group.setValue<T>(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<void> setBool(String storageKey, bool value) async { | ||||||
|  |     await set<bool>(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<void> setInt(String storageKey, int value) async { | ||||||
|  |     await set<int>(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<void> setDouble(String storageKey, double value) async { | ||||||
|  |     await set<double>(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<void> setString(String storageKey, String value) async { | ||||||
|  |     await set<String>(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<void> setMultiple(Map<String, dynamic> settings) async { | ||||||
|  |     final futures = <Future<void>>[]; | ||||||
|  |     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<void> 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<void> resetGroup(String groupKey) async { | ||||||
|  |     final group = getGroup(groupKey); | ||||||
|  |     await group.resetAll(); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   /// Reset all settings across all groups to their default values. | ||||||
|  |   Future<void> 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(', ')}}'; | ||||||
|  |   } | ||||||
|  | } | ||||||
							
								
								
									
										143
									
								
								lib/settings/settings_store.dart
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										143
									
								
								lib/settings/settings_store.dart
									
										
									
									
									
										Normal file
									
								
							|  | @ -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<bool> 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<bool>(); | ||||||
|  |     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; | ||||||
|  | } | ||||||
							
								
								
									
										314
									
								
								lib/ui/in_game_ui.dart
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										314
									
								
								lib/ui/in_game_ui.dart
									
										
									
									
									
										Normal file
									
								
							|  | @ -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<String>( | ||||||
|  |                       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()), | ||||||
|  |                 ), | ||||||
|  |               ], | ||||||
|  |             ), | ||||||
|  |           ), | ||||||
|  |         ), | ||||||
|  |       ), | ||||||
|  |     ); | ||||||
|  |   } | ||||||
|  | } | ||||||
|  | @ -6,7 +6,9 @@ import FlutterMacOS | ||||||
| import Foundation | import Foundation | ||||||
| 
 | 
 | ||||||
| import path_provider_foundation | import path_provider_foundation | ||||||
|  | import shared_preferences_foundation | ||||||
| 
 | 
 | ||||||
| func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { | func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { | ||||||
|   PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) |   PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) | ||||||
|  |   SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) | ||||||
| } | } | ||||||
|  |  | ||||||
							
								
								
									
										106
									
								
								pubspec.lock
									
										
									
									
									
								
							
							
						
						
									
										106
									
								
								pubspec.lock
									
										
									
									
									
								
							|  | @ -1,6 +1,14 @@ | ||||||
| # Generated by pub | # Generated by pub | ||||||
| # See https://dart.dev/tools/pub/glossary#lockfile | # See https://dart.dev/tools/pub/glossary#lockfile | ||||||
| packages: | packages: | ||||||
|  |   args: | ||||||
|  |     dependency: transitive | ||||||
|  |     description: | ||||||
|  |       name: args | ||||||
|  |       sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04 | ||||||
|  |       url: "https://pub.dev" | ||||||
|  |     source: hosted | ||||||
|  |     version: "2.7.0" | ||||||
|   async: |   async: | ||||||
|     dependency: transitive |     dependency: transitive | ||||||
|     description: |     description: | ||||||
|  | @ -57,6 +65,22 @@ packages: | ||||||
|       url: "https://pub.dev" |       url: "https://pub.dev" | ||||||
|     source: hosted |     source: hosted | ||||||
|     version: "1.0.8" |     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: |   equatable: | ||||||
|     dependency: transitive |     dependency: transitive | ||||||
|     description: |     description: | ||||||
|  | @ -81,6 +105,14 @@ packages: | ||||||
|       url: "https://pub.dev" |       url: "https://pub.dev" | ||||||
|     source: hosted |     source: hosted | ||||||
|     version: "2.1.4" |     version: "2.1.4" | ||||||
|  |   file: | ||||||
|  |     dependency: transitive | ||||||
|  |     description: | ||||||
|  |       name: file | ||||||
|  |       sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 | ||||||
|  |       url: "https://pub.dev" | ||||||
|  |     source: hosted | ||||||
|  |     version: "7.0.1" | ||||||
|   flame: |   flame: | ||||||
|     dependency: "direct main" |     dependency: "direct main" | ||||||
|     description: |     description: | ||||||
|  | @ -102,6 +134,11 @@ packages: | ||||||
|       url: "https://pub.dev" |       url: "https://pub.dev" | ||||||
|     source: hosted |     source: hosted | ||||||
|     version: "5.0.0" |     version: "5.0.0" | ||||||
|  |   flutter_localizations: | ||||||
|  |     dependency: transitive | ||||||
|  |     description: flutter | ||||||
|  |     source: sdk | ||||||
|  |     version: "0.0.0" | ||||||
|   flutter_mini_sprite: |   flutter_mini_sprite: | ||||||
|     dependency: transitive |     dependency: transitive | ||||||
|     description: |     description: | ||||||
|  | @ -115,6 +152,11 @@ packages: | ||||||
|     description: flutter |     description: flutter | ||||||
|     source: sdk |     source: sdk | ||||||
|     version: "0.0.0" |     version: "0.0.0" | ||||||
|  |   flutter_web_plugins: | ||||||
|  |     dependency: transitive | ||||||
|  |     description: flutter | ||||||
|  |     source: sdk | ||||||
|  |     version: "0.0.0" | ||||||
|   google_fonts: |   google_fonts: | ||||||
|     dependency: "direct main" |     dependency: "direct main" | ||||||
|     description: |     description: | ||||||
|  | @ -139,6 +181,14 @@ packages: | ||||||
|       url: "https://pub.dev" |       url: "https://pub.dev" | ||||||
|     source: hosted |     source: hosted | ||||||
|     version: "4.1.2" |     version: "4.1.2" | ||||||
|  |   intl: | ||||||
|  |     dependency: transitive | ||||||
|  |     description: | ||||||
|  |       name: intl | ||||||
|  |       sha256: "3df61194eb431efc39c4ceba583b95633a403f46c9fd341e550ce0bfa50e9aa5" | ||||||
|  |       url: "https://pub.dev" | ||||||
|  |     source: hosted | ||||||
|  |     version: "0.20.2" | ||||||
|   leak_tracker: |   leak_tracker: | ||||||
|     dependency: transitive |     dependency: transitive | ||||||
|     description: |     description: | ||||||
|  | @ -299,6 +349,62 @@ packages: | ||||||
|       url: "https://pub.dev" |       url: "https://pub.dev" | ||||||
|     source: hosted |     source: hosted | ||||||
|     version: "2.1.8" |     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: |   sky_engine: | ||||||
|     dependency: transitive |     dependency: transitive | ||||||
|     description: flutter |     description: flutter | ||||||
|  |  | ||||||
							
								
								
									
										73
									
								
								pubspec.yaml
									
										
									
									
									
								
							
							
						
						
									
										73
									
								
								pubspec.yaml
									
										
									
									
									
								
							|  | @ -1,92 +1,29 @@ | ||||||
| name: shitman | name: shitman | ||||||
| description: "Hitman, but with shit." | description: "Hitman, but with shit." | ||||||
| # The following line prevents the package from being accidentally published to | publish_to: 'none' | ||||||
| # 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 |  | ||||||
| 
 | 
 | ||||||
| # 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 | version: 1.0.0+1 | ||||||
| 
 | 
 | ||||||
| environment: | environment: | ||||||
|   sdk: ^3.7.0 |   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: | dependencies: | ||||||
|   flutter: |   flutter: | ||||||
|     sdk: 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 |   cupertino_icons: ^1.0.8 | ||||||
|   flame: ^1.30.1 |   flame: ^1.30.1 | ||||||
|   nes_ui: ^0.25.0 |   nes_ui: ^0.25.0 | ||||||
|   google_fonts: ^6.2.1 |   google_fonts: ^6.2.1 | ||||||
|  |   easy_localization: ^3.0.7+1 | ||||||
|  |   shared_preferences: ^2.2.2 | ||||||
| 
 | 
 | ||||||
| dev_dependencies: | dev_dependencies: | ||||||
|   flutter_test: |   flutter_test: | ||||||
|     sdk: flutter |     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 |   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: | flutter: | ||||||
| 
 |   uses-material-design: true | ||||||
|   # 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: |  | ||||||
|   assets: |   assets: | ||||||
|     - assets/google_fonts/ |     - assets/google_fonts/ | ||||||
|   #   - images/a_dot_ham.jpeg |     - assets/translations/ | ||||||
| 
 |  | ||||||
|   # 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 |  | ||||||
|  |  | ||||||
|  | @ -13,7 +13,7 @@ import 'package:shitman/main.dart'; | ||||||
| void main() { | void main() { | ||||||
|   testWidgets('Counter increments smoke test', (WidgetTester tester) async { |   testWidgets('Counter increments smoke test', (WidgetTester tester) async { | ||||||
|     // Build our app and trigger a frame. |     // Build our app and trigger a frame. | ||||||
|     await tester.pumpWidget(const MyApp()); |     await tester.pumpWidget(const Shitman()); | ||||||
| 
 | 
 | ||||||
|     // Verify that our counter starts at 0. |     // Verify that our counter starts at 0. | ||||||
|     expect(find.text('0'), findsOneWidget); |     expect(find.text('0'), findsOneWidget); | ||||||
|  |  | ||||||
		Loading…
	
	Add table
		
		Reference in a new issue