Files
MEIZU-Battery-Healthy/lib/pages/battery_health_page.dart
T

1360 lines
41 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: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';
import '../services/saf_storage_service.dart';
import '../models/saf_permission_state.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;
// ---------- 趋势图数据 ----------
List<Map<String, dynamic>> _trendData = [];
// ---------- 充电状态 ----------
bool _isCharging = false;
String _chargeType = '';
double _chargePower = 0.0;
double _chargeVolt = 0.0;
double _chargeCurr = 0.0;
int? _wiredLevel;
bool _isWireless = false;
DateTime? _chargingStartTime;
// ---------- SAF 状态 ----------
final SafStorageService _safService = SafStorageService();
SafPermissionState _safState = SafPermissionState.unauthorized;
// 动画控制
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;
});
}
}
// ---------- 扫描目录 (SAF 模式) ----------
Future<void> _scanAndUpdateDB() async {
// 检查 SAF 授权状态
_safState = await _safService.getPermissionState();
if (_safState != SafPermissionState.authorized) {
_showSafAuthorizationDialog();
return;
}
try {
final files = await _safService.listFiles();
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('_', '-');
try {
List<String> lines = await _safService.readLogFile(fileName);
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 (_) {}
}
}
} 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');
return 'mbattery_charger_log_${year}_${month}_$day';
}
// ---------- 时间戳工具方法 ----------
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;
}
}
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() {
if (!mounted) return;
showDialog(
context: context,
barrierDismissible: false,
builder: (context) => AlertDialog(
title: const Text('需要文件夹访问权限'),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: const [
Text('请授权访问日志文件夹,以读取电池数据。'),
SizedBox(height: 8),
Text(
'目标路径:\n/storage/emulated/0/mbattery_charger/',
style: TextStyle(fontSize: 12, color: Colors.grey),
),
SizedBox(height: 8),
Text('点击"选择文件夹"后,请在系统文件管理器中找到并选中上述文件夹。'),
],
),
actions: [
TextButton(
onPressed: () {
Navigator.pop(context);
setState(() {
_safState = SafPermissionState.denied;
});
},
child: const Text('取消'),
),
ElevatedButton(
onPressed: () async {
// 触发关闭
Navigator.pop(context);
// 延迟确保动画完成
await Future.delayed(const Duration(milliseconds: 150));
final result = await _safService.requestPermission();
if (!mounted) return;
if (result.success) {
setState(() {
_safState = SafPermissionState.authorized;
});
readBatteryCapacity();
} else {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(result.errorMessage ?? '授权失败,请重试'),
backgroundColor: Colors.red,
),
);
_showSafAuthorizationDialog();
}
},
child: const Text('选择文件夹'),
),
],
),
);
}
// ---------- 核心:系统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;
}
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 {
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',
);
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',
);
// ---------- 无线 ----------
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<void> _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;
}
}
// 检查 SAF 授权
_safState = await _safService.getPermissionState();
if (_safState != SafPermissionState.authorized) {
if (mounted) _showSafAuthorizationDialog();
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<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 {
// 检查 SAF 授权
_safState = await _safService.getPermissionState();
if (_safState != SafPermissionState.authorized) {
if (mounted) _showSafAuthorizationDialog();
return;
}
await _getDeviceInfo();
await _scanAndUpdateDB();
final fileName = getTodayFileName();
final exists = await _safService.logFileExists(fileName);
if (!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 {
final lines = await _safService.readLogFile(fileName);
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();
}
});
// 延迟初始化 SAF 并读取数据
WidgetsBinding.instance.addPostFrameCallback((_) async {
await _safService.init();
_safState = await _safService.getPermissionState();
if (_safState == SafPermissionState.unauthorized ||
_safState == SafPermissionState.expired) {
if (mounted) _showSafAuthorizationDialog();
} else {
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: 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,
),
),
],
),
],
),
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 >= 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<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),
),
],
);
}
}