diff --git a/apps/example/lib/pages/home_page.dart b/apps/example/lib/pages/home_page.dart index f0e66cdc..5c71b166 100644 --- a/apps/example/lib/pages/home_page.dart +++ b/apps/example/lib/pages/home_page.dart @@ -8,6 +8,7 @@ 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/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'; @@ -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..625fb38d --- /dev/null +++ b/apps/example/lib/pages/tab_example/tab_home_page.dart @@ -0,0 +1,249 @@ +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. +class TabHomePage extends StatelessWidget { + const TabHomePage({super.key}); + + @override + Widget build(BuildContext context) { + return HomePageScaffold( + tabs: [ + HomeTab( + icon: Icon(ApIcon.home), + label: context.ap.home, + builder: (_) => const _HomeTab(), + ), + HomeTab( + icon: Icon(ApIcon.classIcon), + label: context.ap.course, + builder: (_) => CoursePage(), + ), + HomeTab( + icon: Icon(ApIcon.assignment), + label: context.ap.score, + builder: (_) => ScorePage(), + ), + HomeTab( + icon: const Icon(Icons.more_horiz), + label: context.ap.otherInfo, + builder: (_) => const _MoreTab(), + ), + ], + ); + } +} + +// ─── 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) { + return Scaffold( + appBar: AppBar( + title: Text(context.ap.home), + automaticallyImplyLeading: false, + ), + 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); + }); + } +} + +// ─── More Tab ───────────────────────────────────────────── + +class _MoreTab extends StatelessWidget { + const _MoreTab(); + + @override + Widget build(BuildContext context) { + return Scaffold( + 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, + HomePageState.aboutPage(context), + ); + }, + ), + ], + ), + ); + } +} 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/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/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/home_page_scaffold.dart b/packages/ap_common_flutter_ui/lib/src/scaffold/home_page_scaffold.dart index 3ae8cd8b..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 @@ -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'; @@ -7,12 +8,16 @@ 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, - 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 +30,10 @@ class HomePageScaffold extends StatefulWidget { this.autoPlayDuration = const Duration(milliseconds: 5000), this.dashboardWidgets, this.carouselHeight, + this.tabs, + this.enableHaptics = true, + this.showDrawerOnMobileWhenTabbed = false, + this.minimizeTabBarOnScroll = true, }); /// Creates a [HomePageScaffold] from a [DataState>]. @@ -52,6 +61,10 @@ class HomePageScaffold extends StatefulWidget { this.autoPlayDuration = const Duration(milliseconds: 5000), this.dashboardWidgets, this.carouselHeight, + this.tabs, + this.enableHaptics = true, + this.showDrawerOnMobileWhenTabbed = false, + this.minimizeTabBarOnScroll = true, }) : state = dataState.when( loading: () => HomeState.loading, loaded: (_, __) => HomeState.finish, @@ -88,6 +101,27 @@ 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; + + /// 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(); } @@ -102,22 +136,81 @@ class HomePageScaffoldState extends State { Timer? _timer; + 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; + + /// 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( + 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(); } @override Widget build(BuildContext context) { + if (widget.tabs != null && widget.tabs!.isNotEmpty) { + return _buildTabMode(); + } return PopScope( canPop: false, onPopInvokedWithResult: (bool didPop, _) { @@ -430,6 +523,336 @@ 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() { + final ColorScheme colors = Theme.of(context).colorScheme; + return Scaffold( + drawer: widget.showDrawerOnMobileWhenTabbed ? widget.drawer : null, + floatingActionButton: widget.floatingActionButton, + extendBody: true, + // 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: _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( + icon: tab.icon, + selectedIcon: tab.activeIcon, + label: Text(tab.label), + ), + ) + .toList(), + ), + const VerticalDivider( + thickness: 1, + width: 1, + ), + Expanded(child: _buildTabBody()), + ], + ), + ); + } + + /// 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(22), + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: 24, sigmaY: 24), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 3, vertical: 7), + decoration: BoxDecoration( + color: colors.surfaceContainerHighest.withValues(alpha: 0.85), + borderRadius: BorderRadius.circular(22), + ), + 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: () => _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, + ), + 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, + ), + 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 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( @@ -484,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 new file mode 100644 index 00000000..35750788 --- /dev/null +++ b/packages/ap_common_flutter_ui/lib/src/scaffold/home_tab.dart @@ -0,0 +1,44 @@ +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 navigation bar (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, + this.role = HomeTabRole.standard, + }); + + /// Icon for the navigation destination. + final Widget icon; + + /// Icon shown when this tab is selected. + final Widget? activeIcon; + + /// 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. + /// + /// 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; +} 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; 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: