feat(train): setting up and keeping the Polar Loop (ADR 0030, D34–D36)
On Android, the Loop through Polar's BLE SDK 8.3.0 (from JitPack, asked for Polar's group alone, its files pinned by SHA-256 in Gradle's verification metadata): LoopBridge.kt over app.halkyon.train/loop, the first-time use's figures mapped and checked in LoopSetup.kt, tested on the JVM. PolarLoop per person remembers the band in loop.json, connects while the app is open, sets its clock at each connection, keeps what it tells (battery, charging, firmware), offers a changed profile and sends it only when asked (D35: first-time use writes a new user identifier each time), and lets the band go by a factory reset after asking. The setup sheet finds Loops nearest first and asks only for what the profile lacks; Polar's defaults (60, 220 less the age, a VO2max from Polar's fitness tables) go to the band alone. Polar's SDK is not free software (D36, correcting D32): the app's AGPL gains a section 7 permission for linking it, LICENSE.md names it, and its licence and bundled notices join the licence list on Android. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
de6fe5c15a
commit
cd72cf6ccc
19 changed files with 3485 additions and 99 deletions
|
|
@ -188,5 +188,37 @@
|
|||
"profileSleepGoal": "Schlafziel",
|
||||
"profileSave": "Speichern",
|
||||
"profileSaved": "Gespeichert",
|
||||
"profileInvalid": "Prüfe die markierten Angaben."
|
||||
"profileInvalid": "Prüfe die markierten Angaben.",
|
||||
|
||||
"profileNeeded": "Braucht dein Band",
|
||||
"loopTitle": "Polar Loop",
|
||||
"loopHint": "Dein Band: Einrichtung, Akku und Uhrzeit",
|
||||
"loopNone": "Train richtet einen Polar Loop ein und betreut ihn selbst, ohne Polar Flow und ohne Polar-Konto. Ein Band, das mit Polar Flow verbunden war, braucht zuerst einen Werksreset.",
|
||||
"loopSetUp": "Loop einrichten",
|
||||
"loopSetUpAgain": "Neu einrichten",
|
||||
"loopLetGo": "Band freigeben",
|
||||
"loopLetGoTitle": "Band freigeben?",
|
||||
"loopLetGoText": "Das Band wird auf Werkseinstellungen zurückgesetzt, und was es hält und noch nicht bei Train angekommen ist, geht verloren. Danach kannst du es mit einem anderen Handy oder mit Polar Flow koppeln.",
|
||||
"loopLetGoConfirm": "Band zurücksetzen",
|
||||
"loopConnected": "Verbunden",
|
||||
"loopLastSeen": "Zuletzt gesehen {when}",
|
||||
"loopNotInReach": "Nicht in Reichweite",
|
||||
"loopBattery": "Akku {level} %",
|
||||
"loopCharging": "Akku {level} %, lädt",
|
||||
"loopFirmware": "Firmware {version}",
|
||||
"loopProfileChanged": "Dein Körper hat sich geändert, seit das Band eingerichtet wurde",
|
||||
"loopProfileChangedHint": "Senden richtet das Band mit deinen jetzigen Angaben neu ein.",
|
||||
"loopSend": "Ans Band senden",
|
||||
"loopSent": "Ans Band gesendet.",
|
||||
"loopFailed": "Das Band hat nicht geantwortet. Halte es nah und versuch es noch mal.",
|
||||
"loopSearching": "Suche Loops in der Nähe",
|
||||
"loopSearchingHint": "Halte das Band nah; tipp darauf, um es zu wecken.",
|
||||
"loopRefused": "Ohne Geräte in der Nähe kann Train dein Band nicht suchen. Erlaube sie in den Android-Einstellungen.",
|
||||
"loopUnnamed": "Polar Loop",
|
||||
"loopSettingUp": "Richte das Band ein …",
|
||||
"loopSettingUpHint": "Android fragt dich vielleicht, ob du es koppeln willst.",
|
||||
"loopSetUpDone": "Das Band ist eingerichtet.",
|
||||
"loopFormTitle": "Für dein Band",
|
||||
"loopFormIntro": "Dein Band braucht diese Angaben. Sie bleiben in „Dein Körper“, mit deinem Schlüssel versiegelt. Für einen Ruhe- oder Maximalpuls oder eine VO2max, die du nicht angegeben hast, nimmt das Band Polars Standardwerte, und „Dein Körper“ bleibt, wie er ist.",
|
||||
"loopFormSave": "Band einrichten"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -239,5 +239,42 @@
|
|||
"profileSleepGoal": "Sleep goal",
|
||||
"profileSave": "Save",
|
||||
"profileSaved": "Saved",
|
||||
"profileInvalid": "Check the figures marked."
|
||||
"profileInvalid": "Check the figures marked.",
|
||||
|
||||
"profileNeeded": "Needed for your band",
|
||||
"loopTitle": "Polar Loop",
|
||||
"loopHint": "Your band: its setup, battery and clock",
|
||||
"@loopHint": {"description": "Under Polar Loop in the account sheet."},
|
||||
"loopNone": "Train sets up a Polar Loop and keeps it itself, without Polar Flow or a Polar account. A band used with Polar Flow needs a factory reset first.",
|
||||
"loopSetUp": "Set up a Loop",
|
||||
"loopSetUpAgain": "Set up again",
|
||||
"loopLetGo": "Let the band go",
|
||||
"loopLetGoTitle": "Let the band go?",
|
||||
"loopLetGoText": "The band is reset to the factory's state, and what it holds that hasn't reached Train is lost. Afterwards it can be paired with another phone, or with Polar Flow.",
|
||||
"loopLetGoConfirm": "Reset the band",
|
||||
"loopConnected": "Connected",
|
||||
"loopLastSeen": "Last seen {when}",
|
||||
"@loopLastSeen": {"placeholders": {"when": {"type": "String", "example": "28 Oct 2026, 18:30"}}},
|
||||
"loopNotInReach": "Not in reach",
|
||||
"loopBattery": "Battery {level} %",
|
||||
"@loopBattery": {"placeholders": {"level": {"type": "int"}}},
|
||||
"loopCharging": "Battery {level} %, charging",
|
||||
"@loopCharging": {"placeholders": {"level": {"type": "int"}}},
|
||||
"loopFirmware": "Firmware {version}",
|
||||
"@loopFirmware": {"placeholders": {"version": {"type": "String", "example": "1.2.3"}}},
|
||||
"loopProfileChanged": "Your body changed since the band was set up",
|
||||
"loopProfileChangedHint": "Sending sets the band up again with your figures as they are now.",
|
||||
"loopSend": "Send to the band",
|
||||
"loopSent": "Sent to the band.",
|
||||
"loopFailed": "The band didn't answer. Keep it close, and try again.",
|
||||
"loopSearching": "Looking for Loops nearby",
|
||||
"loopSearchingHint": "Keep the band close; tap it to wake it up.",
|
||||
"loopRefused": "Train can't look for your band without nearby devices. Allow them in Android's settings.",
|
||||
"loopUnnamed": "Polar Loop",
|
||||
"loopSettingUp": "Setting up the band…",
|
||||
"loopSettingUpHint": "Android may ask you to pair it.",
|
||||
"loopSetUpDone": "The band is set up.",
|
||||
"loopFormTitle": "For your band",
|
||||
"loopFormIntro": "Your band needs these. They're kept in Your body, sealed with your key. For a resting or highest heart rate or a VO2max you haven't given, the band takes Polar's defaults, and Your body stays as it is.",
|
||||
"loopFormSave": "Set up the band"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1161,6 +1161,192 @@ abstract class AppLocalizations {
|
|||
/// In en, this message translates to:
|
||||
/// **'Check the figures marked.'**
|
||||
String get profileInvalid;
|
||||
|
||||
/// No description provided for @profileNeeded.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Needed for your band'**
|
||||
String get profileNeeded;
|
||||
|
||||
/// No description provided for @loopTitle.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Polar Loop'**
|
||||
String get loopTitle;
|
||||
|
||||
/// Under Polar Loop in the account sheet.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Your band: its setup, battery and clock'**
|
||||
String get loopHint;
|
||||
|
||||
/// No description provided for @loopNone.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Train sets up a Polar Loop and keeps it itself, without Polar Flow or a Polar account. A band used with Polar Flow needs a factory reset first.'**
|
||||
String get loopNone;
|
||||
|
||||
/// No description provided for @loopSetUp.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Set up a Loop'**
|
||||
String get loopSetUp;
|
||||
|
||||
/// No description provided for @loopSetUpAgain.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Set up again'**
|
||||
String get loopSetUpAgain;
|
||||
|
||||
/// No description provided for @loopLetGo.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Let the band go'**
|
||||
String get loopLetGo;
|
||||
|
||||
/// No description provided for @loopLetGoTitle.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Let the band go?'**
|
||||
String get loopLetGoTitle;
|
||||
|
||||
/// No description provided for @loopLetGoText.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'The band is reset to the factory\'s state, and what it holds that hasn\'t reached Train is lost. Afterwards it can be paired with another phone, or with Polar Flow.'**
|
||||
String get loopLetGoText;
|
||||
|
||||
/// No description provided for @loopLetGoConfirm.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Reset the band'**
|
||||
String get loopLetGoConfirm;
|
||||
|
||||
/// No description provided for @loopConnected.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Connected'**
|
||||
String get loopConnected;
|
||||
|
||||
/// No description provided for @loopLastSeen.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Last seen {when}'**
|
||||
String loopLastSeen(String when);
|
||||
|
||||
/// No description provided for @loopNotInReach.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Not in reach'**
|
||||
String get loopNotInReach;
|
||||
|
||||
/// No description provided for @loopBattery.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Battery {level} %'**
|
||||
String loopBattery(int level);
|
||||
|
||||
/// No description provided for @loopCharging.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Battery {level} %, charging'**
|
||||
String loopCharging(int level);
|
||||
|
||||
/// No description provided for @loopFirmware.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Firmware {version}'**
|
||||
String loopFirmware(String version);
|
||||
|
||||
/// No description provided for @loopProfileChanged.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Your body changed since the band was set up'**
|
||||
String get loopProfileChanged;
|
||||
|
||||
/// No description provided for @loopProfileChangedHint.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Sending sets the band up again with your figures as they are now.'**
|
||||
String get loopProfileChangedHint;
|
||||
|
||||
/// No description provided for @loopSend.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Send to the band'**
|
||||
String get loopSend;
|
||||
|
||||
/// No description provided for @loopSent.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Sent to the band.'**
|
||||
String get loopSent;
|
||||
|
||||
/// No description provided for @loopFailed.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'The band didn\'t answer. Keep it close, and try again.'**
|
||||
String get loopFailed;
|
||||
|
||||
/// No description provided for @loopSearching.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Looking for Loops nearby'**
|
||||
String get loopSearching;
|
||||
|
||||
/// No description provided for @loopSearchingHint.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Keep the band close; tap it to wake it up.'**
|
||||
String get loopSearchingHint;
|
||||
|
||||
/// No description provided for @loopRefused.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Train can\'t look for your band without nearby devices. Allow them in Android\'s settings.'**
|
||||
String get loopRefused;
|
||||
|
||||
/// No description provided for @loopUnnamed.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Polar Loop'**
|
||||
String get loopUnnamed;
|
||||
|
||||
/// No description provided for @loopSettingUp.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Setting up the band…'**
|
||||
String get loopSettingUp;
|
||||
|
||||
/// No description provided for @loopSettingUpHint.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Android may ask you to pair it.'**
|
||||
String get loopSettingUpHint;
|
||||
|
||||
/// No description provided for @loopSetUpDone.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'The band is set up.'**
|
||||
String get loopSetUpDone;
|
||||
|
||||
/// No description provided for @loopFormTitle.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'For your band'**
|
||||
String get loopFormTitle;
|
||||
|
||||
/// No description provided for @loopFormIntro.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Your band needs these. They\'re kept in Your body, sealed with your key. For a resting or highest heart rate or a VO2max you haven\'t given, the band takes Polar\'s defaults, and Your body stays as it is.'**
|
||||
String get loopFormIntro;
|
||||
|
||||
/// No description provided for @loopFormSave.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Set up the band'**
|
||||
String get loopFormSave;
|
||||
}
|
||||
|
||||
class _AppLocalizationsDelegate
|
||||
|
|
|
|||
|
|
@ -637,4 +637,114 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||
|
||||
@override
|
||||
String get profileInvalid => 'Prüfe die markierten Angaben.';
|
||||
|
||||
@override
|
||||
String get profileNeeded => 'Braucht dein Band';
|
||||
|
||||
@override
|
||||
String get loopTitle => 'Polar Loop';
|
||||
|
||||
@override
|
||||
String get loopHint => 'Dein Band: Einrichtung, Akku und Uhrzeit';
|
||||
|
||||
@override
|
||||
String get loopNone =>
|
||||
'Train richtet einen Polar Loop ein und betreut ihn selbst, ohne Polar Flow und ohne Polar-Konto. Ein Band, das mit Polar Flow verbunden war, braucht zuerst einen Werksreset.';
|
||||
|
||||
@override
|
||||
String get loopSetUp => 'Loop einrichten';
|
||||
|
||||
@override
|
||||
String get loopSetUpAgain => 'Neu einrichten';
|
||||
|
||||
@override
|
||||
String get loopLetGo => 'Band freigeben';
|
||||
|
||||
@override
|
||||
String get loopLetGoTitle => 'Band freigeben?';
|
||||
|
||||
@override
|
||||
String get loopLetGoText =>
|
||||
'Das Band wird auf Werkseinstellungen zurückgesetzt, und was es hält und noch nicht bei Train angekommen ist, geht verloren. Danach kannst du es mit einem anderen Handy oder mit Polar Flow koppeln.';
|
||||
|
||||
@override
|
||||
String get loopLetGoConfirm => 'Band zurücksetzen';
|
||||
|
||||
@override
|
||||
String get loopConnected => 'Verbunden';
|
||||
|
||||
@override
|
||||
String loopLastSeen(String when) {
|
||||
return 'Zuletzt gesehen $when';
|
||||
}
|
||||
|
||||
@override
|
||||
String get loopNotInReach => 'Nicht in Reichweite';
|
||||
|
||||
@override
|
||||
String loopBattery(int level) {
|
||||
return 'Akku $level %';
|
||||
}
|
||||
|
||||
@override
|
||||
String loopCharging(int level) {
|
||||
return 'Akku $level %, lädt';
|
||||
}
|
||||
|
||||
@override
|
||||
String loopFirmware(String version) {
|
||||
return 'Firmware $version';
|
||||
}
|
||||
|
||||
@override
|
||||
String get loopProfileChanged =>
|
||||
'Dein Körper hat sich geändert, seit das Band eingerichtet wurde';
|
||||
|
||||
@override
|
||||
String get loopProfileChangedHint =>
|
||||
'Senden richtet das Band mit deinen jetzigen Angaben neu ein.';
|
||||
|
||||
@override
|
||||
String get loopSend => 'Ans Band senden';
|
||||
|
||||
@override
|
||||
String get loopSent => 'Ans Band gesendet.';
|
||||
|
||||
@override
|
||||
String get loopFailed =>
|
||||
'Das Band hat nicht geantwortet. Halte es nah und versuch es noch mal.';
|
||||
|
||||
@override
|
||||
String get loopSearching => 'Suche Loops in der Nähe';
|
||||
|
||||
@override
|
||||
String get loopSearchingHint =>
|
||||
'Halte das Band nah; tipp darauf, um es zu wecken.';
|
||||
|
||||
@override
|
||||
String get loopRefused =>
|
||||
'Ohne Geräte in der Nähe kann Train dein Band nicht suchen. Erlaube sie in den Android-Einstellungen.';
|
||||
|
||||
@override
|
||||
String get loopUnnamed => 'Polar Loop';
|
||||
|
||||
@override
|
||||
String get loopSettingUp => 'Richte das Band ein …';
|
||||
|
||||
@override
|
||||
String get loopSettingUpHint =>
|
||||
'Android fragt dich vielleicht, ob du es koppeln willst.';
|
||||
|
||||
@override
|
||||
String get loopSetUpDone => 'Das Band ist eingerichtet.';
|
||||
|
||||
@override
|
||||
String get loopFormTitle => 'Für dein Band';
|
||||
|
||||
@override
|
||||
String get loopFormIntro =>
|
||||
'Dein Band braucht diese Angaben. Sie bleiben in „Dein Körper“, mit deinem Schlüssel versiegelt. Für einen Ruhe- oder Maximalpuls oder eine VO2max, die du nicht angegeben hast, nimmt das Band Polars Standardwerte, und „Dein Körper“ bleibt, wie er ist.';
|
||||
|
||||
@override
|
||||
String get loopFormSave => 'Band einrichten';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -633,6 +633,114 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||
|
||||
@override
|
||||
String get profileInvalid => 'Check the figures marked.';
|
||||
|
||||
@override
|
||||
String get profileNeeded => 'Needed for your band';
|
||||
|
||||
@override
|
||||
String get loopTitle => 'Polar Loop';
|
||||
|
||||
@override
|
||||
String get loopHint => 'Your band: its setup, battery and clock';
|
||||
|
||||
@override
|
||||
String get loopNone =>
|
||||
'Train sets up a Polar Loop and keeps it itself, without Polar Flow or a Polar account. A band used with Polar Flow needs a factory reset first.';
|
||||
|
||||
@override
|
||||
String get loopSetUp => 'Set up a Loop';
|
||||
|
||||
@override
|
||||
String get loopSetUpAgain => 'Set up again';
|
||||
|
||||
@override
|
||||
String get loopLetGo => 'Let the band go';
|
||||
|
||||
@override
|
||||
String get loopLetGoTitle => 'Let the band go?';
|
||||
|
||||
@override
|
||||
String get loopLetGoText =>
|
||||
'The band is reset to the factory\'s state, and what it holds that hasn\'t reached Train is lost. Afterwards it can be paired with another phone, or with Polar Flow.';
|
||||
|
||||
@override
|
||||
String get loopLetGoConfirm => 'Reset the band';
|
||||
|
||||
@override
|
||||
String get loopConnected => 'Connected';
|
||||
|
||||
@override
|
||||
String loopLastSeen(String when) {
|
||||
return 'Last seen $when';
|
||||
}
|
||||
|
||||
@override
|
||||
String get loopNotInReach => 'Not in reach';
|
||||
|
||||
@override
|
||||
String loopBattery(int level) {
|
||||
return 'Battery $level %';
|
||||
}
|
||||
|
||||
@override
|
||||
String loopCharging(int level) {
|
||||
return 'Battery $level %, charging';
|
||||
}
|
||||
|
||||
@override
|
||||
String loopFirmware(String version) {
|
||||
return 'Firmware $version';
|
||||
}
|
||||
|
||||
@override
|
||||
String get loopProfileChanged =>
|
||||
'Your body changed since the band was set up';
|
||||
|
||||
@override
|
||||
String get loopProfileChangedHint =>
|
||||
'Sending sets the band up again with your figures as they are now.';
|
||||
|
||||
@override
|
||||
String get loopSend => 'Send to the band';
|
||||
|
||||
@override
|
||||
String get loopSent => 'Sent to the band.';
|
||||
|
||||
@override
|
||||
String get loopFailed =>
|
||||
'The band didn\'t answer. Keep it close, and try again.';
|
||||
|
||||
@override
|
||||
String get loopSearching => 'Looking for Loops nearby';
|
||||
|
||||
@override
|
||||
String get loopSearchingHint => 'Keep the band close; tap it to wake it up.';
|
||||
|
||||
@override
|
||||
String get loopRefused =>
|
||||
'Train can\'t look for your band without nearby devices. Allow them in Android\'s settings.';
|
||||
|
||||
@override
|
||||
String get loopUnnamed => 'Polar Loop';
|
||||
|
||||
@override
|
||||
String get loopSettingUp => 'Setting up the band…';
|
||||
|
||||
@override
|
||||
String get loopSettingUpHint => 'Android may ask you to pair it.';
|
||||
|
||||
@override
|
||||
String get loopSetUpDone => 'The band is set up.';
|
||||
|
||||
@override
|
||||
String get loopFormTitle => 'For your band';
|
||||
|
||||
@override
|
||||
String get loopFormIntro =>
|
||||
'Your band needs these. They\'re kept in Your body, sealed with your key. For a resting or highest heart rate or a VO2max you haven\'t given, the band takes Polar\'s defaults, and Your body stays as it is.';
|
||||
|
||||
@override
|
||||
String get loopFormSave => 'Set up the band';
|
||||
}
|
||||
|
||||
/// The translations for English, as used in the United Kingdom (`en_GB`).
|
||||
|
|
|
|||
|
|
@ -12,10 +12,14 @@ import 'package:serverpod_flutter/serverpod_flutter.dart';
|
|||
Future<void> main() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
// Noto Sans' notice (halkyon_ui), the flags' and Unicode CLDR's
|
||||
// (halkyon_app), and Lucide's for the app's icon.
|
||||
// (halkyon_app), Lucide's for the app's icon, and Polar's where its SDK
|
||||
// is linked, on Android.
|
||||
registerFontLicenses();
|
||||
registerHalkyonAppLicenses();
|
||||
registerLucideLicense();
|
||||
if (Platform.isAndroid) {
|
||||
registerPolarLicense();
|
||||
}
|
||||
// The instance the build names (`--dart-define=SERVER_URL=…`, or
|
||||
// `assets/config.json`): the first offered at the first start.
|
||||
final builtIn = Instance.parse(await getServerUrl())!;
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import 'package:halkyon_train_flutter/l10n/app_localizations.dart';
|
|||
import 'package:halkyon_train_flutter/src/app/train_dependencies.dart';
|
||||
import 'package:halkyon_train_flutter/src/app/train_frame.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/health/widget/health_screen.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/loop/widget/loop_screen.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/profile/widget/profile_screen.dart';
|
||||
import 'package:halkyon_ui/halkyon_ui.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
|
|
@ -61,6 +62,14 @@ class const _AccountSheet() extends StatelessWidget {
|
|||
subtitle: Text(l10n.healthHint),
|
||||
onTap: () => openHealthScreen(context),
|
||||
),
|
||||
// The person's Polar Loop on this phone (D34).
|
||||
if (PersonScope.of<TrainPerson>(context).loop != null)
|
||||
ListTile(
|
||||
leading: const Icon(LucideIcons.watch),
|
||||
title: Text(l10n.loopTitle),
|
||||
subtitle: Text(l10n.loopHint),
|
||||
onTap: () => openLoopScreen(context),
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(LucideIcons.info),
|
||||
title: Text(l10n.aboutTitle),
|
||||
|
|
|
|||
|
|
@ -12,3 +12,22 @@ void registerLucideLicense() => LicenseRegistry.addLicense(() async* {
|
|||
'Lucide (the app icon)',
|
||||
], await rootBundle.loadString(lucideLicenseAsset));
|
||||
});
|
||||
|
||||
/// Where Polar's notices are: its SDK's licence and the notices of the
|
||||
/// libraries it bundles, verbatim from the SDK's 8.3.0 tag (ADR 0030, D36).
|
||||
const polarLicenseAssets = (
|
||||
sdk: 'assets/licences/Polar_SDK_License.txt',
|
||||
bundled: 'assets/licences/Polar_ThirdPartySoftwareListing.txt',
|
||||
);
|
||||
|
||||
/// Adds Polar's notices to the app's licence list: the Android app links
|
||||
/// Polar's BLE SDK for the Loop (D34, D36), whose licence asks that its
|
||||
/// notice go with every copy.
|
||||
void registerPolarLicense() => LicenseRegistry.addLicense(() async* {
|
||||
yield LicenseEntryWithLineBreaks(const [
|
||||
"Polar BLE SDK (the Polar Loop's connection)",
|
||||
], await rootBundle.loadString(polarLicenseAssets.sdk));
|
||||
yield LicenseEntryWithLineBreaks(const [
|
||||
"Polar BLE SDK (the Polar Loop's connection)",
|
||||
], await rootBundle.loadString(polarLicenseAssets.bundled));
|
||||
});
|
||||
|
|
|
|||
|
|
@ -11,6 +11,9 @@ import 'package:halkyon_train_flutter/src/core/privacy/train_keys.dart';
|
|||
import 'package:halkyon_train_flutter/src/feature/health/data/android_health_platform.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/health/data/health_connect.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/health/data/health_store.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/loop/data/android_loop_platform.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/loop/data/loop_store.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/loop/data/polar_loop.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/map/data/map_source_repository.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/map/widget/map_tiles.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/profile/data/profile_repository.dart';
|
||||
|
|
@ -65,6 +68,10 @@ class TrainPerson(
|
|||
/// Health Connect on this phone (D31); null where there is none (iOS
|
||||
/// until Apple Health, the desktop).
|
||||
final HealthConnect? health,
|
||||
|
||||
/// Their Polar Loop on this phone (D34); null where Train can't hold one
|
||||
/// (iOS for now, the desktop).
|
||||
final PolarLoop? loop,
|
||||
final Future<void> Function()? _onDispose,
|
||||
}) implements Disposable {
|
||||
/// Workouts deleted, to be undone for a moment (D24); dropped with the
|
||||
|
|
@ -76,6 +83,7 @@ class TrainPerson(
|
|||
Future<void> dispose() async {
|
||||
await deletions.dispose();
|
||||
await health?.dispose();
|
||||
await loop?.dispose();
|
||||
await _onDispose?.call();
|
||||
}
|
||||
}
|
||||
|
|
@ -318,20 +326,30 @@ Future<TrainPerson> _openPersonData(
|
|||
workouts,
|
||||
)..start())
|
||||
: null;
|
||||
final recorder = Platform.isAndroid ? AndroidRecorderPlatform() : null;
|
||||
// Connects to the band remembered while the app is open (D34).
|
||||
final loop = recorder == null
|
||||
? null
|
||||
: (PolarLoop(
|
||||
AndroidLoopPlatform(recorder),
|
||||
LoopStore(File(p.join(folder.path, 'loop.json'))),
|
||||
profile,
|
||||
)..start());
|
||||
return TrainPerson(
|
||||
person,
|
||||
workouts: workouts,
|
||||
profile: profile,
|
||||
maps: MapSourceRepository(ServerpodMapSourceGateway(client)),
|
||||
recording: Platform.isAndroid
|
||||
recording: recorder != null
|
||||
? (
|
||||
platform: AndroidRecorderPlatform(),
|
||||
platform: recorder,
|
||||
file: File(p.join(folder.path, 'recording.txt')),
|
||||
straps: DeviceStore(File(p.join(folder.path, 'strap.json'))),
|
||||
rowers: DeviceStore(File(p.join(folder.path, 'rower.json'))),
|
||||
)
|
||||
: null,
|
||||
health: health,
|
||||
loop: loop,
|
||||
onDispose: () async {
|
||||
try {
|
||||
await workouts.dispose();
|
||||
|
|
|
|||
|
|
@ -0,0 +1,66 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:equatable/equatable.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/loop/data/loop_platform.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/loop/data/polar_loop.dart';
|
||||
|
||||
/// What the band's screen asked last, and how it went.
|
||||
enum LoopOutcome() {
|
||||
sent,
|
||||
letGo,
|
||||
failed,
|
||||
}
|
||||
|
||||
/// The band's screen (ADR 0030, D34, D35): where the band stands, sending
|
||||
/// a changed profile when the person says, and letting it go.
|
||||
class LoopCubit(final PolarLoop _loop) extends Cubit<LoopState> {
|
||||
this : super(LoopState(status: _loop.status));
|
||||
|
||||
StreamSubscription<LoopStatus>? _changes;
|
||||
|
||||
void open() {
|
||||
_changes = _loop.changes.listen(
|
||||
(status) => emit(
|
||||
LoopState(status: status, busy: state.busy, outcome: state.outcome),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> sendProfile() => _do(_loop.sendProfile, LoopOutcome.sent);
|
||||
|
||||
Future<void> letGo() => _do(_loop.letGo, LoopOutcome.letGo);
|
||||
|
||||
Future<void> _do(Future<void> Function() action, LoopOutcome done) async {
|
||||
emit(LoopState(status: state.status, busy: true));
|
||||
LoopOutcome outcome;
|
||||
try {
|
||||
await action();
|
||||
outcome = done;
|
||||
} on LoopFailed {
|
||||
outcome = LoopOutcome.failed;
|
||||
}
|
||||
if (!isClosed) {
|
||||
emit(LoopState(status: _loop.status, outcome: outcome));
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> close() async {
|
||||
await _changes?.cancel();
|
||||
await super.close();
|
||||
}
|
||||
}
|
||||
|
||||
final class const LoopState({
|
||||
required final LoopStatus status,
|
||||
|
||||
/// Whether the band is being asked something.
|
||||
final bool busy = false,
|
||||
|
||||
/// How the last question went; null until one is done.
|
||||
final LoopOutcome? outcome,
|
||||
}) with Equatable {
|
||||
@override
|
||||
List<Object?> get props => [status, busy, outcome];
|
||||
}
|
||||
|
|
@ -0,0 +1,184 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:equatable/equatable.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/loop/data/loop_platform.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/loop/data/polar_loop.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/loop/model/loop_figures.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/profile/data/profile_repository.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/profile/model/profile.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/recording/data/recorder_platform.dart';
|
||||
|
||||
/// Where a Loop's setup stands.
|
||||
enum LoopSetupPhase() {
|
||||
/// Asking for nearby devices.
|
||||
asking,
|
||||
|
||||
/// Nearby devices refused.
|
||||
refused,
|
||||
|
||||
/// Looking: the Loops found so far.
|
||||
searching,
|
||||
|
||||
/// The profile lacks what the band needs: [LoopSetupState.missing].
|
||||
form,
|
||||
|
||||
/// The profile doesn't open on this device.
|
||||
locked,
|
||||
|
||||
/// Connecting and setting up the band chosen.
|
||||
settingUp,
|
||||
|
||||
done,
|
||||
|
||||
/// The band didn't connect or refused; the search goes on.
|
||||
failed,
|
||||
}
|
||||
|
||||
/// Setting up a Loop (ADR 0030, D34): Loops nearby, the nearest first, once
|
||||
/// nearby devices are allowed; the one chosen set up with the profile, what
|
||||
/// it lacks asked for first and kept in it.
|
||||
class LoopSetupCubit(
|
||||
final PolarLoop _loop,
|
||||
final ProfileRepository _profile, {
|
||||
|
||||
/// Today, for the age the defaults take.
|
||||
final DateTime Function() today = DateTime.now,
|
||||
}) extends Cubit<LoopSetupState> {
|
||||
this : super(const LoopSetupState());
|
||||
|
||||
StreamSubscription<FoundDevice>? _search;
|
||||
|
||||
Future<void> open() async {
|
||||
final allowed = await _loop.platform.askBluetooth();
|
||||
if (isClosed) {
|
||||
return;
|
||||
}
|
||||
if (!allowed) {
|
||||
emit(const LoopSetupState(phase: LoopSetupPhase.refused));
|
||||
return;
|
||||
}
|
||||
emit(const LoopSetupState(phase: LoopSetupPhase.searching));
|
||||
_search = _loop.platform.search().listen(_seen, onError: (Object _) {});
|
||||
}
|
||||
|
||||
void _seen(FoundDevice device) {
|
||||
final before = [
|
||||
for (final found in state.found)
|
||||
if (found.address == device.address) found,
|
||||
].firstOrNull;
|
||||
emit(
|
||||
_with(
|
||||
found: [
|
||||
for (final found in state.found)
|
||||
if (found.address != device.address) found,
|
||||
FoundDevice(
|
||||
address: device.address,
|
||||
name: device.name ?? before?.name,
|
||||
rssi: device.rssi,
|
||||
),
|
||||
]..sort((a, b) => b.rssi.compareTo(a.rssi)),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Sets up [device], asking first for what the profile lacks.
|
||||
Future<void> choose(FoundDevice device) async {
|
||||
final profile = await _profile.read();
|
||||
if (isClosed) {
|
||||
return;
|
||||
}
|
||||
if (profile == null) {
|
||||
emit(_with(phase: LoopSetupPhase.locked));
|
||||
return;
|
||||
}
|
||||
final missing = loopMissing(profile);
|
||||
if (missing.isNotEmpty) {
|
||||
emit(
|
||||
_with(
|
||||
phase: LoopSetupPhase.form,
|
||||
chosen: device,
|
||||
profile: profile,
|
||||
missing: missing,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
await _setUp(device, profile);
|
||||
}
|
||||
|
||||
/// Keeps what the form gave in the profile, and sets up the band chosen.
|
||||
Future<void> completeProfile(ProfileContent profile) async {
|
||||
final device = state.chosen;
|
||||
if (device == null) {
|
||||
return;
|
||||
}
|
||||
await _profile.save(profile);
|
||||
await _setUp(device, profile);
|
||||
}
|
||||
|
||||
/// Back to the search, the form left without its figures.
|
||||
void formLeft() => emit(_with(phase: LoopSetupPhase.searching));
|
||||
|
||||
Future<void> _setUp(FoundDevice device, ProfileContent profile) async {
|
||||
final figures = loopFiguresOf(profile, today());
|
||||
if (figures == null) {
|
||||
return;
|
||||
}
|
||||
emit(_with(phase: LoopSetupPhase.settingUp, chosen: device));
|
||||
// The search stops while the band connects, as scanning slows that.
|
||||
await _search?.cancel();
|
||||
_search = null;
|
||||
try {
|
||||
await _loop.setUp(device, figures);
|
||||
if (!isClosed) {
|
||||
emit(_with(phase: LoopSetupPhase.done));
|
||||
}
|
||||
} on LoopFailed {
|
||||
if (!isClosed) {
|
||||
emit(_with(phase: LoopSetupPhase.failed));
|
||||
_search = _loop.platform.search().listen(
|
||||
_seen,
|
||||
onError: (Object _) {},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LoopSetupState _with({
|
||||
LoopSetupPhase? phase,
|
||||
List<FoundDevice>? found,
|
||||
FoundDevice? chosen,
|
||||
ProfileContent? profile,
|
||||
Set<ProfileField> missing = const {},
|
||||
}) => LoopSetupState(
|
||||
phase: phase ?? state.phase,
|
||||
found: found ?? state.found,
|
||||
chosen: chosen ?? state.chosen,
|
||||
profile: profile ?? state.profile,
|
||||
missing: missing,
|
||||
);
|
||||
|
||||
@override
|
||||
Future<void> close() async {
|
||||
await _search?.cancel();
|
||||
await super.close();
|
||||
}
|
||||
}
|
||||
|
||||
final class const LoopSetupState({
|
||||
final LoopSetupPhase phase = LoopSetupPhase.asking,
|
||||
|
||||
/// The Loops seen, the nearest first.
|
||||
final List<FoundDevice> found = const [],
|
||||
|
||||
/// The band chosen.
|
||||
final FoundDevice? chosen,
|
||||
|
||||
/// The profile as it was read, and what of it the band still needs.
|
||||
final ProfileContent profile = const ProfileContent(),
|
||||
final Set<ProfileField> missing = const {},
|
||||
}) with Equatable {
|
||||
@override
|
||||
List<Object?> get props => [phase, found, chosen, profile, missing];
|
||||
}
|
||||
|
|
@ -0,0 +1,88 @@
|
|||
import 'package:flutter/services.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/loop/data/loop_platform.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/loop/model/loop_figures.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/recording/data/recorder_platform.dart';
|
||||
|
||||
/// The Loop on Android, through the app's own `LoopBridge` (Kotlin,
|
||||
/// `android/app/src/main/kotlin/`, ADR 0030, D34) on Polar's SDK; nearby
|
||||
/// devices asked through the recorder's channel, as for a strap.
|
||||
class AndroidLoopPlatform(final RecorderPlatform _recorder)
|
||||
implements LoopPlatform {
|
||||
static const _channel = MethodChannel('app.halkyon.train/loop');
|
||||
static const _state = EventChannel('app.halkyon.train/loop/state');
|
||||
static const _search = EventChannel('app.halkyon.train/loop/search');
|
||||
|
||||
@override
|
||||
Future<bool> askBluetooth() async => (await _recorder.ask({
|
||||
RecorderPermission.bluetooth,
|
||||
})).contains(RecorderPermission.bluetooth);
|
||||
|
||||
@override
|
||||
Stream<FoundDevice> search() => _search.receiveBroadcastStream().map(
|
||||
(event) => switch (event) {
|
||||
{
|
||||
'id': final String id,
|
||||
'name': final String? name,
|
||||
'rssi': final int rssi,
|
||||
} =>
|
||||
FoundDevice(address: id, name: name, rssi: rssi),
|
||||
_ => throw const FormatException('A Loop found unread'),
|
||||
},
|
||||
);
|
||||
|
||||
@override
|
||||
late final Stream<LoopEvent> events = _state
|
||||
.receiveBroadcastStream()
|
||||
.map(loopEventOf)
|
||||
.where((event) => event != null)
|
||||
.cast<LoopEvent>();
|
||||
|
||||
@override
|
||||
Future<void> connect(String id) => _call('connect', {'id': id});
|
||||
|
||||
@override
|
||||
Future<void> disconnect(String id) => _call('disconnect', {'id': id});
|
||||
|
||||
@override
|
||||
Future<void> setUp(String id, LoopFigures figures) =>
|
||||
_call('setUp', {'id': id, 'figures': figures.toMap()});
|
||||
|
||||
@override
|
||||
Future<void> setClock(String id) => _call('setClock', {'id': id});
|
||||
|
||||
@override
|
||||
Future<void> reset(String id) => _call('reset', {'id': id});
|
||||
|
||||
Future<void> _call(String method, Map<String, Object?> arguments) async {
|
||||
try {
|
||||
await _channel.invokeMethod<void>(method, arguments);
|
||||
} on PlatformException catch (error) {
|
||||
throw LoopFailed(error.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What `LoopBridge` sent as one of [LoopEvent]; null for what the app
|
||||
/// doesn't know.
|
||||
LoopEvent? loopEventOf(Object? event) => switch (event) {
|
||||
{'id': final String id, 'event': 'connecting'} => LoopConnecting(id),
|
||||
{'id': final String id, 'event': 'connected', 'name': final String? name} =>
|
||||
LoopConnected(id, name: name),
|
||||
{'id': final String id, 'event': 'ready'} => LoopReady(id),
|
||||
{'id': final String id, 'event': 'disconnected'} => LoopDisconnected(id),
|
||||
{'id': final String id, 'event': 'battery', 'level': final int level} =>
|
||||
LoopBattery(id, level),
|
||||
{
|
||||
'id': final String id,
|
||||
'event': 'charging',
|
||||
'charging': final bool charging,
|
||||
} =>
|
||||
LoopCharging(id, charging: charging),
|
||||
{
|
||||
'id': final String id,
|
||||
'event': 'firmware',
|
||||
'version': final String version,
|
||||
} =>
|
||||
LoopFirmware(id, version),
|
||||
_ => null,
|
||||
};
|
||||
|
|
@ -0,0 +1,64 @@
|
|||
import 'package:halkyon_train_flutter/src/feature/loop/model/loop_figures.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/recording/data/recorder_platform.dart';
|
||||
|
||||
/// What a Loop tells of itself as it connects (ADR 0030, D34).
|
||||
sealed class LoopEvent(
|
||||
/// The band's ID, as Polar's SDK names it.
|
||||
final String id,
|
||||
);
|
||||
|
||||
/// Connecting, as the band came in reach.
|
||||
final class LoopConnecting(super.id) extends LoopEvent;
|
||||
|
||||
/// Connected, under [name].
|
||||
final class LoopConnected(super.id, {final String? name}) extends LoopEvent;
|
||||
|
||||
/// Ready for its clock and its first-time use.
|
||||
final class LoopReady(super.id) extends LoopEvent;
|
||||
|
||||
/// Gone out of reach, or let go.
|
||||
final class LoopDisconnected(super.id) extends LoopEvent;
|
||||
|
||||
/// Its battery, in per cent.
|
||||
final class LoopBattery(super.id, final int level) extends LoopEvent;
|
||||
|
||||
/// Whether it is charging.
|
||||
final class LoopCharging(super.id, {required final bool charging})
|
||||
extends LoopEvent;
|
||||
|
||||
/// Its firmware's version.
|
||||
final class LoopFirmware(super.id, final String version) extends LoopEvent;
|
||||
|
||||
/// The Loop through Polar's SDK on this phone (D34); each call throws a
|
||||
/// [LoopFailed] where the band refused or wasn't there.
|
||||
abstract interface class LoopPlatform() {
|
||||
/// Asks for nearby devices where not granted (D19); whether they are now.
|
||||
Future<bool> askBluetooth();
|
||||
|
||||
/// Loops nearby, each as seen again with its signal, until cancelled; its
|
||||
/// [FoundDevice.address] is the band's ID.
|
||||
Stream<FoundDevice> search();
|
||||
|
||||
/// What the bands connected tell.
|
||||
Stream<LoopEvent> get events;
|
||||
|
||||
/// Connects to the band [id] whenever it is in reach, until [disconnect].
|
||||
Future<void> connect(String id);
|
||||
|
||||
Future<void> disconnect(String id);
|
||||
|
||||
/// Sets the band up with [figures] (its first-time use), and its clock.
|
||||
Future<void> setUp(String id, LoopFigures figures);
|
||||
|
||||
/// Sets the band's clock to the phone's.
|
||||
Future<void> setClock(String id);
|
||||
|
||||
/// Resets the band to the factory's state, so that it pairs anew.
|
||||
Future<void> reset(String id);
|
||||
}
|
||||
|
||||
/// The band refused, or wasn't there.
|
||||
final class const LoopFailed(final String? message) implements Exception {
|
||||
@override
|
||||
String toString() => 'LoopFailed: $message';
|
||||
}
|
||||
|
|
@ -0,0 +1,105 @@
|
|||
import 'dart:convert';
|
||||
import 'dart:developer';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:equatable/equatable.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/loop/model/loop_figures.dart';
|
||||
|
||||
/// The person's Loop on this phone (ADR 0030, D34): which band, what it was
|
||||
/// set up with, and what it told last. Never synced: one phone holds the
|
||||
/// band (D3).
|
||||
final class const KnownLoop({
|
||||
/// Its ID, as Polar's SDK names it.
|
||||
required final String id,
|
||||
final String? name,
|
||||
|
||||
/// What it was set up with last; null where that isn't known.
|
||||
final LoopFigures? figures,
|
||||
|
||||
/// When it was last connected; null while it is.
|
||||
final DateTime? lastSeen,
|
||||
final int? battery,
|
||||
final bool? charging,
|
||||
final String? firmware,
|
||||
}) with Equatable {
|
||||
KnownLoop copyWith({
|
||||
String? name,
|
||||
LoopFigures? figures,
|
||||
DateTime? Function()? lastSeen,
|
||||
int? battery,
|
||||
bool? charging,
|
||||
String? firmware,
|
||||
}) => KnownLoop(
|
||||
id: id,
|
||||
name: name ?? this.name,
|
||||
figures: figures ?? this.figures,
|
||||
lastSeen: lastSeen == null ? this.lastSeen : lastSeen(),
|
||||
battery: battery ?? this.battery,
|
||||
charging: charging ?? this.charging,
|
||||
firmware: firmware ?? this.firmware,
|
||||
);
|
||||
|
||||
@override
|
||||
List<Object?> get props => [
|
||||
id,
|
||||
name,
|
||||
figures,
|
||||
lastSeen,
|
||||
battery,
|
||||
charging,
|
||||
firmware,
|
||||
];
|
||||
}
|
||||
|
||||
/// Remembers the [KnownLoop] in `loop.json` of the person's folder; a file
|
||||
/// it can't read is none.
|
||||
class LoopStore(final File _file) {
|
||||
KnownLoop? read() {
|
||||
if (!_file.existsSync()) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return switch (jsonDecode(_file.readAsStringSync())) {
|
||||
{'id': final String id} && final Map<String, Object?> json => KnownLoop(
|
||||
id: id,
|
||||
name: _as<String>(json['name']),
|
||||
figures: switch (json['figures']) {
|
||||
final Map<String, Object?> figures => LoopFigures.fromMap(figures),
|
||||
_ => null,
|
||||
},
|
||||
lastSeen: switch (json['lastSeen']) {
|
||||
final String seen => DateTime.tryParse(seen),
|
||||
_ => null,
|
||||
},
|
||||
battery: _as<int>(json['battery']),
|
||||
charging: _as<bool>(json['charging']),
|
||||
firmware: _as<String>(json['firmware']),
|
||||
),
|
||||
_ => null,
|
||||
};
|
||||
} on FormatException {
|
||||
log('The Loop not read', name: 'Loop');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
void save(KnownLoop loop) => _file.writeAsStringSync(
|
||||
jsonEncode({
|
||||
'id': loop.id,
|
||||
'name': ?loop.name,
|
||||
'figures': ?loop.figures?.toMap(),
|
||||
'lastSeen': ?loop.lastSeen?.toUtc().toIso8601String(),
|
||||
'battery': ?loop.battery,
|
||||
'charging': ?loop.charging,
|
||||
'firmware': ?loop.firmware,
|
||||
}),
|
||||
);
|
||||
|
||||
void forget() {
|
||||
if (_file.existsSync()) {
|
||||
_file.deleteSync();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
T? _as<T>(Object? value) => value is T ? value : null;
|
||||
|
|
@ -0,0 +1,285 @@
|
|||
import 'dart:async';
|
||||
import 'dart:developer';
|
||||
|
||||
import 'package:equatable/equatable.dart';
|
||||
import 'package:halkyon_core/halkyon_core.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/loop/data/loop_platform.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/loop/data/loop_store.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/loop/model/loop_figures.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/profile/data/profile_repository.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/recording/data/recorder_platform.dart';
|
||||
|
||||
/// How long a band chosen has to connect and be ready for its setup.
|
||||
const _readyWithin = Duration(seconds: 60);
|
||||
|
||||
/// Where the person's Loop stands.
|
||||
final class const LoopStatus({
|
||||
/// The band remembered; null without one.
|
||||
final KnownLoop? loop,
|
||||
final bool connected = false,
|
||||
|
||||
/// Whether the profile changed since the band was set up with it, and can
|
||||
/// be sent (D35).
|
||||
final bool profileChanged = false,
|
||||
}) with Equatable {
|
||||
@override
|
||||
List<Object?> get props => [loop, connected, profileChanged];
|
||||
}
|
||||
|
||||
/// The person's Polar Loop on this phone (ADR 0030, D34, D35): set up with
|
||||
/// their profile and remembered in their folder; connected while the app is
|
||||
/// open and the band in reach, each connection setting its clock; a change
|
||||
/// of the profile offered to the band, sent only when the person says, as
|
||||
/// the SDK's first-time use gives the band a new user identifier each time.
|
||||
class PolarLoop(
|
||||
/// Polar's SDK.
|
||||
final LoopPlatform platform,
|
||||
final LoopStore _store,
|
||||
final ProfileRepository _profile, {
|
||||
|
||||
/// Today, for the age the defaults take.
|
||||
final DateTime Function() today = DateTime.now,
|
||||
}) {
|
||||
late LoopStatus _status = LoopStatus(loop: _store.read());
|
||||
final _changes = StreamController<LoopStatus>.broadcast();
|
||||
final _ready = <String>{};
|
||||
final _waiting = <String, Completer<void>>{};
|
||||
StreamSubscription<LoopEvent>? _events;
|
||||
StreamSubscription<void>? _profileChanges;
|
||||
LoopFigures? _current;
|
||||
late final _comparing = CoalescingRunner(
|
||||
_compare,
|
||||
onError: (error, stackTrace) => _failed('compared', error, stackTrace),
|
||||
);
|
||||
late final _clocking = CoalescingRunner(
|
||||
_setClock,
|
||||
onError: (error, stackTrace) =>
|
||||
_failed('given its clock', error, stackTrace),
|
||||
);
|
||||
|
||||
/// Where the band stands now.
|
||||
LoopStatus get status => _status;
|
||||
|
||||
/// Fires with the status whenever it changes.
|
||||
Stream<LoopStatus> get changes => _changes.stream;
|
||||
|
||||
/// Connects to the band remembered, and follows the profile.
|
||||
void start() {
|
||||
_events = platform.events.listen(_on);
|
||||
_profileChanges = _profile.changes.listen(
|
||||
(_) => unawaited(_comparing.run()),
|
||||
);
|
||||
unawaited(_comparing.run());
|
||||
final known = _status.loop;
|
||||
if (known != null) {
|
||||
unawaited(_connect(known.id));
|
||||
}
|
||||
}
|
||||
|
||||
/// Completes once nothing is being compared or sent.
|
||||
Future<void> get idle async {
|
||||
await _comparing.idle;
|
||||
await _clocking.idle;
|
||||
}
|
||||
|
||||
/// Sets up [device] with [figures] and remembers it in place of any
|
||||
/// before; throws [LoopFailed] where it didn't connect or refused.
|
||||
Future<void> setUp(FoundDevice device, LoopFigures figures) async {
|
||||
final before = _status.loop;
|
||||
if (before != null && before.id != device.address) {
|
||||
await platform.disconnect(before.id);
|
||||
}
|
||||
final id = device.address;
|
||||
try {
|
||||
await platform.connect(id);
|
||||
await _readyOf(id);
|
||||
await platform.setUp(id, figures);
|
||||
} on Object {
|
||||
if (before?.id != id) {
|
||||
await platform.disconnect(id);
|
||||
}
|
||||
rethrow;
|
||||
}
|
||||
_update(
|
||||
LoopStatus(
|
||||
loop: KnownLoop(
|
||||
id: id,
|
||||
name: device.name,
|
||||
figures: figures,
|
||||
// What the band told before its setup stays.
|
||||
battery: before?.id == id ? before?.battery : null,
|
||||
firmware: before?.id == id ? before?.firmware : null,
|
||||
),
|
||||
connected: true,
|
||||
),
|
||||
);
|
||||
await _comparing.run();
|
||||
}
|
||||
|
||||
/// Sends the profile as it is now to the band (D35); throws [LoopFailed]
|
||||
/// where it isn't connected or refused.
|
||||
Future<void> sendProfile() async {
|
||||
final known = _status.loop;
|
||||
await _comparing.run();
|
||||
final figures = _current;
|
||||
if (known == null || figures == null) {
|
||||
return;
|
||||
}
|
||||
if (!_ready.contains(known.id)) {
|
||||
throw const LoopFailed('not connected');
|
||||
}
|
||||
await platform.setUp(known.id, figures);
|
||||
_update(
|
||||
LoopStatus(
|
||||
loop: known.copyWith(figures: figures),
|
||||
connected: _status.connected,
|
||||
),
|
||||
);
|
||||
await _comparing.run();
|
||||
}
|
||||
|
||||
/// Resets the band and forgets it, the only way it pairs elsewhere (D34);
|
||||
/// throws [LoopFailed] where it isn't in reach, and keeps it then.
|
||||
Future<void> letGo() async {
|
||||
final known = _status.loop;
|
||||
if (known == null) {
|
||||
return;
|
||||
}
|
||||
await platform.reset(known.id);
|
||||
await platform.disconnect(known.id);
|
||||
_ready.remove(known.id);
|
||||
_store.forget();
|
||||
_status = const LoopStatus();
|
||||
_changes.add(_status);
|
||||
}
|
||||
|
||||
/// Lets go of the connection; the band stays remembered.
|
||||
Future<void> dispose() async {
|
||||
await _events?.cancel();
|
||||
await _profileChanges?.cancel();
|
||||
await idle;
|
||||
final known = _status.loop;
|
||||
if (known != null) {
|
||||
await platform.disconnect(known.id);
|
||||
}
|
||||
for (final waiting in _waiting.values) {
|
||||
waiting.completeError(const LoopFailed('closed'));
|
||||
}
|
||||
await _changes.close();
|
||||
}
|
||||
|
||||
Future<void> _connect(String id) async {
|
||||
try {
|
||||
await platform.connect(id);
|
||||
} on LoopFailed catch (error, st) {
|
||||
_failed('connected', error, st);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _readyOf(String id) {
|
||||
if (_ready.contains(id)) {
|
||||
return Future.value();
|
||||
}
|
||||
return _waiting
|
||||
.putIfAbsent(id, Completer.new)
|
||||
.future
|
||||
.timeout(
|
||||
_readyWithin,
|
||||
onTimeout: () => throw const LoopFailed('not ready'),
|
||||
)
|
||||
.whenComplete(() => _waiting.remove(id));
|
||||
}
|
||||
|
||||
void _on(LoopEvent event) {
|
||||
final id = event.id;
|
||||
if (event case LoopReady()) {
|
||||
_ready.add(id);
|
||||
_waiting.remove(id)?.complete();
|
||||
} else if (event case LoopDisconnected()) {
|
||||
_ready.remove(id);
|
||||
}
|
||||
final known = _status.loop;
|
||||
if (known == null || known.id != id) {
|
||||
return;
|
||||
}
|
||||
switch (event) {
|
||||
case LoopConnecting():
|
||||
break;
|
||||
case LoopConnected(:final name):
|
||||
_update(
|
||||
LoopStatus(
|
||||
loop: known.copyWith(name: name, lastSeen: () => null),
|
||||
connected: true,
|
||||
profileChanged: _status.profileChanged,
|
||||
),
|
||||
);
|
||||
case LoopReady():
|
||||
// Each connection sets the band's clock (D34).
|
||||
unawaited(_clocking.run());
|
||||
case LoopDisconnected():
|
||||
_update(
|
||||
LoopStatus(
|
||||
loop: known.copyWith(lastSeen: () => DateTime.now().toUtc()),
|
||||
profileChanged: _status.profileChanged,
|
||||
),
|
||||
);
|
||||
case LoopBattery(:final level):
|
||||
_update(_with(known.copyWith(battery: level)));
|
||||
case LoopCharging(:final charging):
|
||||
_update(_with(known.copyWith(charging: charging)));
|
||||
case LoopFirmware(:final version):
|
||||
_update(_with(known.copyWith(firmware: version)));
|
||||
}
|
||||
}
|
||||
|
||||
/// Sets the clock of the band remembered, where it is ready.
|
||||
Future<void> _setClock() async {
|
||||
final known = _status.loop;
|
||||
if (known != null && _ready.contains(known.id)) {
|
||||
await platform.setClock(known.id);
|
||||
}
|
||||
}
|
||||
|
||||
LoopStatus _with(KnownLoop loop) => LoopStatus(
|
||||
loop: loop,
|
||||
connected: _status.connected,
|
||||
profileChanged: _status.profileChanged,
|
||||
);
|
||||
|
||||
/// Works out the figures the profile gives now, and whether they differ
|
||||
/// from the band's.
|
||||
Future<void> _compare() async {
|
||||
final profile = await _profile.read();
|
||||
_current = profile == null ? null : loopFiguresOf(profile, today());
|
||||
final sent = _status.loop?.figures;
|
||||
final current = _current;
|
||||
_update(
|
||||
LoopStatus(
|
||||
loop: _status.loop,
|
||||
connected: _status.connected,
|
||||
profileChanged: sent != null && current != null && current != sent,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _update(LoopStatus status) {
|
||||
if (status == _status) {
|
||||
return;
|
||||
}
|
||||
final loop = status.loop;
|
||||
if (loop != null && loop != _status.loop) {
|
||||
_store.save(loop);
|
||||
}
|
||||
_status = status;
|
||||
if (!_changes.isClosed) {
|
||||
_changes.add(status);
|
||||
}
|
||||
}
|
||||
|
||||
void _failed(String what, Object error, StackTrace stackTrace) => log(
|
||||
'The Loop not $what: $error',
|
||||
name: 'Loop',
|
||||
error: error,
|
||||
stackTrace: stackTrace,
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,132 @@
|
|||
import 'package:equatable/equatable.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/profile/model/profile.dart';
|
||||
|
||||
/// What a Loop's first-time use sets on the band (ADR 0030, D34): every
|
||||
/// figure Polar's SDK asks for, within the ranges it takes.
|
||||
final class const LoopFigures({
|
||||
required final Sex sex,
|
||||
required final DateTime birthDate,
|
||||
required final int heightCm,
|
||||
required final double weightKg,
|
||||
required final int restingHeartRate,
|
||||
required final int maxHeartRate,
|
||||
required final int vo2Max,
|
||||
required final TrainingBackground trainingBackground,
|
||||
required final TypicalDay typicalDay,
|
||||
required final int sleepGoalMinutes,
|
||||
}) with Equatable {
|
||||
/// The figures as the platform and `loop.json` take them; null where they
|
||||
/// don't read as such.
|
||||
static LoopFigures? fromMap(Map<String, Object?> map) => switch (map) {
|
||||
{
|
||||
'sex': final String sex,
|
||||
'birthDate': final String birthDate,
|
||||
'heightCm': final int heightCm,
|
||||
'weightKg': final num weightKg,
|
||||
'restingHeartRate': final int restingHeartRate,
|
||||
'maxHeartRate': final int maxHeartRate,
|
||||
'vo2Max': final int vo2Max,
|
||||
'trainingBackground': final String trainingBackground,
|
||||
'typicalDay': final String typicalDay,
|
||||
'sleepGoalMinutes': final int sleepGoalMinutes,
|
||||
}
|
||||
when Sex.values.asNameMap()[sex] != null &&
|
||||
TrainingBackground.values.asNameMap()[trainingBackground] != null &&
|
||||
TypicalDay.values.asNameMap()[typicalDay] != null =>
|
||||
LoopFigures(
|
||||
sex: Sex.values.byName(sex),
|
||||
birthDate: DateTime.parse('${birthDate}T00:00:00Z'),
|
||||
heightCm: heightCm,
|
||||
weightKg: weightKg.toDouble(),
|
||||
restingHeartRate: restingHeartRate,
|
||||
maxHeartRate: maxHeartRate,
|
||||
vo2Max: vo2Max,
|
||||
trainingBackground: TrainingBackground.values.byName(
|
||||
trainingBackground,
|
||||
),
|
||||
typicalDay: TypicalDay.values.byName(typicalDay),
|
||||
sleepGoalMinutes: sleepGoalMinutes,
|
||||
),
|
||||
_ => null,
|
||||
};
|
||||
|
||||
Map<String, Object> toMap() => {
|
||||
'sex': sex.name,
|
||||
'birthDate': birthDate.toIso8601String().substring(0, 10),
|
||||
'heightCm': heightCm,
|
||||
'weightKg': weightKg,
|
||||
'restingHeartRate': restingHeartRate,
|
||||
'maxHeartRate': maxHeartRate,
|
||||
'vo2Max': vo2Max,
|
||||
'trainingBackground': trainingBackground.name,
|
||||
'typicalDay': typicalDay.name,
|
||||
'sleepGoalMinutes': sleepGoalMinutes,
|
||||
};
|
||||
|
||||
@override
|
||||
List<Object?> get props => [
|
||||
sex,
|
||||
birthDate,
|
||||
heightCm,
|
||||
weightKg,
|
||||
restingHeartRate,
|
||||
maxHeartRate,
|
||||
vo2Max,
|
||||
trainingBackground,
|
||||
typicalDay,
|
||||
sleepGoalMinutes,
|
||||
];
|
||||
}
|
||||
|
||||
/// What the band needs that [profile] lacks and Polar has no default for,
|
||||
/// which the setup asks for (D34).
|
||||
Set<ProfileField> loopMissing(ProfileContent profile) => {
|
||||
if (profile.sex == null) ProfileField.sex,
|
||||
if (profile.birthDate == null) ProfileField.birthDate,
|
||||
if (profile.heightCm == null) ProfileField.height,
|
||||
if (profile.weightKg == null) ProfileField.weight,
|
||||
if (profile.trainingBackground == null) ProfileField.trainingBackground,
|
||||
if (profile.typicalDay == null) ProfileField.typicalDay,
|
||||
if (profile.sleepGoal == null) ProfileField.sleepGoal,
|
||||
};
|
||||
|
||||
/// [profile] as the band takes it on [today], Polar's defaults filled in
|
||||
/// where it has no resting or highest heart rate or VO2max (D33: for the
|
||||
/// band alone, the profile keeping its blanks); null while [loopMissing]
|
||||
/// names anything.
|
||||
LoopFigures? loopFiguresOf(ProfileContent profile, DateTime today) {
|
||||
if (loopMissing(profile).isNotEmpty) {
|
||||
return null;
|
||||
}
|
||||
final sex = profile.sex!;
|
||||
final age = profile.ageOn(today)!;
|
||||
return LoopFigures(
|
||||
sex: sex,
|
||||
birthDate: profile.birthDate!,
|
||||
// Your body takes 100 to 250 cm; Polar 90 to 240.
|
||||
heightCm: profile.heightCm!.clamp(90, 240),
|
||||
weightKg: profile.weightKg!.clamp(15, 300).toDouble(),
|
||||
restingHeartRate: profile.restingHeartRate ?? 60,
|
||||
maxHeartRate: profile.maxHeartRate ?? defaultMaxHeartRate(age),
|
||||
vo2Max: profile.vo2Max ?? defaultVo2Max(sex, age),
|
||||
trainingBackground: profile.trainingBackground!,
|
||||
typicalDay: profile.typicalDay!,
|
||||
sleepGoalMinutes: profile.sleepGoal!.inMinutes.clamp(300, 660),
|
||||
);
|
||||
}
|
||||
|
||||
/// Polar's default highest heart rate at [age]: 220 less it, within the
|
||||
/// range the band takes.
|
||||
int defaultMaxHeartRate(int age) => (220 - age).clamp(100, 240);
|
||||
|
||||
/// A VO2max for a person who doesn't know theirs: the middle of the
|
||||
/// Moderate class of Polar's fitness levels (its manuals' tables, after
|
||||
/// Shvartz and Reibold 1990), by sex and age in five-year rows from 20 to
|
||||
/// 65; the first row below 20, the last above 65.
|
||||
int defaultVo2Max(Sex sex, int age) {
|
||||
final row = ((age - 20) ~/ 5).clamp(0, 8);
|
||||
return switch (sex) {
|
||||
Sex.female => const [39, 38, 36, 34, 32, 30, 28, 26, 23],
|
||||
Sex.male => const [47, 46, 43, 41, 39, 37, 35, 33, 31],
|
||||
}[row];
|
||||
}
|
||||
|
|
@ -0,0 +1,335 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:halkyon_app/halkyon_app.dart';
|
||||
import 'package:halkyon_train_flutter/l10n/app_localizations.dart';
|
||||
import 'package:halkyon_train_flutter/src/app/train_dependencies.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/loop/bloc/loop_cubit.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/loop/bloc/loop_setup_cubit.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/loop/data/polar_loop.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/profile/data/profile_repository.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/profile/model/profile.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/profile/widget/profile_screen.dart';
|
||||
import 'package:halkyon_ui/halkyon_ui.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
/// A signal stronger than this is close by, as a band worn or in the hand.
|
||||
const _nearRssi = -65;
|
||||
|
||||
/// Opens the band's screen over [context]'s.
|
||||
Future<void> openLoopScreen(BuildContext context) {
|
||||
final loop = PersonScope.of<TrainPerson>(context).loop!;
|
||||
return Navigator.of(context).push(
|
||||
MaterialPageRoute<void>(
|
||||
builder: (context) => BlocProvider(
|
||||
create: (context) => LoopCubit(loop)..open(),
|
||||
child: const LoopScreen(),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// The person's Polar Loop (ADR 0030, D34, D35): its name, battery,
|
||||
/// firmware, whether it is connected or when it was last; a changed profile
|
||||
/// offered to it; set up again, or let go after asking.
|
||||
class const LoopScreen({super.key}) extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
return BlocConsumer<LoopCubit, LoopState>(
|
||||
listenWhen: (before, now) =>
|
||||
now.outcome != null && before.outcome != now.outcome,
|
||||
listener: (context, state) {
|
||||
final text = switch (state.outcome) {
|
||||
LoopOutcome.sent => l10n.loopSent,
|
||||
LoopOutcome.failed => l10n.loopFailed,
|
||||
LoopOutcome.letGo || null => null,
|
||||
};
|
||||
if (text != null) {
|
||||
// In place of the setup's, which would hold this one back.
|
||||
ScaffoldMessenger.of(context)
|
||||
..hideCurrentSnackBar()
|
||||
..showSnackBar(SnackBar(content: Text(text)));
|
||||
}
|
||||
},
|
||||
builder: (context, state) {
|
||||
final known = state.status.loop;
|
||||
final theme = Theme.of(context);
|
||||
final locale = Localizations.localeOf(context).toString();
|
||||
Future<void> setUp() {
|
||||
final person = PersonScope.of<TrainPerson>(context);
|
||||
return showLoopSetupSheet(context, person.loop!, person.profile);
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: Text(l10n.loopTitle)),
|
||||
body: SafeArea(
|
||||
child: Center(
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(
|
||||
maxWidth: readableContentMaxWidth,
|
||||
),
|
||||
child: known == null
|
||||
? ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
Text(l10n.loopNone),
|
||||
const SizedBox(height: 16),
|
||||
FilledButton.icon(
|
||||
icon: const Icon(LucideIcons.watch),
|
||||
label: Text(l10n.loopSetUp),
|
||||
onPressed: setUp,
|
||||
),
|
||||
],
|
||||
)
|
||||
: ListView(
|
||||
children: [
|
||||
if (state.busy) const LinearProgressIndicator(),
|
||||
ListTile(
|
||||
leading: const Icon(LucideIcons.watch),
|
||||
title: Text(known.name ?? l10n.loopUnnamed),
|
||||
subtitle: Text(
|
||||
state.status.connected
|
||||
? l10n.loopConnected
|
||||
: switch (known.lastSeen) {
|
||||
final DateTime seen => l10n.loopLastSeen(
|
||||
DateFormat.yMMMd(
|
||||
locale,
|
||||
).add_Hm().format(seen.toLocal()),
|
||||
),
|
||||
null => l10n.loopNotInReach,
|
||||
},
|
||||
),
|
||||
),
|
||||
if (known.battery case final int level)
|
||||
ListTile(
|
||||
leading: Icon(
|
||||
known.charging ?? false
|
||||
? LucideIcons.batteryCharging
|
||||
: LucideIcons.battery,
|
||||
),
|
||||
title: Text(
|
||||
known.charging ?? false
|
||||
? l10n.loopCharging(level)
|
||||
: l10n.loopBattery(level),
|
||||
),
|
||||
),
|
||||
if (known.firmware case final String version)
|
||||
ListTile(
|
||||
leading: const Icon(LucideIcons.cpu),
|
||||
title: Text(l10n.loopFirmware(version)),
|
||||
),
|
||||
if (state.status.profileChanged)
|
||||
Card(
|
||||
margin: const EdgeInsets.all(16),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
l10n.loopProfileChanged,
|
||||
style: theme.textTheme.titleSmall,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(l10n.loopProfileChangedHint),
|
||||
const SizedBox(height: 12),
|
||||
FilledButton(
|
||||
onPressed:
|
||||
state.busy || !state.status.connected
|
||||
? null
|
||||
: () => context
|
||||
.read<LoopCubit>()
|
||||
.sendProfile(),
|
||||
child: Text(l10n.loopSend),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const Divider(),
|
||||
ListTile(
|
||||
leading: const Icon(LucideIcons.refreshCw),
|
||||
title: Text(l10n.loopSetUpAgain),
|
||||
onTap: state.busy ? null : setUp,
|
||||
),
|
||||
ListTile(
|
||||
leading: Icon(
|
||||
LucideIcons.unlink,
|
||||
color: theme.colorScheme.error,
|
||||
),
|
||||
title: Text(l10n.loopLetGo),
|
||||
onTap: state.busy
|
||||
? null
|
||||
: () => _letGo(context, l10n),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _letGo(BuildContext context, AppLocalizations l10n) async {
|
||||
final cubit = context.read<LoopCubit>();
|
||||
final sure = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: Text(l10n.loopLetGoTitle),
|
||||
content: Text(l10n.loopLetGoText),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(false),
|
||||
child: Text(MaterialLocalizations.of(context).cancelButtonLabel),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.of(context).pop(true),
|
||||
child: Text(l10n.loopLetGoConfirm),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (sure ?? false) {
|
||||
await cubit.letGo();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The sheet a Loop is set up on (D34): Loops nearby, the nearest first;
|
||||
/// choosing one asks for what the profile lacks, then sets it up.
|
||||
Future<void> showLoopSetupSheet(
|
||||
BuildContext context,
|
||||
PolarLoop loop,
|
||||
ProfileRepository profile,
|
||||
) => showAdaptiveSheet<void>(
|
||||
context,
|
||||
swipeable: false,
|
||||
builder: (context) => BlocProvider(
|
||||
create: (context) => LoopSetupCubit(loop, profile)..open(),
|
||||
child: const _LoopSetupSheet(),
|
||||
),
|
||||
);
|
||||
|
||||
class const _LoopSetupSheet() extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final theme = Theme.of(context);
|
||||
final muted = theme.textTheme.bodyMedium?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
);
|
||||
return BlocConsumer<LoopSetupCubit, LoopSetupState>(
|
||||
listenWhen: (before, now) => before.phase != now.phase,
|
||||
listener: (context, state) async {
|
||||
switch (state.phase) {
|
||||
case LoopSetupPhase.done:
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(l10n.loopSetUpDone)),
|
||||
);
|
||||
Navigator.of(context).pop();
|
||||
case LoopSetupPhase.form:
|
||||
final cubit = context.read<LoopSetupCubit>();
|
||||
final completed = await Navigator.of(context).push(
|
||||
MaterialPageRoute<ProfileContent>(
|
||||
builder: (context) => _LoopFormPage(
|
||||
profile: state.profile,
|
||||
missing: state.missing,
|
||||
),
|
||||
),
|
||||
);
|
||||
if (completed == null) {
|
||||
cubit.formLeft();
|
||||
} else {
|
||||
await cubit.completeProfile(completed);
|
||||
}
|
||||
case _:
|
||||
break;
|
||||
}
|
||||
},
|
||||
builder: (context, state) => SheetScaffold(
|
||||
title: l10n.loopSetUp,
|
||||
busy: state.phase == LoopSetupPhase.settingUp,
|
||||
body: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: switch (state.phase) {
|
||||
LoopSetupPhase.asking => const [
|
||||
Center(child: AppLoadingIndicator()),
|
||||
],
|
||||
LoopSetupPhase.refused => [Text(l10n.loopRefused)],
|
||||
LoopSetupPhase.locked => [Text(l10n.profileLocked)],
|
||||
LoopSetupPhase.settingUp || LoopSetupPhase.done => [
|
||||
const LinearProgressIndicator(),
|
||||
const SizedBox(height: 12),
|
||||
Text(l10n.loopSettingUp, style: theme.textTheme.titleSmall),
|
||||
Text(l10n.loopSettingUpHint, style: muted),
|
||||
],
|
||||
LoopSetupPhase.searching ||
|
||||
LoopSetupPhase.form ||
|
||||
LoopSetupPhase.failed => [
|
||||
if (state.phase == LoopSetupPhase.failed) ...[
|
||||
Text(
|
||||
l10n.loopFailed,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: theme.colorScheme.error,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
],
|
||||
const LinearProgressIndicator(),
|
||||
const SizedBox(height: 12),
|
||||
Text(l10n.loopSearching, style: theme.textTheme.titleSmall),
|
||||
Text(l10n.loopSearchingHint, style: muted),
|
||||
const SizedBox(height: 8),
|
||||
for (final device in state.found)
|
||||
ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
leading: const Icon(LucideIcons.watch),
|
||||
title: Text(device.name ?? l10n.loopUnnamed),
|
||||
subtitle: Text(
|
||||
device.rssi > _nearRssi ? l10n.strapNear : l10n.strapFar,
|
||||
),
|
||||
onTap: () => context.read<LoopSetupCubit>().choose(device),
|
||||
),
|
||||
],
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// What the band needs that the profile lacks, asked for on a page of its
|
||||
/// own over the sheet; pops with the profile completed.
|
||||
class const _LoopFormPage({
|
||||
required final ProfileContent profile,
|
||||
required final Set<ProfileField> missing,
|
||||
}) extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: Text(l10n.loopFormTitle)),
|
||||
body: SafeArea(
|
||||
child: Center(
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(
|
||||
maxWidth: readableContentMaxWidth,
|
||||
),
|
||||
child: ProfileForm(
|
||||
profile: profile,
|
||||
only: missing,
|
||||
intro: l10n.loopFormIntro,
|
||||
saveLabel: l10n.loopFormSave,
|
||||
onSave: (completed) => Navigator.of(context).pop(completed),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -67,3 +67,17 @@ final class const ProfileContent({
|
|||
sleepGoal,
|
||||
];
|
||||
}
|
||||
|
||||
/// Each of the profile's fields, for a form that asks for some alone.
|
||||
enum ProfileField() {
|
||||
sex,
|
||||
birthDate,
|
||||
height,
|
||||
weight,
|
||||
restingHeartRate,
|
||||
maxHeartRate,
|
||||
vo2Max,
|
||||
trainingBackground,
|
||||
typicalDay,
|
||||
sleepGoal,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -74,6 +74,13 @@ class const ProfileForm({
|
|||
|
||||
/// What Save says; *Save* where not given.
|
||||
final String? saveLabel,
|
||||
|
||||
/// The fields shown, each then needed (the Loop's setup asks for what it
|
||||
/// lacks, ADR 0030, D34); every one, each optional, where not given.
|
||||
final Set<ProfileField>? only,
|
||||
|
||||
/// What the form says first; *Your body*'s introduction where not given.
|
||||
final String? intro,
|
||||
super.key,
|
||||
}) extends StatefulWidget {
|
||||
@override
|
||||
|
|
@ -93,6 +100,10 @@ class _ProfileFormState() extends State<ProfileForm> {
|
|||
late final _max = _text(widget.profile.maxHeartRate);
|
||||
late final _vo2 = _text(widget.profile.vo2Max);
|
||||
|
||||
bool _shows(ProfileField field) => widget.only?.contains(field) ?? true;
|
||||
|
||||
bool get _needed => widget.only != null;
|
||||
|
||||
static TextEditingController _text(num? value) => TextEditingController(
|
||||
text: switch (value) {
|
||||
null => '',
|
||||
|
|
@ -120,93 +131,114 @@ class _ProfileFormState() extends State<ProfileForm> {
|
|||
child: ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
Text(l10n.profileIntro),
|
||||
Text(widget.intro ?? l10n.profileIntro),
|
||||
const SizedBox(height: 16),
|
||||
_Choice<Sex>(
|
||||
label: l10n.profileSex,
|
||||
helper: l10n.profileSexHint,
|
||||
value: _sex,
|
||||
names: {
|
||||
Sex.female: l10n.profileSexFemale,
|
||||
Sex.male: l10n.profileSexMale,
|
||||
},
|
||||
onChanged: (sex) => setState(() => _sex = sex),
|
||||
),
|
||||
ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
title: Text(l10n.profileBirthDate),
|
||||
subtitle: Text(
|
||||
born == null
|
||||
? l10n.profileNotGiven
|
||||
: DateFormat.yMMMd(locale).format(born),
|
||||
if (_shows(ProfileField.sex))
|
||||
_Choice<Sex>(
|
||||
needed: _needed,
|
||||
label: l10n.profileSex,
|
||||
helper: l10n.profileSexHint,
|
||||
value: _sex,
|
||||
names: {
|
||||
Sex.female: l10n.profileSexFemale,
|
||||
Sex.male: l10n.profileSexMale,
|
||||
},
|
||||
onChanged: (sex) => setState(() => _sex = sex),
|
||||
),
|
||||
if (_shows(ProfileField.birthDate))
|
||||
ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
title: Text(l10n.profileBirthDate),
|
||||
subtitle: Text(
|
||||
switch (born) {
|
||||
null when _needed => l10n.profileNeeded,
|
||||
null => l10n.profileNotGiven,
|
||||
_ => DateFormat.yMMMd(locale).format(born),
|
||||
},
|
||||
),
|
||||
onTap: _pickBirthDate,
|
||||
),
|
||||
if (_shows(ProfileField.height))
|
||||
_Figure(
|
||||
controller: _height,
|
||||
needed: _needed,
|
||||
label: l10n.profileHeight,
|
||||
unit: l10n.profileHeightUnit,
|
||||
range: (100, 250),
|
||||
),
|
||||
if (_shows(ProfileField.weight))
|
||||
_Figure(
|
||||
controller: _weight,
|
||||
needed: _needed,
|
||||
label: l10n.profileWeight,
|
||||
unit: l10n.profileWeightUnit,
|
||||
range: (20, 300),
|
||||
decimal: true,
|
||||
),
|
||||
if (_shows(ProfileField.restingHeartRate))
|
||||
_Figure(
|
||||
controller: _resting,
|
||||
needed: _needed,
|
||||
label: l10n.profileRestingHeartRate,
|
||||
unit: l10n.profileBpmUnit,
|
||||
range: (20, 120),
|
||||
),
|
||||
if (_shows(ProfileField.maxHeartRate))
|
||||
_Figure(
|
||||
controller: _max,
|
||||
needed: _needed,
|
||||
label: l10n.profileMaxHeartRate,
|
||||
unit: l10n.profileBpmUnit,
|
||||
range: (100, 240),
|
||||
),
|
||||
if (_shows(ProfileField.vo2Max))
|
||||
_Figure(
|
||||
controller: _vo2,
|
||||
needed: _needed,
|
||||
label: l10n.profileVo2Max,
|
||||
unit: l10n.profileVo2MaxUnit,
|
||||
range: (10, 95),
|
||||
),
|
||||
if (_shows(ProfileField.trainingBackground))
|
||||
_Choice<TrainingBackground>(
|
||||
needed: _needed,
|
||||
label: l10n.profileTrainingBackground,
|
||||
value: _background,
|
||||
names: {
|
||||
TrainingBackground.occasional: l10n.trainingOccasional,
|
||||
TrainingBackground.regular: l10n.trainingRegular,
|
||||
TrainingBackground.frequent: l10n.trainingFrequent,
|
||||
TrainingBackground.heavy: l10n.trainingHeavy,
|
||||
TrainingBackground.semiPro: l10n.trainingSemiPro,
|
||||
TrainingBackground.pro: l10n.trainingPro,
|
||||
},
|
||||
onChanged: (value) => setState(() => _background = value),
|
||||
),
|
||||
if (_shows(ProfileField.typicalDay))
|
||||
_Choice<TypicalDay>(
|
||||
needed: _needed,
|
||||
label: l10n.profileTypicalDay,
|
||||
value: _day,
|
||||
names: {
|
||||
TypicalDay.mostlySitting: l10n.typicalDaySitting,
|
||||
TypicalDay.mostlyStanding: l10n.typicalDayStanding,
|
||||
TypicalDay.mostlyMoving: l10n.typicalDayMoving,
|
||||
},
|
||||
onChanged: (value) => setState(() => _day = value),
|
||||
),
|
||||
if (_shows(ProfileField.sleepGoal))
|
||||
_Choice<Duration>(
|
||||
needed: _needed,
|
||||
label: l10n.profileSleepGoal,
|
||||
value: _sleep,
|
||||
names: {
|
||||
for (var minutes = 300; minutes <= 660; minutes += 30)
|
||||
Duration(minutes: minutes): clockTime(
|
||||
Duration(minutes: minutes),
|
||||
),
|
||||
},
|
||||
onChanged: (value) => setState(() => _sleep = value),
|
||||
),
|
||||
onTap: _pickBirthDate,
|
||||
),
|
||||
_Figure(
|
||||
controller: _height,
|
||||
label: l10n.profileHeight,
|
||||
unit: l10n.profileHeightUnit,
|
||||
range: (100, 250),
|
||||
),
|
||||
_Figure(
|
||||
controller: _weight,
|
||||
label: l10n.profileWeight,
|
||||
unit: l10n.profileWeightUnit,
|
||||
range: (20, 300),
|
||||
decimal: true,
|
||||
),
|
||||
_Figure(
|
||||
controller: _resting,
|
||||
label: l10n.profileRestingHeartRate,
|
||||
unit: l10n.profileBpmUnit,
|
||||
range: (20, 120),
|
||||
),
|
||||
_Figure(
|
||||
controller: _max,
|
||||
label: l10n.profileMaxHeartRate,
|
||||
unit: l10n.profileBpmUnit,
|
||||
range: (100, 240),
|
||||
),
|
||||
_Figure(
|
||||
controller: _vo2,
|
||||
label: l10n.profileVo2Max,
|
||||
unit: l10n.profileVo2MaxUnit,
|
||||
range: (10, 95),
|
||||
),
|
||||
_Choice<TrainingBackground>(
|
||||
label: l10n.profileTrainingBackground,
|
||||
value: _background,
|
||||
names: {
|
||||
TrainingBackground.occasional: l10n.trainingOccasional,
|
||||
TrainingBackground.regular: l10n.trainingRegular,
|
||||
TrainingBackground.frequent: l10n.trainingFrequent,
|
||||
TrainingBackground.heavy: l10n.trainingHeavy,
|
||||
TrainingBackground.semiPro: l10n.trainingSemiPro,
|
||||
TrainingBackground.pro: l10n.trainingPro,
|
||||
},
|
||||
onChanged: (value) => setState(() => _background = value),
|
||||
),
|
||||
_Choice<TypicalDay>(
|
||||
label: l10n.profileTypicalDay,
|
||||
value: _day,
|
||||
names: {
|
||||
TypicalDay.mostlySitting: l10n.typicalDaySitting,
|
||||
TypicalDay.mostlyStanding: l10n.typicalDayStanding,
|
||||
TypicalDay.mostlyMoving: l10n.typicalDayMoving,
|
||||
},
|
||||
onChanged: (value) => setState(() => _day = value),
|
||||
),
|
||||
_Choice<Duration>(
|
||||
label: l10n.profileSleepGoal,
|
||||
value: _sleep,
|
||||
names: {
|
||||
for (var minutes = 300; minutes <= 660; minutes += 30)
|
||||
Duration(minutes: minutes): clockTime(
|
||||
Duration(minutes: minutes),
|
||||
),
|
||||
},
|
||||
onChanged: (value) => setState(() => _sleep = value),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
FilledButton(
|
||||
onPressed: _save,
|
||||
|
|
@ -233,7 +265,10 @@ class _ProfileFormState() extends State<ProfileForm> {
|
|||
}
|
||||
|
||||
void _save() {
|
||||
if (!(_form.currentState?.validate() ?? false)) {
|
||||
final valid = _form.currentState?.validate() ?? false;
|
||||
final bornMissing =
|
||||
_needed && _shows(ProfileField.birthDate) && _birthDate == null;
|
||||
if (!valid || bornMissing) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(AppLocalizations.of(context).profileInvalid)),
|
||||
);
|
||||
|
|
@ -268,6 +303,9 @@ class const _Figure({
|
|||
required final String unit,
|
||||
required final (num, num) range,
|
||||
final bool decimal = false,
|
||||
|
||||
/// Whether it may not stay blank.
|
||||
final bool needed = false,
|
||||
}) extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) => Padding(
|
||||
|
|
@ -287,7 +325,7 @@ class const _Figure({
|
|||
],
|
||||
validator: (text) {
|
||||
if (text == null || text.trim().isEmpty) {
|
||||
return null;
|
||||
return needed ? AppLocalizations.of(context).profileNeeded : null;
|
||||
}
|
||||
final value = _number(text);
|
||||
return value == null || value < range.$1 || value > range.$2
|
||||
|
|
@ -305,6 +343,9 @@ class const _Choice<T>({
|
|||
required final T? value,
|
||||
required final Map<T, String> names,
|
||||
required final void Function(T? value) onChanged,
|
||||
|
||||
/// Whether it may not stay blank.
|
||||
final bool needed = false,
|
||||
}) extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
|
|
@ -320,6 +361,8 @@ class const _Choice<T>({
|
|||
DropdownMenuItem(value: key, child: Text(name)),
|
||||
],
|
||||
onChanged: onChanged,
|
||||
validator: (value) =>
|
||||
needed && value == null ? l10n.profileNeeded : null,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue