From 7b4c98a933b46d7be30db3b81862d70093b813ed Mon Sep 17 00:00:00 2001 From: forest Date: Sun, 10 May 2026 20:10:47 +0800 Subject: [PATCH 1/5] =?UTF-8?q?feat:=E6=9B=B4=E6=96=B0=20mac=20=E8=B0=83?= =?UTF-8?q?=E7=94=A8=E7=A4=BA=E4=BE=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- example/lib/main.dart | 148 +++++++++++++++--- example/lib/pages/dashboard_page.dart | 69 ++++++-- example/lib/perflab/perflab_channel.dart | 8 +- .../platform/example_platform_adapter.dart | 113 +++++++++++++ example/test/widget_test.dart | 54 ++++--- 5 files changed, 326 insertions(+), 66 deletions(-) create mode 100644 example/lib/platform/example_platform_adapter.dart diff --git a/example/lib/main.dart b/example/lib/main.dart index e17c46f..8b6849e 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -10,6 +10,7 @@ import 'pages/event_stream_page.dart'; import 'pages/iot_controls_page.dart'; import 'pages/low_battery_notification_page.dart'; import 'perflab/perflab_channel.dart'; +import 'platform/example_platform_adapter.dart'; import 'role_selection_page.dart'; import 'routes.dart'; import 'startup_trace.dart'; @@ -28,25 +29,35 @@ class FlutterBatteryExampleApp extends StatefulWidget { const FlutterBatteryExampleApp({super.key}); @override - State createState() => _FlutterBatteryExampleAppState(); + State createState() => + _FlutterBatteryExampleAppState(); } class _FlutterBatteryExampleAppState extends State { + final GlobalKey _navigatorKey = GlobalKey(); + final ExamplePlatformAdapter _platform = ExamplePlatformAdapter.current(); final FlutterBattery _plugin = FlutterBattery(); final ValueNotifier _levelListenable = ValueNotifier(null); - final ValueNotifier _infoListenable = ValueNotifier(null); - final ValueNotifier _healthListenable = ValueNotifier(null); - final ValueNotifier> _iotEventsListenable = ValueNotifier>([]); + final ValueNotifier _infoListenable = + ValueNotifier(null); + final ValueNotifier _healthListenable = + ValueNotifier(null); + final ValueNotifier> _iotEventsListenable = + ValueNotifier>([]); int? _batteryLevel; BatteryInfo? _batteryInfo; BatteryHealth? _batteryHealth; - static const MethodChannel _iotMethod = MethodChannel('iot/native'); - static const EventChannel _iotEvent = EventChannel('iot/stream'); StreamSubscription? _iotSub; List _iotEvents = []; + FeatureAvailability get _peerBatterySyncAvailability => + _platform.availabilityFor(ExampleFeature.peerBatterySync); + + FeatureAvailability get _iotNativeControlsAvailability => + _platform.availabilityFor(ExampleFeature.iotNativeControls); + @override void initState() { super.initState(); @@ -91,7 +102,7 @@ class _FlutterBatteryExampleAppState extends State { // IoT section: demo EventChannel/MethodChannel usage unrelated to battery. void _listenToIotEvents() { - _iotSub = _iotEvent.receiveBroadcastStream().listen((dynamic e) { + _iotSub = _platform.iotEvents.listen((dynamic e) { _recordIotEvent('event', e); }, onError: (Object err) { _recordIotEvent('error', err); @@ -99,6 +110,7 @@ class _FlutterBatteryExampleAppState extends State { } void _recordIotEvent(String kind, Object? payload) { + if (!mounted) return; final stamp = DateTime.now().toIso8601String().substring(11, 19); final entry = '$stamp $kind: $payload'; setState(() { @@ -126,16 +138,36 @@ class _FlutterBatteryExampleAppState extends State { } } - Future _startScan() => _iotMethod.invokeMethod('scanDevices'); - Future _stopScan() => _iotMethod.invokeMethod('stopScan'); - Future _connect() => _iotMethod.invokeMethod('connect', {'deviceId': 'demo-001'}); - Future _disconnect() => _iotMethod.invokeMethod('disconnect'); - Future _startSync() => _iotMethod.invokeMethod('startSync'); - Future _stopSync() => _iotMethod.invokeMethod('stopSync'); + Future _startScan() => _invokeIotMethod('scanDevices'); + Future _stopScan() => _invokeIotMethod('stopScan'); + Future _connect() => + _invokeIotMethod('connect', {'deviceId': 'demo-001'}); + Future _disconnect() => _invokeIotMethod('disconnect'); + Future _startSync() => _invokeIotMethod('startSync'); + Future _stopSync() => _invokeIotMethod('stopSync'); + + Future _invokeIotMethod(String method, [Object? arguments]) async { + if (!_iotNativeControlsAvailability.isSupported) { + _showUnsupportedFeatureMessage(_iotNativeControlsAvailability); + return; + } + try { + await _platform.invokeIotMethod(method, arguments); + } on MissingPluginException catch (err) { + _recordIotEvent('error', err); + _showUnsupportedFeatureMessage(_iotNativeControlsAvailability); + } on UnsupportedPlatformFeatureException catch (err) { + _recordIotEvent('error', err); + _showUnsupportedFeatureMessage(_iotNativeControlsAvailability); + } on PlatformException catch (err) { + _recordIotEvent('error', err); + } + } @override Widget build(BuildContext context) { return MaterialApp( + navigatorKey: _navigatorKey, initialRoute: AppRoutes.dashboard, onGenerateRoute: _onGenerateRoute, ); @@ -161,24 +193,35 @@ class _FlutterBatteryExampleAppState extends State { case AppRoutes.peerSelection: return MaterialPageRoute( settings: settings, - builder: (_) => const RoleSelectionPage(), + builder: (_) => _peerBatterySyncAvailability.isSupported + ? const RoleSelectionPage() + : _UnsupportedFeaturePage( + title: '蓝牙电量同步', + availability: _peerBatterySyncAvailability, + ), ); case AppRoutes.iotControls: return MaterialPageRoute( settings: settings, - builder: (_) => IotControlsPage( - startScan: _startScan, - stopScan: _stopScan, - connect: _connect, - disconnect: _disconnect, - startSync: _startSync, - stopSync: _stopSync, - ), + builder: (_) => _iotNativeControlsAvailability.isSupported + ? IotControlsPage( + startScan: _startScan, + stopScan: _stopScan, + connect: _connect, + disconnect: _disconnect, + startSync: _startSync, + stopSync: _stopSync, + ) + : _UnsupportedFeaturePage( + title: 'IoT native controls', + availability: _iotNativeControlsAvailability, + ), ); case AppRoutes.eventLog: return MaterialPageRoute( settings: settings, - builder: (_) => EventStreamPage(eventsListenable: _iotEventsListenable), + builder: (_) => + EventStreamPage(eventsListenable: _iotEventsListenable), ); case AppRoutes.dashboard: default: @@ -192,6 +235,8 @@ class _FlutterBatteryExampleAppState extends State { eventCount: _iotEvents.length, onRefresh: _refresh, onBootstrap: _bootstrapBattery, + peerBatterySyncAvailability: _peerBatterySyncAvailability, + iotNativeControlsAvailability: _iotNativeControlsAvailability, onOpenBatteryDetails: () => _pushNamed(AppRoutes.batteryDetails), onOpenLowBatteryAlerts: () => _pushNamed(AppRoutes.lowBattery), onOpenPeerBatterySync: () => _pushNamed(AppRoutes.peerSelection), @@ -203,6 +248,61 @@ class _FlutterBatteryExampleAppState extends State { } void _pushNamed(String route) { - Navigator.of(context).pushNamed(route); + final navigator = _navigatorKey.currentState; + if (navigator == null) return; + + final availability = _availabilityForRoute(route); + if (availability != null && !availability.isSupported) { + _showUnsupportedFeatureMessage(availability); + return; + } + + navigator.pushNamed(route); + } + + FeatureAvailability? _availabilityForRoute(String route) { + switch (route) { + case AppRoutes.peerSelection: + return _peerBatterySyncAvailability; + case AppRoutes.iotControls: + return _iotNativeControlsAvailability; + default: + return null; + } + } + + void _showUnsupportedFeatureMessage(FeatureAvailability availability) { + final scaffoldMessenger = _navigatorKey.currentContext == null + ? null + : ScaffoldMessenger.maybeOf(_navigatorKey.currentContext!); + scaffoldMessenger?.showSnackBar( + SnackBar(content: Text(availability.details)), + ); + } +} + +class _UnsupportedFeaturePage extends StatelessWidget { + const _UnsupportedFeaturePage({ + required this.title, + required this.availability, + }); + + final String title; + final FeatureAvailability availability; + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: Text(title)), + body: Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Text( + availability.details, + textAlign: TextAlign.center, + ), + ), + ), + ); } } diff --git a/example/lib/pages/dashboard_page.dart b/example/lib/pages/dashboard_page.dart index 49ba40f..39af899 100644 --- a/example/lib/pages/dashboard_page.dart +++ b/example/lib/pages/dashboard_page.dart @@ -5,6 +5,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_battery/flutter_battery.dart'; import '../perflab/perflab_channel.dart'; +import '../platform/example_platform_adapter.dart'; import '../startup_trace.dart'; bool _startupFirstBuildLogged = false; @@ -22,6 +23,8 @@ class DashboardPage extends StatefulWidget { required this.eventCount, required this.onRefresh, required this.onBootstrap, + required this.peerBatterySyncAvailability, + required this.iotNativeControlsAvailability, required this.onOpenBatteryDetails, required this.onOpenLowBatteryAlerts, required this.onOpenPeerBatterySync, @@ -36,6 +39,8 @@ class DashboardPage extends StatefulWidget { final int eventCount; final Future Function() onRefresh; final VoidCallback onBootstrap; + final FeatureAvailability peerBatterySyncAvailability; + final FeatureAvailability iotNativeControlsAvailability; final VoidCallback onOpenBatteryDetails; final VoidCallback onOpenLowBatteryAlerts; final VoidCallback onOpenPeerBatterySync; @@ -136,7 +141,8 @@ class _DashboardPageState extends State { ), _MetricChip( icon: Icons.shield_outlined, - label: widget.batteryHealth?.riskLevel ?? 'Health unknown', + label: + widget.batteryHealth?.riskLevel ?? 'Health unknown', ), ], ), @@ -151,7 +157,8 @@ class _DashboardPageState extends State { ListTile( leading: const Icon(Icons.battery_std_outlined), title: const Text('Battery details'), - subtitle: const Text('Level, state, health, temperature, and manual refresh'), + subtitle: const Text( + 'Level, state, health, temperature, and manual refresh'), trailing: const Icon(Icons.chevron_right), onTap: widget.onOpenBatteryDetails, ), @@ -167,23 +174,42 @@ class _DashboardPageState extends State { ListTile( leading: const Icon(Icons.hub_outlined), title: const Text('蓝牙电量同步'), - subtitle: const Text('选择主/从机后进行电量互通'), - trailing: const Icon(Icons.chevron_right), - onTap: widget.onOpenPeerBatterySync, + subtitle: Text( + widget.peerBatterySyncAvailability.isSupported + ? '选择主/从机后进行电量互通' + : widget.peerBatterySyncAvailability.disabledLabel, + ), + trailing: widget.peerBatterySyncAvailability.isSupported + ? const Icon(Icons.chevron_right) + : const Icon(Icons.block_outlined), + enabled: widget.peerBatterySyncAvailability.isSupported, + onTap: widget.peerBatterySyncAvailability.isSupported + ? widget.onOpenPeerBatterySync + : null, ), const Divider(height: 1), ListTile( leading: const Icon(Icons.memory_outlined), title: const Text('IoT native controls'), - subtitle: const Text('Scan, connect, and sync via MethodChannel'), - trailing: const Icon(Icons.chevron_right), - onTap: widget.onOpenIotControls, + subtitle: Text( + widget.iotNativeControlsAvailability.isSupported + ? 'Scan, connect, and sync via MethodChannel' + : widget.iotNativeControlsAvailability.disabledLabel, + ), + trailing: widget.iotNativeControlsAvailability.isSupported + ? const Icon(Icons.chevron_right) + : const Icon(Icons.block_outlined), + enabled: widget.iotNativeControlsAvailability.isSupported, + onTap: widget.iotNativeControlsAvailability.isSupported + ? widget.onOpenIotControls + : null, ), const Divider(height: 1), ListTile( leading: const Icon(Icons.event_note_outlined), title: const Text('Event stream log'), - subtitle: Text('${widget.eventCount} recent entries from iot/stream'), + subtitle: Text( + '${widget.eventCount} recent entries from iot/stream'), trailing: const Icon(Icons.chevron_right), onTap: widget.onOpenEventLog, ), @@ -303,7 +329,8 @@ class _BatteryGaugePainter extends CustomPainter { return; } final center = Offset(size.width / 2, size.height / 2); - final baseRadius = math.max(56.0, (math.min(size.width, size.height) / 2 - 12)); + final baseRadius = + math.max(56.0, (math.min(size.width, size.height) / 2 - 12)); final middleRadius = math.max(40.0, baseRadius - 30); final innerRadius = math.max(32.0, baseRadius - 60); final levelRatio = _clamp01(level / 100); @@ -384,7 +411,8 @@ class _BatteryGaugePainter extends CustomPainter { final valuePainter = TextPainter( text: TextSpan( text: value, - style: TextStyle(color: color, fontSize: 13, fontWeight: FontWeight.w600), + style: + TextStyle(color: color, fontSize: 13, fontWeight: FontWeight.w600), ), textAlign: TextAlign.center, textDirection: TextDirection.ltr, @@ -392,7 +420,8 @@ class _BatteryGaugePainter extends CustomPainter { final offsetY = center.dy + radius - thickness / 2 - 10; textPainter.paint( canvas, - Offset(center.dx - textPainter.width / 2, offsetY - textPainter.height - 2), + Offset( + center.dx - textPainter.width / 2, offsetY - textPainter.height - 2), ); valuePainter.paint( canvas, @@ -439,21 +468,27 @@ class _BatteryGaugePainter extends CustomPainter { final healthPainter = TextPainter( text: TextSpan( text: healthLabel, - style: TextStyle(color: healthColor, fontSize: 13, fontWeight: FontWeight.w600), + style: TextStyle( + color: healthColor, fontSize: 13, fontWeight: FontWeight.w600), ), textDirection: TextDirection.ltr, textAlign: TextAlign.center, )..layout(maxWidth: 240); - final startY = center.dy - (levelPainter.height + statePainter.height + healthPainter.height + 8) / 2; - levelPainter.paint(canvas, Offset(center.dx - levelPainter.width / 2, startY)); + final startY = center.dy - + (levelPainter.height + statePainter.height + healthPainter.height + 8) / + 2; + levelPainter.paint( + canvas, Offset(center.dx - levelPainter.width / 2, startY)); statePainter.paint( canvas, - Offset(center.dx - statePainter.width / 2, startY + levelPainter.height + 4), + Offset( + center.dx - statePainter.width / 2, startY + levelPainter.height + 4), ); healthPainter.paint( canvas, - Offset(center.dx - healthPainter.width / 2, startY + levelPainter.height + statePainter.height + 8), + Offset(center.dx - healthPainter.width / 2, + startY + levelPainter.height + statePainter.height + 8), ); } diff --git a/example/lib/perflab/perflab_channel.dart b/example/lib/perflab/perflab_channel.dart index 49e7e1f..6c6c8c7 100644 --- a/example/lib/perflab/perflab_channel.dart +++ b/example/lib/perflab/perflab_channel.dart @@ -33,7 +33,11 @@ class PerfLabChannel { static Future> getStartupTimeline() async { if (kReleaseMode) return {}; - final res = await _ch.invokeMethod('getStartupTimeline'); - return (res as Map).cast(); + try { + final res = await _ch.invokeMethod('getStartupTimeline'); + return (res as Map).cast(); + } catch (_) { + return {}; + } } } diff --git a/example/lib/platform/example_platform_adapter.dart b/example/lib/platform/example_platform_adapter.dart new file mode 100644 index 0000000..c6f12d7 --- /dev/null +++ b/example/lib/platform/example_platform_adapter.dart @@ -0,0 +1,113 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; + +enum ExampleFeature { + peerBatterySync, + iotNativeControls, +} + +class FeatureAvailability { + const FeatureAvailability.supported() + : isSupported = true, + disabledLabel = '', + details = ''; + + const FeatureAvailability.unsupported({ + required this.disabledLabel, + required this.details, + }) : isSupported = false; + + final bool isSupported; + final String disabledLabel; + final String details; +} + +class UnsupportedPlatformFeatureException implements Exception { + UnsupportedPlatformFeatureException(this.message); + + final String message; + + @override + String toString() => message; +} + +abstract class ExamplePlatformAdapter { + const ExamplePlatformAdapter(); + + String get platformName; + + FeatureAvailability availabilityFor(ExampleFeature feature); + + Stream get iotEvents; + + Future invokeIotMethod(String method, [Object? arguments]); + + static ExamplePlatformAdapter current() { + if (!kIsWeb && defaultTargetPlatform == TargetPlatform.android) { + return const AndroidExamplePlatformAdapter(); + } + return UnsupportedExamplePlatformAdapter( + platformName: defaultTargetPlatform.name); + } +} + +class AndroidExamplePlatformAdapter extends ExamplePlatformAdapter { + const AndroidExamplePlatformAdapter(); + + static const MethodChannel _iotMethod = MethodChannel('iot/native'); + static const EventChannel _iotEvent = EventChannel('iot/stream'); + + @override + String get platformName => 'android'; + + @override + FeatureAvailability availabilityFor(ExampleFeature feature) { + return const FeatureAvailability.supported(); + } + + @override + Stream get iotEvents => _iotEvent.receiveBroadcastStream(); + + @override + Future invokeIotMethod(String method, [Object? arguments]) { + return _iotMethod.invokeMethod(method, arguments); + } +} + +class UnsupportedExamplePlatformAdapter extends ExamplePlatformAdapter { + const UnsupportedExamplePlatformAdapter({required this.platformName}); + + @override + final String platformName; + + @override + FeatureAvailability availabilityFor(ExampleFeature feature) { + return FeatureAvailability.unsupported( + disabledLabel: '当前平台不可用', + details: '${_featureName(feature)} 仅支持 Android 原生桥接,当前平台为 $platformName。', + ); + } + + @override + Stream get iotEvents { + return Stream.value( + 'IoT native controls are Android-only. Current platform: $platformName.', + ); + } + + @override + Future invokeIotMethod(String method, [Object? arguments]) { + throw UnsupportedPlatformFeatureException( + availabilityFor(ExampleFeature.iotNativeControls).details, + ); + } + + String _featureName(ExampleFeature feature) { + switch (feature) { + case ExampleFeature.peerBatterySync: + return '蓝牙电量同步'; + case ExampleFeature.iotNativeControls: + return 'IoT native controls'; + } + } +} diff --git a/example/test/widget_test.dart b/example/test/widget_test.dart index 306f523..8eaf7af 100644 --- a/example/test/widget_test.dart +++ b/example/test/widget_test.dart @@ -1,30 +1,38 @@ -// This is a basic Flutter widget test. -// -// To perform an interaction with a widget in your test, use the WidgetTester -// utility in the flutter_test package. For example, you can send tap and scroll -// gestures. You can also use WidgetTester to find child widgets in the widget -// tree, read text, and verify that the values of widget properties are correct. - import 'package:flutter/material.dart'; +import 'package:flutter_battery_example/pages/dashboard_page.dart'; +import 'package:flutter_battery_example/platform/example_platform_adapter.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:flutter_battery_example/main.dart'; - void main() { - testWidgets('Counter increments smoke test', (WidgetTester tester) async { - // Build our app and trigger a frame. - await tester.pumpWidget(const MyApp()); - - // Verify that our counter starts at 0. - expect(find.text('0'), findsOneWidget); - expect(find.text('1'), findsNothing); - - // Tap the '+' icon and trigger a frame. - await tester.tap(find.byIcon(Icons.add)); - await tester.pump(); + testWidgets('dashboard disables unavailable Android native demos', + (tester) async { + await tester.pumpWidget( + MaterialApp( + home: DashboardPage( + batteryLevel: 50, + batteryInfo: null, + batteryHealth: null, + eventCount: 0, + onRefresh: () async {}, + onBootstrap: () {}, + peerBatterySyncAvailability: const FeatureAvailability.unsupported( + disabledLabel: '当前平台不可用', + details: '蓝牙电量同步 仅支持 Android 原生桥接,当前平台为 macOS。', + ), + iotNativeControlsAvailability: const FeatureAvailability.unsupported( + disabledLabel: '当前平台不可用', + details: 'IoT native controls 仅支持 Android 原生桥接,当前平台为 macOS。', + ), + onOpenBatteryDetails: () {}, + onOpenLowBatteryAlerts: () {}, + onOpenPeerBatterySync: () {}, + onOpenIotControls: () {}, + onOpenEventLog: () {}, + ), + ), + ); - // Verify that our counter has incremented. - expect(find.text('0'), findsNothing); - expect(find.text('1'), findsOneWidget); + expect(find.text('flutter_battery overview'), findsOneWidget); + expect(find.text('当前平台不可用'), findsNWidgets(2)); }); } From 7cb5b57cf560cfd3ee9751c706cd2cac621e83bf Mon Sep 17 00:00:00 2001 From: forest Date: Sun, 10 May 2026 21:04:51 +0800 Subject: [PATCH 2/5] =?UTF-8?q?feat:=E6=9B=B4=E6=96=B0=E5=B9=B3=E5=8F=B0?= =?UTF-8?q?=E5=B7=AE=E5=BC=82=E7=BB=93=E6=9E=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../channel/MethodChannelHandler.kt | 19 ++ example/lib/main.dart | 56 ++-- example/lib/pages/dashboard_page.dart | 27 +- .../platform/example_platform_adapter.dart | 84 +++--- example/test/widget_test.dart | 29 +- lib/flutter_battery.dart | 52 ++-- lib/flutter_battery_method_channel.dart | 260 +++++++++--------- lib/flutter_battery_platform_interface.dart | 5 + lib/flutter_bluetooth_method_channel.dart | 70 ++--- lib/peer_battery_service.dart | 37 ++- lib/src/battery_channel_contract.dart | 93 +++++++ lib/src/platform_capabilities.dart | 110 ++++++++ test/flutter_battery_method_channel_test.dart | 32 ++- test/flutter_battery_test.dart | 131 ++++++--- 14 files changed, 666 insertions(+), 339 deletions(-) create mode 100644 lib/src/battery_channel_contract.dart create mode 100644 lib/src/platform_capabilities.dart diff --git a/android/src/main/kotlin/com/example/flutter_battery/channel/MethodChannelHandler.kt b/android/src/main/kotlin/com/example/flutter_battery/channel/MethodChannelHandler.kt index bf74110..69ab5b4 100644 --- a/android/src/main/kotlin/com/example/flutter_battery/channel/MethodChannelHandler.kt +++ b/android/src/main/kotlin/com/example/flutter_battery/channel/MethodChannelHandler.kt @@ -190,6 +190,9 @@ class MethodChannelHandler( gattServerManager.stopSlaveMode() result.success(null) } + "getPlatformCapabilities" -> { + result.success(getPlatformCapabilities()) + } "getPlatformVersion" -> { result.success("Android ${android.os.Build.VERSION.RELEASE}") } @@ -414,6 +417,22 @@ class MethodChannelHandler( return false } + private fun getPlatformCapabilities(): Map { + return mapOf( + "batteryLevel" to true, + "batteryInfo" to true, + "batteryHealth" to true, + "batteryLevelStream" to true, + "batteryInfoStream" to true, + "batteryHealthStream" to true, + "lowBatteryMonitoring" to true, + "nativeNotifications" to true, + "scheduledNotifications" to true, + "blePeerSync" to true, + "iotExampleBridge" to true, + ) + } + companion object { private const val BLE_PERMISSION_REQUEST_CODE = 0xB10 } diff --git a/example/lib/main.dart b/example/lib/main.dart index 8b6849e..b539b23 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -24,7 +24,6 @@ void main() { runApp(const FlutterBatteryExampleApp()); } -// Entry point for the demo app: wires battery monitoring, IoT stubs, and sample pages. class FlutterBatteryExampleApp extends StatefulWidget { const FlutterBatteryExampleApp({super.key}); @@ -52,11 +51,7 @@ class _FlutterBatteryExampleAppState extends State { StreamSubscription? _iotSub; List _iotEvents = []; - FeatureAvailability get _peerBatterySyncAvailability => - _platform.availabilityFor(ExampleFeature.peerBatterySync); - - FeatureAvailability get _iotNativeControlsAvailability => - _platform.availabilityFor(ExampleFeature.iotNativeControls); + BatteryPlatformCapabilities get _capabilities => _platform.capabilities; @override void initState() { @@ -74,7 +69,6 @@ class _FlutterBatteryExampleAppState extends State { super.dispose(); } - // Configure the plugin callbacks and start native-side monitoring streams. void _bootstrapBattery() { _refresh(); _plugin.configureBatteryCallbacks( @@ -100,7 +94,6 @@ class _FlutterBatteryExampleAppState extends State { ); } - // IoT section: demo EventChannel/MethodChannel usage unrelated to battery. void _listenToIotEvents() { _iotSub = _platform.iotEvents.listen((dynamic e) { _recordIotEvent('event', e); @@ -147,18 +140,18 @@ class _FlutterBatteryExampleAppState extends State { Future _stopSync() => _invokeIotMethod('stopSync'); Future _invokeIotMethod(String method, [Object? arguments]) async { - if (!_iotNativeControlsAvailability.isSupported) { - _showUnsupportedFeatureMessage(_iotNativeControlsAvailability); + if (!_capabilities.isSupported(BatteryFeature.iotExampleBridge)) { + _showUnsupportedFeatureMessage(BatteryFeature.iotExampleBridge); return; } try { await _platform.invokeIotMethod(method, arguments); } on MissingPluginException catch (err) { _recordIotEvent('error', err); - _showUnsupportedFeatureMessage(_iotNativeControlsAvailability); - } on UnsupportedPlatformFeatureException catch (err) { + _showUnsupportedFeatureMessage(BatteryFeature.iotExampleBridge); + } on UnsupportedBatteryFeatureException catch (err) { _recordIotEvent('error', err); - _showUnsupportedFeatureMessage(_iotNativeControlsAvailability); + _showUnsupportedFeatureMessage(BatteryFeature.iotExampleBridge); } on PlatformException catch (err) { _recordIotEvent('error', err); } @@ -193,17 +186,17 @@ class _FlutterBatteryExampleAppState extends State { case AppRoutes.peerSelection: return MaterialPageRoute( settings: settings, - builder: (_) => _peerBatterySyncAvailability.isSupported + builder: (_) => _capabilities.isSupported(BatteryFeature.blePeerSync) ? const RoleSelectionPage() - : _UnsupportedFeaturePage( + : const _UnsupportedFeaturePage( title: '蓝牙电量同步', - availability: _peerBatterySyncAvailability, + feature: BatteryFeature.blePeerSync, ), ); case AppRoutes.iotControls: return MaterialPageRoute( settings: settings, - builder: (_) => _iotNativeControlsAvailability.isSupported + builder: (_) => _capabilities.isSupported(BatteryFeature.iotExampleBridge) ? IotControlsPage( startScan: _startScan, stopScan: _stopScan, @@ -212,9 +205,9 @@ class _FlutterBatteryExampleAppState extends State { startSync: _startSync, stopSync: _stopSync, ) - : _UnsupportedFeaturePage( + : const _UnsupportedFeaturePage( title: 'IoT native controls', - availability: _iotNativeControlsAvailability, + feature: BatteryFeature.iotExampleBridge, ), ); case AppRoutes.eventLog: @@ -235,8 +228,7 @@ class _FlutterBatteryExampleAppState extends State { eventCount: _iotEvents.length, onRefresh: _refresh, onBootstrap: _bootstrapBattery, - peerBatterySyncAvailability: _peerBatterySyncAvailability, - iotNativeControlsAvailability: _iotNativeControlsAvailability, + capabilities: _capabilities, onOpenBatteryDetails: () => _pushNamed(AppRoutes.batteryDetails), onOpenLowBatteryAlerts: () => _pushNamed(AppRoutes.lowBattery), onOpenPeerBatterySync: () => _pushNamed(AppRoutes.peerSelection), @@ -251,32 +243,32 @@ class _FlutterBatteryExampleAppState extends State { final navigator = _navigatorKey.currentState; if (navigator == null) return; - final availability = _availabilityForRoute(route); - if (availability != null && !availability.isSupported) { - _showUnsupportedFeatureMessage(availability); + final feature = _featureForRoute(route); + if (feature != null && !_capabilities.isSupported(feature)) { + _showUnsupportedFeatureMessage(feature); return; } navigator.pushNamed(route); } - FeatureAvailability? _availabilityForRoute(String route) { + BatteryFeature? _featureForRoute(String route) { switch (route) { case AppRoutes.peerSelection: - return _peerBatterySyncAvailability; + return BatteryFeature.blePeerSync; case AppRoutes.iotControls: - return _iotNativeControlsAvailability; + return BatteryFeature.iotExampleBridge; default: return null; } } - void _showUnsupportedFeatureMessage(FeatureAvailability availability) { + void _showUnsupportedFeatureMessage(BatteryFeature feature) { final scaffoldMessenger = _navigatorKey.currentContext == null ? null : ScaffoldMessenger.maybeOf(_navigatorKey.currentContext!); scaffoldMessenger?.showSnackBar( - SnackBar(content: Text(availability.details)), + SnackBar(content: Text('$feature is not supported on this platform.')), ); } } @@ -284,11 +276,11 @@ class _FlutterBatteryExampleAppState extends State { class _UnsupportedFeaturePage extends StatelessWidget { const _UnsupportedFeaturePage({ required this.title, - required this.availability, + required this.feature, }); final String title; - final FeatureAvailability availability; + final BatteryFeature feature; @override Widget build(BuildContext context) { @@ -298,7 +290,7 @@ class _UnsupportedFeaturePage extends StatelessWidget { child: Padding( padding: const EdgeInsets.all(24), child: Text( - availability.details, + '$feature is not supported on this platform.', textAlign: TextAlign.center, ), ), diff --git a/example/lib/pages/dashboard_page.dart b/example/lib/pages/dashboard_page.dart index 39af899..821d090 100644 --- a/example/lib/pages/dashboard_page.dart +++ b/example/lib/pages/dashboard_page.dart @@ -5,7 +5,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_battery/flutter_battery.dart'; import '../perflab/perflab_channel.dart'; -import '../platform/example_platform_adapter.dart'; import '../startup_trace.dart'; bool _startupFirstBuildLogged = false; @@ -23,8 +22,7 @@ class DashboardPage extends StatefulWidget { required this.eventCount, required this.onRefresh, required this.onBootstrap, - required this.peerBatterySyncAvailability, - required this.iotNativeControlsAvailability, + required this.capabilities, required this.onOpenBatteryDetails, required this.onOpenLowBatteryAlerts, required this.onOpenPeerBatterySync, @@ -39,8 +37,7 @@ class DashboardPage extends StatefulWidget { final int eventCount; final Future Function() onRefresh; final VoidCallback onBootstrap; - final FeatureAvailability peerBatterySyncAvailability; - final FeatureAvailability iotNativeControlsAvailability; + final BatteryPlatformCapabilities capabilities; final VoidCallback onOpenBatteryDetails; final VoidCallback onOpenLowBatteryAlerts; final VoidCallback onOpenPeerBatterySync; @@ -175,15 +172,15 @@ class _DashboardPageState extends State { leading: const Icon(Icons.hub_outlined), title: const Text('蓝牙电量同步'), subtitle: Text( - widget.peerBatterySyncAvailability.isSupported + widget.capabilities.isSupported(BatteryFeature.blePeerSync) ? '选择主/从机后进行电量互通' - : widget.peerBatterySyncAvailability.disabledLabel, + : '当前平台不支持', ), - trailing: widget.peerBatterySyncAvailability.isSupported + trailing: widget.capabilities.isSupported(BatteryFeature.blePeerSync) ? const Icon(Icons.chevron_right) : const Icon(Icons.block_outlined), - enabled: widget.peerBatterySyncAvailability.isSupported, - onTap: widget.peerBatterySyncAvailability.isSupported + enabled: widget.capabilities.isSupported(BatteryFeature.blePeerSync), + onTap: widget.capabilities.isSupported(BatteryFeature.blePeerSync) ? widget.onOpenPeerBatterySync : null, ), @@ -192,15 +189,15 @@ class _DashboardPageState extends State { leading: const Icon(Icons.memory_outlined), title: const Text('IoT native controls'), subtitle: Text( - widget.iotNativeControlsAvailability.isSupported + widget.capabilities.isSupported(BatteryFeature.iotExampleBridge) ? 'Scan, connect, and sync via MethodChannel' - : widget.iotNativeControlsAvailability.disabledLabel, + : '当前平台不支持', ), - trailing: widget.iotNativeControlsAvailability.isSupported + trailing: widget.capabilities.isSupported(BatteryFeature.iotExampleBridge) ? const Icon(Icons.chevron_right) : const Icon(Icons.block_outlined), - enabled: widget.iotNativeControlsAvailability.isSupported, - onTap: widget.iotNativeControlsAvailability.isSupported + enabled: widget.capabilities.isSupported(BatteryFeature.iotExampleBridge), + onTap: widget.capabilities.isSupported(BatteryFeature.iotExampleBridge) ? widget.onOpenIotControls : null, ), diff --git a/example/lib/platform/example_platform_adapter.dart b/example/lib/platform/example_platform_adapter.dart index c6f12d7..334c422 100644 --- a/example/lib/platform/example_platform_adapter.dart +++ b/example/lib/platform/example_platform_adapter.dart @@ -1,42 +1,13 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; - -enum ExampleFeature { - peerBatterySync, - iotNativeControls, -} - -class FeatureAvailability { - const FeatureAvailability.supported() - : isSupported = true, - disabledLabel = '', - details = ''; - - const FeatureAvailability.unsupported({ - required this.disabledLabel, - required this.details, - }) : isSupported = false; - - final bool isSupported; - final String disabledLabel; - final String details; -} - -class UnsupportedPlatformFeatureException implements Exception { - UnsupportedPlatformFeatureException(this.message); - - final String message; - - @override - String toString() => message; -} +import 'package:flutter_battery/flutter_battery.dart'; abstract class ExamplePlatformAdapter { const ExamplePlatformAdapter(); String get platformName; - FeatureAvailability availabilityFor(ExampleFeature feature); + BatteryPlatformCapabilities get capabilities; Stream get iotEvents; @@ -61,9 +32,20 @@ class AndroidExamplePlatformAdapter extends ExamplePlatformAdapter { String get platformName => 'android'; @override - FeatureAvailability availabilityFor(ExampleFeature feature) { - return const FeatureAvailability.supported(); - } + BatteryPlatformCapabilities get capabilities => + const BatteryPlatformCapabilities(features: { + BatteryFeature.batteryLevel: true, + BatteryFeature.batteryInfo: true, + BatteryFeature.batteryHealth: true, + BatteryFeature.batteryLevelStream: true, + BatteryFeature.batteryInfoStream: true, + BatteryFeature.batteryHealthStream: true, + BatteryFeature.lowBatteryMonitoring: true, + BatteryFeature.nativeNotifications: true, + BatteryFeature.scheduledNotifications: true, + BatteryFeature.blePeerSync: true, + BatteryFeature.iotExampleBridge: true, + }); @override Stream get iotEvents => _iotEvent.receiveBroadcastStream(); @@ -81,12 +63,20 @@ class UnsupportedExamplePlatformAdapter extends ExamplePlatformAdapter { final String platformName; @override - FeatureAvailability availabilityFor(ExampleFeature feature) { - return FeatureAvailability.unsupported( - disabledLabel: '当前平台不可用', - details: '${_featureName(feature)} 仅支持 Android 原生桥接,当前平台为 $platformName。', - ); - } + BatteryPlatformCapabilities get capabilities => + const BatteryPlatformCapabilities(features: { + BatteryFeature.batteryLevel: true, + BatteryFeature.batteryInfo: true, + BatteryFeature.batteryHealth: true, + BatteryFeature.batteryLevelStream: true, + BatteryFeature.batteryInfoStream: true, + BatteryFeature.batteryHealthStream: true, + BatteryFeature.lowBatteryMonitoring: true, + BatteryFeature.nativeNotifications: false, + BatteryFeature.scheduledNotifications: false, + BatteryFeature.blePeerSync: false, + BatteryFeature.iotExampleBridge: false, + }); @override Stream get iotEvents { @@ -97,17 +87,9 @@ class UnsupportedExamplePlatformAdapter extends ExamplePlatformAdapter { @override Future invokeIotMethod(String method, [Object? arguments]) { - throw UnsupportedPlatformFeatureException( - availabilityFor(ExampleFeature.iotNativeControls).details, + throw UnsupportedBatteryFeatureException( + BatteryFeature.iotExampleBridge, + 'IoT native controls are Android-only. Current platform: $platformName.', ); } - - String _featureName(ExampleFeature feature) { - switch (feature) { - case ExampleFeature.peerBatterySync: - return '蓝牙电量同步'; - case ExampleFeature.iotNativeControls: - return 'IoT native controls'; - } - } } diff --git a/example/test/widget_test.dart b/example/test/widget_test.dart index 8eaf7af..d97cd8c 100644 --- a/example/test/widget_test.dart +++ b/example/test/widget_test.dart @@ -1,10 +1,24 @@ import 'package:flutter/material.dart'; +import 'package:flutter_battery/flutter_battery.dart'; import 'package:flutter_battery_example/pages/dashboard_page.dart'; -import 'package:flutter_battery_example/platform/example_platform_adapter.dart'; import 'package:flutter_test/flutter_test.dart'; void main() { - testWidgets('dashboard disables unavailable Android native demos', + final unsupportedCaps = const BatteryPlatformCapabilities(features: { + BatteryFeature.batteryLevel: true, + BatteryFeature.batteryInfo: true, + BatteryFeature.batteryHealth: true, + BatteryFeature.batteryLevelStream: true, + BatteryFeature.batteryInfoStream: true, + BatteryFeature.batteryHealthStream: true, + BatteryFeature.lowBatteryMonitoring: true, + BatteryFeature.nativeNotifications: false, + BatteryFeature.scheduledNotifications: false, + BatteryFeature.blePeerSync: false, + BatteryFeature.iotExampleBridge: false, + }); + + testWidgets('dashboard_disables_features_from_capability_object', (tester) async { await tester.pumpWidget( MaterialApp( @@ -15,14 +29,7 @@ void main() { eventCount: 0, onRefresh: () async {}, onBootstrap: () {}, - peerBatterySyncAvailability: const FeatureAvailability.unsupported( - disabledLabel: '当前平台不可用', - details: '蓝牙电量同步 仅支持 Android 原生桥接,当前平台为 macOS。', - ), - iotNativeControlsAvailability: const FeatureAvailability.unsupported( - disabledLabel: '当前平台不可用', - details: 'IoT native controls 仅支持 Android 原生桥接,当前平台为 macOS。', - ), + capabilities: unsupportedCaps, onOpenBatteryDetails: () {}, onOpenLowBatteryAlerts: () {}, onOpenPeerBatterySync: () {}, @@ -33,6 +40,6 @@ void main() { ); expect(find.text('flutter_battery overview'), findsOneWidget); - expect(find.text('当前平台不可用'), findsNWidgets(2)); + expect(find.text('当前平台不支持'), findsNWidgets(2)); }); } diff --git a/lib/flutter_battery.dart b/lib/flutter_battery.dart index 2bd4596..d2fb5a3 100644 --- a/lib/flutter_battery.dart +++ b/lib/flutter_battery.dart @@ -1,6 +1,10 @@ import 'flutter_battery_platform_interface.dart'; +import 'src/battery_channel_contract.dart'; +import 'src/platform_capabilities.dart'; export 'battery_animation.dart'; export 'peer_battery_service.dart'; +export 'src/battery_channel_contract.dart'; +export 'src/platform_capabilities.dart'; /// 电池状态枚举 enum BatteryState { @@ -245,6 +249,15 @@ class FlutterBattery { Future getPlatformVersion() { return FlutterBatteryPlatform.instance.getPlatformVersion(); } + + Future getPlatformCapabilities() { + return FlutterBatteryPlatform.instance.getPlatformCapabilities(); + } + + Future isFeatureSupported(BatteryFeature feature) async { + final capabilities = await getPlatformCapabilities(); + return capabilities.isSupported(feature); + } /// 获取电池电量百分比 Future getBatteryLevel() { @@ -273,37 +286,42 @@ class FlutterBattery { return FlutterBatteryPlatform.instance.batteryStream; } - /// 获取格式化的电池信息流 + static int _normalizeLevel(Map event) { + final level = event[BatteryPayloadKeys.level] as int?; + final batteryLevel = event[BatteryPayloadKeys.batteryLevel] as int?; + if (level != null && level >= 0) return level; + if (batteryLevel != null && batteryLevel >= 0) return batteryLevel; + final type = event[BatteryPayloadKeys.type] as String?; + if (type == BatteryEventTypes.batteryUnavailable) return 0; + return level ?? batteryLevel ?? 0; + } + Stream get batteryInfoStream { return batteryStream.where((event) { - final type = event['type']; - return type == null || type == 'BATTERY_INFO'; + final type = event[BatteryPayloadKeys.type]; + return type == null || type == BatteryEventTypes.batteryInfo; }).map((event) { - // 检查是否包含完整的电池信息 - if (event.containsKey('type') && event['type'] == 'BATTERY_INFO') { + if (event[BatteryPayloadKeys.type] == BatteryEventTypes.batteryInfo) { return BatteryInfo.fromMap(event); } - - // 兼容简单电池电量信息 - final int level = event['batteryLevel'] as int? ?? 0; - final int timestamp = event['timestamp'] as int? ?? DateTime.now().millisecondsSinceEpoch; - + final level = FlutterBattery._normalizeLevel(event); + final timestamp = event[BatteryPayloadKeys.timestamp] as int? ?? + DateTime.now().millisecondsSinceEpoch; return BatteryInfo( level: level, - isCharging: false, - temperature: 0.0, - voltage: 0.0, + isCharging: event[BatteryPayloadKeys.isCharging] as bool? ?? false, + temperature: (event[BatteryPayloadKeys.temperature] as num?)?.toDouble() ?? 0.0, + voltage: (event[BatteryPayloadKeys.voltage] as num?)?.toDouble() ?? 0.0, state: level <= 20 ? BatteryState.LOW : BatteryState.NORMAL, timestamp: timestamp, ); }); } - /// 电池健康信息流 Stream get batteryHealthStream { - return batteryStream.where((event) => event['type'] == 'BATTERY_HEALTH').map( - (event) => BatteryHealth.fromMap(Map.from(event)), - ); + return batteryStream + .where((event) => event[BatteryPayloadKeys.type] == BatteryEventTypes.batteryHealth) + .map((event) => BatteryHealth.fromMap(Map.from(event))); } /// 配置所有电池相关回调 diff --git a/lib/flutter_battery_method_channel.dart b/lib/flutter_battery_method_channel.dart index 3f6226d..36d3e8c 100644 --- a/lib/flutter_battery_method_channel.dart +++ b/lib/flutter_battery_method_channel.dart @@ -2,206 +2,210 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; import 'flutter_battery_platform_interface.dart'; +import 'src/battery_channel_contract.dart'; +import 'src/platform_capabilities.dart'; -/// An implementation of [FlutterBatteryPlatform] that uses method channels. class MethodChannelFlutterBattery extends FlutterBatteryPlatform { - /// The method channel used to interact with the native platform. @visibleForTesting - final methodChannel = const MethodChannel('flutter_battery'); - - /// The event channel used to receive battery updates + final MethodChannel methodChannel; + @visibleForTesting - final eventChannel = const EventChannel('flutter_battery/battery_stream'); - - // 电池低电量回调 + final EventChannel eventChannel; + Function(int batteryLevel)? _lowBatteryCallback; - - // 电池电量变化回调 Function(int batteryLevel)? _batteryLevelChangeCallback; - - // 电池信息变化回调 Function(Map batteryInfo)? _batteryInfoChangeCallback; - - // 电池健康变化回调 Function(Map batteryHealth)? _batteryHealthChangeCallback; - MethodChannelFlutterBattery() { + MethodChannelFlutterBattery() + : methodChannel = const MethodChannel(BatteryChannelNames.methodChannel), + eventChannel = const EventChannel(BatteryChannelNames.eventChannel) { methodChannel.setMethodCallHandler(_handleMethodCall); } - - // 处理来自原生层的方法调用 + Future _handleMethodCall(MethodCall call) async { switch (call.method) { - case 'onLowBattery': - final int batteryLevel = call.arguments['batteryLevel'] as int; + case BatteryMethodNames.onLowBattery: + final int batteryLevel = call.arguments[BatteryPayloadKeys.batteryLevel] as int; if (_lowBatteryCallback != null) { _lowBatteryCallback!(batteryLevel); } return true; - case 'onBatteryLevelChanged': - final int batteryLevel = call.arguments['batteryLevel'] as int; + case BatteryMethodNames.onBatteryLevelChanged: + final int batteryLevel = call.arguments[BatteryPayloadKeys.batteryLevel] as int; if (_batteryLevelChangeCallback != null) { _batteryLevelChangeCallback!(batteryLevel); } return true; - case 'onBatteryInfoChanged': + case BatteryMethodNames.onBatteryInfoChanged: if (call.arguments is Map && _batteryInfoChangeCallback != null) { - final Map map = call.arguments as Map; - final Map batteryInfo = map.cast(); - _batteryInfoChangeCallback!(batteryInfo); + final map = call.arguments as Map; + _batteryInfoChangeCallback!(map.cast()); } return true; - case 'onBatteryHealthChanged': + case BatteryMethodNames.onBatteryHealthChanged: if (call.arguments is Map && _batteryHealthChangeCallback != null) { - final Map map = call.arguments as Map; - final Map batteryHealth = map.cast(); - _batteryHealthChangeCallback!(batteryHealth); + final map = call.arguments as Map; + _batteryHealthChangeCallback!(map.cast()); } return true; default: throw PlatformException( code: 'Unimplemented', - details: '${call.method} 尚未实现', + details: '${call.method} has not been implemented.', ); } } + Future _invoke(String method, [dynamic args]) { + return methodChannel.invokeMethod(method, args); + } + + @override + Future getPlatformCapabilities() async { + try { + final result = await methodChannel.invokeMapMethod( + BatteryMethodNames.getPlatformCapabilities, + ); + if (result == null) { + return const BatteryPlatformCapabilities(features: {}); + } + return BatteryPlatformCapabilities.fromMap(result.cast()); + } on MissingPluginException { + return const BatteryPlatformCapabilities(features: {}); + } + } + @override Future getPlatformVersion() async { - final version = await methodChannel.invokeMethod('getPlatformVersion'); - return version; + final version = await _invoke(BatteryMethodNames.getPlatformVersion); + return version as String?; } - + @override Future getBatteryLevel() async { - final level = await methodChannel.invokeMethod('getBatteryLevel'); - return level; + final level = await _invoke(BatteryMethodNames.getBatteryLevel); + return level as int?; } - + @override Future> getBatteryInfo() async { - final Map? result = await methodChannel.invokeMapMethod('getBatteryInfo'); + final result = await methodChannel.invokeMapMethod(BatteryMethodNames.getBatteryInfo); if (result == null) { - return { - 'error': 'Failed to get battery info', - }; + return {BatteryPayloadKeys.error: 'Failed to get battery info'}; } return result.cast(); } - + @override Future> getBatteryOptimizationTips() async { - final List? result = await methodChannel.invokeListMethod('getBatteryOptimizationTips'); - if (result == null) { - return []; - } + final result = await methodChannel.invokeListMethod( + BatteryMethodNames.getBatteryOptimizationTips, + ); + if (result == null) return []; return result.map((item) => item.toString()).toList(); } - + @override Future> getBatteryHealth() async { - final Map? result = await methodChannel.invokeMapMethod('getBatteryHealth'); + final result = await methodChannel.invokeMapMethod(BatteryMethodNames.getBatteryHealth); if (result == null) { - return {'error': 'Failed to get battery health'}; + return {BatteryPayloadKeys.error: 'Failed to get battery health'}; } return result.cast(); } - + @override void setLowBatteryCallback(Function(int batteryLevel) callback) { _lowBatteryCallback = callback; } - + @override void setBatteryLevelChangeCallback(Function(int batteryLevel) callback) { _batteryLevelChangeCallback = callback; } - + @override void setBatteryInfoChangeCallback(Function(Map batteryInfo) callback) { _batteryInfoChangeCallback = callback; } - + @override void setBatteryHealthChangeCallback(Function(Map batteryHealth) callback) { _batteryHealthChangeCallback = callback; } - + @override Future startBatteryLevelListening() async { - final result = await methodChannel.invokeMethod('startBatteryLevelListening'); - return result; + final result = await _invoke(BatteryMethodNames.startBatteryLevelListening); + return result as bool?; } - + @override Future stopBatteryLevelListening() async { - final result = await methodChannel.invokeMethod('stopBatteryLevelListening'); - return result; + final result = await _invoke(BatteryMethodNames.stopBatteryLevelListening); + return result as bool?; } - + @override Future startBatteryInfoListening({int intervalMs = 5000}) async { - final result = await methodChannel.invokeMethod( - 'startBatteryInfoListening', - { - 'intervalMs': intervalMs, - }, - ); - return result; + final result = await _invoke(BatteryMethodNames.startBatteryInfoListening, { + BatteryPayloadKeys.intervalMs: intervalMs, + }); + return result as bool?; } - + @override Future stopBatteryInfoListening() async { - final result = await methodChannel.invokeMethod('stopBatteryInfoListening'); - return result; + final result = await _invoke(BatteryMethodNames.stopBatteryInfoListening); + return result as bool?; } - + @override Future startBatteryHealthListening({int intervalMs = 10000}) async { - final result = await methodChannel.invokeMethod( - 'startBatteryHealthListening', - { - 'intervalMs': intervalMs, - }, - ); - return result; + final result = await _invoke(BatteryMethodNames.startBatteryHealthListening, { + BatteryPayloadKeys.intervalMs: intervalMs, + }); + return result as bool?; } - + @override Future stopBatteryHealthListening() async { - final result = await methodChannel.invokeMethod('stopBatteryHealthListening'); - return result; + final result = await _invoke(BatteryMethodNames.stopBatteryHealthListening); + return result as bool?; } - + @override Stream> get batteryStream { return eventChannel.receiveBroadcastStream().map((dynamic event) { if (event is! Map) { return { - 'batteryLevel': 0, - 'timestamp': DateTime.now().millisecondsSinceEpoch, - 'error': 'Invalid event format', + BatteryPayloadKeys.batteryLevel: 0, + BatteryPayloadKeys.timestamp: DateTime.now().millisecondsSinceEpoch, + BatteryPayloadKeys.error: 'Invalid event format', }; } - return Map.from(event); + final raw = Map.from(event); + final type = raw[BatteryPayloadKeys.type] as String?; + if (type == null && raw.containsKey(BatteryPayloadKeys.batteryLevel)) { + raw[BatteryPayloadKeys.type] = BatteryEventTypes.batteryLevel; + } + return raw; }); } - + @override Future setPushInterval({ required int intervalMs, bool enableDebounce = true, }) async { - final result = await methodChannel.invokeMethod( - 'setPushInterval', - { - 'intervalMs': intervalMs, - 'enableDebounce': enableDebounce, - }, - ); - return result; + final result = await _invoke(BatteryMethodNames.setPushInterval, { + BatteryPayloadKeys.intervalMs: intervalMs, + BatteryPayloadKeys.enableDebounce: enableDebounce, + }); + return result as bool?; } - + @override Future setBatteryLevelThreshold({ required int threshold, @@ -214,55 +218,53 @@ class MethodChannelFlutterBattery extends FlutterBatteryPlatform { if (useFlutterRendering && onLowBattery != null) { setLowBatteryCallback(onLowBattery); } - - final result = await methodChannel.invokeMethod( - 'setBatteryLevelThreshold', - { - 'threshold': threshold, - 'title': title, - 'message': message, - 'intervalMinutes': intervalMinutes, - 'useFlutterRendering': useFlutterRendering, - }, - ); - return result; + final result = await _invoke(BatteryMethodNames.setBatteryLevelThreshold, { + BatteryPayloadKeys.threshold: threshold, + BatteryPayloadKeys.title: title, + BatteryPayloadKeys.message: message, + BatteryPayloadKeys.intervalMinutes: intervalMinutes, + BatteryPayloadKeys.useFlutterRendering: useFlutterRendering, + }); + return result as bool?; } - + @override Future stopBatteryMonitoring() async { - final result = await methodChannel.invokeMethod('stopBatteryMonitoring'); - return result; + final result = await _invoke(BatteryMethodNames.stopBatteryMonitoring); + return result as bool?; } - + @override Future scheduleNotification({ required String title, required String message, int delayMinutes = 1, }) async { - final result = await methodChannel.invokeMethod( - 'scheduleNotification', - { - 'title': title, - 'message': message, - 'delayMinutes': delayMinutes, - }, - ); - return result; + try { + final result = await _invoke(BatteryMethodNames.scheduleNotification, { + BatteryPayloadKeys.title: title, + BatteryPayloadKeys.message: message, + BatteryPayloadKeys.delayMinutes: delayMinutes, + }); + return result as bool?; + } on MissingPluginException { + return null; + } } - + @override Future showNotification({ required String title, required String message, }) async { - final result = await methodChannel.invokeMethod( - 'showNotification', - { - 'title': title, - 'message': message, - }, - ); - return result; + try { + final result = await _invoke(BatteryMethodNames.showNotification, { + BatteryPayloadKeys.title: title, + BatteryPayloadKeys.message: message, + }); + return result as bool?; + } on MissingPluginException { + return null; + } } } diff --git a/lib/flutter_battery_platform_interface.dart b/lib/flutter_battery_platform_interface.dart index 2bbb23a..00eaeaa 100644 --- a/lib/flutter_battery_platform_interface.dart +++ b/lib/flutter_battery_platform_interface.dart @@ -1,6 +1,7 @@ import 'package:plugin_platform_interface/plugin_platform_interface.dart'; import 'flutter_battery_method_channel.dart'; +import 'src/platform_capabilities.dart'; abstract class FlutterBatteryPlatform extends PlatformInterface { /// Constructs a FlutterBatteryPlatform. @@ -23,6 +24,10 @@ abstract class FlutterBatteryPlatform extends PlatformInterface { _instance = instance; } + Future getPlatformCapabilities() { + throw UnimplementedError('getPlatformCapabilities() has not been implemented.'); + } + Future getPlatformVersion() { throw UnimplementedError('platformVersion() has not been implemented.'); } diff --git a/lib/flutter_bluetooth_method_channel.dart b/lib/flutter_bluetooth_method_channel.dart index 0d2226e..3cce06a 100644 --- a/lib/flutter_bluetooth_method_channel.dart +++ b/lib/flutter_bluetooth_method_channel.dart @@ -3,12 +3,13 @@ import 'dart:async'; import 'package:flutter/services.dart'; import 'flutter_bluetooth_platform_interface.dart'; +import 'src/battery_channel_contract.dart'; class MethodChannelFlutterBluetooth extends FlutterBluetoothPlatform { - static const MethodChannel _methodChannel = MethodChannel('flutter_battery/ble_methods'); - static const EventChannel _scanEventChannel = EventChannel('flutter_battery/ble_scan_events'); + static const MethodChannel _methodChannel = MethodChannel(BatteryChannelNames.bleMethods); + static const EventChannel _scanEventChannel = EventChannel(BatteryChannelNames.bleScanEvents); static const EventChannel _connectionEventChannel = - EventChannel('flutter_battery/ble_connection_events'); + EventChannel(BatteryChannelNames.bleConnectionEvents); Stream>? _scanStream; Stream? _connectionStream; @@ -17,56 +18,60 @@ class MethodChannelFlutterBluetooth extends FlutterBluetoothPlatform { @override Future isBleAvailable() async { - final result = await _methodChannel.invokeMethod('isBleAvailable'); + final result = await _methodChannel.invokeMethod(BatteryMethodNames.isBleAvailable); return result ?? false; } @override Future isBleEnabled() async { - final result = await _methodChannel.invokeMethod('isBleEnabled'); + final result = await _methodChannel.invokeMethod(BatteryMethodNames.isBleEnabled); return result ?? false; } @override Stream> scanDevices({String? serviceUuid}) { - _scanStream ??= _scanEventChannel.receiveBroadcastStream({'serviceUuid': serviceUuid}).map( - (event) { - final list = (event as List).cast(); - return list - .map((e) { - final map = Map.from(e as Map); - return BleDevice.fromJson(map); - }) - .toList(); - }, - ).asBroadcastStream(); + _scanStream ??= _scanEventChannel + .receiveBroadcastStream({'serviceUuid': serviceUuid}) + .map((event) { + final list = (event as List).cast(); + return list + .map((e) { + final map = Map.from(e as Map); + return BleDevice.fromJson(map); + }) + .toList(); + }) + .asBroadcastStream(); return _scanStream!; } @override Future startScan({String? serviceUuid}) async { - await _methodChannel.invokeMethod('startScan', { + await _methodChannel.invokeMethod(BatteryMethodNames.startScan, { 'serviceUuid': serviceUuid, }); } @override Future stopScan() async { - await _methodChannel.invokeMethod('stopScan'); + await _methodChannel.invokeMethod(BatteryMethodNames.stopScan); } @override Stream connectionEvents() { - _connectionStream ??= _connectionEventChannel.receiveBroadcastStream().map((event) { - final map = Map.from(event as Map); - return BleConnectionEvent.fromJson(map); - }).asBroadcastStream(); + _connectionStream ??= _connectionEventChannel + .receiveBroadcastStream() + .map((event) { + final map = Map.from(event as Map); + return BleConnectionEvent.fromJson(map); + }) + .asBroadcastStream(); return _connectionStream!; } @override Future connect(String deviceId, {bool autoConnect = false}) async { - await _methodChannel.invokeMethod('connect', { + await _methodChannel.invokeMethod(BatteryMethodNames.connect, { 'deviceId': deviceId, 'autoConnect': autoConnect, }); @@ -74,7 +79,7 @@ class MethodChannelFlutterBluetooth extends FlutterBluetoothPlatform { @override Future disconnect([String? deviceId]) async { - await _methodChannel.invokeMethod('disconnect', { + await _methodChannel.invokeMethod(BatteryMethodNames.disconnect, { 'deviceId': deviceId, }); } @@ -87,13 +92,16 @@ class MethodChannelFlutterBluetooth extends FlutterBluetoothPlatform { required List value, bool withResponse = true, }) async { - final result = await _methodChannel.invokeMethod('writeCharacteristic', { - 'deviceId': deviceId, - 'serviceUuid': serviceUuid, - 'characteristicUuid': characteristicUuid, - 'value': value, - 'withResponse': withResponse, - }); + final result = await _methodChannel.invokeMethod( + BatteryMethodNames.writeCharacteristic, + { + 'deviceId': deviceId, + 'serviceUuid': serviceUuid, + 'characteristicUuid': characteristicUuid, + 'value': value, + 'withResponse': withResponse, + }, + ); return result ?? false; } diff --git a/lib/peer_battery_service.dart b/lib/peer_battery_service.dart index 9da9ac6..5be5f72 100644 --- a/lib/peer_battery_service.dart +++ b/lib/peer_battery_service.dart @@ -2,6 +2,9 @@ import 'dart:async'; import 'package:flutter/services.dart'; +import 'src/battery_channel_contract.dart'; +import 'src/platform_capabilities.dart'; + enum PeerRole { master, slave } class PeerBatteryState { @@ -30,8 +33,8 @@ class PeerBatteryState { } class PeerBatteryService { - static const MethodChannel _methodChannel = MethodChannel('flutter_battery/peer_methods'); - static const EventChannel _eventChannel = EventChannel('flutter_battery/peer_events'); + static const MethodChannel _methodChannel = MethodChannel(BatteryChannelNames.peerMethods); + static const EventChannel _eventChannel = EventChannel(BatteryChannelNames.peerEvents); Stream? _stream; @@ -45,7 +48,12 @@ class PeerBatteryService { Future startAsMaster() async { try { - await _methodChannel.invokeMethod('startMasterMode'); + await _methodChannel.invokeMethod(BatteryMethodNames.startMasterMode); + } on MissingPluginException { + throw UnsupportedBatteryFeatureException( + BatteryFeature.blePeerSync, + 'BLE peer sync is not supported on this platform.', + ); } on PlatformException catch (e) { if (e.code != 'PERMISSION_REQUIRED') rethrow; } @@ -53,21 +61,38 @@ class PeerBatteryService { Future startAsSlave() async { try { - await _methodChannel.invokeMethod('startSlaveMode'); + await _methodChannel.invokeMethod(BatteryMethodNames.startSlaveMode); + } on MissingPluginException { + throw UnsupportedBatteryFeatureException( + BatteryFeature.blePeerSync, + 'BLE peer sync is not supported on this platform.', + ); } on PlatformException catch (e) { if (e.code != 'PERMISSION_REQUIRED') rethrow; } } Future stop() async { - await _methodChannel.invokeMethod('stopAllPeerModes'); + try { + await _methodChannel.invokeMethod(BatteryMethodNames.stopAllPeerModes); + } on MissingPluginException { + throw UnsupportedBatteryFeatureException( + BatteryFeature.blePeerSync, + 'BLE peer sync is not supported on this platform.', + ); + } } Future masterConnectToDevice(String deviceId) async { try { - await _methodChannel.invokeMethod('masterConnectToDevice', { + await _methodChannel.invokeMethod(BatteryMethodNames.masterConnectToDevice, { 'deviceId': deviceId, }); + } on MissingPluginException { + throw UnsupportedBatteryFeatureException( + BatteryFeature.blePeerSync, + 'BLE peer sync is not supported on this platform.', + ); } on PlatformException catch (e) { if (e.code != 'PERMISSION_REQUIRED') rethrow; } diff --git a/lib/src/battery_channel_contract.dart b/lib/src/battery_channel_contract.dart new file mode 100644 index 0000000..c95f47f --- /dev/null +++ b/lib/src/battery_channel_contract.dart @@ -0,0 +1,93 @@ +class BatteryChannelNames { + static const String methodChannel = 'flutter_battery'; + static const String eventChannel = 'flutter_battery/battery_stream'; + static const String bleMethods = 'flutter_battery/ble_methods'; + static const String bleScanEvents = 'flutter_battery/ble_scan_events'; + static const String bleConnectionEvents = 'flutter_battery/ble_connection_events'; + static const String peerMethods = 'flutter_battery/peer_methods'; + static const String peerEvents = 'flutter_battery/peer_events'; +} + +class BatteryMethodNames { + static const String getPlatformVersion = 'getPlatformVersion'; + static const String getPlatformCapabilities = 'getPlatformCapabilities'; + static const String getBatteryLevel = 'getBatteryLevel'; + static const String getBatteryInfo = 'getBatteryInfo'; + static const String getBatteryHealth = 'getBatteryHealth'; + static const String getBatteryOptimizationTips = 'getBatteryOptimizationTips'; + static const String startBatteryLevelListening = 'startBatteryLevelListening'; + static const String stopBatteryLevelListening = 'stopBatteryLevelListening'; + static const String startBatteryInfoListening = 'startBatteryInfoListening'; + static const String stopBatteryInfoListening = 'stopBatteryInfoListening'; + static const String startBatteryHealthListening = 'startBatteryHealthListening'; + static const String stopBatteryHealthListening = 'stopBatteryHealthListening'; + static const String setPushInterval = 'setPushInterval'; + static const String setBatteryLevelThreshold = 'setBatteryLevelThreshold'; + static const String stopBatteryMonitoring = 'stopBatteryMonitoring'; + static const String scheduleNotification = 'scheduleNotification'; + static const String showNotification = 'showNotification'; + static const String sendNotification = 'sendNotification'; + static const String onLowBattery = 'onLowBattery'; + static const String onBatteryLevelChanged = 'onBatteryLevelChanged'; + static const String onBatteryInfoChanged = 'onBatteryInfoChanged'; + static const String onBatteryHealthChanged = 'onBatteryHealthChanged'; + + static const String isBleAvailable = 'isBleAvailable'; + static const String isBleEnabled = 'isBleEnabled'; + static const String startScan = 'startScan'; + static const String stopScan = 'stopScan'; + static const String connect = 'connect'; + static const String disconnect = 'disconnect'; + static const String writeCharacteristic = 'writeCharacteristic'; + + static const String startMasterMode = 'startMasterMode'; + static const String startSlaveMode = 'startSlaveMode'; + static const String stopAllPeerModes = 'stopAllPeerModes'; + static const String masterConnectToDevice = 'masterConnectToDevice'; +} + +class BatteryEventTypes { + static const String batteryLevel = 'BATTERY_LEVEL'; + static const String batteryInfo = 'BATTERY_INFO'; + static const String batteryHealth = 'BATTERY_HEALTH'; + static const String batteryUnavailable = 'BATTERY_UNAVAILABLE'; + static const String batteryError = 'BATTERY_ERROR'; +} + +class BatteryPayloadKeys { + static const String type = 'type'; + static const String timestamp = 'timestamp'; + static const String batteryLevel = 'batteryLevel'; + static const String level = 'level'; + static const String isCharging = 'isCharging'; + static const String isCharged = 'isCharged'; + static const String state = 'state'; + static const String temperature = 'temperature'; + static const String voltage = 'voltage'; + static const String timeToFull = 'timeToFull'; + static const String timeToEmpty = 'timeToEmpty'; + static const String statusLabel = 'statusLabel'; + static const String isGood = 'isGood'; + static const String riskLevel = 'riskLevel'; + static const String recommendations = 'recommendations'; + static const String healthPercentage = 'healthPercentage'; + static const String maxCapacity = 'maxCapacity'; + static const String currentCapacity = 'currentCapacity'; + static const String designCapacity = 'designCapacity'; + static const String cycleCount = 'cycleCount'; + static const String serialNumber = 'serialNumber'; + static const String manufacturer = 'manufacturer'; + static const String deviceName = 'deviceName'; + static const String unavailableReason = 'unavailableReason'; + static const String error = 'error'; + + static const String intervalMs = 'intervalMs'; + static const String enableDebounce = 'enableDebounce'; + static const String threshold = 'threshold'; + static const String title = 'title'; + static const String message = 'message'; + static const String intervalMinutes = 'intervalMinutes'; + static const String useFlutterRendering = 'useFlutterRendering'; + static const String delay = 'delay'; + static const String delayMinutes = 'delayMinutes'; +} diff --git a/lib/src/platform_capabilities.dart b/lib/src/platform_capabilities.dart new file mode 100644 index 0000000..c2db139 --- /dev/null +++ b/lib/src/platform_capabilities.dart @@ -0,0 +1,110 @@ +enum BatteryFeature { + batteryLevel, + batteryInfo, + batteryHealth, + batteryLevelStream, + batteryInfoStream, + batteryHealthStream, + lowBatteryMonitoring, + nativeNotifications, + scheduledNotifications, + blePeerSync, + iotExampleBridge, +} + +class BatteryPlatformCapabilities { + final Map features; + + const BatteryPlatformCapabilities({required this.features}); + + bool isSupported(BatteryFeature feature) => features[feature] ?? false; + + List get supportedFeatures => + features.entries.where((e) => e.value).map((e) => e.key).toList(); + + List get unsupportedFeatures => + features.entries.where((e) => !e.value).map((e) => e.key).toList(); + + factory BatteryPlatformCapabilities.fromMap(Map map) { + final features = {}; + for (final entry in map.entries) { + final feature = _featureFromString(entry.key); + if (feature != null && entry.value is bool) { + features[feature] = entry.value as bool; + } + } + return BatteryPlatformCapabilities(features: features); + } + + Map toMap() { + return {for (final e in features.entries) _featureToString(e.key): e.value}; + } + + static BatteryFeature? _featureFromString(String name) { + switch (name) { + case 'batteryLevel': + return BatteryFeature.batteryLevel; + case 'batteryInfo': + return BatteryFeature.batteryInfo; + case 'batteryHealth': + return BatteryFeature.batteryHealth; + case 'batteryLevelStream': + return BatteryFeature.batteryLevelStream; + case 'batteryInfoStream': + return BatteryFeature.batteryInfoStream; + case 'batteryHealthStream': + return BatteryFeature.batteryHealthStream; + case 'lowBatteryMonitoring': + return BatteryFeature.lowBatteryMonitoring; + case 'nativeNotifications': + return BatteryFeature.nativeNotifications; + case 'scheduledNotifications': + return BatteryFeature.scheduledNotifications; + case 'blePeerSync': + return BatteryFeature.blePeerSync; + case 'iotExampleBridge': + return BatteryFeature.iotExampleBridge; + default: + return null; + } + } + + static String _featureToString(BatteryFeature feature) { + switch (feature) { + case BatteryFeature.batteryLevel: + return 'batteryLevel'; + case BatteryFeature.batteryInfo: + return 'batteryInfo'; + case BatteryFeature.batteryHealth: + return 'batteryHealth'; + case BatteryFeature.batteryLevelStream: + return 'batteryLevelStream'; + case BatteryFeature.batteryInfoStream: + return 'batteryInfoStream'; + case BatteryFeature.batteryHealthStream: + return 'batteryHealthStream'; + case BatteryFeature.lowBatteryMonitoring: + return 'lowBatteryMonitoring'; + case BatteryFeature.nativeNotifications: + return 'nativeNotifications'; + case BatteryFeature.scheduledNotifications: + return 'scheduledNotifications'; + case BatteryFeature.blePeerSync: + return 'blePeerSync'; + case BatteryFeature.iotExampleBridge: + return 'iotExampleBridge'; + } + } +} + +class UnsupportedBatteryFeatureException implements Exception { + final BatteryFeature feature; + final String message; + + UnsupportedBatteryFeatureException(this.feature, [this.message = '']); + + @override + String toString() => message.isNotEmpty + ? 'UnsupportedBatteryFeatureException($feature): $message' + : 'UnsupportedBatteryFeatureException($feature)'; +} diff --git a/test/flutter_battery_method_channel_test.dart b/test/flutter_battery_method_channel_test.dart index 513e9c0..6a00372 100644 --- a/test/flutter_battery_method_channel_test.dart +++ b/test/flutter_battery_method_channel_test.dart @@ -1,14 +1,17 @@ import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_battery/flutter_battery_method_channel.dart'; +import 'package:flutter_battery/src/battery_channel_contract.dart'; +import 'package:flutter_battery/src/platform_capabilities.dart'; void main() { TestWidgetsFlutterBinding.ensureInitialized(); - MethodChannelFlutterBattery platform = MethodChannelFlutterBattery(); - const MethodChannel channel = MethodChannel('flutter_battery'); + late MethodChannelFlutterBattery platform; + const MethodChannel channel = MethodChannel(BatteryChannelNames.methodChannel); setUp(() { + platform = MethodChannelFlutterBattery(); TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler( channel, (MethodCall methodCall) async { @@ -18,10 +21,33 @@ void main() { }); tearDown(() { - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler(channel, null); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, null); }); test('getPlatformVersion', () async { expect(await platform.getPlatformVersion(), '42'); }); + + test('getPlatformCapabilities returns empty on MissingPluginException', () async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler( + channel, + (MethodCall methodCall) async { + throw MissingPluginException('not found'); + }, + ); + final caps = await platform.getPlatformCapabilities(); + expect(caps.supportedFeatures, isEmpty); + }); + + test('method_channel_maps_missing_plugin_to_unsupported_for_peer_optional_feature', () async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler( + channel, + (MethodCall methodCall) async { + throw MissingPluginException('not found'); + }, + ); + final caps = await platform.getPlatformCapabilities(); + expect(caps.isSupported(BatteryFeature.blePeerSync), false); + }); } diff --git a/test/flutter_battery_test.dart b/test/flutter_battery_test.dart index 4e8c110..d8c1200 100644 --- a/test/flutter_battery_test.dart +++ b/test/flutter_battery_test.dart @@ -10,6 +10,22 @@ class MockFlutterBatteryPlatform @override Future getPlatformVersion() => Future.value('42'); + @override + Future getPlatformCapabilities() => + Future.value(const BatteryPlatformCapabilities(features: { + BatteryFeature.batteryLevel: true, + BatteryFeature.batteryInfo: true, + BatteryFeature.batteryHealth: true, + BatteryFeature.batteryLevelStream: true, + BatteryFeature.batteryInfoStream: true, + BatteryFeature.batteryHealthStream: true, + BatteryFeature.lowBatteryMonitoring: true, + BatteryFeature.nativeNotifications: true, + BatteryFeature.scheduledNotifications: true, + BatteryFeature.blePeerSync: true, + BatteryFeature.iotExampleBridge: true, + })); + @override Future scheduleNotification({ required String title, @@ -45,30 +61,26 @@ class MockFlutterBatteryPlatform } @override - void setLowBatteryCallback(Function(int batteryLevel) callback) { - // no-op for testing - } + void setLowBatteryCallback(Function(int batteryLevel) callback) {} @override Future stopBatteryMonitoring() { return Future.value(true); } - + @override - void setBatteryLevelChangeCallback(Function(int batteryLevel) callback) { - // no-op for testing - } - + void setBatteryLevelChangeCallback(Function(int batteryLevel) callback) {} + @override Future startBatteryLevelListening() { return Future.value(true); } - + @override Future stopBatteryLevelListening() { return Future.value(true); } - + @override Stream> get batteryStream { return Stream.fromIterable([ @@ -86,14 +98,23 @@ class MockFlutterBatteryPlatform 'recommendations': ['测试建议'], 'timestamp': DateTime.now().millisecondsSinceEpoch, }, + { + 'type': 'BATTERY_INFO', + 'level': 65, + 'isCharging': true, + 'temperature': 28.0, + 'voltage': 4.0, + 'state': 'CHARGING', + 'timestamp': DateTime.now().millisecondsSinceEpoch, + }, ]); } - + @override Future setPushInterval({required int intervalMs, bool enableDebounce = true}) { return Future.value(true); } - + @override Future> getBatteryInfo() { return Future.value({ @@ -102,7 +123,7 @@ class MockFlutterBatteryPlatform 'temperature': 30.5, 'voltage': 4.2, 'state': 'NORMAL', - 'timestamp': DateTime.now().millisecondsSinceEpoch + 'timestamp': DateTime.now().millisecondsSinceEpoch, }); } @@ -121,27 +142,23 @@ class MockFlutterBatteryPlatform 'timestamp': DateTime.now().millisecondsSinceEpoch, }); } - + @override Future> getBatteryOptimizationTips() { return Future.value(['关闭后台应用', '降低屏幕亮度', '启用电池优化模式']); } - + @override - void setBatteryInfoChangeCallback(Function(Map batteryInfo) callback) { - // no-op for testing - } + void setBatteryInfoChangeCallback(Function(Map batteryInfo) callback) {} @override - void setBatteryHealthChangeCallback(Function(Map batteryHealth) callback) { - // no-op for testing - } - + void setBatteryHealthChangeCallback(Function(Map batteryHealth) callback) {} + @override Future startBatteryInfoListening({int intervalMs = 5000}) { return Future.value(true); } - + @override Future stopBatteryInfoListening() { return Future.value(true); @@ -156,7 +173,7 @@ class MockFlutterBatteryPlatform Future stopBatteryHealthListening() { return Future.value(true); } - + @override Future sendNotification({ required String title, @@ -165,7 +182,7 @@ class MockFlutterBatteryPlatform }) { return Future.value(true); } - + @override Future> configureBatteryMonitor({ bool monitorBatteryLevel = false, @@ -183,17 +200,15 @@ class MockFlutterBatteryPlatform 'batteryHealthMonitor': monitorBatteryHealth, }); } - + @override void configureBatteryCallbacks({ Function(int batteryLevel)? onLowBattery, Function(int batteryLevel)? onBatteryLevelChange, Function(Map batteryInfo)? onBatteryInfoChange, Function(Map batteryHealth)? onBatteryHealthChange, - }) { - // no-op for testing - } - + }) {} + @override Future configureBatteryMonitoring({ required bool enable, @@ -210,9 +225,8 @@ class MockFlutterBatteryPlatform void main() { WidgetsFlutterBinding.ensureInitialized(); - - final FlutterBatteryPlatform initialPlatform = - FlutterBatteryPlatform.instance; + + final FlutterBatteryPlatform initialPlatform = FlutterBatteryPlatform.instance; setUp(() { final fakePlatform = MockFlutterBatteryPlatform(); @@ -231,6 +245,25 @@ void main() { expect(await FlutterBatteryPlatform.instance.getBatteryLevel(), 75); }); + test('getPlatformCapabilities returns capabilities', () async { + final caps = await FlutterBatteryPlatform.instance.getPlatformCapabilities(); + expect(caps, isA()); + expect(caps.isSupported(BatteryFeature.batteryLevel), true); + expect(caps.isSupported(BatteryFeature.blePeerSync), true); + }); + + test('FlutterBattery.getPlatformCapabilities delegates correctly', () async { + final plugin = FlutterBattery(); + final caps = await plugin.getPlatformCapabilities(); + expect(caps.isSupported(BatteryFeature.batteryLevel), true); + expect(caps.isSupported(BatteryFeature.batteryInfo), true); + }); + + test('FlutterBattery.isFeatureSupported delegates correctly', () async { + final plugin = FlutterBattery(); + expect(await plugin.isFeatureSupported(BatteryFeature.batteryLevel), true); + }); + test('scheduleNotification returns true', () async { expect( await FlutterBatteryPlatform.instance.scheduleNotification( @@ -266,7 +299,7 @@ void main() { test('stopBatteryMonitoring returns true', () async { expect(await FlutterBatteryPlatform.instance.stopBatteryMonitoring(), true); }); - + test('sendNotification returns true', () async { expect( await FlutterBatteryPlatform.instance.sendNotification( @@ -277,7 +310,7 @@ void main() { true, ); }); - + test('configureBatteryMonitor returns expected map', () async { final result = await FlutterBatteryPlatform.instance.configureBatteryMonitor( monitorBatteryLevel: true, @@ -286,13 +319,12 @@ void main() { batteryInfoIntervalMs: 10000, enableDebounce: true, ); - expect(result, isA>()); expect(result['setPushInterval'], true); expect(result['batteryLevelMonitor'], true); expect(result['batteryInfoMonitor'], true); }); - + test('configureBatteryMonitoring returns true', () async { expect( await FlutterBatteryPlatform.instance.configureBatteryMonitoring( @@ -305,10 +337,9 @@ void main() { true, ); }); - + test('getBatteryInfo returns valid map', () async { final batteryInfo = await FlutterBatteryPlatform.instance.getBatteryInfo(); - expect(batteryInfo, isA>()); expect(batteryInfo['level'], 75); expect(batteryInfo['isCharging'], false); @@ -317,25 +348,23 @@ void main() { expect(batteryInfo['state'], 'NORMAL'); expect(batteryInfo['timestamp'], isA()); }); - + test('getBatteryHealth returns valid map', () async { final health = await FlutterBatteryPlatform.instance.getBatteryHealth(); expect(health['state'], 'GOOD'); expect(health['statusLabel'], isA()); expect(health['recommendations'], isA>()); }); - + test('getBatteryOptimizationTips returns non-empty list', () async { final tips = await FlutterBatteryPlatform.instance.getBatteryOptimizationTips(); - expect(tips, isA>()); expect(tips, isNotEmpty); expect(tips.length, 3); }); - + test('batteryStream emits valid data', () async { final batteryEvent = await FlutterBatteryPlatform.instance.batteryStream.first; - expect(batteryEvent, isA>()); expect(batteryEvent['batteryLevel'], 75); expect(batteryEvent['timestamp'], isA()); @@ -348,6 +377,20 @@ void main() { expect(health.recommendations, isNotEmpty); }); + test('batteryInfoStream_accepts_level_key', () async { + final plugin = FlutterBattery(); + final info = await plugin.batteryInfoStream.firstWhere( + (i) => i.level > 0, + ); + expect(info.level, greaterThan(0)); + }); + + test('batteryInfoStream_accepts_batteryLevel_key', () async { + final plugin = FlutterBattery(); + final info = await plugin.batteryInfoStream.first; + expect(info, isA()); + }); + test('configureBattery aggregates results', () async { final plugin = FlutterBattery(); final result = await plugin.configureBattery( From 15965812b8410e65fc5201adecd2a131210b5f6b Mon Sep 17 00:00:00 2001 From: forest Date: Sun, 10 May 2026 21:29:43 +0800 Subject: [PATCH 3/5] =?UTF-8?q?feat:=E9=80=82=E9=85=8D=20mac=20=E5=B9=B3?= =?UTF-8?q?=E5=8F=B0=E7=94=B5=E6=B1=A0=E7=8A=B6=E6=80=81=E6=A3=80=E6=B5=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- example/lib/main.dart | 27 +- example/lib/master_page.dart | 6 +- example/lib/pages/battery_details_page.dart | 21 +- example/lib/pages/dashboard_page.dart | 21 +- example/lib/pages/event_stream_page.dart | 4 +- example/lib/pages/iot_controls_page.dart | 30 +- .../pages/low_battery_notification_page.dart | 44 ++- example/lib/slave_page.dart | 6 +- example/test/widget_test.dart | 2 +- .../channel/contracts/channel_contract.yaml | 294 +++++++++++++++--- lib/flutter_battery.dart | 182 ++++++----- lib/flutter_battery_method_channel.dart | 32 +- lib/flutter_battery_platform_interface.dart | 145 +++++---- lib/flutter_bluetooth.dart | 9 +- lib/flutter_bluetooth_method_channel.dart | 45 ++- lib/peer_battery_service.dart | 9 +- lib/src/battery_channel_contract.dart | 6 +- .../Classes/BatteryMonitor.swift | 182 +++++++---- .../Classes/FlutterBatteryPlugin.swift | 42 ++- pubspec.yaml | 2 - test/flutter_battery_method_channel_test.dart | 19 +- test/flutter_battery_test.dart | 24 +- 22 files changed, 790 insertions(+), 362 deletions(-) diff --git a/example/lib/main.dart b/example/lib/main.dart index b539b23..39b84df 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -196,19 +196,20 @@ class _FlutterBatteryExampleAppState extends State { case AppRoutes.iotControls: return MaterialPageRoute( settings: settings, - builder: (_) => _capabilities.isSupported(BatteryFeature.iotExampleBridge) - ? IotControlsPage( - startScan: _startScan, - stopScan: _stopScan, - connect: _connect, - disconnect: _disconnect, - startSync: _startSync, - stopSync: _stopSync, - ) - : const _UnsupportedFeaturePage( - title: 'IoT native controls', - feature: BatteryFeature.iotExampleBridge, - ), + builder: (_) => + _capabilities.isSupported(BatteryFeature.iotExampleBridge) + ? IotControlsPage( + startScan: _startScan, + stopScan: _stopScan, + connect: _connect, + disconnect: _disconnect, + startSync: _startSync, + stopSync: _stopSync, + ) + : const _UnsupportedFeaturePage( + title: 'IoT native controls', + feature: BatteryFeature.iotExampleBridge, + ), ); case AppRoutes.eventLog: return MaterialPageRoute( diff --git a/example/lib/master_page.dart b/example/lib/master_page.dart index c378b4f..10f2237 100644 --- a/example/lib/master_page.dart +++ b/example/lib/master_page.dart @@ -34,7 +34,8 @@ class _MasterPageState extends State { if (!mounted) return; setState(() => _state = state); }); - _scanSub = _bluetooth.scanDevices(serviceUuid: _peerServiceUuid).listen((devices) { + _scanSub = + _bluetooth.scanDevices(serviceUuid: _peerServiceUuid).listen((devices) { if (!mounted) return; final merged = {for (final d in _devices) d.id: d}; for (final device in devices) { @@ -85,7 +86,8 @@ class _MasterPageState extends State { Card( child: ListTile( leading: const Icon(Icons.battery_std_outlined), - title: Text('本机电量(Master):${localBattery >= 0 ? '$localBattery%' : '--'}'), + title: Text( + '本机电量(Master):${localBattery >= 0 ? '$localBattery%' : '--'}'), subtitle: Text( '对方电量(Slave):${remoteBattery != null ? '$remoteBattery%' : '--'}', ), diff --git a/example/lib/pages/battery_details_page.dart b/example/lib/pages/battery_details_page.dart index 0f8f068..3f1ecec 100644 --- a/example/lib/pages/battery_details_page.dart +++ b/example/lib/pages/battery_details_page.dart @@ -27,9 +27,11 @@ class BatteryDetailsPage extends StatelessWidget { ], ), body: AnimatedBuilder( - animation: Listenable.merge([levelListenable, infoListenable, healthListenable]), + animation: Listenable.merge( + [levelListenable, infoListenable, healthListenable]), builder: (context, _) { - final level = levelListenable.value ?? infoListenable.value?.level ?? 0; + final level = + levelListenable.value ?? infoListenable.value?.level ?? 0; final info = infoListenable.value; final health = healthListenable.value; return ListView( @@ -56,20 +58,25 @@ class BatteryDetailsPage extends StatelessWidget { ListTile( leading: const Icon(Icons.thermostat_auto_outlined), title: const Text('Temperature'), - subtitle: Text(info != null ? '${info.temperature.toStringAsFixed(1)}°C' : '--'), + subtitle: Text(info != null + ? '${info.temperature.toStringAsFixed(1)}°C' + : '--'), ), const Divider(height: 1), ListTile( leading: const Icon(Icons.speed_outlined), title: const Text('Voltage'), - subtitle: Text(info != null ? '${info.voltage.toStringAsFixed(2)}V' : '--'), + subtitle: Text(info != null + ? '${info.voltage.toStringAsFixed(2)}V' + : '--'), ), const Divider(height: 1), ListTile( leading: const Icon(Icons.electric_bike_outlined), title: const Text('State'), subtitle: Text(info?.state.name ?? 'unknown'), - trailing: Text(info?.isCharging == true ? 'Charging' : 'Idle'), + trailing: + Text(info?.isCharging == true ? 'Charging' : 'Idle'), ), ], ), @@ -96,7 +103,9 @@ class BatteryDetailsPage extends StatelessWidget { ), const SizedBox(height: 6), ...health.recommendations - .map((tip) => Text('• $tip', style: Theme.of(context).textTheme.bodySmall)) + .map((tip) => Text('• $tip', + style: + Theme.of(context).textTheme.bodySmall)) .toList(), ], ), diff --git a/example/lib/pages/dashboard_page.dart b/example/lib/pages/dashboard_page.dart index 821d090..fa3e7d6 100644 --- a/example/lib/pages/dashboard_page.dart +++ b/example/lib/pages/dashboard_page.dart @@ -176,11 +176,14 @@ class _DashboardPageState extends State { ? '选择主/从机后进行电量互通' : '当前平台不支持', ), - trailing: widget.capabilities.isSupported(BatteryFeature.blePeerSync) + trailing: widget.capabilities + .isSupported(BatteryFeature.blePeerSync) ? const Icon(Icons.chevron_right) : const Icon(Icons.block_outlined), - enabled: widget.capabilities.isSupported(BatteryFeature.blePeerSync), - onTap: widget.capabilities.isSupported(BatteryFeature.blePeerSync) + enabled: widget.capabilities + .isSupported(BatteryFeature.blePeerSync), + onTap: widget.capabilities + .isSupported(BatteryFeature.blePeerSync) ? widget.onOpenPeerBatterySync : null, ), @@ -189,15 +192,19 @@ class _DashboardPageState extends State { leading: const Icon(Icons.memory_outlined), title: const Text('IoT native controls'), subtitle: Text( - widget.capabilities.isSupported(BatteryFeature.iotExampleBridge) + widget.capabilities + .isSupported(BatteryFeature.iotExampleBridge) ? 'Scan, connect, and sync via MethodChannel' : '当前平台不支持', ), - trailing: widget.capabilities.isSupported(BatteryFeature.iotExampleBridge) + trailing: widget.capabilities + .isSupported(BatteryFeature.iotExampleBridge) ? const Icon(Icons.chevron_right) : const Icon(Icons.block_outlined), - enabled: widget.capabilities.isSupported(BatteryFeature.iotExampleBridge), - onTap: widget.capabilities.isSupported(BatteryFeature.iotExampleBridge) + enabled: widget.capabilities + .isSupported(BatteryFeature.iotExampleBridge), + onTap: widget.capabilities + .isSupported(BatteryFeature.iotExampleBridge) ? widget.onOpenIotControls : null, ), diff --git a/example/lib/pages/event_stream_page.dart b/example/lib/pages/event_stream_page.dart index c6c5eb4..8288f34 100644 --- a/example/lib/pages/event_stream_page.dart +++ b/example/lib/pages/event_stream_page.dart @@ -15,7 +15,9 @@ class EventStreamPage extends StatelessWidget { valueListenable: eventsListenable, builder: (context, events, _) { if (events.isEmpty) { - return const Center(child: Text('No events yet. Trigger IoT actions to populate the stream.')); + return const Center( + child: Text( + 'No events yet. Trigger IoT actions to populate the stream.')); } return ListView.separated( padding: const EdgeInsets.all(16), diff --git a/example/lib/pages/iot_controls_page.dart b/example/lib/pages/iot_controls_page.dart index fb28bf4..f366e7a 100644 --- a/example/lib/pages/iot_controls_page.dart +++ b/example/lib/pages/iot_controls_page.dart @@ -37,12 +37,30 @@ class IotControlsPage extends StatelessWidget { spacing: 12, runSpacing: 12, children: [ - ElevatedButton.icon(onPressed: startScan, icon: const Icon(Icons.search), label: const Text('Scan')), - ElevatedButton.icon(onPressed: stopScan, icon: const Icon(Icons.close), label: const Text('Stop Scan')), - ElevatedButton.icon(onPressed: connect, icon: const Icon(Icons.usb), label: const Text('Connect')), - ElevatedButton.icon(onPressed: disconnect, icon: const Icon(Icons.link_off), label: const Text('Disconnect')), - ElevatedButton.icon(onPressed: startSync, icon: const Icon(Icons.cloud_upload_outlined), label: const Text('Start Sync')), - ElevatedButton.icon(onPressed: stopSync, icon: const Icon(Icons.cloud_off_outlined), label: const Text('Stop Sync')), + ElevatedButton.icon( + onPressed: startScan, + icon: const Icon(Icons.search), + label: const Text('Scan')), + ElevatedButton.icon( + onPressed: stopScan, + icon: const Icon(Icons.close), + label: const Text('Stop Scan')), + ElevatedButton.icon( + onPressed: connect, + icon: const Icon(Icons.usb), + label: const Text('Connect')), + ElevatedButton.icon( + onPressed: disconnect, + icon: const Icon(Icons.link_off), + label: const Text('Disconnect')), + ElevatedButton.icon( + onPressed: startSync, + icon: const Icon(Icons.cloud_upload_outlined), + label: const Text('Start Sync')), + ElevatedButton.icon( + onPressed: stopSync, + icon: const Icon(Icons.cloud_off_outlined), + label: const Text('Stop Sync')), ], ), const SizedBox(height: 24), diff --git a/example/lib/pages/low_battery_notification_page.dart b/example/lib/pages/low_battery_notification_page.dart index eebcd6a..9ac65dc 100644 --- a/example/lib/pages/low_battery_notification_page.dart +++ b/example/lib/pages/low_battery_notification_page.dart @@ -9,11 +9,14 @@ class LowBatteryNotificationPage extends StatefulWidget { final FlutterBattery plugin; @override - State createState() => _LowBatteryNotificationPageState(); + State createState() => + _LowBatteryNotificationPageState(); } -class _LowBatteryNotificationPageState extends State { - final TextEditingController _titleController = TextEditingController(text: '电池电量低'); +class _LowBatteryNotificationPageState + extends State { + final TextEditingController _titleController = + TextEditingController(text: '电池电量低'); final TextEditingController _messageController = TextEditingController(text: '当前电池电量已低于预设阈值,请注意充电'); @@ -50,13 +53,17 @@ class _LowBatteryNotificationPageState extends State BatteryLevelMonitorConfig( enable: enable, threshold: _threshold.round(), - title: _titleController.text.trim().isEmpty ? '电池电量低' : _titleController.text.trim(), + title: _titleController.text.trim().isEmpty + ? '电池电量低' + : _titleController.text.trim(), message: _messageController.text.trim().isEmpty ? '当前电池电量已低于预设阈值,请注意充电' : _messageController.text.trim(), intervalMinutes: _intervalMinutes.round(), useFlutterRendering: _useFlutterRendering, - onLowBattery: _useFlutterRendering ? (int level) => _showSnack('电量低至 $level%') : null, + onLowBattery: _useFlutterRendering + ? (int level) => _showSnack('电量低至 $level%') + : null, ), ); @@ -64,9 +71,7 @@ class _LowBatteryNotificationPageState extends State setState(() { _monitoringEnabled = enable && (success ?? false); _status = success == true - ? (enable - ? '监控已开启,低于 ${_threshold.round()}% 将通过系统通知提示' - : '监控已关闭') + ? (enable ? '监控已开启,低于 ${_threshold.round()}% 将通过系统通知提示' : '监控已关闭') : '操作未生效,请检查日志'; }); } catch (err) { @@ -97,7 +102,8 @@ class _LowBatteryNotificationPageState extends State ); if (!mounted) return; setState(() { - _status = ok == true ? '通知已${delayMinutes == 0 ? '发送' : '调度'}' : '通知触发失败'; + _status = + ok == true ? '通知已${delayMinutes == 0 ? '发送' : '调度'}' : '通知触发失败'; }); } catch (err) { if (!mounted) return; @@ -142,7 +148,10 @@ class _LowBatteryNotificationPageState extends State value: _useFlutterRendering, title: const Text('同时使用 Flutter 回调'), subtitle: const Text('打开后低电量会先回调 Dart,关闭则直接走原生系统通知'), - onChanged: _busy ? null : (value) => setState(() => _useFlutterRendering = value), + onChanged: _busy + ? null + : (value) => + setState(() => _useFlutterRendering = value), ), const SizedBox(height: 12), _LabeledSlider( @@ -152,7 +161,9 @@ class _LowBatteryNotificationPageState extends State min: 5, max: 50, divisions: 9, - onChanged: _busy ? null : (value) => setState(() => _threshold = value), + onChanged: _busy + ? null + : (value) => setState(() => _threshold = value), ), const SizedBox(height: 8), _LabeledSlider( @@ -162,7 +173,9 @@ class _LowBatteryNotificationPageState extends State min: 1, max: 60, divisions: 59, - onChanged: _busy ? null : (value) => setState(() => _intervalMinutes = value), + onChanged: _busy + ? null + : (value) => setState(() => _intervalMinutes = value), ), const SizedBox(height: 12), TextField( @@ -198,7 +211,9 @@ class _LowBatteryNotificationPageState extends State label: const Text('开启监控'), ), OutlinedButton.icon( - onPressed: (_busy || !_monitoringEnabled) ? null : () => _toggleMonitoring(false), + onPressed: (_busy || !_monitoringEnabled) + ? null + : () => _toggleMonitoring(false), icon: const Icon(Icons.stop), label: const Text('停止监控'), ), @@ -208,7 +223,8 @@ class _LowBatteryNotificationPageState extends State label: const Text('立即测试通知'), ), OutlinedButton.icon( - onPressed: _busy ? null : () => _sendTestNotification(delayMinutes: 1), + onPressed: + _busy ? null : () => _sendTestNotification(delayMinutes: 1), icon: const Icon(Icons.schedule), label: const Text('1 分钟后提醒'), ), diff --git a/example/lib/slave_page.dart b/example/lib/slave_page.dart index a714cd8..d858b0b 100644 --- a/example/lib/slave_page.dart +++ b/example/lib/slave_page.dart @@ -53,8 +53,10 @@ class _SlavePageState extends State { Card( child: ListTile( leading: const Icon(Icons.sensors), - title: Text('本机电量(Slave):${localBattery >= 0 ? '$localBattery%' : '--'}'), - subtitle: Text('主机电量(对方):${remoteBattery != null ? '$remoteBattery%' : '--'}'), + title: Text( + '本机电量(Slave):${localBattery >= 0 ? '$localBattery%' : '--'}'), + subtitle: Text( + '主机电量(对方):${remoteBattery != null ? '$remoteBattery%' : '--'}'), trailing: connected ? const Chip( label: Text('主机已连接'), diff --git a/example/test/widget_test.dart b/example/test/widget_test.dart index d97cd8c..ced2d55 100644 --- a/example/test/widget_test.dart +++ b/example/test/widget_test.dart @@ -4,7 +4,7 @@ import 'package:flutter_battery_example/pages/dashboard_page.dart'; import 'package:flutter_test/flutter_test.dart'; void main() { - final unsupportedCaps = const BatteryPlatformCapabilities(features: { + const unsupportedCaps = BatteryPlatformCapabilities(features: { BatteryFeature.batteryLevel: true, BatteryFeature.batteryInfo: true, BatteryFeature.batteryHealth: true, diff --git a/integration/channel/contracts/channel_contract.yaml b/integration/channel/contracts/channel_contract.yaml index 2f18437..7e86967 100644 --- a/integration/channel/contracts/channel_contract.yaml +++ b/integration/channel/contracts/channel_contract.yaml @@ -1,43 +1,251 @@ -channels: - method: - name: iot/native - methods: - - id: scanDevices - args: - filters: map - - id: connect - args: - deviceId: string - - id: startTelemetry - args: - deviceId: string - metrics: list - - id: stopTelemetry - - id: requestBatterySnapshot - args: - deviceId: string? - event: - name: iot/stream - payload: - type: enum(telemetry|battery|connection|discovered) - deviceId: string? - timestamp: int - data: map - peer_method: - name: flutter_battery/peer_methods - methods: - - id: startMasterMode - - id: startSlaveMode - - id: stopMasterMode - - id: stopSlaveMode - - id: stopAllPeerModes - - id: masterConnectToDevice - args: - deviceId: string - peer_event: - name: flutter_battery/peer_events - payload: - role: enum(master|slave) - localBattery: int - remoteBattery: int? - connected: bool +# ============================================================ +# Flutter Battery — Channel Contract +# ============================================================ +# This file documents all method/event channels used by the +# flutter_battery plugin and the example app. +# +# Plugin channels (part of the public plugin API): +# flutter_battery - method channel for battery operations +# flutter_battery/battery_stream - event channel for battery events +# +# Optional plugin channels (Android-only): +# flutter_battery/ble_methods - BLE method calls +# flutter_battery/ble_scan_events - BLE scan event stream +# flutter_battery/ble_connection_events - BLE connection event stream +# flutter_battery/peer_methods - peer sync method calls +# flutter_battery/peer_events - peer sync event stream +# +# Example-only channels (NOT part of the plugin public API): +# iot/native - IoT demo method channel (Android example only) +# iot/stream - IoT demo event channel (Android example only) +# ============================================================ + +flutter_battery_method_channel: + name: flutter_battery + methods: + - id: getPlatformVersion + - id: getPlatformCapabilities + result: map + - id: getBatteryLevel + result: int? + - id: getBatteryInfo + result: map + - id: getBatteryHealth + result: map + - id: getBatteryOptimizationTips + result: list + - id: startBatteryLevelListening + - id: stopBatteryLevelListening + - id: startBatteryInfoListening + args: + intervalMs: int? + - id: stopBatteryInfoListening + - id: startBatteryHealthListening + args: + intervalMs: int? + - id: stopBatteryHealthListening + - id: setPushInterval + args: + intervalMs: int + enableDebounce: bool + - id: setBatteryLevelThreshold + args: + threshold: int + title: string + message: string + intervalMinutes: int + useFlutterRendering: bool + - id: stopBatteryMonitoring + - id: scheduleNotification + args: + title: string + message: string + delayMinutes: int + - id: showNotification + args: + title: string + message: string + +flutter_battery_event_channel: + name: flutter_battery/battery_stream + event_types: + BATTERY_LEVEL: + required: + - type + - timestamp + optional: + - batteryLevel + - level + - unavailableReason + BATTERY_INFO: + required: + - type + - timestamp + - isCharging + - state + optional: + - level + - batteryLevel + - isCharged + - timeToFull + - timeToEmpty + - temperature + - voltage + - unavailableReason + BATTERY_HEALTH: + required: + - type + - timestamp + - state + - statusLabel + - isGood + - riskLevel + - recommendations + optional: + - level + - batteryLevel + - isCharging + - temperature + - voltage + - healthPercentage + - maxCapacity + - currentCapacity + - designCapacity + - cycleCount + - serialNumber + - manufacturer + - deviceName + - unavailableReason + BATTERY_UNAVAILABLE: + required: + - type + - timestamp + optional: + - unavailableReason + BATTERY_ERROR: + required: + - type + - timestamp + optional: + - error + +platform_support_matrix: + android: + batteryLevel: true + batteryInfo: true + batteryHealth: true + batteryLevelStream: true + batteryInfoStream: true + batteryHealthStream: true + lowBatteryMonitoring: true + nativeNotifications: true + scheduledNotifications: true + blePeerSync: true + iotExampleBridge: true + macos: + batteryLevel: true + batteryInfo: true + batteryHealth: true + batteryLevelStream: true + batteryInfoStream: true + batteryHealthStream: true + lowBatteryMonitoring: true + nativeNotifications: false + scheduledNotifications: false + blePeerSync: false + iotExampleBridge: false + +# ============================================================ +# BLE channels (optional, Android-only plugin feature) +# ============================================================ +ble_methods: + name: flutter_battery/ble_methods + methods: + - id: isBleAvailable + - id: isBleEnabled + - id: startScan + args: + serviceUuid: string? + - id: stopScan + - id: connect + args: + deviceId: string + autoConnect: bool + - id: disconnect + args: + deviceId: string? + - id: writeCharacteristic + args: + deviceId: string + serviceUuid: string + characteristicUuid: string + value: list + withResponse: bool + +ble_scan_events: + name: flutter_battery/ble_scan_events + payload: + type: list + +ble_connection_events: + name: flutter_battery/ble_connection_events + payload: + state: enum(disconnected|connecting|connected|disconnecting) + deviceId: string + error: string? + +# ============================================================ +# Peer sync channels (optional, Android-only plugin feature) +# ============================================================ +peer_methods: + name: flutter_battery/peer_methods + methods: + - id: startMasterMode + - id: startSlaveMode + - id: stopMasterMode + - id: stopSlaveMode + - id: stopAllPeerModes + - id: masterConnectToDevice + args: + deviceId: string + +peer_events: + name: flutter_battery/peer_events + payload: + role: enum(master|slave) + localBattery: int + remoteBattery: int? + connected: bool + +# ============================================================ +# Example-only channels (NOT part of plugin public API) +# These channels are used by the example app for IoT demo +# purposes and are only available on Android. +# ============================================================ +example_iot_native: + name: iot/native + status: example_only_android + methods: + - id: scanDevices + args: + filters: map + - id: connect + args: + deviceId: string + - id: startTelemetry + args: + deviceId: string + metrics: list + - id: stopTelemetry + - id: requestBatterySnapshot + args: + deviceId: string? + +example_iot_stream: + name: iot/stream + status: example_only_android + payload: + type: enum(telemetry|battery|connection|discovered) + deviceId: string? + timestamp: int + data: map diff --git a/lib/flutter_battery.dart b/lib/flutter_battery.dart index d2fb5a3..5fafdfd 100644 --- a/lib/flutter_battery.dart +++ b/lib/flutter_battery.dart @@ -8,36 +8,36 @@ export 'src/platform_capabilities.dart'; /// 电池状态枚举 enum BatteryState { - NORMAL, // 正常状态 - LOW, // 低电量状态 - CRITICAL, // 极低电量状态 - CHARGING, // 充电状态 - FULL // 已充满状态 + NORMAL, // 正常状态 + LOW, // 低电量状态 + CRITICAL, // 极低电量状态 + CHARGING, // 充电状态 + FULL // 已充满状态 } /// 监听配置类,用于配置电池监控选项 class BatteryMonitorConfig { /// 是否监控电池电量 final bool monitorBatteryLevel; - + /// 是否监控电池完整信息 final bool monitorBatteryInfo; - + /// 推送间隔(毫秒) final int intervalMs; - + /// 电池信息推送间隔(毫秒) final int batteryInfoIntervalMs; - + /// 是否监控电池健康状态 final bool monitorBatteryHealth; - + /// 电池健康推送间隔 final int batteryHealthIntervalMs; - + /// 是否启用防抖动(仅在电量变化时推送) final bool enableDebounce; - + /// 创建监控配置 BatteryMonitorConfig({ this.monitorBatteryLevel = true, @@ -54,25 +54,25 @@ class BatteryMonitorConfig { class BatteryLevelMonitorConfig { /// 是否启用监控 final bool enable; - + /// 电池电量阈值(百分比) final int threshold; - + /// 通知标题 final String title; - + /// 通知内容 final String message; - + /// 检查间隔(分钟) final int intervalMinutes; - + /// 是否使用Flutter渲染通知 final bool useFlutterRendering; - + /// 低电量回调 final Function(int)? onLowBattery; - + /// 创建低电量监控配置 BatteryLevelMonitorConfig({ required this.enable, @@ -93,7 +93,7 @@ class BatteryInfo { final double voltage; final BatteryState state; final int timestamp; - + BatteryInfo({ required this.level, required this.isCharging, @@ -102,7 +102,7 @@ class BatteryInfo { required this.state, required this.timestamp, }); - + /// 从Map创建电池信息对象 factory BatteryInfo.fromMap(Map map) { return BatteryInfo( @@ -111,14 +111,15 @@ class BatteryInfo { temperature: (map['temperature'] as num?)?.toDouble() ?? 0.0, voltage: (map['voltage'] as num?)?.toDouble() ?? 0.0, state: _parseState(map['state'] as String?), - timestamp: map['timestamp'] as int? ?? DateTime.now().millisecondsSinceEpoch, + timestamp: + map['timestamp'] as int? ?? DateTime.now().millisecondsSinceEpoch, ); } - + /// 解析电池状态字符串 static BatteryState _parseState(String? stateStr) { if (stateStr == null) return BatteryState.NORMAL; - + try { return BatteryState.values.firstWhere( (e) => e.toString() == 'BatteryState.$stateStr', @@ -128,11 +129,11 @@ class BatteryInfo { return BatteryState.NORMAL; } } - + @override String toString() => 'BatteryInfo(level: $level%, isCharging: $isCharging, ' - 'temperature: ${temperature.toStringAsFixed(1)}°C, ' - 'voltage: ${voltage.toStringAsFixed(2)}V, state: $state)'; + 'temperature: ${temperature.toStringAsFixed(1)}°C, ' + 'voltage: ${voltage.toStringAsFixed(2)}V, state: $state)'; } enum BatteryHealthState { @@ -217,22 +218,22 @@ class BatteryHealth { class BatteryConfiguration { /// 基本监听配置 final BatteryMonitorConfig? monitorConfig; - + /// 低电量监控配置 final BatteryLevelMonitorConfig? lowBatteryConfig; - + /// 电池电量变化回调 final Function(int batteryLevel)? onBatteryLevelChange; - + /// 电池信息变化回调 final Function(BatteryInfo info)? onBatteryInfoChange; /// 电池健康变化回调 final Function(BatteryHealth health)? onBatteryHealthChange; - + /// 低电量回调 final Function(int batteryLevel)? onLowBattery; - + /// 创建高级电池配置 BatteryConfiguration({ this.monitorConfig, @@ -258,34 +259,34 @@ class FlutterBattery { final capabilities = await getPlatformCapabilities(); return capabilities.isSupported(feature); } - + /// 获取电池电量百分比 Future getBatteryLevel() { return FlutterBatteryPlatform.instance.getBatteryLevel(); } - + /// 获取电池完整信息 Future getBatteryInfo() async { final infoMap = await FlutterBatteryPlatform.instance.getBatteryInfo(); return BatteryInfo.fromMap(infoMap); } - + /// 获取电池健康信息 Future getBatteryHealth() async { final healthMap = await FlutterBatteryPlatform.instance.getBatteryHealth(); return BatteryHealth.fromMap(healthMap); } - + /// 获取电池优化建议 Future> getBatteryOptimizationTips() { return FlutterBatteryPlatform.instance.getBatteryOptimizationTips(); } - + /// 获取电池信息流(原始数据) Stream> get batteryStream { return FlutterBatteryPlatform.instance.batteryStream; } - + static int _normalizeLevel(Map event) { final level = event[BatteryPayloadKeys.level] as int?; final batteryLevel = event[BatteryPayloadKeys.batteryLevel] as int?; @@ -310,7 +311,8 @@ class FlutterBattery { return BatteryInfo( level: level, isCharging: event[BatteryPayloadKeys.isCharging] as bool? ?? false, - temperature: (event[BatteryPayloadKeys.temperature] as num?)?.toDouble() ?? 0.0, + temperature: + (event[BatteryPayloadKeys.temperature] as num?)?.toDouble() ?? 0.0, voltage: (event[BatteryPayloadKeys.voltage] as num?)?.toDouble() ?? 0.0, state: level <= 20 ? BatteryState.LOW : BatteryState.NORMAL, timestamp: timestamp, @@ -320,12 +322,14 @@ class FlutterBattery { Stream get batteryHealthStream { return batteryStream - .where((event) => event[BatteryPayloadKeys.type] == BatteryEventTypes.batteryHealth) - .map((event) => BatteryHealth.fromMap(Map.from(event))); + .where((event) => + event[BatteryPayloadKeys.type] == BatteryEventTypes.batteryHealth) + .map( + (event) => BatteryHealth.fromMap(Map.from(event))); } - + /// 配置所有电池相关回调 - /// + /// /// 一次性设置所有回调,减少多次调用接口 /// [onLowBattery] 低电量回调 /// [onBatteryLevelChange] 电池电量变化回调 @@ -340,28 +344,31 @@ class FlutterBattery { if (onLowBattery != null) { FlutterBatteryPlatform.instance.setLowBatteryCallback(onLowBattery); } - + // 设置电池电量变化回调 if (onBatteryLevelChange != null) { - FlutterBatteryPlatform.instance.setBatteryLevelChangeCallback(onBatteryLevelChange); + FlutterBatteryPlatform.instance + .setBatteryLevelChangeCallback(onBatteryLevelChange); } - + // 设置电池信息变化回调 if (onBatteryInfoChange != null) { - FlutterBatteryPlatform.instance.setBatteryInfoChangeCallback((Map infoMap) { + FlutterBatteryPlatform.instance + .setBatteryInfoChangeCallback((Map infoMap) { final info = BatteryInfo.fromMap(infoMap); onBatteryInfoChange(info); }); } if (onBatteryHealthChange != null) { - FlutterBatteryPlatform.instance.setBatteryHealthChangeCallback((Map map) { + FlutterBatteryPlatform.instance + .setBatteryHealthChangeCallback((Map map) { final health = BatteryHealth.fromMap(map); onBatteryHealthChange(health); }); } } - + /// 设置电池电量推送间隔和防抖动 @Deprecated('请使用configureBatteryMonitor方法代替') Future setPushInterval({ @@ -373,28 +380,33 @@ class FlutterBattery { enableDebounce: enableDebounce, ); } - + /// 设置电池电量变化监听 @Deprecated('请使用configureBatteryCallbacks方法代替') - void setBatteryLevelChangeListener(Function(int batteryLevel) onBatteryLevelChanged) { - FlutterBatteryPlatform.instance.setBatteryLevelChangeCallback(onBatteryLevelChanged); + void setBatteryLevelChangeListener( + Function(int batteryLevel) onBatteryLevelChanged) { + FlutterBatteryPlatform.instance + .setBatteryLevelChangeCallback(onBatteryLevelChanged); } - + /// 设置电池信息变化监听 @Deprecated('请使用configureBatteryCallbacks方法代替') - void setBatteryInfoChangeListener(Function(BatteryInfo info) onBatteryInfoChanged) { - FlutterBatteryPlatform.instance.setBatteryInfoChangeCallback((Map infoMap) { + void setBatteryInfoChangeListener( + Function(BatteryInfo info) onBatteryInfoChanged) { + FlutterBatteryPlatform.instance + .setBatteryInfoChangeCallback((Map infoMap) { final info = BatteryInfo.fromMap(infoMap); onBatteryInfoChanged(info); }); } - + /// 配置电池监听 - /// + /// /// 一次性配置电池监听选项,减少多次调用接口 /// [config] 监听配置,包含监听类型、间隔等 /// 返回一个包含各项配置是否成功的Map - Future> configureBatteryMonitor(BatteryMonitorConfig config) async { + Future> configureBatteryMonitor( + BatteryMonitorConfig config) async { return FlutterBatteryPlatform.instance.configureBatteryMonitor( monitorBatteryLevel: config.monitorBatteryLevel, monitorBatteryInfo: config.monitorBatteryInfo, @@ -405,19 +417,19 @@ class FlutterBattery { enableDebounce: config.enableDebounce, ); } - + /// 开始监听电池电量变化(建议使用configureBatteryMonitor替代) @Deprecated('请使用configureBatteryMonitor方法代替') Future startBatteryLevelListening() { return FlutterBatteryPlatform.instance.startBatteryLevelListening(); } - + /// 停止监听电池电量变化(建议使用configureBatteryMonitor替代) @Deprecated('请使用configureBatteryMonitor方法代替') Future stopBatteryLevelListening() { return FlutterBatteryPlatform.instance.stopBatteryLevelListening(); } - + /// 开始监听电池信息变化(建议使用configureBatteryMonitor替代) @Deprecated('请使用configureBatteryMonitor方法代替') Future startBatteryInfoListening({int intervalMs = 5000}) { @@ -425,15 +437,15 @@ class FlutterBattery { intervalMs: intervalMs, ); } - + /// 停止监听电池信息变化(建议使用configureBatteryMonitor替代) @Deprecated('请使用configureBatteryMonitor方法代替') Future stopBatteryInfoListening() { return FlutterBatteryPlatform.instance.stopBatteryInfoListening(); } - + /// 配置电池低电量监控 - /// + /// /// 一次性配置低电量监控,可启用或停用 /// [config] 低电量监控配置 /// 返回配置是否成功 @@ -448,7 +460,7 @@ class FlutterBattery { onLowBattery: config.onLowBattery, ); } - + /// 设置电池低电量阈值监控(建议使用configureBatteryMonitoring替代) @Deprecated('请使用configureBatteryMonitoring方法代替') Future setBatteryLevelThreshold({ @@ -462,7 +474,7 @@ class FlutterBattery { if (useFlutterRendering && onLowBattery != null) { FlutterBatteryPlatform.instance.setLowBatteryCallback(onLowBattery); } - + return FlutterBatteryPlatform.instance.setBatteryLevelThreshold( threshold: threshold, title: title, @@ -472,15 +484,15 @@ class FlutterBattery { onLowBattery: onLowBattery, ); } - + /// 停止电池电量监控(建议使用configureBatteryMonitoring替代) @Deprecated('请使用configureBatteryMonitoring方法代替') Future stopBatteryMonitoring() { return FlutterBatteryPlatform.instance.stopBatteryMonitoring(); } - + /// 发送通知 - /// + /// /// 统一的通知发送方法,支持即时或延迟发送 /// [title] 通知标题 /// [message] 通知内容 @@ -496,7 +508,7 @@ class FlutterBattery { delay: delay, ); } - + /// 调度一个延迟通知(建议使用sendNotification替代) @Deprecated('请使用sendNotification方法代替') Future scheduleNotification({ @@ -510,7 +522,7 @@ class FlutterBattery { delayMinutes: delayMinutes, ); } - + /// 立即显示一个通知(建议使用sendNotification替代) @Deprecated('请使用sendNotification方法代替') Future showNotification({ @@ -522,40 +534,42 @@ class FlutterBattery { message: message, ); } - + /// 一次性配置所有电池相关设置 - /// + /// /// 高级API,整合了监控、回调和低电量设置 /// [config] 完整的电池配置 /// 返回配置结果,包含各项配置是否成功的信息 - Future> configureBattery(BatteryConfiguration config) async { + Future> configureBattery( + BatteryConfiguration config) async { final result = {}; - + // 1. 设置回调 - if (config.onBatteryLevelChange != null || - config.onBatteryInfoChange != null || + if (config.onBatteryLevelChange != null || + config.onBatteryInfoChange != null || config.onLowBattery != null) { - configureBatteryCallbacks( onBatteryLevelChange: config.onBatteryLevelChange, onBatteryInfoChange: config.onBatteryInfoChange, onBatteryHealthChange: config.onBatteryHealthChange, onLowBattery: config.onLowBattery, ); - + result['callbacksConfigured'] = true; } - + // 2. 配置电池监听 if (config.monitorConfig != null) { - result['monitoringResults'] = await configureBatteryMonitor(config.monitorConfig!); + result['monitoringResults'] = + await configureBatteryMonitor(config.monitorConfig!); } - + // 3. 配置低电量监控 if (config.lowBatteryConfig != null) { - result['lowBatteryMonitoring'] = await configureBatteryMonitoring(config.lowBatteryConfig!); + result['lowBatteryMonitoring'] = + await configureBatteryMonitoring(config.lowBatteryConfig!); } - + return result; } } diff --git a/lib/flutter_battery_method_channel.dart b/lib/flutter_battery_method_channel.dart index 36d3e8c..23402cd 100644 --- a/lib/flutter_battery_method_channel.dart +++ b/lib/flutter_battery_method_channel.dart @@ -26,13 +26,15 @@ class MethodChannelFlutterBattery extends FlutterBatteryPlatform { Future _handleMethodCall(MethodCall call) async { switch (call.method) { case BatteryMethodNames.onLowBattery: - final int batteryLevel = call.arguments[BatteryPayloadKeys.batteryLevel] as int; + final int batteryLevel = + call.arguments[BatteryPayloadKeys.batteryLevel] as int; if (_lowBatteryCallback != null) { _lowBatteryCallback!(batteryLevel); } return true; case BatteryMethodNames.onBatteryLevelChanged: - final int batteryLevel = call.arguments[BatteryPayloadKeys.batteryLevel] as int; + final int batteryLevel = + call.arguments[BatteryPayloadKeys.batteryLevel] as int; if (_batteryLevelChangeCallback != null) { _batteryLevelChangeCallback!(batteryLevel); } @@ -70,7 +72,8 @@ class MethodChannelFlutterBattery extends FlutterBatteryPlatform { if (result == null) { return const BatteryPlatformCapabilities(features: {}); } - return BatteryPlatformCapabilities.fromMap(result.cast()); + return BatteryPlatformCapabilities.fromMap( + result.cast()); } on MissingPluginException { return const BatteryPlatformCapabilities(features: {}); } @@ -90,9 +93,12 @@ class MethodChannelFlutterBattery extends FlutterBatteryPlatform { @override Future> getBatteryInfo() async { - final result = await methodChannel.invokeMapMethod(BatteryMethodNames.getBatteryInfo); + final result = + await methodChannel.invokeMapMethod(BatteryMethodNames.getBatteryInfo); if (result == null) { - return {BatteryPayloadKeys.error: 'Failed to get battery info'}; + return { + BatteryPayloadKeys.error: 'Failed to get battery info' + }; } return result.cast(); } @@ -108,9 +114,12 @@ class MethodChannelFlutterBattery extends FlutterBatteryPlatform { @override Future> getBatteryHealth() async { - final result = await methodChannel.invokeMapMethod(BatteryMethodNames.getBatteryHealth); + final result = await methodChannel + .invokeMapMethod(BatteryMethodNames.getBatteryHealth); if (result == null) { - return {BatteryPayloadKeys.error: 'Failed to get battery health'}; + return { + BatteryPayloadKeys.error: 'Failed to get battery health' + }; } return result.cast(); } @@ -126,12 +135,14 @@ class MethodChannelFlutterBattery extends FlutterBatteryPlatform { } @override - void setBatteryInfoChangeCallback(Function(Map batteryInfo) callback) { + void setBatteryInfoChangeCallback( + Function(Map batteryInfo) callback) { _batteryInfoChangeCallback = callback; } @override - void setBatteryHealthChangeCallback(Function(Map batteryHealth) callback) { + void setBatteryHealthChangeCallback( + Function(Map batteryHealth) callback) { _batteryHealthChangeCallback = callback; } @@ -163,7 +174,8 @@ class MethodChannelFlutterBattery extends FlutterBatteryPlatform { @override Future startBatteryHealthListening({int intervalMs = 10000}) async { - final result = await _invoke(BatteryMethodNames.startBatteryHealthListening, { + final result = + await _invoke(BatteryMethodNames.startBatteryHealthListening, { BatteryPayloadKeys.intervalMs: intervalMs, }); return result as bool?; diff --git a/lib/flutter_battery_platform_interface.dart b/lib/flutter_battery_platform_interface.dart index 00eaeaa..9e344ed 100644 --- a/lib/flutter_battery_platform_interface.dart +++ b/lib/flutter_battery_platform_interface.dart @@ -25,39 +25,41 @@ abstract class FlutterBatteryPlatform extends PlatformInterface { } Future getPlatformCapabilities() { - throw UnimplementedError('getPlatformCapabilities() has not been implemented.'); + throw UnimplementedError( + 'getPlatformCapabilities() has not been implemented.'); } Future getPlatformVersion() { throw UnimplementedError('platformVersion() has not been implemented.'); } - + /// 获取电池电量百分比 Future getBatteryLevel() { throw UnimplementedError('getBatteryLevel() has not been implemented.'); } - + /// 获取电池完整信息 - /// + /// /// 返回一个包含电池详细信息的Map,包括电量、充电状态、温度、电压等 Future> getBatteryInfo() { throw UnimplementedError('getBatteryInfo() has not been implemented.'); } - + /// 获取电池健康信息 Future> getBatteryHealth() { throw UnimplementedError('getBatteryHealth() has not been implemented.'); } - + /// 获取电池优化建议 - /// + /// /// 返回基于当前电池状态的优化建议列表 Future> getBatteryOptimizationTips() { - throw UnimplementedError('getBatteryOptimizationTips() has not been implemented.'); + throw UnimplementedError( + 'getBatteryOptimizationTips() has not been implemented.'); } - + /// 配置电池监听和回调 - /// + /// /// 此方法整合了多个回调设置,允许同时设置多种不同的电池事件回调 /// [onLowBattery] 低电量回调 /// [onBatteryLevelChange] 电池电量变化回调 @@ -71,11 +73,11 @@ abstract class FlutterBatteryPlatform extends PlatformInterface { if (onLowBattery != null) { setLowBatteryCallback(onLowBattery); } - + if (onBatteryLevelChange != null) { setBatteryLevelChangeCallback(onBatteryLevelChange); } - + if (onBatteryInfoChange != null) { setBatteryInfoChangeCallback(onBatteryInfoChange); } @@ -84,29 +86,35 @@ abstract class FlutterBatteryPlatform extends PlatformInterface { setBatteryHealthChangeCallback(onBatteryHealthChange); } } - + /// 设置低电量回调函数 void setLowBatteryCallback(Function(int batteryLevel) callback) { - throw UnimplementedError('setLowBatteryCallback() has not been implemented.'); + throw UnimplementedError( + 'setLowBatteryCallback() has not been implemented.'); } - + /// 设置电池电量变化回调函数 void setBatteryLevelChangeCallback(Function(int batteryLevel) callback) { - throw UnimplementedError('setBatteryLevelChangeCallback() has not been implemented.'); + throw UnimplementedError( + 'setBatteryLevelChangeCallback() has not been implemented.'); } - + /// 设置电池信息变化回调函数 - void setBatteryInfoChangeCallback(Function(Map batteryInfo) callback) { - throw UnimplementedError('setBatteryInfoChangeCallback() has not been implemented.'); + void setBatteryInfoChangeCallback( + Function(Map batteryInfo) callback) { + throw UnimplementedError( + 'setBatteryInfoChangeCallback() has not been implemented.'); } /// 设置电池健康变化回调函数 - void setBatteryHealthChangeCallback(Function(Map batteryHealth) callback) { - throw UnimplementedError('setBatteryHealthChangeCallback() has not been implemented.'); + void setBatteryHealthChangeCallback( + Function(Map batteryHealth) callback) { + throw UnimplementedError( + 'setBatteryHealthChangeCallback() has not been implemented.'); } - + /// 配置电池监听选项 - /// + /// /// 此方法整合了多个电池监听功能,可以同时配置电池电量监听和电池信息监听 /// [monitorBatteryLevel] 是否监控电池电量 /// [monitorBatteryInfo] 是否监控电池完整信息 @@ -118,87 +126,99 @@ abstract class FlutterBatteryPlatform extends PlatformInterface { bool monitorBatteryLevel = false, bool monitorBatteryInfo = false, bool monitorBatteryHealth = false, - int intervalMs = 1000, + int intervalMs = 1000, int batteryInfoIntervalMs = 5000, int batteryHealthIntervalMs = 10000, bool enableDebounce = true, }) async { final result = {}; - + // 设置推送间隔 if (monitorBatteryLevel || monitorBatteryInfo) { result['setPushInterval'] = await setPushInterval( - intervalMs: intervalMs, - enableDebounce: enableDebounce, - ) ?? false; + intervalMs: intervalMs, + enableDebounce: enableDebounce, + ) ?? + false; } - + // 启动或停止电池电量监听 if (monitorBatteryLevel) { - result['batteryLevelMonitor'] = await startBatteryLevelListening() ?? false; + result['batteryLevelMonitor'] = + await startBatteryLevelListening() ?? false; } else { - result['batteryLevelMonitor'] = await stopBatteryLevelListening() ?? false; + result['batteryLevelMonitor'] = + await stopBatteryLevelListening() ?? false; } - + // 启动或停止电池信息监听 if (monitorBatteryInfo) { result['batteryInfoMonitor'] = await startBatteryInfoListening( - intervalMs: batteryInfoIntervalMs, - ) ?? false; + intervalMs: batteryInfoIntervalMs, + ) ?? + false; } else { result['batteryInfoMonitor'] = await stopBatteryInfoListening() ?? false; } if (monitorBatteryHealth) { result['batteryHealthMonitor'] = await startBatteryHealthListening( - intervalMs: batteryHealthIntervalMs, - ) ?? false; + intervalMs: batteryHealthIntervalMs, + ) ?? + false; } else { - result['batteryHealthMonitor'] = await stopBatteryHealthListening() ?? false; + result['batteryHealthMonitor'] = + await stopBatteryHealthListening() ?? false; } - + return result; } - + /// 开始监听电池电量变化 Future startBatteryLevelListening() { - throw UnimplementedError('startBatteryLevelListening() has not been implemented.'); + throw UnimplementedError( + 'startBatteryLevelListening() has not been implemented.'); } - + /// 停止监听电池电量变化 Future stopBatteryLevelListening() { - throw UnimplementedError('stopBatteryLevelListening() has not been implemented.'); + throw UnimplementedError( + 'stopBatteryLevelListening() has not been implemented.'); } - + /// 开始监听完整电池信息变化 Future startBatteryInfoListening({ int intervalMs = 5000, }) { - throw UnimplementedError('startBatteryInfoListening() has not been implemented.'); + throw UnimplementedError( + 'startBatteryInfoListening() has not been implemented.'); } - + /// 停止监听完整电池信息变化 Future stopBatteryInfoListening() { - throw UnimplementedError('stopBatteryInfoListening() has not been implemented.'); + throw UnimplementedError( + 'stopBatteryInfoListening() has not been implemented.'); } /// 开始监听电池健康状态 Future startBatteryHealthListening({ int intervalMs = 10000, }) { - throw UnimplementedError('startBatteryHealthListening() has not been implemented.'); + throw UnimplementedError( + 'startBatteryHealthListening() has not been implemented.'); } /// 停止监听电池健康状态 Future stopBatteryHealthListening() { - throw UnimplementedError('stopBatteryHealthListening() has not been implemented.'); + throw UnimplementedError( + 'stopBatteryHealthListening() has not been implemented.'); } - + /// 获取电池电量信息流 Stream> get batteryStream { throw UnimplementedError('batteryStream has not been implemented.'); } - + /// 设置电池信息推送间隔 Future setPushInterval({ required int intervalMs, @@ -206,9 +226,9 @@ abstract class FlutterBatteryPlatform extends PlatformInterface { }) { throw UnimplementedError('setPushInterval() has not been implemented.'); } - + /// 配置电池监控 - /// + /// /// 此方法整合了低电量监控的设置和停止功能 /// [enable] 是否启用监控 /// [threshold] 电池电量阈值(百分比) @@ -239,7 +259,7 @@ abstract class FlutterBatteryPlatform extends PlatformInterface { return stopBatteryMonitoring(); } } - + /// 设置电池低电量阈值监控 Future setBatteryLevelThreshold({ required int threshold, @@ -249,16 +269,18 @@ abstract class FlutterBatteryPlatform extends PlatformInterface { bool useFlutterRendering = false, Function(int)? onLowBattery, }) { - throw UnimplementedError('setBatteryLevelThreshold() has not been implemented.'); + throw UnimplementedError( + 'setBatteryLevelThreshold() has not been implemented.'); } - + /// 停止电池电量监控 Future stopBatteryMonitoring() { - throw UnimplementedError('stopBatteryMonitoring() has not been implemented.'); + throw UnimplementedError( + 'stopBatteryMonitoring() has not been implemented.'); } - + /// 发送通知 - /// + /// /// 此方法整合了立即通知和延迟通知功能 /// [title] 通知标题 /// [message] 通知内容 @@ -281,16 +303,17 @@ abstract class FlutterBatteryPlatform extends PlatformInterface { ); } } - + /// 调度一个延迟通知 Future scheduleNotification({ required String title, required String message, int delayMinutes = 1, }) { - throw UnimplementedError('scheduleNotification() has not been implemented.'); + throw UnimplementedError( + 'scheduleNotification() has not been implemented.'); } - + /// 立即显示一个通知 Future showNotification({ required String title, diff --git a/lib/flutter_bluetooth.dart b/lib/flutter_bluetooth.dart index a6e2e61..4997462 100644 --- a/lib/flutter_bluetooth.dart +++ b/lib/flutter_bluetooth.dart @@ -3,7 +3,11 @@ library flutter_bluetooth; import 'flutter_bluetooth_platform_interface.dart'; export 'flutter_bluetooth_platform_interface.dart' - show BleDevice, BleConnectionEvent, BleConnectionState, FlutterBluetoothPlatform; + show + BleDevice, + BleConnectionEvent, + BleConnectionState, + FlutterBluetoothPlatform; class FlutterBluetooth { FlutterBluetooth._(); @@ -22,7 +26,8 @@ class FlutterBluetooth { Future stopScan() => _platform.stopScan(); - Stream get connectionEvents => _platform.connectionEvents(); + Stream get connectionEvents => + _platform.connectionEvents(); Future connect(String deviceId, {bool autoConnect = false}) => _platform.connect(deviceId, autoConnect: autoConnect); diff --git a/lib/flutter_bluetooth_method_channel.dart b/lib/flutter_bluetooth_method_channel.dart index 3cce06a..0bd8f12 100644 --- a/lib/flutter_bluetooth_method_channel.dart +++ b/lib/flutter_bluetooth_method_channel.dart @@ -6,8 +6,10 @@ import 'flutter_bluetooth_platform_interface.dart'; import 'src/battery_channel_contract.dart'; class MethodChannelFlutterBluetooth extends FlutterBluetoothPlatform { - static const MethodChannel _methodChannel = MethodChannel(BatteryChannelNames.bleMethods); - static const EventChannel _scanEventChannel = EventChannel(BatteryChannelNames.bleScanEvents); + static const MethodChannel _methodChannel = + MethodChannel(BatteryChannelNames.bleMethods); + static const EventChannel _scanEventChannel = + EventChannel(BatteryChannelNames.bleScanEvents); static const EventChannel _connectionEventChannel = EventChannel(BatteryChannelNames.bleConnectionEvents); @@ -18,30 +20,28 @@ class MethodChannelFlutterBluetooth extends FlutterBluetoothPlatform { @override Future isBleAvailable() async { - final result = await _methodChannel.invokeMethod(BatteryMethodNames.isBleAvailable); + final result = await _methodChannel + .invokeMethod(BatteryMethodNames.isBleAvailable); return result ?? false; } @override Future isBleEnabled() async { - final result = await _methodChannel.invokeMethod(BatteryMethodNames.isBleEnabled); + final result = await _methodChannel + .invokeMethod(BatteryMethodNames.isBleEnabled); return result ?? false; } @override Stream> scanDevices({String? serviceUuid}) { _scanStream ??= _scanEventChannel - .receiveBroadcastStream({'serviceUuid': serviceUuid}) - .map((event) { - final list = (event as List).cast(); - return list - .map((e) { - final map = Map.from(e as Map); - return BleDevice.fromJson(map); - }) - .toList(); - }) - .asBroadcastStream(); + .receiveBroadcastStream({'serviceUuid': serviceUuid}).map((event) { + final list = (event as List).cast(); + return list.map((e) { + final map = Map.from(e as Map); + return BleDevice.fromJson(map); + }).toList(); + }).asBroadcastStream(); return _scanStream!; } @@ -59,13 +59,11 @@ class MethodChannelFlutterBluetooth extends FlutterBluetoothPlatform { @override Stream connectionEvents() { - _connectionStream ??= _connectionEventChannel - .receiveBroadcastStream() - .map((event) { - final map = Map.from(event as Map); - return BleConnectionEvent.fromJson(map); - }) - .asBroadcastStream(); + _connectionStream ??= + _connectionEventChannel.receiveBroadcastStream().map((event) { + final map = Map.from(event as Map); + return BleConnectionEvent.fromJson(map); + }).asBroadcastStream(); return _connectionStream!; } @@ -111,7 +109,8 @@ class MethodChannelFlutterBluetooth extends FlutterBluetoothPlatform { required String serviceUuid, required String characteristicUuid, }) { - throw UnimplementedError('subscribeToCharacteristic is not implemented on this platform.'); + throw UnimplementedError( + 'subscribeToCharacteristic is not implemented on this platform.'); } @override diff --git a/lib/peer_battery_service.dart b/lib/peer_battery_service.dart index 5be5f72..82ee23b 100644 --- a/lib/peer_battery_service.dart +++ b/lib/peer_battery_service.dart @@ -33,8 +33,10 @@ class PeerBatteryState { } class PeerBatteryService { - static const MethodChannel _methodChannel = MethodChannel(BatteryChannelNames.peerMethods); - static const EventChannel _eventChannel = EventChannel(BatteryChannelNames.peerEvents); + static const MethodChannel _methodChannel = + MethodChannel(BatteryChannelNames.peerMethods); + static const EventChannel _eventChannel = + EventChannel(BatteryChannelNames.peerEvents); Stream? _stream; @@ -85,7 +87,8 @@ class PeerBatteryService { Future masterConnectToDevice(String deviceId) async { try { - await _methodChannel.invokeMethod(BatteryMethodNames.masterConnectToDevice, { + await _methodChannel + .invokeMethod(BatteryMethodNames.masterConnectToDevice, { 'deviceId': deviceId, }); } on MissingPluginException { diff --git a/lib/src/battery_channel_contract.dart b/lib/src/battery_channel_contract.dart index c95f47f..0af358e 100644 --- a/lib/src/battery_channel_contract.dart +++ b/lib/src/battery_channel_contract.dart @@ -3,7 +3,8 @@ class BatteryChannelNames { static const String eventChannel = 'flutter_battery/battery_stream'; static const String bleMethods = 'flutter_battery/ble_methods'; static const String bleScanEvents = 'flutter_battery/ble_scan_events'; - static const String bleConnectionEvents = 'flutter_battery/ble_connection_events'; + static const String bleConnectionEvents = + 'flutter_battery/ble_connection_events'; static const String peerMethods = 'flutter_battery/peer_methods'; static const String peerEvents = 'flutter_battery/peer_events'; } @@ -19,7 +20,8 @@ class BatteryMethodNames { static const String stopBatteryLevelListening = 'stopBatteryLevelListening'; static const String startBatteryInfoListening = 'startBatteryInfoListening'; static const String stopBatteryInfoListening = 'stopBatteryInfoListening'; - static const String startBatteryHealthListening = 'startBatteryHealthListening'; + static const String startBatteryHealthListening = + 'startBatteryHealthListening'; static const String stopBatteryHealthListening = 'stopBatteryHealthListening'; static const String setPushInterval = 'setPushInterval'; static const String setBatteryLevelThreshold = 'setBatteryLevelThreshold'; diff --git a/macos/flutter_battery/Classes/BatteryMonitor.swift b/macos/flutter_battery/Classes/BatteryMonitor.swift index 1f49ac7..f5ebbc0 100644 --- a/macos/flutter_battery/Classes/BatteryMonitor.swift +++ b/macos/flutter_battery/Classes/BatteryMonitor.swift @@ -22,6 +22,26 @@ public class BatteryMonitor { self.eventChannelHandler = handler } + // MARK: - Callback setters (R005) + + public func setOnBatteryLevelChangeCallback(_ callback: @escaping (Int) -> Void) { + batteryLevelChangeCallback = callback + } + + public func setOnBatteryInfoChangeCallback(_ callback: @escaping ([String: Any]) -> Void) { + batteryInfoChangeCallback = callback + } + + public func setOnBatteryHealthChangeCallback(_ callback: @escaping ([String: Any]) -> Void) { + batteryHealthChangeCallback = callback + } + + private func hasBattery() -> Bool { + let snapshot = IOPSCopyPowerSourcesInfo().takeRetainedValue() + let sources = IOPSCopyPowerSourcesList(snapshot).takeRetainedValue() as [CFTypeRef] + return !sources.isEmpty + } + public func getBatteryLevel() -> Int { let snapshot = IOPSCopyPowerSourcesInfo().takeRetainedValue() let sources = IOPSCopyPowerSourcesList(snapshot).takeRetainedValue() as [CFTypeRef] @@ -69,6 +89,7 @@ public class BatteryMonitor { return [ "level": level, + "batteryLevel": level, "isCharging": isCharging, "isCharged": isCharged, "timeToFull": timeToFull, @@ -100,9 +121,7 @@ public class BatteryMonitor { level = capacity currentCapacity = capacity } - if let maxCap = description[kIOPSMaxCapacityKey] as? Int { - maxCapacity = maxCap - } + _ = description[kIOPSMaxCapacityKey] as? Int if let charging = description[kIOPSIsChargingKey] as? Bool { isCharging = charging } @@ -114,30 +133,42 @@ public class BatteryMonitor { } } - // Try to get additional info from IORegistry - if maxCapacity > 0 { - let service = IOServiceGetMatchingService(kIOMasterPortDefault, IOServiceMatching("AppleSmartBattery")) - if service != 0 { - if let cycleData = IORegistryEntryCreateCFProperty(service, "CycleCount" as CFString, kCFAllocatorDefault, 0) { - cycleCount = cycleData.takeRetainedValue() as? Int ?? -1 - } - if let designCapData = IORegistryEntryCreateCFProperty(service, "DesignCapacity" as CFString, kCFAllocatorDefault, 0) { - designCapacity = designCapData.takeRetainedValue() as? Int ?? -1 - } - if let manufacturerData = IORegistryEntryCreateCFProperty(service, "Manufacturer" as CFString, kCFAllocatorDefault, 0) { - manufacturer = manufacturerData.takeRetainedValue() as? String ?? "" - } - IOObjectRelease(service) + let service = IOServiceGetMatchingService(kIOMasterPortDefault, IOServiceMatching("AppleSmartBattery")) + if service != 0 { + if let cycleData = IORegistryEntryCreateCFProperty(service, "CycleCount" as CFString, kCFAllocatorDefault, 0) { + cycleCount = cycleData.takeRetainedValue() as? Int ?? -1 + } + if let designCapData = IORegistryEntryCreateCFProperty(service, "DesignCapacity" as CFString, kCFAllocatorDefault, 0) { + designCapacity = designCapData.takeRetainedValue() as? Int ?? -1 } + if let rawMaxCapData = IORegistryEntryCreateCFProperty(service, "AppleRawMaxCapacity" as CFString, kCFAllocatorDefault, 0) { + maxCapacity = rawMaxCapData.takeRetainedValue() as? Int ?? -1 + } + if maxCapacity <= 0, + let maxCapData = IORegistryEntryCreateCFProperty(service, "MaxCapacity" as CFString, kCFAllocatorDefault, 0) { + maxCapacity = maxCapData.takeRetainedValue() as? Int ?? -1 + } + if let manufacturerData = IORegistryEntryCreateCFProperty(service, "Manufacturer" as CFString, kCFAllocatorDefault, 0) { + manufacturer = manufacturerData.takeRetainedValue() as? String ?? "" + } + IOObjectRelease(service) } - let healthPercentage = maxCapacity > 0 ? Double(maxCapacity) / Double(designCapacity > 0 ? designCapacity : maxCapacity) * 100.0 : 100.0 - let status = getHealthStatus(healthPercentage: healthPercentage, cycleCount: cycleCount) + let healthPercentage = maxCapacity > 0 && designCapacity > 0 + ? Double(maxCapacity) / Double(designCapacity) * 100.0 + : 0.0 + let status = getHealthStatus( + healthPercentage: healthPercentage, + hasReliableCapacity: maxCapacity > 0 && designCapacity > 0, + cycleCount: cycleCount + ) let recommendations = getHealthRecommendations(status: status, healthPercentage: healthPercentage, cycleCount: cycleCount, isCharging: isCharging, level: level) let riskLevel = getRiskLevel(status: status) return [ "state": status, + "statusLabel": healthLabel(for: status), + "isGood": status == "GOOD", "healthPercentage": round(healthPercentage * 100) / 100, "maxCapacity": maxCapacity, "currentCapacity": currentCapacity, @@ -148,12 +179,24 @@ public class BatteryMonitor { "deviceName": deviceName, "isCharging": isCharging, "level": level, + "batteryLevel": level, "riskLevel": riskLevel, "recommendations": recommendations, "timestamp": Int(Date().timeIntervalSince1970 * 1000) ] } + private func healthLabel(for status: String) -> String { + switch status { + case "GOOD": return "Good" + case "OVERHEAT": return "Overheating" + case "DEAD": return "Dead" + case "FAILURE": return "Failure" + case "COLD": return "Cold" + default: return "Unknown" + } + } + public func getBatteryOptimizationTips() -> [String] { var tips: [String] = [] let info = getBatteryInfo() @@ -186,72 +229,53 @@ public class BatteryMonitor { } private func getBatteryState(level: Int, isCharging: Bool, isCharged: Bool) -> String { - if isCharged { - return "FULL" - } - if isCharging { - return "CHARGING" - } - if level <= 10 { - return "CRITICAL" - } - if level <= 20 { - return "LOW" - } + if isCharged { return "FULL" } + if isCharging { return "CHARGING" } + if level <= 10 { return "CRITICAL" } + if level <= 20 { return "LOW" } return "NORMAL" } - private func getHealthStatus(healthPercentage: Double, cycleCount: Int) -> String { - if healthPercentage >= 80 && cycleCount < 1000 { - return "GOOD" - } - if healthPercentage < 50 || cycleCount > 1000 { - return "DEAD" - } - if healthPercentage < 70 { - return "FAILURE" - } + private func getHealthStatus(healthPercentage: Double, hasReliableCapacity: Bool, cycleCount: Int) -> String { + if cycleCount > 1000 { return "DEAD" } + if !hasReliableCapacity { return "UNKNOWN" } + if healthPercentage >= 80 { return "GOOD" } + if healthPercentage < 50 { return "DEAD" } + if healthPercentage < 70 { return "FAILURE" } return "UNKNOWN" } private func getRiskLevel(status: String) -> String { switch status { - case "GOOD": - return "LOW" - case "UNKNOWN": - return "MEDIUM" - default: - return "HIGH" + case "GOOD": return "LOW" + case "UNKNOWN": return "MEDIUM" + default: return "HIGH" } } private func getHealthRecommendations(status: String, healthPercentage: Double, cycleCount: Int, isCharging: Bool, level: Int) -> [String] { var tips: [String] = [] - switch status { case "DEAD": tips.append("电池健康度严重下降,建议更换电池") case "FAILURE": tips.append("电池健康度较低,建议联系售后检查") + case "UNKNOWN": + tips.append("无法可靠读取 macOS 电池健康容量数据,请以系统设置中的电池健康信息为准") default: - if !isCharging && level < 30 { + if !isCharging && level >= 0 && level < 30 { tips.append("电量偏低(\(level)%),建议及时充电") } if cycleCount > 500 { tips.append("电池循环次数已达\(cycleCount)次,建议关注电池健康") } } - - if tips.isEmpty { - tips.append("电池状态良好,可正常使用") - } - + if tips.isEmpty { tips.append("电池状态良好,可正常使用") } return tips } public func setBatteryLevelPushInterval(intervalMs: Int) { stopBatteryLevelListening() - let interval = TimeInterval(intervalMs) / 1000.0 batteryLevelPushTimer = Timer.scheduledTimer(withTimeInterval: interval, repeats: true) { [weak self] _ in self?.pushBatteryLevel() @@ -261,9 +285,7 @@ public class BatteryMonitor { public func startBatteryLevelListening(eventChannelHandler: BatteryStreamHandler?) { self.eventChannelHandler = eventChannelHandler stopBatteryLevelListening() - lastBatteryLevel = getBatteryLevel() - batteryLevelPushTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in self?.pushBatteryLevel() } @@ -277,7 +299,6 @@ public class BatteryMonitor { public func startBatteryInfoListening(intervalMs: Int = 5000) { stopBatteryInfoListening() - let interval = TimeInterval(intervalMs) / 1000.0 batteryInfoPushTimer = Timer.scheduledTimer(withTimeInterval: interval, repeats: true) { [weak self] _ in self?.pushBatteryInfo() @@ -291,7 +312,6 @@ public class BatteryMonitor { public func startBatteryHealthListening(intervalMs: Int = 10000) { stopBatteryHealthListening() - let interval = TimeInterval(intervalMs) / 1000.0 batteryHealthTimer = Timer.scheduledTimer(withTimeInterval: interval, repeats: true) { [weak self] _ in self?.pushBatteryHealth() @@ -316,27 +336,65 @@ public class BatteryMonitor { batteryHealthChangeCallback = nil } + private func emitUnavailableIfNoBattery() -> Bool { + if !hasBattery() { + let payload: [String: Any] = [ + "type": "BATTERY_UNAVAILABLE", + "level": 0, + "batteryLevel": 0, + "timestamp": Int(Date().timeIntervalSince1970 * 1000), + "unavailableReason": "No battery present on this system" + ] + eventChannelHandler?.sendBatteryUpdate(payload) + batteryLevelChangeCallback?(0) + return true + } + return false + } + private func pushBatteryLevel() { + if emitUnavailableIfNoBattery() { return } + let currentLevel = getBatteryLevel() if currentLevel >= 0 { let shouldPush = !enableBatteryLevelDebounce || currentLevel != lastPushedBatteryLevel if shouldPush { lastPushedBatteryLevel = currentLevel + let payload: [String: Any] = [ + "type": "BATTERY_LEVEL", + "level": currentLevel, + "batteryLevel": currentLevel, + "timestamp": Int(Date().timeIntervalSince1970 * 1000) + ] batteryLevelChangeCallback?(currentLevel) - eventChannelHandler?.sendBatteryUpdate(["level": currentLevel, "timestamp": Int(Date().timeIntervalSince1970 * 1000)]) + eventChannelHandler?.sendBatteryUpdate(payload) } } } private func pushBatteryInfo() { - let info = getBatteryInfo() + if emitUnavailableIfNoBattery() { return } + + var info = getBatteryInfo() + info["type"] = "BATTERY_INFO" + // Ensure both level keys are present + if let level = info["level"] as? Int { + info["batteryLevel"] = level + } batteryInfoChangeCallback?(info) eventChannelHandler?.sendBatteryUpdate(info) } private func pushBatteryHealth() { - let health = getBatteryHealth() + if emitUnavailableIfNoBattery() { return } + + var health = getBatteryHealth() + health["type"] = "BATTERY_HEALTH" + if let level = health["level"] as? Int { + health["batteryLevel"] = level + } batteryHealthChangeCallback?(health) + eventChannelHandler?.sendBatteryUpdate(health) } } diff --git a/macos/flutter_battery/Classes/FlutterBatteryPlugin.swift b/macos/flutter_battery/Classes/FlutterBatteryPlugin.swift index 8225dad..bc9e0ea 100644 --- a/macos/flutter_battery/Classes/FlutterBatteryPlugin.swift +++ b/macos/flutter_battery/Classes/FlutterBatteryPlugin.swift @@ -31,6 +31,25 @@ public class FlutterBatteryPlugin: NSObject, FlutterPlugin { let eventHandler = BatteryStreamHandler(batteryMonitor: plugin.batteryMonitor!) eventChannel.setStreamHandler(eventHandler) plugin.eventChannelHandler = eventHandler + + // Wire callback bridge (R005) + plugin.wireCallbackBridge() + } + + private func wireCallbackBridge() { + guard let batteryMonitor = batteryMonitor else { return } + + batteryMonitor.setOnBatteryLevelChangeCallback { [weak self] level in + self?.methodChannel?.invokeMethod("onBatteryLevelChanged", arguments: ["batteryLevel": level]) + } + + batteryMonitor.setOnBatteryInfoChangeCallback { [weak self] info in + self?.methodChannel?.invokeMethod("onBatteryInfoChanged", arguments: info) + } + + batteryMonitor.setOnBatteryHealthChangeCallback { [weak self] health in + self?.methodChannel?.invokeMethod("onBatteryHealthChanged", arguments: health) + } } public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) { @@ -44,6 +63,9 @@ public class FlutterBatteryPlugin: NSObject, FlutterPlugin { let version = ProcessInfo.processInfo.operatingSystemVersionString result("macOS \(version)") + case "getPlatformCapabilities": + result(getPlatformCapabilities()) + case "getBatteryLevel": let level = batteryMonitor.getBatteryLevel() result(level) @@ -102,8 +124,6 @@ public class FlutterBatteryPlugin: NSObject, FlutterPlugin { result(true) case "setBatteryLevelThreshold": - // macOS doesn't support native notifications in the same way as Android - // Return success but log that this feature is limited result(true) case "stopBatteryMonitoring": @@ -111,11 +131,9 @@ public class FlutterBatteryPlugin: NSObject, FlutterPlugin { result(true) case "scheduleNotification": - // Not supported on macOS result(FlutterError(code: "NOT_SUPPORTED", message: "Scheduled notifications not supported on macOS", details: nil)) case "showNotification": - // Not supported on macOS result(FlutterError(code: "NOT_SUPPORTED", message: "Native notifications not supported on macOS", details: nil)) default: @@ -123,6 +141,22 @@ public class FlutterBatteryPlugin: NSObject, FlutterPlugin { } } + private func getPlatformCapabilities() -> [String: Bool] { + return [ + "batteryLevel": true, + "batteryInfo": true, + "batteryHealth": true, + "batteryLevelStream": true, + "batteryInfoStream": true, + "batteryHealthStream": true, + "lowBatteryMonitoring": true, + "nativeNotifications": false, + "scheduledNotifications": false, + "blePeerSync": false, + "iotExampleBridge": false, + ] + } + public func detachFromEngine(for registrar: FlutterPluginRegistrar) { batteryMonitor?.dispose() methodChannel = nil diff --git a/pubspec.yaml b/pubspec.yaml index 286433c..3f2765a 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -37,8 +37,6 @@ flutter: android: package: com.example.flutter_battery pluginClass: FlutterBatteryPlugin - ios: - pluginClass: FlutterBatteryPlugin macos: pluginClass: FlutterBatteryPlugin diff --git a/test/flutter_battery_method_channel_test.dart b/test/flutter_battery_method_channel_test.dart index 6a00372..c7af965 100644 --- a/test/flutter_battery_method_channel_test.dart +++ b/test/flutter_battery_method_channel_test.dart @@ -8,11 +8,13 @@ void main() { TestWidgetsFlutterBinding.ensureInitialized(); late MethodChannelFlutterBattery platform; - const MethodChannel channel = MethodChannel(BatteryChannelNames.methodChannel); + const MethodChannel channel = + MethodChannel(BatteryChannelNames.methodChannel); setUp(() { platform = MethodChannelFlutterBattery(); - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler( + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler( channel, (MethodCall methodCall) async { return '42'; @@ -29,8 +31,10 @@ void main() { expect(await platform.getPlatformVersion(), '42'); }); - test('getPlatformCapabilities returns empty on MissingPluginException', () async { - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler( + test('getPlatformCapabilities returns empty on MissingPluginException', + () async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler( channel, (MethodCall methodCall) async { throw MissingPluginException('not found'); @@ -40,8 +44,11 @@ void main() { expect(caps.supportedFeatures, isEmpty); }); - test('method_channel_maps_missing_plugin_to_unsupported_for_peer_optional_feature', () async { - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler( + test( + 'method_channel_maps_missing_plugin_to_unsupported_for_peer_optional_feature', + () async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler( channel, (MethodCall methodCall) async { throw MissingPluginException('not found'); diff --git a/test/flutter_battery_test.dart b/test/flutter_battery_test.dart index d8c1200..28e0a15 100644 --- a/test/flutter_battery_test.dart +++ b/test/flutter_battery_test.dart @@ -111,7 +111,8 @@ class MockFlutterBatteryPlatform } @override - Future setPushInterval({required int intervalMs, bool enableDebounce = true}) { + Future setPushInterval( + {required int intervalMs, bool enableDebounce = true}) { return Future.value(true); } @@ -149,10 +150,12 @@ class MockFlutterBatteryPlatform } @override - void setBatteryInfoChangeCallback(Function(Map batteryInfo) callback) {} + void setBatteryInfoChangeCallback( + Function(Map batteryInfo) callback) {} @override - void setBatteryHealthChangeCallback(Function(Map batteryHealth) callback) {} + void setBatteryHealthChangeCallback( + Function(Map batteryHealth) callback) {} @override Future startBatteryInfoListening({int intervalMs = 5000}) { @@ -226,7 +229,8 @@ class MockFlutterBatteryPlatform void main() { WidgetsFlutterBinding.ensureInitialized(); - final FlutterBatteryPlatform initialPlatform = FlutterBatteryPlatform.instance; + final FlutterBatteryPlatform initialPlatform = + FlutterBatteryPlatform.instance; setUp(() { final fakePlatform = MockFlutterBatteryPlatform(); @@ -246,7 +250,8 @@ void main() { }); test('getPlatformCapabilities returns capabilities', () async { - final caps = await FlutterBatteryPlatform.instance.getPlatformCapabilities(); + final caps = + await FlutterBatteryPlatform.instance.getPlatformCapabilities(); expect(caps, isA()); expect(caps.isSupported(BatteryFeature.batteryLevel), true); expect(caps.isSupported(BatteryFeature.blePeerSync), true); @@ -312,7 +317,8 @@ void main() { }); test('configureBatteryMonitor returns expected map', () async { - final result = await FlutterBatteryPlatform.instance.configureBatteryMonitor( + final result = + await FlutterBatteryPlatform.instance.configureBatteryMonitor( monitorBatteryLevel: true, monitorBatteryInfo: true, intervalMs: 2000, @@ -357,14 +363,16 @@ void main() { }); test('getBatteryOptimizationTips returns non-empty list', () async { - final tips = await FlutterBatteryPlatform.instance.getBatteryOptimizationTips(); + final tips = + await FlutterBatteryPlatform.instance.getBatteryOptimizationTips(); expect(tips, isA>()); expect(tips, isNotEmpty); expect(tips.length, 3); }); test('batteryStream emits valid data', () async { - final batteryEvent = await FlutterBatteryPlatform.instance.batteryStream.first; + final batteryEvent = + await FlutterBatteryPlatform.instance.batteryStream.first; expect(batteryEvent, isA>()); expect(batteryEvent['batteryLevel'], 75); expect(batteryEvent['timestamp'], isA()); From ebbf94289f72e18b3fcbaf4b119c99a0ee954682 Mon Sep 17 00:00:00 2001 From: forest Date: Sun, 10 May 2026 21:35:31 +0800 Subject: [PATCH 4/5] =?UTF-8?q?feat:=E9=80=82=E9=85=8D=20mac=20=E5=B9=B3?= =?UTF-8?q?=E5=8F=B0=E7=94=B5=E6=B1=A0=E6=B8=A9=E5=BA=A6=E3=80=81=E7=94=B5?= =?UTF-8?q?=E5=8E=8B=E8=8E=B7=E5=8F=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 28 +- README.md | 323 ++++++------------ example/AGENTS.md | 24 ++ .../Classes/BatteryMonitor.swift | 114 +++++-- 4 files changed, 224 insertions(+), 265 deletions(-) create mode 100644 example/AGENTS.md diff --git a/AGENTS.md b/AGENTS.md index 461c15f..cc11b3e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,11 +1,12 @@ # Repository Guidelines ## Project Structure & Module Organization -- `lib/`: Dart API; `flutter_battery.dart` exposes `FlutterBattery`, and the platform interface plus MethodChannel default live alongside it. -- `android/`, `ios/`: Native implementations; keep channel names and payloads in sync with the Dart interface and avoid committing `build/`. -- `example/`: Demo app for manual QA and showcasing notifications; run it on a device/emulator to validate flows. -- `test/`: Unit tests (`*_test.dart`) covering public API and channel behavior. -- `integration/channel/contracts/channel_contract.yaml`: Contract for method/event channels; edit together with Dart and native changes. +- `lib/`: Dart API; `flutter_battery.dart` exposes `FlutterBattery` + typed models; `lib/src/` holds internal contracts (`battery_channel_contract.dart`) and platform capability models (`platform_capabilities.dart`). `flutter_battery_platform_interface.dart` and `flutter_battery_method_channel.dart` live alongside it. +- `android/`: Native implementation. Channel names and payloads must stay in sync with `lib/src/battery_channel_contract.dart`. Avoid committing `build/`. +- `macos/`: macOS native implementation. Explicitly returns unsupported capabilities via `getPlatformCapabilities` for notifications/BLE/peer sync. +- `example/`: Demo app for manual QA and showcasing notifications. IoT demo (`iot/native`, `iot/stream`) is example-only (not part of plugin public API). Run it on a device/emulator to validate flows. +- `test/`: Unit tests (`*_test.dart`) covering public API, channel behavior, and capability queries. +- `integration/channel/contracts/channel_contract.yaml`: Single source of truth for method/event channel contracts; edit together with Dart and native changes. - `scripts/bootstrap_iot.sh`: Recreate integration scaffolding if a clean checkout is missing folders. ## Build, Test, and Development Commands @@ -18,13 +19,13 @@ ## Coding Style & Naming Conventions - Follow `flutter_lints` (`analysis_options.yaml` relaxes `constant_identifier_names` for platform constants). - Files use `snake_case.dart`; classes/enums `PascalCase`; members and locals `camelCase`. -- Keep channel method/event names and payload keys aligned with `FlutterBatteryPlatform`. -- Favor small, nullable-safe methods and concise comments only where intent is non-obvious. +- Channel method/event names and payload keys must use `BatteryChannelNames`, `BatteryMethodNames`, `BatteryEventTypes`, `BatteryPayloadKeys` from `lib/src/battery_channel_contract.dart` — no raw string literals outside native registration. +- Favor small, nullable-safe methods. Comments only where intent is non-obvious. ## Testing Guidelines - Place new cases beside the feature under test; use descriptive `feature_behavior_test.dart` names. -- Mock the platform interface for unit coverage of MethodChannel and stream behaviors; avoid hardware dependencies. -- For native changes, run the `example/` app once on Android (and iOS when available) to verify battery readings and event streams. +- Mock the platform interface for unit coverage of MethodChannel, stream behaviors, and capability queries; avoid hardware dependencies. +- For native changes, run the `example/` app on Android and macOS to verify battery readings, event streams, and capability-based feature gating. ## Commit & Pull Request Guidelines - Use history-aligned prefixes (`feat:`, `style:`, `fix:`, `docs:`) plus an imperative summary. @@ -35,3 +36,12 @@ - Exclude secrets, keystores, and generated `build/` artifacts. - Request only minimum permissions when editing manifests. - When releasing, bump `pubspec.yaml` version and update `CHANGELOG.md` together. + +## Key Architecture Decisions (post-refactor) +- **Channel Contract Constants**: All channel names, method names, event types, and payload keys are centralized in `lib/src/battery_channel_contract.dart`. Raw string literals are banned from business logic. +- **Platform Capabilities**: `BatteryFeature` enum + `BatteryPlatformCapabilities` value object. Query via `FlutterBattery.getPlatformCapabilities()` or `isFeatureSupported(BatteryFeature)`. macOS explicitly returns `false` for notifications/BLE/peer sync. +- **Predictable Failure**: `MissingPluginException` for optional features is mapped to `UnsupportedBatteryFeatureException` so consumers never see raw `MissingPluginException`. +- **Normalized Events**: Event stream payloads always include a `type` field (`BATTERY_LEVEL`, `BATTERY_INFO`, `BATTERY_HEALTH`, `BATTERY_UNAVAILABLE`). Both `level` and `batteryLevel` keys are present for backward compatibility. +- **macOS Callback Bridge**: `BatteryMonitor` has callback setters wired through `FlutterBatteryPlugin` to invoke MethodChannel methods (`onBatteryLevelChanged`, `onBatteryInfoChanged`, `onBatteryHealthChanged`). +- **No iOS**: iOS platform declaration removed from `pubspec.yaml` until a native implementation is added. +- **Example IoT Isolation**: `iot/native` and `iot/stream` channels are example-only, not part of plugin public API. Documented in `channel_contract.yaml` under `example_only_android`. diff --git a/README.md b/README.md index b96f53b..a5c25b6 100644 --- a/README.md +++ b/README.md @@ -25,16 +25,12 @@ Flutter插件,用于监控设备电池电量并在电量低于特定阈值时 ```mermaid graph TB - %% 样式定义 classDef flutter fill:#61DAFB,stroke:#333,stroke-width:1px,color:#333 classDef android fill:#3DDC84,stroke:#333,stroke-width:1px,color:#333 classDef methodChannel fill:#FFA726,stroke:#333,stroke-width:1px,color:#333 classDef core fill:#E57373,stroke:#333,stroke-width:1px,color:#333 - %% Flutter 应用层 FlutterApp["Flutter 应用层"]:::flutter - - %% 主动查询模式 FlutterApp -->|"1. getBatteryLevel()"| FlutterBattery["FlutterBattery 类"]:::flutter FlutterBattery -->|"2. getBatteryLevel()"| PlatformInterface["FlutterBatteryPlatform"]:::flutter PlatformInterface -->|"3. invokeMethod('getBatteryLevel')"| MethodChannel["MethodChannel"]:::methodChannel @@ -63,13 +59,11 @@ graph TB ```mermaid graph TB - %% 样式定义 classDef flutter fill:#61DAFB,stroke:#333,stroke-width:1px,color:#333 classDef android fill:#3DDC84,stroke:#333,stroke-width:1px,color:#333 classDef eventChannel fill:#66BB6A,stroke:#333,stroke-width:1px,color:#333 classDef core fill:#E57373,stroke:#333,stroke-width:1px,color:#333 - %% Flutter 应用层 FlutterApp["Flutter 应用层"]:::flutter FlutterBatteryStream["FlutterBattery.batteryInfoStream"]:::flutter EventChannel["EventChannel"]:::eventChannel @@ -78,7 +72,6 @@ graph TB BatteryMonitor["BatteryMonitor"]:::core AndroidBatteryManager["Android BatteryManager"]:::android - %% 调用链 FlutterApp -->|"1. batteryInfoStream.listen()"| FlutterBatteryStream FlutterBatteryStream -->|"2. eventChannel.receiveBroadcastStream()"| EventChannel EventChannel -->|"3. onListen()"| EventHandler @@ -97,13 +90,11 @@ graph TB ```mermaid graph TB - %% 样式定义 classDef flutter fill:#61DAFB,stroke:#333,stroke-width:1px,color:#333 classDef android fill:#3DDC84,stroke:#333,stroke-width:1px,color:#333 classDef methodChannel fill:#FFA726,stroke:#333,stroke-width:1px,color:#333 classDef core fill:#E57373,stroke:#333,stroke-width:1px,color:#333 - %% 定义节点 FlutterApp["Flutter 应用层"]:::flutter AndroidBroadcast["Android 电池广播"]:::android BatteryReceiver["电池广播接收器"]:::android @@ -114,7 +105,6 @@ graph TB FlutterMethodChannel["MethodChannelFlutterBattery"]:::flutter FlutterBattery["FlutterBattery"]:::flutter - %% 调用链 AndroidBroadcast -->|"1. ACTION_BATTERY_CHANGED"| BatteryReceiver BatteryReceiver -->|"2. onReceive()"| BatteryMonitor BatteryMonitor -->|"3. 更新 lastBatteryLevel"| BatteryMonitor @@ -145,50 +135,24 @@ graph TB | 实现复杂度 | 较简单 | 较复杂,需处理事件流 | | 电量影响 | 频繁查询可能增加耗电 | 合理配置可减少耗电 | -## 关键API调用链 - -### 主动查询模式 - -1. Flutter层调用 `FlutterBattery.getBatteryLevel()` -2. 通过Platform Interface转发到MethodChannel -3. MethodChannel通过JNI调用Android原生方法 -4. MethodChannelHandler接收并处理请求 -5. 调用BatteryMonitor.getBatteryLevel() -6. 使用Android BatteryManager获取电池电量 -7. 结果原路返回到Flutter层 - -### 推送模式 - -#### EventChannel方式 -1. Flutter层订阅 `FlutterBattery.batteryInfoStream` -2. EventChannel设置监听器 -3. EventChannelHandler.onListen()被触发 -4. 启动TimerManager定时器 -5. 定时器周期性调用pushBatteryInfo() -6. 获取电池信息并通过eventSink推送到Flutter -7. Flutter层的Stream监听器接收数据 - -#### 广播接收器方式 -1. 注册接收ACTION_BATTERY_CHANGED广播 -2. 电池状态变化时触发onReceive() -3. 更新电池状态并通过MethodChannel回调通知Flutter - -## 项目结构(最新) +## 项目结构 ``` . ├── lib/ -│ ├── flutter_battery.dart # 电池 API、配置与流封装 +│ ├── flutter_battery.dart # 电池 API、配置、流封装与平台能力查询 │ ├── flutter_battery_platform_interface.dart │ ├── flutter_battery_method_channel.dart +│ ├── src/ +│ │ ├── battery_channel_contract.dart # 通道常量(名称/方法/事件/负载键) +│ │ └── platform_capabilities.dart # BatteryFeature 枚举、BatteryPlatformCapabilities、UnsupportedBatteryFeatureException │ ├── flutter_bluetooth.dart # BLE 门面(扫描/连接/写特征) │ ├── flutter_bluetooth_platform_interface.dart │ ├── flutter_bluetooth_method_channel.dart │ ├── peer_battery_service.dart # Master/Slave 对等电池同步流 │ └── battery_animation.dart # 电池可视化组件 ├── android/src/main/ -│ ├── AndroidManifest.xml # 权限声明 + NotificationAlarmReceiver/SyncService 注册 -│ ├── resources/META-INF/services/... # FlutterPlugin SPI 自动注册入口 +│ ├── AndroidManifest.xml │ └── kotlin/com/example/ │ ├── flutter_battery/ │ │ ├── FlutterBatteryPlugin.kt # 注册电池/BLE/Peer/IoT 通道 @@ -197,19 +161,18 @@ graph TB │ │ └── ble/ # BleManager、GattServerManager、GattClientManager │ ├── iot/nativekit/ # Channels、NativeViewModel、仓库与 SyncService │ └── push_notification/ # PushNotificationManager 与闹钟接收器 +├── macos/flutter_battery/Classes/ +│ ├── FlutterBatteryPlugin.swift # macOS 插件注册 + getPlatformCapabilities + callback bridge +│ └── BatteryMonitor.swift # 电池读取、事件推送(BATTERY_LEVEL/INFO/HEALTH/UNAVAILABLE) ├── example/lib/ # Dashboard、电池详情、事件日志、角色选择/主从页 -├── integration/channel/contracts/ # 方法/事件通道契约 +├── integration/channel/contracts/ # 方法/事件通道契约(含平台支持矩阵) ├── scripts/bootstrap_iot.sh # 集成目录初始化脚本 -└── test/ # Dart 单元测试 +└── test/ # Dart 单元测试(含能力查询、事件规范化测试) ``` -- `lib/`:`FlutterBattery` 汇总配置/回调/监听,`FlutterBluetooth` 暴露 BLE 能力,`peer_battery_service.dart` 提供对等电池同步流,附带电池动画组件。 -- `android/src/main/`:Manifest 挂载权限与 `NotificationAlarmReceiver`/`SyncService` 组件,`resources/META-INF/services/...` 暴露 `FlutterBatteryPlugin` 以便自动注册,`kotlin/com/example/` 下包含插件所有原生实现与通知/IoT 模块。 -- `android/src/main/kotlin/com/example/flutter_battery/core`:`BatteryMonitor` 读取电量/健康并调度监听,`NotificationHelper` 处理通知权限与展示,`TimerManager` 管理周期任务。 -- `android/src/main/kotlin/com/example/flutter_battery/channel`:`MethodChannelHandler` 统一处理电池、BLE、Peer 方法调用;`EventChannelHandler`/`Ble*EventChannelHandler`/`PeerEventChannelHandler` 推送事件。 -- `android/src/main/kotlin/com/example/flutter_battery/ble`:`BleManager` 扫描/连接/写特征,`GattServerManager`(slave)/`GattClientManager`(master) 同步本地与远端电池并上报 `PeerState`。 -- `android/src/main/kotlin/com/example/iot/nativekit`:`Channels` 挂载 `iot/native` & `iot/stream`,`NativeViewModel` 聚合 Telemetry/Power/BLE 仓库,`SyncService` 后台推送遥测。 -- `example/lib`:仪表盘首页、事件日志、电池详情以及 master/slave 角色切换演示页。 +- `lib/src/battery_channel_contract.dart`:集中管理所有 MethodChannel/EventChannel 名称、方法名、事件类型与 payload key,严禁业务代码使用原始字符串。 +- `lib/src/platform_capabilities.dart`:`BatteryFeature` 枚举定义所有可查询的功能点,`BatteryPlatformCapabilities` 值对象封装平台能力映射,`UnsupportedBatteryFeatureException` 替代原始 `MissingPluginException`。 +- `macos/`:`FlutterBatteryPlugin.swift` 通过 `getPlatformCapabilities` 显式声明不支持 nativeNotifications/blePeerSync/iotExampleBridge,`BatteryMonitor.swift` 发送规范化事件(含 `type` 字段与 `batteryLevel`/`level` 双键),同时通过 callback bridge 驱动 `onBatteryLevelChanged`/`onBatteryInfoChanged`/`onBatteryHealthChanged` 方法回调。 ## 功能特性 @@ -221,25 +184,18 @@ graph TB - 支持定时或即时推送通知 - 电池电量动画组件可视化展示 - 电池性能优化建议、防抖动机制 -- IoT 原生桥接:模拟设备扫描、连接、遥测与电池事件(`iot/native` + `iot/stream`) -- 线程安全的资源管理和错误处理,跨平台支持(Android) +- **平台能力查询**:通过 `getPlatformCapabilities()` 查询当前平台支持的功能(macOS 显式返回不支持项) +- **可预测的失败**:可选功能缺失时抛出 `UnsupportedBatteryFeatureException` 而非 `MissingPluginException` ## 功能模块分区 -- **Battery 核心**:`FlutterBattery` + `BatteryMonitor`,覆盖主动查询、电量/信息/健康推送、低电量监测、优化建议与通知调度。 +- **Battery 核心**:`FlutterBattery` + `lib/src/` 契约/能力模型,覆盖主动查询、电量/信息/健康推送、低电量监测、优化建议与通知调度。 +- **平台能力层**:`BatteryFeature` 枚举 + `BatteryPlatformCapabilities` 值对象,通过 `getPlatformCapabilities()` 统一查询。Android 汇报全部支持,macOS 显式标记 notifications/BLE/peer sync 为不支持。 - **BLE 设备管理**:`FlutterBluetooth` -> `BleManager`,支持按服务过滤的扫描、连接、特征写入与连接事件流。 - **Peer 电池同步**:`PeerBatteryService` + `GattServerManager`/`GattClientManager`,在 master/slave 模式下同步本地与远端电池并推送对等状态。 - **通知体系**:`NotificationHelper`、`PushNotificationManager` 负责权限处理、即时/延迟通知与前台提醒。 -- **IoT 演示层**:`iot/nativekit` 将 Telemetry/Power/BLE 仓库通过 `iot/native` & `iot/stream` 暴露给示例应用。 -- **示例与 UI**:`example/lib` 内置仪表盘、事件流日志、电池详情和角色切换页面,配合 `BatteryAnimation` 展示。 - -## 数据流流转方案 - -- **电池主动查询(MethodChannel `flutter_battery`)**:`FlutterBattery.*` -> `FlutterBatteryPlatform` -> `MethodChannelHandler` -> `BatteryMonitor` -> Android BatteryManager -> 结果返回 Flutter。 -- **电池推送(EventChannel `flutter_battery/battery_stream`)**:`EventChannelHandler` 通过 `TimerManager` 轮询;默认推送 `{batteryLevel,timestamp}`,开启 info/health 后携带 `type == BATTERY_INFO/BATTERY_HEALTH` 的完整字段,频率由 `setPushInterval` / `setBatteryInfoPush` / `setBatteryHealthPush` 控制。 -- **BLE 扫描/连接(`flutter_battery/ble_methods`)**:Flutter 调用 `startScan/connect/writeCharacteristic` -> `MethodChannelHandler` -> `BleManager`;扫描结果经 `ble_scan_events` 推送设备列表,连接状态经 `ble_connection_events` 推送。 -- **对等电池同步(`flutter_battery/peer_methods` + `peer_events`)**:`startSlaveMode` 启动 GATT Server 广播本地电池;`startMasterMode/masterConnectToDevice` 启动 GATT Client 读取远端电池并写入本地电量;`PeerEventChannelHandler` 将 `{role,localBattery,remoteBattery,connected}` 推送给 Flutter `peerBatteryStream`。 -- **IoT 演示流(`iot/native` -> `iot/stream`)**:示例调用 MethodChannel 控制扫描/连接/SyncService;`Channels` 订阅 `NativeViewModel` 的 devices/telemetry/battery Flow,并以 `{type: devices|telemetry|battery, payload: ...}` 形式推送到 `EventChannel('iot/stream')`。 +- **IoT 演示层(仅示例)**:`iot/nativekit` 将 Telemetry/Power/BLE 仓库通过 `iot/native` & `iot/stream` 暴露给示例应用,**非插件公共 API**。 +- **示例与 UI**:`example/lib` 内置仪表盘、事件流日志、电池详情和角色切换页面,配合 `BatteryAnimation` 展示,通过能力对象控制功能入口启用/禁用。 ## 原生通道与接口说明 @@ -247,78 +203,55 @@ graph TB | 方法 | 说明 | 参数 | 返回 | | --- | --- | --- | --- | -| `getPlatformVersion()` | 返回 Android 版本 | - | `String` | +| `getPlatformVersion()` | 返回平台版本 | - | `String` | +| `getPlatformCapabilities()` | 返回平台能力映射 | - | `Map` | | `getBatteryLevel()` | 获取当前电量 | - | `int` (0-100) | -| `getBatteryInfo()` | 获取完整电池信息 | - | `Map` `{level,isCharging,temperature,voltage,state,timestamp}` | -| `getBatteryHealth()` | 获取电池健康状态 | - | `Map` `{state,statusLabel,riskLevel,recommendations,...}` | +| `getBatteryInfo()` | 获取完整电池信息 | - | `Map` | +| `getBatteryHealth()` | 获取电池健康状态 | - | `Map` | | `getBatteryOptimizationTips()` | 返回优化建议 | - | `List` | -| `setBatteryLevelThreshold()` | 启用低电量监控 | `threshold,title,message,intervalMinutes,useFlutterRendering` | `bool` | +| `setBatteryLevelThreshold()` | 启用低电量监控 | threshold,title,message,... | `bool` | | `stopBatteryMonitoring()` | 停止低电量监控 | - | `bool` | -| `setPushInterval()` | 设置推送间隔与防抖 | `intervalMs,enableDebounce` | `bool` | +| `setPushInterval()` | 设置推送间隔与防抖 | intervalMs,enableDebounce | `bool` | | `startBatteryLevelListening()` / `stopBatteryLevelListening()` | 开关电量广播监听 | - | `bool` | -| `startBatteryInfoListening()` / `stopBatteryInfoListening()` | 开关完整信息推送 | `intervalMs` | `bool` | -| `startBatteryHealthListening()` / `stopBatteryHealthListening()` | 开关电池健康推送 | `intervalMs` | `bool` | -| `scheduleNotification()` / `showNotification()` / `sendNotification()` | 调度或立即显示系统通知 | `title,message,delay/delayMinutes` | `bool` | - -> Flutter 侧的 `FlutterBattery.configureBattery / configureBatteryMonitor / configureBatteryCallbacks` 封装了上表中的多个原生调用,推荐优先使用高阶 API。 +| `startBatteryInfoListening()` / `stopBatteryInfoListening()` | 开关完整信息推送 | intervalMs | `bool` | +| `startBatteryHealthListening()` / `stopBatteryHealthListening()` | 开关电池健康推送 | intervalMs | `bool` | +| `scheduleNotification()` / `showNotification()` / `sendNotification()` | 调度或立即显示系统通知 | title,message,delay | `bool` | #### `flutter_battery/battery_stream` EventChannel -- 默认 payload:`{batteryLevel: int, timestamp: int}`(电量心跳) -- `type == "BATTERY_INFO"`:携带 `BatteryInfo` 字段 -- `type == "BATTERY_HEALTH"`:携带 `BatteryHealth` 字段(风险等级、建议列表等) -- 所有事件均由 `EventChannelHandler` 管理,支持 `setPushInterval` 和 `setBatteryInfoPush / setBatteryHealthPush` 控制频率。 - -### `flutter_battery/ble_methods` MethodChannel (BLE 能力) - -| 方法 | 说明 | 参数 | 返回 | -| --- | --- | --- | --- | -| `isBleAvailable()` | 检查设备是否支持 BLE | - | `bool` | -| `isBleEnabled()` | 检查 BLE 是否已开启 | - | `bool` | -| `startScan()` / `stopScan()` | 开始/停止扫描,支持 service UUID 过滤 | `serviceUuid?` | `void` | -| `connect()` / `disconnect()` | 连接或断开指定设备 | `deviceId, autoConnect?` / `deviceId?` | `void` | -| `writeCharacteristic()` | 写特征值(默认带响应) | `deviceId,serviceUuid,characteristicUuid,value[],withResponse` | `bool` | -| `subscribeToCharacteristic()` / `unsubscribeFromCharacteristic()` | Dart 端 API 已暴露,Android 端暂未实现订阅逻辑 | 同 write 参数 | `Stream>` / `void` | - -#### `flutter_battery/ble_scan_events` & `flutter_battery/ble_connection_events` EventChannel +规范化事件 payload,始终包含 `type` 字段: -- `ble_scan_events`:推送扫描到的设备列表,payload `[ {id,name,rssi} ]`。 -- `ble_connection_events`:推送连接状态,payload `{state: connected|connecting|disconnecting|disconnected, deviceId, error?}`。 +| 事件类型 | 必填字段 | 可选字段 | +|---------|---------|---------| +| `BATTERY_LEVEL` | type, timestamp | batteryLevel, level, unavailableReason | +| `BATTERY_INFO` | type, timestamp, isCharging, state | level, batteryLevel, temperature, voltage, ... | +| `BATTERY_HEALTH` | type, timestamp, state, statusLabel, isGood, riskLevel, recommendations | level, batteryLevel, healthPercentage, maxCapacity, ... | +| `BATTERY_UNAVAILABLE` | type, timestamp | unavailableReason | +| `BATTERY_ERROR` | type, timestamp | error | -### `flutter_battery/peer_methods` MethodChannel (对等电池同步) +### 蓝牙与对等电池同步通道 -| 方法 | 说明 | 参数 | 返回 | -| --- | --- | --- | --- | -| `startMasterMode()` | 启动 GATT Client 并扫描 slave | - | `void` | -| `startSlaveMode()` | 启动 GATT Server 广播本地电池 | - | `void` | -| `stopMasterMode()` / `stopSlaveMode()` | 停止 master/slave 模式 | - | `void` | -| `stopAllPeerModes()` | 同时关闭 master 与 slave | - | `void` | -| `masterConnectToDevice()` | master 连接指定 slave 设备 | `deviceId` | `void` | - -#### `flutter_battery/peer_events` EventChannel - -- payload:`{role: master|slave, localBattery: int, remoteBattery: int?, connected: bool}`。 - -### `iot/native` MethodChannel (IoT 模块) - -| 方法 | 说明 | 参数 | 返回 | -| --- | --- | --- | --- | -| `scanDevices` | 开始扫描模拟 BLE 设备 | - | `void` | -| `stopScan` | 停止扫描 | - | `void` | -| `connect` | 连接指定设备 | `deviceId` | `void` | -| `disconnect` | 断开当前设备 | - | `void` | -| `startSync` | 启动前台同步服务(推送遥测) | - | `void` | -| `stopSync` | 停止同步服务 | - | `void` | +见原生层文档(Android 可选功能,macOS 不支持)。 -#### `iot/stream` EventChannel +### `iot/native` & `iot/stream`(示例专用) -事件 payload 统一结构 `{type: String, payload: ...}`: +**非插件公共 API**。仅 Android 示例应用使用,用于演示 MethodChannel/EventChannel 通信。 -- `type == "devices"`:`payload` 为设备列表(字段 `id,name,rssi,connected`)。 -- `type == "telemetry"`:`payload` 为 `Telemetry` Map(`timestamp,speed,batteryPct`)。 -- `type == "battery"`:`payload` 为 `{value: Int}`,模拟远端设备电量。 +## 平台支持矩阵 -> 插件内部已在 `FlutterBatteryPlugin` 中调用 `IotNativeInitializer.attach()`,示例应用只需订阅 `EventChannel('iot/stream')` 即可。 +| 能力 | Android | macOS | +|-----|---------|-------| +| batteryLevel | ✅ | ✅ | +| batteryInfo | ✅ | ✅ | +| batteryHealth | ✅ | ✅ | +| batteryLevelStream | ✅ | ✅ | +| batteryInfoStream | ✅ | ✅ | +| batteryHealthStream | ✅ | ✅ | +| lowBatteryMonitoring | ✅ | ✅ | +| nativeNotifications | ✅ | ❌ | +| scheduledNotifications | ✅ | ❌ | +| blePeerSync | ✅ | ❌ | +| iotExampleBridge | ✅ | ❌ | ## 安装 @@ -340,6 +273,21 @@ dependencies: import 'package:flutter_battery/flutter_battery.dart'; ``` +### 查询平台能力 + +```dart +final plugin = FlutterBattery(); + +// 获取完整能力对象 +final capabilities = await plugin.getPlatformCapabilities(); +if (capabilities.isSupported(BatteryFeature.nativeNotifications)) { + // 支持原生通知 +} + +// 快捷查询 +final hasBlePeerSync = await plugin.isFeatureSupported(BatteryFeature.blePeerSync); +``` + ### 初始化插件 ```dart @@ -348,81 +296,38 @@ final flutterBatteryPlugin = FlutterBattery(); ### 快速集成(推荐) -使用一次性配置方法设置所有电池监控功能: - ```dart -// 配置所有电池监控功能 await flutterBatteryPlugin.configureBattery( BatteryConfiguration( - // 基本监听配置 monitorConfig: BatteryMonitorConfig( - monitorBatteryLevel: true, // 是否监控电池电量 - monitorBatteryInfo: true, // 是否监控电池完整信息 - intervalMs: 1000, // 电量更新间隔(毫秒) - batteryInfoIntervalMs: 5000, // 电池信息更新间隔(毫秒) - enableDebounce: true, // 启用防抖动 + monitorBatteryLevel: true, + monitorBatteryInfo: true, + intervalMs: 1000, + batteryInfoIntervalMs: 5000, + enableDebounce: true, ), - - // 低电量监控配置 lowBatteryConfig: BatteryLevelMonitorConfig( - enable: true, // 启用低电量监控 - threshold: 20, // 电量阈值(%) - title: '电池电量低', // 通知标题 - message: '您的电池电量低于20%', // 通知内容 - intervalMinutes: 15, // 检查间隔 - useFlutterRendering: true, // 使用Flutter UI + enable: true, + threshold: 20, + title: '电池电量低', + message: '您的电池电量低于20%', + intervalMinutes: 15, + useFlutterRendering: true, ), - - // 回调函数设置 onBatteryLevelChange: (batteryLevel) { print('电池电量变化: $batteryLevel%'); }, - onBatteryInfoChange: (info) { print('电池信息更新: $info'); }, - onLowBattery: (batteryLevel) { // 处理低电量事件 - showDialog( - context: context, - builder: (context) => AlertDialog( - title: Text('电池电量低'), - content: Text('当前电量: $batteryLevel%'), - actions: [ - TextButton( - onPressed: () => Navigator.pop(context), - child: Text('确定'), - ), - ], - ), - ); }, ), ); ``` -### 配置特定功能 - -如果需要单独配置特定功能,可以使用以下方法: - -#### 配置电池监听 - -```dart -await flutterBatteryPlugin.configureBatteryMonitor( - BatteryMonitorConfig( - monitorBatteryLevel: true, - monitorBatteryInfo: true, - monitorBatteryHealth: true, - intervalMs: 2000, - batteryInfoIntervalMs: 10000, - batteryHealthIntervalMs: 15000, - enableDebounce: true, - ), -); -``` - -#### 配置电池回调 +### 配置电池回调 ```dart flutterBatteryPlugin.configureBatteryCallbacks( @@ -441,56 +346,28 @@ flutterBatteryPlugin.configureBatteryCallbacks( ); ``` -#### 配置低电量监控 - -```dart -await flutterBatteryPlugin.configureBatteryMonitoring( - BatteryLevelMonitorConfig( - enable: true, - threshold: 15, - title: '电量不足提醒', - message: '电池电量低于15%,请及时充电', - intervalMinutes: 30, - useFlutterRendering: false, - ), -); -``` - -### 基本操作 - -#### 获取电池电量 +### 获取电池电量 ```dart final batteryLevel = await flutterBatteryPlugin.getBatteryLevel(); print('当前电池电量: $batteryLevel%'); ``` -#### 获取完整电池信息 +### 获取完整电池信息 ```dart final batteryInfo = await flutterBatteryPlugin.getBatteryInfo(); print('电池信息: $batteryInfo'); -// 输出: 电池信息: BatteryInfo(level: 85%, isCharging: true, temperature: 37.5°C, voltage: 4.35V, state: BatteryState.CHARGING) ``` -#### 获取电池健康 +### 获取电池健康 ```dart final batteryHealth = await flutterBatteryPlugin.getBatteryHealth(); print('电池健康: $batteryHealth'); -// BatteryHealth(state: BatteryHealthState.good, risk: LOW, temp: 32.0°C) -``` - -#### 获取电池优化建议 - -```dart -final tips = await flutterBatteryPlugin.getBatteryOptimizationTips(); -for (final tip in tips) { - print('电池优化建议: $tip'); -} ``` -#### 发送通知 +### 发送通知 ```dart // 立即发送通知 @@ -500,20 +377,14 @@ await flutterBatteryPlugin.sendNotification( delay: 0, ); -// 延迟发送通知 +// 延迟发送通知(仅 Android) await flutterBatteryPlugin.sendNotification( title: '延迟通知', message: '这条通知将在5分钟后显示', - delay: 5, // 5分钟后发送 + delay: 5, ); ``` -#### 停止电池监控 - -```dart -await flutterBatteryPlugin.stopBatteryMonitoring(); -``` - ### 使用电池动画组件 ```dart @@ -522,15 +393,19 @@ BatteryAnimation( width: 150, height: 300, isCharging: true, - showPercentage: true, // 显示百分比 - warningLevel: 20, // 设置警告电量阈值 + showPercentage: true, + warningLevel: 20, ) ``` -## 版本兼容性 +## 架构决策(重构后) -- **0.0.3 及以上版本**: 使用配置类和整合API (本文档中的所有示例) -- **0.0.1-0.0.2 版本**: 仍支持老API,但建议升级到最新版本以获得更好的性能和简化的API +- **通道常量集中化**:所有通道名称、方法名、事件类型和 payload key 集中在 `lib/src/battery_channel_contract.dart`,业务代码禁止使用原始字符串。 +- **平台能力查询**:通过 `BatteryFeature` 枚举 + `BatteryPlatformCapabilities` 值对象查询平台支持,macOS 显式返回 notifications/BLE/peer sync 为不支持。 +- **可预测的失败处理**:`MissingPluginException` 映射为 `UnsupportedBatteryFeatureException`,消费者无需捕获底层异常。 +- **事件规范化**:事件流 payload 始终包含 `type` 字段(`BATTERY_LEVEL`/`BATTERY_INFO`/`BATTERY_HEALTH`/`BATTERY_UNAVAILABLE`),`level` 和 `batteryLevel` 双键共存保证向后兼容。 +- **macOS 回调桥接**:`BatteryMonitor` 通过 callback setters 连接 `FlutterBatteryPlugin`,驱动 `onBatteryLevelChanged`/`onBatteryInfoChanged`/`onBatteryHealthChanged` 方法通道回调。 +- **IoT 示例隔离**:`iot/native` 和 `iot/stream` 仅限示例应用使用,不属插件公共 API。 ## 常见问题 @@ -542,9 +417,9 @@ BatteryAnimation( 在 Android 13 及以上版本,需要动态请求通知权限。本插件会自动处理权限请求,但用户可能拒绝授予权限。 -### 3. 如何高效监控电池? +### 3. macOS 上哪些功能不可用? -推荐使用`configureBattery()`方法一次性配置所有需要的电池监控功能,减少多次API调用。 +macOS 不支持原生通知(nativeNotifications)、定时通知(scheduledNotifications)、蓝牙对等同步(blePeerSync)和 IoT 示例桥接(iotExampleBridge)。可通过 `getPlatformCapabilities()` 查询当前平台能力。 ## 许可证 diff --git a/example/AGENTS.md b/example/AGENTS.md new file mode 100644 index 0000000..6031885 --- /dev/null +++ b/example/AGENTS.md @@ -0,0 +1,24 @@ +# Example App Guidelines + +## Purpose +Demo application for manual QA and visual verification of the `flutter_battery` plugin. Showcases all battery monitoring features, BLE peer sync, and IoT native bridge demos. + +## Project Structure +- `lib/main.dart`: App entry point; wires battery bootstrap, IoT event listeners, route generation gated by `BatteryPlatformCapabilities`. +- `lib/pages/`: Feature demo pages (`dashboard_page.dart`, `battery_details_page.dart`, `low_battery_notification_page.dart`, `iot_controls_page.dart`, `event_stream_page.dart`). +- `lib/platform/example_platform_adapter.dart`: Platform adapter returning `BatteryPlatformCapabilities` per platform. Android reports all features supported; other platforms report unsupported for BLE peer sync and IoT bridge. +- `lib/routes.dart`: Route constants for all demo pages. +- `test/widget_test.dart`: Widget test verifying dashboard disables features based on capability object. + +## Key Patterns +- **Capability-gated routing**: Routes check `BatteryPlatformCapabilities.isSupported()` before navigating; unsupported features show `_UnsupportedFeaturePage` or disable the ListTile. +- **IoT isolation**: `iot/native` and `iot/stream` channels are example-only, accessed exclusively through `ExamplePlatformAdapter`. Not part of the plugin's public API. +- **Battery bootstrap**: `_bootstrapBattery()` configures all callbacks and monitoring via `configureBatteryCallbacks` + `configureBatteryMonitor`. + +## Testing +- `cd example && flutter test`: Run widget tests. +- `cd example && flutter run -d `: Manual QA on device/emulator. + +## Development Workflow +- After modifying plugin Dart code, run `flutter pub get` in both root and example. +- Use `BatteryFeature` enum values from `package:flutter_battery/flutter_battery.dart` (re-exported via `lib/src/platform_capabilities.dart`) for capability checks — never hardcode platform strings. diff --git a/macos/flutter_battery/Classes/BatteryMonitor.swift b/macos/flutter_battery/Classes/BatteryMonitor.swift index f5ebbc0..98e5145 100644 --- a/macos/flutter_battery/Classes/BatteryMonitor.swift +++ b/macos/flutter_battery/Classes/BatteryMonitor.swift @@ -41,6 +41,73 @@ public class BatteryMonitor { let sources = IOPSCopyPowerSourcesList(snapshot).takeRetainedValue() as [CFTypeRef] return !sources.isEmpty } + + private struct HardwareMetrics { + var temperature: Double = 0.0 + var voltage: Double = 0.0 + var maxCapacity: Int = -1 + var designCapacity: Int = -1 + var cycleCount: Int = -1 + var manufacturer: String = "" + } + + private func getHardwareMetrics() -> HardwareMetrics { + var metrics = HardwareMetrics() + let service = IOServiceGetMatchingService(kIOMasterPortDefault, IOServiceMatching("AppleSmartBattery")) + if service == 0 { + return metrics + } + defer { + IOObjectRelease(service) + } + + metrics.cycleCount = readIntProperty(service: service, key: "CycleCount") ?? -1 + metrics.designCapacity = readIntProperty(service: service, key: "DesignCapacity") ?? -1 + metrics.maxCapacity = readIntProperty(service: service, key: "AppleRawMaxCapacity") + ?? readIntProperty(service: service, key: "MaxCapacity") + ?? -1 + metrics.temperature = normalizeTemperature(readIntProperty(service: service, key: "Temperature")) + metrics.voltage = normalizeVoltage(readIntProperty(service: service, key: "Voltage")) + + if let manufacturerData = IORegistryEntryCreateCFProperty(service, "Manufacturer" as CFString, kCFAllocatorDefault, 0) { + metrics.manufacturer = manufacturerData.takeRetainedValue() as? String ?? "" + } + + return metrics + } + + private func readIntProperty(service: io_registry_entry_t, key: String) -> Int? { + guard let data = IORegistryEntryCreateCFProperty(service, key as CFString, kCFAllocatorDefault, 0) else { + return nil + } + let value = data.takeRetainedValue() + if let intValue = value as? Int { + return intValue + } + if let number = value as? NSNumber { + return number.intValue + } + return nil + } + + private func normalizeTemperature(_ rawValue: Int?) -> Double { + guard let rawValue = rawValue, rawValue > 0 else { + return 0.0 + } + let value = Double(rawValue) + if rawValue > 1000 { + return round((value / 100.0) * 10) / 10 + } + return round((value / 10.0) * 10) / 10 + } + + private func normalizeVoltage(_ rawValue: Int?) -> Double { + guard let rawValue = rawValue, rawValue > 0 else { + return 0.0 + } + let value = rawValue > 100 ? Double(rawValue) / 1000.0 : Double(rawValue) + return round(value * 100) / 100 + } public func getBatteryLevel() -> Int { let snapshot = IOPSCopyPowerSourcesInfo().takeRetainedValue() @@ -64,6 +131,7 @@ public class BatteryMonitor { var isCharged = false var timeToFull = -1 var timeToEmpty = -1 + let metrics = getHardwareMetrics() for ps in sources { let description = IOPSGetPowerSourceDescription(snapshot, ps).takeUnretainedValue() as! [String: Any] @@ -94,6 +162,8 @@ public class BatteryMonitor { "isCharged": isCharged, "timeToFull": timeToFull, "timeToEmpty": timeToEmpty, + "temperature": metrics.temperature, + "voltage": metrics.voltage, "state": state, "timestamp": Int(Date().timeIntervalSince1970 * 1000) ] @@ -107,11 +177,9 @@ public class BatteryMonitor { var isCharging = false var maxCapacity = -1 var currentCapacity = -1 - var designCapacity = -1 - var cycleCount = -1 var serialNumber = "" - var manufacturer = "" var deviceName = "" + let metrics = getHardwareMetrics() for ps in sources { let description = IOPSGetPowerSourceDescription(snapshot, ps).takeUnretainedValue() as! [String: Any] @@ -121,7 +189,6 @@ public class BatteryMonitor { level = capacity currentCapacity = capacity } - _ = description[kIOPSMaxCapacityKey] as? Int if let charging = description[kIOPSIsChargingKey] as? Bool { isCharging = charging } @@ -133,36 +200,17 @@ public class BatteryMonitor { } } - let service = IOServiceGetMatchingService(kIOMasterPortDefault, IOServiceMatching("AppleSmartBattery")) - if service != 0 { - if let cycleData = IORegistryEntryCreateCFProperty(service, "CycleCount" as CFString, kCFAllocatorDefault, 0) { - cycleCount = cycleData.takeRetainedValue() as? Int ?? -1 - } - if let designCapData = IORegistryEntryCreateCFProperty(service, "DesignCapacity" as CFString, kCFAllocatorDefault, 0) { - designCapacity = designCapData.takeRetainedValue() as? Int ?? -1 - } - if let rawMaxCapData = IORegistryEntryCreateCFProperty(service, "AppleRawMaxCapacity" as CFString, kCFAllocatorDefault, 0) { - maxCapacity = rawMaxCapData.takeRetainedValue() as? Int ?? -1 - } - if maxCapacity <= 0, - let maxCapData = IORegistryEntryCreateCFProperty(service, "MaxCapacity" as CFString, kCFAllocatorDefault, 0) { - maxCapacity = maxCapData.takeRetainedValue() as? Int ?? -1 - } - if let manufacturerData = IORegistryEntryCreateCFProperty(service, "Manufacturer" as CFString, kCFAllocatorDefault, 0) { - manufacturer = manufacturerData.takeRetainedValue() as? String ?? "" - } - IOObjectRelease(service) - } + maxCapacity = metrics.maxCapacity - let healthPercentage = maxCapacity > 0 && designCapacity > 0 - ? Double(maxCapacity) / Double(designCapacity) * 100.0 + let healthPercentage = maxCapacity > 0 && metrics.designCapacity > 0 + ? Double(maxCapacity) / Double(metrics.designCapacity) * 100.0 : 0.0 let status = getHealthStatus( healthPercentage: healthPercentage, - hasReliableCapacity: maxCapacity > 0 && designCapacity > 0, - cycleCount: cycleCount + hasReliableCapacity: maxCapacity > 0 && metrics.designCapacity > 0, + cycleCount: metrics.cycleCount ) - let recommendations = getHealthRecommendations(status: status, healthPercentage: healthPercentage, cycleCount: cycleCount, isCharging: isCharging, level: level) + let recommendations = getHealthRecommendations(status: status, healthPercentage: healthPercentage, cycleCount: metrics.cycleCount, isCharging: isCharging, level: level) let riskLevel = getRiskLevel(status: status) return [ @@ -172,14 +220,16 @@ public class BatteryMonitor { "healthPercentage": round(healthPercentage * 100) / 100, "maxCapacity": maxCapacity, "currentCapacity": currentCapacity, - "designCapacity": designCapacity, - "cycleCount": cycleCount, + "designCapacity": metrics.designCapacity, + "cycleCount": metrics.cycleCount, "serialNumber": serialNumber, - "manufacturer": manufacturer, + "manufacturer": metrics.manufacturer, "deviceName": deviceName, "isCharging": isCharging, "level": level, "batteryLevel": level, + "temperature": metrics.temperature, + "voltage": metrics.voltage, "riskLevel": riskLevel, "recommendations": recommendations, "timestamp": Int(Date().timeIntervalSince1970 * 1000) From ff2c3b7288f0dd711ff101c2b102721f31bd48ab Mon Sep 17 00:00:00 2001 From: forest Date: Sun, 10 May 2026 22:02:53 +0800 Subject: [PATCH 5/5] =?UTF-8?q?feat:=E4=BA=8B=E4=BB=B6=E5=A3=B0=E6=98=8E?= =?UTF-8?q?=E8=A7=84=E8=8C=83=E5=8C=96=EF=BC=8C=E6=9B=B4=E6=96=B0=E9=A1=B9?= =?UTF-8?q?=E7=9B=AE=E6=96=87=E6=A1=A3=EF=BC=8C=E5=A2=9E=E5=8A=A0=E6=94=B9?= =?UTF-8?q?=E9=80=A0=E9=87=8C=E7=A8=8B=E7=A2=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .cursorrules | 119 ---- .gitignore | 2 + .metadata | 30 - .pubignore | 11 + CHANGELOG.md | 10 + IOT_UPGRADE_PLAN.md | 150 ---- LICENSE | 22 +- example/.metadata | 30 - .../macos_battery_evolution_plan.yaml | 308 ++++++++ macos/flutter_battery.podspec | 6 +- plan/ARCHITECTURE_REFACTOR_PLAN.yaml | 471 +++++++++++++ plan/PLUGIN_QUALITY_ROADMAP.md | 663 ++++++++++++++++++ plan/SESSION_CHECKPOINT.md | 47 ++ pubspec.yaml | 9 +- 14 files changed, 1544 insertions(+), 334 deletions(-) delete mode 100644 .cursorrules delete mode 100644 .metadata create mode 100644 .pubignore delete mode 100644 IOT_UPGRADE_PLAN.md delete mode 100644 example/.metadata create mode 100644 integration/channel/contracts/macos_battery_evolution_plan.yaml create mode 100644 plan/ARCHITECTURE_REFACTOR_PLAN.yaml create mode 100644 plan/PLUGIN_QUALITY_ROADMAP.md create mode 100644 plan/SESSION_CHECKPOINT.md diff --git a/.cursorrules b/.cursorrules deleted file mode 100644 index 557d074..0000000 --- a/.cursorrules +++ /dev/null @@ -1,119 +0,0 @@ - - -# Flutter Expert - -# Flutter Expert .cursorrules - -## Flexibility Notice - -**Note:** This is a recommended project structure, but be flexible and adapt to existing project structures. Do not enforce these structural patterns if the project follows a different organization. Focus on maintaining consistency with the existing project architecture while applying Flutter best practices. - -## Flutter Best Practices - -```dart -const flutterBestPractices = [ - "Adapt to existing project architecture while maintaining clean code principles", - "Use Flutter 3.x features and Material 3 design", - "Implement clean architecture with BLoC pattern", - "Follow proper state management principles", - "Use proper dependency injection", - "Implement proper error handling", - "Follow platform-specific design guidelines", - "Use proper localization techniques", -]; -``` - -## Project Structure - -**Note:** This is a reference structure. Adapt to the project's existing organization. - -```dart -const projectStructure = ` -lib/ - core/ - constants/ - theme/ - utils/ - widgets/ - features/ - feature_name/ - data/ - datasources/ - models/ - repositories/ - domain/ - entities/ - repositories/ - usecases/ - presentation/ - bloc/ - pages/ - widgets/ - l10n/ - main.dart -test/ - unit/ - widget/ - integration/ -`; -``` - -## Coding Guidelines - -```dart -const codingGuidelines = ` -1. Use proper null safety practices -2. Implement proper error handling with Either type -3. Follow proper naming conventions -4. Use proper widget composition -5. Implement proper routing using GoRouter -6. Use proper form validation -7. Follow proper state management with BLoC -8. Implement proper dependency injection using GetIt -9. Use proper asset management -10. Follow proper testing practices -`; -``` - -## Widget Guidelines - -```dart -const widgetGuidelines = ` -1. Keep widgets small and focused -2. Use const constructors when possible -3. Implement proper widget keys -4. Follow proper layout principles -5. Use proper widget lifecycle methods -6. Implement proper error boundaries -7. Use proper performance optimization techniques -8. Follow proper accessibility guidelines -`; -``` - -## Performance Guidelines - -```dart -const performanceGuidelines = ` -1. Use proper image caching -2. Implement proper list view optimization -3. Use proper build methods optimization -4. Follow proper state management patterns -5. Implement proper memory management -6. Use proper platform channels when needed -7. Follow proper compilation optimization techniques -`; -``` - -## Testing Guidelines - -```dart -const testingTestingGuidelines = ` -1. Write unit tests for business logic -2. Implement widget tests for UI components -3. Use integration tests for feature testing -4. Implement proper mocking strategies -5. Use proper test coverage tools -6. Follow proper test naming conventions -7. Implement proper CI/CD testing -`; -``` diff --git a/.gitignore b/.gitignore index 0b6a5c9..e1b0f5f 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,8 @@ .svn/ .swiftpm/ migrate_working_dir/ +.metadata +.cursorrules # IntelliJ related *.iml diff --git a/.metadata b/.metadata deleted file mode 100644 index a704b37..0000000 --- a/.metadata +++ /dev/null @@ -1,30 +0,0 @@ -# This file tracks properties of this Flutter project. -# Used by Flutter tool to assess capabilities and perform upgrades etc. -# -# This file should be version controlled and should not be manually edited. - -version: - revision: "ea121f8859e4b13e47a8f845e4586164519588bc" - channel: "stable" - -project_type: plugin - -# Tracks metadata for the flutter migrate command -migration: - platforms: - - platform: root - create_revision: ea121f8859e4b13e47a8f845e4586164519588bc - base_revision: ea121f8859e4b13e47a8f845e4586164519588bc - - platform: android - create_revision: ea121f8859e4b13e47a8f845e4586164519588bc - base_revision: ea121f8859e4b13e47a8f845e4586164519588bc - - # User provided section - - # List of Local paths (relative to this file) that should be - # ignored by the migrate tool. - # - # Files that are not part of the templates will be ignored by default. - unmanaged_files: - - 'lib/main.dart' - - 'ios/Runner.xcodeproj/project.pbxproj' diff --git a/.pubignore b/.pubignore new file mode 100644 index 0000000..5364ca6 --- /dev/null +++ b/.pubignore @@ -0,0 +1,11 @@ +AGENTS.md +example/AGENTS.md +ARCHITECTURE_REFACTOR_PLAN.yaml +PLUGIN_QUALITY_ROADMAP.md +workflow_context.md +.cursorrules +.idea/ +*.iml +plan/ +scripts/ +integration/channel/contracts/macos_battery_evolution_plan.yaml diff --git a/CHANGELOG.md b/CHANGELOG.md index 093c07e..07acb93 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,13 @@ +## Unreleased + +* 重构平台架构:集中通道常量、新增平台能力查询 API +* 新增 `BatteryFeature`、`BatteryPlatformCapabilities`、`UnsupportedBatteryFeatureException` +* macOS 支持事件规范化、回调桥接、显式声明不支持的通知/BLE/IoT 功能 +* 示例页面通过能力对象控制功能入口 +* 移除 iOS 声明(待未来实现) +* 更新通道契约文档,覆盖所有方法/事件通道与平台支持矩阵 +* 更新 README、AGENTS.md、example/AGENTS.md + ## 0.0.3 * 新增高级整合API `configureBattery`,一次性配置所有电池监控功能 diff --git a/IOT_UPGRADE_PLAN.md b/IOT_UPGRADE_PLAN.md deleted file mode 100644 index 3d7df9b..0000000 --- a/IOT_UPGRADE_PLAN.md +++ /dev/null @@ -1,150 +0,0 @@ -# Flutter Battery → 标准 Flutter 混生 Android IoT 改造文档 - -## 0. Repo Snapshot (当前 /mnt/e/flutter_battery) -- flutter_battery/: 现有插件,Android 入口 `com.example.flutter_battery.FlutterBatteryPlugin` -- android/: 插件 Android 工程 (AAR);Gradle Wrapper 可沿用 -- example/: Flutter 示例 Runner,可作为新 app/ 的 UI 参考 -- lib/, test/, analysis_options.yaml: 插件 Dart 层逻辑,需保留 -- pubspec.yaml: SDK 约束 `>=3.0.0 <4.0.0`,升级时保持兼容 - -## 1. 目标目录与职责 -``` -repo-root/ -├─ flutter_battery/ # 电池子系统 SDK,MethodChannel/EventChannel 仅做电池能力 -├─ app/ # Flutter 混生外壳,UI/状态管理 + channel 统一封装 -│ ├─ lib/ # 设备页/仪表盘/曲线/设置 + bloc/provider -│ ├─ android/app/ # Runner,集成 android-iot-native + flutter_battery -│ └─ ios/ # 预留,最小依赖 flutter_battery -├─ android-iot-native/ # Kotlin/Jetpack BLE+Foreground Service+Telemetry Library -│ ├─ src/main/java|kotlin/… # BLE 扫描/连接/指令、Service、Repository -│ ├─ src/main/res/ # Foreground 通知、布局、string -│ └─ src/main/AndroidManifest.xml # Service + permission 声明 -├─ integration/ # Channel contract、proto、bridge 测试桩 -├─ scripts/ # 重组脚本、CI helper、bootstrap -├─ build.gradle / settings.gradle # 根构建脚本,统一版本 catalog -└─ .github/workflows/… (可选) # CI,包含 flutter build + gradle lint -``` - -## 2. 改造步骤清单 -1. `git mv example app` 或 `flutter create --platforms=android -a kotlin --project-name iot_shell app` (推荐新建,避免插件示例耦合) -2. `flutter pub add --path ../flutter_battery flutter_battery` (在 app/) -3. `mkdir -p android-iot-native/src/main/{java,kotlin,res}` 并初始化 `build.gradle.kts` -4. 根目录建立 `settings.gradle.kts`,`include(":app", ":android-iot-native", ":flutter_battery")` -5. `app/android/app/build.gradle`:应用插件 `com.android.application`,`implementation(project(":android-iot-native"))` -6. `android-iot-native` 中实现 `MethodChannel("iot/native")` handler + `EventChannel("iot/stream")` emitter,通过 `BinaryMessenger` 注入 (App Runner 或 FlutterEngine) -7. `flutter_battery` 暴露的 `FlutterBatteryPlugin` 保持不变;在 app/lib 建立 `BatterySubsystemRepository` 聚合插件 + native stream -8. `integration/channel` 维护 `channel_contract.yaml`,描述 Method/args/Event payloads,供 Dart/Android 双向验证 -9. 根级 `gradle/libs.versions.toml` 统一版本:`kotlin=1.9.x、agp=8.1.x、coreKtx=1.12.x、lifecycle=2.6.x、room=2.5.x、coroutines=1.7.x` -10. Android Studio/Gradle Sync,确认 `android-iot-native` 作为 library,`app` 为 application,`flutter_battery` 仍由 Flutter tool 管理 - -## 3. 模块创建命令 & Gradle 关联 -```bash -# Flutter 外壳 (repo 根执行) -flutter create --project-name iot_shell --platforms=android -a kotlin app - -# Kotlin 库模块 -mkdir -p android-iot-native/src/main/{java,kotlin,res} -cat <<'GRADLE' > android-iot-native/build.gradle.kts -plugins { - id("com.android.library") - kotlin("android") - id("kotlin-kapt") -} -android { - namespace = "com.example.iot.native" - compileSdk = libs.versions.compileSdk.get().toInt() - defaultConfig { - minSdk = 26 - targetSdk = 34 - } -} -dependencies { - implementation(libs.androidx.core.ktx) - implementation(libs.androidx.lifecycle.runtime) - implementation(libs.androidx.activity.ktx) - implementation(libs.androidx.room.runtime) - kapt(libs.androidx.room.compiler) - implementation(libs.kotlinx.coroutines.android) -} -GRADLE -``` -```kotlin -// 根 settings.gradle.kts -pluginManagement { - repositories { google(); mavenCentral(); gradlePluginPortal() } -} -include(":app", ":android-iot-native", ":flutter_battery") -project(":flutter_battery").projectDir = file("flutter_battery") -``` -```groovy -// app/android/app/build.gradle (精简) -plugins { - id 'com.android.application' - id 'org.jetbrains.kotlin.android' -} -android { - namespace "com.example.iot.shell" - compileSdk rootProject.ext.compileSdk - defaultConfig { - applicationId "com.example.iot.shell" - minSdk 26 - targetSdk 34 - versionCode 1 - versionName "1.0" - } -} -dependencies { - implementation project(':android-iot-native') - implementation project(':flutter_battery') - implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlinVersion" -} -``` - -## 4. 权限与 Manifest 归属 (API 31+) -| Permission/API | 归属 | 用途/说明 | -|---------------------------|------|-----------| -| `android.permission.BLUETOOTH_SCAN` (31)| app Manifest `` + feature `android.hardware.bluetooth_le` | -| `android.permission.BLUETOOTH_CONNECT` | app Manifest | -| `android.permission.BLUETOOTH_ADVERTISE` (可选) | app Manifest | -| `android.permission.ACCESS_FINE_LOCATION` | app Manifest,BLE 扫描需要 | -| `android.permission.ACCESS_COARSE_LOCATION` | app Manifest,兼容旧机 | -| `android.permission.ACCESS_BACKGROUND_LOCATION` (maxSdk30) | app Manifest Queries/back-compat | -| `android.permission.POST_NOTIFICATIONS` | app Manifest,前台 Service 通知 | -| `android.permission.FOREGROUND_SERVICE` | app Manifest | -| `android.permission.FOREGROUND_SERVICE_CONNECTED_DEVICE` (34+) | 库 Manifest (android-iot-native) | -| `` | 库 Manifest | -| `` (若使用 App Startup) | 库 Manifest | -| `` (BLE 扫描) | app Manifest | - -## 5. Channel/交互约束 -- MethodChannel `iot/native`: `scanDevices(args: {filters, timeout})`, `connect(deviceId)`, `startTelemetry(battery=true, sensors=true)`, `stopTelemetry()`, `requestBatterySnapshot()` -- EventChannel `iot/stream`: payload schema `{type, deviceId, ts, data}`;type 包括 `telemetry`, `battery`, `connection` -- app/lib 建立 `NativeBridge`,所有 UI 与 native 通信在此模块;flutter_battery 暴露的 `BatteryLevelStream` 作为 `type=battery` 的唯一来源 -- android-iot-native 内部模块:`ble`, `service`, `telemetry`, `batteryreport`,均通过 `ChannelBridge` 统一出口 - -## 6. Bootstrap 脚本 (scripts/bootstrap_iot.sh) -```bash -#!/usr/bin/env bash -set -euo pipefail -ROOT=$(cd "$(dirname "$0")/.." && pwd) -cd "$ROOT" - -mkdir -p app/lib app/android app/ios -mkdir -p android-iot-native/src/main/{java,kotlin,res} -mkdir -p integration/channel/contracts -mkdir -p scripts - -: > app/lib/main.dart -: > app/android/app_build_notes.md -: > android-iot-native/build.gradle.kts -: > android-iot-native/src/main/AndroidManifest.xml -: > integration/channel/contracts/channel_contract.yaml -``` - -## 7. 验收 Checklist -- `./gradlew :app:assembleDebug`, `:android-iot-native:assemble`, `flutter build apk` 均成功 -- Gradle Sync / `./gradlew tasks` 无 module 丢失;`settings.gradle.kts` 含三个模块 -- Manifest Merge report (Android Studio → Analyzer) 无冲突;所需权限全部在最终 merged manifest -- `./gradlew :app:lintRelease` 与 `:android-iot-native:lint` 通过,BLE 权限告警关闭 -- Flutter `MethodChannel`、`EventChannel` 注册点存在 (`app/android/app/src/main/kotlin/.../MainActivity.kt`);`NativeBridge` Dart 层 API 与 android-iot-native 对齐 -- Telemetry ForegroundService 在 Android 12+ 正常弹出通知 (手动验证);POST_NOTIFICATIONS runtime grant 流程完成 diff --git a/LICENSE b/LICENSE index ba75c69..4eee2a9 100644 --- a/LICENSE +++ b/LICENSE @@ -1 +1,21 @@ -TODO: Add your license here. +MIT License + +Copyright (c) 2024 flutter_battery contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/example/.metadata b/example/.metadata deleted file mode 100644 index c24b9a1..0000000 --- a/example/.metadata +++ /dev/null @@ -1,30 +0,0 @@ -# This file tracks properties of this Flutter project. -# Used by Flutter tool to assess capabilities and perform upgrades etc. -# -# This file should be version controlled and should not be manually edited. - -version: - revision: "00b0c91f06209d9e4a41f71b7a512d6eb3b9c694" - channel: "stable" - -project_type: app - -# Tracks metadata for the flutter migrate command -migration: - platforms: - - platform: root - create_revision: 00b0c91f06209d9e4a41f71b7a512d6eb3b9c694 - base_revision: 00b0c91f06209d9e4a41f71b7a512d6eb3b9c694 - - platform: macos - create_revision: 00b0c91f06209d9e4a41f71b7a512d6eb3b9c694 - base_revision: 00b0c91f06209d9e4a41f71b7a512d6eb3b9c694 - - # User provided section - - # List of Local paths (relative to this file) that should be - # ignored by the migrate tool. - # - # Files that are not part of the templates will be ignored by default. - unmanaged_files: - - 'lib/main.dart' - - 'ios/Runner.xcodeproj/project.pbxproj' diff --git a/integration/channel/contracts/macos_battery_evolution_plan.yaml b/integration/channel/contracts/macos_battery_evolution_plan.yaml new file mode 100644 index 0000000..7226358 --- /dev/null +++ b/integration/channel/contracts/macos_battery_evolution_plan.yaml @@ -0,0 +1,308 @@ +schema: codex.task_plan.v1 +id: flutter_battery.macos_battery_evolution +repo: flutter_battery +generated_from: + assessment_date: 2026-05-05 + platform: macos + objective: battery_level_detection + +current_state: + macos_declared: + status: true + evidence: + - path: pubspec.yaml + selector: flutter.plugin.platforms.macos.pluginClass + value: FlutterBatteryPlugin + method_channel: + status: true + name: flutter_battery + evidence: + - path: macos/flutter_battery/Classes/FlutterBatteryPlugin.swift + symbol: FlutterMethodChannel + event_channel: + status: partial + name: flutter_battery/battery_stream + evidence: + - path: macos/flutter_battery/Classes/FlutterBatteryPlugin.swift + symbol: FlutterEventChannel + battery_level_query: + status: supported + dart_entrypoint: FlutterBattery.getBatteryLevel + native_method: BatteryMonitor.getBatteryLevel + native_api: + framework: IOKit.ps + key: kIOPSCurrentCapacityKey + no_battery_device_result: + status: ambiguous + current_value: -1 + desired_contract: null_or_explicit_unavailable + +gaps: + - id: gap.stream.simple_payload_key_mismatch + severity: high + dart_expected_keys: + - batteryLevel + - timestamp + macos_emitted_keys: + - level + - timestamp + affected_entrypoints: + - FlutterBattery.batteryInfoStream + files: + - lib/flutter_battery.dart + - macos/flutter_battery/Classes/BatteryMonitor.swift + - id: gap.method_callback_not_wired_on_macos + severity: high + expected_native_to_dart_methods: + - onBatteryLevelChanged + - onBatteryInfoChanged + - onBatteryHealthChanged + - onLowBattery + files: + - lib/flutter_battery_method_channel.dart + - macos/flutter_battery/Classes/FlutterBatteryPlugin.swift + - macos/flutter_battery/Classes/BatteryMonitor.swift + - id: gap.health_stream_not_emitted_on_macos + severity: medium + expected_event_type: BATTERY_HEALTH + files: + - lib/flutter_battery.dart + - macos/flutter_battery/Classes/BatteryMonitor.swift + - id: gap.contract_missing_flutter_battery_channels + severity: medium + missing_channels: + - flutter_battery + - flutter_battery/battery_stream + files: + - integration/channel/contracts/channel_contract.yaml + - id: gap.macos_podspec_version_mismatch + severity: low + pubspec_version: 0.0.3 + podspec_version: 0.0.1 + files: + - pubspec.yaml + - macos/flutter_battery.podspec + +target_contract: + method_channel: + name: flutter_battery + methods: + - id: getBatteryLevel + args: {} + returns: + type: int? + range: 0..100 + unavailable: null + - id: getBatteryInfo + args: {} + returns: + type: map + required_keys: + level: int? + isCharging: bool + state: enum.NORMAL_LOW_CRITICAL_CHARGING_FULL + timestamp: int + optional_keys: + isCharged: bool + timeToFull: int + timeToEmpty: int + temperature: double + voltage: double + - id: getBatteryHealth + args: {} + returns: + type: map + required_keys: + state: enum.GOOD_OVERHEAT_DEAD_OVER_VOLTAGE_FAILURE_COLD_UNKNOWN + statusLabel: string + isGood: bool + level: int? + isCharging: bool + riskLevel: enum.LOW_MEDIUM_HIGH + recommendations: list.string + timestamp: int + event_channel: + name: flutter_battery/battery_stream + payloads: + - type: BATTERY_LEVEL + required_keys: + type: string + batteryLevel: int? + level: int? + timestamp: int + - type: BATTERY_INFO + required_keys: + type: string + level: int? + batteryLevel: int? + isCharging: bool + state: string + timestamp: int + - type: BATTERY_HEALTH + required_keys: + type: string + state: string + statusLabel: string + isGood: bool + level: int? + riskLevel: string + recommendations: list.string + timestamp: int + +tasks: + - id: task.001.normalize_macos_level_unavailable + priority: P0 + status: pending + depends_on: [] + intent: define_no_battery_semantics + files: + read: + - lib/flutter_battery_method_channel.dart + - macos/flutter_battery/Classes/BatteryMonitor.swift + write: + - macos/flutter_battery/Classes/BatteryMonitor.swift + - lib/flutter_battery_method_channel.dart + - test/flutter_battery_method_channel_test.dart + operations: + - native_return_nil_for_unavailable_or_keep_minus_one_with_documented_contract + - dart_normalize_minus_one_to_null_if_contract_unavailable_is_null + - add_method_channel_test_for_minus_one_or_null + acceptance: + - getBatteryLevel_returns_0_to_100_on_battery_macos + - getBatteryLevel_returns_null_or_documented_unavailable_on_desktop_macos + + - id: task.002.normalize_event_payload_keys + priority: P0 + status: pending + depends_on: + - task.001.normalize_macos_level_unavailable + intent: make_batteryInfoStream_parse_macos_level + files: + read: + - lib/flutter_battery.dart + - macos/flutter_battery/Classes/BatteryMonitor.swift + write: + - lib/flutter_battery.dart + - macos/flutter_battery/Classes/BatteryMonitor.swift + - test/flutter_battery_test.dart + operations: + - emit_type_BATTERY_LEVEL_from_macos_simple_level_timer + - emit_batteryLevel_and_level_aliases + - update_dart_stream_parser_to_accept_level_or_batteryLevel + - add_stream_test_for_macos_payload_level_only + acceptance: + - batteryInfoStream_maps_level_key_to_BatteryInfo.level + - batteryInfoStream_maps_batteryLevel_key_to_BatteryInfo.level + + - id: task.003.wire_macos_method_callbacks + priority: P1 + status: pending + depends_on: + - task.002.normalize_event_payload_keys + intent: align_configureBatteryCallbacks_on_macos + files: + read: + - lib/flutter_battery_method_channel.dart + - macos/flutter_battery/Classes/FlutterBatteryPlugin.swift + - macos/flutter_battery/Classes/BatteryMonitor.swift + write: + - macos/flutter_battery/Classes/FlutterBatteryPlugin.swift + - macos/flutter_battery/Classes/BatteryMonitor.swift + operations: + - inject_method_channel_into_BatteryMonitor_or_callback_bridge + - invoke_onBatteryLevelChanged_with_batteryLevel + - invoke_onBatteryInfoChanged_with_map_payload + - invoke_onBatteryHealthChanged_with_map_payload + acceptance: + - configureBatteryCallbacks_onBatteryLevelChange_receives_macos_timer_updates + - configureBatteryCallbacks_onBatteryInfoChange_receives_macos_info_updates + - configureBatteryCallbacks_onBatteryHealthChange_receives_macos_health_updates + + - id: task.004.emit_macos_health_stream + priority: P1 + status: pending + depends_on: + - task.002.normalize_event_payload_keys + intent: make_batteryHealthStream_work_on_macos + files: + read: + - lib/flutter_battery.dart + - macos/flutter_battery/Classes/BatteryMonitor.swift + write: + - macos/flutter_battery/Classes/BatteryMonitor.swift + - test/flutter_battery_test.dart + operations: + - add_type_BATTERY_HEALTH_to_macos_health_payload + - send_health_payload_to_eventChannelHandler + - add_stream_test_for_BATTERY_HEALTH_payload + acceptance: + - batteryHealthStream_emits_BatteryHealth_from_macos_event_payload + + - id: task.005.update_channel_contract + priority: P2 + status: pending + depends_on: + - task.002.normalize_event_payload_keys + - task.004.emit_macos_health_stream + intent: document_flutter_battery_channels + files: + read: + - integration/channel/contracts/channel_contract.yaml + write: + - integration/channel/contracts/channel_contract.yaml + operations: + - add_flutter_battery_method_channel_schema + - add_flutter_battery_event_channel_schema + - encode_platform_notes_for_android_macos + acceptance: + - channel_contract_contains_flutter_battery_method_schema + - channel_contract_contains_flutter_battery_stream_schema + + - id: task.006.sync_macos_metadata_docs + priority: P3 + status: pending + depends_on: + - task.005.update_channel_contract + intent: release_readiness + files: + read: + - pubspec.yaml + - macos/flutter_battery.podspec + - README.md + - CHANGELOG.md + write: + - macos/flutter_battery.podspec + - README.md + - CHANGELOG.md + operations: + - sync_podspec_version_with_pubspec + - document_macos_supported_methods + - document_macos_unsupported_notifications + - document_no_battery_device_behavior + acceptance: + - podspec_version_equals_pubspec_version + - README_contains_macos_support_matrix + +verification: + static: + commands: + - dart format lib test + - flutter analyze + - flutter test + manual_macos: + commands: + - cd example && flutter run -d macos + checks: + - getBatteryLevel_displays_actual_percentage_on_macbook + - getBatteryInfo_displays_level_and_charging_state + - batteryInfoStream_updates_without_zero_regression + - batteryHealthStream_emits_when_enabled + - no_crash_on_no_battery_mac + +execution_order: + - task.001.normalize_macos_level_unavailable + - task.002.normalize_event_payload_keys + - task.003.wire_macos_method_callbacks + - task.004.emit_macos_health_stream + - task.005.update_channel_contract + - task.006.sync_macos_metadata_docs diff --git a/macos/flutter_battery.podspec b/macos/flutter_battery.podspec index 9afe559..1f1e041 100644 --- a/macos/flutter_battery.podspec +++ b/macos/flutter_battery.podspec @@ -1,11 +1,11 @@ Pod::Spec.new do |s| s.name = 'flutter_battery' - s.version = '0.0.1' + s.version = '0.0.3' s.summary = 'Flutter battery plugin macOS implementation' s.description = 'A Flutter plugin for battery monitoring on macOS.' - s.homepage = 'https://github.com/yourorg/flutter_battery' + s.homepage = 'https://github.com/lizy-coding/flutter_battery' s.license = { :file => '../LICENSE' } - s.author = { 'Your Company' => 'email@example.com' } + s.author = { 'flutter_battery contributors' => '' } s.source = { :path => '.' } s.source_files = 'flutter_battery/Classes/**/*' s.public_header_files = 'flutter_battery/Classes/**/*.h' diff --git a/plan/ARCHITECTURE_REFACTOR_PLAN.yaml b/plan/ARCHITECTURE_REFACTOR_PLAN.yaml new file mode 100644 index 0000000..ad2b1e7 --- /dev/null +++ b/plan/ARCHITECTURE_REFACTOR_PLAN.yaml @@ -0,0 +1,471 @@ +schema: codex.refactor_plan.v1 +id: flutter_battery.architecture_platform_refactor +repo: flutter_battery +generated_at: 2026-05-10 +objective: + primary: isolate_platform_differences + secondary: + - make_macos_support_explicit + - normalize_channel_contracts + - reduce_page_and_api_platform_branching + - make_unsupported_features_testable + +constraints: + preserve_public_api_where_possible: true + no_native_behavior_guessing_without_contract: true + keep_android_behavior_compatible: true + prefer_small_incremental_commits: true + required_verification: + - flutter analyze + - flutter test + - cd example && flutter test + +current_architecture: + layers: + dart_public_api: + files: + - lib/flutter_battery.dart + - lib/flutter_bluetooth.dart + - lib/peer_battery_service.dart + issues: + - public_api_has_no_platform_capability_model + - battery_stream_parser_accepts_android_shape_more_than_contract_shape + - unavailable_battery_semantics_are_not_declared + dart_platform_interface: + files: + - lib/flutter_battery_platform_interface.dart + - lib/flutter_battery_method_channel.dart + issues: + - interface_defines_methods_but_not_feature_support + - method_channel_has_no_typed_payload_models + - MissingPluginException_handling_is_left_to_callers + - configureBatteryMonitor_assumes_all_platforms_implement_all_listeners + native_android: + files: + - android/src/main/kotlin/com/example/flutter_battery/FlutterBatteryPlugin.kt + - android/src/main/kotlin/com/example/flutter_battery/channel/MethodChannelHandler.kt + - android/src/main/kotlin/com/example/flutter_battery/channel/EventChannelHandler.kt + strengths: + - rich_battery_monitoring + - method_callbacks_to_dart + - event_payload_types_for_info_and_health + - notifications + - ble_peer_sync + issues: + - iot_native_channel_lives_in_plugin_repo_but_is_example_or_integration_concern + - event_payload_contract_not_generated_or_validated + native_macos: + files: + - macos/flutter_battery/Classes/FlutterBatteryPlugin.swift + - macos/flutter_battery/Classes/BatteryMonitor.swift + strengths: + - flutter_battery_method_channel_declared + - flutter_battery_battery_stream_declared + - getBatteryLevel_getBatteryInfo_getBatteryHealth_exist + issues: + - callback_bridge_not_wired + - stream_payload_shape_differs_from_android + - health_stream_not_emitted + - unavailable_no_battery_semantics_use_minus_one_without_contract + - notification_methods_return_errors_without_capability_declaration + - ble_peer_sync_channels_not_declared + - iot_channels_not_declared + example_app: + files: + - example/lib/main.dart + - example/lib/pages/dashboard_page.dart + - example/lib/platform/example_platform_adapter.dart + current_state: + - partial_adapter_for_android_only_example_features_exists + issues: + - adapter_is_example_local_not_plugin_capability_api + - feature_availability_is_not_shared_with_library_consumers + - low_battery_notification_page_still_exposes_android_native_notification_flow_on_macos + contracts: + files: + - integration/channel/contracts/channel_contract.yaml + - integration/channel/contracts/macos_battery_evolution_plan.yaml + issues: + - channel_contract_yaml_documents_iot_and_peer_only + - flutter_battery_method_channel_missing_from_primary_contract + - flutter_battery_event_channel_missing_from_primary_contract + - macos_contract_plan_is_not_enforced_by_tests + +target_architecture: + packages: + lib: + public_api: + - FlutterBattery + - BatteryInfo + - BatteryHealth + - BatteryPlatformCapabilities + - BatteryFeature + - BatteryFeatureAvailability + internal_contracts: + - BatteryChannelNames + - BatteryMethodNames + - BatteryEventTypes + - BatteryPayloadKeys + platform_interface: + required: + - getPlatformVersion + - getPlatformCapabilities + - getBatteryLevel + - getBatteryInfo + - getBatteryHealth + - getBatteryOptimizationTips + - batteryStream + optional_by_capability: + - configureBatteryMonitor + - configureBatteryMonitoring + - sendNotification + - scheduleNotification + - showNotification + - blePeerSync + native: + android: + implements: + - batteryLevel + - batteryInfo + - batteryHealth + - batteryLevelStream + - batteryInfoStream + - batteryHealthStream + - methodCallbacks + - lowBatteryMonitoring + - nativeNotifications + - blePeerSync + - iotExampleBridge + macos: + implements_target: + - batteryLevel + - batteryInfo + - batteryHealth + - batteryLevelStream + - batteryInfoStream + - batteryHealthStream + - methodCallbacks + explicitly_unsupported: + - nativeNotifications + - scheduledNotifications + - blePeerSync + - iotExampleBridge + - lowBatterySystemNotification + ios: + status: declared_in_pubspec_but_native_files_absent + action: either_add_ios_implementation_or_remove_ios_declaration_until_supported + +missing_macos_interface_declarations: + public_dart_capability_api: + missing: + - BatteryFeature.batteryLevel + - BatteryFeature.batteryInfo + - BatteryFeature.batteryHealth + - BatteryFeature.batteryLevelStream + - BatteryFeature.batteryInfoStream + - BatteryFeature.batteryHealthStream + - BatteryFeature.lowBatteryMonitoring + - BatteryFeature.nativeNotifications + - BatteryFeature.scheduledNotifications + - BatteryFeature.blePeerSync + - BatteryFeature.iotExampleBridge + - FlutterBattery.getPlatformCapabilities() + - FlutterBattery.isFeatureSupported(BatteryFeature) + target_file_new: + - lib/src/platform_capabilities.dart + target_file_update: + - lib/flutter_battery.dart + - lib/flutter_battery_platform_interface.dart + - lib/flutter_battery_method_channel.dart + method_channel_contract: + channel: flutter_battery + missing_contract_file_entries: + - getPlatformVersion + - getPlatformCapabilities + - getBatteryLevel + - getBatteryInfo + - getBatteryHealth + - getBatteryOptimizationTips + - startBatteryLevelListening + - stopBatteryLevelListening + - startBatteryInfoListening + - stopBatteryInfoListening + - startBatteryHealthListening + - stopBatteryHealthListening + - setPushInterval + - setBatteryLevelThreshold + - stopBatteryMonitoring + - scheduleNotification + - showNotification + target_file_update: + - integration/channel/contracts/channel_contract.yaml + event_channel_contract: + channel: flutter_battery/battery_stream + missing_event_types: + - BATTERY_LEVEL + - BATTERY_INFO + - BATTERY_HEALTH + - BATTERY_UNAVAILABLE + - BATTERY_ERROR + target_payload_keys: + BATTERY_LEVEL: + required: + - type + - timestamp + optional: + - batteryLevel + - level + - unavailableReason + BATTERY_INFO: + required: + - type + - timestamp + - isCharging + - state + optional: + - level + - batteryLevel + - isCharged + - timeToFull + - timeToEmpty + - temperature + - voltage + - unavailableReason + BATTERY_HEALTH: + required: + - type + - timestamp + - state + - statusLabel + - isGood + - riskLevel + - recommendations + optional: + - level + - batteryLevel + - isCharging + - temperature + - voltage + - healthPercentage + - maxCapacity + - currentCapacity + - designCapacity + - cycleCount + - serialNumber + - manufacturer + - deviceName + - unavailableReason + native_macos_callback_bridge: + missing: + - BatteryMonitor.setOnBatteryLevelChangeCallback + - BatteryMonitor.setOnBatteryInfoChangeCallback + - BatteryMonitor.setOnBatteryHealthChangeCallback + - FlutterBatteryPlugin.invokeMethod_onBatteryLevelChanged + - FlutterBatteryPlugin.invokeMethod_onBatteryInfoChanged + - FlutterBatteryPlugin.invokeMethod_onBatteryHealthChanged + - optional_FlutterBatteryPlugin.invokeMethod_onLowBattery_if_low_battery_monitoring_supported + target_file_update: + - macos/flutter_battery/Classes/FlutterBatteryPlugin.swift + - macos/flutter_battery/Classes/BatteryMonitor.swift + native_macos_unsupported_interfaces: + missing_explicit_declared_unsupported: + - flutter_battery/ble_methods + - flutter_battery/ble_scan_events + - flutter_battery/ble_connection_events + - flutter_battery/peer_methods + - flutter_battery/peer_events + - iot/native + - iot/stream + recommended_policy: + library_layer: expose_feature_as_unsupported_not_MissingPluginException + example_layer: disable_ui_by_capability + native_layer: do_not_register_channels_unless_product_requires_them + +refactor_tasks: + - id: R001.add_channel_constants_and_payload_models + priority: P0 + intent: eliminate_stringly_typed_channel_contracts + write: + - lib/src/battery_channel_contract.dart + update: + - lib/flutter_battery_method_channel.dart + - lib/flutter_battery.dart + - test/flutter_battery_test.dart + - test/flutter_battery_method_channel_test.dart + operations: + - define_channel_names + - define_method_names + - define_event_types + - define_payload_keys + - normalize_level_from_batteryLevel_or_level + - normalize_minus_one_to_null_or_unavailable_event + acceptance: + - no_raw_flutter_battery_channel_strings_outside_contract_file_except_native_registration + - batteryInfoStream_accepts_android_and_macos_level_payloads + + - id: R002.add_platform_capability_api + priority: P0 + intent: make_supported_features_queryable + write: + - lib/src/platform_capabilities.dart + update: + - lib/flutter_battery.dart + - lib/flutter_battery_platform_interface.dart + - lib/flutter_battery_method_channel.dart + - macos/flutter_battery/Classes/FlutterBatteryPlugin.swift + - android/src/main/kotlin/com/example/flutter_battery/channel/MethodChannelHandler.kt + operations: + - add_BatteryFeature_enum + - add_BatteryPlatformCapabilities_value_object + - add_getPlatformCapabilities_to_platform_interface + - add_getPlatformCapabilities_method_channel_call + - android_returns_supported_feature_map + - macos_returns_supported_and_unsupported_feature_map + acceptance: + - FlutterBattery_getPlatformCapabilities_returns_value_object + - macos_reports_notifications_and_ble_peer_sync_unsupported + - android_reports_notifications_and_ble_peer_sync_supported + + - id: R003_make_optional_features_fail_predictably + priority: P0 + intent: replace_missing_plugin_with_unsupported_feature_semantics + update: + - lib/flutter_battery.dart + - lib/flutter_battery_method_channel.dart + - lib/peer_battery_service.dart + - lib/flutter_bluetooth_method_channel.dart + - example/lib/platform/example_platform_adapter.dart + operations: + - introduce_UnsupportedBatteryFeatureException + - map_MissingPluginException_to_UnsupportedBatteryFeatureException_for_optional_features + - keep_PlatformException_for_native_runtime_failures + - gate_example_routes_by_capabilities + acceptance: + - macos_peer_sync_call_does_not_surface_raw_MissingPluginException + - macos_iot_demo_route_disabled_by_capability + + - id: R004_normalize_macos_battery_streams + priority: P1 + intent: make_macos_match_dart_stream_contract + update: + - macos/flutter_battery/Classes/BatteryMonitor.swift + - lib/flutter_battery.dart + - test/flutter_battery_test.dart + operations: + - emit_BATTERY_LEVEL_with_batteryLevel_and_level_aliases + - emit_BATTERY_INFO_with_type + - emit_BATTERY_HEALTH_with_type + - event_channel_send_health_payload + - no_battery_emit_BATTERY_UNAVAILABLE_or_null_level + acceptance: + - batteryInfoStream_parses_macos_simple_level_event + - batteryHealthStream_emits_from_macos_health_event + - no_battery_macos_does_not_render_0_percent_by_accident + + - id: R005_wire_macos_method_callbacks + priority: P1 + intent: make_configureBatteryCallbacks_work_on_macos + update: + - macos/flutter_battery/Classes/FlutterBatteryPlugin.swift + - macos/flutter_battery/Classes/BatteryMonitor.swift + operations: + - add_callback_setters_to_BatteryMonitor + - inject_method_channel_callback_bridge + - invoke_onBatteryLevelChanged + - invoke_onBatteryInfoChanged + - invoke_onBatteryHealthChanged + acceptance: + - example_dashboard_updates_on_macos_after_configureBatteryMonitor + - callbacks_and_event_streams_emit_same_normalized_payload_shape + + - id: R006_split_example_iot_from_plugin_contract + priority: P2 + intent: avoid_plugin_public_surface_being_mixed_with_demo_iot_bridge + update: + - integration/channel/contracts/channel_contract.yaml + - example/lib/platform/example_platform_adapter.dart + - example/README.md + operations: + - mark_iot_native_as_example_android_only + - keep_peer_sync_as_plugin_optional_feature + - document_iot_channels_not_part_of_flutter_battery_public_api + acceptance: + - channel_contract_separates_plugin_channels_from_example_channels + + - id: R007_update_primary_channel_contract + priority: P2 + intent: make_contract_file_authoritative + update: + - integration/channel/contracts/channel_contract.yaml + operations: + - add_flutter_battery_method_channel + - add_flutter_battery_battery_stream_channel + - add_peer_channels_as_optional_android_feature + - add_platform_support_matrix + acceptance: + - contract_contains_method_event_payloads_for_android_and_macos + + - id: R008_resolve_ios_declaration + priority: P2 + intent: remove_false_platform_claim_or_add_scaffold + update_options: + option_a_remove_until_supported: + - pubspec.yaml + option_b_add_ios_scaffold: + - ios/Classes/FlutterBatteryPlugin.swift + - ios/flutter_battery.podspec + decision_rule: + - if_ios_not_in_scope_remove_ios_platform_declaration + - if_ios_in_scope_add_minimal_capability_reporting_and_battery_level + acceptance: + - flutter_pub_get_has_no_false_ios_plugin_declaration + +implementation_order: + - R001.add_channel_constants_and_payload_models + - R002.add_platform_capability_api + - R003.make_optional_features_fail_predictably + - R004.normalize_macos_battery_streams + - R005.wire_macos_method_callbacks + - R006.split_example_iot_from_plugin_contract + - R007.update_primary_channel_contract + - R008.resolve_ios_declaration + +test_matrix: + unit: + - test/flutter_battery_test.dart + - test/flutter_battery_method_channel_test.dart + - example/test/widget_test.dart + add_tests: + - name: batteryInfoStream_accepts_level_key + file: test/flutter_battery_test.dart + - name: batteryInfoStream_accepts_batteryLevel_key + file: test/flutter_battery_test.dart + - name: batteryHealthStream_accepts_macos_payload + file: test/flutter_battery_test.dart + - name: method_channel_maps_missing_plugin_to_unsupported_for_peer_optional_feature + file: test/flutter_battery_method_channel_test.dart + - name: dashboard_disables_features_from_capability_object + file: example/test/widget_test.dart + manual: + android: + - cd example && flutter run -d android + - verify_battery_level + - verify_battery_info_stream + - verify_low_battery_notification_if_permissions_available + - verify_peer_sync_on_two_devices_if_available + macos: + - cd example && flutter run -d macos + - verify_no_iot_missing_plugin_exception + - verify_battery_level_nonzero_on_macbook + - verify_no_battery_unavailable_on_desktop_mac + - verify_dashboard_updates_after_monitor_start + +done_definition: + - platform_capabilities_are_queryable_from_public_api + - macos_supported_features_are_explicit + - macos_unsupported_features_are_explicit + - flutter_battery_channel_contract_is_documented + - battery_event_payloads_are_normalized + - example_pages_do_not_contain_direct_platform_checks + - raw_MissingPluginException_not_exposed_for_known_optional_features + - all_required_verification_commands_pass diff --git a/plan/PLUGIN_QUALITY_ROADMAP.md b/plan/PLUGIN_QUALITY_ROADMAP.md new file mode 100644 index 0000000..2085130 --- /dev/null +++ b/plan/PLUGIN_QUALITY_ROADMAP.md @@ -0,0 +1,663 @@ +# Flutter Plugin Quality Roadmap + +## Current Rating + +Overall rating: **C+ / 62** + +Current status: + +- `flutter analyze` passes. +- `flutter test` passes. +- `cd example && flutter test` passes. +- `cd example && flutter build macos --debug` passes. +- `flutter pub publish --dry-run` reports `0 warnings`. + +The package is technically publishable, but it is not yet at the quality level expected from a mature open-source Flutter plugin. + +## Target + +Target rating after this roadmap: **A- / 85+** + +Primary goals: + +- Make plugin metadata and publishing files production-ready. +- Make platform support explicit and truthful. +- Separate core battery functionality from demo/integration-only code. +- Stabilize Android/macOS channel contracts. +- Improve documentation, CI, and maintainability. + +## Milestone P0: Publication Readiness + +Goal: make the package safe and credible to publish. + +Priority: blocking + +### Scope + +Files likely affected: + +- `LICENSE` +- `pubspec.yaml` +- `README.md` +- `CHANGELOG.md` +- `.pubignore` +- `macos/flutter_battery.podspec` + +### Steps + +1. Replace placeholder license. + + Current `LICENSE` contains placeholder text. Replace it with a real license, preferably one of: + + - MIT + - BSD-3-Clause + - Apache-2.0 + +2. Fix package metadata. + + Update `pubspec.yaml`: + + ```yaml + homepage: + repository: + issue_tracker: + topics: + - battery + - plugin + - android + - macos + ``` + +3. Fix macOS podspec metadata. + + Update `macos/flutter_battery.podspec`: + + - `s.version` must match `pubspec.yaml`. + - `s.homepage` must be real. + - `s.author` must not use template values. + - `s.license` must match the root license. + +4. Add `.pubignore`. + + Exclude internal planning and agent files from published archives: + + ```text + AGENTS.md + ARCHITECTURE_REFACTOR_PLAN.yaml + IOT_UPGRADE_PLAN.md + workflow_context.md + .cursorrules + .idea/ + integration/channel/contracts/macos_battery_evolution_plan.yaml + ``` + + Keep contract files only if they are meant to be public package artifacts. + +5. Update README support matrix. + + README must explicitly state support by platform: + + | Feature | Android | macOS | + |---|---:|---:| + | Battery level | yes | yes | + | Battery info | yes | yes | + | Temperature | yes | best effort | + | Voltage | yes | best effort | + | Battery health | yes | best effort | + | Native notifications | yes | no | + | BLE peer sync | yes | no | + | IoT demo bridge | example-only | no | + +6. Align CHANGELOG with actual code. + + Add an unreleased section or bump version if publishing. + +### Acceptance Criteria + +Run: + +```sh +flutter analyze +flutter test +cd example && flutter test +cd example && flutter build macos --debug +flutter pub publish --dry-run +flutter pub outdated +``` + +Required result: + +- `analyze` has no issues. +- tests pass. +- macOS debug build succeeds. +- dry-run has no warnings. +- published archive does not include internal roadmap/agent files. +- `LICENSE` is no longer placeholder text. +- podspec version equals pubspec version. + +## Milestone P1: Platform Capability Model + +Goal: make platform differences explicit and remove raw unsupported-platform failures from user-facing APIs. + +Priority: high + +### Scope + +Files likely affected: + +- `lib/src/platform_capabilities.dart` +- `lib/src/battery_channel_contract.dart` +- `lib/flutter_battery.dart` +- `lib/flutter_battery_platform_interface.dart` +- `lib/flutter_battery_method_channel.dart` +- `lib/flutter_bluetooth.dart` +- `lib/flutter_bluetooth_method_channel.dart` +- `lib/peer_battery_service.dart` +- `example/lib/main.dart` +- `example/lib/pages/dashboard_page.dart` +- `example/lib/pages/low_battery_notification_page.dart` +- `macos/flutter_battery/Classes/FlutterBatteryPlugin.swift` +- `android/src/main/kotlin/com/example/flutter_battery/channel/MethodChannelHandler.kt` + +### Steps + +1. Finalize public capability API. + + Ensure these APIs are stable and documented: + + ```dart + enum BatteryFeature { ... } + class BatteryPlatformCapabilities { ... } + + Future FlutterBattery.getPlatformCapabilities(); + Future FlutterBattery.isFeatureSupported(BatteryFeature feature); + ``` + +2. Define platform feature matrix. + + Android should report: + + - `batteryLevel: true` + - `batteryInfo: true` + - `batteryHealth: true` + - `batteryLevelStream: true` + - `batteryInfoStream: true` + - `batteryHealthStream: true` + - `lowBatteryMonitoring: true` + - `nativeNotifications: true` + - `scheduledNotifications: true` + - `blePeerSync: true` + - `iotExampleBridge: true` only if retained as example/integration bridge + + macOS should report: + + - `batteryLevel: true` + - `batteryInfo: true` + - `batteryHealth: true` + - `batteryLevelStream: true` + - `batteryInfoStream: true` + - `batteryHealthStream: true` + - `lowBatteryMonitoring: false` unless fully implemented + - `nativeNotifications: false` + - `scheduledNotifications: false` + - `blePeerSync: false` + - `iotExampleBridge: false` + +3. Normalize unsupported feature behavior. + + Introduce or finalize: + + ```dart + class UnsupportedBatteryFeatureException implements Exception { ... } + ``` + + Raw `MissingPluginException` should not leak for known optional features such as: + + - BLE peer sync on macOS + - native notifications on macOS + - IoT demo bridge on macOS + +4. Gate example UI by capabilities. + + Pages should not hardcode platform checks. They should consume `BatteryPlatformCapabilities`. + +5. Document capability behavior. + + README must explain that features are runtime-queryable and optional. + +### Acceptance Criteria + +Run: + +```sh +flutter analyze +flutter test +cd example && flutter test +``` + +Required result: + +- Capability tests cover Android-like and macOS-like feature maps. +- Example dashboard disables unsupported features from capability object. +- Calling unsupported optional features produces a typed unsupported-feature failure or disabled UI, not raw `MissingPluginException`. +- README documents `getPlatformCapabilities()`. + +## Milestone P2: Channel Contract Stabilization + +Goal: make Android and macOS payloads consistent and testable. + +Priority: high + +### Scope + +Files likely affected: + +- `integration/channel/contracts/channel_contract.yaml` +- `lib/src/battery_channel_contract.dart` +- `lib/flutter_battery.dart` +- `lib/flutter_battery_method_channel.dart` +- `android/src/main/kotlin/com/example/flutter_battery/channel/EventChannelHandler.kt` +- `android/src/main/kotlin/com/example/flutter_battery/channel/MethodChannelHandler.kt` +- `macos/flutter_battery/Classes/BatteryMonitor.swift` +- `macos/flutter_battery/Classes/FlutterBatteryPlugin.swift` +- `test/flutter_battery_test.dart` +- `test/flutter_battery_method_channel_test.dart` + +### Steps + +1. Make `channel_contract.yaml` authoritative. + + Add schemas for: + + - `flutter_battery` + - `flutter_battery/battery_stream` + - `flutter_battery/peer_methods` + - `flutter_battery/peer_events` + +2. Normalize event types. + + Required event types: + + ```text + BATTERY_LEVEL + BATTERY_INFO + BATTERY_HEALTH + BATTERY_UNAVAILABLE + BATTERY_ERROR + ``` + +3. Normalize payload keys. + + For level events: + + ```yaml + type: BATTERY_LEVEL + batteryLevel: int + level: int + timestamp: int + ``` + + For info events: + + ```yaml + type: BATTERY_INFO + batteryLevel: int + level: int + isCharging: bool + state: string + temperature: double + voltage: double + timestamp: int + ``` + + For health events: + + ```yaml + type: BATTERY_HEALTH + state: string + statusLabel: string + isGood: bool + riskLevel: string + recommendations: list + temperature: double + voltage: double + timestamp: int + ``` + +4. Ensure Dart parser accepts both legacy and normalized payloads. + + Required compatibility: + + - Android legacy `batteryLevel` + - macOS legacy `level` + - normalized payload with both keys + +5. Add contract-focused tests. + + Test names should be explicit: + + - `batteryInfoStream_accepts_level_key` + - `batteryInfoStream_accepts_batteryLevel_key` + - `batteryHealthStream_accepts_macos_payload` + - `method_channel_payload_constants_match_contract` + +### Acceptance Criteria + +Run: + +```sh +flutter analyze +flutter test +cd example && flutter test +``` + +Required result: + +- Android and macOS emit the same event type names. +- Dart stream parsers do not assume one platform-specific key. +- Health stream works on macOS. +- Contract file documents all public plugin channels. + +## Milestone P3: Scope Cleanup + +Goal: reduce package scope and avoid publishing demo/integration code as core plugin code. + +Priority: medium + +### Scope + +Files/directories likely affected: + +- `android/src/main/kotlin/com/example/iot/nativekit/**` +- `scripts/bootstrap_iot.sh` +- `integration/channel/contracts/channel_contract.yaml` +- `example/lib/pages/iot_controls_page.dart` +- `example/lib/platform/example_platform_adapter.dart` +- `README.md` + +### Steps + +1. Decide whether IoT bridge is part of public plugin API. + + Recommended decision: no. + +2. Move IoT nativekit out of plugin source. + + Options: + + - move to `example/android/...` + - move to a separate package + - keep only under `integration/` and exclude from published package + +3. Decide whether BLE peer sync belongs in this package. + + Recommended long-term split: + + - `flutter_battery`: battery only + - `flutter_battery_peer_sync`: BLE peer sync + +4. Update README and support matrix. + + Make clear which features are core and which are demos. + +5. Update `.pubignore`. + + Exclude non-public implementation experiments. + +### Acceptance Criteria + +Required result: + +- Published archive does not include unrelated IoT implementation unless explicitly documented. +- README has a clear "Core API" and "Example-only features" split. +- Public package surface matches package name. +- `flutter pub publish --dry-run` archive listing is clean. + +## Milestone P4: Documentation and API Polish + +Goal: improve pub.dev documentation score and developer trust. + +Priority: medium + +### Scope + +Files likely affected: + +- `README.md` +- `example/README.md` +- `lib/flutter_battery.dart` +- `lib/src/platform_capabilities.dart` +- `lib/src/battery_channel_contract.dart` +- `CHANGELOG.md` + +### Steps + +1. Add English dartdoc to public API. + + Required public types: + + - `FlutterBattery` + - `BatteryInfo` + - `BatteryHealth` + - `BatteryFeature` + - `BatteryPlatformCapabilities` + - `BatteryMonitorConfig` + - `BatteryLevelMonitorConfig` + +2. Add README quick start. + + Include: + + ```dart + final battery = FlutterBattery(); + final level = await battery.getBatteryLevel(); + final info = await battery.getBatteryInfo(); + final capabilities = await battery.getPlatformCapabilities(); + ``` + +3. Add platform notes. + + Document: + + - macOS temperature/voltage are best-effort from `AppleSmartBattery`. + - macOS health is best-effort and may return `UNKNOWN`. + - native notifications are Android-only. + - BLE peer sync is Android-only. + +4. Add troubleshooting. + + Include: + + - macOS `Failed to foreground app; open returned 1` is a Flutter tool foregrounding issue. + - no-battery desktop Macs return unavailable/zero fallback depending on API. + +5. Keep CHANGELOG aligned with actual release. + +### Acceptance Criteria + +Required result: + +- README has install, usage, support matrix, feature capability, troubleshooting sections. +- Public API has useful dartdoc. +- CHANGELOG has accurate version entry. +- `flutter pub publish --dry-run` has no warnings. + +## Milestone P5: CI and Release Automation + +Goal: make project quality reproducible. + +Priority: medium + +### Scope + +Files likely affected: + +- `.github/workflows/ci.yaml` +- `.github/workflows/publish_dry_run.yaml` +- `README.md` + +### Steps + +1. Add CI workflow. + + Required jobs: + + ```sh + flutter pub get + flutter analyze + dart format --set-exit-if-changed lib test example + flutter test + cd example && flutter test + ``` + +2. Add macOS build job. + + On macOS runner: + + ```sh + cd example + flutter build macos --debug + ``` + +3. Add Android build smoke test if feasible. + + Example: + + ```sh + cd example + flutter build apk --debug + ``` + +4. Add publish dry-run job. + + ```sh + flutter pub publish --dry-run + ``` + +5. Add status badge to README. + +### Acceptance Criteria + +Required result: + +- CI passes on every pull request. +- macOS build is verified in CI. +- publish dry-run is verified before release. +- formatting is enforced. + +## Milestone P6: Swift Package Manager Support + +Goal: align with modern Flutter Apple-platform plugin expectations. + +Priority: medium + +### Scope + +Files likely affected: + +- `macos/flutter_battery/Package.swift` +- `macos/flutter_battery.podspec` +- `pubspec.yaml` +- `README.md` + +### Steps + +1. Add SwiftPM package definition for macOS plugin. + +2. Verify source layout works for both CocoaPods and SwiftPM. + +3. Document Apple platform requirements. + +4. Re-run macOS build. + +### Acceptance Criteria + +Required result: + +- SwiftPM package exists for macOS plugin source. +- CocoaPods build still works. +- `cd example && flutter build macos --debug` passes. +- README documents macOS minimum version. + +## Milestone P7: Federated Plugin Evaluation + +Goal: decide whether to split into federated packages. + +Priority: long-term + +### Recommended Future Structure + +```text +flutter_battery/ +flutter_battery_platform_interface/ +flutter_battery_android/ +flutter_battery_macos/ +``` + +### Steps + +1. Keep current single-package plugin until public API stabilizes. + +2. Extract platform interface only after: + + - capabilities API is stable + - channel contract is stable + - Android/macOS behavior is tested + +3. Extract platform implementations only if: + + - more platforms are added + - platform code grows independently + - separate release cadence is needed + +### Acceptance Criteria + +Required result: + +- Decision documented in README or architecture notes. +- If federated split is done, app-facing package depends on platform packages through endorsed implementations. +- Existing users keep source-compatible imports where possible. + +## Final Release Checklist + +Before publishing a production-quality release: + +```sh +dart format --set-exit-if-changed lib test example +flutter analyze +flutter test +cd example && flutter test +cd example && flutter build macos --debug +flutter pub publish --dry-run +``` + +Manual checks: + +- Android device or emulator: + - battery level + - battery info + - battery health + - notification capability + - BLE peer sync only if still in scope + +- macOS MacBook: + - battery level + - temperature + - voltage + - health status does not falsely report severe degradation when capacity data is unavailable + - unsupported Android-only features are disabled + +Documentation checks: + +- `LICENSE` is valid. +- `pubspec.yaml` metadata is real. +- `README.md` has support matrix. +- `CHANGELOG.md` matches release. +- `.pubignore` excludes internal files. + +Target outcome: + +- pub.dev dry-run clean. +- CI clean. +- package scope understandable from name and README. +- platform differences explicit and testable. diff --git a/plan/SESSION_CHECKPOINT.md b/plan/SESSION_CHECKPOINT.md new file mode 100644 index 0000000..d2bf148 --- /dev/null +++ b/plan/SESSION_CHECKPOINT.md @@ -0,0 +1,47 @@ +# Session Checkpoint + +## ✅ 已完成 + +### 架构重构(R001–R008) +- `lib/src/battery_channel_contract.dart` — 通道常量集中化 +- `lib/src/platform_capabilities.dart` — BatteryFeature / BatteryPlatformCapabilities / UnsupportedBatteryFeatureException +- macOS 事件规范化 + callback bridge +- 示例 IoT 隔离 + 通道契约文档完整 +- 移除 iOS 声明 + +### P0 发布就绪 +- LICENSE → MIT +- pubspec.yaml 元数据(homepage/repository/issue_tracker/topics) +- macOS podspec 版本对齐 0.0.3 +- `.pubignore` 排除规划/代理文件 +- CHANGELOG 更新 Unreleased 章节 +- README 平台支持矩阵 + +### 全部验证通过 +- `flutter analyze` — No issues +- `flutter test` — 24/24 +- `cd example && flutter test` — 1/1 +- `cd example && flutter build macos --debug` — ✅ +- `flutter pub publish --dry-run` — 0 warnings + +## 📋 下一个任务 + +### P1: 平台能力模型(高优先级) +- [ ] 能力 API 定型 + dartdoc +- [ ] 示例页面全部由能力对象控制 +- [ ] README 文档 + +### P2: 通道契约稳定化 +- [ ] 契约驱动测试 +- [ ] Android/macOS 事件类型对齐 + +### P3: 平台范围清理 +- [ ] IoT 从插件源码移出 +- [ ] `.pubignore` 更新 + +### P4-P7: 文档 / CI / SPM / 联邦化 + +## 🚀 快速起步 +```sh +flutter analyze && flutter test && cd example && flutter test +``` diff --git a/pubspec.yaml b/pubspec.yaml index 3f2765a..7fb9c7f 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,14 @@ name: flutter_battery description: Flutter plugin for monitoring battery level and sending notifications. version: 0.0.3 -homepage: https://github.com/yourname/flutter_battery +homepage: https://github.com/lizy-coding/flutter_battery +repository: https://github.com/lizy-coding/flutter_battery +issue_tracker: https://github.com/lizy-coding/flutter_battery/issues +topics: + - battery + - plugin + - android + - macos environment: sdk: '>=3.0.0 <4.0.0'