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>
This commit is contained in:
Philip Peterson 2026-10-10 20:12:15 -08:00
commit 24dcfcf70d
51 changed files with 4342 additions and 0 deletions

51
.gitignore vendored Normal file
View file

@ -0,0 +1,51 @@
# Miscellaneous
*.class
*.log
*.pyc
*.swp
.DS_Store
.atom/
.build/
.buildlog/
.history
.svn/
.swiftpm/
migrate_working_dir/
# IntelliJ related
*.iml
*.ipr
*.iws
.idea/
# The .vscode folder contains launch configuration and tasks you configure in
# VS Code which you may wish to be included in version control, so this line
# is commented out by default.
#.vscode/
# Flutter/Dart/Pub related
**/doc/api/
**/ios/Flutter/.last_build_id
.dart_tool/
.flutter-plugins-dependencies
.pub-cache/
.pub/
/build/
/coverage/
# Symbolication related
app.*.symbols
# Obfuscation related
app.*.map.json
# Android Studio will place build artifacts here
/android/app/debug
/android/app/profile
/android/app/release
# Widget Preview related
.widget_preview/
# go-task checksum cache
.task/

30
.metadata Normal file
View file

@ -0,0 +1,30 @@
# This file tracks properties of this Flutter project.
# Used by Flutter tool to assess capabilities and perform upgrades etc.
#
# This file should be version controlled and should not be manually edited.
version:
revision: "abaf9c523780a608bd46686fd5e53740a07077f8"
channel: "stable"
project_type: app
# Tracks metadata for the flutter migrate command
migration:
platforms:
- platform: root
create_revision: abaf9c523780a608bd46686fd5e53740a07077f8
base_revision: abaf9c523780a608bd46686fd5e53740a07077f8
- platform: macos
create_revision: abaf9c523780a608bd46686fd5e53740a07077f8
base_revision: abaf9c523780a608bd46686fd5e53740a07077f8
# User provided section
# List of Local paths (relative to this file) that should be
# ignored by the migrate tool.
#
# Files that are not part of the templates will be ignored by default.
unmanaged_files:
- 'lib/main.dart'
- 'ios/Runner.xcodeproj/project.pbxproj'

22
README.md Normal file
View file

