From 6a8e1b9d0290efc850f6173a132ed3f6720817fe Mon Sep 17 00:00:00 2001 From: BluewhaleYF Date: Sun, 25 Oct 2026 12:59:11 +0800 Subject: [PATCH 1/7] :lipstick: Center the settings search bar --- lib/misc/settings.dart | 100 +++++++++++++++++++++++------------------ 1 file changed, 57 insertions(+), 43 deletions(-) diff --git a/lib/misc/settings.dart b/lib/misc/settings.dart index 0a8e65695..87937922e 100644 --- a/lib/misc/settings.dart +++ b/lib/misc/settings.dart @@ -1,5 +1,6 @@ import 'dart:convert'; import 'dart:io'; +import 'dart:math' as math; import 'package:collection/collection.dart'; import 'package:dropdown_button2/dropdown_button2.dart'; @@ -2513,54 +2514,67 @@ class SettingsScreen extends HookConsumerWidget { ); } + // Center the search field in the app bar. Keep the left and right margins + // symmetric so the field stays visually centered even with the leading + // back button. + final searchBarWidth = math.min( + 420.0, + math.max(160.0, MediaQuery.sizeOf(context).width - 144), + ); + return AppScaffold( isNoBackground: false, appBar: AppBar( - title: SearchBar( - controller: searchController, - focusNode: searchFocusNode, - constraints: const BoxConstraints(maxWidth: 420, minHeight: 36), - // Flat, shadowless search field so it blends into the app bar instead - // of floating above it with the default M3 elevation (6dp + shadow). - elevation: const WidgetStatePropertyAll(0), - shadowColor: const WidgetStatePropertyAll(Colors.transparent), - surfaceTintColor: const WidgetStatePropertyAll(Colors.transparent), - // One tone deeper than the surrounding top bar so the field still - // reads clearly against the unified gray background. - backgroundColor: WidgetStatePropertyAll( - Theme.of(context).colorScheme.surfaceContainerHighest, - ), - hintText: 'searchSettings'.tr(), - hintStyle: WidgetStatePropertyAll( - TextStyle( - fontSize: 14, - color: Theme.of(context).colorScheme.onSurfaceVariant, + centerTitle: true, + title: SizedBox( + width: searchBarWidth, + child: SearchBar( + controller: searchController, + focusNode: searchFocusNode, + constraints: const BoxConstraints(minHeight: 36), + // Flat, shadowless search field so it blends into the app bar + // instead of floating above it with the default M3 elevation + // (6dp + shadow). + elevation: const WidgetStatePropertyAll(0), + shadowColor: const WidgetStatePropertyAll(Colors.transparent), + surfaceTintColor: const WidgetStatePropertyAll(Colors.transparent), + // One tone deeper than the surrounding top bar so the field still + // reads clearly against the unified gray background. + backgroundColor: WidgetStatePropertyAll( + Theme.of(context).colorScheme.surfaceContainerHighest, ), - ), - textStyle: const WidgetStatePropertyAll(TextStyle(fontSize: 14)), - onTapOutside: (_) => searchFocusNode.unfocus(), - trailing: [ - if (searchQuery.value.isNotEmpty) - IconButton( - onPressed: () { - searchController.clear(); - searchQuery.value = ''; - searchFocusNode.unfocus(); - }, - icon: Icon( - Symbols.close, - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - padding: EdgeInsets.zero, - constraints: const BoxConstraints(), - visualDensity: VisualDensity.compact, + hintText: 'searchSettings'.tr(), + hintStyle: WidgetStatePropertyAll( + TextStyle( + fontSize: 14, + color: Theme.of(context).colorScheme.onSurfaceVariant, ), - ], - onChanged: (value) => searchQuery.value = value, - leading: Icon( - Symbols.search, - size: 20, - color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + textStyle: const WidgetStatePropertyAll(TextStyle(fontSize: 14)), + onTapOutside: (_) => searchFocusNode.unfocus(), + trailing: [ + if (searchQuery.value.isNotEmpty) + IconButton( + onPressed: () { + searchController.clear(); + searchQuery.value = ''; + searchFocusNode.unfocus(); + }, + icon: Icon( + Symbols.close, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + visualDensity: VisualDensity.compact, + ), + ], + onChanged: (value) => searchQuery.value = value, + leading: Icon( + Symbols.search, + size: 20, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), ), ), leading: const AutoLeadingButton(), From c5d4510597012141f5ff5dced9511611d7c3864b Mon Sep 17 00:00:00 2001 From: BluewhaleYF Date: Sun, 25 Oct 2026 13:47:58 +0800 Subject: [PATCH 2/7] :lipstick: Reveal the account pill bar after scrolling --- lib/accounts/account_screen.dart | 58 +++++++++++++++---------- lib/discovery/widgets/pill_app_bar.dart | 20 ++++++--- 2 files changed, 48 insertions(+), 30 deletions(-) diff --git a/lib/accounts/account_screen.dart b/lib/accounts/account_screen.dart index 83c81d490..d3e95765f 100644 --- a/lib/accounts/account_screen.dart +++ b/lib/accounts/account_screen.dart @@ -113,51 +113,63 @@ class AccountFeatureWidget extends HookConsumerWidget { final scrollController = useScrollController(); final statusTop = MediaQuery.paddingOf(context).top; final artwork = account.profile.background; + // With artwork the page opens on a clean header image that runs all the way + // to the top edge, so the pill is kept out of sight until the page has + // scrolled 30% of the viewport. Accounts without artwork keep the pill + // surfaced from the start: there is nothing to uncover. + final pillRevealThreshold = MediaQuery.sizeOf(context).height * 0.3; // The artwork sliding under the pill drives its glass. Without artwork the // page opens on the identity block and never scrolls anything behind the // bar, so the pill stays surfaced instead of hiding over an empty gap. final fade = useValueNotifier(0.0); + final pillChild = Row( + children: [ + const SolDrawerButton(), + const Gap(SolSpace.sm), + Expanded( + child: Align( + alignment: Alignment.centerLeft, + child: Text( + 'account'.tr(), + style: Theme.of(context).appBarTheme.titleTextStyle, + ), + ), + ), + ], + ); + return AppScaffold( isNoBackground: isWide, appBarOverContent: true, appBar: PillAppBar( fade: artwork == null ? null : fade, statusTop: statusTop, - child: Row( - children: [ - const SolDrawerButton(), - const Gap(SolSpace.sm), - Expanded( - child: Align( - alignment: Alignment.centerLeft, - child: Text( - 'account'.tr(), - style: Theme.of(context).appBarTheme.titleTextStyle, + child: artwork == null + ? pillChild + : ValueListenableBuilder( + valueListenable: fade, + builder: (context, t, child) => IgnorePointer( + ignoring: t == 0, + child: Opacity(opacity: t, child: child), ), + child: pillChild, ), - ), - ], - ), ), body: NotificationListener( - onNotification: (notification) => updatePillFade(notification, fade), + onNotification: (notification) => + updatePillFade(notification, fade, threshold: pillRevealThreshold), child: CustomScrollView( controller: scrollController, slivers: [ - // The band the pill floats in. Its content is painted by the - // header below, which overhangs into it: the artwork's own top - // edge, mirrored upward and blurred, so the picture reads as - // continuing off the top of the screen instead of starting at a - // seam. - if (artwork != null) SliverGap(statusTop + kPillAppBarHeight), + // The artwork owns the very top of the page while the pill is + // hidden; once revealed, the pill floats over the content that has + // scrolled beneath it. SliverToBoxAdapter( child: _AccountHeader( account: account, statusTop: statusTop, - reflectionBand: artwork == null - ? 0 - : statusTop + kPillAppBarHeight, + reflectionBand: 0, onAvatarTap: () { context.router.push(AccountProfileRoute(name: account.name)); }, diff --git a/lib/discovery/widgets/pill_app_bar.dart b/lib/discovery/widgets/pill_app_bar.dart index 89e6772fa..33c61718a 100644 --- a/lib/discovery/widgets/pill_app_bar.dart +++ b/lib/discovery/widgets/pill_app_bar.dart @@ -31,19 +31,25 @@ const double kPillAppBarHeight = kPillBarContentHeight + SolSpace.md; /// Feeds a pill's fade ramp (0..1) from a scroll notification. /// /// The ramp is read off the scroll offset itself so the surface tracks the -/// drag instead of snapping at the first pixel. Only the outermost scrollable -/// counts: inside a [NestedScrollView] the inner list reports `depth` 1 and -/// restarts at offset 0 whenever the header re-expands, which would slam the -/// pill back to invisible while content is still under it. +/// drag instead of snapping at the first pixel. [threshold] keeps the pill +/// hidden until the page has scrolled that far, which lets a page open on its +/// artwork before the bar appears. Only the outermost scrollable counts: +/// inside a [NestedScrollView] the inner list reports `depth` 1 and restarts +/// at offset 0 whenever the header re-expands, which would slam the pill back +/// to invisible while content is still under it. /// /// Returns false so the notification keeps bubbling to other listeners. bool updatePillFade( ScrollNotification notification, - ValueNotifier fade, -) { + ValueNotifier fade, { + double threshold = 0, +}) { final metrics = notification.metrics; if (notification.depth != 0 || metrics.axis != Axis.vertical) return false; - final ramp = (metrics.pixels / kPillFadeDistance).clamp(0.0, 1.0); + final ramp = ((metrics.pixels - threshold) / kPillFadeDistance).clamp( + 0.0, + 1.0, + ); if (fade.value != ramp) fade.value = ramp; return false; } From 473d71827cb62dd4cfca356078a467f5eac51ed7 Mon Sep 17 00:00:00 2001 From: BluewhaleYF Date: Sun, 25 Oct 2026 14:13:11 +0800 Subject: [PATCH 3/7] :lipstick: Pin the rail toggle to the sidebar --- lib/misc/tabs_screen.dart | 72 +++++++++++++++++++--------- lib/shared/widgets/page_app_bar.dart | 14 +++++- test/misc/dashboard_toggle_test.dart | 6 +-- 3 files changed, 64 insertions(+), 28 deletions(-) diff --git a/lib/misc/tabs_screen.dart b/lib/misc/tabs_screen.dart index a814136fc..4a988ca7c 100644 --- a/lib/misc/tabs_screen.dart +++ b/lib/misc/tabs_screen.dart @@ -18,6 +18,7 @@ import 'package:island/core/navigation/navigation_rail_scope.dart'; import 'package:island/notifications/notification.dart'; import 'package:island/route.gr.dart'; import 'package:island/shared/widgets/app_scaffold.dart'; +import 'package:island/shared/widgets/page_app_bar.dart'; import 'package:island/core/navigation/tab_destinations.dart'; import 'package:material_symbols_icons/symbols.dart'; import 'package:island/chat/pods/chat_summary.dart'; @@ -106,7 +107,9 @@ class _TabsScreenContentState extends ConsumerState<_TabsScreenContent> { /// Whether the wide layout's rail is expanded over every destination. /// /// The rail replaces the drawer there, so a request to open the navigation - /// expands it instead of sliding a drawer over the page. + /// expands it instead of sliding a drawer over the page. The choice is kept + /// while switching destinations, so a collapsed rail stays collapsed and an + /// expanded one stays expanded. bool _railExpanded = false; void _toggleRail() { @@ -220,6 +223,11 @@ class _TabsScreenContentState extends ConsumerState<_TabsScreenContent> { // The rail is the wide layout's navigation surface: whenever it is shown, // expanding it replaces the drawer entirely. final railAvailable = wideScreen && railDestinations.isNotEmpty; + // In landscape the rail carries the navigation toggle in its bottom-left + // corner, so pages no longer spend their leading slot on the hamburger. + final showRailToggle = + railAvailable && + MediaQuery.orientationOf(context) == Orientation.landscape; final drawerEnabled = isDrawerEnabled && !railAvailable; /// Handles the "open the navigation" request: expand the rail where it @@ -264,7 +272,6 @@ class _TabsScreenContentState extends ConsumerState<_TabsScreenContent> { void onRailDestinationSelected(int index) { if (index < 0 || index >= displayedRailDestinations.length) return; tabsRouter.setActiveIndex(displayedRailDestinations[index].routeIndex); - if (_railExpanded) setState(() => _railExpanded = false); } void onBottomNavDestinationSelected(int index) { @@ -521,33 +528,52 @@ class _TabsScreenContentState extends ConsumerState<_TabsScreenContent> { // No drawer on this layout: the rail is present, so a menu request // expands it over every destination instead of sliding a second // navigation surface over the page. + // + // The rail sizes itself to its content, so a trailing child cannot be + // spread across its full width. The toggle is therefore overlaid on the + // bottom slot reserved by `trailing`: 16px in, so its 24px glyph lines + // up with the destination icons (which are centred in the rail's 80px + // control column), but with no label beside it. + Widget rail = NavigationRail( + backgroundColor: Colors.transparent, + extended: railExpanded, + // Extended rails always paint their labels and reject any + // other label type. + labelType: railExpanded ? NavigationRailLabelType.none : null, + // Short windows cannot fit every destination, and the + // expanded rail shows all of them. + scrollable: true, + destinations: displayedRailDestinations.mapIndexed((idx, d) { + return NavigationRailDestination( + icon: withEntranceMenu(d.iconBuilder(railCurrentIndex == idx), d), + label: withEntranceMenu(Text(d.label), d), + ); + }).toList(), + selectedIndex: railCurrentIndex, + onDestinationSelected: onRailDestinationSelected, + trailing: showRailToggle ? const SizedBox(height: 56) : null, + trailingAtBottom: true, + ); + if (showRailToggle) { + rail = Stack( + children: [ + rail, + Positioned( + left: 16, + bottom: 8, + child: const SolDrawerButton(pinnedToRail: true), + ), + ], + ); + } + final railContent = Scaffold( key: rootScaffoldKey, backgroundColor: Theme.of(context).colorScheme.surfaceContainer, body: SafeArea( child: Row( children: [ - NavigationRail( - backgroundColor: Colors.transparent, - extended: railExpanded, - // Extended rails always paint their labels and reject any - // other label type. - labelType: railExpanded ? NavigationRailLabelType.none : null, - // Short windows cannot fit every destination, and the - // expanded rail shows all of them. - scrollable: true, - destinations: displayedRailDestinations.mapIndexed((idx, d) { - return NavigationRailDestination( - icon: withEntranceMenu( - d.iconBuilder(railCurrentIndex == idx), - d, - ), - label: withEntranceMenu(Text(d.label), d), - ); - }).toList(), - selectedIndex: railCurrentIndex, - onDestinationSelected: onRailDestinationSelected, - ), + rail, Expanded( child: ClipRRect( borderRadius: const BorderRadius.only( diff --git a/lib/shared/widgets/page_app_bar.dart b/lib/shared/widgets/page_app_bar.dart index bfca2256c..7cbde56b1 100644 --- a/lib/shared/widgets/page_app_bar.dart +++ b/lib/shared/widgets/page_app_bar.dart @@ -13,10 +13,16 @@ import 'package:island/shared/widgets/app_scaffold.dart'; /// navigation bar, so every root tab's leading slot uses this exact button. /// /// On the wide layout the rail replaces the drawer, so the same button expands -/// and collapses the rail over every destination instead. +/// and collapses the rail over every destination instead. In landscape the +/// shell moves that toggle to the rail's bottom-left corner and page app bars +/// drop their copy, leaving a single entry point. class SolDrawerButton extends StatelessWidget { final Color? color; - const SolDrawerButton({super.key, this.color}); + + /// Set when the shell renders this button in the rail's bottom slot. + final bool pinnedToRail; + + const SolDrawerButton({super.key, this.color, this.pinnedToRail = false}); @override Widget build(BuildContext context) { @@ -29,6 +35,10 @@ class SolDrawerButton extends StatelessWidget { Theme.of(context).colorScheme.onSurface; final rail = NavigationRailScope.maybeOf(context); if (rail != null && rail.available) { + if (!pinnedToRail && + MediaQuery.orientationOf(context) == Orientation.landscape) { + return const SizedBox.shrink(); + } return IconButton( icon: const Icon(Symbols.menu_rounded), color: foreground, diff --git a/test/misc/dashboard_toggle_test.dart b/test/misc/dashboard_toggle_test.dart index 969d230d1..990957225 100644 --- a/test/misc/dashboard_toggle_test.dart +++ b/test/misc/dashboard_toggle_test.dart @@ -289,7 +289,7 @@ void main() { expect(find.byType(Drawer), findsNothing); // A destination only the expanded rail offers is selectable, and picking - // one navigates and collapses the rail. + // one navigates without collapsing the rail. await tester.tap( find.descendant( of: find.byType(NavigationRail), @@ -300,8 +300,8 @@ void main() { expect(tester.takeException(), isNull); expect(_router.currentPath, '/wallet'); - expect(rail().extended, isFalse); - expect(rail().destinations.length, 2); + expect(rail().extended, isTrue); + expect(rail().destinations.length, 7); await _teardown(tester); debugDefaultTargetPlatformOverride = null; From 8e2aa5081e12110f2f2864aae5a5d899fa46e7d2 Mon Sep 17 00:00:00 2001 From: BluewhaleYF Date: Sun, 25 Oct 2026 14:39:23 +0800 Subject: [PATCH 4/7] :lipstick: Split the portrait headers into floating glass controls --- lib/chat/widgets/chat_list_screen.dart | 220 +++++++++++++++++++------ lib/misc/dashboard/dash.dart | 38 ++--- lib/shared/widgets/page_app_bar.dart | 26 +++ test/chat/chat_list_pill_test.dart | 17 +- 4 files changed, 218 insertions(+), 83 deletions(-) diff --git a/lib/chat/widgets/chat_list_screen.dart b/lib/chat/widgets/chat_list_screen.dart index e8048164d..248277d60 100644 --- a/lib/chat/widgets/chat_list_screen.dart +++ b/lib/chat/widgets/chat_list_screen.dart @@ -41,6 +41,7 @@ import 'package:island/route.gr.dart'; import 'package:island/shared/widgets/alert.dart'; import 'package:island/shared/widgets/app_scaffold.dart'; import 'package:island/shared/widgets/page_app_bar.dart'; +import 'package:island/shared/widgets/pill_surface.dart'; import 'package:island/shared/widgets/confuse_spinner.dart'; import 'package:island/shared/widgets/empty_state.dart'; import 'package:island/shared/widgets/response.dart'; @@ -1488,6 +1489,94 @@ class _MarkAllReadButton extends ConsumerWidget { } } +/// Height of each half of the portrait chat header. Matches the dashboard's +/// standalone glass navigation control. +const double _kChatSplitControlHeight = 48; + +/// Vertical band the split portrait chat header occupies below the status bar. +/// +/// Kept public alongside the other pill bands so tests and neighbouring chrome +/// can reserve the same space. +const double kChatSplitBarBand = + kPillSideGutter + _kChatSplitControlHeight + SolSpace.xs; + +/// Portrait chat header, split into two independent glass surfaces. +/// +/// The navigation toggle sits alone at the top-left, mirroring the dashboard; +/// the section picker (and its trailing actions) keeps the top-right corner. +class _ChatSplitBar extends StatelessWidget implements PreferredSizeWidget { + final double statusTop; + final TabController tabController; + final bool isSyncing; + final Color foreground; + + const _ChatSplitBar({ + required this.statusTop, + required this.tabController, + required this.isSyncing, + required this.foreground, + }); + + @override + Size get preferredSize => Size.fromHeight(statusTop + kChatSplitBarBand); + + @override + Widget build(BuildContext context) { + final top = statusTop + kPillSideGutter; + return SizedBox( + height: preferredSize.height, + child: Stack( + children: [ + Positioned( + left: kPillSideGutter, + top: top, + child: const GlassNavigationButton(), + ), + Positioned( + right: kPillSideGutter, + top: top, + child: SizedBox( + height: _kChatSplitControlHeight, + child: PillSurface( + t: 1, + margin: EdgeInsets.zero, + radius: SolRadius.pillRadius, + contentPadding: const EdgeInsets.symmetric( + horizontal: SolSpace.xs, + ), + child: Material( + color: Colors.transparent, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + _ChatSectionButton(tabController: tabController), + if (isSyncing) + Padding( + padding: const EdgeInsets.only(right: SolSpace.sm), + child: SizedBox( + width: 24, + height: 24, + child: ConfuseSpinner( + size: 24, + speed: 7, + fontSize: 12, + color: foreground.withOpacity(0.65), + ), + ), + ), + _MarkAllReadButton(color: foreground), + ], + ), + ), + ), + ), + ), + ], + ), + ); + } +} + class ChatListWidget extends HookConsumerWidget { final bool isAside; const ChatListWidget({super.key, this.isAside = false}); @@ -1574,6 +1663,8 @@ class ChatListWidget extends HookConsumerWidget { }, [lifecycleState.value]); final asideLayout = isAside || isWideScreen(context); + final isPortrait = + MediaQuery.orientationOf(context) == Orientation.portrait; final sidebarWidth = useState(320.0); const minSidebarWidth = 260.0; const maxSidebarWidth = 520.0; @@ -1595,33 +1686,44 @@ class ChatListWidget extends HookConsumerWidget { chatGroups: chatGroups, onChatGroupsChanged: refreshChatGroups, accountId: accountId, - headerGap: kPillAppBarHeight, - onScroll: (notification) => updatePillFade(notification, fade), + headerGap: isPortrait ? kChatSplitBarBand : kPillAppBarHeight, + onScroll: isPortrait + ? null + : (notification) => updatePillFade(notification, fade), ), Positioned( top: 0, left: 0, right: 0, - child: PillAppBar( - fade: fade, - statusTop: 0, - child: Row( - children: [ - // Same toggle the narrow page's pill carries, so the - // navigation is reachable from the chat pane too — it - // expands the rail on the wide layout. - const SolDrawerButton(), - const Gap(SolSpace.sm), - Expanded( - child: Align( - alignment: Alignment.centerLeft, - child: _ChatSectionButton(tabController: tabController), + child: isPortrait + ? _ChatSplitBar( + statusTop: 0, + tabController: tabController, + isSyncing: false, + foreground: Theme.of(context).colorScheme.onSurface, + ) + : PillAppBar( + fade: fade, + statusTop: 0, + child: Row( + children: [ + // Same toggle the narrow page's pill carries, so the + // navigation is reachable from the chat pane too — it + // expands the rail on the wide layout. + const SolDrawerButton(), + const Gap(SolSpace.sm), + Expanded( + child: Align( + alignment: Alignment.centerLeft, + child: _ChatSectionButton( + tabController: tabController, + ), + ), + ), + const _MarkAllReadButton(), + ], ), ), - const _MarkAllReadButton(), - ], - ), - ), ), Positioned( bottom: 0, @@ -1671,39 +1773,46 @@ class ChatListWidget extends HookConsumerWidget { floatingActionButton: const ChatFabWidget().padding( bottom: MediaQuery.paddingOf(context).bottom, ), - appBar: PillAppBar( - fade: fade, - statusTop: statusTop, - child: Row( - children: [ - const SolDrawerButton(), - const Gap(SolSpace.sm), - // Same placement as the explore bar: the picker takes the title - // slot and the actions stay flush right. - Expanded( - child: Align( - alignment: Alignment.centerLeft, - child: _ChatSectionButton(tabController: tabController), - ), - ), - if (isSyncing) - Padding( - padding: const EdgeInsets.only(right: SolSpace.sm), - child: SizedBox( - width: 24, - height: 24, - child: ConfuseSpinner( - size: 24, - speed: 7, - fontSize: 12, - color: appBarForeground.withOpacity(0.65), + appBar: isPortrait + ? _ChatSplitBar( + statusTop: statusTop, + tabController: tabController, + isSyncing: isSyncing, + foreground: appBarForeground, + ) + : PillAppBar( + fade: fade, + statusTop: statusTop, + child: Row( + children: [ + const SolDrawerButton(), + const Gap(SolSpace.sm), + // Same placement as the explore bar: the picker takes the + // title slot and the actions stay flush right. + Expanded( + child: Align( + alignment: Alignment.centerLeft, + child: _ChatSectionButton(tabController: tabController), + ), ), - ), + if (isSyncing) + Padding( + padding: const EdgeInsets.only(right: SolSpace.sm), + child: SizedBox( + width: 24, + height: 24, + child: ConfuseSpinner( + size: 24, + speed: 7, + fontSize: 12, + color: appBarForeground.withOpacity(0.65), + ), + ), + ), + _MarkAllReadButton(color: appBarForeground), + ], ), - _MarkAllReadButton(color: appBarForeground), - ], - ), - ), + ), body: userInfo.value == null ? const ResponseUnauthorizedWidget() : Column( @@ -1716,9 +1825,12 @@ class ChatListWidget extends HookConsumerWidget { chatGroups: chatGroups, onChatGroupsChanged: refreshChatGroups, accountId: accountId, - headerGap: statusTop + kPillAppBarHeight, - onScroll: (notification) => - updatePillFade(notification, fade), + headerGap: isPortrait + ? statusTop + kChatSplitBarBand + : statusTop + kPillAppBarHeight, + onScroll: isPortrait + ? null + : (notification) => updatePillFade(notification, fade), ), ), ], diff --git a/lib/misc/dashboard/dash.dart b/lib/misc/dashboard/dash.dart index a7bfed367..0776cbe0b 100644 --- a/lib/misc/dashboard/dash.dart +++ b/lib/misc/dashboard/dash.dart @@ -2,8 +2,8 @@ import 'dart:async'; import 'package:auto_route/auto_route.dart'; import 'package:easy_localization/easy_localization.dart'; -import 'package:island/discovery/widgets/pill_app_bar.dart'; import 'package:island/shared/widgets/page_app_bar.dart'; +import 'package:island/shared/widgets/pill_surface.dart'; import 'package:material_ui/material_ui.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_staggered_grid_view/flutter_staggered_grid_view.dart'; @@ -68,33 +68,23 @@ class DashboardScreen extends HookConsumerWidget { ); if (!dashboardEnabled) return const _DisabledDashboardRedirect(); - final fade = useValueNotifier(0.0); + final isPortrait = + MediaQuery.orientationOf(context) == Orientation.portrait; return AppScaffold( - appBarOverContent: true, isNoBackground: false, - appBar: PillAppBar( - fade: fade, - statusTop: MediaQuery.paddingOf(context).top, - child: Row( - children: [ - const SolDrawerButton(), - const Gap(SolSpace.sm), - Expanded( - child: Align( - alignment: Alignment.centerLeft, - child: Text( - 'Solar Network', - style: Theme.of(context).appBarTheme.titleTextStyle, - ), - ), + body: Stack( + children: [ + Center(child: DashboardGrid()), + // The dashboard no longer carries a header pill, so portrait keeps a + // single glass hamburger at the top-left as its navigation entry. + if (isPortrait) + Positioned( + left: kPillSideGutter, + top: MediaQuery.paddingOf(context).top + kPillSideGutter, + child: const GlassNavigationButton(), ), - ], - ), - ), - body: NotificationListener( - onNotification: (notification) => updatePillFade(notification, fade), - child: Center(child: DashboardGrid()), + ], ), ); } diff --git a/lib/shared/widgets/page_app_bar.dart b/lib/shared/widgets/page_app_bar.dart index 7cbde56b1..7a8f3e9e8 100644 --- a/lib/shared/widgets/page_app_bar.dart +++ b/lib/shared/widgets/page_app_bar.dart @@ -6,6 +6,7 @@ import 'package:material_symbols_icons/symbols.dart'; import 'package:island/core/navigation/navigation_rail_scope.dart'; import 'package:island/core/theme.dart'; import 'package:island/shared/widgets/app_scaffold.dart'; +import 'package:island/shared/widgets/pill_surface.dart'; /// The navigation toggle shared by the root tabs. /// @@ -55,6 +56,31 @@ class SolDrawerButton extends StatelessWidget { } } +/// A single glass-circle navigation toggle. +/// +/// Pages without a header pill (the dashboard) and the portrait chat header use +/// it as a standalone control: the same [SolDrawerButton] behaviour wrapped in +/// the app's liquid-glass surface. +class GlassNavigationButton extends StatelessWidget { + final Color? color; + + const GlassNavigationButton({super.key, this.color}); + + @override + Widget build(BuildContext context) { + return PillSurface( + t: 1, + margin: EdgeInsets.zero, + radius: SolRadius.pillRadius, + contentPadding: EdgeInsets.zero, + child: Material( + color: Colors.transparent, + child: SolDrawerButton(color: color), + ), + ); + } +} + /// App bar used by the root tabs. /// /// The title is left aligned, and the leading slot always shows something the diff --git a/test/chat/chat_list_pill_test.dart b/test/chat/chat_list_pill_test.dart index 68d785eed..f5d8361e2 100644 --- a/test/chat/chat_list_pill_test.dart +++ b/test/chat/chat_list_pill_test.dart @@ -16,6 +16,8 @@ import 'package:island/core/database.dart'; import 'package:island/core/network.dart'; import 'package:island/data/database.dart'; import 'package:island/discovery/widgets/pill_app_bar.dart'; +import 'package:island/shared/widgets/page_app_bar.dart'; +import 'package:island/shared/widgets/pill_surface.dart'; import 'package:material_ui/material_ui.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:solar_network_sdk/solar_network_sdk.dart'; @@ -174,7 +176,9 @@ void main() { while (tester.takeException() != null) {} } - testWidgets('narrow: the rooms render under a glass pill', (tester) async { + testWidgets('narrow: the rooms render under the split glass header', ( + tester, + ) async { await pumpList( tester, const Size(400, 800), @@ -184,18 +188,21 @@ void main() { expect(tester.takeException(), isNull); expect(find.byType(ChatRoomListTile), findsNWidgets(2)); expect(find.text('Design crew'), findsOneWidget); - expect(find.byType(PillAppBar), findsOneWidget); + // The portrait header is two separate glass surfaces: navigation on the + // left and the section picker on the right. + expect(find.byType(PillAppBar), findsNothing); + expect(find.byType(GlassNavigationButton), findsOneWidget); + expect(find.byType(PillSurface), findsNWidgets(2)); // The switcher is the explore page's dropdown: one trigger naming the // current filter, and no leftover tab strip. expect(find.byType(TabBar), findsNothing); expect(find.byType(MenuAnchor), findsOneWidget); expect(find.text('All'), findsOneWidget); - // The first room sits exactly under the capsule: the bar's band is reserved - // once, not once by the scaffold's spacer and again by the list. + // The first room sits exactly under the split header's band. expect( tester.getTopLeft(find.byType(ChatRoomListTile).first).dy, - closeTo(kPillAppBarHeight, 1), + closeTo(kChatSplitBarBand, 1), ); await disposeTree(tester); From 773a401850afd6dec8aac43da9f3dd387687ee8e Mon Sep 17 00:00:00 2001 From: BluewhaleYF Date: Sun, 25 Oct 2026 15:32:45 +0800 Subject: [PATCH 5/7] :lipstick: Scroll the chat list title under floating controls --- lib/chat/widgets/chat_list_screen.dart | 252 +++++++++++++++++-------- lib/shared/widgets/page_app_bar.dart | 10 + test/chat/chat_list_pill_test.dart | 26 ++- 3 files changed, 197 insertions(+), 91 deletions(-) diff --git a/lib/chat/widgets/chat_list_screen.dart b/lib/chat/widgets/chat_list_screen.dart index 248277d60..eb02cc007 100644 --- a/lib/chat/widgets/chat_list_screen.dart +++ b/lib/chat/widgets/chat_list_screen.dart @@ -614,11 +614,16 @@ class ChatListBodyWidget extends HookConsumerWidget { /// view can drive the pill's fade off this list's own scroll. final bool Function(ScrollNotification notification)? onScroll; + /// Content pinned to the top of the scrolling list, above the rooms. It + /// scrolls away with them instead of hovering over the list. + final Widget? leadingHeader; + const ChatListBodyWidget({ super.key, this.isFloating = false, this.headerGap = 0, this.onScroll, + this.leadingHeader, required this.tabController, required this.selectedTab, required this.chatGroups, @@ -755,6 +760,8 @@ class ChatListBodyWidget extends HookConsumerWidget { // capsule. if (headerGap > 0) SliverToBoxAdapter(child: SizedBox(height: headerGap)), + if (leadingHeader != null) + SliverToBoxAdapter(child: leadingHeader), // Global notification status indicator if (accountStatus .whenData((data) => data) @@ -1412,9 +1419,13 @@ class _ChatSectionButton extends StatelessWidget { spacing: SolSpace.sm, children: [ Icon(selected.icon, size: 20, color: colorScheme.onSurface), - Text( - selected.label.tr(), - style: theme.appBarTheme.titleTextStyle, + Flexible( + child: Text( + selected.label.tr(), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.appBarTheme.titleTextStyle, + ), ), ], ), @@ -1500,6 +1511,56 @@ const double _kChatSplitControlHeight = 48; const double kChatSplitBarBand = kPillSideGutter + _kChatSplitControlHeight + SolSpace.xs; +/// The section picker and its trailing actions, in their own glass pill. +class _ChatActionPill extends StatelessWidget { + final TabController tabController; + final bool isSyncing; + final Color foreground; + + const _ChatActionPill({ + required this.tabController, + required this.isSyncing, + required this.foreground, + }); + + @override + Widget build(BuildContext context) { + return SizedBox( + height: _kChatSplitControlHeight, + child: PillSurface( + t: 1, + margin: EdgeInsets.zero, + radius: SolRadius.pillRadius, + contentPadding: const EdgeInsets.symmetric(horizontal: SolSpace.xs), + child: Material( + color: Colors.transparent, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Flexible(child: _ChatSectionButton(tabController: tabController)), + if (isSyncing) + Padding( + padding: const EdgeInsets.only(right: SolSpace.sm), + child: SizedBox( + width: 24, + height: 24, + child: ConfuseSpinner( + size: 24, + speed: 7, + fontSize: 12, + color: foreground.withOpacity(0.65), + ), + ), + ), + _MarkAllReadButton(color: foreground), + ], + ), + ), + ), + ); + } +} + /// Portrait chat header, split into two independent glass surfaces. /// /// The navigation toggle sits alone at the top-left, mirroring the dashboard; @@ -1522,53 +1583,70 @@ class _ChatSplitBar extends StatelessWidget implements PreferredSizeWidget { @override Widget build(BuildContext context) { - final top = statusTop + kPillSideGutter; return SizedBox( height: preferredSize.height, + child: Padding( + padding: EdgeInsets.fromLTRB( + kPillSideGutter, + statusTop + kPillSideGutter, + kPillSideGutter, + SolSpace.xs, + ), + child: Row( + children: [ + const GlassNavigationButton(), + const SizedBox(width: SolSpace.xs), + Expanded( + child: Align( + alignment: Alignment.centerRight, + child: _ChatActionPill( + tabController: tabController, + isSyncing: isSyncing, + foreground: foreground, + ), + ), + ), + ], + ), + ), + ); + } +} + +/// Floating controls above the wide chat layout's list pane. +/// +/// The pane title now lives inside the scrolling list; this layer only carries +/// the navigation toggle (where the rail cannot) and the section picker. +class _ChatListFloatingButtons extends StatelessWidget { + final TabController tabController; + final bool isSyncing; + final Color foreground; + + const _ChatListFloatingButtons({ + required this.tabController, + required this.isSyncing, + required this.foreground, + }); + + @override + Widget build(BuildContext context) { + return SizedBox( + height: kChatSplitBarBand, child: Stack( children: [ - Positioned( - left: kPillSideGutter, - top: top, - child: const GlassNavigationButton(), - ), + if (SolDrawerButton.isShownIn(context)) + const Positioned( + left: kPillSideGutter, + top: kPillSideGutter, + child: GlassNavigationButton(), + ), Positioned( right: kPillSideGutter, - top: top, - child: SizedBox( - height: _kChatSplitControlHeight, - child: PillSurface( - t: 1, - margin: EdgeInsets.zero, - radius: SolRadius.pillRadius, - contentPadding: const EdgeInsets.symmetric( - horizontal: SolSpace.xs, - ), - child: Material( - color: Colors.transparent, - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - _ChatSectionButton(tabController: tabController), - if (isSyncing) - Padding( - padding: const EdgeInsets.only(right: SolSpace.sm), - child: SizedBox( - width: 24, - height: 24, - child: ConfuseSpinner( - size: 24, - speed: 7, - fontSize: 12, - color: foreground.withOpacity(0.65), - ), - ), - ), - _MarkAllReadButton(color: foreground), - ], - ), - ), - ), + top: kPillSideGutter, + child: _ChatActionPill( + tabController: tabController, + isSyncing: isSyncing, + foreground: foreground, ), ), ], @@ -1577,6 +1655,44 @@ class _ChatSplitBar extends StatelessWidget implements PreferredSizeWidget { } } +/// The list pane's own title, scrolled with the rooms instead of floating. +/// +/// Its row uses the same vertical band as the floating controls, so the title +/// and the section picker sit at one height at rest. The title still belongs to +/// the scrollable list, so it slides away with the rooms. +class _ChatListHeading extends StatelessWidget { + const _ChatListHeading(); + + @override + Widget build(BuildContext context) { + // Where the navigation toggle floats on the left (portrait wide layouts), + // the title starts after it instead of running underneath. + final leftInset = SolDrawerButton.isShownIn(context) + ? kPillSideGutter + _kChatSplitControlHeight + SolSpace.xs + : SolSpace.xl; + return SizedBox( + height: kChatSplitBarBand, + child: Padding( + padding: EdgeInsets.fromLTRB( + leftInset, + kPillSideGutter, + SolSpace.xl, + SolSpace.xs, + ), + child: Align( + alignment: Alignment.centerLeft, + child: Text( + 'chat'.tr(), + style: Theme.of( + context, + ).appBarTheme.titleTextStyle?.copyWith(fontSize: 18), + ), + ), + ), + ); + } +} + class ChatListWidget extends HookConsumerWidget { final bool isAside; const ChatListWidget({super.key, this.isAside = false}); @@ -1670,15 +1786,14 @@ class ChatListWidget extends HookConsumerWidget { const maxSidebarWidth = 520.0; if (asideLayout) { - // No outer card: the pane floats on the page background, so only the pill - // header carries a surface. + // No outer card: the pane floats on the page background, and only the + // floating pills carry a surface. return SizedBox( width: sidebarWidth.value, child: Stack( children: [ - // The room list runs the full height of the pane and the bar floats - // its glass over the top of it, instead of a solid row eating into - // the list. + // The room list runs the full height of the pane; the pane title + // scrolls with it while the two controls float above. ChatListBodyWidget( isFloating: false, tabController: tabController, @@ -1686,44 +1801,17 @@ class ChatListWidget extends HookConsumerWidget { chatGroups: chatGroups, onChatGroupsChanged: refreshChatGroups, accountId: accountId, - headerGap: isPortrait ? kChatSplitBarBand : kPillAppBarHeight, - onScroll: isPortrait - ? null - : (notification) => updatePillFade(notification, fade), + leadingHeader: const _ChatListHeading(), ), Positioned( top: 0, left: 0, right: 0, - child: isPortrait - ? _ChatSplitBar( - statusTop: 0, - tabController: tabController, - isSyncing: false, - foreground: Theme.of(context).colorScheme.onSurface, - ) - : PillAppBar( - fade: fade, - statusTop: 0, - child: Row( - children: [ - // Same toggle the narrow page's pill carries, so the - // navigation is reachable from the chat pane too — it - // expands the rail on the wide layout. - const SolDrawerButton(), - const Gap(SolSpace.sm), - Expanded( - child: Align( - alignment: Alignment.centerLeft, - child: _ChatSectionButton( - tabController: tabController, - ), - ), - ), - const _MarkAllReadButton(), - ], - ), - ), + child: _ChatListFloatingButtons( + tabController: tabController, + isSyncing: false, + foreground: Theme.of(context).colorScheme.onSurface, + ), ), Positioned( bottom: 0, diff --git a/lib/shared/widgets/page_app_bar.dart b/lib/shared/widgets/page_app_bar.dart index 7a8f3e9e8..725aa86d7 100644 --- a/lib/shared/widgets/page_app_bar.dart +++ b/lib/shared/widgets/page_app_bar.dart @@ -25,6 +25,16 @@ class SolDrawerButton extends StatelessWidget { const SolDrawerButton({super.key, this.color, this.pinnedToRail = false}); + /// Whether a page-level (non-rail) copy of this button would be visible. + /// + /// In landscape the rail owns the toggle, so page headers drop theirs. + static bool isShownIn(BuildContext context) { + final rail = NavigationRailScope.maybeOf(context); + return rail == null || + !rail.available || + MediaQuery.orientationOf(context) == Orientation.portrait; + } + @override Widget build(BuildContext context) { // An explicit [color] wins in both layouts: callers pass it when the diff --git a/test/chat/chat_list_pill_test.dart b/test/chat/chat_list_pill_test.dart index f5d8361e2..ccd866bba 100644 --- a/test/chat/chat_list_pill_test.dart +++ b/test/chat/chat_list_pill_test.dart @@ -272,7 +272,7 @@ void main() { await disposeTree(tester); }); - testWidgets('wide: the sidebar is a pill over the list, with no outer card', ( + testWidgets('wide: the title scrolls while the controls float', ( tester, ) async { await pumpList( @@ -283,22 +283,30 @@ void main() { expect(tester.takeException(), isNull); expect(find.byType(ChatRoomListTile), findsNWidgets(2)); - expect(find.byType(PillAppBar), findsOneWidget); + // The pane title now scrolls with the rooms; the section picker still + // floats above the list. + expect(find.byType(PillAppBar), findsNothing); + final heading = find.text('Chat'); + expect(heading, findsOneWidget); + expect(find.byType(PillSurface), findsNWidgets(2)); + expect(find.byType(BackdropFilter), findsNothing); // The three-way switcher is the explore page's dropdown, not a tab strip. expect(find.byType(TabBar), findsNothing); expect(find.byType(MenuAnchor), findsOneWidget); - // The sidebar pill is floated in a Stack rather than placed in a scaffold - // slot, so it has to measure the same band as every other pill and the list - // has to start under it. - expect(tester.getSize(find.byType(PillAppBar)).height, kPillAppBarHeight); + // The floating controls own the same band as the list's title row: the + // title sits at the picker's height but still scrolls with the rooms. + expect( + tester.getCenter(heading).dy, + closeTo(tester.getCenter(find.byType(MenuAnchor)).dy, 1), + ); expect( tester.getTopLeft(find.byType(ChatRoomListTile).first).dy, - closeTo(kPillAppBarHeight, 1), + closeTo(kChatSplitBarBand, 1), ); - // The pane no longer paints a card surface of its own: only the pill header - // carries a surface, and the list floats on the page background. + // The pane no longer paints a card surface of its own: only the header + // pills carry a surface, and the list floats on the page background. expect( find.descendant( of: find.byType(ChatListWidget), From 687603b56e7314077e973dc70df7bc6961ef5e72 Mon Sep 17 00:00:00 2001 From: BluewhaleYF Date: Sun, 25 Oct 2026 15:40:18 +0800 Subject: [PATCH 6/7] :bug: Avoid provider reads in chat subscription lifecycles --- lib/chat/pods/chat_subscribe.dart | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/lib/chat/pods/chat_subscribe.dart b/lib/chat/pods/chat_subscribe.dart index e9e399a44..3e2376518 100644 --- a/lib/chat/pods/chat_subscribe.dart +++ b/lib/chat/pods/chat_subscribe.dart @@ -315,9 +315,11 @@ class ChatSubscribeNotifier extends _$ChatSubscribeNotifier { _chatRoom = chatRoomAsync.value!; _chatIdentity = chatIdentityAsync.value!; - // Subscribe to messages - final wsState = ref.read(websocketStateProvider.notifier); - _sendMessage = wsState.sendMessage; + // Subscribe to messages. Capture the service's own send function, not the + // notifier method: `onCancel`/`onResume` run inside Riverpod life-cycles + // where any `ref.read` is forbidden, and `WebSocketStateNotifier.sendMessage` + // reads `websocketProvider` from its ref on every call. + _sendMessage = ref.read(websocketProvider).sendMessage; _sendSubscribe(reason: 'initial'); // Send initial read receipt From 4934af1b91fadf047ea23968ae2843d9238c138b Mon Sep 17 00:00:00 2001 From: BluewhaleYF Date: Sun, 25 Oct 2026 16:28:17 +0800 Subject: [PATCH 7/7] :bug: Match the portrait chat header control heights --- lib/chat/widgets/chat_list_screen.dart | 6 +++--- lib/shared/widgets/page_app_bar.dart | 15 ++++++++++++--- test/chat/chat_list_pill_test.dart | 8 +++++++- 3 files changed, 22 insertions(+), 7 deletions(-) diff --git a/lib/chat/widgets/chat_list_screen.dart b/lib/chat/widgets/chat_list_screen.dart index eb02cc007..6296f6edc 100644 --- a/lib/chat/widgets/chat_list_screen.dart +++ b/lib/chat/widgets/chat_list_screen.dart @@ -1500,9 +1500,9 @@ class _MarkAllReadButton extends ConsumerWidget { } } -/// Height of each half of the portrait chat header. Matches the dashboard's -/// standalone glass navigation control. -const double _kChatSplitControlHeight = 48; +/// Height of each half of the portrait chat header. Shares the standalone +/// glass navigation control's diameter so the two pills line up. +const double _kChatSplitControlHeight = kGlassNavigationButtonSize; /// Vertical band the split portrait chat header occupies below the status bar. /// diff --git a/lib/shared/widgets/page_app_bar.dart b/lib/shared/widgets/page_app_bar.dart index 725aa86d7..bb1e6c908 100644 --- a/lib/shared/widgets/page_app_bar.dart +++ b/lib/shared/widgets/page_app_bar.dart @@ -66,6 +66,12 @@ class SolDrawerButton extends StatelessWidget { } } +/// Diameter of the standalone glass navigation control. +/// +/// The control is pinned to the height the split chat headers use, so the +/// hamburger and the piped section picker read as one row. +const double kGlassNavigationButtonSize = 48; + /// A single glass-circle navigation toggle. /// /// Pages without a header pill (the dashboard) and the portrait chat header use @@ -83,9 +89,12 @@ class GlassNavigationButton extends StatelessWidget { margin: EdgeInsets.zero, radius: SolRadius.pillRadius, contentPadding: EdgeInsets.zero, - child: Material( - color: Colors.transparent, - child: SolDrawerButton(color: color), + child: SizedBox.square( + dimension: kGlassNavigationButtonSize, + child: Material( + color: Colors.transparent, + child: SolDrawerButton(color: color), + ), ), ); } diff --git a/test/chat/chat_list_pill_test.dart b/test/chat/chat_list_pill_test.dart index ccd866bba..8465ea2c0 100644 --- a/test/chat/chat_list_pill_test.dart +++ b/test/chat/chat_list_pill_test.dart @@ -192,7 +192,13 @@ void main() { // left and the section picker on the right. expect(find.byType(PillAppBar), findsNothing); expect(find.byType(GlassNavigationButton), findsOneWidget); - expect(find.byType(PillSurface), findsNWidgets(2)); + final pills = find.byType(PillSurface); + expect(pills, findsNWidgets(2)); + expect( + tester.getSize(pills.at(0)).height, + tester.getSize(pills.at(1)).height, + reason: 'both portrait header controls share one row height', + ); // The switcher is the explore page's dropdown: one trigger naming the // current filter, and no leftover tab strip. expect(find.byType(TabBar), findsNothing);