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)
+
+
+
+
+
+
+
+
+
+## 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.
+
+
+
+
+### 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.
+
+
+
+
+
+
+## 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.
+
+
+
+
+### 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.
+
+
+
+
+## App Screenshots
+
+### App icon
+
+
+
+### Search results
+
+
+
+
+
+
+
+### Empty states
+
+
+
+
+
+
+
+### About screen
+
+
+
+
+
+## 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 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/xml/backup_rules.xml b/app/src/main/res/xml/backup_rules.xml
new file mode 100644
index 0000000..fa0f996
--- /dev/null
+++ b/app/src/main/res/xml/backup_rules.xml
@@ -0,0 +1,13 @@
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/xml/data_extraction_rules.xml b/app/src/main/res/xml/data_extraction_rules.xml
new file mode 100644
index 0000000..9ee9997
--- /dev/null
+++ b/app/src/main/res/xml/data_extraction_rules.xml
@@ -0,0 +1,19 @@
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/test/java/com/tinaciousdesign/interviews/stocks/models/StockTest.kt b/app/src/test/java/com/tinaciousdesign/interviews/stocks/models/StockTest.kt
new file mode 100644
index 0000000..eb58696
--- /dev/null
+++ b/app/src/test/java/com/tinaciousdesign/interviews/stocks/models/StockTest.kt
@@ -0,0 +1,112 @@
+package com.tinaciousdesign.interviews.stocks.models
+
+import org.junit.Assert.assertEquals
+import org.junit.Test
+
+class StockTest {
+ @Test
+ fun `formats the price rounding it to 2 decimal places`() {
+ val subject = Stock("ABC", "ABC Co.", 3.14159)
+
+ val result = subject.formattedPrice
+
+ assertEquals("3.14", result)
+ }
+
+ @Test
+ fun `matches() returns only stocks whose name or ticker contain the provided query - case sensitive`() {
+ val query = "A"
+ val stocks = listOf(
+ Stock("FFF", "FFF Co", 100.0),
+ Stock("DDD", "DDD Co", 100.0),
+ Stock("CCC", "CCC Co", 100.0),
+ Stock("AB", "Absolute", 100.0),
+ Stock("ABC", "ABC Industries", 100.0),
+ Stock("XYZ", "XYZ and ABC", 100.0),
+ )
+
+ val result = stocks.matches(query)
+ val expected = listOf(
+ Stock("AB", "Absolute", 100.0),
+ Stock("ABC", "ABC Industries", 100.0),
+ Stock("XYZ", "XYZ and ABC", 100.0),
+ )
+
+ assertEquals(expected, result)
+ }
+
+ @Test
+ fun `matches() returns only stocks whose name or ticker contain the provided query - case insensitive`() {
+ val query = "a"
+ val stocks = listOf(
+ Stock("FFF", "FFF Co", 100.0),
+ Stock("DDD", "DDD Co", 100.0),
+ Stock("CCC", "CCC Co", 100.0),
+ Stock("AB", "Absolute", 100.0),
+ Stock("ABC", "ABC Industries", 100.0),
+ Stock("XYZ", "XYZ and ABC", 100.0),
+ )
+
+ val result = stocks.matches(query)
+ val expected = listOf(
+ Stock("AB", "Absolute", 100.0),
+ Stock("ABC", "ABC Industries", 100.0),
+ Stock("XYZ", "XYZ and ABC", 100.0),
+ )
+
+ assertEquals(expected, result)
+ }
+
+ @Test
+ fun `matches() returns an empty list if no matches`() {
+ val query = "P"
+ val stocks = listOf(
+ Stock("FFF", "FFF Co", 100.0),
+ Stock("DDD", "DDD Co", 100.0),
+ Stock("CCC", "CCC Co", 100.0),
+ Stock("AB", "Absolute", 100.0),
+ Stock("ABC", "ABC Industries", 100.0),
+ Stock("XYZ", "XYZ and ABC", 100.0),
+ )
+
+ val result = stocks.matches(query)
+ val expected = emptyList()
+
+ assertEquals(expected, result)
+ }
+
+ @Test
+ fun `exact matches show first (AB)`() {
+ val query = "AB"
+ val stocks = listOf(
+ Stock("XYZ", "XYZ and ABC", 100.0),
+ Stock("AB", "Absolute", 100.0),
+ Stock("ABC", "ABC Industries", 100.0),
+ )
+
+ val result = stocks.sortedWith(Stock.compareQuery(query))
+
+ assertEquals(3, result.size)
+ assertEquals(
+ Stock("AB", "Absolute", 100.0),
+ result[0]
+ )
+ }
+
+ @Test
+ fun `exact matches show first (ABC)`() {
+ val query = "ABC"
+ val stocks = listOf(
+ Stock("XYZ", "XYZ and ABC", 100.0),
+ Stock("ABC", "ABC Industries", 100.0),
+ )
+
+ val result = stocks.sortedWith(Stock.compareQuery(query))
+
+ assertEquals(2, result.size)
+ assertEquals(
+ Stock("ABC", "ABC Industries", 100.0),
+ result[0]
+ )
+ }
+}
diff --git a/app/src/test/java/com/tinaciousdesign/interviews/stocks/repositories/StocksRepositoryTest.kt b/app/src/test/java/com/tinaciousdesign/interviews/stocks/repositories/StocksRepositoryTest.kt
new file mode 100644
index 0000000..0a3576d
--- /dev/null
+++ b/app/src/test/java/com/tinaciousdesign/interviews/stocks/repositories/StocksRepositoryTest.kt
@@ -0,0 +1,79 @@
+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.models.Stock
+import com.tinaciousdesign.interviews.stocks.networking.ApiResult
+import com.tinaciousdesign.interviews.stocks.networking.api.StocksApi
+import io.mockk.MockKAnnotations
+import io.mockk.coEvery
+import io.mockk.coVerify
+import io.mockk.impl.annotations.MockK
+import kotlinx.coroutines.runBlocking
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Test
+
+class StocksRepositoryTest {
+ @MockK
+ lateinit var stocksApi: StocksApi
+
+ @MockK
+ lateinit var stockDao: StockDao
+
+ @Before
+ fun setup() {
+ MockKAnnotations.init(this, relaxUnitFun = true)
+ }
+
+ @Test
+ fun `fetch stocks fetches the stocks`(): Unit = runBlocking {
+ val fetchedStocks = listOf(
+ Stock("FFF", "FFF Co", 100.0),
+ Stock("DDD", "DDD Co", 100.0),
+ Stock("CCC", "CCC Co", 100.0),
+ Stock("AB", "Absolute", 100.0),
+ Stock("ABC", "ABC Industries", 100.0),
+ Stock("XYZ", "XYZ and ABC", 100.0),
+ )
+
+ coEvery { stocksApi.getStocks() } answers { fetchedStocks }
+
+ val subject = StocksRepositoryImpl(stocksApi, stockDao)
+ val result = subject.fetchStocks()
+
+ assertTrue(result is ApiResult.Success)
+ assertEquals(fetchedStocks, result.data)
+ }
+
+ @Test
+ fun `fetch stocks caches the stocks in Room database`(): Unit = runBlocking {
+ val fetchedStocks = listOf(
+ Stock("FFF", "FFF Co", 100.0),
+ Stock("DDD", "DDD Co", 100.0),
+ Stock("CCC", "CCC Co", 100.0),
+ Stock("AB", "Absolute", 100.0),
+ Stock("ABC", "ABC Industries", 100.0),
+ Stock("XYZ", "XYZ and ABC", 100.0),
+ )
+
+ coEvery { stocksApi.getStocks() } answers { fetchedStocks }
+
+ val subject = StocksRepositoryImpl(stocksApi, stockDao)
+ subject.fetchStocks()
+
+ coVerify(exactly = 1) {
+ 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),
+ )
+ )
+ }
+ }
+}
diff --git a/app/src/test/java/com/tinaciousdesign/interviews/stocks/testutils/TestCoroutineRule.kt b/app/src/test/java/com/tinaciousdesign/interviews/stocks/testutils/TestCoroutineRule.kt
new file mode 100644
index 0000000..8692110
--- /dev/null
+++ b/app/src/test/java/com/tinaciousdesign/interviews/stocks/testutils/TestCoroutineRule.kt
@@ -0,0 +1,24 @@
+package com.tinaciousdesign.interviews.stocks.testutils
+
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.test.TestDispatcher
+import kotlinx.coroutines.test.UnconfinedTestDispatcher
+import kotlinx.coroutines.test.resetMain
+import kotlinx.coroutines.test.setMain
+import org.junit.rules.TestWatcher
+import org.junit.runner.Description
+
+class TestCoroutineRule(
+ private val testDispatcher: TestDispatcher = UnconfinedTestDispatcher()
+) : TestWatcher() {
+
+ override fun starting(description: Description?) {
+ super.starting(description)
+ Dispatchers.setMain(testDispatcher)
+ }
+
+ override fun finished(description: Description?) {
+ super.finished(description)
+ Dispatchers.resetMain()
+ }
+}
diff --git a/app/src/test/java/com/tinaciousdesign/interviews/stocks/ui/screens/stocksearch/StockSearchViewModelTest.kt b/app/src/test/java/com/tinaciousdesign/interviews/stocks/ui/screens/stocksearch/StockSearchViewModelTest.kt
new file mode 100644
index 0000000..502ac6b
--- /dev/null
+++ b/app/src/test/java/com/tinaciousdesign/interviews/stocks/ui/screens/stocksearch/StockSearchViewModelTest.kt
@@ -0,0 +1,115 @@
+package com.tinaciousdesign.interviews.stocks.ui.screens.stocksearch
+
+import androidx.lifecycle.SavedStateHandle
+import app.cash.turbine.test
+import com.tinaciousdesign.interviews.stocks.models.Stock
+import com.tinaciousdesign.interviews.stocks.networking.ApiResult
+import com.tinaciousdesign.interviews.stocks.repositories.StocksRepository
+import com.tinaciousdesign.interviews.stocks.testutils.TestCoroutineRule
+import io.mockk.MockKAnnotations
+import io.mockk.coEvery
+import io.mockk.coVerify
+import io.mockk.impl.annotations.MockK
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.collect
+import kotlinx.coroutines.flow.flowOf
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.test.runTest
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+
+class StockSearchViewModelTest {
+
+ @get:Rule
+ val testCoroutineRule = TestCoroutineRule()
+
+ @MockK
+ lateinit var stocksRepository: StocksRepository
+
+ @Before
+ fun setup() {
+ MockKAnnotations.init(this, relaxUnitFun = true)
+ }
+
+ @Test
+ fun `loads the stocks`(): Unit = runTest {
+ val fetchedStocks = listOf(
+ Stock("FFF", "FFF Co", 100.0),
+ Stock("DDD", "DDD Co", 100.0),
+ Stock("CCC", "CCC Co", 100.0),
+ Stock("AB", "Absolute", 100.0),
+ Stock("ABC", "ABC Industries", 100.0),
+ Stock("XYZ", "XYZ and ABC", 100.0),
+ )
+ coEvery { stocksRepository.fetchStocks() } answers { ApiResult.Success(fetchedStocks) }
+
+ val savedStateHandle = SavedStateHandle()
+ savedStateHandle["searchQuery"] = ""
+ val subject = StockSearchViewModel(savedStateHandle, stocksRepository)
+
+ subject.loadStocks()
+
+ coVerify(exactly = 1) {
+ stocksRepository.fetchStocks()
+ }
+ }
+
+ @Test
+ fun `exposes a state flow of the stocks`(): Unit = runTest {
+ val allStocks = listOf(
+ Stock("FFF", "FFF Co", 100.0),
+ Stock("DDD", "DDD Co", 100.0),
+ Stock("CCC", "CCC Co", 100.0),
+ Stock("AB", "Absolute", 100.0),
+ Stock("ABC", "ABC Industries", 100.0),
+ Stock("XYZ", "XYZ and ABC", 100.0),
+ )
+ val fakeStocksRepository = object : StocksRepository {
+ override fun findStocksFlow(query: String): Flow> = flowOf(allStocks)
+
+ override suspend fun fetchStocks(forceRefresh: Boolean): ApiResult, StocksRepository.GetStocksError> =
+ ApiResult.Success(emptyList())
+ }
+ val savedStateHandle = SavedStateHandle()
+ savedStateHandle["searchQuery"] = ""
+ val subject = StockSearchViewModel(savedStateHandle, fakeStocksRepository)
+
+ backgroundScope.launch {
+ subject.stocks.collect()
+ }
+
+ subject.stocks.test {
+ assertEquals(allStocks, awaitItem())
+ }
+ }
+
+ @Test
+ fun `supports searching for stocks by prioritizing exact matches`(): Unit = runTest {
+ val allStocks = listOf(
+ Stock("ABC", "ABC Industries", 100.0),
+ Stock("AB", "Absolute", 100.0), // This is the one that matches exactly "ab"
+ Stock("XYZ", "XYZ and ABC", 100.0),
+ )
+ val fakeStocksRepository = object : StocksRepository {
+ override fun findStocksFlow(query: String): Flow> = flowOf(allStocks)
+
+ override suspend fun fetchStocks(forceRefresh: Boolean): ApiResult, StocksRepository.GetStocksError> =
+ ApiResult.Success(emptyList())
+ }
+ val savedStateHandle = SavedStateHandle()
+ savedStateHandle["searchQuery"] = "ab"
+ val subject = StockSearchViewModel(savedStateHandle, fakeStocksRepository)
+
+ backgroundScope.launch {
+ subject.stocks.collect()
+ }
+
+ subject.stocks.test {
+ val searchedStocks = awaitItem()
+ assertEquals(Stock("AB", "Absolute", 100.0), searchedStocks[0])
+ }
+ }
+}
diff --git a/build.gradle.kts b/build.gradle.kts
new file mode 100644
index 0000000..2f263a8
--- /dev/null
+++ b/build.gradle.kts
@@ -0,0 +1,7 @@
+// Top-level build file where you can add configuration options common to all sub-projects/modules.
+plugins {
+ alias(libs.plugins.android.application) apply false
+ alias(libs.plugins.jetbrains.kotlin.android) apply false
+
+ id("com.google.dagger.hilt.android") version "2.51" apply false
+}
\ No newline at end of file
diff --git a/gradle.properties b/gradle.properties
new file mode 100644
index 0000000..20e2a01
--- /dev/null
+++ b/gradle.properties
@@ -0,0 +1,23 @@
+# Project-wide Gradle settings.
+# IDE (e.g. Android Studio) users:
+# Gradle settings configured through the IDE *will override*
+# any settings specified in this file.
+# For more details on how to configure your build environment visit
+# http://www.gradle.org/docs/current/userguide/build_environment.html
+# Specifies the JVM arguments used for the daemon process.
+# The setting is particularly useful for tweaking memory settings.
+org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
+# When configured, Gradle will run in incubating parallel mode.
+# This option should only be used with decoupled projects. For more details, visit
+# https://developer.android.com/r/tools/gradle-multi-project-decoupled-projects
+# org.gradle.parallel=true
+# AndroidX package structure to make it clearer which packages are bundled with the
+# Android operating system, and which are packaged with your app's APK
+# https://developer.android.com/topic/libraries/support-library/androidx-rn
+android.useAndroidX=true
+# Kotlin code style for this project: "official" or "obsolete":
+kotlin.code.style=official
+# Enables namespacing of each library's R class so that its R class includes only the
+# resources declared in the library itself and none from the library's dependencies,
+# thereby reducing the size of the R class for that library
+android.nonTransitiveRClass=true
\ No newline at end of file
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
new file mode 100644
index 0000000..8352ede
--- /dev/null
+++ b/gradle/libs.versions.toml
@@ -0,0 +1,75 @@
+[versions]
+activityCompose = "1.9.2"
+agp = "8.4.2"
+coil = "2.7.0"
+coilCompose = "2.6.0"
+composeBom = "2024.09.00"
+coreKtx = "1.13.1"
+espressoCore = "3.6.1"
+hiltAndroidCompiler = "2.51"
+#hiltAndroidTesting = "2.37"
+hiltCompiler = "1.2.0"
+hiltNavigationCompose = "1.2.0"
+hiltNavigationFragment = "1.2.0"
+hiltWork = "1.2.0"
+junit = "4.13.2"
+junitVersion = "1.2.1"
+kotlin = "1.9.0"
+kotlinxCoroutinesTest = "1.8.1"
+kotlinxSerializationJson = "1.6.3"
+lifecycleRuntimeKtx = "2.8.5"
+mockk = "1.13.12"
+navigationCompose = "2.8.0"
+okHttpLoggingInterceptor = "4.12.0"
+retrofit = "2.11.0"
+roomCompiler = "2.6.1"
+roomKtx = "2.6.1"
+roomRuntime = "2.6.1"
+timber = "5.0.1"
+turbine = "1.1.0"
+workRuntimeKtx = "2.9.1"
+
+[libraries]
+androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
+androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
+androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
+androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" }
+androidx-hilt-navigation-compose = { module = "androidx.hilt:hilt-navigation-compose", version.ref = "hiltNavigationCompose" }
+androidx-hilt-navigation-fragment = { module = "androidx.hilt:hilt-navigation-fragment", version.ref = "hiltNavigationFragment" }
+androidx-hilt-work = { module = "androidx.hilt:hilt-work", version.ref = "hiltWork" }
+androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" }
+androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" }
+androidx-material3 = { group = "androidx.compose.material3", name = "material3" }
+androidx-navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "navigationCompose" }
+androidx-room-compiler = { module = "androidx.room:room-compiler", version.ref = "roomCompiler" }
+androidx-room-ktx = { module = "androidx.room:room-ktx", version.ref = "roomKtx" }
+androidx-room-runtime = { module = "androidx.room:room-runtime", version.ref = "roomRuntime" }
+androidx-ui = { group = "androidx.compose.ui", name = "ui" }
+androidx-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" }
+androidx-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" }
+androidx-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" }
+androidx-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
+androidx-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
+androidx-work-runtime-ktx = { module = "androidx.work:work-runtime-ktx", version.ref = "workRuntimeKtx" }
+coil = { module = "io.coil-kt:coil", version.ref = "coil" }
+coil-compose = { module = "io.coil-kt:coil-compose", version.ref = "coilCompose" }
+dagger-compiler = { module = "com.google.dagger:dagger-compiler", version.ref = "hiltAndroidCompiler" }
+hilt-android = { module = "com.google.dagger:hilt-android", version.ref = "hiltAndroidCompiler" }
+hilt-android-compiler = { module = "com.google.dagger:hilt-android-compiler", version.ref = "hiltAndroidCompiler" }
+hilt-android-testing = { module = "com.google.dagger:hilt-android-testing", version.ref = "hiltAndroidCompiler" }
+hilt-compiler = { module = "androidx.hilt:hilt-compiler", version.ref = "hiltCompiler" }
+junit = { group = "junit", name = "junit", version.ref = "junit" }
+kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlinxCoroutinesTest" }
+kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinxSerializationJson" }
+mockk = { module = "io.mockk:mockk", version.ref = "mockk" }
+retrofit = { module = "com.squareup.retrofit2:retrofit", version.ref = "retrofit" }
+retrofit-converter-kotlinx-serialization = { module = "com.squareup.retrofit2:converter-kotlinx-serialization", version.ref = "retrofit" }
+square-okhttp-logging-interceptor = { module = "com.squareup.okhttp3:logging-interceptor", version.ref = "okHttpLoggingInterceptor" }
+
+timber = { module = "com.jakewharton.timber:timber", version.ref = "timber" }
+turbine = { module = "app.cash.turbine:turbine", version.ref = "turbine" }
+
+[plugins]
+android-application = { id = "com.android.application", version.ref = "agp" }
+jetbrains-kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
+kotlinxSerialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar
new file mode 100644
index 0000000..e708b1c
Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ
diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties
new file mode 100644
index 0000000..d4da679
--- /dev/null
+++ b/gradle/wrapper/gradle-wrapper.properties
@@ -0,0 +1,6 @@
+#Wed Sep 04 14:46:48 EDT 2024
+distributionBase=GRADLE_USER_HOME
+distributionPath=wrapper/dists
+distributionUrl=https\://services.gradle.org/distributions/gradle-8.6-bin.zip
+zipStoreBase=GRADLE_USER_HOME
+zipStorePath=wrapper/dists
diff --git a/gradlew b/gradlew
new file mode 100755
index 0000000..4f906e0
--- /dev/null
+++ b/gradlew
@@ -0,0 +1,185 @@
+#!/usr/bin/env sh
+
+#
+# Copyright 2015 the original author or authors.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+
+##############################################################################
+##
+## Gradle start up script for UN*X
+##
+##############################################################################
+
+# Attempt to set APP_HOME
+# Resolve links: $0 may be a link
+PRG="$0"
+# Need this for relative symlinks.
+while [ -h "$PRG" ] ; do
+ ls=`ls -ld "$PRG"`
+ link=`expr "$ls" : '.*-> \(.*\)$'`
+ if expr "$link" : '/.*' > /dev/null; then
+ PRG="$link"
+ else
+ PRG=`dirname "$PRG"`"/$link"
+ fi
+done
+SAVED="`pwd`"
+cd "`dirname \"$PRG\"`/" >/dev/null
+APP_HOME="`pwd -P`"
+cd "$SAVED" >/dev/null
+
+APP_NAME="Gradle"
+APP_BASE_NAME=`basename "$0"`
+
+# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
+
+# Use the maximum available, or set MAX_FD != -1 to use that value.
+MAX_FD="maximum"
+
+warn () {
+ echo "$*"
+}
+
+die () {
+ echo
+ echo "$*"
+ echo
+ exit 1
+}
+
+# OS specific support (must be 'true' or 'false').
+cygwin=false
+msys=false
+darwin=false
+nonstop=false
+case "`uname`" in
+ CYGWIN* )
+ cygwin=true
+ ;;
+ Darwin* )
+ darwin=true
+ ;;
+ MINGW* )
+ msys=true
+ ;;
+ NONSTOP* )
+ nonstop=true
+ ;;
+esac
+
+CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
+
+
+# Determine the Java command to use to start the JVM.
+if [ -n "$JAVA_HOME" ] ; then
+ if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
+ # IBM's JDK on AIX uses strange locations for the executables
+ JAVACMD="$JAVA_HOME/jre/sh/java"
+ else
+ JAVACMD="$JAVA_HOME/bin/java"
+ fi
+ if [ ! -x "$JAVACMD" ] ; then
+ die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+ fi
+else
+ JAVACMD="java"
+ which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+fi
+
+# Increase the maximum file descriptors if we can.
+if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then
+ MAX_FD_LIMIT=`ulimit -H -n`
+ if [ $? -eq 0 ] ; then
+ if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
+ MAX_FD="$MAX_FD_LIMIT"
+ fi
+ ulimit -n $MAX_FD
+ if [ $? -ne 0 ] ; then
+ warn "Could not set maximum file descriptor limit: $MAX_FD"
+ fi
+ else
+ warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
+ fi
+fi
+
+# For Darwin, add options to specify how the application appears in the dock
+if $darwin; then
+ GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
+fi
+
+# For Cygwin or MSYS, switch paths to Windows format before running java
+if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then
+ APP_HOME=`cygpath --path --mixed "$APP_HOME"`
+ CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
+
+ JAVACMD=`cygpath --unix "$JAVACMD"`
+
+ # We build the pattern for arguments to be converted via cygpath
+ ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
+ SEP=""
+ for dir in $ROOTDIRSRAW ; do
+ ROOTDIRS="$ROOTDIRS$SEP$dir"
+ SEP="|"
+ done
+ OURCYGPATTERN="(^($ROOTDIRS))"
+ # Add a user-defined pattern to the cygpath arguments
+ if [ "$GRADLE_CYGPATTERN" != "" ] ; then
+ OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
+ fi
+ # Now convert the arguments - kludge to limit ourselves to /bin/sh
+ i=0
+ for arg in "$@" ; do
+ CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
+ CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option
+
+ if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition
+ eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
+ else
+ eval `echo args$i`="\"$arg\""
+ fi
+ i=`expr $i + 1`
+ done
+ case $i in
+ 0) set -- ;;
+ 1) set -- "$args0" ;;
+ 2) set -- "$args0" "$args1" ;;
+ 3) set -- "$args0" "$args1" "$args2" ;;
+ 4) set -- "$args0" "$args1" "$args2" "$args3" ;;
+ 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
+ 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
+ 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
+ 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
+ 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
+ esac
+fi
+
+# Escape application args
+save () {
+ for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done
+ echo " "
+}
+APP_ARGS=`save "$@"`
+
+# Collect all arguments for the java command, following the shell quoting and substitution rules
+eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS"
+
+exec "$JAVACMD" "$@"
diff --git a/gradlew.bat b/gradlew.bat
new file mode 100644
index 0000000..ac1b06f
--- /dev/null
+++ b/gradlew.bat
@@ -0,0 +1,89 @@
+@rem
+@rem Copyright 2015 the original author or authors.
+@rem
+@rem Licensed under the Apache License, Version 2.0 (the "License");
+@rem you may not use this file except in compliance with the License.
+@rem You may obtain a copy of the License at
+@rem
+@rem https://www.apache.org/licenses/LICENSE-2.0
+@rem
+@rem Unless required by applicable law or agreed to in writing, software
+@rem distributed under the License is distributed on an "AS IS" BASIS,
+@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+@rem See the License for the specific language governing permissions and
+@rem limitations under the License.
+@rem
+
+@if "%DEBUG%" == "" @echo off
+@rem ##########################################################################
+@rem
+@rem Gradle startup script for Windows
+@rem
+@rem ##########################################################################
+
+@rem Set local scope for the variables with windows NT shell
+if "%OS%"=="Windows_NT" setlocal
+
+set DIRNAME=%~dp0
+if "%DIRNAME%" == "" set DIRNAME=.
+set APP_BASE_NAME=%~n0
+set APP_HOME=%DIRNAME%
+
+@rem Resolve any "." and ".." in APP_HOME to make it shorter.
+for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
+
+@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
+
+@rem Find java.exe
+if defined JAVA_HOME goto findJavaFromJavaHome
+
+set JAVA_EXE=java.exe
+%JAVA_EXE% -version >NUL 2>&1
+if "%ERRORLEVEL%" == "0" goto execute
+
+echo.
+echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
+echo.
+echo Please set the JAVA_HOME variable in your environment to match the
+echo location of your Java installation.
+
+goto fail
+
+:findJavaFromJavaHome
+set JAVA_HOME=%JAVA_HOME:"=%
+set JAVA_EXE=%JAVA_HOME%/bin/java.exe
+
+if exist "%JAVA_EXE%" goto execute
+
+echo.
+echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
+echo.
+echo Please set the JAVA_HOME variable in your environment to match the
+echo location of your Java installation.
+
+goto fail
+
+:execute
+@rem Setup the command line
+
+set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
+
+
+@rem Execute Gradle
+"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
+
+:end
+@rem End local scope for the variables with windows NT shell
+if "%ERRORLEVEL%"=="0" goto mainEnd
+
+:fail
+rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
+rem the _cmd.exe /c_ return code!
+if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
+exit /b 1
+
+:mainEnd
+if "%OS%"=="Windows_NT" endlocal
+
+:omega
diff --git a/screenshots/01 dark mode - search results.png b/screenshots/01 dark mode - search results.png
new file mode 100644
index 0000000..7e24e5a
Binary files /dev/null and b/screenshots/01 dark mode - search results.png differ
diff --git a/screenshots/01 light mode - search results.png b/screenshots/01 light mode - search results.png
new file mode 100644
index 0000000..6d95b20
Binary files /dev/null and b/screenshots/01 light mode - search results.png differ
diff --git a/screenshots/02 dark mode - typing.png b/screenshots/02 dark mode - typing.png
new file mode 100644
index 0000000..1db2cd5
Binary files /dev/null and b/screenshots/02 dark mode - typing.png differ
diff --git a/screenshots/02 light mode - typing.png b/screenshots/02 light mode - typing.png
new file mode 100644
index 0000000..05ddafc
Binary files /dev/null and b/screenshots/02 light mode - typing.png differ
diff --git a/screenshots/03 dark mode - empty.png b/screenshots/03 dark mode - empty.png
new file mode 100644
index 0000000..5c9a72b
Binary files /dev/null and b/screenshots/03 dark mode - empty.png differ
diff --git a/screenshots/03 light mode - empty.png b/screenshots/03 light mode - empty.png
new file mode 100644
index 0000000..07ced68
Binary files /dev/null and b/screenshots/03 light mode - empty.png differ
diff --git a/screenshots/04 dark mode - no results.png b/screenshots/04 dark mode - no results.png
new file mode 100644
index 0000000..3f71d10
Binary files /dev/null and b/screenshots/04 dark mode - no results.png differ
diff --git a/screenshots/04 light mode - no results.png b/screenshots/04 light mode - no results.png
new file mode 100644
index 0000000..115609d
Binary files /dev/null and b/screenshots/04 light mode - no results.png differ
diff --git a/screenshots/05 dark mode - about.png b/screenshots/05 dark mode - about.png
new file mode 100644
index 0000000..7734f80
Binary files /dev/null and b/screenshots/05 dark mode - about.png differ
diff --git a/screenshots/05 light mode - about.png b/screenshots/05 light mode - about.png
new file mode 100644
index 0000000..29d1169
Binary files /dev/null and b/screenshots/05 light mode - about.png differ
diff --git a/screenshots/app icon.png b/screenshots/app icon.png
new file mode 100644
index 0000000..bf74b11
Binary files /dev/null and b/screenshots/app icon.png differ
diff --git a/screenshots/build-app-config.png b/screenshots/build-app-config.png
new file mode 100644
index 0000000..0fb07f3
Binary files /dev/null and b/screenshots/build-app-config.png differ
diff --git a/screenshots/large-app-icon.png b/screenshots/large-app-icon.png
new file mode 100644
index 0000000..7952106
Binary files /dev/null and b/screenshots/large-app-icon.png differ
diff --git a/screenshots/test-results-android.png b/screenshots/test-results-android.png
new file mode 100644
index 0000000..fc56058
Binary files /dev/null and b/screenshots/test-results-android.png differ
diff --git a/screenshots/test-results-unit.png b/screenshots/test-results-unit.png
new file mode 100644
index 0000000..34bf38e
Binary files /dev/null and b/screenshots/test-results-unit.png differ
diff --git a/screenshots/tests-running-android-studio.png b/screenshots/tests-running-android-studio.png
new file mode 100644
index 0000000..6dcc310
Binary files /dev/null and b/screenshots/tests-running-android-studio.png differ
diff --git a/settings.gradle.kts b/settings.gradle.kts
new file mode 100644
index 0000000..840bdce
--- /dev/null
+++ b/settings.gradle.kts
@@ -0,0 +1,23 @@
+pluginManagement {
+ repositories {
+ google {
+ content {
+ includeGroupByRegex("com\\.android.*")
+ includeGroupByRegex("com\\.google.*")
+ includeGroupByRegex("androidx.*")
+ }
+ }
+ mavenCentral()
+ gradlePluginPortal()
+ }
+}
+dependencyResolutionManagement {
+ repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
+ repositories {
+ google()
+ mavenCentral()
+ }
+}
+
+rootProject.name = "Stocks"
+include(":app")