Skip to content

Instantly share code, notes, and snippets.

@vbalagovic
Created November 28, 2024 17:37
Show Gist options
  • Save vbalagovic/054987173544298ea4d7e3aa60359a7d to your computer and use it in GitHub Desktop.
Save vbalagovic/054987173544298ea4d7e3aa60359a7d to your computer and use it in GitHub Desktop.
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