├── ios
├── Flutter
│ ├── Debug.xcconfig
│ ├── Release.xcconfig
│ └── AppFrameworkInfo.plist
├── 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
├── Runner.xcodeproj
│ ├── project.xcworkspace
│ │ ├── contents.xcworkspacedata
│ │ └── xcshareddata
│ │ │ ├── WorkspaceSettings.xcsettings
│ │ │ └── IDEWorkspaceChecks.plist
│ ├── xcshareddata
│ │ └── xcschemes
│ │ │ └── Runner.xcscheme
│ └── project.pbxproj
├── Runner.xcworkspace
│ ├── contents.xcworkspacedata
│ └── xcshareddata
│ │ ├── WorkspaceSettings.xcsettings
│ │ └── IDEWorkspaceChecks.plist
└── .gitignore
├── 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
│ │ │ │ └── dev
│ │ │ │ │ └── dacianflorea
│ │ │ │ │ └── rxdart_state_management
│ │ │ │ │ └── rxdart_state_management_article
│ │ │ │ │ └── MainActivity.kt
│ │ │ └── AndroidManifest.xml
│ │ ├── debug
│ │ │ └── AndroidManifest.xml
│ │ └── profile
│ │ │ └── AndroidManifest.xml
│ └── build.gradle
├── gradle
│ └── wrapper
│ │ └── gradle-wrapper.properties
├── .gitignore
├── settings.gradle
└── build.gradle
├── lib
├── features
│ └── universities_feed
│ │ ├── domain
│ │ ├── repository
│ │ │ └── untiversities_repository.dart
│ │ ├── entity
│ │ │ └── university.dart
│ │ └── usecase
│ │ │ └── get_universities_by_country_use_case.dart
│ │ ├── data
│ │ ├── source
│ │ │ ├── network
│ │ │ │ ├── endpoint
│ │ │ │ │ └── university_endpoint.dart
│ │ │ │ └── model
│ │ │ │ │ └── api_university_model.dart
│ │ │ └── university_remote_data_source.dart
│ │ └── repository
│ │ │ └── university_repository_impl.dart
│ │ └── presentation
│ │ ├── models
│ │ ├── university_screen_state.dart
│ │ └── university_screen_model.dart
│ │ └── screen
│ │ ├── universities_view_model.dart
│ │ ├── universities_screen.dart
│ │ └── universities_screen_manual_subscription.dart
├── network_config
│ ├── json_api_response.dart
│ ├── app_result.dart
│ ├── retrofit_client.dart
│ ├── error_convertor.dart
│ └── api_error.dart
├── main.dart
├── app.dart
└── utils
│ ├── extensions
│ ├── iterable_extensions.dart
│ ├── map_extensions.dart
│ └── future_extensions.dart
│ └── app_config.dart
├── test
├── widget_test
│ └── widget_test.dart
└── unit_test
│ ├── universities_feed
│ ├── presentation
│ │ ├── model
│ │ │ └── university_screen_model_test.dart
│ │ └── screen
│ │ │ └── universities_view_model_test.dart
│ ├── data
│ │ ├── repository
│ │ │ └── universitiy_repository_impl_test.dart
│ │ └── source
│ │ │ └── network
│ │ │ ├── model
│ │ │ └── api_university_model_test.dart
│ │ │ ├── university_remote_data_source_test.dart
│ │ │ └── endpoint
│ │ │ └── university_endpoint_test.dart
│ └── domain
│ │ └── usecase
│ │ └── get_universities_by_country_use_case_test.dart
│ ├── network_config
│ ├── mock_interceptor
│ │ └── dio_mock_responses_adapter.dart
│ └── dio_error_convertor_test.dart
│ └── extensions
│ ├── future_extensions_test.dart
│ ├── iterable_extensions_test.dart
│ └── map_extensions_test.dart
├── .metadata
├── README.md
├── analysis_options.yaml
├── pubspec.yaml
├── .gitignore
└── pubspec.lock
/ios/Flutter/Debug.xcconfig:
--------------------------------------------------------------------------------
1 | #include "Generated.xcconfig"
2 |
--------------------------------------------------------------------------------
/ios/Flutter/Release.xcconfig:
--------------------------------------------------------------------------------
1 | #include "Generated.xcconfig"
2 |
--------------------------------------------------------------------------------
/ios/Runner/Runner-Bridging-Header.h:
--------------------------------------------------------------------------------
1 | #import "GeneratedPluginRegistrant.h"
2 |
--------------------------------------------------------------------------------
/web/favicon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dacianf/flutter_rxdart_state_management/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/dacianf/flutter_rxdart_state_management/HEAD/web/icons/Icon-192.png
--------------------------------------------------------------------------------
/web/icons/Icon-512.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dacianf/flutter_rxdart_state_management/HEAD/web/icons/Icon-512.png
--------------------------------------------------------------------------------
/web/icons/Icon-maskable-192.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dacianf/flutter_rxdart_state_management/HEAD/web/icons/Icon-maskable-192.png
--------------------------------------------------------------------------------
/web/icons/Icon-maskable-512.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dacianf/flutter_rxdart_state_management/HEAD/web/icons/Icon-maskable-512.png
--------------------------------------------------------------------------------
/android/app/src/main/res/mipmap-hdpi/ic_launcher.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dacianf/flutter_rxdart_state_management/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/dacianf/flutter_rxdart_state_management/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/dacianf/flutter_rxdart_state_management/HEAD/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png
--------------------------------------------------------------------------------
/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dacianf/flutter_rxdart_state_management/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/dacianf/flutter_rxdart_state_management/HEAD/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
--------------------------------------------------------------------------------
/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dacianf/flutter_rxdart_state_management/HEAD/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png
--------------------------------------------------------------------------------
/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dacianf/flutter_rxdart_state_management/HEAD/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png
--------------------------------------------------------------------------------
/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dacianf/flutter_rxdart_state_management/HEAD/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png
--------------------------------------------------------------------------------
/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dacianf/flutter_rxdart_state_management/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/dacianf/flutter_rxdart_state_management/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/dacianf/flutter_rxdart_state_management/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/dacianf/flutter_rxdart_state_management/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/dacianf/flutter_rxdart_state_management/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/dacianf/flutter_rxdart_state_management/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/dacianf/flutter_rxdart_state_management/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/dacianf/flutter_rxdart_state_management/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/dacianf/flutter_rxdart_state_management/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/dacianf/flutter_rxdart_state_management/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/dacianf/flutter_rxdart_state_management/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/dacianf/flutter_rxdart_state_management/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/dacianf/flutter_rxdart_state_management/HEAD/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png
--------------------------------------------------------------------------------
/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dacianf/flutter_rxdart_state_management/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/dacianf/flutter_rxdart_state_management/HEAD/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png
--------------------------------------------------------------------------------
/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata:
--------------------------------------------------------------------------------
1 |
2 |
4 |
6 |
7 |
8 |
--------------------------------------------------------------------------------
/ios/Runner.xcworkspace/contents.xcworkspacedata:
--------------------------------------------------------------------------------
1 |
2 |
4 |
6 |
7 |
8 |
--------------------------------------------------------------------------------
/android/gradle/wrapper/gradle-wrapper.properties:
--------------------------------------------------------------------------------
1 | #Fri Jun 23 08:50:38 CEST 2017
2 | distributionBase=GRADLE_USER_HOME
3 | distributionPath=wrapper/dists
4 | zipStoreBase=GRADLE_USER_HOME
5 | zipStorePath=wrapper/dists
6 | distributionUrl=https\://services.gradle.org/distributions/gradle-7.4-all.zip
7 |
--------------------------------------------------------------------------------
/android/app/src/main/kotlin/dev/dacianflorea/rxdart_state_management/rxdart_state_management_article/MainActivity.kt:
--------------------------------------------------------------------------------
1 | package dev.dacianflorea.rxdart_state_management.rxdart_state_management_article
2 |
3 | import io.flutter.embedding.android.FlutterActivity
4 |
5 | class MainActivity : FlutterActivity()
6 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/lib/features/universities_feed/domain/repository/untiversities_repository.dart:
--------------------------------------------------------------------------------
1 | import 'package:rxdart_state_management_article/features/universities_feed/domain/entity/university.dart';
2 | import 'package:rxdart_state_management_article/network_config/app_result.dart';
3 |
4 | abstract class UniversitiesRepository {
5 | Stream>> getUniversities(String? country);
6 | }
7 |
--------------------------------------------------------------------------------
/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
4 | this directory.
5 |
6 | You can also do it by opening your Flutter project's Xcode project
7 | with `open ios/Runner.xcworkspace`, selecting `Runner/Assets.xcassets` in the Project Navigator and
8 | dropping in the desired images.
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/android/app/src/debug/AndroidManifest.xml:
--------------------------------------------------------------------------------
1 |
3 |
7 |
8 |
9 |
--------------------------------------------------------------------------------
/android/app/src/profile/AndroidManifest.xml:
--------------------------------------------------------------------------------
1 |
3 |
7 |
8 |
9 |
--------------------------------------------------------------------------------
/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 |
11 |
12 |
--------------------------------------------------------------------------------
/lib/network_config/json_api_response.dart:
--------------------------------------------------------------------------------
1 | import 'package:flutter/foundation.dart';
2 | import 'package:freezed_annotation/freezed_annotation.dart';
3 |
4 | part 'json_api_response.freezed.dart';
5 |
6 | part 'json_api_response.g.dart';
7 |
8 | @freezed
9 | class JsonApiResponse with _$JsonApiResponse {
10 | factory JsonApiResponse({required Map json}) =
11 | _JsonApiResponse;
12 |
13 | factory JsonApiResponse.fromJson(Map json) =>
14 | _$JsonApiResponseFromJson(json);
15 | }
16 |
--------------------------------------------------------------------------------
/android/app/src/main/res/drawable-v21/launch_background.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
11 |
12 |
--------------------------------------------------------------------------------
/lib/features/universities_feed/domain/entity/university.dart:
--------------------------------------------------------------------------------
1 | import 'package:flutter/foundation.dart';
2 | import 'package:freezed_annotation/freezed_annotation.dart';
3 |
4 | part 'university.freezed.dart';
5 |
6 | @freezed
7 | class University with _$University {
8 | factory University({
9 | required String alphaCode,
10 | required String country,
11 | required String state,
12 | required String name,
13 | required List websites,
14 | required List domains,
15 | }) = _University;
16 | }
17 |
--------------------------------------------------------------------------------
/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/main.dart:
--------------------------------------------------------------------------------
1 | import 'package:flutter/material.dart';
2 | import 'package:logging/logging.dart';
3 | import 'package:rxdart_state_management_article/app.dart';
4 | import 'package:rxdart_state_management_article/utils/app_config.dart';
5 |
6 | void main() {
7 | _setupLogging();
8 | AppConfig.setEnvironment(Environment.dev);
9 | runApp(const MyApp());
10 | }
11 |
12 | void _setupLogging() {
13 | Logger.root.level = Level.ALL;
14 | Logger.root.onRecord.listen((rec) {
15 | print('${rec.level.name}: ${rec.time}: ${rec.message}');
16 | });
17 | }
18 |
--------------------------------------------------------------------------------
/lib/features/universities_feed/data/source/network/endpoint/university_endpoint.dart:
--------------------------------------------------------------------------------
1 | import 'package:dio/dio.dart';
2 | import 'package:retrofit/retrofit.dart';
3 | import 'package:rxdart_state_management_article/features/universities_feed/data/source/network/model/api_university_model.dart';
4 |
5 | part 'university_endpoint.g.dart';
6 |
7 | @RestApi()
8 | abstract class UniversityEndpoint {
9 | factory UniversityEndpoint(Dio dio, {String baseUrl}) = _UniversityEndpoint;
10 |
11 | @GET("/search")
12 | Future> getUniversitiesByCountry(
13 | @Query("country") String country);
14 | }
15 |
--------------------------------------------------------------------------------
/lib/features/universities_feed/presentation/models/university_screen_state.dart:
--------------------------------------------------------------------------------
1 | import 'package:flutter/foundation.dart';
2 | import 'package:freezed_annotation/freezed_annotation.dart';
3 | import 'package:rxdart_state_management_article/features/universities_feed/presentation/models/university_screen_model.dart';
4 |
5 | part 'university_screen_state.freezed.dart';
6 |
7 | @freezed
8 | class UniversityScreenState with _$UniversityScreenState {
9 | const UniversityScreenState._();
10 |
11 | factory UniversityScreenState({
12 | required List universities,
13 | }) = _UniversityScreenState;
14 | }
15 |
--------------------------------------------------------------------------------
/lib/app.dart:
--------------------------------------------------------------------------------
1 | import 'package:flutter/material.dart';
2 | import 'package:rxdart_state_management_article/features/universities_feed/presentation/screen/universities_screen.dart';
3 |
4 | class MyApp extends StatelessWidget {
5 | const MyApp({Key? key}) : super(key: key);
6 |
7 | // This widget is the root of your application.
8 | @override
9 | Widget build(BuildContext context) {
10 | return MaterialApp(
11 | title: 'Flutter Demo',
12 | theme: ThemeData(
13 | primarySwatch: Colors.blue,
14 | ),
15 | home: const UniversitiesScreen(),
16 | // home: const UniversitiesScreenManualSubscription(),
17 | );
18 | }
19 | }
20 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/android/build.gradle:
--------------------------------------------------------------------------------
1 | buildscript {
2 | ext.kotlin_version = '1.6.10'
3 | repositories {
4 | google()
5 | mavenCentral()
6 | }
7 |
8 | dependencies {
9 | classpath 'com.android.tools.build:gradle:7.1.2'
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 | task clean(type: Delete) {
30 | delete rootProject.buildDir
31 | }
32 |
--------------------------------------------------------------------------------
/lib/features/universities_feed/presentation/models/university_screen_model.dart:
--------------------------------------------------------------------------------
1 | import 'package:flutter/foundation.dart';
2 | import 'package:freezed_annotation/freezed_annotation.dart';
3 | import 'package:rxdart_state_management_article/features/universities_feed/domain/entity/university.dart';
4 |
5 | part 'university_screen_model.freezed.dart';
6 |
7 | @freezed
8 | class UniversityScreenModel with _$UniversityScreenModel {
9 | const UniversityScreenModel._();
10 |
11 | factory UniversityScreenModel({
12 | required String country,
13 | required String name,
14 | required String website,
15 | }) = _UniversityScreenModel;
16 |
17 | factory UniversityScreenModel.fromDomain(University university) {
18 | return UniversityScreenModel(
19 | country: university.country,
20 | name: university.name,
21 | website: university.websites.first,
22 | );
23 | }
24 | }
25 |
--------------------------------------------------------------------------------
/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 | 9.0
25 |
26 |
27 |
--------------------------------------------------------------------------------
/lib/network_config/app_result.dart:
--------------------------------------------------------------------------------
1 | import 'package:flutter/foundation.dart';
2 | import 'package:freezed_annotation/freezed_annotation.dart';
3 | import 'package:rxdart_state_management_article/network_config/api_error.dart';
4 |
5 | part 'app_result.freezed.dart';
6 |
7 | @freezed
8 | class AppResult with _$AppResult {
9 | const AppResult._();
10 |
11 | const factory AppResult.data(T value) = Data;
12 |
13 | const factory AppResult.loading() = Loading;
14 |
15 | const factory AppResult.appError([String? message]) = AppError;
16 |
17 | const factory AppResult.apiError(ApiError error) = AppResultApiError;
18 |
19 | AppResult safeMap(E Function(T) transform) {
20 | return when(
21 | data: (e) => AppResult.data(transform(e)),
22 | loading: () => const AppResult.loading(),
23 | appError: (e) => AppResult.appError(e),
24 | apiError: (e) => AppResult.apiError(e),
25 | );
26 | }
27 | }
28 |
--------------------------------------------------------------------------------
/web/manifest.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "rxdart_state_management_article",
3 | "short_name": "rxdart_state_management_article",
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 |
--------------------------------------------------------------------------------
/lib/features/universities_feed/data/repository/university_repository_impl.dart:
--------------------------------------------------------------------------------
1 | import 'package:rxdart_state_management_article/features/universities_feed/data/source/university_remote_data_source.dart';
2 | import 'package:rxdart_state_management_article/features/universities_feed/domain/entity/university.dart';
3 | import 'package:rxdart_state_management_article/features/universities_feed/domain/repository/untiversities_repository.dart';
4 | import 'package:rxdart_state_management_article/network_config/app_result.dart';
5 |
6 | class UniversityRepositoryImpl extends UniversitiesRepository {
7 | final UniversityRemoteDataSource _universityRemoteDataSource;
8 |
9 | UniversityRepositoryImpl(
10 | {UniversityRemoteDataSource? universityRemoteDataSource})
11 | : _universityRemoteDataSource =
12 | universityRemoteDataSource ?? UniversityRemoteDataSource();
13 |
14 | @override
15 | Stream>> getUniversities(String? country) {
16 | return _universityRemoteDataSource.getUniversitiesByCountry(country);
17 | }
18 | }
19 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/test/widget_test/widget_test.dart:
--------------------------------------------------------------------------------
1 | // This is a basic Flutter widget test.
2 | //
3 | // To perform an interaction with a widget in your test, use the WidgetTester
4 | // utility in the flutter_test package. For example, you can send tap and scroll
5 | // gestures. You can also use WidgetTester to find child widgets in the widget
6 | // tree, read text, and verify that the values of widget properties are correct.
7 |
8 | void main() {
9 | // testWidgets('Counter increments smoke test', (WidgetTester tester) async {
10 | // // Build our app and trigger a frame.
11 | // await tester.pumpWidget(const MyApp());
12 | //
13 | // // Verify that our counter starts at 0.
14 | // expect(find.text('0'), findsOneWidget);
15 | // expect(find.text('1'), findsNothing);
16 | //
17 | // // Tap the '+' icon and trigger a frame.
18 | // await tester.tap(find.byIcon(Icons.add));
19 | // await tester.pump();
20 | //
21 | // // Verify that our counter has incremented.
22 | // expect(find.text('0'), findsNothing);
23 | // expect(find.text('1'), findsOneWidget);
24 | // });
25 | }
26 |
--------------------------------------------------------------------------------
/lib/utils/extensions/iterable_extensions.dart:
--------------------------------------------------------------------------------
1 | import 'package:rxdart_state_management_article/utils/extensions/map_extensions.dart';
2 |
3 | extension IterableExtensions on Iterable {
4 | bool hasSameElementsAs(Iterable? other) {
5 | if (other == null || other.length != length) return false;
6 |
7 | var currentIterator = iterator;
8 | var otherIterator = other.iterator;
9 |
10 | while (currentIterator.moveNext() && otherIterator.moveNext()) {
11 | if (currentIterator.current is Iterable) {
12 | if ((currentIterator.current as Iterable)
13 | .hasSameElementsAs(otherIterator.current as Iterable) ==
14 | false) {
15 | return false;
16 | }
17 | } else if (currentIterator.current is Map) {
18 | if ((currentIterator.current as Map)
19 | .hasSameElementsAs(otherIterator.current as Map) ==
20 | false) {
21 | return false;
22 | }
23 | } else if (currentIterator.current != otherIterator.current) {
24 | return false;
25 | }
26 | }
27 | return true;
28 | }
29 | }
30 |
--------------------------------------------------------------------------------
/lib/utils/extensions/map_extensions.dart:
--------------------------------------------------------------------------------
1 | import 'package:rxdart_state_management_article/utils/extensions/iterable_extensions.dart';
2 |
3 | extension MapExtensions on Map {
4 | bool hasSameElementsAs(Map? other) {
5 | if (other == null || other.length != length) return false;
6 |
7 | var currentIterator = entries.iterator;
8 | while (currentIterator.moveNext()) {
9 | var currentValue = currentIterator.current;
10 | var otherValue = other[currentValue.key];
11 | if (otherValue == null ||
12 | otherValue.runtimeType != currentValue.value.runtimeType) {
13 | return false;
14 | }
15 | if (otherValue is Map) {
16 | if (!otherValue.hasSameElementsAs(currentValue.value as Map)) {
17 | return false;
18 | }
19 | } else if (otherValue is List) {
20 | if (!otherValue.hasSameElementsAs(currentValue.value as List)) {
21 | return false;
22 | }
23 | } else {
24 | if (otherValue != currentValue.value) {
25 | return false;
26 | }
27 | }
28 | }
29 | return true;
30 | }
31 | }
32 |
--------------------------------------------------------------------------------
/lib/features/universities_feed/data/source/university_remote_data_source.dart:
--------------------------------------------------------------------------------
1 | import 'package:rxdart_state_management_article/features/universities_feed/data/source/network/endpoint/university_endpoint.dart';
2 | import 'package:rxdart_state_management_article/features/universities_feed/domain/entity/university.dart';
3 | import 'package:rxdart_state_management_article/network_config/app_result.dart';
4 | import 'package:rxdart_state_management_article/network_config/retrofit_client.dart';
5 | import 'package:rxdart_state_management_article/utils/extensions/future_extensions.dart';
6 |
7 | class UniversityRemoteDataSource {
8 | final UniversityEndpoint _universityEndpoint;
9 |
10 | UniversityRemoteDataSource({UniversityEndpoint? universityEndpoint})
11 | : _universityEndpoint = universityEndpoint ??
12 | UniversityEndpoint(
13 | DioClientExtension.createUniversitiesApiClient());
14 |
15 | Stream>> getUniversitiesByCountry(
16 | String? country,
17 | ) {
18 | return _universityEndpoint
19 | .getUniversitiesByCountry(country ?? "United states")
20 | .safeApiConvert((p0) => p0.map((e) => e.toDomain()).toList());
21 | }
22 | }
23 |
--------------------------------------------------------------------------------
/lib/utils/extensions/future_extensions.dart:
--------------------------------------------------------------------------------
1 | import 'package:rxdart/rxdart.dart';
2 | import 'package:rxdart_state_management_article/network_config/api_error.dart';
3 | import 'package:rxdart_state_management_article/network_config/app_result.dart';
4 |
5 | extension FutureExtension on Future {
6 | Stream> safeApi() {
7 | return then((value) {
8 | return AppResult.data(value);
9 | })
10 | .onError((error, stackTrace) {
11 | if (error is ApiError) {
12 | return AppResult.apiError(error);
13 | }
14 | return AppResult.appError(error.toString());
15 | })
16 | .asStream()
17 | .startWith(AppResult.loading());
18 | }
19 |
20 | Stream> safeApiConvert(E Function(T) transform) {
21 | return then((value) {
22 | return AppResult.data(transform(value));
23 | })
24 | .onError((error, stackTrace) {
25 | if (error is ApiError) {
26 | return AppResult.apiError(error);
27 | }
28 | return AppResult.appError(error.toString());
29 | })
30 | .asStream()
31 | .startWith(AppResult.loading());
32 | }
33 | }
34 |
--------------------------------------------------------------------------------
/lib/features/universities_feed/presentation/screen/universities_view_model.dart:
--------------------------------------------------------------------------------
1 | import 'package:rxdart/rxdart.dart';
2 | import 'package:rxdart_state_management_article/features/universities_feed/domain/usecase/get_universities_by_country_use_case.dart';
3 | import 'package:rxdart_state_management_article/features/universities_feed/presentation/models/university_screen_state.dart';
4 | import 'package:rxdart_state_management_article/network_config/app_result.dart';
5 |
6 | class UniversitiesViewModel {
7 | final GetUniversitiesByCountryUseCase _getUniversitiesByCountryUseCase;
8 |
9 | final Subject _searchByCountry = PublishSubject();
10 |
11 | late Stream> universities;
12 |
13 | UniversitiesViewModel(
14 | {GetUniversitiesByCountryUseCase? getUniversitiesByCountryUseCase})
15 | : _getUniversitiesByCountryUseCase = getUniversitiesByCountryUseCase ??
16 | GetUniversitiesByCountryUseCase() {
17 | universities = _searchByCountry
18 | .startWith(null)
19 | .flatMap((value) => _getUniversitiesByCountryUseCase.invoke(value));
20 | }
21 |
22 | void searchByCountry(String country) {
23 | _searchByCountry.add(country);
24 | }
25 | }
26 |
--------------------------------------------------------------------------------
/lib/network_config/retrofit_client.dart:
--------------------------------------------------------------------------------
1 | import 'package:dio/dio.dart';
2 | import 'package:logging/logging.dart';
3 | import 'package:rxdart_state_management_article/network_config/error_convertor.dart';
4 | import 'package:rxdart_state_management_article/utils/app_config.dart';
5 |
6 | extension DioClientExtension on Dio {
7 | static Dio createUniversitiesApiClient({
8 | contentType = "application/json",
9 | bool shouldRefreshToken = true,
10 | ApiAuthorization authorizationType = ApiAuthorization.none,
11 | }) {
12 | Map headers = {
13 | "Content-Type": contentType,
14 | "Accept": "application/json",
15 | };
16 | Dio dio = Dio(BaseOptions(
17 | baseUrl: AppConfig.universitiesApiUrl,
18 | headers: headers,
19 | connectTimeout: 10000,
20 | receiveTimeout: 15000,
21 | sendTimeout: 15000,
22 | ));
23 | dio.interceptors.addAll([
24 | LogInterceptor(
25 | responseBody: true,
26 | requestBody: true,
27 | logPrint: (text) {
28 | if (!AppConfig.isProduction) {
29 | Logger.root.log(Level.INFO, "${DateTime.now()}: $text");
30 | }
31 | }),
32 | ErrorConverter(),
33 | ]);
34 | return dio;
35 | }
36 | }
37 |
38 | enum ApiAuthorization { none, basic, token }
39 |
--------------------------------------------------------------------------------
/lib/features/universities_feed/domain/usecase/get_universities_by_country_use_case.dart:
--------------------------------------------------------------------------------
1 | import 'package:rxdart_state_management_article/features/universities_feed/data/repository/university_repository_impl.dart';
2 | import 'package:rxdart_state_management_article/features/universities_feed/domain/repository/untiversities_repository.dart';
3 | import 'package:rxdart_state_management_article/features/universities_feed/presentation/models/university_screen_model.dart';
4 | import 'package:rxdart_state_management_article/features/universities_feed/presentation/models/university_screen_state.dart';
5 | import 'package:rxdart_state_management_article/network_config/app_result.dart';
6 |
7 | class GetUniversitiesByCountryUseCase {
8 | final UniversitiesRepository _universitiesRepository;
9 |
10 | GetUniversitiesByCountryUseCase(
11 | {UniversitiesRepository? universitiesRepository})
12 | : _universitiesRepository =
13 | universitiesRepository ?? UniversityRepositoryImpl();
14 |
15 | Stream> invoke(String? country) {
16 | return _universitiesRepository.getUniversities(country).map((event) {
17 | return event.safeMap((p0) => UniversityScreenState(
18 | universities:
19 | p0.map((e) => UniversityScreenModel.fromDomain(e)).toList(),
20 | ));
21 | });
22 | }
23 | }
24 |
--------------------------------------------------------------------------------
/.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.
5 |
6 | version:
7 | revision: ee4e09cce01d6f2d7f4baebd247fde02e5008851
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: ee4e09cce01d6f2d7f4baebd247fde02e5008851
17 | base_revision: ee4e09cce01d6f2d7f4baebd247fde02e5008851
18 | - platform: android
19 | create_revision: ee4e09cce01d6f2d7f4baebd247fde02e5008851
20 | base_revision: ee4e09cce01d6f2d7f4baebd247fde02e5008851
21 | - platform: ios
22 | create_revision: ee4e09cce01d6f2d7f4baebd247fde02e5008851
23 | base_revision: ee4e09cce01d6f2d7f4baebd247fde02e5008851
24 | - platform: web
25 | create_revision: ee4e09cce01d6f2d7f4baebd247fde02e5008851
26 | base_revision: ee4e09cce01d6f2d7f4baebd247fde02e5008851
27 |
28 | # User provided section
29 |
30 | # List of Local paths (relative to this file) that should be
31 | # ignored by the migrate tool.
32 | #
33 | # Files that are not part of the templates will be ignored by default.
34 | unmanaged_files:
35 | - 'lib/main.dart'
36 | - 'ios/Runner.xcodeproj/project.pbxproj'
37 |
--------------------------------------------------------------------------------
/lib/features/universities_feed/data/source/network/model/api_university_model.dart:
--------------------------------------------------------------------------------
1 | import 'package:flutter/foundation.dart';
2 | import 'package:freezed_annotation/freezed_annotation.dart';
3 | import 'package:rxdart_state_management_article/features/universities_feed/domain/entity/university.dart';
4 |
5 | part 'api_university_model.freezed.dart';
6 | part 'api_university_model.g.dart';
7 |
8 | @freezed
9 | class ApiUniversityModel with _$ApiUniversityModel {
10 | const ApiUniversityModel._();
11 |
12 | factory ApiUniversityModel({
13 | @JsonKey(name: "alpha_two_code") String? alphaCode,
14 | @JsonKey(name: "country") String? country,
15 | @JsonKey(name: "state-province") String? state,
16 | @JsonKey(name: "name") String? name,
17 | @JsonKey(name: "web_pages") List? websites,
18 | @JsonKey(name: "domains") List? domains,
19 | }) = _ApiUniversityModel;
20 |
21 | factory ApiUniversityModel.fromJson(Map json) =>
22 | _$ApiUniversityModelFromJson(json);
23 |
24 | University toDomain() {
25 | return University(
26 | alphaCode: alphaCode ?? "",
27 | country: country ?? "",
28 | state: state ?? "",
29 | name: name ?? "",
30 | websites: websites?.map((e) => e ?? "").toList() ?? [],
31 | domains: domains?.map((e) => e ?? "").toList() ?? [],
32 | );
33 | }
34 | }
35 |
--------------------------------------------------------------------------------
/lib/network_config/error_convertor.dart:
--------------------------------------------------------------------------------
1 | import 'dart:convert';
2 |
3 | import 'package:dio/dio.dart';
4 | import 'package:rxdart_state_management_article/network_config/api_error.dart';
5 |
6 | class ErrorConverter extends InterceptorsWrapper {
7 | @override
8 | void onError(DioError err, ErrorInterceptorHandler handler) {
9 | if (err.type != DioErrorType.response) {
10 | handler.next(err);
11 | return;
12 | }
13 | Map errorBody = {
14 | "statusCode": -1,
15 | "message": "Something went wrong",
16 | };
17 | errorBody["statusCode"] = err.response?.statusCode;
18 | var error = err.response?.data;
19 | Map errorResponse = {};
20 | if (error is String) {
21 | try {
22 | errorResponse = (jsonDecode(error) as Map);
23 | } on Exception catch (err, _) {
24 | errorResponse = {
25 | "error": (error.isNotEmpty) ? error : errorBody["messsage"],
26 | };
27 | }
28 | } else if (error is Map) {
29 | errorResponse = error;
30 | }
31 |
32 | errorBody["message"] =
33 | errorResponse["errorMessage"] ?? errorBody["message"];
34 | if (errorResponse["errors"] is Map) {
35 | errorBody["errors"] = errorResponse["errors"];
36 | }
37 |
38 | var apiError = ApiError.fromJson(errorBody);
39 | handler.next(err..error = apiError);
40 | }
41 | }
42 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # RxDart State Management Using MVVM With Clean Architecture and Unit Testing
2 |
3 | This project is a Flutter application that fetches and displays a list of universities with the help of an external API. The project also lets us filter the list by country. To make all this magic happen, we used clean and testable architecture to provide an easy-to-maintain and scalable code.
4 |
5 | ### Technologies Used:
6 |
7 | * MVVM and Clean Architecture pattern
8 | * RxDart for state management
9 | * Retrofit with Dio for the Network layer
10 | * Freezed and JsonSerializable for generating the models' boilerplates
11 | * Mockito for data mocking
12 |
13 | ### API Used:
14 | * [universities.hipolabs.com](http://universities.hipolabs.com/search)
15 |
16 | ### Related Reading by Dacian Florea:
17 | You can find me on [LinkedIn](https://www.linkedin.com/in/dacian-florea/) and [Toptal](https://www.toptal.com/resume/dacian-florea).
18 |
19 | * Clean architecture facilitates unit testing, which we demonstrate in the [Unit Testing in Flutter: From Workflow Essentials to Complex Scenarios](https://www.toptal.com/flutter/unit-testing-flutter) article published in the Toptal Engineering Blog.
20 | * RxDart combined MVVM with clean architecture facilitates state management in Flutter, as demonstrated in https://hackernoon.com/flutter-state-management-with-rxdart-streams.
21 |
22 | ## Getting Started
23 |
24 | Before running the app, you have to run: `flutter pub run build_runner build`
25 |
--------------------------------------------------------------------------------
/lib/network_config/api_error.dart:
--------------------------------------------------------------------------------
1 | import 'package:freezed_annotation/freezed_annotation.dart';
2 | import 'package:rxdart_state_management_article/utils/extensions/map_extensions.dart';
3 |
4 | part 'api_error.g.dart';
5 |
6 | @JsonSerializable()
7 | class ApiError extends Error {
8 | int statusCode;
9 | String message;
10 | Map? errors;
11 |
12 | ApiError({
13 | required this.statusCode,
14 | required this.message,
15 | this.errors,
16 | }) : super();
17 |
18 | factory ApiError.fromJson(Map json) =>
19 | _$ApiErrorFromJson(json);
20 |
21 | ApiError copyWith({
22 | int? statusCode,
23 | String? message,
24 | Map? errors,
25 | }) {
26 | return ApiError(
27 | statusCode: statusCode ?? this.statusCode,
28 | message: message ?? this.message,
29 | errors: errors ?? this.errors,
30 | );
31 | }
32 |
33 | @override
34 | String toString() {
35 | return 'ApiError{statusCode: $statusCode, message: $message, errors: $errors}';
36 | }
37 |
38 | @override
39 | bool operator ==(Object other) =>
40 | identical(this, other) ||
41 | other is ApiError &&
42 | runtimeType == other.runtimeType &&
43 | statusCode == other.statusCode &&
44 | message == other.message &&
45 | (errors?.hasSameElementsAs(other.errors) ?? errors == other.errors);
46 |
47 | @override
48 | int get hashCode => statusCode.hashCode ^ message.hashCode;
49 | }
50 |
--------------------------------------------------------------------------------
/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 | errors:
14 | invalid_annotation_target: ignore
15 |
16 | linter:
17 | # The lint rules applied to this project can be customized in the
18 | # section below to disable rules from the `package:flutter_lints/flutter.yaml`
19 | # included above or to enable additional rules. A list of all available lints
20 | # and their documentation is published at
21 | # https://dart-lang.github.io/linter/lints/index.html.
22 | #
23 | # Instead of disabling a lint rule for the entire project in the
24 | # section below, it can also be suppressed for a single line of code
25 | # or a specific dart file by using the `// ignore: name_of_lint` and
26 | # `// ignore_for_file: name_of_lint` syntax on the line or in the file
27 | # producing the lint.
28 | rules:
29 | # avoid_print: false # Uncomment to disable the `avoid_print` rule
30 | # prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule
31 |
32 | # Additional information about this file can be found at
33 | # https://dart.dev/guides/language/analysis-options
34 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/test/unit_test/universities_feed/presentation/model/university_screen_model_test.dart:
--------------------------------------------------------------------------------
1 | import 'package:flutter_test/flutter_test.dart';
2 | import 'package:rxdart_state_management_article/features/universities_feed/domain/entity/university.dart';
3 | import 'package:rxdart_state_management_article/features/universities_feed/presentation/models/university_screen_model.dart';
4 |
5 | void main() {
6 | University universityOne = University(
7 | alphaCode: "US",
8 | country: "United States",
9 | state: "",
10 | name: "Marywood University",
11 | websites: ["http://www.marywood.edu"],
12 | domains: ["marywood.edu"],
13 | );
14 |
15 | University universityTwo = University(
16 | alphaCode: "US",
17 | country: "United States",
18 | state: "",
19 | name: "Lindenwood University",
20 | websites: ["http://www.lindenwood.edu/"],
21 | domains: ["lindenwood.edu"],
22 | );
23 | UniversityScreenModel expectedUniversityScreenModelOne =
24 | UniversityScreenModel(
25 | country: "United States",
26 | name: "Marywood University",
27 | website: "http://www.marywood.edu",
28 | );
29 |
30 | UniversityScreenModel expectedUniversityScreenModelTwo =
31 | UniversityScreenModel(
32 | country: "United States",
33 | name: "Lindenwood University",
34 | website: "http://www.lindenwood.edu/",
35 | );
36 |
37 | group("Test UniversityScreenModel fromDomain", () {
38 | test('Test fromDomain using universityOne', () {
39 | expect(UniversityScreenModel.fromDomain(universityOne),
40 | expectedUniversityScreenModelOne);
41 | });
42 | test('Test fromDomain using universityTwo', () {
43 | expect(UniversityScreenModel.fromDomain(universityTwo),
44 | expectedUniversityScreenModelTwo);
45 | });
46 | });
47 | }
48 |
--------------------------------------------------------------------------------
/android/app/src/main/AndroidManifest.xml:
--------------------------------------------------------------------------------
1 |
3 |
4 |
8 |
16 |
20 |
23 |
24 |
25 |
26 |
27 |
28 |
30 |
33 |
34 |
35 |
--------------------------------------------------------------------------------
/ios/Runner/Info.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | CFBundleDevelopmentRegion
6 | $(DEVELOPMENT_LANGUAGE)
7 | CFBundleDisplayName
8 | Rxdart State Management Article
9 | CFBundleExecutable
10 | $(EXECUTABLE_NAME)
11 | CFBundleIdentifier
12 | $(PRODUCT_BUNDLE_IDENTIFIER)
13 | CFBundleInfoDictionaryVersion
14 | 6.0
15 | CFBundleName
16 | rxdart_state_management_article
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 |
49 |
50 |
--------------------------------------------------------------------------------
/test/unit_test/network_config/mock_interceptor/dio_mock_responses_adapter.dart:
--------------------------------------------------------------------------------
1 | import 'dart:convert';
2 | import 'dart:typed_data';
3 |
4 | import 'package:dio/dio.dart';
5 | import 'package:flutter_test/flutter_test.dart';
6 | import 'package:rxdart_state_management_article/utils/extensions/map_extensions.dart';
7 |
8 | class DioMockResponsesAdapter extends HttpClientAdapter {
9 | final MockAdapterInterceptor interceptor;
10 |
11 | DioMockResponsesAdapter(this.interceptor);
12 |
13 | @override
14 | void close({bool force = false}) {}
15 |
16 | @override
17 | Future fetch(RequestOptions options,
18 | Stream? requestStream, Future? cancelFuture) {
19 | if (options.method == interceptor.type.name.toUpperCase() &&
20 | options.baseUrl == interceptor.uri &&
21 | options.queryParameters.hasSameElementsAs(interceptor.query) &&
22 | options.path == interceptor.path) {
23 | return Future.value(ResponseBody.fromString(
24 | jsonEncode(interceptor.serializableResponse),
25 | interceptor.responseCode,
26 | headers: {
27 | "content-type": ["application/json"]
28 | },
29 | ));
30 | }
31 | return Future.value(ResponseBody.fromString(
32 | jsonEncode(
33 | {"error": "Request doesn't match the mock interceptor details!"}),
34 | -1,
35 | statusMessage: "Request doesn't match the mock interceptor details!"));
36 | }
37 | }
38 |
39 | enum RequestType { GET, POST, PUT, PATCH, DELETE }
40 |
41 | class MockAdapterInterceptor {
42 | final RequestType type;
43 | final String uri;
44 | final String path;
45 | final Map query;
46 | final Object serializableResponse;
47 | final int responseCode;
48 |
49 | MockAdapterInterceptor(this.type, this.uri, this.path, this.query,
50 | this.serializableResponse, this.responseCode);
51 | }
52 |
--------------------------------------------------------------------------------
/lib/utils/app_config.dart:
--------------------------------------------------------------------------------
1 | enum Environment { dev, prod }
2 |
3 | extension EnvironmentExtensions on Environment {
4 | static Environment fromString(String string) {
5 | switch (string) {
6 | case "dev":
7 | return Environment.dev;
8 | case "prod":
9 | return Environment.prod;
10 | default:
11 | return Environment.dev;
12 | }
13 | }
14 |
15 | String get value {
16 | switch (this) {
17 | case Environment.dev:
18 | return "dev";
19 | case Environment.prod:
20 | return "prod";
21 | default:
22 | return "dev";
23 | }
24 | }
25 |
26 | static String get key => "EnvironmentKey";
27 | }
28 |
29 | class AppConfig {
30 | static Map _config = {};
31 |
32 | static void setEnvironment(Environment env) {
33 | switch (env) {
34 | case Environment.dev:
35 | _config = _Config.debugConstants;
36 | break;
37 | case Environment.prod:
38 | _config = _Config.prodConstants;
39 | break;
40 | }
41 | }
42 |
43 | static bool get isProduction {
44 | return env == Environment.prod;
45 | }
46 |
47 | static String get universitiesApiUrl {
48 | return _config[_Config.universitiesApiUrl];
49 | }
50 |
51 | static Environment get env {
52 | switch (_config[_Config.envKey]) {
53 | case "dev":
54 | return Environment.dev;
55 | case "prod":
56 | return Environment.prod;
57 | default:
58 | return Environment.dev;
59 | }
60 | }
61 | }
62 |
63 | class _Config {
64 | static const String envKey = "ENV_KEY";
65 | static const String universitiesApiUrl = "UNIVERSITIES_API_URL";
66 |
67 | static Map debugConstants = {
68 | envKey: "dev",
69 | universitiesApiUrl: "http://universities.hipolabs.com",
70 | };
71 |
72 | static Map prodConstants = {
73 | envKey: "prod",
74 | universitiesApiUrl: "http://universities.hipolabs.com",
75 | };
76 | }
77 |
--------------------------------------------------------------------------------
/web/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 | rxdart_state_management_article
33 |
34 |
35 |
41 |
42 |
43 |
44 |
45 |
61 |
62 |
63 |
--------------------------------------------------------------------------------
/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 | compileSdkVersion flutter.compileSdkVersion
30 | ndkVersion flutter.ndkVersion
31 |
32 | compileOptions {
33 | sourceCompatibility JavaVersion.VERSION_1_8
34 | targetCompatibility JavaVersion.VERSION_1_8
35 | }
36 |
37 | kotlinOptions {
38 | jvmTarget = '1.8'
39 | }
40 |
41 | sourceSets {
42 | main.java.srcDirs += 'src/main/kotlin'
43 | }
44 |
45 | defaultConfig {
46 | // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
47 | applicationId "dev.dacianflorea.rxdart_state_management.rxdart_state_management_article"
48 | // You can update the following values to match your application needs.
49 | // For more information, see: https://docs.flutter.dev/deployment/android#reviewing-the-build-configuration.
50 | minSdkVersion flutter.minSdkVersion
51 | targetSdkVersion flutter.targetSdkVersion
52 | versionCode flutterVersionCode.toInteger()
53 | versionName flutterVersionName
54 | }
55 |
56 | buildTypes {
57 | release {
58 | // TODO: Add your own signing config for the release build.
59 | // Signing with the debug keys for now, so `flutter run --release` works.
60 | signingConfig signingConfigs.debug
61 | }
62 | }
63 | }
64 |
65 | flutter {
66 | source '../..'
67 | }
68 |
69 | dependencies {
70 | implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
71 | }
72 |
--------------------------------------------------------------------------------
/test/unit_test/extensions/future_extensions_test.dart:
--------------------------------------------------------------------------------
1 | import 'package:flutter_test/flutter_test.dart';
2 | import 'package:rxdart_state_management_article/network_config/api_error.dart';
3 | import 'package:rxdart_state_management_article/network_config/app_result.dart';
4 | import 'package:rxdart_state_management_article/utils/extensions/future_extensions.dart';
5 |
6 | void main() {
7 | group("Test safeApi extension", () {
8 | test('Test Future emits data', () {
9 | expect(
10 | Future.value("Test").safeApi(),
11 | emitsInOrder([
12 | const AppResult.loading(),
13 | const AppResult.data("Test"),
14 | ]),
15 | );
16 | });
17 |
18 | test('Test Future emits appError', () {
19 | expect(
20 | Future.error(Exception("ERROR")).safeApi(),
21 | emitsInOrder([
22 | const AppResult.loading(),
23 | AppResult.appError(Exception("ERROR").toString()),
24 | ]),
25 | );
26 | });
27 |
28 | test('Test Future emits apiError from ApiError', () {
29 | ApiError apiError =
30 | ApiError(statusCode: 500, message: "Internal Server Error!");
31 | expect(
32 | Future.error(apiError).safeApi(),
33 | emitsInOrder([
34 | const AppResult.loading(),
35 | AppResult.apiError(apiError),
36 | ]),
37 | );
38 | });
39 | });
40 |
41 | group("Test safeApiConvert extension", () {
42 | String converter(int value) {
43 | return value.toString();
44 | }
45 |
46 | test('Test Future emits data', () {
47 | expect(
48 | Future.value(1).safeApiConvert(converter),
49 | emitsInOrder([
50 | const AppResult.loading(),
51 | const AppResult.data("1"),
52 | ]),
53 | );
54 | });
55 |
56 | test('Test Future emits appError', () {
57 | expect(
58 | Future.error(Exception("ERROR")).safeApiConvert(converter),
59 | emitsInOrder([
60 | const AppResult.loading(),
61 | AppResult.appError(Exception("ERROR").toString()),
62 | ]),
63 | );
64 | });
65 |
66 | test('Test Future emits apiError from ApiError', () {
67 | ApiError apiError =
68 | ApiError(statusCode: 500, message: "Internal Server Error!");
69 | expect(
70 | Future.error(apiError).safeApiConvert(converter),
71 | emitsInOrder([
72 | const AppResult.loading(),
73 | AppResult.apiError(apiError),
74 | ]),
75 | );
76 | });
77 | });
78 | }
79 |
--------------------------------------------------------------------------------
/test/unit_test/universities_feed/data/repository/universitiy_repository_impl_test.dart:
--------------------------------------------------------------------------------
1 | import 'package:flutter_test/flutter_test.dart';
2 | import 'package:mockito/annotations.dart';
3 | import 'package:mockito/mockito.dart';
4 | import 'package:rxdart_state_management_article/features/universities_feed/data/repository/university_repository_impl.dart';
5 | import 'package:rxdart_state_management_article/features/universities_feed/data/source/university_remote_data_source.dart';
6 | import 'package:rxdart_state_management_article/features/universities_feed/domain/entity/university.dart';
7 | import 'package:rxdart_state_management_article/features/universities_feed/domain/repository/untiversities_repository.dart';
8 | import 'package:rxdart_state_management_article/network_config/app_result.dart';
9 |
10 | import 'universitiy_repository_impl_test.mocks.dart';
11 |
12 | @GenerateMocks([UniversityRemoteDataSource])
13 | void main() {
14 | late UniversityRemoteDataSource dataSource;
15 | late UniversitiesRepository repo;
16 |
17 | group("Test function calls", () {
18 | setUp(() {
19 | dataSource = MockUniversityRemoteDataSource();
20 | repo = UniversityRepositoryImpl(universityRemoteDataSource: dataSource);
21 | });
22 |
23 | test('Test repo calls getUniversitiesByCountry from data source', () {
24 | when(dataSource.getUniversitiesByCountry(null)).thenAnswer(
25 | (realInvocation) => Stream.value(const AppResult.data([])));
26 |
27 | repo.getUniversities(null);
28 | verify(dataSource.getUniversitiesByCountry(null));
29 | });
30 |
31 | test(
32 | 'Test repo calls getUniversitiesByCountry from data source and gets error',
33 | () {
34 | when(dataSource.getUniversitiesByCountry(null)).thenAnswer(
35 | (realInvocation) => Stream.value(const AppResult.appError("ERROR")));
36 |
37 | expect(
38 | repo.getUniversities(null),
39 | emits(const AppResult>.appError("ERROR")),
40 | );
41 | });
42 |
43 | test(
44 | 'Test repo calls getUniversitiesByCountry from data source and gets data',
45 | () {
46 | University university = University(
47 | alphaCode: "alphaCode",
48 | country: "country",
49 | state: "state",
50 | name: "name",
51 | websites: ["websites"],
52 | domains: ["domains"]);
53 |
54 | when(dataSource.getUniversitiesByCountry(null)).thenAnswer(
55 | (realInvocation) => Stream.value(AppResult.data([university])));
56 |
57 | expect(
58 | repo.getUniversities(null),
59 | emits(AppResult.data([university.copyWith()])),
60 | );
61 | });
62 | });
63 | }
64 |
--------------------------------------------------------------------------------
/test/unit_test/extensions/iterable_extensions_test.dart:
--------------------------------------------------------------------------------
1 | import 'package:flutter_test/flutter_test.dart';
2 | import 'package:rxdart_state_management_article/utils/extensions/iterable_extensions.dart';
3 |
4 | void main() {
5 | group("Test hasSameElementsAs extension", () {
6 | List listWithInts = List.generate(10, (index) => index);
7 |
8 | List listWithStrings =
9 | List.generate(10, (index) => index.toString());
10 |
11 | List> listWithListOfStrings = List.generate(
12 | 10, (index1) => List.generate(5, (index2) => "$index1-$index2"));
13 |
14 | List>> listWithListOfListOfStrings = List.generate(
15 | 10,
16 | (index1) => List.generate(
17 | 5,
18 | (index2) => List.generate(3, (index3) => "$index1-$index2-$index3"),
19 | ),
20 | );
21 |
22 | List>> listWithListOfListOfMapOfStringAndInt =
23 | List.generate(
24 | 10,
25 | (index1) => List.generate(
26 | 5,
27 | (index2) => Map.fromEntries(List.generate(
28 | 3, (index3) => MapEntry("$index1-$index2-$index3", index3))),
29 | ),
30 | );
31 |
32 | test('Test hasSameElementsAs on null', () {
33 | expect(
34 | listWithInts.hasSameElementsAs(null),
35 | false,
36 | );
37 | });
38 |
39 | test('Test hasSameElementsAs on null', () {
40 | expect(
41 | listWithInts.hasSameElementsAs([]),
42 | false,
43 | );
44 | });
45 |
46 | test('Test hasSameElementsAs on list with ints', () {
47 | expect(
48 | listWithInts.hasSameElementsAs(List.of(listWithInts)),
49 | true,
50 | );
51 | });
52 |
53 | test('Test hasSameElementsAs on list with strings', () {
54 | expect(
55 | listWithStrings.hasSameElementsAs(List.of(listWithStrings)),
56 | true,
57 | );
58 | });
59 |
60 | test('Test hasSameElementsAs on list with list of strings', () {
61 | expect(
62 | listWithListOfStrings.hasSameElementsAs(List.of(listWithListOfStrings)),
63 | true,
64 | );
65 | });
66 |
67 | test('Test hasSameElementsAs on list with list of list of strings', () {
68 | expect(
69 | listWithListOfListOfStrings
70 | .hasSameElementsAs(List.of(listWithListOfListOfStrings)),
71 | true,
72 | );
73 | });
74 |
75 | test('Test hasSameElementsAs on list with list of map of strings and ints',
76 | () {
77 | expect(
78 | listWithListOfListOfMapOfStringAndInt
79 | .hasSameElementsAs(List.of(listWithListOfListOfMapOfStringAndInt)),
80 | true,
81 | );
82 | });
83 | });
84 | }
85 |
--------------------------------------------------------------------------------
/test/unit_test/universities_feed/data/source/network/model/api_university_model_test.dart:
--------------------------------------------------------------------------------
1 | import 'package:flutter_test/flutter_test.dart';
2 | import 'package:rxdart_state_management_article/features/universities_feed/data/source/network/model/api_university_model.dart';
3 | import 'package:rxdart_state_management_article/features/universities_feed/domain/entity/university.dart';
4 |
5 | void main() {
6 | Map apiUniversityOneAsJson = {
7 | "alpha_two_code": "US",
8 | "domains": ["marywood.edu"],
9 | "country": "United States",
10 | "state-province": null,
11 | "web_pages": ["http://www.marywood.edu"],
12 | "name": "Marywood University"
13 | };
14 | ApiUniversityModel expectedApiUniversityOne = ApiUniversityModel(
15 | alphaCode: "US",
16 | country: "United States",
17 | state: null,
18 | name: "Marywood University",
19 | websites: ["http://www.marywood.edu"],
20 | domains: ["marywood.edu"],
21 | );
22 | University expectedUniversityOne = University(
23 | alphaCode: "US",
24 | country: "United States",
25 | state: "",
26 | name: "Marywood University",
27 | websites: ["http://www.marywood.edu"],
28 | domains: ["marywood.edu"],
29 | );
30 |
31 | Map apiUniversityTwoAsJson = {
32 | "alpha_two_code": "US",
33 | "domains": ["lindenwood.edu"],
34 | "country": "United States",
35 | "state-province": "MJ",
36 | "web_pages": null,
37 | "name": "Lindenwood University"
38 | };
39 | ApiUniversityModel expectedApiUniversityTwo = ApiUniversityModel(
40 | alphaCode: "US",
41 | country: "United States",
42 | state: "MJ",
43 | name: "Lindenwood University",
44 | websites: null,
45 | domains: ["lindenwood.edu"],
46 | );
47 | University expectedUniversityTwo = University(
48 | alphaCode: "US",
49 | country: "United States",
50 | state: "MJ",
51 | name: "Lindenwood University",
52 | websites: [],
53 | domains: ["lindenwood.edu"],
54 | );
55 |
56 | group("Test ApiUniversityModel initialization from json", () {
57 | test('Test using json one', () {
58 | expect(ApiUniversityModel.fromJson(apiUniversityOneAsJson),
59 | expectedApiUniversityOne);
60 | });
61 | test('Test using json two', () {
62 | expect(ApiUniversityModel.fromJson(apiUniversityTwoAsJson),
63 | expectedApiUniversityTwo);
64 | });
65 | });
66 |
67 | group("Test ApiUniversityModel toDomain", () {
68 | test('Test toDomain using json one', () {
69 | expect(ApiUniversityModel.fromJson(apiUniversityOneAsJson).toDomain(),
70 | expectedUniversityOne);
71 | });
72 | test('Test toDomain using json two', () {
73 | expect(ApiUniversityModel.fromJson(apiUniversityTwoAsJson).toDomain(),
74 | expectedUniversityTwo);
75 | });
76 | });
77 | }
78 |
--------------------------------------------------------------------------------
/test/unit_test/universities_feed/data/source/network/university_remote_data_source_test.dart:
--------------------------------------------------------------------------------
1 | import 'package:flutter_test/flutter_test.dart';
2 | import 'package:mockito/annotations.dart';
3 | import 'package:mockito/mockito.dart';
4 | import 'package:rxdart_state_management_article/features/universities_feed/data/source/network/endpoint/university_endpoint.dart';
5 | import 'package:rxdart_state_management_article/features/universities_feed/data/source/network/model/api_university_model.dart';
6 | import 'package:rxdart_state_management_article/features/universities_feed/data/source/university_remote_data_source.dart';
7 | import 'package:rxdart_state_management_article/features/universities_feed/domain/entity/university.dart';
8 | import 'package:rxdart_state_management_article/network_config/api_error.dart';
9 | import 'package:rxdart_state_management_article/network_config/app_result.dart';
10 |
11 | import 'university_remote_data_source_test.mocks.dart';
12 |
13 | @GenerateMocks([UniversityEndpoint])
14 | void main() {
15 | late UniversityEndpoint endpoint;
16 | late UniversityRemoteDataSource dataSource;
17 |
18 | group("Test function calls", () {
19 | setUp(() {
20 | endpoint = MockUniversityEndpoint();
21 | dataSource = UniversityRemoteDataSource(universityEndpoint: endpoint);
22 | });
23 |
24 | test('Test dataSource calls getUniversitiesByCountry from endpoint', () {
25 | when(endpoint.getUniversitiesByCountry("test"))
26 | .thenAnswer((realInvocation) => Future.value([]));
27 |
28 | dataSource.getUniversitiesByCountry("test");
29 | verify(endpoint.getUniversitiesByCountry("test"));
30 | });
31 |
32 | test('Test dataSource maps getUniversitiesByCountry response to Stream',
33 | () {
34 | when(endpoint.getUniversitiesByCountry("test"))
35 | .thenAnswer((realInvocation) => Future.value([]));
36 |
37 | expect(
38 | dataSource.getUniversitiesByCountry("test"),
39 | emitsInOrder([
40 | const AppResult>.loading(),
41 | const AppResult>.data([])
42 | ]),
43 | );
44 | });
45 |
46 | test(
47 | 'Test dataSource maps getUniversitiesByCountry response to Stream with error',
48 | () {
49 | ApiError mockApiError = ApiError(
50 | statusCode: 400,
51 | message: "error",
52 | errors: null,
53 | );
54 | when(endpoint.getUniversitiesByCountry("test"))
55 | .thenAnswer((realInvocation) => Future.error(mockApiError));
56 |
57 | expect(
58 | dataSource.getUniversitiesByCountry("test"),
59 | emitsInOrder([
60 | const AppResult>.loading(),
61 | AppResult>.apiError(mockApiError)
62 | ]),
63 | );
64 | });
65 | });
66 | }
67 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/lib/features/universities_feed/presentation/screen/universities_screen.dart:
--------------------------------------------------------------------------------
1 | import 'package:flutter/material.dart';
2 | import 'package:rxdart_state_management_article/features/universities_feed/presentation/models/university_screen_model.dart';
3 | import 'package:rxdart_state_management_article/features/universities_feed/presentation/models/university_screen_state.dart';
4 | import 'package:rxdart_state_management_article/features/universities_feed/presentation/screen/universities_view_model.dart';
5 | import 'package:rxdart_state_management_article/network_config/app_result.dart';
6 |
7 | class UniversitiesScreen extends StatefulWidget {
8 | const UniversitiesScreen({Key? key}) : super(key: key);
9 |
10 | @override
11 | State createState() => _UniversitiesScreenState();
12 | }
13 |
14 | class _UniversitiesScreenState extends State {
15 | final UniversitiesViewModel _viewModel = UniversitiesViewModel();
16 |
17 | @override
18 | Widget build(BuildContext context) {
19 | return Scaffold(
20 | appBar: AppBar(
21 | title: const Text("RxDart State"),
22 | ),
23 | body: Column(
24 | children: [
25 | Container(
26 | margin: const EdgeInsets.all(10),
27 | child: TextField(
28 | onChanged: _viewModel.searchByCountry,
29 | decoration: const InputDecoration(
30 | labelText: 'Search', suffixIcon: Icon(Icons.search)),
31 | ),
32 | ),
33 | Expanded(
34 | child: StreamBuilder(
35 | stream: _viewModel.universities,
36 | builder: (BuildContext context,
37 | AsyncSnapshot> snapshot) {
38 | return snapshot.data?.when(
39 | data: (e) => _buildUniversities(e.universities),
40 | loading: () => _buildLoading(),
41 | appError: (e) => _buildError(e.toString()),
42 | apiError: (e) => _buildError(e.toString())) ??
43 | _buildLoading();
44 | },
45 | ),
46 | ),
47 | ],
48 | ),
49 | );
50 | }
51 |
52 | Widget _buildUniversities(List universities) {
53 | return ListView.builder(
54 | itemCount: universities.length,
55 | itemBuilder: (BuildContext context, int index) {
56 | return Card(
57 | elevation: 5,
58 | margin: const EdgeInsets.all(10),
59 | child: Container(
60 | padding: const EdgeInsets.all(25),
61 | child: Column(
62 | children: [
63 | Text("Name: ${universities[index].name}"),
64 | Text("Country: ${universities[index].country}"),
65 | Text("Website: ${universities[index].website}"),
66 | ],
67 | ),
68 | ),
69 | );
70 | },
71 | );
72 | }
73 |
74 | Widget _buildLoading() {
75 | return const Center(
76 | child: CircularProgressIndicator(),
77 | );
78 | }
79 |
80 | Widget _buildError(String error) {
81 | return Center(
82 | child: Text(
83 | error,
84 | style:
85 | Theme.of(context).textTheme.headline3?.copyWith(color: Colors.red),
86 | ),
87 | );
88 | }
89 | }
90 |
--------------------------------------------------------------------------------
/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme:
--------------------------------------------------------------------------------
1 |
2 |
5 |
8 |
9 |
15 |
21 |
22 |
23 |
24 |
25 |
30 |
31 |
37 |
38 |
39 |
40 |
41 |
42 |
52 |
54 |
60 |
61 |
62 |
63 |
69 |
71 |
77 |
78 |
79 |
80 |
82 |
83 |
86 |
87 |
88 |
--------------------------------------------------------------------------------
/test/unit_test/network_config/dio_error_convertor_test.dart:
--------------------------------------------------------------------------------
1 | import 'package:dio/dio.dart';
2 | import 'package:flutter_test/flutter_test.dart';
3 | import 'package:rxdart_state_management_article/network_config/api_error.dart';
4 | import 'package:rxdart_state_management_article/network_config/error_convertor.dart';
5 |
6 | import 'mock_interceptor/dio_mock_responses_adapter.dart';
7 |
8 | void main() {
9 | late Dio dioClient;
10 | late String baseUrl;
11 |
12 | DioMockResponsesAdapter _createMockAdapterForSearchRequest(
13 | int responseCode, Object responseBody) {
14 | return DioMockResponsesAdapter(MockAdapterInterceptor(
15 | RequestType.GET,
16 | baseUrl,
17 | "/test",
18 | {},
19 | responseBody,
20 | responseCode,
21 | ));
22 | }
23 |
24 | RequestOptions getRequestOptions() {
25 | const _extra = {};
26 | final queryParameters = {};
27 | final _headers = {};
28 | final _data = {};
29 | return Options(method: 'GET', headers: _headers, extra: _extra)
30 | .compose(dioClient.options, '/test',
31 | queryParameters: queryParameters, data: _data)
32 | .copyWith(baseUrl: dioClient.options.baseUrl);
33 | }
34 |
35 | group("Test Dio Error Convertor", () {
36 | setUp(() {
37 | baseUrl = "https://test.url";
38 | dioClient = Dio(BaseOptions(baseUrl: baseUrl));
39 | dioClient.interceptors.add(ErrorConverter());
40 | });
41 |
42 | test('Test endpoint returns 404 error with error message', () async {
43 | dioClient.httpClientAdapter = _createMockAdapterForSearchRequest(
44 | 404,
45 | {"errorMessage": "Not found!"},
46 | );
47 | ApiError? apiError;
48 | try {
49 | await dioClient.fetch(getRequestOptions());
50 | } on DioError catch (dioError, _) {
51 | expect(dioError.error.runtimeType, ApiError);
52 | apiError = dioError.error;
53 | }
54 | expect(apiError, ApiError(statusCode: 404, message: "Not found!"));
55 | });
56 |
57 | test('Test endpoint returns 404 error with no error message', () async {
58 | dioClient.httpClientAdapter = _createMockAdapterForSearchRequest(
59 | 404,
60 | {},
61 | );
62 | ApiError? apiError;
63 | try {
64 | await dioClient.fetch(getRequestOptions());
65 | } on DioError catch (dioError, _) {
66 | expect(dioError.error.runtimeType, ApiError);
67 | apiError = dioError.error;
68 | }
69 | expect(
70 | apiError, ApiError(statusCode: 404, message: "Something went wrong"));
71 | });
72 |
73 | test('Test endpoint returns 400 error with List of errors', () async {
74 | dioClient.httpClientAdapter = _createMockAdapterForSearchRequest(
75 | 400,
76 | {
77 | "errorMessage": "Incorrect fields!",
78 | "errors": {
79 | "emailField": "Email is invalid",
80 | "passwordField": "Password is too short",
81 | },
82 | },
83 | );
84 | ApiError? apiError;
85 | try {
86 | await dioClient.fetch(getRequestOptions());
87 | } on DioError catch (dioError, _) {
88 | expect(dioError.error.runtimeType, ApiError);
89 | apiError = dioError.error;
90 | }
91 | expect(
92 | apiError,
93 | ApiError(
94 | statusCode: 400,
95 | message: "Incorrect fields!",
96 | errors: {
97 | "emailField": "Email is invalid",
98 | "passwordField": "Password is too short",
99 | },
100 | ));
101 | });
102 | });
103 | }
104 |
--------------------------------------------------------------------------------
/lib/features/universities_feed/presentation/screen/universities_screen_manual_subscription.dart:
--------------------------------------------------------------------------------
1 | import 'package:flutter/material.dart';
2 | import 'package:rxdart/rxdart.dart';
3 | import 'package:rxdart_state_management_article/features/universities_feed/presentation/models/university_screen_model.dart';
4 | import 'package:rxdart_state_management_article/features/universities_feed/presentation/models/university_screen_state.dart';
5 | import 'package:rxdart_state_management_article/features/universities_feed/presentation/screen/universities_view_model.dart';
6 | import 'package:rxdart_state_management_article/network_config/app_result.dart';
7 |
8 | class UniversitiesScreenManualSubscription extends StatefulWidget {
9 | const UniversitiesScreenManualSubscription({Key? key}) : super(key: key);
10 |
11 | @override
12 | State createState() =>
13 | _UniversitiesScreenManualSubscriptionState();
14 | }
15 |
16 | class _UniversitiesScreenManualSubscriptionState
17 | extends State {
18 | final UniversitiesViewModel _viewModel = UniversitiesViewModel();
19 | final CompositeSubscription _subscriptions = CompositeSubscription();
20 |
21 | AppResult _screenState = const AppResult.loading();
22 |
23 | @override
24 | void initState() {
25 | super.initState();
26 | _subscriptions.add(_viewModel.universities.listen((event) {
27 | setState(() {
28 | _screenState = event;
29 | });
30 | }));
31 | }
32 |
33 | @override
34 | void dispose() {
35 | _subscriptions.dispose();
36 | super.dispose();
37 | }
38 |
39 | @override
40 | Widget build(BuildContext context) {
41 | return Scaffold(
42 | appBar: AppBar(
43 | title: const Text("RxDart State"),
44 | ),
45 | body: Column(
46 | children: [
47 | Container(
48 | margin: const EdgeInsets.all(10),
49 | child: TextField(
50 | onChanged: _viewModel.searchByCountry,
51 | decoration: const InputDecoration(
52 | labelText: 'Search', suffixIcon: Icon(Icons.search)),
53 | ),
54 | ),
55 | Expanded(
56 | child: _screenState.when(
57 | data: (e) => _buildUniversities(e.universities),
58 | loading: () => _buildLoading(),
59 | appError: (e) => _buildError(e.toString()),
60 | apiError: (e) => _buildError(e.toString())),
61 | ),
62 | ],
63 | ),
64 | );
65 | }
66 |
67 | Widget _buildUniversities(List universities) {
68 | return ListView.builder(
69 | itemCount: universities.length,
70 | itemBuilder: (BuildContext context, int index) {
71 | return Card(
72 | elevation: 5,
73 | margin: const EdgeInsets.all(10),
74 | child: Container(
75 | padding: const EdgeInsets.all(25),
76 | child: Column(
77 | children: [
78 | Text("Name: ${universities[index].name}"),
79 | Text("Country: ${universities[index].country}"),
80 | Text("Website: ${universities[index].website}"),
81 | ],
82 | ),
83 | ),
84 | );
85 | },
86 | );
87 | }
88 |
89 | Widget _buildLoading() {
90 | return const Center(
91 | child: CircularProgressIndicator(),
92 | );
93 | }
94 |
95 | Widget _buildError(String error) {
96 | return Center(
97 | child: Text(
98 | error,
99 | style:
100 | Theme.of(context).textTheme.headline3?.copyWith(color: Colors.red),
101 | ),
102 | );
103 | }
104 | }
105 |
--------------------------------------------------------------------------------
/test/unit_test/universities_feed/data/source/network/endpoint/university_endpoint_test.dart:
--------------------------------------------------------------------------------
1 | import 'package:dio/dio.dart';
2 | import 'package:flutter_test/flutter_test.dart';
3 | import 'package:rxdart_state_management_article/features/universities_feed/data/source/network/endpoint/university_endpoint.dart';
4 | import 'package:rxdart_state_management_article/features/universities_feed/data/source/network/model/api_university_model.dart';
5 |
6 | import '../../../../../network_config/mock_interceptor/dio_mock_responses_adapter.dart';
7 |
8 | void main() {
9 | late Dio dioClient;
10 | late UniversityEndpoint endpoint;
11 | late String baseUrl;
12 |
13 | DioMockResponsesAdapter _createMockAdapterForSearchRequest(
14 | int responseCode, Object responseBody) {
15 | return DioMockResponsesAdapter(MockAdapterInterceptor(
16 | RequestType.GET,
17 | baseUrl,
18 | "/search",
19 | {"country": "us"},
20 | responseBody,
21 | responseCode,
22 | ));
23 | }
24 |
25 | List