- 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>
273 lines
10 KiB
Dart
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)),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|