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