bluetooth connectivity layer added

This commit is contained in:
Constantin Leue
2026-07-30 19:46:52 +02:00
parent 310d92f187
commit 5994aee7ad
19 changed files with 1665 additions and 15 deletions
+23 -3
View File
@@ -43,14 +43,24 @@ class DroneProfiles extends Table {
}
/// Einzeilige Einstellungstabelle (id fest 0) fuer app-weite Auswahl, die
/// einen Neustart ueberleben soll: zuletzt aktives Drohnenprofil und die
/// einen Neustart ueberleben soll: zuletzt aktives Drohnenprofil, die
/// zuletzt bearbeitete (autosaved) Mission, um sie beim naechsten Start
/// fortzusetzen (Doku 7.5: Autosave/Mission-Drafts).
/// fortzusetzen (Doku 7.5: Autosave/Mission-Drafts), sowie die
/// Verbindungseinstellungen (Doku Kommunikationsschicht Abschnitt 7).
class AppSettingsTable extends Table {
IntColumn get id => integer()();
IntColumn get activeDroneProfileId => integer().nullable()();
IntColumn get currentMissionId => integer().nullable()();
/// Gemerkte MAC-Adresse des Bluetooth-Zielgeraets (Doku Kommunikations-
/// schicht 3.2a) - entweder manuell gewaehlt oder automatisch anhand des
/// "mLRS"-Namenspraefix aufgeloest.
TextColumn get connectionDeviceAddress => text().nullable()();
/// "Beim Wechsel in Fly automatisch verbinden" (Doku 7), Standard an.
BoolColumn get connectionAutoConnect =>
boolean().withDefault(const Constant(true))();
@override
Set<Column> get primaryKey => {id};
}
@@ -62,7 +72,7 @@ class AppDatabase extends _$AppDatabase {
AppDatabase.forTesting(super.executor);
@override
int get schemaVersion => 2;
int get schemaVersion => 3;
@override
MigrationStrategy get migration => MigrationStrategy(
@@ -73,6 +83,16 @@ class AppDatabase extends _$AppDatabase {
await m.createTable(droneProfiles);
await m.createTable(appSettingsTable);
}
if (from < 3) {
await m.addColumn(
appSettingsTable,
appSettingsTable.connectionDeviceAddress,
);
await m.addColumn(
appSettingsTable,
appSettingsTable.connectionAutoConnect,
);
}
},
);
+182 -4
View File
@@ -1322,11 +1322,39 @@ class $AppSettingsTableTable extends AppSettingsTable
type: DriftSqlType.int,
requiredDuringInsert: false,
);
static const VerificationMeta _connectionDeviceAddressMeta =
const VerificationMeta('connectionDeviceAddress');
@override
late final GeneratedColumn<String> connectionDeviceAddress =
GeneratedColumn<String>(
'connection_device_address',
aliasedName,
true,
type: DriftSqlType.string,
requiredDuringInsert: false,
);
static const VerificationMeta _connectionAutoConnectMeta =
const VerificationMeta('connectionAutoConnect');
@override
late final GeneratedColumn<bool> connectionAutoConnect =
GeneratedColumn<bool>(
'connection_auto_connect',
aliasedName,
false,
type: DriftSqlType.bool,
requiredDuringInsert: false,
defaultConstraints: GeneratedColumn.constraintIsAlways(
'CHECK ("connection_auto_connect" IN (0, 1))',
),
defaultValue: const Constant(true),
);
@override
List<GeneratedColumn> get $columns => [
id,
activeDroneProfileId,
currentMissionId,
connectionDeviceAddress,
connectionAutoConnect,
];
@override
String get aliasedName => _alias ?? actualTableName;
@@ -1361,6 +1389,24 @@ class $AppSettingsTableTable extends AppSettingsTable
),
);
}
if (data.containsKey('connection_device_address')) {
context.handle(
_connectionDeviceAddressMeta,
connectionDeviceAddress.isAcceptableOrUnknown(
data['connection_device_address']!,
_connectionDeviceAddressMeta,
),
);
}
if (data.containsKey('connection_auto_connect')) {
context.handle(
_connectionAutoConnectMeta,
connectionAutoConnect.isAcceptableOrUnknown(
data['connection_auto_connect']!,
_connectionAutoConnectMeta,
),
);
}
return context;
}
@@ -1382,6 +1428,14 @@ class $AppSettingsTableTable extends AppSettingsTable
DriftSqlType.int,
data['${effectivePrefix}current_mission_id'],
),
connectionDeviceAddress: attachedDatabase.typeMapping.read(
DriftSqlType.string,
data['${effectivePrefix}connection_device_address'],
),
connectionAutoConnect: attachedDatabase.typeMapping.read(
DriftSqlType.bool,
data['${effectivePrefix}connection_auto_connect'],
)!,
);
}
@@ -1396,10 +1450,20 @@ class AppSettingsTableData extends DataClass
final int id;
final int? activeDroneProfileId;
final int? currentMissionId;
/// Gemerkte MAC-Adresse des Bluetooth-Zielgeraets (Doku Kommunikations-
/// schicht 3.2a) - entweder manuell gewaehlt oder automatisch anhand des
/// "mLRS"-Namenspraefix aufgeloest.
final String? connectionDeviceAddress;
/// "Beim Wechsel in Fly automatisch verbinden" (Doku 7), Standard an.
final bool connectionAutoConnect;
const AppSettingsTableData({
required this.id,
this.activeDroneProfileId,
this.currentMissionId,
this.connectionDeviceAddress,
required this.connectionAutoConnect,
});
@override
Map<String, Expression> toColumns(bool nullToAbsent) {
@@ -1411,6 +1475,12 @@ class AppSettingsTableData extends DataClass
if (!nullToAbsent || currentMissionId != null) {
map['current_mission_id'] = Variable<int>(currentMissionId);
}
if (!nullToAbsent || connectionDeviceAddress != null) {
map['connection_device_address'] = Variable<String>(
connectionDeviceAddress,
);
}
map['connection_auto_connect'] = Variable<bool>(connectionAutoConnect);
return map;
}
@@ -1423,6 +1493,10 @@ class AppSettingsTableData extends DataClass
currentMissionId: currentMissionId == null && nullToAbsent
? const Value.absent()
: Value(currentMissionId),
connectionDeviceAddress: connectionDeviceAddress == null && nullToAbsent
? const Value.absent()
: Value(connectionDeviceAddress),
connectionAutoConnect: Value(connectionAutoConnect),
);
}
@@ -1437,6 +1511,12 @@ class AppSettingsTableData extends DataClass
json['activeDroneProfileId'],
),
currentMissionId: serializer.fromJson<int?>(json['currentMissionId']),
connectionDeviceAddress: serializer.fromJson<String?>(
json['connectionDeviceAddress'],
),
connectionAutoConnect: serializer.fromJson<bool>(
json['connectionAutoConnect'],
),
);
}
@override
@@ -1446,6 +1526,10 @@ class AppSettingsTableData extends DataClass
'id': serializer.toJson<int>(id),
'activeDroneProfileId': serializer.toJson<int?>(activeDroneProfileId),
'currentMissionId': serializer.toJson<int?>(currentMissionId),
'connectionDeviceAddress': serializer.toJson<String?>(
connectionDeviceAddress,
),
'connectionAutoConnect': serializer.toJson<bool>(connectionAutoConnect),
};
}
@@ -1453,6 +1537,8 @@ class AppSettingsTableData extends DataClass
int? id,
Value<int?> activeDroneProfileId = const Value.absent(),
Value<int?> currentMissionId = const Value.absent(),
Value<String?> connectionDeviceAddress = const Value.absent(),
bool? connectionAutoConnect,
}) => AppSettingsTableData(
id: id ?? this.id,
activeDroneProfileId: activeDroneProfileId.present
@@ -1461,6 +1547,10 @@ class AppSettingsTableData extends DataClass
currentMissionId: currentMissionId.present
? currentMissionId.value
: this.currentMissionId,
connectionDeviceAddress: connectionDeviceAddress.present
? connectionDeviceAddress.value
: this.connectionDeviceAddress,
connectionAutoConnect: connectionAutoConnect ?? this.connectionAutoConnect,
);
AppSettingsTableData copyWithCompanion(AppSettingsTableCompanion data) {
return AppSettingsTableData(
@@ -1471,6 +1561,12 @@ class AppSettingsTableData extends DataClass
currentMissionId: data.currentMissionId.present
? data.currentMissionId.value
: this.currentMissionId,
connectionDeviceAddress: data.connectionDeviceAddress.present
? data.connectionDeviceAddress.value
: this.connectionDeviceAddress,
connectionAutoConnect: data.connectionAutoConnect.present
? data.connectionAutoConnect.value
: this.connectionAutoConnect,
);
}
@@ -1479,46 +1575,68 @@ class AppSettingsTableData extends DataClass
return (StringBuffer('AppSettingsTableData(')
..write('id: $id, ')
..write('activeDroneProfileId: $activeDroneProfileId, ')
..write('currentMissionId: $currentMissionId')
..write('currentMissionId: $currentMissionId, ')
..write('connectionDeviceAddress: $connectionDeviceAddress, ')
..write('connectionAutoConnect: $connectionAutoConnect')
..write(')'))
.toString();
}
@override
int get hashCode => Object.hash(id, activeDroneProfileId, currentMissionId);
int get hashCode => Object.hash(
id,
activeDroneProfileId,
currentMissionId,
connectionDeviceAddress,
connectionAutoConnect,
);
@override
bool operator ==(Object other) =>
identical(this, other) ||
(other is AppSettingsTableData &&
other.id == this.id &&
other.activeDroneProfileId == this.activeDroneProfileId &&
other.currentMissionId == this.currentMissionId);
other.currentMissionId == this.currentMissionId &&
other.connectionDeviceAddress == this.connectionDeviceAddress &&
other.connectionAutoConnect == this.connectionAutoConnect);
}
class AppSettingsTableCompanion extends UpdateCompanion<AppSettingsTableData> {
final Value<int> id;
final Value<int?> activeDroneProfileId;
final Value<int?> currentMissionId;
final Value<String?> connectionDeviceAddress;
final Value<bool> connectionAutoConnect;
const AppSettingsTableCompanion({
this.id = const Value.absent(),
this.activeDroneProfileId = const Value.absent(),
this.currentMissionId = const Value.absent(),
this.connectionDeviceAddress = const Value.absent(),
this.connectionAutoConnect = const Value.absent(),
});
AppSettingsTableCompanion.insert({
this.id = const Value.absent(),
this.activeDroneProfileId = const Value.absent(),
this.currentMissionId = const Value.absent(),
this.connectionDeviceAddress = const Value.absent(),
this.connectionAutoConnect = const Value.absent(),
});
static Insertable<AppSettingsTableData> custom({
Expression<int>? id,
Expression<int>? activeDroneProfileId,
Expression<int>? currentMissionId,
Expression<String>? connectionDeviceAddress,
Expression<bool>? connectionAutoConnect,
}) {
return RawValuesInsertable({
if (id != null) 'id': id,
if (activeDroneProfileId != null)
'active_drone_profile_id': activeDroneProfileId,
if (currentMissionId != null) 'current_mission_id': currentMissionId,
if (connectionDeviceAddress != null)
'connection_device_address': connectionDeviceAddress,
if (connectionAutoConnect != null)
'connection_auto_connect': connectionAutoConnect,
});
}
@@ -1526,11 +1644,17 @@ class AppSettingsTableCompanion extends UpdateCompanion<AppSettingsTableData> {
Value<int>? id,
Value<int?>? activeDroneProfileId,
Value<int?>? currentMissionId,
Value<String?>? connectionDeviceAddress,
Value<bool>? connectionAutoConnect,
}) {
return AppSettingsTableCompanion(
id: id ?? this.id,
activeDroneProfileId: activeDroneProfileId ?? this.activeDroneProfileId,
currentMissionId: currentMissionId ?? this.currentMissionId,
connectionDeviceAddress:
connectionDeviceAddress ?? this.connectionDeviceAddress,
connectionAutoConnect:
connectionAutoConnect ?? this.connectionAutoConnect,
);
}
@@ -1548,6 +1672,16 @@ class AppSettingsTableCompanion extends UpdateCompanion<AppSettingsTableData> {
if (currentMissionId.present) {
map['current_mission_id'] = Variable<int>(currentMissionId.value);
}
if (connectionDeviceAddress.present) {
map['connection_device_address'] = Variable<String>(
connectionDeviceAddress.value,
);
}
if (connectionAutoConnect.present) {
map['connection_auto_connect'] = Variable<bool>(
connectionAutoConnect.value,
);
}
return map;
}
@@ -1556,7 +1690,9 @@ class AppSettingsTableCompanion extends UpdateCompanion<AppSettingsTableData> {
return (StringBuffer('AppSettingsTableCompanion(')
..write('id: $id, ')
..write('activeDroneProfileId: $activeDroneProfileId, ')
..write('currentMissionId: $currentMissionId')
..write('currentMissionId: $currentMissionId, ')
..write('connectionDeviceAddress: $connectionDeviceAddress, ')
..write('connectionAutoConnect: $connectionAutoConnect')
..write(')'))
.toString();
}
@@ -2195,12 +2331,16 @@ typedef $$AppSettingsTableTableCreateCompanionBuilder =
Value<int> id,
Value<int?> activeDroneProfileId,
Value<int?> currentMissionId,
Value<String?> connectionDeviceAddress,
Value<bool> connectionAutoConnect,
});
typedef $$AppSettingsTableTableUpdateCompanionBuilder =
AppSettingsTableCompanion Function({
Value<int> id,
Value<int?> activeDroneProfileId,
Value<int?> currentMissionId,
Value<String?> connectionDeviceAddress,
Value<bool> connectionAutoConnect,
});
class $$AppSettingsTableTableFilterComposer
@@ -2226,6 +2366,16 @@ class $$AppSettingsTableTableFilterComposer
column: $table.currentMissionId,
builder: (column) => ColumnFilters(column),
);
ColumnFilters<String> get connectionDeviceAddress => $composableBuilder(
column: $table.connectionDeviceAddress,
builder: (column) => ColumnFilters(column),
);
ColumnFilters<bool> get connectionAutoConnect => $composableBuilder(
column: $table.connectionAutoConnect,
builder: (column) => ColumnFilters(column),
);
}
class $$AppSettingsTableTableOrderingComposer
@@ -2251,6 +2401,16 @@ class $$AppSettingsTableTableOrderingComposer
column: $table.currentMissionId,
builder: (column) => ColumnOrderings(column),
);
ColumnOrderings<String> get connectionDeviceAddress => $composableBuilder(
column: $table.connectionDeviceAddress,
builder: (column) => ColumnOrderings(column),
);
ColumnOrderings<bool> get connectionAutoConnect => $composableBuilder(
column: $table.connectionAutoConnect,
builder: (column) => ColumnOrderings(column),
);
}
class $$AppSettingsTableTableAnnotationComposer
@@ -2274,6 +2434,16 @@ class $$AppSettingsTableTableAnnotationComposer
column: $table.currentMissionId,
builder: (column) => column,
);
GeneratedColumn<String> get connectionDeviceAddress => $composableBuilder(
column: $table.connectionDeviceAddress,
builder: (column) => column,
);
GeneratedColumn<bool> get connectionAutoConnect => $composableBuilder(
column: $table.connectionAutoConnect,
builder: (column) => column,
);
}
class $$AppSettingsTableTableTableManager
@@ -2316,20 +2486,28 @@ class $$AppSettingsTableTableTableManager
Value<int> id = const Value.absent(),
Value<int?> activeDroneProfileId = const Value.absent(),
Value<int?> currentMissionId = const Value.absent(),
Value<String?> connectionDeviceAddress = const Value.absent(),
Value<bool> connectionAutoConnect = const Value.absent(),
}) => AppSettingsTableCompanion(
id: id,
activeDroneProfileId: activeDroneProfileId,
currentMissionId: currentMissionId,
connectionDeviceAddress: connectionDeviceAddress,
connectionAutoConnect: connectionAutoConnect,
),
createCompanionCallback:
({
Value<int> id = const Value.absent(),
Value<int?> activeDroneProfileId = const Value.absent(),
Value<int?> currentMissionId = const Value.absent(),
Value<String?> connectionDeviceAddress = const Value.absent(),
Value<bool> connectionAutoConnect = const Value.absent(),
}) => AppSettingsTableCompanion.insert(
id: id,
activeDroneProfileId: activeDroneProfileId,
currentMissionId: currentMissionId,
connectionDeviceAddress: connectionDeviceAddress,
connectionAutoConnect: connectionAutoConnect,
),
withReferenceMapper: (p0) => p0
.map((e) => (e.readTable(table), BaseReferences(db, table, e)))
@@ -12,13 +12,21 @@ class AppSettingsRepository {
static const _rowId = 0;
Future<({int? activeDroneProfileId, int? currentMissionId})> load() async {
Future<
({
int? activeDroneProfileId,
int? currentMissionId,
String? connectionDeviceAddress,
bool connectionAutoConnect,
})> load() async {
final row = await (_db.select(_db.appSettingsTable)
..where((s) => s.id.equals(_rowId)))
.getSingleOrNull();
return (
activeDroneProfileId: row?.activeDroneProfileId,
currentMissionId: row?.currentMissionId,
connectionDeviceAddress: row?.connectionDeviceAddress,
connectionAutoConnect: row?.connectionAutoConnect ?? true,
);
}
@@ -36,6 +44,26 @@ class AppSettingsRepository {
));
}
/// Gemerkte Bluetooth-MAC-Adresse (Doku Kommunikationsschicht 3.2a), oder
/// null um die Erinnerung zu loeschen (z.B. nach "Geraet entkoppeln").
Future<String?> loadConnectionDeviceAddress() async =>
(await load()).connectionDeviceAddress;
Future<void> setConnectionDeviceAddress(String? address) async {
await _upsert(AppSettingsTableCompanion(
id: const Value(_rowId),
connectionDeviceAddress: Value(address),
));
}
/// "Beim Wechsel in Fly automatisch verbinden" (Doku 7), Standard an.
Future<void> setConnectionAutoConnect(bool value) async {
await _upsert(AppSettingsTableCompanion(
id: const Value(_rowId),
connectionAutoConnect: Value(value),
));
}
Future<void> _upsert(AppSettingsTableCompanion companion) async {
await _db.into(_db.appSettingsTable).insertOnConflictUpdate(companion);
}
@@ -0,0 +1,257 @@
import 'dart:async';
import 'dart:io';
import 'dart:typed_data';
import 'package:flutter_classic_bluetooth/flutter_classic_bluetooth.dart';
import 'package:permission_handler/permission_handler.dart';
import '../link_transport.dart';
import 'bluetooth_device_info.dart';
import 'bluetooth_device_selector.dart';
/// Bluetooth-Classic-(SPP)-Transport (Doku Kommunikationsschicht Abschnitt 3):
/// verbindet sich mit dem RadioMaster-Pocket-Backpack (mLRS Tx) ueber RFCOMM.
/// Setzt den in Abschnitt 3 beschriebenen Verbindungsablauf um:
///
/// 1. Bluetooth-Adapter an? (kann ab Android 12 nicht mehr programmatisch
/// eingeschaltet werden - meldet stattdessen [LinkErrorReason.bluetoothOff]).
/// 2. Geraet bestimmen, ohne zu scannen: gemerkte MAC-Adresse, sonst erstes
/// gekoppeltes Geraet mit "mLRS"-Praefix, sonst
/// [LinkErrorReason.deviceSelectionRequired] (Oberflaeche zeigt
/// Auswahlliste, siehe [listBondedDevices]).
/// 3. Laufende Discovery abbrechen, falls eine laeuft.
/// 4. Socket oeffnen; danach uebernimmt der eingebaute Reconnect-Mechanismus
/// des Plugins (exponentielles Backoff 1s -> 30s) das Wiederverbinden bei
/// Verbindungsabbruch.
class BluetoothClassicTransport implements LinkTransport {
BluetoothClassicTransport({
required Future<String?> Function() getRememberedAddress,
required Future<void> Function(String address) rememberAddress,
FlutterClassicBluetooth? bluetooth,
}) : _getRememberedAddress = getRememberedAddress,
_rememberAddress = rememberAddress,
_bluetooth = bluetooth ?? FlutterClassicBluetooth();
final Future<String?> Function() _getRememberedAddress;
final Future<void> Function(String address) _rememberAddress;
final FlutterClassicBluetooth _bluetooth;
final _incomingController = StreamController<Uint8List>.broadcast();
final _stateController = StreamController<LinkState>.broadcast();
BtcReconnectingConnection? _link;
StreamSubscription<Uint8List>? _linkDataSub;
StreamSubscription<BtcReconnectState>? _linkStateSub;
BluetoothDeviceInfo? _connectedDevice;
LinkState _state = LinkState.disconnected;
LinkErrorReason? _lastErrorReason;
String? _lastErrorMessage;
@override
Stream<Uint8List> get incoming => _incomingController.stream;
@override
Stream<LinkState> get state => _stateController.stream;
@override
LinkErrorReason? get lastErrorReason => _lastErrorReason;
@override
String? get lastErrorMessage => _lastErrorMessage;
/// Das aktuell verbundene (oder zuletzt verbundene) Geraet, falls bekannt.
BluetoothDeviceInfo? get connectedDevice => _connectedDevice;
/// Gekoppelte Geraete fuer die manuelle Auswahlliste (Doku 3.2c). Scannt
/// nicht - liefert nur bereits gekoppelte Geraete, das ist sofort verfuegbar.
Future<List<BluetoothDeviceInfo>> listBondedDevices() async {
final devices = await _bluetooth.getPairedDevices();
return [
for (final d in devices)
BluetoothDeviceInfo(address: d.address, name: d.name ?? d.alias),
];
}
@override
Future<void> connect() async {
_lastErrorReason = null;
_lastErrorMessage = null;
_setState(LinkState.connecting);
try {
if (!await _bluetooth.isEnabled()) {
_fail(
LinkErrorReason.bluetoothOff,
'Bluetooth-Adapter ist aus. Kann ab Android 12 nicht mehr aus der '
'App heraus eingeschaltet werden.',
);
}
final permissionOutcome = await _ensureConnectPermission();
if (permissionOutcome == _PermissionOutcome.deniedPermanently) {
_fail(
LinkErrorReason.permissionDeniedPermanently,
'"Geraete in der Naehe"-Berechtigung dauerhaft abgelehnt.',
);
}
if (permissionOutcome == _PermissionOutcome.denied) {
_fail(
LinkErrorReason.permissionDenied,
'"Geraete in der Naehe"-Berechtigung abgelehnt.',
);
}
final bonded = await listBondedDevices();
final remembered = await _getRememberedAddress();
final selection = selectBluetoothDevice(
bondedDevices: bonded,
rememberedAddress: remembered,
);
if (selection is BluetoothDeviceSelectionRequired) {
_fail(
LinkErrorReason.deviceSelectionRequired,
'Kein gekoppeltes mLRS-Geraet automatisch bestimmbar '
'(${bonded.length} gekoppelte Geraete zur Auswahl).',
);
}
final device = (selection as BluetoothDeviceResolved).device;
if (device.address != remembered) {
await _rememberAddress(device.address);
}
try {
if (await _bluetooth.isDiscovering()) {
await _bluetooth.stopDiscovery();
}
} catch (_) {
// Discovery laeuft ohnehin nicht (mehr) - unkritisch (Doku 3.3).
}
_connectedDevice = device;
final link = _bluetooth.connectWithReconnect(
address: device.address,
uuid: BtcUuid.spp,
);
_link = link;
_linkStateSub = link.state.listen(_onReconnectStateChanged);
_linkDataSub = link.input.listen(_incomingController.add);
} on BtcException catch (e) {
_fail(_reasonForException(e), e.message);
}
}
@override
Future<void> disconnect() async {
await _linkDataSub?.cancel();
_linkDataSub = null;
await _linkStateSub?.cancel();
_linkStateSub = null;
await _link?.close();
_link = null;
_setState(LinkState.disconnected);
}
@override
Future<void> send(Uint8List data) async {
final link = _link;
if (link == null) {
throw const LinkConnectException(
LinkErrorReason.connectionFailed,
'send() ohne aktive Verbindung aufgerufen.',
);
}
await link.send(data);
}
@override
void dispose() {
_linkDataSub?.cancel();
_linkStateSub?.cancel();
unawaited(_link?.close());
_link = null;
_incomingController.close();
_stateController.close();
}
void _onReconnectStateChanged(BtcReconnectState s) {
switch (s) {
case BtcReconnectState.connecting:
case BtcReconnectState.reconnecting:
_setState(LinkState.connecting);
case BtcReconnectState.connected:
_lastErrorReason = null;
_lastErrorMessage = null;
_setState(LinkState.connected);
case BtcReconnectState.closed:
_setState(LinkState.disconnected);
case BtcReconnectState.failed:
// Nur erreichbar, wenn BtcReconnectPolicy.maxAttempts gesetzt waere;
// die Standardpolicy (Doku 3, Robustheit: 1s->30s, unbegrenzt) haelt
// stattdessen einfach LinkState.connecting, bis es klappt oder
// disconnect() gerufen wird. Trotzdem hier behandelt, falls die
// Policy kuenftig eine Obergrenze bekommt.
_setError(
LinkErrorReason.connectionFailed,
_link?.lastError?.toString() ?? 'Verbindung fehlgeschlagen.',
);
}
}
/// Setzt Fehlergrund/-nachricht und meldet [LinkState.error] ueber den
/// Zustands-Stream, ohne selbst zu werfen - fuer asynchrone Beobachter
/// (z.B. den Reconnect-Zustands-Listener).
void _setError(LinkErrorReason reason, String message) {
_lastErrorReason = reason;
_lastErrorMessage = message;
_setState(LinkState.error);
}
/// Wie [_setError], wirft danach zusaetzlich eine [LinkConnectException]
/// mit demselben Grund - siehe dortige Doku. Nur fuer Fehler, die
/// synchron innerhalb von [connect] erkannt werden.
Never _fail(LinkErrorReason reason, String message) {
_setError(reason, message);
throw LinkConnectException(reason, message);
}
void _setState(LinkState next) {
_state = next;
if (!_stateController.isClosed) _stateController.add(next);
}
LinkState get currentState => _state;
Future<_PermissionOutcome> _ensureConnectPermission() async {
// MVP ist auf Android beschraenkt (Doku CLAUDE.md); auf anderen
// Plattformen (z.B. Windows-Desktop waehrend der UI-Entwicklung) gibt es
// keine "Geraete in der Naehe"-Laufzeitberechtigung zu pruefen.
if (!Platform.isAndroid) return _PermissionOutcome.granted;
var status = await Permission.bluetoothConnect.status;
if (status.isGranted) return _PermissionOutcome.granted;
if (status.isPermanentlyDenied) {
return _PermissionOutcome.deniedPermanently;
}
status = await Permission.bluetoothConnect.request();
if (status.isGranted) return _PermissionOutcome.granted;
if (status.isPermanentlyDenied) {
return _PermissionOutcome.deniedPermanently;
}
return _PermissionOutcome.denied;
}
LinkErrorReason _reasonForException(BtcException e) {
if (e is BtcPermissionException) return LinkErrorReason.permissionDenied;
if (e is BtcDisabledException) return LinkErrorReason.bluetoothOff;
if (e is BtcConnectionException || e is BtcTimeoutException) {
return LinkErrorReason.connectionFailed;
}
return LinkErrorReason.unknown;
}
}
enum _PermissionOutcome { granted, denied, deniedPermanently }
@@ -0,0 +1,23 @@
/// DMC-eigenes, plugin-unabhaengiges Geraetemodell fuer gekoppelte
/// Bluetooth-Classic-Geraete (Doku Kommunikationsschicht Abschnitt 3.2).
/// Haelt `flutter_classic_bluetooth`-Typen aus der Oberflaeche heraus, damit
/// nur transport/bluetooth/ das Paket kennen muss.
class BluetoothDeviceInfo {
const BluetoothDeviceInfo({required this.address, this.name});
final String address;
final String? name;
String get displayName => name ?? address;
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is BluetoothDeviceInfo && other.address == address;
@override
int get hashCode => address.hashCode;
@override
String toString() => 'BluetoothDeviceInfo($address, $name)';
}
@@ -0,0 +1,63 @@
import 'bluetooth_device_info.dart';
/// Praefix, an dem der mLRS-Backpack in seinem Bluetooth-Namen erkannt wird
/// (Doku Kommunikationsschicht Abschnitt 3.2).
const mLrsDeviceNamePrefix = 'mLRS';
/// Ergebnis der automatischen Geraeteauswahl (Doku Abschnitt 3.2): entweder
/// ein eindeutig bestimmtes Geraet, oder die Angabe, dass die Oberflaeche
/// eine Auswahlliste zeigen muss.
sealed class BluetoothDeviceSelection {
const BluetoothDeviceSelection();
}
class BluetoothDeviceResolved extends BluetoothDeviceSelection {
const BluetoothDeviceResolved(this.device);
final BluetoothDeviceInfo device;
}
class BluetoothDeviceSelectionRequired extends BluetoothDeviceSelection {
const BluetoothDeviceSelectionRequired(this.candidates);
/// Gekoppelte Geraete, aus denen die Oberflaeche eine Auswahl anbieten
/// muss (Doku 3.2c).
final List<BluetoothDeviceInfo> candidates;
}
/// Reine Auswahllogik, unabhaengig vom Bluetooth-Plugin und ohne
/// Plattform-Channel - daher ohne Hardware testbar.
///
/// Reihenfolge (Doku Abschnitt 3.2):
/// a) gemerkte MAC-Adresse aus den Einstellungen (falls noch gekoppelt)
/// b) erstes gekoppeltes Geraet, dessen Name mit "mLRS" beginnt
/// c) sonst: Auswahlliste anzeigen
BluetoothDeviceSelection selectBluetoothDevice({
required List<BluetoothDeviceInfo> bondedDevices,
String? rememberedAddress,
}) {
if (rememberedAddress != null) {
final remembered = _firstWhereOrNull(
bondedDevices,
(d) => d.address == rememberedAddress,
);
if (remembered != null) return BluetoothDeviceResolved(remembered);
}
final mLrsDevice = _firstWhereOrNull(
bondedDevices,
(d) => (d.name ?? '').startsWith(mLrsDeviceNamePrefix),
);
if (mLrsDevice != null) return BluetoothDeviceResolved(mLrsDevice);
return BluetoothDeviceSelectionRequired(bondedDevices);
}
BluetoothDeviceInfo? _firstWhereOrNull(
List<BluetoothDeviceInfo> devices,
bool Function(BluetoothDeviceInfo) test,
) {
for (final device in devices) {
if (test(device)) return device;
}
return null;
}
@@ -0,0 +1,20 @@
import 'package:app_settings/app_settings.dart';
import 'package:permission_handler/permission_handler.dart' as ph;
/// Verlinkt zu den System-/App-Einstellungen fuer die drei Bluetooth-
/// Hinweisfaelle aus Doku Kommunikationsschicht Abschnitt 3/7 - haelt die
/// Wahl des Settings-Pakets aus ui/ heraus (Doku: kein Bluetooth-/
/// Settings-Import ausserhalb von transport/).
abstract final class BluetoothSystemSettings {
/// "Bluetooth an?" negativ (Doku 3.1) bzw. der "Gerät koppeln"-Knopf
/// (Doku 7): oeffnet die System-Bluetooth-Einstellungen, in denen der
/// Nutzer sowohl den Adapter einschalten als auch ein neues Geraet koppeln
/// kann.
static Future<void> openBluetoothSettings() =>
AppSettings.openAppSettings(type: AppSettingsType.bluetooth);
/// Berechtigung dauerhaft abgelehnt (Doku 3, Berechtigungen): oeffnet die
/// App-Einstellungen, in denen der Nutzer die Berechtigung manuell erteilen
/// kann, da Android den Dialog kein zweites Mal zeigt.
static Future<void> openAppSettings() => ph.openAppSettings();
}
+79
View File
@@ -0,0 +1,79 @@
import 'dart:typed_data';
/// Transportzustand, protokollneutral (Doku Kommunikationsschicht Abschnitt 2).
enum LinkState { disconnected, connecting, connected, error }
/// Grund eines [LinkState.error] (Doku Abschnitt 3, Robustheit/Abnahme):
/// Bluetooth aus und Berechtigung abgelehnt brauchen jeweils einen eigenen,
/// erklaerenden Hinweis in der Oberflaeche statt eines generischen
/// "Verbindung fehlgeschlagen".
enum LinkErrorReason {
/// Bluetooth-Adapter ist aus. Kann ab Android 12 nicht mehr programmatisch
/// eingeschaltet werden - die Oberflaeche muss zu den Systemeinstellungen
/// verlinken.
bluetoothOff,
/// Berechtigung wurde abgelehnt, darf aber erneut angefragt werden.
permissionDenied,
/// Berechtigung wurde dauerhaft abgelehnt ("nicht mehr fragen"). Android
/// zeigt den Dialog kein zweites Mal - die Oberflaeche muss zu den
/// App-Einstellungen verlinken.
permissionDeniedPermanently,
/// Kein gekoppeltes Zielgeraet konnte automatisch bestimmt werden (weder
/// gemerkte MAC-Adresse noch ein Geraet mit "mLRS"-Praefix). Die
/// Oberflaeche muss eine Auswahlliste der gekoppelten Geraete anbieten.
deviceSelectionRequired,
/// Verbindungsaufbau zum bestimmten Geraet ist fehlgeschlagen oder wurde
/// getrennt.
connectionFailed,
/// Sonstiger, nicht naeher klassifizierter Fehler.
unknown,
}
/// Protokollneutrale Transportschnittstelle (Doku Kommunikationsschicht
/// Abschnitt 2). Alles oberhalb (MSP-Encoder/-Decoder) programmiert
/// ausschliesslich gegen diese Schnittstelle - kein Import von Bluetooth-
/// oder Netzwerkpaketen ausserhalb von transport/.
///
/// Implementierungen: BluetoothClassicTransport (MVP), UdpTransport
/// (spaeter, Doku Abschnitt 6), LoopbackTransport (Tests/Entwicklung ohne
/// Hardware).
abstract class LinkTransport {
Stream<Uint8List> get incoming;
Stream<LinkState> get state;
/// Grund des zuletzt gemeldeten [LinkState.error], oder null solange kein
/// Fehler aufgetreten ist bzw. nach erfolgreicher Verbindung.
LinkErrorReason? get lastErrorReason;
/// Freitext zum zuletzt gemeldeten Fehler, fuer Diagnose/Verbindungsprotokoll
/// (Doku Abschnitt 7). Nicht fuer die direkte Nutzeranzeige gedacht - dafuer
/// dient [lastErrorReason].
String? get lastErrorMessage;
Future<void> send(Uint8List data);
Future<void> connect();
Future<void> disconnect();
void dispose();
}
/// Wird von [LinkTransport.connect] geworfen, wenn der Verbindungsaufbau
/// vorab (also bevor ueberhaupt eine Socket-Verbindung versucht wird)
/// erkennbar scheitert - z.B. Bluetooth aus, Berechtigung fehlt, oder kein
/// Geraet automatisch bestimmbar. Der gleiche [reason] wird zeitgleich ueber
/// [LinkTransport.state]/[LinkTransport.lastErrorReason] gemeldet, damit
/// sowohl der aufrufende Code (durch Warten auf [LinkTransport.connect])
/// als auch reine Beobachter der Zustands-Streams reagieren koennen.
class LinkConnectException implements Exception {
const LinkConnectException(this.reason, this.message);
final LinkErrorReason reason;
final String message;
@override
String toString() => 'LinkConnectException($reason): $message';
}
@@ -0,0 +1,76 @@
import 'dart:async';
import 'dart:typed_data';
import '../link_transport.dart';
/// Transport ohne echte Gegenstelle (Doku Kommunikationsschicht Abschnitt 2/9):
/// erlaubt UI- und MSP-Entwicklung ohne Bluetooth-Hardware oder SITL. Bytes,
/// die die Gegenstelle "gesendet" haette, werden ueber [feed] eingespeist;
/// [sentData] sammelt, was die App darueber gesendet hat, damit Tests das
/// pruefen koennen.
class LoopbackTransport implements LinkTransport {
final _incomingController = StreamController<Uint8List>.broadcast();
final _stateController = StreamController<LinkState>.broadcast();
LinkState _state = LinkState.disconnected;
LinkErrorReason? _lastErrorReason;
String? _lastErrorMessage;
/// Alle bisher per [send] geschickten Bytes, in Reihenfolge.
final List<Uint8List> sentData = [];
@override
Stream<Uint8List> get incoming => _incomingController.stream;
@override
Stream<LinkState> get state => _stateController.stream;
@override
LinkErrorReason? get lastErrorReason => _lastErrorReason;
@override
String? get lastErrorMessage => _lastErrorMessage;
/// Simuliert eingehende Bytes von der Gegenstelle (z.B. aufgezeichnete
/// MSP-Antworten, Doku Abschnitt 9).
void feed(Uint8List data) => _incomingController.add(data);
/// Simuliert einen Verbindungsfehler, z.B. um Fehlerbehandlung in der
/// Oberflaeche ohne Hardware zu testen.
void simulateError(LinkErrorReason reason, [String? message]) {
_lastErrorReason = reason;
_lastErrorMessage = message;
_setState(LinkState.error);
}
@override
Future<void> connect() async {
_lastErrorReason = null;
_lastErrorMessage = null;
_setState(LinkState.connecting);
_setState(LinkState.connected);
}
@override
Future<void> disconnect() async {
_setState(LinkState.disconnected);
}
@override
Future<void> send(Uint8List data) async {
sentData.add(data);
}
@override
void dispose() {
_incomingController.close();
_stateController.close();
}
void _setState(LinkState next) {
_state = next;
if (!_stateController.isClosed) _stateController.add(next);
}
LinkState get currentState => _state;
}
@@ -1,16 +1,20 @@
import '../../domain/waypoint/flat_waypoint_list.dart';
import '../flight_controller_link.dart';
import '../link_transport.dart';
/// iNAV-Anbindung via MSP (Architektur-Doku 3.1). Eigenstaendiger Adapter,
/// da kein fertiges Dart-Paket fuer MSP verfuegbar ist (Doku 4.19).
///
/// Kennt nur die protokollneutrale [LinkTransport]-Abstraktion (Doku
/// Kommunikationsschicht Abschnitt 2) - ob darunter Bluetooth Classic (MVP)
/// oder spaeter WLAN/UDP steckt, ist fuer diese Klasse unsichtbar.
///
/// Protokoll-Implementierung (Framing, MSP_SET_WP, MSP_NAV_STATUS, ...) ist
/// noch offen - dies ist bewusst nur das Geruest fuer die Abstraktion.
class MspFlightControllerLink implements FlightControllerLink {
MspFlightControllerLink({required this.host, required this.port});
MspFlightControllerLink({required this.transport});
final String host;
final int port;
final LinkTransport transport;
@override
FcCapabilities get capabilities => throw UnimplementedError();
@@ -0,0 +1,110 @@
import 'dart:async';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../transport/bluetooth/bluetooth_classic_transport.dart';
import '../../transport/bluetooth/bluetooth_device_info.dart';
import '../../transport/link_transport.dart';
import 'database_provider.dart';
/// App-lebenslanger Bluetooth-Classic-Transport (Doku Kommunikationsschicht
/// Abschnitt 3), verdrahtet gegen die gemerkte Geraete-MAC in den
/// Einstellungen. Bewusst nicht autoDispose: die Verbindung soll bestehen
/// bleiben, waehrend zwischen Fly- und Plan-Modus gewechselt wird, und erst
/// beim Verlassen des Fly-Modus explizit getrennt werden (Doku 3,
/// Robustheit: "Verbindung beim Verlassen des Fly-Modus sauber schliessen").
final bluetoothTransportProvider = Provider<BluetoothClassicTransport>((ref) {
final settingsRepo = ref.watch(appSettingsRepositoryProvider);
final transport = BluetoothClassicTransport(
getRememberedAddress: settingsRepo.loadConnectionDeviceAddress,
rememberAddress: settingsRepo.setConnectionDeviceAddress,
);
ref.onDispose(transport.dispose);
return transport;
});
/// Reaktiver [LinkState] des Bluetooth-Transports fuer die Oberflaeche
/// (Verbindungsindikator, Abnahmekriterium: "Zustand jederzeit sichtbar").
final linkStateProvider = StreamProvider<LinkState>((ref) {
final transport = ref.watch(bluetoothTransportProvider);
return transport.state;
});
/// Grund des zuletzt gemeldeten [LinkState.error] (Doku 3, Robustheit: "Grund
/// mitfuehren und in der Oberflaeche anzeigen"). Wird bei jedem neuen
/// LinkState neu ausgelesen, da der Transport ihn synchron mitfuehrt.
final linkErrorReasonProvider = Provider<LinkErrorReason?>((ref) {
ref.watch(linkStateProvider);
return ref.watch(bluetoothTransportProvider).lastErrorReason;
});
/// Gekoppelte Geraete fuer die manuelle Auswahlliste (Doku 3.2c). Wird bei
/// jedem [LinkState]-Wechsel neu geladen, damit eine frisch angeschlossene
/// Kopplung (ueber den "Geraet koppeln"-Knopf) ohne expliziten Reload
/// auftaucht.
final bondedDevicesProvider = FutureProvider<List<BluetoothDeviceInfo>>((ref) {
ref.watch(linkStateProvider);
return ref.watch(bluetoothTransportProvider).listBondedDevices();
});
/// Ein Eintrag im Verbindungsprotokoll (Doku 7: "einfache Textansicht der
/// letzten Ereignisse ... spart im Feld viel Ratearbeit").
class ConnectionLogEntry {
const ConnectionLogEntry(this.timestamp, this.message);
final DateTime timestamp;
final String message;
}
/// Verbindungsprotokoll + Paketzaehler (Doku 7). Haengt sich an den
/// LinkState- und den Rohdaten-Stream des Transports, damit auch ohne
/// MSP-Decoder schon sichtbar ist, dass ueberhaupt Bytes ankommen.
class ConnectionLogNotifier extends Notifier<List<ConnectionLogEntry>> {
static const _maxEntries = 50;
StreamSubscription<LinkState>? _stateSub;
StreamSubscription<void>? _dataSub;
int _packetCount = 0;
int get packetCount => _packetCount;
@override
List<ConnectionLogEntry> build() {
final transport = ref.watch(bluetoothTransportProvider);
_stateSub?.cancel();
_dataSub?.cancel();
_packetCount = 0;
_stateSub = transport.state.listen(_onStateChanged);
_dataSub = transport.incoming.listen((_) => _packetCount++);
ref.onDispose(() {
_stateSub?.cancel();
_dataSub?.cancel();
});
return const [];
}
void _onStateChanged(LinkState linkState) {
final transport = ref.read(bluetoothTransportProvider);
final message = switch (linkState) {
LinkState.disconnected => 'Getrennt',
LinkState.connecting => 'Verbinde ...',
LinkState.connected =>
'Verbunden${transport.connectedDevice != null ? ' (${transport.connectedDevice!.displayName})' : ''}',
LinkState.error =>
'Fehler: ${transport.lastErrorMessage ?? transport.lastErrorReason?.name ?? 'unbekannt'}',
};
_append(message);
}
void _append(String message) {
final entry = ConnectionLogEntry(DateTime.now(), message);
final next = [...state, entry];
state = next.length > _maxEntries
? next.sublist(next.length - _maxEntries)
: next;
}
}
final connectionLogProvider =
NotifierProvider<ConnectionLogNotifier, List<ConnectionLogEntry>>(
ConnectionLogNotifier.new);
@@ -0,0 +1,12 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'database_provider.dart';
/// Verbindungseinstellungen (Doku Kommunikationsschicht Abschnitt 7):
/// gemerkte Bluetooth-Geraete-MAC und "beim Wechsel in Fly automatisch
/// verbinden". Eigener Provider statt Wiederverwendung von
/// [appSettingsRepositoryProvider] direkt, damit die Einstellungs-UI nach
/// einem Schreibzugriff gezielt neu laden kann (`ref.invalidate`).
final connectionSettingsProvider = FutureProvider((ref) {
return ref.watch(appSettingsRepositoryProvider).load();
});
@@ -1,15 +1,277 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
/// Drohnen-Profile: FC-Typ, Flugleistungsdaten, Standardprofil T1 Ranger
/// (Architektur-Doku 3.5).
import '../../../transport/bluetooth/bluetooth_device_info.dart';
import '../../../transport/bluetooth/bluetooth_system_settings.dart';
import '../../../transport/link_transport.dart';
import '../../dmc_colors.dart';
import '../../providers/bluetooth_connection_provider.dart';
import '../../providers/connection_settings_provider.dart';
import '../../providers/database_provider.dart';
/// Einstellungen: Drohnen-Profile (Doku 3.5/7.3, noch nicht hier
/// angebunden - siehe DroneProfileEditor) und Verbindung (Doku
/// Kommunikationsschicht Abschnitt 7).
class SettingsScreen extends StatelessWidget {
const SettingsScreen({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Drone Profiles')),
body: const Center(child: Text('Settings')),
appBar: AppBar(title: const Text('Settings')),
body: ListView(
padding: const EdgeInsets.all(16),
children: const [_ConnectionSection()],
),
);
}
}
class _ConnectionSection extends ConsumerWidget {
const _ConnectionSection();
@override
Widget build(BuildContext context, WidgetRef ref) {
final linkState = ref.watch(linkStateProvider).value;
final errorReason = ref.watch(linkErrorReasonProvider);
final bondedDevices = ref.watch(bondedDevicesProvider);
final connectionSettings = ref.watch(connectionSettingsProvider).value;
final log = ref.watch(connectionLogProvider);
final packetCount = ref.watch(connectionLogProvider.notifier).packetCount;
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text('Verbindung', style: Theme.of(context).textTheme.titleMedium),
const Spacer(),
_StatePill(state: linkState),
],
),
const SizedBox(height: 12),
const Text('Art: Bluetooth'),
const SizedBox(height: 4),
const Text(
'WLAN und USB folgen spaeter (Doku Kommunikationsschicht 6/7).',
style: TextStyle(fontSize: 12, color: Colors.grey),
),
if (errorReason != null) ...[
const SizedBox(height: 12),
_ErrorHint(reason: errorReason),
],
const SizedBox(height: 16),
Row(
children: [
ElevatedButton(
onPressed: () => _connect(ref),
child: const Text('Verbinden'),
),
const SizedBox(width: 8),
OutlinedButton(
onPressed: () => ref.read(bluetoothTransportProvider).disconnect(),
child: const Text('Trennen'),
),
const SizedBox(width: 8),
TextButton(
onPressed: BluetoothSystemSettings.openBluetoothSettings,
child: const Text('Geraet koppeln'),
),
],
),
const SizedBox(height: 16),
SwitchListTile(
contentPadding: EdgeInsets.zero,
title: const Text('Beim Wechsel in Fly automatisch verbinden'),
value: connectionSettings?.connectionAutoConnect ?? true,
onChanged: (value) async {
await ref
.read(appSettingsRepositoryProvider)
.setConnectionAutoConnect(value);
ref.invalidate(connectionSettingsProvider);
},
),
const SizedBox(height: 8),
Text('Gekoppelte Geraete', style: Theme.of(context).textTheme.titleSmall),
const SizedBox(height: 4),
bondedDevices.when(
data: (devices) => devices.isEmpty
? const Text(
'Keine gekoppelten Geraete. Ueber "Geraet koppeln" '
'zunaechst einmalig ueber den Systemdialog koppeln.',
style: TextStyle(fontSize: 12, color: Colors.grey),
)
: RadioGroup<String>(
groupValue: connectionSettings?.connectionDeviceAddress,
onChanged: (address) =>
_selectDevice(ref, address, devices),
child: Column(
children: [
for (final device in devices)
RadioListTile<String>(
contentPadding: EdgeInsets.zero,
dense: true,
value: device.address,
title: Text(device.displayName),
subtitle: Text(
device.address,
style: const TextStyle(fontSize: 11),
),
),
],
),
),
loading: () => const Padding(
padding: EdgeInsets.symmetric(vertical: 8),
child: LinearProgressIndicator(),
),
error: (e, _) => Text(
'Gekoppelte Geraete konnten nicht gelesen werden: $e',
style: const TextStyle(fontSize: 12, color: Colors.grey),
),
),
const SizedBox(height: 16),
Text(
'Verbindungsprotokoll ($packetCount Datenpakete empfangen)',
style: Theme.of(context).textTheme.titleSmall,
),
const SizedBox(height: 4),
SizedBox(
height: 120,
child: log.isEmpty
? const Text(
'Noch keine Ereignisse.',
style: TextStyle(fontSize: 12, color: Colors.grey),
)
: ListView.builder(
reverse: true,
itemCount: log.length,
itemBuilder: (context, index) {
final entry = log[log.length - 1 - index];
return Text(
'${_formatTime(entry.timestamp)} ${entry.message}',
style: const TextStyle(fontSize: 12, fontFamily: 'monospace'),
);
},
),
),
],
),
),
);
}
Future<void> _connect(WidgetRef ref) async {
try {
await ref.read(bluetoothTransportProvider).connect();
} on LinkConnectException {
// Grund/Nachricht sind bereits ueber lastErrorReason/state gemeldet
// (siehe LinkConnectException-Doku) - die Oberflaeche reagiert darauf
// ueber linkErrorReasonProvider/_ErrorHint, hier ist nichts weiter zu tun.
}
}
String _formatTime(DateTime t) =>
'${t.hour.toString().padLeft(2, '0')}:${t.minute.toString().padLeft(2, '0')}:${t.second.toString().padLeft(2, '0')}';
Future<void> _selectDevice(
WidgetRef ref,
String? address,
List<BluetoothDeviceInfo> devices,
) async {
if (address == null) return;
await ref
.read(appSettingsRepositoryProvider)
.setConnectionDeviceAddress(address);
ref.invalidate(connectionSettingsProvider);
try {
await ref.read(bluetoothTransportProvider).connect();
} on LinkConnectException {
// siehe _connect() oben
}
}
}
class _StatePill extends StatelessWidget {
const _StatePill({required this.state});
final LinkState? state;
@override
Widget build(BuildContext context) {
final (label, color) = switch (state) {
LinkState.connected => ('Verbunden', DmcColors.liveGreen),
LinkState.connecting => ('Verbinde ...', DmcColors.pafAmber),
LinkState.error => ('Fehler', DmcColors.warnRed),
LinkState.disconnected || null => ('Getrennt', Colors.grey),
};
return Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: color),
),
child: Text(label, style: TextStyle(color: color, fontSize: 12)),
);
}
}
class _ErrorHint extends StatelessWidget {
const _ErrorHint({required this.reason});
final LinkErrorReason reason;
@override
Widget build(BuildContext context) {
final (text, action) = switch (reason) {
LinkErrorReason.bluetoothOff => (
'Bluetooth ist aus. Bitte in den Systemeinstellungen einschalten.',
_HintAction('Einstellungen', BluetoothSystemSettings.openBluetoothSettings),
),
LinkErrorReason.permissionDenied => (
'"Geraete in der Naehe"-Berechtigung wird benoetigt. Bitte erneut '
'auf "Verbinden" tippen.',
null,
),
LinkErrorReason.permissionDeniedPermanently => (
'"Geraete in der Naehe"-Berechtigung wurde dauerhaft abgelehnt. '
'Bitte in den App-Einstellungen manuell erteilen.',
_HintAction('App-Einstellungen', BluetoothSystemSettings.openAppSettings),
),
LinkErrorReason.deviceSelectionRequired => (
'Kein gekoppeltes mLRS-Geraet gefunden. Bitte unten ein Geraet '
'auswaehlen.',
null,
),
LinkErrorReason.connectionFailed => (
'Verbindung zum Geraet ist fehlgeschlagen.',
null,
),
LinkErrorReason.unknown => ('Unbekannter Verbindungsfehler.', null),
};
return Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: DmcColors.warnRed.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(8),
),
child: Row(
children: [
Expanded(child: Text(text, style: const TextStyle(fontSize: 12))),
if (action != null)
TextButton(onPressed: action.onTap, child: Text(action.label)),
],
),
);
}
}
class _HintAction {
const _HintAction(this.label, this.onTap);
final String label;
final Future<void> Function() onTap;
}