Skip to content

Commit bd66092

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 56388c7 commit bd66092

8 files changed

Lines changed: 299 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: 109 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,50 @@ class _Body extends StatelessWidget {
305278
_serverPhase == _PhaseState.failed ||
306279
_diagnostic != null;
307280

281+
TorBullState get _mascotState {
282+
final diagnostic = _diagnostic;
283+
if (diagnostic?.suggestsCensorship ?? false) {
284+
return TorBullState.filtered;
285+
}
286+
287+
if (_hasFailure) return TorBullState.failed;
288+
if (_tor is tor.TorReady) return TorBullState.ready;
289+
290+
if (diagnostic != null) {
291+
return diagnostic.suggestsCensorship
292+
? TorBullState.filtered
293+
: TorBullState.failed;
294+
}
295+
296+
return switch (_tor) {
297+
tor.TorConnecting(transport: tor.TorTransport.snowflake) =>
298+
TorBullState.snowflake,
299+
tor.TorUninitialized() || tor.TorStopped() => TorBullState.idle,
300+
tor.TorReady() ||
301+
tor.TorConnecting() ||
302+
tor.TorUnavailable() => TorBullState.direct,
303+
};
304+
}
305+
306+
String _connectionNarrative(BuildContext context) {
307+
if (_tor is tor.TorReady &&
308+
state.keyServerStatus != KeyServerStatus.online) {
309+
return context.loc.recoverbullConnectingTor;
310+
}
311+
312+
return switch (_mascotState) {
313+
TorBullState.direct => context.loc.torSettingsModeDirectDescription,
314+
TorBullState.filtered => [
315+
context.loc.torSettingsDescCensored,
316+
context.loc.torSettingsModeAutomaticDescription,
317+
].join(' '),
318+
TorBullState.snowflake => context.loc.torSettingsModeSnowflakeDescription,
319+
TorBullState.ready => context.loc.torSettingsDescConnected,
320+
TorBullState.failed => _failureMessage(context),
321+
TorBullState.idle => context.loc.recoverbullPleaseWait,
322+
};
323+
}
324+
308325
/// One message, chosen by what actually failed.
309326
///
310327
/// The wording stays hedged for the network diagnoses: upstream documents
@@ -346,12 +363,68 @@ class _Body extends StatelessWidget {
346363
mainAxisAlignment: .center,
347364
crossAxisAlignment: .stretch,
348365
children: [
366+
Center(
367+
child: TorBullMascot(
368+
state: _mascotState,
369+
semanticLabel: context.loc.recoverbullTorNetwork,
370+
),
371+
),
372+
const Gap(12),
349373
BBText(
350374
context.loc.recoverbullCheckingConnection,
351375
textAlign: .center,
352376
style: context.font.headlineLarge?.copyWith(fontWeight: .bold),
353377
),
354-
const Gap(32),
378+
const Gap(12),
379+
if (!_hasFailure)
380+
AnimatedSwitcher(
381+
duration: const Duration(milliseconds: 250),
382+
child: Container(
383+
key: ValueKey(_mascotState),
384+
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
385+
decoration: BoxDecoration(
386+
color: switch (_mascotState) {
387+
TorBullState.filtered => context.appColors.warningContainer,
388+
TorBullState.failed => context.appColors.errorContainer,
389+
_ => context.appColors.surface,
390+
},
391+
borderRadius: BorderRadius.circular(12),
392+
),
393+
child: Column(
394+
children: [
395+
if (_mascotState == TorBullState.filtered)
396+
BBText(
397+
context.loc.torSettingsStatusCensored,
398+
textAlign: .center,
399+
style: context.font.bodyLarge?.copyWith(
400+
color: context.appColors.warning,
401+
fontWeight: .w700,
402+
),
403+
),
404+
if (_mascotState == TorBullState.snowflake)
405+
BBText(
406+
context.loc.torSettingsActiveTransport(
407+
context.loc.torSettingsModeSnowflake,
408+
),
409+
textAlign: .center,
410+
style: context.font.bodyLarge?.copyWith(
411+
color: context.appColors.info,
412+
fontWeight: .w700,
413+
),
414+
),
415+
BBText(
416+
_connectionNarrative(context),
417+
textAlign: .center,
418+
style: context.font.bodySmall?.copyWith(
419+
color: context.appColors.textMuted,
420+
),
421+
maxLines: 4,
422+
),
423+
],
424+
),
425+
),
426+
),
427+
const Gap(20),
355428
_PhaseCard(
356429
label: context.loc.recoverbullTorNetwork,
357430
phase: _torPhase,
@@ -361,14 +434,6 @@ class _Body extends StatelessWidget {
361434
caption: _torPhase == _PhaseState.active
362435
? context.loc.recoverbullPhaseNetworkInfo
363436
: 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,
372437
elapsed: _torPhase == _PhaseState.active ? elapsed : null,
373438
),
374439
const Gap(8),
@@ -419,7 +484,6 @@ class _PhaseCard extends StatelessWidget {
419484
final String label;
420485
final _PhaseState phase;
421486
final String? caption;
422-
final double? fraction;
423487
final Duration? elapsed;
424488

425489
/// An extra language-neutral token for the detail line, such as `2/3`.
@@ -429,7 +493,6 @@ class _PhaseCard extends StatelessWidget {
429493
required this.label,
430494
required this.phase,
431495
this.caption,
432-
this.fraction,
433496
this.elapsed,
434497
this.trailingDetail,
435498
});
@@ -481,10 +544,7 @@ class _PhaseCard extends StatelessWidget {
481544
),
482545
],
483546
),
484-
if (caption != null ||
485-
fraction != null ||
486-
elapsed != null ||
487-
trailingDetail != null) ...[
547+
if (caption != null || elapsed != null || trailingDetail != null) ...[
488548
const Gap(8),
489549
Padding(
490550
padding: const EdgeInsets.only(left: 32),
@@ -499,30 +559,10 @@ class _PhaseCard extends StatelessWidget {
499559
),
500560
maxLines: 2,
501561
),
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) ...[
562+
if (elapsed != null || trailingDetail != null) ...[
519563
const Gap(6),
520564
BBText(
521565
[
522-
if (fraction != null)
523-
context.loc.torSettingsBootstrapProgress(
524-
(fraction! * 100).round(),
525-
),
526566
?trailingDetail,
527567
// A bare timer, deliberately unlocalised: no words to
528568
// translate, and it is the only element that keeps

0 commit comments

Comments
 (0)