From 6318918cdc7ae7fabf9b728ab3b78e8ebc7c73fb Mon Sep 17 00:00:00 2001 From: RarDog Date: Sun, 13 Sep 2026 19:24:43 +0300 Subject: [PATCH] feat: Add Recommended badge for AUR packages with highest likes among app variants --- lib/src/services/catalog_service.dart | 53 ++++++++ lib/src/ui/screens/discover_screen.dart | 43 +++++- .../ui/widgets/spotlight_search_dialog.dart | 58 +++++++- test/recommended_aur_package_test.dart | 128 ++++++++++++++++++ 4 files changed, 277 insertions(+), 5 deletions(-) create mode 100644 test/recommended_aur_package_test.dart diff --git a/lib/src/services/catalog_service.dart b/lib/src/services/catalog_service.dart index 49b91bf..7556d7f 100644 --- a/lib/src/services/catalog_service.dart +++ b/lib/src/services/catalog_service.dart @@ -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 aurApps) { + if (aurApps.length < 2) return; + + final groups = >{}; + 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> _searchAur(String query) async { final List list = []; @@ -414,6 +465,8 @@ class CatalogService { : 4.2), )); } + + markRecommendedAurPackages(list); } catch (_) {} return list; } diff --git a/lib/src/ui/screens/discover_screen.dart b/lib/src/ui/screens/discover_screen.dart index 4d748bf..f631cd7 100644 --- a/lib/src/ui/screens/discover_screen.dart +++ b/lib/src/ui/screens/discover_screen.dart @@ -602,13 +602,16 @@ class _DiscoverScreenState extends State { } 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 { 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 { 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(), diff --git a/lib/src/ui/widgets/spotlight_search_dialog.dart b/lib/src/ui/widgets/spotlight_search_dialog.dart index a18bd84..d0d2526 100644 --- a/lib/src/ui/widgets/spotlight_search_dialog.dart +++ b/lib/src/ui/widgets/spotlight_search_dialog.dart @@ -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 { // 2. Поиск в AUR if (_activeFilter == 'Все' || _activeFilter == 'AUR') { final aurList = await AurService.search(query); + final aurItems = []; 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 { aurVotes: aur.numVotes, )); } + + if (aurItems.length > 1) { + final groups = >{}; + 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 { ), 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), ], ), diff --git a/test/recommended_aur_package_test.dart b/test/recommended_aur_package_test.dart new file mode 100644 index 0000000..6cc6a8c --- /dev/null +++ b/test/recommended_aur_package_test.dart @@ -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); + }); + }); +}