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/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:nes_ui/nes_ui.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:shitman/game/shitman_game.dart';
|
||||
import 'package:shitman/ui/in_game_ui.dart';
|
||||
|
||||
class AnyInputScrollBehavior extends MaterialScrollBehavior {
|
||||
// Override behavior methods and getters like dragDevices
|
||||
@override
|
||||
Set<PointerDeviceKind> get dragDevices => {
|
||||
PointerDeviceKind.touch,
|
||||
PointerDeviceKind.mouse,
|
||||
PointerDeviceKind.trackpad,
|
||||
};
|
||||
}
|
||||
|
||||
void main() {
|
||||
LicenseRegistry.addLicense(() async* {
|
||||
|
@ -9,105 +25,46 @@ void main() {
|
|||
yield LicenseEntryWithLineBreaks(['google_fonts'], license);
|
||||
});
|
||||
|
||||
runApp(const MyApp());
|
||||
runApp(
|
||||
EasyLocalization(
|
||||
supportedLocales: [Locale('en'), Locale('da'), Locale('de')],
|
||||
path: 'assets/translations',
|
||||
fallbackLocale: Locale('en'),
|
||||
startLocale: Locale('en'),
|
||||
useOnlyLangCode: true,
|
||||
useFallbackTranslations: true,
|
||||
child: Shitman(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
class MyApp extends StatelessWidget {
|
||||
const MyApp({super.key});
|
||||
class Shitman extends StatelessWidget {
|
||||
const Shitman({super.key});
|
||||
|
||||
// This widget is the root of your application.
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MaterialApp(
|
||||
localizationsDelegates: context.localizationDelegates,
|
||||
supportedLocales: context.supportedLocales,
|
||||
scrollBehavior: AnyInputScrollBehavior(),
|
||||
locale: context.locale,
|
||||
theme: flutterNesTheme(brightness: Brightness.dark),
|
||||
themeMode: ThemeMode.dark,
|
||||
home: const MyHomePage(title: 'Flutter Demo Home Page'),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class MyHomePage extends StatefulWidget {
|
||||
const MyHomePage({super.key, required this.title});
|
||||
|
||||
// This widget is the home page of your application. It is stateful, meaning
|
||||
// that it has a State object (defined below) that contains fields that affect
|
||||
// how it looks.
|
||||
|
||||
// This class is the configuration for the state. It holds the values (in this
|
||||
// case the title) provided by the parent (in this case the App widget) and
|
||||
// used by the build method of the State. Fields in a Widget subclass are
|
||||
// always marked "final".
|
||||
|
||||
final String title;
|
||||
|
||||
@override
|
||||
State<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.
|
||||
home: GameWidget(
|
||||
game: ShitmanGame(),
|
||||
initialActiveOverlays: const ['MainMenu'],
|
||||
overlayBuilderMap: {
|
||||
InGameUI.overlayID: (context, game) =>
|
||||
InGameUI(game as ShitmanGame),
|
||||
MainMenuUI.overlayID: (context, game) =>
|
||||
MainMenuUI(game as ShitmanGame),
|
||||
SettingsUI.overlayID: (context, game) =>
|
||||
SettingsUI(game as ShitmanGame),
|
||||
PauseMenuUI.overlayID: (context, game) =>
|
||||
PauseMenuUI(game as ShitmanGame),
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
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 path_provider_foundation
|
||||
import shared_preferences_foundation
|
||||
|
||||
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
||||
PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin"))
|
||||
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
|
||||
}
|
||||
|
|
106
pubspec.lock
106
pubspec.lock
|
@ -1,6 +1,14 @@
|
|||
# Generated by pub
|
||||
# See https://dart.dev/tools/pub/glossary#lockfile
|
||||
packages:
|
||||
args:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: args
|
||||
sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.7.0"
|
||||
async:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
@ -57,6 +65,22 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.8"
|
||||
easy_localization:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: easy_localization
|
||||
sha256: "0f5239c7b8ab06c66440cfb0e9aa4b4640429c6668d5a42fe389c5de42220b12"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.7+1"
|
||||
easy_logger:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: easy_logger
|
||||
sha256: c764a6e024846f33405a2342caf91c62e357c24b02c04dbc712ef232bf30ffb7
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.0.2"
|
||||
equatable:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
@ -81,6 +105,14 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.4"
|
||||
file:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: file
|
||||
sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "7.0.1"
|
||||
flame:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
|
@ -102,6 +134,11 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "5.0.0"
|
||||
flutter_localizations:
|
||||
dependency: transitive
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
flutter_mini_sprite:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
@ -115,6 +152,11 @@ packages:
|
|||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
flutter_web_plugins:
|
||||
dependency: transitive
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
google_fonts:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
|
@ -139,6 +181,14 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.1.2"
|
||||
intl:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: intl
|
||||
sha256: "3df61194eb431efc39c4ceba583b95633a403f46c9fd341e550ce0bfa50e9aa5"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.20.2"
|
||||
leak_tracker:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
@ -299,6 +349,62 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.8"
|
||||
shared_preferences:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: shared_preferences
|
||||
sha256: "6e8bf70b7fef813df4e9a36f658ac46d107db4b4cfe1048b477d4e453a8159f5"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.5.3"
|
||||
shared_preferences_android:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: shared_preferences_android
|
||||
sha256: "20cbd561f743a342c76c151d6ddb93a9ce6005751e7aa458baad3858bfbfb6ac"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.4.10"
|
||||
shared_preferences_foundation:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: shared_preferences_foundation
|
||||
sha256: "6a52cfcdaeac77cad8c97b539ff688ccfc458c007b4db12be584fbe5c0e49e03"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.5.4"
|
||||
shared_preferences_linux:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: shared_preferences_linux
|
||||
sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.4.1"
|
||||
shared_preferences_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: shared_preferences_platform_interface
|
||||
sha256: "57cbf196c486bc2cf1f02b85784932c6094376284b3ad5779d1b1c6c6a816b80"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.4.1"
|
||||
shared_preferences_web:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: shared_preferences_web
|
||||
sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.4.3"
|
||||
shared_preferences_windows:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: shared_preferences_windows
|
||||
sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.4.1"
|
||||
sky_engine:
|
||||
dependency: transitive
|
||||
description: flutter
|
||||
|
|
73
pubspec.yaml
73
pubspec.yaml
|
@ -1,92 +1,29 @@
|
|||
name: shitman
|
||||
description: "Hitman, but with shit."
|
||||
# The following line prevents the package from being accidentally published to
|
||||
# pub.dev using `flutter pub publish`. This is preferred for private packages.
|
||||
publish_to: 'none' # Remove this line if you wish to publish to pub.dev
|
||||
publish_to: 'none'
|
||||
|
||||
# The following defines the version and build number for your application.
|
||||
# A version number is three numbers separated by dots, like 1.2.43
|
||||
# followed by an optional build number separated by a +.
|
||||
# Both the version and the builder number may be overridden in flutter
|
||||
# build by specifying --build-name and --build-number, respectively.
|
||||
# In Android, build-name is used as versionName while build-number used as versionCode.
|
||||
# Read more about Android versioning at https://developer.android.com/studio/publish/versioning
|
||||
# In iOS, build-name is used as CFBundleShortVersionString while build-number is used as CFBundleVersion.
|
||||
# Read more about iOS versioning at
|
||||
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
|
||||
# In Windows, build-name is used as the major, minor, and patch parts
|
||||
# of the product and file versions while build-number is used as the build suffix.
|
||||
version: 1.0.0+1
|
||||
|
||||
environment:
|
||||
sdk: ^3.7.0
|
||||
|
||||
# Dependencies specify other packages that your package needs in order to work.
|
||||
# To automatically upgrade your package dependencies to the latest versions
|
||||
# consider running `flutter pub upgrade --major-versions`. Alternatively,
|
||||
# dependencies can be manually updated by changing the version numbers below to
|
||||
# the latest version available on pub.dev. To see which dependencies have newer
|
||||
# versions available, run `flutter pub outdated`.
|
||||
dependencies:
|
||||
flutter:
|
||||
sdk: flutter
|
||||
|
||||
# The following adds the Cupertino Icons font to your application.
|
||||
# Use with the CupertinoIcons class for iOS style icons.
|
||||
cupertino_icons: ^1.0.8
|
||||
flame: ^1.30.1
|
||||
nes_ui: ^0.25.0
|
||||
google_fonts: ^6.2.1
|
||||
easy_localization: ^3.0.7+1
|
||||
shared_preferences: ^2.2.2
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
sdk: flutter
|
||||
|
||||
# The "flutter_lints" package below contains a set of recommended lints to
|
||||
# encourage good coding practices. The lint set provided by the package is
|
||||
# activated in the `analysis_options.yaml` file located at the root of your
|
||||
# package. See that file for information about deactivating specific lint
|
||||
# rules and activating additional ones.
|
||||
flutter_lints: ^5.0.0
|
||||
|
||||
# For information on the generic Dart part of this file, see the
|
||||
# following page: https://dart.dev/tools/pub/pubspec
|
||||
|
||||
# The following section is specific to Flutter packages.
|
||||
flutter:
|
||||
|
||||
# The following line ensures that the Material Icons font is
|
||||
# included with your application, so that you can use the icons in
|
||||
# the material Icons class.
|
||||
uses-material-design: false
|
||||
|
||||
# To add assets to your application, add an assets section, like this:
|
||||
uses-material-design: true
|
||||
assets:
|
||||
- assets/google_fonts/
|
||||
# - images/a_dot_ham.jpeg
|
||||
|
||||
# An image asset can refer to one or more resolution-specific "variants", see
|
||||
# https://flutter.dev/to/resolution-aware-images
|
||||
|
||||
# For details regarding adding assets from package dependencies, see
|
||||
# https://flutter.dev/to/asset-from-package
|
||||
|
||||
# To add custom fonts to your application, add a fonts section here,
|
||||
# in this "flutter" section. Each entry in this list should have a
|
||||
# "family" key with the font family name, and a "fonts" key with a
|
||||
# list giving the asset and other descriptors for the font. For
|
||||
# example:
|
||||
# fonts:
|
||||
# - family: Schyler
|
||||
# fonts:
|
||||
# - asset: fonts/Schyler-Regular.ttf
|
||||
# - asset: fonts/Schyler-Italic.ttf
|
||||
# style: italic
|
||||
# - family: Trajan Pro
|
||||
# fonts:
|
||||
# - asset: fonts/TrajanPro.ttf
|
||||
# - asset: fonts/TrajanPro_Bold.ttf
|
||||
# weight: 700
|
||||
#
|
||||
# For details regarding fonts from package dependencies,
|
||||
# see https://flutter.dev/to/font-from-package
|
||||
- assets/translations/
|
||||
|
|
|
@ -13,7 +13,7 @@ import 'package:shitman/main.dart';
|
|||
void main() {
|
||||
testWidgets('Counter increments smoke test', (WidgetTester tester) async {
|
||||
// Build our app and trigger a frame.
|
||||
await tester.pumpWidget(const MyApp());
|
||||
await tester.pumpWidget(const Shitman());
|
||||
|
||||
// Verify that our counter starts at 0.
|
||||
expect(find.text('0'), findsOneWidget);
|
||||
|
|
Loading…
Add table
Reference in a new issue