From 9d1a5fe5014cefd47c5504aef302011c473e025b Mon Sep 17 00:00:00 2001 From: Oliver Bagge Date: Sun, 4 Jan 2026 15:35:02 +0100 Subject: [PATCH 1/6] add beta channel UI code from yesterday, this doesn't work yet because artifacts can't be downloaded easily. Need to reimplement beta_fimware_list.dart to fetch from prerelease pr-builds --- .../ios/Flutter/AppFrameworkInfo.plist | 4 +- open_wearable/lib/router.dart | 3 +- .../lib/view_models/wearables_provider.dart | 17 +- .../firmware_select/beta_firmware_list.dart | 365 ++++++++++++++++++ .../fota/firmware_select/firmware_list.dart | 28 +- 5 files changed, 406 insertions(+), 11 deletions(-) create mode 100644 open_wearable/lib/widgets/fota/firmware_select/beta_firmware_list.dart diff --git a/open_wearable/ios/Flutter/AppFrameworkInfo.plist b/open_wearable/ios/Flutter/AppFrameworkInfo.plist index be767884..391a902b 100644 --- a/open_wearable/ios/Flutter/AppFrameworkInfo.plist +++ b/open_wearable/ios/Flutter/AppFrameworkInfo.plist @@ -20,7 +20,5 @@ ???? CFBundleVersion 1.0 - MinimumOSVersion - 13.0 - \ No newline at end of file + diff --git a/open_wearable/lib/router.dart b/open_wearable/lib/router.dart index 20b29ae2..d4293e40 100644 --- a/open_wearable/lib/router.dart +++ b/open_wearable/lib/router.dart @@ -55,8 +55,9 @@ final GoRouter router = GoRouter( name: 'fota', redirect: (context, state) { final bool isAndroid = !kIsWeb && Platform.isAndroid; + final bool isIOS = !kIsWeb && Platform.isIOS; - if (!isAndroid) { + if (!isAndroid && !isIOS) { WidgetsBinding.instance.addPostFrameCallback((_) { final ctx = rootNavigatorKey.currentContext; if (ctx == null) return; diff --git a/open_wearable/lib/view_models/wearables_provider.dart b/open_wearable/lib/view_models/wearables_provider.dart index f58f2c2a..d7453002 100644 --- a/open_wearable/lib/view_models/wearables_provider.dart +++ b/open_wearable/lib/view_models/wearables_provider.dart @@ -53,7 +53,9 @@ class WearableTimeSynchronizedEvent extends WearableEvent { WearableTimeSynchronizedEvent({ required super.wearable, String? description, - }) : super(description: description ?? 'Time synchronized for ${wearable.name}'); + }) : super( + description: + description ?? 'Time synchronized for ${wearable.name}'); @override String toString() => 'WearableTimeSynchronizedEvent for ${wearable.name}'; @@ -65,7 +67,9 @@ class WearableErrorEvent extends WearableEvent { required super.wearable, required this.errorMessage, String? description, - }) : super(description: description ?? 'Error for ${wearable.name}: $errorMessage'); + }) : super( + description: + description ?? 'Error for ${wearable.name}: $errorMessage'); @override String toString() => @@ -89,9 +93,9 @@ class WearablesProvider with ChangeNotifier { Stream get unsupportedFirmwareStream => _unsupportedFirmwareEventsController.stream; - final _wearableEventController = - StreamController.broadcast(); - Stream get wearableEventStream => _wearableEventController.stream; + final _wearableEventController = StreamController.broadcast(); + Stream get wearableEventStream => + _wearableEventController.stream; final Map _capabilitySubscriptions = {}; @@ -285,7 +289,8 @@ class WearablesProvider with ChangeNotifier { final currentVersion = await dev.readDeviceFirmwareVersion(); if (currentVersion == null || currentVersion.isEmpty) { - logger.d('Could not read firmware version for ${(dev as Wearable).name}'); + logger + .d('Could not read firmware version for ${(dev as Wearable).name}'); return; } diff --git a/open_wearable/lib/widgets/fota/firmware_select/beta_firmware_list.dart b/open_wearable/lib/widgets/fota/firmware_select/beta_firmware_list.dart new file mode 100644 index 00000000..8195d76f --- /dev/null +++ b/open_wearable/lib/widgets/fota/firmware_select/beta_firmware_list.dart @@ -0,0 +1,365 @@ +// ignore_for_file: use_build_context_synchronously + +import 'package:flutter/material.dart'; +import 'package:flutter_platform_widgets/flutter_platform_widgets.dart'; +import 'package:provider/provider.dart'; +import 'package:open_earable_flutter/open_earable_flutter.dart'; +import 'package:http/http.dart' as http; +import 'dart:convert'; + +class BetaFirmwareList extends StatefulWidget { + const BetaFirmwareList({super.key}); + + @override + State createState() => _BetaFirmwareListState(); +} + +class _BetaFirmwareListState extends State { + late Future> _firmwareFuture; + String? firmwareVersion; + bool _expanded = false; + + @override + void initState() { + super.initState(); + _loadFirmwares(); + _loadFirmwareVersion(); + } + + void _loadFirmwares() { + _firmwareFuture = _fetchLatestFotaPerPR(); + } + + void _loadFirmwareVersion() async { + final wearable = + Provider.of(context, listen: false) + .selectedWearable; + if (wearable is DeviceFirmwareVersion) { + final version = + await (wearable as DeviceFirmwareVersion).readDeviceFirmwareVersion(); + setState(() { + firmwareVersion = version; + }); + } + } + + Future> _fetchLatestFotaPerPR() async { + const owner = 'OpenEarable'; + const repo = 'open-earable-2'; + + // Fetch artifacts + final artifactsResponse = await http.get( + Uri.parse( + 'https://api.github.com/repos/$owner/$repo/actions/artifacts?per_page=100', + ), + headers: {'Accept': 'application/vnd.github.v3+json'}, + ); + if (artifactsResponse.statusCode != 200) { + throw Exception('Failed to fetch artifacts'); + } + final artifactsJson = + jsonDecode(artifactsResponse.body)['artifacts'] as List; + + // Fetch PRs + final prsResponse = await http.get( + Uri.parse( + 'https://api.github.com/repos/$owner/$repo/pulls?state=open&sort=updated&direction=desc', + ), + headers: {'Accept': 'application/vnd.github.v3+json'}, + ); + if (prsResponse.statusCode != 200) { + throw Exception('Failed to fetch PRs'); + } + final prsJson = jsonDecode(prsResponse.body) as List; + + // Keep only FOTA artifacts + final fotaArtifacts = artifactsJson + .where((a) { + final name = a['name'] as String? ?? ''; + return name.contains('fota'); + }) + .cast>() + .toList(); + + final result = []; + + for (final pr in prsJson) { + final prBranch = pr['head']['ref'] as String; + final prTitle = pr['title'] as String; + + // all artifacts for this PR branch + final matches = fotaArtifacts.where((artifact) { + final run = artifact['workflow_run'] as Map?; + final branch = run?['head_branch'] as String?; + return branch == prBranch; + }).toList(); + + if (matches.isEmpty) continue; // hide PRs with no builds + + matches.sort((a, b) { + final dateA = DateTime.parse(a['created_at'] as String); + final dateB = DateTime.parse(b['created_at'] as String); + return dateB.compareTo(dateA); + }); + + final latest = matches.first; + + result.add(RemoteFirmware( + name: prTitle, + url: latest['archive_download_url'] as String, + version: (latest['workflow_run'] as Map)['head_sha'] + as String, + type: FirmwareType.multiImage, + )); + } + + // Optional: sort PRs by newest artifact overall + result.sort((a, b) => b.version.compareTo(a.version)); + + return result; + } + + @override + Widget build(BuildContext context) { + return PlatformScaffold( + appBar: PlatformAppBar( + title: PlatformText('Beta Firmware (PR Builds)'), + ), + body: Material( + type: MaterialType.transparency, + child: _body(), + ), + ); + } + + Widget _body() { + return Container( + alignment: Alignment.center, + child: FutureBuilder>( + future: _firmwareFuture, + builder: (context, snapshot) { + if (snapshot.hasData) { + List apps = snapshot.data!; + if (apps.isEmpty) { + return Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.bug_report, size: 64, color: Colors.orange), + const SizedBox(height: 16), + PlatformText( + 'No Beta Firmware Available', + style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold), + ), + const SizedBox(height: 8), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 32.0), + child: PlatformText( + 'No pull request builds are currently available.\n\n' + 'Beta firmware is built automatically from GitHub pull requests.', + textAlign: TextAlign.center, + ), + ), + const SizedBox(height: 24), + PlatformElevatedButton( + onPressed: () { + setState(_loadFirmwares); + }, + child: PlatformText('Refresh'), + ), + ], + ); + } + return _listBuilder(apps); + } else if (snapshot.hasError) { + return Expanded( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.error_outline, size: 64, color: Colors.red), + const SizedBox(height: 16), + PlatformText( + 'Error Loading Beta Firmware', + style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold), + ), + const SizedBox(height: 8), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 32.0), + child: PlatformText( + 'Could not fetch beta firmware from GitHub.\n\n' + 'Error: ${snapshot.error}', + textAlign: TextAlign.center, + ), + ), + const SizedBox(height: 24), + PlatformElevatedButton( + onPressed: () { + setState(_loadFirmwares); + }, + child: PlatformText('Retry'), + ), + ], + ), + ); + } + return const CircularProgressIndicator(); + }, + ), + ); + } + + Widget _listBuilder(List apps) { + final visibleApps = _expanded ? apps : [apps.first]; + + return Column( + children: [ + // Warning banner + Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + color: Colors.orange.withOpacity(0.2), + child: Row( + children: [ + Icon(Icons.warning, color: Colors.orange, size: 20), + const SizedBox(width: 8), + Expanded( + child: PlatformText( + 'Beta firmware is experimental and may be unstable. Use at your own risk.', + style: TextStyle( + color: Colors.orange.shade900, + fontSize: 12, + fontWeight: FontWeight.w500, + ), + ), + ), + ], + ), + ), + ListView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + itemCount: visibleApps.length, + itemBuilder: (context, index) { + final firmware = visibleApps[index]; + final isLatest = firmware == apps.first; + final remarks = []; + bool isInstalled = false; + + if (firmwareVersion != null && + firmware.version + .contains(firmwareVersion!.replaceAll('\x00', ''))) { + remarks.add('Current'); + isInstalled = true; + } + + if (isLatest) { + remarks.add('Latest'); + } + remarks.add('Beta'); + + return ListTile( + leading: Icon(Icons.bug_report, color: Colors.orange), + title: PlatformText( + firmware.name, + style: TextStyle( + color: isLatest ? Colors.black : Colors.grey, + ), + ), + subtitle: PlatformText( + remarks.join(', '), + style: TextStyle( + color: isLatest ? Colors.black : Colors.grey, + ), + ), + onTap: () { + if (isInstalled) { + showDialog( + context: context, + builder: (context) => PlatformAlertDialog( + title: PlatformText('Already Installed'), + content: PlatformText( + 'This firmware version is already installed on the device.', + ), + actions: [ + PlatformTextButton( + onPressed: () => Navigator.of(context).pop(), + child: PlatformText('Cancel'), + ), + PlatformTextButton( + onPressed: () { + final selectedFW = apps[index]; + context + .read() + .setFirmware(selectedFW); + Navigator.of(context).pop(); + Navigator.pop(context); + }, + child: PlatformText('Install Anyway'), + ), + ], + ), + ); + } else { + // Show warning for beta firmware + showDialog( + context: context, + builder: (context) => PlatformAlertDialog( + title: PlatformText('Beta Firmware Warning'), + content: PlatformText( + 'You are about to install beta firmware from a pull request. ' + 'This firmware may be unstable or incomplete. ' + 'Proceed at your own risk.', + ), + actions: [ + PlatformTextButton( + onPressed: () => Navigator.of(context).pop(), + child: PlatformText('Cancel'), + ), + PlatformTextButton( + onPressed: () { + final selectedFW = apps[index]; + context + .read() + .setFirmware(selectedFW); + Navigator.of(context).pop(); + Navigator.pop(context); + }, + child: PlatformText( + 'Install', + style: TextStyle(color: Colors.red), + ), + ), + ], + ), + ); + } + }, + ); + }, + ), + if (apps.length > 1) + PlatformTextButton( + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + PlatformText( + _expanded ? 'Hide older versions' : 'Show older versions', + style: TextStyle(color: Colors.black), + ), + SizedBox(width: 8), + Icon( + _expanded + ? Icons.keyboard_arrow_up + : Icons.keyboard_arrow_down, + ), + ], + ), + onPressed: () { + setState(() { + _expanded = !_expanded; + }); + }, + ), + ], + ); + } +} diff --git a/open_wearable/lib/widgets/fota/firmware_select/firmware_list.dart b/open_wearable/lib/widgets/fota/firmware_select/firmware_list.dart index 564cc03c..e5c70162 100644 --- a/open_wearable/lib/widgets/fota/firmware_select/firmware_list.dart +++ b/open_wearable/lib/widgets/fota/firmware_select/firmware_list.dart @@ -7,6 +7,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_platform_widgets/flutter_platform_widgets.dart'; import 'package:provider/provider.dart'; import 'package:open_earable_flutter/open_earable_flutter.dart'; +import 'beta_firmware_list.dart'; class FirmwareList extends StatefulWidget { const FirmwareList({super.key}); @@ -136,7 +137,8 @@ class _FirmwareListState extends State { return Expanded( child: Column( children: [ - PlatformText("Could not fetch firmware update, plase try again"), + PlatformText( + "Could not fetch firmware update, plase try again"), const SizedBox(height: 16), PlatformElevatedButton( onPressed: () { @@ -281,6 +283,30 @@ class _FirmwareListState extends State { }); }, ), + Padding( + padding: const EdgeInsets.all(16.0), + child: PlatformElevatedButton( + onPressed: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => BetaFirmwareList(), + ), + ); + }, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.bug_report, color: Colors.white), + const SizedBox(width: 8), + PlatformText( + 'Beta Firmware (PR Builds)', + style: TextStyle(color: Colors.white), + ), + ], + ), + ), + ), ], ); } From 8dd7047ca6a4143abb12f37c0a6a195b29523e23 Mon Sep 17 00:00:00 2001 From: Oliver Bagge Date: Sun, 4 Jan 2026 17:16:34 +0100 Subject: [PATCH 2/6] beta firmwares are now selectable after long pressing on the title of the firmware select page --- .../firmware_select/beta_firmware_list.dart | 365 -------------- .../fota/firmware_select/firmware_list.dart | 449 ++++++++++-------- .../firmware_select/firmware_repository.dart | 193 ++++++++ open_wearable/pubspec.lock | 2 +- open_wearable/pubspec.yaml | 1 + 5 files changed, 457 insertions(+), 553 deletions(-) delete mode 100644 open_wearable/lib/widgets/fota/firmware_select/beta_firmware_list.dart create mode 100644 open_wearable/lib/widgets/fota/firmware_select/firmware_repository.dart diff --git a/open_wearable/lib/widgets/fota/firmware_select/beta_firmware_list.dart b/open_wearable/lib/widgets/fota/firmware_select/beta_firmware_list.dart deleted file mode 100644 index 8195d76f..00000000 --- a/open_wearable/lib/widgets/fota/firmware_select/beta_firmware_list.dart +++ /dev/null @@ -1,365 +0,0 @@ -// ignore_for_file: use_build_context_synchronously - -import 'package:flutter/material.dart'; -import 'package:flutter_platform_widgets/flutter_platform_widgets.dart'; -import 'package:provider/provider.dart'; -import 'package:open_earable_flutter/open_earable_flutter.dart'; -import 'package:http/http.dart' as http; -import 'dart:convert'; - -class BetaFirmwareList extends StatefulWidget { - const BetaFirmwareList({super.key}); - - @override - State createState() => _BetaFirmwareListState(); -} - -class _BetaFirmwareListState extends State { - late Future> _firmwareFuture; - String? firmwareVersion; - bool _expanded = false; - - @override - void initState() { - super.initState(); - _loadFirmwares(); - _loadFirmwareVersion(); - } - - void _loadFirmwares() { - _firmwareFuture = _fetchLatestFotaPerPR(); - } - - void _loadFirmwareVersion() async { - final wearable = - Provider.of(context, listen: false) - .selectedWearable; - if (wearable is DeviceFirmwareVersion) { - final version = - await (wearable as DeviceFirmwareVersion).readDeviceFirmwareVersion(); - setState(() { - firmwareVersion = version; - }); - } - } - - Future> _fetchLatestFotaPerPR() async { - const owner = 'OpenEarable'; - const repo = 'open-earable-2'; - - // Fetch artifacts - final artifactsResponse = await http.get( - Uri.parse( - 'https://api.github.com/repos/$owner/$repo/actions/artifacts?per_page=100', - ), - headers: {'Accept': 'application/vnd.github.v3+json'}, - ); - if (artifactsResponse.statusCode != 200) { - throw Exception('Failed to fetch artifacts'); - } - final artifactsJson = - jsonDecode(artifactsResponse.body)['artifacts'] as List; - - // Fetch PRs - final prsResponse = await http.get( - Uri.parse( - 'https://api.github.com/repos/$owner/$repo/pulls?state=open&sort=updated&direction=desc', - ), - headers: {'Accept': 'application/vnd.github.v3+json'}, - ); - if (prsResponse.statusCode != 200) { - throw Exception('Failed to fetch PRs'); - } - final prsJson = jsonDecode(prsResponse.body) as List; - - // Keep only FOTA artifacts - final fotaArtifacts = artifactsJson - .where((a) { - final name = a['name'] as String? ?? ''; - return name.contains('fota'); - }) - .cast>() - .toList(); - - final result = []; - - for (final pr in prsJson) { - final prBranch = pr['head']['ref'] as String; - final prTitle = pr['title'] as String; - - // all artifacts for this PR branch - final matches = fotaArtifacts.where((artifact) { - final run = artifact['workflow_run'] as Map?; - final branch = run?['head_branch'] as String?; - return branch == prBranch; - }).toList(); - - if (matches.isEmpty) continue; // hide PRs with no builds - - matches.sort((a, b) { - final dateA = DateTime.parse(a['created_at'] as String); - final dateB = DateTime.parse(b['created_at'] as String); - return dateB.compareTo(dateA); - }); - - final latest = matches.first; - - result.add(RemoteFirmware( - name: prTitle, - url: latest['archive_download_url'] as String, - version: (latest['workflow_run'] as Map)['head_sha'] - as String, - type: FirmwareType.multiImage, - )); - } - - // Optional: sort PRs by newest artifact overall - result.sort((a, b) => b.version.compareTo(a.version)); - - return result; - } - - @override - Widget build(BuildContext context) { - return PlatformScaffold( - appBar: PlatformAppBar( - title: PlatformText('Beta Firmware (PR Builds)'), - ), - body: Material( - type: MaterialType.transparency, - child: _body(), - ), - ); - } - - Widget _body() { - return Container( - alignment: Alignment.center, - child: FutureBuilder>( - future: _firmwareFuture, - builder: (context, snapshot) { - if (snapshot.hasData) { - List apps = snapshot.data!; - if (apps.isEmpty) { - return Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon(Icons.bug_report, size: 64, color: Colors.orange), - const SizedBox(height: 16), - PlatformText( - 'No Beta Firmware Available', - style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold), - ), - const SizedBox(height: 8), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 32.0), - child: PlatformText( - 'No pull request builds are currently available.\n\n' - 'Beta firmware is built automatically from GitHub pull requests.', - textAlign: TextAlign.center, - ), - ), - const SizedBox(height: 24), - PlatformElevatedButton( - onPressed: () { - setState(_loadFirmwares); - }, - child: PlatformText('Refresh'), - ), - ], - ); - } - return _listBuilder(apps); - } else if (snapshot.hasError) { - return Expanded( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon(Icons.error_outline, size: 64, color: Colors.red), - const SizedBox(height: 16), - PlatformText( - 'Error Loading Beta Firmware', - style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold), - ), - const SizedBox(height: 8), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 32.0), - child: PlatformText( - 'Could not fetch beta firmware from GitHub.\n\n' - 'Error: ${snapshot.error}', - textAlign: TextAlign.center, - ), - ), - const SizedBox(height: 24), - PlatformElevatedButton( - onPressed: () { - setState(_loadFirmwares); - }, - child: PlatformText('Retry'), - ), - ], - ), - ); - } - return const CircularProgressIndicator(); - }, - ), - ); - } - - Widget _listBuilder(List apps) { - final visibleApps = _expanded ? apps : [apps.first]; - - return Column( - children: [ - // Warning banner - Container( - width: double.infinity, - padding: const EdgeInsets.all(12), - color: Colors.orange.withOpacity(0.2), - child: Row( - children: [ - Icon(Icons.warning, color: Colors.orange, size: 20), - const SizedBox(width: 8), - Expanded( - child: PlatformText( - 'Beta firmware is experimental and may be unstable. Use at your own risk.', - style: TextStyle( - color: Colors.orange.shade900, - fontSize: 12, - fontWeight: FontWeight.w500, - ), - ), - ), - ], - ), - ), - ListView.builder( - shrinkWrap: true, - physics: const NeverScrollableScrollPhysics(), - itemCount: visibleApps.length, - itemBuilder: (context, index) { - final firmware = visibleApps[index]; - final isLatest = firmware == apps.first; - final remarks = []; - bool isInstalled = false; - - if (firmwareVersion != null && - firmware.version - .contains(firmwareVersion!.replaceAll('\x00', ''))) { - remarks.add('Current'); - isInstalled = true; - } - - if (isLatest) { - remarks.add('Latest'); - } - remarks.add('Beta'); - - return ListTile( - leading: Icon(Icons.bug_report, color: Colors.orange), - title: PlatformText( - firmware.name, - style: TextStyle( - color: isLatest ? Colors.black : Colors.grey, - ), - ), - subtitle: PlatformText( - remarks.join(', '), - style: TextStyle( - color: isLatest ? Colors.black : Colors.grey, - ), - ), - onTap: () { - if (isInstalled) { - showDialog( - context: context, - builder: (context) => PlatformAlertDialog( - title: PlatformText('Already Installed'), - content: PlatformText( - 'This firmware version is already installed on the device.', - ), - actions: [ - PlatformTextButton( - onPressed: () => Navigator.of(context).pop(), - child: PlatformText('Cancel'), - ), - PlatformTextButton( - onPressed: () { - final selectedFW = apps[index]; - context - .read() - .setFirmware(selectedFW); - Navigator.of(context).pop(); - Navigator.pop(context); - }, - child: PlatformText('Install Anyway'), - ), - ], - ), - ); - } else { - // Show warning for beta firmware - showDialog( - context: context, - builder: (context) => PlatformAlertDialog( - title: PlatformText('Beta Firmware Warning'), - content: PlatformText( - 'You are about to install beta firmware from a pull request. ' - 'This firmware may be unstable or incomplete. ' - 'Proceed at your own risk.', - ), - actions: [ - PlatformTextButton( - onPressed: () => Navigator.of(context).pop(), - child: PlatformText('Cancel'), - ), - PlatformTextButton( - onPressed: () { - final selectedFW = apps[index]; - context - .read() - .setFirmware(selectedFW); - Navigator.of(context).pop(); - Navigator.pop(context); - }, - child: PlatformText( - 'Install', - style: TextStyle(color: Colors.red), - ), - ), - ], - ), - ); - } - }, - ); - }, - ), - if (apps.length > 1) - PlatformTextButton( - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - PlatformText( - _expanded ? 'Hide older versions' : 'Show older versions', - style: TextStyle(color: Colors.black), - ), - SizedBox(width: 8), - Icon( - _expanded - ? Icons.keyboard_arrow_up - : Icons.keyboard_arrow_down, - ), - ], - ), - onPressed: () { - setState(() { - _expanded = !_expanded; - }); - }, - ), - ], - ); - } -} diff --git a/open_wearable/lib/widgets/fota/firmware_select/firmware_list.dart b/open_wearable/lib/widgets/fota/firmware_select/firmware_list.dart index e5c70162..ba2a8959 100644 --- a/open_wearable/lib/widgets/fota/firmware_select/firmware_list.dart +++ b/open_wearable/lib/widgets/fota/firmware_select/firmware_list.dart @@ -7,7 +7,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_platform_widgets/flutter_platform_widgets.dart'; import 'package:provider/provider.dart'; import 'package:open_earable_flutter/open_earable_flutter.dart'; -import 'beta_firmware_list.dart'; +import 'firmware_repository.dart'; class FirmwareList extends StatefulWidget { const FirmwareList({super.key}); @@ -17,10 +17,11 @@ class FirmwareList extends StatefulWidget { } class _FirmwareListState extends State { - late Future> _firmwareFuture; - final repository = FirmwareImageRepository(); + late Future> _firmwareFuture; + final _repository = UnifiedFirmwareRepository(); String? firmwareVersion; bool _expanded = false; + bool _showBeta = false; @override void initState() { @@ -30,7 +31,7 @@ class _FirmwareListState extends State { } void _loadFirmwares() { - _firmwareFuture = repository.getFirmwareImages(); + _firmwareFuture = _repository.getAllFirmwares(includeBeta: _showBeta); } void _loadFirmwareVersion() async { @@ -46,27 +47,35 @@ class _FirmwareListState extends State { } } + void _toggleBeta() { + setState(() { + _showBeta = !_showBeta; + _loadFirmwares(); + }); + print(_showBeta ? 'Beta firmware enabled' : 'Beta firmware disabled'); + } + @override Widget build(BuildContext context) { return PlatformScaffold( appBar: PlatformAppBar( - title: PlatformText('Select Firmware'), + title: GestureDetector( + onLongPress: _toggleBeta, + child: PlatformText('Select Firmware'), + ), trailingActions: [ IconButton( - onPressed: () => onFirmwareSelect(context), + onPressed: () => _onCustomFirmwareSelect(context), icon: Icon(Icons.add), padding: EdgeInsets.zero, ), ], ), - body: Material( - type: MaterialType.transparency, - child: _body(), - ), + body: _body(), // Remove Material wrapper ); } - void onFirmwareSelect(BuildContext context) async { + void _onCustomFirmwareSelect(BuildContext context) async { final confirmed = await showDialog( context: context, builder: (_) => PlatformAlertDialog( @@ -93,18 +102,14 @@ class _FirmwareListState extends State { ), ); - if (confirmed != true) { - return; - } + if (confirmed != true) return; - // Navigator.pop(context, 'Firmware'); FilePickerResult? result = await FilePicker.platform.pickFiles( type: FileType.custom, allowedExtensions: ['zip', 'bin'], ); - if (result == null) { - return; - } + if (result == null) return; + final ext = result.files.first.extension; final fwType = ext == 'zip' ? FirmwareType.multiImage : FirmwareType.singleImage; @@ -123,191 +128,261 @@ class _FirmwareListState extends State { Navigator.pop(context); } - Container _body() { - // ignore: avoid_unnecessary_containers - return Container( - alignment: Alignment.center, - child: FutureBuilder>( - future: _firmwareFuture, - builder: (context, snapshot) { - if (snapshot.hasData) { - List apps = snapshot.data!; - return _listBuilder(apps); - } else if (snapshot.hasError) { - return Expanded( - child: Column( - children: [ - PlatformText( - "Could not fetch firmware update, plase try again"), - const SizedBox(height: 16), - PlatformElevatedButton( - onPressed: () { - setState(_loadFirmwares); - }, - child: PlatformText('Reload'), - ), - ], - ), - ); + Widget _body() { + return FutureBuilder>( + future: _firmwareFuture, + builder: (context, snapshot) { + if (snapshot.hasData) { + final entries = snapshot.data!; + if (entries.isEmpty) { + return Center(child: PlatformText('No firmware available')); } - return const CircularProgressIndicator(); - }, + return _listBuilder(entries); + } else if (snapshot.hasError) { + return _errorWidget(); + } + return const Center(child: CircularProgressIndicator()); + }, + ); + } + + Widget _errorWidget() { + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + PlatformText("Could not fetch firmware, please try again"), + const SizedBox(height: 16), + PlatformElevatedButton( + onPressed: _loadFirmwares, + child: PlatformText('Reload'), + ), + ], ), ); } - Widget _listBuilder(List apps) { - final visibleApps = _expanded ? apps : [apps.first]; - - return Column( - children: [ - ListView.builder( - shrinkWrap: true, - physics: const NeverScrollableScrollPhysics(), - itemCount: visibleApps.length, - itemBuilder: (context, index) { - final firmware = visibleApps[index]; - final isLatest = firmware == apps.first; - final remarks = []; - bool isInstalled = false; - - if (firmwareVersion != null && - firmware.version - .contains(firmwareVersion!.replaceAll('\x00', ''))) { - remarks.add('Current'); - isInstalled = true; - } - - if (isLatest) { - remarks.add('Latest'); - } - - return ListTile( - title: PlatformText( - firmware.name, - style: TextStyle( - color: isLatest ? Colors.black : Colors.grey, - ), - ), - subtitle: PlatformText( - remarks.join(', '), - style: TextStyle( - color: isLatest ? Colors.black : Colors.grey, - ), + Widget _listBuilder(List entries) { + final stableEntries = entries.where((e) => e.isStable).toList(); + final betaEntries = entries.where((e) => e.isBeta).toList(); + final latestStable = stableEntries.isNotEmpty ? stableEntries.first : null; + + final visibleEntries = _expanded ? entries : [entries.first]; + + return SafeArea( + child: SingleChildScrollView( + child: Column( + children: [ + if (_showBeta && betaEntries.isNotEmpty) _betaWarningBanner(), + ListView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + itemCount: visibleEntries.length, + itemBuilder: (context, index) => + _firmwareListItem(visibleEntries[index], latestStable, index), + ), + if (entries.length > 1) _expandButton(), + const SizedBox(height: 16), // Simple bottom padding + ], + ), + ), + ); + } + + Widget _betaWarningBanner() { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + color: Colors.orange.withOpacity(0.2), + child: Row( + children: [ + Icon(Icons.warning, color: Colors.orange, size: 20), + const SizedBox(width: 8), + Expanded( + child: PlatformText( + 'Beta firmware is experimental and may be unstable. Use at your own risk.', + style: TextStyle( + color: Colors.orange.shade900, + fontSize: 12, + fontWeight: FontWeight.w500, ), - onTap: () { - if (isInstalled) { - showDialog( - context: context, - builder: (context) => PlatformAlertDialog( - title: PlatformText('Already Installed'), - content: PlatformText( - 'This firmware version is already installed on the device.', - ), - actions: [ - PlatformTextButton( - onPressed: () => Navigator.of(context).pop(), - child: PlatformText('Cancel'), - ), - PlatformTextButton( - onPressed: () { - final selectedFW = apps[index]; - context - .read() - .setFirmware(selectedFW); - Navigator.of(context).pop(); - Navigator.pop(context, 'Firmware $index'); - }, - child: PlatformText('Install Anyway'), - ), - ], - ), - ); - } else if (!isLatest) { - showDialog( - context: context, - builder: (context) => PlatformAlertDialog( - title: PlatformText('Warning'), - content: PlatformText( - 'You are selecting an old firmware version. We recommend installing the newest version.', - ), - actions: [ - PlatformTextButton( - onPressed: () => Navigator.of(context).pop(), - child: PlatformText('Cancel'), - ), - PlatformTextButton( - onPressed: () { - final selectedFW = apps[index]; - context - .read() - .setFirmware(selectedFW); - Navigator.of(context).pop(); - Navigator.pop(context, 'Firmware $index'); - }, - child: PlatformText('Proceed'), - ), - ], - ), - ); - } else { - context - .read() - .setFirmware(firmware); - Navigator.pop(context, 'Firmware $index'); - } - }, - ); - }, + ), + ), + ], + ), + ); + } + + Widget _firmwareListItem( + FirmwareEntry entry, FirmwareEntry? latestStable, int index) { + final firmware = entry.firmware; + final isBeta = entry.isBeta; + final isLatestStable = entry == latestStable; + final remarks = []; + bool isInstalled = false; + + if (firmwareVersion != null && + firmware.version.contains(firmwareVersion!.replaceAll('\x00', ''))) { + remarks.add('Current'); + isInstalled = true; + } + + if (isLatestStable) { + remarks.add('Latest'); + } + + if (isBeta) { + remarks.add('Beta'); + } + + return ListTile( + leading: isBeta ? Icon(Icons.bug_report, color: Colors.orange) : null, + title: PlatformText( + firmware.name, + style: TextStyle( + color: isLatestStable ? Colors.black : Colors.grey, + ), + ), + subtitle: PlatformText( + remarks.join(', '), + style: TextStyle( + color: isLatestStable ? Colors.black : Colors.grey, + ), + ), + onTap: () => _onFirmwareTap( + firmware, + index, + isInstalled, + isLatestStable, + isBeta, + ), + ); + } + + Widget _expandButton() { + return PlatformTextButton( + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + PlatformText( + _expanded ? 'Hide older versions' : 'Show older versions', + style: TextStyle(color: Colors.black), + ), + SizedBox(width: 8), + Icon( + _expanded ? Icons.keyboard_arrow_up : Icons.keyboard_arrow_down, + ), + ], + ), + onPressed: () { + setState(() { + _expanded = !_expanded; + }); + }, + ); + } + + void _onFirmwareTap( + RemoteFirmware firmware, + int index, + bool isInstalled, + bool isLatest, + bool isBeta, + ) { + if (isInstalled) { + _showInstalledDialog(firmware, index); + } else if (isBeta) { + _showBetaWarningDialog(firmware, index); + } else if (!isLatest) { + _showOldVersionWarningDialog(firmware, index); + } else { + _installFirmware(firmware, index); + } + } + + void _showInstalledDialog(RemoteFirmware firmware, int index) { + showDialog( + context: context, + builder: (context) => PlatformAlertDialog( + title: PlatformText('Already Installed'), + content: PlatformText( + 'This firmware version is already installed on the device.', ), - if (apps.length > 1) + actions: [ + PlatformTextButton( + onPressed: () => Navigator.of(context).pop(), + child: PlatformText('Cancel'), + ), PlatformTextButton( - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - PlatformText( - _expanded ? 'Hide older versions' : 'Show older versions', - style: TextStyle(color: Colors.black), - ), - SizedBox(width: 8), - Icon( - _expanded - ? Icons.keyboard_arrow_up - : Icons.keyboard_arrow_down, - ), - ], - ), onPressed: () { - setState(() { - _expanded = !_expanded; - }); + _installFirmware(firmware, index); + Navigator.of(context).pop(); }, + child: PlatformText('Install Anyway'), + ), + ], + ), + ); + } + + void _showBetaWarningDialog(RemoteFirmware firmware, int index) { + showDialog( + context: context, + builder: (context) => PlatformAlertDialog( + title: PlatformText('Beta Firmware Warning'), + content: PlatformText( + 'You are about to install beta firmware from a pull request. ' + 'This firmware may be unstable or incomplete. ' + 'Proceed at your own risk.', + ), + actions: [ + PlatformTextButton( + onPressed: () => Navigator.of(context).pop(), + child: PlatformText('Cancel'), ), - Padding( - padding: const EdgeInsets.all(16.0), - child: PlatformElevatedButton( + PlatformTextButton( onPressed: () { - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => BetaFirmwareList(), - ), - ); + _installFirmware(firmware, index); + Navigator.of(context).pop(); }, - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(Icons.bug_report, color: Colors.white), - const SizedBox(width: 8), - PlatformText( - 'Beta Firmware (PR Builds)', - style: TextStyle(color: Colors.white), - ), - ], + child: PlatformText( + 'Install', + style: TextStyle(color: Colors.red), ), ), + ], + ), + ); + } + + void _showOldVersionWarningDialog(RemoteFirmware firmware, int index) { + showDialog( + context: context, + builder: (context) => PlatformAlertDialog( + title: PlatformText('Warning'), + content: PlatformText( + 'You are selecting an old firmware version. We recommend installing the newest version.', ), - ], + actions: [ + PlatformTextButton( + onPressed: () => Navigator.of(context).pop(), + child: PlatformText('Cancel'), + ), + PlatformTextButton( + onPressed: () { + _installFirmware(firmware, index); + Navigator.of(context).pop(); + }, + child: PlatformText('Proceed'), + ), + ], + ), ); } + + void _installFirmware(RemoteFirmware firmware, int index) { + context.read().setFirmware(firmware); + Navigator.pop(context, 'Firmware $index'); + } } diff --git a/open_wearable/lib/widgets/fota/firmware_select/firmware_repository.dart b/open_wearable/lib/widgets/fota/firmware_select/firmware_repository.dart new file mode 100644 index 00000000..17422107 --- /dev/null +++ b/open_wearable/lib/widgets/fota/firmware_select/firmware_repository.dart @@ -0,0 +1,193 @@ +import 'package:open_earable_flutter/open_earable_flutter.dart'; +import 'package:http/http.dart' as http; +import 'dart:convert'; + +enum FirmwareSource { stable, beta } + +class FirmwareEntry { + final RemoteFirmware firmware; + final FirmwareSource source; + + FirmwareEntry({ + required this.firmware, + required this.source, + }); + + bool get isBeta => source == FirmwareSource.beta; + bool get isStable => source == FirmwareSource.stable; +} + +class UnifiedFirmwareRepository { + final FirmwareImageRepository _stableRepository = FirmwareImageRepository(); + + List? _cachedStable; + List? _cachedBeta; + DateTime? _lastFetchTime; + + static const _cacheDuration = Duration(minutes: 15); + + /// Fetch stable releases + Future> getStableFirmwares({ + bool forceRefresh = false, + }) async { + if (!forceRefresh && _cachedStable != null && !_isCacheExpired()) { + return _cachedStable!; + } + + final firmwares = await _stableRepository.getFirmwareImages(); + _cachedStable = firmwares + .map( + (fw) => FirmwareEntry( + firmware: fw, + source: FirmwareSource.stable, + ), + ) + .toList(); + _lastFetchTime = DateTime.now(); + return _cachedStable!; + } + + /// Fetch beta (PR build) releases + Future> getBetaFirmwares({ + bool forceRefresh = false, + }) async { + if (!forceRefresh && _cachedBeta != null && !_isCacheExpired()) { + return _cachedBeta!; + } + + //TODO: change to OpenEarable repo when available + const owner = 'o-bagge'; + + const repo = 'open-earable-2'; + const prereleaseTag = 'pr-builds'; + + try { + final releaseResponse = await http.get( + Uri.parse( + 'https://api.github.com/repos/$owner/$repo/releases/tags/$prereleaseTag', + ), + headers: { + 'Accept': 'application/vnd.github.v3+json', + }, + ); + + if (releaseResponse.statusCode != 200) { + return []; + } + + final releaseJson = + jsonDecode(releaseResponse.body) as Map; + final assets = + (releaseJson['assets'] as List).cast>(); + + final fotaAssets = assets.where((asset) { + final name = asset['name'] as String? ?? ''; + return name.endsWith('fota.zip'); + }).toList(); + + final Map> prMap = {}; + + for (final asset in fotaAssets) { + final name = asset['name'] as String; + final match = RegExp(r'^pr-(\d+)-(.+?)-openearable_v2_fota\.zip$') + .firstMatch(name); + + if (match != null) { + final prNumber = int.parse(match.group(1)!); + final prTitle = match.group(2)!.replaceAll('_', ' '); + + prMap[prNumber] = { + 'asset': asset, + 'title': prTitle, + 'prNumber': prNumber, + }; + } + } + + final result = []; + + for (final entry in prMap.entries) { + final prNumber = entry.key; + final assetData = entry.value; + + //Commented out because GitHub API rate limits are exceeded quickly + /* + final prResponse = await http.get( + Uri.parse( + 'https://api.github.com/repos/$owner/$repo/pulls/$prNumber'), + headers: {'Accept': 'application/vnd.github.v3+json'}, + ); + + if (prResponse.statusCode == 200) { + final prJson = jsonDecode(prResponse.body) as Map; + final prTitle = prJson['title'] as String; + final prSha = prJson['head']['sha'] as String; + final asset = assetData['asset'] as Map; + + result.add(FirmwareEntry( + firmware: RemoteFirmware( + name: prTitle, + url: asset['browser_download_url'] as String, + version: prSha.substring(0, 7), + type: FirmwareType.multiImage, + ), + source: FirmwareSource.beta, + )); + } else { + */ + final asset = assetData['asset'] as Map; + result.add( + FirmwareEntry( + firmware: RemoteFirmware( + name: assetData['title'] + as String, // Already sanitized from filename + url: asset['browser_download_url'] as String, + version: 'PR #$prNumber', + type: FirmwareType.multiImage, + ), + source: FirmwareSource.beta, + ), + ); + } + + result.sort((a, b) { + final aNum = + int.tryParse(a.firmware.version.replaceAll(RegExp(r'[^\d]'), '')) ?? + 0; + final bNum = + int.tryParse(b.firmware.version.replaceAll(RegExp(r'[^\d]'), '')) ?? + 0; + return bNum.compareTo(aNum); + }); + + _cachedBeta = result; + return _cachedBeta!; + } catch (e) { + print('Error fetching beta firmwares: $e'); + return []; + } + } + + bool _isCacheExpired() { + if (_lastFetchTime == null) return true; + return DateTime.now().difference(_lastFetchTime!) > _cacheDuration; + } + + /// Fetch all firmwares (stable + beta) + Future> getAllFirmwares({ + bool includeBeta = false, + }) async { + final stable = await getStableFirmwares(); + if (!includeBeta) { + return stable; + } + final beta = await getBetaFirmwares(); + return [...stable, ...beta]; + } + + void clearCache() { + _cachedStable = null; + _cachedBeta = null; + _lastFetchTime = null; + } +} diff --git a/open_wearable/pubspec.lock b/open_wearable/pubspec.lock index 78870ac3..40a4843a 100644 --- a/open_wearable/pubspec.lock +++ b/open_wearable/pubspec.lock @@ -337,7 +337,7 @@ packages: source: hosted version: "14.8.1" http: - dependency: transitive + dependency: "direct main" description: name: http sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" diff --git a/open_wearable/pubspec.yaml b/open_wearable/pubspec.yaml index 4f1365d1..319f5eb2 100644 --- a/open_wearable/pubspec.yaml +++ b/open_wearable/pubspec.yaml @@ -54,6 +54,7 @@ dependencies: shared_preferences: ^2.5.3 url_launcher: ^6.3.2 go_router: ^14.6.2 + http: ^1.6.0 dev_dependencies: flutter_test: From 051d5f330db86f48598ed2b0f0b928e0648de786 Mon Sep 17 00:00:00 2001 From: Oliver Bagge Date: Wed, 4 Feb 2026 15:14:03 +0100 Subject: [PATCH 3/6] refactor beta channel firmware repository out into open_earable_flutter library --- .../fota/firmware_select/firmware_list.dart | 1 - .../firmware_select/firmware_repository.dart | 193 ------------------ 2 files changed, 194 deletions(-) delete mode 100644 open_wearable/lib/widgets/fota/firmware_select/firmware_repository.dart diff --git a/open_wearable/lib/widgets/fota/firmware_select/firmware_list.dart b/open_wearable/lib/widgets/fota/firmware_select/firmware_list.dart index ba2a8959..dcf520c3 100644 --- a/open_wearable/lib/widgets/fota/firmware_select/firmware_list.dart +++ b/open_wearable/lib/widgets/fota/firmware_select/firmware_list.dart @@ -7,7 +7,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_platform_widgets/flutter_platform_widgets.dart'; import 'package:provider/provider.dart'; import 'package:open_earable_flutter/open_earable_flutter.dart'; -import 'firmware_repository.dart'; class FirmwareList extends StatefulWidget { const FirmwareList({super.key}); diff --git a/open_wearable/lib/widgets/fota/firmware_select/firmware_repository.dart b/open_wearable/lib/widgets/fota/firmware_select/firmware_repository.dart deleted file mode 100644 index 17422107..00000000 --- a/open_wearable/lib/widgets/fota/firmware_select/firmware_repository.dart +++ /dev/null @@ -1,193 +0,0 @@ -import 'package:open_earable_flutter/open_earable_flutter.dart'; -import 'package:http/http.dart' as http; -import 'dart:convert'; - -enum FirmwareSource { stable, beta } - -class FirmwareEntry { - final RemoteFirmware firmware; - final FirmwareSource source; - - FirmwareEntry({ - required this.firmware, - required this.source, - }); - - bool get isBeta => source == FirmwareSource.beta; - bool get isStable => source == FirmwareSource.stable; -} - -class UnifiedFirmwareRepository { - final FirmwareImageRepository _stableRepository = FirmwareImageRepository(); - - List? _cachedStable; - List? _cachedBeta; - DateTime? _lastFetchTime; - - static const _cacheDuration = Duration(minutes: 15); - - /// Fetch stable releases - Future> getStableFirmwares({ - bool forceRefresh = false, - }) async { - if (!forceRefresh && _cachedStable != null && !_isCacheExpired()) { - return _cachedStable!; - } - - final firmwares = await _stableRepository.getFirmwareImages(); - _cachedStable = firmwares - .map( - (fw) => FirmwareEntry( - firmware: fw, - source: FirmwareSource.stable, - ), - ) - .toList(); - _lastFetchTime = DateTime.now(); - return _cachedStable!; - } - - /// Fetch beta (PR build) releases - Future> getBetaFirmwares({ - bool forceRefresh = false, - }) async { - if (!forceRefresh && _cachedBeta != null && !_isCacheExpired()) { - return _cachedBeta!; - } - - //TODO: change to OpenEarable repo when available - const owner = 'o-bagge'; - - const repo = 'open-earable-2'; - const prereleaseTag = 'pr-builds'; - - try { - final releaseResponse = await http.get( - Uri.parse( - 'https://api.github.com/repos/$owner/$repo/releases/tags/$prereleaseTag', - ), - headers: { - 'Accept': 'application/vnd.github.v3+json', - }, - ); - - if (releaseResponse.statusCode != 200) { - return []; - } - - final releaseJson = - jsonDecode(releaseResponse.body) as Map; - final assets = - (releaseJson['assets'] as List).cast>(); - - final fotaAssets = assets.where((asset) { - final name = asset['name'] as String? ?? ''; - return name.endsWith('fota.zip'); - }).toList(); - - final Map> prMap = {}; - - for (final asset in fotaAssets) { - final name = asset['name'] as String; - final match = RegExp(r'^pr-(\d+)-(.+?)-openearable_v2_fota\.zip$') - .firstMatch(name); - - if (match != null) { - final prNumber = int.parse(match.group(1)!); - final prTitle = match.group(2)!.replaceAll('_', ' '); - - prMap[prNumber] = { - 'asset': asset, - 'title': prTitle, - 'prNumber': prNumber, - }; - } - } - - final result = []; - - for (final entry in prMap.entries) { - final prNumber = entry.key; - final assetData = entry.value; - - //Commented out because GitHub API rate limits are exceeded quickly - /* - final prResponse = await http.get( - Uri.parse( - 'https://api.github.com/repos/$owner/$repo/pulls/$prNumber'), - headers: {'Accept': 'application/vnd.github.v3+json'}, - ); - - if (prResponse.statusCode == 200) { - final prJson = jsonDecode(prResponse.body) as Map; - final prTitle = prJson['title'] as String; - final prSha = prJson['head']['sha'] as String; - final asset = assetData['asset'] as Map; - - result.add(FirmwareEntry( - firmware: RemoteFirmware( - name: prTitle, - url: asset['browser_download_url'] as String, - version: prSha.substring(0, 7), - type: FirmwareType.multiImage, - ), - source: FirmwareSource.beta, - )); - } else { - */ - final asset = assetData['asset'] as Map; - result.add( - FirmwareEntry( - firmware: RemoteFirmware( - name: assetData['title'] - as String, // Already sanitized from filename - url: asset['browser_download_url'] as String, - version: 'PR #$prNumber', - type: FirmwareType.multiImage, - ), - source: FirmwareSource.beta, - ), - ); - } - - result.sort((a, b) { - final aNum = - int.tryParse(a.firmware.version.replaceAll(RegExp(r'[^\d]'), '')) ?? - 0; - final bNum = - int.tryParse(b.firmware.version.replaceAll(RegExp(r'[^\d]'), '')) ?? - 0; - return bNum.compareTo(aNum); - }); - - _cachedBeta = result; - return _cachedBeta!; - } catch (e) { - print('Error fetching beta firmwares: $e'); - return []; - } - } - - bool _isCacheExpired() { - if (_lastFetchTime == null) return true; - return DateTime.now().difference(_lastFetchTime!) > _cacheDuration; - } - - /// Fetch all firmwares (stable + beta) - Future> getAllFirmwares({ - bool includeBeta = false, - }) async { - final stable = await getStableFirmwares(); - if (!includeBeta) { - return stable; - } - final beta = await getBetaFirmwares(); - return [...stable, ...beta]; - } - - void clearCache() { - _cachedStable = null; - _cachedBeta = null; - _lastFetchTime = null; - } -} From 298b5c4e02986af74a9a3695e9ac42723dc1ee98 Mon Sep 17 00:00:00 2001 From: Oliver Bagge Date: Wed, 4 Feb 2026 15:33:42 +0100 Subject: [PATCH 4/6] update version of open_earable_flutter to 2.3.1 --- .../Flutter/GeneratedPluginRegistrant.swift | 2 - open_wearable/pubspec.lock | 88 ++++++++++++++----- open_wearable/pubspec.yaml | 2 +- 3 files changed, 69 insertions(+), 23 deletions(-) diff --git a/open_wearable/macos/Flutter/GeneratedPluginRegistrant.swift b/open_wearable/macos/Flutter/GeneratedPluginRegistrant.swift index a3e457d4..7da018f9 100644 --- a/open_wearable/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/open_wearable/macos/Flutter/GeneratedPluginRegistrant.swift @@ -9,7 +9,6 @@ import file_picker import file_selector_macos import flutter_archive import open_file_mac -import path_provider_foundation import share_plus import shared_preferences_foundation import universal_ble @@ -20,7 +19,6 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin")) FlutterArchivePlugin.register(with: registry.registrar(forPlugin: "FlutterArchivePlugin")) OpenFilePlugin.register(with: registry.registrar(forPlugin: "OpenFilePlugin")) - PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) UniversalBlePlugin.register(with: registry.registrar(forPlugin: "UniversalBlePlugin")) diff --git a/open_wearable/pubspec.lock b/open_wearable/pubspec.lock index 40a4843a..034ce0e3 100644 --- a/open_wearable/pubspec.lock +++ b/open_wearable/pubspec.lock @@ -57,6 +57,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.2" + code_assets: + dependency: transitive + description: + name: code_assets + sha256: "83ccdaa064c980b5596c35dd64a8d3ecc68620174ab9b90b6343b753aa721687" + url: "https://pub.dev" + source: hosted + version: "1.0.0" collection: dependency: transitive description: @@ -93,10 +101,10 @@ packages: dependency: transitive description: name: cross_file - sha256: "701dcfc06da0882883a2657c445103380e53e647060ad8d9dfb710c100996608" + sha256: "28bb3ae56f117b5aec029d702a90f57d285cd975c3c5c281eaca38dbc47c5937" url: "https://pub.dev" source: hosted - version: "0.3.5+1" + version: "0.3.5+2" crypto: dependency: transitive description: @@ -157,10 +165,10 @@ packages: dependency: "direct main" description: name: file_picker - sha256: d974b6ba2606371ac71dd94254beefb6fa81185bde0b59bdc1df09885da85fde + sha256: "57d9a1dd5063f85fa3107fb42d1faffda52fdc948cefd5fe5ea85267a5fc7343" url: "https://pub.dev" source: hosted - version: "10.3.8" + version: "10.3.10" file_selector: dependency: "direct main" description: @@ -328,6 +336,14 @@ packages: description: flutter source: sdk version: "0.0.0" + glob: + dependency: transitive + description: + name: glob + sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de + url: "https://pub.dev" + source: hosted + version: "2.1.3" go_router: dependency: "direct main" description: @@ -336,6 +352,14 @@ packages: url: "https://pub.dev" source: hosted version: "14.8.1" + hooks: + dependency: transitive + description: + name: hooks + sha256: "7a08a0d684cb3b8fb604b78455d5d352f502b68079f7b80b831c62220ab0a4f6" + url: "https://pub.dev" + source: hosted + version: "1.0.1" http: dependency: "direct main" description: @@ -364,10 +388,10 @@ packages: dependency: transitive description: name: json_annotation - sha256: "1ce844379ca14835a50d2f019a3099f419082cfdd231cd86a142af94dd5c6bb1" + sha256: "805fa86df56383000f640384b282ce0cb8431f1a7a2396de92fb66186d8c57df" url: "https://pub.dev" source: hosted - version: "4.9.0" + version: "4.10.0" leak_tracker: dependency: transitive description: @@ -396,10 +420,10 @@ packages: dependency: transitive description: name: lints - sha256: a5e2b223cb7c9c8efdc663ef484fdd95bb243bff242ef5b13e26883547fce9a0 + sha256: "12f842a479589fea194fe5c5a3095abc7be0c1f2ddfa9a0e76aed1dbd26a87df" url: "https://pub.dev" source: hosted - version: "6.0.0" + version: "6.1.0" logger: dependency: "direct main" description: @@ -456,6 +480,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.0" + native_toolchain_c: + dependency: transitive + description: + name: native_toolchain_c + sha256: "89e83885ba09da5fdf2cdacc8002a712ca238c28b7f717910b34bcd27b0d03ac" + url: "https://pub.dev" + source: hosted + version: "0.17.4" nested: dependency: transitive description: @@ -464,14 +496,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.0" + objective_c: + dependency: transitive + description: + name: objective_c + sha256: "983c7fa1501f6dcc0cb7af4e42072e9993cb28d73604d25ebf4dab08165d997e" + url: "https://pub.dev" + source: hosted + version: "9.2.5" open_earable_flutter: dependency: "direct main" description: name: open_earable_flutter - sha256: beec110a534837dedec5ab92118824def851d9a712d1f749b486affef0af9d7d + sha256: "23b784abdb9aa2a67afd6bcf22778cc9e3d124eba5a4d02f49443581fa3f8958" url: "https://pub.dev" source: hosted - version: "2.3.0" + version: "2.3.1" open_file: dependency: "direct main" description: @@ -572,10 +612,10 @@ packages: dependency: transitive description: name: path_provider_foundation - sha256: "6d13aece7b3f5c5a9731eaf553ff9dcbc2eff41087fd2df587fd0fed9a3eb0c4" + sha256: "2a376b7d6392d80cd3705782d2caa734ca4727776db0b6ec36ef3f1855197699" url: "https://pub.dev" source: hosted - version: "2.5.1" + version: "2.6.0" path_provider_linux: dependency: transitive description: @@ -732,10 +772,10 @@ packages: dependency: transitive description: name: shared_preferences_android - sha256: "83af5c682796c0f7719c2bbf74792d113e40ae97981b8f266fa84574573556bc" + sha256: cbc40be9be1c5af4dab4d6e0de4d5d3729e6f3d65b89d21e1815d57705644a6f url: "https://pub.dev" source: hosted - version: "2.4.18" + version: "2.4.20" shared_preferences_foundation: dependency: transitive description: @@ -905,10 +945,10 @@ packages: dependency: transitive description: name: url_launcher_web - sha256: "4bd2b7b4dc4d4d0b94e5babfffbca8eac1a126c7f3d6ecbc1a11013faa3abba2" + sha256: d0412fcf4c6b31ecfdb7762359b7206ffba3bbffd396c6d9f9c4616ece476c1f url: "https://pub.dev" source: hosted - version: "2.4.1" + version: "2.4.2" url_launcher_windows: dependency: transitive description: @@ -945,10 +985,10 @@ packages: dependency: transitive description: name: vector_graphics_compiler - sha256: d354a7ec6931e6047785f4db12a1f61ec3d43b207fc0790f863818543f8ff0dc + sha256: "201e876b5d52753626af64b6359cd13ac6011b80728731428fd34bc840f71c9b" url: "https://pub.dev" source: hosted - version: "1.1.19" + version: "1.1.20" vector_math: dependency: transitive description: @@ -997,6 +1037,14 @@ packages: url: "https://pub.dev" source: hosted version: "6.6.1" + yaml: + dependency: transitive + description: + name: yaml + sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce + url: "https://pub.dev" + source: hosted + version: "3.1.3" sdks: - dart: ">=3.10.0 <4.0.0" - flutter: ">=3.38.0" + dart: ">=3.10.3 <4.0.0" + flutter: ">=3.38.4" diff --git a/open_wearable/pubspec.yaml b/open_wearable/pubspec.yaml index 319f5eb2..b16076c9 100644 --- a/open_wearable/pubspec.yaml +++ b/open_wearable/pubspec.yaml @@ -35,7 +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: ^2.3.0 + open_earable_flutter: ^2.3.1 flutter_platform_widgets: ^9.0.0 provider: ^6.1.2 logger: ^2.5.0 From a70998568a299a7c5cadea9352a8f72d559cf786 Mon Sep 17 00:00:00 2001 From: Oliver Bagge Date: Wed, 4 Feb 2026 15:37:07 +0100 Subject: [PATCH 5/6] add missing trailing comma and replace deprecated opacity function --- .../widgets/fota/firmware_select/firmware_list.dart | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/open_wearable/lib/widgets/fota/firmware_select/firmware_list.dart b/open_wearable/lib/widgets/fota/firmware_select/firmware_list.dart index dcf520c3..b526c687 100644 --- a/open_wearable/lib/widgets/fota/firmware_select/firmware_list.dart +++ b/open_wearable/lib/widgets/fota/firmware_select/firmware_list.dart @@ -38,8 +38,9 @@ class _FirmwareListState extends State { Provider.of(context, listen: false) .selectedWearable; if (wearable != null && wearable.hasCapability()) { - final version = - await wearable.requireCapability().readDeviceFirmwareVersion(); + final version = await wearable + .requireCapability() + .readDeviceFirmwareVersion(); setState(() { firmwareVersion = version; }); @@ -193,7 +194,7 @@ class _FirmwareListState extends State { return Container( width: double.infinity, padding: const EdgeInsets.all(12), - color: Colors.orange.withOpacity(0.2), + color: Colors.orange.withValues(alpha: 0.2), child: Row( children: [ Icon(Icons.warning, color: Colors.orange, size: 20), @@ -214,7 +215,10 @@ class _FirmwareListState extends State { } Widget _firmwareListItem( - FirmwareEntry entry, FirmwareEntry? latestStable, int index) { + FirmwareEntry entry, + FirmwareEntry? latestStable, + int index, + ) { final firmware = entry.firmware; final isBeta = entry.isBeta; final isLatestStable = entry == latestStable; From 20e5447f248e14a5068c734fc24a8b570afbe356 Mon Sep 17 00:00:00 2001 From: Dennis Moschina <45356478+DennisMoschina@users.noreply.github.com> Date: Wed, 4 Feb 2026 15:58:48 +0100 Subject: [PATCH 6/6] open_wearable/lib/view_models/wearables_provider.dart: fixed formatting --- .../lib/view_models/wearables_provider.dart | 82 +++++++++++++------ 1 file changed, 55 insertions(+), 27 deletions(-) diff --git a/open_wearable/lib/view_models/wearables_provider.dart b/open_wearable/lib/view_models/wearables_provider.dart index d7453002..1eb50be0 100644 --- a/open_wearable/lib/view_models/wearables_provider.dart +++ b/open_wearable/lib/view_models/wearables_provider.dart @@ -54,8 +54,8 @@ class WearableTimeSynchronizedEvent extends WearableEvent { required super.wearable, String? description, }) : super( - description: - description ?? 'Time synchronized for ${wearable.name}'); + description: description ?? 'Time synchronized for ${wearable.name}', + ); @override String toString() => 'WearableTimeSynchronizedEvent for ${wearable.name}'; @@ -68,15 +68,15 @@ class WearableErrorEvent extends WearableEvent { required this.errorMessage, String? description, }) : super( - description: - description ?? 'Error for ${wearable.name}: $errorMessage'); + description: + description ?? 'Error for ${wearable.name}: $errorMessage', + ); @override String toString() => 'WearableErrorEvent for ${wearable.name}: $errorMessage, description: $description'; } - // MARK: WearablesProvider class WearablesProvider with ChangeNotifier { @@ -139,7 +139,8 @@ class WearablesProvider with ChangeNotifier { }) async { try { logger.d('Synchronizing time for wearable ${wearable.name}'); - await (wearable.requireCapability()).synchronizeTime(); + await (wearable.requireCapability()) + .synchronizeTime(); logger.d('Time synchronized for wearable ${wearable.name}'); _emitWearableEvent( WearableTimeSynchronizedEvent( @@ -148,7 +149,9 @@ class WearablesProvider with ChangeNotifier { ), ); } catch (e, st) { - logger.w('Failed to synchronize time for wearable ${wearable.name}: $e\n$st'); + logger.w( + 'Failed to synchronize time for wearable ${wearable.name}: $e\n$st', + ); _emitWearableError( wearable: wearable, errorMessage: 'Failed to synchronize time with ${wearable.name}: $e', @@ -163,8 +166,12 @@ class WearablesProvider with ChangeNotifier { _wearables.add(wearable); - _capabilitySubscriptions[wearable] = wearable.capabilityRegistered.listen((addedCapabilities) { - _handleCapabilitiesChanged(wearable: wearable, addedCapabilites: addedCapabilities); + _capabilitySubscriptions[wearable] = + wearable.capabilityRegistered.listen((addedCapabilities) { + _handleCapabilitiesChanged( + wearable: wearable, + addedCapabilites: addedCapabilities, + ); }); // Init SensorConfigurationProvider synchronously (no awaits here) @@ -172,7 +179,8 @@ class WearablesProvider with ChangeNotifier { _ensureSensorConfigProvider(wearable); final notifier = _sensorConfigurationProviders[wearable]!; for (final config - in (wearable.requireCapability()).sensorConfigurations) { + in (wearable.requireCapability()) + .sensorConfigurations) { if (notifier.getSelectedConfigurationValue(config) == null && config.values.isNotEmpty) { notifier.addSensorConfiguration(config, config.values.first); @@ -180,11 +188,13 @@ class WearablesProvider with ChangeNotifier { } } if (wearable.hasCapability()) { - _scheduleMicrotask(() => _syncTimeAndEmit( - wearable: wearable, - successDescription: 'Time synchronized for ${wearable.name}', - failureDescription: 'Failed to synchronize time for ${wearable.name}', - ),); + _scheduleMicrotask( + () => _syncTimeAndEmit( + wearable: wearable, + successDescription: 'Time synchronized for ${wearable.name}', + failureDescription: 'Failed to synchronize time for ${wearable.name}', + ), + ); } // Disconnect listener (sync) @@ -199,17 +209,29 @@ class WearablesProvider with ChangeNotifier { // 2) Slow/async work: run in microtasks so it doesn't block the add // Stereo pairing (if applicable) if (wearable.hasCapability()) { - _scheduleMicrotask(() => _maybeAutoPairStereoAsync(wearable.requireCapability())); + _scheduleMicrotask( + () => _maybeAutoPairStereoAsync( + wearable.requireCapability(), + ), + ); } // Firmware support check (if applicable) if (wearable.hasCapability()) { - _scheduleMicrotask(() => _maybeEmitUnsupportedFirmwareAsync(wearable.requireCapability())); + _scheduleMicrotask( + () => _maybeEmitUnsupportedFirmwareAsync( + wearable.requireCapability(), + ), + ); } // Check for newer firmware (if applicable) if (wearable.hasCapability()) { - _scheduleMicrotask(() => _checkForNewerFirmwareAsync(wearable.requireCapability())); + _scheduleMicrotask( + () => _checkForNewerFirmwareAsync( + wearable.requireCapability(), + ), + ); } } @@ -218,7 +240,8 @@ class WearablesProvider with ChangeNotifier { void _ensureSensorConfigProvider(Wearable wearable) { if (!_sensorConfigurationProviders.containsKey(wearable)) { _sensorConfigurationProviders[wearable] = SensorConfigurationProvider( - sensorConfigurationManager: wearable.requireCapability(), + sensorConfigurationManager: + wearable.requireCapability(), ); } } @@ -343,18 +366,23 @@ class WearablesProvider with ChangeNotifier { return _sensorConfigurationProviders[wearable]!; } - void _handleCapabilitiesChanged({required Wearable wearable, required List addedCapabilites}) { + void _handleCapabilitiesChanged({ + required Wearable wearable, + required List addedCapabilites, + }) { if (addedCapabilites.contains(SensorConfigurationManager)) { _ensureSensorConfigProvider(wearable); } if (addedCapabilites.contains(TimeSynchronizable)) { - _scheduleMicrotask(() => _syncTimeAndEmit( - wearable: wearable, - successDescription: - 'Time synchronized for ${wearable.name} after capability change', - failureDescription: - 'Failed to synchronize time for ${wearable.name} after capability change', - ),); + _scheduleMicrotask( + () => _syncTimeAndEmit( + wearable: wearable, + successDescription: + 'Time synchronized for ${wearable.name} after capability change', + failureDescription: + 'Failed to synchronize time for ${wearable.name} after capability change', + ), + ); } } }