diff --git a/.github/ISSUE_TEMPLATE/bug_template.md b/.github/ISSUE_TEMPLATE/bug_template.md index 18943b88..4da7909c 100644 --- a/.github/ISSUE_TEMPLATE/bug_template.md +++ b/.github/ISSUE_TEMPLATE/bug_template.md @@ -7,14 +7,14 @@ labels: "type : bug" ## Issue -Describe the issue you are facing. Show us the implementation: screenshots, GIFs, etc. +Describe the issue you are facing. Show us the implementation: screenshots, gif, etc. ## Expected -Describe what should be the correct behavior. +Describe what should be the correct behaviour. ## Steps to reproduce -1. -2. -3. +1. +2. +3. diff --git a/.github/ISSUE_TEMPLATE/chore_template.md b/.github/ISSUE_TEMPLATE/chore_template.md index 31da9ba4..1599a04f 100644 --- a/.github/ISSUE_TEMPLATE/chore_template.md +++ b/.github/ISSUE_TEMPLATE/chore_template.md @@ -1,13 +1,13 @@ --- name: "Chore" -about: "Open a chore issue for a minor update." +about: "Open a Chore for minor update." title: "Update " labels: "type : chore" --- ## Why -Describe the update in detail and why it is needed. +Describe the update details and why it's needed. ## Who Benefits? diff --git a/.github/ISSUE_TEMPLATE/feature_template.md b/.github/ISSUE_TEMPLATE/feature_template.md index 3b009dfa..4ad1fb45 100644 --- a/.github/ISSUE_TEMPLATE/feature_template.md +++ b/.github/ISSUE_TEMPLATE/feature_template.md @@ -7,7 +7,7 @@ labels: "type : feature" ## Why -Describe the big picture of the feature and why it is needed. +Describe the big picture of the feature and why it's needed. ## Who Benefits? diff --git a/.github/ISSUE_TEMPLATE/rfc_template.md b/.github/ISSUE_TEMPLATE/rfc_template.md new file mode 100644 index 00000000..907fbb86 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/rfc_template.md @@ -0,0 +1,28 @@ +--- +name: "Request For Comments (RFC)" +about: "You have an idea on how to improve our processes. Propose your idea so that the team can provide feedback." +title: "RFC: " +labels: "type : rfc" +--- + +## Issue + +Describe the issue the team is currently facing. Provide as much content as possible. + +## Solution + +Describe the solution you are prescribing for the issue + +## Who Benefits? + +Describe who will be the beneficiaries e.g. everyone, specific chapters, clients... + +## What's Next? + +Provide an actionable list of things that must happen in order to implement the solution: + +- [ ] +- [ ] +- [ ] + +Using a poll is encouraged to gather feedback on the RFA 👉 Use this tool: https://gh-polls.com/ diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 91206f99..107d9e0e 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -1,4 +1,8 @@ -https://github.com/nimblehq/git-template/issues/?? +Note: for a release PR, append this parameter `?template=release_template.md` to the current URL to apply the release PR template, e.g. `{Github PR URL}?template=release_template.md` + +-- + +https://github.com/nimblehq/jetpack-compose-crypto/issues/?? ## What happened 👀 @@ -6,8 +10,8 @@ Describe the big picture of your changes here to communicate to the team why we ## Insight 📝 -Describe in detail how to test the changes, which solution you tried but did not go with, referenced documentation is welcome as well. +Describe in details how to test the changes, which solution you tried but did not go with, referenced documentation is welcome as well. ## Proof Of Work 📹 -Show us the implementation: screenshots, GIFs, etc. +Show us the implementation: screenshots, gif, etc. diff --git a/.github/PULL_REQUEST_TEMPLATE/release_template.md b/.github/PULL_REQUEST_TEMPLATE/release_template.md index 32abb7cf..f3558cb6 100644 --- a/.github/PULL_REQUEST_TEMPLATE/release_template.md +++ b/.github/PULL_REQUEST_TEMPLATE/release_template.md @@ -1,4 +1,4 @@ -Link to the milestone on Github e.g. https://github.com/nimblehq/git-templates/milestone/41?closed=1 +Link to the milestone on Github e.g. https://github.com/nimblehq/jetpack-compose-crypto/milestone/1?closed=1 or Link to the project management tool for the release @@ -7,8 +7,8 @@ Link to the project management tool for the release Provide the ID and title of the issue in the section for each type (feature, chore and bug). The link is optional. - [ch1234] As a user, I can log in -or -- [[ch1234](https://github.com/nimblehq/git-templates/issues/1234)] As a user, I can log in + or +- [[ch1234](https://github.com/nimblehq/jetpack-compose-crypto/issues/1234)] As a user, I can log in ## Chores - Same structure as in ## Feature diff --git a/.github/workflows/deploy_to_firebase_app_distribution.yml b/.github/workflows/deploy_to_firebase_app_distribution.yml new file mode 100644 index 00000000..c514340f --- /dev/null +++ b/.github/workflows/deploy_to_firebase_app_distribution.yml @@ -0,0 +1,69 @@ +name: Deploy staging to Firebase App Distribution + +on: + # Trigger the workflow on push action in develop branch. + # So it will trigger when the PR of the feature/chore/bugfix branch was merged. + push: + branches: + - develop + +jobs: + deploy_staging_to_firebase_app_distribution: + name: Deploy staging to Firebase App Distribution + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - name: Set up JDK 1.8 + uses: actions/setup-java@v1 + with: + java-version: 1.8 + + - name: Set up timezone + uses: zcong1993/setup-timezone@master + with: + timezone: Asia/Bangkok + + - name: Checkout source code + uses: actions/checkout@v2.3.2 + + - name: Cache Gradle + uses: actions/cache@v2 + with: + path: | + ~/.gradle/caches/modules-* + ~/.gradle/caches/jars-* + ~/.gradle/caches/build-cache-* + key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*') }} + restore-keys: | + ${{ runner.os }}-gradle- + + - name: Run Detekt + run: ./gradlew detekt + + - name: Archive Detekt reports + uses: actions/upload-artifact@v2 + with: + name: DetektReports + path: build/reports/detekt/ + + - name: Run unit tests and Jacoco + run: ./gradlew jacocoTestReport + + - name: Archive code coverage reports + uses: actions/upload-artifact@v2 + with: + name: CodeCoverageReports + path: | + app/build/reports/jacoco/jacocoTestReport/ + data/build/reports/jacoco/jacocoTestReport/ + + - name: Build debug APK + run: ./gradlew assembleDebug + + - name: Deploy staging to Firebase + uses: wzieba/Firebase-Distribution-Github-Action@v1 + with: + appId: ${{secrets.FIREBASE_APP_ID_STAGING}} + token: ${{secrets.FIREBASE_TOKEN}} + groups: Dev + file: app/build/outputs/apk/staging/debug/app-staging-debug.apk diff --git a/.github/workflows/review_pull_request.yml b/.github/workflows/review_pull_request.yml new file mode 100644 index 00000000..2d264165 --- /dev/null +++ b/.github/workflows/review_pull_request.yml @@ -0,0 +1,67 @@ +name: Review pull request + +on: + pull_request: + types: [ opened, edited, reopened, synchronize ] + +jobs: + review_pull_request: + name: Review pull request + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - name: Set up JDK 1.8 + uses: actions/setup-java@v1 + with: + java-version: 1.8 + + - name: Checkout source code + uses: actions/checkout@v2.3.2 + + - name: Cache Gradle + uses: actions/cache@v2 + with: + path: | + ~/.gradle/caches/modules-* + ~/.gradle/caches/jars-* + ~/.gradle/caches/build-cache-* + key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*') }} + restore-keys: | + ${{ runner.os }}-gradle- + + - name: Run Detekt + run: ./gradlew detekt + + - name: Run Android Lint + run: ./gradlew lint + + - name: Run unit tests and Jacoco + run: ./gradlew jacocoTestReport + + - name: Set up Ruby + uses: actions/setup-ruby@v1 + with: + ruby-version: '2.7' + + - name: Cache gems + uses: actions/cache@v2 + with: + path: vendor/bundle + key: ${{ runner.os }}-gems-${{ hashFiles('**/Gemfile.lock') }} + restore-keys: | + ${{ runner.os }}-gems- + + - name: Install Bundle and check environment versions + run: | + echo 'Install Bundle' + bundle config path vendor/bundle + bundle install + echo 'Check environment setup versions' + ruby --version + gem --version + bundler --version + + - name: Run Danger + env: + DANGER_GITHUB_API_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: bundle exec danger diff --git a/.gitignore b/.gitignore index 2f2567c7..aa6c24c3 100644 --- a/.gitignore +++ b/.gitignore @@ -1,36 +1,52 @@ -*.gem -*.rbc -/.config -/.idea -/coverage/ -/InstalledFiles -/node_modules -/pkg/ -/spec/reports/ -/spec/examples.txt -/test/tmp/ -/test/version_tmp/ -/tmp/ - -# Used by dotenv library to load environment variables. -# .env - -## Documentation cache and generated files: -/.yardoc/ -/_yardoc/ -/doc/ -/rdoc/ - -## Environment normalization: -/.bundle/ -/vendor/bundle -/lib/bundler/man/ - -# for a library or gem, you might want to ignore these files since the code is -# intended to run in multiple environments; otherwise, check them in: -# Gemfile.lock -# .ruby-version -# .ruby-gemset - -# unless supporting rvm < 1.11.0 or doing something fancy, ignore this: -.rvmrc +# Created by .ignore support plugin (hsz.mobi) +### Android template +# Built application files +*.apk +*.ap_ + +# Files for the ART/Dalvik VM +*.dex + +# Java class files +*.class + +# Generated files +bin/ +gen/ +out/ +.DS_Store + +# Gradle files +.gradle/ +/build + +# Local configuration file (sdk path, etc) +local.properties + +# Proguard folder generated by Eclipse +proguard/ + +# Log Files +*.log + + +# Android Studio captures folder +captures/ + +# Intellij +*.iml +.idea/* + +# Keystore files +secret/ + +# External native build folder generated in Android Studio 2.2 and later +.externalNativeBuild + +# Code coverage +jacoco.exec + +# Google services +google-services.json +# Keystore +config/release.keystore diff --git a/.gitlab/merge_request_templates/Default.md b/.gitlab/merge_request_templates/Default.md deleted file mode 100644 index feb41aa8..00000000 --- a/.gitlab/merge_request_templates/Default.md +++ /dev/null @@ -1,20 +0,0 @@ -{COPY THE TASK URL HERE} - -## What happened 👀 - -Describe the big picture of your changes here to communicate to the team why we should accept this pull request. - -## Insight 📝 - -Describe in detail how to test the changes, which solution you tried but did not go with, referenced documentation is welcome as well. - -## Proof Of Work 📹 - -Show us the implementation: screenshots, GIFs, etc. - -## Reviewers ✅ - -Mention the reviewers here once the merge request is ready for review. - - -/label ~"status : wip" diff --git a/.gitlab/merge_request_templates/Release.md b/.gitlab/merge_request_templates/Release.md deleted file mode 100644 index fbc43e3d..00000000 --- a/.gitlab/merge_request_templates/Release.md +++ /dev/null @@ -1,22 +0,0 @@ -## Features - -Provide the ID and title of each issue in the section for each type (feature, chore and bug). The link is optional. - -- [{ISSUE ID}] {ISSUE TITLE} -or -- [[{ISSUE ID}]]({ISSUE LINK})] {ISSUE TITLE} - -## Chores - -- Same structure as in ## Feature - -## Bugs - -- Same structure as in ## Feature - -## Reviewers ✅ - -Mention the reviewers here once the merge request is ready for review. - - -/label ~"type : release" diff --git a/Dangerfile b/Dangerfile new file mode 100644 index 00000000..3456f3d4 --- /dev/null +++ b/Dangerfile @@ -0,0 +1,39 @@ +# Make it more obvious that a PR is a work in progress and shouldn't be merged yet +warn("PR is classed as Work in Progress") if github.pr_title.include? "WIP" + +# Warn when there is a big PR +warn("Big PR") if git.lines_of_code > 500 + +# Warn to encourage a PR description +warn("Please provide a summary in the PR description to make it easier to review") if github.pr_body.length == 0 + +# Warn to encourage that labels should have been used on the PR +warn("Please add labels to this PR") if github.pr_labels.empty? + +# Check commits lint and warn on all checks (instead of failing) +commit_lint.check warn: :all, disable: [:subject_length] + +# Detekt output check +detekt_dir = "**/build/reports/detekt/detekt-result.xml" +Dir[detekt_dir].each do |file_name| + kotlin_detekt.skip_gradle_task = true + kotlin_detekt.report_file = file_name + kotlin_detekt.detekt(inline_mode: true) +end + +# Android Lint output check +lint_dir = "**/**/build/reports/lint/lint-result.xml" +Dir[lint_dir].each do |file_name| + android_lint.skip_gradle_task = true + android_lint.report_file = file_name + android_lint.lint(inline_mode: true) +end + +# Show Danger test coverage report from Jacoco for CoroutineTemplate +jacoco_dir = "**/**/build/reports/jacoco/jacocoTestReport/jacocoTestReport.xml" +markdown "## CoroutineTemplate Jacoco report:" +Dir[jacoco_dir].each do |file_name| + # Report coverage of modified files, warn if total project coverage is under 80% + # or if any modified file's coverage is under 95% + shroud.report file_name, 80, 95, false +end diff --git a/Gemfile b/Gemfile new file mode 100644 index 00000000..0b95cb7d --- /dev/null +++ b/Gemfile @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +source "https://rubygems.org" + +git_source(:github) {|repo_name| "https://github.com/#{repo_name}" } + +gem 'danger' +gem 'danger-android_lint' +gem 'danger-commit_lint' +gem 'danger-kotlin_detekt' +gem 'danger-shroud' diff --git a/Gemfile.lock b/Gemfile.lock new file mode 100644 index 00000000..264f8e5c --- /dev/null +++ b/Gemfile.lock @@ -0,0 +1,109 @@ +GEM + remote: https://rubygems.org/ + specs: + addressable (2.8.0) + public_suffix (>= 2.0.2, < 5.0) + ansi (1.5.0) + ast (2.4.2) + claide (1.0.3) + claide-plugins (0.9.2) + cork + nap + open4 (~> 1.3) + colored2 (3.1.2) + cork (0.3.0) + colored2 (~> 3.1) + danger (8.4.0) + claide (~> 1.0) + claide-plugins (>= 0.9.2) + colored2 (~> 3.1) + cork (~> 0.1) + faraday (>= 0.9.0, < 2.0) + faraday-http-cache (~> 2.0) + git (~> 1.7) + kramdown (~> 2.3) + kramdown-parser-gfm (~> 1.0) + no_proxy_fix + octokit (~> 4.7) + terminal-table (>= 1, < 4) + danger-android_lint (0.0.9) + danger-plugin-api (~> 1.0) + oga + danger-commit_lint (0.0.7) + danger-plugin-api (~> 1.0) + danger-kotlin_detekt (0.0.3) + danger-plugin-api (~> 1.0) + danger-plugin-api (1.0.0) + danger (> 2.0) + danger-shroud (0.0.3) + danger-plugin-api (~> 1.0) + nokogiri + faraday (1.8.0) + faraday-em_http (~> 1.0) + faraday-em_synchrony (~> 1.0) + faraday-excon (~> 1.1) + faraday-httpclient (~> 1.0.1) + faraday-net_http (~> 1.0) + faraday-net_http_persistent (~> 1.1) + faraday-patron (~> 1.0) + faraday-rack (~> 1.0) + multipart-post (>= 1.2, < 3) + ruby2_keywords (>= 0.0.4) + faraday-em_http (1.0.0) + faraday-em_synchrony (1.0.0) + faraday-excon (1.1.0) + faraday-http-cache (2.2.0) + faraday (>= 0.8) + faraday-httpclient (1.0.1) + faraday-net_http (1.0.1) + faraday-net_http_persistent (1.2.0) + faraday-patron (1.0.0) + faraday-rack (1.0.0) + git (1.9.1) + rchardet (~> 1.8) + kramdown (2.3.1) + rexml + kramdown-parser-gfm (1.1.0) + kramdown (~> 2.0) + multipart-post (2.1.1) + nap (1.1.0) + no_proxy_fix (0.1.2) + nokogiri (1.13.3-x86_64-darwin) + racc (~> 1.4) + nokogiri (1.13.3-x86_64-linux) + racc (~> 1.4) + octokit (4.21.0) + faraday (>= 0.9) + sawyer (~> 0.8.0, >= 0.5.3) + oga (3.3) + ast + ruby-ll (~> 2.1) + open4 (1.3.4) + public_suffix (4.0.6) + racc (1.6.0) + rchardet (1.8.0) + rexml (3.2.5) + ruby-ll (2.1.2) + ansi + ast + ruby2_keywords (0.0.5) + sawyer (0.8.2) + addressable (>= 2.3.5) + faraday (> 0.8, < 2.0) + terminal-table (3.0.2) + unicode-display_width (>= 1.1.1, < 3) + unicode-display_width (2.1.0) + +PLATFORMS + x86_64-darwin-19 + x86_64-linux + +DEPENDENCIES + danger + danger-android_lint + danger-commit_lint + danger-kotlin_detekt + danger-shroud + +BUNDLED WITH + 2.2.15 diff --git a/README.md b/README.md index c937ad0e..c5b580ef 100644 --- a/README.md +++ b/README.md @@ -1,25 +1,35 @@ -# Git Repository Template +# Jetpack Compose Crypto -Project repository template to set up all public projects at [Nimble](https://nimblehq.co/) +This is an sample Crypto market prices app built with Jetpack Compose + +### Screenshot 📸 + +TBD + +### References: +- [Coingecko API](https://www.coingecko.com/en/api/documentation) +- [Figma Design](https://www.figma.com/community/file/1108313912145052181) ## Usage Clone the repository -`git clone git@github.com:nimblehq/git-template.git` +`git clone git@github.com:nimblehq/jetpack-compose-crypto.git` ## License -This project is Copyright (c) 2014 and onwards Nimble. It is free software and may be redistributed under the terms specified in the [LICENSE] file. +This project is Copyright (c) 2014 and onwards. It is free software, +and may be redistributed under the terms specified in the [LICENSE] file. [LICENSE]: /LICENSE ## About + Nimble logo - + This project is maintained and funded by Nimble. diff --git a/app/.gitignore b/app/.gitignore new file mode 100644 index 00000000..796b96d1 --- /dev/null +++ b/app/.gitignore @@ -0,0 +1 @@ +/build diff --git a/app/build.gradle.kts b/app/build.gradle.kts new file mode 100644 index 00000000..5534cb19 --- /dev/null +++ b/app/build.gradle.kts @@ -0,0 +1,174 @@ +plugins { + id("com.android.application") + id("com.google.gms.google-services") + + id("kotlin-android") + id("kotlin-kapt") + id("kotlin-parcelize") + + id("dagger.hilt.android.plugin") + id("androidx.navigation.safeargs.kotlin") + + id("plugins.jacoco-report") +} + +val keystoreProperties = rootDir.loadGradleProperties("signing.properties") + +android { + signingConfigs { + create(BuildType.RELEASE) { + // Remember to edit signing.properties to have the correct info for release build. + storeFile = file("../config/release.keystore") + storePassword = keystoreProperties.getProperty("KEYSTORE_PASSWORD") as String + keyPassword = keystoreProperties.getProperty("KEY_PASSWORD") as String + keyAlias = keystoreProperties.getProperty("KEY_ALIAS") as String + } + + getByName(BuildType.DEBUG) { + storeFile = file("../config/debug.keystore") + storePassword = "oQ4mL1jY2uX7wD8q" + keyAlias = "debug-key-alias" + keyPassword = "oQ4mL1jY2uX7wD8q" + } + } + + compileSdk = Versions.ANDROID_COMPILE_SDK_VERSION + defaultConfig { + applicationId = "co.nimblehq.compose.crypto" + minSdk = Versions.ANDROID_MIN_SDK_VERSION + targetSdk = Versions.ANDROID_TARGET_SDK_VERSION + versionCode = Versions.ANDROID_VERSION_CODE + versionName = Versions.ANDROID_VERSION_NAME + } + + buildTypes { + getByName(BuildType.RELEASE) { + isMinifyEnabled = true + isDebuggable = false + isShrinkResources = true + proguardFiles(getDefaultProguardFile("proguard-android.txt"), "proguard-rules.pro") + signingConfig = signingConfigs[BuildType.RELEASE] + buildConfigField("String", "BASE_API_URL", "\"https://jsonplaceholder.typicode.com/\"") + } + + getByName(BuildType.DEBUG) { + // For quickly testing build with proguard, enable this + isMinifyEnabled = false + signingConfig = signingConfigs[BuildType.DEBUG] + buildConfigField("String", "BASE_API_URL", "\"https://jsonplaceholder.typicode.com/\"") + /** + * From AGP 4.2.0, Jacoco generates the report incorrectly, and the report is missing + * some code coverage from module. On the new version of Gradle, they introduce a new + * flag [testCoverageEnabled], we must enable this flag if using Jacoco to capture + * coverage and creates a report in the build directory. + * Reference: https://developer.android.com/reference/tools/gradle-api/7.1/com/android/build/api/dsl/BuildType#istestcoverageenabled + */ + isTestCoverageEnabled = true + } + } + + flavorDimensions(Flavor.DIMENSIONS) + productFlavors { + create(Flavor.STAGING) { + applicationIdSuffix = ".staging" + } + + create(Flavor.PRODUCTION) {} + } + + sourceSets["test"].resources { + srcDir("src/test/resources") + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 + } + + kotlinOptions { + jvmTarget = JavaVersion.VERSION_1_8.toString() + } + + composeOptions { + kotlinCompilerVersion = Versions.KOTLIN_VERSION + kotlinCompilerExtensionVersion = Versions.COMPOSE_VERSION + } + + buildFeatures { + viewBinding = true + compose = true + } + + lintOptions { + isCheckDependencies = true + xmlReport = true + xmlOutput = file("build/reports/lint/lint-result.xml") + } + + testOptions { + unitTests { + // Robolectric resource processing/loading + isIncludeAndroidResources = true + isReturnDefaultValues = true + } + } +} + +kapt { + correctErrorTypes = true +} + +dependencies { + implementation(project(Module.DATA)) + implementation(project(Module.DOMAIN)) + + implementation(fileTree(mapOf("dir" to "libs", "include" to listOf("*.jar")))) + + implementation("androidx.activity:activity-ktx:${Versions.ANDROIDX_ACTIVITY_KTX_VERSION}") + implementation("androidx.appcompat:appcompat:${Versions.ANDROIDX_SUPPORT_VERSION}") + implementation("androidx.constraintlayout:constraintlayout:${Versions.CONSTRAINT_LAYOUT_VERSION}") + implementation("androidx.core:core-ktx:${Versions.ANDROIDX_CORE_KTX_VERSION}") + implementation("androidx.lifecycle:lifecycle-runtime-ktx:${Versions.ANDROIDX_LIFECYCLE_VERSION}") + + implementation("androidx.activity:activity-compose:${Versions.COMPOSE_ACTIVITY_VERSION}") + implementation("androidx.constraintlayout:constraintlayout-compose:${Versions.COMPOSE_CONSTRAINT_LAYOUT_VERSION}") + implementation("androidx.compose.ui:ui:${Versions.COMPOSE_VERSION}") + implementation("androidx.compose.ui:ui-tooling:${Versions.COMPOSE_VERSION}") + implementation("androidx.compose.foundation:foundation:${Versions.COMPOSE_VERSION}") + implementation("androidx.compose.material:material:${Versions.COMPOSE_VERSION}") + + implementation("androidx.navigation:navigation-fragment-ktx:${Versions.ANDROIDX_NAVIGATION_VERSION}") + implementation("androidx.navigation:navigation-runtime-ktx:${Versions.ANDROIDX_NAVIGATION_VERSION}") + implementation("androidx.navigation:navigation-ui-ktx:${Versions.ANDROIDX_NAVIGATION_VERSION}") + + implementation("com.google.dagger:hilt-android:${Versions.HILT_VERSION}") + + implementation("com.google.firebase:firebase-bom:${Versions.FIREBASE_BOM_VERSION}") + + + implementation("com.jakewharton.timber:timber:${Versions.TIMBER_LOG_VERSION}") + + implementation("com.github.nimblehq:android-common-ktx:${Versions.ANDROID_COMMON_KTX_VERSION}") + + implementation("org.jetbrains.kotlin:kotlin-stdlib:${Versions.KOTLIN_VERSION}") + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:${Versions.KOTLINX_COROUTINES_VERSION}") + + kapt("com.google.dagger:hilt-compiler:${Versions.HILT_VERSION}") + + debugImplementation("androidx.fragment:fragment-testing:${Versions.ANDROIDX_FRAGMENT_VERSION}") + + // Testing + testImplementation("io.kotest:kotest-assertions-core:${Versions.TEST_KOTEST_VERSION}") + testImplementation("junit:junit:${Versions.TEST_JUNIT_VERSION}") + testImplementation("org.robolectric:robolectric:${Versions.TEST_ROBOLECTRIC_VERSION}") + testImplementation("androidx.test:core:${Versions.ANDROIDX_CORE_KTX_VERSION}") + testImplementation("androidx.test:runner:${Versions.TEST_RUNNER_VERSION}") + testImplementation("androidx.test:rules:${Versions.TEST_RUNNER_VERSION}") + testImplementation("androidx.test.ext:junit-ktx:${Versions.TEST_JUNIT_ANDROIDX_EXT_VERSION}") + testImplementation("com.google.dagger:hilt-android-testing:${Versions.HILT_VERSION}") + testImplementation("org.jetbrains.kotlin:kotlin-reflect:${Versions.KOTLIN_REFLECT_VERSION}") + testImplementation("io.mockk:mockk:${Versions.TEST_MOCKK_VERSION}") + + kaptTest("com.google.dagger:hilt-android-compiler:${Versions.HILT_VERSION}") + testAnnotationProcessor("com.google.dagger:hilt-android-compiler:${Versions.HILT_VERSION}") +} diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro new file mode 100644 index 00000000..2f9dc5a4 --- /dev/null +++ b/app/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle.kts. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile diff --git a/app/src/debug/AndroidManifest.xml b/app/src/debug/AndroidManifest.xml new file mode 100644 index 00000000..53fb0fa4 --- /dev/null +++ b/app/src/debug/AndroidManifest.xml @@ -0,0 +1,11 @@ + + + + + + + + diff --git a/app/src/debug/java/co/nimblehq/compose/crypto/EmptyHiltActivity.kt b/app/src/debug/java/co/nimblehq/compose/crypto/EmptyHiltActivity.kt new file mode 100644 index 00000000..18342e41 --- /dev/null +++ b/app/src/debug/java/co/nimblehq/compose/crypto/EmptyHiltActivity.kt @@ -0,0 +1,7 @@ +package co.nimblehq.compose.crypto + +import androidx.appcompat.app.AppCompatActivity +import dagger.hilt.android.AndroidEntryPoint + +@AndroidEntryPoint +class EmptyHiltActivity : AppCompatActivity() diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 00000000..9f07f062 --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + diff --git a/app/src/main/java/co/nimblehq/compose/crypto/CrytoComposeApplication.kt b/app/src/main/java/co/nimblehq/compose/crypto/CrytoComposeApplication.kt new file mode 100644 index 00000000..ff1e2b53 --- /dev/null +++ b/app/src/main/java/co/nimblehq/compose/crypto/CrytoComposeApplication.kt @@ -0,0 +1,20 @@ +package co.nimblehq.compose.crypto + +import android.app.Application +import dagger.hilt.android.HiltAndroidApp +import timber.log.Timber + +@HiltAndroidApp +class CrytoComposeApplication : Application() { + + override fun onCreate() { + super.onCreate() + setupLogging() + } + + private fun setupLogging() { + if (BuildConfig.DEBUG) { + Timber.plant(Timber.DebugTree()) + } + } +} diff --git a/app/src/main/java/co/nimblehq/compose/crypto/di/modules/AppModule.kt b/app/src/main/java/co/nimblehq/compose/crypto/di/modules/AppModule.kt new file mode 100644 index 00000000..17761754 --- /dev/null +++ b/app/src/main/java/co/nimblehq/compose/crypto/di/modules/AppModule.kt @@ -0,0 +1,17 @@ +package co.nimblehq.compose.crypto.di.modules + +import co.nimblehq.compose.crypto.util.DispatchersProvider +import co.nimblehq.compose.crypto.util.DispatchersProviderImpl +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent + +@Module +@InstallIn(SingletonComponent::class) +class AppModule { + @Provides + fun provideDispatchersProvider(): DispatchersProvider { + return DispatchersProviderImpl() + } +} diff --git a/app/src/main/java/co/nimblehq/compose/crypto/di/modules/MoshiModule.kt b/app/src/main/java/co/nimblehq/compose/crypto/di/modules/MoshiModule.kt new file mode 100644 index 00000000..2e2f1cbe --- /dev/null +++ b/app/src/main/java/co/nimblehq/compose/crypto/di/modules/MoshiModule.kt @@ -0,0 +1,16 @@ +package co.nimblehq.compose.crypto.di.modules + +import co.nimblehq.compose.crypto.data.service.providers.MoshiBuilderProvider +import com.squareup.moshi.Moshi +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent + +@Module +@InstallIn(SingletonComponent::class) +class MoshiModule { + + @Provides + fun provideMoshi(): Moshi = MoshiBuilderProvider.moshiBuilder.build() +} diff --git a/app/src/main/java/co/nimblehq/compose/crypto/di/modules/NavigatorModule.kt b/app/src/main/java/co/nimblehq/compose/crypto/di/modules/NavigatorModule.kt new file mode 100644 index 00000000..7674ccaa --- /dev/null +++ b/app/src/main/java/co/nimblehq/compose/crypto/di/modules/NavigatorModule.kt @@ -0,0 +1,12 @@ +package co.nimblehq.compose.crypto.di.modules + +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.android.components.FragmentComponent + +@Module +@InstallIn(FragmentComponent::class) +abstract class NavigatorModule { + + // TODO Bind navigator +} diff --git a/app/src/main/java/co/nimblehq/compose/crypto/di/modules/OkHttpClientModule.kt b/app/src/main/java/co/nimblehq/compose/crypto/di/modules/OkHttpClientModule.kt new file mode 100644 index 00000000..a58477e2 --- /dev/null +++ b/app/src/main/java/co/nimblehq/compose/crypto/di/modules/OkHttpClientModule.kt @@ -0,0 +1,23 @@ +package co.nimblehq.compose.crypto.di.modules + +import co.nimblehq.compose.crypto.BuildConfig +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import okhttp3.OkHttpClient +import okhttp3.logging.HttpLoggingInterceptor + +@Module +@InstallIn(SingletonComponent::class) +class OkHttpClientModule { + + @Provides + fun provideOkHttpClient() = OkHttpClient.Builder().apply { + if (BuildConfig.DEBUG) { + addInterceptor(HttpLoggingInterceptor().apply { + level = HttpLoggingInterceptor.Level.BODY + }) + } + }.build() +} diff --git a/app/src/main/java/co/nimblehq/compose/crypto/di/modules/RepositoryModule.kt b/app/src/main/java/co/nimblehq/compose/crypto/di/modules/RepositoryModule.kt new file mode 100644 index 00000000..3e379025 --- /dev/null +++ b/app/src/main/java/co/nimblehq/compose/crypto/di/modules/RepositoryModule.kt @@ -0,0 +1,12 @@ +package co.nimblehq.compose.crypto.di.modules + +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.android.components.ViewModelComponent + +@Module +@InstallIn(ViewModelComponent::class) +class RepositoryModule { + + // TODO Provides Repositories +} diff --git a/app/src/main/java/co/nimblehq/compose/crypto/di/modules/RetrofitModule.kt b/app/src/main/java/co/nimblehq/compose/crypto/di/modules/RetrofitModule.kt new file mode 100644 index 00000000..d9e93c7e --- /dev/null +++ b/app/src/main/java/co/nimblehq/compose/crypto/di/modules/RetrofitModule.kt @@ -0,0 +1,40 @@ +package co.nimblehq.compose.crypto.di.modules + +import co.nimblehq.compose.crypto.BuildConfig +import co.nimblehq.compose.crypto.data.service.ApiService +import co.nimblehq.compose.crypto.data.service.providers.ApiServiceProvider +import co.nimblehq.compose.crypto.data.service.providers.ConverterFactoryProvider +import co.nimblehq.compose.crypto.data.service.providers.RetrofitProvider +import com.squareup.moshi.Moshi +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import okhttp3.OkHttpClient +import retrofit2.Converter +import retrofit2.Retrofit + +@Module +@InstallIn(SingletonComponent::class) +class RetrofitModule { + + @Provides + fun provideBaseApiUrl() = BuildConfig.BASE_API_URL + + @Provides + fun provideMoshiConverterFactory(moshi: Moshi): Converter.Factory = + ConverterFactoryProvider.getMoshiConverterFactory(moshi) + + @Provides + fun provideRetrofit( + baseUrl: String, + okHttpClient: OkHttpClient, + converterFactory: Converter.Factory, + ): Retrofit = RetrofitProvider + .getRetrofitBuilder(baseUrl, okHttpClient, converterFactory) + .build() + + @Provides + fun provideApiService(retrofit: Retrofit): ApiService = + ApiServiceProvider.getApiService(retrofit) +} diff --git a/app/src/main/java/co/nimblehq/compose/crypto/di/modules/StorageModule.kt b/app/src/main/java/co/nimblehq/compose/crypto/di/modules/StorageModule.kt new file mode 100644 index 00000000..11f046c3 --- /dev/null +++ b/app/src/main/java/co/nimblehq/compose/crypto/di/modules/StorageModule.kt @@ -0,0 +1,34 @@ +package co.nimblehq.compose.crypto.di.modules + +import android.content.Context +import android.content.SharedPreferences +import android.preference.PreferenceManager +import co.nimblehq.compose.crypto.data.storage.EncryptedSharedPreferences +import co.nimblehq.compose.crypto.data.storage.NormalSharedPreferences +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent + +@Module +@InstallIn(SingletonComponent::class) +abstract class StorageModule { + + companion object { + + @Provides + fun provideDefaultSharedPreferences(@ApplicationContext context: Context): SharedPreferences = + PreferenceManager.getDefaultSharedPreferences(context) + + @Provides + fun provideSecuredLocalStorage(@ApplicationContext context: Context) = + EncryptedSharedPreferences(context) + + @Provides + fun provideNormalLocalStorage( + @ApplicationContext context: Context, + defaultSharedPreferences: SharedPreferences + ) = NormalSharedPreferences(context, defaultSharedPreferences) + } +} diff --git a/app/src/main/java/co/nimblehq/compose/crypto/di/modules/main/MainActivityModule.kt b/app/src/main/java/co/nimblehq/compose/crypto/di/modules/main/MainActivityModule.kt new file mode 100644 index 00000000..62873753 --- /dev/null +++ b/app/src/main/java/co/nimblehq/compose/crypto/di/modules/main/MainActivityModule.kt @@ -0,0 +1,9 @@ +package co.nimblehq.compose.crypto.di.modules.main + +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.android.components.ActivityComponent + +@Module +@InstallIn(ActivityComponent::class) +class MainActivityModule diff --git a/app/src/main/java/co/nimblehq/compose/crypto/extension/ViewModelExt.kt b/app/src/main/java/co/nimblehq/compose/crypto/extension/ViewModelExt.kt new file mode 100644 index 00000000..49b31558 --- /dev/null +++ b/app/src/main/java/co/nimblehq/compose/crypto/extension/ViewModelExt.kt @@ -0,0 +1,40 @@ +package co.nimblehq.compose.crypto.extension + +import androidx.activity.ComponentActivity +import androidx.activity.viewModels +import androidx.annotation.MainThread +import androidx.fragment.app.* +import androidx.lifecycle.* + +/** + * PLEASE READ THIS BEFORE IMPLEMENT: + * Right now, there is no easy way to mock/ fake the viewModel inside the Fragment when applying + * the 'by viewModels()' Kotlin property delegate from the activity-ktx/ fragment-ktx artifact. + * After finding many ways to handle this issue, I end up with this solution that is to override the + * loading mechanism of the delegate. + * There is another way to resolve the issue as well and it is mentioned in the reference link. + * Reference: https://proandroiddev.com/testing-the-untestable-the-case-of-the-viewmodel-delegate-975c09160993 + */ +@MainThread +inline fun Fragment.provideActivityViewModels( + noinline factoryProducer: (() -> ViewModelProvider.Factory)? = null +): Lazy = OverridableLazy(activityViewModels(factoryProducer)) + +@MainThread +inline fun Fragment.provideViewModels( + noinline ownerProducer: () -> ViewModelStoreOwner = { this }, + noinline factoryProducer: (() -> ViewModelProvider.Factory)? = null +): Lazy = OverridableLazy(viewModels(ownerProducer, factoryProducer)) + +@MainThread +inline fun ComponentActivity.provideViewModels( + noinline factoryProducer: (() -> ViewModelProvider.Factory)? = null +): Lazy = OverridableLazy(viewModels(factoryProducer)) + +class OverridableLazy(var implementation: Lazy) : Lazy { + + override val value + get() = implementation.value + + override fun isInitialized() = implementation.isInitialized() +} diff --git a/app/src/main/java/co/nimblehq/compose/crypto/lib/TypeAlias.kt b/app/src/main/java/co/nimblehq/compose/crypto/lib/TypeAlias.kt new file mode 100644 index 00000000..7181104f --- /dev/null +++ b/app/src/main/java/co/nimblehq/compose/crypto/lib/TypeAlias.kt @@ -0,0 +1,3 @@ +package co.nimblehq.compose.crypto.lib + +typealias IsLoading = Boolean diff --git a/app/src/main/java/co/nimblehq/compose/crypto/ui/ErrorMapping.kt b/app/src/main/java/co/nimblehq/compose/crypto/ui/ErrorMapping.kt new file mode 100644 index 00000000..e45d20d7 --- /dev/null +++ b/app/src/main/java/co/nimblehq/compose/crypto/ui/ErrorMapping.kt @@ -0,0 +1,9 @@ +package co.nimblehq.compose.crypto.ui + +import android.content.Context +import co.nimblehq.compose.crypto.R + +fun Throwable.userReadableMessage(context: Context): String { + // TODO implement user readable message + return context.getString(R.string.error_generic) +} diff --git a/app/src/main/java/co/nimblehq/compose/crypto/ui/base/BaseActivity.kt b/app/src/main/java/co/nimblehq/compose/crypto/ui/base/BaseActivity.kt new file mode 100644 index 00000000..e06f1218 --- /dev/null +++ b/app/src/main/java/co/nimblehq/compose/crypto/ui/base/BaseActivity.kt @@ -0,0 +1,31 @@ +package co.nimblehq.compose.crypto.ui.base + +import android.os.Bundle +import android.view.LayoutInflater +import androidx.appcompat.app.AppCompatActivity +import androidx.viewbinding.ViewBinding + +abstract class BaseActivity : AppCompatActivity() { + + protected abstract val viewModel: BaseViewModel + + protected abstract val bindingInflater: (LayoutInflater) -> VB + + private var _binding: ViewBinding? = null + + @Suppress("UNCHECKED_CAST") + protected val binding: VB + get() = _binding as VB + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(bindingInflater.invoke(layoutInflater).apply { + _binding = this + }.root) + } + + override fun onDestroy() { + super.onDestroy() + _binding = null + } +} diff --git a/app/src/main/java/co/nimblehq/compose/crypto/ui/base/BaseComposeFragment.kt b/app/src/main/java/co/nimblehq/compose/crypto/ui/base/BaseComposeFragment.kt new file mode 100644 index 00000000..7cdff257 --- /dev/null +++ b/app/src/main/java/co/nimblehq/compose/crypto/ui/base/BaseComposeFragment.kt @@ -0,0 +1,62 @@ +package co.nimblehq.compose.crypto.ui.base + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.annotation.CallSuper +import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.ComposeView +import androidx.fragment.app.Fragment +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle +import co.nimblehq.compose.crypto.ui.common.Toaster +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.launch +import javax.inject.Inject + +abstract class BaseComposeFragment : Fragment(), BaseComposeFragmentCallbacks { + + @Inject + lateinit var toaster: Toaster + + protected abstract val composeScreen: @Composable () -> Unit + + @CallSuper + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + (this as? BaseComposeFragmentCallbacks)?.let { initViewModel() } + } + + override fun initViewModel() {} + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View? { + return ComposeView(requireContext()).apply { + setContent { composeScreen.invoke() } + } + } + + @CallSuper + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + (this as? BaseComposeFragmentCallbacks)?.let { + bindViewModel() + } + } + + protected inline infix fun Flow.bindTo(crossinline action: (T) -> Unit) { + with(viewLifecycleOwner) { + lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.STARTED) { + collect { action(it) } + } + } + } + } +} diff --git a/app/src/main/java/co/nimblehq/compose/crypto/ui/base/BaseComposeFragmentCallbacks.kt b/app/src/main/java/co/nimblehq/compose/crypto/ui/base/BaseComposeFragmentCallbacks.kt new file mode 100644 index 00000000..9e49eab9 --- /dev/null +++ b/app/src/main/java/co/nimblehq/compose/crypto/ui/base/BaseComposeFragmentCallbacks.kt @@ -0,0 +1,36 @@ +package co.nimblehq.compose.crypto.ui.base + +/** + * An interface provide abstract commitments for the implemented class + * from [BaseComposeFragment], with the [BaseViewModel] + * + * These methods are set to go well with the Lifecycle of the [BaseComposeFragment], + * so developers don't have to worry about the basic setups, + * which could produce conflicts with the fragment's lifecycle + * + * See more detail in each function. + */ +interface BaseComposeFragmentCallbacks { + + /** + * The initial callback where you want to place your initialize functions that trigger + * the setup block for the ViewModel. + * + * This method usually get called only ONCE during the time the Fragment is created. + * Ideally, you would want to place the network calls, api requests in here. + * + * This is called right after [BaseComposeFragment.onCreate] so we should NOT implement or place + * view events functions here. + */ + fun initViewModel() + + /** + * The initial callback where you want to place your view events functions. + * + * This method usually get called multiple times, whenever the Fragment view is being created/re-created. + * Ideally, you would want to setup the data binding from ViewModel to View here. + * + * This is called right after [BaseComposeFragment.onViewCreated] + */ + fun bindViewModel() +} diff --git a/app/src/main/java/co/nimblehq/compose/crypto/ui/base/BaseFragment.kt b/app/src/main/java/co/nimblehq/compose/crypto/ui/base/BaseFragment.kt new file mode 100644 index 00000000..b1c4e3fa --- /dev/null +++ b/app/src/main/java/co/nimblehq/compose/crypto/ui/base/BaseFragment.kt @@ -0,0 +1,88 @@ +package co.nimblehq.compose.crypto.ui.base + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.annotation.CallSuper +import androidx.fragment.app.Fragment +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle +import androidx.viewbinding.ViewBinding +import co.nimblehq.common.extensions.hideSoftKeyboard +import co.nimblehq.compose.crypto.ui.common.Toaster +import co.nimblehq.compose.crypto.ui.userReadableMessage +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.launch +import javax.inject.Inject + +abstract class BaseFragment : Fragment(), BaseFragmentCallbacks { + + @Inject + lateinit var toaster: Toaster + + abstract val bindingInflater: (LayoutInflater, ViewGroup?, Boolean) -> VB + + private var _binding: ViewBinding? = null + + @Suppress("UNCHECKED_CAST") + val binding: VB + get() = _binding as VB + + @CallSuper + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + (this as? BaseFragmentCallbacks)?.let { initViewModel() } + } + + override fun initViewModel() {} + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View? { + return bindingInflater.invoke(inflater, container, false).apply { + _binding = this + }.root + } + + @CallSuper + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + (this as? BaseFragmentCallbacks)?.let { + setupView() + bindViewEvents() + bindViewModel() + } + } + + override fun onDestroyView() { + super.onDestroyView() + _binding = null + } + + @CallSuper + override fun bindViewEvents() { + requireNotNull(view).setOnClickListener { + requireActivity().hideSoftKeyboard() + } + } + + open fun displayError(error: Throwable) { + val message = error.userReadableMessage(requireContext()) + toaster.display(message) + } + + protected inline infix fun Flow.bindTo(crossinline action: (T) -> Unit) { + with(viewLifecycleOwner) { + lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.STARTED) { + collect { action(it) } + } + } + } + } +} diff --git a/app/src/main/java/co/nimblehq/compose/crypto/ui/base/BaseFragmentCallbacks.kt b/app/src/main/java/co/nimblehq/compose/crypto/ui/base/BaseFragmentCallbacks.kt new file mode 100644 index 00000000..a124d141 --- /dev/null +++ b/app/src/main/java/co/nimblehq/compose/crypto/ui/base/BaseFragmentCallbacks.kt @@ -0,0 +1,57 @@ +package co.nimblehq.compose.crypto.ui.base + +/** + * An interface provide abstract commitments for the implemented class + * from [BaseFragment], with the [BaseViewModel] + * + * These methods are set to go well with the Lifecycle of the [BaseFragment], + * so developers don't have to worry about the basic setups, + * which could produce conflicts with the fragment's lifecycle + * + * See more detail in each function. + */ +interface BaseFragmentCallbacks { + + /** + * The initial callback where you want to place your initialize functions that trigger + * the setup block for the ViewModel. + * + * This method usually get called only ONCE during the time the Fragment is created. + * Ideally, you would want to place the network calls, api requests in here. + * + * This is called right after [BaseFragment.onCreate] so we should NOT implement or place + * view events functions here. + */ + fun initViewModel() + + /** + * The initial callback where you want to place your setup view components functions. + * + * This method usually get called multiple times, whenever the Fragment view is being created/re-created. + * Ideally, you would want to setup your RecyclerView, ViewPager here (without the data involvement). + * + * This is called right after [BaseFragment.onViewCreated] + */ + fun setupView() + + /** + * The initial callback where you want to place your view events functions. + * + * This method usually get called multiple times, whenever the Fragment view is being created/re-created. + * Ideally, you would want to setup your input events like: + * onClick, onPageChanged, onTextChanged here. + * + * This is called right after [BaseFragment.onViewCreated] + */ + fun bindViewEvents() + + /** + * The initial callback where you want to place your view events functions. + * + * This method usually get called multiple times, whenever the Fragment view is being created/re-created. + * Ideally, you would want to setup the data binding from ViewModel to View here. + * + * This is called right after [BaseFragment.onViewCreated] + */ + fun bindViewModel() +} diff --git a/app/src/main/java/co/nimblehq/compose/crypto/ui/base/BaseNavigator.kt b/app/src/main/java/co/nimblehq/compose/crypto/ui/base/BaseNavigator.kt new file mode 100644 index 00000000..887b72d9 --- /dev/null +++ b/app/src/main/java/co/nimblehq/compose/crypto/ui/base/BaseNavigator.kt @@ -0,0 +1,86 @@ +package co.nimblehq.compose.crypto.ui.base + +import android.os.Bundle +import android.os.Parcelable +import androidx.annotation.IdRes +import androidx.fragment.app.Fragment +import androidx.navigation.NavController +import androidx.navigation.fragment.findNavController +import co.nimblehq.common.extensions.getResourceName +import timber.log.Timber + +interface BaseNavigator { + + val navHostFragmentId: Int + + fun findNavController(): NavController? + + fun navigate(event: NavigationEvent) + + fun navigateUp() +} + +abstract class BaseNavigatorImpl( + protected val fragment: Fragment +) : BaseNavigator { + + private var navController: NavController? = null + + override fun findNavController(): NavController? { + return navController ?: try { + fragment.findNavController().apply { + navController = this + } + } catch (e: IllegalStateException) { + // Log Crashlytics as non-fatal for monitoring + Timber.e(e) + null + } + } + + override fun navigateUp() { + findNavController()?.navigateUp() + } + + protected fun popBackTo(@IdRes destinationId: Int, inclusive: Boolean = false) { + findNavController()?.popBackStack(destinationId, inclusive) + } + + protected fun unsupportedNavigation() { + val navController = findNavController() + val currentGraph = fragment.requireActivity().getResourceName(navController?.graph?.id) + val currentDestination = + fragment.requireActivity().getResourceName(navController?.currentDestination?.id) + handleError( + NavigationException.UnsupportedNavigationException( + currentGraph, + currentDestination + ) + ) + } + + protected fun NavController.navigateToDestinationByDeepLink( + destinationId: Int, + bundle: Parcelable? = null + ) { + createDeepLink() + .setDestination(destinationId).apply { + bundle?.let { + setArguments(Bundle().apply { + putParcelable("bundle", bundle) + }) + } + } + .createPendingIntent() + .send() + } + + private fun handleError(error: Throwable) { + if (fragment is BaseFragment<*>) { + Timber.e(error) + fragment.displayError(error) + } else { + throw error + } + } +} diff --git a/app/src/main/java/co/nimblehq/compose/crypto/ui/base/BaseViewModel.kt b/app/src/main/java/co/nimblehq/compose/crypto/ui/base/BaseViewModel.kt new file mode 100644 index 00000000..62a80bb8 --- /dev/null +++ b/app/src/main/java/co/nimblehq/compose/crypto/ui/base/BaseViewModel.kt @@ -0,0 +1,52 @@ +package co.nimblehq.compose.crypto.ui.base + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import co.nimblehq.compose.crypto.lib.IsLoading +import co.nimblehq.compose.crypto.util.DispatchersProvider +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.flow.* +import kotlinx.coroutines.launch + +@Suppress("PropertyName") +abstract class BaseViewModel(private val dispatchersProvider: DispatchersProvider) : ViewModel() { + + private var loadingCount: Int = 0 + + private val _showLoading = MutableStateFlow(false) + val showLoading: StateFlow + get() = _showLoading + + protected val _error = MutableSharedFlow() + val error: SharedFlow + get() = _error + + protected val _navigator = MutableSharedFlow() + val navigator: SharedFlow + get() = _navigator + + /** + * To show loading manually, should call `hideLoading` after + */ + protected fun showLoading() { + if (loadingCount == 0) { + _showLoading.value = true + } + loadingCount++ + } + + /** + * To hide loading manually, should be called after `showLoading` + */ + protected fun hideLoading() { + loadingCount-- + if (loadingCount == 0) { + _showLoading.value = false + } + } + + fun execute(coroutineDispatcher: CoroutineDispatcher = dispatchersProvider.io, job: suspend () -> Unit) = + viewModelScope.launch(coroutineDispatcher) { + job.invoke() + } +} diff --git a/app/src/main/java/co/nimblehq/compose/crypto/ui/base/NavigationEvent.kt b/app/src/main/java/co/nimblehq/compose/crypto/ui/base/NavigationEvent.kt new file mode 100644 index 00000000..a6481bea --- /dev/null +++ b/app/src/main/java/co/nimblehq/compose/crypto/ui/base/NavigationEvent.kt @@ -0,0 +1,4 @@ +package co.nimblehq.compose.crypto.ui.base + +sealed class NavigationEvent { +} diff --git a/app/src/main/java/co/nimblehq/compose/crypto/ui/base/NavigationException.kt b/app/src/main/java/co/nimblehq/compose/crypto/ui/base/NavigationException.kt new file mode 100644 index 00000000..63aade19 --- /dev/null +++ b/app/src/main/java/co/nimblehq/compose/crypto/ui/base/NavigationException.kt @@ -0,0 +1,11 @@ +package co.nimblehq.compose.crypto.ui.base + +sealed class NavigationException( + cause: Throwable? +) : Throwable(cause) { + + class UnsupportedNavigationException( + currentGraph: String?, + currentDestination: String? + ) : NavigationException(RuntimeException("Unsupported navigation on $currentGraph at $currentDestination")) +} diff --git a/app/src/main/java/co/nimblehq/compose/crypto/ui/common/Toaster.kt b/app/src/main/java/co/nimblehq/compose/crypto/ui/common/Toaster.kt new file mode 100644 index 00000000..449c0277 --- /dev/null +++ b/app/src/main/java/co/nimblehq/compose/crypto/ui/common/Toaster.kt @@ -0,0 +1,18 @@ +package co.nimblehq.compose.crypto.ui.common + +import android.content.Context +import android.widget.Toast +import android.widget.Toast.LENGTH_LONG +import android.widget.Toast.makeText +import dagger.hilt.android.qualifiers.ApplicationContext +import javax.inject.Inject + +class Toaster @Inject constructor(@ApplicationContext private val context: Context) { + + private var toast: Toast? = null + + fun display(message: String) { + toast?.cancel() + toast = makeText(context, message, LENGTH_LONG).also { it.show() } + } +} diff --git a/app/src/main/java/co/nimblehq/compose/crypto/ui/screens/MainActivity.kt b/app/src/main/java/co/nimblehq/compose/crypto/ui/screens/MainActivity.kt new file mode 100644 index 00000000..297a1e62 --- /dev/null +++ b/app/src/main/java/co/nimblehq/compose/crypto/ui/screens/MainActivity.kt @@ -0,0 +1,22 @@ +package co.nimblehq.compose.crypto.ui.screens + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import co.nimblehq.compose.crypto.ui.screens.home.HomeScreen +import co.nimblehq.compose.crypto.ui.theme.ComposeTheme +import dagger.hilt.android.AndroidEntryPoint + +// TODO: Consider update BaseActivity to extends ComponentActivity. +@AndroidEntryPoint +class MainActivity : ComponentActivity() { + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContent { + ComposeTheme { + HomeScreen() + } + } + } +} diff --git a/app/src/main/java/co/nimblehq/compose/crypto/ui/screens/MainViewModel.kt b/app/src/main/java/co/nimblehq/compose/crypto/ui/screens/MainViewModel.kt new file mode 100644 index 00000000..6962d1ef --- /dev/null +++ b/app/src/main/java/co/nimblehq/compose/crypto/ui/screens/MainViewModel.kt @@ -0,0 +1,10 @@ +package co.nimblehq.compose.crypto.ui.screens + +import co.nimblehq.compose.crypto.ui.base.BaseViewModel +import co.nimblehq.compose.crypto.util.DispatchersProvider +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject + +// TODO: Consider to update or remove it on Integrate task. +@HiltViewModel +class MainViewModel @Inject constructor(dispatchers: DispatchersProvider) : BaseViewModel(dispatchers) diff --git a/app/src/main/java/co/nimblehq/compose/crypto/ui/screens/home/CoinItem.kt b/app/src/main/java/co/nimblehq/compose/crypto/ui/screens/home/CoinItem.kt new file mode 100644 index 00000000..1eba485c --- /dev/null +++ b/app/src/main/java/co/nimblehq/compose/crypto/ui/screens/home/CoinItem.kt @@ -0,0 +1,156 @@ +package co.nimblehq.compose.crypto.ui.screens.home + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.Icon +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.KeyboardArrowDown +import androidx.compose.material.icons.filled.KeyboardArrowUp +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.constraintlayout.compose.Dimension +import androidx.compose.ui.tooling.preview.Preview +import androidx.constraintlayout.compose.ConstraintLayout +import co.nimblehq.compose.crypto.R +import co.nimblehq.compose.crypto.ui.theme.Color.FireOpal +import co.nimblehq.compose.crypto.ui.theme.Color.GuppieGreen +import co.nimblehq.compose.crypto.ui.theme.ComposeTheme +import co.nimblehq.compose.crypto.ui.theme.Dimension.Dp12 +import co.nimblehq.compose.crypto.ui.theme.Dimension.Dp16 +import co.nimblehq.compose.crypto.ui.theme.Dimension.Dp22 +import co.nimblehq.compose.crypto.ui.theme.Dimension.Dp25 +import co.nimblehq.compose.crypto.ui.theme.Dimension.Dp4 +import co.nimblehq.compose.crypto.ui.theme.Dimension.Dp60 +import co.nimblehq.compose.crypto.ui.theme.Dimension.Dp8 +import co.nimblehq.compose.crypto.ui.theme.Style +import co.nimblehq.compose.crypto.ui.theme.Style.coinItemColor +import co.nimblehq.compose.crypto.ui.theme.Style.coinNameColor +import co.nimblehq.compose.crypto.ui.theme.Style.textColor + +@Suppress("FunctionNaming", "LongMethod") +@Composable +fun CoinItem( + isPositiveNumber: Boolean = false /* TODO Update value to Object on Integrate ticket */ +) { + ConstraintLayout( + modifier = Modifier + .wrapContentWidth() + .clip(RoundedCornerShape(Dp12)) + .background(color = MaterialTheme.colors.coinItemColor) + .padding(horizontal = Dp8, vertical = Dp8) + ) { + val ( + logo, + coinSymbol, + coinName, + price, + icon, + priceChange + ) = createRefs() + + Image( + modifier = Modifier + .size(Dp60) + .padding(end = Dp16) + .constrainAs(logo) { + top.linkTo(coinSymbol.top) + bottom.linkTo(coinName.bottom) + start.linkTo(parent.start) + }, + // TODO: Remove dummy image when work on Integrate. + painter = painterResource(id = R.drawable.ic_btc_bitcoin), + contentDescription = null + ) + + Text( + modifier = Modifier + .constrainAs(coinSymbol) { + top.linkTo(parent.top) + start.linkTo(logo.end) + }, + // TODO: Remove dummy value when work on Integrate. + text = "BTC", + color = MaterialTheme.colors.textColor, + style = Style.semiBold16() + ) + + Text( + modifier = Modifier + .padding(top = Dp4) + .constrainAs(coinName) { + start.linkTo(coinSymbol.start) + top.linkTo(coinSymbol.bottom) + width = Dimension.preferredWrapContent + }, + // TODO: Remove dummy value when work on Integrate. + text = "Bitcoin", + color = MaterialTheme.colors.coinNameColor, + style = Style.medium14() + ) + + Text( + modifier = Modifier + .padding(top = Dp22) + .constrainAs(price) { + start.linkTo(logo.start) + top.linkTo(coinName.bottom) + width = Dimension.preferredWrapContent + }, + // TODO: Remove dummy value when work on Integrate. + text = stringResource(R.string.coin_currency, "24,209"), + color = MaterialTheme.colors.textColor, + style = Style.semiBold16() + ) + + Icon( + modifier = Modifier + .padding(start = Dp25) + .constrainAs(icon) { + start.linkTo(price.end) + top.linkTo(priceChange.top) + bottom.linkTo(priceChange.bottom) + width = Dimension.preferredWrapContent + }, + imageVector = if (isPositiveNumber) Icons.Filled.KeyboardArrowUp else Icons.Filled.KeyboardArrowDown, + tint = if (isPositiveNumber) GuppieGreen else FireOpal, + contentDescription = null + ) + + Text( + modifier = Modifier + .constrainAs(priceChange) { + start.linkTo(icon.end) + bottom.linkTo(parent.bottom) + width = Dimension.preferredWrapContent + }, + // TODO: Remove dummy value when work on Integrate. + text = stringResource(R.string.coin_profit_percent, "6.21"), + style = if (isPositiveNumber) Style.guppieGreenMedium16() else Style.fireOpalGreenMedium16() + ) + } +} + +@Suppress("FunctionNaming") +@Composable +@Preview +fun CoinItemPreview() { + ComposeTheme { + CoinItem() + } +} + +@Suppress("FunctionNaming") +@Composable +@Preview +fun CoinItemPreviewDark() { + ComposeTheme(darkTheme = true) { + CoinItem() + } +} diff --git a/app/src/main/java/co/nimblehq/compose/crypto/ui/screens/home/HomeScreen.kt b/app/src/main/java/co/nimblehq/compose/crypto/ui/screens/home/HomeScreen.kt new file mode 100644 index 00000000..e8b271ec --- /dev/null +++ b/app/src/main/java/co/nimblehq/compose/crypto/ui/screens/home/HomeScreen.kt @@ -0,0 +1,117 @@ +package co.nimblehq.compose.crypto.ui.screens.home + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.material.* +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.constraintlayout.compose.ConstraintLayout +import androidx.constraintlayout.compose.Dimension +import co.nimblehq.compose.crypto.R +import co.nimblehq.compose.crypto.ui.theme.ComposeTheme +import co.nimblehq.compose.crypto.ui.theme.Dimension.Dp16 +import co.nimblehq.compose.crypto.ui.theme.Dimension.Dp40 +import co.nimblehq.compose.crypto.ui.theme.Dimension.Dp52 +import co.nimblehq.compose.crypto.ui.theme.Style +import co.nimblehq.compose.crypto.ui.theme.Style.textColor + +@Suppress("FunctionNaming", "LongMethod") +@Composable +fun HomeScreen() { + Surface { + ConstraintLayout( + modifier = Modifier.fillMaxSize() + ) { + val ( + title, + portfolioCard, + myCoinsTitle, + seeAll, + myCoins + ) = createRefs() + + Text( + modifier = Modifier + .padding(top = Dp16) + .constrainAs(title) { + top.linkTo(parent.top) + linkTo(start = parent.start, end = parent.end) + width = Dimension.preferredWrapContent + }, + text = stringResource(id = R.string.home_title), + textAlign = TextAlign.Center, + style = Style.semiBold24(), + color = MaterialTheme.colors.textColor + ) + + PortfolioCard( + modifier = Modifier + .constrainAs(portfolioCard) { + top.linkTo(title.bottom, margin = Dp40) + } + .padding(horizontal = Dp16) + ) + + Text( + modifier = Modifier + .constrainAs(myCoinsTitle) { + top.linkTo(portfolioCard.bottom, margin = Dp52) + start.linkTo(parent.start) + width = Dimension.preferredWrapContent + } + .padding(start = Dp16), + text = stringResource(id = R.string.home_my_coins_title), + style = Style.medium16(), + color = MaterialTheme.colors.textColor + ) + + SeeAll( + modifier = Modifier + .clickable(onClick = { /* TODO: Update on Integrate ticket */ }) + .constrainAs(seeAll) { + top.linkTo(myCoinsTitle.top) + end.linkTo(parent.end) + width = Dimension.preferredWrapContent + } + .padding(end = Dp16) + ) + + LazyRow( + modifier = Modifier + .fillMaxWidth() + .constrainAs(myCoins) { + top.linkTo(myCoinsTitle.bottom, margin = Dp16) + }, + contentPadding = PaddingValues(horizontal = Dp16), + horizontalArrangement = Arrangement.spacedBy(Dp16), + ) { + // TODO: Remove dummy value when work on Integrate. + item { CoinItem() } + item { CoinItem(true) } + item { CoinItem() } + } + } + } +} + +@Suppress("FunctionNaming") +@Composable +@Preview +fun HomeScreenPreview() { + ComposeTheme { + HomeScreen() + } +} + +@Suppress("FunctionNaming") +@Composable +@Preview +fun HomeScreenPreviewDark() { + ComposeTheme(darkTheme = true) { + HomeScreen() + } +} diff --git a/app/src/main/java/co/nimblehq/compose/crypto/ui/screens/home/PortfolioCard.kt b/app/src/main/java/co/nimblehq/compose/crypto/ui/screens/home/PortfolioCard.kt new file mode 100644 index 00000000..f06d9760 --- /dev/null +++ b/app/src/main/java/co/nimblehq/compose/crypto/ui/screens/home/PortfolioCard.kt @@ -0,0 +1,129 @@ +package co.nimblehq.compose.crypto.ui.screens.home + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.* +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.KeyboardArrowUp +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.constraintlayout.compose.ConstraintLayout +import co.nimblehq.compose.crypto.R +import co.nimblehq.compose.crypto.ui.theme.Color +import co.nimblehq.compose.crypto.ui.theme.ComposeTheme +import co.nimblehq.compose.crypto.ui.theme.Dimension.Dp0 +import co.nimblehq.compose.crypto.ui.theme.Dimension.Dp12 +import co.nimblehq.compose.crypto.ui.theme.Dimension.Dp16 +import co.nimblehq.compose.crypto.ui.theme.Dimension.Dp20 +import co.nimblehq.compose.crypto.ui.theme.Dimension.Dp4 +import co.nimblehq.compose.crypto.ui.theme.Dimension.Dp40 +import co.nimblehq.compose.crypto.ui.theme.Dimension.Dp8 +import co.nimblehq.compose.crypto.ui.theme.Style + +@Suppress("FunctionNaming", "LongMethod") +@Composable +fun PortfolioCard( + modifier: Modifier +) { + ConstraintLayout( + modifier = modifier + .fillMaxWidth() + .clip(RoundedCornerShape(Dp12)) + .background( + brush = Brush.linearGradient( + colors = listOf(Color.MetallicSeaweed, Color.TiffanyBlue), + ) + ) + .padding(horizontal = Dp16, vertical = Dp16) + ) { + val ( + totalCoinsLabel, + totalCoins, + todayProfitLabel, + todayProfit, + profitPercent + ) = createRefs() + + Text( + modifier = Modifier + .constrainAs(totalCoinsLabel) { + start.linkTo(parent.start) + }, + text = stringResource(R.string.portfolio_card_total_coin_label), + style = Style.lightSilverMedium16() + ) + + Text( + modifier = Modifier + .constrainAs(totalCoins) { + top.linkTo(totalCoinsLabel.bottom, margin = Dp8) + }, + // TODO: Remove dummy value when work on Integrate. + text = stringResource(R.string.coin_currency, "7,273,291"), + style = Style.whiteSemiBold24() + ) + + Text( + modifier = Modifier + .constrainAs(todayProfitLabel) { + top.linkTo(totalCoins.bottom, margin = Dp40) + }, + text = stringResource(R.string.portfolio_card_today_profit_label), + style = Style.lightSilverMedium16() + ) + + Text( + modifier = Modifier + .constrainAs(todayProfit) { + top.linkTo(todayProfitLabel.bottom, margin = Dp8) + }, + // TODO: Remove dummy value when work on Integrate. + text = stringResource(R.string.coin_currency, "193,280"), + style = Style.whiteSemiBold24() + ) + + Button( + modifier = Modifier + .shadow(elevation = Dp0) + .constrainAs(profitPercent) { + linkTo(top = todayProfitLabel.top, bottom = todayProfit.bottom) + end.linkTo(parent.end) + }, + colors = ButtonDefaults.buttonColors( + backgroundColor = Color.Water, + contentColor = Color.GuppieGreen + ), + shape = RoundedCornerShape(Dp20), + onClick = { /* TODO */ } + ) { + Icon( + imageVector = Icons.Filled.KeyboardArrowUp, + modifier = Modifier.padding(end = Dp4), + contentDescription = null + ) + Text( + // TODO: Remove dummy value when work on Integrate. + text = stringResource(R.string.coin_profit_percent, "2.41"), + style = Style.medium16() + ) + } + } +} + +@Suppress("FunctionNaming") +@Composable +@Preview +fun PortfolioCardPreview() { + ComposeTheme { + PortfolioCard( + modifier = Modifier + ) + } +} diff --git a/app/src/main/java/co/nimblehq/compose/crypto/ui/screens/home/SeeAll.kt b/app/src/main/java/co/nimblehq/compose/crypto/ui/screens/home/SeeAll.kt new file mode 100644 index 00000000..9220a716 --- /dev/null +++ b/app/src/main/java/co/nimblehq/compose/crypto/ui/screens/home/SeeAll.kt @@ -0,0 +1,53 @@ +package co.nimblehq.compose.crypto.ui.screens.home + +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.Icon +import androidx.compose.material.Text +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowForward +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import co.nimblehq.compose.crypto.R +import co.nimblehq.compose.crypto.ui.theme.Color +import co.nimblehq.compose.crypto.ui.theme.ComposeTheme +import co.nimblehq.compose.crypto.ui.theme.Dimension.Dp14 +import co.nimblehq.compose.crypto.ui.theme.Dimension.Dp8 +import co.nimblehq.compose.crypto.ui.theme.Style + +@Suppress("FunctionNaming") +@Composable +fun SeeAll( + modifier: Modifier +) { + Row(modifier = modifier) { + Text( + modifier = Modifier.padding(end = Dp8), + text = stringResource(id = R.string.home_my_coins_see_all), + style = Style.tiffanyBlueMedium14() + ) + Icon( + modifier = Modifier + .size(Dp14) + .align(Alignment.CenterVertically), + imageVector = Icons.Filled.ArrowForward, + tint = Color.TiffanyBlue, + contentDescription = null + ) + } +} + +@Suppress("FunctionNaming") +@Composable +@Preview +fun SeeAllPreview() { + ComposeTheme { + SeeAll( + modifier = Modifier + ) + } +} diff --git a/app/src/main/java/co/nimblehq/compose/crypto/ui/screens/to_be_removed/ContentCard.kt b/app/src/main/java/co/nimblehq/compose/crypto/ui/screens/to_be_removed/ContentCard.kt new file mode 100644 index 00000000..9c0b7b09 --- /dev/null +++ b/app/src/main/java/co/nimblehq/compose/crypto/ui/screens/to_be_removed/ContentCard.kt @@ -0,0 +1,26 @@ +package co.nimblehq.compose.crypto.ui.screens.to_be_removed + +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.Card +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import co.nimblehq.compose.crypto.ui.theme.Dimension + +@Composable +fun ContentCard( + content: @Composable () -> Unit +) { + Card( + shape = RoundedCornerShape( + topStart = Dimension.Dp24, + topEnd = Dimension.Dp24, + bottomStart = Dimension.Dp0, + bottomEnd = Dimension.Dp0 + ), + elevation = Dimension.Dp0, + modifier = Modifier.fillMaxSize() + ) { + content.invoke() + } +} diff --git a/app/src/main/java/co/nimblehq/compose/crypto/ui/screens/to_be_removed/TitleBar.kt b/app/src/main/java/co/nimblehq/compose/crypto/ui/screens/to_be_removed/TitleBar.kt new file mode 100644 index 00000000..28332ef5 --- /dev/null +++ b/app/src/main/java/co/nimblehq/compose/crypto/ui/screens/to_be_removed/TitleBar.kt @@ -0,0 +1,53 @@ +package co.nimblehq.compose.crypto.ui.screens.to_be_removed + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.material.TextField +import androidx.compose.runtime.Composable +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import co.nimblehq.compose.crypto.ui.theme.Dimension + +@ExperimentalComposeUiApi +@Composable +fun TitleBar( + title: String, + textFieldValue: String, + onTextFieldValueChange: (String) -> Unit +) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(Dimension.Dp24) + ) { + val keyboardController = LocalSoftwareKeyboardController.current + + Text( + text = title, + style = MaterialTheme.typography.h6 + ) + TextField( + value = textFieldValue, + onValueChange = onTextFieldValueChange, + label = { Text(text = "Demo TextField") }, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Text, + imeAction = ImeAction.Done, + ), + keyboardActions = KeyboardActions( + onDone = { keyboardController?.hide() }, + ), + modifier = Modifier + .fillMaxWidth() + .padding(top = Dimension.Dp16) + ) + } +} diff --git a/app/src/main/java/co/nimblehq/compose/crypto/ui/theme/Color.kt b/app/src/main/java/co/nimblehq/compose/crypto/ui/theme/Color.kt new file mode 100644 index 00000000..ade2ea6b --- /dev/null +++ b/app/src/main/java/co/nimblehq/compose/crypto/ui/theme/Color.kt @@ -0,0 +1,20 @@ +package co.nimblehq.compose.crypto.ui.theme + +import androidx.compose.ui.graphics.Color + +@Suppress("MagicNumber") +object Color { + val BlueFreeSpeech = Color(0xFF3F51B5) + val White = Color.White + val MetallicSeaweed = Color(0xFF028090) + val TiffanyBlue = Color(0xFF00BFB2) + val Water = Color(0xFFD6F5F3) + val GuppieGreen = Color(0xFF10DC78) + val DarkJungleGreen = Color(0xFF141B29) + val LightSilver = Color(0xFFD6D7D8) + val Quartz = Color(0xFF484D58) + val QuartzAlpha20 = Quartz.copy(alpha = 0.2f) + val Cultured = Color(0xFFEEEEEE) + val FireOpal = Color(0xFFF15950) + val SonicSilver = Color(0xFF70747C) +} diff --git a/app/src/main/java/co/nimblehq/compose/crypto/ui/theme/Dimension.kt b/app/src/main/java/co/nimblehq/compose/crypto/ui/theme/Dimension.kt new file mode 100644 index 00000000..abf7cc37 --- /dev/null +++ b/app/src/main/java/co/nimblehq/compose/crypto/ui/theme/Dimension.kt @@ -0,0 +1,20 @@ +package co.nimblehq.compose.crypto.ui.theme + +import androidx.compose.ui.unit.dp + +@Suppress("MagicNumber") +object Dimension { + val Dp0 = 0.dp + val Dp4 = 4.dp + val Dp8 = 8.dp + val Dp12 = 12.dp + val Dp14 = 14.dp + val Dp16 = 16.dp + val Dp20 = 20.dp + val Dp22 = 22.dp + val Dp24 = 24.dp + val Dp25 = 25.dp + val Dp40 = 40.dp + val Dp52 = 52.dp + val Dp60 = 60.dp +} diff --git a/app/src/main/java/co/nimblehq/compose/crypto/ui/theme/Shape.kt b/app/src/main/java/co/nimblehq/compose/crypto/ui/theme/Shape.kt new file mode 100644 index 00000000..09bebf0e --- /dev/null +++ b/app/src/main/java/co/nimblehq/compose/crypto/ui/theme/Shape.kt @@ -0,0 +1,10 @@ +package co.nimblehq.compose.crypto.ui.theme + +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.Shapes + +object Shape { + val ComposeShapes = Shapes( + medium = RoundedCornerShape(Dimension.Dp8) + ) +} diff --git a/app/src/main/java/co/nimblehq/compose/crypto/ui/theme/Style.kt b/app/src/main/java/co/nimblehq/compose/crypto/ui/theme/Style.kt new file mode 100644 index 00000000..acb3799d --- /dev/null +++ b/app/src/main/java/co/nimblehq/compose/crypto/ui/theme/Style.kt @@ -0,0 +1,70 @@ +package co.nimblehq.compose.crypto.ui.theme + +import androidx.compose.material.Colors +import androidx.compose.ui.graphics.Color +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color.Companion.White +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.Font +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import co.nimblehq.compose.crypto.R +import co.nimblehq.compose.crypto.ui.theme.Color.DarkJungleGreen +import co.nimblehq.compose.crypto.ui.theme.Color.FireOpal +import co.nimblehq.compose.crypto.ui.theme.Color.GuppieGreen +import co.nimblehq.compose.crypto.ui.theme.Color.LightSilver +import co.nimblehq.compose.crypto.ui.theme.Color.QuartzAlpha20 +import co.nimblehq.compose.crypto.ui.theme.Color.SonicSilver +import co.nimblehq.compose.crypto.ui.theme.Color.TiffanyBlue +import co.nimblehq.compose.crypto.ui.theme.TextDimension.Sp14 +import co.nimblehq.compose.crypto.ui.theme.TextDimension.Sp16 +import co.nimblehq.compose.crypto.ui.theme.TextDimension.Sp24 + +object Style { + + private val textStyle = TextStyle( + fontFamily = FontFamily( + Font(R.font.inter_medium, FontWeight.Medium), + Font(R.font.inter_semi_bold, FontWeight.SemiBold) + ) + ) + + val Colors.textColor: Color + @Composable + get() = if (isLight) DarkJungleGreen else White + + val Colors.coinItemColor: Color + @Composable + get() = if (isLight) White else QuartzAlpha20 + + val Colors.coinNameColor: Color + @Composable + get() = if (isLight) SonicSilver else LightSilver + + @Composable + fun medium14() = textStyle.copy(fontWeight = FontWeight.Medium, fontSize = Sp14) + + @Composable + fun tiffanyBlueMedium14() = medium14().copy(color = TiffanyBlue) + + @Composable + fun medium16() = textStyle.copy(fontWeight = FontWeight.Medium, fontSize = Sp16) + + @Composable + fun lightSilverMedium16() = medium16().copy(color = LightSilver) + + @Composable + fun guppieGreenMedium16() = medium16().copy(color = GuppieGreen) + + @Composable + fun fireOpalGreenMedium16() = medium16().copy(color = FireOpal) + + @Composable + fun semiBold16() = textStyle.copy(fontWeight = FontWeight.SemiBold, fontSize = Sp16) + + @Composable + fun semiBold24() = textStyle.copy(fontWeight = FontWeight.SemiBold, fontSize = Sp24) + + @Composable + fun whiteSemiBold24() = semiBold24().copy(color = White) +} diff --git a/app/src/main/java/co/nimblehq/compose/crypto/ui/theme/TextDimension.kt b/app/src/main/java/co/nimblehq/compose/crypto/ui/theme/TextDimension.kt new file mode 100644 index 00000000..abec2d59 --- /dev/null +++ b/app/src/main/java/co/nimblehq/compose/crypto/ui/theme/TextDimension.kt @@ -0,0 +1,10 @@ +package co.nimblehq.compose.crypto.ui.theme + +import androidx.compose.ui.unit.sp + +@Suppress("MagicNumber") +object TextDimension { + val Sp14 = 14.sp + val Sp16 = 16.sp + val Sp24 = 24.sp +} diff --git a/app/src/main/java/co/nimblehq/compose/crypto/ui/theme/Theme.kt b/app/src/main/java/co/nimblehq/compose/crypto/ui/theme/Theme.kt new file mode 100644 index 00000000..fdf67922 --- /dev/null +++ b/app/src/main/java/co/nimblehq/compose/crypto/ui/theme/Theme.kt @@ -0,0 +1,41 @@ +package co.nimblehq.compose.crypto.ui.theme + +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material.MaterialTheme +import androidx.compose.material.darkColors +import androidx.compose.material.lightColors +import androidx.compose.runtime.Composable +import co.nimblehq.compose.crypto.ui.theme.Color.Cultured +import co.nimblehq.compose.crypto.ui.theme.Color.BlueFreeSpeech +import co.nimblehq.compose.crypto.ui.theme.Color.DarkJungleGreen + +@Suppress("MatchingDeclarationName") +object Palette { + val ComposeLightPalette = lightColors( + primary = BlueFreeSpeech, + surface = Cultured + ) + + val ComposeDarkPalette = darkColors( + surface = DarkJungleGreen + ) +} + +@Suppress("FunctionNaming") +@Composable +fun ComposeTheme( + darkTheme: Boolean = isSystemInDarkTheme(), + content: @Composable () -> Unit +) { + val colors = if (darkTheme) { + Palette.ComposeDarkPalette + } else { + Palette.ComposeLightPalette + } + + MaterialTheme( + colors = colors, + shapes = Shape.ComposeShapes, + content = content + ) +} diff --git a/app/src/main/java/co/nimblehq/compose/crypto/util/DispatchersProvider.kt b/app/src/main/java/co/nimblehq/compose/crypto/util/DispatchersProvider.kt new file mode 100644 index 00000000..c83cb70c --- /dev/null +++ b/app/src/main/java/co/nimblehq/compose/crypto/util/DispatchersProvider.kt @@ -0,0 +1,9 @@ +package co.nimblehq.compose.crypto.util + +import kotlinx.coroutines.CoroutineDispatcher + +interface DispatchersProvider { + val io: CoroutineDispatcher + val main: CoroutineDispatcher + val default: CoroutineDispatcher +} diff --git a/app/src/main/java/co/nimblehq/compose/crypto/util/DispatchersProviderImpl.kt b/app/src/main/java/co/nimblehq/compose/crypto/util/DispatchersProviderImpl.kt new file mode 100644 index 00000000..943c5475 --- /dev/null +++ b/app/src/main/java/co/nimblehq/compose/crypto/util/DispatchersProviderImpl.kt @@ -0,0 +1,12 @@ +package co.nimblehq.compose.crypto.util + +import kotlinx.coroutines.Dispatchers + +class DispatchersProviderImpl : DispatchersProvider { + + override val io = Dispatchers.IO + + override val main = Dispatchers.Main + + override val default = Dispatchers.Default +} diff --git a/app/src/main/res/drawable-v24/ic_launcher_foreground.xml b/app/src/main/res/drawable-v24/ic_launcher_foreground.xml new file mode 100644 index 00000000..1f6bb290 --- /dev/null +++ b/app/src/main/res/drawable-v24/ic_launcher_foreground.xml @@ -0,0 +1,34 @@ + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_btc_bitcoin.png b/app/src/main/res/drawable/ic_btc_bitcoin.png new file mode 100644 index 00000000..3a311de9 Binary files /dev/null and b/app/src/main/res/drawable/ic_btc_bitcoin.png differ diff --git a/app/src/main/res/drawable/ic_launcher_background.xml b/app/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 00000000..a339898e --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,170 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/font/inter_medium.ttf b/app/src/main/res/font/inter_medium.ttf new file mode 100644 index 00000000..49b53ab3 Binary files /dev/null and b/app/src/main/res/font/inter_medium.ttf differ diff --git a/app/src/main/res/font/inter_semi_bold.ttf b/app/src/main/res/font/inter_semi_bold.ttf new file mode 100644 index 00000000..01523b22 Binary files /dev/null and b/app/src/main/res/font/inter_semi_bold.ttf differ diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml new file mode 100644 index 00000000..1856a039 --- /dev/null +++ b/app/src/main/res/layout/activity_main.xml @@ -0,0 +1,7 @@ + + + + diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 00000000..6b78462d --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 00000000..6b78462d --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher.png b/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 00000000..a2f59082 Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_round.png b/app/src/main/res/mipmap-hdpi/ic_launcher_round.png new file mode 100644 index 00000000..1b523998 Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher_round.png differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher.png b/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 00000000..ff10afd6 Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_round.png b/app/src/main/res/mipmap-mdpi/ic_launcher_round.png new file mode 100644 index 00000000..115a4c76 Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher_round.png differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 00000000..dcd3cd80 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png new file mode 100644 index 00000000..459ca609 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 00000000..8ca12fe0 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png new file mode 100644 index 00000000..8e19b410 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 00000000..b824ebdd Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png new file mode 100644 index 00000000..4c19a13c Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png differ diff --git a/app/src/main/res/navigation/nav_graph_main.xml b/app/src/main/res/navigation/nav_graph_main.xml new file mode 100644 index 00000000..7fea3d84 --- /dev/null +++ b/app/src/main/res/navigation/nav_graph_main.xml @@ -0,0 +1,7 @@ + + + + + + diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml new file mode 100644 index 00000000..d442e10e --- /dev/null +++ b/app/src/main/res/values/colors.xml @@ -0,0 +1,7 @@ + + + + @color/blue_free_speech + @color/blue_tory + @color/red_violet + diff --git a/app/src/main/res/values/colors_pallete.xml b/app/src/main/res/values/colors_pallete.xml new file mode 100644 index 00000000..1e3f7b65 --- /dev/null +++ b/app/src/main/res/values/colors_pallete.xml @@ -0,0 +1,7 @@ + + + + #3F51B5 + #303F9F + #FF4081 + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml new file mode 100644 index 00000000..ac01adf6 --- /dev/null +++ b/app/src/main/res/values/strings.xml @@ -0,0 +1,15 @@ + + + Cryto Compose + Unexpected error + + Trade Now and Get\nYour Life + My Coins 😎 + see all + + Total Coins + Today\'s Profit + + $%s + %1$s%% + diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml new file mode 100644 index 00000000..0eb88fe3 --- /dev/null +++ b/app/src/main/res/values/styles.xml @@ -0,0 +1,11 @@ + + + + + + diff --git a/app/src/main/res/xml/network_security_config.xml b/app/src/main/res/xml/network_security_config.xml new file mode 100644 index 00000000..04ead227 --- /dev/null +++ b/app/src/main/res/xml/network_security_config.xml @@ -0,0 +1 @@ + diff --git a/app/src/production/google-services.json b/app/src/production/google-services.json new file mode 100644 index 00000000..e8e97124 --- /dev/null +++ b/app/src/production/google-services.json @@ -0,0 +1,68 @@ +{ + "project_info": { + "project_number": "815978434764", + "project_id": "jetpack-compose-crypto", + "storage_bucket": "jetpack-compose-crypto.appspot.com" + }, + "client": [ + { + "client_info": { + "mobilesdk_app_id": "1:815978434764:android:c2d6ee0f2a14357ffc7269", + "android_client_info": { + "package_name": "co.nimblehq.compose.crypto" + } + }, + "oauth_client": [ + { + "client_id": "815978434764-h4p3pe9e9198t33ghiv2ptp99b0nqko5.apps.googleusercontent.com", + "client_type": 3 + } + ], + "api_key": [ + { + "current_key": "AIzaSyBPYTu8leHXVj8iqmnrSyMllEm0Ckbp8t4" + } + ], + "services": { + "appinvite_service": { + "other_platform_oauth_client": [ + { + "client_id": "815978434764-h4p3pe9e9198t33ghiv2ptp99b0nqko5.apps.googleusercontent.com", + "client_type": 3 + } + ] + } + } + }, + { + "client_info": { + "mobilesdk_app_id": "1:815978434764:android:b009466b8c08fa18fc7269", + "android_client_info": { + "package_name": "co.nimblehq.compose.crypto.staging" + } + }, + "oauth_client": [ + { + "client_id": "815978434764-h4p3pe9e9198t33ghiv2ptp99b0nqko5.apps.googleusercontent.com", + "client_type": 3 + } + ], + "api_key": [ + { + "current_key": "AIzaSyBPYTu8leHXVj8iqmnrSyMllEm0Ckbp8t4" + } + ], + "services": { + "appinvite_service": { + "other_platform_oauth_client": [ + { + "client_id": "815978434764-h4p3pe9e9198t33ghiv2ptp99b0nqko5.apps.googleusercontent.com", + "client_type": 3 + } + ] + } + } + } + ], + "configuration_version": "1" +} \ No newline at end of file diff --git a/app/src/staging/google-services.json b/app/src/staging/google-services.json new file mode 100644 index 00000000..1fa69d0c --- /dev/null +++ b/app/src/staging/google-services.json @@ -0,0 +1,39 @@ +{ + "project_info": { + "project_number": "815978434764", + "project_id": "jetpack-compose-crypto", + "storage_bucket": "jetpack-compose-crypto.appspot.com" + }, + "client": [ + { + "client_info": { + "mobilesdk_app_id": "1:815978434764:android:b009466b8c08fa18fc7269", + "android_client_info": { + "package_name": "co.nimblehq.compose.crypto.staging" + } + }, + "oauth_client": [ + { + "client_id": "815978434764-h4p3pe9e9198t33ghiv2ptp99b0nqko5.apps.googleusercontent.com", + "client_type": 3 + } + ], + "api_key": [ + { + "current_key": "AIzaSyBPYTu8leHXVj8iqmnrSyMllEm0Ckbp8t4" + } + ], + "services": { + "appinvite_service": { + "other_platform_oauth_client": [ + { + "client_id": "815978434764-h4p3pe9e9198t33ghiv2ptp99b0nqko5.apps.googleusercontent.com", + "client_type": 3 + } + ] + } + } + } + ], + "configuration_version": "1" +} diff --git a/app/src/staging/res/values/strings.xml b/app/src/staging/res/values/strings.xml new file mode 100644 index 00000000..0111ad80 --- /dev/null +++ b/app/src/staging/res/values/strings.xml @@ -0,0 +1,3 @@ + + Cryto Compose - Staging + diff --git a/app/src/staging/res/xml/network_security_config.xml b/app/src/staging/res/xml/network_security_config.xml new file mode 100644 index 00000000..2a99cd31 --- /dev/null +++ b/app/src/staging/res/xml/network_security_config.xml @@ -0,0 +1,18 @@ + + + + + + + + + + + + www.jsonplaceholder.typicode.com + + + + + + diff --git a/app/src/test/java/co/nimblehq/compose/crypto/test/ViewModelExt.kt b/app/src/test/java/co/nimblehq/compose/crypto/test/ViewModelExt.kt new file mode 100644 index 00000000..3fadbadc --- /dev/null +++ b/app/src/test/java/co/nimblehq/compose/crypto/test/ViewModelExt.kt @@ -0,0 +1,21 @@ +package co.nimblehq.compose.crypto.test + +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModel +import co.nimblehq.compose.crypto.extension.OverridableLazy +import kotlin.reflect.KProperty1 +import kotlin.reflect.full.memberProperties +import kotlin.reflect.jvm.isAccessible + +fun T.replace( + viewModelDelegate: KProperty1, + viewModel: VM +) { + viewModelDelegate.isAccessible = true + (viewModelDelegate.getDelegate(this) as OverridableLazy).implementation = lazy { viewModel } +} + +inline fun T.getPrivateProperty(name: String): KProperty1 = + T::class + .memberProperties + .first { it.name == name } as KProperty1 diff --git a/app/src/test/resources/robolectric.properties b/app/src/test/resources/robolectric.properties new file mode 100644 index 00000000..1a7a2e6e --- /dev/null +++ b/app/src/test/resources/robolectric.properties @@ -0,0 +1,2 @@ +sdk=28 +application=dagger.hilt.android.testing.HiltTestApplication diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 00000000..2f227aad --- /dev/null +++ b/build.gradle.kts @@ -0,0 +1,68 @@ +// Top-level build file where you can add configuration options common to all sub-projects/modules. +buildscript { + repositories { + google() + mavenCentral() + } + + dependencies { + classpath("androidx.navigation:navigation-safe-args-gradle-plugin:${Versions.ANDROIDX_NAVIGATION_VERSION}") + classpath("com.android.tools.build:gradle:${Versions.BUILD_GRADLE_VERSION}") + classpath("com.google.dagger:hilt-android-gradle-plugin:${Versions.HILT_VERSION}") + classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:${Versions.KOTLIN_VERSION}") + classpath("com.google.gms:google-services:${Versions.GOOGLE_SERVICE_VERSION}") + } +} + +plugins { + id("io.gitlab.arturbosch.detekt").version(Versions.DETEKT_VERSION) +} + +allprojects { + repositories { + google() + maven(url = "https://jitpack.io") + mavenCentral() + } +} + +tasks.register("clean", Delete::class) { + delete(rootProject.buildDir) +} + +tasks.withType().configureEach { + // Target version of the generated JVM bytecode. It is used for type resolution. + jvmTarget = JavaVersion.VERSION_1_8.toString() +} + +detekt { + toolVersion = Versions.DETEKT_VERSION + + source = files( + "app/src/main/java", + "data/src/main/java", + "domain/src/main/java", + "buildSrc/src/main/java" + ) + parallel = false + config = files("detekt-config.yml") + buildUponDefaultConfig = false + disableDefaultRuleSets = false + + debug = false + ignoreFailures = false + + ignoredBuildTypes = listOf("release") + ignoredFlavors = listOf("production") + + reports { + xml { + enabled = true + destination = file("build/reports/detekt.xml") + } + html { + enabled = true + destination = file("build/reports/detekt.html") + } + } +} diff --git a/buildSrc/.gitignore b/buildSrc/.gitignore new file mode 100644 index 00000000..796b96d1 --- /dev/null +++ b/buildSrc/.gitignore @@ -0,0 +1 @@ +/build diff --git a/buildSrc/build.gradle.kts b/buildSrc/build.gradle.kts new file mode 100644 index 00000000..181a9870 --- /dev/null +++ b/buildSrc/build.gradle.kts @@ -0,0 +1,9 @@ +import org.gradle.kotlin.dsl.`kotlin-dsl` + +plugins { + `kotlin-dsl` +} + +repositories { + mavenCentral() +} diff --git a/buildSrc/src/main/java/Configurations.kt b/buildSrc/src/main/java/Configurations.kt new file mode 100644 index 00000000..51b3dee4 --- /dev/null +++ b/buildSrc/src/main/java/Configurations.kt @@ -0,0 +1,15 @@ +object Flavor { + const val PRODUCTION = "production" + const val STAGING = "staging" + const val DIMENSIONS = "stage" +} + +object BuildType { + const val DEBUG = "debug" + const val RELEASE = "release" +} + +object Module { + const val DATA = ":data" + const val DOMAIN = ":domain" +} diff --git a/buildSrc/src/main/java/FileExt.kt b/buildSrc/src/main/java/FileExt.kt new file mode 100644 index 00000000..e52f52d1 --- /dev/null +++ b/buildSrc/src/main/java/FileExt.kt @@ -0,0 +1,12 @@ +import java.io.File +import java.util.* + +fun File.loadGradleProperties(fileName: String): Properties { + val properties = Properties() + val signingProperties = File(this, fileName) + + if (signingProperties.isFile) { + properties.load(signingProperties.inputStream()) + } + return properties +} diff --git a/buildSrc/src/main/java/Versions.kt b/buildSrc/src/main/java/Versions.kt new file mode 100644 index 00000000..593758f8 --- /dev/null +++ b/buildSrc/src/main/java/Versions.kt @@ -0,0 +1,56 @@ +object Versions { + const val BUILD_GRADLE_VERSION = "4.2.1" + + const val ANDROID_COMPILE_SDK_VERSION = 30 + const val ANDROID_MIN_SDK_VERSION = 23 + const val ANDROID_TARGET_SDK_VERSION = 30 + + const val ANDROID_VERSION_CODE = 1 + const val ANDROID_VERSION_NAME = "3.9.0" + + // Dependencies (Alphabet sorted) + const val ANDROID_COMMON_KTX_VERSION = "0.1.1" + const val ANDROID_CRYPTO_VERSION = "1.0.0" + const val ANDROIDX_ACTIVITY_KTX_VERSION = "1.2.1" + const val ANDROIDX_CORE_KTX_VERSION = "1.3.0" + const val ANDROIDX_FRAGMENT_VERSION = "1.3.3" + const val ANDROIDX_LIFECYCLE_VERSION = "2.4.0-alpha02" + const val ANDROIDX_NAVIGATION_VERSION = "2.3.4" + const val ANDROIDX_SUPPORT_VERSION = "1.3.0" + + const val COMPOSE_VERSION = "1.0.2" + const val COMPOSE_ACTIVITY_VERSION = "1.3.1" + const val COMPOSE_CONSTRAINT_LAYOUT_VERSION = "1.0.1" + const val CONSTRAINT_LAYOUT_VERSION = "2.0.0-alpha3" + + const val FIREBASE_BOM_VERSION = "30.3.0" + + const val GOOGLE_SERVICE_VERSION = "4.3.13" + + const val HILT_VERSION = "2.38.1" + + const val JAVAX_INJECT_VERSION = "1" + const val JACOCO_VERSION = "0.8.7" + + const val KOTLIN_REFLECT_VERSION = "1.5.10" + const val KOTLIN_VERSION = "1.5.21" + const val KOTLINX_COROUTINES_VERSION = "1.5.0" + + const val MOSHI_VERSION = "1.12.0" + + const val OKHTTP_VERSION = "4.9.1" + const val RETROFIT_VERSION = "2.9.0" + + const val TIMBER_LOG_VERSION = "4.7.1" + + // Configuration + const val DETEKT_VERSION = "1.20.0" + + // Testing libraries + const val TEST_JUNIT_ANDROIDX_EXT_VERSION = "1.1.2" + const val TEST_JUNIT_VERSION = "4.13.2" + const val TEST_KOTEST_VERSION = "4.6.3" + const val TEST_MOCKK_VERSION = "1.10.6" + const val TEST_ROBOLECTRIC_VERSION = "4.3.1" + const val TEST_RUNNER_VERSION = "1.3.0" +} diff --git a/buildSrc/src/main/java/plugins/jacoco-report.gradle.kts b/buildSrc/src/main/java/plugins/jacoco-report.gradle.kts new file mode 100644 index 00000000..d4e32fb8 --- /dev/null +++ b/buildSrc/src/main/java/plugins/jacoco-report.gradle.kts @@ -0,0 +1,142 @@ +package plugins + +plugins { + jacoco +} + +jacoco { + toolVersion = Versions.JACOCO_VERSION +} + +val fileGenerated = setOf( + "**/R.class", + "**/R\$*.class", + "**/*\$ViewBinder*.*", + "**/*\$InjectAdapter*.*", + "**/*Injector*.*", + "**/BuildConfig.*", + "**/Manifest*.*", + "**/*_ViewBinding*.*", + "**/*Adapter*.*", + "**/*Test*.*", + // Enum + "**/*\$Creator*", + // Nav Component + "**/*_Factory*", + "**/*FragmentArgs*", + "**/*FragmentDirections*", + "**/FragmentNavArgsLazy.kt", + "**/*Fragment*navArgs*", + "**/*ModuleDeps*.*", + "**/*NavGraphDirections*", + // Hilt + "**/*_ComponentTreeDeps*", + "**/*_HiltComponents*", + "**/*_HiltModules*", + "**/Hilt_*" +) + +val packagesExcluded = setOf( + "**/com/bumptech/glide", + "**/dagger/hilt/internal", + "**/hilt_aggregated_deps", + "**/co/nimblehq/coroutine/databinding/**", + "**/co/nimblehq/coroutine/di/**" +) + +val fileFilter = fileGenerated + packagesExcluded + +val classDirectoriesTree = files( + fileTree(project.rootDir) { + include( + "**/app/build/intermediates/javac/stagingDebug/classes/**", + "**/data/build/intermediates/javac/debug/classes/**", + "**/app/build/tmp/kotlin-classes/stagingDebug/**", + "**/data/build/tmp/kotlin-classes/debug/**", + "**/domain/build/classes/kotlin/main/**" + ) + exclude(fileFilter) + } +) + +val sourceDirectoriesTree = files( + listOf( + "${project.rootDir}/app/src/main/java", + "${project.rootDir}/data/src/main/java", + "${project.rootDir}/domain/src/main/java" + ) +) + +/** + * Once enabled [testCoverageEnabled], Jacoco will capture the coverage and store them in + * [${project.module}/jacoco.exec]. We need to add all [jacoco.exec] to here. + * [${project.module}/build/jacoco/testFlavorDebugTest.exec] won't have the result anymore, so we + * can safety get rid of them. + * Reference: https://stackoverflow.com/a/67626100/5187859 + * Issue tracker 1: https://issuetracker.google.com/issues/171125857#comment20 + * Issue tracker 2: https://issuetracker.google.com/issues/195860510 + */ +val executionDataTree = fileTree(project.rootDir) { + include( + "app/jacoco.exec", + "data/jacoco.exec", + "domain/build/jacoco/test.exec" + ) +} + +tasks.register("jacocoTestReport") { + group = "Reporting" + description = "Generate Jacoco coverage reports for Debug build" + + dependsOn( + ":app:testStagingDebugUnitTest", + ":data:testDebugUnitTest", + ":domain:test" + ) + + classDirectories.setFrom(classDirectoriesTree) + sourceDirectories.setFrom(sourceDirectoriesTree) + executionData.setFrom(executionDataTree) + + reports { + xml.isEnabled = true + html.isEnabled = true + csv.isEnabled = false + } +} + +tasks.withType { + configure { + isIncludeNoLocationClasses = true + /* + * From AGP 4.2, JDK 11 is now bundled, but Jacoco is running on JDK 8. It causes the + * build failed because of the missing of some classes that do not exist on JDK 8 but + * JDK 11. We need to exclude that classes temporarily until Jacoco supports running + * on JDK 11. + * Android Gradle Plugin 4.2.0 release note: https://developer.android.com/studio/releases#4.2-bundled-jdk-11 + * Reference: https://stackoverflow.com/a/68739364/5187859 + */ + excludes = listOf("jdk.internal.*") + } +} + +/** + * Workaround to bypass "Caused by: java.lang.IllegalStateException: + * Cannot process instrumented class... + * Please supply original non-instrumented classes." issue. + * + * Application projects that depend on variants of libraries that have test coverage enabled will + * still not work as app code should be instrumented on the fly, while library code should not be. + * https://issuetracker.google.com/issues/171125857#comment26 + */ +tasks.withType().configureEach { + configure { + includes = listOf("com.application.*") // include only application classes + } +} + +tasks.withType { + testLogging { + events("passed", "skipped", "failed") + } +} diff --git a/config/debug.keystore b/config/debug.keystore new file mode 100644 index 00000000..930eba40 Binary files /dev/null and b/config/debug.keystore differ diff --git a/data/.gitignore b/data/.gitignore new file mode 100644 index 00000000..796b96d1 --- /dev/null +++ b/data/.gitignore @@ -0,0 +1 @@ +/build diff --git a/data/build.gradle.kts b/data/build.gradle.kts new file mode 100644 index 00000000..88f21ba9 --- /dev/null +++ b/data/build.gradle.kts @@ -0,0 +1,81 @@ +plugins { + id("com.android.library") + id("kotlin-android") + + id("plugins.jacoco-report") +} + +android { + compileSdk = Versions.ANDROID_COMPILE_SDK_VERSION + defaultConfig { + minSdk = Versions.ANDROID_MIN_SDK_VERSION + targetSdk = Versions.ANDROID_TARGET_SDK_VERSION + versionCode = Versions.ANDROID_VERSION_CODE + versionName = Versions.ANDROID_VERSION_NAME + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + consumerProguardFiles("consumer-rules.pro") + } + + buildTypes { + getByName(BuildType.RELEASE) { + isMinifyEnabled = true + proguardFiles( + getDefaultProguardFile("proguard-android.txt"), "proguard-rules.pro" + ) + } + + getByName(BuildType.DEBUG) { + isMinifyEnabled = false + /** + * From AGP 4.2.0, Jacoco generates the report incorrectly, and the report is missing + * some code coverage from module. On the new version of Gradle, they introduce a new + * flag [testCoverageEnabled], we must enable this flag if using Jacoco to capture + * coverage and creates a report in the build directory. + * Reference: https://developer.android.com/reference/tools/gradle-api/7.1/com/android/build/api/dsl/BuildType#istestcoverageenabled + */ + isTestCoverageEnabled = true + } + } + + compileOptions { + targetCompatibility = JavaVersion.VERSION_1_8 + sourceCompatibility = JavaVersion.VERSION_1_8 + } + + kotlinOptions { + jvmTarget = JavaVersion.VERSION_1_8.toString() + } + + lintOptions { + isCheckDependencies = true + xmlReport = true + xmlOutput = file("build/reports/lint/lint-result.xml") + } +} + +dependencies { + implementation(project(Module.DOMAIN)) + + implementation("androidx.core:core-ktx:${Versions.ANDROIDX_CORE_KTX_VERSION}") + implementation("androidx.security:security-crypto:${Versions.ANDROID_CRYPTO_VERSION}") + implementation("com.google.dagger:hilt-android:${Versions.HILT_VERSION}") + implementation("com.squareup.moshi:moshi:${Versions.MOSHI_VERSION}") + implementation("org.jetbrains.kotlin:kotlin-stdlib:${Versions.KOTLIN_VERSION}") + implementation("javax.inject:javax.inject:${Versions.JAVAX_INJECT_VERSION}") + + api("com.squareup.retrofit2:converter-moshi:${Versions.RETROFIT_VERSION}") + api("com.squareup.retrofit2:retrofit:${Versions.RETROFIT_VERSION}") + + api("com.squareup.moshi:moshi-adapters:${Versions.MOSHI_VERSION}") + api("com.squareup.moshi:moshi-kotlin:${Versions.MOSHI_VERSION}") + + api("com.squareup.okhttp3:okhttp:${Versions.OKHTTP_VERSION}") + api("com.squareup.okhttp3:logging-interceptor:${Versions.OKHTTP_VERSION}") + + // Testing + testImplementation("junit:junit:${Versions.TEST_JUNIT_VERSION}") + testImplementation("io.mockk:mockk:${Versions.TEST_MOCKK_VERSION}") + testImplementation("io.kotest:kotest-assertions-core:${Versions.TEST_KOTEST_VERSION}") + testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:${Versions.KOTLINX_COROUTINES_VERSION}") +} diff --git a/data/proguard-rules.pro b/data/proguard-rules.pro new file mode 100644 index 00000000..2f9dc5a4 --- /dev/null +++ b/data/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle.kts. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile diff --git a/data/src/main/AndroidManifest.xml b/data/src/main/AndroidManifest.xml new file mode 100644 index 00000000..3897cc6e --- /dev/null +++ b/data/src/main/AndroidManifest.xml @@ -0,0 +1,2 @@ + + diff --git a/data/src/main/java/co/nimblehq/compose/crypto/data/service/ApiService.kt b/data/src/main/java/co/nimblehq/compose/crypto/data/service/ApiService.kt new file mode 100644 index 00000000..283a83e5 --- /dev/null +++ b/data/src/main/java/co/nimblehq/compose/crypto/data/service/ApiService.kt @@ -0,0 +1,5 @@ +package co.nimblehq.compose.crypto.data.service + +interface ApiService { + // TODO Add API Service +} diff --git a/data/src/main/java/co/nimblehq/compose/crypto/data/service/providers/ApiServiceProvider.kt b/data/src/main/java/co/nimblehq/compose/crypto/data/service/providers/ApiServiceProvider.kt new file mode 100644 index 00000000..b2958907 --- /dev/null +++ b/data/src/main/java/co/nimblehq/compose/crypto/data/service/providers/ApiServiceProvider.kt @@ -0,0 +1,11 @@ +package co.nimblehq.compose.crypto.data.service.providers + +import co.nimblehq.compose.crypto.data.service.ApiService +import retrofit2.Retrofit + +object ApiServiceProvider { + + fun getApiService(retrofit: Retrofit): ApiService { + return retrofit.create(ApiService::class.java) + } +} diff --git a/data/src/main/java/co/nimblehq/compose/crypto/data/service/providers/ConverterFactoryProvider.kt b/data/src/main/java/co/nimblehq/compose/crypto/data/service/providers/ConverterFactoryProvider.kt new file mode 100644 index 00000000..f85bfd5a --- /dev/null +++ b/data/src/main/java/co/nimblehq/compose/crypto/data/service/providers/ConverterFactoryProvider.kt @@ -0,0 +1,12 @@ +package co.nimblehq.compose.crypto.data.service.providers + +import com.squareup.moshi.Moshi +import retrofit2.Converter +import retrofit2.converter.moshi.MoshiConverterFactory + +object ConverterFactoryProvider { + + fun getMoshiConverterFactory(moshi: Moshi): Converter.Factory { + return MoshiConverterFactory.create(moshi) + } +} diff --git a/data/src/main/java/co/nimblehq/compose/crypto/data/service/providers/MoshiBuilderProvider.kt b/data/src/main/java/co/nimblehq/compose/crypto/data/service/providers/MoshiBuilderProvider.kt new file mode 100644 index 00000000..c1087c39 --- /dev/null +++ b/data/src/main/java/co/nimblehq/compose/crypto/data/service/providers/MoshiBuilderProvider.kt @@ -0,0 +1,16 @@ +package co.nimblehq.compose.crypto.data.service.providers + +import com.squareup.moshi.Moshi +import com.squareup.moshi.adapters.Rfc3339DateJsonAdapter +import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory +import java.util.* + +object MoshiBuilderProvider { + + val moshiBuilder: Moshi.Builder + get() = Moshi.Builder() + // Parse the DateTime in this format: [yyyy-MM-ddThh:mm:ss.ssZ] + // e.g: [2019-10-12T07:20:50.52Z] + .add(Date::class.java, Rfc3339DateJsonAdapter()) + .add(KotlinJsonAdapterFactory()) +} diff --git a/data/src/main/java/co/nimblehq/compose/crypto/data/service/providers/RetrofitProvider.kt b/data/src/main/java/co/nimblehq/compose/crypto/data/service/providers/RetrofitProvider.kt new file mode 100644 index 00000000..687e00ad --- /dev/null +++ b/data/src/main/java/co/nimblehq/compose/crypto/data/service/providers/RetrofitProvider.kt @@ -0,0 +1,19 @@ +package co.nimblehq.compose.crypto.data.service.providers + +import okhttp3.OkHttpClient +import retrofit2.Converter +import retrofit2.Retrofit + +object RetrofitProvider { + + fun getRetrofitBuilder( + baseUrl: String, + okHttpClient: OkHttpClient, + converterFactory: Converter.Factory, + ): Retrofit.Builder { + return Retrofit.Builder() + .baseUrl(baseUrl) + .addConverterFactory(converterFactory) + .client(okHttpClient) + } +} diff --git a/data/src/main/java/co/nimblehq/compose/crypto/data/storage/BaseSharedPreferences.kt b/data/src/main/java/co/nimblehq/compose/crypto/data/storage/BaseSharedPreferences.kt new file mode 100644 index 00000000..2030d754 --- /dev/null +++ b/data/src/main/java/co/nimblehq/compose/crypto/data/storage/BaseSharedPreferences.kt @@ -0,0 +1,42 @@ +package co.nimblehq.compose.crypto.data.storage + +import android.content.SharedPreferences + +abstract class BaseSharedPreferences { + + protected lateinit var sharedPreferences: SharedPreferences + + protected inline fun get(key: String): T? = + if (sharedPreferences.contains(key)) { + when (T::class) { + Boolean::class -> sharedPreferences.getBoolean(key, false) as T? + String::class -> sharedPreferences.getString(key, null) as T? + Float::class -> sharedPreferences.getFloat(key, 0f) as T? + Int::class -> sharedPreferences.getInt(key, 0) as T? + Long::class -> sharedPreferences.getLong(key, 0L) as T? + else -> null + } + } else { + null + } + + protected fun set(key: String, value: T) { + sharedPreferences.execute { + when (value) { + is Boolean -> it.putBoolean(key, value) + is String -> it.putString(key, value) + is Float -> it.putFloat(key, value) + is Long -> it.putLong(key, value) + is Int -> it.putInt(key, value) + } + } + } + + protected fun remove(key: String) { + sharedPreferences.execute { it.remove(key) } + } + + protected fun clearAll() { + sharedPreferences.execute { it.clear() } + } +} diff --git a/data/src/main/java/co/nimblehq/compose/crypto/data/storage/EncryptedSharedPreferences.kt b/data/src/main/java/co/nimblehq/compose/crypto/data/storage/EncryptedSharedPreferences.kt new file mode 100644 index 00000000..eaabbf41 --- /dev/null +++ b/data/src/main/java/co/nimblehq/compose/crypto/data/storage/EncryptedSharedPreferences.kt @@ -0,0 +1,23 @@ +package co.nimblehq.compose.crypto.data.storage + +import android.content.Context +import androidx.security.crypto.EncryptedSharedPreferences +import androidx.security.crypto.MasterKeys +import javax.inject.Inject + +private const val APP_SECRET_SHARED_PREFS = "app_secret_shared_prefs" + +class EncryptedSharedPreferences @Inject constructor(applicationContext: Context) : + BaseSharedPreferences() { + + init { + val masterKey = MasterKeys.getOrCreate(MasterKeys.AES256_GCM_SPEC) + sharedPreferences = EncryptedSharedPreferences.create( + APP_SECRET_SHARED_PREFS, + masterKey, + applicationContext, + EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, + EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM + ) + } +} diff --git a/data/src/main/java/co/nimblehq/compose/crypto/data/storage/NormalSharedPreferences.kt b/data/src/main/java/co/nimblehq/compose/crypto/data/storage/NormalSharedPreferences.kt new file mode 100644 index 00000000..3f765595 --- /dev/null +++ b/data/src/main/java/co/nimblehq/compose/crypto/data/storage/NormalSharedPreferences.kt @@ -0,0 +1,24 @@ +package co.nimblehq.compose.crypto.data.storage + +import android.content.Context +import android.content.SharedPreferences +import javax.inject.Inject + +class NormalSharedPreferences @Inject constructor( + private val applicationContext: Context, + private val defaultSharedPreferences: SharedPreferences +) : BaseSharedPreferences() { + + init { + useDefaultSharedPreferences() + } + + fun useDefaultSharedPreferences() { + sharedPreferences = defaultSharedPreferences + } + + // Use this function for creating a custom sharedPreferences if needed + fun useCustomSharedPreferences(name: String) { + sharedPreferences = applicationContext.getSharedPreferences(name, Context.MODE_PRIVATE) + } +} diff --git a/data/src/main/java/co/nimblehq/compose/crypto/data/storage/SharedPreferencesExt.kt b/data/src/main/java/co/nimblehq/compose/crypto/data/storage/SharedPreferencesExt.kt new file mode 100644 index 00000000..58a3a464 --- /dev/null +++ b/data/src/main/java/co/nimblehq/compose/crypto/data/storage/SharedPreferencesExt.kt @@ -0,0 +1,10 @@ +package co.nimblehq.compose.crypto.data.storage + +import android.content.SharedPreferences + +fun SharedPreferences.execute(operation: (SharedPreferences.Editor) -> Unit) { + with(edit()) { + operation(this) + apply() + } +} diff --git a/detekt-config.yml b/detekt-config.yml new file mode 100644 index 00000000..1b0b030d --- /dev/null +++ b/detekt-config.yml @@ -0,0 +1,345 @@ +build: + maxIssues: 10 + weights: + complexity: 2 + formatting: 1 + LongParameterList: 1 + comments: 1 + +processors: + active: true + exclude: + # - 'FunctionCountProcessor' + # - 'PropertyCountProcessor' + # - 'ClassCountProcessor' + # - 'PackageCountProcessor' + # - 'KtFileCountProcessor' + +console-reports: + active: true + exclude: + # - 'ProjectStatisticsReport' + # - 'ComplexityReport' + # - 'NotificationReport' + # - 'FindingsReport' + # - 'BuildFailureReport' + +output-reports: + active: true + exclude: + # - 'PlainOutputReport' + # - 'XmlOutputReport' + +comments: + active: true + CommentOverPrivateFunction: + active: false + CommentOverPrivateProperty: + active: false + EndOfSentenceFormat: + active: false + endOfSentenceFormat: ([.?!][ \t\n\r\f<])|([.?!]$) + UndocumentedPublicClass: + active: false + searchInNestedClass: true + searchInInnerClass: true + searchInInnerObject: true + searchInInnerInterface: true + UndocumentedPublicFunction: + active: false + +complexity: + active: true + ComplexCondition: + active: true + threshold: 3 + ComplexInterface: + active: false + threshold: 10 + includeStaticDeclarations: false + ComplexMethod: + active: true + threshold: 10 + LabeledExpression: + active: false + LargeClass: + active: true + threshold: 150 + LongMethod: + active: true + threshold: 20 + LongParameterList: + active: true + threshold: 5 + ignoreDefaultParameters: false + MethodOverloading: + active: false + threshold: 5 + NestedBlockDepth: + active: true + threshold: 3 + StringLiteralDuplication: + active: false + threshold: 2 + ignoreAnnotation: true + excludeStringsWithLessThan5Characters: true + ignoreStringsRegex: '$^' + TooManyFunctions: + active: true + thresholdInFiles: 10 + thresholdInClasses: 10 + thresholdInInterfaces: 10 + thresholdInObjects: 10 + thresholdInEnums: 10 + +empty-blocks: + active: true + EmptyCatchBlock: + active: true + EmptyClassBlock: + active: true + EmptyDefaultConstructor: + active: true + EmptyDoWhileBlock: + active: true + EmptyElseBlock: + active: true + EmptyFinallyBlock: + active: true + EmptyForBlock: + active: true + EmptyFunctionBlock: + active: false + EmptyIfBlock: + active: true + EmptyInitBlock: + active: true + EmptyKtFile: + active: true + EmptySecondaryConstructor: + active: true + EmptyWhenBlock: + active: true + EmptyWhileBlock: + active: true + +exceptions: + active: true + ExceptionRaisedInUnexpectedLocation: + active: false + methodNames: 'toString,hashCode,equals,finalize' + InstanceOfCheckForException: + active: false + NotImplementedDeclaration: + active: false + PrintStackTrace: + active: false + RethrowCaughtException: + active: false + ReturnFromFinally: + active: false + SwallowedException: + active: false + ThrowingExceptionFromFinally: + active: false + ThrowingExceptionInMain: + active: false + ThrowingExceptionsWithoutMessageOrCause: + active: false + exceptions: 'IllegalArgumentException,IllegalStateException,IOException' + ThrowingNewInstanceOfSameException: + active: false + TooGenericExceptionCaught: + active: true + exceptionNames: + - ArrayIndexOutOfBoundsException + - Error + - Exception + - IllegalMonitorStateException + - NullPointerException + - IndexOutOfBoundsException + - RuntimeException + - Throwable + TooGenericExceptionThrown: + active: true + exceptionNames: + - Error + - Exception + - NullPointerException + - Throwable + - RuntimeException + +naming: + active: true + ClassNaming: + active: true + classPattern: '[A-Z$][a-zA-Z0-9$]*' + EnumNaming: + active: true + enumEntryPattern: '^[A-Z$][a-zA-Z_$]*$' + ForbiddenClassName: + active: false + forbiddenName: '' + FunctionMaxLength: + active: false + maximumFunctionNameLength: 30 + FunctionMinLength: + active: false + minimumFunctionNameLength: 3 + FunctionNaming: + active: true + functionPattern: '^([a-z$][a-zA-Z$0-9]*)|(`.*`)$' + MatchingDeclarationName: + active: true + MemberNameEqualsClassName: + active: false + ignoreOverriddenFunction: true + ObjectPropertyNaming: + active: true + propertyPattern: '[A-Za-z][_A-Za-z0-9]*' + PackageNaming: + active: true + packagePattern: '^[a-z]+(\.[a-z][a-z0-9]*)*$' + TopLevelPropertyNaming: + active: true + constantPattern: '[A-Z][_A-Z0-9]*' + propertyPattern: '[a-z][A-Za-z\d]*' + privatePropertyPattern: '(_)?[a-z][A-Za-z0-9]*' + VariableMaxLength: + active: false + maximumVariableNameLength: 64 + VariableMinLength: + active: false + minimumVariableNameLength: 1 + VariableNaming: + active: true + variablePattern: '(_)?[a-z][A-Za-z0-9]*' + privateVariablePattern: '(_)?[a-z][A-Za-z0-9]*' + +performance: + active: true + ForEachOnRange: + active: true + SpreadOperator: + active: true + UnnecessaryTemporaryInstantiation: + active: true + +potential-bugs: + active: true + DuplicateCaseInWhenExpression: + active: true + EqualsAlwaysReturnsTrueOrFalse: + active: false + EqualsWithHashCodeExist: + active: true + ExplicitGarbageCollectionCall: + active: true + InvalidRange: + active: false + IteratorHasNextCallsNextMethod: + active: false + IteratorNotThrowingNoSuchElementException: + active: false + LateinitUsage: + active: false + excludeAnnotatedProperties: "" + ignoreOnClassesPattern: "" + UnconditionalJumpStatementInLoop: + active: false + UnreachableCode: + active: true + UnsafeCallOnNullableType: + active: false + UnsafeCast: + active: false + UselessPostfixExpression: + active: false + WrongEqualsTypeParameter: + active: false + +style: + active: true + CollapsibleIfStatements: + active: false + DataClassContainsFunctions: + active: false + conversionFunctionPrefix: 'to' + EqualsNullCall: + active: false + ExpressionBodySyntax: + active: false + ForbiddenComment: + active: true + values: 'FIXME:,STOPSHIP:' + ForbiddenImport: + active: false + imports: '' + FunctionOnlyReturningConstant: + active: false + ignoreOverridableFunction: true + excludedFunctions: 'describeContents' + LoopWithTooManyJumpStatements: + active: false + maxJumpCount: 1 + MagicNumber: + active: true + ignoreNumbers: '-1,0,1,2' + ignoreHashCodeFunction: false + ignorePropertyDeclaration: false + ignoreConstantDeclaration: true + ignoreCompanionObjectPropertyDeclaration: true + ignoreAnnotation: false + ignoreNamedArgument: true + ignoreEnums: false + MaxLineLength: + active: true + maxLineLength: 120 + excludePackageStatements: false + excludeImportStatements: false + ModifierOrder: + active: true + NestedClassesVisibility: + active: false + NewLineAtEndOfFile: + active: false + OptionalAbstractKeyword: + active: true + OptionalUnit: + active: false + OptionalWhenBraces: + active: false + ProtectedMemberInFinalClass: + active: false + RedundantVisibilityModifierRule: + active: false + ReturnCount: + active: true + max: 2 + excludedFunctions: "equals" + SafeCast: + active: true + SerialVersionUIDInSerializableClass: + active: false + SpacingBetweenPackageAndImports: + active: false + ThrowsCount: + active: true + max: 2 + UnnecessaryAbstractClass: + active: false + UnnecessaryInheritance: + active: false + UnnecessaryParentheses: + active: false + UntilInsteadOfRangeTo: + active: false + UnusedImports: + active: false + UseDataClass: + active: false + excludeAnnotatedClasses: "" + UtilityClassWithPublicConstructor: + active: false + WildcardImport: + active: false diff --git a/domain/.gitignore b/domain/.gitignore new file mode 100644 index 00000000..796b96d1 --- /dev/null +++ b/domain/.gitignore @@ -0,0 +1 @@ +/build diff --git a/domain/build.gradle.kts b/domain/build.gradle.kts new file mode 100644 index 00000000..4d57eadf --- /dev/null +++ b/domain/build.gradle.kts @@ -0,0 +1,21 @@ +plugins { + id("java-library") + id("kotlin") + + jacoco +} + +java { + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 +} + +dependencies { + implementation("javax.inject:javax.inject:${Versions.JAVAX_INJECT_VERSION}") + + // Testing + testImplementation("junit:junit:${Versions.TEST_JUNIT_VERSION}") + testImplementation("io.mockk:mockk:${Versions.TEST_MOCKK_VERSION}") + testImplementation("io.kotest:kotest-assertions-core:${Versions.TEST_KOTEST_VERSION}") + testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:${Versions.KOTLINX_COROUTINES_VERSION}") +} diff --git a/domain/src/main/java/co/nimblehq/compose/crypto/domain/usecase/UseCaseResult.kt b/domain/src/main/java/co/nimblehq/compose/crypto/domain/usecase/UseCaseResult.kt new file mode 100644 index 00000000..a81350b9 --- /dev/null +++ b/domain/src/main/java/co/nimblehq/compose/crypto/domain/usecase/UseCaseResult.kt @@ -0,0 +1,6 @@ +package co.nimblehq.compose.crypto.domain.usecase + +sealed class UseCaseResult { + class Success(val data: T) : UseCaseResult() + class Error(val exception: Throwable) : UseCaseResult() +} diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 00000000..0115e9df --- /dev/null +++ b/gradle.properties @@ -0,0 +1,17 @@ +# Project-wide Gradle settings. +# IDE (e.g. Android Studio) users: +# Gradle settings configured through the IDE *will override* +# any settings specified in this file. +# For more details on how to configure your build environment visit +# http://www.gradle.org/docs/current/userguide/build_environment.html +# Specifies the JVM arguments used for the daemon process. +# The setting is particularly useful for tweaking memory settings. +org.gradle.jvmargs=-Xmx1536m +# When configured, Gradle will run in incubating parallel mode. +# This option should only be used with decoupled projects. More details, visit +# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects +# org.gradle.parallel=true +android.enableJetifier=true +android.useAndroidX=true +#https://kotlinlang.org/docs/kapt.html#running-kapt-tasks-in-parallel +kapt.use.worker.api=true diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 00000000..f3d88b1c Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 00000000..9fe8d05d --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,5 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-6.7.1-all.zip diff --git a/gradlew b/gradlew new file mode 100755 index 00000000..2fe81a7d --- /dev/null +++ b/gradlew @@ -0,0 +1,183 @@ +#!/usr/bin/env sh + +# +# Copyright 2015 the original author or authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +############################################################################## +## +## Gradle start up script for UN*X +## +############################################################################## + +# Attempt to set APP_HOME +# Resolve links: $0 may be a link +PRG="$0" +# Need this for relative symlinks. +while [ -h "$PRG" ] ; do + ls=`ls -ld "$PRG"` + link=`expr "$ls" : '.*-> \(.*\)$'` + if expr "$link" : '/.*' > /dev/null; then + PRG="$link" + else + PRG=`dirname "$PRG"`"/$link" + fi +done +SAVED="`pwd`" +cd "`dirname \"$PRG\"`/" >/dev/null +APP_HOME="`pwd -P`" +cd "$SAVED" >/dev/null + +APP_NAME="Gradle" +APP_BASE_NAME=`basename "$0"` + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD="maximum" + +warn () { + echo "$*" +} + +die () { + echo + echo "$*" + echo + exit 1 +} + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "`uname`" in + CYGWIN* ) + cygwin=true + ;; + Darwin* ) + darwin=true + ;; + MINGW* ) + msys=true + ;; + NONSTOP* ) + nonstop=true + ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + else + JAVACMD="$JAVA_HOME/bin/java" + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD="java" + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then + MAX_FD_LIMIT=`ulimit -H -n` + if [ $? -eq 0 ] ; then + if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then + MAX_FD="$MAX_FD_LIMIT" + fi + ulimit -n $MAX_FD + if [ $? -ne 0 ] ; then + warn "Could not set maximum file descriptor limit: $MAX_FD" + fi + else + warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" + fi +fi + +# For Darwin, add options to specify how the application appears in the dock +if $darwin; then + GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" +fi + +# For Cygwin or MSYS, switch paths to Windows format before running java +if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then + APP_HOME=`cygpath --path --mixed "$APP_HOME"` + CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` + JAVACMD=`cygpath --unix "$JAVACMD"` + + # We build the pattern for arguments to be converted via cygpath + ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` + SEP="" + for dir in $ROOTDIRSRAW ; do + ROOTDIRS="$ROOTDIRS$SEP$dir" + SEP="|" + done + OURCYGPATTERN="(^($ROOTDIRS))" + # Add a user-defined pattern to the cygpath arguments + if [ "$GRADLE_CYGPATTERN" != "" ] ; then + OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" + fi + # Now convert the arguments - kludge to limit ourselves to /bin/sh + i=0 + for arg in "$@" ; do + CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` + CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option + + if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition + eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` + else + eval `echo args$i`="\"$arg\"" + fi + i=`expr $i + 1` + done + case $i in + 0) set -- ;; + 1) set -- "$args0" ;; + 2) set -- "$args0" "$args1" ;; + 3) set -- "$args0" "$args1" "$args2" ;; + 4) set -- "$args0" "$args1" "$args2" "$args3" ;; + 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; + 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; + 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; + 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; + 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; + esac +fi + +# Escape application args +save () { + for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done + echo " " +} +APP_ARGS=`save "$@"` + +# Collect all arguments for the java command, following the shell quoting and substitution rules +eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 00000000..9618d8d9 --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,100 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%" == "" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%" == "" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if "%ERRORLEVEL%" == "0" goto init + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto init + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:init +@rem Get command-line arguments, handling Windows variants + +if not "%OS%" == "Windows_NT" goto win9xME_args + +:win9xME_args +@rem Slurp the command line arguments. +set CMD_LINE_ARGS= +set _SKIP=2 + +:win9xME_args_slurp +if "x%~1" == "x" goto execute + +set CMD_LINE_ARGS=%* + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS% + +:end +@rem End local scope for the variables with windows NT shell +if "%ERRORLEVEL%"=="0" goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 +exit /b 1 + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/settings.gradle.kts b/settings.gradle.kts new file mode 100644 index 00000000..6f32d6ba --- /dev/null +++ b/settings.gradle.kts @@ -0,0 +1,2 @@ +// FIXME Project build error when using Configurations.Module constants +include(":app", ":data", ":domain") diff --git a/signing.properties b/signing.properties new file mode 100644 index 00000000..e13bd617 --- /dev/null +++ b/signing.properties @@ -0,0 +1,3 @@ +KEY_ALIAS=ENTER_KEY_ALIAS_NAME +KEYSTORE_PASSWORD=ENTER_KEYSTORE_PASSWORD +KEY_PASSWORD=ENTER_KEY_PASSWORD