From 20e29f3043e2332ab876be312084283917fa901c Mon Sep 17 00:00:00 2001 From: lxh2875931338 Date: Sun, 5 Jul 2026 23:23:30 +0800 Subject: [PATCH] =?UTF-8?q?=E5=B0=9D=E8=AF=95=E9=92=88=E5=AF=B9=E9=AD=85?= =?UTF-8?q?=E6=97=8F20=E7=B3=BB=E8=AE=BE=E8=AE=A1=E6=96=B0=E7=9A=84?= =?UTF-8?q?=E8=AE=A1=E7=AE=97=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- android/app/build.gradle.kts.txt | 73 + android/app/src/main/AndroidManifest.xml.txt | 50 + lib/logic/capacity_extractor.dart | 41 + lib/logic/charge_parser.dart | 139 ++ lib/pages/battery_health_page.dart | 1379 +++++------------- lib/services/battery_data_service.dart | 80 + lib/services/database_service.dart | 130 ++ lib/utils/time_utils.dart | 48 + lib/widgets/battery_info_panel.dart | 135 ++ lib/widgets/battery_summary.dart | 101 ++ lib/widgets/charge_detail_page.dart | 6 +- lib/widgets/charge_entry.dart | 55 + lib/widgets/trend_chart.dart | 222 +++ 13 files changed, 1423 insertions(+), 1036 deletions(-) create mode 100644 android/app/build.gradle.kts.txt create mode 100644 android/app/src/main/AndroidManifest.xml.txt create mode 100644 lib/logic/capacity_extractor.dart create mode 100644 lib/logic/charge_parser.dart create mode 100644 lib/services/battery_data_service.dart create mode 100644 lib/services/database_service.dart create mode 100644 lib/utils/time_utils.dart create mode 100644 lib/widgets/battery_info_panel.dart create mode 100644 lib/widgets/battery_summary.dart create mode 100644 lib/widgets/charge_entry.dart create mode 100644 lib/widgets/trend_chart.dart diff --git a/android/app/build.gradle.kts.txt b/android/app/build.gradle.kts.txt new file mode 100644 index 0000000..cdca13c --- /dev/null +++ b/android/app/build.gradle.kts.txt @@ -0,0 +1,73 @@ +plugins { + id("com.android.application") + id("kotlin-android") + id("dev.flutter.flutter-gradle-plugin") +} + +android { + namespace = "com.lxh.battery_health" + compileSdk = 34 + ndkVersion = flutter.ndkVersion + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + kotlinOptions { + jvmTarget = "17" + } + + defaultConfig { + applicationId = "com.lxh.battery_health" + minSdk = flutter.minSdkVersion + targetSdk = 34 + versionCode = flutter.versionCode + versionName = flutter.versionName + multiDexEnabled = true + } + + signingConfigs { + // 如果没有正式签名,可以用 debug 签名代替(测试用) + // 正式发布时请替换为自己的密钥 + getByName("debug") { + // debug 签名已经存在,无需额外配置 + } + create("release") { + // 如果有正式签名,在这里配置 + // keyAlias = "..." + // keyPassword = "..." + // storeFile = file("...") + // storePassword = "..." + } + } + + buildTypes { + debug { + // 🟢 关键:debug 版本包名加 .dev 后缀 + applicationIdSuffix = ".dev" + signingConfig = signingConfigs.getByName("debug") + isDebuggable = true + isMinifyEnabled = false + isShrinkResources = false + } + release { + // release 版本不加后缀,保持原包名 + signingConfig = signingConfigs.getByName("debug") // 若没有正式签名,先用 debug 签名 + isMinifyEnabled = true + isShrinkResources = true + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + } + } +} + +flutter { + source = "../.." +} + +dependencies { + // 无需额外添加 Activity KTX,因为已改用 startActivityForResult +} diff --git a/android/app/src/main/AndroidManifest.xml.txt b/android/app/src/main/AndroidManifest.xml.txt new file mode 100644 index 0000000..c76ece7 --- /dev/null +++ b/android/app/src/main/AndroidManifest.xml.txt @@ -0,0 +1,50 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/lib/logic/capacity_extractor.dart b/lib/logic/capacity_extractor.dart new file mode 100644 index 0000000..c53dcfe --- /dev/null +++ b/lib/logic/capacity_extractor.dart @@ -0,0 +1,41 @@ +/// 容量提取工具 +/// 支持两种日志格式: +/// 1. learned_cap(21 系列):单位 µAh,除以 1000 得到 mAh +/// 2. remaining_cap(20 系列):单位 mAh,直接使用(但 21 系列的 remaining_cap 也是 µAh,需判断) +class CapacityExtractor { + static int? extractCapacity(List lines) { + final validLines = lines.where((line) => line.contains('tbat=')).toList(); + if (validLines.isEmpty) return null; + + // 模式一:learned_cap(21 系列) + final learnedReg = RegExp(r'learned_cap=(\d+)'); + for (String line in validLines.reversed) { + final match = learnedReg.firstMatch(line); + if (match != null) { + return int.parse(match.group(1)!) ~/ 1000; + } + } + + // 模式二:remaining_cap 最大值 + final remainingReg = RegExp(r'remaining_cap=(\d+)'); + int? maxRemaining; + for (String line in validLines) { + final match = remainingReg.firstMatch(line); + if (match != null) { + final value = int.parse(match.group(1)!); + if (maxRemaining == null || value > maxRemaining) { + maxRemaining = value; + } + } + } + if (maxRemaining == null) return null; + + // 🟢 判断单位:如果数值大于 100000(约 100 mAh),则认为是 µAh,需除以 1000 + // 否则认为是 mAh,直接使用 + if (maxRemaining > 100000) { + return maxRemaining ~/ 1000; + } else { + return maxRemaining; + } + } +} diff --git a/lib/logic/charge_parser.dart b/lib/logic/charge_parser.dart new file mode 100644 index 0000000..7e5c2ad --- /dev/null +++ b/lib/logic/charge_parser.dart @@ -0,0 +1,139 @@ +import 'dart:developer' as dev; +import '../utils/time_utils.dart'; + +/// 充电日志解析器 +class ChargeParser { + /// 解析充电数据,返回解析结果 + static ParseResult parse(List lines, DateTime? chargingStartTime) { + dev.log('[ChargeParser] 开始解析,共 ${lines.length} 行', name: 'BatteryHealth'); + + final validLines = _collectValidLines(lines, chargingStartTime); + if (validLines.isEmpty) { + dev.log('[ChargeParser] 无有效时间窗口内的数据行', name: 'BatteryHealth'); + return ParseResult.empty(); + } + + // 按时间戳从新到旧排序 + validLines.sort((a, b) => b['timestamp'].compareTo(a['timestamp'])); + final latest = validLines.first; + final latestLine = latest['line'] as String; + final latestType = latest['type'] as String; + + dev.log( + '[ChargeParser] 最新有效数据: $latestType, 时间: ${latest['timestamp']}', + name: 'BatteryHealth', + ); + + if (latestType == 'wls') { + return _parseWireless(latestLine); + } else { + return _parseWired(latestLine); + } + } + + static List> _collectValidLines( + List lines, + DateTime? chargingStartTime, + ) { + final result = >[]; + for (String line in lines) { + final timestamp = TimeUtils.parseLogTimestamp(line); + if (!TimeUtils.isLogTimestampValid(timestamp, chargingStartTime)) + continue; + + final isUsb = + line.contains('chg_usb') && + (line.contains('ibus=') || line.contains('real_type=')); + final isWls = line.contains('chg_wls') && line.contains('wls_online=1'); + + if (isUsb || isWls) { + result.add({ + 'line': line, + 'timestamp': timestamp, + 'type': isWls ? 'wls' : 'usb', + }); + } + } + return result; + } + + static ParseResult _parseWireless(String line) { + final typeReg = RegExp(r'wls_type=(\w+)'); + final voltReg = RegExp(r'wls_volt=(\d+)'); + final currReg = RegExp(r'wls_curr=(\d+)'); + + final wlsType = typeReg.firstMatch(line)?.group(1); + final volt = int.parse(voltReg.firstMatch(line)?.group(1) ?? '0'); + final curr = int.parse(currReg.firstMatch(line)?.group(1) ?? '0'); + + return ParseResult( + isWireless: true, + chargeType: wlsType ?? '无线', + power: (volt * curr) / 1000000.0, + volt: volt / 1000.0, + curr: curr / 1000.0, + wiredLevel: null, + ); + } + + static ParseResult _parseWired(String line) { + final ibusReg = RegExp(r'ibus=(\d+)'); + final vbusReg = RegExp(r'vbus=(\d+)'); + final levelReg = RegExp(r'wired_level=(\d+)'); + final typeReg = RegExp(r'real_type=(\w+)'); + + final ibus = int.parse(ibusReg.firstMatch(line)?.group(1) ?? '0'); + final vbus = int.parse(vbusReg.firstMatch(line)?.group(1) ?? '0'); + final realType = typeReg.firstMatch(line)?.group(1); + final level = int.parse(levelReg.firstMatch(line)?.group(1) ?? '0'); + + String chargeType; + if (realType == 'PPS') { + chargeType = 'PPS'; + } else if (realType == 'SDP' || realType == 'FLOAT') { + chargeType = 'SDP'; + } else if (level >= 3) { + chargeType = 'PPS'; + } else { + chargeType = 'SDP'; + } + + return ParseResult( + isWireless: false, + chargeType: chargeType, + power: (vbus * ibus) / 1000000000.0, + volt: vbus / 1000000.0, + curr: ibus / 1000000.0, + wiredLevel: chargeType == 'PPS' ? level : null, + ); + } +} + +class ParseResult { + final bool isWireless; + final String chargeType; + final double power; + final double volt; + final double curr; + final int? wiredLevel; + + ParseResult({ + required this.isWireless, + required this.chargeType, + required this.power, + required this.volt, + required this.curr, + this.wiredLevel, + }); + + factory ParseResult.empty() { + return ParseResult( + isWireless: false, + chargeType: '', + power: 0.0, + volt: 0.0, + curr: 0.0, + wiredLevel: null, + ); + } +} diff --git a/lib/pages/battery_health_page.dart b/lib/pages/battery_health_page.dart index 8b9b37b..db5e004 100644 --- a/lib/pages/battery_health_page.dart +++ b/lib/pages/battery_health_page.dart @@ -1,20 +1,26 @@ import 'dart:async'; -import 'dart:io'; import 'dart:developer' as dev; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_svg/flutter_svg.dart'; -import 'package:sqflite/sqflite.dart'; -import 'package:path_provider/path_provider.dart'; -import 'package:path/path.dart' as path; import 'package:device_info_plus/device_info_plus.dart'; -//import 'package:shared_preferences/shared_preferences.dart'; +import 'package:shared_preferences/shared_preferences.dart'; -import '../models/device_matching.dart'; -import '../utils/charge_utils.dart'; -import '../widgets/charge_detail_page.dart'; -import '../services/saf_storage_service.dart'; -import '../models/saf_permission_state.dart'; +// 检查这些导入是否正确 +import '../models/device_matching.dart'; // ✅ 存在 +import '../models/saf_permission_state.dart'; // ✅ 存在 +import '../services/database_service.dart'; +import '../services/battery_data_service.dart'; +import '../services/saf_storage_service.dart'; // ✅ 存在 +import '../logic/charge_parser.dart'; // ✅ 存在 +import '../logic/capacity_extractor.dart'; // ✅ 存在(注意:不是 utils/) +import '../utils/time_utils.dart'; // ✅ 存在 +import '../utils/charge_utils.dart'; // ✅ 存在 +import '../widgets/trend_chart.dart'; // ⚠️ 需要确认文件内容完整 +import '../widgets/battery_summary.dart'; // ⚠️ 需要确认文件内容完整 +import '../widgets/battery_info_panel.dart'; // ⚠️ 需要确认文件内容完整 +import '../widgets/charge_entry.dart'; // ⚠️ 需要确认文件内容完整 +import '../widgets/charge_detail_page.dart'; // ✅ 存在 // --- 潘通色定义 --- const Color kHealthGreen = Color(0xFF00B140); @@ -58,6 +64,8 @@ class _BatteryHealthPageState extends State // ---------- SAF 状态 ---------- final SafStorageService _safService = SafStorageService(); + final DatabaseService _dbService = DatabaseService(); + final BatteryDataService _dataService = BatteryDataService(); SafPermissionState _safState = SafPermissionState.unauthorized; // 动画控制 @@ -75,75 +83,91 @@ class _BatteryHealthPageState extends State // 轮询定时器(15秒) Timer? _pollTimer; - // ---------- 数据库 ---------- - Database? _db; + // ---------- 生命周期 ---------- + @override + void initState() { + super.initState(); + dev.log('🔵 ========== initState 开始 ==========', name: 'BatteryHealth'); - Future get database async { - if (_db != null) return _db!; - _db = await _initDatabase(); - return _db!; + _initAnimations(); + + _getInitialChargeState(); + + _chargeEventSubscription = _eventChannel.receiveBroadcastStream().listen(( + event, + ) { + final bool isCharging = event['isCharging'] ?? false; + final String systemType = event['chargeType'] ?? 'NONE'; + _applySystemChargeState(isCharging, systemType); + }); + + _pollTimer = Timer.periodic(const Duration(seconds: 15), (timer) { + if (_isCharging) { + _fetchChargeDataFromLog(); + } + }); + + WidgetsBinding.instance.addPostFrameCallback((_) async { + dev.log('🔵 initState -> addPostFrameCallback 开始', name: 'BatteryHealth'); + + await _safService.init(); + dev.log( + '🔵 _safService.init() 完成,cachedUri: ${_safService.cachedUri}', + name: 'BatteryHealth', + ); + + _safState = await _safService.getPermissionState(); + dev.log('🔵 _safState: $_safState', name: 'BatteryHealth'); + + if (_safState == SafPermissionState.unauthorized || + _safState == SafPermissionState.expired) { + dev.log('🔵 需要授权,弹窗', name: 'BatteryHealth'); + if (mounted) _showSafAuthorizationDialog(); + } else { + dev.log('🔵 已有授权,调用 readBatteryCapacity', name: 'BatteryHealth'); + readBatteryCapacity(); + } + }); + + dev.log('🔵 ========== initState 结束 ==========', name: 'BatteryHealth'); } - Future _initDatabase() async { - dev.log('🔵 _initDatabase 开始', name: 'BatteryHealth'); - Directory appDocDir = await getApplicationDocumentsDirectory(); - String dbPath = path.join(appDocDir.path, 'battery_health.db'); - dev.log('🔵 数据库路径: $dbPath', name: 'BatteryHealth'); - return await openDatabase( - dbPath, - version: 2, - onCreate: (db, version) async { - await db.execute( - 'CREATE TABLE battery_history (' - 'date TEXT PRIMARY KEY, ' - 'capacity INTEGER, ' - 'volt_max REAL, ' - 'volt_min REAL' - ')', - ); - }, - onUpgrade: (db, oldVersion, newVersion) async { - if (oldVersion < 2) { - try { - await db.execute( - 'ALTER TABLE battery_history ADD COLUMN volt_max REAL', - ); - await db.execute( - 'ALTER TABLE battery_history ADD COLUMN volt_min REAL', - ); - } catch (_) {} - } - }, + @override + void dispose() { + _chargeEventSubscription?.cancel(); + _pollTimer?.cancel(); + _chargeAnimationController.dispose(); + _dbService.database.then((db) => db.close()).ignore(); + super.dispose(); + } + + // ---------- 动画初始化 ---------- + void _initAnimations() { + _chargeAnimationController = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 500), + ); + _bottomSlide = Tween(begin: 0, end: 24).animate( + CurvedAnimation( + parent: _chargeAnimationController, + curve: Curves.easeInOutCubic, + ), + ); + _entryFadeIn = Tween(begin: 0, end: 1).animate( + CurvedAnimation( + parent: _chargeAnimationController, + curve: const Interval(0.0, 0.5, curve: Curves.easeIn), + ), ); - } - - Future _insertOrUpdate( - String date, - int capacity, - double voltMax, - double voltMin, - ) async { - Database db = await database; - await db.insert('battery_history', { - 'date': date, - 'capacity': capacity, - 'volt_max': voltMax, - 'volt_min': voltMin, - }, conflictAlgorithm: ConflictAlgorithm.replace); - } - - Future>> _queryRecentData(int days) async { - Database db = await database; - return await db.query('battery_history', orderBy: 'date DESC', limit: days); } // ---------- 设备信息 ---------- Future _getDeviceInfo() async { dev.log('🔵 _getDeviceInfo 开始', name: 'BatteryHealth'); - DeviceInfoPlugin deviceInfo = DeviceInfoPlugin(); + final deviceInfo = DeviceInfoPlugin(); try { - AndroidDeviceInfo androidInfo = await deviceInfo.androidInfo; - String model = androidInfo.model; + final androidInfo = await deviceInfo.androidInfo; + final model = androidInfo.model; dev.log('🔵 设备型号: $model', name: 'BatteryHealth'); setState(() { _deviceModel = DeviceMatching.getModelName(model); @@ -158,109 +182,6 @@ class _BatteryHealthPageState extends State } } - // ---------- 扫描目录 (SAF) ---------- - Future _scanAndUpdateDB() async { - dev.log('🔵 _scanAndUpdateDB 开始', name: 'BatteryHealth'); - _safState = await _safService.getPermissionState(); - if (_safState != SafPermissionState.authorized) { - dev.log('🔵 _scanAndUpdateDB: 未授权', name: 'BatteryHealth'); - return; - } - - try { - final files = await _safService.listFiles(); - dev.log('🔵 扫描文件列表: $files', name: 'BatteryHealth'); - RegExp filePattern = RegExp(r'mbattery_charger_log_(\d{4}_\d{2}_\d{2})$'); - - for (String fileName in files) { - Match? match = filePattern.firstMatch(fileName); - if (match != null) { - String dateStr = match.group(1)!.replaceAll('_', '-'); - dev.log('🔵 处理文件: $fileName, 日期: $dateStr', name: 'BatteryHealth'); - try { - List lines = await _safService.readLogFile(fileName); - if (lines.isEmpty) continue; - - List validLines = lines - .where((line) => line.contains('tbat=')) - .toList(); - if (validLines.isEmpty) continue; - - String lastLine = validLines.last; - RegExp capReg = RegExp(r'learned_cap=(\d+)'); - String? capStr = capReg.firstMatch(lastLine)?.group(1); - if (capStr == null) continue; - int cap = int.parse(capStr) ~/ 1000; - - RegExp voltReg = RegExp(r'vbat=(\d+)'); - double? maxVolt, minVolt; - for (String line in validLines) { - Match? match = voltReg.firstMatch(line); - if (match != null) { - double v = int.parse(match.group(1)!) / 1000000; - if (maxVolt == null || v > maxVolt) maxVolt = v; - if (minVolt == null || v < minVolt) minVolt = v; - } - } - if (maxVolt == null || minVolt == null) continue; - - await _insertOrUpdate(dateStr, cap, maxVolt, minVolt); - dev.log('🔵 插入数据库: $dateStr, cap=$cap', name: 'BatteryHealth'); - } catch (e) { - dev.log('🔵 处理文件 $fileName 异常: $e', name: 'BatteryHealth'); - } - } - } - } catch (e) { - dev.log('🔵 _scanAndUpdateDB 异常: $e', name: 'BatteryHealth'); - } - } - - // ---------- UTC 文件名 ---------- - String getTodayFileName() { - DateTime nowUtc = DateTime.now().toUtc(); - String year = nowUtc.year.toString(); - String month = nowUtc.month.toString().padLeft(2, '0'); - String day = nowUtc.day.toString().padLeft(2, '0'); - String fileName = 'mbattery_charger_log_${year}_${month}_$day'; - dev.log('🔵 getTodayFileName: $fileName', name: 'BatteryHealth'); - return fileName; - } - - // ---------- 时间戳工具方法 ---------- - DateTime? _parseLogTimestamp(String line) { - RegExp reg = RegExp(r'\[(\d{4}-\d{2}-\d{2}) (\d{2}:\d{2}:\d{2})\]'); - Match? match = reg.firstMatch(line); - if (match == null) return null; - try { - String dateStr = match.group(1)!; - String timeStr = match.group(2)!; - List dateParts = dateStr.split('-'); - List timeParts = timeStr.split(':'); - return DateTime.utc( - int.parse(dateParts[0]), - int.parse(dateParts[1]), - int.parse(dateParts[2]), - int.parse(timeParts[0]), - int.parse(timeParts[1]), - int.parse(timeParts[2]), - ); - } catch (_) { - return null; - } - } - - bool _isLogTimestampValid(DateTime? logTime) { - if (logTime == null) return false; - if (_chargingStartTime == null) { - DateTime nowUtc = DateTime.now().toUtc(); - Duration diff = nowUtc.difference(logTime); - return diff.abs().inMinutes <= 3; - } - DateTime endTime = _chargingStartTime!.add(const Duration(minutes: 3)); - return logTime.isAfter(_chargingStartTime!) && logTime.isBefore(endTime); - } - // ---------- SAF 授权引导 ---------- void _showSafAuthorizationDialog() { dev.log('🔵 _showSafAuthorizationDialog 被调用', name: 'BatteryHealth'); @@ -368,32 +289,24 @@ class _BatteryHealthPageState extends State ); } - if (systemChargeType == "WIRELESS") { - dev.log('[_applySystemChargeState] 无线充电模式', name: 'BatteryHealth'); - _isWireless = true; - _chargeType = "无线"; - _wiredLevel = null; - } else if (systemChargeType == "AC") { - dev.log( - '[_applySystemChargeState] 有线快充模式 (AC/PPS)', - name: 'BatteryHealth', - ); - _isWireless = false; - _chargeType = "PPS"; - } else if (systemChargeType == "USB") { - dev.log( - '[_applySystemChargeState] 有线慢充模式 (USB/SDP)', - name: 'BatteryHealth', - ); - _isWireless = false; - _chargeType = "SDP"; - } else { - dev.log( - '[_applySystemChargeState] ⚠️ 未知类型: $systemChargeType,清空类型等待日志', - name: 'BatteryHealth', - ); - _isWireless = false; - _chargeType = ''; + switch (systemChargeType) { + case "WIRELESS": + _isWireless = true; + _chargeType = "无线"; + _wiredLevel = null; + break; + case "AC": + _isWireless = false; + _chargeType = "PPS"; + break; + case "USB": + _isWireless = false; + _chargeType = "SDP"; + break; + default: + _isWireless = false; + _chargeType = ''; + break; } _chargePower = 0.0; @@ -407,227 +320,6 @@ class _BatteryHealthPageState extends State } } - // ---------- 日志解析 ---------- - void _parseChargeDataFromLog(List lines) { - dev.log( - '[_parseChargeDataFromLog] 开始解析,共 ${lines.length} 行', - name: 'BatteryHealth', - ); - - List> validLines = []; - for (String line in lines) { - DateTime? timestamp = _parseLogTimestamp(line); - if (!_isLogTimestampValid(timestamp)) continue; - - bool isUsb = - line.contains('chg_usb') && - (line.contains('ibus=') || line.contains('real_type=')); - bool isWls = line.contains('chg_wls') && line.contains('wls_online=1'); - - if (isUsb || isWls) { - validLines.add({ - 'line': line, - 'timestamp': timestamp, - 'type': isWls ? 'wls' : 'usb', - }); - } - } - - if (validLines.isEmpty) { - dev.log('[_parseChargeDataFromLog] 无有效时间窗口内的数据行', name: 'BatteryHealth'); - return; - } - - validLines.sort((a, b) => b['timestamp'].compareTo(a['timestamp'])); - - Map latest = validLines.first; - String latestLine = latest['line']; - String latestType = latest['type']; - DateTime latestTime = latest['timestamp']; - - dev.log( - '[_parseChargeDataFromLog] 最新有效数据: $latestType, 时间: $latestTime', - name: 'BatteryHealth', - ); - - // ---------- 无线 ---------- - if (latestType == 'wls') { - dev.log('[_parseChargeDataFromLog] 处理无线数据', name: 'BatteryHealth'); - - RegExp typeReg = RegExp(r'wls_type=(\w+)'); - RegExp voltReg = RegExp(r'wls_volt=(\d+)'); - RegExp currReg = RegExp(r'wls_curr=(\d+)'); - - String? wlsType = typeReg.firstMatch(latestLine)?.group(1); - int volt = int.parse(voltReg.firstMatch(latestLine)?.group(1) ?? '0'); - int curr = int.parse(currReg.firstMatch(latestLine)?.group(1) ?? '0'); - - setState(() { - _isWireless = true; - _chargeType = (wlsType != null && wlsType.isNotEmpty) ? wlsType : "无线"; - _chargePower = (volt * curr) / 1000000.0; - _chargeVolt = volt / 1000.0; - _chargeCurr = curr / 1000.0; - _wiredLevel = null; - dev.log( - '[_parseChargeDataFromLog] 无线覆盖成功: $_chargeType, ${_chargePower.toStringAsFixed(2)}W', - name: 'BatteryHealth', - ); - }); - return; - } - - // ---------- 有线 ---------- - if (latestType == 'usb') { - dev.log('[_parseChargeDataFromLog] 处理有线数据', name: 'BatteryHealth'); - - RegExp ibusReg = RegExp(r'ibus=(\d+)'); - RegExp vbusReg = RegExp(r'vbus=(\d+)'); - RegExp levelReg = RegExp(r'wired_level=(\d+)'); - RegExp typeReg = RegExp(r'real_type=(\w+)'); - - int ibus = int.parse(ibusReg.firstMatch(latestLine)?.group(1) ?? '0'); - int vbus = int.parse(vbusReg.firstMatch(latestLine)?.group(1) ?? '0'); - String? realType = typeReg.firstMatch(latestLine)?.group(1); - int level = int.parse(levelReg.firstMatch(latestLine)?.group(1) ?? '0'); - - setState(() { - _chargePower = (vbus * ibus) / 1000000000.0; - _chargeVolt = vbus / 1000000.0; - _chargeCurr = ibus / 1000000.0; - - _isWireless = false; - - if (realType != null && realType == "PPS") { - _chargeType = "PPS"; - } else if (realType != null && - (realType == "SDP" || realType == "FLOAT")) { - _chargeType = "SDP"; - } else if (level >= 3 && _chargeType != "PPS") { - _chargeType = "PPS"; - } else if (_chargeType.isEmpty) { - _chargeType = "SDP"; - } - - if (_chargeType == "PPS") { - _wiredLevel = level; - } else { - _wiredLevel = null; - } - - dev.log( - '[_parseChargeDataFromLog] 有线更新: $_chargeType, ${_chargePower.toStringAsFixed(2)}W, level: $level', - name: 'BatteryHealth', - ); - }); - return; - } - } - - // ---------- SAF 文件读取 ---------- - Future _fetchChargeDataFromLog() async { - if (!_isCharging) { - dev.log('[_fetchChargeDataFromLog] 未充电,跳过读取', name: 'BatteryHealth'); - return; - } - - if (_chargingStartTime != null) { - Duration elapsed = DateTime.now().toUtc().difference(_chargingStartTime!); - if (elapsed.inMinutes > 5) { - dev.log( - '[_fetchChargeDataFromLog] 充电开始已超过 5 分钟,跳过本次轮询', - name: 'BatteryHealth', - ); - return; - } - } - - _safState = await _safService.getPermissionState(); - if (_safState != SafPermissionState.authorized) { - dev.log('[_fetchChargeDataFromLog] 未授权,跳过', name: 'BatteryHealth'); - return; - } - - final fileName = getTodayFileName(); - dev.log('[_fetchChargeDataFromLog] 尝试读取: $fileName', name: 'BatteryHealth'); - - final exists = await _safService.logFileExists(fileName); - if (!exists) { - dev.log( - '[_fetchChargeDataFromLog] ⚠️ 文件不存在: $fileName', - name: 'BatteryHealth', - ); - return; - } - - try { - final lines = await _safService.readLogFile(fileName); - if (lines.isEmpty) { - dev.log('[_fetchChargeDataFromLog] 文件为空', name: 'BatteryHealth'); - return; - } - dev.log( - '[_fetchChargeDataFromLog] ✅ 读取成功,共 ${lines.length} 行', - name: 'BatteryHealth', - ); - _parseChargeDataFromLog(lines); - } catch (e) { - dev.log('[_fetchChargeDataFromLog] ❌ 读取异常: $e', name: 'BatteryHealth'); - } - } - - // ---------- 显示辅助 ---------- - String _getDisplayPower() { - if (!_isCharging) return ''; - if (_isWireless) { - return ChargeUtils.getDisplayPowerWireless(_chargePower); - } else { - return ChargeUtils.getDisplayPowerWired( - _chargePower, - _wiredLevel, - _chargeType, - ); - } - } - - String _getChargeTypeLabel() { - return ChargeUtils.getChargeTypeLabel(_isWireless, _chargeType); - } - - // ---------- 动画控制 ---------- - void _startChargeAnimation() { - if (_chargeAnimationController.isAnimating) return; - _chargeAnimationController.forward(); - } - - void _stopChargeAnimation() { - if (_chargeAnimationController.isAnimating) return; - _chargeAnimationController.reverse(); - } - - void _updateChargeState() { - if (!mounted) return; - if (_isCharging) { - _startChargeAnimation(); - } else { - _stopChargeAnimation(); - } - } - - // ---------- 平台通道 ---------- - Future _getInitialChargeState() async { - try { - final Map result = await _methodChannel.invokeMethod( - 'getChargeState', - ); - bool isCharging = result['isCharging'] ?? false; - String systemType = result['chargeType'] ?? 'NONE'; - _applySystemChargeState(isCharging, systemType); - } catch (e) { - // 静默降级 - } - } - // ---------- 读取当前数据 ---------- Future readBatteryCapacity() async { dev.log( @@ -635,7 +327,6 @@ class _BatteryHealthPageState extends State name: 'BatteryHealth', ); - // 检查 SAF 授权 _safState = await _safService.getPermissionState(); dev.log('🔵 SAF状态: $_safState', name: 'BatteryHealth'); @@ -646,9 +337,9 @@ class _BatteryHealthPageState extends State } await _getDeviceInfo(); - await _scanAndUpdateDB(); + await _dataService.scanAndUpdateDB(); - final fileName = getTodayFileName(); + final fileName = TimeUtils.getTodayFileName(); dev.log('🔵 今日文件名: $fileName', name: 'BatteryHealth'); final exists = await _safService.logFileExists(fileName); @@ -656,7 +347,7 @@ class _BatteryHealthPageState extends State if (!exists) { dev.log('🔵 文件不存在,尝试从数据库读取', name: 'BatteryHealth'); - List> recent = await _queryRecentData(1); + final recent = await _dbService.queryRecentData(1); if (recent.isNotEmpty) { dev.log('🔵 数据库有数据: ${recent.first}', name: 'BatteryHealth'); setState(() { @@ -679,6 +370,7 @@ class _BatteryHealthPageState extends State try { final lines = await _safService.readLogFile(fileName); dev.log('🔵 读取到 ${lines.length} 行', name: 'BatteryHealth'); + if (lines.isEmpty) { dev.log('🔵 文件为空', name: 'BatteryHealth'); setState(() => _hasData = false); @@ -688,21 +380,21 @@ class _BatteryHealthPageState extends State return; } - // 打印前几行 - if (lines.isNotEmpty) { - dev.log('🔵 第一行: ${lines.first}', name: 'BatteryHealth'); - if (lines.length > 1) { - dev.log('🔵 第二行: ${lines[1]}', name: 'BatteryHealth'); - } + // 使用 CapacityExtractor 提取容量 + final cap = CapacityExtractor.extractCapacity(lines); + if (cap == null) { + dev.log( + '🔵 未找到容量数据(learned_cap 或 remaining_cap)', + name: 'BatteryHealth', + ); + setState(() => _hasData = false); + _chargeAnimationController.reset(); + _updateChargeState(); + return; } - if (_isCharging) _parseChargeDataFromLog(lines); - - List validLines = lines - .where((line) => line.contains('tbat=')) - .toList(); - dev.log('🔵 有效行数 (含tbat): ${validLines.length}', name: 'BatteryHealth'); - + // 提取最后一条有效数据用于显示 + final validLines = lines.where((line) => line.contains('tbat=')).toList(); if (validLines.isEmpty) { dev.log('🔵 无有效数据行', name: 'BatteryHealth'); setState(() => _hasData = false); @@ -711,80 +403,34 @@ class _BatteryHealthPageState extends State return; } - String lastLine = validLines.last; - dev.log('🔵 最后一行: $lastLine', name: 'BatteryHealth'); - - RegExp capReg = RegExp(r'learned_cap=(\d+)'); - RegExp cycleReg = RegExp(r'cycle=(\d+)'); - RegExp tempReg = RegExp(r'tbat=(\d+)'); - RegExp voltageReg = RegExp(r'vbat=(\d+)'); - - String? capStr = capReg.firstMatch(lastLine)?.group(1); - String? cycleStr = cycleReg.firstMatch(lastLine)?.group(1); - String? tempStr = tempReg.firstMatch(lastLine)?.group(1); - String? voltStr = voltageReg.firstMatch(lastLine)?.group(1); + final lastLine = validLines.last; + final cycle = _extractCycle(lastLine); + final temp = _extractTemp(lastLine); + final volt = _extractVolt(lastLine); + final (maxTemp, minTemp) = _extractTempExtremes(validLines); + final (maxVolt, minVolt) = _extractVoltExtremes(validLines); dev.log( - '🔵 capStr=$capStr, cycleStr=$cycleStr, tempStr=$tempStr, voltStr=$voltStr', + '🔵 解析结果: cap=$cap, cycle=$cycle, temp=$temp, volt=$volt', name: 'BatteryHealth', ); - if (capStr == null) { - dev.log('🔵 未找到 learned_cap', name: 'BatteryHealth'); - setState(() => _hasData = false); - _chargeAnimationController.reset(); - _updateChargeState(); - return; - } - - double capMah = int.parse(capStr) / 1000; - int cycle = int.parse(cycleStr ?? '0'); - double temp = double.parse(tempStr ?? '0'); - double volt = double.parse(voltStr ?? '0') / 1000000; - - dev.log( - '🔵 解析结果: capMah=$capMah, cycle=$cycle, temp=$temp, volt=$volt', - name: 'BatteryHealth', - ); - - List temps = []; - for (String line in validLines) { - RegExpMatch? match = tempReg.firstMatch(line); - if (match != null) temps.add(double.parse(match.group(1)!)); - } - double maxTemp = temps.isNotEmpty - ? temps.reduce((a, b) => a > b ? a : b) - : temp; - double minTemp = temps.isNotEmpty - ? temps.reduce((a, b) => a < b ? a : b) - : temp; - - double? maxVolt, minVolt; - for (String line in validLines) { - Match? match = voltageReg.firstMatch(line); - if (match != null) { - double v = int.parse(match.group(1)!) / 1000000; - if (maxVolt == null || v > maxVolt) maxVolt = v; - if (minVolt == null || v < minVolt) minVolt = v; - } - } - setState(() { - _currentCap = capMah; + _currentCap = cap.toDouble(); _cycleCount = cycle; _batteryTemp = temp; _batteryVolt = volt; _maxTemp = maxTemp; _minTemp = minTemp; - _maxVolt = maxVolt ?? volt; - _minVolt = minVolt ?? volt; + _maxVolt = maxVolt; + _minVolt = minVolt; _hasData = true; }); - dev.log( - '🔵 数据更新完成: _currentCap=$_currentCap, _cycleCount=$_cycleCount', - name: 'BatteryHealth', - ); + if (_isCharging) { + final parseResult = ChargeParser.parse(lines, _chargingStartTime); + _applyParseResult(parseResult); + } await _updateTrendChart(); _chargeAnimationController.reset(); @@ -801,101 +447,191 @@ class _BatteryHealthPageState extends State } } + // ---------- 辅助提取方法 ---------- + int _extractCycle(String line) { + final reg = RegExp(r'cycle=(\d+)'); + final match = reg.firstMatch(line); + return match != null ? int.parse(match.group(1)!) : 0; + } + + double _extractTemp(String line) { + final reg = RegExp(r'tbat=(\d+)'); + final match = reg.firstMatch(line); + return match != null ? double.parse(match.group(1)!) : 0; + } + + double _extractVolt(String line) { + final reg = RegExp(r'vbat=(\d+)'); + final match = reg.firstMatch(line); + return match != null ? int.parse(match.group(1)!) / 1000000 : 0; + } + + (double, double) _extractTempExtremes(List lines) { + final reg = RegExp(r'tbat=(\d+)'); + double? maxTemp, minTemp; + for (String line in lines) { + final match = reg.firstMatch(line); + if (match != null) { + final v = double.parse(match.group(1)!); + if (maxTemp == null || v > maxTemp) maxTemp = v; + if (minTemp == null || v < minTemp) minTemp = v; + } + } + return (maxTemp ?? 0, minTemp ?? 0); + } + + (double, double) _extractVoltExtremes(List lines) { + final reg = RegExp(r'vbat=(\d+)'); + double? maxVolt, minVolt; + for (String line in lines) { + final match = reg.firstMatch(line); + if (match != null) { + final v = int.parse(match.group(1)!) / 1000000; + if (maxVolt == null || v > maxVolt) maxVolt = v; + if (minVolt == null || v < minVolt) minVolt = v; + } + } + return (maxVolt ?? 0, minVolt ?? 0); + } + + void _applyParseResult(ParseResult result) { + setState(() { + _isWireless = result.isWireless; + _chargeType = result.chargeType; + _chargePower = result.power; + _chargeVolt = result.volt; + _chargeCurr = result.curr; + _wiredLevel = result.wiredLevel; + }); + } + + // ---------- 轮询读取日志 ---------- + Future _fetchChargeDataFromLog() async { + if (!_isCharging) { + dev.log('[_fetchChargeDataFromLog] 未充电,跳过读取', name: 'BatteryHealth'); + return; + } + + if (_chargingStartTime != null) { + final elapsed = DateTime.now().toUtc().difference(_chargingStartTime!); + if (elapsed.inMinutes > 5) { + dev.log( + '[_fetchChargeDataFromLog] 充电开始已超过 5 分钟,跳过本次轮询', + name: 'BatteryHealth', + ); + return; + } + } + + _safState = await _safService.getPermissionState(); + if (_safState != SafPermissionState.authorized) { + dev.log('[_fetchChargeDataFromLog] 未授权,跳过', name: 'BatteryHealth'); + return; + } + + final fileName = TimeUtils.getTodayFileName(); + final exists = await _safService.logFileExists(fileName); + if (!exists) { + dev.log( + '[_fetchChargeDataFromLog] ⚠️ 文件不存在: $fileName', + name: 'BatteryHealth', + ); + return; + } + + try { + final lines = await _safService.readLogFile(fileName); + if (lines.isEmpty) { + dev.log('[_fetchChargeDataFromLog] 文件为空', name: 'BatteryHealth'); + return; + } + dev.log( + '[_fetchChargeDataFromLog] ✅ 读取成功,共 ${lines.length} 行', + name: 'BatteryHealth', + ); + + final result = ChargeParser.parse(lines, _chargingStartTime); + _applyParseResult(result); + } catch (e) { + dev.log('[_fetchChargeDataFromLog] ❌ 读取异常: $e', name: 'BatteryHealth'); + } + } + + // ---------- 趋势图 ---------- Future _updateTrendChart() async { - List> data = await _queryRecentData(15); + final data = await _dbService.queryRecentData(15); setState(() { _trendData = data.reversed.toList(); }); } - // ---------- 生命周期 ---------- - @override - void initState() { - super.initState(); - dev.log('🔵 ========== initState 开始 ==========', name: 'BatteryHealth'); - - _chargeAnimationController = AnimationController( - vsync: this, - duration: const Duration(milliseconds: 500), - ); - _bottomSlide = Tween(begin: 0, end: 24).animate( - CurvedAnimation( - parent: _chargeAnimationController, - curve: Curves.easeInOutCubic, - ), - ); - _entryFadeIn = Tween(begin: 0, end: 1).animate( - CurvedAnimation( - parent: _chargeAnimationController, - curve: const Interval(0.0, 0.5, curve: Curves.easeIn), - ), - ); - - _getInitialChargeState(); - - _chargeEventSubscription = _eventChannel.receiveBroadcastStream().listen(( - event, - ) { - final bool isCharging = event['isCharging'] ?? false; - final String systemType = event['chargeType'] ?? 'NONE'; + // ---------- 平台通道 ---------- + Future _getInitialChargeState() async { + try { + final result = await _methodChannel.invokeMethod('getChargeState'); + final isCharging = result['isCharging'] ?? false; + final systemType = result['chargeType'] ?? 'NONE'; _applySystemChargeState(isCharging, systemType); - }); - - _pollTimer = Timer.periodic(const Duration(seconds: 15), (timer) { - if (_isCharging) { - _fetchChargeDataFromLog(); - } - }); - - WidgetsBinding.instance.addPostFrameCallback((_) async { - dev.log('🔵 initState -> addPostFrameCallback 开始', name: 'BatteryHealth'); - - await _safService.init(); - dev.log( - '🔵 _safService.init() 完成,cachedUri: ${_safService.cachedUri}', - name: 'BatteryHealth', - ); - - _safState = await _safService.getPermissionState(); - dev.log('🔵 _safState: $_safState', name: 'BatteryHealth'); - - if (_safState == SafPermissionState.unauthorized || - _safState == SafPermissionState.expired) { - dev.log('🔵 需要授权,弹窗', name: 'BatteryHealth'); - if (mounted) _showSafAuthorizationDialog(); - } else { - dev.log('🔵 已有授权,调用 readBatteryCapacity', name: 'BatteryHealth'); - readBatteryCapacity(); - } - }); - - dev.log('🔵 ========== initState 结束 ==========', name: 'BatteryHealth'); + } catch (e) { + // 静默降级 + } } - @override - void dispose() { - _chargeEventSubscription?.cancel(); - _pollTimer?.cancel(); - _chargeAnimationController.dispose(); - _db?.close(); - super.dispose(); + // ---------- 动画控制 ---------- + void _startChargeAnimation() { + if (_chargeAnimationController.isAnimating) return; + _chargeAnimationController.forward(); + } + + void _stopChargeAnimation() { + if (_chargeAnimationController.isAnimating) return; + _chargeAnimationController.reverse(); + } + + void _updateChargeState() { + if (!mounted) return; + if (_isCharging) { + _startChargeAnimation(); + } else { + _stopChargeAnimation(); + } + } + + // ---------- 显示辅助 ---------- + String _getDisplayPower() { + if (!_isCharging) return ''; + if (_isWireless) { + return ChargeUtils.getDisplayPowerWireless(_chargePower); + } else { + return ChargeUtils.getDisplayPowerWired( + _chargePower, + _wiredLevel, + _chargeType, + ); + } + } + + String _getChargeTypeLabel() { + return ChargeUtils.getChargeTypeLabel(_isWireless, _chargeType); } // ---------- Build ---------- @override Widget build(BuildContext context) { return AnnotatedRegion( - value: SystemUiOverlayStyle( + value: const SystemUiOverlayStyle( statusBarColor: Colors.white, statusBarIconBrightness: Brightness.dark, statusBarBrightness: Brightness.dark, ), child: Scaffold( - backgroundColor: Colors.white, // 🟢 页面背景色与 AppBar 统一 + backgroundColor: Colors.white, appBar: AppBar( backgroundColor: Colors.white, - elevation: 0, // 🟢 去掉阴影 - scrolledUnderElevation: 0, // 🟢 滚动时也不出现阴影 + elevation: 0, + scrolledUnderElevation: 0, + shadowColor: Colors.transparent, + surfaceTintColor: Colors.transparent, title: const Text( '电池健康度', style: TextStyle( @@ -933,15 +669,13 @@ class _BatteryHealthPageState extends State } Widget _buildDataLayout() { - double healthPercent = (_designCap > 0) + final healthPercent = (_designCap > 0) ? (_currentCap / _designCap) * 100 : 0; String chargeSummary = ''; if (_isCharging) { - String typeLabel = _getChargeTypeLabel(); - String powerLabel = _getDisplayPower(); - chargeSummary = '充电中 · $typeLabel · $powerLabel'; + chargeSummary = '充电中 · ${_getChargeTypeLabel()} · ${_getDisplayPower()}'; } return Padding( @@ -959,99 +693,46 @@ class _BatteryHealthPageState extends State ), const SizedBox(height: 4), - Row( - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - Row( - crossAxisAlignment: CrossAxisAlignment.baseline, - textBaseline: TextBaseline.alphabetic, - children: [ - Text( - healthPercent.toStringAsFixed(1), - style: TextStyle( - fontSize: 40, - fontWeight: FontWeight.w500, - height: 1.0, - color: healthPercent < 70 ? Colors.red : Colors.black, - ), - ), - const SizedBox(width: 2), - Text( - '%', - style: TextStyle( - fontSize: 20, - fontWeight: FontWeight.w300, - height: 1.0, - color: healthPercent < 70 - ? Colors.red - : Colors.grey.shade600, - ), - ), - ], - ), - const Spacer(), - Column( - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - Text( - '$_cycleCount cycles', - style: const TextStyle( - fontSize: 16, - fontWeight: FontWeight.w500, - height: 1.2, - ), - ), - const SizedBox(height: 4), - Text.rich( - TextSpan( - children: [ - TextSpan( - text: _currentCap.toStringAsFixed(0), - style: const TextStyle( - fontSize: 20, - fontWeight: FontWeight.w300, - ), - ), - TextSpan( - text: ' / ', - style: TextStyle( - fontSize: 20, - fontWeight: FontWeight.w300, - color: Colors.grey.shade500, - ), - ), - TextSpan( - text: '${_designCap}mAh', - style: const TextStyle( - fontSize: 20, - fontWeight: FontWeight.w300, - ), - ), - ], - ), - style: const TextStyle( - fontSize: 20, - fontWeight: FontWeight.w300, - height: 1.0, - ), - ), - ], - ), - ], + BatterySummary( + healthPercent: healthPercent.toDouble(), // 显式转换 + cycleCount: _cycleCount, + currentCap: _currentCap, + designCap: _designCap, ), + const SizedBox(height: 24), - _buildTrendChart(), + TrendChart(data: _trendData, designCap: _designCap), + const SizedBox(height: 8), AnimatedBuilder( animation: _chargeAnimationController, builder: (context, child) { - double entryHeight = 28 * _entryFadeIn.value; - - Widget chargeEntry = Opacity( + final entryHeight = 28 * _entryFadeIn.value; + final chargeEntry = Opacity( opacity: _entryFadeIn.value, - child: _buildChargeEntry(chargeSummary), + child: ChargeEntry( + summary: chargeSummary, + isCharging: _isCharging, + onTap: () { + if (_isCharging) { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => ChargeDetailPage( + chargeType: _chargeType, + isWireless: _isWireless, + power: _chargePower, + volt: _chargeVolt, + curr: _chargeCurr, + wiredLevel: _wiredLevel, + ), + ), + ); + } + }, + ), ); return Column( @@ -1059,7 +740,14 @@ class _BatteryHealthPageState extends State SizedBox(height: entryHeight, child: chargeEntry), Transform.translate( offset: Offset(0, _bottomSlide.value), - child: _buildBottomInfo(), + child: BatteryInfoPanel( + batteryVolt: _batteryVolt, + batteryTemp: _batteryTemp, + maxVolt: _maxVolt, + minVolt: _minVolt, + maxTemp: _maxTemp, + minTemp: _minTemp, + ), ), ], ); @@ -1070,379 +758,4 @@ class _BatteryHealthPageState extends State ), ); } - - Widget _buildChargeEntry(String summary) { - return GestureDetector( - onTap: () { - if (_isCharging) { - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => ChargeDetailPage( - chargeType: _chargeType, - isWireless: _isWireless, - power: _chargePower, - volt: _chargeVolt, - curr: _chargeCurr, - wiredLevel: _wiredLevel, - ), - ), - ); - } - }, - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - summary, - style: const TextStyle( - fontSize: 16, - fontWeight: FontWeight.w500, - color: Colors.black87, - ), - ), - Row( - children: [ - Text( - '充电信息', - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.w500, - color: Colors.grey.shade700, - ), - ), - const SizedBox(width: 4), - Text( - '>', - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.w500, - color: Colors.grey.shade700, - ), - ), - ], - ), - ], - ), - ); - } - - Widget _buildTrendChart() { - List> displayData = _trendData.isNotEmpty - ? _trendData - : _getMockData(); - - List dates = displayData.map((e) { - String date = e['date'] as String; - var parts = date.split('-'); - return '${parts[1]}/${parts[2]}'; - }).toList(); - - List percents = displayData.map((e) { - int cap = e['capacity'] as int; - return (cap / _designCap) * 100; - }).toList(); - - while (percents.length < 15) { - percents.insert(0, 0); - dates.insert(0, ''); - } - if (percents.length > 15) { - percents = percents.sublist(percents.length - 15); - dates = dates.sublist(dates.length - 15); - } - - int todayIndex = percents.length - 1; - int sevenDaysAgoIndex = todayIndex - 7; - bool showSevenDaysAgo = sevenDaysAgoIndex >= 0; - - String sevenDaysAgoDate = showSevenDaysAgo ? dates[sevenDaysAgoIndex] : ''; - String todayDate = dates[todayIndex]; - - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text( - '容量百分比变动趋势(近15天)', - style: TextStyle(fontSize: 16, fontWeight: FontWeight.w500), - ), - const SizedBox(height: 8), - Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - SizedBox( - height: 180, - child: Column( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: const [ - Text( - '100', - style: TextStyle( - fontSize: 10, - color: Colors.grey, - height: 1.0, - ), - ), - Text( - '70', - style: TextStyle( - fontSize: 10, - color: Colors.grey, - height: 1.0, - ), - ), - ], - ), - ), - const SizedBox(width: 4), - Expanded( - child: LayoutBuilder( - builder: (context, constraints) { - double totalWidth = constraints.maxWidth; - double step = totalWidth / 15; - - return SizedBox( - height: 180, - child: Stack( - children: [ - Padding( - padding: const EdgeInsets.only(bottom: 20), - child: Row( - crossAxisAlignment: CrossAxisAlignment.end, - children: List.generate(percents.length, (index) { - double value = percents[index]; - Color barColor; - if (value > 0) { - if (value >= 80) { - barColor = kHealthGreen; - } else if (value >= 70) { - barColor = kHealthYellow; - } else { - barColor = Colors.red; - } - } else { - barColor = Colors.grey.shade300; - } - double clampedValue = value.clamp(70.0, 100.0); - double heightFactor = - (clampedValue - 70.0) / 30.0; - - return Expanded( - child: Align( - alignment: Alignment.bottomCenter, - child: Container( - margin: const EdgeInsets.symmetric( - horizontal: 2, - ), - height: 20 + (100 * heightFactor), - width: 18, - decoration: BoxDecoration( - color: barColor, - borderRadius: BorderRadius.circular(4), - ), - ), - ), - ); - }), - ), - ), - if (showSevenDaysAgo && sevenDaysAgoDate.isNotEmpty) - Positioned( - left: sevenDaysAgoIndex * step + (step / 2) - 14, - bottom: 2, - child: Text( - sevenDaysAgoDate, - style: const TextStyle( - fontSize: 9, - color: Colors.grey, - height: 1.0, - ), - ), - ), - if (todayDate.isNotEmpty) - Positioned( - left: todayIndex * step + (step / 2) - 14, - bottom: 2, - child: Text( - todayDate, - style: const TextStyle( - fontSize: 9, - color: Colors.black, - height: 1.0, - fontWeight: FontWeight.w600, - ), - ), - ), - ], - ), - ); - }, - ), - ), - ], - ), - if (percents.every((v) => v == 0)) - const Padding( - padding: EdgeInsets.only(top: 8.0), - child: Text( - '暂无历史数据,请点击刷新按钮更新', - style: TextStyle(fontSize: 12, color: Colors.grey), - ), - ), - ], - ); - } - - List> _getMockData() { - List mockDates = [ - '2026-06-14', - '2026-06-15', - '2026-06-16', - '2026-06-17', - '2026-06-18', - '2026-06-19', - '2026-06-20', - '2026-06-21', - '2026-06-22', - '2026-06-23', - '2026-06-24', - '2026-06-25', - '2026-06-26', - '2026-06-27', - '2026-06-28', - ]; - List mockCap = [ - 4635, - 4632, - 4628, - 4625, - 4620, - 4618, - 4615, - 4610, - 4608, - 4605, - 4600, - 4598, - 4595, - 4590, - 4585, - ]; - return List.generate( - 15, - (i) => {'date': mockDates[i], 'capacity': mockCap[i]}, - ); - } - - Widget _buildBottomInfo() { - return Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _buildInfoItem( - _buildIcon('voltage'), - '电压', - '${_batteryVolt.toStringAsFixed(3)}V', - ), - const SizedBox(height: 8), - Row( - children: [ - Text( - '↑ ${_maxVolt.toStringAsFixed(3)}V', - style: const TextStyle( - color: Colors.red, - fontSize: 14, - fontWeight: FontWeight.w500, - ), - ), - const SizedBox(width: 12), - Text( - '↓ ${_minVolt.toStringAsFixed(3)}V', - style: const TextStyle( - color: Colors.blue, - fontSize: 14, - fontWeight: FontWeight.w500, - ), - ), - ], - ), - ], - ), - ), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _buildInfoItem( - _buildIcon('temperature'), - '温度', - '${_batteryTemp.toStringAsFixed(0)}°C', - ), - const SizedBox(height: 8), - Row( - children: [ - Text( - '↑ ${_maxTemp.toStringAsFixed(0)}°C', - style: const TextStyle( - color: Colors.red, - fontSize: 14, - fontWeight: FontWeight.w500, - ), - ), - const SizedBox(width: 12), - Text( - '↓ ${_minTemp.toStringAsFixed(0)}°C', - style: const TextStyle( - color: Colors.blue, - fontSize: 14, - fontWeight: FontWeight.w500, - ), - ), - ], - ), - ], - ), - ), - ], - ); - } - - Widget _buildIcon(String type) { - switch (type) { - case 'voltage': - return SvgPicture.asset( - 'assets/icons/si--lightning-fill.svg', - width: 18, - height: 18, - colorFilter: const ColorFilter.mode(Colors.grey, BlendMode.srcIn), - ); - case 'temperature': - return SvgPicture.asset( - 'assets/icons/mdi--temperature.svg', - width: 18, - height: 18, - colorFilter: const ColorFilter.mode(Colors.grey, BlendMode.srcIn), - ); - default: - return const Icon(Icons.help_outline, size: 18, color: Colors.grey); - } - } - - Widget _buildInfoItem(Widget icon, String label, String value) { - return Row( - children: [ - icon, - const SizedBox(width: 6), - Text(label, style: const TextStyle(fontSize: 14, color: Colors.grey)), - const SizedBox(width: 6), - Text( - value, - style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w500), - ), - ], - ); - } } diff --git a/lib/services/battery_data_service.dart b/lib/services/battery_data_service.dart new file mode 100644 index 0000000..972b85e --- /dev/null +++ b/lib/services/battery_data_service.dart @@ -0,0 +1,80 @@ +import 'dart:developer' as dev; +import '../services/database_service.dart'; +import '../logic/capacity_extractor.dart'; +import '../models/saf_permission_state.dart'; +import '../services/saf_storage_service.dart'; + +/// 电池数据服务:扫描日志、更新数据库 +class BatteryDataService { + final SafStorageService _safService = SafStorageService(); + final DatabaseService _dbService = DatabaseService(); + + /// 扫描所有日志文件并更新数据库 + Future scanAndUpdateDB() async { + dev.log('🔵 scanAndUpdateDB 开始', name: 'BatteryHealth'); + + final state = await _safService.getPermissionState(); + if (state != SafPermissionState.authorized) { + dev.log('🔵 scanAndUpdateDB: 未授权', name: 'BatteryHealth'); + return; + } + + try { + final files = await _safService.listFiles(); + final filePattern = RegExp(r'mbattery_charger_log_(\d{4}_\d{2}_\d{2})$'); + + for (String fileName in files) { + final match = filePattern.firstMatch(fileName); + if (match == null) continue; + + final dateStr = match.group(1)!.replaceAll('_', '-'); + dev.log('🔵 处理文件: $fileName, 日期: $dateStr', name: 'BatteryHealth'); + + try { + final lines = await _safService.readLogFile(fileName); + if (lines.isEmpty) continue; + + // 使用 CapacityExtractor 提取容量 + final cap = CapacityExtractor.extractCapacity(lines); + if (cap == null) { + dev.log('🔵 文件 $fileName 无容量数据', name: 'BatteryHealth'); + continue; + } + + // 提取电压极值 + final voltReg = RegExp(r'vbat=(\d+)'); + double? maxVolt, minVolt; + for (String line in lines) { + if (!line.contains('tbat=')) continue; + final match = voltReg.firstMatch(line); + if (match != null) { + final v = int.parse(match.group(1)!) / 1000000; + if (maxVolt == null || v > maxVolt) maxVolt = v; + if (minVolt == null || v < minVolt) minVolt = v; + } + } + if (maxVolt == null || minVolt == null) continue; + + await _dbService.insertOrUpdate(dateStr, cap, maxVolt, minVolt); + dev.log('🔵 插入数据库: $dateStr, cap=$cap', name: 'BatteryHealth'); + } catch (e) { + dev.log('🔵 处理文件 $fileName 异常: $e', name: 'BatteryHealth'); + } + } + } catch (e) { + dev.log('🔵 scanAndUpdateDB 异常: $e', name: 'BatteryHealth'); + } + } + + /// 获取趋势数据(最近 N 天) + Future>> getTrendData(int days) async { + return await _dbService.queryRecentData(days); + } + + /// 获取最近一条数据 + Future?> getLatestData() async { + final data = await _dbService.queryRecentData(1); + if (data.isNotEmpty) return data.first; + return null; + } +} diff --git a/lib/services/database_service.dart b/lib/services/database_service.dart new file mode 100644 index 0000000..016422b --- /dev/null +++ b/lib/services/database_service.dart @@ -0,0 +1,130 @@ +import 'dart:developer' as dev; +import 'package:sqflite/sqflite.dart'; +import 'package:path/path.dart'; + +/// 数据库服务:管理电池数据持久化 +class DatabaseService { + static Database? _database; + static const String _tableName = 'battery_data'; + + /// 获取数据库实例(单例) + Future get database async { + if (_database != null) return _database!; + _database = await _initDatabase(); + return _database!; + } + + /// 初始化数据库 + Future _initDatabase() async { + final dbPath = await getDatabasesPath(); + final path = join(dbPath, 'battery_health.db'); + + return await openDatabase( + path, + version: 1, + onCreate: (db, version) async { + await db.execute(''' + CREATE TABLE $_tableName ( + date TEXT PRIMARY KEY, + capacity INTEGER NOT NULL, + volt_max REAL NOT NULL, + volt_min REAL NOT NULL, + updated_at TEXT NOT NULL + ) + '''); + await db.execute(''' + CREATE INDEX idx_date ON $_tableName (date DESC) + '''); + dev.log('✅ 数据库表创建成功', name: 'DatabaseService'); + }, + ); + } + + /// 插入或更新数据 + Future insertOrUpdate( + String date, + int capacity, + double voltMax, + double voltMin, + ) async { + final db = await database; + final now = DateTime.now().toIso8601String(); + + await db.insert(_tableName, { + 'date': date, + 'capacity': capacity, + 'volt_max': voltMax, + 'volt_min': voltMin, + 'updated_at': now, + }, conflictAlgorithm: ConflictAlgorithm.replace); + dev.log('📝 数据已保存: $date, cap=$capacity', name: 'DatabaseService'); + } + + /// 查询最近 N 条数据(按日期倒序) + Future>> queryRecentData(int limit) async { + final db = await database; + try { + final result = await db.query( + _tableName, + orderBy: 'date DESC', + limit: limit, + ); + return result; + } catch (e) { + dev.log('⚠️ 查询数据失败: $e', name: 'DatabaseService'); + return []; + } + } + + /// 查询指定日期数据 + Future?> queryByDate(String date) async { + final db = await database; + try { + final result = await db.query( + _tableName, + where: 'date = ?', + whereArgs: [date], + ); + return result.isNotEmpty ? result.first : null; + } catch (e) { + dev.log('⚠️ 查询单日数据失败: $e', name: 'DatabaseService'); + return null; + } + } + + /// 删除指定日期数据 + Future deleteByDate(String date) async { + final db = await database; + await db.delete(_tableName, where: 'date = ?', whereArgs: [date]); + } + + /// 获取数据库中所有日期列表 + Future> getAllDates() async { + final db = await database; + try { + final result = await db.query( + _tableName, + columns: ['date'], + orderBy: 'date DESC', + ); + return result.map((row) => row['date'] as String).toList(); + } catch (e) { + return []; + } + } + + /// 清空所有数据 + Future clearAll() async { + final db = await database; + await db.delete(_tableName); + dev.log('🗑️ 所有数据已清空', name: 'DatabaseService'); + } + + /// 关闭数据库 + Future close() async { + if (_database != null) { + await _database!.close(); + _database = null; + } + } +} diff --git a/lib/utils/time_utils.dart b/lib/utils/time_utils.dart new file mode 100644 index 0000000..670fc31 --- /dev/null +++ b/lib/utils/time_utils.dart @@ -0,0 +1,48 @@ +import 'dart:developer' as dev; + +class TimeUtils { + /// 解析日志行中的时间戳,强制解析为 UTC + static DateTime? parseLogTimestamp(String line) { + final reg = RegExp(r'\[(\d{4}-\d{2}-\d{2}) (\d{2}:\d{2}:\d{2})\]'); + final match = reg.firstMatch(line); + if (match == null) return null; + try { + final dateParts = match.group(1)!.split('-'); + final timeParts = match.group(2)!.split(':'); + return DateTime.utc( + int.parse(dateParts[0]), + int.parse(dateParts[1]), + int.parse(dateParts[2]), + int.parse(timeParts[0]), + int.parse(timeParts[1]), + int.parse(timeParts[2]), + ); + } catch (_) { + return null; + } + } + + /// 判断日志时间戳是否在有效窗口内 + static bool isLogTimestampValid( + DateTime? logTime, + DateTime? chargingStartTime, + ) { + if (logTime == null) return false; + if (chargingStartTime == null) { + final nowUtc = DateTime.now().toUtc(); + final diff = nowUtc.difference(logTime); + return diff.abs().inMinutes <= 3; + } + final endTime = chargingStartTime.add(const Duration(minutes: 3)); + return logTime.isAfter(chargingStartTime) && logTime.isBefore(endTime); + } + + /// 生成今日日志文件名(UTC) + static String getTodayFileName() { + final nowUtc = DateTime.now().toUtc(); + final year = nowUtc.year.toString(); + final month = nowUtc.month.toString().padLeft(2, '0'); + final day = nowUtc.day.toString().padLeft(2, '0'); + return 'mbattery_charger_log_${year}_${month}_$day'; + } +} diff --git a/lib/widgets/battery_info_panel.dart b/lib/widgets/battery_info_panel.dart new file mode 100644 index 0000000..aee9898 --- /dev/null +++ b/lib/widgets/battery_info_panel.dart @@ -0,0 +1,135 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_svg/flutter_svg.dart'; + +/// 电池信息面板(电压 + 温度 + 极值) +class BatteryInfoPanel extends StatelessWidget { + final double batteryVolt; + final double batteryTemp; + final double maxVolt; + final double minVolt; + final double maxTemp; + final double minTemp; + + const BatteryInfoPanel({ + super.key, + required this.batteryVolt, + required this.batteryTemp, + required this.maxVolt, + required this.minVolt, + required this.maxTemp, + required this.minTemp, + }); + + @override + Widget build(BuildContext context) { + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildInfoItem( + _buildIcon('voltage'), + '电压', + '${batteryVolt.toStringAsFixed(3)}V', + ), + const SizedBox(height: 8), + Row( + children: [ + Text( + '↑ ${maxVolt.toStringAsFixed(3)}V', + style: const TextStyle( + color: Colors.red, + fontSize: 14, + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(width: 12), + Text( + '↓ ${minVolt.toStringAsFixed(3)}V', + style: const TextStyle( + color: Colors.blue, + fontSize: 14, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ], + ), + ), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildInfoItem( + _buildIcon('temperature'), + '温度', + '${batteryTemp.toStringAsFixed(0)}°C', + ), + const SizedBox(height: 8), + Row( + children: [ + Text( + '↑ ${maxTemp.toStringAsFixed(0)}°C', + style: const TextStyle( + color: Colors.red, + fontSize: 14, + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(width: 12), + Text( + '↓ ${minTemp.toStringAsFixed(0)}°C', + style: const TextStyle( + color: Colors.blue, + fontSize: 14, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ], + ), + ), + ], + ); + } + + Widget _buildIcon(String type) { + switch (type) { + case 'voltage': + return SvgPicture.asset( + 'assets/icons/si--lightning-fill.svg', + width: 18, + height: 18, + colorFilter: const ColorFilter.mode(Colors.grey, BlendMode.srcIn), + ); + case 'temperature': + return SvgPicture.asset( + 'assets/icons/mdi--temperature.svg', + width: 18, + height: 18, + colorFilter: const ColorFilter.mode(Colors.grey, BlendMode.srcIn), + ); + default: + return const Icon(Icons.help_outline, size: 18, color: Colors.grey); + } + } + + Widget _buildInfoItem(Widget icon, String label, String value) { + return Row( + children: [ + icon, + const SizedBox(width: 6), + Text(label, style: const TextStyle(fontSize: 14, color: Colors.grey)), + const SizedBox(width: 6), + Text( + value, + style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w500), + ), + ], + ); + } +} diff --git a/lib/widgets/battery_summary.dart b/lib/widgets/battery_summary.dart new file mode 100644 index 0000000..51c4565 --- /dev/null +++ b/lib/widgets/battery_summary.dart @@ -0,0 +1,101 @@ +import 'package:flutter/material.dart'; + +/// 电池健康度摘要组件(健康度 + cycles + mAh) +class BatterySummary extends StatelessWidget { + final double healthPercent; + final int cycleCount; + final double currentCap; + final int designCap; + + const BatterySummary({ + super.key, + required this.healthPercent, + required this.cycleCount, + required this.currentCap, + required this.designCap, + }); + + @override + Widget build(BuildContext context) { + return Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + // 健康度大数字 + Row( + crossAxisAlignment: CrossAxisAlignment.baseline, + textBaseline: TextBaseline.alphabetic, + children: [ + Text( + healthPercent.toStringAsFixed(1), + style: TextStyle( + fontSize: 40, + fontWeight: FontWeight.w500, + height: 1.0, + color: healthPercent < 70 ? Colors.red : Colors.black, + ), + ), + const SizedBox(width: 2), + Text( + '%', + style: TextStyle( + fontSize: 20, + fontWeight: FontWeight.w300, + height: 1.0, + color: healthPercent < 70 ? Colors.red : Colors.grey.shade600, + ), + ), + ], + ), + const Spacer(), + // cycles + mAh + Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text( + '$cycleCount cycles', // ✅ 修正:去掉下划线,使用 cycleCount + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w500, + height: 1.2, + ), + ), + const SizedBox(height: 4), + Text.rich( + TextSpan( + children: [ + TextSpan( + text: currentCap.toStringAsFixed(0), + style: const TextStyle( + fontSize: 20, + fontWeight: FontWeight.w300, + ), + ), + TextSpan( + text: ' / ', + style: TextStyle( + fontSize: 20, + fontWeight: FontWeight.w300, + color: Colors.grey.shade500, + ), + ), + TextSpan( + text: '${designCap}mAh', + style: const TextStyle( + fontSize: 20, + fontWeight: FontWeight.w300, + ), + ), + ], + ), + style: const TextStyle( + fontSize: 20, + fontWeight: FontWeight.w300, + height: 1.0, + ), + ), + ], + ), + ], + ); + } +} diff --git a/lib/widgets/charge_detail_page.dart b/lib/widgets/charge_detail_page.dart index 734f7fa..015fadc 100644 --- a/lib/widgets/charge_detail_page.dart +++ b/lib/widgets/charge_detail_page.dart @@ -11,14 +11,14 @@ class ChargeDetailPage extends StatelessWidget { final int? wiredLevel; const ChargeDetailPage({ - super.key, + Key? key, required this.chargeType, required this.isWireless, required this.power, required this.volt, required this.curr, this.wiredLevel, - }); + }) : super(key: key); String _getDisplayPower() { if (isWireless) { @@ -31,7 +31,7 @@ class ChargeDetailPage extends StatelessWidget { String _getProtocolDisplay() { if (isWireless) return '无线充电($chargeType)'; if (chargeType == 'PPS') return 'PPS 快充'; - if (chargeType == 'SDP') return 'USB 充电'; // 🟢 改成和主页面一致 + if (chargeType == 'SDP') return 'USB 充电'; return chargeType; } diff --git a/lib/widgets/charge_entry.dart b/lib/widgets/charge_entry.dart new file mode 100644 index 0000000..1901174 --- /dev/null +++ b/lib/widgets/charge_entry.dart @@ -0,0 +1,55 @@ +import 'package:flutter/material.dart'; + +class ChargeEntry extends StatelessWidget { + final String summary; + final bool isCharging; + final VoidCallback onTap; + + const ChargeEntry({ + super.key, + required this.summary, + required this.isCharging, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: isCharging ? onTap : null, + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + summary, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w500, + color: Colors.black87, + ), + ), + Row( + children: [ + Text( + '充电信息', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w500, + color: Colors.grey.shade700, + ), + ), + const SizedBox(width: 4), + Text( + '>', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w500, + color: Colors.grey.shade700, + ), + ), + ], + ), + ], + ), + ); + } +} diff --git a/lib/widgets/trend_chart.dart b/lib/widgets/trend_chart.dart new file mode 100644 index 0000000..9139359 --- /dev/null +++ b/lib/widgets/trend_chart.dart @@ -0,0 +1,222 @@ +import 'package:flutter/material.dart'; + +// --- 潘通色定义 --- +const Color kHealthGreen = Color(0xFF00B140); +const Color kHealthYellow = Color(0xFFEFDF00); +const Color kHealthOrange = Color(0xFFD75A16); + +/// 趋势图柱状图组件 +class TrendChart extends StatelessWidget { + final List> data; + final int designCap; + + const TrendChart({super.key, required this.data, required this.designCap}); + + @override + Widget build(BuildContext context) { + List> displayData = data.isNotEmpty + ? data + : _getMockData(); + + List dates = displayData.map((e) { + String date = e['date'] as String; + var parts = date.split('-'); + return '${parts[1]}/${parts[2]}'; + }).toList(); + + List percents = displayData.map((e) { + int cap = e['capacity'] as int; + return (cap / designCap) * 100; + }).toList(); + + while (percents.length < 15) { + percents.insert(0, 0); + dates.insert(0, ''); + } + if (percents.length > 15) { + percents = percents.sublist(percents.length - 15); + dates = dates.sublist(dates.length - 15); + } + + int todayIndex = percents.length - 1; + int sevenDaysAgoIndex = todayIndex - 7; + bool showSevenDaysAgo = sevenDaysAgoIndex >= 0; + + String sevenDaysAgoDate = showSevenDaysAgo ? dates[sevenDaysAgoIndex] : ''; + String todayDate = dates[todayIndex]; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + '容量百分比变动趋势(近15天)', + style: TextStyle(fontSize: 16, fontWeight: FontWeight.w500), + ), + const SizedBox(height: 8), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + height: 180, + child: Column( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: const [ + Text( + '100', + style: TextStyle( + fontSize: 10, + color: Colors.grey, + height: 1.0, + ), + ), + Text( + '70', + style: TextStyle( + fontSize: 10, + color: Colors.grey, + height: 1.0, + ), + ), + ], + ), + ), + const SizedBox(width: 4), + Expanded( + child: LayoutBuilder( + builder: (context, constraints) { + double totalWidth = constraints.maxWidth; + double step = totalWidth / 15; + + return SizedBox( + height: 180, + child: Stack( + children: [ + Padding( + padding: const EdgeInsets.only(bottom: 20), + child: Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: List.generate(percents.length, (index) { + double value = percents[index]; + Color barColor; + if (value > 0) { + if (value >= 80) { + barColor = kHealthGreen; + } else if (value >= 70) { + barColor = kHealthYellow; + } else { + barColor = Colors.red; + } + } else { + barColor = Colors.grey.shade300; + } + double clampedValue = value.clamp(70.0, 100.0); + double heightFactor = + (clampedValue - 70.0) / 30.0; + + return Expanded( + child: Align( + alignment: Alignment.bottomCenter, + child: Container( + margin: const EdgeInsets.symmetric( + horizontal: 2, + ), + height: 20 + (100 * heightFactor), + width: 18, + decoration: BoxDecoration( + color: barColor, + borderRadius: BorderRadius.circular(4), + ), + ), + ), + ); + }), + ), + ), + if (showSevenDaysAgo && sevenDaysAgoDate.isNotEmpty) + Positioned( + left: sevenDaysAgoIndex * step + (step / 2) - 14, + bottom: 2, + child: Text( + sevenDaysAgoDate, + style: const TextStyle( + fontSize: 9, + color: Colors.grey, + height: 1.0, + ), + ), + ), + if (todayDate.isNotEmpty) + Positioned( + left: todayIndex * step + (step / 2) - 14, + bottom: 2, + child: Text( + todayDate, + style: const TextStyle( + fontSize: 9, + color: Colors.black, + height: 1.0, + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ), + ); + }, + ), + ), + ], + ), + if (percents.every((v) => v == 0)) + const Padding( + padding: EdgeInsets.only(top: 8.0), + child: Text( + '暂无历史数据,请点击刷新按钮更新', + style: TextStyle(fontSize: 12, color: Colors.grey), + ), + ), + ], + ); + } + + List> _getMockData() { + List mockDates = [ + '2026-06-14', + '2026-06-15', + '2026-06-16', + '2026-06-17', + '2026-06-18', + '2026-06-19', + '2026-06-20', + '2026-06-21', + '2026-06-22', + '2026-06-23', + '2026-06-24', + '2026-06-25', + '2026-06-26', + '2026-06-27', + '2026-06-28', + ]; + List mockCap = [ + 4635, + 4632, + 4628, + 4625, + 4620, + 4618, + 4615, + 4610, + 4608, + 4605, + 4600, + 4598, + 4595, + 4590, + 4585, + ]; + return List.generate( + 15, + (i) => {'date': mockDates[i], 'capacity': mockCap[i]}, + ); + } +}