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:
co-authored by
Claude Opus 4.5
parent
5c09bac3d2
commit
de739e8f7a
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 {
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user