import 'dart:math' as math; import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; import '../../app/theme.dart'; import '../../core/models.dart'; /// Dark fade behind text on photos so it stays readable on any image. class PhotoScrim extends StatelessWidget { const PhotoScrim({super.key, this.begin = Alignment.topCenter, this.end = Alignment.bottomCenter, this.stops}); final Alignment begin; final Alignment end; final List? stops; @override Widget build(BuildContext context) => DecoratedBox( decoration: BoxDecoration( gradient: LinearGradient( begin: begin, end: end, stops: stops ?? const [0.3, 1], colors: [NkColors.ground.withValues(alpha: 0), NkColors.ground.withValues(alpha: 0.9)], ), ), ); } /// Cover for a song: remote cover, bundled photo, or the generated occasion cover. class TrackArtwork extends StatelessWidget { const TrackArtwork({ super.key, required this.coverUrl, required this.coverAsset, required this.occasion, this.title, this.size = 56, this.radius = 10, }); TrackArtwork.track(Track t, {Key? key, double size = 56, double radius = 10, bool showTitle = false}) : this( key: key, coverUrl: t.coverUrl, coverAsset: t.coverAsset, occasion: t.occasion, title: showTitle ? t.titleLatin : null, size: size, radius: radius, ); final String? coverUrl; final String? coverAsset; final Occasion occasion; final String? title; final double size; final double radius; @override Widget build(BuildContext context) { final Widget child; if (coverUrl != null) { child = CachedNetworkImage( imageUrl: coverUrl!, fit: BoxFit.cover, placeholder: (_, _) => OccasionCover(occasion: occasion, size: size), errorWidget: (_, _, _) => OccasionCover(occasion: occasion, size: size), ); } else if (coverAsset != null) { child = Image.asset(coverAsset!, fit: BoxFit.cover, cacheWidth: (size * 3).round()); } else { child = OccasionCover(occasion: occasion, size: size, title: title); } return ClipRRect( borderRadius: BorderRadius.circular(radius), child: SizedBox.square(dimension: size, child: child), ); } } /// Generated cover: occasion colour, label and the white-cloth border stripe. class OccasionCover extends StatelessWidget { const OccasionCover({super.key, required this.occasion, required this.size, this.title, this.label}); final Occasion occasion; final double size; final String? title; /// Top line; defaults to the occasion's name. final String? label; @override Widget build(BuildContext context) { final band = math.max(4.0, size * 0.06); final showText = size >= 96; return Container( color: occasion.color, child: Stack( fit: StackFit.expand, children: [ if (showText) Padding( padding: EdgeInsets.fromLTRB(size * 0.08, size * 0.08, size * 0.08, band * 2.5), child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text( (label ?? occasion.label).toUpperCase(), style: TextStyle( fontSize: size * 0.07, letterSpacing: 1.6, fontWeight: FontWeight.w800, color: NkColors.cream.withValues(alpha: 0.85), ), ), if (title != null) Text( title!, maxLines: 3, overflow: TextOverflow.ellipsis, style: Theme.of(context).textTheme.titleLarge?.copyWith(fontSize: size * 0.11), ), ], ), ) else Center(child: Icon(occasion.icon, color: NkColors.cream.withValues(alpha: 0.7), size: size * 0.4)), Positioned(left: 0, right: 0, bottom: 0, height: band, child: const ColoredBox(color: NkColors.cream)), Positioned( left: 0, right: 0, bottom: band * 0.3, height: band * 0.3, child: const ColoredBox(color: NkColors.ground), ), ], ), ); } } /// Singer photo, or initials in a dashed ring until a permitted photo exists. /// Album cover photo, or a generated cover in the occasion style with a colour that stays /// the same per album. class AlbumCover extends StatelessWidget { const AlbumCover({super.key, required this.album, required this.size}); final Album album; final double size; @override Widget build(BuildContext context) { final palette = Occasion.values; final hash = album.title.codeUnits.fold(7, (h, c) => (h * 31 + c) & 0x7fffffff); final generated = OccasionCover(occasion: palette[hash % palette.length], size: size, label: 'Album', title: album.title); if (album.coverUrl == null) return generated; return CachedNetworkImage( imageUrl: album.coverUrl!, fit: BoxFit.cover, memCacheWidth: (size * 3).round(), placeholder: (_, _) => generated, errorWidget: (_, _, _) => generated, ); } } class SingerAvatar extends StatelessWidget { const SingerAvatar({super.key, required this.artist, this.size = 76}); final Artist artist; final double size; @override Widget build(BuildContext context) { if (artist.photoUrl != null) { return ClipOval( child: CachedNetworkImage(imageUrl: artist.photoUrl!, width: size, height: size, fit: BoxFit.cover), ); } return CustomPaint( painter: _DashedRingPainter(), child: Container( width: size, height: size, alignment: Alignment.center, decoration: const BoxDecoration(color: NkColors.emblemGreen, shape: BoxShape.circle), child: Text( artist.initials, style: Theme.of(context).textTheme.titleLarge?.copyWith(fontSize: size * 0.3), ), ), ); } } class _DashedRingPainter extends CustomPainter { @override void paint(Canvas canvas, Size size) { final paint = Paint() ..color = NkColors.teaLeaf ..style = PaintingStyle.stroke ..strokeWidth = 2; final rect = Offset.zero & size; const dashes = 24; const sweep = 2 * math.pi / dashes; for (var i = 0; i < dashes; i++) { canvas.drawArc(rect.deflate(1), i * sweep, sweep * 0.6, false, paint); } } @override bool shouldRepaint(covariant CustomPainter oldDelegate) => false; }