diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..5245895 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,9 @@ +root = true + +[*] +indent_size = 4 +indent_style = space +insert_final_newline = true + +[*.yml] +indent_size = 2 diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml new file mode 100644 index 0000000..600dd9d --- /dev/null +++ b/.github/workflows/android.yml @@ -0,0 +1,19 @@ +name: Android CI + +on: + push: +jobs: + build-test: + + runs-on: ubuntu-latest + timeout-minutes: 30 + + steps: + - uses: actions/checkout@v4 + - name: set up JDK 11 + uses: actions/setup-java@v4 + with: + java-version: 17 + distribution: 'temurin' + - name: Build, Lint, Test with Gradle + run: ./gradlew assemble test diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..30c3b70 --- /dev/null +++ b/.gitignore @@ -0,0 +1,17 @@ +*.iml +.gradle +/local.properties +/.idea +/.idea/caches +/.idea/libraries +/.idea/modules.xml +/.idea/workspace.xml +/.idea/navEditor.xml +/.idea/assetWizardSettings.xml +/.idea/inspectionProfiles +.DS_Store +/build +/captures +.externalNativeBuild +.cxx +local.properties diff --git a/README.md b/README.md new file mode 100644 index 0000000..20957d1 --- /dev/null +++ b/README.md @@ -0,0 +1,265 @@ +# Stocks + +This is a sample stock search app for Android built with Jetpack Compose and various other [libraries](#libraries). + + +- [About this Guide](#about-this-guide) +- [Setup](#setup) + - [Requirements](#requirements) + - [Building](#building) + - [Unit and UI tests](#unit-and-ui-tests) +- [Features](#features) +- [Libraries](#libraries) +- [Architecture Walkthrough](#architecture-walkthrough) + - [Unit test walkthrough](#unit-test-walkthrough) + - [Android test walkthrough](#android-test-walkthrough) +- [App Screenshots](#app-screenshots) + - [App icon](#app-icon) + - [Search results](#search-results) + - [Empty states](#empty-states) + - [About screen](#about-screen) +- [Install](#install) + +![](./screenshots/app%20icon.png) + + + + + + + +## About this Guide + +This guide assumes that you have experience in Android Development and are familiar with Android Studio and the command line tools. + +## Setup + +### Requirements + +- [Android Studio](https://developer.android.com/studio) version Android Studio Jellyfish | 2023.3.1 Patch 1 or greater +- [Android command line tools](https://developer.android.com/tools) + + +### Building + +You can build the app either in Android Studio or the command line (requires the command line tools mentioned above). + +Build the app from the command line using: + + ./gradlew assemble + +Install the debug build to your connected device: + + adb install app/build/outputs/apk/debug/app-debug.apk + +The screenshot below shows that you need to choose the "app" build configuration and your chosen device. This section is in the top bar of the Android Studio UI. + +![](./screenshots/build-app-config.png) + + +### Unit and UI tests + +You can run the tests either in Android Studio or the command line (requires the command line tools mentioned above). + + +Run the tests in Android Studio by visiting the `test` and `androidTest` folders at `./app/src/test` and `./app/src/androidTest` respectively. Right click and choose `Run tests in com.tinaciousdesign.interviews.stocks`. + +Run the unit tests from the command line with the following command: + + ./gradlew test + +Run the UI tests from the command line with the following command: + + ./gradlew connectedCheck --stacktrace + +Below is a screenshot of the Android Studio UI and where to find these tests. This view is in the tree explorer view of Android Studio using either "Android" or "Project" versions of the tree explorer view. + +![Screenshot of the androidTest and test directories in Android Studio](./screenshots/tests-running-android-studio.png) + + + + +## Features + +- πŸ“ˆ Loads stocks from an API endpoint +- πŸ›œ Enables offline access to stock searching, provided that stocks had initially loaded from the API while there was a connection +- 🌝 Supports light and dark mode using the user's system preference setting +- πŸ—ΊοΈ Bottom navigation to enable navigating to different screens +- 🍫 Snackbar UI to enable global alert messages +- πŸ€– Unit tests and Android tests, including tests for the UI and the persistence layer +- πŸ«™ Empty states + + + +## Libraries + +The following libraries were used for the project: + +- [Jetpack Compose](https://developer.android.com/compose): Android's new declarative UI framework and Google's recommended way to build Android apps going forward. +- [Room Database](https://developer.android.com/jetpack/androidx/releases/room): On-device database storage using a SQLite database and the Room database ORM, a library by Google for a SQL-based on-device persistence layer. +- [Hilt](https://developer.android.com/training/dependency-injection/hilt-android): Compile-time dependency injection library by Google that uses Dagger. This is used to inject dependencies into Android components (e.g. Application, Activity, View Model, WorkManager classes, etc.) Test helpers for Hilt were also included to enable unit and instrumentation tests for Android components using Hilt. +- [JUnit](https://junit.org/junit4/) for unit testing and instrumented Android tests +- [Compose UI testing](https://developer.android.com/develop/ui/compose/testing) for instrumented UI tests that include UI interactions like tapping elements, filling out forms, and waiting for views to appear. +- [Mockk](https://mockk.io/): A mocking library for Kotlin with support for coroutines, making it possible to test suspending functions. +- [Turbine](https://github.com/cashapp/turbine): A testing library by Square that makes it easier to test Kotlin coroutine flows. +- [Navigation Compose](https://developer.android.com/develop/ui/compose/navigation): Type-safe navigation library for Jetpack Compose, by Google. This is used to support navigation globally in the app. +- [Timber](https://github.com/JakeWharton/timber): Production-grade logging library by Square. This is used to log to the console and can also be used in production apps to log to exception tracking services. +- [Retrofit](https://square.github.io/retrofit/): Networking library by Square. This is used to make network requests to the API endpoint. +- [kotlinx-serialization](https://github.com/Kotlin/kotlinx.serialization): Kotlin standard library serialization library and compiler plugin. It's used to support serializing JSON from network requests. +- [Coil](https://coil-kt.github.io/coil/): Image loading library with support for Jetpack Compose for loading remote images. This is used to load the placeholder images for the stocks. + + +## Architecture Walkthrough + +I decided to build the app using Android's [Jetpack Compose](https://developer.android.com/compose) framework with MVVM architecture and Kotlin flows. + +I used the [Hilt](https://developer.android.com/training/dependency-injection/hilt-android) dependency injection library to make it easier to work with all of the various dependencies as well as enable the app to be more easily tested. There was also another option I've enjoyed using called [Koin](https://insert-koin.io/), and while it's simpler to set up and work with than Hilt (especially in production apps with various Android components and services), I opted to go with Hilt because it's by Google and offers more compile-time assurances. Dependency injection is currently configured as a single module `AppModule` at `app/src/main/java/com/tinaciousdesign/interviews/stocks/di/AppModule.kt`. In a production app, it may make sense to split it up into multiple modules which can make it easier for testing. + +I used [Room Database](https://developer.android.com/jetpack/androidx/releases/room) as the local persistence layer to cache stock prices to enable more efficient searching. This also enables using the app offline. Another option I've used in the past is [Realm](https://github.com/realm), a cross-platform noSQL-based on-device database, though I opted to go with Room as it's officially by the Google Android team. Classes related to the database can be found in `app/src/main/java/com/tinaciousdesign/interviews/stocks/db`, which is where the Room database along with the Stock Data Access Object (DAO) and entity are configured. + +```text +app/src/main/java/com/tinaciousdesign/interviews/stocks/db +β”œβ”€β”€ AppDatabase.kt +└── stock + β”œβ”€β”€ StockDao.kt + └── StockEntity.kt +``` + +I'm using a repository pattern. The `StocksRepository` accesses Room database and the network API and exposes suspending methods and flows for accessing all of the stock data, as well as filtering stock data by a search query. It implements a Retrofit service which accesses the API endpoint. The Retrofit services are configured with a custom `OkHttpClient`, making it easy to add request and response interceptors. You can see a logging interceptor was added as an example. Networking code, including a custom `ApiResult` sealed class, can be viewed in `app/src/main/java/com/tinaciousdesign/interviews/stocks/networking`: + +```text +app/src/main/java/com/tinaciousdesign/interviews/stocks/networking +β”œβ”€β”€ ApiError.kt +β”œβ”€β”€ ApiResult.kt +└── api + └── StocksApi.kt +``` + +The app is scaffolded with a bottom navigation bar and support for global alert messages using Android's [Snackbar](https://developer.android.com/develop/ui/compose/components/snackbar) component. Navigation is implemented using Jetpack Compose's new type-safe navigation component. The implementation can be viewed in `app/src/main/java/com/tinaciousdesign/interviews/stocks/navigation`: + +```text +app/src/main/java/com/tinaciousdesign/interviews/stocks/navigation +β”œβ”€β”€ BottomNavigationBar.kt +β”œβ”€β”€ NavigationRouter.kt +└── Route.kt +``` + +The app uses an EventBus pattern for platform-agnostic event-based programming that leverages Kotlin coroutine flows, which can be useful for listening to events across the app. You can toggle your internet on and off to view the Snackbar messaging. The snackbar duration is set to "long" to make it easy for people to test when toggling airplane mode on and off, but in a production app should be set to "short" since they can continue to use the app with the cached prices. Event-related code is in the directory `app/src/main/java/com/tinaciousdesign/interviews/stocks/events`, which includes the `EventBus` which publishes and subscribes to `AppEvent`, and a compose-based utility for observing events in composable components (`ObserveAsEvents`): + +```text +app/src/main/java/com/tinaciousdesign/interviews/stocks/events +β”œβ”€β”€ AppEvent.kt +β”œβ”€β”€ EventBus.kt +└── ObserveAsEvents.kt +``` + +Constants are defined in `AppConfig`. In a production app, the app would have [multiple build variants called product flavours](https://developer.android.com/build/build-variants) and would be configured to build separate production, staging, and development artifacts. An `AppConfig` would be provided for each of these with their respective values. We can also use product flavours to configure other things based on the environment, like the app icon. + +Logging has been added in `app/src/main/java/com/tinaciousdesign/interviews/stocks/logging` which includes configuration for Timber and a placeholder for production logging: + +```text +app/src/main/java/com/tinaciousdesign/interviews/stocks/logging +β”œβ”€β”€ CrashReportingTree.kt +β”œβ”€β”€ DebugConsoleLoggingTree.kt +└── Logger.kt +``` + +The composables are split up into separate directories for screens, components, and icons. + +- Common components are in `components` +- Icons that can be reused across screens are in `icons` +- Screens are (usually) root-level components. + +The `ui` package also includes utilities for working with various Android elements, as well as test tags to make it easy to access elements for instrumented UI tests. + +Other utility functions are available in `app/src/main/java/com/tinaciousdesign/interviews/stocks/utils`. + +Tests are split up into 2 separate directories, `test` and `androidTest`: + +- `test` is where platform-agnostic unit tests can be found +- `androidTest` is where unit tests that use Android components are. This is where you can find tests for Room database queries and instrumented UI tests that test the stock searching flow. + + +### Unit test walkthrough + +Unit tests are available in `app/src/test/java/com/tinaciousdesign/interviews/stocks`: + +```text +app/src/test/java/com/tinaciousdesign/interviews/stocks +β”œβ”€β”€ models +β”‚Β Β  └── StockTest.kt +β”œβ”€β”€ repositories +β”‚Β Β  └── StocksRepositoryTest.kt +β”œβ”€β”€ testutils +β”‚Β Β  └── TestCoroutineRule.kt +└── ui + └── screens + └── stocksearch + └── StockSearchViewModelTest.kt +``` + +- **StockTest**: tests for the `Stock` model including formatting data for the UI, and filtering and sorting the model. While the filtering logic here did not end up getting used and was replaced by a Room database query, it can still be useful for filtering a collection of stocks in-memory. This test also tests the custom comparator which was implemented to support the criteria that exact matches for the ticker or name are prioritized above partial matches. While I wasn't able to see which data would enable me to test this in the app, it is unit tested, and a separate endpoint was added to the code, which can be commented in in-place of the default one (see `com/tinaciousdesign/interviews/stocks/networking/api/StocksApi.kt` and enable the code `@GET("tinacious/a3ddc32e49c04b5de21e4bb30eb47e68/raw/5b590f6f369fb92fc49e33a14ab2275eb5629c24/mock-stocks.json")`). +- **StocksRepositoryTest**: tests for the repository layer including testing that we can make API calls with Retrofit and cache the data to Room database. +- **StockSearchViewModelTest**: tests that the data is queried when the view model has its `loadStocks()` method called and that stocks are available on the exposed `stocks` StateFlow. It also includes tests for the sorting prioritization logic with a mock repository. + +Below is a screenshot of the unit test results. + +![](./screenshots/test-results-unit.png) + + +### Android test walkthrough + +Android tests are available in `androidTest` at `app/src/androidTest/java/com/tinaciousdesign/interviews/stocks`: + +```text +app/src/androidTest/java/com/tinaciousdesign/interviews/stocks +β”œβ”€β”€ HiltTestRunner.kt +β”œβ”€β”€ StockSearchBehaviourTest.kt +β”œβ”€β”€ UiTestUtils.kt +β”œβ”€β”€ db +β”‚Β Β  └── stock +β”‚Β Β  └── StockDaoTest.kt +└── di + └── AppTestModule.kt +``` + +- **HiltTestRunner** and **AppTestModule** are Hilt-related utilities for helping with instrumented tests in projects that use Hilt +- **StockDaoTest**: Tests for the Room database implementation which test that we can insert records, get all records, and query records. It includes the suspending methods as well as the flow which is the one the search screen uses. +- **StockSearchBehaviourTest**: This is an instrumented UI test that tests the behaviour of loading up the app and searching. It makes a real network request to fetch the data and uses an in-memory database. + +Below is a screenshot of the Android test results including database tests and UI tests. + +![](./screenshots/test-results-android.png) + + +## App Screenshots + +### App icon + + + +### Search results + +![](./screenshots/01%20dark%20mode%20-%20search%20results.png) +![](./screenshots/01%20light%20mode%20-%20search%20results.png) +![](./screenshots/02%20dark%20mode%20-%20typing.png) +![](./screenshots/02%20light%20mode%20-%20typing.png) + + +### Empty states + +![](./screenshots/03%20dark%20mode%20-%20empty.png) +![](./screenshots/03%20light%20mode%20-%20empty.png) +![](./screenshots/04%20dark%20mode%20-%20no%20results.png) +![](./screenshots/04%20light%20mode%20-%20no%20results.png) + + +### About screen + +![](./screenshots/05%20dark%20mode%20-%20about.png) +![](./screenshots/05%20light%20mode%20-%20about.png) + + +## Install + +You can clone the source and build with the provided instructions above, or you can use [adb](https://developer.android.com/tools/adb) to install a debug build APK available in [Releases](https://github.com/tinacious/stocks-android-jetpack-compose/releases). diff --git a/app/.gitignore b/app/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/app/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/app/build.gradle.kts b/app/build.gradle.kts new file mode 100644 index 0000000..f790d81 --- /dev/null +++ b/app/build.gradle.kts @@ -0,0 +1,119 @@ +plugins { + alias(libs.plugins.android.application) + alias(libs.plugins.jetbrains.kotlin.android) + kotlin("kapt") + id("com.google.dagger.hilt.android") + id("dagger.hilt.android.plugin") + alias(libs.plugins.kotlinxSerialization) +} + +android { + namespace = "com.tinaciousdesign.interviews.stocks" + compileSdk = 34 + + defaultConfig { + applicationId = "com.tinaciousdesign.interviews.stocks" + minSdk = 29 + targetSdk = 34 + versionCode = 1 + versionName = "1.0" + + testInstrumentationRunner = "com.tinaciousdesign.interviews.stocks.HiltTestRunner" + vectorDrawables { + useSupportLibrary = true + } + + kotlinOptions { + freeCompilerArgs += arrayOf( + "-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi" + ) + } + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + } + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 + } + kotlinOptions { + jvmTarget = "1.8" + } + buildFeatures { + buildConfig = true + compose = true + } + composeOptions { + kotlinCompilerExtensionVersion = "1.5.1" + } + packaging { + resources { + excludes += "/META-INF/{AL2.0,LGPL2.1}" + } + } +} + +dependencies { + + implementation(libs.androidx.core.ktx) + implementation(libs.androidx.lifecycle.runtime.ktx) + implementation(libs.androidx.activity.compose) + implementation(platform(libs.androidx.compose.bom)) + implementation(libs.androidx.ui) + implementation(libs.androidx.ui.graphics) + implementation(libs.androidx.ui.tooling.preview) + implementation(libs.androidx.material3) + + // Logging + implementation(libs.timber) + implementation(libs.square.okhttp.logging.interceptor) + + // DI + implementation(libs.hilt.android) + kapt(libs.hilt.android.compiler) + kapt(libs.dagger.compiler) + kapt(libs.hilt.compiler) + + // Networking + implementation(libs.retrofit) + implementation(libs.retrofit.converter.kotlinx.serialization) + implementation(libs.coil.compose) + + // Navigation + implementation(libs.androidx.navigation.compose) + implementation(libs.androidx.hilt.navigation.compose) + implementation(libs.androidx.hilt.navigation.fragment) + implementation(libs.kotlinx.serialization.json) + + // Room database + implementation(libs.androidx.room.runtime) + annotationProcessor(libs.androidx.room.compiler) + kapt(libs.androidx.room.compiler) + implementation(libs.androidx.room.ktx) + + // WorkManager + implementation(libs.androidx.hilt.work) + implementation(libs.androidx.work.runtime.ktx) + + /// Testing + androidTestImplementation(libs.hilt.android.testing) + kaptAndroidTest(libs.hilt.android.compiler) + testImplementation(libs.junit) + testImplementation(libs.mockk) + testImplementation(libs.kotlinx.coroutines.test) + testImplementation(libs.turbine) + androidTestImplementation(libs.turbine) + androidTestImplementation(libs.androidx.junit) + androidTestImplementation(libs.androidx.espresso.core) + androidTestImplementation(platform(libs.androidx.compose.bom)) + androidTestImplementation(libs.androidx.ui.test.junit4) + debugImplementation(libs.androidx.ui.tooling) + debugImplementation(libs.androidx.ui.test.manifest) +} diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro new file mode 100644 index 0000000..481bb43 --- /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. +# +# 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 \ No newline at end of file diff --git a/app/src/androidTest/java/com/tinaciousdesign/interviews/stocks/HiltTestRunner.kt b/app/src/androidTest/java/com/tinaciousdesign/interviews/stocks/HiltTestRunner.kt new file mode 100644 index 0000000..355efc9 --- /dev/null +++ b/app/src/androidTest/java/com/tinaciousdesign/interviews/stocks/HiltTestRunner.kt @@ -0,0 +1,13 @@ +package com.tinaciousdesign.interviews.stocks + +import android.app.Application +import android.content.Context +import androidx.test.runner.AndroidJUnitRunner +import dagger.hilt.android.testing.HiltTestApplication + +class HiltTestRunner : AndroidJUnitRunner() { + + override fun newApplication(cl: ClassLoader?, className: String?, context: Context?): Application { + return super.newApplication(cl, HiltTestApplication::class.java.name, context) + } +} diff --git a/app/src/androidTest/java/com/tinaciousdesign/interviews/stocks/StockSearchBehaviourTest.kt b/app/src/androidTest/java/com/tinaciousdesign/interviews/stocks/StockSearchBehaviourTest.kt new file mode 100644 index 0000000..fee3b9b --- /dev/null +++ b/app/src/androidTest/java/com/tinaciousdesign/interviews/stocks/StockSearchBehaviourTest.kt @@ -0,0 +1,93 @@ +package com.tinaciousdesign.interviews.stocks + +import androidx.activity.compose.setContent +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.ui.Modifier +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performTextInput +import androidx.compose.ui.unit.dp +import androidx.navigation.compose.rememberNavController +import com.tinaciousdesign.interviews.stocks.di.AppModule +import com.tinaciousdesign.interviews.stocks.navigation.BottomNavigationBar +import com.tinaciousdesign.interviews.stocks.navigation.NavigationRouter +import com.tinaciousdesign.interviews.stocks.ui.TestTags +import com.tinaciousdesign.interviews.stocks.ui.theme.StocksTheme +import dagger.hilt.android.testing.HiltAndroidRule +import dagger.hilt.android.testing.HiltAndroidTest +import dagger.hilt.android.testing.UninstallModules +import org.junit.Before +import org.junit.Rule +import org.junit.Test + + +@HiltAndroidTest +@UninstallModules(AppModule::class) +class StockSearchBehaviourTest { + @get:Rule(order = 0) + val hiltRule = HiltAndroidRule(this) + + @get:Rule(order = 1) + val composeRule = createAndroidComposeRule() + + @Before + fun setUp() { + hiltRule.inject() + + composeRule.activity.setContent { + StocksTheme { + Surface( + modifier = Modifier.fillMaxSize(), + color = MaterialTheme.colorScheme.background + ) { + val navController = rememberNavController() + + Scaffold( + modifier = Modifier.fillMaxSize(), + bottomBar = { BottomNavigationBar(navController = navController) } + ) { innerPadding -> + Box( + modifier = Modifier.padding( + PaddingValues( + 0.dp, + 0.dp, + 0.dp, + innerPadding.calculateBottomPadding() + ) + ) + ) { + NavigationRouter(navController) + } + } + } + } + } + } + + @Test + fun userCanSearchForStocks() { + with(composeRule) { + composeRule.onNodeWithText("Use the search field above to find stocks by ticker or by name").assertIsDisplayed() + + composeRule + .onNodeWithTag(TestTags.searchField) + .performTextInput("ow") + + waitForText("POWL") + + textIsDisplayed("POWL") + textIsDisplayed("Omni Resources") + + textIsDisplayed("FVOW") + textIsDisplayed("Harmony Enterprises") + } + } +} diff --git a/app/src/androidTest/java/com/tinaciousdesign/interviews/stocks/UiTestUtils.kt b/app/src/androidTest/java/com/tinaciousdesign/interviews/stocks/UiTestUtils.kt new file mode 100644 index 0000000..885a33e --- /dev/null +++ b/app/src/androidTest/java/com/tinaciousdesign/interviews/stocks/UiTestUtils.kt @@ -0,0 +1,49 @@ +package com.tinaciousdesign.interviews.stocks + +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.hasText +import androidx.compose.ui.test.junit4.ComposeTestRule +import androidx.compose.ui.test.onAllNodesWithText +import androidx.compose.ui.test.onNodeWithText +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue + +@OptIn(ExperimentalTestApi::class) +fun ComposeTestRule.waitForText( + text: String, + timeoutMillis: Long = 5000 +) { + waitUntilAtLeastOneExists(hasText(text), timeoutMillis = timeoutMillis) +} + +fun ComposeTestRule.textIsDisplayed( + text: String, + expectedOccurrences: Int = 1 +) { + if (expectedOccurrences == 1) { + onNodeWithText(text).assertIsDisplayed() + } else { + assertEquals(onAllNodesWithText(text).fetchSemanticsNodes().size, expectedOccurrences) + } +} + +fun ComposeTestRule.textIsDisplayedAtLeastOnce( + text: String, + minOccurrences: Int = 1 +) { + assertTrue(this.onAllNodesWithText(text).fetchSemanticsNodes().size >= minOccurrences) +} + +@OptIn(ExperimentalTestApi::class) +fun ComposeTestRule.sleep( + timeoutMillis: Long +) { + @Suppress("SwallowedException") + try { + // Random string that will never be found + waitUntilAtLeastOneExists(hasText("446fc9cdc8e63d9f11fb6bacd2f51ef5!"), timeoutMillis = timeoutMillis) + } catch (t: Throwable) { + // swallow this exception + } +} diff --git a/app/src/androidTest/java/com/tinaciousdesign/interviews/stocks/db/stock/StockDaoTest.kt b/app/src/androidTest/java/com/tinaciousdesign/interviews/stocks/db/stock/StockDaoTest.kt new file mode 100644 index 0000000..1476a86 --- /dev/null +++ b/app/src/androidTest/java/com/tinaciousdesign/interviews/stocks/db/stock/StockDaoTest.kt @@ -0,0 +1,123 @@ +package com.tinaciousdesign.interviews.stocks.db.stock + +import android.content.Context +import androidx.room.Room +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import app.cash.turbine.test +import com.tinaciousdesign.interviews.stocks.db.AppDatabase +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import java.io.IOException + +@RunWith(AndroidJUnit4::class) +class StockDaoTest { + + private lateinit var stockDao: StockDao + private lateinit var db: AppDatabase + + @Before + fun createDb() { + val context = ApplicationProvider.getApplicationContext() + db = Room.inMemoryDatabaseBuilder(context, AppDatabase::class.java).build() + stockDao = db.stocks() + } + + @After + @Throws(IOException::class) + fun closeDb() { + db.close() + } + + @Test + @Throws(Exception::class) + fun insertStocksAndGetAllStocks(): Unit = runTest { + assertEquals(emptyList(), stockDao.getAll()) + + stockDao.insertAll( + listOf( + StockEntity(ticker ="FFF", name = "FFF Co", price = 100.0), + StockEntity(ticker ="DDD", name = "DDD Co", price = 100.0), + StockEntity(ticker ="CCC", name = "CCC Co", price = 100.0), + StockEntity(ticker ="AB", name = "Absolute", price = 100.0), + StockEntity(ticker ="ABC", name = "ABC Industries", price = 100.0), + StockEntity(ticker ="XYZ", name = "XYZ and ABC", price = 100.0), + ) + ) + + val result = stockDao.getAll() + + assertEquals( + listOf( + StockEntity(id = 1, ticker ="FFF", name = "FFF Co", price = 100.0), + StockEntity(id = 2, ticker ="DDD", name = "DDD Co", price = 100.0), + StockEntity(id = 3, ticker ="CCC", name = "CCC Co", price = 100.0), + StockEntity(id = 4, ticker ="AB", name = "Absolute", price = 100.0), + StockEntity(id = 5, ticker ="ABC", name = "ABC Industries", price = 100.0), + StockEntity(id = 6, ticker ="XYZ", name = "XYZ and ABC", price = 100.0), + ), + result, + ) + } + + @Test + @Throws(Exception::class) + fun searchStocks(): Unit = runTest { + assertEquals(emptyList(), stockDao.getAll()) + + stockDao.insertAll( + listOf( + StockEntity(ticker = "FFF", name = "FFF Co", price = 100.0), + StockEntity(ticker = "DDD", name = "DDD Co", price = 100.0), + StockEntity(ticker = "CCC", name = "CCC Co", price = 100.0), + StockEntity(ticker = "AB", name = "Absolute", price = 100.0), + StockEntity(ticker = "ABC", name = "ABC Industries", price = 100.0), + StockEntity(ticker = "XYZ", name = "XYZ and ABC", price = 100.0), + ) + ) + + val result = stockDao.find("ab") + + assertEquals( + listOf( + StockEntity(id = 4, ticker ="AB", name = "Absolute", price = 100.0), + StockEntity(id = 5, ticker ="ABC", name = "ABC Industries", price = 100.0), + StockEntity(id = 6, ticker ="XYZ", name = "XYZ and ABC", price = 100.0), + ), + result, + ) + } + + @Test + @Throws(Exception::class) + fun searchStocksWithFlow(): Unit = runTest { + assertEquals(emptyList(), stockDao.getAll()) + + stockDao.insertAll( + listOf( + StockEntity(ticker = "FFF", name = "FFF Co", price = 100.0), + StockEntity(ticker = "DDD", name = "DDD Co", price = 100.0), + StockEntity(ticker = "CCC", name = "CCC Co", price = 100.0), + StockEntity(ticker = "AB", name = "Absolute", price = 100.0), + StockEntity(ticker = "ABC", name = "ABC Industries", price = 100.0), + StockEntity(ticker = "XYZ", name = "XYZ and ABC", price = 100.0), + ) + ) + + stockDao.findStocksFlow("abc").test { + val result = awaitItem() + + assertEquals( + listOf( + StockEntity(id = 5, ticker ="ABC", name = "ABC Industries", price = 100.0), + StockEntity(id = 6, ticker ="XYZ", name = "XYZ and ABC", price = 100.0), + ), + result, + ) + } + } +} diff --git a/app/src/androidTest/java/com/tinaciousdesign/interviews/stocks/di/AppTestModule.kt b/app/src/androidTest/java/com/tinaciousdesign/interviews/stocks/di/AppTestModule.kt new file mode 100644 index 0000000..ef71c1b --- /dev/null +++ b/app/src/androidTest/java/com/tinaciousdesign/interviews/stocks/di/AppTestModule.kt @@ -0,0 +1,83 @@ +package com.tinaciousdesign.interviews.stocks.di + +import android.content.Context +import androidx.room.Room +import com.tinaciousdesign.interviews.stocks.BuildConfig +import com.tinaciousdesign.interviews.stocks.config.AppConfig +import com.tinaciousdesign.interviews.stocks.db.AppDatabase +import com.tinaciousdesign.interviews.stocks.db.stock.StockDao +import com.tinaciousdesign.interviews.stocks.events.EventBus +import com.tinaciousdesign.interviews.stocks.networking.api.StocksApi +import com.tinaciousdesign.interviews.stocks.repositories.StocksRepository +import com.tinaciousdesign.interviews.stocks.repositories.StocksRepositoryImpl +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import kotlinx.serialization.json.Json +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.logging.HttpLoggingInterceptor +import retrofit2.Retrofit +import retrofit2.converter.kotlinx.serialization.asConverterFactory +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +class AppTestModule { + // region Repositories + + @Provides @Singleton + fun provideStocksRepository( + stocksApi: StocksApi, + stockDao: StockDao, + ): StocksRepository = StocksRepositoryImpl( + stocksApi, + stockDao, + ) + + @Provides @Singleton + fun provideEventBus(): EventBus = EventBus() + + // endregion Repositories + + // region Networking + + @Provides @Singleton + fun provideRetrofit(): Retrofit { + val converterFactory = Json.asConverterFactory( + "application/json; charset=UTF8".toMediaType() + ) + + return Retrofit.Builder() + .baseUrl(AppConfig.stocksApiBaseUrl) + .addConverterFactory(converterFactory) + .build() + } + + // region Networking -> API + + @Provides @Singleton + fun provideStocksApi(retrofit: Retrofit): StocksApi = retrofit.create(StocksApi::class.java) + + // endregion Networking -> API + + // endregion Networking + + // region Database + + @Provides @Singleton + fun provideAppDatabase( + @ApplicationContext appContext: Context + ): AppDatabase = + Room.inMemoryDatabaseBuilder( + appContext, + AppDatabase::class.java, + ).build() + + @Provides @Singleton + fun provideStockDao(appDatabase: AppDatabase): StockDao = appDatabase.stocks() + + // endregion Database +} diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..ff902ed --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,42 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/ic_launcher-playstore.png b/app/src/main/ic_launcher-playstore.png new file mode 100644 index 0000000..62e2bd8 Binary files /dev/null and b/app/src/main/ic_launcher-playstore.png differ diff --git a/app/src/main/java/com/tinaciousdesign/interviews/stocks/MainActivity.kt b/app/src/main/java/com/tinaciousdesign/interviews/stocks/MainActivity.kt new file mode 100644 index 0000000..aaa777f --- /dev/null +++ b/app/src/main/java/com/tinaciousdesign/interviews/stocks/MainActivity.kt @@ -0,0 +1,136 @@ +package com.tinaciousdesign.interviews.stocks + +import android.content.Context +import android.net.ConnectivityManager +import android.net.Network +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.BottomAppBar +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Surface +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.lifecycle.lifecycleScope +import androidx.navigation.compose.rememberNavController +import com.tinaciousdesign.interviews.stocks.events.AppEvent +import com.tinaciousdesign.interviews.stocks.events.EventBus +import com.tinaciousdesign.interviews.stocks.navigation.BottomNavigationBar +import com.tinaciousdesign.interviews.stocks.navigation.NavigationRouter +import com.tinaciousdesign.interviews.stocks.ui.snackbar.SnackBarController +import com.tinaciousdesign.interviews.stocks.ui.theme.StocksTheme +import com.tinaciousdesign.interviews.stocks.ui.utils.KeyboardState +import com.tinaciousdesign.interviews.stocks.ui.utils.ObserveInternetConnectionState +import com.tinaciousdesign.interviews.stocks.ui.utils.ObserveSnackBarEvents +import com.tinaciousdesign.interviews.stocks.ui.utils.keyboardVisibleState +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.launch +import javax.inject.Inject + +@AndroidEntryPoint +class MainActivity : ComponentActivity() { + + @Inject + lateinit var eventBus: EventBus + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + registerNetworkListener() + + setContent { + StocksTheme { + Surface( + modifier = Modifier.fillMaxSize(), + color = MaterialTheme.colorScheme.background + ) { + val scope = rememberCoroutineScope() + val navController = rememberNavController() + val snackBarHostState = remember { SnackbarHostState() } + val keyboardState by keyboardVisibleState() + + ObserveInternetConnectionState(eventBus, scope) + ObserveSnackBarEvents(SnackBarController.events, snackBarHostState, scope) + + Scaffold( + modifier = Modifier.fillMaxSize(), + snackbarHost = { + SnackbarHost(hostState = snackBarHostState) + }, + bottomBar = { + if (keyboardState == KeyboardState.Closed) { + BottomAppBar { + BottomNavigationBar(navController = navController) + } + } + } + ) { innerPadding -> + Box( + modifier = Modifier.padding( + PaddingValues( + 0.dp, + 0.dp, + 0.dp, + innerPadding.calculateBottomPadding() + ) + ) + ) { + NavigationRouter(navController) + } + } + } + } + } + } + + + // region Network Listener + + private fun registerNetworkListener() { + val connectivityManager = getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager + connectivityManager.registerDefaultNetworkCallback(networkListener) + } + + private var hasDisconnected = false + + private val networkListener = object : ConnectivityManager.NetworkCallback() { + override fun onAvailable(network: Network) { + super.onAvailable(network) + + if (hasDisconnected) { + handleNetworkConnectionRestored() + } + } + + override fun onLost(network: Network) { + super.onLost(network) + + hasDisconnected = true + handleNetworkConnectionLost() + } + } + + private fun handleNetworkConnectionLost() { + lifecycleScope.launch { + eventBus.emitEvent(AppEvent.ConnectionLost) + } + } + + private fun handleNetworkConnectionRestored() { + lifecycleScope.launch { + eventBus.emitEvent(AppEvent.ConnectionRestored) + } + } + + // endregion Network Listener +} diff --git a/app/src/main/java/com/tinaciousdesign/interviews/stocks/StocksApplication.kt b/app/src/main/java/com/tinaciousdesign/interviews/stocks/StocksApplication.kt new file mode 100644 index 0000000..6390201 --- /dev/null +++ b/app/src/main/java/com/tinaciousdesign/interviews/stocks/StocksApplication.kt @@ -0,0 +1,23 @@ +package com.tinaciousdesign.interviews.stocks + +import android.app.Application +import com.tinaciousdesign.interviews.stocks.BuildConfig +import com.tinaciousdesign.interviews.stocks.logging.CrashReportingTree +import com.tinaciousdesign.interviews.stocks.logging.DebugConsoleLoggingTree +import dagger.hilt.android.HiltAndroidApp +import timber.log.Timber + +@HiltAndroidApp +class StocksApplication : Application() { + + override fun onCreate() { + super.onCreate() + + setUpLogging() + } + + private fun setUpLogging() { + val loggingTree = if (BuildConfig.DEBUG) DebugConsoleLoggingTree() else CrashReportingTree() + Timber.plant(loggingTree) + } +} diff --git a/app/src/main/java/com/tinaciousdesign/interviews/stocks/config/AppConfig.kt b/app/src/main/java/com/tinaciousdesign/interviews/stocks/config/AppConfig.kt new file mode 100644 index 0000000..468bb7d --- /dev/null +++ b/app/src/main/java/com/tinaciousdesign/interviews/stocks/config/AppConfig.kt @@ -0,0 +1,5 @@ +package com.tinaciousdesign.interviews.stocks.config + +object AppConfig { + val stocksApiBaseUrl = "https://gist.githubusercontent.com/" +} diff --git a/app/src/main/java/com/tinaciousdesign/interviews/stocks/db/.gitkeep b/app/src/main/java/com/tinaciousdesign/interviews/stocks/db/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/app/src/main/java/com/tinaciousdesign/interviews/stocks/db/AppDatabase.kt b/app/src/main/java/com/tinaciousdesign/interviews/stocks/db/AppDatabase.kt new file mode 100644 index 0000000..3c7fd25 --- /dev/null +++ b/app/src/main/java/com/tinaciousdesign/interviews/stocks/db/AppDatabase.kt @@ -0,0 +1,16 @@ +package com.tinaciousdesign.interviews.stocks.db + +import androidx.room.Database +import androidx.room.RoomDatabase +import com.tinaciousdesign.interviews.stocks.db.stock.StockDao +import com.tinaciousdesign.interviews.stocks.db.stock.StockEntity + +@Database( + entities = [ + StockEntity::class + ], + version = 1 +) +abstract class AppDatabase : RoomDatabase() { + abstract fun stocks(): StockDao +} diff --git a/app/src/main/java/com/tinaciousdesign/interviews/stocks/db/stock/StockDao.kt b/app/src/main/java/com/tinaciousdesign/interviews/stocks/db/stock/StockDao.kt new file mode 100644 index 0000000..ce172a2 --- /dev/null +++ b/app/src/main/java/com/tinaciousdesign/interviews/stocks/db/stock/StockDao.kt @@ -0,0 +1,33 @@ +package com.tinaciousdesign.interviews.stocks.db.stock + +import androidx.room.Dao +import androidx.room.Delete +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import kotlinx.coroutines.flow.Flow + +@Dao +interface StockDao { + + @Query("SELECT * FROM stocks") + suspend fun getAll(): List + + @Query("SELECT * FROM stocks") + fun stocksFlow(): Flow> + + @Query("SELECT * FROM stocks WHERE LOWER(ticker) LIKE '%' || :query || '%' OR LOWER(name) LIKE '%' || :query || '%'") + suspend fun find(query: String): List + + @Query("SELECT * FROM stocks WHERE LOWER(ticker) LIKE '%' || :query || '%' OR LOWER(name) LIKE '%' || :query || '%'") + fun findStocksFlow(query: String): Flow> + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insert(stock: StockEntity) + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertAll(stocks: List) + + @Query("DELETE FROM stocks") + suspend fun deleteAll() +} diff --git a/app/src/main/java/com/tinaciousdesign/interviews/stocks/db/stock/StockEntity.kt b/app/src/main/java/com/tinaciousdesign/interviews/stocks/db/stock/StockEntity.kt new file mode 100644 index 0000000..7723686 --- /dev/null +++ b/app/src/main/java/com/tinaciousdesign/interviews/stocks/db/stock/StockEntity.kt @@ -0,0 +1,29 @@ +package com.tinaciousdesign.interviews.stocks.db.stock + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.Index +import androidx.room.PrimaryKey +import com.tinaciousdesign.interviews.stocks.models.Stock + +@Entity( + tableName = "stocks", + indices = [ + Index(value = ["ticker"], unique = true) + ] +) +data class StockEntity( + @PrimaryKey(autoGenerate = true) val id: Int = 0, + @ColumnInfo(name = "ticker") val ticker: String, + @ColumnInfo(name = "name") val name: String, + @ColumnInfo(name = "price") val price: Double, +) { + companion object { + fun fromStock(stock: Stock): StockEntity = + StockEntity( + ticker = stock.ticker, + name = stock.name, + price = stock.price, + ) + } +} diff --git a/app/src/main/java/com/tinaciousdesign/interviews/stocks/di/AppModule.kt b/app/src/main/java/com/tinaciousdesign/interviews/stocks/di/AppModule.kt new file mode 100644 index 0000000..b77a239 --- /dev/null +++ b/app/src/main/java/com/tinaciousdesign/interviews/stocks/di/AppModule.kt @@ -0,0 +1,108 @@ +package com.tinaciousdesign.interviews.stocks.di + +import android.content.Context +import androidx.room.Room +import com.tinaciousdesign.interviews.stocks.BuildConfig +import com.tinaciousdesign.interviews.stocks.config.AppConfig +import com.tinaciousdesign.interviews.stocks.db.AppDatabase +import com.tinaciousdesign.interviews.stocks.db.stock.StockDao +import com.tinaciousdesign.interviews.stocks.events.EventBus +import com.tinaciousdesign.interviews.stocks.logging.Logger +import com.tinaciousdesign.interviews.stocks.networking.api.StocksApi +import com.tinaciousdesign.interviews.stocks.repositories.StocksRepository +import com.tinaciousdesign.interviews.stocks.repositories.StocksRepositoryImpl +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import kotlinx.serialization.json.Json +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.logging.HttpLoggingInterceptor +import okhttp3.logging.HttpLoggingInterceptor.Level +import retrofit2.Retrofit +import retrofit2.converter.kotlinx.serialization.asConverterFactory +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +class AppModule { + + // region Repositories + + @Provides @Singleton + fun provideStocksRepository( + stocksApi: StocksApi, + stockDao: StockDao, + ): StocksRepository = StocksRepositoryImpl( + stocksApi, + stockDao, + ) + + @Provides @Singleton + fun provideEventBus(): EventBus = EventBus() + + // endregion Repositories + + // region Networking + + @Provides @Singleton + fun provideRetrofit( + okHttpClient: OkHttpClient + ): Retrofit { + val converterFactory = Json.asConverterFactory( + "application/json; charset=UTF8".toMediaType() + ) + + return Retrofit.Builder() + .baseUrl(AppConfig.stocksApiBaseUrl) + .client(okHttpClient) + .addConverterFactory(converterFactory) + .build() + } + + @Provides @Singleton + fun provideHttpLoggingInterceptor(): HttpLoggingInterceptor = + HttpLoggingInterceptor { Logger.tag("HttpLog").d(it) } + .apply { + level = if (BuildConfig.DEBUG) Level.BODY else Level.NONE + } + + @Provides @Singleton + fun provideOkHttpClient( + httpLoggingInterceptor: HttpLoggingInterceptor + ): OkHttpClient { + return OkHttpClient.Builder() + // We can add other interceptors here, e.g. auth + .addInterceptor(httpLoggingInterceptor) + .build() + } + + // region Networking -> API + + @Provides @Singleton + fun provideStocksApi(retrofit: Retrofit): StocksApi = retrofit.create(StocksApi::class.java) + + // endregion Networking -> API + + // endregion Networking + + // region Database + + @Provides @Singleton + fun provideAppDatabase( + @ApplicationContext appContext: Context + ): AppDatabase = + Room.databaseBuilder( + appContext, + AppDatabase::class.java, + "stocks", + ) + .build() + + @Provides @Singleton + fun provideStockDao(appDatabase: AppDatabase): StockDao = appDatabase.stocks() + + // endregion Database +} diff --git a/app/src/main/java/com/tinaciousdesign/interviews/stocks/events/AppEvent.kt b/app/src/main/java/com/tinaciousdesign/interviews/stocks/events/AppEvent.kt new file mode 100644 index 0000000..210cb93 --- /dev/null +++ b/app/src/main/java/com/tinaciousdesign/interviews/stocks/events/AppEvent.kt @@ -0,0 +1,7 @@ +package com.tinaciousdesign.interviews.stocks.events + +sealed class AppEvent { + data object ConnectionLost : AppEvent() + + data object ConnectionRestored : AppEvent() +} diff --git a/app/src/main/java/com/tinaciousdesign/interviews/stocks/events/EventBus.kt b/app/src/main/java/com/tinaciousdesign/interviews/stocks/events/EventBus.kt new file mode 100644 index 0000000..ba55dfa --- /dev/null +++ b/app/src/main/java/com/tinaciousdesign/interviews/stocks/events/EventBus.kt @@ -0,0 +1,40 @@ +package com.tinaciousdesign.interviews.stocks.events + +import com.tinaciousdesign.interviews.stocks.logging.Logger +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import kotlin.coroutines.coroutineContext + +class EventBus { + private val _events = MutableSharedFlow(replay = 10) + val events = _events.asSharedFlow() + + suspend fun emitEvent(event: AppEvent) { + Logger.d("🚌🏁 Emitting event = $event") + _events.emit(event) + } + + suspend inline fun subscribe(crossinline onEvent: (T) -> Unit) { + events.filterIsInstance() + .collectLatest { appEvent -> + if (!coroutineContext.isActive) { + Logger.d("πŸšŒπŸ›‘ Coroutine inactive - Not collecting event: $appEvent") + return@collectLatest + } + + Logger.d("πŸšŒπŸ›οΈ Collecting event: $appEvent") + onEvent(appEvent) + } + } + + inline fun subscribe(coroutineScope: CoroutineScope, crossinline onEvent: (T) -> Unit) { + coroutineScope.launch { + subscribe(onEvent) + } + } +} diff --git a/app/src/main/java/com/tinaciousdesign/interviews/stocks/events/ObserveAsEvents.kt b/app/src/main/java/com/tinaciousdesign/interviews/stocks/events/ObserveAsEvents.kt new file mode 100644 index 0000000..2981826 --- /dev/null +++ b/app/src/main/java/com/tinaciousdesign/interviews/stocks/events/ObserveAsEvents.kt @@ -0,0 +1,27 @@ +package com.tinaciousdesign.interviews.stocks.events + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.lifecycle.repeatOnLifecycle +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.withContext + +@Composable +fun ObserveAsEvents( + flow: Flow, + key1: Any? = null, + key2: Any? = null, + onEvent: (T) -> Unit +) { + val lifecycleOwner = LocalLifecycleOwner.current + LaunchedEffect(lifecycleOwner.lifecycle, key1, key2, flow) { + lifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { + withContext(Dispatchers.Main.immediate) { + flow.collect(onEvent) + } + } + } +} diff --git a/app/src/main/java/com/tinaciousdesign/interviews/stocks/logging/CrashReportingTree.kt b/app/src/main/java/com/tinaciousdesign/interviews/stocks/logging/CrashReportingTree.kt new file mode 100644 index 0000000..43fa342 --- /dev/null +++ b/app/src/main/java/com/tinaciousdesign/interviews/stocks/logging/CrashReportingTree.kt @@ -0,0 +1,26 @@ +package com.tinaciousdesign.interviews.stocks.logging + +import android.annotation.SuppressLint +import android.util.Log +import timber.log.Timber + +@SuppressLint("LogNotTimber") +class CrashReportingTree : Timber.Tree() { + override fun log(priority: Int, tag: String?, message: String, t: Throwable?) { + if (priority < Log.INFO) return + + logToConsole(priority, tag, message, t) + logToMonitoringService(priority, tag, message, t) + } + + private fun logToConsole(priority: Int, tag: String?, message: String, t: Throwable?) { + when (priority) { + Log.ASSERT, Log.ERROR -> Log.e(tag, message, t) + Log.WARN -> Log.w(tag, message, t) + } + } + + private fun logToMonitoringService(priority: Int, tag: String?, message: String, t: Throwable?) { + // todo: Implement third-party logging service, e.g. Crashlytics + } +} diff --git a/app/src/main/java/com/tinaciousdesign/interviews/stocks/logging/DebugConsoleLoggingTree.kt b/app/src/main/java/com/tinaciousdesign/interviews/stocks/logging/DebugConsoleLoggingTree.kt new file mode 100644 index 0000000..715a307 --- /dev/null +++ b/app/src/main/java/com/tinaciousdesign/interviews/stocks/logging/DebugConsoleLoggingTree.kt @@ -0,0 +1,17 @@ +package com.tinaciousdesign.interviews.stocks.logging + +import timber.log.Timber + +/** + * Should only be used in debug builds since references to [StackTraceElement] will be lost in minified builds + */ +class DebugConsoleLoggingTree : Timber.DebugTree() { + override fun createStackElementTag(element: StackTraceElement): String? { + return String.format( + "%s:%s#%s", + element.fileName.replace(".kt", ""), + element.lineNumber, + element.methodName, + ) + } +} diff --git a/app/src/main/java/com/tinaciousdesign/interviews/stocks/logging/Logger.kt b/app/src/main/java/com/tinaciousdesign/interviews/stocks/logging/Logger.kt new file mode 100644 index 0000000..9484362 --- /dev/null +++ b/app/src/main/java/com/tinaciousdesign/interviews/stocks/logging/Logger.kt @@ -0,0 +1,5 @@ +package com.tinaciousdesign.interviews.stocks.logging + +import timber.log.Timber + +typealias Logger = Timber diff --git a/app/src/main/java/com/tinaciousdesign/interviews/stocks/models/Stock.kt b/app/src/main/java/com/tinaciousdesign/interviews/stocks/models/Stock.kt new file mode 100644 index 0000000..b92da85 --- /dev/null +++ b/app/src/main/java/com/tinaciousdesign/interviews/stocks/models/Stock.kt @@ -0,0 +1,37 @@ +package com.tinaciousdesign.interviews.stocks.models + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class Stock( + @SerialName("ticker") val ticker: String, + @SerialName("name") val name: String, + @SerialName("currentPrice") val price: Double, +) { + val formattedPrice: String get() = "%.2f".format(price) + + companion object { + fun compareQuery(query: String): Comparator = + Comparator { a, b -> + val queryLowered = query.lowercase() + + val aExactMatch = a.name.lowercase() == queryLowered || + a.ticker.lowercase() == queryLowered + val bExactMatch = b.name.lowercase() == queryLowered || + b.ticker.lowercase() == queryLowered + + if (aExactMatch && bExactMatch) { + return@Comparator 0 + } + + if (aExactMatch) -1 else 1 + } + } +} + +fun List.matches(query: String): List = + this.filter { stock -> + stock.ticker.contains(query.trim(), ignoreCase = true) || + stock.name.contains(query.trim(), ignoreCase = true) + } diff --git a/app/src/main/java/com/tinaciousdesign/interviews/stocks/navigation/BottomNavigationBar.kt b/app/src/main/java/com/tinaciousdesign/interviews/stocks/navigation/BottomNavigationBar.kt new file mode 100644 index 0000000..afee008 --- /dev/null +++ b/app/src/main/java/com/tinaciousdesign/interviews/stocks/navigation/BottomNavigationBar.kt @@ -0,0 +1,64 @@ +package com.tinaciousdesign.interviews.stocks.navigation + +import androidx.compose.material3.NavigationBar +import androidx.compose.material3.NavigationBarItem +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.platform.LocalContext +import androidx.navigation.NavController +import com.tinaciousdesign.interviews.stocks.utils.lastSegment + +@Composable +fun BottomNavigationBar(navController: NavController) { + val context = LocalContext.current + + val navItems = listOf( + Route.StockSearch, + Route.About + ) + var selectedItem by remember { mutableStateOf(Route.StockSearch) } + + // Update the active item's highlighted state and navigate to the desired screen + fun handleRouteClicked(route: Route) { + selectedItem = route + + navController.navigate(route) { + navController.graph.startDestinationRoute?.let { startRoute -> + popUpTo(startRoute) { + saveState = true + } + } + launchSingleTop = true + restoreState = true + } + } + + // Update the active item's highlighted state + LaunchedEffect(0) { + navController.addOnDestinationChangedListener { _, destination, _ -> + navItems.forEach { navItem -> + val current = destination.route?.lastSegment(".") + if (current == navItem.routeName) { + selectedItem = navItem + } + } + } + } + + // Render the tabs with icons and localized titles + NavigationBar { + navItems.forEach { route -> + NavigationBarItem( + selected = route == selectedItem, + label = { Text(context.getString(route.titleRes)) }, + icon = route.icon, + onClick = { handleRouteClicked(route) }, + ) + } + } +} diff --git a/app/src/main/java/com/tinaciousdesign/interviews/stocks/navigation/NavigationRouter.kt b/app/src/main/java/com/tinaciousdesign/interviews/stocks/navigation/NavigationRouter.kt new file mode 100644 index 0000000..547ff34 --- /dev/null +++ b/app/src/main/java/com/tinaciousdesign/interviews/stocks/navigation/NavigationRouter.kt @@ -0,0 +1,27 @@ +package com.tinaciousdesign.interviews.stocks.navigation + +import androidx.compose.runtime.Composable +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.navigation.NavHostController +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import com.tinaciousdesign.interviews.stocks.ui.screens.about.AboutScreen +import com.tinaciousdesign.interviews.stocks.ui.screens.stocksearch.StockSearchScreen +import com.tinaciousdesign.interviews.stocks.ui.screens.stocksearch.StockSearchViewModel + +@Composable +fun NavigationRouter( + navHostController: NavHostController +) { + NavHost(navController = navHostController, startDestination = Route.StockSearch) { + composable { backStackEntry -> + val viewModel = hiltViewModel() + + StockSearchScreen(viewModel) + } + + composable { backStackEntry -> + AboutScreen() + } + } +} diff --git a/app/src/main/java/com/tinaciousdesign/interviews/stocks/navigation/Route.kt b/app/src/main/java/com/tinaciousdesign/interviews/stocks/navigation/Route.kt new file mode 100644 index 0000000..2536c69 --- /dev/null +++ b/app/src/main/java/com/tinaciousdesign/interviews/stocks/navigation/Route.kt @@ -0,0 +1,40 @@ +package com.tinaciousdesign.interviews.stocks.navigation + +import androidx.annotation.Keep +import androidx.annotation.StringRes +import androidx.compose.runtime.Composable +import com.tinaciousdesign.interviews.stocks.R +import com.tinaciousdesign.interviews.stocks.ui.icons.TinaciousDesignLogoIcon +import com.tinaciousdesign.interviews.stocks.ui.icons.TintedIconDrawable +import kotlinx.serialization.Serializable + +@Serializable @Keep +sealed class Route { + abstract val icon: @Composable () -> Unit + + @get:StringRes + abstract val titleRes: Int + + val routeName: String? get() = javaClass.simpleName + + @Serializable @Keep + data object StockSearch : Route() { + override val titleRes: Int get() = R.string.route_stock_search + + override val icon: @Composable () -> Unit = { + TintedIconDrawable( + R.drawable.ic_dollar, + R.string.route_stock_search + ) + } + } + + @Serializable @Keep + data object About : Route() { + override val titleRes: Int get() = R.string.route_about + + override val icon: @Composable () -> Unit = { + TinaciousDesignLogoIcon() + } + } +} diff --git a/app/src/main/java/com/tinaciousdesign/interviews/stocks/networking/ApiError.kt b/app/src/main/java/com/tinaciousdesign/interviews/stocks/networking/ApiError.kt new file mode 100644 index 0000000..a88e460 --- /dev/null +++ b/app/src/main/java/com/tinaciousdesign/interviews/stocks/networking/ApiError.kt @@ -0,0 +1,6 @@ +package com.tinaciousdesign.interviews.stocks.networking + + open class ApiError( + cause: Throwable? = null, + message: String? = null, +) : Exception(message, cause) diff --git a/app/src/main/java/com/tinaciousdesign/interviews/stocks/networking/ApiResult.kt b/app/src/main/java/com/tinaciousdesign/interviews/stocks/networking/ApiResult.kt new file mode 100644 index 0000000..1b19125 --- /dev/null +++ b/app/src/main/java/com/tinaciousdesign/interviews/stocks/networking/ApiResult.kt @@ -0,0 +1,24 @@ +package com.tinaciousdesign.interviews.stocks.networking + +sealed class ApiResult { + data class Success( + override val data: ResultData + ) : ApiResult() { + override val ok: Boolean = true + override val error: Error? = null + } + + data class Failed( + override val error: Error + ) : ApiResult() { + override val ok: Boolean = false + override val data: Result? = null + } + + abstract val data: ResultData? + abstract val error: Error? + + abstract val ok: Boolean + + val failed: Boolean get() = !ok +} diff --git a/app/src/main/java/com/tinaciousdesign/interviews/stocks/networking/api/StocksApi.kt b/app/src/main/java/com/tinaciousdesign/interviews/stocks/networking/api/StocksApi.kt new file mode 100644 index 0000000..16b978f --- /dev/null +++ b/app/src/main/java/com/tinaciousdesign/interviews/stocks/networking/api/StocksApi.kt @@ -0,0 +1,14 @@ +package com.tinaciousdesign.interviews.stocks.networking.api + +import com.tinaciousdesign.interviews.stocks.models.Stock +import retrofit2.http.GET + +interface StocksApi { + // This endpoint will allow you to search for "omni" or "lol" and see matches where exact matches are prioritized. + // To use, comment out the provided endpoint GET("...") and comment this one back in +// @GET("tinacious/a3ddc32e49c04b5de21e4bb30eb47e68/raw/5b590f6f369fb92fc49e33a14ab2275eb5629c24/mock-stocks.json") + + // Provided endpoint + @GET("priyanshrastogi/0e1d4f8d517698cfdced49f5e59567be/raw/9158ad254e92aaffe215e950f4846a23a0680703/mock-stocks.json") + suspend fun getStocks(): List +} diff --git a/app/src/main/java/com/tinaciousdesign/interviews/stocks/repositories/StocksRepository.kt b/app/src/main/java/com/tinaciousdesign/interviews/stocks/repositories/StocksRepository.kt new file mode 100644 index 0000000..0f03aea --- /dev/null +++ b/app/src/main/java/com/tinaciousdesign/interviews/stocks/repositories/StocksRepository.kt @@ -0,0 +1,62 @@ +package com.tinaciousdesign.interviews.stocks.repositories + +import com.tinaciousdesign.interviews.stocks.db.stock.StockDao +import com.tinaciousdesign.interviews.stocks.db.stock.StockEntity +import com.tinaciousdesign.interviews.stocks.logging.Logger +import com.tinaciousdesign.interviews.stocks.models.Stock +import com.tinaciousdesign.interviews.stocks.networking.ApiError +import com.tinaciousdesign.interviews.stocks.networking.ApiResult +import com.tinaciousdesign.interviews.stocks.networking.api.StocksApi +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map +import javax.inject.Inject + +interface StocksRepository { + class GetStocksError(): ApiError() + + fun findStocksFlow(query: String): Flow> + + suspend fun fetchStocks(forceRefresh: Boolean = false): ApiResult, GetStocksError> +} + +class StocksRepositoryImpl @Inject constructor( + private val stocksApi: StocksApi, + private val stockDao: StockDao, +) : StocksRepository { + private var cachedStocks = listOf() + + override fun findStocksFlow(query: String): Flow> { + return stockDao.findStocksFlow(query).flowOn(Dispatchers.IO).map { stocks -> + stocks.map { stockEntity -> + Stock( + ticker = stockEntity.ticker, + name = stockEntity.name, + price = stockEntity.price + ) + } + } + } + + override suspend fun fetchStocks(forceRefresh: Boolean): ApiResult, StocksRepository.GetStocksError> { + if (cachedStocks.isNotEmpty() && !forceRefresh) { + return ApiResult.Success(cachedStocks) + } + + return try { + val response = stocksApi.getStocks() + + cachedStocks = response + + val stockEntities = response.map(StockEntity::fromStock) + stockDao.deleteAll() + stockDao.insertAll(stockEntities) + + ApiResult.Success(response) + } catch (e: Exception) { + Logger.e(e) + ApiResult.Failed(StocksRepository.GetStocksError()) + } + } +} diff --git a/app/src/main/java/com/tinaciousdesign/interviews/stocks/ui/TestTags.kt b/app/src/main/java/com/tinaciousdesign/interviews/stocks/ui/TestTags.kt new file mode 100644 index 0000000..b0ddd1a --- /dev/null +++ b/app/src/main/java/com/tinaciousdesign/interviews/stocks/ui/TestTags.kt @@ -0,0 +1,5 @@ +package com.tinaciousdesign.interviews.stocks.ui + +object TestTags { + val searchField = "searchField" +} diff --git a/app/src/main/java/com/tinaciousdesign/interviews/stocks/ui/components/Divider.kt b/app/src/main/java/com/tinaciousdesign/interviews/stocks/ui/components/Divider.kt new file mode 100644 index 0000000..e403539 --- /dev/null +++ b/app/src/main/java/com/tinaciousdesign/interviews/stocks/ui/components/Divider.kt @@ -0,0 +1,22 @@ +package com.tinaciousdesign.interviews.stocks.ui.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp + +@Composable +fun Divider( + color: Color, +) { + Box( + modifier = Modifier + .background(color) + .height(1.dp) + .fillMaxWidth() + ) +} diff --git a/app/src/main/java/com/tinaciousdesign/interviews/stocks/ui/components/EmptyState.kt b/app/src/main/java/com/tinaciousdesign/interviews/stocks/ui/components/EmptyState.kt new file mode 100644 index 0000000..1e8d7a1 --- /dev/null +++ b/app/src/main/java/com/tinaciousdesign/interviews/stocks/ui/components/EmptyState.kt @@ -0,0 +1,44 @@ +package com.tinaciousdesign.interviews.stocks.ui.components + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp + +@Composable +fun EmptyState( + title: String, + message: String, + modifier: Modifier = Modifier, +) { + Box( + contentAlignment = Alignment.Center, + modifier = modifier, + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.padding(20.dp), + ) { + Text( + text = title, + textAlign = TextAlign.Center, + fontWeight = FontWeight.Bold, + fontSize = 20.sp, + modifier = Modifier.padding(bottom = 20.dp) + ) + Text( + text = message, + textAlign = TextAlign.Center, + fontSize = 17.sp, + ) + } + + } +} diff --git a/app/src/main/java/com/tinaciousdesign/interviews/stocks/ui/components/SearchInputView.kt b/app/src/main/java/com/tinaciousdesign/interviews/stocks/ui/components/SearchInputView.kt new file mode 100644 index 0000000..17d77fc --- /dev/null +++ b/app/src/main/java/com/tinaciousdesign/interviews/stocks/ui/components/SearchInputView.kt @@ -0,0 +1,51 @@ +package com.tinaciousdesign.interviews.stocks.ui.components + +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Button +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.unit.dp +import com.tinaciousdesign.interviews.stocks.R +import com.tinaciousdesign.interviews.stocks.ui.TestTags +import com.tinaciousdesign.interviews.stocks.ui.icons.TintedIconDrawable + +@Composable +fun SearchInputView(currentValue: String, onSearch: (String) -> Unit) { + val context = LocalContext.current + + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .padding( + vertical = 20.dp, + horizontal = 14.dp, + ) + ) { + OutlinedTextField( + value = currentValue, + onValueChange = onSearch, + placeholder = { + Text(context.getString(R.string.search_field_placeholder)) + }, + leadingIcon = { + TintedIconDrawable(R.drawable.ic_search, R.string.search_icon_content_description) + }, + modifier = Modifier + .weight(1.0f) + .padding(end = 14.dp) + .testTag(TestTags.searchField) + ) + + Button({ + onSearch("") + }) { + Text(context.getString(R.string.search_clear_button)) + } + } +} diff --git a/app/src/main/java/com/tinaciousdesign/interviews/stocks/ui/components/StockSearchResultListItem.kt b/app/src/main/java/com/tinaciousdesign/interviews/stocks/ui/components/StockSearchResultListItem.kt new file mode 100644 index 0000000..c8fcd07 --- /dev/null +++ b/app/src/main/java/com/tinaciousdesign/interviews/stocks/ui/components/StockSearchResultListItem.kt @@ -0,0 +1,64 @@ +package com.tinaciousdesign.interviews.stocks.ui.components + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import coil.compose.AsyncImage +import com.tinaciousdesign.interviews.stocks.models.Stock + +@Composable +fun StockSearchResultListItem( + stock: Stock +) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .padding( + horizontal = 12.dp, + vertical = 12.dp, + ) + ) { + AsyncImage( + model = "https://api.dicebear.com/9.x/glass/png?seed=${stock.ticker}", + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier + .width(60.dp) + .height(60.dp) + .clip(CircleShape) + ) + + Column( + modifier = Modifier + .padding(start = 12.dp) + ) { + Text( + text = stock.ticker, + fontSize = 17.sp, + fontWeight = FontWeight.SemiBold, + ) + Text( + text = stock.name, + ) + } + + Spacer( + modifier = Modifier.weight(1.0f) + ) + + Text(stock.formattedPrice) + } +} diff --git a/app/src/main/java/com/tinaciousdesign/interviews/stocks/ui/components/StockSearchResults.kt b/app/src/main/java/com/tinaciousdesign/interviews/stocks/ui/components/StockSearchResults.kt new file mode 100644 index 0000000..0e2ca24 --- /dev/null +++ b/app/src/main/java/com/tinaciousdesign/interviews/stocks/ui/components/StockSearchResults.kt @@ -0,0 +1,36 @@ +package com.tinaciousdesign.interviews.stocks.ui.components + +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import com.tinaciousdesign.interviews.stocks.R +import com.tinaciousdesign.interviews.stocks.models.Stock + +@Composable +fun StockSearchResults( + stocks: List, +) { + val context = LocalContext.current + + LazyColumn { + itemsIndexed(stocks) { idx, stock -> + StockSearchResultListItem(stock) + + if (idx < stocks.lastIndex) { + Divider(MaterialTheme.colorScheme.surface) + } + } + } + if (stocks.isEmpty()) { + EmptyState( + title = context.getString(R.string.stock_search_no_results_heading), + message = context.getString(R.string.stock_search_no_results_message), + modifier = Modifier + .fillMaxSize() + ) + } +} diff --git a/app/src/main/java/com/tinaciousdesign/interviews/stocks/ui/components/UnstyledButton.kt b/app/src/main/java/com/tinaciousdesign/interviews/stocks/ui/components/UnstyledButton.kt new file mode 100644 index 0000000..9a917a6 --- /dev/null +++ b/app/src/main/java/com/tinaciousdesign/interviews/stocks/ui/components/UnstyledButton.kt @@ -0,0 +1,25 @@ +package com.tinaciousdesign.interviews.stocks.ui.components + +import androidx.compose.foundation.layout.RowScope +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color + +@Composable +fun UnstyledButton( + modifier: Modifier = Modifier, + onClick: () -> Unit, + content: @Composable RowScope.() -> Unit, +) { + Button( + colors = ButtonDefaults.buttonColors( + containerColor = Color.Transparent, + contentColor = Color.Transparent, + ), + modifier = modifier, + onClick = onClick, + content = content + ) +} diff --git a/app/src/main/java/com/tinaciousdesign/interviews/stocks/ui/icons/IconDrawable.kt b/app/src/main/java/com/tinaciousdesign/interviews/stocks/ui/icons/IconDrawable.kt new file mode 100644 index 0000000..e283102 --- /dev/null +++ b/app/src/main/java/com/tinaciousdesign/interviews/stocks/ui/icons/IconDrawable.kt @@ -0,0 +1,34 @@ +package com.tinaciousdesign.interviews.stocks.ui.icons + +import androidx.annotation.DrawableRes +import androidx.annotation.StringRes +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.width +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp + +@Composable +fun IconDrawable( + @DrawableRes drawableId: Int, + @StringRes contentDescriptionRes: Int, + tint: Color = Color.Unspecified, + size: Dp = 24.dp +) { + val context = LocalContext.current + + Image( + painterResource(drawableId), + context.getString(contentDescriptionRes), + colorFilter = ColorFilter.tint(tint), + modifier = Modifier + .width(size) + .height(size) + ) +} diff --git a/app/src/main/java/com/tinaciousdesign/interviews/stocks/ui/icons/TinaciousDesignLogoIcon.kt b/app/src/main/java/com/tinaciousdesign/interviews/stocks/ui/icons/TinaciousDesignLogoIcon.kt new file mode 100644 index 0000000..0c7ace8 --- /dev/null +++ b/app/src/main/java/com/tinaciousdesign/interviews/stocks/ui/icons/TinaciousDesignLogoIcon.kt @@ -0,0 +1,27 @@ +package com.tinaciousdesign.interviews.stocks.ui.icons + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.width +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import com.tinaciousdesign.interviews.stocks.R + +@Composable +fun TinaciousDesignLogoIcon( + size: Dp = 24.dp +) { + val context = LocalContext.current + + Image( + painterResource(R.drawable.tinacious_design_logo), + context.getString(R.string.tinacious_design_logo_content_description), + modifier = Modifier + .width(size) + .height(size) + ) +} diff --git a/app/src/main/java/com/tinaciousdesign/interviews/stocks/ui/icons/TintedIconDrawable.kt b/app/src/main/java/com/tinaciousdesign/interviews/stocks/ui/icons/TintedIconDrawable.kt new file mode 100644 index 0000000..263349f --- /dev/null +++ b/app/src/main/java/com/tinaciousdesign/interviews/stocks/ui/icons/TintedIconDrawable.kt @@ -0,0 +1,22 @@ +package com.tinaciousdesign.interviews.stocks.ui.icons + +import androidx.annotation.DrawableRes +import androidx.annotation.StringRes +import androidx.compose.material3.LocalContentColor +import androidx.compose.runtime.Composable +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp + +@Composable +fun TintedIconDrawable( + @DrawableRes drawableId: Int, + @StringRes contentDescriptionRes: Int, + size: Dp = 24.dp +) { + IconDrawable( + drawableId = drawableId, + contentDescriptionRes = contentDescriptionRes, + size = size, + tint = LocalContentColor.current, + ) +} diff --git a/app/src/main/java/com/tinaciousdesign/interviews/stocks/ui/screens/about/AboutScreen.kt b/app/src/main/java/com/tinaciousdesign/interviews/stocks/ui/screens/about/AboutScreen.kt new file mode 100644 index 0000000..ea878e7 --- /dev/null +++ b/app/src/main/java/com/tinaciousdesign/interviews/stocks/ui/screens/about/AboutScreen.kt @@ -0,0 +1,59 @@ +package com.tinaciousdesign.interviews.stocks.ui.screens.about + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.tinaciousdesign.interviews.stocks.R +import com.tinaciousdesign.interviews.stocks.ui.components.UnstyledButton +import com.tinaciousdesign.interviews.stocks.ui.icons.TinaciousDesignLogoIcon +import com.tinaciousdesign.interviews.stocks.utils.openExternalBrowser + +@Composable +fun AboutScreen() { + val context = LocalContext.current + + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .fillMaxHeight() + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier + .fillMaxWidth() + .padding(30.dp) + ) { + UnstyledButton(onClick = { + context.openExternalBrowser("https://tinaciousdesign.com") + }) { + TinaciousDesignLogoIcon(100.dp) + } + + Text( + text = context.getString(R.string.about_screen_title), + fontSize = 20.sp, + fontWeight = FontWeight.Bold, + textAlign = TextAlign.Center, + modifier = Modifier + .padding(bottom = 16.dp, top = 20.dp) + ) + + Text( + text = context.getString(R.string.about_screen_message), + textAlign = TextAlign.Center, + fontSize = 17.sp, + ) + } + } +} diff --git a/app/src/main/java/com/tinaciousdesign/interviews/stocks/ui/screens/stocksearch/StockSearchScreen.kt b/app/src/main/java/com/tinaciousdesign/interviews/stocks/ui/screens/stocksearch/StockSearchScreen.kt new file mode 100644 index 0000000..c20c17b --- /dev/null +++ b/app/src/main/java/com/tinaciousdesign/interviews/stocks/ui/screens/stocksearch/StockSearchScreen.kt @@ -0,0 +1,54 @@ +package com.tinaciousdesign.interviews.stocks.ui.screens.stocksearch + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.compose.LifecycleEventEffect +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.tinaciousdesign.interviews.stocks.R +import com.tinaciousdesign.interviews.stocks.ui.components.Divider +import com.tinaciousdesign.interviews.stocks.ui.components.EmptyState +import com.tinaciousdesign.interviews.stocks.ui.components.SearchInputView +import com.tinaciousdesign.interviews.stocks.ui.components.StockSearchResults + +@Composable +fun StockSearchScreen( + viewModel: StockSearchViewModel +) { + val context = LocalContext.current + + val stocks by viewModel.stocks.collectAsStateWithLifecycle() + val searchQuery by viewModel.searchQuery.collectAsStateWithLifecycle() + val isSearching by viewModel.isSearching.collectAsStateWithLifecycle(false) + + LifecycleEventEffect(Lifecycle.Event.ON_START) { + viewModel.loadStocks() + } + + Column { + SearchInputView(searchQuery) { newValue -> + viewModel.onSearch(newValue) + } + + Divider(MaterialTheme.colorScheme.secondary) + + Box(modifier = Modifier.weight(1.0f)) { + if (isSearching) { + StockSearchResults(stocks) + } else { + EmptyState( + title = context.getString(R.string.stock_search_empty_heading), + message = context.getString(R.string.stock_search_empty_message), + modifier = Modifier + .fillMaxSize() + ) + } + } + } +} diff --git a/app/src/main/java/com/tinaciousdesign/interviews/stocks/ui/screens/stocksearch/StockSearchViewModel.kt b/app/src/main/java/com/tinaciousdesign/interviews/stocks/ui/screens/stocksearch/StockSearchViewModel.kt new file mode 100644 index 0000000..21a12f7 --- /dev/null +++ b/app/src/main/java/com/tinaciousdesign/interviews/stocks/ui/screens/stocksearch/StockSearchViewModel.kt @@ -0,0 +1,71 @@ +package com.tinaciousdesign.interviews.stocks.ui.screens.stocksearch + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.tinaciousdesign.interviews.stocks.R +import com.tinaciousdesign.interviews.stocks.models.Stock +import com.tinaciousdesign.interviews.stocks.repositories.StocksRepository +import com.tinaciousdesign.interviews.stocks.ui.snackbar.SnackBarController +import com.tinaciousdesign.interviews.stocks.ui.snackbar.SnackBarEvent +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import javax.inject.Inject + +interface StockSearchVM { + val stocks: StateFlow> + val searchQuery: StateFlow + val isSearching: Flow + + fun loadStocks() + fun onSearch(query: String) +} + +@HiltViewModel +class StockSearchViewModel @Inject constructor( + private val savedStateHandle: SavedStateHandle, + private val stocksRepository: StocksRepository, +) : StockSearchVM, ViewModel() { + + override val searchQuery: StateFlow = savedStateHandle.getStateFlow("searchQuery", "") + + override val stocks: StateFlow> = + searchQuery.flatMapLatest { query -> + stocksRepository.findStocksFlow(query) + } + .combine(searchQuery, ::Pair) + .map { (list, query) -> + list.sortedWith(Stock.compareQuery(query)) + } + .stateIn( + viewModelScope, + SharingStarted.WhileSubscribed(5000), + emptyList(), + ) + + override val isSearching = searchQuery.map { it.isNotBlank() } + + override fun loadStocks() { + viewModelScope.launch { + val result = stocksRepository.fetchStocks() + if (result.failed) { + SnackBarController.sendEvent( + SnackBarEvent({ resources -> + resources.getString(R.string.stock_search_error_stocks_fetch_failed) + }) + ) + } + } + } + + override fun onSearch(query: String) { + savedStateHandle["searchQuery"] = query + } +} diff --git a/app/src/main/java/com/tinaciousdesign/interviews/stocks/ui/snackbar/SnackBarController.kt b/app/src/main/java/com/tinaciousdesign/interviews/stocks/ui/snackbar/SnackBarController.kt new file mode 100644 index 0000000..86b646d --- /dev/null +++ b/app/src/main/java/com/tinaciousdesign/interviews/stocks/ui/snackbar/SnackBarController.kt @@ -0,0 +1,26 @@ +package com.tinaciousdesign.interviews.stocks.ui.snackbar + +import android.content.res.Resources +import androidx.compose.material3.SnackbarDuration +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.receiveAsFlow + +data class SnackBarEvent( + val getLocalizedMessage: (Resources) -> String, + val duration: SnackbarDuration = SnackbarDuration.Long, + val action: SnackBarAction? = null +) + +data class SnackBarAction( + val getLocalizedName: (Resources) -> String, + val action: suspend () -> Unit +) + +object SnackBarController { + private val _events = Channel() + val events = _events.receiveAsFlow() + + suspend fun sendEvent(event: SnackBarEvent) { + _events.send(event) + } +} diff --git a/app/src/main/java/com/tinaciousdesign/interviews/stocks/ui/theme/Color.kt b/app/src/main/java/com/tinaciousdesign/interviews/stocks/ui/theme/Color.kt new file mode 100644 index 0000000..8361e82 --- /dev/null +++ b/app/src/main/java/com/tinaciousdesign/interviews/stocks/ui/theme/Color.kt @@ -0,0 +1,29 @@ +package com.tinaciousdesign.interviews.stocks.ui.theme + +import androidx.compose.ui.graphics.Color + +/** + * Colours: https://codepen.io/tinacious/pen/pobYoWj + */ +object BrandColours { + val pink = Color(0xFFFF3399) + val blue = Color(0xFF33D5FF) + val green = Color(0xFF00D364) + val turquoise = Color(0xFF00CED1) + val purple = Color(0xFFCC66FF) + val yellow = Color(0xFFFFCC66) + val red = Color(0xFFF10F36) + + // Shades generated for #1d1d26 with: https://colour-tools.pages.dev/shades + object Greys { + val black = Color(0xFF000000) + val grey_50 = Color(0xFF0B0B0E) + val grey_100 = Color(0xFF2C2C3A) + val grey_200 = Color(0xFF585874) + val grey_300 = Color(0xFF8B8BA7) + val grey_400 = Color(0xFFC5C5D3) + val grey_450 = Color(0xFFF1F1F4) + val white = Color(0xFFFFFFFF) + } +} + diff --git a/app/src/main/java/com/tinaciousdesign/interviews/stocks/ui/theme/Theme.kt b/app/src/main/java/com/tinaciousdesign/interviews/stocks/ui/theme/Theme.kt new file mode 100644 index 0000000..c31fc7d --- /dev/null +++ b/app/src/main/java/com/tinaciousdesign/interviews/stocks/ui/theme/Theme.kt @@ -0,0 +1,58 @@ +package com.tinaciousdesign.interviews.stocks.ui.theme + +import android.app.Activity +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.SideEffect +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.platform.LocalView +import androidx.core.view.WindowCompat + +private val DarkColorScheme = darkColorScheme( + background = BrandColours.Greys.grey_50, + onBackground = BrandColours.Greys.white, + surface = BrandColours.Greys.grey_100, + onSurface = BrandColours.Greys.white, + surfaceTint = BrandColours.Greys.grey_300, + primary = BrandColours.pink, + secondary = BrandColours.blue, + tertiary = BrandColours.turquoise +) + +private val LightColorScheme = lightColorScheme( + background = BrandColours.Greys.white, + onBackground = BrandColours.Greys.black, + surface = BrandColours.Greys.grey_450, + surfaceVariant = BrandColours.Greys.grey_400, + onSurface = BrandColours.Greys.black, + surfaceTint = BrandColours.Greys.grey_300, + primary = BrandColours.pink, + secondary = BrandColours.blue, + tertiary = BrandColours.turquoise, +) + +@Composable +fun StocksTheme( + darkTheme: Boolean = isSystemInDarkTheme(), + content: @Composable () -> Unit +) { + val colorScheme = if (darkTheme) DarkColorScheme else LightColorScheme + + val view = LocalView.current + if (!view.isInEditMode) { + SideEffect { + val window = (view.context as Activity).window + window.statusBarColor = colorScheme.primary.toArgb() + WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = darkTheme + } + } + + MaterialTheme( + colorScheme = colorScheme, + typography = Typography, + content = content + ) +} diff --git a/app/src/main/java/com/tinaciousdesign/interviews/stocks/ui/theme/Type.kt b/app/src/main/java/com/tinaciousdesign/interviews/stocks/ui/theme/Type.kt new file mode 100644 index 0000000..7068189 --- /dev/null +++ b/app/src/main/java/com/tinaciousdesign/interviews/stocks/ui/theme/Type.kt @@ -0,0 +1,18 @@ +package com.tinaciousdesign.interviews.stocks.ui.theme + +import androidx.compose.material3.Typography +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.sp + +// Set of Material typography styles to start with +val Typography = Typography( + bodyLarge = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 16.sp, + lineHeight = 24.sp, + letterSpacing = 0.5.sp + ) +) diff --git a/app/src/main/java/com/tinaciousdesign/interviews/stocks/ui/utils/KeyboardVisibleState.kt b/app/src/main/java/com/tinaciousdesign/interviews/stocks/ui/utils/KeyboardVisibleState.kt new file mode 100644 index 0000000..6c00f31 --- /dev/null +++ b/app/src/main/java/com/tinaciousdesign/interviews/stocks/ui/utils/KeyboardVisibleState.kt @@ -0,0 +1,43 @@ +package com.tinaciousdesign.interviews.stocks.ui.utils + +import android.graphics.Rect +import android.view.ViewTreeObserver +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.State +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.platform.LocalView + +enum class KeyboardState { + Opened, + Closed +} + +@Composable +fun keyboardVisibleState(): State { + val keyboardState = remember { mutableStateOf(KeyboardState.Closed) } + val view = LocalView.current + + DisposableEffect(view) { + val onGlobalListener = ViewTreeObserver.OnGlobalLayoutListener { + val rect = Rect() + view.getWindowVisibleDisplayFrame(rect) + val screenHeight = view.rootView.height + val keypadHeight = screenHeight - rect.bottom + + keyboardState.value = if (keypadHeight > screenHeight * 0.15) { + KeyboardState.Opened + } else { + KeyboardState.Closed + } + } + view.viewTreeObserver.addOnGlobalLayoutListener(onGlobalListener) + + onDispose { + view.viewTreeObserver.removeOnGlobalLayoutListener(onGlobalListener) + } + } + + return keyboardState +} diff --git a/app/src/main/java/com/tinaciousdesign/interviews/stocks/ui/utils/ObserveInternetConnectionState.kt b/app/src/main/java/com/tinaciousdesign/interviews/stocks/ui/utils/ObserveInternetConnectionState.kt new file mode 100644 index 0000000..a34aebf --- /dev/null +++ b/app/src/main/java/com/tinaciousdesign/interviews/stocks/ui/utils/ObserveInternetConnectionState.kt @@ -0,0 +1,39 @@ +package com.tinaciousdesign.interviews.stocks.ui.utils + +import androidx.compose.runtime.Composable +import com.tinaciousdesign.interviews.stocks.R +import com.tinaciousdesign.interviews.stocks.events.AppEvent +import com.tinaciousdesign.interviews.stocks.events.EventBus +import com.tinaciousdesign.interviews.stocks.events.ObserveAsEvents +import com.tinaciousdesign.interviews.stocks.ui.snackbar.SnackBarController +import com.tinaciousdesign.interviews.stocks.ui.snackbar.SnackBarEvent +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch + +@Composable +fun ObserveInternetConnectionState( + eventBus: EventBus, + coroutineScope: CoroutineScope, +) { + ObserveAsEvents(eventBus.events) { appEvent -> + coroutineScope.launch { + when (appEvent) { + AppEvent.ConnectionLost -> { + SnackBarController.sendEvent( + SnackBarEvent({ resources -> + resources.getString(R.string.connection_lost_message) + }) + ) + } + AppEvent.ConnectionRestored -> { + SnackBarController.sendEvent( + SnackBarEvent({ resources -> + resources.getString(R.string.connection_restored_message) + }) + ) + } + else -> {} + } + } + } +} diff --git a/app/src/main/java/com/tinaciousdesign/interviews/stocks/ui/utils/ObserveSnackBarEvents.kt b/app/src/main/java/com/tinaciousdesign/interviews/stocks/ui/utils/ObserveSnackBarEvents.kt new file mode 100644 index 0000000..49f6469 --- /dev/null +++ b/app/src/main/java/com/tinaciousdesign/interviews/stocks/ui/utils/ObserveSnackBarEvents.kt @@ -0,0 +1,36 @@ +package com.tinaciousdesign.interviews.stocks.ui.utils + +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.SnackbarResult +import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.LocalContext +import com.tinaciousdesign.interviews.stocks.events.ObserveAsEvents +import com.tinaciousdesign.interviews.stocks.ui.snackbar.SnackBarEvent +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.launch + +@Composable +fun ObserveSnackBarEvents( + snackBarFlow: Flow, + snackBarHostState: SnackbarHostState, + coroutineScope: CoroutineScope, +) { + val resources = LocalContext.current.resources + + ObserveAsEvents(snackBarFlow, snackBarHostState) { event -> + coroutineScope.launch { + snackBarHostState.currentSnackbarData?.dismiss() + + val result = snackBarHostState.showSnackbar( + message = event.getLocalizedMessage(resources), + actionLabel = event.action?.getLocalizedName?.invoke(resources), + duration = event.duration, + ) + + if (result == SnackbarResult.ActionPerformed) { + event.action?.action?.invoke() + } + } + } +} diff --git a/app/src/main/java/com/tinaciousdesign/interviews/stocks/utils/.gitkeep b/app/src/main/java/com/tinaciousdesign/interviews/stocks/utils/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/app/src/main/java/com/tinaciousdesign/interviews/stocks/utils/IntentUtils.kt b/app/src/main/java/com/tinaciousdesign/interviews/stocks/utils/IntentUtils.kt new file mode 100644 index 0000000..9ea051a --- /dev/null +++ b/app/src/main/java/com/tinaciousdesign/interviews/stocks/utils/IntentUtils.kt @@ -0,0 +1,12 @@ +package com.tinaciousdesign.interviews.stocks.utils + +import android.content.Context +import android.content.Intent +import android.net.Uri + +fun Context.openExternalBrowser(url: String) { + val intent = Intent(Intent.ACTION_VIEW).apply { + data = Uri.parse(url) + } + startActivity(intent) +} diff --git a/app/src/main/java/com/tinaciousdesign/interviews/stocks/utils/StringUtils.kt b/app/src/main/java/com/tinaciousdesign/interviews/stocks/utils/StringUtils.kt new file mode 100644 index 0000000..24c81e0 --- /dev/null +++ b/app/src/main/java/com/tinaciousdesign/interviews/stocks/utils/StringUtils.kt @@ -0,0 +1,4 @@ +package com.tinaciousdesign.interviews.stocks.utils + +fun String.lastSegment(delimiter: String): String? = + split(delimiter).lastOrNull() \ No newline at end of file diff --git a/app/src/main/java/com/tinaciousdesign/interviews/stocks/workers/.gitkeep b/app/src/main/java/com/tinaciousdesign/interviews/stocks/workers/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/app/src/main/res/drawable/ic_dollar.xml b/app/src/main/res/drawable/ic_dollar.xml new file mode 100644 index 0000000..6dddcbb --- /dev/null +++ b/app/src/main/res/drawable/ic_dollar.xml @@ -0,0 +1,9 @@ + + + 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 0000000..07d5da9 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,170 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/app/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 0000000..2b068d1 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_search.xml b/app/src/main/res/drawable/ic_search.xml new file mode 100644 index 0000000..d1140de --- /dev/null +++ b/app/src/main/res/drawable/ic_search.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/tinacious_design_logo.xml b/app/src/main/res/drawable/tinacious_design_logo.xml new file mode 100644 index 0000000..a5ed7b0 --- /dev/null +++ b/app/src/main/res/drawable/tinacious_design_logo.xml @@ -0,0 +1,26 @@ + + + + + + + + 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 0000000..036d09b --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file 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 0000000..036d09b --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher.webp b/app/src/main/res/mipmap-hdpi/ic_launcher.webp new file mode 100644 index 0000000..bfa8dc9 Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp b/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp new file mode 100644 index 0000000..c937610 Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp differ diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp new file mode 100644 index 0000000..f4bcb28 Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher.webp b/app/src/main/res/mipmap-mdpi/ic_launcher.webp new file mode 100644 index 0000000..95eab22 Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp b/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp new file mode 100644 index 0000000..50f6b5e Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp new file mode 100644 index 0000000..70266a7 Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher.webp new file mode 100644 index 0000000..5279dd6 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp new file mode 100644 index 0000000..6e5214d Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..83acc82 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp new file mode 100644 index 0000000..6ab8b42 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp new file mode 100644 index 0000000..807a024 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..b2e30a1 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp new file mode 100644 index 0000000..25f1a1a Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp new file mode 100644 index 0000000..91e4d27 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..3557953 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..045e125 --- /dev/null +++ b/app/src/main/res/values/colors.xml @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/res/values/ic_launcher_background.xml b/app/src/main/res/values/ic_launcher_background.xml new file mode 100644 index 0000000..5eead77 --- /dev/null +++ b/app/src/main/res/values/ic_launcher_background.xml @@ -0,0 +1,4 @@ + + + #023465 + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..4c7d18d --- /dev/null +++ b/app/src/main/res/values/strings.xml @@ -0,0 +1,26 @@ + + Stocks + Tinacious Design logo + + + Stocks + About + + Lost internet connection. Using cached data if available. + Internet connection restored. + + Failed to fetch stocks. Will be using cached data if available. + + Search + Search for a stock… + Clear + + 🧐 Search for stocks + Use the search field above to find stocks by ticker or by name + + ☹️ No stocks + No stocks matched your search query + + About + This stocks app was built by Tina Holly at Tinacious Design. + diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml new file mode 100644 index 0000000..16d4c58 --- /dev/null +++ b/app/src/main/res/values/themes.xml @@ -0,0 +1,5 @@ + + + +