@ -0,0 +1,22 @@
# Taproom
A Flutter desktop frontend for Homebrew: browse what's installed, see and
apply upgrades, search formulae and casks, and explore your taps.
It drives the `brew` CLI directly (JSON output for queries, streamed output
for installs), so it needs Homebrew at `/opt/homebrew` or `/usr/local` and
runs **without the macOS App Sandbox** (see `macos/Runner/*.entitlements`),
since a sandboxed app can't launch `brew`.
Casks whose installers need `sudo` prompt for your password via a native
dialog (a `SUDO_ASKPASS` helper written to the temp dir at launch).
## Develop
Tasks are defined in `Taskfile.yml` ([go-task](https://taskfile.dev),
`brew install go-task`); run `task` to list them.
task run # debug build; needs full Xcode, not just the Command Line Tools
task check # format check + analyze + tests (tests include read-only
# checks against your real brew)
task install # release build copied to /Applications

82
Taskfile.yml Normal file
View file

@ -0,0 +1,82 @@
# https://taskfile.dev — run `task` to list tasks.
version: '3'
vars:
APP: build/macos/Build/Products/Release/taproom.app
tasks:
default:
silent: true
cmds:
- task --list
deps:
desc: Fetch Dart/Flutter packages
sources: [pubspec.yaml, pubspec.lock]
cmds:
- flutter pub get
run:
desc: Run the app in debug mode (needs full Xcode)
deps: [deps]
cmds:
- flutter run -d macos
test:
desc: Run all tests (includes read-only checks against your real brew)
deps: [deps]
cmds:
- flutter test {{.CLI_ARGS}}
analyze:
desc: Static analysis
deps: [deps]
cmds:
- flutter analyze
fmt:
desc: Format all Dart code
cmds:
- dart format lib test
fmt:check:
desc: Fail if any Dart code is unformatted
cmds:
- dart format --output=none --set-exit-if-changed lib test
check:
desc: Formatting, analysis and tests — run before committing
cmds:
- task: fmt:check
- task: analyze
- task: test
build:
desc: Build the release .app (needs full Xcode)
deps: [deps]
cmds:
- flutter build macos --release
- echo "Built {{.APP}}"
open:
desc: Launch the release build
deps: [build]
cmds:
- open "{{.APP}}"
install:
desc: Copy the release build to /Applications
deps: [build]
cmds:
- rm -rf /Applications/Taproom.app
- cp -R "{{.APP}}" /Applications/Taproom.app
doctor:
desc: Check the Flutter/Xcode toolchain
cmds:
- flutter doctor
clean:
desc: Remove build outputs
cmds:
- flutter clean

33
analysis_options.yaml Normal file
View file

@ -0,0 +1,33 @@
# This file configures the analyzer, which statically analyzes Dart code to
# check for errors, warnings, and lints.
#
# The issues identified by the analyzer are surfaced in the UI of Dart-enabled
# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be
# invoked from the command line by running `flutter analyze`.
# The following line activates a set of recommended lints for Flutter apps,
# packages, and plugins designed to encourage good coding practices.
include: package:flutter_lints/flutter.yaml
analyzer:
exclude:
- build/**
- macos/**
linter:
# The lint rules applied to this project can be customized in the
# section below to disable rules from the `package:flutter_lints/flutter.yaml`
# included above or to enable additional rules. A list of all available lints
# and their documentation is published at https://dart.dev/lints.
#
# Instead of disabling a lint rule for the entire project in the
# section below, it can also be suppressed for a single line of code
# or a specific dart file by using the `// ignore: name_of_lint` and
# `// ignore_for_file: name_of_lint` syntax on the line or in the file
# producing the lint.
rules:
# avoid_print: false # Uncomment to disable the `avoid_print` rule
# prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule
# Additional information about this file can be found at
# https://dart.dev/guides/language/analysis-options

240
lib/brew/brew_client.dart Normal file
View file

@ -0,0 +1,240 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'models.dart';
typedef ProcessRunner = Future<ProcessResult> Function(
String executable,
List<String> args,
Map<String, String> environment,
);
typedef ProcessStarter = Future<Process> Function(
String executable,
List<String> args,
Map<String, String> environment,
);
class BrewException implements Exception {
BrewException(this.args, this.exitCode, this.stderr);
final List<String> args;
final int exitCode;
final String stderr;
@override
String toString() =>
'brew ${args.join(' ')} exited $exitCode${stderr.isEmpty ? '' : ':\n$stderr'}';
}
/// Thin wrapper over the `brew` CLI. Queries go through [runner] and are
/// parsed from brew's JSON output; mutations (install, tap, ...) are
/// returned as [BrewTask]s that stream their output.
class BrewClient {
BrewClient({
required this.brewPath,
ProcessRunner? runner,
ProcessStarter? starter,
this.askpassPath,
}) : _runner = runner ?? _defaultRunner,
_starter = starter ?? _defaultStarter;
final String brewPath;
/// A SUDO_ASKPASS helper, so casks that run `sudo` (pkg installers) can
/// prompt for a password without a terminal.
final String? askpassPath;
final ProcessRunner _runner;
final ProcessStarter _starter;
static const _candidatePaths = [
'/opt/homebrew/bin/brew', // Apple Silicon
'/usr/local/bin/brew', // Intel
'/home/linuxbrew/.linuxbrew/bin/brew',
];
/// GUI apps don't inherit the shell's PATH, so look in brew's standard
/// install locations rather than relying on `which`.
static String? locate() {
for (final p in _candidatePaths) {
if (File(p).existsSync()) return p;
}
return null;
}
static Future<ProcessResult> _defaultRunner(
String exe,
List<String> args,
Map<String, String> env,
) => Process.run(exe, args, environment: env);
static Future<Process> _defaultStarter(
String exe,
List<String> args,
Map<String, String> env,
) => Process.start(exe, args, environment: env);
Map<String, String> _environment({required bool readOnly}) {
final inherited = Platform.environment;
final brewBin = File(brewPath).parent.path;
return {
...inherited,
'PATH':
'$brewBin:/usr/bin:/bin:/usr/sbin:/sbin'
'${inherited['PATH'] == null ? '' : ':${inherited['PATH']}'}',
'HOMEBREW_NO_COLOR': '1',
'HOMEBREW_NO_EMOJI': '1',
'HOMEBREW_NO_ENV_HINTS': '1',
'NONINTERACTIVE': '1',
// Keep queries snappy; an explicit "brew update" is one click away.
if (readOnly) 'HOMEBREW_NO_AUTO_UPDATE': '1',
'SUDO_ASKPASS': ?askpassPath,
};
}
Future<String> _run(List<String> args, {bool allowFailure = false}) async {
final r = await _runner(brewPath, args, _environment(readOnly: true));
if (r.exitCode != 0 && !allowFailure) {
throw BrewException(args, r.exitCode, (r.stderr as String).trim());
}
return r.stdout as String;
}
Future<Map<String, dynamic>> _runJson(List<String> args) async =>
jsonDecode(await _run(args)) as Map<String, dynamic>;
Future<List<Package>> installed() async => Package.listFromInfoJson(
await _runJson(['info', '--json=v2', '--installed']),
);
Future<List<OutdatedEntry>> outdated({bool greedy = false}) async =>
OutdatedEntry.listFromJson(
await _runJson(['outdated', '--json=v2', if (greedy) '--greedy']),
);
Future<List<Tap>> taps() async {
final out = await _run(['tap-info', '--json', '--installed']);
return (jsonDecode(out) as List)
.map((t) => Tap.fromJson(t as Map<String, dynamic>))
.toList();
}
Future<Package?> info(String name, PackageKind kind) async {
final list = Package.listFromInfoJson(
await _runJson(['info', '--json=v2', kind.flag, name]),
);
return list.isEmpty ? null : list.first;
}
/// Searches formulae and casks by name. Like `brew search`, a query
/// wrapped in slashes is treated as a regex.
Future<List<SearchResult>> search(String query) async {
final results = await Future.wait([
for (final kind in PackageKind.values)
// brew exits non-zero when nothing matches; that's just no results.
_run([
'search',
kind.flag,
query,
], allowFailure: true).then((out) => parseSearchOutput(out, kind)),
]);
return results.expand((r) => r).toList();
}
@visibleForTesting
static List<SearchResult> parseSearchOutput(String out, PackageKind kind) => [
for (final line in const LineSplitter().convert(out))
if (line.trim().isNotEmpty &&
!line.startsWith('==>') &&
!line.contains(' '))
SearchResult(line.trim(), kind),
];
BrewTask install(String name, PackageKind kind) =>
task('Install $name', ['install', kind.flag, name]);
BrewTask uninstall(String name, PackageKind kind) =>
task('Uninstall $name', ['uninstall', kind.flag, name]);
BrewTask reinstall(String name, PackageKind kind) =>
task('Reinstall $name', ['reinstall', kind.flag, name]);
/// Upgrades one package, or everything outdated when [name] is null.
BrewTask upgrade({String? name, PackageKind? kind, bool greedy = false}) =>
task(name == null ? 'Upgrade all' : 'Upgrade $name', [
'upgrade',
if (kind != null) kind.flag,
if (greedy) '--greedy',
?name,
]);
BrewTask pin(String name, {required bool pinned}) => task(
'${pinned ? 'Pin' : 'Unpin'} $name',
[pinned ? 'pin' : 'unpin', name],
);
BrewTask update() => task('Update Homebrew', ['update']);
BrewTask cleanup() => task('Clean up', ['cleanup']);
BrewTask addTap(String name, {String? url}) =>
task('Tap $name', ['tap', name, if (url != null && url.isNotEmpty) url]);
BrewTask removeTap(String name) => task('Untap $name', ['untap', name]);
BrewTask task(String title, List<String> args) => BrewTask._(
title: title,
args: args,
start: () => _starter(brewPath, args, _environment(readOnly: false)),
);
}
/// A running (or finished) brew command whose output is streamed into
/// [lines]. Listen to it to rebuild as output arrives.
class BrewTask extends ChangeNotifier {
BrewTask._({required this.title, required this.args, required this.start});
final String title;
final List<String> args;
final Future<Process> Function() start;
final List<String> lines = [];
int? exitCode;
Process? _process;
bool _started = false;
bool get running => _started && exitCode == null;
bool get succeeded => exitCode == 0;
String get command => 'brew ${args.join(' ')}';
/// Starts the command; completes with its exit code.
Future<int> run() async {
if (_started) throw StateError('BrewTask already started');
_started = true;
lines.add('\$ $command');
notifyListeners();
try {
final p = _process = await start();
await p.stdin.close();
Future<void> pump(Stream<List<int>> s) => s
.transform(utf8.decoder)
.transform(const LineSplitter())
.forEach((l) {
lines.add(l);
notifyListeners();
});
await Future.wait([pump(p.stdout), pump(p.stderr)]);
exitCode = await p.exitCode;
} catch (e) {
lines.add('Failed to run brew: $e');
exitCode = -1;
}
notifyListeners();
return exitCode!;
}
void cancel() => _process?.kill();
}

195
lib/brew/models.dart Normal file
View file

@ -0,0 +1,195 @@
enum PackageKind {
formula,
cask;
/// The flag brew uses to disambiguate a name, e.g. `brew info --cask foo`.
String get flag => this == formula ? '--formula' : '--cask';
String get label => this == formula ? 'Formula' : 'Cask';
}
/// A formula or cask, as described by `brew info --json=v2`.
class Package {
const Package({
required this.name,
required this.fullName,
required this.kind,
this.displayName,
this.tap,
this.desc,
this.homepage,
this.latestVersion,
this.installedVersion,
this.outdated = false,
this.pinned = false,
this.installedOnRequest = false,
this.dependencies = const [],
this.caveats,
this.deprecated = false,
});
/// Formula name or cask token; what you pass to `brew install`.
final String name;
final String fullName;
final PackageKind kind;
/// Casks have a human name ("Firefox") separate from their token.
final String? displayName;
final String? tap;
final String? desc;
final String? homepage;
final String? latestVersion;
final String? installedVersion;
final bool outdated;
final bool pinned;
/// False for formulae only present as a dependency of something else.
final bool installedOnRequest;
final List<String> dependencies;
final String? caveats;
final bool deprecated;
bool get isInstalled => installedVersion != null;
factory Package.fromFormulaJson(Map<String, dynamic> j) {
final installs = (j['installed'] as List? ?? const [])
.cast<Map<String, dynamic>>();
return Package(
name: j['name'] as String,
fullName: (j['full_name'] as String?) ?? j['name'] as String,
kind: PackageKind.formula,
tap: j['tap'] as String?,
desc: j['desc'] as String?,
homepage: j['homepage'] as String?,
latestVersion: (j['versions'] as Map?)?['stable'] as String?,
installedVersion: installs.isEmpty
? null
: installs.last['version'] as String?,
outdated: j['outdated'] == true,
pinned: j['pinned'] == true,
installedOnRequest: installs.any(
(i) => i['installed_on_request'] == true,
),
dependencies: (j['dependencies'] as List? ?? const []).cast<String>(),
caveats: j['caveats'] as String?,
deprecated: j['deprecated'] == true,
);
}
factory Package.fromCaskJson(Map<String, dynamic> j) {
final names = (j['name'] as List? ?? const []).cast<String>();
final installed = j['installed'] as String?;
return Package(
name: j['token'] as String,
fullName: (j['full_token'] as String?) ?? j['token'] as String,
kind: PackageKind.cask,
displayName: names.isEmpty ? null : names.first,
tap: j['tap'] as String?,
desc: j['desc'] as String?,
homepage: j['homepage'] as String?,
latestVersion: j['version'] as String?,
installedVersion: installed,
outdated: j['outdated'] == true,
pinned: j['pinned'] == true,
// Casks have no dependency-only installs.
installedOnRequest: installed != null,
caveats: j['caveats'] as String?,
deprecated: j['deprecated'] == true,
);
}
/// Parses the `{"formulae": [...], "casks": [...]}` shape of
/// `brew info --json=v2`.
static List<Package> listFromInfoJson(Map<String, dynamic> j) => [
for (final f in (j['formulae'] as List? ?? const []))
Package.fromFormulaJson(f as Map<String, dynamic>),
for (final c in (j['casks'] as List? ?? const []))
Package.fromCaskJson(c as Map<String, dynamic>),
];
}
/// An entry from `brew tap-info --json`.
class Tap {
const Tap({
required this.name,
this.path,
this.remote,
this.official = false,
this.cloned = false,
this.branch,
this.lastCommit,
this.formulaNames = const [],
this.caskTokens = const [],
});
final String name;
final String? path;
final String? remote;
final bool official;
/// Whether the tap is a local git checkout. homebrew/core and
/// homebrew/cask are usually served from the JSON API instead, so this is
/// false for them even though they're always available.
final bool cloned;
/// Git details; only present for cloned taps.
final String? branch;
final String? lastCommit;
final List<String> formulaNames;
final List<String> caskTokens;
factory Tap.fromJson(Map<String, dynamic> j) => Tap(
name: j['name'] as String,
path: j['path'] as String?,
// `remote` is the URL; `custom_remote` is just a flag for whether
// it was overridden.
remote: j['remote'] as String?,
official: j['official'] == true,
cloned: j['installed'] == true,
branch: j['branch'] as String?,
lastCommit: j['last_commit'] as String?,
formulaNames: (j['formula_names'] as List? ?? const []).cast<String>(),
caskTokens: (j['cask_tokens'] as List? ?? const []).cast<String>(),
);
}
/// An entry from `brew outdated --json=v2`.
class OutdatedEntry {
const OutdatedEntry({
required this.name,
required this.kind,
required this.installedVersions,
required this.currentVersion,
this.pinned = false,
});
final String name;
final PackageKind kind;
final List<String> installedVersions;
final String currentVersion;
final bool pinned;
factory OutdatedEntry.fromJson(Map<String, dynamic> j, PackageKind kind) =>
OutdatedEntry(
name: j['name'] as String,
kind: kind,
installedVersions: (j['installed_versions'] as List? ?? const [])
.cast<String>(),
currentVersion: j['current_version'] as String? ?? '?',
pinned: j['pinned'] == true,
);
static List<OutdatedEntry> listFromJson(Map<String, dynamic> j) => [
for (final f in (j['formulae'] as List? ?? const []))
OutdatedEntry.fromJson(f as Map<String, dynamic>, PackageKind.formula),
for (final c in (j['casks'] as List? ?? const []))
OutdatedEntry.fromJson(c as Map<String, dynamic>, PackageKind.cask),
];
}
class SearchResult {
const SearchResult(this.name, this.kind);
final String name;
final PackageKind kind;
}

184
lib/main.dart Normal file
View file

@ -0,0 +1,184 @@
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'),
),
],
),
),
);
}

54
lib/state/app_state.dart Normal file
View file

@ -0,0 +1,54 @@
import 'package:flutter/foundation.dart';
import '../brew/brew_client.dart';
import '../brew/models.dart';
/// Everything the app knows about the local Homebrew install. Pages read
/// from here; anything that changes the install calls [refresh] afterwards.
class AppState extends ChangeNotifier {
AppState(this.brew);
final BrewClient brew;
List<Package> installed = const [];
List<OutdatedEntry> outdated = const [];
List<Tap> taps = const [];
/// Also report casks that update themselves or have version :latest.
bool greedy = false;
bool loading = false;
Object? error;
Future<void> refresh() async {
loading = true;
error = null;
notifyListeners();
try {
final results = await Future.wait([
brew.installed(),
brew.outdated(greedy: greedy),
brew.taps(),
]);
installed = results[0] as List<Package>;
outdated = results[1] as List<OutdatedEntry>;
taps = results[2] as List<Tap>;
} catch (e) {
error = e;
} finally {
loading = false;
notifyListeners();
}
}
Future<void> setGreedy(bool value) async {
greedy = value;
await refresh();
}
Package? installedPackage(String name, PackageKind kind) {
for (final p in installed) {
if (p.kind == kind && (p.name == name || p.fullName == name)) return p;
}
return null;
}
}

204
lib/ui/common.dart Normal file
View file

@ -0,0 +1,204 @@
import 'dart:io';
import 'package:flutter/material.dart';
import '../brew/brew_client.dart';
import '../brew/models.dart';
import '../state/app_state.dart';
import 'task_dialog.dart';
/// Makes the [AppState] available to the widget tree.
class AppScope extends InheritedNotifier<AppState> {
const AppScope({super.key, required AppState state, required super.child})
: super(notifier: state);
/// Reads the state and rebuilds the caller when it changes.
static AppState of(BuildContext context) =>
context.dependOnInheritedWidgetOfExactType<AppScope>()!.notifier!;
/// Reads the state without subscribing, for use in callbacks.
static AppState read(BuildContext context) =>
context.getInheritedWidgetOfExactType<AppScope>()!.notifier!;
}
/// Runs [task] in a dialog showing its live output, then refreshes the app
/// state. Returns whether the command succeeded.
Future<bool> runTask(BuildContext context, BrewTask task) async {
final state = AppScope.read(context);
await showDialog<void>(
context: context,
barrierDismissible: false,
builder: (_) => TaskDialog(task: task),
);
// Not awaited: the lists update in place once brew answers.
state.refresh();
return task.succeeded;
}
Future<bool> confirm(
BuildContext context, {
required String title,
required String message,
String action = 'OK',
}) async {
final ok = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: Text(title),
content: Text(message),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('Cancel'),
),
FilledButton(
onPressed: () => Navigator.pop(context, true),
child: Text(action),
),
],
),
);
return ok ?? false;
}
void openUrl(String url) => Process.run('open', [url]);
IconData kindIcon(PackageKind kind) =>
kind == PackageKind.formula ? Icons.terminal : Icons.apps;
/// Title row + optional controls shown at the top of every page.
class PageHeader extends StatelessWidget {
const PageHeader({
super.key,
required this.title,
this.actions = const [],
this.bottom,
});
final String title;
final List<Widget> actions;
final Widget? bottom;
@override
Widget build(BuildContext context) {
final state = AppScope.of(context);
return Padding(
padding: const EdgeInsets.fromLTRB(24, 20, 16, 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
children: [
Text(title, style: Theme.of(context).textTheme.headlineSmall),
const SizedBox(width: 12),
if (state.loading)
const SizedBox.square(
dimension: 16,
child: CircularProgressIndicator(strokeWidth: 2),
),
const Spacer(),
...actions,
IconButton(
tooltip: 'Reload',
onPressed: state.loading ? null : state.refresh,
icon: const Icon(Icons.refresh),
),
],
),
if (state.error != null) ...[
const SizedBox(height: 8),
ErrorBanner(state.error!),
],
if (bottom != null) ...[const SizedBox(height: 12), bottom!],
],
),
);
}
}
class ErrorBanner extends StatelessWidget {
const ErrorBanner(this.error, {super.key});
final Object error;
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
return Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: scheme.errorContainer,
borderRadius: BorderRadius.circular(8),
),
child: SelectableText(
'$error',
style: TextStyle(color: scheme.onErrorContainer),
),
);
}
}
class Tag extends StatelessWidget {
const Tag(this.label, {super.key, this.color});
final String label;
final Color? color;
@override
Widget build(BuildContext context) {
final c = color ?? Theme.of(context).colorScheme.secondary;
return Container(
margin: const EdgeInsets.only(left: 6),
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
border: Border.all(color: c),
borderRadius: BorderRadius.circular(4),
),
child: Text(
label,
style: Theme.of(context).textTheme.labelSmall?.copyWith(color: c),
),
);
}
}
class EmptyState extends StatelessWidget {
const EmptyState(this.message, {super.key, this.icon = Icons.inbox});
final String message;
final IconData icon;
@override
Widget build(BuildContext context) {
final color = Theme.of(context).colorScheme.outline;
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 48, color: color),
const SizedBox(height: 12),
Text(message, style: TextStyle(color: color)),
],
),
);
}
}
/// A search box used to filter lists.
class FilterField extends StatelessWidget {
const FilterField({super.key, required this.onChanged, this.hint = 'Filter'});
final ValueChanged<String> onChanged;
final String hint;
@override
Widget build(BuildContext context) => TextField(
onChanged: onChanged,
decoration: InputDecoration(
isDense: true,
prefixIcon: const Icon(Icons.filter_list),
hintText: hint,
border: const OutlineInputBorder(),
),
);
}

135
lib/ui/installed_page.dart Normal file
View file

@ -0,0 +1,135 @@
import 'package:flutter/material.dart';
import '../brew/models.dart';
import 'common.dart';
import 'package_details.dart';
class InstalledPage extends StatefulWidget {
const InstalledPage({super.key});
@override
State<InstalledPage> createState() => _InstalledPageState();
}
class _InstalledPageState extends State<InstalledPage> {
String _filter = '';
PackageKind? _kind;
/// Hide formulae that were only pulled in as dependencies.
bool _requestedOnly = false;
@override
Widget build(BuildContext context) {
final state = AppScope.of(context);
final q = _filter.toLowerCase();
final packages =
state.installed
.where((p) => _kind == null || p.kind == _kind)
.where((p) => !_requestedOnly || p.installedOnRequest)
.where(
(p) =>
q.isEmpty ||
p.name.toLowerCase().contains(q) ||
(p.displayName?.toLowerCase().contains(q) ?? false) ||
(p.desc?.toLowerCase().contains(q) ?? false),
)
.toList()
..sort((a, b) => a.name.compareTo(b.name));
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
PageHeader(
title: 'Installed',
bottom: Row(
children: [
Expanded(
child: FilterField(
onChanged: (v) => setState(() => _filter = v),
),
),
const SizedBox(width: 12),
SegmentedButton<PackageKind?>(
segments: const [
ButtonSegment(value: null, label: Text('All')),
ButtonSegment(
value: PackageKind.formula,
label: Text('Formulae'),
),
ButtonSegment(value: PackageKind.cask, label: Text('Casks')),
],
selected: {_kind},
onSelectionChanged: (s) => setState(() => _kind = s.first),
),
const SizedBox(width: 12),
FilterChip(
label: const Text('Requested only'),
tooltip: 'Hide formulae installed only as dependencies',
selected: _requestedOnly,
onSelected: (v) => setState(() => _requestedOnly = v),
),
],
),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 24),
child: Text(
'${packages.length} of ${state.installed.length}',
style: TextStyle(color: Theme.of(context).colorScheme.outline),
),
),
Expanded(
child: packages.isEmpty
? EmptyState(
state.loading ? 'Loading…' : 'Nothing installed matches.',
)
: ListView.builder(
itemCount: packages.length,
itemBuilder: (context, i) =>
PackageTile(package: packages[i]),
),
),
],
);
}
}
class PackageTile extends StatelessWidget {
const PackageTile({super.key, required this.package});
final Package package;
@override
Widget build(BuildContext context) {
final p = package;
final scheme = Theme.of(context).colorScheme;
return ListTile(
leading: Icon(kindIcon(p.kind)),
title: Row(
children: [
Flexible(child: Text(p.name, overflow: TextOverflow.ellipsis)),
if (p.displayName != null && p.displayName != p.name)
Padding(
padding: const EdgeInsets.only(left: 8),
child: Text(
p.displayName!,
style: TextStyle(color: scheme.outline),
),
),
if (p.outdated) Tag('outdated', color: scheme.tertiary),
if (p.pinned) const Tag('pinned'),
if (p.kind == PackageKind.formula && !p.installedOnRequest)
Tag('dependency', color: scheme.outline),
],
),
subtitle: p.desc == null
? null
: Text(p.desc!, maxLines: 1, overflow: TextOverflow.ellipsis),
trailing: Text(
p.installedVersion ?? '',
style: const TextStyle(fontFamily: 'Menlo', fontSize: 12),
),
onTap: () => showPackageDetails(context, p.fullName, p.kind),
);
}
}

104
lib/ui/outdated_page.dart Normal file
View file

@ -0,0 +1,104 @@
import 'package:flutter/material.dart';
import 'common.dart';
import 'package_details.dart';
class OutdatedPage extends StatelessWidget {
const OutdatedPage({super.key});
@override
Widget build(BuildContext context) {
final state = AppScope.of(context);
final scheme = Theme.of(context).colorScheme;
final upgradable = state.outdated.where((e) => !e.pinned).length;
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
PageHeader(
title: 'Outdated',
actions: [
Tooltip(
message:
'Include casks that update themselves or use '
'version :latest (brew outdated --greedy)',
child: Row(
children: [
const Text('Greedy'),
Switch(
value: state.greedy,
onChanged: state.loading ? null : state.setGreedy,
),
],
),
),
const SizedBox(width: 8),
FilledButton.icon(
icon: const Icon(Icons.upgrade),
label: Text('Upgrade all ($upgradable)'),
onPressed: upgradable == 0
? null
: () => runTask(
context,
state.brew.upgrade(greedy: state.greedy),
),
),
const SizedBox(width: 8),
],
),
Expanded(
child: state.outdated.isEmpty
? EmptyState(
state.loading ? 'Checking…' : 'Everything is up to date.',
icon: Icons.check_circle_outline,
)
: ListView(
children: [
for (final e in state.outdated)
ListTile(
leading: Icon(kindIcon(e.kind)),
title: Row(
children: [
Text(e.name),
if (e.pinned) const Tag('pinned'),
],
),
subtitle: Text(
'${e.installedVersions.join(', ')} → ${e.currentVersion}',
style: const TextStyle(
fontFamily: 'Menlo',
fontSize: 12,
),
),
trailing: OutlinedButton(
onPressed: e.pinned
? null
: () => runTask(
context,
state.brew.upgrade(
name: e.name,
kind: e.kind,
greedy: state.greedy,
),
),
child: const Text('Upgrade'),
),
onTap: () =>
showPackageDetails(context, e.name, e.kind),
),
if (state.outdated.any((e) => e.pinned))
Padding(
padding: const EdgeInsets.all(16),
child: Text(
'Pinned formulae are skipped by upgrades; unpin them '
'from their details to upgrade.',
style: TextStyle(color: scheme.outline),
),
),
],
),
),
],
);
}
}

241
lib/ui/package_details.dart Normal file
View file

@ -0,0 +1,241 @@
import 'package:flutter/material.dart';
import '../brew/models.dart';
import 'common.dart';
Future<void> showPackageDetails(
BuildContext context,
String name,
PackageKind kind,
) => showDialog<void>(
context: context,
builder: (_) => PackageDetailsDialog(name: name, kind: kind),
);
/// Full `brew info` for one package, with the actions that apply to it.
class PackageDetailsDialog extends StatefulWidget {
const PackageDetailsDialog({
super.key,
required this.name,
required this.kind,
});
final String name;
final PackageKind kind;
@override
State<PackageDetailsDialog> createState() => _PackageDetailsDialogState();
}
class _PackageDetailsDialogState extends State<PackageDetailsDialog> {
late Future<Package?> _info;
@override
void initState() {
super.initState();
_load();
}
void _load() =>
_info = AppScope.read(context).brew.info(widget.name, widget.kind);
Future<void> _act(Future<bool> Function() action) async {
await action();
if (mounted) setState(_load);
}
@override
Widget build(BuildContext context) {
return AlertDialog(
contentPadding: const EdgeInsets.fromLTRB(24, 16, 24, 0),
content: SizedBox(
width: 600,
child: FutureBuilder<Package?>(
future: _info,
builder: (context, snap) {
if (snap.connectionState != ConnectionState.done) {
return const SizedBox(
height: 160,
child: Center(child: CircularProgressIndicator()),
);
}
if (snap.hasError) return ErrorBanner(snap.error!);
final p = snap.data;
if (p == null) return Text('No information for ${widget.name}.');
return _Details(package: p);
},
),
),
actions: [
FutureBuilder<Package?>(
future: _info,
builder: (context, snap) => snap.data == null
? const SizedBox.shrink()
: Wrap(spacing: 8, children: _actions(snap.data!)),
),
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Close'),
),
],
);
}
List<Widget> _actions(Package p) {
final brew = AppScope.read(context).brew;
if (!p.isInstalled) {
return [
FilledButton.icon(
icon: const Icon(Icons.download),
label: const Text('Install'),
onPressed: () =>
_act(() => runTask(context, brew.install(p.fullName, p.kind))),
),
];
}
return [
if (p.outdated && !p.pinned)
FilledButton.icon(
icon: const Icon(Icons.upgrade),
label: const Text('Upgrade'),
onPressed: () => _act(
() =>
runTask(context, brew.upgrade(name: p.fullName, kind: p.kind)),
),
),
if (p.kind == PackageKind.formula)
OutlinedButton.icon(
icon: Icon(p.pinned ? Icons.push_pin_outlined : Icons.push_pin),
label: Text(p.pinned ? 'Unpin' : 'Pin'),
onPressed: () => _act(
() => runTask(context, brew.pin(p.fullName, pinned: !p.pinned)),
),
),
OutlinedButton(
onPressed: () =>
_act(() => runTask(context, brew.reinstall(p.fullName, p.kind))),
child: const Text('Reinstall'),
),
OutlinedButton.icon(
style: OutlinedButton.styleFrom(
foregroundColor: Theme.of(context).colorScheme.error,
),
icon: const Icon(Icons.delete_outline),
label: const Text('Uninstall'),
onPressed: () => _act(() async {
final ok = await confirm(
context,
title: 'Uninstall ${p.name}?',
message: p.kind == PackageKind.formula
? 'Homebrew will refuse if other installed formulae '
'depend on it.'
: 'This removes the app and its Homebrew-managed files.',
action: 'Uninstall',
);
if (!ok || !mounted) return false;
return runTask(context, brew.uninstall(p.fullName, p.kind));
}),
),
];
}
}
class _Details extends StatelessWidget {
const _Details({required this.package});
final Package package;
@override
Widget build(BuildContext context) {
final p = package;
final theme = Theme.of(context);
final muted = TextStyle(color: theme.colorScheme.outline);
return SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Row(
children: [
Icon(kindIcon(p.kind)),
const SizedBox(width: 8),
Flexible(
child: Text(
p.displayName ?? p.name,
style: theme.textTheme.headlineSmall,
),
),
Tag(p.kind.label),
if (p.isInstalled)
Tag('installed', color: theme.colorScheme.primary),
if (p.outdated)
Tag('outdated', color: theme.colorScheme.tertiary),
if (p.pinned) const Tag('pinned'),
if (p.deprecated)
Tag('deprecated', color: theme.colorScheme.error),
],
),
if (p.displayName != null) Text(p.fullName, style: muted),
const SizedBox(height: 12),
if (p.desc != null) Text(p.desc!, style: theme.textTheme.bodyLarge),
const SizedBox(height: 12),
_field('Tap', p.tap ?? '—'),
_field('Latest', p.latestVersion ?? '—'),
_field('Installed', p.installedVersion ?? 'not installed'),
if (p.homepage != null)
Row(
children: [
const SizedBox(width: 90, child: Text('Homepage')),
Flexible(
child: InkWell(
onTap: () => openUrl(p.homepage!),
child: Text(
p.homepage!,
style: TextStyle(color: theme.colorScheme.primary),
),
),
),
],
),
if (p.dependencies.isNotEmpty) ...[
const SizedBox(height: 12),
Text('Dependencies', style: theme.textTheme.titleSmall),
const SizedBox(height: 6),
Wrap(
spacing: 6,
runSpacing: 6,
children: [
for (final d in p.dependencies)
ActionChip(
label: Text(d),
onPressed: () =>
showPackageDetails(context, d, PackageKind.formula),
),
],
),
],
if (p.caveats != null && p.caveats!.trim().isNotEmpty) ...[
const SizedBox(height: 12),
Text('Caveats', style: theme.textTheme.titleSmall),
const SizedBox(height: 6),
SelectableText(
p.caveats!.trim(),
style: const TextStyle(fontFamily: 'Menlo', fontSize: 12),
),
],
const SizedBox(height: 16),
],
),
);
}
Widget _field(String label, String value) => Padding(
padding: const EdgeInsets.only(bottom: 4),
child: Row(
children: [
SizedBox(width: 90, child: Text(label)),
Flexible(child: SelectableText(value)),
],
),
);
}

125
lib/ui/search_page.dart Normal file
View file

@ -0,0 +1,125 @@
import 'package:flutter/material.dart';
import '../brew/models.dart';
import 'common.dart';
import 'package_details.dart';
class SearchPage extends StatefulWidget {
const SearchPage({super.key});
@override
State<SearchPage> createState() => _SearchPageState();
}
class _SearchPageState extends State<SearchPage> {
final _controller = TextEditingController();
Future<List<SearchResult>>? _results;
String _query = '';
@override
void dispose() {
_controller.dispose();
super.dispose();
}
void _search() {
final q = _controller.text.trim();
if (q.isEmpty) return;
setState(() {
_query = q;
_results = AppScope.read(context).brew.search(q);
});
}
@override
Widget build(BuildContext context) {
final state = AppScope.of(context);
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
PageHeader(
title: 'Search',
bottom: Row(
children: [
Expanded(
child: TextField(
controller: _controller,
autofocus: true,
onSubmitted: (_) => _search(),
decoration: const InputDecoration(
isDense: true,
prefixIcon: Icon(Icons.search),
hintText: 'Formula or cask name — /regex/ also works',
border: OutlineInputBorder(),
),
),
),
const SizedBox(width: 12),
FilledButton(onPressed: _search, child: const Text('Search')),
],
),
),
Expanded(
child: _results == null
? const EmptyState(
'Search all formulae and casks in your taps.',
icon: Icons.search,
)
: FutureBuilder<List<SearchResult>>(
future: _results,
builder: (context, snap) {
if (snap.connectionState != ConnectionState.done) {
return const Center(child: CircularProgressIndicator());
}
if (snap.hasError) {
return Padding(
padding: const EdgeInsets.all(24),
child: ErrorBanner(snap.error!),
);
}
final results = snap.data!;
if (results.isEmpty) {
return EmptyState('No matches for "$_query".');
}
return ListView.builder(
itemCount: results.length,
itemBuilder: (context, i) {
final r = results[i];
final installed = state.installedPackage(
r.name,
r.kind,
);
return ListTile(
leading: Icon(kindIcon(r.kind)),
title: Row(
children: [
Text(r.name),
Tag(r.kind.label),
if (installed != null)
Tag(
'installed',
color: Theme.of(context).colorScheme.primary,
),
],
),
trailing: installed != null
? null
: OutlinedButton(
onPressed: () => runTask(
context,
state.brew.install(r.name, r.kind),
),
child: const Text('Install'),
),
onTap: () =>
showPackageDetails(context, r.name, r.kind),
);
},
);
},
),
),
],
);
}
}

295
lib/ui/taps_page.dart Normal file
View file

@ -0,0 +1,295 @@
import 'package:flutter/material.dart';
import '../brew/models.dart';
import 'common.dart';
import 'package_details.dart';
/// Lists taps on the left; the selected tap's formulae and casks on the right.
class TapsPage extends StatefulWidget {
const TapsPage({super.key});
@override
State<TapsPage> createState() => _TapsPageState();
}
class _TapsPageState extends State<TapsPage> {
String? _selected;
@override
Widget build(BuildContext context) {
final state = AppScope.of(context);
final taps = state.taps;
final selected =
taps.where((t) => t.name == _selected).firstOrNull ?? taps.firstOrNull;
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
PageHeader(
title: 'Taps',
actions: [
FilledButton.icon(
icon: const Icon(Icons.add),
label: const Text('Add tap'),
onPressed: () => _addTap(context),
),
const SizedBox(width: 8),
],
),
Expanded(
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
SizedBox(
width: 280,
child: ListView(
children: [
for (final t in taps)
ListTile(
selected: t.name == selected?.name,
leading: Icon(
t.official ? Icons.verified : Icons.source,
),
title: Text(t.name),
subtitle: Text(_counts(t)),
onTap: () => setState(() => _selected = t.name),
),
],
),
),
const VerticalDivider(width: 1),
Expanded(
child: selected == null
? const EmptyState('No taps.')
// Keyed so filter/tab state resets when switching taps.
: _TapContents(key: ValueKey(selected.name), tap: selected),
),
],
),
),
],
);
}
static String _counts(Tap t) => [
if (t.formulaNames.isNotEmpty) '${t.formulaNames.length} formulae',
if (t.caskTokens.isNotEmpty) '${t.caskTokens.length} casks',
if (t.formulaNames.isEmpty && t.caskTokens.isEmpty) 'empty',
].join(' · ');
Future<void> _addTap(BuildContext context) async {
final name = TextEditingController();
final url = TextEditingController();
final ok = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Add tap'),
content: SizedBox(
width: 420,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextField(
controller: name,
autofocus: true,
decoration: const InputDecoration(
labelText: 'Name',
hintText: 'user/repo',
),
),
const SizedBox(height: 8),
TextField(
controller: url,
decoration: const InputDecoration(
labelText: 'Git URL (optional)',
hintText: 'Defaults to github.com/user/homebrew-repo',
),
),
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('Cancel'),
),
FilledButton(
onPressed: () => Navigator.pop(context, true),
child: const Text('Tap'),
),
],
),
);
final n = name.text.trim();
final u = url.text.trim();
name.dispose();
url.dispose();
if (ok != true || n.isEmpty || !context.mounted) return;
await runTask(context, AppScope.read(context).brew.addTap(n, url: u));
if (mounted) setState(() => _selected = n);
}
}
class _TapContents extends StatefulWidget {
const _TapContents({super.key, required this.tap});
final Tap tap;
@override
State<_TapContents> createState() => _TapContentsState();
}
class _TapContentsState extends State<_TapContents> {
String _filter = '';
@override
Widget build(BuildContext context) {
final t = widget.tap;
final muted = TextStyle(color: Theme.of(context).colorScheme.outline);
final hasBoth = t.formulaNames.isNotEmpty && t.caskTokens.isNotEmpty;
final kinds = [
// An empty tap still gets one (empty) list.
if (t.formulaNames.isNotEmpty || t.caskTokens.isEmpty)
PackageKind.formula,
if (t.caskTokens.isNotEmpty) PackageKind.cask,
];
return DefaultTabController(
length: kinds.length,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 0),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(
t.name,
style: Theme.of(context).textTheme.titleLarge,
),
if (t.official) const Tag('official'),
if (!t.cloned) Tag('via API', color: muted.color),
],
),
if (t.remote != null)
SelectableText(t.remote!, style: muted),
if (t.path != null && t.cloned)
SelectableText(t.path!, style: muted),
if (t.branch != null || t.lastCommit != null)
Text(
[
if (t.branch != null) 'branch ${t.branch}',
if (t.lastCommit != null)
'last commit ${t.lastCommit}',
].join(' · '),
style: muted,
),
],
),
),
OutlinedButton.icon(
icon: const Icon(Icons.link_off),
label: const Text('Untap'),
// homebrew/core and homebrew/cask come from the JSON API
// unless cloned; there's nothing local to untap.
onPressed: !t.cloned
? null
: () async {
final ok = await confirm(
context,
title: 'Untap ${t.name}?',
message:
'Homebrew refuses if you have packages '
'installed from this tap.',
action: 'Untap',
);
if (ok && context.mounted) {
await runTask(
context,
AppScope.read(context).brew.removeTap(t.name),
);
}
},
),
],
),
),
Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 0),
child: FilterField(onChanged: (v) => setState(() => _filter = v)),
),
if (hasBoth)
TabBar(
tabs: [
Tab(text: 'Formulae (${t.formulaNames.length})'),
Tab(text: 'Casks (${t.caskTokens.length})'),
],
),
Expanded(
child: TabBarView(
children: [
for (final k in kinds)
_NameList(
names: k == PackageKind.formula
? t.formulaNames
: t.caskTokens,
kind: k,
filter: _filter,
),
],
),
),
],
),
);
}
}
class _NameList extends StatelessWidget {
const _NameList({
required this.names,
required this.kind,
required this.filter,
});
final List<String> names;
final PackageKind kind;
final String filter;
@override
Widget build(BuildContext context) {
final state = AppScope.of(context);
final q = filter.toLowerCase();
final shown = q.isEmpty
? names
: names.where((n) => n.toLowerCase().contains(q)).toList();
if (shown.isEmpty) return const EmptyState('No matches.');
return ListView.builder(
itemCount: shown.length,
itemExtent: 40,
itemBuilder: (context, i) {
final name = shown[i];
final installed = state.installedPackage(name, kind) != null;
return ListTile(
dense: true,
leading: Icon(kindIcon(kind), size: 18),
title: Text(name),
trailing: installed
? Icon(
Icons.check_circle,
size: 18,
color: Theme.of(context).colorScheme.primary,
)
: null,
onTap: () => showPackageDetails(context, name, kind),
);
},
);
}
}

106
lib/ui/task_dialog.dart Normal file
View file

@ -0,0 +1,106 @@
import 'package:flutter/material.dart';
import '../brew/brew_client.dart';
/// Runs a [BrewTask] and shows its output as it streams in.
class TaskDialog extends StatefulWidget {
const TaskDialog({super.key, required this.task});
final BrewTask task;
@override
State<TaskDialog> createState() => _TaskDialogState();
}
class _TaskDialogState extends State<TaskDialog> {
final _scroll = ScrollController();
@override
void initState() {
super.initState();
widget.task.addListener(_onOutput);
widget.task.run();
}
@override
void dispose() {
widget.task.removeListener(_onOutput);
_scroll.dispose();
super.dispose();
}
void _onOutput() {
if (!mounted) return;
// Follow the output, unless the user scrolled up to read something.
final follow =
!_scroll.hasClients ||
_scroll.position.pixels >= _scroll.position.maxScrollExtent - 40;
setState(() {});
if (follow) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (_scroll.hasClients) {
_scroll.jumpTo(_scroll.position.maxScrollExtent);
}
});
}
}
@override
Widget build(BuildContext context) {
final task = widget.task;
final scheme = Theme.of(context).colorScheme;
final Widget status = task.running
? const SizedBox.square(
dimension: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: task.succeeded
? Icon(Icons.check_circle, color: scheme.primary)
: Icon(Icons.error, color: scheme.error);
return AlertDialog(
title: Row(
children: [
status,
const SizedBox(width: 12),
Expanded(child: Text(task.title)),
],
),
content: SizedBox(
width: 720,
height: 420,
child: Container(
decoration: BoxDecoration(
color: scheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(8),
),
padding: const EdgeInsets.all(12),
child: SingleChildScrollView(
controller: _scroll,
child: SelectableText(
task.lines.join('\n'),
style: const TextStyle(fontFamily: 'Menlo', fontSize: 12),
),
),
),
),
actions: [
if (!task.running && !task.succeeded)
Padding(
padding: const EdgeInsets.only(right: 8),
child: Text(
'Exited with code ${task.exitCode}',
style: TextStyle(color: scheme.error),
),
),
if (task.running)
TextButton(onPressed: task.cancel, child: const Text('Cancel'))
else
FilledButton(
onPressed: () => Navigator.pop(context),
child: const Text('Close'),
),
],
);
}
}

7
macos/.gitignore vendored Normal file
View file

@ -0,0 +1,7 @@
# Flutter-related
**/Flutter/ephemeral/
**/Pods/
# Xcode-related
**/dgph
**/xcuserdata/

View file

@ -0,0 +1 @@
#include "ephemeral/Flutter-Generated.xcconfig"

View file

@ -0,0 +1 @@
#include "ephemeral/Flutter-Generated.xcconfig"

View file

@ -0,0 +1,10 @@
//
// Generated file. Do not edit.
//
import FlutterMacOS
import Foundation
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
}

