diff --git a/assets/locales/en.po b/assets/locales/en.po index 792c034b96..ce6ad0ebf9 100644 --- a/assets/locales/en.po +++ b/assets/locales/en.po @@ -57,6 +57,11 @@ msgstr "Share Referral Code" msgid "unbounded" msgstr "Unbounded" +# Tab label shown alongside the Unbounded tab on the Home screen when +# the dual-tab strip is active. +msgid "vpn" +msgstr "VPN" + msgid "help_fight_global_internet_censorship" msgstr "Help Fight Global Internet Censorship" @@ -557,12 +562,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" @@ -607,19 +615,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" @@ -674,6 +707,24 @@ msgstr "Basic mode (Unbounded)" msgid "smc_disclosure_full" msgstr "Full mode (SmC)" +# Unbounded Settings menu entry (Settings → Unbounded Settings) +msgid "unbounded_settings_title" +msgstr "Unbounded Settings" + +# Auto-enable Unbounded toggle +msgid "auto_enable_unbounded" +msgstr "Auto-enable Unbounded" + +msgid "auto_enable_unbounded_subtitle" +msgstr "Turn on automatically when Lantern is open" + +# Hide Unbounded toggle (collapses the Unbounded tab on the Home shell) +msgid "hide_unbounded" +msgstr "Hide Unbounded" + +msgid "hide_unbounded_subtitle" +msgstr "Removes Unbounded from the top of this screen" + msgid "vpn_connected" msgstr "Lantern is now connected." diff --git a/go.mod b/go.mod index 570d554f7e..43d049f933 100644 --- a/go.mod +++ b/go.mod @@ -23,7 +23,7 @@ replace github.com/quic-go/qpack => github.com/quic-go/qpack v0.5.1 require ( github.com/alecthomas/assert/v2 v2.3.0 github.com/getlantern/lantern-server-provisioner v0.0.0-20251031121934-8ea031fccfa9 - github.com/getlantern/radiance v0.0.0-20260531221356-11aa55a6ff16 + github.com/getlantern/radiance v0.0.0-20260607000056-0a9e276a9e00 github.com/sagernet/sing-box v1.12.22 golang.org/x/mobile v0.0.0-20250711185624-d5bb5ecc55c0 golang.org/x/sys v0.41.0 diff --git a/go.sum b/go.sum index 528a7c3e57..734f3f6b4f 100644 --- a/go.sum +++ b/go.sum @@ -261,8 +261,8 @@ github.com/getlantern/pluriconfig v0.0.0-20251126214241-8cc8bc561535 h1:rtDmW8YL github.com/getlantern/pluriconfig v0.0.0-20251126214241-8cc8bc561535/go.mod h1:WKJEdjMOD4IuTRYwjQHjT4bmqDl5J82RShMLxPAvi0Q= github.com/getlantern/publicip v0.0.0-20260328175246-2c460fe80c6b h1:gMYJzEhLrmIqQ+JnjiYNm+UyUDalK3WUmVyecFwmV5g= github.com/getlantern/publicip v0.0.0-20260328175246-2c460fe80c6b/go.mod h1:NpfXdK4ldEKkjQ4P1R+DBF4ua5VFOlxmgHROTnYrApg= -github.com/getlantern/radiance v0.0.0-20260531221356-11aa55a6ff16 h1:CpsYjT3sBimvg/GNYO5IKvRjWDc4BCeDjDQxUNsx8gA= -github.com/getlantern/radiance v0.0.0-20260531221356-11aa55a6ff16/go.mod h1:wemClXaug4hwPdsUEm8g1bCa8tkjk3UjDM+6PfWJwMI= +github.com/getlantern/radiance v0.0.0-20260607000056-0a9e276a9e00 h1:+KshI14A9S7fpfjWGFynGjUarD30oukj0vhhjoQ2YpU= +github.com/getlantern/radiance v0.0.0-20260607000056-0a9e276a9e00/go.mod h1:wemClXaug4hwPdsUEm8g1bCa8tkjk3UjDM+6PfWJwMI= github.com/getlantern/samizdat v0.0.3-0.20260529191731-5ea8ae61ddbf h1:KxiMF+oG0rTtuBi7GiIaHfccYOf69rLJ/VnO5myoYc4= github.com/getlantern/samizdat v0.0.3-0.20260529191731-5ea8ae61ddbf/go.mod h1:uEeykQSW2/6rTjfPlj3MTTo59poSHXfAHTGgzYDkbr0= github.com/getlantern/semconv v0.0.0-20260327040646-21845dda05cb h1:c5YM7b3a4r2J8Eh89KkI6M/iTFe6Bi+b8AJlfkKdFq4= diff --git a/lib/core/models/app_setting.dart b/lib/core/models/app_setting.dart index 3f6eb7a6ee..12fd7acaa8 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 = true, + this.unboundedHidden = false, + this.unboundedWelcomeSeen = false, + this.unboundedTotalHelped = 0, }); AppSetting copyWith({ @@ -31,6 +49,10 @@ class AppSetting { bool? successfulConnection, String? dataCapThreshold, bool? onboardingCompleted, + bool? unboundedAutoEnable, + bool? unboundedHidden, + bool? unboundedWelcomeSeen, + int? unboundedTotalHelped, }) { return AppSetting( locale: newLocale ?? locale, @@ -42,6 +64,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, ); } @@ -55,6 +82,10 @@ class AppSetting { 'successfulConnection': successfulConnection, 'dataCapThreshold': dataCapThreshold, 'onboardingCompleted': onboardingCompleted, + 'unboundedAutoEnable': unboundedAutoEnable, + 'unboundedHidden': unboundedHidden, + 'unboundedWelcomeSeen': unboundedWelcomeSeen, + 'unboundedTotalHelped': unboundedTotalHelped, }; factory AppSetting.fromJson(Map json) => AppSetting( @@ -67,5 +98,12 @@ class AppSetting { successfulConnection: json['successfulConnection'] == true, dataCapThreshold: (json['dataCapThreshold'] ?? '').toString(), onboardingCompleted: json['onboardingCompleted'] == true, + // Default to true when missing (first-time post-upgrade users + // should get the auto-enable behaviour the spec calls for). + unboundedAutoEnable: json['unboundedAutoEnable'] != false, + unboundedHidden: json['unboundedHidden'] == true, + unboundedWelcomeSeen: json['unboundedWelcomeSeen'] == true, + 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 4f9329ba4f..1e85062389 100644 --- a/lib/core/router/router.gr.dart +++ b/lib/core/router/router.gr.dart @@ -9,11 +9,11 @@ // coverage:ignore-file // ignore_for_file: no_leading_underscores_for_library_prefixes -import 'package:auto_route/auto_route.dart' as _i44; -import 'package:collection/collection.dart' as _i48; -import 'package:flutter/material.dart' as _i45; -import 'package:lantern/core/common/common.dart' as _i46; -import 'package:lantern/core/models/user.dart' as _i47; +import 'package:auto_route/auto_route.dart' as _i45; +import 'package:collection/collection.dart' as _i49; +import 'package:flutter/material.dart' as _i46; +import 'package:lantern/core/common/common.dart' as _i47; +import 'package:lantern/core/models/user.dart' as _i48; import 'package:lantern/core/widgets/app_webview.dart' as _i3; import 'package:lantern/features/account/account.dart' as _i1; import 'package:lantern/features/account/delete_account.dart' as _i9; @@ -59,26 +59,27 @@ import 'package:lantern/features/setting/follow_us.dart' as _i13; import 'package:lantern/features/setting/invite_friends.dart' as _i15; import 'package:lantern/features/setting/setting.dart' as _i35; import 'package:lantern/features/setting/smart_routing.dart' as _i38; -import 'package:lantern/features/setting/vpn_setting.dart' as _i42; +import 'package:lantern/features/setting/unbounded_setting.dart' as _i42; +import 'package:lantern/features/setting/vpn_setting.dart' as _i43; import 'package:lantern/features/split_tunneling/apps_split_tunneling.dart' as _i5; import 'package:lantern/features/split_tunneling/split_tunneling.dart' as _i39; import 'package:lantern/features/split_tunneling/split_tunneling_info.dart' as _i40; import 'package:lantern/features/split_tunneling/website_split_tunneling.dart' - as _i43; + as _i44; import 'package:lantern/features/support/support.dart' as _i41; import 'package:lantern/features/vpn/server_selection.dart' as _i34; /// generated route for /// [_i1.Account] -class Account extends _i44.PageRouteInfo { - const Account({List<_i44.PageRouteInfo>? children}) +class Account extends _i45.PageRouteInfo { + const Account({List<_i45.PageRouteInfo>? children}) : super(Account.name, initialChildren: children); static const String name = 'Account'; - static _i44.PageInfo page = _i44.PageInfo( + static _i45.PageInfo page = _i45.PageInfo( name, builder: (data) { return const _i1.Account(); @@ -88,12 +89,12 @@ class Account extends _i44.PageRouteInfo { /// generated route for /// [_i2.AddEmail] -class AddEmail extends _i44.PageRouteInfo { +class AddEmail extends _i45.PageRouteInfo { AddEmail({ - _i45.Key? key, - _i46.AuthFlow authFlow = _i46.AuthFlow.signUp, + _i46.Key? key, + _i47.AuthFlow authFlow = _i47.AuthFlow.signUp, String? password, - List<_i44.PageRouteInfo>? children, + List<_i45.PageRouteInfo>? children, }) : super( AddEmail.name, args: AddEmailArgs(key: key, authFlow: authFlow, password: password), @@ -102,7 +103,7 @@ class AddEmail extends _i44.PageRouteInfo { static const String name = 'AddEmail'; - static _i44.PageInfo page = _i44.PageInfo( + static _i45.PageInfo page = _i45.PageInfo( name, builder: (data) { final args = data.argsAs( @@ -120,13 +121,13 @@ class AddEmail extends _i44.PageRouteInfo { class AddEmailArgs { const AddEmailArgs({ this.key, - this.authFlow = _i46.AuthFlow.signUp, + this.authFlow = _i47.AuthFlow.signUp, this.password, }); - final _i45.Key? key; + final _i46.Key? key; - final _i46.AuthFlow authFlow; + final _i47.AuthFlow authFlow; final String? password; @@ -150,12 +151,12 @@ class AddEmailArgs { /// generated route for /// [_i3.AppWebView] -class AppWebview extends _i44.PageRouteInfo { +class AppWebview extends _i45.PageRouteInfo { AppWebview({ - _i45.Key? key, + _i46.Key? key, required String title, required String url, - List<_i44.PageRouteInfo>? children, + List<_i45.PageRouteInfo>? children, }) : super( AppWebview.name, args: AppWebviewArgs(key: key, title: title, url: url), @@ -164,7 +165,7 @@ class AppWebview extends _i44.PageRouteInfo { static const String name = 'AppWebview'; - static _i44.PageInfo page = _i44.PageInfo( + static _i45.PageInfo page = _i45.PageInfo( name, builder: (data) { final args = data.argsAs(); @@ -176,7 +177,7 @@ class AppWebview extends _i44.PageRouteInfo { class AppWebviewArgs { const AppWebviewArgs({this.key, required this.title, required this.url}); - final _i45.Key? key; + final _i46.Key? key; final String title; @@ -200,13 +201,13 @@ class AppWebviewArgs { /// generated route for /// [_i4.Appearance] -class Appearance extends _i44.PageRouteInfo { - const Appearance({List<_i44.PageRouteInfo>? children}) +class Appearance extends _i45.PageRouteInfo { + const Appearance({List<_i45.PageRouteInfo>? children}) : super(Appearance.name, initialChildren: children); static const String name = 'Appearance'; - static _i44.PageInfo page = _i44.PageInfo( + static _i45.PageInfo page = _i45.PageInfo( name, builder: (data) { return const _i4.Appearance(); @@ -216,13 +217,13 @@ class Appearance extends _i44.PageRouteInfo { /// generated route for /// [_i5.AppsSplitTunneling] -class AppsSplitTunneling extends _i44.PageRouteInfo { - const AppsSplitTunneling({List<_i44.PageRouteInfo>? children}) +class AppsSplitTunneling extends _i45.PageRouteInfo { + const AppsSplitTunneling({List<_i45.PageRouteInfo>? children}) : super(AppsSplitTunneling.name, initialChildren: children); static const String name = 'AppsSplitTunneling'; - static _i44.PageInfo page = _i44.PageInfo( + static _i45.PageInfo page = _i45.PageInfo( name, builder: (data) { return const _i5.AppsSplitTunneling(); @@ -232,13 +233,13 @@ class AppsSplitTunneling extends _i44.PageRouteInfo { /// generated route for /// [_i6.ChoosePaymentMethod] -class ChoosePaymentMethod extends _i44.PageRouteInfo { +class ChoosePaymentMethod extends _i45.PageRouteInfo { ChoosePaymentMethod({ - _i45.Key? key, + _i46.Key? key, required String email, String? code, - required _i46.AuthFlow authFlow, - List<_i44.PageRouteInfo>? children, + required _i47.AuthFlow authFlow, + List<_i45.PageRouteInfo>? children, }) : super( ChoosePaymentMethod.name, args: ChoosePaymentMethodArgs( @@ -252,7 +253,7 @@ class ChoosePaymentMethod extends _i44.PageRouteInfo { static const String name = 'ChoosePaymentMethod'; - static _i44.PageInfo page = _i44.PageInfo( + static _i45.PageInfo page = _i45.PageInfo( name, builder: (data) { final args = data.argsAs(); @@ -274,13 +275,13 @@ class ChoosePaymentMethodArgs { required this.authFlow, }); - final _i45.Key? key; + final _i46.Key? key; final String email; final String? code; - final _i46.AuthFlow authFlow; + final _i47.AuthFlow authFlow; @override String toString() { @@ -304,13 +305,13 @@ class ChoosePaymentMethodArgs { /// generated route for /// [_i7.ConfirmEmail] -class ConfirmEmail extends _i44.PageRouteInfo { +class ConfirmEmail extends _i45.PageRouteInfo { ConfirmEmail({ - _i45.Key? key, + _i46.Key? key, required String email, String? password, - _i46.AuthFlow authFlow = _i46.AuthFlow.signUp, - List<_i44.PageRouteInfo>? children, + _i47.AuthFlow authFlow = _i47.AuthFlow.signUp, + List<_i45.PageRouteInfo>? children, }) : super( ConfirmEmail.name, args: ConfirmEmailArgs( @@ -324,7 +325,7 @@ class ConfirmEmail extends _i44.PageRouteInfo { static const String name = 'ConfirmEmail'; - static _i44.PageInfo page = _i44.PageInfo( + static _i45.PageInfo page = _i45.PageInfo( name, builder: (data) { final args = data.argsAs(); @@ -343,16 +344,16 @@ class ConfirmEmailArgs { this.key, required this.email, this.password, - this.authFlow = _i46.AuthFlow.signUp, + this.authFlow = _i47.AuthFlow.signUp, }); - final _i45.Key? key; + final _i46.Key? key; final String email; final String? password; - final _i46.AuthFlow authFlow; + final _i47.AuthFlow authFlow; @override String toString() { @@ -376,13 +377,13 @@ class ConfirmEmailArgs { /// generated route for /// [_i8.CreatePassword] -class CreatePassword extends _i44.PageRouteInfo { +class CreatePassword extends _i45.PageRouteInfo { CreatePassword({ - _i45.Key? key, + _i46.Key? key, required String email, - required _i46.AuthFlow authFlow, + required _i47.AuthFlow authFlow, required String code, - List<_i44.PageRouteInfo>? children, + List<_i45.PageRouteInfo>? children, }) : super( CreatePassword.name, args: CreatePasswordArgs( @@ -396,7 +397,7 @@ class CreatePassword extends _i44.PageRouteInfo { static const String name = 'CreatePassword'; - static _i44.PageInfo page = _i44.PageInfo( + static _i45.PageInfo page = _i45.PageInfo( name, builder: (data) { final args = data.argsAs(); @@ -418,11 +419,11 @@ class CreatePasswordArgs { required this.code, }); - final _i45.Key? key; + final _i46.Key? key; final String email; - final _i46.AuthFlow authFlow; + final _i47.AuthFlow authFlow; final String code; @@ -448,13 +449,13 @@ class CreatePasswordArgs { /// generated route for /// [_i9.DeleteAccount] -class DeleteAccount extends _i44.PageRouteInfo { - const DeleteAccount({List<_i44.PageRouteInfo>? children}) +class DeleteAccount extends _i45.PageRouteInfo { + const DeleteAccount({List<_i45.PageRouteInfo>? children}) : super(DeleteAccount.name, initialChildren: children); static const String name = 'DeleteAccount'; - static _i44.PageInfo page = _i44.PageInfo( + static _i45.PageInfo page = _i45.PageInfo( name, builder: (data) { return const _i9.DeleteAccount(); @@ -464,13 +465,13 @@ class DeleteAccount extends _i44.PageRouteInfo { /// generated route for /// [_i10.DeveloperMode] -class DeveloperMode extends _i44.PageRouteInfo { - const DeveloperMode({List<_i44.PageRouteInfo>? children}) +class DeveloperMode extends _i45.PageRouteInfo { + const DeveloperMode({List<_i45.PageRouteInfo>? children}) : super(DeveloperMode.name, initialChildren: children); static const String name = 'DeveloperMode'; - static _i44.PageInfo page = _i44.PageInfo( + static _i45.PageInfo page = _i45.PageInfo( name, builder: (data) { return const _i10.DeveloperMode(); @@ -480,11 +481,11 @@ class DeveloperMode extends _i44.PageRouteInfo { /// generated route for /// [_i11.DeviceLimitReached] -class DeviceLimitReached extends _i44.PageRouteInfo { +class DeviceLimitReached extends _i45.PageRouteInfo { DeviceLimitReached({ - _i45.Key? key, - required List<_i47.DeviceModel> devices, - List<_i44.PageRouteInfo>? children, + _i46.Key? key, + required List<_i48.DeviceModel> devices, + List<_i45.PageRouteInfo>? children, }) : super( DeviceLimitReached.name, args: DeviceLimitReachedArgs(key: key, devices: devices), @@ -493,7 +494,7 @@ class DeviceLimitReached extends _i44.PageRouteInfo { static const String name = 'DeviceLimitReached'; - static _i44.PageInfo page = _i44.PageInfo( + static _i45.PageInfo page = _i45.PageInfo( name, builder: (data) { final args = data.argsAs(); @@ -505,9 +506,9 @@ class DeviceLimitReached extends _i44.PageRouteInfo { class DeviceLimitReachedArgs { const DeviceLimitReachedArgs({this.key, required this.devices}); - final _i45.Key? key; + final _i46.Key? key; - final List<_i47.DeviceModel> devices; + final List<_i48.DeviceModel> devices; @override String toString() { @@ -519,7 +520,7 @@ class DeviceLimitReachedArgs { if (identical(this, other)) return true; if (other is! DeviceLimitReachedArgs) return false; return key == other.key && - const _i48.ListEquality<_i47.DeviceModel>().equals( + const _i49.ListEquality<_i48.DeviceModel>().equals( devices, other.devices, ); @@ -527,18 +528,18 @@ class DeviceLimitReachedArgs { @override int get hashCode => - key.hashCode ^ const _i48.ListEquality<_i47.DeviceModel>().hash(devices); + key.hashCode ^ const _i49.ListEquality<_i48.DeviceModel>().hash(devices); } /// generated route for /// [_i12.DownloadLinks] -class DownloadLinks extends _i44.PageRouteInfo { - const DownloadLinks({List<_i44.PageRouteInfo>? children}) +class DownloadLinks extends _i45.PageRouteInfo { + const DownloadLinks({List<_i45.PageRouteInfo>? children}) : super(DownloadLinks.name, initialChildren: children); static const String name = 'DownloadLinks'; - static _i44.PageInfo page = _i44.PageInfo( + static _i45.PageInfo page = _i45.PageInfo( name, builder: (data) { return const _i12.DownloadLinks(); @@ -548,13 +549,13 @@ class DownloadLinks extends _i44.PageRouteInfo { /// generated route for /// [_i13.FollowUs] -class FollowUs extends _i44.PageRouteInfo { - const FollowUs({List<_i44.PageRouteInfo>? children}) +class FollowUs extends _i45.PageRouteInfo { + const FollowUs({List<_i45.PageRouteInfo>? children}) : super(FollowUs.name, initialChildren: children); static const String name = 'FollowUs'; - static _i44.PageInfo page = _i44.PageInfo( + static _i45.PageInfo page = _i45.PageInfo( name, builder: (data) { return const _i13.FollowUs(); @@ -564,13 +565,13 @@ class FollowUs extends _i44.PageRouteInfo { /// generated route for /// [_i14.Home] -class Home extends _i44.PageRouteInfo { - const Home({List<_i44.PageRouteInfo>? children}) +class Home extends _i45.PageRouteInfo { + const Home({List<_i45.PageRouteInfo>? children}) : super(Home.name, initialChildren: children); static const String name = 'Home'; - static _i44.PageInfo page = _i44.PageInfo( + static _i45.PageInfo page = _i45.PageInfo( name, builder: (data) { return const _i14.Home(); @@ -580,13 +581,13 @@ class Home extends _i44.PageRouteInfo { /// generated route for /// [_i15.InviteFriends] -class InviteFriends extends _i44.PageRouteInfo { - const InviteFriends({List<_i44.PageRouteInfo>? children}) +class InviteFriends extends _i45.PageRouteInfo { + const InviteFriends({List<_i45.PageRouteInfo>? children}) : super(InviteFriends.name, initialChildren: children); static const String name = 'InviteFriends'; - static _i44.PageInfo page = _i44.PageInfo( + static _i45.PageInfo page = _i45.PageInfo( name, builder: (data) { return const _i15.InviteFriends(); @@ -596,11 +597,11 @@ class InviteFriends extends _i44.PageRouteInfo { /// generated route for /// [_i16.JoinPrivateServer] -class JoinPrivateServer extends _i44.PageRouteInfo { +class JoinPrivateServer extends _i45.PageRouteInfo { JoinPrivateServer({ - _i45.Key? key, + _i46.Key? key, Map? deepLinkData, - List<_i44.PageRouteInfo>? children, + List<_i45.PageRouteInfo>? children, }) : super( JoinPrivateServer.name, args: JoinPrivateServerArgs(key: key, deepLinkData: deepLinkData), @@ -609,7 +610,7 @@ class JoinPrivateServer extends _i44.PageRouteInfo { static const String name = 'JoinPrivateServer'; - static _i44.PageInfo page = _i44.PageInfo( + static _i45.PageInfo page = _i45.PageInfo( name, builder: (data) { final args = data.argsAs( @@ -626,7 +627,7 @@ class JoinPrivateServer extends _i44.PageRouteInfo { class JoinPrivateServerArgs { const JoinPrivateServerArgs({this.key, this.deepLinkData}); - final _i45.Key? key; + final _i46.Key? key; final Map? deepLinkData; @@ -640,7 +641,7 @@ class JoinPrivateServerArgs { if (identical(this, other)) return true; if (other is! JoinPrivateServerArgs) return false; return key == other.key && - const _i48.MapEquality().equals( + const _i49.MapEquality().equals( deepLinkData, other.deepLinkData, ); @@ -649,18 +650,18 @@ class JoinPrivateServerArgs { @override int get hashCode => key.hashCode ^ - const _i48.MapEquality().hash(deepLinkData); + const _i49.MapEquality().hash(deepLinkData); } /// generated route for /// [_i17.Language] -class Language extends _i44.PageRouteInfo { - const Language({List<_i44.PageRouteInfo>? children}) +class Language extends _i45.PageRouteInfo { + const Language({List<_i45.PageRouteInfo>? children}) : super(Language.name, initialChildren: children); static const String name = 'Language'; - static _i44.PageInfo page = _i44.PageInfo( + static _i45.PageInfo page = _i45.PageInfo( name, builder: (data) { return const _i17.Language(); @@ -670,12 +671,12 @@ class Language extends _i44.PageRouteInfo { /// generated route for /// [_i18.LanternProLicense] -class LanternProLicense extends _i44.PageRouteInfo { +class LanternProLicense extends _i45.PageRouteInfo { LanternProLicense({ - _i45.Key? key, + _i46.Key? key, required String email, required String code, - List<_i44.PageRouteInfo>? children, + List<_i45.PageRouteInfo>? children, }) : super( LanternProLicense.name, args: LanternProLicenseArgs(key: key, email: email, code: code), @@ -684,7 +685,7 @@ class LanternProLicense extends _i44.PageRouteInfo { static const String name = 'LanternProLicense'; - static _i44.PageInfo page = _i44.PageInfo( + static _i45.PageInfo page = _i45.PageInfo( name, builder: (data) { final args = data.argsAs(); @@ -704,7 +705,7 @@ class LanternProLicenseArgs { required this.code, }); - final _i45.Key? key; + final _i46.Key? key; final String email; @@ -728,13 +729,13 @@ class LanternProLicenseArgs { /// generated route for /// [_i19.Logs] -class Logs extends _i44.PageRouteInfo { - const Logs({List<_i44.PageRouteInfo>? children}) +class Logs extends _i45.PageRouteInfo { + const Logs({List<_i45.PageRouteInfo>? children}) : super(Logs.name, initialChildren: children); static const String name = 'Logs'; - static _i44.PageInfo page = _i44.PageInfo( + static _i45.PageInfo page = _i45.PageInfo( name, builder: (data) { return const _i19.Logs(); @@ -744,13 +745,13 @@ class Logs extends _i44.PageRouteInfo { /// generated route for /// [_i20.MacOSExtensionDialog] -class MacOSExtensionDialog extends _i44.PageRouteInfo { - const MacOSExtensionDialog({List<_i44.PageRouteInfo>? children}) +class MacOSExtensionDialog extends _i45.PageRouteInfo { + const MacOSExtensionDialog({List<_i45.PageRouteInfo>? children}) : super(MacOSExtensionDialog.name, initialChildren: children); static const String name = 'MacOSExtensionDialog'; - static _i44.PageInfo page = _i44.PageInfo( + static _i45.PageInfo page = _i45.PageInfo( name, builder: (data) { return const _i20.MacOSExtensionDialog(); @@ -760,13 +761,13 @@ class MacOSExtensionDialog extends _i44.PageRouteInfo { /// generated route for /// [_i21.ManagePrivateServer] -class ManagePrivateServer extends _i44.PageRouteInfo { - const ManagePrivateServer({List<_i44.PageRouteInfo>? children}) +class ManagePrivateServer extends _i45.PageRouteInfo { + const ManagePrivateServer({List<_i45.PageRouteInfo>? children}) : super(ManagePrivateServer.name, initialChildren: children); static const String name = 'ManagePrivateServer'; - static _i44.PageInfo page = _i44.PageInfo( + static _i45.PageInfo page = _i45.PageInfo( name, builder: (data) { return const _i21.ManagePrivateServer(); @@ -776,13 +777,13 @@ class ManagePrivateServer extends _i44.PageRouteInfo { /// generated route for /// [_i22.ManuallyServerSetup] -class ManuallyServerSetup extends _i44.PageRouteInfo { - const ManuallyServerSetup({List<_i44.PageRouteInfo>? children}) +class ManuallyServerSetup extends _i45.PageRouteInfo { + const ManuallyServerSetup({List<_i45.PageRouteInfo>? children}) : super(ManuallyServerSetup.name, initialChildren: children); static const String name = 'ManuallyServerSetup'; - static _i44.PageInfo page = _i44.PageInfo( + static _i45.PageInfo page = _i45.PageInfo( name, builder: (data) { return const _i22.ManuallyServerSetup(); @@ -792,13 +793,13 @@ class ManuallyServerSetup extends _i44.PageRouteInfo { /// generated route for /// [_i23.Onboarding] -class Onboarding extends _i44.PageRouteInfo { - const Onboarding({List<_i44.PageRouteInfo>? children}) +class Onboarding extends _i45.PageRouteInfo { + const Onboarding({List<_i45.PageRouteInfo>? children}) : super(Onboarding.name, initialChildren: children); static const String name = 'Onboarding'; - static _i44.PageInfo page = _i44.PageInfo( + static _i45.PageInfo page = _i45.PageInfo( name, builder: (data) { return const _i23.Onboarding(); @@ -808,13 +809,13 @@ class Onboarding extends _i44.PageRouteInfo { /// generated route for /// [_i24.Plans] -class Plans extends _i44.PageRouteInfo { - const Plans({List<_i44.PageRouteInfo>? children}) +class Plans extends _i45.PageRouteInfo { + const Plans({List<_i45.PageRouteInfo>? children}) : super(Plans.name, initialChildren: children); static const String name = 'Plans'; - static _i44.PageInfo page = _i44.PageInfo( + static _i45.PageInfo page = _i45.PageInfo( name, builder: (data) { return const _i24.Plans(); @@ -824,13 +825,13 @@ class Plans extends _i44.PageRouteInfo { /// generated route for /// [_i25.PrivateServerAddBilling] -class PrivateServerAddBilling extends _i44.PageRouteInfo { - const PrivateServerAddBilling({List<_i44.PageRouteInfo>? children}) +class PrivateServerAddBilling extends _i45.PageRouteInfo { + const PrivateServerAddBilling({List<_i45.PageRouteInfo>? children}) : super(PrivateServerAddBilling.name, initialChildren: children); static const String name = 'PrivateServerAddBilling'; - static _i44.PageInfo page = _i44.PageInfo( + static _i45.PageInfo page = _i45.PageInfo( name, builder: (data) { return const _i25.PrivateServerAddBilling(); @@ -840,11 +841,11 @@ class PrivateServerAddBilling extends _i44.PageRouteInfo { /// generated route for /// [_i26.PrivateServerDeploy] -class PrivateServerDeploy extends _i44.PageRouteInfo { +class PrivateServerDeploy extends _i45.PageRouteInfo { PrivateServerDeploy({ - _i45.Key? key, + _i46.Key? key, required String serverName, - List<_i44.PageRouteInfo>? children, + List<_i45.PageRouteInfo>? children, }) : super( PrivateServerDeploy.name, args: PrivateServerDeployArgs(key: key, serverName: serverName), @@ -853,7 +854,7 @@ class PrivateServerDeploy extends _i44.PageRouteInfo { static const String name = 'PrivateServerDeploy'; - static _i44.PageInfo page = _i44.PageInfo( + static _i45.PageInfo page = _i45.PageInfo( name, builder: (data) { final args = data.argsAs(); @@ -868,7 +869,7 @@ class PrivateServerDeploy extends _i44.PageRouteInfo { class PrivateServerDeployArgs { const PrivateServerDeployArgs({this.key, required this.serverName}); - final _i45.Key? key; + final _i46.Key? key; final String serverName; @@ -891,14 +892,14 @@ class PrivateServerDeployArgs { /// generated route for /// [_i27.PrivateServerLocation] class PrivateServerLocation - extends _i44.PageRouteInfo { + extends _i45.PageRouteInfo { PrivateServerLocation({ - _i45.Key? key, + _i46.Key? key, required List location, required String? selectedLocation, required dynamic Function(String) onLocationSelected, - required _i46.CloudProvider provider, - List<_i44.PageRouteInfo>? children, + required _i47.CloudProvider provider, + List<_i45.PageRouteInfo>? children, }) : super( PrivateServerLocation.name, args: PrivateServerLocationArgs( @@ -913,7 +914,7 @@ class PrivateServerLocation static const String name = 'PrivateServerLocation'; - static _i44.PageInfo page = _i44.PageInfo( + static _i45.PageInfo page = _i45.PageInfo( name, builder: (data) { final args = data.argsAs(); @@ -937,7 +938,7 @@ class PrivateServerLocationArgs { required this.provider, }); - final _i45.Key? key; + final _i46.Key? key; final List location; @@ -945,7 +946,7 @@ class PrivateServerLocationArgs { final dynamic Function(String) onLocationSelected; - final _i46.CloudProvider provider; + final _i47.CloudProvider provider; @override String toString() { @@ -957,7 +958,7 @@ class PrivateServerLocationArgs { if (identical(this, other)) return true; if (other is! PrivateServerLocationArgs) return false; return key == other.key && - const _i48.ListEquality().equals(location, other.location) && + const _i49.ListEquality().equals(location, other.location) && selectedLocation == other.selectedLocation && provider == other.provider; } @@ -965,20 +966,20 @@ class PrivateServerLocationArgs { @override int get hashCode => key.hashCode ^ - const _i48.ListEquality().hash(location) ^ + const _i49.ListEquality().hash(location) ^ selectedLocation.hashCode ^ provider.hashCode; } /// generated route for /// [_i28.PrivateServerSetup] -class PrivateServerSetup extends _i44.PageRouteInfo { - const PrivateServerSetup({List<_i44.PageRouteInfo>? children}) +class PrivateServerSetup extends _i45.PageRouteInfo { + const PrivateServerSetup({List<_i45.PageRouteInfo>? children}) : super(PrivateServerSetup.name, initialChildren: children); static const String name = 'PrivateServerSetup'; - static _i44.PageInfo page = _i44.PageInfo( + static _i45.PageInfo page = _i45.PageInfo( name, builder: (data) { return const _i28.PrivateServerSetup(); @@ -989,13 +990,13 @@ class PrivateServerSetup extends _i44.PageRouteInfo { /// generated route for /// [_i29.PrivateSeverDetails] class PrivateServerDetails - extends _i44.PageRouteInfo { + extends _i45.PageRouteInfo { PrivateServerDetails({ - _i45.Key? key, + _i46.Key? key, required List accounts, - required _i46.CloudProvider provider, + required _i47.CloudProvider provider, bool isPreFilled = false, - List<_i44.PageRouteInfo>? children, + List<_i45.PageRouteInfo>? children, }) : super( PrivateServerDetails.name, args: PrivateServerDetailsArgs( @@ -1009,7 +1010,7 @@ class PrivateServerDetails static const String name = 'PrivateServerDetails'; - static _i44.PageInfo page = _i44.PageInfo( + static _i45.PageInfo page = _i45.PageInfo( name, builder: (data) { final args = data.argsAs(); @@ -1031,11 +1032,11 @@ class PrivateServerDetailsArgs { this.isPreFilled = false, }); - final _i45.Key? key; + final _i46.Key? key; final List accounts; - final _i46.CloudProvider provider; + final _i47.CloudProvider provider; final bool isPreFilled; @@ -1049,7 +1050,7 @@ class PrivateServerDetailsArgs { if (identical(this, other)) return true; if (other is! PrivateServerDetailsArgs) return false; return key == other.key && - const _i48.ListEquality().equals(accounts, other.accounts) && + const _i49.ListEquality().equals(accounts, other.accounts) && provider == other.provider && isPreFilled == other.isPreFilled; } @@ -1057,20 +1058,20 @@ class PrivateServerDetailsArgs { @override int get hashCode => key.hashCode ^ - const _i48.ListEquality().hash(accounts) ^ + const _i49.ListEquality().hash(accounts) ^ provider.hashCode ^ isPreFilled.hashCode; } /// generated route for /// [_i30.QrCodeScanner] -class QrCodeScanner extends _i44.PageRouteInfo { - const QrCodeScanner({List<_i44.PageRouteInfo>? children}) +class QrCodeScanner extends _i45.PageRouteInfo { + const QrCodeScanner({List<_i45.PageRouteInfo>? children}) : super(QrCodeScanner.name, initialChildren: children); static const String name = 'QrCodeScanner'; - static _i44.PageInfo page = _i44.PageInfo( + static _i45.PageInfo page = _i45.PageInfo( name, builder: (data) { return const _i30.QrCodeScanner(); @@ -1080,12 +1081,12 @@ class QrCodeScanner extends _i44.PageRouteInfo { /// generated route for /// [_i31.ReportIssue] -class ReportIssue extends _i44.PageRouteInfo { +class ReportIssue extends _i45.PageRouteInfo { ReportIssue({ - _i45.Key? key, + _i46.Key? key, String? description, String? type, - List<_i44.PageRouteInfo>? children, + List<_i45.PageRouteInfo>? children, }) : super( ReportIssue.name, args: ReportIssueArgs(key: key, description: description, type: type), @@ -1094,7 +1095,7 @@ class ReportIssue extends _i44.PageRouteInfo { static const String name = 'ReportIssue'; - static _i44.PageInfo page = _i44.PageInfo( + static _i45.PageInfo page = _i45.PageInfo( name, builder: (data) { final args = data.argsAs( @@ -1112,7 +1113,7 @@ class ReportIssue extends _i44.PageRouteInfo { class ReportIssueArgs { const ReportIssueArgs({this.key, this.description, this.type}); - final _i45.Key? key; + final _i46.Key? key; final String? description; @@ -1138,12 +1139,12 @@ class ReportIssueArgs { /// generated route for /// [_i32.ResetPassword] -class ResetPassword extends _i44.PageRouteInfo { +class ResetPassword extends _i45.PageRouteInfo { ResetPassword({ - _i45.Key? key, + _i46.Key? key, required String email, required String code, - List<_i44.PageRouteInfo>? children, + List<_i45.PageRouteInfo>? children, }) : super( ResetPassword.name, args: ResetPasswordArgs(key: key, email: email, code: code), @@ -1152,7 +1153,7 @@ class ResetPassword extends _i44.PageRouteInfo { static const String name = 'ResetPassword'; - static _i44.PageInfo page = _i44.PageInfo( + static _i45.PageInfo page = _i45.PageInfo( name, builder: (data) { final args = data.argsAs(); @@ -1168,7 +1169,7 @@ class ResetPassword extends _i44.PageRouteInfo { class ResetPasswordArgs { const ResetPasswordArgs({this.key, required this.email, required this.code}); - final _i45.Key? key; + final _i46.Key? key; final String email; @@ -1192,11 +1193,11 @@ class ResetPasswordArgs { /// generated route for /// [_i33.ResetPasswordEmail] -class ResetPasswordEmail extends _i44.PageRouteInfo { +class ResetPasswordEmail extends _i45.PageRouteInfo { ResetPasswordEmail({ - _i45.Key? key, + _i46.Key? key, String? email, - List<_i44.PageRouteInfo>? children, + List<_i45.PageRouteInfo>? children, }) : super( ResetPasswordEmail.name, args: ResetPasswordEmailArgs(key: key, email: email), @@ -1205,7 +1206,7 @@ class ResetPasswordEmail extends _i44.PageRouteInfo { static const String name = 'ResetPasswordEmail'; - static _i44.PageInfo page = _i44.PageInfo( + static _i45.PageInfo page = _i45.PageInfo( name, builder: (data) { final args = data.argsAs( @@ -1219,7 +1220,7 @@ class ResetPasswordEmail extends _i44.PageRouteInfo { class ResetPasswordEmailArgs { const ResetPasswordEmailArgs({this.key, this.email}); - final _i45.Key? key; + final _i46.Key? key; final String? email; @@ -1241,13 +1242,13 @@ class ResetPasswordEmailArgs { /// generated route for /// [_i34.ServerSelection] -class ServerSelection extends _i44.PageRouteInfo { - const ServerSelection({List<_i44.PageRouteInfo>? children}) +class ServerSelection extends _i45.PageRouteInfo { + const ServerSelection({List<_i45.PageRouteInfo>? children}) : super(ServerSelection.name, initialChildren: children); static const String name = 'ServerSelection'; - static _i44.PageInfo page = _i44.PageInfo( + static _i45.PageInfo page = _i45.PageInfo( name, builder: (data) { return const _i34.ServerSelection(); @@ -1257,13 +1258,13 @@ class ServerSelection extends _i44.PageRouteInfo { /// generated route for /// [_i35.Setting] -class Setting extends _i44.PageRouteInfo { - const Setting({List<_i44.PageRouteInfo>? children}) +class Setting extends _i45.PageRouteInfo { + const Setting({List<_i45.PageRouteInfo>? children}) : super(Setting.name, initialChildren: children); static const String name = 'Setting'; - static _i44.PageInfo page = _i44.PageInfo( + static _i45.PageInfo page = _i45.PageInfo( name, builder: (data) { return const _i35.Setting(); @@ -1273,13 +1274,13 @@ class Setting extends _i44.PageRouteInfo { /// generated route for /// [_i36.SignInEmail] -class SignInEmail extends _i44.PageRouteInfo { - const SignInEmail({List<_i44.PageRouteInfo>? children}) +class SignInEmail extends _i45.PageRouteInfo { + const SignInEmail({List<_i45.PageRouteInfo>? children}) : super(SignInEmail.name, initialChildren: children); static const String name = 'SignInEmail'; - static _i44.PageInfo page = _i44.PageInfo( + static _i45.PageInfo page = _i45.PageInfo( name, builder: (data) { return const _i36.SignInEmail(); @@ -1289,12 +1290,12 @@ class SignInEmail extends _i44.PageRouteInfo { /// generated route for /// [_i37.SignInPassword] -class SignInPassword extends _i44.PageRouteInfo { +class SignInPassword extends _i45.PageRouteInfo { SignInPassword({ - _i45.Key? key, + _i46.Key? key, required String email, bool fromChangeEmail = false, - List<_i44.PageRouteInfo>? children, + List<_i45.PageRouteInfo>? children, }) : super( SignInPassword.name, args: SignInPasswordArgs( @@ -1307,7 +1308,7 @@ class SignInPassword extends _i44.PageRouteInfo { static const String name = 'SignInPassword'; - static _i44.PageInfo page = _i44.PageInfo( + static _i45.PageInfo page = _i45.PageInfo( name, builder: (data) { final args = data.argsAs(); @@ -1327,7 +1328,7 @@ class SignInPasswordArgs { this.fromChangeEmail = false, }); - final _i45.Key? key; + final _i46.Key? key; final String email; @@ -1353,13 +1354,13 @@ class SignInPasswordArgs { /// generated route for /// [_i38.SmartRouting] -class SmartRouting extends _i44.PageRouteInfo { - const SmartRouting({List<_i44.PageRouteInfo>? children}) +class SmartRouting extends _i45.PageRouteInfo { + const SmartRouting({List<_i45.PageRouteInfo>? children}) : super(SmartRouting.name, initialChildren: children); static const String name = 'SmartRouting'; - static _i44.PageInfo page = _i44.PageInfo( + static _i45.PageInfo page = _i45.PageInfo( name, builder: (data) { return const _i38.SmartRouting(); @@ -1369,13 +1370,13 @@ class SmartRouting extends _i44.PageRouteInfo { /// generated route for /// [_i39.SplitTunneling] -class SplitTunneling extends _i44.PageRouteInfo { - const SplitTunneling({List<_i44.PageRouteInfo>? children}) +class SplitTunneling extends _i45.PageRouteInfo { + const SplitTunneling({List<_i45.PageRouteInfo>? children}) : super(SplitTunneling.name, initialChildren: children); static const String name = 'SplitTunneling'; - static _i44.PageInfo page = _i44.PageInfo( + static _i45.PageInfo page = _i45.PageInfo( name, builder: (data) { return const _i39.SplitTunneling(); @@ -1385,13 +1386,13 @@ class SplitTunneling extends _i44.PageRouteInfo { /// generated route for /// [_i40.SplitTunnelingInfo] -class SplitTunnelingInfo extends _i44.PageRouteInfo { - const SplitTunnelingInfo({List<_i44.PageRouteInfo>? children}) +class SplitTunnelingInfo extends _i45.PageRouteInfo { + const SplitTunnelingInfo({List<_i45.PageRouteInfo>? children}) : super(SplitTunnelingInfo.name, initialChildren: children); static const String name = 'SplitTunnelingInfo'; - static _i44.PageInfo page = _i44.PageInfo( + static _i45.PageInfo page = _i45.PageInfo( name, builder: (data) { return const _i40.SplitTunnelingInfo(); @@ -1401,13 +1402,13 @@ class SplitTunnelingInfo extends _i44.PageRouteInfo { /// generated route for /// [_i41.Support] -class Support extends _i44.PageRouteInfo { - const Support({List<_i44.PageRouteInfo>? children}) +class Support extends _i45.PageRouteInfo { + const Support({List<_i45.PageRouteInfo>? children}) : super(Support.name, initialChildren: children); static const String name = 'Support'; - static _i44.PageInfo page = _i44.PageInfo( + static _i45.PageInfo page = _i45.PageInfo( name, builder: (data) { return const _i41.Support(); @@ -1416,33 +1417,49 @@ class Support extends _i44.PageRouteInfo { } /// generated route for -/// [_i42.VPNSetting] -class VPNSetting extends _i44.PageRouteInfo { - const VPNSetting({List<_i44.PageRouteInfo>? children}) +/// [_i42.UnboundedSetting] +class UnboundedSetting extends _i45.PageRouteInfo { + const UnboundedSetting({List<_i45.PageRouteInfo>? children}) + : super(UnboundedSetting.name, initialChildren: children); + + static const String name = 'UnboundedSetting'; + + static _i45.PageInfo page = _i45.PageInfo( + name, + builder: (data) { + return const _i42.UnboundedSetting(); + }, + ); +} + +/// generated route for +/// [_i43.VPNSetting] +class VPNSetting extends _i45.PageRouteInfo { + const VPNSetting({List<_i45.PageRouteInfo>? children}) : super(VPNSetting.name, initialChildren: children); static const String name = 'VPNSetting'; - static _i44.PageInfo page = _i44.PageInfo( + static _i45.PageInfo page = _i45.PageInfo( name, builder: (data) { - return const _i42.VPNSetting(); + return const _i43.VPNSetting(); }, ); } /// generated route for -/// [_i43.WebsiteSplitTunneling] -class WebsiteSplitTunneling extends _i44.PageRouteInfo { - const WebsiteSplitTunneling({List<_i44.PageRouteInfo>? children}) +/// [_i44.WebsiteSplitTunneling] +class WebsiteSplitTunneling extends _i45.PageRouteInfo { + const WebsiteSplitTunneling({List<_i45.PageRouteInfo>? children}) : super(WebsiteSplitTunneling.name, initialChildren: children); static const String name = 'WebsiteSplitTunneling'; - static _i44.PageInfo page = _i44.PageInfo( + static _i45.PageInfo page = _i45.PageInfo( name, builder: (data) { - return const _i43.WebsiteSplitTunneling(); + return const _i44.WebsiteSplitTunneling(); }, ); } diff --git a/lib/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 961deb9022..42470573aa 100644 --- a/lib/features/home/home.dart +++ b/lib/features/home/home.dart @@ -1,96 +1,116 @@ import 'package:auto_route/auto_route.dart'; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:lantern/core/common/app_text_styles.dart'; import 'package:lantern/core/extensions/user_data.dart'; import 'package:lantern/core/models/feature_flags.dart'; import 'package:lantern/core/utils/pro_utils.dart'; -import 'package:lantern/core/widgets/info_row.dart'; -import 'package:lantern/core/widgets/setting_tile.dart'; import 'package:lantern/features/home/provider/app_event_notifier.dart'; import 'package:lantern/features/home/provider/app_setting_notifier.dart'; import 'package:lantern/features/home/provider/feature_flag_notifier.dart'; import 'package:lantern/features/home/provider/home_notifier.dart'; import 'package:lantern/features/home/provider/radiance_settings_providers.dart'; -import 'package:lantern/features/vpn/location_setting.dart'; +import 'package:lantern/features/home/vpn_tab.dart'; +import 'package:lantern/features/share_my_connection/share_my_connection.dart'; import 'package:lantern/features/vpn/provider/available_servers_notifier.dart'; -import 'package:lantern/features/vpn/provider/server_location_notifier.dart'; -import 'package:lantern/features/vpn/vpn_status.dart'; -import 'package:lantern/features/vpn/vpn_switch.dart'; +import 'package:lantern/features/vpn/provider/vpn_notifier.dart'; import '../../core/common/common.dart'; -enum _SettingTileType { smartLocation, splitTunneling, smartRouting } - +/// Root tab shell hosting the VPN and Unbounded tabs. Tab strip lives in +/// the AppBar so the chrome (Lantern logo + settings menu + account +/// actions) is shared across tabs and lines up with the Figma spec at +/// figma.com/design/hNlyYToB5TnX9SDBFDYJTq?node-id=2403-19287. +/// +/// Tab labels carry a small dot indicator that turns green when the +/// matching feature is active (VPN: connected; Unbounded: peer share +/// on) and grey otherwise — also per spec. @RoutePage(name: 'Home') -class Home extends StatefulHookConsumerWidget { +class Home extends HookConsumerWidget { const Home({super.key}); @override - ConsumerState createState() => _HomeState(); -} - -class _HomeState extends ConsumerState { - TextTheme? textTheme; - - @override - void initState() { - super.initState(); - - WidgetsBinding.instance.addPostFrameCallback((_) { - /// Kick off the server fetch as soon as Home mounts so the Smart - /// Location tile can reflect the fastest server without waiting for - /// the user to open the server-selection screen. - ref.read(availableServersProvider); - - final appSetting = ref.read(appSettingProvider); - final appSettingNotifier = ref.read(appSettingProvider.notifier); - if (!appSetting.onboardingCompleted) { - appLogger.info( - "User has not completed onboarding, navigating to Onboarding Screen", - ); - appRouter.push(const Onboarding()); - return; - } - - if (PlatformUtils.isMacOS) { - /// Show macOS system extension dialog if needed - appLogger.info( - "App Setting - showSplashScreen: ${appSetting.showSplashScreen}", - ); - if (appSetting.showSplashScreen) { - appLogger.info("Showing System Extension Dialog"); - appRouter.push(const MacOSExtensionDialog()); - //User has seen dialog, do not show again - appLogger.info("Setting showSplashScreen to false"); - appSettingNotifier.setSplashScreen(false); - return; - } + Widget build(BuildContext context, WidgetRef ref) { + final tabController = useTabController(initialLength: 2); + // Tell the Unbounded globe whether its tab is on screen so it can mute + // its ~60fps sphere re-projection while the user is on the VPN tab + // (TabBarView keeps the off-screen tab mounted and ticking). The globe + // lives in tab index 1, so it should animate whenever any part of it + // is on screen — including mid-swipe. animation.value is the + // fractional tab position (0.0 = VPN fully shown, 1.0 = Unbounded + // fully shown), so > 0 means the Unbounded tab is at least partly + // revealed. (indexIsChanging only covers tap/animateTo, not a finger + // drag, so it would freeze the globe during a swipe in.) + useEffect(() { + void sync() { + final pos = + tabController.animation?.value ?? tabController.index.toDouble(); + ref.read(unboundedTabVisibleProvider.notifier).set(pos > 0.0); } - }); - } - @override - Widget build(BuildContext context) { + tabController.addListener(sync); + 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); }); } @@ -98,21 +118,62 @@ 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 (default ON) AND the per-user + // "Hide Unbounded" toggle. Hiding the tab is the opt-out: a user + // who hid Unbounded should not see it silently auto-enable in + // the background. Fires from two entry points so the spec's + // subtitle "Turn on automatically when Lantern is open" is + // honoured whether the user connects the VPN or not: + // 1. App launch (useEffect below) — once on Home mount. + // 2. VPN connect (ref.listen further down) — on every + // disconnected → connected transition, in case the toggle + // 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), - ), elevation: 5, leading: IconButton( - onPressed: () { - appRouter.push(Setting()); - }, + onPressed: () => appRouter.push(Setting()), icon: const AppImage(path: AppImagePaths.menu), ), actions: [ @@ -125,209 +186,132 @@ class _HomeState extends ConsumerState { final email = localUser!.legacyUserData.email; final isPro = localUser.legacyUserData.isPro; if (isPro && !userSignedIn) { - // this means user has pro account but not signed in await showProAccountFlowDialog( context: context, hasEmail: email.isNotEmpty, ); return; } - appRouter.push(Account()); }, ) else if (!userLoggedIn) AppTextButton( label: 'sign_in'.i18n, - onPressed: () { - appRouter.push(const SignInEmail()); - }, + onPressed: () => appRouter.push(const SignInEmail()), ), ], + // 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, + tabs: [ + _TabLabel( + label: 'vpn'.i18n, + active: vpnStatus == VPNStatus.connected), + _TabLabel( + label: 'unbounded'.i18n, + 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 green/grey status dot from the Figma spec. +class _TabLabel extends StatelessWidget { + const _TabLabel({required this.label, required this.active}); - final serverType = serverLocation.serverType.toServerLocationType; + final String label; + 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) { + return Tab( + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 8, + height: 8, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: active ? AppColors.green4 : context.textDisabled, + ), ), + const SizedBox(width: 6), + Text(label), ], ), ); } +} - 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/vpn_tab.dart b/lib/features/home/vpn_tab.dart new file mode 100644 index 0000000000..68302d04e1 --- /dev/null +++ b/lib/features/home/vpn_tab.dart @@ -0,0 +1,136 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:lantern/core/widgets/info_row.dart'; +import 'package:lantern/core/widgets/setting_tile.dart'; +import 'package:lantern/features/home/provider/radiance_settings_providers.dart'; +import 'package:lantern/features/vpn/location_setting.dart'; +import 'package:lantern/features/vpn/provider/server_location_notifier.dart'; +import 'package:lantern/features/vpn/vpn_status.dart'; +import 'package:lantern/features/vpn/vpn_switch.dart'; + +import '../../core/common/common.dart'; + +/// VPN tab body — the connect toggle, data usage, location, routing and +/// split-tunnel rows. Originally the body of the Home screen; lifted out +/// when Home was refactored into a two-tab shell (VPN + Unbounded). No +/// Scaffold or AppBar — the shell provides that chrome. +class VpnTab extends ConsumerWidget { + const VpnTab({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final isUserPro = ref.watch(isUserProProvider); + final serverLocation = ref.watch(serverLocationProvider); + final serverType = serverLocation.serverType.toServerLocationType; + + return SafeArea( + child: Padding( + padding: EdgeInsets.symmetric(horizontal: defaultSize), + child: Column( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + if (isUserPro) const SizedBox(height: 0) else const ProBanner(), + const VPNSwitch(), + Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (!isUserPro) ...{ + if (serverType == ServerLocationType.privateServer) + InfoRow(text: 'private_server_usage_message'.i18n) + else if (PlatformUtils.isIOS) + const SizedBox.shrink() + else + const DataUsage(), + }, + const SizedBox(height: 8), + _SettingCard(), + SizedBox(height: 10.h), + ], + ), + ], + ), + ), + ); + } +} + +class _SettingCard extends ConsumerWidget { + @override + Widget build(BuildContext context, WidgetRef ref) { + final routingMode = ref.watch( + radianceSettingsProvider.select((s) => s.routingMode), + ); + final isSplitTunnelingOn = ref.watch( + radianceSettingsProvider.select((s) => s.splitTunneling), + ); + + return Container( + decoration: const BoxDecoration( + boxShadow: [ + BoxShadow( + color: AppColors.shadowColor, + blurRadius: 32, + offset: Offset(0, 4), + spreadRadius: 0, + ), + ], + ), + child: Card( + elevation: 0, + margin: EdgeInsets.zero, + child: Column( + children: [ + const VpnStatus(), + const DividerSpace(), + const LocationSetting(), + if (!PlatformUtils.isIOS) ...{ + const DividerSpace(), + SettingTile( + label: 'routing_mode'.i18n, + icon: AppImagePaths.route, + value: routingMode.label(), + actions: [ + IconButton( + onPressed: null, + style: ElevatedButton.styleFrom( + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + icon: const AppImage(path: AppImagePaths.arrowForward), + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + visualDensity: VisualDensity.compact, + ), + ], + onTap: () => appRouter.push(const SmartRouting()), + ), + }, + if (PlatformUtils.isAndroid || + PlatformUtils.isMacOS || + PlatformUtils.isWindows) ...{ + const DividerSpace(), + SettingTile( + label: 'split_tunneling'.i18n, + icon: AppImagePaths.callSpilt, + value: isSplitTunnelingOn ? 'enabled'.i18n : 'disabled'.i18n, + actions: [ + IconButton( + onPressed: null, + style: ElevatedButton.styleFrom( + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + icon: const AppImage(path: AppImagePaths.arrowForward), + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + visualDensity: VisualDensity.compact, + ), + ], + onTap: () => appRouter.push(const SplitTunneling()), + ), + }, + ], + ), + ), + ); + } +} diff --git a/lib/features/setting/setting.dart b/lib/features/setting/setting.dart index 027eb40279..a8f12414b8 100644 --- a/lib/features/setting/setting.dart +++ b/lib/features/setting/setting.dart @@ -7,7 +7,9 @@ import 'package:lantern/core/localization/localization_constants.dart'; import 'package:lantern/core/updater/updater.dart'; import 'package:lantern/core/utils/pro_utils.dart'; import 'package:lantern/core/widgets/subscription_tags.dart'; +import 'package:lantern/core/models/feature_flags.dart'; import 'package:lantern/features/home/provider/app_setting_notifier.dart'; +import 'package:lantern/features/home/provider/feature_flag_notifier.dart'; import 'package:lantern/features/home/provider/home_notifier.dart'; import 'package:lantern/features/plans/restore_purchase_mixin.dart'; import 'package:lantern/features/setting/appearance.dart' @@ -19,6 +21,7 @@ enum _SettingType { account, signIn, vpnSetting, + unboundedSetting, language, appearance, support, @@ -61,6 +64,11 @@ class _SettingState extends ConsumerState final email = ref.watch(userEmailProvider); final appSetting = ref.watch(appSettingProvider); + // Server-side gate. Censored regions get Features[unbounded]=false, + // and every Unbounded-flavoured row in this menu (the settings sub- + // page link AND the project promo card at the bottom) disappears. + final unboundedAvailable = + ref.watch(featureFlagProvider).getBool(FeatureFlag.unbounded); final hasProSession = (user?.legacyUserData.isPro ?? false) && @@ -142,6 +150,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( label: 'language'.i18n, @@ -233,36 +250,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), + ], ], ), ); @@ -317,6 +336,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..47fb82c0a1 --- /dev/null +++ b/lib/features/setting/unbounded_setting.dart @@ -0,0 +1,84 @@ +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 '../../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. +@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: notifier.setUnboundedAutoEnable, + ), + onPressed: () => + notifier.setUnboundedAutoEnable(!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), + ), + ], + ), + ), + ], + ), + ); + } +} diff --git a/lib/features/setting/vpn_setting.dart b/lib/features/setting/vpn_setting.dart index 0071fe5c53..057efc9794 100644 --- a/lib/features/setting/vpn_setting.dart +++ b/lib/features/setting/vpn_setting.dart @@ -6,7 +6,6 @@ import 'package:lantern/core/common/common.dart'; import 'package:lantern/core/widgets/split_tunneling_tile.dart'; import 'package:lantern/core/widgets/switch_button.dart'; import 'package:lantern/features/home/provider/radiance_settings_providers.dart'; -import 'package:lantern/features/share_my_connection/share_my_connection.dart'; @RoutePage(name: 'VPNSetting') class VPNSetting extends HookConsumerWidget { @@ -36,19 +35,6 @@ class VPNSetting extends HookConsumerWidget { final telemetryConsent = ref.watch( radianceSettingsProvider.select((s) => s.telemetry), ); - final peerProxy = ref.watch( - radianceSettingsProvider.select((s) => s.peerProxy), - ); - final unboundedEnabled = ref.watch( - radianceSettingsProvider.select((s) => s.unboundedEnabled), - ); - // The tile reads "On" when EITHER donor protocol is active — - // the disclosure dialog flips peerProxy for "Full mode" and - // unboundedEnabled for "Basic mode", and the user shouldn't - // see a stale "Off" subtitle just because they picked the - // lower-friction Unbounded path. - final shareActive = peerProxy || unboundedEnabled; - return ListView( padding: const EdgeInsets.all(0), shrinkWrap: true, @@ -130,36 +116,10 @@ class VPNSetting extends HookConsumerWidget { }, ), ), - if (PlatformUtils.isDesktop) ...[ - SizedBox(height: 16), - AppCard( - padding: EdgeInsets.zero, - child: AppTile( - label: 'share_my_connection'.i18n, - subtitle: Text( - shareActive - ? 'share_my_connection_on_tap_to_view'.i18n - : 'share_my_connection_subtitle'.i18n, - style: textTheme.labelMedium!.copyWith( - color: context.textTertiary, - letterSpacing: 0.0, - ), - ), - icon: AppImagePaths.share, - trailing: AppImage( - path: AppImagePaths.arrowForward, - height: 20, - ), - onPressed: () { - Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => const ShareMyConnectionScreen(), - ), - ); - }, - ), - ), - ], + // The "Share My Connection" entry that used to push a SmC + // screen from here moved to a top-level Unbounded tab in the + // Home shell — see lib/features/home/home.dart. Toggling peer + // share now happens inside that tab. SizedBox(height: 16), AppCard( padding: EdgeInsets.zero, diff --git a/lib/features/share_my_connection/share_my_connection.dart b/lib/features/share_my_connection/share_my_connection.dart index ed9d7f2cb0..cc56d92639 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'; @@ -218,7 +219,13 @@ class ShareNotifier extends Notifier { _stopEventSubscription(); _eventController.close(); }); - return const ShareState(); + // Seed totalCount from the persisted lifetime running total so the + // "Total people helped to date" stat survives app restarts. New + // arrivals (line further down) increment both ShareState.totalCount + // and the persisted value via setUnboundedTotalHelped. + final persistedTotal = + ref.read(appSettingProvider).unboundedTotalHelped; + return ShareState(totalCount: persistedTotal); } /// Toggle entry point. Caller passes its BuildContext so we can show the @@ -297,13 +304,33 @@ class ShareNotifier extends Notifier { } } + /// 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 requires the explicit-disclosure + /// dialog enforced by toggle(); auto-enabling SmC would silently + /// turn the user's device into a residential exit they never + /// agreed to act as. The auto path is the low-friction Unbounded + /// surface only. + /// + /// No-ops if already active or in flight. + Future autoStart(WidgetRef widgetRef) async { + if (state.active || state.probing) return; + state = state.copyWith(probing: true); + await _start(widgetRef, ShareMode.unbounded); + } + Future _start(WidgetRef widgetRef, ShareMode mode) async { state = ShareState( active: true, 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) { @@ -335,7 +362,10 @@ class ShareNotifier extends Notifier { probing: false, mode: ShareMode.off, activeCount: 0, - totalCount: 0, + // Preserve lifetime totalCount across a failed Start — the + // persisted "Total people helped to date" stat is set + // independent of the active session's outcome. + totalCount: state.totalCount, phase: SharePhase.error, errorMessage: err.error, ); @@ -370,7 +400,9 @@ class ShareNotifier extends Notifier { probing: false, mode: ShareMode.off, activeCount: 0, - totalCount: 0, + // Preserve lifetime totalCount across a failed Start (see + // matching comment in the SmC branch above). + totalCount: state.totalCount, phase: SharePhase.error, errorMessage: err.error, ); @@ -386,7 +418,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 @@ -422,7 +456,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; @@ -452,10 +486,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. @@ -575,32 +619,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( @@ -611,15 +665,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; +} + +// ─── Tab body ──────────────────────────────────────────────────────────────── -class ShareMyConnectionScreen extends HookConsumerWidget { - const ShareMyConnectionScreen({super.key}); +/// Unbounded tab content, rendered inside the Home tab shell (see +/// home.dart). Hosts the description text, globe + arrival toast, the +/// status card with the toggle, and the advanced section. No Scaffold +/// or AppBar — the shell provides the chrome and the tab strip. +class UnboundedTab extends HookConsumerWidget { + const UnboundedTab({super.key}); @override Widget build(BuildContext context, WidgetRef ref) { @@ -627,28 +761,64 @@ 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, + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Text( + 'smc_intro'.i18n, + style: textTheme.bodyMedium, + ), + ), + // Info bubble — re-opens the welcome popup. Mirrors the + // Figma spec, which calls out the info-bubble as the + // way back into the explanatory dialog. + IconButton( + icon: const Icon(Icons.info_outline, size: 20), + visualDensity: VisualDensity.compact, + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + tooltip: 'about_unbounded'.i18n, + onPressed: () => showUnboundedWelcomeDialog(context, ref), + ), + ], ), const SizedBox(height: 16), Expanded( 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, @@ -780,8 +950,12 @@ class _StatusCard extends StatelessWidget { Row( mainAxisAlignment: MainAxisAlignment.spaceAround, children: [ - _Stat(label: 'smc_stat_active_now'.i18n, value: '${state.activeCount}'), - _Stat(label: 'smc_stat_total_today'.i18n, value: '${state.totalCount}'), + _Stat( + label: 'smc_stat_active_now'.i18n, + value: '${state.activeCount}'), + _Stat( + label: 'smc_stat_total_helped'.i18n, + value: '${state.totalCount}'), ], ), Positioned( @@ -850,8 +1024,12 @@ class _GlobeViewState extends ConsumerState<_GlobeView> { final FlutterEarthGlobeController _globeController = FlutterEarthGlobeController( - isRotating: true, - rotationSpeed: 0.04, + // Static globe — no continuous rotation. A spinning sphere re-projects + // in software via setState() every frame (~60fps), which is the + // dominant CPU cost of this tab. Instead we rotate only on demand + // (focusOnCoordinates) to bring each new connection into view, then + // settle back to static. + isRotating: false, zoom: 0, isZoomEnabled: false, showAtmosphere: true, @@ -868,6 +1046,10 @@ class _GlobeViewState extends ConsumerState<_GlobeView> { // workerIdx +1's again before it fires. final Map _pendingRemovals = {}; static const _arcLinger = Duration(seconds: 5); + // workerIdx of every arc+point currently on the globe, so a stop can + // remove them all without relying on per-peer -1 events (which _stop() + // never emits). + final Set _drawn = {}; @override void initState() { @@ -921,6 +1103,9 @@ class _GlobeViewState extends ConsumerState<_GlobeView> { coordinates: coords, style: PointStyle(color: _originPointColor, size: 8), )); + // Center the static globe on the user's own location so the first + // thing shown is "us" (instant — no intro spin). + _globeController.focusOnCoordinates(coords, animate: false); // Origin coords are now known — draw any peers that connected // before (or during) the origin lookup. _addPeer's null-guard // skipped them earlier; replayCurrentPeers re-fires +1 events @@ -964,9 +1149,12 @@ class _GlobeViewState extends ConsumerState<_GlobeView> { // skipped here gets drawn with the correct destination. if (_originCoords == null) return; final coords = _jittered(event.coordinates!, event.workerIdx); - // Arc direction is censored user → uncensored peer (us). The dash - // animation flows from start to end, so the visual "travel" reads - // as traffic arriving at our peer to escape censorship. + // Solid arc, censored user → uncensored peer (us), drawn in once on + // add (animateOnAdd). dashAnimateTime is 0 on purpose: a non-zero + // value makes flutter_earth_globe run its ~60fps repaint loop for as + // long as any arc exists, which would re-spike the CPU the whole time + // a peer is connected — the same per-frame full-sphere repaint we + // removed by disabling rotation. _globeController.addPointConnection(PointConnection( id: 'conn_${event.workerIdx}', start: coords, @@ -976,10 +1164,7 @@ class _GlobeViewState extends ConsumerState<_GlobeView> { color: _arcColor, lineWidth: 3, type: PointConnectionType.solid, - dashAnimateTime: 1000, - dashSize: 13, - spacing: 15, - dotSize: 10, + dashAnimateTime: 0, animateOnAdd: true, ), )); @@ -988,16 +1173,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 @@ -1030,18 +1256,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(); @@ -1097,7 +1324,10 @@ class _ArrivalToastState extends ConsumerState<_ArrivalToast> { ), ), child: event == null - ? const SizedBox.shrink(key: ValueKey('arrival-hidden')) + // Idle state — the spec wants a "Waiting for connections..." + // pill rather than empty space, so the user knows the screen + // is live and just nothing has arrived yet. + ? const _WaitingCard(key: ValueKey('arrival-waiting')) : _ArrivalCard( // ValueKey forces AnimatedSwitcher to swap children when a // new arrival lands while the previous toast is still up, @@ -1125,6 +1355,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), @@ -1140,12 +1374,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, @@ -1158,76 +1427,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, ), - ], + ), ), ); } @@ -1495,3 +1761,97 @@ class SmcDisclosureDialog extends StatelessWidget { ); } } + +// ─── Welcome dialog ────────────────────────────────────────────────────────── + +/// Shows the first-visit Unbounded welcome popup per Figma +/// (figma.com/design/hNlyYToB5TnX9SDBFDYJTq?node-id=2403-19287). +/// Idempotent: dismissing the dialog (either button OR scrim tap) +/// flips appSettingProvider.unboundedWelcomeSeen → true so the dialog +/// only fires on the first visit. The info-bubble icon in the +/// Unbounded tab header calls this same function to re-open it later. +void showUnboundedWelcomeDialog(BuildContext context, WidgetRef ref) { + // 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), + ), + ], + ), + ], + ), + ), + ), + ); + } +}