diff --git a/assets/l10n/app_en.arb b/assets/l10n/app_en.arb index 776a866d3b..d2688c5479 100644 --- a/assets/l10n/app_en.arb +++ b/assets/l10n/app_en.arb @@ -1082,6 +1082,10 @@ "@sharePageTitle": { "description": "Title for the page about sharing content received from other apps." }, + "shareChooseAccountLabel": "Choose account", + "@shareChooseAccountLabel": { + "description": "Label for the page about selecting an account to share content received from other apps." + }, "mainMenuMyProfile": "My profile", "@mainMenuMyProfile": { "description": "Label for main-menu button leading to the user's own profile." diff --git a/lib/generated/l10n/zulip_localizations.dart b/lib/generated/l10n/zulip_localizations.dart index 238d336f39..3641d59eb9 100644 --- a/lib/generated/l10n/zulip_localizations.dart +++ b/lib/generated/l10n/zulip_localizations.dart @@ -1597,6 +1597,12 @@ abstract class ZulipLocalizations { /// **'Share'** String get sharePageTitle; + /// Label for the page about selecting an account to share content received from other apps. + /// + /// In en, this message translates to: + /// **'Choose account'** + String get shareChooseAccountLabel; + /// Label for main-menu button leading to the user's own profile. /// /// In en, this message translates to: diff --git a/lib/generated/l10n/zulip_localizations_ar.dart b/lib/generated/l10n/zulip_localizations_ar.dart index 0ff35c4b5b..cb19777c54 100644 --- a/lib/generated/l10n/zulip_localizations_ar.dart +++ b/lib/generated/l10n/zulip_localizations_ar.dart @@ -903,6 +903,9 @@ class ZulipLocalizationsAr extends ZulipLocalizations { @override String get sharePageTitle => 'Share'; + @override + String get shareChooseAccountLabel => 'Choose account'; + @override String get mainMenuMyProfile => 'My profile'; diff --git a/lib/generated/l10n/zulip_localizations_de.dart b/lib/generated/l10n/zulip_localizations_de.dart index a22b46f863..e10067fdd5 100644 --- a/lib/generated/l10n/zulip_localizations_de.dart +++ b/lib/generated/l10n/zulip_localizations_de.dart @@ -926,6 +926,9 @@ class ZulipLocalizationsDe extends ZulipLocalizations { @override String get sharePageTitle => 'Teilen'; + @override + String get shareChooseAccountLabel => 'Choose account'; + @override String get mainMenuMyProfile => 'Mein Profil'; diff --git a/lib/generated/l10n/zulip_localizations_en.dart b/lib/generated/l10n/zulip_localizations_en.dart index 2e94965a87..767da70cbb 100644 --- a/lib/generated/l10n/zulip_localizations_en.dart +++ b/lib/generated/l10n/zulip_localizations_en.dart @@ -903,6 +903,9 @@ class ZulipLocalizationsEn extends ZulipLocalizations { @override String get sharePageTitle => 'Share'; + @override + String get shareChooseAccountLabel => 'Choose account'; + @override String get mainMenuMyProfile => 'My profile'; diff --git a/lib/generated/l10n/zulip_localizations_fr.dart b/lib/generated/l10n/zulip_localizations_fr.dart index 3766c7051e..4285980c4a 100644 --- a/lib/generated/l10n/zulip_localizations_fr.dart +++ b/lib/generated/l10n/zulip_localizations_fr.dart @@ -917,6 +917,9 @@ class ZulipLocalizationsFr extends ZulipLocalizations { @override String get sharePageTitle => 'Share'; + @override + String get shareChooseAccountLabel => 'Choose account'; + @override String get mainMenuMyProfile => 'My profile'; diff --git a/lib/generated/l10n/zulip_localizations_it.dart b/lib/generated/l10n/zulip_localizations_it.dart index 028f8680fb..921126a570 100644 --- a/lib/generated/l10n/zulip_localizations_it.dart +++ b/lib/generated/l10n/zulip_localizations_it.dart @@ -918,6 +918,9 @@ class ZulipLocalizationsIt extends ZulipLocalizations { @override String get sharePageTitle => 'Share'; + @override + String get shareChooseAccountLabel => 'Choose account'; + @override String get mainMenuMyProfile => 'Il mio profilo'; diff --git a/lib/generated/l10n/zulip_localizations_ja.dart b/lib/generated/l10n/zulip_localizations_ja.dart index 249e42587b..bc9812807b 100644 --- a/lib/generated/l10n/zulip_localizations_ja.dart +++ b/lib/generated/l10n/zulip_localizations_ja.dart @@ -882,6 +882,9 @@ class ZulipLocalizationsJa extends ZulipLocalizations { @override String get sharePageTitle => '共有'; + @override + String get shareChooseAccountLabel => 'Choose account'; + @override String get mainMenuMyProfile => '自分のプロフィール'; diff --git a/lib/generated/l10n/zulip_localizations_nb.dart b/lib/generated/l10n/zulip_localizations_nb.dart index 36411b5274..e70e85b0d1 100644 --- a/lib/generated/l10n/zulip_localizations_nb.dart +++ b/lib/generated/l10n/zulip_localizations_nb.dart @@ -903,6 +903,9 @@ class ZulipLocalizationsNb extends ZulipLocalizations { @override String get sharePageTitle => 'Share'; + @override + String get shareChooseAccountLabel => 'Choose account'; + @override String get mainMenuMyProfile => 'My profile'; diff --git a/lib/generated/l10n/zulip_localizations_pl.dart b/lib/generated/l10n/zulip_localizations_pl.dart index 2794614e81..627b9bb63e 100644 --- a/lib/generated/l10n/zulip_localizations_pl.dart +++ b/lib/generated/l10n/zulip_localizations_pl.dart @@ -917,6 +917,9 @@ class ZulipLocalizationsPl extends ZulipLocalizations { @override String get sharePageTitle => 'Udostępnij'; + @override + String get shareChooseAccountLabel => 'Choose account'; + @override String get mainMenuMyProfile => 'Mój profil'; diff --git a/lib/generated/l10n/zulip_localizations_ru.dart b/lib/generated/l10n/zulip_localizations_ru.dart index b07752966c..2641b8acd6 100644 --- a/lib/generated/l10n/zulip_localizations_ru.dart +++ b/lib/generated/l10n/zulip_localizations_ru.dart @@ -928,6 +928,9 @@ class ZulipLocalizationsRu extends ZulipLocalizations { @override String get sharePageTitle => 'Поделиться'; + @override + String get shareChooseAccountLabel => 'Choose account'; + @override String get mainMenuMyProfile => 'Мой профиль'; diff --git a/lib/generated/l10n/zulip_localizations_sk.dart b/lib/generated/l10n/zulip_localizations_sk.dart index 26be66502e..a9fc46b0ba 100644 --- a/lib/generated/l10n/zulip_localizations_sk.dart +++ b/lib/generated/l10n/zulip_localizations_sk.dart @@ -905,6 +905,9 @@ class ZulipLocalizationsSk extends ZulipLocalizations { @override String get sharePageTitle => 'Share'; + @override + String get shareChooseAccountLabel => 'Choose account'; + @override String get mainMenuMyProfile => 'Môj profil'; diff --git a/lib/generated/l10n/zulip_localizations_sl.dart b/lib/generated/l10n/zulip_localizations_sl.dart index 782288eb49..98168e6a58 100644 --- a/lib/generated/l10n/zulip_localizations_sl.dart +++ b/lib/generated/l10n/zulip_localizations_sl.dart @@ -928,6 +928,9 @@ class ZulipLocalizationsSl extends ZulipLocalizations { @override String get sharePageTitle => 'Share'; + @override + String get shareChooseAccountLabel => 'Choose account'; + @override String get mainMenuMyProfile => 'Moj profil'; diff --git a/lib/generated/l10n/zulip_localizations_uk.dart b/lib/generated/l10n/zulip_localizations_uk.dart index 92bcdb05c8..6501a672d2 100644 --- a/lib/generated/l10n/zulip_localizations_uk.dart +++ b/lib/generated/l10n/zulip_localizations_uk.dart @@ -918,6 +918,9 @@ class ZulipLocalizationsUk extends ZulipLocalizations { @override String get sharePageTitle => 'Поділитися'; + @override + String get shareChooseAccountLabel => 'Choose account'; + @override String get mainMenuMyProfile => 'Мій профіль'; diff --git a/lib/generated/l10n/zulip_localizations_zh.dart b/lib/generated/l10n/zulip_localizations_zh.dart index 00e7ed864e..4a8a708c72 100644 --- a/lib/generated/l10n/zulip_localizations_zh.dart +++ b/lib/generated/l10n/zulip_localizations_zh.dart @@ -903,6 +903,9 @@ class ZulipLocalizationsZh extends ZulipLocalizations { @override String get sharePageTitle => 'Share'; + @override + String get shareChooseAccountLabel => 'Choose account'; + @override String get mainMenuMyProfile => 'My profile'; diff --git a/lib/widgets/app.dart b/lib/widgets/app.dart index 8902eecb04..8ee37af40e 100644 --- a/lib/widgets/app.dart +++ b/lib/widgets/app.dart @@ -308,12 +308,72 @@ class _PreventEmptyStack extends NavigatorObserver { class ChooseAccountPage extends StatelessWidget { const ChooseAccountPage({super.key}); - Widget _buildAccountItem( - BuildContext context, { - required int accountId, - required Widget title, - Widget? subtitle, - }) { + @override + Widget build(BuildContext context) { + final colorScheme = ColorScheme.of(context); + final zulipLocalizations = ZulipLocalizations.of(context); + assert(!PerAccountStoreWidget.debugExistsOf(context)); + final globalStore = GlobalStoreWidget.of(context); + + // Borrowed from [AppBar.build]. + // See documentation on [ModalRoute.impliesAppBarDismissal]: + // > Whether an [AppBar] in the route should automatically add a back button or + // > close button. + final hasBackButton = ModalRoute.of(context)?.impliesAppBarDismissal ?? false; + + return MenuButtonTheme( + data: MenuButtonThemeData(style: MenuItemButton.styleFrom( + backgroundColor: colorScheme.secondaryContainer, + foregroundColor: colorScheme.onSecondaryContainer)), + child: Scaffold( + appBar: AppBar( + titleSpacing: hasBackButton ? null : 16, + title: Text(zulipLocalizations.chooseAccountPageTitle), + actions: const [ChooseAccountPageOverflowButton()]), + body: SafeArea( + minimum: const EdgeInsets.fromLTRB(8, 0, 8, 8), + child: Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 400), + child: Column(mainAxisSize: MainAxisSize.min, children: [ + Flexible(child: SingleChildScrollView( + padding: const EdgeInsets.only(top: 8), + child: Column(mainAxisSize: MainAxisSize.min, children: [ + for (final (:accountId, :account) in globalStore.accountEntries) + ChooseAccountListItem( + accountId: accountId, + title: Text(account.realmUrl.toString()), + subtitle: Text(account.email), + showLogoutMenu: true, + onTap: () => HomePage.navigate(context, accountId: accountId)), + ]))), + const SizedBox(height: 12), + ElevatedButton( + onPressed: () => Navigator.push(context, + AddAccountPage.buildRoute()), + child: Text(zulipLocalizations.chooseAccountButtonAddAnAccount)), + ])))))); + } +} + +class ChooseAccountListItem extends StatelessWidget { + const ChooseAccountListItem({ + super.key, + required this.accountId, + required this.title, + required this.subtitle, + required this.showLogoutMenu, + required this.onTap, + }); + + final int accountId; + final Widget title; + final Widget? subtitle; + final bool showLogoutMenu; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { final colorScheme = ColorScheme.of(context); final designVariables = DesignVariables.of(context); final zulipLocalizations = ZulipLocalizations.of(context); @@ -325,7 +385,7 @@ class ChooseAccountPage extends StatelessWidget { subtitle: subtitle, tileColor: colorScheme.secondaryContainer, textColor: colorScheme.onSecondaryContainer, - trailing: MenuAnchor( + trailing: !showLogoutMenu ? null : MenuAnchor( menuChildren: [ MenuItemButton( onPressed: () async { @@ -357,52 +417,7 @@ class ChooseAccountPage extends StatelessWidget { // The default trailing padding with M3 is 24px. Decrease by 12 because // IconButton (the "…" button) comes with 12px padding on all sides. contentPadding: const EdgeInsetsDirectional.only(start: 16, end: 12), - onTap: () => HomePage.navigate(context, accountId: accountId))); - } - - @override - Widget build(BuildContext context) { - final colorScheme = ColorScheme.of(context); - final zulipLocalizations = ZulipLocalizations.of(context); - assert(!PerAccountStoreWidget.debugExistsOf(context)); - final globalStore = GlobalStoreWidget.of(context); - - // Borrowed from [AppBar.build]. - // See documentation on [ModalRoute.impliesAppBarDismissal]: - // > Whether an [AppBar] in the route should automatically add a back button or - // > close button. - final hasBackButton = ModalRoute.of(context)?.impliesAppBarDismissal ?? false; - - return MenuButtonTheme( - data: MenuButtonThemeData(style: MenuItemButton.styleFrom( - backgroundColor: colorScheme.secondaryContainer, - foregroundColor: colorScheme.onSecondaryContainer)), - child: Scaffold( - appBar: AppBar( - titleSpacing: hasBackButton ? null : 16, - title: Text(zulipLocalizations.chooseAccountPageTitle), - actions: const [ChooseAccountPageOverflowButton()]), - body: SafeArea( - minimum: const EdgeInsets.fromLTRB(8, 0, 8, 8), - child: Center( - child: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 400), - child: Column(mainAxisSize: MainAxisSize.min, children: [ - Flexible(child: SingleChildScrollView( - padding: const EdgeInsets.only(top: 8), - child: Column(mainAxisSize: MainAxisSize.min, children: [ - for (final (:accountId, :account) in globalStore.accountEntries) - _buildAccountItem(context, - accountId: accountId, - title: Text(account.realmUrl.toString()), - subtitle: Text(account.email)), - ]))), - const SizedBox(height: 12), - ElevatedButton( - onPressed: () => Navigator.push(context, - AddAccountPage.buildRoute()), - child: Text(zulipLocalizations.chooseAccountButtonAddAnAccount)), - ])))))); + onTap: onTap)); } } diff --git a/lib/widgets/button.dart b/lib/widgets/button.dart index d4f8c98486..d1dfb76982 100644 --- a/lib/widgets/button.dart +++ b/lib/widgets/button.dart @@ -11,6 +11,9 @@ import 'theme.dart'; /// The Figma uses this for the "Cancel" and "Save" buttons in the compose box /// for editing an already-sent message. /// +/// The icon is optional; +/// to provide one, pass [icon] or [buildIcon] but not both. +/// /// See Figma: /// * Component: https://www.figma.com/design/msWyAJ8cnMHgOMPxi7BUvA/Zulip-Web-UI-kit?node-id=1-2780&t=Wia0D0i1I0GXdD9z-0 /// * Edit-message compose box: https://www.figma.com/design/1JTNtYo9memgW7vV6d0ygq/Zulip-Mobile?node-id=3988-38201&m=dev @@ -22,14 +25,16 @@ class ZulipWebUiKitButton extends StatelessWidget { this.size = ZulipWebUiKitButtonSize.normal, required this.label, this.icon, + this.buildIcon, required this.onPressed, - }); + }) : assert(icon == null || buildIcon == null); final ZulipWebUiKitButtonAttention attention; final ZulipWebUiKitButtonIntent intent; final ZulipWebUiKitButtonSize size; final String label; final IconData? icon; + final Widget Function(double size)? buildIcon; final VoidCallback onPressed; WidgetStateColor _backgroundColor(DesignVariables designVariables) { @@ -141,14 +146,24 @@ class ZulipWebUiKitButton extends StatelessWidget { final labelColor = _labelColor(designVariables); + final iconSize = 16.0; + + Widget? effectiveIcon; + if (icon != null) { + effectiveIcon = Icon(icon); // (size is set in TextButton.styleFrom) + } + if (buildIcon != null) { + effectiveIcon = buildIcon!(iconSize); + } + return AnimatedScaleOnTap( scaleEnd: 0.96, duration: Duration(milliseconds: 100), child: TextButton.icon( // TODO the gap between the icon and label should be 6px, not 8px - icon: icon != null ? Icon(icon) : null, + icon: effectiveIcon, style: TextButton.styleFrom( - iconSize: 16, + iconSize: iconSize, iconColor: labelColor, padding: EdgeInsets.symmetric( horizontal: _forSize(6, 10), diff --git a/lib/widgets/home.dart b/lib/widgets/home.dart index 62c09c0857..f354a7ef5f 100644 --- a/lib/widgets/home.dart +++ b/lib/widgets/home.dart @@ -330,8 +330,8 @@ void _showMainMenu(BuildContext context, { }); } -abstract class _MenuButton extends StatelessWidget { - const _MenuButton(); +abstract class MenuButton extends StatelessWidget { + const MenuButton({super.key}); String label(ZulipLocalizations zulipLocalizations); @@ -342,12 +342,12 @@ abstract class _MenuButton extends StatelessWidget { /// Must be non-null unless [buildLeading] is overridden. IconData? get icon; - static const _iconSize = 24.0; + static const iconSize = 24.0; Widget buildLeading(BuildContext context) { assert(icon != null); final designVariables = DesignVariables.of(context); - return Icon(icon, size: _iconSize, + return Icon(icon, size: iconSize, color: selected ? designVariables.iconSelected : designVariables.icon); } @@ -400,7 +400,7 @@ abstract class _MenuButton extends StatelessWidget { onPressed: () => _handlePress(context), style: buttonStyle, child: Row(spacing: 8, children: [ - SizedBox.square(dimension: _iconSize, + SizedBox.square(dimension: iconSize, child: buildLeading(context)), Expanded(child: Text(label(zulipLocalizations), // TODO(design): determine if we prefer to wrap @@ -412,7 +412,7 @@ abstract class _MenuButton extends StatelessWidget { } /// A menu button controlling the selected [_HomePageTab] on the bottom nav bar. -abstract class _NavigationBarMenuButton extends _MenuButton { +abstract class _NavigationBarMenuButton extends MenuButton { const _NavigationBarMenuButton({required this.tabNotifier}); final ValueNotifier<_HomePageTab> tabNotifier; @@ -428,7 +428,7 @@ abstract class _NavigationBarMenuButton extends _MenuButton { } } -class _SearchButton extends _MenuButton { +class _SearchButton extends MenuButton { const _SearchButton(); @override @@ -461,7 +461,7 @@ class _InboxButton extends _NavigationBarMenuButton { _HomePageTab get navigationTarget => _HomePageTab.inbox; } -class _MentionsButton extends _MenuButton { +class _MentionsButton extends MenuButton { const _MentionsButton(); @override @@ -479,7 +479,7 @@ class _MentionsButton extends _MenuButton { } } -class _StarredMessagesButton extends _MenuButton { +class _StarredMessagesButton extends MenuButton { const _StarredMessagesButton(); @override @@ -497,7 +497,7 @@ class _StarredMessagesButton extends _MenuButton { } } -class _CombinedFeedButton extends _MenuButton { +class _CombinedFeedButton extends MenuButton { const _CombinedFeedButton(); @override @@ -545,7 +545,7 @@ class _DirectMessagesButton extends _NavigationBarMenuButton { _HomePageTab get navigationTarget => _HomePageTab.directMessages; } -class _MyProfileButton extends _MenuButton { +class _MyProfileButton extends MenuButton { const _MyProfileButton(); @override @@ -556,7 +556,7 @@ class _MyProfileButton extends _MenuButton { final store = PerAccountStoreWidget.of(context); return Avatar( userId: store.selfUserId, - size: _MenuButton._iconSize, + size: MenuButton.iconSize, borderRadius: 4, showPresence: false, ); @@ -575,7 +575,7 @@ class _MyProfileButton extends _MenuButton { } } -class _SwitchAccountButton extends _MenuButton { +class _SwitchAccountButton extends MenuButton { const _SwitchAccountButton(); @override @@ -592,7 +592,7 @@ class _SwitchAccountButton extends _MenuButton { } } -class _SettingsButton extends _MenuButton { +class _SettingsButton extends MenuButton { const _SettingsButton(); @override @@ -609,7 +609,7 @@ class _SettingsButton extends _MenuButton { } } -class _AboutZulipButton extends _MenuButton { +class _AboutZulipButton extends MenuButton { const _AboutZulipButton(); @override diff --git a/lib/widgets/page.dart b/lib/widgets/page.dart index 9aebca9dfd..19510c44cd 100644 --- a/lib/widgets/page.dart +++ b/lib/widgets/page.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; +import '../model/store.dart'; import 'store.dart'; import 'text.dart'; import 'theme.dart'; @@ -213,7 +214,11 @@ class LoadingPlaceholderPage extends StatelessWidget { } } -/// A "no content here" message for when a page has no content to show. +/// A placeholder for when a page body has no content to show. +/// +/// Pass [message] for a "no-content-here" message, +/// or pass true for [loading] if the content hasn't finished loading yet, +/// but don't pass both. /// /// Suitable for the inbox, the message-list page, etc. /// @@ -227,14 +232,30 @@ class LoadingPlaceholderPage extends StatelessWidget { // TODO(#311) If the message list gets a bottom nav, the bottom inset will // always be handled externally too; simplify implementation and dartdoc. class PageBodyEmptyContentPlaceholder extends StatelessWidget { - const PageBodyEmptyContentPlaceholder({super.key, required this.message}); + const PageBodyEmptyContentPlaceholder({ + super.key, + this.message, + this.loading = false, + }) : assert((message != null) ^ loading); - final String message; + final String? message; + final bool loading; @override Widget build(BuildContext context) { final designVariables = DesignVariables.of(context); + final child = loading + ? CircularProgressIndicator() + : Text( + textAlign: TextAlign.center, + style: TextStyle( + color: designVariables.labelSearchPrompt, + fontSize: 17, + height: 23 / 17, + ).merge(weightVariableTextStyle(context, wght: 500)), + message!); + return SafeArea( minimum: EdgeInsets.fromLTRB(24, 0, 24, 16), child: Padding( @@ -243,13 +264,107 @@ class PageBodyEmptyContentPlaceholder extends StatelessWidget { alignment: Alignment.topCenter, // TODO leading and trailing elements, like in Figma (given as SVGs): // https://www.figma.com/design/1JTNtYo9memgW7vV6d0ygq/Zulip-Mobile?node-id=5957-167736&m=dev - child: Text( - textAlign: TextAlign.center, - style: TextStyle( - color: designVariables.labelSearchPrompt, - fontSize: 17, - height: 23 / 17, - ).merge(weightVariableTextStyle(context, wght: 500)), - message)))); + child: child))); + } +} + +/// A [ChangeNotifier] that tracks a nullable account-ID selection. +/// +/// Maintains a listener on [GlobalStore] and will clear the selection +/// if the account no longer exists because it was logged out. +/// +/// Use [selectedAccountId] to read the selection +/// and [selectAccount] to update it. +class MultiAccountPageController extends ChangeNotifier { + factory MultiAccountPageController.init(GlobalStore globalStore) { + + final result = MultiAccountPageController._(globalStore); + globalStore.addListener(result._globalStoreChanged); + // TODO initialize selectedAccountId to last-visited? + result._reconcile(); + return result; + } + + MultiAccountPageController._(this._globalStore); + + final GlobalStore _globalStore; + + int? get selectedAccountId => _selectedAccountId; + int? _selectedAccountId; + + void selectAccount(int accountId) { + _selectedAccountId = accountId; + _reconcile(); + notifyListeners(); + } + + void _globalStoreChanged() { + _reconcile(); + } + + void _reconcile() { + if (_selectedAccountId == null) return; + if (_globalStore.accountIds.contains(_selectedAccountId)) return; + _selectedAccountId = null; + notifyListeners(); + } + + @override + void dispose() { + _globalStore.removeListener(_globalStoreChanged); + super.dispose(); + } +} + +/// A widget that can efficiently provide a [MultiAccountPageController] +/// to its descendants, via [BuildContext.dependOnInheritedWidgetOfExactType]. +class MultiAccountPageProvider extends StatefulWidget { + const MultiAccountPageProvider({super.key, required this.child}); + + final Widget child; + + /// The [MultiAccountPageController] of an assumed + /// [MultiAccountPageProvider] ancestor. + /// + /// Creates a dependency on the controller via [InheritedNotifier]. + static MultiAccountPageController of(BuildContext context) { + final widget = context.dependOnInheritedWidgetOfExactType<_MultiAccountPageControllerInheritedWidget>(); + assert(widget != null, 'No MultiAccountPageProvider ancestor'); + return widget!.controller; } + + @override + State createState() => _MultiAccountPageProviderState(); +} + +class _MultiAccountPageProviderState extends State { + GlobalStore? _globalStore; + + MultiAccountPageController? _controller; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + + final store = GlobalStoreWidget.of(context); + if (_globalStore != store) { + _controller?.dispose(); + _controller = MultiAccountPageController.init(store); + } + } + + @override + Widget build(BuildContext context) { + return _MultiAccountPageControllerInheritedWidget( + controller: _controller!, child: widget.child); + } +} + +class _MultiAccountPageControllerInheritedWidget extends InheritedNotifier { + const _MultiAccountPageControllerInheritedWidget({ + required MultiAccountPageController controller, + required super.child, + }) : super(notifier: controller); + + MultiAccountPageController get controller => notifier!; } diff --git a/lib/widgets/share.dart b/lib/widgets/share.dart index c46e333c64..e080e57865 100644 --- a/lib/widgets/share.dart +++ b/lib/widgets/share.dart @@ -10,16 +10,21 @@ import '../host/android_intents.dart'; import '../log.dart'; import '../model/binding.dart'; import '../model/narrow.dart'; +import '../model/store.dart'; +import 'action_sheet.dart'; import 'app.dart'; +import 'button.dart'; import 'color.dart'; import 'compose_box.dart'; import 'dialog.dart'; +import 'home.dart'; import 'message_list.dart'; import 'page.dart'; import 'recent_dm_conversations.dart'; import 'store.dart'; import 'subscription_list.dart'; import 'theme.dart'; +import 'user.dart'; // Responds to receiving shared content from other apps. class ShareService { @@ -60,11 +65,7 @@ class ShareService { final globalStore = GlobalStoreWidget.of(context); - // TODO(#524) use last account used, not the first in the list - // TODO(#1779) allow selecting account, if there are multiple - final accountId = globalStore.accounts.firstOrNull?.id; - - if (accountId == null) { + if (globalStore.accounts.isEmpty) { final zulipLocalizations = ZulipLocalizations.of(context); showErrorDialog( context: context, @@ -101,7 +102,6 @@ class ShareService { unawaited(navigator.push( SharePage.buildRoute( - accountId: accountId, sharedFiles: sharedFiles, sharedText: intentSendEvent.extraText))); } @@ -117,16 +117,18 @@ class SharePage extends StatelessWidget { final Iterable? sharedFiles; final String? sharedText; - static AccountRoute buildRoute({ - required int accountId, + static MaterialWidgetRoute buildRoute({ required Iterable? sharedFiles, required String? sharedText, }) { - return MaterialAccountWidgetRoute( - accountId: accountId, - page: SharePage( - sharedFiles: sharedFiles, - sharedText: sharedText)); + return MaterialWidgetRoute( + // TODO either call [ChooseAccountForShareDialog.show] every time this + // page initializes, or else have the [MultiAccountPageController] + // default to the last-visited account + page: MultiAccountPageProvider( + // So that PageRoot.contextOf can be used for MultiAccountPageProvider.of + child: PageRoot( + child: SharePage(sharedFiles: sharedFiles, sharedText: sharedText)))); } void _handleNarrowSelect(BuildContext context, Narrow narrow) { @@ -173,22 +175,27 @@ class SharePage extends StatelessWidget { Widget build(BuildContext context) { final zulipLocalizations = ZulipLocalizations.of(context); final designVariables = DesignVariables.of(context); + final selectedAccountId = MultiAccountPageProvider.of(context).selectedAccountId; - return DefaultTabController( - length: 2, - child: Scaffold( - appBar: AppBar( - title: Text(zulipLocalizations.sharePageTitle), - bottom: TabBar( - indicatorColor: designVariables.icon, - labelColor: designVariables.foreground, - unselectedLabelColor: designVariables.foreground.withFadedAlpha(0.7), - splashFactory: NoSplash.splashFactory, - tabs: [ - Tab(text: zulipLocalizations.channelsPageTitle), - Tab(text: zulipLocalizations.recentDmConversationsPageTitle), - ])), - body: TabBarView(children: [ + PreferredSizeWidget? bottom; + if (selectedAccountId != null) { + bottom = TabBar( + indicatorColor: designVariables.icon, + labelColor: designVariables.foreground, + unselectedLabelColor: designVariables.foreground.withFadedAlpha(0.7), + splashFactory: NoSplash.splashFactory, + tabs: [ + Tab(text: zulipLocalizations.channelsPageTitle), + Tab(text: zulipLocalizations.recentDmConversationsPageTitle), + ]); + } + + final Widget? body; + if (selectedAccountId != null) { + body = PerAccountStoreWidget( + accountId: selectedAccountId, + placeholder: PageBodyEmptyContentPlaceholder(loading: true), + child: TabBarView(children: [ SubscriptionListPageBody( showTopicListButtonInActionSheet: false, hideChannelsIfUserCantPost: true, @@ -203,6 +210,139 @@ class SharePage extends StatelessWidget { RecentDmConversationsPageBody( hideDmsIfUserCantPost: true, onDmSelect: (narrow) => _handleNarrowSelect(context, narrow)), - ]))); + ])); + } else { + body = PageBodyEmptyContentPlaceholder( + // TODO i18n, choose the right wording + message: 'No account is selected. Please use the button above to choose one.'); + } + + return DefaultTabController( + length: 2, + child: Scaffold( + appBar: AppBar( + title: Text(zulipLocalizations.sharePageTitle), + actionsPadding: EdgeInsetsDirectional.only(end: 8), + actions: [AccountSelectorButton()], + bottom: bottom), + body: body)); + } +} + +class AccountSelectorButton extends StatelessWidget { + const AccountSelectorButton({super.key}); + + @override + Widget build(BuildContext context) { + final pageContext = PageRoot.contextOf(context); + final controller = MultiAccountPageProvider.of(context); + final selectedAccountId = controller.selectedAccountId; + + if (selectedAccountId == null) { + return ZulipWebUiKitButton( + attention: ZulipWebUiKitButtonAttention.high, // TODO medium looks better? + label: 'Choose account', // TODO i18n, choose the right text + onPressed: () => ChooseAccountForShareDialog.show(pageContext)); + } else { + return ZulipWebUiKitButton( + attention: ZulipWebUiKitButtonAttention.medium, // TODO low looks better? + label: 'Change account', // TODO i18n, choose the right text + buildIcon: (size) => PerAccountStoreWidget( + accountId: selectedAccountId, + placeholder: SizedBox.square(dimension: size), // TODO(#1036) realm logo + child: Builder(builder: (context) { + final store = PerAccountStoreWidget.of(context); + return AvatarShape(size: size, borderRadius: 3, + // TODO get realm logo from `store` + child: ColoredBox(color: Colors.pink)); + })), + onPressed: () => ChooseAccountForShareDialog.show(pageContext)); + } + } +} + +/// A dialog offering the list of accounts, +/// for one to be chosen to share to. +class ChooseAccountForShareDialog extends StatelessWidget { + const ChooseAccountForShareDialog._(this.pageContext); + + final BuildContext pageContext; + + static void show(BuildContext pageContext) async { + unawaited(showModalBottomSheet( + context: pageContext, + // Clip.hardEdge looks bad; Clip.antiAliasWithSaveLayer looks pixel-perfect + // on my iPhone 13 Pro but is marked as "much slower": + // https://api.flutter.dev/flutter/dart-ui/Clip.html + clipBehavior: Clip.antiAlias, + useSafeArea: true, + isScrollControlled: true, + builder: (_) { + return SafeArea( + minimum: const EdgeInsets.only(bottom: 16), + child: ChooseAccountForShareDialog._(pageContext)); + })); + } + + @override + Widget build(BuildContext context) { + final globalStore = GlobalStoreWidget.of(context); + final accountIds = globalStore.accountIds.toList(); + final controller = MultiAccountPageProvider.of(pageContext); + final content = SliverList.builder( + itemCount: accountIds.length, + itemBuilder: (_, index) { + final accountId = accountIds[index]; + final account = globalStore.getAccount(accountId); + return _AccountButton( + account!, + handlePressed: () => controller.selectAccount(accountId), + selected: accountId == controller.selectedAccountId); + }); + + return DraggableScrollableModalBottomSheet( + header: Padding( + padding: const EdgeInsets.only(top: 8), + child: BottomSheetHeader(title: 'Choose an account:')), + contentSliver: SliverPadding( + padding: EdgeInsets.symmetric(horizontal: 8), + sliver: content)); + } +} + +class _AccountButton extends MenuButton { + const _AccountButton(this.account, { + required this.handlePressed, + required bool selected, + }) : _selected = selected; + + final Account account; + final VoidCallback handlePressed; + + @override + bool get selected => _selected; + final bool _selected; + + @override + IconData? get icon => null; + + @override + Widget buildLeading(BuildContext context) { + return AvatarShape( + size: MenuButton.iconSize, + borderRadius: 4, + // TODO(#1036) realm logo + child: ColoredBox(color: Colors.pink)); + } + + @override + String label(ZulipLocalizations zulipLocalizations) { + // TODO(#1036) realm name (and email?) + return account.email; + } + + @override + void onPressed(BuildContext context) { + handlePressed(); } }