nkbmusic/lib/shared/widgets/artwork.dart
metatroncubeswdev 178d2d00ab Nanga Kathai: Flutter app, self-hosted backend and song tools
- Flutter app (Android/iOS): home, explore, albums, search, player with
  equalizer, mini player, sign up/sign in with email codes, profile,
  encrypted offline downloads, About page
- server/: one-command deploy of self-hosted Supabase behind HestiaCP/nginx
  (or Caddy), migrations, backups, email templates
- supabase/migrations: schema, row-level security, profiles
- tools/: song scanner and importer

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 10:39:26 -04:00

208 lines
6.4 KiB
Dart

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<double>? 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.
/// Generated album 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<int>(7, (h, c) => (h * 31 + c) & 0x7fffffff);
final pick = palette[hash % palette.length];
return OccasionCover(occasion: pick, size: size, label: 'Album', title: album.title);
}
}
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;
}