mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-08-02 09:08:35 +02:00
refactor(extensions): share extension rows
This commit is contained in:
+115
-224
@@ -1,4 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:spotiflac_android/widgets/extension_row.dart';
|
||||
import 'package:spotiflac_android/theme/app_tokens.dart';
|
||||
import 'package:spotiflac_android/widgets/app_sliver_header.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:spotiflac_android/l10n/l10n.dart';
|
||||
@@ -6,7 +9,6 @@ import 'package:spotiflac_android/providers/repo_provider.dart';
|
||||
import 'package:spotiflac_android/widgets/settings_group.dart';
|
||||
import 'package:spotiflac_android/widgets/animation_utils.dart';
|
||||
import 'package:spotiflac_android/screens/repo/extension_details_screen.dart';
|
||||
import 'package:spotiflac_android/utils/app_bar_layout.dart';
|
||||
import 'package:spotiflac_android/utils/nav_bar_inset.dart';
|
||||
|
||||
class RepoTab extends ConsumerStatefulWidget {
|
||||
@@ -76,7 +78,6 @@ class _RepoTabState extends ConsumerState<RepoTab> {
|
||||
);
|
||||
}
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final topPadding = normalizedHeaderTopPadding(context);
|
||||
final bottomInset = context.navBarBottomInset;
|
||||
|
||||
return Scaffold(
|
||||
@@ -85,14 +86,8 @@ class _RepoTabState extends ConsumerState<RepoTab> {
|
||||
ref.read(repoProvider.notifier).refresh(forceRefresh: true),
|
||||
child: CustomScrollView(
|
||||
slivers: [
|
||||
SliverAppBar(
|
||||
expandedHeight: 120 + topPadding,
|
||||
collapsedHeight: kToolbarHeight,
|
||||
floating: false,
|
||||
pinned: true,
|
||||
backgroundColor: colorScheme.surface,
|
||||
surfaceTintColor: Colors.transparent,
|
||||
automaticallyImplyLeading: false,
|
||||
AppSliverHeader.tabRoot(
|
||||
title: context.l10n.storeTitle,
|
||||
actions: [
|
||||
if (hasRegistryUrl)
|
||||
IconButton(
|
||||
@@ -101,29 +96,6 @@ class _RepoTabState extends ConsumerState<RepoTab> {
|
||||
onPressed: () => _showChangeRepoDialog(registryUrl),
|
||||
),
|
||||
],
|
||||
flexibleSpace: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final maxHeight = 120 + topPadding;
|
||||
final minHeight = kToolbarHeight + topPadding;
|
||||
final expandRatio =
|
||||
((constraints.maxHeight - minHeight) /
|
||||
(maxHeight - minHeight))
|
||||
.clamp(0.0, 1.0);
|
||||
|
||||
return FlexibleSpaceBar(
|
||||
expandedTitleScale: 1.0,
|
||||
titlePadding: const EdgeInsets.only(left: 24, bottom: 16),
|
||||
title: Text(
|
||||
context.l10n.storeTitle,
|
||||
style: TextStyle(
|
||||
fontSize: 20 + (14 * expandRatio),
|
||||
fontWeight: FontWeight.bold,
|
||||
color: colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
if (!hasRegistryUrl)
|
||||
@@ -720,200 +692,119 @@ class _ExtensionItem extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final tokens = context.tokens;
|
||||
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
InkWell(
|
||||
onTap: onTap,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 44,
|
||||
height: 44,
|
||||
decoration: BoxDecoration(
|
||||
color: extension.isInstalled
|
||||
? colorScheme.primaryContainer
|
||||
: colorScheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child:
|
||||
extension.iconUrl != null && extension.iconUrl!.isNotEmpty
|
||||
? Image.network(
|
||||
extension.iconUrl!,
|
||||
width: 44,
|
||||
height: 44,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (context, error, stackTrace) => Icon(
|
||||
_getCategoryIcon(extension.category),
|
||||
color: extension.isInstalled
|
||||
? colorScheme.onPrimaryContainer
|
||||
: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
loadingBuilder: (context, child, loadingProgress) {
|
||||
if (loadingProgress == null) return child;
|
||||
return Center(
|
||||
child: SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
value:
|
||||
loadingProgress.expectedTotalBytes != null
|
||||
? loadingProgress.cumulativeBytesLoaded /
|
||||
loadingProgress.expectedTotalBytes!
|
||||
: null,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
)
|
||||
: Icon(
|
||||
_getCategoryIcon(extension.category),
|
||||
color: extension.isInstalled
|
||||
? colorScheme.onPrimaryContainer
|
||||
: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
extension.displayName,
|
||||
style: Theme.of(context).textTheme.bodyLarge
|
||||
?.copyWith(fontWeight: FontWeight.w500),
|
||||
),
|
||||
),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 6,
|
||||
vertical: 2,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: Text(
|
||||
'v${extension.version}',
|
||||
style: Theme.of(context).textTheme.labelSmall
|
||||
?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (extension.requiresNewerApp) ...[
|
||||
const SizedBox(height: 4),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 6,
|
||||
vertical: 2,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.errorContainer,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.warning_amber_rounded,
|
||||
size: 12,
|
||||
color: colorScheme.onErrorContainer,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
context.l10n.storeRequiresVersion(
|
||||
extension.minAppVersion ?? '',
|
||||
),
|
||||
style: Theme.of(context).textTheme.labelSmall
|
||||
?.copyWith(
|
||||
color: colorScheme.onErrorContainer,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
] else ...[
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
extension.description,
|
||||
style: Theme.of(context).textTheme.bodySmall
|
||||
?.copyWith(color: colorScheme.onSurfaceVariant),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
if (isDownloading)
|
||||
const SizedBox(
|
||||
width: 24,
|
||||
height: 24,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
else if (extension.hasUpdate)
|
||||
FilledButton.tonal(
|
||||
onPressed: onUpdate,
|
||||
style: FilledButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
minimumSize: const Size(0, 36),
|
||||
),
|
||||
child: Text(context.l10n.storeUpdate),
|
||||
)
|
||||
else if (extension.isInstalled)
|
||||
OutlinedButton(
|
||||
onPressed: null,
|
||||
style: OutlinedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
minimumSize: const Size(0, 36),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.check, size: 16, color: colorScheme.outline),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
context.l10n.storeInstalled,
|
||||
style: TextStyle(color: colorScheme.outline),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
else
|
||||
FilledButton(
|
||||
onPressed: onInstall,
|
||||
style: FilledButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
minimumSize: const Size(0, 36),
|
||||
),
|
||||
child: Text(context.l10n.storeInstall),
|
||||
),
|
||||
],
|
||||
return ExtensionRow(
|
||||
showDivider: showDivider,
|
||||
onTap: onTap,
|
||||
avatar: ExtensionAvatar(
|
||||
imageUrl: extension.iconUrl,
|
||||
fallbackIcon: _getCategoryIcon(extension.category),
|
||||
background: extension.isInstalled
|
||||
? colorScheme.primaryContainer
|
||||
: colorScheme.surfaceContainerHighest,
|
||||
foreground: extension.isInstalled
|
||||
? colorScheme.onPrimaryContainer
|
||||
: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
title: Row(
|
||||
children: [
|
||||
Expanded(child: Text(extension.displayName)),
|
||||
Container(
|
||||
padding: tokens.badgePadding,
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
borderRadius: tokens.borderRadiusBadge,
|
||||
),
|
||||
child: Text(
|
||||
'v${extension.version}',
|
||||
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (showDivider)
|
||||
Divider(
|
||||
height: 1,
|
||||
thickness: 1,
|
||||
indent: 76,
|
||||
endIndent: 16,
|
||||
color: colorScheme.outlineVariant.withValues(alpha: 0.3),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
subtitle: extension.requiresNewerApp
|
||||
? Container(
|
||||
padding: tokens.badgePadding,
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.errorContainer,
|
||||
borderRadius: tokens.borderRadiusBadge,
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.warning_amber_rounded,
|
||||
size: 12,
|
||||
color: colorScheme.onErrorContainer,
|
||||
),
|
||||
SizedBox(width: tokens.gapXs),
|
||||
Text(
|
||||
context.l10n.storeRequiresVersion(
|
||||
extension.minAppVersion ?? '',
|
||||
),
|
||||
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||
color: colorScheme.onErrorContainer,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
: Text(
|
||||
extension.description,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
trailing: Padding(
|
||||
padding: EdgeInsets.only(left: tokens.gapMd),
|
||||
child: isDownloading
|
||||
? const SizedBox.square(
|
||||
dimension: 24,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: extension.hasUpdate
|
||||
? FilledButton.tonal(
|
||||
onPressed: onUpdate,
|
||||
style: FilledButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
minimumSize: const Size(0, 40),
|
||||
),
|
||||
child: Text(context.l10n.storeUpdate),
|
||||
)
|
||||
: extension.isInstalled
|
||||
? OutlinedButton(
|
||||
onPressed: null,
|
||||
style: OutlinedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
minimumSize: const Size(0, 40),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.check, size: 16, color: colorScheme.outline),
|
||||
SizedBox(width: tokens.gapXs),
|
||||
Text(
|
||||
context.l10n.storeInstalled,
|
||||
style: TextStyle(color: colorScheme.outline),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
: FilledButton(
|
||||
onPressed: onInstall,
|
||||
style: FilledButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
minimumSize: const Size(0, 40),
|
||||
),
|
||||
child: Text(context.l10n.storeInstall),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'dart:io';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:spotiflac_android/widgets/extension_row.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:file_picker/file_picker.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
@@ -13,7 +14,7 @@ import 'package:spotiflac_android/screens/settings/extension_detail_page.dart';
|
||||
import 'package:spotiflac_android/screens/settings/metadata_provider_priority_page.dart';
|
||||
import 'package:spotiflac_android/screens/settings/provider_priority_page.dart';
|
||||
import 'package:spotiflac_android/widgets/settings_group.dart';
|
||||
import 'package:spotiflac_android/widgets/settings_sliver_app_bar.dart';
|
||||
import 'package:spotiflac_android/widgets/app_sliver_header.dart';
|
||||
|
||||
class ExtensionsPage extends ConsumerStatefulWidget {
|
||||
const ExtensionsPage({super.key});
|
||||
@@ -57,7 +58,7 @@ class _ExtensionsPageState extends ConsumerState<ExtensionsPage> {
|
||||
child: Scaffold(
|
||||
body: CustomScrollView(
|
||||
slivers: [
|
||||
SettingsSliverAppBar(title: context.l10n.extensionsTitle),
|
||||
AppSliverHeader.page(title: context.l10n.extensionsTitle),
|
||||
|
||||
if (extState.isLoading)
|
||||
const SliverToBoxAdapter(
|
||||
@@ -199,34 +200,11 @@ class _ExtensionsPageState extends ConsumerState<ExtensionsPage> {
|
||||
),
|
||||
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 32),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.tertiaryContainer.withValues(alpha: 0.3),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.info_outline,
|
||||
size: 20,
|
||||
color: colorScheme.tertiary,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
context.l10n.extensionsInfoTip,
|
||||
style: Theme.of(context).textTheme.bodySmall
|
||||
?.copyWith(
|
||||
color: colorScheme.onTertiaryContainer,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
child: SettingsInfoCard(
|
||||
icon: Icons.info_outline,
|
||||
tone: SettingsInfoTone.warning,
|
||||
message: context.l10n.extensionsInfoTip,
|
||||
margin: const EdgeInsets.fromLTRB(16, 8, 16, 32),
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -330,97 +308,39 @@ class _ExtensionItem extends StatelessWidget {
|
||||
? null
|
||||
: _extensionHealthColor(colorScheme, serviceHealthStatus);
|
||||
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
InkWell(
|
||||
onTap: onTap,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 44,
|
||||
height: 44,
|
||||
decoration: BoxDecoration(
|
||||
color: hasError
|
||||
? colorScheme.errorContainer
|
||||
: colorScheme.primaryContainer,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child:
|
||||
extension.iconPath != null &&
|
||||
extension.iconPath!.isNotEmpty
|
||||
? ClipRRect(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Image.file(
|
||||
File(extension.iconPath!),
|
||||
width: 44,
|
||||
height: 44,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (context, error, stackTrace) => Icon(
|
||||
hasError ? Icons.error_outline : Icons.extension,
|
||||
color: hasError
|
||||
? colorScheme.error
|
||||
: colorScheme.onPrimaryContainer,
|
||||
),
|
||||
),
|
||||
)
|
||||
: Icon(
|
||||
hasError ? Icons.error_outline : Icons.extension,
|
||||
color: hasError
|
||||
? colorScheme.error
|
||||
: colorScheme.onPrimaryContainer,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
extension.displayName,
|
||||
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
hasError
|
||||
? context.friendlyError(
|
||||
extension.errorMessage,
|
||||
fallback: context.l10n.extensionsErrorLoading,
|
||||
)
|
||||
: serviceHealthStatus == null
|
||||
? 'v${extension.version}'
|
||||
: 'v${extension.version} · ${_extensionHealthLabel(context, serviceHealthStatus)}',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: hasError
|
||||
? colorScheme.error
|
||||
: serviceHealthColor ??
|
||||
colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Switch(
|
||||
value: extension.enabled,
|
||||
onChanged: hasError ? null : onToggle,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
return ExtensionRow(
|
||||
showDivider: showDivider,
|
||||
onTap: onTap,
|
||||
avatar: ExtensionAvatar(
|
||||
filePath: extension.iconPath,
|
||||
fallbackIcon: hasError ? Icons.error_outline : Icons.extension,
|
||||
background: hasError
|
||||
? colorScheme.errorContainer
|
||||
: colorScheme.primaryContainer,
|
||||
foreground: hasError
|
||||
? colorScheme.error
|
||||
: colorScheme.onPrimaryContainer,
|
||||
),
|
||||
title: Text(extension.displayName),
|
||||
subtitle: Text(
|
||||
hasError
|
||||
? context.friendlyError(
|
||||
extension.errorMessage,
|
||||
fallback: context.l10n.extensionsErrorLoading,
|
||||
)
|
||||
: serviceHealthStatus == null
|
||||
? 'v${extension.version}'
|
||||
: 'v${extension.version} · ${_extensionHealthLabel(context, serviceHealthStatus)}',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: hasError
|
||||
? colorScheme.error
|
||||
: serviceHealthColor ?? colorScheme.onSurfaceVariant,
|
||||
),
|
||||
if (showDivider)
|
||||
Divider(
|
||||
height: 1,
|
||||
thickness: 1,
|
||||
indent: 76,
|
||||
endIndent: 16,
|
||||
color: colorScheme.outlineVariant.withValues(alpha: 0.3),
|
||||
),
|
||||
],
|
||||
),
|
||||
trailing: Switch(
|
||||
value: extension.enabled,
|
||||
onChanged: hasError ? null : onToggle,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -750,9 +670,6 @@ class _SearchProviderSelector extends ConsumerWidget {
|
||||
context: context,
|
||||
useRootNavigator: true,
|
||||
backgroundColor: colorScheme.surfaceContainerHigh,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(28)),
|
||||
),
|
||||
builder: (ctx) => SafeArea(
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
@@ -893,9 +810,6 @@ class _HomeFeedProviderSelector extends ConsumerWidget {
|
||||
context: context,
|
||||
useRootNavigator: true,
|
||||
backgroundColor: colorScheme.surfaceContainerHigh,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(28)),
|
||||
),
|
||||
builder: (ctx) => SafeArea(
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:spotiflac_android/theme/app_tokens.dart';
|
||||
|
||||
/// Square extension icon with a tinted fallback.
|
||||
///
|
||||
/// Both the store and the installed-extensions page drew this by hand with the
|
||||
/// same 44dp box, radius and fallback-icon logic, differing only in whether the
|
||||
/// image came from a file or the network.
|
||||
class ExtensionAvatar extends StatelessWidget {
|
||||
const ExtensionAvatar({
|
||||
super.key,
|
||||
required this.fallbackIcon,
|
||||
this.filePath,
|
||||
this.imageUrl,
|
||||
this.background,
|
||||
this.foreground,
|
||||
});
|
||||
|
||||
/// Shown when no image is available or the image fails to load.
|
||||
final IconData fallbackIcon;
|
||||
|
||||
/// Local icon shipped inside an installed extension.
|
||||
final String? filePath;
|
||||
|
||||
/// Remote icon from the store registry.
|
||||
final String? imageUrl;
|
||||
|
||||
final Color? background;
|
||||
final Color? foreground;
|
||||
|
||||
static const double size = 44;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = context.tokens;
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final borderRadius = tokens.borderRadiusCover;
|
||||
final fallback = Icon(
|
||||
fallbackIcon,
|
||||
color: foreground ?? colorScheme.onPrimaryContainer,
|
||||
);
|
||||
|
||||
Widget? image;
|
||||
if (filePath != null && filePath!.isNotEmpty) {
|
||||
image = Image.file(
|
||||
File(filePath!),
|
||||
width: size,
|
||||
height: size,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (_, _, _) => fallback,
|
||||
);
|
||||
} else if (imageUrl != null && imageUrl!.isNotEmpty) {
|
||||
image = Image.network(
|
||||
imageUrl!,
|
||||
width: size,
|
||||
height: size,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (_, _, _) => fallback,
|
||||
loadingBuilder: (context, child, progress) {
|
||||
if (progress == null) return child;
|
||||
return const Center(
|
||||
child: SizedBox.square(
|
||||
dimension: 20,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return Container(
|
||||
width: size,
|
||||
height: size,
|
||||
decoration: BoxDecoration(
|
||||
color: background ?? colorScheme.primaryContainer,
|
||||
borderRadius: borderRadius,
|
||||
),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: image ?? fallback,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Row shell shared by the extension store and the installed-extensions list:
|
||||
/// avatar, title/subtitle column, trailing control, and the group divider.
|
||||
class ExtensionRow extends StatelessWidget {
|
||||
const ExtensionRow({
|
||||
super.key,
|
||||
required this.avatar,
|
||||
required this.title,
|
||||
required this.subtitle,
|
||||
required this.showDivider,
|
||||
this.trailing,
|
||||
this.onTap,
|
||||
});
|
||||
|
||||
final Widget avatar;
|
||||
|
||||
/// A widget rather than a string: the store row appends a version pill.
|
||||
final Widget title;
|
||||
|
||||
final Widget subtitle;
|
||||
final bool showDivider;
|
||||
final Widget? trailing;
|
||||
final VoidCallback? onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = context.tokens;
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
InkWell(
|
||||
onTap: onTap,
|
||||
child: Padding(
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal: tokens.gapLg,
|
||||
vertical: tokens.gapMd,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
avatar,
|
||||
SizedBox(width: tokens.gapLg),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
DefaultTextStyle.merge(
|
||||
style: Theme.of(context).textTheme.bodyLarge!.copyWith(
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
child: title,
|
||||
),
|
||||
SizedBox(height: tokens.gapXs / 2),
|
||||
subtitle,
|
||||
],
|
||||
),
|
||||
),
|
||||
?trailing,
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
if (showDivider)
|
||||
Divider(
|
||||
height: 1,
|
||||
thickness: 1,
|
||||
// Aligns with the text column: avatar width + its trailing gap.
|
||||
indent: ExtensionAvatar.size + tokens.gapLg * 2,
|
||||
endIndent: tokens.gapLg,
|
||||
color: colorScheme.outlineVariant.withValues(alpha: 0.3),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user