From 2aa07ae8367933798433807500eac4dabb9be49a Mon Sep 17 00:00:00 2001 From: Agus Darmawan Date: Sat, 14 Nov 2026 13:10:39 +0700 Subject: [PATCH 1/2] feat: the robot's trip meter on the drive screen, hold to reset it --- lib/l10n.dart | 12 +++++ lib/protocol/messages.dart | 66 +++++++++++++++++++++++ lib/screens/drive_screen.dart | 33 +++++++++--- lib/screens/settings_screen.dart | 1 + lib/settings.dart | 5 ++ lib/widgets/trip_panel.dart | 93 ++++++++++++++++++++++++++++++++ test/protocol_test.dart | 19 +++++++ test/screenshots_test.dart | 1 + test/trip_panel_test.dart | 57 ++++++++++++++++++++ 9 files changed, 281 insertions(+), 6 deletions(-) create mode 100644 lib/widgets/trip_panel.dart create mode 100644 test/trip_panel_test.dart diff --git a/lib/l10n.dart b/lib/l10n.dart index 08bbf13..4269af8 100644 --- a/lib/l10n.dart +++ b/lib/l10n.dart @@ -179,6 +179,11 @@ const _en = { 'speed_unavailable': 'Navigation is not running (teach mode, or starting).', 'speed_unsupported': 'This robot cannot set its patrol speed yet: update Pashupati.', 'glance_waiting': 'AI: waiting for a result', + 'trip_distance': '{d} · moving {t}', + 'trip_speed': 'Now {v} m/s', + 'trip_average': 'Avg {a} · top {m} m/s', + 'trip_hold': 'Hold to reset to zero', + 'trip_reset': 'Trip reset to zero', 'ai_loading': 'AI: loading the model…', 'ai_off': 'AI off', 'ai_error': 'AI: error', @@ -196,6 +201,7 @@ const _en = { 'people_missing': 'No person detection here: PEOPLE=true in pashupati.env, then restart.', 'people_detail': '{n} in view · {ms} ms · {backend}', 'show_load': 'Robot CPU, GPU and RAM on screen', + 'show_trip': 'Distance and speed on screen', // routes 'routes': 'Routes', 'refresh': 'Refresh', @@ -400,6 +406,11 @@ const _id = { 'speed_unavailable': 'Navigasi tidak jalan (mode teach, atau sedang mulai).', 'speed_unsupported': 'Robot ini belum bisa atur kecepatan patroli: update Pashupati.', 'glance_waiting': 'AI: menunggu hasil', + 'trip_distance': '{d} · jalan {t}', + 'trip_speed': 'Sekarang {v} m/s', + 'trip_average': 'Rata² {a} · maks {m} m/s', + 'trip_hold': 'Tahan untuk nol-kan', + 'trip_reset': 'Trip di-nol-kan', 'ai_loading': 'AI: memuat model…', 'ai_off': 'AI mati', 'ai_error': 'AI: error', @@ -417,6 +428,7 @@ const _id = { 'people_missing': 'Deteksi orang tidak jalan: set PEOPLE=true di pashupati.env lalu restart.', 'people_detail': '{n} terlihat · {ms} ms · {backend}', 'show_load': 'CPU, GPU dan RAM robot di layar', + 'show_trip': 'Jarak dan kecepatan di layar', 'routes': 'Rute', 'refresh': 'Muat ulang', 'no_routes': 'Belum ada rute. Rekam satu dulu.', diff --git a/lib/protocol/messages.dart b/lib/protocol/messages.dart index 80baf86..7f14a51 100644 --- a/lib/protocol/messages.dart +++ b/lib/protocol/messages.dart @@ -23,6 +23,7 @@ const pashupatiCommands = { 'inspection_categories', 'people_set', 'nav_speed', + 'trip_reset', }; double? _num(Object? value) => value is num && value.isFinite ? value.toDouble() : null; @@ -191,6 +192,7 @@ class RobotState { this.people, this.load, this.speed, + this.trip, }); factory RobotState.fromJson(Map json) { @@ -232,6 +234,7 @@ class RobotState { people: PeopleStatus.fromJson(json['people']), load: SystemLoad.fromJson(health), speed: PatrolSpeed.fromJson(json['speed']), + trip: TripStatus.fromJson(json['trip']), ); } @@ -273,6 +276,10 @@ class RobotState { /// Patrol speed of the navigation; null when navigation does not run. final PatrolSpeed? speed; + /// Distance and speeds since the last trip_reset, counted on the robot; null on a robot + /// without the trip meter. + final TripStatus? trip; + bool get remote => mode == 'remote'; bool get localized => localization.isNotEmpty && localization != 'none'; @@ -465,6 +472,65 @@ class PatrolSpeed { final double max; } +/// `state.trip`: distance and speeds since the last `trip_reset`, counted on the robot from its +/// LiDAR odometry (dharma_odometry), also while no handheld is connected. +class TripStatus { + const TripStatus({ + required this.distance, + required this.movingSeconds, + required this.speed, + required this.average, + required this.top, + this.since, + }); + + /// Null unless the distance and the speeds are numbers. + static TripStatus? fromJson(Object? json) { + if (json is! Map) return null; + final distance = _num(json['m']); + final speed = _num(json['v']); + if (distance == null || speed == null) return null; + final since = _num(json['since']); + return TripStatus( + distance: distance, + movingSeconds: _num(json['s']) ?? 0, + speed: speed, + average: _num(json['avg']) ?? 0, + top: _num(json['max']) ?? 0, + since: since != null && since > 0 + ? DateTime.fromMillisecondsSinceEpoch((since * 1000).round()) + : null, + ); + } + + /// m travelled. + final double distance; + + /// s spent moving. + final double movingSeconds; + + /// m/s now, average while moving, and the highest. + final double speed; + final double average; + final double top; + + /// When the trip was last reset. + final DateTime? since; +} + +/// "12.3 m" below a kilometre, "1.24 km" above. +String formatDistance(double metres) => + metres < 1000 ? '${metres.toStringAsFixed(1)} m' : '${(metres / 1000).toStringAsFixed(2)} km'; + +/// "m:ss" below an hour, "h:mm:ss" above. +String formatDuration(double seconds) { + final s = seconds.floor(); + final h = s ~/ 3600; + final m = (s % 3600) ~/ 60; + final rest = (s % 60).toString().padLeft(2, '0'); + return h > 0 ? '$h:${m.toString().padLeft(2, '0')}:$rest' : '$m:$rest'; +} + /// `state.people`: the robot's person detection (person_detector_node). class PeopleStatus { const PeopleStatus({ diff --git a/lib/screens/drive_screen.dart b/lib/screens/drive_screen.dart index d43c522..ed54fe8 100644 --- a/lib/screens/drive_screen.dart +++ b/lib/screens/drive_screen.dart @@ -15,6 +15,7 @@ import '../theme.dart'; import '../widgets/camera_view.dart'; import '../widgets/glance.dart'; import '../widgets/hud.dart'; +import '../widgets/trip_panel.dart'; import '../widgets/map_view.dart'; import 'patrol_screen.dart'; import 'routes_screen.dart'; @@ -228,6 +229,12 @@ class _DriveScreenState extends State { return result; } + /// Holding the trip panel: the robot starts its trip meter again from zero. + void _resetTrip() { + HapticFeedback.mediumImpact(); + _run('trip_reset', label: s.t('trip_reset')); + } + void _estop() { HapticFeedback.heavyImpact(); _show(Toast(s.t('estop_sent'), color: Palette.stop, icon: Icons.pan_tool_rounded)); @@ -515,7 +522,7 @@ class _DriveScreenState extends State { // the map listens to the viewport itself: a pinch does not rebuild the whole screen child: ListenableBuilder(listenable: _viewport, builder: (context, _) => _viewButtons()), ), - // last inspection, people in view and the robot's load, above the toolbar + // last inspection, people in view, the robot's load and its trip, above the toolbar if (size.height - (top + insetHeight + 52) - 70 > 30) Positioned( key: const ValueKey('glance'), @@ -527,11 +534,25 @@ class _DriveScreenState extends State { alignment: Alignment.topLeft, child: SingleChildScrollView( physics: const NeverScrollableScrollPhysics(), - child: GlancePanel( - state: state, - language: s.settings.language, - showLoad: s.settings.showLoad, - onTap: (client.welcome?.has('inspection_set') ?? false) ? _openPatrol : null, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + GlancePanel( + state: state, + language: s.settings.language, + showLoad: s.settings.showLoad, + onTap: (client.welcome?.has('inspection_set') ?? false) ? _openPatrol : null, + ), + if (s.settings.showTrip && state.trip != null) ...[ + const SizedBox(height: 6), + TripPanel( + trip: state.trip!, + language: s.settings.language, + onReset: (client.welcome?.has('trip_reset') ?? false) ? _resetTrip : null, + ), + ], + ], ), ), ), diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index cca5791..aef1d19 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -154,6 +154,7 @@ class _SettingsScreenState extends State { ), _switch(t('lidar_on_map'), _settings.lidar, (value) => _settings.update(lidar: value)), _switch(t('show_load'), _settings.showLoad, (value) => _settings.update(showLoad: value)), + _switch(t('show_trip'), _settings.showTrip, (value) => _settings.update(showTrip: value)), _choice( t('language'), SegmentedButton( diff --git a/lib/settings.dart b/lib/settings.dart index b2c0a8d..0a5f54c 100644 --- a/lib/settings.dart +++ b/lib/settings.dart @@ -49,6 +49,9 @@ class AppSettings extends ChangeNotifier { /// The robot computer's CPU, GPU and memory use on the drive screen (for development). bool get showLoad => _prefs.getBool('show_load') ?? true; + /// The robot's trip meter (distance, speeds) on the drive screen. + bool get showTrip => _prefs.getBool('show_trip') ?? true; + /// Inspection time of a stop point marked from the handheld, seconds. double get stopDwell => _prefs.getDouble('dwell') ?? 10; @@ -69,6 +72,7 @@ class AppSettings extends ChangeNotifier { String? mainView, bool? deadman, bool? showLoad, + bool? showTrip, double? stopDwell, int? gear, }) async { @@ -84,6 +88,7 @@ class AppSettings extends ChangeNotifier { if (mainView != null) _prefs.setString('main_view', mainView), if (deadman != null) _prefs.setBool('deadman', deadman), if (showLoad != null) _prefs.setBool('show_load', showLoad), + if (showTrip != null) _prefs.setBool('show_trip', showTrip), if (stopDwell != null) _prefs.setDouble('dwell', stopDwell), if (gear != null) _prefs.setInt('gear', _gear(gear)), ]; diff --git a/lib/widgets/trip_panel.dart b/lib/widgets/trip_panel.dart new file mode 100644 index 0000000..7e0ea09 --- /dev/null +++ b/lib/widgets/trip_panel.dart @@ -0,0 +1,93 @@ +import 'package:flutter/material.dart'; + +import '../l10n.dart'; +import '../protocol/messages.dart'; +import '../theme.dart'; +import 'hud.dart'; + +/// The robot's trip meter under the glance panel: distance and moving time, the speed now, the +/// average and the top speed since the last reset. The robot counts (also while no handheld is +/// connected); holding the panel asks it to start again from zero. +class TripPanel extends StatelessWidget { + const TripPanel({super.key, required this.trip, required this.language, this.onReset}); + + final TripStatus trip; + final String language; + + /// Hold to reset; null on a robot that cannot (no trip_reset command). + final VoidCallback? onReset; + + String t(String key, [Map args = const {}]) => tr(language, key, args); + + @override + Widget build(BuildContext context) { + return GestureDetector( + key: const ValueKey('trip'), + behavior: HitTestBehavior.opaque, + onLongPress: onReset, + child: Glass( + radius: 12, + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 5), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _row( + Icons.route_rounded, + Palette.text, + t('trip_distance', { + 'd': formatDistance(trip.distance), + 't': formatDuration(trip.movingSeconds), + }), + strong: true, + ), + _row( + Icons.speed_rounded, + trip.speed > 0 ? Palette.auto : Palette.muted, + t('trip_speed', {'v': trip.speed.toStringAsFixed(2)}), + ), + _row( + Icons.show_chart_rounded, + Palette.muted, + t('trip_average', { + 'a': trip.average.toStringAsFixed(2), + 'm': trip.top.toStringAsFixed(2), + }), + ), + if (onReset != null) + Padding( + padding: const EdgeInsets.only(top: 1), + child: Text( + t('trip_hold'), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Txt.small.copyWith(color: Palette.muted, fontSize: 10), + ), + ), + ], + ), + ), + ); + } + + Widget _row(IconData icon, Color color, String text, {bool strong = false}) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 1.5), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 14, color: color), + const SizedBox(width: 5), + Flexible( + child: Text( + text, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: strong ? Txt.label.copyWith(color: color) : Txt.small, + ), + ), + ], + ), + ); + } +} diff --git a/test/protocol_test.dart b/test/protocol_test.dart index ff11d79..b218686 100644 --- a/test/protocol_test.dart +++ b/test/protocol_test.dart @@ -203,6 +203,25 @@ void main() { expect(PeopleStatus.fromJson({'state': 'loading'})!.count, isNull); }); + test('the trip meter in the state', () { + final trip = RobotState.fromJson({ + 'trip': {'m': 1234.5, 's': 3725, 'v': 0.41, 'avg': 0.38, 'max': 0.92, 'since': 1794550000}, + }).trip!; + expect(trip.distance, 1234.5); + expect(trip.movingSeconds, 3725); + expect(trip.speed, 0.41); + expect(trip.average, 0.38); + expect(trip.top, 0.92); + expect(trip.since, DateTime.fromMillisecondsSinceEpoch(1794550000000)); + expect(RobotState.fromJson({}).trip, isNull); // a robot without the trip meter + expect(TripStatus.fromJson({'m': 'far', 'v': 0.1}), isNull); + expect(TripStatus.fromJson({'m': 2.0, 'v': 0.0})!.since, isNull); + expect(formatDistance(12.34), '12.3 m'); + expect(formatDistance(1234.5), '1.23 km'); + expect(formatDuration(75), '1:15'); + expect(formatDuration(3725), '1:02:05'); + }); + test('inspection models and their names', () { final models = InspectionModel.listFromJson([ {'name': 'moondream:latest', 'installed': true, 'active': true}, diff --git a/test/screenshots_test.dart b/test/screenshots_test.dart index a15b91a..742d314 100644 --- a/test/screenshots_test.dart +++ b/test/screenshots_test.dart @@ -135,6 +135,7 @@ Map _state({ }, 'people': {'on': true, 'state': 'running', 'count': 2, 'ms': 18, 'backend': 'onnxruntime-cuda'}, 'speed': {'target': 0.4, 'min': 0.1, 'max': 0.6}, + 'trip': {'m': 1284.6, 's': 3412, 'v': 0.38, 'avg': 0.36, 'max': 0.61, 'since': 1794540000}, }; /// The robot's answers to the pages' own commands. diff --git a/test/trip_panel_test.dart b/test/trip_panel_test.dart new file mode 100644 index 0000000..b402773 --- /dev/null +++ b/test/trip_panel_test.dart @@ -0,0 +1,57 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_teleop/protocol/messages.dart'; +import 'package:flutter_teleop/theme.dart'; +import 'package:flutter_teleop/widgets/trip_panel.dart'; +import 'package:flutter_test/flutter_test.dart'; + +const _trip = TripStatus( + distance: 1284.6, + movingSeconds: 3412, + speed: 0.38, + average: 0.36, + top: 0.61, +); + +Future show(WidgetTester tester, TripStatus trip, {VoidCallback? onReset}) { + return tester.pumpWidget( + MaterialApp( + theme: buildTheme(), + home: Scaffold( + body: Align( + alignment: Alignment.topLeft, + // as wide as the small view on a Retroid Pocket + child: SizedBox( + width: 180, + child: TripPanel(trip: trip, language: 'id', onReset: onReset), + ), + ), + ), + ), + ); +} + +void main() { + testWidgets('distance, moving time and the speeds of the robot', (tester) async { + await show(tester, _trip, onReset: () {}); + expect(find.text('1.28 km · jalan 56:52'), findsOneWidget); + expect(find.text('Sekarang 0.38 m/s'), findsOneWidget); + expect(find.text('Rata² 0.36 · maks 0.61 m/s'), findsOneWidget); + expect(find.text('Tahan untuk nol-kan'), findsOneWidget); + }); + + testWidgets('holding the panel asks the robot to reset; a tap does not', (tester) async { + var resets = 0; + await show(tester, _trip, onReset: () => resets++); + await tester.tap(find.byKey(const ValueKey('trip'))); + expect(resets, 0); + await tester.longPress(find.byKey(const ValueKey('trip'))); + expect(resets, 1); + }); + + testWidgets('a robot that cannot reset shows no hint', (tester) async { + const zero = TripStatus(distance: 0, movingSeconds: 0, speed: 0, average: 0, top: 0); + await show(tester, zero); + expect(find.text('0.0 m · jalan 0:00'), findsOneWidget); + expect(find.text('Tahan untuk nol-kan'), findsNothing); + }); +} From 19cf42815435ab957fd25859a8067ee482486dc0 Mon Sep 17 00:00:00 2001 From: Agus Darmawan Date: Sat, 14 Nov 2026 13:46:36 +0700 Subject: [PATCH 2/2] chore(release): 2.7.0 with the trip meter --- README.md | 15 +++++++++------ lib/screens/connect_screen.dart | 2 +- pubspec.yaml | 2 +- 3 files changed, 11 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 06508a7..7511594 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ them, watch through their camera, teach routes and run their AI inspection, from Pocket 4 or any Android device in landscape.** [![CI](https://github.com/DarmawanRobotics/PashupatiController/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/DarmawanRobotics/PashupatiController/actions/workflows/ci.yml) -![Version](https://img.shields.io/badge/version-2.6.1-4FD1C5) +![Version](https://img.shields.io/badge/version-2.7.0-4FD1C5) ![Flutter](https://img.shields.io/badge/Flutter-3.35%2B-02569B?logo=flutter&logoColor=white) ![Android](https://img.shields.io/badge/Android-11%2B-3DDC84?logo=android&logoColor=white) ![Robot](https://img.shields.io/badge/robot-ROS%202%20Humble-22314E?logo=ros&logoColor=white) @@ -66,6 +66,9 @@ and takes the video over WebRTC. There is no ROS on the handheld. - Battery, link quality, protective field, health and the localization source (identity, tag or pose). - The robot's CPU, GPU and RAM use and its temperature, at a glance, for development. +- Trip meter under the glance panel: distance and moving time, speed now, average and top speed + since the last reset. The robot counts (also with no handheld connected) and keeps it across + restarts; hold the panel to reset it to zero. Needs Pashupati with `odometry/trip`. **Everything else** - Finds robots on the LAN by itself, and connects with a token. @@ -79,7 +82,7 @@ and takes the video over WebRTC. There is no ROS on the handheld. browser or file manager once), or from a computer: ```bash -adb install -r pashupati-controller-2.6.2.apk # -r: update in place, settings kept +adb install -r pashupati-controller-2.7.0.apk # -r: update in place, settings kept ``` Releases are signed with one release key, so a newer release installs over an older one. @@ -171,7 +174,7 @@ lib/ ├── net/ teleop_client.dart (session, commands, joystick), discovery.dart, │ webrtc_video.dart + whep.dart (camera over WebRTC) ├── input/ gamepad_input.dart (gamepads, normalized) -├── widgets/ hud, camera (tag and person boxes), map, glance (AI, people, load) +├── widgets/ hud, camera (tag and person boxes), map, glance (AI, people, load), trip ├── screens/ connect, drive (home), patrol (inspection), routes, settings ├── robot_actions.dart icons and labels of known robot commands └── l10n.dart Indonesian and English text @@ -192,11 +195,11 @@ Bump `version` in `pubspec.yaml` (the name and the `+` build number), merge it t that commit with the same version: ```bash -git tag v2.6.2 && git push origin v2.6.2 +git tag v2.7.0 && git push origin v2.7.0 ``` -The `release` workflow tests, builds and publishes `pashupati-controller-2.6.2.apk` (and its -SHA-256) as the GitHub Release `v2.6.2`. A tag that differs from `pubspec.yaml` fails the run. +The `release` workflow tests, builds and publishes `pashupati-controller-2.7.0.apk` (and its +SHA-256) as the GitHub Release `v2.7.0`. A tag that differs from `pubspec.yaml` fails the run. The release key lives in four repository secrets (Settings → Secrets and variables → Actions): `ANDROID_KEYSTORE_BASE64` (the keystore, `base64 -w0 release.jks`), `ANDROID_KEYSTORE_PASSWORD`, diff --git a/lib/screens/connect_screen.dart b/lib/screens/connect_screen.dart index 68104fe..a478522 100644 --- a/lib/screens/connect_screen.dart +++ b/lib/screens/connect_screen.dart @@ -10,7 +10,7 @@ import '../widgets/hud.dart'; import 'drive_screen.dart'; import 'settings_screen.dart'; -const appVersion = '2.6.1'; // the version in pubspec.yaml (test/version_test.dart) +const appVersion = '2.7.0'; // the version in pubspec.yaml (test/version_test.dart) /// Robot picker: the main robot from the settings plus every robot that answers on the LAN. class ConnectScreen extends StatefulWidget { diff --git a/pubspec.yaml b/pubspec.yaml index 433b102..4634db8 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: flutter_teleop description: Handheld teleop for Pashupati robots (Retroid Pocket and other Android devices). publish_to: none -version: 2.6.1+29 +version: 2.7.0+30 environment: sdk: ^3.9.0