From a91c049353760a325281138f2dd9dd56a643e5e2 Mon Sep 17 00:00:00 2001 From: Marc Rejohn Castillano Date: Fri, 5 Jun 2026 09:15:22 +0800 Subject: [PATCH] Scrollable Navigation Rail --- lib/main.dart | 27 +++--- lib/widgets/app_shell.dart | 167 +++++++++++++++++++++++++++++-------- 2 files changed, 147 insertions(+), 47 deletions(-) diff --git a/lib/main.dart b/lib/main.dart index 677b4be7..577c9039 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -268,6 +268,21 @@ Future main() async { debugPrint('dotenv load failed or timed out: $e'); } + // Read VAPID_KEY once at startup. trim() handles trailing whitespace / BOM + // artifacts that the .env parser may leave, which would cause isEmpty to + // return true even when the key is present with a non-empty value. + final vapidKey = kIsWeb ? (dotenv.env['VAPID_KEY']?.trim() ?? '') : ''; + if (kIsWeb) { + if (vapidKey.isEmpty) { + debugPrint( + 'Web FCM: VAPID_KEY not set in .env — web push notifications disabled. ' + 'Add VAPID_KEY= from Firebase Console → Project Settings → Cloud Messaging.', + ); + } else { + debugPrint('Web FCM: VAPID_KEY loaded (${vapidKey.length} chars).'); + } + } + AppTime.initialize(location: 'Asia/Manila'); final supabaseUrl = dotenv.env['SUPABASE_URL'] ?? ''; @@ -336,17 +351,9 @@ Future main() async { final event = data.event; // Web: register FCM token for iOS 16.4+ PWA push support. - // Requires the VAPID key from Firebase Console → Project Settings → - // Cloud Messaging → Web Push certificates → Key pair. - // Add VAPID_KEY= to your .env file. + // vapidKey was read once at startup from dotenv — see boot sequence above. if (kIsWeb) { - final vapidKey = dotenv.env['VAPID_KEY'] ?? ''; - if (vapidKey.isEmpty) { - debugPrint( - 'Web FCM: VAPID_KEY not set in .env — skipping token registration.', - ); - return; - } + if (vapidKey.isEmpty) return; // already warned at startup if (event == AuthChangeEvent.signedIn) { try { final token = await FirebaseMessaging.instance.getToken( diff --git a/lib/widgets/app_shell.dart b/lib/widgets/app_shell.dart index 07cb9844..f892110b 100644 --- a/lib/widgets/app_shell.dart +++ b/lib/widgets/app_shell.dart @@ -126,7 +126,7 @@ class AppScaffold extends ConsumerWidget { } } -class AppNavigationRail extends StatelessWidget { +class AppNavigationRail extends StatefulWidget { const AppNavigationRail({ super.key, required this.items, @@ -142,60 +142,153 @@ class AppNavigationRail extends StatelessWidget { final String displayName; final VoidCallback onLogout; + @override + State createState() => _AppNavigationRailState(); +} + +class _AppNavigationRailState extends State { + final _scrollController = ScrollController(); + + @override + void dispose() { + _scrollController.dispose(); + super.dispose(); + } + @override Widget build(BuildContext context) { - final currentIndex = _currentIndex(location, items); final cs = Theme.of(context).colorScheme; + final currentIndex = _currentIndex(widget.location, widget.items); // M3 Expressive: tonal surface container instead of a hard border divider. + // Custom scrollable layout replaces NavigationRail so items are reachable + // on small viewport heights (web, small laptop screens). return Container( decoration: BoxDecoration(color: cs.surfaceContainerLow), - child: NavigationRail( - backgroundColor: Colors.transparent, - extended: extended, - selectedIndex: currentIndex, - onDestinationSelected: (value) { - items[value].onTap(context, onLogout: onLogout); - }, - leading: Padding( - padding: EdgeInsets.symmetric( - vertical: extended ? 12 : 8, - horizontal: extended ? 16 : 0, - ), - child: Center( - child: Image.asset( - 'assets/tasq_ico.png', - width: extended ? 48 : 40, - height: extended ? 48 : 40, + child: Column( + children: [ + Padding( + padding: EdgeInsets.symmetric( + vertical: widget.extended ? 12 : 8, + horizontal: widget.extended ? 16 : 0, ), - ), - ), - trailing: Expanded( - child: Align( - alignment: Alignment.bottomCenter, - child: Padding( - padding: const EdgeInsets.only(bottom: 16), - child: IconButton( - tooltip: 'Sign out', - onPressed: onLogout, - icon: const Icon(Icons.logout), + child: Center( + child: Image.asset( + 'assets/tasq_ico.png', + width: widget.extended ? 48 : 40, + height: widget.extended ? 48 : 40, ), ), ), - ), - destinations: [ - for (final item in items) - NavigationRailDestination( - icon: Icon(item.icon), - selectedIcon: Icon(item.selectedIcon ?? item.icon), - label: Text(item.label), + Expanded( + child: Scrollbar( + controller: _scrollController, + child: SingleChildScrollView( + controller: _scrollController, + child: Column( + children: [ + for (int i = 0; i < widget.items.length; i++) + _NavRailItem( + item: widget.items[i], + selected: i == currentIndex, + extended: widget.extended, + onTap: () => widget.items[i] + .onTap(context, onLogout: widget.onLogout), + ), + ], + ), + ), ), + ), + Padding( + padding: const EdgeInsets.only(bottom: 16), + child: IconButton( + tooltip: 'Sign out', + onPressed: widget.onLogout, + icon: const Icon(Icons.logout), + ), + ), ], ), ); } } +class _NavRailItem extends StatelessWidget { + const _NavRailItem({ + required this.item, + required this.selected, + required this.extended, + required this.onTap, + }); + + final NavItem item; + final bool selected; + final bool extended; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + + final icon = Icon( + selected ? (item.selectedIcon ?? item.icon) : item.icon, + size: 24, + color: selected ? cs.onSecondaryContainer : cs.onSurfaceVariant, + ); + + final indicator = AnimatedContainer( + duration: const Duration(milliseconds: 200), + width: 56, + height: 32, + decoration: BoxDecoration( + color: selected ? cs.secondaryContainer : Colors.transparent, + borderRadius: BorderRadius.circular(16), + ), + child: Center(child: icon), + ); + + final Widget content = extended + ? Padding( + padding: const EdgeInsets.symmetric(horizontal: 12), + child: Row( + children: [ + indicator, + const SizedBox(width: 12), + Expanded( + child: Text( + item.label, + style: Theme.of(context).textTheme.labelLarge?.copyWith( + color: selected + ? cs.onSecondaryContainer + : cs.onSurfaceVariant, + fontWeight: selected + ? FontWeight.w600 + : FontWeight.normal, + ), + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ) + : Center(child: indicator); + + final tile = InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(8), + child: SizedBox( + height: 56, + width: double.infinity, + child: content, + ), + ); + + if (extended) return tile; + return Tooltip(message: item.label, child: tile); + } +} + class AppBottomNav extends StatelessWidget { const AppBottomNav({ super.key,