Created
November 28, 2024 17:37
-
-
Save vbalagovic/054987173544298ea4d7e3aa60359a7d to your computer and use it in GitHub Desktop.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import 'package:flutter/material.dart'; | |
import 'package:flutter_riverpod/flutter_riverpod.dart'; | |
import 'package:dio/dio.dart'; | |
import 'package:freezed_annotation/freezed_annotation.dart'; | |
import 'package:riverpod_annotation/riverpod_annotation.dart'; | |
import 'package:go_router/go_router.dart'; | |
part 'main.freezed.dart'; | |
part 'main.g.dart'; | |
// MODELS | |
@freezed | |
class Movie with _$Movie { | |
const factory Movie({ | |
required String id, | |
required String title, | |
required String year, | |
required String plot, | |
String? poster, | |
}) = _Movie; | |
factory Movie.fromJson(Map<String, dynamic> json) => _$MovieFromJson(json); | |
} | |
// SERVICES | |
@riverpod | |
class MovieService extends _$MovieService { | |
late final _dio = Dio( | |
BaseOptions( | |
baseUrl: 'https://6748a43f5801f5153591b589.mockapi.io/api', | |
connectTimeout: const Duration(seconds: 5), | |
receiveTimeout: const Duration(seconds: 3), | |
), | |
); | |
@override | |
Future<List<Movie>> build() async { | |
return fetchMovies(); | |
} | |
Future<List<Movie>> fetchMovies([String filter = ""]) async { | |
final response = await _dio.get('/movies', queryParameters: { | |
if (filter.isNotEmpty) 'Title': filter, | |
}); | |
return (response.data as List).map((json) => Movie.fromJson(json)).toList(); | |
} | |
Future<Movie> fetchMovie(String id) async { | |
final response = await _dio.get('/movies/$id'); | |
return Movie.fromJson(response.data); | |
} | |
Future<Movie> updateMovie(String id, Map<String, dynamic> movieData) async { | |
try { | |
final response = await _dio.put('/movies/$id', data: movieData); | |
return Movie.fromJson(response.data); | |
} on DioException catch (e) { | |
throw e.message ?? 'Update failed'; | |
} | |
} | |
Future<void> deleteMovie(String id) async { | |
try { | |
await _dio.delete('/movies/$id'); | |
} on DioException catch (e) { | |
throw e.message ?? 'Delete failed'; | |
} | |
} | |
} | |
// PROVIDERS | |
@riverpod | |
class MovieNotifier extends _$MovieNotifier { | |
@override | |
FutureOr<List<Movie>> build() async { | |
return _fetch(); | |
} | |
Future<List<Movie>> _fetch([String filter = ""]) async { | |
final movieService = ref.read(movieServiceProvider.notifier); | |
return movieService.fetchMovies(filter); | |
} | |
Future<void> filterMovies(String filter) async { | |
state = const AsyncValue.loading(); | |
state = await AsyncValue.guard(() => _fetch(filter)); | |
} | |
Future<void> updateMovie(String id, Map<String, dynamic> movieData) async { | |
final movieService = ref.read(movieServiceProvider.notifier); | |
await movieService.updateMovie(id, movieData); | |
ref.invalidateSelf(); | |
} | |
Future<void> deleteMovie(String id) async { | |
final movieService = ref.read(movieServiceProvider.notifier); | |
await movieService.deleteMovie(id); | |
ref.invalidateSelf(); | |
} | |
} | |
@riverpod | |
Future<Movie> movieDetails(Ref ref, String id) { | |
return ref.watch(movieServiceProvider.notifier).fetchMovie(id); | |
} | |
@riverpod | |
class EditMovie extends _$EditMovie { | |
@override | |
AsyncValue<Movie?> build() => const AsyncValue.data(null); | |
void setMovie(Movie movie) { | |
state = AsyncValue.data(movie); | |
} | |
Future<void> saveChanges(Movie updatedMovie) async { | |
if (state.value == null) return; | |
state = const AsyncValue.loading(); | |
try { | |
await ref.read(movieNotifierProvider.notifier) | |
.updateMovie(updatedMovie.id, updatedMovie.toJson()); | |
state = AsyncValue.data(updatedMovie); | |
} catch (e, st) { | |
state = AsyncValue.error(e, st); | |
} | |
} | |
} | |
// ROUTER | |
@riverpod | |
GoRouter router(Ref ref) { | |
return GoRouter( | |
routes: [ | |
GoRoute( | |
path: '/', | |
builder: (context, state) => const MovieList(), | |
), | |
GoRoute( | |
path: '/:id', | |
builder: (context, state) { | |
final id = state.pathParameters['id']!; | |
return MovieDetails(id: id); | |
}, | |
), | |
], | |
); | |
} | |
// UI COMPONENTS | |
class MovieApp extends ConsumerWidget { | |
const MovieApp({super.key}); | |
@override | |
Widget build(BuildContext context, WidgetRef ref) { | |
final router = ref.watch(routerProvider); | |
return MaterialApp.router( | |
routerConfig: router, | |
theme: ThemeData( | |
colorScheme: ColorScheme.fromSeed(seedColor: Colors.blue), | |
useMaterial3: true, | |
), | |
title: 'Movie App', | |
); | |
} | |
} | |
class MovieList extends ConsumerWidget { | |
const MovieList({super.key}); | |
@override | |
Widget build(BuildContext context, WidgetRef ref) { | |
final moviesAsync = ref.watch(movieNotifierProvider); | |
return Scaffold( | |
appBar: AppBar(title: const Text('Movies')), | |
body: moviesAsync.when( | |
data: (movies) => ListView.builder( | |
itemCount: movies.length, | |
itemBuilder: (context, index) => MovieCard(movie: movies[index]), | |
), | |
loading: () => const Center(child: CircularProgressIndicator()), | |
error: (error, stack) => Center(child: Text('Error: $error')), | |
), | |
); | |
} | |
} | |
class MovieCard extends ConsumerWidget { | |
final Movie movie; | |
const MovieCard({required this.movie, super.key}); | |
@override | |
Widget build(BuildContext context, WidgetRef ref) { | |
return Card( | |
child: ListTile( | |
title: Text(movie.title), | |
subtitle: Text('${movie.year} - ${movie.plot}'), | |
trailing: Row( | |
mainAxisSize: MainAxisSize.min, | |
children: [ | |
IconButton( | |
icon: const Icon(Icons.edit), | |
onPressed: () => _showEditModal(context, ref), | |
), | |
IconButton( | |
icon: const Icon(Icons.delete), | |
onPressed: () => _showDeleteDialog(context, ref), | |
), | |
], | |
), | |
onTap: () => context.push('/${movie.id}'), | |
), | |
); | |
} | |
void _showEditModal(BuildContext context, WidgetRef ref) { | |
showModalBottomSheet( | |
context: context, | |
isScrollControlled: true, | |
builder: (context) => Padding( | |
padding: EdgeInsets.only( | |
bottom: MediaQuery.of(context).viewInsets.bottom, | |
), | |
child: EditMovieModal(movie: movie), | |
), | |
); | |
} | |
void _showDeleteDialog(BuildContext context, WidgetRef ref) { | |
showDialog( | |
context: context, | |
builder: (context) => AlertDialog( | |
title: Text('Delete ${movie.title}?'), | |
content: const Text('This action cannot be undone.'), | |
actions: [ | |
TextButton( | |
onPressed: () => Navigator.pop(context), | |
child: const Text('Cancel'), | |
), | |
TextButton( | |
onPressed: () { | |
ref.read(movieNotifierProvider.notifier).deleteMovie(movie.id); | |
Navigator.pop(context); | |
}, | |
child: const Text('Delete'), | |
), | |
], | |
), | |
); | |
} | |
} | |
class EditMovieModal extends ConsumerStatefulWidget { | |
final Movie movie; | |
const EditMovieModal({required this.movie, super.key}); | |
@override | |
ConsumerState<EditMovieModal> createState() => _EditMovieModalState(); | |
} | |
class _EditMovieModalState extends ConsumerState<EditMovieModal> { | |
final _formKey = GlobalKey<FormState>(); | |
late Movie editedMovie; | |
@override | |
void initState() { | |
super.initState(); | |
editedMovie = widget.movie; | |
// Schedule the state update for the next frame | |
WidgetsBinding.instance.addPostFrameCallback((_) { | |
ref.read(editMovieProvider.notifier).setMovie(widget.movie); | |
}); | |
} | |
@override | |
Widget build(BuildContext context) { | |
final movieState = ref.watch(editMovieProvider); | |
return movieState.when( | |
data: (movie) => Form( | |
key: _formKey, | |
child: Padding( | |
padding: const EdgeInsets.all(16), | |
child: Column( | |
mainAxisSize: MainAxisSize.min, | |
children: [ | |
TextFormField( | |
initialValue: editedMovie.title, | |
decoration: const InputDecoration(labelText: 'Title'), | |
validator: (value) => | |
value?.isEmpty ?? true ? 'Please enter a title' : null, | |
onChanged: (value) { | |
setState(() { | |
editedMovie = editedMovie.copyWith(title: value); | |
}); | |
}, | |
), | |
const SizedBox(height: 16), | |
TextFormField( | |
initialValue: editedMovie.year, | |
decoration: const InputDecoration(labelText: 'Year'), | |
validator: (value) => | |
value?.isEmpty ?? true ? 'Please enter a year' : null, | |
onChanged: (value) { | |
setState(() { | |
editedMovie = editedMovie.copyWith(year: value); | |
}); | |
}, | |
), | |
const SizedBox(height: 16), | |
TextFormField( | |
initialValue: editedMovie.plot, | |
maxLines: 3, | |
decoration: const InputDecoration(labelText: 'Plot'), | |
validator: (value) => | |
value?.isEmpty ?? true ? 'Please enter a plot' : null, | |
onChanged: (value) { | |
setState(() { | |
editedMovie = editedMovie.copyWith(plot: value); | |
}); | |
}, | |
), | |
const SizedBox(height: 24), | |
ElevatedButton( | |
onPressed: () async { | |
if (_formKey.currentState?.validate() ?? false) { | |
await ref.read(editMovieProvider.notifier) | |
.saveChanges(editedMovie); | |
if (context.mounted) { | |
Navigator.pop(context); | |
} | |
} | |
}, | |
child: const Text('Save Changes'), | |
), | |
], | |
), | |
), | |
), | |
loading: () => const Center(child: CircularProgressIndicator()), | |
error: (error, stack) => Center(child: Text('Error: $error')), | |
); | |
} | |
} | |
class MovieDetails extends ConsumerWidget { | |
const MovieDetails({super.key, required this.id}); | |
final String id; | |
@override | |
Widget build(BuildContext context, WidgetRef ref) { | |
final movieAsync = ref.watch(movieDetailsProvider(id)); | |
return Scaffold( | |
appBar: AppBar( | |
leading: IconButton( | |
icon: const Icon(Icons.arrow_back), | |
onPressed: () => context.pop(), | |
), | |
title: const Text("Movie Details"), | |
), | |
body: movieAsync.when( | |
data: (movie) => MovieDetailsContent(movie: movie), | |
loading: () => const Center(child: CircularProgressIndicator()), | |
error: (error, stack) => Center(child: Text('Error: $error')), | |
), | |
); | |
} | |
} | |
class MovieDetailsContent extends StatelessWidget { | |
final Movie movie; | |
const MovieDetailsContent({required this.movie, super.key}); | |
@override | |
Widget build(BuildContext context) { | |
return SingleChildScrollView( | |
child: Column( | |
crossAxisAlignment: CrossAxisAlignment.start, | |
children: [ | |
if (movie.poster != null) | |
Image.network( | |
movie.poster!, | |
width: double.infinity, | |
fit: BoxFit.cover, | |
), | |
const SizedBox(height: 20), | |
Padding( | |
padding: const EdgeInsets.all(16), | |
child: Column( | |
crossAxisAlignment: CrossAxisAlignment.start, | |
children: [ | |
Text( | |
"${movie.title} (${movie.year})", | |
style: Theme.of(context).textTheme.headlineSmall, | |
), | |
const SizedBox(height: 8), | |
Text(movie.plot), | |
], | |
), | |
), | |
], | |
), | |
); | |
} | |
} | |
void main() { | |
runApp(const ProviderScope(child: MovieApp())); | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment