Files
tatlock-ui/lib/shared/layouts/widgets/top_header_bar.dart
T
Jeroen SchweitzerandClaude Opus 4.5 c3f6d27a52
Build and Push / release (push) Successful in 3s
Build and Push / build (push) Successful in 3m7s
chore: release v1.4.0
- Decentralized Room Registry pattern
- Each room registers itself with central registry
- Dynamic navigation tabs and settings dropdown
- New Media Room and Parlor feature folders
- Permission-based room filtering support

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-05 15:46:44 +01:00

242 lines
7.6 KiB
Dart

import 'dart:math';
import 'package:flutter/material.dart';
import 'package:tatlock_ui/core/semantics/semantic_ids.dart';
import 'package:tatlock_ui/routing/room_registry.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
/// Get rooms from the registry.
List<RoomDefinition> get _rooms => roomRegistry.all;
@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: (context, error, stack) => 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;
final rooms = _rooms;
return List.generate(rooms.length, (index) {
final room = rooms[index];
final isSelected = index == selectedIndex;
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 4),
child: Semantics(
identifier: RoomTabSemantics.forIndex(index),
label: room.label,
button: true,
selected: isSelected,
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;
}
}