Skip to content

Commit 62aab4e

Browse files
committed
feat(recoverbull): show Tor connection state with a mascot
The screen reported arti's bootstrap fraction as a percentage and a progress bar. That number is not user-facing progress: it is 85% directory completeness, the directory is cached on disk, and arti documents it as explicitly non-monotonic. A device run went 30% -> 85% -> ready -> 93% -> 37%, so the bar made a healthy bootstrap look broken, while a warm cache made a disconnected client read 85% from the first frame. Five illustrated states replace it, each mapped to a fact the data can actually carry: searching while connecting, filtered when a censorship-suggesting blockage outlives the grace period, snowflake when that transport is in use, ready once Tor is usable, failed on a terminal outcome. The elapsed timer stays, because during the 40s+ directory phase it is the only element on screen that keeps moving. Tor readiness alone drives the ready pose. Requiring the key server as well held the mascot on "searching" for the whole gap between the two, measured at 17-24s on a Pixel 5, which is precisely the moment the user needs to see that something advanced. The narrative line still says the key server is being contacted, so the two facts stay distinguishable. Provenance of the five PNGs: generated with OpenAI GPT-Image through the Codex CLI, prompted from this repository's own Bull assets; no third-party artwork was used as a reference. They are placeholders that have not been through design review, and may need replacing with official Bull artwork before release.
1 parent e2049ca commit 62aab4e

8 files changed

Lines changed: 270 additions & 70 deletions

File tree

220 KB
Loading
227 KB
Loading
231 KB
Loading
248 KB
Loading
220 KB
Loading

lib/features/recoverbull/ui/pages/connecting_page.dart

Lines changed: 105 additions & 69 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import 'package:bb_mobile/features/recoverbull/presentation/bloc.dart';
88
import 'package:bb_mobile/features/recoverbull/presentation/recoverbull_failure_l10n.dart';
99
import 'package:bb_mobile/features/recoverbull/ui/pages/password_input_page.dart';
1010
import 'package:bb_mobile/features/recoverbull/ui/pages/vault_provider_selection_page.dart';
11+
import 'package:bb_mobile/features/recoverbull/ui/widgets/tor_bull_mascot.dart';
1112
import 'package:flutter/material.dart';
1213
import 'package:flutter_bloc/flutter_bloc.dart';
1314
import 'package:bull_ui/bull_ui.dart' show Gap;
@@ -22,14 +23,6 @@ import 'package:bull_tor/tor.dart' as tor;
2223
/// error and a Retry button on screen.
2324
const _blockageGrace = Duration(seconds: 5);
2425

