diff --git a/.github/workflows/lint.yaml b/.github/workflows/lint.yaml index 6084475..01f1752 100644 --- a/.github/workflows/lint.yaml +++ b/.github/workflows/lint.yaml @@ -9,6 +9,8 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 + with: + submodules: recursive - name: Install flutter dependencies run: | sudo apt-get update -y && sudo apt-get upgrade -y; diff --git a/.github/workflows/linux-build.yaml b/.github/workflows/linux-build.yaml index 39af7a5..7005e09 100644 --- a/.github/workflows/linux-build.yaml +++ b/.github/workflows/linux-build.yaml @@ -17,6 +17,8 @@ jobs: artifact_name: outlet-linux-arm64 steps: - uses: actions/checkout@v4 + with: + submodules: recursive - name: Install flutter dependencies run: | sudo apt-get update -y; diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..a3e32fd --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "lib/appstream.dart"] + path = lib/appstream.dart + url = git@github.com:jardon/appstream.dart.git diff --git a/analysis_options.yaml b/analysis_options.yaml new file mode 100644 index 0000000..1523995 --- /dev/null +++ b/analysis_options.yaml @@ -0,0 +1,30 @@ +# This file configures the analyzer, which statically analyzes Dart code to +# check for errors, warnings, and lints. +# +# The issues identified by the analyzer are surfaced in the UI of Dart-enabled +# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be +# invoked from the command line by running `flutter analyze`. + +# The following line activates a set of recommended lints for Flutter apps, +# packages, and plugins designed to encourage good coding practices. +include: package:flutter_lints/flutter.yaml + +linter: + # The lint rules applied to this project can be customized in the + # section below to disable rules from the `package:flutter_lints/flutter.yaml` + # included above or to enable additional rules. A list of all available lints + # and their documentation is published at https://dart.dev/lints. + # + # Instead of disabling a lint rule for the entire project in the + # section below, it can also be suppressed for a single line of code + # or a specific dart file by using the `// ignore: name_of_lint` and + # `// ignore_for_file: name_of_lint` syntax on the line or in the file + # producing the lint. + rules: + # avoid_print: false # Uncomment to disable the `avoid_print` rule + # prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule +analyzer: + exclude: + - lib/appstream.dart +# Additional information about this file can be found at +# https://dart.dev/guides/language/analysis-options diff --git a/lib/appstream.dart b/lib/appstream.dart new file mode 160000 index 0000000..d0b847a --- /dev/null +++ b/lib/appstream.dart @@ -0,0 +1 @@ +Subproject commit d0b847a7125c9a17c04941bcea988d37578ffae7 diff --git a/lib/backends/backend.dart b/lib/backends/backend.dart index a318b1d..1eaa6c8 100644 --- a/lib/backends/backend.dart +++ b/lib/backends/backend.dart @@ -4,10 +4,11 @@ import 'dart:core'; import 'package:outlet/backends/flatpak_backend.dart'; import 'package:outlet/core/application.dart'; import 'package:outlet/core/flatpak_application.dart'; +import 'package:outlet/appstream.dart/lib/appstream.dart'; interface class Backend { - Map getInstalledPackages() { - return {}; + List getInstalledPackages() { + return []; } Future> getAllRemotePackages() async { @@ -44,22 +45,19 @@ class TestBackend implements Backend { _pickRandomCategories(categoriesList, 2, random); apps[appId] = FlatpakApplication( - name: "App $i", + name: {"C": "App $i"}, id: appId, - icon: "lib/views/assets/flatpak-icon.svg", - description: "Description for App $i", + icons: const [AppstreamLocalIcon("lib/views/assets/flatpak-icon.svg")], + description: {"C": "Description for App $i"}, + package: null, + summary: {"C": "Summary text for App $i"}, + type: AppstreamComponentType.generic, categories: pickedCategories, // Randomly picked categories installed: random.nextBool(), - verified: random.nextBool(), - featured: random.nextBool(), - reviews: [ - { - "title": "Review for App $i", - "rating": random.nextInt(5) + - 1, // Random rating between 1 and 5 for review - "message": "This is a review message for App $i.", - } + custom: [ + {'flathub::verification::verified': "${random.nextBool()}"} ], + featured: random.nextBool(), ); } } @@ -71,8 +69,8 @@ class TestBackend implements Backend { } @override - Map getInstalledPackages() { - return apps; + List getInstalledPackages() { + return ['app-3', 'app-7']; } @override diff --git a/lib/backends/flatpak_backend.dart b/lib/backends/flatpak_backend.dart index e2efea4..eb08660 100644 --- a/lib/backends/flatpak_backend.dart +++ b/lib/backends/flatpak_backend.dart @@ -3,9 +3,9 @@ import 'dart:core'; import 'dart:ffi' as ffi; import 'dart:io'; import 'dart:typed_data'; -import 'package:collection/collection.dart'; import 'package:ffi/ffi.dart' as pkg_ffi; import 'package:libflatpak/libflatpak.dart'; +import 'package:outlet/appstream.dart/lib/appstream.dart'; import 'package:outlet/backends/backend.dart'; import 'package:outlet/core/application.dart'; import 'package:outlet/core/flatpak_application.dart'; @@ -40,13 +40,13 @@ class FlatpakBackend implements Backend { } @override - Map getInstalledPackages() { + List getInstalledPackages() { final FlatpakBindings bindings = FlatpakBindings(ffi.DynamicLibrary.open('libflatpak.so')); ffi.Pointer installationPtr = getFlatpakInstallation(); ffi.Pointer> error = pkg_ffi.calloc>(); - Map apps = {}; + List apps = []; final ffi.Pointer installedRefsPtr = bindings.flatpak_installation_list_installed_refs( installationPtr, ffi.nullptr, error); @@ -63,10 +63,6 @@ class FlatpakBackend implements Backend { final ffi.Pointer installedRefPtr = refVoidPtr.cast(); - final ffi.Pointer dirPtr = - bindings.flatpak_installed_ref_get_deploy_dir(installedRefPtr); - final deployDir = dirPtr.cast().toDartString(); - final ffi.Pointer refAppPtr = bindings.flatpak_installed_ref_load_appdata( installedRefPtr, @@ -90,28 +86,17 @@ class FlatpakBackend implements Backend { final XmlElement? componentElement = document.findAllElements('component').firstOrNull; if (componentElement == null) { - throw XmlParserException( - "Error: Could not find the main tag."); + logger.i("Error: Could not find the main tag."); + continue; + } + final id = + componentElement.findElements('id').firstOrNull?.innerText; + if (id == null) { + logger.i("Could not read component id of InstalledRef."); + continue; } - final app = appFromXML(componentElement, deployDir, true, null); - - final ffi.Pointer refPtr = - refVoidPtr.cast(); - ffi.Pointer branchPtr = - bindings.flatpak_ref_get_branch(refPtr); - final String branch = branchPtr.cast().toDartString(); - app.branch = branch; - - app.current = bindings - .flatpak_installed_ref_get_is_current(installedRefPtr) == - 1; - - ffi.Pointer archPtr = - bindings.flatpak_ref_get_arch(refPtr); - final String arch = archPtr.cast().toDartString(); - app.arch = arch; - apps[app.id] = app; + apps.add(id); } on XmlParserException catch (e) { logger.e('Error parsing XML: $e'); } @@ -120,29 +105,6 @@ class FlatpakBackend implements Backend { logger.w( 'Failed to load appdata. GError pointer received: ${error.value}'); error.value = ffi.nullptr; - final ffi.Pointer refVoidPtr = pdataPtr[i]; - final ffi.Pointer refPtr = refVoidPtr.cast(); - final ffi.Pointer namePtr = - bindings.flatpak_ref_get_name(refPtr); - final String id = namePtr.cast().toDartString(); - ffi.Pointer branchPtr = - bindings.flatpak_ref_get_branch(refPtr); - final String branch = branchPtr.cast().toDartString(); - - final bool current = - bindings.flatpak_installed_ref_get_is_current(installedRefPtr) == - 1; - - ffi.Pointer archPtr = bindings.flatpak_ref_get_arch(refPtr); - final String arch = archPtr.cast().toDartString(); - - apps[id] = FlatpakApplication( - id: id, - installed: true, - branch: branch, - current: current, - arch: arch, - ); } } pkg_ffi.calloc.free(pdataPtr); @@ -154,258 +116,6 @@ class FlatpakBackend implements Backend { return apps; } - Application appFromXML(XmlElement componentElement, String deployDir, - bool installed, String? remote) { - final id = componentElement.findElements('id').firstOrNull?.innerText; - if (id == null) { - throw XmlParserException("Error: Could not find tag."); - } - - String? type = componentElement.getAttribute('type'); - if (type != null && type != "runtime") { - type = "app"; - } - - String? name; - for (var nameElement in componentElement.findAllElements('name').where( - (element) { - return element.parentElement?.name.local != 'developer'; - }, - )) { - if (nameElement.getAttribute('xml:lang') == null) { - name = nameElement.innerText; - } - } - - String? summary; - for (var summaryElement in componentElement.findAllElements('summary')) { - if (summaryElement.getAttribute('xml:lang') == null) { - summary = summaryElement.innerText; - } - } - - final license = - componentElement.findElements('project_license').firstOrNull?.innerText; - - String? description; - for (final child in componentElement.children) { - if (child is XmlElement && - child.name.local == 'description' && - child.getAttribute('xml:lang') == null) { - description = child.innerText; - break; - } - } - - String? developer; - for (var devElement in componentElement.findAllElements('name').where( - (element) { - return element.parentElement?.name.local == 'developer'; - }, - )) { - if (devElement.getAttribute('xml:lang') == null) { - developer = devElement.innerText; - } - } - - String? icon; - String? remoteIcon; - int iconHeight = 0; - int remoteIconHeight = 0; - for (var iconXML in componentElement.findAllElements('icon')) { - String? heightAttr = iconXML.getAttribute('height'); - int height = (heightAttr != null) ? int.parse(heightAttr) : 0; - if (iconXML.getAttribute('type') == 'cached' && height > iconHeight) { - icon = (deployDir.startsWith("/var/lib/flatpak/appstream")) - ? "$deployDir/icons/${height}x$height/${iconXML.innerText}" - : "$deployDir/files/share/app-info/icons/flatpak/${height}x$height/${iconXML.innerText}"; - iconHeight = height; - } else if (iconXML.getAttribute('type') == 'remote' && - height > remoteIconHeight) { - remoteIcon = iconXML.innerText; - remoteIconHeight = height; - } - } - if (icon != null) { - File file = File(icon); - if (!file.existsSync()) { - icon = null; - } - } - - String? homepage; - String? help; - String? translate; - String? vcs; - for (var url in componentElement.findAllElements('url')) { - final type = url.getAttribute('type'); - final text = url.innerText; - switch (type) { - case 'homepage': - homepage = text; - case 'help': - help = text; - case 'translate': - translate = text; - case 'vcs-browser': - vcs = text; - } - } - - List categories = []; - var categoriesParent = - componentElement.findElements('categories').firstOrNull; - if (categoriesParent != null) { - for (var category in categoriesParent.findElements('category')) { - categories.add(category.innerText); - } - } - - List screenshots = []; - var screenshotsParent = - componentElement.findElements('screenshots').firstOrNull; - if (screenshotsParent != null) { - for (var screenshot in screenshotsParent.findElements('screenshot')) { - String? caption; - String? thumb; - String? full; - int min = 1000000; - int max = 0; - if (screenshot.findElements('caption').isNotEmpty) { - caption = screenshot.findElements('caption').first.innerText; - } - for (var image in screenshot.findElements('image')) { - String? heightAttr = image.getAttribute('height'); - int height = (heightAttr != null) ? int.parse(heightAttr) : 0; - if (height < min) { - thumb = image.innerText; - min = height; - } - if (height > max) { - full = image.innerText; - max = height; - } - } - screenshots.add(Screenshot( - caption: caption, - thumb: thumb!, - full: full ?? thumb, - )); - } - } - - List keywords = []; - var keywordsParent = componentElement.findElements('keywords').firstOrNull; - if (keywordsParent != null) { - for (var keyword in keywordsParent.findElements('keyword')) { - keywords.add(keyword.innerText); - } - } - - List releases = []; - final releasesParent = - componentElement.findAllElements('releases').firstOrNull; - if (releasesParent != null) { - for (final release in releasesParent.findElements('release')) { - final version = release.getAttribute('version'); - final type = release.getAttribute('type'); - final timestamp = release.getAttribute('timestamp'); - final description = - release.findAllElements('description').firstOrNull?.innerText; - - if (version != null && type != null && timestamp != null) { - releases.add(Release( - version: version, - type: type, - timestamp: timestamp, - description: description, - )); - } else { - logger.w(' -> WARNING: Skipping malformed release tag.'); - } - } - } - - final Map content = {}; - final contentRatingElement = - componentElement.findAllElements('content_rating').firstOrNull; - - if (contentRatingElement != null) { - final attributeElements = - contentRatingElement.findAllElements('content_attribute'); - - for (final attribute in attributeElements) { - final id = attribute.getAttribute('id'); - final value = attribute.innerText; - - if (id != null) { - content[id] = value; - } else { - logger.w( - ' -> WARNING: Skipping content_attribute tag with missing id.'); - } - } - } - - bool verified = false; - final customParent = componentElement.findAllElements('custom').firstOrNull; - if (customParent != null) { - for (var value in customParent.findElements('value')) { - if (value.getAttribute('key') == 'flathub::verification::verified') { - verified = bool.parse(value.innerText); - } - } - } - - Bundle? bundle; - final bundleParent = componentElement.findAllElements('bundle').firstOrNull; - if (bundleParent != null) { - String? type = bundleParent.getAttribute('type'); - String? runtime = bundleParent.getAttribute('runtime'); - String? sdk = bundleParent.getAttribute('sdk'); - bundle = Bundle( - type: type, - runtime: runtime, - sdk: sdk, - value: bundleParent.innerText); - } - - String? arch; - String? branch; - if (bundle != null) { - List bundleInfo = bundle.value.split('/'); - type = bundleInfo[0]; - arch = bundleInfo[2]; - branch = bundleInfo[3]; - } - - return FlatpakApplication( - id: id, - name: name, - summary: summary, - license: license, - description: description, - developer: developer, - icon: icon ?? remoteIcon, - homepage: homepage, - help: help, - translate: translate, - vcs: vcs, - categories: categories, - screenshots: screenshots, - keywords: keywords, - releases: releases, - content: content, - verified: verified, - installed: installed, - bundle: bundle, - remote: remote, - branch: branch, - arch: arch, - type: type, - ); - } - @override Future> getAllRemotePackages() async { await Future.microtask(() {}); @@ -465,18 +175,34 @@ class FlatpakBackend implements Backend { appstreamXmlContent = utf8.decode(metaDataBytes, allowMalformed: true); - try { - final XmlDocument document = - XmlDocument.parse(appstreamXmlContent); - for (var componentElement - in List.from(document.findAllElements('component')) - ..shuffle()) { - Application app = appFromXML( - componentElement, appstreamDir, false, remoteName); - apps[app.id] = app; - } - } on XmlParserException catch (e) { - logger.w('Error parsing XML: $e'); + var collection = AppstreamCollection.fromXml(appstreamXmlContent); + + for (var component in collection.components) { + apps[component.id] = FlatpakApplication( + id: component.id, + package: component.package, + name: component.name, + summary: component.summary, + description: component.description, + developerName: component.developerName, + projectLicense: component.projectLicense, + projectGroup: component.projectGroup, + icons: component.icons, + urls: component.urls, + categories: component.categories, + screenshots: component.screenshots, + keywords: component.keywords, + compulsoryForDesktops: component.compulsoryForDesktops, + releases: component.releases, + provides: component.provides, + contentRatings: component.contentRatings, + custom: component.custom, + installed: false, + bundles: component.bundles, + remote: remoteName, + type: component.type, + deployDir: appstreamDir, + ); } } } else { diff --git a/lib/core/application.dart b/lib/core/application.dart index 9a152a6..dc805e9 100644 --- a/lib/core/application.dart +++ b/lib/core/application.dart @@ -1,60 +1,102 @@ import 'dart:core'; +import 'dart:ui'; +import 'package:outlet/appstream.dart/lib/appstream.dart'; -abstract class Application { +class Application extends AppstreamComponent { Application({ - required this.id, - this.name, - this.summary, - this.license, - this.description, - this.developer, - required this.icon, - this.homepage, - this.help, - this.translate, - this.vcs, - this.categories = const [], - this.screenshots = const [], - this.keywords = const [], - this.releases = const [], - this.content = const {}, + required super.id, + required super.type, + required super.package, + Map? name, + required super.summary, + super.description = const {}, + super.developerName = const {}, + super.projectLicense, + super.projectGroup, + super.icons = const [], + super.urls = const [], + super.categories = const [], + super.keywords = const {}, + super.screenshots = const [], + super.compulsoryForDesktops = const [], + super.releases = const [], + super.provides = const [], + super.launchables = const [], + super.languages = const [], + super.contentRatings = const {}, + super.bundles = const [], + super.custom = const [], this.featured = false, - this.verified = false, this.installed = false, - this.reviews = const [], this.remote, this.version, - this.branch, this.current, - this.arch, - }); - - final String id; - final String? name; - final String? summary; - final String? license; - final String? description; - final String? developer; - final String icon; - final String? homepage; - final String? help; - final String? translate; - final String? vcs; - final List categories; - final List screenshots; - final List keywords; - final List releases; - final Map content; + }) : super(name: name ?? {"C": id}); + bool featured; - final bool verified; - final bool installed; - final List reviews; - double? get rating; + bool installed; final String? remote; final String? version; - String? branch; bool? current; - String? arch; + + String getLocalizedName() { + final key = bestLanguageKey(name); + return name.getOrDefault(key, ''); + } + + String getLocalizedDeveloperName() { + final key = bestLanguageKey(name); + return developerName.getOrDefault(key, ''); + } + + List getLocalizedKeywords() { + final key = bestLanguageKey(keywords); + return keywords.getOrDefault(key, []); + } + + String getLocalizedSummary() { + final key = bestLanguageKey(summary); + return summary.getOrDefault(key, ''); + } + + String getLocalizedDescription() { + final key = bestLanguageKey(description); + return description.getOrDefault(key, ''); + } + + String? bestLanguageKey(Map keyedByLanguage) { + final locale = PlatformDispatcher.instance.locale; + + if (locale.toLanguageTag() == 'und') return null; + + final countryCode = locale.countryCode; + final languageCode = locale.languageCode; + final fullLocale = '${languageCode}_$countryCode'; + const fallback = 'C'; + final candidates = [fullLocale, languageCode, fallback]; + final keys = keyedByLanguage.keys; + + for (final candidate in candidates) { + if (keys.contains(candidate)) return candidate; + } + + return null; + } + + String get icon { + final localIcons = icons.whereType(); + final remoteIcons = icons.whereType(); + + if (localIcons.isNotEmpty) return localIcons.first.filename; + + if (remoteIcons.isEmpty) return ""; + + return remoteIcons + .reduce((a, b) => (a.height ?? 0) > (b.height ?? 0) ? a : b) + .url; + } + + bool get verified => false; String getInstallTarget() => id; @@ -65,28 +107,12 @@ abstract class Application { String launchCommand() => id; } -class Screenshot { - final String thumb; - final String full; - final String? caption; +extension _GetOrDefault on Map { + V getOrDefault(K? key, V fallback) { + if (key == null) { + return fallback; + } - Screenshot({ - required this.thumb, - required this.full, - this.caption, - }); -} - -class Release { - final String? version; - final String? timestamp; - final String? type; - final String? description; - - Release({ - this.version, - this.timestamp, - this.type, - this.description, - }); + return this[key] ?? fallback; + } } diff --git a/lib/core/flatpak_application.dart b/lib/core/flatpak_application.dart index 0689eb3..b461232 100644 --- a/lib/core/flatpak_application.dart +++ b/lib/core/flatpak_application.dart @@ -1,46 +1,44 @@ import 'dart:core'; +import 'dart:io'; +import 'package:collection/collection.dart'; +import 'package:outlet/appstream.dart/lib/appstream.dart'; import 'package:outlet/core/application.dart'; class FlatpakApplication extends Application { - final String? type; - final Bundle? bundle; + final String? deployDir; FlatpakApplication({ required super.id, + super.type = AppstreamComponentType.unknown, + super.package, super.name, - super.summary, - super.license, - super.description, - super.developer, - String? icon, - super.homepage, - super.help, - super.translate, - super.vcs, + super.summary = const {"C": "No summary available"}, + super.description = const {}, + super.developerName = const {}, + super.projectLicense, + super.projectGroup, + super.icons = const [ + AppstreamLocalIcon("lib/views/assets/flatpak-icon.svg") + ], + super.urls = const [], super.categories = const [], + super.keywords = const {}, super.screenshots = const [], - super.keywords = const [], + super.compulsoryForDesktops = const [], super.releases = const [], - super.content = const {}, + super.provides = const [], + super.launchables = const [], + super.languages = const [], + super.contentRatings = const {}, super.featured = false, - super.verified = false, super.installed = false, - super.reviews = const [], - this.type, - this.bundle, super.remote, super.version, - super.branch, - super.current = true, - super.arch, - }) : super( - icon: icon ?? "lib/views/assets/flatpak-icon.svg", - ); - - @override - double? get rating { - return null; - } + super.current, + super.bundles, + super.custom, + this.deployDir, + }); @override List get categories { @@ -88,10 +86,50 @@ class FlatpakApplication extends Application { return categoryBadges.toList(); } + @override + String get icon { + final localIcons = icons.whereType(); + final cachedIcons = icons.whereType(); + final remoteIcons = icons.whereType(); + + if (localIcons.isNotEmpty) return localIcons.first.filename; + + if (cachedIcons.isNotEmpty && deployDir != null) { + var iconList = cachedIcons.toList(); + iconList.sort((a, b) => (b.height ?? 0).compareTo(a.height ?? 0)); + var icon = iconList.firstOrNull; + if (icon != null) { + String cachedIcon = (deployDir! + .startsWith("/var/lib/flatpak/appstream")) + ? "$deployDir/icons/${icon.height}x${icon.width}/${icon.name}" + : "$deployDir/files/share/app-info/icons/flatpak/${icon.height}x${icon.width}/${icon.name}"; + var file = File(cachedIcon); + if (file.existsSync()) return cachedIcon; + } + } + + if (remoteIcons.isEmpty) return "lib/views/assets/flatpak-icon.svg"; + + return remoteIcons + .reduce((a, b) => (a.height ?? 0) > (b.height ?? 0) ? a : b) + .url; + } + + @override + bool get verified { + for (var item in custom) { + var verifiedCustomValue = item['flathub::verification::verified']; + if (verifiedCustomValue != null) { + return bool.parse(verifiedCustomValue); + } + } + return false; + } + @override String getInstallTarget() { - if (bundle != null) { - return bundle?.value as String; + if (bundles.isNotEmpty) { + return bundles.first.id; } else { return id; } @@ -99,10 +137,8 @@ class FlatpakApplication extends Application { @override String getUninstallTarget() { - if (bundle != null) { - return bundle?.value as String; - } else if (type != null && branch != null && arch != null) { - return "$type/$id/$arch/$branch"; + if (bundles.isNotEmpty) { + return bundles.first.id; } else { return id; } @@ -116,17 +152,3 @@ class FlatpakApplication extends Application { return "flatpak run $id"; } } - -class Bundle { - final String? type; - final String? runtime; - final String? sdk; - final String value; - - Bundle({ - this.type, - this.runtime, - this.sdk, - required this.value, - }); -} diff --git a/lib/providers/application_provider.dart b/lib/providers/application_provider.dart index 2ab0db5..6d375e2 100644 --- a/lib/providers/application_provider.dart +++ b/lib/providers/application_provider.dart @@ -15,7 +15,7 @@ final remoteAppListProvider = Map env = Platform.environment; Map apps = {}; if (env['TEST_BACKEND_ENABLED'] != null) { - return backend.getInstalledPackages(); + return backend.getAllRemotePackages(); } if (env['FLATPAK_ENABLED'] != null) { @@ -25,16 +25,15 @@ final remoteAppListProvider = return apps; }); -final installedAppListProvider = AutoDisposeNotifierProvider< - InstalledAppListNotifier, - Map>(InstalledAppListNotifier.new); +final installedAppListProvider = + AutoDisposeNotifierProvider>( + InstalledAppListNotifier.new); -class InstalledAppListNotifier - extends AutoDisposeNotifier> { - Map _getInstalledPackages() { +class InstalledAppListNotifier extends AutoDisposeNotifier> { + List _getInstalledPackages() { final backend = ref.read(backendProvider); Map env = Platform.environment; - Map apps = {}; + List apps = []; if (env['TEST_BACKEND_ENABLED'] != null) { return backend.getInstalledPackages(); @@ -47,7 +46,7 @@ class InstalledAppListNotifier } @override - Map build() { + List build() { return _getInstalledPackages(); } @@ -79,31 +78,37 @@ final appListProvider = Provider((ref) { final remoteApps = ref.watch(remoteAppListProvider).value ?? {}; final installedApps = ref.watch(installedAppListProvider); final featuredApps = ref.watch(featuredAppList); - final allApps = {...remoteApps, ...installedApps}; + + // if (installedApps.hasValue) + for (var app in installedApps) { + try { + remoteApps[app]!.installed = true; + } catch (e) { + logger.w('Failed to find $app in installed list.'); + } + } if (featuredApps.hasValue) { List featured = featuredApps.value!; for (var app in featured) { try { - allApps[app]!.featured = true; + remoteApps[app]!.featured = true; } catch (e) { logger.w('Failed to find $app in featured list.'); } } } - return allApps; + return remoteApps; }); final searchKeywordsProvider = Provider((ref) { final allApps = ref.watch(appListProvider); return allApps.map((key, app) { - if (app.name != null) { - return MapEntry("${app.name!.toLowerCase()} ${app.keywords.join()}", key); - } else { - return MapEntry(key.toLowerCase(), key); - } + return MapEntry( + "${app.getLocalizedName().toLowerCase()} ${(app.getLocalizedKeywords()).join(' ')}", + key); }); }); diff --git a/lib/views/components/app_actions.dart b/lib/views/components/app_actions.dart index 600b0aa..3cfc27a 100644 --- a/lib/views/components/app_actions.dart +++ b/lib/views/components/app_actions.dart @@ -65,8 +65,8 @@ class AppActions extends ConsumerWidget { "installTarget": app.getInstallTarget(), "remote": app.remote! }; - actionQueue.add("Installing ${app.name ?? app.id}", - app.id, _installWorker, data); + actionQueue.add("Installing ${app.name}", app.id, + _installWorker, data); } }, style: const ButtonStyle( @@ -97,8 +97,8 @@ class AppActions extends ConsumerWidget { TextButton( onPressed: () async { final data = {"updateTarget": app.getUpdateTarget()}; - actionQueue.add("Updating ${app.name ?? app.id}", app.id, - _updateWorker, data); + actionQueue.add( + "Updating ${app.name}", app.id, _updateWorker, data); }, style: const ButtonStyle( backgroundColor: @@ -108,9 +108,6 @@ class AppActions extends ConsumerWidget { style: TextStyle(color: Colors.white)), ), Expanded(child: Container()), - Text(app.branch != null - ? '${app.branch![0].toUpperCase()}${app.branch!.substring(1)}' - : ''), ]), ), ) diff --git a/lib/views/components/app_card.dart b/lib/views/components/app_card.dart index 1d8fc29..5c2487a 100644 --- a/lib/views/components/app_card.dart +++ b/lib/views/components/app_card.dart @@ -86,10 +86,9 @@ class AppCardState extends State with SingleTickerProviderStateMixin { featured: widget.app.featured, verified: widget.app.verified, installed: widget.app.installed, - name: widget.app.name ?? widget.app.id, + name: widget.app.getLocalizedName(), icon: widget.app.icon, categories: widget.app.categories, - rating: widget.app.rating, ), widget.details ? ClipRRect( @@ -100,9 +99,8 @@ class AppCardState extends State with SingleTickerProviderStateMixin { child: FadeTransition( opacity: _fadeAnimation, child: Details( - name: widget.app.name ?? widget.app.id, - summary: widget.app.summary ?? - "No summary available.", + name: widget.app.getLocalizedName(), + summary: widget.app.getLocalizedSummary(), categories: widget.app.categories, ), ), @@ -123,7 +121,6 @@ class Listing extends StatelessWidget { required this.name, required this.icon, required this.categories, - this.rating, }); final bool featured; @@ -134,7 +131,6 @@ class Listing extends StatelessWidget { final cardWidth = 300.0; final cardHeight = 340.0; final List categories; - final double? rating; @override Widget build(BuildContext context) { diff --git a/lib/views/components/app_description.dart b/lib/views/components/app_description.dart index 1e5b109..b461728 100644 --- a/lib/views/components/app_description.dart +++ b/lib/views/components/app_description.dart @@ -44,23 +44,21 @@ class AppDescription extends StatelessWidget { const SizedBox(height: 8), ExpandableContainer( maxHeight: 150, - child: Html( - data: app.description ?? "No description available.", - style: { - "h1": Style( - fontSize: FontSize.xxLarge, - textAlign: TextAlign.center, - ), - "p": Style( - margin: Margins.zero, - padding: HtmlPaddings.zero, - lineHeight: const LineHeight(1.5), - ), - "ul": Style( - padding: HtmlPaddings.only(left: 20), - margin: Margins.only(top: 8, bottom: 8), - ), - })), + child: Html(data: app.getLocalizedDescription(), style: { + "h1": Style( + fontSize: FontSize.xxLarge, + textAlign: TextAlign.center, + ), + "p": Style( + margin: Margins.zero, + padding: HtmlPaddings.zero, + lineHeight: const LineHeight(1.5), + ), + "ul": Style( + padding: HtmlPaddings.only(left: 20), + margin: Margins.only(top: 8, bottom: 8), + ), + })), ], ), ), diff --git a/lib/views/components/app_info.dart b/lib/views/components/app_info.dart index e2672ff..da81549 100644 --- a/lib/views/components/app_info.dart +++ b/lib/views/components/app_info.dart @@ -41,7 +41,7 @@ class AppInfo extends StatelessWidget { Row(spacing: 10, children: [ Flexible( child: Text( - app.name ?? app.id, + app.getLocalizedName(), softWrap: false, overflow: TextOverflow.fade, style: const TextStyle( @@ -52,14 +52,16 @@ class AppInfo extends StatelessWidget { ]), Expanded( child: Text( - app.summary ?? "No summary available.", + app.getLocalizedSummary(), style: const TextStyle(fontSize: 16), softWrap: true, maxLines: 2, overflow: TextOverflow.ellipsis, )), Row(spacing: 5, children: [ - Text((app.developer != null ? "by ${app.developer}" : "")), + Text((app.getLocalizedDeveloperName() != "" + ? "by ${app.getLocalizedDeveloperName()}" + : "")), app.verified ? const VerifiedBadge(size: 20) : Container(), ]), ], diff --git a/lib/views/components/app_links.dart b/lib/views/components/app_links.dart index 364cd06..dddfcd5 100644 --- a/lib/views/components/app_links.dart +++ b/lib/views/components/app_links.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:outlet/appstream.dart/lib/src/url.dart'; import 'package:outlet/core/application.dart'; import 'package:outlet/views/components/theme.dart'; import 'package:url_launcher/url_launcher.dart'; @@ -21,13 +22,25 @@ class AppLinks extends StatelessWidget { @override Widget build(BuildContext context) { - List> links = [ - if (app.homepage != null) {"title": "Homepage", "url": app.homepage!}, - if (app.help != null) {"title": "Help", "url": app.help!}, - if (app.translate != null) - {"title": "Translation", "url": app.translate!}, - if (app.vcs != null) {"title": "Source Code", "url": app.vcs!}, - ]; + List> links = app.urls.map((link) { + return switch (link.type) { + AppstreamUrlType.homepage => {"title": "Homepage", "url": link.url}, + AppstreamUrlType.bugtracker => { + "title": "Bug Tracker", + "url": link.url + }, + AppstreamUrlType.faq => {"title": "FAQ", "url": link.url}, + AppstreamUrlType.help => {"title": "Help", "url": link.url}, + AppstreamUrlType.donation => {"title": "Donate", "url": link.url}, + AppstreamUrlType.translate => {"title": "Translate", "url": link.url}, + AppstreamUrlType.contact => {"title": "Contact", "url": link.url}, + AppstreamUrlType.vcsBrowser => { + "title": "VCS Browser", + "url": link.url + }, + AppstreamUrlType.contribute => {"title": "Contribute", "url": link.url}, + }; + }).toList(); final Color color = fgColor(context); diff --git a/lib/views/components/category_card.dart b/lib/views/components/category_card.dart index 7813f7a..3bb8c20 100644 --- a/lib/views/components/category_card.dart +++ b/lib/views/components/category_card.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'package:flutter_html/flutter_html.dart'; import 'package:flutter/material.dart'; import 'package:flutter_svg/flutter_svg.dart'; import 'package:outlet/core/application.dart'; @@ -112,6 +113,22 @@ class _CategoryCardState extends State { return LayoutBuilder(builder: (context, constraints) { const double breakpoint = 600.0; final bool isWide = constraints.maxWidth > breakpoint; + Widget description = Html( + data: + (widget.apps[_currentIndex].getLocalizedDescription()).trimLeft(), + style: { + "p": Style( + margin: Margins.zero, + padding: HtmlPaddings.zero, + lineHeight: const LineHeight(1.5), + color: Colors.white, + ), + "ul": Style( + padding: HtmlPaddings.only(left: 20), + margin: Margins.only(top: 8, bottom: 8), + color: Colors.white, + ), + }); return isWide ? SizedBox( height: 200, @@ -146,14 +163,7 @@ class _CategoryCardState extends State { child: Container( padding: const EdgeInsets.only(top: 5, bottom: 5), - child: Text( - (widget.apps[_currentIndex].description ?? - "No description available.") - .trimLeft(), - style: - const TextStyle(color: Colors.white), - overflow: TextOverflow.fade, - softWrap: true))), + child: description)), ])), Expanded( flex: 1, @@ -191,13 +201,7 @@ class _CategoryCardState extends State { Expanded( child: Container( padding: const EdgeInsets.only(top: 5, bottom: 5), - child: Text( - (widget.apps[_currentIndex].description ?? - "No description available.") - .trimLeft(), - style: const TextStyle(color: Colors.white), - overflow: TextOverflow.fade, - softWrap: true))), + child: description)), SizedBox( height: 80, child: ScreenshotsList( diff --git a/lib/views/components/download_queue.dart b/lib/views/components/download_queue.dart index 186aaea..43629cb 100644 --- a/lib/views/components/download_queue.dart +++ b/lib/views/components/download_queue.dart @@ -234,7 +234,7 @@ class PendingDownloadsItem extends ConsumerWidget { AppIconLoader(icon: app.icon), Expanded( child: Text( - app.name ?? app.id, + app.getLocalizedName(), style: const TextStyle( fontSize: 18, fontWeight: FontWeight.bold, diff --git a/lib/views/components/navbar.dart b/lib/views/components/navbar.dart index fd96a1c..1d44819 100644 --- a/lib/views/components/navbar.dart +++ b/lib/views/components/navbar.dart @@ -132,7 +132,7 @@ class _SearchBarAppState extends ConsumerState { final appId = searchKeywords[result.choice]!; final app = ref.read(liveApplicationProvider(appId)); return ListTile( - title: Text(app!.name ?? app.id), + title: Text(app!.getLocalizedName()), leading: SizedBox( width: 45, child: AppIconLoader(icon: app.icon)), contentPadding: const EdgeInsets.symmetric( diff --git a/lib/views/components/screenshots.dart b/lib/views/components/screenshots.dart index 220bd5e..7f9e0bc 100644 --- a/lib/views/components/screenshots.dart +++ b/lib/views/components/screenshots.dart @@ -1,10 +1,10 @@ import 'package:flutter/material.dart'; -import 'package:outlet/core/application.dart'; +import 'package:outlet/appstream.dart/lib/appstream.dart'; import 'package:outlet/views/screenshots_view.dart'; import 'package:outlet/views/components/theme.dart'; class Screenshots extends StatelessWidget { - final List screenshots; + final List screenshots; const Screenshots({ super.key, @@ -33,7 +33,7 @@ class Screenshots extends StatelessWidget { } class ScreenshotsList extends StatelessWidget { - final List screenshots; + final List screenshots; const ScreenshotsList({ super.key, @@ -58,14 +58,22 @@ class ScreenshotsList extends StatelessWidget { SlideAndFadeAnimationPageRoute( pageBuilder: (context, animation, secondaryAnimation) => ScreenshotsView( - screenshot: screenshots[index].full, + screenshot: screenshots[index] + .images + .firstWhere((image) => + image.type == AppstreamImageType.source) + .url, ), ), ); }, child: Center( child: Image.network( - screenshots[index].thumb, + screenshots[index] + .images + .firstWhere((image) => + image.type == AppstreamImageType.thumbnail) + .url, fit: BoxFit.cover, loadingBuilder: (context, child, loadingProgress) { if (loadingProgress == null) return child; @@ -89,7 +97,7 @@ class ScreenshotsList extends StatelessWidget { } class ScreenshotsGrid extends StatelessWidget { - final List screenshots; + final List screenshots; const ScreenshotsGrid({ super.key, @@ -125,14 +133,22 @@ class ScreenshotsGrid extends StatelessWidget { pageBuilder: (context, animation, secondaryAnimation) => ScreenshotsView( - screenshot: screenshots[index].full, + screenshot: screenshots[index] + .images + .firstWhere((image) => + image.type == AppstreamImageType.source) + .url, ), ), ); }, child: Center( child: Image.network( - screenshots[index].thumb, + screenshots[index] + .images + .firstWhere((image) => + image.type == AppstreamImageType.thumbnail) + .url, fit: BoxFit.cover, loadingBuilder: (context, child, loadingProgress) { if (loadingProgress == null) return child; @@ -157,7 +173,7 @@ class ScreenshotsGrid extends StatelessWidget { } class ScreenshotsExpanded extends StatelessWidget { - final List screenshots; + final List screenshots; const ScreenshotsExpanded({ super.key, @@ -176,7 +192,10 @@ class ScreenshotsExpanded extends StatelessWidget { SlideAndFadeAnimationPageRoute( pageBuilder: (context, animation, secondaryAnimation) => ScreenshotsView( - screenshot: screenshot.full, + screenshot: screenshot.images + .firstWhere( + (image) => image.type == AppstreamImageType.source) + .url, ), ), ); @@ -184,7 +203,10 @@ class ScreenshotsExpanded extends StatelessWidget { child: ClipRRect( borderRadius: BorderRadius.circular(5.0), child: Image.network( - screenshot.full, + screenshot.images + .firstWhere( + (image) => image.type == AppstreamImageType.source) + .url, fit: BoxFit.cover, loadingBuilder: (context, child, loadingProgress) { if (loadingProgress == null) return child; @@ -204,7 +226,7 @@ class ScreenshotsExpanded extends StatelessWidget { ); }).toList(); - final List thumbSize = + final List thumbSize = screenshots.length > 2 ? screenshots.sublist(2) : []; final Color color = fgColor(context); diff --git a/lib/views/loading.dart b/lib/views/loading.dart index d5b98bf..ed291d5 100644 --- a/lib/views/loading.dart +++ b/lib/views/loading.dart @@ -14,6 +14,7 @@ class Loading extends ConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { AsyncValue> remoteApps = ref.watch(remoteAppListProvider); + ref.watch(featuredAppList); return remoteApps.when( loading: () => const Center( diff --git a/lib/views/this_device.dart b/lib/views/this_device.dart index ecf5586..43b3cb4 100644 --- a/lib/views/this_device.dart +++ b/lib/views/this_device.dart @@ -10,8 +10,10 @@ class ThisDevice extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final apps = ref.watch(installedAppListProvider); + final apps = ref.watch(appListProvider); - return AppList(apps: apps.values.toList(), details: false); + return AppList( + apps: apps.values.where((app) => app.installed).toList(), + details: false); } } diff --git a/pubspec.lock b/pubspec.lock index 7a28a31..81dfd81 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -525,6 +525,14 @@ packages: url: "https://pub.dev" source: hosted version: "6.6.1" + yaml: + dependency: "direct main" + description: + name: yaml + sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce + url: "https://pub.dev" + source: hosted + version: "3.1.3" sdks: dart: ">=3.9.0 <4.0.0" flutter: ">=3.35.0" diff --git a/pubspec.yaml b/pubspec.yaml index 1edba0f..724ba41 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -48,6 +48,7 @@ dependencies: logger: ^2.6.2 url_launcher: ^6.3.2 fuzzywuzzy: ^1.2.0 + yaml: ^3.1.3 dev_dependencies: flutter_test: