diff --git a/build_release_apk.sh b/build_release_apk.sh new file mode 100755 index 0000000..c34f6d4 --- /dev/null +++ b/build_release_apk.sh @@ -0,0 +1,53 @@ +#!/usr/bin/env bash +# +# Erstellt eine Release-APK und benennt sie nach der Versionsnummer aus der +# pubspec.yaml, z. B. version: 353.0.0+353 -> a353.apk +# +set -euo pipefail + +# In das Verzeichnis des Scripts wechseln (Projekt-Root) +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "$SCRIPT_DIR" + +PUBSPEC="pubspec.yaml" +if [[ ! -f "$PUBSPEC" ]]; then + echo "Fehler: $PUBSPEC nicht gefunden." >&2 + exit 1 +fi + +# Version aus pubspec.yaml lesen (z. B. "353.0.0+353") +VERSION_LINE="$(grep -E '^version:' "$PUBSPEC" | head -n1)" +FULL_VERSION="$(echo "$VERSION_LINE" | sed -E 's/^version:[[:space:]]*//')" + +# Major-Versionsnummer extrahieren (alles vor dem ersten Punkt) +MAJOR="${FULL_VERSION%%.*}" + +if [[ -z "$MAJOR" ]]; then + echo "Fehler: Konnte Versionsnummer nicht aus '$VERSION_LINE' ermitteln." >&2 + exit 1 +fi + +APK_NAME="a${MAJOR}.apk" + +echo "Version aus pubspec.yaml: $FULL_VERSION" +echo "Ziel-APK-Name: $APK_NAME" +echo + +# Release-APK bauen +flutter build apk --release + +# Pfad zur erzeugten APK +BUILT_APK="build/app/outputs/flutter-apk/app-release.apk" +if [[ ! -f "$BUILT_APK" ]]; then + echo "Fehler: Erwartete APK nicht gefunden: $BUILT_APK" >&2 + exit 1 +fi + +# Ausgabeverzeichnis +OUTPUT_DIR="build/releases" +mkdir -p "$OUTPUT_DIR" + +cp "$BUILT_APK" "$OUTPUT_DIR/$APK_NAME" + +echo +echo "Fertig: $OUTPUT_DIR/$APK_NAME" diff --git a/lib/model/chat_message.dart b/lib/model/chat_message.dart index f895aa3..3240327 100644 --- a/lib/model/chat_message.dart +++ b/lib/model/chat_message.dart @@ -1,3 +1,8 @@ +// Direktimport nötig, damit build_runner die @Entity-Annotation auflösen kann, +// ohne auf die gerade erst generierte objectbox.g.dart angewiesen zu sein +// (zirkuläre Auflösung). Nicht entfernen, auch wenn der Lint ihn als unnötig meldet. +// ignore: unnecessary_import +import 'package:objectbox/objectbox.dart'; import 'package:stadtbote/main.dart'; import 'package:stadtbote/objectbox.g.dart'; import 'package:stadtbote/tools/util.dart'; diff --git a/lib/network_messages/track_message.dart b/lib/network_messages/track_message.dart index b0d6234..114afd3 100644 --- a/lib/network_messages/track_message.dart +++ b/lib/network_messages/track_message.dart @@ -12,6 +12,12 @@ class TrackPosition { late int acc; + /// Bewegungszustand der Ortung (String-Label, z. B. "static", "moving_medium"). + late String state; + + /// Geschwindigkeit in m/s. + late double speed; + TrackPosition(); factory TrackPosition.fromJson(Map json) => _$TrackPositionFromJson(json); diff --git a/lib/network_messages/track_message.g.dart b/lib/network_messages/track_message.g.dart index 0bca49f..ee045cb 100644 --- a/lib/network_messages/track_message.g.dart +++ b/lib/network_messages/track_message.g.dart @@ -11,7 +11,9 @@ TrackPosition _$TrackPositionFromJson(Map json) => ..time = (json['time'] as num).toInt() ..lat = (json['lat'] as num).toDouble() ..long = (json['long'] as num).toDouble() - ..acc = (json['acc'] as num).toInt(); + ..acc = (json['acc'] as num).toInt() + ..state = json['state'] as String + ..speed = (json['speed'] as num).toDouble(); Map _$TrackPositionToJson(TrackPosition instance) => { @@ -19,4 +21,6 @@ Map _$TrackPositionToJson(TrackPosition instance) => 'lat': instance.lat, 'long': instance.long, 'acc': instance.acc, + 'state': instance.state, + 'speed': instance.speed, }; diff --git a/lib/tools/mqtt_core.dart b/lib/tools/mqtt_core.dart index e54cf01..1560caf 100644 --- a/lib/tools/mqtt_core.dart +++ b/lib/tools/mqtt_core.dart @@ -6,6 +6,7 @@ import 'dart:math'; import 'package:archive/archive.dart'; import 'package:dart_mq/dart_mq.dart'; import 'package:device_info_plus/device_info_plus.dart'; +import 'package:geolocator/geolocator.dart'; import 'package:http/http.dart' as http; import 'package:mqtt5_client/mqtt5_client.dart'; import 'package:mqtt5_client/mqtt5_server_client.dart'; @@ -52,6 +53,7 @@ import '../main.dart'; import '../model/session_status.dart'; import 'constants.dart'; import 'messages/bwv_message.dart'; +import 'track_state.dart'; import 'util.dart'; enum ConnectionStateEnum { notConnected, connected } @@ -77,6 +79,15 @@ class MqttCore with ProducerMixin, ConsumerMixin { Timer? _trackTimer; + /// Zeitpunkt der letzten gesendeten Positionsmeldung. + DateTime? _lastTrackSentAt; + + /// Zuletzt gemeldeter Bewegungszustand (für Erkennung von Zustandswechseln). + TrackState? _lastTrackState; + + /// Zuletzt gesampelte Position (Referenz für die Statisch-Erkennung). + Position? _lastSampledPosition; + MqttCore() { instance = this; @@ -627,25 +638,117 @@ class MqttCore with ProducerMixin, ConsumerMixin { void _startTrackTimer() { _cancelTrackTimer(); - _trackTimer ??= Timer.periodic(const Duration(seconds: 120), (Timer t) async { - try { - var position = await MyApp.getCurrentPosition(); - var trackMessage = TrackPosition(); + // Zustand zurücksetzen, damit die erste Meldung als Zustandswechsel gilt. + _lastTrackSentAt = null; + _lastTrackState = null; + _lastSampledPosition = null; - if (position != null) { - trackMessage.time = Util.toUnixSecondsUtc(position.timestamp); - trackMessage.lat = position.latitude; - trackMessage.long = position.longitude; - trackMessage.acc = position.accuracy.toInt(); - - _publishMessage("track", trackMessage.toJson(), false); - } else { - _publishMessage("track", {}, false); - } - } catch (ex) { - Util.logException("Error in periodic track timer: {$ex}"); - } + // Feste Sampling-Rate (= kleinste Intervallzeit). Bei jedem Sample wird + // entschieden, ob eine Positionsmeldung fällig ist. + _trackTimer = Timer.periodic(TrackTuning.samplingInterval, (Timer t) async { + await _sampleAndMaybeTrack(); }); + + // Direkt nach Session-Start eine erste Meldung absetzen. + _sampleAndMaybeTrack(); + } + + /// Ermittelt den aktuellen Zustand und sendet eine Positionsmeldung, falls + /// ein Zustandswechsel vorliegt oder das Meldeintervall abgelaufen ist. + Future _sampleAndMaybeTrack() async { + try { + var position = await MyApp.getCurrentPosition(); + var state = _determineTrackState(position); + var withJob = _hasActiveJob(); + + var now = DateTime.now(); + var isTransition = state != _lastTrackState; + var intervalSeconds = state.intervalSeconds(withJob: withJob); + var elapsedEnough = _lastTrackSentAt == null || + now.difference(_lastTrackSentAt!).inSeconds >= intervalSeconds; + + if (isTransition || elapsedEnough) { + _sendTrack(position, state); + _lastTrackSentAt = now; + _lastTrackState = state; + } + + // Referenzposition für die Statisch-Erkennung immer aktualisieren. + _lastSampledPosition = position; + } catch (ex) { + Util.logException("Error in periodic track timer: {$ex}"); + } + } + + /// Bestimmt den Bewegungszustand aus der aktuellen Position. + TrackState _determineTrackState(Position? position) { + if (position == null) { + return TrackState.noFix; + } + + // Statisch: Positionsänderung seit letztem Sample < Ortungsradius / 2, + // wobei der Ortungsradius die GPS-Genauigkeit (accuracy) ist. + var last = _lastSampledPosition; + if (last != null) { + var distance = Geolocator.distanceBetween( + last.latitude, + last.longitude, + position.latitude, + position.longitude, + ); + if (distance < position.accuracy / 2) { + return TrackState.stationary; + } + } + + var speed = position.speed; // m/s + if (speed < TrackTuning.movingSlowMaxSpeed) { + return TrackState.movingSlow; + } else if (speed < TrackTuning.movingMediumMaxSpeed) { + return TrackState.movingMedium; + } else { + return TrackState.movingFast; + } + } + + /// Prüft, ob ein Auftrag aktiv ist: akzeptierter Job, dessen Auftragszeit + /// höchstens [TrackTuning.activeJobLeadSeconds] in der Zukunft liegt. + bool _hasActiveJob() { + final box = MyApp.objectBoxStore.box(); + final query = box.query(Job_.state.equals(Constants.stateAccepted)).build(); + var jobs = query.find(); + query.close(); + + var nowSeconds = Util.nowUnixSecondsUtc(); + for (var job in jobs) { + if (nowSeconds >= job.ordertime - TrackTuning.activeJobLeadSeconds) { + return true; + } + } + return false; + } + + /// Baut die Positionsmeldung und veröffentlicht sie auf dem Topic "track". + void _sendTrack(Position? position, TrackState state) { + var trackMessage = TrackPosition(); + trackMessage.state = state.label; + + if (position != null) { + trackMessage.time = Util.toUnixSecondsUtc(position.timestamp); + trackMessage.lat = position.latitude; + trackMessage.long = position.longitude; + trackMessage.acc = position.accuracy.toInt(); + trackMessage.speed = position.speed; + } else { + // Keine Ortung verfügbar: nur Zustand + Zeitstempel melden. + trackMessage.time = Util.nowUnixSecondsUtc(); + trackMessage.lat = 0.0; + trackMessage.long = 0.0; + trackMessage.acc = 0; + trackMessage.speed = 0.0; + } + + _publishMessage("track", trackMessage.toJson(), false); } void _cancelTrackTimer() { diff --git a/lib/tools/track_state.dart b/lib/tools/track_state.dart new file mode 100644 index 0000000..4f97a82 --- /dev/null +++ b/lib/tools/track_state.dart @@ -0,0 +1,58 @@ +/// Bewegungszustände für die adaptive Positionsmeldung (Tracking). +/// +/// Je Zustand gibt es zwei Meldeintervalle: eines, wenn ein Auftrag aktiv ist +/// ("mit Auftrag"), und eines ohne aktiven Auftrag. Die kleinste vorkommende +/// Intervallzeit ([TrackTuning.samplingInterval]) wird als feste Sampling-Rate +/// verwendet; bei jedem Sample wird entschieden, ob eine Meldung fällig ist. +enum TrackState { + /// Keine Ortung verfügbar. + noFix('no_fix', 1800, 1800), + + /// Statische Position (Positionsänderung < Ortungsradius / 2). + stationary('static', 600, 600), + + /// In Bewegung, langsam (Geschwindigkeit < 1.5 m/s). + movingSlow('moving_slow', 60, 120), + + /// In Bewegung, mittel (Geschwindigkeit < 17 m/s). + movingMedium('moving_medium', 15, 60), + + /// In Bewegung, schnell (Geschwindigkeit >= 17 m/s). + movingFast('moving_fast', 30, 120); + + const TrackState( + this.label, + this.intervalWithJobSeconds, + this.intervalWithoutJobSeconds, + ); + + /// Im JSON übertragenes String-Label des Zustands. + final String label; + + /// Meldeintervall in Sekunden, wenn ein Auftrag aktiv ist. + final int intervalWithJobSeconds; + + /// Meldeintervall in Sekunden ohne aktiven Auftrag. + final int intervalWithoutJobSeconds; + + /// Liefert das passende Meldeintervall abhängig davon, ob ein Auftrag aktiv ist. + int intervalSeconds({required bool withJob}) => + withJob ? intervalWithJobSeconds : intervalWithoutJobSeconds; +} + +/// Feste Parameter für die Zustandsbestimmung des Trackings. +class TrackTuning { + TrackTuning._(); + + /// Feste Sampling-Rate = kleinste vorkommende Intervallzeit (15 s). + static const Duration samplingInterval = Duration(seconds: 15); + + /// Obergrenze (exklusiv) für "langsam" in m/s. + static const double movingSlowMaxSpeed = 1.5; + + /// Obergrenze (exklusiv) für "mittel" in m/s. + static const double movingMediumMaxSpeed = 17.0; + + /// Vorlaufzeit, ab der ein akzeptierter Auftrag als aktiv gilt (15 min vor ordertime). + static const int activeJobLeadSeconds = 15 * 60; +} diff --git a/pubspec.yaml b/pubspec.yaml index d83622f..b317407 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -16,13 +16,11 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev # 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: 352.0.0+352 +version: 354.0.0+354 environment: sdk: '>=3.9.0 <4.0.0' - - # Dependencies specify other packages that your package needs in order to work. # To automatically upgrade your package dependencies to the latest versions # consider running `flutter pub upgrade --major-versions`. Alternatively,