1272 lines
39 KiB
Dart
1272 lines
39 KiB
Dart
import 'dart:io';
|
|
import 'dart:async';
|
|
import 'dart:developer' as dev;
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter/services.dart';
|
|
import 'package:flutter_svg/flutter_svg.dart';
|
|
import 'package:permission_handler/permission_handler.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 '../models/device_matching.dart';
|
|
import '../utils/charge_utils.dart';
|
|
import '../widgets/charge_detail_page.dart';
|
|
|
|
// --- 潘通色定义 ---
|
|
const Color kHealthGreen = Color(0xFF00B140);
|
|
const Color kHealthYellow = Color(0xFFEFDF00);
|
|
const Color kHealthOrange = Color(0xFFD75A16);
|
|
|
|
class BatteryHealthPage extends StatefulWidget {
|
|
const BatteryHealthPage({super.key});
|
|
|
|
@override
|
|
State<BatteryHealthPage> createState() => _BatteryHealthPageState();
|
|
}
|
|
|
|
class _BatteryHealthPageState extends State<BatteryHealthPage>
|
|
with SingleTickerProviderStateMixin {
|
|
// ---------- 数据 ----------
|
|
double _currentCap = 0;
|
|
int _cycleCount = 0;
|
|
double _batteryTemp = 0;
|
|
double _batteryVolt = 0;
|
|
int _designCap = 5050;
|
|
String _deviceModel = "魅族21 Pro (默认)";
|
|
double _maxTemp = 0;
|
|
double _minTemp = 0;
|
|
double _maxVolt = 0;
|
|
double _minVolt = 0;
|
|
bool _hasData = false;
|
|
final String _filePath = '/storage/emulated/0/mbattery_charger/';
|
|
|
|
// ---------- 趋势图数据 ----------
|
|
List<Map<String, dynamic>> _trendData = [];
|
|
|
|
// ---------- 充电状态 ----------
|
|
bool _isCharging = false;
|
|
String _chargeType = ''; // "EPP", "PPS", "SDP", "无线", "MZ_EPP" 等
|
|
double _chargePower = 0.0;
|
|
double _chargeVolt = 0.0;
|
|
double _chargeCurr = 0.0;
|
|
int? _wiredLevel;
|
|
bool _isWireless = false;
|
|
|
|
// 🟢 1.3.0-dev.3: 充电开始时间(UTC),用于时间窗口判断
|
|
DateTime? _chargingStartTime;
|
|
|
|
// 动画控制
|
|
late AnimationController _chargeAnimationController;
|
|
late Animation<double> _bottomSlide;
|
|
late Animation<double> _entryFadeIn;
|
|
|
|
// 平台通道
|
|
static const _methodChannel = MethodChannel('com.lxh.battery_health/charge');
|
|
static const _eventChannel = EventChannel(
|
|
'com.lxh.battery_health/charge_events',
|
|
);
|
|
StreamSubscription? _chargeEventSubscription;
|
|
|
|
// 轮询定时器(15秒)
|
|
Timer? _pollTimer;
|
|
|
|
// ---------- 数据库 ----------
|
|
Database? _db;
|
|
|
|
Future<Database> get database async {
|
|
if (_db != null) return _db!;
|
|
_db = await _initDatabase();
|
|
return _db!;
|
|
}
|
|
|
|
Future<Database> _initDatabase() async {
|
|
Directory appDocDir = await getApplicationDocumentsDirectory();
|
|
String dbPath = path.join(appDocDir.path, 'battery_health.db');
|
|
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 (_) {}
|
|
}
|
|
},
|
|
);
|
|
}
|
|
|
|
Future<void> _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<List<Map<String, dynamic>>> _queryRecentData(int days) async {
|
|
Database db = await database;
|
|
return await db.query('battery_history', orderBy: 'date DESC', limit: days);
|
|
}
|
|
|
|
// ---------- 设备信息 ----------
|
|
Future<void> _getDeviceInfo() async {
|
|
DeviceInfoPlugin deviceInfo = DeviceInfoPlugin();
|
|
try {
|
|
AndroidDeviceInfo androidInfo = await deviceInfo.androidInfo;
|
|
String model = androidInfo.model;
|
|
setState(() {
|
|
_deviceModel = DeviceMatching.getModelName(model);
|
|
_designCap = DeviceMatching.getDesignCapacity(model);
|
|
});
|
|
} catch (e) {
|
|
setState(() {
|
|
_deviceModel = '魅族21 Pro (默认)';
|
|
_designCap = 5050;
|
|
});
|
|
}
|
|
}
|
|
|
|
// ---------- 扫描目录 ----------
|
|
Future<void> _scanAndUpdateDB() async {
|
|
Directory dir = Directory(_filePath);
|
|
if (!await dir.exists()) return;
|
|
|
|
List<FileSystemEntity> files = await dir.list().toList();
|
|
RegExp filePattern = RegExp(r'mbattery_charger_log_(\d{4}_\d{2}_\d{2})$');
|
|
|
|
for (FileSystemEntity entity in files) {
|
|
if (entity is File) {
|
|
String fileName = path.basename(entity.path);
|
|
Match? match = filePattern.firstMatch(fileName);
|
|
if (match != null) {
|
|
String dateStr = match.group(1)!.replaceAll('_', '-');
|
|
try {
|
|
List<String> lines = await entity.readAsLines();
|
|
if (lines.isEmpty) continue;
|
|
|
|
List<String> 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);
|
|
} catch (_) {}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---------- 1.3.0-dev.1: 使用 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');
|
|
return 'mbattery_charger_log_${year}_${month}_$day';
|
|
}
|
|
|
|
// ---------- 时间戳工具方法 ----------
|
|
/// 解析日志行中的时间戳,强制解析为 UTC
|
|
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<String> dateParts = dateStr.split('-');
|
|
List<String> 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;
|
|
}
|
|
}
|
|
|
|
/// 🟢 1.3.0-dev.3: 时间窗口以插入时刻为基准,只向前看
|
|
bool _isLogTimestampValid(DateTime? logTime) {
|
|
if (logTime == null) return false;
|
|
if (_chargingStartTime == null) {
|
|
// 边缘情况:回退到当前时间 ±3 分钟
|
|
DateTime nowUtc = DateTime.now().toUtc();
|
|
Duration diff = nowUtc.difference(logTime);
|
|
return diff.abs().inMinutes <= 3;
|
|
}
|
|
// 日志时间必须在 [充电开始时间, 充电开始时间 + 3 分钟] 范围内
|
|
DateTime endTime = _chargingStartTime!.add(const Duration(minutes: 3));
|
|
return logTime.isAfter(_chargingStartTime!) && logTime.isBefore(endTime);
|
|
}
|
|
|
|
// ---------- 核心:系统API定调 ----------
|
|
void _applySystemChargeState(bool isCharging, String systemChargeType) {
|
|
dev.log(
|
|
'[_applySystemChargeState] isCharging: $isCharging, systemChargeType: $systemChargeType',
|
|
name: 'BatteryHealth',
|
|
);
|
|
|
|
setState(() {
|
|
_isCharging = isCharging;
|
|
|
|
if (!isCharging) {
|
|
dev.log('[_applySystemChargeState] 充电断开,重置状态', name: 'BatteryHealth');
|
|
_chargeType = '';
|
|
_isWireless = false;
|
|
_chargePower = 0.0;
|
|
_chargeVolt = 0.0;
|
|
_chargeCurr = 0.0;
|
|
_wiredLevel = null;
|
|
_chargingStartTime = null; // 🟢 清空开始时间
|
|
_updateChargeState();
|
|
return;
|
|
}
|
|
|
|
// 🟢 1.3.0-dev.3: 记录充电开始时刻(仅当从断开状态变为充电状态时)
|
|
if (_chargingStartTime == null) {
|
|
_chargingStartTime = DateTime.now().toUtc();
|
|
dev.log(
|
|
'[_applySystemChargeState] 充电开始时间: $_chargingStartTime',
|
|
name: 'BatteryHealth',
|
|
);
|
|
}
|
|
|
|
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 {
|
|
// UNKNOWN 时清空旧类型,等待日志修正
|
|
dev.log(
|
|
'[_applySystemChargeState] ⚠️ 未知类型: $systemChargeType,清空类型等待日志',
|
|
name: 'BatteryHealth',
|
|
);
|
|
_isWireless = false;
|
|
_chargeType = '';
|
|
}
|
|
|
|
_chargePower = 0.0;
|
|
_chargeVolt = 0.0;
|
|
_chargeCurr = 0.0;
|
|
_updateChargeState();
|
|
});
|
|
|
|
if (_isCharging) {
|
|
_fetchChargeDataFromLog();
|
|
}
|
|
}
|
|
|
|
// ---------- 日志解析 ----------
|
|
void _parseChargeDataFromLog(List<String> lines) {
|
|
dev.log(
|
|
'[_parseChargeDataFromLog] 开始解析,共 ${lines.length} 行',
|
|
name: 'BatteryHealth',
|
|
);
|
|
|
|
// ---------- 1. 收集所有在有效时间窗口内的数据行 ----------
|
|
List<Map<String, dynamic>> 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<String, dynamic> latest = validLines.first;
|
|
String latestLine = latest['line'];
|
|
String latestType = latest['type'];
|
|
DateTime latestTime = latest['timestamp'];
|
|
|
|
dev.log(
|
|
'[_parseChargeDataFromLog] 最新有效数据: $latestType, 时间: $latestTime',
|
|
name: 'BatteryHealth',
|
|
);
|
|
|
|
// ---------- 2. 无线处理 ----------
|
|
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;
|
|
}
|
|
|
|
// ---------- 3. 有线处理 ----------
|
|
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;
|
|
|
|
// 🟢 1.3.0-dev.2: real_type 优先级最高
|
|
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;
|
|
}
|
|
}
|
|
|
|
// 🟢 1.3.0-dev.3: 增加超时保护
|
|
Future<void> _fetchChargeDataFromLog() async {
|
|
if (!_isCharging) {
|
|
dev.log('[_fetchChargeDataFromLog] 未充电,跳过读取', name: 'BatteryHealth');
|
|
return;
|
|
}
|
|
|
|
// 超时检查:如果插入超过 5 分钟还没读到数据,不再频繁报错
|
|
if (_chargingStartTime != null) {
|
|
Duration elapsed = DateTime.now().toUtc().difference(_chargingStartTime!);
|
|
if (elapsed.inMinutes > 5) {
|
|
dev.log(
|
|
'[_fetchChargeDataFromLog] 充电开始已超过 5 分钟,跳过本次轮询',
|
|
name: 'BatteryHealth',
|
|
);
|
|
return;
|
|
}
|
|
}
|
|
|
|
String fullPath = _filePath + getTodayFileName();
|
|
dev.log('[_fetchChargeDataFromLog] 尝试读取: $fullPath', name: 'BatteryHealth');
|
|
|
|
File file = File(fullPath);
|
|
if (!await file.exists()) {
|
|
dev.log(
|
|
'[_fetchChargeDataFromLog] ⚠️ 文件不存在: $fullPath',
|
|
name: 'BatteryHealth',
|
|
);
|
|
return;
|
|
}
|
|
try {
|
|
List<String> lines = await file.readAsLines();
|
|
dev.log(
|
|
'[_fetchChargeDataFromLog] ✅ 读取成功,共 ${lines.length} 行',
|
|
name: 'BatteryHealth',
|
|
);
|
|
if (lines.isEmpty) return;
|
|
_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<void> _getInitialChargeState() async {
|
|
try {
|
|
final Map<dynamic, dynamic> result = await _methodChannel.invokeMethod(
|
|
'getChargeState',
|
|
);
|
|
bool isCharging = result['isCharging'] ?? false;
|
|
String systemType = result['chargeType'] ?? 'NONE';
|
|
_applySystemChargeState(isCharging, systemType);
|
|
} catch (e) {
|
|
// 静默降级
|
|
}
|
|
}
|
|
|
|
// ---------- 读取当前数据 ----------
|
|
Future<void> readBatteryCapacity() async {
|
|
var status = await Permission.manageExternalStorage.request();
|
|
if (!status.isGranted) {
|
|
setState(() => _hasData = false);
|
|
return;
|
|
}
|
|
|
|
await _getDeviceInfo();
|
|
await _scanAndUpdateDB();
|
|
|
|
String fullPath = _filePath + getTodayFileName();
|
|
File file = File(fullPath);
|
|
|
|
if (!await file.exists()) {
|
|
List<Map<String, dynamic>> recent = await _queryRecentData(1);
|
|
if (recent.isNotEmpty) {
|
|
setState(() {
|
|
_currentCap = recent.first['capacity'].toDouble();
|
|
_maxVolt = recent.first['volt_max'] ?? 0;
|
|
_minVolt = recent.first['volt_min'] ?? 0;
|
|
_hasData = true;
|
|
});
|
|
await _updateTrendChart();
|
|
} else {
|
|
setState(() => _hasData = false);
|
|
}
|
|
if (_isCharging) _fetchChargeDataFromLog();
|
|
_chargeAnimationController.reset();
|
|
_updateChargeState();
|
|
return;
|
|
}
|
|
|
|
try {
|
|
List<String> lines = await file.readAsLines();
|
|
if (lines.isEmpty) {
|
|
setState(() => _hasData = false);
|
|
if (_isCharging) _fetchChargeDataFromLog();
|
|
_chargeAnimationController.reset();
|
|
_updateChargeState();
|
|
return;
|
|
}
|
|
|
|
if (_isCharging) _parseChargeDataFromLog(lines);
|
|
|
|
List<String> validLines = lines
|
|
.where((line) => line.contains('tbat='))
|
|
.toList();
|
|
if (validLines.isEmpty) {
|
|
setState(() => _hasData = false);
|
|
_chargeAnimationController.reset();
|
|
_updateChargeState();
|
|
return;
|
|
}
|
|
|
|
String lastLine = validLines.last;
|
|
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);
|
|
|
|
if (capStr == null) {
|
|
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;
|
|
|
|
List<double> 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;
|
|
_cycleCount = cycle;
|
|
_batteryTemp = temp;
|
|
_batteryVolt = volt;
|
|
_maxTemp = maxTemp;
|
|
_minTemp = minTemp;
|
|
_maxVolt = maxVolt ?? volt;
|
|
_minVolt = minVolt ?? volt;
|
|
_hasData = true;
|
|
});
|
|
|
|
await _updateTrendChart();
|
|
_chargeAnimationController.reset();
|
|
_updateChargeState();
|
|
} catch (e) {
|
|
setState(() => _hasData = false);
|
|
_chargeAnimationController.reset();
|
|
_updateChargeState();
|
|
}
|
|
}
|
|
|
|
Future<void> _updateTrendChart() async {
|
|
List<Map<String, dynamic>> data = await _queryRecentData(15);
|
|
setState(() {
|
|
_trendData = data.reversed.toList();
|
|
});
|
|
}
|
|
|
|
// ---------- 生命周期 ----------
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
|
|
_chargeAnimationController = AnimationController(
|
|
vsync: this,
|
|
duration: const Duration(milliseconds: 500),
|
|
);
|
|
_bottomSlide = Tween<double>(begin: 0, end: 24).animate(
|
|
CurvedAnimation(
|
|
parent: _chargeAnimationController,
|
|
curve: Curves.easeInOutCubic,
|
|
),
|
|
);
|
|
_entryFadeIn = Tween<double>(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';
|
|
_applySystemChargeState(isCharging, systemType);
|
|
});
|
|
|
|
_pollTimer = Timer.periodic(const Duration(seconds: 15), (timer) {
|
|
if (_isCharging) {
|
|
_fetchChargeDataFromLog();
|
|
}
|
|
});
|
|
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
readBatteryCapacity();
|
|
});
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_chargeEventSubscription?.cancel();
|
|
_pollTimer?.cancel();
|
|
_chargeAnimationController.dispose();
|
|
_db?.close();
|
|
super.dispose();
|
|
}
|
|
|
|
// ---------- Build ----------
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return AnnotatedRegion<SystemUiOverlayStyle>(
|
|
value: SystemUiOverlayStyle.dark,
|
|
child: Scaffold(
|
|
appBar: AppBar(
|
|
backgroundColor: const Color.fromARGB(0, 255, 255, 255),
|
|
elevation: 0,
|
|
title: const Text(
|
|
'电池健康度',
|
|
style: TextStyle(
|
|
fontSize: 24,
|
|
fontWeight: FontWeight.w600,
|
|
color: Colors.black,
|
|
),
|
|
),
|
|
actions: [
|
|
IconButton(
|
|
icon: const Icon(Icons.refresh),
|
|
onPressed: readBatteryCapacity,
|
|
tooltip: '刷新数据',
|
|
color: Colors.black,
|
|
),
|
|
],
|
|
),
|
|
body: _hasData ? _buildDataLayout() : _buildEmptyState(),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildEmptyState() {
|
|
return const Center(
|
|
child: Column(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
Icon(Icons.battery_unknown, size: 64, color: Colors.grey),
|
|
SizedBox(height: 16),
|
|
Text('暂无数据', style: TextStyle(fontSize: 18, color: Colors.grey)),
|
|
Text('点击右上角刷新尝试', style: TextStyle(fontSize: 14, color: Colors.grey)),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildDataLayout() {
|
|
double healthPercent = (_designCap > 0)
|
|
? (_currentCap / _designCap) * 100
|
|
: 0;
|
|
|
|
String chargeSummary = '';
|
|
if (_isCharging) {
|
|
String typeLabel = _getChargeTypeLabel();
|
|
String powerLabel = _getDisplayPower();
|
|
chargeSummary = '充电中 · $typeLabel · $powerLabel';
|
|
}
|
|
|
|
return Padding(
|
|
padding: const EdgeInsets.all(20.0),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
_deviceModel,
|
|
style: const TextStyle(
|
|
fontSize: 14,
|
|
color: Colors.grey,
|
|
fontWeight: FontWeight.w400,
|
|
),
|
|
),
|
|
const SizedBox(height: 4),
|
|
|
|
Row(
|
|
crossAxisAlignment: CrossAxisAlignment.end,
|
|
children: [
|
|
Row(
|
|
crossAxisAlignment: CrossAxisAlignment.baseline,
|
|
textBaseline: TextBaseline.alphabetic,
|
|
children: [
|
|
Text(
|
|
healthPercent.toStringAsFixed(1),
|
|
style: const TextStyle(
|
|
fontSize: 40,
|
|
fontWeight: FontWeight.w500,
|
|
height: 1.0,
|
|
),
|
|
),
|
|
const SizedBox(width: 2),
|
|
Text(
|
|
'%',
|
|
style: TextStyle(
|
|
fontSize: 20,
|
|
fontWeight: FontWeight.w300,
|
|
height: 1.0,
|
|
color: 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,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 24),
|
|
|
|
_buildTrendChart(),
|
|
const SizedBox(height: 8),
|
|
|
|
AnimatedBuilder(
|
|
animation: _chargeAnimationController,
|
|
builder: (context, child) {
|
|
double entryHeight = 28 * _entryFadeIn.value;
|
|
|
|
Widget chargeEntry = Opacity(
|
|
opacity: _entryFadeIn.value,
|
|
child: _buildChargeEntry(chargeSummary),
|
|
);
|
|
|
|
return Column(
|
|
children: [
|
|
SizedBox(height: entryHeight, child: chargeEntry),
|
|
Transform.translate(
|
|
offset: Offset(0, _bottomSlide.value),
|
|
child: _buildBottomInfo(),
|
|
),
|
|
],
|
|
);
|
|
},
|
|
),
|
|
const Spacer(),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
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<Map<String, dynamic>> displayData = _trendData.isNotEmpty
|
|
? _trendData
|
|
: _getMockData();
|
|
|
|
List<String> dates = displayData.map((e) {
|
|
String date = e['date'] as String;
|
|
var parts = date.split('-');
|
|
return '${parts[1]}/${parts[2]}';
|
|
}).toList();
|
|
|
|
List<double> 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 >= 60)
|
|
barColor = kHealthYellow;
|
|
else
|
|
barColor = kHealthOrange;
|
|
} 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<Map<String, dynamic>> _getMockData() {
|
|
List<String> 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<int> 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),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|