diff --git a/lib/screens/repo_tab.dart b/lib/screens/repo_tab.dart index 04d2aa9d..77b35917 100644 --- a/lib/screens/repo_tab.dart +++ b/lib/screens/repo_tab.dart @@ -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 { ); } final colorScheme = Theme.of(context).colorScheme; - final topPadding = normalizedHeaderTopPadding(context); final bottomInset = context.navBarBottomInset; return Scaffold( @@ -85,14 +86,8 @@ class _RepoTabState extends ConsumerState { 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 { 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), + ), + ), ); } } diff --git a/lib/screens/settings/extensions_page.dart b/lib/screens/settings/extensions_page.dart index 2e02d0d4..2cadda6f 100644 --- a/lib/screens/settings/extensions_page.dart +++ b/lib/screens/settings/extensions_page.dart @@ -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 { 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 { ), 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( diff --git a/lib/widgets/extension_row.dart b/lib/widgets/extension_row.dart new file mode 100644 index 00000000..7895439c --- /dev/null +++ b/lib/widgets/extension_row.dart @@ -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), + ), + ], + ); + } +}