chore: bring flutter client, docs, deploy and tools into monorepo

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-05 03:22:24 +02:00
co-authored by Claude Opus 4.8
parent 8c0519d125
commit 4b80c7dbb0
189 changed files with 19124 additions and 0 deletions
+8
View File
@@ -0,0 +1,8 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'src/app.dart';
void main() {
runApp(const ProviderScope(child: MusifyApp()));
}
+20
View File
@@ -0,0 +1,20 @@
import 'package:flutter/material.dart';
import 'core/router/app_router.dart';
import 'core/theme/app_theme.dart';
class MusifyApp extends StatelessWidget {
const MusifyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp.router(
title: 'Musify',
debugShowCheckedModeBanner: false,
theme: AppTheme.light(),
darkTheme: AppTheme.dark(),
themeMode: ThemeMode.system,
routerConfig: appRouter,
);
}
}
@@ -0,0 +1,24 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:oidc/oidc.dart';
import 'auth_repository.dart';
final authRepositoryProvider = Provider<AuthRepository>((ref) {
final repository = AuthRepository();
ref.onDispose(repository.dispose);
return repository;
});
final authUserProvider = StreamProvider<OidcUser?>((ref) async* {
final repository = ref.watch(authRepositoryProvider);
await repository.init();
yield* repository.userChanges;
});
final isAuthenticatedProvider = Provider<bool>((ref) {
return ref.watch(authUserProvider).valueOrNull != null;
});
final currentUserIdProvider = Provider<String?>((ref) {
return ref.watch(authUserProvider).valueOrNull?.uid;
});
@@ -0,0 +1,50 @@
import 'package:oidc/oidc.dart';
import 'package:oidc_default_store/oidc_default_store.dart';
import '../config/app_config.dart';
class AuthRepository {
AuthRepository() : _manager = _createManager();
final OidcUserManager _manager;
bool _initialized = false;
Future<void> init() async {
if (_initialized) return;
_initialized = true;
await _manager.init();
}
Stream<OidcUser?> get userChanges => _manager.userChanges();
OidcUser? get currentUser => _manager.currentUser;
String? get accessToken => _manager.currentUser?.token.accessToken;
Future<OidcUser?> login() => _manager.loginAuthorizationCodeFlow();
Future<void> logout() => _manager.logout();
Future<void> dispose() => _manager.dispose();
static OidcUserManager _createManager() {
final redirectUri = Uri.parse('http://localhost:$_redirectPort/');
return OidcUserManager.lazy(
discoveryDocumentUri: OidcUtils.getOpenIdConfigWellKnownUri(
Uri.parse(AppConfig.zitadelIssuer),
),
clientCredentials: const OidcClientAuthentication.none(
clientId: AppConfig.zitadelClientId,
),
store: OidcDefaultStore(),
settings: OidcUserManagerSettings(
scope: const ['openid', 'profile', 'email', 'offline_access'],
redirectUri: redirectUri,
postLogoutRedirectUri: redirectUri,
),
);
}
static const _redirectPort = 8765;
}
@@ -0,0 +1,23 @@
class AppConfig {
const AppConfig._();
static const String apiBaseUrl = String.fromEnvironment(
'API_BASE_URL',
defaultValue: 'http://localhost:5111',
);
static const String streamingGatewayUrl = String.fromEnvironment(
'STREAMING_GATEWAY_URL',
defaultValue: 'http://localhost:8081',
);
static const String zitadelIssuer = String.fromEnvironment(
'ZITADEL_ISSUER',
defaultValue: 'http://localhost:8080',
);
static const String zitadelClientId = String.fromEnvironment(
'ZITADEL_CLIENT_ID',
defaultValue: '372825689755287555',
);
}
@@ -0,0 +1,30 @@
import 'package:dio/dio.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../auth/auth_providers.dart';
import '../config/app_config.dart';
final dioProvider = Provider<Dio>((ref) {
final dio = Dio(
BaseOptions(
baseUrl: AppConfig.apiBaseUrl,
connectTimeout: const Duration(seconds: 10),
receiveTimeout: const Duration(seconds: 15),
headers: const {'Accept': 'application/json'},
),
);
dio.interceptors.add(
InterceptorsWrapper(
onRequest: (options, handler) {
final token = ref.read(authRepositoryProvider).accessToken;
if (token != null) {
options.headers['Authorization'] = 'Bearer $token';
}
handler.next(options);
},
),
);
return dio;
});
@@ -0,0 +1,65 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import '../../features/home/presentation/home_page.dart';
import '../../features/library/presentation/library_page.dart';
import '../../features/playlists/presentation/playlist_detail_page.dart';
import '../../features/profile/presentation/profile_page.dart';
import '../../features/search/presentation/search_page.dart';
import '../../shell/scaffold_with_nav.dart';
final _rootKey = GlobalKey<NavigatorState>(debugLabel: 'root');
final appRouter = GoRouter(
navigatorKey: _rootKey,
initialLocation: '/home',
routes: [
StatefulShellRoute.indexedStack(
builder: (context, state, navigationShell) =>
ScaffoldWithNav(navigationShell: navigationShell),
branches: [
StatefulShellBranch(
routes: [
GoRoute(
path: '/home',
builder: (context, state) => const HomePage(),
),
],
),
StatefulShellBranch(
routes: [
GoRoute(
path: '/search',
builder: (context, state) => const SearchPage(),
),
],
),
StatefulShellBranch(
routes: [
GoRoute(
path: '/library',
builder: (context, state) => const LibraryPage(),
routes: [
GoRoute(
path: ':playlistId',
parentNavigatorKey: _rootKey,
builder: (context, state) => PlayListDetailPage(
playlistId: state.pathParameters['playlistId']!,
),
),
],
),
],
),
StatefulShellBranch(
routes: [
GoRoute(
path: '/profile',
builder: (context, state) => const ProfilePage(),
),
],
),
],
),
],
);
@@ -0,0 +1,48 @@
import 'package:flutter/material.dart';
class AppTheme {
const AppTheme._();
static const Color _seed = Color(0xFF1DB954);
static ThemeData light() => _build(Brightness.light);
static ThemeData dark() => _build(Brightness.dark);
static ThemeData _build(Brightness brightness) {
final colorScheme = ColorScheme.fromSeed(
seedColor: _seed,
brightness: brightness,
);
return ThemeData(
useMaterial3: true,
colorScheme: colorScheme,
scaffoldBackgroundColor: colorScheme.surface,
appBarTheme: AppBarTheme(
centerTitle: false,
backgroundColor: colorScheme.surface,
foregroundColor: colorScheme.onSurface,
elevation: 0,
scrolledUnderElevation: 2,
),
cardTheme: CardThemeData(
clipBehavior: Clip.antiAlias,
elevation: 0,
color: colorScheme.surfaceContainerHigh,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
),
listTileTheme: const ListTileThemeData(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.all(Radius.circular(12)),
),
),
navigationBarTheme: NavigationBarThemeData(
backgroundColor: colorScheme.surfaceContainer,
indicatorColor: colorScheme.secondaryContainer,
),
);
}
}
@@ -0,0 +1,161 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../../shared/widgets/async_value_view.dart';
import '../../../shared/widgets/cover_art.dart';
import '../../player/application/player_controller.dart';
import '../../playlists/data/playlist_repository.dart';
import '../../playlists/domain/playlist.dart';
import '../../tracks/data/track_repository.dart';
import '../../tracks/domain/track.dart';
class HomePage extends ConsumerWidget {
const HomePage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final playLists = ref.watch(playListsProvider);
final tracks = ref.watch(tracksProvider);
return RefreshIndicator(
onRefresh: () async {
ref.invalidate(playListsProvider);
ref.invalidate(tracksProvider);
await Future.wait([
ref.read(playListsProvider.future),
ref.read(tracksProvider.future),
]);
},
child: ListView(
padding: const EdgeInsets.symmetric(vertical: 8),
children: [
const _SectionHeader(title: 'Tus playlists'),
SizedBox(
height: 190,
child: AsyncValueView<List<PlayList>>(
value: playLists,
onRetry: () => ref.invalidate(playListsProvider),
data: (items) => _PlayListRow(playLists: items),
),
),
const _SectionHeader(title: 'Pistas recientes'),
AsyncValueView<List<Track>>(
value: tracks,
onRetry: () => ref.invalidate(tracksProvider),
data: (items) => _TrackList(
tracks: items,
onPlay: (track) =>
ref.read(playerControllerProvider.notifier).play(track),
),
),
const SizedBox(height: 24),
],
),
);
}
}
class _SectionHeader extends StatelessWidget {
const _SectionHeader({required this.title});
final String title;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
child: Text(title, style: Theme.of(context).textTheme.titleLarge),
);
}
}
class _PlayListRow extends StatelessWidget {
const _PlayListRow({required this.playLists});
final List<PlayList> playLists;
@override
Widget build(BuildContext context) {
if (playLists.isEmpty) {
return const _EmptyHint(message: 'Aún no hay playlists');
}
return ListView.separated(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 16),
itemCount: playLists.length,
separatorBuilder: (_, _) => const SizedBox(width: 12),
itemBuilder: (context, index) {
final playList = playLists[index];
return SizedBox(
width: 130,
child: InkWell(
borderRadius: BorderRadius.circular(12),
onTap: () => context.go('/library/${playList.id}'),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
CoverArt(seed: playList.id, icon: Icons.queue_music),
const SizedBox(height: 8),
Text(
playList.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.bodyMedium,
),
],
),
),
);
},
);
}
}
class _TrackList extends StatelessWidget {
const _TrackList({required this.tracks, required this.onPlay});
final List<Track> tracks;
final void Function(Track track) onPlay;
@override
Widget build(BuildContext context) {
if (tracks.isEmpty) {
return const _EmptyHint(message: 'Aún no hay pistas');
}
return Column(
children: [
for (final track in tracks)
ListTile(
leading: SizedBox(
width: 48,
child: CoverArt(seed: track.id, borderRadius: 8),
),
title: Text(track.title, maxLines: 1, overflow: TextOverflow.ellipsis),
subtitle: const Text('Pista'),
trailing: const Icon(Icons.play_arrow),
onTap: () => onPlay(track),
),
],
);
}
}
class _EmptyHint extends StatelessWidget {
const _EmptyHint({required this.message});
final String message;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.all(16),
child: Text(
message,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
);
}
}
@@ -0,0 +1,68 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../../core/auth/auth_providers.dart';
import '../../../shared/widgets/async_value_view.dart';
import '../../../shared/widgets/cover_art.dart';
import '../../playlists/data/playlist_repository.dart';
import '../../playlists/domain/playlist.dart';
class LibraryPage extends ConsumerWidget {
const LibraryPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final userId = ref.watch(currentUserIdProvider);
final provider = userId != null
? myPlayListsProvider(userId)
: playListsProvider;
final playLists = ref.watch(provider);
final emptyMessage = userId != null
? 'Aún no tienes playlists'
: 'No hay playlists públicas';
return AsyncValueView<List<PlayList>>(
value: playLists,
onRetry: () => ref.invalidate(provider),
data: (items) {
if (items.isEmpty) {
return Center(child: Text(emptyMessage));
}
return RefreshIndicator(
onRefresh: () async {
ref.invalidate(provider);
await ref.read(provider.future);
},
child: ListView.builder(
padding: const EdgeInsets.symmetric(vertical: 8),
itemCount: items.length,
itemBuilder: (context, index) {
final playList = items[index];
return ListTile(
leading: SizedBox(
width: 48,
child: CoverArt(
seed: playList.id,
icon: Icons.queue_music,
borderRadius: 8,
),
),
title: Text(playList.name),
subtitle: playList.description.isEmpty
? null
: Text(
playList.description,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
trailing: const Icon(Icons.chevron_right),
onTap: () => context.go('/library/${playList.id}'),
);
},
),
);
},
);
}
}
@@ -0,0 +1,124 @@
import 'dart:async';
import 'package:audioplayers/audioplayers.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../tracks/domain/track.dart';
import '../data/stream_repository.dart';
final audioPlayerProvider = Provider<AudioPlayer>((ref) {
final player = AudioPlayer();
ref.onDispose(player.dispose);
return player;
});
class PlaybackState {
const PlaybackState({
this.track,
this.playing = false,
this.loading = false,
this.position = Duration.zero,
this.duration = Duration.zero,
this.error,
});
final Track? track;
final bool playing;
final bool loading;
final Duration position;
final Duration duration;
final String? error;
bool get hasContent => track != null;
PlaybackState copyWith({
Track? track,
bool? playing,
bool? loading,
Duration? position,
Duration? duration,
Object? error = _sentinel,
}) {
return PlaybackState(
track: track ?? this.track,
playing: playing ?? this.playing,
loading: loading ?? this.loading,
position: position ?? this.position,
duration: duration ?? this.duration,
error: identical(error, _sentinel) ? this.error : error as String?,
);
}
static const _sentinel = Object();
}
class PlayerController extends Notifier<PlaybackState> {
AudioPlayer get _player => ref.read(audioPlayerProvider);
@override
PlaybackState build() {
final player = ref.read(audioPlayerProvider);
final subscriptions = <StreamSubscription<dynamic>>[
player.onPlayerStateChanged.listen((value) {
final playing = value == PlayerState.playing;
state = state.copyWith(
playing: playing,
loading: playing ? false : state.loading,
);
}),
player.onPositionChanged.listen((value) {
state = state.copyWith(position: value);
}),
player.onDurationChanged.listen((value) {
state = state.copyWith(duration: value, loading: false);
}),
player.onPlayerComplete.listen((_) {
state = state.copyWith(playing: false, position: Duration.zero);
}),
];
ref.onDispose(() {
for (final subscription in subscriptions) {
subscription.cancel();
}
});
return const PlaybackState();
}
Future<void> play(Track track) async {
state = PlaybackState(track: track, loading: true);
try {
final stream = await ref.read(streamRepositoryProvider).getStream(track.id);
await _player.play(UrlSource(_withTicket(stream.manifestUrl, stream.ticket)));
} catch (error) {
state = state.copyWith(loading: false, error: _describe(error));
}
}
Future<void> togglePlay() {
return _player.state == PlayerState.playing
? _player.pause()
: _player.resume();
}
Future<void> seek(Duration position) => _player.seek(position);
Future<void> stop() async {
await _player.stop();
state = const PlaybackState();
}
String _withTicket(String url, String ticket) {
final separator = url.contains('?') ? '&' : '?';
return '$url${separator}t=${Uri.encodeComponent(ticket)}';
}
String _describe(Object error) {
if (error.toString().contains('401')) {
return 'Inicia sesión para reproducir.';
}
return 'No se pudo reproducir la pista.';
}
}
final playerControllerProvider =
NotifierProvider<PlayerController, PlaybackState>(PlayerController.new);
@@ -0,0 +1,22 @@
import 'package:dio/dio.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/network/dio_client.dart';
import '../domain/track_stream.dart';
class StreamRepository {
StreamRepository(this._dio);
final Dio _dio;
Future<TrackStream> getStream(String trackId) async {
final response = await _dio.get<Map<String, dynamic>>(
'/tracks/$trackId/stream',
);
return TrackStream.fromJson(response.data!);
}
}
final streamRepositoryProvider = Provider<StreamRepository>((ref) {
return StreamRepository(ref.watch(dioProvider));
});
@@ -0,0 +1,19 @@
class TrackStream {
const TrackStream({
required this.manifestUrl,
required this.ticket,
required this.expiresInSeconds,
});
final String manifestUrl;
final String ticket;
final int expiresInSeconds;
factory TrackStream.fromJson(Map<String, dynamic> json) {
return TrackStream(
manifestUrl: json['manifestUrl'] as String,
ticket: json['ticket'] as String,
expiresInSeconds: json['expiresInSeconds'] as int? ?? 0,
);
}
}
@@ -0,0 +1,124 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../shared/widgets/cover_art.dart';
import '../application/player_controller.dart';
class MiniPlayer extends ConsumerWidget {
const MiniPlayer({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final state = ref.watch(playerControllerProvider);
if (!state.hasContent) return const SizedBox.shrink();
final theme = Theme.of(context);
final controller = ref.read(playerControllerProvider.notifier);
final track = state.track!;
return Material(
color: theme.colorScheme.surfaceContainerHigh,
elevation: 3,
child: Padding(
padding: const EdgeInsets.fromLTRB(12, 6, 4, 2),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Row(
children: [
SizedBox(
width: 40,
child: CoverArt(seed: track.id, borderRadius: 6),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
track.title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.titleSmall,
),
Text(
state.error ??
(state.loading ? 'Cargando…' : 'Reproduciendo'),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodySmall?.copyWith(
color: state.error != null
? theme.colorScheme.error
: theme.colorScheme.onSurfaceVariant,
),
),
],
),
),
IconButton(
iconSize: 36,
icon: Icon(
state.playing ? Icons.pause_circle : Icons.play_circle,
),
onPressed: state.error != null ? null : controller.togglePlay,
),
IconButton(
icon: const Icon(Icons.close),
onPressed: controller.stop,
),
],
),
_ProgressBar(
position: state.position,
duration: state.duration,
onSeek: controller.seek,
),
],
),
),
);
}
}
class _ProgressBar extends StatelessWidget {
const _ProgressBar({
required this.position,
required this.duration,
required this.onSeek,
});
final Duration position;
final Duration duration;
final void Function(Duration position) onSeek;
@override
Widget build(BuildContext context) {
final totalMs = duration.inMilliseconds;
final value = totalMs == 0
? 0.0
: (position.inMilliseconds / totalMs).clamp(0.0, 1.0);
return Row(
children: [
Text(_format(position), style: Theme.of(context).textTheme.labelSmall),
Expanded(
child: Slider(
value: value,
onChanged: totalMs == 0
? null
: (fraction) => onSeek(
Duration(milliseconds: (fraction * totalMs).round()),
),
),
),
Text(_format(duration), style: Theme.of(context).textTheme.labelSmall),
],
);
}
String _format(Duration value) {
final minutes = value.inMinutes;
final seconds = value.inSeconds % 60;
return '$minutes:${seconds.toString().padLeft(2, '0')}';
}
}
@@ -0,0 +1,87 @@
import 'package:dio/dio.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/network/dio_client.dart';
import '../../../shared/models/paginated_response.dart';
import '../../tracks/domain/track.dart';
import '../domain/playlist.dart';
class PlayListRepository {
PlayListRepository(this._dio);
final Dio _dio;
Future<PaginatedResponse<PlayList>> getPlayLists({
int pageNumber = 1,
int pageSize = 20,
}) async {
final response = await _dio.get<Map<String, dynamic>>(
'/playLists',
queryParameters: {'pageNumber': pageNumber, 'pageSize': pageSize},
);
return PaginatedResponse<PlayList>.fromJson(
response.data!,
PlayList.fromJson,
);
}
Future<PaginatedResponse<PlayList>> getPlayListsByUser(
String userId, {
int pageNumber = 1,
int pageSize = 50,
}) async {
final response = await _dio.get<Map<String, dynamic>>(
'/playLists/users/$userId',
queryParameters: {'pageNumber': pageNumber, 'pageSize': pageSize},
);
return PaginatedResponse<PlayList>.fromJson(
response.data!,
PlayList.fromJson,
);
}
Future<PlayList> getPlayListById(String id) async {
final response = await _dio.get<Map<String, dynamic>>('/playLists/$id');
return PlayList.fromJson(response.data!);
}
Future<PaginatedResponse<Track>> getPlayListTracks(
String playlistId, {
int pageNumber = 1,
int pageSize = 50,
}) async {
final response = await _dio.get<Map<String, dynamic>>(
'/playLists/$playlistId/tracks',
queryParameters: {'pageNumber': pageNumber, 'pageSize': pageSize},
);
return PaginatedResponse<Track>.fromJson(response.data!, Track.fromJson);
}
}
final playListRepositoryProvider = Provider<PlayListRepository>((ref) {
return PlayListRepository(ref.watch(dioProvider));
});
final playListsProvider =
FutureProvider.autoDispose<List<PlayList>>((ref) async {
final page = await ref.watch(playListRepositoryProvider).getPlayLists();
return page.items;
});
final myPlayListsProvider =
FutureProvider.autoDispose.family<List<PlayList>, String>((ref, userId) async {
final page =
await ref.watch(playListRepositoryProvider).getPlayListsByUser(userId);
return page.items;
});
final playListByIdProvider =
FutureProvider.autoDispose.family<PlayList, String>((ref, id) async {
return ref.watch(playListRepositoryProvider).getPlayListById(id);
});
final playListTracksProvider =
FutureProvider.autoDispose.family<List<Track>, String>((ref, id) async {
final page = await ref.watch(playListRepositoryProvider).getPlayListTracks(id);
return page.items;
});
@@ -0,0 +1,34 @@
class PlayList {
const PlayList({
required this.id,
required this.name,
required this.description,
required this.smallImageKeyName,
required this.mediumImageKeyName,
required this.largeImageKeyName,
required this.createdAt,
required this.updatedAt,
});
final String id;
final String name;
final String description;
final String smallImageKeyName;
final String mediumImageKeyName;
final String largeImageKeyName;
final DateTime createdAt;
final DateTime updatedAt;
factory PlayList.fromJson(Map<String, dynamic> json) {
return PlayList(
id: json['id'] as String,
name: json['name'] as String? ?? 'Sin nombre',
description: json['description'] as String? ?? '',
smallImageKeyName: json['smallImageKeyName'] as String? ?? '',
mediumImageKeyName: json['mediumImageKeyName'] as String? ?? '',
largeImageKeyName: json['largeImageKeyName'] as String? ?? '',
createdAt: DateTime.parse(json['createdAt'] as String),
updatedAt: DateTime.parse(json['updatedAt'] as String),
);
}
}
@@ -0,0 +1,137 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../shared/widgets/async_value_view.dart';
import '../../../shared/widgets/cover_art.dart';
import '../../player/application/player_controller.dart';
import '../../tracks/domain/track.dart';
import '../data/playlist_repository.dart';
import '../domain/playlist.dart';
class PlayListDetailPage extends ConsumerWidget {
const PlayListDetailPage({super.key, required this.playlistId});
final String playlistId;
@override
Widget build(BuildContext context, WidgetRef ref) {
final playList = ref.watch(playListByIdProvider(playlistId));
final tracks = ref.watch(playListTracksProvider(playlistId));
return Scaffold(
body: AsyncValueView<PlayList>(
value: playList,
onRetry: () => ref.invalidate(playListByIdProvider(playlistId)),
data: (playlist) => CustomScrollView(
slivers: [
SliverAppBar.large(
title: Text(playlist.name),
),
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 120,
child: CoverArt(
seed: playlist.id,
icon: Icons.queue_music,
),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
playlist.name,
style: Theme.of(context).textTheme.headlineSmall,
),
const SizedBox(height: 8),
if (playlist.description.isNotEmpty)
Text(
playlist.description,
style: Theme.of(context).textTheme.bodyMedium,
),
],
),
),
],
),
),
),
_TracksSliver(
tracks: tracks,
onRetry: () =>
ref.invalidate(playListTracksProvider(playlistId)),
onPlay: (track) =>
ref.read(playerControllerProvider.notifier).play(track),
),
const SliverToBoxAdapter(child: SizedBox(height: 24)),
],
),
),
);
}
}
class _TracksSliver extends StatelessWidget {
const _TracksSliver({
required this.tracks,
required this.onRetry,
required this.onPlay,
});
final AsyncValue<List<Track>> tracks;
final VoidCallback onRetry;
final void Function(Track track) onPlay;
@override
Widget build(BuildContext context) {
return tracks.when(
loading: () => const SliverToBoxAdapter(
child: Padding(
padding: EdgeInsets.all(32),
child: Center(child: CircularProgressIndicator()),
),
),
error: (error, _) => SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.all(24),
child: Center(child: Text('Error al cargar pistas: $error')),
),
),
data: (items) {
if (items.isEmpty) {
return const SliverToBoxAdapter(
child: Padding(
padding: EdgeInsets.all(24),
child: Center(child: Text('Esta playlist no tiene pistas')),
),
);
}
return SliverList.builder(
itemCount: items.length,
itemBuilder: (context, index) {
final track = items[index];
return ListTile(
leading: Text(
'${index + 1}',
style: Theme.of(context).textTheme.bodyMedium,
),
title: Text(
track.title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
trailing: const Icon(Icons.play_arrow),
onTap: () => onPlay(track),
);
},
);
},
);
}
}
@@ -0,0 +1,158 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:oidc/oidc.dart';
import '../../../core/auth/auth_providers.dart';
class ProfilePage extends ConsumerWidget {
const ProfilePage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final authState = ref.watch(authUserProvider);
return authState.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (error, _) => Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Text('Error de autenticación: $error'),
),
),
data: (user) =>
user == null ? const _GuestView() : _UserView(user: user),
);
}
}
class _GuestView extends ConsumerStatefulWidget {
const _GuestView();
@override
ConsumerState<_GuestView> createState() => _GuestViewState();
}
class _GuestViewState extends ConsumerState<_GuestView> {
bool _loading = false;
Future<void> _login() async {
setState(() => _loading = true);
try {
await ref.read(authRepositoryProvider).login();
} catch (error) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('No se pudo iniciar sesión: $error')),
);
}
} finally {
if (mounted) setState(() => _loading = false);
}
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
CircleAvatar(
radius: 40,
backgroundColor: theme.colorScheme.secondaryContainer,
child: Icon(
Icons.person,
size: 40,
color: theme.colorScheme.onSecondaryContainer,
),
),
const SizedBox(height: 16),
Text('Invitado', style: theme.textTheme.titleLarge),
const SizedBox(height: 8),
Text(
'Inicia sesión con Zitadel para crear playlists,\n'
'subir pistas y reproducir tu biblioteca.',
textAlign: TextAlign.center,
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 24),
FilledButton.icon(
onPressed: _loading ? null : _login,
icon: _loading
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.login),
label: const Text('Iniciar sesión'),
),
],
),
),
);
}
}
class _UserView extends ConsumerWidget {
const _UserView({required this.user});
final OidcUser user;
@override
Widget build(BuildContext context, WidgetRef ref) {
final theme = Theme.of(context);
final claims = user.aggregatedClaims;
final name = (claims['name'] ?? claims['preferred_username']) as String?;
final email = claims['email'] as String?;
final picture = claims['picture'] as String?;
return Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
CircleAvatar(
radius: 44,
backgroundColor: theme.colorScheme.secondaryContainer,
foregroundImage: picture != null ? NetworkImage(picture) : null,
child: Icon(
Icons.person,
size: 44,
color: theme.colorScheme.onSecondaryContainer,
),
),
const SizedBox(height: 16),
Text(
name ?? 'Usuario',
style: theme.textTheme.titleLarge,
textAlign: TextAlign.center,
),
if (email != null) ...[
const SizedBox(height: 4),
Text(email, style: theme.textTheme.bodyMedium),
],
const SizedBox(height: 8),
Text(
'ID: ${user.uid ?? '—'}',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 24),
OutlinedButton.icon(
onPressed: () => ref.read(authRepositoryProvider).logout(),
icon: const Icon(Icons.logout),
label: const Text('Cerrar sesión'),
),
],
),
),
);
}
}
@@ -0,0 +1,45 @@
import 'package:flutter/material.dart';
class SearchPage extends StatelessWidget {
const SearchPage({super.key});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
SearchBar(
hintText: 'Buscar pistas, playlists, artistas…',
leading: const Icon(Icons.search),
padding: const WidgetStatePropertyAll(
EdgeInsets.symmetric(horizontal: 16),
),
onSubmitted: (_) {},
),
const SizedBox(height: 32),
Expanded(
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.search,
size: 48,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
const SizedBox(height: 8),
Text(
'La búsqueda llegará pronto',
style: Theme.of(context).textTheme.bodyMedium,
),
],
),
),
),
],
),
);
}
}
@@ -0,0 +1,32 @@
import 'package:dio/dio.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/network/dio_client.dart';
import '../../../shared/models/paginated_response.dart';
import '../domain/track.dart';
class TrackRepository {
TrackRepository(this._dio);
final Dio _dio;
Future<PaginatedResponse<Track>> getTracks({
int pageNumber = 1,
int pageSize = 20,
}) async {
final response = await _dio.get<Map<String, dynamic>>(
'/tracks',
queryParameters: {'pageNumber': pageNumber, 'pageSize': pageSize},
);
return PaginatedResponse<Track>.fromJson(response.data!, Track.fromJson);
}
}
final trackRepositoryProvider = Provider<TrackRepository>((ref) {
return TrackRepository(ref.watch(dioProvider));
});
final tracksProvider = FutureProvider.autoDispose<List<Track>>((ref) async {
final page = await ref.watch(trackRepositoryProvider).getTracks();
return page.items;
});
@@ -0,0 +1,22 @@
class Track {
const Track({
required this.id,
required this.title,
required this.createdAt,
required this.updatedAt,
});
final String id;
final String title;
final DateTime createdAt;
final DateTime updatedAt;
factory Track.fromJson(Map<String, dynamic> json) {
return Track(
id: json['id'] as String,
title: json['title'] as String? ?? 'Sin título',
createdAt: DateTime.parse(json['createdAt'] as String),
updatedAt: DateTime.parse(json['updatedAt'] as String),
);
}
}
@@ -0,0 +1,37 @@
class PaginatedResponse<T> {
const PaginatedResponse({
required this.items,
required this.pageNumber,
required this.pageSize,
required this.pageCount,
required this.totalItemCount,
required this.hasNextPage,
required this.hasPreviousPage,
});
final List<T> items;
final int pageNumber;
final int pageSize;
final int pageCount;
final int totalItemCount;
final bool hasNextPage;
final bool hasPreviousPage;
factory PaginatedResponse.fromJson(
Map<String, dynamic> json,
T Function(Map<String, dynamic> item) fromItem,
) {
final rawItems = (json['items'] as List<dynamic>? ?? const []);
return PaginatedResponse<T>(
items: rawItems
.map((dynamic e) => fromItem(e as Map<String, dynamic>))
.toList(growable: false),
pageNumber: json['pageNumber'] as int? ?? 1,
pageSize: json['pageSize'] as int? ?? rawItems.length,
pageCount: json['pageCount'] as int? ?? 1,
totalItemCount: json['totalItemCount'] as int? ?? rawItems.length,
hasNextPage: json['hasNextPage'] as bool? ?? false,
hasPreviousPage: json['hasPreviousPage'] as bool? ?? false,
);
}
}
@@ -0,0 +1,69 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
class AsyncValueView<T> extends StatelessWidget {
const AsyncValueView({
super.key,
required this.value,
required this.data,
this.onRetry,
});
final AsyncValue<T> value;
final Widget Function(T data) data;
final VoidCallback? onRetry;
@override
Widget build(BuildContext context) {
return value.when(
data: data,
loading: () => const Center(child: CircularProgressIndicator()),
error: (error, _) => _ErrorView(error: error, onRetry: onRetry),
);
}
}
class _ErrorView extends StatelessWidget {
const _ErrorView({required this.error, this.onRetry});
final Object error;
final VoidCallback? onRetry;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Center(
child: SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.cloud_off, size: 36, color: theme.colorScheme.error),
const SizedBox(height: 8),
Text(
'No se pudo cargar el contenido',
style: theme.textTheme.titleSmall,
textAlign: TextAlign.center,
),
const SizedBox(height: 4),
Text(
'$error',
style: theme.textTheme.bodySmall,
textAlign: TextAlign.center,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
if (onRetry != null) ...[
const SizedBox(height: 12),
FilledButton.tonalIcon(
onPressed: onRetry,
icon: const Icon(Icons.refresh),
label: const Text('Reintentar'),
),
],
],
),
),
);
}
}
@@ -0,0 +1,40 @@
import 'package:flutter/material.dart';
class CoverArt extends StatelessWidget {
const CoverArt({
super.key,
required this.seed,
this.icon = Icons.music_note,
this.size,
this.borderRadius = 12,
});
final String seed;
final IconData icon;
final double? size;
final double borderRadius;
@override
Widget build(BuildContext context) {
final hue = (seed.hashCode % 360).abs().toDouble();
final base = HSLColor.fromAHSL(1, hue, 0.55, 0.45).toColor();
final accent = HSLColor.fromAHSL(1, (hue + 40) % 360, 0.6, 0.35).toColor();
return AspectRatio(
aspectRatio: 1,
child: Container(
width: size,
height: size,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(borderRadius),
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [base, accent],
),
),
child: Icon(icon, color: Colors.white.withValues(alpha: 0.85), size: 32),
),
);
}
}
@@ -0,0 +1,101 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import '../features/player/presentation/mini_player.dart';
class _Destination {
const _Destination(this.label, this.icon, this.selectedIcon);
final String label;
final IconData icon;
final IconData selectedIcon;
}
const _destinations = <_Destination>[
_Destination('Inicio', Icons.home_outlined, Icons.home),
_Destination('Buscar', Icons.search_outlined, Icons.search),
_Destination('Biblioteca', Icons.library_music_outlined, Icons.library_music),
_Destination('Perfil', Icons.person_outline, Icons.person),
];
class ScaffoldWithNav extends StatelessWidget {
const ScaffoldWithNav({super.key, required this.navigationShell});
final StatefulNavigationShell navigationShell;
void _onSelect(int index) {
navigationShell.goBranch(
index,
initialLocation: index == navigationShell.currentIndex,
);
}
@override
Widget build(BuildContext context) {
final isWide = MediaQuery.sizeOf(context).width >= 720;
if (isWide) {
return Scaffold(
body: Row(
children: [
NavigationRail(
selectedIndex: navigationShell.currentIndex,
onDestinationSelected: _onSelect,
labelType: NavigationRailLabelType.all,
leading: const Padding(
padding: EdgeInsets.symmetric(vertical: 12),
child: Icon(Icons.graphic_eq, size: 28),
),
destinations: [
for (final d in _destinations)
NavigationRailDestination(
icon: Icon(d.icon),
selectedIcon: Icon(d.selectedIcon),
label: Text(d.label),
),
],
),
const VerticalDivider(width: 1),
Expanded(child: _BranchScaffold(child: navigationShell)),
],
),
);
}
return _BranchScaffold(
bottomNavigationBar: NavigationBar(
selectedIndex: navigationShell.currentIndex,
onDestinationSelected: _onSelect,
destinations: [
for (final d in _destinations)
NavigationDestination(
icon: Icon(d.icon),
selectedIcon: Icon(d.selectedIcon),
label: d.label,
),
],
),
child: navigationShell,
);
}
}
class _BranchScaffold extends StatelessWidget {
const _BranchScaffold({required this.child, this.bottomNavigationBar});
final Widget child;
final Widget? bottomNavigationBar;
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Musify')),
body: Column(
children: [
Expanded(child: child),
const MiniPlayer(),
],
),
bottomNavigationBar: bottomNavigationBar,
);
}
}