From 88843128b050f6ffbdaa2ee46ef52712d930c318 Mon Sep 17 00:00:00 2001 From: Sharjeel Yunus Date: Tue, 3 Feb 2026 01:06:45 +0500 Subject: [PATCH 1/4] feat(activity): add motion data actions and activity manager - Introduced `GetMotionDataAction` and `StopMotionDataAction` for handling motion sensor data. - Implemented `ActivityManager` and its stub for managing motion data streams. - Updated `ScopeManager` to support motion listener management. - Added new `activity_module` for integrating motion functionalities into the Ensemble framework. - Enhanced documentation for usage of motion data actions in YAML configurations. --- .../ensemble/lib/action/action_invokable.dart | 2 + .../ensemble/lib/action/getMotionData.dart | 152 +++++++++++++++ modules/ensemble/lib/framework/action.dart | 9 + modules/ensemble/lib/framework/scope.dart | 27 +++ .../lib/framework/stub/activity_manager.dart | 101 ++++++++++ .../ensemble/lib/module/activity_module.dart | 10 + modules/ensemble_activity/README.md | 48 +++++ .../lib/activity_manager.dart | 173 ++++++++++++++++++ .../lib/activity_module.dart | 11 ++ modules/ensemble_activity/pubspec.yaml | 67 +++++++ starter/lib/generated/ensemble_modules.dart | 12 ++ starter/pubspec.yaml | 7 + 12 files changed, 619 insertions(+) create mode 100644 modules/ensemble/lib/action/getMotionData.dart create mode 100644 modules/ensemble/lib/framework/stub/activity_manager.dart create mode 100644 modules/ensemble/lib/module/activity_module.dart create mode 100644 modules/ensemble_activity/README.md create mode 100644 modules/ensemble_activity/lib/activity_manager.dart create mode 100644 modules/ensemble_activity/lib/activity_module.dart create mode 100644 modules/ensemble_activity/pubspec.yaml diff --git a/modules/ensemble/lib/action/action_invokable.dart b/modules/ensemble/lib/action/action_invokable.dart index d16bc50d0..9eb17431d 100644 --- a/modules/ensemble/lib/action/action_invokable.dart +++ b/modules/ensemble/lib/action/action_invokable.dart @@ -46,6 +46,8 @@ abstract class ActionInvokable with Invokable { ActionType.controlDeviceBackNavigation, ActionType.closeApp, ActionType.getLocation, + ActionType.getMotionData, + ActionType.stopMotionData, ActionType.pickFiles, ActionType.openPlaidLink, ActionType.updateBadgeCount, diff --git a/modules/ensemble/lib/action/getMotionData.dart b/modules/ensemble/lib/action/getMotionData.dart new file mode 100644 index 000000000..78ba0827c --- /dev/null +++ b/modules/ensemble/lib/action/getMotionData.dart @@ -0,0 +1,152 @@ +import 'dart:async'; +import 'package:ensemble/framework/action.dart'; +import 'package:ensemble/framework/error_handling.dart'; +import 'package:ensemble/framework/event.dart'; +import 'package:ensemble/framework/scope.dart'; +import 'package:ensemble/framework/stub/activity_manager.dart'; +import 'package:ensemble/screen_controller.dart'; +import 'package:ensemble/util/utils.dart'; +import 'package:ensemble_ts_interpreter/invokables/invokable.dart'; +import 'package:flutter/material.dart'; +import 'package:get_it/get_it.dart'; + +class GetMotionDataAction extends EnsembleAction { + GetMotionDataAction({ + super.initiator, + this.id, + this.onDataReceived, + this.onError, + this.recurring, + this.sensorType, + this.updateInterval, + }); + + final String? id; + final EnsembleAction? onDataReceived; + final EnsembleAction? onError; + final bool? recurring; + final MotionSensorType? sensorType; + final int? updateInterval; // in milliseconds + + factory GetMotionDataAction.fromYaml({Invokable? initiator, Map? payload}) { + MotionSensorType? sensorType; + if (payload?['options']?['sensorType'] != null) { + final sensorTypeStr = Utils.getString( + payload?['options']?['sensorType'], + fallback: 'all', + ); + sensorType = MotionSensorType.values.firstWhere( + (e) => e.name == sensorTypeStr.toLowerCase(), + orElse: () => MotionSensorType.all, + ); + } + + return GetMotionDataAction( + initiator: initiator, + id: Utils.optionalString(payload?['id']), + onDataReceived: EnsembleAction.from(payload?['onDataReceived']), + onError: EnsembleAction.from(payload?['onError']), + recurring: Utils.optionalBool(payload?['options']?['recurring']), + sensorType: sensorType, + updateInterval: Utils.optionalInt(payload?['options']?['updateInterval']), + ); + } + + @override + Future execute(BuildContext context, ScopeManager scopeManager) async { + if (onDataReceived == null) { + throw LanguageError( + '${ActionType.getMotionData.name} requires onDataReceived callback'); + } + + try { + // Handle recurring motion updates + if (recurring == true) { + StreamSubscription streamSubscription = + GetIt.I() + .startMotionStream( + sensorType: sensorType, + updateInterval: updateInterval != null + ? Duration(milliseconds: updateInterval!) + : null, + ) + .listen((MotionData? data) { + if (data != null) { + ScreenController().executeActionWithScope( + context, + scopeManager, + onDataReceived!, + event: EnsembleEvent(initiator, data: data.toJson()), + ); + } else if (onError != null) { + ScreenController().executeActionWithScope( + context, + scopeManager, + onError!, + event: EnsembleEvent(initiator, error: 'unknown'), + ); + } + }, onError: (error) { + if (onError != null) { + ScreenController().executeActionWithScope( + context, + scopeManager, + onError!, + event: EnsembleEvent(initiator, error: error.toString()), + ); + } + }); + // Store subscription for cleanup + scopeManager.addMotionListener(streamSubscription, id: id); + } + // Handle one-time motion request + else { + final data = await GetIt.I() + .getMotionData(sensorType: sensorType); + if (data != null) { + ScreenController().executeActionWithScope( + context, + scopeManager, + onDataReceived!, + event: EnsembleEvent(initiator, data: data.toJson()), + ); + } else if (onError != null) { + ScreenController().executeActionWithScope( + context, + scopeManager, + onError!, + event: EnsembleEvent(initiator, error: 'noData'), + ); + } + } + } catch (e) { + if (onError != null) { + ScreenController().executeActionWithScope( + context, + scopeManager, + onError!, + event: EnsembleEvent(initiator, error: e.toString()), + ); + } else { + rethrow; + } + } + } +} + +class StopMotionDataAction extends EnsembleAction { + StopMotionDataAction({this.id}); + + final String? id; + + factory StopMotionDataAction.fromYaml({Map? payload}) { + return StopMotionDataAction( + id: Utils.optionalString(payload?['id']), + ); + } + + @override + Future execute(BuildContext context, ScopeManager scopeManager) async { + scopeManager.stopMotionListener(id); + } +} diff --git a/modules/ensemble/lib/framework/action.dart b/modules/ensemble/lib/framework/action.dart index 45475125c..e018a27d8 100644 --- a/modules/ensemble/lib/framework/action.dart +++ b/modules/ensemble/lib/framework/action.dart @@ -33,6 +33,7 @@ import 'package:ensemble/action/take_screenshot.dart'; import 'package:ensemble/action/disable_hardware_navigation.dart'; import 'package:ensemble/action/close_app.dart'; import 'package:ensemble/action/getLocation.dart'; +import 'package:ensemble/action/getMotionData.dart'; import 'package:ensemble/action/wakelock_action.dart'; import 'package:ensemble/framework/apiproviders/api_provider.dart'; import 'package:ensemble/framework/apiproviders/http_api_provider.dart'; @@ -1051,6 +1052,9 @@ enum ActionType { // Stripe actions initializeStripe, showPaymentSheet, + // Activity actions + getMotionData, + stopMotionData, } /// payload representing an Action to do (navigateToScreen, InvokeAPI, ..) @@ -1304,6 +1308,11 @@ abstract class EnsembleAction { } else if (actionType == ActionType.showPaymentSheet) { return ShowPaymentSheetAction.fromYaml( initiator: initiator, payload: payload); + } else if (actionType == ActionType.getMotionData) { + return GetMotionDataAction.fromYaml( + initiator: initiator, payload: payload); + } else if (actionType == ActionType.stopMotionData) { + return StopMotionDataAction.fromYaml(payload: payload); } else { throw LanguageError("Invalid action.", recovery: "Make sure to use one of Ensemble-provided actions."); diff --git a/modules/ensemble/lib/framework/scope.dart b/modules/ensemble/lib/framework/scope.dart index 6b570b2a9..23b1dca65 100644 --- a/modules/ensemble/lib/framework/scope.dart +++ b/modules/ensemble/lib/framework/scope.dart @@ -12,6 +12,7 @@ import 'package:ensemble/framework/ensemble_widget.dart'; import 'package:ensemble/framework/error_handling.dart'; import 'package:ensemble/framework/event.dart'; import 'package:ensemble/framework/stub/location_manager.dart'; +import 'package:ensemble/framework/stub/activity_manager.dart'; import 'package:ensemble/framework/theme_manager.dart'; import 'package:ensemble/framework/view/data_scope_widget.dart'; import 'package:ensemble/framework/view/page.dart'; @@ -81,6 +82,8 @@ class ScopeManager extends IsScopeManager with ViewBuilder, PageBindingManager { // cancel the screen's location listener pageData.locationListener?.cancel(); + // cancel the screen's motion listener + pageData.motionListener?.cancel(); SocketService().dispose(); } @@ -93,6 +96,26 @@ class ScopeManager extends IsScopeManager with ViewBuilder, PageBindingManager { pageData.locationListener = streamSubscription; } + /// only 1 motion listener per screen + void addMotionListener(StreamSubscription streamSubscription, + {String? id}) { + // first cancel the previous one + pageData.motionListener?.cancel(); + pageData.motionListener = streamSubscription; + pageData.motionListenerId = id; + } + + /// stop the motion listener + void stopMotionListener([String? id]) { + // if id is provided, only cancel if it matches + if (id != null && pageData.motionListenerId != id) { + return; + } + pageData.motionListener?.cancel(); + pageData.motionListener = null; + pageData.motionListenerId = null; + } + // add repeating timer so we can manage it later. void addTimer(StartTimerAction timerAction, Timer timer) { EnsembleTimer newTimer = EnsembleTimer(timer, id: timerAction.id); @@ -708,6 +731,10 @@ class PageData { /// 1 recurring location listener per page StreamSubscription? locationListener; + /// 1 recurring motion listener per page + StreamSubscription? motionListener; + String? motionListenerId; + // list of all opened Dialogs' contexts final List openedDialogs = []; diff --git a/modules/ensemble/lib/framework/stub/activity_manager.dart b/modules/ensemble/lib/framework/stub/activity_manager.dart new file mode 100644 index 000000000..f28ce267c --- /dev/null +++ b/modules/ensemble/lib/framework/stub/activity_manager.dart @@ -0,0 +1,101 @@ +import 'dart:async'; +import 'package:ensemble/framework/error_handling.dart'; + +abstract class ActivityManager { + // Motion sensor methods + Stream startMotionStream({ + MotionSensorType? sensorType, + Duration? updateInterval, + }); + void stopMotionStream(); + Future getMotionData({MotionSensorType? sensorType}); +} + +class ActivityManagerStub extends ActivityManager { + @override + Stream startMotionStream({ + MotionSensorType? sensorType, + Duration? updateInterval, + }) { + throw ConfigError( + "Activity Manager is not enabled. Please review the Ensemble documentation."); + } + + @override + void stopMotionStream() { + throw ConfigError( + "Activity Manager is not enabled. Please review the Ensemble documentation."); + } + + @override + Future getMotionData({MotionSensorType? sensorType}) { + throw ConfigError( + "Activity Manager is not enabled. Please review the Ensemble documentation."); + } +} + +class MotionData { + MotionData({ + this.accelerometer, + this.gyroscope, + this.magnetometer, + required this.timestamp, + }); + + final AccelerometerData? accelerometer; + final GyroscopeData? gyroscope; + final MagnetometerData? magnetometer; + final DateTime timestamp; + + Map toJson() { + return { + 'accelerometer': accelerometer?.toJson(), + 'gyroscope': gyroscope?.toJson(), + 'magnetometer': magnetometer?.toJson(), + 'timestamp': timestamp.toIso8601String(), + }; + } +} + +class AccelerometerData { + AccelerometerData({required this.x, required this.y, required this.z}); + + final double x; + final double y; + final double z; + + Map toJson() { + return {'x': x, 'y': y, 'z': z}; + } +} + +class GyroscopeData { + GyroscopeData({required this.x, required this.y, required this.z}); + + final double x; + final double y; + final double z; + + Map toJson() { + return {'x': x, 'y': y, 'z': z}; + } +} + +class MagnetometerData { + MagnetometerData({required this.x, required this.y, required this.z}); + + final double x; + final double y; + final double z; + + Map toJson() { + return {'x': x, 'y': y, 'z': z}; + } +} + +enum MotionSensorType { + accelerometer, + gyroscope, + magnetometer, + all, +} diff --git a/modules/ensemble/lib/module/activity_module.dart b/modules/ensemble/lib/module/activity_module.dart new file mode 100644 index 000000000..62dd8e558 --- /dev/null +++ b/modules/ensemble/lib/module/activity_module.dart @@ -0,0 +1,10 @@ +import 'package:ensemble/framework/stub/activity_manager.dart'; +import 'package:get_it/get_it.dart'; + +abstract class ActivityModule {} + +class ActivityModuleStub implements ActivityModule { + ActivityModuleStub() { + GetIt.I.registerSingleton(ActivityManagerStub()); + } +} diff --git a/modules/ensemble_activity/README.md b/modules/ensemble_activity/README.md new file mode 100644 index 000000000..a3bd9562a --- /dev/null +++ b/modules/ensemble_activity/README.md @@ -0,0 +1,48 @@ +# Ensemble Activity + +This ensemble activity module handles motion sensor (direction, movement patterns) functionality for tracking user activity and movement. + +## Features + +### Motion Sensors +- Accelerometer data (x, y, z axes) +- Gyroscope data (rotation rates) +- Magnetometer data (compass direction) +- Combined motion data streams +- Configurable update intervals + +## Usage + +The module provides actions that can be used in Ensemble YAML configurations: + +- `getMotionData`: Get motion sensor data (accelerometer, gyroscope, magnetometer) + - `id`: Optional identifier for the motion data stream + - `options`: Optional options for the motion data stream + - `recurring`: Whether to get motion data recurringly + - `sensorType`: The type of sensor to get data from (accelerometer, gyroscope, magnetometer, all) + - `updateInterval`: The interval in milliseconds to get motion data (default is 1000 milliseconds) + - `onDataReceived`: The action to execute when motion data is received + - `onError`: The action to execute when an error occurs + - Example: + - ```yaml + getMotionData: + id: motion_stream_1 + options: + recurring: true + sensorType: all + updateInterval: 1000 + onDataReceived: + executeCode: + body: |- + console.log(event.data); + onError: + showToast: + message: "Error: ${event.error}" + ``` +- `stopMotionData`: Stop motion sensor data stream + - `id`: Optional identifier for the motion data stream + - Example: + - ```yaml + stopMotionData: + id: motion_stream_1 + ``` \ No newline at end of file diff --git a/modules/ensemble_activity/lib/activity_manager.dart b/modules/ensemble_activity/lib/activity_manager.dart new file mode 100644 index 000000000..6dc85dee7 --- /dev/null +++ b/modules/ensemble_activity/lib/activity_manager.dart @@ -0,0 +1,173 @@ +import 'dart:async'; +import 'package:ensemble/framework/stub/activity_manager.dart'; +import 'package:sensors_plus/sensors_plus.dart'; + +class ActivityManagerImpl extends ActivityManager { + StreamSubscription? _accelerometerSubscription; + StreamSubscription? _gyroscopeSubscription; + StreamSubscription? _magnetometerSubscription; + + final StreamController _motionController = + StreamController.broadcast(); + + // Track latest readings from each sensor for combining when sensorType is 'all' + AccelerometerData? _latestAccelerometer; + GyroscopeData? _latestGyroscope; + MagnetometerData? _latestMagnetometer; + + @override + Stream startMotionStream({ + MotionSensorType? sensorType, + Duration? updateInterval, + }) { + final sensor = sensorType ?? MotionSensorType.all; + + // Reset latest readings when starting a new stream + if (sensor == MotionSensorType.all) { + _latestAccelerometer = null; + _latestGyroscope = null; + _latestMagnetometer = null; + } + + if (sensor == MotionSensorType.accelerometer || + sensor == MotionSensorType.all) { + _accelerometerSubscription = accelerometerEvents.listen( + (AccelerometerEvent event) { + final accelerometerData = AccelerometerData( + x: event.x, + y: event.y, + z: event.z, + ); + + if (sensor == MotionSensorType.all) { + // Store latest accelerometer reading + _latestAccelerometer = accelerometerData; + // Emit combined data with all available sensors + _emitCombinedMotionData(); + } else { + // Emit single sensor data + final data = MotionData( + accelerometer: accelerometerData, + timestamp: DateTime.now(), + ); + _motionController.add(data); + } + }, + onError: (error) { + _motionController.addError(error); + }, + ); + } + + if (sensor == MotionSensorType.gyroscope || + sensor == MotionSensorType.all) { + _gyroscopeSubscription = gyroscopeEvents.listen( + (GyroscopeEvent event) { + final gyroscopeData = GyroscopeData( + x: event.x, + y: event.y, + z: event.z, + ); + + if (sensor == MotionSensorType.all) { + // Store latest gyroscope reading + _latestGyroscope = gyroscopeData; + // Emit combined data with all available sensors + _emitCombinedMotionData(); + } else { + // Emit single sensor data + final data = MotionData( + gyroscope: gyroscopeData, + timestamp: DateTime.now(), + ); + _motionController.add(data); + } + }, + onError: (error) { + _motionController.addError(error); + }, + ); + } + + if (sensor == MotionSensorType.magnetometer || + sensor == MotionSensorType.all) { + _magnetometerSubscription = magnetometerEvents.listen( + (MagnetometerEvent event) { + final magnetometerData = MagnetometerData( + x: event.x, + y: event.y, + z: event.z, + ); + + if (sensor == MotionSensorType.all) { + // Store latest magnetometer reading + _latestMagnetometer = magnetometerData; + // Emit combined data with all available sensors + _emitCombinedMotionData(); + } else { + // Emit single sensor data + final data = MotionData( + magnetometer: magnetometerData, + timestamp: DateTime.now(), + ); + _motionController.add(data); + } + }, + onError: (error) { + _motionController.addError(error); + }, + ); + } + + return _motionController.stream; + } + + void _emitCombinedMotionData() { + // Only emit if we have at least one sensor reading + // This ensures we don't emit empty data initially + if (_latestAccelerometer != null || + _latestGyroscope != null || + _latestMagnetometer != null) { + final data = MotionData( + accelerometer: _latestAccelerometer, + gyroscope: _latestGyroscope, + magnetometer: _latestMagnetometer, + timestamp: DateTime.now(), + ); + _motionController.add(data); + } + } + + @override + void stopMotionStream() { + _accelerometerSubscription?.cancel(); + _accelerometerSubscription = null; + _gyroscopeSubscription?.cancel(); + _gyroscopeSubscription = null; + _magnetometerSubscription?.cancel(); + _magnetometerSubscription = null; + // Clear latest readings when stopping + _latestAccelerometer = null; + _latestGyroscope = null; + _latestMagnetometer = null; + } + + @override + Future getMotionData({MotionSensorType? sensorType}) async { + try { + final sensor = sensorType ?? MotionSensorType.accelerometer; + final stream = startMotionStream(sensorType: sensor); + final data = await stream.first.timeout(const Duration(seconds: 2)); + stopMotionStream(); + return data; + } catch (e) { + stopMotionStream(); + return null; + } + } + + void dispose() { + stopMotionStream(); + _motionController.close(); + } +} diff --git a/modules/ensemble_activity/lib/activity_module.dart b/modules/ensemble_activity/lib/activity_module.dart new file mode 100644 index 000000000..715dccd5c --- /dev/null +++ b/modules/ensemble_activity/lib/activity_module.dart @@ -0,0 +1,11 @@ +import 'package:ensemble/framework/stub/activity_manager.dart'; +import 'package:ensemble/module/activity_module.dart'; +import 'package:get_it/get_it.dart'; +import 'activity_manager.dart'; + +class ActivityModuleImpl implements ActivityModule { + ActivityModuleImpl() { + GetIt.I.registerSingleton(ActivityManagerImpl()); + } +} + diff --git a/modules/ensemble_activity/pubspec.yaml b/modules/ensemble_activity/pubspec.yaml new file mode 100644 index 000000000..ef5958094 --- /dev/null +++ b/modules/ensemble_activity/pubspec.yaml @@ -0,0 +1,67 @@ +name: ensemble_activity +description: Activity module for Ensemble framework +version: 0.0.1 +homepage: https://github.com/EnsembleUI/ensemble/modules/activity +publish_to: none + +environment: + sdk: ">=3.5.0" + flutter: ">=3.24.0" + +dependencies: + flutter: + sdk: flutter + + ensemble: + git: + url: https://github.com/EnsembleUI/ensemble.git + ref: ensemble-v1.2.28 + path: modules/ensemble + + ensemble_ts_interpreter: ^1.0.6 + + get_it: ^8.0.0 + sensors_plus: ^7.0.0 + +dev_dependencies: + flutter_test: + sdk: flutter + flutter_lints: ^2.0.0 + +# 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: + + # To add assets to your package, add an assets section, like this: + # assets: + # - images/a_dot_burr.jpeg + # - images/a_dot_ham.jpeg + # + # For details regarding assets in packages, see + # https://flutter.dev/assets-and-images/#from-packages + # + # An image asset can refer to one or more resolution-specific "variants", see + # https://flutter.dev/assets-and-images/#resolution-aware + + # To add custom fonts to your package, 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-Regular.ttf + # style: italic + # - family: Trajan Pro + # fonts: + # - asset: fonts/TrajanPro.ttf + # - asset: fonts/TrajanPro_Bold.ttf + # weight: 700 + # + # For details regarding fonts in packages, see + # https://flutter.dev/custom-fonts/#from-packages + diff --git a/starter/lib/generated/ensemble_modules.dart b/starter/lib/generated/ensemble_modules.dart index 2935a6c54..1044e4a18 100644 --- a/starter/lib/generated/ensemble_modules.dart +++ b/starter/lib/generated/ensemble_modules.dart @@ -14,6 +14,7 @@ import 'package:ensemble/framework/stub/contacts_manager.dart'; import 'package:ensemble/framework/stub/plaid_link_manager.dart'; import 'package:ensemble/module/auth_module.dart'; import 'package:ensemble/module/location_module.dart'; +import 'package:ensemble/module/activity_module.dart'; import 'package:ensemble/framework/stub/moengage_manager.dart'; import 'package:ensemble/module/stripe_module.dart'; // import 'package:ensemble_stripe/ensemble_stripe.dart'; @@ -57,6 +58,9 @@ import 'package:get_it/get_it.dart'; // Uncomment to enable location services // import 'package:ensemble_location/location_manager.dart'; +// Uncomment to enable activity services +// import 'package:ensemble_activity/activity_module.dart'; + // Uncomment to enable deeplink services // import 'package:ensemble_deeplink/deferred_link_manager.dart'; @@ -81,6 +85,7 @@ class EnsembleModules { static const useContacts = false; static const useConnect = false; static const useLocation = false; + static const useActivity = false; static const useDeeplink = false; static const useFirebaseAnalytics = false; static const useNotifications = false; @@ -176,6 +181,13 @@ class EnsembleModules { GetIt.I.registerSingleton(LocationModuleStub()); } + if (useActivity) { + // Uncomment to enable ensemble_activity service + // GetIt.I.registerSingleton(ActivityModuleImpl()); + } else { + GetIt.I.registerSingleton(ActivityModuleStub()); + } + if (useDeeplink) { // Uncomment to enable ensemble_deeplink service // GetIt.I.registerSingleton(DeferredLinkManagerImpl()); diff --git a/starter/pubspec.yaml b/starter/pubspec.yaml index 30e98ebec..cdd9c5141 100644 --- a/starter/pubspec.yaml +++ b/starter/pubspec.yaml @@ -94,6 +94,13 @@ dependencies: # ref: main # path: modules/location + # Uncomment to enable activity module + # ensemble_activity: + # git: + # url: https://github.com/EnsembleUI/ensemble.git + # ref: main + # path: modules/ensemble_activity + # Uncomment to enable deeplink module # ensemble_deeplink: # git: From af5474fa9dd6fb4bde93c0879805e9c68297155d Mon Sep 17 00:00:00 2001 From: Sharjeel Yunus Date: Tue, 3 Feb 2026 13:12:44 +0500 Subject: [PATCH 2/4] chore(dependencies): update ensemble_ts_interpreter to version 1.0.7 in pubspec.yaml --- modules/ensemble_activity/pubspec.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/ensemble_activity/pubspec.yaml b/modules/ensemble_activity/pubspec.yaml index ef5958094..129837dfb 100644 --- a/modules/ensemble_activity/pubspec.yaml +++ b/modules/ensemble_activity/pubspec.yaml @@ -18,7 +18,7 @@ dependencies: ref: ensemble-v1.2.28 path: modules/ensemble - ensemble_ts_interpreter: ^1.0.6 + ensemble_ts_interpreter: ^1.0.7 get_it: ^8.0.0 sensors_plus: ^7.0.0 From d9454e7f7a386d2a2972dcc870e46d1124395116 Mon Sep 17 00:00:00 2001 From: M-Talha4 Date: Sat, 7 Feb 2026 20:48:09 +0500 Subject: [PATCH 3/4] feat(activity): enhance motion data handling with pedometer support --- .../ensemble/lib/action/getMotionData.dart | 5 +- .../lib/framework/stub/activity_manager.dart | 26 ++ modules/ensemble_activity/README.md | 216 +++++++++--- .../lib/activity_manager.dart | 324 +++++++++++------- modules/ensemble_activity/pubspec.yaml | 3 +- 5 files changed, 404 insertions(+), 170 deletions(-) diff --git a/modules/ensemble/lib/action/getMotionData.dart b/modules/ensemble/lib/action/getMotionData.dart index 78ba0827c..6dcf418b8 100644 --- a/modules/ensemble/lib/action/getMotionData.dart +++ b/modules/ensemble/lib/action/getMotionData.dart @@ -44,8 +44,9 @@ class GetMotionDataAction extends EnsembleAction { return GetMotionDataAction( initiator: initiator, id: Utils.optionalString(payload?['id']), - onDataReceived: EnsembleAction.from(payload?['onDataReceived']), - onError: EnsembleAction.from(payload?['onError']), + onDataReceived: + EnsembleAction.from(payload?['options']?['onDataReceived']), + onError: EnsembleAction.from(payload?['options']?['onError']), recurring: Utils.optionalBool(payload?['options']?['recurring']), sensorType: sensorType, updateInterval: Utils.optionalInt(payload?['options']?['updateInterval']), diff --git a/modules/ensemble/lib/framework/stub/activity_manager.dart b/modules/ensemble/lib/framework/stub/activity_manager.dart index f28ce267c..a718f022f 100644 --- a/modules/ensemble/lib/framework/stub/activity_manager.dart +++ b/modules/ensemble/lib/framework/stub/activity_manager.dart @@ -39,12 +39,14 @@ class MotionData { this.accelerometer, this.gyroscope, this.magnetometer, + this.pedometer, required this.timestamp, }); final AccelerometerData? accelerometer; final GyroscopeData? gyroscope; final MagnetometerData? magnetometer; + final PedometerData? pedometer; final DateTime timestamp; Map toJson() { @@ -52,6 +54,7 @@ class MotionData { 'accelerometer': accelerometer?.toJson(), 'gyroscope': gyroscope?.toJson(), 'magnetometer': magnetometer?.toJson(), + 'pedometer': pedometer?.toJson(), 'timestamp': timestamp.toIso8601String(), }; } @@ -93,9 +96,32 @@ class MagnetometerData { } } +class PedometerData { + PedometerData( + {required this.steps, + required this.stepsOnStart, + required this.distanceMeters, + required this.status}); + + final int steps; + final int stepsOnStart; + final double distanceMeters; + final String status; + + Map toJson() { + return { + 'steps': steps, + 'stepsOnStart': stepsOnStart, + 'distanceMeters': distanceMeters, + 'status': status + }; + } +} + enum MotionSensorType { accelerometer, gyroscope, magnetometer, + pedometer, all, } diff --git a/modules/ensemble_activity/README.md b/modules/ensemble_activity/README.md index a3bd9562a..aae15663a 100644 --- a/modules/ensemble_activity/README.md +++ b/modules/ensemble_activity/README.md @@ -1,48 +1,180 @@ # Ensemble Activity -This ensemble activity module handles motion sensor (direction, movement patterns) functionality for tracking user activity and movement. +The Ensemble Activity module provides access to device motion and activity sensors for tracking user movement and orientation. It supports real-time streaming and one-time reads of motion data, with optional sensor selection. + +--- ## Features ### Motion Sensors -- Accelerometer data (x, y, z axes) -- Gyroscope data (rotation rates) -- Magnetometer data (compass direction) -- Combined motion data streams -- Configurable update intervals - -## Usage - -The module provides actions that can be used in Ensemble YAML configurations: - -- `getMotionData`: Get motion sensor data (accelerometer, gyroscope, magnetometer) - - `id`: Optional identifier for the motion data stream - - `options`: Optional options for the motion data stream - - `recurring`: Whether to get motion data recurringly - - `sensorType`: The type of sensor to get data from (accelerometer, gyroscope, magnetometer, all) - - `updateInterval`: The interval in milliseconds to get motion data (default is 1000 milliseconds) - - `onDataReceived`: The action to execute when motion data is received - - `onError`: The action to execute when an error occurs - - Example: - - ```yaml - getMotionData: - id: motion_stream_1 - options: - recurring: true - sensorType: all - updateInterval: 1000 - onDataReceived: - executeCode: - body: |- - console.log(event.data); - onError: - showToast: - message: "Error: ${event.error}" - ``` -- `stopMotionData`: Stop motion sensor data stream - - `id`: Optional identifier for the motion data stream - - Example: - - ```yaml - stopMotionData: - id: motion_stream_1 - ``` \ No newline at end of file +- Accelerometer (x, y, z axes) +- Gyroscope (rotation rates) +- Magnetometer (compass / magnetic field) +- Pedometer + - Step count since stream start + - Pedestrian status (walking, stopped, etc.) + - Distance estimation + +### Streaming Capabilities +- Combined or single-sensor streams +- Broadcast streams (multiple listeners supported) +- Explicit start / stop lifecycle +- Automatic cleanup on cancel + +### Permissions +- Automatically requests **Activity Recognition** permission when using: + - `pedometer` + - `all` +- Emits an error if permission is denied + +--- + +## Motion Data Model + +Each emitted event contains a `MotionData` object: + +```json +{ + "accelerometer": { "x": 0.0, "y": 0.0, "z": 0.0 }, + "gyroscope": { "x": 0.0, "y": 0.0, "z": 0.0 }, + "magnetometer": { "x": 0.0, "y": 0.0, "z": 0.0 }, + "pedometer": { + "steps": 120, + "status": "walking", + "stepsOnStart": 3450, + "distanceMeters": 90.0 + }, + "timestamp": "2026-02-07T10:30:00.000Z" +} +``` + +> Only the requested sensors will be populated. Others may be `null`. + +--- + +## Available Actions (YAML) + +### `getMotionData` + +Starts a motion sensor stream or retrieves motion data. + +#### Parameters + +| Field | Type | Description | +|------|------|------------| +| `id` | string (optional) | Identifier for the motion stream | +| `options.recurring` | boolean | If `true`, keeps streaming until stopped | +| `options.sensorType` | string | `accelerometer`, `gyroscope`, `magnetometer`, `pedometer`, `all` | +| `options.updateInterval` | number | Update interval in milliseconds (default: 1000) | +| `options.onDataReceived` | action | Executed when motion data is emitted | +| `options.onError` | action | Executed when an error occurs | + +#### Notes +- If a stream with the same `id` is already running, the existing stream is reused +- For `pedometer` and `all`, activity permission is requested automatically +- Pedometer steps are reset when the stream starts + +--- + +### Example: All Sensors (Recurring) + +```yaml +getMotionData: + id: motion_all + options: + recurring: true + sensorType: all + updateInterval: 1000 + onDataReceived: + executeCode: + body: |- + console.log('All motion:', event.data); + onError: + executeCode: + body: |- + console.log('Motion error:', event.error); +``` + +--- + +### Example: Pedometer Only + +```yaml +getMotionData: + id: pedometer_stream + options: + recurring: true + sensorType: pedometer + updateInterval: 1000 + onDataReceived: + executeCode: + body: |- + if (event.data.pedometer) { + console.log( + 'Steps:', + event.data.pedometer.steps, + 'Distance:', + event.data.pedometer.distanceMeters + ); + } +``` + +--- + +### One-Time Motion Read + +Returns the first available motion event and automatically stops the stream. + +```yaml +getMotionData: + options: + sensorType: accelerometer + onDataReceived: + executeCode: + body: |- + console.log('Single accelerometer read:', event.data); +``` + +--- + +## `stopMotionData` + +Stops a running motion stream and releases all sensor subscriptions. + +### Parameters + +| Field | Type | Description | +|------|------|------------| +| `id` | string (optional) | ID of the motion stream to stop | + +### Behavior +- Cancels all active sensor subscriptions +- Stops pedometer tracking +- Resets internal state (steps and cached sensor values) +- Closes the motion stream controller + +### Example + +```yaml +stopMotionData: + id: motion_all +``` + +--- + +## Lifecycle Notes + +- Multiple sensors are merged into a single stream +- A new `MotionData` event is emitted whenever any sensor updates +- Calling `stopMotionData` fully resets the activity state +- Cancelling the stream from the UI automatically stops motion tracking + +--- + +## Platform Notes + +- **Android** + - Requires `ACTIVITY_RECOGNITION` permission for pedometer +- **iOS** + - Uses Core Motion pedometer APIs + - Distance is estimated using a fixed step length of `0.75 meters` diff --git a/modules/ensemble_activity/lib/activity_manager.dart b/modules/ensemble_activity/lib/activity_manager.dart index 6dc85dee7..3ef03e3ae 100644 --- a/modules/ensemble_activity/lib/activity_manager.dart +++ b/modules/ensemble_activity/lib/activity_manager.dart @@ -1,166 +1,101 @@ import 'dart:async'; + +import 'package:async/async.dart'; import 'package:ensemble/framework/stub/activity_manager.dart'; +import 'package:pedometer/pedometer.dart'; +import 'package:permission_handler/permission_handler.dart'; import 'package:sensors_plus/sensors_plus.dart'; class ActivityManagerImpl extends ActivityManager { + // Subscriptions StreamSubscription? _accelerometerSubscription; StreamSubscription? _gyroscopeSubscription; StreamSubscription? _magnetometerSubscription; + StreamSubscription? _pedometerSubscription; - final StreamController _motionController = - StreamController.broadcast(); + // Controller + StreamController? _motionController; + bool _running = false; - // Track latest readings from each sensor for combining when sensorType is 'all' + // Latest sensor values AccelerometerData? _latestAccelerometer; GyroscopeData? _latestGyroscope; MagnetometerData? _latestMagnetometer; + PedometerData? _latestPedometer; + + // Pedometer state + int? _stepsOnStart; + int _stepsSinceStart = 0; + String _pedestrianStatus = 'initializing'; + + static const double stepLengthMeters = 0.75; + + // --------------------------------------------------------------------------- + // Permissions + // --------------------------------------------------------------------------- + Future requestActivityPermission() async { + final status = await Permission.activityRecognition.status; + if (status.isGranted) return true; + + final result = await Permission.activityRecognition.request(); + return result.isGranted; + } + // --------------------------------------------------------------------------- + // Public API + // --------------------------------------------------------------------------- @override Stream startMotionStream({ MotionSensorType? sensorType, Duration? updateInterval, }) { - final sensor = sensorType ?? MotionSensorType.all; - - // Reset latest readings when starting a new stream - if (sensor == MotionSensorType.all) { - _latestAccelerometer = null; - _latestGyroscope = null; - _latestMagnetometer = null; - } - - if (sensor == MotionSensorType.accelerometer || - sensor == MotionSensorType.all) { - _accelerometerSubscription = accelerometerEvents.listen( - (AccelerometerEvent event) { - final accelerometerData = AccelerometerData( - x: event.x, - y: event.y, - z: event.z, - ); - - if (sensor == MotionSensorType.all) { - // Store latest accelerometer reading - _latestAccelerometer = accelerometerData; - // Emit combined data with all available sensors - _emitCombinedMotionData(); - } else { - // Emit single sensor data - final data = MotionData( - accelerometer: accelerometerData, - timestamp: DateTime.now(), - ); - _motionController.add(data); - } - }, - onError: (error) { - _motionController.addError(error); - }, - ); + // If already running, return the same stream + if (_running && _motionController != null) { + return _motionController!.stream; } - if (sensor == MotionSensorType.gyroscope || - sensor == MotionSensorType.all) { - _gyroscopeSubscription = gyroscopeEvents.listen( - (GyroscopeEvent event) { - final gyroscopeData = GyroscopeData( - x: event.x, - y: event.y, - z: event.z, - ); - - if (sensor == MotionSensorType.all) { - // Store latest gyroscope reading - _latestGyroscope = gyroscopeData; - // Emit combined data with all available sensors - _emitCombinedMotionData(); - } else { - // Emit single sensor data - final data = MotionData( - gyroscope: gyroscopeData, - timestamp: DateTime.now(), - ); - _motionController.add(data); - } - }, - onError: (error) { - _motionController.addError(error); - }, - ); - } + _running = true; + final sensor = sensorType ?? MotionSensorType.all; - if (sensor == MotionSensorType.magnetometer || - sensor == MotionSensorType.all) { - _magnetometerSubscription = magnetometerEvents.listen( - (MagnetometerEvent event) { - final magnetometerData = MagnetometerData( - x: event.x, - y: event.y, - z: event.z, - ); - - if (sensor == MotionSensorType.all) { - // Store latest magnetometer reading - _latestMagnetometer = magnetometerData; - // Emit combined data with all available sensors - _emitCombinedMotionData(); - } else { - // Emit single sensor data - final data = MotionData( - magnetometer: magnetometerData, - timestamp: DateTime.now(), - ); - _motionController.add(data); - } - }, - onError: (error) { - _motionController.addError(error); - }, - ); - } + _motionController = StreamController.broadcast( + onCancel: stopMotionStream, + ); - return _motionController.stream; - } + _startSensors(sensor); - void _emitCombinedMotionData() { - // Only emit if we have at least one sensor reading - // This ensures we don't emit empty data initially - if (_latestAccelerometer != null || - _latestGyroscope != null || - _latestMagnetometer != null) { - final data = MotionData( - accelerometer: _latestAccelerometer, - gyroscope: _latestGyroscope, - magnetometer: _latestMagnetometer, - timestamp: DateTime.now(), - ); - _motionController.add(data); - } + return _motionController!.stream; } @override void stopMotionStream() { + if (!_running) return; + + _running = false; + _accelerometerSubscription?.cancel(); - _accelerometerSubscription = null; _gyroscopeSubscription?.cancel(); - _gyroscopeSubscription = null; _magnetometerSubscription?.cancel(); + _pedometerSubscription?.cancel(); + + _accelerometerSubscription = null; + _gyroscopeSubscription = null; _magnetometerSubscription = null; - // Clear latest readings when stopping - _latestAccelerometer = null; - _latestGyroscope = null; - _latestMagnetometer = null; + _pedometerSubscription = null; + + _motionController?.close(); + _motionController = null; + + _resetState(); } @override Future getMotionData({MotionSensorType? sensorType}) async { try { - final sensor = sensorType ?? MotionSensorType.accelerometer; - final stream = startMotionStream(sensorType: sensor); - final data = await stream.first.timeout(const Duration(seconds: 2)); + final stream = startMotionStream(sensorType: sensorType); + final data = await stream.first; stopMotionStream(); return data; - } catch (e) { + } catch (_) { stopMotionStream(); return null; } @@ -168,6 +103,145 @@ class ActivityManagerImpl extends ActivityManager { void dispose() { stopMotionStream(); - _motionController.close(); + } + + // --------------------------------------------------------------------------- + // Internal helpers + // --------------------------------------------------------------------------- + void _resetState() { + _latestAccelerometer = null; + _latestGyroscope = null; + _latestMagnetometer = null; + _latestPedometer = null; + + _stepsOnStart = null; + _stepsSinceStart = 0; + _pedestrianStatus = 'initializing'; + } + + void _startSensors(MotionSensorType sensor) async { + if (sensor == MotionSensorType.pedometer || + sensor == MotionSensorType.all) { + final ok = await requestActivityPermission(); + if (!ok) { + _motionController?.addError( + Exception('Activity recognition permission denied'), + ); + return; + } + _startPedometer(sensor); + } + + if (sensor == MotionSensorType.accelerometer || + sensor == MotionSensorType.all) { + _accelerometerSubscription = accelerometerEvents.listen(_onAccelerometer); + } + + if (sensor == MotionSensorType.gyroscope || + sensor == MotionSensorType.all) { + _gyroscopeSubscription = gyroscopeEvents.listen(_onGyroscope); + } + + if (sensor == MotionSensorType.magnetometer || + sensor == MotionSensorType.all) { + _magnetometerSubscription = magnetometerEvents.listen(_onMagnetometer); + } + } + + // --------------------------------------------------------------------------- + // Sensor handlers + // --------------------------------------------------------------------------- + void _onAccelerometer(AccelerometerEvent event) { + if (!_running) return; + + _latestAccelerometer = AccelerometerData( + x: event.x, + y: event.y, + z: event.z, + ); + + _emit(); + } + + void _onGyroscope(GyroscopeEvent event) { + if (!_running) return; + + _latestGyroscope = GyroscopeData( + x: event.x, + y: event.y, + z: event.z, + ); + + _emit(); + } + + void _onMagnetometer(MagnetometerEvent event) { + if (!_running) return; + + _latestMagnetometer = MagnetometerData( + x: event.x, + y: event.y, + z: event.z, + ); + + _emit(); + } + + void _startPedometer(MotionSensorType sensor) { + _stepsOnStart = null; + _stepsSinceStart = 0; + _pedestrianStatus = 'initializing'; + + _latestPedometer = PedometerData( + steps: 0, + status: 'initializing', + stepsOnStart: 0, + distanceMeters: 0, + ); + + _pedometerSubscription = StreamGroup.merge([ + Pedometer.stepCountStream.map((event) { + _stepsOnStart ??= event.steps; + _stepsSinceStart = event.steps - (_stepsOnStart ?? event.steps); + }), + Pedometer.pedestrianStatusStream.map((event) { + _pedestrianStatus = event.status; + }), + ]).listen((_) { + if (!_running) return; + + _latestPedometer = PedometerData( + steps: _stepsSinceStart, + status: _pedestrianStatus, + stepsOnStart: _stepsOnStart ?? 0, + distanceMeters: _stepsSinceStart * stepLengthMeters, + ); + + _emit(); + }); + } + + // --------------------------------------------------------------------------- + // Emit combined data + // --------------------------------------------------------------------------- + void _emit() { + if (!_running || _motionController == null) return; + + if (_latestAccelerometer == null && + _latestGyroscope == null && + _latestMagnetometer == null && + _latestPedometer == null) { + return; + } + + _motionController!.add( + MotionData( + accelerometer: _latestAccelerometer, + gyroscope: _latestGyroscope, + magnetometer: _latestMagnetometer, + pedometer: _latestPedometer, + timestamp: DateTime.now(), + ), + ); } } diff --git a/modules/ensemble_activity/pubspec.yaml b/modules/ensemble_activity/pubspec.yaml index 129837dfb..7b07a47e4 100644 --- a/modules/ensemble_activity/pubspec.yaml +++ b/modules/ensemble_activity/pubspec.yaml @@ -22,6 +22,8 @@ dependencies: get_it: ^8.0.0 sensors_plus: ^7.0.0 + permission_handler: ^12.0.1 + pedometer: ^4.1.1 dev_dependencies: flutter_test: @@ -64,4 +66,3 @@ flutter: # # For details regarding fonts in packages, see # https://flutter.dev/custom-fonts/#from-packages - From a14e4980db441c72ef6fad93531d824071c79481 Mon Sep 17 00:00:00 2001 From: M-Talha4 Date: Sun, 8 Feb 2026 17:56:12 +0500 Subject: [PATCH 4/4] chore(activity): enhance motion data handling with support for multiple sensors --- .../ensemble/lib/action/getMotionData.dart | 131 +++++++------ .../lib/framework/stub/activity_manager.dart | 9 +- modules/ensemble_activity/README.md | 173 +++++++++--------- .../lib/activity_manager.dart | 52 +++--- starter/scripts/modules/enable_activity.dart | 83 +++++++++ starter/src/modules_scripts.ts | 12 ++ 6 files changed, 282 insertions(+), 178 deletions(-) create mode 100644 starter/scripts/modules/enable_activity.dart diff --git a/modules/ensemble/lib/action/getMotionData.dart b/modules/ensemble/lib/action/getMotionData.dart index 6dcf418b8..2ab31fa71 100644 --- a/modules/ensemble/lib/action/getMotionData.dart +++ b/modules/ensemble/lib/action/getMotionData.dart @@ -16,8 +16,8 @@ class GetMotionDataAction extends EnsembleAction { this.id, this.onDataReceived, this.onError, - this.recurring, - this.sensorType, + this.recurring = true, + this.sensors, this.updateInterval, }); @@ -25,20 +25,25 @@ class GetMotionDataAction extends EnsembleAction { final EnsembleAction? onDataReceived; final EnsembleAction? onError; final bool? recurring; - final MotionSensorType? sensorType; + final List? sensors; final int? updateInterval; // in milliseconds - factory GetMotionDataAction.fromYaml({Invokable? initiator, Map? payload}) { - MotionSensorType? sensorType; - if (payload?['options']?['sensorType'] != null) { - final sensorTypeStr = Utils.getString( - payload?['options']?['sensorType'], - fallback: 'all', - ); - sensorType = MotionSensorType.values.firstWhere( - (e) => e.name == sensorTypeStr.toLowerCase(), - orElse: () => MotionSensorType.all, - ); + factory GetMotionDataAction.fromYaml({ + Invokable? initiator, + Map? payload, + }) { + List? sensors; + + final rawSensors = payload?['options']?['sensors']; + if (rawSensors is List) { + sensors = rawSensors + .map((e) => MotionSensorType.values.firstWhere( + (v) => v.name == e.toString().toLowerCase(), + orElse: () => throw LanguageError( + 'Invalid sensor type: $e', + ), + )) + .toList(); } return GetMotionDataAction( @@ -47,76 +52,86 @@ class GetMotionDataAction extends EnsembleAction { onDataReceived: EnsembleAction.from(payload?['options']?['onDataReceived']), onError: EnsembleAction.from(payload?['options']?['onError']), - recurring: Utils.optionalBool(payload?['options']?['recurring']), - sensorType: sensorType, - updateInterval: Utils.optionalInt(payload?['options']?['updateInterval']), + recurring: + Utils.getBool(payload?['options']?['recurring'], fallback: true), + sensors: sensors, + updateInterval: Utils.optionalInt( + payload?['options']?['updateInterval'], + ), ); } @override - Future execute(BuildContext context, ScopeManager scopeManager) async { + Future execute( + BuildContext context, + ScopeManager scopeManager, + ) async { if (onDataReceived == null) { throw LanguageError( - '${ActionType.getMotionData.name} requires onDataReceived callback'); + '${ActionType.getMotionData.name} requires onDataReceived callback', + ); } try { - // Handle recurring motion updates if (recurring == true) { - StreamSubscription streamSubscription = - GetIt.I() - .startMotionStream( - sensorType: sensorType, + final subscription = GetIt.I() + .startMotionStream( + sensors: sensors, updateInterval: updateInterval != null ? Duration(milliseconds: updateInterval!) : null, ) - .listen((MotionData? data) { - if (data != null) { + .listen( + (MotionData data) { ScreenController().executeActionWithScope( context, scopeManager, onDataReceived!, - event: EnsembleEvent(initiator, data: data.toJson()), + event: EnsembleEvent( + initiator, + data: data.toJson(), + ), ); - } else if (onError != null) { - ScreenController().executeActionWithScope( - context, - scopeManager, - onError!, - event: EnsembleEvent(initiator, error: 'unknown'), - ); - } - }, onError: (error) { - if (onError != null) { - ScreenController().executeActionWithScope( - context, - scopeManager, - onError!, - event: EnsembleEvent(initiator, error: error.toString()), - ); - } - }); - // Store subscription for cleanup - scopeManager.addMotionListener(streamSubscription, id: id); - } - // Handle one-time motion request - else { - final data = await GetIt.I() - .getMotionData(sensorType: sensorType); + }, + onError: (error) { + if (onError != null) { + ScreenController().executeActionWithScope( + context, + scopeManager, + onError!, + event: EnsembleEvent( + initiator, + error: error.toString(), + ), + ); + } + }, + ); + + scopeManager.addMotionListener(subscription, id: id); + } else { + final data = + await GetIt.I().getMotionData(sensors: sensors); + if (data != null) { ScreenController().executeActionWithScope( context, scopeManager, onDataReceived!, - event: EnsembleEvent(initiator, data: data.toJson()), + event: EnsembleEvent( + initiator, + data: data.toJson(), + ), ); } else if (onError != null) { ScreenController().executeActionWithScope( context, scopeManager, onError!, - event: EnsembleEvent(initiator, error: 'noData'), + event: EnsembleEvent( + initiator, + error: 'noData', + ), ); } } @@ -126,7 +141,10 @@ class GetMotionDataAction extends EnsembleAction { context, scopeManager, onError!, - event: EnsembleEvent(initiator, error: e.toString()), + event: EnsembleEvent( + initiator, + error: e.toString(), + ), ); } else { rethrow; @@ -137,15 +155,12 @@ class GetMotionDataAction extends EnsembleAction { class StopMotionDataAction extends EnsembleAction { StopMotionDataAction({this.id}); - final String? id; - factory StopMotionDataAction.fromYaml({Map? payload}) { return StopMotionDataAction( id: Utils.optionalString(payload?['id']), ); } - @override Future execute(BuildContext context, ScopeManager scopeManager) async { scopeManager.stopMotionListener(id); diff --git a/modules/ensemble/lib/framework/stub/activity_manager.dart b/modules/ensemble/lib/framework/stub/activity_manager.dart index a718f022f..2a8d216ec 100644 --- a/modules/ensemble/lib/framework/stub/activity_manager.dart +++ b/modules/ensemble/lib/framework/stub/activity_manager.dart @@ -4,17 +4,17 @@ import 'package:ensemble/framework/error_handling.dart'; abstract class ActivityManager { // Motion sensor methods Stream startMotionStream({ - MotionSensorType? sensorType, + List? sensors, Duration? updateInterval, }); void stopMotionStream(); - Future getMotionData({MotionSensorType? sensorType}); + Future getMotionData({List? sensors}); } class ActivityManagerStub extends ActivityManager { @override Stream startMotionStream({ - MotionSensorType? sensorType, + List? sensors, Duration? updateInterval, }) { throw ConfigError( @@ -28,7 +28,7 @@ class ActivityManagerStub extends ActivityManager { } @override - Future getMotionData({MotionSensorType? sensorType}) { + Future getMotionData({List? sensors}) { throw ConfigError( "Activity Manager is not enabled. Please review the Ensemble documentation."); } @@ -123,5 +123,4 @@ enum MotionSensorType { gyroscope, magnetometer, pedometer, - all, } diff --git a/modules/ensemble_activity/README.md b/modules/ensemble_activity/README.md index aae15663a..46cb6505f 100644 --- a/modules/ensemble_activity/README.md +++ b/modules/ensemble_activity/README.md @@ -1,37 +1,41 @@ # Ensemble Activity -The Ensemble Activity module provides access to device motion and activity sensors for tracking user movement and orientation. It supports real-time streaming and one-time reads of motion data, with optional sensor selection. +The Ensemble Activity module provides access to device motion and activity sensors for tracking user movement, orientation, and walking activity. ---- +It supports: +- Real-time streaming +- One-time motion reads +- Selective sensor subscription +- Automatic lifecycle management ## Features ### Motion Sensors -- Accelerometer (x, y, z axes) -- Gyroscope (rotation rates) -- Magnetometer (compass / magnetic field) + +Supported sensors: +- Accelerometer — device acceleration on X/Y/Z axes +- Gyroscope — rotation rate on X/Y/Z axes +- Magnetometer — magnetic field / compass direction - Pedometer - Step count since stream start - - Pedestrian status (walking, stopped, etc.) - - Distance estimation + - Walking status (walking, stopped, etc.) + - Estimated distance in meters ### Streaming Capabilities -- Combined or single-sensor streams -- Broadcast streams (multiple listeners supported) + +- Subscribe to one or multiple sensors +- Merged motion stream for all selected sensors +- Broadcast stream (multiple listeners supported) - Explicit start / stop lifecycle -- Automatic cleanup on cancel +- Automatic cleanup when stopped or cancelled ### Permissions -- Automatically requests **Activity Recognition** permission when using: - - `pedometer` - - `all` -- Emits an error if permission is denied ---- +Activity Recognition permission is automatically requested when the pedometer sensor is included. If permission is denied, the stream emits an error event. ## Motion Data Model -Each emitted event contains a `MotionData` object: +Each emitted event contains a MotionData object: ```json { @@ -48,133 +52,132 @@ Each emitted event contains a `MotionData` object: } ``` -> Only the requested sensors will be populated. Others may be `null`. +Notes: +- Only requested sensors are populated +- Non-requested sensors may be null ---- +## Available Actions -## Available Actions (YAML) +### getMotionData -### `getMotionData` +Starts a motion stream or performs a single motion read. -Starts a motion sensor stream or retrieves motion data. - -#### Parameters +**Parameters** | Field | Type | Description | -|------|------|------------| -| `id` | string (optional) | Identifier for the motion stream | -| `options.recurring` | boolean | If `true`, keeps streaming until stopped | -| `options.sensorType` | string | `accelerometer`, `gyroscope`, `magnetometer`, `pedometer`, `all` | -| `options.updateInterval` | number | Update interval in milliseconds (default: 1000) | -| `options.onDataReceived` | action | Executed when motion data is emitted | -| `options.onError` | action | Executed when an error occurs | +|-------|------|-------------| +| id | string (optional) | Identifier for the motion stream | +| options.sensors | string[] | List of sensors to subscribe to | +| options.updateInterval | number | Update interval in milliseconds | +| options.recurring | boolean (optional) | Defaults to true | +| options.onDataReceived | action | Executed when motion data is emitted | +| options.onError | action | Executed when an error occurs | + +**Sensor Values** -#### Notes -- If a stream with the same `id` is already running, the existing stream is reused -- For `pedometer` and `all`, activity permission is requested automatically -- Pedometer steps are reset when the stream starts +Valid sensor names: +- accelerometer +- gyroscope +- magnetometer +- pedometer ---- +**Behavior** -### Example: All Sensors (Recurring) +- If a stream with the same id already exists, the existing stream is reused +- If recurring is set to false, returns one motion event and stops automatically +- Step counter resets when stream starts + +**Examples** + +Streaming Motion Data: ```yaml getMotionData: - id: motion_all + id: motion_stream options: - recurring: true - sensorType: all + sensors: + - accelerometer + - gyroscope + - magnetometer updateInterval: 1000 onDataReceived: executeCode: body: |- - console.log('All motion:', event.data); + console.log('Motion data:', event.data); onError: executeCode: body: |- console.log('Motion error:', event.error); ``` ---- - -### Example: Pedometer Only +Pedometer Tracking: ```yaml getMotionData: id: pedometer_stream options: - recurring: true - sensorType: pedometer + sensors: + - pedometer updateInterval: 1000 onDataReceived: executeCode: body: |- - if (event.data.pedometer) { - console.log( - 'Steps:', - event.data.pedometer.steps, - 'Distance:', - event.data.pedometer.distanceMeters - ); + var p = event.data && event.data.pedometer; + if (p) { + console.log('Steps:', p.steps, 'Distance:', p.distanceMeters); } ``` ---- - -### One-Time Motion Read - -Returns the first available motion event and automatically stops the stream. +One-Time Motion Read: ```yaml getMotionData: options: - sensorType: accelerometer + recurring: false + sensors: + - accelerometer onDataReceived: executeCode: body: |- - console.log('Single accelerometer read:', event.data); + console.log('Single read:', event.data); ``` ---- +### stopMotionData -## `stopMotionData` +Stops an active motion stream and releases sensor subscriptions. -Stops a running motion stream and releases all sensor subscriptions. - -### Parameters +**Parameters** | Field | Type | Description | -|------|------|------------| -| `id` | string (optional) | ID of the motion stream to stop | +|-------|------|-------------| +| id | string (optional) | ID of the stream to stop | + +**Behavior** -### Behavior -- Cancels all active sensor subscriptions +- Cancels active sensor subscriptions - Stops pedometer tracking -- Resets internal state (steps and cached sensor values) -- Closes the motion stream controller +- Clears cached motion values +- Fully closes the motion stream -### Example +**Example** ```yaml stopMotionData: - id: motion_all + id: motion_stream ``` ---- - ## Lifecycle Notes -- Multiple sensors are merged into a single stream -- A new `MotionData` event is emitted whenever any sensor updates -- Calling `stopMotionData` fully resets the activity state -- Cancelling the stream from the UI automatically stops motion tracking - ---- +- Multiple sensors are merged into one stream +- A new MotionData event is emitted whenever any sensor updates +- Calling stopMotionData fully resets motion state +- UI cancellation automatically stops tracking ## Platform Notes -- **Android** - - Requires `ACTIVITY_RECOGNITION` permission for pedometer -- **iOS** - - Uses Core Motion pedometer APIs - - Distance is estimated using a fixed step length of `0.75 meters` +**Android** +- Requires ACTIVITY_RECOGNITION permission for pedometer + +**iOS** +- Uses Core Motion pedometer APIs +- Distance estimated using approximately 0.75 m step length diff --git a/modules/ensemble_activity/lib/activity_manager.dart b/modules/ensemble_activity/lib/activity_manager.dart index 3ef03e3ae..dc4e5f5e7 100644 --- a/modules/ensemble_activity/lib/activity_manager.dart +++ b/modules/ensemble_activity/lib/activity_manager.dart @@ -30,9 +30,7 @@ class ActivityManagerImpl extends ActivityManager { static const double stepLengthMeters = 0.75; - // --------------------------------------------------------------------------- // Permissions - // --------------------------------------------------------------------------- Future requestActivityPermission() async { final status = await Permission.activityRecognition.status; if (status.isGranted) return true; @@ -41,27 +39,30 @@ class ActivityManagerImpl extends ActivityManager { return result.isGranted; } - // --------------------------------------------------------------------------- // Public API - // --------------------------------------------------------------------------- @override Stream startMotionStream({ - MotionSensorType? sensorType, + List? sensors, Duration? updateInterval, }) { - // If already running, return the same stream if (_running && _motionController != null) { return _motionController!.stream; } _running = true; - final sensor = sensorType ?? MotionSensorType.all; + + final activeSensors = (sensors == null || sensors.isEmpty) + ? MotionSensorType.values + : sensors; + + print( + 'Starting motion stream with sensors: $activeSensors and updateInterval: $updateInterval'); _motionController = StreamController.broadcast( onCancel: stopMotionStream, ); - _startSensors(sensor); + _startSensors(activeSensors); return _motionController!.stream; } @@ -89,9 +90,11 @@ class ActivityManagerImpl extends ActivityManager { } @override - Future getMotionData({MotionSensorType? sensorType}) async { + Future getMotionData({ + List? sensors, + }) async { try { - final stream = startMotionStream(sensorType: sensorType); + final stream = startMotionStream(sensors: sensors); final data = await stream.first; stopMotionStream(); return data; @@ -105,9 +108,7 @@ class ActivityManagerImpl extends ActivityManager { stopMotionStream(); } - // --------------------------------------------------------------------------- // Internal helpers - // --------------------------------------------------------------------------- void _resetState() { _latestAccelerometer = null; _latestGyroscope = null; @@ -119,9 +120,8 @@ class ActivityManagerImpl extends ActivityManager { _pedestrianStatus = 'initializing'; } - void _startSensors(MotionSensorType sensor) async { - if (sensor == MotionSensorType.pedometer || - sensor == MotionSensorType.all) { + void _startSensors(List sensors) async { + if (sensors.contains(MotionSensorType.pedometer)) { final ok = await requestActivityPermission(); if (!ok) { _motionController?.addError( @@ -129,28 +129,23 @@ class ActivityManagerImpl extends ActivityManager { ); return; } - _startPedometer(sensor); + _startPedometer(); } - if (sensor == MotionSensorType.accelerometer || - sensor == MotionSensorType.all) { + if (sensors.contains(MotionSensorType.accelerometer)) { _accelerometerSubscription = accelerometerEvents.listen(_onAccelerometer); } - if (sensor == MotionSensorType.gyroscope || - sensor == MotionSensorType.all) { + if (sensors.contains(MotionSensorType.gyroscope)) { _gyroscopeSubscription = gyroscopeEvents.listen(_onGyroscope); } - if (sensor == MotionSensorType.magnetometer || - sensor == MotionSensorType.all) { + if (sensors.contains(MotionSensorType.magnetometer)) { _magnetometerSubscription = magnetometerEvents.listen(_onMagnetometer); } } - // --------------------------------------------------------------------------- // Sensor handlers - // --------------------------------------------------------------------------- void _onAccelerometer(AccelerometerEvent event) { if (!_running) return; @@ -187,16 +182,16 @@ class ActivityManagerImpl extends ActivityManager { _emit(); } - void _startPedometer(MotionSensorType sensor) { + void _startPedometer() { _stepsOnStart = null; _stepsSinceStart = 0; _pedestrianStatus = 'initializing'; _latestPedometer = PedometerData( steps: 0, - status: 'initializing', stepsOnStart: 0, distanceMeters: 0, + status: 'initializing', ); _pedometerSubscription = StreamGroup.merge([ @@ -212,18 +207,15 @@ class ActivityManagerImpl extends ActivityManager { _latestPedometer = PedometerData( steps: _stepsSinceStart, - status: _pedestrianStatus, stepsOnStart: _stepsOnStart ?? 0, distanceMeters: _stepsSinceStart * stepLengthMeters, + status: _pedestrianStatus, ); _emit(); }); } - // --------------------------------------------------------------------------- - // Emit combined data - // --------------------------------------------------------------------------- void _emit() { if (!_running || _motionController == null) return; diff --git a/starter/scripts/modules/enable_activity.dart b/starter/scripts/modules/enable_activity.dart new file mode 100644 index 000000000..ab845e2d9 --- /dev/null +++ b/starter/scripts/modules/enable_activity.dart @@ -0,0 +1,83 @@ +import 'dart:io'; + +import '../utils.dart'; + +void main(List arguments) async { + List platforms = getPlatforms(arguments); + String? ensembleVersion = getArgumentValue(arguments, 'ensemble_version'); + + final statements = { + 'moduleStatements': [ + "import 'package:ensemble_activity/ensemble_activity.dart';", + 'GetIt.I.registerSingleton(ActivityManagerImpl());', + ], + 'useStatements': [ + 'static const useActivity = true;', + ], + }; + + final pubspecDependencies = [ + { + 'statement': ''' +ensemble_activity: + git: + url: https://github.com/EnsembleUI/ensemble.git + ref: ${await packageVersion(version: ensembleVersion)} + path: modules/ensemble_activity''', + 'regex': + r'#\s*ensemble_activity:\s*\n\s*#\s*git:\s*\n\s*#\s*url:\s*https:\/\/github\.com\/EnsembleUI\/ensemble\.git\s*\n\s*#\s*ref:\s*main\s*\n\s*#\s*path:\s*modules\/ensemble_activity', + } + ]; + + final androidPermissions = [ + // Required for activity recognition (Android 10+) + '', + ]; + + final iOSPermissions = [ + { + 'key': 'motionDescription', + 'value': 'NSMotionUsageDescription', + }, + ]; + final iOSAdditionalSettings = [ + { + // Required if you want motion updates while app is in background + 'key': 'UIBackgroundModes', + 'value': ['motion'], + 'isArray': true, + }, + ]; + + try { + // Update ensemble_modules.dart + updateEnsembleModules( + statements['moduleStatements'], + statements['useStatements'], + ); + + // Update pubspec.yaml + updatePubspec(pubspecDependencies); + + // Android permissions + if (platforms.contains('android')) { + updateAndroidPermissions(permissions: androidPermissions); + } + + // iOS permissions (no background modes) + if (platforms.contains('ios')) { + updateIOSPermissions( + iOSPermissions, + arguments, + additionalSettings: iOSAdditionalSettings, + ); + } + + print( + 'Activity recognition module enabled successfully for ${platforms.join(', ')}! 🚶'); + exit(0); + } catch (e) { + print('Starter Error: $e'); + exit(1); + } +} diff --git a/starter/src/modules_scripts.ts b/starter/src/modules_scripts.ts index 3a1b2203a..419898df5 100644 --- a/starter/src/modules_scripts.ts +++ b/starter/src/modules_scripts.ts @@ -241,6 +241,18 @@ export const modules: Script[] = [ }, ], }, + { + name: 'activity', + path: 'scripts/modules/enable_activity.dart', + parameters: [ + { + key: 'motionDescription', + question: 'Please provide a description for motion & activity usage: ', + platform: ['ios'], + type: 'text', + }, + ], + }, { name: 'bluetooth', path: 'scripts/modules/enable_bluetooth.dart',