室内功率车 / 智能骑行台的模拟骑行 App。连接 BLE FTMS 骑行台与心率带,导入 Strava 路线,按真实坡度模拟阻力骑行;OSM 地图导航 + 可定制码表界面, 支持自动暂停 / 自动分段,结束后生成 FIT 文件并可自动上传 Strava(虚拟骑行)。
- BLE FTMS 连接 — 智能骑行台 / 功率车(服务
0x1826)- 解析 Indoor Bike Data(
0x2AD2):功率、踏频、速度、阻力、距离… - 写控制点(
0x2AD9)OpCode0x11设置坡度模拟阻力
- 解析 Indoor Bike Data(
- 心率带 — Heart Rate Service(
0x180D) - Strava 路线导入 — GPX / TCX,自动计算逐段距离与坡度
- 坡度模拟 — 按骑行台速度沿路线推进,实时把坡度写给骑行台
- OSM 地图 — 路线线条、当前位置、镜头跟随导航
- 码表界面 — 功率/心率/踏频/速度/距离/时间/坡度/平均值/卡路里,坡度条 + 海拔剖面
- 会话 — 自动暂停(低速/无功率)、自动分段(每 N 公里)、总结统计
- FIT 导出 & 自动上传 —
sport=cycling, subSport=virtualActivity→ 虚拟骑行, 同一份 FIT 可同时推送到 Strava 与 Intervals.icu
flutter pub get
flutter run # 连接真机(BLE 需真机,模拟器无蓝牙)无硬件时可在首页打开 演示模式,用示例路线体验完整流程。
凭据一律在 App 内「首页 → 设置」运行时填写,只存本机,分发的安装包里不含任何密钥。 自用构建也可以用 dart-define 预置默认值,运行时填写的值优先。
- 在 https://www.strava.com/settings/api 创建应用,拿到 Client ID / Secret。
- Authorization Callback Domain 填
localhost(Strava 只校验 host,不看 scheme)。 - 在 App 里「Strava → 编辑凭据」填入,再点 Connect 授权。
Strava OAuth 不支持 PKCE(换 token 强制要 secret),所以移动端去不掉 secret,只能靠
运行时填写或自建后端代理。回调 scheme veloverse://localhost 已在 iOS Info.plist
与 Android AndroidManifest.xml 中注册。
可选的构建期默认值:
flutter run \
--dart-define=STRAVA_CLIENT_ID=你的ID \
--dart-define=STRAVA_CLIENT_SECRET=你的密钥没有 OAuth 那一套:在 https://intervals.icu/settings 底部「Developer Settings」生成
API Key,在 App 里「Intervals.icu → 编辑 API Key」粘贴,点「测试连接」即可。
运动员 ID 会自动识别(接口用 athlete/0 代表「当前 key 的主人」),不用手填。
可选的构建期默认值:--dart-define=INTERVALS_API_KEY=你的Key。
Intervals.icu 按文件内容 hash 去重:HTTP 201 表示新建活动,200 表示这份 FIT 已经传过了。两者都算成功,App 会分别提示。
.github/workflows/build.yml 在 push / PR / 打 tag 时跑 flutter analyze + flutter test,
然后并行产出:
- Android
app-release.apk - iOS
VeloVerse-unsigned.ipa(未签名,需自行侧载签名)
打 v* tag 会把两份产物挂到 GitHub Release 上。CI 不需要任何 API 密钥。
Android 默认用 debug key 签名——每台机器/每次 CI 的 debug key 都不同,装了新包无法覆盖旧包。
想让产物能覆盖升级,就配好这几个仓库 secret,CI 会自动改用正式签名:
ANDROID_KEYSTORE_BASE64(base64 -i upload-keystore.jks)、ANDROID_STORE_PASSWORD、
ANDROID_KEY_ALIAS、ANDROID_KEY_PASSWORD。本地则放一个 android/key.properties(已 gitignore)。
见 ARCHITECTURE.md。核心分层:
lib/
core/ BLE UUID / OpCode 常量、格式化、haversine
models/ BikeData(FTMS 解析)、HrData、GeoRoute(坡度)、RideSample、Lap
services/
ble/ BleManager、FtmsService、HeartRateService
route/ RouteImporter(GPX/TCX)、GradientSimulator
session/ SessionManager(自动暂停/分段/统计)
export/ FitWriter(Garmin FIT)
strava/ StravaService(OAuth2 + 上传为 VirtualRide)
intervals/ IntervalsService(API Key + 上传 FIT)
state/ RideController(编排 1Hz 循环) + Riverpod providers
ui/ home / devices / ride(地图+码表+总结) / widgets
Garmin 官方 FIT SDK 只提供 Java/C/C++/C#/Obj-C/Rust,无官方 Dart 绑定。本项目用
社区包 fit_tool(忠实实现 FIT 二进制协议)编码,
所有 FIT 细节封装在 services/export/fit_writer.dart,便于日后替换为平台通道调用官方 SDK。
结论:要走 App 内的「上传 Strava」,别把导出的 FIT 手动传到 strava.com。
生成的 FIT 里 GPS 是完整的(每条 record 都带 position_lat/long,测试有 round-trip 断言)。
差别在上传通道:
- App 内上传走
POST /uploads并带sport_type=VirtualRide,这个参数会 覆盖从文件推断出的类型,活动被判为 Virtual Ride,地图正常。 - 手动在网页上传没有这个覆盖。Strava 不会把 virtual 类型授予一个未注册设备的文件
(我们是 manufacturer
255/development,拿不到 ANT+ 分配的厂商 ID),于是回落成室内 Ride——而室内活动 Strava 不渲染地图。 已经这样传上去的,可以在网页版活动里点铅笔图标、取消勾选 Indoor 标签,地图就会回来。
另外上传时绝不要带 trainer 字段,哪怕值是 0——它把活动标成室内,
实测会让轨迹消失。
strava_service.dart 里有注释守着这几条。
- BLE 与传感器功能需在 真机 上测试。
- OSM 瓦片来自
tile.openstreetmap.org,生产环境请遵守其使用政策或自建瓦片源。