@@ -8,6 +8,7 @@ import 'package:bb_mobile/features/recoverbull/presentation/bloc.dart';
88import 'package:bb_mobile/features/recoverbull/presentation/recoverbull_failure_l10n.dart' ;
99import 'package:bb_mobile/features/recoverbull/ui/pages/password_input_page.dart' ;
1010import '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' ;
1112import 'package:flutter/material.dart' ;
1213import 'package:flutter_bloc/flutter_bloc.dart' ;
1314import '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.
2324const _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