feat: Add Recommended badge for AUR packages with highest likes among app variants

This commit is contained in:
RarDog
2026-09-13 19:24:43 +03:00
parent fda87c63fd
commit 6318918cdc
4 changed files with 277 additions and 5 deletions
+53
View File
@@ -15,6 +15,7 @@ class CatalogApp {
final IconData fallbackIcon;
final Color accentColor;
bool isInstalled;
bool isRecommended;
final double rating;
final String? version;
final int? votes;
@@ -30,6 +31,7 @@ class CatalogApp {
required this.fallbackIcon,
this.accentColor = const Color(0xFF0A84FF),
this.isInstalled = false,
this.isRecommended = false,
this.rating = 4.8,
this.version,
this.votes,
@@ -390,6 +392,55 @@ class CatalogService {
return list;
}
/// Извлечение базового имени приложения без специфических суффиксов
static String extractAppBaseName(String pkgName) {
String name = pkgName.toLowerCase();
const suffixes = [
'-bin', '-git', '-beta', '-dev', '-devel', '-nightly',
'-insiders', '-preview', '-canary', '-launcher', '-electron',
'-appimage', '-standalone', '-latest', '-edge', '-ptb',
'-nox', '-qt5', '-qt6', '-qt', '-gtk3', '-gtk4', '-gtk',
'-wayland', '-x11', '-static', '-wrapper',
];
bool changed = true;
while (changed) {
changed = false;
for (final suf in suffixes) {
if (name.endsWith(suf) && name.length > suf.length) {
name = name.substring(0, name.length - suf.length);
changed = true;
}
}
}
return name;
}
/// Если пакетов одного приложения в AUR несколько, помечаем рекомендуемый с наибольшим количеством лайков/голосов
static void markRecommendedAurPackages(List<CatalogApp> aurApps) {
if (aurApps.length < 2) return;
final groups = <String, List<CatalogApp>>{};
for (final app in aurApps) {
final base = extractAppBaseName(app.id);
groups.putIfAbsent(base, () => []).add(app);
}
for (final group in groups.values) {
if (group.length > 1) {
// Сортируем пакеты внутри группы по числу голосов (по убыванию)
group.sort((a, b) => (b.votes ?? 0).compareTo(a.votes ?? 0));
final top = group.first;
final topVotes = top.votes ?? 0;
final runnerUpVotes = group[1].votes ?? 0;
// Помечаем пакет как рекомендуемый, если у него больше лайков чем у других или он очевидный лидер
if (topVotes > runnerUpVotes || (topVotes > 0 && group.length > 1)) {
top.isRecommended = true;
}
}
}
}
/// Поиск в AUR
static Future<List<CatalogApp>> _searchAur(String query) async {
final List<CatalogApp> list = [];
@@ -414,6 +465,8 @@ class CatalogService {
: 4.2),
));
}
markRecommendedAurPackages(list);
} catch (_) {}
return list;
}
+40 -3
View File
@@ -602,13 +602,16 @@ class _DiscoverScreenState extends State<DiscoverScreen> {
}
Widget _buildAppCard(CatalogApp app, MacosThemeData macosTheme) {
final isRecommended = app.isRecommended;
return Container(
decoration: BoxDecoration(
color: macosTheme.canvasColor,
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: macosTheme.dividerColor.withValues(alpha: 0.7),
width: 0.8,
color: isRecommended
? const Color(0xFFFF9F0A).withValues(alpha: 0.45)
: macosTheme.dividerColor.withValues(alpha: 0.7),
width: isRecommended ? 1.0 : 0.8,
),
),
child: CupertinoButton(
@@ -642,6 +645,36 @@ class _DiscoverScreenState extends State<DiscoverScreen> {
overflow: TextOverflow.ellipsis,
),
),
if (app.isRecommended) ...[
const SizedBox(width: 6),
Container(
padding: const EdgeInsets.symmetric(horizontal: 5.5, vertical: 2),
decoration: BoxDecoration(
color: const Color(0xFFFF9F0A).withValues(alpha: 0.16),
borderRadius: BorderRadius.circular(5),
border: Border.all(
color: const Color(0xFFFF9F0A).withValues(alpha: 0.45),
width: 0.8,
),
),
child: const Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(CupertinoIcons.hand_thumbsup_fill, size: 9, color: Color(0xFFFF9F0A)),
SizedBox(width: 3.5),
Text(
'РЕКОМЕНДУЕМЫЙ',
style: TextStyle(
fontSize: 8.5,
fontWeight: FontWeight.w800,
letterSpacing: 0.3,
color: Color(0xFFFF9F0A),
),
),
],
),
),
],
const SizedBox(width: 6),
_buildBackendPill(app.backend),
],
@@ -671,7 +704,11 @@ class _DiscoverScreenState extends State<DiscoverScreen> {
const SizedBox(width: 6),
Text(
'(${app.votes} 👍)',
style: const TextStyle(fontSize: 10, color: CupertinoColors.systemGrey),
style: TextStyle(
fontSize: 10,
fontWeight: app.isRecommended ? FontWeight.bold : FontWeight.normal,
color: app.isRecommended ? const Color(0xFFFF9F0A) : CupertinoColors.systemGrey,
),
),
],
const Spacer(),
@@ -5,6 +5,7 @@ import 'package:flutter/services.dart';
import 'package:macos_ui/macos_ui.dart';
import '../../rust/api/packages.dart';
import '../../services/aur_service.dart';
import '../../services/catalog_service.dart';
class SpotlightItem {
final String name;
@@ -13,14 +14,16 @@ class SpotlightItem {
final BackendType backend;
final bool isInstalled;
final int? aurVotes;
bool isRecommended;
const SpotlightItem({
SpotlightItem({
required this.name,
required this.version,
required this.description,
required this.backend,
this.isInstalled = false,
this.aurVotes,
this.isRecommended = false,
});
}
@@ -128,8 +131,9 @@ class _SpotlightSearchDialogState extends State<SpotlightSearchDialog> {
// 2. Поиск в AUR
if (_activeFilter == 'Все' || _activeFilter == 'AUR') {
final aurList = await AurService.search(query);
final aurItems = <SpotlightItem>[];
for (final aur in aurList.take(15)) {
items.add(SpotlightItem(
aurItems.add(SpotlightItem(
name: aur.name,
version: aur.version,
description: aur.description,
@@ -137,6 +141,26 @@ class _SpotlightSearchDialogState extends State<SpotlightSearchDialog> {
aurVotes: aur.numVotes,
));
}
if (aurItems.length > 1) {
final groups = <String, List<SpotlightItem>>{};
for (final it in aurItems) {
final base = CatalogService.extractAppBaseName(it.name);
groups.putIfAbsent(base, () => []).add(it);
}
for (final group in groups.values) {
if (group.length > 1) {
group.sort((a, b) => (b.aurVotes ?? 0).compareTo(a.aurVotes ?? 0));
final top = group.first;
final topVotes = top.aurVotes ?? 0;
final runnerUpVotes = group[1].aurVotes ?? 0;
if (topVotes > runnerUpVotes || (topVotes > 0 && group.length > 1)) {
top.isRecommended = true;
}
}
}
}
items.addAll(aurItems);
}
// 3. Поиск во Flatpak
@@ -411,6 +435,36 @@ class _SpotlightSearchDialogState extends State<SpotlightSearchDialog> {
),
const SizedBox(width: 6),
],
if (item.isRecommended) ...[
Container(
padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 1.5),
decoration: BoxDecoration(
color: const Color(0xFFFF9F0A).withValues(alpha: 0.18),
borderRadius: BorderRadius.circular(4),
border: Border.all(
color: const Color(0xFFFF9F0A).withValues(alpha: 0.45),
width: 0.7,
),
),
child: const Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(CupertinoIcons.hand_thumbsup_fill, size: 8, color: Color(0xFFFF9F0A)),
SizedBox(width: 2.5),
Text(
'РЕКОМЕНДУЕМЫЙ',
style: TextStyle(
fontSize: 7.5,
fontWeight: FontWeight.w800,
letterSpacing: 0.2,
color: Color(0xFFFF9F0A),
),
),
],
),
),
const SizedBox(width: 6),
],
_buildBackendBadge(item.backend),
],
),
+128
View File
@@ -0,0 +1,128 @@
import 'package:flutter/cupertino.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:forge/src/rust/api/packages.dart';
import 'package:forge/src/services/catalog_service.dart';
void main() {
group('CatalogService.extractAppBaseName', () {
test('strips common AUR package suffixes correctly', () {
expect(CatalogService.extractAppBaseName('paru'), 'paru');
expect(CatalogService.extractAppBaseName('paru-bin'), 'paru');
expect(CatalogService.extractAppBaseName('paru-git'), 'paru');
expect(CatalogService.extractAppBaseName('paru-static'), 'paru');
expect(CatalogService.extractAppBaseName('google-chrome-beta'), 'google-chrome');
expect(CatalogService.extractAppBaseName('google-chrome-dev'), 'google-chrome');
expect(CatalogService.extractAppBaseName('telegram-desktop-bin'), 'telegram-desktop');
expect(CatalogService.extractAppBaseName('visual-studio-code-insiders-bin'), 'visual-studio-code');
expect(CatalogService.extractAppBaseName('zen-browser-beta-bin'), 'zen-browser');
});
});
group('CatalogService.markRecommendedAurPackages', () {
test('marks package with highest votes as recommended when multiple variants exist', () {
final apps = [
CatalogApp(
id: 'paru',
name: 'paru',
summary: 'Paru source',
category: 'AUR',
backend: BackendType.aur,
votes: 1256,
fallbackIcon: CupertinoIcons.app,
),
CatalogApp(
id: 'paru-bin',
name: 'paru-bin',
summary: 'Paru binary',
category: 'AUR',
backend: BackendType.aur,
votes: 324,
fallbackIcon: CupertinoIcons.app,
),
CatalogApp(
id: 'paru-git',
name: 'paru-git',
summary: 'Paru development git',
category: 'AUR',
backend: BackendType.aur,
votes: 69,
fallbackIcon: CupertinoIcons.app,
),
CatalogApp(
id: 'unique-single-utility',
name: 'unique-single-utility',
summary: 'Single tool',
category: 'AUR',
backend: BackendType.aur,
votes: 50,
fallbackIcon: CupertinoIcons.app,
),
];
CatalogService.markRecommendedAurPackages(apps);
final paru = apps.firstWhere((a) => a.id == 'paru');
final paruBin = apps.firstWhere((a) => a.id == 'paru-bin');
final paruGit = apps.firstWhere((a) => a.id == 'paru-git');
final single = apps.firstWhere((a) => a.id == 'unique-single-utility');
expect(paru.isRecommended, isTrue);
expect(paruBin.isRecommended, isFalse);
expect(paruGit.isRecommended, isFalse);
expect(single.isRecommended, isFalse);
});
test('correctly handles multiple different app groups in the same search result', () {
final apps = [
CatalogApp(
id: 'spotify',
name: 'spotify',
summary: 'Spotify client',
category: 'AUR',
backend: BackendType.aur,
votes: 276,
fallbackIcon: CupertinoIcons.app,
),
CatalogApp(
id: 'spotify-dev',
name: 'spotify-dev',
summary: 'Spotify dev',
category: 'AUR',
backend: BackendType.aur,
votes: 26,
fallbackIcon: CupertinoIcons.app,
),
CatalogApp(
id: 'zen-browser-bin',
name: 'zen-browser-bin',
summary: 'Zen Browser Binary',
category: 'AUR',
backend: BackendType.aur,
votes: 140,
fallbackIcon: CupertinoIcons.app,
),
CatalogApp(
id: 'zen-browser-beta-bin',
name: 'zen-browser-beta-bin',
summary: 'Zen Browser Beta',
category: 'AUR',
backend: BackendType.aur,
votes: 15,
fallbackIcon: CupertinoIcons.app,
),
];
CatalogService.markRecommendedAurPackages(apps);
final spotify = apps.firstWhere((a) => a.id == 'spotify');
final spotifyDev = apps.firstWhere((a) => a.id == 'spotify-dev');
final zenBin = apps.firstWhere((a) => a.id == 'zen-browser-bin');
final zenBeta = apps.firstWhere((a) => a.id == 'zen-browser-beta-bin');
expect(spotify.isRecommended, isTrue);
expect(spotifyDev.isRecommended, isFalse);
expect(zenBin.isRecommended, isTrue);
expect(zenBeta.isRecommended, isFalse);
});
});
}