Lobby Screen
All checks were successful
Deploy Docs / build-and-deploy (push) Successful in 27s

This commit is contained in:
2026-01-13 14:56:56 +01:00
parent 832a60d044
commit 261921b652
9 changed files with 232 additions and 47 deletions

View File

@@ -24,8 +24,9 @@ void game_mgmt_set_state(sys_state_t state)
sys_state_t game_mgmt_get_state(void) { return current_state; }
void game_mgmt_set_game_id(uint64_t id) {
if (current_game_id == id) return;
current_game_id = id;
LOG_INF("Game ID updated: %llu", id);
LOG_INF("Game ID updated: 0x%llx", id);
}
uint64_t game_mgmt_get_game_id(void) { return current_game_id; }

View File

@@ -1,18 +1,22 @@
class LasertagUUIDs {
class LasertagConfig {
// BLE UUID for services and characteristics
static const String base = "03afe2cf-6c64-4a22-9289-c3ae820c";
static const String provService = "${base}1000";
static const String provNameChar = "${base}1001";
// static const String provPanIdChar = "${base}1002";
// static const String provChanChar = "${base}1003";
// static const String provExtPanIdChar = "${base}1004";
// static const String provNetKeyChar = "${base}1005";
// static const String provNetNameChar = "${base}1006";
static const String provTypeChar = "${base}1008";
static const String provConfigChar = "${base}100c";
// Gerätetypen aus deiner ble_mgmt.h
// Device types from ble_mgmt.h
static const int typeLeader = 0x01;
static const int typeWeapon = 0x02;
static const int typeVest = 0x03;
static const int typeBeacon = 0x04;
// System states from game_mgmt.h
static const int sysStateNoChange = 0x00;
static const int sysStateIdle = 0x01;
static const int sysStateLobby = 0x02;
static const int sysStateStarting = 0x03;
static const int sysStateRunning = 0x04;
static const int sysStatePostGame = 0x05;
}

View File

@@ -1,5 +1,6 @@
import 'dart:convert';
import 'dart:typed_data';
import 'dart:math';
class DeviceConfig {
final int systemState;
@@ -22,9 +23,30 @@ class DeviceConfig {
required this.nodeName,
});
static BigInt generateRandom64BitId() {
final rand = Random.secure();
final low = rand.nextInt(0xFFFFFFFF);
final high = rand.nextInt(0xFFFFFFFF);
return (BigInt.from(high) << 32) | BigInt.from(low);
}
// Hilfsmethode für eine leere/Standard-Config
factory DeviceConfig.defaultConfig() {
return DeviceConfig(
systemState: 1, // IDLE
gameId: BigInt.from(0),
panId: 0xABCD,
channel: 15,
extPanId: "0011223344556677",
networkKey: "00112233445566778899AABBCCDDEEFF",
networkName: "LasertagMesh",
nodeName: "New Device",
);
}
factory DeviceConfig.fromBytes(List<int> bytes) {
final data = ByteData.sublistView(Uint8List.fromList(bytes));
// Manuelles Decoding von 64-Bit (2x 32-Bit), da getUint64 nicht Standard ist
final low = data.getUint32(1, Endian.little);
final high = data.getUint32(5, Endian.little);
@@ -47,14 +69,18 @@ class DeviceConfig {
final data = ByteData.view(bytes.buffer);
data.setUint8(0, systemState);
// Encoding von 64-Bit BigInt in zwei 32-Bit Segmente
data.setUint32(1, (gameId & BigInt.from(0xFFFFFFFF)).toInt(), Endian.little);
data.setUint32(
1,
(gameId & BigInt.from(0xFFFFFFFF)).toInt(),
Endian.little,
);
data.setUint32(5, (gameId >> 32).toInt(), Endian.little);
data.setUint16(9, panId, Endian.little);
data.setUint8(11, channel);
_writeHexToBuffer(bytes, 12, extPanId);
_writeHexToBuffer(bytes, 20, networkKey);
_writeStringToBuffer(bytes, 36, 17, networkName);
@@ -65,10 +91,17 @@ class DeviceConfig {
static String _decodeString(List<int> bytes) {
int nullIdx = bytes.indexOf(0);
return utf8.decode(nullIdx == -1 ? bytes : bytes.sublist(0, nullIdx)).trim();
return utf8
.decode(nullIdx == -1 ? bytes : bytes.sublist(0, nullIdx))
.trim();
}
static void _writeStringToBuffer(Uint8List buffer, int offset, int maxLen, String val) {
static void _writeStringToBuffer(
Uint8List buffer,
int offset,
int maxLen,
String val,
) {
final encoded = utf8.encode(val);
for (int i = 0; i < maxLen - 1 && i < encoded.length; i++) {
buffer[offset + i] = encoded[i];
@@ -76,12 +109,36 @@ class DeviceConfig {
// Buffer ist bereits mit 0 initialisiert, Terminator steht also am Ende
}
static String _bytesToHex(List<int> bytes) =>
bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join().toUpperCase();
static String _bytesToHex(List<int> bytes) => bytes
.map((b) => b.toRadixString(16).padLeft(2, '0'))
.join()
.toUpperCase();
static void _writeHexToBuffer(Uint8List buffer, int offset, String hex) {
for (int i = 0; i < hex.length; i += 2) {
buffer[offset + (i ~/ 2)] = int.parse(hex.substring(i, i + 2), radix: 16);
}
}
}
DeviceConfig copyWith({
int? systemState,
BigInt? gameId,
int? panId,
int? channel,
String? extPanId,
String? networkKey,
String? networkName,
String? nodeName,
}) {
return DeviceConfig(
systemState: systemState ?? this.systemState,
gameId: gameId ?? this.gameId,
panId: panId ?? this.panId,
channel: channel ?? this.channel,
extPanId: extPanId ?? this.extPanId,
networkKey: networkKey ?? this.networkKey,
networkName: networkName ?? this.networkName,
nodeName: nodeName ?? this.nodeName,
);
}
}

View File

@@ -19,7 +19,7 @@ class LasertagDevice {
this.isNameVerified = false,
});
bool get isLeader => type == LasertagUUIDs.typeLeader;
bool get isLeader => type == LasertagConfig.typeLeader;
LasertagDevice copyWith({String? name, bool? isNameVerified}) {
return LasertagDevice(

View File

@@ -1,7 +1,6 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:flutter_blue_plus/flutter_blue_plus.dart';
@@ -14,11 +13,29 @@ class DeviceProvider extends ChangeNotifier {
final List<LasertagDevice> _discoveredLeaders = [];
final List<LasertagDevice> _discoveredPeripherals = [];
StreamSubscription? _scanSubscription;
DeviceConfig? _activeConfig;
DeviceConfig? get activeConfig => _activeConfig;
List<LasertagDevice> get leaders => _discoveredLeaders;
List<LasertagDevice> get peripherals => _discoveredPeripherals;
// ... startScan bleibt gleich ...
void setActiveConfig(DeviceConfig config) {
_activeConfig = config;
notifyListeners();
}
Future<void> provisionWithActiveConfig(LasertagDevice device, {bool keepName = true}) async {
if (_activeConfig == null) throw Exception("Keine aktive Konfiguration vorhanden");
DeviceConfig configToUse = _activeConfig!;
if (keepName) {
configToUse = configToUse.copyWith(
nodeName: device.name,
systemState: LasertagConfig.sysStateLobby,
);
}
await provisionDevice(device, configToUse);
}
void startScan() async {
_discoveredLeaders.clear();
_discoveredPeripherals.clear();
@@ -28,7 +45,7 @@ class DeviceProvider extends ChangeNotifier {
_scanSubscription?.cancel();
_scanSubscription = FlutterBluePlus.scanResults.listen((results) {
for (ScanResult r in results) {
bool hasService = r.advertisementData.serviceUuids.contains(Guid(LasertagUUIDs.provService));
bool hasService = r.advertisementData.serviceUuids.contains(Guid(LasertagConfig.provService));
if (!hasService) continue;
final mfgData = r.advertisementData.manufacturerData[65535];
if (mfgData != null && mfgData.isNotEmpty) {
@@ -53,8 +70,8 @@ class DeviceProvider extends ChangeNotifier {
await ltDevice.btDevice.connect(license: License.free, timeout: const Duration(seconds: 5));
}
List<BluetoothService> srv = await ltDevice.btDevice.discoverServices();
var c = srv.firstWhere((s) => s.uuid == Guid(LasertagUUIDs.provService))
.characteristics.firstWhere((c) => c.uuid == Guid(LasertagUUIDs.provNameChar));
var c = srv.firstWhere((s) => s.uuid == Guid(LasertagConfig.provService))
.characteristics.firstWhere((c) => c.uuid == Guid(LasertagConfig.provNameChar));
List<int> value = await c.read();
String hardwareName = utf8.decode(value);
updateDeviceName(ltDevice.id, hardwareName, verified: true);
@@ -66,8 +83,8 @@ class DeviceProvider extends ChangeNotifier {
try {
await ltDevice.btDevice.connect(license: License.free, timeout: const Duration(seconds: 10));
List<BluetoothService> srv = await ltDevice.btDevice.discoverServices();
var c = srv.firstWhere((s) => s.uuid == Guid(LasertagUUIDs.provService))
.characteristics.firstWhere((c) => c.uuid == Guid(LasertagUUIDs.provNameChar));
var c = srv.firstWhere((s) => s.uuid == Guid(LasertagConfig.provService))
.characteristics.firstWhere((c) => c.uuid == Guid(LasertagConfig.provNameChar));
await c.write(utf8.encode(newName));
updateDeviceName(ltDevice.id, newName, verified: true);
} finally { await ltDevice.btDevice.disconnect(); }
@@ -80,20 +97,19 @@ class DeviceProvider extends ChangeNotifier {
await device.btDevice.connect(license: License.free, timeout: const Duration(seconds: 10));
}
List<BluetoothService> srv = await device.btDevice.discoverServices();
var c = srv.firstWhere((s) => s.uuid == Guid(LasertagUUIDs.provService))
.characteristics.firstWhere((c) => c.uuid == Guid(LasertagUUIDs.provConfigChar));
var c = srv.firstWhere((s) => s.uuid == Guid(LasertagConfig.provService))
.characteristics.firstWhere((c) => c.uuid == Guid(LasertagConfig.provConfigChar));
final bytes = await c.read();
return DeviceConfig.fromBytes(bytes);
} finally { await device.btDevice.disconnect(); }
}
Future<void> provisionDevice(LasertagDevice device, DeviceConfig config) async {
Future<void> provisionDevice(LasertagDevice device, DeviceConfig config, {bool keepConnected = false}) async {
try {
if (!device.btDevice.isConnected) {
await device.btDevice.connect(license: License.free, timeout: const Duration(seconds: 10));
}
// MTU nur auf Android anfordern, auf macOS/iOS macht das das OS automatisch
if (Platform.isAndroid) {
try {
await device.btDevice.requestMtu(250);
@@ -102,13 +118,23 @@ class DeviceProvider extends ChangeNotifier {
debugPrint("MTU Request failed (Android only): $e");
}
}
List<BluetoothService> srv = await device.btDevice.discoverServices();
var c = srv.firstWhere((s) => s.uuid == Guid(LasertagUUIDs.provService))
.characteristics.firstWhere((c) => c.uuid == Guid(LasertagUUIDs.provConfigChar));
var c = srv
.firstWhere((s) => s.uuid == Guid(LasertagConfig.provService))
.characteristics
.firstWhere((c) => c.uuid == Guid(LasertagConfig.provConfigChar));
await c.write(config.toBytes(), allowLongWrite: true);
updateDeviceName(device.id, config.nodeName, verified: true);
} finally { await device.btDevice.disconnect(); }
} catch (e) {
await device.btDevice.disconnect();
rethrow;
} finally {
if (!keepConnected) {
await device.btDevice.disconnect();
}
}
}
// ... Hilfsmethoden updateDeviceName, _addDeviceToLists, _verifyNameInBackground (mit license!), _sortList etc. bleiben ...
@@ -131,8 +157,8 @@ class DeviceProvider extends ChangeNotifier {
try {
await device.btDevice.connect(license: License.free, timeout: const Duration(seconds: 5));
List<BluetoothService> services = await device.btDevice.discoverServices();
var characteristic = services.firstWhere((s) => s.uuid == Guid(LasertagUUIDs.provService))
.characteristics.firstWhere((c) => c.uuid == Guid(LasertagUUIDs.provNameChar));
var characteristic = services.firstWhere((s) => s.uuid == Guid(LasertagConfig.provService))
.characteristics.firstWhere((c) => c.uuid == Guid(LasertagConfig.provNameChar));
List<int> value = await characteristic.read();
updateDeviceName(device.id, utf8.decode(value), verified: true);
} catch (e) {

View File

@@ -115,13 +115,13 @@ class _DeviceSelectionScreenState extends State<DeviceSelectionScreen> {
IconData _getIconForType(int type) {
switch (type) {
case LasertagUUIDs.typeLeader:
case LasertagConfig.typeLeader:
return Icons.hub;
case LasertagUUIDs.typeWeapon:
case LasertagConfig.typeWeapon:
return Icons.flash_on; // Blitz für Waffe
case LasertagUUIDs.typeVest:
case LasertagConfig.typeVest:
return Icons.accessibility_new; // Person für Weste
case LasertagUUIDs.typeBeacon:
case LasertagConfig.typeBeacon:
return Icons.flag; // Flagge für Basis
default:
return Icons.device_unknown;

View File

@@ -1,10 +1,12 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:lasertag_app/constants.dart';
import 'package:provider/provider.dart';
import 'dart:math';
import '../../models/device_model.dart';
import '../../models/device_config_model.dart';
import '../../providers/device_provider.dart';
import "../screens/lobby_screen.dart";
class LeaderConfigScreen extends StatefulWidget {
final LasertagDevice device;
@@ -204,8 +206,10 @@ class _LeaderConfigScreenState extends State<LeaderConfigScreen> {
try {
// Neues Model-Objekt erstellen
final newConfig = DeviceConfig(
systemState: _currentConfig.systemState, // Unverändert übernehmen
gameId: _currentConfig.gameId, // Unverändert übernehmen
systemState: LasertagConfig.sysStateLobby, // Immer Lobby setzen
gameId: _currentConfig.gameId == BigInt.from(0)
? _generateRandom64BitId()
: _currentConfig.gameId,
nodeName: _nameCtrl.text.trim(),
networkName: _netNameCtrl.text.trim(),
channel: _selectedChannel,
@@ -214,14 +218,20 @@ class _LeaderConfigScreenState extends State<LeaderConfigScreen> {
networkKey: _netKeyCtrl.text.trim(),
);
// Optimierte Methode im Provider aufrufen
await context.read<DeviceProvider>().provisionDevice(widget.device, newConfig);
await context.read<DeviceProvider>().provisionDevice(
widget.device,
newConfig,
keepConnected: true,
);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text("Konfiguration erfolgreich übertragen!")),
// Navigation zum Lobby Screen statt pop
Navigator.pushReplacement(
context,
MaterialPageRoute(
builder: (context) => LobbyScreen(leaderDevice: widget.device),
),
);
Navigator.pop(context);
}
} catch (e) {
if (mounted) {
@@ -241,6 +251,14 @@ class _LeaderConfigScreenState extends State<LeaderConfigScreen> {
.toUpperCase();
}
BigInt _generateRandom64BitId() {
final random = Random.secure(); // Sicherer Zufall
// Generiere zwei 32-Bit Werte und kombiniere sie
final low = random.nextInt(0xFFFFFFFF);
final high = random.nextInt(0xFFFFFFFF);
return (BigInt.from(high) << 32) | BigInt.from(low);
}
@override
void dispose() {
_nameCtrl.dispose();

View File

@@ -0,0 +1,42 @@
// lib/ui/screens/lobby_screen.dart
import 'package:flutter/material.dart';
import '../../models/device_model.dart';
import '../widgets/leader_info_dialog.dart';
class LobbyScreen extends StatelessWidget {
final LasertagDevice leaderDevice;
const LobbyScreen({super.key, required this.leaderDevice});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text("Spiel-Lobby")),
body: Column(
children: [
// Leader-Karte mit interaktiver Info
Card(
margin: const EdgeInsets.all(16),
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: () => showDialog(
context: context,
builder: (context) => LeaderInfoDialog(device: leaderDevice),
),
child: ListTile(
leading: Icon(Icons.hub, color: Theme.of(context).colorScheme.primary),
title: Text(leaderDevice.name),
subtitle: const Text("Status: LOBBY (Tippen für Details)"),
trailing: const Icon(Icons.info_outline),
),
),
),
const Divider(),
const Expanded(
child: Center(child: Text("Warten auf Teilnehmer...")),
),
// Buttons zum Abbrechen oder Starten
],
),
);
}
}

View File

@@ -0,0 +1,37 @@
// lib/ui/widgets/leader_info_dialog.dart
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../models/device_model.dart';
import '../../models/device_config_model.dart';
import '../../providers/device_provider.dart';
class LeaderInfoDialog extends StatelessWidget {
final LasertagDevice device;
const LeaderInfoDialog({super.key, required this.device});
@override
Widget build(BuildContext context) {
return AlertDialog(
title: const Text("Leader Details"),
content: FutureBuilder<DeviceConfig>(
future: context.read<DeviceProvider>().readDeviceConfig(device),
builder: (context, snapshot) {
if (!snapshot.hasData) return const CircularProgressIndicator();
final cfg = snapshot.data!;
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text("Game ID: 0x${cfg.gameId.toRadixString(16).toUpperCase()}"),
const Divider(),
Text("Netzwerk: ${cfg.networkName}"),
Text("Kanal: ${cfg.channel}"),
Text("PAN ID: 0x${cfg.panId.toRadixString(16).toUpperCase()}"),
],
);
},
),
actions: [TextButton(onPressed: () => Navigator.pop(context), child: const Text("Schließen"))],
);
}
}