@@ -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,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