From 1dd4e5e411a0033f262cf6754494a325cb873e33 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 11 Jan 2026 22:52:15 +0000 Subject: [PATCH 1/6] Initial plan From 6904f477487de683a2f9774240bea040d5428af0 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 12 Jan 2026 16:40:59 +0000 Subject: [PATCH 2/6] Add BLE log viewer screen with automatic log streaming Co-authored-by: doudar <17362216+doudar@users.noreply.github.com> --- lib/screens/ble_log_screen.dart | 264 ++++++++++++++++++++++++++++ lib/screens/main_device_screen.dart | 33 ++++ lib/utils/constants.dart | 12 ++ pubspec.yaml | 2 +- 4 files changed, 310 insertions(+), 1 deletion(-) create mode 100644 lib/screens/ble_log_screen.dart diff --git a/lib/screens/ble_log_screen.dart b/lib/screens/ble_log_screen.dart new file mode 100644 index 0000000..5143cdf --- /dev/null +++ b/lib/screens/ble_log_screen.dart @@ -0,0 +1,264 @@ +/* + * Copyright (C) 2020 Anthony Doud + * All rights reserved + * + * SPDX-License-Identifier: GPL-2.0-only + */ +import 'dart:async'; +import 'package:flutter/material.dart'; +import 'package:flutter_blue_plus/flutter_blue_plus.dart'; +import '../utils/bledata.dart'; +import '../utils/constants.dart'; +import '../widgets/ss2k_app_bar.dart'; + +class BleLogScreen extends StatefulWidget { + final BluetoothDevice device; + const BleLogScreen({Key? key, required this.device}) : super(key: key); + + @override + State createState() => _BleLogScreenState(); +} + +class _BleLogScreenState extends State { + late BLEData bleData; + late Map logCharacteristic; + final List _logMessages = []; + final ScrollController _scrollController = ScrollController(); + StreamSubscription? _connectionStateSubscription; + bool _refreshBlocker = false; + Timer? _demoTimer; + + @override + void initState() { + super.initState(); + bleData = BLEDataManager.forDevice(widget.device); + + // Find the log characteristic + logCharacteristic = bleData.customCharacteristic.firstWhere( + (i) => i["vName"] == BLE_logStreamVname, + orElse: () => {"vName": BLE_logStreamVname, "value": ""}, + ); + + // Automatically enable log streaming when entering the screen + _enableLogStreaming(); + + // Setup subscriptions + if (bleData.isSimulated) { + _setupDemoMode(); + } else { + _setupSubscriptions(); + } + } + + void _enableLogStreaming() { + if (bleData.isSimulated) { + // Demo mode - no need to write to device + return; + } + + // Write "1" to enable log streaming + logCharacteristic["value"] = "1"; + bleData.writeToSS2k(widget.device, logCharacteristic, s: "1"); + } + + void _disableLogStreaming() { + if (bleData.isSimulated) { + // Demo mode - no need to write to device + return; + } + + // Write "0" to disable log streaming + logCharacteristic["value"] = "0"; + bleData.writeToSS2k(widget.device, logCharacteristic, s: "0"); + } + + void _setupDemoMode() { + // Simulate some log messages in demo mode + _demoTimer = Timer.periodic(Duration(seconds: 2), (timer) { + if (!mounted) { + timer.cancel(); + return; + } + setState(() { + _logMessages.add('[${DateTime.now().toIso8601String()}] Demo log message ${_logMessages.length + 1}'); + _scrollToBottom(); + }); + }); + } + + void _setupSubscriptions() { + _connectionStateSubscription = widget.device.connectionState.listen((state) async { + if (mounted) { + setState(() {}); + } + }); + + WidgetsBinding.instance.addPostFrameCallback((timeStamp) { + bleData.isReadingOrWriting.addListener(_rwListener); + }); + } + + void _rwListener() async { + if (_refreshBlocker) { + return; + } + _refreshBlocker = true; + await Future.delayed(Duration(milliseconds: 500)); + + if (mounted) { + String newMessage = logCharacteristic["value"]?.toString() ?? ""; + if (newMessage.isNotEmpty && (_logMessages.isEmpty || _logMessages.last != newMessage)) { + setState(() { + _logMessages.add(newMessage); + _scrollToBottom(); + }); + } + } + _refreshBlocker = false; + } + + @override + void dispose() { + // Automatically disable streaming when leaving the screen + _disableLogStreaming(); + + _demoTimer?.cancel(); + _connectionStateSubscription?.cancel(); + bleData.isReadingOrWriting.removeListener(_rwListener); + _scrollController.dispose(); + super.dispose(); + } + + void _scrollToBottom() { + if (_scrollController.hasClients) { + Future.delayed(Duration(milliseconds: 100), () { + if (_scrollController.hasClients) { + _scrollController.animateTo( + _scrollController.position.maxScrollExtent, + duration: Duration(milliseconds: 300), + curve: Curves.easeOut, + ); + } + }); + } + } + + void _clearLogs() { + setState(() { + _logMessages.clear(); + }); + } + + void _readCurrentLog() { + if (bleData.isSimulated) { + setState(() { + _logMessages.add('[${DateTime.now().toIso8601String()}] Demo: Current log buffer content'); + _scrollToBottom(); + }); + return; + } + + // Request a read from the device + bleData.requestSettings(widget.device); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: SS2KAppBar( + device: widget.device, + title: "BLE Logs", + ), + body: Column( + children: [ + // Control panel + Card( + margin: EdgeInsets.all(8), + child: Padding( + padding: EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + children: [ + Icon(Icons.info_outline, color: Theme.of(context).colorScheme.primary), + SizedBox(width: 8), + Expanded( + child: Text( + 'Log streaming is active', + style: Theme.of(context).textTheme.bodyMedium, + ), + ), + ], + ), + SizedBox(height: 12), + Row( + children: [ + Expanded( + child: ElevatedButton.icon( + onPressed: _readCurrentLog, + icon: Icon(Icons.refresh), + label: Text('Read Current'), + ), + ), + SizedBox(width: 8), + Expanded( + child: ElevatedButton.icon( + onPressed: _logMessages.isNotEmpty ? _clearLogs : null, + icon: Icon(Icons.clear_all), + label: Text('Clear'), + ), + ), + ], + ), + if (!widget.device.isConnected && !bleData.isSimulated) + Padding( + padding: EdgeInsets.only(top: 8), + child: Text( + 'Device disconnected', + style: TextStyle(color: Colors.red), + textAlign: TextAlign.center, + ), + ), + ], + ), + ), + ), + // Log display + Expanded( + child: Card( + margin: EdgeInsets.all(8), + child: _logMessages.isEmpty + ? Center( + child: Text( + 'No logs to display.\nWaiting for logs...', + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.bodyLarge?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ) + : ListView.builder( + controller: _scrollController, + padding: EdgeInsets.all(8), + itemCount: _logMessages.length, + itemBuilder: (context, index) { + return Padding( + padding: EdgeInsets.symmetric(vertical: 2), + child: SelectableText( + _logMessages[index], + style: TextStyle( + fontFamily: 'monospace', + fontSize: 12, + ), + ), + ); + }, + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/screens/main_device_screen.dart b/lib/screens/main_device_screen.dart index a104af6..577f31d 100644 --- a/lib/screens/main_device_screen.dart +++ b/lib/screens/main_device_screen.dart @@ -14,6 +14,7 @@ import '../screens/settings_screen.dart'; import '../screens/shifter_screen.dart'; import '../screens/firmware_update_screen.dart'; import '../screens/workout_screen.dart'; +import '../screens/ble_log_screen.dart'; import '../utils/extra.dart'; @@ -126,6 +127,34 @@ class _MainDeviceScreenState extends State { ); } + Widget _buildCardWithIcon(IconData iconData, String title, VoidCallback onPressed) { + return Card( + elevation: 4, + margin: EdgeInsets.symmetric(vertical: 8), + child: Column( + children: [ + ListTile( + onTap: onPressed, + leading: SizedBox( + width: 56, + height: 56, + child: Icon( + iconData, + size: 40, + color: Theme.of(context).colorScheme.primary, + ), + ), + title: Text(title), + trailing: IconButton( + icon: Icon(Icons.arrow_forward), + onPressed: onPressed, + ), + ), + ], + ), + ); + } + @override Widget build(BuildContext context) { return Scaffold( @@ -157,6 +186,10 @@ class _MainDeviceScreenState extends State { Navigator.of(context) .push(MaterialPageRoute(builder: (context) => FirmwareUpdateScreen(device: this.widget.device))); }), + _buildCardWithIcon(Icons.article_outlined, "BLE Logs", () { + Navigator.of(context) + .push(MaterialPageRoute(builder: (context) => BleLogScreen(device: this.widget.device))); + }), ], ), ); diff --git a/lib/utils/constants.dart b/lib/utils/constants.dart index 8a776f0..0515207 100644 --- a/lib/utils/constants.dart +++ b/lib/utils/constants.dart @@ -75,6 +75,7 @@ final String BLE_hMinVname = "BLE_homingMin"; final String BLE_hMaxVname = "BLE_homingMax"; final String homingSensitivityVname = "BLE_homingSensitivity"; final String pTab4pwrVname = "BLE_pTab4pwr"; +final String BLE_logStreamVname = "BLE_BLELogging"; // Refactored customCharacteristicFramework to directly use Dart map final dynamic customCharacteristicFramework = [ @@ -607,5 +608,16 @@ final dynamic customCharacteristicFramework = [ "max": 1, "textDescription": "Enable to use the power table for power instead of a power meter", "defaultData": "false" + }, + { + "vName": BLE_logStreamVname, + "reference": "0x30", + "isSetting": false, + "type": "string", + "humanReadableName": "BLE Log Stream", + "min": 0, + "max": 2000, + "textDescription": "Read last BLE log message or enable/disable BLE log streaming", + "defaultData": "" } ]; diff --git a/pubspec.yaml b/pubspec.yaml index f860e8c..f45b835 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -7,7 +7,7 @@ publish_to: "none" environment: sdk: ">=3.10.0 <=4.0.0" # Updated for current Dart 3.10 - flutter: 3.38.1 # Match current Flutter stable + flutter: ">=3.20.0 <4.0.0" dependencies: flutter: From 2bea4871f7b9f271552f68f5e3918755fac420a9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 12 Jan 2026 17:15:35 +0000 Subject: [PATCH 3/6] Replace Read Current button with Save and Send buttons Co-authored-by: doudar <17362216+doudar@users.noreply.github.com> --- lib/screens/ble_log_screen.dart | 87 ++++++++++++++++++++++++++++----- 1 file changed, 76 insertions(+), 11 deletions(-) diff --git a/lib/screens/ble_log_screen.dart b/lib/screens/ble_log_screen.dart index 5143cdf..af70ddf 100644 --- a/lib/screens/ble_log_screen.dart +++ b/lib/screens/ble_log_screen.dart @@ -5,8 +5,11 @@ * SPDX-License-Identifier: GPL-2.0-only */ import 'dart:async'; +import 'dart:io'; import 'package:flutter/material.dart'; import 'package:flutter_blue_plus/flutter_blue_plus.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:share_plus/share_plus.dart'; import '../utils/bledata.dart'; import '../utils/constants.dart'; import '../widgets/ss2k_app_bar.dart'; @@ -149,17 +152,71 @@ class _BleLogScreenState extends State { }); } - void _readCurrentLog() { - if (bleData.isSimulated) { - setState(() { - _logMessages.add('[${DateTime.now().toIso8601String()}] Demo: Current log buffer content'); - _scrollToBottom(); - }); + Future _saveLogs() async { + if (_logMessages.isEmpty) { return; } - // Request a read from the device - bleData.requestSettings(widget.device); + try { + // Get the directory for saving files + final directory = await getApplicationDocumentsDirectory(); + final timestamp = DateTime.now().toIso8601String().replaceAll(':', '-'); + final filePath = '${directory.path}/ble_logs_$timestamp.txt'; + + // Create the file and write logs + final file = File(filePath); + await file.writeAsString(_logMessages.join('\n')); + + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Logs saved to $filePath'), + duration: Duration(seconds: 3), + ), + ); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Failed to save logs: $e'), + backgroundColor: Colors.red, + ), + ); + } + } + } + + Future _sendLogs() async { + if (_logMessages.isEmpty) { + return; + } + + try { + // Create a temporary file with logs + final directory = await getTemporaryDirectory(); + final timestamp = DateTime.now().toIso8601String().replaceAll(':', '-'); + final filePath = '${directory.path}/ble_logs_$timestamp.txt'; + + final file = File(filePath); + await file.writeAsString(_logMessages.join('\n')); + + // Share the file + await Share.shareXFiles( + [XFile(filePath)], + subject: 'SmartSpin2k BLE Logs', + text: 'BLE logs from SmartSpin2k device', + ); + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Failed to send logs: $e'), + backgroundColor: Colors.red, + ), + ); + } + } } @override @@ -196,9 +253,17 @@ class _BleLogScreenState extends State { children: [ Expanded( child: ElevatedButton.icon( - onPressed: _readCurrentLog, - icon: Icon(Icons.refresh), - label: Text('Read Current'), + onPressed: _logMessages.isNotEmpty ? _saveLogs : null, + icon: Icon(Icons.save), + label: Text('Save'), + ), + ), + SizedBox(width: 8), + Expanded( + child: ElevatedButton.icon( + onPressed: _logMessages.isNotEmpty ? _sendLogs : null, + icon: Icon(Icons.share), + label: Text('Send'), ), ), SizedBox(width: 8), From 6f276b4383f61624b9270873e4fcef4baf047d45 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 12 Jan 2026 17:58:00 +0000 Subject: [PATCH 4/6] Add expandable Maintenance section and rename BLE Logs to View Logs Co-authored-by: doudar <17362216+doudar@users.noreply.github.com> --- lib/screens/ble_log_screen.dart | 2 +- lib/screens/main_device_screen.dart | 87 ++++++++++++++++++++++++++--- 2 files changed, 80 insertions(+), 9 deletions(-) diff --git a/lib/screens/ble_log_screen.dart b/lib/screens/ble_log_screen.dart index af70ddf..308a9e1 100644 --- a/lib/screens/ble_log_screen.dart +++ b/lib/screens/ble_log_screen.dart @@ -224,7 +224,7 @@ class _BleLogScreenState extends State { return Scaffold( appBar: SS2KAppBar( device: widget.device, - title: "BLE Logs", + title: "View Logs", ), body: Column( children: [ diff --git a/lib/screens/main_device_screen.dart b/lib/screens/main_device_screen.dart index 577f31d..db711ec 100644 --- a/lib/screens/main_device_screen.dart +++ b/lib/screens/main_device_screen.dart @@ -30,6 +30,7 @@ class MainDeviceScreen extends StatefulWidget { class _MainDeviceScreenState extends State { late BLEData bleData; + bool _maintenanceExpanded = false; @override void initState() { @@ -155,6 +156,83 @@ class _MainDeviceScreenState extends State { ); } + Widget _buildExpandableMaintenanceCard() { + return Card( + elevation: 4, + margin: EdgeInsets.symmetric(vertical: 8), + child: Column( + children: [ + ListTile( + onTap: () { + setState(() { + _maintenanceExpanded = !_maintenanceExpanded; + }); + }, + leading: SizedBox( + width: 56, + height: 56, + child: Icon( + Icons.build_outlined, + size: 40, + color: Theme.of(context).colorScheme.primary, + ), + ), + title: Text("Maintenance"), + trailing: Icon( + _maintenanceExpanded ? Icons.expand_less : Icons.expand_more, + ), + ), + if (_maintenanceExpanded) ...[ + Divider(height: 1), + ListTile( + leading: SizedBox( + width: 56, + height: 56, + child: Image.asset( + 'assets/GitHub-logo.png', + fit: BoxFit.contain, + filterQuality: FilterQuality.high, + gaplessPlayback: true, + isAntiAlias: true, + ), + ), + title: Text("Update Firmware"), + trailing: Icon(Icons.arrow_forward), + onTap: () { + Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => FirmwareUpdateScreen(device: this.widget.device), + ), + ); + }, + ), + Divider(height: 1), + ListTile( + leading: SizedBox( + width: 56, + height: 56, + child: Icon( + Icons.article_outlined, + size: 40, + color: Theme.of(context).colorScheme.primary, + ), + ), + title: Text("View Logs"), + trailing: Icon(Icons.arrow_forward), + onTap: () { + Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => BleLogScreen(device: this.widget.device), + ), + ); + }, + ), + ], + ], + ), + ); + } + @override Widget build(BuildContext context) { return Scaffold( @@ -182,14 +260,7 @@ class _MainDeviceScreenState extends State { Navigator.of(context) .push(MaterialPageRoute(builder: (context) => WorkoutScreen(device: this.widget.device))); }), - _buildCard('assets/GitHub-logo.png', "Update Firmware", () { - Navigator.of(context) - .push(MaterialPageRoute(builder: (context) => FirmwareUpdateScreen(device: this.widget.device))); - }), - _buildCardWithIcon(Icons.article_outlined, "BLE Logs", () { - Navigator.of(context) - .push(MaterialPageRoute(builder: (context) => BleLogScreen(device: this.widget.device))); - }), + _buildExpandableMaintenanceCard(), ], ), ); From 579416cb974774c277146c644dd3eaed2d4a68aa Mon Sep 17 00:00:00 2001 From: Anthony Doud Date: Mon, 12 Jan 2026 18:32:52 -0600 Subject: [PATCH 5/6] macos build updates --- macos/Runner.xcodeproj/project.pbxproj | 6 +++--- .../Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme | 1 + macos/Runner/AppDelegate.swift | 4 ++++ 3 files changed, 8 insertions(+), 3 deletions(-) diff --git a/macos/Runner.xcodeproj/project.pbxproj b/macos/Runner.xcodeproj/project.pbxproj index 727c549..2086983 100644 --- a/macos/Runner.xcodeproj/project.pbxproj +++ b/macos/Runner.xcodeproj/project.pbxproj @@ -552,7 +552,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - MACOSX_DEPLOYMENT_TARGET = 10.14; + MACOSX_DEPLOYMENT_TARGET = 10.15; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = macosx; SWIFT_COMPILATION_MODE = wholemodule; @@ -632,7 +632,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - MACOSX_DEPLOYMENT_TARGET = 10.14; + MACOSX_DEPLOYMENT_TARGET = 10.15; MTL_ENABLE_DEBUG_INFO = YES; ONLY_ACTIVE_ARCH = YES; SDKROOT = macosx; @@ -679,7 +679,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - MACOSX_DEPLOYMENT_TARGET = 10.14; + MACOSX_DEPLOYMENT_TARGET = 10.15; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = macosx; SWIFT_COMPILATION_MODE = wholemodule; diff --git a/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme index 007ad56..564fe50 100644 --- a/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme +++ b/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -59,6 +59,7 @@ ignoresPersistentStateOnLaunch = "NO" debugDocumentVersioning = "YES" debugServiceExtension = "internal" + enableGPUValidationMode = "1" allowLocationSimulation = "YES"> diff --git a/macos/Runner/AppDelegate.swift b/macos/Runner/AppDelegate.swift index 8e02df2..b3c1761 100644 --- a/macos/Runner/AppDelegate.swift +++ b/macos/Runner/AppDelegate.swift @@ -6,4 +6,8 @@ class AppDelegate: FlutterAppDelegate { override func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { return true } + + override func applicationSupportsSecureRestorableState(_ app: NSApplication) -> Bool { + return true + } } From edb473c161606acf1da542fda745e81ad64fc7bc Mon Sep 17 00:00:00 2001 From: Anthony Doud Date: Mon, 12 Jan 2026 21:17:13 -0600 Subject: [PATCH 6/6] PowerTable on shifter screen --- .gitignore | 2 - lib/screens/ble_log_screen.dart | 3 + lib/screens/power_table_screen.dart | 200 ++++------------------ lib/screens/shifter_screen.dart | 249 ++++++++++++++++++++-------- lib/utils/constants.dart | 4 +- lib/utils/power_table_painter.dart | 165 +++++++++++++++++- lib/widgets/power_table_chart.dart | 240 +++++++++++++++++++++++++++ 7 files changed, 619 insertions(+), 244 deletions(-) create mode 100644 lib/widgets/power_table_chart.dart diff --git a/.gitignore b/.gitignore index 2581438..f34d2e0 100644 --- a/.gitignore +++ b/.gitignore @@ -32,7 +32,6 @@ /build/ # Web related -lib/generated_plugin_registrant.dart # Symbolication related app.*.symbols @@ -82,7 +81,6 @@ pubspec.lock .env.production # Generated code files -/lib/generated_plugin_registrant.dart # Environment configuration lib/config/env.local.dart diff --git a/lib/screens/ble_log_screen.dart b/lib/screens/ble_log_screen.dart index 308a9e1..cc3d530 100644 --- a/lib/screens/ble_log_screen.dart +++ b/lib/screens/ble_log_screen.dart @@ -110,6 +110,9 @@ class _BleLogScreenState extends State { if (mounted) { String newMessage = logCharacteristic["value"]?.toString() ?? ""; + if (newMessage == "1") { + newMessage = "Initializing Logging."; + } if (newMessage.isNotEmpty && (_logMessages.isEmpty || _logMessages.last != newMessage)) { setState(() { _logMessages.add(newMessage); diff --git a/lib/screens/power_table_screen.dart b/lib/screens/power_table_screen.dart index 1d39099..b80aa01 100644 --- a/lib/screens/power_table_screen.dart +++ b/lib/screens/power_table_screen.dart @@ -7,14 +7,13 @@ import 'dart:async'; import 'package:ss2kconfigapp/utils/constants.dart'; -import 'package:ss2kconfigapp/utils/extra.dart'; import 'package:flutter/material.dart'; import 'package:flutter_blue_plus/flutter_blue_plus.dart'; -import 'package:shared_preferences/shared_preferences.dart'; -import '../utils/power_table_painter.dart'; import '../utils/bledata.dart'; +import '../utils/extra.dart'; import '../widgets/metric_card.dart'; import '../widgets/ss2k_app_bar.dart'; +import '../widgets/power_table_chart.dart'; class PowerTableScreen extends StatefulWidget { final BluetoothDevice device; @@ -24,45 +23,17 @@ class PowerTableScreen extends StatefulWidget { State createState() => _PowerTableScreenState(); } -class _PowerTableScreenState extends State with SingleTickerProviderStateMixin { +class _PowerTableScreenState extends State { StreamSubscription? _connectionStateSubscription; late BLEData bleData; String statusString = ''; - late AnimationController _pulseController; - double maxResistance = 0; - double? homingMin; - double? homingMax; - // Removed unused chart key - bool _swapAxes = false; // false = Resistance(Y) vs Watts(X), true = Watts(Y) vs Resistance(X) - static const String _prefsSwapAxesKey = 'power_table_swap_axes'; - - // Trail tracking - final List> _positionHistory = []; - static const int maxTrailLength = 10; - DateTime _lastPositionUpdate = DateTime.now(); - Timer? _homingValuesTimer; - Timer? _targetTimer; + final GlobalKey _chartKey = GlobalKey(); + bool _refreshBlocker = false; @override void initState() { super.initState(); bleData = BLEDataManager.forDevice(this.widget.device); - requestAllCadenceLines(); - requestHomingValues(); - - // Set up timer to periodically check homing values - _homingValuesTimer = Timer.periodic(const Duration(seconds: 5), (_homingValuesTimer) { - if (mounted && this.widget.device.isConnected) { - requestHomingValues(); - } - }); - - // Initialize pulse animation - _pulseController = AnimationController( - duration: const Duration(seconds: 1), - vsync: this, - )..repeat(reverse: true); - // refresh the screen completely every VV seconds. Timer.periodic(const Duration(seconds: 15), (refreshTimer) { if (bleData.isUserDisconnect) { @@ -76,25 +47,12 @@ class _PowerTableScreenState extends State with SingleTickerPr print("failed to reconnect."); } } else { - if (mounted) { - requestAllCadenceLines(); - } else { + if (!mounted) { refreshTimer.cancel(); return; } } }); - - // Request target position every second - _targetTimer = Timer.periodic(const Duration(seconds: 1), (_targetTimer) { - if (this.bleData.isUserDisconnect) { - _targetTimer.cancel(); - } - if (mounted && this.widget.device.isConnected) { - bleData.requestSetting(this.widget.device, targetPositionVname); - } - }); - // If the data is simulated, wait for a second before calling setState if (bleData.isSimulated) { this.bleData.isReadingOrWriting.value = true; @@ -110,78 +68,28 @@ class _PowerTableScreenState extends State with SingleTickerPr }); } rwSubscription(); - _loadAxisPreference(); } @override void dispose() { _connectionStateSubscription?.cancel(); this.bleData.isReadingOrWriting.removeListener(_rwListner); - _pulseController.dispose(); - _homingValuesTimer?.cancel(); - _targetTimer?.cancel(); super.dispose(); } - Future _loadAxisPreference() async { - final prefs = await SharedPreferences.getInstance(); - final saved = prefs.getBool(_prefsSwapAxesKey) ?? false; - if (mounted) { - setState(() => _swapAxes = saved); - } - } - - Future _toggleAxisOrientation() async { - final prefs = await SharedPreferences.getInstance(); - setState(() => _swapAxes = !_swapAxes); - await prefs.setBool(_prefsSwapAxesKey, _swapAxes); - } - - void requestHomingValues() async { - if (mounted && this.widget.device.isConnected) { - await bleData.requestSetting(this.widget.device, BLE_hMinVname); - await bleData.requestSetting(this.widget.device, BLE_hMaxVname); - await bleData.requestSetting(this.widget.device, pTab4pwrVname); - // Parse values from BLE data - String test = bleData.getVnameValue(BLE_hMinVname, returnNoFirmSupport: true); - if (test == noFirmSupport) { - return; - } - double? value = double.tryParse(bleData.getVnameValue(BLE_hMinVname)); - double? value2 = double.tryParse(bleData.getVnameValue(BLE_hMaxVname)); - - bleData.tableDivisor = (bleData.getVnameValue(pTab4pwrVname, returnNoFirmSupport: true) == noFirmSupport) - ? bleData.tableOldDivisor - : bleData.tableNewDivisor; + bool get _swapAxes => _chartKey.currentState?.swapAxes ?? false; - setState(() { - homingMin = (value == INT32_MIN) ? null : value! / bleData.tableDivisor; - homingMax = (value2 == INT32_MIN) ? null : value2! / bleData.tableDivisor; - }); - } + void _toggleAxisOrientation() { + _chartKey.currentState?.toggleAxisOrientation(); + setState(() {}); // refresh the icon state } - bool _refreshBlocker = false; - - final List colors = [ - Colors.purple, - Colors.indigo, - Colors.blue, - Colors.cyan, - Colors.teal, - Colors.green, - Colors.lime, - Colors.orange, - Colors.red, - Colors.pink, - Colors.brown, - ]; - Future rwSubscription() async { _connectionStateSubscription = this.widget.device.connectionState.listen((state) async { if (state == BluetoothConnectionState.connected) { // Request power table data when connection is restored - requestAllCadenceLines(); + _chartKey.currentState?.requestAllCadenceLines(); + _chartKey.currentState?.requestHomingValues(); } if (mounted) { setState(() {}); @@ -204,76 +112,12 @@ class _PowerTableScreenState extends State with SingleTickerPr } if (mounted) { setState(() {}); - maxResistance = calculateMaxResistance(); } _refreshBlocker = false; } - void requestAllCadenceLines() async { - for (int i = 0; i < 10; i++) { - await bleData.requestSetting(this.widget.device, powerTableDataVname, extraByte: i); - } - } - - // Generate watts values up to 1000w in 30w increments - final List watts = List.generate((1000 ~/ 30) + 1, (index) => index * 30); - final List cadences = [60, 65, 70, 75, 80, 85, 90, 95, 100, 105]; - - // Calculate max resistance from plotted data - double calculateMaxResistance() { - double maxRes = 0; - for (var row in bleData.powerTableData) { - for (int i = 0; i < row.length; i++) { - if (row[i] != null && row[i]! > maxRes) { - maxRes = row[i]!.toDouble(); - } - } - } - return maxRes; - } - - void _updatePositionHistory(double x, double y) { - final now = DateTime.now(); - if (now.difference(_lastPositionUpdate).inMilliseconds >= 100) { - // Update every 100ms - _positionHistory.add({'x': x, 'y': y}); - if (_positionHistory.length > maxTrailLength) { - _positionHistory.removeAt(0); - } - _lastPositionUpdate = now; - } - } - - Widget _buildChart(BuildContext context, BoxConstraints constraints) { - if (bleData.ftmsData.watts > 0 && bleData.ftmsData.watts <= 1000 && maxResistance > 0) { - double targetPosition = double.tryParse(bleData.getVnameValue(targetPositionVname)) ?? 0; - _updatePositionHistory(bleData.ftmsData.watts.toDouble(), targetPosition); - } - - return CustomPaint( - size: Size(constraints.maxWidth, constraints.maxHeight), - painter: PowerTablePainter( - powerTableData: bleData.powerTableData.map((row) => row.map((value) => value?.toDouble()).toList()).toList(), - cadences: cadences, - colors: colors, - maxResistance: maxResistance, - homingMin: homingMin, - homingMax: homingMax, - currentWatts: bleData.ftmsData.watts.toDouble(), - currentResistance: double.tryParse(bleData.getVnameValue(targetPositionVname)) ?? 0, - currentCadence: bleData.ftmsData.cadence, - positionHistory: _positionHistory, - tableDivisor: bleData.tableDivisor, - swapAxes: _swapAxes, - ), - ); - } - @override Widget build(BuildContext context) { - // Update maxResistance whenever we rebuild - maxResistance = calculateMaxResistance(); - return Scaffold( appBar: SS2KAppBar( device: widget.device, @@ -332,8 +176,21 @@ class _PowerTableScreenState extends State with SingleTickerPr ), const SizedBox(height: 8), Expanded( - child: LayoutBuilder( - builder: _buildChart, + child: Container( + decoration: BoxDecoration( + color: Theme.of(context).cardColor, + borderRadius: BorderRadius.circular(16), + boxShadow: const [ + BoxShadow(color: Colors.black12, blurRadius: 8, offset: Offset(0, 4)), + ], + ), + padding: const EdgeInsets.all(16), + child: PowerTableChart( + key: _chartKey, + device: widget.device, + bleData: bleData, + pollTargetPosition: true, + ), ), ), SizedBox(height: 16), @@ -345,6 +202,9 @@ class _PowerTableScreenState extends State with SingleTickerPr } Widget _buildLegend() { + final cadences = PowerTableChart.cadenceTicks; + final colors = PowerTableChart.lineColors; + return Wrap( spacing: 8, children: List.generate(cadences.length, (index) { diff --git a/lib/screens/shifter_screen.dart b/lib/screens/shifter_screen.dart index 0a2c89f..02ec43a 100644 --- a/lib/screens/shifter_screen.dart +++ b/lib/screens/shifter_screen.dart @@ -6,13 +6,14 @@ */ import 'dart:async'; import 'package:ss2kconfigapp/utils/constants.dart'; -import 'package:ss2kconfigapp/utils/extra.dart'; import 'package:flutter/material.dart'; import 'package:flutter_blue_plus/flutter_blue_plus.dart'; import 'package:wakelock_plus/wakelock_plus.dart'; import '../utils/bledata.dart'; +import '../utils/extra.dart'; import '../widgets/metric_card.dart'; import '../widgets/ss2k_app_bar.dart'; +import '../widgets/power_table_chart.dart'; class ShifterScreen extends StatefulWidget { final BluetoothDevice device; @@ -24,19 +25,28 @@ class ShifterScreen extends StatefulWidget { class _ShifterScreenState extends State { late BLEData bleData; - late Map c; + Map c = const {}; String t = "Loading"; - String statusString = ''; StreamSubscription? _connectionStateSubscription; bool _refreshBlocker = false; + double _chartOpacity = 0.15; + bool _showOpacityControl = false; final GlobalKey _scaffoldMessengerKey = GlobalKey(); + final GlobalKey _chartKey = GlobalKey(); @override void initState() { super.initState(); bleData = BLEDataManager.forDevice(this.widget.device); - this.bleData.customCharacteristic.forEach((i) => i["vName"] == shifterPositionVname ? c = i : ()); - t = c["value"] ?? "Loading"; + c = this + .bleData + .customCharacteristic + .firstWhere( + (i) => i["vName"] == shifterPositionVname, + orElse: () => {}, + ); + t = c.isNotEmpty ? (c["value"]?.toString() ?? "Loading") : "Loading"; + //special setup for demo mode if (bleData.isSimulated) { t = "0"; @@ -54,6 +64,7 @@ class _ShifterScreenState extends State { } } }); + //Start Subscription rwSubscription(); } @@ -68,6 +79,10 @@ class _ShifterScreenState extends State { Future rwSubscription() async { _connectionStateSubscription = this.widget.device.connectionState.listen((state) async { + if (state == BluetoothConnectionState.connected) { + _chartKey.currentState?.requestAllCadenceLines(); + _chartKey.currentState?.requestHomingValues(); + } if (mounted) { setState(() {}); } @@ -85,13 +100,13 @@ class _ShifterScreenState extends State { await Future.delayed(Duration(microseconds: 500)); if (mounted) { + // Refresh the shifter characteristic value from BLE so UI updates when it changes remotely + c = bleData.customCharacteristic.firstWhere( + (i) => i["vName"] == shifterPositionVname, + orElse: () => {}, + ); setState(() { - t = c["value"] ?? "Loading"; - statusString = bleData.ftmsData.watts.toString() + - "w " + - bleData.ftmsData.cadence.toString() + - "rpm " + - (bleData.ftmsData.heartRate == 0 ? "" : bleData.ftmsData.heartRate.toString() + "bpm "); + t = c["value"]?.toString() ?? "Loading"; }); if (bleData.FTMSmode == 0 || bleData.simulateTargetWatts == false) { bleData.simulatedTargetWatts = ""; @@ -105,13 +120,17 @@ class _ShifterScreenState extends State { shift(int amount) { if (t != "Loading") { - String _t = (int.parse(c["value"]) + amount).toString(); - c["value"] = _t; + final current = int.tryParse(c["value"]?.toString() ?? ""); + if (current == null) { + return; + } + String _t = (current + amount).toString(); + c = Map.from(c)..["value"] = _t; this.bleData.writeToSS2k(this.widget.device, c); } if (bleData.isSimulated) { setState(() { - t = c["value"]; + t = c["value"]?.toString() ?? t; }); } WakelockPlus.enable(); @@ -159,66 +178,160 @@ class _ShifterScreenState extends State { device: widget.device, title: "Virtual Shifter", ), - body: Align( - alignment: Alignment.center, - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.center, - mainAxisSize: MainAxisSize.max, - children: [ - SizedBox(height: 8), - SingleChildScrollView( - scrollDirection: Axis.horizontal, - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - if (bleData.simulatedTargetWatts != "") - Padding( - padding: const EdgeInsets.symmetric(horizontal: 8.0), - child: MetricBox( - value: bleData.simulatedTargetWatts.toString(), - label: 'Target Watts', + body: Stack( + children: [ + // Background Chart + Positioned.fill( + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 32.0), + child: Opacity( + opacity: _chartOpacity, + child: IgnorePointer( + child: PowerTableChart( + key: _chartKey, + device: widget.device, + bleData: bleData, + pollTargetPosition: true, + ), + ), + ), + ), + ), + // Foreground Content + Align( + alignment: Alignment.center, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisSize: MainAxisSize.max, + children: [ + SizedBox(height: 8), + SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + if (bleData.simulatedTargetWatts != "") + Padding( + padding: const EdgeInsets.symmetric(horizontal: 8.0), + child: MetricBox( + value: bleData.simulatedTargetWatts.toString(), + label: 'Target Watts', + ), + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 8.0), + child: MetricBox( + value: bleData.ftmsData.watts.toString(), + label: 'Watts', + ), ), - ), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 8.0), - child: MetricBox( - value: bleData.ftmsData.watts.toString(), - label: 'Watts', - ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 8.0), + child: MetricBox( + value: bleData.ftmsData.cadence.toString(), + label: 'RPM', + ), + ), + if (bleData.ftmsData.heartRate != 0) + Padding( + padding: const EdgeInsets.symmetric(horizontal: 8.0), + child: MetricBox( + value: bleData.ftmsData.heartRate.toString(), + label: 'BPM', + ), + ) + ], + ), + ), + SizedBox(height: 12), + _buildShiftButton(Icons.arrow_upward, () { + shift(1); + }), + Spacer(flex: 1), + _buildGearDisplay(t), // Assuming '0' is the current gear value + Spacer(flex: 1), + _buildShiftButton(Icons.arrow_downward, () { + shift(-1); + }), + Spacer(flex: 1), + ], + ), + ), + Positioned( + right: 16, + bottom: 24, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Material( + color: Theme.of(context).colorScheme.surface.withOpacity(0.9), + shape: const CircleBorder(), + elevation: 4, + child: IconButton( + tooltip: _showOpacityControl ? 'Hide power table opacity' : 'Show power table opacity', + icon: Icon(_showOpacityControl ? Icons.opacity : Icons.opacity_outlined), + onPressed: () => setState(() => _showOpacityControl = !_showOpacityControl), ), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 8.0), - child: MetricBox( - value: bleData.ftmsData.cadence.toString(), - label: 'RPM', + ), + ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 320), + child: AnimatedSwitcher( + duration: const Duration(milliseconds: 200), + transitionBuilder: (child, animation) => FadeTransition( + opacity: animation, + child: child, ), + child: _showOpacityControl + ? Padding( + key: const ValueKey('opacityControl'), + padding: const EdgeInsets.only(top: 8), + child: _buildOpacityControl(context), + ) + : const SizedBox.shrink(key: ValueKey('opacityControlEmpty')), ), - if (bleData.ftmsData.heartRate != 0) - Padding( - padding: const EdgeInsets.symmetric(horizontal: 8.0), - child: MetricBox( - value: bleData.ftmsData.heartRate.toString(), - label: 'BPM', - ), - ) - ], - ), + ), + ], ), - SizedBox(height: 12), - _buildShiftButton(Icons.arrow_upward, () { - shift(1); - }), - Spacer(flex: 1), - _buildGearDisplay(t), // Assuming '0' is the current gear value - Spacer(flex: 1), - _buildShiftButton(Icons.arrow_downward, () { - shift(-1); - }), - Spacer(flex: 1), - ], - ), + ), + ], ), )); } + + Widget _buildOpacityControl(BuildContext context) { + return Container( + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surface.withOpacity(0.9), + borderRadius: BorderRadius.circular(12), + boxShadow: const [BoxShadow(color: Colors.black26, blurRadius: 8, offset: Offset(0, 4))], + ), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Text('Power Table Opacity'), + const Spacer(), + Text('${(_chartOpacity * 100).round()}%'), + ], + ), + Slider( + value: _chartOpacity, + min: 0.05, + max: 0.5, + divisions: 9, + label: '${(_chartOpacity * 100).round()}%', + onChanged: (value) { + setState(() { + _chartOpacity = value; + }); + }, + ), + ], + ), + ); + } } diff --git a/lib/utils/constants.dart b/lib/utils/constants.dart index 0515207..8b825ab 100644 --- a/lib/utils/constants.dart +++ b/lib/utils/constants.dart @@ -9,8 +9,8 @@ import 'package:flutter/services.dart'; // Constants const int INT32_MIN = -2147483648; -const double MIN_POWER_RANGE = 1000.0; // Minimum watts range for power table -const double MIN_RESISTANCE_RANGE = 200.0; // Minimum resistance range for power table +const double MIN_POWER_RANGE = 900.0; // Minimum watts range for power table +const double MIN_RESISTANCE_RANGE = 2000.0; // Minimum resistance range for power table final String csUUID = "77776277-7877-7774-4466-896665500000"; final String ccUUID = "77776277-7877-7774-4466-896665500001"; diff --git a/lib/utils/power_table_painter.dart b/lib/utils/power_table_painter.dart index a807ccf..a03e884 100644 --- a/lib/utils/power_table_painter.dart +++ b/lib/utils/power_table_painter.dart @@ -16,6 +16,7 @@ class PowerTablePainter extends CustomPainter { final List> positionHistory; // stores {'x': watts, 'y': rawResistance} final double tableDivisor; final bool swapAxes; // false: Resistance(Y)/Watts(X), true: Watts(Y)/Resistance(X) + final double animationValue; PowerTablePainter({ required this.powerTableData, @@ -30,12 +31,16 @@ class PowerTablePainter extends CustomPainter { required this.positionHistory, required this.tableDivisor, required this.swapAxes, + this.animationValue = 0.0, }); final leftPadding = 20.0; @override void paint(Canvas canvas, Size size) { + _drawAxisEffects(canvas, size); + _drawAxisLabels(canvas, size); + final paint = Paint() ..style = PaintingStyle.stroke ..strokeWidth = WorkoutStroke.actualPowerLine; @@ -54,6 +59,162 @@ class PowerTablePainter extends CustomPainter { } } + void _drawAxisEffects(Canvas canvas, Size size) { + double minRes = 0; + double maxRes = max(MIN_RESISTANCE_RANGE, homingMax ?? max(maxResistance, MIN_RESISTANCE_RANGE)); + double range = maxRes - minRes; + + double xValue = 0.0; + double yValue = 0.0; + double xMax = 0.0; + double yMax = 0.0; + + double scaledResistance = currentResistance / tableDivisor; + + if (!swapAxes) { + // Y is Resistance, X is Watts + yValue = scaledResistance - minRes; + yMax = range; + xValue = currentWatts; + xMax = MIN_POWER_RANGE; + } else { + // Y is Watts, X is Resistance + yValue = currentWatts; + yMax = MIN_POWER_RANGE; + xValue = scaledResistance - minRes; + xMax = range; + } + + // Normalize 0..1 + double yRatio = (yMax == 0) ? 0 : (yValue / yMax).clamp(0.0, 1.0); + double xRatio = (xMax == 0) ? 0 : (xValue / xMax).clamp(0.0, 1.0); + + // Visual styles + final double barThickness = 20.0; + final List flameColors = [ + Colors.blueAccent, + Colors.cyanAccent, + Colors.greenAccent, + Colors.yellowAccent, + Colors.orangeAccent, + Colors.deepOrangeAccent, + Colors.redAccent, + ]; + + // --- Vertical Axis (Y) --- + // Draw from bottom up + if (yRatio > 0) { + double barHeight = size.height * yRatio; + Rect yFillRect = Rect.fromLTRB( + 2, // x + size.height - barHeight, // top + 2 + barThickness, // right + size.height // bottom + ); + _paintAnimatedBar(canvas, yFillRect, flameColors, isVertical: true, ratio: yRatio); + } + + // --- Horizontal Axis (X) --- + // Draw from left to right + if (xRatio > 0) { + double barWidth = (size.width - leftPadding) * xRatio; + Rect xFillRect = Rect.fromLTRB( + leftPadding, // left + -15, // top (above labels) + leftPadding + barWidth, // right + -15 + barThickness // bottom + ); + _paintAnimatedBar(canvas, xFillRect, flameColors, isVertical: false, ratio: xRatio); + } + } + + void _paintAnimatedBar(Canvas canvas, Rect rect, List colors, + {required bool isVertical, required double ratio}) { + final Paint barPaint = Paint()..style = PaintingStyle.fill; + + double totalExtent = isVertical ? (rect.height / ratio) : (rect.width / ratio); + + // Create a Rect that represents the full range of the axis for gradient mapping + Rect gradientRect; + Alignment beginAlignment; // Start of axis (Low value) + Alignment endAlignment; // End of axis (High value) + + if (isVertical) { + // Vertical: Axis starts at Bottom (High Y) and goes to Top (Low Y) + gradientRect = Rect.fromLTRB(rect.left, rect.bottom - totalExtent, rect.right, rect.bottom); + beginAlignment = Alignment.bottomCenter; + endAlignment = Alignment.topCenter; + } else { + // Horizontal: Axis starts at Left and goes to Right + gradientRect = Rect.fromLTRB(rect.left, rect.top, rect.left + totalExtent, rect.bottom); + beginAlignment = Alignment.centerLeft; + endAlignment = Alignment.centerRight; + } + + // Pulse effect + double pulse = 0.1 * sin(animationValue * 2 * pi); // -0.1 to 0.1 + + barPaint.shader = LinearGradient( + begin: beginAlignment, + end: endAlignment, + colors: colors.map((c) => c.withOpacity((0.8 + pulse).clamp(0.0, 1.0))).toList(), + ).createShader(gradientRect); + + canvas.drawRect(rect, barPaint); + + // Draw a "Peak" indicator + Paint peakPaint = Paint() + ..color = Colors.white.withOpacity(0.9) + ..style = PaintingStyle.fill + ..maskFilter = MaskFilter.blur(BlurStyle.normal, 2); + + if (isVertical) { + canvas.drawRect(Rect.fromLTRB(rect.left, rect.top, rect.right, rect.top + 2), peakPaint); + } else { + canvas.drawRect(Rect.fromLTRB(rect.right - 2, rect.top, rect.right, rect.bottom), peakPaint); + } + } + + void _drawAxisLabels(Canvas canvas, Size size) { + final labelStyle = TextStyle( + color: Colors.blueGrey.withOpacity(0.2), + fontSize: 28, + fontWeight: FontWeight.w900, + letterSpacing: 2.0, + ); + + void drawLabel(String text, bool isVertical) { + final textSpan = TextSpan(text: text, style: labelStyle); + final textPainter = TextPainter( + text: textSpan, + textDirection: TextDirection.ltr, + ); + textPainter.layout(); + + canvas.save(); + if (isVertical) { + // Y-axis (Vertical): Center vertically, stick to left edge + canvas.translate(10, size.height / 2); + canvas.rotate(-pi / 2); + textPainter.paint(canvas, Offset(-textPainter.width / 2, -textPainter.height / 2)); + } else { + // X-axis (Horizontal): Center horizontally, near top + double x = leftPadding + (size.width - leftPadding) / 2 - textPainter.width / 2; + double y = -12; + textPainter.paint(canvas, Offset(x, y)); + } + canvas.restore(); + } + + if (!swapAxes) { + drawLabel("RESISTANCE", true); // Y + drawLabel("P O W E R", false); // X + } else { + drawLabel("P O W E R", true); // Y + drawLabel("RESISTANCE", false); // X + } + } + void _drawGrid(Canvas canvas, Size size) { final gridPaint = Paint() ..style = PaintingStyle.stroke @@ -80,7 +241,7 @@ class PowerTablePainter extends CustomPainter { style: TextStyle(color: Colors.grey[600], fontSize: WorkoutFontSizes.small), ); textPainter.layout(); - final double labelX = max(0.0, leftPadding - textPainter.width - 4); + final double labelX = leftPadding + 4; textPainter.paint(canvas, Offset(labelX, y - textPainter.height / 2)); } for (double watts = 0; watts <= MIN_POWER_RANGE; watts += 100) { @@ -107,7 +268,7 @@ class PowerTablePainter extends CustomPainter { style: TextStyle(color: Colors.grey[600], fontSize: WorkoutFontSizes.small), ); textPainter.layout(); - final double labelX = max(0.0, leftPadding - textPainter.width - 4); + final double labelX = leftPadding + 4; textPainter.paint(canvas, Offset(labelX, y - textPainter.height / 2)); } for (double resistance = minRes; resistance <= maxRes; resistance += range / 5) { diff --git a/lib/widgets/power_table_chart.dart b/lib/widgets/power_table_chart.dart new file mode 100644 index 0000000..6f84eec --- /dev/null +++ b/lib/widgets/power_table_chart.dart @@ -0,0 +1,240 @@ +import 'dart:async'; +import 'package:flutter/material.dart'; +import 'package:flutter_blue_plus/flutter_blue_plus.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import '../utils/bledata.dart'; +import '../utils/constants.dart'; +import '../utils/power_table_painter.dart'; + +class PowerTableChart extends StatefulWidget { + static const List lineColors = [ + Colors.purple, + Colors.indigo, + Colors.blue, + Colors.cyan, + Colors.teal, + Colors.green, + Colors.lime, + Colors.orange, + Colors.red, + Colors.pink, + Colors.brown, + ]; + + static const List cadenceTicks = [60, 65, 70, 75, 80, 85, 90, 95, 100, 105]; + + final BluetoothDevice device; + final BLEData bleData; + final bool pollTargetPosition; + final Duration pollInterval; + + const PowerTableChart({ + Key? key, + required this.device, + required this.bleData, + this.pollTargetPosition = true, + this.pollInterval = const Duration(seconds: 1), + }) : super(key: key); + + @override + State createState() => PowerTableChartState(); +} + +class PowerTableChartState extends State with SingleTickerProviderStateMixin { + late AnimationController _pulseController; + double maxResistance = 0; + double? homingMin; + double? homingMax; + bool _swapAxes = false; // false = Resistance(Y) vs Watts(X), true = Watts(Y) vs Resistance(X) + static const String _prefsSwapAxesKey = 'power_table_swap_axes'; + + // Trail tracking + final List> _positionHistory = []; + static const int maxTrailLength = 10; + DateTime _lastPositionUpdate = DateTime.now(); + Timer? _homingValuesTimer; + Timer? _targetPositionTimer; + + List get colors => PowerTableChart.lineColors; + List get cadences => PowerTableChart.cadenceTicks; + + bool get swapAxes => _swapAxes; + + @override + void initState() { + super.initState(); + // Initialize pulse animation + _pulseController = AnimationController( + duration: const Duration(seconds: 3), + vsync: this, + )..repeat(reverse: true); + + requestAllCadenceLines(); + requestHomingValues(); + _loadAxisPreference(); + _startTargetPositionPolling(); + + // Set up timer to periodically check homing values + _homingValuesTimer = Timer.periodic(const Duration(seconds: 5), (_homingValuesTimer) { + if (mounted && widget.device.isConnected) { + requestHomingValues(); + } + }); + } + + @override + void dispose() { + _pulseController.dispose(); + _homingValuesTimer?.cancel(); + _targetPositionTimer?.cancel(); + super.dispose(); + } + + @override + void didUpdateWidget(PowerTableChart oldWidget) { + super.didUpdateWidget(oldWidget); + // React to data changes from parent rebuilds + if (mounted) { + _updatePulseSpeed(); + } + } + + Future _loadAxisPreference() async { + final prefs = await SharedPreferences.getInstance(); + final saved = prefs.getBool(_prefsSwapAxesKey) ?? false; + if (mounted) { + setState(() => _swapAxes = saved); + } + } + + Future toggleAxisOrientation() async { + final prefs = await SharedPreferences.getInstance(); + setState(() => _swapAxes = !_swapAxes); + await prefs.setBool(_prefsSwapAxesKey, _swapAxes); + } + + void _startTargetPositionPolling() { + if (!widget.pollTargetPosition) return; + _targetPositionTimer?.cancel(); + _targetPositionTimer = Timer.periodic(widget.pollInterval, (timer) { + if (widget.bleData.isUserDisconnect) { + timer.cancel(); + return; + } + if (mounted && widget.device.isConnected) { + widget.bleData.requestSetting(widget.device, targetPositionVname); + } + }); + } + + Future requestHomingValues() async { + if (mounted && widget.device.isConnected) { + await widget.bleData.requestSetting(widget.device, BLE_hMinVname); + await widget.bleData.requestSetting(widget.device, BLE_hMaxVname); + await widget.bleData.requestSetting(widget.device, pTab4pwrVname); + + String test = widget.bleData.getVnameValue(BLE_hMinVname, returnNoFirmSupport: true); + if (test == noFirmSupport) return; + + double? value = double.tryParse(widget.bleData.getVnameValue(BLE_hMinVname)); + double? value2 = double.tryParse(widget.bleData.getVnameValue(BLE_hMaxVname)); + + widget.bleData.tableDivisor = (widget.bleData.getVnameValue(pTab4pwrVname, returnNoFirmSupport: true) == noFirmSupport) + ? widget.bleData.tableOldDivisor + : widget.bleData.tableNewDivisor; + + if (mounted) { + setState(() { + homingMin = (value == INT32_MIN) ? null : value! / widget.bleData.tableDivisor; + homingMax = (value2 == INT32_MIN) ? null : value2! / widget.bleData.tableDivisor; + }); + } + } + } + + Future requestAllCadenceLines() async { + if (!mounted || !widget.device.isConnected) return; + for (int i = 0; i < 10; i++) { + await widget.bleData.requestSetting(widget.device, powerTableDataVname, extraByte: i); + } + } + + double calculateMaxResistance() { + double maxRes = 0; + for (var row in widget.bleData.powerTableData) { + for (int i = 0; i < row.length; i++) { + if (row[i] != null && row[i]! > maxRes) { + maxRes = row[i]!.toDouble(); + } + } + } + return maxRes; + } + + void _updatePositionHistory(double x, double y) { + final now = DateTime.now(); + if (now.difference(_lastPositionUpdate).inMilliseconds >= 100) { + _positionHistory.add({'x': x, 'y': y}); + if (_positionHistory.length > maxTrailLength) { + _positionHistory.removeAt(0); + } + _lastPositionUpdate = now; + } + } + + void _updatePulseSpeed() { + int durationMs = 3500; + int cadence = widget.bleData.ftmsData.cadence; + if (cadence > 10) { + durationMs = (50000 / cadence).round(); + } + if (durationMs < 400) durationMs = 400; + if (durationMs > 5000) durationMs = 5000; + + if (_pulseController.duration?.inMilliseconds != durationMs) { + _pulseController.duration = Duration(milliseconds: durationMs); + if (_pulseController.isAnimating) { + _pulseController.repeat(reverse: true); + } + } + } + + @override + Widget build(BuildContext context) { + // Recalculate max resistance and update history on build + maxResistance = calculateMaxResistance(); + + if (widget.bleData.ftmsData.watts > 0 && widget.bleData.ftmsData.watts <= 1000 && maxResistance > 0) { + double targetPosition = double.tryParse(widget.bleData.getVnameValue(targetPositionVname)) ?? 0; + _updatePositionHistory(widget.bleData.ftmsData.watts.toDouble(), targetPosition); + } + + return LayoutBuilder( + builder: (context, constraints) { + return AnimatedBuilder( + animation: _pulseController, + builder: (context, child) { + return CustomPaint( + size: Size(constraints.maxWidth, constraints.maxHeight), + painter: PowerTablePainter( + powerTableData: widget.bleData.powerTableData.map((row) => row.map((value) => value?.toDouble()).toList()).toList(), + cadences: cadences, + colors: colors, + maxResistance: maxResistance, + homingMin: homingMin, + homingMax: homingMax, + currentWatts: widget.bleData.ftmsData.watts.toDouble(), + currentResistance: double.tryParse(widget.bleData.getVnameValue(targetPositionVname)) ?? 0, + currentCadence: widget.bleData.ftmsData.cadence, + positionHistory: _positionHistory, + tableDivisor: widget.bleData.tableDivisor, + swapAxes: _swapAxes, + animationValue: _pulseController.value, + ), + ); + }, + ); + } + ); + } +} \ No newline at end of file