diff --git a/assets/images/unbounded.svg b/assets/images/unbounded.svg new file mode 100644 index 0000000000..8467d4352d --- /dev/null +++ b/assets/images/unbounded.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/images/unbounded_wordmark.svg b/assets/images/unbounded_wordmark.svg new file mode 100644 index 0000000000..8c7da14520 --- /dev/null +++ b/assets/images/unbounded_wordmark.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/assets/locales/en.po b/assets/locales/en.po index e83fc56fbc..aacf06728e 100644 --- a/assets/locales/en.po +++ b/assets/locales/en.po @@ -114,6 +114,11 @@ msgstr "Remind them to use code %s when they upgrade!" 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" @@ -617,12 +622,15 @@ 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" - -# 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." + +# Info-bubble tooltip on the Unbounded tab header +msgid "about_unbounded" +msgstr "About Unbounded" # Status card — phase labels msgid "smc_status_label" @@ -667,19 +675,44 @@ 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 "Active now" +msgstr "People being helped 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." -# 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" + +# 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" @@ -715,24 +748,23 @@ msgstr "Manual port cleared — using UPnP" msgid "smc_manual_port_saved" msgstr "Manual port set to %d" -# SmC disclosure dialog (shown before opting into the residential-proxy mode) -msgid "smc_disclosure_title" -msgstr "Use full Share My Connection?" - -msgid "smc_disclosure_body_capability" -msgstr "Your network supports the higher-bandwidth, more block-resistant mode. In this mode, your home internet connection routes traffic for users in censored countries." +# Unbounded Settings menu entry (Settings → Unbounded Settings) +msgid "unbounded_settings_title" +msgstr "Unbounded Settings" -msgid "smc_disclosure_body_safety" -msgstr "Lantern blocks abuse destinations, rotates credentials, and operates as a \"mere conduit\" under DMCA § 512(a) — but your IP address will appear in the destination's logs while you're sharing." +# Auto-enable Unbounded toggle +msgid "auto_enable_unbounded" +msgstr "Auto-enable Unbounded" -msgid "smc_disclosure_body_alternative" -msgstr "You can still help by selecting \"Basic mode\" instead, which uses ephemeral WebRTC connections that are not tied to your IP in the same way." +msgid "auto_enable_unbounded_subtitle" +msgstr "Turn on automatically when Lantern is open" -msgid "smc_disclosure_basic" -msgstr "Basic mode (Unbounded)" +# Hide Unbounded toggle (collapses the Unbounded tab on the Home shell) +msgid "hide_unbounded" +msgstr "Hide Unbounded" -msgid "smc_disclosure_full" -msgstr "Full mode (SmC)" +msgid "hide_unbounded_subtitle" +msgstr "Removes Unbounded from the top of this screen" msgid "vpn_connected" msgstr "Lantern is now connected." @@ -1830,6 +1862,9 @@ msgstr "Configuration cleared" msgid "turn_off_vpn" msgstr "Turn off VPN" +msgid "turn_on_vpn" +msgstr "Turn on VPN" + msgid "turn_off_vpn_message" msgstr "The VPN needs to be off before you can refresh your configuration. Turn it off, then try again." @@ -1851,6 +1886,24 @@ msgstr "Order Total" msgid "smart_routing_mode_description" msgstr "Smart Location picks the fastest server and switches automatically as it finds better routes." +# Share consent dialog (shown once before any sharing starts, covers both modes) +msgid "share_consent_title" +msgstr "Share your connection?" + +msgid "share_consent_body_what" +msgstr "While sharing is on, people in censored countries route their traffic through your connection to reach the open internet." + +msgid "share_consent_body_ip" +msgstr "If your router supports port forwarding, your home connection is used as the exit and your IP address will appear in the destination's logs while you're sharing. Otherwise that traffic is relayed through Lantern's servers instead, and your IP is not exposed that way." + +msgid "share_consent_body_safety" +msgstr "In both cases Lantern blocks known-abuse destinations, private network ranges, and abuse-prone ports, rotates credentials, and operates as a \"mere conduit\" under DMCA § 512(a)." + +msgid "share_consent_decline" +msgstr "Not now" + +msgid "share_consent_accept" +msgstr "Turn on sharing" msgid "bypass_browser_warning_title" msgstr "Add %s to the bypass list?" diff --git a/lib/core/common/app_image_paths.dart b/lib/core/common/app_image_paths.dart index 84250edfd6..6290a676fd 100644 --- a/lib/core/common/app_image_paths.dart +++ b/lib/core/common/app_image_paths.dart @@ -90,6 +90,8 @@ class AppImagePaths { static const success = 'assets/images/green_checkmark.svg'; static const errorIcon = 'assets/images/error_icon.svg'; static const key = 'assets/images/key.svg'; + static const unbounded = 'assets/images/unbounded.svg'; + static const unboundedWordmark = 'assets/images/unbounded_wordmark.svg'; static const qrCodeScanner = 'assets/images/qr_code_scanner.svg'; static const settingServer = 'assets/images/setting_server.svg'; static const joinServer = 'assets/images/join_server.svg'; diff --git a/lib/core/models/app_setting.dart b/lib/core/models/app_setting.dart index dfd9f4db43..60fcca2529 100644 --- a/lib/core/models/app_setting.dart +++ b/lib/core/models/app_setting.dart @@ -8,6 +8,20 @@ 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; + // 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', @@ -19,6 +33,10 @@ class AppSetting { this.successfulConnection = false, this.dataCapThreshold = '', this.onboardingCompleted = false, + this.unboundedAutoEnable = false, + this.unboundedHidden = false, + this.unboundedWelcomeSeen = false, + this.unboundedTotalHelped = 0, }); /// Whether the app points at the staging backend. The notifier writes @@ -35,6 +53,10 @@ class AppSetting { bool? successfulConnection, String? dataCapThreshold, bool? onboardingCompleted, + bool? unboundedAutoEnable, + bool? unboundedHidden, + bool? unboundedWelcomeSeen, + int? unboundedTotalHelped, }) { return AppSetting( locale: newLocale ?? locale, @@ -46,6 +68,11 @@ 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, + unboundedTotalHelped: + unboundedTotalHelped ?? this.unboundedTotalHelped, ); } @@ -59,6 +86,10 @@ class AppSetting { 'successfulConnection': successfulConnection, 'dataCapThreshold': dataCapThreshold, 'onboardingCompleted': onboardingCompleted, + 'unboundedAutoEnable': unboundedAutoEnable, + 'unboundedHidden': unboundedHidden, + 'unboundedWelcomeSeen': unboundedWelcomeSeen, + 'unboundedTotalHelped': unboundedTotalHelped, }; factory AppSetting.fromJson(Map json) => AppSetting( @@ -71,5 +102,13 @@ class AppSetting { successfulConnection: json['successfulConnection'] == true, dataCapThreshold: (json['dataCapThreshold'] ?? '').toString(), onboardingCompleted: json['onboardingCompleted'] == true, + // Opt-in: auto-enable only when the user explicitly turned it on. + // Missing/unset means manual — Unbounded does not start on its own + // until the user enables it (toggle) or opts into auto-enable. + unboundedAutoEnable: json['unboundedAutoEnable'] == true, + unboundedHidden: json['unboundedHidden'] == true, + unboundedWelcomeSeen: json['unboundedWelcomeSeen'] == true, + unboundedTotalHelped: + (json['unboundedTotalHelped'] as num?)?.toInt() ?? 0, ); } 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/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 ebdc081aa4..d7d5c5a2ce 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,13 +151,13 @@ 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, _i3.AppWebViewObserver? observer, - List<_i44.PageRouteInfo>? children, + List<_i45.PageRouteInfo>? children, }) : super( AppWebview.name, args: AppWebviewArgs( @@ -170,7 +171,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(); @@ -192,7 +193,7 @@ class AppWebviewArgs { this.observer, }); - final _i45.Key? key; + final _i46.Key? key; final String title; @@ -222,13 +223,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(); @@ -238,13 +239,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(); @@ -254,14 +255,14 @@ 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, + required _i47.AuthFlow authFlow, _i3.AppWebViewObserver? checkoutObserver, - List<_i44.PageRouteInfo>? children, + List<_i45.PageRouteInfo>? children, }) : super( ChoosePaymentMethod.name, args: ChoosePaymentMethodArgs( @@ -276,7 +277,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(); @@ -300,13 +301,13 @@ class ChoosePaymentMethodArgs { this.checkoutObserver, }); - final _i45.Key? key; + final _i46.Key? key; final String email; final String? code; - final _i46.AuthFlow authFlow; + final _i47.AuthFlow authFlow; final _i3.AppWebViewObserver? checkoutObserver; @@ -337,13 +338,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( @@ -357,7 +358,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(); @@ -376,16 +377,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() { @@ -409,13 +410,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( @@ -429,7 +430,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(); @@ -451,11 +452,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; @@ -481,13 +482,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(); @@ -497,13 +498,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(); @@ -513,11 +514,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), @@ -526,7 +527,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(); @@ -538,9 +539,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() { @@ -552,7 +553,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, ); @@ -560,18 +561,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(); @@ -581,13 +582,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(); @@ -597,13 +598,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(); @@ -613,13 +614,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(); @@ -629,11 +630,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), @@ -642,7 +643,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( @@ -659,7 +660,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; @@ -673,7 +674,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, ); @@ -682,18 +683,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(); @@ -703,12 +704,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), @@ -717,7 +718,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(); @@ -737,7 +738,7 @@ class LanternProLicenseArgs { required this.code, }); - final _i45.Key? key; + final _i46.Key? key; final String email; @@ -761,13 +762,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(); @@ -777,13 +778,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(); @@ -793,13 +794,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(); @@ -809,13 +810,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(); @@ -825,13 +826,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(); @@ -841,11 +842,11 @@ class Onboarding extends _i44.PageRouteInfo { /// generated route for /// [_i24.Plans] -class Plans extends _i44.PageRouteInfo { +class Plans extends _i45.PageRouteInfo { Plans({ - _i45.Key? key, + _i46.Key? key, String? referralCode, - List<_i44.PageRouteInfo>? children, + List<_i45.PageRouteInfo>? children, }) : super( Plans.name, args: PlansArgs(key: key, referralCode: referralCode), @@ -854,7 +855,7 @@ class Plans extends _i44.PageRouteInfo { static const String name = 'Plans'; - static _i44.PageInfo page = _i44.PageInfo( + static _i45.PageInfo page = _i45.PageInfo( name, builder: (data) { final args = data.argsAs(orElse: () => const PlansArgs()); @@ -866,7 +867,7 @@ class Plans extends _i44.PageRouteInfo { class PlansArgs { const PlansArgs({this.key, this.referralCode}); - final _i45.Key? key; + final _i46.Key? key; final String? referralCode; @@ -888,13 +889,13 @@ class PlansArgs { /// 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(); @@ -904,11 +905,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), @@ -917,7 +918,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(); @@ -932,7 +933,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; @@ -955,14 +956,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( @@ -977,7 +978,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(); @@ -1001,7 +1002,7 @@ class PrivateServerLocationArgs { required this.provider, }); - final _i45.Key? key; + final _i46.Key? key; final List location; @@ -1009,7 +1010,7 @@ class PrivateServerLocationArgs { final dynamic Function(String) onLocationSelected; - final _i46.CloudProvider provider; + final _i47.CloudProvider provider; @override String toString() { @@ -1021,7 +1022,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; } @@ -1029,20 +1030,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(); @@ -1053,13 +1054,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( @@ -1073,7 +1074,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(); @@ -1095,11 +1096,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; @@ -1113,7 +1114,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; } @@ -1121,20 +1122,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(); @@ -1144,12 +1145,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), @@ -1158,7 +1159,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( @@ -1176,7 +1177,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; @@ -1202,12 +1203,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), @@ -1216,7 +1217,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(); @@ -1232,7 +1233,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; @@ -1256,11 +1257,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), @@ -1269,7 +1270,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( @@ -1283,7 +1284,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; @@ -1305,13 +1306,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(); @@ -1321,13 +1322,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(); @@ -1337,13 +1338,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(); @@ -1353,12 +1354,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( @@ -1371,7 +1372,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(); @@ -1391,7 +1392,7 @@ class SignInPasswordArgs { this.fromChangeEmail = false, }); - final _i45.Key? key; + final _i46.Key? key; final String email; @@ -1417,13 +1418,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(); @@ -1433,13 +1434,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(); @@ -1449,13 +1450,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(); @@ -1465,13 +1466,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(); @@ -1480,33 +1481,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/core/services/geo_lookup_service.dart b/lib/core/services/geo_lookup_service.dart index fdc09ff948..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,39 +178,59 @@ class GeoLookupService { return GlobeCoordinates(c.lat, c.lng); } - /// Looks up the current device's location (no IP argument). + // 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. 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('$_geoUrl/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 (_) {} 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 @@ -220,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), ); } } diff --git a/lib/features/home/home.dart b/lib/features/home/home.dart index 358904b687..786b6bbb93 100644 --- a/lib/features/home/home.dart +++ b/lib/features/home/home.dart @@ -1,108 +1,150 @@ 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/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/home/vpn_tab.dart'; import 'package:lantern/features/setting/referral_reward_dialog.dart'; -import 'package:lantern/features/vpn/location_setting.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(); - + Widget build(BuildContext context, WidgetRef ref) { // Congratulate the user once per converted referral, whenever fresh - // user data lands (first load or later refreshes). - ref.listenManual(homeProvider, fireImmediately: true, (previous, next) { - final user = next.value; - if (user == null) return; - if (!ref.read(appSettingProvider).onboardingCompleted) return; - WidgetsBinding.instance.addPostFrameCallback((_) { - if (!mounted) return; - checkAndShowReferralReward(context, user); + // user data lands (first load or later refreshes). listenManual (not + // ref.listen) because the subscription must outlive a rebuild and is + // torn down explicitly; the empty dep list keeps it to one + // subscription for the widget's lifetime rather than one per build. + useEffect(() { + final sub = ref.listenManual(homeProvider, fireImmediately: true, ( + previous, + next, + ) { + final user = next.value; + if (user == null) return; + if (!ref.read(appSettingProvider).onboardingCompleted) return; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!context.mounted) return; + checkAndShowReferralReward(context, user); + }); }); - }); - - 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; - } + return sub.close; + }, const []); - 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; - } + 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). 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.) + // + // The same listener drives the app bar wordmark, which swaps with the tab. + final onUnboundedTab = useState(false); + useEffect(() { + void sync() { + final pos = + tabController.animation?.value ?? tabController.index.toDouble(); + ref.read(unboundedTabVisibleProvider.notifier).set(pos > 0.0); + // Halfway through the swipe, so the title swaps once rather than + // flickering per frame. + onUnboundedTab.value = pos >= 0.5; } - }); - } - @override - Widget build(BuildContext context) { + tabController.addListener(sync); + // Deferred: on first mount this effect body runs inside Home.build, and + // sync writes to a provider, which Riverpod rejects during a build. + // Later invocations come from the controller's animation ticks, which + // are already outside the build phase. + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!context.mounted) return; + sync(); + }); + return () => tabController.removeListener(sync); + }, [tabController]); final isUserPro = ref.watch(isUserProProvider); - final featureFlag = ref.watch(featureFlagProvider); final userLoggedIn = ref.watch( appSettingProvider.select((s) => s.userLoggedIn), ); + final unboundedHidden = ref.watch( + 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)); + + // 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); }); } @@ -110,16 +152,68 @@ class _HomeState extends ConsumerState { return null; }, [featureFlag]); - textTheme = Theme.of(context).textTheme; ref.read(appEventProvider); + + // Auto-enable Unbounded — gated on the "Auto-enable Unbounded" + // toggle from Unbounded Settings (opt-in, default off) 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 + // 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((_) { + 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); + if (share.active || share.probing) return; + ref.read(shareProvider.notifier).autoStart(ref); + }); + return null; + }, [unboundedAvailable]); + + ref.listen(vpnProvider, (prev, next) { + if (prev == next) return; + if (next != VPNStatus.connected) return; + if (!unboundedAvailable) 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; + // 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( 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), - ), + // Both titles are brand logotypes, not text: the wordmarks use a + // condensed face the app's Urbanist theme cannot reproduce. + title: showUnboundedTab && onUnboundedTab.value + ? AppImage( + path: AppImagePaths.unboundedWordmark, + color: context.textPrimary, + height: 20, + width: 149, + ) + : LanternLogo(isPro: isUserPro, color: context.textPrimary), elevation: 5, leading: IconButton( key: const Key('home.menu_button'), @@ -145,195 +239,156 @@ class _HomeState extends ConsumerState { else if (!userLoggedIn) AppTextButton( label: 'sign_in'.i18n, - onPressed: () { - appRouter.push(const SignInEmail()); - }, + onPressed: () => appRouter.push(const SignInEmail()), ), ], + // 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, + // Pill-shaped selection rather than the Material underline, + // per the Figma spec. indicatorPadding insets the fill so the + // pill floats inside the tab instead of filling it edge to + // edge; the strip carries no divider in the spec. + indicatorSize: TabBarIndicatorSize.tab, + indicator: BoxDecoration( + color: AppColors.blue1, // action/tabbar/tabbar-bg + borderRadius: BorderRadius.circular(9999), + border: Border.all( + color: AppColors.blue2, // action/tabbar/tabbar-border + ), + ), + indicatorPadding: + const EdgeInsets.symmetric(horizontal: 8, vertical: 6), + dividerColor: Colors.transparent, + labelColor: AppColors.blue10, // tabbar-selected-text + unselectedLabelColor: AppColors.gray6, // tabbar-disabled-text + tabs: [ + _TabLabel( + label: 'vpn'.i18n, + iconPath: AppImagePaths.key, + active: vpnStatus == VPNStatus.connected), + _TabLabel( + label: 'unbounded'.i18n, + iconPath: AppImagePaths.unbounded, + active: shareActive), + ], + ), ), - body: SafeArea(child: _buildBody(ref, isUserPro)), + body: !showUnboundedTab + ? const VpnTab() + : TabBarView( + controller: tabController, + children: const [ + VpnTab(), + UnboundedTab(), + ], + ), ); } +} - Widget _buildBody(WidgetRef ref, bool isUserPro) { - final serverLocation = ref.watch(serverLocationProvider); +/// Tab label with the leading feature icon and green/grey status dot from the +/// Figma spec. The dot reflects whether the feature is running, which is +/// independent of which tab is selected. +class _TabLabel extends StatelessWidget { + const _TabLabel({ + required this.label, + required this.iconPath, + required this.active, + }); - final serverType = serverLocation.serverType.toServerLocationType; + final String label; + final String iconPath; + final bool active; - 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), - ], + @override + Widget build(BuildContext context) { + // TabBar wraps each tab in a DefaultTextStyle carrying the resolved + // selected/unselected label colour, so tinting the icon from it keeps the + // two in step without plumbing the selected index down here. + final labelColor = DefaultTextStyle.of(context).style.color; + return Tab( + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: [ + AppImage(path: iconPath, width: 24, height: 24, color: labelColor), + const SizedBox(width: 8), + Text(label), + const SizedBox(width: 8), + Container( + width: 10, + height: 10, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: active ? AppColors.green6 : AppColors.gray5, + border: Border.all( + color: active ? AppColors.green3 : AppColors.gray3, + width: 2, + ), + ), ), ], ), ); } +} - Widget _buildSetting(WidgetRef ref) { - final routingMode = ref.watch( - radianceSettingsProvider.select((s) => s.routingMode), - ); - final isSplitTunnelingOn = ref.watch( - radianceSettingsProvider.select((s) => s.splitTunneling), - ); - - return Container( - decoration: BoxDecoration( - boxShadow: [ - BoxShadow( - color: AppColors.shadowColor, - blurRadius: 32, - offset: Offset(0, 4), - spreadRadius: 0, - ), - ], - ), - 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 _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), ), - ), - ); - } - - 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, - ), + 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/provider/app_setting_notifier.dart b/lib/features/home/provider/app_setting_notifier.dart index 97af2d8365..ff9f232047 100644 --- a/lib/features/home/provider/app_setting_notifier.dart +++ b/lib/features/home/provider/app_setting_notifier.dart @@ -105,6 +105,18 @@ 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)); + + void setUnboundedTotalHelped(int value) => + update(state.copyWith(unboundedTotalHelped: value)); + Future _writeInitMarker() async { try { final dataDir = await AppStorageUtils.getAppDirectory(); diff --git a/lib/features/home/provider/app_setting_notifier.g.dart b/lib/features/home/provider/app_setting_notifier.g.dart index 1b6adb27d9..21fcecd787 100644 --- a/lib/features/home/provider/app_setting_notifier.g.dart +++ b/lib/features/home/provider/app_setting_notifier.g.dart @@ -42,7 +42,7 @@ final class AppSettingNotifierProvider } String _$appSettingNotifierHash() => - r'5cdda57737603a04881abd030c2711fcc8ef4363'; + r'15fdcdfad400e9a243e2857a0ac05c4c7720698b'; abstract class _$AppSettingNotifier extends $Notifier { AppSetting build(); 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/plans/provider/plans_notifier.g.dart b/lib/features/plans/provider/plans_notifier.g.dart index 61ed275e8d..a58aa91d07 100644 --- a/lib/features/plans/provider/plans_notifier.g.dart +++ b/lib/features/plans/provider/plans_notifier.g.dart @@ -33,7 +33,7 @@ final class PlansNotifierProvider PlansNotifier create() => PlansNotifier(); } -String _$plansNotifierHash() => r'46dafba6df5eac2edceef781f5e9ba3fa24bc30c'; +String _$plansNotifierHash() => r'e180b96469743773ac0d11f94fd8b0b6dc26393a'; abstract class _$PlansNotifier extends $AsyncNotifier { FutureOr build(); diff --git a/lib/features/setting/setting.dart b/lib/features/setting/setting.dart index bc2bf02217..16b0c694d9 100644 --- a/lib/features/setting/setting.dart +++ b/lib/features/setting/setting.dart @@ -6,7 +6,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' @@ -18,6 +20,7 @@ enum _SettingType { account, signIn, vpnSetting, + unboundedSetting, language, appearance, support, @@ -60,6 +63,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 = hasRegisteredProAccount(user); @@ -141,6 +149,15 @@ class _SettingState extends ConsumerState icon: AppImagePaths.glob, onPressed: () => settingMenuTap(_SettingType.vpnSetting), ), + if (unboundedAvailable) ...[ + DividerSpace(), + AppTile( + label: 'unbounded_settings_title'.i18n, + icon: AppImagePaths.share, + onPressed: () => + settingMenuTap(_SettingType.unboundedSetting), + ), + ], DividerSpace(), AppTile( tileKey: const Key('setting.language_tile'), @@ -237,36 +254,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), + ], ], ), ); @@ -310,6 +329,9 @@ class _SettingState extends ConsumerState case _SettingType.vpnSetting: appRouter.push(VPNSetting()); break; + case _SettingType.unboundedSetting: + appRouter.push(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..18dc9ecaf5 --- /dev/null +++ b/lib/features/setting/unbounded_setting.dart @@ -0,0 +1,99 @@ +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'; +import 'package:lantern/features/home/provider/app_setting_notifier.dart'; +import 'package:lantern/features/share_my_connection/share_my_connection.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. +/// +/// Manual port forwarding sits here rather than on the tab because the spec's +/// Unbounded screen has no Advanced section. +@RoutePage(name: 'UnboundedSetting') +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_title'.i18n, + body: ListView( + children: [ + const SizedBox(height: 8), + AppCard( + padding: EdgeInsets.zero, + child: Column( + children: [ + AppTile( + label: 'auto_enable_unbounded'.i18n, + subtitle: Text( + 'auto_enable_unbounded_subtitle'.i18n, + style: textTheme.labelMedium!.copyWith( + color: context.textTertiary, + letterSpacing: 0.0, + ), + ), + icon: AppImagePaths.share, + trailing: SwitchButton( + value: autoEnable, + onChanged: (v) => + ref.read(shareProvider.notifier).setAutoEnable(context, v), + ), + onPressed: () => ref + .read(shareProvider.notifier) + .setAutoEnable(context, !autoEnable), + ), + DividerSpace(), + AppTile( + label: 'hide_unbounded'.i18n, + subtitle: Text( + 'hide_unbounded_subtitle'.i18n, + 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), + ), + ], + ), + ), + const SizedBox(height: 8), + // The only knob in here is the manually forwarded port, which + // exists to select the peer-proxy mode. iOS never enters that mode, + // so the field would report a setting as active that nothing can + // act on. + if (!PlatformUtils.isIOS) ...[ + const UnboundedAdvancedCard(), + const SizedBox(height: 8), + ], + ], + ), + ); + } +} diff --git a/lib/features/setting/vpn_setting.dart b/lib/features/setting/vpn_setting.dart index 73234b1fba..624577145d 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( key: const Key('vpn_setting.list'), padding: const EdgeInsets.all(0), @@ -131,36 +117,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 2f114c1ff3..f05d19a9c5 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'; @@ -179,13 +180,62 @@ class _PeerArc { } class ShareNotifier extends Notifier { - // Disclosure ack persists across launches via LocalStorageService + // Consent ack persists across launches via LocalStorageService // (SharedPreferences). Key-presence is the signal — the value is // arbitrary. Cleared by deleteAll() in the existing reset flow. - static const _smcAckKey = 'smc_disclosure_acked'; + static const _consentAckKey = 'share_consent_acked'; + // Anyone who accepted the old SmC-only disclosure consented to the exit-node + // case, which is the stronger of the two, so that ack carries forward rather + // than re-prompting them. + static const _legacySmcAckKey = 'smc_disclosure_acked'; + Future? _consentInFlight; LocalStorageService get _storage => sl(); - bool get _smcAck => _storage.containsKey(_smcAckKey); - Future _persistSmcAck() => _storage.setString(_smcAckKey, '1'); + bool get _consentAcked => + _storage.containsKey(_consentAckKey) || + _storage.containsKey(_legacySmcAckKey); + Future _persistConsentAck() => _storage.setString(_consentAckKey, '1'); + + /// Prompts for sharing consent if it hasn't been given, and reports whether + /// sharing may proceed. Safe to call when consent already exists — it + /// short-circuits without showing anything. + /// + /// Both modes route other people's traffic through the user's connection, so + /// one disclosure covers both; it is worded for the worst case (SmC, where + /// the user's own IP is the exit) because the mode isn't known until the + /// UPnP probe runs, after consent. + Future ensureConsent(BuildContext context) { + if (_consentAcked) return Future.value(true); + // The toggle, the tab's auto-enable card and its checkbox, and the + // Settings row can all reach this before the first dialog closes. Sharing + // one future keeps that to a single dialog and a single ack write. + return _consentInFlight ??= _showConsentDialog(context) + .whenComplete(() => _consentInFlight = null); + } + + Future _showConsentDialog(BuildContext context) async { + final accepted = await showDialog( + context: context, + barrierDismissible: false, + builder: (_) => const ShareConsentDialog(), + ); + if (accepted != true) return false; + await _persistConsentAck(); + return true; + } + + /// Persists the auto-start preference, collecting consent first when turning + /// it on. Every surface that offers the toggle routes through here: + /// autoStart refuses to run without an ack, so a preference written without + /// one would read as enabled and then silently never start. + Future setAutoEnable(BuildContext context, bool enabled) async { + final settings = ref.read(appSettingProvider.notifier); + if (!enabled) { + settings.setUnboundedAutoEnable(false); + return; + } + if (!await ensureConsent(context)) return; + settings.setUnboundedAutoEnable(true); + } StreamSubscription? _appEventSub; int _workerSeq = 0; @@ -218,44 +268,63 @@ 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; + // Reconcile an auto-enable preference stored before consent existed. + // Earlier builds defaulted it to true and wrote it with no disclosure, so + // after an upgrade the toggle would read as on while autoStart refuses to + // run for want of an ack — enabled in the UI, silently never starting. + // Clear it rather than grandfather consent nobody gave; re-enabling + // prompts. Deferred because build() must not mutate another provider. + if (!_consentAcked && ref.read(appSettingProvider).unboundedAutoEnable) { + Future.microtask( + () => + ref.read(appSettingProvider.notifier).setUnboundedAutoEnable(false), + ); + } + return ShareState(totalCount: persistedTotal); } /// Toggle entry point. Caller passes its BuildContext so we can show the - /// disclosure modal inline, and a WidgetRef so we can drive the radiance + /// consent modal inline, and a WidgetRef so we can drive the radiance /// peer-share toggle. /// - /// Resolution order on enable: - /// 1. If the user has set a manual port in Advanced settings, that - /// is an explicit opt-in — go straight to SmC mode. No UPnP - /// probe, no disclosure (user already crossed that line by - /// configuring the port forward on their router). - /// 2. Otherwise probe UPnP. If UPnP works AND the user accepts - /// the SmC disclosure, run SmC. Decline → Unbounded. - /// 3. UPnP unavailable → Unbounded fallback. + /// Consent is collected first and covers both modes. The mode itself is not + /// the user's choice — it follows from the network: + /// 1. A manual port set in Unbounded Settings means the user has already + /// forwarded a port, so use it and skip the probe. + /// 2. Otherwise probe UPnP: available → SmC, unavailable → Unbounded. Future toggle(BuildContext context, WidgetRef widgetRef) async { if (state.active || state.probing) { await _stop(widgetRef); return; } + // Consent gates every start path, before the mode is even known. + if (!await ensureConsent(context)) return; + // Showing it is async, so re-check: another surface — or a second tap on + // this one — can have started sharing while the dialog was up. + if (state.active || state.probing) return; + state = state.copyWith(probing: true); // iOS runs radiance inside the network extension, whose memory budget // cannot carry the peer proxy's second sing-box instance, so the backend - // refuses SmC there. Short-circuit ahead of every SmC path below: the - // probe blocks ~6s and neither it, a manually forwarded port, nor the - // disclosure dialog can change the outcome, because the constraint is - // the extension's budget rather than reachability or consent. + // refuses SmC there. Fall through to Unbounded before the probe rather + // than after: the probe blocks ~6s and its answer cannot change the mode. + // A manually forwarded port doesn't override this — the constraint is the + // extension's budget, not reachability. if (PlatformUtils.isIOS) { await _start(widgetRef, ShareMode.unbounded); return; } - // Manual port forward bypasses both the UPnP probe and the SmC - // disclosure dialog. Configuring a port in Advanced is an explicit - // user-driven SmC opt-in — they wouldn't have set it up if they - // weren't sure they wanted to share via the residential-IP path. + // Manual port forward skips the UPnP probe: configuring a port by hand is + // an explicit request for the residential-IP path. final manualPortRes = await widgetRef.read(lanternServiceProvider).getPeerManualPort(); final manualPort = manualPortRes.fold((_) => 0, (p) => p); @@ -279,33 +348,27 @@ class ShareNotifier extends Notifier { return; } - if (_smcAck) { - await _start(widgetRef, ShareMode.smc); - return; - } - - if (!context.mounted) { - state = state.copyWith(probing: false); - return; - } - - final accepted = await showDialog( - context: context, - barrierDismissible: false, - builder: (_) => const SmcDisclosureDialog(), - ); + await _start(widgetRef, ShareMode.smc); + } - if (accepted == null) { - // User dismissed without choosing — leave off. - state = state.copyWith(probing: false); - return; - } - if (accepted) { - await _persistSmcAck(); - await _start(widgetRef, ShareMode.smc); - } else { - await _start(widgetRef, ShareMode.unbounded); - } + /// Programmatic entry point used by the Home shell's auto-enable + /// 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 would make the user's device a + /// residential exit, and this path has no UI to disclose that, so it stays + /// on the relayed surface even for a user who has consented. + /// + /// No-ops if already active or in flight, or if consent has never been + /// given — this path cannot prompt, so refusing to start is the only safe + /// direction. Consent is collected by toggle() or by enabling auto-start in + /// Unbounded Settings, both of which have a BuildContext. + Future autoStart(WidgetRef widgetRef) async { + if (state.active || state.probing) return; + if (!_consentAcked) return; + state = state.copyWith(probing: true); + await _start(widgetRef, ShareMode.unbounded); } Future _start(WidgetRef widgetRef, ShareMode mode) async { @@ -314,7 +377,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) { @@ -346,7 +411,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, ); @@ -381,7 +449,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, ); @@ -397,7 +467,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 @@ -433,7 +505,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; @@ -463,10 +535,20 @@ 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. 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); // 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. @@ -586,32 +668,42 @@ 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(); - } + // 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( @@ -622,15 +714,95 @@ 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. + // + // 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, + probing: false, + mode: ShareMode.unbounded, + activeCount: 0, + totalCount: state.totalCount, + phase: SharePhase.idle, + ); + final result = await widgetRef + .read(lanternServiceProvider) + .setUnboundedEnabled(true); + result.fold( + (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, + ); + }, + (_) => {}, + ); + } } final shareProvider = NotifierProvider(ShareNotifier.new); -// ─── Screen ────────────────────────────────────────────────────────────────── +/// 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; +} -class ShareMyConnectionScreen extends HookConsumerWidget { - const ShareMyConnectionScreen({super.key}); +// ─── Tab body ──────────────────────────────────────────────────────────────── + +/// 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) { @@ -638,28 +810,72 @@ 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( + // 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, + Container( + decoration: BoxDecoration( + color: Theme.of(context).cardColor, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: Colors.black12), + ), + padding: const EdgeInsets.all(12), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Tappable despite reading as a static glyph: it is the only + // way back into the welcome dialog once it has been seen. + 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(width: 12), + Expanded( + child: Text( + 'smc_intro'.i18n, + style: textTheme.bodyMedium, + ), + ), + ], + ), ), const SizedBox(height: 16), Expanded( flex: 3, child: Stack( + clipBehavior: Clip.none, 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 — 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: 0, right: 0, @@ -672,7 +888,7 @@ class ShareMyConnectionScreen extends HookConsumerWidget { const SizedBox(height: 8), _StatusCard(state: state, onToggle: () => notifier.toggle(context, ref)), const SizedBox(height: 12), - const _AdvancedCard(), + const _AutoEnableCard(), const SizedBox(height: 16), ], ), @@ -742,85 +958,63 @@ class _StatusCard extends StatelessWidget { borderRadius: BorderRadius.circular(12), border: Border.all(color: Colors.black12), ), - padding: const EdgeInsets.all(16), child: Column( - crossAxisAlignment: CrossAxisAlignment.start, children: [ - Row( - children: [ - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'smc_status_label'.i18n, - style: textTheme.labelLarge, - ), - const SizedBox(height: 4), - Text( - modeLabel, - style: textTheme.bodyMedium?.copyWith( - color: state.active - ? AppColors.blue4 - : Theme.of(context).hintColor, - fontWeight: FontWeight.w600, - ), - ), - ], - ), - ), - // Match the rest of the app's toggles (vpn_setting.dart etc.). - // SwitchButton has no built-in disabled state, so during the - // probe we render the switch but absorb the tap so the user - // doesn't double-fire toggle(). - SwitchButton( - value: state.active || state.probing, - onChanged: (value) { - if (state.probing) return; - onToggle(); - }, - ), - ], - ), - if (state.active) ...[ - const SizedBox(height: 12), - const Divider(height: 1), - const SizedBox(height: 12), - Stack( + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + child: Row( children: [ - 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}'), - ], - ), - Positioned( - top: 0, - right: 0, - child: Tooltip( - triggerMode: TooltipTriggerMode.tap, - waitDuration: const Duration(milliseconds: 200), - showDuration: const Duration(seconds: 8), - preferBelow: false, - margin: const EdgeInsets.symmetric(horizontal: 24), - padding: const EdgeInsets.symmetric( - horizontal: 12, vertical: 10), - textStyle: const TextStyle(color: Colors.white, fontSize: 12), - decoration: BoxDecoration( - color: Colors.black87, - borderRadius: BorderRadius.circular(8), - ), - message: 'smc_connections_tooltip'.i18n, - child: Icon( - Icons.info_outline, - size: 16, - color: Theme.of(context).hintColor, + Icon(Icons.public, + size: 20, color: Theme.of(context).hintColor), + const SizedBox(width: 12), + Expanded( + child: Text.rich( + TextSpan( + style: textTheme.bodyMedium, + children: [ + TextSpan(text: '${'smc_status_label'.i18n}: '), + TextSpan( + text: modeLabel, + style: TextStyle( + color: state.active + ? AppColors.green6 + : Theme.of(context).hintColor, + fontWeight: FontWeight.w600, + ), + ), + ], ), ), ), + const SizedBox(width: 8), + // Match the rest of the app's toggles (vpn_setting.dart etc.). + // SwitchButton has no built-in disabled state, so during the + // probe we render the switch but absorb the tap so the user + // doesn't double-fire toggle(). + SwitchButton( + value: state.active || state.probing, + onChanged: (value) { + if (state.probing) return; + onToggle(); + }, + ), ], ), + ), + if (state.active) ...[ + const Divider(height: 1), + _StatRow( + icon: Icons.person_outline, + label: 'smc_stat_active_now'.i18n, + value: '${state.activeCount}', + tooltip: 'smc_connections_tooltip'.i18n, + ), + const Divider(height: 1), + _StatRow( + icon: Icons.people_outline, + label: 'smc_stat_total_helped'.i18n, + value: '${state.totalCount}', + ), ], ], ), @@ -828,19 +1022,116 @@ class _StatusCard extends StatelessWidget { } } -class _Stat extends StatelessWidget { +class _StatRow extends StatelessWidget { + final IconData icon; final String label; final String value; - const _Stat({required this.label, required this.value}); + final String? tooltip; + + const _StatRow({ + required this.icon, + required this.label, + required this.value, + this.tooltip, + }); @override Widget build(BuildContext context) { final textTheme = Theme.of(context).textTheme; - return Column( - children: [ - Text(value, style: textTheme.headlineSmall), - Text(label, style: textTheme.labelSmall), - ], + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + child: Row( + children: [ + Icon(icon, size: 20, color: Theme.of(context).hintColor), + const SizedBox(width: 12), + Expanded(child: Text(label, style: textTheme.bodyMedium)), + if (tooltip != null) ...[ + Tooltip( + triggerMode: TooltipTriggerMode.tap, + waitDuration: const Duration(milliseconds: 200), + showDuration: const Duration(seconds: 8), + preferBelow: false, + margin: const EdgeInsets.symmetric(horizontal: 24), + padding: + const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + textStyle: const TextStyle(color: Colors.white, fontSize: 12), + decoration: BoxDecoration( + color: Colors.black87, + borderRadius: BorderRadius.circular(8), + ), + message: tooltip!, + child: Icon( + Icons.info_outline, + size: 16, + color: Theme.of(context).hintColor, + ), + ), + const SizedBox(width: 8), + ], + Text( + value, + style: textTheme.bodyMedium?.copyWith( + color: AppColors.blue8, // text/link + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ); + } +} + +/// Mirrors the Unbounded Settings toggle, surfaced on the tab itself because +/// the spec puts the choice next to the thing it controls. +class _AutoEnableCard extends ConsumerWidget { + const _AutoEnableCard(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final textTheme = Theme.of(context).textTheme; + final autoEnable = + ref.watch(appSettingProvider.select((s) => s.unboundedAutoEnable)); + final notifier = ref.read(shareProvider.notifier); + return Container( + decoration: BoxDecoration( + color: Theme.of(context).cardColor, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: Colors.black12), + ), + child: InkWell( + borderRadius: BorderRadius.circular(12), + onTap: () => notifier.setAutoEnable(context, !autoEnable), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + child: Row( + children: [ + Icon(Icons.auto_mode, + size: 20, color: Theme.of(context).hintColor), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('auto_enable_unbounded'.i18n, + style: textTheme.bodyMedium), + Text('auto_enable_unbounded_subtitle'.i18n, + style: textTheme.labelSmall), + ], + ), + ), + const SizedBox(width: 8), + Checkbox( + value: autoEnable, + onChanged: (v) => notifier.setAutoEnable(context, v ?? false), + activeColor: AppColors.blue10, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(4), + ), + ), + ], + ), + ), + ), ); } } @@ -853,22 +1144,42 @@ class _GlobeView extends ConsumerStatefulWidget { } class _GlobeViewState extends ConsumerState<_GlobeView> { - static final _arcColor = AppColors.blue4.withValues(alpha: 0.75); + // The spec draws each arc as a cyan-to-yellow gradient, but + // PointConnectionStyle exposes a single flat colour, so alternate the two + // ends of that ramp by workerIdx instead. Concurrent connections stay + // distinguishable where they overlap near the origin either way. + static final _arcColors = [ + AppColors.blue4.withValues(alpha: 0.75), + AppColors.yellow3.withValues(alpha: 0.75), + ]; static final _originPointColor = AppColors.blue4.withValues(alpha: 0.15); - static final _peerPointColor = AppColors.yellow3.withValues(alpha: 0.15); + static const _peerPointColor = AppColors.green6; static const _atmosphereDark = AppColors.blue4; static const _atmosphereLight = AppColors.blue6; final FlutterEarthGlobeController _globeController = FlutterEarthGlobeController( - isRotating: true, + // Slow continuous spin, matching unbounded.lantern.io. Desktop only: + // the package falls back to a per-pixel Dart projection when its sphere + // shader is unavailable, and that cost is not worth a background + // animation on battery. TickerMode in build() freezes this whenever the + // tab is off screen, so it only runs while the user is watching it. + isRotating: PlatformUtils.isDesktop, rotationSpeed: 0.04, zoom: 0, isZoomEnabled: false, showAtmosphere: true, atmosphereColor: _atmosphereDark, - atmosphereOpacity: 0.2, - atmosphereBlur: 20, + // Soft bloom, not a rim: a small blur reads as a hard teal ring. + atmosphereOpacity: 0.18, + atmosphereBlur: 22, + // The package's defaults (ambient 0.6 / intensity 0.75) multiply the + // surface down to ~60% on the unlit side, turning the light texture into + // a mid-grey ball. The spec's globe renders its ocean at the texture's + // own 233, so almost everything here is ambient; the small directional + // term is only what keeps the sphere from reading flat. + ambientLight: 0.97, + lightIntensity: 0.15, ); StreamSubscription? _eventSub; @@ -879,6 +1190,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 = {}; Brightness? _appliedBrightness; @override @@ -953,6 +1268,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 @@ -996,22 +1314,22 @@ 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, end: _originCoords!, curveScale: .6, style: PointConnectionStyle( - color: _arcColor, + color: _arcColors[event.workerIdx.abs() % _arcColors.length], lineWidth: 3, type: PointConnectionType.solid, - dashAnimateTime: 1000, - dashSize: 13, - spacing: 15, - dotSize: 10, + dashAnimateTime: 0, animateOnAdd: true, ), )); @@ -1020,16 +1338,57 @@ class _GlobeViewState extends ConsumerState<_GlobeView> { coordinates: coords, 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) { _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( + // 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 + // 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 @@ -1038,8 +1397,13 @@ class _GlobeViewState extends ConsumerState<_GlobeView> { // within this widget's box; ClipRect keeps arcs from painting // outside the box when they curve high. final widgetSize = Size(constraints.maxWidth, constraints.maxHeight); - final radius = - min(constraints.maxWidth, constraints.maxHeight) / 2 * 0.7; + // Driven off width, since the spec sizes the globe as a fraction of + // the frame (~50% of it across), then clamped so a short slot doesn't + // push the sphere out of its box. + final radius = min( + constraints.maxWidth * 0.275, + constraints.maxHeight * 0.42, + ); return ClipRect( // copyWith preserves the inherited devicePixelRatio, // textScaleFactor, padding/insets etc. — constructing @@ -1050,6 +1414,25 @@ class _GlobeViewState extends ConsumerState<_GlobeView> { data: MediaQuery.of(context).copyWith(size: widgetSize), child: Stack( children: [ + // Sits behind the sphere because the package paints no + // shadow of its own; matched to the spec's Globe effect. + Align( + alignment: const Alignment(0.0, -0.1), + child: Container( + width: radius * 2, + height: radius * 2, + decoration: const BoxDecoration( + shape: BoxShape.circle, + boxShadow: [ + BoxShadow( + color: Color(0x42006163), + offset: Offset(0, 4), + blurRadius: 64, + ), + ], + ), + ), + ), Positioned.fill( child: FlutterEarthGlobe( controller: _globeController, @@ -1062,18 +1445,19 @@ 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(); @@ -1117,6 +1501,11 @@ class _ArrivalToastState extends ConsumerState<_ArrivalToast> { @override Widget build(BuildContext context) { final event = _current; + // _current only tracks arrivals from the last few seconds, so its absence + // does not mean nobody is connected. The idle pill has to key off the live + // peer count or it claims we are waiting for connections directly above a + // stat reporting several active ones. + final hasPeers = ref.watch(shareProvider).activeCount > 0; return AnimatedSwitcher( duration: const Duration(milliseconds: 280), transitionBuilder: (child, anim) => FadeTransition( @@ -1129,7 +1518,9 @@ class _ArrivalToastState extends ConsumerState<_ArrivalToast> { ), ), child: event == null - ? const SizedBox.shrink(key: ValueKey('arrival-hidden')) + ? (hasPeers + ? const SizedBox.shrink(key: ValueKey('arrival-idle')) + : 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, @@ -1157,6 +1548,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), @@ -1172,12 +1567,47 @@ class _ArrivalCard extends StatelessWidget { child: Row( mainAxisSize: MainAxisSize.min, children: [ - const SizedBox(width: 40, height: 40, child: _HeartBurst()), - const SizedBox(width: 12), + // 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: Stack( + clipBehavior: Clip.none, + alignment: Alignment.center, + children: const [ + // Lottie spreads upward + rightward from the heart. + // 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: 502, + child: _ArrivalLottie(), + ), + CustomPaint(painter: _HeartPainter()), + ], + ), + ), + 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 + // 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, @@ -1190,76 +1620,73 @@ class _ArrivalCard extends StatelessWidget { } } -// ─── Heart burst ───────────────────────────────────────────────────────────── - -/// 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(); +/// 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<_HeartBurst> createState() => _HeartBurstState(); + State<_ArrivalLottie> createState() => _ArrivalLottieState(); } -class _HeartBurstState extends State<_HeartBurst> +class _ArrivalLottieState extends State<_ArrivalLottie> with TickerProviderStateMixin { - AnimationController? _lottieCtrl; + AnimationController? _ctrl; @override void dispose() { - _lottieCtrl?.dispose(); + _ctrl?.dispose(); super.dispose(); } + @override + Widget build(BuildContext context) { + return Lottie.asset( + 'assets/unbounded/explosion.json', + 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, + )..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. +class _WaitingCard extends StatelessWidget { + const _WaitingCard({super.key}); + @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: 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( + 'unbounded_waiting_for_connections'.i18n, + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w500, + color: Theme.of(context).hintColor, ), - ], + ), ), ); } @@ -1295,16 +1722,16 @@ class _HeartPainter extends CustomPainter { // ─── Advanced section ──────────────────────────────────────────────────────── -/// _AdvancedCard exposes power-user knobs that don't belong in the -/// always-visible status card. Today: manual port forward (for users on -/// networks where UPnP doesn't work, who've configured a router-side -/// port forward by hand). +/// UnboundedAdvancedCard exposes power-user knobs that don't belong on the +/// Unbounded tab itself. Today: manual port forward (for users on networks +/// where UPnP doesn't work, who've configured a router-side port forward by +/// hand). Lives in Unbounded Settings. /// /// Persisted via the existing FFI setPeerManualPort path; takes effect /// on the next peer.Client.Start (i.e. next time the toggle is flipped /// on after editing the field). -class _AdvancedCard extends HookConsumerWidget { - const _AdvancedCard(); +class UnboundedAdvancedCard extends HookConsumerWidget { + const UnboundedAdvancedCard({super.key}); @override Widget build(BuildContext context, WidgetRef ref) { @@ -1482,31 +1909,29 @@ class _ManualPortField extends HookConsumerWidget { // ─── Disclosure dialog ─────────────────────────────────────────────────────── -class SmcDisclosureDialog extends StatelessWidget { - const SmcDisclosureDialog({super.key}); +/// One disclosure for both sharing modes. The user is not asked to pick a +/// mode — that follows from whether their router supports port forwarding — +/// so the copy describes the worst case (their own IP as the exit) and the +/// relayed case, and the single choice is whether to share at all. +class ShareConsentDialog extends StatelessWidget { + const ShareConsentDialog({super.key}); @override Widget build(BuildContext context) { final textTheme = Theme.of(context).textTheme; return AlertDialog( - title: Text('smc_disclosure_title'.i18n), + title: Text('share_consent_title'.i18n), content: SingleChildScrollView( child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ - Text( - 'smc_disclosure_body_capability'.i18n, - style: textTheme.bodyMedium, - ), + Text('share_consent_body_what'.i18n, style: textTheme.bodyMedium), const SizedBox(height: 12), - Text( - 'smc_disclosure_body_safety'.i18n, - style: textTheme.bodyMedium, - ), + Text('share_consent_body_ip'.i18n, style: textTheme.bodyMedium), const SizedBox(height: 12), Text( - 'smc_disclosure_body_alternative'.i18n, + 'share_consent_body_safety'.i18n, style: textTheme.bodyMedium?.copyWith( color: Theme.of(context).hintColor, ), @@ -1517,13 +1942,107 @@ class SmcDisclosureDialog extends StatelessWidget { actions: [ TextButton( onPressed: () => Navigator.of(context).pop(false), - child: Text('smc_disclosure_basic'.i18n), + child: Text('share_consent_decline'.i18n), ), FilledButton( onPressed: () => Navigator.of(context).pop(true), - child: Text('smc_disclosure_full'.i18n), + child: Text('share_consent_accept'.i18n), ), ], ); } } + +// ─── 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) { + // 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(() { + appSetting.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( + 'unbounded_welcome_title'.i18n, + style: textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + ), + const SizedBox(height: 16), + Text( + 'unbounded_welcome_body_1'.i18n, + style: textTheme.bodyMedium, + ), + const SizedBox(height: 12), + Text( + 'unbounded_welcome_body_2'.i18n, + style: textTheme.bodyMedium, + ), + const SizedBox(height: 12), + Text( + '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.end, + children: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: Text('got_it'.i18n), + ), + ], + ), + ], + ), + ), + ), + ); + } +}