Files
SpotiFLAC-Mobile/lib/widgets/extension_row.dart
T

162 lines
4.4 KiB
Dart

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),
),
],
);
}
}