addon store initial
This commit is contained in:
@@ -0,0 +1,218 @@
|
||||
/*
|
||||
* Copyright (c) 2024-2026 Fabian Freund.
|
||||
*
|
||||
* This file is part of WebLibre
|
||||
* (see https://weblibre.eu).
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as
|
||||
* published by the Free Software Foundation, either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:fading_scroll/fading_scroll.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:flutter_mozilla_components/flutter_mozilla_components.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:weblibre/core/routing/routes.dart';
|
||||
import 'package:weblibre/features/addons/domain/providers.dart';
|
||||
import 'package:weblibre/features/addons/presentation/widgets/addon_listing_card.dart';
|
||||
|
||||
class AddonBrowseView extends HookConsumerWidget {
|
||||
const AddonBrowseView({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final app = ref.watch(addonStoreAppFilterProvider);
|
||||
|
||||
final searchController = useTextEditingController();
|
||||
final query = useState<String>('');
|
||||
final debounceTimer = useRef<Timer?>(null);
|
||||
|
||||
useEffect(
|
||||
() =>
|
||||
() => debounceTimer.value?.cancel(),
|
||||
const [],
|
||||
);
|
||||
|
||||
final listingsAsync = ref.watch(
|
||||
searchAddonListingsProvider(query.value, app),
|
||||
);
|
||||
|
||||
final installed = ref.watch(
|
||||
addonListProvider.select(
|
||||
(value) =>
|
||||
value.value?.where((a) => a.isInstalled).map((a) => a.id).toSet() ??
|
||||
const <String>{},
|
||||
),
|
||||
);
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 0),
|
||||
child: SizedBox(
|
||||
width: double.infinity,
|
||||
child: SegmentedButton<AddonStoreApp>(
|
||||
segments: const [
|
||||
ButtonSegment(
|
||||
value: AddonStoreApp.android,
|
||||
icon: Icon(Icons.phone_android),
|
||||
label: Text('Android'),
|
||||
),
|
||||
ButtonSegment(
|
||||
value: AddonStoreApp.firefox,
|
||||
icon: Icon(Icons.desktop_windows),
|
||||
label: Text('Desktop'),
|
||||
),
|
||||
],
|
||||
selected: {app},
|
||||
onSelectionChanged: (selection) => ref
|
||||
.read(addonStoreAppFilterProvider.notifier)
|
||||
.setApp(selection.first),
|
||||
),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: TextField(
|
||||
controller: searchController,
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Search addons.mozilla.org',
|
||||
prefixIcon: const Icon(Icons.search),
|
||||
suffixIcon: query.value.isEmpty
|
||||
? null
|
||||
: IconButton(
|
||||
icon: const Icon(Icons.clear),
|
||||
onPressed: () {
|
||||
searchController.clear();
|
||||
query.value = '';
|
||||
},
|
||||
),
|
||||
border: const OutlineInputBorder(),
|
||||
isDense: true,
|
||||
),
|
||||
onChanged: (text) {
|
||||
debounceTimer.value?.cancel();
|
||||
debounceTimer.value = Timer(
|
||||
const Duration(milliseconds: 400),
|
||||
() => query.value = text,
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
if (app == AddonStoreApp.firefox)
|
||||
const Padding(
|
||||
padding: EdgeInsets.fromLTRB(16, 0, 16, 12),
|
||||
child: _DesktopCompatibilityWarning(),
|
||||
),
|
||||
Expanded(
|
||||
child: listingsAsync.when(
|
||||
skipLoadingOnReload: true,
|
||||
data: (listings) =>
|
||||
_ListingList(listings: listings, installedIds: installed),
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (error, _) => Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(Icons.error_outline, size: 40),
|
||||
const SizedBox(height: 12),
|
||||
const Text('Failed to load extensions'),
|
||||
const SizedBox(height: 8),
|
||||
Text(error.toString(), textAlign: TextAlign.center),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _DesktopCompatibilityWarning extends StatelessWidget {
|
||||
const _DesktopCompatibilityWarning();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.tertiaryContainer.withValues(alpha: 0.5),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: theme.colorScheme.tertiary),
|
||||
),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Icon(Icons.info_outline, color: theme.colorScheme.tertiary, size: 20),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Desktop extensions are not reviewed for mobile. Some may not '
|
||||
'work, may crash, or may behave unexpectedly on Android.',
|
||||
style: TextStyle(
|
||||
color: theme.colorScheme.onTertiaryContainer,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ListingList extends StatelessWidget {
|
||||
final List<AddonListing> listings;
|
||||
final Set<String> installedIds;
|
||||
|
||||
const _ListingList({required this.listings, required this.installedIds});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (listings.isEmpty) {
|
||||
return const Center(child: Text('No extensions found.'));
|
||||
}
|
||||
|
||||
return FadingScroll(
|
||||
fadingSize: 25,
|
||||
builder: (context, controller) {
|
||||
return ListView.builder(
|
||||
controller: controller,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
itemCount: listings.length,
|
||||
itemBuilder: (context, index) {
|
||||
final listing = listings[index];
|
||||
return AddonListingCard(
|
||||
listing: listing,
|
||||
isInstalled: installedIds.contains(listing.id),
|
||||
onTap: () async {
|
||||
await AddonListingDetailsRoute(
|
||||
addonId: listing.id,
|
||||
$extra: listing,
|
||||
).push<void>(context);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,7 @@
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_markdown/flutter_markdown.dart';
|
||||
import 'package:flutter_mozilla_components/flutter_mozilla_components.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
@@ -27,6 +28,7 @@ import 'package:weblibre/features/addons/extensions/addon_info.dart';
|
||||
import 'package:weblibre/features/addons/presentation/screens/addon_internal_settings.dart';
|
||||
import 'package:weblibre/features/addons/presentation/widgets/addon_ui.dart';
|
||||
import 'package:weblibre/features/geckoview/features/browser/domain/services/browser_addon.dart';
|
||||
import 'package:weblibre/utils/number_format.dart';
|
||||
import 'package:weblibre/utils/ui_helper.dart';
|
||||
|
||||
class AddonDetailsScreen extends ConsumerWidget {
|
||||
@@ -163,6 +165,7 @@ class _AddonHeader extends StatelessWidget {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return Card(
|
||||
color: Theme.of(context).colorScheme.surfaceContainerHigh,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
@@ -188,7 +191,6 @@ class _AddonHeader extends StatelessWidget {
|
||||
const SizedBox(height: 12),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
Chip(
|
||||
label: Text(
|
||||
@@ -204,7 +206,7 @@ class _AddonHeader extends StatelessWidget {
|
||||
avatar: const Icon(Icons.star, size: 18),
|
||||
label: Text(
|
||||
'${addon.ratingAverage!.toStringAsFixed(1)}'
|
||||
' (${addon.ratingReviews ?? 0})',
|
||||
' (${formatCompactNumber(addon.ratingReviews ?? 0)})',
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -272,6 +274,7 @@ class _ManagementSection extends ConsumerWidget {
|
||||
Text('Management', style: theme.textTheme.titleMedium),
|
||||
const SizedBox(height: 8),
|
||||
Card(
|
||||
color: Theme.of(context).colorScheme.surfaceContainerHigh,
|
||||
child: Column(
|
||||
children: [
|
||||
if (addon.isSupported)
|
||||
@@ -423,7 +426,7 @@ class _UpdatesSection extends ConsumerWidget {
|
||||
addon.installedVersion != availableVersion;
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text('Updates', style: theme.textTheme.titleMedium),
|
||||
const SizedBox(height: 8),
|
||||
@@ -566,20 +569,42 @@ void _reportUpdateResult(
|
||||
}
|
||||
}
|
||||
|
||||
class _DescriptionCard extends StatelessWidget {
|
||||
class _DescriptionCard extends ConsumerWidget {
|
||||
final AddonInfo addon;
|
||||
|
||||
const _DescriptionCard({required this.addon});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final description = addon.description;
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final markdownAsync = ref.watch(addonDescriptionMarkdownProvider(addon.id));
|
||||
|
||||
return Card(
|
||||
color: Theme.of(context).colorScheme.surfaceContainerHigh,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Text(
|
||||
description.isNotEmpty ? description : 'No description provided.',
|
||||
child: markdownAsync.when(
|
||||
skipLoadingOnReload: true,
|
||||
data: (markdown) => markdown.isEmpty
|
||||
? const Text('No description provided.')
|
||||
: MarkdownBody(
|
||||
data: markdown,
|
||||
selectable: true,
|
||||
onTapLink: (text, href, title) {
|
||||
if (href != null && href.isNotEmpty) {
|
||||
launchUrl(Uri.parse(href));
|
||||
}
|
||||
},
|
||||
),
|
||||
loading: () => Text(
|
||||
addon.description.isNotEmpty
|
||||
? addon.description
|
||||
: 'Loading description…',
|
||||
),
|
||||
error: (_, _) => Text(
|
||||
addon.description.isNotEmpty
|
||||
? addon.description
|
||||
: 'No description provided.',
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -594,6 +619,7 @@ class _DetailsCard extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Card(
|
||||
color: Theme.of(context).colorScheme.surfaceContainerHigh,
|
||||
child: Column(
|
||||
children: [
|
||||
if ((addon.authorName ?? '').isNotEmpty)
|
||||
|
||||
@@ -0,0 +1,648 @@
|
||||
/*
|
||||
* Copyright (c) 2024-2026 Fabian Freund.
|
||||
*
|
||||
* This file is part of WebLibre
|
||||
* (see https://weblibre.eu).
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as
|
||||
* published by the Free Software Foundation, either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
import 'package:fading_scroll/fading_scroll.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:flutter_markdown/flutter_markdown.dart';
|
||||
import 'package:flutter_mozilla_components/flutter_mozilla_components.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
import 'package:weblibre/features/addons/domain/providers.dart';
|
||||
import 'package:weblibre/features/addons/presentation/widgets/addon_listing_card.dart';
|
||||
import 'package:weblibre/features/addons/utils/permissions.dart';
|
||||
import 'package:weblibre/features/geckoview/domain/providers.dart';
|
||||
import 'package:weblibre/utils/number_format.dart';
|
||||
import 'package:weblibre/utils/ui_helper.dart';
|
||||
|
||||
class AddonListingDetailsScreen extends ConsumerWidget {
|
||||
final AddonListing listing;
|
||||
|
||||
const AddonListingDetailsScreen({required this.listing, super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
final installedAsync = ref.watch(addonListProvider);
|
||||
|
||||
final isInstalled = installedAsync.maybeWhen(
|
||||
data: (addons) => addons.any((a) => a.id == listing.id && a.isInstalled),
|
||||
orElse: () => false,
|
||||
);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: Text(listing.name)),
|
||||
body: SafeArea(
|
||||
child: FadingScroll(
|
||||
fadingSize: 25,
|
||||
builder: (context, controller) {
|
||||
return ListView(
|
||||
controller: controller,
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
AddonListingIcon(iconUrl: listing.iconUrl, size: 64),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(listing.name, style: theme.textTheme.titleLarge),
|
||||
if (listing.authorName != null) ...[
|
||||
const SizedBox(height: 4),
|
||||
_AuthorLink(
|
||||
name: listing.authorName!,
|
||||
url: listing.authorUrl,
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Version ${listing.latestVersion}',
|
||||
style: theme.textTheme.bodySmall,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_InstallButton(listing: listing, isInstalled: isInstalled),
|
||||
const SizedBox(height: 16),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
children: [
|
||||
if (listing.promoted == AddonStorePromoted.recommended)
|
||||
const Chip(
|
||||
avatar: Icon(Icons.verified, size: 16),
|
||||
label: Text('Recommended'),
|
||||
),
|
||||
if (listing.ratingAverage != null)
|
||||
Chip(
|
||||
avatar: const Icon(Icons.star, size: 16),
|
||||
label: Text(
|
||||
'${listing.ratingAverage!.toStringAsFixed(1)}'
|
||||
'${listing.ratingReviews != null ? ' (${formatCompactNumber(listing.ratingReviews!)})' : ''}',
|
||||
),
|
||||
),
|
||||
if (listing.averageDailyUsers != null)
|
||||
Chip(
|
||||
avatar: const Icon(Icons.group_outlined, size: 16),
|
||||
label: Text(
|
||||
'${formatCompactNumber(listing.averageDailyUsers!)} users',
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (listing.previews.isNotEmpty) ...[
|
||||
const SizedBox(height: 24),
|
||||
_ScreenshotsSection(previews: listing.previews),
|
||||
],
|
||||
if ((listing.summary ?? '').isNotEmpty) ...[
|
||||
const SizedBox(height: 16),
|
||||
Text(listing.summary!, style: theme.textTheme.bodyLarge),
|
||||
],
|
||||
if ((listing.description ?? '').isNotEmpty) ...[
|
||||
const SizedBox(height: 16),
|
||||
const _SectionHeader(title: 'About this extension'),
|
||||
const SizedBox(height: 8),
|
||||
_ExpandableDescription(html: listing.description!),
|
||||
],
|
||||
if (_hasFriendlyPermissions(listing)) ...[
|
||||
const SizedBox(height: 24),
|
||||
const _SectionHeader(title: 'Permissions'),
|
||||
const SizedBox(height: 8),
|
||||
_PermissionsSection(listing: listing),
|
||||
],
|
||||
if (_hasTechnicalPermissions(listing)) ...[
|
||||
const SizedBox(height: 24),
|
||||
const _SectionHeader(title: 'Technical permissions'),
|
||||
const SizedBox(height: 8),
|
||||
_TechnicalPermissionsSection(listing: listing),
|
||||
],
|
||||
const SizedBox(height: 24),
|
||||
const _SectionHeader(title: 'More information'),
|
||||
const SizedBox(height: 8),
|
||||
_MoreInformationSection(listing: listing),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SectionHeader extends StatelessWidget {
|
||||
final String title;
|
||||
const _SectionHeader({required this.title});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Text(title, style: Theme.of(context).textTheme.titleMedium);
|
||||
}
|
||||
}
|
||||
|
||||
class _AuthorLink extends StatelessWidget {
|
||||
final String name;
|
||||
final String? url;
|
||||
|
||||
const _AuthorLink({required this.name, required this.url});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final style = Theme.of(context).textTheme.bodyMedium;
|
||||
if (url == null) return Text('by $name', style: style);
|
||||
return InkWell(
|
||||
onTap: () => launchUrl(Uri.parse(url!)),
|
||||
child: Text(
|
||||
'by $name',
|
||||
style: style?.copyWith(
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
decoration: TextDecoration.underline,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _InstallButton extends ConsumerWidget {
|
||||
final AddonListing listing;
|
||||
final bool isInstalled;
|
||||
|
||||
const _InstallButton({required this.listing, required this.isInstalled});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final busy = ref.watch(addonBusyIdsProvider).contains(listing.id);
|
||||
|
||||
if (isInstalled) {
|
||||
return FilledButton.icon(
|
||||
onPressed: null,
|
||||
icon: const Icon(Icons.check),
|
||||
label: const Text('Installed'),
|
||||
);
|
||||
}
|
||||
|
||||
return FilledButton.icon(
|
||||
onPressed: busy
|
||||
? null
|
||||
: () async {
|
||||
ref.read(addonBusyIdsProvider.notifier).add(listing.id);
|
||||
try {
|
||||
await ref
|
||||
.read(addonServiceProvider)
|
||||
.installAddon(Uri.parse(listing.downloadUrl));
|
||||
ref.invalidate(addonListProvider);
|
||||
ref.invalidate(addonDetailsProvider(listing.id));
|
||||
if (!context.mounted) return;
|
||||
showInfoMessage(context, '${listing.name} installed');
|
||||
} catch (error) {
|
||||
if (!context.mounted) return;
|
||||
showInfoMessage(context, 'Install failed: $error');
|
||||
} finally {
|
||||
ref.read(addonBusyIdsProvider.notifier).remove(listing.id);
|
||||
}
|
||||
},
|
||||
icon: busy
|
||||
? const SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Icon(Icons.download_outlined),
|
||||
label: const Text('Install'),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ScreenshotsSection extends StatelessWidget {
|
||||
final List<AddonListingPreview> previews;
|
||||
const _ScreenshotsSection({required this.previews});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SizedBox(
|
||||
height: 200,
|
||||
child: ListView.separated(
|
||||
scrollDirection: Axis.horizontal,
|
||||
itemCount: previews.length,
|
||||
separatorBuilder: (_, _) => const SizedBox(width: 12),
|
||||
itemBuilder: (context, index) {
|
||||
final p = previews[index];
|
||||
return GestureDetector(
|
||||
onTap: () => _showFullScreenImage(context, p.imageUrl),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Image.network(
|
||||
p.thumbnailUrl ?? p.imageUrl,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (_, _, _) => Container(
|
||||
width: 300,
|
||||
color: Theme.of(context).colorScheme.surfaceContainerHighest,
|
||||
child: const Icon(Icons.broken_image_outlined),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showFullScreenImage(BuildContext context, String url) {
|
||||
Navigator.of(context).push(
|
||||
PageRouteBuilder<void>(
|
||||
opaque: false,
|
||||
barrierColor: Colors.black87,
|
||||
pageBuilder: (_, _, _) => _FullScreenImage(url: url),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _FullScreenImage extends StatelessWidget {
|
||||
final String url;
|
||||
const _FullScreenImage({required this.url});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.transparent,
|
||||
body: GestureDetector(
|
||||
onTap: () => Navigator.of(context).pop(),
|
||||
child: Center(
|
||||
child: InteractiveViewer(
|
||||
child: Image.network(url, fit: BoxFit.contain),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ExpandableDescription extends HookConsumerWidget {
|
||||
final String html;
|
||||
const _ExpandableDescription({required this.html});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final expanded = useState(false);
|
||||
final markdownAsync = ref.watch(addonHtmlMarkdownProvider(html));
|
||||
final body = markdownAsync.when(
|
||||
skipLoadingOnReload: true,
|
||||
data: (markdown) => MarkdownBody(
|
||||
data: markdown.isEmpty ? html : markdown,
|
||||
onTapLink: (_, href, _) {
|
||||
if (href != null) launchUrl(Uri.parse(href));
|
||||
},
|
||||
),
|
||||
loading: () => Text(html),
|
||||
error: (_, _) => Text(html),
|
||||
);
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
AnimatedSize(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
alignment: Alignment.topCenter,
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(
|
||||
maxHeight: expanded.value ? double.infinity : 160,
|
||||
),
|
||||
child: ShaderMask(
|
||||
shaderCallback: (bounds) {
|
||||
if (expanded.value) {
|
||||
return const LinearGradient(
|
||||
colors: [Colors.black, Colors.black],
|
||||
).createShader(bounds);
|
||||
}
|
||||
return const LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [Colors.black, Colors.black, Colors.transparent],
|
||||
stops: [0.0, 0.75, 1.0],
|
||||
).createShader(bounds);
|
||||
},
|
||||
blendMode: BlendMode.dstIn,
|
||||
child: SingleChildScrollView(
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
child: body,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => expanded.value = !expanded.value,
|
||||
child: Text(expanded.value ? 'Show less' : 'Read more'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
typedef _PermissionGroup = ({String title, List<String> perms});
|
||||
|
||||
class _PermissionsSection extends StatelessWidget {
|
||||
final AddonListing listing;
|
||||
const _PermissionsSection({required this.listing});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final items = <Widget>[];
|
||||
|
||||
final groups = <_PermissionGroup>[
|
||||
(title: 'Required', perms: listing.permissions),
|
||||
(title: 'Websites', perms: listing.hostPermissions),
|
||||
(title: 'Optional', perms: listing.optionalPermissions),
|
||||
(title: 'Data collection', perms: listing.dataCollectionPermissions),
|
||||
];
|
||||
|
||||
for (final group in groups) {
|
||||
final friendly = group.perms
|
||||
.map(describePermission)
|
||||
.where((d) => !d.technical)
|
||||
.toList();
|
||||
if (friendly.isEmpty) continue;
|
||||
items.add(
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 8, bottom: 4),
|
||||
child: Text(group.title, style: theme.textTheme.titleSmall),
|
||||
),
|
||||
);
|
||||
for (final d in friendly) {
|
||||
items.add(
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 2),
|
||||
child: Text('\u2022 ${d.text}', style: theme.textTheme.bodyMedium),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: items,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _TechnicalPermissionsSection extends StatelessWidget {
|
||||
final AddonListing listing;
|
||||
const _TechnicalPermissionsSection({required this.listing});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final items = <Widget>[];
|
||||
|
||||
final groups = <_PermissionGroup>[
|
||||
(title: 'Required', perms: listing.permissions),
|
||||
(title: 'Websites', perms: listing.hostPermissions),
|
||||
(title: 'Optional', perms: listing.optionalPermissions),
|
||||
(title: 'Data collection', perms: listing.dataCollectionPermissions),
|
||||
];
|
||||
|
||||
final monoStyle = TextStyle(
|
||||
fontFamily: 'monospace',
|
||||
fontSize: (theme.textTheme.bodyMedium?.fontSize ?? 14) - 1,
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
);
|
||||
|
||||
for (final group in groups) {
|
||||
final technical = group.perms
|
||||
.map(describePermission)
|
||||
.where((d) => d.technical)
|
||||
.toList();
|
||||
if (technical.isEmpty) continue;
|
||||
items.add(
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 8, bottom: 4),
|
||||
child: Text(group.title, style: theme.textTheme.titleSmall),
|
||||
),
|
||||
);
|
||||
for (final d in technical) {
|
||||
items.add(
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 2),
|
||||
child: Text.rich(
|
||||
TextSpan(
|
||||
children: [
|
||||
const TextSpan(text: '\u2022 '),
|
||||
TextSpan(text: d.text, style: monoStyle),
|
||||
],
|
||||
style: theme.textTheme.bodyMedium,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: items,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
bool _hasTechnicalPermissions(AddonListing l) {
|
||||
bool any(List<String> list) =>
|
||||
list.any((p) => describePermission(p).technical);
|
||||
return any(l.permissions) ||
|
||||
any(l.hostPermissions) ||
|
||||
any(l.optionalPermissions) ||
|
||||
any(l.dataCollectionPermissions);
|
||||
}
|
||||
|
||||
bool _hasFriendlyPermissions(AddonListing l) {
|
||||
bool any(List<String> list) =>
|
||||
list.any((p) => !describePermission(p).technical);
|
||||
return any(l.permissions) ||
|
||||
any(l.hostPermissions) ||
|
||||
any(l.optionalPermissions) ||
|
||||
any(l.dataCollectionPermissions);
|
||||
}
|
||||
|
||||
class _MoreInformationSection extends StatelessWidget {
|
||||
final AddonListing listing;
|
||||
const _MoreInformationSection({required this.listing});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final rows = <Widget>[];
|
||||
|
||||
rows.add(_InfoRow(label: 'Version', value: listing.latestVersion));
|
||||
|
||||
if (listing.fileSize != null) {
|
||||
rows.add(_InfoRow(label: 'Size', value: formatBytes(listing.fileSize!)));
|
||||
}
|
||||
|
||||
if (listing.lastUpdated != null) {
|
||||
rows.add(
|
||||
_InfoRow(
|
||||
label: 'Last updated',
|
||||
value: formatIsoDate(listing.lastUpdated!),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (listing.categories.isNotEmpty) {
|
||||
rows.add(
|
||||
_InfoRow(label: 'Categories', value: listing.categories.join(', ')),
|
||||
);
|
||||
}
|
||||
|
||||
if (listing.licenseName != null) {
|
||||
rows.add(
|
||||
_InfoRow(
|
||||
label: 'License',
|
||||
value: listing.licenseName!,
|
||||
url: listing.licenseUrl,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final links = <Widget>[];
|
||||
if (listing.homepageUrl != null) {
|
||||
links.add(
|
||||
_LinkTile(
|
||||
icon: Icons.home_outlined,
|
||||
label: 'Homepage',
|
||||
url: listing.homepageUrl!,
|
||||
),
|
||||
);
|
||||
}
|
||||
if (listing.supportUrl != null) {
|
||||
links.add(
|
||||
_LinkTile(
|
||||
icon: Icons.help_outline,
|
||||
label: 'Support site',
|
||||
url: listing.supportUrl!,
|
||||
),
|
||||
);
|
||||
}
|
||||
if (listing.supportEmail != null) {
|
||||
links.add(
|
||||
_LinkTile(
|
||||
icon: Icons.email_outlined,
|
||||
label: listing.supportEmail!,
|
||||
url: 'mailto:${listing.supportEmail!}',
|
||||
),
|
||||
);
|
||||
}
|
||||
links.add(
|
||||
_LinkTile(
|
||||
icon: Icons.public,
|
||||
label: 'View on addons.mozilla.org',
|
||||
url: listing.detailUrl,
|
||||
),
|
||||
);
|
||||
if (listing.ratingUrl != null) {
|
||||
links.add(
|
||||
_LinkTile(
|
||||
icon: Icons.reviews_outlined,
|
||||
label: 'Reviews',
|
||||
url: listing.ratingUrl!,
|
||||
),
|
||||
);
|
||||
}
|
||||
if (listing.hasPrivacyPolicy && listing.slug != null) {
|
||||
links.add(
|
||||
_LinkTile(
|
||||
icon: Icons.privacy_tip_outlined,
|
||||
label: 'Privacy policy',
|
||||
url: 'https://addons.mozilla.org/addon/${listing.slug}/privacy/',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [...rows, const SizedBox(height: 8), ...links],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _InfoRow extends StatelessWidget {
|
||||
final String label;
|
||||
final String value;
|
||||
final String? url;
|
||||
|
||||
const _InfoRow({required this.label, required this.value, this.url});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final valueWidget = url != null
|
||||
? InkWell(
|
||||
onTap: () => launchUrl(Uri.parse(url!)),
|
||||
child: Text(
|
||||
value,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: theme.colorScheme.primary,
|
||||
decoration: TextDecoration.underline,
|
||||
),
|
||||
),
|
||||
)
|
||||
: Text(value, style: theme.textTheme.bodyMedium);
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 120,
|
||||
child: Text(
|
||||
label,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(child: valueWidget),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _LinkTile extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final String label;
|
||||
final String url;
|
||||
|
||||
const _LinkTile({required this.icon, required this.label, required this.url});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
leading: Icon(icon),
|
||||
title: Text(label),
|
||||
trailing: const Icon(Icons.open_in_new, size: 18),
|
||||
onTap: () => launchUrl(Uri.parse(url)),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -17,13 +17,16 @@
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
import 'package:fading_scroll/fading_scroll.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_mozilla_components/flutter_mozilla_components.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:weblibre/core/routing/routes.dart';
|
||||
import 'package:weblibre/features/addons/domain/providers.dart';
|
||||
import 'package:weblibre/features/addons/extensions/addon_info.dart';
|
||||
import 'package:weblibre/features/addons/presentation/screens/addon_browse.dart';
|
||||
import 'package:weblibre/features/addons/presentation/widgets/addon_ui.dart';
|
||||
import 'package:weblibre/features/geckoview/features/browser/presentation/dialogs/install_local_addon_dialog.dart';
|
||||
import 'package:weblibre/utils/ui_helper.dart';
|
||||
|
||||
class AddonManagerScreen extends ConsumerWidget {
|
||||
@@ -35,67 +38,107 @@ class AddonManagerScreen extends ConsumerWidget {
|
||||
|
||||
Future<void> refresh() => ref.read(addonListProvider.notifier).refresh();
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Extensions'),
|
||||
actions: [
|
||||
IconButton(
|
||||
onPressed: addonsAsync.isLoading ? null : refresh,
|
||||
icon: const Icon(Icons.refresh),
|
||||
return DefaultTabController(
|
||||
length: 2,
|
||||
child: Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Extensions'),
|
||||
bottom: const TabBar(
|
||||
tabs: [
|
||||
Tab(text: 'Installed'),
|
||||
Tab(text: 'Browse'),
|
||||
],
|
||||
),
|
||||
_TriggerAllUpdatesButton(
|
||||
enabled: addonsAsync.maybeWhen(
|
||||
data: (addons) =>
|
||||
addons.any((a) => a.isInstalled && a.isSupported),
|
||||
orElse: () => false,
|
||||
actions: [
|
||||
IconButton(
|
||||
onPressed: addonsAsync.isLoading ? null : refresh,
|
||||
icon: const Icon(Icons.refresh),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: addonsAsync.when(
|
||||
skipLoadingOnReload: true,
|
||||
skipError: true,
|
||||
data: (addons) => RefreshIndicator(
|
||||
onRefresh: refresh,
|
||||
child: _AddonList(addons: addons),
|
||||
_AddonManagerOverflowMenu(
|
||||
canCheckForUpdates: addonsAsync.maybeWhen(
|
||||
data: (addons) =>
|
||||
addons.any((a) => a.isInstalled && a.isSupported),
|
||||
orElse: () => false,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: SafeArea(
|
||||
child: TabBarView(
|
||||
children: [
|
||||
addonsAsync.when(
|
||||
skipLoadingOnReload: true,
|
||||
skipError: true,
|
||||
data: (addons) => RefreshIndicator(
|
||||
onRefresh: refresh,
|
||||
child: _AddonList(addons: addons),
|
||||
),
|
||||
error: (error, _) =>
|
||||
_AddonLoadError(error: error, onRetry: refresh),
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
),
|
||||
const AddonBrowseView(),
|
||||
],
|
||||
),
|
||||
),
|
||||
error: (error, _) => _AddonLoadError(error: error, onRetry: refresh),
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _TriggerAllUpdatesButton extends ConsumerWidget {
|
||||
final bool enabled;
|
||||
enum _AddonManagerMenuAction { checkForUpdates, installFromFile }
|
||||
|
||||
const _TriggerAllUpdatesButton({required this.enabled});
|
||||
class _AddonManagerOverflowMenu extends ConsumerWidget {
|
||||
final bool canCheckForUpdates;
|
||||
|
||||
const _AddonManagerOverflowMenu({required this.canCheckForUpdates});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final busy = ref.watch(
|
||||
final updatesBusy = ref.watch(
|
||||
bulkAddonUpdateProvider.select((value) => value.isLoading),
|
||||
);
|
||||
|
||||
return IconButton(
|
||||
onPressed: enabled && !busy
|
||||
? () async {
|
||||
await ref.read(bulkAddonUpdateProvider.notifier).triggerAll();
|
||||
if (!context.mounted) return;
|
||||
showInfoMessage(
|
||||
context,
|
||||
'Background update checks started for installed extensions',
|
||||
);
|
||||
}
|
||||
: null,
|
||||
icon: busy
|
||||
return PopupMenuButton<_AddonManagerMenuAction>(
|
||||
icon: updatesBusy
|
||||
? const SizedBox(
|
||||
width: 18,
|
||||
height: 18,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Icon(Icons.system_update_alt),
|
||||
tooltip: 'Check all installed extensions for updates',
|
||||
: const Icon(Icons.more_vert),
|
||||
onSelected: (action) async {
|
||||
switch (action) {
|
||||
case _AddonManagerMenuAction.checkForUpdates:
|
||||
await ref.read(bulkAddonUpdateProvider.notifier).triggerAll();
|
||||
if (!context.mounted) return;
|
||||
showInfoMessage(
|
||||
context,
|
||||
'Background update checks started for installed extensions',
|
||||
);
|
||||
case _AddonManagerMenuAction.installFromFile:
|
||||
await showInstallLocalAddonDialog(context);
|
||||
}
|
||||
},
|
||||
itemBuilder: (context) => [
|
||||
PopupMenuItem(
|
||||
value: _AddonManagerMenuAction.checkForUpdates,
|
||||
enabled: canCheckForUpdates && !updatesBusy,
|
||||
child: const ListTile(
|
||||
leading: Icon(Icons.system_update_alt),
|
||||
title: Text('Check for updates'),
|
||||
contentPadding: EdgeInsets.zero,
|
||||
),
|
||||
),
|
||||
const PopupMenuItem(
|
||||
value: _AddonManagerMenuAction.installFromFile,
|
||||
child: ListTile(
|
||||
leading: Icon(Icons.file_open),
|
||||
title: Text('Install from file'),
|
||||
contentPadding: EdgeInsets.zero,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -113,78 +156,50 @@ class _AddonList extends StatelessWidget {
|
||||
final disabled = addons
|
||||
.where((a) => a.isInstalled && a.isSupported && !a.isEnabled)
|
||||
.toList();
|
||||
final recommended = addons.where((a) => !a.isInstalled).toList();
|
||||
final unsupported = addons
|
||||
.where((a) => a.isInstalled && !a.isSupported)
|
||||
.toList();
|
||||
final installed = enabled.length + disabled.length + unsupported.length;
|
||||
|
||||
return ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
const Card(
|
||||
child: ListTile(
|
||||
leading: Icon(Icons.info_outline),
|
||||
title: Text('Addon updates run in the background'),
|
||||
subtitle: Text(
|
||||
'Use each extension detail screen to view its last update result or trigger a manual check.',
|
||||
),
|
||||
),
|
||||
),
|
||||
if (enabled.isNotEmpty) ...[
|
||||
const SizedBox(height: 16),
|
||||
const _Section(title: 'Enabled'),
|
||||
for (final addon in enabled) _AddonCard(addon: addon),
|
||||
],
|
||||
if (disabled.isNotEmpty) ...[
|
||||
const SizedBox(height: 16),
|
||||
const _Section(title: 'Disabled'),
|
||||
for (final addon in disabled) _AddonCard(addon: addon),
|
||||
],
|
||||
if (recommended.isNotEmpty) ...[
|
||||
const SizedBox(height: 16),
|
||||
const _Section(title: 'Available'),
|
||||
for (final addon in recommended)
|
||||
_AddonCard(
|
||||
addon: addon,
|
||||
action: _InstallAction(addon: addon),
|
||||
),
|
||||
],
|
||||
if (unsupported.isNotEmpty) ...[
|
||||
const SizedBox(height: 16),
|
||||
const _Section(title: 'Unsupported'),
|
||||
for (final addon in unsupported)
|
||||
_AddonCard(
|
||||
addon: addon,
|
||||
action: _UninstallAction(addon: addon),
|
||||
),
|
||||
],
|
||||
if (addons.isEmpty)
|
||||
const Padding(
|
||||
padding: EdgeInsets.only(top: 48),
|
||||
child: Center(child: Text('No extensions available right now.')),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _InstallAction extends ConsumerWidget {
|
||||
final AddonInfo addon;
|
||||
|
||||
const _InstallAction({required this.addon});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final busy = ref.watch(addonBusyIdsProvider).contains(addon.id);
|
||||
return FilledButton(
|
||||
onPressed: busy
|
||||
? null
|
||||
: () async {
|
||||
await ref.read(addonListProvider.notifier).install(addon);
|
||||
if (!context.mounted) return;
|
||||
showInfoMessage(context, '${addon.displayName} installed');
|
||||
},
|
||||
child: const Text('Install'),
|
||||
return FadingScroll(
|
||||
fadingSize: 25,
|
||||
builder: (context, controller) {
|
||||
return ListView(
|
||||
controller: controller,
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
if (enabled.isNotEmpty) ...[
|
||||
const SizedBox(height: 16),
|
||||
const _Section(title: 'Enabled'),
|
||||
for (final addon in enabled) _AddonCard(addon: addon),
|
||||
],
|
||||
if (disabled.isNotEmpty) ...[
|
||||
const SizedBox(height: 16),
|
||||
const _Section(title: 'Disabled'),
|
||||
for (final addon in disabled) _AddonCard(addon: addon),
|
||||
],
|
||||
if (unsupported.isNotEmpty) ...[
|
||||
const SizedBox(height: 16),
|
||||
const _Section(title: 'Unsupported'),
|
||||
for (final addon in unsupported)
|
||||
_AddonCard(
|
||||
addon: addon,
|
||||
action: _UninstallAction(addon: addon),
|
||||
),
|
||||
],
|
||||
if (installed == 0)
|
||||
const Padding(
|
||||
padding: EdgeInsets.only(top: 48),
|
||||
child: Center(
|
||||
child: Text(
|
||||
'No extensions installed yet.\nBrowse the store to find some.',
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -236,6 +251,7 @@ class _AddonCard extends ConsumerWidget {
|
||||
final busy = ref.watch(addonBusyIdsProvider).contains(addon.id);
|
||||
|
||||
return Card(
|
||||
color: Theme.of(context).colorScheme.surfaceContainerHigh,
|
||||
margin: const EdgeInsets.only(bottom: 12),
|
||||
child: InkWell(
|
||||
onTap: busy
|
||||
@@ -267,7 +283,6 @@ class _AddonCard extends ConsumerWidget {
|
||||
const SizedBox(height: 8),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
if (addon.isAllowedInPrivateBrowsing)
|
||||
const Chip(label: Text('Private Browsing')),
|
||||
|
||||
@@ -71,8 +71,9 @@ class AddonPermissionsScreen extends ConsumerWidget {
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
if (permissions.isEmpty && dataCollection.isEmpty)
|
||||
const Card(
|
||||
child: ListTile(
|
||||
Card(
|
||||
color: Theme.of(context).colorScheme.surfaceContainerHigh,
|
||||
child: const ListTile(
|
||||
leading: Icon(Icons.verified_user_outlined),
|
||||
title: Text('No special permissions listed'),
|
||||
subtitle: Text(
|
||||
@@ -87,6 +88,7 @@ class AddonPermissionsScreen extends ConsumerWidget {
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Card(
|
||||
color: Theme.of(context).colorScheme.surfaceContainerHigh,
|
||||
child: Column(
|
||||
children: [
|
||||
for (final permission in permissions)
|
||||
@@ -106,6 +108,7 @@ class AddonPermissionsScreen extends ConsumerWidget {
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Card(
|
||||
color: Theme.of(context).colorScheme.surfaceContainerHigh,
|
||||
child: Column(
|
||||
children: [
|
||||
for (final permission in dataCollection)
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
/*
|
||||
* Copyright (c) 2024-2026 Fabian Freund.
|
||||
*
|
||||
* This file is part of WebLibre
|
||||
* (see https://weblibre.eu).
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as
|
||||
* published by the Free Software Foundation, either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_mozilla_components/flutter_mozilla_components.dart';
|
||||
import 'package:weblibre/utils/number_format.dart';
|
||||
|
||||
class AddonListingIcon extends StatelessWidget {
|
||||
final String? iconUrl;
|
||||
final double size;
|
||||
|
||||
const AddonListingIcon({required this.iconUrl, this.size = 40, super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final borderRadius = BorderRadius.circular(12);
|
||||
if (iconUrl == null || iconUrl!.isEmpty) {
|
||||
return _fallback(context);
|
||||
}
|
||||
return ClipRRect(
|
||||
borderRadius: borderRadius,
|
||||
child: Image.network(
|
||||
iconUrl!,
|
||||
width: size,
|
||||
height: size,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (_, _, _) => _fallback(context),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _fallback(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return Container(
|
||||
width: size,
|
||||
height: size,
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Icon(Icons.extension, color: theme.colorScheme.onSurfaceVariant),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class AddonListingCard extends StatelessWidget {
|
||||
final AddonListing listing;
|
||||
final bool isInstalled;
|
||||
final VoidCallback? onTap;
|
||||
final Widget? trailing;
|
||||
|
||||
const AddonListingCard({
|
||||
required this.listing,
|
||||
required this.isInstalled,
|
||||
this.onTap,
|
||||
this.trailing,
|
||||
super.key,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return Card(
|
||||
color: theme.colorScheme.surfaceContainerHigh,
|
||||
margin: const EdgeInsets.only(bottom: 12),
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
AddonListingIcon(iconUrl: listing.iconUrl),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(listing.name, style: theme.textTheme.titleMedium),
|
||||
if ((listing.summary ?? '').isNotEmpty) ...[
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
listing.summary!,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 8),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
children: [
|
||||
if (listing.promoted == AddonStorePromoted.recommended)
|
||||
const Chip(
|
||||
avatar: Icon(Icons.verified, size: 16),
|
||||
label: Text('Recommended'),
|
||||
),
|
||||
if (listing.ratingAverage != null)
|
||||
Chip(
|
||||
avatar: const Icon(Icons.star, size: 16),
|
||||
label: Text(
|
||||
listing.ratingAverage!.toStringAsFixed(1),
|
||||
),
|
||||
),
|
||||
if (listing.averageDailyUsers != null)
|
||||
Chip(
|
||||
avatar: const Icon(Icons.group_outlined, size: 16),
|
||||
label: Text(
|
||||
formatCompactNumber(listing.averageDailyUsers!),
|
||||
),
|
||||
),
|
||||
if (isInstalled)
|
||||
const Chip(
|
||||
avatar: Icon(Icons.check, size: 16),
|
||||
label: Text('Installed'),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
trailing ?? const Icon(Icons.chevron_right),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user