Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

docs(rest_api): add example for REST Api handling #3957

Open
wants to merge 2 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions examples/rest_api/.metadata
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
# This file tracks properties of this Flutter project.
# Used by Flutter tool to assess capabilities and perform upgrades etc.
#
# This file should be version controlled and should not be manually edited.

version:
revision: "17025dd88227cd9532c33fa78f5250d548d87e9a"
channel: "stable"

project_type: app

# Tracks metadata for the flutter migrate command
migration:
platforms:
- platform: root
create_revision: 17025dd88227cd9532c33fa78f5250d548d87e9a
base_revision: 17025dd88227cd9532c33fa78f5250d548d87e9a
- platform: android
create_revision: 17025dd88227cd9532c33fa78f5250d548d87e9a
base_revision: 17025dd88227cd9532c33fa78f5250d548d87e9a
- platform: ios
create_revision: 17025dd88227cd9532c33fa78f5250d548d87e9a
base_revision: 17025dd88227cd9532c33fa78f5250d548d87e9a
- platform: linux
create_revision: 17025dd88227cd9532c33fa78f5250d548d87e9a
base_revision: 17025dd88227cd9532c33fa78f5250d548d87e9a
- platform: macos
create_revision: 17025dd88227cd9532c33fa78f5250d548d87e9a
base_revision: 17025dd88227cd9532c33fa78f5250d548d87e9a
- platform: web
create_revision: 17025dd88227cd9532c33fa78f5250d548d87e9a
base_revision: 17025dd88227cd9532c33fa78f5250d548d87e9a
- platform: windows
create_revision: 17025dd88227cd9532c33fa78f5250d548d87e9a
base_revision: 17025dd88227cd9532c33fa78f5250d548d87e9a

# User provided section

# List of Local paths (relative to this file) that should be
# ignored by the migrate tool.
#
# Files that are not part of the templates will be ignored by default.
unmanaged_files:
- 'lib/main.dart'
- 'ios/Runner.xcodeproj/project.pbxproj'
43 changes: 43 additions & 0 deletions examples/rest_api/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
# REST API Example

A simple example demonstrating how to handle REST APIs with Riverpod, including proper testing practices.

## Key Points

This example demonstrates how to:

- Implement a REST API client with proper error handling
- Manage async state with AsyncNotifier
- Handle loading and error states
- Perform CRUD operations (Create and Read)
- Write comprehensive tests for all layers
- Use modern Dart patterns like pattern matching
- Implement form validation
- Handle pull-to-refresh

## Code Structure

- `lib/main.dart` - Contains all the code for simplicity
- User model with JSON serialization
- Repository for API communication
- State management with AsyncNotifier
- UI with proper error and loading states

- `test/user_test.dart` - Repository and state management tests
- `test/widget_test.dart` - Widget tests

## Testing

The example includes tests for:

- Repository layer (API calls)
- State management (AsyncNotifier)
- Widget behavior
- Form validation
- Error handling

Run tests with:

```bash
flutter test
```
28 changes: 28 additions & 0 deletions examples/rest_api/analysis_options.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# This file configures the analyzer, which statically analyzes Dart code to
# check for errors, warnings, and lints.
#
# The issues identified by the analyzer are surfaced in the UI of Dart-enabled
# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be
# invoked from the command line by running `flutter analyze`.

# The following line activates a set of recommended lints for Flutter apps,
# packages, and plugins designed to encourage good coding practices.
include: package:flutter_lints/flutter.yaml

linter:
# The lint rules applied to this project can be customized in the
# section below to disable rules from the `package:flutter_lints/flutter.yaml`
# included above or to enable additional rules. A list of all available lints
# and their documentation is published at https://dart.dev/lints.
#
# Instead of disabling a lint rule for the entire project in the
# section below, it can also be suppressed for a single line of code
# or a specific dart file by using the `// ignore: name_of_lint` and
# `// ignore_for_file: name_of_lint` syntax on the line or in the file
# producing the lint.
rules:
# avoid_print: false # Uncomment to disable the `avoid_print` rule
# prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule

# Additional information about this file can be found at
# https://dart.dev/guides/language/analysis-options
238 changes: 238 additions & 0 deletions examples/rest_api/lib/main.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,238 @@
import 'package:dio/dio.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';

void main() {
runApp(ProviderScope(child: MyApp()));
}

class MyApp extends StatelessWidget {
const MyApp({super.key});

@override
Widget build(BuildContext context) {
return MaterialApp(
home: UserListScreen(),
);
}
}

