diff --git a/assets/images/action_mode.svg b/assets/images/action_mode.svg new file mode 100644 index 0000000000..9d1a8eeb16 --- /dev/null +++ b/assets/images/action_mode.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/assets/images/action_mode_auto.svg b/assets/images/action_mode_auto.svg new file mode 100644 index 0000000000..a05ae8e1be --- /dev/null +++ b/assets/images/action_mode_auto.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/assets/images/action_mode_people.svg b/assets/images/action_mode_people.svg new file mode 100644 index 0000000000..c2d1ec3ab7 --- /dev/null +++ b/assets/images/action_mode_people.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/assets/images/action_mode_person.svg b/assets/images/action_mode_person.svg new file mode 100644 index 0000000000..da01761f7a --- /dev/null +++ b/assets/images/action_mode_person.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/assets/locales/ar.po b/assets/locales/ar.po index ab253e6b37..b0d5a20d4b 100644 --- a/assets/locales/ar.po +++ b/assets/locales/ar.po @@ -138,7 +138,7 @@ msgid "remind_use_code" msgstr "ذكّرهم باستخدام الرمز %s عند الترقية!" msgid "unbounded" -msgstr "Unbounded" +msgstr "Action Mode" msgid "help_fight_global_internet_censorship" msgstr "ساعد في مكافحة الرقابة العالمية على الإنترنت" diff --git a/assets/locales/bn.po b/assets/locales/bn.po index 4190c7689a..a57c6bc850 100644 --- a/assets/locales/bn.po +++ b/assets/locales/bn.po @@ -130,7 +130,7 @@ msgid "remind_use_code" msgstr "আপগ্রেড করার সময় %s কোডটি ব্যবহার করতে তাদের মনে করিয়ে দিন!" msgid "unbounded" -msgstr "Unbounded" +msgstr "Action Mode" msgid "help_fight_global_internet_censorship" msgstr "বিশ্বব্যাপী ইন্টারনেট সেন্সরশিপের বিরুদ্ধে লড়াইয়ে সাহায্য করুন" diff --git a/assets/locales/en.po b/assets/locales/en.po index fcbb4ecaf2..8eb660d233 100644 --- a/assets/locales/en.po +++ b/assets/locales/en.po @@ -112,7 +112,7 @@ msgid "remind_use_code" msgstr "Remind them to use code %s when they upgrade!" msgid "unbounded" -msgstr "Unbounded" +msgstr "Action Mode" # Tab label shown alongside the Unbounded tab on the Home screen when # the dual-tab strip is active. @@ -630,14 +630,14 @@ msgstr "Help others bypass censorship by securely sharing your connection." # Info-bubble tooltip on the Unbounded tab header msgid "about_unbounded" -msgstr "Learn more about Unbounded" +msgstr "About Action Mode" # Status card — phase labels msgid "smc_status_label" msgstr "Status" msgid "smc_status_off" -msgstr "Off" +msgstr "Disabled" msgid "smc_status_configuring" msgstr "Configuring network" @@ -646,10 +646,10 @@ msgid "smc_status_probing" msgstr "Probing your network…" msgid "smc_status_active_unbounded" -msgstr "Active — sharing via Unbounded (WebRTC)" +msgstr "Enabled" msgid "smc_status_active_smc" -msgstr "Active — sharing via Share My Connection (residential proxy)" +msgstr "Starting…" msgid "smc_status_mapping_port" msgstr "Opening port on your router…" @@ -667,7 +667,7 @@ msgid "smc_status_verifying" msgstr "Verifying connectivity…" msgid "smc_status_serving" -msgstr "Sharing — ready to serve users in censored regions" +msgstr "Enabled" msgid "smc_status_stopping" msgstr "Stopping…" @@ -683,12 +683,12 @@ msgstr "Couldn't share — try toggling again" # helped, not helpers); parallel framing to smc_stat_total_helped # (same semantics, lifetime). msgid "smc_stat_active_now" -msgstr "People being helped right now" +msgstr "People you are helping right now:" # 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" +msgstr "Total people helped to date:" # Arrival toast surfaced when a censored user starts routing through # this peer. The "Helping a new person" framing is intentional — @@ -703,16 +703,16 @@ msgstr "Waiting for connections..." # Welcome dialog shown the first time the Unbounded tab opens. msgid "unbounded_welcome_title" -msgstr "Welcome to Unbounded" +msgstr "Welcome to Action Mode" 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." +msgstr "When you enable Action Mode, 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." +msgstr "You can remove Action Mode from the interface anytime in Settings." # Advanced section / manual port forward msgid "smc_advanced" @@ -737,7 +737,7 @@ msgid "smc_manual_port_save" msgstr "Save" msgid "smc_manual_port_currently_set" -msgstr "Currently set to port %d. Toggle Share My Connection off and back on for the change to take effect." +msgstr "Currently set to port %d. Toggle Action Mode off and back on for the change to take effect." msgid "smc_manual_port_out_of_range" msgstr "Port must be between 1 and 65535" @@ -750,21 +750,21 @@ msgstr "Manual port set to %d" # Unbounded Settings menu entry (Settings → Unbounded Settings) msgid "unbounded_settings_title" -msgstr "Unbounded Settings" +msgstr "Action Mode Settings" # Auto-enable Unbounded toggle msgid "auto_enable_unbounded" -msgstr "Auto-enable Unbounded" +msgstr "Auto-enable Action Mode" msgid "auto_enable_unbounded_subtitle" msgstr "Turns on when Lantern opens" # Hide Unbounded toggle (collapses the Unbounded tab on the Home shell) msgid "hide_unbounded" -msgstr "Hide Unbounded" +msgstr "Hide Action Mode" msgid "hide_unbounded_subtitle" -msgstr "Removes Unbounded from the UI" +msgstr "Removes Action Mode from the UI" msgid "vpn_connected" msgstr "Lantern is now connected." diff --git a/assets/locales/es-cu.po b/assets/locales/es-cu.po index e62a173175..ca8033de9c 100644 --- a/assets/locales/es-cu.po +++ b/assets/locales/es-cu.po @@ -137,7 +137,7 @@ msgid "remind_use_code" msgstr "¡Recuérdales usar el código %s al actualizarse!" msgid "unbounded" -msgstr "Unbounded" +msgstr "Action Mode" msgid "help_fight_global_internet_censorship" msgstr "Ayuda a Combatir la Censura Global de Internet" diff --git a/assets/locales/es.po b/assets/locales/es.po index 45bc3eecef..5f66ea677d 100644 --- a/assets/locales/es.po +++ b/assets/locales/es.po @@ -134,7 +134,7 @@ msgid "remind_use_code" msgstr "¡Recuérdales usar el código %s al actualizarse!" msgid "unbounded" -msgstr "Unbounded" +msgstr "Action Mode" msgid "help_fight_global_internet_censorship" msgstr "Ayuda a Combatir la Censura Global de Internet" diff --git a/assets/locales/fa.po b/assets/locales/fa.po index 3052553b53..dbb9c56f66 100644 --- a/assets/locales/fa.po +++ b/assets/locales/fa.po @@ -140,7 +140,7 @@ msgid "remind_use_code" msgstr "به آن‌ها یادآوری کنید هنگام ارتقا از کد %s استفاده کنند!" msgid "unbounded" -msgstr "Unbounded" +msgstr "Action Mode" msgid "help_fight_global_internet_censorship" msgstr "در مبارزه با سانسور جهانی اینترنت هم‌راه شوید" diff --git a/assets/locales/fr.po b/assets/locales/fr.po index 86948e36d2..fd725a7e36 100644 --- a/assets/locales/fr.po +++ b/assets/locales/fr.po @@ -132,7 +132,7 @@ msgid "remind_use_code" msgstr "Rappelez-leur d'utiliser le code %s lors de leur passage à Pro !" msgid "unbounded" -msgstr "Unbounded" +msgstr "Action Mode" msgid "help_fight_global_internet_censorship" msgstr "Aidez à combattre la censure mondiale d'Internet" diff --git a/assets/locales/hi.po b/assets/locales/hi.po index c0864fb832..6e6140018a 100644 --- a/assets/locales/hi.po +++ b/assets/locales/hi.po @@ -133,7 +133,7 @@ msgid "remind_use_code" msgstr "उन्हें याद दिलाएँ कि अपग्रेड करते समय कोड %s का उपयोग करें!" msgid "unbounded" -msgstr "Unbounded" +msgstr "Action Mode" msgid "help_fight_global_internet_censorship" msgstr "वैश्विक इंटरनेट सेंसरशिप से लड़ने में मदद करें" diff --git a/assets/locales/ms.po b/assets/locales/ms.po index 7cf7068107..9011956216 100644 --- a/assets/locales/ms.po +++ b/assets/locales/ms.po @@ -133,7 +133,7 @@ msgid "remind_use_code" msgstr "Ingatkan mereka untuk menggunakan kod %s semasa menaik taraf!" msgid "unbounded" -msgstr "Unbounded" +msgstr "Action Mode" msgid "help_fight_global_internet_censorship" msgstr "Bantu Menentang Penapisan Internet Global" diff --git a/assets/locales/my.po b/assets/locales/my.po index 6a4be2d3ff..c70afbe67c 100644 --- a/assets/locales/my.po +++ b/assets/locales/my.po @@ -131,7 +131,7 @@ msgid "remind_use_code" msgstr "အဆင့်မြှင့်သောအခါ ကုဒ် %s ကို အသုံးပြုရန် သူတို့ကို သတိပေးပါ!" msgid "unbounded" -msgstr "Unbounded" +msgstr "Action Mode" msgid "help_fight_global_internet_censorship" msgstr "ကမ္ဘာလုံးဆိုင်ရာ အင်တာနက် ဆင်ဆာဖြတ်ခြင်းကို တိုက်ဖျက်ရန် ကူညီပါ" diff --git a/assets/locales/ps.po b/assets/locales/ps.po index 7ce4d0e576..5440a34c0c 100644 --- a/assets/locales/ps.po +++ b/assets/locales/ps.po @@ -130,7 +130,7 @@ msgid "remind_use_code" msgstr "دوی ته ورپه یاد کړئ چې د لوړېدو پر مهال %s کوډ وکاروي!" msgid "unbounded" -msgstr "Unbounded" +msgstr "Action Mode" msgid "help_fight_global_internet_censorship" msgstr "د نړیوال انټرنیټ سانسورشپ په مقابله کې مرسته وکړئ" diff --git a/assets/locales/pt_BR.po b/assets/locales/pt_BR.po index 4fd3e93ed8..b0b1e7dd79 100644 --- a/assets/locales/pt_BR.po +++ b/assets/locales/pt_BR.po @@ -136,7 +136,7 @@ msgid "remind_use_code" msgstr "Lembre-os de usar o código %s ao fazer o upgrade!" msgid "unbounded" -msgstr "Unbounded" +msgstr "Action Mode" msgid "help_fight_global_internet_censorship" msgstr "Ajude a combater a censura global na Internet" diff --git a/assets/locales/ru.po b/assets/locales/ru.po index a93e44b978..cf8345eaae 100644 --- a/assets/locales/ru.po +++ b/assets/locales/ru.po @@ -138,7 +138,7 @@ msgid "remind_use_code" msgstr "Напомните им использовать код %s при переходе на Pro!" msgid "unbounded" -msgstr "Unbounded" +msgstr "Action Mode" msgid "help_fight_global_internet_censorship" msgstr "Помогите бороться с интернет-цензурой по всему миру" diff --git a/assets/locales/th.po b/assets/locales/th.po index 3c2380a221..86ac84a4b0 100644 --- a/assets/locales/th.po +++ b/assets/locales/th.po @@ -132,7 +132,7 @@ msgid "remind_use_code" msgstr "เตือนให้พวกเขาใช้รหัส %s เมื่ออัปเกรด!" msgid "unbounded" -msgstr "Unbounded" +msgstr "Action Mode" msgid "help_fight_global_internet_censorship" msgstr "ช่วยต่อสู้กับการเซ็นเซอร์อินเทอร์เน็ตทั่วโลก" diff --git a/assets/locales/tk.po b/assets/locales/tk.po index 5e2df5fcb7..f106964799 100644 --- a/assets/locales/tk.po +++ b/assets/locales/tk.po @@ -128,7 +128,7 @@ msgid "remind_use_code" msgstr "Geçenlerinde %s koduny ulanmagy ýatladyň!" msgid "unbounded" -msgstr "Unbounded" +msgstr "Action Mode" msgid "help_fight_global_internet_censorship" msgstr "Global internet senzurasy bilen göreşmäge kömek ediň" diff --git a/assets/locales/tr.po b/assets/locales/tr.po index a3a30c0969..4a983e1474 100644 --- a/assets/locales/tr.po +++ b/assets/locales/tr.po @@ -137,7 +137,7 @@ msgid "remind_use_code" msgstr "Yükseltirken %s kodunu kullanmalarını hatırlatın!" msgid "unbounded" -msgstr "Unbounded" +msgstr "Action Mode" msgid "help_fight_global_internet_censorship" msgstr "Küresel İnternet Sansürüne Karşı Mücadeleye Yardım Et" diff --git a/assets/locales/ur-in.po b/assets/locales/ur-in.po index f7ce305300..21a543e43f 100644 --- a/assets/locales/ur-in.po +++ b/assets/locales/ur-in.po @@ -132,7 +132,7 @@ msgid "remind_use_code" msgstr "انہیں یاد دلائیں کہ اپ گریڈ کرتے وقت کوڈ %s استعمال کریں!" msgid "unbounded" -msgstr "Unbounded" +msgstr "Action Mode" msgid "help_fight_global_internet_censorship" msgstr "عالمی انٹرنیٹ سنسرشپ کے خلاف لڑنے میں مدد کریں" diff --git a/assets/locales/vi.po b/assets/locales/vi.po index 66ed62d475..01b4bda3dc 100644 --- a/assets/locales/vi.po +++ b/assets/locales/vi.po @@ -134,7 +134,7 @@ msgid "remind_use_code" msgstr "Nhắc họ dùng mã %s khi nâng cấp!" msgid "unbounded" -msgstr "Unbounded" +msgstr "Action Mode" msgid "help_fight_global_internet_censorship" msgstr "Chung tay chống kiểm duyệt Internet toàn cầu" diff --git a/assets/locales/zh-Hans.po b/assets/locales/zh-Hans.po index 35d8633d14..90ed29edc4 100644 --- a/assets/locales/zh-Hans.po +++ b/assets/locales/zh-Hans.po @@ -125,7 +125,7 @@ msgid "remind_use_code" msgstr "提醒他们升级时使用推荐码 %s!" msgid "unbounded" -msgstr "Unbounded" +msgstr "Action Mode" msgid "help_fight_global_internet_censorship" msgstr "共同对抗全球互联网审查" diff --git a/assets/locales/zh-Hant.po b/assets/locales/zh-Hant.po index 34d164832d..70fddab320 100644 --- a/assets/locales/zh-Hant.po +++ b/assets/locales/zh-Hant.po @@ -127,7 +127,7 @@ msgid "remind_use_code" msgstr "提醒他們升級時使用推薦碼 %s!" msgid "unbounded" -msgstr "Unbounded" +msgstr "Action Mode" msgid "help_fight_global_internet_censorship" msgstr "協助對抗全球網路審查" diff --git a/lib/core/common/app_image_paths.dart b/lib/core/common/app_image_paths.dart index 4a53fa704b..8bc73c7cdc 100644 --- a/lib/core/common/app_image_paths.dart +++ b/lib/core/common/app_image_paths.dart @@ -1,4 +1,8 @@ class AppImagePaths { + static const actionMode = 'assets/images/action_mode.svg'; + static const actionModeAuto = 'assets/images/action_mode_auto.svg'; + static const actionModePerson = 'assets/images/action_mode_person.svg'; + static const actionModePeople = 'assets/images/action_mode_people.svg'; static const lanternLogo = 'assets/images/lantern_logo.svg'; static const menu = 'assets/images/menu.svg'; static const crown = 'assets/images/crown.svg'; diff --git a/lib/features/home/home.dart b/lib/features/home/home.dart index 5f6900e93d..90884b0d6c 100644 --- a/lib/features/home/home.dart +++ b/lib/features/home/home.dart @@ -1,11 +1,11 @@ +import 'package:lantern/core/common/app_text_styles.dart'; import 'package:auto_route/auto_route.dart'; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:lantern/core/common/app_text_styles.dart'; import 'package:lantern/core/models/feature_flags.dart'; import 'package:lantern/core/utils/pro_utils.dart'; -import 'package:lantern/core/widgets/vpn_status_indicator.dart'; +import 'package:lantern/features/share_my_connection/action_mode_widgets.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'; @@ -106,6 +106,14 @@ class Home extends HookConsumerWidget { // wins on top of this for non-censored users who want it hidden. final unboundedAvailable = featureFlag.getBool(FeatureFlag.unbounded); final showUnboundedTab = unboundedAvailable && !unboundedHidden; + useEffect(() { + if (!showUnboundedTab) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (context.mounted) tabController.index = 0; + }); + } + return null; + }, [showUnboundedTab]); final vpnStatus = ref.watch(vpnProvider); final shareActive = ref.watch(shareProvider.select((s) => s.active)); @@ -231,14 +239,10 @@ class Home extends HookConsumerWidget { return Scaffold( key: const Key('home.screen'), appBar: AppBar( - // Both titles are brand logotypes, not text: the wordmarks use a - // condensed face the app's Urbanist theme cannot reproduce. title: showUnboundedTab && onUnboundedTab.value - ? AppImage( - path: AppImagePaths.unboundedWordmark, - color: context.textPrimary, - height: 20, - width: 149, + ? Text( + 'unbounded'.i18n, + style: Theme.of(context).textTheme.titleLarge, ) : LanternLogo(isPro: isUserPro, color: context.textPrimary), // bg/elevated (white in light mode) per the Figma spec — the Home @@ -284,67 +288,15 @@ class Home extends HookConsumerWidget { bottom: !showUnboundedTab || PlatformUtils.isMobile ? null : PreferredSize( - // The Figma spec's whole Tabs row is 56px tall (the 40px - // pill centered inside it, giving 8px above/below) with a - // dividing line under the entire row — neither of which - // TabBar provides on its own, so both are added here rather - // than relying on TabBar's own (shorter) computed height. - preferredSize: const Size.fromHeight(56), - child: DecoratedBox( - decoration: BoxDecoration( - border: Border( - bottom: BorderSide(color: context.borderDefault), - ), - ), - child: TabBar( - controller: tabController, - // The pill is drawn by each _TabLabel itself (hugging - // its own content with 24px side padding, per spec) - // rather than through TabBar's indicator geometry, - // which can only size itself to the tab's full flex - // slot or its label's intrinsic size — neither hugs - // content with extra padding the way the spec wants. - // The indicator here is fully transparent; - // splashBorderRadius still rounds the hover/press - // overlay into a pill instead of a square. - indicator: const BoxDecoration(), - labelPadding: EdgeInsets.zero, - splashBorderRadius: BorderRadius.circular(9999), - overlayColor: WidgetStateProperty.resolveWith((states) { - if (states.contains(WidgetState.hovered) || - states.contains(WidgetState.pressed)) { - return context.bgHover; // action/bg-hover - } - return null; - }), - dividerColor: Colors.transparent, - labelColor: context - .actionTabbarSelectedText, // tabbar-selected-text - unselectedLabelColor: context - .actionTabbarDisabledText, // tabbar-disabled-text - labelStyle: Theme.of( - context, - ).textTheme.titleSmall, // subtitle/small - unselectedLabelStyle: Theme.of( - context, - ).textTheme.titleSmall, - tabs: [ - _TabLabel( - label: 'vpn'.i18n, - iconPath: AppImagePaths.vpnKey, - iconFillPath: AppImagePaths.vpnKeyFill, - selected: !onUnboundedTab.value, - active: vpnStatus == VPNStatus.connected, - ), - _TabLabel( - label: 'unbounded'.i18n, - iconPath: AppImagePaths.handshake, - iconFillPath: AppImagePaths.handshakeFill, - selected: onUnboundedTab.value, - active: shareActive, - ), - ], - ), + preferredSize: Size.fromHeight( + ActionModeNavigation.desktopHeight(context), + ), + child: ActionModeNavigation( + selectedIndex: onUnboundedTab.value ? 1 : 0, + onSelected: tabController.animateTo, + vpnActive: vpnStatus == VPNStatus.connected, + actionActive: shareActive, + desktop: true, ), ), ), @@ -356,229 +308,16 @@ class Home extends HookConsumerWidget { ), bottomNavigationBar: !showUnboundedTab || !PlatformUtils.isMobile ? null - : _MobileTabBar( - vpnSelected: !onUnboundedTab.value, - vpnActive: vpnStatus == VPNStatus.connected, - unboundedActive: shareActive, - onSelectVpn: () => tabController.animateTo(0), - onSelectUnbounded: () => tabController.animateTo(1), - ), - ); - } -} - -/// Tab label with the leading feature icon and the shared status dot -/// ([StatusDot], reused from the VPN status panel) from the Figma spec. The -/// dot reflects whether the feature is running, independent of which tab is -/// selected; the icon itself flips outline → filled when its tab is selected. -class _TabLabel extends StatelessWidget { - const _TabLabel({ - required this.label, - required this.iconPath, - required this.iconFillPath, - required this.selected, - required this.active, - }); - - final String label; - final String iconPath; - final String iconFillPath; - final bool selected; - final bool active; - - @override - Widget build(BuildContext context) { - // TabBar wraps each tab in a DefaultTextStyle carrying the resolved - // selected/unselected label colour, so tinting the icon from it keeps the - // two in step without plumbing the selected index down here. - final labelColor = DefaultTextStyle.of(context).style.color; - return Tab( - // 56px total so the 40px pill centers with 8px above/below, per spec — - // Tab's own preferredSize would otherwise be barely taller than the - // pill itself, leaving almost no breathing room below it. - height: 56, - child: Container( - height: 40, - padding: const EdgeInsets.symmetric(horizontal: 24), - decoration: selected - ? BoxDecoration( - color: context.actionTabbarBg, // action/tabbar/tabbar-bg - borderRadius: BorderRadius.circular(9999), - border: Border.all( - color: context.actionTabbarBorder, // tabbar-border - ), - ) - : null, - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - mainAxisSize: MainAxisSize.min, - children: [ - AppImage( - path: selected ? iconFillPath : iconPath, - width: 24, - height: 24, - color: labelColor, - ), - const SizedBox(width: 8), - Text(label), - const SizedBox(width: 8), - SizedBox( - width: 24, - height: 24, - child: Center(child: StatusDot(active: active)), - ), - ], - ), - ), - ); - } -} - -/// Mobile counterpart to the desktop [TabBar] strip, rendered as -/// [Scaffold.bottomNavigationBar] instead of [AppBar.bottom] per the Figma -/// mobile spec (figma.com/design/hNlyYToB5TnX9SDBFDYJTq?node-id=2716-11057): -/// a capsule pill spanning the bottom of the screen, each tab stacking its -/// icon above its label rather than side-by-side, and the selected tab's -/// pill filling its whole half of the capsule instead of hugging its -/// content the way the desktop pill does. -class _MobileTabBar extends StatelessWidget { - const _MobileTabBar({ - required this.vpnSelected, - required this.vpnActive, - required this.unboundedActive, - required this.onSelectVpn, - required this.onSelectUnbounded, - }); - - final bool vpnSelected; - final bool vpnActive; - final bool unboundedActive; - final VoidCallback onSelectVpn; - final VoidCallback onSelectUnbounded; - - @override - Widget build(BuildContext context) { - // Scaffold.bottomNavigationBar gets no automatic safe-area handling — - // without this the bar can sit under the iOS home indicator or an - // Android gesture nav area taller than the spec's fixed 16px gap. - return SafeArea( - top: false, - minimum: const EdgeInsets.only(bottom: 16), - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 16), - child: Container( - height: 64, - padding: const EdgeInsets.all(4), - decoration: BoxDecoration( - color: context.bgElevated, - border: Border.all(color: context.borderDefault), - borderRadius: BorderRadius.circular(9999), - ), - child: Row( - children: [ - Expanded( - child: _MobileTabButton( - label: 'vpn'.i18n, - iconPath: AppImagePaths.vpnKey, - iconFillPath: AppImagePaths.vpnKeyFill, - selected: vpnSelected, - active: vpnActive, - onTap: onSelectVpn, - ), + : SafeArea( + minimum: const EdgeInsets.fromLTRB(16, 8, 16, 16), + child: ActionModeNavigation( + selectedIndex: onUnboundedTab.value ? 1 : 0, + vpnActive: vpnStatus == VPNStatus.connected, + actionActive: shareActive, + onSelected: tabController.animateTo, + desktop: false, ), - const SizedBox(width: 16), - Expanded( - child: _MobileTabButton( - label: 'unbounded'.i18n, - iconPath: AppImagePaths.handshake, - iconFillPath: AppImagePaths.handshakeFill, - selected: !vpnSelected, - active: unboundedActive, - onTap: onSelectUnbounded, - ), - ), - ], - ), - ), - ), - ); - } -} - -class _MobileTabButton extends StatelessWidget { - const _MobileTabButton({ - required this.label, - required this.iconPath, - required this.iconFillPath, - required this.selected, - required this.active, - required this.onTap, - }); - - final String label; - final String iconPath; - final String iconFillPath; - final bool selected; - final bool active; - final VoidCallback onTap; - - @override - Widget build(BuildContext context) { - final labelColor = selected - ? context.actionTabbarSelectedText - : context.actionTabbarDisabledText; - // InkWell has no selected semantics of its own — without this, - // assistive tech has no way to tell which tab is current. The label - // itself isn't repeated here so it merges in from the descendant Text - // instead of announcing twice. - return Semantics( - selected: selected, - button: true, - child: Material( - type: MaterialType.transparency, - child: InkWell( - borderRadius: BorderRadius.circular(9999), - onTap: onTap, - child: Container( - decoration: selected - ? BoxDecoration( - color: context.actionTabbarBg, - border: Border.all(color: context.actionTabbarBorder), - borderRadius: BorderRadius.circular(9999), - ) - : null, - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - AppImage( - path: selected ? iconFillPath : iconPath, - width: 24, - height: 24, - color: labelColor, - ), - const SizedBox(height: 4), - Row( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - label, - style: Theme.of( - context, - ).textTheme.titleSmall?.copyWith(color: labelColor), - ), - const SizedBox(width: 8), - SizedBox( - width: 24, - height: 24, - child: Center(child: StatusDot(active: active)), - ), - ], - ), - ], ), - ), - ), - ), ); } } diff --git a/lib/features/setting/setting.dart b/lib/features/setting/setting.dart index 623b826678..6c83215f33 100644 --- a/lib/features/setting/setting.dart +++ b/lib/features/setting/setting.dart @@ -158,7 +158,7 @@ class _SettingState extends ConsumerState DividerSpace(), AppTile( label: 'unbounded_settings_title'.i18n, - icon: AppImagePaths.handshake, + icon: AppImagePaths.actionMode, onPressed: () => settingMenuTap(_SettingType.unboundedSetting), ), diff --git a/lib/features/setting/unbounded_setting.dart b/lib/features/setting/unbounded_setting.dart index 46f832e912..d95be81ae0 100644 --- a/lib/features/setting/unbounded_setting.dart +++ b/lib/features/setting/unbounded_setting.dart @@ -3,32 +3,26 @@ import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:lantern/core/widgets/switch_button.dart'; import 'package:lantern/features/home/provider/app_setting_notifier.dart'; -import 'package:lantern/features/share_my_connection/share_my_connection.dart'; import '../../core/common/common.dart'; +import '../share_my_connection/share_my_connection.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 +/// Lantern is open. The actual auto-enable wiring lives /// in the Home shell (or a VPN-status listener) and reads this flag. /// 2. Hide Unbounded — collapse the Unbounded tab in the Home shell /// when the user doesn't want to see it. With only the VPN tab /// left, Home hides the tab strip entirely. -/// -/// Manual port forwarding sits here rather than on the tab because the spec's -/// Unbounded screen has no Advanced section. @RoutePage(name: 'UnboundedSetting') class UnboundedSetting extends ConsumerWidget { const UnboundedSetting({super.key}); @override Widget build(BuildContext context, WidgetRef ref) { - final autoEnable = ref.watch( - appSettingProvider.select((s) => s.unboundedAutoEnable), - ); final hidden = ref.watch( appSettingProvider.select((s) => s.unboundedHidden), ); @@ -39,31 +33,13 @@ class UnboundedSetting extends ConsumerWidget { title: 'unbounded_settings_title'.i18n, body: ListView( children: [ - const SizedBox(height: 8), + const SizedBox(height: 16), + const ActionModeAutoEnable(), + const SizedBox(height: 16), 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.autoMode, - trailing: SwitchButton( - value: autoEnable, - onChanged: (v) => - ref.read(shareProvider.notifier).setAutoEnable(context, v), - ), - onPressed: () => ref - .read(shareProvider.notifier) - .setAutoEnable(context, !autoEnable), - ), - DividerSpace(), AppTile( label: 'hide_unbounded'.i18n, subtitle: Text( @@ -73,7 +49,7 @@ class UnboundedSetting extends ConsumerWidget { letterSpacing: 0.0, ), ), - icon: AppImagePaths.visibilityOff, + icon: Icons.visibility_off_outlined, trailing: SwitchButton( value: hidden, onChanged: notifier.setUnboundedHidden, @@ -83,15 +59,8 @@ class UnboundedSetting extends ConsumerWidget { ], ), ), - const SizedBox(height: 8), - // The only knob in here is the manually forwarded port, which - // exists to select the peer-proxy mode. iOS never enters that mode, - // so the field would report a setting as active that nothing can - // act on. - if (!PlatformUtils.isIOS) ...[ - const UnboundedAdvancedCard(), - const SizedBox(height: 8), - ], + const SizedBox(height: 16), + if (!PlatformUtils.isIOS) const UnboundedAdvancedCard(), ], ), ); diff --git a/lib/features/share_my_connection/action_mode_widgets.dart b/lib/features/share_my_connection/action_mode_widgets.dart new file mode 100644 index 0000000000..fc32e23187 --- /dev/null +++ b/lib/features/share_my_connection/action_mode_widgets.dart @@ -0,0 +1,350 @@ +import 'dart:math' as math; + +import 'package:flutter/material.dart'; +import 'package:lantern/core/common/common.dart'; +import 'package:lantern/core/widgets/switch_button.dart'; + +/// Scrolls on small screens and with large accessibility text instead of +/// squeezing the globe or overflowing the controls. +class ActionModePanel extends StatelessWidget { + const ActionModePanel({ + super.key, + required this.globe, + required this.statusCard, + required this.autoEnable, + required this.onAbout, + }); + final Widget globe; + final Widget statusCard; + final Widget autoEnable; + final VoidCallback onAbout; + + @override + Widget build(BuildContext context) => ColoredBox( + color: context.bgSurface, + child: LayoutBuilder( + builder: (context, constraints) { + return SingleChildScrollView( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 24), + child: Column( + children: [ + Material( + color: context.bgElevated, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + side: BorderSide(color: context.borderDefault), + ), + child: InkWell( + borderRadius: BorderRadius.circular(8), + onTap: onAbout, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 8, + ), + child: Row( + children: [ + Tooltip( + message: 'about_unbounded'.i18n, + child: const AppImage( + path: AppImagePaths.info, + width: 24, + height: 24, + ), + ), + const SizedBox(width: 16), + Expanded( + child: Text( + 'smc_intro'.i18n, + style: Theme.of(context).textTheme.labelMedium + ?.copyWith( + color: context.textSecondary, + height: 16 / 12, + ), + ), + ), + ], + ), + ), + ), + ), + SizedBox( + height: math.max(180, constraints.maxHeight - 336), + child: globe, + ), + statusCard, + const SizedBox(height: 8), + autoEnable, + ], + ), + ); + }, + ), + ); +} + +class ActionModeStatusCard extends StatelessWidget { + const ActionModeStatusCard({ + super.key, + required this.status, + required this.enabled, + required this.ready, + required this.busy, + required this.hasError, + required this.activeCount, + required this.totalCount, + required this.onToggle, + }); + final String status; + final bool enabled, ready, busy, hasError; + final int activeCount, totalCount; + final VoidCallback onToggle; + + @override + Widget build(BuildContext context) { + final body = Theme.of(context).textTheme.bodyLarge; + return AppCard( + child: Column( + children: [ + ConstrainedBox( + constraints: const BoxConstraints(minHeight: 56), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Row( + children: [ + const AppImage( + path: AppImagePaths.glob, + width: 24, + height: 24, + ), + const SizedBox(width: 16), + Expanded( + child: Text.rich( + TextSpan( + children: [ + TextSpan(text: '${'smc_status_label'.i18n}: '), + TextSpan( + text: status, + style: TextStyle( + fontWeight: FontWeight.w700, + color: hasError + ? context.statusErrorText + : ready + ? (Theme.of(context).brightness == + Brightness.dark + ? AppColors.green3 + : AppColors.green6) + : context.textTertiary, + ), + ), + ], + ), + style: body, + ), + ), + const SizedBox(width: 8), + Semantics( + key: const Key('action-mode.toggle'), + label: 'unbounded'.i18n, + toggled: enabled, + enabled: !busy, + child: AbsorbPointer( + absorbing: busy, + child: SwitchButton( + value: enabled, + onChanged: (_) { + if (!busy) onToggle(); + }, + ), + ), + ), + ], + ), + ), + ), + Divider(height: 1, color: context.borderDefault), + _ImpactRow( + icon: AppImagePaths.actionModePerson, + label: 'smc_stat_active_now'.i18n, + value: activeCount, + ), + Divider(height: 1, color: context.borderDefault), + _ImpactRow( + icon: AppImagePaths.actionModePeople, + label: 'smc_stat_total_helped'.i18n, + value: totalCount, + ), + ], + ), + ); + } +} + +class _ImpactRow extends StatelessWidget { + const _ImpactRow({ + required this.icon, + required this.label, + required this.value, + }); + final String icon, label; + final int value; + + @override + Widget build(BuildContext context) => ConstrainedBox( + constraints: const BoxConstraints(minHeight: 56), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Row( + children: [ + AppImage(path: icon, width: 24, height: 24), + const SizedBox(width: 16), + Expanded( + child: Text(label, style: Theme.of(context).textTheme.bodyLarge), + ), + const SizedBox(width: 12), + Text( + '$value', + style: Theme.of(context).textTheme.bodyLarge?.copyWith( + color: context.textLink, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ), + ); +} + +/// Desktop uses a horizontal pill strip; mobile uses a bottom navigation pill. +class ActionModeNavigation extends StatelessWidget { + const ActionModeNavigation({ + super.key, + required this.selectedIndex, + required this.onSelected, + required this.vpnActive, + required this.actionActive, + required this.desktop, + }); + final int selectedIndex; + final ValueChanged onSelected; + final bool vpnActive, actionActive, desktop; + + /// Used by both the desktop strip and its AppBar so neither clips the label. + static double desktopHeight(BuildContext context) { + final painter = TextPainter( + text: TextSpan( + text: 'unbounded'.i18n, + style: Theme.of( + context, + ).textTheme.labelLarge?.copyWith(fontWeight: FontWeight.w600), + ), + textDirection: Directionality.of(context), + textScaler: MediaQuery.textScalerOf(context), + maxLines: 1, + )..layout(); + // Eight pixels of padding plus the one-pixel border on each side. + final height = math.max(56.0, painter.height + 18); + painter.dispose(); + return height; + } + + @override + Widget build(BuildContext context) => Container( + height: desktop + ? desktopHeight(context) + : 64 + math.max(0, MediaQuery.textScalerOf(context).scale(14) - 14) * 2, + padding: EdgeInsets.all(desktop ? 8 : 4), + decoration: BoxDecoration( + color: context.bgElevated, + borderRadius: BorderRadius.circular(desktop ? 0 : 9999), + border: Border.all(color: context.borderDefault), + ), + child: Row( + children: [ + for (var i = 0; i < 2; i++) Expanded(child: _item(context, i)), + ], + ), + ); + + Widget _item(BuildContext context, int index) { + final selected = selectedIndex == index; + final active = index == 0 ? vpnActive : actionActive; + final color = selected ? context.textLink : context.textDisabled; + final label = (index == 0 ? 'vpn' : 'unbounded').i18n; + final icon = AppImage( + path: index == 0 + ? (selected ? AppImagePaths.vpnKeyFill : AppImagePaths.vpnKey) + : (selected ? AppImagePaths.handshakeFill : AppImagePaths.actionMode), + width: 24, + height: 24, + color: color, + ); + final caption = Row( + mainAxisSize: MainAxisSize.min, + children: [ + Flexible( + child: Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.labelLarge?.copyWith( + color: color, + fontWeight: FontWeight.w600, + ), + ), + ), + const SizedBox(width: 8), + if (!desktop && index == 1) + Icon( + Icons.sensors, + size: 20, + color: active ? AppColors.green6 : context.textDisabled, + ) + else + Container( + width: 8, + height: 8, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: active ? AppColors.green6 : context.textDisabled, + ), + ), + ], + ); + return Semantics( + selected: selected, + button: true, + label: label, + child: Material( + color: selected ? context.bgHover : Colors.transparent, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(9999), + side: BorderSide( + color: selected ? context.borderDefault : Colors.transparent, + ), + ), + child: InkWell( + key: Key('action-mode.nav.$index'), + borderRadius: BorderRadius.circular(9999), + onTap: () => onSelected(index), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: desktop + ? Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + icon, + const SizedBox(width: 8), + Flexible(child: caption), + ], + ) + : Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [icon, caption], + ), + ), + ), + ), + ); + } +} diff --git a/lib/features/share_my_connection/share_my_connection.dart b/lib/features/share_my_connection/share_my_connection.dart index 137dae0ea2..b3c8399acb 100644 --- a/lib/features/share_my_connection/share_my_connection.dart +++ b/lib/features/share_my_connection/share_my_connection.dart @@ -29,8 +29,7 @@ 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'; -import 'package:lantern/core/widgets/info_row.dart'; -import 'package:lantern/core/widgets/switch_button.dart'; +import 'package:lantern/features/share_my_connection/action_mode_widgets.dart'; import 'package:lantern/features/home/provider/radiance_settings_providers.dart'; import 'package:lantern/lantern/lantern_service_notifier.dart'; @@ -888,6 +887,48 @@ class UnboundedTabVisible extends Notifier { void set(bool visible) => state = visible; } +/// Shared by the feature screen and Settings so both edit the same preference. +class ActionModeAutoEnable extends ConsumerWidget { + const ActionModeAutoEnable({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final enabled = ref.watch( + appSettingProvider.select((s) => s.unboundedAutoEnable), + ); + void change(bool? value) { + if (value != null) { + ref.read(shareProvider.notifier).setAutoEnable(context, value); + } + } + + return AppCard( + padding: EdgeInsets.zero, + child: AppTile( + label: 'auto_enable_unbounded'.i18n, + labelWidget: Text( + 'auto_enable_unbounded'.i18n, + style: Theme.of(context).textTheme.bodyLarge, + ), + subtitle: Text( + 'auto_enable_unbounded_subtitle'.i18n, + style: Theme.of( + context, + ).textTheme.labelMedium?.copyWith(color: context.textTertiary), + ), + icon: AppImagePaths.actionModeAuto, + trailing: Checkbox( + key: const Key('action-mode.auto-enable'), + value: enabled, + activeColor: context.textLink, + onChanged: change, + ), + onPressed: () => change(!enabled), + ), + ); + } +} + // ─── Tab body ──────────────────────────────────────────────────────────────── /// Unbounded tab content, rendered inside the Home tab shell (see @@ -901,78 +942,38 @@ class UnboundedTab extends HookConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final state = ref.watch(shareProvider); final notifier = ref.read(shareProvider.notifier); - final textTheme = Theme.of(context).textTheme; - - // First-visit welcome popup. Fires once per device (persisted via - // appSettingProvider.unboundedWelcomeSeen) when the user first lands - // on the Unbounded tab. Re-openable via the info-bubble icon in the - // header. + final visible = ref.watch(unboundedTabVisibleProvider); useEffect(() { - final seen = ref.read(appSettingProvider).unboundedWelcomeSeen; - if (!seen) { + if (visible && !ref.read(appSettingProvider).unboundedWelcomeSeen) { WidgetsBinding.instance.addPostFrameCallback((_) { - if (!context.mounted) return; - showUnboundedWelcomeDialog(context, ref); + if (context.mounted && ref.read(unboundedTabVisibleProvider)) { + showUnboundedWelcomeDialog(context, ref); + } }); } return null; - }, const []); + }, [visible]); return SafeArea( - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 16), - child: Column( + child: ActionModePanel( + autoEnable: const ActionModeAutoEnable(), + onAbout: () => showUnboundedWelcomeDialog(context, ref), + globe: Stack( + clipBehavior: Clip.none, children: [ - const SizedBox(height: 12), - // The whole note re-opens the welcome dialog — a strict superset - // of the old icon-only tap target — so it can reuse the app's - // shared note component instead of a one-off Container. - Tooltip( - message: 'about_unbounded'.i18n, - child: InfoRow( - text: 'smc_intro'.i18n, - textStyle: textTheme.labelMedium?.copyWith( - color: context.textSecondary, - ), - // ListTile's default 56dp minimum height is sized for a - // single-line tile; this note's text wraps to two lines, - // so without overriding it the tile pads out to that floor - // and reads as too much space above/below the text. - minTileHeight: 0, - onPressed: () => showUnboundedWelcomeDialog(context, ref), - ), - ), - const SizedBox(height: 16), - Expanded( - flex: 3, - child: Stack( - clipBehavior: Clip.none, - children: [ - Positioned.fill(child: _GlobeView()), - // 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, - bottom: 8, - child: Center(child: _ArrivalToast()), - ), - ], - ), + Positioned.fill(child: _GlobeView()), + const Positioned( + left: 0, + right: 0, + bottom: 8, + child: Center(child: _ArrivalToast()), ), - const SizedBox(height: 8), - _StatusCard(state: state, onToggle: () => notifier.toggle(context, ref)), - const SizedBox(height: 12), - const _AutoEnableCard(), - const SizedBox(height: 16), ], ), + statusCard: _StatusCard( + state: state, + onToggle: () => notifier.toggle(context, ref), + ), ), ); } @@ -988,7 +989,6 @@ class _StatusCard extends StatelessWidget { @override Widget build(BuildContext context) { - final textTheme = Theme.of(context).textTheme; // Status text source-of-truth, collapsed to the three states the spec // calls for — "Off", "Enabled", and (while a Start/probe is actually in // flight) "Configuring network" — rather than the old multi-line @@ -1024,141 +1024,16 @@ class _StatusCard extends StatelessWidget { }, }; - 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: [ - Padding( - padding: - const EdgeInsets.symmetric(horizontal: 16, vertical: 12), - child: Row( - children: [ - AppImage( - path: AppImagePaths.languageGlobe, - width: 20, - height: 20, - color: context.textTertiary), - const SizedBox(width: 12), - Expanded( - child: Text.rich( - TextSpan( - style: textTheme.bodyMedium, - children: [ - TextSpan(text: '${'smc_status_label'.i18n}: '), - TextSpan( - text: modeLabel, - style: TextStyle( - color: state.active - ? AppColors.green6 - : Theme.of(context).hintColor, - fontWeight: FontWeight.w600, - ), - ), - ], - ), - ), - ), - const SizedBox(width: 8), - // Match the rest of the app's toggles (vpn_setting.dart etc.). - // SwitchButton has no built-in disabled state, so during the - // probe we render the switch but absorb the tap so the user - // doesn't double-fire toggle(). - SwitchButton( - value: state.active || state.probing, - onChanged: (value) { - if (state.probing) return; - onToggle(); - }, - ), - ], - ), - ), - // Always shown — including while Unbounded is off — so the - // panel doesn't collapse/expand as the toggle flips. activeCount - // reads 0 and totalCount keeps the persisted lifetime total. - const DividerSpace(), - AppTile( - icon: AppImagePaths.person, - label: 'smc_stat_active_now'.i18n, - trailing: Text( - '${state.activeCount}', - style: - textTheme.titleMedium!.copyWith(color: context.textLink), - ), - ), - const DividerSpace(), - AppTile( - icon: AppImagePaths.groups2, - label: 'smc_stat_total_helped'.i18n, - trailing: Text( - '${state.totalCount}', - style: - textTheme.titleMedium!.copyWith(color: context.textLink), - ), - ), - ], - ), - ), - ); - } -} - -/// Mirrors the Unbounded Settings toggle, surfaced on the tab itself because -/// the spec puts the choice next to the thing it controls. Uses a checkbox -/// rather than the switch UnboundedSetting's identical row uses — per the -/// Figma spec, this tab-embedded copy is the one exception. -class _AutoEnableCard extends ConsumerWidget { - const _AutoEnableCard(); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final textTheme = Theme.of(context).textTheme; - final autoEnable = - ref.watch(appSettingProvider.select((s) => s.unboundedAutoEnable)); - final notifier = ref.read(shareProvider.notifier); - return Container( - decoration: const BoxDecoration( - boxShadow: [ - BoxShadow( - color: AppColors.shadowColor, - blurRadius: 32, - offset: Offset(0, 4), - spreadRadius: 0, - ), - ], - ), - child: Card( - elevation: 0, - margin: EdgeInsets.zero, - child: AppTile( - label: 'auto_enable_unbounded'.i18n, - subtitle: Text( - 'auto_enable_unbounded_subtitle'.i18n, - style: textTheme.labelMedium!.copyWith( - color: context.textTertiary, - ), - ), - icon: AppImagePaths.autoMode, - trailing: Checkbox( - value: autoEnable, - onChanged: (v) => notifier.setAutoEnable(context, v ?? false), - activeColor: context.textLink, - ), - onPressed: () => notifier.setAutoEnable(context, !autoEnable), - ), - ), + return ActionModeStatusCard( + status: modeLabel, + enabled: state.active || state.probing, + ready: state.mode == ShareMode.unbounded || + (state.mode == ShareMode.smc && state.phase == SharePhase.serving), + busy: state.probing, + hasError: state.phase == SharePhase.error, + activeCount: state.activeCount, + totalCount: state.totalCount, + onToggle: onToggle, ); } } @@ -2024,29 +1899,28 @@ class _UnboundedWelcomeDialog extends StatelessWidget { Widget build(BuildContext context) { final textTheme = Theme.of(context).textTheme; return Dialog( - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(16), - ), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), child: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 360), - child: Padding( - padding: const EdgeInsets.fromLTRB(24, 28, 24, 16), + constraints: const BoxConstraints(maxWidth: 312), + child: SingleChildScrollView( + padding: const EdgeInsets.all(24), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ - // Heart logo, matching the Figma's heart-Lantern motif. + // Action Mode handshake icon from the Figma design. const Center( - child: SizedBox( - width: 40, - height: 34, - child: CustomPaint(painter: _HeartPainter()), + child: AppImage( + path: AppImagePaths.actionMode, + width: 48, + height: 48, ), ), const SizedBox(height: 16), Center( child: Text( 'unbounded_welcome_title'.i18n, + textAlign: TextAlign.center, style: textTheme.titleLarge?.copyWith( fontWeight: FontWeight.w600, ), @@ -2068,13 +1942,24 @@ class _UnboundedWelcomeDialog extends StatelessWidget { 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, + Wrap( + alignment: WrapAlignment.end, children: [ + TextButton( + onPressed: () => UrlUtils.openUrl(AppUrls.unbounded), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text('learn_more'.i18n), + const SizedBox(width: 4), + const AppImage( + path: AppImagePaths.outsideBrowser, + width: 16, + height: 16, + ), + ], + ), + ), TextButton( onPressed: () => Navigator.of(context).pop(), child: Text('got_it'.i18n), diff --git a/test/features/share_my_connection/action_mode_widgets_test.dart b/test/features/share_my_connection/action_mode_widgets_test.dart new file mode 100644 index 0000000000..f27c8ce517 --- /dev/null +++ b/test/features/share_my_connection/action_mode_widgets_test.dart @@ -0,0 +1,312 @@ +import 'dart:ui' show SemanticsAction; +import 'package:flutter/material.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:lantern/core/common/common.dart'; +import 'package:lantern/core/models/app_setting.dart'; +import 'package:lantern/features/home/provider/app_setting_notifier.dart'; +import 'package:lantern/features/share_my_connection/action_mode_widgets.dart'; +import 'package:lantern/features/share_my_connection/share_my_connection.dart'; + +class _Share extends ShareNotifier { + @override + ShareState build() => const ShareState( + active: true, + mode: ShareMode.unbounded, + activeCount: 9, + totalCount: 219, + ); + @override + void replayCurrentPeers() {} + @override + Future ensureConsent(BuildContext context) async => consent; + static bool consent = true; +} + +class _Settings extends AppSettingNotifier { + @override + AppSetting build() => + const AppSetting(unboundedAutoEnable: false, unboundedWelcomeSeen: true); + @override + void setUnboundedAutoEnable(bool value) { + state = state.copyWith(unboundedAutoEnable: value); + } + + @override + void setUnboundedWelcomeSeen(bool value) { + state = state.copyWith(unboundedWelcomeSeen: value); + } +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + setUpAll(Localization.loadTranslations); + setUp(() => _Share.consent = true); + + Future mount( + WidgetTester tester, + Widget child, { + Size size = const Size(393, 852), + double scale = 1, + Brightness brightness = Brightness.light, + bool animated = false, + }) async { + tester.view.physicalSize = size; + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + await tester.pumpWidget( + ProviderScope( + overrides: [ + appSettingProvider.overrideWith(_Settings.new), + shareProvider.overrideWith(_Share.new), + ], + child: ScreenUtilInit( + designSize: const Size(393, 852), + child: MaterialApp( + theme: ThemeData(brightness: brightness), + builder: (context, child) => MediaQuery( + data: MediaQuery.of( + context, + ).copyWith(textScaler: TextScaler.linear(scale)), + child: child!, + ), + home: Scaffold(body: child), + ), + ), + ), + ); + if (animated) { + await tester.pump(const Duration(milliseconds: 300)); + } else { + await tester.pumpAndSettle(); + } + } + + ActionModeStatusCard status({bool busy = false, VoidCallback? toggle}) => + ActionModeStatusCard( + status: 'Disabled', + enabled: false, + ready: false, + busy: busy, + hasError: false, + activeCount: 0, + totalCount: 219, + onToggle: toggle ?? () {}, + ); + + testWidgets('busy switch blocks taps, drags, and semantic tap actions', ( + tester, + ) async { + final semantics = tester.ensureSemantics(); + var toggles = 0; + await mount(tester, status(busy: true, toggle: () => toggles++)); + final toggle = find.byKey(const Key('action-mode.toggle')); + await tester.tapAt(tester.getCenter(toggle)); + await tester.dragFrom(tester.getCenter(toggle), const Offset(40, 0)); + await tester.pumpAndSettle(); + expect(toggles, 0); + expect( + tester + .getSemantics(toggle) + .getSemanticsData() + .hasAction(SemanticsAction.tap), + isFalse, + ); + await mount(tester, status(toggle: () => toggles++)); + await tester.tapAt(tester.getCenter(toggle)); + await tester.pumpAndSettle(); + expect(toggles, 1); + semantics.dispose(); + }); + + testWidgets('keeps lifetime impact visible while sharing is disabled', ( + tester, + ) async { + await mount(tester, status()); + expect(find.text('219'), findsOneWidget); + expect(find.text('0'), findsOneWidget); + expect(find.text('Total people helped to date:'), findsOneWidget); + expect(find.text('People you are helping right now:'), findsOneWidget); + expect(tester.takeException(), isNull); + }); + + testWidgets( + 'actual Action Mode screen mounts with live state and welcome dialog scrolls', + (tester) async { + await mount( + tester, + const UnboundedTab(), + size: const Size(360, 640), + scale: 2, + animated: true, + ); + expect(find.text('9'), findsOneWidget); + expect(find.text('219'), findsOneWidget); + await tester.tap( + find.text( + 'Help others bypass censorship by securely sharing your connection.', + ), + ); + await tester.pump(const Duration(milliseconds: 300)); + expect(find.text('Welcome to Action Mode'), findsOneWidget); + await tester.ensureVisible(find.text('Got It')); + expect(tester.takeException(), isNull); + await tester.tap(find.text('Got It')); + await tester.pump(const Duration(milliseconds: 300)); + await tester.pumpWidget(const SizedBox()); + }, + ); + + testWidgets('both auto-enable controls edit the same saved preference', ( + tester, + ) async { + await mount( + tester, + const Column(children: [ActionModeAutoEnable(), ActionModeAutoEnable()]), + ); + expect( + tester + .widgetList(find.byType(Checkbox)) + .every((c) => !c.value!), + isTrue, + ); + await tester.tap(find.byType(Checkbox).first); + await tester.pumpAndSettle(); + expect( + tester.widgetList(find.byType(Checkbox)).every((c) => c.value!), + isTrue, + ); + await tester.tap(find.text('Auto-enable Action Mode').last); + await tester.pumpAndSettle(); + expect( + tester + .widgetList(find.byType(Checkbox)) + .every((c) => !c.value!), + isTrue, + ); + }); + + testWidgets('declining consent leaves auto-enable off', (tester) async { + _Share.consent = false; + await mount(tester, const ActionModeAutoEnable()); + await tester.tap(find.byType(Checkbox)); + await tester.pumpAndSettle(); + expect(tester.widget(find.byType(Checkbox)).value, isFalse); + }); + + for (final scale in [1.0, 2.0]) { + for (final brightness in Brightness.values) { + testWidgets( + 'controls remain usable at 360x640, scale $scale, $brightness', + (tester) async { + var about = 0; + await mount( + tester, + ActionModePanel( + autoEnable: const ActionModeAutoEnable(), + globe: const SizedBox(), + statusCard: status(), + onAbout: () => about++, + ), + size: const Size(360, 640), + scale: scale, + brightness: brightness, + ); + await tester.tap( + find.text( + 'Help others bypass censorship by securely sharing your connection.', + ), + ); + expect(about, 1); + await tester.ensureVisible(find.byType(Checkbox)); + await tester.tap(find.byType(Checkbox)); + await tester.pumpAndSettle(); + expect(tester.widget(find.byType(Checkbox)).value, isTrue); + expect(tester.takeException(), isNull); + }, + ); + } + } + + for (final scale in [1.0, 2.0, 3.0]) { + testWidgets('desktop AppBar contains full labels at text scale $scale', ( + tester, + ) async { + await mount( + tester, + Builder( + builder: (context) => Scaffold( + appBar: AppBar( + bottom: PreferredSize( + preferredSize: Size.fromHeight( + ActionModeNavigation.desktopHeight(context), + ), + child: ActionModeNavigation( + selectedIndex: 0, + onSelected: (_) {}, + vpnActive: false, + actionActive: true, + desktop: true, + ), + ), + ), + ), + ), + size: const Size(800, 640), + scale: scale, + ); + final label = find.text('Action Mode'); + final element = tester.element(label); + final painter = TextPainter( + text: TextSpan( + text: 'Action Mode', + style: tester.widget(label).style, + ), + textDirection: TextDirection.ltr, + textScaler: MediaQuery.textScalerOf(element), + maxLines: 1, + )..layout(); + expect( + tester.getSize(label).height, + greaterThanOrEqualTo(painter.height), + ); + painter.dispose(); + final labelRect = tester.getRect(label); + final navRect = tester.getRect(find.byType(ActionModeNavigation)); + final appBarRect = tester.getRect(find.byType(AppBar)); + expect(navRect.contains(labelRect.topLeft), isTrue); + expect(navRect.contains(labelRect.bottomRight), isTrue); + expect(appBarRect.bottom, greaterThanOrEqualTo(navRect.bottom)); + expect(tester.takeException(), isNull); + }); + } + + for (final desktop in [false, true]) { + testWidgets( + 'navigation selects Action Mode with large text, desktop=$desktop', + (tester) async { + var selected = 0; + await mount( + tester, + StatefulBuilder( + builder: (context, setState) => ActionModeNavigation( + selectedIndex: selected, + onSelected: (value) => setState(() => selected = value), + vpnActive: false, + actionActive: true, + desktop: desktop, + ), + ), + size: const Size(360, 640), + scale: 2, + ); + await tester.tap(find.byKey(const Key('action-mode.nav.1'))); + await tester.pumpAndSettle(); + expect(selected, 1); + expect(tester.takeException(), isNull); + }, + ); + } +}