View file

@ -0,0 +1,729 @@
// !$*UTF8*$!
{
archiveVersion = 1;
classes = {
};
objectVersion = 54;
objects = {
/* Begin PBXAggregateTarget section */
33CC111A2044C6BA0003C045 /* Flutter Assemble */ = {
isa = PBXAggregateTarget;
buildConfigurationList = 33CC111B2044C6BA0003C045 /* Build configuration list for PBXAggregateTarget "Flutter Assemble" */;
buildPhases = (
33CC111E2044C6BF0003C045 /* ShellScript */,
);
dependencies = (
);
name = "Flutter Assemble";
productName = FLX;
};
/* End PBXAggregateTarget section */
/* Begin PBXBuildFile section */
331C80D8294CF71000263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C80D7294CF71000263BE5 /* RunnerTests.swift */; };
335BBD1B22A9A15E00E9071D /* GeneratedPluginRegistrant.swift in Sources */ = {isa = PBXBuildFile; fileRef = 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */; };
33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC10F02044A3C60003C045 /* AppDelegate.swift */; };
33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F22044A3C60003C045 /* Assets.xcassets */; };
33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F42044A3C60003C045 /* MainMenu.xib */; };
33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */; };
78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
331C80D9294CF71000263BE5 /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = 33CC10E52044A3C60003C045 /* Project object */;
proxyType = 1;
remoteGlobalIDString = 33CC10EC2044A3C60003C045;
remoteInfo = Runner;
};
33CC111F2044C79F0003C045 /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = 33CC10E52044A3C60003C045 /* Project object */;
proxyType = 1;
remoteGlobalIDString = 33CC111A2044C6BA0003C045;
remoteInfo = FLX;
};
/* End PBXContainerItemProxy section */
/* Begin PBXCopyFilesBuildPhase section */
33CC110E2044A8840003C045 /* Bundle Framework */ = {
isa = PBXCopyFilesBuildPhase;
buildActionMask = 2147483647;
dstPath = "";
dstSubfolderSpec = 10;
files = (
);
name = "Bundle Framework";
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXCopyFilesBuildPhase section */
/* Begin PBXFileReference section */
331C80D5294CF71000263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
331C80D7294CF71000263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = "<group>"; };
333000ED22D3DE5D00554162 /* Warnings.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Warnings.xcconfig; sourceTree = "<group>"; };
335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GeneratedPluginRegistrant.swift; sourceTree = "<group>"; };
33CC10ED2044A3C60003C045 /* taproom.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "taproom.app"; sourceTree = BUILT_PRODUCTS_DIR; };
33CC10F02044A3C60003C045 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
33CC10F22044A3C60003C045 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Assets.xcassets; path = Runner/Assets.xcassets; sourceTree = "<group>"; };
33CC10F52044A3C60003C045 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.xib; name = Base; path = Base.lproj/MainMenu.xib; sourceTree = "<group>"; };
33CC10F72044A3C60003C045 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; name = Info.plist; path = Runner/Info.plist; sourceTree = "<group>"; };
33CC11122044BFA00003C045 /* MainFlutterWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainFlutterWindow.swift; sourceTree = "<group>"; };
33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Flutter-Debug.xcconfig"; sourceTree = "<group>"; };
33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Flutter-Release.xcconfig"; sourceTree = "<group>"; };
33CEB47722A0578A004F2AC0 /* Flutter-Generated.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = "Flutter-Generated.xcconfig"; path = "ephemeral/Flutter-Generated.xcconfig"; sourceTree = "<group>"; };
33E51913231747F40026EE4D /* DebugProfile.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = DebugProfile.entitlements; sourceTree = "<group>"; };
33E51914231749380026EE4D /* Release.entitlements */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.entitlements; path = Release.entitlements; sourceTree = "<group>"; };
33E5194F232828860026EE4D /* AppInfo.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = AppInfo.xcconfig; sourceTree = "<group>"; };
78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = "<group>"; };
7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = "<group>"; };
9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
331C80D2294CF70F00263BE5 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
33CC10EA2044A3C60003C045 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
331C80D6294CF71000263BE5 /* RunnerTests */ = {
isa = PBXGroup;
children = (
331C80D7294CF71000263BE5 /* RunnerTests.swift */,
);
path = RunnerTests;
sourceTree = "<group>";
};
33BA886A226E78AF003329D5 /* Configs */ = {
isa = PBXGroup;
children = (
33E5194F232828860026EE4D /* AppInfo.xcconfig */,
9740EEB21CF90195004384FC /* Debug.xcconfig */,
7AFA3C8E1D35360C0083082E /* Release.xcconfig */,
333000ED22D3DE5D00554162 /* Warnings.xcconfig */,
);
path = Configs;
sourceTree = "<group>";
};
33CC10E42044A3C60003C045 = {
isa = PBXGroup;
children = (
33FAB671232836740065AC1E /* Runner */,
33CEB47122A05771004F2AC0 /* Flutter */,
331C80D6294CF71000263BE5 /* RunnerTests */,
33CC10EE2044A3C60003C045 /* Products */,
D73912EC22F37F3D000D13A0 /* Frameworks */,
);
sourceTree = "<group>";
};
33CC10EE2044A3C60003C045 /* Products */ = {
isa = PBXGroup;
children = (
33CC10ED2044A3C60003C045 /* taproom.app */,
331C80D5294CF71000263BE5 /* RunnerTests.xctest */,
);
name = Products;
sourceTree = "<group>";
};
33CC11242044D66E0003C045 /* Resources */ = {
isa = PBXGroup;
children = (
33CC10F22044A3C60003C045 /* Assets.xcassets */,
33CC10F42044A3C60003C045 /* MainMenu.xib */,
33CC10F72044A3C60003C045 /* Info.plist */,
);
name = Resources;
path = ..;
sourceTree = "<group>";
};
33CEB47122A05771004F2AC0 /* Flutter */ = {
isa = PBXGroup;
children = (
78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */,
335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */,
33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */,
33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */,
33CEB47722A0578A004F2AC0 /* Flutter-Generated.xcconfig */,
);
path = Flutter;
sourceTree = "<group>";
};
33FAB671232836740065AC1E /* Runner */ = {
isa = PBXGroup;
children = (
33CC10F02044A3C60003C045 /* AppDelegate.swift */,
33CC11122044BFA00003C045 /* MainFlutterWindow.swift */,
33E51913231747F40026EE4D /* DebugProfile.entitlements */,
33E51914231749380026EE4D /* Release.entitlements */,
33CC11242044D66E0003C045 /* Resources */,
33BA886A226E78AF003329D5 /* Configs */,
);
path = Runner;
sourceTree = "<group>";
};
D73912EC22F37F3D000D13A0 /* Frameworks */ = {
isa = PBXGroup;
children = (
);
name = Frameworks;
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
331C80D4294CF70F00263BE5 /* RunnerTests */ = {
isa = PBXNativeTarget;
buildConfigurationList = 331C80DE294CF71000263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */;
buildPhases = (
331C80D1294CF70F00263BE5 /* Sources */,
331C80D2294CF70F00263BE5 /* Frameworks */,
331C80D3294CF70F00263BE5 /* Resources */,
);
buildRules = (
);
dependencies = (
331C80DA294CF71000263BE5 /* PBXTargetDependency */,
);
name = RunnerTests;
productName = RunnerTests;
productReference = 331C80D5294CF71000263BE5 /* RunnerTests.xctest */;
productType = "com.apple.product-type.bundle.unit-test";
};
33CC10EC2044A3C60003C045 /* Runner */ = {
isa = PBXNativeTarget;
buildConfigurationList = 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */;
buildPhases = (
33CC10E92044A3C60003C045 /* Sources */,
33CC10EA2044A3C60003C045 /* Frameworks */,
33CC10EB2044A3C60003C045 /* Resources */,
33CC110E2044A8840003C045 /* Bundle Framework */,
3399D490228B24CF009A79C7 /* ShellScript */,
);
buildRules = (
);
dependencies = (
33CC11202044C79F0003C045 /* PBXTargetDependency */,
);
name = Runner;
packageProductDependencies = (
78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */,
);
productName = Runner;
productReference = 33CC10ED2044A3C60003C045 /* taproom.app */;
productType = "com.apple.product-type.application";
};
/* End PBXNativeTarget section */
/* Begin PBXProject section */
33CC10E52044A3C60003C045 /* Project object */ = {
isa = PBXProject;
attributes = {
BuildIndependentTargetsInParallel = YES;
LastSwiftUpdateCheck = 0920;
LastUpgradeCheck = 1510;
ORGANIZATIONNAME = "";
TargetAttributes = {
331C80D4294CF70F00263BE5 = {
CreatedOnToolsVersion = 14.0;
TestTargetID = 33CC10EC2044A3C60003C045;
};
33CC10EC2044A3C60003C045 = {
CreatedOnToolsVersion = 9.2;
LastSwiftMigration = 1100;
ProvisioningStyle = Automatic;
SystemCapabilities = {
com.apple.Sandbox = {
enabled = 1;
};
};
};
33CC111A2044C6BA0003C045 = {
CreatedOnToolsVersion = 9.2;
ProvisioningStyle = Manual;
};
};
};
buildConfigurationList = 33CC10E82044A3C60003C045 /* Build configuration list for PBXProject "Runner" */;
compatibilityVersion = "Xcode 9.3";
developmentRegion = en;
hasScannedForEncodings = 0;
knownRegions = (
en,
Base,
);
mainGroup = 33CC10E42044A3C60003C045;
packageReferences = (
781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */,
);
productRefGroup = 33CC10EE2044A3C60003C045 /* Products */;
projectDirPath = "";
projectRoot = "";
targets = (
33CC10EC2044A3C60003C045 /* Runner */,
331C80D4294CF70F00263BE5 /* RunnerTests */,
33CC111A2044C6BA0003C045 /* Flutter Assemble */,
);
};
/* End PBXProject section */
/* Begin PBXResourcesBuildPhase section */
331C80D3294CF70F00263BE5 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
33CC10EB2044A3C60003C045 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */,
33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXResourcesBuildPhase section */
/* Begin PBXShellScriptBuildPhase section */
3399D490228B24CF009A79C7 /* ShellScript */ = {
isa = PBXShellScriptBuildPhase;
alwaysOutOfDate = 1;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
);
inputPaths = (
);
outputFileListPaths = (
);
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "echo \"$PRODUCT_NAME.app\" > \"$PROJECT_DIR\"/Flutter/ephemeral/.app_filename && \"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh embed\n";
};
33CC111E2044C6BF0003C045 /* ShellScript */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
Flutter/ephemeral/FlutterInputs.xcfilelist,
);
inputPaths = (
Flutter/ephemeral/tripwire,
);
outputFileListPaths = (
Flutter/ephemeral/FlutterOutputs.xcfilelist,
);
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "\"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh && touch Flutter/ephemeral/tripwire";
};
/* End PBXShellScriptBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
331C80D1294CF70F00263BE5 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
331C80D8294CF71000263BE5 /* RunnerTests.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
33CC10E92044A3C60003C045 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */,
33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */,
335BBD1B22A9A15E00E9071D /* GeneratedPluginRegistrant.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */
/* Begin PBXTargetDependency section */
331C80DA294CF71000263BE5 /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = 33CC10EC2044A3C60003C045 /* Runner */;
targetProxy = 331C80D9294CF71000263BE5 /* PBXContainerItemProxy */;
};
33CC11202044C79F0003C045 /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = 33CC111A2044C6BA0003C045 /* Flutter Assemble */;
targetProxy = 33CC111F2044C79F0003C045 /* PBXContainerItemProxy */;
};
/* End PBXTargetDependency section */
/* Begin PBXVariantGroup section */
33CC10F42044A3C60003C045 /* MainMenu.xib */ = {
isa = PBXVariantGroup;
children = (
33CC10F52044A3C60003C045 /* Base */,
);
name = MainMenu.xib;
path = Runner;
sourceTree = "<group>";
};
/* End PBXVariantGroup section */
/* Begin XCBuildConfiguration section */
331C80DB294CF71000263BE5 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.philippeterson.taproom.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/taproom.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/taproom";
};
name = Debug;
};
331C80DC294CF71000263BE5 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.philippeterson.taproom.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/taproom.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/taproom";
};
name = Release;
};
331C80DD294CF71000263BE5 /* Profile */ = {
isa = XCBuildConfiguration;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.philippeterson.taproom.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/taproom.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/taproom";
};
name = Profile;
};
338D0CE9231458BD00FA5F75 /* Profile */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
CLANG_CXX_LIBRARY = "libc++";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CODE_SIGN_IDENTITY = "-";
COPY_PHASE_STRIP = NO;
DEAD_CODE_STRIPPING = YES;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
GCC_C_LANGUAGE_STANDARD = gnu11;
GCC_NO_COMMON_BLOCKS = YES;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
MACOSX_DEPLOYMENT_TARGET = 12.0;
MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = macosx;
SWIFT_COMPILATION_MODE = wholemodule;
SWIFT_OPTIMIZATION_LEVEL = "-O";
};
name = Profile;
};
338D0CEA231458BD00FA5F75 /* Profile */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements;
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
INFOPLIST_FILE = Runner/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/../Frameworks",
);
PROVISIONING_PROFILE_SPECIFIER = "";
SWIFT_VERSION = 5.0;
};
name = Profile;
};
338D0CEB231458BD00FA5F75 /* Profile */ = {
isa = XCBuildConfiguration;
buildSettings = {
CODE_SIGN_STYLE = Manual;
PRODUCT_NAME = "$(TARGET_NAME)";
};
name = Profile;
};
33CC10F92044A3C60003C045 /* Debug */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
CLANG_CXX_LIBRARY = "libc++";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CODE_SIGN_IDENTITY = "-";
COPY_PHASE_STRIP = NO;
DEAD_CODE_STRIPPING = YES;
DEBUG_INFORMATION_FORMAT = dwarf;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
GCC_C_LANGUAGE_STANDARD = gnu11;
GCC_DYNAMIC_NO_PIC = NO;
GCC_NO_COMMON_BLOCKS = YES;
GCC_OPTIMIZATION_LEVEL = 0;
GCC_PREPROCESSOR_DEFINITIONS = (
"DEBUG=1",
"$(inherited)",
);
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
MACOSX_DEPLOYMENT_TARGET = 12.0;
MTL_ENABLE_DEBUG_INFO = YES;
ONLY_ACTIVE_ARCH = YES;
SDKROOT = macosx;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
};
name = Debug;
};
33CC10FA2044A3C60003C045 /* Release */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
CLANG_CXX_LIBRARY = "libc++";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CODE_SIGN_IDENTITY = "-";
COPY_PHASE_STRIP = NO;
DEAD_CODE_STRIPPING = YES;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
GCC_C_LANGUAGE_STANDARD = gnu11;
GCC_NO_COMMON_BLOCKS = YES;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
MACOSX_DEPLOYMENT_TARGET = 12.0;
MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = macosx;
SWIFT_COMPILATION_MODE = wholemodule;
SWIFT_OPTIMIZATION_LEVEL = "-O";
};
name = Release;
};
33CC10FC2044A3C60003C045 /* Debug */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements;
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
INFOPLIST_FILE = Runner/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/../Frameworks",
);
PROVISIONING_PROFILE_SPECIFIER = "";
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_VERSION = 5.0;
};
name = Debug;
};
33CC10FD2044A3C60003C045 /* Release */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = Runner/Release.entitlements;
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
INFOPLIST_FILE = Runner/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/../Frameworks",
);
PROVISIONING_PROFILE_SPECIFIER = "";
SWIFT_VERSION = 5.0;
};
name = Release;
};
33CC111C2044C6BA0003C045 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
CODE_SIGN_STYLE = Manual;
PRODUCT_NAME = "$(TARGET_NAME)";
};
name = Debug;
};
33CC111D2044C6BA0003C045 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
CODE_SIGN_STYLE = Automatic;
PRODUCT_NAME = "$(TARGET_NAME)";
};
name = Release;
};
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
331C80DE294CF71000263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = {
isa = XCConfigurationList;
buildConfigurations = (
331C80DB294CF71000263BE5 /* Debug */,
331C80DC294CF71000263BE5 /* Release */,
331C80DD294CF71000263BE5 /* Profile */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
33CC10E82044A3C60003C045 /* Build configuration list for PBXProject "Runner" */ = {
isa = XCConfigurationList;
buildConfigurations = (
33CC10F92044A3C60003C045 /* Debug */,
33CC10FA2044A3C60003C045 /* Release */,
338D0CE9231458BD00FA5F75 /* Profile */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */ = {
isa = XCConfigurationList;
buildConfigurations = (
33CC10FC2044A3C60003C045 /* Debug */,
33CC10FD2044A3C60003C045 /* Release */,
338D0CEA231458BD00FA5F75 /* Profile */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
33CC111B2044C6BA0003C045 /* Build configuration list for PBXAggregateTarget "Flutter Assemble" */ = {
isa = XCConfigurationList;
buildConfigurations = (
33CC111C2044C6BA0003C045 /* Debug */,
33CC111D2044C6BA0003C045 /* Release */,
338D0CEB231458BD00FA5F75 /* Profile */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
/* End XCConfigurationList section */
/* Begin XCLocalSwiftPackageReference section */
781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */ = {
isa = XCLocalSwiftPackageReference;
relativePath = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage;
};
/* End XCLocalSwiftPackageReference section */
/* Begin XCSwiftPackageProductDependency section */
78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */ = {
isa = XCSwiftPackageProductDependency;
productName = FlutterGeneratedPluginSwiftPackage;
};
/* End XCSwiftPackageProductDependency section */
};
rootObject = 33CC10E52044A3C60003C045 /* Project object */;
}

View file

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>IDEDidComputeMac32BitWarning</key>
<true/>
</dict>
</plist>

View file

@ -0,0 +1,117 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1510"
version = "1.3">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES">
<PreActions>
<ExecutionAction
ActionType = "Xcode.IDEStandardExecutionActionsCore.ExecutionActionType.ShellScriptAction">
<ActionContent
title = "Run Prepare Flutter Framework Script"
scriptText = "&quot;$FLUTTER_ROOT&quot;/packages/flutter_tools/bin/macos_assemble.sh prepare&#10;">
<EnvironmentBuildable>
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "33CC10EC2044A3C60003C045"
BuildableName = "taproom.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</EnvironmentBuildable>
</ActionContent>
</ExecutionAction>
</PreActions>
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "33CC10EC2044A3C60003C045"
BuildableName = "taproom.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES">
<MacroExpansion>
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "33CC10EC2044A3C60003C045"
BuildableName = "taproom.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</MacroExpansion>
<Testables>
<TestableReference
skipped = "NO"
parallelizable = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "331C80D4294CF70F00263BE5"
BuildableName = "RunnerTests.xctest"
BlueprintName = "RunnerTests"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</TestableReference>
</Testables>
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
enableGPUValidationMode = "1"
allowLocationSimulation = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "33CC10EC2044A3C60003C045"
BuildableName = "taproom.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</LaunchAction>
<ProfileAction
buildConfiguration = "Profile"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "33CC10EC2044A3C60003C045"
BuildableName = "taproom.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>

View file

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "group:Runner.xcodeproj">
</FileRef>
</Workspace>

View file

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>IDEDidComputeMac32BitWarning</key>
<true/>
</dict>
</plist>

View file

@ -0,0 +1,13 @@
import Cocoa
import FlutterMacOS
@main
class AppDelegate: FlutterAppDelegate {
override func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool {
return true
}
override func applicationSupportsSecureRestorableState(_ app: NSApplication) -> Bool {
return true
}
}

View file

@ -0,0 +1,68 @@
{
"images" : [
{
"size" : "16x16",
"idiom" : "mac",
"filename" : "app_icon_16.png",
"scale" : "1x"
},
{
"size" : "16x16",
"idiom" : "mac",
"filename" : "app_icon_32.png",
"scale" : "2x"
},
{
"size" : "32x32",
"idiom" : "mac",
"filename" : "app_icon_32.png",
"scale" : "1x"
},
{
"size" : "32x32",
"idiom" : "mac",
"filename" : "app_icon_64.png",
"scale" : "2x"
},
{
"size" : "128x128",
"idiom" : "mac",
"filename" : "app_icon_128.png",
"scale" : "1x"
},
{
"size" : "128x128",
"idiom" : "mac",
"filename" : "app_icon_256.png",
"scale" : "2x"
},
{
"size" : "256x256",
"idiom" : "mac",
"filename" : "app_icon_256.png",
"scale" : "1x"
},
{
"size" : "256x256",
"idiom" : "mac",
"filename" : "app_icon_512.png",
"scale" : "2x"
},
{
"size" : "512x512",
"idiom" : "mac",
"filename" : "app_icon_512.png",
"scale" : "1x"
},
{
"size" : "512x512",
"idiom" : "mac",
"filename" : "app_icon_1024.png",
"scale" : "2x"
}
],
"info" : {
"version" : 1,
"author" : "xcode"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 520 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

View file

@ -0,0 +1,343 @@
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.Cocoa.XIB" version="3.0" toolsVersion="14490.70" targetRuntime="MacOSX.Cocoa" propertyAccessControl="none" useAutolayout="YES" customObjectInstantitationMethod="direct">
<dependencies>
<deployment identifier="macosx"/>
<plugIn identifier="com.apple.InterfaceBuilder.CocoaPlugin" version="14490.70"/>
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
</dependencies>
<objects>
<customObject id="-2" userLabel="File's Owner" customClass="NSApplication">
<connections>
<outlet property="delegate" destination="Voe-Tx-rLC" id="GzC-gU-4Uq"/>
</connections>
</customObject>
<customObject id="-1" userLabel="First Responder" customClass="FirstResponder"/>
<customObject id="-3" userLabel="Application" customClass="NSObject"/>
<customObject id="Voe-Tx-rLC" customClass="AppDelegate" customModule="Runner" customModuleProvider="target">
<connections>
<outlet property="applicationMenu" destination="uQy-DD-JDr" id="XBo-yE-nKs"/>
<outlet property="mainFlutterWindow" destination="QvC-M9-y7g" id="gIp-Ho-8D9"/>
</connections>
</customObject>
<customObject id="YLy-65-1bz" customClass="NSFontManager"/>
<menu title="Main Menu" systemMenu="main" id="AYu-sK-qS6">
<items>
<menuItem title="APP_NAME" id="1Xt-HY-uBw">
<modifierMask key="keyEquivalentModifierMask"/>
<menu key="submenu" title="APP_NAME" systemMenu="apple" id="uQy-DD-JDr">
<items>
<menuItem title="About APP_NAME" id="5kV-Vb-QxS">
<modifierMask key="keyEquivalentModifierMask"/>
<connections>
<action selector="orderFrontStandardAboutPanel:" target="-1" id="Exp-CZ-Vem"/>
</connections>
</menuItem>
<menuItem isSeparatorItem="YES" id="VOq-y0-SEH"/>
<menuItem title="Preferences…" keyEquivalent="," id="BOF-NM-1cW"/>
<menuItem isSeparatorItem="YES" id="wFC-TO-SCJ"/>
<menuItem title="Services" id="NMo-om-nkz">
<modifierMask key="keyEquivalentModifierMask"/>
<menu key="submenu" title="Services" systemMenu="services" id="hz9-B4-Xy5"/>
</menuItem>
<menuItem isSeparatorItem="YES" id="4je-JR-u6R"/>
<menuItem title="Hide APP_NAME" keyEquivalent="h" id="Olw-nP-bQN">
<connections>
<action selector="hide:" target="-1" id="PnN-Uc-m68"/>
</connections>
</menuItem>
<menuItem title="Hide Others" keyEquivalent="h" id="Vdr-fp-XzO">
<modifierMask key="keyEquivalentModifierMask" option="YES" command="YES"/>
<connections>
<action selector="hideOtherApplications:" target="-1" id="VT4-aY-XCT"/>
</connections>
</menuItem>
<menuItem title="Show All" id="Kd2-mp-pUS">
<modifierMask key="keyEquivalentModifierMask"/>
<connections>
<action selector="unhideAllApplications:" target="-1" id="Dhg-Le-xox"/>
</connections>
</menuItem>
<menuItem isSeparatorItem="YES" id="kCx-OE-vgT"/>
<menuItem title="Quit APP_NAME" keyEquivalent="q" id="4sb-4s-VLi">
<connections>
<action selector="terminate:" target="-1" id="Te7-pn-YzF"/>
</connections>
</menuItem>
</items>
</menu>
</menuItem>
<menuItem title="Edit" id="5QF-Oa-p0T">
<modifierMask key="keyEquivalentModifierMask"/>
<menu key="submenu" title="Edit" id="W48-6f-4Dl">
<items>
<menuItem title="Undo" keyEquivalent="z" id="dRJ-4n-Yzg">
<connections>
<action selector="undo:" target="-1" id="M6e-cu-g7V"/>
</connections>
</menuItem>
<menuItem title="Redo" keyEquivalent="Z" id="6dh-zS-Vam">
<connections>
<action selector="redo:" target="-1" id="oIA-Rs-6OD"/>
</connections>
</menuItem>
<menuItem isSeparatorItem="YES" id="WRV-NI-Exz"/>
<menuItem title="Cut" keyEquivalent="x" id="uRl-iY-unG">
<connections>
<action selector="cut:" target="-1" id="YJe-68-I9s"/>
</connections>
</menuItem>
<menuItem title="Copy" keyEquivalent="c" id="x3v-GG-iWU">
<connections>
<action selector="copy:" target="-1" id="G1f-GL-Joy"/>
</connections>
</menuItem>
<menuItem title="Paste" keyEquivalent="v" id="gVA-U4-sdL">
<connections>
<action selector="paste:" target="-1" id="UvS-8e-Qdg"/>
</connections>
</menuItem>
<menuItem title="Paste and Match Style" keyEquivalent="V" id="WeT-3V-zwk">
<modifierMask key="keyEquivalentModifierMask" option="YES" command="YES"/>
<connections>
<action selector="pasteAsPlainText:" target="-1" id="cEh-KX-wJQ"/>
</connections>
</menuItem>
<menuItem title="Delete" id="pa3-QI-u2k">
<modifierMask key="keyEquivalentModifierMask"/>
<connections>
<action selector="delete:" target="-1" id="0Mk-Ml-PaM"/>
</connections>
</menuItem>
<menuItem title="Select All" keyEquivalent="a" id="Ruw-6m-B2m">
<connections>
<action selector="selectAll:" target="-1" id="VNm-Mi-diN"/>
</connections>
</menuItem>
<menuItem isSeparatorItem="YES" id="uyl-h8-XO2"/>
<menuItem title="Find" id="4EN-yA-p0u">
<modifierMask key="keyEquivalentModifierMask"/>
<menu key="submenu" title="Find" id="1b7-l0-nxx">
<items>
<menuItem title="Find…" tag="1" keyEquivalent="f" id="Xz5-n4-O0W">
<connections>
<action selector="performFindPanelAction:" target="-1" id="cD7-Qs-BN4"/>
</connections>
</menuItem>
<menuItem title="Find and Replace…" tag="12" keyEquivalent="f" id="YEy-JH-Tfz">
<modifierMask key="keyEquivalentModifierMask" option="YES" command="YES"/>
<connections>
<action selector="performFindPanelAction:" target="-1" id="WD3-Gg-5AJ"/>
</connections>
</menuItem>
<menuItem title="Find Next" tag="2" keyEquivalent="g" id="q09-fT-Sye">
<connections>
<action selector="performFindPanelAction:" target="-1" id="NDo-RZ-v9R"/>
</connections>
</menuItem>
<menuItem title="Find Previous" tag="3" keyEquivalent="G" id="OwM-mh-QMV">
<connections>
<action selector="performFindPanelAction:" target="-1" id="HOh-sY-3ay"/>
</connections>
</menuItem>
<menuItem title="Use Selection for Find" tag="7" keyEquivalent="e" id="buJ-ug-pKt">
<connections>
<action selector="performFindPanelAction:" target="-1" id="U76-nv-p5D"/>
</connections>
</menuItem>
<menuItem title="Jump to Selection" keyEquivalent="j" id="S0p-oC-mLd">
<connections>
<action selector="centerSelectionInVisibleArea:" target="-1" id="IOG-6D-g5B"/>
</connections>
</menuItem>
</items>
</menu>
</menuItem>
<menuItem title="Spelling and Grammar" id="Dv1-io-Yv7">
<modifierMask key="keyEquivalentModifierMask"/>
<menu key="submenu" title="Spelling" id="3IN-sU-3Bg">
<items>
<menuItem title="Show Spelling and Grammar" keyEquivalent=":" id="HFo-cy-zxI">
<connections>
<action selector="showGuessPanel:" target="-1" id="vFj-Ks-hy3"/>
</connections>
</menuItem>
<menuItem title="Check Document Now" keyEquivalent=";" id="hz2-CU-CR7">
<connections>
<action selector="checkSpelling:" target="-1" id="fz7-VC-reM"/>
</connections>
</menuItem>
<menuItem isSeparatorItem="YES" id="bNw-od-mp5"/>
<menuItem title="Check Spelling While Typing" id="rbD-Rh-wIN">
<modifierMask key="keyEquivalentModifierMask"/>
<connections>
<action selector="toggleContinuousSpellChecking:" target="-1" id="7w6-Qz-0kB"/>
</connections>
</menuItem>
<menuItem title="Check Grammar With Spelling" id="mK6-2p-4JG">
<modifierMask key="keyEquivalentModifierMask"/>
<connections>
<action selector="toggleGrammarChecking:" target="-1" id="muD-Qn-j4w"/>
</connections>
</menuItem>
<menuItem title="Correct Spelling Automatically" id="78Y-hA-62v">
<modifierMask key="keyEquivalentModifierMask"/>
<connections>
<action selector="toggleAutomaticSpellingCorrection:" target="-1" id="2lM-Qi-WAP"/>
</connections>
</menuItem>
</items>
</menu>
</menuItem>
<menuItem title="Substitutions" id="9ic-FL-obx">
<modifierMask key="keyEquivalentModifierMask"/>
<menu key="submenu" title="Substitutions" id="FeM-D8-WVr">
<items>
<menuItem title="Show Substitutions" id="z6F-FW-3nz">
<modifierMask key="keyEquivalentModifierMask"/>
<connections>
<action selector="orderFrontSubstitutionsPanel:" target="-1" id="oku-mr-iSq"/>
</connections>
</menuItem>
<menuItem isSeparatorItem="YES" id="gPx-C9-uUO"/>
<menuItem title="Smart Copy/Paste" id="9yt-4B-nSM">
<modifierMask key="keyEquivalentModifierMask"/>
<connections>
<action selector="toggleSmartInsertDelete:" target="-1" id="3IJ-Se-DZD"/>
</connections>
</menuItem>
<menuItem title="Smart Quotes" id="hQb-2v-fYv">
<modifierMask key="keyEquivalentModifierMask"/>
<connections>
<action selector="toggleAutomaticQuoteSubstitution:" target="-1" id="ptq-xd-QOA"/>
</connections>
</menuItem>
<menuItem title="Smart Dashes" id="rgM-f4-ycn">
<modifierMask key="keyEquivalentModifierMask"/>
<connections>
<action selector="toggleAutomaticDashSubstitution:" target="-1" id="oCt-pO-9gS"/>
</connections>
</menuItem>
<menuItem title="Smart Links" id="cwL-P1-jid">
<modifierMask key="keyEquivalentModifierMask"/>
<connections>
<action selector="toggleAutomaticLinkDetection:" target="-1" id="Gip-E3-Fov"/>
</connections>
</menuItem>
<menuItem title="Data Detectors" id="tRr-pd-1PS">
<modifierMask key="keyEquivalentModifierMask"/>
<connections>
<action selector="toggleAutomaticDataDetection:" target="-1" id="R1I-Nq-Kbl"/>
</connections>
</menuItem>
<menuItem title="Text Replacement" id="HFQ-gK-NFA">
<modifierMask key="keyEquivalentModifierMask"/>
<connections>
<action selector="toggleAutomaticTextReplacement:" target="-1" id="DvP-Fe-Py6"/>
</connections>
</menuItem>
</items>
</menu>
</menuItem>
<menuItem title="Transformations" id="2oI-Rn-ZJC">
<modifierMask key="keyEquivalentModifierMask"/>
<menu key="submenu" title="Transformations" id="c8a-y6-VQd">
<items>
<menuItem title="Make Upper Case" id="vmV-6d-7jI">
<modifierMask key="keyEquivalentModifierMask"/>
<connections>
<action selector="uppercaseWord:" target="-1" id="sPh-Tk-edu"/>
</connections>
</menuItem>
<menuItem title="Make Lower Case" id="d9M-CD-aMd">
<modifierMask key="keyEquivalentModifierMask"/>
<connections>
<action selector="lowercaseWord:" target="-1" id="iUZ-b5-hil"/>
</connections>
</menuItem>
<menuItem title="Capitalize" id="UEZ-Bs-lqG">
<modifierMask key="keyEquivalentModifierMask"/>
<connections>
<action selector="capitalizeWord:" target="-1" id="26H-TL-nsh"/>
</connections>
</menuItem>
</items>
</menu>
</menuItem>
<menuItem title="Speech" id="xrE-MZ-jX0">
<modifierMask key="keyEquivalentModifierMask"/>
<menu key="submenu" title="Speech" id="3rS-ZA-NoH">
<items>
<menuItem title="Start Speaking" id="Ynk-f8-cLZ">
<modifierMask key="keyEquivalentModifierMask"/>
<connections>
<action selector="startSpeaking:" target="-1" id="654-Ng-kyl"/>
</connections>
</menuItem>
<menuItem title="Stop Speaking" id="Oyz-dy-DGm">
<modifierMask key="keyEquivalentModifierMask"/>
<connections>
<action selector="stopSpeaking:" target="-1" id="dX8-6p-jy9"/>
</connections>
</menuItem>
</items>
</menu>
</menuItem>
</items>
</menu>
</menuItem>
<menuItem title="View" id="H8h-7b-M4v">
<modifierMask key="keyEquivalentModifierMask"/>
<menu key="submenu" title="View" id="HyV-fh-RgO">
<items>
<menuItem title="Enter Full Screen" keyEquivalent="f" id="4J7-dP-txa">
<modifierMask key="keyEquivalentModifierMask" control="YES" command="YES"/>
<connections>
<action selector="toggleFullScreen:" target="-1" id="dU3-MA-1Rq"/>
</connections>
</menuItem>
</items>
</menu>
</menuItem>
<menuItem title="Window" id="aUF-d1-5bR">
<modifierMask key="keyEquivalentModifierMask"/>
<menu key="submenu" title="Window" systemMenu="window" id="Td7-aD-5lo">
<items>
<menuItem title="Minimize" keyEquivalent="m" id="OY7-WF-poV">
<connections>
<action selector="performMiniaturize:" target="-1" id="VwT-WD-YPe"/>
</connections>
</menuItem>
<menuItem title="Zoom" id="R4o-n2-Eq4">
<modifierMask key="keyEquivalentModifierMask"/>
<connections>
<action selector="performZoom:" target="-1" id="DIl-cC-cCs"/>
</connections>
</menuItem>
<menuItem isSeparatorItem="YES" id="eu3-7i-yIM"/>
<menuItem title="Bring All to Front" id="LE2-aR-0XJ">
<modifierMask key="keyEquivalentModifierMask"/>
<connections>
<action selector="arrangeInFront:" target="-1" id="DRN-fu-gQh"/>
</connections>
</menuItem>
</items>
</menu>
</menuItem>
<menuItem title="Help" id="EPT-qC-fAb">
<modifierMask key="keyEquivalentModifierMask"/>
<menu key="submenu" title="Help" systemMenu="help" id="rJ0-wn-3NY"/>
</menuItem>
</items>
<point key="canvasLocation" x="142" y="-258"/>
</menu>
<window title="APP_NAME" allowsToolTipsWhenApplicationIsInactive="NO" autorecalculatesKeyViewLoop="NO" releasedWhenClosed="NO" animationBehavior="default" id="QvC-M9-y7g" customClass="MainFlutterWindow" customModule="Runner" customModuleProvider="target">
<windowStyleMask key="styleMask" titled="YES" closable="YES" miniaturizable="YES" resizable="YES"/>
<rect key="contentRect" x="335" y="390" width="800" height="600"/>
<rect key="screenRect" x="0.0" y="0.0" width="2560" height="1577"/>
<view key="contentView" wantsLayer="YES" id="EiT-Mj-1SZ">
<rect key="frame" x="0.0" y="0.0" width="800" height="600"/>
<autoresizingMask key="autoresizingMask"/>
</view>
</window>
</objects>
</document>

View file

@ -0,0 +1,14 @@
// Application-level settings for the Runner target.
//
// This may be replaced with something auto-generated from metadata (e.g., pubspec.yaml) in the
// future. If not, the values below would default to using the project name when this becomes a
// 'flutter create' template.
// The application's name. By default this is also the title of the Flutter window.
PRODUCT_NAME = taproom
// The application's bundle identifier
PRODUCT_BUNDLE_IDENTIFIER = com.philippeterson.taproom
// The copyright displayed in application information
PRODUCT_COPYRIGHT = Copyright © 2026 com.philippeterson. All rights reserved.

View file

@ -0,0 +1,2 @@
#include "../../Flutter/Flutter-Debug.xcconfig"
#include "Warnings.xcconfig"

View file

@ -0,0 +1,2 @@
#include "../../Flutter/Flutter-Release.xcconfig"
#include "Warnings.xcconfig"

View file

@ -0,0 +1,13 @@
WARNING_CFLAGS = -Wall -Wconditional-uninitialized -Wnullable-to-nonnull-conversion -Wmissing-method-return-type -Woverlength-strings
GCC_WARN_UNDECLARED_SELECTOR = YES
CLANG_UNDEFINED_BEHAVIOR_SANITIZER_NULLABILITY = YES
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES
CLANG_WARN_PRAGMA_PACK = YES
CLANG_WARN_STRICT_PROTOTYPES = YES
CLANG_WARN_COMMA = YES
GCC_WARN_STRICT_SELECTOR_MATCH = YES
CLANG_WARN_OBJC_REPEATED_USE_OF_WEAK = YES
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES
GCC_WARN_SHADOW = YES
CLANG_WARN_UNREACHABLE_CODE = YES

View file

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.app-sandbox</key>
<false/>
<key>com.apple.security.cs.allow-jit</key>
<true/>
<key>com.apple.security.network.server</key>
<true/>
</dict>
</plist>

32
macos/Runner/Info.plist Normal file
View file

@ -0,0 +1,32 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIconFile</key>
<string></string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>$(FLUTTER_BUILD_NAME)</string>
<key>CFBundleVersion</key>
<string>$(FLUTTER_BUILD_NUMBER)</string>
<key>LSMinimumSystemVersion</key>
<string>$(MACOSX_DEPLOYMENT_TARGET)</string>
<key>NSHumanReadableCopyright</key>
<string>$(PRODUCT_COPYRIGHT)</string>
<key>NSMainNibFile</key>
<string>MainMenu</string>
<key>NSPrincipalClass</key>
<string>NSApplication</string>
</dict>
</plist>

View file

@ -0,0 +1,15 @@
import Cocoa
import FlutterMacOS
class MainFlutterWindow: NSWindow {
override func awakeFromNib() {
let flutterViewController = FlutterViewController()
let windowFrame = self.frame
self.contentViewController = flutterViewController
self.setFrame(windowFrame, display: true)
RegisterGeneratedPlugins(registry: flutterViewController)
super.awakeFromNib()
}
}

View file

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.app-sandbox</key>
<false/>
</dict>
</plist>

View file

@ -0,0 +1,12 @@
import Cocoa
import FlutterMacOS
import XCTest
class RunnerTests: XCTestCase {
func testExample() {
// If you add code to the Runner application, consider adding tests here.
// See https://developer.apple.com/documentation/xctest for more information about using XCTest.
}
}

213
pubspec.lock Normal file
View file

@ -0,0 +1,213 @@
# Generated by pub
# See https://dart.dev/tools/pub/glossary#lockfile
packages:
async:
dependency: transitive
description:
name: async
sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37
url: "https://pub.dev"
source: hosted
version: "2.13.1"
boolean_selector:
dependency: transitive
description:
name: boolean_selector
sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea"
url: "https://pub.dev"
source: hosted
version: "2.1.2"
characters:
dependency: transitive
description:
name: characters
sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b
url: "https://pub.dev"
source: hosted
version: "1.4.1"
clock:
dependency: transitive
description:
name: clock
sha256: e51d50bca3217c9a9fa2b41a30e4a38971133f5f9ec7a3d57bae095007f1d28e
url: "https://pub.dev"
source: hosted
version: "1.1.3"
collection:
dependency: transitive
description:
name: collection
sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76"
url: "https://pub.dev"
source: hosted
version: "1.19.1"
cupertino_icons:
dependency: "direct main"
description:
name: cupertino_icons
sha256: "41e005c33bd814be4d3096aff55b1908d419fde52ca656c8c47719ec745873cd"
url: "https://pub.dev"
source: hosted
version: "1.0.9"
fake_async:
dependency: transitive
description:
name: fake_async
sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44"
url: "https://pub.dev"
source: hosted
version: "1.3.3"
flutter:
dependency: "direct main"
description: flutter
source: sdk
version: "0.0.0"
flutter_lints:
dependency: "direct dev"
description:
name: flutter_lints
sha256: "3105dc8492f6183fb076ccf1f351ac3d60564bff92e20bfc4af9cc1651f4e7e1"
url: "https://pub.dev"
source: hosted
version: "6.0.0"
flutter_test:
dependency: "direct dev"
description: flutter
source: sdk
version: "0.0.0"
leak_tracker:
dependency: transitive
description:
name: leak_tracker
sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de"
url: "https://pub.dev"
source: hosted
version: "11.0.2"
leak_tracker_flutter_testing:
dependency: transitive
description:
name: leak_tracker_flutter_testing
sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1"
url: "https://pub.dev"
source: hosted
version: "3.0.10"
leak_tracker_testing:
dependency: transitive
description:
name: leak_tracker_testing
sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1"
url: "https://pub.dev"
source: hosted
version: "3.0.2"
lints:
dependency: transitive
description:
name: lints
sha256: "12f842a479589fea194fe5c5a3095abc7be0c1f2ddfa9a0e76aed1dbd26a87df"
url: "https://pub.dev"
source: hosted
version: "6.1.0"
matcher:
dependency: transitive
description:
name: matcher
sha256: "31bd099b47c10cd1aeb55146a2d46ce0277630ecef3f7dae54ad7873f36696cd"
url: "https://pub.dev"
source: hosted
version: "0.12.20"
material_color_utilities:
dependency: transitive
description:
name: material_color_utilities
sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b"
url: "https://pub.dev"
source: hosted
version: "0.13.0"
meta:
dependency: transitive
description:
name: meta
sha256: c82594181e3312f3d0695fc95aaaf7758d75b8d4ae2bbecf223b9fd5109a059d
url: "https://pub.dev"
source: hosted
version: "1.18.3"
path:
dependency: transitive
description:
name: path
sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5"
url: "https://pub.dev"
source: hosted
version: "1.9.1"
sky_engine:
dependency: transitive
description: flutter
source: sdk
version: "0.0.0"
source_span:
dependency: transitive
description:
name: source_span
sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab"
url: "https://pub.dev"
source: hosted
version: "1.10.2"
stack_trace:
dependency: transitive
description:
name: stack_trace
sha256: "277654b3034d17ac6f9f1cb5595db011b1d5d41e8806866db28e0abaa101c490"
url: "https://pub.dev"
source: hosted
version: "1.12.2"
stream_channel:
dependency: transitive
description:
name: stream_channel
sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d"
url: "https://pub.dev"
source: hosted
version: "2.1.4"
string_scanner:
dependency: transitive
description:
name: string_scanner
sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43"
url: "https://pub.dev"
source: hosted
version: "1.4.1"
term_glyph:
dependency: transitive
description:
name: term_glyph
sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e"
url: "https://pub.dev"
source: hosted
version: "1.2.2"
test_api:
dependency: transitive
description:
name: test_api
sha256: "2a122cbe059f8b610d3a5415f42e255b6c17b1f21eee1d960f31080237fb4f11"
url: "https://pub.dev"
source: hosted
version: "0.7.12"
vector_math:
dependency: transitive
description:
name: vector_math
sha256: "1d774bbdf6b72a0b12122fc1560c9c2d2a67db5a4a4cc2bd8a5c990ab20e3188"
url: "https://pub.dev"
source: hosted
version: "2.4.0"
vm_service:
dependency: transitive
description:
name: vm_service
sha256: "5f37239c4851efcef929cea7824e76df7f2f0970aef85d66bbc430afa40e72f0"
url: "https://pub.dev"
source: hosted
version: "15.3.0"
sdks:
dart: ">=3.13.5 <4.0.0"
flutter: ">=3.18.0-18.0.pre.54"

89
pubspec.yaml Normal file
View file

@ -0,0 +1,89 @@
name: taproom
description: "A Flutter frontend for Homebrew"
# The following line prevents the package from being accidentally published to
# pub.dev using `flutter pub publish`. This is preferred for private packages.
publish_to: 'none' # Remove this line if you wish to publish to pub.dev
# The following defines the version and build number for your application.
# A version number is three numbers separated by dots, like 1.2.43
# followed by an optional build number separated by a +.
# Both the version and the builder number may be overridden in flutter
# build by specifying --build-name and --build-number, respectively.
# In Android, build-name is used as versionName while build-number used as versionCode.
# Read more about Android versioning at https://developer.android.com/studio/publish/versioning
# In iOS, build-name is used as CFBundleShortVersionString while build-number is used as CFBundleVersion.
# Read more about iOS versioning at
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
# In Windows, build-name is used as the major, minor, and patch parts
# of the product and file versions while build-number is used as the build suffix.
version: 1.0.0+1
environment:
sdk: ^3.13.5
# Dependencies specify other packages that your package needs in order to work.
# To automatically upgrade your package dependencies to the latest versions
# consider running `flutter pub upgrade --major-versions`. Alternatively,
# dependencies can be manually updated by changing the version numbers below to
# the latest version available on pub.dev. To see which dependencies have newer
# versions available, run `flutter pub outdated`.
dependencies:
flutter:
sdk: flutter
# The following adds the Cupertino Icons font to your application.
# Use with the CupertinoIcons class for iOS style icons.
cupertino_icons: ^1.0.8
dev_dependencies:
flutter_test:
sdk: flutter
# The "flutter_lints" package below contains a set of recommended lints to
# encourage good coding practices. The lint set provided by the package is
# activated in the `analysis_options.yaml` file located at the root of your
# package. See that file for information about deactivating specific lint
# rules and activating additional ones.
flutter_lints: ^6.0.0
# For information on the generic Dart part of this file, see the
# following page: https://dart.dev/tools/pub/pubspec
# The following section is specific to Flutter packages.
flutter:
# The following line ensures that the Material Icons font is
# included with your application, so that you can use the icons in
# the material Icons class.
uses-material-design: true
# To add assets to your application, add an assets section, like this:
# assets:
# - images/a_dot_burr.jpeg
# - images/a_dot_ham.jpeg
# An image asset can refer to one or more resolution-specific "variants", see
# https://flutter.dev/to/resolution-aware-images
# For details regarding adding assets from package dependencies, see
# https://flutter.dev/to/asset-from-package
# To add custom fonts to your application, add a fonts section here,
# in this "flutter" section. Each entry in this list should have a
# "family" key with the font family name, and a "fonts" key with a
# list giving the asset and other descriptors for the font. For
# example:
# fonts:
# - family: Schyler
# fonts:
# - asset: fonts/Schyler-Regular.ttf
# - asset: fonts/Schyler-Italic.ttf
# style: italic
# - family: Trajan Pro
# fonts:
# - asset: fonts/TrajanPro.ttf
# - asset: fonts/TrajanPro_Bold.ttf
# weight: 700
#
# For details regarding fonts from package dependencies,
# see https://flutter.dev/to/font-from-package

82
test/app_test.dart Normal file
View file

@ -0,0 +1,82 @@
import 'dart:convert';
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:taproom/brew/brew_client.dart';
import 'package:taproom/main.dart';
import 'package:taproom/state/app_state.dart';
void main() {
testWidgets('shows installed packages and outdated badge', (tester) async {
final client = BrewClient(
brewPath: '/fake/brew',
runner: (exe, args, env) async {
final out = switch (args.first) {
'info' => {
'formulae': [
{
'name': 'jq',
'desc': 'Lightweight and flexible command-line JSON processor',
'installed': [
{'version': '1.8.1', 'installed_on_request': true},
],
'outdated': true,
},
{
'name': 'oniguruma',
'installed': [
{'version': '6.9.10', 'installed_on_request': false},
],
},
],
'casks': [
{
'token': 'firefox',
'name': ['Mozilla Firefox'],
'installed': '141.0',
},
],
},
'outdated' => {
'formulae': [
{
'name': 'jq',
'installed_versions': ['1.8.1'],
'current_version': '1.8.2',
},
],
'casks': [],
},
'tap-info' => [
{
'name': 'homebrew/core',
'official': true,
'formula_names': ['jq'],
},
],
_ => throw UnimplementedError('$args'),
};
return ProcessResult(0, 0, jsonEncode(out), '');
},
);
final state = AppState(client);
await tester.binding.setSurfaceSize(const Size(1200, 800));
await tester.pumpWidget(TaproomApp(state: state));
await tester.runAsync(state.refresh);
await tester.pump();
expect(find.text('jq'), findsOneWidget);
expect(find.text('oniguruma'), findsOneWidget);
expect(find.text('Mozilla Firefox'), findsOneWidget);
expect(find.text('dependency'), findsOneWidget);
expect(find.text('3 of 3'), findsOneWidget);
// Badge on the Outdated rail item.
expect(find.text('1'), findsOneWidget);
await tester.tap(find.text('Requested only'));
await tester.pump();
expect(find.text('oniguruma'), findsNothing);
expect(find.text('2 of 3'), findsOneWidget);
});
}

177
test/brew_client_test.dart Normal file
View file

@ -0,0 +1,177 @@
import 'dart:convert';
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:taproom/brew/brew_client.dart';
import 'package:taproom/brew/models.dart';
/// Records calls and answers each with a canned result chosen by [respond].
class FakeRunner {
FakeRunner(this.respond);
final ProcessResult Function(List<String> args) respond;
final calls = <(List<String>, Map<String, String>)>[];
Future<ProcessResult> call(
String exe,
List<String> args,
Map<String, String> env,
) async {
calls.add((args, env));
return respond(args);
}
}
ProcessResult result(String stdout, {int exitCode = 0, String stderr = ''}) =>
ProcessResult(0, exitCode, stdout, stderr);
void main() {
test('search queries formulae and casks and tolerates no matches', () async {
final runner = FakeRunner(
(args) => args.contains('--formula')
? result('jq\njq-lsp\n')
// brew exits 1 with a message when nothing matches.
: result('', exitCode: 1, stderr: 'Error: No casks found for "jq".'),
);
final brew = BrewClient(
brewPath: '/opt/homebrew/bin/brew',
runner: runner.call,
);
final results = await brew.search('jq');
expect(results.map((r) => (r.name, r.kind)), [
('jq', PackageKind.formula),
('jq-lsp', PackageKind.formula),
]);
expect(runner.calls.map((c) => c.$1), [
['search', '--formula', 'jq'],
['search', '--cask', 'jq'],
]);
});
test('search output parsing skips headers and prose', () {
final r = BrewClient.parseSearchOutput(
'==> Formulae\nfoo\n\nIf you meant "foo" specifically:\nbar\n',
PackageKind.formula,
);
expect(r.map((e) => e.name), ['foo', 'bar']);
});
test('queries run with a usable PATH and without auto-update', () async {
final runner = FakeRunner((_) => result('{"formulae": [], "casks": []}'));
final brew = BrewClient(
brewPath: '/opt/homebrew/bin/brew',
runner: runner.call,
);
await brew.installed();
final env = runner.calls.single.$2;
expect(env['PATH'], startsWith('/opt/homebrew/bin:/usr/bin:'));
expect(env['HOMEBREW_NO_AUTO_UPDATE'], '1');
expect(env['HOMEBREW_NO_COLOR'], '1');
});
test('a failing query throws BrewException with stderr', () async {
final runner = FakeRunner(
(_) => result('', exitCode: 1, stderr: 'Error: No available formula'),
);
final brew = BrewClient(
brewPath: '/opt/homebrew/bin/brew',
runner: runner.call,
);
expect(
() => brew.info('nope', PackageKind.formula),
throwsA(
isA<BrewException>().having(
(e) => e.stderr,
'stderr',
contains('No available formula'),
),
),
);
});
test(
'BrewTask streams stdout and stderr and records the exit code',
() async {
late Map<String, String> seenEnv;
final brew = BrewClient(
brewPath: '/opt/homebrew/bin/brew',
askpassPath: '/tmp/askpass',
starter: (exe, args, env) {
seenEnv = env;
return Process.start('/bin/sh', [
'-c',
'echo installing; echo warning >&2; exit 3',
]);
},
);
final task = brew.install('jq', PackageKind.formula);
var notified = 0;
task.addListener(() => notified++);
final code = await task.run();
expect(code, 3);
expect(task.succeeded, isFalse);
expect(task.running, isFalse);
expect(task.lines.first, r'$ brew install --formula jq');
expect(task.lines, containsAll(['installing', 'warning']));
expect(notified, greaterThan(2));
// Mutations may auto-update and may need sudo via the askpass helper.
expect(seenEnv.containsKey('HOMEBREW_NO_AUTO_UPDATE'), isFalse);
expect(seenEnv['SUDO_ASKPASS'], '/tmp/askpass');
},
);
test('upgrade builds the right arguments', () {
final brew = BrewClient(brewPath: '/b/brew');
expect(brew.upgrade().args, ['upgrade']);
expect(brew.upgrade(greedy: true).args, ['upgrade', '--greedy']);
expect(brew.upgrade(name: 'firefox', kind: PackageKind.cask).args, [
'upgrade',
'--cask',
'firefox',
]);
expect(brew.addTap('a/b', url: '').args, ['tap', 'a/b']);
expect(brew.addTap('a/b', url: 'https://x/y.git').args, [
'tap',
'a/b',
'https://x/y.git',
]);
});
// Read-only checks against the real Homebrew on this machine, to catch
// drift between brew's JSON and the parsers.
group('real brew', () {
final path = BrewClient.locate();
final brew = path == null ? null : BrewClient(brewPath: path);
test('lists installed packages', () async {
final installed = await brew!.installed();
expect(installed, everyElement(isA<Package>()));
expect(installed.where((p) => !p.isInstalled), isEmpty);
}, skip: brew == null ? 'Homebrew not installed' : false);
test('lists taps including homebrew/core', () async {
final taps = await brew!.taps();
expect(taps.map((t) => t.name), contains('homebrew/core'));
final core = taps.firstWhere((t) => t.name == 'homebrew/core');
expect(core.formulaNames, contains('jq'));
}, skip: brew == null ? 'Homebrew not installed' : false);
test('searches', () async {
final r = await brew!.search('jq');
expect(r.map((e) => e.name), contains('jq'));
}, skip: brew == null ? 'Homebrew not installed' : false);
test('gets cask info', () async {
final p = await brew!.info('firefox', PackageKind.cask);
expect(p?.displayName, isNotEmpty);
expect(jsonEncode(p?.name), '"firefox"');
}, skip: brew == null ? 'Homebrew not installed' : false);
});
}

58
test/fixtures/info_firefox.json vendored Normal file
View file

@ -0,0 +1,58 @@
{
"formulae": [],
"casks": [
{
"token": "firefox",
"full_token": "firefox",
"old_tokens": [],
"tap": "homebrew/cask",
"name": [
"Mozilla Firefox"
],
"desc": "Web browser",
"homepage": "https://www.mozilla.org/firefox/",
"url": "https://download-installer.cdn.mozilla.net/pub/firefox/releases/157.0.1/mac/en-US/Firefox%20157.0.1.dmg",
"version": "157.0.1",
"autobump": true,
"no_autobump_message": null,
"skip_livecheck": false,
"installed": null,
"installed_time": null,
"bundle_version": null,
"bundle_short_version": null,
"pinned": false,
"pinned_version": null,
"outdated": false,
"sha256": "40a0a649120635460256dae9ad63e40377f77326cbc9c449a5d0a1d7f5dd7982",
"caveats": null,
"caveats_rosetta": null,
"depends_on": {
"macos": {}
},
"conflicts_with": {
"cask": [
"firefox@beta",
"firefox@cn",
"firefox@esr"
]
},
"container": null,
"rename": [],
"auto_updates": true,
"deprecated": false,
"deprecation_date": null,
"deprecation_reason": null,
"deprecation_replacement_formula": null,
"deprecation_replacement_cask": null,
"deprecate_args": null,
"disabled": false,
"disable_date": null,
"disable_reason": null,
"disable_replacement_formula": null,
"disable_replacement_cask": null,
"disable_args": null,
"tap_git_head": "086dfbab8a72cd87eab20a8ff7e974db70051883",
"ruby_source_path": "Casks/f/firefox.rb"
}
]
}

81
test/fixtures/info_jq.json vendored Normal file
View file

@ -0,0 +1,81 @@
{
"formulae": [
{
"name": "jq",
"full_name": "jq",
"tap": "homebrew/core",
"oldnames": [],
"aliases": [],
"versioned_formulae": [],
"desc": "Lightweight and flexible command-line JSON processor",
"license": "MIT",
"homepage": "https://jqlang.github.io/jq/",
"versions": {
"stable": "1.8.2",
"head": "HEAD",
"bottle": true
},
"patches": [],
"revision": 0,
"version_scheme": 0,
"compatibility_version": null,
"autobump": true,
"no_autobump_message": null,
"skip_livecheck": false,
"pour_bottle_only_if": null,
"keg_only": false,
"keg_only_reason": null,
"options": [],
"build_dependencies": [],
"dependencies": [
"oniguruma"
],
"test_dependencies": [],
"recommended_dependencies": [],
"optional_dependencies": [],
"uses_from_macos": [],
"uses_from_macos_bounds": [],
"conflicts_with": [],
"conflicts_with_reasons": [],
"link_overwrite": [],
"caveats": null,
"installed": [],
"linked_keg": null,
"pinned": false,
"outdated": false,
"deprecated": false,
"deprecation_date": null,
"deprecation_reason": null,
"deprecation_replacement_formula": null,
"deprecation_replacement_cask": null,
"deprecate_args": null,
"disabled": false,
"disable_date": null,
"disable_reason": null,
"disable_replacement_formula": null,
"disable_replacement_cask": null,
"disable_args": null,
"post_install_steps": [],
"post_install_defined": false,
"service": null,
"tap_git_head": "3c09418e50ad51f7f241f4062ffcd7bffe36e3ce",
"ruby_source_path": "Formula/j/jq.rb",
"head_dependencies": {
"build_dependencies": [
"autoconf",
"automake",
"libtool"
],
"dependencies": [
"oniguruma"
],
"test_dependencies": [],
"recommended_dependencies": [],
"optional_dependencies": [],
"uses_from_macos": [],
"uses_from_macos_bounds": []
}
}
],
"casks": []
}

119
test/models_test.dart Normal file
View file

@ -0,0 +1,119 @@
import 'dart:convert';
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:taproom/brew/models.dart';
Map<String, dynamic> fixture(String name) =>
jsonDecode(File('test/fixtures/$name').readAsStringSync())
as Map<String, dynamic>;
void main() {
test('parses a formula from real brew info output', () {
final p = Package.listFromInfoJson(fixture('info_jq.json')).single;
expect(p.name, 'jq');
expect(p.kind, PackageKind.formula);
expect(p.tap, 'homebrew/core');
expect(p.latestVersion, '1.8.2');
expect(p.dependencies, ['oniguruma']);
expect(p.isInstalled, isFalse);
});
test('parses a cask from real brew info output', () {
final p = Package.listFromInfoJson(fixture('info_firefox.json')).single;
expect(p.name, 'firefox');
expect(p.kind, PackageKind.cask);
expect(p.displayName, 'Mozilla Firefox');
expect(p.tap, 'homebrew/cask');
expect(p.latestVersion, isNotNull);
});
test('installed formula uses the newest install and on-request flag', () {
final p = Package.fromFormulaJson({
'name': 'git',
'versions': {'stable': '2.50.0'},
'installed': [
{'version': '2.49.0', 'installed_on_request': false},
{'version': '2.50.0', 'installed_on_request': true},
],
'outdated': false,
'pinned': true,
});
expect(p.installedVersion, '2.50.0');
expect(p.installedOnRequest, isTrue);
expect(p.pinned, isTrue);
expect(p.fullName, 'git');
});
test('dependency-only formula is not installed on request', () {
final p = Package.fromFormulaJson({
'name': 'oniguruma',
'installed': [
{'version': '6.9.10', 'installed_on_request': false},
],
});
expect(p.isInstalled, isTrue);
expect(p.installedOnRequest, isFalse);
});
test('parses outdated formulae and casks', () {
final list = OutdatedEntry.listFromJson({
'formulae': [
{
'name': 'node',
'installed_versions': ['22.1.0'],
'current_version': '22.2.0',
'pinned': true,
},
],
'casks': [
{
'name': 'firefox',
'installed_versions': ['140.0'],
'current_version': '141.0',
},
],
});
expect(list.map((e) => (e.name, e.kind, e.pinned)), [
('node', PackageKind.formula, true),
('firefox', PackageKind.cask, false),
]);
expect(list.last.installedVersions, ['140.0']);
});
test('parses an API-backed tap', () {
final t = Tap.fromJson({
'name': 'homebrew/core',
'path': '/opt/homebrew/Library/Taps/homebrew/homebrew-core',
'installed': false,
'official': true,
'formula_names': ['a', 'b'],
'cask_tokens': [],
});
expect(t.cloned, isFalse);
expect(t.official, isTrue);
expect(t.formulaNames, hasLength(2));
expect(t.remote, isNull);
});
test('parses a cloned third-party tap', () {
// Shape from `brew tap-info --json hashicorp/tap`: custom_remote is a
// bool, not the URL.
final t = Tap.fromJson({
'name': 'hashicorp/tap',
'path': '/opt/homebrew/Library/Taps/hashicorp/homebrew-tap',
'installed': true,
'official': false,
'remote': 'https://github.com/hashicorp/homebrew-tap',
'custom_remote': false,
'last_commit': '2 days ago',
'branch': 'main',
'formula_names': ['hashicorp/tap/terraform'],
'cask_tokens': [],
});
expect(t.cloned, isTrue);
expect(t.remote, 'https://github.com/hashicorp/homebrew-tap');
expect(t.branch, 'main');
expect(t.lastCommit, '2 days ago');
});
}