feat(train): the Loop's firmware updates, directly or through the instance (ADR 0030, D10, D40)
Train's server relays Polar's firmware service for the people signed in (`firmware`: offered, check, download), a fetched file kept in memory a day per hardware and version, off with LOOP_FIRMWARE_RELAY=off. The app asks on the day's first connection and on the band's screen, from Polar or the instance as the person chose, never Polar in the instance's place; an update runs only after asking, reads and keeps the band first, and sets it up again with its figures and clock after. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
7c5e539df5
commit
388b3b1ae6
13 changed files with 2318 additions and 45 deletions
|
|
@ -208,6 +208,20 @@
|
|||
"loopBattery": "Akku {level} %",
|
||||
"loopCharging": "Akku {level} %, lädt",
|
||||
"loopFirmware": "Firmware {version}",
|
||||
"loopFirmwareCheck": "Nach Firmware-Update suchen",
|
||||
"loopFirmwareCurrent": "Die Firmware des Armbands ist aktuell.",
|
||||
"loopFirmwareOffered": "Firmware {version} verfügbar",
|
||||
"loopFirmwareViaPolar": "Direkt von Polar geholt: Polar erfährt die Adresse dieses Telefons (oder die seines VPN), dazu Firmware-Version und Hardware-Code des Armbands.",
|
||||
"loopFirmwareViaInstance": "Über deine Instanz geholt: Polar erfährt die Adresse der Instanz, nicht die dieses Telefons, dazu Firmware-Version und Hardware-Code des Armbands.",
|
||||
"loopFirmwareErases": "Das Update löscht das Armband. Train sichert zuerst, was darauf ist, und richtet es danach wieder mit deinem Profil ein.",
|
||||
"loopFirmwareUpdate": "Aktualisieren",
|
||||
"loopFirmwareUpdateTitle": "Firmware des Armbands aktualisieren?",
|
||||
"loopFirmwareUpdateText": "Train sichert zuerst, was auf dem Armband ist, da das Update es löscht. Das Armband startet neu, was einige Minuten dauert, und Train richtet es danach wieder mit deinem Profil ein. Lass es bis dahin beim Telefon.",
|
||||
"loopFirmwareStage": "{stage, select, reading{Sichere, was auf dem Armband ist …} fetching{Hole die Firmware …} preparing{Bereite das Armband vor …} writing{Schreibe die Firmware …} finalizing{Schließe das Update ab …} done{Geschrieben} restarting{Das Armband startet neu …} other{Aktualisiere …}}",
|
||||
"loopFirmwareStageHint": "Lass das Armband beim Telefon und Train geöffnet.",
|
||||
"loopFirmwareUpdated": "Firmware aktualisiert; das Armband ist wieder eingerichtet.",
|
||||
"loopFirmwareThroughInstance": "Firmware über deine Instanz",
|
||||
"loopFirmwareThroughInstanceHint": "Polar erfährt dann die Adresse der Instanz statt die dieses Telefons. Aus, fragt das Telefon Polar direkt.",
|
||||
"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",
|
||||
|
|
|
|||
|
|
@ -264,6 +264,22 @@
|
|||
"@loopCharging": {"placeholders": {"level": {"type": "int"}}},
|
||||
"loopFirmware": "Firmware {version}",
|
||||
"@loopFirmware": {"placeholders": {"version": {"type": "String", "example": "1.2.3"}}},
|
||||
"loopFirmwareCheck": "Check for a firmware update",
|
||||
"loopFirmwareCurrent": "The band's firmware is up to date.",
|
||||
"loopFirmwareOffered": "Firmware {version} available",
|
||||
"@loopFirmwareOffered": {"placeholders": {"version": {"type": "String", "example": "2.1.0"}}},
|
||||
"loopFirmwareViaPolar": "Fetched from Polar directly: Polar learns this phone's address (or its VPN's), with the band's firmware version and hardware code.",
|
||||
"loopFirmwareViaInstance": "Fetched through your instance: Polar learns the instance's address, not this phone's, with the band's firmware version and hardware code.",
|
||||
"loopFirmwareErases": "The update erases the band. Train first keeps what is on it, then sets it up again with your profile.",
|
||||
"loopFirmwareUpdate": "Update",
|
||||
"loopFirmwareUpdateTitle": "Update the band's firmware?",
|
||||
"loopFirmwareUpdateText": "Train first keeps what is on the band, as the update erases it. The band restarts, which takes a few minutes, and Train then sets it up again with your profile. Keep it near the phone until then.",
|
||||
"loopFirmwareStage": "{stage, select, reading{Keeping what is on the band…} fetching{Fetching the firmware…} preparing{Preparing the band…} writing{Writing the firmware…} finalizing{Finishing the update…} done{Written} restarting{The band restarts…} other{Updating…}}",
|
||||
"@loopFirmwareStage": {"placeholders": {"stage": {"type": "String"}}},
|
||||
"loopFirmwareStageHint": "Keep the band near the phone, and Train open.",
|
||||
"loopFirmwareUpdated": "Firmware updated; the band is set up again.",
|
||||
"loopFirmwareThroughInstance": "Firmware through your instance",
|
||||
"loopFirmwareThroughInstanceHint": "Polar then learns the instance's address rather than this phone's. Off, the phone asks Polar directly.",
|
||||
"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",
|
||||
|
|
|
|||
|
|
@ -1270,6 +1270,90 @@ abstract class AppLocalizations {
|
|||
/// **'Firmware {version}'**
|
||||
String loopFirmware(String version);
|
||||
|
||||
/// No description provided for @loopFirmwareCheck.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Check for a firmware update'**
|
||||
String get loopFirmwareCheck;
|
||||
|
||||
/// No description provided for @loopFirmwareCurrent.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'The band\'s firmware is up to date.'**
|
||||
String get loopFirmwareCurrent;
|
||||
|
||||
/// No description provided for @loopFirmwareOffered.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Firmware {version} available'**
|
||||
String loopFirmwareOffered(String version);
|
||||
|
||||
/// No description provided for @loopFirmwareViaPolar.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Fetched from Polar directly: Polar learns this phone\'s address (or its VPN\'s), with the band\'s firmware version and hardware code.'**
|
||||
String get loopFirmwareViaPolar;
|
||||
|
||||
/// No description provided for @loopFirmwareViaInstance.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Fetched through your instance: Polar learns the instance\'s address, not this phone\'s, with the band\'s firmware version and hardware code.'**
|
||||
String get loopFirmwareViaInstance;
|
||||
|
||||
/// No description provided for @loopFirmwareErases.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'The update erases the band. Train first keeps what is on it, then sets it up again with your profile.'**
|
||||
String get loopFirmwareErases;
|
||||
|
||||
/// No description provided for @loopFirmwareUpdate.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Update'**
|
||||
String get loopFirmwareUpdate;
|
||||
|
||||
/// No description provided for @loopFirmwareUpdateTitle.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Update the band\'s firmware?'**
|
||||
String get loopFirmwareUpdateTitle;
|
||||
|
||||
/// No description provided for @loopFirmwareUpdateText.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Train first keeps what is on the band, as the update erases it. The band restarts, which takes a few minutes, and Train then sets it up again with your profile. Keep it near the phone until then.'**
|
||||
String get loopFirmwareUpdateText;
|
||||
|
||||
/// No description provided for @loopFirmwareStage.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'{stage, select, reading{Keeping what is on the band…} fetching{Fetching the firmware…} preparing{Preparing the band…} writing{Writing the firmware…} finalizing{Finishing the update…} done{Written} restarting{The band restarts…} other{Updating…}}'**
|
||||
String loopFirmwareStage(String stage);
|
||||
|
||||
/// No description provided for @loopFirmwareStageHint.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Keep the band near the phone, and Train open.'**
|
||||
String get loopFirmwareStageHint;
|
||||
|
||||
/// No description provided for @loopFirmwareUpdated.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Firmware updated; the band is set up again.'**
|
||||
String get loopFirmwareUpdated;
|
||||
|
||||
/// No description provided for @loopFirmwareThroughInstance.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Firmware through your instance'**
|
||||
String get loopFirmwareThroughInstance;
|
||||
|
||||
/// No description provided for @loopFirmwareThroughInstanceHint.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Polar then learns the instance\'s address rather than this phone\'s. Off, the phone asks Polar directly.'**
|
||||
String get loopFirmwareThroughInstanceHint;
|
||||
|
||||
/// No description provided for @loopProfileChanged.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
|
|
|
|||
|
|
@ -702,6 +702,72 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||
return 'Firmware $version';
|
||||
}
|
||||
|
||||
@override
|
||||
String get loopFirmwareCheck => 'Nach Firmware-Update suchen';
|
||||
|
||||
@override
|
||||
String get loopFirmwareCurrent => 'Die Firmware des Armbands ist aktuell.';
|
||||
|
||||
@override
|
||||
String loopFirmwareOffered(String version) {
|
||||
return 'Firmware $version verfügbar';
|
||||
}
|
||||
|
||||
@override
|
||||
String get loopFirmwareViaPolar =>
|
||||
'Direkt von Polar geholt: Polar erfährt die Adresse dieses Telefons (oder die seines VPN), dazu Firmware-Version und Hardware-Code des Armbands.';
|
||||
|
||||
@override
|
||||
String get loopFirmwareViaInstance =>
|
||||
'Über deine Instanz geholt: Polar erfährt die Adresse der Instanz, nicht die dieses Telefons, dazu Firmware-Version und Hardware-Code des Armbands.';
|
||||
|
||||
@override
|
||||
String get loopFirmwareErases =>
|
||||
'Das Update löscht das Armband. Train sichert zuerst, was darauf ist, und richtet es danach wieder mit deinem Profil ein.';
|
||||
|
||||
@override
|
||||
String get loopFirmwareUpdate => 'Aktualisieren';
|
||||
|
||||
@override
|
||||
String get loopFirmwareUpdateTitle => 'Firmware des Armbands aktualisieren?';
|
||||
|
||||
@override
|
||||
String get loopFirmwareUpdateText =>
|
||||
'Train sichert zuerst, was auf dem Armband ist, da das Update es löscht. Das Armband startet neu, was einige Minuten dauert, und Train richtet es danach wieder mit deinem Profil ein. Lass es bis dahin beim Telefon.';
|
||||
|
||||
@override
|
||||
String loopFirmwareStage(String stage) {
|
||||
String _temp0 = intl.Intl.selectLogic(
|
||||
stage,
|
||||
{
|
||||
'reading': 'Sichere, was auf dem Armband ist …',
|
||||
'fetching': 'Hole die Firmware …',
|
||||
'preparing': 'Bereite das Armband vor …',
|
||||
'writing': 'Schreibe die Firmware …',
|
||||
'finalizing': 'Schließe das Update ab …',
|
||||
'done': 'Geschrieben',
|
||||
'restarting': 'Das Armband startet neu …',
|
||||
'other': 'Aktualisiere …',
|
||||
},
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String get loopFirmwareStageHint =>
|
||||
'Lass das Armband beim Telefon und Train geöffnet.';
|
||||
|
||||
@override
|
||||
String get loopFirmwareUpdated =>
|
||||
'Firmware aktualisiert; das Armband ist wieder eingerichtet.';
|
||||
|
||||
@override
|
||||
String get loopFirmwareThroughInstance => 'Firmware über deine Instanz';
|
||||
|
||||
@override
|
||||
String get loopFirmwareThroughInstanceHint =>
|
||||
'Polar erfährt dann die Adresse der Instanz statt die dieses Telefons. Aus, fragt das Telefon Polar direkt.';
|
||||
|
||||
@override
|
||||
String get loopProfileChanged =>
|
||||
'Dein Körper hat sich geändert, seit das Band eingerichtet wurde';
|
||||
|
|
|
|||
|
|
@ -698,6 +698,72 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||
return 'Firmware $version';
|
||||
}
|
||||
|
||||
@override
|
||||
String get loopFirmwareCheck => 'Check for a firmware update';
|
||||
|
||||
@override
|
||||
String get loopFirmwareCurrent => 'The band\'s firmware is up to date.';
|
||||
|
||||
@override
|
||||
String loopFirmwareOffered(String version) {
|
||||
return 'Firmware $version available';
|
||||
}
|
||||
|
||||
@override
|
||||
String get loopFirmwareViaPolar =>
|
||||
'Fetched from Polar directly: Polar learns this phone\'s address (or its VPN\'s), with the band\'s firmware version and hardware code.';
|
||||
|
||||
@override
|
||||
String get loopFirmwareViaInstance =>
|
||||
'Fetched through your instance: Polar learns the instance\'s address, not this phone\'s, with the band\'s firmware version and hardware code.';
|
||||
|
||||
@override
|
||||
String get loopFirmwareErases =>
|
||||
'The update erases the band. Train first keeps what is on it, then sets it up again with your profile.';
|
||||
|
||||
@override
|
||||
String get loopFirmwareUpdate => 'Update';
|
||||
|
||||
@override
|
||||
String get loopFirmwareUpdateTitle => 'Update the band\'s firmware?';
|
||||
|
||||
@override
|
||||
String get loopFirmwareUpdateText =>
|
||||
'Train first keeps what is on the band, as the update erases it. The band restarts, which takes a few minutes, and Train then sets it up again with your profile. Keep it near the phone until then.';
|
||||
|
||||
@override
|
||||
String loopFirmwareStage(String stage) {
|
||||
String _temp0 = intl.Intl.selectLogic(
|
||||
stage,
|
||||
{
|
||||
'reading': 'Keeping what is on the band…',
|
||||
'fetching': 'Fetching the firmware…',
|
||||
'preparing': 'Preparing the band…',
|
||||
'writing': 'Writing the firmware…',
|
||||
'finalizing': 'Finishing the update…',
|
||||
'done': 'Written',
|
||||
'restarting': 'The band restarts…',
|
||||
'other': 'Updating…',
|
||||
},
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String get loopFirmwareStageHint =>
|
||||
'Keep the band near the phone, and Train open.';
|
||||
|
||||
@override
|
||||
String get loopFirmwareUpdated =>
|
||||
'Firmware updated; the band is set up again.';
|
||||
|
||||
@override
|
||||
String get loopFirmwareThroughInstance => 'Firmware through your instance';
|
||||
|
||||
@override
|
||||
String get loopFirmwareThroughInstanceHint =>
|
||||
'Polar then learns the instance\'s address rather than this phone\'s. Off, the phone asks Polar directly.';
|
||||
|
||||
@override
|
||||
String get loopProfileChanged =>
|
||||
'Your body changed since the band was set up';
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import 'package:halkyon_train_flutter/src/feature/health/data/android_health_pla
|
|||
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/firmware_relay_gateway.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';
|
||||
|
|
@ -348,6 +349,7 @@ Future<TrainPerson> _openPersonData(
|
|||
days: days,
|
||||
workouts: workouts,
|
||||
backgroundLines: File(p.join(folder.path, 'loop_days.txt')),
|
||||
relay: ServerpodFirmwareRelayGateway(client),
|
||||
)..start());
|
||||
return TrainPerson(
|
||||
person,
|
||||
|
|
|
|||
|
|
@ -3,17 +3,24 @@ 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/loop_store.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,
|
||||
|
||||
/// Asked for its firmware (D40): none newer, or one found.
|
||||
firmwareCurrent,
|
||||
firmwareFound,
|
||||
updated,
|
||||
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.
|
||||
/// The band's screen (ADR 0030, D34, D35, D40): where the band stands,
|
||||
/// sending a changed profile when the person says, its firmware asked for
|
||||
/// and updated, and letting it go.
|
||||
class LoopCubit(final PolarLoop _loop) extends Cubit<LoopState> {
|
||||
this : super(LoopState(status: _loop.status));
|
||||
|
||||
|
|
@ -31,12 +38,28 @@ class LoopCubit(final PolarLoop _loop) extends Cubit<LoopState> {
|
|||
|
||||
Future<void> letGo() => _do(_loop.letGo, LoopOutcome.letGo);
|
||||
|
||||
Future<void> _do(Future<void> Function() action, LoopOutcome done) async {
|
||||
Future<void> checkFirmware() => _ask(
|
||||
() async => await _loop.checkFirmware() == null
|
||||
? LoopOutcome.firmwareCurrent
|
||||
: LoopOutcome.firmwareFound,
|
||||
);
|
||||
|
||||
Future<void> updateFirmware() =>
|
||||
_do(_loop.updateFirmware, LoopOutcome.updated);
|
||||
|
||||
void setFirmwareVia(FirmwareVia via) => _loop.setFirmwareVia(via);
|
||||
|
||||
Future<void> _do(Future<void> Function() action, LoopOutcome done) =>
|
||||
_ask(() async {
|
||||
await action();
|
||||
return done;
|
||||
});
|
||||
|
||||
Future<void> _ask(Future<LoopOutcome> Function() action) async {
|
||||
emit(LoopState(status: state.status, busy: true));
|
||||
LoopOutcome outcome;
|
||||
try {
|
||||
await action();
|
||||
outcome = done;
|
||||
outcome = await action();
|
||||
} on LoopFailed {
|
||||
outcome = LoopOutcome.failed;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -75,6 +75,38 @@ class AndroidLoopPlatform(final RecorderPlatform _recorder)
|
|||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<LoopFirmwareInfo> firmwareInfo(String id) async {
|
||||
try {
|
||||
return switch (await _channel.invokeMethod<Object?>('firmwareInfo', {
|
||||
'id': id,
|
||||
})) {
|
||||
{
|
||||
'uuid': final String uuid,
|
||||
'version': final String version,
|
||||
'hardwareCode': final String hardwareCode,
|
||||
} =>
|
||||
(uuid: uuid, version: version, hardwareCode: hardwareCode),
|
||||
_ => throw const LoopFailed('firmware unread'),
|
||||
};
|
||||
} on PlatformException catch (error) {
|
||||
throw LoopFailed(error.message);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<String?> checkFirmware(String id) async {
|
||||
try {
|
||||
return await _channel.invokeMethod<String>('checkFirmware', {'id': id});
|
||||
} on PlatformException catch (error) {
|
||||
throw LoopFailed(error.message);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> updateFirmware(String id, {String? file}) =>
|
||||
_call('updateFirmware', {'id': id, 'file': ?file});
|
||||
|
||||
@override
|
||||
Future<void> background(LoopBackground? config) =>
|
||||
_call('background', {'config': config?.toMap()});
|
||||
|
|
@ -110,5 +142,7 @@ LoopEvent? loopEventOf(Object? event) => switch (event) {
|
|||
'version': final String version,
|
||||
} =>
|
||||
LoopFirmware(id, version),
|
||||
{'id': final String id, 'event': 'firmware', 'stage': final String stage} =>
|
||||
LoopFirmwareStage(id, stage),
|
||||
_ => null,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -0,0 +1,44 @@
|
|||
import 'dart:typed_data';
|
||||
|
||||
import 'package:halkyon_train_client/halkyon_train_client.dart' show Client;
|
||||
import 'package:halkyon_train_flutter/src/feature/loop/data/loop_platform.dart';
|
||||
|
||||
/// The Loop's firmware through the instance (ADR 0030, D10, D40): Train's
|
||||
/// server asks Polar in the person's place and hands the file on.
|
||||
abstract interface class FirmwareRelayGateway() {
|
||||
/// Whether the instance relays firmware (`LOOP_FIRMWARE_RELAY`).
|
||||
Future<bool> offered();
|
||||
|
||||
/// The version Polar offers for the band [info] names; null where none.
|
||||
Future<String?> check(LoopFirmwareInfo info);
|
||||
|
||||
/// The file of [version] for bands of [hardwareCode].
|
||||
Future<Uint8List> download({
|
||||
required String hardwareCode,
|
||||
required String version,
|
||||
});
|
||||
}
|
||||
|
||||
/// [FirmwareRelayGateway] on Train's server (`firmware`).
|
||||
class ServerpodFirmwareRelayGateway(final Client _client)
|
||||
implements FirmwareRelayGateway {
|
||||
@override
|
||||
Future<bool> offered() => _client.firmware.offered();
|
||||
|
||||
@override
|
||||
Future<String?> check(LoopFirmwareInfo info) async =>
|
||||
(await _client.firmware.check(
|
||||
info.uuid,
|
||||
info.version,
|
||||
info.hardwareCode,
|
||||
))?.version;
|
||||
|
||||
@override
|
||||
Future<Uint8List> download({
|
||||
required String hardwareCode,
|
||||
required String version,
|
||||
}) async {
|
||||
final file = await _client.firmware.download(hardwareCode, version);
|
||||
return file.buffer.asUint8List(file.offsetInBytes, file.lengthInBytes);
|
||||
}
|
||||
}
|
||||
|
|
@ -30,6 +30,14 @@ final class LoopCharging(super.id, {required final bool charging})
|
|||
/// Its firmware's version.
|
||||
final class LoopFirmware(super.id, final String version) extends LoopEvent;
|
||||
|
||||
/// How far a firmware update is (D40): `fetching`, `preparing`, `writing`,
|
||||
/// `finalizing`, `done`.
|
||||
final class LoopFirmwareStage(super.id, final String stage) extends LoopEvent;
|
||||
|
||||
/// What Polar's firmware service is asked with (D10): Polar's UUID of the
|
||||
/// band, its firmware's version and its hardware code.
|
||||
typedef LoopFirmwareInfo = ({String uuid, String version, String hardwareCode});
|
||||
|
||||
/// 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() {
|
||||
|
|
@ -66,6 +74,18 @@ abstract interface class LoopPlatform() {
|
|||
DateTime? keptUntil,
|
||||
});
|
||||
|
||||
/// What the band's firmware is, for the instance to ask Polar with (D40).
|
||||
Future<LoopFirmwareInfo> firmwareInfo(String id);
|
||||
|
||||
/// Asks Polar directly for an update of the band's firmware; its version,
|
||||
/// null where there is none.
|
||||
Future<String?> checkFirmware(String id);
|
||||
|
||||
/// Updates the band's firmware, which erases it: fetched from Polar, or
|
||||
/// from [file] (a package the instance relayed); its stages as
|
||||
/// [LoopFirmwareStage]s. Throws [LoopFailed] where it failed.
|
||||
Future<void> updateFirmware(String id, {String? file});
|
||||
|
||||
/// Has the band read every few hours while the app is closed, into
|
||||
/// `loop_days.txt` in the person's folder, with what the app kept; or no longer,
|
||||
/// where [config] is null.
|
||||
|
|
|
|||
|
|
@ -6,6 +6,13 @@ import 'package:equatable/equatable.dart';
|
|||
import 'package:halkyon_train_flutter/src/feature/day/model/day.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/loop/model/loop_figures.dart';
|
||||
|
||||
/// Where the band's firmware is asked for and fetched (ADR 0030, D10):
|
||||
/// from Polar directly, or through the instance.
|
||||
enum FirmwareVia() {
|
||||
polar,
|
||||
instance,
|
||||
}
|
||||
|
||||
/// 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).
|
||||
|
|
@ -33,6 +40,12 @@ final class const KnownLoop({
|
|||
/// The band's training sessions taken in (D39), by ID, each with its
|
||||
/// date, so that one isn't brought again, deleted in Train or not.
|
||||
final Map<String, DateTime> takenSessions = const {},
|
||||
|
||||
/// Where its firmware comes from (D10), and the band's date it was last
|
||||
/// asked for, once a day (D40); the update offered then, if any.
|
||||
final FirmwareVia firmwareVia = FirmwareVia.polar,
|
||||
final DateTime? firmwareChecked,
|
||||
final String? firmwareOffered,
|
||||
}) with Equatable {
|
||||
KnownLoop copyWith({
|
||||
String? name,
|
||||
|
|
@ -44,6 +57,9 @@ final class const KnownLoop({
|
|||
DateTime? keptUntil,
|
||||
DateTime? lastRead,
|
||||
Map<String, DateTime>? takenSessions,
|
||||
FirmwareVia? firmwareVia,
|
||||
DateTime? firmwareChecked,
|
||||
String? Function()? firmwareOffered,
|
||||
}) => KnownLoop(
|
||||
id: id,
|
||||
name: name ?? this.name,
|
||||
|
|
@ -55,6 +71,11 @@ final class const KnownLoop({
|
|||
keptUntil: keptUntil ?? this.keptUntil,
|
||||
lastRead: lastRead ?? this.lastRead,
|
||||
takenSessions: takenSessions ?? this.takenSessions,
|
||||
firmwareVia: firmwareVia ?? this.firmwareVia,
|
||||
firmwareChecked: firmwareChecked ?? this.firmwareChecked,
|
||||
firmwareOffered: firmwareOffered == null
|
||||
? this.firmwareOffered
|
||||
: firmwareOffered(),
|
||||
);
|
||||
|
||||
@override
|
||||
|
|
@ -69,6 +90,9 @@ final class const KnownLoop({
|
|||
keptUntil,
|
||||
lastRead,
|
||||
takenSessions,
|
||||
firmwareVia,
|
||||
firmwareChecked,
|
||||
firmwareOffered,
|
||||
];
|
||||
}
|
||||
|
||||
|
|
@ -105,6 +129,11 @@ class LoopStore(final File _file) {
|
|||
for (final MapEntry(:key, :value) in taken.entries)
|
||||
key: ?dateOfKey(value),
|
||||
},
|
||||
firmwareVia: json['firmwareVia'] == FirmwareVia.instance.name
|
||||
? FirmwareVia.instance
|
||||
: FirmwareVia.polar,
|
||||
firmwareChecked: dateOfKey(json['firmwareChecked']),
|
||||
firmwareOffered: _as<String>(json['firmwareOffered']),
|
||||
),
|
||||
_ => null,
|
||||
};
|
||||
|
|
@ -133,6 +162,13 @@ class LoopStore(final File _file) {
|
|||
for (final MapEntry(:key, :value) in loop.takenSessions.entries)
|
||||
key: dayKey(value),
|
||||
},
|
||||
if (loop.firmwareVia != FirmwareVia.polar)
|
||||
'firmwareVia': loop.firmwareVia.name,
|
||||
'firmwareChecked': ?switch (loop.firmwareChecked) {
|
||||
final date? => dayKey(date),
|
||||
null => null,
|
||||
},
|
||||
'firmwareOffered': ?loop.firmwareOffered,
|
||||
}),
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -7,6 +7,8 @@ import 'package:halkyon_core/halkyon_core.dart';
|
|||
import 'package:halkyon_train_flutter/src/feature/day/data/day_repository.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/day/data/loop_lines.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/day/data/loop_workouts.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/day/model/day.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/loop/data/firmware_relay_gateway.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';
|
||||
|
|
@ -29,6 +31,10 @@ const _firstReadDays = 28;
|
|||
/// the band has deleted it long since (D39).
|
||||
const _takenKept = Duration(days: 7);
|
||||
|
||||
/// How long the band has to come back after its firmware was written: it
|
||||
/// restarts, and may take a few minutes (D40).
|
||||
const _backWithin = Duration(minutes: 5);
|
||||
|
||||
/// Where the person's Loop stands.
|
||||
final class const LoopStatus({
|
||||
/// The band remembered; null without one.
|
||||
|
|
@ -38,9 +44,36 @@ final class const LoopStatus({
|
|||
/// Whether the profile changed since the band was set up with it, and can
|
||||
/// be sent (D35).
|
||||
final bool profileChanged = false,
|
||||
|
||||
/// Whether the instance relays firmware (D40).
|
||||
final bool relayOffered = false,
|
||||
|
||||
/// How far an update of its firmware is (a [LoopFirmwareStage]'s), or
|
||||
/// `reading` before it; null while none runs.
|
||||
final String? firmwareStage,
|
||||
}) with Equatable {
|
||||
LoopStatus copyWith({
|
||||
KnownLoop? loop,
|
||||
bool? connected,
|
||||
bool? profileChanged,
|
||||
bool? relayOffered,
|
||||
String? Function()? firmwareStage,
|
||||
}) => LoopStatus(
|
||||
loop: loop ?? this.loop,
|
||||
connected: connected ?? this.connected,
|
||||
profileChanged: profileChanged ?? this.profileChanged,
|
||||
relayOffered: relayOffered ?? this.relayOffered,
|
||||
firmwareStage: firmwareStage == null ? this.firmwareStage : firmwareStage(),
|
||||
);
|
||||
|
||||
@override
|
||||
List<Object?> get props => [loop, connected, profileChanged];
|
||||
List<Object?> get props => [
|
||||
loop,
|
||||
connected,
|
||||
profileChanged,
|
||||
relayOffered,
|
||||
firmwareStage,
|
||||
];
|
||||
}
|
||||
|
||||
/// The person's Polar Loop on this phone (ADR 0030, D34, D35): set up with
|
||||
|
|
@ -52,6 +85,9 @@ final class const LoopStatus({
|
|||
/// most, and kept sealed in the person's database; what the background job
|
||||
/// read meanwhile into [backgroundLines] is kept as the app opens. Once
|
||||
/// kept, a day before today is deleted on the band by the next read (D38).
|
||||
/// Its firmware (D10, D40) is asked for on the day's first connection and
|
||||
/// when the person asks, from Polar or through the instance as they chose,
|
||||
/// and updated only when they say.
|
||||
class PolarLoop(
|
||||
/// Polar's SDK.
|
||||
final LoopPlatform platform,
|
||||
|
|
@ -66,6 +102,9 @@ class PolarLoop(
|
|||
/// writes what it reads.
|
||||
required final File backgroundLines,
|
||||
|
||||
/// The instance's firmware relay; null where there is none to ask.
|
||||
final FirmwareRelayGateway? relay,
|
||||
|
||||
/// Today, for the age the defaults take and the dates read.
|
||||
final DateTime Function() today = DateTime.now,
|
||||
final DateTime Function() now = DateTime.now,
|
||||
|
|
@ -88,6 +127,17 @@ class PolarLoop(
|
|||
|
||||
/// Whether the next read goes to the band however recent the last.
|
||||
var _readAsked = false;
|
||||
late final _checking = CoalescingRunner(
|
||||
_checkDaily,
|
||||
onError: (error, stackTrace) =>
|
||||
_failed('checked for firmware', error, stackTrace),
|
||||
);
|
||||
|
||||
/// Whether its firmware is being updated, during which nothing else asks
|
||||
/// the band; and whether it is being written, the stages it tells
|
||||
/// shown only then, as one coming late would hide the restart's.
|
||||
var _updating = false;
|
||||
var _writing = false;
|
||||
late final _clocking = CoalescingRunner(
|
||||
_setClock,
|
||||
onError: (error, stackTrace) =>
|
||||
|
|
@ -109,6 +159,7 @@ class PolarLoop(
|
|||
unawaited(_comparing.run());
|
||||
// What the background job read while the app was closed.
|
||||
unawaited(_reading.run());
|
||||
unawaited(_askRelay());
|
||||
final known = _status.loop;
|
||||
if (known != null) {
|
||||
unawaited(_schedule());
|
||||
|
|
@ -121,6 +172,7 @@ class PolarLoop(
|
|||
await _comparing.idle;
|
||||
await _clocking.idle;
|
||||
await _reading.idle;
|
||||
await _checking.idle;
|
||||
}
|
||||
|
||||
/// Reads the band's days now, where it is ready, however recent the last
|
||||
|
|
@ -162,12 +214,19 @@ class PolarLoop(
|
|||
keptUntil: same ? before?.keptUntil : null,
|
||||
lastRead: same ? before?.lastRead : null,
|
||||
takenSessions: same ? before!.takenSessions : const {},
|
||||
firmwareChecked: same ? before?.firmwareChecked : null,
|
||||
firmwareOffered: same ? before?.firmwareOffered : null,
|
||||
// The person's choice, whichever band.
|
||||
firmwareVia: before?.firmwareVia ?? FirmwareVia.polar,
|
||||
),
|
||||
connected: true,
|
||||
relayOffered: _status.relayOffered,
|
||||
),
|
||||
);
|
||||
await _comparing.run();
|
||||
await _schedule();
|
||||
// A band just set up is asked for its firmware as a connection is.
|
||||
unawaited(_checking.run());
|
||||
}
|
||||
|
||||
/// Sends the profile as it is now to the band (D35); throws [LoopFailed]
|
||||
|
|
@ -183,15 +242,103 @@ class PolarLoop(
|
|||
throw const LoopFailed('not connected');
|
||||
}
|
||||
await platform.setUp(known.id, figures);
|
||||
_update(
|
||||
LoopStatus(
|
||||
loop: known.copyWith(figures: figures),
|
||||
connected: _status.connected,
|
||||
),
|
||||
);
|
||||
_update(_status.copyWith(loop: known.copyWith(figures: figures)));
|
||||
await _comparing.run();
|
||||
}
|
||||
|
||||
/// Where the band's firmware comes from from now on (D10).
|
||||
void setFirmwareVia(FirmwareVia via) {
|
||||
if (_status.loop case final known?) {
|
||||
_update(_with(known.copyWith(firmwareVia: via)));
|
||||
}
|
||||
}
|
||||
|
||||
/// Asks for an update of the band's firmware now, the way the person
|
||||
/// chose (D40); the version offered, null where there is none. Throws
|
||||
/// [LoopFailed] where the band isn't ready or the asking failed.
|
||||
Future<String?> checkFirmware() async {
|
||||
final known = _status.loop;
|
||||
if (known == null) {
|
||||
return null;
|
||||
}
|
||||
if (!_ready.contains(known.id) || _updating) {
|
||||
throw const LoopFailed('not connected');
|
||||
}
|
||||
return await _check(known);
|
||||
}
|
||||
|
||||
/// Updates the band's firmware to the version offered (D40): reads and
|
||||
/// keeps what is on it first, as the update erases it, then writes the
|
||||
/// firmware, fetched from Polar or relayed by the instance, and sets the
|
||||
/// band up again with the figures it had and its clock. Throws
|
||||
/// [LoopFailed] where it isn't ready, the read or the update failed, or
|
||||
/// it didn't come back; a failed read leaves the band untouched.
|
||||
Future<void> updateFirmware() async {
|
||||
final known = _status.loop;
|
||||
final version = known?.firmwareOffered;
|
||||
if (known == null || version == null) {
|
||||
return;
|
||||
}
|
||||
if (!_ready.contains(known.id) || _updating) {
|
||||
throw const LoopFailed('not connected');
|
||||
}
|
||||
final id = known.id;
|
||||
_updating = true;
|
||||
_update(_status.copyWith(firmwareStage: () => 'reading'));
|
||||
final file = File(p.join(backgroundLines.parent.path, 'loop_firmware.zip'));
|
||||
try {
|
||||
await _reading.idle;
|
||||
await _readBand(known);
|
||||
if (known.firmwareVia == FirmwareVia.instance) {
|
||||
final relay = _relay();
|
||||
final info = await platform.firmwareInfo(id);
|
||||
final offered = await _relayed(() => relay.check(info));
|
||||
if (offered == null) {
|
||||
_update(_with(_status.loop!.copyWith(firmwareOffered: () => null)));
|
||||
throw const LoopFailed('no update');
|
||||
}
|
||||
final bytes = await _relayed(
|
||||
() =>
|
||||
relay.download(hardwareCode: info.hardwareCode, version: offered),
|
||||
);
|
||||
await file.writeAsBytes(bytes, flush: true);
|
||||
_writing = true;
|
||||
await platform.updateFirmware(id, file: file.path);
|
||||
} else {
|
||||
_writing = true;
|
||||
await platform.updateFirmware(id);
|
||||
}
|
||||
_writing = false;
|
||||
// It restarts: what it is set up with comes once it is back.
|
||||
_ready.remove(id);
|
||||
_update(_status.copyWith(firmwareStage: () => 'restarting'));
|
||||
await platform.connect(id);
|
||||
await _readyOf(id, within: _backWithin);
|
||||
final figures = known.figures ?? _current;
|
||||
if (figures != null) {
|
||||
await platform.setUp(id, figures);
|
||||
}
|
||||
await platform.setClock(id);
|
||||
_update(
|
||||
_with(
|
||||
_status.loop!.copyWith(
|
||||
figures: figures,
|
||||
firmware: version,
|
||||
firmwareOffered: () => null,
|
||||
),
|
||||
),
|
||||
);
|
||||
await _comparing.run();
|
||||
} finally {
|
||||
_updating = false;
|
||||
_writing = false;
|
||||
if (file.existsSync()) {
|
||||
file.deleteSync();
|
||||
}
|
||||
_update(_status.copyWith(firmwareStage: () => null));
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
|
|
@ -208,7 +355,7 @@ class PolarLoop(
|
|||
}
|
||||
_ready.remove(known.id);
|
||||
_store.forget();
|
||||
_status = const LoopStatus();
|
||||
_status = LoopStatus(relayOffered: _status.relayOffered);
|
||||
_changes.add(_status);
|
||||
}
|
||||
|
||||
|
|
@ -235,7 +382,7 @@ class PolarLoop(
|
|||
}
|
||||
}
|
||||
|
||||
Future<void> _readyOf(String id) {
|
||||
Future<void> _readyOf(String id, {Duration within = _readyWithin}) {
|
||||
if (_ready.contains(id)) {
|
||||
return Future.value();
|
||||
}
|
||||
|
|
@ -243,7 +390,7 @@ class PolarLoop(
|
|||
.putIfAbsent(id, Completer.new)
|
||||
.future
|
||||
.timeout(
|
||||
_readyWithin,
|
||||
within,
|
||||
onTimeout: () => throw const LoopFailed('not ready'),
|
||||
)
|
||||
.whenComplete(() => _waiting.remove(id));
|
||||
|
|
@ -266,22 +413,25 @@ class PolarLoop(
|
|||
break;
|
||||
case LoopConnected(:final name):
|
||||
_update(
|
||||
LoopStatus(
|
||||
_status.copyWith(
|
||||
loop: known.copyWith(name: name, lastSeen: () => null),
|
||||
connected: true,
|
||||
profileChanged: _status.profileChanged,
|
||||
),
|
||||
);
|
||||
case LoopReady():
|
||||
// Each connection sets the band's clock (D34), and reads its days
|
||||
// where the last read is long enough ago (D37).
|
||||
unawaited(_clocking.run());
|
||||
unawaited(_reading.run());
|
||||
// Each connection sets the band's clock (D34), reads its days where
|
||||
// the last read is long enough ago (D37), and asks for its firmware
|
||||
// on the day's first (D40); an update does all this itself.
|
||||
if (!_updating) {
|
||||
unawaited(_clocking.run());
|
||||
unawaited(_reading.run());
|
||||
unawaited(_checking.run());
|
||||
}
|
||||
case LoopDisconnected():
|
||||
_update(
|
||||
LoopStatus(
|
||||
_status.copyWith(
|
||||
loop: known.copyWith(lastSeen: () => DateTime.now().toUtc()),
|
||||
profileChanged: _status.profileChanged,
|
||||
connected: false,
|
||||
),
|
||||
);
|
||||
case LoopBattery(:final level):
|
||||
|
|
@ -290,6 +440,79 @@ class PolarLoop(
|
|||
_update(_with(known.copyWith(charging: charging)));
|
||||
case LoopFirmware(:final version):
|
||||
_update(_with(known.copyWith(firmware: version)));
|
||||
case LoopFirmwareStage(:final stage):
|
||||
if (_writing) {
|
||||
_update(_status.copyWith(firmwareStage: () => stage));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Asks the instance whether it relays firmware.
|
||||
Future<void> _askRelay() async {
|
||||
final relay = this.relay;
|
||||
if (relay == null) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
final offered = await relay.offered();
|
||||
_update(_status.copyWith(relayOffered: offered));
|
||||
} on Object catch (error, st) {
|
||||
_failed('asked about the relay', error, st);
|
||||
}
|
||||
}
|
||||
|
||||
/// Asks for the band's firmware where it wasn't asked for on the band's
|
||||
/// date yet.
|
||||
Future<void> _checkDaily() async {
|
||||
final known = _status.loop;
|
||||
if (known == null || !_ready.contains(known.id) || _updating) {
|
||||
return;
|
||||
}
|
||||
if (known.firmwareChecked case final checked?
|
||||
when dayKey(checked) == dayKey(today())) {
|
||||
return;
|
||||
}
|
||||
await _check(known);
|
||||
}
|
||||
|
||||
/// Asks for an update for [known] the way the person chose, and
|
||||
/// remembers the answer; never Polar directly where the instance was
|
||||
/// chosen.
|
||||
Future<String?> _check(KnownLoop known) async {
|
||||
final String? offered;
|
||||
if (known.firmwareVia == FirmwareVia.instance) {
|
||||
final relay = _relay();
|
||||
final info = await platform.firmwareInfo(known.id);
|
||||
offered = await _relayed(() => relay.check(info));
|
||||
} else {
|
||||
offered = await platform.checkFirmware(known.id);
|
||||
}
|
||||
final date = today();
|
||||
if (_status.loop case final now? when now.id == known.id) {
|
||||
_update(
|
||||
_with(
|
||||
now.copyWith(
|
||||
firmwareChecked: DateTime.utc(date.year, date.month, date.day),
|
||||
firmwareOffered: () => offered,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
return offered;
|
||||
}
|
||||
|
||||
FirmwareRelayGateway _relay() =>
|
||||
relay ?? (throw const LoopFailed('no relay'));
|
||||
|
||||
/// [ask]'s answer from the instance; its failure (refused, offline) as a
|
||||
/// [LoopFailed].
|
||||
Future<T> _relayed<T>(Future<T> Function() ask) async {
|
||||
try {
|
||||
return await ask();
|
||||
} on LoopFailed {
|
||||
rethrow;
|
||||
} on Object catch (error) {
|
||||
throw LoopFailed('relay: ${error.runtimeType}');
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -312,9 +535,15 @@ class PolarLoop(
|
|||
return;
|
||||
}
|
||||
final last = known.lastRead;
|
||||
if (!asked && last != null && now().difference(last) < _readEvery) {
|
||||
if (_updating ||
|
||||
!asked && last != null && now().difference(last) < _readEvery) {
|
||||
return;
|
||||
}
|
||||
await _readBand(known);
|
||||
}
|
||||
|
||||
/// Reads [known]'s days and keeps them; throws where the read failed.
|
||||
Future<void> _readBand(KnownLoop known) async {
|
||||
final lines = LoopLines();
|
||||
(await platform.readDays(
|
||||
known.id,
|
||||
|
|
@ -438,11 +667,7 @@ class PolarLoop(
|
|||
}
|
||||
}
|
||||
|
||||
LoopStatus _with(KnownLoop loop) => LoopStatus(
|
||||
loop: loop,
|
||||
connected: _status.connected,
|
||||
profileChanged: _status.profileChanged,
|
||||
);
|
||||
LoopStatus _with(KnownLoop loop) => _status.copyWith(loop: loop);
|
||||
|
||||
/// Works out the figures the profile gives now, and whether they differ
|
||||
/// from the band's.
|
||||
|
|
@ -452,9 +677,7 @@ class PolarLoop(
|
|||
final sent = _status.loop?.figures;
|
||||
final current = _current;
|
||||
_update(
|
||||
LoopStatus(
|
||||
loop: _status.loop,
|
||||
connected: _status.connected,
|
||||
_status.copyWith(
|
||||
profileChanged: sent != null && current != null && current != sent,
|
||||
),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -6,6 +6,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/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/loop_store.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';
|
||||
|
|
@ -31,9 +32,11 @@ Future<void> openLoopScreen(BuildContext context) {
|
|||
);
|
||||
}
|
||||
|
||||
/// The person's Polar Loop (ADR 0030, D34, D35): its name, battery,
|
||||
/// The person's Polar Loop (ADR 0030, D34, D35, D40): 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.
|
||||
/// offered to it; a firmware update offered with what Polar learns, made
|
||||
/// after asking, and where the firmware comes from; set up again, or let go
|
||||
/// after asking.
|
||||
class const LoopScreen({super.key}) extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
|
|
@ -44,8 +47,10 @@ class const LoopScreen({super.key}) extends StatelessWidget {
|
|||
listener: (context, state) {
|
||||
final text = switch (state.outcome) {
|
||||
LoopOutcome.sent => l10n.loopSent,
|
||||
LoopOutcome.firmwareCurrent => l10n.loopFirmwareCurrent,
|
||||
LoopOutcome.updated => l10n.loopFirmwareUpdated,
|
||||
LoopOutcome.failed => l10n.loopFailed,
|
||||
LoopOutcome.letGo || null => null,
|
||||
LoopOutcome.letGo || LoopOutcome.firmwareFound || null => null,
|
||||
};
|
||||
if (text != null) {
|
||||
// In place of the setup's, which would hold this one back.
|
||||
|
|
@ -121,6 +126,7 @@ class const LoopScreen({super.key}) extends StatelessWidget {
|
|||
leading: const Icon(LucideIcons.cpu),
|
||||
title: Text(l10n.loopFirmware(version)),
|
||||
),
|
||||
..._firmware(context, l10n, state, known),
|
||||
if (state.status.profileChanged)
|
||||
Card(
|
||||
margin: const EdgeInsets.all(16),
|
||||
|
|
@ -175,6 +181,111 @@ class const LoopScreen({super.key}) extends StatelessWidget {
|
|||
);
|
||||
}
|
||||
|
||||
/// The firmware's part (D40): an update running, or one offered with
|
||||
/// what Polar learns; asking for one; where it comes from, where the
|
||||
/// instance relays it or was chosen.
|
||||
List<Widget> _firmware(
|
||||
BuildContext context,
|
||||
AppLocalizations l10n,
|
||||
LoopState state,
|
||||
KnownLoop known,
|
||||
) {
|
||||
final theme = Theme.of(context);
|
||||
final cubit = context.read<LoopCubit>();
|
||||
final through = known.firmwareVia == FirmwareVia.instance;
|
||||
final stage = state.status.firmwareStage;
|
||||
final offered = known.firmwareOffered;
|
||||
final card = switch ((stage, offered)) {
|
||||
(final String stage, _) => [
|
||||
Text(l10n.loopFirmwareStage(stage), style: theme.textTheme.titleSmall),
|
||||
const SizedBox(height: 4),
|
||||
Text(l10n.loopFirmwareStageHint),
|
||||
const SizedBox(height: 12),
|
||||
const LinearProgressIndicator(),
|
||||
],
|
||||
(null, final String version) => [
|
||||
Text(
|
||||
l10n.loopFirmwareOffered(version),
|
||||
style: theme.textTheme.titleSmall,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
through ? l10n.loopFirmwareViaInstance : l10n.loopFirmwareViaPolar,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(l10n.loopFirmwareErases),
|
||||
const SizedBox(height: 12),
|
||||
FilledButton.icon(
|
||||
icon: const Icon(LucideIcons.download),
|
||||
label: Text(l10n.loopFirmwareUpdate),
|
||||
onPressed: state.busy || !state.status.connected
|
||||
? null
|
||||
: () => _update(context, l10n),
|
||||
),
|
||||
],
|
||||
(null, null) => null,
|
||||
};
|
||||
return [
|
||||
if (card != null)
|
||||
Card(
|
||||
margin: const EdgeInsets.all(16),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: card,
|
||||
),
|
||||
),
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(LucideIcons.search),
|
||||
title: Text(l10n.loopFirmwareCheck),
|
||||
subtitle: Text(
|
||||
through ? l10n.loopFirmwareViaInstance : l10n.loopFirmwareViaPolar,
|
||||
),
|
||||
onTap: state.busy || stage != null || !state.status.connected
|
||||
? null
|
||||
: cubit.checkFirmware,
|
||||
),
|
||||
if (state.status.relayOffered || through)
|
||||
SwitchListTile(
|
||||
secondary: const Icon(LucideIcons.server),
|
||||
title: Text(l10n.loopFirmwareThroughInstance),
|
||||
subtitle: Text(l10n.loopFirmwareThroughInstanceHint),
|
||||
value: through,
|
||||
onChanged: stage != null
|
||||
? null
|
||||
: (on) => cubit.setFirmwareVia(
|
||||
on ? FirmwareVia.instance : FirmwareVia.polar,
|
||||
),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
Future<void> _update(BuildContext context, AppLocalizations l10n) async {
|
||||
final cubit = context.read<LoopCubit>();
|
||||
final sure = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: Text(l10n.loopFirmwareUpdateTitle),
|
||||
content: Text(l10n.loopFirmwareUpdateText),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(false),
|
||||
child: Text(MaterialLocalizations.of(context).cancelButtonLabel),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.of(context).pop(true),
|
||||
child: Text(l10n.loopFirmwareUpdate),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (sure ?? false) {
|
||||
await cubit.updateFirmware();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _letGo(BuildContext context, AppLocalizations l10n) async {
|
||||
final cubit = context.read<LoopCubit>();
|
||||
final sure = await showDialog<bool>(
|
||||
|
|
|
|||
Loading…
Reference in a new issue