new extension management
This commit is contained in:
@@ -209,38 +209,6 @@
|
||||
android:name="eu.weblibre.flutter_mozilla_components.activities.AuthIntentReceiverActivity"
|
||||
android:exported="false" />
|
||||
|
||||
<activity
|
||||
android:theme="@style/AddonsActivityTheme"
|
||||
android:name="eu.weblibre.flutter_mozilla_components.addons.AddonsActivity"
|
||||
android:label="@string/mozac_feature_addons_addons"
|
||||
android:exported="false"
|
||||
android:parentActivityName=".MainActivity" />
|
||||
|
||||
<activity
|
||||
android:theme="@style/AppTheme"
|
||||
android:name="eu.weblibre.flutter_mozilla_components.addons.AddonDetailsActivity"
|
||||
android:exported="false"
|
||||
android:label="@string/mozac_feature_addons_addons" />
|
||||
|
||||
<activity
|
||||
android:name="eu.weblibre.flutter_mozilla_components.addons.InstalledAddonDetailsActivity"
|
||||
android:label="@string/mozac_feature_addons_addons"
|
||||
android:parentActivityName="eu.weblibre.flutter_mozilla_components.addons.AddonsActivity"
|
||||
android:exported="false"
|
||||
android:theme="@style/AppTheme" />
|
||||
|
||||
<activity
|
||||
android:name="eu.weblibre.flutter_mozilla_components.addons.PermissionsDetailsActivity"
|
||||
android:label="@string/mozac_feature_addons_addons"
|
||||
android:exported="false"
|
||||
android:theme="@style/AppTheme" />
|
||||
|
||||
<activity
|
||||
android:name="eu.weblibre.flutter_mozilla_components.addons.AddonInternalSettingsActivity"
|
||||
android:label="@string/mozac_feature_addons_addons"
|
||||
android:exported="false"
|
||||
android:theme="@style/AppTheme" />
|
||||
|
||||
<activity
|
||||
android:name="eu.weblibre.flutter_mozilla_components.addons.WebExtensionActionPopupActivity"
|
||||
android:label="@string/mozac_feature_addons_addons"
|
||||
|
||||
@@ -34,8 +34,4 @@
|
||||
<item name="android:statusBarColor">@android:color/transparent</item>
|
||||
<item name="android:navigationBarColor">@android:color/transparent</item>
|
||||
</style>
|
||||
|
||||
<style name="AddonsActivityTheme" parent="AppTheme">
|
||||
<item name="mozac_primary_text_color">@color/photonDarkGrey90</item>
|
||||
</style>
|
||||
</resources>
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
/*
|
||||
* 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/>.
|
||||
*/
|
||||
part of 'routes.dart';
|
||||
|
||||
@TypedGoRoute<AddonManagerRoute>(
|
||||
name: 'AddonManagerRoute',
|
||||
path: '/addons',
|
||||
routes: [
|
||||
TypedGoRoute<AddonDetailsRoute>(
|
||||
name: 'AddonDetailsRoute',
|
||||
path: 'details/:addonId',
|
||||
),
|
||||
TypedGoRoute<AddonPermissionsRoute>(
|
||||
name: 'AddonPermissionsRoute',
|
||||
path: 'permissions/:addonId',
|
||||
),
|
||||
TypedGoRoute<AddonInternalSettingsRoute>(
|
||||
name: 'AddonInternalSettingsRoute',
|
||||
path: 'settings/:addonId',
|
||||
),
|
||||
],
|
||||
)
|
||||
class AddonManagerRoute extends GoRouteData with $AddonManagerRoute {
|
||||
const AddonManagerRoute();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, GoRouterState state) {
|
||||
return const AddonManagerScreen();
|
||||
}
|
||||
}
|
||||
|
||||
class AddonDetailsRoute extends GoRouteData with $AddonDetailsRoute {
|
||||
final String addonId;
|
||||
|
||||
const AddonDetailsRoute({required this.addonId});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, GoRouterState state) {
|
||||
return AddonDetailsScreen(addonId: addonId);
|
||||
}
|
||||
}
|
||||
|
||||
class AddonPermissionsRoute extends GoRouteData with $AddonPermissionsRoute {
|
||||
final String addonId;
|
||||
|
||||
const AddonPermissionsRoute({required this.addonId});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, GoRouterState state) {
|
||||
return AddonPermissionsScreen(addonId: addonId);
|
||||
}
|
||||
}
|
||||
|
||||
class AddonInternalSettingsRoute extends GoRouteData
|
||||
with $AddonInternalSettingsRoute {
|
||||
final String addonId;
|
||||
|
||||
const AddonInternalSettingsRoute({required this.addonId});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, GoRouterState state) {
|
||||
return AddonInternalSettingsScreen(addonId: addonId);
|
||||
}
|
||||
}
|
||||
@@ -28,6 +28,10 @@ import 'package:weblibre/core/routing/widgets/bottom_sheet_page.dart';
|
||||
import 'package:weblibre/core/routing/widgets/dialog_page.dart';
|
||||
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_manager.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/presentation/screens/categories.dart';
|
||||
import 'package:weblibre/features/bangs/presentation/screens/category.dart';
|
||||
@@ -97,6 +101,7 @@ import 'package:weblibre/features/web_feed/presentation/select_feed_dialog.dart'
|
||||
part 'routes.bangs.dart';
|
||||
part 'routes.bookmarks.dart';
|
||||
part 'routes.browser.dart';
|
||||
part 'routes.addons.dart';
|
||||
part 'routes.feeds.dart';
|
||||
part 'routes.g.dart';
|
||||
part 'routes.history.dart';
|
||||
|
||||
@@ -13,6 +13,7 @@ List<RouteBase> get $appRoutes => [
|
||||
$bangMenuRoute,
|
||||
$bookmarksRoute,
|
||||
$browserRoute,
|
||||
$addonManagerRoute,
|
||||
$feedListRoute,
|
||||
$historyRoute,
|
||||
$profileListRoute,
|
||||
@@ -931,6 +932,125 @@ extension<T extends Enum> on Map<T, String> {
|
||||
entries.where((element) => element.value == value).firstOrNull?.key;
|
||||
}
|
||||
|
||||
RouteBase get $addonManagerRoute => GoRouteData.$route(
|
||||
path: '/addons',
|
||||
name: 'AddonManagerRoute',
|
||||
factory: $AddonManagerRoute._fromState,
|
||||
routes: [
|
||||
GoRouteData.$route(
|
||||
path: 'details/:addonId',
|
||||
name: 'AddonDetailsRoute',
|
||||
factory: $AddonDetailsRoute._fromState,
|
||||
),
|
||||
GoRouteData.$route(
|
||||
path: 'permissions/:addonId',
|
||||
name: 'AddonPermissionsRoute',
|
||||
factory: $AddonPermissionsRoute._fromState,
|
||||
),
|
||||
GoRouteData.$route(
|
||||
path: 'settings/:addonId',
|
||||
name: 'AddonInternalSettingsRoute',
|
||||
factory: $AddonInternalSettingsRoute._fromState,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
mixin $AddonManagerRoute on GoRouteData {
|
||||
static AddonManagerRoute _fromState(GoRouterState state) =>
|
||||
const AddonManagerRoute();
|
||||
|
||||
@override
|
||||
String get location => GoRouteData.$location('/addons');
|
||||
|
||||
@override
|
||||
void go(BuildContext context) => context.go(location);
|
||||
|
||||
@override
|
||||
Future<T?> push<T>(BuildContext context) => context.push<T>(location);
|
||||
|
||||
@override
|
||||
void pushReplacement(BuildContext context) =>
|
||||
context.pushReplacement(location);
|
||||
|
||||
@override
|
||||
void replace(BuildContext context) => context.replace(location);
|
||||
}
|
||||
|
||||
mixin $AddonDetailsRoute on GoRouteData {
|
||||
static AddonDetailsRoute _fromState(GoRouterState state) =>
|
||||
AddonDetailsRoute(addonId: state.pathParameters['addonId']!);
|
||||
|
||||
AddonDetailsRoute get _self => this as AddonDetailsRoute;
|
||||
|
||||
@override
|
||||
String get location => GoRouteData.$location(
|
||||
'/addons/details/${Uri.encodeComponent(_self.addonId)}',
|
||||
);
|
||||
|
||||
@override
|
||||
void go(BuildContext context) => context.go(location);
|
||||
|
||||
@override
|
||||
Future<T?> push<T>(BuildContext context) => context.push<T>(location);
|
||||
|
||||
@override
|
||||
void pushReplacement(BuildContext context) =>
|
||||
context.pushReplacement(location);
|
||||
|
||||
@override
|
||||
void replace(BuildContext context) => context.replace(location);
|
||||
}
|
||||
|
||||
mixin $AddonPermissionsRoute on GoRouteData {
|
||||
static AddonPermissionsRoute _fromState(GoRouterState state) =>
|
||||
AddonPermissionsRoute(addonId: state.pathParameters['addonId']!);
|
||||
|
||||
AddonPermissionsRoute get _self => this as AddonPermissionsRoute;
|
||||
|
||||
@override
|
||||
String get location => GoRouteData.$location(
|
||||
'/addons/permissions/${Uri.encodeComponent(_self.addonId)}',
|
||||
);
|
||||
|
||||
@override
|
||||
void go(BuildContext context) => context.go(location);
|
||||
|
||||
@override
|
||||
Future<T?> push<T>(BuildContext context) => context.push<T>(location);
|
||||
|
||||
@override
|
||||
void pushReplacement(BuildContext context) =>
|
||||
context.pushReplacement(location);
|
||||
|
||||
@override
|
||||
void replace(BuildContext context) => context.replace(location);
|
||||
}
|
||||
|
||||
mixin $AddonInternalSettingsRoute on GoRouteData {
|
||||
static AddonInternalSettingsRoute _fromState(GoRouterState state) =>
|
||||
AddonInternalSettingsRoute(addonId: state.pathParameters['addonId']!);
|
||||
|
||||
AddonInternalSettingsRoute get _self => this as AddonInternalSettingsRoute;
|
||||
|
||||
@override
|
||||
String get location => GoRouteData.$location(
|
||||
'/addons/settings/${Uri.encodeComponent(_self.addonId)}',
|
||||
);
|
||||
|
||||
@override
|
||||
void go(BuildContext context) => context.go(location);
|
||||
|
||||
@override
|
||||
Future<T?> push<T>(BuildContext context) => context.push<T>(location);
|
||||
|
||||
@override
|
||||
void pushReplacement(BuildContext context) =>
|
||||
context.pushReplacement(location);
|
||||
|
||||
@override
|
||||
void replace(BuildContext context) => context.replace(location);
|
||||
}
|
||||
|
||||
RouteBase get $feedListRoute => GoRouteData.$route(
|
||||
path: '/feeds',
|
||||
name: 'FeedListRoute',
|
||||
|
||||
@@ -0,0 +1,294 @@
|
||||
/*
|
||||
* 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:convert';
|
||||
|
||||
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/geckoview/domain/providers.dart';
|
||||
import 'package:weblibre/features/user/data/providers.dart';
|
||||
|
||||
part 'providers.g.dart';
|
||||
|
||||
sealed class AddonUpdateOutcome {
|
||||
const AddonUpdateOutcome();
|
||||
}
|
||||
|
||||
class AddonUpdateOutcomeAvailable extends AddonUpdateOutcome {
|
||||
final AddonInfo addon;
|
||||
final String availableVersion;
|
||||
|
||||
const AddonUpdateOutcomeAvailable({
|
||||
required this.addon,
|
||||
required this.availableVersion,
|
||||
});
|
||||
}
|
||||
|
||||
class AddonUpdateOutcomeUpToDate extends AddonUpdateOutcome {
|
||||
const AddonUpdateOutcomeUpToDate();
|
||||
}
|
||||
|
||||
class AddonUpdateOutcomeMissing extends AddonUpdateOutcome {
|
||||
const AddonUpdateOutcomeMissing();
|
||||
}
|
||||
|
||||
sealed class AddonUpdateRunResult {
|
||||
const AddonUpdateRunResult();
|
||||
}
|
||||
|
||||
class AddonUpdateRunDone extends AddonUpdateRunResult {
|
||||
final String? message;
|
||||
|
||||
const AddonUpdateRunDone(this.message);
|
||||
}
|
||||
|
||||
class AddonUpdateRunNoRemoteSource extends AddonUpdateRunResult {
|
||||
const AddonUpdateRunNoRemoteSource();
|
||||
}
|
||||
|
||||
class AddonUpdateRunFailed extends AddonUpdateRunResult {
|
||||
const AddonUpdateRunFailed();
|
||||
}
|
||||
|
||||
String _resolveAvailableVersion(AddonInfo addon, AddonStoreInfo? storeInfo) {
|
||||
final latest = storeInfo?.latestVersion.trim();
|
||||
return (latest != null && latest.isNotEmpty) ? latest : addon.version;
|
||||
}
|
||||
|
||||
@Riverpod()
|
||||
class AddonDetails extends _$AddonDetails {
|
||||
GeckoAddonService get _service => ref.read(addonServiceProvider);
|
||||
|
||||
Future<void> _run(Future<AddonInfo?> Function() action) async {
|
||||
state = const AsyncLoading<AddonInfo?>();
|
||||
state = await AsyncValue.guard(action);
|
||||
|
||||
ref.invalidate(addonListProvider);
|
||||
}
|
||||
|
||||
Future<void> refresh() async {
|
||||
ref.invalidateSelf();
|
||||
await future;
|
||||
}
|
||||
|
||||
Future<void> install() async {
|
||||
final current = state.value;
|
||||
if (current == null) return;
|
||||
|
||||
await _run(() async {
|
||||
await _service.installAddon(Uri.parse(current.downloadUrl));
|
||||
return _service.getAddonById(addonId);
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> uninstall() async {
|
||||
await _run(() async {
|
||||
await _service.uninstallAddon(addonId);
|
||||
return null;
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> setEnabled({required bool enabled}) async {
|
||||
await _run(
|
||||
() => enabled
|
||||
? _service.enableAddon(addonId)
|
||||
: _service.disableAddon(addonId),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> setAllowedInPrivateBrowsing({required bool allowed}) async {
|
||||
await _run(
|
||||
() => _service.setAddonAllowedInPrivateBrowsing(addonId, allowed),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> setAutoUpdateEnabled({required bool enabled}) async {
|
||||
await _run(
|
||||
() => _service.setAddonAutoUpdateEnabledForAddon(addonId, enabled),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<AddonInfo?> build(String addonId) {
|
||||
return _service.getAddonById(addonId);
|
||||
}
|
||||
}
|
||||
|
||||
@Riverpod()
|
||||
Future<AddonStoreInfo?> addonStoreInfo(Ref ref, String addonId) {
|
||||
return ref.read(addonServiceProvider).getAddonStoreInfo(addonId);
|
||||
}
|
||||
|
||||
@Riverpod()
|
||||
Future<AddonUpdateAttemptInfo?> lastAddonUpdateAttempt(
|
||||
Ref ref,
|
||||
String addonId,
|
||||
) {
|
||||
return ref.read(addonServiceProvider).getLastAddonUpdateAttempt(addonId);
|
||||
}
|
||||
|
||||
@Riverpod()
|
||||
class AddonUpdateCheck extends _$AddonUpdateCheck {
|
||||
/// Refreshes store info and returns whether an update is available.
|
||||
Future<AddonUpdateOutcome> resolveAvailableUpdate() async {
|
||||
final storeInfo = await ref
|
||||
.read(addonServiceProvider)
|
||||
.getAddonStoreInfo(addonId);
|
||||
final fresh = await ref
|
||||
.read(addonServiceProvider)
|
||||
.getAddonById(addonId, allowCache: false);
|
||||
|
||||
if (fresh == null) return const AddonUpdateOutcomeMissing();
|
||||
|
||||
final available = _resolveAvailableVersion(fresh, storeInfo);
|
||||
final hasUpdate =
|
||||
fresh.installedVersion != null &&
|
||||
available.isNotEmpty &&
|
||||
fresh.installedVersion != available;
|
||||
|
||||
return hasUpdate
|
||||
? AddonUpdateOutcomeAvailable(addon: fresh, availableVersion: available)
|
||||
: const AddonUpdateOutcomeUpToDate();
|
||||
}
|
||||
|
||||
/// Triggers a remote update and awaits completion. Invalidates dependent
|
||||
/// providers on completion.
|
||||
Future<AddonUpdateRunResult> triggerAndAwait() async {
|
||||
state = const AsyncLoading();
|
||||
|
||||
final result = await AsyncValue.guard<AddonUpdateRunResult>(() async {
|
||||
final AddonUpdateAttemptInfo? attempt;
|
||||
try {
|
||||
attempt = await ref
|
||||
.read(addonServiceProvider)
|
||||
.triggerAddonUpdate(addonId);
|
||||
} catch (error) {
|
||||
final noRemote = error.toString().contains(
|
||||
'No remote update source is available for this locally installed extension.',
|
||||
);
|
||||
return noRemote
|
||||
? const AddonUpdateRunNoRemoteSource()
|
||||
: const AddonUpdateRunFailed();
|
||||
}
|
||||
|
||||
return attempt?.status == AddonUpdateStatus.error
|
||||
? const AddonUpdateRunFailed()
|
||||
: AddonUpdateRunDone(attempt?.message);
|
||||
});
|
||||
|
||||
state = result;
|
||||
ref.invalidate(addonDetailsProvider(addonId));
|
||||
ref.invalidate(lastAddonUpdateAttemptProvider(addonId));
|
||||
|
||||
return result.value ?? const AddonUpdateRunFailed();
|
||||
}
|
||||
|
||||
@override
|
||||
AsyncValue<AddonUpdateRunResult> build(String addonId) =>
|
||||
const AsyncData(AddonUpdateRunDone(null));
|
||||
}
|
||||
|
||||
@Riverpod()
|
||||
class AddonList extends _$AddonList {
|
||||
GeckoAddonService get _service => ref.read(addonServiceProvider);
|
||||
|
||||
Future<void> refresh() async {
|
||||
ref.invalidateSelf();
|
||||
await future;
|
||||
}
|
||||
|
||||
Future<void> install(AddonInfo addon) async {
|
||||
ref.read(addonBusyIdsProvider.notifier).add(addon.id);
|
||||
try {
|
||||
await _service.installAddon(Uri.parse(addon.downloadUrl));
|
||||
ref.invalidate(addonDetailsProvider(addon.id));
|
||||
ref.invalidateSelf();
|
||||
await future;
|
||||
} finally {
|
||||
ref.read(addonBusyIdsProvider.notifier).remove(addon.id);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> uninstall(AddonInfo addon) async {
|
||||
ref.read(addonBusyIdsProvider.notifier).add(addon.id);
|
||||
try {
|
||||
await _service.uninstallAddon(addon.id);
|
||||
ref.invalidate(addonDetailsProvider(addon.id));
|
||||
ref.invalidateSelf();
|
||||
await future;
|
||||
} finally {
|
||||
ref.read(addonBusyIdsProvider.notifier).remove(addon.id);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<AddonInfo>> build() {
|
||||
return _service.getAddons();
|
||||
}
|
||||
}
|
||||
|
||||
@Riverpod()
|
||||
class AddonBusyIds extends _$AddonBusyIds {
|
||||
void add(String id) => state = {...state, id};
|
||||
void remove(String id) => state = {...state}..remove(id);
|
||||
|
||||
@override
|
||||
Set<String> build() => const {};
|
||||
}
|
||||
|
||||
@Riverpod(keepAlive: true)
|
||||
class PinnedAddonIds extends _$PinnedAddonIds {
|
||||
void setPinned(String addonId, {required bool pinned}) {
|
||||
if (pinned) {
|
||||
if (!state.contains(addonId)) {
|
||||
state = {...state, addonId};
|
||||
}
|
||||
} else if (state.contains(addonId)) {
|
||||
state = {...state}..remove(addonId);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Set<String> build() {
|
||||
persist(
|
||||
ref.watch(riverpodDatabaseStorageProvider),
|
||||
key: 'PinnedAddonIds',
|
||||
encode: (state) => jsonEncode(state.toList()),
|
||||
decode: (encoded) =>
|
||||
(jsonDecode(encoded) as List<dynamic>).cast<String>().toSet(),
|
||||
);
|
||||
|
||||
return stateOrNull ?? const {};
|
||||
}
|
||||
}
|
||||
|
||||
@Riverpod()
|
||||
class BulkAddonUpdate extends _$BulkAddonUpdate {
|
||||
Future<void> triggerAll() async {
|
||||
state = const AsyncLoading();
|
||||
state = await AsyncValue.guard(() async {
|
||||
await ref.read(addonServiceProvider).triggerAllAddonUpdates();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
AsyncValue<void> build() => const AsyncData(null);
|
||||
}
|
||||
@@ -0,0 +1,562 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'providers.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
// ignore_for_file: type=lint, type=warning
|
||||
|
||||
@ProviderFor(AddonDetails)
|
||||
final addonDetailsProvider = AddonDetailsFamily._();
|
||||
|
||||
final class AddonDetailsProvider
|
||||
extends $AsyncNotifierProvider<AddonDetails, AddonInfo?> {
|
||||
AddonDetailsProvider._({
|
||||
required AddonDetailsFamily super.from,
|
||||
required String super.argument,
|
||||
}) : super(
|
||||
retry: null,
|
||||
name: r'addonDetailsProvider',
|
||||
isAutoDispose: true,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@override
|
||||
String debugGetCreateSourceHash() => _$addonDetailsHash();
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return r'addonDetailsProvider'
|
||||
''
|
||||
'($argument)';
|
||||
}
|
||||
|
||||
@$internal
|
||||
@override
|
||||
AddonDetails create() => AddonDetails();
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return other is AddonDetailsProvider && other.argument == argument;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode {
|
||||
return argument.hashCode;
|
||||
}
|
||||
}
|
||||
|
||||
String _$addonDetailsHash() => r'26b4a33e9d17aced1d3fb5c6ff28921f611ca5b0';
|
||||
|
||||
final class AddonDetailsFamily extends $Family
|
||||
with
|
||||
$ClassFamilyOverride<
|
||||
AddonDetails,
|
||||
AsyncValue<AddonInfo?>,
|
||||
AddonInfo?,
|
||||
FutureOr<AddonInfo?>,
|
||||
String
|
||||
> {
|
||||
AddonDetailsFamily._()
|
||||
: super(
|
||||
retry: null,
|
||||
name: r'addonDetailsProvider',
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
isAutoDispose: true,
|
||||
);
|
||||
|
||||
AddonDetailsProvider call(String addonId) =>
|
||||
AddonDetailsProvider._(argument: addonId, from: this);
|
||||
|
||||
@override
|
||||
String toString() => r'addonDetailsProvider';
|
||||
}
|
||||
|
||||
abstract class _$AddonDetails extends $AsyncNotifier<AddonInfo?> {
|
||||
late final _$args = ref.$arg as String;
|
||||
String get addonId => _$args;
|
||||
|
||||
FutureOr<AddonInfo?> build(String addonId);
|
||||
@$mustCallSuper
|
||||
@override
|
||||
void runBuild() {
|
||||
final ref = this.ref as $Ref<AsyncValue<AddonInfo?>, AddonInfo?>;
|
||||
final element =
|
||||
ref.element
|
||||
as $ClassProviderElement<
|
||||
AnyNotifier<AsyncValue<AddonInfo?>, AddonInfo?>,
|
||||
AsyncValue<AddonInfo?>,
|
||||
Object?,
|
||||
Object?
|
||||
>;
|
||||
element.handleCreate(ref, () => build(_$args));
|
||||
}
|
||||
}
|
||||
|
||||
@ProviderFor(addonStoreInfo)
|
||||
final addonStoreInfoProvider = AddonStoreInfoFamily._();
|
||||
|
||||
final class AddonStoreInfoProvider
|
||||
extends
|
||||
$FunctionalProvider<
|
||||
AsyncValue<AddonStoreInfo?>,
|
||||
AddonStoreInfo?,
|
||||
FutureOr<AddonStoreInfo?>
|
||||
>
|
||||
with $FutureModifier<AddonStoreInfo?>, $FutureProvider<AddonStoreInfo?> {
|
||||
AddonStoreInfoProvider._({
|
||||
required AddonStoreInfoFamily super.from,
|
||||
required String super.argument,
|
||||
}) : super(
|
||||
retry: null,
|
||||
name: r'addonStoreInfoProvider',
|
||||
isAutoDispose: true,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@override
|
||||
String debugGetCreateSourceHash() => _$addonStoreInfoHash();
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return r'addonStoreInfoProvider'
|
||||
''
|
||||
'($argument)';
|
||||
}
|
||||
|
||||
@$internal
|
||||
@override
|
||||
$FutureProviderElement<AddonStoreInfo?> $createElement(
|
||||
$ProviderPointer pointer,
|
||||
) => $FutureProviderElement(pointer);
|
||||
|
||||
@override
|
||||
FutureOr<AddonStoreInfo?> create(Ref ref) {
|
||||
final argument = this.argument as String;
|
||||
return addonStoreInfo(ref, argument);
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return other is AddonStoreInfoProvider && other.argument == argument;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode {
|
||||
return argument.hashCode;
|
||||
}
|
||||
}
|
||||
|
||||
String _$addonStoreInfoHash() => r'0024a540ecf6f1d55243d9dc963e04fbc212275e';
|
||||
|
||||
final class AddonStoreInfoFamily extends $Family
|
||||
with $FunctionalFamilyOverride<FutureOr<AddonStoreInfo?>, String> {
|
||||
AddonStoreInfoFamily._()
|
||||
: super(
|
||||
retry: null,
|
||||
name: r'addonStoreInfoProvider',
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
isAutoDispose: true,
|
||||
);
|
||||
|
||||
AddonStoreInfoProvider call(String addonId) =>
|
||||
AddonStoreInfoProvider._(argument: addonId, from: this);
|
||||
|
||||
@override
|
||||
String toString() => r'addonStoreInfoProvider';
|
||||
}
|
||||
|
||||
@ProviderFor(lastAddonUpdateAttempt)
|
||||
final lastAddonUpdateAttemptProvider = LastAddonUpdateAttemptFamily._();
|
||||
|
||||
final class LastAddonUpdateAttemptProvider
|
||||
extends
|
||||
$FunctionalProvider<
|
||||
AsyncValue<AddonUpdateAttemptInfo?>,
|
||||
AddonUpdateAttemptInfo?,
|
||||
FutureOr<AddonUpdateAttemptInfo?>
|
||||
>
|
||||
with
|
||||
$FutureModifier<AddonUpdateAttemptInfo?>,
|
||||
$FutureProvider<AddonUpdateAttemptInfo?> {
|
||||
LastAddonUpdateAttemptProvider._({
|
||||
required LastAddonUpdateAttemptFamily super.from,
|
||||
required String super.argument,
|
||||
}) : super(
|
||||
retry: null,
|
||||
name: r'lastAddonUpdateAttemptProvider',
|
||||
isAutoDispose: true,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@override
|
||||
String debugGetCreateSourceHash() => _$lastAddonUpdateAttemptHash();
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return r'lastAddonUpdateAttemptProvider'
|
||||
''
|
||||
'($argument)';
|
||||
}
|
||||
|
||||
@$internal
|
||||
@override
|
||||
$FutureProviderElement<AddonUpdateAttemptInfo?> $createElement(
|
||||
$ProviderPointer pointer,
|
||||
) => $FutureProviderElement(pointer);
|
||||
|
||||
@override
|
||||
FutureOr<AddonUpdateAttemptInfo?> create(Ref ref) {
|
||||
final argument = this.argument as String;
|
||||
return lastAddonUpdateAttempt(ref, argument);
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return other is LastAddonUpdateAttemptProvider &&
|
||||
other.argument == argument;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode {
|
||||
return argument.hashCode;
|
||||
}
|
||||
}
|
||||
|
||||
String _$lastAddonUpdateAttemptHash() =>
|
||||
r'79847d9f5720fea1f742d57c3b17272ffd980cc1';
|
||||
|
||||
final class LastAddonUpdateAttemptFamily extends $Family
|
||||
with $FunctionalFamilyOverride<FutureOr<AddonUpdateAttemptInfo?>, String> {
|
||||
LastAddonUpdateAttemptFamily._()
|
||||
: super(
|
||||
retry: null,
|
||||
name: r'lastAddonUpdateAttemptProvider',
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
isAutoDispose: true,
|
||||
);
|
||||
|
||||
LastAddonUpdateAttemptProvider call(String addonId) =>
|
||||
LastAddonUpdateAttemptProvider._(argument: addonId, from: this);
|
||||
|
||||
@override
|
||||
String toString() => r'lastAddonUpdateAttemptProvider';
|
||||
}
|
||||
|
||||
@ProviderFor(AddonUpdateCheck)
|
||||
final addonUpdateCheckProvider = AddonUpdateCheckFamily._();
|
||||
|
||||
final class AddonUpdateCheckProvider
|
||||
extends
|
||||
$NotifierProvider<AddonUpdateCheck, AsyncValue<AddonUpdateRunResult>> {
|
||||
AddonUpdateCheckProvider._({
|
||||
required AddonUpdateCheckFamily super.from,
|
||||
required String super.argument,
|
||||
}) : super(
|
||||
retry: null,
|
||||
name: r'addonUpdateCheckProvider',
|
||||
isAutoDispose: true,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@override
|
||||
String debugGetCreateSourceHash() => _$addonUpdateCheckHash();
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return r'addonUpdateCheckProvider'
|
||||
''
|
||||
'($argument)';
|
||||
}
|
||||
|
||||
@$internal
|
||||
@override
|
||||
AddonUpdateCheck create() => AddonUpdateCheck();
|
||||
|
||||
/// {@macro riverpod.override_with_value}
|
||||
Override overrideWithValue(AsyncValue<AddonUpdateRunResult> value) {
|
||||
return $ProviderOverride(
|
||||
origin: this,
|
||||
providerOverride: $SyncValueProvider<AsyncValue<AddonUpdateRunResult>>(
|
||||
value,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return other is AddonUpdateCheckProvider && other.argument == argument;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode {
|
||||
return argument.hashCode;
|
||||
}
|
||||
}
|
||||
|
||||
String _$addonUpdateCheckHash() => r'4ef375b5cd9b0eb89fbbdaff3f93a35482191af5';
|
||||
|
||||
final class AddonUpdateCheckFamily extends $Family
|
||||
with
|
||||
$ClassFamilyOverride<
|
||||
AddonUpdateCheck,
|
||||
AsyncValue<AddonUpdateRunResult>,
|
||||
AsyncValue<AddonUpdateRunResult>,
|
||||
AsyncValue<AddonUpdateRunResult>,
|
||||
String
|
||||
> {
|
||||
AddonUpdateCheckFamily._()
|
||||
: super(
|
||||
retry: null,
|
||||
name: r'addonUpdateCheckProvider',
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
isAutoDispose: true,
|
||||
);
|
||||
|
||||
AddonUpdateCheckProvider call(String addonId) =>
|
||||
AddonUpdateCheckProvider._(argument: addonId, from: this);
|
||||
|
||||
@override
|
||||
String toString() => r'addonUpdateCheckProvider';
|
||||
}
|
||||
|
||||
abstract class _$AddonUpdateCheck
|
||||
extends $Notifier<AsyncValue<AddonUpdateRunResult>> {
|
||||
late final _$args = ref.$arg as String;
|
||||
String get addonId => _$args;
|
||||
|
||||
AsyncValue<AddonUpdateRunResult> build(String addonId);
|
||||
@$mustCallSuper
|
||||
@override
|
||||
void runBuild() {
|
||||
final ref =
|
||||
this.ref
|
||||
as $Ref<
|
||||
AsyncValue<AddonUpdateRunResult>,
|
||||
AsyncValue<AddonUpdateRunResult>
|
||||
>;
|
||||
final element =
|
||||
ref.element
|
||||
as $ClassProviderElement<
|
||||
AnyNotifier<
|
||||
AsyncValue<AddonUpdateRunResult>,
|
||||
AsyncValue<AddonUpdateRunResult>
|
||||
>,
|
||||
AsyncValue<AddonUpdateRunResult>,
|
||||
Object?,
|
||||
Object?
|
||||
>;
|
||||
element.handleCreate(ref, () => build(_$args));
|
||||
}
|
||||
}
|
||||
|
||||
@ProviderFor(AddonList)
|
||||
final addonListProvider = AddonListProvider._();
|
||||
|
||||
final class AddonListProvider
|
||||
extends $AsyncNotifierProvider<AddonList, List<AddonInfo>> {
|
||||
AddonListProvider._()
|
||||
: super(
|
||||
from: null,
|
||||
argument: null,
|
||||
retry: null,
|
||||
name: r'addonListProvider',
|
||||
isAutoDispose: true,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@override
|
||||
String debugGetCreateSourceHash() => _$addonListHash();
|
||||
|
||||
@$internal
|
||||
@override
|
||||
AddonList create() => AddonList();
|
||||
}
|
||||
|
||||
String _$addonListHash() => r'7625b69a433d073e186571453e39b557d8b48a5d';
|
||||
|
||||
abstract class _$AddonList extends $AsyncNotifier<List<AddonInfo>> {
|
||||
FutureOr<List<AddonInfo>> build();
|
||||
@$mustCallSuper
|
||||
@override
|
||||
void runBuild() {
|
||||
final ref = this.ref as $Ref<AsyncValue<List<AddonInfo>>, List<AddonInfo>>;
|
||||
final element =
|
||||
ref.element
|
||||
as $ClassProviderElement<
|
||||
AnyNotifier<AsyncValue<List<AddonInfo>>, List<AddonInfo>>,
|
||||
AsyncValue<List<AddonInfo>>,
|
||||
Object?,
|
||||
Object?
|
||||
>;
|
||||
element.handleCreate(ref, build);
|
||||
}
|
||||
}
|
||||
|
||||
@ProviderFor(AddonBusyIds)
|
||||
final addonBusyIdsProvider = AddonBusyIdsProvider._();
|
||||
|
||||
final class AddonBusyIdsProvider
|
||||
extends $NotifierProvider<AddonBusyIds, Set<String>> {
|
||||
AddonBusyIdsProvider._()
|
||||
: super(
|
||||
from: null,
|
||||
argument: null,
|
||||
retry: null,
|
||||
name: r'addonBusyIdsProvider',
|
||||
isAutoDispose: true,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@override
|
||||
String debugGetCreateSourceHash() => _$addonBusyIdsHash();
|
||||
|
||||
@$internal
|
||||
@override
|
||||
AddonBusyIds create() => AddonBusyIds();
|
||||
|
||||
/// {@macro riverpod.override_with_value}
|
||||
Override overrideWithValue(Set<String> value) {
|
||||
return $ProviderOverride(
|
||||
origin: this,
|
||||
providerOverride: $SyncValueProvider<Set<String>>(value),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _$addonBusyIdsHash() => r'6f9761320d42b2b5132936797617fdb13b718dd3';
|
||||
|
||||
abstract class _$AddonBusyIds extends $Notifier<Set<String>> {
|
||||
Set<String> build();
|
||||
@$mustCallSuper
|
||||
@override
|
||||
void runBuild() {
|
||||
final ref = this.ref as $Ref<Set<String>, Set<String>>;
|
||||
final element =
|
||||
ref.element
|
||||
as $ClassProviderElement<
|
||||
AnyNotifier<Set<String>, Set<String>>,
|
||||
Set<String>,
|
||||
Object?,
|
||||
Object?
|
||||
>;
|
||||
element.handleCreate(ref, build);
|
||||
}
|
||||
}
|
||||
|
||||
@ProviderFor(PinnedAddonIds)
|
||||
final pinnedAddonIdsProvider = PinnedAddonIdsProvider._();
|
||||
|
||||
final class PinnedAddonIdsProvider
|
||||
extends $NotifierProvider<PinnedAddonIds, Set<String>> {
|
||||
PinnedAddonIdsProvider._()
|
||||
: super(
|
||||
from: null,
|
||||
argument: null,
|
||||
retry: null,
|
||||
name: r'pinnedAddonIdsProvider',
|
||||
isAutoDispose: false,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@override
|
||||
String debugGetCreateSourceHash() => _$pinnedAddonIdsHash();
|
||||
|
||||
@$internal
|
||||
@override
|
||||
PinnedAddonIds create() => PinnedAddonIds();
|
||||
|
||||
/// {@macro riverpod.override_with_value}
|
||||
Override overrideWithValue(Set<String> value) {
|
||||
return $ProviderOverride(
|
||||
origin: this,
|
||||
providerOverride: $SyncValueProvider<Set<String>>(value),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _$pinnedAddonIdsHash() => r'4f46cd69d4817e6e19e4d04f4fdcf83d5e2efb8c';
|
||||
|
||||
abstract class _$PinnedAddonIds extends $Notifier<Set<String>> {
|
||||
Set<String> build();
|
||||
@$mustCallSuper
|
||||
@override
|
||||
void runBuild() {
|
||||
final ref = this.ref as $Ref<Set<String>, Set<String>>;
|
||||
final element =
|
||||
ref.element
|
||||
as $ClassProviderElement<
|
||||
AnyNotifier<Set<String>, Set<String>>,
|
||||
Set<String>,
|
||||
Object?,
|
||||
Object?
|
||||
>;
|
||||
element.handleCreate(ref, build);
|
||||
}
|
||||
}
|
||||
|
||||
@ProviderFor(BulkAddonUpdate)
|
||||
final bulkAddonUpdateProvider = BulkAddonUpdateProvider._();
|
||||
|
||||
final class BulkAddonUpdateProvider
|
||||
extends $NotifierProvider<BulkAddonUpdate, AsyncValue<void>> {
|
||||
BulkAddonUpdateProvider._()
|
||||
: super(
|
||||
from: null,
|
||||
argument: null,
|
||||
retry: null,
|
||||
name: r'bulkAddonUpdateProvider',
|
||||
isAutoDispose: true,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@override
|
||||
String debugGetCreateSourceHash() => _$bulkAddonUpdateHash();
|
||||
|
||||
@$internal
|
||||
@override
|
||||
BulkAddonUpdate create() => BulkAddonUpdate();
|
||||
|
||||
/// {@macro riverpod.override_with_value}
|
||||
Override overrideWithValue(AsyncValue<void> value) {
|
||||
return $ProviderOverride(
|
||||
origin: this,
|
||||
providerOverride: $SyncValueProvider<AsyncValue<void>>(value),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _$bulkAddonUpdateHash() => r'2605711734f9eb6af70e721a4337556a7ea85512';
|
||||
|
||||
abstract class _$BulkAddonUpdate extends $Notifier<AsyncValue<void>> {
|
||||
AsyncValue<void> build();
|
||||
@$mustCallSuper
|
||||
@override
|
||||
void runBuild() {
|
||||
final ref = this.ref as $Ref<AsyncValue<void>, AsyncValue<void>>;
|
||||
final element =
|
||||
ref.element
|
||||
as $ClassProviderElement<
|
||||
AnyNotifier<AsyncValue<void>, AsyncValue<void>>,
|
||||
AsyncValue<void>,
|
||||
Object?,
|
||||
Object?
|
||||
>;
|
||||
element.handleCreate(ref, build);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import 'package:flutter_mozilla_components/flutter_mozilla_components.dart';
|
||||
|
||||
extension AddonInfoUi on AddonInfo {
|
||||
bool get hasOptionsPage => optionsPageUrl?.isNotEmpty ?? false;
|
||||
|
||||
bool get canUserToggleEnabled {
|
||||
return switch (disabledReason) {
|
||||
AddonDisabledReason.blocklisted ||
|
||||
AddonDisabledReason.notCorrectlySigned ||
|
||||
AddonDisabledReason.incompatible => false,
|
||||
_ => true,
|
||||
};
|
||||
}
|
||||
|
||||
String? get statusBannerMessage {
|
||||
return switch (disabledReason) {
|
||||
AddonDisabledReason.blocklisted =>
|
||||
'This extension has been blocklisted and should remain disabled.',
|
||||
AddonDisabledReason.notCorrectlySigned =>
|
||||
'This extension is not correctly signed and cannot be safely enabled.',
|
||||
AddonDisabledReason.incompatible =>
|
||||
'This extension is incompatible with the current app version.',
|
||||
AddonDisabledReason.softBlocked =>
|
||||
isEnabled
|
||||
? 'This extension is soft-blocked. Use caution while it remains enabled.'
|
||||
: 'This extension is soft-blocked, but it can still be re-enabled.',
|
||||
AddonDisabledReason.unsupported =>
|
||||
'This extension is installed, but WebLibre does not currently support it.',
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,638 @@
|
||||
/*
|
||||
* 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:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:url_launcher/url_launcher.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_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/ui_helper.dart';
|
||||
|
||||
class AddonDetailsScreen extends ConsumerWidget {
|
||||
final String addonId;
|
||||
|
||||
const AddonDetailsScreen({required this.addonId, super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final addonAsync = ref.watch(addonDetailsProvider(addonId));
|
||||
final addon = addonAsync.value;
|
||||
|
||||
if (addonAsync.isLoading && addon == null) {
|
||||
return const Scaffold(body: Center(child: CircularProgressIndicator()));
|
||||
}
|
||||
|
||||
if (addon == null) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Extension')),
|
||||
body: Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Text(
|
||||
addonAsync.error?.toString() ??
|
||||
'This extension could not be found.',
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(addon.displayName),
|
||||
actions: [
|
||||
IconButton(
|
||||
onPressed: addonAsync.isLoading
|
||||
? null
|
||||
: ref.read(addonDetailsProvider(addonId).notifier).refresh,
|
||||
icon: const Icon(Icons.refresh),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: RefreshIndicator(
|
||||
onRefresh: ref.read(addonDetailsProvider(addonId).notifier).refresh,
|
||||
child: _AddonDetailsBody(addonId: addonId),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _AddonDetailsBody extends ConsumerWidget {
|
||||
final String addonId;
|
||||
|
||||
const _AddonDetailsBody({required this.addonId});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
final addon = ref.watch(
|
||||
addonDetailsProvider(addonId).select((value) => value.value),
|
||||
);
|
||||
if (addon == null) return const SizedBox.shrink();
|
||||
|
||||
return ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
_AddonHeader(addon: addon),
|
||||
const SizedBox(height: 16),
|
||||
if (addon.isInstalled) ...[
|
||||
_ManagementSection(addonId: addonId),
|
||||
const SizedBox(height: 16),
|
||||
_UpdatesSection(addonId: addonId),
|
||||
] else ...[
|
||||
_InstallButton(addonId: addonId),
|
||||
const SizedBox(height: 12),
|
||||
OutlinedButton.icon(
|
||||
onPressed: () =>
|
||||
AddonPermissionsRoute(addonId: addon.id).push<void>(context),
|
||||
icon: const Icon(Icons.privacy_tip_outlined),
|
||||
label: const Text('View Permissions'),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 16),
|
||||
Text('Details', style: theme.textTheme.titleMedium),
|
||||
const SizedBox(height: 8),
|
||||
_DetailsCard(addon: addon),
|
||||
const SizedBox(height: 16),
|
||||
Text('Description', style: theme.textTheme.titleMedium),
|
||||
const SizedBox(height: 8),
|
||||
_DescriptionCard(addon: addon),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _InstallButton extends ConsumerWidget {
|
||||
final String addonId;
|
||||
|
||||
const _InstallButton({required this.addonId});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final addonAsync = ref.watch(addonDetailsProvider(addonId));
|
||||
final addon = addonAsync.value;
|
||||
|
||||
return FilledButton.icon(
|
||||
onPressed: (addonAsync.isLoading || addon == null)
|
||||
? null
|
||||
: () async {
|
||||
final displayName = addon.displayName;
|
||||
|
||||
await ref.read(addonDetailsProvider(addonId).notifier).install();
|
||||
|
||||
if (!context.mounted) return;
|
||||
|
||||
showInfoMessage(context, '$displayName installed');
|
||||
},
|
||||
icon: const Icon(Icons.download),
|
||||
label: const Text('Install Extension'),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _AddonHeader extends StatelessWidget {
|
||||
final AddonInfo addon;
|
||||
|
||||
const _AddonHeader({required this.addon});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
AddonIconView(addon: addon, size: 56),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
addon.displayName,
|
||||
style: theme.textTheme.headlineSmall,
|
||||
),
|
||||
if ((addon.summary ?? '').isNotEmpty) ...[
|
||||
const SizedBox(height: 8),
|
||||
Text(addon.summary!),
|
||||
],
|
||||
const SizedBox(height: 12),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
Chip(
|
||||
label: Text(
|
||||
addon.isInstalled
|
||||
? (addon.isEnabled ? 'Installed' : 'Disabled')
|
||||
: 'Available',
|
||||
),
|
||||
),
|
||||
if (addon.isAllowedInPrivateBrowsing)
|
||||
const Chip(label: Text('Private Browsing')),
|
||||
if (addon.ratingAverage != null)
|
||||
Chip(
|
||||
avatar: const Icon(Icons.star, size: 18),
|
||||
label: Text(
|
||||
'${addon.ratingAverage!.toStringAsFixed(1)}'
|
||||
' (${addon.ratingReviews ?? 0})',
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
AddonStatusBanner(addon: addon),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ManagementSection extends ConsumerWidget {
|
||||
final String addonId;
|
||||
|
||||
const _ManagementSection({required this.addonId});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
final addonAsync = ref.watch(addonDetailsProvider(addonId));
|
||||
final addon = addonAsync.value;
|
||||
|
||||
if (addon == null) return const SizedBox.shrink();
|
||||
|
||||
final globalAutoUpdate = ref.watch(addonAutoUpdateProvider);
|
||||
final isLocalFileInstalled = addon.isLocalFileInstalled;
|
||||
|
||||
final isPinned = ref.watch(pinnedAddonIdsProvider).contains(addonId);
|
||||
|
||||
final (
|
||||
globalAutoUpdateEnabled,
|
||||
canChangePerAddonAutoUpdate,
|
||||
) = globalAutoUpdate.when(
|
||||
data: (enabled) =>
|
||||
(enabled, !addonAsync.isLoading && enabled && !isLocalFileInstalled),
|
||||
loading: () => (true, false),
|
||||
error: (_, _) => (true, false),
|
||||
);
|
||||
|
||||
final autoUpdateSubtitle = switch ((
|
||||
isLocalFileInstalled,
|
||||
addon.isAutoUpdateEnabled,
|
||||
globalAutoUpdateEnabled,
|
||||
)) {
|
||||
(_, _, false) => 'Global automatic updates are disabled.',
|
||||
(true, _, true) =>
|
||||
'Run a manual update once and restart the app before automatic updates can be enabled.',
|
||||
(false, true, true) =>
|
||||
'Allow this extension to receive background updates.',
|
||||
(false, false, true) =>
|
||||
'Background updates are disabled for this extension.',
|
||||
};
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('Management', style: theme.textTheme.titleMedium),
|
||||
const SizedBox(height: 8),
|
||||
Card(
|
||||
child: Column(
|
||||
children: [
|
||||
if (addon.isSupported)
|
||||
SwitchListTile.adaptive(
|
||||
title: const Text('Enabled'),
|
||||
subtitle: Text(
|
||||
addon.canUserToggleEnabled
|
||||
? 'Allow this extension to run in WebLibre.'
|
||||
: 'This extension cannot be safely enabled.',
|
||||
),
|
||||
value: addon.isEnabled,
|
||||
onChanged: addonAsync.isLoading || !addon.canUserToggleEnabled
|
||||
? null
|
||||
: (enabled) => ref
|
||||
.read(addonDetailsProvider(addonId).notifier)
|
||||
.setEnabled(enabled: enabled),
|
||||
),
|
||||
SwitchListTile.adaptive(
|
||||
title: const Text('Allow in Private Browsing'),
|
||||
subtitle: const Text(
|
||||
'Let this extension run in private browsing tabs.',
|
||||
),
|
||||
value: addon.isAllowedInPrivateBrowsing,
|
||||
onChanged: addonAsync.isLoading
|
||||
? null
|
||||
: (allowed) => ref
|
||||
.read(addonDetailsProvider(addonId).notifier)
|
||||
.setAllowedInPrivateBrowsing(allowed: allowed),
|
||||
),
|
||||
SwitchListTile.adaptive(
|
||||
title: const Text('Automatic updates'),
|
||||
subtitle: Text(autoUpdateSubtitle),
|
||||
value: addon.isAutoUpdateEnabled,
|
||||
onChanged: canChangePerAddonAutoUpdate
|
||||
? (enabled) => ref
|
||||
.read(addonDetailsProvider(addonId).notifier)
|
||||
.setAutoUpdateEnabled(enabled: enabled)
|
||||
: null,
|
||||
),
|
||||
SwitchListTile.adaptive(
|
||||
title: const Text('Pin to toolbar'),
|
||||
subtitle: const Text(
|
||||
'Show this extension as an icon in the main tab bar.',
|
||||
),
|
||||
value: isPinned,
|
||||
onChanged: (pinned) {
|
||||
ref
|
||||
.read(pinnedAddonIdsProvider.notifier)
|
||||
.setPinned(addonId, pinned: pinned);
|
||||
},
|
||||
),
|
||||
if (addon.hasOptionsPage)
|
||||
ListTile(
|
||||
leading: const Icon(Icons.settings_outlined),
|
||||
title: const Text('Extension Settings'),
|
||||
subtitle: Text(
|
||||
addon.openOptionsPageInTab
|
||||
? 'Open the extension options page in a browser tab'
|
||||
: 'Open the extension options page',
|
||||
),
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
onTap: () => openAddonSettingsFlow(context, ref, addon),
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.privacy_tip_outlined),
|
||||
title: const Text('Permissions'),
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
onTap: () => AddonPermissionsRoute(
|
||||
addonId: addon.id,
|
||||
).push<void>(context),
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.delete_outline),
|
||||
title: const Text('Remove Extension'),
|
||||
textColor: theme.colorScheme.error,
|
||||
iconColor: theme.colorScheme.error,
|
||||
onTap: addonAsync.isLoading
|
||||
? null
|
||||
: () async {
|
||||
final confirmed = await _showConfirmUninstallDialog(
|
||||
context,
|
||||
addon,
|
||||
);
|
||||
if (confirmed != true || !context.mounted) return;
|
||||
|
||||
final displayName = addon.displayName;
|
||||
await ref
|
||||
.read(addonDetailsProvider(addonId).notifier)
|
||||
.uninstall();
|
||||
if (!context.mounted) return;
|
||||
|
||||
showInfoMessage(context, '$displayName removed');
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool?> _showConfirmUninstallDialog(
|
||||
BuildContext context,
|
||||
AddonInfo addon,
|
||||
) {
|
||||
return showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Remove extension?'),
|
||||
content: Text('Remove ${addon.displayName} from WebLibre?'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(false),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.of(context).pop(true),
|
||||
child: const Text('Remove'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
class _UpdatesSection extends ConsumerWidget {
|
||||
final String addonId;
|
||||
|
||||
const _UpdatesSection({required this.addonId});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
final addon = ref.watch(addonDetailsProvider(addonId)).value;
|
||||
if (addon == null) return const SizedBox.shrink();
|
||||
|
||||
final storeInfo = ref.watch(addonStoreInfoProvider(addonId)).value;
|
||||
final updateAttempt = ref
|
||||
.watch(lastAddonUpdateAttemptProvider(addonId))
|
||||
.value;
|
||||
final checking = ref.watch(addonUpdateCheckProvider(addonId)).isLoading;
|
||||
|
||||
final availableVersion = _displayAvailableVersion(addon, storeInfo);
|
||||
final hasAvailableUpdate =
|
||||
addon.installedVersion != null &&
|
||||
availableVersion.isNotEmpty &&
|
||||
addon.installedVersion != availableVersion;
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('Updates', style: theme.textTheme.titleMedium),
|
||||
const SizedBox(height: 8),
|
||||
Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(formatUpdateAttemptStatus(updateAttempt)),
|
||||
const SizedBox(height: 8),
|
||||
if (hasAvailableUpdate) ...[
|
||||
Text(
|
||||
'Update available: ${addon.installedVersion} \u2192 $availableVersion',
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: theme.colorScheme.primary,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
],
|
||||
Text(
|
||||
updateAttempt == null
|
||||
? 'No recent update attempt information is available yet.'
|
||||
: 'Last checked: ${formatUpdateAttemptDate(updateAttempt)}',
|
||||
style: theme.textTheme.bodySmall,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
FilledButton.icon(
|
||||
onPressed: checking
|
||||
? null
|
||||
: () => _runUpdateCheck(context, ref, addonId),
|
||||
icon: checking
|
||||
? const SizedBox(
|
||||
width: 18,
|
||||
height: 18,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Icon(Icons.system_update_alt),
|
||||
label: Text(
|
||||
checking ? 'Checking for Updates' : 'Check for Updates',
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _displayAvailableVersion(AddonInfo addon, AddonStoreInfo? storeInfo) {
|
||||
final latest = storeInfo?.latestVersion.trim();
|
||||
return (latest != null && latest.isNotEmpty) ? latest : addon.version;
|
||||
}
|
||||
|
||||
Future<void> _runUpdateCheck(
|
||||
BuildContext context,
|
||||
WidgetRef ref,
|
||||
String addonId,
|
||||
) async {
|
||||
final outcome = await ref
|
||||
.read(addonUpdateCheckProvider(addonId).notifier)
|
||||
.resolveAvailableUpdate();
|
||||
|
||||
if (!context.mounted) return;
|
||||
|
||||
switch (outcome) {
|
||||
case AddonUpdateOutcomeMissing():
|
||||
return;
|
||||
case AddonUpdateOutcomeUpToDate():
|
||||
final result = await ref
|
||||
.read(addonUpdateCheckProvider(addonId).notifier)
|
||||
.triggerAndAwait();
|
||||
if (!context.mounted) return;
|
||||
_reportUpdateResult(context, result, fallback: 'No update available');
|
||||
case AddonUpdateOutcomeAvailable(
|
||||
addon: final fresh,
|
||||
:final availableVersion,
|
||||
):
|
||||
final confirmed = await _confirmUpdateDialog(
|
||||
context,
|
||||
fresh,
|
||||
availableVersion,
|
||||
);
|
||||
if (confirmed != true || !context.mounted) return;
|
||||
|
||||
final result = await ref
|
||||
.read(addonUpdateCheckProvider(addonId).notifier)
|
||||
.triggerAndAwait();
|
||||
if (!context.mounted) return;
|
||||
_reportUpdateResult(context, result);
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool?> _confirmUpdateDialog(
|
||||
BuildContext context,
|
||||
AddonInfo addon,
|
||||
String availableVersion,
|
||||
) {
|
||||
return showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Update available'),
|
||||
content: Text(
|
||||
'Update ${addon.displayName} from '
|
||||
'${addon.installedVersion} to $availableVersion?',
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(false),
|
||||
child: const Text('Not now'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.of(context).pop(true),
|
||||
child: const Text('Update'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _reportUpdateResult(
|
||||
BuildContext context,
|
||||
AddonUpdateRunResult result, {
|
||||
String? fallback,
|
||||
}) {
|
||||
switch (result) {
|
||||
case AddonUpdateRunDone(:final message):
|
||||
final text = (message != null && message.isNotEmpty) ? message : fallback;
|
||||
if (text != null) showInfoMessage(context, text);
|
||||
case AddonUpdateRunNoRemoteSource():
|
||||
showErrorMessage(
|
||||
context,
|
||||
'This locally installed extension has no remote update source.',
|
||||
);
|
||||
case AddonUpdateRunFailed():
|
||||
showErrorMessage(context, 'Failed to start update check.');
|
||||
}
|
||||
}
|
||||
|
||||
class _DescriptionCard extends StatelessWidget {
|
||||
final AddonInfo addon;
|
||||
|
||||
const _DescriptionCard({required this.addon});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final description = addon.description;
|
||||
|
||||
return Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Text(
|
||||
description.isNotEmpty ? description : 'No description provided.',
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _DetailsCard extends StatelessWidget {
|
||||
final AddonInfo addon;
|
||||
|
||||
const _DetailsCard({required this.addon});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Card(
|
||||
child: Column(
|
||||
children: [
|
||||
if ((addon.authorName ?? '').isNotEmpty)
|
||||
ListTile(
|
||||
leading: const Icon(Icons.person_outline),
|
||||
title: const Text('Author'),
|
||||
subtitle: Text(addon.authorName!),
|
||||
onTap: (addon.authorUrl ?? '').isEmpty
|
||||
? null
|
||||
: () => launchUrl(Uri.parse(addon.authorUrl!)),
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.tag_outlined),
|
||||
title: const Text('Version'),
|
||||
subtitle: Text(addon.installedVersion ?? addon.version),
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.update_outlined),
|
||||
title: const Text('Last Updated'),
|
||||
subtitle: Text(formatAddonDate(addon.updatedAt)),
|
||||
),
|
||||
if (addon.homepageUrl.isNotEmpty)
|
||||
ListTile(
|
||||
leading: const Icon(Icons.public),
|
||||
title: const Text('Homepage'),
|
||||
subtitle: Text(addon.homepageUrl),
|
||||
trailing: const Icon(Icons.open_in_new),
|
||||
onTap: () => launchUrl(Uri.parse(addon.homepageUrl)),
|
||||
),
|
||||
if (addon.detailUrl.isNotEmpty)
|
||||
ListTile(
|
||||
leading: const Icon(Icons.storefront_outlined),
|
||||
title: const Text('Addon Listing'),
|
||||
subtitle: Text(addon.detailUrl),
|
||||
trailing: const Icon(Icons.open_in_new),
|
||||
onTap: () => launchUrl(Uri.parse(addon.detailUrl)),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
/*
|
||||
* 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:flutter/foundation.dart';
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/rendering.dart';
|
||||
import 'package:flutter/services.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/geckoview/domain/providers.dart';
|
||||
|
||||
Future<void> openAddonSettingsFlow(
|
||||
BuildContext context,
|
||||
WidgetRef ref,
|
||||
AddonInfo addon,
|
||||
) async {
|
||||
if (!addon.hasOptionsPage) {
|
||||
await const AddonManagerRoute().push<void>(context);
|
||||
return;
|
||||
}
|
||||
|
||||
if (addon.openOptionsPageInTab) {
|
||||
final optionsPageUrl = addon.optionsPageUrl;
|
||||
if (optionsPageUrl == null || optionsPageUrl.isEmpty) {
|
||||
return;
|
||||
}
|
||||
|
||||
await GeckoTabService().selectOrAddTabByUrl(
|
||||
url: Uri.parse(optionsPageUrl),
|
||||
ignoreFragment: true,
|
||||
);
|
||||
|
||||
if (context.mounted) {
|
||||
const BrowserRoute().go(context);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
await AddonInternalSettingsRoute(addonId: addon.id).push<void>(context);
|
||||
}
|
||||
|
||||
Future<void> openAddonSettingsFlowById(
|
||||
BuildContext context,
|
||||
WidgetRef ref,
|
||||
String addonId,
|
||||
) async {
|
||||
final addon = await ref.read(addonServiceProvider).getAddonById(addonId);
|
||||
if (addon == null) {
|
||||
if (!context.mounted) return;
|
||||
|
||||
await const AddonManagerRoute().push<void>(context);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!context.mounted) return;
|
||||
|
||||
await openAddonSettingsFlow(context, ref, addon);
|
||||
}
|
||||
|
||||
class AddonInternalSettingsScreen extends ConsumerWidget {
|
||||
final String addonId;
|
||||
|
||||
const AddonInternalSettingsScreen({required this.addonId, super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final addonAsync = ref.watch(addonDetailsProvider(addonId));
|
||||
|
||||
final addon = addonAsync.value;
|
||||
final optionsPageUrl = addon?.optionsPageUrl;
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(
|
||||
addon == null
|
||||
? 'Extension Settings'
|
||||
: '${addon.displayName} Settings',
|
||||
),
|
||||
),
|
||||
body: switch (addonAsync) {
|
||||
AsyncLoading() when addon == null => const Center(
|
||||
child: CircularProgressIndicator(),
|
||||
),
|
||||
AsyncError(:final error) => Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Text(
|
||||
'Failed to load extension settings: $error',
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
),
|
||||
_
|
||||
when addon == null ||
|
||||
optionsPageUrl == null ||
|
||||
optionsPageUrl.isEmpty =>
|
||||
const Center(
|
||||
child: Text('This extension does not expose a settings page.'),
|
||||
),
|
||||
_ => _AddonSettingsPlatformView(optionsPageUrl: optionsPageUrl),
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _AddonSettingsPlatformView extends StatelessWidget {
|
||||
final String optionsPageUrl;
|
||||
|
||||
const _AddonSettingsPlatformView({required this.optionsPageUrl});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return PlatformViewLink(
|
||||
viewType: 'eu.weblibre/addon_settings',
|
||||
surfaceFactory: (context, controller) {
|
||||
return AndroidViewSurface(
|
||||
controller: controller as AndroidViewController,
|
||||
gestureRecognizers: const <Factory<OneSequenceGestureRecognizer>>{},
|
||||
hitTestBehavior: PlatformViewHitTestBehavior.opaque,
|
||||
);
|
||||
},
|
||||
onCreatePlatformView: (params) {
|
||||
final controller = PlatformViewsService.initExpensiveAndroidView(
|
||||
id: params.id,
|
||||
viewType: 'eu.weblibre/addon_settings',
|
||||
layoutDirection: TextDirection.ltr,
|
||||
creationParams: <String, Object?>{'optionsPageUrl': optionsPageUrl},
|
||||
creationParamsCodec: const StandardMessageCodec(),
|
||||
);
|
||||
controller.addOnPlatformViewCreatedListener(
|
||||
params.onPlatformViewCreated,
|
||||
);
|
||||
unawaited(controller.create());
|
||||
return controller;
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,328 @@
|
||||
/*
|
||||
* 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: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/widgets/addon_ui.dart';
|
||||
import 'package:weblibre/utils/ui_helper.dart';
|
||||
|
||||
class AddonManagerScreen extends ConsumerWidget {
|
||||
const AddonManagerScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final addonsAsync = ref.watch(addonListProvider);
|
||||
|
||||
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),
|
||||
),
|
||||
_TriggerAllUpdatesButton(
|
||||
enabled: addonsAsync.maybeWhen(
|
||||
data: (addons) =>
|
||||
addons.any((a) => a.isInstalled && a.isSupported),
|
||||
orElse: () => false,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: 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()),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _TriggerAllUpdatesButton extends ConsumerWidget {
|
||||
final bool enabled;
|
||||
|
||||
const _TriggerAllUpdatesButton({required this.enabled});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final busy = 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
|
||||
? const SizedBox(
|
||||
width: 18,
|
||||
height: 18,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Icon(Icons.system_update_alt),
|
||||
tooltip: 'Check all installed extensions for updates',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _AddonList extends StatelessWidget {
|
||||
final List<AddonInfo> addons;
|
||||
|
||||
const _AddonList({required this.addons});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final enabled = addons
|
||||
.where((a) => a.isInstalled && a.isSupported && a.isEnabled)
|
||||
.toList();
|
||||
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();
|
||||
|
||||
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'),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _UninstallAction extends ConsumerWidget {
|
||||
final AddonInfo addon;
|
||||
|
||||
const _UninstallAction({required this.addon});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final busy = ref.watch(addonBusyIdsProvider).contains(addon.id);
|
||||
return IconButton(
|
||||
tooltip: 'Remove extension',
|
||||
onPressed: busy
|
||||
? null
|
||||
: () async {
|
||||
await ref.read(addonListProvider.notifier).uninstall(addon);
|
||||
if (!context.mounted) return;
|
||||
showInfoMessage(context, '${addon.displayName} removed');
|
||||
},
|
||||
icon: const Icon(Icons.delete_outline),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Section extends StatelessWidget {
|
||||
final String title;
|
||||
|
||||
const _Section({required this.title});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: Text(title, style: Theme.of(context).textTheme.titleMedium),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _AddonCard extends ConsumerWidget {
|
||||
final AddonInfo addon;
|
||||
final Widget? action;
|
||||
|
||||
const _AddonCard({required this.addon, this.action});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final busy = ref.watch(addonBusyIdsProvider).contains(addon.id);
|
||||
|
||||
return Card(
|
||||
margin: const EdgeInsets.only(bottom: 12),
|
||||
child: InkWell(
|
||||
onTap: busy
|
||||
? null
|
||||
: () => AddonDetailsRoute(addonId: addon.id).push<void>(context),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
AddonIconView(addon: addon),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
addon.displayName,
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
if ((addon.summary ?? '').isNotEmpty) ...[
|
||||
const SizedBox(height: 4),
|
||||
Text(addon.summary!),
|
||||
],
|
||||
const SizedBox(height: 8),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
if (addon.isAllowedInPrivateBrowsing)
|
||||
const Chip(label: Text('Private Browsing')),
|
||||
if (addon.ratingAverage != null)
|
||||
Chip(
|
||||
avatar: const Icon(Icons.star, size: 16),
|
||||
label: Text(
|
||||
addon.ratingAverage!.toStringAsFixed(1),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
action ?? const Icon(Icons.chevron_right),
|
||||
],
|
||||
),
|
||||
if (addon.statusBannerMessage != null) ...[
|
||||
const SizedBox(height: 12),
|
||||
AddonStatusBanner(addon: addon),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _AddonLoadError extends StatelessWidget {
|
||||
final Object? error;
|
||||
final VoidCallback onRetry;
|
||||
|
||||
const _AddonLoadError({required this.error, required this.onRetry});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return 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),
|
||||
const SizedBox(height: 16),
|
||||
FilledButton(onPressed: onRetry, child: const Text('Retry')),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
/*
|
||||
* 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:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
import 'package:weblibre/features/addons/domain/providers.dart';
|
||||
|
||||
const _permissionsLearnMoreUrl =
|
||||
'https://support.mozilla.org/kb/permission-request-messages-firefox-extensions';
|
||||
|
||||
class AddonPermissionsScreen extends ConsumerWidget {
|
||||
final String addonId;
|
||||
|
||||
const AddonPermissionsScreen({required this.addonId, super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final addonAsync = ref.watch(addonDetailsProvider(addonId));
|
||||
final addon = addonAsync.value;
|
||||
|
||||
final permissions = addon?.translatedPermissions.toList() ?? <String>[];
|
||||
permissions.sort();
|
||||
|
||||
final dataCollection =
|
||||
addon?.translatedRequiredDataCollectionPermissions.toList() ??
|
||||
<String>[];
|
||||
dataCollection.sort();
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(
|
||||
addon == null
|
||||
? 'Extension Permissions'
|
||||
: '${addon.displayName} Permissions',
|
||||
),
|
||||
),
|
||||
body: switch (addonAsync) {
|
||||
AsyncLoading() when addon == null => const Center(
|
||||
child: CircularProgressIndicator(),
|
||||
),
|
||||
AsyncError(:final error) => Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Text(
|
||||
'Failed to load extension permissions: $error',
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
),
|
||||
_ when addon == null => const Center(
|
||||
child: Text('This extension could not be found.'),
|
||||
),
|
||||
_ => ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
if (permissions.isEmpty && dataCollection.isEmpty)
|
||||
const Card(
|
||||
child: ListTile(
|
||||
leading: Icon(Icons.verified_user_outlined),
|
||||
title: Text('No special permissions listed'),
|
||||
subtitle: Text(
|
||||
'This extension does not currently expose any translated permission details.',
|
||||
),
|
||||
),
|
||||
),
|
||||
if (permissions.isNotEmpty) ...[
|
||||
Text(
|
||||
'Permissions',
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Card(
|
||||
child: Column(
|
||||
children: [
|
||||
for (final permission in permissions)
|
||||
ListTile(
|
||||
leading: const Icon(Icons.check_circle_outline),
|
||||
title: Text(permission),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
if (dataCollection.isNotEmpty) ...[
|
||||
Text(
|
||||
'Required Data Collection',
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Card(
|
||||
child: Column(
|
||||
children: [
|
||||
for (final permission in dataCollection)
|
||||
ListTile(
|
||||
leading: const Icon(Icons.data_usage_outlined),
|
||||
title: Text(permission),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
FilledButton.icon(
|
||||
onPressed: () async {
|
||||
await launchUrl(Uri.parse(_permissionsLearnMoreUrl));
|
||||
},
|
||||
icon: const Icon(Icons.open_in_new),
|
||||
label: const Text('Learn More'),
|
||||
),
|
||||
],
|
||||
),
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
/*
|
||||
* 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:intl/intl.dart';
|
||||
import 'package:weblibre/features/addons/extensions/addon_info.dart';
|
||||
|
||||
class AddonIconView extends StatelessWidget {
|
||||
final AddonInfo addon;
|
||||
final double size;
|
||||
|
||||
const AddonIconView({required this.addon, this.size = 40, super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final bytes = addon.icon;
|
||||
final borderRadius = BorderRadius.circular(12);
|
||||
|
||||
if (bytes != null && bytes.isNotEmpty) {
|
||||
return ClipRRect(
|
||||
borderRadius: borderRadius,
|
||||
child: Image.memory(
|
||||
bytes,
|
||||
width: size,
|
||||
height: size,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (_, _, _) => _FallbackIcon(size: size),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return _FallbackIcon(size: size);
|
||||
}
|
||||
}
|
||||
|
||||
class _FallbackIcon extends StatelessWidget {
|
||||
final double size;
|
||||
|
||||
const _FallbackIcon({required this.size});
|
||||
|
||||
@override
|
||||
Widget build(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 AddonStatusBanner extends StatelessWidget {
|
||||
final AddonInfo addon;
|
||||
|
||||
const AddonStatusBanner({required this.addon, super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final message = addon.statusBannerMessage;
|
||||
if (message == null) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
final isWarning = addon.disabledReason == AddonDisabledReason.softBlocked;
|
||||
final theme = Theme.of(context);
|
||||
final background = isWarning
|
||||
? theme.colorScheme.tertiaryContainer
|
||||
: theme.colorScheme.errorContainer;
|
||||
final foreground = isWarning
|
||||
? theme.colorScheme.onTertiaryContainer
|
||||
: theme.colorScheme.onErrorContainer;
|
||||
final icon = isWarning ? Icons.warning_amber_rounded : Icons.error_outline;
|
||||
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: background,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Icon(icon, color: foreground, size: 20),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Text(
|
||||
message,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(color: foreground),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String formatAddonDate(String raw) {
|
||||
final parsed = DateTime.tryParse(raw);
|
||||
if (parsed == null) {
|
||||
return raw.isEmpty ? 'Unknown' : raw;
|
||||
}
|
||||
|
||||
return DateFormat.yMMMd().format(parsed.toLocal());
|
||||
}
|
||||
|
||||
String formatUpdateAttemptDate(AddonUpdateAttemptInfo attempt) {
|
||||
final date = DateTime.fromMillisecondsSinceEpoch(
|
||||
attempt.dateMillisecondsSinceEpoch,
|
||||
).toLocal();
|
||||
return DateFormat.yMMMd().add_jm().format(date);
|
||||
}
|
||||
|
||||
String formatUpdateAttemptStatus(AddonUpdateAttemptInfo? attempt) {
|
||||
return switch (attempt?.status) {
|
||||
AddonUpdateStatus.successfullyUpdated =>
|
||||
attempt?.message?.isNotEmpty == true
|
||||
? attempt!.message!
|
||||
: 'Updated successfully',
|
||||
AddonUpdateStatus.noUpdateAvailable => 'No update available',
|
||||
AddonUpdateStatus.notInstalled => 'Extension not installed',
|
||||
AddonUpdateStatus.error =>
|
||||
attempt?.message?.isNotEmpty == true
|
||||
? 'Update failed: ${attempt!.message}'
|
||||
: 'Update failed',
|
||||
null => 'No update checks recorded yet',
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
/*
|
||||
* 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:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:weblibre/core/routing/routes.dart';
|
||||
import 'package:weblibre/features/addons/domain/providers.dart';
|
||||
import 'package:weblibre/features/geckoview/domain/providers.dart';
|
||||
import 'package:weblibre/features/geckoview/domain/providers/web_extensions_state.dart';
|
||||
import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/extension_badge_icon.dart';
|
||||
|
||||
class PinnedAddonBar extends ConsumerWidget {
|
||||
const PinnedAddonBar({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final pinnedIds = ref.watch(pinnedAddonIdsProvider);
|
||||
if (pinnedIds.isEmpty) return const SizedBox.shrink();
|
||||
|
||||
final extensions = ref.watch(
|
||||
webExtensionsStateProvider(
|
||||
WebExtensionActionType.browser,
|
||||
).select((value) => value.values.toList()),
|
||||
);
|
||||
|
||||
final pinned = extensions
|
||||
.where((e) => pinnedIds.contains(e.extensionId))
|
||||
.toList();
|
||||
if (pinned.isEmpty) return const SizedBox.shrink();
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(right: 6.0),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
for (final extension in pinned)
|
||||
InkResponse(
|
||||
radius: 22,
|
||||
onTap: () async {
|
||||
await ref
|
||||
.read(addonServiceProvider)
|
||||
.invokeAddonAction(
|
||||
extension.extensionId,
|
||||
WebExtensionActionType.browser,
|
||||
);
|
||||
},
|
||||
onLongPress: () async {
|
||||
await AddonDetailsRoute(
|
||||
addonId: extension.extensionId,
|
||||
).push<void>(context);
|
||||
},
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 6.0,
|
||||
vertical: 8.0,
|
||||
),
|
||||
child: ExtensionBadgeIcon(extension),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
+16
-2
@@ -47,8 +47,7 @@ class AllowUnsignedExtensions extends _$AllowUnsignedExtensions {
|
||||
|
||||
@override
|
||||
FutureOr<bool> build() async {
|
||||
final prefs =
|
||||
await GeckoPrefService().getPrefs([_signatureRequiredPref]);
|
||||
final prefs = await GeckoPrefService().getPrefs([_signatureRequiredPref]);
|
||||
final pref = prefs[_signatureRequiredPref];
|
||||
final allowUnsigned = pref?.value == false;
|
||||
|
||||
@@ -63,6 +62,21 @@ class AllowUnsignedExtensions extends _$AllowUnsignedExtensions {
|
||||
}
|
||||
}
|
||||
|
||||
@Riverpod(keepAlive: true)
|
||||
class AddonAutoUpdate extends _$AddonAutoUpdate {
|
||||
Future<void> setEnabled({required bool enabled}) async {
|
||||
final service = ref.read(addonServiceProvider);
|
||||
await service.setAddonAutoUpdateEnabled(enabled: enabled);
|
||||
|
||||
state = AsyncData(enabled);
|
||||
}
|
||||
|
||||
@override
|
||||
FutureOr<bool> build() {
|
||||
return ref.read(addonServiceProvider).isAddonAutoUpdateEnabled();
|
||||
}
|
||||
}
|
||||
|
||||
@Riverpod(keepAlive: true)
|
||||
class BrowserAddonService extends _$BrowserAddonService {
|
||||
Future<Uri> getAddonXpiUrl(String guid) async {
|
||||
|
||||
+44
@@ -54,6 +54,50 @@ abstract class _$AllowUnsignedExtensions extends $AsyncNotifier<bool> {
|
||||
}
|
||||
}
|
||||
|
||||
@ProviderFor(AddonAutoUpdate)
|
||||
final addonAutoUpdateProvider = AddonAutoUpdateProvider._();
|
||||
|
||||
final class AddonAutoUpdateProvider
|
||||
extends $AsyncNotifierProvider<AddonAutoUpdate, bool> {
|
||||
AddonAutoUpdateProvider._()
|
||||
: super(
|
||||
from: null,
|
||||
argument: null,
|
||||
retry: null,
|
||||
name: r'addonAutoUpdateProvider',
|
||||
isAutoDispose: false,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@override
|
||||
String debugGetCreateSourceHash() => _$addonAutoUpdateHash();
|
||||
|
||||
@$internal
|
||||
@override
|
||||
AddonAutoUpdate create() => AddonAutoUpdate();
|
||||
}
|
||||
|
||||
String _$addonAutoUpdateHash() => r'89791e8b771da715b068bbdbe5c3c24a3dad4194';
|
||||
|
||||
abstract class _$AddonAutoUpdate extends $AsyncNotifier<bool> {
|
||||
FutureOr<bool> build();
|
||||
@$mustCallSuper
|
||||
@override
|
||||
void runBuild() {
|
||||
final ref = this.ref as $Ref<AsyncValue<bool>, bool>;
|
||||
final element =
|
||||
ref.element
|
||||
as $ClassProviderElement<
|
||||
AnyNotifier<AsyncValue<bool>, bool>,
|
||||
AsyncValue<bool>,
|
||||
Object?,
|
||||
Object?
|
||||
>;
|
||||
element.handleCreate(ref, build);
|
||||
}
|
||||
}
|
||||
|
||||
@ProviderFor(BrowserAddonService)
|
||||
final browserAddonServiceProvider = BrowserAddonServiceProvider._();
|
||||
|
||||
|
||||
+9
-1
@@ -76,7 +76,10 @@ class _InstallLocalAddonSheet extends HookConsumerWidget {
|
||||
.installFromFile(selectedFile.value!);
|
||||
|
||||
if (context.mounted) {
|
||||
showInfoMessage(context, 'Extension installed successfully');
|
||||
showInfoMessage(
|
||||
context,
|
||||
'Extension installed. Automatic updates are disabled for this local version.',
|
||||
);
|
||||
context.pop(true);
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -149,6 +152,11 @@ class _InstallLocalAddonSheet extends HookConsumerWidget {
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
'Extensions installed from a local XPI stay pinned to that version and will not update automatically.',
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
if (errorMessage.value != null) ...[
|
||||
const SizedBox(height: 8),
|
||||
Container(
|
||||
|
||||
+13
@@ -44,6 +44,7 @@ import 'package:weblibre/features/geckoview/features/browser/presentation/contro
|
||||
import 'package:weblibre/features/geckoview/features/browser/presentation/controllers/toolbar_visibility.dart';
|
||||
import 'package:weblibre/features/geckoview/features/browser/presentation/dialogs/keep_tab_dialog.dart';
|
||||
import 'package:weblibre/features/geckoview/features/browser/presentation/providers/browser_viewport_toolbar_insets.dart';
|
||||
import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/addon_popup_bottom_sheet.dart';
|
||||
import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/browser_modules/bottom_app_bar.dart';
|
||||
import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/browser_modules/browser_fab.dart';
|
||||
import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/browser_modules/browser_view.dart';
|
||||
@@ -305,6 +306,18 @@ class BrowserScreen extends HookConsumerWidget {
|
||||
},
|
||||
);
|
||||
|
||||
final addonService = ref.watch(addonServiceProvider);
|
||||
useOnStreamChange(
|
||||
addonService.popupStream,
|
||||
onData: (event) async {
|
||||
await showAddonPopupBottomSheet(
|
||||
context,
|
||||
extensionId: event.extensionId,
|
||||
extensionName: event.extensionName,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
useOnAppLifecycleStateChange((previous, current) {
|
||||
switch (current) {
|
||||
case AppLifecycleState.resumed:
|
||||
|
||||
+121
@@ -0,0 +1,121 @@
|
||||
/*
|
||||
* 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:flutter/foundation.dart';
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/rendering.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
const _viewType = 'eu.weblibre/addon_popup';
|
||||
|
||||
Future<void> showAddonPopupBottomSheet(
|
||||
BuildContext context, {
|
||||
required String extensionId,
|
||||
required String extensionName,
|
||||
}) {
|
||||
return showModalBottomSheet<void>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(24)),
|
||||
),
|
||||
builder: (context) => _AddonPopupSheet(
|
||||
extensionId: extensionId,
|
||||
extensionName: extensionName,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
class _AddonPopupSheet extends StatelessWidget {
|
||||
final String extensionId;
|
||||
final String extensionName;
|
||||
|
||||
const _AddonPopupSheet({
|
||||
required this.extensionId,
|
||||
required this.extensionName,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
return DraggableScrollableSheet(
|
||||
initialChildSize: 0.65,
|
||||
minChildSize: 0.3,
|
||||
maxChildSize: 0.95,
|
||||
expand: false,
|
||||
builder: (context, scrollController) {
|
||||
return Column(
|
||||
children: [
|
||||
Container(
|
||||
margin: const EdgeInsets.only(top: 12, bottom: 8),
|
||||
height: 4,
|
||||
width: 40,
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.onSurfaceVariant.withValues(alpha: 0.4),
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
),
|
||||
const Divider(height: 1),
|
||||
Expanded(child: _AddonPopupPlatformView(extensionId: extensionId)),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _AddonPopupPlatformView extends StatelessWidget {
|
||||
final String extensionId;
|
||||
|
||||
const _AddonPopupPlatformView({required this.extensionId});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return PlatformViewLink(
|
||||
viewType: _viewType,
|
||||
surfaceFactory: (context, controller) {
|
||||
return AndroidViewSurface(
|
||||
controller: controller as AndroidViewController,
|
||||
gestureRecognizers: const <Factory<OneSequenceGestureRecognizer>>{
|
||||
Factory<EagerGestureRecognizer>(EagerGestureRecognizer.new),
|
||||
},
|
||||
hitTestBehavior: PlatformViewHitTestBehavior.opaque,
|
||||
);
|
||||
},
|
||||
onCreatePlatformView: (params) {
|
||||
final controller = PlatformViewsService.initExpensiveAndroidView(
|
||||
id: params.id,
|
||||
viewType: _viewType,
|
||||
layoutDirection: TextDirection.ltr,
|
||||
creationParams: <String, Object?>{'extensionId': extensionId},
|
||||
creationParamsCodec: const StandardMessageCodec(),
|
||||
);
|
||||
controller.addOnPlatformViewCreatedListener(
|
||||
params.onPlatformViewCreated,
|
||||
);
|
||||
unawaited(controller.create());
|
||||
return controller;
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
+4
-2
@@ -36,6 +36,7 @@ import 'package:skeletonizer/skeletonizer.dart';
|
||||
import 'package:weblibre/core/design/app_colors.dart';
|
||||
import 'package:weblibre/core/providers/persisted_bool.dart';
|
||||
import 'package:weblibre/core/routing/routes.dart';
|
||||
import 'package:weblibre/features/addons/presentation/screens/addon_internal_settings.dart';
|
||||
import 'package:weblibre/features/geckoview/domain/controllers/bottom_sheet.dart';
|
||||
import 'package:weblibre/features/geckoview/domain/entities/states/readerable.dart';
|
||||
import 'package:weblibre/features/geckoview/domain/entities/tab_container_selection.dart';
|
||||
@@ -1706,9 +1707,10 @@ class _ExtensionsCard extends HookConsumerWidget {
|
||||
WebExtensionActionType.browser,
|
||||
).select((value) => value.values.toList()),
|
||||
);
|
||||
final rootContext = Navigator.of(context, rootNavigator: true).context;
|
||||
Future<void> openExtensionSettings(String extensionId) async {
|
||||
Navigator.pop(context);
|
||||
await addonService.startAddonSettingsActivity(extensionId);
|
||||
await openAddonSettingsFlowById(rootContext, ref, extensionId);
|
||||
}
|
||||
|
||||
return _buildMenuCard(
|
||||
@@ -1804,7 +1806,7 @@ class _ExtensionsCard extends HookConsumerWidget {
|
||||
icon: MdiIcons.puzzleEdit,
|
||||
onTap: () async {
|
||||
Navigator.pop(context);
|
||||
await addonService.startAddonManagerActivity();
|
||||
await const AddonManagerRoute().push<void>(rootContext);
|
||||
},
|
||||
),
|
||||
_buildSubTile(
|
||||
|
||||
+2
@@ -27,6 +27,7 @@ import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:flutter_material_design_icons/flutter_material_design_icons.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:weblibre/core/design/app_colors.dart';
|
||||
import 'package:weblibre/features/addons/presentation/widgets/pinned_addon_bar.dart';
|
||||
import 'package:weblibre/features/geckoview/domain/controllers/bottom_sheet.dart';
|
||||
import 'package:weblibre/features/geckoview/domain/providers/selected_tab.dart';
|
||||
import 'package:weblibre/features/geckoview/domain/repositories/tab.dart';
|
||||
@@ -252,6 +253,7 @@ class BrowserTabBar extends HookConsumerWidget {
|
||||
: const AppBarTitle()
|
||||
: null,
|
||||
actions: [
|
||||
const PinnedAddonBar(),
|
||||
if (isSmallWebMode)
|
||||
ReaderButton(
|
||||
buttonBuilder: (isLoading, readerActive, icon) => ToolbarButton(
|
||||
|
||||
+135
-51
@@ -46,8 +46,11 @@ class ExtensionsSettingsScreen extends StatelessWidget {
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12.0),
|
||||
children: const [
|
||||
SettingSection(name: 'Extensions'),
|
||||
_ManageExtensionsTile(),
|
||||
_InstallLocalAddonTile(),
|
||||
_AddonCollectionTile(),
|
||||
SettingSection(name: 'Updates'),
|
||||
_AutoUpdateTile(),
|
||||
SettingSection(name: 'Security'),
|
||||
_AllowUnsignedExtensionsTile(),
|
||||
],
|
||||
@@ -59,6 +62,34 @@ class ExtensionsSettingsScreen extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class _ManageExtensionsTile extends StatelessWidget {
|
||||
const _ManageExtensionsTile();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return CustomListTile(
|
||||
title: 'Manage Extensions',
|
||||
subtitle:
|
||||
'Browse installed, disabled, available, and unsupported extensions',
|
||||
prefix: Padding(
|
||||
padding: const EdgeInsets.only(right: 16.0),
|
||||
child: Icon(
|
||||
MdiIcons.puzzleEdit,
|
||||
size: 24,
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
suffix: FilledButton.icon(
|
||||
onPressed: () async {
|
||||
await const AddonManagerRoute().push<void>(context);
|
||||
},
|
||||
icon: const Icon(Icons.open_in_new),
|
||||
label: const Text('Open'),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _InstallLocalAddonTile extends StatelessWidget {
|
||||
const _InstallLocalAddonTile();
|
||||
|
||||
@@ -113,6 +144,45 @@ class _AddonCollectionTile extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class _AutoUpdateTile extends ConsumerWidget {
|
||||
const _AutoUpdateTile();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final autoUpdate = ref.watch(addonAutoUpdateProvider);
|
||||
|
||||
return autoUpdate.when(
|
||||
data: (enabled) => SwitchListTile.adaptive(
|
||||
title: const Text('Automatic updates'),
|
||||
subtitle: const Text(
|
||||
'Automatically check for and install extension updates every 12 hours',
|
||||
),
|
||||
secondary: const Icon(Icons.system_update_alt),
|
||||
value: enabled,
|
||||
onChanged: (value) async {
|
||||
await ref
|
||||
.read(addonAutoUpdateProvider.notifier)
|
||||
.setEnabled(enabled: value);
|
||||
},
|
||||
),
|
||||
loading: () => const SwitchListTile.adaptive(
|
||||
title: Text('Automatic updates'),
|
||||
subtitle: Text(
|
||||
'Automatically check for and install extension updates every 12 hours',
|
||||
),
|
||||
secondary: Icon(Icons.system_update_alt),
|
||||
value: true,
|
||||
onChanged: null,
|
||||
),
|
||||
error: (error, stack) => ListTile(
|
||||
leading: const Icon(Icons.error_outline),
|
||||
title: const Text('Automatic updates'),
|
||||
subtitle: Text('Failed to load: $error'),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _AllowUnsignedExtensionsTile extends ConsumerWidget {
|
||||
const _AllowUnsignedExtensionsTile();
|
||||
|
||||
@@ -120,63 +190,78 @@ class _AllowUnsignedExtensionsTile extends ConsumerWidget {
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final allowUnsigned = ref.watch(allowUnsignedExtensionsProvider);
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
SwitchListTile.adaptive(
|
||||
title: const Text('Allow unsigned extensions'),
|
||||
subtitle: const Text(
|
||||
'Unsigned extensions have not been verified by Mozilla',
|
||||
),
|
||||
secondary: const Icon(Icons.extension_off),
|
||||
value: allowUnsigned.value ?? false,
|
||||
onChanged: allowUnsigned.isLoading
|
||||
? null
|
||||
: (value) async {
|
||||
if (value) {
|
||||
final confirmed =
|
||||
await _showAllowUnsignedConfirmationDialog(context);
|
||||
if (confirmed != true) return;
|
||||
}
|
||||
await ref
|
||||
.read(allowUnsignedExtensionsProvider.notifier)
|
||||
.setAllowUnsigned(allow: value);
|
||||
},
|
||||
),
|
||||
if (allowUnsigned.value == true)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16.0),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(
|
||||
return allowUnsigned.when(
|
||||
data: (allowed) => Column(
|
||||
children: [
|
||||
SwitchListTile.adaptive(
|
||||
title: const Text('Allow unsigned extensions'),
|
||||
subtitle: const Text(
|
||||
'Unsigned extensions have not been verified by Mozilla',
|
||||
),
|
||||
secondary: const Icon(Icons.extension_off),
|
||||
value: allowed,
|
||||
onChanged: (value) async {
|
||||
if (value) {
|
||||
final confirmed = await _showAllowUnsignedConfirmationDialog(
|
||||
context,
|
||||
).colorScheme.errorContainer.withValues(alpha: 0.5),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: Theme.of(context).colorScheme.error),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.warning_amber,
|
||||
);
|
||||
if (confirmed != true) return;
|
||||
}
|
||||
await ref
|
||||
.read(allowUnsignedExtensionsProvider.notifier)
|
||||
.setAllowUnsigned(allow: value);
|
||||
},
|
||||
),
|
||||
if (allowed)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16.0),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.errorContainer.withValues(alpha: 0.5),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(
|
||||
color: Theme.of(context).colorScheme.error,
|
||||
size: 20,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Only install unsigned extensions from sources you trust. '
|
||||
'They may contain malicious code.',
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).colorScheme.onErrorContainer,
|
||||
fontSize: 12,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.warning_amber,
|
||||
color: Theme.of(context).colorScheme.error,
|
||||
size: 20,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Only install unsigned extensions from sources you trust. '
|
||||
'They may contain malicious code.',
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).colorScheme.onErrorContainer,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
loading: () => const SwitchListTile.adaptive(
|
||||
title: Text('Allow unsigned extensions'),
|
||||
subtitle: Text('Unsigned extensions have not been verified by Mozilla'),
|
||||
secondary: Icon(Icons.extension_off),
|
||||
value: false,
|
||||
onChanged: null,
|
||||
),
|
||||
error: (error, stack) => ListTile(
|
||||
leading: const Icon(Icons.error_outline),
|
||||
title: const Text('Allow unsigned extensions'),
|
||||
subtitle: Text('Failed to load: $error'),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -262,4 +347,3 @@ class _AllowUnsignedConfirmationDialog extends HookWidget {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user