The Garage SDKs, in the open
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
This commit is contained in:
@@ -0,0 +1,156 @@
|
||||
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),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user