From 0788b01ff958eb7e2e7fbdb738410efcf8731bbb Mon Sep 17 00:00:00 2001 From: Philip Peterson <1326208+philip-peterson@users.noreply.github.com> Date: Mon, 5 Oct 2026 04:04:22 -0800 Subject: [PATCH] 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 --- README.md | 29 +++- lib/category_service.dart | 126 ++++++++++++++ lib/dnf_service.dart | 70 +++++++- lib/main.dart | 306 +++++++++++++++++++++++++++++++-- lib/settings_page.dart | 126 ++++++++++++++ linux/CMakeLists.txt | 4 +- linux/runner/my_application.cc | 4 +- pubspec.yaml | 4 +- test/widget_test.dart | 4 +- 9 files changed, 636 insertions(+), 37 deletions(-) create mode 100644 lib/category_service.dart create mode 100644 lib/settings_page.dart diff --git a/README.md b/README.md index a7dd814..2eb733a 100644 --- a/README.md +++ b/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, diff --git a/lib/category_service.dart b/lib/category_service.dart new file mode 100644 index 0000000..6ffa4e8 --- /dev/null +++ b/lib/category_service.dart @@ -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 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> 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> 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 tagsFor(String packageName) { + return {...(appstream[packageName] ?? const {}), ...(comps[packageName] ?? const {})}; + } + + bool hasTag(String packageName, String tag) => tagsFor(packageName).contains(tag); + + Set 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 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>> buildAppStreamIndex() async { + final index = >{}; + 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)!, () => {}).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>> buildCompsIndex() async { + final listResult = await Process.run('dnf', ['group', 'list']); + if (listResult.exitCode != 0) { + throw Exception('dnf group list failed: ${listResult.stderr}'); + } + final groupIds = []; + 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 = >{}; + 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, () => {}).add(groupName ?? id); + } else if (key.isEmpty && inPackagesSection) { + if (value.isNotEmpty) index.putIfAbsent(value, () => {}).add(groupName ?? id); + } else { + inPackagesSection = false; + } + } + }); + await Future.wait(futures); + return index; + } + + static Future build() async { + final results = await Future.wait([buildAppStreamIndex(), buildCompsIndex()]); + return CategoryIndex(appstream: results[0], comps: results[1]); + } +} diff --git a/lib/dnf_service.dart b/lib/dnf_service.dart index 45d1004..e69cba5 100644 --- a/lib/dnf_service.dart +++ b/lib/dnf_service.dart @@ -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> listRepos() async { + final result = await Process.run('dnf', ['repolist']); + if (result.exitCode != 0) { + throw Exception('dnf repolist failed: ${result.stderr}'); + } + final repos = []; + 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> 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> 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> 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> 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 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. diff --git a/lib/main.dart b/lib/main.dart index 58508e3..1dad0fe 100644 --- a/lib/main.dart +++ b/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 { final List _installLog = []; final ScrollController _logScroll = ScrollController(); List _launchTargets = []; + bool _uninstalling = false; + + bool _packageKitAvailable = false; + bool _installingPackageKit = false; + CategoryIndex? _categoryIndex; + String? _activeCategory; // null = unfiltered; '' sentinel = "Other" + bool _loadingCategoryResults = false; + + List _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 _loadCategories() async { + _packageKitAvailable = await CategoryService.isFullCatalogAvailable(); + final index = await CategoryService.build(); + if (mounted) setState(() => _categoryIndex = index); + } + + Future _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 _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 _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 _doSearch() async { final query = _queryController.text.trim(); @@ -49,10 +156,11 @@ class _SearchPageState extends State { _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 { _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 { } } + Future _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( + 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 { ), ], ), + 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 { ); } + Widget _buildCategoryBar() { + final idx = _categoryIndex; + final compsLabels = idx == null ? {} : 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 { 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( - onPressed: _doInstall, - child: const Text('Install (transient)'), - ) - : null, + 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)'), + ), + ), + ], + ], + ), + ), ), ); }, @@ -225,7 +489,15 @@ class _SearchPageState extends State { 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), diff --git a/lib/settings_page.dart b/lib/settings_page.dart new file mode 100644 index 0000000..52c8344 --- /dev/null +++ b/lib/settings_page.dart @@ -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 createState() => _SettingsPageState(); +} + +class _SettingsPageState extends State { + bool? _packageKitInstalled; + bool _uninstalling = false; + final List _log = []; + final ScrollController _logScroll = ScrollController(); + + @override + void initState() { + super.initState(); + _checkPackageKit(); + } + + Future _checkPackageKit() async { + final installed = await CategoryService.isFullCatalogAvailable(); + if (mounted) setState(() => _packageKitInstalled = installed); + } + + Future _confirmAndUninstall() async { + final confirmed = await showDialog( + 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), + ), + ), + ), + ), + ], + ], + ), + ), + ); + } +} diff --git a/linux/CMakeLists.txt b/linux/CMakeLists.txt index 1a940f1..fc45c4e 100644 --- a/linux/CMakeLists.txt +++ b/linux/CMakeLists.txt @@ -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. diff --git a/linux/runner/my_application.cc b/linux/runner/my_application.cc index 87fe1ea..745c53c 100644 --- a/linux/runner/my_application.cc +++ b/linux/runner/my_application.cc @@ -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); diff --git a/pubspec.yaml b/pubspec.yaml index 99f93af..8afabc2 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -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 diff --git a/test/widget_test.dart b/test/widget_test.dart index f560462..add996b 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -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);