25-
/// How long the progress bar survives without the fraction moving.
26-
///
27-
/// The fraction is 85% directory completeness, and the directory is cached on
28-
/// disk: with a warm cache and no connectivity at all it reads 0.85 from the
29-
/// first frame. A bar that only appears while the number is actually moving
30-
/// cannot make that claim.
31-
const _progressStale = Duration(seconds: 15);
32-
3326
/// When to start reassuring the user that the wait is normal.
3427
///
3528
/// The directory phase is 85% of the bootstrap budget and reports progress only
@@ -55,10 +48,6 @@ class _ConnectingPageState extends State<ConnectingPage> {
5548
/// When the current user-visible blockage first appeared, for [_blockageGrace].
5649
DateTime? _blockageSince;
5750

58-
/// When the bootstrap fraction last actually changed, for [_progressStale].
59-
DateTime? _fractionMovedAt;
60-
double? _lastFraction;
61-
6251
/// Whether this screen has already handed the flow to the next page.
6352
///
6453
/// Readiness is not a single event: Tor republishes `TorReady` on every
@@ -84,26 +73,20 @@ class _ConnectingPageState extends State<ConnectingPage> {
8473
}
8574

8675
void _onStateChanged(BuildContext context, RecoverBullState state) {
76+
if (_hasNavigated) return;
77+
8778
final connection = state.torConnection;
8879
final now = DateTime.now();
8980

90-
final fraction = switch (connection) {
91-
tor.TorConnecting(:final progress) => progress,
92-
_ => null,
93-
};
9481
final diagnostic = switch (connection) {
9582
tor.TorConnecting(:final diagnostic) => diagnostic,
9683
_ => null,
9784
};
9885

99-
// `build` reads these through `_progressIsLive` and `_blockageIsSettled`,
100-
// so they are widget state, not bookkeeping. Mutating them bare only
101-
// appeared to work because the one-second ticker rebuilt anyway.
86+
// `build` reads this through `_blockageIsSettled`, so it is widget state,
87+
// not bookkeeping. Mutating it bare only appeared to work because the
88+
// one-second ticker rebuilt anyway.
10289
setState(() {
103-
if (fraction != null && _lastFraction != fraction) {
104-
_lastFraction = fraction;
105-
_fractionMovedAt = now;
106-
}
10790
if (diagnostic == null) {
10891
_blockageSince = null;
10992
} else {
@@ -113,7 +96,6 @@ class _ConnectingPageState extends State<ConnectingPage> {
11396

11497
if (connection is tor.TorReady &&
11598
state.keyServerStatus == KeyServerStatus.online) {
116-
if (_hasNavigated) return;
11799
_hasNavigated = true;
118100
final hasPreSelectedVault = state.vault != null;
119101
final nextPage = switch (state.flow) {
@@ -136,12 +118,6 @@ class _ConnectingPageState extends State<ConnectingPage> {
136118
return since != null && DateTime.now().difference(since) >= _blockageGrace;
137119
}
138120

139-
/// Whether the fraction is moving, and so worth drawing as a bar.
140-
bool get _progressIsLive {
141-
final at = _fractionMovedAt;
142-
return at != null && DateTime.now().difference(at) < _progressStale;
143-
}
144-
145121
@override
146122
Widget build(BuildContext context) {
147123
return BlocListener<RecoverBullBloc, RecoverBullState>(
@@ -176,7 +152,6 @@ class _ConnectingPageState extends State<ConnectingPage> {
176152
state: state,
177153
elapsed: _elapsed,
178154
showBlockage: _blockageIsSettled,
179-
progressIsLive: _progressIsLive,
180155
),
181156
),
182157
),
@@ -196,13 +171,11 @@ class _Body extends StatelessWidget {
196171
final RecoverBullState state;
197172
final Duration elapsed;
198173
final bool showBlockage;
199-
final bool progressIsLive;
200174

201175
const _Body({
202176
required this.state,
203177
required this.elapsed,
204178
required this.showBlockage,
205-
required this.progressIsLive,
206179
});
207180

208181
tor.TorConnectionState get _tor => state.torConnection;
@@ -256,6 +229,46 @@ class _Body extends StatelessWidget {
256229
bool get _hasFailure =>
257230
_torPhase == _PhaseState.failed || _serverPhase == _PhaseState.failed;
258231

232+
TorBullState get _mascotState {
233+
if (_hasFailure) return TorBullState.failed;
234+
if (_tor is tor.TorReady) return TorBullState.ready;
235+
236+
final diagnostic = _diagnostic;
237+
if (diagnostic != null) {
238+
return diagnostic.suggestsCensorship
239+
? TorBullState.filtered
240+
: TorBullState.failed;
241+
}
242+
243+
return switch (_tor) {
244+
tor.TorConnecting(transport: tor.TorTransport.snowflake) =>
245+
TorBullState.snowflake,
246+
tor.TorUninitialized() || tor.TorStopped() => TorBullState.idle,
247+
tor.TorReady() ||
248+
tor.TorConnecting() ||
249+
tor.TorUnavailable() => TorBullState.direct,
250+
};
251+
}
252+
253+
String _connectionNarrative(BuildContext context) {
254+
if (_tor is tor.TorReady &&
255+
state.keyServerStatus != KeyServerStatus.online) {
256+
return context.loc.recoverbullConnectingTor;
257+
}
258+
259+
return switch (_mascotState) {
260+
TorBullState.direct => context.loc.torSettingsModeDirectDescription,
261+
TorBullState.filtered => [
262+
context.loc.torSettingsDescCensored,
263+
context.loc.torSettingsModeAutomaticDescription,
264+
].join(' '),
265+
TorBullState.snowflake => context.loc.torSettingsModeSnowflakeDescription,
266+
TorBullState.ready => context.loc.torSettingsDescConnected,
267+
TorBullState.failed => _failureMessage(context),
268+
TorBullState.idle => context.loc.recoverbullPleaseWait,
269+
};
270+
}
271+
259272
/// One message, chosen by what actually failed.
260273
///
261274
/// The wording stays hedged for the network diagnoses: upstream documents
@@ -297,12 +310,68 @@ class _Body extends StatelessWidget {
297310
mainAxisAlignment: .center,
298311
crossAxisAlignment: .stretch,
299312
children: [
313+
Center(
314+
child: TorBullMascot(
315+
state: _mascotState,
316+
semanticLabel: context.loc.recoverbullTorNetwork,
317+
),
318+
),
319+
const Gap(12),
300320
BBText(
301321
context.loc.recoverbullCheckingConnection,
302322
textAlign: .center,
303323
style: context.font.headlineLarge?.copyWith(fontWeight: .bold),
304324
),
305-
const Gap(32),
325+
const Gap(12),
326+
if (!_hasFailure)
327+
AnimatedSwitcher(
328+
duration: const Duration(milliseconds: 250),
329+
child: Container(
330+
key: ValueKey(_mascotState),
331+
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
332+
decoration: BoxDecoration(
333+
color: switch (_mascotState) {
334+
TorBullState.filtered => context.appColors.warningContainer,
335+
TorBullState.failed => context.appColors.errorContainer,
336+
_ => context.appColors.surface,
337+
},
338+
borderRadius: BorderRadius.circular(12),
339+
),
340+
child: Column(
341+
children: [
342+
if (_mascotState == TorBullState.filtered)
343+
BBText(
344+
context.loc.torSettingsStatusCensored,
345+
textAlign: .center,
346+
style: context.font.bodyLarge?.copyWith(
347+
color: context.appColors.warning,
348+
fontWeight: .w700,
349+
),
350+
),
351+
if (_mascotState == TorBullState.snowflake)
352+
BBText(
353+
context.loc.torSettingsActiveTransport(
354+
context.loc.torSettingsModeSnowflake,
355+
),
356+
textAlign: .center,
357+
style: context.font.bodyLarge?.copyWith(
358+
color: context.appColors.info,
359+
fontWeight: .w700,
360+
),
361+
),
362+
BBText(
363+
_connectionNarrative(context),
364+
textAlign: .center,
365+
style: context.font.bodySmall?.copyWith(
366+
color: context.appColors.textMuted,
367+
),
368+
maxLines: 4,
369+
),
370+
],
371+
),
372+
),
373+
),
374+
const Gap(20),
306375
_PhaseCard(
307376
label: context.loc.recoverbullTorNetwork,
308377
phase: _torPhase,
@@ -312,14 +381,6 @@ class _Body extends StatelessWidget {
312381
caption: _torPhase == _PhaseState.active
313382
? context.loc.recoverbullPhaseNetworkInfo
314383
: null,
315-
// Only drawn while the number is genuinely moving — a cached
316-
// directory reports 0.85 with no connectivity whatsoever.
317-
fraction: _torPhase == _PhaseState.active && progressIsLive
318-
? switch (_tor) {
319-
tor.TorConnecting(:final progress) => progress,
320-
_ => null,
321-
}
322-
: null,
323384
elapsed: _torPhase == _PhaseState.active ? elapsed : null,
324385
),
325386
const Gap(8),
@@ -370,7 +431,6 @@ class _PhaseCard extends StatelessWidget {
370431
final String label;
371432
final _PhaseState phase;
372433
final String? caption;
373-
final double? fraction;
374434
final Duration? elapsed;
375435

376436
/// An extra language-neutral token for the detail line, such as `2/3`.
@@ -380,7 +440,6 @@ class _PhaseCard extends StatelessWidget {
380440
required this.label,
381441
required this.phase,
382442
this.caption,
383-
this.fraction,
384443
this.elapsed,
385444
this.trailingDetail,
386445
});
@@ -432,10 +491,7 @@ class _PhaseCard extends StatelessWidget {
432491
),
433492
],
434493
),
435-
if (caption != null ||
436-
fraction != null ||
437-
elapsed != null ||
438-
trailingDetail != null) ...[
494+
if (caption != null || elapsed != null || trailingDetail != null) ...[
439495
const Gap(8),
440496
Padding(
441497
padding: const EdgeInsets.only(left: 32),
@@ -450,30 +506,10 @@ class _PhaseCard extends StatelessWidget {
450506
),
451507
maxLines: 2,
452508
),
453-
if (fraction != null) ...[
454-
const Gap(6),
455-
ClipRRect(
456-
borderRadius: BorderRadius.circular(4),
457-
child: LinearProgressIndicator(
458-
value: fraction!.clamp(0.0, 1.0),
459-
minHeight: 4,
460-
backgroundColor: context.appColors.onSurface.withValues(
461-
alpha: 0.1,
462-
),
463-
valueColor: AlwaysStoppedAnimation<Color>(color),
464-
),
465-
),
466-
],
467-
if (fraction != null ||
468-
elapsed != null ||
469-
trailingDetail != null) ...[
509+
if (elapsed != null || trailingDetail != null) ...[
470510
const Gap(6),
471511
BBText(
472512
[
473-
if (fraction != null)
474-
context.loc.torSettingsBootstrapProgress(
475-
(fraction! * 100).round(),
476-
),
477513
?trailingDetail,
478514
// A bare timer, deliberately unlocalised: no words to
479515
// translate, and it is the only element that keeps

0 commit comments

Comments
 (0)