Add Flutter project skeleton (Android-only MVP)

Five-layer architecture per DMC_Architektur_und_Design.md: FlightControllerLink
abstraction (Mock + MSP stub), protocol-neutral mission domain layer, App-Mode
state machine with Ready-to-Fly gate, Drift persistence schema, and placeholder
UI screens wired via go_router. App icon generated from design/Y2_pink_dark.svg.
Verified with flutter analyze and flutter test.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Constantin Leue
2026-07-26 21:28:25 +02:00
co-authored by Claude Sonnet 5
parent 62d655fe8e
commit 346fcc2183
45 changed files with 2884 additions and 0 deletions
+83
View File
@@ -0,0 +1,83 @@
# Drone Mission Control (DMC)
Mobile App zur Missionsplanung und Flugsteuerung für einen Fixed-Wing-Drohnen-Aufbau
(Heewing T1 Ranger, iNAV-Firmware). Wegpunkt-Missionen, Live-Fly-Modus, Point-and-Fly
(PnF)-Einzelziel-Anflug.
**Vollständige Architektur- und Design-Entscheidungen:** [DMC_Architektur_und_Design.md](DMC_Architektur_und_Design.md)
— vor jeder größeren Änderung lesen, insbesondere Abschnitt 4 (Entscheidungen inkl.
Begründung), Abschnitt 5 (verworfene Alternativen) und Abschnitt 6 (offene Fragen).
## Sprachregelung
Konversation und Dokumentation auf Deutsch, Code und UI-Texte auf Englisch.
## Projektstruktur
- `Drone Mission Control.html` — lauffähiger Vanilla-JS/Leaflet-Demonstrator, dient als
Referenz für UI-Verhalten und bereits gelöste Probleme (Wind-Interpolation,
Terrain-Sampling, Fillet-Geometrie, swipe-adjustierbare Wegpunkt-Chips, ...). Bei
Unklarheiten über gewünschtes Verhalten zuerst hier nachsehen, bevor neu entschieden
wird.
- `design/` — Logo-Assets (SVG-Varianten) und Icon-Sketches. Aktuell verwendetes
App-Icon: `design/Y2_pink_dark.svg`.
- `app/` — Flutter-Zielarchitektur (Einzelpaket, kein Melos-Monorepo). Siehe unten.
- `test/` — Altlast/Sandbox, nicht Teil der aktiven Entwicklung.
## Flutter-App (`app/`)
Fünf-Schichten-Architektur (Doku Abschnitt 2):
```
lib/
ui/ Screens (plan, fly, settings, library) + geteilte Widgets
app_mode/ App-Mode State Machine (Plan → Connect → Upload → Fly)
domain/ Mission-Domain-Layer (Strategy Pattern: TrackMission, ...)
+ protokollneutrale FlatWaypointList
services/ MissionSyncService, TelemetryService, Drift-Datenbank
transport/ FlightControllerLink-Abstraktion (msp/, mavlink/, mock/)
```
**MVP-Scope (Doku 4.1):** nur Android, nur iNAV via MSP, nur WiFi/TCP. MAVLink,
USB-Seriell und iOS sind bewusst zurückgestellt.
**Kernprinzip:** Die Domain-Schicht kennt nur `FlatWaypointList`, protokollneutral.
Divergenz zu MSP/MAVLink findet ausschließlich im Encoder statt. `FlightControllerLink`
ist die zentrale Abstraktion; `MockFlightControllerLink` erlaubt UI-Arbeit ohne
Hardware. `MspFlightControllerLink` ist aktuell nur ein Gerüst (`UnimplementedError`)
— die eigentliche MSP-Protokoll-Implementierung (Framing, `MSP_SET_WP`,
`MSP_NAV_STATUS`) ist noch offen.
**Ready-to-Fly-Gate (Doku 2.2/4.5):** Upload + verifiziert + disarmed. Wird in
`AppModeCubit.toFly()` technisch erzwungen (wirft `StateError`), nicht nur als
UI-Konvention behandelt.
**Persistenz:** Drift (SQLite), nicht Hive — siehe Begründung 4.15. Schema in
`lib/services/database/app_database.dart`. Nach Schema-Änderungen:
```
dart run build_runner build
```
(im `app/`-Verzeichnis; `--delete-conflicting-outputs` wird von der installierten
build_runner-Version ignoriert und ist nicht nötig).
**Entschiedene Pakete** (siehe Doku Abschnitt 8): `flutter_map` + `flutter_map_tile_caching`
(nicht `google_maps_flutter`), `drift`, `flutter_bloc` + `flutter_riverpod` (kombiniert:
Bloc für die App-Mode State Machine, Riverpod für reaktive Einzelwerte wie Telemetrie),
`go_router`.
**Noch nicht umgesetzt / bewusst zurückgestellt:**
- Eigener MSP-Encoder/Decoder in Dart (4.19) — kein fertiges Paket verfügbar.
- Telemetrie-Verarbeitung in eigenem Isolate (4.21) — erst sinnvoll mit echtem
Protokoll-Adapter zu verifizieren.
- `SurveyAreaMission`/`SearchAreaMission` (Strategy-Pattern vorgesehen, Details offen).
- Foreground Service (Android) für Hintergrund-Telemetrie (4.20).
- `flutter_launcher_icons` ist konfiguriert für Android; iOS ist aus MVP-Gründen
deaktiviert (`ios: false` in der `flutter_launcher_icons`-Sektion der `pubspec.yaml`).
## Verifikation nach Änderungen
Im `app/`-Verzeichnis:
```
flutter analyze
flutter test
```
+45
View File
@@ -0,0 +1,45 @@
# Miscellaneous
*.class
*.log
*.pyc
*.swp
.DS_Store
.atom/
.build/
.buildlog/
.history
.svn/
.swiftpm/
migrate_working_dir/
# IntelliJ related
*.iml
*.ipr
*.iws
.idea/
# The .vscode folder contains launch configuration and tasks you configure in
# VS Code which you may wish to be included in version control, so this line
# is commented out by default.
#.vscode/
# Flutter/Dart/Pub related
**/doc/api/
**/ios/Flutter/.last_build_id
.dart_tool/
.flutter-plugins-dependencies
.pub-cache/
.pub/
/build/
/coverage/
# Symbolication related
app.*.symbols
# Obfuscation related
app.*.map.json
# Android Studio will place build artifacts here
/android/app/debug
/android/app/profile
/android/app/release
+30
View File
@@ -0,0 +1,30 @@
# This file tracks properties of this Flutter project.
# Used by Flutter tool to assess capabilities and perform upgrades etc.
#
# This file should be version controlled and should not be manually edited.
version:
revision: "058e0af2c2b57e369d905a03ac9748b0ebf543c6"
channel: "stable"
project_type: app
# Tracks metadata for the flutter migrate command
migration:
platforms:
- platform: root
create_revision: 058e0af2c2b57e369d905a03ac9748b0ebf543c6
base_revision: 058e0af2c2b57e369d905a03ac9748b0ebf543c6
- platform: android
create_revision: 058e0af2c2b57e369d905a03ac9748b0ebf543c6
base_revision: 058e0af2c2b57e369d905a03ac9748b0ebf543c6
# User provided section
# List of Local paths (relative to this file) that should be
# ignored by the migrate tool.
#
# Files that are not part of the templates will be ignored by default.
unmanaged_files:
- 'lib/main.dart'
- 'ios/Runner.xcodeproj/project.pbxproj'
+17
View File
@@ -0,0 +1,17 @@
# dmc_app
Drone Mission Control - Missionsplanung und Flugsteuerung f\u00fcr Fixed-Wing-Drohnen
## Getting Started
This project is a starting point for a Flutter application.
A few resources to get you started if this is your first Flutter project:
- [Learn Flutter](https://docs.flutter.dev/get-started/learn-flutter)
- [Write your first Flutter app](https://docs.flutter.dev/get-started/codelab)
- [Flutter learning resources](https://docs.flutter.dev/reference/learning-resources)
For help getting started with Flutter development, view the
[online documentation](https://docs.flutter.dev/), which offers tutorials,
samples, guidance on mobile development, and a full API reference.
+28
View File
@@ -0,0 +1,28 @@
# This file configures the analyzer, which statically analyzes Dart code to
# check for errors, warnings, and lints.
#
# The issues identified by the analyzer are surfaced in the UI of Dart-enabled
# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be
# invoked from the command line by running `flutter analyze`.
# The following line activates a set of recommended lints for Flutter apps,
# packages, and plugins designed to encourage good coding practices.
include: package:flutter_lints/flutter.yaml
linter:
# The lint rules applied to this project can be customized in the
# section below to disable rules from the `package:flutter_lints/flutter.yaml`
# included above or to enable additional rules. A list of all available lints
# and their documentation is published at https://dart.dev/lints.
#
# Instead of disabling a lint rule for the entire project in the
# section below, it can also be suppressed for a single line of code
# or a specific dart file by using the `// ignore: name_of_lint` and
# `// ignore_for_file: name_of_lint` syntax on the line or in the file
# producing the lint.
rules:
# avoid_print: false # Uncomment to disable the `avoid_print` rule
# prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule
# Additional information about this file can be found at
# https://dart.dev/guides/language/analysis-options
+14
View File
@@ -0,0 +1,14 @@
gradle-wrapper.jar
/.gradle
/captures/
/gradlew
/gradlew.bat
/local.properties
GeneratedPluginRegistrant.java
.cxx/
# Remember to never publicly share your keystore.
# See https://flutter.dev/to/reference-keystore
key.properties
**/*.keystore
**/*.jks
+45
View File
@@ -0,0 +1,45 @@
plugins {
id("com.android.application")
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
id("dev.flutter.flutter-gradle-plugin")
}
android {
namespace = "com.dmc.dmc_app"
compileSdk = flutter.compileSdkVersion
ndkVersion = flutter.ndkVersion
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
defaultConfig {
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
applicationId = "com.dmc.dmc_app"
// You can update the following values to match your application needs.
// For more information, see: https://flutter.dev/to/review-gradle-config.
minSdk = flutter.minSdkVersion
targetSdk = flutter.targetSdkVersion
versionCode = flutter.versionCode
versionName = flutter.versionName
}
buildTypes {
release {
// TODO: Add your own signing config for the release build.
// Signing with the debug keys for now, so `flutter run --release` works.
signingConfig = signingConfigs.getByName("debug")
}
}
}
kotlin {
compilerOptions {
jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17
}
}
flutter {
source = "../.."
}
@@ -0,0 +1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- The INTERNET permission is required for development. Specifically,
the Flutter tool needs it to communicate with the running application
to allow setting breakpoints, to provide hot reload, etc.
-->
<uses-permission android:name="android.permission.INTERNET"/>
</manifest>
@@ -0,0 +1,45 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application
android:label="dmc_app"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher">
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTop"
android:taskAffinity=""
android:theme="@style/LaunchTheme"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true"
android:windowSoftInputMode="adjustResize">
<!-- Specifies an Android theme to apply to this Activity as soon as
the Android process has started. This theme is visible to the user
while the Flutter UI initializes. After that, this theme continues
to determine the Window background behind the Flutter UI. -->
<meta-data
android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme"
/>
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
</activity>
<!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
<meta-data
android:name="flutterEmbedding"
android:value="2" />
</application>
<!-- Required to query activities that can process text, see:
https://developer.android.com/training/package-visibility and
https://developer.android.com/reference/android/content/Intent#ACTION_PROCESS_TEXT.
In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. -->
<queries>
<intent>
<action android:name="android.intent.action.PROCESS_TEXT"/>
<data android:mimeType="text/plain"/>
</intent>
</queries>
</manifest>
@@ -0,0 +1,5 @@
package com.dmc.dmc_app
import io.flutter.embedding.android.FlutterActivity
class MainActivity : FlutterActivity()
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Modify this file to customize your launch splash screen -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="?android:colorBackground" />
<!-- You can insert your own image assets here -->
<!-- <item>
<bitmap
android:gravity="center"
android:src="@mipmap/launch_image" />
</item> -->
</layer-list>
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Modify this file to customize your launch splash screen -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@android:color/white" />
<!-- You can insert your own image assets here -->
<!-- <item>
<bitmap
android:gravity="center"
android:src="@mipmap/launch_image" />
</item> -->
</layer-list>
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is on -->
<style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
<!-- Show a splash screen on the activity. Automatically removed when
the Flutter engine draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your
Flutter UI initializes, as well as behind your Flutter UI while its
running.
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
</style>
</resources>
@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is off -->
<style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar">
<!-- Show a splash screen on the activity. Automatically removed when
the Flutter engine draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your
Flutter UI initializes, as well as behind your Flutter UI while its
running.
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
</style>
</resources>
@@ -0,0 +1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- The INTERNET permission is required for development. Specifically,
the Flutter tool needs it to communicate with the running application
to allow setting breakpoints, to provide hot reload, etc.
-->
<uses-permission android:name="android.permission.INTERNET"/>
</manifest>
+24
View File
@@ -0,0 +1,24 @@
allprojects {
repositories {
google()
mavenCentral()
}
}
val newBuildDir: Directory =
rootProject.layout.buildDirectory
.dir("../../build")
.get()
rootProject.layout.buildDirectory.value(newBuildDir)
subprojects {
val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name)
project.layout.buildDirectory.value(newSubprojectBuildDir)
}
subprojects {
project.evaluationDependsOn(":app")
}
tasks.register<Delete>("clean") {
delete(rootProject.layout.buildDirectory)
}
+6
View File
@@ -0,0 +1,6 @@
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
android.useAndroidX=true
# This newDsl flag was added by the Flutter template
android.newDsl=false
# This builtInKotlin flag was added by the Flutter template
android.builtInKotlin=false
+5
View File
@@ -0,0 +1,5 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.1.0-all.zip
+26
View File
@@ -0,0 +1,26 @@
pluginManagement {
val flutterSdkPath =
run {
val properties = java.util.Properties()
file("local.properties").inputStream().use { properties.load(it) }
val flutterSdkPath = properties.getProperty("flutter.sdk")
require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" }
flutterSdkPath
}
includeBuild("$flutterSdkPath/packages/flutter_tools/gradle")
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
plugins {
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
id("com.android.application") version "9.0.1" apply false
id("org.jetbrains.kotlin.android") version "2.3.20" apply false
}
include(":app")
Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

+65
View File
@@ -0,0 +1,65 @@
import 'package:flutter_bloc/flutter_bloc.dart';
/// Explizite Zustandsfolge Plan -> Connect -> Upload -> Fly
/// (Architektur-Doku 2.2).
enum AppMode { plan, connect, upload, fly }
class AppModeState {
const AppModeState({
this.mode = AppMode.plan,
this.missionUploaded = false,
this.missionVerified = false,
this.droneArmed = false,
});
final AppMode mode;
final bool missionUploaded;
final bool missionVerified;
final bool droneArmed;
/// Ready-to-Fly-Gate (Doku 2.2 / 4.5): Upload + verifiziert + disarmed.
/// Muss technisch erzwungen werden, nicht nur als UI-Konvention gelten.
bool get readyToFly => missionUploaded && missionVerified && !droneArmed;
AppModeState copyWith({
AppMode? mode,
bool? missionUploaded,
bool? missionVerified,
bool? droneArmed,
}) {
return AppModeState(
mode: mode ?? this.mode,
missionUploaded: missionUploaded ?? this.missionUploaded,
missionVerified: missionVerified ?? this.missionVerified,
droneArmed: droneArmed ?? this.droneArmed,
);
}
}
class AppModeCubit extends Cubit<AppModeState> {
AppModeCubit() : super(const AppModeState());
void toConnect() => emit(state.copyWith(mode: AppMode.connect));
void toUpload() => emit(state.copyWith(mode: AppMode.upload));
void missionUploadConfirmed() =>
emit(state.copyWith(missionUploaded: true));
void missionVerifiedConfirmed() =>
emit(state.copyWith(missionVerified: true));
void droneArmedChanged(bool armed) =>
emit(state.copyWith(droneArmed: armed));
/// Wirft, wenn das Ready-to-Fly-Gate nicht erfuellt ist (Doku 4.5).
void toFly() {
if (!state.readyToFly) {
throw StateError('Ready-to-Fly-Gate nicht erfuellt.');
}
emit(state.copyWith(mode: AppMode.fly));
}
/// Verlassen des Fly-Modus setzt den kompletten Handover-Status zurueck.
void toPlan() => emit(const AppModeState());
}
+24
View File
@@ -0,0 +1,24 @@
import '../waypoint/flat_waypoint_list.dart';
/// Strategy-Pattern fuer erweiterbare Missionstypen (Architektur-Doku 3.2).
abstract class Mission {
String get name;
FlatWaypointList toFlatWaypointList();
}
/// Wegpunkt-Streckenmission - aktuell einziger implementierter Missionstyp.
class TrackMission implements Mission {
const TrackMission({required this.name, required this.waypoints});
@override
final String name;
final List<Waypoint> waypoints;
@override
FlatWaypointList toFlatWaypointList() => FlatWaypointList(waypoints);
}
// SurveyAreaMission (Flaechenabflug) und SearchAreaMission (Suchmuster) sind
// im Strategy-Pattern vorgesehen, aber noch nicht spezifiziert (Doku 6).
@@ -0,0 +1,61 @@
/// Protokollneutrales Wegpunkt-Modell (Architektur-Doku 3.2).
/// Divergenz zu MSP/MAVLink findet ausschliesslich im Encoder-Schritt statt
/// (MspWaypointEncoder / MavlinkMissionEncoder), nicht in diesem Modell.
library;
enum WaypointAction { none, loiter, landing, jump }
class Waypoint {
const Waypoint({
required this.lat,
required this.lon,
required this.altitudeM,
required this.speedMs,
required this.catchRadiusM,
this.action = WaypointAction.none,
this.jumpTargetIndex,
this.jumpRepeatCount,
});
final double lat;
final double lon;
final double altitudeM;
final double speedMs;
final double catchRadiusM;
final WaypointAction action;
/// Nur bei action == jump gesetzt (iNAV Action-Code 6, Doku 7.1).
final int? jumpTargetIndex;
/// Nur bei action == jump gesetzt; null bedeutet Endlos-Wiederholung.
final int? jumpRepeatCount;
Waypoint copyWith({
double? lat,
double? lon,
double? altitudeM,
double? speedMs,
double? catchRadiusM,
WaypointAction? action,
int? jumpTargetIndex,
int? jumpRepeatCount,
}) {
return Waypoint(
lat: lat ?? this.lat,
lon: lon ?? this.lon,
altitudeM: altitudeM ?? this.altitudeM,
speedMs: speedMs ?? this.speedMs,
catchRadiusM: catchRadiusM ?? this.catchRadiusM,
action: action ?? this.action,
jumpTargetIndex: jumpTargetIndex ?? this.jumpTargetIndex,
jumpRepeatCount: jumpRepeatCount ?? this.jumpRepeatCount,
);
}
}
/// Gemeinsame, protokollunabhaengige Datenstruktur aller Missionstypen.
class FlatWaypointList {
const FlatWaypointList(this.waypoints);
final List<Waypoint> waypoints;
}
+55
View File
@@ -0,0 +1,55 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'app_mode/app_mode_cubit.dart';
import 'ui/app_shell.dart';
import 'ui/dmc_colors.dart';
import 'ui/screens/library/library_screen.dart';
import 'ui/screens/settings/settings_screen.dart';
void main() {
runApp(const DmcApp());
}
final _router = GoRouter(
routes: [
GoRoute(path: '/', builder: (context, state) => const AppShell()),
GoRoute(
path: '/settings',
builder: (context, state) => const SettingsScreen(),
),
GoRoute(
path: '/library',
builder: (context, state) => const LibraryScreen(),
),
],
);
class DmcApp extends StatelessWidget {
const DmcApp({super.key});
@override
Widget build(BuildContext context) {
return ProviderScope(
child: BlocProvider(
create: (_) => AppModeCubit(),
child: MaterialApp.router(
title: 'Drone Mission Control',
debugShowCheckedModeBanner: false,
theme: ThemeData(
useMaterial3: true,
brightness: Brightness.dark,
scaffoldBackgroundColor: const Color(0xFF0F0F0F),
colorScheme: ColorScheme.fromSeed(
seedColor: DmcColors.setBlue,
brightness: Brightness.dark,
),
),
routerConfig: _router,
),
),
);
}
}
@@ -0,0 +1,39 @@
import 'dart:io';
import 'package:drift/drift.dart';
import 'package:drift/native.dart';
import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart';
part 'app_database.g.dart';
/// Persistenz fuer Missionen (Architektur-Doku 4.15): Drift statt Hive, weil
/// indexierte Abfragen (Suche nach Name/Ort/Datum) benoetigt werden und das
/// Schema kompatibel zu einem kuenftigen lokal-first Server-Sync-Muster
/// (Dirty-Flags, Server-IDs) bleiben soll.
class Missions extends Table {
IntColumn get id => integer().autoIncrement()();
TextColumn get name => text()();
TextColumn get location => text().nullable()();
DateTimeColumn get createdAt =>
dateTime().withDefault(currentDateAndTime)();
TextColumn get waypointsJson => text()();
BoolColumn get isDirty => boolean().withDefault(const Constant(true))();
TextColumn get serverId => text().nullable()();
}
@DriftDatabase(tables: [Missions])
class AppDatabase extends _$AppDatabase {
AppDatabase() : super(_openConnection());
@override
int get schemaVersion => 1;
static LazyDatabase _openConnection() {
return LazyDatabase(() async {
final dbFolder = await getApplicationDocumentsDirectory();
final file = File(p.join(dbFolder.path, 'dmc_missions.sqlite'));
return NativeDatabase.createInBackground(file);
});
}
}
@@ -0,0 +1,708 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'app_database.dart';
// ignore_for_file: type=lint
class $MissionsTable extends Missions with TableInfo<$MissionsTable, Mission> {
@override
final GeneratedDatabase attachedDatabase;
final String? _alias;
$MissionsTable(this.attachedDatabase, [this._alias]);
static const VerificationMeta _idMeta = const VerificationMeta('id');
@override
late final GeneratedColumn<int> id = GeneratedColumn<int>(
'id',
aliasedName,
false,
hasAutoIncrement: true,
type: DriftSqlType.int,
requiredDuringInsert: false,
defaultConstraints: GeneratedColumn.constraintIsAlways(
'PRIMARY KEY AUTOINCREMENT',
),
);
static const VerificationMeta _nameMeta = const VerificationMeta('name');
@override
late final GeneratedColumn<String> name = GeneratedColumn<String>(
'name',
aliasedName,
false,
type: DriftSqlType.string,
requiredDuringInsert: true,
);
static const VerificationMeta _locationMeta = const VerificationMeta(
'location',
);
@override
late final GeneratedColumn<String> location = GeneratedColumn<String>(
'location',
aliasedName,
true,
type: DriftSqlType.string,
requiredDuringInsert: false,
);
static const VerificationMeta _createdAtMeta = const VerificationMeta(
'createdAt',
);
@override
late final GeneratedColumn<DateTime> createdAt = GeneratedColumn<DateTime>(
'created_at',
aliasedName,
false,
type: DriftSqlType.dateTime,
requiredDuringInsert: false,
defaultValue: currentDateAndTime,
);
static const VerificationMeta _waypointsJsonMeta = const VerificationMeta(
'waypointsJson',
);
@override
late final GeneratedColumn<String> waypointsJson = GeneratedColumn<String>(
'waypoints_json',
aliasedName,
false,
type: DriftSqlType.string,
requiredDuringInsert: true,
);
static const VerificationMeta _isDirtyMeta = const VerificationMeta(
'isDirty',
);
@override
late final GeneratedColumn<bool> isDirty = GeneratedColumn<bool>(
'is_dirty',
aliasedName,
false,
type: DriftSqlType.bool,
requiredDuringInsert: false,
defaultConstraints: GeneratedColumn.constraintIsAlways(
'CHECK ("is_dirty" IN (0, 1))',
),
defaultValue: const Constant(true),
);
static const VerificationMeta _serverIdMeta = const VerificationMeta(
'serverId',
);
@override
late final GeneratedColumn<String> serverId = GeneratedColumn<String>(
'server_id',
aliasedName,
true,
type: DriftSqlType.string,
requiredDuringInsert: false,
);
@override
List<GeneratedColumn> get $columns => [
id,
name,
location,
createdAt,
waypointsJson,
isDirty,
serverId,
];
@override
String get aliasedName => _alias ?? actualTableName;
@override
String get actualTableName => $name;
static const String $name = 'missions';
@override
VerificationContext validateIntegrity(
Insertable<Mission> instance, {
bool isInserting = false,
}) {
final context = VerificationContext();
final data = instance.toColumns(true);
if (data.containsKey('id')) {
context.handle(_idMeta, id.isAcceptableOrUnknown(data['id']!, _idMeta));
}
if (data.containsKey('name')) {
context.handle(
_nameMeta,
name.isAcceptableOrUnknown(data['name']!, _nameMeta),
);
} else if (isInserting) {
context.missing(_nameMeta);
}
if (data.containsKey('location')) {
context.handle(
_locationMeta,
location.isAcceptableOrUnknown(data['location']!, _locationMeta),
);
}
if (data.containsKey('created_at')) {
context.handle(
_createdAtMeta,
createdAt.isAcceptableOrUnknown(data['created_at']!, _createdAtMeta),
);
}
if (data.containsKey('waypoints_json')) {
context.handle(
_waypointsJsonMeta,
waypointsJson.isAcceptableOrUnknown(
data['waypoints_json']!,
_waypointsJsonMeta,
),
);
} else if (isInserting) {
context.missing(_waypointsJsonMeta);
}
if (data.containsKey('is_dirty')) {
context.handle(
_isDirtyMeta,
isDirty.isAcceptableOrUnknown(data['is_dirty']!, _isDirtyMeta),
);
}
if (data.containsKey('server_id')) {
context.handle(
_serverIdMeta,
serverId.isAcceptableOrUnknown(data['server_id']!, _serverIdMeta),
);
}
return context;
}
@override
Set<GeneratedColumn> get $primaryKey => {id};
@override
Mission map(Map<String, dynamic> data, {String? tablePrefix}) {
final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : '';
return Mission(
id: attachedDatabase.typeMapping.read(
DriftSqlType.int,
data['${effectivePrefix}id'],
)!,
name: attachedDatabase.typeMapping.read(
DriftSqlType.string,
data['${effectivePrefix}name'],
)!,
location: attachedDatabase.typeMapping.read(
DriftSqlType.string,
data['${effectivePrefix}location'],
),
createdAt: attachedDatabase.typeMapping.read(
DriftSqlType.dateTime,
data['${effectivePrefix}created_at'],
)!,
waypointsJson: attachedDatabase.typeMapping.read(
DriftSqlType.string,
data['${effectivePrefix}waypoints_json'],
)!,
isDirty: attachedDatabase.typeMapping.read(
DriftSqlType.bool,
data['${effectivePrefix}is_dirty'],
)!,
serverId: attachedDatabase.typeMapping.read(
DriftSqlType.string,
data['${effectivePrefix}server_id'],
),
);
}
@override
$MissionsTable createAlias(String alias) {
return $MissionsTable(attachedDatabase, alias);
}
}
class Mission extends DataClass implements Insertable<Mission> {
final int id;
final String name;
final String? location;
final DateTime createdAt;
final String waypointsJson;
final bool isDirty;
final String? serverId;
const Mission({
required this.id,
required this.name,
this.location,
required this.createdAt,
required this.waypointsJson,
required this.isDirty,
this.serverId,
});
@override
Map<String, Expression> toColumns(bool nullToAbsent) {
final map = <String, Expression>{};
map['id'] = Variable<int>(id);
map['name'] = Variable<String>(name);
if (!nullToAbsent || location != null) {
map['location'] = Variable<String>(location);
}
map['created_at'] = Variable<DateTime>(createdAt);
map['waypoints_json'] = Variable<String>(waypointsJson);
map['is_dirty'] = Variable<bool>(isDirty);
if (!nullToAbsent || serverId != null) {
map['server_id'] = Variable<String>(serverId);
}
return map;
}
MissionsCompanion toCompanion(bool nullToAbsent) {
return MissionsCompanion(
id: Value(id),
name: Value(name),
location: location == null && nullToAbsent
? const Value.absent()
: Value(location),
createdAt: Value(createdAt),
waypointsJson: Value(waypointsJson),
isDirty: Value(isDirty),
serverId: serverId == null && nullToAbsent
? const Value.absent()
: Value(serverId),
);
}
factory Mission.fromJson(
Map<String, dynamic> json, {
ValueSerializer? serializer,
}) {
serializer ??= driftRuntimeOptions.defaultSerializer;
return Mission(
id: serializer.fromJson<int>(json['id']),
name: serializer.fromJson<String>(json['name']),
location: serializer.fromJson<String?>(json['location']),
createdAt: serializer.fromJson<DateTime>(json['createdAt']),
waypointsJson: serializer.fromJson<String>(json['waypointsJson']),
isDirty: serializer.fromJson<bool>(json['isDirty']),
serverId: serializer.fromJson<String?>(json['serverId']),
);
}
@override
Map<String, dynamic> toJson({ValueSerializer? serializer}) {
serializer ??= driftRuntimeOptions.defaultSerializer;
return <String, dynamic>{
'id': serializer.toJson<int>(id),
'name': serializer.toJson<String>(name),
'location': serializer.toJson<String?>(location),
'createdAt': serializer.toJson<DateTime>(createdAt),
'waypointsJson': serializer.toJson<String>(waypointsJson),
'isDirty': serializer.toJson<bool>(isDirty),
'serverId': serializer.toJson<String?>(serverId),
};
}
Mission copyWith({
int? id,
String? name,
Value<String?> location = const Value.absent(),
DateTime? createdAt,
String? waypointsJson,
bool? isDirty,
Value<String?> serverId = const Value.absent(),
}) => Mission(
id: id ?? this.id,
name: name ?? this.name,
location: location.present ? location.value : this.location,
createdAt: createdAt ?? this.createdAt,
waypointsJson: waypointsJson ?? this.waypointsJson,
isDirty: isDirty ?? this.isDirty,
serverId: serverId.present ? serverId.value : this.serverId,
);
Mission copyWithCompanion(MissionsCompanion data) {
return Mission(
id: data.id.present ? data.id.value : this.id,
name: data.name.present ? data.name.value : this.name,
location: data.location.present ? data.location.value : this.location,
createdAt: data.createdAt.present ? data.createdAt.value : this.createdAt,
waypointsJson: data.waypointsJson.present
? data.waypointsJson.value
: this.waypointsJson,
isDirty: data.isDirty.present ? data.isDirty.value : this.isDirty,
serverId: data.serverId.present ? data.serverId.value : this.serverId,
);
}
@override
String toString() {
return (StringBuffer('Mission(')
..write('id: $id, ')
..write('name: $name, ')
..write('location: $location, ')
..write('createdAt: $createdAt, ')
..write('waypointsJson: $waypointsJson, ')
..write('isDirty: $isDirty, ')
..write('serverId: $serverId')
..write(')'))
.toString();
}
@override
int get hashCode => Object.hash(
id,
name,
location,
createdAt,
waypointsJson,
isDirty,
serverId,
);
@override
bool operator ==(Object other) =>
identical(this, other) ||
(other is Mission &&
other.id == this.id &&
other.name == this.name &&
other.location == this.location &&
other.createdAt == this.createdAt &&
other.waypointsJson == this.waypointsJson &&
other.isDirty == this.isDirty &&
other.serverId == this.serverId);
}
class MissionsCompanion extends UpdateCompanion<Mission> {
final Value<int> id;
final Value<String> name;
final Value<String?> location;
final Value<DateTime> createdAt;
final Value<String> waypointsJson;
final Value<bool> isDirty;
final Value<String?> serverId;
const MissionsCompanion({
this.id = const Value.absent(),
this.name = const Value.absent(),
this.location = const Value.absent(),
this.createdAt = const Value.absent(),
this.waypointsJson = const Value.absent(),
this.isDirty = const Value.absent(),
this.serverId = const Value.absent(),
});
MissionsCompanion.insert({
this.id = const Value.absent(),
required String name,
this.location = const Value.absent(),
this.createdAt = const Value.absent(),
required String waypointsJson,
this.isDirty = const Value.absent(),
this.serverId = const Value.absent(),
}) : name = Value(name),
waypointsJson = Value(waypointsJson);
static Insertable<Mission> custom({
Expression<int>? id,
Expression<String>? name,
Expression<String>? location,
Expression<DateTime>? createdAt,
Expression<String>? waypointsJson,
Expression<bool>? isDirty,
Expression<String>? serverId,
}) {
return RawValuesInsertable({
if (id != null) 'id': id,
if (name != null) 'name': name,
if (location != null) 'location': location,
if (createdAt != null) 'created_at': createdAt,
if (waypointsJson != null) 'waypoints_json': waypointsJson,
if (isDirty != null) 'is_dirty': isDirty,
if (serverId != null) 'server_id': serverId,
});
}
MissionsCompanion copyWith({
Value<int>? id,
Value<String>? name,
Value<String?>? location,
Value<DateTime>? createdAt,
Value<String>? waypointsJson,
Value<bool>? isDirty,
Value<String?>? serverId,
}) {
return MissionsCompanion(
id: id ?? this.id,
name: name ?? this.name,
location: location ?? this.location,
createdAt: createdAt ?? this.createdAt,
waypointsJson: waypointsJson ?? this.waypointsJson,
isDirty: isDirty ?? this.isDirty,
serverId: serverId ?? this.serverId,
);
}
@override
Map<String, Expression> toColumns(bool nullToAbsent) {
final map = <String, Expression>{};
if (id.present) {
map['id'] = Variable<int>(id.value);
}
if (name.present) {
map['name'] = Variable<String>(name.value);
}
if (location.present) {
map['location'] = Variable<String>(location.value);
}
if (createdAt.present) {
map['created_at'] = Variable<DateTime>(createdAt.value);
}
if (waypointsJson.present) {
map['waypoints_json'] = Variable<String>(waypointsJson.value);
}
if (isDirty.present) {
map['is_dirty'] = Variable<bool>(isDirty.value);
}
if (serverId.present) {
map['server_id'] = Variable<String>(serverId.value);
}
return map;
}
@override
String toString() {
return (StringBuffer('MissionsCompanion(')
..write('id: $id, ')
..write('name: $name, ')
..write('location: $location, ')
..write('createdAt: $createdAt, ')
..write('waypointsJson: $waypointsJson, ')
..write('isDirty: $isDirty, ')
..write('serverId: $serverId')
..write(')'))
.toString();
}
}
abstract class _$AppDatabase extends GeneratedDatabase {
_$AppDatabase(QueryExecutor e) : super(e);
$AppDatabaseManager get managers => $AppDatabaseManager(this);
late final $MissionsTable missions = $MissionsTable(this);
@override
Iterable<TableInfo<Table, Object?>> get allTables =>
allSchemaEntities.whereType<TableInfo<Table, Object?>>();
@override
List<DatabaseSchemaEntity> get allSchemaEntities => [missions];
}
typedef $$MissionsTableCreateCompanionBuilder =
MissionsCompanion Function({
Value<int> id,
required String name,
Value<String?> location,
Value<DateTime> createdAt,
required String waypointsJson,
Value<bool> isDirty,
Value<String?> serverId,
});
typedef $$MissionsTableUpdateCompanionBuilder =
MissionsCompanion Function({
Value<int> id,
Value<String> name,
Value<String?> location,
Value<DateTime> createdAt,
Value<String> waypointsJson,
Value<bool> isDirty,
Value<String?> serverId,
});
class $$MissionsTableFilterComposer
extends Composer<_$AppDatabase, $MissionsTable> {
$$MissionsTableFilterComposer({
required super.$db,
required super.$table,
super.joinBuilder,
super.$addJoinBuilderToRootComposer,
super.$removeJoinBuilderFromRootComposer,
});
ColumnFilters<int> get id => $composableBuilder(
column: $table.id,
builder: (column) => ColumnFilters(column),
);
ColumnFilters<String> get name => $composableBuilder(
column: $table.name,
builder: (column) => ColumnFilters(column),
);
ColumnFilters<String> get location => $composableBuilder(
column: $table.location,
builder: (column) => ColumnFilters(column),
);
ColumnFilters<DateTime> get createdAt => $composableBuilder(
column: $table.createdAt,
builder: (column) => ColumnFilters(column),
);
ColumnFilters<String> get waypointsJson => $composableBuilder(
column: $table.waypointsJson,
builder: (column) => ColumnFilters(column),
);
ColumnFilters<bool> get isDirty => $composableBuilder(
column: $table.isDirty,
builder: (column) => ColumnFilters(column),
);
ColumnFilters<String> get serverId => $composableBuilder(
column: $table.serverId,
builder: (column) => ColumnFilters(column),
);
}
class $$MissionsTableOrderingComposer
extends Composer<_$AppDatabase, $MissionsTable> {
$$MissionsTableOrderingComposer({
required super.$db,
required super.$table,
super.joinBuilder,
super.$addJoinBuilderToRootComposer,
super.$removeJoinBuilderFromRootComposer,
});
ColumnOrderings<int> get id => $composableBuilder(
column: $table.id,
builder: (column) => ColumnOrderings(column),
);
ColumnOrderings<String> get name => $composableBuilder(
column: $table.name,
builder: (column) => ColumnOrderings(column),
);
ColumnOrderings<String> get location => $composableBuilder(
column: $table.location,
builder: (column) => ColumnOrderings(column),
);
ColumnOrderings<DateTime> get createdAt => $composableBuilder(
column: $table.createdAt,
builder: (column) => ColumnOrderings(column),
);
ColumnOrderings<String> get waypointsJson => $composableBuilder(
column: $table.waypointsJson,
builder: (column) => ColumnOrderings(column),
);
ColumnOrderings<bool> get isDirty => $composableBuilder(
column: $table.isDirty,
builder: (column) => ColumnOrderings(column),
);
ColumnOrderings<String> get serverId => $composableBuilder(
column: $table.serverId,
builder: (column) => ColumnOrderings(column),
);
}
class $$MissionsTableAnnotationComposer
extends Composer<_$AppDatabase, $MissionsTable> {
$$MissionsTableAnnotationComposer({
required super.$db,
required super.$table,
super.joinBuilder,
super.$addJoinBuilderToRootComposer,
super.$removeJoinBuilderFromRootComposer,
});
GeneratedColumn<int> get id =>
$composableBuilder(column: $table.id, builder: (column) => column);
GeneratedColumn<String> get name =>
$composableBuilder(column: $table.name, builder: (column) => column);
GeneratedColumn<String> get location =>
$composableBuilder(column: $table.location, builder: (column) => column);
GeneratedColumn<DateTime> get createdAt =>
$composableBuilder(column: $table.createdAt, builder: (column) => column);
GeneratedColumn<String> get waypointsJson => $composableBuilder(
column: $table.waypointsJson,
builder: (column) => column,
);
GeneratedColumn<bool> get isDirty =>
$composableBuilder(column: $table.isDirty, builder: (column) => column);
GeneratedColumn<String> get serverId =>
$composableBuilder(column: $table.serverId, builder: (column) => column);
}
class $$MissionsTableTableManager
extends
RootTableManager<
_$AppDatabase,
$MissionsTable,
Mission,
$$MissionsTableFilterComposer,
$$MissionsTableOrderingComposer,
$$MissionsTableAnnotationComposer,
$$MissionsTableCreateCompanionBuilder,
$$MissionsTableUpdateCompanionBuilder,
(Mission, BaseReferences<_$AppDatabase, $MissionsTable, Mission>),
Mission,
PrefetchHooks Function()
> {
$$MissionsTableTableManager(_$AppDatabase db, $MissionsTable table)
: super(
TableManagerState(
db: db,
table: table,
createFilteringComposer: () =>
$$MissionsTableFilterComposer($db: db, $table: table),
createOrderingComposer: () =>
$$MissionsTableOrderingComposer($db: db, $table: table),
createComputedFieldComposer: () =>
$$MissionsTableAnnotationComposer($db: db, $table: table),
updateCompanionCallback:
({
Value<int> id = const Value.absent(),
Value<String> name = const Value.absent(),
Value<String?> location = const Value.absent(),
Value<DateTime> createdAt = const Value.absent(),
Value<String> waypointsJson = const Value.absent(),
Value<bool> isDirty = const Value.absent(),
Value<String?> serverId = const Value.absent(),
}) => MissionsCompanion(
id: id,
name: name,
location: location,
createdAt: createdAt,
waypointsJson: waypointsJson,
isDirty: isDirty,
serverId: serverId,
),
createCompanionCallback:
({
Value<int> id = const Value.absent(),
required String name,
Value<String?> location = const Value.absent(),
Value<DateTime> createdAt = const Value.absent(),
required String waypointsJson,
Value<bool> isDirty = const Value.absent(),
Value<String?> serverId = const Value.absent(),
}) => MissionsCompanion.insert(
id: id,
name: name,
location: location,
createdAt: createdAt,
waypointsJson: waypointsJson,
isDirty: isDirty,
serverId: serverId,
),
withReferenceMapper: (p0) => p0
.map((e) => (e.readTable(table), BaseReferences(db, table, e)))
.toList(),
prefetchHooksCallback: null,
),
);
}
typedef $$MissionsTableProcessedTableManager =
ProcessedTableManager<
_$AppDatabase,
$MissionsTable,
Mission,
$$MissionsTableFilterComposer,
$$MissionsTableOrderingComposer,
$$MissionsTableAnnotationComposer,
$$MissionsTableCreateCompanionBuilder,
$$MissionsTableUpdateCompanionBuilder,
(Mission, BaseReferences<_$AppDatabase, $MissionsTable, Mission>),
Mission,
PrefetchHooks Function()
>;
class $AppDatabaseManager {
final _$AppDatabase _db;
$AppDatabaseManager(this._db);
$$MissionsTableTableManager get missions =>
$$MissionsTableTableManager(_db, _db.missions);
}
@@ -0,0 +1,26 @@
import '../app_mode/app_mode_cubit.dart';
import '../domain/waypoint/flat_waypoint_list.dart';
import '../transport/flight_controller_link.dart';
/// Orchestriert Connect -> Verify -> Upload -> Ready-to-Fly und erzwingt das
/// Ready-to-Fly-Gate (Architektur-Doku 3.3 / 4.5).
class MissionSyncService {
MissionSyncService(this._link, this._appMode);
final FlightControllerLink _link;
final AppModeCubit _appMode;
Future<void> syncAndUpload(FlatWaypointList mission) async {
await _link.connect();
// uploadMission() der jeweiligen FlightControllerLink-Implementierung
// verweigert den Upload selbst, falls die Drohne armed ist (Doku 4.5) -
// dieser Service verlaesst sich bewusst nicht zusaetzlich auf einen
// eigenen Telemetrie-Vorab-Check, um keine zwei Wahrheitsquellen zu haben.
await _link.uploadMission(mission);
_appMode.missionUploadConfirmed();
// TODO: echte Verifikation gegen FC-Rueckmeldung, sobald ein
// Protokoll-Adapter (MSP/MAVLink) eine Upload-Bestaetigung liefert.
_appMode.missionVerifiedConfirmed();
}
}
+15
View File
@@ -0,0 +1,15 @@
import '../transport/flight_controller_link.dart';
/// Abonniert Live-Telemetrie vom FC und reicht normalisierte Frames an die
/// UI weiter (Architektur-Doku 3.4).
///
/// Laeuft im MVP noch synchron auf dem UI-Isolate. Die Auslagerung in ein
/// eigenes Dart-Isolate (Doku 4.21) ist erst sinnvoll zu verifizieren, sobald
/// ein echter Protokoll-Adapter reale Telemetrie-Raten liefert.
class TelemetryService {
TelemetryService(this._link);
final FlightControllerLink _link;
Stream<TelemetryFrame> get frames => _link.subscribeTelemetry();
}
@@ -0,0 +1,57 @@
import '../domain/waypoint/flat_waypoint_list.dart';
/// Abstrakte Flugmodi, protokollunabhaengig (Architektur-Doku 3.1).
enum FlightMode { missionRun, guidedPoint, returnHome, hold }
/// Feature-Flags des angebundenen Flightcontrollers (Doku 3.1).
class FcCapabilities {
const FcCapabilities({
required this.supportsMultiMission,
required this.supportsInFlightUpload,
required this.maxWaypoints,
});
final bool supportsMultiMission;
final bool supportsInFlightUpload;
final int maxWaypoints;
}
/// Normalisiertes Telemetrie-Frame, das an die UI weitergereicht wird (Doku 3.4).
class TelemetryFrame {
const TelemetryFrame({
required this.lat,
required this.lon,
required this.altitudeM,
required this.speedMs,
required this.armed,
this.activeWaypointIndex,
});
final double lat;
final double lon;
final double altitudeM;
final double speedMs;
final bool armed;
final int? activeWaypointIndex;
}
/// Zentrale Abstraktion fuer alle Flightcontroller-Interaktionen (Doku 3.1).
/// Implementierungen: MspFlightControllerLink (iNAV), MavlinkFlightControllerLink
/// (ArduPilot, Post-MVP), MockFlightControllerLink (Tests ohne Hardware).
abstract class FlightControllerLink {
FcCapabilities get capabilities;
Future<void> connect();
Future<void> disconnect();
Future<void> uploadMission(FlatWaypointList mission);
Future<void> setFlightMode(FlightMode mode);
Future<void> arm();
Stream<TelemetryFrame> subscribeTelemetry();
Future<int?> readActiveWaypointIndex();
}
@@ -0,0 +1,71 @@
import 'dart:async';
import '../../domain/waypoint/flat_waypoint_list.dart';
import '../flight_controller_link.dart';
/// Synthetische Telemetrie fuer UI-Entwicklung/Tests ohne Hardware oder SITL
/// (Architektur-Doku 3.1).
class MockFlightControllerLink implements FlightControllerLink {
bool _armed = false;
int? _activeWaypointIndex;
StreamController<TelemetryFrame>? _telemetryController;
Timer? _ticker;
@override
FcCapabilities get capabilities => const FcCapabilities(
supportsMultiMission: true,
supportsInFlightUpload: false,
maxWaypoints: 60,
);
@override
Future<void> connect() async {
_telemetryController = StreamController<TelemetryFrame>.broadcast();
_ticker = Timer.periodic(const Duration(seconds: 1), (_) {
_telemetryController?.add(TelemetryFrame(
lat: 52.5,
lon: 13.4,
altitudeM: 120,
speedMs: 18,
armed: _armed,
activeWaypointIndex: _activeWaypointIndex,
));
});
}
@override
Future<void> disconnect() async {
_ticker?.cancel();
await _telemetryController?.close();
_telemetryController = null;
}
@override
Future<void> uploadMission(FlatWaypointList mission) async {
// iNAV/MAVLink erlauben Mission-Upload nur im unarmed-Zustand (Doku 4.5).
if (_armed) {
throw StateError('Mission-Upload verweigert: Drohne ist armed.');
}
_activeWaypointIndex = mission.waypoints.isEmpty ? null : 0;
}
@override
Future<void> setFlightMode(FlightMode mode) async {}
@override
Future<void> arm() async {
_armed = true;
}
@override
Stream<TelemetryFrame> subscribeTelemetry() {
final controller = _telemetryController;
if (controller == null) {
throw StateError('subscribeTelemetry() vor connect() aufgerufen.');
}
return controller.stream;
}
@override
Future<int?> readActiveWaypointIndex() async => _activeWaypointIndex;
}
@@ -0,0 +1,39 @@
import '../../domain/waypoint/flat_waypoint_list.dart';
import '../flight_controller_link.dart';
/// iNAV-Anbindung via MSP (Architektur-Doku 3.1). Eigenstaendiger Adapter,
/// da kein fertiges Dart-Paket fuer MSP verfuegbar ist (Doku 4.19).
///
/// 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});
final String host;
final int port;
@override
FcCapabilities get capabilities => throw UnimplementedError();
@override
Future<void> connect() => throw UnimplementedError();
@override
Future<void> disconnect() => throw UnimplementedError();
@override
Future<void> uploadMission(FlatWaypointList mission) =>
throw UnimplementedError();
@override
Future<void> setFlightMode(FlightMode mode) => throw UnimplementedError();
@override
Future<void> arm() => throw UnimplementedError();
@override
Stream<TelemetryFrame> subscribeTelemetry() => throw UnimplementedError();
@override
Future<int?> readActiveWaypointIndex() => throw UnimplementedError();
}
+19
View File
@@ -0,0 +1,19 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../app_mode/app_mode_cubit.dart';
import 'screens/fly/fly_screen.dart';
import 'screens/plan/plan_screen.dart';
/// Schaltet zwischen Plan- und Fly-Screen um, gesteuert von der
/// App-Mode State Machine (Architektur-Doku 2.2). Connect/Upload sind
/// Uebergangszustaende innerhalb des Plan-Screens (Handover-Prozess).
class AppShell extends StatelessWidget {
const AppShell({super.key});
@override
Widget build(BuildContext context) {
final mode = context.select((AppModeCubit c) => c.state.mode);
return mode == AppMode.fly ? const FlyScreen() : const PlanScreen();
}
}
+14
View File
@@ -0,0 +1,14 @@
import 'package:flutter/material.dart';
/// Markenfarben, uebernommen aus dem HTML-Demonstrator
/// ("Drone Mission Control.html"), damit Flutter-UI und Prototyp konsistent
/// bleiben.
abstract final class DmcColors {
static const setBlue = Color(0xFF4D9DFF);
static const setBlueLight = Color(0xFF8FD0FF);
static const liveGreen = Color(0xFF3ECF8E);
static const warnRed = Color(0xFFE2574A);
static const pafAmber = Color(0xFFF0C86E);
static const editModeGreen = Color(0xFF50C878);
static const flyModeRed = Color(0xFFE2574A);
}
+15
View File
@@ -0,0 +1,15 @@
import 'package:flutter/material.dart';
/// Fly-Modus: Telemetrie, Draeder, Point-and-Fly-Fadenkreuz
/// (Architektur-Doku 3.10/3.11). Platzhalter bis Telemetry-Service und
/// FlightControllerLink verdrahtet sind.
class FlyScreen extends StatelessWidget {
const FlyScreen({super.key});
@override
Widget build(BuildContext context) {
return const Scaffold(
body: Center(child: Text('Fly')),
);
}
}
@@ -0,0 +1,15 @@
import 'package:flutter/material.dart';
/// Missionsuebersicht (Architektur-Doku 3.5/7.5): Suche nach Name/Ort/Datum
/// ueber Drift, sobald AppDatabase verdrahtet ist.
class LibraryScreen extends StatelessWidget {
const LibraryScreen({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Missions')),
body: const Center(child: Text('Library')),
);
}
}
+15
View File
@@ -0,0 +1,15 @@
import 'package:flutter/material.dart';
/// Missionsplanung: Karte + Wegpunktliste (Architektur-Doku 3.6/3.7).
/// Kartenintegration (flutter_map) und Fillet-Geometrie folgen als naechster
/// Schritt - dies ist bewusst nur der Screen-Platzhalter.
class PlanScreen extends StatelessWidget {
const PlanScreen({super.key});
@override
Widget build(BuildContext context) {
return const Scaffold(
body: Center(child: Text('Plan')),
);
}
}
@@ -0,0 +1,15 @@
import 'package:flutter/material.dart';
/// Drohnen-Profile: FC-Typ, Flugleistungsdaten, Standardprofil T1 Ranger
/// (Architektur-Doku 3.5).
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')),
);
}
}
+1050
View File
File diff suppressed because it is too large Load Diff
+112
View File
@@ -0,0 +1,112 @@
name: dmc_app
description: "Drone Mission Control - Missionsplanung und Flugsteuerung f\\u00fcr Fixed-Wing-Drohnen"
# The following line prevents the package from being accidentally published to
# pub.dev using `flutter pub publish`. This is preferred for private packages.
publish_to: 'none' # Remove this line if you wish to publish to pub.dev
# The following defines the version and build number for your application.
# A version number is three numbers separated by dots, like 1.2.43
# followed by an optional build number separated by a +.
# Both the version and the builder number may be overridden in flutter
# build by specifying --build-name and --build-number, respectively.
# In Android, build-name is used as versionName while build-number used as versionCode.
# Read more about Android versioning at https://developer.android.com/studio/publish/versioning
# In iOS, build-name is used as CFBundleShortVersionString while build-number is used as CFBundleVersion.
# Read more about iOS versioning at
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
# In Windows, build-name is used as the major, minor, and patch parts
# of the product and file versions while build-number is used as the build suffix.
version: 1.0.0+1
flutter_launcher_icons:
android: true
ios: false
image_path: "assets/icon/icon.png"
min_sdk_android: 21
environment:
sdk: ^3.12.2
# Dependencies specify other packages that your package needs in order to work.
# To automatically upgrade your package dependencies to the latest versions
# consider running `flutter pub upgrade --major-versions`. Alternatively,
# dependencies can be manually updated by changing the version numbers below to
# the latest version available on pub.dev. To see which dependencies have newer
# versions available, run `flutter pub outdated`.
dependencies:
flutter:
sdk: flutter
# The following adds the Cupertino Icons font to your application.
# Use with the CupertinoIcons class for iOS style icons.
cupertino_icons: ^1.0.8
flutter_map: ^8.3.1
latlong2: ^0.9.1
flutter_map_tile_caching: ^10.1.1
drift: ^2.34.2
sqlite3_flutter_libs: ^0.6.0+eol
path_provider: ^2.1.6
path: ^1.9.1
http: ^1.6.0
flutter_bloc: ^9.1.1
flutter_riverpod: ^3.4.1
go_router: ^17.3.0
dev_dependencies:
flutter_test:
sdk: flutter
# The "flutter_lints" package below contains a set of recommended lints to
# encourage good coding practices. The lint set provided by the package is
# activated in the `analysis_options.yaml` file located at the root of your
# package. See that file for information about deactivating specific lint
# rules and activating additional ones.
flutter_lints: ^6.0.0
drift_dev: ^2.34.0
build_runner: ^2.15.1
flutter_launcher_icons: ^0.14.4
# For information on the generic Dart part of this file, see the
# following page: https://dart.dev/tools/pub/pubspec
# The following section is specific to Flutter packages.
flutter:
# The following line ensures that the Material Icons font is
# included with your application, so that you can use the icons in
# the material Icons class.
uses-material-design: true
assets:
- assets/icon/icon.png
# To add assets to your application, add an assets section, like this:
# assets:
# - images/a_dot_burr.jpeg
# - images/a_dot_ham.jpeg
# An image asset can refer to one or more resolution-specific "variants", see
# https://flutter.dev/to/resolution-aware-images
# For details regarding adding assets from package dependencies, see
# https://flutter.dev/to/asset-from-package
# To add custom fonts to your application, add a fonts section here,
# in this "flutter" section. Each entry in this list should have a
# "family" key with the font family name, and a "fonts" key with a
# list giving the asset and other descriptors for the font. For
# example:
# fonts:
# - family: Schyler
# fonts:
# - asset: fonts/Schyler-Regular.ttf
# - asset: fonts/Schyler-Italic.ttf
# style: italic
# - family: Trajan Pro
# fonts:
# - asset: fonts/TrajanPro.ttf
# - asset: fonts/TrajanPro_Bold.ttf
# weight: 700
#
# For details regarding fonts from package dependencies,
# see https://flutter.dev/to/font-from-package
+22
View File
@@ -0,0 +1,22 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:dmc_app/app_mode/app_mode_cubit.dart';
import 'package:dmc_app/ui/app_shell.dart';
void main() {
testWidgets('AppShell zeigt Plan-Screen im Startzustand', (tester) async {
await tester.pumpWidget(
ProviderScope(
child: BlocProvider(
create: (_) => AppModeCubit(),
child: const MaterialApp(home: AppShell()),
),
),
);
expect(find.text('Plan'), findsOneWidget);
});
}