Skip to content

Commit 219c35b

Browse files
committed
chore: release v1.5.0
1 parent 2c6fe8b commit 219c35b

28 files changed

Lines changed: 2852 additions & 1579 deletions

README.md

Lines changed: 18 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -16,13 +16,13 @@
1616
</div>
1717

1818
## ✨ 亮点功能
19-
- **BLE 扫描与连接**:自动过滤无关广播,优先匹配心率服务/常见穿戴品牌。
19+
- **BLE 扫描与连接**:自动过滤无关广播,优先匹配心率服务/常见穿戴品牌(新增小米手环 10 等设备支持)
2020
- **智能自动重连**:记忆最近成功设备,断连或心率长时间无更新时自动重连。
21-
- **实时展示**:BPM、上次更新时间、RSSI 信号强度;RSSI 轮询与刷新间隔一致
21+
- **实时展示**全新“Lub-Dub”仿生心跳动画,BPM、上次更新时间、RSSI 信号强度一目了然
2222
- **多协议推送**:HTTP/WS、OSC、MQTT 任意组合启用,统一 JSON payload。
2323
- **调试视图**:查看附近广播、Service UUID、RSSI、厂商数据长度。
2424
- **桌面端体验**:Windows/macOS/Linux 固定竖屏窗口;Windows 支持托盘最小化。
25-
- **Android 常驻通知**连接后在通知栏显示心率与连接状态
25+
- **Android 常驻通知**全新设计的原生“实时活动”风格通知卡片,支持 Android 12+ (ColorOS 14 等) 系统
2626

2727
## 🗺️ 适用场景
2828
- **常驻心率推送**:家里/工作室有一台不关机的 Mac mini 或 Windows 主机,手表常开心率广播,回到范围内即可自动连接并持续推送。
@@ -36,7 +36,7 @@
3636

3737
## 🚀 快速开始(用户)
3838
1. 启动应用后点击“重新扫描”。
39-
2. 选择心率设备并连接。
39+
2. 选择心率设备并连接(现已支持更多广播心率的手环/手表)
4040
3. 在配置页填写推送目标(HTTP/WS、OSC 或 MQTT),保存后即可推送。
4141

4242
> 若设备仅广播心率但不支持连接,仍可在“广播调试”视图中查看数据与信号,但推送仅在连接并订阅特征后触发。
@@ -59,7 +59,7 @@
5959
"percent": 0.42,
6060
"connected": true,
6161
"device": "Polar H10",
62-
"timestamp": "2025-12-12T09:00:00.000Z"
62+
"timestamp": "2025-12-25T09:00:00.000Z"
6363
}
6464
```
6565

@@ -69,7 +69,7 @@
6969
"event": "connection",
7070
"connected": false,
7171
"device": "Polar H10",
72-
"timestamp": "2025-12-12T09:05:00.000Z"
72+
"timestamp": "2025-12-25T09:05:00.000Z"
7373
}
7474
```
7575

@@ -107,17 +107,18 @@
107107
### 已验证设备
108108
**蓝牙广播发送端**
109109
1. Garmin Enduro 2(佳明手表,蓝牙广播推送)
110-
2. Xiaomi Smart Band 9(小米手环9,更新到1.3.206+固件后在 设置-心率广播 手动开启)(调研小米手环8以下版本的设备仍不支持或待测试)
110+
2. Xiaomi Smart Band 10 / 9(小米手环9/10,更新固件后开启心率广播)
111+
3. HuaWei Watch GT 4
111112

