addon store initial
This commit is contained in:
@@ -27,6 +27,10 @@ part of 'routes.dart';
|
|||||||
name: 'AddonDetailsRoute',
|
name: 'AddonDetailsRoute',
|
||||||
path: 'details/:addonId',
|
path: 'details/:addonId',
|
||||||
),
|
),
|
||||||
|
TypedGoRoute<AddonListingDetailsRoute>(
|
||||||
|
name: 'AddonListingDetailsRoute',
|
||||||
|
path: 'listing/:addonId',
|
||||||
|
),
|
||||||
TypedGoRoute<AddonPermissionsRoute>(
|
TypedGoRoute<AddonPermissionsRoute>(
|
||||||
name: 'AddonPermissionsRoute',
|
name: 'AddonPermissionsRoute',
|
||||||
path: 'permissions/:addonId',
|
path: 'permissions/:addonId',
|
||||||
@@ -57,6 +61,19 @@ class AddonDetailsRoute extends GoRouteData with $AddonDetailsRoute {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class AddonListingDetailsRoute extends GoRouteData
|
||||||
|
with $AddonListingDetailsRoute {
|
||||||
|
final String addonId;
|
||||||
|
final AddonListing $extra;
|
||||||
|
|
||||||
|
const AddonListingDetailsRoute({required this.addonId, required this.$extra});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, GoRouterState state) {
|
||||||
|
return AddonListingDetailsScreen(listing: $extra);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
class AddonPermissionsRoute extends GoRouteData with $AddonPermissionsRoute {
|
class AddonPermissionsRoute extends GoRouteData with $AddonPermissionsRoute {
|
||||||
final String addonId;
|
final String addonId;
|
||||||
|
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ import 'package:weblibre/domain/entities/profile.dart';
|
|||||||
import 'package:weblibre/features/about/presentation/screens/about.dart';
|
import 'package:weblibre/features/about/presentation/screens/about.dart';
|
||||||
import 'package:weblibre/features/addons/presentation/screens/addon_details.dart';
|
import 'package:weblibre/features/addons/presentation/screens/addon_details.dart';
|
||||||
import 'package:weblibre/features/addons/presentation/screens/addon_internal_settings.dart';
|
import 'package:weblibre/features/addons/presentation/screens/addon_internal_settings.dart';
|
||||||
|
import 'package:weblibre/features/addons/presentation/screens/addon_listing_details.dart';
|
||||||
import 'package:weblibre/features/addons/presentation/screens/addon_manager.dart';
|
import 'package:weblibre/features/addons/presentation/screens/addon_manager.dart';
|
||||||
import 'package:weblibre/features/addons/presentation/screens/addon_permissions.dart';
|
import 'package:weblibre/features/addons/presentation/screens/addon_permissions.dart';
|
||||||
import 'package:weblibre/features/bangs/data/models/bang.dart';
|
import 'package:weblibre/features/bangs/data/models/bang.dart';
|
||||||
|
|||||||
@@ -942,6 +942,11 @@ RouteBase get $addonManagerRoute => GoRouteData.$route(
|
|||||||
name: 'AddonDetailsRoute',
|
name: 'AddonDetailsRoute',
|
||||||
factory: $AddonDetailsRoute._fromState,
|
factory: $AddonDetailsRoute._fromState,
|
||||||
),
|
),
|
||||||
|
GoRouteData.$route(
|
||||||
|
path: 'listing/:addonId',
|
||||||
|
name: 'AddonListingDetailsRoute',
|
||||||
|
factory: $AddonListingDetailsRoute._fromState,
|
||||||
|
),
|
||||||
GoRouteData.$route(
|
GoRouteData.$route(
|
||||||
path: 'permissions/:addonId',
|
path: 'permissions/:addonId',
|
||||||
name: 'AddonPermissionsRoute',
|
name: 'AddonPermissionsRoute',
|
||||||
@@ -1001,6 +1006,36 @@ mixin $AddonDetailsRoute on GoRouteData {
|
|||||||
void replace(BuildContext context) => context.replace(location);
|
void replace(BuildContext context) => context.replace(location);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
mixin $AddonListingDetailsRoute on GoRouteData {
|
||||||
|
static AddonListingDetailsRoute _fromState(GoRouterState state) =>
|
||||||
|
AddonListingDetailsRoute(
|
||||||
|
addonId: state.pathParameters['addonId']!,
|
||||||
|
$extra: state.extra as AddonListing,
|
||||||
|
);
|
||||||
|
|
||||||
|
AddonListingDetailsRoute get _self => this as AddonListingDetailsRoute;
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get location => GoRouteData.$location(
|
||||||
|
'/addons/listing/${Uri.encodeComponent(_self.addonId)}',
|
||||||
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
void go(BuildContext context) => context.go(location, extra: _self.$extra);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<T?> push<T>(BuildContext context) =>
|
||||||
|
context.push<T>(location, extra: _self.$extra);
|
||||||
|
|
||||||
|
@override
|
||||||
|
void pushReplacement(BuildContext context) =>
|
||||||
|
context.pushReplacement(location, extra: _self.$extra);
|
||||||
|
|
||||||
|
@override
|
||||||
|
void replace(BuildContext context) =>
|
||||||
|
context.replace(location, extra: _self.$extra);
|
||||||
|
}
|
||||||
|
|
||||||
mixin $AddonPermissionsRoute on GoRouteData {
|
mixin $AddonPermissionsRoute on GoRouteData {
|
||||||
static AddonPermissionsRoute _fromState(GoRouterState state) =>
|
static AddonPermissionsRoute _fromState(GoRouterState state) =>
|
||||||
AddonPermissionsRoute(addonId: state.pathParameters['addonId']!);
|
AddonPermissionsRoute(addonId: state.pathParameters['addonId']!);
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import 'package:flutter_mozilla_components/flutter_mozilla_components.dart';
|
|||||||
import 'package:riverpod/experimental/persist.dart';
|
import 'package:riverpod/experimental/persist.dart';
|
||||||
import 'package:riverpod_annotation/experimental/persist.dart';
|
import 'package:riverpod_annotation/experimental/persist.dart';
|
||||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||||
|
import 'package:weblibre/features/addons/utils/addon_html.dart';
|
||||||
import 'package:weblibre/features/geckoview/domain/providers.dart';
|
import 'package:weblibre/features/geckoview/domain/providers.dart';
|
||||||
import 'package:weblibre/features/user/data/providers.dart';
|
import 'package:weblibre/features/user/data/providers.dart';
|
||||||
|
|
||||||
@@ -137,6 +138,50 @@ Future<AddonStoreInfo?> addonStoreInfo(Ref ref, String addonId) {
|
|||||||
return ref.read(addonServiceProvider).getAddonStoreInfo(addonId);
|
return ref.read(addonServiceProvider).getAddonStoreInfo(addonId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Riverpod()
|
||||||
|
Future<List<AddonListing>> featuredAddonListings(Ref ref, AddonStoreApp app) {
|
||||||
|
return ref.read(addonServiceProvider).getFeaturedAddonListings(app: app);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Riverpod()
|
||||||
|
Future<List<AddonListing>> searchAddonListings(
|
||||||
|
Ref ref,
|
||||||
|
String query,
|
||||||
|
AddonStoreApp app,
|
||||||
|
) async {
|
||||||
|
final trimmed = query.trim();
|
||||||
|
if (trimmed.isEmpty) {
|
||||||
|
return ref.watch(featuredAddonListingsProvider(app).future);
|
||||||
|
}
|
||||||
|
return ref.read(addonServiceProvider).searchAddonListings(
|
||||||
|
query: trimmed,
|
||||||
|
app: app,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Riverpod(keepAlive: true)
|
||||||
|
class AddonStoreAppFilter extends _$AddonStoreAppFilter {
|
||||||
|
void setApp(AddonStoreApp app) => state = app;
|
||||||
|
|
||||||
|
@override
|
||||||
|
AddonStoreApp build() => AddonStoreApp.android;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Riverpod()
|
||||||
|
Future<String> addonDescriptionMarkdown(Ref ref, String addonId) async {
|
||||||
|
final description = await ref.watch(
|
||||||
|
addonDetailsProvider(
|
||||||
|
addonId,
|
||||||
|
).selectAsync((addon) => addon?.description ?? ''),
|
||||||
|
);
|
||||||
|
return turndownAddonHtml(description);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Riverpod()
|
||||||
|
Future<String> addonHtmlMarkdown(Ref ref, String html) {
|
||||||
|
return turndownAddonHtml(html);
|
||||||
|
}
|
||||||
|
|
||||||
@Riverpod()
|
@Riverpod()
|
||||||
Future<AddonUpdateAttemptInfo?> lastAddonUpdateAttempt(
|
Future<AddonUpdateAttemptInfo?> lastAddonUpdateAttempt(
|
||||||
Ref ref,
|
Ref ref,
|
||||||
|
|||||||
@@ -173,6 +173,360 @@ final class AddonStoreInfoFamily extends $Family
|
|||||||
String toString() => r'addonStoreInfoProvider';
|
String toString() => r'addonStoreInfoProvider';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ProviderFor(featuredAddonListings)
|
||||||
|
final featuredAddonListingsProvider = FeaturedAddonListingsFamily._();
|
||||||
|
|
||||||
|
final class FeaturedAddonListingsProvider
|
||||||
|
extends
|
||||||
|
$FunctionalProvider<
|
||||||
|
AsyncValue<List<AddonListing>>,
|
||||||
|
List<AddonListing>,
|
||||||
|
FutureOr<List<AddonListing>>
|
||||||
|
>
|
||||||
|
with
|
||||||
|
$FutureModifier<List<AddonListing>>,
|
||||||
|
$FutureProvider<List<AddonListing>> {
|
||||||
|
FeaturedAddonListingsProvider._({
|
||||||
|
required FeaturedAddonListingsFamily super.from,
|
||||||
|
required AddonStoreApp super.argument,
|
||||||
|
}) : super(
|
||||||
|
retry: null,
|
||||||
|
name: r'featuredAddonListingsProvider',
|
||||||
|
isAutoDispose: true,
|
||||||
|
dependencies: null,
|
||||||
|
$allTransitiveDependencies: null,
|
||||||
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String debugGetCreateSourceHash() => _$featuredAddonListingsHash();
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() {
|
||||||
|
return r'featuredAddonListingsProvider'
|
||||||
|
''
|
||||||
|
'($argument)';
|
||||||
|
}
|
||||||
|
|
||||||
|
@$internal
|
||||||
|
@override
|
||||||
|
$FutureProviderElement<List<AddonListing>> $createElement(
|
||||||
|
$ProviderPointer pointer,
|
||||||
|
) => $FutureProviderElement(pointer);
|
||||||
|
|
||||||
|
@override
|
||||||
|
FutureOr<List<AddonListing>> create(Ref ref) {
|
||||||
|
final argument = this.argument as AddonStoreApp;
|
||||||
|
return featuredAddonListings(ref, argument);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) {
|
||||||
|
return other is FeaturedAddonListingsProvider && other.argument == argument;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get hashCode {
|
||||||
|
return argument.hashCode;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String _$featuredAddonListingsHash() =>
|
||||||
|
r'94f2eb452297611d5b91ac827cb73045cc553826';
|
||||||
|
|
||||||
|
final class FeaturedAddonListingsFamily extends $Family
|
||||||
|
with
|
||||||
|
$FunctionalFamilyOverride<FutureOr<List<AddonListing>>, AddonStoreApp> {
|
||||||
|
FeaturedAddonListingsFamily._()
|
||||||
|
: super(
|
||||||
|
retry: null,
|
||||||
|
name: r'featuredAddonListingsProvider',
|
||||||
|
dependencies: null,
|
||||||
|
$allTransitiveDependencies: null,
|
||||||
|
isAutoDispose: true,
|
||||||
|
);
|
||||||
|
|
||||||
|
FeaturedAddonListingsProvider call(AddonStoreApp app) =>
|
||||||
|
FeaturedAddonListingsProvider._(argument: app, from: this);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() => r'featuredAddonListingsProvider';
|
||||||
|
}
|
||||||
|
|
||||||
|
@ProviderFor(searchAddonListings)
|
||||||
|
final searchAddonListingsProvider = SearchAddonListingsFamily._();
|
||||||
|
|
||||||
|
final class SearchAddonListingsProvider
|
||||||
|
extends
|
||||||
|
$FunctionalProvider<
|
||||||
|
AsyncValue<List<AddonListing>>,
|
||||||
|
List<AddonListing>,
|
||||||
|
FutureOr<List<AddonListing>>
|
||||||
|
>
|
||||||
|
with
|
||||||
|
$FutureModifier<List<AddonListing>>,
|
||||||
|
$FutureProvider<List<AddonListing>> {
|
||||||
|
SearchAddonListingsProvider._({
|
||||||
|
required SearchAddonListingsFamily super.from,
|
||||||
|
required (String, AddonStoreApp) super.argument,
|
||||||
|
}) : super(
|
||||||
|
retry: null,
|
||||||
|
name: r'searchAddonListingsProvider',
|
||||||
|
isAutoDispose: true,
|
||||||
|
dependencies: null,
|
||||||
|
$allTransitiveDependencies: null,
|
||||||
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String debugGetCreateSourceHash() => _$searchAddonListingsHash();
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() {
|
||||||
|
return r'searchAddonListingsProvider'
|
||||||
|
''
|
||||||
|
'$argument';
|
||||||
|
}
|
||||||
|
|
||||||
|
@$internal
|
||||||
|
@override
|
||||||
|
$FutureProviderElement<List<AddonListing>> $createElement(
|
||||||
|
$ProviderPointer pointer,
|
||||||
|
) => $FutureProviderElement(pointer);
|
||||||
|
|
||||||
|
@override
|
||||||
|
FutureOr<List<AddonListing>> create(Ref ref) {
|
||||||
|
final argument = this.argument as (String, AddonStoreApp);
|
||||||
|
return searchAddonListings(ref, argument.$1, argument.$2);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) {
|
||||||
|
return other is SearchAddonListingsProvider && other.argument == argument;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get hashCode {
|
||||||
|
return argument.hashCode;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String _$searchAddonListingsHash() =>
|
||||||
|
r'e20c27fb597093b92f8f4d402f196cc707a3e928';
|
||||||
|
|
||||||
|
final class SearchAddonListingsFamily extends $Family
|
||||||
|
with
|
||||||
|
$FunctionalFamilyOverride<
|
||||||
|
FutureOr<List<AddonListing>>,
|
||||||
|
(String, AddonStoreApp)
|
||||||
|
> {
|
||||||
|
SearchAddonListingsFamily._()
|
||||||
|
: super(
|
||||||
|
retry: null,
|
||||||
|
name: r'searchAddonListingsProvider',
|
||||||
|
dependencies: null,
|
||||||
|
$allTransitiveDependencies: null,
|
||||||
|
isAutoDispose: true,
|
||||||
|
);
|
||||||
|
|
||||||
|
SearchAddonListingsProvider call(String query, AddonStoreApp app) =>
|
||||||
|
SearchAddonListingsProvider._(argument: (query, app), from: this);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() => r'searchAddonListingsProvider';
|
||||||
|
}
|
||||||
|
|
||||||
|
@ProviderFor(AddonStoreAppFilter)
|
||||||
|
final addonStoreAppFilterProvider = AddonStoreAppFilterProvider._();
|
||||||
|
|
||||||
|
final class AddonStoreAppFilterProvider
|
||||||
|
extends $NotifierProvider<AddonStoreAppFilter, AddonStoreApp> {
|
||||||
|
AddonStoreAppFilterProvider._()
|
||||||
|
: super(
|
||||||
|
from: null,
|
||||||
|
argument: null,
|
||||||
|
retry: null,
|
||||||
|
name: r'addonStoreAppFilterProvider',
|
||||||
|
isAutoDispose: false,
|
||||||
|
dependencies: null,
|
||||||
|
$allTransitiveDependencies: null,
|
||||||
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String debugGetCreateSourceHash() => _$addonStoreAppFilterHash();
|
||||||
|
|
||||||
|
@$internal
|
||||||
|
@override
|
||||||
|
AddonStoreAppFilter create() => AddonStoreAppFilter();
|
||||||
|
|
||||||
|
/// {@macro riverpod.override_with_value}
|
||||||
|
Override overrideWithValue(AddonStoreApp value) {
|
||||||
|
return $ProviderOverride(
|
||||||
|
origin: this,
|
||||||
|
providerOverride: $SyncValueProvider<AddonStoreApp>(value),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String _$addonStoreAppFilterHash() =>
|
||||||
|
r'167303f223c14ce2c118aa5b76342cc3433dc8f0';
|
||||||
|
|
||||||
|
abstract class _$AddonStoreAppFilter extends $Notifier<AddonStoreApp> {
|
||||||
|
AddonStoreApp build();
|
||||||
|
@$mustCallSuper
|
||||||
|
@override
|
||||||
|
void runBuild() {
|
||||||
|
final ref = this.ref as $Ref<AddonStoreApp, AddonStoreApp>;
|
||||||
|
final element =
|
||||||
|
ref.element
|
||||||
|
as $ClassProviderElement<
|
||||||
|
AnyNotifier<AddonStoreApp, AddonStoreApp>,
|
||||||
|
AddonStoreApp,
|
||||||
|
Object?,
|
||||||
|
Object?
|
||||||
|
>;
|
||||||
|
element.handleCreate(ref, build);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ProviderFor(addonDescriptionMarkdown)
|
||||||
|
final addonDescriptionMarkdownProvider = AddonDescriptionMarkdownFamily._();
|
||||||
|
|
||||||
|
final class AddonDescriptionMarkdownProvider
|
||||||
|
extends $FunctionalProvider<AsyncValue<String>, String, FutureOr<String>>
|
||||||
|
with $FutureModifier<String>, $FutureProvider<String> {
|
||||||
|
AddonDescriptionMarkdownProvider._({
|
||||||
|
required AddonDescriptionMarkdownFamily super.from,
|
||||||
|
required String super.argument,
|
||||||
|
}) : super(
|
||||||
|
retry: null,
|
||||||
|
name: r'addonDescriptionMarkdownProvider',
|
||||||
|
isAutoDispose: true,
|
||||||
|
dependencies: null,
|
||||||
|
$allTransitiveDependencies: null,
|
||||||
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String debugGetCreateSourceHash() => _$addonDescriptionMarkdownHash();
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() {
|
||||||
|
return r'addonDescriptionMarkdownProvider'
|
||||||
|
''
|
||||||
|
'($argument)';
|
||||||
|
}
|
||||||
|
|
||||||
|
@$internal
|
||||||
|
@override
|
||||||
|
$FutureProviderElement<String> $createElement($ProviderPointer pointer) =>
|
||||||
|
$FutureProviderElement(pointer);
|
||||||
|
|
||||||
|
@override
|
||||||
|
FutureOr<String> create(Ref ref) {
|
||||||
|
final argument = this.argument as String;
|
||||||
|
return addonDescriptionMarkdown(ref, argument);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) {
|
||||||
|
return other is AddonDescriptionMarkdownProvider &&
|
||||||
|
other.argument == argument;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get hashCode {
|
||||||
|
return argument.hashCode;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String _$addonDescriptionMarkdownHash() =>
|
||||||
|
r'e60659ccef172237e44b9f77b79df4fe11997617';
|
||||||
|
|
||||||
|
final class AddonDescriptionMarkdownFamily extends $Family
|
||||||
|
with $FunctionalFamilyOverride<FutureOr<String>, String> {
|
||||||
|
AddonDescriptionMarkdownFamily._()
|
||||||
|
: super(
|
||||||
|
retry: null,
|
||||||
|
name: r'addonDescriptionMarkdownProvider',
|
||||||
|
dependencies: null,
|
||||||
|
$allTransitiveDependencies: null,
|
||||||
|
isAutoDispose: true,
|
||||||
|
);
|
||||||
|
|
||||||
|
AddonDescriptionMarkdownProvider call(String addonId) =>
|
||||||
|
AddonDescriptionMarkdownProvider._(argument: addonId, from: this);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() => r'addonDescriptionMarkdownProvider';
|
||||||
|
}
|
||||||
|
|
||||||
|
@ProviderFor(addonHtmlMarkdown)
|
||||||
|
final addonHtmlMarkdownProvider = AddonHtmlMarkdownFamily._();
|
||||||
|
|
||||||
|
final class AddonHtmlMarkdownProvider
|
||||||
|
extends $FunctionalProvider<AsyncValue<String>, String, FutureOr<String>>
|
||||||
|
with $FutureModifier<String>, $FutureProvider<String> {
|
||||||
|
AddonHtmlMarkdownProvider._({
|
||||||
|
required AddonHtmlMarkdownFamily super.from,
|
||||||
|
required String super.argument,
|
||||||
|
}) : super(
|
||||||
|
retry: null,
|
||||||
|
name: r'addonHtmlMarkdownProvider',
|
||||||
|
isAutoDispose: true,
|
||||||
|
dependencies: null,
|
||||||
|
$allTransitiveDependencies: null,
|
||||||
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String debugGetCreateSourceHash() => _$addonHtmlMarkdownHash();
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() {
|
||||||
|
return r'addonHtmlMarkdownProvider'
|
||||||
|
''
|
||||||
|
'($argument)';
|
||||||
|
}
|
||||||
|
|
||||||
|
@$internal
|
||||||
|
@override
|
||||||
|
$FutureProviderElement<String> $createElement($ProviderPointer pointer) =>
|
||||||
|
$FutureProviderElement(pointer);
|
||||||
|
|
||||||
|
@override
|
||||||
|
FutureOr<String> create(Ref ref) {
|
||||||
|
final argument = this.argument as String;
|
||||||
|
return addonHtmlMarkdown(ref, argument);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) {
|
||||||
|
return other is AddonHtmlMarkdownProvider && other.argument == argument;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get hashCode {
|
||||||
|
return argument.hashCode;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String _$addonHtmlMarkdownHash() => r'51d6c7d7cf6040acc9540893dacad581606ff4a8';
|
||||||
|
|
||||||
|
final class AddonHtmlMarkdownFamily extends $Family
|
||||||
|
with $FunctionalFamilyOverride<FutureOr<String>, String> {
|
||||||
|
AddonHtmlMarkdownFamily._()
|
||||||
|
: super(
|
||||||
|
retry: null,
|
||||||
|
name: r'addonHtmlMarkdownProvider',
|
||||||
|
dependencies: null,
|
||||||
|
$allTransitiveDependencies: null,
|
||||||
|
isAutoDispose: true,
|
||||||
|
);
|
||||||
|
|
||||||
|
AddonHtmlMarkdownProvider call(String html) =>
|
||||||
|
AddonHtmlMarkdownProvider._(argument: html, from: this);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() => r'addonHtmlMarkdownProvider';
|
||||||
|
}
|
||||||
|
|
||||||
@ProviderFor(lastAddonUpdateAttempt)
|
@ProviderFor(lastAddonUpdateAttempt)
|
||||||
final lastAddonUpdateAttemptProvider = LastAddonUpdateAttemptFamily._();
|
final lastAddonUpdateAttemptProvider = LastAddonUpdateAttemptFamily._();
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,218 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2024-2026 Fabian Freund.
|
||||||
|
*
|
||||||
|
* This file is part of WebLibre
|
||||||
|
* (see https://weblibre.eu).
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU Affero General Public License as
|
||||||
|
* published by the Free Software Foundation, either version 3 of the
|
||||||
|
* License, or (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU Affero General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU Affero General Public License
|
||||||
|
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:fading_scroll/fading_scroll.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||||
|
import 'package:flutter_mozilla_components/flutter_mozilla_components.dart';
|
||||||
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
import 'package:weblibre/core/routing/routes.dart';
|
||||||
|
import 'package:weblibre/features/addons/domain/providers.dart';
|
||||||
|
import 'package:weblibre/features/addons/presentation/widgets/addon_listing_card.dart';
|
||||||
|
|
||||||
|
class AddonBrowseView extends HookConsumerWidget {
|
||||||
|
const AddonBrowseView({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final app = ref.watch(addonStoreAppFilterProvider);
|
||||||
|
|
||||||
|
final searchController = useTextEditingController();
|
||||||
|
final query = useState<String>('');
|
||||||
|
final debounceTimer = useRef<Timer?>(null);
|
||||||
|
|
||||||
|
useEffect(
|
||||||
|
() =>
|
||||||
|
() => debounceTimer.value?.cancel(),
|
||||||
|
const [],
|
||||||
|
);
|
||||||
|
|
||||||
|
final listingsAsync = ref.watch(
|
||||||
|
searchAddonListingsProvider(query.value, app),
|
||||||
|
);
|
||||||
|
|
||||||
|
final installed = ref.watch(
|
||||||
|
addonListProvider.select(
|
||||||
|
(value) =>
|
||||||
|
value.value?.where((a) => a.isInstalled).map((a) => a.id).toSet() ??
|
||||||
|
const <String>{},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
children: [
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 8, 16, 0),
|
||||||
|
child: SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
child: SegmentedButton<AddonStoreApp>(
|
||||||
|
segments: const [
|
||||||
|
ButtonSegment(
|
||||||
|
value: AddonStoreApp.android,
|
||||||
|
icon: Icon(Icons.phone_android),
|
||||||
|
label: Text('Android'),
|
||||||
|
),
|
||||||
|
ButtonSegment(
|
||||||
|
value: AddonStoreApp.firefox,
|
||||||
|
icon: Icon(Icons.desktop_windows),
|
||||||
|
label: Text('Desktop'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
selected: {app},
|
||||||
|
onSelectionChanged: (selection) => ref
|
||||||
|
.read(addonStoreAppFilterProvider.notifier)
|
||||||
|
.setApp(selection.first),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: TextField(
|
||||||
|
controller: searchController,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
hintText: 'Search addons.mozilla.org',
|
||||||
|
prefixIcon: const Icon(Icons.search),
|
||||||
|
suffixIcon: query.value.isEmpty
|
||||||
|
? null
|
||||||
|
: IconButton(
|
||||||
|
icon: const Icon(Icons.clear),
|
||||||
|
onPressed: () {
|
||||||
|
searchController.clear();
|
||||||
|
query.value = '';
|
||||||
|
},
|
||||||
|
),
|
||||||
|
border: const OutlineInputBorder(),
|
||||||
|
isDense: true,
|
||||||
|
),
|
||||||
|
onChanged: (text) {
|
||||||
|
debounceTimer.value?.cancel();
|
||||||
|
debounceTimer.value = Timer(
|
||||||
|
const Duration(milliseconds: 400),
|
||||||
|
() => query.value = text,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (app == AddonStoreApp.firefox)
|
||||||
|
const Padding(
|
||||||
|
padding: EdgeInsets.fromLTRB(16, 0, 16, 12),
|
||||||
|
child: _DesktopCompatibilityWarning(),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: listingsAsync.when(
|
||||||
|
skipLoadingOnReload: true,
|
||||||
|
data: (listings) =>
|
||||||
|
_ListingList(listings: listings, installedIds: installed),
|
||||||
|
loading: () => const Center(child: CircularProgressIndicator()),
|
||||||
|
error: (error, _) => Center(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(24),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.error_outline, size: 40),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
const Text('Failed to load extensions'),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(error.toString(), textAlign: TextAlign.center),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _DesktopCompatibilityWarning extends StatelessWidget {
|
||||||
|
const _DesktopCompatibilityWarning();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: theme.colorScheme.tertiaryContainer.withValues(alpha: 0.5),
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
border: Border.all(color: theme.colorScheme.tertiary),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Icon(Icons.info_outline, color: theme.colorScheme.tertiary, size: 20),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
'Desktop extensions are not reviewed for mobile. Some may not '
|
||||||
|
'work, may crash, or may behave unexpectedly on Android.',
|
||||||
|
style: TextStyle(
|
||||||
|
color: theme.colorScheme.onTertiaryContainer,
|
||||||
|
fontSize: 12,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ListingList extends StatelessWidget {
|
||||||
|
final List<AddonListing> listings;
|
||||||
|
final Set<String> installedIds;
|
||||||
|
|
||||||
|
const _ListingList({required this.listings, required this.installedIds});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
if (listings.isEmpty) {
|
||||||
|
return const Center(child: Text('No extensions found.'));
|
||||||
|
}
|
||||||
|
|
||||||
|
return FadingScroll(
|
||||||
|
fadingSize: 25,
|
||||||
|
builder: (context, controller) {
|
||||||
|
return ListView.builder(
|
||||||
|
controller: controller,
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
|
itemCount: listings.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final listing = listings[index];
|
||||||
|
return AddonListingCard(
|
||||||
|
listing: listing,
|
||||||
|
isInstalled: installedIds.contains(listing.id),
|
||||||
|
onTap: () async {
|
||||||
|
await AddonListingDetailsRoute(
|
||||||
|
addonId: listing.id,
|
||||||
|
$extra: listing,
|
||||||
|
).push<void>(context);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,6 +18,7 @@
|
|||||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_markdown/flutter_markdown.dart';
|
||||||
import 'package:flutter_mozilla_components/flutter_mozilla_components.dart';
|
import 'package:flutter_mozilla_components/flutter_mozilla_components.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:url_launcher/url_launcher.dart';
|
import 'package:url_launcher/url_launcher.dart';
|
||||||
@@ -27,6 +28,7 @@ import 'package:weblibre/features/addons/extensions/addon_info.dart';
|
|||||||
import 'package:weblibre/features/addons/presentation/screens/addon_internal_settings.dart';
|
import 'package:weblibre/features/addons/presentation/screens/addon_internal_settings.dart';
|
||||||
import 'package:weblibre/features/addons/presentation/widgets/addon_ui.dart';
|
import 'package:weblibre/features/addons/presentation/widgets/addon_ui.dart';
|
||||||
import 'package:weblibre/features/geckoview/features/browser/domain/services/browser_addon.dart';
|
import 'package:weblibre/features/geckoview/features/browser/domain/services/browser_addon.dart';
|
||||||
|
import 'package:weblibre/utils/number_format.dart';
|
||||||
import 'package:weblibre/utils/ui_helper.dart';
|
import 'package:weblibre/utils/ui_helper.dart';
|
||||||
|
|
||||||
class AddonDetailsScreen extends ConsumerWidget {
|
class AddonDetailsScreen extends ConsumerWidget {
|
||||||
@@ -163,6 +165,7 @@ class _AddonHeader extends StatelessWidget {
|
|||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
|
|
||||||
return Card(
|
return Card(
|
||||||
|
color: Theme.of(context).colorScheme.surfaceContainerHigh,
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
child: Column(
|
child: Column(
|
||||||
@@ -188,7 +191,6 @@ class _AddonHeader extends StatelessWidget {
|
|||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
Wrap(
|
Wrap(
|
||||||
spacing: 8,
|
spacing: 8,
|
||||||
runSpacing: 8,
|
|
||||||
children: [
|
children: [
|
||||||
Chip(
|
Chip(
|
||||||
label: Text(
|
label: Text(
|
||||||
@@ -204,7 +206,7 @@ class _AddonHeader extends StatelessWidget {
|
|||||||
avatar: const Icon(Icons.star, size: 18),
|
avatar: const Icon(Icons.star, size: 18),
|
||||||
label: Text(
|
label: Text(
|
||||||
'${addon.ratingAverage!.toStringAsFixed(1)}'
|
'${addon.ratingAverage!.toStringAsFixed(1)}'
|
||||||
' (${addon.ratingReviews ?? 0})',
|
' (${formatCompactNumber(addon.ratingReviews ?? 0)})',
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -272,6 +274,7 @@ class _ManagementSection extends ConsumerWidget {
|
|||||||
Text('Management', style: theme.textTheme.titleMedium),
|
Text('Management', style: theme.textTheme.titleMedium),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Card(
|
Card(
|
||||||
|
color: Theme.of(context).colorScheme.surfaceContainerHigh,
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
if (addon.isSupported)
|
if (addon.isSupported)
|
||||||
@@ -423,7 +426,7 @@ class _UpdatesSection extends ConsumerWidget {
|
|||||||
addon.installedVersion != availableVersion;
|
addon.installedVersion != availableVersion;
|
||||||
|
|
||||||
return Column(
|
return Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
Text('Updates', style: theme.textTheme.titleMedium),
|
Text('Updates', style: theme.textTheme.titleMedium),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
@@ -566,20 +569,42 @@ void _reportUpdateResult(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _DescriptionCard extends StatelessWidget {
|
class _DescriptionCard extends ConsumerWidget {
|
||||||
final AddonInfo addon;
|
final AddonInfo addon;
|
||||||
|
|
||||||
const _DescriptionCard({required this.addon});
|
const _DescriptionCard({required this.addon});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final description = addon.description;
|
final markdownAsync = ref.watch(addonDescriptionMarkdownProvider(addon.id));
|
||||||
|
|
||||||
return Card(
|
return Card(
|
||||||
|
color: Theme.of(context).colorScheme.surfaceContainerHigh,
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
child: Text(
|
child: markdownAsync.when(
|
||||||
description.isNotEmpty ? description : 'No description provided.',
|
skipLoadingOnReload: true,
|
||||||
|
data: (markdown) => markdown.isEmpty
|
||||||
|
? const Text('No description provided.')
|
||||||
|
: MarkdownBody(
|
||||||
|
data: markdown,
|
||||||
|
selectable: true,
|
||||||
|
onTapLink: (text, href, title) {
|
||||||
|
if (href != null && href.isNotEmpty) {
|
||||||
|
launchUrl(Uri.parse(href));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
loading: () => Text(
|
||||||
|
addon.description.isNotEmpty
|
||||||
|
? addon.description
|
||||||
|
: 'Loading description…',
|
||||||
|
),
|
||||||
|
error: (_, _) => Text(
|
||||||
|
addon.description.isNotEmpty
|
||||||
|
? addon.description
|
||||||
|
: 'No description provided.',
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -594,6 +619,7 @@ class _DetailsCard extends StatelessWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Card(
|
return Card(
|
||||||
|
color: Theme.of(context).colorScheme.surfaceContainerHigh,
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
if ((addon.authorName ?? '').isNotEmpty)
|
if ((addon.authorName ?? '').isNotEmpty)
|
||||||
|
|||||||
@@ -0,0 +1,648 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2024-2026 Fabian Freund.
|
||||||
|
*
|
||||||
|
* This file is part of WebLibre
|
||||||
|
* (see https://weblibre.eu).
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU Affero General Public License as
|
||||||
|
* published by the Free Software Foundation, either version 3 of the
|
||||||
|
* License, or (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU Affero General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU Affero General Public License
|
||||||
|
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
import 'package:fading_scroll/fading_scroll.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||||
|
import 'package:flutter_markdown/flutter_markdown.dart';
|
||||||
|
import 'package:flutter_mozilla_components/flutter_mozilla_components.dart';
|
||||||
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
import 'package:url_launcher/url_launcher.dart';
|
||||||
|
import 'package:weblibre/features/addons/domain/providers.dart';
|
||||||
|
import 'package:weblibre/features/addons/presentation/widgets/addon_listing_card.dart';
|
||||||
|
import 'package:weblibre/features/addons/utils/permissions.dart';
|
||||||
|
import 'package:weblibre/features/geckoview/domain/providers.dart';
|
||||||
|
import 'package:weblibre/utils/number_format.dart';
|
||||||
|
import 'package:weblibre/utils/ui_helper.dart';
|
||||||
|
|
||||||
|
class AddonListingDetailsScreen extends ConsumerWidget {
|
||||||
|
final AddonListing listing;
|
||||||
|
|
||||||
|
const AddonListingDetailsScreen({required this.listing, super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
|
||||||
|
final installedAsync = ref.watch(addonListProvider);
|
||||||
|
|
||||||
|
final isInstalled = installedAsync.maybeWhen(
|
||||||
|
data: (addons) => addons.any((a) => a.id == listing.id && a.isInstalled),
|
||||||
|
orElse: () => false,
|
||||||
|
);
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(title: Text(listing.name)),
|
||||||
|
body: SafeArea(
|
||||||
|
child: FadingScroll(
|
||||||
|
fadingSize: 25,
|
||||||
|
builder: (context, controller) {
|
||||||
|
return ListView(
|
||||||
|
controller: controller,
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
AddonListingIcon(iconUrl: listing.iconUrl, size: 64),
|
||||||
|
const SizedBox(width: 16),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(listing.name, style: theme.textTheme.titleLarge),
|
||||||
|
if (listing.authorName != null) ...[
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
_AuthorLink(
|
||||||
|
name: listing.authorName!,
|
||||||
|
url: listing.authorUrl,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
'Version ${listing.latestVersion}',
|
||||||
|
style: theme.textTheme.bodySmall,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
_InstallButton(listing: listing, isInstalled: isInstalled),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Wrap(
|
||||||
|
spacing: 8,
|
||||||
|
children: [
|
||||||
|
if (listing.promoted == AddonStorePromoted.recommended)
|
||||||
|
const Chip(
|
||||||
|
avatar: Icon(Icons.verified, size: 16),
|
||||||
|
label: Text('Recommended'),
|
||||||
|
),
|
||||||
|
if (listing.ratingAverage != null)
|
||||||
|
Chip(
|
||||||
|
avatar: const Icon(Icons.star, size: 16),
|
||||||
|
label: Text(
|
||||||
|
'${listing.ratingAverage!.toStringAsFixed(1)}'
|
||||||
|
'${listing.ratingReviews != null ? ' (${formatCompactNumber(listing.ratingReviews!)})' : ''}',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (listing.averageDailyUsers != null)
|
||||||
|
Chip(
|
||||||
|
avatar: const Icon(Icons.group_outlined, size: 16),
|
||||||
|
label: Text(
|
||||||
|
'${formatCompactNumber(listing.averageDailyUsers!)} users',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
if (listing.previews.isNotEmpty) ...[
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
_ScreenshotsSection(previews: listing.previews),
|
||||||
|
],
|
||||||
|
if ((listing.summary ?? '').isNotEmpty) ...[
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text(listing.summary!, style: theme.textTheme.bodyLarge),
|
||||||
|
],
|
||||||
|
if ((listing.description ?? '').isNotEmpty) ...[
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
const _SectionHeader(title: 'About this extension'),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
_ExpandableDescription(html: listing.description!),
|
||||||
|
],
|
||||||
|
if (_hasFriendlyPermissions(listing)) ...[
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
const _SectionHeader(title: 'Permissions'),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
_PermissionsSection(listing: listing),
|
||||||
|
],
|
||||||
|
if (_hasTechnicalPermissions(listing)) ...[
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
const _SectionHeader(title: 'Technical permissions'),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
_TechnicalPermissionsSection(listing: listing),
|
||||||
|
],
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
const _SectionHeader(title: 'More information'),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
_MoreInformationSection(listing: listing),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _SectionHeader extends StatelessWidget {
|
||||||
|
final String title;
|
||||||
|
const _SectionHeader({required this.title});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Text(title, style: Theme.of(context).textTheme.titleMedium);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AuthorLink extends StatelessWidget {
|
||||||
|
final String name;
|
||||||
|
final String? url;
|
||||||
|
|
||||||
|
const _AuthorLink({required this.name, required this.url});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final style = Theme.of(context).textTheme.bodyMedium;
|
||||||
|
if (url == null) return Text('by $name', style: style);
|
||||||
|
return InkWell(
|
||||||
|
onTap: () => launchUrl(Uri.parse(url!)),
|
||||||
|
child: Text(
|
||||||
|
'by $name',
|
||||||
|
style: style?.copyWith(
|
||||||
|
color: Theme.of(context).colorScheme.primary,
|
||||||
|
decoration: TextDecoration.underline,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _InstallButton extends ConsumerWidget {
|
||||||
|
final AddonListing listing;
|
||||||
|
final bool isInstalled;
|
||||||
|
|
||||||
|
const _InstallButton({required this.listing, required this.isInstalled});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final busy = ref.watch(addonBusyIdsProvider).contains(listing.id);
|
||||||
|
|
||||||
|
if (isInstalled) {
|
||||||
|
return FilledButton.icon(
|
||||||
|
onPressed: null,
|
||||||
|
icon: const Icon(Icons.check),
|
||||||
|
label: const Text('Installed'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return FilledButton.icon(
|
||||||
|
onPressed: busy
|
||||||
|
? null
|
||||||
|
: () async {
|
||||||
|
ref.read(addonBusyIdsProvider.notifier).add(listing.id);
|
||||||
|
try {
|
||||||
|
await ref
|
||||||
|
.read(addonServiceProvider)
|
||||||
|
.installAddon(Uri.parse(listing.downloadUrl));
|
||||||
|
ref.invalidate(addonListProvider);
|
||||||
|
ref.invalidate(addonDetailsProvider(listing.id));
|
||||||
|
if (!context.mounted) return;
|
||||||
|
showInfoMessage(context, '${listing.name} installed');
|
||||||
|
} catch (error) {
|
||||||
|
if (!context.mounted) return;
|
||||||
|
showInfoMessage(context, 'Install failed: $error');
|
||||||
|
} finally {
|
||||||
|
ref.read(addonBusyIdsProvider.notifier).remove(listing.id);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
icon: busy
|
||||||
|
? const SizedBox(
|
||||||
|
width: 16,
|
||||||
|
height: 16,
|
||||||
|
child: CircularProgressIndicator(strokeWidth: 2),
|
||||||
|
)
|
||||||
|
: const Icon(Icons.download_outlined),
|
||||||
|
label: const Text('Install'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ScreenshotsSection extends StatelessWidget {
|
||||||
|
final List<AddonListingPreview> previews;
|
||||||
|
const _ScreenshotsSection({required this.previews});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return SizedBox(
|
||||||
|
height: 200,
|
||||||
|
child: ListView.separated(
|
||||||
|
scrollDirection: Axis.horizontal,
|
||||||
|
itemCount: previews.length,
|
||||||
|
separatorBuilder: (_, _) => const SizedBox(width: 12),
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final p = previews[index];
|
||||||
|
return GestureDetector(
|
||||||
|
onTap: () => _showFullScreenImage(context, p.imageUrl),
|
||||||
|
child: ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
child: Image.network(
|
||||||
|
p.thumbnailUrl ?? p.imageUrl,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
errorBuilder: (_, _, _) => Container(
|
||||||
|
width: 300,
|
||||||
|
color: Theme.of(context).colorScheme.surfaceContainerHighest,
|
||||||
|
child: const Icon(Icons.broken_image_outlined),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showFullScreenImage(BuildContext context, String url) {
|
||||||
|
Navigator.of(context).push(
|
||||||
|
PageRouteBuilder<void>(
|
||||||
|
opaque: false,
|
||||||
|
barrierColor: Colors.black87,
|
||||||
|
pageBuilder: (_, _, _) => _FullScreenImage(url: url),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _FullScreenImage extends StatelessWidget {
|
||||||
|
final String url;
|
||||||
|
const _FullScreenImage({required this.url});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
backgroundColor: Colors.transparent,
|
||||||
|
body: GestureDetector(
|
||||||
|
onTap: () => Navigator.of(context).pop(),
|
||||||
|
child: Center(
|
||||||
|
child: InteractiveViewer(
|
||||||
|
child: Image.network(url, fit: BoxFit.contain),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ExpandableDescription extends HookConsumerWidget {
|
||||||
|
final String html;
|
||||||
|
const _ExpandableDescription({required this.html});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final expanded = useState(false);
|
||||||
|
final markdownAsync = ref.watch(addonHtmlMarkdownProvider(html));
|
||||||
|
final body = markdownAsync.when(
|
||||||
|
skipLoadingOnReload: true,
|
||||||
|
data: (markdown) => MarkdownBody(
|
||||||
|
data: markdown.isEmpty ? html : markdown,
|
||||||
|
onTapLink: (_, href, _) {
|
||||||
|
if (href != null) launchUrl(Uri.parse(href));
|
||||||
|
},
|
||||||
|
),
|
||||||
|
loading: () => Text(html),
|
||||||
|
error: (_, _) => Text(html),
|
||||||
|
);
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
AnimatedSize(
|
||||||
|
duration: const Duration(milliseconds: 200),
|
||||||
|
alignment: Alignment.topCenter,
|
||||||
|
child: ConstrainedBox(
|
||||||
|
constraints: BoxConstraints(
|
||||||
|
maxHeight: expanded.value ? double.infinity : 160,
|
||||||
|
),
|
||||||
|
child: ShaderMask(
|
||||||
|
shaderCallback: (bounds) {
|
||||||
|
if (expanded.value) {
|
||||||
|
return const LinearGradient(
|
||||||
|
colors: [Colors.black, Colors.black],
|
||||||
|
).createShader(bounds);
|
||||||
|
}
|
||||||
|
return const LinearGradient(
|
||||||
|
begin: Alignment.topCenter,
|
||||||
|
end: Alignment.bottomCenter,
|
||||||
|
colors: [Colors.black, Colors.black, Colors.transparent],
|
||||||
|
stops: [0.0, 0.75, 1.0],
|
||||||
|
).createShader(bounds);
|
||||||
|
},
|
||||||
|
blendMode: BlendMode.dstIn,
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
physics: const NeverScrollableScrollPhysics(),
|
||||||
|
child: body,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => expanded.value = !expanded.value,
|
||||||
|
child: Text(expanded.value ? 'Show less' : 'Read more'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
typedef _PermissionGroup = ({String title, List<String> perms});
|
||||||
|
|
||||||
|
class _PermissionsSection extends StatelessWidget {
|
||||||
|
final AddonListing listing;
|
||||||
|
const _PermissionsSection({required this.listing});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final items = <Widget>[];
|
||||||
|
|
||||||
|
final groups = <_PermissionGroup>[
|
||||||
|
(title: 'Required', perms: listing.permissions),
|
||||||
|
(title: 'Websites', perms: listing.hostPermissions),
|
||||||
|
(title: 'Optional', perms: listing.optionalPermissions),
|
||||||
|
(title: 'Data collection', perms: listing.dataCollectionPermissions),
|
||||||
|
];
|
||||||
|
|
||||||
|
for (final group in groups) {
|
||||||
|
final friendly = group.perms
|
||||||
|
.map(describePermission)
|
||||||
|
.where((d) => !d.technical)
|
||||||
|
.toList();
|
||||||
|
if (friendly.isEmpty) continue;
|
||||||
|
items.add(
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 8, bottom: 4),
|
||||||
|
child: Text(group.title, style: theme.textTheme.titleSmall),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
for (final d in friendly) {
|
||||||
|
items.add(
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 2),
|
||||||
|
child: Text('\u2022 ${d.text}', style: theme.textTheme.bodyMedium),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: items,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _TechnicalPermissionsSection extends StatelessWidget {
|
||||||
|
final AddonListing listing;
|
||||||
|
const _TechnicalPermissionsSection({required this.listing});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final items = <Widget>[];
|
||||||
|
|
||||||
|
final groups = <_PermissionGroup>[
|
||||||
|
(title: 'Required', perms: listing.permissions),
|
||||||
|
(title: 'Websites', perms: listing.hostPermissions),
|
||||||
|
(title: 'Optional', perms: listing.optionalPermissions),
|
||||||
|
(title: 'Data collection', perms: listing.dataCollectionPermissions),
|
||||||
|
];
|
||||||
|
|
||||||
|
final monoStyle = TextStyle(
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
fontSize: (theme.textTheme.bodyMedium?.fontSize ?? 14) - 1,
|
||||||
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
|
);
|
||||||
|
|
||||||
|
for (final group in groups) {
|
||||||
|
final technical = group.perms
|
||||||
|
.map(describePermission)
|
||||||
|
.where((d) => d.technical)
|
||||||
|
.toList();
|
||||||
|
if (technical.isEmpty) continue;
|
||||||
|
items.add(
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 8, bottom: 4),
|
||||||
|
child: Text(group.title, style: theme.textTheme.titleSmall),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
for (final d in technical) {
|
||||||
|
items.add(
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 2),
|
||||||
|
child: Text.rich(
|
||||||
|
TextSpan(
|
||||||
|
children: [
|
||||||
|
const TextSpan(text: '\u2022 '),
|
||||||
|
TextSpan(text: d.text, style: monoStyle),
|
||||||
|
],
|
||||||
|
style: theme.textTheme.bodyMedium,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: items,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _hasTechnicalPermissions(AddonListing l) {
|
||||||
|
bool any(List<String> list) =>
|
||||||
|
list.any((p) => describePermission(p).technical);
|
||||||
|
return any(l.permissions) ||
|
||||||
|
any(l.hostPermissions) ||
|
||||||
|
any(l.optionalPermissions) ||
|
||||||
|
any(l.dataCollectionPermissions);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _hasFriendlyPermissions(AddonListing l) {
|
||||||
|
bool any(List<String> list) =>
|
||||||
|
list.any((p) => !describePermission(p).technical);
|
||||||
|
return any(l.permissions) ||
|
||||||
|
any(l.hostPermissions) ||
|
||||||
|
any(l.optionalPermissions) ||
|
||||||
|
any(l.dataCollectionPermissions);
|
||||||
|
}
|
||||||
|
|
||||||
|
class _MoreInformationSection extends StatelessWidget {
|
||||||
|
final AddonListing listing;
|
||||||
|
const _MoreInformationSection({required this.listing});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final rows = <Widget>[];
|
||||||
|
|
||||||
|
rows.add(_InfoRow(label: 'Version', value: listing.latestVersion));
|
||||||
|
|
||||||
|
if (listing.fileSize != null) {
|
||||||
|
rows.add(_InfoRow(label: 'Size', value: formatBytes(listing.fileSize!)));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (listing.lastUpdated != null) {
|
||||||
|
rows.add(
|
||||||
|
_InfoRow(
|
||||||
|
label: 'Last updated',
|
||||||
|
value: formatIsoDate(listing.lastUpdated!),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (listing.categories.isNotEmpty) {
|
||||||
|
rows.add(
|
||||||
|
_InfoRow(label: 'Categories', value: listing.categories.join(', ')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (listing.licenseName != null) {
|
||||||
|
rows.add(
|
||||||
|
_InfoRow(
|
||||||
|
label: 'License',
|
||||||
|
value: listing.licenseName!,
|
||||||
|
url: listing.licenseUrl,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final links = <Widget>[];
|
||||||
|
if (listing.homepageUrl != null) {
|
||||||
|
links.add(
|
||||||
|
_LinkTile(
|
||||||
|
icon: Icons.home_outlined,
|
||||||
|
label: 'Homepage',
|
||||||
|
url: listing.homepageUrl!,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (listing.supportUrl != null) {
|
||||||
|
links.add(
|
||||||
|
_LinkTile(
|
||||||
|
icon: Icons.help_outline,
|
||||||
|
label: 'Support site',
|
||||||
|
url: listing.supportUrl!,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (listing.supportEmail != null) {
|
||||||
|
links.add(
|
||||||
|
_LinkTile(
|
||||||
|
icon: Icons.email_outlined,
|
||||||
|
label: listing.supportEmail!,
|
||||||
|
url: 'mailto:${listing.supportEmail!}',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
links.add(
|
||||||
|
_LinkTile(
|
||||||
|
icon: Icons.public,
|
||||||
|
label: 'View on addons.mozilla.org',
|
||||||
|
url: listing.detailUrl,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (listing.ratingUrl != null) {
|
||||||
|
links.add(
|
||||||
|
_LinkTile(
|
||||||
|
icon: Icons.reviews_outlined,
|
||||||
|
label: 'Reviews',
|
||||||
|
url: listing.ratingUrl!,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (listing.hasPrivacyPolicy && listing.slug != null) {
|
||||||
|
links.add(
|
||||||
|
_LinkTile(
|
||||||
|
icon: Icons.privacy_tip_outlined,
|
||||||
|
label: 'Privacy policy',
|
||||||
|
url: 'https://addons.mozilla.org/addon/${listing.slug}/privacy/',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [...rows, const SizedBox(height: 8), ...links],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _InfoRow extends StatelessWidget {
|
||||||
|
final String label;
|
||||||
|
final String value;
|
||||||
|
final String? url;
|
||||||
|
|
||||||
|
const _InfoRow({required this.label, required this.value, this.url});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final valueWidget = url != null
|
||||||
|
? InkWell(
|
||||||
|
onTap: () => launchUrl(Uri.parse(url!)),
|
||||||
|
child: Text(
|
||||||
|
value,
|
||||||
|
style: theme.textTheme.bodyMedium?.copyWith(
|
||||||
|
color: theme.colorScheme.primary,
|
||||||
|
decoration: TextDecoration.underline,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: Text(value, style: theme.textTheme.bodyMedium);
|
||||||
|
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
SizedBox(
|
||||||
|
width: 120,
|
||||||
|
child: Text(
|
||||||
|
label,
|
||||||
|
style: theme.textTheme.bodyMedium?.copyWith(
|
||||||
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Expanded(child: valueWidget),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _LinkTile extends StatelessWidget {
|
||||||
|
final IconData icon;
|
||||||
|
final String label;
|
||||||
|
final String url;
|
||||||
|
|
||||||
|
const _LinkTile({required this.icon, required this.label, required this.url});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return ListTile(
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
leading: Icon(icon),
|
||||||
|
title: Text(label),
|
||||||
|
trailing: const Icon(Icons.open_in_new, size: 18),
|
||||||
|
onTap: () => launchUrl(Uri.parse(url)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,13 +17,16 @@
|
|||||||
* You should have received a copy of the GNU Affero General Public License
|
* You should have received a copy of the GNU Affero General Public License
|
||||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
import 'package:fading_scroll/fading_scroll.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_mozilla_components/flutter_mozilla_components.dart';
|
import 'package:flutter_mozilla_components/flutter_mozilla_components.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:weblibre/core/routing/routes.dart';
|
import 'package:weblibre/core/routing/routes.dart';
|
||||||
import 'package:weblibre/features/addons/domain/providers.dart';
|
import 'package:weblibre/features/addons/domain/providers.dart';
|
||||||
import 'package:weblibre/features/addons/extensions/addon_info.dart';
|
import 'package:weblibre/features/addons/extensions/addon_info.dart';
|
||||||
|
import 'package:weblibre/features/addons/presentation/screens/addon_browse.dart';
|
||||||
import 'package:weblibre/features/addons/presentation/widgets/addon_ui.dart';
|
import 'package:weblibre/features/addons/presentation/widgets/addon_ui.dart';
|
||||||
|
import 'package:weblibre/features/geckoview/features/browser/presentation/dialogs/install_local_addon_dialog.dart';
|
||||||
import 'package:weblibre/utils/ui_helper.dart';
|
import 'package:weblibre/utils/ui_helper.dart';
|
||||||
|
|
||||||
class AddonManagerScreen extends ConsumerWidget {
|
class AddonManagerScreen extends ConsumerWidget {
|
||||||
@@ -35,67 +38,107 @@ class AddonManagerScreen extends ConsumerWidget {
|
|||||||
|
|
||||||
Future<void> refresh() => ref.read(addonListProvider.notifier).refresh();
|
Future<void> refresh() => ref.read(addonListProvider.notifier).refresh();
|
||||||
|
|
||||||
return Scaffold(
|
return DefaultTabController(
|
||||||
appBar: AppBar(
|
length: 2,
|
||||||
title: const Text('Extensions'),
|
child: Scaffold(
|
||||||
actions: [
|
appBar: AppBar(
|
||||||
IconButton(
|
title: const Text('Extensions'),
|
||||||
onPressed: addonsAsync.isLoading ? null : refresh,
|
bottom: const TabBar(
|
||||||
icon: const Icon(Icons.refresh),
|
tabs: [
|
||||||
|
Tab(text: 'Installed'),
|
||||||
|
Tab(text: 'Browse'),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
_TriggerAllUpdatesButton(
|
actions: [
|
||||||
enabled: addonsAsync.maybeWhen(
|
IconButton(
|
||||||
data: (addons) =>
|
onPressed: addonsAsync.isLoading ? null : refresh,
|
||||||
addons.any((a) => a.isInstalled && a.isSupported),
|
icon: const Icon(Icons.refresh),
|
||||||
orElse: () => false,
|
|
||||||
),
|
),
|
||||||
),
|
_AddonManagerOverflowMenu(
|
||||||
],
|
canCheckForUpdates: addonsAsync.maybeWhen(
|
||||||
),
|
data: (addons) =>
|
||||||
body: addonsAsync.when(
|
addons.any((a) => a.isInstalled && a.isSupported),
|
||||||
skipLoadingOnReload: true,
|
orElse: () => false,
|
||||||
skipError: true,
|
),
|
||||||
data: (addons) => RefreshIndicator(
|
),
|
||||||
onRefresh: refresh,
|
],
|
||||||
child: _AddonList(addons: addons),
|
),
|
||||||
|
body: SafeArea(
|
||||||
|
child: TabBarView(
|
||||||
|
children: [
|
||||||
|
addonsAsync.when(
|
||||||
|
skipLoadingOnReload: true,
|
||||||
|
skipError: true,
|
||||||
|
data: (addons) => RefreshIndicator(
|
||||||
|
onRefresh: refresh,
|
||||||
|
child: _AddonList(addons: addons),
|
||||||
|
),
|
||||||
|
error: (error, _) =>
|
||||||
|
_AddonLoadError(error: error, onRetry: refresh),
|
||||||
|
loading: () => const Center(child: CircularProgressIndicator()),
|
||||||
|
),
|
||||||
|
const AddonBrowseView(),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
error: (error, _) => _AddonLoadError(error: error, onRetry: refresh),
|
|
||||||
loading: () => const Center(child: CircularProgressIndicator()),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _TriggerAllUpdatesButton extends ConsumerWidget {
|
enum _AddonManagerMenuAction { checkForUpdates, installFromFile }
|
||||||
final bool enabled;
|
|
||||||
|
|
||||||
const _TriggerAllUpdatesButton({required this.enabled});
|
class _AddonManagerOverflowMenu extends ConsumerWidget {
|
||||||
|
final bool canCheckForUpdates;
|
||||||
|
|
||||||
|
const _AddonManagerOverflowMenu({required this.canCheckForUpdates});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final busy = ref.watch(
|
final updatesBusy = ref.watch(
|
||||||
bulkAddonUpdateProvider.select((value) => value.isLoading),
|
bulkAddonUpdateProvider.select((value) => value.isLoading),
|
||||||
);
|
);
|
||||||
|
|
||||||
return IconButton(
|
return PopupMenuButton<_AddonManagerMenuAction>(
|
||||||
onPressed: enabled && !busy
|
icon: updatesBusy
|
||||||
? () async {
|
|
||||||
await ref.read(bulkAddonUpdateProvider.notifier).triggerAll();
|
|
||||||
if (!context.mounted) return;
|
|
||||||
showInfoMessage(
|
|
||||||
context,
|
|
||||||
'Background update checks started for installed extensions',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
: null,
|
|
||||||
icon: busy
|
|
||||||
? const SizedBox(
|
? const SizedBox(
|
||||||
width: 18,
|
width: 18,
|
||||||
height: 18,
|
height: 18,
|
||||||
child: CircularProgressIndicator(strokeWidth: 2),
|
child: CircularProgressIndicator(strokeWidth: 2),
|
||||||
)
|
)
|
||||||
: const Icon(Icons.system_update_alt),
|
: const Icon(Icons.more_vert),
|
||||||
tooltip: 'Check all installed extensions for updates',
|
onSelected: (action) async {
|
||||||
|
switch (action) {
|
||||||
|
case _AddonManagerMenuAction.checkForUpdates:
|
||||||
|
await ref.read(bulkAddonUpdateProvider.notifier).triggerAll();
|
||||||
|
if (!context.mounted) return;
|
||||||
|
showInfoMessage(
|
||||||
|
context,
|
||||||
|
'Background update checks started for installed extensions',
|
||||||
|
);
|
||||||
|
case _AddonManagerMenuAction.installFromFile:
|
||||||
|
await showInstallLocalAddonDialog(context);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
itemBuilder: (context) => [
|
||||||
|
PopupMenuItem(
|
||||||
|
value: _AddonManagerMenuAction.checkForUpdates,
|
||||||
|
enabled: canCheckForUpdates && !updatesBusy,
|
||||||
|
child: const ListTile(
|
||||||
|
leading: Icon(Icons.system_update_alt),
|
||||||
|
title: Text('Check for updates'),
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const PopupMenuItem(
|
||||||
|
value: _AddonManagerMenuAction.installFromFile,
|
||||||
|
child: ListTile(
|
||||||
|
leading: Icon(Icons.file_open),
|
||||||
|
title: Text('Install from file'),
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -113,78 +156,50 @@ class _AddonList extends StatelessWidget {
|
|||||||
final disabled = addons
|
final disabled = addons
|
||||||
.where((a) => a.isInstalled && a.isSupported && !a.isEnabled)
|
.where((a) => a.isInstalled && a.isSupported && !a.isEnabled)
|
||||||
.toList();
|
.toList();
|
||||||
final recommended = addons.where((a) => !a.isInstalled).toList();
|
|
||||||
final unsupported = addons
|
final unsupported = addons
|
||||||
.where((a) => a.isInstalled && !a.isSupported)
|
.where((a) => a.isInstalled && !a.isSupported)
|
||||||
.toList();
|
.toList();
|
||||||
|
final installed = enabled.length + disabled.length + unsupported.length;
|
||||||
|
|
||||||
return ListView(
|
return FadingScroll(
|
||||||
padding: const EdgeInsets.all(16),
|
fadingSize: 25,
|
||||||
children: [
|
builder: (context, controller) {
|
||||||
const Card(
|
return ListView(
|
||||||
child: ListTile(
|
controller: controller,
|
||||||
leading: Icon(Icons.info_outline),
|
padding: const EdgeInsets.all(16),
|
||||||
title: Text('Addon updates run in the background'),
|
children: [
|
||||||
subtitle: Text(
|
if (enabled.isNotEmpty) ...[
|
||||||
'Use each extension detail screen to view its last update result or trigger a manual check.',
|
const SizedBox(height: 16),
|
||||||
),
|
const _Section(title: 'Enabled'),
|
||||||
),
|
for (final addon in enabled) _AddonCard(addon: addon),
|
||||||
),
|
],
|
||||||
if (enabled.isNotEmpty) ...[
|
if (disabled.isNotEmpty) ...[
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
const _Section(title: 'Enabled'),
|
const _Section(title: 'Disabled'),
|
||||||
for (final addon in enabled) _AddonCard(addon: addon),
|
for (final addon in disabled) _AddonCard(addon: addon),
|
||||||
],
|
],
|
||||||
if (disabled.isNotEmpty) ...[
|
if (unsupported.isNotEmpty) ...[
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
const _Section(title: 'Disabled'),
|
const _Section(title: 'Unsupported'),
|
||||||
for (final addon in disabled) _AddonCard(addon: addon),
|
for (final addon in unsupported)
|
||||||
],
|
_AddonCard(
|
||||||
if (recommended.isNotEmpty) ...[
|
addon: addon,
|
||||||
const SizedBox(height: 16),
|
action: _UninstallAction(addon: addon),
|
||||||
const _Section(title: 'Available'),
|
),
|
||||||
for (final addon in recommended)
|
],
|
||||||
_AddonCard(
|
if (installed == 0)
|
||||||
addon: addon,
|
const Padding(
|
||||||
action: _InstallAction(addon: addon),
|
padding: EdgeInsets.only(top: 48),
|
||||||
),
|
child: Center(
|
||||||
],
|
child: Text(
|
||||||
if (unsupported.isNotEmpty) ...[
|
'No extensions installed yet.\nBrowse the store to find some.',
|
||||||
const SizedBox(height: 16),
|
textAlign: TextAlign.center,
|
||||||
const _Section(title: 'Unsupported'),
|
),
|
||||||
for (final addon in unsupported)
|
),
|
||||||
_AddonCard(
|
),
|
||||||
addon: addon,
|
],
|
||||||
action: _UninstallAction(addon: addon),
|
);
|
||||||
),
|
},
|
||||||
],
|
|
||||||
if (addons.isEmpty)
|
|
||||||
const Padding(
|
|
||||||
padding: EdgeInsets.only(top: 48),
|
|
||||||
child: Center(child: Text('No extensions available right now.')),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class _InstallAction extends ConsumerWidget {
|
|
||||||
final AddonInfo addon;
|
|
||||||
|
|
||||||
const _InstallAction({required this.addon});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
|
||||||
final busy = ref.watch(addonBusyIdsProvider).contains(addon.id);
|
|
||||||
return FilledButton(
|
|
||||||
onPressed: busy
|
|
||||||
? null
|
|
||||||
: () async {
|
|
||||||
await ref.read(addonListProvider.notifier).install(addon);
|
|
||||||
if (!context.mounted) return;
|
|
||||||
showInfoMessage(context, '${addon.displayName} installed');
|
|
||||||
},
|
|
||||||
child: const Text('Install'),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -236,6 +251,7 @@ class _AddonCard extends ConsumerWidget {
|
|||||||
final busy = ref.watch(addonBusyIdsProvider).contains(addon.id);
|
final busy = ref.watch(addonBusyIdsProvider).contains(addon.id);
|
||||||
|
|
||||||
return Card(
|
return Card(
|
||||||
|
color: Theme.of(context).colorScheme.surfaceContainerHigh,
|
||||||
margin: const EdgeInsets.only(bottom: 12),
|
margin: const EdgeInsets.only(bottom: 12),
|
||||||
child: InkWell(
|
child: InkWell(
|
||||||
onTap: busy
|
onTap: busy
|
||||||
@@ -267,7 +283,6 @@ class _AddonCard extends ConsumerWidget {
|
|||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Wrap(
|
Wrap(
|
||||||
spacing: 8,
|
spacing: 8,
|
||||||
runSpacing: 8,
|
|
||||||
children: [
|
children: [
|
||||||
if (addon.isAllowedInPrivateBrowsing)
|
if (addon.isAllowedInPrivateBrowsing)
|
||||||
const Chip(label: Text('Private Browsing')),
|
const Chip(label: Text('Private Browsing')),
|
||||||
|
|||||||
@@ -71,8 +71,9 @@ class AddonPermissionsScreen extends ConsumerWidget {
|
|||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
children: [
|
children: [
|
||||||
if (permissions.isEmpty && dataCollection.isEmpty)
|
if (permissions.isEmpty && dataCollection.isEmpty)
|
||||||
const Card(
|
Card(
|
||||||
child: ListTile(
|
color: Theme.of(context).colorScheme.surfaceContainerHigh,
|
||||||
|
child: const ListTile(
|
||||||
leading: Icon(Icons.verified_user_outlined),
|
leading: Icon(Icons.verified_user_outlined),
|
||||||
title: Text('No special permissions listed'),
|
title: Text('No special permissions listed'),
|
||||||
subtitle: Text(
|
subtitle: Text(
|
||||||
@@ -87,6 +88,7 @@ class AddonPermissionsScreen extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Card(
|
Card(
|
||||||
|
color: Theme.of(context).colorScheme.surfaceContainerHigh,
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
for (final permission in permissions)
|
for (final permission in permissions)
|
||||||
@@ -106,6 +108,7 @@ class AddonPermissionsScreen extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Card(
|
Card(
|
||||||
|
color: Theme.of(context).colorScheme.surfaceContainerHigh,
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
for (final permission in dataCollection)
|
for (final permission in dataCollection)
|
||||||
|
|||||||
@@ -0,0 +1,146 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2024-2026 Fabian Freund.
|
||||||
|
*
|
||||||
|
* This file is part of WebLibre
|
||||||
|
* (see https://weblibre.eu).
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU Affero General Public License as
|
||||||
|
* published by the Free Software Foundation, either version 3 of the
|
||||||
|
* License, or (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU Affero General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU Affero General Public License
|
||||||
|
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_mozilla_components/flutter_mozilla_components.dart';
|
||||||
|
import 'package:weblibre/utils/number_format.dart';
|
||||||
|
|
||||||
|
class AddonListingIcon extends StatelessWidget {
|
||||||
|
final String? iconUrl;
|
||||||
|
final double size;
|
||||||
|
|
||||||
|
const AddonListingIcon({required this.iconUrl, this.size = 40, super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final borderRadius = BorderRadius.circular(12);
|
||||||
|
if (iconUrl == null || iconUrl!.isEmpty) {
|
||||||
|
return _fallback(context);
|
||||||
|
}
|
||||||
|
return ClipRRect(
|
||||||
|
borderRadius: borderRadius,
|
||||||
|
child: Image.network(
|
||||||
|
iconUrl!,
|
||||||
|
width: size,
|
||||||
|
height: size,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
errorBuilder: (_, _, _) => _fallback(context),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _fallback(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
return Container(
|
||||||
|
width: size,
|
||||||
|
height: size,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: theme.colorScheme.surfaceContainerHighest,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
child: Icon(Icons.extension, color: theme.colorScheme.onSurfaceVariant),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class AddonListingCard extends StatelessWidget {
|
||||||
|
final AddonListing listing;
|
||||||
|
final bool isInstalled;
|
||||||
|
final VoidCallback? onTap;
|
||||||
|
final Widget? trailing;
|
||||||
|
|
||||||
|
const AddonListingCard({
|
||||||
|
required this.listing,
|
||||||
|
required this.isInstalled,
|
||||||
|
this.onTap,
|
||||||
|
this.trailing,
|
||||||
|
super.key,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
return Card(
|
||||||
|
color: theme.colorScheme.surfaceContainerHigh,
|
||||||
|
margin: const EdgeInsets.only(bottom: 12),
|
||||||
|
child: InkWell(
|
||||||
|
onTap: onTap,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
AddonListingIcon(iconUrl: listing.iconUrl),
|
||||||
|
const SizedBox(width: 16),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(listing.name, style: theme.textTheme.titleMedium),
|
||||||
|
if ((listing.summary ?? '').isNotEmpty) ...[
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
listing.summary!,
|
||||||
|
maxLines: 2,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Wrap(
|
||||||
|
spacing: 8,
|
||||||
|
children: [
|
||||||
|
if (listing.promoted == AddonStorePromoted.recommended)
|
||||||
|
const Chip(
|
||||||
|
avatar: Icon(Icons.verified, size: 16),
|
||||||
|
label: Text('Recommended'),
|
||||||
|
),
|
||||||
|
if (listing.ratingAverage != null)
|
||||||
|
Chip(
|
||||||
|
avatar: const Icon(Icons.star, size: 16),
|
||||||
|
label: Text(
|
||||||
|
listing.ratingAverage!.toStringAsFixed(1),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (listing.averageDailyUsers != null)
|
||||||
|
Chip(
|
||||||
|
avatar: const Icon(Icons.group_outlined, size: 16),
|
||||||
|
label: Text(
|
||||||
|
formatCompactNumber(listing.averageDailyUsers!),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (isInstalled)
|
||||||
|
const Chip(
|
||||||
|
avatar: Icon(Icons.check, size: 16),
|
||||||
|
label: Text('Installed'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
trailing ?? const Icon(Icons.chevron_right),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2024-2026 Fabian Freund.
|
||||||
|
*
|
||||||
|
* This file is part of WebLibre
|
||||||
|
* (see https://weblibre.eu).
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU Affero General Public License as
|
||||||
|
* published by the Free Software Foundation, either version 3 of the
|
||||||
|
* License, or (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU Affero General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU Affero General Public License
|
||||||
|
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
import 'package:flutter_mozilla_components/flutter_mozilla_components.dart';
|
||||||
|
|
||||||
|
/// AMO descriptions mix raw HTML tags with entity-escaped ones
|
||||||
|
/// (e.g. `<b>not</b>` next to `<ul><li>…</li></ul>`). DOMParser
|
||||||
|
/// would decode the entities to literal text characters, so turndown would
|
||||||
|
/// emit them verbatim. Decode entities once here so the escaped tags become
|
||||||
|
/// real markup before the extension runs.
|
||||||
|
String _decodeHtmlEntities(String input) {
|
||||||
|
return input
|
||||||
|
.replaceAll('<', '<')
|
||||||
|
.replaceAll('>', '>')
|
||||||
|
.replaceAll('"', '"')
|
||||||
|
.replaceAll(''', "'")
|
||||||
|
.replaceAll(''', "'")
|
||||||
|
.replaceAll(' ', ' ')
|
||||||
|
.replaceAll('&', '&');
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<String> turndownAddonHtml(String description) async {
|
||||||
|
if (description.isEmpty) return '';
|
||||||
|
|
||||||
|
final results = await GeckoBrowserExtensionService.turndownHtml([
|
||||||
|
_decodeHtmlEntities(description),
|
||||||
|
], timeout: const Duration(seconds: 3));
|
||||||
|
|
||||||
|
final markdown = results.firstOrNull?.markdown?.trim();
|
||||||
|
return (markdown == null || markdown.isEmpty) ? description : markdown;
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2024-2026 Fabian Freund.
|
||||||
|
*
|
||||||
|
* This file is part of WebLibre
|
||||||
|
* (see https://weblibre.eu).
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU Affero General Public License as
|
||||||
|
* published by the Free Software Foundation, either version 3 of the
|
||||||
|
* License, or (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU Affero General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU Affero General Public License
|
||||||
|
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
typedef PermissionDescription = ({String text, bool technical});
|
||||||
|
|
||||||
|
PermissionDescription describePermission(String raw) {
|
||||||
|
String? mapped;
|
||||||
|
switch (raw) {
|
||||||
|
case 'bookmarks':
|
||||||
|
mapped = 'Read and modify bookmarks';
|
||||||
|
case 'browserSettings':
|
||||||
|
mapped = 'Read and modify browser settings';
|
||||||
|
case 'browsingData':
|
||||||
|
mapped = 'Clear recent browsing history, cookies, and related data';
|
||||||
|
case 'clipboardRead':
|
||||||
|
mapped = 'Read data you copy and paste';
|
||||||
|
case 'clipboardWrite':
|
||||||
|
mapped = 'Input data to the clipboard';
|
||||||
|
case 'contextualIdentities':
|
||||||
|
mapped = 'Access and modify container tabs';
|
||||||
|
case 'cookies':
|
||||||
|
mapped = 'Access cookies for visited sites';
|
||||||
|
case 'downloads':
|
||||||
|
mapped = 'Download files and read/modify download history';
|
||||||
|
case 'downloads.open':
|
||||||
|
mapped = 'Open files downloaded to your computer';
|
||||||
|
case 'find':
|
||||||
|
mapped = 'Read the text of all open tabs';
|
||||||
|
case 'geolocation':
|
||||||
|
mapped = 'Access your location';
|
||||||
|
case 'history':
|
||||||
|
mapped = 'Access browsing history';
|
||||||
|
case 'management':
|
||||||
|
mapped = 'Monitor extension usage and manage themes';
|
||||||
|
case 'nativeMessaging':
|
||||||
|
mapped = 'Exchange messages with programs other than the browser';
|
||||||
|
case 'notifications':
|
||||||
|
mapped = 'Display notifications';
|
||||||
|
case 'pkcs11':
|
||||||
|
mapped = 'Provide cryptographic authentication services';
|
||||||
|
case 'privacy':
|
||||||
|
mapped = 'Read and modify privacy settings';
|
||||||
|
case 'proxy':
|
||||||
|
mapped = 'Control browser proxy settings';
|
||||||
|
case 'sessions':
|
||||||
|
mapped = 'Access recently closed tabs';
|
||||||
|
case 'tabs':
|
||||||
|
mapped = 'Access browser tabs';
|
||||||
|
case 'tabHide':
|
||||||
|
mapped = 'Hide and show browser tabs';
|
||||||
|
case 'topSites':
|
||||||
|
mapped = 'Access browsing history';
|
||||||
|
case 'webNavigation':
|
||||||
|
mapped = 'Access browser activity during navigation';
|
||||||
|
case '<all_urls>':
|
||||||
|
mapped = 'Access your data for all websites';
|
||||||
|
}
|
||||||
|
if (mapped != null) return (text: mapped, technical: false);
|
||||||
|
if (raw.startsWith('http') ||
|
||||||
|
raw.contains('://') ||
|
||||||
|
raw.contains('*') ||
|
||||||
|
raw.startsWith('file:')) {
|
||||||
|
return (text: 'Access your data for $raw', technical: false);
|
||||||
|
}
|
||||||
|
return (text: raw, technical: true);
|
||||||
|
}
|
||||||
-21
@@ -1809,27 +1809,6 @@ class _ExtensionsCard extends HookConsumerWidget {
|
|||||||
await const AddonManagerRoute().push<void>(rootContext);
|
await const AddonManagerRoute().push<void>(rootContext);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
_buildSubTile(
|
|
||||||
'Get Extensions',
|
|
||||||
icon: MdiIcons.puzzlePlus,
|
|
||||||
onTap: () async {
|
|
||||||
Navigator.pop(context);
|
|
||||||
final tabMode = TabMode.fromTabType(
|
|
||||||
ref
|
|
||||||
.read(generalSettingsWithDefaultsProvider)
|
|
||||||
.effectiveDefaultCreateTabType,
|
|
||||||
);
|
|
||||||
await ref
|
|
||||||
.read(tabRepositoryProvider.notifier)
|
|
||||||
.addTab(
|
|
||||||
url: Uri.parse('https://addons.mozilla.org'),
|
|
||||||
tabMode: tabMode,
|
|
||||||
containerSelection:
|
|
||||||
const TabContainerSelection.unassigned(),
|
|
||||||
selectTab: true,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
+1
-1
@@ -33,7 +33,7 @@ final class IntentGatekeeperProvider
|
|||||||
IntentGatekeeper create() => IntentGatekeeper();
|
IntentGatekeeper create() => IntentGatekeeper();
|
||||||
}
|
}
|
||||||
|
|
||||||
String _$intentGatekeeperHash() => r'94df8850478ad6695eb14752e82af1919ea8a077';
|
String _$intentGatekeeperHash() => r'0ab4a96dde7a21df5dd32d034f841bf4d40dbb10';
|
||||||
|
|
||||||
abstract class _$IntentGatekeeper
|
abstract class _$IntentGatekeeper
|
||||||
extends $StreamNotifier<PendingIntentDecision> {
|
extends $StreamNotifier<PendingIntentDecision> {
|
||||||
|
|||||||
@@ -26,7 +26,6 @@ import 'package:flutter_material_design_icons/flutter_material_design_icons.dart
|
|||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:weblibre/core/routing/routes.dart';
|
import 'package:weblibre/core/routing/routes.dart';
|
||||||
import 'package:weblibre/features/geckoview/features/browser/domain/services/browser_addon.dart';
|
import 'package:weblibre/features/geckoview/features/browser/domain/services/browser_addon.dart';
|
||||||
import 'package:weblibre/features/geckoview/features/browser/presentation/dialogs/install_local_addon_dialog.dart';
|
|
||||||
import 'package:weblibre/features/settings/presentation/widgets/custom_list_tile.dart';
|
import 'package:weblibre/features/settings/presentation/widgets/custom_list_tile.dart';
|
||||||
import 'package:weblibre/features/settings/presentation/widgets/sections.dart';
|
import 'package:weblibre/features/settings/presentation/widgets/sections.dart';
|
||||||
|
|
||||||
@@ -47,7 +46,6 @@ class ExtensionsSettingsScreen extends StatelessWidget {
|
|||||||
children: const [
|
children: const [
|
||||||
SettingSection(name: 'Extensions'),
|
SettingSection(name: 'Extensions'),
|
||||||
_ManageExtensionsTile(),
|
_ManageExtensionsTile(),
|
||||||
_InstallLocalAddonTile(),
|
|
||||||
_AddonCollectionTile(),
|
_AddonCollectionTile(),
|
||||||
SettingSection(name: 'Updates'),
|
SettingSection(name: 'Updates'),
|
||||||
_AutoUpdateTile(),
|
_AutoUpdateTile(),
|
||||||
@@ -90,33 +88,6 @@ class _ManageExtensionsTile extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _InstallLocalAddonTile extends StatelessWidget {
|
|
||||||
const _InstallLocalAddonTile();
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return CustomListTile(
|
|
||||||
title: 'Install from File',
|
|
||||||
subtitle: 'Install an extension from a local .xpi file',
|
|
||||||
prefix: Padding(
|
|
||||||
padding: const EdgeInsets.only(right: 16.0),
|
|
||||||
child: Icon(
|
|
||||||
MdiIcons.puzzle,
|
|
||||||
size: 24,
|
|
||||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
suffix: FilledButton.icon(
|
|
||||||
onPressed: () async {
|
|
||||||
await showInstallLocalAddonDialog(context);
|
|
||||||
},
|
|
||||||
icon: const Icon(Icons.file_open),
|
|
||||||
label: const Text('Install'),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class _AddonCollectionTile extends StatelessWidget {
|
class _AddonCollectionTile extends StatelessWidget {
|
||||||
const _AddonCollectionTile();
|
const _AddonCollectionTile();
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2024-2026 Fabian Freund.
|
||||||
|
*
|
||||||
|
* This file is part of WebLibre
|
||||||
|
* (see https://weblibre.eu).
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU Affero General Public License as
|
||||||
|
* published by the Free Software Foundation, either version 3 of the
|
||||||
|
* License, or (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU Affero General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU Affero General Public License
|
||||||
|
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
import 'package:intl/intl.dart';
|
||||||
|
|
||||||
|
/// Formats integers in a compact, locale-aware form (e.g. 10.6M, 1.3K).
|
||||||
|
String formatCompactNumber(num value) {
|
||||||
|
return NumberFormat.compact().format(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Formats a byte count as a short, human-readable string (B/KB/MB).
|
||||||
|
String formatBytes(int bytes) {
|
||||||
|
if (bytes < 1024) return '$bytes B';
|
||||||
|
if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)} KB';
|
||||||
|
return '${(bytes / (1024 * 1024)).toStringAsFixed(2)} MB';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parses an ISO-8601 timestamp and formats it as a short local date.
|
||||||
|
/// Returns the original string if parsing fails.
|
||||||
|
String formatIsoDate(String iso) {
|
||||||
|
try {
|
||||||
|
final dt = DateTime.parse(iso).toLocal();
|
||||||
|
return DateFormat.yMMMd().format(dt);
|
||||||
|
} catch (_) {
|
||||||
|
return iso;
|
||||||
|
}
|
||||||
|
}
|
||||||
+353
-18
@@ -13,11 +13,16 @@ import eu.weblibre.flutter_mozilla_components.ext.toWebPBytes
|
|||||||
import eu.weblibre.flutter_mozilla_components.pigeons.AddonDisabledReason
|
import eu.weblibre.flutter_mozilla_components.pigeons.AddonDisabledReason
|
||||||
import eu.weblibre.flutter_mozilla_components.pigeons.AddonIncognito
|
import eu.weblibre.flutter_mozilla_components.pigeons.AddonIncognito
|
||||||
import eu.weblibre.flutter_mozilla_components.pigeons.AddonInfo
|
import eu.weblibre.flutter_mozilla_components.pigeons.AddonInfo
|
||||||
|
import eu.weblibre.flutter_mozilla_components.pigeons.AddonListing
|
||||||
|
import eu.weblibre.flutter_mozilla_components.pigeons.AddonListingPreview
|
||||||
|
import eu.weblibre.flutter_mozilla_components.pigeons.AddonStoreApp
|
||||||
import eu.weblibre.flutter_mozilla_components.pigeons.AddonStoreInfo
|
import eu.weblibre.flutter_mozilla_components.pigeons.AddonStoreInfo
|
||||||
|
import eu.weblibre.flutter_mozilla_components.pigeons.AddonStorePromoted
|
||||||
import eu.weblibre.flutter_mozilla_components.pigeons.AddonUpdateAttemptInfo
|
import eu.weblibre.flutter_mozilla_components.pigeons.AddonUpdateAttemptInfo
|
||||||
import eu.weblibre.flutter_mozilla_components.pigeons.AddonUpdateStatus
|
import eu.weblibre.flutter_mozilla_components.pigeons.AddonUpdateStatus
|
||||||
import eu.weblibre.flutter_mozilla_components.pigeons.GeckoAddonsApi
|
import eu.weblibre.flutter_mozilla_components.pigeons.GeckoAddonsApi
|
||||||
import eu.weblibre.flutter_mozilla_components.pigeons.WebExtensionActionType
|
import eu.weblibre.flutter_mozilla_components.pigeons.WebExtensionActionType
|
||||||
|
import org.json.JSONArray
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.SupervisorJob
|
import kotlinx.coroutines.SupervisorJob
|
||||||
@@ -35,6 +40,7 @@ import mozilla.components.feature.addons.ui.displayName
|
|||||||
import mozilla.components.feature.addons.ui.summary
|
import mozilla.components.feature.addons.ui.summary
|
||||||
import mozilla.components.feature.addons.ui.translateDescription
|
import mozilla.components.feature.addons.ui.translateDescription
|
||||||
import org.mozilla.geckoview.WebExtension.InstallException.ErrorCodes.ERROR_POSTPONED
|
import org.mozilla.geckoview.WebExtension.InstallException.ErrorCodes.ERROR_POSTPONED
|
||||||
|
import java.util.Locale
|
||||||
import org.json.JSONObject
|
import org.json.JSONObject
|
||||||
|
|
||||||
class GeckoAddonsApiImpl(private val context: Context) : GeckoAddonsApi {
|
class GeckoAddonsApiImpl(private val context: Context) : GeckoAddonsApi {
|
||||||
@@ -56,6 +62,23 @@ class GeckoAddonsApiImpl(private val context: Context) : GeckoAddonsApi {
|
|||||||
"Update downloaded and will be applied after restarting the app."
|
"Update downloaded and will be applied after restarting the app."
|
||||||
private const val DEFAULT_AMO_SERVER_URL = "https://addons.mozilla.org"
|
private const val DEFAULT_AMO_SERVER_URL = "https://addons.mozilla.org"
|
||||||
private const val PERIODIC_UPDATE_RESTORE_DELAY_MS = 10_000L
|
private const val PERIODIC_UPDATE_RESTORE_DELAY_MS = 10_000L
|
||||||
|
private const val STORE_INFO_CACHE_TTL_MS = 30L * 60L * 1000L
|
||||||
|
}
|
||||||
|
|
||||||
|
private data class StoreInfoCacheEntry(val value: AddonStoreInfo, val timestampMs: Long)
|
||||||
|
private val storeInfoCache = java.util.concurrent.ConcurrentHashMap<String, StoreInfoCacheEntry>()
|
||||||
|
|
||||||
|
private fun cachedStoreInfo(addonId: String): AddonStoreInfo? {
|
||||||
|
val entry = storeInfoCache[addonId] ?: return null
|
||||||
|
if (System.currentTimeMillis() - entry.timestampMs > STORE_INFO_CACHE_TTL_MS) {
|
||||||
|
storeInfoCache.remove(addonId)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return entry.value
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun cacheStoreInfo(addonId: String, info: AddonStoreInfo) {
|
||||||
|
storeInfoCache[addonId] = StoreInfoCacheEntry(info, System.currentTimeMillis())
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun getAddons(allowCache: Boolean, callback: (Result<List<AddonInfo>>) -> Unit) {
|
override fun getAddons(allowCache: Boolean, callback: (Result<List<AddonInfo>>) -> Unit) {
|
||||||
@@ -84,12 +107,25 @@ class GeckoAddonsApiImpl(private val context: Context) : GeckoAddonsApi {
|
|||||||
scope.launch {
|
scope.launch {
|
||||||
runCatching {
|
runCatching {
|
||||||
val installedAddon = components.core.addonManager.getAddonByID(addonId)
|
val installedAddon = components.core.addonManager.getAddonByID(addonId)
|
||||||
(installedAddon ?: components.core.addonManager.getAddons(allowCache = allowCache)
|
val resolved = installedAddon ?: components.core.addonManager.getAddons(
|
||||||
.find { it.id == addonId })?.toPigeon(
|
allowCache = allowCache,
|
||||||
context = context,
|
).find { it.id == addonId }
|
||||||
isAutoUpdateEnabled = isAutoUpdateEffectivelyEnabledForAddon(addonId),
|
|
||||||
isLocalFileInstalled = isLocalFileInstalledAddon(addonId),
|
val isLocalFile = isLocalFileInstalledAddon(addonId)
|
||||||
)
|
val storeInfo = if (resolved != null && resolved.isInstalled() && !isLocalFile &&
|
||||||
|
resolved.needsAmoEnrichment()
|
||||||
|
) {
|
||||||
|
runCatching { fetchAddonStoreInfo(addonId) }.getOrNull()
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
|
||||||
|
resolved?.toPigeon(
|
||||||
|
context = context,
|
||||||
|
isAutoUpdateEnabled = isAutoUpdateEffectivelyEnabledForAddon(addonId),
|
||||||
|
isLocalFileInstalled = isLocalFile,
|
||||||
|
storeInfo = storeInfo,
|
||||||
|
)
|
||||||
}.fold(
|
}.fold(
|
||||||
onSuccess = { callback(Result.success(it)) },
|
onSuccess = { callback(Result.success(it)) },
|
||||||
onFailure = { callback(Result.failure(it)) },
|
onFailure = { callback(Result.failure(it)) },
|
||||||
@@ -108,6 +144,51 @@ class GeckoAddonsApiImpl(private val context: Context) : GeckoAddonsApi {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun searchAddonListings(
|
||||||
|
query: String,
|
||||||
|
app: AddonStoreApp,
|
||||||
|
page: Long,
|
||||||
|
pageSize: Long,
|
||||||
|
callback: (Result<List<AddonListing>>) -> Unit,
|
||||||
|
) {
|
||||||
|
scope.launch {
|
||||||
|
runCatching {
|
||||||
|
fetchAddonListings(
|
||||||
|
query = query.ifBlank { null },
|
||||||
|
app = app,
|
||||||
|
page = page.toInt().coerceAtLeast(1),
|
||||||
|
pageSize = pageSize.toInt().coerceIn(1, 50),
|
||||||
|
sort = if (query.isBlank()) "users" else null,
|
||||||
|
)
|
||||||
|
}.fold(
|
||||||
|
onSuccess = { callback(Result.success(it)) },
|
||||||
|
onFailure = { callback(Result.failure(it)) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getFeaturedAddonListings(
|
||||||
|
app: AddonStoreApp,
|
||||||
|
pageSize: Long,
|
||||||
|
callback: (Result<List<AddonListing>>) -> Unit,
|
||||||
|
) {
|
||||||
|
scope.launch {
|
||||||
|
runCatching {
|
||||||
|
fetchAddonListings(
|
||||||
|
query = null,
|
||||||
|
app = app,
|
||||||
|
page = 1,
|
||||||
|
pageSize = pageSize.toInt().coerceIn(1, 50),
|
||||||
|
sort = "users",
|
||||||
|
promoted = "recommended",
|
||||||
|
)
|
||||||
|
}.fold(
|
||||||
|
onSuccess = { callback(Result.success(it)) },
|
||||||
|
onFailure = { callback(Result.failure(it)) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
override fun invokeAddonAction(extensionId: String, actionType: WebExtensionActionType) {
|
override fun invokeAddonAction(extensionId: String, actionType: WebExtensionActionType) {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
withContext(Dispatchers.Main.immediate) {
|
withContext(Dispatchers.Main.immediate) {
|
||||||
@@ -394,6 +475,8 @@ class GeckoAddonsApiImpl(private val context: Context) : GeckoAddonsApi {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun fetchAddonStoreInfo(addonId: String): AddonStoreInfo? {
|
private suspend fun fetchAddonStoreInfo(addonId: String): AddonStoreInfo? {
|
||||||
|
cachedStoreInfo(addonId)?.let { return it }
|
||||||
|
|
||||||
val response = components.core.client.fetch(
|
val response = components.core.client.fetch(
|
||||||
Request(
|
Request(
|
||||||
url = addonStoreInfoUrl(addonId),
|
url = addonStoreInfoUrl(addonId),
|
||||||
@@ -416,15 +499,205 @@ class GeckoAddonsApiImpl(private val context: Context) : GeckoAddonsApi {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
return AddonStoreInfo(
|
val language = Locale.getDefault().language
|
||||||
|
val ratings = json.optJSONObject("ratings")
|
||||||
|
val ratingAverage = ratings?.optDouble("average")?.takeIf { !it.isNaN() && it > 0.0 }
|
||||||
|
val ratingReviews = ratings?.optInt("text_count", -1)?.takeIf { it >= 0 }?.toLong()
|
||||||
|
val firstAuthor = json.optJSONArray("authors")?.optJSONObject(0)
|
||||||
|
|
||||||
|
val storeInfo = AddonStoreInfo(
|
||||||
latestVersion = latestVersion,
|
latestVersion = latestVersion,
|
||||||
latestXpiUrl = latestXpiUrl,
|
latestXpiUrl = latestXpiUrl,
|
||||||
|
ratingAverage = ratingAverage,
|
||||||
|
ratingReviews = ratingReviews,
|
||||||
|
summary = json.pickTranslation("summary", language),
|
||||||
|
description = json.pickTranslation("description", language),
|
||||||
|
homepageUrl = json.pickHomepage(language)
|
||||||
|
?: json.optString("url").takeIf { it.isNotBlank() },
|
||||||
|
detailUrl = json.optString("url").takeIf { it.isNotBlank() },
|
||||||
|
ratingUrl = json.optString("ratings_url").takeIf { it.isNotBlank() },
|
||||||
|
authorName = firstAuthor?.optString("name")?.takeIf { it.isNotBlank() },
|
||||||
|
authorUrl = firstAuthor?.optString("url")?.takeIf { it.isNotBlank() },
|
||||||
)
|
)
|
||||||
|
cacheStoreInfo(addonId, storeInfo)
|
||||||
|
return storeInfo
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun addonStoreInfoUrl(addonId: String): String {
|
private fun addonStoreInfoUrl(addonId: String): String {
|
||||||
val baseUrl = components.addonCollection?.serverURL?.trimEnd('/') ?: DEFAULT_AMO_SERVER_URL
|
return "${amoBaseUrl()}/api/v5/addons/addon/$addonId/"
|
||||||
return "$baseUrl/api/v5/addons/addon/$addonId/"
|
}
|
||||||
|
|
||||||
|
private fun amoBaseUrl(): String {
|
||||||
|
return components.addonCollection?.serverURL?.trimEnd('/') ?: DEFAULT_AMO_SERVER_URL
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun fetchAddonListings(
|
||||||
|
query: String?,
|
||||||
|
app: AddonStoreApp,
|
||||||
|
page: Int,
|
||||||
|
pageSize: Int,
|
||||||
|
sort: String? = null,
|
||||||
|
promoted: String? = null,
|
||||||
|
): List<AddonListing> {
|
||||||
|
val params = mutableListOf<String>()
|
||||||
|
params += "app=${app.queryValue()}"
|
||||||
|
params += "type=extension"
|
||||||
|
params += "page=$page"
|
||||||
|
params += "page_size=$pageSize"
|
||||||
|
if (!query.isNullOrBlank()) {
|
||||||
|
params += "q=${java.net.URLEncoder.encode(query, "UTF-8")}"
|
||||||
|
}
|
||||||
|
sort?.let { params += "sort=$it" }
|
||||||
|
promoted?.let { params += "promoted=$it" }
|
||||||
|
params += "lang=${Locale.getDefault().language}"
|
||||||
|
|
||||||
|
val url = "${amoBaseUrl()}/api/v5/addons/search/?${params.joinToString("&")}"
|
||||||
|
val response = components.core.client.fetch(
|
||||||
|
Request(
|
||||||
|
url = url,
|
||||||
|
method = Request.Method.GET,
|
||||||
|
headers = MutableHeaders("Accept" to "application/json"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if (response.status !in 200..299) {
|
||||||
|
return emptyList()
|
||||||
|
}
|
||||||
|
|
||||||
|
val body = response.body.useStream { stream ->
|
||||||
|
String(stream.readAllBytes(), Charsets.UTF_8)
|
||||||
|
}
|
||||||
|
val json = JSONObject(body)
|
||||||
|
val results = json.optJSONArray("results") ?: return emptyList()
|
||||||
|
val language = Locale.getDefault().language
|
||||||
|
val listings = mutableListOf<AddonListing>()
|
||||||
|
for (index in 0 until results.length()) {
|
||||||
|
val entry = results.optJSONObject(index) ?: continue
|
||||||
|
entry.toAddonListing(language)?.let { listings += it }
|
||||||
|
}
|
||||||
|
return listings
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun JSONObject.toAddonListing(language: String): AddonListing? {
|
||||||
|
val id = optString("guid").takeIf { it.isNotBlank() } ?: return null
|
||||||
|
val currentVersion = optJSONObject("current_version") ?: return null
|
||||||
|
val file = currentVersion.optJSONObject("file")
|
||||||
|
?: currentVersion.optJSONArray("files")?.optJSONObject(0)
|
||||||
|
val downloadUrl = file?.optString("url").orEmpty()
|
||||||
|
val latestVersion = currentVersion.optString("version").orEmpty()
|
||||||
|
if (downloadUrl.isBlank() || latestVersion.isBlank()) return null
|
||||||
|
|
||||||
|
val name = pickTranslation("name", language) ?: return null
|
||||||
|
val ratings = optJSONObject("ratings")
|
||||||
|
val ratingAverage = ratings?.optDouble("average")?.takeIf { !it.isNaN() && it > 0.0 }
|
||||||
|
val ratingReviews = ratings?.optInt("text_count", -1)?.takeIf { it >= 0 }?.toLong()
|
||||||
|
val author = optJSONArray("authors")?.optJSONObject(0)
|
||||||
|
val averageDailyUsers = optInt("average_daily_users", -1).takeIf { it >= 0 }?.toLong()
|
||||||
|
|
||||||
|
val previews = mutableListOf<AddonListingPreview>()
|
||||||
|
optJSONArray("previews")?.let { arr ->
|
||||||
|
for (i in 0 until arr.length()) {
|
||||||
|
val p = arr.optJSONObject(i) ?: continue
|
||||||
|
val imageUrl = p.optString("image_url").takeIf { it.isNotBlank() } ?: continue
|
||||||
|
previews += AddonListingPreview(
|
||||||
|
imageUrl = imageUrl,
|
||||||
|
thumbnailUrl = p.optString("thumbnail_url").takeIf { it.isNotBlank() },
|
||||||
|
caption = p.pickTranslation("caption", language),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val permissions = file?.stringArray("permissions").orEmpty()
|
||||||
|
val hostPermissions = file?.stringArray("host_permissions").orEmpty()
|
||||||
|
val optionalPermissions = file?.stringArray("optional_permissions").orEmpty()
|
||||||
|
val dataCollectionPermissions = file?.stringArray("data_collection_permissions").orEmpty()
|
||||||
|
val fileSize = file?.optLong("size", -1L)?.takeIf { it >= 0L }
|
||||||
|
|
||||||
|
val license = currentVersion.optJSONObject("license")
|
||||||
|
val licenseName = license?.pickTranslation("name", language)
|
||||||
|
val licenseUrl = license?.pickTranslatedUrl("url", language)
|
||||||
|
|
||||||
|
val supportUrl = pickTranslatedUrl("support_url", language)
|
||||||
|
val supportEmail = pickTranslation("support_email", language)
|
||||||
|
|
||||||
|
val categories = mutableListOf<String>()
|
||||||
|
optJSONObject("categories")?.let { cats ->
|
||||||
|
val keys = cats.keys()
|
||||||
|
while (keys.hasNext()) {
|
||||||
|
val key = keys.next()
|
||||||
|
val arr = cats.optJSONArray(key) ?: continue
|
||||||
|
for (i in 0 until arr.length()) {
|
||||||
|
val c = arr.optString(i)
|
||||||
|
if (!c.isNullOrBlank() && !categories.contains(c)) categories += c
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return AddonListing(
|
||||||
|
id = id,
|
||||||
|
name = name,
|
||||||
|
summary = pickTranslation("summary", language),
|
||||||
|
description = pickTranslation("description", language),
|
||||||
|
iconUrl = optString("icon_url").takeIf { it.isNotBlank() },
|
||||||
|
latestVersion = latestVersion,
|
||||||
|
downloadUrl = downloadUrl,
|
||||||
|
ratingAverage = ratingAverage,
|
||||||
|
ratingReviews = ratingReviews,
|
||||||
|
authorName = author?.optString("name")?.takeIf { it.isNotBlank() },
|
||||||
|
authorUrl = author?.optString("url")?.takeIf { it.isNotBlank() },
|
||||||
|
homepageUrl = pickHomepage(language),
|
||||||
|
detailUrl = optString("url").orEmpty(),
|
||||||
|
ratingUrl = optString("ratings_url").takeIf { it.isNotBlank() },
|
||||||
|
averageDailyUsers = averageDailyUsers,
|
||||||
|
promoted = pickPromoted(),
|
||||||
|
previews = previews,
|
||||||
|
permissions = permissions,
|
||||||
|
hostPermissions = hostPermissions,
|
||||||
|
optionalPermissions = optionalPermissions,
|
||||||
|
dataCollectionPermissions = dataCollectionPermissions,
|
||||||
|
fileSize = fileSize,
|
||||||
|
lastUpdated = optString("last_updated").takeIf { it.isNotBlank() && it != "null" },
|
||||||
|
licenseName = licenseName,
|
||||||
|
licenseUrl = licenseUrl,
|
||||||
|
supportUrl = supportUrl,
|
||||||
|
supportEmail = supportEmail,
|
||||||
|
categories = categories,
|
||||||
|
hasPrivacyPolicy = optBoolean("has_privacy_policy", false),
|
||||||
|
slug = optString("slug").takeIf { it.isNotBlank() && it != "null" },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun JSONObject.stringArray(key: String): List<String> {
|
||||||
|
val arr = optJSONArray(key) ?: return emptyList()
|
||||||
|
val out = mutableListOf<String>()
|
||||||
|
for (i in 0 until arr.length()) {
|
||||||
|
val s = arr.optString(i)
|
||||||
|
if (!s.isNullOrBlank() && s != "null") out += s
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun JSONObject.pickPromoted(): AddonStorePromoted {
|
||||||
|
val categories = optJSONObject("promoted")?.optJSONArray("category")
|
||||||
|
?: optJSONArray("promoted")
|
||||||
|
val singleCategory = optJSONObject("promoted")?.optString("category")
|
||||||
|
val candidates = buildList<String> {
|
||||||
|
if (categories is JSONArray) {
|
||||||
|
for (i in 0 until categories.length()) {
|
||||||
|
categories.optString(i)?.let { add(it) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!singleCategory.isNullOrBlank()) add(singleCategory)
|
||||||
|
}
|
||||||
|
return when {
|
||||||
|
candidates.any { it.equals("recommended", ignoreCase = true) } -> AddonStorePromoted.RECOMMENDED
|
||||||
|
candidates.any { it.equals("line", ignoreCase = true) } -> AddonStorePromoted.LINE
|
||||||
|
else -> AddonStorePromoted.NONE
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun AddonStoreApp.queryValue(): String = when (this) {
|
||||||
|
AddonStoreApp.ANDROID -> "android"
|
||||||
|
AddonStoreApp.FIREFOX -> "firefox"
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun saveManualUpdateAttempt(
|
private fun saveManualUpdateAttempt(
|
||||||
@@ -687,10 +960,62 @@ class GeckoAddonsApiImpl(private val context: Context) : GeckoAddonsApi {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun Addon.needsAmoEnrichment(): Boolean {
|
||||||
|
val hasRating = (rating?.average ?: 0f) > 0f
|
||||||
|
val hasDescription = translatableDescription.values.any { it.isNotBlank() }
|
||||||
|
return !hasRating || !hasDescription
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun JSONObject.pickHomepage(language: String): String? {
|
||||||
|
return pickTranslatedUrl("homepage", language)
|
||||||
|
}
|
||||||
|
|
||||||
|
// AMO represents URL-bearing translated fields either as a flat translated map
|
||||||
|
// (locale → url), a plain string, or a nested { url: {locale: url, ...}, outgoing: {...} }.
|
||||||
|
private fun JSONObject.pickTranslatedUrl(key: String, language: String): String? {
|
||||||
|
if (isNull(key)) return null
|
||||||
|
val value = opt(key)
|
||||||
|
if (value is JSONObject && value.has("url")) {
|
||||||
|
return value.pickTranslation("url", language)
|
||||||
|
}
|
||||||
|
return pickTranslation(key, language)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun JSONObject.pickTranslation(key: String, language: String): String? {
|
||||||
|
if (isNull(key)) return null
|
||||||
|
return when (val value = opt(key)) {
|
||||||
|
is String -> value.takeIf { it.isNotBlank() }
|
||||||
|
is JSONObject -> {
|
||||||
|
val lower = language.lowercase(Locale.ROOT)
|
||||||
|
// Filter out keys whose value is JSONObject.NULL — optString would
|
||||||
|
// return the literal string "null" for those.
|
||||||
|
val populatedKeys = value.keys().asSequence()
|
||||||
|
.filter { !value.isNull(it) }
|
||||||
|
.toList()
|
||||||
|
val defaultLocale = value.optString("_default").takeIf {
|
||||||
|
it.isNotBlank() && it != "null"
|
||||||
|
}
|
||||||
|
val candidateKeys = listOfNotNull(
|
||||||
|
populatedKeys.firstOrNull { it.equals(lower, ignoreCase = true) },
|
||||||
|
populatedKeys.firstOrNull { it.lowercase(Locale.ROOT).startsWith("$lower-") },
|
||||||
|
defaultLocale?.takeIf { populatedKeys.contains(it) },
|
||||||
|
"en-US".takeIf { populatedKeys.contains(it) },
|
||||||
|
populatedKeys.firstOrNull { it != "_default" },
|
||||||
|
)
|
||||||
|
candidateKeys
|
||||||
|
.asSequence()
|
||||||
|
.map { value.optString(it) }
|
||||||
|
.firstOrNull { it.isNotBlank() }
|
||||||
|
}
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun Addon.toPigeon(
|
private fun Addon.toPigeon(
|
||||||
context: Context,
|
context: Context,
|
||||||
isAutoUpdateEnabled: Boolean,
|
isAutoUpdateEnabled: Boolean,
|
||||||
isLocalFileInstalled: Boolean,
|
isLocalFileInstalled: Boolean,
|
||||||
|
storeInfo: AddonStoreInfo? = null,
|
||||||
): AddonInfo {
|
): AddonInfo {
|
||||||
val installedState = installedState
|
val installedState = installedState
|
||||||
val localizedName = displayName(context)
|
val localizedName = displayName(context)
|
||||||
@@ -701,24 +1026,34 @@ private fun Addon.toPigeon(
|
|||||||
""
|
""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val geckoRatingAverage = rating?.average?.toDouble()?.takeIf { it > 0.0 }
|
||||||
|
val geckoRatingReviews = rating?.reviews?.toLong()?.takeIf { it > 0L }
|
||||||
|
val resolvedSummary = localizedSummary?.takeIf { it.isNotBlank() } ?: storeInfo?.summary
|
||||||
|
val resolvedDescription = localizedDescription.ifBlank { storeInfo?.description.orEmpty() }
|
||||||
|
val resolvedHomepage = homepageUrl.ifBlank { storeInfo?.homepageUrl.orEmpty() }
|
||||||
|
val resolvedDetailUrl = detailUrl.ifBlank { storeInfo?.detailUrl.orEmpty() }
|
||||||
|
val resolvedRatingUrl = ratingUrl.ifBlank { storeInfo?.ratingUrl.orEmpty() }
|
||||||
|
val resolvedAuthorName = author?.name ?: storeInfo?.authorName
|
||||||
|
val resolvedAuthorUrl = author?.url ?: storeInfo?.authorUrl
|
||||||
|
|
||||||
return AddonInfo(
|
return AddonInfo(
|
||||||
id = id,
|
id = id,
|
||||||
displayName = localizedName,
|
displayName = localizedName,
|
||||||
summary = localizedSummary,
|
summary = resolvedSummary,
|
||||||
description = localizedDescription,
|
description = resolvedDescription,
|
||||||
downloadUrl = downloadUrl,
|
downloadUrl = downloadUrl,
|
||||||
version = version,
|
version = version,
|
||||||
installedVersion = installedState?.version,
|
installedVersion = installedState?.version,
|
||||||
translatedPermissions = translatePermissions(context),
|
translatedPermissions = translatePermissions(context),
|
||||||
translatedRequiredDataCollectionPermissions =
|
translatedRequiredDataCollectionPermissions =
|
||||||
translateRequiredDataCollectionPermissions(context),
|
translateRequiredDataCollectionPermissions(context),
|
||||||
authorName = author?.name,
|
authorName = resolvedAuthorName,
|
||||||
authorUrl = author?.url,
|
authorUrl = resolvedAuthorUrl,
|
||||||
homepageUrl = homepageUrl,
|
homepageUrl = resolvedHomepage,
|
||||||
detailUrl = detailUrl,
|
detailUrl = resolvedDetailUrl,
|
||||||
ratingUrl = ratingUrl,
|
ratingUrl = resolvedRatingUrl,
|
||||||
ratingAverage = rating?.average?.toDouble(),
|
ratingAverage = geckoRatingAverage ?: storeInfo?.ratingAverage,
|
||||||
ratingReviews = rating?.reviews?.toLong(),
|
ratingReviews = geckoRatingReviews ?: storeInfo?.ratingReviews,
|
||||||
createdAt = createdAt,
|
createdAt = createdAt,
|
||||||
updatedAt = updatedAt,
|
updatedAt = updatedAt,
|
||||||
icon = provideIcon()?.toWebPBytes(),
|
icon = provideIcon()?.toWebPBytes(),
|
||||||
|
|||||||
+539
-206
File diff suppressed because it is too large
Load Diff
@@ -41,7 +41,11 @@ export 'src/pigeons/gecko.g.dart'
|
|||||||
AddonDisabledReason,
|
AddonDisabledReason,
|
||||||
AddonIncognito,
|
AddonIncognito,
|
||||||
AddonInfo,
|
AddonInfo,
|
||||||
|
AddonListing,
|
||||||
|
AddonListingPreview,
|
||||||
|
AddonStoreApp,
|
||||||
AddonStoreInfo,
|
AddonStoreInfo,
|
||||||
|
AddonStorePromoted,
|
||||||
AddonUpdateAttemptInfo,
|
AddonUpdateAttemptInfo,
|
||||||
AddonUpdateStatus,
|
AddonUpdateStatus,
|
||||||
AppLinksMode,
|
AppLinksMode,
|
||||||
|
|||||||
@@ -49,6 +49,22 @@ class GeckoAddonService extends GeckoAddonEvents {
|
|||||||
return _api.getAddonStoreInfo(addonId);
|
return _api.getAddonStoreInfo(addonId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<List<AddonListing>> searchAddonListings({
|
||||||
|
required String query,
|
||||||
|
required AddonStoreApp app,
|
||||||
|
int page = 1,
|
||||||
|
int pageSize = 25,
|
||||||
|
}) {
|
||||||
|
return _api.searchAddonListings(query, app, page, pageSize);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<List<AddonListing>> getFeaturedAddonListings({
|
||||||
|
required AddonStoreApp app,
|
||||||
|
int pageSize = 25,
|
||||||
|
}) {
|
||||||
|
return _api.getFeaturedAddonListings(app, pageSize);
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> invokeAddonAction(
|
Future<void> invokeAddonAction(
|
||||||
String extensionId,
|
String extensionId,
|
||||||
WebExtensionActionType actionType,
|
WebExtensionActionType actionType,
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -749,13 +749,113 @@ class AddonInfo {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum AddonStoreApp { android, firefox }
|
||||||
|
|
||||||
|
enum AddonStorePromoted { none, recommended, line }
|
||||||
|
|
||||||
|
class AddonListingPreview {
|
||||||
|
final String imageUrl;
|
||||||
|
final String? thumbnailUrl;
|
||||||
|
final String? caption;
|
||||||
|
|
||||||
|
const AddonListingPreview({
|
||||||
|
required this.imageUrl,
|
||||||
|
this.thumbnailUrl,
|
||||||
|
this.caption,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
class AddonListing {
|
||||||
|
final String id;
|
||||||
|
final String name;
|
||||||
|
final String? summary;
|
||||||
|
final String? description;
|
||||||
|
final String? iconUrl;
|
||||||
|
final String latestVersion;
|
||||||
|
final String downloadUrl;
|
||||||
|
final double? ratingAverage;
|
||||||
|
final int? ratingReviews;
|
||||||
|
final String? authorName;
|
||||||
|
final String? authorUrl;
|
||||||
|
final String? homepageUrl;
|
||||||
|
final String detailUrl;
|
||||||
|
final String? ratingUrl;
|
||||||
|
final int? averageDailyUsers;
|
||||||
|
final AddonStorePromoted promoted;
|
||||||
|
final List<AddonListingPreview> previews;
|
||||||
|
final List<String> permissions;
|
||||||
|
final List<String> hostPermissions;
|
||||||
|
final List<String> optionalPermissions;
|
||||||
|
final List<String> dataCollectionPermissions;
|
||||||
|
final int? fileSize;
|
||||||
|
final String? lastUpdated;
|
||||||
|
final String? licenseName;
|
||||||
|
final String? licenseUrl;
|
||||||
|
final String? supportUrl;
|
||||||
|
final String? supportEmail;
|
||||||
|
final List<String> categories;
|
||||||
|
final bool hasPrivacyPolicy;
|
||||||
|
final String? slug;
|
||||||
|
|
||||||
|
const AddonListing({
|
||||||
|
required this.id,
|
||||||
|
required this.name,
|
||||||
|
this.summary,
|
||||||
|
this.description,
|
||||||
|
this.iconUrl,
|
||||||
|
required this.latestVersion,
|
||||||
|
required this.downloadUrl,
|
||||||
|
this.ratingAverage,
|
||||||
|
this.ratingReviews,
|
||||||
|
this.authorName,
|
||||||
|
this.authorUrl,
|
||||||
|
this.homepageUrl,
|
||||||
|
required this.detailUrl,
|
||||||
|
this.ratingUrl,
|
||||||
|
this.averageDailyUsers,
|
||||||
|
this.promoted = AddonStorePromoted.none,
|
||||||
|
this.previews = const [],
|
||||||
|
this.permissions = const [],
|
||||||
|
this.hostPermissions = const [],
|
||||||
|
this.optionalPermissions = const [],
|
||||||
|
this.dataCollectionPermissions = const [],
|
||||||
|
this.fileSize,
|
||||||
|
this.lastUpdated,
|
||||||
|
this.licenseName,
|
||||||
|
this.licenseUrl,
|
||||||
|
this.supportUrl,
|
||||||
|
this.supportEmail,
|
||||||
|
this.categories = const [],
|
||||||
|
this.hasPrivacyPolicy = false,
|
||||||
|
this.slug,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
class AddonStoreInfo {
|
class AddonStoreInfo {
|
||||||
final String latestVersion;
|
final String latestVersion;
|
||||||
final String latestXpiUrl;
|
final String latestXpiUrl;
|
||||||
|
final double? ratingAverage;
|
||||||
|
final int? ratingReviews;
|
||||||
|
final String? summary;
|
||||||
|
final String? description;
|
||||||
|
final String? homepageUrl;
|
||||||
|
final String? detailUrl;
|
||||||
|
final String? ratingUrl;
|
||||||
|
final String? authorName;
|
||||||
|
final String? authorUrl;
|
||||||
|
|
||||||
const AddonStoreInfo({
|
const AddonStoreInfo({
|
||||||
required this.latestVersion,
|
required this.latestVersion,
|
||||||
required this.latestXpiUrl,
|
required this.latestXpiUrl,
|
||||||
|
this.ratingAverage,
|
||||||
|
this.ratingReviews,
|
||||||
|
this.summary,
|
||||||
|
this.description,
|
||||||
|
this.homepageUrl,
|
||||||
|
this.detailUrl,
|
||||||
|
this.ratingUrl,
|
||||||
|
this.authorName,
|
||||||
|
this.authorUrl,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1853,6 +1953,20 @@ abstract class GeckoAddonsApi {
|
|||||||
@async
|
@async
|
||||||
AddonStoreInfo? getAddonStoreInfo(String addonId);
|
AddonStoreInfo? getAddonStoreInfo(String addonId);
|
||||||
|
|
||||||
|
@async
|
||||||
|
List<AddonListing> searchAddonListings(
|
||||||
|
String query,
|
||||||
|
AddonStoreApp app,
|
||||||
|
int page,
|
||||||
|
int pageSize,
|
||||||
|
);
|
||||||
|
|
||||||
|
@async
|
||||||
|
List<AddonListing> getFeaturedAddonListings(
|
||||||
|
AddonStoreApp app,
|
||||||
|
int pageSize,
|
||||||
|
);
|
||||||
|
|
||||||
void invokeAddonAction(String extensionId, WebExtensionActionType actionType);
|
void invokeAddonAction(String extensionId, WebExtensionActionType actionType);
|
||||||
|
|
||||||
@async
|
@async
|
||||||
|
|||||||
Reference in New Issue
Block a user