// Model class representing a User with JSON serialization
class User {
final int id;
final String name;
final String email;

User({required this.id, required this.name, required this.email});

// Factory constructor for JSON deserialization
factory User.fromJson(Map<String, dynamic> json) => User(
id: json['id'],
name: json['name'],
email: json['email'],
);

// Method for JSON serialization
Map<String, dynamic> toJson() => {
'id': id,
'name': name,
'email': email,
};
}

// Repository class handling API communications
class UserRepository {
final Dio dio;

UserRepository(this.dio);

// Fetches all users from the API
Future<List<User>> fetchUsers() async {
try {
final response =
await dio.get('https://jsonplaceholder.typicode.com/users');
return (response.data as List)
.map((userData) => User.fromJson(userData))
.toList();
} catch (e) {
throw Exception('Failed to load users');
}
}

// Creates a new user via API
Future<User> createUser(User user) async {
try {
final response = await dio.post(
'https://jsonplaceholder.typicode.com/users',
data: user.toJson(),
);
return User.fromJson(response.data);
} catch (e) {
throw Exception('Failed to create user');
}
}
}
rrousselGit marked this conversation as resolved.
Show resolved Hide resolved

// Dependency injection setup using Riverpod providers
final dioProvider = Provider((ref) => Dio());

final userRepositoryProvider = Provider((ref) {
return UserRepository(ref.watch(dioProvider));
});

// Main state provider for the users list
final usersProvider = AsyncNotifierProvider<UsersNotifier, List<User>>(() {
return UsersNotifier();
});

// State management class for Users
class UsersNotifier extends AsyncNotifier<List<User>> {
@override
Future<List<User>> build() async {
final repository = ref.watch(userRepositoryProvider);
return repository.fetchUsers();
}

Future<void> addUser(User user) async {
final repository = ref.watch(userRepositoryProvider);
state = const AsyncValue.loading();
state = await AsyncValue.guard(() async {
final newUser = await repository.createUser(user);
// Get current users and add the new one
final currentUsers = state.value ?? [];
return [...currentUsers, newUser];
});
}
}

// Main screen showing the list of users
class UserListScreen extends ConsumerWidget {
const UserListScreen({super.key});

@override
Widget build(BuildContext context, WidgetRef ref) {
// Watch the users state for changes
final usersAsync = ref.watch(usersProvider);

return Scaffold(
appBar: AppBar(title: const Text('Users')),
floatingActionButton: FloatingActionButton(
onPressed: () => Navigator.push(
context,
MaterialPageRoute(builder: (context) => const AddUserScreen()),
),
child: const Icon(Icons.add),
),
// Using Dart 3's pattern matching with switch expression
// This handles all possible states of AsyncValue:
// - AsyncData: When we have the data
// - AsyncError: When an error occurred
// - _: Wildcard for loading and any other state
body: switch (usersAsync) {
AsyncData(:final value) => RefreshIndicator(
onRefresh: () => ref.refresh(usersProvider.future),
child: ListView.builder(
itemCount: value.length,
itemBuilder: (context, index) {
final user = value[index];
return ListTile(
title: Text(user.name),
subtitle: Text(user.email),
);
},
),
),
AsyncError(:final error) => Center(child: Text('Error: $error')),
_ => const Center(child: CircularProgressIndicator()),
},
);
}
}

// Screen for adding a new user
class AddUserScreen extends ConsumerStatefulWidget {
const AddUserScreen({super.key});

@override
AddUserScreenState createState() => AddUserScreenState();
}

