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:
Philip Peterson 2026-10-05 04:04:22 -08:00
parent 1a84b244b0
commit 0788b01ff9
9 changed files with 636 additions and 37 deletions

View file

@ -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
View 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]);
}
}

View file

@ -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.

View file

@ -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(
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<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
View 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),
),
),
),
),
],
],
),
),
);
}
}

View file

@ -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.

View file

@ -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);

View file

@ -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

View file

@ -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);