- 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>
242 lines
7.6 KiB
Dart
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;
|
|
}
|
|
}
|
|
|