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);
+ },
+ );
+ }
+}