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 IconData fallbackIcon;
|
||||||
final Color accentColor;
|
final Color accentColor;
|
||||||
bool isInstalled;
|
bool isInstalled;
|
||||||
|
bool isRecommended;
|
||||||
final double rating;
|
final double rating;
|
||||||
final String? version;
|
final String? version;
|
||||||
final int? votes;
|
final int? votes;
|
||||||
@@ -30,6 +31,7 @@ class CatalogApp {
|
|||||||
required this.fallbackIcon,
|
required this.fallbackIcon,
|
||||||
this.accentColor = const Color(0xFF0A84FF),
|
this.accentColor = const Color(0xFF0A84FF),
|
||||||
this.isInstalled = false,
|
this.isInstalled = false,
|
||||||
|
this.isRecommended = false,
|
||||||
this.rating = 4.8,
|
this.rating = 4.8,
|
||||||
this.version,
|
this.version,
|
||||||
this.votes,
|
this.votes,
|
||||||
@@ -390,6 +392,55 @@ class CatalogService {
|
|||||||
return list;
|
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
|
/// Поиск в AUR
|
||||||
static Future<List<CatalogApp>> _searchAur(String query) async {
|
static Future<List<CatalogApp>> _searchAur(String query) async {
|
||||||
final List<CatalogApp> list = [];
|
final List<CatalogApp> list = [];
|
||||||
@@ -414,6 +465,8 @@ class CatalogService {
|
|||||||
: 4.2),
|
: 4.2),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
markRecommendedAurPackages(list);
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
return list;
|
return list;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -602,13 +602,16 @@ class _DiscoverScreenState extends State<DiscoverScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildAppCard(CatalogApp app, MacosThemeData macosTheme) {
|
Widget _buildAppCard(CatalogApp app, MacosThemeData macosTheme) {
|
||||||
|
final isRecommended = app.isRecommended;
|
||||||
return Container(
|
return Container(
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: macosTheme.canvasColor,
|
color: macosTheme.canvasColor,
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
border: Border.all(
|
border: Border.all(
|
||||||
color: macosTheme.dividerColor.withValues(alpha: 0.7),
|
color: isRecommended
|
||||||
width: 0.8,
|
? const Color(0xFFFF9F0A).withValues(alpha: 0.45)
|
||||||
|
: macosTheme.dividerColor.withValues(alpha: 0.7),
|
||||||
|
width: isRecommended ? 1.0 : 0.8,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
child: CupertinoButton(
|
child: CupertinoButton(
|
||||||
@@ -642,6 +645,36 @@ class _DiscoverScreenState extends State<DiscoverScreen> {
|
|||||||
overflow: TextOverflow.ellipsis,
|
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),
|
const SizedBox(width: 6),
|
||||||
_buildBackendPill(app.backend),
|
_buildBackendPill(app.backend),
|
||||||
],
|
],
|
||||||
@@ -671,7 +704,11 @@ class _DiscoverScreenState extends State<DiscoverScreen> {
|
|||||||
const SizedBox(width: 6),
|
const SizedBox(width: 6),
|
||||||
Text(
|
Text(
|
||||||
'(${app.votes} 👍)',
|
'(${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(),
|
const Spacer(),
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import 'package:flutter/services.dart';
|
|||||||
import 'package:macos_ui/macos_ui.dart';
|
import 'package:macos_ui/macos_ui.dart';
|
||||||
import '../../rust/api/packages.dart';
|
import '../../rust/api/packages.dart';
|
||||||
import '../../services/aur_service.dart';
|
import '../../services/aur_service.dart';
|
||||||
|
import '../../services/catalog_service.dart';
|
||||||
|
|
||||||
class SpotlightItem {
|
class SpotlightItem {
|
||||||
final String name;
|
final String name;
|
||||||
@@ -13,14 +14,16 @@ class SpotlightItem {
|
|||||||
final BackendType backend;
|
final BackendType backend;
|
||||||
final bool isInstalled;
|
final bool isInstalled;
|
||||||
final int? aurVotes;
|
final int? aurVotes;
|
||||||
|
bool isRecommended;
|
||||||
|
|
||||||
const SpotlightItem({
|
SpotlightItem({
|
||||||
required this.name,
|
required this.name,
|
||||||
required this.version,
|
required this.version,
|
||||||
required this.description,
|
required this.description,
|
||||||
required this.backend,
|
required this.backend,
|
||||||
this.isInstalled = false,
|
this.isInstalled = false,
|
||||||
this.aurVotes,
|
this.aurVotes,
|
||||||
|
this.isRecommended = false,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -128,8 +131,9 @@ class _SpotlightSearchDialogState extends State<SpotlightSearchDialog> {
|
|||||||
// 2. Поиск в AUR
|
// 2. Поиск в AUR
|
||||||
if (_activeFilter == 'Все' || _activeFilter == 'AUR') {
|
if (_activeFilter == 'Все' || _activeFilter == 'AUR') {
|
||||||
final aurList = await AurService.search(query);
|
final aurList = await AurService.search(query);
|
||||||
|
final aurItems = <SpotlightItem>[];
|
||||||
for (final aur in aurList.take(15)) {
|
for (final aur in aurList.take(15)) {
|
||||||
items.add(SpotlightItem(
|
aurItems.add(SpotlightItem(
|
||||||
name: aur.name,
|
name: aur.name,
|
||||||
version: aur.version,
|
version: aur.version,
|
||||||
description: aur.description,
|
description: aur.description,
|
||||||
@@ -137,6 +141,26 @@ class _SpotlightSearchDialogState extends State<SpotlightSearchDialog> {
|
|||||||
aurVotes: aur.numVotes,
|
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
|
// 3. Поиск во Flatpak
|
||||||
@@ -411,6 +435,36 @@ class _SpotlightSearchDialogState extends State<SpotlightSearchDialog> {
|
|||||||
),
|
),
|
||||||
const SizedBox(width: 6),
|
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),
|
_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