Files
tatlock-ui/lib/shared/layouts/widgets/top_header_bar.dart
T
Jeroen SchweitzerandClaude Opus 4.5 de739e8f7a 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>
2025-12-31 14:52:40 +01:00

259 lines
7.8 KiB
Dart

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;
}