garage_auth, garage_entitlements, garage_iap and garage_ui, moved out of Garage-Services and Metro-Map-Maker into one public repo. MIT, one readme, docs under docs/. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013F4NWNvYcdeSgqbWMT1VQ7
157 lines
5.8 KiB
Dart
157 lines
5.8 KiB
Dart
import "package:flutter/widgets.dart";
|
|
import "package:flutter_lucide/flutter_lucide.dart";
|
|
|
|
import "button.dart";
|
|
import "editor_chrome.dart";
|
|
import "surface.dart";
|
|
import "theme/garage_theme.dart";
|
|
|
|
/// One entry in a [TabView] - the label (and optional icon) for its header
|
|
/// button, plus the widget shown while it's selected.
|
|
class TabViewItem {
|
|
const TabViewItem({required this.label, this.icon, required this.child});
|
|
|
|
final String label;
|
|
final IconData? icon;
|
|
final Widget child;
|
|
}
|
|
|
|
/// A row of buttons across the top, one page shown below at a time.
|
|
///
|
|
/// Not a browser-style tab strip - no close buttons, no reordering, no
|
|
/// scrolling overflow. This is closer to what FL Studio calls its pattern/
|
|
/// mixer/playlist "windows": conceptually separate workspaces that happen
|
|
/// to live as pages in the same frame instead of actual floating windows.
|
|
///
|
|
/// Every tab's widget stays mounted the whole time (via [IndexedStack]),
|
|
/// just hidden when it isn't selected, so switching tabs doesn't reset
|
|
/// whatever state the page underneath is holding onto - scroll position,
|
|
/// text fields, a half-finished drag, etc.
|
|
///
|
|
/// Controlled, not stateful - the caller owns [selectedIndex] and finds
|
|
/// out about taps via [onSelected], same shape as everything else in
|
|
/// GarageUI with a "which one is picked" concept (see Select).
|
|
class TabView extends StatelessWidget {
|
|
const TabView({
|
|
super.key,
|
|
required this.tabs,
|
|
required this.selectedIndex,
|
|
required this.onSelected,
|
|
this.onClosed,
|
|
});
|
|
|
|
final List<TabViewItem> tabs;
|
|
final int selectedIndex;
|
|
final ValueChanged<int> onSelected;
|
|
|
|
/// Called with a tab's index when its close (x) button is pressed - only
|
|
/// the active tab gets one. Leave null and no close button shows at all,
|
|
/// same "nothing happens unless the caller wired it up" deal as everywhere
|
|
/// else here - TabView itself doesn't know what "closing" a tab even means
|
|
/// for the caller (remove it? just hide it? ask first?), it just reports it
|
|
final ValueChanged<int>? onClosed;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Column(
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
_header(context),
|
|
Expanded(
|
|
child: IndexedStack(
|
|
index: selectedIndex,
|
|
children: [for (final tab in tabs) tab.child],
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
Widget _header(BuildContext context) {
|
|
final theme = GarageTheme.of(context);
|
|
// tried wrapping ChromeBar in an outer bordered DecoratedBox for the
|
|
// divider first - didnt work, ChromeBar paints its own opaque chrome-
|
|
// coloured background as a child ON TOP of that outer decoration (same
|
|
// bounds and everything) so the border stroke just gets painted over
|
|
// and never shows. a real 1px sibling below it actually renders
|
|
return Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
ChromeBar(
|
|
padding: const EdgeInsets.fromLTRB(8, 4, 6, 4),
|
|
child: Row(
|
|
children: [
|
|
for (var i = 0; i < tabs.length; i++) ...[
|
|
if (i > 0) const Gap(4),
|
|
_tabButton(context, i),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
Container(height: 1, color: theme.colorScheme.divider),
|
|
],
|
|
);
|
|
}
|
|
|
|
Widget _tabButton(BuildContext context, int index) {
|
|
final theme = GarageTheme.of(context);
|
|
final tab = tabs[index];
|
|
final selected = index == selectedIndex;
|
|
|
|
// Button has no automatic icon theming of its own (it never wraps its
|
|
// content in an IconTheme) - has to be coloured by hand here to match
|
|
// whichever variant its sitting on, or it just comes out whatever the
|
|
// ambient default happens to be, not the button's actual foreground.
|
|
// ghost's foreground is just colorScheme.foreground, not a "ghost"
|
|
// specific token - see _buttonGhostIconTheme
|
|
final iconColor = selected
|
|
? theme.colorScheme.primaryForeground
|
|
: theme.colorScheme.foreground;
|
|
final leading = tab.icon == null
|
|
? null
|
|
: Icon(tab.icon, size: theme.iconTheme.small.size, color: iconColor);
|
|
|
|
// compact density, not the default - this header bar is the same
|
|
// slim ChromeBar height as the app's own menu bar (which explicitly
|
|
// opts into ControlDensity.compact for the same reason), a normal-
|
|
// density button doesn't fit in that height without getting squished.
|
|
// active tab reads as primary, everything else fades into ghost so it
|
|
// doesnt fight the active one for attention
|
|
final tabButton = selected
|
|
? Button.primary(
|
|
style: const ButtonStyle.primary(density: ControlDensity.compact),
|
|
leading: leading,
|
|
onPressed: () => onSelected(index),
|
|
child: Text(tab.label),
|
|
)
|
|
: Button.ghost(
|
|
style: const ButtonStyle.ghost(density: ControlDensity.compact),
|
|
leading: leading,
|
|
onPressed: () => onSelected(index),
|
|
child: Text(tab.label),
|
|
);
|
|
|
|
// close button only shows up on the active tab, and only if the caller
|
|
// actually wants one - grouped onto the tab button via ButtonGroup so
|
|
// the two share one pill shape instead of looking like two seperate
|
|
// buttons bolted together
|
|
if (!selected || onClosed == null) return tabButton;
|
|
return ButtonGroup.horizontal(
|
|
children: [
|
|
tabButton,
|
|
IconButton.secondary(
|
|
// same deal as the tab buttons above - Button never themes its
|
|
// child's colour on its own, icon widget has to bring its own
|
|
icon: Icon(
|
|
LucideIcons.x,
|
|
size: theme.iconTheme.small.size,
|
|
color: theme.colorScheme.secondaryForeground,
|
|
),
|
|
density: ControlDensity.compact,
|
|
onPressed: () => onClosed!(index),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|