refactor(extensions): share extension rows

This commit is contained in:
zarzet
2026-07-29 17:11:51 +07:00
parent 309777cc71
commit dc4c75363c
3 changed files with 316 additions and 350 deletions
+115 -224
View File
@@ -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),
),
),
);
}
}
+40 -126
View File
@@ -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(
+161
View File
@@ -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),
),
],
);
}
}