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:
@@ -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()));
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user