diff --git a/examples/rest_api/.metadata b/examples/rest_api/.metadata new file mode 100644 index 000000000..b02a7e4bf --- /dev/null +++ b/examples/rest_api/.metadata @@ -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' diff --git a/examples/rest_api/README.md b/examples/rest_api/README.md new file mode 100644 index 000000000..d7127966b --- /dev/null +++ b/examples/rest_api/README.md @@ -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 +``` diff --git a/examples/rest_api/analysis_options.yaml b/examples/rest_api/analysis_options.yaml new file mode 100644 index 000000000..0d2902135 --- /dev/null +++ b/examples/rest_api/analysis_options.yaml @@ -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 diff --git a/examples/rest_api/lib/main.dart b/examples/rest_api/lib/main.dart new file mode 100644 index 000000000..8d99b334e --- /dev/null +++ b/examples/rest_api/lib/main.dart @@ -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 json) => User( + id: json['id'], + name: json['name'], + email: json['email'], + ); + + // Method for JSON serialization + Map 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> 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 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'); + } + } +} + +// 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>(() { + return UsersNotifier(); +}); + +// State management class for Users +class UsersNotifier extends AsyncNotifier> { + @override + Future> build() async { + final repository = ref.watch(userRepositoryProvider); + return repository.fetchUsers(); + } + + Future 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 { + // Form key for validation + final _formKey = GlobalKey(); + // 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'), + ), + ], + ), + ), + ), + ); + } +} diff --git a/examples/rest_api/pubspec.yaml b/examples/rest_api/pubspec.yaml new file mode 100644 index 000000000..3cabd8988 --- /dev/null +++ b/examples/rest_api/pubspec.yaml @@ -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.8.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 diff --git a/examples/rest_api/test/user_test.dart b/examples/rest_api/test/user_test.dart new file mode 100644 index 000000000..a87115b1d --- /dev/null +++ b/examples/rest_api/test/user_test.dart @@ -0,0 +1,153 @@ +import 'package:dio/dio.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/annotations.dart'; +import 'package:mockito/mockito.dart'; + +import 'package:rest_api/main.dart'; + +// Generate mock classes +@GenerateNiceMocks([MockSpec()]) +import 'user_test.mocks.dart'; + +void main() { + group('UserRepository Tests', () { + late MockDio mockDio; + late UserRepository repository; + + setUp(() { + mockDio = MockDio(); + repository = UserRepository(mockDio); + }); + + test('fetchUsers returns list of users', () async { + // Arrange + when(mockDio.get('https://jsonplaceholder.typicode.com/users')) + .thenAnswer((_) async => Response( + data: [ + { + 'id': 1, + 'name': 'Test User', + 'email': 'test@example.com', + } + ], + statusCode: 200, + requestOptions: RequestOptions(path: ''), + )); + + // Act + final users = await repository.fetchUsers(); + + // Assert + expect(users.length, 1); + expect(users.first.name, 'Test User'); + expect(users.first.email, 'test@example.com'); + }); + + test('createUser returns created user', () async { + // Arrange + final newUser = User(id: 0, name: 'New User', email: 'new@example.com'); + when(mockDio.post( + 'https://jsonplaceholder.typicode.com/users', + data: newUser.toJson(), + )).thenAnswer((_) async => Response( + data: { + 'id': 1, + 'name': 'New User', + 'email': 'new@example.com', + }, + statusCode: 201, + requestOptions: RequestOptions(path: ''), + )); + + // Act + final createdUser = await repository.createUser(newUser); + + // Assert + expect(createdUser.id, 1); + expect(createdUser.name, 'New User'); + expect(createdUser.email, 'new@example.com'); + }); + }); + + group('UsersNotifier Tests', () { + late ProviderContainer container; + late MockDio mockDio; + + setUp(() { + mockDio = MockDio(); + container = ProviderContainer( + overrides: [ + dioProvider.overrideWithValue(mockDio), + ], + ); + }); + + tearDown(() { + container.dispose(); + }); + + test('initial state loads users', () async { + // Arrange + when(mockDio.get('https://jsonplaceholder.typicode.com/users')) + .thenAnswer((_) async => Response( + data: [ + { + 'id': 1, + 'name': 'Test User', + 'email': 'test@example.com', + } + ], + statusCode: 200, + requestOptions: RequestOptions(path: ''), + )); + + // Act & Assert + expect( + container.read(usersProvider), + const AsyncValue>.loading(), + ); + + await container.read(usersProvider.future); + + final users = container.read(usersProvider).value; + expect(users?.length, 1); + expect(users?.first.name, 'Test User'); + }); + + test('addUser adds user to state', () async { + // Arrange + when(mockDio.get('https://jsonplaceholder.typicode.com/users')) + .thenAnswer((_) async => Response( + data: [], + statusCode: 200, + requestOptions: RequestOptions(path: ''), + )); + + final newUser = User(id: 0, name: 'New User', email: 'new@example.com'); + when(mockDio.post( + 'https://jsonplaceholder.typicode.com/users', + data: newUser.toJson(), + )).thenAnswer((_) async => Response( + data: { + 'id': 1, + 'name': 'New User', + 'email': 'new@example.com', + }, + statusCode: 201, + requestOptions: RequestOptions(path: ''), + )); + + // Wait for initial load + await container.read(usersProvider.future); + + // Act + await container.read(usersProvider.notifier).addUser(newUser); + + // Assert + final users = container.read(usersProvider).value; + expect(users?.length, 1); + expect(users?.first.name, 'New User'); + }); + }); +} diff --git a/examples/rest_api/test/user_test.mocks.dart b/examples/rest_api/test/user_test.mocks.dart new file mode 100644 index 000000000..046e3030b --- /dev/null +++ b/examples/rest_api/test/user_test.mocks.dart @@ -0,0 +1,1083 @@ +// Mocks generated by Mockito 5.4.5 from annotations +// in rest_api/test/user_test.dart. +// Do not manually edit this file. + +// ignore_for_file: no_leading_underscores_for_library_prefixes +import 'dart:async' as _i8; + +import 'package:dio/src/adapter.dart' as _i3; +import 'package:dio/src/cancel_token.dart' as _i9; +import 'package:dio/src/dio.dart' as _i7; +import 'package:dio/src/dio_mixin.dart' as _i5; +import 'package:dio/src/options.dart' as _i2; +import 'package:dio/src/response.dart' as _i6; +import 'package:dio/src/transformer.dart' as _i4; +import 'package:mockito/mockito.dart' as _i1; + +// ignore_for_file: type=lint +// ignore_for_file: avoid_redundant_argument_values +// ignore_for_file: avoid_setters_without_getters +// ignore_for_file: comment_references +// ignore_for_file: deprecated_member_use +// ignore_for_file: deprecated_member_use_from_same_package +// ignore_for_file: implementation_imports +// ignore_for_file: invalid_use_of_visible_for_testing_member +// ignore_for_file: must_be_immutable +// ignore_for_file: prefer_const_constructors +// ignore_for_file: unnecessary_parenthesis +// ignore_for_file: camel_case_types +// ignore_for_file: subtype_of_sealed_class + +class _FakeBaseOptions_0 extends _i1.SmartFake implements _i2.BaseOptions { + _FakeBaseOptions_0(Object parent, Invocation parentInvocation) + : super(parent, parentInvocation); +} + +class _FakeHttpClientAdapter_1 extends _i1.SmartFake + implements _i3.HttpClientAdapter { + _FakeHttpClientAdapter_1(Object parent, Invocation parentInvocation) + : super(parent, parentInvocation); +} + +class _FakeTransformer_2 extends _i1.SmartFake implements _i4.Transformer { + _FakeTransformer_2(Object parent, Invocation parentInvocation) + : super(parent, parentInvocation); +} + +class _FakeInterceptors_3 extends _i1.SmartFake implements _i5.Interceptors { + _FakeInterceptors_3(Object parent, Invocation parentInvocation) + : super(parent, parentInvocation); +} + +class _FakeResponse_4 extends _i1.SmartFake implements _i6.Response { + _FakeResponse_4(Object parent, Invocation parentInvocation) + : super(parent, parentInvocation); +} + +class _FakeDio_5 extends _i1.SmartFake implements _i7.Dio { + _FakeDio_5(Object parent, Invocation parentInvocation) + : super(parent, parentInvocation); +} + +/// A class which mocks [Dio]. +/// +/// See the documentation for Mockito's code generation for more information. +class MockDio extends _i1.Mock implements _i7.Dio { + @override + _i2.BaseOptions get options => + (super.noSuchMethod( + Invocation.getter(#options), + returnValue: _FakeBaseOptions_0(this, Invocation.getter(#options)), + returnValueForMissingStub: _FakeBaseOptions_0( + this, + Invocation.getter(#options), + ), + ) + as _i2.BaseOptions); + + @override + set options(_i2.BaseOptions? _options) => super.noSuchMethod( + Invocation.setter(#options, _options), + returnValueForMissingStub: null, + ); + + @override + _i3.HttpClientAdapter get httpClientAdapter => + (super.noSuchMethod( + Invocation.getter(#httpClientAdapter), + returnValue: _FakeHttpClientAdapter_1( + this, + Invocation.getter(#httpClientAdapter), + ), + returnValueForMissingStub: _FakeHttpClientAdapter_1( + this, + Invocation.getter(#httpClientAdapter), + ), + ) + as _i3.HttpClientAdapter); + + @override + set httpClientAdapter(_i3.HttpClientAdapter? _httpClientAdapter) => + super.noSuchMethod( + Invocation.setter(#httpClientAdapter, _httpClientAdapter), + returnValueForMissingStub: null, + ); + + @override + _i4.Transformer get transformer => + (super.noSuchMethod( + Invocation.getter(#transformer), + returnValue: _FakeTransformer_2( + this, + Invocation.getter(#transformer), + ), + returnValueForMissingStub: _FakeTransformer_2( + this, + Invocation.getter(#transformer), + ), + ) + as _i4.Transformer); + + @override + set transformer(_i4.Transformer? _transformer) => super.noSuchMethod( + Invocation.setter(#transformer, _transformer), + returnValueForMissingStub: null, + ); + + @override + _i5.Interceptors get interceptors => + (super.noSuchMethod( + Invocation.getter(#interceptors), + returnValue: _FakeInterceptors_3( + this, + Invocation.getter(#interceptors), + ), + returnValueForMissingStub: _FakeInterceptors_3( + this, + Invocation.getter(#interceptors), + ), + ) + as _i5.Interceptors); + + @override + void close({bool? force = false}) => super.noSuchMethod( + Invocation.method(#close, [], {#force: force}), + returnValueForMissingStub: null, + ); + + @override + _i8.Future<_i6.Response> head( + String? path, { + Object? data, + Map? queryParameters, + _i2.Options? options, + _i9.CancelToken? cancelToken, + }) => + (super.noSuchMethod( + Invocation.method( + #head, + [path], + { + #data: data, + #queryParameters: queryParameters, + #options: options, + #cancelToken: cancelToken, + }, + ), + returnValue: _i8.Future<_i6.Response>.value( + _FakeResponse_4( + this, + Invocation.method( + #head, + [path], + { + #data: data, + #queryParameters: queryParameters, + #options: options, + #cancelToken: cancelToken, + }, + ), + ), + ), + returnValueForMissingStub: _i8.Future<_i6.Response>.value( + _FakeResponse_4( + this, + Invocation.method( + #head, + [path], + { + #data: data, + #queryParameters: queryParameters, + #options: options, + #cancelToken: cancelToken, + }, + ), + ), + ), + ) + as _i8.Future<_i6.Response>); + + @override + _i8.Future<_i6.Response> headUri( + Uri? uri, { + Object? data, + _i2.Options? options, + _i9.CancelToken? cancelToken, + }) => + (super.noSuchMethod( + Invocation.method( + #headUri, + [uri], + {#data: data, #options: options, #cancelToken: cancelToken}, + ), + returnValue: _i8.Future<_i6.Response>.value( + _FakeResponse_4( + this, + Invocation.method( + #headUri, + [uri], + {#data: data, #options: options, #cancelToken: cancelToken}, + ), + ), + ), + returnValueForMissingStub: _i8.Future<_i6.Response>.value( + _FakeResponse_4( + this, + Invocation.method( + #headUri, + [uri], + {#data: data, #options: options, #cancelToken: cancelToken}, + ), + ), + ), + ) + as _i8.Future<_i6.Response>); + + @override + _i8.Future<_i6.Response> get( + String? path, { + Object? data, + Map? queryParameters, + _i2.Options? options, + _i9.CancelToken? cancelToken, + _i2.ProgressCallback? onReceiveProgress, + }) => + (super.noSuchMethod( + Invocation.method( + #get, + [path], + { + #data: data, + #queryParameters: queryParameters, + #options: options, + #cancelToken: cancelToken, + #onReceiveProgress: onReceiveProgress, + }, + ), + returnValue: _i8.Future<_i6.Response>.value( + _FakeResponse_4( + this, + Invocation.method( + #get, + [path], + { + #data: data, + #queryParameters: queryParameters, + #options: options, + #cancelToken: cancelToken, + #onReceiveProgress: onReceiveProgress, + }, + ), + ), + ), + returnValueForMissingStub: _i8.Future<_i6.Response>.value( + _FakeResponse_4( + this, + Invocation.method( + #get, + [path], + { + #data: data, + #queryParameters: queryParameters, + #options: options, + #cancelToken: cancelToken, + #onReceiveProgress: onReceiveProgress, + }, + ), + ), + ), + ) + as _i8.Future<_i6.Response>); + + @override + _i8.Future<_i6.Response> getUri( + Uri? uri, { + Object? data, + _i2.Options? options, + _i9.CancelToken? cancelToken, + _i2.ProgressCallback? onReceiveProgress, + }) => + (super.noSuchMethod( + Invocation.method( + #getUri, + [uri], + { + #data: data, + #options: options, + #cancelToken: cancelToken, + #onReceiveProgress: onReceiveProgress, + }, + ), + returnValue: _i8.Future<_i6.Response>.value( + _FakeResponse_4( + this, + Invocation.method( + #getUri, + [uri], + { + #data: data, + #options: options, + #cancelToken: cancelToken, + #onReceiveProgress: onReceiveProgress, + }, + ), + ), + ), + returnValueForMissingStub: _i8.Future<_i6.Response>.value( + _FakeResponse_4( + this, + Invocation.method( + #getUri, + [uri], + { + #data: data, + #options: options, + #cancelToken: cancelToken, + #onReceiveProgress: onReceiveProgress, + }, + ), + ), + ), + ) + as _i8.Future<_i6.Response>); + + @override + _i8.Future<_i6.Response> post( + String? path, { + Object? data, + Map? queryParameters, + _i2.Options? options, + _i9.CancelToken? cancelToken, + _i2.ProgressCallback? onSendProgress, + _i2.ProgressCallback? onReceiveProgress, + }) => + (super.noSuchMethod( + Invocation.method( + #post, + [path], + { + #data: data, + #queryParameters: queryParameters, + #options: options, + #cancelToken: cancelToken, + #onSendProgress: onSendProgress, + #onReceiveProgress: onReceiveProgress, + }, + ), + returnValue: _i8.Future<_i6.Response>.value( + _FakeResponse_4( + this, + Invocation.method( + #post, + [path], + { + #data: data, + #queryParameters: queryParameters, + #options: options, + #cancelToken: cancelToken, + #onSendProgress: onSendProgress, + #onReceiveProgress: onReceiveProgress, + }, + ), + ), + ), + returnValueForMissingStub: _i8.Future<_i6.Response>.value( + _FakeResponse_4( + this, + Invocation.method( + #post, + [path], + { + #data: data, + #queryParameters: queryParameters, + #options: options, + #cancelToken: cancelToken, + #onSendProgress: onSendProgress, + #onReceiveProgress: onReceiveProgress, + }, + ), + ), + ), + ) + as _i8.Future<_i6.Response>); + + @override + _i8.Future<_i6.Response> postUri( + Uri? uri, { + Object? data, + _i2.Options? options, + _i9.CancelToken? cancelToken, + _i2.ProgressCallback? onSendProgress, + _i2.ProgressCallback? onReceiveProgress, + }) => + (super.noSuchMethod( + Invocation.method( + #postUri, + [uri], + { + #data: data, + #options: options, + #cancelToken: cancelToken, + #onSendProgress: onSendProgress, + #onReceiveProgress: onReceiveProgress, + }, + ), + returnValue: _i8.Future<_i6.Response>.value( + _FakeResponse_4( + this, + Invocation.method( + #postUri, + [uri], + { + #data: data, + #options: options, + #cancelToken: cancelToken, + #onSendProgress: onSendProgress, + #onReceiveProgress: onReceiveProgress, + }, + ), + ), + ), + returnValueForMissingStub: _i8.Future<_i6.Response>.value( + _FakeResponse_4( + this, + Invocation.method( + #postUri, + [uri], + { + #data: data, + #options: options, + #cancelToken: cancelToken, + #onSendProgress: onSendProgress, + #onReceiveProgress: onReceiveProgress, + }, + ), + ), + ), + ) + as _i8.Future<_i6.Response>); + + @override + _i8.Future<_i6.Response> put( + String? path, { + Object? data, + Map? queryParameters, + _i2.Options? options, + _i9.CancelToken? cancelToken, + _i2.ProgressCallback? onSendProgress, + _i2.ProgressCallback? onReceiveProgress, + }) => + (super.noSuchMethod( + Invocation.method( + #put, + [path], + { + #data: data, + #queryParameters: queryParameters, + #options: options, + #cancelToken: cancelToken, + #onSendProgress: onSendProgress, + #onReceiveProgress: onReceiveProgress, + }, + ), + returnValue: _i8.Future<_i6.Response>.value( + _FakeResponse_4( + this, + Invocation.method( + #put, + [path], + { + #data: data, + #queryParameters: queryParameters, + #options: options, + #cancelToken: cancelToken, + #onSendProgress: onSendProgress, + #onReceiveProgress: onReceiveProgress, + }, + ), + ), + ), + returnValueForMissingStub: _i8.Future<_i6.Response>.value( + _FakeResponse_4( + this, + Invocation.method( + #put, + [path], + { + #data: data, + #queryParameters: queryParameters, + #options: options, + #cancelToken: cancelToken, + #onSendProgress: onSendProgress, + #onReceiveProgress: onReceiveProgress, + }, + ), + ), + ), + ) + as _i8.Future<_i6.Response>); + + @override + _i8.Future<_i6.Response> putUri( + Uri? uri, { + Object? data, + _i2.Options? options, + _i9.CancelToken? cancelToken, + _i2.ProgressCallback? onSendProgress, + _i2.ProgressCallback? onReceiveProgress, + }) => + (super.noSuchMethod( + Invocation.method( + #putUri, + [uri], + { + #data: data, + #options: options, + #cancelToken: cancelToken, + #onSendProgress: onSendProgress, + #onReceiveProgress: onReceiveProgress, + }, + ), + returnValue: _i8.Future<_i6.Response>.value( + _FakeResponse_4( + this, + Invocation.method( + #putUri, + [uri], + { + #data: data, + #options: options, + #cancelToken: cancelToken, + #onSendProgress: onSendProgress, + #onReceiveProgress: onReceiveProgress, + }, + ), + ), + ), + returnValueForMissingStub: _i8.Future<_i6.Response>.value( + _FakeResponse_4( + this, + Invocation.method( + #putUri, + [uri], + { + #data: data, + #options: options, + #cancelToken: cancelToken, + #onSendProgress: onSendProgress, + #onReceiveProgress: onReceiveProgress, + }, + ), + ), + ), + ) + as _i8.Future<_i6.Response>); + + @override + _i8.Future<_i6.Response> patch( + String? path, { + Object? data, + Map? queryParameters, + _i2.Options? options, + _i9.CancelToken? cancelToken, + _i2.ProgressCallback? onSendProgress, + _i2.ProgressCallback? onReceiveProgress, + }) => + (super.noSuchMethod( + Invocation.method( + #patch, + [path], + { + #data: data, + #queryParameters: queryParameters, + #options: options, + #cancelToken: cancelToken, + #onSendProgress: onSendProgress, + #onReceiveProgress: onReceiveProgress, + }, + ), + returnValue: _i8.Future<_i6.Response>.value( + _FakeResponse_4( + this, + Invocation.method( + #patch, + [path], + { + #data: data, + #queryParameters: queryParameters, + #options: options, + #cancelToken: cancelToken, + #onSendProgress: onSendProgress, + #onReceiveProgress: onReceiveProgress, + }, + ), + ), + ), + returnValueForMissingStub: _i8.Future<_i6.Response>.value( + _FakeResponse_4( + this, + Invocation.method( + #patch, + [path], + { + #data: data, + #queryParameters: queryParameters, + #options: options, + #cancelToken: cancelToken, + #onSendProgress: onSendProgress, + #onReceiveProgress: onReceiveProgress, + }, + ), + ), + ), + ) + as _i8.Future<_i6.Response>); + + @override + _i8.Future<_i6.Response> patchUri( + Uri? uri, { + Object? data, + _i2.Options? options, + _i9.CancelToken? cancelToken, + _i2.ProgressCallback? onSendProgress, + _i2.ProgressCallback? onReceiveProgress, + }) => + (super.noSuchMethod( + Invocation.method( + #patchUri, + [uri], + { + #data: data, + #options: options, + #cancelToken: cancelToken, + #onSendProgress: onSendProgress, + #onReceiveProgress: onReceiveProgress, + }, + ), + returnValue: _i8.Future<_i6.Response>.value( + _FakeResponse_4( + this, + Invocation.method( + #patchUri, + [uri], + { + #data: data, + #options: options, + #cancelToken: cancelToken, + #onSendProgress: onSendProgress, + #onReceiveProgress: onReceiveProgress, + }, + ), + ), + ), + returnValueForMissingStub: _i8.Future<_i6.Response>.value( + _FakeResponse_4( + this, + Invocation.method( + #patchUri, + [uri], + { + #data: data, + #options: options, + #cancelToken: cancelToken, + #onSendProgress: onSendProgress, + #onReceiveProgress: onReceiveProgress, + }, + ), + ), + ), + ) + as _i8.Future<_i6.Response>); + + @override + _i8.Future<_i6.Response> delete( + String? path, { + Object? data, + Map? queryParameters, + _i2.Options? options, + _i9.CancelToken? cancelToken, + }) => + (super.noSuchMethod( + Invocation.method( + #delete, + [path], + { + #data: data, + #queryParameters: queryParameters, + #options: options, + #cancelToken: cancelToken, + }, + ), + returnValue: _i8.Future<_i6.Response>.value( + _FakeResponse_4( + this, + Invocation.method( + #delete, + [path], + { + #data: data, + #queryParameters: queryParameters, + #options: options, + #cancelToken: cancelToken, + }, + ), + ), + ), + returnValueForMissingStub: _i8.Future<_i6.Response>.value( + _FakeResponse_4( + this, + Invocation.method( + #delete, + [path], + { + #data: data, + #queryParameters: queryParameters, + #options: options, + #cancelToken: cancelToken, + }, + ), + ), + ), + ) + as _i8.Future<_i6.Response>); + + @override + _i8.Future<_i6.Response> deleteUri( + Uri? uri, { + Object? data, + _i2.Options? options, + _i9.CancelToken? cancelToken, + }) => + (super.noSuchMethod( + Invocation.method( + #deleteUri, + [uri], + {#data: data, #options: options, #cancelToken: cancelToken}, + ), + returnValue: _i8.Future<_i6.Response>.value( + _FakeResponse_4( + this, + Invocation.method( + #deleteUri, + [uri], + {#data: data, #options: options, #cancelToken: cancelToken}, + ), + ), + ), + returnValueForMissingStub: _i8.Future<_i6.Response>.value( + _FakeResponse_4( + this, + Invocation.method( + #deleteUri, + [uri], + {#data: data, #options: options, #cancelToken: cancelToken}, + ), + ), + ), + ) + as _i8.Future<_i6.Response>); + + @override + _i8.Future<_i6.Response> download( + String? urlPath, + dynamic savePath, { + _i2.ProgressCallback? onReceiveProgress, + Map? queryParameters, + _i9.CancelToken? cancelToken, + bool? deleteOnError = true, + _i2.FileAccessMode? fileAccessMode = _i2.FileAccessMode.write, + String? lengthHeader = 'content-length', + Object? data, + _i2.Options? options, + }) => + (super.noSuchMethod( + Invocation.method( + #download, + [urlPath, savePath], + { + #onReceiveProgress: onReceiveProgress, + #queryParameters: queryParameters, + #cancelToken: cancelToken, + #deleteOnError: deleteOnError, + #fileAccessMode: fileAccessMode, + #lengthHeader: lengthHeader, + #data: data, + #options: options, + }, + ), + returnValue: _i8.Future<_i6.Response>.value( + _FakeResponse_4( + this, + Invocation.method( + #download, + [urlPath, savePath], + { + #onReceiveProgress: onReceiveProgress, + #queryParameters: queryParameters, + #cancelToken: cancelToken, + #deleteOnError: deleteOnError, + #fileAccessMode: fileAccessMode, + #lengthHeader: lengthHeader, + #data: data, + #options: options, + }, + ), + ), + ), + returnValueForMissingStub: _i8.Future<_i6.Response>.value( + _FakeResponse_4( + this, + Invocation.method( + #download, + [urlPath, savePath], + { + #onReceiveProgress: onReceiveProgress, + #queryParameters: queryParameters, + #cancelToken: cancelToken, + #deleteOnError: deleteOnError, + #fileAccessMode: fileAccessMode, + #lengthHeader: lengthHeader, + #data: data, + #options: options, + }, + ), + ), + ), + ) + as _i8.Future<_i6.Response>); + + @override + _i8.Future<_i6.Response> downloadUri( + Uri? uri, + dynamic savePath, { + _i2.ProgressCallback? onReceiveProgress, + _i9.CancelToken? cancelToken, + bool? deleteOnError = true, + _i2.FileAccessMode? fileAccessMode = _i2.FileAccessMode.write, + String? lengthHeader = 'content-length', + Object? data, + _i2.Options? options, + }) => + (super.noSuchMethod( + Invocation.method( + #downloadUri, + [uri, savePath], + { + #onReceiveProgress: onReceiveProgress, + #cancelToken: cancelToken, + #deleteOnError: deleteOnError, + #fileAccessMode: fileAccessMode, + #lengthHeader: lengthHeader, + #data: data, + #options: options, + }, + ), + returnValue: _i8.Future<_i6.Response>.value( + _FakeResponse_4( + this, + Invocation.method( + #downloadUri, + [uri, savePath], + { + #onReceiveProgress: onReceiveProgress, + #cancelToken: cancelToken, + #deleteOnError: deleteOnError, + #fileAccessMode: fileAccessMode, + #lengthHeader: lengthHeader, + #data: data, + #options: options, + }, + ), + ), + ), + returnValueForMissingStub: _i8.Future<_i6.Response>.value( + _FakeResponse_4( + this, + Invocation.method( + #downloadUri, + [uri, savePath], + { + #onReceiveProgress: onReceiveProgress, + #cancelToken: cancelToken, + #deleteOnError: deleteOnError, + #fileAccessMode: fileAccessMode, + #lengthHeader: lengthHeader, + #data: data, + #options: options, + }, + ), + ), + ), + ) + as _i8.Future<_i6.Response>); + + @override + _i8.Future<_i6.Response> request( + String? url, { + Object? data, + Map? queryParameters, + _i9.CancelToken? cancelToken, + _i2.Options? options, + _i2.ProgressCallback? onSendProgress, + _i2.ProgressCallback? onReceiveProgress, + }) => + (super.noSuchMethod( + Invocation.method( + #request, + [url], + { + #data: data, + #queryParameters: queryParameters, + #cancelToken: cancelToken, + #options: options, + #onSendProgress: onSendProgress, + #onReceiveProgress: onReceiveProgress, + }, + ), + returnValue: _i8.Future<_i6.Response>.value( + _FakeResponse_4( + this, + Invocation.method( + #request, + [url], + { + #data: data, + #queryParameters: queryParameters, + #cancelToken: cancelToken, + #options: options, + #onSendProgress: onSendProgress, + #onReceiveProgress: onReceiveProgress, + }, + ), + ), + ), + returnValueForMissingStub: _i8.Future<_i6.Response>.value( + _FakeResponse_4( + this, + Invocation.method( + #request, + [url], + { + #data: data, + #queryParameters: queryParameters, + #cancelToken: cancelToken, + #options: options, + #onSendProgress: onSendProgress, + #onReceiveProgress: onReceiveProgress, + }, + ), + ), + ), + ) + as _i8.Future<_i6.Response>); + + @override + _i8.Future<_i6.Response> requestUri( + Uri? uri, { + Object? data, + _i9.CancelToken? cancelToken, + _i2.Options? options, + _i2.ProgressCallback? onSendProgress, + _i2.ProgressCallback? onReceiveProgress, + }) => + (super.noSuchMethod( + Invocation.method( + #requestUri, + [uri], + { + #data: data, + #cancelToken: cancelToken, + #options: options, + #onSendProgress: onSendProgress, + #onReceiveProgress: onReceiveProgress, + }, + ), + returnValue: _i8.Future<_i6.Response>.value( + _FakeResponse_4( + this, + Invocation.method( + #requestUri, + [uri], + { + #data: data, + #cancelToken: cancelToken, + #options: options, + #onSendProgress: onSendProgress, + #onReceiveProgress: onReceiveProgress, + }, + ), + ), + ), + returnValueForMissingStub: _i8.Future<_i6.Response>.value( + _FakeResponse_4( + this, + Invocation.method( + #requestUri, + [uri], + { + #data: data, + #cancelToken: cancelToken, + #options: options, + #onSendProgress: onSendProgress, + #onReceiveProgress: onReceiveProgress, + }, + ), + ), + ), + ) + as _i8.Future<_i6.Response>); + + @override + _i8.Future<_i6.Response> fetch(_i2.RequestOptions? requestOptions) => + (super.noSuchMethod( + Invocation.method(#fetch, [requestOptions]), + returnValue: _i8.Future<_i6.Response>.value( + _FakeResponse_4( + this, + Invocation.method(#fetch, [requestOptions]), + ), + ), + returnValueForMissingStub: _i8.Future<_i6.Response>.value( + _FakeResponse_4( + this, + Invocation.method(#fetch, [requestOptions]), + ), + ), + ) + as _i8.Future<_i6.Response>); + + @override + _i7.Dio clone({ + _i2.BaseOptions? options, + _i5.Interceptors? interceptors, + _i3.HttpClientAdapter? httpClientAdapter, + _i4.Transformer? transformer, + }) => + (super.noSuchMethod( + Invocation.method(#clone, [], { + #options: options, + #interceptors: interceptors, + #httpClientAdapter: httpClientAdapter, + #transformer: transformer, + }), + returnValue: _FakeDio_5( + this, + Invocation.method(#clone, [], { + #options: options, + #interceptors: interceptors, + #httpClientAdapter: httpClientAdapter, + #transformer: transformer, + }), + ), + returnValueForMissingStub: _FakeDio_5( + this, + Invocation.method(#clone, [], { + #options: options, + #interceptors: interceptors, + #httpClientAdapter: httpClientAdapter, + #transformer: transformer, + }), + ), + ) + as _i7.Dio); +} diff --git a/examples/rest_api/test/widget_test.dart b/examples/rest_api/test/widget_test.dart new file mode 100644 index 000000000..66c69634e --- /dev/null +++ b/examples/rest_api/test/widget_test.dart @@ -0,0 +1,108 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:dio/dio.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:mockito/mockito.dart'; +import 'package:rest_api/main.dart'; +import 'user_test.mocks.dart'; + +void main() { + group('Widget Tests', () { + testWidgets('UserListScreen shows loading and then data', + (WidgetTester tester) async { + // Arrange + final mockDio = MockDio(); + when(mockDio.get('https://jsonplaceholder.typicode.com/users')) + .thenAnswer((_) async { + // Add delay to simulate network request + await Future.delayed(const Duration(milliseconds: 100)); + return Response( + data: [ + {'id': 1, 'name': 'Test User', 'email': 'test@example.com'} + ], + statusCode: 200, + requestOptions: RequestOptions(path: ''), + ); + }); + + // Act + await tester.pumpWidget( + ProviderScope( + overrides: [dioProvider.overrideWithValue(mockDio)], + child: const MaterialApp(home: UserListScreen()), + ), + ); + + // Assert - should show loading first + expect(find.byType(CircularProgressIndicator), findsOneWidget); + + // Wait for async operations and rebuild + await tester.pumpAndSettle(); + + // Assert - should show user data + expect(find.text('Test User'), findsOneWidget); + expect(find.text('test@example.com'), findsOneWidget); + }); + + testWidgets('Shows error message on network failure', + (WidgetTester tester) async { + // Arrange + final mockDio = MockDio(); + when(mockDio.get('https://jsonplaceholder.typicode.com/users')) + .thenThrow(DioException( + requestOptions: RequestOptions(path: ''), + error: 'No Internet', + )); + + // Act + await tester.pumpWidget( + ProviderScope( + overrides: [dioProvider.overrideWithValue(mockDio)], + child: const MaterialApp(home: UserListScreen()), + ), + ); + + // Wait for async operations and rebuild + await tester.pumpAndSettle(); + + // Assert + expect( + find.text('Error: Exception: Failed to load users'), findsOneWidget); + }); + + testWidgets('AddUserScreen can create new user', + (WidgetTester tester) async { + // Arrange + final mockDio = MockDio(); + when(mockDio.post( + 'https://jsonplaceholder.typicode.com/users', + data: anyNamed('data'), + )).thenAnswer((_) async => Response( + data: {'id': 1, 'name': 'New User', 'email': 'test@example.com'}, + statusCode: 201, + requestOptions: RequestOptions(path: ''), + )); + + // Act + await tester.pumpWidget( + ProviderScope( + overrides: [dioProvider.overrideWithValue(mockDio)], + child: const MaterialApp(home: AddUserScreen()), + ), + ); + + // Fill form + await tester.enterText(find.byType(TextFormField).first, 'New User'); + await tester.enterText( + find.byType(TextFormField).last, 'test@example.com'); + await tester.tap(find.byType(ElevatedButton)); + await tester.pumpAndSettle(); + + // Verify + verify(mockDio.post( + 'https://jsonplaceholder.typicode.com/users', + data: anyNamed('data'), + )).called(1); + }); + }); +}