44 Commits

Author SHA1 Message Date
lxh2875931338 c85a523ec0 机型匹配情况 2026-07-21 19:21:05 +08:00
lxh2875931338 067f0d17e8 机型匹配情况说明 2026-07-21 19:19:50 +08:00
lxh2875931338 a63be9f675 20inf最低支持版本说明 2026-07-21 19:15:43 +08:00
lxh2875931338 86c38a97f5 增加机型匹配信息与适配说明 2026-07-21 19:13:27 +08:00
lxh2875931338 2026e55fde readme机型匹配列表更新 2026-07-21 18:57:35 +08:00
lxh2875931338 286162d41b readme机型匹配列表更新 2026-07-21 18:56:49 +08:00
lxh2875931338 4006684466 Merge branch 'main' of http://192.168.3.16:3800/lxh2875931338/MEIZU-Battery-Healthy 2026-07-21 18:54:50 +08:00
lxh2875931338 e5856296d4 readme机型匹配列表更新 2026-07-21 18:54:19 +08:00
lxh2875931338 e049cab9ad readme机型匹配列表更新 2026-07-21 18:49:27 +08:00
lxh2875931338 2b3d9471cd 补充版本号更新:1.6.0-Release 2026-07-21 18:39:26 +08:00
lxh2875931338 76fd0417e7 紧急补充:魅族21note字段更新 2026-07-21 00:09:47 +08:00
lxh2875931338 471d8310d7 更新日志 2026-07-20 23:59:33 +08:00
lxh2875931338 92f6c30699 更新日志说明 2026-07-20 23:58:48 +08:00
lxh2875931338 2e438aa089 针对readme部分做性能优化,目标帧率120达成 2026-07-20 23:57:01 +08:00
lxh2875931338 5e755d860f 更新日志界面优化、主页部分动画调整 2026-07-20 23:34:42 +08:00
lxh2875931338 0d8c6b081d 更新日志铺路部分,dev中 2026-07-20 23:16:39 +08:00
lxh2875931338 c8866a740a 21note字段确认为乱码,撤回支持 2026-07-20 22:49:16 +08:00
lxh2875931338 41f3ea114b 21Note字段更新(字段预测) 2026-07-20 22:39:17 +08:00
lxh2875931338 b5628510f6 readme更新 2026-07-19 20:57:04 +08:00
lxh2875931338 b390311827 版本号调整 2026-07-19 20:14:32 +08:00
lxh2875931338 f3a414b097 readme更新 2026-07-19 20:05:47 +08:00
lxh2875931338 cf7cec6be3 readme更新 2026-07-19 19:55:32 +08:00
lxh2875931338 6808f099f4 去除魅族22的机型匹配信息(机型没有响应的log文件) 2026-07-19 19:50:20 +08:00
lxh2875931338 38624a8d50 修改机型名称(匹配其他机型的非全大写) 2026-07-19 19:48:55 +08:00
lxh2875931338 b34af6af66 20inf识别字更新 2026-07-19 19:48:07 +08:00
lxh2875931338 ae9bb2dfa1 1.5.2版本发布及部分readme更新 2026-07-14 23:03:35 +08:00
lxh2875931338 bb9a3dc3f5 浪漫雅圆(FlymeFont.ttf)脱色处理并添加代码适配 2026-07-14 22:58:37 +08:00
lxh2875931338 adc49fa1a5 更新一个字体 2026-07-14 21:51:44 +08:00
lxh2875931338 804b992ee4 readme修改,涉及机型匹配相关信息调整 2026-07-11 21:41:59 +08:00
lxh2875931338 4857aa5d1b 1.5.0版本readme更新 2026-07-08 23:47:51 +08:00
lxh2875931338 9066b0f703 优化部分页面下UI显示问题 2026-07-08 23:43:23 +08:00
lxh2875931338 23d5c3b341 针对1.4.7的擦屁股操作:优化status=full的判定逻辑 2026-07-08 23:32:50 +08:00
lxh2875931338 6af7ad6cd2 单位对应数值优化 2026-07-08 23:19:27 +08:00
lxh2875931338 afc83c16c3 充电逻辑修改初步定档 2026-07-08 23:10:16 +08:00
lxh2875931338 c5e5280de7 log信息来源补充 2026-07-07 23:47:54 +08:00
lxh2875931338 28824501dc 相应的版本更新 2026-07-06 18:57:42 +08:00
lxh2875931338 76bff59afc 部分自动优化 2026-07-06 18:29:49 +08:00
lxh2875931338 26aa931e06 更新一下readme 2026-07-05 23:55:56 +08:00
lxh2875931338 0fa47dcb61 新计算逻辑继续补充中 2026-07-05 23:36:58 +08:00
lxh2875931338 20e29f3043 尝试针对魅族20系设计新的计算逻辑 2026-07-05 23:23:30 +08:00
lxh2875931338 14416be48e 更新readme进度 2026-07-05 01:30:14 +08:00
lxh2875931338 0450bc9540 背景色优化 2026-07-05 01:25:49 +08:00
lxh2875931338 f8ffb32bb0 Merge branch 'main' of http://192.168.3.16:3800/lxh2875931338/MEIZU-Battery-Healthy 2026-07-05 01:05:02 +08:00
lxh2875931338 8e93cbeed2 尝试解决二次启动时文件读取问题 2026-07-05 01:01:47 +08:00
29 changed files with 2556 additions and 1235 deletions
-3
View File
@@ -1,3 +0,0 @@
{
"cmake.ignoreCMakeListsMissing": true
}
+43 -8
View File
@@ -24,18 +24,52 @@
| 版本 | 主题 | 状态 |
| :--- | :--- | :--- |
| 1.3.0 | 时间窗口 + real_type 优先级 | ✅ 已发布 |
| 1.4.0 | SAF 权限改造 | 📋 规划中 |
| 1.5.0 | 功率计算优化 | 📋 规划中 |
| 2.0.0 | 数据看板扩展(月/季/年趋势) | 📋 远景 |
| 1.5.0 | 功率计算优化 | ✅ 已发布 |
| 1.5.2 | 部分场景字体替换 | ✅ 已发布 |
| 1.6.0 | 更新日志独立页面 | ✅ 已发布 |
| 1.7.0 | 适配夜间模式 | 📋近期开发 |
| 2.0.0 | 数据看板月份显示扩展等系列更新 | 📋 远景 |
| 3.0.0 | 搭配PC上位机记录充电信息等 | 📋 远景 |
---
## 环境要求
- Flutter SDK: ^3.9.2
- Android: 8.0+ (API 26+)
- 仅支持魅族手机(15 系列 ~ 25 系列)
---
## 机型匹配情况
| 机型 | 系统版本 | 容量来源 | 设计容量 | 状态 |
|---|---|---|---|---|
| 魅族 22 | Flyme 12.x | 系统未生成 `mbattery_charger` 目录 | 5510 mAh | ❌ 不支持 |
| 魅族 21 | Flyme 12.x | `learned_cap` | 4800 mAh | ✅ 与 21 Pro 同步获得支持 |
| 魅族 21 Pro | Flyme 12.x | `learned_cap` | 5050 mAh | ✅ 初始支持 |
| 魅族 21 Note | Flyme 12.x | `learned_cap` | 5500 mAh | ✅ 1.6.0 新增支持 |
| 魅族 21 Note | Flyme 10 / 11 | 日志格式异常 | — | ❌ 不支持 |
| 魅族 20 | Flyme 12.x | `remaining_cap` + `status=Full` | 4700 mAh | ✅ 1.4.0 首次支持 |
| 魅族 20 Pro | Flyme 12.x | `remaining_cap` + `status=Full` | 5000 mAh | ✅ 1.4.0 首次支持 |
| 魅族 20 INFINITY | Flyme 10.x | `remaining_cap` + `status=Full` | 4800 mAh | ✅ 1.5.3 字段匹配 |
| 魅族 18 / 18s / 18 Pro / 18s Pro / 18X | Flyme 9 | 日志停止生成 | 4000 / 4500 mAh | ❌ 无适配条件 |
| 魅族 18 / 18s / 18 Pro / 18s Pro / 18X | Flyme 10 | 不再主动适配 | 4000 / 4500 mAh | ⚠️ 实验性支持 |
| 魅族 17 / 17Pro | Flyme 8 / 9 / 10 | 等待群友提供字段信息 | 4500 / 4500mAh | ⚠️ 待验证 |
| 魅族 16th / 16th Plus / 16X / 16s / 16sPro / 16Xs / 16T | Flyme 7 / 8 / 9 | 需回退原厂固件验证 | - | ⚠️ 若新版本无日志,则变为实验性支持,不主动适配 |
### 状态说明
| 状态 | 含义 |
|---|---|
| ✅ 完美支持 | 已通过实机验证,所有字段完整解析 |
| ⚠️ 实验性支持 | 理论上可支持,但需要用户协助验证 |
| ⚠️ 待验证 | 尚未收到实机日志反馈,待确认 |
| ❌ 不支持 | 系统未生成 BMS 日志,或日志格式无法解析 |
### 特别说明
- **Flyme 版本差异**:同一机型在不同 Flyme 版本下日志格式可能不同。例如 21 Note 在 Flyme 12.x 下可正常读取 `learned_cap`,但在 Flyme 10/11 下日志格式异常。
- **日志停止生成**:部分机型在升级到新系统后(如 17/18 系升级到 Flyme 10),系统不再向 `mbattery_charger` 目录写入新日志,文件夹虽存在但停止更新。
- **适配策略**:优先支持日志格式稳定的 Flyme 12.x 机型,旧版本系统暂不主动适配,若有用户长期停留在旧版本并愿意协助测试,可酌情增加支持。
---
@@ -46,7 +80,8 @@
flutter pub get
# Debug 版本
flutter run
flutter run --debug
# Release APK
flutter build apk --release
flutter build apk --release --target-platform android-arm64
```
+73
View File
@@ -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
}
+16 -14
View File
@@ -1,20 +1,22 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<!--<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE" />-->
<application
android:label="@string/app_name"
android:name="${applicationName}"
android:icon="@drawable/logo"
android:requestLegacyExternalStorage="true">
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTop"
android:taskAffinity=""
android:theme="@style/LaunchTheme"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true"
android:windowSoftInputMode="adjustResize">
<application
android:label="@string/app_name"
android:name="${applicationName}"
android:icon="@drawable/logo"
android:requestLegacyExternalStorage="true"
android:theme="@style/LaunchTheme"> <!-- 全局先用 LaunchTheme -->
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTop"
android:taskAffinity=""
android:theme="@style/AppTheme"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true"
android:windowSoftInputMode="adjustResize">
<!-- Specifies an Android theme to apply to this Activity as soon as
the Android process has started. This theme is visible to the user
while the Flutter UI initializes. After that, this theme continues
@@ -0,0 +1,50 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<!--<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE" />-->
<application
android:label="@string/app_name"
android:name="${applicationName}"
android:icon="@drawable/logo"
android:requestLegacyExternalStorage="true"
android:theme="@style/LaunchTheme"> <!-- 全局先用 LaunchTheme -->
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTop"
android:taskAffinity=""
android:theme="@style/AppTheme"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true"
android:windowSoftInputMode="adjustResize">
<!-- Specifies an Android theme to apply to this Activity as soon as
the Android process has started. This theme is visible to the user
while the Flutter UI initializes. After that, this theme continues
to determine the Window background behind the Flutter UI. -->
<meta-data
android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme"
/>
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
</activity>
<!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
<meta-data
android:name="flutterEmbedding"
android:value="2" />
</application>
<!-- Required to query activities that can process text, see:
https://developer.android.com/training/package-visibility and
https://developer.android.com/reference/android/content/Intent#ACTION_PROCESS_TEXT.
In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. -->
<queries>
<intent>
<action android:name="android.intent.action.PROCESS_TEXT"/>
<data android:mimeType="text/plain"/>
</intent>
</queries>
</manifest>
@@ -11,6 +11,7 @@ import android.os.Build
import android.os.Bundle
import android.provider.DocumentsContract
import android.provider.OpenableColumns
import android.view.Window // 🟢 必须包含
import androidx.activity.ComponentActivity
import androidx.annotation.NonNull
import io.flutter.embedding.android.FlutterActivity
@@ -32,6 +33,9 @@ class MainActivity : FlutterActivity() {
private val PREF_NAME = "battery_health_prefs"
private val KEY_SAF_URI = "saf_uri"
// 🟢 刷新率控制通道
private val FRAME_RATE_CHANNEL = "com.lxh.battery_health/frame_rate"
private val chargeReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
intent ?: return
@@ -44,18 +48,20 @@ class MainActivity : FlutterActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
android.util.Log.d("BatteryHealth", "onCreate 开始")
// 🟢 从 SharedPreferences 恢复 URI
val prefs: SharedPreferences = getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE)
val uriString = prefs.getString(KEY_SAF_URI, null)
if (uriString != null) {
try {
try {
val prefs: SharedPreferences = getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE)
val uriString = prefs.getString(KEY_SAF_URI, null)
android.util.Log.d("BatteryHealth", "SharedPreferences URI: $uriString")
if (uriString != null) {
safUri = Uri.parse(uriString)
android.util.Log.d("BatteryHealth", "恢复 SAF URI: $safUri")
} catch (e: Exception) {
android.util.Log.e("BatteryHealth", "恢复 URI 失败: ${e.message}")
android.util.Log.d("BatteryHealth", "恢复 SAF URI 成功: $safUri")
}
} catch (e: Exception) {
android.util.Log.e("BatteryHealth", "恢复 URI 异常: ${e.message}")
}
android.util.Log.d("BatteryHealth", "onCreate 结束,safUri = $safUri")
}
private fun saveUri(uri: Uri) {
@@ -68,6 +74,17 @@ class MainActivity : FlutterActivity() {
}
}
private fun restoreUri(uriString: String) {
try {
safUri = Uri.parse(uriString)
android.util.Log.d("BatteryHealth", "通过 MethodChannel 恢复 URI: $safUri")
val prefs: SharedPreferences = getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE)
prefs.edit().putString(KEY_SAF_URI, uriString).apply()
} catch (e: Exception) {
android.util.Log.e("BatteryHealth", "restoreUri 失败: ${e.message}")
}
}
private fun sendChargeState(isCharging: Boolean) {
val chargeType = if (isCharging) {
val batteryIntent = registerReceiver(null, IntentFilter(Intent.ACTION_BATTERY_CHANGED))
@@ -125,9 +142,8 @@ class MainActivity : FlutterActivity() {
val takeFlags = Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION
contentResolver.takePersistableUriPermission(uri, takeFlags)
safUri = uri
// 🟢 保存到 SharedPreferences
saveUri(uri)
android.util.Log.d("BatteryHealth", "SAF URI: $uri")
android.util.Log.d("BatteryHealth", "SAF 授权成功 URI: $uri")
safResult?.success(uri.toString())
} catch (e: Exception) {
android.util.Log.e("BatteryHealth", "SAF 授权失败: ${e.message}")
@@ -141,15 +157,13 @@ class MainActivity : FlutterActivity() {
}
private fun getTreeDocId(): String? {
android.util.Log.d("BatteryHealth", "getTreeDocId: safUri = $safUri")
return safUri?.let { DocumentsContract.getTreeDocumentId(it) }
}
private fun listFiles(): List<String> {
android.util.Log.d("BatteryHealth", "listFiles 被调用")
android.util.Log.d("BatteryHealth", "listFiles 被调用safUri = $safUri")
val result = mutableListOf<String>()
val docId = getTreeDocId()
android.util.Log.d("BatteryHealth", "docId = $docId")
if (docId == null || docId.isEmpty()) {
android.util.Log.e("BatteryHealth", "docId 为空")
return result
@@ -157,18 +171,15 @@ class MainActivity : FlutterActivity() {
try {
val childrenUri = DocumentsContract.buildChildDocumentsUriUsingTree(safUri!!, docId)
android.util.Log.d("BatteryHealth", "childrenUri = $childrenUri")
contentResolver.query(childrenUri, null, null, null, null)?.use { cursor ->
val nameIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)
while (cursor.moveToNext()) {
val name = cursor.getString(nameIndex)
if (name != null) {
android.util.Log.d("BatteryHealth", "找到文件: $name")
result.add(name)
}
}
}
android.util.Log.d("BatteryHealth", "共找到 ${result.size} 个文件")
} catch (e: Exception) {
android.util.Log.e("BatteryHealth", "列出文件失败: ${e.message}", e)
}
@@ -214,9 +225,34 @@ class MainActivity : FlutterActivity() {
return fileUri?.let { readFile(it) }
}
// 🟢 刷新率控制方法
// 🟢 刷新率控制方法(使用 preferredRefreshRate,兼容性更好)
private fun setHighRefreshRate() {
try {
val params = window.attributes
params.preferredRefreshRate = 120f
window.attributes = params
android.util.Log.d("BatteryHealth", "setHighRefreshRate: 设置 120Hz")
} catch (e: Exception) {
android.util.Log.e("BatteryHealth", "setHighRefreshRate 失败: ${e.message}")
}
}
private fun clearRefreshRate() {
try {
val params = window.attributes
params.preferredRefreshRate = 0f // 0 表示让系统自动决定
window.attributes = params
android.util.Log.d("BatteryHealth", "clearRefreshRate: 恢复默认")
} catch (e: Exception) {
android.util.Log.e("BatteryHealth", "clearRefreshRate 失败: ${e.message}")
}
}
override fun configureFlutterEngine(@NonNull flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine)
// 充电状态通道
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, CHARGE_CHANNEL)
.setMethodCallHandler { call, result ->
if (call.method == "getChargeState") {
@@ -239,9 +275,19 @@ class MainActivity : FlutterActivity() {
}
}
// SAF 通道
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, SAF_CHANNEL)
.setMethodCallHandler { call, result ->
when (call.method) {
"restoreUri" -> {
val uriString = call.argument<String>("uri")
if (uriString != null) {
restoreUri(uriString)
result.success(true)
} else {
result.error("INVALID_ARGUMENT", "uri is required", null)
}
}
"selectFolder" -> {
safResult = result
openFolderPicker()
@@ -266,6 +312,23 @@ class MainActivity : FlutterActivity() {
}
}
// 🟢 刷新率控制通道
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, FRAME_RATE_CHANNEL)
.setMethodCallHandler { call, result ->
when (call.method) {
"setHighRefreshRate" -> {
setHighRefreshRate()
result.success(true)
}
"clearRefreshRate" -> {
clearRefreshRate()
result.success(true)
}
else -> result.notImplemented()
}
}
// 事件通道
EventChannel(flutterEngine.dartExecutor.binaryMessenger, EVENT_CHANNEL)
.setStreamHandler(
object : EventChannel.StreamHandler {
+15 -13
View File
@@ -1,18 +1,20 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is off -->
<style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar">
<!-- Show a splash screen on the activity. Automatically removed when
the Flutter engine draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item>
<!-- 应用主主题(浅色) -->
<style name="AppTheme" parent="Theme.AppCompat.Light.NoActionBar">
<item name="android:statusBarColor">@android:color/white</item>
<item name="android:windowLightStatusBar">true</item>
<item name="android:windowTranslucentStatus">false</item>
<item name="android:windowTranslucentNavigation">false</item>
<item name="android:navigationBarColor">@android:color/white</item>
<item name="android:windowLightNavigationBar">true</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your
Flutter UI initializes, as well as behind your Flutter UI while its
running.
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
<!-- 启动主题(用于闪屏,保持透明/深色以避免白屏闪烁) -->
<style name="LaunchTheme" parent="Theme.AppCompat.Light.NoActionBar">
<!-- 启动时可保持透明 -->
<item name="android:windowBackground">@android:color/white</item>
<item name="android:statusBarColor">@android:color/white</item>
<item name="android:windowLightStatusBar">true</item>
</style>
</resources>
</resources>
+137
View File
@@ -0,0 +1,137 @@
## [1.6.0] - 2026-07-20
### ✨ 新增
- **魅族 21 Note 机型适配**:新增 `21 Note` 特征字识别,设计容量 5500mAh
- 21 Note 运行 Flyme 12.6 版本,日志格式与 21 系列一致
- 支持 `learned_cap` 字段直接读取,与 21 / 21 Pro 逻辑相同
- 已加入 `DeviceMatching` 机型映射表
- **更新日志页面**:内置 `CHANGELOG.md` 渲染,版本历史一目了然
- 使用 `flutter_markdown` 解析,支持标准 Markdown 格式
- 滚动进度条:实时显示阅读进度,提升浏览体验
- 进入页面自动请求 120Hz 高刷新率,滑动更流畅(Flyme LTPO 适配)
- 滚动时呼吸动画维持高刷感知,停止后自动恢复省电策略
- **AppBar 菜单重构**:刷新按钮左移,右侧新增三点菜单
- `更新日志`:已可用
- `深色模式`:占位(1.7.0 实现)
- `关于`:占位(1.7.0 实现)
- **原生层高刷控制**:新增 `MethodChannel` 通道,支持 Dart 侧动态申请/释放 120Hz
- 进入更新日志页面自动触发
- 退出页面自动释放,不影响系统省电策略
### ✅ 已确认完美支持(更新)
| 机型 | 容量来源 | 设计容量 |
|---|---|---|
| 魅族 20 / 20 Pro / 20 INFINITY | `remaining_cap` + `status=Full` | 4700 / 5000 / 4800 mAh |
| 魅族 21 / 21 Pro | `learned_cap` | 4800 / 5050 mAh |
| **魅族 21 Note(新增)** | `learned_cap` | **5500 mAh** |
### ⚡ 优化
- **UI 拆分**:将 `BatteryHealthPage` 的 UI 部分抽离至独立文件,降低耦合度
- **动画简化**:移除复合动画控制器(`AnimationController`),改用轻量 `AnimatedOpacity` 控制充电条目显隐
- **更新日志性能**:缓存 Markdown 渲染结果,避免重复解析
- **LTPO 适配**:通过进度条微动 + 原生层 `preferredRefreshRate`,解决更新日志页面降频卡顿问题
### 🐛 修复
- 修复更新日志页面因 `ListView` + `shrinkWrap` 导致的 `unbounded height` 渲染异常
- 修复充电条目动画状态残留导致的显示异常
- 修复部分 `TextStyle``color``foreground` 混用导致的渲染断言错误
### 📊 影响范围
- `device_matching.dart`:新增 21 Note 机型映射
- `battery_health_page.dart`AppBar 重构,动画简化
- `battery_data_layout.dart`(新增):UI 布局抽离
- `changelog_page.dart`(新增):更新日志页面
- `MainActivity.kt`:新增原生高刷控制通道
- `pubspec.yaml`:新增 `flutter_markdown` 依赖,添加 `assets/CHANGELOG.md`
### 📌 备注
- 本次更新为 1.6.0 封版版本,后续将聚焦 1.7.0(深色模式 + 关于页面)
- 21 Note 在 Flyme 12.6 上测试通过,日志解析正常 ✅
----
## [1.5.3] - 2026-07-19
- 新增:机型匹配优化
### ✅ 已确认完美支持
- 魅族 20 / 20 Pro / 20 INFINITY(基于remaining_cap + status=full联合判断)
- 魅族 21 / 21 Pro(直接读取learned_cap字段信息)
### ⚠️ 部分支持(测试中)
- 魅族 18 / 18s / 18 Pro / 18s Pro(日志存在,字段解析适配中)
- 魅族 16s(需回退原厂固件验证)
### ❌ 确认不支持
- 魅族 21 Note(星纪团队固件,日志格式乱码无法识别,导致异常)
- 魅族 22(系统未生成 `mbattery_charger` 目录)
### 📌 说明
- 适配状态仅针对 BMS 日志可读性,与手机硬件本身无关
- 部分旧机型(如 16 系)若无日志生成,则无法使用本工具
- 后续若发现其他机型可用,将持续补充
----
## [1.5.2] - 2026-07-14
### 新增
- 浪漫雅圆字体,工程内文件为FlymeFont.ttf
### 🎨 UI 优化
- **字体体系重构**:数字全局替换为 FlymeFont(浪漫雅圆),中文保留 HarmonyOS Sans SC
- 健康度百分比、容量数值、循环次数、电压/温度读数均使用 FlymeFont
- 中文/单位/符号保持 HarmonyOS Sans SC,确保阅读舒适度
- **温标符号修正**:温度显示由 `°C`(度符号 + C)改为专用字符 `℃`U+2103),与 FlymeFont 原生适配
- **容量行视觉权重优化**
- 当前容量与设计容量数字使用 FlymeFont(字号 20,字重 300
- `mAh` 单位改用 HarmonyOS Sans SC(字号 16,字重 500),与 `cycles` 保持一致
- 整行颜色统一为 `rgb(95, 95, 95)`,弱化视觉重量,突出健康度大数字
- **电压/温度极值显示优化**:数值与单位统一使用 FlymeFont,箭头符号保留 HarmonyOS
### 🐛 修复
- 修复 FlymeFont 数字 1 在部分场景下显示为红色的问题(字体内部颜色预设)
- 修复容量行 `designCap`(设计容量)未正确应用 FlymeFont 的问题
- 修复部分 `TextStyle``color``foreground` 混用导致的渲染断言错误
- 统一主界面与二级页面状态栏样式
### 📊 影响范围
- `battery_summary.dart`:健康度/循环/容量显示样式重构
- `battery_info_panel.dart`:电压/温度显示字体统一
- `charge_detail_page.dart`:数值字体统一
- `trend_chart.dart`:坐标轴数字字体统一
### 📌 备注
- 本次更新纯 UI 优化,不影响数据读取与计算逻辑
----
## [1.5.0] - 2026-07-09
### 🐛 修复
- 修复有线充电功率计算单位错误(µV × µA 计算时少除 1000,导致功率放大 1000 倍,属于潜藏bug,外面看不出问题)
- 修复魅族 21 系列 `learned_cap` 因受 `status=Full` 限制导致容量未及时更新的问题
- `learned_cap` 优先级提升:在所有状态行中优先匹配,不再局限于 `status=Full`
### ⚡ 优化
- 有线充电功率显示重构:
- 主界面:`≈{实际功率}w/{请求功率}w`(例:`≈30w/65w`
- 二级页面:拆分为“请求档位”(`≈80w/L-10`)和“实际功率”(`≈30w`)两行
- USB 慢充保持 `≈7.5w` 不变
- UI 统一:
- 统一主界面与二级页面状态栏背景色
- 统一主界面与二级页面 AppBar 背景色为 `Colors.white`
- 状态栏图标保持深色(`Brightness.dark`
## 更早期日志请移步至项目Git Release页面查看
Binary file not shown.
+3
View File
@@ -0,0 +1,3 @@
description: This file stores settings for Dart & Flutter DevTools.
documentation: https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states
extensions:
+54
View File
@@ -0,0 +1,54 @@
/// 容量提取工具
/// 支持两种日志格式:
/// 1. learned_cap21 系列):单位 µAh,除以 1000 得到 mAh
/// 🟢 优先级最高,不受 status 限制(BMS 可能在任意状态行更新此值)
/// 2. remaining_cap20 系列):仅从 status=Full 行提取,单位智能识别(µAh/mAh)
class CapacityExtractor {
static int? extractCapacity(List<String> lines) {
final validLines = lines.where((line) => line.contains('tbat=')).toList();
if (validLines.isEmpty) return null;
// ============================================================
// 🟢 第一优先级:learned_cap21 系列专属)
// 不限制 status,在所有有效行中搜索,取最新的一条
// ============================================================
final learnedReg = RegExp(r'learned_cap=(\d+)');
for (String line in validLines.reversed) {
final match = learnedReg.firstMatch(line);
if (match != null) {
final value = int.parse(match.group(1)!);
// learned_cap 单位固定为 µAh,直接除以 1000 转 mAh
return value ~/ 1000;
}
}
// ============================================================
// 🟢 第二优先级:remaining_cap20 系列回退方案)
// 仅从 status=Full 行提取(充满状态下 BMS 最准确)
// ============================================================
final fullLines = validLines
.where((line) => line.contains('status=Full'))
.toList();
if (fullLines.isEmpty) return null;
final remainingReg = RegExp(r'remaining_cap=(\d+)');
int? maxRemaining;
for (String line in fullLines) {
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
if (maxRemaining > 100000) {
return maxRemaining ~/ 1000;
} else {
return maxRemaining;
}
}
}
+140
View File
@@ -0,0 +1,140 @@
import 'dart:developer' as dev;
import '../utils/time_utils.dart';
/// 充电日志解析器
class ChargeParser {
/// 解析充电数据,返回解析结果
static ParseResult parse(List<String> 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<Map<String, dynamic>> _collectValidLines(
List<String> lines,
DateTime? chargingStartTime,
) {
final result = <Map<String, dynamic>>[];
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) / 1000000000000.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,
);
}
}
+37 -2
View File
@@ -5,7 +5,6 @@ import 'services/saf_storage_service.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
// 🟢 初始化 SAF 服务
await SafStorageService().init();
runApp(const MyApp());
@@ -19,9 +18,45 @@ class MyApp extends StatelessWidget {
return MaterialApp(
title: '电池健康度',
theme: ThemeData(
// 🟢 中文用 HarmonyOS Sans SC
fontFamily: 'HarmonyOS_Sans_SC',
// 🟢 数字回退到 FlymeFont
fontFamilyFallback: const ['Flyme'],
// 🟢 统一文字颜色为黑色
colorScheme: ColorScheme.fromSeed(
seedColor: const Color.fromARGB(255, 255, 255, 255),
seedColor: const Color(0xFFFFFFFF),
).copyWith(onSurface: Colors.black),
textTheme: const TextTheme(
// 大标题/数字(健康度、容量等)
displayMedium: TextStyle(
fontFamily: 'HarmonyOS_Sans_SC',
fontFamilyFallback: ['Flyme'],
color: Colors.black,
),
// 标题(如“容量百分比变动趋势”)
headlineMedium: TextStyle(
fontFamily: 'HarmonyOS_Sans_SC',
fontFamilyFallback: ['Flyme'],
color: Colors.black,
),
// 正文(电压、温度、循环次数等)
bodyMedium: TextStyle(
fontFamily: 'HarmonyOS_Sans_SC',
fontFamilyFallback: ['Flyme'],
color: Colors.black,
),
// 小字(日期、辅助信息)
bodySmall: TextStyle(
fontFamily: 'HarmonyOS_Sans_SC',
fontFamilyFallback: ['Flyme'],
color: Colors.black87,
),
// 数字专用(大号数字,如健康度百分比)
displayLarge: TextStyle(
fontFamily: 'HarmonyOS_Sans_SC',
fontFamilyFallback: ['Flyme'],
color: Colors.black,
),
),
useMaterial3: true,
),
+4 -4
View File
@@ -2,11 +2,11 @@
class DeviceMatching {
/// 根据机型返回设计容量(mAh)
static int getDesignCapacity(String model) {
if (model.contains('22')) return 5510;
if (model.contains('21 Pro')) return 5050;
if (model.contains('21 Note')) return 5500;
if (model.contains('21')) return 4800;
if (model.contains('20 Pro')) return 5000;
if (model.contains('20 INFINITY')) return 4800;
if (model.contains('20 Inf')) return 4800;
if (model.contains('20')) return 4700;
if (model.contains('18s Pro')) return 4500;
if (model.contains('18s')) return 4000;
@@ -29,11 +29,11 @@ class DeviceMatching {
/// 根据机型返回显示名称
static String getModelName(String model) {
if (model.contains('22')) return '魅族22';
if (model.contains('21 Pro')) return '魅族21 Pro';
if (model.contains('21 Note')) return '魅族21 Note';
if (model.contains('21')) return '魅族21';
if (model.contains('20 Pro')) return '魅族20 Pro';
if (model.contains('20 INFINITY')) return '魅族20 INFINITY';
if (model.contains('20 Inf')) return '魅族20 Infinity';
if (model.contains('20')) return '魅族20';
if (model.contains('18s Pro')) return '魅族18s Pro';
if (model.contains('18s')) return '魅族18s';
File diff suppressed because it is too large Load Diff
+205
View File
@@ -0,0 +1,205 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; // 🟢
import 'package:flutter/services.dart' show rootBundle;
import 'package:flutter_markdown/flutter_markdown.dart';
class ChangelogPage extends StatefulWidget {
const ChangelogPage({super.key});
@override
State<ChangelogPage> createState() => _ChangelogPageState();
}
class _ChangelogPageState extends State<ChangelogPage>
with SingleTickerProviderStateMixin {
// ---------- ----------
String _markdownContent = '';
bool _isLoading = true;
String? _error;
Widget? _cachedMarkdown;
// ---------- ----------
final ScrollController _scrollController = ScrollController();
double _scrollProgress = 0.0;
bool _isScrolling = false;
// ---------- ----------
late AnimationController _breathController;
late Animation<double> _breathAnimation;
// ---------- ----------
// 🟢 static final const
static final _frameRateChannel = MethodChannel(
'com.lxh.battery_health/frame_rate',
);
// ---------- ----------
@override
void initState() {
super.initState();
_loadChangelog();
//
_frameRateChannel.invokeMethod('setHighRefreshRate');
//
_breathController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 500),
)..repeat(reverse: true);
_breathAnimation = Tween<double>(begin: 0.0, end: 0.03).animate(
CurvedAnimation(parent: _breathController, curve: Curves.easeInOut),
);
_scrollController.addListener(() {
_updateProgress();
final isScrolling = _scrollController.position.isScrollingNotifier.value;
if (isScrolling != _isScrolling) {
setState(() {
_isScrolling = isScrolling;
});
if (isScrolling) {
_breathController.repeat(reverse: true);
} else {
_breathController.stop();
_breathController.reset();
}
}
});
}
@override
void dispose() {
//
_frameRateChannel.invokeMethod('clearRefreshRate');
_scrollController.removeListener(_updateProgress);
_scrollController.dispose();
_breathController.dispose();
super.dispose();
}
// ---------- ----------
void _updateProgress() {
if (!_scrollController.hasClients) return;
final maxScroll = _scrollController.position.maxScrollExtent;
final currentScroll = _scrollController.position.pixels;
final progress = maxScroll > 0 ? currentScroll / maxScroll : 0.0;
setState(() {
_scrollProgress = progress;
});
}
Future<void> _loadChangelog() async {
try {
final content = await rootBundle.loadString('assets/CHANGELOG.md');
setState(() {
_markdownContent = content;
_isLoading = false;
_cachedMarkdown = _buildMarkdown(content);
});
} catch (e) {
setState(() {
_error = '无法加载更新日志: $e';
_isLoading = false;
});
}
}
Widget _buildMarkdown(String content) {
return Markdown(
data: content,
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
styleSheet: MarkdownStyleSheet(
h1: const TextStyle(
fontFamily: 'HarmonyOS_Sans_SC',
fontSize: 24,
fontWeight: FontWeight.w700,
color: Colors.black,
),
h2: const TextStyle(
fontFamily: 'HarmonyOS_Sans_SC',
fontSize: 20,
fontWeight: FontWeight.w600,
color: Colors.black87,
),
h3: const TextStyle(
fontFamily: 'HarmonyOS_Sans_SC',
fontSize: 18,
fontWeight: FontWeight.w600,
color: Colors.black87,
),
p: const TextStyle(
fontFamily: 'HarmonyOS_Sans_SC',
fontSize: 16,
color: Colors.black87,
),
listBullet: const TextStyle(
fontFamily: 'HarmonyOS_Sans_SC',
fontSize: 16,
color: Colors.black87,
),
code: const TextStyle(
fontFamily: 'HarmonyOS_Sans_SC',
fontSize: 14,
color: Colors.black87,
backgroundColor: Color(0xFFF5F5F5),
),
),
);
}
// ---------- Build ----------
@override
Widget build(BuildContext context) {
final breathOffset = _isScrolling ? _breathAnimation.value : 0.0;
final displayProgress = (_scrollProgress + breathOffset).clamp(0.0, 1.0);
return Scaffold(
backgroundColor: Colors.white,
appBar: AppBar(
title: const Text(
'更新日志',
style: TextStyle(
fontFamily: 'HarmonyOS_Sans_SC',
color: Colors.black,
),
),
backgroundColor: Colors.white,
elevation: 0,
foregroundColor: Colors.black,
iconTheme: const IconThemeData(color: Colors.black),
bottom: PreferredSize(
preferredSize: const Size.fromHeight(2.0),
child: LinearProgressIndicator(
value: displayProgress,
backgroundColor: Colors.grey.shade200,
valueColor: const AlwaysStoppedAnimation<Color>(
Color.fromRGBO(157, 212, 237, 1.0),
),
minHeight: 2.0,
),
),
),
body: _isLoading
? const Center(child: CircularProgressIndicator())
: _error != null
? Center(
child: Text(
_error!,
style: const TextStyle(
fontFamily: 'HarmonyOS_Sans_SC',
color: Colors.grey,
),
),
)
: SingleChildScrollView(
controller: _scrollController,
padding: const EdgeInsets.all(16.0),
child: _cachedMarkdown ?? const SizedBox.shrink(),
),
);
}
}
+107
View File
@@ -0,0 +1,107 @@
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<void> 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;
// 🟢 1.4.5 使 CapacityExtractor Full
int? cap = CapacityExtractor.extractCapacity(lines);
// 🟢 cap == null
if (cap == null) {
dev.log('🔵 日期 $dateStr 无充满记录,尝试复制前一天数据', name: 'BatteryHealth');
//
final recentData = await _dbService.queryRecentData(1);
if (recentData.isNotEmpty) {
final yesterdayCap = recentData.first['capacity'] as int;
final yesterdayMaxVolt =
recentData.first['volt_max'] as double? ?? 0.0;
final yesterdayMinVolt =
recentData.first['volt_min'] as double? ?? 0.0;
dev.log(
'🔵 复制前一天数据: cap=$yesterdayCap, date=$dateStr',
name: 'BatteryHealth',
);
// 🟢
await _dbService.insertOrUpdate(
dateStr,
yesterdayCap,
yesterdayMaxVolt,
yesterdayMinVolt,
);
} else {
dev.log('🔵 无历史数据可复制,跳过日期 $dateStr', name: 'BatteryHealth');
}
continue; //
}
// Full
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<List<Map<String, dynamic>>> getTrendData(int days) async {
return await _dbService.queryRecentData(days);
}
///
Future<Map<String, dynamic>?> getLatestData() async {
final data = await _dbService.queryRecentData(1);
if (data.isNotEmpty) return data.first;
return null;
}
}
+130
View File
@@ -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<Database> get database async {
if (_database != null) return _database!;
_database = await _initDatabase();
return _database!;
}
///
Future<Database> _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<void> 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<List<Map<String, dynamic>>> 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 [];
}
}
/// 🟢 1.4.5
Future<Map<String, dynamic>?> 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<void> deleteByDate(String date) async {
final db = await database;
await db.delete(_tableName, where: 'date = ?', whereArgs: [date]);
}
///
Future<List<String>> 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<void> clearAll() async {
final db = await database;
await db.delete(_tableName);
dev.log('🗑️ 所有数据已清空', name: 'DatabaseService');
}
///
Future<void> close() async {
if (_database != null) {
await _database!.close();
_database = null;
}
}
}
+54 -7
View File
@@ -3,6 +3,8 @@ import 'package:flutter/services.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../models/saf_permission_state.dart';
/// SAF
/// SAF 访
class SafStorageService {
static final SafStorageService _instance = SafStorageService._internal();
factory SafStorageService() => _instance;
@@ -14,7 +16,7 @@ class SafStorageService {
static const String _prefKeySafUri = 'saf_storage_uri';
String? _cachedUri;
// 🟢 SharedPreferences URI
/// SharedPreferences URI
Future<void> init() async {
try {
final prefs = await SharedPreferences.getInstance();
@@ -30,14 +32,16 @@ class SafStorageService {
}
}
///
Future<SafPermissionState> getPermissionState() async {
if (_cachedUri == null || _cachedUri!.isEmpty) {
return SafPermissionState.unauthorized;
}
// 🟢 authorized
// authorized
return SafPermissionState.authorized;
}
/// SAF
Future<SafAuthorizationResult> requestPermission() async {
try {
final String? uriString = await _channel.invokeMethod('selectFolder');
@@ -62,23 +66,57 @@ class SafStorageService {
}
}
/// 🟢 URI Kotlin safUri
Future<bool> restoreUri() async {
if (_cachedUri == null || _cachedUri!.isEmpty) {
if (kDebugMode) {
print('[SAF] restoreUri: 缓存 URI 为空');
}
return false;
}
try {
final bool? result = await _channel.invokeMethod('restoreUri', {
'uri': _cachedUri,
});
if (result == true) {
if (kDebugMode) {
print('[SAF] restoreUri 成功: $_cachedUri');
}
return true;
} else {
if (kDebugMode) {
print('[SAF] restoreUri 失败: 返回 false');
}
return false;
}
} catch (e) {
if (kDebugMode) {
print('[SAF] restoreUri 异常: $e');
}
return false;
}
}
///
Future<bool> logFileExists(String fileName) async {
final state = await getPermissionState();
if (state != SafPermissionState.authorized) return false;
try {
final files = await _channel.invokeMethod('listFiles');
if (files is List) {
return files.contains(fileName);
}
return false;
final files = await listFiles();
return files.contains(fileName);
} catch (e) {
return false;
}
}
///
Future<List<String>> readLogFile(String fileName) async {
// 🟢 URI
await restoreUri();
final state = await getPermissionState();
if (state != SafPermissionState.authorized) return [];
try {
final String? content = await _channel.invokeMethod('readLogFile', {
'fileName': fileName,
@@ -93,9 +131,14 @@ class SafStorageService {
}
}
///
Future<List<String>> listFiles() async {
// 🟢 URI
await restoreUri();
final state = await getPermissionState();
if (state != SafPermissionState.authorized) return [];
try {
final result = await _channel.invokeMethod('listFiles');
if (result is List) {
@@ -110,16 +153,19 @@ class SafStorageService {
}
}
///
Future<bool> validatePermission() async {
final state = await getPermissionState();
return state == SafPermissionState.authorized;
}
///
void _clearCache() {
_cachedUri = null;
_saveUri(null);
}
/// URI SharedPreferences
Future<void> _saveUri(String? uri) async {
try {
final prefs = await SharedPreferences.getInstance();
@@ -141,5 +187,6 @@ class SafStorageService {
}
}
/// URI
String? get cachedUri => _cachedUri;
}
+53 -6
View File
@@ -1,6 +1,7 @@
import '../constants/charge_maps.dart';
class ChargeUtils {
// ============ 线 ============
static String getDisplayPowerWireless(double power) {
if (power == 0) return '读取中...';
const List<int> levels = [5, 7, 15, 27, 40, 50, 66];
@@ -12,21 +13,17 @@ class ChargeUtils {
return '${matchedLevel}W';
}
// ============ 线 ============
static String getDisplayPowerWired(
double power,
int? wiredLevel,
String chargeType,
) {
// USB 7.5W
if (chargeType == "SDP") return "≈7.5W";
// 🟢 使 wiredLevel
if (wiredLevel != null && ChargeMaps.wiredPower.containsKey(wiredLevel)) {
double p = ChargeMaps.wiredPower[wiredLevel]!;
if (p > 0) return '${p.toInt()}W';
}
// 🟢 ±3W
double best = 0;
for (var entry in ChargeMaps.wiredPower.entries) {
if (entry.value <= power + 3 && entry.value > best) {
@@ -37,11 +34,61 @@ class ChargeUtils {
return best > 0 ? '${best.toInt()}W' : '${power.round()}W';
}
// ============ 🟢 1.5.0 / ============
/// 30w/65w
static String getMainDisplayPowerWired(
double power,
int? wiredLevel,
String chargeType,
) {
// USB 7.5W
if (chargeType == "SDP") return "≈7.5w";
if (power == 0) return "读取中...";
final actual = power.round();
//
if (wiredLevel != null && ChargeMaps.wiredPower.containsKey(wiredLevel)) {
final requested = ChargeMaps.wiredPower[wiredLevel]!.round();
return "${actual}w/${requested}w";
}
//
return "${actual}w";
}
// ============ 🟢 1.5.0 ============
/// 80w/L-10
static String getDetailRequestedPowerWired(
double power,
int? wiredLevel,
String chargeType,
) {
if (chargeType == "SDP") return "≈7.5w";
if (power == 0) return "读取中...";
if (wiredLevel != null && ChargeMaps.wiredPower.containsKey(wiredLevel)) {
final requested = ChargeMaps.wiredPower[wiredLevel]!.round();
return "${requested}w/L-$wiredLevel";
}
return "${power.round()}w";
}
// ============ 🟢 1.5.0 ============
static String getDetailActualPowerWired(double power, String chargeType) {
if (chargeType == "SDP") return "≈7.5w";
if (power == 0) return "读取中...";
return "${power.round()}w";
}
// ============ ============
static String getChargeTypeLabel(bool isWireless, String chargeType) {
if (isWireless) {
return chargeType == "无线" ? "无线" : chargeType;
}
// 🟢 "识别中...""等待log信息"
if (chargeType.isEmpty) {
return "识别中...";
}
+46
View File
@@ -0,0 +1,46 @@
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';
}
}
+151
View File
@@ -0,0 +1,151 @@
import 'package:flutter/material.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';
///
class BatteryDataLayout extends StatelessWidget {
final String deviceModel;
final double healthPercent;
final int cycleCount;
final double currentCap;
final int designCap;
final List<Map<String, dynamic>> trendData;
final bool isCharging;
final String chargeSummary;
final double batteryVolt;
final double batteryTemp;
final double maxVolt;
final double minVolt;
final double maxTemp;
final double minTemp;
final String chargeType;
final bool isWireless;
final double chargePower;
final double chargeVolt;
final double chargeCurr;
final int? wiredLevel;
const BatteryDataLayout({
super.key,
required this.deviceModel,
required this.healthPercent,
required this.cycleCount,
required this.currentCap,
required this.designCap,
required this.trendData,
required this.isCharging,
required this.chargeSummary,
required this.batteryVolt,
required this.batteryTemp,
required this.maxVolt,
required this.minVolt,
required this.maxTemp,
required this.minTemp,
required this.chargeType,
required this.isWireless,
required this.chargePower,
required this.chargeVolt,
required this.chargeCurr,
this.wiredLevel,
});
@override
Widget build(BuildContext context) {
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),
BatterySummary(
healthPercent: healthPercent,
cycleCount: cycleCount,
currentCap: currentCap,
designCap: designCap,
),
const SizedBox(height: 24),
TrendChart(data: trendData, designCap: designCap),
const SizedBox(height: 8),
// +
_buildChargeAndInfoPanel(context),
const Spacer(),
],
),
);
}
Widget _buildChargeAndInfoPanel(BuildContext context) {
return Column(
children: [
// /
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,
),
),
);
}
},
),
const SizedBox(height: 4),
BatteryInfoPanel(
batteryVolt: batteryVolt,
batteryTemp: batteryTemp,
maxVolt: maxVolt,
minVolt: minVolt,
maxTemp: maxTemp,
minTemp: minTemp,
),
],
);
}
}
///
class EmptyStateLayout extends StatelessWidget {
const EmptyStateLayout({super.key});
@override
Widget build(BuildContext context) {
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)),
],
),
);
}
}
+194
View File
@@ -0,0 +1,194 @@
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: [
RichText(
text: TextSpan(
style: TextStyle(
fontFamily: 'Flyme',
fontSize: 14,
fontWeight: FontWeight.w500,
color: Colors.red,
),
children: [
const TextSpan(
text: '',
style: TextStyle(
fontFamily: 'HarmonyOS_Sans_SC',
color: Colors.red,
),
),
TextSpan(text: '${maxVolt.toStringAsFixed(3)}V'),
],
),
),
const SizedBox(width: 12),
RichText(
text: TextSpan(
style: TextStyle(
fontFamily: 'Flyme',
fontSize: 14,
fontWeight: FontWeight.w500,
color: Colors.blue,
),
children: [
const TextSpan(
text: '',
style: TextStyle(
fontFamily: 'HarmonyOS_Sans_SC',
color: Colors.blue,
),
),
TextSpan(text: '${minVolt.toStringAsFixed(3)}V'),
],
),
),
],
),
],
),
),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildInfoItem(
_buildIcon('temperature'),
'温度',
'${batteryTemp.toStringAsFixed(0)}',
),
const SizedBox(height: 8),
Row(
children: [
RichText(
text: TextSpan(
style: TextStyle(
fontFamily: 'Flyme',
fontSize: 14,
fontWeight: FontWeight.w500,
color: Colors.red,
),
children: [
const TextSpan(
text: '',
style: TextStyle(
fontFamily: 'HarmonyOS_Sans_SC',
color: Colors.red,
),
),
TextSpan(text: '${maxTemp.toStringAsFixed(0)}'),
],
),
),
const SizedBox(width: 12),
RichText(
text: TextSpan(
style: TextStyle(
fontFamily: 'Flyme',
fontSize: 14,
fontWeight: FontWeight.w500,
color: Colors.blue,
),
children: [
const TextSpan(
text: '',
style: TextStyle(
fontFamily: 'HarmonyOS_Sans_SC',
color: Colors.blue,
),
),
TextSpan(text: '${minTemp.toStringAsFixed(0)}'),
],
),
),
],
),
],
),
),
],
);
}
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(
fontFamily: 'HarmonyOS_Sans_SC',
fontSize: 14,
color: Colors.grey,
),
),
const SizedBox(width: 6),
Text(
value,
style: const TextStyle(
fontFamily: 'Flyme',
fontSize: 16,
fontWeight: FontWeight.w500,
color: Colors.black,
),
),
],
);
}
}
+115
View File
@@ -0,0 +1,115 @@
import 'package:flutter/material.dart';
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) {
final bool isLowHealth = healthPercent < 70;
return Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
//
Row(
crossAxisAlignment: CrossAxisAlignment.baseline,
textBaseline: TextBaseline.alphabetic,
children: [
Text(
healthPercent.toStringAsFixed(1),
style: TextStyle(
fontFamily: 'Flyme',
fontSize: 40,
fontWeight: FontWeight.w500,
height: 1.0,
color: isLowHealth ? Colors.red : Colors.black,
),
),
const SizedBox(width: 2),
Text(
'%',
style: TextStyle(
fontFamily: 'Flyme',
fontSize: 20,
fontWeight: FontWeight.w300,
height: 1.0,
color: isLowHealth ? Colors.red : Colors.grey.shade600,
),
),
],
),
const Spacer(),
// cycles + mAh
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
RichText(
text: TextSpan(
style: TextStyle(
fontFamily: 'Flyme',
fontSize: 16,
fontWeight: FontWeight.w500,
height: 1.2,
color: Colors.black,
),
children: [
TextSpan(text: '$cycleCount'),
TextSpan(
text: ' cycles',
style: const TextStyle(
fontFamily: 'HarmonyOS_Sans_SC',
color: Colors.black,
),
),
],
),
),
const SizedBox(height: 4),
RichText(
text: TextSpan(
style: TextStyle(
fontFamily: 'Flyme',
fontSize: 20,
fontWeight: FontWeight.w300,
height: 1.0,
color: const Color(0xFF5F5F5F),
),
children: [
TextSpan(text: currentCap.toStringAsFixed(0)),
TextSpan(
text: ' / ',
style: TextStyle(
fontFamily: 'HarmonyOS_Sans_SC',
color: const Color(0xFF5F5F5F).withValues(alpha: 0.5),
),
),
TextSpan(text: designCap.toString()),
TextSpan(
text: ' mAh',
style: const TextStyle(
fontFamily: 'HarmonyOS_Sans_SC',
fontSize: 16,
fontWeight: FontWeight.w500,
color: Color(0xFF5F5F5F),
),
),
],
),
),
],
),
],
);
}
}
+113 -42
View File
@@ -1,7 +1,7 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../utils/charge_utils.dart';
///
class ChargeDetailPage extends StatelessWidget {
final String chargeType;
final bool isWireless;
@@ -20,62 +20,91 @@ class ChargeDetailPage extends StatelessWidget {
this.wiredLevel,
});
String _getDisplayPower() {
if (isWireless) {
return ChargeUtils.getDisplayPowerWireless(power);
} else {
return ChargeUtils.getDisplayPowerWired(power, wiredLevel, chargeType);
}
}
String _getProtocolDisplay() {
if (isWireless) return '无线充电($chargeType';
if (chargeType == 'PPS') return 'PPS 快充';
if (chargeType == 'SDP') return 'USB 充电'; // 🟢
if (chargeType == 'SDP') return 'USB 充电';
return chargeType;
}
String _getRequestedDisplay() {
return ChargeUtils.getDetailRequestedPowerWired(
power,
wiredLevel,
chargeType,
);
}
String _getActualDisplay() {
return ChargeUtils.getDetailActualPowerWired(power, chargeType);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('充电详情'),
backgroundColor: const Color.fromARGB(0, 255, 255, 255),
elevation: 0,
foregroundColor: Colors.black,
iconTheme: const IconThemeData(color: Colors.black),
return AnnotatedRegion<SystemUiOverlayStyle>(
value: const SystemUiOverlayStyle(
statusBarColor: Color.fromRGBO(157, 212, 237, 0.1),
statusBarIconBrightness: Brightness.dark,
statusBarBrightness: Brightness.dark,
),
body: Padding(
padding: const EdgeInsets.all(20.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildDetailRow('协议', _getProtocolDisplay()),
const Divider(),
_buildDetailRow('功率', _getDisplayPower()),
const Divider(),
_buildDetailRow('电压', '${volt.toStringAsFixed(2)}V'),
const Divider(),
_buildDetailRow('电流', '${curr.toStringAsFixed(2)}A'),
const Divider(),
if (!isWireless && wiredLevel != null)
_buildDetailRow('档位', '$wiredLevel'),
if (!isWireless && wiredLevel == null) _buildDetailRow('档位', '自动'),
if (isWireless) _buildDetailRow('类型', '无线'),
const Spacer(),
Center(
child: Text(
'数据来自 BMS 实时日志',
style: TextStyle(fontSize: 12, color: Colors.grey.shade500),
child: Scaffold(
backgroundColor: Colors.white,
appBar: AppBar(
title: const Text('充电详情'),
backgroundColor: Colors.white,
elevation: 0,
scrolledUnderElevation: 0,
shadowColor: Colors.transparent,
surfaceTintColor: Colors.transparent,
foregroundColor: Colors.black,
iconTheme: const IconThemeData(color: Colors.black),
),
body: Padding(
padding: const EdgeInsets.all(20.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildTextRow('协议', _getProtocolDisplay()),
const Divider(),
if (!isWireless) ...[
_buildTextRow('标定档位', _getRequestedDisplay()),
const Divider(),
_buildTextRow('实际功率', _getActualDisplay()),
const Divider(),
] else ...[
_buildTextRow('功率', ChargeUtils.getDisplayPowerWireless(power)),
const Divider(),
],
_buildDetailRow('电压', volt, 'V'),
const Divider(),
_buildDetailRow('电流', curr, 'A'),
const Divider(),
if (isWireless) _buildTextRow('类型', '无线'),
if (!isWireless && wiredLevel == null) _buildTextRow('档位', '自动'),
const Spacer(),
Center(
child: Text(
'数据来自 BMS 实时日志',
style: TextStyle(
fontFamily: 'HarmonyOS_Sans_SC',
fontSize: 12,
color: Colors.grey.shade500,
),
),
),
),
],
],
),
),
),
);
}
Widget _buildDetailRow(String label, String value) {
///
Widget _buildTextRow(String label, String value) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 6.0),
child: Row(
@@ -84,6 +113,7 @@ class ChargeDetailPage extends StatelessWidget {
Text(
label,
style: const TextStyle(
fontFamily: 'HarmonyOS_Sans_SC',
fontSize: 16,
fontWeight: FontWeight.w400,
color: Colors.grey,
@@ -92,6 +122,7 @@ class ChargeDetailPage extends StatelessWidget {
Text(
value,
style: const TextStyle(
fontFamily: 'HarmonyOS_Sans_SC', // Harmony
fontSize: 16,
fontWeight: FontWeight.w500,
color: Colors.black87,
@@ -101,4 +132,44 @@ class ChargeDetailPage extends StatelessWidget {
),
);
}
/// / Flyme Harmony
Widget _buildDetailRow(String label, double value, String unit) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 6.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
label,
style: const TextStyle(
fontFamily: 'HarmonyOS_Sans_SC',
fontSize: 16,
fontWeight: FontWeight.w400,
color: Colors.grey,
),
),
RichText(
text: TextSpan(
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w500,
color: Colors.black87,
),
children: [
TextSpan(
text: value.toStringAsFixed(2),
style: const TextStyle(fontFamily: 'Flyme'), // 🟢 Flyme
),
TextSpan(
text: unit,
style: const TextStyle(fontFamily: 'HarmonyOS_Sans_SC'),
),
],
),
),
],
),
);
}
}
+58
View File
@@ -0,0 +1,58 @@
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(
fontFamily: 'HarmonyOS_Sans_SC', // 🟢 Harmony
fontSize: 16,
fontWeight: FontWeight.w500,
color: Colors.black87,
),
),
Row(
children: [
Text(
'充电信息',
style: TextStyle(
fontFamily: 'HarmonyOS_Sans_SC',
fontSize: 16,
fontWeight: FontWeight.w500,
color: Colors.grey.shade700,
),
),
const SizedBox(width: 4),
Text(
'>',
style: TextStyle(
fontFamily: 'HarmonyOS_Sans_SC',
fontSize: 16,
fontWeight: FontWeight.w500,
color: Colors.grey.shade700,
),
),
],
),
],
),
);
}
}
+234
View File
@@ -0,0 +1,234 @@
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<Map<String, dynamic>> data;
final int designCap;
const TrendChart({super.key, required this.data, required this.designCap});
@override
Widget build(BuildContext context) {
List<Map<String, dynamic>> displayData = data.isNotEmpty
? data
: _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(
fontFamily: 'HarmonyOS_Sans_SC',
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(
fontFamily: 'Flyme',
fontSize: 10,
color: Colors.grey,
height: 1.0,
),
),
Text(
'70',
style: TextStyle(
fontFamily: 'Flyme',
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(
fontFamily: 'HarmonyOS_Sans_SC',
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(
fontFamily: 'HarmonyOS_Sans_SC',
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(
fontFamily: 'HarmonyOS_Sans_SC',
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]},
);
}
}
+16
View File
@@ -126,6 +126,14 @@ packages:
url: "https://pub.flutter-io.cn"
source: hosted
version: "5.0.0"
flutter_markdown:
dependency: "direct main"
description:
name: flutter_markdown
sha256: "08fb8315236099ff8e90cb87bb2b935e0a724a3af1623000a9cec930468e0f27"
url: "https://pub.flutter-io.cn"
source: hosted
version: "0.7.7+1"
flutter_svg:
dependency: "direct main"
description:
@@ -224,6 +232,14 @@ packages:
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.3.0"
markdown:
dependency: transitive
description:
name: markdown
sha256: ee85086ad7698b42522c6ad42fe195f1b9898e4d974a1af4576c1a3a176cada9
url: "https://pub.flutter-io.cn"
source: hosted
version: "7.3.1"
matcher:
dependency: transitive
description:
+6 -1
View File
@@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
# In Windows, build-name is used as the major, minor, and patch parts
# of the product and file versions while build-number is used as the build suffix.
version: 1.3.0-dev.4
version: 1.6.0
environment:
sdk: ^3.9.2
@@ -38,6 +38,7 @@ dependencies:
path: ^1.9.0
flutter_svg: ^2.0.10+1
shared_preferences: ^2.2.0
flutter_markdown: ^0.7.0
# The following adds the Cupertino Icons font to your application.
@@ -75,8 +76,12 @@ flutter:
weight: 400
- asset: assets/fonts/HarmonyOS_Sans_SC_Medium.ttf
weight: 500
- family: Flyme
fonts:
- asset: assets/fonts/flymeFont.ttf
assets:
- assets/icons/
- assets/CHANGELOG.md
# To add assets to your application, add an assets section, like this:
# assets: