Rename to DNF Package Store; add category browsing, settings, and package tags
- Add app/package category browsing via AppStream + comps groups (lib/category_service.dart), with per-package tag chips shown when a package is selected. - Add a Settings page to uninstall PackageKit if it's no longer wanted. - Rename the app from "Package Launcher"/pkg_launcher to "DNF Package Store" across the pubspec, window title, and Linux CMake/runner config. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
1a84b244b0
commit
0788b01ff9
9 changed files with 636 additions and 37 deletions
29
README.md
29
README.md
|
|
@ -1,16 +1,29 @@
|
|||
# pkg_launcher
|
||||
# DNF Package Store
|
||||
|
||||
A new Flutter project.
|
||||
A lightweight Flutter desktop app for browsing and trying out packages on
|
||||
DNF-based Linux distros (Fedora and friends), without committing to a
|
||||
permanent install.
|
||||
|
||||
## Features
|
||||
|
||||
- Search or browse the full package catalog, scoped to a single repo/channel
|
||||
or across all enabled repos.
|
||||
- Browse by category — AppStream app categories and comps groups, shown as
|
||||
tags on each package.
|
||||
- Install packages **transiently** (`dnf install --transient`), so test
|
||||
installs disappear automatically on reboot instead of lingering.
|
||||
- Launch installed apps directly, or uninstall them immediately.
|
||||
- Settings page to uninstall PackageKit (and its AppStream catalog data) if
|
||||
you only wanted it for the richer category listings.
|
||||
|
||||
## Getting Started
|
||||
|
||||
This project is a starting point for a Flutter application.
|
||||
This is a standard Flutter project targeting Linux desktop. With the Flutter
|
||||
SDK on your `PATH`:
|
||||
|
||||
A few resources to get you started if this is your first Flutter project:
|
||||
|
||||
- [Learn Flutter](https://docs.flutter.dev/get-started/learn-flutter)
|
||||
- [Write your first Flutter app](https://docs.flutter.dev/get-started/codelab)
|
||||
- [Flutter learning resources](https://docs.flutter.dev/reference/learning-resources)
|
||||
```sh
|
||||
flutter run -d linux
|
||||
```
|
||||
|
||||
For help getting started with Flutter development, view the
|
||||
[online documentation](https://docs.flutter.dev/), which offers tutorials,
|
||||
|
|
|
|||
126
lib/category_service.dart
Normal file
126
lib/category_service.dart
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
import 'dart:io';
|
||||
|
||||
/// Standard top-level freedesktop/AppStream categories. Used both as the
|
||||
/// button set and as the keys enumerated via `appstreamcli list-categories`.
|
||||
const List<String> appstreamCategories = [
|
||||
'AudioVideo',
|
||||
'Development',
|
||||
'Education',
|
||||
'Game',
|
||||
'Graphics',
|
||||
'Network',
|
||||
'Office',
|
||||
'Science',
|
||||
'Settings',
|
||||
'System',
|
||||
'Utility',
|
||||
];
|
||||
|
||||
class CategoryIndex {
|
||||
/// package name -> every AppStream category it appears under (source #2).
|
||||
final Map<String, Set<String>> appstream;
|
||||
|
||||
/// package name -> every comps group it belongs to (source #1, shown
|
||||
/// alongside AppStream categories rather than only as a fallback for tags).
|
||||
final Map<String, Set<String>> comps;
|
||||
|
||||
CategoryIndex({required this.appstream, required this.comps});
|
||||
|
||||
/// All tags (AppStream categories + comps groups) a package belongs to —
|
||||
/// i.e. every browsing bucket it would show up under. Empty means "Other".
|
||||
Set<String> tagsFor(String packageName) {
|
||||
return {...(appstream[packageName] ?? const {}), ...(comps[packageName] ?? const {})};
|
||||
}
|
||||
|
||||
bool hasTag(String packageName, String tag) => tagsFor(packageName).contains(tag);
|
||||
|
||||
Set<String> get allCategoryLabels =>
|
||||
{...appstream.values.expand((s) => s), ...comps.values.expand((s) => s)};
|
||||
}
|
||||
|
||||
class CategoryService {
|
||||
/// AppStream's OS-wide RPM catalog (not just locally-installed apps) is
|
||||
/// only populated once the real `appstream-data` package is present,
|
||||
/// which PackageKit pulls in as a dependency. Fedora's own `fedora`
|
||||
/// repo doesn't ship this in its own repodata.
|
||||
static Future<bool> isFullCatalogAvailable() async {
|
||||
final result = await Process.run('rpm', ['-q', 'PackageKit']);
|
||||
return result.exitCode == 0;
|
||||
}
|
||||
|
||||
/// Builds the package -> AppStream category map by querying each standard
|
||||
/// category and keeping only RPM-backed components (ones with a `Package:`
|
||||
/// line — Flatpak-bundled components have `Bundle: flatpak:...` instead
|
||||
/// and aren't relevant to a dnf-based package list).
|
||||
static Future<Map<String, Set<String>>> buildAppStreamIndex() async {
|
||||
final index = <String, Set<String>>{};
|
||||
final futures = appstreamCategories.map((category) async {
|
||||
final result = await Process.run(
|
||||
'appstreamcli',
|
||||
['list-categories', '--details', category],
|
||||
);
|
||||
if (result.exitCode != 0) return;
|
||||
for (final block in (result.stdout as String).split('\nIdentifier:')) {
|
||||
final match = RegExp(r'^Package:\s*(\S+)', multiLine: true).firstMatch(block);
|
||||
if (match != null) {
|
||||
index.putIfAbsent(match.group(1)!, () => <String>{}).add(category);
|
||||
}
|
||||
}
|
||||
});
|
||||
await Future.wait(futures);
|
||||
return index;
|
||||
}
|
||||
|
||||
/// Builds the package -> comps-group map, used as the fallback for
|
||||
/// packages with no AppStream category. Only considers user-visible
|
||||
/// groups (`dnf group list`, no --hidden) — the ~150 hidden/environment
|
||||
/// groups aren't meaningful browsing categories for an end user.
|
||||
static Future<Map<String, Set<String>>> buildCompsIndex() async {
|
||||
final listResult = await Process.run('dnf', ['group', 'list']);
|
||||
if (listResult.exitCode != 0) {
|
||||
throw Exception('dnf group list failed: ${listResult.stderr}');
|
||||
}
|
||||
final groupIds = <String>[];
|
||||
for (final line in (listResult.stdout as String).split('\n')) {
|
||||
final trimmed = line.trim();
|
||||
if (trimmed.isEmpty) continue;
|
||||
final firstToken = trimmed.split(RegExp(r'\s+')).first;
|
||||
if (firstToken == 'ID') continue; // header row
|
||||
if (!RegExp(r'^[a-z0-9][a-z0-9-]*$').hasMatch(firstToken)) continue;
|
||||
groupIds.add(firstToken);
|
||||
}
|
||||
|
||||
final index = <String, Set<String>>{};
|
||||
final futures = groupIds.map((id) async {
|
||||
final infoResult = await Process.run('dnf', ['group', 'info', id]);
|
||||
if (infoResult.exitCode != 0) return;
|
||||
final lines = (infoResult.stdout as String).split('\n');
|
||||
String? groupName;
|
||||
bool inPackagesSection = false;
|
||||
const packageHeaders = {'Mandatory packages', 'Default packages', 'Optional packages'};
|
||||
for (final line in lines) {
|
||||
final idx = line.indexOf(':');
|
||||
if (idx == -1) continue;
|
||||
final key = line.substring(0, idx).trim();
|
||||
final value = line.substring(idx + 1).trim();
|
||||
if (key == 'Name') {
|
||||
groupName = value;
|
||||
} else if (packageHeaders.contains(key)) {
|
||||
inPackagesSection = true;
|
||||
if (value.isNotEmpty) index.putIfAbsent(value, () => <String>{}).add(groupName ?? id);
|
||||
} else if (key.isEmpty && inPackagesSection) {
|
||||
if (value.isNotEmpty) index.putIfAbsent(value, () => <String>{}).add(groupName ?? id);
|
||||
} else {
|
||||
inPackagesSection = false;
|
||||
}
|
||||
}
|
||||
});
|
||||
await Future.wait(futures);
|
||||
return index;
|
||||
}
|
||||
|
||||
static Future<CategoryIndex> build() async {
|
||||
final results = await Future.wait([buildAppStreamIndex(), buildCompsIndex()]);
|
||||
return CategoryIndex(appstream: results[0], comps: results[1]);
|
||||
}
|
||||
}
|
||||
|
|
@ -26,10 +26,48 @@ class CommandLogger {
|
|||
CommandLogger(this.onLine);
|
||||
}
|
||||
|
||||
class RepoInfo {
|
||||
final String id;
|
||||
final String name;
|
||||
RepoInfo({required this.id, required this.name});
|
||||
}
|
||||
|
||||
class DnfService {
|
||||
/// Lists enabled repos (the "channels" a package can come from).
|
||||
static Future<List<RepoInfo>> listRepos() async {
|
||||
final result = await Process.run('dnf', ['repolist']);
|
||||
if (result.exitCode != 0) {
|
||||
throw Exception('dnf repolist failed: ${result.stderr}');
|
||||
}
|
||||
final repos = <RepoInfo>[];
|
||||
for (final line in (result.stdout as String).split('\n')) {
|
||||
if (line.startsWith('repo id')) continue; // header
|
||||
final trimmed = line.trim();
|
||||
if (trimmed.isEmpty) continue;
|
||||
final spaceIdx = trimmed.indexOf(RegExp(r'\s'));
|
||||
if (spaceIdx == -1) continue;
|
||||
repos.add(RepoInfo(id: trimmed.substring(0, spaceIdx), name: trimmed.substring(spaceIdx).trim()));
|
||||
}
|
||||
return repos;
|
||||
}
|
||||
|
||||
/// Searches package name+summary via `dnf search`. Only stdout is parsed;
|
||||
/// repo-sync progress goes to stderr on dnf5 and is ignored here.
|
||||
static Future<List<PackageResult>> search(String query) async {
|
||||
///
|
||||
/// [repo] scopes the search to a single enabled repo/"channel". dnf5's
|
||||
/// `search` subcommand does NOT actually honor `--repo` (confirmed
|
||||
/// empirically: results from other repos leak through regardless), so
|
||||
/// when a repo is given this instead lists that repo via `repoquery`
|
||||
/// (which does filter correctly) and matches name/summary client-side.
|
||||
static Future<List<PackageResult>> search(String query, {String? repo}) async {
|
||||
if (repo != null) {
|
||||
final all = await listAll(repo: repo);
|
||||
final needle = query.toLowerCase();
|
||||
return all
|
||||
.where((p) =>
|
||||
p.name.toLowerCase().contains(needle) || p.summary.toLowerCase().contains(needle))
|
||||
.toList();
|
||||
}
|
||||
final result = await Process.run('dnf', ['search', query]);
|
||||
if (result.exitCode != 0 && result.exitCode != 1) {
|
||||
throw Exception('dnf search failed: ${result.stderr}');
|
||||
|
|
@ -57,11 +95,13 @@ class DnfService {
|
|||
|
||||
/// Lists every available package (not filtered by a search term). Uses
|
||||
/// repoquery with an explicit pipe-delimited, newline-terminated format
|
||||
/// for clean parsing — unlike `dnf search`'s free-text layout.
|
||||
static Future<List<PackageResult>> listAll() async {
|
||||
/// for clean parsing — unlike `dnf search`'s free-text layout. Pass
|
||||
/// [repo] to scope to a single enabled repo/"channel" instead of mixing
|
||||
/// results from all of them.
|
||||
static Future<List<PackageResult>> listAll({String? repo}) async {
|
||||
final result = await Process.run(
|
||||
'dnf',
|
||||
['repoquery', '--available', '--qf', '%{name}.%{arch}|%{summary}\n'],
|
||||
['repoquery', if (repo != null) '--repo=$repo', '--available', '--qf', '%{name}.%{arch}|%{summary}\n'],
|
||||
);
|
||||
if (result.exitCode != 0) {
|
||||
throw Exception('dnf repoquery failed: ${result.stderr}');
|
||||
|
|
@ -106,6 +146,28 @@ class DnfService {
|
|||
return process.exitCode;
|
||||
}
|
||||
|
||||
/// Removes an installed package via pkexec. Works the same whether the
|
||||
/// package was installed transiently or persistently — this just frees
|
||||
/// it up immediately rather than waiting for the transient overlay to
|
||||
/// reset on reboot. Streams combined stdout+stderr lines.
|
||||
static Future<int> uninstall(String packageNameArch, void Function(String line) onLine) async {
|
||||
final process = await Process.start(
|
||||
'pkexec',
|
||||
['dnf', 'remove', '-y', packageNameArch],
|
||||
);
|
||||
process.stdout.transform(const SystemEncoding().decoder).listen((chunk) {
|
||||
for (final line in chunk.split('\n')) {
|
||||
if (line.isNotEmpty) onLine(line);
|
||||
}
|
||||
});
|
||||
process.stderr.transform(const SystemEncoding().decoder).listen((chunk) {
|
||||
for (final line in chunk.split('\n')) {
|
||||
if (line.isNotEmpty) onLine(line);
|
||||
}
|
||||
});
|
||||
return process.exitCode;
|
||||
}
|
||||
|
||||
/// Finds runnable launch targets for an installed package: prefers
|
||||
/// .desktop entries (launched via gtk-launch) and falls back to raw
|
||||
/// executables under a bin/ directory.
|
||||
|
|
|
|||
304
lib/main.dart
304
lib/main.dart
|
|
@ -1,17 +1,19 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'dnf_service.dart';
|
||||
import 'category_service.dart';
|
||||
import 'settings_page.dart';
|
||||
|
||||
void main() {
|
||||
runApp(const PkgLauncherApp());
|
||||
runApp(const DnfPackageStoreApp());
|
||||
}
|
||||
|
||||
class PkgLauncherApp extends StatelessWidget {
|
||||
const PkgLauncherApp({super.key});
|
||||
class DnfPackageStoreApp extends StatelessWidget {
|
||||
const DnfPackageStoreApp({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MaterialApp(
|
||||
title: 'Package Launcher',
|
||||
title: 'DNF Package Store',
|
||||
theme: ThemeData(colorSchemeSeed: Colors.indigo, useMaterial3: true),
|
||||
home: const SearchPage(),
|
||||
);
|
||||
|
|
@ -39,6 +41,111 @@ class _SearchPageState extends State<SearchPage> {
|
|||
final List<String> _installLog = [];
|
||||
final ScrollController _logScroll = ScrollController();
|
||||
List<LaunchTarget> _launchTargets = [];
|
||||
bool _uninstalling = false;
|
||||
|
||||
bool _packageKitAvailable = false;
|
||||
bool _installingPackageKit = false;
|
||||
CategoryIndex? _categoryIndex;
|
||||
String? _activeCategory; // null = unfiltered; '' sentinel = "Other"
|
||||
bool _loadingCategoryResults = false;
|
||||
|
||||
List<RepoInfo> _repos = [];
|
||||
String? _selectedRepo; // null = all enabled repos mixed together
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadCategories();
|
||||
DnfService.listRepos().then((repos) {
|
||||
if (mounted) setState(() => _repos = repos);
|
||||
});
|
||||
}
|
||||
|
||||
void _onRepoChanged(String? repo) {
|
||||
setState(() {
|
||||
_selectedRepo = repo;
|
||||
_allPackages = null;
|
||||
});
|
||||
if (_activeCategory != null) {
|
||||
_filterByCategory(_activeCategory!);
|
||||
} else if (_queryController.text.trim().isNotEmpty) {
|
||||
_doSearch();
|
||||
} else if (_results.isNotEmpty) {
|
||||
_doListAll();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _loadCategories() async {
|
||||
_packageKitAvailable = await CategoryService.isFullCatalogAvailable();
|
||||
final index = await CategoryService.build();
|
||||
if (mounted) setState(() => _categoryIndex = index);
|
||||
}
|
||||
|
||||
Future<void> _openSettings() async {
|
||||
await Navigator.push(context, MaterialPageRoute(builder: (_) => const SettingsPage()));
|
||||
// PackageKit availability may have changed (e.g. uninstalled from Settings).
|
||||
setState(() => _categoryIndex = null);
|
||||
await _loadCategories();
|
||||
}
|
||||
|
||||
Future<void> _installPackageKit() async {
|
||||
setState(() => _installingPackageKit = true);
|
||||
try {
|
||||
final exitCode = await DnfService.installTransient('PackageKit.x86_64', (_) {});
|
||||
if (exitCode == 0) {
|
||||
setState(() {
|
||||
_packageKitAvailable = true;
|
||||
_categoryIndex = null;
|
||||
});
|
||||
await _loadCategories();
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('PackageKit installed — app categories now available.')),
|
||||
);
|
||||
}
|
||||
} else if (mounted) {
|
||||
ScaffoldMessenger.of(context)
|
||||
.showSnackBar(const SnackBar(content: Text('Failed to install PackageKit.')));
|
||||
}
|
||||
} finally {
|
||||
if (mounted) setState(() => _installingPackageKit = false);
|
||||
}
|
||||
}
|
||||
|
||||
/// category: a real category label, or '' as the sentinel for "Other"
|
||||
/// (packages matched by neither AppStream nor comps).
|
||||
Future<void> _filterByCategory(String category) async {
|
||||
setState(() {
|
||||
_activeCategory = category;
|
||||
_selected = null;
|
||||
_searchError = null;
|
||||
_queryController.clear();
|
||||
_stage = Stage.search;
|
||||
});
|
||||
if (_allPackages == null) {
|
||||
setState(() => _loadingCategoryResults = true);
|
||||
try {
|
||||
_allPackages = await DnfService.listAll(repo: _selectedRepo);
|
||||
} catch (e) {
|
||||
setState(() {
|
||||
_searchError = e.toString();
|
||||
_loadingCategoryResults = false;
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
final idx = _categoryIndex;
|
||||
setState(() {
|
||||
_loadingCategoryResults = false;
|
||||
if (idx == null) {
|
||||
_results = [];
|
||||
} else if (category == '') {
|
||||
_results = _allPackages!.where((p) => idx.tagsFor(p.name).isEmpty).toList();
|
||||
} else {
|
||||
_results = _allPackages!.where((p) => idx.hasTag(p.name, category)).toList();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _doSearch() async {
|
||||
final query = _queryController.text.trim();
|
||||
|
|
@ -49,10 +156,11 @@ class _SearchPageState extends State<SearchPage> {
|
|||
_results = [];
|
||||
_allPackages = null;
|
||||
_selected = null;
|
||||
_activeCategory = null;
|
||||
_stage = Stage.search;
|
||||
});
|
||||
try {
|
||||
final results = await DnfService.search(query);
|
||||
final results = await DnfService.search(query, repo: _selectedRepo);
|
||||
setState(() => _results = results);
|
||||
} catch (e) {
|
||||
setState(() => _searchError = e.toString());
|
||||
|
|
@ -68,10 +176,11 @@ class _SearchPageState extends State<SearchPage> {
|
|||
_results = [];
|
||||
_allPackages = null;
|
||||
_selected = null;
|
||||
_activeCategory = null;
|
||||
_stage = Stage.search;
|
||||
});
|
||||
try {
|
||||
final all = await DnfService.listAll();
|
||||
final all = await DnfService.listAll(repo: _selectedRepo);
|
||||
setState(() {
|
||||
_allPackages = all;
|
||||
_results = all;
|
||||
|
|
@ -133,15 +242,71 @@ class _SearchPageState extends State<SearchPage> {
|
|||
}
|
||||
}
|
||||
|
||||
Future<void> _doUninstall() async {
|
||||
final pkg = _selected;
|
||||
if (pkg == null) return;
|
||||
setState(() => _uninstalling = true);
|
||||
try {
|
||||
final exitCode = await DnfService.uninstall(pkg.nameArch, (line) {
|
||||
setState(() => _installLog.add(line));
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (_logScroll.hasClients) {
|
||||
_logScroll.jumpTo(_logScroll.position.maxScrollExtent);
|
||||
}
|
||||
});
|
||||
});
|
||||
if (exitCode == 0) {
|
||||
setState(() {
|
||||
_stage = Stage.search;
|
||||
_launchTargets = [];
|
||||
});
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context)
|
||||
.showSnackBar(SnackBar(content: Text('${pkg.nameArch} removed.')));
|
||||
}
|
||||
} else if (mounted) {
|
||||
ScaffoldMessenger.of(context)
|
||||
.showSnackBar(const SnackBar(content: Text('Uninstall failed — see log.')));
|
||||
}
|
||||
} finally {
|
||||
if (mounted) setState(() => _uninstalling = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Package Launcher (transient installs)')),
|
||||
appBar: AppBar(
|
||||
title: const Text('DNF Package Store'),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.settings),
|
||||
tooltip: 'Settings',
|
||||
onPressed: _openSettings,
|
||||
),
|
||||
],
|
||||
),
|
||||
body: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
const Text('Channel:'),
|
||||
const SizedBox(width: 8),
|
||||
DropdownButton<String?>(
|
||||
value: _selectedRepo,
|
||||
items: [
|
||||
const DropdownMenuItem(value: null, child: Text('All repos')),
|
||||
for (final repo in _repos)
|
||||
DropdownMenuItem(value: repo.id, child: Text(repo.name)),
|
||||
],
|
||||
onChanged: _searching ? null : _onRepoChanged,
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
|
|
@ -170,6 +335,8 @@ class _SearchPageState extends State<SearchPage> {
|
|||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_buildCategoryBar(),
|
||||
const SizedBox(height: 16),
|
||||
if (_searchError != null)
|
||||
Text(_searchError!, style: TextStyle(color: Theme.of(context).colorScheme.error)),
|
||||
|
|
@ -185,6 +352,88 @@ class _SearchPageState extends State<SearchPage> {
|
|||
);
|
||||
}
|
||||
|
||||
Widget _buildCategoryBar() {
|
||||
final idx = _categoryIndex;
|
||||
final compsLabels = idx == null ? <String>{} : idx.comps.values.expand((s) => s).toSet();
|
||||
return Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
crossAxisAlignment: WrapCrossAlignment.center,
|
||||
children: [
|
||||
ActionChip(
|
||||
label: const Text('Any'),
|
||||
onPressed: _searching ? null : _doListAll,
|
||||
),
|
||||
if (idx == null)
|
||||
const Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 4),
|
||||
child: SizedBox(width: 14, height: 14, child: CircularProgressIndicator(strokeWidth: 2)),
|
||||
),
|
||||
if (idx != null)
|
||||
for (final category in appstreamCategories)
|
||||
if (idx.appstream.values.any((tags) => tags.contains(category)))
|
||||
ChoiceChip(
|
||||
label: Text(category),
|
||||
selected: _activeCategory == category,
|
||||
onSelected: (_) => _filterByCategory(category),
|
||||
),
|
||||
if (!_packageKitAvailable)
|
||||
ActionChip(
|
||||
avatar: _installingPackageKit
|
||||
? const SizedBox(width: 14, height: 14, child: CircularProgressIndicator(strokeWidth: 2))
|
||||
: const Icon(Icons.download, size: 18),
|
||||
label: const Text('Install PackageKit for app categories'),
|
||||
onPressed: _installingPackageKit ? null : _installPackageKit,
|
||||
),
|
||||
if (idx != null)
|
||||
for (final label in compsLabels)
|
||||
ChoiceChip(
|
||||
label: Text(label),
|
||||
selected: _activeCategory == label,
|
||||
onSelected: (_) => _filterByCategory(label),
|
||||
),
|
||||
if (idx != null)
|
||||
ChoiceChip(
|
||||
label: const Text('Other'),
|
||||
selected: _activeCategory == '',
|
||||
onSelected: (_) => _filterByCategory(''),
|
||||
),
|
||||
if (_loadingCategoryResults)
|
||||
const SizedBox(width: 14, height: 14, child: CircularProgressIndicator(strokeWidth: 2)),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTags(PackageResult pkg) {
|
||||
final idx = _categoryIndex;
|
||||
if (idx == null) {
|
||||
return const SizedBox(
|
||||
height: 14,
|
||||
width: 14,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
);
|
||||
}
|
||||
final tags = idx.tagsFor(pkg.name).toList()..sort();
|
||||
if (tags.isEmpty) {
|
||||
return Text(
|
||||
'No category tags (shows up under "Other").',
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
);
|
||||
}
|
||||
return Wrap(
|
||||
spacing: 6,
|
||||
runSpacing: 6,
|
||||
children: [
|
||||
for (final tag in tags)
|
||||
Chip(
|
||||
label: Text(tag),
|
||||
visualDensity: VisualDensity.compact,
|
||||
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildBody() {
|
||||
switch (_stage) {
|
||||
case Stage.search:
|
||||
|
|
@ -198,16 +447,31 @@ class _SearchPageState extends State<SearchPage> {
|
|||
final selected = pkg == _selected;
|
||||
return Card(
|
||||
color: selected ? Theme.of(context).colorScheme.secondaryContainer : null,
|
||||
child: ListTile(
|
||||
title: Text(pkg.nameArch),
|
||||
subtitle: Text(pkg.summary),
|
||||
trailing: selected
|
||||
? FilledButton(
|
||||
child: InkWell(
|
||||
onTap: () => setState(() => _selected = selected ? null : pkg),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(pkg.nameArch, style: Theme.of(context).textTheme.titleMedium),
|
||||
const SizedBox(height: 4),
|
||||
Text(pkg.summary),
|
||||
if (selected) ...[
|
||||
const SizedBox(height: 10),
|
||||
_buildTags(pkg),
|
||||
const SizedBox(height: 10),
|
||||
Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: FilledButton(
|
||||
onPressed: _doInstall,
|
||||
child: const Text('Install (transient)'),
|
||||
)
|
||||
: null,
|
||||
onTap: () => setState(() => _selected = selected ? null : pkg),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
|
|
@ -225,7 +489,15 @@ class _SearchPageState extends State<SearchPage> {
|
|||
children: [
|
||||
Icon(Icons.check_circle, color: Colors.green.shade600),
|
||||
const SizedBox(width: 8),
|
||||
Text('${_selected?.nameArch} installed (transient — gone on reboot)'),
|
||||
Expanded(child: Text('${_selected?.nameArch} installed (transient — gone on reboot)')),
|
||||
TextButton.icon(
|
||||
onPressed: _uninstalling ? null : _doUninstall,
|
||||
icon: _uninstalling
|
||||
? const SizedBox(
|
||||
width: 14, height: 14, child: CircularProgressIndicator(strokeWidth: 2))
|
||||
: const Icon(Icons.delete_outline),
|
||||
label: Text(_uninstalling ? 'Uninstalling...' : 'Uninstall'),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
|
|
|
|||
126
lib/settings_page.dart
Normal file
126
lib/settings_page.dart
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'category_service.dart';
|
||||
import 'dnf_service.dart';
|
||||
|
||||
class SettingsPage extends StatefulWidget {
|
||||
const SettingsPage({super.key});
|
||||
|
||||
@override
|
||||
State<SettingsPage> createState() => _SettingsPageState();
|
||||
}
|
||||
|
||||
class _SettingsPageState extends State<SettingsPage> {
|
||||
bool? _packageKitInstalled;
|
||||
bool _uninstalling = false;
|
||||
final List<String> _log = [];
|
||||
final ScrollController _logScroll = ScrollController();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_checkPackageKit();
|
||||
}
|
||||
|
||||
Future<void> _checkPackageKit() async {
|
||||
final installed = await CategoryService.isFullCatalogAvailable();
|
||||
if (mounted) setState(() => _packageKitInstalled = installed);
|
||||
}
|
||||
|
||||
Future<void> _confirmAndUninstall() async {
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Uninstall PackageKit?'),
|
||||
content: const Text(
|
||||
'This removes PackageKit and disables AppStream-based app categories until it is reinstalled.'),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.pop(context, false), child: const Text('Cancel')),
|
||||
FilledButton(onPressed: () => Navigator.pop(context, true), child: const Text('Uninstall')),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (confirmed != true) return;
|
||||
|
||||
setState(() {
|
||||
_uninstalling = true;
|
||||
_log.clear();
|
||||
});
|
||||
try {
|
||||
final exitCode = await DnfService.uninstall('PackageKit', (line) {
|
||||
setState(() => _log.add(line));
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (_logScroll.hasClients) {
|
||||
_logScroll.jumpTo(_logScroll.position.maxScrollExtent);
|
||||
}
|
||||
});
|
||||
});
|
||||
await _checkPackageKit();
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
|
||||
content: Text(exitCode == 0 ? 'PackageKit removed.' : 'Uninstall failed — see log.'),
|
||||
));
|
||||
}
|
||||
} finally {
|
||||
if (mounted) setState(() => _uninstalling = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Settings')),
|
||||
body: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Card(
|
||||
child: ListTile(
|
||||
leading: Icon(
|
||||
_packageKitInstalled == true ? Icons.check_circle : Icons.remove_circle_outline,
|
||||
color: _packageKitInstalled == true ? Colors.green.shade600 : Colors.grey,
|
||||
),
|
||||
title: const Text('PackageKit'),
|
||||
subtitle: Text(switch (_packageKitInstalled) {
|
||||
null => 'Checking...',
|
||||
true => 'Installed — powers AppStream app categories.',
|
||||
false => 'Not installed.',
|
||||
}),
|
||||
trailing: _packageKitInstalled == true
|
||||
? TextButton.icon(
|
||||
onPressed: _uninstalling ? null : _confirmAndUninstall,
|
||||
icon: _uninstalling
|
||||
? const SizedBox(
|
||||
width: 14,
|
||||
height: 14,
|
||||
child: CircularProgressIndicator(strokeWidth: 2))
|
||||
: const Icon(Icons.delete_outline),
|
||||
label: Text(_uninstalling ? 'Uninstalling...' : 'Uninstall'),
|
||||
)
|
||||
: null,
|
||||
),
|
||||
),
|
||||
if (_log.isNotEmpty) ...[
|
||||
const SizedBox(height: 16),
|
||||
Expanded(
|
||||
child: Container(
|
||||
color: Colors.black87,
|
||||
padding: const EdgeInsets.all(8),
|
||||
child: ListView.builder(
|
||||
controller: _logScroll,
|
||||
itemCount: _log.length,
|
||||
itemBuilder: (context, i) => Text(
|
||||
_log[i],
|
||||
style:
|
||||
const TextStyle(color: Colors.white, fontFamily: 'monospace', fontSize: 12),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -4,10 +4,10 @@ project(runner LANGUAGES CXX)
|
|||
|
||||
# The name of the executable created for the application. Change this to change
|
||||
# the on-disk name of your application.
|
||||
set(BINARY_NAME "pkg_launcher")
|
||||
set(BINARY_NAME "dnf_package_store")
|
||||
# The unique GTK application identifier for this application. See:
|
||||
# https://wiki.gnome.org/HowDoI/ChooseApplicationID
|
||||
set(APPLICATION_ID "com.ironmagma.pkg_launcher")
|
||||
set(APPLICATION_ID "com.ironmagma.dnf_package_store")
|
||||
|
||||
# Explicitly opt in to modern CMake behaviors to avoid warnings with recent
|
||||
# versions of CMake.
|
||||
|
|
|
|||
|
|
@ -45,11 +45,11 @@ static void my_application_activate(GApplication* application) {
|
|||
if (use_header_bar) {
|
||||
GtkHeaderBar* header_bar = GTK_HEADER_BAR(gtk_header_bar_new());
|
||||
gtk_widget_show(GTK_WIDGET(header_bar));
|
||||
gtk_header_bar_set_title(header_bar, "pkg_launcher");
|
||||
gtk_header_bar_set_title(header_bar, "DNF Package Store");
|
||||
gtk_header_bar_set_show_close_button(header_bar, TRUE);
|
||||
gtk_window_set_titlebar(window, GTK_WIDGET(header_bar));
|
||||
} else {
|
||||
gtk_window_set_title(window, "pkg_launcher");
|
||||
gtk_window_set_title(window, "DNF Package Store");
|
||||
}
|
||||
|
||||
gtk_window_set_default_size(window, 1280, 720);
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
name: pkg_launcher
|
||||
description: "A new Flutter project."
|
||||
name: dnf_package_store
|
||||
description: "A DNF-based package browser and transient installer for Linux."
|
||||
# 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
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'package:pkg_launcher/main.dart';
|
||||
import 'package:dnf_package_store/main.dart';
|
||||
|
||||
void main() {
|
||||
testWidgets('search page renders search field', (WidgetTester tester) async {
|
||||
await tester.pumpWidget(const PkgLauncherApp());
|
||||
await tester.pumpWidget(const DnfPackageStoreApp());
|
||||
|
||||
expect(find.text('Search packages'), findsOneWidget);
|
||||
expect(find.widgetWithText(FilledButton, 'Search'), findsOneWidget);
|
||||
|
|
|
|||
Loading…
Reference in a new issue