diff --git a/assets/icons/.gitkeep b/assets/icons/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/assets/icons/logo.png b/assets/icons/logo.png new file mode 100644 index 0000000..f0e12f8 Binary files /dev/null and b/assets/icons/logo.png differ diff --git a/lib/features/front_hall/presentation/pages/front_hall_page.dart b/lib/features/front_hall/presentation/pages/front_hall_page.dart index a6a1f2a..b32e113 100644 --- a/lib/features/front_hall/presentation/pages/front_hall_page.dart +++ b/lib/features/front_hall/presentation/pages/front_hall_page.dart @@ -1,6 +1,5 @@ import 'package:flutter/material.dart'; - -import '../../../../version.g.dart'; +import 'package:tatlock_ui/version.g.dart'; /// Front Hall - estate overview and quick access. class FrontHallPage extends StatelessWidget { @@ -10,19 +9,8 @@ class FrontHallPage extends StatelessWidget { Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; - return Scaffold( - appBar: AppBar( - title: const Text('Front Hall'), - actions: [ - IconButton( - icon: const Icon(Icons.refresh), - onPressed: () { - // TODO: Refresh data - }, - ), - ], - ), - body: ListView( + // No Scaffold/AppBar needed - header is provided by AppScaffold + return ListView( padding: const EdgeInsets.all(16), children: [ // Welcome card @@ -107,8 +95,7 @@ class FrontHallPage extends StatelessWidget { ), ), ], - ), - ); + ); } } diff --git a/lib/shared/layouts/app_scaffold.dart b/lib/shared/layouts/app_scaffold.dart index 6ae92b2..4d4d701 100644 --- a/lib/shared/layouts/app_scaffold.dart +++ b/lib/shared/layouts/app_scaffold.dart @@ -1,48 +1,51 @@ import 'package:flutter/material.dart'; -import 'package:flutter_adaptive_scaffold/flutter_adaptive_scaffold.dart'; import 'package:go_router/go_router.dart'; +import 'package:tatlock_ui/routing/app_router.dart'; +import 'package:tatlock_ui/shared/layouts/widgets/top_header_bar.dart'; -import '../../routing/app_router.dart'; - -/// Main application scaffold with adaptive navigation. +/// Main application scaffold with top header navigation. +/// +/// Layout structure per UI_LAYOUT.md: +/// ``` +/// ┌─────────────────────────────────────────────────────────────────┐ +/// │ HEADER: [Logo] [Room Tabs] [Profile] │ +/// ├─────────────────────────────────────────────────────────────────┤ +/// │ BODY: Room page content (may include room-specific sidebar) │ +/// └─────────────────────────────────────────────────────────────────┘ +/// ``` class AppScaffold extends StatelessWidget { const AppScaffold({super.key, required this.child}); final Widget child; + // Header height must match TopHeaderBar._headerHeight + static const double _headerHeight = 56.0; + @override Widget build(BuildContext context) { - // TODO: Replace AdaptiveScaffold with custom layout per UI_LAYOUT.md - // - Header with room tabs (not bottom/rail nav) - // - Chat dock on right side - return AdaptiveScaffold( - selectedIndex: _selectedIndex(context), - onSelectedIndexChange: (index) => _onNavSelected(context, index), - destinations: const [ - NavigationDestination( - icon: Icon(Icons.door_front_door_outlined), - selectedIcon: Icon(Icons.door_front_door), - label: 'Front Hall', - ), - NavigationDestination( - icon: Icon(Icons.dns_outlined), - selectedIcon: Icon(Icons.dns), - label: 'Control Room', - ), - NavigationDestination( - icon: Icon(Icons.weekend_outlined), - selectedIcon: Icon(Icons.weekend), - label: 'Parlor', - ), - NavigationDestination( - icon: Icon(Icons.settings_outlined), - selectedIcon: Icon(Icons.settings), - label: 'Settings', - ), - ], - body: (_) => child, - smallBody: (_) => child, - useDrawer: false, + return Scaffold( + body: Stack( + children: [ + // Main content area with top padding for header + Positioned.fill( + child: Padding( + padding: const EdgeInsets.only(top: _headerHeight), + child: child, + ), + ), + + // Top header overlays content (bulge extends into content area) + Positioned( + top: 0, + left: 0, + right: 0, + child: TopHeaderBar( + selectedIndex: _selectedIndex(context), + onRoomSelected: (index) => _onNavSelected(context, index), + ), + ), + ], + ), ); } @@ -51,7 +54,7 @@ class AppScaffold extends StatelessWidget { if (location.startsWith(AppRoutes.controlRoom)) return 1; if (location.startsWith(AppRoutes.parlor)) return 2; - if (location.startsWith(AppRoutes.settings)) return 3; + // Settings is no longer in main nav (accessed via Profile dropdown) return 0; // Front Hall } @@ -60,7 +63,6 @@ class AppScaffold extends StatelessWidget { 0 => AppRoutes.frontHall, 1 => AppRoutes.controlRoom, 2 => AppRoutes.parlor, - 3 => AppRoutes.settings, _ => AppRoutes.frontHall, }; context.go(route); diff --git a/lib/shared/layouts/widgets/profile_dropdown.dart b/lib/shared/layouts/widgets/profile_dropdown.dart new file mode 100644 index 0000000..e92bec6 --- /dev/null +++ b/lib/shared/layouts/widgets/profile_dropdown.dart @@ -0,0 +1,129 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; +import 'package:tatlock_ui/core/auth/auth_provider.dart'; +import 'package:tatlock_ui/routing/app_router.dart'; + +/// Profile dropdown menu in the header. +/// +/// Shows user info when authenticated, with Settings and Logout options. +class ProfileDropdown extends ConsumerWidget { + const ProfileDropdown({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final authState = ref.watch(authNotifierProvider); + final colorScheme = Theme.of(context).colorScheme; + + return authState.when( + data: (auth) => PopupMenuButton( + offset: const Offset(0, 48), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + child: Tooltip( + message: auth.isAuthenticated ? auth.userName ?? 'User' : 'Guest', + child: CircleAvatar( + radius: 18, + backgroundColor: colorScheme.primaryContainer, + child: auth.isAuthenticated + ? Text( + _getInitials(auth.userName), + style: TextStyle( + color: colorScheme.onPrimaryContainer, + fontWeight: FontWeight.w500, + ), + ) + : Icon( + Icons.person_outline, + size: 20, + color: colorScheme.onPrimaryContainer, + ), + ), + ), + itemBuilder: (context) => [ + // User info header (non-selectable) + PopupMenuItem( + enabled: false, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + auth.isAuthenticated ? auth.userName ?? 'User' : 'Guest', + style: Theme.of(context).textTheme.titleSmall, + ), + if (auth.userEmail != null) + Text( + auth.userEmail!, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + const PopupMenuDivider(), + + // Settings + const PopupMenuItem( + value: 'settings', + child: Row( + children: [ + Icon(Icons.settings_outlined, size: 20), + SizedBox(width: 12), + Text('Settings'), + ], + ), + ), + + // Logout (only if authenticated) + if (auth.isAuthenticated) + const PopupMenuItem( + value: 'logout', + child: Row( + children: [ + Icon(Icons.logout, size: 20), + SizedBox(width: 12), + Text('Logout'), + ], + ), + ), + ], + onSelected: (value) { + switch (value) { + case 'settings': + context.go(AppRoutes.settings); + case 'logout': + ref.read(authNotifierProvider.notifier).signOut(); + } + }, + ), + loading: () => const CircleAvatar( + radius: 18, + child: SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ), + ), + error: (_, __) => CircleAvatar( + radius: 18, + backgroundColor: colorScheme.errorContainer, + child: Icon( + Icons.error_outline, + size: 20, + color: colorScheme.onErrorContainer, + ), + ), + ); + } + + String _getInitials(String? name) { + if (name == null || name.isEmpty) return '?'; + final parts = name.trim().split(' '); + if (parts.length >= 2) { + return '${parts.first[0]}${parts.last[0]}'.toUpperCase(); + } + return name[0].toUpperCase(); + } +} diff --git a/lib/shared/layouts/widgets/top_header_bar.dart b/lib/shared/layouts/widgets/top_header_bar.dart new file mode 100644 index 0000000..6170f3e --- /dev/null +++ b/lib/shared/layouts/widgets/top_header_bar.dart @@ -0,0 +1,258 @@ +import 'dart:math'; + +import 'package:flutter/material.dart'; + +import 'profile_dropdown.dart'; + +/// Top header bar with room navigation tabs and profile dropdown. +/// Features a circular "bulge" extending below the header for the logo. +class TopHeaderBar extends StatelessWidget { + const TopHeaderBar({ + super.key, + required this.selectedIndex, + required this.onRoomSelected, + }); + + final int selectedIndex; + final ValueChanged onRoomSelected; + + // Header dimensions + static const double _headerHeight = 56.0; + static const double _logoSize = 120.0; + static const double _logoMargin = 4.0; // Equal margin top/bottom within bubble + static const double _logoCircleRadius = (_logoSize + _logoMargin * 2) / 2; // 64px + static const double _bulgeFraction = 0.20; // 20% of circle below header line + + static const _rooms = [ + _RoomDestination( + icon: Icons.door_front_door_outlined, + selectedIcon: Icons.door_front_door, + label: 'Front Hall', + ), + _RoomDestination( + icon: Icons.dns_outlined, + selectedIcon: Icons.dns, + label: 'Control Room', + ), + _RoomDestination( + icon: Icons.lightbulb_outline, + selectedIcon: Icons.lightbulb, + label: 'Parlor', + ), + ]; + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + + // Calculate bulge: 20% of circle diameter extends below + final bulgeExtension = _logoCircleRadius * 2 * _bulgeFraction; + + return SizedBox( + height: _headerHeight + bulgeExtension, + child: Stack( + clipBehavior: Clip.none, + children: [ + // Main header bar with custom bottom shape + Positioned.fill( + child: CustomPaint( + painter: _HeaderPainter( + color: colorScheme.surface, + borderColor: colorScheme.outlineVariant, + circleRadius: _logoCircleRadius, + bulgeFraction: _bulgeFraction, + headerHeight: _headerHeight, + circleLeftOffset: 16 + _logoCircleRadius, + ), + ), + ), + + // Logo centered in the visible bubble area (from top of header to bottom of bulge) + Positioned( + left: 16 + _logoMargin, + // Center logo in visible bubble: from y=0 to y=headerHeight+bulgeExtension + top: ((_headerHeight + bulgeExtension) - _logoSize) / 2, + child: Image.asset( + 'assets/icons/logo.png', + height: _logoSize, + width: _logoSize, + errorBuilder: (_, __, ___) => Icon( + Icons.layers, + size: 32, + color: colorScheme.primary, + ), + ), + ), + + // Header content (room tabs and profile) + Positioned( + top: 0, + left: 0, + right: 0, + height: _headerHeight, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Row( + children: [ + // Spacer for logo area + SizedBox(width: _logoCircleRadius * 2 + 16), + + // Room tabs + ..._buildRoomTabs(context), + + const Spacer(), + + // Profile dropdown + const ProfileDropdown(), + ], + ), + ), + ), + ], + ), + ); + } + + List _buildRoomTabs(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + + return List.generate(_rooms.length, (index) { + final room = _rooms[index]; + final isSelected = index == selectedIndex; + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 4), + child: Tooltip( + message: room.label, + child: IconButton( + icon: Icon( + isSelected ? room.selectedIcon : room.icon, + color: isSelected ? colorScheme.primary : colorScheme.onSurface, + ), + onPressed: () => onRoomSelected(index), + style: IconButton.styleFrom( + backgroundColor: + isSelected ? colorScheme.primaryContainer : null, + ), + ), + ), + ); + }); + } +} + +/// Custom painter for the header with a circular bulge for the logo. +class _HeaderPainter extends CustomPainter { + _HeaderPainter({ + required this.color, + required this.borderColor, + required this.circleRadius, + required this.bulgeFraction, + required this.headerHeight, + required this.circleLeftOffset, + }); + + final Color color; + final Color borderColor; + final double circleRadius; + final double bulgeFraction; // Fraction of circle below header (0.0 to 0.5) + final double headerHeight; + final double circleLeftOffset; + + @override + void paint(Canvas canvas, Size size) { + final paint = Paint() + ..color = color + ..style = PaintingStyle.fill; + + final borderPaint = Paint() + ..color = borderColor + ..style = PaintingStyle.stroke + ..strokeWidth = 1; + + // Calculate circle center position + // bulgeFraction of the diameter should be below the header line + // bulgeAmount = bulgeFraction * 2 * radius (fraction of diameter) + final bulgeAmount = bulgeFraction * 2 * circleRadius; + // Center is positioned so that (radius - centerOffset) = bulgeAmount + // centerOffset = radius - bulgeAmount + final centerY = headerHeight - circleRadius + bulgeAmount; + final circleCenter = Offset(circleLeftOffset, centerY); + + // Calculate the angle where circle intersects header line + // At y = headerHeight: distance from center = headerHeight - centerY + final distFromCenter = headerHeight - centerY; + // cos(angle) = distFromCenter / radius + final cosAngle = distFromCenter / circleRadius; + final angle = acos(cosAngle.clamp(-1.0, 1.0)); + + // Arc starts at (π/2 - angle) and ends at (π/2 + angle) + // In Flutter, 0 is at 3 o'clock, π/2 is at 6 o'clock + final startAngle = (3.14159 / 2) - angle; + final sweepAngle = angle * 2; + + // Calculate where arc intersects header line + final halfChord = circleRadius * sin(angle); + final arcLeft = circleLeftOffset - halfChord; + final arcRight = circleLeftOffset + halfChord; + + // Create path for header with bulge + final path = Path(); + + // Start from top-left + path.moveTo(0, 0); + + // Top edge + path.lineTo(size.width, 0); + + // Right edge down to header height + path.lineTo(size.width, headerHeight); + + // Bottom edge - going right to left with curved bulge + path.lineTo(arcRight, headerHeight); + + // Arc for the bulge + final arcRect = Rect.fromCircle(center: circleCenter, radius: circleRadius); + path.arcTo(arcRect, startAngle, sweepAngle, false); + + // Continue to left edge + path.lineTo(0, headerHeight); + + // Close path + path.close(); + + // Draw filled shape + canvas.drawPath(path, paint); + + // Draw border along bottom edge only (with bulge) + final borderPath = Path(); + borderPath.moveTo(0, headerHeight); + borderPath.lineTo(arcLeft, headerHeight); + borderPath.arcTo(arcRect, 3.14159 - startAngle, -sweepAngle, false); + borderPath.lineTo(size.width, headerHeight); + + canvas.drawPath(borderPath, borderPaint); + } + + @override + bool shouldRepaint(covariant _HeaderPainter oldDelegate) { + return color != oldDelegate.color || + borderColor != oldDelegate.borderColor || + circleRadius != oldDelegate.circleRadius || + bulgeFraction != oldDelegate.bulgeFraction || + headerHeight != oldDelegate.headerHeight || + circleLeftOffset != oldDelegate.circleLeftOffset; + } +} + +class _RoomDestination { + const _RoomDestination({ + required this.icon, + required this.selectedIcon, + required this.label, + }); + + final IconData icon; + final IconData selectedIcon; + final String label; +}