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