112113
**蓝牙广播接收端**
113114
1. iPhone 15 Pro(无证书可自行签名)
114-
2. OnePlus Ace(ColorOS / Android 14
115+
2. OnePlus Ace / ColorOS 14 (Android 14)
115116
3. MacBook Pro M5(macOS Tahoe 26.1)
116-
4. Windows(B450I GAMING PLUS AC 主板,自带蓝牙
117+
4. Windows(蓝牙适配器需支持 BLE
117118

118119
## 🛡️ 平台支持与权限
119-
- **Android**:需要 BLE 扫描/连接权限(Android 12+ 无需定位,11 及以下需定位权限)。Android 13+ 若想显示常驻通知卡片,请允许通知权限。
120-
- ColorOS/部分国产 ROM:需在系统设置中打开应用通知,并允许后台运行/自启动,否则可能看不到常驻卡片或后台停止更新。
120+
- **Android**:需要 BLE 扫描/连接权限(Android 12+ 无需定位,11 及以下需定位权限)。若想显示状态栏卡片,请允许通知权限。
121+
- ColorOS/MIUI/HyperOS 等:需在系统设置中打开应用通知,并允许后台运行/自启动,否则可能看不到常驻卡片或后台停止更新。
121122
- **iOS/macOS**:首次启动会请求蓝牙权限。
122123

123124
## 🔧 开发与构建
@@ -132,6 +133,12 @@
132133
- Windows 平台下中文路径可能会存在运行失败的问题,建议在英文路径目录下执行本程序。
133134

134135
## 🧾 更新日志
136+
### v1.5.0
137+
- **UI 重构**:首页心率动画重构,采用更自然的仿生“Lub-Dub”跳动节奏与波纹扩散效果。
138+
- **功能增强**:Android 端状态栏通知全新改版为 Native 布局(类似 iOS 实时活动风格),适配 Android 12+ (ColorOS 14) 系统,修复了部分机型不显示通知的问题。
139+
- **兼容性**:修复了小米手环 10 (Xiaomi Smart Band 10) 及部分以 `Mi` / `Xiaomi` 命名的设备无法被扫描到的问题;增加了详细的 BLE 服务发现日志以便排查连接问题。
140+
- **优化**:移除未使用的资源文件,精简代码逻辑。
141+
135142
### v1.4.0
136143
- Android:状态栏/导航栏颜色同步与沉浸式刷新优化(含部分定制 ROM 适配)。
137144
- Android:常驻通知通道与样式升级,权限请求与颜色配置更稳定。

android/app/src/main/kotlin/moe/iacg/hrpush/MainActivity.kt

Lines changed: 90 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,95 @@ import android.os.Bundle
44
import io.flutter.embedding.android.FlutterActivity
55

66
class MainActivity : FlutterActivity() {
7-
override fun onCreate(savedInstanceState: Bundle?) {
8-
super.onCreate(savedInstanceState)
7+
private val CHANNEL = "moe.iacg.hrpush/notification"
8+
private val NOTIFICATION_ID = 1001
9+
private val NOTIFICATION_CHANNEL_ID = "hr_push_live"
10+
11+
override fun configureFlutterEngine(flutterEngine: io.flutter.embedding.engine.FlutterEngine) {
12+
super.configureFlutterEngine(flutterEngine)
13+
io.flutter.plugin.common.MethodChannel(flutterEngine.dartExecutor.binaryMessenger, CHANNEL).setMethodCallHandler { call, result ->
14+
if (call.method == "updateNotification") {
15+
val bpm = call.argument<Int>("bpm")
16+
val deviceName = call.argument<String>("deviceName")
17+
val isConnected = call.argument<Boolean>("isConnected") ?: false
18+
19+
updateOldNotification(bpm, deviceName, isConnected)
20+
result.success(null)
21+
} else if (call.method == "cancelNotification") {
22+
val notificationManager = getSystemService(android.content.Context.NOTIFICATION_SERVICE) as android.app.NotificationManager
23+
notificationManager.cancel(NOTIFICATION_ID)
24+
result.success(null)
25+
} else {
26+
result.notImplemented()
27+
}
28+
}
29+
}
30+
31+
private fun updateOldNotification(bpm: Int?, deviceName: String?, isConnected: Boolean) {
32+
android.util.Log.d("HrPush", "updateNotification: bpm=$bpm, connected=$isConnected")
33+
val notificationManager = getSystemService(android.content.Context.NOTIFICATION_SERVICE) as android.app.NotificationManager
34+
35+
// Create Channel if needed
36+
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.O) {
37+
val channel = android.app.NotificationChannel(
38+
NOTIFICATION_CHANNEL_ID,
39+
"Live Activity",
40+
android.app.NotificationManager.IMPORTANCE_LOW
41+
).apply {
42+
description = "Shows live heart rate"
43+
setSound(null, null)
44+
enableVibration(false)
45+
setShowBadge(false)
46+
lockscreenVisibility = android.app.Notification.VISIBILITY_PUBLIC
47+
}
48+
notificationManager.createNotificationChannel(channel)
49+
}
50+
51+
// Setup RemoteViews
52+
val views = android.widget.RemoteViews(packageName, R.layout.live_activity)
53+
54+
if (isConnected) {
55+
val bpmText = if (bpm != null && bpm > 0) "$bpm BPM" else "-- BPM"
56+
views.setTextViewText(R.id.bpm_value, bpmText)
57+
views.setTextViewText(R.id.status_text, "Connected to ${deviceName ?: "Device"}")
58+
views.setTextViewText(R.id.time_text, "LIVE")
59+
views.setTextColor(R.id.time_text, android.graphics.Color.parseColor("#34C759")) // Green
60+
} else {
61+
views.setTextViewText(R.id.bpm_value, "--")
62+
views.setTextViewText(R.id.status_text, "Disconnected")
63+
views.setTextViewText(R.id.time_text, "OFF")
64+
views.setTextColor(R.id.time_text, android.graphics.Color.parseColor("#86868B")) // Grey
65+
}
66+
67+
// Build Notification
68+
val builder = android.app.Notification.Builder(this)
69+
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.O) {
70+
builder.setChannelId(NOTIFICATION_CHANNEL_ID)
71+
}
72+
73+
// Intent to open app
74+
val intent = android.content.Intent(this, MainActivity::class.java)
75+
val pendingIntent = android.app.PendingIntent.getActivity(this, 0, intent, android.app.PendingIntent.FLAG_IMMUTABLE)
76+
77+
// Use valid notification icon (white monochrome)
78+
builder.setSmallIcon(R.drawable.ic_stat_heart)
79+
80+
builder.setCustomContentView(views)
81+
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.S) {
82+
builder.setCustomBigContentView(views)
83+
// Style workaround for some Android 12+ devices to ensure custom view shows
84+
builder.setStyle(android.app.Notification.DecoratedCustomViewStyle())
85+
}
86+
87+
builder.setContentIntent(pendingIntent)
88+
builder.setOngoing(true)
89+
builder.setOnlyAlertOnce(true)
90+
builder.setVisibility(android.app.Notification.VISIBILITY_PUBLIC)
91+
92+
try {
93+
notificationManager.notify(NOTIFICATION_ID, builder.build())
94+
} catch (e: Exception) {
95+
android.util.Log.e("HrPush", "Failed to notify", e)
96+
}
997
}
1098
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
<vector xmlns:android="http://schemas.android.com/apk/res/android"
2+
android:width="24dp"
3+
android:height="24dp"
4+
android:viewportWidth="24"
5+
android:viewportHeight="24"
6+
android:tint="#FF2D55">
7+
<path
8+
android:fillColor="@android:color/white"
9+
android:pathData="M12,21.35l-1.45,-1.32C5.4,15.36 2,12.28 2,8.5 2,5.42 4.42,3 7.5,3c1.74,0 3.41,0.81 4.5,2.09C13.09,3.81 14.76,3 16.5,3 19.58,3 22,5.42 22,8.5c0,3.78 -3.4,6.86 -8.55,11.54L12,21.35z"/>
10+
</vector>
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
<vector xmlns:android="http://schemas.android.com/apk/res/android"
2+
android:width="24dp"
3+
android:height="24dp"
4+
android:viewportWidth="24"
5+
android:viewportHeight="24"
6+
android:tint="#FFFFFF">
7+
<path
8+
android:fillColor="@android:color/white"
9+
android:pathData="M12,21.35l-1.45,-1.32C5.4,15.36 2,12.28 2,8.5 2,5.42 4.42,3 7.5,3c1.74,0 3.41,0.81 4.5,2.09C13.09,3.81 14.76,3 16.5,3 19.58,3 22,5.42 22,8.5c0,3.78 -3.4,6.86 -8.55,11.54L12,21.35z"/>
10+
</vector>
Lines changed: 38 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,42 +1,60 @@
11
<?xml version="1.0" encoding="utf-8"?>
22
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
3-
android:orientation="horizontal"
4-
android:gravity="center_vertical"
5-
android:padding="12dp"
63
android:layout_width="match_parent"
7-
android:layout_height="wrap_content"
8-
android:background="#161b22">
4+
android:layout_height="64dp"
5+
android:background="#2C2C2E"
6+
android:gravity="center_vertical"
7+
android:orientation="horizontal"
8+
android:paddingStart="16dp"
9+
android:paddingEnd="16dp">
10+
11+
<!-- Heart Icon -->
12+
<ImageView
13+
android:id="@+id/icon"
14+
android:layout_width="24dp"
15+
android:layout_height="24dp"
16+
android:src="@drawable/ic_heart_filled"
17+
android:contentDescription="Heart Icon" />
18+
19+
<Space
20+
android:layout_width="16dp"
21+
android:layout_height="wrap_content" />
922

23+
<!-- Text Info -->
1024
<LinearLayout
11-
android:orientation="vertical"
1225
android:layout_width="0dp"
1326
android:layout_height="wrap_content"
14-
android:layout_weight="1">
27+
android:layout_weight="1"
28+
android:orientation="vertical">
1529

1630
<TextView
17-
android:id="@+id/status_text"
31+
android:id="@+id/bpm_value"
1832
android:layout_width="wrap_content"
1933
android:layout_height="wrap_content"
20-
android:text="心率"
21-
android:textColor="#9fb5c3"
22-
android:textSize="12sp" />
34+
android:text="--"
35+
android:textColor="#FFFFFF"
36+
android:textSize="20sp"
37+
android:textStyle="bold"
38+
android:includeFontPadding="false" />
2339

2440
<TextView
25-
android:id="@+id/bpm_value"
41+
android:id="@+id/status_text"
2642
android:layout_width="wrap_content"
2743
android:layout_height="wrap_content"
28-
android:text="--"
29-
android:textColor="#ffffff"
30-
android:textSize="32sp"
31-
android:textStyle="bold" />
44+
android:text="等待连接..."
45+
android:textColor="#98989D"
46+
android:textSize="12sp"
47+
android:includeFontPadding="false" />
3248
</LinearLayout>
3349

34-
<Chronometer
50+
<!-- Time / Extra Info -->
51+
<TextView
3552
android:id="@+id/time_text"
3653
android:layout_width="wrap_content"
3754
android:layout_height="wrap_content"
38-
android:text="更新"
39-
android:textColor="#9fb5c3"
40-
android:textSize="12sp" />
55+
android:text="Live"
56+
android:textColor="#34C759"
57+
android:textSize="12sp"
58+
android:textStyle="bold" />
4159

4260
</LinearLayout>

images/main.png

7.3 KB
Loading

images/settings.png

-20.7 KB
Loading

l10n.yaml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
arb-dir: lib/l10n
2+
template-arb-file: app_en.arb
3+
output-localization-file: app_localizations.dart
4+
synthetic-package: false

lib/app_log.dart

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
import 'package:flutter_loggy/flutter_loggy.dart';
2+
import 'package:loggy/loggy.dart' as loggy;
3+
4+
class AppLog {
5+
static final AppStreamPrinter _streamPrinter =
6+
AppStreamPrinter(const PrettyDeveloperPrinter());
7+
static bool _enabled = false;
8+
static bool _initialized = false;
9+
10+
static void init({required bool enabled}) {
11+
_enabled = enabled;
12+
_initialized = true;
13+
loggy.Loggy.initLoggy(
14+
logPrinter: _streamPrinter,
15+
logOptions:
16+
loggy.LogOptions(enabled ? loggy.LogLevel.all : loggy.LogLevel.off),
17+
);
18+
}
19+
20+
static void setEnabled(bool enabled) {
21+
_enabled = enabled;
22+
if (!_initialized) {
23+
init(enabled: enabled);
24+
return;
25+
}
26+
loggy.Loggy.initLoggy(
27+
logPrinter: _streamPrinter,
28+
logOptions:
29+
loggy.LogOptions(enabled ? loggy.LogLevel.all : loggy.LogLevel.off),
30+
);
31+
if (!enabled) {
32+
clear();
33+
}
34+
}
35+
36+
static bool get enabled => _enabled;
37+
38+
static StreamPrinter get streamPrinter => _streamPrinter;
39+
40+
static void clear() {
41+
_streamPrinter.clear();
42+
}
43+
44+
static void debug(String message, {Object? error, StackTrace? stackTrace}) {
45+
if (!_enabled) return;
46+
loggy.logDebug(message, error, stackTrace);
47+
}
48+
49+
static void info(String message, {Object? error, StackTrace? stackTrace}) {
50+
if (!_enabled) return;
51+
loggy.logInfo(message, error, stackTrace);
52+
}
53+
54+
static void warning(String message, {Object? error, StackTrace? stackTrace}) {
55+
if (!_enabled) return;
56+
loggy.logWarning(message, error, stackTrace);
57+
}
58+
59+
static void error(String message, {Object? error, StackTrace? stackTrace}) {
60+
if (!_enabled) return;
61+
loggy.logError(message, error, stackTrace);
62+
}
63+
}
64+
65+
class AppStreamPrinter extends StreamPrinter {
66+
AppStreamPrinter(super.childPrinter, {this.maxRecords = 800});
67+
68+
final int maxRecords;
69+
70+
@override
71+
void onLog(loggy.LogRecord record) {
72+
super.onLog(record);
73+
final records = logRecord.value;
74+
if (records.length <= maxRecords) return;
75+
logRecord.value = records.take(maxRecords).toList(growable: false);
76+
}
77+
78+
void clear() {
79+
logRecord.value = <loggy.LogRecord>[];
80+
}
81+
}

0 commit comments

Comments
 (0)