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:
ImBenji
2026-09-23 18:49:21 +01:00
co-authored by Claude Opus 5.5
commit b269201919
117 changed files with 26944 additions and 0 deletions
+156
View File
@@ -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),
),
],
);
}
}