From 86879e6dd17b1bb94db3921eb9898c50f0b20fd8 Mon Sep 17 00:00:00 2001 From: Adam Fisk Date: Tue, 12 May 2026 12:07:10 -0600 Subject: [PATCH 01/26] =?UTF-8?q?unbounded:=20phase=201=20=E2=80=94=20tab?= =?UTF-8?q?=20shell=20+=20Unbounded=20as=20a=20top-level=20tab?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 " 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) --- assets/locales/en.po | 12 +- lib/features/home/home.dart | 382 +++++++----------- lib/features/home/vpn_tab.dart | 136 +++++++ lib/features/setting/vpn_setting.dart | 48 +-- .../share_my_connection.dart | 71 +++- 5 files changed, 340 insertions(+), 309 deletions(-) create mode 100644 lib/features/home/vpn_tab.dart diff --git a/assets/locales/en.po b/assets/locales/en.po index 792c034b96..bdf7fd7bee 100644 --- a/assets/locales/en.po +++ b/assets/locales/en.po @@ -560,9 +560,11 @@ msgstr "Let other Lantern users route through your connection to bypass censorsh msgid "share_my_connection_on_tap_to_view" msgstr "On — tap to view" -# Share My Connection screen — body / hero copy +# Unbounded tab — hero copy. Short variant for the tab embed; the +# longer privacy explanation now lives in the welcome dialog +# (showUnboundedWelcomeDialog) so the tab body stays scannable. msgid "smc_intro" -msgstr "Help others bypass censorship by sharing a small portion of your home internet connection. While sharing is on, traffic from users in censored regions will egress through your IP." +msgstr "Help others bypass censorship by securely sharing your connection." # Status card — phase labels msgid "smc_status_label" @@ -617,9 +619,11 @@ msgstr "Total this session" msgid "smc_connections_tooltip" msgstr "Most connections are short liveness probes — Lantern clients periodically check that this peer is reachable before sending real traffic. A quick burst from many locations is normal; an arc that lingers represents an actual user session." -# Arrival toast — "New connection from {country}" +# Arrival toast surfaced when a censored user starts routing through +# this peer. The "Helping a new person" framing is intentional — +# emphasizes the user-impact framing the SmC tab leans into. msgid "smc_arrival_toast" -msgstr "New connection from %s" +msgstr "Helping a new person in %s" # Advanced section / manual port forward msgid "smc_advanced" diff --git a/lib/features/home/home.dart b/lib/features/home/home.dart index 961deb9022..04136d2ab1 100644 --- a/lib/features/home/home.dart +++ b/lib/features/home/home.dart @@ -1,96 +1,87 @@ import 'package:auto_route/auto_route.dart'; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:lantern/core/common/app_text_styles.dart'; import 'package:lantern/core/extensions/user_data.dart'; import 'package:lantern/core/models/feature_flags.dart'; import 'package:lantern/core/utils/pro_utils.dart'; -import 'package:lantern/core/widgets/info_row.dart'; -import 'package:lantern/core/widgets/setting_tile.dart'; import 'package:lantern/features/home/provider/app_event_notifier.dart'; import 'package:lantern/features/home/provider/app_setting_notifier.dart'; import 'package:lantern/features/home/provider/feature_flag_notifier.dart'; import 'package:lantern/features/home/provider/home_notifier.dart'; import 'package:lantern/features/home/provider/radiance_settings_providers.dart'; -import 'package:lantern/features/vpn/location_setting.dart'; +import 'package:lantern/features/home/vpn_tab.dart'; +import 'package:lantern/features/share_my_connection/share_my_connection.dart'; import 'package:lantern/features/vpn/provider/available_servers_notifier.dart'; -import 'package:lantern/features/vpn/provider/server_location_notifier.dart'; -import 'package:lantern/features/vpn/vpn_status.dart'; -import 'package:lantern/features/vpn/vpn_switch.dart'; +import 'package:lantern/features/vpn/provider/vpn_notifier.dart'; import '../../core/common/common.dart'; -enum _SettingTileType { smartLocation, splitTunneling, smartRouting } - +/// Root tab shell hosting the VPN and Unbounded tabs. Tab strip lives in +/// the AppBar so the chrome (Lantern logo + settings menu + account +/// actions) is shared across tabs and lines up with the Figma spec at +/// figma.com/design/hNlyYToB5TnX9SDBFDYJTq?node-id=2403-19287. +/// +/// Tab labels carry a small dot indicator that turns green when the +/// matching feature is active (VPN: connected; Unbounded: peer share +/// on) and grey otherwise — also per spec. @RoutePage(name: 'Home') -class Home extends StatefulHookConsumerWidget { +class Home extends HookConsumerWidget { const Home({super.key}); @override - ConsumerState createState() => _HomeState(); -} - -class _HomeState extends ConsumerState { - TextTheme? textTheme; - - @override - void initState() { - super.initState(); - - WidgetsBinding.instance.addPostFrameCallback((_) { - /// Kick off the server fetch as soon as Home mounts so the Smart - /// Location tile can reflect the fastest server without waiting for - /// the user to open the server-selection screen. - ref.read(availableServersProvider); - - final appSetting = ref.read(appSettingProvider); - final appSettingNotifier = ref.read(appSettingProvider.notifier); - if (!appSetting.onboardingCompleted) { - appLogger.info( - "User has not completed onboarding, navigating to Onboarding Screen", - ); - appRouter.push(const Onboarding()); - return; - } - - if (PlatformUtils.isMacOS) { - /// Show macOS system extension dialog if needed - appLogger.info( - "App Setting - showSplashScreen: ${appSetting.showSplashScreen}", - ); - if (appSetting.showSplashScreen) { - appLogger.info("Showing System Extension Dialog"); - appRouter.push(const MacOSExtensionDialog()); - //User has seen dialog, do not show again - appLogger.info("Setting showSplashScreen to false"); - appSettingNotifier.setSplashScreen(false); - return; - } - } - }); - } - - @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { + final tabController = useTabController(initialLength: 2); final isUserPro = ref.watch(isUserProProvider); - final featureFlag = ref.watch(featureFlagProvider); final userLoggedIn = ref.watch( appSettingProvider.select((s) => s.userLoggedIn), ); + final featureFlag = ref.watch(featureFlagProvider); + final vpnStatus = ref.watch(vpnProvider); + final shareActive = ref.watch(shareProvider.select((s) => s.active)); + + // First-frame side effects: kick off server fetch, gate onboarding, + // macOS sysext dialog. Lifted unchanged from the old Home body so + // app-launch behaviour stays the same after the tab refactor. + useEffect(() { + WidgetsBinding.instance.addPostFrameCallback((_) { + ref.read(availableServersProvider); + final appSetting = ref.read(appSettingProvider); + final appSettingNotifier = ref.read(appSettingProvider.notifier); + if (!appSetting.onboardingCompleted) { + appLogger.info( + "User has not completed onboarding, navigating to Onboarding Screen", + ); + appRouter.push(const Onboarding()); + return; + } + if (PlatformUtils.isMacOS) { + appLogger.info( + "App Setting - showSplashScreen: ${appSetting.showSplashScreen}", + ); + if (appSetting.showSplashScreen) { + appLogger.info("Showing System Extension Dialog"); + appRouter.push(const MacOSExtensionDialog()); + appLogger.info("Setting showSplashScreen to false"); + appSettingNotifier.setSplashScreen(false); + } + } + }); + return null; + }, const []); + + // Telemetry consent dialog — fires once per app session after the + // first successful connection, gated on the metrics + traces + // feature flags. Preserved from the old Home behaviour. useEffect(() { final appSetting = ref.read(appSettingProvider); if (appSetting.successfulConnection) { - appLogger.info( - "User has successfully connected, checking if need to show Help Lantern Dialog or not", - ); if (!appSetting.telemetryDialogDismissed && (featureFlag.getBool(FeatureFlag.metrics) && featureFlag.getBool(FeatureFlag.traces))) { - appLogger.info("Showing Help Lantern Dialog"); WidgetsBinding.instance.addPostFrameCallback((_) { - showHelpLanternDialog(); + _showHelpLanternDialog(context, ref); ref.read(appSettingProvider.notifier).setShowTelemetryDialog(true); }); } @@ -98,21 +89,15 @@ class _HomeState extends ConsumerState { return null; }, [featureFlag]); - textTheme = Theme.of(context).textTheme; ref.read(appEventProvider); + return Scaffold( key: const Key('home.screen'), appBar: AppBar( title: LanternLogo(isPro: isUserPro, color: context.textPrimary), - bottom: PreferredSize( - preferredSize: Size.fromHeight(0), - child: DividerSpace(padding: EdgeInsets.zero), - ), elevation: 5, leading: IconButton( - onPressed: () { - appRouter.push(Setting()); - }, + onPressed: () => appRouter.push(Setting()), icon: const AppImage(path: AppImagePaths.menu), ), actions: [ @@ -125,209 +110,120 @@ class _HomeState extends ConsumerState { final email = localUser!.legacyUserData.email; final isPro = localUser.legacyUserData.isPro; if (isPro && !userSignedIn) { - // this means user has pro account but not signed in await showProAccountFlowDialog( context: context, hasEmail: email.isNotEmpty, ); return; } - appRouter.push(Account()); }, ) else if (!userLoggedIn) AppTextButton( label: 'sign_in'.i18n, - onPressed: () { - appRouter.push(const SignInEmail()); - }, + onPressed: () => appRouter.push(const SignInEmail()), ), ], + bottom: TabBar( + controller: tabController, + tabs: [ + _TabLabel(label: 'VPN', active: vpnStatus == VPNStatus.connected), + _TabLabel(label: 'Unbounded', active: shareActive), + ], + ), ), - body: SafeArea(child: _buildBody(ref, isUserPro)), - ); - } - - Widget _buildBody(WidgetRef ref, bool isUserPro) { - final serverLocation = ref.watch(serverLocationProvider); - - final serverType = serverLocation.serverType.toServerLocationType; - - return Padding( - padding: EdgeInsets.symmetric(horizontal: defaultSize), - child: Column( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - if (isUserPro) SizedBox(height: 0) else ProBanner(), - VPNSwitch(), - Column( - mainAxisSize: MainAxisSize.min, - children: [ - if (!isUserPro) ...{ - if (serverType == ServerLocationType.privateServer) - InfoRow(text: 'private_server_usage_message'.i18n) - else if (PlatformUtils.isIOS) - const SizedBox.shrink() - else - const DataUsage(), - }, - SizedBox(height: 8), - _buildSetting(ref), - SizedBox(height: 10.h), - ], - ), + body: TabBarView( + controller: tabController, + children: const [ + VpnTab(), + UnboundedTab(), ], ), ); } +} - Widget _buildSetting(WidgetRef ref) { - final routingMode = ref.watch( - radianceSettingsProvider.select((s) => s.routingMode), - ); - final isSplitTunnelingOn = ref.watch( - radianceSettingsProvider.select((s) => s.splitTunneling), - ); +/// Tab label with the green/grey status dot from the Figma spec. +class _TabLabel extends StatelessWidget { + const _TabLabel({required this.label, required this.active}); - return Container( - decoration: BoxDecoration( - boxShadow: [ - BoxShadow( - color: AppColors.shadowColor, - blurRadius: 32, - offset: Offset(0, 4), - spreadRadius: 0, + final String label; + final bool active; + + @override + Widget build(BuildContext context) { + return Tab( + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 8, + height: 8, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: active ? AppColors.green4 : context.textDisabled, + ), ), + const SizedBox(width: 6), + Text(label), ], ), - child: Card( - elevation: 0, - margin: EdgeInsets.zero, - child: Column( - children: [ - VpnStatus(), - DividerSpace(), - LocationSetting(), - if (!PlatformUtils.isIOS) ...{ - DividerSpace(), - SettingTile( - label: 'routing_mode'.i18n, - icon: AppImagePaths.route, - value: routingMode.label(), - actions: [ - IconButton( - onPressed: null, - style: ElevatedButton.styleFrom( - tapTargetSize: MaterialTapTargetSize.shrinkWrap, - ), - icon: AppImage(path: AppImagePaths.arrowForward), - padding: EdgeInsets.zero, - constraints: BoxConstraints(), - visualDensity: VisualDensity.compact, - ), - ], - onTap: () => onSettingTileTap(_SettingTileType.smartRouting), - ), - }, - if (PlatformUtils.isAndroid || - PlatformUtils.isMacOS || - PlatformUtils.isWindows) ...{ - DividerSpace(), - SettingTile( - label: 'split_tunneling'.i18n, - icon: AppImagePaths.callSpilt, - value: isSplitTunnelingOn ? 'enabled'.i18n : 'disabled'.i18n, - actions: [ - IconButton( - onPressed: null, - style: ElevatedButton.styleFrom( - tapTargetSize: MaterialTapTargetSize.shrinkWrap, - ), - icon: AppImage(path: AppImagePaths.arrowForward), - padding: EdgeInsets.zero, - constraints: BoxConstraints(), - visualDensity: VisualDensity.compact, - ), - ], - onTap: () => onSettingTileTap(_SettingTileType.splitTunneling), - ), - }, - ], - ), - ), ); } +} - void onSettingTileTap(_SettingTileType tileType) { - switch (tileType) { - case _SettingTileType.smartLocation: - appRouter.push(const ServerSelection()); - break; - case _SettingTileType.splitTunneling: - appRouter.push(const SplitTunneling()); - break; - case _SettingTileType.smartRouting: - appRouter.push(const SmartRouting()); - } - } - - void showHelpLanternDialog() { - AppDialog.customDialog( - context: context, - content: Column( - mainAxisSize: MainAxisSize.min, - children: [ - SizedBox(height: 24), - AppImage(path: AppImagePaths.assessment), - SizedBox(height: 24), - Text( - 'help_improve_lantern'.i18n, - style: textTheme!.headlineSmall!.copyWith( - color: context.textPrimary, - ), - ), - SizedBox(height: defaultSize), - Text( - 'share_anonymous_usage_data'.i18n, - style: textTheme!.bodyMedium!.copyWith( - color: context.textSecondary, - ), - ), - SizedBox(height: defaultSize), - Text( - 'data_we_collect'.i18n, - style: AppTextStyles.bodyMediumBold.copyWith( - color: context.textSecondary, - ), - ), - SizedBox(height: defaultSize), - Text( - 'you_can_change_anytime'.i18n, - style: textTheme!.bodyMedium!.copyWith( - color: context.textSecondary, - ), +void _showHelpLanternDialog(BuildContext context, WidgetRef ref) { + final textTheme = Theme.of(context).textTheme; + AppDialog.customDialog( + context: context, + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const SizedBox(height: 24), + const AppImage(path: AppImagePaths.assessment), + const SizedBox(height: 24), + Text( + 'help_improve_lantern'.i18n, + style: textTheme.headlineSmall!.copyWith(color: context.textPrimary), + ), + SizedBox(height: defaultSize), + Text( + 'share_anonymous_usage_data'.i18n, + style: textTheme.bodyMedium!.copyWith(color: context.textSecondary), + ), + SizedBox(height: defaultSize), + Text( + 'data_we_collect'.i18n, + style: AppTextStyles.bodyMediumBold.copyWith( + color: context.textSecondary, ), - ], - ), - action: [ - AppTextButton( - label: 'dont_allow'.i18n, - textColor: context.textDisabled, - onPressed: () { - context.pop(); - ref.read(radianceSettingsProvider.notifier).setTelemetry(false); - }, ), - AppTextButton( - label: 'allow'.i18n, - textColor: AppColors.blue6, - onPressed: () { - context.pop(); - ref.read(radianceSettingsProvider.notifier).setTelemetry(true); - }, + SizedBox(height: defaultSize), + Text( + 'you_can_change_anytime'.i18n, + style: textTheme.bodyMedium!.copyWith(color: context.textSecondary), ), ], - ); - } + ), + action: [ + AppTextButton( + label: 'dont_allow'.i18n, + textColor: context.textDisabled, + onPressed: () { + context.pop(); + ref.read(radianceSettingsProvider.notifier).setTelemetry(false); + }, + ), + AppTextButton( + label: 'allow'.i18n, + textColor: AppColors.blue6, + onPressed: () { + context.pop(); + ref.read(radianceSettingsProvider.notifier).setTelemetry(true); + }, + ), + ], + ); } diff --git a/lib/features/home/vpn_tab.dart b/lib/features/home/vpn_tab.dart new file mode 100644 index 0000000000..68302d04e1 --- /dev/null +++ b/lib/features/home/vpn_tab.dart @@ -0,0 +1,136 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:lantern/core/widgets/info_row.dart'; +import 'package:lantern/core/widgets/setting_tile.dart'; +import 'package:lantern/features/home/provider/radiance_settings_providers.dart'; +import 'package:lantern/features/vpn/location_setting.dart'; +import 'package:lantern/features/vpn/provider/server_location_notifier.dart'; +import 'package:lantern/features/vpn/vpn_status.dart'; +import 'package:lantern/features/vpn/vpn_switch.dart'; + +import '../../core/common/common.dart'; + +/// VPN tab body — the connect toggle, data usage, location, routing and +/// split-tunnel rows. Originally the body of the Home screen; lifted out +/// when Home was refactored into a two-tab shell (VPN + Unbounded). No +/// Scaffold or AppBar — the shell provides that chrome. +class VpnTab extends ConsumerWidget { + const VpnTab({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final isUserPro = ref.watch(isUserProProvider); + final serverLocation = ref.watch(serverLocationProvider); + final serverType = serverLocation.serverType.toServerLocationType; + + return SafeArea( + child: Padding( + padding: EdgeInsets.symmetric(horizontal: defaultSize), + child: Column( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + if (isUserPro) const SizedBox(height: 0) else const ProBanner(), + const VPNSwitch(), + Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (!isUserPro) ...{ + if (serverType == ServerLocationType.privateServer) + InfoRow(text: 'private_server_usage_message'.i18n) + else if (PlatformUtils.isIOS) + const SizedBox.shrink() + else + const DataUsage(), + }, + const SizedBox(height: 8), + _SettingCard(), + SizedBox(height: 10.h), + ], + ), + ], + ), + ), + ); + } +} + +class _SettingCard extends ConsumerWidget { + @override + Widget build(BuildContext context, WidgetRef ref) { + final routingMode = ref.watch( + radianceSettingsProvider.select((s) => s.routingMode), + ); + final isSplitTunnelingOn = ref.watch( + radianceSettingsProvider.select((s) => s.splitTunneling), + ); + + return Container( + decoration: const BoxDecoration( + boxShadow: [ + BoxShadow( + color: AppColors.shadowColor, + blurRadius: 32, + offset: Offset(0, 4), + spreadRadius: 0, + ), + ], + ), + child: Card( + elevation: 0, + margin: EdgeInsets.zero, + child: Column( + children: [ + const VpnStatus(), + const DividerSpace(), + const LocationSetting(), + if (!PlatformUtils.isIOS) ...{ + const DividerSpace(), + SettingTile( + label: 'routing_mode'.i18n, + icon: AppImagePaths.route, + value: routingMode.label(), + actions: [ + IconButton( + onPressed: null, + style: ElevatedButton.styleFrom( + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + icon: const AppImage(path: AppImagePaths.arrowForward), + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + visualDensity: VisualDensity.compact, + ), + ], + onTap: () => appRouter.push(const SmartRouting()), + ), + }, + if (PlatformUtils.isAndroid || + PlatformUtils.isMacOS || + PlatformUtils.isWindows) ...{ + const DividerSpace(), + SettingTile( + label: 'split_tunneling'.i18n, + icon: AppImagePaths.callSpilt, + value: isSplitTunnelingOn ? 'enabled'.i18n : 'disabled'.i18n, + actions: [ + IconButton( + onPressed: null, + style: ElevatedButton.styleFrom( + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + icon: const AppImage(path: AppImagePaths.arrowForward), + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + visualDensity: VisualDensity.compact, + ), + ], + onTap: () => appRouter.push(const SplitTunneling()), + ), + }, + ], + ), + ), + ); + } +} diff --git a/lib/features/setting/vpn_setting.dart b/lib/features/setting/vpn_setting.dart index 0071fe5c53..057efc9794 100644 --- a/lib/features/setting/vpn_setting.dart +++ b/lib/features/setting/vpn_setting.dart @@ -6,7 +6,6 @@ import 'package:lantern/core/common/common.dart'; import 'package:lantern/core/widgets/split_tunneling_tile.dart'; import 'package:lantern/core/widgets/switch_button.dart'; import 'package:lantern/features/home/provider/radiance_settings_providers.dart'; -import 'package:lantern/features/share_my_connection/share_my_connection.dart'; @RoutePage(name: 'VPNSetting') class VPNSetting extends HookConsumerWidget { @@ -36,19 +35,6 @@ class VPNSetting extends HookConsumerWidget { final telemetryConsent = ref.watch( radianceSettingsProvider.select((s) => s.telemetry), ); - final peerProxy = ref.watch( - radianceSettingsProvider.select((s) => s.peerProxy), - ); - final unboundedEnabled = ref.watch( - radianceSettingsProvider.select((s) => s.unboundedEnabled), - ); - // The tile reads "On" when EITHER donor protocol is active — - // the disclosure dialog flips peerProxy for "Full mode" and - // unboundedEnabled for "Basic mode", and the user shouldn't - // see a stale "Off" subtitle just because they picked the - // lower-friction Unbounded path. - final shareActive = peerProxy || unboundedEnabled; - return ListView( padding: const EdgeInsets.all(0), shrinkWrap: true, @@ -130,36 +116,10 @@ class VPNSetting extends HookConsumerWidget { }, ), ), - if (PlatformUtils.isDesktop) ...[ - SizedBox(height: 16), - AppCard( - padding: EdgeInsets.zero, - child: AppTile( - label: 'share_my_connection'.i18n, - subtitle: Text( - shareActive - ? 'share_my_connection_on_tap_to_view'.i18n - : 'share_my_connection_subtitle'.i18n, - style: textTheme.labelMedium!.copyWith( - color: context.textTertiary, - letterSpacing: 0.0, - ), - ), - icon: AppImagePaths.share, - trailing: AppImage( - path: AppImagePaths.arrowForward, - height: 20, - ), - onPressed: () { - Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => const ShareMyConnectionScreen(), - ), - ); - }, - ), - ), - ], + // The "Share My Connection" entry that used to push a SmC + // screen from here moved to a top-level Unbounded tab in the + // Home shell — see lib/features/home/home.dart. Toggling peer + // share now happens inside that tab. SizedBox(height: 16), AppCard( padding: EdgeInsets.zero, diff --git a/lib/features/share_my_connection/share_my_connection.dart b/lib/features/share_my_connection/share_my_connection.dart index ed9d7f2cb0..3e2b42ce23 100644 --- a/lib/features/share_my_connection/share_my_connection.dart +++ b/lib/features/share_my_connection/share_my_connection.dart @@ -616,10 +616,14 @@ class ShareNotifier extends Notifier { final shareProvider = NotifierProvider(ShareNotifier.new); -// ─── Screen ────────────────────────────────────────────────────────────────── +// ─── Tab body ──────────────────────────────────────────────────────────────── -class ShareMyConnectionScreen extends HookConsumerWidget { - const ShareMyConnectionScreen({super.key}); +/// Unbounded tab content, rendered inside the Home tab shell (see +/// home.dart). Hosts the description text, globe + arrival toast, the +/// status card with the toggle, and the advanced section. No Scaffold +/// or AppBar — the shell provides the chrome and the tab strip. +class UnboundedTab extends HookConsumerWidget { + const UnboundedTab({super.key}); @override Widget build(BuildContext context, WidgetRef ref) { @@ -627,9 +631,8 @@ class ShareMyConnectionScreen extends HookConsumerWidget { final notifier = ref.read(shareProvider.notifier); final textTheme = Theme.of(context).textTheme; - return BaseScreen( - title: 'share_my_connection'.i18n, - body: Padding( + return SafeArea( + child: Padding( padding: const EdgeInsets.symmetric(horizontal: 16), child: Column( children: [ @@ -644,11 +647,11 @@ class ShareMyConnectionScreen extends HookConsumerWidget { child: Stack( children: [ Positioned.fill(child: _GlobeView()), - // Floating "new connection from X" toast — overlays the - // bottom of the globe area rather than the peer's exact - // location on the sphere. Anchoring to projected coords - // forced the burst to repaint every globe rotation - // frame, which made the rotation jittery. + // Floating arrival toast — overlays the bottom of the + // globe area rather than the peer's exact location on + // the sphere. Anchoring to projected coords forced the + // burst to repaint every globe rotation frame, which + // made the rotation jittery. const Positioned( left: 0, right: 0, @@ -1036,12 +1039,12 @@ class _GlobeViewState extends ConsumerState<_GlobeView> { // ─── Arrival toast ─────────────────────────────────────────────────────────── -/// Floating notification overlay shown under the globe when a new peer -/// arrives. Mirrors the unbounded.lantern.io notification pattern: -/// heart-burst on the left, `New connection from ` text on -/// the right. Slides up + fades in, auto-hides after ~3.5s. Listens -/// directly to ShareNotifier.connectionEvents so we don't depend on -/// the globe widget for triggering. +/// Floating notification overlay shown under the globe. Mirrors the +/// unbounded.lantern.io notification pattern: heart-burst on the left, +/// `Helping a new person in ` text on the right while a peer +/// is connecting. When no peer has arrived recently, falls back to +/// `Waiting for connections...` (no heart) per the Figma spec. Slides +/// up + fades in, auto-hides connection arrivals after ~3.5s. class _ArrivalToast extends ConsumerStatefulWidget { const _ArrivalToast(); @@ -1097,7 +1100,10 @@ class _ArrivalToastState extends ConsumerState<_ArrivalToast> { ), ), child: event == null - ? const SizedBox.shrink(key: ValueKey('arrival-hidden')) + // Idle state — the spec wants a "Waiting for connections..." + // pill rather than empty space, so the user knows the screen + // is live and just nothing has arrived yet. + ? const _WaitingCard(key: ValueKey('arrival-waiting')) : _ArrivalCard( // ValueKey forces AnimatedSwitcher to swap children when a // new arrival lands while the previous toast is still up, @@ -1158,6 +1164,35 @@ class _ArrivalCard extends StatelessWidget { } } +/// Idle-state companion to _ArrivalCard. Same pill chrome, no heart, +/// `Waiting for connections...` text. Shown whenever the toast switch +/// has no current arrival to display. +class _WaitingCard extends StatelessWidget { + const _WaitingCard({super.key}); + + @override + Widget build(BuildContext context) { + return IgnorePointer( + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surface.withValues(alpha: 0.92), + borderRadius: BorderRadius.circular(100), + border: Border.all(color: Colors.black12), + ), + child: Text( + 'Waiting for connections...', + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w500, + color: Theme.of(context).hintColor, + ), + ), + ), + ); + } +} + // ─── Heart burst ───────────────────────────────────────────────────────────── /// Heart + Lottie explosion lifted from getlantern/unbounded. The pink From 3ed84c3872c6614446b739709c68f509074cfc2e Mon Sep 17 00:00:00 2001 From: Adam Fisk Date: Tue, 12 May 2026 12:12:38 -0600 Subject: [PATCH 02/26] =?UTF-8?q?unbounded:=20phase=202=20=E2=80=94=20Unbo?= =?UTF-8?q?unded=20Settings=20sheet=20+=20hide-tab=20toggle?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- lib/core/models/app_setting.dart | 25 ++++++ lib/features/home/home.dart | 40 +++++---- .../home/provider/app_setting_notifier.dart | 9 ++ lib/features/setting/setting.dart | 14 ++++ lib/features/setting/unbounded_setting.dart | 82 +++++++++++++++++++ 5 files changed, 156 insertions(+), 14 deletions(-) create mode 100644 lib/features/setting/unbounded_setting.dart diff --git a/lib/core/models/app_setting.dart b/lib/core/models/app_setting.dart index 3f6eb7a6ee..a86bb1ccc0 100644 --- a/lib/core/models/app_setting.dart +++ b/lib/core/models/app_setting.dart @@ -8,6 +8,14 @@ class AppSetting { final bool successfulConnection; final String dataCapThreshold; final bool onboardingCompleted; + // Unbounded preferences. autoEnable: turn the peer share on whenever + // the VPN connects (defaults on per the Figma spec). hideTab: hide + // the Unbounded tab + collapse the tab bar when the user doesn't + // want to see it. welcomeSeen: tracks the first-visit info popup so + // we only show it once. All persisted across launches. + final bool unboundedAutoEnable; + final bool unboundedHidden; + final bool unboundedWelcomeSeen; const AppSetting({ this.themeMode = 'system', @@ -19,6 +27,9 @@ class AppSetting { this.successfulConnection = false, this.dataCapThreshold = '', this.onboardingCompleted = false, + this.unboundedAutoEnable = true, + this.unboundedHidden = false, + this.unboundedWelcomeSeen = false, }); AppSetting copyWith({ @@ -31,6 +42,9 @@ class AppSetting { bool? successfulConnection, String? dataCapThreshold, bool? onboardingCompleted, + bool? unboundedAutoEnable, + bool? unboundedHidden, + bool? unboundedWelcomeSeen, }) { return AppSetting( locale: newLocale ?? locale, @@ -42,6 +56,9 @@ class AppSetting { successfulConnection: successfulConnection ?? this.successfulConnection, dataCapThreshold: dataCapThreshold ?? this.dataCapThreshold, onboardingCompleted: onboardingCompleted ?? this.onboardingCompleted, + unboundedAutoEnable: unboundedAutoEnable ?? this.unboundedAutoEnable, + unboundedHidden: unboundedHidden ?? this.unboundedHidden, + unboundedWelcomeSeen: unboundedWelcomeSeen ?? this.unboundedWelcomeSeen, ); } @@ -55,6 +72,9 @@ class AppSetting { 'successfulConnection': successfulConnection, 'dataCapThreshold': dataCapThreshold, 'onboardingCompleted': onboardingCompleted, + 'unboundedAutoEnable': unboundedAutoEnable, + 'unboundedHidden': unboundedHidden, + 'unboundedWelcomeSeen': unboundedWelcomeSeen, }; factory AppSetting.fromJson(Map json) => AppSetting( @@ -67,5 +87,10 @@ class AppSetting { successfulConnection: json['successfulConnection'] == true, dataCapThreshold: (json['dataCapThreshold'] ?? '').toString(), onboardingCompleted: json['onboardingCompleted'] == true, + // Default to true when missing (first-time post-upgrade users + // should get the auto-enable behaviour the spec calls for). + unboundedAutoEnable: json['unboundedAutoEnable'] != false, + unboundedHidden: json['unboundedHidden'] == true, + unboundedWelcomeSeen: json['unboundedWelcomeSeen'] == true, ); } diff --git a/lib/features/home/home.dart b/lib/features/home/home.dart index 04136d2ab1..1aa8b026ef 100644 --- a/lib/features/home/home.dart +++ b/lib/features/home/home.dart @@ -37,6 +37,9 @@ class Home extends HookConsumerWidget { final userLoggedIn = ref.watch( appSettingProvider.select((s) => s.userLoggedIn), ); + final unboundedHidden = ref.watch( + appSettingProvider.select((s) => s.unboundedHidden), + ); final featureFlag = ref.watch(featureFlagProvider); final vpnStatus = ref.watch(vpnProvider); final shareActive = ref.watch(shareProvider.select((s) => s.active)); @@ -125,21 +128,30 @@ class Home extends HookConsumerWidget { onPressed: () => appRouter.push(const SignInEmail()), ), ], - bottom: TabBar( - controller: tabController, - tabs: [ - _TabLabel(label: 'VPN', active: vpnStatus == VPNStatus.connected), - _TabLabel(label: 'Unbounded', active: shareActive), - ], - ), - ), - body: TabBarView( - controller: tabController, - children: const [ - VpnTab(), - UnboundedTab(), - ], + // Tab strip collapses when the user has hidden the Unbounded + // tab in Unbounded Settings — with only one tab left, a strip + // is just noise. Body falls back to VpnTab directly. + bottom: unboundedHidden + ? null + : TabBar( + controller: tabController, + tabs: [ + _TabLabel( + label: 'VPN', + active: vpnStatus == VPNStatus.connected), + _TabLabel(label: 'Unbounded', active: shareActive), + ], + ), ), + body: unboundedHidden + ? const VpnTab() + : TabBarView( + controller: tabController, + children: const [ + VpnTab(), + UnboundedTab(), + ], + ), ); } } diff --git a/lib/features/home/provider/app_setting_notifier.dart b/lib/features/home/provider/app_setting_notifier.dart index 97af2d8365..4738642a36 100644 --- a/lib/features/home/provider/app_setting_notifier.dart +++ b/lib/features/home/provider/app_setting_notifier.dart @@ -105,6 +105,15 @@ class AppSettingNotifier extends _$AppSettingNotifier { if (value) unawaited(_writeInitMarker()); } + void setUnboundedAutoEnable(bool value) => + update(state.copyWith(unboundedAutoEnable: value)); + + void setUnboundedHidden(bool value) => + update(state.copyWith(unboundedHidden: value)); + + void setUnboundedWelcomeSeen(bool value) => + update(state.copyWith(unboundedWelcomeSeen: value)); + Future _writeInitMarker() async { try { final dataDir = await AppStorageUtils.getAppDirectory(); diff --git a/lib/features/setting/setting.dart b/lib/features/setting/setting.dart index 027eb40279..eb507518aa 100644 --- a/lib/features/setting/setting.dart +++ b/lib/features/setting/setting.dart @@ -12,6 +12,7 @@ import 'package:lantern/features/home/provider/home_notifier.dart'; import 'package:lantern/features/plans/restore_purchase_mixin.dart'; import 'package:lantern/features/setting/appearance.dart' show appearanceModeLabel, showAppearanceBottomSheet; +import 'package:lantern/features/setting/unbounded_setting.dart'; import '../../core/services/injection_container.dart'; @@ -19,6 +20,7 @@ enum _SettingType { account, signIn, vpnSetting, + unboundedSetting, language, appearance, support, @@ -143,6 +145,13 @@ class _SettingState extends ConsumerState onPressed: () => settingMenuTap(_SettingType.vpnSetting), ), DividerSpace(), + AppTile( + label: 'Unbounded Settings', + icon: AppImagePaths.share, + onPressed: () => + settingMenuTap(_SettingType.unboundedSetting), + ), + DividerSpace(), AppTile( label: 'language'.i18n, icon: AppImagePaths.translate, @@ -317,6 +326,11 @@ class _SettingState extends ConsumerState case _SettingType.vpnSetting: appRouter.push(VPNSetting()); break; + case _SettingType.unboundedSetting: + Navigator.of(context).push( + MaterialPageRoute(builder: (_) => const UnboundedSetting()), + ); + break; case _SettingType.browserUnbounded: // TODO: Handle this case. throw UnimplementedError(); diff --git a/lib/features/setting/unbounded_setting.dart b/lib/features/setting/unbounded_setting.dart new file mode 100644 index 0000000000..559b85d8e3 --- /dev/null +++ b/lib/features/setting/unbounded_setting.dart @@ -0,0 +1,82 @@ +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:lantern/core/widgets/switch_button.dart'; +import 'package:lantern/features/home/provider/app_setting_notifier.dart'; + +import '../../core/common/common.dart'; + +/// Unbounded Settings sheet, reached from the main Settings menu. Two +/// toggles per the Figma spec +/// (figma.com/design/hNlyYToB5TnX9SDBFDYJTq?node-id=2403-19287): +/// +/// 1. Auto-enable Unbounded — turn Unbounded on automatically when +/// Lantern (VPN) is connected. The actual auto-enable wiring lives +/// in the Home shell (or a VPN-status listener) and reads this flag. +/// 2. Hide Unbounded — collapse the Unbounded tab in the Home shell +/// when the user doesn't want to see it. With only the VPN tab +/// left, Home hides the tab strip entirely. +class UnboundedSetting extends ConsumerWidget { + const UnboundedSetting({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final autoEnable = ref.watch( + appSettingProvider.select((s) => s.unboundedAutoEnable), + ); + final hidden = ref.watch( + appSettingProvider.select((s) => s.unboundedHidden), + ); + final notifier = ref.read(appSettingProvider.notifier); + final textTheme = Theme.of(context).textTheme; + + return BaseScreen( + title: 'Unbounded Settings', + body: ListView( + children: [ + const SizedBox(height: 8), + AppCard( + padding: EdgeInsets.zero, + child: Column( + children: [ + AppTile( + label: 'Auto-enable Unbounded', + subtitle: Text( + 'Turn on automatically when Lantern is open', + style: textTheme.labelMedium!.copyWith( + color: context.textTertiary, + letterSpacing: 0.0, + ), + ), + icon: AppImagePaths.share, + trailing: SwitchButton( + value: autoEnable, + onChanged: notifier.setUnboundedAutoEnable, + ), + onPressed: () => + notifier.setUnboundedAutoEnable(!autoEnable), + ), + DividerSpace(), + AppTile( + label: 'Hide Unbounded', + subtitle: Text( + 'Removes Unbounded from the top of this screen', + style: textTheme.labelMedium!.copyWith( + color: context.textTertiary, + letterSpacing: 0.0, + ), + ), + icon: const Icon(Icons.visibility_off_outlined), + trailing: SwitchButton( + value: hidden, + onChanged: notifier.setUnboundedHidden, + ), + onPressed: () => notifier.setUnboundedHidden(!hidden), + ), + ], + ), + ), + ], + ), + ); + } +} From 11abe38b63094d6bf4578f6facba5ac71463d46e Mon Sep 17 00:00:00 2001 From: Adam Fisk Date: Tue, 12 May 2026 12:14:15 -0600 Subject: [PATCH 03/26] =?UTF-8?q?unbounded:=20phase=203=20=E2=80=94=20auto?= =?UTF-8?q?-enable=20on=20VPN=20connect?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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(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) --- lib/features/home/home.dart | 19 +++++++++++++++ .../share_my_connection.dart | 24 +++++++++++++++++++ 2 files changed, 43 insertions(+) diff --git a/lib/features/home/home.dart b/lib/features/home/home.dart index 1aa8b026ef..7e8fbb9ff4 100644 --- a/lib/features/home/home.dart +++ b/lib/features/home/home.dart @@ -94,6 +94,25 @@ class Home extends HookConsumerWidget { ref.read(appEventProvider); + // Auto-enable Unbounded on VPN-connected transitions, gated on the + // "Auto-enable Unbounded" toggle from Unbounded Settings. The actual + // start (UPnP probe + radiance peer_share_enabled setting) happens + // inside ShareNotifier.autoStart; the user has already consented in + // settings, so we skip the disclosure dialog. + ref.listen(vpnProvider, (prev, next) { + if (prev == next) return; + if (next != VPNStatus.connected) 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), + ); + }); + return Scaffold( key: const Key('home.screen'), appBar: AppBar( diff --git a/lib/features/share_my_connection/share_my_connection.dart b/lib/features/share_my_connection/share_my_connection.dart index 3e2b42ce23..df70b28615 100644 --- a/lib/features/share_my_connection/share_my_connection.dart +++ b/lib/features/share_my_connection/share_my_connection.dart @@ -297,6 +297,30 @@ class ShareNotifier extends Notifier { } } + /// Programmatic entry point used by the Home shell's auto-enable + /// listener (VPN-connected → Unbounded on). Mirrors `toggle()` but + /// skips the disclosure dialog because the user has already opted + /// in via the Unbounded Settings sheet. No-ops if already active or + /// in flight. + Future autoStart(WidgetRef widgetRef) async { + if (state.active || state.probing) return; + state = state.copyWith(probing: true); + final manualPortRes = + await widgetRef.read(lanternServiceProvider).getPeerManualPort(); + final manualPort = manualPortRes.fold((_) => 0, (p) => p); + if (manualPort > 0) { + await _start(widgetRef, ShareMode.smc); + return; + } + // MOCK UPnP probe — same as toggle(), pending real FFI. + await Future.delayed(const Duration(milliseconds: 1500)); + final upnpAvailable = Random().nextBool(); + await _start( + widgetRef, + upnpAvailable ? ShareMode.smc : ShareMode.unbounded, + ); + } + Future _start(WidgetRef widgetRef, ShareMode mode) async { state = ShareState( active: true, From e191f63ac682b219dda4a145beff165bfc883c61 Mon Sep 17 00:00:00 2001 From: Adam Fisk Date: Tue, 12 May 2026 12:16:23 -0600 Subject: [PATCH 04/26] =?UTF-8?q?unbounded:=20phase=204=20=E2=80=94=20firs?= =?UTF-8?q?t-visit=20welcome=20popup=20+=20info=20bubble?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../share_my_connection.dart | 139 +++++++++++++++++- 1 file changed, 136 insertions(+), 3 deletions(-) diff --git a/lib/features/share_my_connection/share_my_connection.dart b/lib/features/share_my_connection/share_my_connection.dart index df70b28615..25158c20ef 100644 --- a/lib/features/share_my_connection/share_my_connection.dart +++ b/lib/features/share_my_connection/share_my_connection.dart @@ -25,6 +25,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:lottie/lottie.dart'; import 'package:lantern/core/common/common.dart'; import 'package:lantern/core/models/unbounded_connection_event.dart'; +import 'package:lantern/features/home/provider/app_setting_notifier.dart'; import 'package:lantern/core/services/geo_lookup_service.dart'; import 'package:lantern/core/services/injection_container.dart' show sl; import 'package:lantern/core/services/local_storage_service.dart'; @@ -655,15 +656,48 @@ class UnboundedTab extends HookConsumerWidget { final notifier = ref.read(shareProvider.notifier); final textTheme = Theme.of(context).textTheme; + // First-visit welcome popup. Fires once per device (persisted via + // appSettingProvider.unboundedWelcomeSeen) when the user first lands + // on the Unbounded tab. Re-openable via the info-bubble icon in the + // header. + useEffect(() { + final seen = ref.read(appSettingProvider).unboundedWelcomeSeen; + if (!seen) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!context.mounted) return; + showUnboundedWelcomeDialog(context, ref); + }); + } + return null; + }, const []); + return SafeArea( child: Padding( padding: const EdgeInsets.symmetric(horizontal: 16), child: Column( children: [ const SizedBox(height: 12), - Text( - 'smc_intro'.i18n, - style: textTheme.bodyMedium, + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Text( + 'smc_intro'.i18n, + style: textTheme.bodyMedium, + ), + ), + // Info bubble — re-opens the welcome popup. Mirrors the + // Figma spec, which calls out the info-bubble as the + // way back into the explanatory dialog. + IconButton( + icon: const Icon(Icons.info_outline, size: 20), + visualDensity: VisualDensity.compact, + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + tooltip: 'about_unbounded'.i18n, + onPressed: () => showUnboundedWelcomeDialog(context, ref), + ), + ], ), const SizedBox(height: 16), Expanded( @@ -1554,3 +1588,102 @@ class SmcDisclosureDialog extends StatelessWidget { ); } } + +// ─── Welcome dialog ────────────────────────────────────────────────────────── + +/// Shows the first-visit Unbounded welcome popup per Figma +/// (figma.com/design/hNlyYToB5TnX9SDBFDYJTq?node-id=2403-19287). +/// Idempotent: dismissing the dialog (either button OR scrim tap) +/// flips appSettingProvider.unboundedWelcomeSeen → true so the dialog +/// only fires on the first visit. The info-bubble icon in the +/// Unbounded tab header calls this same function to re-open it later. +void showUnboundedWelcomeDialog(BuildContext context, WidgetRef ref) { + showDialog( + context: context, + builder: (_) => const _UnboundedWelcomeDialog(), + ).whenComplete(() { + ref.read(appSettingProvider.notifier).setUnboundedWelcomeSeen(true); + }); +} + +class _UnboundedWelcomeDialog extends StatelessWidget { + const _UnboundedWelcomeDialog(); + + @override + Widget build(BuildContext context) { + final textTheme = Theme.of(context).textTheme; + return Dialog( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 360), + child: Padding( + padding: const EdgeInsets.fromLTRB(24, 28, 24, 16), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Heart logo, matching the Figma's heart-Lantern motif. + const Center( + child: SizedBox( + width: 40, + height: 34, + child: CustomPaint(painter: _HeartPainter()), + ), + ), + const SizedBox(height: 16), + Center( + child: Text( + 'Welcome to Unbounded', + style: textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + ), + const SizedBox(height: 16), + Text( + "When you enable Unbounded, your device becomes part of a " + "network of 'digital bridges' to the open internet. " + "Censored users connect to these bridges, allowing them " + "to bypass government-imposed restrictors and access the " + "information they need.", + style: textTheme.bodyMedium, + ), + const SizedBox(height: 12), + Text( + 'This collective effort makes censorship harder to ' + 'enforce, expanding access to the open internet.', + style: textTheme.bodyMedium, + ), + const SizedBox(height: 12), + Text( + 'You can remove Unbounded from the interface anytime in ' + 'Settings.', + style: textTheme.bodyMedium, + ), + const SizedBox(height: 16), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + TextButton.icon( + onPressed: () { + // TODO: deep-link to the Unbounded explainer page + // once the URL is wired (AppUrls.unbounded?). + }, + icon: const Icon(Icons.open_in_new, size: 14), + label: const Text('Learn more'), + ), + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Got it'), + ), + ], + ), + ], + ), + ), + ), + ); + } +} From dcf42b76e325f051cff2efe31989e4a8dff05a3c Mon Sep 17 00:00:00 2001 From: Adam Fisk Date: Tue, 12 May 2026 12:25:00 -0600 Subject: [PATCH 05/26] unbounded: also auto-enable on app launch (not just VPN connect) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 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) --- lib/features/home/home.dart | 29 ++++++++++++++++++++++++----- 1 file changed, 24 insertions(+), 5 deletions(-) diff --git a/lib/features/home/home.dart b/lib/features/home/home.dart index 7e8fbb9ff4..24d23bd8db 100644 --- a/lib/features/home/home.dart +++ b/lib/features/home/home.dart @@ -94,11 +94,30 @@ class Home extends HookConsumerWidget { ref.read(appEventProvider); - // Auto-enable Unbounded on VPN-connected transitions, gated on the - // "Auto-enable Unbounded" toggle from Unbounded Settings. The actual - // start (UPnP probe + radiance peer_share_enabled setting) happens - // inside ShareNotifier.autoStart; the user has already consented in - // settings, so we skip the disclosure dialog. + // Auto-enable Unbounded — gated on the "Auto-enable Unbounded" + // toggle from Unbounded Settings (default ON). Fires from two + // entry points so the spec's subtitle "Turn on automatically when + // Lantern is open" is honoured whether the user connects the VPN + // or not: + // 1. App launch (useEffect below) — once on Home mount. + // 2. VPN connect (ref.listen further down) — on every + // disconnected → connected transition, in case the toggle + // flipped on after launch or the user finally connects. + // Both paths gate on (active || probing) to avoid re-triggering + // while a Start is in flight, and skip the disclosure dialog + // because the user has already opted in via settings. + useEffect(() { + WidgetsBinding.instance.addPostFrameCallback((_) { + 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; + }, const []); + ref.listen(vpnProvider, (prev, next) { if (prev == next) return; if (next != VPNStatus.connected) return; From 76f59cb371613fb4aa875f0f35c23bf669061233 Mon Sep 17 00:00:00 2001 From: Adam Fisk Date: Tue, 12 May 2026 12:37:59 -0600 Subject: [PATCH 06/26] unbounded: lift the heart spray out of the pill, onto the globe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../share_my_connection.dart | 162 +++++++++++------- 1 file changed, 100 insertions(+), 62 deletions(-) diff --git a/lib/features/share_my_connection/share_my_connection.dart b/lib/features/share_my_connection/share_my_connection.dart index 25158c20ef..9d077bc06d 100644 --- a/lib/features/share_my_connection/share_my_connection.dart +++ b/lib/features/share_my_connection/share_my_connection.dart @@ -703,13 +703,19 @@ class UnboundedTab extends HookConsumerWidget { Expanded( flex: 3, child: Stack( + clipBehavior: Clip.none, children: [ Positioned.fill(child: _GlobeView()), + // Lottie burst overlay — fills the globe area so the + // heart spray can spread across the whole sphere, + // matching unbounded.lantern.io. Plays once on each + // new arrival; the layer manages its own restart. + const Positioned.fill(child: _LottieBurstLayer()), // Floating arrival toast — overlays the bottom of the // globe area rather than the peer's exact location on - // the sphere. Anchoring to projected coords forced the - // burst to repaint every globe rotation frame, which - // made the rotation jittery. + // the sphere. Anchoring to projected coords forced + // the burst to repaint every globe rotation frame, + // which made the rotation jittery. const Positioned( left: 0, right: 0, @@ -1204,8 +1210,15 @@ class _ArrivalCard extends StatelessWidget { child: Row( mainAxisSize: MainAxisSize.min, children: [ - const SizedBox(width: 40, height: 40, child: _HeartBurst()), - const SizedBox(width: 12), + // Static heart inside the pill. The animated burst is its + // own layer covering the globe (see _LottieBurstLayer); the + // pill stays small and clean — matches unbounded.lantern.io. + const SizedBox( + width: 22, + height: 19, + child: CustomPaint(painter: _HeartPainter()), + ), + const SizedBox(width: 10), Text( flagEmoji.isEmpty ? 'smc_arrival_toast'.i18n.fill([countryName]) @@ -1251,77 +1264,102 @@ class _WaitingCard extends StatelessWidget { } } -// ─── Heart burst ───────────────────────────────────────────────────────────── +// ─── Lottie burst layer ────────────────────────────────────────────────────── -/// Heart + Lottie explosion lifted from getlantern/unbounded. The pink -/// heart is the inline SVG path from `notification/explosion.tsx` -/// (FF5A79 fill, 32×27 viewBox); the burst is `explosion.json` played -/// once via the `lottie` Flutter package. Rendered inside _ArrivalCard -/// (under the globe), NOT anchored to globe coords — anchoring forced -/// a repaint per globe rotation frame and made rotation jittery. -class _HeartBurst extends StatefulWidget { - const _HeartBurst(); +/// Full-area Lottie heart-spray layer that overlays the globe and +/// plays the `explosion.json` animation each time a new peer arrives. +/// Matches unbounded.lantern.io's behaviour where dozens of small +/// hearts spray outward across the globe — NOT confined to the toast +/// pill (the pill stays clean with just a static heart icon). +/// +/// Subscribes to ShareNotifier.connectionEvents directly. On each +/// non-replay state=1 event, swaps the `key` on the inner Lottie via +/// a counter so AnimatedSwitcher cross-fades / Lottie restarts from +/// frame 0 every time. +class _LottieBurstLayer extends ConsumerStatefulWidget { + const _LottieBurstLayer(); @override - State<_HeartBurst> createState() => _HeartBurstState(); + ConsumerState<_LottieBurstLayer> createState() => _LottieBurstLayerState(); } -class _HeartBurstState extends State<_HeartBurst> - with TickerProviderStateMixin { - AnimationController? _lottieCtrl; +class _LottieBurstLayerState extends ConsumerState<_LottieBurstLayer> { + StreamSubscription? _sub; + int _burstId = 0; + + @override + void initState() { + super.initState(); + _sub = ref + .read(shareProvider.notifier) + .connectionEvents + .listen(_onEvent); + } + + void _onEvent(UnboundedConnectionEvent event) { + if (event.state != 1 || event.isReplay) return; + if (event.countryName.isEmpty) return; + if (!mounted) return; + setState(() => _burstId++); + } @override void dispose() { - _lottieCtrl?.dispose(); + _sub?.cancel(); super.dispose(); } @override Widget build(BuildContext context) { return IgnorePointer( - child: Stack( - alignment: Alignment.center, - clipBehavior: Clip.none, - children: [ - // Lottie explosion sized so particle spray extends slightly - // past the card bounds (Clip.none on parent lets it overflow). - // Mirrors unbounded's LottieWrapper sizing, scaled down for an - // inline card slot. - Positioned( - width: 110, - height: 110, - child: Lottie.asset( - 'assets/unbounded/explosion.json', - repeat: false, - fit: BoxFit.contain, - onLoaded: (composition) { - // Dispose guard: Lottie's onLoaded can fire after the - // burst is replaced (rapid peer arrivals replace the - // ArrivalCard before composition load completes). - // Without this, AnimationController(vsync: this) + - // setState would throw on a disposed State. - if (!mounted) return; - // Also dispose any prior controller — a stale - // AnimationController from an earlier onLoaded would - // leak its ticker subscription otherwise. - _lottieCtrl?.dispose(); - _lottieCtrl = AnimationController( - vsync: this, - duration: composition.duration, - )..forward(); - setState(() {}); - }, - controller: _lottieCtrl, - ), - ), - // Heart SVG path — exact coords from unbounded's inline SVG. - const SizedBox( - width: 22, - height: 19, - child: CustomPaint(painter: _HeartPainter()), - ), - ], - ), + child: _burstId == 0 + ? const SizedBox.shrink() + : _BurstAnimation(key: ValueKey(_burstId)), + ); + } +} + +class _BurstAnimation extends StatefulWidget { + const _BurstAnimation({super.key}); + + @override + State<_BurstAnimation> createState() => _BurstAnimationState(); +} + +class _BurstAnimationState extends State<_BurstAnimation> + with TickerProviderStateMixin { + AnimationController? _ctrl; + + @override + void dispose() { + _ctrl?.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Lottie.asset( + 'assets/unbounded/explosion.json', + repeat: false, + fit: BoxFit.contain, + onLoaded: (composition) { + // Dispose guard: Lottie's onLoaded can fire after this + // State is disposed (rapid peer arrivals replace the + // keyed _BurstAnimation before composition load completes). + // Without this, AnimationController(vsync: this) + + // setState would throw on a disposed State. + if (!mounted) return; + // Also dispose any prior controller — a stale + // AnimationController from an earlier onLoaded would + // leak its ticker subscription otherwise. + _ctrl?.dispose(); + _ctrl = AnimationController( + vsync: this, + duration: composition.duration, + )..forward(); + setState(() {}); + }, + controller: _ctrl, ); } } From ec18d7f7af7587367983f656db1638ad1f2c2161 Mon Sep 17 00:00:00 2001 From: Adam Fisk Date: Tue, 12 May 2026 13:00:42 -0600 Subject: [PATCH 07/26] =?UTF-8?q?unbounded:=20put=20the=20Lottie=20inside?= =?UTF-8?q?=20the=20pill,=20overflowing=20=E2=80=94=20matches=20CSS?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../share_my_connection.dart | 86 ++++++++++++++++--- 1 file changed, 73 insertions(+), 13 deletions(-) diff --git a/lib/features/share_my_connection/share_my_connection.dart b/lib/features/share_my_connection/share_my_connection.dart index 9d077bc06d..723d91ed3b 100644 --- a/lib/features/share_my_connection/share_my_connection.dart +++ b/lib/features/share_my_connection/share_my_connection.dart @@ -706,16 +706,13 @@ class UnboundedTab extends HookConsumerWidget { clipBehavior: Clip.none, children: [ Positioned.fill(child: _GlobeView()), - // Lottie burst overlay — fills the globe area so the - // heart spray can spread across the whole sphere, - // matching unbounded.lantern.io. Plays once on each - // new arrival; the layer manages its own restart. - const Positioned.fill(child: _LottieBurstLayer()), // Floating arrival toast — overlays the bottom of the // globe area rather than the peer's exact location on - // the sphere. Anchoring to projected coords forced - // the burst to repaint every globe rotation frame, - // which made the rotation jittery. + // the sphere. The Lottie heart-spray now lives INSIDE + // the toast pill (with negative-offset positioning so + // it overflows upward/rightward into the globe area), + // mirroring unbounded's CSS: the static heart anchors + // the burst, hearts spray outward from there. const Positioned( left: 0, right: 0, @@ -1195,6 +1192,10 @@ class _ArrivalCard extends StatelessWidget { return IgnorePointer( child: Container( padding: const EdgeInsets.fromLTRB(10, 8, 16, 8), + // clipBehavior:none lets the absolutely-positioned Lottie burst + // (inside the heart slot below) overflow the pill's rounded + // bounds and spray upward across the globe. + clipBehavior: Clip.none, decoration: BoxDecoration( color: Theme.of(context).colorScheme.surface.withValues(alpha: 0.92), borderRadius: BorderRadius.circular(100), @@ -1210,13 +1211,34 @@ class _ArrivalCard extends StatelessWidget { child: Row( mainAxisSize: MainAxisSize.min, children: [ - // Static heart inside the pill. The animated burst is its - // own layer covering the globe (see _LottieBurstLayer); the - // pill stays small and clean — matches unbounded.lantern.io. - const SizedBox( + // Heart slot with the static heart icon + a Lottie burst + // that overflows upward and rightward into the globe area. + // Layout mirrors unbounded's CSS one-for-one: heart in a + // 22×19 slot, Lottie absolute-positioned at bottom:-55, + // left:-105, width:420 (scaled to the slot's natural + // bottom/left = pill heart's bottom/left). + SizedBox( width: 22, height: 19, - child: CustomPaint(painter: _HeartPainter()), + child: Stack( + clipBehavior: Clip.none, + alignment: Alignment.center, + children: const [ + // Lottie spreads upward + rightward from the heart. + // height is auto from BoxFit.contain on its 420-wide + // canvas; explosion.json is roughly square so ~420 + // tall, but most of that is above the heart due to + // the negative bottom offset. + Positioned( + bottom: -55, + left: -105, + width: 420, + height: 420, + child: _ArrivalLottie(), + ), + CustomPaint(painter: _HeartPainter()), + ], + ), ), const SizedBox(width: 10), Text( @@ -1235,6 +1257,43 @@ class _ArrivalCard extends StatelessWidget { } } +/// Plays explosion.json once per build. Stateful so each _ArrivalCard +/// instance (keyed on workerIdx) gets its own clean Lottie playback. +class _ArrivalLottie extends StatefulWidget { + const _ArrivalLottie(); + + @override + State<_ArrivalLottie> createState() => _ArrivalLottieState(); +} + +class _ArrivalLottieState extends State<_ArrivalLottie> + with TickerProviderStateMixin { + AnimationController? _ctrl; + + @override + void dispose() { + _ctrl?.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Lottie.asset( + 'assets/unbounded/explosion.json', + repeat: false, + fit: BoxFit.contain, + onLoaded: (composition) { + _ctrl = AnimationController( + vsync: this, + duration: composition.duration, + )..forward(); + setState(() {}); + }, + controller: _ctrl, + ); + } +} + /// Idle-state companion to _ArrivalCard. Same pill chrome, no heart, /// `Waiting for connections...` text. Shown whenever the toast switch /// has no current arrival to display. @@ -1364,6 +1423,7 @@ class _BurstAnimationState extends State<_BurstAnimation> } } + /// Pink heart from `getlantern/unbounded` — exact SVG path coords /// (viewBox 0 0 32 27, fill #FF5A79). class _HeartPainter extends CustomPainter { From 1dfee348197c295fd354598bfbea1bdf87b29818 Mon Sep 17 00:00:00 2001 From: Adam Fisk Date: Tue, 12 May 2026 13:15:58 -0600 Subject: [PATCH 08/26] =?UTF-8?q?unbounded:=20match=20the=20pill=20exactly?= =?UTF-8?q?=20=E2=80=94=20heart+text,=20bottom-left=20anchor?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../share_my_connection.dart | 28 ++++++++++--------- 1 file changed, 15 insertions(+), 13 deletions(-) diff --git a/lib/features/share_my_connection/share_my_connection.dart b/lib/features/share_my_connection/share_my_connection.dart index 723d91ed3b..a68f135771 100644 --- a/lib/features/share_my_connection/share_my_connection.dart +++ b/lib/features/share_my_connection/share_my_connection.dart @@ -706,18 +706,17 @@ class UnboundedTab extends HookConsumerWidget { clipBehavior: Clip.none, children: [ Positioned.fill(child: _GlobeView()), - // Floating arrival toast — overlays the bottom of the - // globe area rather than the peer's exact location on - // the sphere. The Lottie heart-spray now lives INSIDE - // the toast pill (with negative-offset positioning so - // it overflows upward/rightward into the globe area), - // mirroring unbounded's CSS: the static heart anchors - // the burst, hearts spray outward from there. + // Floating arrival toast — anchored at the bottom-LEFT + // of the globe area per unbounded.lantern.io. The + // Lottie heart-spray lives INSIDE the pill (negative- + // offset positioning, overflows upward/rightward into + // the globe), mirroring unbounded's CSS: the static + // heart anchors the burst origin, hearts spray + // outward from there. const Positioned( - left: 0, - right: 0, + left: 12, bottom: 8, - child: Center(child: _ArrivalToast()), + child: _ArrivalToast(), ), ], ), @@ -1241,10 +1240,13 @@ class _ArrivalCard extends StatelessWidget { ), ), const SizedBox(width: 10), + // unbounded.lantern.io renders just `heart + text`, no flag + // emoji — matching that exactly so the pill width stays in + // bounds and the layout reads cleanly. flagEmoji is still + // carried on the event for future use (e.g. label above + // the peer's arc on the globe). Text( - flagEmoji.isEmpty - ? 'smc_arrival_toast'.i18n.fill([countryName]) - : '$flagEmoji ${'smc_arrival_toast'.i18n.fill([countryName])}', + 'smc_arrival_toast'.i18n.fill([countryName]), style: const TextStyle( fontSize: 13, fontWeight: FontWeight.w500, From 74a1d4b19c12f5c33044320d09c93dddaabd770f Mon Sep 17 00:00:00 2001 From: Adam Fisk Date: Tue, 12 May 2026 13:33:18 -0600 Subject: [PATCH 09/26] unbounded: revert the pill back to centered MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../share_my_connection.dart | 20 ++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/lib/features/share_my_connection/share_my_connection.dart b/lib/features/share_my_connection/share_my_connection.dart index a68f135771..f8c3b53b6b 100644 --- a/lib/features/share_my_connection/share_my_connection.dart +++ b/lib/features/share_my_connection/share_my_connection.dart @@ -706,17 +706,19 @@ class UnboundedTab extends HookConsumerWidget { clipBehavior: Clip.none, children: [ Positioned.fill(child: _GlobeView()), - // Floating arrival toast — anchored at the bottom-LEFT - // of the globe area per unbounded.lantern.io. The - // Lottie heart-spray lives INSIDE the pill (negative- - // offset positioning, overflows upward/rightward into - // the globe), mirroring unbounded's CSS: the static - // heart anchors the burst origin, hearts spray - // outward from there. + // Floating arrival toast — centered horizontally + // under the globe per unbounded.lantern.io + // (frame-020 of unbounded-russia.mp4 shows the pill + // sitting roughly under the globe's centre, not at + // a corner). The Lottie heart-spray lives INSIDE the + // pill via Stack(Clip.none) + negative offsets, so + // hearts originate from the pill's static heart and + // overflow upward/leftward into the globe area. const Positioned( - left: 12, + left: 0, + right: 0, bottom: 8, - child: _ArrivalToast(), + child: Center(child: _ArrivalToast()), ), ], ), From 8ad21a5f3a0bb6a66213ec7a0a85ea35e424f39a Mon Sep 17 00:00:00 2001 From: Adam Fisk Date: Tue, 12 May 2026 13:39:11 -0600 Subject: [PATCH 10/26] unbounded: persist "Total people helped to date" across restarts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- assets/locales/en.po | 8 +++-- lib/core/models/app_setting.dart | 13 +++++++ .../home/provider/app_setting_notifier.dart | 3 ++ .../share_my_connection.dart | 34 +++++++++++++++---- 4 files changed, 49 insertions(+), 9 deletions(-) diff --git a/assets/locales/en.po b/assets/locales/en.po index bdf7fd7bee..0f7a1f5a6b 100644 --- a/assets/locales/en.po +++ b/assets/locales/en.po @@ -611,10 +611,12 @@ msgstr "Couldn't share — try toggling again" # Status card — stats + tooltip msgid "smc_stat_active_now" -msgstr "Active now" +msgstr "People helping right now" -msgid "smc_stat_total_today" -msgstr "Total this session" +# Lifetime ("to date") rather than daily — backed by the persisted +# unboundedTotalHelped setting so the count survives app restarts. +msgid "smc_stat_total_helped" +msgstr "Total people helped to date" msgid "smc_connections_tooltip" msgstr "Most connections are short liveness probes — Lantern clients periodically check that this peer is reachable before sending real traffic. A quick burst from many locations is normal; an arc that lingers represents an actual user session." diff --git a/lib/core/models/app_setting.dart b/lib/core/models/app_setting.dart index a86bb1ccc0..12fd7acaa8 100644 --- a/lib/core/models/app_setting.dart +++ b/lib/core/models/app_setting.dart @@ -16,6 +16,12 @@ class AppSetting { final bool unboundedAutoEnable; final bool unboundedHidden; final bool unboundedWelcomeSeen; + // Lifetime running total of peers this device has helped. Survives + // restarts so the "Total people helped to date" stat in the + // Unbounded tab can keep climbing — that's the spec wording in the + // Figma. ShareNotifier seeds totalCount from this on build, and + // writes back each time the count increments. + final int unboundedTotalHelped; const AppSetting({ this.themeMode = 'system', @@ -30,6 +36,7 @@ class AppSetting { this.unboundedAutoEnable = true, this.unboundedHidden = false, this.unboundedWelcomeSeen = false, + this.unboundedTotalHelped = 0, }); AppSetting copyWith({ @@ -45,6 +52,7 @@ class AppSetting { bool? unboundedAutoEnable, bool? unboundedHidden, bool? unboundedWelcomeSeen, + int? unboundedTotalHelped, }) { return AppSetting( locale: newLocale ?? locale, @@ -59,6 +67,8 @@ class AppSetting { unboundedAutoEnable: unboundedAutoEnable ?? this.unboundedAutoEnable, unboundedHidden: unboundedHidden ?? this.unboundedHidden, unboundedWelcomeSeen: unboundedWelcomeSeen ?? this.unboundedWelcomeSeen, + unboundedTotalHelped: + unboundedTotalHelped ?? this.unboundedTotalHelped, ); } @@ -75,6 +85,7 @@ class AppSetting { 'unboundedAutoEnable': unboundedAutoEnable, 'unboundedHidden': unboundedHidden, 'unboundedWelcomeSeen': unboundedWelcomeSeen, + 'unboundedTotalHelped': unboundedTotalHelped, }; factory AppSetting.fromJson(Map json) => AppSetting( @@ -92,5 +103,7 @@ class AppSetting { unboundedAutoEnable: json['unboundedAutoEnable'] != false, unboundedHidden: json['unboundedHidden'] == true, unboundedWelcomeSeen: json['unboundedWelcomeSeen'] == true, + unboundedTotalHelped: + (json['unboundedTotalHelped'] as num?)?.toInt() ?? 0, ); } diff --git a/lib/features/home/provider/app_setting_notifier.dart b/lib/features/home/provider/app_setting_notifier.dart index 4738642a36..ff9f232047 100644 --- a/lib/features/home/provider/app_setting_notifier.dart +++ b/lib/features/home/provider/app_setting_notifier.dart @@ -114,6 +114,9 @@ class AppSettingNotifier extends _$AppSettingNotifier { void setUnboundedWelcomeSeen(bool value) => update(state.copyWith(unboundedWelcomeSeen: value)); + void setUnboundedTotalHelped(int value) => + update(state.copyWith(unboundedTotalHelped: value)); + Future _writeInitMarker() async { try { final dataDir = await AppStorageUtils.getAppDirectory(); diff --git a/lib/features/share_my_connection/share_my_connection.dart b/lib/features/share_my_connection/share_my_connection.dart index f8c3b53b6b..5546d6f88e 100644 --- a/lib/features/share_my_connection/share_my_connection.dart +++ b/lib/features/share_my_connection/share_my_connection.dart @@ -219,7 +219,13 @@ class ShareNotifier extends Notifier { _stopEventSubscription(); _eventController.close(); }); - return const ShareState(); + // Seed totalCount from the persisted lifetime running total so the + // "Total people helped to date" stat survives app restarts. New + // arrivals (line further down) increment both ShareState.totalCount + // and the persisted value via setUnboundedTotalHelped. + final persistedTotal = + ref.read(appSettingProvider).unboundedTotalHelped; + return ShareState(totalCount: persistedTotal); } /// Toggle entry point. Caller passes its BuildContext so we can show the @@ -328,7 +334,9 @@ class ShareNotifier extends Notifier { probing: false, mode: mode, activeCount: 0, - totalCount: 0, + // Preserve the running total across off→on cycles so toggling + // doesn't reset the user's lifetime count. + totalCount: state.totalCount, ); _startEventSubscription(widgetRef); switch (mode) { @@ -411,7 +419,9 @@ class ShareNotifier extends Notifier { Future _stop(WidgetRef widgetRef) async { _stopEventSubscription(); final priorMode = state.mode; - state = const ShareState(); + // Preserve totalCount across toggle-off (same reason as _start — + // user's lifetime count shouldn't reset on a toggle cycle). + state = ShareState(totalCount: state.totalCount); switch (priorMode) { case ShareMode.smc: await widgetRef @@ -477,10 +487,18 @@ class ShareNotifier extends Notifier { final widx = _workerSeq++; final arc = _PeerArc(widx); _peerArcs[ip] = arc; + final newTotal = state.totalCount + 1; state = state.copyWith( activeCount: state.activeCount + 1, - totalCount: state.totalCount + 1, + totalCount: newTotal, ); + // Persist so the "Total people helped to date" stat + // survives restarts. Write happens per-arrival, but arrivals + // are bursty rather than continuous so SharedPreferences I/O + // pressure is fine. + ref + .read(appSettingProvider.notifier) + .setUnboundedTotalHelped(newTotal); // Resolve country async. Emit the +1 only after lookup so the // globe can render the arc at the right coords and the UI can // surface the country name in the connection banner. @@ -845,8 +863,12 @@ class _StatusCard extends StatelessWidget { Row( mainAxisAlignment: MainAxisAlignment.spaceAround, children: [ - _Stat(label: 'smc_stat_active_now'.i18n, value: '${state.activeCount}'), - _Stat(label: 'smc_stat_total_today'.i18n, value: '${state.totalCount}'), + _Stat( + label: 'smc_stat_active_now'.i18n, + value: '${state.activeCount}'), + _Stat( + label: 'smc_stat_total_helped'.i18n, + value: '${state.totalCount}'), ], ), Positioned( From 6b4cf04111fcdaea1f908d40ed4f4cf7cb4f5992 Mon Sep 17 00:00:00 2001 From: Adam Fisk Date: Wed, 13 May 2026 13:03:57 -0600 Subject: [PATCH 11/26] share: auto-fall-back from SmC to Unbounded on any Start failure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../share_my_connection.dart | 75 ++++++++++++++----- 1 file changed, 55 insertions(+), 20 deletions(-) diff --git a/lib/features/share_my_connection/share_my_connection.dart b/lib/features/share_my_connection/share_my_connection.dart index 5546d6f88e..32e9a7349d 100644 --- a/lib/features/share_my_connection/share_my_connection.dart +++ b/lib/features/share_my_connection/share_my_connection.dart @@ -457,7 +457,7 @@ class ShareNotifier extends Notifier { .watchAppEvents() .listen((event) { if (event.eventType == 'peer-status') { - _handlePeerStatus(event.message); + _handlePeerStatus(event.message, widgetRef); return; } if (event.eventType != 'peer-connection') return; @@ -618,32 +618,38 @@ class ShareNotifier extends Notifier { // from radiance/peer/peer.go's Phase constants; we map them through // SharePhase.fromWire so an unknown future phase falls back to idle // instead of crashing the consumer. - void _handlePeerStatus(String message) { + // + // SmC Start failures (UPnP miss, /v1/peer/register 404/4xx/5xx, + // samizdat verify timeout, …) arrive as phase=error. Treat any such + // failure as a signal to switch transparently to Unbounded mode — + // the user's intent ("I want to share") is honoured via broflake + // regardless of SmC's outcome, and raw protocol error text never + // reaches the status card. + void _handlePeerStatus(String message, WidgetRef widgetRef) { try { final payload = jsonDecode(message) as Map; final phase = SharePhase.fromWire(payload['phase'] as String?); final errMsg = payload['error'] as String?; final hasErr = errMsg != null && errMsg.isNotEmpty; - // Terminal-phase reset: when radiance reports the backend is - // idle (clean stop) or error (start failed / runtime collapse), - // the SmC active/mode bits also need to flip — otherwise the - // toggle stays ON while the backend is off. Both terminal - // phases tear down the event subscription and return to the - // off-state; the error phase additionally preserves the - // backend's error message so the StatusCard's (off, error) - // arm renders it. - if ((phase == SharePhase.idle || phase == SharePhase.error) && - state.mode == ShareMode.smc) { + // Terminal-phase reset in SmC mode: + // error → automatically fall back to Unbounded so the user + // keeps helping via the lower-friction path instead + // of seeing the screen flip off; SmC failures during + // Start are surfaced via this phase=error event. + // idle → clean stop (user toggled off, or radiance + // transitioned through stopping → idle). Tear down + // the event subscription and return to off. + if (phase == SharePhase.error && state.mode == ShareMode.smc) { + appLogger.info( + 'SmC start failed, falling back to Unbounded: ${errMsg ?? ""}', + ); + unawaited(_fallbackToUnbounded(widgetRef)); + return; + } + if (phase == SharePhase.idle && state.mode == ShareMode.smc) { _stopEventSubscription(); - if (phase == SharePhase.error) { - state = ShareState( - phase: SharePhase.error, - errorMessage: hasErr ? errMsg : null, - ); - } else { - state = const ShareState(); - } + state = const ShareState(); return; } state = state.copyWith( @@ -654,6 +660,35 @@ class ShareNotifier extends Notifier { debugPrint('share-my-connection: bad peer-status event: $e'); } } + + // Seamlessly switches an in-flight SmC session to Unbounded. Called when + // the radiance peer client reports phase=error — the SmC Start has + // already failed and radiance has rolled the PeerShareEnabledKey + // setting back to false, so all we owe is to flip our local state to + // Unbounded and enable broflake. + // + // Constructs ShareState directly (rather than copyWith) so errorMessage + // gets cleared — copyWith's `?? this.errorMessage` keeps the previous + // SmC failure string around otherwise. + Future _fallbackToUnbounded(WidgetRef widgetRef) async { + state = ShareState( + active: true, + probing: false, + mode: ShareMode.unbounded, + activeCount: 0, + totalCount: state.totalCount, + phase: SharePhase.idle, + ); + final result = await widgetRef + .read(lanternServiceProvider) + .setUnboundedEnabled(true); + result.fold( + (err) => appLogger.error( + 'SmC→Unbounded fallback: setUnboundedEnabled failed: ${err.error}', + ), + (_) => {}, + ); + } } final shareProvider = From d8566248d4cdbe7946f99a9637d01d9eaff209c9 Mon Sep 17 00:00:00 2001 From: Adam Fisk Date: Wed, 13 May 2026 13:46:43 -0600 Subject: [PATCH 12/26] unbounded: gate entire UI surface on server Features[unbounded] flag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- lib/core/models/feature_flags.dart | 10 +++- lib/features/home/home.dart | 21 ++++++--- lib/features/setting/setting.dart | 75 +++++++++++++++++------------- 3 files changed, 67 insertions(+), 39 deletions(-) diff --git a/lib/core/models/feature_flags.dart b/lib/core/models/feature_flags.dart index 91052949f7..70787331bb 100644 --- a/lib/core/models/feature_flags.dart +++ b/lib/core/models/feature_flags.dart @@ -3,7 +3,15 @@ enum FeatureFlag { metrics('otel.metrics'), traces('otel.traces'), autoUpdateEnabled('autoUpdateEnabled'), - androidSideloadAutoUpdateEnabled('androidSideloadAutoUpdateEnabled'); + androidSideloadAutoUpdateEnabled('androidSideloadAutoUpdateEnabled'), + // Server-side gate for the entire Unbounded / Share My Connection + // surface. When false (the default for censored regions), the + // Unbounded tab, settings entry, project link, and auto-enable hooks + // all disappear — censored users should never see a "share your + // connection" UI that could draw attention to them on-device. Mirrors + // radiance/unbounded/unbounded.go shouldRunUnbounded, which already + // gates execution on the same Features[UNBOUNDED] flag. + unbounded('unbounded'); final String key; diff --git a/lib/features/home/home.dart b/lib/features/home/home.dart index 24d23bd8db..ed0465d9b7 100644 --- a/lib/features/home/home.dart +++ b/lib/features/home/home.dart @@ -41,6 +41,12 @@ class Home extends HookConsumerWidget { appSettingProvider.select((s) => s.unboundedHidden), ); final featureFlag = ref.watch(featureFlagProvider); + // Server-side gate for the whole Unbounded UI surface. Censored + // regions get the flag off, so the tab, strip, and any auto-enable + // hook disappear. The user's own "Hide Unbounded tab" toggle still + // wins on top of this for non-censored users who want it hidden. + final unboundedAvailable = featureFlag.getBool(FeatureFlag.unbounded); + final showUnboundedTab = unboundedAvailable && !unboundedHidden; final vpnStatus = ref.watch(vpnProvider); final shareActive = ref.watch(shareProvider.select((s) => s.active)); @@ -108,6 +114,7 @@ class Home extends HookConsumerWidget { // because the user has already opted in via settings. useEffect(() { WidgetsBinding.instance.addPostFrameCallback((_) { + if (!unboundedAvailable) return; final appSetting = ref.read(appSettingProvider); if (!appSetting.onboardingCompleted) return; if (!appSetting.unboundedAutoEnable) return; @@ -116,11 +123,12 @@ class Home extends HookConsumerWidget { ref.read(shareProvider.notifier).autoStart(ref); }); return null; - }, const []); + }, [unboundedAvailable]); ref.listen(vpnProvider, (prev, next) { if (prev == next) return; if (next != VPNStatus.connected) return; + if (!unboundedAvailable) return; final autoEnable = ref.read(appSettingProvider).unboundedAutoEnable; if (!autoEnable) return; @@ -166,10 +174,11 @@ class Home extends HookConsumerWidget { onPressed: () => appRouter.push(const SignInEmail()), ), ], - // Tab strip collapses when the user has hidden the Unbounded - // tab in Unbounded Settings — with only one tab left, a strip - // is just noise. Body falls back to VpnTab directly. - bottom: unboundedHidden + // Tab strip collapses when Unbounded is unavailable — either the + // server flag is off (censored region) or the user hid the tab + // in Unbounded Settings. With only one tab left, a strip is just + // noise; body falls back to VpnTab directly. + bottom: !showUnboundedTab ? null : TabBar( controller: tabController, @@ -181,7 +190,7 @@ class Home extends HookConsumerWidget { ], ), ), - body: unboundedHidden + body: !showUnboundedTab ? const VpnTab() : TabBarView( controller: tabController, diff --git a/lib/features/setting/setting.dart b/lib/features/setting/setting.dart index eb507518aa..1b9b097521 100644 --- a/lib/features/setting/setting.dart +++ b/lib/features/setting/setting.dart @@ -7,7 +7,9 @@ import 'package:lantern/core/localization/localization_constants.dart'; import 'package:lantern/core/updater/updater.dart'; import 'package:lantern/core/utils/pro_utils.dart'; import 'package:lantern/core/widgets/subscription_tags.dart'; +import 'package:lantern/core/models/feature_flags.dart'; import 'package:lantern/features/home/provider/app_setting_notifier.dart'; +import 'package:lantern/features/home/provider/feature_flag_notifier.dart'; import 'package:lantern/features/home/provider/home_notifier.dart'; import 'package:lantern/features/plans/restore_purchase_mixin.dart'; import 'package:lantern/features/setting/appearance.dart' @@ -63,6 +65,11 @@ class _SettingState extends ConsumerState final email = ref.watch(userEmailProvider); final appSetting = ref.watch(appSettingProvider); + // Server-side gate. Censored regions get Features[unbounded]=false, + // and every Unbounded-flavoured row in this menu (the settings sub- + // page link AND the project promo card at the bottom) disappears. + final unboundedAvailable = + ref.watch(featureFlagProvider).getBool(FeatureFlag.unbounded); final hasProSession = (user?.legacyUserData.isPro ?? false) && @@ -144,13 +151,15 @@ class _SettingState extends ConsumerState icon: AppImagePaths.glob, onPressed: () => settingMenuTap(_SettingType.vpnSetting), ), - DividerSpace(), - AppTile( - label: 'Unbounded Settings', - icon: AppImagePaths.share, - onPressed: () => - settingMenuTap(_SettingType.unboundedSetting), - ), + if (unboundedAvailable) ...[ + DividerSpace(), + AppTile( + label: 'Unbounded Settings', + icon: AppImagePaths.share, + onPressed: () => + settingMenuTap(_SettingType.unboundedSetting), + ), + ], DividerSpace(), AppTile( label: 'language'.i18n, @@ -242,36 +251,38 @@ class _SettingState extends ConsumerState ), ), }, - const SizedBox(height: defaultSize), - Padding( - padding: const EdgeInsets.only(left: 16), - child: Text( - 'lantern_projects'.i18n, - style: textTheme.labelLarge!.copyWith( - color: context.textSecondary, + if (unboundedAvailable) ...[ + const SizedBox(height: defaultSize), + Padding( + padding: const EdgeInsets.only(left: 16), + child: Text( + 'lantern_projects'.i18n, + style: textTheme.labelLarge!.copyWith( + color: context.textSecondary, + ), ), ), - ), - const SizedBox(height: 4), - Card( - child: AppTile( - minHeight: 72, - icon: AppImagePaths.lanternLogoRounded, - iconUseThemeColor: false, - trailing: AppImage(path: AppImagePaths.outsideBrowser), - label: 'unbounded'.i18n, - subtitle: Text( - 'help_fight_global_internet_censorship'.i18n, - style: textTheme.labelMedium!.copyWith( - color: context.textTertiary, + const SizedBox(height: 4), + Card( + child: AppTile( + minHeight: 72, + icon: AppImagePaths.lanternLogoRounded, + iconUseThemeColor: false, + trailing: AppImage(path: AppImagePaths.outsideBrowser), + label: 'unbounded'.i18n, + subtitle: Text( + 'help_fight_global_internet_censorship'.i18n, + style: textTheme.labelMedium!.copyWith( + color: context.textTertiary, + ), ), + onPressed: () { + UrlUtils.openUrl(AppUrls.unbounded); + }, ), - onPressed: () { - UrlUtils.openUrl(AppUrls.unbounded); - }, ), - ), - SizedBox(height: defaultSize), + SizedBox(height: defaultSize), + ], ], ), ); From f0283518b255288a5fcdc5d7fde7a12097f38d66 Mon Sep 17 00:00:00 2001 From: Adam Fisk Date: Thu, 14 May 2026 15:43:53 -0600 Subject: [PATCH 13/26] share: render Lottie arrival heart-burst at native canvas size MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../share_my_connection/share_my_connection.dart | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/lib/features/share_my_connection/share_my_connection.dart b/lib/features/share_my_connection/share_my_connection.dart index 32e9a7349d..884f61bc5b 100644 --- a/lib/features/share_my_connection/share_my_connection.dart +++ b/lib/features/share_my_connection/share_my_connection.dart @@ -1283,15 +1283,19 @@ class _ArrivalCard extends StatelessWidget { alignment: Alignment.center, children: const [ // Lottie spreads upward + rightward from the heart. - // height is auto from BoxFit.contain on its 420-wide - // canvas; explosion.json is roughly square so ~420 - // tall, but most of that is above the heart due to - // the negative bottom offset. + // The size matches explosion.json's native 420×502 + // canvas — unbounded.lantern.io's CSS uses width:420 + // with height:auto for the same effect. Forcing the + // height to 420 (as we did before) scaled the + // animation down by ~83% via BoxFit.contain and lost + // ~82px of upward spread, leaving the hearts visibly + // smaller and clustered just above the pill instead + // of fanning out across the globe. Positioned( bottom: -55, left: -105, width: 420, - height: 420, + height: 502, child: _ArrivalLottie(), ), CustomPaint(painter: _HeartPainter()), From caba92d77ff08fb8e3d98ce7469489617a088d00 Mon Sep 17 00:00:00 2001 From: Adam Fisk Date: Thu, 14 May 2026 16:09:23 -0600 Subject: [PATCH 14/26] =?UTF-8?q?share:=20nudge=20heart-to-text=20gap=20fr?= =?UTF-8?q?om=2010=20=E2=86=92=2014=20px?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pill's static heart was sitting a touch close to the "H" in "Helping a new person in ". 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) --- lib/features/share_my_connection/share_my_connection.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/features/share_my_connection/share_my_connection.dart b/lib/features/share_my_connection/share_my_connection.dart index 884f61bc5b..f245ebd124 100644 --- a/lib/features/share_my_connection/share_my_connection.dart +++ b/lib/features/share_my_connection/share_my_connection.dart @@ -1302,7 +1302,7 @@ class _ArrivalCard extends StatelessWidget { ], ), ), - const SizedBox(width: 10), + const SizedBox(width: 14), // unbounded.lantern.io renders just `heart + text`, no flag // emoji — matching that exactly so the pill width stays in // bounds and the layout reads cleanly. flagEmoji is still From 74f58f09b848971cb3f02aaab9e872064787b70e Mon Sep 17 00:00:00 2001 From: Adam Fisk Date: Sun, 31 May 2026 16:41:02 -0600 Subject: [PATCH 15/26] smc: address Copilot review on #8820 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- assets/locales/en.po | 25 +++++++++-- lib/features/setting/setting.dart | 2 +- lib/features/setting/unbounded_setting.dart | 10 ++--- .../share_my_connection.dart | 45 ++++++++++--------- 4 files changed, 51 insertions(+), 31 deletions(-) diff --git a/assets/locales/en.po b/assets/locales/en.po index 0f7a1f5a6b..9c16c26f00 100644 --- a/assets/locales/en.po +++ b/assets/locales/en.po @@ -557,15 +557,16 @@ msgstr "Share My Connection" msgid "share_my_connection_subtitle" msgstr "Let other Lantern users route through your connection to bypass censorship." -msgid "share_my_connection_on_tap_to_view" -msgstr "On — tap to view" - # Unbounded tab — hero copy. Short variant for the tab embed; the # longer privacy explanation now lives in the welcome dialog # (showUnboundedWelcomeDialog) so the tab body stays scannable. msgid "smc_intro" msgstr "Help others bypass censorship by securely sharing your connection." +# Info-bubble tooltip on the Unbounded tab header +msgid "about_unbounded" +msgstr "About Unbounded" + # Status card — phase labels msgid "smc_status_label" msgstr "Status" @@ -680,6 +681,24 @@ msgstr "Basic mode (Unbounded)" msgid "smc_disclosure_full" msgstr "Full mode (SmC)" +# Unbounded Settings menu entry (Settings → Unbounded Settings) +msgid "unbounded_settings_title" +msgstr "Unbounded Settings" + +# Auto-enable Unbounded toggle +msgid "auto_enable_unbounded" +msgstr "Auto-enable Unbounded" + +msgid "auto_enable_unbounded_subtitle" +msgstr "Turn on automatically when Lantern is open" + +# Hide Unbounded toggle (collapses the Unbounded tab on the Home shell) +msgid "hide_unbounded" +msgstr "Hide Unbounded" + +msgid "hide_unbounded_subtitle" +msgstr "Removes Unbounded from the top of this screen" + msgid "vpn_connected" msgstr "Lantern is now connected." diff --git a/lib/features/setting/setting.dart b/lib/features/setting/setting.dart index 1b9b097521..7e7e5cb8bb 100644 --- a/lib/features/setting/setting.dart +++ b/lib/features/setting/setting.dart @@ -154,7 +154,7 @@ class _SettingState extends ConsumerState if (unboundedAvailable) ...[ DividerSpace(), AppTile( - label: 'Unbounded Settings', + label: 'unbounded_settings_title'.i18n, icon: AppImagePaths.share, onPressed: () => settingMenuTap(_SettingType.unboundedSetting), diff --git a/lib/features/setting/unbounded_setting.dart b/lib/features/setting/unbounded_setting.dart index 559b85d8e3..e8ac0cca64 100644 --- a/lib/features/setting/unbounded_setting.dart +++ b/lib/features/setting/unbounded_setting.dart @@ -30,7 +30,7 @@ class UnboundedSetting extends ConsumerWidget { final textTheme = Theme.of(context).textTheme; return BaseScreen( - title: 'Unbounded Settings', + title: 'unbounded_settings_title'.i18n, body: ListView( children: [ const SizedBox(height: 8), @@ -39,9 +39,9 @@ class UnboundedSetting extends ConsumerWidget { child: Column( children: [ AppTile( - label: 'Auto-enable Unbounded', + label: 'auto_enable_unbounded'.i18n, subtitle: Text( - 'Turn on automatically when Lantern is open', + 'auto_enable_unbounded_subtitle'.i18n, style: textTheme.labelMedium!.copyWith( color: context.textTertiary, letterSpacing: 0.0, @@ -57,9 +57,9 @@ class UnboundedSetting extends ConsumerWidget { ), DividerSpace(), AppTile( - label: 'Hide Unbounded', + label: 'hide_unbounded'.i18n, subtitle: Text( - 'Removes Unbounded from the top of this screen', + 'hide_unbounded_subtitle'.i18n, style: textTheme.labelMedium!.copyWith( color: context.textTertiary, letterSpacing: 0.0, diff --git a/lib/features/share_my_connection/share_my_connection.dart b/lib/features/share_my_connection/share_my_connection.dart index f245ebd124..e28a6c783c 100644 --- a/lib/features/share_my_connection/share_my_connection.dart +++ b/lib/features/share_my_connection/share_my_connection.dart @@ -305,27 +305,21 @@ class ShareNotifier extends Notifier { } /// Programmatic entry point used by the Home shell's auto-enable - /// listener (VPN-connected → Unbounded on). Mirrors `toggle()` but - /// skips the disclosure dialog because the user has already opted - /// in via the Unbounded Settings sheet. No-ops if already active or - /// in flight. + /// listener (VPN-connected → Unbounded on) and by the + /// "Auto-enable Unbounded" Settings toggle. + /// + /// Always starts Unbounded mode regardless of UPnP capability or + /// manual-port configuration. SmC requires the explicit-disclosure + /// dialog enforced by toggle(); auto-enabling SmC would silently + /// turn the user's device into a residential exit they never + /// agreed to act as. The auto path is the low-friction Unbounded + /// surface only. + /// + /// No-ops if already active or in flight. Future autoStart(WidgetRef widgetRef) async { if (state.active || state.probing) return; state = state.copyWith(probing: true); - final manualPortRes = - await widgetRef.read(lanternServiceProvider).getPeerManualPort(); - final manualPort = manualPortRes.fold((_) => 0, (p) => p); - if (manualPort > 0) { - await _start(widgetRef, ShareMode.smc); - return; - } - // MOCK UPnP probe — same as toggle(), pending real FFI. - await Future.delayed(const Duration(milliseconds: 1500)); - final upnpAvailable = Random().nextBool(); - await _start( - widgetRef, - upnpAvailable ? ShareMode.smc : ShareMode.unbounded, - ); + await _start(widgetRef, ShareMode.unbounded); } Future _start(WidgetRef widgetRef, ShareMode mode) async { @@ -670,6 +664,13 @@ class ShareNotifier extends Notifier { // Constructs ShareState directly (rather than copyWith) so errorMessage // gets cleared — copyWith's `?? this.errorMessage` keeps the previous // SmC failure string around otherwise. + // + // Event subscription: deliberately does NOT call _startEventSubscription. + // The error path arrives here via _handlePeerStatus which is already + // inside the subscription started by the prior _start; flipping the + // local state.mode keeps the same subscription forwarding events for + // the new (Unbounded) mode. _stop is the only teardown path for the + // subscription, and the error path doesn't go through _stop. Future _fallbackToUnbounded(WidgetRef widgetRef) async { state = ShareState( active: true, @@ -1806,11 +1807,11 @@ class _UnboundedWelcomeDialog extends StatelessWidget { ), const SizedBox(height: 16), Text( - "When you enable Unbounded, your device becomes part of a " + 'When you enable Unbounded, your device becomes part of a ' "network of 'digital bridges' to the open internet. " - "Censored users connect to these bridges, allowing them " - "to bypass government-imposed restrictors and access the " - "information they need.", + 'Censored users connect to these bridges, allowing them ' + 'to bypass government-imposed restrictions and access the ' + 'information they need.', style: textTheme.bodyMedium, ), const SizedBox(height: 12), From 9024906b300f4d622737d5388fd8ce7d3a999776 Mon Sep 17 00:00:00 2001 From: Adam Fisk Date: Tue, 2 Jun 2026 01:10:58 -0600 Subject: [PATCH 16/26] review: i18n the Unbounded UI + preserve totalCount on idle reset MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses Copilot review on #8820 (commit 74f58f0): - Preserve totalCount when radiance reports SmC phase=idle. The rebase combined #8819's idle-handling with #8820's lifetime totalCount semantics; the literal const ShareState() reset on idle clobbered totalCount until the next app restart re-seeded it from disk. Now constructs ShareState with totalCount carried over, matching the pattern in _stop. - Guard _ArrivalLottie.onLoaded against mounted=false and dispose any prior AnimationController before reassigning. Lottie can fire onLoaded after dispose if the surrounding _ArrivalCard rebuilds rapidly mid-load. Mirrors the fix already on _BurstAnimation. - Localize "Waiting for connections..." pill via new unbounded_waiting_for_connections key. - Localize Welcome dialog title and 3 body paragraphs via unbounded_welcome_title / unbounded_welcome_body_{1,2,3}. - Localize the Got It button via existing got_it key. Remove the Learn More button — its onPressed was a no-op TODO and a dead control reads worse than no control. Will be reintroduced pointing at AppUrls.unbounded once that URL exists. - Localize Home tab labels VPN / Unbounded via new vpn key plus existing unbounded key. Co-Authored-By: Claude Opus 4.7 --- assets/locales/en.po | 23 ++++++++++ lib/features/home/home.dart | 6 ++- .../share_my_connection.dart | 44 +++++++++---------- 3 files changed, 49 insertions(+), 24 deletions(-) diff --git a/assets/locales/en.po b/assets/locales/en.po index 9c16c26f00..c2e19f9d7d 100644 --- a/assets/locales/en.po +++ b/assets/locales/en.po @@ -57,6 +57,11 @@ msgstr "Share Referral Code" msgid "unbounded" msgstr "Unbounded" +# Tab label shown alongside the Unbounded tab on the Home screen when +# the dual-tab strip is active. +msgid "vpn" +msgstr "VPN" + msgid "help_fight_global_internet_censorship" msgstr "Help Fight Global Internet Censorship" @@ -628,6 +633,24 @@ msgstr "Most connections are short liveness probes — Lantern clients periodica msgid "smc_arrival_toast" msgstr "Helping a new person in %s" +# Pill shown beneath the globe when Unbounded is active but no peer is +# currently routing through this device. +msgid "unbounded_waiting_for_connections" +msgstr "Waiting for connections..." + +# Welcome dialog shown the first time the Unbounded tab opens. +msgid "unbounded_welcome_title" +msgstr "Welcome to Unbounded" + +msgid "unbounded_welcome_body_1" +msgstr "When you enable Unbounded, your device becomes part of a network of 'digital bridges' to the open internet. Censored users connect to these bridges, allowing them to bypass government-imposed restrictions and access the information they need." + +msgid "unbounded_welcome_body_2" +msgstr "This collective effort makes censorship harder to enforce, expanding access to the open internet." + +msgid "unbounded_welcome_body_3" +msgstr "You can remove Unbounded from the interface anytime in Settings." + # Advanced section / manual port forward msgid "smc_advanced" msgstr "Advanced" diff --git a/lib/features/home/home.dart b/lib/features/home/home.dart index ed0465d9b7..2b8f88ad5e 100644 --- a/lib/features/home/home.dart +++ b/lib/features/home/home.dart @@ -184,9 +184,11 @@ class Home extends HookConsumerWidget { controller: tabController, tabs: [ _TabLabel( - label: 'VPN', + label: 'vpn'.i18n, active: vpnStatus == VPNStatus.connected), - _TabLabel(label: 'Unbounded', active: shareActive), + _TabLabel( + label: 'unbounded'.i18n, + active: shareActive), ], ), ), diff --git a/lib/features/share_my_connection/share_my_connection.dart b/lib/features/share_my_connection/share_my_connection.dart index e28a6c783c..c7e4e76d28 100644 --- a/lib/features/share_my_connection/share_my_connection.dart +++ b/lib/features/share_my_connection/share_my_connection.dart @@ -643,7 +643,11 @@ class ShareNotifier extends Notifier { } if (phase == SharePhase.idle && state.mode == ShareMode.smc) { _stopEventSubscription(); - state = const ShareState(); + // Preserve totalCount across the radiance-driven idle reset — + // lifetime running total is persisted via appSettingProvider and + // would otherwise be zeroed until the next app restart re-seeds + // it from disk. + state = ShareState(totalCount: state.totalCount); return; } state = state.copyWith( @@ -1349,6 +1353,12 @@ class _ArrivalLottieState extends State<_ArrivalLottie> repeat: false, fit: BoxFit.contain, onLoaded: (composition) { + // Lottie can fire onLoaded after dispose if the surrounding + // _ArrivalCard rebuilds rapidly (worker swap mid-load). Guard + // mounted and discard any controller that was already attached + // so we don't leak a ticker on rebuild. + if (!mounted) return; + _ctrl?.dispose(); _ctrl = AnimationController( vsync: this, duration: composition.duration, @@ -1377,7 +1387,7 @@ class _WaitingCard extends StatelessWidget { border: Border.all(color: Colors.black12), ), child: Text( - 'Waiting for connections...', + 'unbounded_waiting_for_connections'.i18n, style: TextStyle( fontSize: 13, fontWeight: FontWeight.w500, @@ -1799,7 +1809,7 @@ class _UnboundedWelcomeDialog extends StatelessWidget { const SizedBox(height: 16), Center( child: Text( - 'Welcome to Unbounded', + 'unbounded_welcome_title'.i18n, style: textTheme.titleLarge?.copyWith( fontWeight: FontWeight.w600, ), @@ -1807,40 +1817,30 @@ class _UnboundedWelcomeDialog extends StatelessWidget { ), const SizedBox(height: 16), Text( - 'When you enable Unbounded, your device becomes part of a ' - "network of 'digital bridges' to the open internet. " - 'Censored users connect to these bridges, allowing them ' - 'to bypass government-imposed restrictions and access the ' - 'information they need.', + 'unbounded_welcome_body_1'.i18n, style: textTheme.bodyMedium, ), const SizedBox(height: 12), Text( - 'This collective effort makes censorship harder to ' - 'enforce, expanding access to the open internet.', + 'unbounded_welcome_body_2'.i18n, style: textTheme.bodyMedium, ), const SizedBox(height: 12), Text( - 'You can remove Unbounded from the interface anytime in ' - 'Settings.', + 'unbounded_welcome_body_3'.i18n, style: textTheme.bodyMedium, ), const SizedBox(height: 16), + // No "Learn more" button until the explainer URL is wired + // (will be re-added pointing at AppUrls.unbounded). Showing + // a button with an empty onPressed in production reads as a + // dead control. Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, + mainAxisAlignment: MainAxisAlignment.end, children: [ - TextButton.icon( - onPressed: () { - // TODO: deep-link to the Unbounded explainer page - // once the URL is wired (AppUrls.unbounded?). - }, - icon: const Icon(Icons.open_in_new, size: 14), - label: const Text('Learn more'), - ), TextButton( onPressed: () => Navigator.of(context).pop(), - child: const Text('Got it'), + child: Text('got_it'.i18n), ), ], ), From de0b5b681f1722ba30093dd10ee3705a753c059d Mon Sep 17 00:00:00 2001 From: Adam Fisk Date: Tue, 2 Jun 2026 01:47:55 -0600 Subject: [PATCH 17/26] review: preserve totalCount on Start error, remove dead Lottie layer, AutoRoute UnboundedSetting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses Copilot review on #8820 (commit 9024906): - _start() error branches in both SmC and Unbounded modes were resetting totalCount to 0 on Start failure, defeating the persisted lifetime-total semantics. Now carries state.totalCount through, matching the idle-reset and _stop paths. - Remove _LottieBurstLayer / _BurstAnimation entirely (~95 lines). These were never wired into the globe stack — the actual per-arrival heart burst is _ArrivalLottie inside _ArrivalCard. They were left over from a prior rebase merge and were carrying their own dispose-race guard, peer-event subscription, and AnimationController plumbing for no consumer. - Register UnboundedSetting in AutoRoute and navigate via appRouter.push(UnboundedSetting()) from the Settings list, replacing the lone MaterialPageRoute (which bypassed route-level guards/transitions vs. every other Settings entry). Co-Authored-By: Claude Opus 4.7 --- lib/core/router/router.dart | 4 + lib/core/router/router.gr.dart | 405 +++++++++--------- lib/features/setting/setting.dart | 5 +- lib/features/setting/unbounded_setting.dart | 2 + .../share_my_connection.dart | 110 +---- 5 files changed, 225 insertions(+), 301 deletions(-) diff --git a/lib/core/router/router.dart b/lib/core/router/router.dart index 6bb8c84719..b8f0dc852d 100644 --- a/lib/core/router/router.dart +++ b/lib/core/router/router.dart @@ -41,6 +41,10 @@ class AppRouter extends RootStackRouter { path: '/vpn-setting', page: VPNSetting.page, ), + AutoRoute( + path: '/unbounded-setting', + page: UnboundedSetting.page, + ), AutoRoute( path: '/account', page: Account.page, diff --git a/lib/core/router/router.gr.dart b/lib/core/router/router.gr.dart index 4f9329ba4f..1e85062389 100644 --- a/lib/core/router/router.gr.dart +++ b/lib/core/router/router.gr.dart @@ -9,11 +9,11 @@ // coverage:ignore-file // ignore_for_file: no_leading_underscores_for_library_prefixes -import 'package:auto_route/auto_route.dart' as _i44; -import 'package:collection/collection.dart' as _i48; -import 'package:flutter/material.dart' as _i45; -import 'package:lantern/core/common/common.dart' as _i46; -import 'package:lantern/core/models/user.dart' as _i47; +import 'package:auto_route/auto_route.dart' as _i45; +import 'package:collection/collection.dart' as _i49; +import 'package:flutter/material.dart' as _i46; +import 'package:lantern/core/common/common.dart' as _i47; +import 'package:lantern/core/models/user.dart' as _i48; import 'package:lantern/core/widgets/app_webview.dart' as _i3; import 'package:lantern/features/account/account.dart' as _i1; import 'package:lantern/features/account/delete_account.dart' as _i9; @@ -59,26 +59,27 @@ import 'package:lantern/features/setting/follow_us.dart' as _i13; import 'package:lantern/features/setting/invite_friends.dart' as _i15; import 'package:lantern/features/setting/setting.dart' as _i35; import 'package:lantern/features/setting/smart_routing.dart' as _i38; -import 'package:lantern/features/setting/vpn_setting.dart' as _i42; +import 'package:lantern/features/setting/unbounded_setting.dart' as _i42; +import 'package:lantern/features/setting/vpn_setting.dart' as _i43; import 'package:lantern/features/split_tunneling/apps_split_tunneling.dart' as _i5; import 'package:lantern/features/split_tunneling/split_tunneling.dart' as _i39; import 'package:lantern/features/split_tunneling/split_tunneling_info.dart' as _i40; import 'package:lantern/features/split_tunneling/website_split_tunneling.dart' - as _i43; + as _i44; import 'package:lantern/features/support/support.dart' as _i41; import 'package:lantern/features/vpn/server_selection.dart' as _i34; /// generated route for /// [_i1.Account] -class Account extends _i44.PageRouteInfo { - const Account({List<_i44.PageRouteInfo>? children}) +class Account extends _i45.PageRouteInfo { + const Account({List<_i45.PageRouteInfo>? children}) : super(Account.name, initialChildren: children); static const String name = 'Account'; - static _i44.PageInfo page = _i44.PageInfo( + static _i45.PageInfo page = _i45.PageInfo( name, builder: (data) { return const _i1.Account(); @@ -88,12 +89,12 @@ class Account extends _i44.PageRouteInfo { /// generated route for /// [_i2.AddEmail] -class AddEmail extends _i44.PageRouteInfo { +class AddEmail extends _i45.PageRouteInfo { AddEmail({ - _i45.Key? key, - _i46.AuthFlow authFlow = _i46.AuthFlow.signUp, + _i46.Key? key, + _i47.AuthFlow authFlow = _i47.AuthFlow.signUp, String? password, - List<_i44.PageRouteInfo>? children, + List<_i45.PageRouteInfo>? children, }) : super( AddEmail.name, args: AddEmailArgs(key: key, authFlow: authFlow, password: password), @@ -102,7 +103,7 @@ class AddEmail extends _i44.PageRouteInfo { static const String name = 'AddEmail'; - static _i44.PageInfo page = _i44.PageInfo( + static _i45.PageInfo page = _i45.PageInfo( name, builder: (data) { final args = data.argsAs( @@ -120,13 +121,13 @@ class AddEmail extends _i44.PageRouteInfo { class AddEmailArgs { const AddEmailArgs({ this.key, - this.authFlow = _i46.AuthFlow.signUp, + this.authFlow = _i47.AuthFlow.signUp, this.password, }); - final _i45.Key? key; + final _i46.Key? key; - final _i46.AuthFlow authFlow; + final _i47.AuthFlow authFlow; final String? password; @@ -150,12 +151,12 @@ class AddEmailArgs { /// generated route for /// [_i3.AppWebView] -class AppWebview extends _i44.PageRouteInfo { +class AppWebview extends _i45.PageRouteInfo { AppWebview({ - _i45.Key? key, + _i46.Key? key, required String title, required String url, - List<_i44.PageRouteInfo>? children, + List<_i45.PageRouteInfo>? children, }) : super( AppWebview.name, args: AppWebviewArgs(key: key, title: title, url: url), @@ -164,7 +165,7 @@ class AppWebview extends _i44.PageRouteInfo { static const String name = 'AppWebview'; - static _i44.PageInfo page = _i44.PageInfo( + static _i45.PageInfo page = _i45.PageInfo( name, builder: (data) { final args = data.argsAs(); @@ -176,7 +177,7 @@ class AppWebview extends _i44.PageRouteInfo { class AppWebviewArgs { const AppWebviewArgs({this.key, required this.title, required this.url}); - final _i45.Key? key; + final _i46.Key? key; final String title; @@ -200,13 +201,13 @@ class AppWebviewArgs { /// generated route for /// [_i4.Appearance] -class Appearance extends _i44.PageRouteInfo { - const Appearance({List<_i44.PageRouteInfo>? children}) +class Appearance extends _i45.PageRouteInfo { + const Appearance({List<_i45.PageRouteInfo>? children}) : super(Appearance.name, initialChildren: children); static const String name = 'Appearance'; - static _i44.PageInfo page = _i44.PageInfo( + static _i45.PageInfo page = _i45.PageInfo( name, builder: (data) { return const _i4.Appearance(); @@ -216,13 +217,13 @@ class Appearance extends _i44.PageRouteInfo { /// generated route for /// [_i5.AppsSplitTunneling] -class AppsSplitTunneling extends _i44.PageRouteInfo { - const AppsSplitTunneling({List<_i44.PageRouteInfo>? children}) +class AppsSplitTunneling extends _i45.PageRouteInfo { + const AppsSplitTunneling({List<_i45.PageRouteInfo>? children}) : super(AppsSplitTunneling.name, initialChildren: children); static const String name = 'AppsSplitTunneling'; - static _i44.PageInfo page = _i44.PageInfo( + static _i45.PageInfo page = _i45.PageInfo( name, builder: (data) { return const _i5.AppsSplitTunneling(); @@ -232,13 +233,13 @@ class AppsSplitTunneling extends _i44.PageRouteInfo { /// generated route for /// [_i6.ChoosePaymentMethod] -class ChoosePaymentMethod extends _i44.PageRouteInfo { +class ChoosePaymentMethod extends _i45.PageRouteInfo { ChoosePaymentMethod({ - _i45.Key? key, + _i46.Key? key, required String email, String? code, - required _i46.AuthFlow authFlow, - List<_i44.PageRouteInfo>? children, + required _i47.AuthFlow authFlow, + List<_i45.PageRouteInfo>? children, }) : super( ChoosePaymentMethod.name, args: ChoosePaymentMethodArgs( @@ -252,7 +253,7 @@ class ChoosePaymentMethod extends _i44.PageRouteInfo { static const String name = 'ChoosePaymentMethod'; - static _i44.PageInfo page = _i44.PageInfo( + static _i45.PageInfo page = _i45.PageInfo( name, builder: (data) { final args = data.argsAs(); @@ -274,13 +275,13 @@ class ChoosePaymentMethodArgs { required this.authFlow, }); - final _i45.Key? key; + final _i46.Key? key; final String email; final String? code; - final _i46.AuthFlow authFlow; + final _i47.AuthFlow authFlow; @override String toString() { @@ -304,13 +305,13 @@ class ChoosePaymentMethodArgs { /// generated route for /// [_i7.ConfirmEmail] -class ConfirmEmail extends _i44.PageRouteInfo { +class ConfirmEmail extends _i45.PageRouteInfo { ConfirmEmail({ - _i45.Key? key, + _i46.Key? key, required String email, String? password, - _i46.AuthFlow authFlow = _i46.AuthFlow.signUp, - List<_i44.PageRouteInfo>? children, + _i47.AuthFlow authFlow = _i47.AuthFlow.signUp, + List<_i45.PageRouteInfo>? children, }) : super( ConfirmEmail.name, args: ConfirmEmailArgs( @@ -324,7 +325,7 @@ class ConfirmEmail extends _i44.PageRouteInfo { static const String name = 'ConfirmEmail'; - static _i44.PageInfo page = _i44.PageInfo( + static _i45.PageInfo page = _i45.PageInfo( name, builder: (data) { final args = data.argsAs(); @@ -343,16 +344,16 @@ class ConfirmEmailArgs { this.key, required this.email, this.password, - this.authFlow = _i46.AuthFlow.signUp, + this.authFlow = _i47.AuthFlow.signUp, }); - final _i45.Key? key; + final _i46.Key? key; final String email; final String? password; - final _i46.AuthFlow authFlow; + final _i47.AuthFlow authFlow; @override String toString() { @@ -376,13 +377,13 @@ class ConfirmEmailArgs { /// generated route for /// [_i8.CreatePassword] -class CreatePassword extends _i44.PageRouteInfo { +class CreatePassword extends _i45.PageRouteInfo { CreatePassword({ - _i45.Key? key, + _i46.Key? key, required String email, - required _i46.AuthFlow authFlow, + required _i47.AuthFlow authFlow, required String code, - List<_i44.PageRouteInfo>? children, + List<_i45.PageRouteInfo>? children, }) : super( CreatePassword.name, args: CreatePasswordArgs( @@ -396,7 +397,7 @@ class CreatePassword extends _i44.PageRouteInfo { static const String name = 'CreatePassword'; - static _i44.PageInfo page = _i44.PageInfo( + static _i45.PageInfo page = _i45.PageInfo( name, builder: (data) { final args = data.argsAs(); @@ -418,11 +419,11 @@ class CreatePasswordArgs { required this.code, }); - final _i45.Key? key; + final _i46.Key? key; final String email; - final _i46.AuthFlow authFlow; + final _i47.AuthFlow authFlow; final String code; @@ -448,13 +449,13 @@ class CreatePasswordArgs { /// generated route for /// [_i9.DeleteAccount] -class DeleteAccount extends _i44.PageRouteInfo { - const DeleteAccount({List<_i44.PageRouteInfo>? children}) +class DeleteAccount extends _i45.PageRouteInfo { + const DeleteAccount({List<_i45.PageRouteInfo>? children}) : super(DeleteAccount.name, initialChildren: children); static const String name = 'DeleteAccount'; - static _i44.PageInfo page = _i44.PageInfo( + static _i45.PageInfo page = _i45.PageInfo( name, builder: (data) { return const _i9.DeleteAccount(); @@ -464,13 +465,13 @@ class DeleteAccount extends _i44.PageRouteInfo { /// generated route for /// [_i10.DeveloperMode] -class DeveloperMode extends _i44.PageRouteInfo { - const DeveloperMode({List<_i44.PageRouteInfo>? children}) +class DeveloperMode extends _i45.PageRouteInfo { + const DeveloperMode({List<_i45.PageRouteInfo>? children}) : super(DeveloperMode.name, initialChildren: children); static const String name = 'DeveloperMode'; - static _i44.PageInfo page = _i44.PageInfo( + static _i45.PageInfo page = _i45.PageInfo( name, builder: (data) { return const _i10.DeveloperMode(); @@ -480,11 +481,11 @@ class DeveloperMode extends _i44.PageRouteInfo { /// generated route for /// [_i11.DeviceLimitReached] -class DeviceLimitReached extends _i44.PageRouteInfo { +class DeviceLimitReached extends _i45.PageRouteInfo { DeviceLimitReached({ - _i45.Key? key, - required List<_i47.DeviceModel> devices, - List<_i44.PageRouteInfo>? children, + _i46.Key? key, + required List<_i48.DeviceModel> devices, + List<_i45.PageRouteInfo>? children, }) : super( DeviceLimitReached.name, args: DeviceLimitReachedArgs(key: key, devices: devices), @@ -493,7 +494,7 @@ class DeviceLimitReached extends _i44.PageRouteInfo { static const String name = 'DeviceLimitReached'; - static _i44.PageInfo page = _i44.PageInfo( + static _i45.PageInfo page = _i45.PageInfo( name, builder: (data) { final args = data.argsAs(); @@ -505,9 +506,9 @@ class DeviceLimitReached extends _i44.PageRouteInfo { class DeviceLimitReachedArgs { const DeviceLimitReachedArgs({this.key, required this.devices}); - final _i45.Key? key; + final _i46.Key? key; - final List<_i47.DeviceModel> devices; + final List<_i48.DeviceModel> devices; @override String toString() { @@ -519,7 +520,7 @@ class DeviceLimitReachedArgs { if (identical(this, other)) return true; if (other is! DeviceLimitReachedArgs) return false; return key == other.key && - const _i48.ListEquality<_i47.DeviceModel>().equals( + const _i49.ListEquality<_i48.DeviceModel>().equals( devices, other.devices, ); @@ -527,18 +528,18 @@ class DeviceLimitReachedArgs { @override int get hashCode => - key.hashCode ^ const _i48.ListEquality<_i47.DeviceModel>().hash(devices); + key.hashCode ^ const _i49.ListEquality<_i48.DeviceModel>().hash(devices); } /// generated route for /// [_i12.DownloadLinks] -class DownloadLinks extends _i44.PageRouteInfo { - const DownloadLinks({List<_i44.PageRouteInfo>? children}) +class DownloadLinks extends _i45.PageRouteInfo { + const DownloadLinks({List<_i45.PageRouteInfo>? children}) : super(DownloadLinks.name, initialChildren: children); static const String name = 'DownloadLinks'; - static _i44.PageInfo page = _i44.PageInfo( + static _i45.PageInfo page = _i45.PageInfo( name, builder: (data) { return const _i12.DownloadLinks(); @@ -548,13 +549,13 @@ class DownloadLinks extends _i44.PageRouteInfo { /// generated route for /// [_i13.FollowUs] -class FollowUs extends _i44.PageRouteInfo { - const FollowUs({List<_i44.PageRouteInfo>? children}) +class FollowUs extends _i45.PageRouteInfo { + const FollowUs({List<_i45.PageRouteInfo>? children}) : super(FollowUs.name, initialChildren: children); static const String name = 'FollowUs'; - static _i44.PageInfo page = _i44.PageInfo( + static _i45.PageInfo page = _i45.PageInfo( name, builder: (data) { return const _i13.FollowUs(); @@ -564,13 +565,13 @@ class FollowUs extends _i44.PageRouteInfo { /// generated route for /// [_i14.Home] -class Home extends _i44.PageRouteInfo { - const Home({List<_i44.PageRouteInfo>? children}) +class Home extends _i45.PageRouteInfo { + const Home({List<_i45.PageRouteInfo>? children}) : super(Home.name, initialChildren: children); static const String name = 'Home'; - static _i44.PageInfo page = _i44.PageInfo( + static _i45.PageInfo page = _i45.PageInfo( name, builder: (data) { return const _i14.Home(); @@ -580,13 +581,13 @@ class Home extends _i44.PageRouteInfo { /// generated route for /// [_i15.InviteFriends] -class InviteFriends extends _i44.PageRouteInfo { - const InviteFriends({List<_i44.PageRouteInfo>? children}) +class InviteFriends extends _i45.PageRouteInfo { + const InviteFriends({List<_i45.PageRouteInfo>? children}) : super(InviteFriends.name, initialChildren: children); static const String name = 'InviteFriends'; - static _i44.PageInfo page = _i44.PageInfo( + static _i45.PageInfo page = _i45.PageInfo( name, builder: (data) { return const _i15.InviteFriends(); @@ -596,11 +597,11 @@ class InviteFriends extends _i44.PageRouteInfo { /// generated route for /// [_i16.JoinPrivateServer] -class JoinPrivateServer extends _i44.PageRouteInfo { +class JoinPrivateServer extends _i45.PageRouteInfo { JoinPrivateServer({ - _i45.Key? key, + _i46.Key? key, Map? deepLinkData, - List<_i44.PageRouteInfo>? children, + List<_i45.PageRouteInfo>? children, }) : super( JoinPrivateServer.name, args: JoinPrivateServerArgs(key: key, deepLinkData: deepLinkData), @@ -609,7 +610,7 @@ class JoinPrivateServer extends _i44.PageRouteInfo { static const String name = 'JoinPrivateServer'; - static _i44.PageInfo page = _i44.PageInfo( + static _i45.PageInfo page = _i45.PageInfo( name, builder: (data) { final args = data.argsAs( @@ -626,7 +627,7 @@ class JoinPrivateServer extends _i44.PageRouteInfo { class JoinPrivateServerArgs { const JoinPrivateServerArgs({this.key, this.deepLinkData}); - final _i45.Key? key; + final _i46.Key? key; final Map? deepLinkData; @@ -640,7 +641,7 @@ class JoinPrivateServerArgs { if (identical(this, other)) return true; if (other is! JoinPrivateServerArgs) return false; return key == other.key && - const _i48.MapEquality().equals( + const _i49.MapEquality().equals( deepLinkData, other.deepLinkData, ); @@ -649,18 +650,18 @@ class JoinPrivateServerArgs { @override int get hashCode => key.hashCode ^ - const _i48.MapEquality().hash(deepLinkData); + const _i49.MapEquality().hash(deepLinkData); } /// generated route for /// [_i17.Language] -class Language extends _i44.PageRouteInfo { - const Language({List<_i44.PageRouteInfo>? children}) +class Language extends _i45.PageRouteInfo { + const Language({List<_i45.PageRouteInfo>? children}) : super(Language.name, initialChildren: children); static const String name = 'Language'; - static _i44.PageInfo page = _i44.PageInfo( + static _i45.PageInfo page = _i45.PageInfo( name, builder: (data) { return const _i17.Language(); @@ -670,12 +671,12 @@ class Language extends _i44.PageRouteInfo { /// generated route for /// [_i18.LanternProLicense] -class LanternProLicense extends _i44.PageRouteInfo { +class LanternProLicense extends _i45.PageRouteInfo { LanternProLicense({ - _i45.Key? key, + _i46.Key? key, required String email, required String code, - List<_i44.PageRouteInfo>? children, + List<_i45.PageRouteInfo>? children, }) : super( LanternProLicense.name, args: LanternProLicenseArgs(key: key, email: email, code: code), @@ -684,7 +685,7 @@ class LanternProLicense extends _i44.PageRouteInfo { static const String name = 'LanternProLicense'; - static _i44.PageInfo page = _i44.PageInfo( + static _i45.PageInfo page = _i45.PageInfo( name, builder: (data) { final args = data.argsAs(); @@ -704,7 +705,7 @@ class LanternProLicenseArgs { required this.code, }); - final _i45.Key? key; + final _i46.Key? key; final String email; @@ -728,13 +729,13 @@ class LanternProLicenseArgs { /// generated route for /// [_i19.Logs] -class Logs extends _i44.PageRouteInfo { - const Logs({List<_i44.PageRouteInfo>? children}) +class Logs extends _i45.PageRouteInfo { + const Logs({List<_i45.PageRouteInfo>? children}) : super(Logs.name, initialChildren: children); static const String name = 'Logs'; - static _i44.PageInfo page = _i44.PageInfo( + static _i45.PageInfo page = _i45.PageInfo( name, builder: (data) { return const _i19.Logs(); @@ -744,13 +745,13 @@ class Logs extends _i44.PageRouteInfo { /// generated route for /// [_i20.MacOSExtensionDialog] -class MacOSExtensionDialog extends _i44.PageRouteInfo { - const MacOSExtensionDialog({List<_i44.PageRouteInfo>? children}) +class MacOSExtensionDialog extends _i45.PageRouteInfo { + const MacOSExtensionDialog({List<_i45.PageRouteInfo>? children}) : super(MacOSExtensionDialog.name, initialChildren: children); static const String name = 'MacOSExtensionDialog'; - static _i44.PageInfo page = _i44.PageInfo( + static _i45.PageInfo page = _i45.PageInfo( name, builder: (data) { return const _i20.MacOSExtensionDialog(); @@ -760,13 +761,13 @@ class MacOSExtensionDialog extends _i44.PageRouteInfo { /// generated route for /// [_i21.ManagePrivateServer] -class ManagePrivateServer extends _i44.PageRouteInfo { - const ManagePrivateServer({List<_i44.PageRouteInfo>? children}) +class ManagePrivateServer extends _i45.PageRouteInfo { + const ManagePrivateServer({List<_i45.PageRouteInfo>? children}) : super(ManagePrivateServer.name, initialChildren: children); static const String name = 'ManagePrivateServer'; - static _i44.PageInfo page = _i44.PageInfo( + static _i45.PageInfo page = _i45.PageInfo( name, builder: (data) { return const _i21.ManagePrivateServer(); @@ -776,13 +777,13 @@ class ManagePrivateServer extends _i44.PageRouteInfo { /// generated route for /// [_i22.ManuallyServerSetup] -class ManuallyServerSetup extends _i44.PageRouteInfo { - const ManuallyServerSetup({List<_i44.PageRouteInfo>? children}) +class ManuallyServerSetup extends _i45.PageRouteInfo { + const ManuallyServerSetup({List<_i45.PageRouteInfo>? children}) : super(ManuallyServerSetup.name, initialChildren: children); static const String name = 'ManuallyServerSetup'; - static _i44.PageInfo page = _i44.PageInfo( + static _i45.PageInfo page = _i45.PageInfo( name, builder: (data) { return const _i22.ManuallyServerSetup(); @@ -792,13 +793,13 @@ class ManuallyServerSetup extends _i44.PageRouteInfo { /// generated route for /// [_i23.Onboarding] -class Onboarding extends _i44.PageRouteInfo { - const Onboarding({List<_i44.PageRouteInfo>? children}) +class Onboarding extends _i45.PageRouteInfo { + const Onboarding({List<_i45.PageRouteInfo>? children}) : super(Onboarding.name, initialChildren: children); static const String name = 'Onboarding'; - static _i44.PageInfo page = _i44.PageInfo( + static _i45.PageInfo page = _i45.PageInfo( name, builder: (data) { return const _i23.Onboarding(); @@ -808,13 +809,13 @@ class Onboarding extends _i44.PageRouteInfo { /// generated route for /// [_i24.Plans] -class Plans extends _i44.PageRouteInfo { - const Plans({List<_i44.PageRouteInfo>? children}) +class Plans extends _i45.PageRouteInfo { + const Plans({List<_i45.PageRouteInfo>? children}) : super(Plans.name, initialChildren: children); static const String name = 'Plans'; - static _i44.PageInfo page = _i44.PageInfo( + static _i45.PageInfo page = _i45.PageInfo( name, builder: (data) { return const _i24.Plans(); @@ -824,13 +825,13 @@ class Plans extends _i44.PageRouteInfo { /// generated route for /// [_i25.PrivateServerAddBilling] -class PrivateServerAddBilling extends _i44.PageRouteInfo { - const PrivateServerAddBilling({List<_i44.PageRouteInfo>? children}) +class PrivateServerAddBilling extends _i45.PageRouteInfo { + const PrivateServerAddBilling({List<_i45.PageRouteInfo>? children}) : super(PrivateServerAddBilling.name, initialChildren: children); static const String name = 'PrivateServerAddBilling'; - static _i44.PageInfo page = _i44.PageInfo( + static _i45.PageInfo page = _i45.PageInfo( name, builder: (data) { return const _i25.PrivateServerAddBilling(); @@ -840,11 +841,11 @@ class PrivateServerAddBilling extends _i44.PageRouteInfo { /// generated route for /// [_i26.PrivateServerDeploy] -class PrivateServerDeploy extends _i44.PageRouteInfo { +class PrivateServerDeploy extends _i45.PageRouteInfo { PrivateServerDeploy({ - _i45.Key? key, + _i46.Key? key, required String serverName, - List<_i44.PageRouteInfo>? children, + List<_i45.PageRouteInfo>? children, }) : super( PrivateServerDeploy.name, args: PrivateServerDeployArgs(key: key, serverName: serverName), @@ -853,7 +854,7 @@ class PrivateServerDeploy extends _i44.PageRouteInfo { static const String name = 'PrivateServerDeploy'; - static _i44.PageInfo page = _i44.PageInfo( + static _i45.PageInfo page = _i45.PageInfo( name, builder: (data) { final args = data.argsAs(); @@ -868,7 +869,7 @@ class PrivateServerDeploy extends _i44.PageRouteInfo { class PrivateServerDeployArgs { const PrivateServerDeployArgs({this.key, required this.serverName}); - final _i45.Key? key; + final _i46.Key? key; final String serverName; @@ -891,14 +892,14 @@ class PrivateServerDeployArgs { /// generated route for /// [_i27.PrivateServerLocation] class PrivateServerLocation - extends _i44.PageRouteInfo { + extends _i45.PageRouteInfo { PrivateServerLocation({ - _i45.Key? key, + _i46.Key? key, required List location, required String? selectedLocation, required dynamic Function(String) onLocationSelected, - required _i46.CloudProvider provider, - List<_i44.PageRouteInfo>? children, + required _i47.CloudProvider provider, + List<_i45.PageRouteInfo>? children, }) : super( PrivateServerLocation.name, args: PrivateServerLocationArgs( @@ -913,7 +914,7 @@ class PrivateServerLocation static const String name = 'PrivateServerLocation'; - static _i44.PageInfo page = _i44.PageInfo( + static _i45.PageInfo page = _i45.PageInfo( name, builder: (data) { final args = data.argsAs(); @@ -937,7 +938,7 @@ class PrivateServerLocationArgs { required this.provider, }); - final _i45.Key? key; + final _i46.Key? key; final List location; @@ -945,7 +946,7 @@ class PrivateServerLocationArgs { final dynamic Function(String) onLocationSelected; - final _i46.CloudProvider provider; + final _i47.CloudProvider provider; @override String toString() { @@ -957,7 +958,7 @@ class PrivateServerLocationArgs { if (identical(this, other)) return true; if (other is! PrivateServerLocationArgs) return false; return key == other.key && - const _i48.ListEquality().equals(location, other.location) && + const _i49.ListEquality().equals(location, other.location) && selectedLocation == other.selectedLocation && provider == other.provider; } @@ -965,20 +966,20 @@ class PrivateServerLocationArgs { @override int get hashCode => key.hashCode ^ - const _i48.ListEquality().hash(location) ^ + const _i49.ListEquality().hash(location) ^ selectedLocation.hashCode ^ provider.hashCode; } /// generated route for /// [_i28.PrivateServerSetup] -class PrivateServerSetup extends _i44.PageRouteInfo { - const PrivateServerSetup({List<_i44.PageRouteInfo>? children}) +class PrivateServerSetup extends _i45.PageRouteInfo { + const PrivateServerSetup({List<_i45.PageRouteInfo>? children}) : super(PrivateServerSetup.name, initialChildren: children); static const String name = 'PrivateServerSetup'; - static _i44.PageInfo page = _i44.PageInfo( + static _i45.PageInfo page = _i45.PageInfo( name, builder: (data) { return const _i28.PrivateServerSetup(); @@ -989,13 +990,13 @@ class PrivateServerSetup extends _i44.PageRouteInfo { /// generated route for /// [_i29.PrivateSeverDetails] class PrivateServerDetails - extends _i44.PageRouteInfo { + extends _i45.PageRouteInfo { PrivateServerDetails({ - _i45.Key? key, + _i46.Key? key, required List accounts, - required _i46.CloudProvider provider, + required _i47.CloudProvider provider, bool isPreFilled = false, - List<_i44.PageRouteInfo>? children, + List<_i45.PageRouteInfo>? children, }) : super( PrivateServerDetails.name, args: PrivateServerDetailsArgs( @@ -1009,7 +1010,7 @@ class PrivateServerDetails static const String name = 'PrivateServerDetails'; - static _i44.PageInfo page = _i44.PageInfo( + static _i45.PageInfo page = _i45.PageInfo( name, builder: (data) { final args = data.argsAs(); @@ -1031,11 +1032,11 @@ class PrivateServerDetailsArgs { this.isPreFilled = false, }); - final _i45.Key? key; + final _i46.Key? key; final List accounts; - final _i46.CloudProvider provider; + final _i47.CloudProvider provider; final bool isPreFilled; @@ -1049,7 +1050,7 @@ class PrivateServerDetailsArgs { if (identical(this, other)) return true; if (other is! PrivateServerDetailsArgs) return false; return key == other.key && - const _i48.ListEquality().equals(accounts, other.accounts) && + const _i49.ListEquality().equals(accounts, other.accounts) && provider == other.provider && isPreFilled == other.isPreFilled; } @@ -1057,20 +1058,20 @@ class PrivateServerDetailsArgs { @override int get hashCode => key.hashCode ^ - const _i48.ListEquality().hash(accounts) ^ + const _i49.ListEquality().hash(accounts) ^ provider.hashCode ^ isPreFilled.hashCode; } /// generated route for /// [_i30.QrCodeScanner] -class QrCodeScanner extends _i44.PageRouteInfo { - const QrCodeScanner({List<_i44.PageRouteInfo>? children}) +class QrCodeScanner extends _i45.PageRouteInfo { + const QrCodeScanner({List<_i45.PageRouteInfo>? children}) : super(QrCodeScanner.name, initialChildren: children); static const String name = 'QrCodeScanner'; - static _i44.PageInfo page = _i44.PageInfo( + static _i45.PageInfo page = _i45.PageInfo( name, builder: (data) { return const _i30.QrCodeScanner(); @@ -1080,12 +1081,12 @@ class QrCodeScanner extends _i44.PageRouteInfo { /// generated route for /// [_i31.ReportIssue] -class ReportIssue extends _i44.PageRouteInfo { +class ReportIssue extends _i45.PageRouteInfo { ReportIssue({ - _i45.Key? key, + _i46.Key? key, String? description, String? type, - List<_i44.PageRouteInfo>? children, + List<_i45.PageRouteInfo>? children, }) : super( ReportIssue.name, args: ReportIssueArgs(key: key, description: description, type: type), @@ -1094,7 +1095,7 @@ class ReportIssue extends _i44.PageRouteInfo { static const String name = 'ReportIssue'; - static _i44.PageInfo page = _i44.PageInfo( + static _i45.PageInfo page = _i45.PageInfo( name, builder: (data) { final args = data.argsAs( @@ -1112,7 +1113,7 @@ class ReportIssue extends _i44.PageRouteInfo { class ReportIssueArgs { const ReportIssueArgs({this.key, this.description, this.type}); - final _i45.Key? key; + final _i46.Key? key; final String? description; @@ -1138,12 +1139,12 @@ class ReportIssueArgs { /// generated route for /// [_i32.ResetPassword] -class ResetPassword extends _i44.PageRouteInfo { +class ResetPassword extends _i45.PageRouteInfo { ResetPassword({ - _i45.Key? key, + _i46.Key? key, required String email, required String code, - List<_i44.PageRouteInfo>? children, + List<_i45.PageRouteInfo>? children, }) : super( ResetPassword.name, args: ResetPasswordArgs(key: key, email: email, code: code), @@ -1152,7 +1153,7 @@ class ResetPassword extends _i44.PageRouteInfo { static const String name = 'ResetPassword'; - static _i44.PageInfo page = _i44.PageInfo( + static _i45.PageInfo page = _i45.PageInfo( name, builder: (data) { final args = data.argsAs(); @@ -1168,7 +1169,7 @@ class ResetPassword extends _i44.PageRouteInfo { class ResetPasswordArgs { const ResetPasswordArgs({this.key, required this.email, required this.code}); - final _i45.Key? key; + final _i46.Key? key; final String email; @@ -1192,11 +1193,11 @@ class ResetPasswordArgs { /// generated route for /// [_i33.ResetPasswordEmail] -class ResetPasswordEmail extends _i44.PageRouteInfo { +class ResetPasswordEmail extends _i45.PageRouteInfo { ResetPasswordEmail({ - _i45.Key? key, + _i46.Key? key, String? email, - List<_i44.PageRouteInfo>? children, + List<_i45.PageRouteInfo>? children, }) : super( ResetPasswordEmail.name, args: ResetPasswordEmailArgs(key: key, email: email), @@ -1205,7 +1206,7 @@ class ResetPasswordEmail extends _i44.PageRouteInfo { static const String name = 'ResetPasswordEmail'; - static _i44.PageInfo page = _i44.PageInfo( + static _i45.PageInfo page = _i45.PageInfo( name, builder: (data) { final args = data.argsAs( @@ -1219,7 +1220,7 @@ class ResetPasswordEmail extends _i44.PageRouteInfo { class ResetPasswordEmailArgs { const ResetPasswordEmailArgs({this.key, this.email}); - final _i45.Key? key; + final _i46.Key? key; final String? email; @@ -1241,13 +1242,13 @@ class ResetPasswordEmailArgs { /// generated route for /// [_i34.ServerSelection] -class ServerSelection extends _i44.PageRouteInfo { - const ServerSelection({List<_i44.PageRouteInfo>? children}) +class ServerSelection extends _i45.PageRouteInfo { + const ServerSelection({List<_i45.PageRouteInfo>? children}) : super(ServerSelection.name, initialChildren: children); static const String name = 'ServerSelection'; - static _i44.PageInfo page = _i44.PageInfo( + static _i45.PageInfo page = _i45.PageInfo( name, builder: (data) { return const _i34.ServerSelection(); @@ -1257,13 +1258,13 @@ class ServerSelection extends _i44.PageRouteInfo { /// generated route for /// [_i35.Setting] -class Setting extends _i44.PageRouteInfo { - const Setting({List<_i44.PageRouteInfo>? children}) +class Setting extends _i45.PageRouteInfo { + const Setting({List<_i45.PageRouteInfo>? children}) : super(Setting.name, initialChildren: children); static const String name = 'Setting'; - static _i44.PageInfo page = _i44.PageInfo( + static _i45.PageInfo page = _i45.PageInfo( name, builder: (data) { return const _i35.Setting(); @@ -1273,13 +1274,13 @@ class Setting extends _i44.PageRouteInfo { /// generated route for /// [_i36.SignInEmail] -class SignInEmail extends _i44.PageRouteInfo { - const SignInEmail({List<_i44.PageRouteInfo>? children}) +class SignInEmail extends _i45.PageRouteInfo { + const SignInEmail({List<_i45.PageRouteInfo>? children}) : super(SignInEmail.name, initialChildren: children); static const String name = 'SignInEmail'; - static _i44.PageInfo page = _i44.PageInfo( + static _i45.PageInfo page = _i45.PageInfo( name, builder: (data) { return const _i36.SignInEmail(); @@ -1289,12 +1290,12 @@ class SignInEmail extends _i44.PageRouteInfo { /// generated route for /// [_i37.SignInPassword] -class SignInPassword extends _i44.PageRouteInfo { +class SignInPassword extends _i45.PageRouteInfo { SignInPassword({ - _i45.Key? key, + _i46.Key? key, required String email, bool fromChangeEmail = false, - List<_i44.PageRouteInfo>? children, + List<_i45.PageRouteInfo>? children, }) : super( SignInPassword.name, args: SignInPasswordArgs( @@ -1307,7 +1308,7 @@ class SignInPassword extends _i44.PageRouteInfo { static const String name = 'SignInPassword'; - static _i44.PageInfo page = _i44.PageInfo( + static _i45.PageInfo page = _i45.PageInfo( name, builder: (data) { final args = data.argsAs(); @@ -1327,7 +1328,7 @@ class SignInPasswordArgs { this.fromChangeEmail = false, }); - final _i45.Key? key; + final _i46.Key? key; final String email; @@ -1353,13 +1354,13 @@ class SignInPasswordArgs { /// generated route for /// [_i38.SmartRouting] -class SmartRouting extends _i44.PageRouteInfo { - const SmartRouting({List<_i44.PageRouteInfo>? children}) +class SmartRouting extends _i45.PageRouteInfo { + const SmartRouting({List<_i45.PageRouteInfo>? children}) : super(SmartRouting.name, initialChildren: children); static const String name = 'SmartRouting'; - static _i44.PageInfo page = _i44.PageInfo( + static _i45.PageInfo page = _i45.PageInfo( name, builder: (data) { return const _i38.SmartRouting(); @@ -1369,13 +1370,13 @@ class SmartRouting extends _i44.PageRouteInfo { /// generated route for /// [_i39.SplitTunneling] -class SplitTunneling extends _i44.PageRouteInfo { - const SplitTunneling({List<_i44.PageRouteInfo>? children}) +class SplitTunneling extends _i45.PageRouteInfo { + const SplitTunneling({List<_i45.PageRouteInfo>? children}) : super(SplitTunneling.name, initialChildren: children); static const String name = 'SplitTunneling'; - static _i44.PageInfo page = _i44.PageInfo( + static _i45.PageInfo page = _i45.PageInfo( name, builder: (data) { return const _i39.SplitTunneling(); @@ -1385,13 +1386,13 @@ class SplitTunneling extends _i44.PageRouteInfo { /// generated route for /// [_i40.SplitTunnelingInfo] -class SplitTunnelingInfo extends _i44.PageRouteInfo { - const SplitTunnelingInfo({List<_i44.PageRouteInfo>? children}) +class SplitTunnelingInfo extends _i45.PageRouteInfo { + const SplitTunnelingInfo({List<_i45.PageRouteInfo>? children}) : super(SplitTunnelingInfo.name, initialChildren: children); static const String name = 'SplitTunnelingInfo'; - static _i44.PageInfo page = _i44.PageInfo( + static _i45.PageInfo page = _i45.PageInfo( name, builder: (data) { return const _i40.SplitTunnelingInfo(); @@ -1401,13 +1402,13 @@ class SplitTunnelingInfo extends _i44.PageRouteInfo { /// generated route for /// [_i41.Support] -class Support extends _i44.PageRouteInfo { - const Support({List<_i44.PageRouteInfo>? children}) +class Support extends _i45.PageRouteInfo { + const Support({List<_i45.PageRouteInfo>? children}) : super(Support.name, initialChildren: children); static const String name = 'Support'; - static _i44.PageInfo page = _i44.PageInfo( + static _i45.PageInfo page = _i45.PageInfo( name, builder: (data) { return const _i41.Support(); @@ -1416,33 +1417,49 @@ class Support extends _i44.PageRouteInfo { } /// generated route for -/// [_i42.VPNSetting] -class VPNSetting extends _i44.PageRouteInfo { - const VPNSetting({List<_i44.PageRouteInfo>? children}) +/// [_i42.UnboundedSetting] +class UnboundedSetting extends _i45.PageRouteInfo { + const UnboundedSetting({List<_i45.PageRouteInfo>? children}) + : super(UnboundedSetting.name, initialChildren: children); + + static const String name = 'UnboundedSetting'; + + static _i45.PageInfo page = _i45.PageInfo( + name, + builder: (data) { + return const _i42.UnboundedSetting(); + }, + ); +} + +/// generated route for +/// [_i43.VPNSetting] +class VPNSetting extends _i45.PageRouteInfo { + const VPNSetting({List<_i45.PageRouteInfo>? children}) : super(VPNSetting.name, initialChildren: children); static const String name = 'VPNSetting'; - static _i44.PageInfo page = _i44.PageInfo( + static _i45.PageInfo page = _i45.PageInfo( name, builder: (data) { - return const _i42.VPNSetting(); + return const _i43.VPNSetting(); }, ); } /// generated route for -/// [_i43.WebsiteSplitTunneling] -class WebsiteSplitTunneling extends _i44.PageRouteInfo { - const WebsiteSplitTunneling({List<_i44.PageRouteInfo>? children}) +/// [_i44.WebsiteSplitTunneling] +class WebsiteSplitTunneling extends _i45.PageRouteInfo { + const WebsiteSplitTunneling({List<_i45.PageRouteInfo>? children}) : super(WebsiteSplitTunneling.name, initialChildren: children); static const String name = 'WebsiteSplitTunneling'; - static _i44.PageInfo page = _i44.PageInfo( + static _i45.PageInfo page = _i45.PageInfo( name, builder: (data) { - return const _i43.WebsiteSplitTunneling(); + return const _i44.WebsiteSplitTunneling(); }, ); } diff --git a/lib/features/setting/setting.dart b/lib/features/setting/setting.dart index 7e7e5cb8bb..a8f12414b8 100644 --- a/lib/features/setting/setting.dart +++ b/lib/features/setting/setting.dart @@ -14,7 +14,6 @@ import 'package:lantern/features/home/provider/home_notifier.dart'; import 'package:lantern/features/plans/restore_purchase_mixin.dart'; import 'package:lantern/features/setting/appearance.dart' show appearanceModeLabel, showAppearanceBottomSheet; -import 'package:lantern/features/setting/unbounded_setting.dart'; import '../../core/services/injection_container.dart'; @@ -338,9 +337,7 @@ class _SettingState extends ConsumerState appRouter.push(VPNSetting()); break; case _SettingType.unboundedSetting: - Navigator.of(context).push( - MaterialPageRoute(builder: (_) => const UnboundedSetting()), - ); + appRouter.push(UnboundedSetting()); break; case _SettingType.browserUnbounded: // TODO: Handle this case. diff --git a/lib/features/setting/unbounded_setting.dart b/lib/features/setting/unbounded_setting.dart index e8ac0cca64..47fb82c0a1 100644 --- a/lib/features/setting/unbounded_setting.dart +++ b/lib/features/setting/unbounded_setting.dart @@ -1,3 +1,4 @@ +import 'package:auto_route/auto_route.dart'; import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:lantern/core/widgets/switch_button.dart'; @@ -15,6 +16,7 @@ import '../../core/common/common.dart'; /// 2. Hide Unbounded — collapse the Unbounded tab in the Home shell /// when the user doesn't want to see it. With only the VPN tab /// left, Home hides the tab strip entirely. +@RoutePage(name: 'UnboundedSetting') class UnboundedSetting extends ConsumerWidget { const UnboundedSetting({super.key}); diff --git a/lib/features/share_my_connection/share_my_connection.dart b/lib/features/share_my_connection/share_my_connection.dart index c7e4e76d28..4dbb58812c 100644 --- a/lib/features/share_my_connection/share_my_connection.dart +++ b/lib/features/share_my_connection/share_my_connection.dart @@ -362,7 +362,10 @@ class ShareNotifier extends Notifier { probing: false, mode: ShareMode.off, activeCount: 0, - totalCount: 0, + // Preserve lifetime totalCount across a failed Start — the + // persisted "Total people helped to date" stat is set + // independent of the active session's outcome. + totalCount: state.totalCount, phase: SharePhase.error, errorMessage: err.error, ); @@ -397,7 +400,9 @@ class ShareNotifier extends Notifier { probing: false, mode: ShareMode.off, activeCount: 0, - totalCount: 0, + // Preserve lifetime totalCount across a failed Start (see + // matching comment in the SmC branch above). + totalCount: state.totalCount, phase: SharePhase.error, errorMessage: err.error, ); @@ -1399,107 +1404,6 @@ class _WaitingCard extends StatelessWidget { } } -// ─── Lottie burst layer ────────────────────────────────────────────────────── - -/// Full-area Lottie heart-spray layer that overlays the globe and -/// plays the `explosion.json` animation each time a new peer arrives. -/// Matches unbounded.lantern.io's behaviour where dozens of small -/// hearts spray outward across the globe — NOT confined to the toast -/// pill (the pill stays clean with just a static heart icon). -/// -/// Subscribes to ShareNotifier.connectionEvents directly. On each -/// non-replay state=1 event, swaps the `key` on the inner Lottie via -/// a counter so AnimatedSwitcher cross-fades / Lottie restarts from -/// frame 0 every time. -class _LottieBurstLayer extends ConsumerStatefulWidget { - const _LottieBurstLayer(); - - @override - ConsumerState<_LottieBurstLayer> createState() => _LottieBurstLayerState(); -} - -class _LottieBurstLayerState extends ConsumerState<_LottieBurstLayer> { - StreamSubscription? _sub; - int _burstId = 0; - - @override - void initState() { - super.initState(); - _sub = ref - .read(shareProvider.notifier) - .connectionEvents - .listen(_onEvent); - } - - void _onEvent(UnboundedConnectionEvent event) { - if (event.state != 1 || event.isReplay) return; - if (event.countryName.isEmpty) return; - if (!mounted) return; - setState(() => _burstId++); - } - - @override - void dispose() { - _sub?.cancel(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - return IgnorePointer( - child: _burstId == 0 - ? const SizedBox.shrink() - : _BurstAnimation(key: ValueKey(_burstId)), - ); - } -} - -class _BurstAnimation extends StatefulWidget { - const _BurstAnimation({super.key}); - - @override - State<_BurstAnimation> createState() => _BurstAnimationState(); -} - -class _BurstAnimationState extends State<_BurstAnimation> - with TickerProviderStateMixin { - AnimationController? _ctrl; - - @override - void dispose() { - _ctrl?.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - return Lottie.asset( - 'assets/unbounded/explosion.json', - repeat: false, - fit: BoxFit.contain, - onLoaded: (composition) { - // Dispose guard: Lottie's onLoaded can fire after this - // State is disposed (rapid peer arrivals replace the - // keyed _BurstAnimation before composition load completes). - // Without this, AnimationController(vsync: this) + - // setState would throw on a disposed State. - if (!mounted) return; - // Also dispose any prior controller — a stale - // AnimationController from an earlier onLoaded would - // leak its ticker subscription otherwise. - _ctrl?.dispose(); - _ctrl = AnimationController( - vsync: this, - duration: composition.duration, - )..forward(); - setState(() {}); - }, - controller: _ctrl, - ); - } -} - - /// Pink heart from `getlantern/unbounded` — exact SVG path coords /// (viewBox 0 0 32 27, fill #FF5A79). class _HeartPainter extends CustomPainter { From c9fe5fa5477fc8e5f1ba12f411f07550946d1b90 Mon Sep 17 00:00:00 2001 From: Adam Fisk Date: Tue, 2 Jun 2026 01:48:45 -0600 Subject: [PATCH 18/26] share_my_connection: clarify peer-arrival persist invariant MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Strengthen the inline comment to spell out the dedup invariant that gates the SharedPreferences write — repeat events on an existing peer connection (liveness probes, stream reattaches) return early at the _peerArcs lookup and never reach the persist call, so this is one write per unique peer-arrival. Co-Authored-By: Claude Opus 4.7 --- lib/features/share_my_connection/share_my_connection.dart | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/lib/features/share_my_connection/share_my_connection.dart b/lib/features/share_my_connection/share_my_connection.dart index 4dbb58812c..9cafeb6844 100644 --- a/lib/features/share_my_connection/share_my_connection.dart +++ b/lib/features/share_my_connection/share_my_connection.dart @@ -492,9 +492,11 @@ class ShareNotifier extends Notifier { totalCount: newTotal, ); // Persist so the "Total people helped to date" stat - // survives restarts. Write happens per-arrival, but arrivals - // are bursty rather than continuous so SharedPreferences I/O - // pressure is fine. + // survives restarts. Reached only on first-time-seen IPs — + // the dedup at _peerArcs[ip] above returns early for repeat + // events on an existing connection (liveness probes, stream + // reattaches), so this is one write per unique peer-arrival, + // not per peer-connection event. ref .read(appSettingProvider.notifier) .setUnboundedTotalHelped(newTotal); From 53c2d223783bea4832a3797bb2fb44d62775d141 Mon Sep 17 00:00:00 2001 From: Adam Fisk Date: Tue, 2 Jun 2026 02:50:12 -0600 Subject: [PATCH 19/26] share_my_connection: roll back state if Unbounded fallback also fails MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit If SmC fails and the auto-fallback setUnboundedEnabled(true) also fails, the previous code optimistically set state.active=true / mode=unbounded and only logged the secondary error — leaving the UI claiming "Unbounded is on" while nothing actually started. Mirror the Unbounded branch in _start(): tear down the event subscription and reset state to off+error+errorMessage so the status card shows the failure instead of misleading the user. Lifetime totalCount is preserved. Co-Authored-By: Claude Opus 4.7 --- .../share_my_connection.dart | 24 ++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/lib/features/share_my_connection/share_my_connection.dart b/lib/features/share_my_connection/share_my_connection.dart index 9cafeb6844..27b5635619 100644 --- a/lib/features/share_my_connection/share_my_connection.dart +++ b/lib/features/share_my_connection/share_my_connection.dart @@ -695,9 +695,27 @@ class ShareNotifier extends Notifier { .read(lanternServiceProvider) .setUnboundedEnabled(true); result.fold( - (err) => appLogger.error( - 'SmC→Unbounded fallback: setUnboundedEnabled failed: ${err.error}', - ), + (err) { + // Both SmC and the fallback to Unbounded failed. Roll back the + // optimistic active=true state to off+error so the UI doesn't + // claim Unbounded is running when nothing actually started — + // same shape as the Unbounded branch in _start. Tear down the + // event subscription too, since it was kept alive across the + // SmC→Unbounded flip and there's nothing left to consume it. + appLogger.error( + 'SmC→Unbounded fallback: setUnboundedEnabled failed: ${err.error}', + ); + _stopEventSubscription(); + state = ShareState( + active: false, + probing: false, + mode: ShareMode.off, + activeCount: 0, + totalCount: state.totalCount, + phase: SharePhase.error, + errorMessage: err.error, + ); + }, (_) => {}, ); } From b8557fe71d9ed3b58e7e02e5e3995a55c3ccfed6 Mon Sep 17 00:00:00 2001 From: Adam Fisk Date: Tue, 2 Jun 2026 14:42:57 -0600 Subject: [PATCH 20/26] review: honor unboundedHidden in auto-enable, drop microtask, fix active-now label MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses Copilot review on #8820 (commit 53c2d22): - Both auto-enable paths (Home mount via useEffect, VPN-connected via ref.listen) now bail out when unboundedHidden is true. The PR description promised auto-enable would be "mediated by a 'has the user opted out' check"; hiding the Unbounded tab is that opt-out, and the listener was silently still starting broflake even after the user dismissed the surface. Same gate applied to the launch-time useEffect to keep the two paths symmetric. - Drop Future.microtask from the VPN-connect listener. The defer was unsafe under teardown — if Home unmounted between the listener firing and the microtask running, the deferred ref.read would land on a disposed scope. shareProvider is a different provider than vpnProvider (the one we're listening to), so the synchronous autoStart call doesn't risk a re-entrancy cycle. - Fix the "People helping right now" label: smc_stat_active_now is rendered with state.activeCount, which counts people BEING HELPED (remote clients routing through this device), not helpers. New string: "People being helped right now" — parallel to the existing "Total people helped to date" lifetime stat. Co-Authored-By: Claude Opus 4.7 --- assets/locales/en.po | 7 +++++-- lib/features/home/home.dart | 29 ++++++++++++++++++----------- 2 files changed, 23 insertions(+), 13 deletions(-) diff --git a/assets/locales/en.po b/assets/locales/en.po index c2e19f9d7d..ce6ad0ebf9 100644 --- a/assets/locales/en.po +++ b/assets/locales/en.po @@ -615,9 +615,12 @@ msgstr "Couldn't share: %s" msgid "smc_status_error_generic" msgstr "Couldn't share — try toggling again" -# Status card — stats + tooltip +# Status card — stats + tooltip. Counts the number of remote +# clients currently routing through THIS device (people being +# helped, not helpers); parallel framing to smc_stat_total_helped +# (same semantics, lifetime). msgid "smc_stat_active_now" -msgstr "People helping right now" +msgstr "People being helped right now" # Lifetime ("to date") rather than daily — backed by the persisted # unboundedTotalHelped setting so the count survives app restarts. diff --git a/lib/features/home/home.dart b/lib/features/home/home.dart index 2b8f88ad5e..534fdc08ec 100644 --- a/lib/features/home/home.dart +++ b/lib/features/home/home.dart @@ -101,10 +101,12 @@ class Home extends HookConsumerWidget { ref.read(appEventProvider); // Auto-enable Unbounded — gated on the "Auto-enable Unbounded" - // toggle from Unbounded Settings (default ON). Fires from two - // entry points so the spec's subtitle "Turn on automatically when - // Lantern is open" is honoured whether the user connects the VPN - // or not: + // toggle from Unbounded Settings (default ON) AND the per-user + // "Hide Unbounded" toggle. Hiding the tab is the opt-out: a user + // who hid Unbounded should not see it silently auto-enable in + // the background. Fires from two entry points so the spec's + // subtitle "Turn on automatically when Lantern is open" is + // honoured whether the user connects the VPN or not: // 1. App launch (useEffect below) — once on Home mount. // 2. VPN connect (ref.listen further down) — on every // disconnected → connected transition, in case the toggle @@ -116,6 +118,7 @@ class Home extends HookConsumerWidget { WidgetsBinding.instance.addPostFrameCallback((_) { if (!unboundedAvailable) return; final appSetting = ref.read(appSettingProvider); + if (appSetting.unboundedHidden) return; if (!appSetting.onboardingCompleted) return; if (!appSetting.unboundedAutoEnable) return; final share = ref.read(shareProvider); @@ -129,15 +132,19 @@ class Home extends HookConsumerWidget { if (prev == next) return; if (next != VPNStatus.connected) return; if (!unboundedAvailable) return; - final autoEnable = - ref.read(appSettingProvider).unboundedAutoEnable; - if (!autoEnable) return; + final appSetting = ref.read(appSettingProvider); + if (appSetting.unboundedHidden) return; + if (!appSetting.unboundedAutoEnable) 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), - ); + // Call autoStart synchronously — shareProvider is a different + // provider than vpnProvider (the one we're listening to), so + // mutating it inside this callback doesn't risk a re-entrancy + // cycle. The previous Future.microtask defer was unsafe under + // teardown: if Home unmounted between the listener firing and + // the microtask running, the deferred ref.read would be on a + // disposed scope. + ref.read(shareProvider.notifier).autoStart(ref); }); return Scaffold( From bd61905ad31d588555c306317f3099c71b57637b Mon Sep 17 00:00:00 2001 From: Adam Fisk Date: Sat, 6 Jun 2026 18:22:48 -0600 Subject: [PATCH 21/26] deps: bump radiance for the unbounded connection-source fix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pins radiance to fisk/unbounded-conn-source-on-close (radiance#512, stacked on the unbounded-integration branch this PR already tracked), which restores the consumer Source on broflake close events. Without it the Unbounded tab's globe arcs orphaned and "People being helped right now" only ever grew (it equalled the lifetime total). No app-side change needed — the existing peer-connection handler balances +1/-1 by Source once the close carries one. Co-Authored-By: Claude Opus 4.8 (1M context) --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 570d554f7e..43d049f933 100644 --- a/go.mod +++ b/go.mod @@ -23,7 +23,7 @@ replace github.com/quic-go/qpack => github.com/quic-go/qpack v0.5.1 require ( github.com/alecthomas/assert/v2 v2.3.0 github.com/getlantern/lantern-server-provisioner v0.0.0-20251031121934-8ea031fccfa9 - github.com/getlantern/radiance v0.0.0-20260531221356-11aa55a6ff16 + github.com/getlantern/radiance v0.0.0-20260607000056-0a9e276a9e00 github.com/sagernet/sing-box v1.12.22 golang.org/x/mobile v0.0.0-20250711185624-d5bb5ecc55c0 golang.org/x/sys v0.41.0 diff --git a/go.sum b/go.sum index 528a7c3e57..734f3f6b4f 100644 --- a/go.sum +++ b/go.sum @@ -261,8 +261,8 @@ github.com/getlantern/pluriconfig v0.0.0-20251126214241-8cc8bc561535 h1:rtDmW8YL github.com/getlantern/pluriconfig v0.0.0-20251126214241-8cc8bc561535/go.mod h1:WKJEdjMOD4IuTRYwjQHjT4bmqDl5J82RShMLxPAvi0Q= github.com/getlantern/publicip v0.0.0-20260328175246-2c460fe80c6b h1:gMYJzEhLrmIqQ+JnjiYNm+UyUDalK3WUmVyecFwmV5g= github.com/getlantern/publicip v0.0.0-20260328175246-2c460fe80c6b/go.mod h1:NpfXdK4ldEKkjQ4P1R+DBF4ua5VFOlxmgHROTnYrApg= -github.com/getlantern/radiance v0.0.0-20260531221356-11aa55a6ff16 h1:CpsYjT3sBimvg/GNYO5IKvRjWDc4BCeDjDQxUNsx8gA= -github.com/getlantern/radiance v0.0.0-20260531221356-11aa55a6ff16/go.mod h1:wemClXaug4hwPdsUEm8g1bCa8tkjk3UjDM+6PfWJwMI= +github.com/getlantern/radiance v0.0.0-20260607000056-0a9e276a9e00 h1:+KshI14A9S7fpfjWGFynGjUarD30oukj0vhhjoQ2YpU= +github.com/getlantern/radiance v0.0.0-20260607000056-0a9e276a9e00/go.mod h1:wemClXaug4hwPdsUEm8g1bCa8tkjk3UjDM+6PfWJwMI= github.com/getlantern/samizdat v0.0.3-0.20260529191731-5ea8ae61ddbf h1:KxiMF+oG0rTtuBi7GiIaHfccYOf69rLJ/VnO5myoYc4= github.com/getlantern/samizdat v0.0.3-0.20260529191731-5ea8ae61ddbf/go.mod h1:uEeykQSW2/6rTjfPlj3MTTo59poSHXfAHTGgzYDkbr0= github.com/getlantern/semconv v0.0.0-20260327040646-21845dda05cb h1:c5YM7b3a4r2J8Eh89KkI6M/iTFe6Bi+b8AJlfkKdFq4= From 0bfd915372d0f6d8f217c3109123674a9361d221 Mon Sep 17 00:00:00 2001 From: Adam Fisk Date: Sat, 6 Jun 2026 18:37:05 -0600 Subject: [PATCH 22/26] =?UTF-8?q?unbounded:=20fix=20globe=20origin=20?= =?UTF-8?q?=E2=80=94=20call=20geo=20/lookup=20+=20use=20precise=20coords?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit selfLookup requested the geo service root (https://geo.getiantem.org/), which 404s, so it always fell through to the US-centre fallback (38,-97). The globe drew every donor's origin in the central US regardless of where they actually were (a tester in Japan saw themselves in ~Colorado). Hit the correct /lookup endpoint, and use the response's precise Location lat/lon (falling back to the country centre, then US) so the origin point sits on the user's real location. Co-Authored-By: Claude Opus 4.8 (1M context) --- lib/core/services/geo_lookup_service.dart | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/lib/core/services/geo_lookup_service.dart b/lib/core/services/geo_lookup_service.dart index fdc09ff948..a5acf7a403 100644 --- a/lib/core/services/geo_lookup_service.dart +++ b/lib/core/services/geo_lookup_service.dart @@ -178,17 +178,30 @@ class GeoLookupService { return GlobeCoordinates(c.lat, c.lng); } - /// Looks up the current device's location (no IP argument). + /// Looks up the current device's location (no IP argument). Prefers the + /// precise Location lat/lon the geo service returns, falling back to the + /// country centre, then the US centre on any failure. static Future selfLookup() async { try { + // The geo service answers on /lookup; the bare root 404s. Hitting the + // root meant every donor's origin silently fell through to the US-centre + // fallback below regardless of where they actually were. final response = await http - .get(Uri.parse('$_selfUrl/')) + .get(Uri.parse('$_selfUrl/lookup')) .timeout(const Duration(seconds: 5)); if (response.statusCode == 200) { final data = jsonDecode(response.body) as Map; + // Precise device coordinates when present — so the origin point sits + // on the user's actual location, not the centre of their country. + final loc = data['Location'] as Map?; + final lat = (loc?['Latitude'] as num?)?.toDouble(); + final lng = (loc?['Longitude'] as num?)?.toDouble(); + if (lat != null && lng != null) { + return GlobeCoordinates(lat, lng); + } final iso = (data['Country'] as Map?)?['IsoCode'] as String? ?? - 'US'; + 'US'; return _isoToCoords(iso); } } catch (_) {} From 12aca41a290b5ebbf5c3ff2759f12656b22a5d1a Mon Sep 17 00:00:00 2001 From: Adam Fisk Date: Sat, 6 Jun 2026 18:43:53 -0600 Subject: [PATCH 23/26] unbounded: route peer geo lookups through Lantern's own service MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit peerLookup shipped each peer IP (often a censored user's address) to ipwho.is, a third party — the function's own privacy note flagged this as something to fix before real rollout. geo.getiantem.org/lookup/ is Lantern's own MaxMind-backed service (cmd/api/pro-server geoLookup) and resolves an arbitrary IP, so peer lookups now stay on Lantern infrastructure, same endpoint family as the self lookup. Parse the MaxMind City shape (Country.IsoCode/Names, Location lat/lon) and synthesize the flag emoji from the ISO code, since MaxMind returns no flag; drop the ipwho.is dependency entirely. Co-Authored-By: Claude Opus 4.8 (1M context) --- lib/core/services/geo_lookup_service.dart | 84 +++++++++++++---------- 1 file changed, 48 insertions(+), 36 deletions(-) diff --git a/lib/core/services/geo_lookup_service.dart b/lib/core/services/geo_lookup_service.dart index a5acf7a403..38616bc444 100644 --- a/lib/core/services/geo_lookup_service.dart +++ b/lib/core/services/geo_lookup_service.dart @@ -27,27 +27,27 @@ class PeerGeo { } class GeoLookupService { - static const _selfUrl = 'https://geo.getiantem.org'; - // ipwho.is: HTTPS, no auth, 10k req/month free. Returns country + lat/lon - // + flag emoji in one shot. - static const _peerUrl = 'https://ipwho.is'; + // Lantern's own geo service (cmd/api/pro-server geoLookup, MaxMind-backed). + // /lookup geo-locates the caller's own IP; /lookup/ a specific address, + // so both the self lookup and peer lookups stay on Lantern infrastructure + // rather than shipping addresses to a third party. + static const _geoUrl = 'https://geo.getiantem.org'; // Per-IP cache for peerLookup. Most connections are short-lived // liveness probes from the same handful of client IPs, so without a - // cache an active SmC peer would issue hundreds of ipwho.is requests - // per minute — chewing through the 10k/month free quota and leaking - // more data to the third party than necessary. Cached entries - // (including the PeerGeo.unknown sentinel from a failed lookup) live - // for the rest of the process — IP→country bindings don't change on - // human timescales, and the alternative TTL bookkeeping adds - // complexity without changing the privacy or quota math. + // cache an active SmC peer would issue hundreds of geo lookups per + // minute against geo.getiantem.org for no new information. Cached + // entries (including the PeerGeo.unknown sentinel from a failed + // lookup) live for the rest of the process — IP→country bindings + // don't change on human timescales, and the alternative TTL + // bookkeeping adds complexity without changing the result. static final Map _peerCache = {}; /// Test-only: drop the cache. Production code does not call this. static void resetCacheForTest() => _peerCache.clear(); // ISO country code → approximate centre coordinates. Used as a fallback - // when ipwho.is doesn't return city-level coords. + // when the geo service doesn't return city-level coords. static const _countryCenters = { 'AF': (lat: 33.0, lng: 65.0), 'AL': (lat: 41.0, lng: 20.0), @@ -178,6 +178,20 @@ class GeoLookupService { return GlobeCoordinates(c.lat, c.lng); } + // Synthesizes a flag emoji from an ISO 3166-1 alpha-2 code by mapping each + // letter to its regional-indicator symbol (A→🇦 … Z→🇿). MaxMind returns no + // flag (ipwho.is used to), so we derive it client-side. Returns "" for + // anything that isn't two ASCII letters. + static String _flagEmoji(String iso) { + if (iso.length != 2) return ''; + final a = iso.toUpperCase().codeUnitAt(0); + final b = iso.toUpperCase().codeUnitAt(1); + if (a < 0x41 || a > 0x5A || b < 0x41 || b > 0x5A) return ''; + const base = 0x1F1E6; // regional indicator symbol 'A' + return String.fromCharCode(base + (a - 0x41)) + + String.fromCharCode(base + (b - 0x41)); + } + /// Looks up the current device's location (no IP argument). Prefers the /// precise Location lat/lon the geo service returns, falling back to the /// country centre, then the US centre on any failure. @@ -187,7 +201,7 @@ class GeoLookupService { // root meant every donor's origin silently fell through to the US-centre // fallback below regardless of where they actually were. final response = await http - .get(Uri.parse('$_selfUrl/lookup')) + .get(Uri.parse('$_geoUrl/lookup')) .timeout(const Duration(seconds: 5)); if (response.statusCode == 200) { final data = jsonDecode(response.body) as Map; @@ -208,22 +222,15 @@ class GeoLookupService { return _isoToCoords('US'); } - /// Looks up country, flag, and coordinates for a peer [ip] address. - /// Returns [PeerGeo.unknown] on any failure so callers can suppress the - /// arc / banner rather than displaying a wrong country. + /// Looks up country, flag, and coordinates for a peer [ip] address via + /// Lantern's own geo service. Returns [PeerGeo.unknown] on any failure so + /// callers can suppress the arc / banner rather than displaying a wrong + /// country. /// - /// Privacy note: each call ships [ip] — the address of a peer routing - /// through this user's Share My Connection inbound — to ipwho.is, a - /// third-party geo-IP service. For most SmC consumers these are - /// censored users' addresses, so this is an outbound data flow to a - /// non-Lantern endpoint. Current rationale for accepting it: the IP - /// is already public-by-virtue-of-being-a-TCP-source-addr the host - /// observes, ipwho.is doesn't tie lookups to the caller, and the - /// alternative — relaying through Lantern's own infrastructure or - /// shipping a MaxMind DB with the app — is meaningful additional - /// scope. Move this to a Lantern-controlled endpoint or a local DB - /// before any production-scale rollout where peer protection matters - /// beyond demo use. + /// The peer IP is sent to geo.getiantem.org (Lantern infrastructure — the + /// same MaxMind-backed service used for the caller's own location) rather + /// than a third party. It's an address the local host already observes as a + /// connection source, and the service doesn't tie lookups to the caller. static Future peerLookup(String ip) async { // Cache hit: short-circuit before any network I/O. Also caches // PeerGeo.unknown so a previously-failed lookup doesn't retry on @@ -233,23 +240,28 @@ class GeoLookupService { PeerGeo result = PeerGeo.unknown; try { final response = await http - .get(Uri.parse('$_peerUrl/$ip')) + .get(Uri.parse('$_geoUrl/lookup/$ip')) .timeout(const Duration(seconds: 5)); if (response.statusCode == 200) { + // MaxMind City shape (see cmd/api/pro-server geoLookup): Country.IsoCode, + // Country.Names.en, and Location.Latitude/Longitude. An empty IsoCode + // (a private or unresolvable IP) is treated as a failed lookup. final data = jsonDecode(response.body) as Map; - if (data['success'] == true) { - final iso = (data['country_code'] as String?) ?? ''; - final lat = (data['latitude'] as num?)?.toDouble(); - final lng = (data['longitude'] as num?)?.toDouble(); + final country = data['Country'] as Map?; + final iso = (country?['IsoCode'] as String?) ?? ''; + if (iso.isNotEmpty) { + final loc = data['Location'] as Map?; + final lat = (loc?['Latitude'] as num?)?.toDouble(); + final lng = (loc?['Longitude'] as num?)?.toDouble(); final coords = (lat != null && lng != null) ? GlobeCoordinates(lat, lng) : _isoToCoords(iso); - final flagObj = data['flag'] as Map?; + final names = country?['Names'] as Map?; result = PeerGeo( coordinates: coords, - countryName: (data['country'] as String?) ?? '', + countryName: (names?['en'] as String?) ?? '', countryCode: iso, - flagEmoji: (flagObj?['emoji'] as String?) ?? '', + flagEmoji: _flagEmoji(iso), ); } } From e4a005e48f490c20bc6e081f0db0a2560eff2603 Mon Sep 17 00:00:00 2001 From: Adam Fisk Date: Sat, 6 Jun 2026 19:45:36 -0600 Subject: [PATCH 24/26] unbounded: stop the globe burning CPU off-tab and after toggle-off MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The globe is a software-projected sphere whose rotationController calls setState() ~60fps, re-rendering the entire sphere every frame. It was mounted unconditionally in the Unbounded tab, and TabBarView does not pause off-screen tabs' tickers — so it kept re-projecting a sphere the user couldn't see while they sat on the VPN tab. Separately, _stop() tears down the event stream without emitting the -1 events that remove arcs, so whatever arcs were live at toggle-off kept animating their flowing dashes indefinitely. - Gate the globe subtree in a TickerMode driven by a new unboundedTabVisibleProvider that Home updates from its TabController. rotating_globe builds its AnimationControllers with `vsync: this`, so muting the TickerMode pauses rotation AND the arc-dash controller at once. Per product call the globe still spins whenever its tab is visible; this only pauses it off-tab (and the engine already pauses frames when backgrounded). - Clear all arcs + peer points (and pending-removal timers) on the active->off edge, stopping the per-arc dash repaints and fixing the stale/orphaned-arc visual. Co-Authored-By: Claude Opus 4.8 (1M context) --- lib/features/home/home.dart | 16 +++++ .../share_my_connection.dart | 61 ++++++++++++++++++- 2 files changed, 76 insertions(+), 1 deletion(-) diff --git a/lib/features/home/home.dart b/lib/features/home/home.dart index 534fdc08ec..da8d333324 100644 --- a/lib/features/home/home.dart +++ b/lib/features/home/home.dart @@ -33,6 +33,22 @@ class Home extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final tabController = useTabController(initialLength: 2); + // Tell the Unbounded globe whether its tab is on screen so it can mute + // its ~60fps sphere re-projection while the user is on the VPN tab + // (TabBarView keeps the off-screen tab mounted and ticking). Enabled + // when settled on the Unbounded tab OR mid-swipe, so the globe + // animates through the transition rather than freezing. + useEffect(() { + void sync() { + final visible = + tabController.index == 1 || tabController.indexIsChanging; + ref.read(unboundedTabVisibleProvider.notifier).set(visible); + } + + tabController.addListener(sync); + sync(); + return () => tabController.removeListener(sync); + }, [tabController]); final isUserPro = ref.watch(isUserProProvider); final userLoggedIn = ref.watch( appSettingProvider.select((s) => s.userLoggedIn), diff --git a/lib/features/share_my_connection/share_my_connection.dart b/lib/features/share_my_connection/share_my_connection.dart index 27b5635619..61fddbbb7c 100644 --- a/lib/features/share_my_connection/share_my_connection.dart +++ b/lib/features/share_my_connection/share_my_connection.dart @@ -724,6 +724,28 @@ class ShareNotifier extends Notifier { final shareProvider = NotifierProvider(ShareNotifier.new); +/// Whether the Unbounded tab is the one on screen. Home drives this from +/// its TabController; the globe watches it to mute its tickers (via +/// TickerMode) while the user is on another tab. +/// +/// Why this is needed: the globe is a software-projected sphere whose +/// rotationController calls setState() ~60fps, re-rendering the whole +/// sphere every frame. TabBarView keeps off-screen tabs mounted and does +/// not pause their tickers, so without this gate the globe keeps burning +/// a core re-projecting a sphere the user can't see while they sit on +/// the VPN tab. Defaults true so the globe runs in any context that +/// doesn't wire the signal (tests, or the single-tab layout where the +/// globe isn't built anyway). +final unboundedTabVisibleProvider = + NotifierProvider(UnboundedTabVisible.new); + +class UnboundedTabVisible extends Notifier { + @override + bool build() => true; + + void set(bool visible) => state = visible; +} + // ─── Tab body ──────────────────────────────────────────────────────────────── /// Unbounded tab content, rendered inside the Home tab shell (see @@ -1020,6 +1042,10 @@ class _GlobeViewState extends ConsumerState<_GlobeView> { // workerIdx +1's again before it fires. final Map _pendingRemovals = {}; static const _arcLinger = Duration(seconds: 5); + // workerIdx of every arc+point currently on the globe, so a stop can + // remove them all without relying on per-peer -1 events (which _stop() + // never emits). + final Set _drawn = {}; @override void initState() { @@ -1140,16 +1166,48 @@ class _GlobeViewState extends ConsumerState<_GlobeView> { coordinates: coords, style: PointStyle(color: _peerPointColor, size: 6), )); + _drawn.add(event.workerIdx); } void _removePeer(int workerIdx) { _globeController.removePointConnection('conn_$workerIdx'); _globeController.removePoint('peer_$workerIdx'); + _drawn.remove(workerIdx); + } + + // Drop every arc + peer point at once. Used on the active->off edge, + // since _stop() tears down the event stream without emitting the -1s + // that would otherwise remove each arc. + void _clearAllPeers() { + for (final t in _pendingRemovals.values) { + t.cancel(); + } + _pendingRemovals.clear(); + for (final workerIdx in _drawn.toList()) { + _globeController.removePointConnection('conn_$workerIdx'); + _globeController.removePoint('peer_$workerIdx'); + } + _drawn.clear(); } @override Widget build(BuildContext context) { - return LayoutBuilder( + // Mute every globe ticker (rotation + arc dashes) while the Unbounded + // tab is off screen. rotating_globe builds its AnimationControllers + // with `vsync: this`, so an ancestor TickerMode(enabled: false) pauses + // all of them at once — the dominant cost is rotationController's + // per-frame setState re-projecting the sphere, and there's no reason + // to pay it for a sphere the user can't see. + final visible = ref.watch(unboundedTabVisibleProvider); + // _stop() tears down the upstream event subscription without emitting + // -1 events, so without this the globe keeps animating whatever arcs + // were live at toggle-off. Clear them on the active->off edge. + ref.listen(shareProvider.select((s) => s.active), (prev, next) { + if (prev == true && next == false) _clearAllPeers(); + }); + return TickerMode( + enabled: visible, + child: LayoutBuilder( builder: (context, constraints) { // FlutterEarthGlobe positions the sphere relative to MediaQuery.size // (i.e. the full screen). Without overriding it the globe ends up @@ -1182,6 +1240,7 @@ class _GlobeViewState extends ConsumerState<_GlobeView> { ), ); }, + ), ); } } From c491c67b8e515bb846da34a4e8de4a7965296244 Mon Sep 17 00:00:00 2001 From: Adam Fisk Date: Sun, 7 Jun 2026 17:37:06 -0600 Subject: [PATCH 25/26] =?UTF-8?q?unbounded:=20address=20review=20=E2=80=94?= =?UTF-8?q?=20globe=20visibility=20via=20tab=20animation,=20dialog=20ref?= =?UTF-8?q?=20capture?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - home.dart: drive the globe's TickerMode from tabController.animation.value (fractional tab position) rather than index + indexIsChanging. indexIsChanging is false during a finger swipe, so the old gate froze the globe mid-swipe-in; animation.value > 0 keeps it animating whenever any part of the Unbounded tab is on screen. - share_my_connection.dart: capture the appSetting notifier before showUnboundedWelcomeDialog's showDialog so whenComplete doesn't touch a WidgetRef whose widget may have been disposed while the dialog was open (navigation replacing Home) — which would throw. Co-Authored-By: Claude Opus 4.8 (1M context) --- lib/features/home/home.dart | 16 ++++++++++------ .../share_my_connection/share_my_connection.dart | 7 ++++++- 2 files changed, 16 insertions(+), 7 deletions(-) diff --git a/lib/features/home/home.dart b/lib/features/home/home.dart index da8d333324..42470573aa 100644 --- a/lib/features/home/home.dart +++ b/lib/features/home/home.dart @@ -35,14 +35,18 @@ class Home extends HookConsumerWidget { final tabController = useTabController(initialLength: 2); // Tell the Unbounded globe whether its tab is on screen so it can mute // its ~60fps sphere re-projection while the user is on the VPN tab - // (TabBarView keeps the off-screen tab mounted and ticking). Enabled - // when settled on the Unbounded tab OR mid-swipe, so the globe - // animates through the transition rather than freezing. + // (TabBarView keeps the off-screen tab mounted and ticking). The globe + // lives in tab index 1, so it should animate whenever any part of it + // is on screen — including mid-swipe. animation.value is the + // fractional tab position (0.0 = VPN fully shown, 1.0 = Unbounded + // fully shown), so > 0 means the Unbounded tab is at least partly + // revealed. (indexIsChanging only covers tap/animateTo, not a finger + // drag, so it would freeze the globe during a swipe in.) useEffect(() { void sync() { - final visible = - tabController.index == 1 || tabController.indexIsChanging; - ref.read(unboundedTabVisibleProvider.notifier).set(visible); + final pos = + tabController.animation?.value ?? tabController.index.toDouble(); + ref.read(unboundedTabVisibleProvider.notifier).set(pos > 0.0); } tabController.addListener(sync); diff --git a/lib/features/share_my_connection/share_my_connection.dart b/lib/features/share_my_connection/share_my_connection.dart index 61fddbbb7c..7f6ea814ce 100644 --- a/lib/features/share_my_connection/share_my_connection.dart +++ b/lib/features/share_my_connection/share_my_connection.dart @@ -1755,11 +1755,16 @@ class SmcDisclosureDialog extends StatelessWidget { /// only fires on the first visit. The info-bubble icon in the /// Unbounded tab header calls this same function to re-open it later. void showUnboundedWelcomeDialog(BuildContext context, WidgetRef ref) { + // Capture the notifier up front: whenComplete fires after the dialog + // closes, by which point the calling widget (and its WidgetRef) may be + // disposed if navigation replaced Home — ref.read would then throw. The + // notifier is owned by the root container and outlives the widget. + final appSetting = ref.read(appSettingProvider.notifier); showDialog( context: context, builder: (_) => const _UnboundedWelcomeDialog(), ).whenComplete(() { - ref.read(appSettingProvider.notifier).setUnboundedWelcomeSeen(true); + appSetting.setUnboundedWelcomeSeen(true); }); } From 8fe94b829e76fdf7501a0cdd1c596a5b08dfb83f Mon Sep 17 00:00:00 2001 From: Adam Fisk Date: Mon, 8 Jun 2026 16:54:29 -0600 Subject: [PATCH 26/26] =?UTF-8?q?unbounded:=20make=20the=20globe=20static?= =?UTF-8?q?=20=E2=80=94=20rotate-to-arc=20instead=20of=20spinning?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A spinning flutter_earth_globe re-projects the whole sphere in software via setState() every frame (~60fps); that continuous repaint is the dominant CPU cost of the Unbounded tab (confirmed: CPU spikes on open, drops on close). Two changes remove it: - isRotating: false. The globe is static at rest. On each new connection focusOnCoordinates(animate) turns it so the new arc is in view, then it settles back to static — a finite animation, no continuous cost. On origin resolve it focuses (instant) on the user's own location. - Arc dashAnimateTime 1000 -> 0. A non-zero dash time keeps the package's ~60fps repaint loop running for as long as any arc exists, which would re-spike the CPU the entire time a peer is connected. Arcs are now solid lines, drawn in once via animateOnAdd. Net: the globe animates only during the brief turn toward a new connection; idle and steady-state are static and cheap. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../share_my_connection.dart | 46 +++++++++++++------ 1 file changed, 31 insertions(+), 15 deletions(-) diff --git a/lib/features/share_my_connection/share_my_connection.dart b/lib/features/share_my_connection/share_my_connection.dart index 7f6ea814ce..cc56d92639 100644 --- a/lib/features/share_my_connection/share_my_connection.dart +++ b/lib/features/share_my_connection/share_my_connection.dart @@ -1024,8 +1024,12 @@ class _GlobeViewState extends ConsumerState<_GlobeView> { final FlutterEarthGlobeController _globeController = FlutterEarthGlobeController( - isRotating: true, - rotationSpeed: 0.04, + // Static globe — no continuous rotation. A spinning sphere re-projects + // in software via setState() every frame (~60fps), which is the + // dominant CPU cost of this tab. Instead we rotate only on demand + // (focusOnCoordinates) to bring each new connection into view, then + // settle back to static. + isRotating: false, zoom: 0, isZoomEnabled: false, showAtmosphere: true, @@ -1099,6 +1103,9 @@ class _GlobeViewState extends ConsumerState<_GlobeView> { coordinates: coords, style: PointStyle(color: _originPointColor, size: 8), )); + // Center the static globe on the user's own location so the first + // thing shown is "us" (instant — no intro spin). + _globeController.focusOnCoordinates(coords, animate: false); // Origin coords are now known — draw any peers that connected // before (or during) the origin lookup. _addPeer's null-guard // skipped them earlier; replayCurrentPeers re-fires +1 events @@ -1142,9 +1149,12 @@ class _GlobeViewState extends ConsumerState<_GlobeView> { // skipped here gets drawn with the correct destination. if (_originCoords == null) return; final coords = _jittered(event.coordinates!, event.workerIdx); - // Arc direction is censored user → uncensored peer (us). The dash - // animation flows from start to end, so the visual "travel" reads - // as traffic arriving at our peer to escape censorship. + // Solid arc, censored user → uncensored peer (us), drawn in once on + // add (animateOnAdd). dashAnimateTime is 0 on purpose: a non-zero + // value makes flutter_earth_globe run its ~60fps repaint loop for as + // long as any arc exists, which would re-spike the CPU the whole time + // a peer is connected — the same per-frame full-sphere repaint we + // removed by disabling rotation. _globeController.addPointConnection(PointConnection( id: 'conn_${event.workerIdx}', start: coords, @@ -1154,10 +1164,7 @@ class _GlobeViewState extends ConsumerState<_GlobeView> { color: _arcColor, lineWidth: 3, type: PointConnectionType.solid, - dashAnimateTime: 1000, - dashSize: 13, - spacing: 15, - dotSize: 10, + dashAnimateTime: 0, animateOnAdd: true, ), )); @@ -1167,6 +1174,16 @@ class _GlobeViewState extends ConsumerState<_GlobeView> { style: PointStyle(color: _peerPointColor, size: 6), )); _drawn.add(event.workerIdx); + // Turn the static globe to bring the new connection into view — a + // brief, finite animation that settles back to static (no continuous + // cost). Without it, an arc on the far hemisphere of a non-spinning + // globe would be invisible. + _globeController.focusOnCoordinates( + coords, + animate: true, + duration: const Duration(milliseconds: 900), + curve: Curves.easeInOutCubic, + ); } void _removePeer(int workerIdx) { @@ -1192,12 +1209,11 @@ class _GlobeViewState extends ConsumerState<_GlobeView> { @override Widget build(BuildContext context) { - // Mute every globe ticker (rotation + arc dashes) while the Unbounded - // tab is off screen. rotating_globe builds its AnimationControllers - // with `vsync: this`, so an ancestor TickerMode(enabled: false) pauses - // all of them at once — the dominant cost is rotationController's - // per-frame setState re-projecting the sphere, and there's no reason - // to pay it for a sphere the user can't see. + // Pause the globe's tickers while the Unbounded tab is off screen. + // rotating_globe builds its AnimationControllers with `vsync: this`, + // so an ancestor TickerMode(enabled: false) freezes any in-flight + // focusOnCoordinates turn and the package's idle repaint loop at once + // — no point spending frames on a globe the user can't see. final visible = ref.watch(unboundedTabVisibleProvider); // _stop() tears down the upstream event subscription without emitting // -1 events, so without this the globe keeps animating whatever arcs