class AddUserScreenState extends ConsumerState<AddUserScreen> {
// Form key for validation
final _formKey = GlobalKey<FormState>();
// Controllers for form fields
final _nameController = TextEditingController();
final _emailController = TextEditingController();

// Clean up controllers when the widget is disposed
@override
void dispose() {
_nameController.dispose();
_emailController.dispose();
super.dispose();
}

@override
Widget build(BuildContext context) {
// Function to pop the context to avoid using
//context across async gaps
void popContext() {
Navigator.pop(context);
}

return Scaffold(
appBar: AppBar(title: const Text('Add User')),
body: Padding(
padding: const EdgeInsets.all(16.0),
child: Form(
key: _formKey,
child: Column(
children: [
// Name input field with validation
TextFormField(
controller: _nameController,
decoration: const InputDecoration(labelText: 'Name'),
validator: (value) {
if (value == null || value.isEmpty) {
return 'Please enter a name';
}
return null;
},
),
// Email input field with validation
TextFormField(
controller: _emailController,
decoration: const InputDecoration(labelText: 'Email'),
validator: (value) {
if (value == null || value.isEmpty) {
return 'Please enter an email';
}
if (!value.contains('@')) {
return 'Please enter a valid email';
}
return null;
},
),
const SizedBox(height: 16),
ElevatedButton(
onPressed: () async {
if (_formKey.currentState!.validate()) {
final newUser = User(
id: 0, // API will assign the real ID
name: _nameController.text,
email: _emailController.text,
);
// Add user and return to previous screen
await ref.read(usersProvider.notifier).addUser(newUser);
popContext();
}
},
child: const Text('Add User'),
),
],
),
),
),
);
}
}
Comment on lines +153 to +238
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Improve form handling and user feedback.

The form could provide better feedback and validation.

