Unbounded as top-level tab on the home screen (Part 2/2)#8820
Unbounded as top-level tab on the home screen (Part 2/2)#8820myleshorton wants to merge 15 commits into
Conversation
9b35034 to
8fa541b
Compare
There was a problem hiding this comment.
Pull request overview
Part 2/2 of the Unbounded UX split: lifts Unbounded from the buried Share-My-Connection screen onto its own top-level Home tab, adds a Settings sub-page (auto-enable + hide-tab), a one-shot welcome dialog, a persisted lifetime "people helped" counter, and a transparent SmC→Unbounded fallback when peer.Client.Start errors. Gated end-to-end on the server-side Features[unbounded] flag.
Changes:
- Refactor Home into a two-tab shell (VPN + Unbounded) with status-dot tab labels; lift VPN body into
vpn_tab.dart. - Add
UnboundedSettingpage +appSettingProviderfields (unboundedAutoEnable,unboundedHidden,unboundedWelcomeSeen,unboundedTotalHelped) and wire auto-enable on app launch and VPN-connect. - Rework the share screen body into
UnboundedTabwith persisted total counter, "Waiting for connections…" idle pill, full-canvas Lottie heart-burst, info bubble + welcome dialog, and a_handlePeerStatus-driven SmC→Unbounded fallback path.
Reviewed changes
Copilot reviewed 9 out of 9 changed files in this pull request and generated 7 comments.
Show a summary per file
| File | Description |
|---|---|
| lib/features/share_my_connection/share_my_connection.dart | Renames screen to UnboundedTab, adds autoStart, persisted total, SmC→Unbounded fallback, welcome dialog, restyled arrival/idle toasts |
| lib/features/home/home.dart | Rewrites Home as a tab shell with auto-enable useEffect + VPN-connect listener and i18n-preserved telemetry dialog |
| lib/features/home/vpn_tab.dart | New file extracting the VPN tab body from old Home |
| lib/features/setting/setting.dart | Adds Unbounded Settings menu entry and gates the Unbounded promo card on the new feature flag |
| lib/features/setting/unbounded_setting.dart | New Settings sub-page exposing auto-enable + hide-tab toggles |
| lib/features/setting/vpn_setting.dart | Removes the in-VPN-settings "Share My Connection" tile (moved to tab) |
| lib/features/home/provider/app_setting_notifier.dart | Adds setters for the four new Unbounded preferences |
| lib/core/models/app_setting.dart | Adds persisted fields for Unbounded prefs and lifetime total |
| lib/core/models/feature_flags.dart | Adds unbounded server-side feature flag enum entry |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| useEffect(() { | ||
| WidgetsBinding.instance.addPostFrameCallback((_) { | ||
| if (!unboundedAvailable) return; | ||
| final appSetting = ref.read(appSettingProvider); | ||
| if (!appSetting.onboardingCompleted) return; | ||
| if (!appSetting.unboundedAutoEnable) return; | ||
| final share = ref.read(shareProvider); | ||
| if (share.active || share.probing) return; | ||
| ref.read(shareProvider.notifier).autoStart(ref); | ||
| }); | ||
| return null; | ||
| }, [unboundedAvailable]); | ||
|
|
||
| ref.listen<VPNStatus>(vpnProvider, (prev, next) { | ||
| if (prev == next) return; | ||
| if (next != VPNStatus.connected) return; | ||
| if (!unboundedAvailable) return; | ||
| final autoEnable = | ||
| ref.read(appSettingProvider).unboundedAutoEnable; | ||
| if (!autoEnable) return; | ||
| final share = ref.read(shareProvider); | ||
| if (share.active || share.probing) return; | ||
| // Defer to avoid mutating provider state inside the listen callback. | ||
| Future.microtask( | ||
| () => ref.read(shareProvider.notifier).autoStart(ref), | ||
| ); | ||
| }); |
There was a problem hiding this comment.
Re-analyzed and I think the inner guard in autoStart already handles this. Trace:
useEffect's postFrameCallback fires (or microtask, whichever first)
→ caller-side guard reads share (false, false) → passes
→ calls autoStart UNAWAITED (autoStart returns a Future)
→ autoStart's first statement: 'if (state.active || state.probing) return;' → passes
→ next statement (synchronous): 'state = state.copyWith(probing: true);'
→ first await ('_start' eventually awaits PatchSettings) → yields control
Now the OTHER scheduler fires:
→ caller-side guard reads share (false, TRUE now) → bails
→ never even calls autoStart
Dart isolates are single-threaded — there's no preemption between autoStart's guard and the probing=true set; they run synchronously in the same microtask hop. The only way both would pass is if there's a yield between them, and there isn't.
That said, the combined fix from #3321840455 (autoStart now does a single unconditional _start(unbounded) rather than mixing manual-port lookup + probe + mode selection) further shortens the synchronous prefix, so even hypothetical scheduling oddities have less room to interleave. Going to leave the home.dart caller-side guards as-is; happy to consolidate into a single helper in a follow-up if the dual-entry-point pattern grows.
| useEffect(() { | ||
| final seen = ref.read(appSettingProvider).unboundedWelcomeSeen; | ||
| if (!seen) { | ||
| WidgetsBinding.instance.addPostFrameCallback((_) { | ||
| if (!context.mounted) return; | ||
| showUnboundedWelcomeDialog(context, ref); | ||
| }); | ||
| } | ||
| return null; | ||
| }, const []); |
There was a problem hiding this comment.
Pushing back: the empty deps list is intentional, not an oversight.
The welcome dialog is a once-per-device first-visit primer (persisted via appSettingProvider.unboundedWelcomeSeen). The design contract is:
- First visit on a clean install → dialog auto-fires once.
- Re-openable anytime via the info-bubble icon (already wired).
- Sign-out + sign-in or settings reset → flag clears → next launch fires the dialog again on next Home mount.
What the dialog explicitly should NOT do: re-fire WITHIN the same Home lifetime when the flag is reset programmatically (e.g. user opens it via the info bubble, the whenComplete sets seen=true, but if they immediately reset settings via a debug menu, we don't want the dialog to pop again in the same session — that would be jarring).
The current shape (useEffect(..., const []) + read on mount) gives exactly that. Watching the provider would re-fire the dialog when the flag transitioned from true→false within the same lifetime, which isn't desired.
If a future design wants the dialog to re-fire on sign-out within the same session, the right place would be the sign-out handler itself (push a Navigator route or similar), not the welcome-dialog useEffect.
f7e65ef to
a190859
Compare
8fa541b to
ff6cce1
Compare
share_my_connection.dart:
- autoStart unconditionally starts ShareMode.unbounded, dropping
the manual-port and UPnP-probe branches. The auto path is the
low-friction Unbounded-only surface; SmC requires the explicit
disclosure dialog enforced by toggle(). Without this fix, a
user who'd configured a manual port (or got lucky on the mock
probe) would silently land in SmC mode without ever seeing the
disclosure, turning their device into a residential exit they
never agreed to.
- _fallbackToUnbounded: added a comment documenting the
intentional reuse of the prior _start's event subscription. The
invariant works because the error path stays inside the same
subscription; flipping state.mode keeps the same forwarder
pushing events for Unbounded. _stop is the only teardown path.
- Welcome dialog 'restrictors' → 'restrictions' typo; normalized
the surrounding paragraph to single-quote-delimited strings
(the inner curly quote in 'digital bridges' is retained — it's
intentional inside the prose).
- About-bubble tooltip moved through .i18n.
unbounded_setting.dart + setting.dart:
- New i18n keys added to en.po:
unbounded_settings_title
auto_enable_unbounded / auto_enable_unbounded_subtitle
hide_unbounded / hide_unbounded_subtitle
about_unbounded
All hardcoded English strings in the new screens replaced with
.i18n lookups, matching the existing pattern in vpn_setting.dart.
dart analyze clean on the touched files.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Restructures Home into a two-tab shell (VPN + Unbounded) per the Figma spec at figma.com/design/hNlyYToB5TnX9SDBFDYJTq?node-id=2403-19287 and tracking ticket getlantern/engineering#3455. Previously the peer-share UI sat behind a "Share My Connection" entry on the VPN settings screen that opened it as a modal; the spec elevates it to a peer of the VPN view. - New lib/features/home/vpn_tab.dart: VpnTab body lifted from the old Home (toggle, data usage, location, routing, split tunneling). Scaffold/AppBar moved up to the shell. - home.dart: Home becomes the tab shell. AppBar hosts the Lantern logo, settings menu, account/sign-in actions, plus a TabBar with green/grey-dot tab labels (green when feature enabled per spec). Onboarding, macOS sysext, and telemetry-consent init preserved inside the shell so launch behaviour is unchanged. - share_my_connection.dart: ShareMyConnectionScreen renamed to UnboundedTab, BaseScreen wrapper dropped (shell provides chrome). Description text updated to the spec's "Help others bypass censorship by securely sharing your connection." - Arrival toast copy updated to match the spec: "Helping a new person in <country>" while a peer is arriving, "Waiting for connections..." in the idle state (new _WaitingCard). - vpn_setting.dart: SmC modal entry removed — there is no longer a Share-My-Connection tile here. Unused peerProxy watch dropped. Followups (separate phases): Unbounded Settings sheet (Auto-enable + Hide Unbounded toggles), auto-enable on VPN connect, first-visit Welcome popup. Files/class names still say "share_my_connection" and "ShareNotifier" to keep this diff focused; rename to "unbounded" is a polish step at the end. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds the Unbounded Settings sheet from the Figma spec (figma.com/design/hNlyYToB5TnX9SDBFDYJTq?node-id=2403-19287), reached from the main Settings menu (between VPN Settings and Language). Two toggles: - Auto-enable Unbounded — defaults on, subtitle "Turn on automatically when Lantern is open". The actual auto-enable wiring (listening to vpnProvider and toggling peer-proxy) lands in phase 3. - Hide Unbounded — defaults off, subtitle "Removes Unbounded from the top of this screen". When on, the Home shell hides the Unbounded tab AND collapses the tab strip entirely (single-tab case), falling back to rendering VpnTab directly. State persistence via AppSetting: - unboundedAutoEnable (default true) - unboundedHidden (default false) - unboundedWelcomeSeen (default false) — added now, used in phase 4 All three round-trip via toJson/fromJson and the new setUnboundedAutoEnable / setUnboundedHidden / setUnboundedWelcomeSeen notifier methods. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
When the "Auto-enable Unbounded" toggle in Unbounded Settings is on (default per phase 2), Unbounded turns on automatically the moment the VPN reaches the connected state — per the Figma spec and ticket getlantern/engineering#3455 ("turns on automatically when Lantern connects"). - New ShareNotifier.autoStart(): public, programmatic entry point that mirrors the toggle() probe-then-start path but skips the disclosure dialog because the user has already opted in via settings. No-ops if already active or probing. - Home shell uses ref.listen<VPNStatus>(vpnProvider, ...) to detect the disconnected → connected transition. On match, reads the auto-enable flag and current share state, then calls autoStart in a microtask so we don't mutate provider state from inside the listen callback. Disconnect path is left alone — turning Unbounded off when the VPN drops would be surprising; the user can toggle it off manually. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds the "Welcome to Unbounded" first-visit explainer dialog per Figma (figma.com/design/hNlyYToB5TnX9SDBFDYJTq?node-id=2403-19287). Fires automatically the first time the user opens the Unbounded tab, then never again — gated on unboundedWelcomeSeen (added to AppSetting in phase 2). The info-bubble icon in the tab header re-opens the same dialog so users can revisit the explanation. - New showUnboundedWelcomeDialog(context, ref): wraps a Dialog with the spec's heart-Lantern logo (re-using _HeartPainter), title, three-paragraph explainer body, and Learn more + Got it buttons. Dismissal (either button or scrim tap) flips welcomeSeen true via whenComplete so a single completion path handles both. - UnboundedTab.useEffect runs once on mount, schedules the dialog in a post-frame callback when welcomeSeen is false. - Description text row now also hosts an Icons.info_outline button to the right that calls showUnboundedWelcomeDialog directly. "Learn more" link is a no-op stub for now — wiring it to the public Unbounded explainer URL is a tiny followup once the URL is decided. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The Unbounded Settings subtitle reads "Turn on automatically when Lantern is open" — which is app-launch, not VPN-connect. Phase 3 only handled the VPN-connect transition, so a user who launches the app and never connects the VPN would never see Unbounded auto-start despite the toggle being on. Adds a second entry point: a post-frame useEffect on Home mount that reads autoEnable + onboardingCompleted, and calls ShareNotifier.autoStart if conditions hold. The existing ref.listen<VPNStatus> path stays in place for the case where the toggle flipped on after launch or the user connects the VPN later. Both paths gate on (active || probing) to avoid re-triggering mid-flight and skip the disclosure dialog since settings opt-in is the consent gate. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
unbounded.lantern.io shows dozens of pink hearts spraying outward across the whole globe area on each arrival — not a single burst cramped inside the toast pill. Watching unbounded-russia.mp4 made it clear my previous implementation had the wrong scale: the Lottie was confined to a 40×40 slot inside the pill, so all the particle spray got clipped. Restructure: - New _LottieBurstLayer: a Positioned.fill overlay on top of the globe (sibling to _GlobeView, parent Stack now clipBehavior: Clip.none). Subscribes to ShareNotifier.connectionEvents and bumps a burstId counter on each non-replay state=1. The inner _BurstAnimation widget gets a fresh ValueKey per burst so the Lottie restarts from frame 0; the previous Lottie's AnimationController is disposed when the State unmounts. - _ArrivalCard simplified: replaces the embedded _HeartBurst with a static _HeartPainter heart, matching unbounded's pill chrome (small heart icon + text, no animation inside the pill). - _HeartBurst class removed. Result: the hearts now spread across the entire globe Stack area instead of being trapped inside a 40×40 box. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Previous approach made the Lottie a globe-wide Positioned.fill layer.
unbounded.lantern.io actually anchors the Lottie INSIDE the toast
pill's heart slot, with absolute-positioned negative offsets so it
overflows up and to the right into the globe area:
LottieContainer { position: relative; width: 32px; height: 27px; }
LottieWrapper { position: absolute; bottom: -55px; left: -105px;
width: 420px; }
Translating one-to-one in Flutter: the pill's heart slot is a Stack
with clipBehavior: Clip.none, containing the static _HeartPainter
centered + a Positioned _ArrivalLottie at bottom: -55, left: -105,
width: 420, height: 420. The pill Container itself also uses
clipBehavior: Clip.none so the Lottie can spill past the rounded
borders.
Side benefits:
- The burst now follows the pill — when AnimatedSwitcher swaps to a
new arrival card, the Lottie restarts naturally because each card
has its own _ArrivalLottie state (no need for the burstId counter
+ the standalone _LottieBurstLayer, both deleted).
- The burst origin is anchored at the pill's heart, so hearts spray
from a single, semantically-meaningful point instead of
centre-of-globe.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Compared the current implementation against frame-020.png of unbounded-russia.mp4: - The pill in unbounded is just [heart icon] + text, no flag emoji. Removed the flag prefix so the pill width stays manageable and the layout reads identically. flagEmoji is still on the event for future use (label above the arc, etc). - Anchor the pill at the bottom-LEFT of the globe area, not centered. Position changes from (left: 0, right: 0, child: Center(...)) to (left: 12, bottom: 8, child: ...). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Previous commit moved the pill to bottom-left, overshooting the fix for the cut-off text — the actual cause was the extra flag-emoji width, which is already removed. Restoring (left: 0, right: 0, child: Center(...)) so the pill sits under the globe's centre per frame-020 of unbounded-russia.mp4. Static heart in the pill stays visible (also matches unbounded) and continues to anchor the Lottie burst origin. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The stat was an in-memory counter that reset on every app launch and
on every off→on toggle. Spec wording ("Total people helped to date")
implies lifetime — survives both.
- AppSetting gains unboundedTotalHelped (int, default 0) + the
matching setUnboundedTotalHelped notifier method. Round-trips via
toJson/fromJson.
- ShareNotifier.build() seeds totalCount from the persisted value
instead of starting at 0.
- _start and _stop now preserve state.totalCount across toggle
cycles (were overwriting with ShareState() defaults).
- On each new-peer arrival, after incrementing totalCount, write the
new value via setUnboundedTotalHelped so the persisted value stays
in sync. SharedPreferences I/O is fine — peer arrivals are bursty,
not continuous.
- Stat labels updated to the Figma copy: "People helping right now"
(was "Active now") and "Total people helped to date" (was "Total
today" — which was inaccurate even before persistence).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
peer.Client.Start failures (UPnP miss, /v1/peer/register 404/4xx/5xx,
samizdat verify timeout) arrive in Dart as a peer-status FlutterEvent
with phase=error. Until now those rendered raw inside the SmC status
card ("Couldn't share: register with lantern-cloud: register: peer api:
status=404 body=404 page not found"), which is both ugly and inactionable.
Now `_handlePeerStatus` detects phase==error with mode==SmC and
transparently switches to Unbounded via setUnboundedEnabled(true).
The user's intent — "I want to share" — is honoured via broflake
regardless of SmC's outcome. UPnP failure is the common case; treating
it as a routine fallback rather than an error matches the design
expectation that UPnP works only some of the time.
State is rebuilt with ShareState() directly (rather than copyWith) so
errorMessage clears — copyWith's `?? this.errorMessage` would otherwise
keep the stale SmC failure string visible after the fallback.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Censored users should not see a "share your connection" UI on their device — it can be a red flag on-device evidence even when broflake itself is server-gated off. Mirror the radiance shouldRunUnbounded gate up into Flutter so the Unbounded tab, settings sub-page, project promo tile, first-visit welcome dialog, and auto-enable hooks all disappear when Features[unbounded] is false. Adds FeatureFlag.unbounded backed by the same "unbounded" key the server already emits (common/types.go UNBOUNDED). Default getBool(...) is false, so any user whose /v1/config-new response omits the flag (no connectivity, parse failure, censored region) sees the safe state: no Unbounded UI at all. The user's "Hide Unbounded tab" toggle (appSettingProvider unboundedHidden) still wins on top of this for non-censored users who want it hidden. The new effective predicate is unboundedAvailable && !unboundedHidden. The welcome dialog at share_my_connection.dart:572 and the info-bubble re-opener at :607 are both inside UnboundedTab.build, which never mounts when the tab is hidden, so no defensive code is needed there. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Lottie's explosion.json is 420×502; we were forcing it into a 420×420 Positioned with BoxFit.contain, which uniform-scaled the animation down by ~83% and lopped 82 px off the upward spread. End result: the hearts clustered tightly just above the pill instead of fanning out across the globe the way unbounded.lantern.io's CSS renders them (width:420 with height:auto preserves the native aspect ratio). Set height to 502 to match the native canvas exactly. Width and the bottom/left negative offsets stay the same — the bottom of the Lottie still anchors 55 px below the pill heart's bottom and 105 px left of its left edge. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The pill's static heart was sitting a touch close to the "H" in "Helping a new person in <country>". 4 px is the smallest visibly noticeable nudge — large enough to ease the crowding without making the pill feel padded. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
share_my_connection.dart:
- autoStart unconditionally starts ShareMode.unbounded, dropping
the manual-port and UPnP-probe branches. The auto path is the
low-friction Unbounded-only surface; SmC requires the explicit
disclosure dialog enforced by toggle(). Without this fix, a
user who'd configured a manual port (or got lucky on the mock
probe) would silently land in SmC mode without ever seeing the
disclosure, turning their device into a residential exit they
never agreed to.
- _fallbackToUnbounded: added a comment documenting the
intentional reuse of the prior _start's event subscription. The
invariant works because the error path stays inside the same
subscription; flipping state.mode keeps the same forwarder
pushing events for Unbounded. _stop is the only teardown path.
- Welcome dialog 'restrictors' → 'restrictions' typo; normalized
the surrounding paragraph to single-quote-delimited strings
(the inner curly quote in 'digital bridges' is retained — it's
intentional inside the prose).
- About-bubble tooltip moved through .i18n.
unbounded_setting.dart + setting.dart:
- New i18n keys added to en.po:
unbounded_settings_title
auto_enable_unbounded / auto_enable_unbounded_subtitle
hide_unbounded / hide_unbounded_subtitle
about_unbounded
All hardcoded English strings in the new screens replaced with
.i18n lookups, matching the existing pattern in vpn_setting.dart.
dart analyze clean on the touched files.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
03e78f5 to
1809538
Compare
Two Copilot fixes (+ a third pushback in the reply): 1. _resolveAndEmit awaits peerLookup. If the notifier is disposed during the await, _eventController.close() has already run; the subsequent _eventController.add would throw 'Bad state: Cannot add event after closing'. Added an isClosed check after the await before the identity check. 2. smc_stat_total_today msgstr read 'Total today' but the ShareState.totalCount is session-scoped (reset on every toggle-on, no day bucket, no persistence). Renamed to 'Total this session' so the label matches the implemented semantics. #8820's rebase later replaces this key with smc_stat_total_helped + 'Total people helped to date' alongside persistence via unboundedTotalHelped — until then the more honest 'session' wording matches reality. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Summary
Adds Unbounded as a top-level tab at the top of the home screen — its own surface alongside the VPN tab, distinct from the unified Share My Connection screen shipped in [Part 1].
Where the SmC screen is opt-in, settings-buried, explicit-disclosure (the user actively chooses to be a peer), the Unbounded tab is visible, auto-enabled, low-friction (Unbounded contributes by default once the server-side flag is on, with an easy hide-tab opt-out). The two surfaces share the same underlying broflake widget-proxy in radiance — this PR is purely about exposing it as a top-level surface and shaping the auto-enable behavior.
This is Part 2 of 2 of the original #8740 split, stacked on Part 1 ([8740-B]).
What's in this PR
Tab surface (Phases 1–4):
Platformcapability check so unsupported platforms skip it cleanly.Auto-enable refinement:
Visual polish:
Server gating:
Features[unbounded]flag. When false (the default for censored regions) the tab and all associated UI disappear — censored users should never see a "share your connection" surface that could draw on-device attention.SmC → Unbounded fallback:
peer.Client.Startfails for any reason (UPnP denied, port collision, lantern-cloud unreachable), the SmC screen auto-falls back to Unbounded mode. User sees a "trying Basic mode instead" notice rather than a hard error.Final SmC polish:
How this was sliced
Cherry-picked from the original
fisk/share-my-connection-ux(#8740) — chronological commits 22 through 35, stacked on top of [8740-B]'s 21 commits. Reviewers see the Unbounded-tab-specific diff only.Why both surfaces (tab + SmC screen)
The unified SmC screen is for users who want to think about being a peer; it makes the act explicit and gives them controls. The Unbounded tab is the low-friction, default-on path for everyone else — it's Unbounded contributing in the background with an easy way to turn it off. Different products serving different segments of the user base.
Reconciling the two surfaces is a follow-up product decision (does turning Unbounded off in one place turn it off in the other? does the SmC screen "Basic mode" pick stick across visits to the tab?). For MVP, both ship, and operations are independent.
Test plan
flutter analyzeclean.go build ./lantern-core/...clean.Features[unbounded]off → entire Unbounded UI surface disappears (tab, settings entry, auto-enable disabled).peer.Client.Startto fail (e.g., block UPnP) → SmC screen toggles to "trying Basic mode instead", broflake takes over.Dependencies
fisk/smc-unified-screen) — this PR's base.🤖 Generated with Claude Code