This commit is contained in:
parent
aa823338be
commit
bc128cef3d
24 changed files with 2921 additions and 159 deletions
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