diff --git a/.github/workflows/android_release_staging.yml b/.github/workflows/android_release_staging.yml new file mode 100644 index 0000000..a1bd77f --- /dev/null +++ b/.github/workflows/android_release_staging.yml @@ -0,0 +1,41 @@ +# This is a lint and test workflow for Flutter CI + +name: Relase (staging) + +on: + push: + branches: + - release/android-* + pull_request: + branches: + - release/android-* +jobs: + deploy: + name: Build android + runs-on: ubuntu-latest + steps: + # Setup Java environment in order to build the Android app. + - uses: ruby/setup-ruby@v1.71.0 + with: + ruby-version: '2.7.2' + - uses: actions/checkout@v2.3.2 + - uses: actions/setup-java@v1 + with: + java-version: '12.x' + + # Setup the Flutter environment. + - uses: subosito/flutter-action@v1 + with: + channel: 'stable' # 'dev', 'alpha', default to: 'stable' + + - name: Get Flutter dependencies. + run: flutter pub get + + - name: Build app + run: flutter build apk --flavor staging -t lib/main-staging.dart --debug + + - name: Upload to firebase + uses: maierj/fastlane-action@v2.0.1 + with: + lane: "android upload_firebase" + options: '{"token": "${{secrets.FIREBASE_TOKEN}}", "app_id": "${{secrets.FIREBASE_ANDROID_APP_ID}}", "apk_path": "build/app/outputs/flutter-apk/app-staging-debug.apk"}' diff --git a/.github/workflows/ios_release_staging.yml b/.github/workflows/ios_release_staging.yml new file mode 100644 index 0000000..5f22a16 --- /dev/null +++ b/.github/workflows/ios_release_staging.yml @@ -0,0 +1,67 @@ +# This is a lint and test workflow for Flutter CI + +name: Relase (staging) + +on: + push: + branches: + - release/ios-* + pull_request: + branches: + - release/ios-* +jobs: + deploy: + name: Build iOS + runs-on: macOS-latest + steps: + # Setup environment + - uses: ruby/setup-ruby@v1.71.0 + with: + ruby-version: '2.7.2' + - uses: actions/checkout@v2.3.2 + + # Setup the Flutter environment. + - uses: subosito/flutter-action@v1 + with: + channel: 'stable' # 'dev', 'alpha', default to: 'stable' + + - name: Get Flutter dependencies. + run: flutter pub get + + - name: Build app + run: flutter build ios --flavor staging -t lib/main-staging.dart --no-codesign + + - name: Install the Apple certificate and provisioning profile + env: + BUILD_CERTIFICATE_BASE64: ${{ secrets.DEVELOPMENT_CERTIFICATE_DATA }} + P12_PASSWORD: ${{ secrets.DEVELOPMENT_CERTIFICATE_PASSPHRASE }} + BUILD_PROVISION_PROFILE_BASE64: ${{ secrets.BUILD_PROVISION_PROFILE }} + KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }} + run: | + # create variables + CERTIFICATE_PATH=$RUNNER_TEMP/build_certificate.p12 + PP_PATH=$RUNNER_TEMP/build_pp.mobileprovision + KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db + + # import certificate and provisioning profile from secrets + echo -n "$BUILD_CERTIFICATE_BASE64" | base64 --decode --output $CERTIFICATE_PATH + echo -n "$BUILD_PROVISION_PROFILE_BASE64" | base64 --decode --output $PP_PATH + + # create temporary keychain + security create-keychain -p $KEYCHAIN_PASSWORD $KEYCHAIN_PATH + security set-keychain-settings -lut 21600 $KEYCHAIN_PATH + security unlock-keychain -p $KEYCHAIN_PASSWORD $KEYCHAIN_PATH + + # import certificate to keychain + security import $CERTIFICATE_PATH -P $P12_PASSWORD -A -t cert -f pkcs12 -k $KEYCHAIN_PATH + security list-keychain -d user -s $KEYCHAIN_PATH + + # apply provisioning profile + mkdir -p ~/Library/MobileDevice/Provisioning\ Profiles + cp $PP_PATH ~/Library/MobileDevice/Provisioning\ Profiles + + - name: Export ipa and upload to firebase + uses: maierj/fastlane-action@v2.0.1 + with: + lane: "ios release_staging" + options: '{"token": "${{secrets.FIREBASE_TOKEN}}", "app_id": "${{secrets.FIREBASE_IOS_APP_ID}}"}' diff --git a/Gemfile b/Gemfile new file mode 100644 index 0000000..b734015 --- /dev/null +++ b/Gemfile @@ -0,0 +1,10 @@ +# Autogenerated by fastlane +# +# Ensure this file is checked in to source control! + +source "https://rubygems.org" + +gem 'fastlane' + +plugins_path = File.join(File.dirname(__FILE__), 'fastlane', 'Pluginfile') +eval_gemfile(plugins_path) if File.exist?(plugins_path) diff --git a/Gemfile.lock b/Gemfile.lock new file mode 100644 index 0000000..86c0f73 --- /dev/null +++ b/Gemfile.lock @@ -0,0 +1,207 @@ +GEM + remote: https://rubygems.org/ + specs: + CFPropertyList (3.0.3) + addressable (2.7.0) + public_suffix (>= 2.0.2, < 5.0) + artifactory (3.0.15) + atomos (0.1.3) + aws-eventstream (1.1.1) + aws-partitions (1.462.0) + aws-sdk-core (3.114.0) + aws-eventstream (~> 1, >= 1.0.2) + aws-partitions (~> 1, >= 1.239.0) + aws-sigv4 (~> 1.1) + jmespath (~> 1.0) + aws-sdk-kms (1.43.0) + aws-sdk-core (~> 3, >= 3.112.0) + aws-sigv4 (~> 1.1) + aws-sdk-s3 (1.95.1) + aws-sdk-core (~> 3, >= 3.112.0) + aws-sdk-kms (~> 1) + aws-sigv4 (~> 1.1) + aws-sigv4 (1.2.3) + aws-eventstream (~> 1, >= 1.0.2) + babosa (1.0.4) + claide (1.0.3) + colored (1.2) + colored2 (3.1.2) + commander (4.6.0) + highline (~> 2.0.0) + declarative (0.0.20) + digest-crc (0.6.3) + rake (>= 12.0.0, < 14.0.0) + domain_name (0.5.20190701) + unf (>= 0.0.5, < 1.0.0) + dotenv (2.7.6) + emoji_regex (3.2.2) + excon (0.81.0) + faraday (1.4.2) + faraday-em_http (~> 1.0) + faraday-em_synchrony (~> 1.0) + faraday-excon (~> 1.1) + faraday-net_http (~> 1.0) + faraday-net_http_persistent (~> 1.1) + multipart-post (>= 1.2, < 3) + ruby2_keywords (>= 0.0.4) + faraday-cookie_jar (0.0.7) + faraday (>= 0.8.0) + http-cookie (~> 1.0.0) + faraday-em_http (1.0.0) + faraday-em_synchrony (1.0.0) + faraday-excon (1.1.0) + faraday-net_http (1.0.1) + faraday-net_http_persistent (1.1.0) + faraday_middleware (1.0.0) + faraday (~> 1.0) + fastimage (2.2.3) + fastlane (2.184.0) + CFPropertyList (>= 2.3, < 4.0.0) + addressable (>= 2.3, < 3.0.0) + artifactory (~> 3.0) + aws-sdk-s3 (~> 1.0) + babosa (>= 1.0.3, < 2.0.0) + bundler (>= 1.12.0, < 3.0.0) + colored + commander (~> 4.6) + dotenv (>= 2.1.1, < 3.0.0) + emoji_regex (>= 0.1, < 4.0) + excon (>= 0.71.0, < 1.0.0) + faraday (~> 1.0) + faraday-cookie_jar (~> 0.0.6) + faraday_middleware (~> 1.0) + fastimage (>= 2.1.0, < 3.0.0) + gh_inspector (>= 1.1.2, < 2.0.0) + google-apis-androidpublisher_v3 (~> 0.1) + google-apis-playcustomapp_v1 (~> 0.1) + google-cloud-storage (~> 1.31) + highline (~> 2.0) + json (< 3.0.0) + jwt (>= 2.1.0, < 3) + mini_magick (>= 4.9.4, < 5.0.0) + multipart-post (~> 2.0.0) + naturally (~> 2.2) + plist (>= 3.1.0, < 4.0.0) + rubyzip (>= 2.0.0, < 3.0.0) + security (= 0.1.3) + simctl (~> 1.6.3) + terminal-notifier (>= 2.0.0, < 3.0.0) + terminal-table (>= 1.4.5, < 2.0.0) + tty-screen (>= 0.6.3, < 1.0.0) + tty-spinner (>= 0.8.0, < 1.0.0) + word_wrap (~> 1.0.0) + xcodeproj (>= 1.13.0, < 2.0.0) + xcpretty (~> 0.3.0) + xcpretty-travis-formatter (>= 0.0.3) + fastlane-plugin-firebase_app_distribution (0.2.9) + gh_inspector (1.1.3) + google-apis-androidpublisher_v3 (0.4.0) + google-apis-core (~> 0.1) + google-apis-core (0.3.0) + addressable (~> 2.5, >= 2.5.1) + googleauth (~> 0.14) + httpclient (>= 2.8.1, < 3.0) + mini_mime (~> 1.0) + representable (~> 3.0) + retriable (>= 2.0, < 4.0) + rexml + signet (~> 0.14) + webrick + google-apis-iamcredentials_v1 (0.4.0) + google-apis-core (~> 0.1) + google-apis-playcustomapp_v1 (0.3.0) + google-apis-core (~> 0.1) + google-apis-storage_v1 (0.4.0) + google-apis-core (~> 0.1) + google-cloud-core (1.6.0) + google-cloud-env (~> 1.0) + google-cloud-errors (~> 1.0) + google-cloud-env (1.5.0) + faraday (>= 0.17.3, < 2.0) + google-cloud-errors (1.1.0) + google-cloud-storage (1.31.1) + addressable (~> 2.5) + digest-crc (~> 0.4) + google-apis-iamcredentials_v1 (~> 0.1) + google-apis-storage_v1 (~> 0.1) + google-cloud-core (~> 1.2) + googleauth (~> 0.9) + mini_mime (~> 1.0) + googleauth (0.16.2) + faraday (>= 0.17.3, < 2.0) + jwt (>= 1.4, < 3.0) + memoist (~> 0.16) + multi_json (~> 1.11) + os (>= 0.9, < 2.0) + signet (~> 0.14) + highline (2.0.3) + http-cookie (1.0.3) + domain_name (~> 0.5) + httpclient (2.8.3) + jmespath (1.4.0) + json (2.5.1) + jwt (2.2.3) + memoist (0.16.2) + mini_magick (4.11.0) + mini_mime (1.1.0) + multi_json (1.15.0) + multipart-post (2.0.0) + nanaimo (0.3.0) + naturally (2.2.1) + os (1.1.1) + plist (3.6.0) + public_suffix (4.0.6) + rake (13.0.3) + representable (3.1.1) + declarative (< 0.1.0) + trailblazer-option (>= 0.1.1, < 0.2.0) + uber (< 0.2.0) + retriable (3.1.2) + rexml (3.2.5) + rouge (2.0.7) + ruby2_keywords (0.0.4) + rubyzip (2.3.0) + security (0.1.3) + signet (0.15.0) + addressable (~> 2.3) + faraday (>= 0.17.3, < 2.0) + jwt (>= 1.5, < 3.0) + multi_json (~> 1.10) + simctl (1.6.8) + CFPropertyList + naturally + terminal-notifier (2.0.0) + terminal-table (1.8.0) + unicode-display_width (~> 1.1, >= 1.1.1) + trailblazer-option (0.1.1) + tty-cursor (0.7.1) + tty-screen (0.8.1) + tty-spinner (0.9.3) + tty-cursor (~> 0.7) + uber (0.1.0) + unf (0.1.4) + unf_ext + unf_ext (0.0.7.7) + unicode-display_width (1.7.0) + webrick (1.7.0) + word_wrap (1.0.0) + xcodeproj (1.19.0) + CFPropertyList (>= 2.3.3, < 4.0) + atomos (~> 0.1.3) + claide (>= 1.0.2, < 2.0) + colored2 (~> 3.1) + nanaimo (~> 0.3.0) + xcpretty (0.3.0) + rouge (~> 2.0.7) + xcpretty-travis-formatter (1.0.1) + xcpretty (~> 0.2, >= 0.0.7) + +PLATFORMS + x86_64-darwin-20 + +DEPENDENCIES + fastlane + fastlane-plugin-firebase_app_distribution + +BUNDLED WITH + 2.2.15 diff --git a/assets/l10n/app_en.arb b/assets/l10n/app_en.arb index e63b9ff..4e68bd8 100644 --- a/assets/l10n/app_en.arb +++ b/assets/l10n/app_en.arb @@ -1,6 +1,9 @@ { - "helloWorld": "Hello World!", "alertErrorTitle": "Error", "alertErrorOKLabel": "OK", - "alertErrorFallbackMessage": "Unknown error" + "alertErrorFallbackMessage": "Unknown error", + "loginScreenLoginButtonText": "Login", + "loginScreenForgotButtonText": "Forgot?", + "loginScreenEmailTextFieldPlaceholderText": "Email", + "loginScreenPasswordTextFieldPlaceholderText": "Password" } diff --git a/assets/l10n/app_vi.arb b/assets/l10n/app_vi.arb index bab5152..9a0fb9d 100644 --- a/assets/l10n/app_vi.arb +++ b/assets/l10n/app_vi.arb @@ -2,5 +2,9 @@ "helloWorld": "Xin chào Thế giới!", "alertErrorTitle": "Lỗi", "alertErrorOKLabel": "OK", - "alertErrorFallbackMessage": "Lỗi không xác định" + "alertErrorFallbackMessage": "Lỗi không xác định", + "loginScreenLoginButtonText": "Đăng nhập", + "loginScreenForgotButtonText": "Quên?", + "loginScreenEmailTextFieldPlaceholderText": "Email", + "loginScreenPasswordTextFieldPlaceholderText": "Mật khẩu" } diff --git a/coverage/lcov.info b/coverage/lcov.info index 64e6a2a..8d6c3cb 100644 --- a/coverage/lcov.info +++ b/coverage/lcov.info @@ -4,33 +4,33 @@ LF:1 LH:0 end_of_record SF:lib/modules/landing/landing_view.dart -DA:19,6 -DA:21,1 +DA:20,11 DA:22,1 -DA:28,2 -DA:33,2 -DA:34,1 -DA:38,1 +DA:23,1 +DA:30,2 +DA:35,2 +DA:36,1 DA:40,1 -DA:41,3 -DA:42,3 -DA:43,1 +DA:42,1 +DA:43,3 DA:44,3 -DA:48,1 +DA:45,1 +DA:46,3 DA:50,1 -DA:51,1 +DA:52,1 DA:53,1 -DA:54,1 -DA:57,2 -DA:61,1 -DA:62,1 +DA:55,1 +DA:56,1 +DA:59,2 +DA:63,1 DA:64,1 -DA:65,2 -DA:73,1 -DA:75,2 -DA:78,1 -DA:80,2 -DA:81,1 +DA:66,1 +DA:67,2 +DA:75,1 +DA:77,2 +DA:80,1 +DA:82,2 +DA:83,1 LF:27 LH:27 end_of_record @@ -82,36 +82,184 @@ LF:1 LH:1 end_of_record SF:lib/components/alert/alert.dart +DA:9,2 +DA:15,2 +DA:23,2 +DA:28,4 +DA:29,2 +DA:33,2 +DA:34,0 +DA:37,2 +DA:39,4 +DA:41,4 +DA:50,2 +DA:51,2 +DA:52,2 +DA:54,6 +LF:14 +LH:13 +end_of_record +SF:lib/modules/login/login_view.dart +DA:37,1 +DA:38,1 +DA:48,2 +DA:53,2 +DA:54,1 +DA:58,1 +DA:60,1 +DA:61,3 +DA:62,5 +DA:65,3 +DA:66,5 +DA:68,3 +DA:71,1 +DA:73,1 +DA:74,1 +DA:75,1 +DA:76,1 +DA:82,1 +DA:83,1 +DA:85,1 +DA:86,1 +DA:87,1 +DA:88,1 +DA:89,1 +DA:90,3 +DA:91,3 +DA:94,2 +DA:98,1 +DA:100,1 +DA:102,1 +DA:104,1 +DA:105,1 +DA:106,1 +DA:107,1 +DA:108,1 +DA:110,2 +DA:111,1 +DA:117,1 +DA:118,1 +DA:119,2 +DA:123,4 +DA:124,2 +DA:136,1 +DA:138,2 +DA:139,1 +DA:142,1 +DA:144,2 +DA:147,1 +DA:149,2 +DA:152,1 +DA:154,2 +DA:157,1 +DA:159,2 +LF:53 +LH:53 +end_of_record +SF:lib/modules/login/login_presenter.dart +DA:7,1 +DA:8,3 +DA:9,3 +DA:10,3 +DA:11,3 +DA:12,1 +DA:13,2 +DA:15,3 +DA:16,3 +DA:43,1 +DA:44,2 +DA:45,2 +DA:48,1 +DA:49,4 +DA:52,1 +DA:53,2 +DA:54,4 +DA:57,1 +DA:58,4 +DA:59,2 +DA:62,1 +DA:63,4 +DA:66,1 +DA:67,2 +DA:68,2 +LF:25 +LH:25 +end_of_record +SF:lib/modules/login/login_interactor.dart +DA:16,1 +DA:18,2 +DA:19,1 +DA:25,3 +DA:26,3 +DA:27,1 +DA:28,3 +LF:7 +LH:7 +end_of_record +SF:lib/modules/login/login_router.dart DA:9,1 -DA:15,1 +DA:11,2 +DA:14,1 +DA:16,2 +LF:4 +LH:4 +end_of_record +SF:lib/modules/login/components/form.dart +DA:4,1 +DA:5,1 +DA:9,3 +DA:11,1 +DA:13,1 +DA:16,1 +DA:18,1 +DA:20,1 +DA:22,1 DA:23,1 -DA:28,2 -DA:29,1 +DA:27,1 +DA:31,1 +DA:32,2 DA:33,1 -DA:34,0 -DA:37,1 -DA:39,2 -DA:41,2 +DA:34,1 +DA:36,2 +DA:38,6 +DA:39,3 +DA:40,3 DA:50,1 DA:51,1 -DA:52,1 -DA:54,5 -LF:14 -LH:13 +DA:53,1 +DA:55,2 +DA:56,1 +DA:58,2 +DA:63,1 +DA:64,1 +DA:66,1 +DA:68,2 +DA:69,1 +DA:71,2 +DA:72,1 +DA:74,5 +DA:75,2 +DA:78,1 +DA:79,3 +DA:80,1 +DA:81,1 +LF:38 +LH:38 end_of_record SF:lib/modules/login/login_module.dart -DA:8,0 -LF:1 -LH:0 +DA:34,1 +DA:36,1 +LF:2 +LH:2 end_of_record SF:lib/app.dart -DA:9,1 -DA:11,1 -DA:13,1 -DA:14,1 -DA:15,3 -DA:18,2 -DA:19,1 +DA:9,2 +DA:11,2 +DA:13,2 +DA:14,2 +DA:15,6 +DA:18,4 +DA:19,2 DA:26,0 LF:8 LH:7 @@ -121,31 +269,32 @@ DA:3,0 LF:1 LH:0 end_of_record -SF:lib/configs/app.dart -DA:4,5 -DA:6,0 -DA:10,5 -DA:12,0 -DA:14,0 -DA:22,0 -DA:24,0 -DA:32,0 -DA:34,0 -LF:9 -LH:2 -end_of_record SF:lib/configs/routes.dart -DA:3,6 +DA:3,9 DA:4,0 DA:5,2 DA:6,2 -LF:4 +DA:7,0 +LF:5 LH:3 end_of_record +SF:lib/configs/app.dart +DA:4,10 +DA:6,1 +DA:10,10 +DA:12,0 +DA:14,0 +DA:22,1 +DA:24,1 +DA:32,1 +DA:34,1 +LF:9 +LH:7 +end_of_record SF:lib/gen/configs.gen.dart -DA:15,0 -DA:16,6 -DA:18,0 +DA:16,0 +DA:17,9 +DA:19,0 LF:3 LH:1 end_of_record @@ -164,23 +313,87 @@ LF:5 LH:0 end_of_record SF:lib/core/viper/view.dart -DA:16,1 -DA:18,1 -DA:20,2 -DA:22,1 +DA:16,2 +DA:18,2 +DA:20,4 +DA:22,2 LF:4 LH:4 end_of_record SF:lib/core/viper/presenter.dart +DA:9,2 +DA:14,2 +DA:15,2 +DA:16,2 +LF:4 +LH:4 +end_of_record +SF:lib/components/button/button.dart DA:9,1 DA:14,1 -DA:15,1 DA:16,1 -LF:4 -LH:4 +DA:18,1 +DA:19,1 +DA:20,1 +DA:21,1 +DA:23,1 +DA:24,2 +DA:25,2 +DA:26,1 +DA:28,1 +DA:29,1 +DA:30,1 +DA:31,1 +LF:15 +LH:15 +end_of_record +SF:lib/components/progress_hud/progress_hud.dart +DA:10,1 +DA:14,1 +DA:16,1 +DA:20,1 +DA:21,1 +DA:22,1 +DA:25,0 +DA:32,0 +DA:33,0 +DA:38,1 +DA:39,1 +DA:41,1 +LF:12 +LH:9 +end_of_record +SF:lib/components/translucent_text_field/translucent_text_field.dart +DA:7,1 +DA:14,1 +DA:22,1 +DA:24,1 +DA:26,1 +DA:29,1 +DA:31,1 +DA:32,1 +DA:33,1 +DA:35,1 +DA:36,1 +DA:37,1 +DA:38,1 +DA:39,1 +DA:40,1 +DA:42,2 +DA:43,1 +DA:44,1 +DA:45,1 +DA:49,1 +DA:51,0 +DA:52,0 +DA:53,0 +DA:56,0 +DA:59,3 +LF:25 +LH:21 end_of_record SF:lib/core/extensions/build_context.dart -DA:5,2 +DA:5,4 LF:1 LH:1 end_of_record @@ -200,9 +413,9 @@ DA:8,0 DA:9,0 DA:10,0 DA:17,0 -DA:22,1 -DA:23,2 -DA:24,1 +DA:22,2 +DA:23,4 +DA:24,2 LF:11 LH:3 end_of_record @@ -218,34 +431,37 @@ DA:12,0 DA:15,0 DA:16,0 DA:17,0 -LF:11 +DA:20,0 +DA:21,0 +DA:22,0 +LF:14 LH:0 end_of_record SF:lib/services/locator/locator_service.dart -DA:12,8 +DA:13,12 LF:1 LH:1 end_of_record SF:lib/gen/assets.gen.dart -DA:11,5 +DA:11,10 DA:13,0 -DA:15,1 -DA:17,1 +DA:15,2 +DA:17,2 DA:22,0 -DA:28,5 -DA:30,1 -DA:50,1 +DA:28,10 +DA:30,2 +DA:50,2 DA:73,0 -DA:77,5 -DA:81,1 -DA:98,1 -DA:99,1 +DA:77,10 +DA:81,2 +DA:98,2 +DA:99,2 DA:118,0 LF:14 LH:10 end_of_record SF:lib/gen/flavors.gen.dart -DA:1,5 +DA:1,10 LF:1 LH:1 end_of_record @@ -267,17 +483,22 @@ DA:10,0 LF:3 LH:0 end_of_record +SF:lib/modules/forgot_password/forgot_password_module.dart +DA:8,0 +LF:1 +LH:0 +end_of_record SF:lib/modules/screen.dart -DA:11,11 -DA:16,1 -DA:20,1 -DA:21,1 -DA:22,1 -DA:23,1 +DA:11,22 +DA:16,2 +DA:20,2 +DA:21,2 +DA:22,2 +DA:23,2 DA:27,0 DA:28,0 -DA:32,1 -DA:33,1 +DA:32,2 +DA:33,2 LF:10 LH:8 end_of_record @@ -326,11 +547,11 @@ LF:40 LH:0 end_of_record SF:lib/services/auth/params/auth_login_params.dart -DA:4,0 -DA:10,0 -DA:13,0 -DA:14,0 -DA:18,0 +DA:4,1 +DA:10,1 +DA:13,2 +DA:14,2 +DA:18,1 DA:31,0 DA:33,0 DA:34,0 @@ -339,7 +560,7 @@ DA:36,0 DA:37,0 DA:38,0 LF:12 -LH:0 +LH:5 end_of_record SF:lib/services/api/api_exception.dart DA:4,0 @@ -381,13 +602,13 @@ LH:0 end_of_record SF:lib/services/http/http_method.dart DA:4,0 -DA:5,5 +DA:5,10 DA:13,0 LF:3 LH:1 end_of_record SF:lib/services/http/http_exception.dart -DA:3,5 +DA:3,10 DA:25,0 DA:31,0 DA:33,0 diff --git a/fastlane/Appfile b/fastlane/Appfile new file mode 100644 index 0000000..1803063 --- /dev/null +++ b/fastlane/Appfile @@ -0,0 +1,6 @@ +# app_identifier("[[APP_IDENTIFIER]]") # The bundle identifier of your app +# apple_id("[[APPLE_ID]]") # Your Apple email address + + +# For more information about the Appfile, see: +# https://docs.fastlane.tools/advanced/#appfile diff --git a/fastlane/Fastfile b/fastlane/Fastfile new file mode 100644 index 0000000..8014908 --- /dev/null +++ b/fastlane/Fastfile @@ -0,0 +1,48 @@ +# This file contains the fastlane.tools configuration +# You can find the documentation at https://docs.fastlane.tools +# +# For a list of all available actions, check out +# +# https://docs.fastlane.tools/actions +# +# For a list of all available plugins, check out +# +# https://docs.fastlane.tools/plugins/available-plugins +# + +# Uncomment the line if you want fastlane to automatically update itself +# update_fastlane + +# default_platform(:ios) + +platform :ios do + desc "Export ipa and upload to Firebase" + lane :release_staging do |options| + + build_app( + workspace: "ios/Runner.xcworkspace", + scheme: "staging", + silent: true, + configuration: "Release-staging", + export_options: { + method: "development" + } + ) + + firebase_app_distribution( + app: options[:app_id], + firebase_cli_token: options[:token], + ) + end +end + +platform :android do + lane :upload_firebase do |options| + firebase_app_distribution( + app: options[:app_id], + android_artifact_type: "APK", + android_artifact_path: options[:apk_path], + firebase_cli_token: options[:token], + ) +end +end diff --git a/fastlane/Pluginfile b/fastlane/Pluginfile new file mode 100644 index 0000000..a812a01 --- /dev/null +++ b/fastlane/Pluginfile @@ -0,0 +1,6 @@ +# Autogenerated by fastlane +# +# Ensure this file is checked in to source control! + +gem 'fastlane-plugin-firebase_app_distribution' +gem 'fastlane-plugin-firebase_app_distribution' diff --git a/fastlane/README.md b/fastlane/README.md new file mode 100644 index 0000000..1d028d2 --- /dev/null +++ b/fastlane/README.md @@ -0,0 +1,38 @@ +fastlane documentation +================ +# Installation + +Make sure you have the latest version of the Xcode command line tools installed: + +``` +xcode-select --install +``` + +Install _fastlane_ using +``` +[sudo] gem install fastlane -NV +``` +or alternatively using `brew install fastlane` + +# Available Actions +## iOS +### ios release_staging +``` +fastlane ios release_staging +``` +Export ipa and upload to Firebase + +---- + +## Android +### android release_staging +``` +fastlane android release_staging +``` +Export ipa and upload to Firebase + +---- + +This README.md is auto-generated and will be re-generated every time [fastlane](https://fastlane.tools) is run. +More information about fastlane can be found on [fastlane.tools](https://fastlane.tools). +The documentation of fastlane can be found on [docs.fastlane.tools](https://docs.fastlane.tools). diff --git a/fastlane/report.xml b/fastlane/report.xml new file mode 100644 index 0000000..e1978a6 --- /dev/null +++ b/fastlane/report.xml @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index 87b09a4..b632a02 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -476,6 +476,7 @@ isa = XCBuildConfiguration; baseConfigurationReference = 723E2E286422E7355D82CD8E /* Pods-Runner.debug-production.xcconfig */; buildSettings = { + DEVELOPMENT_TEAM = FM5NT9MLCT; PRODUCT_NAME = Runner; }; name = "Debug-production"; @@ -484,6 +485,7 @@ isa = XCBuildConfiguration; baseConfigurationReference = A5EBA3F1CC940667DF79EE46 /* Pods-Runner.profile-production.xcconfig */; buildSettings = { + DEVELOPMENT_TEAM = FM5NT9MLCT; PRODUCT_NAME = Runner; }; name = "Profile-production"; @@ -492,6 +494,7 @@ isa = XCBuildConfiguration; baseConfigurationReference = BD4D2DD9EF09CB23F1BFE775 /* Pods-Runner.release-production.xcconfig */; buildSettings = { + DEVELOPMENT_TEAM = FM5NT9MLCT; PRODUCT_NAME = Runner; }; name = "Release-production"; @@ -500,6 +503,7 @@ isa = XCBuildConfiguration; baseConfigurationReference = 527D168B5B717A9D51E5BDE8 /* Pods-Runner.debug-staging.xcconfig */; buildSettings = { + DEVELOPMENT_TEAM = FM5NT9MLCT; PRODUCT_NAME = Runner; }; name = "Debug-staging"; @@ -508,6 +512,7 @@ isa = XCBuildConfiguration; baseConfigurationReference = C2F4412A61D42AC96F3D7D8A /* Pods-Runner.profile-staging.xcconfig */; buildSettings = { + DEVELOPMENT_TEAM = FM5NT9MLCT; PRODUCT_NAME = Runner; }; name = "Profile-staging"; @@ -516,6 +521,7 @@ isa = XCBuildConfiguration; baseConfigurationReference = E2E32C4D4960E4DC124372C0 /* Pods-Runner.release-staging.xcconfig */; buildSettings = { + DEVELOPMENT_TEAM = FM5NT9MLCT; PRODUCT_NAME = Runner; }; name = "Release-staging"; diff --git a/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme index a28140c..fb2dffc 100644 --- a/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme +++ b/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -27,8 +27,6 @@ selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB" selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" shouldUseLaunchSchemeArgsEnv = "YES"> - - - - + + - - + + + + + + - - + shouldUseLaunchSchemeArgsEnv = "YES"> + + - - - + - + - + debugDocumentVersioning = "YES"> + - + diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index cda6f0c..cd93082 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -1,45 +1,45 @@ - - CFBundleDevelopmentRegion - $(DEVELOPMENT_LANGUAGE) - CFBundleExecutable - $(EXECUTABLE_NAME) - CFBundleIdentifier - $(PRODUCT_BUNDLE_IDENTIFIER) - CFBundleInfoDictionaryVersion - 6.0 - CFBundleName - $(BUNDLE_NAME) - CFBundlePackageType - APPL - CFBundleShortVersionString - $(FLUTTER_BUILD_NAME) - CFBundleSignature - ???? - CFBundleVersion - $(FLUTTER_BUILD_NUMBER) - LSRequiresIPhoneOS - - UILaunchStoryboardName - $(ASSET_PREFIX)LaunchScreen - UIMainStoryboardFile - Main - UISupportedInterfaceOrientations - - UIInterfaceOrientationPortrait - UIInterfaceOrientationLandscapeLeft - UIInterfaceOrientationLandscapeRight - - UISupportedInterfaceOrientations~ipad - - UIInterfaceOrientationPortrait - UIInterfaceOrientationPortraitUpsideDown - UIInterfaceOrientationLandscapeLeft - UIInterfaceOrientationLandscapeRight - - UIViewControllerBasedStatusBarAppearance - - + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(BUNDLE_NAME) + CFBundlePackageType + APPL + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleSignature + ???? + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + LSRequiresIPhoneOS + + UILaunchStoryboardName + $(ASSET_PREFIX)LaunchScreen + UIMainStoryboardFile + Main + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UIViewControllerBasedStatusBarAppearance + + diff --git a/lib/components/button/button.dart b/lib/components/button/button.dart new file mode 100644 index 0000000..5c9fdeb --- /dev/null +++ b/lib/components/button/button.dart @@ -0,0 +1,41 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_platform_widgets/flutter_platform_widgets.dart'; + +class Button extends StatelessWidget { + final String? title; + final VoidCallback? onPressed; + final bool isEnabled; + const Button({ + Key? key, + this.title, + this.onPressed, + this.isEnabled = true, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + return Container( + decoration: BoxDecoration( + color: isEnabled ? Colors.white : Colors.grey, + borderRadius: BorderRadius.circular(12.0), + ), + child: PlatformButton( + onPressed: isEnabled ? onPressed : null, + materialFlat: (_, __) => MaterialFlatButtonData(), + child: Center( + child: title != null + ? Text( + title!, + style: const TextStyle( + color: Colors.black, + fontSize: 17.0, + fontWeight: FontWeight.bold, + ), + ) + : null, + ), + ), + ); + } +} diff --git a/lib/components/translucent_text_field/translucent_text_field.dart b/lib/components/translucent_text_field/translucent_text_field.dart new file mode 100644 index 0000000..415a678 --- /dev/null +++ b/lib/components/translucent_text_field/translucent_text_field.dart @@ -0,0 +1,65 @@ +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_platform_widgets/flutter_platform_widgets.dart'; + +class TranslucentTextField extends StatelessWidget { + const TranslucentTextField({ + Key? key, + this.placeholder, + this.trailing, + this.keyboardType = TextInputType.text, + this.obscureText = false, + this.controller, + }) : super(key: key); + + final String? placeholder; + final Widget? trailing; + final bool obscureText; + final TextInputType keyboardType; + final TextEditingController? controller; + + @override + Widget build(BuildContext context) { + final placeholderStyle = TextStyle( + fontSize: 17.0, + color: Colors.white.withAlpha(30), + ); + + return Container( + padding: const EdgeInsets.fromLTRB(12.0, 0.0, 12.0, 0.0), + decoration: BoxDecoration( + color: Colors.white.withAlpha(10), + borderRadius: BorderRadius.circular(12.0), + ), + child: Center( + child: Row( + children: [ + Expanded( + child: PlatformTextField( + controller: controller, + style: const TextStyle(color: Colors.white), + material: (_, __) => MaterialTextFieldData( + keyboardType: keyboardType, + decoration: InputDecoration( + hintText: placeholder, + hintStyle: placeholderStyle, + border: InputBorder.none, + ), + obscureText: obscureText, + ), + cupertino: (_, __) => CupertinoTextFieldData( + keyboardType: keyboardType, + placeholder: placeholder, + placeholderStyle: placeholderStyle, + decoration: const BoxDecoration(border: Border()), + obscureText: obscureText), + ), + ), + if (trailing != null) trailing! + ], + ), + ), + ); + } +} diff --git a/lib/configs/routes.dart b/lib/configs/routes.dart index c436d67..216952b 100644 --- a/lib/configs/routes.dart +++ b/lib/configs/routes.dart @@ -4,4 +4,5 @@ final Map _routes = { LandingModule.routePath: (_) => LandingModule(), LoginModule.routePath: (_) => LoginModule(), HomeModule.routePath: (_) => HomeModule(), + ForgotPasswordModule.routePath: (_) => ForgotPasswordModule(), }; diff --git a/lib/gen/assets.gen.dart b/lib/gen/assets.gen.dart index face551..2d07bd4 100644 --- a/lib/gen/assets.gen.dart +++ b/lib/gen/assets.gen.dart @@ -27,10 +27,7 @@ class Assets { } class AssetGenImage extends AssetImage { - const AssetGenImage(String assetName) - : _assetName = assetName, - super(assetName); - final String _assetName; + const AssetGenImage(String assetName) : super(assetName); Image image({ Key? key, @@ -75,7 +72,7 @@ class AssetGenImage extends AssetImage { ); } - String get path => _assetName; + String get path => assetName; } class SvgGenImage { diff --git a/lib/gen/configs.gen.dart b/lib/gen/configs.gen.dart index 25ddf7f..77d1dfc 100644 --- a/lib/gen/configs.gen.dart +++ b/lib/gen/configs.gen.dart @@ -2,6 +2,7 @@ import 'package:survey/gen/flavors.gen.dart'; import 'package:flutter/widgets.dart'; import 'package:survey/models/auth_token_info.dart'; import 'package:survey/models/user_info.dart'; +import 'package:survey/modules/forgot_password/forgot_password_module.dart'; import 'package:survey/modules/home/home_module.dart'; import 'package:survey/modules/landing/landing_module.dart'; import 'package:survey/modules/login/login_module.dart'; diff --git a/lib/modules/forgot_password/forgot_password_module.dart b/lib/modules/forgot_password/forgot_password_module.dart new file mode 100644 index 0000000..4d327a9 --- /dev/null +++ b/lib/modules/forgot_password/forgot_password_module.dart @@ -0,0 +1,16 @@ +import 'package:flutter/widgets.dart'; +import 'package:survey/core/viper/module.dart'; +import 'package:survey/modules/screen.dart'; + +class ForgotPasswordModule extends Module { + static const routePath = "/forgot-password"; + + @override + Widget build(BuildContext context) { + return const Screen( + body: Center( + child: Text("Forgot Password"), + ), + ); + } +} diff --git a/lib/modules/landing/landing_interactor.dart b/lib/modules/landing/landing_interactor.dart index 45a5ba5..9090486 100644 --- a/lib/modules/landing/landing_interactor.dart +++ b/lib/modules/landing/landing_interactor.dart @@ -2,6 +2,7 @@ part of 'landing_module.dart'; abstract class LandingInteractor extends Interactor { void validateAuthentication(); + void logout(); } abstract class LandingInteractorDelegate { @@ -21,4 +22,9 @@ class LandingInteractorImpl extends LandingInteractor { delegate?.authenticationDidFailToValidate.add(exception); }); } + + @override + void logout() { + _authRepository.logout().then((value) => null); + } } diff --git a/lib/modules/landing/landing_module.dart b/lib/modules/landing/landing_module.dart index 4772568..aa3a4f9 100644 --- a/lib/modules/landing/landing_module.dart +++ b/lib/modules/landing/landing_module.dart @@ -8,7 +8,8 @@ import 'package:survey/modules/home/home_module.dart'; import 'package:survey/modules/login/login_module.dart'; import 'package:survey/modules/screen.dart'; import 'package:survey/core/extensions/build_context.dart'; -import 'package:survey/repositories/auth_repository.dart'; +import 'package:survey/repositories/auth/auth_repository.dart'; +import 'package:survey/services/api/api_service.dart'; import 'package:survey/services/locator/locator_service.dart'; part 'landing_presenter.dart'; diff --git a/lib/modules/landing/landing_presenter.dart b/lib/modules/landing/landing_presenter.dart index aa6951a..df2c9e9 100644 --- a/lib/modules/landing/landing_presenter.dart +++ b/lib/modules/landing/landing_presenter.dart @@ -42,8 +42,14 @@ class LandingPresenterImpl extends LandingPresenter interactor.validateAuthentication(); } - void _authenticationDidFailToValidate(Object error) { - view.alert(error); + void _authenticationDidFailToValidate(Exception exception) { + if (exception == ApiException.invalidToken) { + interactor.logout(); + router.replaceToLoginScreen(context: view.context); + return; + } + + view.alert(exception); } void _didAllFinish(bool isAuthenticated) { diff --git a/lib/modules/login/components/form.dart b/lib/modules/login/components/form.dart new file mode 100644 index 0000000..9d3fa51 --- /dev/null +++ b/lib/modules/login/components/form.dart @@ -0,0 +1,89 @@ +part of '../login_module.dart'; + +class _Form extends StatefulWidget { + @override + __FormState createState() => __FormState(); +} + +class __FormState extends State<_Form> { + _LoginViewImplState get _state => context.findAncestorStateOfType()!; + + @override + void initState() { + super.initState(); + } + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.fromLTRB(24, 0, 24, 0), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + _emailTextField(), + const SizedBox( + height: 20, + ), + _passwordTextField(), + const SizedBox( + height: 20, + ), + StreamsSelector0.value( + stream: _state._isLoginButtonEnabled, + builder: (_, isEnabled, __) { + return Button( + key: LoginView.loginButtonKey, + title: AppLocalizations.of(context)!.loginScreenLoginButtonText, + isEnabled: isEnabled, + onPressed: () => _state.delegate?.loginButtonDidTap.add([ + _state._emailController.text, + _state._passwordController.text + ]), + ); + }, + ) + ], + ), + ); + } + + Widget _emailTextField() { + return SizedBox( + height: 56.0, + child: TranslucentTextField( + key: LoginView.emailTextFieldKey, + placeholder: AppLocalizations.of(context)! + .loginScreenEmailTextFieldPlaceholderText, + keyboardType: TextInputType.emailAddress, + controller: _state._emailController, + ), + ); + } + + Widget _passwordTextField() { + return SizedBox( + height: 56.0, + child: TranslucentTextField( + key: LoginView.passwordTextFieldKey, + placeholder: AppLocalizations.of(context)! + .loginScreenPasswordTextFieldPlaceholderText, + obscureText: true, + controller: _state._passwordController, + trailing: PlatformButton( + key: LoginView.forgotButtonKey, + onPressed: () => _state.delegate?.forgotButtonDidTap.add(null), + materialFlat: (_, __) => MaterialFlatButtonData( + color: Colors.transparent, + ), + child: Text( + AppLocalizations.of(context)!.loginScreenForgotButtonText, + style: TextStyle( + color: Colors.white.withAlpha(50), + fontSize: 15.0, + ), + ), + ), + ), + ); + } +} diff --git a/lib/modules/login/login_interactor.dart b/lib/modules/login/login_interactor.dart new file mode 100644 index 0000000..befa9f6 --- /dev/null +++ b/lib/modules/login/login_interactor.dart @@ -0,0 +1,27 @@ +part of 'login_module.dart'; + +abstract class LoginInteractor extends Interactor { + void login(String email, String password); +} + +abstract class LoginInteractorDelegate { + BehaviorSubject get didLogin; + + BehaviorSubject get didFailToLogin; +} + +class LoginInteractorImpl extends LoginInteractor { + final AuthRepository _authRepository = locator.get(); + + @override + void login(String email, String password) { + Future.microtask(() async { + try { + await _authRepository.login(email: email, password: password); + delegate?.didLogin.add(null); + } on Exception catch (e) { + delegate?.didFailToLogin.add(e); + } + }); + } +} diff --git a/lib/modules/login/login_module.dart b/lib/modules/login/login_module.dart index 6fe54d5..c94ad74 100644 --- a/lib/modules/login/login_module.dart +++ b/lib/modules/login/login_module.dart @@ -1,16 +1,38 @@ -import 'package:flutter/widgets.dart'; +import 'dart:ui'; + +import 'package:flutter/cupertino.dart' hide Router; +import 'package:flutter/material.dart' hide Router; +import 'package:flutter_platform_widgets/flutter_platform_widgets.dart'; +import 'package:streams_provider/streams_provider.dart'; +import 'package:survey/components/alert/alert.dart'; +import 'package:survey/components/button/button.dart'; +import 'package:survey/components/common/progress_hud.dart'; +import 'package:survey/components/translucent_text_field/translucent_text_field.dart'; import 'package:survey/core/viper/module.dart'; +import 'package:survey/gen/assets.gen.dart'; +import 'package:survey/modules/forgot_password/forgot_password_module.dart'; +import 'package:survey/modules/home/home_module.dart'; import 'package:survey/modules/screen.dart'; +import 'package:survey/repositories/auth/auth_repository.dart'; +import 'package:survey/services/locator/locator_service.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; + +part 'login_presenter.dart'; + +part 'login_router.dart'; + +part 'login_interactor.dart'; + +part 'login_view.dart'; + +part 'components/form.dart'; -class LoginModule extends Module { +class LoginModule + extends Module { static const routePath = "/login"; @override Widget build(BuildContext context) { - return const Screen( - body: Center( - child: Text("Login"), - ), - ); + return LoginViewImpl(); } } diff --git a/lib/modules/login/login_presenter.dart b/lib/modules/login/login_presenter.dart new file mode 100644 index 0000000..fc10985 --- /dev/null +++ b/lib/modules/login/login_presenter.dart @@ -0,0 +1,71 @@ +part of 'login_module.dart'; + +abstract class LoginPresenter + extends Presenter {} + +class LoginPresenterImpl extends LoginPresenter + implements LoginViewDelegate, LoginInteractorDelegate { + LoginPresenterImpl() { + stateDidInit.voidListen(_didInitState); + forgotButtonDidTap.voidListen(_didTapForgotButton); + loginButtonDidTap.listen(_didTapLoginButton); + [emailTextFieldDidChange, passwordTextFieldDidChange] + .combineLatest() + .listen(_didChangeEmailOrPasswordText); + + didLogin.voidListen(_didLogin); + didFailToLogin.listen(_didFailToLogin); + } + + @override + final emailTextFieldDidChange = BehaviorSubject(); + + @override + final passwordTextFieldDidChange = BehaviorSubject(); + + @override + final stateDidInit = BehaviorSubject(); + + @override + final forgotButtonDidTap = BehaviorSubject(); + + @override + final loginButtonDidTap = BehaviorSubject>(); + + @override + final alertDialogDidClose = BehaviorSubject(); + + @override + final didLogin = BehaviorSubject(); + + @override + final didFailToLogin = BehaviorSubject(); + + void _didInitState() { + view.setLoginButton(isEnabled: false); + view.beginAnimation(); + } + + void _didTapForgotButton() { + router.pushToForgotPasswordScreen(view.context); + } + + void _didTapLoginButton(List data) { + view.showProgressHUD(); + interactor.login(data[0], data[1]); + } + + void _didChangeEmailOrPasswordText(List data) { + final isEnabled = data[0].isNotEmpty && data[1].isNotEmpty; + view.setLoginButton(isEnabled: isEnabled); + } + + void _didLogin() { + router.replaceToHomeScreen(view.context); + } + + void _didFailToLogin(Object error) { + view.dismissProgressHUD(); + view.alert(error); + } +} diff --git a/lib/modules/login/login_router.dart b/lib/modules/login/login_router.dart new file mode 100644 index 0000000..7208e8d --- /dev/null +++ b/lib/modules/login/login_router.dart @@ -0,0 +1,18 @@ +part of 'login_module.dart'; + +abstract class LoginRouter extends Router { + void replaceToHomeScreen(BuildContext context); + void pushToForgotPasswordScreen(BuildContext context); +} + +class LoginRouterImpl extends LoginRouter { + @override + void replaceToHomeScreen(BuildContext context) { + context.navigator.pushReplacementNamed(HomeModule.routePath); + } + + @override + void pushToForgotPasswordScreen(BuildContext context) { + context.navigator.pushNamed(ForgotPasswordModule.routePath); + } +} diff --git a/lib/modules/login/login_view.dart b/lib/modules/login/login_view.dart new file mode 100644 index 0000000..458e70e --- /dev/null +++ b/lib/modules/login/login_view.dart @@ -0,0 +1,151 @@ +part of 'login_module.dart'; + +abstract class LoginView extends View + with AlertViewMixin, ProgressHUDViewMixin { + static const backgroundImageKey = Key("background_image"); + static const logoImageKey = Key("logo_image"); + static const loginButtonKey = Key("login_button"); + static const forgotButtonKey = Key("forgot_button"); + static const emailTextFieldKey = Key("email_text_field"); + static const passwordTextFieldKey = Key("password_text_field"); + static const opacityFormKey = Key("opacity_form"); + static const progressHUDKey = Key("progress_hud_key"); + + static const animationDuration = Duration(seconds: 1); + + void beginAnimation(); + + void setLoginButton({required bool isEnabled}); +} + +abstract class LoginViewDelegate implements AlertViewMixinDelegate { + BehaviorSubject get forgotButtonDidTap; + + BehaviorSubject get stateDidInit; + + BehaviorSubject get emailTextFieldDidChange; + + BehaviorSubject get passwordTextFieldDidChange; + + BehaviorSubject> get loginButtonDidTap; +} + +class LoginViewImpl extends StatefulWidget { + @override + _LoginViewImplState createState() => _LoginViewImplState(); +} + +class _LoginViewImplState + extends ViewState + with TickerProviderStateMixin, AlertViewMixin, ProgressHUDViewMixin + implements LoginView { + final _emailController = TextEditingController(); + final _passwordController = TextEditingController(); + final _isLoginButtonEnabled = BehaviorSubject.seeded(false); + + late final AnimationController _animationController = AnimationController( + duration: LoginView.animationDuration, + vsync: this, + ); + + late final Animation _animation = CurvedAnimation( + parent: _animationController, + curve: Curves.easeIn, + ); + + @override + void initState() { + super.initState(); + _emailController.addListener(() { + delegate?.emailTextFieldDidChange.add(_emailController.text); + }); + + _passwordController.addListener(() { + delegate?.passwordTextFieldDidChange.add(_passwordController.text); + }); + delegate?.stateDidInit.add(null); + } + + @override + Widget build(BuildContext context) { + return StreamsSelector0.value( + stream: isProgressHUDShown, + builder: (_, isShow, child) { + return ProgressHUD( + key: LoginView.progressHUDKey, + isShow: isShow, + child: child!, + ); + }, + child: Screen( + body: Stack( + fit: StackFit.expand, + children: [ + AnimatedBuilder( + builder: (context, child) { + return ImageFiltered( + imageFilter: ImageFilter.blur( + sigmaX: 50.0 * _animation.value, + sigmaY: 50.0 * _animation.value, + tileMode: TileMode.mirror, + ), + child: Assets.images.mainBackgroundDimmed.image( + key: LoginView.backgroundImageKey, fit: BoxFit.fill), + ); + }, + animation: _animationController, + ), + Container( + padding: const EdgeInsets.fromLTRB(24, 0, 24, 0), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + AnimatedBuilder( + animation: _animationController, + builder: (_, __) { + return Opacity( + key: LoginView.opacityFormKey, + opacity: _animation.value, + child: _Form(), + ); + }) + ], + ), + ), + AnimatedBuilder( + animation: _animationController, + builder: (_, __) => Positioned( + left: 0.0, + right: 0.0, + bottom: 0.0, + top: -450 * _animation.value, + child: Assets.images.logoWhite.svg( + key: LoginView.logoImageKey, + fit: BoxFit.none, + ), + ), + ) + ], + ), + ), + ); + } + + @override + void dispose() { + _animationController.dispose(); + _emailController.dispose(); + _passwordController.dispose(); + super.dispose(); + } + + @override + void beginAnimation() { + _animationController.forward(); + } + + @override + void setLoginButton({required bool isEnabled}) { + _isLoginButtonEnabled.add(isEnabled); + } +} diff --git a/lib/repositories/auth/auth_refresh_token_interceptor.dart b/lib/repositories/auth/auth_refresh_token_interceptor.dart new file mode 100644 index 0000000..a40f14b --- /dev/null +++ b/lib/repositories/auth/auth_refresh_token_interceptor.dart @@ -0,0 +1,25 @@ +part of 'auth_repository.dart'; + +class AuthRefreshTokenInterceptor extends HttpInterceptor { + final AuthRepository _authRepository = locator.get(); + + @override + final identifier = "auth_refresh_token"; + + @override + Future onException( + HttpException exception, HttpExceptionInterceptorHandler handler) async { + final apiException = ApiException.fromHttpException(exception); + if (apiException == null || apiException != ApiException.invalidToken) { + return handler.next(exception); + } + + try { + await _authRepository.refreshToken(); + } on Exception { + return handler.next(exception); + } + + return handler.retry(exception); + } +} diff --git a/lib/repositories/auth_repository.dart b/lib/repositories/auth/auth_repository.dart similarity index 79% rename from lib/repositories/auth_repository.dart rename to lib/repositories/auth/auth_repository.dart index 00b5e43..8cb7bd9 100644 --- a/lib/repositories/auth_repository.dart +++ b/lib/repositories/auth/auth_repository.dart @@ -2,10 +2,13 @@ import 'package:survey/models/auth_token_info.dart'; import 'package:survey/services/api/api_service.dart'; import 'package:survey/services/api/auth/auth_api_service.dart'; import 'package:survey/services/api/user/user_api_service.dart'; +import 'package:survey/services/http/http_service.dart'; import 'package:survey/services/local_storage/local_storage_service.dart'; import 'package:survey/services/locator/locator_service.dart'; import 'package:survey/models/user_info.dart'; +part 'auth_refresh_token_interceptor.dart'; + abstract class AuthRepository { static const tokenLocalStorageKey = "auth_repository_token"; @@ -27,6 +30,8 @@ abstract class AuthRepository { Future fetchUser(); Future attemptAndFetchUser(); + + Future refreshToken(); } class AuthRepositoryImpl implements AuthRepository { @@ -83,6 +88,7 @@ class AuthRepositoryImpl implements AuthRepository { } _accessToken = token.accessToken; + _apiService.addGlobalInterceptors([AuthRefreshTokenInterceptor()]); _apiService.configureGlobalToken(_accessToken, token.tokenType); } @@ -99,4 +105,18 @@ class AuthRepositoryImpl implements AuthRepository { await attempt(); await fetchUser(); } + + @override + Future refreshToken() async { + final oldToken = await _localStorageService + .getObject(AuthRepository.tokenLocalStorageKey); + + final params = + AuthRefreshTokenParams(refreshToken: oldToken!.refreshToken!); + final token = await _authApiService.refreshToken(params: params); + + await _localStorageService.setObject(token, + key: AuthRepository.tokenLocalStorageKey); + await attemptAndFetchUser(); + } } diff --git a/lib/services/api/api_exception.dart b/lib/services/api/api_exception.dart index b2b7c14..04ece69 100644 --- a/lib/services/api/api_exception.dart +++ b/lib/services/api/api_exception.dart @@ -1,6 +1,6 @@ part of 'api_service.dart'; -class ApiException implements LocalizedException { +class ApiException extends Equatable implements LocalizedException { const ApiException({ required this.source, required this.message, @@ -26,13 +26,23 @@ class ApiException implements LocalizedException { } final String? source; + final String code; + @override final String message; - final String code; + + @override + List get props => [source, code]; static const invalidResponseStructure = ApiException( source: "local", message: "Wrong response structure", code: "wrong_response_structure", ); + + static const invalidToken = ApiException( + source: "unauthorized", + message: "The access token is invalid", + code: "invalid_token", + ); } diff --git a/lib/services/api/api_service.dart b/lib/services/api/api_service.dart index ca369a2..a3c5bc7 100644 --- a/lib/services/api/api_service.dart +++ b/lib/services/api/api_service.dart @@ -1,3 +1,4 @@ +import 'package:equatable/equatable.dart'; import 'package:survey/core/classes/localized_exception.dart'; import 'package:survey/gen/configs.gen.dart'; import 'package:survey/services/http/http_service.dart'; @@ -36,12 +37,16 @@ abstract class ApiService { void configureGlobalBaseUrl(String? baseUrl); void configureGlobalToken(String? token, String? tokenType); + + void addGlobalInterceptors(List interceptor); } class ApiServiceImpl implements ApiService { static String? _baseUrl; static String? _token; static String _tokenType = "Bearer"; + static final List _interceptor = []; + final HttpService _httpService = locator.get(); @override @@ -111,6 +116,14 @@ class ApiServiceImpl implements ApiService { } } + @override + void addGlobalInterceptors(List interceptor) { + final identifiers = interceptor.map((e) => e.identifier); + _interceptor + .removeWhere((element) => identifiers.contains(element.identifier)); + _interceptor.addAll(interceptor); + } + Future> _request({ required HttpMethod method, String? baseUrl, @@ -139,6 +152,7 @@ class ApiServiceImpl implements ApiService { data: params?.toJson(), url: url, headers: headers, + interceptors: _interceptor, ) as Map; } on HttpException catch (e) { throw ApiException.fromHttpException(e) ?? e; diff --git a/lib/services/api/auth/auth_api_service.dart b/lib/services/api/auth/auth_api_service.dart index af5b605..12eeb28 100644 --- a/lib/services/api/auth/auth_api_service.dart +++ b/lib/services/api/auth/auth_api_service.dart @@ -7,16 +7,20 @@ import 'package:survey/services/http/http_service.dart'; part 'params/auth_login_params.dart'; +part 'params/auth_refresh_token_params.dart'; + abstract class AuthApiService { static const loginEndpoint = "/oauth/token"; static const logoutEndpoint = "/oauth/revoke"; + static const refreshTokenEndpoint = loginEndpoint; + static const preferenceTokenKey = "auth_service_preference_token"; - Future login({ - required AuthLoginParams params, - }); + Future login({required AuthLoginParams params}); Future logout(); + + Future refreshToken({required AuthRefreshTokenParams params}); } class AuthApiServiceImpl implements AuthApiService { @@ -40,4 +44,13 @@ class AuthApiServiceImpl implements AuthApiService { endpoint: AuthApiService.logoutEndpoint, ); } + + @override + Future refreshToken({required AuthRefreshTokenParams params}) { + return _apiService.call( + method: HttpMethod.post, + endpoint: AuthApiService.refreshTokenEndpoint, + params: params, + ); + } } diff --git a/lib/services/api/auth/params/auth_refresh_token_params.dart b/lib/services/api/auth/params/auth_refresh_token_params.dart new file mode 100644 index 0000000..d4bdb06 --- /dev/null +++ b/lib/services/api/auth/params/auth_refresh_token_params.dart @@ -0,0 +1,36 @@ +part of '../auth_api_service.dart'; + +class AuthRefreshTokenParams extends ApiParams { + factory AuthRefreshTokenParams({ + required String refreshToken, + String? clientId, + String? clientSecret, + }) { + return AuthRefreshTokenParams._( + refreshToken: refreshToken, + clientId: clientId ?? Configs.app.api.clientId, + clientSecret: clientSecret ?? Configs.app.api.clientSecret, + ); + } + + AuthRefreshTokenParams._({ + required this.refreshToken, + required this.clientId, + required this.clientSecret, + }); + + String refreshToken; + String clientId; + String clientSecret; + String grantType = "refresh_token"; + + @override + void mapping(Mapper map) { + map( + "refresh_token", refreshToken, (v) => refreshToken = v as String); + map("client_id", clientId, (v) => clientId = v as String); + map( + "client_secret", clientSecret, (v) => clientSecret = v as String); + map("grant_type", grantType, (v) => grantType = v as String); + } +} diff --git a/lib/services/http/http_exception.dart b/lib/services/http/http_exception.dart index c2931c9..cc1cd61 100644 --- a/lib/services/http/http_exception.dart +++ b/lib/services/http/http_exception.dart @@ -58,7 +58,7 @@ class HttpException implements Exception { return HttpException( response: response, type: type, - error: error.error, + error: error, ); } diff --git a/lib/services/http/http_interceptor.dart b/lib/services/http/http_interceptor.dart new file mode 100644 index 0000000..d4d6f5c --- /dev/null +++ b/lib/services/http/http_interceptor.dart @@ -0,0 +1,51 @@ +part of 'http_service.dart'; + +abstract class HttpInterceptor { + String get identifier; + + void onException( + HttpException exception, + HttpExceptionInterceptorHandler handler, + ) => + handler.next(exception); + + Interceptor toInterceptor(Dio dio) { + return InterceptorsWrapper(onError: (e, handler) { + onException( + HttpException.fromDioError(e), + HttpExceptionInterceptorHandler._(dio: dio, handler: handler), + ); + }); + } +} + +class HttpExceptionInterceptorHandler { + const HttpExceptionInterceptorHandler._({ + required Dio dio, + required ErrorInterceptorHandler handler, + }) : _dio = dio, + _handler = handler; + + final ErrorInterceptorHandler _handler; + final Dio _dio; + + void next(HttpException exception) { + _handler.next(exception.error as DioError); + } + + void reject(HttpException exception) { + _handler.reject(exception.error as DioError); + } + + Future retry(HttpException exception) { + final requestOptions = (exception.error as DioError).requestOptions; + + return _dio.request(requestOptions.path, + cancelToken: requestOptions.cancelToken, + data: requestOptions.data, + onReceiveProgress: requestOptions.onReceiveProgress, + onSendProgress: requestOptions.onSendProgress, + queryParameters: requestOptions.queryParameters, + options: requestOptions as Options); + } +} diff --git a/lib/services/http/http_service.dart b/lib/services/http/http_service.dart index e1f1450..8ea5e73 100644 --- a/lib/services/http/http_service.dart +++ b/lib/services/http/http_service.dart @@ -3,15 +3,20 @@ import 'package:enumerated_class/enumerated_class.dart'; import 'package:flutter/foundation.dart'; part 'http_method.dart'; + part 'http_exception.dart'; + part 'http_response.dart'; +part 'http_interceptor.dart'; + abstract class HttpService { Future request({ required HttpMethod method, dynamic data, required String url, Map? headers, + List interceptors = const [], }); } @@ -33,7 +38,12 @@ class HttpServiceImpl implements HttpService { dynamic data, required String url, Map? headers, + List interceptors = const [], }) async { + _dio.interceptors.addAll(interceptors.map( + (e) => e.toInterceptor(_dio), + )); + final options = Options(method: method.rawValue, headers: headers); try { final response = await _dio.request(url, diff --git a/lib/services/locator/locator_service.dart b/lib/services/locator/locator_service.dart index c5f3a8c..5cbae91 100644 --- a/lib/services/locator/locator_service.dart +++ b/lib/services/locator/locator_service.dart @@ -1,9 +1,10 @@ import 'package:get_it/get_it.dart'; import 'package:flutter/cupertino.dart'; import 'package:survey/modules/landing/landing_module.dart'; +import 'package:survey/modules/login/login_module.dart'; import 'package:survey/services/api/api_service.dart'; import 'package:survey/services/api/auth/auth_api_service.dart'; -import 'package:survey/repositories/auth_repository.dart'; +import 'package:survey/repositories/auth/auth_repository.dart'; import 'package:survey/services/local_storage/local_storage_service.dart'; import 'package:survey/services/http/http_service.dart'; import 'package:survey/services/api/user/user_api_service.dart'; diff --git a/lib/services/locator/locator_service_register.dart b/lib/services/locator/locator_service_register.dart index 96cbcf9..34b7c24 100644 --- a/lib/services/locator/locator_service_register.dart +++ b/lib/services/locator/locator_service_register.dart @@ -17,5 +17,10 @@ class LocatorServiceRegister { locator.registerFactory(() => LandingInteractorImpl()); locator.registerFactory(() => LandingRouterImpl()); locator.registerFactory(() => LandingPresenterImpl()); + + // Login + locator.registerFactory(() => LoginInteractorImpl()); + locator.registerFactory(() => LoginRouterImpl()); + locator.registerFactory(() => LoginPresenterImpl()); } } diff --git a/pubspec.lock b/pubspec.lock index 7ed8a7c..2ed4b3b 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -49,7 +49,7 @@ packages: name: async url: "https://pub.dartlang.org" source: hosted - version: "2.5.0" + version: "2.6.1" boolean_selector: dependency: transitive description: @@ -218,6 +218,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "0.0.1+1" + equatable: + dependency: "direct main" + description: + name: equatable + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.0" fake_async: dependency: transitive description: @@ -655,7 +662,7 @@ packages: name: source_span url: "https://pub.dartlang.org" source: hosted - version: "1.8.0" + version: "1.8.1" stack_trace: dependency: transitive description: @@ -704,7 +711,7 @@ packages: name: test_api url: "https://pub.dartlang.org" source: hosted - version: "0.2.19" + version: "0.3.0" time: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index e233774..f687039 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -40,6 +40,7 @@ dependencies: shared_preferences: ^2.0.5 modal_progress_hud_nsn: ^0.1.0-nullsafety-1 adaptive_dialog: ^0.10.0+5 + equatable: ^2.0.0 dev_dependencies: flutter_test: diff --git a/test/modules/landing/landing_interactor_test.dart b/test/modules/landing/landing_interactor_test.dart index b712a55..200ec60 100644 --- a/test/modules/landing/landing_interactor_test.dart +++ b/test/modules/landing/landing_interactor_test.dart @@ -3,7 +3,7 @@ import 'package:mockito/annotations.dart'; import 'package:mockito/mockito.dart'; import 'package:quick_test/quick_test.dart'; import 'package:survey/modules/landing/landing_module.dart'; -import 'package:survey/repositories/auth_repository.dart'; +import 'package:survey/repositories/auth/auth_repository.dart'; import 'package:survey/services/locator/locator_service.dart'; import '../../helpers/behavior_subject_generator.dart'; @@ -62,5 +62,17 @@ void main() { }); }); }); + + describe("its logout()", () { + beforeEach(() { + when(authRepository.logout()) + .thenAnswer((realInvocation) => Future.value(null)); + interactor.logout(); + }); + + it("triggers authRepository call logout()", () { + verify(authRepository.logout()).called(1); + }); + }); }); } diff --git a/test/modules/landing/landing_interactor_test.mocks.dart b/test/modules/landing/landing_interactor_test.mocks.dart index cd1e993..cb48a65 100644 --- a/test/modules/landing/landing_interactor_test.mocks.dart +++ b/test/modules/landing/landing_interactor_test.mocks.dart @@ -7,7 +7,7 @@ import 'dart:async' as _i4; import 'package:mockito/mockito.dart' as _i1; import 'package:rxdart/src/subjects/behavior_subject.dart' as _i2; import 'package:survey/modules/landing/landing_module.dart' as _i5; -import 'package:survey/repositories/auth_repository.dart' as _i3; +import 'package:survey/repositories/auth/auth_repository.dart' as _i3; // ignore_for_file: comment_references // ignore_for_file: unnecessary_parenthesis @@ -57,6 +57,11 @@ class MockAuthRepository extends _i1.Mock implements _i3.AuthRepository { (super.noSuchMethod(Invocation.method(#attemptAndFetchUser, []), returnValue: Future.value(null), returnValueForMissingStub: Future.value()) as _i4.Future); + @override + _i4.Future refreshToken() => + (super.noSuchMethod(Invocation.method(#refreshToken, []), + returnValue: Future.value(null), + returnValueForMissingStub: Future.value()) as _i4.Future); } /// A class which mocks [LandingInteractorDelegate]. diff --git a/test/modules/landing/landing_module_test.mocks.dart b/test/modules/landing/landing_module_test.mocks.dart index 9786c77..ad29b0a 100644 --- a/test/modules/landing/landing_module_test.mocks.dart +++ b/test/modules/landing/landing_module_test.mocks.dart @@ -47,4 +47,7 @@ class MockLandingInteractor extends _i1.Mock implements _i2.LandingInteractor { void validateAuthentication() => super.noSuchMethod(Invocation.method(#validateAuthentication, []), returnValueForMissingStub: null); + @override + void logout() => super.noSuchMethod(Invocation.method(#logout, []), + returnValueForMissingStub: null); } diff --git a/test/modules/landing/landing_presenter_test.dart b/test/modules/landing/landing_presenter_test.dart index cfbc5ff..dd11d4b 100644 --- a/test/modules/landing/landing_presenter_test.dart +++ b/test/modules/landing/landing_presenter_test.dart @@ -2,6 +2,7 @@ import 'package:mockito/annotations.dart'; import 'package:mockito/mockito.dart'; import 'package:quick_test/quick_test.dart'; import 'package:survey/modules/landing/landing_module.dart'; +import 'package:survey/services/api/api_service.dart'; import '../../mocks/build_context.dart'; import 'landing_presenter_test.mocks.dart'; @@ -54,12 +55,29 @@ void main() { }); describe("it receives didFailToValidateAuthentication event", () { - beforeEach(() { - presenter.authenticationDidFailToValidate.add(Exception()); + context("when exception is not InvalidToken", () { + beforeEach(() { + presenter.authenticationDidFailToValidate.add(Exception()); + }); + + it("triggers view to show alert dialog", () { + verify(view.alert(any)).called(1); + }); }); - it("triggers view to show alert dialog", () { - verify(view.alert(any)).called(1); + context("when exception is InvalidToken", () { + beforeEach(() { + presenter.authenticationDidFailToValidate + .add(ApiException.invalidToken); + }); + + it("triggers interactor to logout", () { + verify(interactor.logout()).called(1); + }); + + it("triggers router to replace to Login screen", () { + verify(router.replaceToLoginScreen(context: buildContext)).called(1); + }); }); }); diff --git a/test/modules/landing/landing_presenter_test.mocks.dart b/test/modules/landing/landing_presenter_test.mocks.dart index e857426..f1c550c 100644 --- a/test/modules/landing/landing_presenter_test.mocks.dart +++ b/test/modules/landing/landing_presenter_test.mocks.dart @@ -75,4 +75,7 @@ class MockLandingInteractor extends _i1.Mock implements _i3.LandingInteractor { void validateAuthentication() => super.noSuchMethod(Invocation.method(#validateAuthentication, []), returnValueForMissingStub: null); + @override + void logout() => super.noSuchMethod(Invocation.method(#logout, []), + returnValueForMissingStub: null); } diff --git a/test/modules/login/login_interactor_test.dart b/test/modules/login/login_interactor_test.dart new file mode 100644 index 0000000..99c8382 --- /dev/null +++ b/test/modules/login/login_interactor_test.dart @@ -0,0 +1,63 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/annotations.dart'; +import 'package:mockito/mockito.dart'; +import 'package:quick_test/quick_test.dart'; +import 'package:survey/modules/login/login_module.dart'; +import 'package:survey/repositories/auth/auth_repository.dart'; +import 'package:survey/services/locator/locator_service.dart'; + +import '../../helpers/behavior_subject_generator.dart'; +import 'login_interactor_test.mocks.dart'; + +@GenerateMocks([AuthRepository, LoginInteractorDelegate]) +void main() { + describe("a Login interactor", () { + late MockAuthRepository authRepository; + late LoginInteractor interactor; + late MockLoginInteractorDelegate delegate; + late BehaviorSubjectGenerator generator; + + beforeEach(() { + authRepository = MockAuthRepository(); + locator.registerFactory(() => authRepository); + + delegate = MockLoginInteractorDelegate(); + generator = BehaviorSubjectGenerator(); + when(delegate.didLogin).thenAnswer((realInvocation) => generator.make(1)); + when(delegate.didFailToLogin) + .thenAnswer((realInvocation) => generator.make(2)); + + interactor = LoginInteractorImpl(); + interactor.delegate = delegate; + }); + + describe("its login() is called", () { + context("when success", () { + beforeEach(() { + when(authRepository.login( + email: anyNamed("email"), password: anyNamed("password"))) + .thenAnswer((realInvocation) => Future.value(null)); + interactor.login("email", "password"); + }); + + it("triggers delegate's didLogin emits", () { + expect(delegate.didLogin, emits(null)); + }); + }); + + context("when failure", () { + final error = Exception(); + beforeEach(() { + when(authRepository.login( + email: anyNamed("email"), password: anyNamed("password"))) + .thenAnswer((realInvocation) => Future.error(error)); + interactor.login("email", "password"); + }); + + it("triggers delegate's didFailToLogin emits", () { + expect(delegate.didFailToLogin, emits(error)); + }); + }); + }); + }); +} diff --git a/test/modules/login/login_interactor_test.mocks.dart b/test/modules/login/login_interactor_test.mocks.dart new file mode 100644 index 0000000..4fc5979 --- /dev/null +++ b/test/modules/login/login_interactor_test.mocks.dart @@ -0,0 +1,85 @@ +// Mocks generated by Mockito 5.0.7 from annotations +// in survey/test/modules/login/login_interactor_test.dart. +// Do not manually edit this file. + +import 'dart:async' as _i4; + +import 'package:mockito/mockito.dart' as _i1; +import 'package:rxdart/src/subjects/behavior_subject.dart' as _i2; +import 'package:survey/modules/login/login_module.dart' as _i5; +import 'package:survey/repositories/auth/auth_repository.dart' as _i3; + +// ignore_for_file: comment_references +// ignore_for_file: unnecessary_parenthesis + +// ignore_for_file: prefer_const_constructors + +// ignore_for_file: avoid_redundant_argument_values + +class _FakeBehaviorSubject extends _i1.Fake + implements _i2.BehaviorSubject {} + +/// A class which mocks [AuthRepository]. +/// +/// See the documentation for Mockito's code generation for more information. +class MockAuthRepository extends _i1.Mock implements _i3.AuthRepository { + MockAuthRepository() { + _i1.throwOnMissingStub(this); + } + + @override + bool get isAuthenticated => (super + .noSuchMethod(Invocation.getter(#isAuthenticated), returnValue: false) + as bool); + @override + _i4.Future login({String? email, String? password}) => + (super.noSuchMethod( + Invocation.method(#login, [], {#email: email, #password: password}), + returnValue: Future.value(null), + returnValueForMissingStub: Future.value()) as _i4.Future); + @override + _i4.Future logout() => + (super.noSuchMethod(Invocation.method(#logout, []), + returnValue: Future.value(null), + returnValueForMissingStub: Future.value()) as _i4.Future); + @override + _i4.Future attempt() => + (super.noSuchMethod(Invocation.method(#attempt, []), + returnValue: Future.value(null), + returnValueForMissingStub: Future.value()) as _i4.Future); + @override + _i4.Future fetchUser() => + (super.noSuchMethod(Invocation.method(#fetchUser, []), + returnValue: Future.value(null), + returnValueForMissingStub: Future.value()) as _i4.Future); + @override + _i4.Future attemptAndFetchUser() => + (super.noSuchMethod(Invocation.method(#attemptAndFetchUser, []), + returnValue: Future.value(null), + returnValueForMissingStub: Future.value()) as _i4.Future); + @override + _i4.Future refreshToken() => + (super.noSuchMethod(Invocation.method(#refreshToken, []), + returnValue: Future.value(null), + returnValueForMissingStub: Future.value()) as _i4.Future); +} + +/// A class which mocks [LoginInteractorDelegate]. +/// +/// See the documentation for Mockito's code generation for more information. +class MockLoginInteractorDelegate extends _i1.Mock + implements _i5.LoginInteractorDelegate { + MockLoginInteractorDelegate() { + _i1.throwOnMissingStub(this); + } + + @override + _i2.BehaviorSubject get didLogin => (super.noSuchMethod( + Invocation.getter(#didLogin), + returnValue: _FakeBehaviorSubject()) as _i2.BehaviorSubject); + @override + _i2.BehaviorSubject get didFailToLogin => + (super.noSuchMethod(Invocation.getter(#didFailToLogin), + returnValue: _FakeBehaviorSubject()) + as _i2.BehaviorSubject); +} diff --git a/test/modules/login/login_module_test.dart b/test/modules/login/login_module_test.dart new file mode 100644 index 0000000..46be578 --- /dev/null +++ b/test/modules/login/login_module_test.dart @@ -0,0 +1,28 @@ +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:quick_test/quick_test.dart'; +import 'package:survey/modules/login/login_module.dart'; + +import '../../mocks/build_context.dart'; + +void main() { + describe("a Login module", () { + late LoginModule module; + + beforeEach(() { + module = LoginModule(); + }); + + context("its build() is called", () { + late Widget widget; + + beforeEach(() { + widget = module.build(MockBuildContext()); + }); + + it("returns LoginViewImpl", () { + expect(widget, isA()); + }); + }); + }); +} diff --git a/test/modules/login/login_presenter_test.dart b/test/modules/login/login_presenter_test.dart new file mode 100644 index 0000000..bb9d282 --- /dev/null +++ b/test/modules/login/login_presenter_test.dart @@ -0,0 +1,109 @@ +import 'package:mockito/annotations.dart'; +import 'package:mockito/mockito.dart'; +import 'package:quick_test/quick_test.dart'; +import 'package:survey/modules/login/login_module.dart'; + +import '../../mocks/build_context.dart'; +import 'login_presenter_test.mocks.dart'; + +@GenerateMocks([LoginView, LoginInteractor, LoginRouter]) +void main() { + describe("a Login presenter", () { + late LoginPresenterImpl presenter; + late MockLoginInteractor interactor; + late MockLoginRouter router; + late MockLoginView view; + + beforeEach(() { + interactor = MockLoginInteractor(); + router = MockLoginRouter(); + + view = MockLoginView(); + when(view.context).thenReturn(MockBuildContext()); + + presenter = LoginPresenterImpl(); + presenter.configure(view: view, interactor: interactor, router: router); + }); + + describe("its didChangeEmailText and didChangePasswordText emit", () { + context("when one of them is emptied", () { + beforeEach(() { + presenter.passwordTextFieldDidChange.add(""); + presenter.emailTextFieldDidChange.add("email"); + }); + + it("triggers view to disable login button", () { + verify(view.setLoginButton(isEnabled: false)).called(1); + }); + }); + + context("when both of them are not emptied", () { + beforeEach(() { + presenter.passwordTextFieldDidChange.add("password"); + presenter.emailTextFieldDidChange.add("email"); + }); + + it("triggers view to enable login button", () { + verify(view.setLoginButton(isEnabled: true)).called(1); + }); + }); + }); + + describe("its didInitState emits", () { + beforeEach(() { + presenter.stateDidInit.add(null); + }); + + it("triggers view to disable login button and begin animation", () { + verify(view.setLoginButton(isEnabled: false)).called(1); + verify(view.beginAnimation()).called(1); + }); + }); + + describe("its didTapForgotButton emits", () { + beforeEach(() { + presenter.forgotButtonDidTap.add(null); + }); + + it("triggers router to push to Forgot Password screen", () { + verify(router.pushToForgotPasswordScreen(any)).called(1); + }); + }); + + describe("its didTapLoginButton emits", () { + beforeEach(() { + presenter.loginButtonDidTap.add(["", ""]); + }); + + it("triggers view to show progress HUD", () { + verify(view.showProgressHUD()).called(1); + }); + + it("triggers interactor to login", () { + verify(interactor.login("", "")).called(1); + }); + }); + + describe("its didLogin emits", () { + beforeEach(() { + presenter.didLogin.add(null); + }); + + it("triggers router to replace to Home screen", () { + verify(router.replaceToHomeScreen(any)).called(1); + }); + }); + + describe("its didFailToLogin emits", () { + final error = Exception(); + beforeEach(() { + presenter.didFailToLogin.add(error); + }); + + it("triggers view to dismiss progress hud and alert error", () { + verify(view.dismissProgressHUD()).called(1); + verify(view.alert(error)).called(1); + }); + }); + }); +} diff --git a/test/modules/login/login_presenter_test.mocks.dart b/test/modules/login/login_presenter_test.mocks.dart new file mode 100644 index 0000000..3336380 --- /dev/null +++ b/test/modules/login/login_presenter_test.mocks.dart @@ -0,0 +1,98 @@ +// Mocks generated by Mockito 5.0.7 from annotations +// in survey/test/modules/login/login_presenter_test.dart. +// Do not manually edit this file. + +import 'package:flutter/src/widgets/framework.dart' as _i3; +import 'package:mockito/mockito.dart' as _i1; +import 'package:rxdart/src/subjects/behavior_subject.dart' as _i2; +import 'package:survey/modules/login/login_module.dart' as _i4; + +// ignore_for_file: comment_references +// ignore_for_file: unnecessary_parenthesis + +// ignore_for_file: prefer_const_constructors + +// ignore_for_file: avoid_redundant_argument_values + +class _FakeBehaviorSubject extends _i1.Fake + implements _i2.BehaviorSubject {} + +class _FakeBuildContext extends _i1.Fake implements _i3.BuildContext {} + +/// A class which mocks [LoginView]. +/// +/// See the documentation for Mockito's code generation for more information. +class MockLoginView extends _i1.Mock implements _i4.LoginView { + MockLoginView() { + _i1.throwOnMissingStub(this); + } + + @override + _i2.BehaviorSubject get isProgressHUDShown => (super.noSuchMethod( + Invocation.getter(#isProgressHUDShown), + returnValue: _FakeBehaviorSubject()) as _i2.BehaviorSubject); + @override + set delegate(_i4.LoginViewDelegate? _delegate) => + super.noSuchMethod(Invocation.setter(#delegate, _delegate), + returnValueForMissingStub: null); + @override + _i3.BuildContext get context => + (super.noSuchMethod(Invocation.getter(#context), + returnValue: _FakeBuildContext()) as _i3.BuildContext); + @override + void beginAnimation() => + super.noSuchMethod(Invocation.method(#beginAnimation, []), + returnValueForMissingStub: null); + @override + void setLoginButton({bool? isEnabled}) => super.noSuchMethod( + Invocation.method(#setLoginButton, [], {#isEnabled: isEnabled}), + returnValueForMissingStub: null); + @override + void alert(Object? error) => + super.noSuchMethod(Invocation.method(#alert, [error]), + returnValueForMissingStub: null); + @override + void showProgressHUD() => + super.noSuchMethod(Invocation.method(#showProgressHUD, []), + returnValueForMissingStub: null); + @override + void dismissProgressHUD() => + super.noSuchMethod(Invocation.method(#dismissProgressHUD, []), + returnValueForMissingStub: null); +} + +/// A class which mocks [LoginInteractor]. +/// +/// See the documentation for Mockito's code generation for more information. +class MockLoginInteractor extends _i1.Mock implements _i4.LoginInteractor { + MockLoginInteractor() { + _i1.throwOnMissingStub(this); + } + + @override + set delegate(_i4.LoginInteractorDelegate? _delegate) => + super.noSuchMethod(Invocation.setter(#delegate, _delegate), + returnValueForMissingStub: null); + @override + void login(String? email, String? password) => + super.noSuchMethod(Invocation.method(#login, [email, password]), + returnValueForMissingStub: null); +} + +/// A class which mocks [LoginRouter]. +/// +/// See the documentation for Mockito's code generation for more information. +class MockLoginRouter extends _i1.Mock implements _i4.LoginRouter { + MockLoginRouter() { + _i1.throwOnMissingStub(this); + } + + @override + void replaceToHomeScreen(_i3.BuildContext? context) => + super.noSuchMethod(Invocation.method(#replaceToHomeScreen, [context]), + returnValueForMissingStub: null); + @override + void pushToForgotPasswordScreen(_i3.BuildContext? context) => super + .noSuchMethod(Invocation.method(#pushToForgotPasswordScreen, [context]), + returnValueForMissingStub: null); +} diff --git a/test/modules/login/login_router_test.dart b/test/modules/login/login_router_test.dart new file mode 100644 index 0000000..361b961 --- /dev/null +++ b/test/modules/login/login_router_test.dart @@ -0,0 +1,50 @@ +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/mockito.dart'; +import 'package:quick_test/quick_test.dart'; +import 'package:survey/modules/forgot_password/forgot_password_module.dart'; +import 'package:survey/modules/home/home_module.dart'; +import 'package:survey/modules/login/login_module.dart'; + +import '../../mocks/build_context.dart'; +import '../../mocks/navigator_state.dart'; + +void main() { + describe("a Login router", () { + late LoginRouter router; + late MockBuildContext buildContext; + late MockNavigatorState navigatorState; + + beforeEach(() { + buildContext = MockBuildContext(); + navigatorState = MockNavigatorState(); + when(buildContext.findAncestorStateOfType()) + .thenReturn(navigatorState); + when(navigatorState.pushReplacementNamed(any)) + .thenAnswer((_) => Future.value()); + when(navigatorState.pushNamed(any)).thenAnswer((_) => Future.value()); + + router = LoginRouterImpl(); + }); + + describe("its replaceToHomeScreen() is called", () { + beforeEach(() { + router.replaceToHomeScreen(buildContext); + }); + + it("triggers navigator to push replacement to Home screen", () { + verify(navigatorState.pushReplacementNamed(HomeModule.routePath)); + }); + }); + + describe("its pushToForgotPasswordScreen() is called", () { + beforeEach(() { + router.pushToForgotPasswordScreen(buildContext); + }); + + it("triggers navigator to push to Forgot Password screen", () { + verify(navigatorState.pushNamed(ForgotPasswordModule.routePath)); + }); + }); + }); +} diff --git a/test/modules/login/login_view_test.dart b/test/modules/login/login_view_test.dart new file mode 100644 index 0000000..89aace9 --- /dev/null +++ b/test/modules/login/login_view_test.dart @@ -0,0 +1,203 @@ +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/annotations.dart'; +import 'package:mockito/mockito.dart'; +import 'package:quick_test/quick_widget_test.dart'; +import 'package:survey/components/button/button.dart'; +import 'package:survey/components/common/progress_hud.dart'; +import 'package:survey/core/viper/module.dart'; +import 'package:survey/modules/login/login_module.dart'; + +import '../../fakers/fake_module.dart'; +import '../../helpers/behavior_subject_generator.dart'; +import '../../helpers/extensions/widget_tester.dart'; +import 'login_view_test.mocks.dart'; + +@GenerateMocks([LoginViewDelegate]) +void main() { + describe("a Login view", () { + late FakeModule module; + late MockLoginViewDelegate delegate; + late BehaviorSubjectGenerator generator; + + beforeEach((_) async { + generator = BehaviorSubjectGenerator(); + delegate = MockLoginViewDelegate(); + when(delegate.emailTextFieldDidChange) + .thenAnswer((realInvocation) => generator.make(0)); + when(delegate.passwordTextFieldDidChange) + .thenAnswer((realInvocation) => generator.make(1)); + when(delegate.stateDidInit) + .thenAnswer((realInvocation) => generator.make(2)); + when(delegate.loginButtonDidTap) + .thenAnswer((realInvocation) => generator.make(3)); + when(delegate.forgotButtonDidTap) + .thenAnswer((realInvocation) => generator.make(4)); + + module = FakeModule( + builder: () => LoginViewImpl(), + delegate: delegate, + ); + ViewState.overriddenModule = module; + }); + + describe("it at initial state", () { + beforeEach((tester) async { + await tester.pumpModule(module); + await tester.pumpAndSettle(); + }); + + it("shows logo & background only", (tester) async { + final opacityForm = + tester.widget(find.byKey(LoginView.opacityFormKey)); + expect(opacityForm.opacity, 0); + + expect(find.byKey(LoginView.backgroundImageKey), findsOneWidget); + expect(find.byKey(LoginView.logoImageKey), findsOneWidget); + }); + }); + + describe("its beginAnimation() is called", () { + beforeEach((tester) async { + await tester.pumpModule(module); + module.view.beginAnimation(); + await tester.pumpAndSettle(LoginView.animationDuration); + }); + + it("triggers to fade in the form", (tester) async { + final opacityForm = + tester.widget(find.byKey(LoginView.opacityFormKey)); + expect(opacityForm.opacity, 1); + expect(find.byKey(LoginView.emailTextFieldKey), findsOneWidget); + expect(find.byKey(LoginView.passwordTextFieldKey), findsOneWidget); + expect(find.byKey(LoginView.loginButtonKey), findsOneWidget); + expect(find.byKey(LoginView.forgotButtonKey), findsOneWidget); + }); + }); + + describe("its showProgressHUD() is called", () { + beforeEach((tester) async { + await tester.pumpModule(module); + module.view.showProgressHUD(); + await tester.pump(); + }); + + it("triggers to show progress HUD", (tester) async { + final progressHUD = + tester.widget(find.byKey(LoginView.progressHUDKey)); + expect(progressHUD.isShow, true); + }); + }); + + describe("its dismissProgressHUD() is called", () { + beforeEach((tester) async { + await tester.pumpModule(module); + module.view.dismissProgressHUD(); + await tester.pump(); + }); + + it("triggers to hide progress HUD", (tester) async { + final progressHUD = + tester.widget(find.byKey(LoginView.progressHUDKey)); + expect(progressHUD.isShow, false); + }); + }); + + describe("its setLoginButton() is called", () { + context("isEnabled is true", () { + beforeEach((tester) async { + await tester.pumpModule(module); + module.view.setLoginButton(isEnabled: true); + await tester.pumpAndSettle(); + }); + + it("triggers to enable login button", (tester) async { + final loginButton = + tester.widget