diff --git a/README.md b/README.md index 3081e7d6..f0eb454a 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ All the templates can be used to kick off a new Flutter project quickly. - Supports __Android__ and __iOS__ platforms *(Web and Desktop are not yet supported)*. - [__Clean Architecture__](https://blog.cleancoder.com/uncle-bob/2012/08/13/the-clean-architecture.html) with `MVVM` and pre-built foundational components. - [Pre-set environments](bricks/template/__brick__/%7B%7Bproject_name.snakeCase()%7D%7D#setup): `Staging` and `Production`. Environment variables are supplied through `.env` files through [flutter_config](https://pub.dev/packages/flutter_config). -- Dependency Injection (DI), State Management, and Navigating with [get_it](https://pub.dev/packages/get_it) and [go_router](https://pub.dev/packages/go_router). +- Dependency Injection (DI), State Management, and Navigating with [get_it](https://pub.dev/packages/get_it), [flutter_riverpod](https://pub.dev/packages/flutter_riverpod), and [go_router](https://pub.dev/packages/go_router). - Networking with [dio](https://pub.dev/packages/dio) and [retrofit](https://pub.dev/packages/retrofit), JSON serializing with [json_serializable](https://pub.dev/packages/json_serializable). - [Localization](https://docs.flutter.dev/accessibility-and-localization/internationalization) integrated in [3 initial languages](bricks/template/__brick__/%7B%7Bproject_name.snakeCase()%7D%7D/lib/l10n). - [Testing](https://docs.flutter.dev/testing)-ready (unit, integration, and widget testing), [production and deployment](https://docs.flutter.dev/deployment)-ready (to Firebase, Play Store, TestFlight, and AppStore). diff --git a/bricks/template/__brick__/{{project_name.snakeCase()}}/lib/api/api_service.dart b/bricks/template/__brick__/{{project_name.snakeCase()}}/lib/api/api_service.dart index 0e3fdec0..60bc5696 100644 --- a/bricks/template/__brick__/{{project_name.snakeCase()}}/lib/api/api_service.dart +++ b/bricks/template/__brick__/{{project_name.snakeCase()}}/lib/api/api_service.dart @@ -1,14 +1,19 @@ import 'package:dio/dio.dart'; -import 'package:{{project_name.snakeCase()}}/model/response/user_response.dart'; +import 'package:{{project_name.snakeCase()}}/api/response/user_response.dart'; import 'package:retrofit/retrofit.dart'; part 'api_service.g.dart'; +abstract class BaseApiService { + Future> getUsers(); +} + @RestApi() -abstract class ApiService { +abstract class ApiService extends BaseApiService { factory ApiService(Dio dio, {String baseUrl}) = _ApiService; // TODO add API endpoint + @override @GET('users') Future> getUsers(); } diff --git a/bricks/template/__brick__/{{project_name.snakeCase()}}/lib/api/repository/credential_repository.dart b/bricks/template/__brick__/{{project_name.snakeCase()}}/lib/api/repository/credential_repository.dart index 211a712d..f7b62485 100644 --- a/bricks/template/__brick__/{{project_name.snakeCase()}}/lib/api/repository/credential_repository.dart +++ b/bricks/template/__brick__/{{project_name.snakeCase()}}/lib/api/repository/credential_repository.dart @@ -1,20 +1,25 @@ import 'package:{{project_name.snakeCase()}}/api/api_service.dart'; import 'package:{{project_name.snakeCase()}}/api/exception/network_exceptions.dart'; -import 'package:{{project_name.snakeCase()}}/model/response/user_response.dart'; +import 'package:{{project_name.snakeCase()}}/model/user.dart'; +import 'package:injectable/injectable.dart'; abstract class CredentialRepository { - Future> getUsers(); + Future> getUsers(); } +@LazySingleton(as: CredentialRepository) class CredentialRepositoryImpl extends CredentialRepository { - final ApiService _apiService; + final BaseApiService _apiService; CredentialRepositoryImpl(this._apiService); @override - Future> getUsers() async { + Future> getUsers() async { try { - return await _apiService.getUsers(); + final userResponses = await _apiService.getUsers(); + return userResponses + .map((userResponse) => User.fromUserResponse(userResponse)) + .toList(); } catch (exception) { throw NetworkExceptions.fromDioException(exception); } diff --git a/bricks/template/__brick__/{{project_name.snakeCase()}}/lib/model/response/user_response.dart b/bricks/template/__brick__/{{project_name.snakeCase()}}/lib/api/response/user_response.dart similarity index 100% rename from bricks/template/__brick__/{{project_name.snakeCase()}}/lib/model/response/user_response.dart rename to bricks/template/__brick__/{{project_name.snakeCase()}}/lib/api/response/user_response.dart diff --git a/bricks/template/__brick__/{{project_name.snakeCase()}}/lib/di/di.dart b/bricks/template/__brick__/{{project_name.snakeCase()}}/lib/di/di.dart new file mode 100644 index 00000000..45a8f7ea --- /dev/null +++ b/bricks/template/__brick__/{{project_name.snakeCase()}}/lib/di/di.dart @@ -0,0 +1,9 @@ +import 'package:get_it/get_it.dart'; +import 'package:injectable/injectable.dart'; + +import 'di.config.dart'; + +final GetIt getIt = GetIt.instance; + +@injectableInit +Future configureInjection() async => getIt.init(); diff --git a/bricks/template/__brick__/{{project_name.snakeCase()}}/lib/di/module/network_module.dart b/bricks/template/__brick__/{{project_name.snakeCase()}}/lib/di/module/network_module.dart new file mode 100644 index 00000000..23857658 --- /dev/null +++ b/bricks/template/__brick__/{{project_name.snakeCase()}}/lib/di/module/network_module.dart @@ -0,0 +1,15 @@ +import 'package:{{project_name.snakeCase()}}/api/api_service.dart'; +import 'package:{{project_name.snakeCase()}}/env.dart'; +import 'package:{{project_name.snakeCase()}}/di/provider/dio_provider.dart'; +import 'package:injectable/injectable.dart'; + +@module +abstract class NetworkModule { + @Singleton(as: BaseApiService) + ApiService provideApiService(DioProvider dioProvider) { + return ApiService( + dioProvider.getDio(), + baseUrl: Env.restApiEndpoint, + ); + } +} diff --git a/bricks/template/__brick__/{{project_name.snakeCase()}}/lib/di/provider/dio_provider.dart b/bricks/template/__brick__/{{project_name.snakeCase()}}/lib/di/provider/dio_provider.dart index cba6b251..7e3b5957 100644 --- a/bricks/template/__brick__/{{project_name.snakeCase()}}/lib/di/provider/dio_provider.dart +++ b/bricks/template/__brick__/{{project_name.snakeCase()}}/lib/di/provider/dio_provider.dart @@ -1,10 +1,12 @@ import 'package:dio/dio.dart'; import 'package:flutter/foundation.dart'; import 'package:{{project_name.snakeCase()}}/di/interceptor/app_interceptor.dart'; +import 'package:injectable/injectable.dart'; const String headerContentType = 'Content-Type'; const String defaultContentType = 'application/json; charset=utf-8'; +@Singleton() class DioProvider { Dio? _dio; diff --git a/bricks/template/__brick__/{{project_name.snakeCase()}}/lib/home_view_model.dart b/bricks/template/__brick__/{{project_name.snakeCase()}}/lib/home_view_model.dart new file mode 100644 index 00000000..313ee7dc --- /dev/null +++ b/bricks/template/__brick__/{{project_name.snakeCase()}}/lib/home_view_model.dart @@ -0,0 +1,27 @@ +import 'dart:async'; +import 'package:{{project_name.snakeCase()}}/home_view_state.dart'; +import 'package:{{project_name.snakeCase()}}/usecases/base/base_use_case.dart'; +import 'package:{{project_name.snakeCase()}}/usecases/user/get_users_use_case.dart'; +import 'package:{{project_name.snakeCase()}}/model/user.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +class HomeViewModel extends StateNotifier { + final GetUsersUseCase _getUsersUseCase; + + HomeViewModel( + this._getUsersUseCase, + ) : super(const HomeViewState.init()); + + final StreamController> _usersStream = StreamController(); + + Stream> get usersStream => _usersStream.stream; + + Future getUsers() async { + final result = await _getUsersUseCase.call(); + if (result is Success>) { + _usersStream.add(result.value); + } else { + // TODO handle error + } + } +} diff --git a/bricks/template/__brick__/{{project_name.snakeCase()}}/lib/home_view_state.dart b/bricks/template/__brick__/{{project_name.snakeCase()}}/lib/home_view_state.dart new file mode 100644 index 00000000..6d561521 --- /dev/null +++ b/bricks/template/__brick__/{{project_name.snakeCase()}}/lib/home_view_state.dart @@ -0,0 +1,8 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'home_view_state.freezed.dart'; + +@freezed +class HomeViewState with _$HomeViewState { + const factory HomeViewState.init() = _Init; +} diff --git a/bricks/template/__brick__/{{project_name.snakeCase()}}/lib/main.dart b/bricks/template/__brick__/{{project_name.snakeCase()}}/lib/main.dart index e8d22c3a..4513ce90 100644 --- a/bricks/template/__brick__/{{project_name.snakeCase()}}/lib/main.dart +++ b/bricks/template/__brick__/{{project_name.snakeCase()}}/lib/main.dart @@ -1,15 +1,26 @@ import 'package:flutter/material.dart'; import 'package:flutter_config/flutter_config.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; -import 'package:{{project_name.snakeCase()}}/gen/assets.gen.dart'; -import 'package:{{project_name.snakeCase()}}/gen/colors.gen.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import 'package:package_info_plus/package_info_plus.dart'; +import 'package:{{project_name.snakeCase()}}/di/di.dart'; +import 'package:{{project_name.snakeCase()}}/gen/assets.gen.dart'; +import 'package:{{project_name.snakeCase()}}/gen/colors.gen.dart'; +import 'package:{{project_name.snakeCase()}}/usecases/user/get_users_use_case.dart'; + +import 'home_view_model.dart'; +import 'home_view_state.dart'; void main() async { WidgetsFlutterBinding.ensureInitialized(); await FlutterConfig.loadEnvVariables(); - runApp(MyApp()); + await configureInjection(); + runApp( + ProviderScope( + child: MyApp(), + ), + ); } const routePathRootScreen = '/'; @@ -52,8 +63,26 @@ class MyApp extends StatelessWidget { } } -class HomeScreen extends StatelessWidget { - const HomeScreen({Key? key}) : super(key: key); +final homeViewModelProvider = + StateNotifierProvider.autoDispose((ref) { + return HomeViewModel( + getIt.get(), + ); +}); + +class HomeScreen extends ConsumerStatefulWidget { + const HomeScreen({super.key}); + + @override + HomeScreenState createState() => HomeScreenState(); +} + +class HomeScreenState extends ConsumerState { + @override + void initState() { + super.initState(); + ref.read(homeViewModelProvider.notifier).getUsers(); + } @override Widget build(BuildContext context) { diff --git a/bricks/template/__brick__/{{project_name.snakeCase()}}/lib/model/user.dart b/bricks/template/__brick__/{{project_name.snakeCase()}}/lib/model/user.dart new file mode 100644 index 00000000..644c34e5 --- /dev/null +++ b/bricks/template/__brick__/{{project_name.snakeCase()}}/lib/model/user.dart @@ -0,0 +1,28 @@ +import 'package:equatable/equatable.dart'; +import 'package:{{project_name.snakeCase()}}/api/response/user_response.dart'; + +class User extends Equatable { + final String email; + final String username; + + const User({ + required this.email, + required this.username, + }); + + factory User.fromUserResponse(UserResponse response) { + return User( + email: response.email, + username: response.username, + ); + } + + @override + bool? get stringify => true; + + @override + List get props => [ + email, + username, + ]; +} diff --git a/bricks/template/__brick__/{{project_name.snakeCase()}}/lib/usecases/user/get_users_use_case.dart b/bricks/template/__brick__/{{project_name.snakeCase()}}/lib/usecases/user/get_users_use_case.dart new file mode 100644 index 00000000..0967e82e --- /dev/null +++ b/bricks/template/__brick__/{{project_name.snakeCase()}}/lib/usecases/user/get_users_use_case.dart @@ -0,0 +1,22 @@ +import 'package:{{project_name.snakeCase()}}/api/exception/network_exceptions.dart'; +import 'package:{{project_name.snakeCase()}}/api/repository/credential_repository.dart'; +import 'package:{{project_name.snakeCase()}}/usecases/base/base_use_case.dart'; +import 'package:{{project_name.snakeCase()}}/model/user.dart'; +import 'package:injectable/injectable.dart'; + +@Injectable() +class GetUsersUseCase extends NoParamsUseCase> { + final CredentialRepository _credentialRepository; + + GetUsersUseCase(this._credentialRepository); + + @override + Future>> call() async { + return _credentialRepository + .getUsers() + .then((value) => + Success(value) as Result>) // ignore: unnecessary_cast + .onError( + (err, stackTrace) => Failed(UseCaseException(err))); + } +} diff --git a/bricks/template/__brick__/{{project_name.snakeCase()}}/pubspec.lock b/bricks/template/__brick__/{{project_name.snakeCase()}}/pubspec.lock index fff25cc5..afe8f03c 100644 --- a/bricks/template/__brick__/{{project_name.snakeCase()}}/pubspec.lock +++ b/bricks/template/__brick__/{{project_name.snakeCase()}}/pubspec.lock @@ -280,6 +280,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.7" + flutter_riverpod: + dependency: "direct main" + description: + name: flutter_riverpod + sha256: b83ac5827baadefd331ea1d85110f34645827ea234ccabf53a655f41901a9bf4 + url: "https://pub.dev" + source: hosted + version: "2.3.6" flutter_test: dependency: "direct dev" description: flutter @@ -319,6 +327,14 @@ packages: description: flutter source: sdk version: "0.0.0" + get_it: + dependency: "direct main" + description: + name: get_it + sha256: "529de303c739fca98cd7ece5fca500d8ff89649f1bb4b4e94fb20954abcd7468" + url: "https://pub.dev" + source: hosted + version: "7.6.0" glob: dependency: transitive description: @@ -367,6 +383,14 @@ packages: url: "https://pub.dev" source: hosted version: "4.0.2" + injectable: + dependency: "direct main" + description: + name: injectable + sha256: fbe4b673f8c344e03eb4ab50fa853405872f401bbf6a7ba84b2b7447ac81ed1b + url: "https://pub.dev" + source: hosted + version: "2.1.2" integration_test: dependency: "direct dev" description: flutter @@ -580,6 +604,14 @@ packages: url: "https://pub.dev" source: hosted version: "7.0.0" + riverpod: + dependency: transitive + description: + name: riverpod + sha256: "80e48bebc83010d5e67a11c9514af6b44bbac1ec77b4333c8ea65dbc79e2d8ef" + url: "https://pub.dev" + source: hosted + version: "2.3.6" shelf: dependency: transitive description: @@ -633,6 +665,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.11.0" + state_notifier: + dependency: transitive + description: + name: state_notifier + sha256: "8fe42610f179b843b12371e40db58c9444f8757f8b69d181c97e50787caed289" + url: "https://pub.dev" + source: hosted + version: "0.7.2+1" stream_channel: dependency: transitive description: diff --git a/bricks/template/__brick__/{{project_name.snakeCase()}}/pubspec.yaml b/bricks/template/__brick__/{{project_name.snakeCase()}}/pubspec.yaml index ddf35ddf..c7da77b5 100644 --- a/bricks/template/__brick__/{{project_name.snakeCase()}}/pubspec.yaml +++ b/bricks/template/__brick__/{{project_name.snakeCase()}}/pubspec.yaml @@ -29,14 +29,18 @@ environment: # versions available, run `flutter pub outdated`. dependencies: dio: ^5.1.2 + equatable: ^2.0.5 flutter: sdk: flutter flutter_config: ^2.0.2 flutter_localizations: sdk: flutter + flutter_riverpod: ^2.3.6 flutter_svg: ^2.0.7 freezed_annotation: ^2.2.0 + get_it: ^7.6.0 go_router: ^9.0.3 + injectable: ^2.1.2 intl: ^0.18.0 json_annotation: ^4.8.1 package_info_plus: ^4.0.0{{#add_permission_handler}}{{{ _pubspec_dependencyyaml }}}{{/add_permission_handler}} @@ -49,6 +53,7 @@ dev_dependencies: flutter_test: sdk: flutter freezed: ^2.3.4 + injectable_generator: ^2.1.6 integration_test: sdk: flutter json_serializable: ^6.6.2 diff --git a/bricks/template/__brick__/{{project_name.snakeCase()}}/test/api/repository/credential_repository_test.dart b/bricks/template/__brick__/{{project_name.snakeCase()}}/test/api/repository/credential_repository_test.dart index 11bd8fd9..4f55260a 100644 --- a/bricks/template/__brick__/{{project_name.snakeCase()}}/test/api/repository/credential_repository_test.dart +++ b/bricks/template/__brick__/{{project_name.snakeCase()}}/test/api/repository/credential_repository_test.dart @@ -1,6 +1,6 @@ import 'package:{{project_name.snakeCase()}}/api/exception/network_exceptions.dart'; import 'package:{{project_name.snakeCase()}}/api/repository/credential_repository.dart'; -import 'package:{{project_name.snakeCase()}}/model/response/user_response.dart'; +import 'package:{{project_name.snakeCase()}}/api/response/user_response.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:mockito/mockito.dart'; diff --git a/bricks/template/__brick__/{{project_name.snakeCase()}}/test/mocks/generate_mocks.dart b/bricks/template/__brick__/{{project_name.snakeCase()}}/test/mocks/generate_mocks.dart index 85ddc95f..ae4439d8 100644 --- a/bricks/template/__brick__/{{project_name.snakeCase()}}/test/mocks/generate_mocks.dart +++ b/bricks/template/__brick__/{{project_name.snakeCase()}}/test/mocks/generate_mocks.dart @@ -1,10 +1,14 @@ import 'package:dio/dio.dart'; import 'package:{{project_name.snakeCase()}}/api/api_service.dart'; +import 'package:{{project_name.snakeCase()}}/api/repository/credential_repository.dart'; +import 'package:{{project_name.snakeCase()}}/usecases/user/get_users_use_case.dart'; import 'package:mockito/annotations.dart'; @GenerateMocks([ ApiService, + CredentialRepository, DioError, + GetUsersUseCase, ]) main() { // empty class to generate mock repository classes diff --git a/bricks/template/__brick__/{{project_name.snakeCase()}}/test/mocks/response/user_response_mocks.dart b/bricks/template/__brick__/{{project_name.snakeCase()}}/test/mocks/response/user_response_mocks.dart new file mode 100644 index 00000000..b7137346 --- /dev/null +++ b/bricks/template/__brick__/{{project_name.snakeCase()}}/test/mocks/response/user_response_mocks.dart @@ -0,0 +1,10 @@ +import 'package:{{project_name.snakeCase()}}/api/response/user_response.dart'; + +class UserResponseMocks { + static UserResponse mock() { + return UserResponse( + "email", + "username", + ); + } +} diff --git a/bricks/template/__brick__/{{project_name.snakeCase()}}/test/usecases/user/get_users_use_case_test.dart b/bricks/template/__brick__/{{project_name.snakeCase()}}/test/usecases/user/get_users_use_case_test.dart new file mode 100644 index 00000000..2350f6b9 --- /dev/null +++ b/bricks/template/__brick__/{{project_name.snakeCase()}}/test/usecases/user/get_users_use_case_test.dart @@ -0,0 +1,30 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/mockito.dart'; +import 'package:{{project_name.snakeCase()}}/usecases/base/base_use_case.dart'; +import 'package:{{project_name.snakeCase()}}/usecases/user/get_users_use_case.dart'; +import 'package:{{project_name.snakeCase()}}/model/user.dart'; + +import '../../mocks/generate_mocks.mocks.dart'; +import '../../mocks/response/user_response_mocks.dart'; + +void main() { + group('GetUsersUseCase', () { + late MockCredentialRepository mockRepository; + late GetUsersUseCase getUsersUseCase; + + setUp(() { + mockRepository = MockCredentialRepository(); + getUsersUseCase = GetUsersUseCase(mockRepository); + }); + + test('When getting users successfully, it returns Success result', + () async { + final expectedResult = [User.fromUserResponse(UserResponseMocks.mock())]; + when(mockRepository.getUsers()).thenAnswer((_) async => expectedResult); + final result = await getUsersUseCase.call(); + + expect(result, isA()); + expect((result as Success).value, expectedResult); + }); + }); +} diff --git a/bricks/template/__brick__/{{project_name.snakeCase()}}/test/viewmodel/home_view_model_test.dart b/bricks/template/__brick__/{{project_name.snakeCase()}}/test/viewmodel/home_view_model_test.dart new file mode 100644 index 00000000..37f8a291 --- /dev/null +++ b/bricks/template/__brick__/{{project_name.snakeCase()}}/test/viewmodel/home_view_model_test.dart @@ -0,0 +1,44 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/mockito.dart'; +import 'package:{{project_name.snakeCase()}}/usecases/base/base_use_case.dart'; +import 'package:{{project_name.snakeCase()}}/home_view_model.dart'; +import 'package:{{project_name.snakeCase()}}/main.dart'; +import 'package:{{project_name.snakeCase()}}/model/user.dart'; + +import '../mocks/generate_mocks.mocks.dart'; +import '../mocks/response/user_response_mocks.dart'; + +void main() { + group("HomeViewModelTest", () { + late ProviderContainer container; + late MockGetUsersUseCase mockGetUsersUseCase; + + setUp(() { + TestWidgetsFlutterBinding.ensureInitialized(); + mockGetUsersUseCase = MockGetUsersUseCase(); + + container = ProviderContainer( + overrides: [ + homeViewModelProvider.overrideWith((ref) => HomeViewModel( + mockGetUsersUseCase, + )), + ], + ); + addTearDown(container.dispose); + }); + + test('When calling get user list successfully, it returns correctly', + () async { + final expectedResult = [User.fromUserResponse(UserResponseMocks.mock())]; + when(mockGetUsersUseCase.call()) + .thenAnswer((_) async => Success(expectedResult)); + + final usersStream = + container.read(homeViewModelProvider.notifier).usersStream; + expect(usersStream, emitsInOrder([expectedResult])); + + container.read(homeViewModelProvider.notifier).getUsers(); + }); + }); +}