Consider these improvements:

 class AddUserScreenState extends ConsumerState<AddUserScreen> {
   final _formKey = GlobalKey<FormState>();
   final _nameController = TextEditingController();
   final _emailController = TextEditingController();
+  bool _isSubmitting = false;

   @override
   void dispose() {
     _nameController.dispose();
     _emailController.dispose();
     super.dispose();
   }

   @override
   Widget build(BuildContext context) {
     void popContext() {
       Navigator.pop(context);
     }

+    String? validateEmail(String? value) {
+      if (value == null || value.isEmpty) {
+        return 'Please enter an email';
+      }
+      final emailRegex = RegExp(r'^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$');
+      if (!emailRegex.hasMatch(value)) {
+        return 'Please enter a valid email';
+      }
+      return null;
+    }

     return Scaffold(
       appBar: AppBar(title: const Text('Add User')),
       body: Padding(
         padding: const EdgeInsets.all(16.0),
         child: Form(
           key: _formKey,
           child: Column(
             children: [
               TextFormField(
                 controller: _nameController,
                 decoration: const InputDecoration(
                   labelText: 'Name',
+                  prefixIcon: Icon(Icons.person),
                 ),
                 validator: (value) {
                   if (value == null || value.isEmpty) {
                     return 'Please enter a name';
                   }
+                  if (value.length < 2) {
+                    return 'Name must be at least 2 characters';
+                  }
                   return null;
                 },
+                textInputAction: TextInputAction.next,
               ),
               TextFormField(
                 controller: _emailController,
                 decoration: const InputDecoration(
                   labelText: 'Email',
+                  prefixIcon: Icon(Icons.email),
                 ),
-                validator: (value) {
-                  if (value == null || value.isEmpty) {
-                    return 'Please enter an email';
-                  }
-                  if (!value.contains('@')) {
-                    return 'Please enter a valid email';
-                  }
-                  return null;
-                },
+                validator: validateEmail,
+                keyboardType: TextInputType.emailAddress,
+                textInputAction: TextInputAction.done,
               ),
               const SizedBox(height: 16),
-              ElevatedButton(
+              _isSubmitting
+                  ? const CircularProgressIndicator()
+                  : ElevatedButton(
                 onPressed: () async {
                   if (_formKey.currentState!.validate()) {
+                    setState(() => _isSubmitting = true);
                     final newUser = User(
                       id: 0,
                       name: _nameController.text,
                       email: _emailController.text,
                     );
-                    await ref.read(usersProvider.notifier).addUser(newUser);
-                    popContext();
+                    try {
+                      await ref.read(usersProvider.notifier).addUser(newUser);
+                      ScaffoldMessenger.of(context).showSnackBar(
+                        const SnackBar(
+                          content: Text('User added successfully'),
+                          backgroundColor: Colors.green,
+                        ),
+                      );
+                      popContext();
+                    } catch (e) {
+                      ScaffoldMessenger.of(context).showSnackBar(
+                        SnackBar(
+                          content: Text('Error: $e'),
+                          backgroundColor: Colors.red,
+                        ),
+                      );
+                    } finally {
+                      setState(() => _isSubmitting = false);
+                    }
                   }
                 },
                 child: const Text('Add User'),
               ),
             ],
           ),
         ),
       ),
     );
   }
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
class AddUserScreen extends ConsumerStatefulWidget {
const AddUserScreen({super.key});
@override
AddUserScreenState createState() => AddUserScreenState();
}
class AddUserScreenState extends ConsumerState<AddUserScreen> {
// Form key for validation
final _formKey = GlobalKey<FormState>();
// Controllers for form fields
final _nameController = TextEditingController();
final _emailController = TextEditingController();
// Clean up controllers when the widget is disposed
@override
void dispose() {
_nameController.dispose();
_emailController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
// Function to pop the context to avoid using
//context across async gaps
void popContext() {
Navigator.pop(context);
}
return Scaffold(
appBar: AppBar(title: const Text('Add User')),
body: Padding(
padding: const EdgeInsets.all(16.0),
child: Form(
key: _formKey,
child: Column(
children: [
// Name input field with validation
TextFormField(
controller: _nameController,
decoration: const InputDecoration(labelText: 'Name'),
validator: (value) {
if (value == null || value.isEmpty) {
return 'Please enter a name';
}
return null;
},
),
// Email input field with validation
TextFormField(
controller: _emailController,
decoration: const InputDecoration(labelText: 'Email'),
validator: (value) {
if (value == null || value.isEmpty) {
return 'Please enter an email';
}
if (!value.contains('@')) {
return 'Please enter a valid email';
}
return null;
},
),
const SizedBox(height: 16),
ElevatedButton(
onPressed: () async {
if (_formKey.currentState!.validate()) {
final newUser = User(
id: 0, // API will assign the real ID
name: _nameController.text,
email: _emailController.text,
);
// Add user and return to previous screen
await ref.read(usersProvider.notifier).addUser(newUser);
popContext();
}
},
child: const Text('Add User'),
),
],
),
),
),
);
}
}
class AddUserScreen extends ConsumerStatefulWidget {
const AddUserScreen({super.key});
@override
AddUserScreenState createState() => AddUserScreenState();
}
class AddUserScreenState extends ConsumerState<AddUserScreen> {
final _formKey = GlobalKey<FormState>();
final _nameController = TextEditingController();
final _emailController = TextEditingController();
bool _isSubmitting = false;
@override
void dispose() {
_nameController.dispose();
_emailController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
void popContext() {
Navigator.pop(context);
}
String? validateEmail(String? value) {
if (value == null || value.isEmpty) {
return 'Please enter an email';
}
final emailRegex = RegExp(r'^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$');
if (!emailRegex.hasMatch(value)) {
return 'Please enter a valid email';
}
return null;
}
return Scaffold(
appBar: AppBar(title: const Text('Add User')),
body: Padding(
padding: const EdgeInsets.all(16.0),
child: Form(
key: _formKey,
child: Column(
children: [
TextFormField(
controller: _nameController,
decoration: const InputDecoration(
labelText: 'Name',
prefixIcon: Icon(Icons.person),
),
validator: (value) {
if (value == null || value.isEmpty) {
return 'Please enter a name';
}
if (value.length < 2) {
return 'Name must be at least 2 characters';
}
return null;
},
textInputAction: TextInputAction.next,
),
TextFormField(
controller: _emailController,
decoration: const InputDecoration(
labelText: 'Email',
prefixIcon: Icon(Icons.email),
),
validator: validateEmail,
keyboardType: TextInputType.emailAddress,
textInputAction: TextInputAction.done,
),
const SizedBox(height: 16),
_isSubmitting
? const CircularProgressIndicator()
: ElevatedButton(
onPressed: () async {
if (_formKey.currentState!.validate()) {
setState(() => _isSubmitting = true);
final newUser = User(
id: 0,
name: _nameController.text,
email: _emailController.text,
);
try {
await ref.read(usersProvider.notifier).addUser(newUser);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('User added successfully'),
backgroundColor: Colors.green,
),
);
popContext();
} catch (e) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Error: $e'),
backgroundColor: Colors.red,
),
);
} finally {
setState(() => _isSubmitting = false);
}
}
},
child: const Text('Add User'),
),
],
),
),
),
);
}
}

27 changes: 27 additions & 0 deletions examples/rest_api/pubspec.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
name: rest_api
description: "A demo for handling rest api with riverpod"
publish_to: 'none'
version: 1.0.0+1

environment:
sdk: '>=3.0.0 <4.0.0'
dependencies:
flutter:
sdk: flutter
flutter_riverpod: ^2.6.1
dio: ^5.7.0

cupertino_icons: ^1.0.8

dev_dependencies:
flutter_test:
sdk: flutter

flutter_lints: ^5.0.0
mockito: ^5.4.4
build_runner: ^2.4.8

flutter:


uses-material-design: true
Loading
Loading