From 9f4cc0eae7ac9c5c2feb49646ead052e546cfc10 Mon Sep 17 00:00:00 2001 From: Rainvisitor Date: Tue, 14 Apr 2026 09:01:05 +0800 Subject: [PATCH 01/11] feat(ui): add bottom tab navigation mode to HomePageScaffold Support Telegram-style bottom tab navigation with IndexedStack and per-tab Navigators. When `tabs` parameter is provided, the scaffold switches to tab mode with state-preserving tab switching, independent navigation stacks per tab, and responsive layout (NavigationBar on mobile, NavigationRail on tablet). Closes #178 --- apps/example/lib/pages/home_page.dart | 8 + .../lib/pages/tab_example/tab_home_page.dart | 97 ++++++++++++ .../lib/ap_common_flutter_ui.dart | 1 + .../lib/src/scaffold/home_page_scaffold.dart | 139 +++++++++++++++++- .../lib/src/scaffold/home_tab.dart | 32 ++++ 5 files changed, 273 insertions(+), 4 deletions(-) create mode 100644 apps/example/lib/pages/tab_example/tab_home_page.dart create mode 100644 packages/ap_common_flutter_ui/lib/src/scaffold/home_tab.dart diff --git a/apps/example/lib/pages/home_page.dart b/apps/example/lib/pages/home_page.dart index f0e66cdc..0416fc46 100644 --- a/apps/example/lib/pages/home_page.dart +++ b/apps/example/lib/pages/home_page.dart @@ -6,6 +6,7 @@ import 'package:ap_common_example/pages/notification_utils_page.dart'; import 'package:ap_common_example/pages/school_info_page.dart'; import 'package:ap_common_example/pages/setting_page.dart'; import 'package:ap_common_example/pages/simple_example/simple_home_page.dart'; +import 'package:ap_common_example/pages/tab_example/tab_home_page.dart'; import 'package:ap_common_example/pages/study/course_page.dart'; import 'package:ap_common_example/pages/study/score_page.dart'; import 'package:ap_common_example/pages/user_info_page.dart'; @@ -203,6 +204,13 @@ class HomePageState extends State { const SimpleHomePage(), ), ), + DrawerMenuItem( + icon: Icons.tab, + title: 'Tab Navigation Example', + onTap: () => _openPage( + const TabHomePage(), + ), + ), if (isLogin) ...[ const DrawerDivider(), DrawerMenuItem( diff --git a/apps/example/lib/pages/tab_example/tab_home_page.dart b/apps/example/lib/pages/tab_example/tab_home_page.dart new file mode 100644 index 00000000..625d91f5 --- /dev/null +++ b/apps/example/lib/pages/tab_example/tab_home_page.dart @@ -0,0 +1,97 @@ +import 'package:ap_common/ap_common.dart'; +import 'package:ap_common_example/pages/setting_page.dart'; +import 'package:ap_common_example/pages/study/course_page.dart'; +import 'package:ap_common_example/pages/study/score_page.dart'; +import 'package:flutter/material.dart'; + +/// Demonstrates [HomePageScaffold] with bottom tab navigation +/// using [IndexedStack] and per-tab [Navigator]s. +class TabHomePage extends StatelessWidget { + const TabHomePage({super.key}); + + @override + Widget build(BuildContext context) { + return HomePageScaffold( + tabs: [ + HomeTab( + icon: const Icon(ApIcon.home), + label: context.ap.home, + builder: (_) => const _HomeTabContent(), + ), + HomeTab( + icon: const Icon(ApIcon.classIcon), + label: context.ap.course, + builder: (_) => CoursePage(), + ), + HomeTab( + icon: const Icon(ApIcon.assignment), + label: context.ap.score, + builder: (_) => ScorePage(), + ), + HomeTab( + icon: const Icon(ApIcon.settings), + label: context.ap.settings, + builder: (_) => SettingPage(), + ), + ], + ); + } +} + +class _HomeTabContent extends StatelessWidget { + const _HomeTabContent(); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text(context.ap.home), + automaticallyImplyLeading: false, + ), + body: ListView.builder( + itemCount: 20, + itemBuilder: (BuildContext context, int index) { + return ListTile( + leading: CircleAvatar( + child: Text('${index + 1}'), + ), + title: Text('Item ${index + 1}'), + subtitle: const Text( + 'Tap to navigate within tab', + ), + onTap: () { + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => + _DetailPage(index: index + 1), + ), + ); + }, + ); + }, + ), + ); + } +} + +class _DetailPage extends StatelessWidget { + const _DetailPage({required this.index}); + + final int index; + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: Text('Detail #$index')), + body: Center( + child: Text( + 'Detail page for item #$index\n\n' + 'This page is inside the tab ' + 'Navigator.\n' + 'Press back to return to the list.', + textAlign: TextAlign.center, + ), + ), + ); + } +} diff --git a/packages/ap_common_flutter_ui/lib/ap_common_flutter_ui.dart b/packages/ap_common_flutter_ui/lib/ap_common_flutter_ui.dart index f8659031..8324dc57 100644 --- a/packages/ap_common_flutter_ui/lib/ap_common_flutter_ui.dart +++ b/packages/ap_common_flutter_ui/lib/ap_common_flutter_ui.dart @@ -10,6 +10,7 @@ export 'src/resources/ap_theme.dart'; export 'src/resources/resources.dart'; export 'src/scaffold/course_scaffold.dart'; export 'src/scaffold/home_page_scaffold.dart'; +export 'src/scaffold/home_tab.dart'; export 'src/scaffold/image_viewer_scaffold.dart'; export 'src/scaffold/login_scaffold.dart'; export 'src/scaffold/score_scaffold.dart'; diff --git a/packages/ap_common_flutter_ui/lib/src/scaffold/home_page_scaffold.dart b/packages/ap_common_flutter_ui/lib/src/scaffold/home_page_scaffold.dart index 3ae8cd8b..1c4d7c32 100644 --- a/packages/ap_common_flutter_ui/lib/src/scaffold/home_page_scaffold.dart +++ b/packages/ap_common_flutter_ui/lib/src/scaffold/home_page_scaffold.dart @@ -10,9 +10,9 @@ enum HomeState { loading, finish, error, empty, offline } class HomePageScaffold extends StatefulWidget { const HomePageScaffold({ super.key, - required this.state, - required this.announcements, - required this.isLogin, + this.state = HomeState.loading, + this.announcements = const [], + this.isLogin = false, this.actions, this.onTabTapped, this.bottomNavigationBarItems, @@ -25,6 +25,7 @@ class HomePageScaffold extends StatefulWidget { this.autoPlayDuration = const Duration(milliseconds: 5000), this.dashboardWidgets, this.carouselHeight, + this.tabs, }); /// Creates a [HomePageScaffold] from a [DataState>]. @@ -52,6 +53,7 @@ class HomePageScaffold extends StatefulWidget { this.autoPlayDuration = const Duration(milliseconds: 5000), this.dashboardWidgets, this.carouselHeight, + this.tabs, }) : state = dataState.when( loading: () => HomeState.loading, loaded: (_, __) => HomeState.finish, @@ -88,6 +90,12 @@ class HomePageScaffold extends StatefulWidget { /// Defaults to 260 in portrait and 180 in landscape. final double? carouselHeight; + /// Tab definitions for bottom tab navigation mode. + /// + /// When non-null, the scaffold uses [IndexedStack] with per-tab + /// [Navigator]s instead of the default drawer-based layout. + final List? tabs; + @override HomePageScaffoldState createState() => HomePageScaffoldState(); } @@ -102,10 +110,22 @@ class HomePageScaffoldState extends State { Timer? _timer; - bool get isTablet => MediaQuery.of(context).size.shortestSide >= 680; + int _currentTabIndex = 0; + List> _tabNavigatorKeys = + >[]; + + bool get isTablet => + MediaQuery.of(context).size.shortestSide >= 680; @override void initState() { + if (widget.tabs != null) { + _tabNavigatorKeys = + List>.generate( + widget.tabs!.length, + (_) => GlobalKey(), + ); + } setTimer(); super.initState(); } @@ -118,6 +138,9 @@ class HomePageScaffoldState extends State { @override Widget build(BuildContext context) { + if (widget.tabs != null && widget.tabs!.isNotEmpty) { + return _buildTabMode(); + } return PopScope( canPop: false, onPopInvokedWithResult: (bool didPop, _) { @@ -430,6 +453,114 @@ class HomePageScaffoldState extends State { } } + // ─── Tab navigation mode ──────────────────────────────── + + Widget _buildTabMode() { + return PopScope( + canPop: false, + onPopInvokedWithResult: (bool didPop, _) { + final NavigatorState? navigator = + _tabNavigatorKeys[_currentTabIndex] + .currentState; + if (navigator != null && navigator.canPop()) { + navigator.pop(); + return; + } + if (_currentTabIndex != 0) { + setState(() => _currentTabIndex = 0); + return; + } + if (Platform.isAndroid) { + _showLogoutDialog(); + } else { + SystemNavigator.pop(); + } + }, + child: ScaffoldMessenger( + key: _scaffoldMessengerKey, + child: isTablet + ? _buildTabletTabScaffold() + : _buildMobileTabScaffold(), + ), + ); + } + + Widget _buildMobileTabScaffold() { + return Scaffold( + drawer: widget.drawer, + floatingActionButton: widget.floatingActionButton, + body: _buildTabBody(), + bottomNavigationBar: NavigationBar( + elevation: 12.0, + height: 56, + indicatorColor: const Color(0x00000000), + selectedIndex: _currentTabIndex, + onDestinationSelected: (int index) { + setState(() => _currentTabIndex = index); + }, + destinations: widget.tabs! + .map( + (HomeTab tab) => NavigationDestination( + icon: tab.icon, + selectedIcon: tab.activeIcon, + label: tab.label, + ), + ) + .toList(), + ), + ); + } + + Widget _buildTabletTabScaffold() { + return Scaffold( + floatingActionButton: widget.floatingActionButton, + body: Row( + children: [ + NavigationRail( + selectedIndex: _currentTabIndex, + onDestinationSelected: (int index) { + setState(() => _currentTabIndex = index); + }, + labelType: NavigationRailLabelType.all, + destinations: widget.tabs! + .map( + (HomeTab tab) => + NavigationRailDestination( + icon: tab.icon, + selectedIcon: tab.activeIcon, + label: Text(tab.label), + ), + ) + .toList(), + ), + const VerticalDivider( + thickness: 1, + width: 1, + ), + Expanded(child: _buildTabBody()), + ], + ), + ); + } + + Widget _buildTabBody() { + return IndexedStack( + index: _currentTabIndex, + children: List.generate( + widget.tabs!.length, + (int index) => Navigator( + key: _tabNavigatorKeys[index], + onGenerateRoute: (RouteSettings settings) { + return MaterialPageRoute( + builder: widget.tabs![index].builder, + settings: settings, + ); + }, + ), + ), + ); + } + void _showLogoutDialog() { final ApLocalizations l10n = context.ap; showDialog( diff --git a/packages/ap_common_flutter_ui/lib/src/scaffold/home_tab.dart b/packages/ap_common_flutter_ui/lib/src/scaffold/home_tab.dart new file mode 100644 index 00000000..bd0955c2 --- /dev/null +++ b/packages/ap_common_flutter_ui/lib/src/scaffold/home_tab.dart @@ -0,0 +1,32 @@ +import 'package:flutter/widgets.dart'; + +/// Defines a tab for [HomePageScaffold] bottom tab navigation. +/// +/// When [HomePageScaffold.tabs] is provided, each [HomeTab] becomes +/// a destination in the bottom [NavigationBar] (mobile) or +/// [NavigationRail] (tablet). The [builder] creates the tab's root +/// page inside its own [Navigator]. +class HomeTab { + /// Creates a tab destination. + const HomeTab({ + required this.icon, + required this.label, + required this.builder, + this.activeIcon, + }); + + /// Icon for the navigation destination. + final Widget icon; + + /// Icon shown when this tab is selected. + final Widget? activeIcon; + + /// Text label for the navigation destination. + final String label; + + /// Builds the root page for this tab. + /// + /// The widget is placed inside a per-tab [Navigator], so it can + /// push sub-pages without affecting other tabs. + final WidgetBuilder builder; +} From af16c37a8970056f45b8da1bff4ffc1685653bc8 Mon Sep 17 00:00:00 2001 From: Rainvisitor Date: Tue, 14 Apr 2026 09:07:25 +0800 Subject: [PATCH 02/11] fix(example): fix const Icon and import ordering in tab example --- apps/example/lib/pages/home_page.dart | 2 +- apps/example/lib/pages/tab_example/tab_home_page.dart | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/apps/example/lib/pages/home_page.dart b/apps/example/lib/pages/home_page.dart index 0416fc46..5c71b166 100644 --- a/apps/example/lib/pages/home_page.dart +++ b/apps/example/lib/pages/home_page.dart @@ -6,9 +6,9 @@ import 'package:ap_common_example/pages/notification_utils_page.dart'; import 'package:ap_common_example/pages/school_info_page.dart'; import 'package:ap_common_example/pages/setting_page.dart'; import 'package:ap_common_example/pages/simple_example/simple_home_page.dart'; -import 'package:ap_common_example/pages/tab_example/tab_home_page.dart'; import 'package:ap_common_example/pages/study/course_page.dart'; import 'package:ap_common_example/pages/study/score_page.dart'; +import 'package:ap_common_example/pages/tab_example/tab_home_page.dart'; import 'package:ap_common_example/pages/user_info_page.dart'; import 'package:ap_common_example/res/assets.dart'; import 'package:ap_common_example/utils/app_localizations.dart'; diff --git a/apps/example/lib/pages/tab_example/tab_home_page.dart b/apps/example/lib/pages/tab_example/tab_home_page.dart index 625d91f5..dcdd8743 100644 --- a/apps/example/lib/pages/tab_example/tab_home_page.dart +++ b/apps/example/lib/pages/tab_example/tab_home_page.dart @@ -14,22 +14,22 @@ class TabHomePage extends StatelessWidget { return HomePageScaffold( tabs: [ HomeTab( - icon: const Icon(ApIcon.home), + icon: Icon(ApIcon.home), label: context.ap.home, builder: (_) => const _HomeTabContent(), ), HomeTab( - icon: const Icon(ApIcon.classIcon), + icon: Icon(ApIcon.classIcon), label: context.ap.course, builder: (_) => CoursePage(), ), HomeTab( - icon: const Icon(ApIcon.assignment), + icon: Icon(ApIcon.assignment), label: context.ap.score, builder: (_) => ScorePage(), ), HomeTab( - icon: const Icon(ApIcon.settings), + icon: Icon(ApIcon.settings), label: context.ap.settings, builder: (_) => SettingPage(), ), From 9390e96d145498e06693b89232512e716970c1f9 Mon Sep 17 00:00:00 2001 From: Rainvisitor Date: Tue, 14 Apr 2026 09:45:28 +0800 Subject: [PATCH 03/11] refactor(example): use real pages in tab navigation example Replace placeholder content with actual app pages: - Home tab: announcements list + TodayScheduleCard - Course tab: existing CoursePage - Score tab: existing ScorePage - More tab: school info, settings, about links --- .../lib/pages/tab_example/tab_home_page.dart | 235 +++++++++++++++--- 1 file changed, 197 insertions(+), 38 deletions(-) diff --git a/apps/example/lib/pages/tab_example/tab_home_page.dart b/apps/example/lib/pages/tab_example/tab_home_page.dart index dcdd8743..faef53fd 100644 --- a/apps/example/lib/pages/tab_example/tab_home_page.dart +++ b/apps/example/lib/pages/tab_example/tab_home_page.dart @@ -1,8 +1,12 @@ import 'package:ap_common/ap_common.dart'; +import 'package:ap_common_example/pages/home_page.dart'; +import 'package:ap_common_example/pages/school_info_page.dart'; import 'package:ap_common_example/pages/setting_page.dart'; import 'package:ap_common_example/pages/study/course_page.dart'; import 'package:ap_common_example/pages/study/score_page.dart'; +import 'package:ap_common_example/res/assets.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; /// Demonstrates [HomePageScaffold] with bottom tab navigation /// using [IndexedStack] and per-tab [Navigator]s. @@ -16,7 +20,7 @@ class TabHomePage extends StatelessWidget { HomeTab( icon: Icon(ApIcon.home), label: context.ap.home, - builder: (_) => const _HomeTabContent(), + builder: (_) => const _HomeTab(), ), HomeTab( icon: Icon(ApIcon.classIcon), @@ -29,17 +33,35 @@ class TabHomePage extends StatelessWidget { builder: (_) => ScorePage(), ), HomeTab( - icon: Icon(ApIcon.settings), - label: context.ap.settings, - builder: (_) => SettingPage(), + icon: Icon(Icons.more_horiz), + label: context.ap.otherInfo, + builder: (_) => const _MoreTab(), ), ], ); } } -class _HomeTabContent extends StatelessWidget { - const _HomeTabContent(); +// ─── Home Tab ───────────────────────────────────────────── + +class _HomeTab extends StatefulWidget { + const _HomeTab(); + + @override + State<_HomeTab> createState() => _HomeTabState(); +} + +class _HomeTabState extends State<_HomeTab> { + HomeState state = HomeState.loading; + List announcements = []; + CourseData? courseData; + + @override + void initState() { + super.initState(); + _getAnnouncements(); + _loadCourseData(); + } @override Widget build(BuildContext context) { @@ -48,49 +70,186 @@ class _HomeTabContent extends StatelessWidget { title: Text(context.ap.home), automaticallyImplyLeading: false, ), - body: ListView.builder( - itemCount: 20, - itemBuilder: (BuildContext context, int index) { - return ListTile( - leading: CircleAvatar( - child: Text('${index + 1}'), - ), - title: Text('Item ${index + 1}'), - subtitle: const Text( - 'Tap to navigate within tab', - ), - onTap: () { - Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => - _DetailPage(index: index + 1), + body: _buildBody(), + ); + } + + Widget _buildBody() { + switch (state) { + case HomeState.loading: + return const Center( + child: CircularProgressIndicator(), + ); + case HomeState.finish: + return _buildContent(); + case HomeState.error: + return HintContent( + icon: ApIcon.offlineBolt, + content: context.ap.somethingError, + ); + case HomeState.empty: + return HintContent( + icon: ApIcon.offlineBolt, + content: context.ap.announcementEmpty, + ); + case HomeState.offline: + return HintContent( + icon: ApIcon.offlineBolt, + content: context.ap.offlineMode, + ); + } + } + + Widget _buildContent() { + return ListView( + padding: const EdgeInsets.only(top: 8), + children: [ + Padding( + padding: const EdgeInsets.symmetric( + horizontal: 16, + ), + child: courseData != null + ? TodayScheduleCard( + courseData: courseData!, + ) + : EmptyScheduleCard( + message: context.ap.courseEmpty, ), - ); - }, - ); - }, + ), + const SizedBox(height: 16), + Padding( + padding: const EdgeInsets.symmetric( + horizontal: 16, + ), + child: Text( + context.ap.announcements, + style: Theme.of(context) + .textTheme + .titleMedium, + ), + ), + const SizedBox(height: 8), + ...announcements.map(_buildAnnouncementTile), + ], + ); + } + + Widget _buildAnnouncementTile( + Announcement announcement, + ) { + return ListTile( + leading: ClipRRect( + borderRadius: BorderRadius.circular(8), + child: SizedBox( + width: 56, + height: 56, + child: ApNetworkImage( + url: announcement.imgUrl, + ), + ), + ), + title: Text( + announcement.title, + maxLines: 2, + overflow: TextOverflow.ellipsis, ), + onTap: () { + ApUtils.pushCupertinoStyle( + context, + AnnouncementContentPage( + announcement: announcement, + ), + ); + }, + ); + } + + Future _getAnnouncements() async { + final ApiResult> result = + await AnnouncementHelper.instance + .getAnnouncements(tags: []); + switch (result) { + case ApiSuccess>( + :final List data, + ): + announcements = data; + setState(() { + state = announcements.isEmpty + ? HomeState.empty + : HomeState.finish; + }); + case ApiFailure>(): + case ApiError>(): + setState(() => state = HomeState.error); + } + } + + Future _loadCourseData() async { + final String rawString = + await rootBundle.loadString( + FileAssets.courses, ); + setState(() { + courseData = + CourseData.fromRawJson(rawString); + }); } } -class _DetailPage extends StatelessWidget { - const _DetailPage({required this.index}); +// ─── More Tab ───────────────────────────────────────────── - final int index; +class _MoreTab extends StatelessWidget { + const _MoreTab(); @override Widget build(BuildContext context) { return Scaffold( - appBar: AppBar(title: Text('Detail #$index')), - body: Center( - child: Text( - 'Detail page for item #$index\n\n' - 'This page is inside the tab ' - 'Navigator.\n' - 'Press back to return to the list.', - textAlign: TextAlign.center, - ), + appBar: AppBar( + title: Text(context.ap.otherInfo), + automaticallyImplyLeading: false, + ), + body: ListView( + children: [ + ListTile( + leading: Icon(ApIcon.info), + title: Text(context.ap.schoolInfo), + trailing: const Icon( + Icons.chevron_right, + ), + onTap: () { + ApUtils.pushCupertinoStyle( + context, + SchoolInfoPage(), + ); + }, + ), + ListTile( + leading: Icon(ApIcon.settings), + title: Text(context.ap.settings), + trailing: const Icon( + Icons.chevron_right, + ), + onTap: () { + ApUtils.pushCupertinoStyle( + context, + SettingPage(), + ); + }, + ), + ListTile( + leading: Icon(ApIcon.face), + title: Text(context.ap.about), + trailing: const Icon( + Icons.chevron_right, + ), + onTap: () { + ApUtils.pushCupertinoStyle( + context, + HomePage.aboutPage(context), + ); + }, + ), + ], ), ); } From bbaeb1d8178f4ef9fe9c02e3755084cf89afad40 Mon Sep 17 00:00:00 2001 From: Rainvisitor Date: Tue, 14 Apr 2026 10:01:52 +0800 Subject: [PATCH 04/11] style(ui): use Telegram-style bottom navigation bar - Remove elevation shadow (flat design) - Add top Divider for subtle separation - Hide indicator pill with transparent colors - Remove surface tint for clean background --- .../lib/src/scaffold/home_page_scaffold.dart | 44 ++++++++++++------- 1 file changed, 27 insertions(+), 17 deletions(-) diff --git a/packages/ap_common_flutter_ui/lib/src/scaffold/home_page_scaffold.dart b/packages/ap_common_flutter_ui/lib/src/scaffold/home_page_scaffold.dart index 1c4d7c32..ae8a6b3b 100644 --- a/packages/ap_common_flutter_ui/lib/src/scaffold/home_page_scaffold.dart +++ b/packages/ap_common_flutter_ui/lib/src/scaffold/home_page_scaffold.dart @@ -490,23 +490,33 @@ class HomePageScaffoldState extends State { drawer: widget.drawer, floatingActionButton: widget.floatingActionButton, body: _buildTabBody(), - bottomNavigationBar: NavigationBar( - elevation: 12.0, - height: 56, - indicatorColor: const Color(0x00000000), - selectedIndex: _currentTabIndex, - onDestinationSelected: (int index) { - setState(() => _currentTabIndex = index); - }, - destinations: widget.tabs! - .map( - (HomeTab tab) => NavigationDestination( - icon: tab.icon, - selectedIcon: tab.activeIcon, - label: tab.label, - ), - ) - .toList(), + bottomNavigationBar: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Divider(height: 1, thickness: 0.5), + NavigationBar( + elevation: 0, + height: 56, + indicatorColor: Colors.transparent, + surfaceTintColor: Colors.transparent, + selectedIndex: _currentTabIndex, + onDestinationSelected: (int index) { + setState( + () => _currentTabIndex = index, + ); + }, + destinations: widget.tabs! + .map( + (HomeTab tab) => + NavigationDestination( + icon: tab.icon, + selectedIcon: tab.activeIcon, + label: tab.label, + ), + ) + .toList(), + ), + ], ), ); } From 5ab2c9ec3803b8b4103fa06f679601f1a81765e3 Mon Sep 17 00:00:00 2001 From: Rainvisitor Date: Tue, 14 Apr 2026 14:28:48 +0800 Subject: [PATCH 05/11] fix(example): fix aboutPage reference and const lint --- apps/example/lib/pages/tab_example/tab_home_page.dart | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/example/lib/pages/tab_example/tab_home_page.dart b/apps/example/lib/pages/tab_example/tab_home_page.dart index faef53fd..6da80fe9 100644 --- a/apps/example/lib/pages/tab_example/tab_home_page.dart +++ b/apps/example/lib/pages/tab_example/tab_home_page.dart @@ -33,7 +33,7 @@ class TabHomePage extends StatelessWidget { builder: (_) => ScorePage(), ), HomeTab( - icon: Icon(Icons.more_horiz), + icon: const Icon(Icons.more_horiz), label: context.ap.otherInfo, builder: (_) => const _MoreTab(), ), @@ -245,7 +245,7 @@ class _MoreTab extends StatelessWidget { onTap: () { ApUtils.pushCupertinoStyle( context, - HomePage.aboutPage(context), + HomePageState.aboutPage(context), ); }, ), From 61d30d2e70b744c7405812e0146043ee3b0399f4 Mon Sep 17 00:00:00 2001 From: Rainvisitor Date: Tue, 14 Apr 2026 15:44:57 +0800 Subject: [PATCH 06/11] style(ui): floating bottom navigation bar with rounded corners Use extendBody + Container with margin, rounded corners, and subtle shadow to create a floating navigation bar effect similar to Telegram/liquid glass UX, using only Flutter built-in widgets. --- .../lib/src/scaffold/home_page_scaffold.dart | 34 +++++++++++++++---- 1 file changed, 28 insertions(+), 6 deletions(-) diff --git a/packages/ap_common_flutter_ui/lib/src/scaffold/home_page_scaffold.dart b/packages/ap_common_flutter_ui/lib/src/scaffold/home_page_scaffold.dart index ae8a6b3b..e7270f31 100644 --- a/packages/ap_common_flutter_ui/lib/src/scaffold/home_page_scaffold.dart +++ b/packages/ap_common_flutter_ui/lib/src/scaffold/home_page_scaffold.dart @@ -486,17 +486,39 @@ class HomePageScaffoldState extends State { } Widget _buildMobileTabScaffold() { + final ColorScheme colors = + Theme.of(context).colorScheme; return Scaffold( drawer: widget.drawer, floatingActionButton: widget.floatingActionButton, + extendBody: true, body: _buildTabBody(), - bottomNavigationBar: Column( - mainAxisSize: MainAxisSize.min, - children: [ - const Divider(height: 1, thickness: 0.5), - NavigationBar( + bottomNavigationBar: Container( + margin: const EdgeInsets.fromLTRB( + 16, + 0, + 16, + 12, + ), + decoration: BoxDecoration( + color: colors.surfaceContainerHighest, + borderRadius: BorderRadius.circular(28), + boxShadow: [ + BoxShadow( + color: colors.shadow.withValues( + alpha: 0.08, + ), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ], + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(28), + child: NavigationBar( elevation: 0, height: 56, + backgroundColor: Colors.transparent, indicatorColor: Colors.transparent, surfaceTintColor: Colors.transparent, selectedIndex: _currentTabIndex, @@ -516,7 +538,7 @@ class HomePageScaffoldState extends State { ) .toList(), ), - ], + ), ), ); } From 67ef72d61a8095222f96e26897c8bb80ad2709fc Mon Sep 17 00:00:00 2001 From: Rainvisitor Date: Tue, 14 Apr 2026 16:17:09 +0800 Subject: [PATCH 07/11] style(ui): frosted glass bottom nav with Telegram-style theming - BackdropFilter blur for translucent frosted glass background - Semi-transparent surfaceContainerHighest (85% opacity) - Selected: primary color icon + bold label - Unselected: onSurfaceVariant icon + normal label - Height 64, radius 24, margin 12/8 for balanced floating feel --- .../lib/src/scaffold/home_page_scaffold.dart | 131 ++++++++++++------ 1 file changed, 92 insertions(+), 39 deletions(-) diff --git a/packages/ap_common_flutter_ui/lib/src/scaffold/home_page_scaffold.dart b/packages/ap_common_flutter_ui/lib/src/scaffold/home_page_scaffold.dart index e7270f31..aed8bef4 100644 --- a/packages/ap_common_flutter_ui/lib/src/scaffold/home_page_scaffold.dart +++ b/packages/ap_common_flutter_ui/lib/src/scaffold/home_page_scaffold.dart @@ -1,5 +1,6 @@ import 'dart:async'; import 'dart:io'; +import 'dart:ui'; import 'package:ap_common_flutter_ui/ap_common_flutter_ui.dart'; import 'package:flutter/material.dart'; @@ -493,50 +494,102 @@ class HomePageScaffoldState extends State { floatingActionButton: widget.floatingActionButton, extendBody: true, body: _buildTabBody(), - bottomNavigationBar: Container( - margin: const EdgeInsets.fromLTRB( - 16, + bottomNavigationBar: Padding( + padding: const EdgeInsets.fromLTRB( + 12, 0, - 16, 12, - ), - decoration: BoxDecoration( - color: colors.surfaceContainerHighest, - borderRadius: BorderRadius.circular(28), - boxShadow: [ - BoxShadow( - color: colors.shadow.withValues( - alpha: 0.08, - ), - blurRadius: 8, - offset: const Offset(0, 2), - ), - ], + 8, ), child: ClipRRect( - borderRadius: BorderRadius.circular(28), - child: NavigationBar( - elevation: 0, - height: 56, - backgroundColor: Colors.transparent, - indicatorColor: Colors.transparent, - surfaceTintColor: Colors.transparent, - selectedIndex: _currentTabIndex, - onDestinationSelected: (int index) { - setState( - () => _currentTabIndex = index, - ); - }, - destinations: widget.tabs! - .map( - (HomeTab tab) => - NavigationDestination( - icon: tab.icon, - selectedIcon: tab.activeIcon, - label: tab.label, + borderRadius: BorderRadius.circular(24), + child: BackdropFilter( + filter: ImageFilter.blur( + sigmaX: 24, + sigmaY: 24, + ), + child: DecoratedBox( + decoration: BoxDecoration( + color: colors.surfaceContainerHighest + .withValues(alpha: 0.85), + borderRadius: + BorderRadius.circular(24), + ), + child: Theme( + data: Theme.of(context).copyWith( + navigationBarTheme: + NavigationBarThemeData( + labelTextStyle: + WidgetStateProperty + .resolveWith( + (Set states) { + final bool selected = + states.contains( + WidgetState.selected, + ); + return TextStyle( + fontSize: 12, + fontWeight: selected + ? FontWeight.w600 + : FontWeight.normal, + color: selected + ? colors.primary + : colors + .onSurfaceVariant, + ); + }, + ), + iconTheme: + WidgetStateProperty + .resolveWith( + (Set states) { + final bool selected = + states.contains( + WidgetState.selected, + ); + return IconThemeData( + size: 24, + color: selected + ? colors.primary + : colors + .onSurfaceVariant, + ); + }, + ), ), - ) - .toList(), + ), + child: NavigationBar( + elevation: 0, + height: 64, + backgroundColor: + Colors.transparent, + indicatorColor: + Colors.transparent, + surfaceTintColor: + Colors.transparent, + selectedIndex: + _currentTabIndex, + onDestinationSelected: + (int index) { + setState( + () => + _currentTabIndex = index, + ); + }, + destinations: widget.tabs! + .map( + (HomeTab tab) => + NavigationDestination( + icon: tab.icon, + selectedIcon: + tab.activeIcon, + label: tab.label, + ), + ) + .toList(), + ), + ), + ), ), ), ), From f0b14478a0b5291d34d2c14908984a5105064057 Mon Sep 17 00:00:00 2001 From: Rainvisitor Date: Tue, 14 Apr 2026 16:37:46 +0800 Subject: [PATCH 08/11] style(ui): shrink-wrap floating nav bar to fit tab items Replace NavigationBar with custom Row(mainAxisSize: min) so the bar width fits its content instead of stretching full screen. Colors read from ColorScheme for future theme management. --- .../lib/src/scaffold/home_page_scaffold.dart | 189 +++++++++--------- 1 file changed, 93 insertions(+), 96 deletions(-) diff --git a/packages/ap_common_flutter_ui/lib/src/scaffold/home_page_scaffold.dart b/packages/ap_common_flutter_ui/lib/src/scaffold/home_page_scaffold.dart index aed8bef4..6d61cb18 100644 --- a/packages/ap_common_flutter_ui/lib/src/scaffold/home_page_scaffold.dart +++ b/packages/ap_common_flutter_ui/lib/src/scaffold/home_page_scaffold.dart @@ -495,102 +495,9 @@ class HomePageScaffoldState extends State { extendBody: true, body: _buildTabBody(), bottomNavigationBar: Padding( - padding: const EdgeInsets.fromLTRB( - 12, - 0, - 12, - 8, - ), - child: ClipRRect( - borderRadius: BorderRadius.circular(24), - child: BackdropFilter( - filter: ImageFilter.blur( - sigmaX: 24, - sigmaY: 24, - ), - child: DecoratedBox( - decoration: BoxDecoration( - color: colors.surfaceContainerHighest - .withValues(alpha: 0.85), - borderRadius: - BorderRadius.circular(24), - ), - child: Theme( - data: Theme.of(context).copyWith( - navigationBarTheme: - NavigationBarThemeData( - labelTextStyle: - WidgetStateProperty - .resolveWith( - (Set states) { - final bool selected = - states.contains( - WidgetState.selected, - ); - return TextStyle( - fontSize: 12, - fontWeight: selected - ? FontWeight.w600 - : FontWeight.normal, - color: selected - ? colors.primary - : colors - .onSurfaceVariant, - ); - }, - ), - iconTheme: - WidgetStateProperty - .resolveWith( - (Set states) { - final bool selected = - states.contains( - WidgetState.selected, - ); - return IconThemeData( - size: 24, - color: selected - ? colors.primary - : colors - .onSurfaceVariant, - ); - }, - ), - ), - ), - child: NavigationBar( - elevation: 0, - height: 64, - backgroundColor: - Colors.transparent, - indicatorColor: - Colors.transparent, - surfaceTintColor: - Colors.transparent, - selectedIndex: - _currentTabIndex, - onDestinationSelected: - (int index) { - setState( - () => - _currentTabIndex = index, - ); - }, - destinations: widget.tabs! - .map( - (HomeTab tab) => - NavigationDestination( - icon: tab.icon, - selectedIcon: - tab.activeIcon, - label: tab.label, - ), - ) - .toList(), - ), - ), - ), - ), + padding: const EdgeInsets.only(bottom: 8), + child: Center( + child: _buildFloatingNavBar(colors), ), ), ); @@ -628,6 +535,96 @@ class HomePageScaffoldState extends State { ); } + Widget _buildFloatingNavBar(ColorScheme colors) { + return ClipRRect( + borderRadius: BorderRadius.circular(20), + child: BackdropFilter( + filter: ImageFilter.blur( + sigmaX: 24, + sigmaY: 24, + ), + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 4, + vertical: 6, + ), + decoration: BoxDecoration( + color: colors.surfaceContainerHighest + .withValues(alpha: 0.85), + borderRadius: + BorderRadius.circular(20), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: _buildNavItems(colors), + ), + ), + ), + ); + } + + List _buildNavItems( + ColorScheme colors, + ) { + return widget.tabs! + .asMap() + .entries + .map( + (MapEntry entry) { + final int index = entry.key; + final HomeTab tab = entry.value; + final bool selected = + index == _currentTabIndex; + return GestureDetector( + onTap: () => setState( + () => _currentTabIndex = index, + ), + behavior: HitTestBehavior.opaque, + child: Padding( + padding: + const EdgeInsets.symmetric( + horizontal: 16, + vertical: 4, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + IconTheme( + data: IconThemeData( + size: 24, + color: selected + ? colors.primary + : colors + .onSurfaceVariant, + ), + child: selected + ? (tab.activeIcon ?? + tab.icon) + : tab.icon, + ), + const SizedBox(height: 2), + Text( + tab.label, + style: TextStyle( + fontSize: 11, + fontWeight: selected + ? FontWeight.w600 + : FontWeight.normal, + color: selected + ? colors.primary + : colors + .onSurfaceVariant, + ), + ), + ], + ), + ), + ); + }, + ) + .toList(); + } + Widget _buildTabBody() { return IndexedStack( index: _currentTabIndex, From c2b32b5286f759e3da62e1ede3deb69bd9012ef3 Mon Sep 17 00:00:00 2001 From: Rainvisitor Date: Sat, 18 Apr 2026 14:39:26 +0800 Subject: [PATCH 09/11] feat(ui): expand HomePageScaffold tab bar with iOS 26 Liquid Glass UX patterns --- .../lib/pages/tab_example/tab_home_page.dart | 21 +- .../lib/src/scaffold/home_page_scaffold.dart | 450 ++++++++++++++---- .../lib/src/scaffold/home_tab.dart | 16 +- 3 files changed, 368 insertions(+), 119 deletions(-) diff --git a/apps/example/lib/pages/tab_example/tab_home_page.dart b/apps/example/lib/pages/tab_example/tab_home_page.dart index 6da80fe9..625fb38d 100644 --- a/apps/example/lib/pages/tab_example/tab_home_page.dart +++ b/apps/example/lib/pages/tab_example/tab_home_page.dart @@ -123,9 +123,7 @@ class _HomeTabState extends State<_HomeTab> { ), child: Text( context.ap.announcements, - style: Theme.of(context) - .textTheme - .titleMedium, + style: Theme.of(context).textTheme.titleMedium, ), ), const SizedBox(height: 8), @@ -166,17 +164,14 @@ class _HomeTabState extends State<_HomeTab> { Future _getAnnouncements() async { final ApiResult> result = - await AnnouncementHelper.instance - .getAnnouncements(tags: []); + await AnnouncementHelper.instance.getAnnouncements(tags: []); switch (result) { case ApiSuccess>( - :final List data, - ): + :final List data, + ): announcements = data; setState(() { - state = announcements.isEmpty - ? HomeState.empty - : HomeState.finish; + state = announcements.isEmpty ? HomeState.empty : HomeState.finish; }); case ApiFailure>(): case ApiError>(): @@ -185,13 +180,11 @@ class _HomeTabState extends State<_HomeTab> { } Future _loadCourseData() async { - final String rawString = - await rootBundle.loadString( + final String rawString = await rootBundle.loadString( FileAssets.courses, ); setState(() { - courseData = - CourseData.fromRawJson(rawString); + courseData = CourseData.fromRawJson(rawString); }); } } diff --git a/packages/ap_common_flutter_ui/lib/src/scaffold/home_page_scaffold.dart b/packages/ap_common_flutter_ui/lib/src/scaffold/home_page_scaffold.dart index 6d61cb18..409fc2c4 100644 --- a/packages/ap_common_flutter_ui/lib/src/scaffold/home_page_scaffold.dart +++ b/packages/ap_common_flutter_ui/lib/src/scaffold/home_page_scaffold.dart @@ -8,6 +8,10 @@ import 'package:flutter/services.dart'; enum HomeState { loading, finish, error, empty, offline } +/// Equal width per tab item in the floating tab bar. Sized to fit +/// 4-character CJK labels comfortably. +const double _kTabItemWidth = 64.0; + class HomePageScaffold extends StatefulWidget { const HomePageScaffold({ super.key, @@ -27,6 +31,9 @@ class HomePageScaffold extends StatefulWidget { this.dashboardWidgets, this.carouselHeight, this.tabs, + this.enableHaptics = true, + this.showDrawerOnMobileWhenTabbed = false, + this.minimizeTabBarOnScroll = true, }); /// Creates a [HomePageScaffold] from a [DataState>]. @@ -55,6 +62,9 @@ class HomePageScaffold extends StatefulWidget { this.dashboardWidgets, this.carouselHeight, this.tabs, + this.enableHaptics = true, + this.showDrawerOnMobileWhenTabbed = false, + this.minimizeTabBarOnScroll = true, }) : state = dataState.when( loading: () => HomeState.loading, loaded: (_, __) => HomeState.finish, @@ -97,6 +107,21 @@ class HomePageScaffold extends StatefulWidget { /// [Navigator]s instead of the default drawer-based layout. final List? tabs; + /// Play a haptic tick when switching tabs. Default: true. + final bool enableHaptics; + + /// Show the drawer on mobile while in tabs mode. + /// + /// Default: false. Modern tabbed apps (Telegram, Instagram) hide + /// the drawer on mobile in favour of tab-only navigation; the drawer + /// is still shown on tablet as a sidebar. + final bool showDrawerOnMobileWhenTabbed; + + /// Slide the floating tab bar out of view on downward scroll and + /// bring it back on upward scroll (iOS 26 tab bar minimize). + /// Default: true. + final bool minimizeTabBarOnScroll; + @override HomePageScaffoldState createState() => HomePageScaffoldState(); } @@ -114,26 +139,70 @@ class HomePageScaffoldState extends State { int _currentTabIndex = 0; List> _tabNavigatorKeys = >[]; + List _tabScrollControllers = []; + List _tabNavigatorObservers = []; + + bool _navBarVisible = true; + double _scrollBuffer = 0.0; + + /// Incremented when any tab's Navigator has an active [PopupRoute] + /// (modal bottom sheet, dialog, popup menu). Hides the floating tab + /// bar so it doesn't overlap the modal content. + int _popupRouteDepth = 0; - bool get isTablet => - MediaQuery.of(context).size.shortestSide >= 680; + /// Null means follow the adaptive default (expanded when width >= 1200). + bool? _railExtendedOverride; + + bool get isTablet => MediaQuery.of(context).size.shortestSide >= 680; + + bool get _isRailExtended { + if (_railExtendedOverride != null) return _railExtendedOverride!; + return MediaQuery.of(context).size.width >= 1200; + } + + bool get _navBarEffectivelyVisible => _navBarVisible && _popupRouteDepth == 0; @override void initState() { if (widget.tabs != null) { - _tabNavigatorKeys = - List>.generate( + _tabNavigatorKeys = List>.generate( widget.tabs!.length, (_) => GlobalKey(), ); + _tabScrollControllers = List.generate( + widget.tabs!.length, + (_) => ScrollController(), + ); + _tabNavigatorObservers = List.generate( + widget.tabs!.length, + (_) => _PopupRouteAwareObserver( + onPopupPushed: _onPopupPushed, + onPopupRemoved: _onPopupRemoved, + ), + ); } setTimer(); super.initState(); } + void _onPopupPushed() { + if (!mounted) return; + setState(() => _popupRouteDepth++); + } + + void _onPopupRemoved() { + if (!mounted) return; + setState(() { + _popupRouteDepth = (_popupRouteDepth - 1).clamp(0, 1 << 20); + }); + } + @override void dispose() { _timer?.cancel(); + for (final ScrollController controller in _tabScrollControllers) { + controller.dispose(); + } super.dispose(); } @@ -461,8 +530,7 @@ class HomePageScaffoldState extends State { canPop: false, onPopInvokedWithResult: (bool didPop, _) { final NavigatorState? navigator = - _tabNavigatorKeys[_currentTabIndex] - .currentState; + _tabNavigatorKeys[_currentTabIndex].currentState; if (navigator != null && navigator.canPop()) { navigator.pop(); return; @@ -479,45 +547,80 @@ class HomePageScaffoldState extends State { }, child: ScaffoldMessenger( key: _scaffoldMessengerKey, - child: isTablet - ? _buildTabletTabScaffold() - : _buildMobileTabScaffold(), + child: isTablet ? _buildTabletTabScaffold() : _buildMobileTabScaffold(), ), ); } Widget _buildMobileTabScaffold() { - final ColorScheme colors = - Theme.of(context).colorScheme; + final ColorScheme colors = Theme.of(context).colorScheme; return Scaffold( - drawer: widget.drawer, + drawer: widget.showDrawerOnMobileWhenTabbed ? widget.drawer : null, floatingActionButton: widget.floatingActionButton, extendBody: true, - body: _buildTabBody(), - bottomNavigationBar: Padding( - padding: const EdgeInsets.only(bottom: 8), - child: Center( - child: _buildFloatingNavBar(colors), + // Body keeps the original MediaQuery so an inner Scaffold's + // FloatingActionButton stays at the screen's bottom-right, sitting + // beside the tab bar at roughly the same baseline. This mirrors the + // iOS 26 Liquid Glass pattern of two floating pills (navigation + + // trailing action) side-by-side. Modal sheets are handled + // separately by the PopupRoute observer below. + body: Stack( + children: [ + _buildTabBody(), + _buildBottomScrim(colors), + ], + ), + bottomNavigationBar: AnimatedSlide( + offset: _navBarEffectivelyVisible ? Offset.zero : const Offset(0, 2), + duration: const Duration(milliseconds: 250), + curve: Curves.easeOutCubic, + child: AnimatedOpacity( + opacity: _navBarEffectivelyVisible ? 1.0 : 0.0, + duration: const Duration(milliseconds: 200), + child: SafeArea( + top: false, + // Match the inner Scaffold's FloatingActionButton baseline + // (kFloatingActionButtonMargin = 16) so the tab bar pill and + // the trailing FAB sit on the same horizontal line. + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 16), + child: Align( + alignment: AlignmentDirectional.bottomStart, + heightFactor: 1, + child: _buildFloatingNavBar(colors), + ), + ), + ), ), ), ); } Widget _buildTabletTabScaffold() { + final bool extended = _isRailExtended; return Scaffold( floatingActionButton: widget.floatingActionButton, body: Row( children: [ NavigationRail( selectedIndex: _currentTabIndex, - onDestinationSelected: (int index) { - setState(() => _currentTabIndex = index); - }, - labelType: NavigationRailLabelType.all, + onDestinationSelected: _onTabSelected, + extended: extended, + labelType: extended + ? NavigationRailLabelType.none + : NavigationRailLabelType.all, + leading: IconButton( + tooltip: extended ? 'Collapse sidebar' : 'Expand sidebar', + icon: Icon( + extended ? Icons.menu_open : Icons.menu, + ), + onPressed: () { + setState(() => _railExtendedOverride = !extended); + }, + ), destinations: widget.tabs! .map( - (HomeTab tab) => - NavigationRailDestination( + (HomeTab tab) => NavigationRailDestination( icon: tab.icon, selectedIcon: tab.activeIcon, label: Text(tab.label), @@ -535,24 +638,47 @@ class HomePageScaffoldState extends State { ); } + /// A gradient scrim layered behind the floating tab bar (and any + /// trailing FAB) so they remain readable over scrolling content. + /// Mirrors the iOS Music / Maps pattern where the tab area fades to + /// a darker tone at the bottom of the screen. + Widget _buildBottomScrim(ColorScheme colors) { + return Positioned( + left: 0, + right: 0, + bottom: 0, + height: 160, + child: IgnorePointer( + child: AnimatedOpacity( + opacity: _navBarEffectivelyVisible ? 1.0 : 0.0, + duration: const Duration(milliseconds: 250), + child: DecoratedBox( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + colors.surface.withValues(alpha: 0), + colors.surface.withValues(alpha: 0.55), + ], + ), + ), + ), + ), + ), + ); + } + Widget _buildFloatingNavBar(ColorScheme colors) { return ClipRRect( - borderRadius: BorderRadius.circular(20), + borderRadius: BorderRadius.circular(22), child: BackdropFilter( - filter: ImageFilter.blur( - sigmaX: 24, - sigmaY: 24, - ), + filter: ImageFilter.blur(sigmaX: 24, sigmaY: 24), child: Container( - padding: const EdgeInsets.symmetric( - horizontal: 4, - vertical: 6, - ), + padding: const EdgeInsets.symmetric(horizontal: 3, vertical: 7), decoration: BoxDecoration( - color: colors.surfaceContainerHighest - .withValues(alpha: 0.85), - borderRadius: - BorderRadius.circular(20), + color: colors.surfaceContainerHighest.withValues(alpha: 0.85), + borderRadius: BorderRadius.circular(22), ), child: Row( mainAxisSize: MainAxisSize.min, @@ -566,83 +692,167 @@ class HomePageScaffoldState extends State { List _buildNavItems( ColorScheme colors, ) { - return widget.tabs! - .asMap() - .entries - .map( - (MapEntry entry) { - final int index = entry.key; - final HomeTab tab = entry.value; - final bool selected = - index == _currentTabIndex; - return GestureDetector( - onTap: () => setState( - () => _currentTabIndex = index, + return widget.tabs!.asMap().entries.map( + (MapEntry entry) { + final int index = entry.key; + final HomeTab tab = entry.value; + final bool selected = index == _currentTabIndex; + return GestureDetector( + onTap: () => _onTabSelected(index), + behavior: HitTestBehavior.opaque, + child: tab.role == HomeTabRole.search + ? _buildSearchNavItem(tab, selected, colors) + : _buildStandardNavItem(tab, selected, colors), + ); + }, + ).toList(); + } + + Widget _buildStandardNavItem( + HomeTab tab, + bool selected, + ColorScheme colors, + ) { + return SizedBox( + width: _kTabItemWidth, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 3), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + IconTheme( + data: IconThemeData( + size: 22, + color: selected ? colors.primary : colors.onSurfaceVariant, ), - behavior: HitTestBehavior.opaque, - child: Padding( - padding: - const EdgeInsets.symmetric( - horizontal: 16, - vertical: 4, - ), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - IconTheme( - data: IconThemeData( - size: 24, - color: selected - ? colors.primary - : colors - .onSurfaceVariant, - ), - child: selected - ? (tab.activeIcon ?? - tab.icon) - : tab.icon, - ), - const SizedBox(height: 2), - Text( - tab.label, - style: TextStyle( - fontSize: 11, - fontWeight: selected - ? FontWeight.w600 - : FontWeight.normal, - color: selected - ? colors.primary - : colors - .onSurfaceVariant, - ), - ), - ], - ), + child: selected ? (tab.activeIcon ?? tab.icon) : tab.icon, + ), + const SizedBox(height: 2), + Text( + tab.label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 11, + fontWeight: selected ? FontWeight.w600 : FontWeight.normal, + color: selected ? colors.primary : colors.onSurfaceVariant, + ), + ), + ], + ), + ), + ); + } + + Widget _buildSearchNavItem( + HomeTab tab, + bool selected, + ColorScheme colors, + ) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration( + color: (selected ? colors.primary : colors.onSurfaceVariant) + .withValues(alpha: selected ? 0.15 : 0.08), + borderRadius: BorderRadius.circular(20), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconTheme( + data: IconThemeData( + size: 18, + color: selected ? colors.primary : colors.onSurfaceVariant, ), - ); - }, - ) - .toList(); + child: selected ? (tab.activeIcon ?? tab.icon) : tab.icon, + ), + const SizedBox(width: 6), + Text( + tab.label, + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w500, + color: selected ? colors.primary : colors.onSurfaceVariant, + ), + ), + ], + ), + ), + ); } Widget _buildTabBody() { - return IndexedStack( - index: _currentTabIndex, - children: List.generate( - widget.tabs!.length, - (int index) => Navigator( - key: _tabNavigatorKeys[index], - onGenerateRoute: (RouteSettings settings) { - return MaterialPageRoute( - builder: widget.tabs![index].builder, - settings: settings, - ); - }, + return NotificationListener( + onNotification: _onScrollNotification, + child: IndexedStack( + index: _currentTabIndex, + children: List.generate( + widget.tabs!.length, + (int index) => PrimaryScrollController( + controller: _tabScrollControllers[index], + child: Navigator( + key: _tabNavigatorKeys[index], + observers: [_tabNavigatorObservers[index]], + onGenerateRoute: (RouteSettings settings) { + return MaterialPageRoute( + builder: widget.tabs![index].builder, + settings: settings, + ); + }, + ), + ), ), ), ); } + Future _onTabSelected(int index) async { + if (widget.enableHaptics) { + unawaited(HapticFeedback.selectionClick()); + } + if (index == _currentTabIndex) { + final NavigatorState? nav = _tabNavigatorKeys[index].currentState; + if (nav != null && nav.canPop()) { + nav.popUntil((Route route) => route.isFirst); + return; + } + final ScrollController controller = _tabScrollControllers[index]; + if (controller.hasClients && controller.offset > 0) { + unawaited( + controller.animateTo( + 0, + duration: const Duration(milliseconds: 300), + curve: Curves.easeOutCubic, + ), + ); + } + return; + } + setState(() => _currentTabIndex = index); + } + + bool _onScrollNotification(ScrollNotification notification) { + if (!widget.minimizeTabBarOnScroll) return false; + if (notification.metrics.axis != Axis.vertical) return false; + if (notification is ScrollUpdateNotification) { + final double delta = notification.scrollDelta ?? 0.0; + _scrollBuffer += delta; + if (_scrollBuffer > 24 && _navBarVisible) { + setState(() => _navBarVisible = false); + _scrollBuffer = 0; + } else if (_scrollBuffer < -24 && !_navBarVisible) { + setState(() => _navBarVisible = true); + _scrollBuffer = 0; + } + } else if (notification is ScrollEndNotification) { + _scrollBuffer = 0; + } + return false; + } + void _showLogoutDialog() { final ApLocalizations l10n = context.ap; showDialog( @@ -697,3 +907,37 @@ class HomePageScaffoldState extends State { ); } } + +/// Notifies [HomePageScaffold] when a [PopupRoute] (modal bottom sheet, +/// dialog, popup menu) is pushed onto or removed from a tab's Navigator, +/// so the floating tab bar can be hidden while the modal is visible. +class _PopupRouteAwareObserver extends NavigatorObserver { + _PopupRouteAwareObserver({ + required this.onPopupPushed, + required this.onPopupRemoved, + }); + + final VoidCallback onPopupPushed; + final VoidCallback onPopupRemoved; + + @override + void didPush(Route route, Route? previousRoute) { + if (route is PopupRoute) { + onPopupPushed(); + } + } + + @override + void didPop(Route route, Route? previousRoute) { + if (route is PopupRoute) { + onPopupRemoved(); + } + } + + @override + void didRemove(Route route, Route? previousRoute) { + if (route is PopupRoute) { + onPopupRemoved(); + } + } +} diff --git a/packages/ap_common_flutter_ui/lib/src/scaffold/home_tab.dart b/packages/ap_common_flutter_ui/lib/src/scaffold/home_tab.dart index bd0955c2..35750788 100644 --- a/packages/ap_common_flutter_ui/lib/src/scaffold/home_tab.dart +++ b/packages/ap_common_flutter_ui/lib/src/scaffold/home_tab.dart @@ -1,9 +1,16 @@ import 'package:flutter/widgets.dart'; +/// Role hint for a [HomeTab], used by [HomePageScaffold] to pick +/// the right visual treatment. +/// +/// Mirrors the iOS 26 tab role concept. `standard` tabs render as +/// icon + label; `search` tabs render as a search pill. +enum HomeTabRole { standard, search } + /// Defines a tab for [HomePageScaffold] bottom tab navigation. /// /// When [HomePageScaffold.tabs] is provided, each [HomeTab] becomes -/// a destination in the bottom [NavigationBar] (mobile) or +/// a destination in the bottom navigation bar (mobile) or /// [NavigationRail] (tablet). The [builder] creates the tab's root /// page inside its own [Navigator]. class HomeTab { @@ -13,6 +20,7 @@ class HomeTab { required this.label, required this.builder, this.activeIcon, + this.role = HomeTabRole.standard, }); /// Icon for the navigation destination. @@ -21,7 +29,8 @@ class HomeTab { /// Icon shown when this tab is selected. final Widget? activeIcon; - /// Text label for the navigation destination. + /// Text label for the navigation destination. For [HomeTabRole.search] + /// tabs, this is also used as the search pill placeholder. final String label; /// Builds the root page for this tab. @@ -29,4 +38,7 @@ class HomeTab { /// The widget is placed inside a per-tab [Navigator], so it can /// push sub-pages without affecting other tabs. final WidgetBuilder builder; + + /// Role hint that influences how this tab is rendered. + final HomeTabRole role; } From 8eae8858fc48007cdcccb0f9ae4635d180c6ebf0 Mon Sep 17 00:00:00 2001 From: Rainvisitor Date: Sat, 18 Apr 2026 14:39:29 +0800 Subject: [PATCH 10/11] fix(ui): reserve bottom space in CourseScaffold and ScoreScaffold for floating tab bar --- .../lib/src/scaffold/course_scaffold.dart | 6 ++++-- .../lib/src/scaffold/score_scaffold.dart | 20 +++++++------------ 2 files changed, 11 insertions(+), 15 deletions(-) diff --git a/packages/ap_common_flutter_ui/lib/src/scaffold/course_scaffold.dart b/packages/ap_common_flutter_ui/lib/src/scaffold/course_scaffold.dart index 12fdf9a8..1463fe8a 100644 --- a/packages/ap_common_flutter_ui/lib/src/scaffold/course_scaffold.dart +++ b/packages/ap_common_flutter_ui/lib/src/scaffold/course_scaffold.dart @@ -474,6 +474,9 @@ class CourseScaffoldState extends State { child: const Icon(Icons.search), ), ], + SizedBox( + height: MediaQuery.of(context).viewPadding.bottom + 28.0, + ), ], ), ), @@ -646,8 +649,7 @@ class CourseScaffoldState extends State { } final double pixelRatio = MediaQuery.devicePixelRatioOf(context).clamp(2.0, 4.0); - final ui.Image image = - await boundary.toImage(pixelRatio: pixelRatio); + final ui.Image image = await boundary.toImage(pixelRatio: pixelRatio); final ByteData? byteData = await image.toByteData(format: ui.ImageByteFormat.png); final DateTime now = DateTime.now(); diff --git a/packages/ap_common_flutter_ui/lib/src/scaffold/score_scaffold.dart b/packages/ap_common_flutter_ui/lib/src/scaffold/score_scaffold.dart index c135874b..48313417 100644 --- a/packages/ap_common_flutter_ui/lib/src/scaffold/score_scaffold.dart +++ b/packages/ap_common_flutter_ui/lib/src/scaffold/score_scaffold.dart @@ -210,6 +210,7 @@ class ScoreScaffoldState extends State { child: const Icon(Icons.search), ), ], + SizedBox(height: MediaQuery.of(context).viewPadding.bottom + 28.0), ], ), ), @@ -646,8 +647,7 @@ class _ScoreListTab extends StatelessWidget { Color scoreColor, ) { final String raw = _effectiveScoreStr(score) ?? '-'; - final bool isNumeric = - scoreData.scoreType == ScoreType.numeric; + final bool isNumeric = scoreData.scoreType == ScoreType.numeric; if (scoreValue == null) { // Cannot parse at all — show raw string @@ -956,14 +956,12 @@ class ScoreAnalysis { late List _gradePoints; late int _totalSubjects; - bool get isGradePoint => - scoreData.scoreType == ScoreType.gradePoint; + bool get isGradePoint => scoreData.scoreType == ScoreType.gradePoint; /// Whether a numeric score value is considered passing. bool isPassing(double scoreValue) { if (isGradePoint) { - return scoreToGradePoint(scoreValue) >= - scoreData.passingGradePoint; + return scoreToGradePoint(scoreValue) >= scoreData.passingGradePoint; } return scoreValue >= scoreData.passingScore; } @@ -1087,9 +1085,7 @@ class ScoreAnalysis { _ScoreListTab._effectiveScoreStr(score), ); final double? unit = double.tryParse(score.units); - if (scoreValue != null && - isPassing(scoreValue) && - unit != null) { + if (scoreValue != null && isPassing(scoreValue) && unit != null) { credits += unit; } } @@ -1101,12 +1097,10 @@ class ScoreAnalysis { /// Returns the effective score string for a [Score], preferring /// [Score.semesterScore] and falling back to [Score.finalScore]. static String? effectiveScoreStr(Score score) { - if (score.semesterScore != null && - score.semesterScore!.isNotEmpty) { + if (score.semesterScore != null && score.semesterScore!.isNotEmpty) { return score.semesterScore; } - if (score.finalScore != null && - score.finalScore!.isNotEmpty) { + if (score.finalScore != null && score.finalScore!.isNotEmpty) { return score.finalScore; } return null; From b0599b9787737bf5f913e2ae04ca32199227b536 Mon Sep 17 00:00:00 2001 From: Rainvisitor Date: Sat, 18 Apr 2026 14:39:33 +0800 Subject: [PATCH 11/11] chore(example): regenerate plugin registrants and refresh pubspec.lock --- .../Flutter/GeneratedPluginRegistrant.swift | 4 ++-- .../flutter/generated_plugin_registrant.cc | 3 +++ .../windows/flutter/generated_plugins.cmake | 1 + pubspec.lock | 16 ++++++++-------- 4 files changed, 14 insertions(+), 10 deletions(-) diff --git a/apps/example/macos/Flutter/GeneratedPluginRegistrant.swift b/apps/example/macos/Flutter/GeneratedPluginRegistrant.swift index 5ad4407a..41fac6b7 100644 --- a/apps/example/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/apps/example/macos/Flutter/GeneratedPluginRegistrant.swift @@ -8,10 +8,10 @@ import Foundation import file_saver import file_selector_macos import flutter_local_notifications +import gal import google_sign_in_ios import in_app_review import package_info_plus -import photo_manager import printing import share_plus import shared_preferences_foundation @@ -23,10 +23,10 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { FileSaverPlugin.register(with: registry.registrar(forPlugin: "FileSaverPlugin")) FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin")) FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin")) + GalPlugin.register(with: registry.registrar(forPlugin: "GalPlugin")) FLTGoogleSignInPlugin.register(with: registry.registrar(forPlugin: "FLTGoogleSignInPlugin")) InAppReviewPlugin.register(with: registry.registrar(forPlugin: "InAppReviewPlugin")) FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin")) - PhotoManagerPlugin.register(with: registry.registrar(forPlugin: "PhotoManagerPlugin")) PrintingPlugin.register(with: registry.registrar(forPlugin: "PrintingPlugin")) SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) diff --git a/apps/example/windows/flutter/generated_plugin_registrant.cc b/apps/example/windows/flutter/generated_plugin_registrant.cc index cd55fe34..ee866391 100644 --- a/apps/example/windows/flutter/generated_plugin_registrant.cc +++ b/apps/example/windows/flutter/generated_plugin_registrant.cc @@ -8,6 +8,7 @@ #include #include +#include #include #include #include @@ -17,6 +18,8 @@ void RegisterPlugins(flutter::PluginRegistry* registry) { registry->GetRegistrarForPlugin("FileSaverPlugin")); FileSelectorWindowsRegisterWithRegistrar( registry->GetRegistrarForPlugin("FileSelectorWindows")); + GalPluginCApiRegisterWithRegistrar( + registry->GetRegistrarForPlugin("GalPluginCApi")); PrintingPluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("PrintingPlugin")); SharePlusWindowsPluginCApiRegisterWithRegistrar( diff --git a/apps/example/windows/flutter/generated_plugins.cmake b/apps/example/windows/flutter/generated_plugins.cmake index 802c4136..37c25929 100644 --- a/apps/example/windows/flutter/generated_plugins.cmake +++ b/apps/example/windows/flutter/generated_plugins.cmake @@ -5,6 +5,7 @@ list(APPEND FLUTTER_PLUGIN_LIST file_saver file_selector_windows + gal printing share_plus url_launcher_windows diff --git a/pubspec.lock b/pubspec.lock index d33de1cc..f8d63747 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -741,6 +741,14 @@ packages: url: "https://pub.dev" source: hosted version: "4.0.0" + gal: + dependency: transitive + description: + name: gal + sha256: "969598f986789127fd407a750413249e1352116d4c2be66e81837ffeeaafdfee" + url: "https://pub.dev" + source: hosted + version: "2.3.2" glob: dependency: transitive description: @@ -1269,14 +1277,6 @@ packages: url: "https://pub.dev" source: hosted version: "7.0.2" - photo_manager: - dependency: transitive - description: - name: photo_manager - sha256: fb3bc8ea653370f88742b3baa304700107c83d12748aa58b2b9f2ed3ef15e6c2 - url: "https://pub.dev" - source: hosted - version: "3.9.0" photo_view: dependency: transitive description: