feat: hr indicator, adjust color theme

This commit is contained in:
2024-07-17 20:39:34 +08:00
parent ad1125af3e
commit 577063d520
17 changed files with 185 additions and 57 deletions

3
components.d.ts vendored
View File

@@ -18,10 +18,13 @@ declare module 'vue' {
SystemUiconsSignalFull: typeof import('./src/components/icons/SystemUiconsSignalFull.vue')['default'] SystemUiconsSignalFull: typeof import('./src/components/icons/SystemUiconsSignalFull.vue')['default']
SystemUiconsSignalLow: typeof import('./src/components/icons/SystemUiconsSignalLow.vue')['default'] SystemUiconsSignalLow: typeof import('./src/components/icons/SystemUiconsSignalLow.vue')['default']
SystemUiconsSignalMedium: typeof import('./src/components/icons/SystemUiconsSignalMedium.vue')['default'] SystemUiconsSignalMedium: typeof import('./src/components/icons/SystemUiconsSignalMedium.vue')['default']
TablerActivityHeartbeat: typeof import('./src/components/icons/TablerActivityHeartbeat.vue')['default']
TablerBluetooth: typeof import('./src/components/icons/TablerBluetooth.vue')['default'] TablerBluetooth: typeof import('./src/components/icons/TablerBluetooth.vue')['default']
TablerBluetoothConnected: typeof import('./src/components/icons/TablerBluetoothConnected.vue')['default'] TablerBluetoothConnected: typeof import('./src/components/icons/TablerBluetoothConnected.vue')['default']
TablerBluetoothOff: typeof import('./src/components/icons/TablerBluetoothOff.vue')['default'] TablerBluetoothOff: typeof import('./src/components/icons/TablerBluetoothOff.vue')['default']
TablerBluetoothX: typeof import('./src/components/icons/TablerBluetoothX.vue')['default'] TablerBluetoothX: typeof import('./src/components/icons/TablerBluetoothX.vue')['default']
TablerCaretDownFilled: typeof import('./src/components/icons/TablerCaretDownFilled.vue')['default']
TablerCaretUpFilled: typeof import('./src/components/icons/TablerCaretUpFilled.vue')['default']
TablerDeviceWatch: typeof import('./src/components/icons/TablerDeviceWatch.vue')['default'] TablerDeviceWatch: typeof import('./src/components/icons/TablerDeviceWatch.vue')['default']
TablerReload: typeof import('./src/components/icons/TablerReload.vue')['default'] TablerReload: typeof import('./src/components/icons/TablerReload.vue')['default']
} }

View File

