nkbmusic/lib/features/explore/explore_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

128 lines
4.4 KiB
Dart

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../app/theme.dart';
import '../../core/models.dart';
import '../../data/catalog_repository.dart';
import '../../shared/widgets/artwork.dart';
import '../../shared/widgets/section_header.dart';
import '../album/album_screen.dart';
/// Landscape photos used for some occasion tiles; the rest use their occasion colour.
const occasionPhotos = {
Occasion.folk: ('assets/images/nilgiris_village.jpg', Alignment(-0.3, 0.2)),
Occasion.habba: ('assets/images/nilgiris_valley.jpg', Alignment(0.6, 0.7)),
Occasion.huttu: ('assets/images/nilgiris_tea.jpg', Alignment(0, 0.4)),
};
class ExploreScreen extends ConsumerWidget {
const ExploreScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final catalog = ref.watch(catalogProvider).value;
final albums = catalog?.albums ?? const [];
return SafeArea(
bottom: false,
child: CustomScrollView(
slivers: [
SliverPadding(
padding: const EdgeInsets.fromLTRB(18, 12, 8, 8),
sliver: SliverToBoxAdapter(
child: Row(
children: [
Expanded(child: Text('Explore', style: Theme.of(context).textTheme.headlineMedium)),
IconButton(tooltip: 'Search', icon: const Icon(Icons.search), onPressed: () => context.push('/search')),
],
),
),
),
SliverPadding(
padding: const EdgeInsets.fromLTRB(18, 8, 18, 24),
sliver: SliverGrid.count(
crossAxisCount: 2,
mainAxisSpacing: 12,
crossAxisSpacing: 12,
childAspectRatio: 1.08,
children: [for (final o in Occasion.values) _CategoryTile(occasion: o)],
),
),
if (albums.isNotEmpty) ...[
const SliverPadding(
padding: EdgeInsets.fromLTRB(18, 0, 18, 8),
sliver: SliverToBoxAdapter(child: SectionHeader('All albums')),
),
SliverPadding(
padding: const EdgeInsets.fromLTRB(18, 0, 18, 24),
sliver: SliverGrid.builder(
gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent: 180,
mainAxisSpacing: 16,
crossAxisSpacing: 12,
childAspectRatio: 0.78,
),
itemCount: albums.length,
itemBuilder: (_, i) => LayoutBuilder(
builder: (context, box) => AlbumCard(
album: albums[i],
size: box.maxWidth,
songCount: catalog!.byAlbum(albums[i].id).length,
onTap: () => context.push('/album/${albums[i].id}'),
),
),
),
),
],
],
),
);
}
}
class _CategoryTile extends StatelessWidget {
const _CategoryTile({required this.occasion});
final Occasion occasion;
@override
Widget build(BuildContext context) {
final photo = occasionPhotos[occasion];
return ClipRRect(
borderRadius: BorderRadius.circular(18),
child: Material(
color: occasion.color,
child: InkWell(
onTap: () => context.push('/occasion/${occasion.id}'),
child: Stack(
fit: StackFit.expand,
children: [
if (photo != null) ...[
Image.asset(photo.$1, fit: BoxFit.cover, alignment: photo.$2, cacheWidth: 500),
const PhotoScrim(stops: [0.35, 1]),
] else
Positioned(
right: 12,
top: 12,
child: Icon(occasion.icon, size: 52, color: NkColors.cream.withValues(alpha: 0.5)),
),
Positioned(
left: 14,
right: 14,
bottom: 12,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(occasion.label, style: Theme.of(context).textTheme.titleLarge?.copyWith(fontSize: 18)),
Text(occasion.subtitle, style: const TextStyle(fontSize: 13, color: NkColors.mutedOnPhoto)),
],
),
),
],
),
),
),
);
}
}