From add7c7bf93620f2f0d6031fcb95492a1879e637e Mon Sep 17 00:00:00 2001 From: pugal Date: Wed, 12 Aug 2026 18:24:34 +0530 Subject: [PATCH] what changes: - Introduces a generic SectionHeader widget in the core package - Replaces duplicated container padding logic in Study, AI, Profile, Info, and Exams screens - Adds support for optional secondary content below main screen headers - Integrates screen reader accessibility tags for all root screen titles why: - Unifies layout and safe-area padding across major screens - Eliminates manual notch and system UI inset calculation - Centralizes design system typography and background color styling - Reduces boilerplate UI code across multiple domain packages --- .../standardize-root-headers/.openspec.yaml | 2 + .../standardize-root-headers/design.md | 23 +++++ .../standardize-root-headers/proposal.md | 24 ++++++ .../specs/section-header/spec.md | 20 +++++ .../changes/standardize-root-headers/tasks.md | 23 +++++ packages/core/lib/core.dart | 1 + .../lib/generated/l10n/app_localizations.dart | 12 --- .../generated/l10n/app_localizations_ar.dart | 8 -- .../generated/l10n/app_localizations_en.dart | 8 -- .../generated/l10n/app_localizations_ml.dart | 8 -- .../generated/l10n/app_localizations_ta.dart | 8 -- packages/core/lib/l10n/app_ar.arb | 2 - packages/core/lib/l10n/app_en.arb | 2 - packages/core/lib/l10n/app_ml.arb | 2 - packages/core/lib/l10n/app_ta.arb | 2 - packages/core/lib/screens/ai_screen.dart | 27 +----- packages/core/lib/widgets/section_header.dart | 85 ++++++++++++++++++ .../test/widgets/section_header_test.dart | 73 ++++++++++++++++ .../courses/lib/screens/info/info_page.dart | 29 ++----- .../courses/lib/screens/store/store_page.dart | 45 +++------- .../courses/lib/screens/study_screen.dart | 23 +---- packages/exams/lib/screens/exams_screen.dart | 32 ++----- .../lib/screens/certificates_screen.dart | 86 +------------------ .../lib/screens/notifications_screen.dart | 66 +------------- .../screens/paid_active_profile_screen.dart | 7 +- .../screens/certificates_screen_test.dart | 1 - .../screens/notifications_screen_test.dart | 11 +-- 27 files changed, 286 insertions(+), 344 deletions(-) create mode 100644 openspec/changes/standardize-root-headers/.openspec.yaml create mode 100644 openspec/changes/standardize-root-headers/design.md create mode 100644 openspec/changes/standardize-root-headers/proposal.md create mode 100644 openspec/changes/standardize-root-headers/specs/section-header/spec.md create mode 100644 openspec/changes/standardize-root-headers/tasks.md create mode 100644 packages/core/lib/widgets/section_header.dart create mode 100644 packages/core/test/widgets/section_header_test.dart diff --git a/openspec/changes/standardize-root-headers/.openspec.yaml b/openspec/changes/standardize-root-headers/.openspec.yaml new file mode 100644 index 000000000..a8821c74d --- /dev/null +++ b/openspec/changes/standardize-root-headers/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-08-11 diff --git a/openspec/changes/standardize-root-headers/design.md b/openspec/changes/standardize-root-headers/design.md new file mode 100644 index 000000000..9d27c20e2 --- /dev/null +++ b/openspec/changes/standardize-root-headers/design.md @@ -0,0 +1,23 @@ +## Context + +The monorepo currently suffers from duplicated UI layout logic for root-level screens (e.g., Study, AI Support, Profile, Exams, Info). Each of these screens manually wraps a `Column` inside a `Container` with hardcoded top padding calculated via `MediaQuery.paddingOf(context)` to avoid overlaps with the system status bar and notches. + +## Goals / Non-Goals + +**Goals:** +- Unify all standard root-level and sub-level header layouts under a single `SectionHeader` widget. +- Support `leadingIcon` actions (menu/back buttons) explicitly. +- Support `trailingAction` widgets. +- Support optional `secondaryContent` below the main header row. + +**Non-Goals:** +- Modifying the navigation paradigms or routing of the app. +- Changing the visual styling or colors (it should perfectly replicate the existing design). + +## Decisions + +- **Nullable Parameters:** To ensure maximal reusability, `leadingIcon`, `trailingAction`, and `secondaryContent` in `SectionHeader` will all be nullable. + +## Risks / Trade-offs + +- [Risk] Replacing manual paddings might slightly alter pixel-perfect alignments if not done carefully. -> Mitigation: Ensure `SectionHeader` uses the equivalent padding logic (`math.max(padding.left, design.spacing.md)`) as the original screens. diff --git a/openspec/changes/standardize-root-headers/proposal.md b/openspec/changes/standardize-root-headers/proposal.md new file mode 100644 index 000000000..0703291da --- /dev/null +++ b/openspec/changes/standardize-root-headers/proposal.md @@ -0,0 +1,24 @@ +## Why + +Many root-level screens (Study, AI, Profile, Info, etc.) manually implement identical container-based safe-area padding logic and header styling. Unifying them into a single widget reduces tech debt, ensures design consistency, and improves maintainability across the monorepo. + +## What Changes + +- Create a generic `SectionHeader` widget in the `core` package that supports built-in safe area padding, leading icons (menu/back buttons), title text, trailing actions, and secondary content (search bars, subtitles). +- Strip out manual header containers in `StudyScreen`, `AiScreen`, `InfoPage`, and `ExamsScreen` and replace them with `SectionHeader`. +- Strip out manual headers in `NotificationsScreen` and `CertificatesScreen` and replace them with `AppHeader` (removing subtitle layouts). + +## Capabilities + +### New Capabilities +- `SectionHeader`: A highly flexible layout widget in the core package for root-level and sub-level page headers, supporting dynamic leading icons, trailing actions, and secondary content. + +### Modified Capabilities +None + +## Impact + +- **packages/core**: Adds `SectionHeader` widget, updates `AiScreen`. +- **packages/profile**: Updates `PaidActiveProfileScreen`, `NotificationsScreen`, `CertificatesScreen`. +- **packages/courses**: Updates `StudyScreen`, `InfoPage`, `StorePage`. +- **packages/exams**: Updates `ExamsScreen`. diff --git a/openspec/changes/standardize-root-headers/specs/section-header/spec.md b/openspec/changes/standardize-root-headers/specs/section-header/spec.md new file mode 100644 index 000000000..50d0ef2e5 --- /dev/null +++ b/openspec/changes/standardize-root-headers/specs/section-header/spec.md @@ -0,0 +1,20 @@ +## ADDED Requirements + +### Requirement: Unified Section Header Layout +The system SHALL provide a `SectionHeader` widget in the core package that unifies layout and safe-area padding for all major screens. + +#### Scenario: Rendering standard title +- **WHEN** the header is rendered with a string title +- **THEN** it displays the text title using the `AppText.headline` style + +#### Scenario: Rendering leading and trailing actions +- **WHEN** `leadingIcon` and/or `trailingAction` widgets are provided +- **THEN** they are displayed on the left and right sides of the title row respectively + +#### Scenario: Rendering secondary content +- **WHEN** a `secondaryContent` widget is provided +- **THEN** it is displayed below the main title row with appropriate vertical spacing + +#### Scenario: Automatic safe area handling +- **WHEN** the header is rendered on a device with a notch or status bar +- **THEN** it automatically applies top, left, and right padding using `MediaQuery.paddingOf(context)` combined with design spacing to ensure content is not obscured diff --git a/openspec/changes/standardize-root-headers/tasks.md b/openspec/changes/standardize-root-headers/tasks.md new file mode 100644 index 000000000..d6efa0198 --- /dev/null +++ b/openspec/changes/standardize-root-headers/tasks.md @@ -0,0 +1,23 @@ +## 1. Core Widget + +- [x] 1.1 Create `SectionHeader` widget in `packages/core/lib/widgets/section_header.dart` with support for `leadingIcon`, `title`, `trailingAction`, and `secondaryContent` + +## 2. Refactor Core usages + +- [x] 2.1 Refactor `AiScreen` in `packages/core/lib/screens/ai_screen.dart` to use `SectionHeader` + +## 3. Refactor Courses usages + +- [x] 3.1 Refactor `StudyScreen` in `packages/courses/lib/screens/study_screen.dart` to use `SectionHeader` (passing search bar and filters to `secondaryContent`) +- [x] 3.2 Refactor `InfoPage` in `packages/courses/lib/screens/info/info_page.dart` to use `SectionHeader` +- [x] 3.3 Refactor `StorePage` in `packages/courses/lib/screens/store/store_page.dart` to use `SectionHeader` + +## 4. Refactor Exams usages + +- [x] 4.1 Refactor `ExamsScreen` in `packages/exams/lib/screens/exams_screen.dart` to use `SectionHeader` + +## 5. Refactor Profile usages + +- [x] 5.1 Refactor `PaidActiveProfileScreen` in `packages/profile/lib/screens/paid_active_profile_screen.dart` to use `SectionHeader` +- [x] 5.2 Refactor `NotificationsScreen` in `packages/profile/lib/screens/notifications_screen.dart` to use `AppHeader` (and removed subtitles since they are standard pages, not root headers) +- [x] 5.3 Refactor `CertificatesScreen` in `packages/profile/lib/screens/certificates_screen.dart` to use `AppHeader` (and removed subtitles since they are standard pages, not root headers) diff --git a/packages/core/lib/core.dart b/packages/core/lib/core.dart index dc90b90de..e81c014c7 100644 --- a/packages/core/lib/core.dart +++ b/packages/core/lib/core.dart @@ -42,6 +42,7 @@ export 'widgets/app_drawer.dart'; export 'widgets/app_bottom_sheet.dart'; export 'widgets/app_error_view.dart'; export 'widgets/app_webview.dart'; +export 'widgets/section_header.dart'; export 'widgets/dashboard_header.dart'; export 'widgets/lesson_detail_shell.dart'; export 'widgets/bookmark_folders_sheet.dart'; diff --git a/packages/core/lib/generated/l10n/app_localizations.dart b/packages/core/lib/generated/l10n/app_localizations.dart index 3ca21a3f4..bb0502978 100644 --- a/packages/core/lib/generated/l10n/app_localizations.dart +++ b/packages/core/lib/generated/l10n/app_localizations.dart @@ -870,12 +870,6 @@ abstract class AppLocalizations { /// **'Notifications'** String get profileNotifications; - /// No description provided for @notificationsManagePreferences. - /// - /// In en, this message translates to: - /// **'Manage your notification preferences'** - String get notificationsManagePreferences; - /// No description provided for @notificationsLiveClassReminders. /// /// In en, this message translates to: @@ -966,12 +960,6 @@ abstract class AppLocalizations { /// **'Log out'** String get logoutButtonLabel; - /// No description provided for @certificatesSubtitleAvailable. - /// - /// In en, this message translates to: - /// **'View and download your course completion certificates'** - String get certificatesSubtitleAvailable; - /// No description provided for @certificatesEmptyPaidNewDesc. /// /// In en, this message translates to: diff --git a/packages/core/lib/generated/l10n/app_localizations_ar.dart b/packages/core/lib/generated/l10n/app_localizations_ar.dart index 92bd0b66b..f7678e70d 100644 --- a/packages/core/lib/generated/l10n/app_localizations_ar.dart +++ b/packages/core/lib/generated/l10n/app_localizations_ar.dart @@ -417,10 +417,6 @@ class AppLocalizationsAr extends AppLocalizations { @override String get profileNotifications => 'التنبيهات'; - @override - String get notificationsManagePreferences => - 'إدارة تفضيلات التنبيهات الخاصة بك'; - @override String get notificationsLiveClassReminders => 'تذكيرات الحصص المباشرة'; @@ -472,10 +468,6 @@ class AppLocalizationsAr extends AppLocalizations { @override String get logoutButtonLabel => 'تسجيل الخروج'; - @override - String get certificatesSubtitleAvailable => - 'اعرض وحمّل شهادات إكمال الدورات الخاصة بك'; - @override String get certificatesEmptyPaidNewDesc => 'لا توجد شهادات متاحة بعد'; diff --git a/packages/core/lib/generated/l10n/app_localizations_en.dart b/packages/core/lib/generated/l10n/app_localizations_en.dart index 3702aad9c..7c6e889fc 100644 --- a/packages/core/lib/generated/l10n/app_localizations_en.dart +++ b/packages/core/lib/generated/l10n/app_localizations_en.dart @@ -417,10 +417,6 @@ class AppLocalizationsEn extends AppLocalizations { @override String get profileNotifications => 'Notifications'; - @override - String get notificationsManagePreferences => - 'Manage your notification preferences'; - @override String get notificationsLiveClassReminders => 'Live class reminders'; @@ -471,10 +467,6 @@ class AppLocalizationsEn extends AppLocalizations { @override String get logoutButtonLabel => 'Log out'; - @override - String get certificatesSubtitleAvailable => - 'View and download your course completion certificates'; - @override String get certificatesEmptyPaidNewDesc => 'No certificates available yet'; diff --git a/packages/core/lib/generated/l10n/app_localizations_ml.dart b/packages/core/lib/generated/l10n/app_localizations_ml.dart index 80457d706..06216b463 100644 --- a/packages/core/lib/generated/l10n/app_localizations_ml.dart +++ b/packages/core/lib/generated/l10n/app_localizations_ml.dart @@ -419,10 +419,6 @@ class AppLocalizationsMl extends AppLocalizations { @override String get profileNotifications => 'നോട്ടിഫിക്കേഷനുകൾ'; - @override - String get notificationsManagePreferences => - 'നിങ്ങളുടെ നോട്ടിഫിക്കേഷൻ മുൻഗണനകൾ നിയന്ത്രിക്കുക'; - @override String get notificationsLiveClassReminders => 'ലൈവ് ക്ലാസ് റിമൈൻഡറുകൾ'; @@ -475,10 +471,6 @@ class AppLocalizationsMl extends AppLocalizations { @override String get logoutButtonLabel => 'ലോഗൗട്ട്'; - @override - String get certificatesSubtitleAvailable => - 'നിങ്ങളുടെ കോഴ്‌സ് പൂർത്തീകരണ സർട്ടിഫിക്കറ്റുകൾ കാണുകയും ഡൗൺലോഡ് ചെയ്യുകയും ചെയ്യുക'; - @override String get certificatesEmptyPaidNewDesc => 'ഇപ്പോൾ സർട്ടിഫിക്കറ്റുകൾ ഒന്നുമില്ല'; diff --git a/packages/core/lib/generated/l10n/app_localizations_ta.dart b/packages/core/lib/generated/l10n/app_localizations_ta.dart index 6ef0de7cf..8a542a06c 100644 --- a/packages/core/lib/generated/l10n/app_localizations_ta.dart +++ b/packages/core/lib/generated/l10n/app_localizations_ta.dart @@ -423,10 +423,6 @@ class AppLocalizationsTa extends AppLocalizations { @override String get profileNotifications => 'அறிவிப்புகள்'; - @override - String get notificationsManagePreferences => - 'உங்கள் அறிவிப்பு விருப்பங்களை நிர்வகிக்கவும்'; - @override String get notificationsLiveClassReminders => 'நேரலை வகுப்பு நினைவூட்டல்கள்'; @@ -479,10 +475,6 @@ class AppLocalizationsTa extends AppLocalizations { @override String get logoutButtonLabel => 'வெளியேறு'; - @override - String get certificatesSubtitleAvailable => - 'உங்கள் பாடநெறி முடித்த சான்றிதழ்களைப் பார்த்து பதிவிறக்கவும்'; - @override String get certificatesEmptyPaidNewDesc => 'இன்னும் சான்றிதழ்கள் கிடைக்கவில்லை'; diff --git a/packages/core/lib/l10n/app_ar.arb b/packages/core/lib/l10n/app_ar.arb index 4fc6cc215..c1acceed6 100644 --- a/packages/core/lib/l10n/app_ar.arb +++ b/packages/core/lib/l10n/app_ar.arb @@ -156,7 +156,6 @@ "profileAccountSettingsTitle": "الحساب والتفضيلات", "profileEditProfile": "تعديل الملف الشخصي", "profileNotifications": "التنبيهات", - "notificationsManagePreferences": "إدارة تفضيلات التنبيهات الخاصة بك", "notificationsLiveClassReminders": "تذكيرات الحصص المباشرة", "notificationsLiveClassRemindersDesc": "تلقي تنبيه قبل بدء الحصص المباشرة", "notificationsTestAssessmentAlerts": "تنبيهات الاختبارات والتقييمات", @@ -172,7 +171,6 @@ "logoutConfirmationTitle": "هل تريد تسجيل الخروج؟", "logoutConfirmationMessage": "ستحتاج إلى تسجيل الدخول مرة أخرى للوصول إلى حسابك", "logoutButtonLabel": "تسجيل الخروج", - "certificatesSubtitleAvailable": "اعرض وحمّل شهادات إكمال الدورات الخاصة بك", "certificatesEmptyPaidNewDesc": "لا توجد شهادات متاحة بعد", "certificatesLockedBadge": "أكمل الدورة لفتح الشهادة", "certificatesUnlockedBadge": "شهادة إكمال", diff --git a/packages/core/lib/l10n/app_en.arb b/packages/core/lib/l10n/app_en.arb index b5e83097f..024920b3e 100644 --- a/packages/core/lib/l10n/app_en.arb +++ b/packages/core/lib/l10n/app_en.arb @@ -168,7 +168,6 @@ "profileAccountSettingsTitle": "Account & preferences", "profileEditProfile": "Edit Profile", "profileNotifications": "Notifications", - "notificationsManagePreferences": "Manage your notification preferences", "notificationsLiveClassReminders": "Live class reminders", "notificationsLiveClassRemindersDesc": "Get notified before your live classes start", "notificationsTestAssessmentAlerts": "Test and assessment alerts", @@ -184,7 +183,6 @@ "logoutConfirmationTitle": "Log out?", "logoutConfirmationMessage": "You'll need to log in again to access your account", "logoutButtonLabel": "Log out", - "certificatesSubtitleAvailable": "View and download your course completion certificates", "certificatesEmptyPaidNewDesc": "No certificates available yet", "certificatesLockedBadge": "COMPLETE COURSE TO UNLOCK", "certificatesUnlockedBadge": "CERTIFICATE OF COMPLETION", diff --git a/packages/core/lib/l10n/app_ml.arb b/packages/core/lib/l10n/app_ml.arb index c65449133..9f0cf1eb1 100644 --- a/packages/core/lib/l10n/app_ml.arb +++ b/packages/core/lib/l10n/app_ml.arb @@ -156,7 +156,6 @@ "profileAccountSettingsTitle": "അക്കൗണ്ടും ക്രമീകരണങ്ങളും", "profileEditProfile": "പ്രൊഫൈൽ എഡിറ്റ് ചെയ്യുക", "profileNotifications": "നോട്ടിഫിക്കേഷനുകൾ", - "notificationsManagePreferences": "നിങ്ങളുടെ നോട്ടിഫിക്കേഷൻ മുൻഗണനകൾ നിയന്ത്രിക്കുക", "notificationsLiveClassReminders": "ലൈവ് ക്ലാസ് റിമൈൻഡറുകൾ", "notificationsLiveClassRemindersDesc": "ലൈവ് ക്ലാസുകൾ തുടങ്ങുന്നതിന് മുമ്പ് അറിയിപ്പുകൾ നേടുക", "notificationsTestAssessmentAlerts": "ടെസ്റ്റ്, അസെസ്മെന്റ് അലർട്ടുകൾ", @@ -172,7 +171,6 @@ "logoutConfirmationTitle": "ലോഗൗട്ട് ചെയ്യണോ?", "logoutConfirmationMessage": "നിങ്ങളുടെ അക്കൗണ്ട് ആക്സസ് ചെയ്യാൻ വീണ്ടും ലോഗിൻ ചെയ്യേണ്ടതുണ്ട്", "logoutButtonLabel": "ലോഗൗട്ട്", - "certificatesSubtitleAvailable": "നിങ്ങളുടെ കോഴ്‌സ് പൂർത്തീകരണ സർട്ടിഫിക്കറ്റുകൾ കാണുകയും ഡൗൺലോഡ് ചെയ്യുകയും ചെയ്യുക", "certificatesEmptyPaidNewDesc": "ഇപ്പോൾ സർട്ടിഫിക്കറ്റുകൾ ഒന്നുമില്ല", "certificatesLockedBadge": "അൺലോക്ക് ചെയ്യാൻ കോഴ്‌സ് പൂർത്തിയാക്കുക", "certificatesUnlockedBadge": "പൂർത്തീകരണ സർട്ടിഫിക്കറ്റ്", diff --git a/packages/core/lib/l10n/app_ta.arb b/packages/core/lib/l10n/app_ta.arb index 1a973fe2c..09e847c1b 100644 --- a/packages/core/lib/l10n/app_ta.arb +++ b/packages/core/lib/l10n/app_ta.arb @@ -168,7 +168,6 @@ "profileAccountSettingsTitle": "கணக்கு மற்றும் விருப்பங்கள்", "profileEditProfile": "சுயவிவரத்தைத் திருத்து", "profileNotifications": "அறிவிப்புகள்", - "notificationsManagePreferences": "உங்கள் அறிவிப்பு விருப்பங்களை நிர்வகிக்கவும்", "notificationsLiveClassReminders": "நேரலை வகுப்பு நினைவூட்டல்கள்", "notificationsLiveClassRemindersDesc": "உங்கள் நேரலை வகுப்புகள் தொடங்கும் முன் அறிவிப்பைப் பெறவும்", "notificationsTestAssessmentAlerts": "தேர்வு மற்றும் மதிப்பீட்டு விழிப்பூட்டல்கள்", @@ -184,7 +183,6 @@ "logoutConfirmationTitle": "வெளியேறவா?", "logoutConfirmationMessage": "உங்கள் கணக்கை அணுக மீண்டும் உள்நுழைய வேண்டும்", "logoutButtonLabel": "வெளியேறு", - "certificatesSubtitleAvailable": "உங்கள் பாடநெறி முடித்த சான்றிதழ்களைப் பார்த்து பதிவிறக்கவும்", "certificatesEmptyPaidNewDesc": "இன்னும் சான்றிதழ்கள் கிடைக்கவில்லை", "certificatesLockedBadge": "திறக்க பாடத்தை முடிக்கவும்", "certificatesUnlockedBadge": "முடித்ததற்கான சான்றிதழ்", diff --git a/packages/core/lib/screens/ai_screen.dart b/packages/core/lib/screens/ai_screen.dart index 519f57fca..b9fe4a240 100644 --- a/packages/core/lib/screens/ai_screen.dart +++ b/packages/core/lib/screens/ai_screen.dart @@ -19,7 +19,6 @@ class AiScreen extends ConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final design = Design.of(context); final l10n = L10n.of(context); - final padding = MediaQuery.paddingOf(context); final user = ref.watch(userProvider).valueOrNull; final userName = user?.name; @@ -28,31 +27,7 @@ class AiScreen extends ConsumerWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - Container( - decoration: BoxDecoration( - color: design.colors.card, - border: Border( - bottom: BorderSide(color: design.colors.divider, width: 1), - ), - ), - padding: EdgeInsets.fromLTRB( - padding.left > design.spacing.md - ? padding.left - : design.spacing.md, - padding.top + design.spacing.md, - padding.right > design.spacing.md - ? padding.right - : design.spacing.md, - design.spacing.md, - ), - child: AppSemantics.header( - label: l10n.aiSupportTitle, - child: AppText.headline( - l10n.aiSupportTitle, - color: design.colors.textPrimary, - ), - ), - ), + SectionHeader(title: l10n.aiSupportTitle), Expanded( child: AppScroll( padding: EdgeInsets.symmetric( diff --git a/packages/core/lib/widgets/section_header.dart b/packages/core/lib/widgets/section_header.dart new file mode 100644 index 000000000..737982a97 --- /dev/null +++ b/packages/core/lib/widgets/section_header.dart @@ -0,0 +1,85 @@ +import 'dart:math' as math; +import 'package:flutter/widgets.dart'; +import 'package:core/core.dart'; + +class SectionHeader extends StatelessWidget { + const SectionHeader({ + super.key, + required this.title, + this.leadingIcon, + this.trailingAction, + this.secondaryContent, + this.secondaryContentSpacing, + this.backgroundColor, + }); + + final String title; + final Widget? leadingIcon; + final Widget? trailingAction; + final Widget? secondaryContent; + final double? secondaryContentSpacing; + final Color? backgroundColor; + + @override + Widget build(BuildContext context) { + final design = Design.of(context); + final padding = MediaQuery.paddingOf(context); + + return Container( + padding: _buildPadding(design, padding), + color: backgroundColor ?? design.colors.card, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + _buildHeaderRow(design), + if (secondaryContent != null) _buildSecondaryContent(design), + ], + ), + ); + } + + EdgeInsets _buildPadding(DesignConfig design, EdgeInsets padding) { + return EdgeInsets.fromLTRB( + math.max(padding.left, design.spacing.md), + padding.top + design.spacing.md, + math.max(padding.right, design.spacing.md), + design.spacing.md, + ); + } + + Widget _buildSecondaryContent(DesignConfig design) { + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox(height: secondaryContentSpacing ?? design.spacing.md), + secondaryContent!, + ], + ); + } + + Widget _buildHeaderRow(DesignConfig design) { + return Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + if (leadingIcon != null) ...[ + leadingIcon!, + SizedBox(width: design.spacing.sm), + ], + Expanded(child: _buildTitle(design)), + if (trailingAction != null) ...[ + SizedBox(width: design.spacing.md), + trailingAction!, + ], + ], + ); + } + + Widget _buildTitle(DesignConfig design) { + return AppSemantics.header( + label: title, + child: AppText.headline(title, color: design.colors.textPrimary), + ); + } +} diff --git a/packages/core/test/widgets/section_header_test.dart b/packages/core/test/widgets/section_header_test.dart new file mode 100644 index 000000000..15237bb34 --- /dev/null +++ b/packages/core/test/widgets/section_header_test.dart @@ -0,0 +1,73 @@ +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:core/core.dart'; + +void main() { + Widget wrap(Widget child) { + return MediaQuery( + data: const MediaQueryData(padding: EdgeInsets.only(top: 40)), + child: DesignProvider( + config: DesignConfig.defaults(), + child: Directionality(textDirection: TextDirection.ltr, child: child), + ), + ); + } + + group('SectionHeader', () { + testWidgets('renders title text', (tester) async { + await tester.pumpWidget(wrap(const SectionHeader(title: 'My Title'))); + expect(find.text('My Title'), findsOneWidget); + }); + + testWidgets('renders leading widget', (tester) async { + await tester.pumpWidget( + wrap( + const SectionHeader( + title: 'Test', + leadingIcon: Text('LeadingWidget'), + ), + ), + ); + expect(find.text('LeadingWidget'), findsOneWidget); + }); + + testWidgets('renders trailing widget', (tester) async { + await tester.pumpWidget( + wrap( + const SectionHeader( + title: 'Test', + trailingAction: Text('TrailingWidget'), + ), + ), + ); + expect(find.text('TrailingWidget'), findsOneWidget); + }); + + testWidgets('renders bottomContent widget', (tester) async { + await tester.pumpWidget( + wrap( + const SectionHeader( + title: 'Test', + secondaryContent: Text('BottomWidget'), + ), + ), + ); + expect(find.text('BottomWidget'), findsOneWidget); + }); + + testWidgets('title has header semantics', (tester) async { + await tester.pumpWidget( + wrap(const SectionHeader(title: 'Semantics Title')), + ); + + final semanticsFinder = find.byWidgetPredicate( + (widget) => + widget is Semantics && + widget.properties.header == true && + widget.properties.label == 'Semantics Title', + ); + + expect(semanticsFinder, findsOneWidget); + }); + }); +} diff --git a/packages/courses/lib/screens/info/info_page.dart b/packages/courses/lib/screens/info/info_page.dart index 35119289f..06e95fb6b 100644 --- a/packages/courses/lib/screens/info/info_page.dart +++ b/packages/courses/lib/screens/info/info_page.dart @@ -90,30 +90,13 @@ class _InfoPageHeader extends StatelessWidget { Widget build(BuildContext context) { final design = Design.of(context); final l10n = L10n.of(context); - final padding = MediaQuery.paddingOf(context); - return Container( - width: double.infinity, - color: design.colors.card, - padding: EdgeInsets.fromLTRB( - padding.left > design.spacing.md ? padding.left : design.spacing.md, - padding.top + design.spacing.md, - padding.right > design.spacing.md ? padding.right : design.spacing.md, - design.spacing.md, - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - AppText.headline( - l10n.infoPageTitle, - color: design.colors.textPrimary, - ), - SizedBox(height: design.spacing.xs), - AppText.body( - l10n.infoPageSubtitle, - color: design.colors.textSecondary, - ), - ], + return SectionHeader( + title: l10n.infoPageTitle, + secondaryContentSpacing: design.spacing.xs, + secondaryContent: AppText.body( + l10n.infoPageSubtitle, + color: design.colors.textSecondary, ), ); } diff --git a/packages/courses/lib/screens/store/store_page.dart b/packages/courses/lib/screens/store/store_page.dart index d37deade5..e5dff7203 100644 --- a/packages/courses/lib/screens/store/store_page.dart +++ b/packages/courses/lib/screens/store/store_page.dart @@ -26,7 +26,6 @@ class _StorePageState extends ConsumerState { Widget build(BuildContext context) { final design = Design.of(context); final l10n = L10n.of(context); - final padding = MediaQuery.paddingOf(context); return Column( children: [ @@ -37,41 +36,19 @@ class _StorePageState extends ConsumerState { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - // Title & Search Section - Padding( - padding: EdgeInsets.fromLTRB( - padding.left > design.spacing.md - ? padding.left - : design.spacing.md, - padding.top + design.spacing.md, - padding.right > design.spacing.md - ? padding.right - : design.spacing.md, - design.spacing.md, - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - AppText.lg( - l10n.storeTabTitle, - style: const TextStyle(fontWeight: FontWeight.w700), - ), - SizedBox(height: design.spacing.md), - AppSearchBar( - controller: _searchController, - hintText: l10n.storeSearchHint, - backgroundColor: design.colors.surface, - onChanged: (value) { - ref - .read(storeSearchQueryProvider.notifier) - .update(value); - }, - ), - ], + SectionHeader( + title: l10n.storeTabTitle, + backgroundColor: design.colors + .transparent, // transparent so outer container color shows + secondaryContent: AppSearchBar( + controller: _searchController, + hintText: l10n.storeSearchHint, + backgroundColor: design.colors.surface, + onChanged: (value) { + ref.read(storeSearchQueryProvider.notifier).update(value); + }, ), ), - - // Category Filter Bar const CategoryFilterBar(), ], ), diff --git a/packages/courses/lib/screens/study_screen.dart b/packages/courses/lib/screens/study_screen.dart index c667bb4ce..5a511cf30 100644 --- a/packages/courses/lib/screens/study_screen.dart +++ b/packages/courses/lib/screens/study_screen.dart @@ -83,7 +83,6 @@ class _StudyScreenState extends ConsumerState { Widget build(BuildContext context) { final design = Design.of(context); final l10n = L10n.of(context); - final padding = MediaQuery.paddingOf(context); final enrolledCoursesState = ref.watch(courseListProvider); final isSyncingInitial = ref.watch(isSyncingInitialPage); @@ -100,27 +99,11 @@ class _StudyScreenState extends ConsumerState { child: Column( children: [ // 1. Static Header Section - Container( - width: double.infinity, - color: design.colors.card, - padding: EdgeInsets.fromLTRB( - padding.left > design.spacing.md - ? padding.left - : design.spacing.md, - padding.top + design.spacing.md, - padding.right > design.spacing.md - ? padding.right - : design.spacing.md, - design.spacing.md, - ), - child: Column( + SectionHeader( + title: l10n.studyTabTitle, + secondaryContent: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - AppText.headline( - l10n.studyTabTitle, - color: design.colors.textPrimary, - ), - SizedBox(height: design.spacing.md), AppSearchBar( controller: _searchController, hintText: l10n.studySearchHint, diff --git a/packages/exams/lib/screens/exams_screen.dart b/packages/exams/lib/screens/exams_screen.dart index 80bbf0ce0..a4e4f261a 100644 --- a/packages/exams/lib/screens/exams_screen.dart +++ b/packages/exams/lib/screens/exams_screen.dart @@ -61,32 +61,12 @@ class _ExamsScreenState extends ConsumerState { Positioned.fill( child: Column( children: [ - Container( - width: double.infinity, - color: design.colors.card, - padding: EdgeInsets.fromLTRB( - padding.left > design.spacing.md - ? padding.left - : design.spacing.md, - padding.top + design.spacing.md, - padding.right > design.spacing.md - ? padding.right - : design.spacing.md, - design.spacing.md, - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - AppText.headline( - l10n.examsTabTitle, - color: design.colors.textPrimary, - ), - SizedBox(height: design.spacing.xs), - AppText.body( - l10n.selectExamToViewQuestions, - color: design.colors.textSecondary, - ), - ], + SectionHeader( + title: l10n.examsTabTitle, + secondaryContentSpacing: design.spacing.xs, + secondaryContent: AppText.body( + l10n.selectExamToViewQuestions, + color: design.colors.textSecondary, ), ), Container(height: 1, color: design.colors.divider), diff --git a/packages/profile/lib/screens/certificates_screen.dart b/packages/profile/lib/screens/certificates_screen.dart index f862b669d..2ac8365c5 100644 --- a/packages/profile/lib/screens/certificates_screen.dart +++ b/packages/profile/lib/screens/certificates_screen.dart @@ -60,64 +60,11 @@ class _CertificatesHeader extends StatelessWidget { @override Widget build(BuildContext context) { - final design = Design.of(context); final l10n = L10n.of(context); - final padding = MediaQuery.paddingOf(context); - - return Container( - padding: EdgeInsets.fromLTRB( - padding.left > design.spacing.md ? padding.left : design.spacing.md, - padding.top + design.spacing.md, - padding.right > design.spacing.md ? padding.right : design.spacing.md, - design.spacing.md, - ), - decoration: _headerDecoration(design), - child: Align( - alignment: Alignment.centerLeft, - child: _BackButton(label: l10n.curriculumBackButton, onBack: onBack), - ), - ); - } - - BoxDecoration _headerDecoration(DesignConfig design) => BoxDecoration( - color: design.colors.card, - border: Border(bottom: BorderSide(color: design.colors.border)), - ); -} - -class _BackButton extends StatelessWidget { - const _BackButton({required this.label, required this.onBack}); - - final String label; - final VoidCallback onBack; - @override - Widget build(BuildContext context) { - final design = Design.of(context); - - return AppSemantics.button( - label: label, - onTap: onBack, - child: GestureDetector( - behavior: HitTestBehavior.opaque, - onTap: onBack, - child: SizedBox( - height: design.iconSize.lg + design.spacing.xs, - child: Row( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Icon( - LucideIcons.chevronLeft, - size: design.iconSize.md, - color: design.colors.textPrimary, - ), - SizedBox(width: design.spacing.xs), - AppText.label(label, color: design.colors.textPrimary), - ], - ), - ), - ), + return AppHeader( + title: l10n.profileCertificates, + leading: AppBackButton(onTap: onBack), ); } } @@ -151,8 +98,6 @@ class _CertificatesBody extends StatelessWidget { design.spacing.xxl, ), children: [ - const _ScreenTitle(), - SizedBox(height: design.spacing.lg), _CertificateList( certificates: certificates, onContinueCourse: onContinueCourse, @@ -164,31 +109,6 @@ class _CertificatesBody extends StatelessWidget { } } -class _ScreenTitle extends StatelessWidget { - const _ScreenTitle(); - - @override - Widget build(BuildContext context) { - final design = Design.of(context); - final l10n = L10n.of(context); - - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - AppText.xl2( - l10n.profileCertificates, - style: const TextStyle(fontWeight: FontWeight.w700, height: 1.33), - ), - SizedBox(height: design.spacing.xs), - AppText.sm( - l10n.certificatesSubtitleAvailable, - color: design.colors.textSecondary, - ), - ], - ); - } -} - class _CertificateList extends StatelessWidget { const _CertificateList({ required this.certificates, diff --git a/packages/profile/lib/screens/notifications_screen.dart b/packages/profile/lib/screens/notifications_screen.dart index 9e38abed8..3fb89bad8 100644 --- a/packages/profile/lib/screens/notifications_screen.dart +++ b/packages/profile/lib/screens/notifications_screen.dart @@ -31,23 +31,6 @@ class NotificationsScreen extends ConsumerWidget { design.spacing.xxl, ), children: [ - AppText.xl2( - l10n.profileNotifications, - style: const TextStyle( - fontWeight: FontWeight.w700, - height: 1.33, - ), - ), - SizedBox(height: design.spacing.xs), - AppText.sm( - l10n.notificationsManagePreferences, - color: design.colors.textSecondary, - style: const TextStyle( - fontWeight: FontWeight.w400, - height: 1.4, - ), - ), - SizedBox(height: design.spacing.lg), AppCard( showShadow: true, padding: EdgeInsets.zero, @@ -135,54 +118,11 @@ class _NotificationsHeader extends StatelessWidget { @override Widget build(BuildContext context) { - final design = Design.of(context); final l10n = L10n.of(context); - final padding = MediaQuery.paddingOf(context); - return SizedBox( - width: double.infinity, - child: Container( - padding: EdgeInsets.fromLTRB( - padding.left > design.spacing.md ? padding.left : design.spacing.md, - padding.top + design.spacing.md, - padding.right > design.spacing.md ? padding.right : design.spacing.md, - design.spacing.md, - ), - decoration: BoxDecoration( - color: design.colors.card, - border: Border(bottom: BorderSide(color: design.colors.border)), - ), - child: Align( - alignment: Alignment.centerLeft, - child: AppSemantics.button( - label: l10n.curriculumBackButton, - onTap: onBack, - child: GestureDetector( - behavior: HitTestBehavior.opaque, - onTap: onBack, - child: SizedBox( - height: design.iconSize.lg + design.spacing.xs, - child: Row( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Icon( - LucideIcons.chevronLeft, - size: design.iconSize.md, - color: design.colors.textPrimary, - ), - SizedBox(width: design.spacing.xs), - AppText.label( - l10n.curriculumBackButton, - color: design.colors.textPrimary, - ), - ], - ), - ), - ), - ), - ), - ), + return AppHeader( + title: l10n.profileNotifications, + leading: AppBackButton(onTap: onBack), ); } } diff --git a/packages/profile/lib/screens/paid_active_profile_screen.dart b/packages/profile/lib/screens/paid_active_profile_screen.dart index f71c12a64..89a646538 100644 --- a/packages/profile/lib/screens/paid_active_profile_screen.dart +++ b/packages/profile/lib/screens/paid_active_profile_screen.dart @@ -41,14 +41,9 @@ class ProfilePage extends ConsumerWidget { color: design.colors.canvas, child: LayoutBuilder( builder: (context, constraints) { - final isLandscape = constraints.maxWidth > constraints.maxHeight; - return Column( children: [ - DashboardHeader( - title: l10n.profileTabTitle, - isLandscape: isLandscape, - ), + SectionHeader(title: l10n.profileTabTitle), Expanded( child: AppScroll( padding: EdgeInsets.only( diff --git a/packages/profile/test/screens/certificates_screen_test.dart b/packages/profile/test/screens/certificates_screen_test.dart index 1c84832c1..2c685111f 100644 --- a/packages/profile/test/screens/certificates_screen_test.dart +++ b/packages/profile/test/screens/certificates_screen_test.dart @@ -95,7 +95,6 @@ void main() { final l10n = L10n.of(tester.element(find.byType(CertificatesScreen))); expect(find.text(l10n.profileCertificates), findsOneWidget); - expect(find.text(l10n.certificatesSubtitleAvailable), findsOneWidget); expect(find.text(l10n.certificatesUnlockedBadge), findsOneWidget); expect(find.text(l10n.certificatesLockedBadge), findsNWidgets(3)); }); diff --git a/packages/profile/test/screens/notifications_screen_test.dart b/packages/profile/test/screens/notifications_screen_test.dart index 0116f116b..784896936 100644 --- a/packages/profile/test/screens/notifications_screen_test.dart +++ b/packages/profile/test/screens/notifications_screen_test.dart @@ -96,7 +96,6 @@ void main() { final l10n = L10n.of(tester.element(find.byType(NotificationsScreen))); expect(find.text(l10n.profileNotifications), findsOneWidget); - expect(find.text(l10n.notificationsManagePreferences), findsOneWidget); expect(find.text(l10n.notificationsLiveClassReminders), findsOneWidget); expect(find.text(l10n.notificationsTestAssessmentAlerts), findsOneWidget); expect(find.text(l10n.notificationsAnnouncementsUpdates), findsOneWidget); @@ -182,15 +181,7 @@ void main() { await tester.tap(notificationsEntry, warnIfMissed: false); await tester.pumpAndSettle(); - final notificationsL10n = L10n.of( - tester.element(find.byType(NotificationsScreen)), - ); - expect( - find.text(notificationsL10n.notificationsManagePreferences), - findsOneWidget, - ); - - await tester.tap(find.text(notificationsL10n.curriculumBackButton)); + await tester.tap(find.byType(AppBackButton)); await tester.pumpAndSettle(); expect( find.text(profileL10n.profileAccountSettingsTitle),