nkbmusic/lib/features/home/home_screen.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

273 lines
10 KiB
Dart

import 'dart:math';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../app/theme.dart';
import '../../audio/player_providers.dart';
import '../../core/models.dart';
import '../../data/user_library.dart';
import '../album/album_screen.dart';
import '../../shared/widgets/artwork.dart';
import '../../shared/widgets/catalog_builder.dart';
import '../../shared/widgets/section_header.dart';
import '../../shared/widgets/track_tile.dart';
class HomeScreen extends ConsumerWidget {
const HomeScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
return CatalogBuilder(
builder: (catalog) {
final recentIds = ref.watch(recentlyPlayedProvider);
final recent = recentIds.map(catalog.trackById).whereType<Track>().take(6).toList();
final controller = ref.read(playerControllerProvider);
// A different mix each day, the same all day.
final now = DateTime.now();
final picks = ([...catalog.tracks]..shuffle(Random(now.year * 1000 + now.month * 40 + now.day)))
.take(12)
.toList();
return CustomScrollView(
slivers: [
SliverSafeArea(
bottom: false,
sliver: SliverPadding(
padding: const EdgeInsets.fromLTRB(18, 12, 18, 24),
sliver: SliverList.list(
children: [
const _Header(),
const SizedBox(height: 16),
const _SearchField(),
const SizedBox(height: 20),
_HeroCard(onPlay: () => controller.playTracks(catalog.tracks, shuffle: true)),
if (catalog.albums.isNotEmpty) ...[
const SizedBox(height: 24),
SectionHeader('Albums', onSeeAll: () => context.go('/explore')),
const SizedBox(height: 8),
SizedBox(
height: 186,
child: ListView.separated(
scrollDirection: Axis.horizontal,
itemCount: catalog.albums.length,
separatorBuilder: (_, _) => const SizedBox(width: 12),
itemBuilder: (_, i) {
final album = catalog.albums[i];
return AlbumCard(
album: album,
songCount: catalog.byAlbum(album.id).length,
onTap: () => context.push('/album/${album.id}'),
);
},
),
),
],
const SizedBox(height: 24),
SectionHeader('Explore by occasion', onSeeAll: () => context.go('/explore')),
const SizedBox(height: 8),
SizedBox(
height: 116,
child: ListView.separated(
scrollDirection: Axis.horizontal,
itemCount: Occasion.values.length,
separatorBuilder: (_, _) => const SizedBox(width: 12),
itemBuilder: (_, i) => _OccasionTile(occasion: Occasion.values[i]),
),
),
const SizedBox(height: 24),
const SectionHeader('Popular singers'),
const SizedBox(height: 8),
SizedBox(
height: 112,
child: ListView.separated(
scrollDirection: Axis.horizontal,
itemCount: catalog.artists.length,
separatorBuilder: (_, _) => const SizedBox(width: 14),
itemBuilder: (_, i) {
final a = catalog.artists[i];
return InkWell(
borderRadius: BorderRadius.circular(12),
onTap: () => context.push('/artist/${a.id}'),
child: SizedBox(
width: 80,
child: Column(
children: [
SingerAvatar(artist: a),
const SizedBox(height: 8),
Text(a.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w600)),
],
),
),
);
},
),
),
if (recent.isNotEmpty) ...[
const SizedBox(height: 20),
const SectionHeader('Recently played'),
const SizedBox(height: 4),
for (var i = 0; i < recent.length; i++)
TrackTile(track: recent[i], onTap: () => controller.playTracks(recent, index: i)),
],
const SizedBox(height: 20),
const SectionHeader('Recommended for you'),
const SizedBox(height: 8),
for (var i = 0; i < picks.length; i++)
Padding(
padding: const EdgeInsets.only(bottom: 10),
child: TrackTile(
card: true,
track: picks[i],
subtitle: '${catalog.albumOf(picks[i])?.title ?? picks[i].occasion.label}'
' · ${formatDuration(picks[i].duration)}',
onTap: () => controller.playTracks(picks, index: i),
),
),
],
),
),
),
],
);
},
);
}
}
class _Header extends StatelessWidget {
const _Header();
@override
Widget build(BuildContext context) {
return Row(
children: [
ClipOval(
child: Container(
color: NkColors.cream,
child: Image.asset('assets/images/emblem.png', width: 40, height: 40, semanticLabel: 'Nanga Kathai'),
),
),
const SizedBox(width: 12),
Expanded(child: Text('Nanga Kathai', style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.w800))),
IconButton(
tooltip: 'Account',
onPressed: () => context.push('/profile'),
icon: const CircleAvatar(
radius: 20,
backgroundColor: NkColors.emblemGreen,
child: Icon(Icons.person_outline, color: NkColors.cream),
),
),
],
);
}
}
class _SearchField extends StatelessWidget {
const _SearchField();
@override
Widget build(BuildContext context) {
return Material(
color: NkColors.surface,
shape: const StadiumBorder(side: BorderSide(color: NkColors.outline)),
child: InkWell(
customBorder: const StadiumBorder(),
onTap: () => context.push('/search'),
child: const SizedBox(
height: 52,
child: Row(
children: [
SizedBox(width: 18),
Icon(Icons.search, color: Color(0xFFC9D6CE)),
SizedBox(width: 12),
Expanded(child: Text('Search songs, singers, lyrics…', style: TextStyle(color: NkColors.muted, fontSize: 15))),
],
),
),
),
);
}
}
class _HeroCard extends StatelessWidget {
const _HeroCard({required this.onPlay});
final VoidCallback onPlay;
@override
Widget build(BuildContext context) {
return ClipRRect(
borderRadius: BorderRadius.circular(22),
child: SizedBox(
height: 200,
child: Stack(
fit: StackFit.expand,
children: [
Image.asset('assets/images/nilgiris_valley.jpg', fit: BoxFit.cover, alignment: const Alignment(0, 0.4)),
const PhotoScrim(begin: Alignment.centerRight, end: Alignment.centerLeft, stops: [0, 0.9]),
Padding(
padding: const EdgeInsets.fromLTRB(18, 22, 96, 18),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Sounds of\nthe Nilgiris', style: Theme.of(context).textTheme.headlineMedium),
const SizedBox(height: 8),
const Text(
'Songs, stories and traditions of the Badaga people',
style: TextStyle(color: NkColors.mutedOnPhoto, height: 1.4),
),
],
),
),
Positioned(
right: 18,
bottom: 18,
child: IconButton.filled(
tooltip: 'Shuffle play all songs',
style: IconButton.styleFrom(backgroundColor: NkColors.cream, fixedSize: const Size(56, 56)),
onPressed: onPlay,
icon: const Icon(Icons.play_arrow_rounded, color: NkColors.ground, size: 30),
),
),
],
),
),
);
}
}
class _OccasionTile extends StatelessWidget {
const _OccasionTile({required this.occasion});
final Occasion occasion;
@override
Widget build(BuildContext context) {
return Material(
color: occasion.color,
borderRadius: BorderRadius.circular(16),
child: InkWell(
borderRadius: BorderRadius.circular(16),
onTap: () => context.push('/occasion/${occasion.id}'),
child: SizedBox(
width: 116,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(occasion.icon, size: 38, color: NkColors.cream),
const SizedBox(height: 10),
Text(occasion.label, style: const TextStyle(fontWeight: FontWeight.w700, fontSize: 15)),
],
),
),
),
);
}
}