Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 21 additions & 9 deletions .github/instructions/routing.instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Expand Down Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions lib/features/quests/repo/quest_plans_repo.dart
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,12 @@ class QuestPlansRepo {

final sequence = res['learning_objective_sequence'] as List<dynamic>?;
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.
Expand Down
2 changes: 2 additions & 0 deletions lib/l10n/intl_en.arb
Original file line number Diff line number Diff line change
Expand Up @@ -833,6 +833,8 @@
},
"chatPermissions": "Chat permissions",
"@chatPermissions": {},
"coursePermissions": "Course permissions",
"@coursePermissions": {},
"editDisplayname": "Edit displayname",
"@editDisplayname": {
"type": "String",
Expand Down
86 changes: 57 additions & 29 deletions lib/routes/courses/add_course_options.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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),
),
],
);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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) {
Expand Down
151 changes: 92 additions & 59 deletions lib/routes/world/left_panel/left_panel_courses_list_view.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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<Room> 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)),
],
);
},
);
Comment on lines 52 to 73

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚀 Performance & Scalability | 🟠 Major | ⚡ Quick win

Cache the derived stream to prevent subscription churn.

Creating a new stream pipeline (.where(...).rateLimit(...)) inside build() causes StreamBuilder to unsubscribe and resubscribe every time the widget rebuilds (e.g., when inherited dependencies like L10n or Matrix update). This defeats the rate-limiter and causes unnecessary memory churn.

Consider converting CoursesHubPanel to a StatefulWidget and caching the stream in didChangeDependencies so it is only recreated when the client actually changes.

🛠 Proposed fix
-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);
-
-    return StreamBuilder(
-      stream: client.onSync.stream
-          .where((s) => s.hasRoomUpdate)
-          .rateLimit(const Duration(seconds: 1)),
-      builder: (context, _) {
-        final courses = joinedCourses(client, l10n);
-        return Column(
-          children: [
-            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(),
-            ),
-            Expanded(child: LeftPanelCoursesListView(courses: courses)),
-          ],
-        );
-      },
-    );
-  }
-}
+class CoursesHubPanel extends StatefulWidget {
+  final Widget closeButton;
+
+  const CoursesHubPanel({super.key, required this.closeButton});
+
+  `@override`
+  State<CoursesHubPanel> createState() => _CoursesHubPanelState();
+}
+
+class _CoursesHubPanelState extends State<CoursesHubPanel> {
+  Client? _client;
+  late Stream<dynamic> _roomUpdateStream;
+
+  `@override`
+  void didChangeDependencies() {
+    super.didChangeDependencies();
+    final newClient = Matrix.of(context).client;
+    if (newClient != _client) {
+      _client = newClient;
+      _roomUpdateStream = _client!.onSync.stream
+          .where((s) => s.hasRoomUpdate)
+          .rateLimit(const Duration(seconds: 1));
+    }
+  }
+
+  `@override`
+  Widget build(BuildContext context) {
+    final l10n = L10n.of(context);
+
+    return StreamBuilder(
+      stream: _roomUpdateStream,
+      builder: (context, _) {
+        final courses = joinedCourses(_client!, l10n);
+        return Column(
+          children: [
+            PanelHeader(
+              leading: widget.closeButton,
+              title: l10n.courses,
+              trailing:
+                  courses.isEmpty ? null : const AddCourseHeaderActions(),
+            ),
+            Expanded(child: LeftPanelCoursesListView(courses: courses)),
+          ],
+        );
+      },
+    );
+  }
+}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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)),
],
);
},
);
class CoursesHubPanel extends StatefulWidget {
final Widget closeButton;
const CoursesHubPanel({super.key, required this.closeButton});
`@override`
State<CoursesHubPanel> createState() => _CoursesHubPanelState();
}
class _CoursesHubPanelState extends State<CoursesHubPanel> {
Client? _client;
late Stream<dynamic> _roomUpdateStream;
`@override`
void didChangeDependencies() {
super.didChangeDependencies();
final newClient = Matrix.of(context).client;
if (newClient != _client) {
_client = newClient;
_roomUpdateStream = _client!.onSync.stream
.where((s) => s.hasRoomUpdate)
.rateLimit(const Duration(seconds: 1));
}
}
`@override`
Widget build(BuildContext context) {
final l10n = L10n.of(context);
return StreamBuilder(
stream: _roomUpdateStream,
builder: (context, _) {
final courses = joinedCourses(_client!, l10n);
return Column(
children: [
PanelHeader(
leading: widget.closeButton,
title: l10n.courses,
trailing:
courses.isEmpty ? null : const AddCourseHeaderActions(),
),
Expanded(child: LeftPanelCoursesListView(courses: courses)),
],
);
},
);
}
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/routes/world/left_panel/left_panel_courses_list_view.dart` around lines
52 - 73, Convert CoursesHubPanel to a StatefulWidget and cache the filtered,
rate-limited client.onSync stream in its State.didChangeDependencies method.
Recreate the cached stream only when the client changes, and have build use that
stable stream instead of constructing the where/rateLimit pipeline inline.

}
}

/// 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<Room> 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(),
],
],
);
}
}
7 changes: 1 addition & 6 deletions lib/routes/world/left_panel/workspace_left_panel.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading
Loading