11 Commits

24 changed files with 1990 additions and 1216 deletions
+2 -2
View File
@@ -24,8 +24,8 @@
| 版本 | 主题 | 状态 | | 版本 | 主题 | 状态 |
| :--- | :--- | :--- | | :--- | :--- | :--- |
| 1.3.0 | 时间窗口 + real_type 优先级 | ✅ 已发布 | | 1.4.0 | SAF 权限改造 | ✅ 已发布 |
| 1.4.0 | SAF 权限改造 | 📋 规划中 | | 1.4.5 | 魅族20系补充适配调整 | ✅ 已发布 |
| 1.5.0 | 功率计算优化 | 📋 规划中 | | 1.5.0 | 功率计算优化 | 📋 规划中 |
| 2.0.0 | 数据看板扩展(月/季/年趋势) | 📋 远景 | | 2.0.0 | 数据看板扩展(月/季/年趋势) | 📋 远景 |
+20 -23
View File
@@ -6,7 +6,7 @@ plugins {
android { android {
namespace = "com.lxh.battery_health" namespace = "com.lxh.battery_health"
compileSdk = flutter.compileSdkVersion compileSdk = 34
ndkVersion = flutter.ndkVersion ndkVersion = flutter.ndkVersion
compileOptions { compileOptions {
@@ -21,38 +21,39 @@ android {
defaultConfig { defaultConfig {
applicationId = "com.lxh.battery_health" applicationId = "com.lxh.battery_health"
minSdk = flutter.minSdkVersion minSdk = flutter.minSdkVersion
targetSdk = flutter.targetSdkVersion targetSdk = 34
versionCode = flutter.versionCode versionCode = flutter.versionCode
versionName = flutter.versionName versionName = flutter.versionName
multiDexEnabled = true multiDexEnabled = true
} }
signingConfigs { signingConfigs {
// 如果没有正式签名,可以用 debug 签名代替(测试用)
// 正式发布时请替换为自己的密钥
getByName("debug") {
// debug 签名已经存在,无需额外配置
}
create("release") { create("release") {
// 这里配置你正式发布的签名 // 如果有正式签名,在这里配置
// 如果还没有正式签名,debug 可以用 debug 签名替代 // keyAlias = "..."
keyAlias = "your_key_alias" // keyPassword = "..."
keyPassword = "your_key_password" // storeFile = file("...")
storeFile = file("your_keystore.jks") // storePassword = "..."
storePassword = "your_store_password"
} }
} }
signingConfigs {
// 如果没有正式签名,可以暂时用 debug 签名
// 正式发布时再替换
}
buildTypes { buildTypes {
debug { debug {
// 🟢 关键:debug 版本包名加 .dev 后缀
applicationIdSuffix = ".dev" applicationIdSuffix = ".dev"
signingConfig = signingConfigs.getByName("debug") signingConfig = signingConfigs.getByName("debug")
isDebuggable = true
isMinifyEnabled = false
isShrinkResources = false
} }
release { release {
applicationIdSuffix = "" // 保持原包名 // release 版本不加后缀,保持原包名
// 🟢 如果还没有正式签名,先用 debug 签名替代 signingConfig = signingConfigs.getByName("debug") // 若没有正式签名,先用 debug 签名
signingConfig = signingConfigs.getByName("debug")
isMinifyEnabled = true isMinifyEnabled = true
isShrinkResources = true isShrinkResources = true
proguardFiles( proguardFiles(
@@ -61,16 +62,12 @@ android {
) )
} }
} }
}
// 如果有 productFlavors,可以在这里配置
// 如果没有,忽略即可
}
flutter { flutter {
source = "../.." source = "../.."
} }
dependencies { dependencies {
// 如果项目中有其他依赖,添加在这里 // 无需额外添加 Activity KTX,因为已改用 startActivityForResult
// 注意:sqflite、permission_handler 等 Flutter 插件会自动包含
} }
+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
}
+5 -3
View File
@@ -1,17 +1,19 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"> <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.READ_EXTERNAL_STORAGE" />
<!--<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE" />--> <!--<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE" />-->
<application <application
android:label="@string/app_name" android:label="@string/app_name"
android:name="${applicationName}" android:name="${applicationName}"
android:icon="@drawable/logo" android:icon="@drawable/logo"
android:requestLegacyExternalStorage="true"> android:requestLegacyExternalStorage="true"
android:theme="@style/LaunchTheme"> <!-- 全局先用 LaunchTheme -->
<activity <activity
android:name=".MainActivity" android:name=".MainActivity"
android:exported="true" android:exported="true"
android:launchMode="singleTop" android:launchMode="singleTop"
android:taskAffinity="" android:taskAffinity=""
android:theme="@style/LaunchTheme" android:theme="@style/AppTheme"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode" android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true" android:hardwareAccelerated="true"
android:windowSoftInputMode="adjustResize"> android:windowSoftInputMode="adjustResize">
@@ -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>
@@ -1,96 +0,0 @@
package com.lxh.battery_health
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.os.BatteryManager
import android.os.Bundle
import androidx.annotation.NonNull
import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.EventChannel
import io.flutter.plugin.common.MethodChannel
class MainActivity : FlutterActivity() {
private val CHANNEL = "com.lxh.battery_health/charge"
private val EVENT_CHANNEL = "com.lxh.battery_health/charge_events"
private var eventSink: EventChannel.EventSink? = null
private val chargeReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
intent ?: return
when (intent.action) {
Intent.ACTION_POWER_CONNECTED -> sendChargeState(true)
Intent.ACTION_POWER_DISCONNECTED -> sendChargeState(false)
}
}
}
private fun sendChargeState(isCharging: Boolean) {
val chargeType = if (isCharging) {
val batteryIntent = registerReceiver(null, IntentFilter(Intent.ACTION_BATTERY_CHANGED))
val plug = batteryIntent?.getIntExtra(BatteryManager.EXTRA_PLUGGED, -1) ?: -1
android.util.Log.d("BatteryHealth", "plug value: $plug, isCharging: $isCharging")
when (plug) {
BatteryManager.BATTERY_PLUGGED_AC -> "AC"
BatteryManager.BATTERY_PLUGGED_USB -> "USB"
BatteryManager.BATTERY_PLUGGED_WIRELESS -> "WIRELESS" // 🟢 关键修复
else -> "UNKNOWN"
}
} else {
"NONE"
}
eventSink?.success(mapOf("isCharging" to isCharging, "chargeType" to chargeType))
}
override fun configureFlutterEngine(@NonNull flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine)
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, CHANNEL).setMethodCallHandler { call, result ->
if (call.method == "getChargeState") {
val isCharging = getBatteryStatus()
val chargeType = if (isCharging) {
val batteryIntent = registerReceiver(null, IntentFilter(Intent.ACTION_BATTERY_CHANGED))
val plug = batteryIntent?.getIntExtra(BatteryManager.EXTRA_PLUGGED, -1) ?: -1
when (plug) {
BatteryManager.BATTERY_PLUGGED_AC -> "AC"
BatteryManager.BATTERY_PLUGGED_USB -> "USB"
BatteryManager.BATTERY_PLUGGED_WIRELESS -> "WIRELESS" // 🟢 关键修复
else -> "UNKNOWN"
}
} else {
"NONE"
}
result.success(mapOf("isCharging" to isCharging, "chargeType" to chargeType))
} else {
result.notImplemented()
}
}
EventChannel(flutterEngine.dartExecutor.binaryMessenger, EVENT_CHANNEL).setStreamHandler(
object : EventChannel.StreamHandler {
override fun onListen(arguments: Any?, events: EventChannel.EventSink?) {
eventSink = events
val filter = IntentFilter().apply {
addAction(Intent.ACTION_POWER_CONNECTED)
addAction(Intent.ACTION_POWER_DISCONNECTED)
}
registerReceiver(chargeReceiver, filter)
}
override fun onCancel(arguments: Any?) {
eventSink = null
unregisterReceiver(chargeReceiver)
}
}
)
}
private fun getBatteryStatus(): Boolean {
val batteryIntent = registerReceiver(null, IntentFilter(Intent.ACTION_BATTERY_CHANGED))
val status = batteryIntent?.getIntExtra(BatteryManager.EXTRA_STATUS, -1) ?: -1
return status == BatteryManager.BATTERY_STATUS_CHARGING ||
status == BatteryManager.BATTERY_STATUS_FULL
}
}
@@ -0,0 +1,305 @@
package com.lxh.battery_health
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.content.SharedPreferences
import android.net.Uri
import android.os.BatteryManager
import android.os.Build
import android.os.Bundle
import android.provider.DocumentsContract
import android.provider.OpenableColumns
import androidx.activity.ComponentActivity
import androidx.annotation.NonNull
import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.EventChannel
import io.flutter.plugin.common.MethodChannel
import java.io.BufferedReader
import java.io.InputStreamReader
class MainActivity : FlutterActivity() {
private val CHARGE_CHANNEL = "com.lxh.battery_health/charge"
private val EVENT_CHANNEL = "com.lxh.battery_health/charge_events"
private var eventSink: EventChannel.EventSink? = null
private val SAF_CHANNEL = "com.lxh.battery_health/saf"
private var safResult: MethodChannel.Result? = null
private var safUri: Uri? = null
private val FOLDER_PICKER_REQUEST = 1001
private val PREF_NAME = "battery_health_prefs"
private val KEY_SAF_URI = "saf_uri"
private val chargeReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
intent ?: return
when (intent.action) {
Intent.ACTION_POWER_CONNECTED -> sendChargeState(true)
Intent.ACTION_POWER_DISCONNECTED -> sendChargeState(false)
}
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
android.util.Log.d("BatteryHealth", "onCreate 开始")
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", "onCreate 结束,safUri = $safUri")
}
private fun saveUri(uri: Uri) {
try {
val prefs: SharedPreferences = getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE)
prefs.edit().putString(KEY_SAF_URI, uri.toString()).apply()
android.util.Log.d("BatteryHealth", "保存 SAF URI: $uri")
} catch (e: Exception) {
android.util.Log.e("BatteryHealth", "保存 URI 失败: ${e.message}")
}
}
private fun restoreUri(uriString: String) {
try {
safUri = Uri.parse(uriString)
android.util.Log.d("BatteryHealth", "通过 MethodChannel 恢复 URI: $safUri")
// 同时保存到 SharedPreferences
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))
val plug = batteryIntent?.getIntExtra(BatteryManager.EXTRA_PLUGGED, -1) ?: -1
android.util.Log.d("BatteryHealth", "plug value: $plug, isCharging: $isCharging")
when (plug) {
BatteryManager.BATTERY_PLUGGED_AC -> "AC"
BatteryManager.BATTERY_PLUGGED_USB -> "USB"
BatteryManager.BATTERY_PLUGGED_WIRELESS -> "WIRELESS"
else -> "UNKNOWN"
}
} else {
"NONE"
}
eventSink?.success(mapOf("isCharging" to isCharging, "chargeType" to chargeType))
}
private fun getBatteryStatus(): Boolean {
val batteryIntent = registerReceiver(null, IntentFilter(Intent.ACTION_BATTERY_CHANGED))
val status = batteryIntent?.getIntExtra(BatteryManager.EXTRA_STATUS, -1) ?: -1
return status == BatteryManager.BATTERY_STATUS_CHARGING ||
status == BatteryManager.BATTERY_STATUS_FULL
}
private fun openFolderPicker() {
try {
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE).apply {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
try {
val downloadsUri = DocumentsContract.buildDocumentUri(
"com.android.externalstorage.documents",
"primary:Downloads"
)
putExtra(DocumentsContract.EXTRA_INITIAL_URI, downloadsUri)
} catch (e: Exception) {
// 忽略
}
}
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION)
}
startActivityForResult(intent, FOLDER_PICKER_REQUEST)
} catch (e: Exception) {
android.util.Log.e("BatteryHealth", "打开文件夹选择器失败: ${e.message}")
safResult?.error("SELECT_FOLDER_FAILED", e.message, null)
safResult = null
}
}
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
super.onActivityResult(requestCode, resultCode, data)
if (requestCode == FOLDER_PICKER_REQUEST && resultCode == ComponentActivity.RESULT_OK) {
val uri = data?.data
if (uri != null) {
try {
val takeFlags = Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION
contentResolver.takePersistableUriPermission(uri, takeFlags)
safUri = uri
saveUri(uri)
android.util.Log.d("BatteryHealth", "SAF 授权成功 URI: $uri")
safResult?.success(uri.toString())
} catch (e: Exception) {
android.util.Log.e("BatteryHealth", "SAF 授权失败: ${e.message}")
safResult?.error("SAF_FAILED", e.message, null)
}
} else {
safResult?.success(null)
}
safResult = null
}
}
private fun getTreeDocId(): String? {
return safUri?.let { DocumentsContract.getTreeDocumentId(it) }
}
private fun listFiles(): List<String> {
android.util.Log.d("BatteryHealth", "listFiles 被调用,safUri = $safUri")
val result = mutableListOf<String>()
val docId = getTreeDocId()
if (docId == null || docId.isEmpty()) {
android.util.Log.e("BatteryHealth", "docId 为空")
return result
}
try {
val childrenUri = DocumentsContract.buildChildDocumentsUriUsingTree(safUri!!, docId)
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) {
result.add(name)
}
}
}
} catch (e: Exception) {
android.util.Log.e("BatteryHealth", "列出文件失败: ${e.message}", e)
}
return result
}
private fun getFileUri(fileName: String): Uri? {
val docId = getTreeDocId() ?: return null
try {
val childrenUri = DocumentsContract.buildChildDocumentsUriUsingTree(safUri!!, docId)
contentResolver.query(childrenUri, null, null, null, null)?.use { cursor ->
val nameIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)
val idIndex = cursor.getColumnIndex(DocumentsContract.Document.COLUMN_DOCUMENT_ID)
while (cursor.moveToNext()) {
val name = cursor.getString(nameIndex)
if (name == fileName) {
val fileDocId = cursor.getString(idIndex)
return DocumentsContract.buildDocumentUriUsingTree(safUri!!, fileDocId)
}
}
}
} catch (e: Exception) {
android.util.Log.e("BatteryHealth", "获取文件URI失败: ${e.message}")
}
return null
}
private fun readFile(uri: Uri): String? {
return try {
contentResolver.openInputStream(uri)?.use { inputStream ->
BufferedReader(InputStreamReader(inputStream)).use { reader ->
reader.readText()
}
}
} catch (e: Exception) {
android.util.Log.e("BatteryHealth", "读取文件失败: ${e.message}")
null
}
}
private fun readLogFile(fileName: String): String? {
val fileUri = getFileUri(fileName)
return fileUri?.let { readFile(it) }
}
override fun configureFlutterEngine(@NonNull flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine)
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, CHARGE_CHANNEL)
.setMethodCallHandler { call, result ->
if (call.method == "getChargeState") {
val isCharging = getBatteryStatus()
val chargeType = if (isCharging) {
val batteryIntent = registerReceiver(null, IntentFilter(Intent.ACTION_BATTERY_CHANGED))
val plug = batteryIntent?.getIntExtra(BatteryManager.EXTRA_PLUGGED, -1) ?: -1
when (plug) {
BatteryManager.BATTERY_PLUGGED_AC -> "AC"
BatteryManager.BATTERY_PLUGGED_USB -> "USB"
BatteryManager.BATTERY_PLUGGED_WIRELESS -> "WIRELESS"
else -> "UNKNOWN"
}
} else {
"NONE"
}
result.success(mapOf("isCharging" to isCharging, "chargeType" to chargeType))
} else {
result.notImplemented()
}
}
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()
}
"readLogFile" -> {
val fileName = call.argument<String>("fileName")
if (fileName != null) {
val content = readLogFile(fileName)
result.success(content)
} else {
result.error("INVALID_ARGUMENT", "fileName is required", null)
}
}
"listFiles" -> {
val files = listFiles()
result.success(files)
}
"getUri" -> {
result.success(safUri?.toString())
}
else -> result.notImplemented()
}
}
EventChannel(flutterEngine.dartExecutor.binaryMessenger, EVENT_CHANNEL)
.setStreamHandler(
object : EventChannel.StreamHandler {
override fun onListen(arguments: Any?, events: EventChannel.EventSink?) {
eventSink = events
val filter = IntentFilter().apply {
addAction(Intent.ACTION_POWER_CONNECTED)
addAction(Intent.ACTION_POWER_DISCONNECTED)
}
registerReceiver(chargeReceiver, filter)
}
override fun onCancel(arguments: Any?) {
eventSink = null
unregisterReceiver(chargeReceiver)
}
}
)
}
}
+14 -12
View File
@@ -1,18 +1,20 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources> <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"> <style name="AppTheme" parent="Theme.AppCompat.Light.NoActionBar">
<!-- Show a splash screen on the activity. Automatically removed when <item name="android:statusBarColor">@android:color/white</item>
the Flutter engine draws its first frame --> <item name="android:windowLightStatusBar">true</item>
<item name="android:windowBackground">@drawable/launch_background</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> </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"> <style name="LaunchTheme" parent="Theme.AppCompat.Light.NoActionBar">
<item name="android:windowBackground">?android:colorBackground</item> <!-- 启动时可保持透明 -->
<item name="android:windowBackground">@android:color/white</item>
<item name="android:statusBarColor">@android:color/white</item>
<item name="android:windowLightStatusBar">true</item>
</style> </style>
</resources> </resources>
+4
View File
@@ -1,3 +1,7 @@
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
android.useAndroidX=true android.useAndroidX=true
android.enableJetifier=true android.enableJetifier=true
# This builtInKotlin flag was added automatically by Flutter migrator
android.builtInKotlin=false
# This newDsl flag was added automatically by Flutter migrator
android.newDsl=false
+49
View File
@@ -0,0 +1,49 @@
/// 容量提取工具
/// 支持两种日志格式:
/// 1. learned_cap21 系列):单位 µAh,除以 1000 得到 mAh
/// 2. remaining_cap20 系列):单位 mAh,直接使用(但 21 系列的 remaining_cap 也是 µAh,需判断)
///
/// 🟢 1.4.5 优化:仅从 status=Full 的行中提取容量(充满状态下 BMS 最准确)
class CapacityExtractor {
static int? extractCapacity(List<String> lines) {
// 🟢 只保留包含 tbat= 且 status=Full 的行
final fullLines = lines.where((line) {
return line.contains('tbat=') && line.contains('status=Full');
}).toList();
if (fullLines.isEmpty) {
// 今天没有充满电的记录,返回 null 表示无有效数据
return null;
}
// 模式一:learned_cap21 系列)
final learnedReg = RegExp(r'learned_cap=(\d+)');
for (String line in fullLines.reversed) {
final match = learnedReg.firstMatch(line);
if (match != null) {
return int.parse(match.group(1)!) ~/ 1000;
}
}
// 模式二:remaining_cap 最大值(20 系列,或 21 系列回退)
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;
}
}
}
+139
View File
@@ -0,0 +1,139 @@
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) / 1000000000.0,
volt: vbus / 1000000.0,
curr: ibus / 1000000.0,
wiredLevel: chargeType == 'PPS' ? level : null,
);
}
}
class ParseResult {
final bool isWireless;
final String chargeType;
final double power;
final double volt;
final double curr;
final int? wiredLevel;
ParseResult({
required this.isWireless,
required this.chargeType,
required this.power,
required this.volt,
required this.curr,
this.wiredLevel,
});
factory ParseResult.empty() {
return ParseResult(
isWireless: false,
chargeType: '',
power: 0.0,
volt: 0.0,
curr: 0.0,
wiredLevel: null,
);
}
}
+3 -1
View File
@@ -20,7 +20,9 @@ class MyApp extends StatelessWidget {
title: '电池健康度', title: '电池健康度',
theme: ThemeData( theme: ThemeData(
fontFamily: 'HarmonyOS_Sans_SC', fontFamily: 'HarmonyOS_Sans_SC',
colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple), colorScheme: ColorScheme.fromSeed(
seedColor: const Color.fromARGB(255, 255, 255, 255),
),
useMaterial3: true, useMaterial3: true,
), ),
home: const BatteryHealthPage(), home: const BatteryHealthPage(),
File diff suppressed because it is too large Load Diff
+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;
}
}
}
+105 -49
View File
@@ -1,65 +1,63 @@
import 'dart:io';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
import 'package:file_picker/file_picker.dart';
import '../models/saf_permission_state.dart'; import '../models/saf_permission_state.dart';
/// 使用 file_picker 实现文件夹访问 /// SAF 存储服务
/// 负责管理通过 SAF 授权的文件夹访问
class SafStorageService { class SafStorageService {
static final SafStorageService _instance = SafStorageService._internal(); static final SafStorageService _instance = SafStorageService._internal();
factory SafStorageService() => _instance; factory SafStorageService() => _instance;
SafStorageService._internal(); SafStorageService._internal();
static const String _prefKeySafPath = 'saf_storage_path'; static const MethodChannel _channel = MethodChannel(
String? _cachedPath; 'com.lxh.battery_health/saf',
);
static const String _prefKeySafUri = 'saf_storage_uri';
String? _cachedUri;
/// 初始化,从 SharedPreferences 恢复 URI
Future<void> init() async { Future<void> init() async {
try {
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
_cachedPath = prefs.getString(_prefKeySafPath); _cachedUri = prefs.getString(_prefKeySafUri);
if (kDebugMode) { if (kDebugMode) {
print('[SAF] 初始化,缓存路径: $_cachedPath'); print('[SAF] 初始化,缓存 URI: $_cachedUri');
}
} catch (e) {
if (kDebugMode) {
print('[SAF] 初始化失败: $e');
}
_cachedUri = null;
} }
} }
/// 获取当前授权状态(简化版,直接根据缓存判断)
Future<SafPermissionState> getPermissionState() async { Future<SafPermissionState> getPermissionState() async {
if (_cachedPath == null || _cachedPath!.isEmpty) { if (_cachedUri == null || _cachedUri!.isEmpty) {
return SafPermissionState.unauthorized; return SafPermissionState.unauthorized;
} }
try { // 直接返回 authorized,相信缓存
final dir = Directory(_cachedPath!);
if (!await dir.exists()) {
_clearCache();
return SafPermissionState.expired;
}
return SafPermissionState.authorized; return SafPermissionState.authorized;
} catch (e) {
_clearCache();
return SafPermissionState.expired;
}
} }
/// 请求 SAF 授权(引导用户选择文件夹)
Future<SafAuthorizationResult> requestPermission() async { Future<SafAuthorizationResult> requestPermission() async {
try { try {
// 使用 file_picker 选择文件夹 final String? uriString = await _channel.invokeMethod('selectFolder');
String? selectedPath = await FilePicker.platform.getDirectoryPath();
if (selectedPath == null || selectedPath.isEmpty) { if (uriString == null || uriString.isEmpty) {
return SafAuthorizationResult.denied(); return SafAuthorizationResult.denied();
} }
// 验证路径是否包含 mbattery_charger(可选,提高容错) _cachedUri = uriString;
if (!selectedPath.contains('mbattery_charger')) { await _saveUri(uriString);
return SafAuthorizationResult.error('请选择 mbattery_charger 文件夹');
}
_cachedPath = selectedPath;
await _savePath(selectedPath);
if (kDebugMode) { if (kDebugMode) {
print('[SAF] 授权成功,路径: $selectedPath'); print('[SAF] 授权成功,URI: $uriString');
} }
return SafAuthorizationResult.success(selectedPath); return SafAuthorizationResult.success(uriString);
} catch (e) { } catch (e) {
if (kDebugMode) { if (kDebugMode) {
print('[SAF] 授权失败: $e'); print('[SAF] 授权失败: $e');
@@ -68,24 +66,63 @@ 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 { Future<bool> logFileExists(String fileName) async {
final state = await getPermissionState(); final state = await getPermissionState();
if (state != SafPermissionState.authorized) return false; if (state != SafPermissionState.authorized) return false;
try { try {
final file = File('${_cachedPath!}/$fileName'); final files = await listFiles();
return await file.exists(); return files.contains(fileName);
} catch (e) { } catch (e) {
return false; return false;
} }
} }
/// 读取日志文件内容(返回行列表)
Future<List<String>> readLogFile(String fileName) async { Future<List<String>> readLogFile(String fileName) async {
// 🟢 先尝试恢复 URI
await restoreUri();
final state = await getPermissionState(); final state = await getPermissionState();
if (state != SafPermissionState.authorized) return []; if (state != SafPermissionState.authorized) return [];
try { try {
final file = File('${_cachedPath!}/$fileName'); final String? content = await _channel.invokeMethod('readLogFile', {
if (!await file.exists()) return []; 'fileName': fileName,
return await file.readAsLines(); });
if (content == null || content.isEmpty) return [];
return content.split('\n');
} catch (e) { } catch (e) {
if (kDebugMode) { if (kDebugMode) {
print('[SAF] 读取文件失败: $e'); print('[SAF] 读取文件失败: $e');
@@ -94,17 +131,20 @@ class SafStorageService {
} }
} }
/// 获取文件夹中的文件列表
Future<List<String>> listFiles() async { Future<List<String>> listFiles() async {
// 🟢 先尝试恢复 URI
await restoreUri();
final state = await getPermissionState(); final state = await getPermissionState();
if (state != SafPermissionState.authorized) return []; if (state != SafPermissionState.authorized) return [];
try { try {
final dir = Directory(_cachedPath!); final result = await _channel.invokeMethod('listFiles');
if (!await dir.exists()) return []; if (result is List) {
final entities = await dir.list().toList(); return result.cast<String>();
return entities }
.whereType<File>() return [];
.map((e) => e.path.split(Platform.pathSeparator).last)
.toList();
} catch (e) { } catch (e) {
if (kDebugMode) { if (kDebugMode) {
print('[SAF] 列出文件失败: $e'); print('[SAF] 列出文件失败: $e');
@@ -113,24 +153,40 @@ class SafStorageService {
} }
} }
/// 验证授权是否有效(简化版)
Future<bool> validatePermission() async { Future<bool> validatePermission() async {
final state = await getPermissionState(); final state = await getPermissionState();
return state == SafPermissionState.authorized; return state == SafPermissionState.authorized;
} }
/// 清除缓存
void _clearCache() { void _clearCache() {
_cachedPath = null; _cachedUri = null;
_savePath(null); _saveUri(null);
} }
Future<void> _savePath(String? path) async { /// 保存 URI 到 SharedPreferences
Future<void> _saveUri(String? uri) async {
try {
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
if (path != null && path.isNotEmpty) { if (uri != null && uri.isNotEmpty) {
await prefs.setString(_prefKeySafPath, path); await prefs.setString(_prefKeySafUri, uri);
if (kDebugMode) {
print('[SAF] 保存 URI 成功: $uri');
}
} else { } else {
await prefs.remove(_prefKeySafPath); await prefs.remove(_prefKeySafUri);
if (kDebugMode) {
print('[SAF] 清除 URI');
}
}
} catch (e) {
if (kDebugMode) {
print('[SAF] 保存 URI 失败: $e');
}
} }
} }
String? get cachedPath => _cachedPath; /// 获取缓存的 URI(用于调试)
String? get cachedUri => _cachedUri;
} }
+48
View File
@@ -0,0 +1,48 @@
import 'dart:developer' as dev;
class TimeUtils {
/// 解析日志行中的时间戳,强制解析为 UTC
static DateTime? parseLogTimestamp(String line) {
final reg = RegExp(r'\[(\d{4}-\d{2}-\d{2}) (\d{2}:\d{2}:\d{2})\]');
final match = reg.firstMatch(line);
if (match == null) return null;
try {
final dateParts = match.group(1)!.split('-');
final timeParts = match.group(2)!.split(':');
return DateTime.utc(
int.parse(dateParts[0]),
int.parse(dateParts[1]),
int.parse(dateParts[2]),
int.parse(timeParts[0]),
int.parse(timeParts[1]),
int.parse(timeParts[2]),
);
} catch (_) {
return null;
}
}
/// 判断日志时间戳是否在有效窗口内
static bool isLogTimestampValid(
DateTime? logTime,
DateTime? chargingStartTime,
) {
if (logTime == null) return false;
if (chargingStartTime == null) {
final nowUtc = DateTime.now().toUtc();
final diff = nowUtc.difference(logTime);
return diff.abs().inMinutes <= 3;
}
final endTime = chargingStartTime.add(const Duration(minutes: 3));
return logTime.isAfter(chargingStartTime) && logTime.isBefore(endTime);
}
/// 生成今日日志文件名(UTC
static String getTodayFileName() {
final nowUtc = DateTime.now().toUtc();
final year = nowUtc.year.toString();
final month = nowUtc.month.toString().padLeft(2, '0');
final day = nowUtc.day.toString().padLeft(2, '0');
return 'mbattery_charger_log_${year}_${month}_$day';
}
}
+135
View File
@@ -0,0 +1,135 @@
import 'package:flutter/material.dart';
import 'package:flutter_svg/flutter_svg.dart';
/// 电池信息面板(电压 + 温度 + 极值)
class BatteryInfoPanel extends StatelessWidget {
final double batteryVolt;
final double batteryTemp;
final double maxVolt;
final double minVolt;
final double maxTemp;
final double minTemp;
const BatteryInfoPanel({
super.key,
required this.batteryVolt,
required this.batteryTemp,
required this.maxVolt,
required this.minVolt,
required this.maxTemp,
required this.minTemp,
});
@override
Widget build(BuildContext context) {
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildInfoItem(
_buildIcon('voltage'),
'电压',
'${batteryVolt.toStringAsFixed(3)}V',
),
const SizedBox(height: 8),
Row(
children: [
Text(
'${maxVolt.toStringAsFixed(3)}V',
style: const TextStyle(
color: Colors.red,
fontSize: 14,
fontWeight: FontWeight.w500,
),
),
const SizedBox(width: 12),
Text(
'${minVolt.toStringAsFixed(3)}V',
style: const TextStyle(
color: Colors.blue,
fontSize: 14,
fontWeight: FontWeight.w500,
),
),
],
),
],
),
),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildInfoItem(
_buildIcon('temperature'),
'温度',
'${batteryTemp.toStringAsFixed(0)}°C',
),
const SizedBox(height: 8),
Row(
children: [
Text(
'${maxTemp.toStringAsFixed(0)}°C',
style: const TextStyle(
color: Colors.red,
fontSize: 14,
fontWeight: FontWeight.w500,
),
),
const SizedBox(width: 12),
Text(
'${minTemp.toStringAsFixed(0)}°C',
style: const TextStyle(
color: Colors.blue,
fontSize: 14,
fontWeight: FontWeight.w500,
),
),
],
),
],
),
),
],
);
}
Widget _buildIcon(String type) {
switch (type) {
case 'voltage':
return SvgPicture.asset(
'assets/icons/si--lightning-fill.svg',
width: 18,
height: 18,
colorFilter: const ColorFilter.mode(Colors.grey, BlendMode.srcIn),
);
case 'temperature':
return SvgPicture.asset(
'assets/icons/mdi--temperature.svg',
width: 18,
height: 18,
colorFilter: const ColorFilter.mode(Colors.grey, BlendMode.srcIn),
);
default:
return const Icon(Icons.help_outline, size: 18, color: Colors.grey);
}
}
Widget _buildInfoItem(Widget icon, String label, String value) {
return Row(
children: [
icon,
const SizedBox(width: 6),
Text(label, style: const TextStyle(fontSize: 14, color: Colors.grey)),
const SizedBox(width: 6),
Text(
value,
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w500),
),
],
);
}
}
+101
View File
@@ -0,0 +1,101 @@
import 'package:flutter/material.dart';
/// 电池健康度摘要组件(健康度 + cycles + mAh
class BatterySummary extends StatelessWidget {
final double healthPercent;
final int cycleCount;
final double currentCap;
final int designCap;
const BatterySummary({
super.key,
required this.healthPercent,
required this.cycleCount,
required this.currentCap,
required this.designCap,
});
@override
Widget build(BuildContext context) {
return Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
// 健康度大数字
Row(
crossAxisAlignment: CrossAxisAlignment.baseline,
textBaseline: TextBaseline.alphabetic,
children: [
Text(
healthPercent.toStringAsFixed(1),
style: TextStyle(
fontSize: 40,
fontWeight: FontWeight.w500,
height: 1.0,
color: healthPercent < 70 ? Colors.red : Colors.black,
),
),
const SizedBox(width: 2),
Text(
'%',
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.w300,
height: 1.0,
color: healthPercent < 70 ? Colors.red : Colors.grey.shade600,
),
),
],
),
const Spacer(),
// cycles + mAh
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
'$cycleCount cycles', // ✅ 修正:去掉下划线,使用 cycleCount
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w500,
height: 1.2,
),
),
const SizedBox(height: 4),
Text.rich(
TextSpan(
children: [
TextSpan(
text: currentCap.toStringAsFixed(0),
style: const TextStyle(
fontSize: 20,
fontWeight: FontWeight.w300,
),
),
TextSpan(
text: ' / ',
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.w300,
color: Colors.grey.shade500,
),
),
TextSpan(
text: '${designCap}mAh',
style: const TextStyle(
fontSize: 20,
fontWeight: FontWeight.w300,
),
),
],
),
style: const TextStyle(
fontSize: 20,
fontWeight: FontWeight.w300,
height: 1.0,
),
),
],
),
],
);
}
}
+3 -3
View File
@@ -11,14 +11,14 @@ class ChargeDetailPage extends StatelessWidget {
final int? wiredLevel; final int? wiredLevel;
const ChargeDetailPage({ const ChargeDetailPage({
super.key, Key? key,
required this.chargeType, required this.chargeType,
required this.isWireless, required this.isWireless,
required this.power, required this.power,
required this.volt, required this.volt,
required this.curr, required this.curr,
this.wiredLevel, this.wiredLevel,
}); }) : super(key: key);
String _getDisplayPower() { String _getDisplayPower() {
if (isWireless) { if (isWireless) {
@@ -31,7 +31,7 @@ class ChargeDetailPage extends StatelessWidget {
String _getProtocolDisplay() { String _getProtocolDisplay() {
if (isWireless) return '无线充电($chargeType'; if (isWireless) return '无线充电($chargeType';
if (chargeType == 'PPS') return 'PPS 快充'; if (chargeType == 'PPS') return 'PPS 快充';
if (chargeType == 'SDP') return 'USB 充电'; // 🟢 改成和主页面一致 if (chargeType == 'SDP') return 'USB 充电';
return chargeType; return chargeType;
} }
+56
View File
@@ -0,0 +1,56 @@
import 'package:flutter/material.dart';
/// 充电入口行(显示充电状态 + 点击进入详情)
class ChargeEntry extends StatelessWidget {
final String summary;
final bool isCharging;
final VoidCallback onTap;
const ChargeEntry({
super.key,
required this.summary,
required this.isCharging,
required this.onTap,
});
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: isCharging ? onTap : null,
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
summary,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w500,
color: Colors.black87,
),
),
Row(
children: [
Text(
'充电信息',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w500,
color: Colors.grey.shade700,
),
),
const SizedBox(width: 4),
Text(
'>',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w500,
color: Colors.grey.shade700,
),
),
],
),
],
),
);
}
}
+222
View File
@@ -0,0 +1,222 @@
import 'package:flutter/material.dart';
// --- 潘通色定义 ---
const Color kHealthGreen = Color(0xFF00B140);
const Color kHealthYellow = Color(0xFFEFDF00);
const Color kHealthOrange = Color(0xFFD75A16);
/// 趋势图柱状图组件
class TrendChart extends StatelessWidget {
final List<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(fontSize: 16, fontWeight: FontWeight.w500),
),
const SizedBox(height: 8),
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
height: 180,
child: Column(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: const [
Text(
'100',
style: TextStyle(
fontSize: 10,
color: Colors.grey,
height: 1.0,
),
),
Text(
'70',
style: TextStyle(
fontSize: 10,
color: Colors.grey,
height: 1.0,
),
),
],
),
),
const SizedBox(width: 4),
Expanded(
child: LayoutBuilder(
builder: (context, constraints) {
double totalWidth = constraints.maxWidth;
double step = totalWidth / 15;
return SizedBox(
height: 180,
child: Stack(
children: [
Padding(
padding: const EdgeInsets.only(bottom: 20),
child: Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: List.generate(percents.length, (index) {
double value = percents[index];
Color barColor;
if (value > 0) {
if (value >= 80) {
barColor = kHealthGreen;
} else if (value >= 70) {
barColor = kHealthYellow;
} else {
barColor = Colors.red;
}
} else {
barColor = Colors.grey.shade300;
}
double clampedValue = value.clamp(70.0, 100.0);
double heightFactor =
(clampedValue - 70.0) / 30.0;
return Expanded(
child: Align(
alignment: Alignment.bottomCenter,
child: Container(
margin: const EdgeInsets.symmetric(
horizontal: 2,
),
height: 20 + (100 * heightFactor),
width: 18,
decoration: BoxDecoration(
color: barColor,
borderRadius: BorderRadius.circular(4),
),
),
),
);
}),
),
),
if (showSevenDaysAgo && sevenDaysAgoDate.isNotEmpty)
Positioned(
left: sevenDaysAgoIndex * step + (step / 2) - 14,
bottom: 2,
child: Text(
sevenDaysAgoDate,
style: const TextStyle(
fontSize: 9,
color: Colors.grey,
height: 1.0,
),
),
),
if (todayDate.isNotEmpty)
Positioned(
left: todayIndex * step + (step / 2) - 14,
bottom: 2,
child: Text(
todayDate,
style: const TextStyle(
fontSize: 9,
color: Colors.black,
height: 1.0,
fontWeight: FontWeight.w600,
),
),
),
],
),
);
},
),
),
],
),
if (percents.every((v) => v == 0))
const Padding(
padding: EdgeInsets.only(top: 8.0),
child: Text(
'暂无历史数据,请点击刷新按钮更新',
style: TextStyle(fontSize: 12, color: Colors.grey),
),
),
],
);
}
List<Map<String, dynamic>> _getMockData() {
List<String> mockDates = [
'2026-06-14',
'2026-06-15',
'2026-06-16',
'2026-06-17',
'2026-06-18',
'2026-06-19',
'2026-06-20',
'2026-06-21',
'2026-06-22',
'2026-06-23',
'2026-06-24',
'2026-06-25',
'2026-06-26',
'2026-06-27',
'2026-06-28',
];
List<int> mockCap = [
4635,
4632,
4628,
4625,
4620,
4618,
4615,
4610,
4608,
4605,
4600,
4598,
4595,
4590,
4585,
];
return List.generate(
15,
(i) => {'date': mockDates[i], 'capacity': mockCap[i]},
);
}
}
-16
View File
@@ -113,14 +113,6 @@ packages:
url: "https://pub.flutter-io.cn" url: "https://pub.flutter-io.cn"
source: hosted source: hosted
version: "7.0.1" version: "7.0.1"
file_picker:
dependency: "direct main"
description:
name: file_picker
sha256: "1bbf65dd997458a08b531042ec3794112a6c39c07c37ff22113d2e7e4f81d4e4"
url: "https://pub.flutter-io.cn"
source: hosted
version: "6.2.1"
flutter: flutter:
dependency: "direct main" dependency: "direct main"
description: flutter description: flutter
@@ -134,14 +126,6 @@ packages:
url: "https://pub.flutter-io.cn" url: "https://pub.flutter-io.cn"
source: hosted source: hosted
version: "5.0.0" version: "5.0.0"
flutter_plugin_android_lifecycle:
dependency: transitive
description:
name: flutter_plugin_android_lifecycle
sha256: "3854fe5e3bff0b113c658f260b90c95dea17c92db0f2addeac2e343dd9969785"
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.0.35"
flutter_svg: flutter_svg:
dependency: "direct main" dependency: "direct main"
description: description:
+2 -2
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 # 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 # 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. # of the product and file versions while build-number is used as the build suffix.
version: 1.3.0-dev.2 version: 1.4.0
environment: environment:
sdk: ^3.9.2 sdk: ^3.9.2
@@ -38,7 +38,7 @@ dependencies:
path: ^1.9.0 path: ^1.9.0
flutter_svg: ^2.0.10+1 flutter_svg: ^2.0.10+1
shared_preferences: ^2.2.0 shared_preferences: ^2.2.0
file_picker: ^6.0.0
# The following adds the Cupertino Icons font to your application. # The following adds the Cupertino Icons font to your application.
# Use with the CupertinoIcons class for iOS style icons. # Use with the CupertinoIcons class for iOS style icons.