@@ -2,10 +2,7 @@
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
use btleplug::api::bleuuid::uuid_from_u16; use btleplug::api::bleuuid::uuid_from_u16;
use btleplug::api::CentralEvent::{ use btleplug::api::CentralEvent::{DeviceDisconnected, DeviceDiscovered, DeviceUpdated};
DeviceConnected, DeviceDisconnected, DeviceDiscovered, DeviceUpdated,
ManufacturerDataAdvertisement, ServiceDataAdvertisement, ServicesAdvertisement,
};
use btleplug::api::{BDAddr, Central, Manager as _, Peripheral as _, ScanFilter}; use btleplug::api::{BDAddr, Central, Manager as _, Peripheral as _, ScanFilter};
use btleplug::platform::{Adapter, Manager as BtleManager, Peripheral, PeripheralId}; use btleplug::platform::{Adapter, Manager as BtleManager, Peripheral, PeripheralId};
use futures::StreamExt; use futures::StreamExt;
@@ -88,6 +85,7 @@ impl BleConnection {
{ {
return Err("Peripheral does not have the required service".into()); return Err("Peripheral does not have the required service".into());
} }
self.set_peripheral(Some(peripheral)).await; self.set_peripheral(Some(peripheral)).await;
let peripheral = self.peripheral.lock().await; let peripheral = self.peripheral.lock().await;
@@ -110,6 +108,40 @@ impl BleConnection {
.rssi .rssi
.unwrap(), .unwrap(),
}; };
let service = peripheral
.as_ref()
.unwrap()
.services()
.into_iter()
.find(|s| s.uuid == uuid_from_u16(0x180D))
.unwrap();
let characteristic = service
.characteristics
.into_iter()
.find(|c| c.uuid == uuid_from_u16(0x2A37))
.unwrap();
let peripheral = peripheral.clone();
peripheral
.as_ref()
.unwrap()
.subscribe(&characteristic)
.await?;
let app_clone = app.clone();
tokio::spawn(async move {
let mut notification_stream =
peripheral.as_ref().unwrap().notifications().await.unwrap();
while let Some(notification) = notification_stream.next().await {
if notification.uuid == uuid_from_u16(0x2A37) {
let value = notification.value;
let heart_rate = value[1] as u16;
app_clone.emit_all("heart-rate", heart_rate).unwrap();
}
}
});
app.emit_all("device-connected", device).unwrap(); app.emit_all("device-connected", device).unwrap();
Ok(()) Ok(())
} }
@@ -127,7 +159,7 @@ impl BleConnection {
let app_handle = app.clone(); // Clone the AppHandle to move into the tokio::spawn closure let app_handle = app.clone(); // Clone the AppHandle to move into the tokio::spawn closure
let mut event_stream = central.as_ref().unwrap().events().await.unwrap(); let mut event_stream = central.as_ref().unwrap().events().await.unwrap();
let mut self_clone = self.clone(); // Clone the BleConnection to move into the tokio::spawn closure let self_clone = self.clone(); // Clone the BleConnection to move into the tokio::spawn closure
tokio::spawn(async move { tokio::spawn(async move {
while let Some(event) = event_stream.next().await { while let Some(event) = event_stream.next().await {

View File

@@ -6,8 +6,8 @@
"distDir": "../dist" "distDir": "../dist"
}, },
"package": { "package": {
"productName": "heartbeat-cat", "productName": "HBCat",
"version": "0.0.1" "version": "1.0.1"
}, },
"tauri": { "tauri": {
"allowlist": { "allowlist": {
@@ -37,7 +37,8 @@
"minWidth": 800, "minWidth": 800,
"minHeight": 500, "minHeight": 500,
"width": 800, "width": 800,
"height": 500 "height": 500,
"userAgent": "Heartbeat Cat"
} }
], ],
"security": { "security": {
@@ -47,9 +48,20 @@
"active": true, "active": true,
"targets": "all", "targets": "all",
"identifier": "ga.bh8.heartbeat-cat", "identifier": "ga.bh8.heartbeat-cat",
"publisher": "TimothyYin",
"copyright": "2024 TimothyYin",
"category": "Utility",
"shortDescription": "HBCat",
"longDescription": "Catch your heartbeat",
"icon": [ "icon": [
"icons/icon.ico" "icons/icon.ico"
] ],
"windows": {
"nsis": {
"installerIcon": "icons/icon.ico",
"installMode": "both"
}
}
} }
} }
} }

View File

@@ -7,10 +7,6 @@ const store = useBrcatStore();
invoke("register_central_events"); invoke("register_central_events");
listen("heart-rate", (hr) => {
console.log('Heart Rate', hr);
})
listen("scan-list-update", (event) => { listen("scan-list-update", (event) => {
console.log('scan-list-update', event.payload); console.log('scan-list-update', event.payload);
}) })
@@ -52,7 +48,7 @@ listen("device-disconnected", (event) => {
--app-background: #f8f8f8; --app-background: #f8f8f8;
--title-bar-background: #e4e4e4; --title-bar-background: #e4e4e4;
--title-bar-color: #F25E86; --title-bar-color: var(--primary-color);
--title-bar-height: 25px; --title-bar-height: 25px;
--drawer-bar-background: #eeeeee; --drawer-bar-background: #eeeeee;

View File

@@ -71,7 +71,6 @@
} }
.btn { .btn {
border: none;
outline: none; outline: none;
padding: 6px 12px; padding: 6px 12px;
min-width: 30px; min-width: 30px;
@@ -79,7 +78,6 @@
cursor: pointer; cursor: pointer;
text-transform: uppercase; text-transform: uppercase;
color: #fff; color: #fff;
background-color: #F25E86;
-webkit-border-radius: 4px; -webkit-border-radius: 4px;
-moz-border-radius: 4px; -moz-border-radius: 4px;
-ms-border-radius: 4px; -ms-border-radius: 4px;
@@ -90,22 +88,19 @@
-moz-transition: all .3s ease; -moz-transition: all .3s ease;
-ms-transition: all .3s ease; -ms-transition: all .3s ease;
-o-transition: all .3s ease; -o-transition: all .3s ease;
@apply text-xs; @apply text-xs bg-primary border-0;
} }
.btn.outline { .btn.outline {
background-color: transparent; @apply text-primary bg-transparent border border-primary shadow-none;
color: #F25E86;
border: 1px solid #F25E86;
box-shadow: none;
} }
.btn:hover { .btn:hover {
box-shadow: 2px 2px 6px 1px rgb(0 0 0 / 20%); box-shadow: 2px 2px 6px 1px rgb(0 0 0 / .1);
} }
.btn:active { .btn:active {
box-shadow: 2px 2px 6px 1px rgb(0 0 0 / 20%), 2px 2px 6px 1px rgb(0 0 0 / 20%) inset; box-shadow: 2px 2px 6px 1px rgb(0 0 0 / .1), 2px 2px 6px 1px rgb(0 0 0 / .1) inset;
} }
.btn:disabled { .btn:disabled {

View File

@@ -21,12 +21,33 @@ const navList = ref([
<span>{{ item.title }}</span> <span>{{ item.title }}</span>
</router-link> </router-link>
</div> </div>
<div class="status"> <div>
<TablerBluetoothConnected v-if="store.is_connected" class="icon text-sm block -mt-0.5 text-emerald-500" /> <Transition name="indicator" mode="out-in">
<TablerBluetooth v-else class="icon text-sm block -mt-0.5 text-neutral-400" /> <div v-if="store.is_connected"
<span class="text" :title="store.is_connected ? store.connected_device?.name : undefined"> class="flex px-2 py-0.5 gap-1 justify-between items-center bg-neutral-200 border-b border-neutral-300">
{{ store.is_connected ? store.connected_device?.name : '未连接' }} <span class="inline-flex items-center gap-0.5 text-2xl text-primary font-bold">
</span> <TablerActivityHeartbeat class="text-base" />
{{ store.current_heart_rate || '--' }}
</span>
<span class="flex flex-col gap-0.5 text-neutral-400">
<span class="flex items-center gap-1 text-xs leading-none">
<TablerCaretUpFilled />
<span>{{ '--' }}</span>
</span>
<span class="flex items-center gap-1 text-xs leading-none">
<TablerCaretDownFilled />
<span>{{ '--' }}</span>
</span>
</span>
</div>
</Transition>
<div class="status">
<TablerBluetoothConnected v-if="store.is_connected" class="icon text-sm block -mt-0.5 text-emerald-500" />
<TablerBluetooth v-else class="icon text-sm block -mt-0.5 text-neutral-400" />
<span class="text" :title="store.is_connected ? store.connected_device?.name : undefined">
{{ store.is_connected ? store.connected_device?.name : '未连接' }}
</span>
</div>
</div> </div>
</div> </div>
<div class="content"> <div class="content">
@@ -36,6 +57,16 @@ const navList = ref([
</template> </template>
<style scoped> <style scoped>
.indicator-enter-active,
.indicator-leave-active {
@apply transition-all duration-150;
}
.indicator-enter-from,
.indicator-leave-to {
@apply opacity-0 translate-y-4;
}
.drawer { .drawer {
background-color: var(--app-background); background-color: var(--app-background);
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='28' height='49' viewBox='0 0 28 49'%3E%3Cg fill-rule='evenodd'%3E%3Cg id='hexagons' fill='%23ccc' fill-opacity='0.1' fill-rule='nonzero'%3E%3Cpath d='M13.99 9.25l13 7.5v15l-13 7.5L1 31.75v-15l12.99-7.5zM3 17.9v12.7l10.99 6.34 11-6.35V17.9l-11-6.34L3 17.9zM0 15l12.98-7.5V0h-2v6.35L0 12.69v2.3zm0 18.5L12.98 41v8h-2v-6.85L0 35.81v-2.3zM15 0v7.5L27.99 15H28v-2.31h-.01L17 6.35V0h-2zm0 49v-8l12.99-7.5H28v2.31h-.01L17 42.15V49h-2z'/%3E%3C/g%3E%3C/g%3E%3C/svg%3E"); background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='28' height='49' viewBox='0 0 28 49'%3E%3Cg fill-rule='evenodd'%3E%3Cg id='hexagons' fill='%23ccc' fill-opacity='0.1' fill-rule='nonzero'%3E%3Cpath d='M13.99 9.25l13 7.5v15l-13 7.5L1 31.75v-15l12.99-7.5zM3 17.9v12.7l10.99 6.34 11-6.35V17.9l-11-6.34L3 17.9zM0 15l12.98-7.5V0h-2v6.35L0 12.69v2.3zm0 18.5L12.98 41v8h-2v-6.85L0 35.81v-2.3zM15 0v7.5L27.99 15H28v-2.31h-.01L17 6.35V0h-2zm0 49v-8l12.99-7.5H28v2.31h-.01L17 42.15V49h-2z'/%3E%3C/g%3E%3C/g%3E%3C/svg%3E");
@@ -54,13 +85,13 @@ const navList = ref([
&.active { &.active {
/* background-color: var(--drawer-bar-item-active-background); */ /* background-color: var(--drawer-bar-item-active-background); */
@apply text-pink-400 border-l-4 border-pink-400 bg-pink-400/10 pl-0; @apply text-primary-400 border-l-4 border-primary-400 bg-primary-400/10 pl-0;
} }
} }
} }
.status { .status {
@apply w-full h-8 bg-neutral-300 flex items-center text-neutral-900 px-2 text-2xs whitespace-nowrap flex-nowrap; @apply w-full h-8 bg-neutral-200 flex items-center text-neutral-900 px-2 text-2xs whitespace-nowrap flex-nowrap;
.text { .text {
@apply flex-1 overflow-hidden text-ellipsis; @apply flex-1 overflow-hidden text-ellipsis;

View File

@@ -15,7 +15,7 @@ defineProps({
<template> <template>
<div class="flex flex-col h-full"> <div class="flex flex-col h-full">
<header <header
class="w-full h-11 px-4 flex justify-between items-center bg-gradient-to-r from-pink-50 to-pink-100 border-b"> class="w-full h-11 px-4 flex justify-between items-center bg-gradient-to-r from-primary-50 to-primary-100 border-b">
<div class="flex flex-col gap-0.5"> <div class="flex flex-col gap-0.5">
<h1 class="font-semibold text-neutral-900" :class="{ 'text-xs': !!subtitle, 'text-base': !subtitle }"> <h1 class="font-semibold text-neutral-900" :class="{ 'text-xs': !!subtitle, 'text-base': !subtitle }">
{{ title }} {{ title }}

View File

@@ -0,0 +1,10 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24"><path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12h4.5L9 6l4 12l2-9l1.5 3H21"></path></svg>
</template>
<script>
export default {
name: 'TablerActivityHeartbeat'
}
</script>

View File

@@ -0,0 +1,10 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24"><path fill="currentColor" d="M18 9c.852 0 1.297.986.783 1.623l-.076.084l-6 6a1 1 0 0 1-1.32.083l-.094-.083l-6-6l-.083-.094l-.054-.077l-.054-.096l-.017-.036l-.027-.067l-.032-.108l-.01-.053l-.01-.06l-.004-.057v-.118l.005-.058l.009-.06l.01-.052l.032-.108l.027-.067l.07-.132l.065-.09l.073-.081l.094-.083l.077-.054l.096-.054l.036-.017l.067-.027l.108-.032l.053-.01l.06-.01l.057-.004z"></path></svg>
</template>
<script>
export default {
name: 'TablerCaretDownFilled'
}
</script>

View File

@@ -0,0 +1,10 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24"><path fill="currentColor" d="M11.293 7.293a1 1 0 0 1 1.32-.083l.094.083l6 6l.083.094l.054.077l.054.096l.017.036l.027.067l.032.108l.01.053l.01.06l.004.057L19 14l-.002.059l-.005.058l-.009.06l-.01.052l-.032.108l-.027.067l-.07.132l-.065.09l-.073.081l-.094.083l-.077.054l-.096.054l-.036.017l-.067.027l-.108.032l-.053.01l-.06.01l-.057.004L18 15H6c-.852 0-1.297-.986-.783-1.623l.076-.084z"></path></svg>
</template>
<script>
export default {
name: 'TablerCaretUpFilled'
}
</script>

View File

@@ -3,11 +3,11 @@
</script> </script>
<template> <template>
<div> <PageContainer title="心率记录">
Charts <div class="w-full h-full flex justify-center items-center bg-white">
</div> <span class="text-sm font-semibold text-neutral-400">前面的区域以后再来探索吧</span>
</div>
</PageContainer>
</template> </template>
<style scoped> <style scoped></style>
</style>

View File

@@ -57,8 +57,7 @@ onMounted(() => {
<SvgSpinnersPulse2 class="icon text-5xl text-neutral-400" /> <SvgSpinnersPulse2 class="icon text-5xl text-neutral-400" />
<span class="text-sm font-semibold text-neutral-400">正在扫描蓝牙设备</span> <span class="text-sm font-semibold text-neutral-400">正在扫描蓝牙设备</span>
</div> </div>
<div v-else-if="is_connecting" <div v-else-if="is_connecting" class="w-full h-full flex flex-col gap-4 justify-center items-center">
class="w-full h-full flex flex-col gap-4 justify-center items-center">
<SvgSpinnersWifiFade class="icon text-5xl text-neutral-400" /> <SvgSpinnersWifiFade class="icon text-5xl text-neutral-400" />
<span class="text-sm font-semibold text-neutral-400">正在连接到设备</span> <span class="text-sm font-semibold text-neutral-400">正在连接到设备</span>
</div> </div>

View File

@@ -2,11 +2,11 @@
</script> </script>
<template> <template>
<div> <PageContainer title="设置">
Settings Page <div class="w-full h-full flex justify-center items-center bg-white">
</div> <span class="text-sm font-semibold text-neutral-400">前面的区域以后再来探索吧</span>
</div>
</PageContainer>
</template> </template>
<style scoped> <style scoped></style>
</style>

View File

@@ -3,9 +3,11 @@
</script> </script>
<template> <template>
<div> <PageContainer title="推流插件">
Streaming Plugins <div class="w-full h-full flex justify-center items-center bg-white">
</div> <span class="text-sm font-semibold text-neutral-400">前面的区域以后再来探索吧</span>
</div>
</PageContainer>
</template> </template>
<style scoped> <style scoped>

View File

@@ -3,11 +3,11 @@
</script> </script>
<template> <template>
<div> <PageContainer title="桌面组件">
Widgets Page <div class="w-full h-full flex justify-center items-center bg-white">
</div> <span class="text-sm font-semibold text-neutral-400">前面的区域以后再来探索吧</span>
</div>
</PageContainer>
</template> </template>
<style scoped> <style scoped></style>
</style>

View File

@@ -1,6 +1,7 @@
import { defineStore } from "pinia"; import { defineStore } from "pinia";
import { ref, watchEffect } from "vue"; import { onMounted, ref, watchEffect } from "vue";
import { invoke } from "@tauri-apps/api/tauri"; import { invoke } from "@tauri-apps/api/tauri";
import { listen } from "@tauri-apps/api/event";
import { useThrottleFn } from "@vueuse/core"; import { useThrottleFn } from "@vueuse/core";
export interface Device { export interface Device {
@@ -17,6 +18,16 @@ export const useBrcatStore = defineStore("brcat", () => {
const is_scanning = ref(false); const is_scanning = ref(false);
const current_heart_rate = ref<Number>(0);
onMounted(async () => {
const unlisten = await listen("heart-rate", (heart_rate) => {
current_heart_rate.value = heart_rate.payload as Number;
});
return unlisten;
})
setInterval(async () => { setInterval(async () => {
is_connected.value = await invoke("is_connected"); is_connected.value = await invoke("is_connected");
}, 500); }, 500);
@@ -64,6 +75,7 @@ export const useBrcatStore = defineStore("brcat", () => {
} }
return { return {
current_heart_rate,
is_connected, is_connected,
is_scanning, is_scanning,
connected_device, connected_device,

View File

@@ -4,6 +4,22 @@ export default <Config>{
content: ["./index.html", "./src/**/*.{vue,js,ts,jsx,tsx}"], content: ["./index.html", "./src/**/*.{vue,js,ts,jsx,tsx}"],
theme: { theme: {
extend: { extend: {
colors: {
primary: {
"50": "#fef1f6",
"100": "#fee5ef",
"200": "#ffcbe1",
"300": "#ffa1c6",
"400": "#ff6ea3",
"500": "#fa3a7b",
"600": "#ea1854",
"700": "#cc0a3c",
"800": "#a80c32",
"900": "#8c0f2d",
"950": "#560116",
DEFAULT: "#ff6ea3",
},
},
fontSize: { fontSize: {
"2xs": [ "2xs": [
"0.625rem", "0.625rem",