diff --git a/open_wearable/lib/apps/heart_tracker/widgets/rowling_chart.dart b/open_wearable/lib/apps/heart_tracker/widgets/rowling_chart.dart index 89a12dc2..6455f325 100644 --- a/open_wearable/lib/apps/heart_tracker/widgets/rowling_chart.dart +++ b/open_wearable/lib/apps/heart_tracker/widgets/rowling_chart.dart @@ -1,13 +1,12 @@ import 'dart:async'; import 'dart:math'; import 'package:flutter/material.dart'; -import 'package:community_charts_flutter/community_charts_flutter.dart' - as charts; +import 'package:community_charts_flutter/community_charts_flutter.dart' as charts; class RollingChart extends StatefulWidget { final Stream<(int, double)> dataSteam; final int timestampExponent; // e.g., 6 for microseconds to milliseconds - final int timeWindow; // in seconds + final int timeWindow; // in milliseconds const RollingChart({ super.key, @@ -21,9 +20,8 @@ class RollingChart extends StatefulWidget { } class _RollingChartState extends State { - List> _seriesList = []; - final List<_RawChartPoint> _rawData = []; - List<_ChartPoint> _normalizedData = []; + List> _seriesList = []; + final List<_ChartPoint> _data = []; StreamSubscription? _subscription; @override @@ -44,87 +42,56 @@ class _RollingChartState extends State { void _listenToStream() { _subscription = widget.dataSteam.listen((event) { final (timestamp, value) = event; - + setState(() { - _rawData.add(_RawChartPoint(timestamp, value)); - + _data.add(_ChartPoint(timestamp, value)); + // Remove old data outside time window - final ticksPerSecond = pow(10, -widget.timestampExponent).toDouble(); - final cutoffTime = - timestamp - (widget.timeWindow * ticksPerSecond).round(); - _rawData.removeWhere((data) => data.timestamp < cutoffTime); - + int cutoffTime = timestamp - (widget.timeWindow * pow(10, -widget.timestampExponent) as int); + _data.removeWhere((data) => data.time < cutoffTime); + _updateSeries(); }); }); } void _updateSeries() { - if (_rawData.isEmpty) { - _normalizedData = []; - _seriesList = []; - return; - } - - final firstTimestamp = _rawData.first.timestamp; - final secondsPerTick = pow(10, widget.timestampExponent).toDouble(); - - _normalizedData = _rawData - .map( - (point) => _ChartPoint( - (point.timestamp - firstTimestamp) * secondsPerTick, - point.value, - ), - ) - .toList(growable: false); - _seriesList = [ - charts.Series<_ChartPoint, num>( - id: 'Live Data', - colorFn: (_, __) => charts.MaterialPalette.red.shadeDefault, - domainFn: (_ChartPoint point, _) => point.timeSeconds, - measureFn: (_ChartPoint point, _) => point.value, - data: _normalizedData, + charts.Series<_ChartPoint, int>( + id: 'Live Data', + colorFn: (_, __) => charts.MaterialPalette.red.shadeDefault, + domainFn: (_ChartPoint point, _) => point.time, + measureFn: (_ChartPoint point, _) => point.value, + data: List.of(_data), ), ]; } @override Widget build(BuildContext context) { - final filteredPoints = _normalizedData; + final filteredPoints = _data; - final xValues = filteredPoints.map((e) => e.timeSeconds).toList(); + final xValues = filteredPoints.map((e) => e.time).toList(); final yValues = filteredPoints.map((e) => e.value).toList(); - final double xMin = 0; - final double xMax = max( - widget.timeWindow.toDouble(), - xValues.isNotEmpty ? xValues.reduce((a, b) => a > b ? a : b) : 0, - ); + final int? xMin = xValues.isNotEmpty ? xValues.reduce((a, b) => a < b ? a : b) : null; + final int? xMax = xValues.isNotEmpty ? xValues.reduce((a, b) => a > b ? a : b) : null; - final double? yMin = - yValues.isNotEmpty ? yValues.reduce((a, b) => a < b ? a : b) : null; - final double? yMax = - yValues.isNotEmpty ? yValues.reduce((a, b) => a > b ? a : b) : null; + final double? yMin = yValues.isNotEmpty ? yValues.reduce((a, b) => a < b ? a : b) : null; + final double? yMax = yValues.isNotEmpty ? yValues.reduce((a, b) => a > b ? a : b) : null; return charts.LineChart( _seriesList, animate: false, domainAxis: charts.NumericAxisSpec( - viewport: charts.NumericExtents(xMin, xMax), - tickFormatterSpec: charts.BasicNumericTickFormatterSpec((num? value) { - if (value == null) return ''; - final rounded = value.roundToDouble(); - if ((value - rounded).abs() < 0.05) { - return '${rounded.toInt()}s'; - } - return '${value.toStringAsFixed(1)}s'; - }), + viewport: xMin != null && xMax != null + ? charts.NumericExtents(xMin, xMax) + : null, ), primaryMeasureAxis: charts.NumericAxisSpec( viewport: yMin != null && yMax != null - ? charts.NumericExtents(yMin, yMax) - : null, + ? charts.NumericExtents(yMin, yMax) + : null, ), ); } @@ -136,16 +103,9 @@ class _RollingChartState extends State { } } -class _RawChartPoint { - final int timestamp; - final double value; - - _RawChartPoint(this.timestamp, this.value); -} - class _ChartPoint { - final double timeSeconds; + final int time; final double value; - _ChartPoint(this.timeSeconds, this.value); + _ChartPoint(this.time, this.value); } diff --git a/open_wearable/lib/apps/widgets/app_compatibility.dart b/open_wearable/lib/apps/widgets/app_compatibility.dart deleted file mode 100644 index 8e1112ec..00000000 --- a/open_wearable/lib/apps/widgets/app_compatibility.dart +++ /dev/null @@ -1,25 +0,0 @@ -bool wearableNameStartsWithPrefix(String wearableName, String prefix) { - final normalizedWearableName = wearableName.trim().toLowerCase(); - final normalizedPrefix = prefix.trim().toLowerCase(); - if (normalizedWearableName.isEmpty || normalizedPrefix.isEmpty) return false; - return normalizedWearableName.startsWith(normalizedPrefix); -} - -bool wearableIsCompatibleWithApp({ - required String wearableName, - required List supportedDevicePrefixes, -}) { - if (supportedDevicePrefixes.isEmpty) return true; - return supportedDevicePrefixes.any( - (prefix) => wearableNameStartsWithPrefix(wearableName, prefix), - ); -} - -bool hasConnectedWearableForPrefix({ - required String devicePrefix, - required Iterable connectedWearableNames, -}) { - return connectedWearableNames.any( - (name) => wearableNameStartsWithPrefix(name, devicePrefix), - ); -} diff --git a/open_wearable/lib/apps/widgets/app_tile.dart b/open_wearable/lib/apps/widgets/app_tile.dart index a0631d47..45fd370a 100644 --- a/open_wearable/lib/apps/widgets/app_tile.dart +++ b/open_wearable/lib/apps/widgets/app_tile.dart @@ -1,9 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_platform_widgets/flutter_platform_widgets.dart'; -import 'package:open_wearable/apps/widgets/app_compatibility.dart'; import 'package:open_wearable/apps/widgets/apps_page.dart'; -import 'package:open_wearable/view_models/wearables_provider.dart'; -import 'package:provider/provider.dart'; class AppTile extends StatelessWidget { final AppInfo app; @@ -12,183 +9,26 @@ class AppTile extends StatelessWidget { @override Widget build(BuildContext context) { - final connectedWearableNames = context - .watch() - .wearables - .map((wearable) => wearable.name) - .toList(growable: false); - final titleStyle = Theme.of(context).textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.w700, - ); - - return Card( - margin: const EdgeInsets.only(bottom: 10), - clipBehavior: Clip.antiAlias, - child: InkWell( + return PlatformListTile( + title: PlatformText(app.title), + subtitle: PlatformText(app.description), + leading: SizedBox( + height: 50.0, + width: 50.0, + child: ClipRRect( + borderRadius: BorderRadius.circular(8.0), + child: Image.asset( + app.logoPath, + fit: BoxFit.cover, + ), + ), + ), onTap: () { Navigator.push( context, - platformPageRoute( - context: context, - builder: (context) => app.widget, - ), + platformPageRoute(context: context, builder: (context) => app.widget), ); }, - child: Padding( - padding: const EdgeInsets.all(12), - child: Row( - children: [ - Container( - height: 62.0, - width: 62.0, - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(14), - border: Border.all( - color: app.accentColor.withValues(alpha: 0.28), - ), - ), - child: ClipRRect( - borderRadius: BorderRadius.circular(13.0), - child: Image.asset( - app.logoPath, - fit: BoxFit.cover, - ), - ), - ), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Expanded( - child: Text( - app.title, - style: titleStyle, - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ), - _LaunchAffordance(accentColor: app.accentColor), - ], - ), - const SizedBox(height: 3), - Text( - app.description, - maxLines: 2, - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.bodyMedium, - ), - const SizedBox(height: 8), - Text( - 'Supported devices', - style: Theme.of(context).textTheme.labelSmall?.copyWith( - color: Theme.of(context) - .textTheme - .bodySmall - ?.color - ?.withValues(alpha: 0.72), - fontWeight: FontWeight.w700, - ), - ), - const SizedBox(height: 6), - Wrap( - spacing: 6, - runSpacing: 6, - children: app.supportedDevices - .map( - (device) => _SupportedDeviceChip( - text: device, - accentColor: app.accentColor, - isConnected: hasConnectedWearableForPrefix( - devicePrefix: device, - connectedWearableNames: connectedWearableNames, - ), - ), - ) - .toList(), - ), - ], - ), - ), - ], - ), - ), - ), - ); - } -} - -class _LaunchAffordance extends StatelessWidget { - final Color accentColor; - - const _LaunchAffordance({ - required this.accentColor, - }); - - @override - Widget build(BuildContext context) { - return Container( - margin: const EdgeInsets.only(left: 8), - height: 30, - width: 30, - decoration: BoxDecoration( - color: accentColor.withValues(alpha: 0.12), - borderRadius: BorderRadius.circular(999), - ), - child: Icon( - Icons.arrow_forward_rounded, - size: 18, - color: accentColor.withValues(alpha: 0.9), - ), - ); - } -} - -class _SupportedDeviceChip extends StatelessWidget { - final String text; - final Color accentColor; - final bool isConnected; - - const _SupportedDeviceChip({ - required this.text, - required this.accentColor, - required this.isConnected, - }); - - @override - Widget build(BuildContext context) { - const connectedDotColor = Color(0xFF2F8F5B); - return Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), - decoration: BoxDecoration( - color: accentColor.withValues(alpha: 0.1), - borderRadius: BorderRadius.circular(999), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - text, - style: Theme.of(context).textTheme.labelSmall?.copyWith( - color: accentColor.withValues(alpha: 0.92), - fontWeight: FontWeight.w600, - ), - ), - if (isConnected) ...[ - const SizedBox(width: 6), - Container( - width: 7, - height: 7, - decoration: const BoxDecoration( - color: connectedDotColor, - shape: BoxShape.circle, - ), - ), - ], - ], - ), - ); + ); } } diff --git a/open_wearable/lib/apps/widgets/apps_page.dart b/open_wearable/lib/apps/widgets/apps_page.dart index 00e9380e..9400bf94 100644 --- a/open_wearable/lib/apps/widgets/apps_page.dart +++ b/open_wearable/lib/apps/widgets/apps_page.dart @@ -1,3 +1,4 @@ +import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:flutter_platform_widgets/flutter_platform_widgets.dart'; import 'package:go_router/go_router.dart'; @@ -7,76 +8,48 @@ import 'package:open_wearable/apps/posture_tracker/model/earable_attitude_tracke import 'package:open_wearable/apps/posture_tracker/view/posture_tracker_view.dart'; import 'package:open_wearable/apps/widgets/select_earable_view.dart'; import 'package:open_wearable/apps/widgets/app_tile.dart'; -import 'package:open_wearable/view_models/wearables_provider.dart'; -import 'package:provider/provider.dart'; + class AppInfo { final String logoPath; final String title; final String description; - final List supportedDevices; - final Color accentColor; final Widget widget; AppInfo({ required this.logoPath, required this.title, required this.description, - required this.supportedDevices, - required this.accentColor, required this.widget, }); } -const Color _appAccentColor = Color(0xFF9A6F6B); -const List _postureSupportedDevices = [ - "OpenEarable", - "eSense", - "Cosinuss", -]; -const List _heartSupportedDevices = [ - "OpenEarable", - "OpenRing", - "Cosinuss", -]; - -final List _apps = [ +List _apps = [ AppInfo( logoPath: "lib/apps/posture_tracker/assets/logo.png", title: "Posture Tracker", description: "Get feedback on bad posture", - supportedDevices: _postureSupportedDevices, - accentColor: _appAccentColor, - widget: SelectEarableView( - supportedDevicePrefixes: _postureSupportedDevices, - startApp: (wearable, sensorConfigProvider) { - return PostureTrackerView( - EarableAttitudeTracker( - wearable.requireCapability(), - sensorConfigProvider, - wearable.name.endsWith("L"), - ), - ); - }, - ), + widget: SelectEarableView(startApp: (wearable, sensorConfigProvider) { + return PostureTrackerView( + EarableAttitudeTracker( + wearable.requireCapability(), + sensorConfigProvider, + wearable.name.endsWith("L"), + ), + ); + },), ), AppInfo( logoPath: "lib/apps/heart_tracker/assets/logo.png", title: "Heart Tracker", description: "Track your heart rate and other vitals", - supportedDevices: _heartSupportedDevices, - accentColor: _appAccentColor, widget: SelectEarableView( - supportedDevicePrefixes: _heartSupportedDevices, startApp: (wearable, _) { if (wearable.hasCapability()) { //TODO: show alert if no ppg sensor is found - Sensor ppgSensor = - wearable.requireCapability().sensors.firstWhere( - (s) => - s.sensorName.toLowerCase() == - "photoplethysmography".toLowerCase(), - ); + Sensor ppgSensor = wearable.requireCapability().sensors.firstWhere( + (s) => s.sensorName.toLowerCase() == "photoplethysmography".toLowerCase(), + ); return HeartTrackerPage(ppgSensor: ppgSensor); } @@ -98,13 +71,11 @@ class AppsPage extends StatelessWidget { @override Widget build(BuildContext context) { - final connectedCount = context.watch().wearables.length; - return PlatformScaffold( appBar: PlatformAppBar( title: PlatformText("Apps"), trailingActions: [ - PlatformIconButton( + PlatformIconButton( icon: Icon(context.platformIcons.bluetooth), onPressed: () { context.push('/connect-devices'); @@ -112,149 +83,14 @@ class AppsPage extends StatelessWidget { ), ], ), - body: ListView( - padding: const EdgeInsets.fromLTRB(14, 12, 14, 24), - children: [ - _AppsHeroCard( - totalApps: _apps.length, - connectedDevices: connectedCount, - ), - const SizedBox(height: 18), - Padding( - padding: const EdgeInsets.only(left: 2, bottom: 8), - child: Text( - 'Available apps', - style: Theme.of(context).textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.w700, - ), - ), - ), - ..._apps.map((app) => AppTile(app: app)), - ], - ), - ); - } -} - -class _AppsHeroCard extends StatelessWidget { - final int totalApps; - final int connectedDevices; - - const _AppsHeroCard({ - required this.totalApps, - required this.connectedDevices, - }); - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - - return Container( - padding: const EdgeInsets.all(18), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(18), - gradient: const LinearGradient( - begin: Alignment.topLeft, - end: Alignment.bottomRight, - colors: [ - Color(0xFF835B58), - Color(0xFFB48A86), - ], + body: Padding( + padding: EdgeInsets.all(10), + child: ListView.builder( + itemCount: _apps.length, + itemBuilder: (context, index) { + return AppTile(app: _apps[index]); + }, ), - boxShadow: [ - BoxShadow( - color: Colors.black.withValues(alpha: 0.12), - blurRadius: 14, - offset: const Offset(0, 8), - ), - ], - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Container( - height: 34, - width: 34, - decoration: BoxDecoration( - color: Colors.white.withValues(alpha: 0.16), - borderRadius: BorderRadius.circular(10), - ), - child: const Icon( - Icons.auto_awesome, - color: Colors.white, - size: 20, - ), - ), - const SizedBox(width: 10), - Text( - 'App Studio', - style: theme.textTheme.titleMedium?.copyWith( - color: Colors.white, - fontWeight: FontWeight.w700, - ), - ), - ], - ), - const SizedBox(height: 10), - Text( - 'Launch wearable experiences from one place.', - style: theme.textTheme.bodyMedium?.copyWith( - color: Colors.white.withValues(alpha: 0.9), - ), - ), - const SizedBox(height: 14), - Wrap( - spacing: 8, - runSpacing: 8, - children: [ - _HeroStatPill( - label: '$totalApps apps', - icon: Icons.widgets_outlined, - ), - _HeroStatPill( - label: '$connectedDevices connected', - icon: Icons.watch_outlined, - ), - ], - ), - ], - ), - ); - } -} - -class _HeroStatPill extends StatelessWidget { - final String label; - final IconData icon; - - const _HeroStatPill({ - required this.label, - required this.icon, - }); - - @override - Widget build(BuildContext context) { - return Container( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), - decoration: BoxDecoration( - color: Colors.white.withValues(alpha: 0.15), - borderRadius: BorderRadius.circular(999), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(icon, size: 15, color: Colors.white), - const SizedBox(width: 6), - Text( - label, - style: Theme.of(context).textTheme.labelMedium?.copyWith( - color: Colors.white, - fontWeight: FontWeight.w600, - ), - ), - ], ), ); } diff --git a/open_wearable/lib/apps/widgets/select_earable_view.dart b/open_wearable/lib/apps/widgets/select_earable_view.dart index c20b9e30..49653a88 100644 --- a/open_wearable/lib/apps/widgets/select_earable_view.dart +++ b/open_wearable/lib/apps/widgets/select_earable_view.dart @@ -1,23 +1,17 @@ import 'package:flutter/material.dart'; import 'package:flutter_platform_widgets/flutter_platform_widgets.dart'; import 'package:open_earable_flutter/open_earable_flutter.dart'; -import 'package:open_wearable/apps/widgets/app_compatibility.dart'; import 'package:open_wearable/view_models/sensor_configuration_provider.dart'; import 'package:open_wearable/view_models/wearables_provider.dart'; import 'package:provider/provider.dart'; -class SelectEarableView extends StatefulWidget { +class SelectEarableView extends StatefulWidget { /// Callback to start the app /// -- [wearable] the selected wearable /// returns a [Widget] of the home page of the app final Widget Function(Wearable, SensorConfigurationProvider) startApp; - final List supportedDevicePrefixes; - const SelectEarableView({ - super.key, - required this.startApp, - this.supportedDevicePrefixes = const [], - }); + const SelectEarableView({super.key, required this.startApp}); @override State createState() => _SelectEarableViewState(); @@ -30,97 +24,56 @@ class _SelectEarableViewState extends State { Widget build(BuildContext context) { return PlatformScaffold( appBar: PlatformAppBar( - title: PlatformText("Select Wearable"), + title: PlatformText("Select Earable"), ), body: Consumer( builder: (context, WearablesProvider wearablesProvider, child) => - _buildBody(context, wearablesProvider), - ), - ); - } + Column( + children: [ + ListView.builder( + shrinkWrap: true, + physics: NeverScrollableScrollPhysics(), + itemCount: wearablesProvider.wearables.length, + itemBuilder: (context, index) { + Wearable wearable = wearablesProvider.wearables[index]; + return PlatformListTile( + title: PlatformText(wearable.name), + subtitle: PlatformText(wearable.deviceId), //TODO: use device ID + trailing: _selectedWearable == wearable + ? Icon(Icons.check) + : null, + onTap: () => setState(() { + _selectedWearable = wearable; + }), + ); + }, + ), - Widget _buildBody( - BuildContext context, - WearablesProvider wearablesProvider, - ) { - final compatibleWearables = wearablesProvider.wearables - .where( - (wearable) => wearableIsCompatibleWithApp( - wearableName: wearable.name, - supportedDevicePrefixes: widget.supportedDevicePrefixes, - ), - ) - .toList(growable: false); - - final hasSelectedCompatibleWearable = _selectedWearable != null && - compatibleWearables.contains(_selectedWearable); - - return Column( - children: [ - Expanded( - child: compatibleWearables.isEmpty - ? Center( - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 18), - child: Text( - 'No compatible wearables connected for this app.', - textAlign: TextAlign.center, - style: Theme.of(context).textTheme.bodyMedium, - ), - ), - ) - : ListView.builder( - itemCount: compatibleWearables.length, - itemBuilder: (context, index) { - final wearable = compatibleWearables[index]; - return PlatformListTile( - title: PlatformText(wearable.name), - subtitle: PlatformText(wearable.deviceId), - trailing: _selectedWearable == wearable - ? Icon(Icons.check) - : null, - onTap: () => setState(() { - _selectedWearable = wearable; - }), + PlatformElevatedButton( + child: PlatformText("Start App"), + onPressed: () { + if (_selectedWearable != null) { + Navigator.push( + context, + platformPageRoute( + context: context, + builder: (context) { + return ChangeNotifierProvider.value( + value: wearablesProvider.getSensorConfigurationProvider(_selectedWearable!), + child: widget.startApp( + _selectedWearable!, + wearablesProvider.getSensorConfigurationProvider(_selectedWearable!), + ), + ); + }, + ), ); - }, - ), - ), - Padding( - padding: const EdgeInsets.fromLTRB(16, 0, 16, 16), - child: SizedBox( - width: double.infinity, - child: PlatformElevatedButton( - onPressed: hasSelectedCompatibleWearable - ? () { - Navigator.push( - context, - platformPageRoute( - context: context, - builder: (context) { - return ChangeNotifierProvider.value( - value: wearablesProvider - .getSensorConfigurationProvider( - _selectedWearable!, - ), - child: widget.startApp( - _selectedWearable!, - wearablesProvider - .getSensorConfigurationProvider( - _selectedWearable!, - ), - ), - ); - }, - ), - ); - } - : null, - child: PlatformText("Start App"), - ), + } + }, + ), + ], ), - ), - ], + ), ); } } diff --git a/open_wearable/lib/main.dart b/open_wearable/lib/main.dart index 40e7b77b..70bead86 100644 --- a/open_wearable/lib/main.dart +++ b/open_wearable/lib/main.dart @@ -7,7 +7,6 @@ import 'package:open_earable_flutter/open_earable_flutter.dart' hide logger; import 'package:open_wearable/models/log_file_manager.dart'; import 'package:open_wearable/models/wearable_connector.dart'; import 'package:open_wearable/router.dart'; -import 'package:open_wearable/theme/app_theme.dart'; import 'package:open_wearable/view_models/sensor_recorder_provider.dart'; import 'package:open_wearable/widgets/app_banner.dart'; import 'package:open_wearable/widgets/global_app_banner_overlay.dart'; @@ -99,8 +98,7 @@ class _MyAppState extends State with WidgetsBindingObserver { ); }); - _wearableProvEventSub = - wearablesProvider.wearableEventStream.listen((event) { + _wearableProvEventSub = wearablesProvider.wearableEventStream.listen((event) { if (!mounted) return; // Handle firmware update available events with a dialog @@ -129,8 +127,7 @@ class _MyAppState extends State with WidgetsBindingObserver { child: const Text('Update Now'), onPressed: () { // Set the selected peripheral for firmware update - final updateProvider = - Provider.of( + final updateProvider = Provider.of( rootNavigatorKey.currentContext!, listen: false, ); @@ -168,8 +165,8 @@ class _MyAppState extends State with WidgetsBindingObserver { content: Text( event.description, style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: textColor, - ), + color: textColor, + ), ), backgroundColor: backgroundColor, key: ValueKey(id), @@ -250,9 +247,14 @@ class _MyAppState extends State with WidgetsBindingObserver { iosUsesMaterialWidgets: true, ), builder: (context) => PlatformTheme( - materialLightTheme: AppTheme.lightTheme(), - materialDarkTheme: AppTheme.darkTheme(), - themeMode: ThemeMode.light, + materialLightTheme: ThemeData( + useMaterial3: true, + colorScheme: ColorScheme.fromSeed(seedColor: Colors.blue), + cardTheme: const CardThemeData( + color: Colors.white, + elevation: 0, + ), + ), builder: (context) => GlobalAppBannerOverlay( child: PlatformApp.router( routerConfig: router, diff --git a/open_wearable/lib/theme/app_theme.dart b/open_wearable/lib/theme/app_theme.dart deleted file mode 100644 index 146ba043..00000000 --- a/open_wearable/lib/theme/app_theme.dart +++ /dev/null @@ -1,320 +0,0 @@ -import 'package:flutter/material.dart'; - -class AppTheme { - static const Color _brand = Color(0xFF9A6F6B); - static const Color _onBrand = Color(0xFFFFFFFF); - static const Color _brandInk = Color(0xFF5A3D3A); - static const Color _lightBackground = Color(0xFFF4F7FB); - static const Color _darkBackground = Color(0xFF0B1117); - - static ThemeData lightTheme() { - final colorScheme = ColorScheme.fromSeed( - seedColor: _brand, - brightness: Brightness.light, - ).copyWith( - primary: _brand, - onPrimary: _onBrand, - secondary: const Color(0xFFAA807C), - onSecondary: _onBrand, - tertiary: const Color(0xFFBB938F), - onTertiary: _onBrand, - surface: Colors.white, - ); - - return _buildTheme( - colorScheme: colorScheme, - scaffoldBackgroundColor: _lightBackground, - ); - } - - static ThemeData darkTheme() { - final colorScheme = ColorScheme.fromSeed( - seedColor: _brand, - brightness: Brightness.dark, - ).copyWith( - primary: const Color(0xFFC79F9B), - secondary: const Color(0xFFD1ACA8), - tertiary: const Color(0xFFE0C1BE), - surface: const Color(0xFF111A22), - ); - - return _buildTheme( - colorScheme: colorScheme, - scaffoldBackgroundColor: _darkBackground, - ); - } - - static ThemeData _buildTheme({ - required ColorScheme colorScheme, - required Color scaffoldBackgroundColor, - }) { - final base = ThemeData( - useMaterial3: true, - colorScheme: colorScheme, - scaffoldBackgroundColor: scaffoldBackgroundColor, - canvasColor: scaffoldBackgroundColor, - ); - - final outlineColor = colorScheme.outline.withValues(alpha: 0.2); - final buttonTextStyle = const TextStyle( - fontWeight: FontWeight.w700, - fontSize: 15, - letterSpacing: 0.2, - ); - final shapeLarge = RoundedRectangleBorder( - borderRadius: BorderRadius.circular(18), - side: BorderSide(color: outlineColor), - ); - final shapeMedium = RoundedRectangleBorder( - borderRadius: BorderRadius.circular(14), - ); - final shapeSmall = RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - ); - - return base.copyWith( - textTheme: _textTheme(base.textTheme, colorScheme), - appBarTheme: AppBarTheme( - elevation: 0, - scrolledUnderElevation: 0, - backgroundColor: scaffoldBackgroundColor, - foregroundColor: colorScheme.onSurface, - surfaceTintColor: Colors.transparent, - centerTitle: false, - ), - cardTheme: CardThemeData( - color: colorScheme.surface, - elevation: 0, - surfaceTintColor: Colors.transparent, - shape: shapeLarge, - ), - listTileTheme: ListTileThemeData( - shape: shapeMedium, - contentPadding: const EdgeInsets.symmetric( - horizontal: 14, - vertical: 4, - ), - iconColor: colorScheme.primary, - titleTextStyle: base.textTheme.titleSmall?.copyWith( - color: colorScheme.onSurface, - fontWeight: FontWeight.w700, - ), - subtitleTextStyle: base.textTheme.bodySmall?.copyWith( - color: colorScheme.onSurfaceVariant, - ), - ), - dividerTheme: DividerThemeData( - color: outlineColor, - thickness: 1, - space: 1, - ), - inputDecorationTheme: InputDecorationTheme( - filled: true, - fillColor: colorScheme.surface.withValues(alpha: 0.9), - contentPadding: const EdgeInsets.symmetric( - horizontal: 14, - vertical: 12, - ), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(14), - borderSide: BorderSide(color: outlineColor), - ), - enabledBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(14), - borderSide: BorderSide(color: outlineColor), - ), - focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(14), - borderSide: BorderSide( - color: colorScheme.primary, - width: 1.6, - ), - ), - errorBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(14), - borderSide: BorderSide(color: colorScheme.error), - ), - ), - bottomNavigationBarTheme: BottomNavigationBarThemeData( - type: BottomNavigationBarType.fixed, - backgroundColor: colorScheme.surface, - selectedItemColor: colorScheme.primary, - unselectedItemColor: colorScheme.onSurfaceVariant.withValues( - alpha: 0.92, - ), - selectedLabelStyle: const TextStyle( - fontWeight: FontWeight.w700, - fontSize: 12, - letterSpacing: 0.1, - ), - unselectedLabelStyle: const TextStyle( - fontWeight: FontWeight.w600, - fontSize: 12, - letterSpacing: 0.1, - ), - elevation: 0, - ), - navigationBarTheme: NavigationBarThemeData( - backgroundColor: colorScheme.surface, - indicatorColor: colorScheme.primaryContainer.withValues(alpha: 0.72), - elevation: 0, - surfaceTintColor: Colors.transparent, - height: 74, - labelTextStyle: WidgetStateProperty.resolveWith((states) { - final selected = states.contains(WidgetState.selected); - return TextStyle( - color: - selected ? colorScheme.primary : colorScheme.onSurfaceVariant, - fontWeight: selected ? FontWeight.w700 : FontWeight.w600, - fontSize: 12, - ); - }), - iconTheme: WidgetStateProperty.resolveWith((states) { - final selected = states.contains(WidgetState.selected); - return IconThemeData( - color: - selected ? colorScheme.primary : colorScheme.onSurfaceVariant, - ); - }), - ), - navigationRailTheme: NavigationRailThemeData( - backgroundColor: colorScheme.surface, - useIndicator: true, - indicatorColor: colorScheme.primaryContainer.withValues(alpha: 0.72), - selectedLabelTextStyle: TextStyle( - color: colorScheme.primary, - fontWeight: FontWeight.w700, - ), - unselectedLabelTextStyle: TextStyle( - color: colorScheme.onSurfaceVariant, - fontWeight: FontWeight.w600, - ), - selectedIconTheme: IconThemeData(color: colorScheme.primary), - unselectedIconTheme: IconThemeData(color: colorScheme.onSurfaceVariant), - ), - elevatedButtonTheme: ElevatedButtonThemeData( - style: ElevatedButton.styleFrom( - elevation: 0, - backgroundColor: colorScheme.primary, - foregroundColor: colorScheme.onPrimary, - disabledBackgroundColor: - colorScheme.onSurface.withValues(alpha: 0.12), - disabledForegroundColor: colorScheme.onSurface.withValues(alpha: 0.5), - minimumSize: const Size(0, 46), - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), - shape: shapeSmall, - textStyle: buttonTextStyle, - ), - ), - filledButtonTheme: FilledButtonThemeData( - style: FilledButton.styleFrom( - backgroundColor: colorScheme.primary, - foregroundColor: colorScheme.onPrimary, - disabledBackgroundColor: - colorScheme.onSurface.withValues(alpha: 0.12), - disabledForegroundColor: colorScheme.onSurface.withValues(alpha: 0.5), - minimumSize: const Size(0, 46), - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), - shape: shapeSmall, - textStyle: buttonTextStyle, - ), - ), - outlinedButtonTheme: OutlinedButtonThemeData( - style: OutlinedButton.styleFrom( - foregroundColor: _brandInk, - minimumSize: const Size(0, 46), - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), - shape: shapeSmall, - side: BorderSide(color: outlineColor), - textStyle: buttonTextStyle, - ), - ), - textButtonTheme: TextButtonThemeData( - style: TextButton.styleFrom( - foregroundColor: _brandInk, - shape: shapeSmall, - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), - textStyle: buttonTextStyle, - ), - ), - chipTheme: base.chipTheme.copyWith( - backgroundColor: colorScheme.secondaryContainer.withValues(alpha: 0.5), - side: BorderSide.none, - shape: const StadiumBorder(), - labelStyle: base.textTheme.labelMedium?.copyWith( - color: colorScheme.onSecondaryContainer, - fontWeight: FontWeight.w700, - ), - ), - dialogTheme: DialogThemeData( - backgroundColor: colorScheme.surface, - surfaceTintColor: Colors.transparent, - shape: shapeLarge, - ), - snackBarTheme: SnackBarThemeData( - behavior: SnackBarBehavior.floating, - elevation: 0, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(14), - ), - ), - progressIndicatorTheme: ProgressIndicatorThemeData( - color: colorScheme.primary, - ), - sliderTheme: base.sliderTheme.copyWith( - activeTrackColor: colorScheme.primary, - inactiveTrackColor: colorScheme.primary.withValues(alpha: 0.2), - thumbColor: colorScheme.primary, - overlayColor: colorScheme.primary.withValues(alpha: 0.14), - ), - switchTheme: SwitchThemeData( - trackColor: WidgetStateProperty.resolveWith((states) { - if (states.contains(WidgetState.selected)) { - return colorScheme.primary.withValues(alpha: 0.4); - } - return colorScheme.outline.withValues(alpha: 0.28); - }), - thumbColor: WidgetStateProperty.resolveWith((states) { - if (states.contains(WidgetState.selected)) { - return colorScheme.primary; - } - return colorScheme.surface; - }), - ), - ); - } - - static TextTheme _textTheme(TextTheme base, ColorScheme colorScheme) { - return base - .copyWith( - headlineSmall: base.headlineSmall?.copyWith( - fontWeight: FontWeight.w700, - letterSpacing: -0.2, - ), - titleLarge: base.titleLarge?.copyWith( - fontWeight: FontWeight.w700, - letterSpacing: -0.2, - ), - titleMedium: base.titleMedium?.copyWith( - fontWeight: FontWeight.w700, - letterSpacing: -0.1, - ), - titleSmall: base.titleSmall?.copyWith( - fontWeight: FontWeight.w700, - ), - bodyLarge: base.bodyLarge?.copyWith( - height: 1.32, - ), - bodyMedium: base.bodyMedium?.copyWith( - height: 1.32, - ), - labelLarge: base.labelLarge?.copyWith( - fontWeight: FontWeight.w700, - ), - ) - .apply( - bodyColor: colorScheme.onSurface, - displayColor: colorScheme.onSurface, - ); - } -} diff --git a/open_wearable/lib/view_models/sensor_configuration_provider.dart b/open_wearable/lib/view_models/sensor_configuration_provider.dart index 2613cf4b..f63bce67 100644 --- a/open_wearable/lib/view_models/sensor_configuration_provider.dart +++ b/open_wearable/lib/view_models/sensor_configuration_provider.dart @@ -5,22 +5,6 @@ import 'package:open_earable_flutter/open_earable_flutter.dart' hide logger; import '../models/logger.dart'; -class SensorConfigurationRestoreResult { - final int restoredCount; - final int requestedCount; - final int skippedCount; - final int unknownConfigCount; - - const SensorConfigurationRestoreResult({ - required this.restoredCount, - required this.requestedCount, - required this.skippedCount, - required this.unknownConfigCount, - }); - - bool get hasRestoredValues => restoredCount > 0; -} - class SensorConfigurationProvider with ChangeNotifier { final SensorConfigurationManager _sensorConfigurationManager; @@ -217,59 +201,29 @@ class SensorConfigurationProvider with ChangeNotifier { ); } - Future restoreFromJson( - Map jsonMap, - ) async { - final restoredConfigurations = - {}; - int requestedCount = 0; - int skippedCount = 0; - - final knownConfigurations = - _sensorConfigurationManager.sensorConfigurations.toList(); - final knownConfigNames = - knownConfigurations.map((config) => config.name).toSet(); - - for (final config in knownConfigurations) { + Future restoreFromJson(Map jsonMap) async { + Map restoredConfigurations = + {}; + for (final config in _sensorConfigurations.keys) { final selectedKey = jsonMap[config.name]; if (selectedKey == null) continue; - requestedCount += 1; - - final matchingValue = config.values - .where((value) => value.key == selectedKey) - .cast() - .firstOrNull; - - if (matchingValue == null) { - skippedCount += 1; - logger.w( - 'Skipped restoring "${config.name}" because value "$selectedKey" is no longer available.', + try { + final SensorConfigurationValue matchingValue = config.values.firstWhere( + (v) => v.key == selectedKey, ); - continue; + restoredConfigurations[config] = matchingValue; + } on StateError { + logger.e("Failed to restore configuration for ${config.name}"); + return false; } - - restoredConfigurations[config] = matchingValue; } - for (final config in restoredConfigurations.keys) { _sensorConfigurations[config] = restoredConfigurations[config]!; _updateSelectedOptions(config); } - - if (restoredConfigurations.isNotEmpty) { - notifyListeners(); - } - - final unknownConfigCount = - jsonMap.keys.where((name) => !knownConfigNames.contains(name)).length; - - return SensorConfigurationRestoreResult( - restoredCount: restoredConfigurations.length, - requestedCount: requestedCount, - skippedCount: skippedCount, - unknownConfigCount: unknownConfigCount, - ); + notifyListeners(); + return true; } @override diff --git a/open_wearable/lib/view_models/sensor_configuration_storage.dart b/open_wearable/lib/view_models/sensor_configuration_storage.dart index ada18289..28586981 100644 --- a/open_wearable/lib/view_models/sensor_configuration_storage.dart +++ b/open_wearable/lib/view_models/sensor_configuration_storage.dart @@ -3,8 +3,6 @@ import 'dart:io'; import 'package:path_provider/path_provider.dart'; class SensorConfigurationStorage { - static const String _scopeSeparator = '__'; - /// Returns the directory where sensor configurations are stored. /// Creates the directory if it does not exist. static Future _getConfigDirectory() async { @@ -20,13 +18,9 @@ class SensorConfigurationStorage { /// Each file is expected to be a JSON file with a specific configuration. static Future> _getAllConfigFiles() async { final configDir = await _getConfigDirectory(); - return configDir - .list() - .where( - (file) => file is File && file.path.endsWith('.json'), - ) - .cast() - .toList(); + return configDir.list().where((file) => + file is File && file.path.endsWith('.json'), + ).cast().toList(); } /// Returns the file for a specific configuration key. @@ -39,10 +33,7 @@ class SensorConfigurationStorage { /// Saves a configuration for a specific key. /// If the file already exists, it will be overwritten. /// The configuration is expected to be a map of string key-value pairs. - static Future saveConfiguration( - String key, - Map config, - ) async { + static Future saveConfiguration(String key, Map config) async { final File file = await _getConfigFile(key); await file.writeAsString(jsonEncode(config)); } @@ -63,8 +54,7 @@ class SensorConfigurationStorage { final configFiles = await _getAllConfigFiles(); for (final file in configFiles) { final contents = await file.readAsString(); - allConfigs[_getKeyFromFile(file)] = - Map.from(jsonDecode(contents)); + allConfigs[_getKeyFromFile(file)] = Map.from(jsonDecode(contents)); } return allConfigs; } @@ -87,33 +77,5 @@ class SensorConfigurationStorage { } } - static String scopedPrefix(String scope) => - '${sanitizeKey(scope)}$_scopeSeparator'; - - static String buildScopedKey({ - required String scope, - required String name, - }) { - final sanitizedName = sanitizeKey(name.trim()); - return '${scopedPrefix(scope)}$sanitizedName'; - } - - static bool keyMatchesScope(String key, String scope) { - return key.startsWith(scopedPrefix(scope)); - } - - static String displayNameFromScopedKey( - String key, { - required String scope, - }) { - if (!keyMatchesScope(key, scope)) { - return key.replaceAll('_', ' '); - } - return key.substring(scopedPrefix(scope).length).replaceAll('_', ' '); - } - - static bool isLegacyUnscopedKey(String key) => !key.contains(_scopeSeparator); - - static String sanitizeKey(String key) => - key.replaceAll(RegExp(r'[^\w\-]'), '_'); + static String sanitizeKey(String key) => key.replaceAll(RegExp(r'[^\w\-]'), '_'); } diff --git a/open_wearable/lib/widgets/devices/connect_devices_page.dart b/open_wearable/lib/widgets/devices/connect_devices_page.dart index b2e4a225..96bc20ef 100644 --- a/open_wearable/lib/widgets/devices/connect_devices_page.dart +++ b/open_wearable/lib/widgets/devices/connect_devices_page.dart @@ -22,14 +22,10 @@ class ConnectDevicesPage extends StatefulWidget { class _ConnectDevicesPageState extends State { final WearableManager _wearableManager = WearableManager(); - StreamSubscription? _scanSubscription; - Timer? _scanIndicatorTimer; + StreamSubscription? _scanSubscription; - final List _discoveredDevices = []; - final Map _connectingDevices = {}; - - bool _isScanning = false; - DateTime? _lastScanStartedAt; + List discoveredDevices = []; + Map connectingDevices = {}; @override void initState() { @@ -39,182 +35,56 @@ class _ConnectDevicesPageState extends State { @override Widget build(BuildContext context) { - final wearablesProvider = context.watch(); - final connectedWearables = wearablesProvider.wearables; - final connectedDeviceIds = - connectedWearables.map((wearable) => wearable.deviceId).toSet(); - - final availableDevices = _discoveredDevices - .where((device) => !connectedDeviceIds.contains(device.id)) - .toList() - ..sort((a, b) { - final nameCompare = _deviceName(a) - .toLowerCase() - .compareTo(_deviceName(b).toLowerCase()); - if (nameCompare != 0) return nameCompare; - return a.id.compareTo(b.id); - }); + final WearablesProvider wearablesProvider = + Provider.of(context); + + List connectedDevicesWidgets = + wearablesProvider.wearables.map((wearable) { + return PlatformListTile( + title: PlatformText(wearable.name), + subtitle: PlatformText(wearable.deviceId), + trailing: Icon(PlatformIcons(context).checkMark), + ); + }).toList(); + List discoveredDevicesWidgets = discoveredDevices.map((device) { + return PlatformListTile( + title: PlatformText(device.name), + subtitle: PlatformText(device.id), + trailing: _buildTrailingWidget(device.id), + onTap: () { + _connectToDevice(device, context); + }, + ); + }).toList(); return PlatformScaffold( appBar: PlatformAppBar( - title: const Text('Connect Devices'), - trailingActions: [ - PlatformIconButton( - icon: _isScanning - ? const SizedBox( - width: 18, - height: 18, - child: CircularProgressIndicator(strokeWidth: 2), - ) - : const Icon(Icons.bluetooth_searching), - onPressed: - _isScanning ? null : () => _startScanning(clearPrevious: true), - ), - ], + title: PlatformText('Connect Devices'), ), - body: RefreshIndicator( - onRefresh: () => _startScanning(clearPrevious: true), + body: Padding( + padding: const EdgeInsets.all(10.0), child: ListView( - physics: const AlwaysScrollableScrollPhysics(), - padding: const EdgeInsets.fromLTRB(12, 10, 12, 16), + shrinkWrap: true, children: [ - _buildScanStatusCard( - context, - connectedCount: connectedWearables.length, - discoveredCount: availableDevices.length, - ), - const SizedBox(height: 12), - _buildSectionHeader( - context, - title: 'Connected', - count: connectedWearables.length, - ), - if (connectedWearables.isEmpty) - _buildEmptyCard( - context, - title: 'No devices connected', - subtitle: 'Tap a discovered device below to connect.', - ) - else - ...connectedWearables.map( - (wearable) => Card( - margin: const EdgeInsets.only(bottom: 8), - child: PlatformListTile( - leading: const Icon(Icons.watch_outlined), - title: PlatformText(wearable.name), - subtitle: PlatformText(wearable.deviceId), - trailing: Icon( - PlatformIcons(context).checkMark, - color: Theme.of(context).colorScheme.primary, - ), - ), - ), + Padding( + padding: const EdgeInsets.all(8.0), + child: PlatformText( + 'Connected Devices', + style: Theme.of(context).textTheme.titleSmall, ), - const SizedBox(height: 12), - _buildSectionHeader( - context, - title: 'Available', - count: availableDevices.length, ), - if (availableDevices.isEmpty) - _buildEmptyCard( - context, - title: _isScanning - ? 'Scanning for devices...' - : 'No devices found yet', - subtitle: _isScanning - ? 'Make sure your wearable is turned on and nearby.' - : 'Press scan again or pull to refresh.', - ) - else - ...availableDevices.map( - (device) => Card( - margin: const EdgeInsets.only(bottom: 8), - child: PlatformListTile( - leading: const Icon(Icons.bluetooth), - title: PlatformText(_deviceName(device)), - subtitle: PlatformText(device.id), - trailing: _buildTrailingWidget(device), - onTap: _connectingDevices[device.id] == true - ? null - : () => _connectToDevice(device, context), - ), - ), + ...connectedDevicesWidgets, + Padding( + padding: const EdgeInsets.all(8.0), + child: PlatformText( + 'Discovered Devices', + style: Theme.of(context).textTheme.titleSmall, ), - const SizedBox(height: 10), - PlatformElevatedButton( - onPressed: _isScanning - ? null - : () => _startScanning(clearPrevious: true), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - if (_isScanning) - const SizedBox( - width: 16, - height: 16, - child: CircularProgressIndicator(strokeWidth: 2), - ) - else - const Icon(Icons.bluetooth_searching), - const SizedBox(width: 8), - Text(_isScanning ? 'Scanning...' : 'Scan Again'), - ], - ), - ), - ], - ), - ), - ); - } - - Widget _buildScanStatusCard( - BuildContext context, { - required int connectedCount, - required int discoveredCount, - }) { - final statusText = - _isScanning ? 'Scanning for nearby devices' : 'Ready to scan'; - final helperText = _lastScanStartedAt == null - ? 'Use Scan to discover nearby wearables.' - : 'Last scan: ${_formatScanTime(_lastScanStartedAt!)}'; - - return Card( - child: Padding( - padding: const EdgeInsets.all(14), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Icon( - _isScanning ? Icons.radar : Icons.bluetooth_searching, - color: Theme.of(context).colorScheme.primary, - ), - const SizedBox(width: 8), - Expanded( - child: Text( - statusText, - style: Theme.of(context).textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.w700, - ), - ), - ), - ], - ), - const SizedBox(height: 4), - Text( - helperText, - style: Theme.of(context).textTheme.bodySmall, ), - const SizedBox(height: 10), - Wrap( - spacing: 8, - runSpacing: 8, - children: [ - _StatusPill(label: '$connectedCount connected'), - _StatusPill(label: '$discoveredCount available'), - ], + ...discoveredDevicesWidgets, + PlatformElevatedButton( + onPressed: _startScanning, + child: PlatformText('Scan'), ), ], ), @@ -222,111 +92,26 @@ class _ConnectDevicesPageState extends State { ); } - Widget _buildSectionHeader( - BuildContext context, { - required String title, - required int count, - }) { - return Padding( - padding: const EdgeInsets.fromLTRB(4, 0, 4, 8), - child: Row( - children: [ - Text( - title, - style: Theme.of(context).textTheme.titleSmall?.copyWith( - fontWeight: FontWeight.w700, - ), - ), - const SizedBox(width: 8), - _StatusPill(label: '$count'), - ], - ), - ); - } - - Widget _buildEmptyCard( - BuildContext context, { - required String title, - required String subtitle, - }) { - return Card( - margin: const EdgeInsets.only(bottom: 8), - child: ListTile( - leading: const Icon(Icons.info_outline), - title: Text(title), - subtitle: Text(subtitle), - ), - ); - } - - Widget _buildTrailingWidget(DiscoveredDevice device) { - if (_connectingDevices[device.id] == true) { + Widget _buildTrailingWidget(String id) { + if (connectingDevices[id] == true) { return SizedBox( height: 24, width: 24, child: PlatformCircularProgressIndicator(), ); } - return PlatformTextButton( - onPressed: () => _connectToDevice(device, context), - child: const Text('Connect'), - ); - } - - String _deviceName(DiscoveredDevice device) { - final name = device.name.trim(); - if (name.isEmpty) return 'Unnamed device'; - return name; - } - - String _formatScanTime(DateTime startedAt) { - final elapsed = DateTime.now().difference(startedAt); - if (elapsed.inSeconds < 10) return 'just now'; - if (elapsed.inMinutes < 1) return '${elapsed.inSeconds}s ago'; - if (elapsed.inHours < 1) return '${elapsed.inMinutes}m ago'; - return '${elapsed.inHours}h ago'; + return const SizedBox.shrink(); } - Future _startScanning({bool clearPrevious = false}) async { - _scanIndicatorTimer?.cancel(); - - if (mounted) { - setState(() { - if (clearPrevious) { - _discoveredDevices.clear(); - } - _isScanning = true; - _lastScanStartedAt = DateTime.now(); - }); - } - - await _scanSubscription?.cancel(); + void _startScanning() async { _wearableManager.startScan(); - _scanSubscription = _wearableManager.scanStream.listen( - (incomingDevice) { - if (incomingDevice.name.isEmpty) return; - - if (_discoveredDevices - .any((device) => device.id == incomingDevice.id)) { - return; - } - + _scanSubscription?.cancel(); + _scanSubscription = _wearableManager.scanStream.listen((incomingDevice) { + if (incomingDevice.name.isNotEmpty && + !discoveredDevices.any((device) => device.id == incomingDevice.id)) { logger.d('Discovered device: ${incomingDevice.name}'); - if (mounted) { - setState(() { - _discoveredDevices.add(incomingDevice); - }); - } - }, - onError: (error, stackTrace) { - logger.w('Device scan stream error: $error\n$stackTrace'); - }, - ); - - _scanIndicatorTimer = Timer(const Duration(seconds: 8), () { - if (mounted) { setState(() { - _isScanning = false; + discoveredDevices.add(incomingDevice); }); } }); @@ -336,79 +121,44 @@ class _ConnectDevicesPageState extends State { DiscoveredDevice device, BuildContext context, ) async { - if (_connectingDevices[device.id] == true) return; - setState(() { - _connectingDevices[device.id] = true; + connectingDevices[device.id] = true; }); try { - final connector = context.read(); + WearableConnector connector = context.read(); await connector.connect(device); - if (mounted) { - setState(() { - _discoveredDevices.removeWhere((d) => d.id == device.id); - }); - } + setState(() { + discoveredDevices.remove(device); + }); } catch (e) { - final message = _wearableManager.deviceErrorMessage(e, device.name); + String message = _wearableManager.deviceErrorMessage(e, device.name); logger.e('Failed to connect to device: ${device.name}, error: $message'); if (context.mounted) { showPlatformDialog( context: context, - builder: (dialogContext) => PlatformAlertDialog( - title: const Text('Connection Error'), - content: Text(message), + builder: (context) => PlatformAlertDialog( + title: PlatformText('Connection Error'), + content: PlatformText(message), actions: [ PlatformDialogAction( - onPressed: () => Navigator.of(dialogContext).pop(), - child: const Text('OK'), + onPressed: () => Navigator.of(context).pop(), + child: PlatformText('OK'), ), ], ), ); } } finally { - if (mounted) { - setState(() { - _connectingDevices.remove(device.id); - }); - } + setState(() { + connectingDevices.remove(device.id); + }); } } @override void dispose() { - _scanIndicatorTimer?.cancel(); _scanSubscription?.cancel(); - _wearableManager.setAutoConnect([]); super.dispose(); } } - -class _StatusPill extends StatelessWidget { - final String label; - - const _StatusPill({ - required this.label, - }); - - @override - Widget build(BuildContext context) { - return Container( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.primaryContainer.withValues( - alpha: 0.65, - ), - borderRadius: BorderRadius.circular(999), - ), - child: Text( - label, - style: Theme.of(context).textTheme.labelSmall?.copyWith( - fontWeight: FontWeight.w700, - ), - ), - ); - } -} diff --git a/open_wearable/lib/widgets/devices/devices_page.dart b/open_wearable/lib/widgets/devices/devices_page.dart index f2b4ffe1..b3b33cd1 100644 --- a/open_wearable/lib/widgets/devices/devices_page.dart +++ b/open_wearable/lib/widgets/devices/devices_page.dart @@ -40,14 +40,17 @@ class _DevicesPageState extends State { ); } - Widget _buildSmallScreenLayout( - BuildContext context, - WearablesProvider wearablesProvider, - ) { + Widget _buildSmallScreenLayout(BuildContext context, WearablesProvider wearablesProvider) { return PlatformScaffold( appBar: PlatformAppBar( title: PlatformText("Devices"), trailingActions: [ + PlatformIconButton( + icon: Icon(context.platformIcons.info), + onPressed: () { + context.push('/log-files'); + }, + ), PlatformIconButton( icon: Icon(context.platformIcons.bluetooth), onPressed: () { @@ -60,10 +63,7 @@ class _DevicesPageState extends State { ); } - Widget _buildSmallScreenContent( - BuildContext context, - WearablesProvider wearablesProvider, - ) { + Widget _buildSmallScreenContent(BuildContext context, WearablesProvider wearablesProvider) { if (wearablesProvider.wearables.isEmpty) { return RefreshIndicator( onRefresh: () async { @@ -160,6 +160,7 @@ class _DevicesPageState extends State { } } + // MARK: DeviceRow /// This widget represents a single device in the list/grid. @@ -217,27 +218,22 @@ class DeviceRow extends StatelessWidget { .bodyLarge ?.copyWith(fontWeight: FontWeight.bold), ), - Row( - children: [ - BatteryStateView(device: _device), - if (_device.hasCapability()) - Padding( - padding: EdgeInsets.only(left: 8.0), - child: StereoPosLabel( - device: - _device.requireCapability(), - ), - ), - ], - ), - ], - ), - Spacer(), - if (_device.hasCapability()) + Row(children: [ + BatteryStateView(device: _device), + if (_device.hasCapability()) + Padding( + padding: EdgeInsets.only(left: 8.0), + child: StereoPosLabel(device: _device.requireCapability()), + ), + ], + ), + ], + ), + Spacer(), + if (_device.hasCapability()) FutureBuilder( - future: _device - .requireCapability() - .readDeviceIdentifier(), + future: + _device.requireCapability().readDeviceIdentifier(), builder: (context, snapshot) { if (snapshot.connectionState == ConnectionState.waiting) { @@ -258,8 +254,7 @@ class DeviceRow extends StatelessWidget { children: [ PlatformText("Firmware Version: "), FutureBuilder( - future: _device - .requireCapability() + future: _device.requireCapability() .readDeviceFirmwareVersion(), builder: (context, snapshot) { if (snapshot.connectionState == @@ -273,8 +268,7 @@ class DeviceRow extends StatelessWidget { }, ), FutureBuilder( - future: _device - .requireCapability() + future: _device.requireCapability() .checkFirmwareSupport(), builder: (context, snapshot) { if (snapshot.connectionState == @@ -311,8 +305,7 @@ class DeviceRow extends StatelessWidget { children: [ PlatformText("Hardware Version: "), FutureBuilder( - future: _device - .requireCapability() + future: _device.requireCapability() .readDeviceHardwareVersion(), builder: (context, snapshot) { if (snapshot.connectionState == diff --git a/open_wearable/lib/widgets/home_page.dart b/open_wearable/lib/widgets/home_page.dart index 9e2805df..54bb8ccf 100644 --- a/open_wearable/lib/widgets/home_page.dart +++ b/open_wearable/lib/widgets/home_page.dart @@ -1,20 +1,15 @@ import 'package:flutter/material.dart'; import 'package:flutter_platform_widgets/flutter_platform_widgets.dart'; -import 'package:go_router/go_router.dart'; import 'package:open_wearable/apps/widgets/apps_page.dart'; -import 'package:open_wearable/view_models/wearables_provider.dart'; import 'package:open_wearable/widgets/devices/devices_page.dart'; -import 'package:provider/provider.dart'; +import 'package:open_wearable/widgets/sensors/configuration/sensor_configuration_view.dart'; +import 'package:open_wearable/widgets/sensors/values/sensor_values_page.dart'; import 'sensors/sensor_page.dart'; -const int _overviewIndex = 0; -const int _devicesIndex = 1; -const int _sensorsIndex = 2; -const int _appsIndex = 3; - -const double _largeScreenBreakpoint = 960; - +/// The home page of the app. +/// +/// The home page contains a tab bar and an AppBar. class HomePage extends StatefulWidget { const HomePage({super.key}); @@ -23,364 +18,97 @@ class HomePage extends StatefulWidget { } class _HomePageState extends State { - late final PlatformTabController _tabController; - late final List<_HomeDestination> _destinations; - late final List _sections; - int _selectedIndex = _overviewIndex; - - @override - void initState() { - super.initState(); + static final titles = ["Devices", "Sensors", "Apps"]; - _tabController = PlatformTabController(initialIndex: _overviewIndex); - _tabController.addListener(_syncSelectedIndex); - - _destinations = const [ - _HomeDestination( - title: 'Overview', - icon: Icons.dashboard_outlined, - selectedIcon: Icons.dashboard, - ), - _HomeDestination( - title: 'Devices', - icon: Icons.devices_outlined, - selectedIcon: Icons.devices, - ), - _HomeDestination( - title: 'Sensors', - icon: Icons.ssid_chart_outlined, - selectedIcon: Icons.ssid_chart, + List items(BuildContext context) { + return [ + BottomNavigationBarItem( + icon: Icon(Icons.devices), + label: titles[0], ), - _HomeDestination( - title: 'Apps', - icon: Icons.apps_outlined, - selectedIcon: Icons.apps, + BottomNavigationBarItem( + icon: Icon(Icons.ssid_chart_rounded), + label: titles[1], ), - _HomeDestination( - title: 'Utilities', - icon: Icons.handyman_outlined, - selectedIcon: Icons.handyman, - ), - ]; - - _sections = [ - _OverviewPage( - onSectionRequested: _jumpToSection, - onConnectRequested: _openConnectDevices, - ), - const DevicesPage(), - const SensorPage(), - const AppsPage(), - _IntegrationsPage( - onLogsRequested: _openLogFiles, + BottomNavigationBarItem( + icon: Icon(Icons.apps_rounded), + label: titles[2], ), ]; } + late PlatformTabController _controller; + + late List _tabs; + @override - void dispose() { - _tabController.removeListener(_syncSelectedIndex); - _tabController.dispose(); - super.dispose(); + void initState() { + super.initState(); + _controller = PlatformTabController(initialIndex: 0); + _tabs = [ + DevicesPage(), + SensorPage(), + const AppsPage(), + ]; } @override Widget build(BuildContext context) { return LayoutBuilder( builder: (context, constraints) { - if (constraints.maxWidth >= _largeScreenBreakpoint) { - return _buildLargeScreenLayout(context); - } - return _buildCompactLayout(context); + return _buildSmallScreenLayout(context); }, ); } - Widget _buildCompactLayout(BuildContext context) { - return PlatformTabScaffold( - tabController: _tabController, - items: _destinations - .map( - (destination) => BottomNavigationBarItem( - icon: Icon(destination.icon), - activeIcon: Icon(destination.selectedIcon), - label: destination.title, - ), - ) - .toList(), - bodyBuilder: (context, index) => IndexedStack( - index: index, - children: _sections, - ), - ); - } - + // ignore: unused_element Widget _buildLargeScreenLayout(BuildContext context) { - final bool useExtendedRail = MediaQuery.of(context).size.width >= 1280; - return PlatformScaffold( - body: SafeArea( - child: Row( + appBar: PlatformAppBar( + title: PlatformText("OpenWearable"), + ), + body: Padding( + padding: const EdgeInsets.all(10), + child: ListView( children: [ - NavigationRail( - selectedIndex: _selectedIndex, - onDestinationSelected: (index) => _selectSection(context, index), - labelType: NavigationRailLabelType.all, - extended: useExtendedRail, - leading: Padding( - padding: const EdgeInsets.symmetric(vertical: 20), - child: useExtendedRail - ? Text( - 'OpenWearable', - style: Theme.of(context).textTheme.titleMedium, - ) - : Icon( - Icons.watch, - color: Theme.of(context).colorScheme.primary, - ), - ), - destinations: _destinations - .map( - (destination) => NavigationRailDestination( - icon: Icon(destination.icon), - selectedIcon: Icon(destination.selectedIcon), - label: Text(destination.title), - ), - ) - .toList(), + PlatformText( + "Connected Devices", + style: Theme.of(context) + .textTheme + .bodyLarge + ?.copyWith(color: Theme.of(context).colorScheme.surfaceTint), ), - const VerticalDivider(width: 1), - Expanded( - child: IndexedStack( - index: _selectedIndex, - children: _sections, - ), + DevicesPage(), + PlatformText( + "Sensor Configuration", + style: Theme.of(context) + .textTheme + .bodyLarge + ?.copyWith(color: Theme.of(context).colorScheme.surfaceTint), ), + SensorConfigurationView(), + PlatformText( + "Sensor Values", + style: Theme.of(context) + .textTheme + .bodyLarge + ?.copyWith(color: Theme.of(context).colorScheme.surfaceTint), + ), + SensorValuesPage(), ], ), ), ); } - void _syncSelectedIndex() { - if (!mounted) return; - final int controllerIndex = _tabController.index(context); - if (_selectedIndex != controllerIndex) { - setState(() { - _selectedIndex = controllerIndex; - }); - } - } - - void _jumpToSection(int index) { - if (!mounted) return; - _selectSection(context, index); - } - - void _selectSection(BuildContext context, int index) { - if (index < 0 || index >= _sections.length) return; - - if (_selectedIndex != index) { - setState(() { - _selectedIndex = index; - }); - } - _tabController.setIndex(context, index); - } - - void _openConnectDevices() { - if (!mounted) return; - context.push('/connect-devices'); - } - - void _openLogFiles() { - if (!mounted) return; - context.push('/log-files'); - } -} - -class _OverviewPage extends StatelessWidget { - final void Function(int index) onSectionRequested; - final VoidCallback onConnectRequested; - - const _OverviewPage({ - required this.onSectionRequested, - required this.onConnectRequested, - }); - - @override - Widget build(BuildContext context) { - return PlatformScaffold( - appBar: PlatformAppBar( - title: const Text('Overview'), - trailingActions: [ - PlatformIconButton( - icon: Icon(context.platformIcons.bluetooth), - onPressed: onConnectRequested, - ), - ], - ), - body: Consumer( - builder: (context, wearablesProvider, _) { - final wearables = wearablesProvider.wearables; - - return ListView( - padding: const EdgeInsets.all(16), - children: [ - Text( - 'Connected devices', - style: Theme.of(context).textTheme.titleMedium, - ), - const SizedBox(height: 8), - if (wearables.isEmpty) - Card( - child: ListTile( - leading: const Icon(Icons.info_outline), - title: const Text('No devices connected'), - subtitle: const Text( - 'Connect a wearable to access sensors and apps.', - ), - trailing: PlatformTextButton( - onPressed: onConnectRequested, - child: const Text('Connect'), - ), - ), - ) - else - ...wearables.map( - (wearable) => Card( - child: ListTile( - leading: const Icon(Icons.watch), - title: Text(wearable.name), - subtitle: Text(wearable.deviceId), - trailing: const Icon(Icons.chevron_right), - onTap: () => - context.push('/device-detail', extra: wearable), - ), - ), - ), - const SizedBox(height: 20), - Text( - 'Quick actions', - style: Theme.of(context).textTheme.titleMedium, - ), - const SizedBox(height: 8), - _QuickActionTile( - icon: Icons.bluetooth_searching, - title: 'Connect device', - subtitle: 'Scan and pair a wearable', - onTap: onConnectRequested, - ), - _QuickActionTile( - icon: Icons.devices, - title: 'Manage devices', - subtitle: 'Open connected devices and hardware controls', - onTap: () => onSectionRequested(_devicesIndex), - ), - _QuickActionTile( - icon: Icons.tune, - title: 'Configure sensors', - subtitle: 'Open sensor configuration and apply settings', - onTap: () => onSectionRequested(_sensorsIndex), - ), - _QuickActionTile( - icon: Icons.apps, - title: 'Open apps', - subtitle: 'Launch tracking apps for connected wearables', - onTap: () => onSectionRequested(_appsIndex), - ), - ], - ); - }, - ), - ); - } -} - -class _IntegrationsPage extends StatelessWidget { - final VoidCallback onLogsRequested; - - const _IntegrationsPage({ - required this.onLogsRequested, - }); - - @override - Widget build(BuildContext context) { - return PlatformScaffold( - appBar: PlatformAppBar( - title: const Text('Utilities'), - ), - body: ListView( - padding: const EdgeInsets.all(16), - children: [ - _QuickActionTile( - icon: Icons.hub, - title: 'Connectors', - subtitle: 'External connector integrations\n(coming soon)', - enabled: false, - ), - _QuickActionTile( - icon: Icons.receipt_long, - title: 'Log files', - subtitle: 'View, share, and remove diagnostic logs', - onTap: onLogsRequested, - ), - ], - ), - ); - } -} - -class _QuickActionTile extends StatelessWidget { - final IconData icon; - final String title; - final String subtitle; - final VoidCallback? onTap; - final bool enabled; - - const _QuickActionTile({ - required this.icon, - required this.title, - required this.subtitle, - this.onTap, - this.enabled = true, - }); - - @override - Widget build(BuildContext context) { - final iconColor = enabled ? null : Theme.of(context).disabledColor; - final textColor = enabled ? null : Theme.of(context).disabledColor; - - return Card( - margin: const EdgeInsets.only(bottom: 8), - child: ListTile( - enabled: enabled, - leading: Icon(icon, color: iconColor), - title: Text( - title, - style: textColor == null ? null : TextStyle(color: textColor), - ), - subtitle: Text( - subtitle, - style: textColor == null ? null : TextStyle(color: textColor), - ), - trailing: enabled - ? const Icon(Icons.chevron_right) - : Icon(Icons.schedule, color: iconColor), - onTap: enabled ? onTap : null, + Widget _buildSmallScreenLayout(BuildContext context) { + return PlatformTabScaffold( + tabController: _controller, + bodyBuilder: (context, index) => IndexedStack( + index: index, + children: _tabs, ), + items: items(context), ); } } - -class _HomeDestination { - final String title; - final IconData icon; - final IconData selectedIcon; - - const _HomeDestination({ - required this.title, - required this.icon, - required this.selectedIcon, - }); -} diff --git a/open_wearable/lib/widgets/sensors/configuration/save_config_row.dart b/open_wearable/lib/widgets/sensors/configuration/save_config_row.dart index eb808e0a..6c8a091e 100644 --- a/open_wearable/lib/widgets/sensors/configuration/save_config_row.dart +++ b/open_wearable/lib/widgets/sensors/configuration/save_config_row.dart @@ -2,21 +2,12 @@ import 'package:flutter/material.dart'; import 'package:flutter_platform_widgets/flutter_platform_widgets.dart'; import 'package:provider/provider.dart'; -import '../../../models/logger.dart'; import '../../../view_models/sensor_configuration_provider.dart'; import '../../../view_models/sensor_configuration_storage.dart'; +import '../../../models/logger.dart'; class SaveConfigRow extends StatefulWidget { - final String storageScope; - final String? defaultName; - final VoidCallback? onSaved; - - const SaveConfigRow({ - super.key, - required this.storageScope, - this.defaultName, - this.onSaved, - }); + const SaveConfigRow({super.key}); @override State createState() => _SaveConfigRowState(); @@ -24,168 +15,59 @@ class SaveConfigRow extends StatefulWidget { class _SaveConfigRowState extends State { String _configName = ''; - bool _isSaving = false; - late final TextEditingController _nameController; - - @override - void initState() { - super.initState(); - _configName = widget.defaultName?.trim() ?? ''; - _nameController = TextEditingController(text: _configName); - } - - @override - void dispose() { - _nameController.dispose(); - super.dispose(); - } @override Widget build(BuildContext context) { - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Expanded( - child: PlatformTextField( - controller: _nameController, - onChanged: (value) { - setState(() { - _configName = value; - }); - }, - onTapOutside: (_) => FocusScope.of(context).unfocus(), - hintText: 'Profile name', - ), - ), - const SizedBox(width: 12), - _isSaving - ? const SizedBox( - height: 24, - width: 24, - child: CircularProgressIndicator(strokeWidth: 2), - ) - : PlatformElevatedButton( - onPressed: _saveConfiguration, - child: const Text('Save Profile'), - ), - ], - ), - const SizedBox(height: 8), - Text( - 'Save current settings as a reusable profile for this device.', - style: Theme.of(context).textTheme.bodySmall, - ), - ], + return PlatformListTile( + title: PlatformTextField( + onChanged: (value) { + setState(() { + _configName = value; + }); + }, + onSubmitted: (value) async { + setState(() { + _configName = value.trim(); + }); + }, + onTapOutside: (event) => FocusScope.of(context).unfocus(), + hintText: "Save as...", ), - ); - } - - Future _saveConfiguration() async { - final String profileName = _configName.trim(); - if (profileName.isEmpty) { - await _showInfoDialog( - title: 'Profile name required', - message: 'Enter a profile name before saving.', - ); - return; - } - - setState(() { - _isSaving = true; - }); - - try { - final SensorConfigurationProvider provider = - Provider.of(context, listen: false); - final Map config = provider.toJson(); - final String storageKey = SensorConfigurationStorage.buildScopedKey( - scope: widget.storageScope, - name: profileName, - ); - - final existingKeys = - await SensorConfigurationStorage.listConfigurationKeys(); - if (existingKeys.contains(storageKey)) { - final shouldOverwrite = await _confirmOverwrite(profileName); - if (!shouldOverwrite) return; - } - - logger.d('Saving sensor profile "$profileName" to "$storageKey".'); - await SensorConfigurationStorage.saveConfiguration(storageKey, config); - - if (!mounted) return; - FocusScope.of(context).unfocus(); - _showToast('Saved profile "$profileName".'); - widget.onSaved?.call(); - } catch (e) { - logger.e('Failed to save sensor profile: $e'); - if (!mounted) return; - await _showInfoDialog( - title: 'Save failed', - message: 'Could not save this profile. Please try again.', - ); - } finally { - if (mounted) { - setState(() { - _isSaving = false; - }); - } - } - } - - Future _confirmOverwrite(String profileName) async { - final bool? confirmed = await showPlatformDialog( - context: context, - builder: (dialogContext) => PlatformAlertDialog( - title: const Text('Overwrite profile?'), - content: Text( - 'A profile named "$profileName" already exists for this device.', - ), - actions: [ - PlatformDialogAction( - child: const Text('Cancel'), - onPressed: () => Navigator.of(dialogContext).pop(false), - ), - PlatformDialogAction( - child: const Text('Overwrite'), - onPressed: () => Navigator.of(dialogContext).pop(true), - ), - ], - ), - ); - - return confirmed ?? false; - } - - Future _showInfoDialog({ - required String title, - required String message, - }) async { - await showPlatformDialog( - context: context, - builder: (dialogContext) => PlatformAlertDialog( - title: Text(title), - content: Text(message), - actions: [ - PlatformDialogAction( - child: const Text('OK'), - onPressed: () => Navigator.of(dialogContext).pop(), - ), - ], + trailing: PlatformElevatedButton( + onPressed: () async { + SensorConfigurationProvider provider = + Provider.of(context, listen: false); + Map config = provider.toJson(); + + logger.d("Saving configuration: $_configName with data: $config"); + + if (_configName.isNotEmpty) { + await SensorConfigurationStorage.saveConfiguration( + _configName.trim(), + config, + ); + } else { + showPlatformDialog( + context: context, + builder: (context) { + return PlatformAlertDialog( + title: PlatformText("Configuration Name Required"), + content: PlatformText( + "Please enter a name for the configuration.", + ), + actions: [ + PlatformDialogAction( + child: PlatformText("OK"), + onPressed: () => Navigator.of(context).pop(), + ), + ], + ); + }, + ); + } + }, + child: PlatformText("Save"), ), ); } - - void _showToast(String message) { - final messenger = ScaffoldMessenger.maybeOf(context); - if (messenger == null) return; - messenger.hideCurrentSnackBar(); - messenger.showSnackBar( - SnackBar(content: Text(message)), - ); - } } diff --git a/open_wearable/lib/widgets/sensors/configuration/sensor_configuration_device_row.dart b/open_wearable/lib/widgets/sensors/configuration/sensor_configuration_device_row.dart index 547896bf..78224085 100644 --- a/open_wearable/lib/widgets/sensors/configuration/sensor_configuration_device_row.dart +++ b/open_wearable/lib/widgets/sensors/configuration/sensor_configuration_device_row.dart @@ -1,4 +1,3 @@ -import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:flutter_platform_widgets/flutter_platform_widgets.dart'; import 'package:open_earable_flutter/open_earable_flutter.dart'; @@ -9,8 +8,9 @@ import 'package:open_wearable/widgets/sensors/configuration/sensor_configuration import 'package:provider/provider.dart'; import '../../../view_models/sensor_configuration_provider.dart'; +import '../../devices/device_detail/stereo_pos_label.dart'; -/// A widget that displays and manages sensor configuration for a single device. +/// A widget that displays a list of sensor configurations for a device. class SensorConfigurationDeviceRow extends StatefulWidget { final Wearable device; @@ -24,23 +24,24 @@ class SensorConfigurationDeviceRow extends StatefulWidget { class _SensorConfigurationDeviceRowState extends State with SingleTickerProviderStateMixin { - late final TabController _tabController; + late TabController _tabController; List _content = []; - String get _deviceProfileScope => 'device_${widget.device.deviceId}'; - @override void initState() { super.initState(); _tabController = TabController(length: 2, vsync: this); - _tabController.addListener(_onTabChanged); - _content = const [Center(child: CircularProgressIndicator())]; + _tabController.addListener(() { + if (!_tabController.indexIsChanging) { + _updateContent(); + } + }); + _content = [PlatformCircularProgressIndicator()]; _updateContent(); } @override void dispose() { - _tabController.removeListener(_onTabChanged); _tabController.dispose(); super.dispose(); } @@ -48,49 +49,27 @@ class _SensorConfigurationDeviceRowState @override Widget build(BuildContext context) { final device = widget.device; - final tabBar = _buildTabBar(context); return Card( child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - Padding( - padding: const EdgeInsets.fromLTRB(12, 8, 12, 4), - child: Row( + PlatformListTile( + title: Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - Expanded( - child: Row( - children: [ - Expanded( - child: PlatformText( - device.name, - style: - Theme.of(context).textTheme.bodyLarge?.copyWith( - fontWeight: FontWeight.bold, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ), - if (device.hasCapability()) - _CompactStereoBadge( - device: device.requireCapability(), - ), - ], - ), + PlatformText( + device.name, + style: Theme.of(context) + .textTheme + .bodyLarge + ?.copyWith(fontWeight: FontWeight.bold), ), - if (tabBar != null) ...[ - const SizedBox(width: 8), - ConstrainedBox( - constraints: const BoxConstraints( - minWidth: 170, - maxWidth: 240, - ), - child: tabBar, - ), - ], + if (device.hasCapability()) + StereoPosLabel(device: device.requireCapability()), ], ), + trailing: _buildTabBar(context), ), ..._content, ], @@ -98,53 +77,51 @@ class _SensorConfigurationDeviceRowState ); } - void _onTabChanged() { - if (!_tabController.indexIsChanging) { - _updateContent(); - } - } - Future _updateContent() async { - final device = widget.device; + final Wearable device = widget.device; if (!device.hasCapability()) { if (!mounted) return; setState(() { _content = [ - const Padding( - padding: EdgeInsets.all(12), - child: Text('This device does not support sensor configuration.'), + Padding( + padding: const EdgeInsets.all(8.0), + child: PlatformText("This device does not support configuring sensors."), ), ]; }); return; } + final SensorConfigurationManager sensorManager = + device.requireCapability(); + if (_tabController.index == 0) { - _buildSettingsTabContent(device); + _buildNewTabContent(device); } else { - await _buildProfilesTabContent(); + await _buildLoadTabContent(sensorManager); } } - void _buildSettingsTabContent(Wearable device) { - final sensorManager = + void _buildNewTabContent(Wearable device) { + SensorConfigurationManager sensorManager = device.requireCapability(); + final List content = sensorManager.sensorConfigurations + .map( + (config) => SensorConfigurationValueRow(sensorConfiguration: config), + ) + .cast() + .toList(); - final content = [ - ...sensorManager.sensorConfigurations.map( - (config) => SensorConfigurationValueRow( - sensorConfiguration: config, - ), - ), - ]; + content.addAll([ + const Divider(), + const SaveConfigRow(), + ]); if (device.hasCapability()) { content.addAll([ const Divider(), - EdgeRecorderPrefixRow( - manager: device.requireCapability(), - ), + EdgeRecorderPrefixRow(manager: device.requireCapability()), ]); } @@ -154,348 +131,85 @@ class _SensorConfigurationDeviceRowState }); } - Future _buildProfilesTabContent() async { + Future _buildLoadTabContent(SensorConfigurationManager device) async { if (!mounted) return; setState(() { - _content = const [Center(child: CircularProgressIndicator())]; + _content = [PlatformCircularProgressIndicator()]; }); - final allConfigKeys = - await SensorConfigurationStorage.listConfigurationKeys(); - final scopedKeys = allConfigKeys - .where( - (key) => SensorConfigurationStorage.keyMatchesScope( - key, - _deviceProfileScope, - ), - ) - .toList() - ..sort(); - final legacyKeys = allConfigKeys - .where(SensorConfigurationStorage.isLegacyUnscopedKey) - .toList() - ..sort(); - final profileKeys = [...scopedKeys, ...legacyKeys]; + final configKeys = await SensorConfigurationStorage.listConfigurationKeys(); if (!mounted) return; - final content = [ - SaveConfigRow( - storageScope: _deviceProfileScope, - onSaved: _refreshProfiles, - ), - const Divider(), - ]; + if (configKeys.isEmpty) { + setState(() { + _content = [ + PlatformListTile(title: PlatformText("No configurations found")), + ]; + }); + return; + } - if (profileKeys.isEmpty) { - content.add( - const Padding( - padding: EdgeInsets.all(12), - child: Text( - 'No profiles saved yet. Save current settings above, then tap a profile to apply it.', - ), + final widgets = configKeys.map((key) { + return PlatformListTile( + title: PlatformText(key), + onTap: () async { + final config = + await SensorConfigurationStorage.loadConfiguration(key); + if (!mounted) return; + + final result = await Provider.of( + context, + listen: false, + ).restoreFromJson(config); + + if (!result && mounted) { + showPlatformDialog( + context: context, + builder: (_) => PlatformAlertDialog( + title: PlatformText("Error"), + content: PlatformText("Failed to load configuration: $key"), + actions: [ + PlatformDialogAction( + child: PlatformText("OK"), + onPressed: () => Navigator.of(context).pop(), + ), + ], + ), + ); + return; + } + + _tabController.index = 0; + _updateContent(); + }, + trailing: PlatformIconButton( + icon: Icon(context.platformIcons.delete), + onPressed: () async { + await SensorConfigurationStorage.deleteConfiguration(key); + if (mounted) _updateContent(); + }, ), ); - } else { - content.addAll(profileKeys.map(_buildProfileTile)); - } + }).toList(); setState(() { - _content = content; + _content = widgets; }); } - Widget _buildProfileTile(String key) { - final isDeviceScoped = SensorConfigurationStorage.keyMatchesScope( - key, - _deviceProfileScope, - ); - - final title = isDeviceScoped - ? SensorConfigurationStorage.displayNameFromScopedKey( - key, - scope: _deviceProfileScope, - ) - : key; - - return PlatformListTile( - leading: Icon( - isDeviceScoped ? Icons.devices_outlined : Icons.folder_outlined, - ), - title: PlatformText(title), - subtitle: PlatformText( - isDeviceScoped ? 'Tap to load into settings' : 'Legacy shared profile', - ), - onTap: () => _loadProfile(key: key, title: title), - trailing: PlatformIconButton( - icon: const Icon(Icons.more_horiz), - onPressed: () => _showProfileActions( - key: key, - title: title, - ), - ), - ); - } - Widget? _buildTabBar(BuildContext context) { if (!widget.device.hasCapability()) return null; - return TabBar.secondary( - controller: _tabController, - isScrollable: false, - labelPadding: const EdgeInsets.symmetric(horizontal: 8), - tabs: const [ - Tab(text: 'Current'), - Tab(text: 'Profiles'), - ], - ); - } - - Future _loadProfile({ - required String key, - required String title, - }) async { - final config = await SensorConfigurationStorage.loadConfiguration(key); - if (!mounted) return; - - final provider = context.read(); - final result = await provider.restoreFromJson(config); - if (!mounted) return; - - if (!result.hasRestoredValues) { - await showPlatformDialog( - context: context, - builder: (dialogContext) => PlatformAlertDialog( - title: const Text('Profile error'), - content: Text( - 'No compatible values from "$title" could be restored for this device.', - ), - actions: [ - PlatformDialogAction( - onPressed: () => Navigator.of(dialogContext).pop(), - child: const Text('OK'), - ), - ], - ), - ); - return; - } - - if (result.skippedCount > 0 || result.unknownConfigCount > 0) { - _showSnackBar( - 'Loaded "$title" (${result.restoredCount} restored, ${result.skippedCount + result.unknownConfigCount} skipped). Tap "Apply Configurations" to push.', - ); - } else { - _showSnackBar( - 'Loaded profile "$title". Tap "Apply Configurations" at the bottom to push to hardware.', - ); - } - - _tabController.index = 0; - _updateContent(); - } - - Future _overwriteProfile({ - required String key, - required String title, - }) async { - final confirmed = await _confirmOverwrite(title); - if (!confirmed) return; - if (!mounted) return; - - final provider = context.read(); - await SensorConfigurationStorage.saveConfiguration(key, provider.toJson()); - if (!mounted) return; - _showSnackBar('Updated profile "$title" with current settings.'); - _updateContent(); - } - - Future _deleteProfile({ - required String key, - required String title, - }) async { - final confirmed = await _confirmDelete(title); - if (!confirmed) return; - - await SensorConfigurationStorage.deleteConfiguration(key); - if (!mounted) return; - _showSnackBar('Deleted profile "$title".'); - _updateContent(); - } - - void _showProfileActions({ - required String key, - required String title, - }) { - showPlatformModalSheet( - context: context, - builder: (sheetContext) => PlatformWidget( - material: (_, __) => SafeArea( - child: Wrap( - children: [ - ListTile( - leading: const Icon(Icons.download), - title: const Text('Load'), - onTap: () async { - Navigator.of(sheetContext).pop(); - await _loadProfile(key: key, title: title); - }, - ), - ListTile( - leading: const Icon(Icons.save), - title: const Text('Overwrite with current settings'), - onTap: () async { - Navigator.of(sheetContext).pop(); - await _overwriteProfile(key: key, title: title); - }, - ), - ListTile( - leading: const Icon(Icons.delete), - title: const Text('Delete'), - onTap: () async { - Navigator.of(sheetContext).pop(); - await _deleteProfile(key: key, title: title); - }, - ), - ], - ), - ), - cupertino: (_, __) => CupertinoActionSheet( - title: Text(title), - actions: [ - CupertinoActionSheetAction( - onPressed: () async { - Navigator.of(sheetContext).pop(); - await _loadProfile(key: key, title: title); - }, - child: const Text('Load'), - ), - CupertinoActionSheetAction( - onPressed: () async { - Navigator.of(sheetContext).pop(); - await _overwriteProfile(key: key, title: title); - }, - child: const Text('Overwrite with current settings'), - ), - CupertinoActionSheetAction( - isDestructiveAction: true, - onPressed: () async { - Navigator.of(sheetContext).pop(); - await _deleteProfile(key: key, title: title); - }, - child: const Text('Delete'), - ), - ], - cancelButton: CupertinoActionSheetAction( - onPressed: () => Navigator.of(sheetContext).pop(), - child: const Text('Cancel'), - ), - ), - ), - ); - } - - Future _confirmDelete(String title) async { - final bool? confirmed = await showPlatformDialog( - context: context, - builder: (dialogContext) => PlatformAlertDialog( - title: const Text('Delete profile?'), - content: Text('Delete "$title" permanently?'), - actions: [ - PlatformDialogAction( - child: const Text('Cancel'), - onPressed: () => Navigator.of(dialogContext).pop(false), - ), - PlatformDialogAction( - child: const Text('Delete'), - onPressed: () => Navigator.of(dialogContext).pop(true), - ), - ], - ), - ); - return confirmed ?? false; - } - - Future _confirmOverwrite(String title) async { - final bool? confirmed = await showPlatformDialog( - context: context, - builder: (dialogContext) => PlatformAlertDialog( - title: const Text('Overwrite profile?'), - content: Text( - 'Replace profile "$title" with current settings from this device?', - ), - actions: [ - PlatformDialogAction( - child: const Text('Cancel'), - onPressed: () => Navigator.of(dialogContext).pop(false), - ), - PlatformDialogAction( - child: const Text('Overwrite'), - onPressed: () => Navigator.of(dialogContext).pop(true), - ), + return SizedBox( + width: MediaQuery.of(context).size.width * 0.4, + child: TabBar.secondary( + controller: _tabController, + tabs: const [ + Tab(text: 'New'), + Tab(text: 'Load'), ], ), ); - return confirmed ?? false; - } - - void _refreshProfiles() { - _updateContent(); - } - - void _showSnackBar(String message) { - final messenger = ScaffoldMessenger.maybeOf(context); - if (messenger == null) return; - messenger.hideCurrentSnackBar(); - messenger.showSnackBar( - SnackBar(content: Text(message)), - ); - } -} - -class _CompactStereoBadge extends StatelessWidget { - final StereoDevice device; - - const _CompactStereoBadge({required this.device}); - - @override - Widget build(BuildContext context) { - return FutureBuilder( - future: device.position, - builder: (context, snapshot) { - String? label; - if (snapshot.hasData) { - switch (snapshot.data) { - case DevicePosition.left: - label = 'L'; - break; - case DevicePosition.right: - label = 'R'; - break; - default: - label = null; - } - } - - if (label == null) { - return const SizedBox.shrink(); - } - - return Padding( - padding: const EdgeInsets.only(left: 8), - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surfaceContainerHighest, - borderRadius: BorderRadius.circular(999), - ), - child: Text( - label, - style: Theme.of(context).textTheme.labelMedium, - ), - ), - ); - }, - ); } } diff --git a/open_wearable/lib/widgets/sensors/configuration/sensor_configuration_view.dart b/open_wearable/lib/widgets/sensors/configuration/sensor_configuration_view.dart index 99ec9374..2dedc189 100644 --- a/open_wearable/lib/widgets/sensors/configuration/sensor_configuration_view.dart +++ b/open_wearable/lib/widgets/sensors/configuration/sensor_configuration_view.dart @@ -10,7 +10,7 @@ import 'package:provider/provider.dart'; import '../../../models/logger.dart'; /// A view that displays the sensor configurations of all connected wearables. -/// +/// /// The specific sensor configurations should be made available via the [SensorConfigurationProvider]. class SensorConfigurationView extends StatelessWidget { final VoidCallback? onSetConfigPressed; @@ -26,258 +26,140 @@ class SensorConfigurationView extends StatelessWidget { ); } - Widget _buildSmallScreenLayout( - BuildContext context, - WearablesProvider wearablesProvider, - ) { + Widget _buildSmallScreenLayout(BuildContext context, WearablesProvider wearablesProvider) { if (wearablesProvider.wearables.isEmpty) { return Center( - child: PlatformText( - "No devices connected", - style: Theme.of(context).textTheme.titleLarge, - ), + child: PlatformText("No devices connected", style: Theme.of(context).textTheme.titleLarge), ); } - return ListView( - padding: const EdgeInsets.all(10), - children: [ - ...wearablesProvider.wearables.map((wearable) { - if (wearable.hasCapability()) { - return ChangeNotifierProvider.value( - value: wearablesProvider.getSensorConfigurationProvider(wearable), - child: SensorConfigurationDeviceRow(device: wearable), - ); - } else { - return SensorConfigurationDeviceRow(device: wearable); - } - }), - _buildApplyConfigButton( - context, - configProviders: wearablesProvider.wearables - // ignore: prefer_iterable_wheretype - .where( - (wearable) => - wearable.hasCapability(), - ) - .map( - (wearable) => - wearablesProvider.getSensorConfigurationProvider(wearable), - ) - .toList(), + return Padding( + padding: EdgeInsets.all(10), + child: wearablesProvider.wearables.isEmpty + ? Center( + child: PlatformText("No devices connected", style: Theme.of(context).textTheme.titleLarge), + ) + : ListView( + children: [ + ...wearablesProvider.wearables.map((wearable) { + if (wearable.hasCapability()) { + return ChangeNotifierProvider.value( + value: wearablesProvider.getSensorConfigurationProvider(wearable), + child: SensorConfigurationDeviceRow(device: wearable), + ); + } else { + return SensorConfigurationDeviceRow(device: wearable); + } + }), + _buildThroughputWarningBanner(context), + _buildSetConfigButton( + configProviders: wearablesProvider.wearables + // ignore: prefer_iterable_wheretype + .where((wearable) => wearable.hasCapability()) + .map( + (wearable) => wearablesProvider.getSensorConfigurationProvider(wearable), + ).toList(), + ), + ], ), - _buildThroughputWarningBanner(context), - ], ); } - Widget _buildApplyConfigButton( - BuildContext context, { - required List configProviders, - }) { + Widget _buildSetConfigButton({required List configProviders}) { return PlatformElevatedButton( - onPressed: () async { - if (configProviders.isEmpty) { - await showPlatformDialog( - context: context, - builder: (dialogContext) => PlatformAlertDialog( - title: PlatformText('No configurable devices'), - content: PlatformText( - 'Connect a wearable with configurable sensors to apply settings.', - ), - actions: [ - PlatformDialogAction( - child: PlatformText('OK'), - onPressed: () => Navigator.of(dialogContext).pop(), - ), - ], - ), - ); - return; - } - - int appliedCount = 0; + onPressed: () { for (SensorConfigurationProvider notifier in configProviders) { logger.d("Setting sensor configurations for notifier: $notifier"); notifier.getSelectedConfigurations().forEach((entry) { SensorConfiguration config = entry.$1; SensorConfigurationValue value = entry.$2; config.setConfiguration(value); - appliedCount += 1; }); } - - final messenger = ScaffoldMessenger.maybeOf(context); - messenger?.hideCurrentSnackBar(); - messenger?.showSnackBar( - SnackBar( - content: Text( - 'Applied $appliedCount sensor settings to ${configProviders.length} device(s).', - ), - ), - ); - (onSetConfigPressed ?? () {})(); }, - child: PlatformText('Apply Configurations'), + child: PlatformText('Set Sensor Configurations'), ); } Widget _buildThroughputWarningBanner(BuildContext context) { - final colorScheme = Theme.of(context).colorScheme; - return Card( - color: colorScheme.surfaceContainerHighest.withValues(alpha: 0.5), + color: Theme.of(context).colorScheme.surfaceContainer, child: Padding( - padding: const EdgeInsets.all(14), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Icon( - Icons.insights_outlined, - size: 20, - color: colorScheme.primary, - ), - const SizedBox(width: 8), - Expanded( - child: Text( - 'Sampling & bandwidth guidance', - style: Theme.of(context).textTheme.titleSmall?.copyWith( - fontWeight: FontWeight.w700, - ), - ), - ), - ], - ), - const SizedBox(height: 8), - Text( - 'High sensor counts and aggressive sampling rates can exceed bandwidth and cause dropped samples.', - style: Theme.of(context).textTheme.bodyMedium, - ), - const SizedBox(height: 10), - _buildGuidanceItem( - context, - 'Enable only the sensors needed for this session.', - ), - _buildGuidanceItem( - context, - 'Lower sampling rates for non-critical signals.', - ), - _buildGuidanceItem( - context, - 'For high-rate recordings, recording to the on-board memory of the device is preferred (if available).', - ), - ], - ), - ), - ); - } - - Widget _buildGuidanceItem(BuildContext context, String text) { - final colorScheme = Theme.of(context).colorScheme; - return Padding( - padding: const EdgeInsets.only(bottom: 6), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Padding( - padding: const EdgeInsets.only(top: 2), - child: Icon( - Icons.check_circle_outline, - size: 16, - color: colorScheme.primary.withValues(alpha: 0.9), - ), - ), - const SizedBox(width: 8), - Expanded( - child: Text( - text, - style: Theme.of(context).textTheme.bodySmall, - ), + padding: const EdgeInsets.all(16.0), + child: RichText( + text: TextSpan( + style: Theme.of(context).textTheme.bodyLarge + ?? TextStyle(color: Colors.black, fontSize: 16), + children: [ + const TextSpan( + text: "Info: ", + style: TextStyle(fontWeight: FontWeight.bold), + ), + const TextSpan( + text: "Using too many sensors or setting high sampling rates can exceed the system’s " + "available bandwidth, causing data drops. Limit the number of active sensors and their " + "sampling rates, and record high-rate data directly to the SD card.", + ), + ], ), - ], + ), ), ); } // ignore: unused_element - Widget _buildLargeScreenLayout( - BuildContext context, - WearablesProvider wearablesProvider, - ) { + Widget _buildLargeScreenLayout(BuildContext context, WearablesProvider wearablesProvider) { final List devices = wearablesProvider.wearables; - List tiles = - _generateTiles(devices, wearablesProvider.sensorConfigurationProviders); + List tiles = _generateTiles(devices, wearablesProvider.sensorConfigurationProviders); if (tiles.isNotEmpty) { - tiles.addAll( - [ - StaggeredGridTile.extent( - crossAxisCellCount: 1, - mainAxisExtent: 100.0, - child: _buildApplyConfigButton( - context, - configProviders: devices - .map( - (device) => wearablesProvider - .getSensorConfigurationProvider(device), - ) - .toList(), - ), - ), - StaggeredGridTile.extent( - crossAxisCellCount: 1, - mainAxisExtent: 230.0, - child: _buildThroughputWarningBanner(context), + tiles.addAll([ + StaggeredGridTile.extent( + crossAxisCellCount: 1, + mainAxisExtent: 230.0, + child: _buildThroughputWarningBanner(context), + ), + StaggeredGridTile.extent( + crossAxisCellCount: 1, + mainAxisExtent: 100.0, + child: _buildSetConfigButton( + configProviders: devices.map((device) => wearablesProvider.getSensorConfigurationProvider(device)).toList(), ), - ], + ),], ); } return StaggeredGrid.count( - crossAxisCount: (MediaQuery.of(context).size.width / 250) - .floor() - .clamp(1, 4), // Adaptive grid + crossAxisCount: (MediaQuery.of(context).size.width / 250).floor().clamp(1, 4), // Adaptive grid mainAxisSpacing: 10, crossAxisSpacing: 10, - children: tiles.isNotEmpty - ? tiles - : [ - StaggeredGridTile.extent( - crossAxisCellCount: 1, - mainAxisExtent: 100.0, - child: Card( - shape: RoundedRectangleBorder( - side: BorderSide( - color: Colors.grey, - width: 1, - style: BorderStyle.solid, - strokeAlign: -1, - ), - borderRadius: BorderRadius.circular(10), - ), - child: Center( - child: PlatformText( - "No devices connected", - style: Theme.of(context).textTheme.titleLarge, - ), - ), - ), + children: tiles.isNotEmpty ? tiles : [ + StaggeredGridTile.extent( + crossAxisCellCount: 1, + mainAxisExtent: 100.0, + child: Card( + shape: RoundedRectangleBorder( + side: BorderSide( + color: Colors.grey, + width: 1, + style: BorderStyle.solid, + strokeAlign: -1, ), - ], + borderRadius: BorderRadius.circular(10), + ), + child: Center( + child: PlatformText("No devices connected", style: Theme.of(context).textTheme.titleLarge), + ), + ), + ), + ], ); } /// Generates a dynamic quilted grid layout based on the device properties - List _generateTiles( - List devices, - Map notifiers, - ) { + List _generateTiles(List devices, Map notifiers) { // Sort devices by size dynamically for a balanced layout - devices.sort( - (a, b) => _getGridSpanForDevice(b) - _getGridSpanForDevice(a), - ); + devices.sort((a, b) => _getGridSpanForDevice(b) - _getGridSpanForDevice(a)); return devices.map((device) { int span = _getGridSpanForDevice(device); @@ -299,10 +181,7 @@ class SensorConfigurationView extends StatelessWidget { return 1; // Default size } - int sensorConfigCount = device - .requireCapability() - .sensorConfigurations - .length; + int sensorConfigCount = device.requireCapability().sensorConfigurations.length; return sensorConfigCount.clamp(1, 4); } diff --git a/open_wearable/lib/widgets/sensors/sensor_page.dart b/open_wearable/lib/widgets/sensors/sensor_page.dart index 24448cde..76eb1d07 100644 --- a/open_wearable/lib/widgets/sensors/sensor_page.dart +++ b/open_wearable/lib/widgets/sensors/sensor_page.dart @@ -8,6 +8,7 @@ import 'package:provider/provider.dart'; import '../../view_models/sensor_recorder_provider.dart'; + class SensorPage extends StatelessWidget { const SensorPage({super.key}); @@ -35,8 +36,8 @@ class SensorPage extends StatelessWidget { forceElevated: innerBoxIsScrolled, bottom: TabBar( tabs: [ - const Tab(text: 'Configure'), - const Tab(text: 'Live Data'), + const Tab(text: 'Configuration'), + const Tab(text: 'Charts'), Tab( child: Row( children: [ @@ -59,7 +60,9 @@ class SensorPage extends StatelessWidget { }, ), ), + SensorValuesPage(), + LocalRecorderView(), ], ), @@ -77,9 +80,7 @@ class _RecordingIndicator extends StatelessWidget { return Consumer( builder: (context, recorderProvider, child) { return Icon( - recorderProvider.isRecording - ? Icons.fiber_manual_record - : Icons.fiber_manual_record_outlined, + recorderProvider.isRecording ? Icons.fiber_manual_record : Icons.fiber_manual_record_outlined, color: recorderProvider.isRecording ? Colors.red : Colors.grey, ); }, diff --git a/open_wearable/lib/widgets/sensors/values/sensor_chart.dart b/open_wearable/lib/widgets/sensors/values/sensor_chart.dart index 56764ed9..6beda2b1 100644 --- a/open_wearable/lib/widgets/sensors/values/sensor_chart.dart +++ b/open_wearable/lib/widgets/sensors/values/sensor_chart.dart @@ -7,6 +7,8 @@ import 'package:open_earable_flutter/open_earable_flutter.dart' hide logger; import 'package:open_wearable/view_models/sensor_data_provider.dart'; import 'package:provider/provider.dart'; +import '../../../models/logger.dart'; + class SensorChart extends StatefulWidget { final bool allowToggleAxes; @@ -20,28 +22,17 @@ class SensorChart extends StatefulWidget { } class _SensorChartState extends State { - static const List _fallbackColors = [ - Color(0xFF4A90E2), - Color(0xFFE76F51), - Color(0xFF2A9D8F), - Color(0xFFB565D9), - Color(0xFFF4A261), - Color(0xFF3D5A80), - Color(0xFFD62828), - ]; - late Map _axisEnabled; - int? _xOriginTimestamp; - int? _originSensorHash; @override void initState() { super.initState(); final sensor = context.read().sensor; - _axisEnabled = {for (var axis in sensor.axisNames) axis: true}; + _axisEnabled = { for (var axis in sensor.axisNames) axis: true }; } void _toggleAxis(String axisName, bool value) { + logger.d('Toggling axis $axisName to $value'); setState(() { _axisEnabled[axisName] = value; }); @@ -49,383 +40,122 @@ class _SensorChartState extends State { @override Widget build(BuildContext context) { - final dataProvider = context.watch(); - final sensor = dataProvider.sensor; - final sensorValues = dataProvider.sensorValues; - final theme = Theme.of(context); - final colorScheme = theme.colorScheme; - final compactMode = !widget.allowToggleAxes; - - final currentSensorHash = identityHashCode(sensor); - if (_originSensorHash != currentSensorHash) { - _originSensorHash = currentSensorHash; - _xOriginTimestamp = null; - } - - final axisData = _buildAxisData(sensor, sensorValues); - final enabledSeries = <_AxisSeries>[ - for (int i = 0; i < sensor.axisNames.length; i++) - if (_axisEnabled[sensor.axisNames[i]] ?? false) - _AxisSeries( - spots: axisData[sensor.axisNames[i]] ?? const [], - color: _axisColor( - axisIndex: i, - axisName: sensor.axisNames[i], - colorScheme: colorScheme, - ), + Sensor sensor = context.watch().sensor; + final enabledAxes = sensor.axisNames + .where((axis) => _axisEnabled[axis] ?? false) + .toList(); + final axisData = _buildAxisData( + sensor, + context.watch().sensorValues, + ); + + return Column( + children: [ + if (widget.allowToggleAxes) + Wrap( + spacing: 8, + children: sensor.axisNames.map((axisName) { + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + Checkbox( + value: _axisEnabled[axisName], + checkColor: Colors.white, + activeColor: _axisColor(axisName), + onChanged: (value) => + _toggleAxis(axisName, value ?? false), + ), + PlatformText(axisName), + ], + ); + }).toList(), ), - ]; - - final windowSeconds = dataProvider.timeWindow.toDouble(); - final maxX = _calculateMaxX(sensor, sensorValues, fallback: windowSeconds); - final minX = max(0.0, maxX - windowSeconds); - - final axisChipTextStyle = theme.textTheme.labelMedium; - const disabledChipLabelColor = Color(0xFF8A8A8A); - const disabledChipBackgroundColor = Color(0xFFECECEC); - const disabledChipBorderColor = Color(0xFFD7D7D7); - const disabledChipDotColor = Color(0xFFB3B3B3); - - final leftUnit = sensor.axisUnits.isNotEmpty ? sensor.axisUnits.first : ''; - - final chartData = LineChartData( - minX: minX, - maxX: maxX, - lineTouchData: LineTouchData( - enabled: !compactMode, - handleBuiltInTouches: !compactMode, - ), - gridData: FlGridData( - show: true, - drawVerticalLine: true, - getDrawingHorizontalLine: (_) => FlLine( - color: colorScheme.outline.withValues(alpha: 0.2), - strokeWidth: 1, - ), - getDrawingVerticalLine: (_) => FlLine( - color: colorScheme.outline.withValues(alpha: 0.2), - strokeWidth: 1, - ), - ), - titlesData: FlTitlesData( - leftTitles: AxisTitles( - axisNameWidget: leftUnit.isEmpty - ? null - : PlatformText( - leftUnit, - style: theme.textTheme.labelSmall?.copyWith( - color: colorScheme.onSurfaceVariant, + Expanded( + child: LineChart( + LineChartData( + lineTouchData: LineTouchData(enabled: true), + gridData: FlGridData(show: true), + titlesData: FlTitlesData( + leftTitles: AxisTitles( + axisNameWidget: PlatformText(sensor.axisUnits.first), + sideTitles: SideTitles( + showTitles: true, + reservedSize: 45, ), ), - axisNameSize: leftUnit.isEmpty ? 0 : (compactMode ? 16 : 22), - sideTitles: SideTitles( - showTitles: true, - reservedSize: compactMode ? 34 : 46, - minIncluded: false, - maxIncluded: false, - getTitlesWidget: (value, meta) => SideTitleWidget( - meta: meta, - space: 6, - child: SizedBox( - width: compactMode ? 30 : 40, - child: Text( - _formatYAxisTick(value), - maxLines: 1, - softWrap: false, - overflow: TextOverflow.fade, - textAlign: TextAlign.right, - style: theme.textTheme.labelSmall?.copyWith( - color: colorScheme.onSurfaceVariant, + rightTitles: AxisTitles( + sideTitles: SideTitles( + showTitles: false, ), ), - ), - ), - ), - ), - rightTitles: const AxisTitles( - sideTitles: SideTitles(showTitles: false), - ), - topTitles: const AxisTitles( - sideTitles: SideTitles(showTitles: false), - ), - bottomTitles: AxisTitles( - axisNameSize: 0, - sideTitles: SideTitles( - showTitles: true, - reservedSize: compactMode ? 20 : 24, - interval: compactMode ? 2 : 1, - minIncluded: false, - maxIncluded: false, - getTitlesWidget: (value, meta) => SideTitleWidget( - meta: meta, - child: Text( - _formatXAxisTick(value), - style: theme.textTheme.labelSmall?.copyWith( - color: colorScheme.onSurfaceVariant, + topTitles: AxisTitles( + sideTitles: SideTitles( + showTitles: false, + ), ), - ), - ), - ), - ), - ), - borderData: FlBorderData( - show: true, - border: Border( - left: BorderSide( - color: colorScheme.outline.withValues(alpha: 0.28), - ), - bottom: BorderSide( - color: colorScheme.outline.withValues(alpha: 0.28), - ), - ), - ), - lineBarsData: enabledSeries - .map( - (series) => LineChartBarData( - spots: series.spots, - isCurved: false, - barWidth: 2.2, - color: series.color, - isStrokeCapRound: true, - dotData: const FlDotData(show: false), - belowBarData: BarAreaData(show: false), - ), - ) - .toList(growable: false), - ); - - final enabledAxes = - sensor.axisNames.where((axis) => _axisEnabled[axis] ?? false).toList(); - - return Column( - children: [ - Expanded( - child: Padding( - padding: EdgeInsets.fromLTRB( - compactMode ? 2 : 6, - compactMode ? 2 : 4, - 2, - 0, - ), - child: LineChart( - chartData, - duration: const Duration(milliseconds: 0), - ), - ), - ), - Padding( - padding: const EdgeInsets.only(top: 6), - child: LayoutBuilder( - builder: (context, constraints) => SingleChildScrollView( - scrollDirection: Axis.horizontal, - child: ConstrainedBox( - constraints: BoxConstraints(minWidth: constraints.maxWidth), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text( - 'Time (s)', - style: theme.textTheme.labelSmall?.copyWith( - color: colorScheme.onSurfaceVariant, - fontWeight: FontWeight.w700, - ), - ), - const SizedBox(width: 8), - ...sensor.axisNames.asMap().entries.map((entry) { - final axisIndex = entry.key; - final axisName = entry.value; - final axisColor = _axisColor( - axisIndex: axisIndex, - axisName: axisName, - colorScheme: colorScheme, - ); - final selected = _axisEnabled[axisName] ?? false; - final chipLabelColor = selected - ? axisColor.withValues(alpha: 0.95) - : disabledChipLabelColor; - final chipBackgroundColor = selected - ? axisColor.withValues(alpha: 0.18) - : disabledChipBackgroundColor; - final chipBorderColor = selected - ? axisColor.withValues(alpha: 0.28) - : disabledChipBorderColor; - final chipDotColor = axisColor; - final disabledDotColor = disabledChipDotColor; - - return Padding( - padding: const EdgeInsets.only(left: 6), - child: FilterChip( - label: Text( - axisName, - style: axisChipTextStyle?.copyWith( - color: chipLabelColor, - fontWeight: FontWeight.w700, - fontSize: compactMode ? 10.5 : 11.5, - ), - ), - avatar: Container( - width: 7, - height: 7, - decoration: BoxDecoration( - color: selected ? chipDotColor : disabledDotColor, - shape: BoxShape.circle, - ), - ), - selected: selected, - onSelected: (value) => _toggleAxis(axisName, value), - showCheckmark: false, - visualDensity: compactMode - ? const VisualDensity( - horizontal: -3, - vertical: -3, - ) - : const VisualDensity( - horizontal: -2, - vertical: -2, - ), - materialTapTargetSize: - MaterialTapTargetSize.shrinkWrap, - labelPadding: - const EdgeInsets.symmetric(horizontal: 4), - padding: const EdgeInsets.symmetric(horizontal: 4), - selectedColor: chipBackgroundColor, - backgroundColor: chipBackgroundColor, - side: BorderSide( - color: chipBorderColor, - ), - ), - ); - }), - ], + bottomTitles: AxisTitles( + axisNameWidget: PlatformText('Time (s)'), + axisNameSize: 30, + sideTitles: SideTitles( + showTitles: true, + reservedSize: 30, + ), ), ), + borderData: FlBorderData(show: false), + lineBarsData: enabledAxes.map((axisName) { + return LineChartBarData( + spots: axisData[axisName] ?? [], + isCurved: false, + barWidth: 2, + color: _axisColor(axisName), + isStrokeCapRound: true, + dotData: FlDotData(show: false), + ); + }).toList(), ), + duration: const Duration(milliseconds: 0), ), ), - if (enabledAxes.isEmpty) - Padding( - padding: const EdgeInsets.only(top: 8), - child: Text( - 'Enable at least one axis to display data.', - style: theme.textTheme.bodySmall?.copyWith( - color: colorScheme.onSurfaceVariant, - ), - ), - ), ], ); } - double _calculateMaxX( - Sensor sensor, - Queue buffer, { - required double fallback, - }) { - if (buffer.isEmpty) return fallback; - return _toElapsedSeconds(sensor, buffer.last.timestamp); - } + Map> _buildAxisData(Sensor sensor, Queue buffer) { + if (buffer.isEmpty) return { for (var axis in sensor.axisNames) axis: [] }; - double _toElapsedSeconds(Sensor sensor, int timestamp) { final scale = pow(10, -sensor.timestampExponent).toDouble(); - _xOriginTimestamp ??= timestamp; - if (timestamp < _xOriginTimestamp!) { - _xOriginTimestamp = timestamp; - } - - return (timestamp - _xOriginTimestamp!).toDouble() / scale; - } - - Map> _buildAxisData( - Sensor sensor, - Queue buffer, - ) { - final data = >{ - for (var axis in sensor.axisNames) axis: [], + return { + for (int i = 0; i < sensor.axisCount; i++) + sensor.axisNames[i]: buffer.map((v) { + final x = v.timestamp.toDouble() / scale; + final y = v is SensorDoubleValue + ? v.values[i] + : (v as SensorIntValue).values[i].toDouble(); + return FlSpot(x, y); + }).toList(), }; - if (buffer.isEmpty) return data; - - for (final sensorValue in buffer) { - final x = _toElapsedSeconds(sensor, sensorValue.timestamp); - if (sensorValue is SensorDoubleValue) { - for (int i = 0; i < sensor.axisCount; i++) { - data[sensor.axisNames[i]]!.add(FlSpot(x, sensorValue.values[i])); - } - } else { - final values = (sensorValue as SensorIntValue).values; - for (int i = 0; i < sensor.axisCount; i++) { - data[sensor.axisNames[i]]!.add(FlSpot(x, values[i].toDouble())); - } - } - } - - return data; - } - - String _formatXAxisTick(double value) { - final rounded = value.roundToDouble(); - if ((value - rounded).abs() < 0.05) { - return rounded.toInt().toString(); - } - return value.toStringAsFixed(1); - } - - String _formatYAxisTick(double value) { - final abs = value.abs(); - String output; - - if (abs >= 100000 || (abs > 0 && abs < 0.001)) { - output = value.toStringAsExponential(1); - } else if (abs >= 1000) { - output = value.toStringAsFixed(0); - } else if (abs >= 100) { - output = value.toStringAsFixed(1); - } else if (abs >= 1) { - output = value.toStringAsFixed(2); - } else { - output = value.toStringAsFixed(3); - } - - return _trimTrailingZeros(output); - } - - String _trimTrailingZeros(String value) { - if (value.contains('e') || value.contains('E')) return value; - var result = value; - if (result.contains('.')) { - result = result.replaceFirst(RegExp(r'0+$'), ''); - result = result.replaceFirst(RegExp(r'\.$'), ''); - } - return result; } - Color _axisColor({ - required int axisIndex, - required String axisName, - required ColorScheme colorScheme, - }) { + Color _axisColor(String axisName) { final name = axisName.toLowerCase(); - if (name == 'x') return const Color(0xFF4A90E2); - if (name == 'y') return const Color(0xFFE76F51); - if (name == 'z') return const Color(0xFF2A9D8F); if (name == 'r' || name == 'red') return Colors.red; if (name == 'g' || name == 'green') return Colors.green; if (name == 'b' || name == 'blue') return Colors.blue; - if (name.contains('temp')) return const Color(0xFFFB8500); - if (name.contains('pressure')) return const Color(0xFF6C63FF); - if (axisIndex == 0) return colorScheme.primary; - return _fallbackColors[axisIndex % _fallbackColors.length]; + // Fallback for unrecognized names (e.g., axis4, temp, etc.) + final fallbackColors = [ + Colors.teal, + Colors.amber, + Colors.indigo, + Colors.lime, + Colors.brown, + Colors.deepOrange, + Colors.pink, + ]; + final index = context.read().sensor.axisNames.indexOf(axisName); + return fallbackColors[index % fallbackColors.length]; } } - -class _AxisSeries { - final List spots; - final Color color; - - const _AxisSeries({ - required this.spots, - required this.color, - }); -} diff --git a/open_wearable/lib/widgets/sensors/values/sensor_value_card.dart b/open_wearable/lib/widgets/sensors/values/sensor_value_card.dart index abda2664..85f8e111 100644 --- a/open_wearable/lib/widgets/sensors/values/sensor_value_card.dart +++ b/open_wearable/lib/widgets/sensors/values/sensor_value_card.dart @@ -10,25 +10,21 @@ class SensorValueCard extends StatelessWidget { final Sensor sensor; final Wearable wearable; - const SensorValueCard({ - super.key, - required this.sensor, - required this.wearable, - }); + const SensorValueCard({super.key, required this.sensor, required this.wearable}); @override Widget build(BuildContext context) { return GestureDetector( onTap: () { - final provider = context.read(); - Navigator.of(context).push( - MaterialPageRoute( - builder: (context) => ChangeNotifierProvider.value( - value: provider, - child: SensorValueDetail(sensor: sensor, wearable: wearable), + final provider = context.read(); + Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => ChangeNotifierProvider.value( + value: provider, + child: SensorValueDetail(sensor: sensor, wearable: wearable), + ), ), - ), - ); + ); }, child: Card( child: Padding( @@ -37,26 +33,16 @@ class SensorValueCard extends StatelessWidget { children: [ Row( children: [ - PlatformText( - sensor.sensorName, - style: Theme.of(context).textTheme.bodyLarge?.copyWith( - fontWeight: FontWeight.w700, - ), - ), + PlatformText(sensor.sensorName, style: Theme.of(context).textTheme.bodyLarge), Spacer(), - PlatformText( - wearable.name, - style: Theme.of(context).textTheme.bodyMedium, - ), + PlatformText(wearable.name, style: Theme.of(context).textTheme.bodyMedium), ], ), Padding( padding: const EdgeInsets.only(top: 10.0), - child: SizedBox( + child: SizedBox( height: 200, - child: SensorChart( - allowToggleAxes: false, - ), + child: SensorChart(allowToggleAxes: false,), ), ), ], diff --git a/open_wearable/lib/widgets/sensors/values/sensor_values_page.dart b/open_wearable/lib/widgets/sensors/values/sensor_values_page.dart index ba88080d..97b6101f 100644 --- a/open_wearable/lib/widgets/sensors/values/sensor_values_page.dart +++ b/open_wearable/lib/widgets/sensors/values/sensor_values_page.dart @@ -18,35 +18,24 @@ class SensorValuesPage extends StatelessWidget { List charts = []; for (var wearable in wearablesProvider.wearables) { if (wearable.hasCapability()) { - for (Sensor sensor - in wearable.requireCapability().sensors) { + for (Sensor sensor in wearable.requireCapability().sensors) { if (!_sensorDataProvider.containsKey((wearable, sensor))) { - _sensorDataProvider[(wearable, sensor)] = - SensorDataProvider(sensor: sensor); + _sensorDataProvider[(wearable, sensor)] = SensorDataProvider(sensor: sensor); } charts.add( ChangeNotifierProvider.value( value: _sensorDataProvider[(wearable, sensor)], - child: SensorValueCard( - sensor: sensor, - wearable: wearable, - ), + child: SensorValueCard(sensor: sensor, wearable: wearable,), ), ); } } } - _sensorDataProvider.removeWhere( - (key, _) => !wearablesProvider.wearables.any( - (device) => - device.hasCapability() && - device == key.$1 && - device - .requireCapability() - .sensors - .contains(key.$2), - ), + _sensorDataProvider.removeWhere((key, _) => + !wearablesProvider.wearables.any((device) => device.hasCapability() + && device == key.$1 + && device.requireCapability().sensors.contains(key.$2),), ); return LayoutBuilder( @@ -63,18 +52,15 @@ class SensorValuesPage extends StatelessWidget { } Widget _buildSmallScreenLayout(BuildContext context, List charts) { - if (charts.isEmpty) { - return Center( - child: PlatformText( - "No sensors connected", - style: Theme.of(context).textTheme.titleLarge, + return Padding( + padding: EdgeInsets.all(10), + child: charts.isEmpty + ? Center( + child: PlatformText("No sensors connected", style: Theme.of(context).textTheme.titleLarge), + ) + : ListView( + children: charts, ), - ); - } - - return ListView( - padding: const EdgeInsets.all(10), - children: charts, ); } @@ -102,10 +88,7 @@ class SensorValuesPage extends StatelessWidget { borderRadius: BorderRadius.circular(10), ), child: Center( - child: PlatformText( - "No sensors available", - style: Theme.of(context).textTheme.titleLarge, - ), + child: PlatformText("No sensors available", style: Theme.of(context).textTheme.titleLarge), ), ); } diff --git a/open_wearable/pubspec.lock b/open_wearable/pubspec.lock index 1010832c..034ce0e3 100644 --- a/open_wearable/pubspec.lock +++ b/open_wearable/pubspec.lock @@ -45,10 +45,10 @@ packages: dependency: transitive description: name: characters - sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 + sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b url: "https://pub.dev" source: hosted - version: "1.4.0" + version: "1.4.1" clock: dependency: transitive description: @@ -444,18 +444,18 @@ packages: dependency: transitive description: name: matcher - sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 + sha256: "12956d0ad8390bbcc63ca2e1469c0619946ccb52809807067a7020d57e647aa6" url: "https://pub.dev" source: hosted - version: "0.12.17" + version: "0.12.18" material_color_utilities: dependency: transitive description: name: material_color_utilities - sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec + sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" url: "https://pub.dev" source: hosted - version: "0.11.1" + version: "0.13.0" mcumgr_flutter: dependency: "direct main" description: @@ -507,10 +507,11 @@ packages: open_earable_flutter: dependency: "direct main" description: - path: "../../open_earable_flutter" - relative: true - source: path - version: "2.3.2" + name: open_earable_flutter + sha256: "23b784abdb9aa2a67afd6bcf22778cc9e3d124eba5a4d02f49443581fa3f8958" + url: "https://pub.dev" + source: hosted + version: "2.3.1" open_file: dependency: "direct main" description: @@ -864,10 +865,10 @@ packages: dependency: transitive description: name: test_api - sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55 + sha256: "19a78f63e83d3a61f00826d09bc2f60e191bf3504183c001262be6ac75589fb8" url: "https://pub.dev" source: hosted - version: "0.7.7" + version: "0.7.8" tuple: dependency: transitive description: diff --git a/open_wearable/pubspec.yaml b/open_wearable/pubspec.yaml index 9b6c8003..6b9c9fa2 100644 --- a/open_wearable/pubspec.yaml +++ b/open_wearable/pubspec.yaml @@ -35,8 +35,7 @@ dependencies: # Use with the CupertinoIcons class for iOS style icons. cupertino_icons: ^1.0.8 open_file: ^3.3.2 - open_earable_flutter: - path: ../../open_earable_flutter + open_earable_flutter: ^2.3.1 flutter_platform_widgets: ^9.0.0 provider: ^6.1.2 logger: ^2.5.0 diff --git a/open_wearable/test/widget_test.dart b/open_wearable/test/widget_test.dart index 9ba375dc..f012e6aa 100644 --- a/open_wearable/test/widget_test.dart +++ b/open_wearable/test/widget_test.dart @@ -1,34 +1,30 @@ +// This is a basic Flutter widget test. +// +// To perform an interaction with a widget in your test, use the WidgetTester +// utility in the flutter_test package. For example, you can send tap and scroll +// gestures. You can also use WidgetTester to find child widgets in the widget +// tree, read PlatformText, and verify that the values of widget properties are correct. + import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:open_earable_flutter/open_earable_flutter.dart'; -import 'package:open_wearable/view_models/sensor_recorder_provider.dart'; -import 'package:open_wearable/view_models/wearables_provider.dart'; -import 'package:open_wearable/widgets/home_page.dart'; -import 'package:provider/provider.dart'; + +import 'package:open_wearable/main.dart'; void main() { - testWidgets('Home shell shows top-level navigation', ( - WidgetTester tester, - ) async { - await tester.pumpWidget( - MultiProvider( - providers: [ - ChangeNotifierProvider(create: (_) => WearablesProvider()), - ChangeNotifierProvider(create: (_) => SensorRecorderProvider()), - ChangeNotifierProvider( - create: (_) => FirmwareUpdateRequestProvider(), - ), - ], - child: const MaterialApp( - home: HomePage(), - ), - ), - ); + testWidgets('Counter increments smoke test', (WidgetTester tester) async { + // Build our app and trigger a frame. + await tester.pumpWidget(const MyApp()); + + // Verify that our counter starts at 0. + expect(find.text('0'), findsOneWidget); + expect(find.text('1'), findsNothing); + + // Tap the '+' icon and trigger a frame. + await tester.tap(find.byIcon(Icons.add)); + await tester.pump(); - expect(find.text('Overview'), findsWidgets); - expect(find.text('Devices'), findsWidgets); - expect(find.text('Sensors'), findsWidgets); - expect(find.text('Apps'), findsWidgets); - expect(find.text('Utilities'), findsWidgets); + // Verify that our counter has incremented. + expect(find.text('0'), findsNothing); + expect(find.text('1'), findsOneWidget); }); }