feat: redesign header with logo bulge and room navigation

- Move room navigation from sidebar to top header bar as icons
- Add circular "bulge" extending below header for larger logo (120px)
- Logo centered in bulge with 20% circle below header line
- Profile dropdown with Settings and Logout options
- Header overlays content (Stack layout) instead of pushing it down
- Remove AppBar from FrontHallPage (provided by AppScaffold)
- Add transparent logo asset

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Jeroen Schweitzer
2025-12-31 14:52:40 +01:00
co-authored by Claude Opus 4.5
parent 5c09bac3d2
commit de739e8f7a
6 changed files with 430 additions and 54 deletions
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 977 KiB

@@ -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 {
),
),
],
),
);
);
}
}
+39 -37
View File
@@ -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);
@@ -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<String>(
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<String>(
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<String>(
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<String>(
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();
}
}
@@ -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<int> 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<Widget> _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;
}