feat: Add Recommended badge for AUR packages with highest likes among app variants
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user