Files
MEIZU-Battery-Healthy/lib/pages/battery_health_page.dart
T
2026-07-04 12:25:46 +08:00

1273 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 >= 70) {
barColor = kHealthYellow;
} else {
barColor = Colors.red; // 🟢 小于 70 显示红色
}
} 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),
),
],
);
}
}