import 'package:flutter/material.dart';
import '../brew/models.dart';
import 'common.dart';
import 'package_details.dart';
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.')
: _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 = [
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'),
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),
);
},
);
}
}