addon store initial

This commit is contained in:
Fabian Freund
2026-04-19 09:15:08 +02:00
parent 57c3dcca86
commit 5bf3afd5e1
23 changed files with 3381 additions and 597 deletions
@@ -27,6 +27,10 @@ part of 'routes.dart';
name: 'AddonDetailsRoute',
path: 'details/:addonId',
),
TypedGoRoute<AddonListingDetailsRoute>(
name: 'AddonListingDetailsRoute',
path: 'listing/:addonId',
),
TypedGoRoute<AddonPermissionsRoute>(
name: 'AddonPermissionsRoute',
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 {
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/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_listing_details.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/bangs/data/models/bang.dart';
@@ -942,6 +942,11 @@ RouteBase get $addonManagerRoute => GoRouteData.$route(
name: 'AddonDetailsRoute',
factory: $AddonDetailsRoute._fromState,
),
GoRouteData.$route(
path: 'listing/:addonId',
name: 'AddonListingDetailsRoute',
factory: $AddonListingDetailsRoute._fromState,
),
GoRouteData.$route(
path: 'permissions/:addonId',
name: 'AddonPermissionsRoute',
@@ -1001,6 +1006,36 @@ mixin $AddonDetailsRoute on GoRouteData {
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 {
static AddonPermissionsRoute _fromState(GoRouterState state) =>
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_annotation/experimental/persist.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/user/data/providers.dart';
@@ -137,6 +138,50 @@ Future<AddonStoreInfo?> addonStoreInfo(Ref ref, String 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()
Future<AddonUpdateAttemptInfo?> lastAddonUpdateAttempt(
Ref ref,
@@ -173,6 +173,360 @@ final class AddonStoreInfoFamily extends $Family
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)
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/>.
*/
import 'package:flutter/material.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';
@@ -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/widgets/addon_ui.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';
class AddonDetailsScreen extends ConsumerWidget {
@@ -163,6 +165,7 @@ class _AddonHeader extends StatelessWidget {
final theme = Theme.of(context);
return Card(
color: Theme.of(context).colorScheme.surfaceContainerHigh,
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
@@ -188,7 +191,6 @@ class _AddonHeader extends StatelessWidget {
const SizedBox(height: 12),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
Chip(
label: Text(
@@ -204,7 +206,7 @@ class _AddonHeader extends StatelessWidget {
avatar: const Icon(Icons.star, size: 18),
label: Text(
'${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),
const SizedBox(height: 8),
Card(
color: Theme.of(context).colorScheme.surfaceContainerHigh,
child: Column(
children: [
if (addon.isSupported)
@@ -423,7 +426,7 @@ class _UpdatesSection extends ConsumerWidget {
addon.installedVersion != availableVersion;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text('Updates', style: theme.textTheme.titleMedium),
const SizedBox(height: 8),
@@ -566,20 +569,42 @@ void _reportUpdateResult(
}
}
class _DescriptionCard extends StatelessWidget {
class _DescriptionCard extends ConsumerWidget {
final AddonInfo addon;
const _DescriptionCard({required this.addon});
@override
Widget build(BuildContext context) {
final description = addon.description;
Widget build(BuildContext context, WidgetRef ref) {
final markdownAsync = ref.watch(addonDescriptionMarkdownProvider(addon.id));
return Card(
color: Theme.of(context).colorScheme.surfaceContainerHigh,
child: Padding(
padding: const EdgeInsets.all(16),
child: Text(
description.isNotEmpty ? description : 'No description provided.',
child: markdownAsync.when(
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
Widget build(BuildContext context) {
return Card(
color: Theme.of(context).colorScheme.surfaceContainerHigh,
child: Column(
children: [
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
* 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_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/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/geckoview/features/browser/presentation/dialogs/install_local_addon_dialog.dart';
import 'package:weblibre/utils/ui_helper.dart';
class AddonManagerScreen extends ConsumerWidget {
@@ -35,67 +38,107 @@ class AddonManagerScreen extends ConsumerWidget {
Future<void> refresh() => ref.read(addonListProvider.notifier).refresh();
return Scaffold(
appBar: AppBar(
title: const Text('Extensions'),
actions: [
IconButton(
onPressed: addonsAsync.isLoading ? null : refresh,
icon: const Icon(Icons.refresh),
return DefaultTabController(
length: 2,
child: Scaffold(
appBar: AppBar(
title: const Text('Extensions'),
bottom: const TabBar(
tabs: [
Tab(text: 'Installed'),
Tab(text: 'Browse'),
],
),
_TriggerAllUpdatesButton(
enabled: addonsAsync.maybeWhen(
data: (addons) =>
addons.any((a) => a.isInstalled && a.isSupported),
orElse: () => false,
actions: [
IconButton(
onPressed: addonsAsync.isLoading ? null : refresh,
icon: const Icon(Icons.refresh),
),
),
],
),
body: addonsAsync.when(
skipLoadingOnReload: true,
skipError: true,
data: (addons) => RefreshIndicator(
onRefresh: refresh,
child: _AddonList(addons: addons),
_AddonManagerOverflowMenu(
canCheckForUpdates: addonsAsync.maybeWhen(
data: (addons) =>
addons.any((a) => a.isInstalled && a.isSupported),
orElse: () => false,
),
),
],
),
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 {
final bool enabled;
enum _AddonManagerMenuAction { checkForUpdates, installFromFile }
const _TriggerAllUpdatesButton({required this.enabled});
class _AddonManagerOverflowMenu extends ConsumerWidget {
final bool canCheckForUpdates;
const _AddonManagerOverflowMenu({required this.canCheckForUpdates});
@override
Widget build(BuildContext context, WidgetRef ref) {
final busy = ref.watch(
final updatesBusy = ref.watch(
bulkAddonUpdateProvider.select((value) => value.isLoading),
);
return IconButton(
onPressed: enabled && !busy
? () async {
await ref.read(bulkAddonUpdateProvider.notifier).triggerAll();
if (!context.mounted) return;
showInfoMessage(
context,
'Background update checks started for installed extensions',
);
}
: null,
icon: busy
return PopupMenuButton<_AddonManagerMenuAction>(
icon: updatesBusy
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.system_update_alt),
tooltip: 'Check all installed extensions for updates',
: const Icon(Icons.more_vert),
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
.where((a) => a.isInstalled && a.isSupported && !a.isEnabled)
.toList();
final recommended = addons.where((a) => !a.isInstalled).toList();
final unsupported = addons
.where((a) => a.isInstalled && !a.isSupported)
.toList();
final installed = enabled.length + disabled.length + unsupported.length;
return ListView(
padding: const EdgeInsets.all(16),
children: [
const Card(
child: ListTile(
leading: Icon(Icons.info_outline),
title: Text('Addon updates run in the background'),
subtitle: Text(
'Use each extension detail screen to view its last update result or trigger a manual check.',
),
),
),
if (enabled.isNotEmpty) ...[
const SizedBox(height: 16),
const _Section(title: 'Enabled'),
for (final addon in enabled) _AddonCard(addon: addon),
],
if (disabled.isNotEmpty) ...[
const SizedBox(height: 16),
const _Section(title: 'Disabled'),
for (final addon in disabled) _AddonCard(addon: addon),
],
if (recommended.isNotEmpty) ...[
const SizedBox(height: 16),
const _Section(title: 'Available'),
for (final addon in recommended)
_AddonCard(
addon: addon,
action: _InstallAction(addon: addon),
),
],
if (unsupported.isNotEmpty) ...[
const SizedBox(height: 16),
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'),
return FadingScroll(
fadingSize: 25,
builder: (context, controller) {
return ListView(
controller: controller,
padding: const EdgeInsets.all(16),
children: [
if (enabled.isNotEmpty) ...[
const SizedBox(height: 16),
const _Section(title: 'Enabled'),
for (final addon in enabled) _AddonCard(addon: addon),
],
if (disabled.isNotEmpty) ...[
const SizedBox(height: 16),
const _Section(title: 'Disabled'),
for (final addon in disabled) _AddonCard(addon: addon),
],
if (unsupported.isNotEmpty) ...[
const SizedBox(height: 16),
const _Section(title: 'Unsupported'),
for (final addon in unsupported)
_AddonCard(
addon: addon,
action: _UninstallAction(addon: addon),
),
],
if (installed == 0)
const Padding(
padding: EdgeInsets.only(top: 48),
child: Center(
child: Text(
'No extensions installed yet.\nBrowse the store to find some.',
textAlign: TextAlign.center,
),
),
),
],
);
},
);
}
}
@@ -236,6 +251,7 @@ class _AddonCard extends ConsumerWidget {
final busy = ref.watch(addonBusyIdsProvider).contains(addon.id);
return Card(
color: Theme.of(context).colorScheme.surfaceContainerHigh,
margin: const EdgeInsets.only(bottom: 12),
child: InkWell(
onTap: busy
@@ -267,7 +283,6 @@ class _AddonCard extends ConsumerWidget {
const SizedBox(height: 8),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
if (addon.isAllowedInPrivateBrowsing)
const Chip(label: Text('Private Browsing')),
@@ -71,8 +71,9 @@ class AddonPermissionsScreen extends ConsumerWidget {
padding: const EdgeInsets.all(16),
children: [
if (permissions.isEmpty && dataCollection.isEmpty)
const Card(
child: ListTile(
Card(
color: Theme.of(context).colorScheme.surfaceContainerHigh,
child: const ListTile(
leading: Icon(Icons.verified_user_outlined),
title: Text('No special permissions listed'),
subtitle: Text(
@@ -87,6 +88,7 @@ class AddonPermissionsScreen extends ConsumerWidget {
),
const SizedBox(height: 8),
Card(
color: Theme.of(context).colorScheme.surfaceContainerHigh,
child: Column(
children: [
for (final permission in permissions)
@@ -106,6 +108,7 @@ class AddonPermissionsScreen extends ConsumerWidget {
),
const SizedBox(height: 8),
Card(
color: Theme.of(context).colorScheme.surfaceContainerHigh,
child: Column(
children: [
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. `&lt;b&gt;not&lt;/b&gt;` 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('&lt;', '<')
.replaceAll('&gt;', '>')
.replaceAll('&quot;', '"')
.replaceAll('&#39;', "'")
.replaceAll('&apos;', "'")
.replaceAll('&nbsp;', ' ')
.replaceAll('&amp;', '&');
}
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);
}
@@ -1809,27 +1809,6 @@ class _ExtensionsCard extends HookConsumerWidget {
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,
);
},
),
],
),
),
@@ -33,7 +33,7 @@ final class IntentGatekeeperProvider
IntentGatekeeper create() => IntentGatekeeper();
}
String _$intentGatekeeperHash() => r'94df8850478ad6695eb14752e82af1919ea8a077';
String _$intentGatekeeperHash() => r'0ab4a96dde7a21df5dd32d034f841bf4d40dbb10';
abstract class _$IntentGatekeeper
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:weblibre/core/routing/routes.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/sections.dart';
@@ -47,7 +46,6 @@ class ExtensionsSettingsScreen extends StatelessWidget {
children: const [
SettingSection(name: 'Extensions'),
_ManageExtensionsTile(),
_InstallLocalAddonTile(),
_AddonCollectionTile(),
SettingSection(name: 'Updates'),
_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 {
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;
}
}