This commit is contained in:
parent
67aaa9589f
commit
f7a08a5099
16 changed files with 788 additions and 112 deletions
|
@ -1,8 +1,8 @@
|
|||
{
|
||||
"game": {
|
||||
"title": "SHITMAN",
|
||||
"subtitle": "Lorteprank Mesteren",
|
||||
"description": "Snig rundt og efterlad brændende lorteposer\nudea at blive opdaget!"
|
||||
"subtitle": "Hitman, but shit",
|
||||
"description": "Snig rundt og efterlad brændende lorteposer\ndiskret og stinkende"
|
||||
},
|
||||
"menu": {
|
||||
"start_mission": "START MISSION",
|
||||
|
@ -40,7 +40,15 @@
|
|||
"show_vision_cones": "Vis Synsfelt",
|
||||
"show_detection_radius": "Vis Detektionsområder",
|
||||
"vision_cones_help": "Viser synsfelt for fjender og spiller",
|
||||
"detection_help": "Viser detektionsområder omkring sikkerhedsfunktioner"
|
||||
"detection_help": "Viser detektionsområder omkring sikkerhedsfunktioner",
|
||||
"touch_controls": "Berøringskontroller",
|
||||
"touch_controls_help": "Aktivér virtuel joystick og knapper på skærmen",
|
||||
"language_title": "Sprog",
|
||||
"language": {
|
||||
"english": "Engelsk",
|
||||
"danish": "Dansk",
|
||||
"german": "Tysk"
|
||||
}
|
||||
},
|
||||
"messages": {
|
||||
"placing_poop": "Placerer lortepose på position",
|
||||
|
|
|
@ -1,8 +1,8 @@
|
|||
{
|
||||
"game": {
|
||||
"title": "SHITMAN",
|
||||
"subtitle": "Der Kackhaufen-Streich-Meister",
|
||||
"description": "Schleich herum und hinterlasse brennende Kacktüten\nohne erwischt zu werden!"
|
||||
"subtitle": "Hitman, but shit",
|
||||
"description": "Schleich herum und hinterlasse brennende Kacktüten\nheimlich und stinkend"
|
||||
},
|
||||
"menu": {
|
||||
"start_mission": "MISSION STARTEN",
|
||||
|
@ -40,7 +40,15 @@
|
|||
"show_vision_cones": "Sichtfelder Anzeigen",
|
||||
"show_detection_radius": "Erkennungsbereiche Anzeigen",
|
||||
"vision_cones_help": "Zeigt Sichtfelder für Feinde und Spieler",
|
||||
"detection_help": "Zeigt Erkennungsbereiche um Sicherheitsmerkmale"
|
||||
"detection_help": "Zeigt Erkennungsbereiche um Sicherheitsmerkmale",
|
||||
"touch_controls": "Touch-Steuerung",
|
||||
"touch_controls_help": "Aktiviere virtuellen Joystick und Tasten auf dem Bildschirm",
|
||||
"language_title": "Sprache",
|
||||
"language": {
|
||||
"english": "Englisch",
|
||||
"danish": "Dänisch",
|
||||
"german": "Deutsch"
|
||||
}
|
||||
},
|
||||
"messages": {
|
||||
"placing_poop": "Kacktüte wird platziert an Position",
|
||||
|
|
|
@ -1,8 +1,8 @@
|
|||
{
|
||||
"game": {
|
||||
"title": "SHITMAN",
|
||||
"subtitle": "The Poop Prank Master",
|
||||
"description": "Sneak around and leave flaming poop bags\nwithout getting caught!"
|
||||
"subtitle": "Hitman, but shit",
|
||||
"description": "Sneak around and leave flaming poop bags\nstealthy and stinky."
|
||||
},
|
||||
"menu": {
|
||||
"start_mission": "START MISSION",
|
||||
|
@ -40,7 +40,15 @@
|
|||
"show_vision_cones": "Show Vision Cones",
|
||||
"show_detection_radius": "Show Detection Areas",
|
||||
"vision_cones_help": "Shows field of view for enemies and player",
|
||||
"detection_help": "Shows detection areas around security features"
|
||||
"detection_help": "Shows detection areas around security features",
|
||||
"touch_controls": "Touch Controls",
|
||||
"touch_controls_help": "Enable on-screen virtual joystick and buttons",
|
||||
"language_title": "Language",
|
||||
"language": {
|
||||
"english": "English",
|
||||
"danish": "Dansk",
|
||||
"german": "Deutsch"
|
||||
}
|
||||
},
|
||||
"messages": {
|
||||
"placing_poop": "Placing poop bag at position",
|
||||
|
|
|
@ -22,6 +22,12 @@
|
|||
<string>????</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>$(FLUTTER_BUILD_NUMBER)</string>
|
||||
<key>CFBundleLocalizations</key>
|
||||
<array>
|
||||
<string>en</string>
|
||||
<string>da</string>
|
||||
<string>de</string>
|
||||
</array>
|
||||
<key>LSRequiresIPhoneOS</key>
|
||||
<true/>
|
||||
<key>UILaunchStoryboardName</key>
|
||||
|
|
|
@ -1,12 +1,12 @@
|
|||
import 'package:flame/components.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:shitman/game/shitman_game.dart';
|
||||
import 'package:shitman/game/components/poop_bag.dart';
|
||||
import 'package:shitman/game/components/house_components.dart';
|
||||
import 'package:shitman/game/components/vision_cone.dart';
|
||||
import 'package:shitman/game/components/base.dart';
|
||||
import 'package:shitman/game/levels/operation_shitstorm.dart';
|
||||
import 'package:shitman/game/input_manager.dart';
|
||||
import 'package:shitman/settings/app_settings.dart';
|
||||
import 'dart:math';
|
||||
|
||||
|
@ -28,6 +28,9 @@ class Player extends InteractiveShit with Ambulatory, AppSettings {
|
|||
double escapeTimeLimit = 30.0; // 30 seconds to escape
|
||||
double escapeTimeRemaining = 0.0;
|
||||
|
||||
// Input manager for handling both keyboard and touch input
|
||||
final InputManager inputManager = InputManager();
|
||||
|
||||
@override
|
||||
Future<void> onLoad() async {
|
||||
await super.onLoad();
|
||||
|
@ -55,28 +58,16 @@ class Player extends InteractiveShit with Ambulatory, AppSettings {
|
|||
// Don't handle input if caught
|
||||
if (isCaught) {
|
||||
velocity = Vector2.zero();
|
||||
inputManager.reset();
|
||||
return;
|
||||
}
|
||||
|
||||
velocity = Vector2.zero();
|
||||
// Update input manager with keyboard input
|
||||
inputManager.setKeyboardMovement(keysPressed);
|
||||
|
||||
// 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;
|
||||
}
|
||||
// Apply movement from input manager
|
||||
final movement = inputManager.movementInput;
|
||||
velocity = movement * speed;
|
||||
}
|
||||
|
||||
void handleAction(LogicalKeyboardKey key) {
|
||||
|
@ -92,6 +83,27 @@ class Player extends InteractiveShit with Ambulatory, AppSettings {
|
|||
}
|
||||
}
|
||||
|
||||
/// Handle touch input from virtual controls
|
||||
void handleTouchMovement(Vector2 input) {
|
||||
if (isCaught) return;
|
||||
inputManager.setTouchMovement(input);
|
||||
|
||||
// Apply movement from input manager
|
||||
final movement = inputManager.movementInput;
|
||||
velocity = movement * speed;
|
||||
}
|
||||
|
||||
/// Handle touch actions
|
||||
void handleTouchPoopBag() {
|
||||
if (isCaught) return;
|
||||
placePoop();
|
||||
}
|
||||
|
||||
void handleTouchDoorbell() {
|
||||
if (isCaught) return;
|
||||
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
|
||||
|
@ -125,7 +137,8 @@ class Player extends InteractiveShit with Ambulatory, AppSettings {
|
|||
appLog.fine('Attempting to ring doorbell');
|
||||
|
||||
// Check if near any target house door
|
||||
final currentLevel = game.world.children.whereType<OperationShitstorm>().firstOrNull;
|
||||
final currentLevel =
|
||||
game.world.children.whereType<OperationShitstorm>().firstOrNull;
|
||||
if (currentLevel != null && currentLevel.isPlayerNearTarget(position)) {
|
||||
if (placedPoopBag != null) {
|
||||
// Light the poop bag on fire
|
||||
|
@ -158,13 +171,15 @@ class Player extends InteractiveShit with Ambulatory, AppSettings {
|
|||
// Check if near any edge of the level
|
||||
return position.x < escapeZoneThreshold ||
|
||||
position.y < escapeZoneThreshold ||
|
||||
position.x > (5 * 100) - escapeZoneThreshold || // 5x5 grid * 100 cell size
|
||||
position.x >
|
||||
(5 * 100) - escapeZoneThreshold || // 5x5 grid * 100 cell size
|
||||
position.y > (5 * 100) - escapeZoneThreshold;
|
||||
}
|
||||
|
||||
void checkMissionProgress() {
|
||||
// Check if near target house and has placed poop bag
|
||||
final currentLevel = game.world.children.whereType<OperationShitstorm>().firstOrNull;
|
||||
final currentLevel =
|
||||
game.world.children.whereType<OperationShitstorm>().firstOrNull;
|
||||
final targetPos = currentLevel?.getTargetPosition();
|
||||
if (targetPos != null && placedPoopBag != null) {
|
||||
final distance = (position - targetPos).length;
|
||||
|
@ -242,7 +257,8 @@ class Player extends InteractiveShit with Ambulatory, AppSettings {
|
|||
|
||||
void checkForDetection() {
|
||||
// Check for detection from houses in any active level
|
||||
final houses = game.world.children
|
||||
final houses =
|
||||
game.world.children
|
||||
.expand((component) => component.children)
|
||||
.whereType<HouseComponent>();
|
||||
|
||||
|
@ -257,10 +273,16 @@ class Player extends InteractiveShit with Ambulatory, AppSettings {
|
|||
@override
|
||||
void render(Canvas canvas) {
|
||||
// Draw player as rectangle with color based on stealth level
|
||||
final playerPaint = Paint()
|
||||
..color = isHidden
|
||||
? const Color(0xFF00FF00).withValues(alpha: 0.7) // Green when hidden
|
||||
: const Color(0xFF0000FF).withValues(alpha: 0.9); // Blue when visible
|
||||
final playerPaint =
|
||||
Paint()
|
||||
..color =
|
||||
isHidden
|
||||
? const Color(0xFF00FF00).withValues(
|
||||
alpha: 0.7,
|
||||
) // Green when hidden
|
||||
: const Color(
|
||||
0xFF0000FF,
|
||||
).withValues(alpha: 0.9); // Blue when visible
|
||||
|
||||
canvas.drawRect(size.toRect(), playerPaint);
|
||||
|
||||
|
@ -268,8 +290,10 @@ class Player extends InteractiveShit with Ambulatory, AppSettings {
|
|||
try {
|
||||
final debugMode = appSettings.getBool('game.debug_mode');
|
||||
if (debugMode) {
|
||||
final stealthPaint = Paint()
|
||||
..color = Color.lerp(
|
||||
final stealthPaint =
|
||||
Paint()
|
||||
..color =
|
||||
Color.lerp(
|
||||
const Color(0xFFFF0000),
|
||||
const Color(0xFF00FF00),
|
||||
stealthLevel,
|
||||
|
|
87
lib/game/input_manager.dart
Normal file
87
lib/game/input_manager.dart
Normal file
|
@ -0,0 +1,87 @@
|
|||
import 'package:flame/components.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
/// Manages input from both keyboard and touch controls
|
||||
class InputManager {
|
||||
// Movement state
|
||||
Vector2 _movementInput = Vector2.zero();
|
||||
|
||||
// Action states
|
||||
bool _poopBagPressed = false;
|
||||
bool _doorbellPressed = false;
|
||||
|
||||
/// Set movement input from touch controls (-1 to 1 range)
|
||||
void setTouchMovement(Vector2 input) {
|
||||
_movementInput = input;
|
||||
}
|
||||
|
||||
/// Set movement input from keyboard
|
||||
void setKeyboardMovement(Set<LogicalKeyboardKey> keysPressed) {
|
||||
Vector2 keyboardInput = Vector2.zero();
|
||||
|
||||
if (keysPressed.contains(LogicalKeyboardKey.arrowUp) ||
|
||||
keysPressed.contains(LogicalKeyboardKey.keyW)) {
|
||||
keyboardInput.y -= 1;
|
||||
}
|
||||
if (keysPressed.contains(LogicalKeyboardKey.arrowDown) ||
|
||||
keysPressed.contains(LogicalKeyboardKey.keyS)) {
|
||||
keyboardInput.y += 1;
|
||||
}
|
||||
if (keysPressed.contains(LogicalKeyboardKey.arrowLeft) ||
|
||||
keysPressed.contains(LogicalKeyboardKey.keyA)) {
|
||||
keyboardInput.x -= 1;
|
||||
}
|
||||
if (keysPressed.contains(LogicalKeyboardKey.arrowRight) ||
|
||||
keysPressed.contains(LogicalKeyboardKey.keyD)) {
|
||||
keyboardInput.x += 1;
|
||||
}
|
||||
|
||||
_movementInput = keyboardInput;
|
||||
}
|
||||
|
||||
/// Trigger poop bag action from touch
|
||||
void setTouchPoopBag(bool pressed) {
|
||||
if (pressed && !_poopBagPressed) {
|
||||
_poopBagPressed = true;
|
||||
} else if (!pressed) {
|
||||
_poopBagPressed = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Trigger doorbell action from touch
|
||||
void setTouchDoorbell(bool pressed) {
|
||||
if (pressed && !_doorbellPressed) {
|
||||
_doorbellPressed = true;
|
||||
} else if (!pressed) {
|
||||
_doorbellPressed = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if poop bag action was triggered this frame
|
||||
bool consumePoopBagAction() {
|
||||
if (_poopBagPressed) {
|
||||
_poopBagPressed = false;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// Check if doorbell action was triggered this frame
|
||||
bool consumeDoorbellAction() {
|
||||
if (_doorbellPressed) {
|
||||
_doorbellPressed = false;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// Get current movement input
|
||||
Vector2 get movementInput => _movementInput;
|
||||
|
||||
/// Reset all input states
|
||||
void reset() {
|
||||
_movementInput = Vector2.zero();
|
||||
_poopBagPressed = false;
|
||||
_doorbellPressed = false;
|
||||
}
|
||||
}
|
|
@ -6,6 +6,7 @@ import 'package:flutter/widgets.dart';
|
|||
import 'package:shitman/game/components/player.dart';
|
||||
import 'package:shitman/game/levels/operation_shitstorm.dart';
|
||||
import 'package:shitman/game/shitman_world.dart';
|
||||
import 'package:shitman/ui/touch_controls.dart';
|
||||
import 'package:shitman/settings/app_settings.dart';
|
||||
|
||||
/// Shitman Game
|
||||
|
@ -49,9 +50,33 @@ class ShitmanGame extends FlameGame
|
|||
}
|
||||
}
|
||||
|
||||
void _initializeTouchControls() {
|
||||
try {
|
||||
final touchControlsEnabled = appSettings.getBool('ui.touch_enabled');
|
||||
if (touchControlsEnabled) {
|
||||
overlays.add('TouchControls');
|
||||
}
|
||||
} catch (e) {
|
||||
// If setting doesn't exist, auto-detect based on platform
|
||||
if (TouchDetection.isTouchDevice) {
|
||||
// Enable touch controls by default on touch devices
|
||||
try {
|
||||
appSettings.setBool('ui.touch_enabled', true);
|
||||
overlays.add('TouchControls');
|
||||
} catch (e) {
|
||||
// Settings not ready, just add overlay
|
||||
overlays.add('TouchControls');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> startGame() async {
|
||||
gameState = GameState.playing;
|
||||
await initializeLevel();
|
||||
|
||||
// Initialize touch controls when game starts
|
||||
_initializeTouchControls();
|
||||
}
|
||||
|
||||
Future<void> startInfiniteMode() async {
|
||||
|
@ -65,14 +90,19 @@ class ShitmanGame extends FlameGame
|
|||
gameState = GameState.mainMenu;
|
||||
world.removeAll(world.children);
|
||||
missionScore = 0;
|
||||
|
||||
// Remove touch controls when stopping game
|
||||
overlays.remove('TouchControls');
|
||||
}
|
||||
|
||||
void pauseGame() {
|
||||
gameState = GameState.paused;
|
||||
// Keep touch controls visible during pause for consistency
|
||||
}
|
||||
|
||||
void resumeGame() {
|
||||
gameState = GameState.playing;
|
||||
// Touch controls remain visible
|
||||
}
|
||||
|
||||
Future<void> initializeLevel() async {
|
||||
|
@ -147,6 +177,42 @@ class ShitmanGame extends FlameGame
|
|||
return KeyEventResult.handled;
|
||||
}
|
||||
|
||||
/// Handle touch movement from virtual joystick
|
||||
void handleTouchMovement(Vector2 input) {
|
||||
if (gameState == GameState.playing) {
|
||||
player.handleTouchMovement(input);
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle poop bag action from touch
|
||||
void handleTouchPoopBag() {
|
||||
if (gameState == GameState.playing) {
|
||||
player.handleTouchPoopBag();
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle doorbell action from touch
|
||||
void handleTouchDoorbell() {
|
||||
if (gameState == GameState.playing) {
|
||||
player.handleTouchDoorbell();
|
||||
}
|
||||
}
|
||||
|
||||
/// Toggle touch controls on/off
|
||||
void toggleTouchControls(bool enabled) {
|
||||
try {
|
||||
appSettings.setBool('ui.touch_enabled', enabled);
|
||||
if (enabled && (gameState == GameState.playing || gameState == GameState.paused || gameState == GameState.missionComplete || gameState == GameState.gameOver)) {
|
||||
// Only show touch controls during actual gameplay states
|
||||
overlays.add('TouchControls');
|
||||
} else {
|
||||
overlays.remove('TouchControls');
|
||||
}
|
||||
} catch (e) {
|
||||
// Handle settings error
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void update(double dt) {
|
||||
super.update(dt);
|
||||
|
|
|
@ -8,6 +8,8 @@ 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';
|
||||
import 'package:shitman/ui/touch_controls_overlay.dart';
|
||||
import 'package:shitman/settings/app_settings.dart';
|
||||
|
||||
class AnyInputScrollBehavior extends MaterialScrollBehavior {
|
||||
// Override behavior methods and getters like dragDevices
|
||||
|
@ -19,18 +21,30 @@ class AnyInputScrollBehavior extends MaterialScrollBehavior {
|
|||
};
|
||||
}
|
||||
|
||||
void main() {
|
||||
void main() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
LicenseRegistry.addLicense(() async* {
|
||||
final license = await rootBundle.loadString('google_fonts/OFL.txt');
|
||||
yield LicenseEntryWithLineBreaks(['google_fonts'], license);
|
||||
});
|
||||
|
||||
// Load saved language preference
|
||||
final appSettings = AppSettings();
|
||||
await appSettings.initSettings();
|
||||
String savedLanguage = 'en';
|
||||
try {
|
||||
savedLanguage = appSettings.appSettings.getString('ui.language');
|
||||
} catch (e) {
|
||||
// Use default if not found
|
||||
}
|
||||
|
||||
runApp(
|
||||
EasyLocalization(
|
||||
supportedLocales: [Locale('en'), Locale('da'), Locale('de')],
|
||||
path: 'assets/translations',
|
||||
fallbackLocale: Locale('en'),
|
||||
startLocale: Locale('en'),
|
||||
startLocale: Locale(savedLanguage),
|
||||
useOnlyLangCode: true,
|
||||
useFallbackTranslations: true,
|
||||
child: Shitman(),
|
||||
|
@ -38,10 +52,29 @@ void main() {
|
|||
);
|
||||
}
|
||||
|
||||
class Shitman extends StatelessWidget {
|
||||
class Shitman extends StatefulWidget {
|
||||
const Shitman({super.key});
|
||||
|
||||
// This widget is the root of your application.
|
||||
@override
|
||||
State<Shitman> createState() => _ShitmanState();
|
||||
}
|
||||
|
||||
class _ShitmanState extends State<Shitman> {
|
||||
late ThemeData _currentTheme;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_currentTheme = flutterNesTheme(brightness: Brightness.dark);
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
// Recreate theme when locale changes to avoid lerp issues
|
||||
_currentTheme = flutterNesTheme(brightness: Brightness.dark);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MaterialApp(
|
||||
|
@ -49,8 +82,9 @@ class Shitman extends StatelessWidget {
|
|||
supportedLocales: context.supportedLocales,
|
||||
scrollBehavior: AnyInputScrollBehavior(),
|
||||
locale: context.locale,
|
||||
theme: flutterNesTheme(brightness: Brightness.dark),
|
||||
theme: _currentTheme,
|
||||
themeMode: ThemeMode.dark,
|
||||
themeAnimationDuration: Duration.zero, // Disable theme animation
|
||||
home: GameWidget(
|
||||
game: ShitmanGame(),
|
||||
initialActiveOverlays: const ['MainMenu'],
|
||||
|
@ -63,6 +97,8 @@ class Shitman extends StatelessWidget {
|
|||
SettingsUI(game as ShitmanGame),
|
||||
PauseMenuUI.overlayID: (context, game) =>
|
||||
PauseMenuUI(game as ShitmanGame),
|
||||
TouchControlsOverlayWidget.overlayID: (context, game) =>
|
||||
TouchControlsOverlayWidget(game: game as ShitmanGame),
|
||||
},
|
||||
),
|
||||
);
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
import 'game.dart';
|
||||
import 'colors.dart';
|
||||
import 'settings_manager.dart';
|
||||
import 'ui.dart';
|
||||
|
||||
mixin class AppSettings {
|
||||
final Settings appSettings = Settings();
|
||||
|
@ -11,6 +12,7 @@ mixin class AppSettings {
|
|||
_isInitialized = true;
|
||||
appSettings.register(gameSettings);
|
||||
appSettings.register(colorSettings);
|
||||
appSettings.register(uiSettings);
|
||||
await appSettings.init();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -42,4 +42,3 @@ export 'exceptions.dart';
|
|||
export 'setting.dart';
|
||||
export 'settings_group.dart';
|
||||
export 'settings_store.dart';
|
||||
export 'game.dart';
|
||||
|
|
21
lib/settings/ui.dart
Normal file
21
lib/settings/ui.dart
Normal file
|
@ -0,0 +1,21 @@
|
|||
import 'setting.dart';
|
||||
import 'settings_group.dart';
|
||||
|
||||
/// Color settings, themes, etc.
|
||||
final uiSettings = SettingsGroup(
|
||||
key: 'ui',
|
||||
items: [
|
||||
/// Base color for game UI
|
||||
BoolSetting(
|
||||
key: 'touch_enabled',
|
||||
defaultValue: false,
|
||||
userConfigurable: true,
|
||||
),
|
||||
/// Selected language code
|
||||
StringSetting(
|
||||
key: 'language',
|
||||
defaultValue: 'en',
|
||||
userConfigurable: true,
|
||||
),
|
||||
],
|
||||
);
|
|
@ -33,7 +33,10 @@ class InGameUI extends StatelessWidget with AppSettings {
|
|||
children: [
|
||||
Icon(Icons.visibility_off, size: 16),
|
||||
SizedBox(width: 8),
|
||||
Text('gameplay.hidden'.tr(), style: TextStyle(color: Colors.green)),
|
||||
Text(
|
||||
'gameplay.hidden'.tr(),
|
||||
style: TextStyle(color: Colors.green),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
@ -43,7 +46,10 @@ class InGameUI extends StatelessWidget with AppSettings {
|
|||
backgroundColor: Colors.black87,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: Text('gameplay.find_target'.tr(), style: TextStyle(color: Colors.white)),
|
||||
child: Text(
|
||||
'gameplay.find_target'.tr(),
|
||||
style: TextStyle(color: Colors.white),
|
||||
),
|
||||
),
|
||||
),
|
||||
// Pause button
|
||||
|
@ -67,9 +73,18 @@ class InGameUI extends StatelessWidget with AppSettings {
|
|||
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)),
|
||||
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),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
@ -168,6 +183,7 @@ class _SettingsUIState extends State<SettingsUI> with AppSettings {
|
|||
bool showVisionCones = false;
|
||||
bool showDetectionRadius = false;
|
||||
bool debugMode = false;
|
||||
bool touchControlsEnabled = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
|
@ -177,14 +193,22 @@ class _SettingsUIState extends State<SettingsUI> with AppSettings {
|
|||
|
||||
void _loadSettings() {
|
||||
try {
|
||||
showVisionCones = widget.game.appSettings.getBool('game.show_vision_cones');
|
||||
showDetectionRadius = widget.game.appSettings.getBool('game.show_detection_radius');
|
||||
showVisionCones = widget.game.appSettings.getBool(
|
||||
'game.show_vision_cones',
|
||||
);
|
||||
showDetectionRadius = widget.game.appSettings.getBool(
|
||||
'game.show_detection_radius',
|
||||
);
|
||||
debugMode = widget.game.appSettings.getBool('game.debug_mode');
|
||||
touchControlsEnabled = widget.game.appSettings.getBool(
|
||||
'ui.touch_enabled',
|
||||
);
|
||||
} catch (e) {
|
||||
// Settings not ready, use defaults
|
||||
showVisionCones = false;
|
||||
showDetectionRadius = false;
|
||||
debugMode = false;
|
||||
touchControlsEnabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -206,9 +230,9 @@ class _SettingsUIState extends State<SettingsUI> with AppSettings {
|
|||
children: [
|
||||
Text(
|
||||
'menu.settings'.tr(),
|
||||
style: Theme.of(context).textTheme.headlineMedium?.copyWith(
|
||||
color: Colors.white,
|
||||
),
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.headlineMedium?.copyWith(color: Colors.white),
|
||||
),
|
||||
IconButton(
|
||||
icon: Icon(Icons.close, color: Colors.white),
|
||||
|
@ -222,28 +246,39 @@ class _SettingsUIState extends State<SettingsUI> with AppSettings {
|
|||
SizedBox(height: 24),
|
||||
|
||||
// Language selector
|
||||
Text('Language / Sprog / Sprache:', style: TextStyle(color: Colors.white)),
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
'settings.language_title'.tr(),
|
||||
style: TextStyle(color: Colors.white),
|
||||
),
|
||||
SizedBox(width: 8),
|
||||
Icon(Icons.language, color: Colors.white),
|
||||
],
|
||||
),
|
||||
SizedBox(height: 8),
|
||||
NesDropdownMenu<String>(
|
||||
initialValue: context.locale.languageCode,
|
||||
entries: const [
|
||||
entries: [
|
||||
NesDropdownMenuEntry(
|
||||
value: 'en',
|
||||
label: 'English',
|
||||
label: 'settings.language.english'.tr(),
|
||||
),
|
||||
NesDropdownMenuEntry(
|
||||
value: 'da',
|
||||
label: 'Dansk',
|
||||
label: 'settings.language.danish'.tr(),
|
||||
),
|
||||
NesDropdownMenuEntry(
|
||||
value: 'de',
|
||||
label: 'Deutsch',
|
||||
label: 'settings.language.german'.tr(),
|
||||
),
|
||||
],
|
||||
onChanged: (String? value) {
|
||||
onChanged: (String? value) async {
|
||||
if (value != null) {
|
||||
context.setLocale(Locale(value));
|
||||
setState(() {});
|
||||
// Save language preference
|
||||
await widget.game.appSettings.setString('ui.language', value);
|
||||
setState(() {}); // Simple setState to rebuild UI
|
||||
}
|
||||
},
|
||||
),
|
||||
|
@ -253,7 +288,11 @@ class _SettingsUIState extends State<SettingsUI> with AppSettings {
|
|||
// Gameplay Settings Section
|
||||
Text(
|
||||
'settings.gameplay'.tr(),
|
||||
style: TextStyle(color: Colors.orange, fontSize: 16, fontWeight: FontWeight.bold),
|
||||
style: TextStyle(
|
||||
color: Colors.orange,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
SizedBox(height: 12),
|
||||
|
||||
|
@ -265,8 +304,17 @@ class _SettingsUIState extends State<SettingsUI> with AppSettings {
|
|||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('settings.show_vision_cones'.tr(), style: TextStyle(color: Colors.white)),
|
||||
Text('settings.vision_cones_help'.tr(), style: TextStyle(color: Colors.white60, fontSize: 11)),
|
||||
Text(
|
||||
'settings.show_vision_cones'.tr(),
|
||||
style: TextStyle(color: Colors.white),
|
||||
),
|
||||
Text(
|
||||
'settings.vision_cones_help'.tr(),
|
||||
style: TextStyle(
|
||||
color: Colors.white60,
|
||||
fontSize: 11,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
@ -276,7 +324,10 @@ class _SettingsUIState extends State<SettingsUI> with AppSettings {
|
|||
setState(() {
|
||||
showVisionCones = value;
|
||||
});
|
||||
await widget.game.appSettings.setBool('game.show_vision_cones', value);
|
||||
await widget.game.appSettings.setBool(
|
||||
'game.show_vision_cones',
|
||||
value,
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
|
@ -291,8 +342,17 @@ class _SettingsUIState extends State<SettingsUI> with AppSettings {
|
|||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('settings.show_detection_radius'.tr(), style: TextStyle(color: Colors.white)),
|
||||
Text('settings.detection_help'.tr(), style: TextStyle(color: Colors.white60, fontSize: 11)),
|
||||
Text(
|
||||
'settings.show_detection_radius'.tr(),
|
||||
style: TextStyle(color: Colors.white),
|
||||
),
|
||||
Text(
|
||||
'settings.detection_help'.tr(),
|
||||
style: TextStyle(
|
||||
color: Colors.white60,
|
||||
fontSize: 11,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
@ -302,7 +362,10 @@ class _SettingsUIState extends State<SettingsUI> with AppSettings {
|
|||
setState(() {
|
||||
showDetectionRadius = value;
|
||||
});
|
||||
await widget.game.appSettings.setBool('game.show_detection_radius', value);
|
||||
await widget.game.appSettings.setBool(
|
||||
'game.show_detection_radius',
|
||||
value,
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
|
@ -313,7 +376,11 @@ class _SettingsUIState extends State<SettingsUI> with AppSettings {
|
|||
// Accessibility Section
|
||||
Text(
|
||||
'settings.accessibility'.tr(),
|
||||
style: TextStyle(color: Colors.orange, fontSize: 16, fontWeight: FontWeight.bold),
|
||||
style: TextStyle(
|
||||
color: Colors.orange,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
SizedBox(height: 12),
|
||||
|
||||
|
@ -321,20 +388,66 @@ class _SettingsUIState extends State<SettingsUI> with AppSettings {
|
|||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text('ui.debug_mode'.tr(), style: TextStyle(color: Colors.white)),
|
||||
Text(
|
||||
'ui.debug_mode'.tr(),
|
||||
style: TextStyle(color: Colors.white),
|
||||
),
|
||||
NesCheckBox(
|
||||
value: debugMode,
|
||||
onChange: (value) async {
|
||||
setState(() {
|
||||
debugMode = value;
|
||||
});
|
||||
await widget.game.appSettings.setBool('game.debug_mode', value);
|
||||
await widget.game.appSettings.setBool(
|
||||
'game.debug_mode',
|
||||
value,
|
||||
);
|
||||
widget.game.debugMode = value;
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
SizedBox(height: 8),
|
||||
|
||||
// Touch controls toggle
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'settings.touch_controls'.tr(),
|
||||
style: TextStyle(color: Colors.white),
|
||||
),
|
||||
Text(
|
||||
'settings.touch_controls_help'.tr(),
|
||||
style: TextStyle(
|
||||
color: Colors.white60,
|
||||
fontSize: 11,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
NesCheckBox(
|
||||
value: touchControlsEnabled,
|
||||
onChange: (value) async {
|
||||
setState(() {
|
||||
touchControlsEnabled = value;
|
||||
});
|
||||
await widget.game.appSettings.setBool(
|
||||
'ui.touch_enabled',
|
||||
value,
|
||||
);
|
||||
widget.game.toggleTouchControls(value);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
SizedBox(height: 40),
|
||||
Center(
|
||||
child: NesButton(
|
||||
|
@ -375,9 +488,9 @@ class PauseMenuUI extends StatelessWidget {
|
|||
children: [
|
||||
Text(
|
||||
'ui.paused'.tr(),
|
||||
style: Theme.of(context).textTheme.headlineMedium?.copyWith(
|
||||
color: Colors.white,
|
||||
),
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.headlineMedium?.copyWith(color: Colors.white),
|
||||
),
|
||||
SizedBox(height: 24),
|
||||
|
||||
|
|
265
lib/ui/touch_controls.dart
Normal file
265
lib/ui/touch_controls.dart
Normal file
|
@ -0,0 +1,265 @@
|
|||
import 'dart:math';
|
||||
import 'dart:io' show Platform;
|
||||
import 'package:flame/components.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter/foundation.dart' show kIsWeb;
|
||||
|
||||
/// A joystick thumb that shows movement direction
|
||||
class JoystickThumb extends StatefulWidget {
|
||||
JoystickThumb({
|
||||
super.key,
|
||||
this.size = 20,
|
||||
Vector2? offset,
|
||||
this.color = Colors.white,
|
||||
}) : offset = offset ?? Vector2.zero();
|
||||
|
||||
final double size;
|
||||
final Vector2 offset;
|
||||
final Color color;
|
||||
|
||||
@override
|
||||
State<JoystickThumb> createState() => _JoystickThumbState();
|
||||
}
|
||||
|
||||
class _JoystickThumbState extends State<JoystickThumb> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Positioned(
|
||||
left: widget.offset.x,
|
||||
top: widget.offset.y,
|
||||
child: Container(
|
||||
width: widget.size,
|
||||
height: widget.size,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: widget.color,
|
||||
border: Border.all(color: Colors.black26, width: 1),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void setOffset(Vector2 offset) {
|
||||
setState(() {
|
||||
widget.offset.setFrom(offset);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Virtual joystick for movement
|
||||
class VirtualJoystick extends StatelessWidget {
|
||||
VirtualJoystick({
|
||||
super.key,
|
||||
required this.onMove,
|
||||
this.size = 80,
|
||||
});
|
||||
|
||||
final Function(Vector2) onMove;
|
||||
final double size;
|
||||
final GlobalKey<_JoystickThumbState> _key = GlobalKey();
|
||||
|
||||
Vector2 get _centerPosition => Vector2(size / 2 - (size / 6), size / 2 - (size / 6));
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
onPanUpdate: (details) {
|
||||
final offset = details.localPosition;
|
||||
final center = Offset(size / 2, size / 2);
|
||||
final distance = offset - center;
|
||||
final angle = distance.direction;
|
||||
final magnitude = distance.distance;
|
||||
|
||||
if (magnitude <= size / 2) {
|
||||
final position = Vector2(
|
||||
magnitude * cos(angle),
|
||||
magnitude * sin(angle), // Fixed Y-axis
|
||||
);
|
||||
// Calculate thumb position relative to center
|
||||
final thumbPos = Vector2(
|
||||
size / 2 + distance.dx - (size / 6), // Center thumb in joystick
|
||||
size / 2 + distance.dy - (size / 6),
|
||||
);
|
||||
onMove(position * 2 / size); // Normalize to -1 to 1
|
||||
_key.currentState?.setOffset(thumbPos);
|
||||
} else {
|
||||
final position = Vector2(
|
||||
(size / 2) * cos(angle),
|
||||
(size / 2) * sin(angle), // Fixed Y-axis
|
||||
);
|
||||
// Calculate thumb position at edge
|
||||
final thumbPos = Vector2(
|
||||
size / 2 + (size / 2) * cos(angle) - (size / 6),
|
||||
size / 2 + (size / 2) * sin(angle) - (size / 6),
|
||||
);
|
||||
onMove(position * 2 / size); // Normalize to -1 to 1
|
||||
_key.currentState?.setOffset(thumbPos);
|
||||
}
|
||||
},
|
||||
onPanCancel: () => {
|
||||
onMove(Vector2.zero()),
|
||||
_key.currentState?.setOffset(_centerPosition),
|
||||
},
|
||||
onPanEnd: (_) => {
|
||||
onMove(Vector2.zero()),
|
||||
_key.currentState?.setOffset(_centerPosition),
|
||||
},
|
||||
child: Container(
|
||||
width: size,
|
||||
height: size,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: Colors.black.withValues(alpha: 0.3),
|
||||
border: Border.all(color: Colors.white.withValues(alpha: 0.5), width: 2),
|
||||
),
|
||||
child: Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
JoystickThumb(
|
||||
key: _key,
|
||||
size: size / 3,
|
||||
offset: _centerPosition, // Start at center
|
||||
color: Colors.white.withValues(alpha: 0.8),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Touch button for actions
|
||||
class TouchButton extends StatefulWidget {
|
||||
const TouchButton({
|
||||
super.key,
|
||||
required this.onPressed,
|
||||
this.onReleased,
|
||||
required this.icon,
|
||||
this.size = 60,
|
||||
this.color = Colors.white,
|
||||
});
|
||||
|
||||
final VoidCallback onPressed;
|
||||
final VoidCallback? onReleased;
|
||||
final IconData icon;
|
||||
final double size;
|
||||
final Color color;
|
||||
|
||||
@override
|
||||
State<TouchButton> createState() => _TouchButtonState();
|
||||
}
|
||||
|
||||
class _TouchButtonState extends State<TouchButton> {
|
||||
bool _isPressed = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
onTapDown: (_) {
|
||||
setState(() => _isPressed = true);
|
||||
widget.onPressed();
|
||||
HapticFeedback.lightImpact();
|
||||
},
|
||||
onTapUp: (_) {
|
||||
setState(() => _isPressed = false);
|
||||
widget.onReleased?.call();
|
||||
},
|
||||
onTapCancel: () {
|
||||
setState(() => _isPressed = false);
|
||||
widget.onReleased?.call();
|
||||
},
|
||||
child: Container(
|
||||
width: widget.size,
|
||||
height: widget.size,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: _isPressed
|
||||
? Colors.white.withValues(alpha: 0.8)
|
||||
: Colors.black.withValues(alpha: 0.3),
|
||||
border: Border.all(
|
||||
color: Colors.white.withValues(alpha: 0.5),
|
||||
width: 2,
|
||||
),
|
||||
),
|
||||
child: Icon(
|
||||
widget.icon,
|
||||
size: widget.size * 0.4,
|
||||
color: _isPressed ? Colors.black : Colors.white,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Main touch controls overlay
|
||||
class TouchControlsOverlay extends StatelessWidget {
|
||||
const TouchControlsOverlay({
|
||||
super.key,
|
||||
required this.onMove,
|
||||
required this.onPoopBag,
|
||||
required this.onDoorbell,
|
||||
});
|
||||
|
||||
final Function(Vector2) onMove;
|
||||
final VoidCallback onPoopBag;
|
||||
final VoidCallback onDoorbell;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Stack(
|
||||
children: [
|
||||
// Movement joystick (bottom left)
|
||||
Positioned(
|
||||
left: 20,
|
||||
bottom: 20,
|
||||
child: VirtualJoystick(
|
||||
onMove: onMove,
|
||||
size: 100,
|
||||
),
|
||||
),
|
||||
|
||||
// Action buttons (bottom right)
|
||||
Positioned(
|
||||
right: 20,
|
||||
bottom: 80,
|
||||
child: TouchButton(
|
||||
onPressed: onPoopBag,
|
||||
icon: Icons.eco, // Poop bag icon
|
||||
size: 70,
|
||||
),
|
||||
),
|
||||
|
||||
Positioned(
|
||||
right: 20,
|
||||
bottom: 20,
|
||||
child: TouchButton(
|
||||
onPressed: onDoorbell,
|
||||
icon: Icons.notifications,
|
||||
size: 70,
|
||||
),
|
||||
),
|
||||
|
||||
// Optional: escape timer display when escaping
|
||||
// TODO: Add escape timer UI here
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Utility to detect if device supports touch
|
||||
class TouchDetection {
|
||||
static bool get isTouchDevice {
|
||||
if (kIsWeb) {
|
||||
// On web, assume no touch controls by default
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
return Platform.isAndroid || Platform.isIOS;
|
||||
} catch (e) {
|
||||
// Fallback: assume no touch if platform detection fails
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
24
lib/ui/touch_controls_overlay.dart
Normal file
24
lib/ui/touch_controls_overlay.dart
Normal file
|
@ -0,0 +1,24 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:shitman/game/shitman_game.dart';
|
||||
import 'package:shitman/ui/touch_controls.dart';
|
||||
|
||||
/// Touch controls overlay widget
|
||||
class TouchControlsOverlayWidget extends StatelessWidget {
|
||||
static const String overlayID = 'TouchControls';
|
||||
|
||||
const TouchControlsOverlayWidget({
|
||||
super.key,
|
||||
required this.game,
|
||||
});
|
||||
|
||||
final ShitmanGame game;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return TouchControlsOverlay(
|
||||
onMove: (input) => game.handleTouchMovement(input),
|
||||
onPoopBag: () => game.handleTouchPoopBag(),
|
||||
onDoorbell: () => game.handleTouchDoorbell(),
|
||||
);
|
||||
}
|
||||
}
|
|
@ -105,6 +105,14 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.0.2"
|
||||
easy_shared_preferences:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: easy_shared_preferences
|
||||
sha256: "47baab4cc8f48b85fa1a5c3613932b47c52449288ba16acdf52f01350aeb6555"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.1"
|
||||
equatable:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
|
|
@ -18,6 +18,7 @@ dependencies:
|
|||
shared_preferences: ^2.2.2
|
||||
meta: ^1.16.0
|
||||
logging: ^1.3.0
|
||||
easy_shared_preferences: ^1.0.1
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
|
|
Loading…
Add table
Reference in a new issue