├── linux ├── .gitignore ├── main.cc ├── flutter │ ├── generated_plugin_registrant.cc │ ├── generated_plugin_registrant.h │ ├── generated_plugins.cmake │ └── CMakeLists.txt ├── my_application.h └── my_application.cc ├── ios ├── Runner │ ├── Runner-Bridging-Header.h │ ├── Assets.xcassets │ │ ├── LaunchImage.imageset │ │ │ ├── LaunchImage.png │ │ │ ├── LaunchImage@2x.png │ │ │ ├── LaunchImage@3x.png │ │ │ ├── README.md │ │ │ └── Contents.json │ │ └── AppIcon.appiconset │ │ │ ├── Icon-App-20x20@1x.png │ │ │ ├── Icon-App-20x20@2x.png │ │ │ ├── Icon-App-20x20@3x.png │ │ │ ├── Icon-App-29x29@1x.png │ │ │ ├── Icon-App-29x29@2x.png │ │ │ ├── Icon-App-29x29@3x.png │ │ │ ├── Icon-App-40x40@1x.png │ │ │ ├── Icon-App-40x40@2x.png │ │ │ ├── Icon-App-40x40@3x.png │ │ │ ├── Icon-App-60x60@2x.png │ │ │ ├── Icon-App-60x60@3x.png │ │ │ ├── Icon-App-76x76@1x.png │ │ │ ├── Icon-App-76x76@2x.png │ │ │ ├── Icon-App-1024x1024@1x.png │ │ │ ├── Icon-App-83.5x83.5@2x.png │ │ │ └── Contents.json │ ├── AppDelegate.swift │ ├── Base.lproj │ │ ├── Main.storyboard │ │ └── LaunchScreen.storyboard │ └── Info.plist ├── Flutter │ ├── Debug.xcconfig │ ├── Release.xcconfig │ └── AppFrameworkInfo.plist ├── Runner.xcodeproj │ ├── project.xcworkspace │ │ ├── contents.xcworkspacedata │ │ └── xcshareddata │ │ │ ├── WorkspaceSettings.xcsettings │ │ │ └── IDEWorkspaceChecks.plist │ └── xcshareddata │ │ └── xcschemes │ │ └── Runner.xcscheme ├── Runner.xcworkspace │ ├── contents.xcworkspacedata │ └── xcshareddata │ │ ├── WorkspaceSettings.xcsettings │ │ └── IDEWorkspaceChecks.plist ├── RunnerTests │ └── RunnerTests.swift ├── .gitignore └── Podfile ├── art ├── app.png ├── arch_1.png └── arch_2.png ├── web ├── favicon.png ├── icons │ ├── Icon-192.png │ ├── Icon-512.png │ ├── Icon-maskable-192.png │ └── Icon-maskable-512.png ├── manifest.json └── index.html ├── android ├── gradle.properties ├── app │ ├── src │ │ ├── main │ │ │ ├── res │ │ │ │ ├── mipmap-hdpi │ │ │ │ │ └── ic_launcher.png │ │ │ │ ├── mipmap-mdpi │ │ │ │ │ └── ic_launcher.png │ │ │ │ ├── mipmap-xhdpi │ │ │ │ │ └── ic_launcher.png │ │ │ │ ├── mipmap-xxhdpi │ │ │ │ │ └── ic_launcher.png │ │ │ │ ├── mipmap-xxxhdpi │ │ │ │ │ └── ic_launcher.png │ │ │ │ ├── drawable │ │ │ │ │ └── launch_background.xml │ │ │ │ ├── drawable-v21 │ │ │ │ │ └── launch_background.xml │ │ │ │ ├── values │ │ │ │ │ └── styles.xml │ │ │ │ └── values-night │ │ │ │ │ └── styles.xml │ │ │ ├── kotlin │ │ │ │ └── com │ │ │ │ │ └── example │ │ │ │ │ └── rickmorty │ │ │ │ │ └── MainActivity.kt │ │ │ └── AndroidManifest.xml │ │ ├── debug │ │ │ └── AndroidManifest.xml │ │ └── profile │ │ │ └── AndroidManifest.xml │ └── build.gradle ├── gradle │ └── wrapper │ │ └── gradle-wrapper.properties ├── .gitignore ├── settings.gradle └── build.gradle ├── assets └── fonts │ ├── Exo-Bold.ttf │ ├── Exo-Medium.ttf │ └── OFL.txt ├── lib ├── layers │ ├── presentation │ │ ├── using_bloc │ │ │ ├── details_page │ │ │ │ └── bloc │ │ │ │ │ ├── character_details_event.dart │ │ │ │ │ ├── character_details_state.dart │ │ │ │ │ └── character_details_bloc.dart │ │ │ ├── list_page │ │ │ │ ├── bloc │ │ │ │ │ ├── character_page_event.dart │ │ │ │ │ ├── character_page_state.dart │ │ │ │ │ └── character_page_bloc.dart │ │ │ │ └── view │ │ │ │ │ └── character_page.dart │ │ │ └── app_using_bloc.dart │ │ ├── using_riverpod │ │ │ ├── details_page │ │ │ │ └── notifier │ │ │ │ │ ├── details_page_state.dart │ │ │ │ │ └── details_page_notifier.dart │ │ │ ├── app_using_riverpod.dart │ │ │ ├── list_page │ │ │ │ ├── notifier │ │ │ │ │ ├── character_page_state.dart │ │ │ │ │ └── character_state_notifier.dart │ │ │ │ └── view │ │ │ │ │ └── character_page.dart │ │ │ └── providers.dart │ │ ├── using_cubit │ │ │ ├── details_page │ │ │ │ └── cubit │ │ │ │ │ ├── character_details_state.dart │ │ │ │ │ └── character_details_cubit.dart │ │ │ ├── app_using_cubit.dart │ │ │ └── list_page │ │ │ │ ├── cubit │ │ │ │ ├── character_page_cubit.dart │ │ │ │ └── character_page_state.dart │ │ │ │ └── view │ │ │ │ └── character_page.dart │ │ ├── using_provider │ │ │ ├── details_page │ │ │ │ └── change_notifier │ │ │ │ │ └── character_details_change_notifier.dart │ │ │ ├── app_using_provider.dart │ │ │ └── list_page │ │ │ │ ├── change_notifier │ │ │ │ └── character_page_change_notifier.dart │ │ │ │ └── view │ │ │ │ └── character_page.dart │ │ ├── using_get_it │ │ │ ├── details_page │ │ │ │ └── controller │ │ │ │ │ └── character_details_controller.dart │ │ │ ├── app_using_get_it.dart │ │ │ ├── list_page │ │ │ │ ├── controller │ │ │ │ │ └── character_page_controller.dart │ │ │ │ └── view │ │ │ │ │ └── character_page.dart │ │ │ └── injector.dart │ │ ├── shared │ │ │ ├── character_list_item_loading.dart │ │ │ ├── character_list_item_header.dart │ │ │ └── character_list_item.dart │ │ ├── using_mobx │ │ │ ├── details_page │ │ │ │ └── store │ │ │ │ │ ├── character_details_page_store.dart │ │ │ │ │ └── character_details_page_store.g.dart │ │ │ ├── list_page │ │ │ │ ├── store │ │ │ │ │ ├── character_page_store.dart │ │ │ │ │ └── character_page_store.g.dart │ │ │ │ └── view │ │ │ │ │ └── character_page.dart │ │ │ └── app_using_mobx.dart │ │ └── assets.dart │ ├── domain │ │ ├── repository │ │ │ └── character_repository.dart │ │ ├── entity │ │ │ ├── location.dart │ │ │ └── character.dart │ │ └── usecase │ │ │ └── get_all_characters.dart │ └── data │ │ ├── character_repository_impl.dart │ │ ├── source │ │ ├── network │ │ │ └── api.dart │ │ └── local │ │ │ └── local_storage.dart │ │ └── dto │ │ ├── location_dto.dart │ │ └── character_dto.dart └── main.dart ├── test ├── lib │ └── layers │ │ ├── presentation │ │ ├── using_get_it │ │ │ ├── details_page │ │ │ │ ├── controller │ │ │ │ │ └── character_details_controller_test.dart │ │ │ │ └── view │ │ │ │ │ └── character_details_page_test.dart │ │ │ └── list_page │ │ │ │ ├── controller │ │ │ │ └── character_page_controller.dart │ │ │ │ └── view │ │ │ │ └── character_page_test.dart │ │ ├── using_provider │ │ │ ├── details_page │ │ │ │ ├── change_notifier │ │ │ │ │ └── character_details_change_notifier_test.dart │ │ │ │ └── view │ │ │ │ │ └── character_details_page_test.dart │ │ │ └── list_page │ │ │ │ ├── view │ │ │ │ └── character_page_test.dart │ │ │ │ └── change_notifier │ │ │ │ └── character_page_change_notifier_test.dart │ │ ├── using_cubit │ │ │ ├── details_page │ │ │ │ ├── cubit │ │ │ │ │ ├── character_details_cubit_test.dart │ │ │ │ │ └── character_details_state_test.dart │ │ │ │ └── view │ │ │ │ │ └── character_details_page_test.dart │ │ │ └── list_page │ │ │ │ ├── view │ │ │ │ └── character_page_test.dart │ │ │ │ └── cubit │ │ │ │ ├── character_page_cubit_test.dart │ │ │ │ └── character_page_state_test.dart │ │ ├── using_bloc │ │ │ ├── details_page │ │ │ │ ├── bloc │ │ │ │ │ ├── character_details_bloc_test.dart │ │ │ │ │ └── character_details_state_test.dart │ │ │ │ └── view │ │ │ │ │ └── character_details_page_test.dart │ │ │ └── list_page │ │ │ │ ├── view │ │ │ │ └── character_page_test.dart │ │ │ │ └── bloc │ │ │ │ ├── character_page_bloc_test.dart │ │ │ │ └── character_page_state_test.dart │ │ ├── helper │ │ │ └── pump_app.dart │ │ ├── using_riverpod │ │ │ └── list_page │ │ │ │ └── view │ │ │ │ └── character_page_test.dart │ │ └── using_mobx │ │ │ └── list_page │ │ │ ├── view │ │ │ └── character_page_test.dart │ │ │ └── controller │ │ │ └── character_page_controller_test.dart │ │ ├── data │ │ ├── dto │ │ │ ├── location_dto_test.dart │ │ │ └── character_dto_test.dart │ │ ├── character_repository_impl_test.dart │ │ └── source │ │ │ └── local │ │ │ └── local_storage_test.dart │ │ └── domain │ │ ├── entity │ │ ├── location_test.dart │ │ └── character_test.dart │ │ └── usecase │ │ └── get_all_characters_test.dart └── fixtures │ └── fixtures.dart ├── .github └── workflows │ └── main.yaml ├── .gitignore ├── .metadata ├── analysis_options.yaml └── pubspec.yaml /linux/.gitignore: -------------------------------------------------------------------------------- 1 | flutter/ephemeral 2 | -------------------------------------------------------------------------------- /ios/Runner/Runner-Bridging-Header.h: -------------------------------------------------------------------------------- 1 | #import "GeneratedPluginRegistrant.h" 2 | -------------------------------------------------------------------------------- /art/app.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/guilherme-v/flutter-clean-architecture-example/HEAD/art/app.png -------------------------------------------------------------------------------- /art/arch_1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/guilherme-v/flutter-clean-architecture-example/HEAD/art/arch_1.png -------------------------------------------------------------------------------- /art/arch_2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/guilherme-v/flutter-clean-architecture-example/HEAD/art/arch_2.png -------------------------------------------------------------------------------- /web/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/guilherme-v/flutter-clean-architecture-example/HEAD/web/favicon.png -------------------------------------------------------------------------------- /android/gradle.properties: -------------------------------------------------------------------------------- 1 | org.gradle.jvmargs=-Xmx1536M 2 | android.useAndroidX=true 3 | android.enableJetifier=true 4 | -------------------------------------------------------------------------------- /web/icons/Icon-192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/guilherme-v/flutter-clean-architecture-example/HEAD/web/icons/Icon-192.png -------------------------------------------------------------------------------- /web/icons/Icon-512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/guilherme-v/flutter-clean-architecture-example/HEAD/web/icons/Icon-512.png -------------------------------------------------------------------------------- /assets/fonts/Exo-Bold.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/guilherme-v/flutter-clean-architecture-example/HEAD/assets/fonts/Exo-Bold.ttf -------------------------------------------------------------------------------- /assets/fonts/Exo-Medium.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/guilherme-v/flutter-clean-architecture-example/HEAD/assets/fonts/Exo-Medium.ttf -------------------------------------------------------------------------------- /ios/Flutter/Debug.xcconfig: -------------------------------------------------------------------------------- 1 | #include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig" 2 | #include "Generated.xcconfig" 3 | -------------------------------------------------------------------------------- /ios/Flutter/Release.xcconfig: -------------------------------------------------------------------------------- 1 | #include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig" 2 | #include "Generated.xcconfig" 3 | -------------------------------------------------------------------------------- /web/icons/Icon-maskable-192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/guilherme-v/flutter-clean-architecture-example/HEAD/web/icons/Icon-maskable-192.png -------------------------------------------------------------------------------- /web/icons/Icon-maskable-512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/guilherme-v/flutter-clean-architecture-example/HEAD/web/icons/Icon-maskable-512.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-hdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/guilherme-v/flutter-clean-architecture-example/HEAD/android/app/src/main/res/mipmap-hdpi/ic_launcher.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-mdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/guilherme-v/flutter-clean-architecture-example/HEAD/android/app/src/main/res/mipmap-mdpi/ic_launcher.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-xhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/guilherme-v/flutter-clean-architecture-example/HEAD/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png -------------------------------------------------------------------------------- /lib/layers/presentation/using_bloc/details_page/bloc/character_details_event.dart: -------------------------------------------------------------------------------- 1 | part of 'character_details_bloc.dart'; 2 | 3 | @immutable 4 | abstract class CharacterDetailsEvent {} 5 | -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/guilherme-v/flutter-clean-architecture-example/HEAD/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/guilherme-v/flutter-clean-architecture-example/HEAD/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/guilherme-v/flutter-clean-architecture-example/HEAD/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png -------------------------------------------------------------------------------- /android/app/src/main/kotlin/com/example/rickmorty/MainActivity.kt: -------------------------------------------------------------------------------- 1 | package com.example.rickmorty 2 | 3 | import io.flutter.embedding.android.FlutterActivity 4 | 5 | class MainActivity: FlutterActivity() { 6 | } 7 | -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/guilherme-v/flutter-clean-architecture-example/HEAD/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/guilherme-v/flutter-clean-architecture-example/HEAD/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/guilherme-v/flutter-clean-architecture-example/HEAD/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/guilherme-v/flutter-clean-architecture-example/HEAD/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/guilherme-v/flutter-clean-architecture-example/HEAD/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/guilherme-v/flutter-clean-architecture-example/HEAD/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/guilherme-v/flutter-clean-architecture-example/HEAD/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/guilherme-v/flutter-clean-architecture-example/HEAD/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/guilherme-v/flutter-clean-architecture-example/HEAD/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/guilherme-v/flutter-clean-architecture-example/HEAD/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/guilherme-v/flutter-clean-architecture-example/HEAD/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/guilherme-v/flutter-clean-architecture-example/HEAD/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/guilherme-v/flutter-clean-architecture-example/HEAD/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/guilherme-v/flutter-clean-architecture-example/HEAD/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/guilherme-v/flutter-clean-architecture-example/HEAD/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png -------------------------------------------------------------------------------- /linux/main.cc: -------------------------------------------------------------------------------- 1 | #include "my_application.h" 2 | 3 | int main(int argc, char** argv) { 4 | g_autoptr(MyApplication) app = my_application_new(); 5 | return g_application_run(G_APPLICATION(app), argc, argv); 6 | } 7 | -------------------------------------------------------------------------------- /ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /ios/Runner.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/guilherme-v/flutter-clean-architecture-example/HEAD/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/guilherme-v/flutter-clean-architecture-example/HEAD/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png -------------------------------------------------------------------------------- /lib/layers/domain/repository/character_repository.dart: -------------------------------------------------------------------------------- 1 | import 'package:rickmorty/layers/domain/entity/character.dart'; 2 | 3 | abstract class CharacterRepository { 4 | Future> getCharacters({int page = 0}); 5 | } 6 | -------------------------------------------------------------------------------- /linux/flutter/generated_plugin_registrant.cc: -------------------------------------------------------------------------------- 1 | // 2 | // Generated file. Do not edit. 3 | // 4 | 5 | // clang-format off 6 | 7 | #include "generated_plugin_registrant.h" 8 | 9 | 10 | void fl_register_plugins(FlPluginRegistry* registry) { 11 | } 12 | -------------------------------------------------------------------------------- /android/gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | distributionBase=GRADLE_USER_HOME 2 | distributionPath=wrapper/dists 3 | zipStoreBase=GRADLE_USER_HOME 4 | zipStorePath=wrapper/dists 5 | distributionUrl=https\://services.gradle.org/distributions/gradle-7.5-all.zip 6 | -------------------------------------------------------------------------------- /lib/layers/presentation/using_riverpod/details_page/notifier/details_page_state.dart: -------------------------------------------------------------------------------- 1 | import 'package:rickmorty/layers/domain/entity/character.dart'; 2 | 3 | class DetailsPageState { 4 | DetailsPageState({this.character}); 5 | 6 | Character? character; 7 | } 8 | -------------------------------------------------------------------------------- /ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | PreviewsEnabled 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | PreviewsEnabled 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /lib/layers/presentation/using_bloc/details_page/bloc/character_details_state.dart: -------------------------------------------------------------------------------- 1 | part of 'character_details_bloc.dart'; 2 | 3 | class CharacterDetailsState with EquatableMixin { 4 | CharacterDetailsState({required this.character}); 5 | 6 | final Character character; 7 | 8 | @override 9 | List get props => [character]; 10 | } 11 | -------------------------------------------------------------------------------- /lib/layers/presentation/using_cubit/details_page/cubit/character_details_state.dart: -------------------------------------------------------------------------------- 1 | part of 'character_details_cubit.dart'; 2 | 3 | class CharacterDetailsState with EquatableMixin { 4 | const CharacterDetailsState(this.character); 5 | 6 | final Character character; 7 | 8 | @override 9 | List get props => [character]; 10 | } 11 | -------------------------------------------------------------------------------- /android/.gitignore: -------------------------------------------------------------------------------- 1 | gradle-wrapper.jar 2 | /.gradle 3 | /captures/ 4 | /gradlew 5 | /gradlew.bat 6 | /local.properties 7 | GeneratedPluginRegistrant.java 8 | 9 | # Remember to never publicly share your keystore. 10 | # See https://flutter.dev/docs/deployment/android#reference-the-keystore-from-the-app 11 | key.properties 12 | **/*.keystore 13 | **/*.jks 14 | -------------------------------------------------------------------------------- /ios/RunnerTests/RunnerTests.swift: -------------------------------------------------------------------------------- 1 | import Flutter 2 | import UIKit 3 | import XCTest 4 | 5 | class RunnerTests: XCTestCase { 6 | 7 | func testExample() { 8 | // If you add code to the Runner application, consider adding tests here. 9 | // See https://developer.apple.com/documentation/xctest for more information about using XCTest. 10 | } 11 | 12 | } 13 | -------------------------------------------------------------------------------- /lib/layers/domain/entity/location.dart: -------------------------------------------------------------------------------- 1 | import 'package:equatable/equatable.dart'; 2 | 3 | class Location with EquatableMixin { 4 | Location({ 5 | this.name, 6 | this.url, 7 | }); 8 | 9 | final String? name; 10 | final String? url; 11 | 12 | @override 13 | List get props => [ 14 | name, 15 | url, 16 | ]; 17 | } 18 | -------------------------------------------------------------------------------- /lib/layers/presentation/using_bloc/list_page/bloc/character_page_event.dart: -------------------------------------------------------------------------------- 1 | part of 'character_page_bloc.dart'; 2 | 3 | sealed class CharacterPageEvent extends Equatable { 4 | const CharacterPageEvent(); 5 | 6 | @override 7 | List get props => []; 8 | } 9 | 10 | final class FetchNextPageEvent extends CharacterPageEvent { 11 | const FetchNextPageEvent(); 12 | } 13 | -------------------------------------------------------------------------------- /lib/layers/presentation/using_provider/details_page/change_notifier/character_details_change_notifier.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/foundation.dart'; 2 | import 'package:rickmorty/layers/domain/entity/character.dart'; 3 | 4 | class CharacterDetailsChangeNotifier extends ChangeNotifier { 5 | CharacterDetailsChangeNotifier({required this.character}); 6 | 7 | final Character character; 8 | } 9 | -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md: -------------------------------------------------------------------------------- 1 | # Launch Screen Assets 2 | 3 | You can customize the launch screen with your own desired assets by replacing the image files in this directory. 4 | 5 | You can also do it by opening your Flutter project's Xcode project with `open ios/Runner.xcworkspace`, selecting `Runner/Assets.xcassets` in the Project Navigator and dropping in the desired images. -------------------------------------------------------------------------------- /lib/layers/presentation/using_get_it/details_page/controller/character_details_controller.dart: -------------------------------------------------------------------------------- 1 | import 'package:rickmorty/layers/domain/entity/character.dart'; 2 | 3 | class CharacterDetailsController { 4 | CharacterDetailsController(); 5 | 6 | // No need to over complicate anything 7 | // The controller just holds the 'character' that UI will show 8 | late Character character; 9 | } 10 | -------------------------------------------------------------------------------- /linux/flutter/generated_plugin_registrant.h: -------------------------------------------------------------------------------- 1 | // 2 | // Generated file. Do not edit. 3 | // 4 | 5 | // clang-format off 6 | 7 | #ifndef GENERATED_PLUGIN_REGISTRANT_ 8 | #define GENERATED_PLUGIN_REGISTRANT_ 9 | 10 | #include 11 | 12 | // Registers Flutter plugins. 13 | void fl_register_plugins(FlPluginRegistry* registry); 14 | 15 | #endif // GENERATED_PLUGIN_REGISTRANT_ 16 | -------------------------------------------------------------------------------- /lib/layers/presentation/shared/character_list_item_loading.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | 3 | class CharacterListItemLoading extends StatelessWidget { 4 | const CharacterListItemLoading({super.key}); 5 | 6 | @override 7 | Widget build(BuildContext context) { 8 | return const SizedBox( 9 | height: 80, 10 | child: Center(child: CircularProgressIndicator()), 11 | ); 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /android/app/src/debug/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /android/app/src/profile/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /ios/Runner/AppDelegate.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | import Flutter 3 | 4 | @UIApplicationMain 5 | @objc class AppDelegate: FlutterAppDelegate { 6 | override func application( 7 | _ application: UIApplication, 8 | didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? 9 | ) -> Bool { 10 | GeneratedPluginRegistrant.register(with: self) 11 | return super.application(application, didFinishLaunchingWithOptions: launchOptions) 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /lib/layers/presentation/using_cubit/details_page/cubit/character_details_cubit.dart: -------------------------------------------------------------------------------- 1 | import 'package:bloc/bloc.dart'; 2 | import 'package:equatable/equatable.dart'; 3 | import 'package:rickmorty/layers/domain/entity/character.dart'; 4 | 5 | part 'character_details_state.dart'; 6 | 7 | class CharacterDetailsCubit extends Cubit { 8 | CharacterDetailsCubit({ 9 | required Character character, 10 | }) : super(CharacterDetailsState(character)); 11 | } 12 | -------------------------------------------------------------------------------- /linux/my_application.h: -------------------------------------------------------------------------------- 1 | #ifndef FLUTTER_MY_APPLICATION_H_ 2 | #define FLUTTER_MY_APPLICATION_H_ 3 | 4 | #include 5 | 6 | G_DECLARE_FINAL_TYPE(MyApplication, my_application, MY, APPLICATION, 7 | GtkApplication) 8 | 9 | /** 10 | * my_application_new: 11 | * 12 | * Creates a new Flutter-based application. 13 | * 14 | * Returns: a new #MyApplication. 15 | */ 16 | MyApplication* my_application_new(); 17 | 18 | #endif // FLUTTER_MY_APPLICATION_H_ 19 | -------------------------------------------------------------------------------- /android/settings.gradle: -------------------------------------------------------------------------------- 1 | include ':app' 2 | 3 | def localPropertiesFile = new File(rootProject.projectDir, "local.properties") 4 | def properties = new Properties() 5 | 6 | assert localPropertiesFile.exists() 7 | localPropertiesFile.withReader("UTF-8") { reader -> properties.load(reader) } 8 | 9 | def flutterSdkPath = properties.getProperty("flutter.sdk") 10 | assert flutterSdkPath != null, "flutter.sdk not set in local.properties" 11 | apply from: "$flutterSdkPath/packages/flutter_tools/gradle/app_plugin_loader.gradle" 12 | -------------------------------------------------------------------------------- /android/app/src/main/res/drawable/launch_background.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 12 | 13 | -------------------------------------------------------------------------------- /android/app/src/main/res/drawable-v21/launch_background.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 12 | 13 | -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "filename" : "LaunchImage.png", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "idiom" : "universal", 10 | "filename" : "LaunchImage@2x.png", 11 | "scale" : "2x" 12 | }, 13 | { 14 | "idiom" : "universal", 15 | "filename" : "LaunchImage@3x.png", 16 | "scale" : "3x" 17 | } 18 | ], 19 | "info" : { 20 | "version" : 1, 21 | "author" : "xcode" 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /lib/layers/domain/usecase/get_all_characters.dart: -------------------------------------------------------------------------------- 1 | import 'package:rickmorty/layers/domain/entity/character.dart'; 2 | import 'package:rickmorty/layers/domain/repository/character_repository.dart'; 3 | 4 | class GetAllCharacters { 5 | GetAllCharacters({ 6 | required CharacterRepository repository, 7 | }) : _repository = repository; 8 | 9 | final CharacterRepository _repository; 10 | 11 | Future> call({int page = 0}) async { 12 | final list = await _repository.getCharacters(page: page); 13 | return list; 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /lib/layers/presentation/using_riverpod/details_page/notifier/details_page_notifier.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter_riverpod/flutter_riverpod.dart'; 2 | import 'package:rickmorty/layers/presentation/using_riverpod/details_page/notifier/details_page_state.dart'; 3 | 4 | final detailsPageProvider = 5 | NotifierProvider( 6 | () => DetailsPageNotifier(), 7 | ); 8 | 9 | class DetailsPageNotifier extends Notifier { 10 | DetailsPageNotifier(); 11 | 12 | @override 13 | DetailsPageState build() => DetailsPageState(); 14 | } 15 | -------------------------------------------------------------------------------- /lib/layers/presentation/using_bloc/details_page/bloc/character_details_bloc.dart: -------------------------------------------------------------------------------- 1 | import 'package:bloc/bloc.dart'; 2 | import 'package:equatable/equatable.dart'; 3 | import 'package:meta/meta.dart'; 4 | import 'package:rickmorty/layers/domain/entity/character.dart'; 5 | 6 | part 'character_details_event.dart'; 7 | 8 | part 'character_details_state.dart'; 9 | 10 | class CharacterDetailsBloc 11 | extends Bloc { 12 | CharacterDetailsBloc({required Character character}) 13 | : super(CharacterDetailsState(character: character)); 14 | } 15 | -------------------------------------------------------------------------------- /lib/layers/presentation/using_get_it/app_using_get_it.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:rickmorty/layers/presentation/using_get_it/list_page/view/character_page.dart'; 3 | 4 | class AppUsingGetIt extends StatelessWidget { 5 | const AppUsingGetIt({super.key}); 6 | 7 | @override 8 | Widget build(BuildContext context) { 9 | return const AppView(); 10 | } 11 | } 12 | 13 | class AppView extends StatelessWidget { 14 | const AppView({super.key}); 15 | 16 | @override 17 | Widget build(BuildContext context) { 18 | return const CharacterPage(); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /lib/layers/presentation/using_mobx/details_page/store/character_details_page_store.dart: -------------------------------------------------------------------------------- 1 | import 'package:mobx/mobx.dart'; 2 | import 'package:rickmorty/layers/domain/entity/character.dart'; 3 | 4 | part 'character_details_page_store.g.dart'; 5 | 6 | class CharacterDetailsPageStore = CharacterDetailsPageStoreBase 7 | with _$CharacterDetailsPageStore; 8 | 9 | abstract class CharacterDetailsPageStoreBase with Store { 10 | CharacterDetailsPageStoreBase({ 11 | required Character character, 12 | }) : _character = character; 13 | 14 | @readonly 15 | // ignore: prefer_final_fields 16 | late Character _character; 17 | } 18 | -------------------------------------------------------------------------------- /test/lib/layers/presentation/using_get_it/details_page/controller/character_details_controller_test.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter_test/flutter_test.dart'; 2 | import 'package:rickmorty/layers/presentation/using_get_it/details_page/controller/character_details_controller.dart'; 3 | 4 | import '../../../../../../fixtures/fixtures.dart'; 5 | 6 | void main() { 7 | late CharacterDetailsController controller; 8 | 9 | test('It should be able to create a new instance', () { 10 | final c = characterList1.first; 11 | controller = CharacterDetailsController()..character = c; 12 | expect(controller.character, c); 13 | }); 14 | } 15 | -------------------------------------------------------------------------------- /test/lib/layers/presentation/using_provider/details_page/change_notifier/character_details_change_notifier_test.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter_test/flutter_test.dart'; 2 | import 'package:rickmorty/layers/presentation/using_provider/details_page/change_notifier/character_details_change_notifier.dart'; 3 | 4 | import '../../../../../../fixtures/fixtures.dart'; 5 | 6 | void main() { 7 | group('CharacterDetailsChangeNotifier', () { 8 | test('initial state is correct', () { 9 | final c = characterList1.first; 10 | 11 | final notifier = CharacterDetailsChangeNotifier(character: c); 12 | 13 | expect(notifier.character, c); 14 | }); 15 | }); 16 | } 17 | -------------------------------------------------------------------------------- /test/lib/layers/presentation/using_cubit/details_page/cubit/character_details_cubit_test.dart: -------------------------------------------------------------------------------- 1 | import 'package:rickmorty/layers/domain/entity/character.dart'; 2 | import 'package:rickmorty/layers/presentation/using_cubit/details_page/cubit/character_details_cubit.dart'; 3 | import 'package:test/test.dart'; 4 | 5 | void main() { 6 | group('CharacterDetailsCubit', () { 7 | test('it should have correct initial state', () { 8 | final character = Character(id: 1, name: 'Test Character'); 9 | final cubit = CharacterDetailsCubit(character: character); 10 | final expected = CharacterDetailsState(character); 11 | expect(cubit.state, expected); 12 | }); 13 | }); 14 | } 15 | -------------------------------------------------------------------------------- /.github/workflows/main.yaml: -------------------------------------------------------------------------------- 1 | name: Flutter Clean 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | branches: 9 | - main 10 | 11 | jobs: 12 | test: 13 | name: Run Tests 14 | runs-on: ubuntu-latest 15 | 16 | steps: 17 | - name: Checkout repository 18 | uses: actions/checkout@v2 19 | 20 | - name: Setup Flutter 21 | uses: subosito/flutter-action@v2 22 | with: 23 | channel: stable 24 | flutter-version: 3.19.5 25 | - run: flutter --version 26 | 27 | - name: Install dependencies 28 | run: flutter pub get 29 | 30 | - name: Run tests 31 | run: flutter test 32 | -------------------------------------------------------------------------------- /ios/.gitignore: -------------------------------------------------------------------------------- 1 | **/dgph 2 | *.mode1v3 3 | *.mode2v3 4 | *.moved-aside 5 | *.pbxuser 6 | *.perspectivev3 7 | **/*sync/ 8 | .sconsign.dblite 9 | .tags* 10 | **/.vagrant/ 11 | **/DerivedData/ 12 | Icon? 13 | **/Pods/ 14 | **/.symlinks/ 15 | profile 16 | xcuserdata 17 | **/.generated/ 18 | Flutter/App.framework 19 | Flutter/Flutter.framework 20 | Flutter/Flutter.podspec 21 | Flutter/Generated.xcconfig 22 | Flutter/ephemeral/ 23 | Flutter/app.flx 24 | Flutter/app.zip 25 | Flutter/flutter_assets/ 26 | Flutter/flutter_export_environment.sh 27 | ServiceDefinitions.json 28 | Runner/GeneratedPluginRegistrant.* 29 | 30 | # Exceptions to above rules. 31 | !default.mode1v3 32 | !default.mode2v3 33 | !default.pbxuser 34 | !default.perspectivev3 35 | -------------------------------------------------------------------------------- /test/lib/layers/presentation/using_bloc/details_page/bloc/character_details_bloc_test.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter_test/flutter_test.dart'; 2 | import 'package:rickmorty/layers/domain/entity/character.dart'; 3 | import 'package:rickmorty/layers/presentation/using_bloc/details_page/bloc/character_details_bloc.dart'; 4 | 5 | import '../../../../../../fixtures/fixtures.dart'; 6 | 7 | void main() { 8 | group('CharacterDetailsBloc', () { 9 | test('initial state is correct', () { 10 | Character c = characterList1.first; 11 | 12 | final expected = CharacterDetailsState(character: c); 13 | final initial = CharacterDetailsBloc(character: c).state; 14 | 15 | expect(initial, expected); 16 | }); 17 | }); 18 | } 19 | -------------------------------------------------------------------------------- /android/build.gradle: -------------------------------------------------------------------------------- 1 | buildscript { 2 | ext.kotlin_version = '1.7.10' 3 | repositories { 4 | google() 5 | mavenCentral() 6 | } 7 | 8 | dependencies { 9 | classpath 'com.android.tools.build:gradle:7.3.0' 10 | classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" 11 | } 12 | } 13 | 14 | allprojects { 15 | repositories { 16 | google() 17 | mavenCentral() 18 | } 19 | } 20 | 21 | rootProject.buildDir = '../build' 22 | subprojects { 23 | project.buildDir = "${rootProject.buildDir}/${project.name}" 24 | } 25 | subprojects { 26 | project.evaluationDependsOn(':app') 27 | } 28 | 29 | tasks.register("clean", Delete) { 30 | delete rootProject.buildDir 31 | } 32 | -------------------------------------------------------------------------------- /test/lib/layers/data/dto/location_dto_test.dart: -------------------------------------------------------------------------------- 1 | import 'package:rickmorty/layers/data/dto/location_dto.dart'; 2 | import 'package:test/test.dart'; 3 | 4 | void main() { 5 | group('LocationDto', () { 6 | late String referenceRawJson; 7 | late LocationDto referenceDto; 8 | 9 | setUp(() { 10 | referenceDto = LocationDto( 11 | name: 'Rick Sanchez', 12 | url: 'https://example.com/character/1', 13 | ); 14 | 15 | referenceRawJson = referenceDto.toRawJson(); 16 | }); 17 | 18 | test('should create LocationDto instance to/from JSON', () { 19 | final createdDto = LocationDto.fromRawJson(referenceRawJson); 20 | final json = createdDto.toRawJson(); 21 | expect(createdDto, referenceDto); 22 | expect(json, referenceRawJson); 23 | }); 24 | }); 25 | } 26 | -------------------------------------------------------------------------------- /lib/main.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:flutter_animate/flutter_animate.dart'; 3 | import 'package:flutter_riverpod/flutter_riverpod.dart'; 4 | import 'package:rickmorty/layers/presentation/app_root.dart'; 5 | import 'package:rickmorty/layers/presentation/using_get_it/injector.dart'; 6 | import 'package:shared_preferences/shared_preferences.dart'; 7 | 8 | enum StateManagementOptions { 9 | bloc, 10 | cubit, 11 | provider, 12 | riverpod, 13 | getIt, 14 | mobX, 15 | } 16 | 17 | late SharedPreferences sharedPref; 18 | 19 | void main() async { 20 | WidgetsFlutterBinding.ensureInitialized(); 21 | sharedPref = await SharedPreferences.getInstance(); 22 | await initializeGetIt(); 23 | Animate.restartOnHotReload = true; 24 | 25 | runApp(const ProviderScope(child: AppRoot())); 26 | } 27 | -------------------------------------------------------------------------------- /lib/layers/presentation/using_riverpod/app_using_riverpod.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:rickmorty/layers/presentation/using_riverpod/list_page/view/character_page.dart'; 3 | 4 | class AppUsingRiverpod extends StatelessWidget { 5 | const AppUsingRiverpod({super.key}); 6 | 7 | @override 8 | Widget build(BuildContext context) { 9 | // In order to make Riverpod works correctly with the default 10 | // Material Widget's Navigator, we've moved the 'ProviderScope' to the 11 | // 'main.dart' file 12 | // 13 | // return const ProviderScope(child: AppView()); 14 | return const AppView(); 15 | } 16 | } 17 | 18 | class AppView extends StatelessWidget { 19 | const AppView({super.key}); 20 | 21 | @override 22 | Widget build(BuildContext context) { 23 | return const CharacterPage(); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /linux/flutter/generated_plugins.cmake: -------------------------------------------------------------------------------- 1 | # 2 | # Generated file, do not edit. 3 | # 4 | 5 | list(APPEND FLUTTER_PLUGIN_LIST 6 | ) 7 | 8 | list(APPEND FLUTTER_FFI_PLUGIN_LIST 9 | ) 10 | 11 | set(PLUGIN_BUNDLED_LIBRARIES) 12 | 13 | foreach(plugin ${FLUTTER_PLUGIN_LIST}) 14 | add_subdirectory(flutter/ephemeral/.plugin_symlinks/${plugin}/linux plugins/${plugin}) 15 | target_link_libraries(${BINARY_NAME} PRIVATE ${plugin}_plugin) 16 | list(APPEND PLUGIN_BUNDLED_LIBRARIES $) 17 | list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${plugin}_bundled_libraries}) 18 | endforeach(plugin) 19 | 20 | foreach(ffi_plugin ${FLUTTER_FFI_PLUGIN_LIST}) 21 | add_subdirectory(flutter/ephemeral/.plugin_symlinks/${ffi_plugin}/linux plugins/${ffi_plugin}) 22 | list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${ffi_plugin}_bundled_libraries}) 23 | endforeach(ffi_plugin) 24 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Miscellaneous 2 | *.class 3 | *.log 4 | *.pyc 5 | *.swp 6 | .DS_Store 7 | .atom/ 8 | .buildlog/ 9 | .history 10 | .svn/ 11 | migrate_working_dir/ 12 | 13 | # IntelliJ related 14 | *.iml 15 | *.ipr 16 | *.iws 17 | .idea/ 18 | 19 | # The .vscode folder contains launch configuration and tasks you configure in 20 | # VS Code which you may wish to be included in version control, so this line 21 | # is commented out by default. 22 | #.vscode/ 23 | 24 | # Flutter/Dart/Pub related 25 | **/doc/api/ 26 | **/ios/Flutter/.last_build_id 27 | .dart_tool/ 28 | .flutter-plugins 29 | .flutter-plugins-dependencies 30 | .packages 31 | .pub-cache/ 32 | .pub/ 33 | /build/ 34 | 35 | # Symbolication related 36 | app.*.symbols 37 | 38 | # Obfuscation related 39 | app.*.map.json 40 | 41 | # Android Studio will place build artifacts here 42 | /android/app/debug 43 | /android/app/profile 44 | /android/app/release 45 | -------------------------------------------------------------------------------- /ios/Flutter/AppFrameworkInfo.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | en 7 | CFBundleExecutable 8 | App 9 | CFBundleIdentifier 10 | io.flutter.flutter.app 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | App 15 | CFBundlePackageType 16 | FMWK 17 | CFBundleShortVersionString 18 | 1.0 19 | CFBundleSignature 20 | ???? 21 | CFBundleVersion 22 | 1.0 23 | MinimumOSVersion 24 | 11.0 25 | 26 | 27 | -------------------------------------------------------------------------------- /test/lib/layers/domain/entity/location_test.dart: -------------------------------------------------------------------------------- 1 | import 'package:rickmorty/layers/domain/entity/location.dart'; 2 | import 'package:test/test.dart'; 3 | 4 | void main() { 5 | group('Location', () { 6 | test('Two instances with the same properties should be equal', () { 7 | final location1 = Location( 8 | name: 'Earth', 9 | url: 'https://example.com/earth', 10 | ); 11 | 12 | final location2 = Location( 13 | name: 'Earth', 14 | url: 'https://example.com/earth', 15 | ); 16 | 17 | expect(location1, equals(location2)); 18 | }); 19 | 20 | test('Two instances with different properties should be different', () { 21 | final location1 = Location( 22 | name: 'Earth', 23 | url: 'https://example.com/earth', 24 | ); 25 | 26 | final location2 = Location( 27 | name: 'Mars', 28 | url: 'https://example.com/mars', 29 | ); 30 | 31 | expect(location1, isNot(equals(location2))); 32 | }); 33 | }); 34 | } 35 | -------------------------------------------------------------------------------- /lib/layers/data/character_repository_impl.dart: -------------------------------------------------------------------------------- 1 | import 'package:rickmorty/layers/data/source/local/local_storage.dart'; 2 | import 'package:rickmorty/layers/data/source/network/api.dart'; 3 | import 'package:rickmorty/layers/domain/entity/character.dart'; 4 | import 'package:rickmorty/layers/domain/repository/character_repository.dart'; 5 | 6 | class CharacterRepositoryImpl implements CharacterRepository { 7 | final Api _api; 8 | final LocalStorage _localStorage; 9 | 10 | CharacterRepositoryImpl({ 11 | required Api api, 12 | required LocalStorage localStorage, 13 | }) : _api = api, 14 | _localStorage = localStorage; 15 | 16 | @override 17 | Future> getCharacters({int page = 0}) async { 18 | final cachedList = _localStorage.loadCharactersPage(page: page); 19 | if (cachedList.isNotEmpty) { 20 | return cachedList; 21 | } 22 | 23 | final fetchedList = await _api.loadCharacters(page: page); 24 | await _localStorage.saveCharactersPage(page: page, list: fetchedList); 25 | return fetchedList; 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /web/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "rickmorty", 3 | "short_name": "rickmorty", 4 | "start_url": ".", 5 | "display": "standalone", 6 | "background_color": "#0175C2", 7 | "theme_color": "#0175C2", 8 | "description": "A new Flutter project.", 9 | "orientation": "portrait-primary", 10 | "prefer_related_applications": false, 11 | "icons": [ 12 | { 13 | "src": "icons/Icon-192.png", 14 | "sizes": "192x192", 15 | "type": "image/png" 16 | }, 17 | { 18 | "src": "icons/Icon-512.png", 19 | "sizes": "512x512", 20 | "type": "image/png" 21 | }, 22 | { 23 | "src": "icons/Icon-maskable-192.png", 24 | "sizes": "192x192", 25 | "type": "image/png", 26 | "purpose": "maskable" 27 | }, 28 | { 29 | "src": "icons/Icon-maskable-512.png", 30 | "sizes": "512x512", 31 | "type": "image/png", 32 | "purpose": "maskable" 33 | } 34 | ] 35 | } 36 | -------------------------------------------------------------------------------- /.metadata: -------------------------------------------------------------------------------- 1 | # This file tracks properties of this Flutter project. 2 | # Used by Flutter tool to assess capabilities and perform upgrades etc. 3 | # 4 | # This file should be version controlled and should not be manually edited. 5 | 6 | version: 7 | revision: "54e66469a933b60ddf175f858f82eaeb97e48c8d" 8 | channel: "stable" 9 | 10 | project_type: app 11 | 12 | # Tracks metadata for the flutter migrate command 13 | migration: 14 | platforms: 15 | - platform: root 16 | create_revision: 54e66469a933b60ddf175f858f82eaeb97e48c8d 17 | base_revision: 54e66469a933b60ddf175f858f82eaeb97e48c8d 18 | - platform: web 19 | create_revision: 54e66469a933b60ddf175f858f82eaeb97e48c8d 20 | base_revision: 54e66469a933b60ddf175f858f82eaeb97e48c8d 21 | 22 | # User provided section 23 | 24 | # List of Local paths (relative to this file) that should be 25 | # ignored by the migrate tool. 26 | # 27 | # Files that are not part of the templates will be ignored by default. 28 | unmanaged_files: 29 | - 'lib/main.dart' 30 | - 'ios/Runner.xcodeproj/project.pbxproj' 31 | -------------------------------------------------------------------------------- /lib/layers/presentation/using_bloc/app_using_bloc.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:flutter_bloc/flutter_bloc.dart'; 3 | import 'package:rickmorty/layers/domain/usecase/get_all_characters.dart'; 4 | import 'package:rickmorty/layers/presentation/using_bloc/list_page/view/character_page.dart'; 5 | 6 | class AppUsingBloc extends StatelessWidget { 7 | const AppUsingBloc({super.key, required this.getAllCharacters}); 8 | 9 | final GetAllCharacters getAllCharacters; 10 | 11 | @override 12 | Widget build(BuildContext context) { 13 | // 1 - Inject all uses cases on the top of the widget's tree 14 | // 2 - Use them as BLOC/Cubit dependencies as needed 15 | return MultiRepositoryProvider( 16 | providers: [ 17 | RepositoryProvider.value(value: getAllCharacters), 18 | ], 19 | child: const AppView(), 20 | ); 21 | } 22 | } 23 | 24 | class AppView extends StatelessWidget { 25 | const AppView({super.key}); 26 | 27 | @override 28 | Widget build(BuildContext context) { 29 | return const CharacterPage(); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /android/app/src/main/res/values/styles.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 9 | 15 | 18 | 19 | -------------------------------------------------------------------------------- /android/app/src/main/res/values-night/styles.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 9 | 15 | 18 | 19 | -------------------------------------------------------------------------------- /lib/layers/presentation/using_provider/app_using_provider.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:provider/provider.dart'; 3 | import 'package:rickmorty/layers/domain/usecase/get_all_characters.dart'; 4 | import 'package:rickmorty/layers/presentation/using_provider/list_page/view/character_page.dart'; 5 | 6 | class AppUsingProvider extends StatelessWidget { 7 | const AppUsingProvider({super.key, required this.getAllCharacters}); 8 | 9 | final GetAllCharacters getAllCharacters; 10 | 11 | @override 12 | Widget build(BuildContext context) { 13 | // - Provides UseCases down to the widget tree using Bloc's D.I widget 14 | // - Later we'll use it to instantiate each Controller (if needed) 15 | return MultiProvider( 16 | providers: [ 17 | Provider.value(value: getAllCharacters), 18 | ], 19 | child: const AppView(), 20 | ); 21 | } 22 | } 23 | 24 | class AppView extends StatelessWidget { 25 | const AppView({super.key}); 26 | 27 | @override 28 | Widget build(BuildContext context) { 29 | return const CharacterPage(); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /lib/layers/presentation/using_cubit/app_using_cubit.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:flutter_bloc/flutter_bloc.dart'; 3 | import 'package:rickmorty/layers/domain/usecase/get_all_characters.dart'; 4 | import 'package:rickmorty/layers/presentation/using_cubit/list_page/view/character_page.dart'; 5 | 6 | class AppUsingCubit extends StatelessWidget { 7 | const AppUsingCubit({super.key, required this.getAllCharacters}); 8 | 9 | final GetAllCharacters getAllCharacters; 10 | 11 | @override 12 | Widget build(BuildContext context) { 13 | // - Provides UseCases down to the widget tree using Bloc's D.I widget 14 | // - Later we'll use it to instantiate each CUBIT (if needed) 15 | // - BLOC and Cubits use the same set of widgets 16 | return RepositoryProvider.value( 17 | value: getAllCharacters, 18 | child: const AppView(), 19 | ); 20 | } 21 | } 22 | 23 | class AppView extends StatelessWidget { 24 | const AppView({super.key}); 25 | 26 | @override 27 | Widget build(BuildContext context) { 28 | return const CharacterPage(); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /lib/layers/presentation/using_cubit/list_page/cubit/character_page_cubit.dart: -------------------------------------------------------------------------------- 1 | import 'package:bloc/bloc.dart'; 2 | import 'package:equatable/equatable.dart'; 3 | import 'package:rickmorty/layers/domain/entity/character.dart'; 4 | import 'package:rickmorty/layers/domain/usecase/get_all_characters.dart'; 5 | 6 | part 'character_page_state.dart'; 7 | 8 | class CharacterPageCubit extends Cubit { 9 | CharacterPageCubit({ 10 | required GetAllCharacters getAllCharacters, 11 | }) : _getAllCharacters = getAllCharacters, 12 | super(const CharacterPageState()); 13 | 14 | final GetAllCharacters _getAllCharacters; 15 | 16 | Future fetchNextPage() async { 17 | if (state.hasReachedEnd) return; 18 | 19 | emit(state.copyWith(status: CharacterPageStatus.loading)); 20 | 21 | final list = await _getAllCharacters(page: state.currentPage); 22 | 23 | emit( 24 | state.copyWith( 25 | status: CharacterPageStatus.success, 26 | hasReachedEnd: list.isEmpty, 27 | currentPage: state.currentPage + 1, 28 | characters: List.of(state.characters)..addAll(list), 29 | ), 30 | ); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /lib/layers/domain/entity/character.dart: -------------------------------------------------------------------------------- 1 | import 'package:equatable/equatable.dart'; 2 | import 'package:rickmorty/layers/domain/entity/location.dart'; 3 | 4 | class Character with EquatableMixin { 5 | Character({ 6 | this.id, 7 | this.name, 8 | this.status, 9 | this.species, 10 | this.type, 11 | this.gender, 12 | this.origin, 13 | this.location, 14 | this.image, 15 | this.episode, 16 | this.url, 17 | this.created, 18 | }); 19 | 20 | final int? id; 21 | final String? name; 22 | final String? status; 23 | final String? species; 24 | final String? type; 25 | final String? gender; 26 | final Location? origin; 27 | final Location? location; 28 | final String? image; 29 | final List? episode; 30 | final String? url; 31 | final DateTime? created; 32 | 33 | @override 34 | List get props => [ 35 | id, 36 | name, 37 | status, 38 | species, 39 | type, 40 | gender, 41 | origin, 42 | location, 43 | image, 44 | episode, 45 | url, 46 | created, 47 | ]; 48 | 49 | bool get isAlive => status == 'Alive'; 50 | } 51 | -------------------------------------------------------------------------------- /test/lib/layers/domain/entity/character_test.dart: -------------------------------------------------------------------------------- 1 | import 'package:rickmorty/layers/domain/entity/character.dart'; 2 | import 'package:test/test.dart'; 3 | 4 | void main() { 5 | group('Character', () { 6 | test('Two instances with the same properties should be equal', () { 7 | final character1 = Character( 8 | id: 1, 9 | name: 'Rick Sanchez', 10 | status: 'Alive', 11 | species: 'Human', 12 | ); 13 | 14 | final character2 = Character( 15 | id: 1, 16 | name: 'Rick Sanchez', 17 | status: 'Alive', 18 | species: 'Human', 19 | ); 20 | 21 | expect(character1, equals(character2)); 22 | }); 23 | 24 | test('Two instances with different properties should be different', () { 25 | final character1 = Character( 26 | id: 1, 27 | name: 'Rick Sanchez', 28 | status: 'Alive', 29 | species: 'Human', 30 | ); 31 | 32 | final character2 = Character( 33 | id: 2, 34 | name: 'Morty Smith', 35 | status: 'Alive', 36 | species: 'Human', 37 | ); 38 | 39 | expect(character1, isNot(equals(character2))); 40 | }); 41 | }); 42 | } 43 | -------------------------------------------------------------------------------- /lib/layers/presentation/using_bloc/list_page/bloc/character_page_state.dart: -------------------------------------------------------------------------------- 1 | part of 'character_page_bloc.dart'; 2 | 3 | enum CharacterPageStatus { initial, loading, success, failure } 4 | 5 | class CharacterPageState extends Equatable { 6 | const CharacterPageState({ 7 | this.status = CharacterPageStatus.initial, 8 | this.characters = const [], 9 | this.hasReachedEnd = false, 10 | this.currentPage = 1, 11 | }); 12 | 13 | final CharacterPageStatus status; 14 | final List characters; 15 | final bool hasReachedEnd; 16 | final int currentPage; 17 | 18 | CharacterPageState copyWith({ 19 | CharacterPageStatus? status, 20 | List? characters, 21 | bool? hasReachedEnd, 22 | int? currentPage, 23 | }) { 24 | return CharacterPageState( 25 | status: status ?? this.status, 26 | characters: characters ?? this.characters, 27 | hasReachedEnd: hasReachedEnd ?? this.hasReachedEnd, 28 | currentPage: currentPage ?? this.currentPage, 29 | ); 30 | } 31 | 32 | @override 33 | List get props => [ 34 | status, 35 | characters, 36 | hasReachedEnd, 37 | currentPage, 38 | ]; 39 | } 40 | -------------------------------------------------------------------------------- /test/lib/layers/presentation/helper/pump_app.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:flutter_bloc/flutter_bloc.dart'; 3 | import 'package:flutter_test/flutter_test.dart'; 4 | import 'package:mockingjay/mockingjay.dart'; 5 | import 'package:rickmorty/layers/domain/usecase/get_all_characters.dart'; 6 | 7 | class GetAllCharactersMock extends Mock implements GetAllCharacters {} 8 | 9 | extension PumpApp on WidgetTester { 10 | Future pumpApp( 11 | Widget widget, { 12 | MockNavigator? navigator, 13 | GetAllCharacters? getAllCharacters, 14 | }) { 15 | final innerChild = Scaffold( 16 | body: widget, 17 | ); 18 | 19 | return pumpWidget( 20 | MultiRepositoryProvider( 21 | providers: [ 22 | RepositoryProvider.value( 23 | value: getAllCharacters ?? GetAllCharactersMock(), 24 | ), 25 | ], 26 | child: MaterialApp( 27 | home: navigator == null 28 | ? innerChild 29 | : MockNavigatorProvider( 30 | navigator: navigator, 31 | child: innerChild, 32 | ), 33 | ), 34 | ), 35 | ); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /lib/layers/presentation/using_cubit/list_page/cubit/character_page_state.dart: -------------------------------------------------------------------------------- 1 | part of 'character_page_cubit.dart'; 2 | 3 | enum CharacterPageStatus { initial, loading, success, failure } 4 | 5 | class CharacterPageState extends Equatable { 6 | const CharacterPageState({ 7 | this.status = CharacterPageStatus.initial, 8 | this.characters = const [], 9 | this.hasReachedEnd = false, 10 | this.currentPage = 1, 11 | }); 12 | 13 | final CharacterPageStatus status; 14 | final List characters; 15 | final bool hasReachedEnd; 16 | final int currentPage; 17 | 18 | CharacterPageState copyWith({ 19 | CharacterPageStatus? status, 20 | List? characters, 21 | bool? hasReachedEnd, 22 | int? currentPage, 23 | }) { 24 | return CharacterPageState( 25 | status: status ?? this.status, 26 | characters: characters ?? this.characters, 27 | hasReachedEnd: hasReachedEnd ?? this.hasReachedEnd, 28 | currentPage: currentPage ?? this.currentPage, 29 | ); 30 | } 31 | 32 | @override 33 | List get props => [ 34 | status, 35 | characters, 36 | hasReachedEnd, 37 | currentPage, 38 | ]; 39 | } 40 | -------------------------------------------------------------------------------- /lib/layers/presentation/using_get_it/list_page/controller/character_page_controller.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/foundation.dart'; 2 | import 'package:rickmorty/layers/domain/entity/character.dart'; 3 | import 'package:rickmorty/layers/domain/usecase/get_all_characters.dart'; 4 | 5 | enum CharacterPageStatus { initial, loading, success, failed } 6 | 7 | class CharacterPageController { 8 | CharacterPageController({ 9 | required GetAllCharacters getAllCharacters, 10 | }) : _getAllCharacters = getAllCharacters; 11 | 12 | final GetAllCharacters _getAllCharacters; 13 | 14 | final status = ValueNotifier(CharacterPageStatus.initial); 15 | final characters = ValueNotifier([]); 16 | final currentPage = ValueNotifier(1); 17 | final hasReachedEnd = ValueNotifier(false); 18 | 19 | Future fetchNextPage() async { 20 | if (hasReachedEnd.value) return; 21 | 22 | status.value = CharacterPageStatus.loading; 23 | 24 | final list = await _getAllCharacters(page: currentPage.value); 25 | 26 | currentPage.value = currentPage.value + 1; 27 | characters.value = characters.value..addAll(list); 28 | status.value = CharacterPageStatus.success; 29 | hasReachedEnd.value = list.isEmpty; 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /lib/layers/presentation/using_riverpod/list_page/notifier/character_page_state.dart: -------------------------------------------------------------------------------- 1 | import 'package:equatable/equatable.dart'; 2 | import 'package:rickmorty/layers/domain/entity/character.dart'; 3 | 4 | enum CharacterPageStatus { initial, loading, success, failure } 5 | 6 | class CharacterPageState extends Equatable { 7 | const CharacterPageState({ 8 | this.status = CharacterPageStatus.initial, 9 | this.characters = const [], 10 | this.hasReachedEnd = false, 11 | this.currentPage = 1, 12 | }); 13 | 14 | final CharacterPageStatus status; 15 | final List characters; 16 | final bool hasReachedEnd; 17 | final int currentPage; 18 | 19 | CharacterPageState copyWith({ 20 | CharacterPageStatus? status, 21 | List? characters, 22 | bool? hasReachedEnd, 23 | int? currentPage, 24 | }) { 25 | return CharacterPageState( 26 | status: status ?? this.status, 27 | characters: characters ?? this.characters, 28 | hasReachedEnd: hasReachedEnd ?? this.hasReachedEnd, 29 | currentPage: currentPage ?? this.currentPage, 30 | ); 31 | } 32 | 33 | @override 34 | List get props => [ 35 | status, 36 | characters, 37 | hasReachedEnd, 38 | currentPage, 39 | ]; 40 | } 41 | -------------------------------------------------------------------------------- /test/lib/layers/presentation/using_cubit/details_page/cubit/character_details_state_test.dart: -------------------------------------------------------------------------------- 1 | import 'package:rickmorty/layers/domain/entity/character.dart'; 2 | import 'package:rickmorty/layers/presentation/using_cubit/details_page/cubit/character_details_cubit.dart'; 3 | import 'package:test/test.dart'; 4 | 5 | void main() { 6 | group('CharacterDetailsState', () { 7 | test('supports value equality', () { 8 | final character1 = Character(id: 1, name: 'Test Character'); 9 | final character2 = Character(id: 1, name: 'Test Character'); 10 | 11 | final state1 = CharacterDetailsState(character1); 12 | final state2 = CharacterDetailsState(character2); 13 | 14 | // Expect the states to be equal because their characters are equal 15 | expect(state1, equals(state2)); 16 | }); 17 | 18 | test('handles different characters', () { 19 | final character1 = Character(id: 1, name: 'Test Character'); 20 | final character2 = Character(id: 2, name: 'Another Character'); 21 | 22 | final state1 = CharacterDetailsState(character1); 23 | final state2 = CharacterDetailsState(character2); 24 | 25 | // Expect the states to be different because their characters are different 26 | expect(state1, isNot(equals(state2))); 27 | }); 28 | }); 29 | } 30 | -------------------------------------------------------------------------------- /lib/layers/presentation/using_mobx/details_page/store/character_details_page_store.g.dart: -------------------------------------------------------------------------------- 1 | // GENERATED CODE - DO NOT MODIFY BY HAND 2 | 3 | part of 'character_details_page_store.dart'; 4 | 5 | // ************************************************************************** 6 | // StoreGenerator 7 | // ************************************************************************** 8 | 9 | // ignore_for_file: non_constant_identifier_names, unnecessary_brace_in_string_interps, unnecessary_lambdas, prefer_expression_function_bodies, lines_longer_than_80_chars, avoid_as, avoid_annotating_with_dynamic, no_leading_underscores_for_local_identifiers 10 | 11 | mixin _$CharacterDetailsPageStore on CharacterDetailsPageStoreBase, Store { 12 | late final _$_characterAtom = 13 | Atom(name: 'CharacterDetailsPageStoreBase._character', context: context); 14 | 15 | Character get character { 16 | _$_characterAtom.reportRead(); 17 | return super._character; 18 | } 19 | 20 | @override 21 | Character get _character => character; 22 | 23 | @override 24 | set _character(Character value) { 25 | _$_characterAtom.reportWrite(value, super._character, () { 26 | super._character = value; 27 | }); 28 | } 29 | 30 | @override 31 | String toString() { 32 | return ''' 33 | 34 | '''; 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /lib/layers/presentation/using_mobx/list_page/store/character_page_store.dart: -------------------------------------------------------------------------------- 1 | import 'package:mobx/mobx.dart'; 2 | import 'package:rickmorty/layers/domain/entity/character.dart'; 3 | import 'package:rickmorty/layers/domain/usecase/get_all_characters.dart'; 4 | 5 | part 'character_page_store.g.dart'; 6 | 7 | enum CharacterPageStatus { initial, loading, success, failed } 8 | 9 | class CharacterPageStore = CharacterPageStoreBase with _$CharacterPageStore; 10 | 11 | abstract class CharacterPageStoreBase with Store { 12 | CharacterPageStoreBase({ 13 | required GetAllCharacters getAllCharacters, 14 | }) : _getAllCharacters = getAllCharacters; 15 | 16 | final GetAllCharacters _getAllCharacters; 17 | 18 | @readonly 19 | var _contentStatus = CharacterPageStatus.initial; 20 | 21 | @readonly 22 | var _currentPage = 1; 23 | 24 | @readonly 25 | var _hasReachedEnd = false; 26 | 27 | final charactersList = ObservableList(); 28 | 29 | @action 30 | Future fetchNextPage() async { 31 | if (_hasReachedEnd) return; 32 | 33 | _contentStatus = CharacterPageStatus.loading; 34 | 35 | final list = await _getAllCharacters(page: _currentPage); 36 | 37 | _currentPage++; 38 | charactersList.addAll(list); 39 | _contentStatus = CharacterPageStatus.success; 40 | _hasReachedEnd = list.isEmpty; 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /test/lib/layers/presentation/using_bloc/details_page/bloc/character_details_state_test.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter_test/flutter_test.dart'; 2 | import 'package:rickmorty/layers/domain/entity/character.dart'; 3 | import 'package:rickmorty/layers/presentation/using_bloc/details_page/bloc/character_details_bloc.dart'; 4 | 5 | void main() { 6 | group('CharacterDetailsPAgeState', () { 7 | test('it should be able to create a new instance', () { 8 | final character = Character(id: 11); 9 | final state = CharacterDetailsState(character: character); 10 | expect(state.character, character); 11 | }); 12 | 13 | test('equivalent instances have the same props', () { 14 | final state1 = CharacterDetailsState( 15 | character: Character(id: 1, name: 'John J'), 16 | ); 17 | 18 | final state2 = CharacterDetailsState( 19 | character: Character(id: 1, name: 'John J'), 20 | ); 21 | 22 | expect(state1, state2); 23 | }); 24 | 25 | test('distinct instances have different props', () { 26 | final state1 = CharacterDetailsState( 27 | character: Character(id: 1, name: 'John J'), 28 | ); 29 | 30 | final state2 = CharacterDetailsState( 31 | character: Character(id: 1, name: 'John M'), 32 | ); 33 | 34 | expect(state1, isNot(equals(state2))); 35 | }); 36 | }); 37 | } 38 | -------------------------------------------------------------------------------- /test/lib/layers/data/dto/character_dto_test.dart: -------------------------------------------------------------------------------- 1 | import 'package:rickmorty/layers/data/dto/character_dto.dart'; 2 | import 'package:rickmorty/layers/data/dto/location_dto.dart'; 3 | import 'package:test/test.dart'; 4 | 5 | void main() { 6 | group('CharacterDto', () { 7 | late String referenceRawJson; 8 | late CharacterDto referenceDto; 9 | 10 | setUp(() { 11 | referenceDto = CharacterDto( 12 | id: 1, 13 | name: 'Rick Sanchez', 14 | status: 'Alive', 15 | species: 'Human', 16 | type: 'Super genius', 17 | gender: 'Male', 18 | origin: LocationDto(name: 'Earth', url: 'https://example.com/earth'), 19 | location: LocationDto(name: 'Earth', url: 'https://example.com/earth'), 20 | image: 'https://example.com/rick.png', 21 | episode: [ 22 | 'https://example.com/episode1', 23 | 'https://example.com/episode2', 24 | ], 25 | url: 'https://example.com/character/1', 26 | created: DateTime.parse('2022-01-01T12:00:00Z'), 27 | ); 28 | 29 | referenceRawJson = referenceDto.toRawJson(); 30 | }); 31 | 32 | test('should create CharacterDto instance to/from JSON', () { 33 | final createdDto = CharacterDto.fromRawJson(referenceRawJson); 34 | final json = createdDto.toRawJson(); 35 | expect(createdDto, referenceDto); 36 | expect(json, referenceRawJson); 37 | }); 38 | }); 39 | } 40 | -------------------------------------------------------------------------------- /lib/layers/presentation/using_mobx/app_using_mobx.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:flutter_bloc/flutter_bloc.dart'; 3 | import 'package:rickmorty/layers/domain/usecase/get_all_characters.dart'; 4 | 5 | import 'package:rickmorty/layers/presentation/using_mobx/list_page/view/character_page.dart'; 6 | 7 | class AppUsingMobX extends StatelessWidget { 8 | const AppUsingMobX({super.key, required this.getAllCharacters}); 9 | 10 | final GetAllCharacters getAllCharacters; 11 | 12 | @override 13 | Widget build(BuildContext context) { 14 | // 15 | // It Provides UseCases down to the widget tree using Bloc's D.I widget. 16 | // 17 | // MobX doesn't have a widget that allow us to provide dependencies, 18 | // because of that it's hard to decouple things properly when using it. 19 | // E.g.: Inject dependencies in Store's constructors. 20 | // 21 | // MobX recommendation is to use 'Provider': 22 | // https://mobx.netlify.app/guides/stores#the-triad-of-widget---store---service 23 | // But here we've used Bloc's Repository widget 24 | // 25 | return RepositoryProvider.value( 26 | value: getAllCharacters, 27 | child: const AppView(), 28 | ); 29 | } 30 | } 31 | 32 | class AppView extends StatelessWidget { 33 | const AppView({super.key}); 34 | 35 | @override 36 | Widget build(BuildContext context) { 37 | return const CharacterPage(); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /lib/layers/data/source/network/api.dart: -------------------------------------------------------------------------------- 1 | // ignore_for_file: avoid_print 2 | 3 | import 'package:dio/dio.dart'; 4 | import 'package:rickmorty/layers/data/dto/character_dto.dart'; 5 | 6 | abstract class Api { 7 | Future> loadCharacters({int page = 0}); 8 | } 9 | 10 | class ApiImpl implements Api { 11 | final dio = Dio(); 12 | 13 | @override 14 | Future> loadCharacters({int page = 0}) async { 15 | try { 16 | final Response> response = await dio 17 | .get('https://rickandmortyapi.com/api/character/?page=$page'); 18 | print('page'); 19 | final l = (response.data!['results'] as List) 20 | .map((e) => CharacterDto.fromMap(e)) 21 | .toList(); 22 | return l; 23 | } on DioException catch (e) { 24 | // The request was made and the server responded with a status code 25 | // that falls out of the range of 2xx and is also not 304. 26 | if (e.response != null) { 27 | print(e.response?.data); 28 | print(e.response?.headers); 29 | print(e.response?.requestOptions); 30 | 31 | // API responds with 404 when reached the end 32 | if (e.response?.statusCode == 404) return []; 33 | } else { 34 | // Something happened in setting up or sending the request that triggered an Error 35 | print(e); 36 | } 37 | } 38 | 39 | return []; 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /lib/layers/presentation/using_riverpod/list_page/notifier/character_state_notifier.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter_riverpod/flutter_riverpod.dart'; 2 | import 'package:rickmorty/layers/domain/usecase/get_all_characters.dart'; 3 | import 'package:rickmorty/layers/presentation/using_riverpod/list_page/notifier/character_page_state.dart'; 4 | import 'package:rickmorty/layers/presentation/using_riverpod/providers.dart'; 5 | 6 | final characterPageStateProvider = 7 | StateNotifierProvider( 8 | (ref) => CharacterStateNotifier( 9 | getAllCharacters: ref.read(getAllCharactersProvider), 10 | ), 11 | ); 12 | 13 | class CharacterStateNotifier extends StateNotifier { 14 | CharacterStateNotifier({ 15 | required GetAllCharacters getAllCharacters, 16 | }) : _getAllCharacters = getAllCharacters, 17 | super(const CharacterPageState()); 18 | 19 | final GetAllCharacters _getAllCharacters; 20 | 21 | Future fetchNextPage() async { 22 | if (state.hasReachedEnd) return; 23 | 24 | state = state.copyWith(status: CharacterPageStatus.loading); 25 | 26 | final list = await _getAllCharacters(page: state.currentPage); 27 | state = state.copyWith( 28 | status: CharacterPageStatus.loading, 29 | currentPage: state.currentPage + 1, 30 | characters: List.of(state.characters)..addAll(list), 31 | hasReachedEnd: list.isEmpty, 32 | ); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /test/lib/layers/domain/usecase/get_all_characters_test.dart: -------------------------------------------------------------------------------- 1 | import 'package:mocktail/mocktail.dart'; 2 | import 'package:rickmorty/layers/domain/entity/character.dart'; 3 | import 'package:rickmorty/layers/domain/repository/character_repository.dart'; 4 | import 'package:rickmorty/layers/domain/usecase/get_all_characters.dart'; 5 | import 'package:test/test.dart'; 6 | 7 | class MockCharacterRepository extends Mock implements CharacterRepository {} 8 | 9 | void main() { 10 | late GetAllCharacters getAllCharacters; 11 | late MockCharacterRepository mockCharacterRepository; 12 | 13 | setUp(() { 14 | mockCharacterRepository = MockCharacterRepository(); 15 | getAllCharacters = GetAllCharacters(repository: mockCharacterRepository); 16 | }); 17 | 18 | group('GetAllCharacters', () { 19 | test('call should return a list of characters', () async { 20 | const page = 0; 21 | final characters = [ 22 | Character(id: 1, name: 'Rick Sanchez'), 23 | Character(id: 2, name: 'Morty Smith'), 24 | ]; 25 | 26 | when(() => mockCharacterRepository.getCharacters(page: page)) 27 | .thenAnswer((_) async => characters); 28 | 29 | final result = await getAllCharacters.call(page: page); 30 | 31 | expect(result, equals(characters)); 32 | 33 | verify(() => mockCharacterRepository.getCharacters(page: page)).called(1); 34 | verifyNoMoreInteractions(mockCharacterRepository); 35 | }); 36 | }); 37 | } 38 | -------------------------------------------------------------------------------- /test/lib/layers/presentation/using_riverpod/list_page/view/character_page_test.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter_riverpod/flutter_riverpod.dart'; 2 | import 'package:flutter_test/flutter_test.dart'; 3 | import 'package:mocktail/mocktail.dart'; 4 | import 'package:rickmorty/layers/presentation/using_riverpod/providers.dart'; 5 | import 'package:rickmorty/layers/presentation/using_riverpod/list_page/view/character_page.dart'; 6 | 7 | import '../../../../../../fixtures/fixtures.dart'; 8 | import '../../../helper/pump_app.dart'; 9 | 10 | void main() { 11 | group('CharacterPage', () { 12 | late GetAllCharactersMock getAllCharactersMock; 13 | 14 | setUp(() { 15 | getAllCharactersMock = GetAllCharactersMock(); 16 | when(() => getAllCharactersMock.call(page: any(named: 'page'))) 17 | .thenAnswer((_) async => [...characterList1, ...characterList2]); 18 | }); 19 | 20 | testWidgets('renders a CharacterView', (tester) async { 21 | when(() => getAllCharactersMock(page: any(named: 'page'))).thenAnswer( 22 | (_) async => characterList1, 23 | ); 24 | 25 | await tester.pumpApp( 26 | ProviderScope( 27 | overrides: [ 28 | getAllCharactersProvider 29 | .overrideWith((ref) => getAllCharactersMock), 30 | ], 31 | child: const CharacterPage(), 32 | ), 33 | ); 34 | 35 | expect(find.byType(CharacterView), findsOneWidget); 36 | }); 37 | }); 38 | } 39 | -------------------------------------------------------------------------------- /lib/layers/data/source/local/local_storage.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/foundation.dart'; 2 | import 'package:rickmorty/layers/data/dto/character_dto.dart'; 3 | import 'package:shared_preferences/shared_preferences.dart'; 4 | 5 | const cachedCharacterListKey = 'CACHED_CHARACTER_LIST_PAGE'; 6 | 7 | abstract class LocalStorage { 8 | Future saveCharactersPage({ 9 | required int page, 10 | required List list, 11 | }); 12 | 13 | List loadCharactersPage({required int page}); 14 | } 15 | 16 | class LocalStorageImpl implements LocalStorage { 17 | final SharedPreferences _sharedPref; 18 | 19 | LocalStorageImpl({ 20 | required SharedPreferences sharedPreferences, 21 | }) : _sharedPref = sharedPreferences; 22 | 23 | @override 24 | List loadCharactersPage({required int page}) { 25 | final key = getKeyToPage(page); 26 | final jsonList = _sharedPref.getStringList(key); 27 | 28 | return jsonList != null 29 | ? jsonList.map((e) => CharacterDto.fromRawJson(e)).toList() 30 | : []; 31 | } 32 | 33 | @override 34 | Future saveCharactersPage({ 35 | required int page, 36 | required List list, 37 | }) { 38 | final jsonList = list.map((e) => e.toRawJson()).toList(); 39 | final key = getKeyToPage(page); 40 | return _sharedPref.setStringList(key, jsonList); 41 | } 42 | 43 | @visibleForTesting 44 | static String getKeyToPage(int page) { 45 | return '${cachedCharacterListKey}_$page'; 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /lib/layers/data/dto/location_dto.dart: -------------------------------------------------------------------------------- 1 | import 'dart:convert'; 2 | 3 | import 'package:rickmorty/layers/domain/entity/location.dart'; 4 | 5 | class LocationDto extends Location { 6 | LocationDto({ 7 | super.name, 8 | super.url, 9 | }); 10 | 11 | // --------------------------------------------------------------------------- 12 | // JSON 13 | // --------------------------------------------------------------------------- 14 | factory LocationDto.fromRawJson(String str) => 15 | LocationDto.fromMap(json.decode(str)); 16 | 17 | String toRawJson() => json.encode(toMap()); 18 | 19 | // --------------------------------------------------------------------------- 20 | // Maps 21 | // --------------------------------------------------------------------------- 22 | factory LocationDto.fromMap(Map json) => LocationDto( 23 | name: json['name'], 24 | url: json['url'], 25 | ); 26 | 27 | Map toMap() => { 28 | 'name': name, 29 | 'url': url, 30 | }; 31 | 32 | // --------------------------------------------------------------------------- 33 | // Domain 34 | // --------------------------------------------------------------------------- 35 | static LocationDto fromLocation(Location location) { 36 | return LocationDto( 37 | name: location.name, 38 | url: location.url, 39 | ); 40 | } 41 | 42 | Location toLocation() { 43 | return Location( 44 | name: name, 45 | url: url, 46 | ); 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /ios/Podfile: -------------------------------------------------------------------------------- 1 | # Uncomment this line to define a global platform for your project 2 | # platform :ios, '11.0' 3 | 4 | # CocoaPods analytics sends network stats synchronously affecting flutter build latency. 5 | ENV['COCOAPODS_DISABLE_STATS'] = 'true' 6 | 7 | project 'Runner', { 8 | 'Debug' => :debug, 9 | 'Profile' => :release, 10 | 'Release' => :release, 11 | } 12 | 13 | def flutter_root 14 | generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'Generated.xcconfig'), __FILE__) 15 | unless File.exist?(generated_xcode_build_settings_path) 16 | raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure flutter pub get is executed first" 17 | end 18 | 19 | File.foreach(generated_xcode_build_settings_path) do |line| 20 | matches = line.match(/FLUTTER_ROOT\=(.*)/) 21 | return matches[1].strip if matches 22 | end 23 | raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Generated.xcconfig, then run flutter pub get" 24 | end 25 | 26 | require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root) 27 | 28 | flutter_ios_podfile_setup 29 | 30 | target 'Runner' do 31 | use_frameworks! 32 | use_modular_headers! 33 | 34 | flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__)) 35 | target 'RunnerTests' do 36 | inherit! :search_paths 37 | end 38 | end 39 | 40 | post_install do |installer| 41 | installer.pods_project.targets.each do |target| 42 | flutter_additional_ios_build_settings(target) 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /lib/layers/presentation/using_riverpod/providers.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter_riverpod/flutter_riverpod.dart'; 2 | import 'package:rickmorty/layers/data/character_repository_impl.dart'; 3 | import 'package:rickmorty/layers/data/source/local/local_storage.dart'; 4 | import 'package:rickmorty/layers/data/source/network/api.dart'; 5 | import 'package:rickmorty/layers/domain/repository/character_repository.dart'; 6 | import 'package:rickmorty/layers/domain/usecase/get_all_characters.dart'; 7 | import 'package:rickmorty/main.dart'; 8 | 9 | // ----------------------------------------------------------------------------- 10 | // Presentation 11 | // ----------------------------------------------------------------------------- 12 | 13 | // ----------------------------------------------------------------------------- 14 | // Domain 15 | // ----------------------------------------------------------------------------- 16 | final characterRepositoryProvider = Provider( 17 | (ref) => CharacterRepositoryImpl( 18 | api: ref.read(apiProvider), 19 | localStorage: ref.read(localStorageProvider), 20 | ), 21 | ); 22 | 23 | final getAllCharactersProvider = Provider( 24 | (ref) => GetAllCharacters( 25 | repository: ref.read(characterRepositoryProvider), 26 | ), 27 | ); 28 | 29 | // ----------------------------------------------------------------------------- 30 | // Data 31 | // ----------------------------------------------------------------------------- 32 | final apiProvider = Provider((ref) => ApiImpl()); 33 | 34 | final localStorageProvider = Provider( 35 | (ref) => LocalStorageImpl(sharedPreferences: sharedPref), 36 | ); 37 | -------------------------------------------------------------------------------- /ios/Runner/Base.lproj/Main.storyboard: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | -------------------------------------------------------------------------------- /lib/layers/presentation/shared/character_list_item_header.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | 3 | class CharacterListItemHeader extends StatelessWidget { 4 | const CharacterListItemHeader({ 5 | super.key, 6 | required this.titleText, 7 | }); 8 | 9 | final String titleText; 10 | 11 | @override 12 | Widget build(BuildContext context) { 13 | final cs = Theme.of(context).colorScheme; 14 | final tt = Theme.of(context).textTheme; 15 | 16 | return Column( 17 | children: [ 18 | Card( 19 | elevation: 0, 20 | color: cs.tertiaryContainer, 21 | child: SizedBox( 22 | height: 60, 23 | child: Padding( 24 | padding: const EdgeInsets.all(8.0), 25 | child: Row( 26 | children: [ 27 | const SizedBox(width: 12), 28 | Icon( 29 | Icons.person, 30 | color: cs.onTertiaryContainer, 31 | size: 18, 32 | ), 33 | const SizedBox(width: 12), 34 | Text( 35 | titleText, 36 | style: tt.titleMedium!.copyWith( 37 | color: cs.onTertiaryContainer, 38 | fontWeight: FontWeight.bold, 39 | ), 40 | ), 41 | ], 42 | ), 43 | ), 44 | ), 45 | ), 46 | Padding( 47 | padding: const EdgeInsets.all(8.0), 48 | child: Divider( 49 | height: 1, 50 | color: cs.tertiaryContainer, 51 | ), 52 | ), 53 | ], 54 | ); 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /lib/layers/presentation/using_bloc/list_page/bloc/character_page_bloc.dart: -------------------------------------------------------------------------------- 1 | import 'package:bloc/bloc.dart'; 2 | import 'package:bloc_concurrency/bloc_concurrency.dart'; 3 | import 'package:equatable/equatable.dart'; 4 | import 'package:rickmorty/layers/domain/entity/character.dart'; 5 | import 'package:rickmorty/layers/domain/usecase/get_all_characters.dart'; 6 | import 'package:stream_transform/stream_transform.dart'; 7 | 8 | part 'character_page_event.dart'; 9 | 10 | part 'character_page_state.dart'; 11 | 12 | EventTransformer throttleDroppable(Duration duration) { 13 | return (events, mapper) { 14 | return droppable().call(events.throttle(duration), mapper); 15 | }; 16 | } 17 | 18 | class CharacterPageBloc extends Bloc { 19 | CharacterPageBloc({ 20 | required GetAllCharacters getAllCharacters, 21 | }) : _getAllCharacters = getAllCharacters, 22 | super(const CharacterPageState()) { 23 | on( 24 | _fetchNextPage, 25 | transformer: throttleDroppable(const Duration(milliseconds: 100)), 26 | ); 27 | } 28 | 29 | final GetAllCharacters _getAllCharacters; 30 | 31 | Future _fetchNextPage(event, Emitter emit) async { 32 | if (state.hasReachedEnd) return; 33 | 34 | emit(state.copyWith(status: CharacterPageStatus.loading)); 35 | 36 | final list = await _getAllCharacters(page: state.currentPage); 37 | 38 | emit( 39 | state.copyWith( 40 | status: CharacterPageStatus.success, 41 | characters: List.of(state.characters)..addAll(list), 42 | hasReachedEnd: list.isEmpty, 43 | currentPage: state.currentPage + 1, 44 | ), 45 | ); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /test/lib/layers/presentation/using_provider/details_page/view/character_details_page_test.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:flutter_test/flutter_test.dart'; 3 | import 'package:provider/provider.dart'; 4 | import 'package:rickmorty/layers/presentation/using_provider/details_page/change_notifier/character_details_change_notifier.dart'; 5 | import 'package:rickmorty/layers/presentation/using_provider/details_page/view/character_details_page.dart'; 6 | 7 | import '../../../../../../fixtures/fixtures.dart'; 8 | 9 | void main() { 10 | testWidgets('CharacterDetailsPage should render correctly', (WidgetTester tester) async { 11 | final character = characterList1.first; 12 | 13 | // Build our app and trigger a frame. 14 | await tester.pumpWidget( 15 | MaterialApp( 16 | home: ChangeNotifierProvider( 17 | create: (_) => CharacterDetailsChangeNotifier(character: character), 18 | child: const CharacterDetailsPage(), 19 | ), 20 | ), 21 | ); 22 | 23 | // Find items on the page 24 | expect(find.text('Details'), findsOneWidget); 25 | expect(find.text(character.name!), findsOneWidget); 26 | expect(find.text('Origin: ${character.origin!.name}'), findsOneWidget); 27 | expect(find.text('Species: ${character.species}'), findsOneWidget); 28 | expect(find.text('Type: ${character.type}'), findsOneWidget); 29 | expect(find.text('Gender: ${character.gender}'), findsOneWidget); 30 | expect(find.text('Status: ${character.isAlive ? 'ALIVE!' : 'DEAD!'}'), findsOneWidget); 31 | expect(find.text('Last location: ${character.location!.name}'), findsOneWidget); 32 | expectLater(find.byType(EpisodeItem), findsNWidgets(character.episode!.length)); 33 | }); 34 | } 35 | -------------------------------------------------------------------------------- /android/app/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 6 | 14 | 18 | 22 | 23 | 24 | 25 | 26 | 27 | 29 | 32 | 33 | 34 | -------------------------------------------------------------------------------- /test/lib/layers/presentation/using_mobx/list_page/view/character_page_test.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:flutter_test/flutter_test.dart'; 3 | import 'package:mocktail/mocktail.dart'; 4 | import 'package:rickmorty/layers/presentation/shared/character_list_item.dart'; 5 | import 'package:rickmorty/layers/presentation/using_mobx/list_page/view/character_page.dart'; 6 | 7 | import '../../../../../../fixtures/fixtures.dart'; 8 | import '../../../helper/pump_app.dart'; 9 | 10 | void main() { 11 | group('CharacterPage', () { 12 | late GetAllCharactersMock getAllCharactersMock; 13 | 14 | setUp(() async { 15 | getAllCharactersMock = GetAllCharactersMock(); 16 | when(() => getAllCharactersMock.call(page: any(named: 'page'))) 17 | .thenAnswer((_) async => [...characterList1, ...characterList2]); 18 | }); 19 | 20 | testWidgets('renders a CharacterView', (tester) async { 21 | await tester.pumpApp( 22 | const CharacterPage(), 23 | getAllCharacters: getAllCharactersMock, 24 | ); 25 | 26 | expect(find.byType(CharacterView), findsOneWidget); 27 | }); 28 | 29 | testWidgets('renders a list of Characters widgets', (tester) async { 30 | const key = Key('character_page_list_key'); 31 | 32 | try { 33 | await tester.pumpApp( 34 | const CharacterPage(), 35 | getAllCharacters: getAllCharactersMock, 36 | ); 37 | 38 | await tester.pumpAndSettle(); 39 | } catch (e) { 40 | // ignore loading at the bottom 41 | } 42 | 43 | expect(find.byKey(key), findsOneWidget); 44 | expectLater( 45 | find.byType(CharacterListItem), 46 | findsNWidgets([...characterList1, ...characterList2].length), 47 | ); 48 | }); 49 | }); 50 | } 51 | -------------------------------------------------------------------------------- /test/lib/layers/presentation/using_bloc/details_page/view/character_details_page_test.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:flutter_bloc/flutter_bloc.dart'; 3 | import 'package:flutter_test/flutter_test.dart'; 4 | import 'package:rickmorty/layers/presentation/using_bloc/details_page/bloc/character_details_bloc.dart'; 5 | import 'package:rickmorty/layers/presentation/using_bloc/details_page/view/character_details_page.dart'; 6 | 7 | import '../../../../../../fixtures/fixtures.dart'; 8 | 9 | void main() { 10 | testWidgets('CharacterDetailsPage should render correctly', 11 | (WidgetTester tester) async { 12 | final character = characterList1.first; 13 | 14 | // Build our app and trigger a frame. 15 | await tester.pumpWidget( 16 | MaterialApp( 17 | home: BlocProvider.value( 18 | value: CharacterDetailsBloc(character: character), 19 | child: const CharacterDetailsPage(), 20 | ), 21 | ), 22 | ); 23 | 24 | // Find items on the page 25 | expect(find.text('Details'), findsOneWidget); 26 | expect(find.text(character.name!), findsOneWidget); 27 | expect(find.text('Origin: ${character.origin!.name}'), findsOneWidget); 28 | expect(find.text('Species: ${character.species}'), findsOneWidget); 29 | expect(find.text('Type: ${character.type}'), findsOneWidget); 30 | expect(find.text('Gender: ${character.gender}'), findsOneWidget); 31 | expect( 32 | find.text('Status: ${character.isAlive ? 'ALIVE!' : 'DEAD!'}'), 33 | findsOneWidget, 34 | ); 35 | expect( 36 | find.text('Last location: ${character.location!.name}'), 37 | findsOneWidget, 38 | ); 39 | expectLater( 40 | find.byType(EpisodeItem), 41 | findsNWidgets(character.episode!.length), 42 | ); 43 | }); 44 | } 45 | -------------------------------------------------------------------------------- /test/lib/layers/presentation/using_provider/list_page/view/character_page_test.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:flutter_test/flutter_test.dart'; 3 | import 'package:mocktail/mocktail.dart'; 4 | import 'package:rickmorty/layers/presentation/shared/character_list_item.dart'; 5 | import 'package:rickmorty/layers/presentation/using_provider/list_page/view/character_page.dart'; 6 | 7 | import '../../../../../../fixtures/fixtures.dart'; 8 | import '../../../helper/pump_app.dart'; 9 | 10 | void main() { 11 | group('CharacterPage', () { 12 | late GetAllCharactersMock getAllCharactersMock; 13 | 14 | setUp(() async { 15 | getAllCharactersMock = GetAllCharactersMock(); 16 | when(() => getAllCharactersMock.call(page: any(named: 'page'))) 17 | .thenAnswer((_) async => [...characterList1, ...characterList2]); 18 | }); 19 | 20 | testWidgets('renders a CharacterView', (tester) async { 21 | await tester.pumpApp( 22 | const CharacterPage(), 23 | getAllCharacters: getAllCharactersMock, 24 | ); 25 | 26 | expect(find.byType(CharacterView), findsOneWidget); 27 | }); 28 | 29 | testWidgets('renders a list of Characters widgets', (tester) async { 30 | const key = Key('character_page_list_key'); 31 | 32 | try { 33 | await tester.pumpApp( 34 | const CharacterPage(), 35 | getAllCharacters: getAllCharactersMock, 36 | ); 37 | 38 | await tester.pumpAndSettle(); 39 | } catch (e) { 40 | // ignore loading at the bottom 41 | } 42 | 43 | expect(find.byKey(key), findsOneWidget); 44 | expectLater( 45 | find.byType(CharacterListItem), 46 | findsNWidgets([...characterList1, ...characterList2].length), 47 | ); 48 | }); 49 | }); 50 | } 51 | -------------------------------------------------------------------------------- /test/lib/layers/presentation/using_cubit/details_page/view/character_details_page_test.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:flutter_bloc/flutter_bloc.dart'; 3 | import 'package:flutter_test/flutter_test.dart'; 4 | import 'package:rickmorty/layers/presentation/using_cubit/details_page/cubit/character_details_cubit.dart'; 5 | import 'package:rickmorty/layers/presentation/using_cubit/details_page/view/character_details_page.dart'; 6 | 7 | import '../../../../../../fixtures/fixtures.dart'; 8 | 9 | void main() { 10 | testWidgets('CharacterDetailsPage should render correctly', 11 | (WidgetTester tester) async { 12 | final character = characterList1.first; 13 | 14 | // Build our app and trigger a frame. 15 | await tester.pumpWidget( 16 | MaterialApp( 17 | home: BlocProvider.value( 18 | value: CharacterDetailsCubit(character: character), 19 | child: const CharacterDetailsPage(), 20 | ), 21 | ), 22 | ); 23 | 24 | // Find items on the page 25 | expect(find.text('Details'), findsOneWidget); 26 | expect(find.text(character.name!), findsOneWidget); 27 | expect(find.text('Origin: ${character.origin!.name}'), findsOneWidget); 28 | expect(find.text('Species: ${character.species}'), findsOneWidget); 29 | expect(find.text('Type: ${character.type}'), findsOneWidget); 30 | expect(find.text('Gender: ${character.gender}'), findsOneWidget); 31 | expect( 32 | find.text('Status: ${character.isAlive ? 'ALIVE!' : 'DEAD!'}'), 33 | findsOneWidget, 34 | ); 35 | expect( 36 | find.text('Last location: ${character.location!.name}'), 37 | findsOneWidget, 38 | ); 39 | expectLater( 40 | find.byType(EpisodeItem), 41 | findsNWidgets(character.episode!.length), 42 | ); 43 | }); 44 | } 45 | -------------------------------------------------------------------------------- /ios/Runner/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 7 | CFBundleDisplayName 8 | Rickmorty 9 | CFBundleExecutable 10 | $(EXECUTABLE_NAME) 11 | CFBundleIdentifier 12 | $(PRODUCT_BUNDLE_IDENTIFIER) 13 | CFBundleInfoDictionaryVersion 14 | 6.0 15 | CFBundleName 16 | rickmorty 17 | CFBundlePackageType 18 | APPL 19 | CFBundleShortVersionString 20 | $(FLUTTER_BUILD_NAME) 21 | CFBundleSignature 22 | ???? 23 | CFBundleVersion 24 | $(FLUTTER_BUILD_NUMBER) 25 | LSRequiresIPhoneOS 26 | 27 | UILaunchStoryboardName 28 | LaunchScreen 29 | UIMainStoryboardFile 30 | Main 31 | UISupportedInterfaceOrientations 32 | 33 | UIInterfaceOrientationPortrait 34 | UIInterfaceOrientationLandscapeLeft 35 | UIInterfaceOrientationLandscapeRight 36 | 37 | UISupportedInterfaceOrientations~ipad 38 | 39 | UIInterfaceOrientationPortrait 40 | UIInterfaceOrientationPortraitUpsideDown 41 | UIInterfaceOrientationLandscapeLeft 42 | UIInterfaceOrientationLandscapeRight 43 | 44 | UIViewControllerBasedStatusBarAppearance 45 | 46 | CADisableMinimumFrameDurationOnPhone 47 | 48 | UIApplicationSupportsIndirectInputEvents 49 | 50 | 51 | 52 | -------------------------------------------------------------------------------- /test/lib/layers/presentation/using_get_it/details_page/view/character_details_page_test.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:flutter_test/flutter_test.dart'; 3 | import 'package:rickmorty/layers/presentation/using_get_it/details_page/controller/character_details_controller.dart'; 4 | import 'package:rickmorty/layers/presentation/using_get_it/details_page/view/character_details_page.dart'; 5 | import 'package:rickmorty/layers/presentation/using_get_it/injector.dart'; 6 | 7 | import '../../../../../../fixtures/fixtures.dart'; 8 | 9 | void main() { 10 | final character = characterList1.first; 11 | 12 | setUp(() async { 13 | await getIt.reset(); 14 | getIt.registerLazySingleton( 15 | () => CharacterDetailsController()..character = character, 16 | ); 17 | }); 18 | 19 | testWidgets( 20 | 'CharacterDetailsPage should renders correctly', 21 | (tester) async { 22 | await tester.pumpWidget( 23 | const MaterialApp( 24 | home: CharacterDetailsPage(), 25 | ), 26 | ); 27 | 28 | // Find items on the page 29 | expect(find.text('Details'), findsOneWidget); 30 | expect(find.text(character.name!), findsOneWidget); 31 | expect(find.text('Origin: ${character.origin!.name}'), findsOneWidget); 32 | expect(find.text('Species: ${character.species}'), findsOneWidget); 33 | expect(find.text('Type: ${character.type}'), findsOneWidget); 34 | expect(find.text('Gender: ${character.gender}'), findsOneWidget); 35 | expect( 36 | find.text('Status: ${character.isAlive ? 'ALIVE!' : 'DEAD!'}'), 37 | findsOneWidget, 38 | ); 39 | expect( 40 | find.text('Last location: ${character.location!.name}'), 41 | findsOneWidget, 42 | ); 43 | expectLater( 44 | find.byType(EpisodeItem), 45 | findsNWidgets(character.episode!.length), 46 | ); 47 | }, 48 | ); 49 | } 50 | -------------------------------------------------------------------------------- /lib/layers/presentation/assets.dart: -------------------------------------------------------------------------------- 1 | import 'dart:ui'; 2 | 3 | class Asset { 4 | // --------------------------------------------------------------------------- 5 | // Images 6 | // --------------------------------------------------------------------------- 7 | static const String _img = 'assets/images'; 8 | 9 | static const String bgBase = '$_img/bg-base.jpg'; 10 | static const String bgLightReceive = '$_img/bg-light-receive.png'; 11 | static const String mgBaseBottom = '$_img/mg-base-bottom.png'; 12 | static const String mgLightReceiveBottom = 13 | '$_img/mg-light-receive-bottom.png'; 14 | static const String mgLightEmitBottom = '$_img/mg-light-emit-bottom.png'; 15 | static const String fgBase = '$_img/fg-base.png'; 16 | static const String fgLightReceive = '$_img/fg-light-receive.png'; 17 | static const String fgEmit = '$_img/fg-emit.png'; 18 | 19 | static const String cardBorderBottomLeft = '$_img/border-bottom-left.png'; 20 | static const String cardBorderTopRight = '$_img/border-top-right.png'; 21 | static const String cardBorderTopLeft = '$_img/border-top-left.png'; 22 | static const String cardBorderBottomRight = '$_img/border-bottom-right.png'; 23 | 24 | // --------------------------------------------------------------------------- 25 | // Shaders 26 | // --------------------------------------------------------------------------- 27 | static const String _shaders = 'assets/shaders'; 28 | static const String uiShader = '$_shaders/ui_glitch.frag'; 29 | } 30 | 31 | // ----------------------------------------------------------------------------- 32 | // Fragments 33 | // ----------------------------------------------------------------------------- 34 | typedef FragmentPrograms = ({FragmentProgram ui}); 35 | 36 | Future loadFragmentPrograms() async => 37 | (ui: (await _loadFragmentProgram(Asset.uiShader)),); 38 | 39 | Future _loadFragmentProgram(String path) async { 40 | return (await FragmentProgram.fromAsset(path)); 41 | } 42 | -------------------------------------------------------------------------------- /web/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | rickmorty 33 | 34 | 35 | 39 | 40 | 41 | 42 | 43 | 58 | 59 | 60 | -------------------------------------------------------------------------------- /lib/layers/presentation/using_get_it/injector.dart: -------------------------------------------------------------------------------- 1 | import 'package:get_it/get_it.dart'; 2 | import 'package:rickmorty/layers/data/character_repository_impl.dart'; 3 | import 'package:rickmorty/layers/data/source/local/local_storage.dart'; 4 | import 'package:rickmorty/layers/data/source/network/api.dart'; 5 | import 'package:rickmorty/layers/domain/repository/character_repository.dart'; 6 | import 'package:rickmorty/layers/domain/usecase/get_all_characters.dart'; 7 | import 'package:rickmorty/layers/presentation/using_get_it/details_page/controller/character_details_controller.dart'; 8 | import 'package:rickmorty/layers/presentation/using_get_it/list_page/controller/character_page_controller.dart'; 9 | import 'package:rickmorty/main.dart'; 10 | 11 | GetIt getIt = GetIt.instance; 12 | 13 | Future initializeGetIt() async { 14 | // --------------------------------------------------------------------------- 15 | // DATA Layer 16 | // --------------------------------------------------------------------------- 17 | getIt.registerLazySingleton(() => ApiImpl()); 18 | getIt.registerFactory( 19 | () => LocalStorageImpl( 20 | sharedPreferences: sharedPref, 21 | ), 22 | ); 23 | 24 | getIt.registerFactory( 25 | () => CharacterRepositoryImpl( 26 | api: getIt(), 27 | localStorage: getIt(), 28 | ), 29 | ); 30 | 31 | // --------------------------------------------------------------------------- 32 | // DOMAIN Layer 33 | // --------------------------------------------------------------------------- 34 | getIt.registerFactory( 35 | () => GetAllCharacters( 36 | repository: getIt(), 37 | ), 38 | ); 39 | 40 | // --------------------------------------------------------------------------- 41 | // PRESENTATION Layer 42 | // --------------------------------------------------------------------------- 43 | getIt.registerLazySingleton( 44 | () => CharacterPageController(getAllCharacters: getIt()), 45 | ); 46 | getIt.registerLazySingleton( 47 | () => CharacterDetailsController(), 48 | ); 49 | } 50 | -------------------------------------------------------------------------------- /lib/layers/presentation/using_provider/list_page/change_notifier/character_page_change_notifier.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:rickmorty/layers/domain/entity/character.dart'; 3 | import 'package:rickmorty/layers/domain/usecase/get_all_characters.dart'; 4 | 5 | enum CharacterPageStatus { initial, loading, success, failed } 6 | 7 | class CharacterPageChangeNotifier extends ChangeNotifier { 8 | CharacterPageChangeNotifier({ 9 | required GetAllCharacters getAllCharacters, 10 | List? characters, 11 | CharacterPageStatus? initialStatus, 12 | int? initialPage, 13 | }) : _getAllCharacters = getAllCharacters, 14 | _characters = characters ?? [], 15 | _status = initialStatus ?? CharacterPageStatus.initial, 16 | _currentPage = initialPage ?? 1; 17 | 18 | // --------------------------------------------------------------------------- 19 | // Use cases 20 | // --------------------------------------------------------------------------- 21 | final GetAllCharacters _getAllCharacters; 22 | 23 | // --------------------------------------------------------------------------- 24 | // Properties 25 | // --------------------------------------------------------------------------- 26 | CharacterPageStatus _status; 27 | CharacterPageStatus get status => _status; 28 | 29 | final List _characters; 30 | List get characters => List.unmodifiable(_characters); 31 | 32 | int _currentPage; 33 | int get currentPage => _currentPage; 34 | 35 | var _hasReachedEnd = false; 36 | bool get hasReachedEnd => _hasReachedEnd; 37 | 38 | // --------------------------------------------------------------------------- 39 | // Actions 40 | // --------------------------------------------------------------------------- 41 | Future fetchNextPage() async { 42 | if (_hasReachedEnd) return; 43 | 44 | _status = CharacterPageStatus.loading; 45 | notifyListeners(); 46 | 47 | final list = await _getAllCharacters(page: _currentPage); 48 | _currentPage++; 49 | _characters.addAll(list); 50 | _status = CharacterPageStatus.success; 51 | _hasReachedEnd = list.isEmpty; 52 | notifyListeners(); 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /test/lib/layers/presentation/using_mobx/list_page/controller/character_page_controller_test.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter_test/flutter_test.dart'; 2 | import 'package:mocktail/mocktail.dart'; 3 | import 'package:rickmorty/layers/presentation/using_mobx/list_page/store/character_page_store.dart'; 4 | 5 | import '../../../../../../fixtures/fixtures.dart'; 6 | import '../../../helper/pump_app.dart'; 7 | 8 | void main() { 9 | late CharacterPageStore store; 10 | late GetAllCharactersMock getAllCharactersMock; 11 | 12 | setUp(() { 13 | getAllCharactersMock = GetAllCharactersMock(); 14 | store = CharacterPageStore(getAllCharacters: getAllCharactersMock); 15 | }); 16 | 17 | test('fetchNextPage success', () async { 18 | // Mock the response of GetAllCharacters 19 | final mockCharacterList = [...characterList1, ...characterList2]; 20 | when(() => getAllCharactersMock.call(page: any(named: 'page'))) 21 | .thenAnswer((_) async => mockCharacterList); 22 | 23 | expect(store.contentStatus, equals(CharacterPageStatus.initial)); 24 | 25 | // Call the method under test 26 | await store.fetchNextPage(); 27 | 28 | // Verify the interactions and expected values 29 | expect(store.contentStatus, equals(CharacterPageStatus.success)); 30 | expect(store.currentPage, equals(2)); 31 | expect(store.charactersList, equals(mockCharacterList)); 32 | expect(store.hasReachedEnd, isFalse); 33 | }); 34 | 35 | test('fetchNextPage has reached end', () async { 36 | // Mock an empty response from GetAllCharacters 37 | when(() => getAllCharactersMock(page: any(named: 'page'))) 38 | .thenAnswer((_) async => []); 39 | 40 | // Set hasReachedEnd to true to simulate the end of the list 41 | // controller.hasReachedEnd = true; 42 | 43 | expect(store.contentStatus, equals(CharacterPageStatus.initial)); 44 | 45 | // Call the method under test 46 | await store.fetchNextPage(); 47 | 48 | // Verify the interactions and expected values 49 | expect(store.contentStatus, equals(CharacterPageStatus.success)); 50 | expect(store.currentPage, equals(2)); 51 | expect(store.charactersList, isEmpty); 52 | expect(store.hasReachedEnd, isTrue); 53 | }); 54 | } 55 | -------------------------------------------------------------------------------- /test/lib/layers/presentation/using_get_it/list_page/controller/character_page_controller.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter_test/flutter_test.dart'; 2 | import 'package:mocktail/mocktail.dart'; 3 | import 'package:rickmorty/layers/presentation/using_get_it/list_page/controller/character_page_controller.dart'; 4 | 5 | import '../../../../../../fixtures/fixtures.dart'; 6 | import '../../../helper/pump_app.dart'; 7 | 8 | void main() { 9 | late CharacterPageController controller; 10 | 11 | setUp(() { 12 | final mock = GetAllCharactersMock(); 13 | when(() => mock.call(page: any(named: 'page'))) 14 | .thenAnswer((_) async => [...characterList1, ...characterList2]); 15 | 16 | controller = CharacterPageController(getAllCharacters: mock); 17 | }); 18 | 19 | group('.fetchNextPage()', () { 20 | test('updates status to loading and notifies listeners', () async { 21 | var loadingHappened = false; 22 | 23 | // the 'loading' state happens between the method call and the final 24 | // state. That's the reason we use a listener to be sure it happened 25 | controller.status.addListener(() { 26 | if (controller.status.value == CharacterPageStatus.loading) { 27 | loadingHappened = true; 28 | } 29 | }); 30 | 31 | expect(controller.status.value, CharacterPageStatus.initial); 32 | await controller.fetchNextPage(); 33 | expect(loadingHappened, true); 34 | }); 35 | 36 | test('fetches characters and updates state', () async { 37 | controller.currentPage.value = 1; 38 | 39 | await controller.fetchNextPage(); 40 | 41 | expect(controller.currentPage.value, 2); 42 | expect(controller.status.value, CharacterPageStatus.success); 43 | expect(controller.characters.value, isNotEmpty); 44 | expect(controller.hasReachedEnd.value, isFalse); 45 | }); 46 | 47 | test('fetchNextPage does not fetch if hasReachedEnd is true', () async { 48 | controller.hasReachedEnd.value = true; 49 | 50 | await controller.fetchNextPage(); 51 | 52 | expect(controller.currentPage.value, 1); 53 | expect(controller.status.value, CharacterPageStatus.initial); 54 | expect(controller.characters.value, isEmpty); 55 | }); 56 | }); 57 | } 58 | -------------------------------------------------------------------------------- /analysis_options.yaml: -------------------------------------------------------------------------------- 1 | # This file configures the analyzer, which statically analyzes Dart code to 2 | # check for errors, warnings, and lints. 3 | # 4 | # The issues identified by the analyzer are surfaced in the UI of Dart-enabled 5 | # IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be 6 | # invoked from the command line by running `flutter analyze`. 7 | 8 | # The following line activates a set of recommended lints for Flutter apps, 9 | # packages, and plugins designed to encourage good coding practices. 10 | include: package:flutter_lints/flutter.yaml 11 | 12 | analyzer: 13 | exclude: [ build/** ] 14 | language: 15 | # strict-casts: true # ensures that the type inference engine never implicitly casts from dynamic to a more specific type 16 | strict-inference: true # ensures that the type inference engine never chooses the dynamic type when it can’t determine a static type 17 | strict-raw-types: true # ensures that the type inference engine never chooses the dynamic type when it can’t determine a static type due to omitted type arguments 18 | errors: 19 | unused_element: ignore # suppress issues with Widget({super.key})... 20 | 21 | linter: 22 | # The lint rules applied to this project can be customized in the 23 | # section below to disable rules from the `package:flutter_lints/flutter.yaml` 24 | # included above or to enable additional rules. A list of all available lints 25 | # and their documentation is published at 26 | # https://dart-lang.github.io/linter/lints/index.html. 27 | # 28 | # Instead of disabling a lint rule for the entire project in the 29 | # section below, it can also be suppressed for a single line of code 30 | # or a specific dart file by using the `// ignore: name_of_lint` and 31 | # `// ignore_for_file: name_of_lint` syntax on the line or in the file 32 | # producing the lint. 33 | rules: 34 | always_use_package_imports: true 35 | require_trailing_commas: true 36 | use_super_parameters: true 37 | prefer_single_quotes: true 38 | avoid_relative_lib_imports: true 39 | # avoid_print: false # Uncomment to disable the `avoid_print` rule 40 | 41 | # Additional information about this file can be found at 42 | # https://dart.dev/guides/language/analysis-options 43 | -------------------------------------------------------------------------------- /test/lib/layers/presentation/using_get_it/list_page/view/character_page_test.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:flutter_test/flutter_test.dart'; 3 | import 'package:mocktail/mocktail.dart'; 4 | import 'package:rickmorty/layers/domain/usecase/get_all_characters.dart'; 5 | import 'package:rickmorty/layers/presentation/shared/character_list_item.dart'; 6 | import 'package:rickmorty/layers/presentation/using_get_it/list_page/controller/character_page_controller.dart'; 7 | import 'package:rickmorty/layers/presentation/using_get_it/injector.dart'; 8 | import 'package:rickmorty/layers/presentation/using_get_it/list_page/view/character_page.dart'; 9 | 10 | import '../../../../../../fixtures/fixtures.dart'; 11 | import '../../../helper/pump_app.dart'; 12 | 13 | void main() { 14 | group('CharacterPage', () { 15 | late GetAllCharactersMock getAllCharactersMock; 16 | 17 | setUp(() async { 18 | getAllCharactersMock = GetAllCharactersMock(); 19 | when(() => getAllCharactersMock.call(page: any(named: 'page'))) 20 | .thenAnswer((_) async => [...characterList1, ...characterList2]); 21 | 22 | await getIt.reset(); 23 | getIt.registerFactory(() => getAllCharactersMock); 24 | getIt.registerLazySingleton( 25 | () => CharacterPageController(getAllCharacters: getIt()), 26 | ); 27 | }); 28 | 29 | testWidgets('renders a CharacterView', (tester) async { 30 | await tester.pumpApp( 31 | const CharacterPage(), 32 | getAllCharacters: getAllCharactersMock, 33 | ); 34 | 35 | expect(find.byType(CharacterView), findsOneWidget); 36 | }); 37 | 38 | testWidgets('renders a list of Characters widgets', (tester) async { 39 | const key = Key('character_page_list_key'); 40 | 41 | await tester.pumpApp( 42 | const CharacterPage(), 43 | getAllCharacters: getAllCharactersMock, 44 | ); 45 | getIt().hasReachedEnd.value = true; 46 | 47 | await tester.pumpAndSettle(); 48 | expect(find.byKey(key), findsOneWidget); 49 | expectLater( 50 | find.byType(CharacterListItem), 51 | findsNWidgets([...characterList1, ...characterList2].length), 52 | ); 53 | }); 54 | }); 55 | } 56 | -------------------------------------------------------------------------------- /ios/Runner/Base.lproj/LaunchScreen.storyboard: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | -------------------------------------------------------------------------------- /android/app/build.gradle: -------------------------------------------------------------------------------- 1 | def localProperties = new Properties() 2 | def localPropertiesFile = rootProject.file('local.properties') 3 | if (localPropertiesFile.exists()) { 4 | localPropertiesFile.withReader('UTF-8') { reader -> 5 | localProperties.load(reader) 6 | } 7 | } 8 | 9 | def flutterRoot = localProperties.getProperty('flutter.sdk') 10 | if (flutterRoot == null) { 11 | throw new GradleException("Flutter SDK not found. Define location with flutter.sdk in the local.properties file.") 12 | } 13 | 14 | def flutterVersionCode = localProperties.getProperty('flutter.versionCode') 15 | if (flutterVersionCode == null) { 16 | flutterVersionCode = '1' 17 | } 18 | 19 | def flutterVersionName = localProperties.getProperty('flutter.versionName') 20 | if (flutterVersionName == null) { 21 | flutterVersionName = '1.0' 22 | } 23 | 24 | apply plugin: 'com.android.application' 25 | apply plugin: 'kotlin-android' 26 | apply from: "$flutterRoot/packages/flutter_tools/gradle/flutter.gradle" 27 | 28 | android { 29 | namespace "com.example.rickmorty" 30 | compileSdkVersion flutter.compileSdkVersion 31 | ndkVersion flutter.ndkVersion 32 | 33 | compileOptions { 34 | sourceCompatibility JavaVersion.VERSION_1_8 35 | targetCompatibility JavaVersion.VERSION_1_8 36 | } 37 | 38 | kotlinOptions { 39 | jvmTarget = '1.8' 40 | } 41 | 42 | sourceSets { 43 | main.java.srcDirs += 'src/main/kotlin' 44 | } 45 | 46 | defaultConfig { 47 | // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). 48 | applicationId "com.example.rickmorty" 49 | // You can update the following values to match your application needs. 50 | // For more information, see: https://docs.flutter.dev/deployment/android#reviewing-the-gradle-build-configuration. 51 | minSdkVersion flutter.minSdkVersion 52 | targetSdkVersion flutter.targetSdkVersion 53 | versionCode flutterVersionCode.toInteger() 54 | versionName flutterVersionName 55 | } 56 | 57 | buildTypes { 58 | release { 59 | // TODO: Add your own signing config for the release build. 60 | // Signing with the debug keys for now, so `flutter run --release` works. 61 | signingConfig signingConfigs.debug 62 | } 63 | } 64 | } 65 | 66 | flutter { 67 | source '../..' 68 | } 69 | 70 | dependencies { 71 | implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version" 72 | } 73 | -------------------------------------------------------------------------------- /lib/layers/data/dto/character_dto.dart: -------------------------------------------------------------------------------- 1 | import 'dart:convert'; 2 | 3 | import 'package:rickmorty/layers/data/dto/location_dto.dart'; 4 | import 'package:rickmorty/layers/domain/entity/character.dart'; 5 | 6 | class CharacterDto extends Character { 7 | CharacterDto({ 8 | super.id, 9 | super.name, 10 | super.status, 11 | super.species, 12 | super.type, 13 | super.gender, 14 | super.origin, 15 | super.location, 16 | super.image, 17 | super.episode, 18 | super.url, 19 | super.created, 20 | }); 21 | 22 | // --------------------------------------------------------------------------- 23 | // JSON 24 | // --------------------------------------------------------------------------- 25 | factory CharacterDto.fromRawJson(String str) => 26 | CharacterDto.fromMap(json.decode(str)); 27 | 28 | String toRawJson() => json.encode(toMap()); 29 | 30 | // --------------------------------------------------------------------------- 31 | // Maps 32 | // --------------------------------------------------------------------------- 33 | factory CharacterDto.fromMap(Map json) => CharacterDto( 34 | id: json['id'], 35 | name: json['name'], 36 | status: json['status'], 37 | species: json['species'], 38 | type: json['type'], 39 | gender: json['gender'], 40 | origin: 41 | json['origin'] == null ? null : LocationDto.fromMap(json['origin']), 42 | location: json['location'] == null 43 | ? null 44 | : LocationDto.fromMap(json['location']), 45 | image: json['image'], 46 | episode: json['episode'] == null 47 | ? [] 48 | : List.from(json['episode']!.map((dynamic x) => x)), 49 | url: json['url'], 50 | created: 51 | json['created'] == null ? null : DateTime.parse(json['created']), 52 | ); 53 | 54 | Map toMap() => { 55 | 'id': id, 56 | 'name': name, 57 | 'status': status, 58 | 'species': species, 59 | 'type': type, 60 | 'gender': gender, 61 | 'origin': 62 | origin != null ? LocationDto.fromLocation(origin!).toMap() : null, 63 | 'location': location != null 64 | ? LocationDto.fromLocation(location!).toMap() 65 | : null, 66 | 'image': image, 67 | 'episode': episode == null 68 | ? [null] 69 | : List.from(episode!.map((x) => x)), 70 | 'url': url, 71 | 'created': created?.toIso8601String(), 72 | }; 73 | } 74 | -------------------------------------------------------------------------------- /test/lib/layers/presentation/using_bloc/list_page/view/character_page_test.dart: -------------------------------------------------------------------------------- 1 | import 'package:bloc_test/bloc_test.dart'; 2 | import 'package:flutter/material.dart'; 3 | import 'package:flutter_bloc/flutter_bloc.dart'; 4 | import 'package:flutter_test/flutter_test.dart'; 5 | import 'package:mocktail/mocktail.dart'; 6 | import 'package:rickmorty/layers/presentation/shared/character_list_item.dart'; 7 | import 'package:rickmorty/layers/presentation/using_bloc/list_page/bloc/character_page_bloc.dart'; 8 | import 'package:rickmorty/layers/presentation/using_bloc/list_page/view/character_page.dart'; 9 | 10 | import '../../../../../../fixtures/fixtures.dart'; 11 | import '../../../helper/pump_app.dart'; 12 | 13 | class CharacterBlocMock extends MockBloc 14 | implements CharacterPageBloc {} 15 | 16 | void main() { 17 | group('CharacterPage', () { 18 | late GetAllCharactersMock getAllCharactersMock; 19 | late CharacterPageBloc blocMock; 20 | 21 | setUp(() { 22 | getAllCharactersMock = GetAllCharactersMock(); 23 | blocMock = CharacterBlocMock(); 24 | 25 | when(() => getAllCharactersMock.call(page: any(named: 'page'))) 26 | .thenAnswer((_) async => [...characterList1, ...characterList2]); 27 | }); 28 | 29 | testWidgets('renders CharacterView', (tester) async { 30 | try { 31 | await tester.pumpApp( 32 | const CharacterPage(), 33 | getAllCharacters: getAllCharactersMock, 34 | ); 35 | await tester.pumpAndSettle(); 36 | } catch (e) { 37 | // https://stackoverflow.com/questions/64231515/widget-test-testing-a-button-with-circularprogressindicator 38 | } 39 | 40 | expectLater(find.byType(CharacterView), findsOneWidget); 41 | }); 42 | 43 | testWidgets('renders a grid of Characters widgets', (tester) async { 44 | const key = Key('character_page_list_key'); 45 | when(() => blocMock.state).thenReturn( 46 | CharacterPageState( 47 | currentPage: 2, 48 | status: CharacterPageStatus.success, 49 | hasReachedEnd: false, 50 | characters: [...characterList1, ...characterList2], 51 | ), 52 | ); 53 | 54 | await tester.pumpApp( 55 | BlocProvider.value( 56 | value: blocMock, 57 | child: const CharacterView(), 58 | ), 59 | ); 60 | 61 | expect(find.byKey(key), findsOneWidget); 62 | final list = [...characterList1, ...characterList2]; 63 | expectLater(find.byType(CharacterListItem), findsNWidgets(list.length)); 64 | }); 65 | }); 66 | } 67 | -------------------------------------------------------------------------------- /lib/layers/presentation/using_mobx/list_page/store/character_page_store.g.dart: -------------------------------------------------------------------------------- 1 | // GENERATED CODE - DO NOT MODIFY BY HAND 2 | 3 | part of 'character_page_store.dart'; 4 | 5 | // ************************************************************************** 6 | // StoreGenerator 7 | // ************************************************************************** 8 | 9 | // ignore_for_file: non_constant_identifier_names, unnecessary_brace_in_string_interps, unnecessary_lambdas, prefer_expression_function_bodies, lines_longer_than_80_chars, avoid_as, avoid_annotating_with_dynamic, no_leading_underscores_for_local_identifiers 10 | 11 | mixin _$CharacterPageStore on CharacterPageStoreBase, Store { 12 | late final _$_contentStatusAtom = 13 | Atom(name: 'CharacterPageStoreBase._contentStatus', context: context); 14 | 15 | CharacterPageStatus get contentStatus { 16 | _$_contentStatusAtom.reportRead(); 17 | return super._contentStatus; 18 | } 19 | 20 | @override 21 | CharacterPageStatus get _contentStatus => contentStatus; 22 | 23 | @override 24 | set _contentStatus(CharacterPageStatus value) { 25 | _$_contentStatusAtom.reportWrite(value, super._contentStatus, () { 26 | super._contentStatus = value; 27 | }); 28 | } 29 | 30 | late final _$_currentPageAtom = 31 | Atom(name: 'CharacterPageStoreBase._currentPage', context: context); 32 | 33 | int get currentPage { 34 | _$_currentPageAtom.reportRead(); 35 | return super._currentPage; 36 | } 37 | 38 | @override 39 | int get _currentPage => currentPage; 40 | 41 | @override 42 | set _currentPage(int value) { 43 | _$_currentPageAtom.reportWrite(value, super._currentPage, () { 44 | super._currentPage = value; 45 | }); 46 | } 47 | 48 | late final _$_hasReachedEndAtom = 49 | Atom(name: 'CharacterPageStoreBase._hasReachedEnd', context: context); 50 | 51 | bool get hasReachedEnd { 52 | _$_hasReachedEndAtom.reportRead(); 53 | return super._hasReachedEnd; 54 | } 55 | 56 | @override 57 | bool get _hasReachedEnd => hasReachedEnd; 58 | 59 | @override 60 | set _hasReachedEnd(bool value) { 61 | _$_hasReachedEndAtom.reportWrite(value, super._hasReachedEnd, () { 62 | super._hasReachedEnd = value; 63 | }); 64 | } 65 | 66 | late final _$fetchNextPageAsyncAction = 67 | AsyncAction('CharacterPageStoreBase.fetchNextPage', context: context); 68 | 69 | @override 70 | Future fetchNextPage() { 71 | return _$fetchNextPageAsyncAction.run(() => super.fetchNextPage()); 72 | } 73 | 74 | @override 75 | String toString() { 76 | return ''' 77 | 78 | '''; 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /test/lib/layers/presentation/using_cubit/list_page/view/character_page_test.dart: -------------------------------------------------------------------------------- 1 | import 'package:bloc_test/bloc_test.dart'; 2 | import 'package:flutter/material.dart'; 3 | import 'package:flutter_bloc/flutter_bloc.dart'; 4 | import 'package:flutter_test/flutter_test.dart'; 5 | import 'package:mocktail/mocktail.dart'; 6 | import 'package:rickmorty/layers/presentation/shared/character_list_item.dart'; 7 | import 'package:rickmorty/layers/presentation/using_cubit/list_page/cubit/character_page_cubit.dart'; 8 | import 'package:rickmorty/layers/presentation/using_cubit/list_page/view/character_page.dart'; 9 | 10 | import '../../../../../../fixtures/fixtures.dart'; 11 | import '../../../helper/pump_app.dart'; 12 | 13 | class CharacterPageCubitMock extends MockCubit 14 | implements CharacterPageCubit {} 15 | 16 | void main() { 17 | group('CharacterPage', () { 18 | late GetAllCharactersMock getAllCharactersMock; 19 | late CharacterPageCubit cubit; 20 | 21 | setUp(() { 22 | getAllCharactersMock = GetAllCharactersMock(); 23 | cubit = CharacterPageCubitMock(); 24 | 25 | when(() => getAllCharactersMock.call(page: any(named: 'page'))) 26 | .thenAnswer((_) async => [...characterList1, ...characterList2]); 27 | }); 28 | 29 | testWidgets('renders a CharacterView', (tester) async { 30 | when(() => getAllCharactersMock(page: any(named: 'page'))).thenAnswer( 31 | (_) async => characterList1, 32 | ); 33 | 34 | await tester.pumpApp( 35 | const CharacterPage(), 36 | getAllCharacters: getAllCharactersMock, 37 | ); 38 | 39 | expect(find.byType(CharacterView), findsOneWidget); 40 | }); 41 | 42 | testWidgets('renders a list of Characters widgets', (tester) async { 43 | const key = Key('character_page_list_key'); 44 | when(() => cubit.state).thenReturn( 45 | CharacterPageState( 46 | currentPage: 2, 47 | status: CharacterPageStatus.success, 48 | hasReachedEnd: true, 49 | characters: [...characterList1, ...characterList2], 50 | ), 51 | ); 52 | when(() => cubit.fetchNextPage()).thenAnswer((_) async => true); 53 | 54 | await tester.pumpApp( 55 | BlocProvider.value( 56 | value: cubit, 57 | child: const CharacterView(), 58 | ), 59 | getAllCharacters: getAllCharactersMock, 60 | ); 61 | await tester.pumpAndSettle(const Duration(seconds: 2)); 62 | 63 | expect(find.byKey(key), findsOneWidget); 64 | final list = [...characterList1, ...characterList2]; 65 | expectLater(find.byType(CharacterListItem), findsNWidgets(list.length)); 66 | }); 67 | }); 68 | } 69 | -------------------------------------------------------------------------------- /test/lib/layers/presentation/using_bloc/list_page/bloc/character_page_bloc_test.dart: -------------------------------------------------------------------------------- 1 | import 'package:bloc_test/bloc_test.dart'; 2 | import 'package:flutter_test/flutter_test.dart'; 3 | import 'package:mocktail/mocktail.dart'; 4 | import 'package:rickmorty/layers/domain/usecase/get_all_characters.dart'; 5 | import 'package:rickmorty/layers/presentation/using_bloc/list_page/bloc/character_page_bloc.dart'; 6 | 7 | import '../../../../../../fixtures/fixtures.dart'; 8 | 9 | class MockGetAllCharacters extends Mock implements GetAllCharacters {} 10 | 11 | void main() { 12 | late CharacterPageBloc bloc; 13 | late GetAllCharacters getAllCharacters; 14 | 15 | setUp(() { 16 | getAllCharacters = MockGetAllCharacters(); 17 | bloc = CharacterPageBloc(getAllCharacters: getAllCharacters); 18 | }); 19 | 20 | group('CharacterPageBloc', () { 21 | test('initial state is correct', () { 22 | final initial = bloc.state; 23 | expect(initial, const CharacterPageState()); 24 | }); 25 | 26 | group('.FetchNextPageEvent', () { 27 | blocTest( 28 | 'emits loading -> runs UseCase -> emits success with a list', 29 | build: () => bloc, 30 | setUp: () { 31 | when(() => getAllCharacters(page: 1)).thenAnswer( 32 | (_) async => characterList1, 33 | ); 34 | }, 35 | act: (bloc) => bloc..add(const FetchNextPageEvent()), 36 | expect: () => [ 37 | const CharacterPageState( 38 | status: CharacterPageStatus.loading, 39 | ), 40 | CharacterPageState( 41 | status: CharacterPageStatus.success, 42 | characters: characterList1, 43 | hasReachedEnd: false, 44 | currentPage: 2, 45 | ), 46 | ], 47 | verify: (_) { 48 | verify(() => getAllCharacters.call(page: 1)); 49 | verifyNoMoreInteractions(getAllCharacters); 50 | }, 51 | ); 52 | 53 | blocTest( 54 | "emits a state with hasReachedEnd 'true' when there are no more items", 55 | build: () => bloc, 56 | setUp: () { 57 | when(() => getAllCharacters(page: 1)).thenAnswer( 58 | (_) async => const [], 59 | ); 60 | }, 61 | skip: 1, // skip 'loading' 62 | act: (bloc) => bloc..add(const FetchNextPageEvent()), 63 | expect: () => [ 64 | const CharacterPageState( 65 | status: CharacterPageStatus.success, 66 | characters: [], 67 | hasReachedEnd: true, 68 | currentPage: 2, 69 | ), 70 | ], 71 | ); 72 | }); 73 | }); 74 | } 75 | -------------------------------------------------------------------------------- /test/lib/layers/data/character_repository_impl_test.dart: -------------------------------------------------------------------------------- 1 | import 'package:mocktail/mocktail.dart'; 2 | import 'package:rickmorty/layers/data/character_repository_impl.dart'; 3 | import 'package:rickmorty/layers/data/source/local/local_storage.dart'; 4 | import 'package:rickmorty/layers/data/source/network/api.dart'; 5 | import 'package:test/test.dart'; 6 | 7 | import '../../../fixtures/fixtures.dart'; 8 | 9 | class MockApi extends Mock implements Api {} 10 | 11 | class MockLocalStorage extends Mock implements LocalStorage {} 12 | 13 | void main() { 14 | late CharacterRepositoryImpl characterRepository; 15 | late MockApi mockApi; 16 | late MockLocalStorage mockLocalStorage; 17 | 18 | setUp(() { 19 | mockApi = MockApi(); 20 | mockLocalStorage = MockLocalStorage(); 21 | characterRepository = CharacterRepositoryImpl( 22 | api: mockApi, 23 | localStorage: mockLocalStorage, 24 | ); 25 | }); 26 | 27 | group('CharacterRepositoryImpl', () { 28 | test('getCharacters should return cached characters if available', 29 | () async { 30 | const page = 0; 31 | final cachedCharacters = characterList1; 32 | when(() => mockLocalStorage.loadCharactersPage(page: page)) 33 | .thenReturn(cachedCharacters); 34 | 35 | final result = await characterRepository.getCharacters(page: page); 36 | 37 | expect(result, equals(cachedCharacters)); 38 | 39 | verify(() => mockLocalStorage.loadCharactersPage(page: page)).called(1); 40 | verifyNoMoreInteractions(mockLocalStorage); 41 | verifyZeroInteractions(mockApi); 42 | }); 43 | 44 | test( 45 | 'getCharacters should fetch characters from API and save to local storage', 46 | () async { 47 | const page = 1; 48 | final apiCharacters = characterList2; 49 | when(() => mockLocalStorage.loadCharactersPage(page: page)) 50 | .thenReturn([]); 51 | when(() => mockApi.loadCharacters(page: page)) 52 | .thenAnswer((_) async => apiCharacters); 53 | when( 54 | () => mockLocalStorage.saveCharactersPage( 55 | page: page, 56 | list: apiCharacters, 57 | ), 58 | ).thenAnswer((_) async => true); 59 | 60 | final result = await characterRepository.getCharacters(page: page); 61 | 62 | expect(result, equals(apiCharacters)); 63 | verify(() => mockLocalStorage.loadCharactersPage(page: page)).called(1); 64 | verify(() => mockApi.loadCharacters(page: page)).called(1); 65 | verify( 66 | () => mockLocalStorage.saveCharactersPage( 67 | page: page, 68 | list: apiCharacters, 69 | ), 70 | ).called(1); 71 | verifyNoMoreInteractions(mockLocalStorage); 72 | verifyNoMoreInteractions(mockApi); 73 | }); 74 | }); 75 | } 76 | -------------------------------------------------------------------------------- /test/lib/layers/presentation/using_cubit/list_page/cubit/character_page_cubit_test.dart: -------------------------------------------------------------------------------- 1 | import 'package:bloc_test/bloc_test.dart'; 2 | import 'package:flutter_test/flutter_test.dart'; 3 | import 'package:mocktail/mocktail.dart'; 4 | import 'package:rickmorty/layers/presentation/using_cubit/list_page/cubit/character_page_cubit.dart'; 5 | 6 | import '../../../../../../fixtures/fixtures.dart'; 7 | import '../../../helper/pump_app.dart'; 8 | 9 | void main() { 10 | group('CharacterPageCubit', () { 11 | late GetAllCharactersMock getAllCharactersMock; 12 | late CharacterPageCubit cubit; 13 | 14 | setUp(() { 15 | getAllCharactersMock = GetAllCharactersMock(); 16 | cubit = CharacterPageCubit(getAllCharacters: getAllCharactersMock); 17 | }); 18 | 19 | test('should have correct initial state', () { 20 | const expected = CharacterPageState( 21 | characters: [], 22 | currentPage: 1, 23 | status: CharacterPageStatus.initial, 24 | hasReachedEnd: false, 25 | ); 26 | 27 | expect( 28 | CharacterPageCubit(getAllCharacters: getAllCharactersMock).state, 29 | expected, 30 | ); 31 | }); 32 | 33 | group('.fetchNextPage()', () { 34 | blocTest( 35 | 'emits loading -> runs UseCase -> emits success with a list', 36 | build: () => cubit, 37 | setUp: () { 38 | when(() => getAllCharactersMock(page: 1)).thenAnswer( 39 | (_) async => characterList1, 40 | ); 41 | }, 42 | act: (cubit) => cubit.fetchNextPage(), 43 | expect: () => [ 44 | const CharacterPageState( 45 | status: CharacterPageStatus.loading, 46 | ), 47 | CharacterPageState( 48 | status: CharacterPageStatus.success, 49 | characters: characterList1, 50 | hasReachedEnd: false, 51 | currentPage: 2, 52 | ), 53 | ], 54 | verify: (_) { 55 | verify(() => getAllCharactersMock.call(page: 1)); 56 | verifyNoMoreInteractions(getAllCharactersMock); 57 | }, 58 | ); 59 | 60 | blocTest( 61 | "emits a state with hasReachedEnd 'true' when there are no more items", 62 | build: () => cubit, 63 | setUp: () { 64 | when(() => getAllCharactersMock(page: 1)).thenAnswer((_) async => []); 65 | }, 66 | act: (cubit) => cubit.fetchNextPage(), 67 | skip: 1, // skip 'loading' 68 | expect: () => [ 69 | const CharacterPageState( 70 | status: CharacterPageStatus.success, 71 | characters: [], 72 | hasReachedEnd: true, 73 | currentPage: 2, 74 | ), 75 | ], 76 | ); 77 | }); 78 | }); 79 | } 80 | -------------------------------------------------------------------------------- /test/lib/layers/presentation/using_provider/list_page/change_notifier/character_page_change_notifier_test.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter_test/flutter_test.dart'; 2 | import 'package:mocktail/mocktail.dart'; 3 | import 'package:rickmorty/layers/presentation/using_provider/list_page/change_notifier/character_page_change_notifier.dart'; 4 | 5 | import '../../../../../../fixtures/fixtures.dart'; 6 | import '../../../helper/pump_app.dart'; 7 | 8 | void main() { 9 | group('CharacterChangeNotifier', () { 10 | late GetAllCharactersMock getAllCharactersMock; 11 | late CharacterPageChangeNotifier characterChangeNotifier; 12 | 13 | setUp(() { 14 | getAllCharactersMock = GetAllCharactersMock(); 15 | }); 16 | 17 | test('fetchNextPage updates state correctly', () async { 18 | characterChangeNotifier = CharacterPageChangeNotifier( 19 | getAllCharacters: getAllCharactersMock, 20 | ); 21 | 22 | when(() => getAllCharactersMock.call(page: any(named: 'page'))) 23 | .thenAnswer((_) async => [...characterList1, ...characterList2]); 24 | 25 | // Set up the initial state 26 | expect( 27 | characterChangeNotifier.status, 28 | equals(CharacterPageStatus.initial), 29 | ); 30 | 31 | // Set up the response from getAllCharacters 32 | final page = characterChangeNotifier.currentPage; 33 | 34 | await characterChangeNotifier.fetchNextPage(); 35 | 36 | // Verify that the state is updated correctly 37 | expect( 38 | characterChangeNotifier.status, 39 | equals(CharacterPageStatus.success), 40 | ); 41 | expect(characterChangeNotifier.currentPage, equals(page + 1)); 42 | expect( 43 | characterChangeNotifier.characters, 44 | equals([ 45 | ...[...characterList1, ...characterList2], 46 | ]), 47 | ); 48 | expect(characterChangeNotifier.hasReachedEnd, equals(false)); 49 | }); 50 | 51 | test('fetchNextPage does not update state when hasReachedEnd is true', 52 | () async { 53 | // Set up the initial state with hasReachedEnd = true 54 | characterChangeNotifier = CharacterPageChangeNotifier( 55 | getAllCharacters: getAllCharactersMock, 56 | ); 57 | 58 | when(() => getAllCharactersMock.call(page: any(named: 'page'))) 59 | .thenAnswer((_) async => []); 60 | 61 | // Call the fetchNextPage method 62 | await characterChangeNotifier.fetchNextPage(); 63 | 64 | // Verify that the state remains unchanged 65 | expect( 66 | characterChangeNotifier.status, 67 | equals(CharacterPageStatus.success), 68 | ); 69 | expect(characterChangeNotifier.currentPage, equals(2)); 70 | expect(characterChangeNotifier.characters, isEmpty); 71 | expect(characterChangeNotifier.hasReachedEnd, equals(true)); 72 | }); 73 | }); 74 | } 75 | -------------------------------------------------------------------------------- /test/fixtures/fixtures.dart: -------------------------------------------------------------------------------- 1 | import 'package:rickmorty/layers/data/dto/character_dto.dart'; 2 | import 'package:rickmorty/layers/data/dto/location_dto.dart'; 3 | 4 | final characterDto = CharacterDto( 5 | id: 1, 6 | name: 'Rick Sanchez', 7 | status: 'Alive', 8 | species: 'Human', 9 | type: 'Super genius', 10 | gender: 'Male', 11 | origin: LocationDto(name: 'Earth', url: 'https://example.com/earth'), 12 | location: LocationDto(name: 'Earth', url: 'https://example.com/earth'), 13 | image: 'https://example.com/rick.png', 14 | episode: ['https://example.com/episode1', 'https://example.com/episode2'], 15 | url: 'https://example.com/character/1', 16 | created: DateTime.parse('2022-01-01T12:00:00Z'), 17 | ); 18 | 19 | final characterList1 = [ 20 | CharacterDto( 21 | id: 1, 22 | name: 'Rick Sanchez', 23 | status: 'Alive', 24 | species: 'Human', 25 | type: 'Super genius', 26 | gender: 'Male', 27 | origin: LocationDto(name: 'Earth', url: 'https://example.com/earth'), 28 | location: LocationDto(name: 'Earth', url: 'https://example.com/earth'), 29 | image: 'https://example.com/rick.png', 30 | episode: ['https://example.com/episode1', 'https://example.com/episode2'], 31 | url: 'https://example.com/character/1', 32 | created: DateTime.parse('2022-01-01T12:00:00Z'), 33 | ), 34 | CharacterDto( 35 | id: 2, 36 | name: 'Morty Smith', 37 | status: 'Alive', 38 | species: 'Human', 39 | type: 'Sidekick', 40 | gender: 'Male', 41 | origin: LocationDto(name: 'Earth', url: 'https://example.com/earth'), 42 | location: LocationDto(name: 'Earth', url: 'https://example.com/earth'), 43 | image: 'https://example.com/morty.png', 44 | episode: ['https://example.com/episode1', 'https://example.com/episode3'], 45 | url: 'https://example.com/character/2', 46 | created: DateTime.parse('2022-01-02T12:00:00Z'), 47 | ), 48 | ]; 49 | 50 | final characterList2 = [ 51 | CharacterDto( 52 | id: 3, 53 | name: 'Summer Smith', 54 | status: 'Alive', 55 | species: 'Human', 56 | type: 'Teenager', 57 | gender: 'Female', 58 | origin: LocationDto(name: 'Earth', url: 'https://example.com/earth'), 59 | location: LocationDto(name: 'Earth', url: 'https://example.com/earth'), 60 | image: 'https://example.com/summer.png', 61 | episode: ['https://example.com/episode1', 'https://example.com/episode4'], 62 | url: 'https://example.com/character/3', 63 | created: DateTime.parse('2022-01-03T12:00:00Z'), 64 | ), 65 | CharacterDto( 66 | id: 4, 67 | name: 'Jerry Smith', 68 | status: 'Alive', 69 | species: 'Human', 70 | type: 'Father', 71 | gender: 'Male', 72 | origin: LocationDto(name: 'Earth', url: 'https://example.com/earth'), 73 | location: LocationDto(name: 'Earth', url: 'https://example.com/earth'), 74 | image: 'https://example.com/jerry.png', 75 | episode: ['https://example.com/episode1', 'https://example.com/episode5'], 76 | url: 'https://example.com/character/4', 77 | created: DateTime.parse('2022-01-04T12:00:00Z'), 78 | ), 79 | ]; 80 | -------------------------------------------------------------------------------- /test/lib/layers/data/source/local/local_storage_test.dart: -------------------------------------------------------------------------------- 1 | import 'package:mocktail/mocktail.dart'; 2 | import 'package:rickmorty/layers/data/source/local/local_storage.dart'; 3 | import 'package:shared_preferences/shared_preferences.dart'; 4 | import 'package:test/test.dart'; 5 | 6 | import '../../../../../fixtures/fixtures.dart'; 7 | 8 | class MockSharedPreferences extends Mock implements SharedPreferences {} 9 | 10 | void main() { 11 | late LocalStorageImpl localStorage; 12 | late MockSharedPreferences mockSharedPreferences; 13 | 14 | setUp(() async { 15 | mockSharedPreferences = MockSharedPreferences(); 16 | localStorage = LocalStorageImpl(sharedPreferences: mockSharedPreferences); 17 | }); 18 | 19 | group('LocalStorageImpl', () { 20 | test('should save a list of CharacterDto per page', () async { 21 | when(() => mockSharedPreferences.setStringList(any(), any())) 22 | .thenAnswer((_) async => true); 23 | 24 | // List 1 25 | final result1 = await localStorage.saveCharactersPage( 26 | page: 1, 27 | list: characterList1, 28 | ); 29 | expect(result1, true); 30 | final key1 = LocalStorageImpl.getKeyToPage(1); 31 | final list1Raw = characterList1.map((e) => e.toRawJson()).toList(); 32 | verify(() => mockSharedPreferences.setStringList(key1, list1Raw)) 33 | .called(1); 34 | 35 | // List 2 36 | final result2 = await localStorage.saveCharactersPage( 37 | page: 2, 38 | list: characterList2, 39 | ); 40 | expect(result2, true); 41 | final key2 = LocalStorageImpl.getKeyToPage(2); 42 | final list2Raw = characterList2.map((e) => e.toRawJson()).toList(); 43 | verify(() => mockSharedPreferences.setStringList(key2, list2Raw)) 44 | .called(1); 45 | 46 | verifyNoMoreInteractions(mockSharedPreferences); 47 | }); 48 | 49 | test('should load a list of CharacterDto per page', () { 50 | // List 1 51 | final key1 = LocalStorageImpl.getKeyToPage(1); 52 | when(() => mockSharedPreferences.getStringList(key1)).thenReturn( 53 | characterList1.map((e) => e.toRawJson()).toList(), 54 | ); 55 | 56 | final result1 = localStorage.loadCharactersPage(page: 1); 57 | 58 | expect(result1, hasLength(2)); 59 | for (int i = 0; i < characterList1.length; i++) { 60 | expect(result1[i], characterList1[i]); 61 | } 62 | verify(() => mockSharedPreferences.getStringList(key1)).called(1); 63 | 64 | // List 2 65 | final key2 = LocalStorageImpl.getKeyToPage(2); 66 | when(() => mockSharedPreferences.getStringList(key2)).thenReturn( 67 | characterList2.map((e) => e.toRawJson()).toList(), 68 | ); 69 | 70 | final result2 = localStorage.loadCharactersPage(page: 2); 71 | 72 | expect(result2, hasLength(2)); 73 | for (int i = 0; i < characterList2.length; i++) { 74 | expect(result2[i], characterList2[i]); 75 | } 76 | verify(() => mockSharedPreferences.getStringList(key2)).called(1); 77 | 78 | verifyNoMoreInteractions(mockSharedPreferences); 79 | }); 80 | }); 81 | } 82 | -------------------------------------------------------------------------------- /linux/flutter/CMakeLists.txt: -------------------------------------------------------------------------------- 1 | # This file controls Flutter-level build steps. It should not be edited. 2 | cmake_minimum_required(VERSION 3.10) 3 | 4 | set(EPHEMERAL_DIR "${CMAKE_CURRENT_SOURCE_DIR}/ephemeral") 5 | 6 | # Configuration provided via flutter tool. 7 | include(${EPHEMERAL_DIR}/generated_config.cmake) 8 | 9 | # TODO: Move the rest of this into files in ephemeral. See 10 | # https://github.com/flutter/flutter/issues/57146. 11 | 12 | # Serves the same purpose as list(TRANSFORM ... PREPEND ...), 13 | # which isn't available in 3.10. 14 | function(list_prepend LIST_NAME PREFIX) 15 | set(NEW_LIST "") 16 | foreach(element ${${LIST_NAME}}) 17 | list(APPEND NEW_LIST "${PREFIX}${element}") 18 | endforeach(element) 19 | set(${LIST_NAME} "${NEW_LIST}" PARENT_SCOPE) 20 | endfunction() 21 | 22 | # === Flutter Library === 23 | # System-level dependencies. 24 | find_package(PkgConfig REQUIRED) 25 | pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0) 26 | pkg_check_modules(GLIB REQUIRED IMPORTED_TARGET glib-2.0) 27 | pkg_check_modules(GIO REQUIRED IMPORTED_TARGET gio-2.0) 28 | 29 | set(FLUTTER_LIBRARY "${EPHEMERAL_DIR}/libflutter_linux_gtk.so") 30 | 31 | # Published to parent scope for install step. 32 | set(FLUTTER_LIBRARY ${FLUTTER_LIBRARY} PARENT_SCOPE) 33 | set(FLUTTER_ICU_DATA_FILE "${EPHEMERAL_DIR}/icudtl.dat" PARENT_SCOPE) 34 | set(PROJECT_BUILD_DIR "${PROJECT_DIR}/build/" PARENT_SCOPE) 35 | set(AOT_LIBRARY "${PROJECT_DIR}/build/lib/libapp.so" PARENT_SCOPE) 36 | 37 | list(APPEND FLUTTER_LIBRARY_HEADERS 38 | "fl_basic_message_channel.h" 39 | "fl_binary_codec.h" 40 | "fl_binary_messenger.h" 41 | "fl_dart_project.h" 42 | "fl_engine.h" 43 | "fl_json_message_codec.h" 44 | "fl_json_method_codec.h" 45 | "fl_message_codec.h" 46 | "fl_method_call.h" 47 | "fl_method_channel.h" 48 | "fl_method_codec.h" 49 | "fl_method_response.h" 50 | "fl_plugin_registrar.h" 51 | "fl_plugin_registry.h" 52 | "fl_standard_message_codec.h" 53 | "fl_standard_method_codec.h" 54 | "fl_string_codec.h" 55 | "fl_value.h" 56 | "fl_view.h" 57 | "flutter_linux.h" 58 | ) 59 | list_prepend(FLUTTER_LIBRARY_HEADERS "${EPHEMERAL_DIR}/flutter_linux/") 60 | add_library(flutter INTERFACE) 61 | target_include_directories(flutter INTERFACE 62 | "${EPHEMERAL_DIR}" 63 | ) 64 | target_link_libraries(flutter INTERFACE "${FLUTTER_LIBRARY}") 65 | target_link_libraries(flutter INTERFACE 66 | PkgConfig::GTK 67 | PkgConfig::GLIB 68 | PkgConfig::GIO 69 | ) 70 | add_dependencies(flutter flutter_assemble) 71 | 72 | # === Flutter tool backend === 73 | # _phony_ is a non-existent file to force this command to run every time, 74 | # since currently there's no way to get a full input/output list from the 75 | # flutter tool. 76 | add_custom_command( 77 | OUTPUT ${FLUTTER_LIBRARY} ${FLUTTER_LIBRARY_HEADERS} 78 | ${CMAKE_CURRENT_BINARY_DIR}/_phony_ 79 | COMMAND ${CMAKE_COMMAND} -E env 80 | ${FLUTTER_TOOL_ENVIRONMENT} 81 | "${FLUTTER_ROOT}/packages/flutter_tools/bin/tool_backend.sh" 82 | ${FLUTTER_TARGET_PLATFORM} ${CMAKE_BUILD_TYPE} 83 | VERBATIM 84 | ) 85 | add_custom_target(flutter_assemble DEPENDS 86 | "${FLUTTER_LIBRARY}" 87 | ${FLUTTER_LIBRARY_HEADERS} 88 | ) 89 | -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "size" : "20x20", 5 | "idiom" : "iphone", 6 | "filename" : "Icon-App-20x20@2x.png", 7 | "scale" : "2x" 8 | }, 9 | { 10 | "size" : "20x20", 11 | "idiom" : "iphone", 12 | "filename" : "Icon-App-20x20@3x.png", 13 | "scale" : "3x" 14 | }, 15 | { 16 | "size" : "29x29", 17 | "idiom" : "iphone", 18 | "filename" : "Icon-App-29x29@1x.png", 19 | "scale" : "1x" 20 | }, 21 | { 22 | "size" : "29x29", 23 | "idiom" : "iphone", 24 | "filename" : "Icon-App-29x29@2x.png", 25 | "scale" : "2x" 26 | }, 27 | { 28 | "size" : "29x29", 29 | "idiom" : "iphone", 30 | "filename" : "Icon-App-29x29@3x.png", 31 | "scale" : "3x" 32 | }, 33 | { 34 | "size" : "40x40", 35 | "idiom" : "iphone", 36 | "filename" : "Icon-App-40x40@2x.png", 37 | "scale" : "2x" 38 | }, 39 | { 40 | "size" : "40x40", 41 | "idiom" : "iphone", 42 | "filename" : "Icon-App-40x40@3x.png", 43 | "scale" : "3x" 44 | }, 45 | { 46 | "size" : "60x60", 47 | "idiom" : "iphone", 48 | "filename" : "Icon-App-60x60@2x.png", 49 | "scale" : "2x" 50 | }, 51 | { 52 | "size" : "60x60", 53 | "idiom" : "iphone", 54 | "filename" : "Icon-App-60x60@3x.png", 55 | "scale" : "3x" 56 | }, 57 | { 58 | "size" : "20x20", 59 | "idiom" : "ipad", 60 | "filename" : "Icon-App-20x20@1x.png", 61 | "scale" : "1x" 62 | }, 63 | { 64 | "size" : "20x20", 65 | "idiom" : "ipad", 66 | "filename" : "Icon-App-20x20@2x.png", 67 | "scale" : "2x" 68 | }, 69 | { 70 | "size" : "29x29", 71 | "idiom" : "ipad", 72 | "filename" : "Icon-App-29x29@1x.png", 73 | "scale" : "1x" 74 | }, 75 | { 76 | "size" : "29x29", 77 | "idiom" : "ipad", 78 | "filename" : "Icon-App-29x29@2x.png", 79 | "scale" : "2x" 80 | }, 81 | { 82 | "size" : "40x40", 83 | "idiom" : "ipad", 84 | "filename" : "Icon-App-40x40@1x.png", 85 | "scale" : "1x" 86 | }, 87 | { 88 | "size" : "40x40", 89 | "idiom" : "ipad", 90 | "filename" : "Icon-App-40x40@2x.png", 91 | "scale" : "2x" 92 | }, 93 | { 94 | "size" : "76x76", 95 | "idiom" : "ipad", 96 | "filename" : "Icon-App-76x76@1x.png", 97 | "scale" : "1x" 98 | }, 99 | { 100 | "size" : "76x76", 101 | "idiom" : "ipad", 102 | "filename" : "Icon-App-76x76@2x.png", 103 | "scale" : "2x" 104 | }, 105 | { 106 | "size" : "83.5x83.5", 107 | "idiom" : "ipad", 108 | "filename" : "Icon-App-83.5x83.5@2x.png", 109 | "scale" : "2x" 110 | }, 111 | { 112 | "size" : "1024x1024", 113 | "idiom" : "ios-marketing", 114 | "filename" : "Icon-App-1024x1024@1x.png", 115 | "scale" : "1x" 116 | } 117 | ], 118 | "info" : { 119 | "version" : 1, 120 | "author" : "xcode" 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /test/lib/layers/presentation/using_bloc/list_page/bloc/character_page_state_test.dart: -------------------------------------------------------------------------------- 1 | import 'package:rickmorty/layers/domain/entity/character.dart'; 2 | import 'package:rickmorty/layers/presentation/using_bloc/list_page/bloc/character_page_bloc.dart'; 3 | import 'package:test/test.dart'; 4 | 5 | void main() { 6 | group('CharacterPageState', () { 7 | test('copyWith creates a new instance with the provided values', () { 8 | final state = CharacterPageState( 9 | status: CharacterPageStatus.loading, 10 | characters: [Character(id: 1, name: 'John')], 11 | hasReachedEnd: true, 12 | currentPage: 2, 13 | ); 14 | 15 | final newState = state.copyWith( 16 | status: CharacterPageStatus.success, 17 | characters: [Character(id: 2, name: 'Jane')], 18 | hasReachedEnd: false, 19 | currentPage: 3, 20 | ); 21 | 22 | expect(newState.status, equals(CharacterPageStatus.success)); 23 | expect(newState.characters.length, equals(1)); 24 | expect(newState.characters[0].id, equals(2)); 25 | expect(newState.characters[0].name, equals('Jane')); 26 | expect(newState.hasReachedEnd, equals(false)); 27 | expect(newState.currentPage, equals(3)); 28 | }); 29 | 30 | test('copyWith maintains unchanged values', () { 31 | final state = CharacterPageState( 32 | status: CharacterPageStatus.loading, 33 | characters: [Character(id: 1, name: 'John')], 34 | hasReachedEnd: true, 35 | currentPage: 2, 36 | ); 37 | 38 | final newState = state.copyWith(status: CharacterPageStatus.success); 39 | 40 | expect(newState.status, equals(CharacterPageStatus.success)); 41 | expect(newState.characters, equals(state.characters)); 42 | expect(newState.hasReachedEnd, equals(state.hasReachedEnd)); 43 | expect(newState.currentPage, equals(state.currentPage)); 44 | }); 45 | 46 | test('props returns a list of the object properties', () { 47 | final state = CharacterPageState( 48 | status: CharacterPageStatus.loading, 49 | characters: [Character(id: 1, name: 'John')], 50 | hasReachedEnd: true, 51 | currentPage: 2, 52 | ); 53 | 54 | final props = state.props; 55 | 56 | expect(props.length, equals(4)); 57 | expect(props[0], equals(CharacterPageStatus.loading)); 58 | expect(props[1], equals(state.characters)); 59 | expect(props[2], equals(true)); 60 | expect(props[3], equals(2)); 61 | }); 62 | 63 | test('equivalent instances have the same props', () { 64 | final state1 = CharacterPageState( 65 | status: CharacterPageStatus.loading, 66 | characters: [Character(id: 1, name: 'John')], 67 | hasReachedEnd: true, 68 | currentPage: 2, 69 | ); 70 | 71 | final state2 = CharacterPageState( 72 | status: CharacterPageStatus.loading, 73 | characters: [Character(id: 1, name: 'John')], 74 | hasReachedEnd: true, 75 | currentPage: 2, 76 | ); 77 | 78 | expect(state1.props, equals(state2.props)); 79 | }); 80 | 81 | test('distinct instances have different props', () { 82 | final state1 = CharacterPageState( 83 | status: CharacterPageStatus.loading, 84 | characters: [Character(id: 1, name: 'John')], 85 | hasReachedEnd: true, 86 | currentPage: 2, 87 | ); 88 | 89 | final state2 = CharacterPageState( 90 | status: CharacterPageStatus.success, 91 | characters: [Character(id: 2, name: 'Jane')], 92 | hasReachedEnd: false, 93 | currentPage: 3, 94 | ); 95 | 96 | expect(state1.props, isNot(equals(state2.props))); 97 | }); 98 | }); 99 | } 100 | -------------------------------------------------------------------------------- /test/lib/layers/presentation/using_cubit/list_page/cubit/character_page_state_test.dart: -------------------------------------------------------------------------------- 1 | import 'package:rickmorty/layers/domain/entity/character.dart'; 2 | import 'package:rickmorty/layers/presentation/using_cubit/list_page/cubit/character_page_cubit.dart'; 3 | import 'package:test/test.dart'; 4 | 5 | void main() { 6 | group('CharacterPageState', () { 7 | test('copyWith creates a new instance with the provided values', () { 8 | final state = CharacterPageState( 9 | status: CharacterPageStatus.loading, 10 | characters: [Character(id: 1, name: 'John')], 11 | hasReachedEnd: true, 12 | currentPage: 2, 13 | ); 14 | 15 | final newState = state.copyWith( 16 | status: CharacterPageStatus.success, 17 | characters: [Character(id: 2, name: 'Jane')], 18 | hasReachedEnd: false, 19 | currentPage: 3, 20 | ); 21 | 22 | expect(newState.status, equals(CharacterPageStatus.success)); 23 | expect(newState.characters.length, equals(1)); 24 | expect(newState.characters[0].id, equals(2)); 25 | expect(newState.characters[0].name, equals('Jane')); 26 | expect(newState.hasReachedEnd, equals(false)); 27 | expect(newState.currentPage, equals(3)); 28 | }); 29 | 30 | test('copyWith maintains unchanged values', () { 31 | final state = CharacterPageState( 32 | status: CharacterPageStatus.loading, 33 | characters: [Character(id: 1, name: 'John')], 34 | hasReachedEnd: true, 35 | currentPage: 2, 36 | ); 37 | 38 | final newState = state.copyWith(status: CharacterPageStatus.success); 39 | 40 | expect(newState.status, equals(CharacterPageStatus.success)); 41 | expect(newState.characters, equals(state.characters)); 42 | expect(newState.hasReachedEnd, equals(state.hasReachedEnd)); 43 | expect(newState.currentPage, equals(state.currentPage)); 44 | }); 45 | 46 | test('props returns a list of the object properties', () { 47 | final state = CharacterPageState( 48 | status: CharacterPageStatus.loading, 49 | characters: [Character(id: 1, name: 'John')], 50 | hasReachedEnd: true, 51 | currentPage: 2, 52 | ); 53 | 54 | final props = state.props; 55 | 56 | expect(props.length, equals(4)); 57 | expect(props[0], equals(CharacterPageStatus.loading)); 58 | expect(props[1], equals(state.characters)); 59 | expect(props[2], equals(true)); 60 | expect(props[3], equals(2)); 61 | }); 62 | 63 | test('equivalent instances have the same props', () { 64 | final state1 = CharacterPageState( 65 | status: CharacterPageStatus.loading, 66 | characters: [Character(id: 1, name: 'John')], 67 | hasReachedEnd: true, 68 | currentPage: 2, 69 | ); 70 | 71 | final state2 = CharacterPageState( 72 | status: CharacterPageStatus.loading, 73 | characters: [Character(id: 1, name: 'John')], 74 | hasReachedEnd: true, 75 | currentPage: 2, 76 | ); 77 | 78 | expect(state1.props, equals(state2.props)); 79 | }); 80 | 81 | test('distinct instances have different props', () { 82 | final state1 = CharacterPageState( 83 | status: CharacterPageStatus.loading, 84 | characters: [Character(id: 1, name: 'John')], 85 | hasReachedEnd: true, 86 | currentPage: 2, 87 | ); 88 | 89 | final state2 = CharacterPageState( 90 | status: CharacterPageStatus.success, 91 | characters: [Character(id: 2, name: 'Jane')], 92 | hasReachedEnd: false, 93 | currentPage: 3, 94 | ); 95 | 96 | expect(state1.props, isNot(equals(state2.props))); 97 | }); 98 | }); 99 | } 100 | -------------------------------------------------------------------------------- /ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 24 | 25 | 30 | 31 | 37 | 38 | 39 | 40 | 43 | 49 | 50 | 51 | 52 | 53 | 63 | 65 | 71 | 72 | 73 | 74 | 80 | 82 | 88 | 89 | 90 | 91 | 93 | 94 | 97 | 98 | 99 | -------------------------------------------------------------------------------- /lib/layers/presentation/shared/character_list_item.dart: -------------------------------------------------------------------------------- 1 | import 'package:cached_network_image/cached_network_image.dart'; 2 | import 'package:flutter/material.dart'; 3 | import 'package:rickmorty/layers/domain/entity/character.dart'; 4 | 5 | typedef OnCharacterListItemTap = void Function(Character character); 6 | 7 | class CharacterListItem extends StatelessWidget { 8 | const CharacterListItem({ 9 | super.key, 10 | required this.item, 11 | this.onTap, 12 | }); 13 | 14 | final Character item; 15 | final OnCharacterListItemTap? onTap; 16 | 17 | @override 18 | Widget build(BuildContext context) { 19 | return GestureDetector( 20 | onTap: () => onTap?.call(item), 21 | child: Card( 22 | elevation: 0, 23 | color: Colors.transparent, 24 | child: SizedBox( 25 | height: 124, 26 | child: Row( 27 | mainAxisAlignment: MainAxisAlignment.start, 28 | children: [ 29 | _ItemPhoto(item: item), 30 | _ItemDescription(item: item), 31 | ], 32 | ), 33 | ), 34 | ), 35 | ); 36 | } 37 | } 38 | 39 | // ----------------------------------------------------------------------------- 40 | // Helpers 41 | // ----------------------------------------------------------------------------- 42 | 43 | class _ItemDescription extends StatelessWidget { 44 | const _ItemDescription({super.key, required this.item}); 45 | 46 | final Character item; 47 | 48 | @override 49 | Widget build(BuildContext context) { 50 | final colorScheme = Theme.of(context).colorScheme; 51 | final textTheme = Theme.of(context).textTheme; 52 | 53 | return Expanded( 54 | child: Padding( 55 | padding: const EdgeInsets.only(top: 10, bottom: 10, right: 8), 56 | child: Container( 57 | decoration: BoxDecoration( 58 | borderRadius: const BorderRadius.only( 59 | topRight: Radius.circular(8), 60 | bottomRight: Radius.circular(8), 61 | ), 62 | color: colorScheme.surfaceVariant, 63 | ), 64 | child: Padding( 65 | padding: const EdgeInsets.all(12.0), 66 | child: Column( 67 | crossAxisAlignment: CrossAxisAlignment.stretch, 68 | mainAxisAlignment: MainAxisAlignment.start, 69 | children: [ 70 | const SizedBox(height: 4), 71 | Text( 72 | item.name ?? '', 73 | style: textTheme.bodyMedium!.copyWith( 74 | color: colorScheme.onSurfaceVariant, 75 | fontWeight: FontWeight.bold, 76 | ), 77 | ), 78 | const SizedBox(height: 4), 79 | Text( 80 | 'Status: ${item.isAlive ? 'ALIVE' : 'DEAD'}', 81 | style: textTheme.labelSmall!.copyWith( 82 | color: item.isAlive ? Colors.lightGreen : Colors.redAccent, 83 | ), 84 | ), 85 | const SizedBox(height: 4), 86 | Text( 87 | 'Last location: ${item.location?.name ?? ''}', 88 | style: textTheme.labelSmall!.copyWith( 89 | color: colorScheme.onSurfaceVariant, 90 | ), 91 | ), 92 | ], 93 | ), 94 | ), 95 | ), 96 | ), 97 | ); 98 | } 99 | } 100 | 101 | class _ItemPhoto extends StatelessWidget { 102 | const _ItemPhoto({super.key, required this.item}); 103 | 104 | final Character item; 105 | 106 | @override 107 | Widget build(BuildContext context) { 108 | return ClipRRect( 109 | borderRadius: const BorderRadius.all(Radius.circular(12)), 110 | child: SizedBox( 111 | height: 122, 112 | child: Hero( 113 | tag: item.id!, 114 | child: CachedNetworkImage( 115 | height: 122, 116 | width: 122, 117 | imageUrl: item.image!, 118 | fit: BoxFit.cover, 119 | errorWidget: (ctx, url, err) => const Icon(Icons.error), 120 | placeholder: (ctx, url) => const Icon(Icons.image), 121 | ), 122 | ), 123 | ), 124 | ); 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /lib/layers/presentation/using_cubit/list_page/view/character_page.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:flutter_bloc/flutter_bloc.dart'; 3 | import 'package:rickmorty/layers/domain/entity/character.dart'; 4 | import 'package:rickmorty/layers/domain/usecase/get_all_characters.dart'; 5 | import 'package:rickmorty/layers/presentation/shared/character_list_item.dart'; 6 | import 'package:rickmorty/layers/presentation/shared/character_list_item_header.dart'; 7 | import 'package:rickmorty/layers/presentation/shared/character_list_item_loading.dart'; 8 | import 'package:rickmorty/layers/presentation/using_cubit/details_page/view/character_details_page.dart'; 9 | import 'package:rickmorty/layers/presentation/using_cubit/list_page/cubit/character_page_cubit.dart'; 10 | 11 | // ----------------------------------------------------------------------------- 12 | // Page 13 | // ----------------------------------------------------------------------------- 14 | class CharacterPage extends StatelessWidget { 15 | const CharacterPage({super.key}); 16 | 17 | @override 18 | Widget build(BuildContext context) { 19 | return BlocProvider( 20 | create: (_) => CharacterPageCubit( 21 | getAllCharacters: context.read(), 22 | )..fetchNextPage(), 23 | child: const CharacterView(), 24 | ); 25 | } 26 | } 27 | 28 | // ----------------------------------------------------------------------------- 29 | // View 30 | // ----------------------------------------------------------------------------- 31 | class CharacterView extends StatelessWidget { 32 | const CharacterView({super.key}); 33 | 34 | @override 35 | Widget build(BuildContext context) { 36 | final status = context.select((CharacterPageCubit c) => c.state.status); 37 | return status == CharacterPageStatus.initial 38 | ? const Center(child: CircularProgressIndicator()) 39 | : const _Content(); 40 | } 41 | } 42 | 43 | // ----------------------------------------------------------------------------- 44 | // Content 45 | // ----------------------------------------------------------------------------- 46 | class _Content extends StatefulWidget { 47 | const _Content({super.key}); 48 | 49 | @override 50 | State<_Content> createState() => __ContentState(); 51 | } 52 | 53 | class __ContentState extends State<_Content> { 54 | final _scrollController = ScrollController(); 55 | 56 | CharacterPageCubit get pageCubit => context.read(); 57 | 58 | @override 59 | void initState() { 60 | super.initState(); 61 | _scrollController.addListener(_onScroll); 62 | } 63 | 64 | @override 65 | Widget build(BuildContext ctx) { 66 | final list = ctx.select((CharacterPageCubit b) => b.state.characters); 67 | final hasEnded = 68 | ctx.select((CharacterPageCubit b) => b.state.hasReachedEnd); 69 | 70 | return Padding( 71 | padding: const EdgeInsets.only(left: 16, right: 16), 72 | child: ListView.builder( 73 | key: const ValueKey('character_page_list_key'), 74 | controller: _scrollController, 75 | itemCount: hasEnded ? list.length : list.length + 1, 76 | itemBuilder: (context, index) { 77 | if (index >= list.length) { 78 | return !hasEnded 79 | ? const CharacterListItemLoading() 80 | : const SizedBox(); 81 | } 82 | final item = list[index]; 83 | return index == 0 84 | ? Column( 85 | children: [ 86 | const CharacterListItemHeader(titleText: 'All Characters'), 87 | CharacterListItem(item: item, onTap: _goToDetails), 88 | ], 89 | ) 90 | : CharacterListItem(item: item, onTap: _goToDetails); 91 | }, 92 | ), 93 | ); 94 | } 95 | 96 | void _goToDetails(Character character) { 97 | final route = CharacterDetailsPage.route(character: character); 98 | Navigator.of(context).push(route); 99 | } 100 | 101 | @override 102 | void dispose() { 103 | _scrollController 104 | ..removeListener(_onScroll) 105 | ..dispose(); 106 | super.dispose(); 107 | } 108 | 109 | void _onScroll() { 110 | if (_isBottom) { 111 | pageCubit.fetchNextPage(); 112 | } 113 | } 114 | 115 | bool get _isBottom { 116 | if (!_scrollController.hasClients) return false; 117 | final maxScroll = _scrollController.position.maxScrollExtent; 118 | final currentScroll = _scrollController.offset; 119 | return currentScroll >= (maxScroll * 0.9); 120 | } 121 | } 122 | -------------------------------------------------------------------------------- /lib/layers/presentation/using_bloc/list_page/view/character_page.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:flutter_bloc/flutter_bloc.dart'; 3 | import 'package:rickmorty/layers/domain/entity/character.dart'; 4 | import 'package:rickmorty/layers/domain/usecase/get_all_characters.dart'; 5 | import 'package:rickmorty/layers/presentation/shared/character_list_item.dart'; 6 | import 'package:rickmorty/layers/presentation/shared/character_list_item_header.dart'; 7 | import 'package:rickmorty/layers/presentation/shared/character_list_item_loading.dart'; 8 | import 'package:rickmorty/layers/presentation/using_bloc/details_page/view/character_details_page.dart'; 9 | import 'package:rickmorty/layers/presentation/using_bloc/list_page/bloc/character_page_bloc.dart'; 10 | 11 | // ----------------------------------------------------------------------------- 12 | // Page 13 | // ----------------------------------------------------------------------------- 14 | class CharacterPage extends StatelessWidget { 15 | const CharacterPage({super.key}); 16 | 17 | @override 18 | Widget build(BuildContext context) { 19 | return BlocProvider( 20 | create: (context) => CharacterPageBloc( 21 | getAllCharacters: context.read(), 22 | )..add(const FetchNextPageEvent()), 23 | child: const CharacterView(), 24 | ); 25 | } 26 | } 27 | 28 | // ----------------------------------------------------------------------------- 29 | // View 30 | // ----------------------------------------------------------------------------- 31 | class CharacterView extends StatelessWidget { 32 | const CharacterView({super.key}); 33 | 34 | @override 35 | Widget build(BuildContext context) { 36 | final status = context.select((CharacterPageBloc b) => b.state.status); 37 | return status == CharacterPageStatus.initial 38 | ? const Center(child: CircularProgressIndicator()) 39 | : const _Content(); 40 | } 41 | } 42 | 43 | // ----------------------------------------------------------------------------- 44 | // Content 45 | // ----------------------------------------------------------------------------- 46 | class _Content extends StatefulWidget { 47 | const _Content({super.key}); 48 | 49 | @override 50 | State<_Content> createState() => __ContentState(); 51 | } 52 | 53 | class __ContentState extends State<_Content> { 54 | final _scrollController = ScrollController(); 55 | 56 | CharacterPageBloc get pageBloc => context.read(); 57 | 58 | @override 59 | void initState() { 60 | super.initState(); 61 | _scrollController.addListener(_onScroll); 62 | } 63 | 64 | @override 65 | Widget build(BuildContext ctx) { 66 | final list = ctx.select((CharacterPageBloc b) => b.state.characters); 67 | final hasEnded = ctx.select((CharacterPageBloc b) => b.state.hasReachedEnd); 68 | 69 | return Padding( 70 | padding: const EdgeInsets.only(left: 16, right: 16), 71 | child: ListView.builder( 72 | key: const ValueKey('character_page_list_key'), 73 | controller: _scrollController, 74 | itemCount: hasEnded ? list.length : list.length + 1, 75 | itemBuilder: (context, index) { 76 | if (index >= list.length) { 77 | return !hasEnded 78 | ? const CharacterListItemLoading() 79 | : const SizedBox(); 80 | } 81 | final item = list[index]; 82 | return index == 0 83 | ? Column( 84 | children: [ 85 | const CharacterListItemHeader(titleText: 'All Characters'), 86 | CharacterListItem(item: item, onTap: _goToDetails), 87 | ], 88 | ) 89 | : CharacterListItem(item: item, onTap: _goToDetails); 90 | }, 91 | ), 92 | ); 93 | } 94 | 95 | void _goToDetails(Character character) { 96 | final route = CharacterDetailsPage.route(character: character); 97 | Navigator.of(context).push(route); 98 | } 99 | 100 | @override 101 | void dispose() { 102 | _scrollController 103 | ..removeListener(_onScroll) 104 | ..dispose(); 105 | super.dispose(); 106 | } 107 | 108 | void _onScroll() { 109 | if (_isBottom) { 110 | pageBloc.add(const FetchNextPageEvent()); 111 | } 112 | } 113 | 114 | bool get _isBottom { 115 | if (!_scrollController.hasClients) return false; 116 | final maxScroll = _scrollController.position.maxScrollExtent; 117 | final currentScroll = _scrollController.offset; 118 | return currentScroll >= (maxScroll * 0.9); 119 | } 120 | } 121 | -------------------------------------------------------------------------------- /assets/fonts/OFL.txt: -------------------------------------------------------------------------------- 1 | Copyright 2017 The Exo Project Authors (https://github.com/NDISCOVER/Exo-1.0) 2 | 3 | This Font Software is licensed under the SIL Open Font License, Version 1.1. 4 | This license is copied below, and is also available with a FAQ at: 5 | http://scripts.sil.org/OFL 6 | 7 | 8 | ----------------------------------------------------------- 9 | SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 10 | ----------------------------------------------------------- 11 | 12 | PREAMBLE 13 | The goals of the Open Font License (OFL) are to stimulate worldwide 14 | development of collaborative font projects, to support the font creation 15 | efforts of academic and linguistic communities, and to provide a free and 16 | open framework in which fonts may be shared and improved in partnership 17 | with others. 18 | 19 | The OFL allows the licensed fonts to be used, studied, modified and 20 | redistributed freely as long as they are not sold by themselves. The 21 | fonts, including any derivative works, can be bundled, embedded, 22 | redistributed and/or sold with any software provided that any reserved 23 | names are not used by derivative works. The fonts and derivatives, 24 | however, cannot be released under any other type of license. The 25 | requirement for fonts to remain under this license does not apply 26 | to any document created using the fonts or their derivatives. 27 | 28 | DEFINITIONS 29 | "Font Software" refers to the set of files released by the Copyright 30 | Holder(s) under this license and clearly marked as such. This may 31 | include source files, build scripts and documentation. 32 | 33 | "Reserved Font Name" refers to any names specified as such after the 34 | copyright statement(s). 35 | 36 | "Original Version" refers to the collection of Font Software components as 37 | distributed by the Copyright Holder(s). 38 | 39 | "Modified Version" refers to any derivative made by adding to, deleting, 40 | or substituting -- in part or in whole -- any of the components of the 41 | Original Version, by changing formats or by porting the Font Software to a 42 | new environment. 43 | 44 | "Author" refers to any designer, engineer, programmer, technical 45 | writer or other person who contributed to the Font Software. 46 | 47 | PERMISSION & CONDITIONS 48 | Permission is hereby granted, free of charge, to any person obtaining 49 | a copy of the Font Software, to use, study, copy, merge, embed, modify, 50 | redistribute, and sell modified and unmodified copies of the Font 51 | Software, subject to the following conditions: 52 | 53 | 1) Neither the Font Software nor any of its individual components, 54 | in Original or Modified Versions, may be sold by itself. 55 | 56 | 2) Original or Modified Versions of the Font Software may be bundled, 57 | redistributed and/or sold with any software, provided that each copy 58 | contains the above copyright notice and this license. These can be 59 | included either as stand-alone text files, human-readable headers or 60 | in the appropriate machine-readable metadata fields within text or 61 | binary files as long as those fields can be easily viewed by the user. 62 | 63 | 3) No Modified Version of the Font Software may use the Reserved Font 64 | Name(s) unless explicit written permission is granted by the corresponding 65 | Copyright Holder. This restriction only applies to the primary font name as 66 | presented to the users. 67 | 68 | 4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font 69 | Software shall not be used to promote, endorse or advertise any 70 | Modified Version, except to acknowledge the contribution(s) of the 71 | Copyright Holder(s) and the Author(s) or with their explicit written 72 | permission. 73 | 74 | 5) The Font Software, modified or unmodified, in part or in whole, 75 | must be distributed entirely under this license, and must not be 76 | distributed under any other license. The requirement for fonts to 77 | remain under this license does not apply to any document created 78 | using the Font Software. 79 | 80 | TERMINATION 81 | This license becomes null and void if any of the above conditions are 82 | not met. 83 | 84 | DISCLAIMER 85 | THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 86 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF 87 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT 88 | OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE 89 | COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, 90 | INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL 91 | DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 92 | FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM 93 | OTHER DEALINGS IN THE FONT SOFTWARE. -------------------------------------------------------------------------------- /lib/layers/presentation/using_get_it/list_page/view/character_page.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:get_it_mixin/get_it_mixin.dart'; 3 | import 'package:rickmorty/layers/domain/entity/character.dart'; 4 | import 'package:rickmorty/layers/presentation/shared/character_list_item.dart'; 5 | import 'package:rickmorty/layers/presentation/shared/character_list_item_header.dart'; 6 | import 'package:rickmorty/layers/presentation/shared/character_list_item_loading.dart'; 7 | import 'package:rickmorty/layers/presentation/using_get_it/details_page/view/character_details_page.dart'; 8 | import 'package:rickmorty/layers/presentation/using_get_it/list_page/controller/character_page_controller.dart'; 9 | 10 | // ----------------------------------------------------------------------------- 11 | // Page 12 | // ----------------------------------------------------------------------------- 13 | class CharacterPage extends StatelessWidget { 14 | const CharacterPage({super.key}); 15 | 16 | @override 17 | Widget build(BuildContext context) { 18 | return CharacterView(); 19 | } 20 | } 21 | 22 | // ----------------------------------------------------------------------------- 23 | // View 24 | // ----------------------------------------------------------------------------- 25 | class CharacterView extends StatefulWidget with GetItStatefulWidgetMixin { 26 | CharacterView({super.key}); 27 | 28 | @override 29 | State createState() => _CharacterViewState(); 30 | } 31 | 32 | class _CharacterViewState extends State with GetItStateMixin { 33 | @override 34 | void initState() { 35 | super.initState(); 36 | WidgetsBinding.instance.addPostFrameCallback((_) { 37 | get().fetchNextPage(); 38 | }); 39 | } 40 | 41 | @override 42 | Widget build(BuildContext context) { 43 | final status = watchX((CharacterPageController state) => state.status); 44 | return status == CharacterPageStatus.initial 45 | ? const Center(child: CircularProgressIndicator()) 46 | : _Content(); 47 | } 48 | } 49 | 50 | // ----------------------------------------------------------------------------- 51 | // Content 52 | // ----------------------------------------------------------------------------- 53 | class _Content extends StatefulWidget with GetItStatefulWidgetMixin { 54 | _Content({super.key}); 55 | 56 | @override 57 | State<_Content> createState() => __ContentState(); 58 | } 59 | 60 | class __ContentState extends State<_Content> with GetItStateMixin { 61 | final _scrollController = ScrollController(); 62 | 63 | @override 64 | void initState() { 65 | super.initState(); 66 | _scrollController.addListener(_onScroll); 67 | } 68 | 69 | @override 70 | Widget build(BuildContext context) { 71 | final list = watchX((CharacterPageController s) => s.characters); 72 | final hasEnded = watchX((CharacterPageController s) => s.hasReachedEnd); 73 | 74 | return Padding( 75 | padding: const EdgeInsets.only(left: 16, right: 16), 76 | child: ListView.builder( 77 | key: const ValueKey('character_page_list_key'), 78 | controller: _scrollController, 79 | itemCount: hasEnded ? list.length : list.length + 1, 80 | itemBuilder: (context, index) { 81 | if (index >= list.length) { 82 | return !hasEnded 83 | ? const CharacterListItemLoading() 84 | : const SizedBox(); 85 | } 86 | final item = list[index]; 87 | return index == 0 88 | ? Column( 89 | children: [ 90 | const CharacterListItemHeader(titleText: 'All Characters'), 91 | CharacterListItem(item: item, onTap: _goToDetails), 92 | ], 93 | ) 94 | : CharacterListItem(item: item, onTap: _goToDetails); 95 | }, 96 | ), 97 | ); 98 | } 99 | 100 | void _goToDetails(Character character) { 101 | final route = CharacterDetailsPage.route(character: character); 102 | Navigator.of(context).push(route); 103 | } 104 | 105 | @override 106 | void dispose() { 107 | _scrollController 108 | ..removeListener(_onScroll) 109 | ..dispose(); 110 | super.dispose(); 111 | } 112 | 113 | void _onScroll() { 114 | if (_isBottom) { 115 | get().fetchNextPage(); 116 | } 117 | } 118 | 119 | bool get _isBottom { 120 | if (!_scrollController.hasClients) return false; 121 | final maxScroll = _scrollController.position.maxScrollExtent; 122 | final currentScroll = _scrollController.offset; 123 | return currentScroll >= (maxScroll * 0.9); 124 | } 125 | } 126 | -------------------------------------------------------------------------------- /linux/my_application.cc: -------------------------------------------------------------------------------- 1 | #include "my_application.h" 2 | 3 | #include 4 | #ifdef GDK_WINDOWING_X11 5 | #include 6 | #endif 7 | 8 | #include "flutter/generated_plugin_registrant.h" 9 | 10 | struct _MyApplication { 11 | GtkApplication parent_instance; 12 | char** dart_entrypoint_arguments; 13 | }; 14 | 15 | G_DEFINE_TYPE(MyApplication, my_application, GTK_TYPE_APPLICATION) 16 | 17 | // Implements GApplication::activate. 18 | static void my_application_activate(GApplication* application) { 19 | MyApplication* self = MY_APPLICATION(application); 20 | GtkWindow* window = 21 | GTK_WINDOW(gtk_application_window_new(GTK_APPLICATION(application))); 22 | 23 | // Use a header bar when running in GNOME as this is the common style used 24 | // by applications and is the setup most users will be using (e.g. Ubuntu 25 | // desktop). 26 | // If running on X and not using GNOME then just use a traditional title bar 27 | // in case the window manager does more exotic layout, e.g. tiling. 28 | // If running on Wayland assume the header bar will work (may need changing 29 | // if future cases occur). 30 | gboolean use_header_bar = TRUE; 31 | #ifdef GDK_WINDOWING_X11 32 | GdkScreen* screen = gtk_window_get_screen(window); 33 | if (GDK_IS_X11_SCREEN(screen)) { 34 | const gchar* wm_name = gdk_x11_screen_get_window_manager_name(screen); 35 | if (g_strcmp0(wm_name, "GNOME Shell") != 0) { 36 | use_header_bar = FALSE; 37 | } 38 | } 39 | #endif 40 | if (use_header_bar) { 41 | GtkHeaderBar* header_bar = GTK_HEADER_BAR(gtk_header_bar_new()); 42 | gtk_widget_show(GTK_WIDGET(header_bar)); 43 | gtk_header_bar_set_title(header_bar, "flutter_clean_architecture_example"); 44 | gtk_header_bar_set_show_close_button(header_bar, TRUE); 45 | gtk_window_set_titlebar(window, GTK_WIDGET(header_bar)); 46 | } else { 47 | gtk_window_set_title(window, "flutter_clean_architecture_example"); 48 | } 49 | 50 | gtk_window_set_default_size(window, 1280, 720); 51 | gtk_widget_show(GTK_WIDGET(window)); 52 | 53 | g_autoptr(FlDartProject) project = fl_dart_project_new(); 54 | fl_dart_project_set_dart_entrypoint_arguments(project, self->dart_entrypoint_arguments); 55 | 56 | FlView* view = fl_view_new(project); 57 | gtk_widget_show(GTK_WIDGET(view)); 58 | gtk_container_add(GTK_CONTAINER(window), GTK_WIDGET(view)); 59 | 60 | fl_register_plugins(FL_PLUGIN_REGISTRY(view)); 61 | 62 | gtk_widget_grab_focus(GTK_WIDGET(view)); 63 | } 64 | 65 | // Implements GApplication::local_command_line. 66 | static gboolean my_application_local_command_line(GApplication* application, gchar*** arguments, int* exit_status) { 67 | MyApplication* self = MY_APPLICATION(application); 68 | // Strip out the first argument as it is the binary name. 69 | self->dart_entrypoint_arguments = g_strdupv(*arguments + 1); 70 | 71 | g_autoptr(GError) error = nullptr; 72 | if (!g_application_register(application, nullptr, &error)) { 73 | g_warning("Failed to register: %s", error->message); 74 | *exit_status = 1; 75 | return TRUE; 76 | } 77 | 78 | g_application_activate(application); 79 | *exit_status = 0; 80 | 81 | return TRUE; 82 | } 83 | 84 | // Implements GApplication::startup. 85 | static void my_application_startup(GApplication* application) { 86 | //MyApplication* self = MY_APPLICATION(object); 87 | 88 | // Perform any actions required at application startup. 89 | 90 | G_APPLICATION_CLASS(my_application_parent_class)->startup(application); 91 | } 92 | 93 | // Implements GApplication::shutdown. 94 | static void my_application_shutdown(GApplication* application) { 95 | //MyApplication* self = MY_APPLICATION(object); 96 | 97 | // Perform any actions required at application shutdown. 98 | 99 | G_APPLICATION_CLASS(my_application_parent_class)->shutdown(application); 100 | } 101 | 102 | // Implements GObject::dispose. 103 | static void my_application_dispose(GObject* object) { 104 | MyApplication* self = MY_APPLICATION(object); 105 | g_clear_pointer(&self->dart_entrypoint_arguments, g_strfreev); 106 | G_OBJECT_CLASS(my_application_parent_class)->dispose(object); 107 | } 108 | 109 | static void my_application_class_init(MyApplicationClass* klass) { 110 | G_APPLICATION_CLASS(klass)->activate = my_application_activate; 111 | G_APPLICATION_CLASS(klass)->local_command_line = my_application_local_command_line; 112 | G_APPLICATION_CLASS(klass)->startup = my_application_startup; 113 | G_APPLICATION_CLASS(klass)->shutdown = my_application_shutdown; 114 | G_OBJECT_CLASS(klass)->dispose = my_application_dispose; 115 | } 116 | 117 | static void my_application_init(MyApplication* self) {} 118 | 119 | MyApplication* my_application_new() { 120 | return MY_APPLICATION(g_object_new(my_application_get_type(), 121 | "application-id", APPLICATION_ID, 122 | "flags", G_APPLICATION_NON_UNIQUE, 123 | nullptr)); 124 | } 125 | -------------------------------------------------------------------------------- /lib/layers/presentation/using_provider/list_page/view/character_page.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:provider/provider.dart'; 3 | import 'package:rickmorty/layers/domain/entity/character.dart'; 4 | import 'package:rickmorty/layers/domain/usecase/get_all_characters.dart'; 5 | import 'package:rickmorty/layers/presentation/shared/character_list_item.dart'; 6 | import 'package:rickmorty/layers/presentation/shared/character_list_item_header.dart'; 7 | import 'package:rickmorty/layers/presentation/shared/character_list_item_loading.dart'; 8 | import 'package:rickmorty/layers/presentation/using_provider/details_page/view/character_details_page.dart'; 9 | import 'package:rickmorty/layers/presentation/using_provider/list_page/change_notifier/character_page_change_notifier.dart'; 10 | 11 | // ----------------------------------------------------------------------------- 12 | // Page 13 | // ----------------------------------------------------------------------------- 14 | class CharacterPage extends StatelessWidget { 15 | const CharacterPage({super.key}); 16 | 17 | @override 18 | Widget build(BuildContext context) { 19 | final useCase = context.read(); 20 | return ChangeNotifierProvider( 21 | create: (_) => CharacterPageChangeNotifier(getAllCharacters: useCase), 22 | child: const CharacterView(), 23 | ); 24 | } 25 | } 26 | 27 | // ----------------------------------------------------------------------------- 28 | // View 29 | // ----------------------------------------------------------------------------- 30 | class CharacterView extends StatefulWidget { 31 | const CharacterView({super.key}); 32 | 33 | @override 34 | State createState() => _CharacterViewState(); 35 | } 36 | 37 | class _CharacterViewState extends State { 38 | @override 39 | void initState() { 40 | super.initState(); 41 | WidgetsBinding.instance.addPostFrameCallback((_) { 42 | context.read().fetchNextPage(); 43 | }); 44 | } 45 | 46 | @override 47 | Widget build(BuildContext context) { 48 | final status = context.select((CharacterPageChangeNotifier c) => c.status); 49 | return status == CharacterPageStatus.initial 50 | ? const Center(child: CircularProgressIndicator()) 51 | : const _Content(); 52 | } 53 | } 54 | 55 | // ----------------------------------------------------------------------------- 56 | // Content 57 | // ----------------------------------------------------------------------------- 58 | class _Content extends StatefulWidget { 59 | const _Content({super.key}); 60 | 61 | @override 62 | State<_Content> createState() => __ContentState(); 63 | } 64 | 65 | class __ContentState extends State<_Content> { 66 | final _scrollController = ScrollController(); 67 | 68 | @override 69 | void initState() { 70 | super.initState(); 71 | _scrollController.addListener(_onScroll); 72 | } 73 | 74 | @override 75 | Widget build(BuildContext context) { 76 | final list = 77 | context.select((CharacterPageChangeNotifier b) => b.characters); 78 | final hasEnded = 79 | context.select((CharacterPageChangeNotifier b) => b.hasReachedEnd); 80 | 81 | return Padding( 82 | padding: const EdgeInsets.only(left: 16, right: 16), 83 | child: ListView.builder( 84 | key: const ValueKey('character_page_list_key'), 85 | controller: _scrollController, 86 | itemCount: hasEnded ? list.length : list.length + 1, 87 | itemBuilder: (context, index) { 88 | if (index >= list.length) { 89 | return !hasEnded 90 | ? const CharacterListItemLoading() 91 | : const SizedBox(); 92 | } 93 | final item = list[index]; 94 | return index == 0 95 | ? Column( 96 | children: [ 97 | const CharacterListItemHeader(titleText: 'All Characters'), 98 | CharacterListItem(item: item, onTap: _goToDetailsPage), 99 | ], 100 | ) 101 | : CharacterListItem(item: item, onTap: _goToDetailsPage); 102 | }, 103 | ), 104 | ); 105 | } 106 | 107 | void _goToDetailsPage(Character character) { 108 | final route = CharacterDetailsPage.route(character: character); 109 | Navigator.of(context).push(route); 110 | } 111 | 112 | @override 113 | void dispose() { 114 | _scrollController 115 | ..removeListener(_onScroll) 116 | ..dispose(); 117 | super.dispose(); 118 | } 119 | 120 | void _onScroll() { 121 | if (_isBottom) { 122 | context.read().fetchNextPage(); 123 | } 124 | } 125 | 126 | bool get _isBottom { 127 | if (!_scrollController.hasClients) return false; 128 | final maxScroll = _scrollController.position.maxScrollExtent; 129 | final currentScroll = _scrollController.offset; 130 | return currentScroll >= (maxScroll * 0.9); 131 | } 132 | } 133 | -------------------------------------------------------------------------------- /pubspec.yaml: -------------------------------------------------------------------------------- 1 | name: rickmorty 2 | description: A new Flutter project. 3 | # The following line prevents the package from being accidentally published to 4 | # pub.dev using `flutter pub publish`. This is preferred for private packages. 5 | publish_to: 'none' # Remove this line if you wish to publish to pub.dev 6 | 7 | # The following defines the version and build number for your application. 8 | # A version number is three numbers separated by dots, like 1.2.43 9 | # followed by an optional build number separated by a +. 10 | # Both the version and the builder number may be overridden in flutter 11 | # build by specifying --build-name and --build-number, respectively. 12 | # In Android, build-name is used as versionName while build-number used as versionCode. 13 | # Read more about Android versioning at https://developer.android.com/studio/publish/versioning 14 | # In iOS, build-name is used as CFBundleShortVersionString while build-number is used as CFBundleVersion. 15 | # Read more about iOS versioning at 16 | # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html 17 | # In Windows, build-name is used as the major, minor, and patch parts 18 | # of the product and file versions while build-number is used as the build suffix. 19 | version: 1.0.0+1 20 | 21 | environment: 22 | sdk: '>=3.0.1 <4.0.0' 23 | 24 | # Dependencies specify other packages that your package needs in order to work. 25 | # To automatically upgrade your package dependencies to the latest versions 26 | # consider running `flutter pub upgrade --major-versions`. Alternatively, 27 | # dependencies can be manually updated by changing the version numbers below to 28 | # the latest version available on pub.dev. To see which dependencies have newer 29 | # versions available, run `flutter pub outdated`. 30 | dependencies: 31 | flutter: 32 | sdk: flutter 33 | 34 | 35 | # The following adds the Cupertino Icons font to your application. 36 | # Use with the CupertinoIcons class for iOS style icons. 37 | bloc: ^8.1.2 38 | bloc_concurrency: ^0.2.2 39 | bloc_test: ^9.1.3 40 | cached_network_image: ^3.2.3 41 | cupertino_icons: ^1.0.2 42 | dio: ^5.1.2 43 | equatable: ^2.0.5 44 | flutter_animate: ^4.2.0 45 | flutter_bloc: ^8.1.3 46 | flutter_localizations: 47 | sdk: flutter 48 | flutter_mobx: ^2.0.6+5 49 | flutter_riverpod: ^2.3.6 50 | get_it: ^7.6.0 51 | get_it_mixin: ^4.2.0 52 | google_fonts: ^6.2.1 53 | intl: any 54 | material_color_utilities: ^0.8.0 55 | provider: ^6.0.5 56 | shared_preferences: ^2.1.2 57 | stream_transform: ^2.1.0 58 | mobx: ^2.2.1 59 | meta: ^1.9.1 60 | 61 | dev_dependencies: 62 | flutter_test: 63 | sdk: flutter 64 | 65 | # The "flutter_lints" package below contains a set of recommended lints to 66 | # encourage good coding practices. The lint set provided by the package is 67 | # activated in the `analysis_options.yaml` file located at the root of your 68 | # package. See that file for information about deactivating specific lint 69 | # rules and activating additional ones. 70 | flutter_lints: ^3.0.2 71 | test: ^1.24.1 72 | mocktail: ^1.0.3 73 | mockingjay: ^0.5.0 74 | build_runner: ^2.3.3 75 | mobx_codegen: ^2.1.1 76 | 77 | # For information on the generic Dart part of this file, see the 78 | # following page: https://dart.dev/tools/pub/pubspec 79 | 80 | # The following section is specific to Flutter packages. 81 | flutter: 82 | 83 | # The following line ensures that the Material Icons font is 84 | # included with your application, so that you can use the icons in 85 | # the material Icons class. 86 | uses-material-design: true 87 | 88 | # To add assets to your application, add an assets section, like this: 89 | # assets: 90 | # - images/a_dot_burr.jpeg 91 | # - images/a_dot_ham.jpeg 92 | 93 | # An image asset can refer to one or more resolution-specific "variants", see 94 | # https://flutter.dev/assets-and-images/#resolution-aware 95 | 96 | # For details regarding adding assets from package dependencies, see 97 | # https://flutter.dev/assets-and-images/#from-packages 98 | 99 | # To add custom fonts to your application, add a fonts section here, 100 | # in this "flutter" section. Each entry in this list should have a 101 | # "family" key with the font family name, and a "fonts" key with a 102 | # list giving the asset and other descriptors for the font. For 103 | # example: 104 | # fonts: 105 | # - family: Schyler 106 | # fonts: 107 | # - asset: fonts/Schyler-Regular.ttf 108 | # - asset: fonts/Schyler-Italic.ttf 109 | # style: italic 110 | # - family: Trajan Pro 111 | # fonts: 112 | # - asset: fonts/TrajanPro.ttf 113 | # - asset: fonts/TrajanPro_Bold.ttf 114 | # weight: 700 115 | # 116 | # For details regarding fonts from package dependencies, 117 | # see https://flutter.dev/custom-fonts/#from-packages 118 | fonts: 119 | - family: Exo 120 | fonts: 121 | - asset: assets/fonts/Exo-Bold.ttf 122 | - asset: assets/fonts/Exo-Medium.ttf 123 | assets: 124 | - assets/fonts/ 125 | -------------------------------------------------------------------------------- /lib/layers/presentation/using_riverpod/list_page/view/character_page.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:flutter_riverpod/flutter_riverpod.dart'; 3 | import 'package:rickmorty/layers/domain/entity/character.dart'; 4 | import 'package:rickmorty/layers/presentation/shared/character_list_item.dart'; 5 | import 'package:rickmorty/layers/presentation/shared/character_list_item_header.dart'; 6 | import 'package:rickmorty/layers/presentation/shared/character_list_item_loading.dart'; 7 | import 'package:rickmorty/layers/presentation/using_riverpod/details_page/view/details_page.dart'; 8 | import 'package:rickmorty/layers/presentation/using_riverpod/list_page/notifier/character_page_state.dart'; 9 | import 'package:rickmorty/layers/presentation/using_riverpod/list_page/notifier/character_state_notifier.dart'; 10 | 11 | // ----------------------------------------------------------------------------- 12 | // Page 13 | // ----------------------------------------------------------------------------- 14 | class CharacterPage extends StatelessWidget { 15 | const CharacterPage({super.key}); 16 | 17 | @override 18 | Widget build(BuildContext context) { 19 | return const CharacterView(); 20 | } 21 | } 22 | 23 | // ----------------------------------------------------------------------------- 24 | // View 25 | // ----------------------------------------------------------------------------- 26 | class CharacterView extends ConsumerStatefulWidget { 27 | const CharacterView({super.key}); 28 | 29 | @override 30 | ConsumerState createState() => _CharacterViewState(); 31 | } 32 | 33 | class _CharacterViewState extends ConsumerState { 34 | @override 35 | void initState() { 36 | super.initState(); 37 | 38 | WidgetsBinding.instance.addPostFrameCallback((_) { 39 | ref.read(characterPageStateProvider.notifier).fetchNextPage(); 40 | }); 41 | } 42 | 43 | @override 44 | Widget build(BuildContext context) { 45 | final status = ref.watch( 46 | characterPageStateProvider.select((p) => p.status), 47 | ); 48 | 49 | return status == CharacterPageStatus.initial 50 | ? const Center(child: CircularProgressIndicator()) 51 | : const _Content(); 52 | } 53 | } 54 | 55 | // ----------------------------------------------------------------------------- 56 | // Content 57 | // ----------------------------------------------------------------------------- 58 | class _Content extends ConsumerStatefulWidget { 59 | const _Content({ 60 | super.key, 61 | }); 62 | 63 | @override 64 | ConsumerState<_Content> createState() => __ContentState(); 65 | } 66 | 67 | class __ContentState extends ConsumerState<_Content> { 68 | final _scrollController = ScrollController(); 69 | 70 | @override 71 | void initState() { 72 | super.initState(); 73 | _scrollController.addListener(_onScroll); 74 | } 75 | 76 | @override 77 | Widget build(BuildContext context) { 78 | return Padding( 79 | padding: const EdgeInsets.only(left: 16, right: 16), 80 | child: Consumer( 81 | builder: (BuildContext context, WidgetRef ref, Widget? child) { 82 | final state = ref.watch(characterPageStateProvider); 83 | final list = state.characters; 84 | final hasEnded = state.hasReachedEnd; 85 | 86 | return ListView.builder( 87 | key: const ValueKey('character_page_list_key'), 88 | controller: _scrollController, 89 | itemCount: hasEnded ? list.length : list.length + 1, 90 | itemBuilder: (context, index) { 91 | if (index >= list.length) { 92 | return !hasEnded 93 | ? const CharacterListItemLoading() 94 | : const SizedBox(); 95 | } 96 | final item = list[index]; 97 | return index == 0 98 | ? Column( 99 | children: [ 100 | const CharacterListItemHeader( 101 | titleText: 'All Characters', 102 | ), 103 | CharacterListItem(item: item, onTap: _goToDetailsPage), 104 | ], 105 | ) 106 | : CharacterListItem(item: item, onTap: _goToDetailsPage); 107 | }, 108 | ); 109 | }, 110 | ), 111 | ); 112 | } 113 | 114 | void _goToDetailsPage(Character character) { 115 | final route = DetailsPage.route(character: character); 116 | Navigator.of(context).push(route); 117 | } 118 | 119 | @override 120 | void dispose() { 121 | _scrollController 122 | ..removeListener(_onScroll) 123 | ..dispose(); 124 | super.dispose(); 125 | } 126 | 127 | void _onScroll() { 128 | if (_isBottom) { 129 | ref.read(characterPageStateProvider.notifier).fetchNextPage(); 130 | } 131 | } 132 | 133 | bool get _isBottom { 134 | if (!_scrollController.hasClients) return false; 135 | final maxScroll = _scrollController.position.maxScrollExtent; 136 | final currentScroll = _scrollController.offset; 137 | return currentScroll >= (maxScroll * 0.9); 138 | } 139 | } 140 | -------------------------------------------------------------------------------- /lib/layers/presentation/using_mobx/list_page/view/character_page.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:flutter_mobx/flutter_mobx.dart'; 3 | import 'package:provider/provider.dart'; 4 | import 'package:rickmorty/layers/domain/entity/character.dart'; 5 | import 'package:rickmorty/layers/domain/usecase/get_all_characters.dart'; 6 | import 'package:rickmorty/layers/presentation/shared/character_list_item.dart'; 7 | import 'package:rickmorty/layers/presentation/shared/character_list_item_header.dart'; 8 | import 'package:rickmorty/layers/presentation/shared/character_list_item_loading.dart'; 9 | import 'package:rickmorty/layers/presentation/using_mobx/list_page/store/character_page_store.dart'; 10 | 11 | import 'package:rickmorty/layers/presentation/using_mobx/details_page/view/character_details_page.dart'; 12 | 13 | // ----------------------------------------------------------------------------- 14 | // Page 15 | // ----------------------------------------------------------------------------- 16 | class CharacterPage extends StatelessWidget { 17 | const CharacterPage({super.key}); 18 | 19 | @override 20 | Widget build(BuildContext context) { 21 | // 22 | // We're creating an Store here and passing it manually as dependency to 23 | // constructors. 24 | // 25 | // A different option would be to use Provider to provide it down to the 26 | // widget tree. This approach is also suggested by the MobX's documentation 27 | // 28 | return CharacterView( 29 | store: CharacterPageStore( 30 | getAllCharacters: context.read(), 31 | ), 32 | ); 33 | } 34 | } 35 | 36 | // ----------------------------------------------------------------------------- 37 | // View 38 | // ----------------------------------------------------------------------------- 39 | class CharacterView extends StatefulWidget { 40 | const CharacterView({super.key, required this.store}); 41 | 42 | final CharacterPageStore store; 43 | 44 | @override 45 | State createState() => _CharacterViewState(); 46 | } 47 | 48 | class _CharacterViewState extends State { 49 | @override 50 | void initState() { 51 | super.initState(); 52 | WidgetsBinding.instance.addPostFrameCallback((_) { 53 | widget.store.fetchNextPage(); 54 | }); 55 | } 56 | 57 | @override 58 | Widget build(BuildContext context) { 59 | return Observer( 60 | builder: (_) => widget.store.contentStatus == CharacterPageStatus.loading 61 | ? const Center(child: CircularProgressIndicator()) 62 | : _Content(store: widget.store), 63 | ); 64 | } 65 | } 66 | 67 | // ----------------------------------------------------------------------------- 68 | // Content 69 | // ----------------------------------------------------------------------------- 70 | class _Content extends StatefulWidget { 71 | const _Content({super.key, required this.store}); 72 | 73 | final CharacterPageStore store; 74 | 75 | @override 76 | State<_Content> createState() => __ContentState(); 77 | } 78 | 79 | class __ContentState extends State<_Content> { 80 | final _scrollController = ScrollController(); 81 | 82 | @override 83 | void initState() { 84 | super.initState(); 85 | _scrollController.addListener(_onScroll); 86 | } 87 | 88 | @override 89 | Widget build(BuildContext context) { 90 | return Observer( 91 | builder: (context) { 92 | final list = widget.store.charactersList.toList(); 93 | final hasEnded = widget.store.hasReachedEnd; 94 | 95 | return Padding( 96 | padding: const EdgeInsets.only(left: 16, right: 16), 97 | child: ListView.builder( 98 | key: const ValueKey('character_page_list_key'), 99 | controller: _scrollController, 100 | itemCount: hasEnded ? list.length : list.length + 1, 101 | itemBuilder: (context, index) { 102 | if (index >= list.length) { 103 | return !hasEnded 104 | ? const CharacterListItemLoading() 105 | : const SizedBox(); 106 | } 107 | final item = list[index]; 108 | return index == 0 109 | ? Column( 110 | children: [ 111 | const CharacterListItemHeader( 112 | titleText: 'All Characters', 113 | ), 114 | CharacterListItem(item: item, onTap: _goToDetails), 115 | ], 116 | ) 117 | : CharacterListItem(item: item, onTap: _goToDetails); 118 | }, 119 | ), 120 | ); 121 | }, 122 | ); 123 | } 124 | 125 | void _goToDetails(Character character) { 126 | final route = CharacterDetailsPage.route(character: character); 127 | Navigator.of(context).push(route); 128 | } 129 | 130 | @override 131 | void dispose() { 132 | _scrollController 133 | ..removeListener(_onScroll) 134 | ..dispose(); 135 | super.dispose(); 136 | } 137 | 138 | void _onScroll() { 139 | if (_isBottom) { 140 | widget.store.fetchNextPage(); 141 | } 142 | } 143 | 144 | bool get _isBottom { 145 | if (!_scrollController.hasClients) return false; 146 | final maxScroll = _scrollController.position.maxScrollExtent; 147 | final currentScroll = _scrollController.offset; 148 | return currentScroll >= (maxScroll * 0.9); 149 | } 150 | } 151 | --------------------------------------------------------------------------------