- 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>
90 lines
3.5 KiB
Dart
90 lines
3.5 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
|
|
import '../../app/theme.dart';
|
|
import '../../audio/player_providers.dart';
|
|
import '../../core/models.dart';
|
|
import '../../shared/widgets/catalog_builder.dart';
|
|
import '../../shared/widgets/mini_player.dart';
|
|
import '../../shared/widgets/section_header.dart';
|
|
import '../../shared/widgets/track_tile.dart';
|
|
|
|
class OccasionScreen extends ConsumerWidget {
|
|
const OccasionScreen({super.key, required this.occasion});
|
|
|
|
final Occasion occasion;
|
|
|
|
@override
|
|
Widget build(BuildContext context, WidgetRef ref) {
|
|
return Scaffold(
|
|
bottomNavigationBar: const SafeArea(child: MiniPlayer()),
|
|
body: CatalogBuilder(builder: (catalog) {
|
|
final tracks = catalog.byOccasion(occasion);
|
|
final controller = ref.read(playerControllerProvider);
|
|
return CustomScrollView(
|
|
slivers: [
|
|
SliverAppBar(
|
|
pinned: true,
|
|
expandedHeight: 200,
|
|
backgroundColor: occasion.color,
|
|
flexibleSpace: FlexibleSpaceBar(
|
|
titlePadding: const EdgeInsetsDirectional.only(start: 56, bottom: 16),
|
|
title: Text(occasion.label, style: Theme.of(context).textTheme.titleLarge),
|
|
background: ColoredBox(
|
|
color: occasion.color,
|
|
child: Align(
|
|
alignment: const Alignment(0.85, 0),
|
|
child: Icon(occasion.icon, size: 110, color: NkColors.cream.withValues(alpha: 0.25)),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
if (tracks.isEmpty)
|
|
const SliverFillRemaining(
|
|
hasScrollBody: false,
|
|
child: EmptyState(
|
|
icon: Icons.library_music_outlined,
|
|
title: 'No songs here yet',
|
|
message: 'Songs for this occasion appear here once they are approved.',
|
|
),
|
|
)
|
|
else ...[
|
|
SliverPadding(
|
|
padding: const EdgeInsets.fromLTRB(18, 16, 18, 8),
|
|
sliver: SliverToBoxAdapter(
|
|
child: Row(
|
|
children: [
|
|
Expanded(
|
|
child: Text('${occasion.subtitle} · ${tracks.length} songs',
|
|
style: const TextStyle(color: NkColors.muted)),
|
|
),
|
|
IconButton(
|
|
tooltip: 'Shuffle',
|
|
icon: const Icon(Icons.shuffle, color: NkColors.sun),
|
|
onPressed: () => controller.playTracks(tracks, shuffle: true),
|
|
),
|
|
IconButton.filled(
|
|
tooltip: 'Play all',
|
|
style: IconButton.styleFrom(fixedSize: const Size(56, 56)),
|
|
onPressed: () => controller.playTracks(tracks),
|
|
icon: const Icon(Icons.play_arrow_rounded, size: 30),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
SliverPadding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 18),
|
|
sliver: SliverList.builder(
|
|
itemCount: tracks.length,
|
|
itemBuilder: (_, i) => TrackTile(track: tracks[i], onTap: () => controller.playTracks(tracks, index: i)),
|
|
),
|
|
),
|
|
],
|
|
],
|
|
);
|
|
}),
|
|
);
|
|
}
|
|
}
|