From fe269ed7df11b4c4c7c1f1106f8f66806a48365c Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 15 Jul 2026 22:34:23 +0000 Subject: [PATCH 1/4] fix(permissions): word member-permissions label for courses When the participant permissions action is opened from a course (a Space), the popup row label and the permission-chooser dialog title now read "Course permissions" instead of "Chat permissions". Mirrors the existing isSpace branching already used for ban/unban in the same menu. Adds a coursePermissions l10n key and threads an isCourse flag into showPermissionChooser (defaulting false, so other callers are unchanged). Closes #7707 Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01MSQX5Y55ycHjcZkaJhc15W --- lib/l10n/intl_en.arb | 2 ++ lib/widgets/permission_slider_dialog.dart | 14 +++++++++++++- .../users/member_actions_popup_menu_button.dart | 12 +++++++++++- 3 files changed, 26 insertions(+), 2 deletions(-) diff --git a/lib/l10n/intl_en.arb b/lib/l10n/intl_en.arb index af6333e9db..efaacd962e 100644 --- a/lib/l10n/intl_en.arb +++ b/lib/l10n/intl_en.arb @@ -833,6 +833,8 @@ }, "chatPermissions": "Chat permissions", "@chatPermissions": {}, + "coursePermissions": "Course permissions", + "@coursePermissions": {}, "editDisplayname": "Edit displayname", "@editDisplayname": { "type": "String", diff --git a/lib/widgets/permission_slider_dialog.dart b/lib/widgets/permission_slider_dialog.dart index c39b3232fa..52e76fe4f4 100644 --- a/lib/widgets/permission_slider_dialog.dart +++ b/lib/widgets/permission_slider_dialog.dart @@ -8,13 +8,25 @@ Future showPermissionChooser( BuildContext context, { int currentLevel = 0, int maxLevel = 100, + // #Pangea + bool isCourse = false, + // Pangea# }) async { final controller = TextEditingController(); final error = ValueNotifier(null); return await showAdaptiveDialog( context: context, builder: (context) => AlertDialog.adaptive( - title: Center(child: Text(L10n.of(context).chatPermissions)), + title: Center( + child: Text( + // #Pangea + // L10n.of(context).chatPermissions, + isCourse + ? L10n.of(context).coursePermissions + : L10n.of(context).chatPermissions, + // Pangea# + ), + ), content: ConstrainedBox( constraints: const BoxConstraints(maxWidth: 256, maxHeight: 256), child: Column( diff --git a/lib/widgets/users/member_actions_popup_menu_button.dart b/lib/widgets/users/member_actions_popup_menu_button.dart index f931605334..3cd712eec8 100644 --- a/lib/widgets/users/member_actions_popup_menu_button.dart +++ b/lib/widgets/users/member_actions_popup_menu_button.dart @@ -199,7 +199,14 @@ void showMemberActionsPopupMenu({ mainAxisSize: .min, crossAxisAlignment: .start, children: [ - Text(L10n.of(context).chatPermissions), + // #Pangea + // Text(L10n.of(context).chatPermissions), + Text( + user.room.isSpace + ? L10n.of(context).coursePermissions + : L10n.of(context).chatPermissions, + ), + // Pangea# Text( user.powerLevel < 50 ? L10n.of(context).userLevel(user.powerLevel) @@ -308,6 +315,9 @@ void showMemberActionsPopupMenu({ context, currentLevel: user.powerLevel, maxLevel: user.room.ownPowerLevel, + // #Pangea + isCourse: user.room.isSpace, + // Pangea# ); if (power == null) return; if (!context.mounted) return; From 40e35dc7146a7bdcdf08e3428ee1bf4e4a635506 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 15 Jul 2026 22:34:31 +0000 Subject: [PATCH 2/4] fix(world): make the single-column map search clear button work On narrow screens the activity-map search clear (X) button did nothing: the bar is built by the shell but its onQueryChanged reaches only the map's State via a GlobalKey, so clearing the query rebuilds the map subtree but never the shell-built bar. Its didUpdateWidget never fires, so the field's controller and the X-button visibility (both keyed off the stale external query) never update. Drive the bar off its own TextEditingController instead: clear it locally on press, and compute the "searching" state from the controller's text so the button tracks the field as the user types, backspaces and clears. Closes #7685 Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01MSQX5Y55ycHjcZkaJhc15W --- lib/routes/world/mobile_search_bar.dart | 23 ++++++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/lib/routes/world/mobile_search_bar.dart b/lib/routes/world/mobile_search_bar.dart index 79aa240a41..f5921b90cc 100644 --- a/lib/routes/world/mobile_search_bar.dart +++ b/lib/routes/world/mobile_search_bar.dart @@ -92,7 +92,12 @@ class _MobileSearchBarState extends State { ); } - final searching = widget.query.trim().isNotEmpty; + // Drive the clear (X) button off the field's own controller, not the + // externally-owned query. This single-column bar is built by the shell, but + // its onQueryChanged reaches only the map's State (through a GlobalKey), so + // a clear — or any programmatic query change — never rebuilds this bar with + // a fresh widget.query. Reading the controller keeps it in sync. See #7685. + final searching = _controller.text.trim().isNotEmpty; return Semantics( label: widget.hintText, container: true, @@ -110,7 +115,12 @@ class _MobileSearchBarState extends State { color: theme.colorScheme.surface, child: TextField( controller: _controller, - onChanged: widget.onQueryChanged, + onChanged: (value) { + widget.onQueryChanged(value); + // Rebuild so [searching] tracks the field as the user types and + // backspaces — the shell doesn't rebuild this bar per keystroke. + setState(() {}); + }, decoration: InputDecoration( isDense: true, filled: true, @@ -121,7 +131,14 @@ class _MobileSearchBarState extends State { ? IconButton( icon: const Icon(Icons.close), tooltip: l10n.clearSearch, - onPressed: () => widget.onQueryChanged(''), + onPressed: () { + // Clear the field locally too: onQueryChanged only + // reaches the map's State, which won't rebuild this + // shell-built bar to sync the emptied query back in. + _controller.clear(); + widget.onQueryChanged(''); + setState(() {}); + }, ) : null, border: OutlineInputBorder( From 56f869975b3eb23f0c0998f743ab9bb96389c40c Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 15 Jul 2026 22:53:41 +0000 Subject: [PATCH 3/4] fix(courses): drop plan-less quest-plans from the creation picker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A quest-plan whose learning_objective_sequence is empty has no content to build a course from — it surfaced as a selectable "0 modules" card that the learner can't actually turn into a course. Drop these rows in the quest-plans -> CoursePlanModel adapter, using the same null-to-filter contract as the existing missing-field guards, so they never reach the creation picker (or the repo's other consumers: find-course, the course-plan provider, onboarding). Closes #7700 Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01MSQX5Y55ycHjcZkaJhc15W --- lib/features/quests/repo/quest_plans_repo.dart | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/lib/features/quests/repo/quest_plans_repo.dart b/lib/features/quests/repo/quest_plans_repo.dart index 7f8ade5018..f863d887b4 100644 --- a/lib/features/quests/repo/quest_plans_repo.dart +++ b/lib/features/quests/repo/quest_plans_repo.dart @@ -125,6 +125,12 @@ class QuestPlansRepo { final sequence = res['learning_objective_sequence'] as List?; final missionCount = sequence?.length ?? 0; + // A quest-plan with no missions has no content to build a course from — it + // would show as a "0 modules" card the learner can't actually create. Drop + // it here (the same null-to-filter contract as the missing-field guards + // above) so it never reaches the creation picker or the repo's other + // consumers (find-course, course-plan provider, onboarding). #7700 + if (missionCount == 0) return null; // Placeholder strings carry the *count* so the "N modules" chip reads // correctly. They are never resolved against the v1 ``course-plan-topics`` // collection — no v3 surface walks ``topicIds`` on a synthesized model. From 90b7b770efc2bcfeba868e7a4cc280ecaee479cb Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 15 Jul 2026 23:15:10 +0000 Subject: [PATCH 4/4] feat(courses): content-fit hub, header add-actions, hide search over full course MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Courses panel content (single- and two-column): - With at least one joined course, the three add-course actions (start my own / enter a code / browse public) move to the panel header as compact right-justified icons, so the joined-course list keeps the vertical space; with no courses they stay as full-width buttons in the body (the empty state). Both hub render-sites are centralized into one CoursesHubPanel. Courses sheet default height (narrow): - The Courses hub opens content-fit — tall enough to show all joined courses (or the add buttons when there are none), capped at the available height — instead of defaulting to half. Closes #7692. Nav widget / map search bar (narrow): - Once a course sheet is pulled to full it covers the map, so the floating map search bar hides entirely and its reserved strip is handed to the course content; the bar returns when the sheet drops below full. The nav widget reports a latched full-height state up to the shell, stable across drags so the reservation (and the drag coordinate space) doesn't thrash. Closes #7697. Updates routing.instructions.md for the content-fit hub, the header actions, and the full-course search-bar hide. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01MSQX5Y55ycHjcZkaJhc15W --- .github/instructions/routing.instructions.md | 30 ++-- lib/routes/courses/add_course_options.dart | 86 ++++++---- .../left_panel_add_course_subpage.dart | 9 +- .../left_panel_courses_list_view.dart | 151 +++++++++++------- .../left_panel/workspace_left_panel.dart | 7 +- lib/widgets/layouts/mobile_nav_widget.dart | 42 ++++- lib/widgets/layouts/workspace_shell.dart | 42 ++++- .../navigation/mobile_nav_widget_test.dart | 52 ++++++ 8 files changed, 306 insertions(+), 113 deletions(-) diff --git a/.github/instructions/routing.instructions.md b/.github/instructions/routing.instructions.md index 5eb87e133a..80c639c906 100644 --- a/.github/instructions/routing.instructions.md +++ b/.github/instructions/routing.instructions.md @@ -449,12 +449,19 @@ of the URL; the other joined courses are reached through the Courses list. web rail — it replaces the open left panels with that section's token (`left=chats`, the Courses hub, the course card under `?c=`) — and expands the widget upward, filling the upper portion of the rounded box with that -section's content. The **chats sheet opens content-fit**: just tall enough to -show all its chats, capped by the height available below the analytics bar (a -short list yields a short sheet; a long one fills to the cap and scrolls). -Other sections open at roughly half the screen. The 4 rail icons remain -anchored at the bottom of the widget at all heights. Content inside the -expanded area is **scrollable**. +section's content. The **chats sheet and the Courses hub open content-fit**: +just tall enough to show all their rows — all chats, or all joined courses (the +add-course buttons when there are none) — capped by the height available below +the analytics bar (a short list yields a short sheet; a long one fills to the +cap and scrolls). Other sections open at roughly half the screen. The 4 rail +icons remain anchored at the bottom of the widget at all heights. Content inside +the expanded area is **scrollable**. + +**The Courses hub header carries the add-course actions.** With at least one +joined course, the three add-course actions (start my own / enter a code / +browse public) ride the panel header as compact right-justified icons, so the +joined-course list keeps the vertical space; when the learner is in no courses +yet they drop to full-width buttons in the body as the empty state. **The chats sheet header carries its actions**: an expanding **search toggle** (an icon; tapping it reveals the filter field, autofocused — the @@ -465,8 +472,10 @@ covered the bottom list rows). **The URL carries the panel, not the geometry.** The widget's height — collapsed, half, full — is ephemeral view state, exactly like fold recency above: a cold link or a refresh with an open **section** token (the chat list, the Courses -hub) or an **activity plan** draws it expanded at half height (the leaf rule); -a **course card** draws at its remembered height — the collapsed peek by +hub) or an **activity plan** draws it expanded at its default rest height (the +leaf rule) — content-fit for the list sections (the chat list, the Courses +hub), roughly half otherwise; a **course card** draws at its remembered height — +the collapsed peek by default (see the per-course memory above), so the scoped map leads. The collapsed rail over the bare map is just `/`. A shared URL never encodes how far someone had dragged a sheet. @@ -612,7 +621,10 @@ section roots while the map is not being actively scrolled. the nav widget collapsed, or while the workspace is course-scoped (`?c=` set), the search bar and its active filters **minimize to a compact search icon button** pinned to the left side just above the nav rail. Tapping it restores -the full bar. +the full bar. **Once the course card itself is pulled to full height it covers +the map, so the bar hides entirely** — its reserved strip is handed to the +course content, and the compact button reappears when the sheet is dragged back +below full (#7697). [Minimized component](https://www.figma.com/design/n2qX4WsnVhYqT2KV6pMVbl/Everything-outside-of-Chat?node-id=13126-44562&t=NJSsG23tsR9Kdwlz-0) **Keyboard behavior.** When the search bar is active and the software keyboard diff --git a/lib/routes/courses/add_course_options.dart b/lib/routes/courses/add_course_options.dart index b4bb5b4461..312d01895d 100644 --- a/lib/routes/courses/add_course_options.dart +++ b/lib/routes/courses/add_course_options.dart @@ -6,46 +6,74 @@ import 'package:fluffychat/features/navigation/token_params/add_course_token.dar import 'package:fluffychat/features/navigation/workspace_nav.dart'; import 'package:fluffychat/l10n/l10n.dart'; +/// The three add-course steps, in display order, each with its icon. One list +/// keeps the two presentations in lockstep: the full-width buttons +/// ([AddCourseOptions], the Courses panel's empty state) and the compact header +/// icons ([AddCourseHeaderActions], shown once the learner is in a course). +const List<({IconData icon, AddCourseSubpageEnum step})> _addCourseActions = [ + (icon: Icons.auto_stories_outlined, step: AddCourseSubpageEnum.own), + (icon: Icons.vpn_key_outlined, step: AddCourseSubpageEnum.private), + (icon: Icons.travel_explore_outlined, step: AddCourseSubpageEnum.browse), +]; + +String _addCourseLabel(L10n l10n, AddCourseSubpageEnum step) => switch (step) { + AddCourseSubpageEnum.own => l10n.addCourseStartMyOwn, + AddCourseSubpageEnum.private => l10n.addCourseEnterCode, + AddCourseSubpageEnum.browse => l10n.addCourseBrowsePublic, +}; + +/// Each option is a token-native `setSection` to the `addcourse` left panel at +/// its step (the plan list defaults to the user's target language; no showAll). +void _goToStep(BuildContext context, AddCourseSubpageEnum step) => context.go( + WorkspaceNav.openAddCoursePage(GoRouterState.of(context).uri, step), +); + /// The three add-course options — Start my own / Enter code for a private -/// course / Browse public courses — as a standalone, chromeless block. Lives at -/// the bottom of the Courses panel (below the joined-course list) and is also -/// the body of the legacy add-course hub. Each option is a token-native -/// `setSection` to the `addcourse` left panel at its step, replacing the open -/// left panels (a focused flow, no chat floating over it). See +/// course / Browse public courses — as full-width tonal buttons. This is the +/// **empty state** of the Courses panel (shown when the learner is in no +/// courses yet); once they have a course the same three actions ride the panel +/// header as compact icons ([AddCourseHeaderActions]). See /// routing.instructions.md. class AddCourseOptions extends StatelessWidget { const AddCourseOptions({super.key}); - void _goToStep(BuildContext context, AddCourseSubpageEnum step) => context.go( - WorkspaceNav.openAddCoursePage(GoRouterState.of(context).uri, step), - ); - @override Widget build(BuildContext context) { final l10n = L10n.of(context); return Column( mainAxisSize: MainAxisSize.min, children: [ - _HubButton( - icon: Icons.auto_stories_outlined, - label: l10n.addCourseStartMyOwn, - // No showAll: the plan list defaults to the user's target language - // (the filter still lets them widen to all). showAll=true here would - // suppress that default and list every language (#7081). - onTap: () => _goToStep(context, AddCourseSubpageEnum.own), - ), - const SizedBox(height: 8.0), - _HubButton( - icon: Icons.vpn_key_outlined, - label: l10n.addCourseEnterCode, - onTap: () => _goToStep(context, AddCourseSubpageEnum.private), - ), - const SizedBox(height: 8.0), - _HubButton( - icon: Icons.travel_explore_outlined, - label: l10n.addCourseBrowsePublic, - onTap: () => _goToStep(context, AddCourseSubpageEnum.browse), - ), + for (final (index, action) in _addCourseActions.indexed) ...[ + if (index > 0) const SizedBox(height: 8.0), + _HubButton( + icon: action.icon, + label: _addCourseLabel(l10n, action.step), + onTap: () => _goToStep(context, action.step), + ), + ], + ], + ); + } +} + +/// The three add-course actions as compact icon-buttons, for the Courses panel +/// header once the learner has at least one course — so the joined-course list +/// keeps the vertical space three full-width buttons would otherwise take. +class AddCourseHeaderActions extends StatelessWidget { + const AddCourseHeaderActions({super.key}); + + @override + Widget build(BuildContext context) { + final l10n = L10n.of(context); + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + for (final action in _addCourseActions) + IconButton( + tooltip: _addCourseLabel(l10n, action.step), + icon: Icon(action.icon), + onPressed: () => _goToStep(context, action.step), + ), ], ); } diff --git a/lib/routes/world/left_panel/left_panel_add_course_subpage.dart b/lib/routes/world/left_panel/left_panel_add_course_subpage.dart index efaea09b78..73c85766b0 100644 --- a/lib/routes/world/left_panel/left_panel_add_course_subpage.dart +++ b/lib/routes/world/left_panel/left_panel_add_course_subpage.dart @@ -4,14 +4,12 @@ import 'package:flutter/material.dart'; import 'package:fluffychat/features/course_plans/new_course_page.dart'; import 'package:fluffychat/features/navigation/token_params/add_course_token.dart'; -import 'package:fluffychat/l10n/l10n.dart'; import 'package:fluffychat/routes/courses/find_course_page.dart'; import 'package:fluffychat/routes/courses/own/invite/course_invite_page.dart'; import 'package:fluffychat/routes/courses/own/selected_course_page.dart'; import 'package:fluffychat/routes/courses/preview/public_course_preview.dart'; import 'package:fluffychat/routes/courses/private/course_code_page.dart'; import 'package:fluffychat/routes/world/left_panel/left_panel_courses_list_view.dart'; -import 'package:fluffychat/routes/world/panel_header.dart'; /// The body of the left-column **add-course panel** (world_v2): the add-course /// wizard, hosted as a URL-token panel instead of the retired route-driven @@ -40,12 +38,7 @@ class LeftPanelAddCourseSubpage extends StatelessWidget { Widget build(BuildContext context) { final param = this.param; if (param == null) { - return Column( - children: [ - PanelHeader(leading: closeButton, title: L10n.of(context).courses), - Expanded(child: LeftPanelCoursesListView()), - ], - ); + return CoursesHubPanel(closeButton: closeButton); } switch (param.subpage) { diff --git a/lib/routes/world/left_panel/left_panel_courses_list_view.dart b/lib/routes/world/left_panel/left_panel_courses_list_view.dart index 91d4fc1c92..57e479acb1 100644 --- a/lib/routes/world/left_panel/left_panel_courses_list_view.dart +++ b/lib/routes/world/left_panel/left_panel_courses_list_view.dart @@ -6,85 +6,118 @@ import 'package:fluffychat/features/course_plans/courses/course_plan_room_extens import 'package:fluffychat/l10n/l10n.dart'; import 'package:fluffychat/routes/courses/add_course_options.dart'; import 'package:fluffychat/routes/courses/course_list_tile.dart'; +import 'package:fluffychat/routes/world/panel_header.dart'; import 'package:fluffychat/utils/matrix_sdk_extensions/matrix_locals.dart'; import 'package:fluffychat/utils/stream_extension.dart'; import 'package:fluffychat/widgets/matrix.dart'; -/// The body of the **Courses** left-column panel (world_v2): the courses the -/// user is in, listed as tiles, followed by the add-course options (start my -/// own / enter a code / browse public). Replaces the old float-over-the-map -/// "Add new course" hub card, which double-wrapped a card inside the panel's own -/// PanelCard. The panel host ([WorkspaceLeftPanel]) supplies the surrounding -/// card chrome and the "Courses" header + close control, so this is just the -/// scrollable content. See routing.instructions.md. -class LeftPanelCoursesListView extends StatelessWidget { - const LeftPanelCoursesListView({super.key}); +/// The courses the learner is in — spaces they've joined that carry a course +/// plan — sorted by localized display name. Shared so the panel body and the +/// shell's content-fit height estimate count exactly the same rooms. +List joinedCourses(Client client, L10n l10n) => + client.rooms + .where( + (r) => + r.isSpace && r.membership == Membership.join && r.coursePlan != null, + ) + .toList() + ..sort( + (a, b) => a + .getLocalizedDisplayname(MatrixLocals(l10n)) + .toLowerCase() + .compareTo( + b.getLocalizedDisplayname(MatrixLocals(l10n)).toLowerCase(), + ), + ); + +/// The **Courses** left-column panel (world_v2): the "Courses" header plus the +/// scrollable list of joined courses. +/// +/// The three add-course actions (start my own / enter a code / browse public) +/// live in the header as compact right-justified icons once the learner has at +/// least one course — so the list gets the vertical space — and drop to +/// full-width buttons in the body as the empty state when the learner is in no +/// courses yet. The panel host ([WorkspaceLeftPanel]) supplies the surrounding +/// card chrome (or, on narrow, the nav-widget cavity). See routing.instructions.md. +class CoursesHubPanel extends StatelessWidget { + final Widget closeButton; + + const CoursesHubPanel({super.key, required this.closeButton}); @override Widget build(BuildContext context) { final client = Matrix.of(context).client; final l10n = L10n.of(context); - final theme = Theme.of(context); return StreamBuilder( stream: client.onSync.stream .where((s) => s.hasRoomUpdate) .rateLimit(const Duration(seconds: 1)), builder: (context, _) { - final courses = - client.rooms - .where( - (r) => - r.isSpace && - r.membership == Membership.join && - r.coursePlan != null, - ) - .toList() - ..sort( - (a, b) => a - .getLocalizedDisplayname(MatrixLocals(l10n)) - .toLowerCase() - .compareTo( - b - .getLocalizedDisplayname(MatrixLocals(l10n)) - .toLowerCase(), - ), - ); - - return ListView( - padding: const EdgeInsets.fromLTRB(12.0, 4.0, 12.0, 16.0), + final courses = joinedCourses(client, l10n); + return Column( children: [ - for (final space in courses) CourseListTile(space), - // #7172: a user in no courses isn't a course-without-a-plan, so don't - // show the "this course needs a plan" message here — the "Add new - // course" section below is the empty state and invites them to join - // or create one. - const SizedBox(height: 4.0), - // "Add new course" section divider + the add options. - Row( - children: [ - Expanded( - child: Divider(color: theme.colorScheme.outlineVariant), - ), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 12.0), - child: Text( - l10n.addNewCourse, - style: theme.textTheme.labelLarge?.copyWith( - color: theme.colorScheme.onSurfaceVariant, - ), - ), - ), - Expanded( - child: Divider(color: theme.colorScheme.outlineVariant), - ), - ], + PanelHeader( + leading: closeButton, + title: l10n.courses, + // With courses present, the three add-course actions ride the + // header as right-justified icons; when empty they stay as full + // buttons in the body below (the empty state). + trailing: + courses.isEmpty ? null : const AddCourseHeaderActions(), ), - const SizedBox(height: 12.0), - const AddCourseOptions(), + Expanded(child: LeftPanelCoursesListView(courses: courses)), ], ); }, ); } } + +/// The scrollable body of [CoursesHubPanel]: a tile per joined course, and — +/// only when the learner has none yet — the "Add new course" divider and the +/// full-width add-course buttons as the empty state (#7172: an empty list isn't +/// a plan-less course, so no "needs a plan" message here). +class LeftPanelCoursesListView extends StatelessWidget { + final List courses; + + const LeftPanelCoursesListView({super.key, required this.courses}); + + @override + Widget build(BuildContext context) { + final l10n = L10n.of(context); + final theme = Theme.of(context); + + return ListView( + padding: const EdgeInsets.fromLTRB(12.0, 4.0, 12.0, 16.0), + children: [ + for (final space in courses) CourseListTile(space), + if (courses.isEmpty) ...[ + const SizedBox(height: 4.0), + // "Add new course" section divider + the full add-course buttons. + Row( + children: [ + Expanded( + child: Divider(color: theme.colorScheme.outlineVariant), + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 12.0), + child: Text( + l10n.addNewCourse, + style: theme.textTheme.labelLarge?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ), + Expanded( + child: Divider(color: theme.colorScheme.outlineVariant), + ), + ], + ), + const SizedBox(height: 12.0), + const AddCourseOptions(), + ], + ], + ); + } +} diff --git a/lib/routes/world/left_panel/workspace_left_panel.dart b/lib/routes/world/left_panel/workspace_left_panel.dart index b97d163c9f..b6fcab4a40 100644 --- a/lib/routes/world/left_panel/workspace_left_panel.dart +++ b/lib/routes/world/left_panel/workspace_left_panel.dart @@ -83,12 +83,7 @@ class WorkspaceLeftPanel extends StatelessWidget { shareItems: shareItems, closeButton: closeButton, ), - AddCoursePanelToken() => Column( - children: [ - PanelHeader(leading: closeButton, title: L10n.of(context).courses), - Expanded(child: LeftPanelCoursesListView()), - ], - ), + AddCoursePanelToken() => CoursesHubPanel(closeButton: closeButton), AddCoursePagePanelToken(param: final param) => LeftPanelAddCourseSubpage( param: param, closeButton: closeButton, diff --git a/lib/widgets/layouts/mobile_nav_widget.dart b/lib/widgets/layouts/mobile_nav_widget.dart index fd8eb3d3ab..cdebaa2824 100644 --- a/lib/widgets/layouts/mobile_nav_widget.dart +++ b/lib/widgets/layouts/mobile_nav_widget.dart @@ -98,6 +98,14 @@ class MobileNavWidget extends StatefulWidget { /// design for section sheets and the course card. final VoidCallback? onDismissed; + /// Fires when the hosted cavity settles at (or leaves) its FULL height, so + /// the shell can drop the floating search bar over a full course sheet and + /// hand that reserved strip to the course content (#7697). Latched to the + /// settled rest state — it deliberately does NOT toggle mid-drag, so the + /// reserved height (and thus the drag's coordinate space) stays stable while + /// the finger moves. + final ValueChanged? onCavityFullChanged; + const MobileNavWidget({ required this.activeSection, this.courseShortcutIcon, @@ -114,6 +122,7 @@ class MobileNavWidget extends StatefulWidget { this.preferredCavityHeightPx, this.topAttachment, this.onDismissed, + this.onCavityFullChanged, super.key, }); @@ -161,10 +170,22 @@ class _MobileNavWidgetState extends State { double _fraction = 0.0; double? _dragStartFraction; + /// Whether the cavity is settled at full height, LATCHED across drags: it + /// flips only when the sheet settles at a rest stop ([_openAt]) or the cavity + /// opens / closes / changes key — never on the transient null rest state + /// mid-drag. That keeps the shell's search-bar reservation (#7697) from + /// thrashing (and the box from jumping) while the finger is dragging. + bool _fullLatched = false; + + /// Last value handed to [MobileNavWidget.onCavityFullChanged]; the post-frame + /// notify in [build] fires only on a real change. + bool _reportedFull = false; + @override void initState() { super.initState(); _restState = _restoreHeight(); + _fullLatched = _restState == NavCavityHeight.full; } @override @@ -183,8 +204,11 @@ class _MobileNavWidgetState extends State { _restState = null; _fraction = 0.0; }); + _fullLatched = false; } else if (openedNow || keyChanged) { - setState(() => _restState = _restoreHeight()); + final restored = _restoreHeight(); + setState(() => _restState = restored); + _fullLatched = restored == NavCavityHeight.full; } // A preferredCavityHeightPx change needs no handling: a resting sheet // derives its fraction from the current hint every build. @@ -260,9 +284,21 @@ class _MobileNavWidgetState extends State { void _openAt(NavCavityHeight height) { _remember(height); + _fullLatched = height == NavCavityHeight.full; setState(() => _restState = height); } + /// Notify the shell of a latched full-height change AFTER the frame — calling + /// back synchronously from build would `setState` the shell mid-build. + void _syncFullReport() { + if (_fullLatched == _reportedFull) return; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted || _fullLatched == _reportedFull) return; + _reportedFull = _fullLatched; + widget.onCavityFullChanged?.call(_reportedFull); + }); + } + /// Handle tap: toggles half <-> full (the #7128 pattern) — reachable without /// a drag gesture for keyboard / switch access. void _toggleHandle() { @@ -335,6 +371,7 @@ class _MobileNavWidgetState extends State { onDismissed(); return; } + _fullLatched = false; setState(() { _restState = null; _fraction = 0.0; @@ -392,6 +429,9 @@ class _MobileNavWidgetState extends State { final isExpanded = widget.cavityChild != null && _currentFraction > 0.01; + // Report a settled full-height change up to the shell (post-frame). + _syncFullReport(); + return Stack( children: [ // Tap-outside barrier: only present while expanded, so it never diff --git a/lib/widgets/layouts/workspace_shell.dart b/lib/widgets/layouts/workspace_shell.dart index ce5fe0870d..af3f77e613 100644 --- a/lib/widgets/layouts/workspace_shell.dart +++ b/lib/widgets/layouts/workspace_shell.dart @@ -18,6 +18,7 @@ import 'package:fluffychat/features/navigation/token_params/room_token.dart'; import 'package:fluffychat/features/navigation/workspace_nav.dart'; import 'package:fluffychat/l10n/l10n.dart'; import 'package:fluffychat/pangea/extensions/pangea_room_extension.dart'; +import 'package:fluffychat/routes/world/left_panel/left_panel_courses_list_view.dart'; import 'package:fluffychat/routes/world/left_panel/workspace_left_panel.dart'; import 'package:fluffychat/routes/world/map_context.dart'; import 'package:fluffychat/routes/world/mobile_search_bar.dart'; @@ -394,6 +395,13 @@ class _MobileNavLayerState extends State<_MobileNavLayer> { static const double _chatsSheetHeaderAllowance = 96.0; static const double _chatsSheetRowEstimate = 76.0; + /// Fit-height estimate inputs for the Courses hub sheet: one course tile per + /// joined course, or — when the learner is in no courses yet — the full + /// add-course buttons block (the empty state). Reuses the shared cavity + /// header allowance above. + static const double _coursesSheetRowEstimate = 84.0; + static const double _coursesSheetAddOptionsAllowance = 236.0; + GoRouterState get state => widget.state; _ShellLayout get layout => widget.layout; @@ -403,6 +411,12 @@ class _MobileNavLayerState extends State<_MobileNavLayer> { bool _searchRestored = false; String? _lastScopeId; + /// The nav widget reports when its hosted cavity is pulled to full height + /// (latched to the settled rest state). Used to drop the floating map search + /// bar over a full COURSE sheet and hand its reserved strip to the course + /// content (#7697) — see the searchBar construction below. + bool _cavityAtFull = false; + @override Widget build(BuildContext context) { final l10n = L10n.of(context); @@ -463,7 +477,15 @@ class _MobileNavLayerState extends State<_MobileNavLayer> { // (chats, the hub) re-target it in the follow-up and mount nothing yet. final mapIsGround = cavityToken == null || isCourseCavity || isActivityCavity; - final searchBar = mapIsGround && mapController != null + // Once a COURSE sheet is pulled to full it covers the map, so the map + // search is moot: hide the bar entirely and let its reserved strip (dropped + // from the height reservation below, since searchBar is then null) go to the + // course content (#7697). The bar stays over a peeking/half course, the + // bare/scoped map, and the chats/courses sections (which re-target it), so + // gate strictly on a full course cavity. + final hideSearchForFullCourse = isCourseCavity && _cavityAtFull; + final searchBar = + mapIsGround && mapController != null && !hideSearchForFullCourse ? MobileSearchBar( hintText: l10n.mapSearchHint, query: mapController.filter.query, @@ -507,6 +529,17 @@ class _MobileNavLayerState extends State<_MobileNavLayer> { .length; preferredCavityHeight = _chatsSheetHeaderAllowance + visibleChats * _chatsSheetRowEstimate; + } else if (cavityToken?.type == PanelTypesEnum.addcourse) { + // The Courses hub opens tall enough to show all joined courses (or the + // add-course buttons when there are none), capped by maxHeightFraction — + // no longer defaulting to half (#7692). Same joined-course predicate as + // the hub list, so the estimate counts exactly what renders. + final courseCount = joinedCourses(client, l10n).length; + preferredCavityHeight = + _chatsSheetHeaderAllowance + + (courseCount == 0 + ? _coursesSheetAddOptionsAllowance + : courseCount * _coursesSheetRowEstimate); } String? cavityKey; @@ -612,6 +645,13 @@ class _MobileNavLayerState extends State<_MobileNavLayer> { onDismissed: isActivityCavity && cavityToken != null ? () => context.go(WorkspaceNav.closeLeft(uri, cavityToken)) : null, + // Latched full-height reports drive the course-sheet search-bar hide + // above (#7697). Guarded so an unchanged report is not a rebuild. + onCavityFullChanged: (full) { + if (_cavityAtFull != full) { + setState(() => _cavityAtFull = full); + } + }, maxHeightFraction: maxHeightFraction, preferredCavityHeightPx: preferredCavityHeight, topAttachment: searchBar, diff --git a/test/pangea/navigation/mobile_nav_widget_test.dart b/test/pangea/navigation/mobile_nav_widget_test.dart index af96f15cab..47ff1b4605 100644 --- a/test/pangea/navigation/mobile_nav_widget_test.dart +++ b/test/pangea/navigation/mobile_nav_widget_test.dart @@ -30,6 +30,7 @@ void main() { AppSection? cavitySection, bool courseShortcutHostsCavity = false, VoidCallback? onDismissed, + ValueChanged? onCavityFullChanged, }) async { tester.view.physicalSize = const Size(400, 800); tester.view.devicePixelRatio = 1.0; @@ -53,6 +54,7 @@ void main() { cavitySection: cavitySection, courseShortcutHostsCavity: courseShortcutHostsCavity, onDismissed: onDismissed, + onCavityFullChanged: onCavityFullChanged, ), ), ), @@ -689,4 +691,54 @@ void main() { expect(height, lessThan(maxHeightPx)); }); }); + + group('full-height reporting (#7697)', () { + testWidgets('reports full only on settle, and toggles back on collapse', ( + tester, + ) async { + final reports = []; + await pumpNav( + tester, + cavityChild: const Text('Chat list'), + cavityKey: 'chats', + maxHeightFraction: 0.75, + onCavityFullChanged: reports.add, + ); + // Opens at half — never full — so nothing is reported yet. + expect(reports, isEmpty); + + // Handle tap settles at full: one true report. + await tester.tap(handleFinder()); + await tester.pumpAndSettle(); + expect(reports, [true]); + + // Handle tap settles back at half: reports false. Only real changes fire. + await tester.tap(handleFinder()); + await tester.pumpAndSettle(); + expect(reports, [true, false]); + }); + + testWidgets('an ephemeral tap-outside collapse reports not-full', ( + tester, + ) async { + final reports = []; + await pumpNav( + tester, + activeSection: AppSection.chats, + cavitySection: AppSection.chats, + cavityChild: const Text('Chat list'), + cavityKey: 'chats', + maxHeightFraction: 0.75, + onCavityFullChanged: reports.add, + ); + await tester.tap(handleFinder()); // -> full + await tester.pumpAndSettle(); + expect(reports, [true]); + + // Tap outside collapses ephemerally — the sheet is no longer full. + await tester.tapAt(const Offset(200, 20)); + await tester.pumpAndSettle(); + expect(reports, [true, false]); + }); + }); }