taproom/lib/main.dart
Philip Peterson 24dcfcf70d Taproom: a Flutter desktop frontend for Homebrew
Browse installed packages, upgrade outdated ones, search formulae and
casks, and explore taps, all by driving the brew CLI. Includes a
Taskfile for running, testing and building.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 20:12:21 -08:00

184 lines
5.6 KiB
Dart

import 'dart:io';
import 'package:flutter/material.dart';
import 'brew/brew_client.dart';
import 'state/app_state.dart';
import 'ui/common.dart';
import 'ui/installed_page.dart';
import 'ui/outdated_page.dart';
import 'ui/search_page.dart';
import 'ui/taps_page.dart';
void main() {
WidgetsFlutterBinding.ensureInitialized();
AppState? state;
final brewPath = BrewClient.locate();
if (brewPath != null) {
state = AppState(
BrewClient(brewPath: brewPath, askpassPath: _writeAskpass()),
);
state.refresh();
}
runApp(TaproomApp(state: state));
}
/// Writes a SUDO_ASKPASS helper that asks for the password with a native
/// dialog, for casks whose installers need sudo. Returns null if it can't.
String? _writeAskpass() {
try {
final f = File('${Directory.systemTemp.path}/taproom-askpass.sh');
f.writeAsStringSync('''#!/bin/sh
exec /usr/bin/osascript -e 'text returned of (display dialog "Homebrew needs your password to continue." default answer "" with hidden answer with title "Taproom" with icon caution)'
''');
Process.runSync('chmod', ['700', f.path]);
return f.path;
} catch (_) {
return null;
}
}
class TaproomApp extends StatelessWidget {
const TaproomApp({super.key, required this.state});
/// Null when Homebrew isn't installed.
final AppState? state;
@override
Widget build(BuildContext context) {
ThemeData theme(Brightness b) => ThemeData(
colorScheme: ColorScheme.fromSeed(
seedColor: const Color(0xFFE0A030),
brightness: b,
),
);
final app = MaterialApp(
title: 'Taproom',
debugShowCheckedModeBanner: false,
theme: theme(Brightness.light),
darkTheme: theme(Brightness.dark),
home: state == null ? const _NoBrew() : const HomeScreen(),
);
// Above MaterialApp so dialogs (which live in the root overlay) see it.
return state == null ? app : AppScope(state: state!, child: app);
}
}
class HomeScreen extends StatefulWidget {
const HomeScreen({super.key});
@override
State<HomeScreen> createState() => _HomeScreenState();
}
class _HomeScreenState extends State<HomeScreen> {
int _index = 0;
static const _pages = [
InstalledPage(),
OutdatedPage(),
SearchPage(),
TapsPage(),
];
@override
Widget build(BuildContext context) {
final state = AppScope.of(context);
final outdated = state.outdated.length;
return Scaffold(
body: Row(
children: [
NavigationRail(
selectedIndex: _index,
onDestinationSelected: (i) => setState(() => _index = i),
labelType: NavigationRailLabelType.all,
leading: const Padding(
padding: EdgeInsets.symmetric(vertical: 12),
child: Icon(Icons.sports_bar, size: 32),
),
destinations: [
const NavigationRailDestination(
icon: Icon(Icons.inventory_2_outlined),
selectedIcon: Icon(Icons.inventory_2),
label: Text('Installed'),
),
NavigationRailDestination(
icon: Badge(
isLabelVisible: outdated > 0,
label: Text('$outdated'),
child: const Icon(Icons.update),
),
label: const Text('Outdated'),
),
const NavigationRailDestination(
icon: Icon(Icons.search),
label: Text('Search'),
),
const NavigationRailDestination(
icon: Icon(Icons.water_drop_outlined),
selectedIcon: Icon(Icons.water_drop),
label: Text('Taps'),
),
],
trailing: Expanded(
child: Align(
alignment: Alignment.bottomCenter,
child: Padding(
padding: const EdgeInsets.only(bottom: 16),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
tooltip: 'brew update — fetch the latest package index',
icon: const Icon(Icons.cloud_sync_outlined),
onPressed: () => runTask(context, state.brew.update()),
),
IconButton(
tooltip:
'brew cleanup — remove old versions and caches',
icon: const Icon(Icons.cleaning_services_outlined),
onPressed: () => runTask(context, state.brew.cleanup()),
),
],
),
),
),
),
),
const VerticalDivider(width: 1),
Expanded(
child: IndexedStack(index: _index, children: _pages),
),
],
),
);
}
}
class _NoBrew extends StatelessWidget {
const _NoBrew();
@override
Widget build(BuildContext context) => Scaffold(
body: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.sports_bar, size: 64),
const SizedBox(height: 16),
Text(
'Homebrew not found',
style: Theme.of(context).textTheme.headlineSmall,
),
const SizedBox(height: 8),
const Text('Looked in /opt/homebrew and /usr/local.'),
const SizedBox(height: 16),
FilledButton(
onPressed: () => openUrl('https://brew.sh'),
child: const Text('Get Homebrew'),
),
],
),
),
);
}