Skip to content

Commit 7ed189c

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 ce5819b commit 7ed189c

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
@@ -65,10 +58,6 @@ class _ConnectingPageState extends State<ConnectingPage> {
6558
/// When the current user-visible blockage first appeared, for [_blockageGrace].
6659
DateTime? _blockageSince;
6760

68-
/// When the bootstrap fraction last actually changed, for [_progressStale].
69-
DateTime? _fractionMovedAt;
70-
double? _lastFraction;
71-
7261
/// Whether this screen has already handed the flow to the next page.
7362
///
7463
/// Readiness is not a single event: Tor republishes `TorReady` on every
@@ -103,26 +92,20 @@ class _ConnectingPageState extends State<ConnectingPage> {
10392
}
10493

10594
void _onStateChanged(BuildContext context, RecoverBullState state) {
95+
if (_hasNavigated) return;
96+
10697
final connection = state.torConnection;
10798
final now = DateTime.now();
10899

109-
final fraction = switch (connection) {
110-
tor.TorConnecting(:final progress) => progress,
111-
_ => null,
112-
};
113100
final diagnostic = switch (connection) {
114101
tor.TorConnecting(:final diagnostic) => diagnostic,
115102
_ => null,
116103
};
117104

118-
// `build` reads these through `_progressIsLive` and `_blockageIsSettled`,
119-
// so they are widget state, not bookkeeping. Mutating them bare only
120-
// appeared to work because the one-second ticker rebuilt anyway.
105+
// `build` reads this through `_blockageIsSettled`, so it is widget state,
106+
// not bookkeeping. Mutating it bare only appeared to work because the
107+
// one-second ticker rebuilt anyway.
121108
setState(() {
122-
if (fraction != null && _lastFraction != fraction) {
123-
_lastFraction = fraction;
124-
_fractionMovedAt = now;
125-
}
126109
if (diagnostic == null) {
127110
_blockageSince = null;
128111
} else {
@@ -152,7 +135,6 @@ class _ConnectingPageState extends State<ConnectingPage> {
152135

153136
if (connection is tor.TorReady &&
154137
state.keyServerStatus == KeyServerStatus.online) {
155-
if (_hasNavigated) return;
156138
_hasNavigated = true;
157139
final hasPreSelectedVault = state.vault != null;
158140
final nextPage = switch (state.flow) {
@@ -175,12 +157,6 @@ class _ConnectingPageState extends State<ConnectingPage> {
175157
return since != null && DateTime.now().difference(since) >= _blockageGrace;
176158
}
177159

178-
/// Whether the fraction is moving, and so worth drawing as a bar.
179-
bool get _progressIsLive {
180-
final at = _fractionMovedAt;
181-
return at != null && DateTime.now().difference(at) < _progressStale;
182-
}
183-
184160
@override
185161
Widget build(BuildContext context) {
186162
return BlocListener<RecoverBullBloc, RecoverBullState>(
@@ -215,7 +191,6 @@ class _ConnectingPageState extends State<ConnectingPage> {
215191
state: state,
216192
elapsed: _elapsed,
217193
showBlockage: _blockageIsSettled,
218-
progressIsLive: _progressIsLive,
219194
),
220195
),
221196
),
@@ -235,13 +210,11 @@ class _Body extends StatelessWidget {
235210
final RecoverBullState state;
236211
final Duration elapsed;
237212
final bool showBlockage;
238-
final bool progressIsLive;
239213

240214
const _Body({
241215
required this.state,
242216
required this.elapsed,
243217
required this.showBlockage,
244-
required this.progressIsLive,
245218
});
246219

247220
tor.TorConnectionState get _tor => state.torConnection;
@@ -305,6 +278,46 @@ class _Body extends StatelessWidget {
305278
_serverPhase == _PhaseState.failed ||
306279
_diagnostic != null;
307280

281+
TorBullState get _mascotState {
282+
if (_hasFailure) return TorBullState.failed;
283+
if (_tor is tor.TorReady) return TorBullState.ready;
284+
285+
final diagnostic = _diagnostic;
286+
if (diagnostic != null) {
287+
return diagnostic.suggestsCensorship
288+
? TorBullState.filtered
289+
: TorBullState.failed;
290+
}
291+
292+
return switch (_tor) {
293+
tor.TorConnecting(transport: tor.TorTransport.snowflake) =>
294+
TorBullState.snowflake,
295+
tor.TorUninitialized() || tor.TorStopped() => TorBullState.idle,
296+
tor.TorReady() ||
297+
tor.TorConnecting() ||
298+
tor.TorUnavailable() => TorBullState.direct,
299+
};
300+
}
301+
302+
String _connectionNarrative(BuildContext context) {
303+
if (_tor is tor.TorReady &&
304+
state.keyServerStatus != KeyServerStatus.online) {
305+
return context.loc.recoverbullConnectingTor;
306+
}
307+
308+
return switch (_mascotState) {
309+
TorBullState.direct => context.loc.torSettingsModeDirectDescription,
310+
TorBullState.filtered => [
311+
context.loc.torSettingsDescCensored,
312+
context.loc.torSettingsModeAutomaticDescription,
313+
].join(' '),
314+
TorBullState.snowflake => context.loc.torSettingsModeSnowflakeDescription,
315+
TorBullState.ready => context.loc.torSettingsDescConnected,
316+
TorBullState.failed => _failureMessage(context),
317+
TorBullState.idle => context.loc.recoverbullPleaseWait,
318+
};
319+
}
320+
308321
/// One message, chosen by what actually failed.
309322
///
310323
/// The wording stays hedged for the network diagnoses: upstream documents
@@ -346,12 +359,68 @@ class _Body extends StatelessWidget {
346359
mainAxisAlignment: .center,
347360
crossAxisAlignment: .stretch,
348361
children: [
362+
Center(
363+
child: TorBullMascot(
364+
state: _mascotState,
365+
semanticLabel: context.loc.recoverbullTorNetwork,
366+
),
367+
),
368+
const Gap(12),
349369
BBText(
350370
context.loc.recoverbullCheckingConnection,
351371
textAlign: .center,
352372
style: context.font.headlineLarge?.copyWith(fontWeight: .bold),
353373
),
354-
const Gap(32),
374+
const Gap(12),
375+
if (!_hasFailure)
376+
AnimatedSwitcher(
377+
duration: const Duration(milliseconds: 250),
378+
child: Container(
379+
key: ValueKey(_mascotState),
380+
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
381+
decoration: BoxDecoration(
382+
color: switch (_mascotState) {
383+
TorBullState.filtered => context.appColors.warningContainer,
384+
TorBullState.failed => context.appColors.errorContainer,
385+
_ => context.appColors.surface,
386+
},
387+
borderRadius: BorderRadius.circular(12),
388+
),
389+
child: Column(
390+
children: [
391+
if (_mascotState == TorBullState.filtered)
392+
BBText(
393+
context.loc.torSettingsStatusCensored,
394+
textAlign: .center,
395+
style: context.font.bodyLarge?.copyWith(
396+
color: context.appColors.warning,
397+
fontWeight: .w700,
398+
),
399+
),
400+
if (_mascotState == TorBullState.snowflake)
401+
BBText(
402+
context.loc.torSettingsActiveTransport(
403+
context.loc.torSettingsModeSnowflake,
404+
),
405+
textAlign: .center,
406+
style: context.font.bodyLarge?.copyWith(
407+
color: context.appColors.info,
408+
fontWeight: .w700,
409+
),
410+
),
411+
BBText(
412+
_connectionNarrative(context),
413+
textAlign: .center,
414+
style: context.font.bodySmall?.copyWith(
415+
color: context.appColors.textMuted,
416+
),
417+
maxLines: 4,
418+
),
419+
],
420+
),
421+
),
422+
),
423+
const Gap(20),
355424
_PhaseCard(
356425
label: context.loc.recoverbullTorNetwork,
357426
phase: _torPhase,
@@ -361,14 +430,6 @@ class _Body extends StatelessWidget {
361430
caption: _torPhase == _PhaseState.active
362431
? context.loc.recoverbullPhaseNetworkInfo
363432
: null,
364-
// Only drawn while the number is genuinely moving — a cached
365-
// directory reports 0.85 with no connectivity whatsoever.
366-
fraction: _torPhase == _PhaseState.active && progressIsLive
367-
? switch (_tor) {
368-
tor.TorConnecting(:final progress) => progress,
369-
_ => null,
370-
}
371-
: null,
372433
elapsed: _torPhase == _PhaseState.active ? elapsed : null,
373434
),
374435
const Gap(8),
@@ -419,7 +480,6 @@ class _PhaseCard extends StatelessWidget {
419480
final String label;
420481
final _PhaseState phase;
421482
final String? caption;
422-
final double? fraction;
423483
final Duration? elapsed;
424484

425485
/// An extra language-neutral token for the detail line, such as `2/3`.
@@ -429,7 +489,6 @@ class _PhaseCard extends StatelessWidget {
429489
required this.label,
430490
required this.phase,
431491
this.caption,
432-
this.fraction,
433492
this.elapsed,
434493
this.trailingDetail,
435494
});
@@ -481,10 +540,7 @@ class _PhaseCard extends StatelessWidget {
481540
),
482541
],
483542
),
484-
if (caption != null ||
485-
fraction != null ||
486-
elapsed != null ||
487-
trailingDetail != null) ...[
543+
if (caption != null || elapsed != null || trailingDetail != null) ...[
488544
const Gap(8),
489545
Padding(
490546
padding: const EdgeInsets.only(left: 32),
@@ -499,30 +555,10 @@ class _PhaseCard extends StatelessWidget {
499555
),
500556
maxLines: 2,
501557
),
502-
if (fraction != null) ...[
503-
const Gap(6),
504-
ClipRRect(
505-
borderRadius: BorderRadius.circular(4),
506-
child: LinearProgressIndicator(
507-
value: fraction!.clamp(0.0, 1.0),
508-
minHeight: 4,
509-
backgroundColor: context.appColors.onSurface.withValues(
510-
alpha: 0.1,
511-
),
512-
valueColor: AlwaysStoppedAnimation<Color>(color),
513-
),
514-
),
515-
],
516-
if (fraction != null ||
517-
elapsed != null ||
518-
trailingDetail != null) ...[
558+
if (elapsed != null || trailingDetail != null) ...[
519559
const Gap(6),
520560
BBText(
521561
[
522-
if (fraction != null)
523-
context.loc.torSettingsBootstrapProgress(
524-
(fraction! * 100).round(),
525-
),
526562
?trailingDetail,
527563
// A bare timer, deliberately unlocalised: no words to
528564
// translate, and it is the only element that keeps

0 commit comments

Comments
 (0)