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/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000..a851072 --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,54 @@ +name: Deploy to Firebase App Tester +on: + push: + branches: + - main + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v3 + - name: set up JDK 17 + uses: actions/setup-java@v4 + with: + java-version: 17 + distribution: 'temurin' + + - name: Service account file + env: + FIREBASE_APP_DISTRIBUTION_KEY: "${{secrets.FIREBASE_APP_DISTRIBUTION_KEY}}" + run: | + echo $FIREBASE_APP_DISTRIBUTION_KEY > service_account.json + + - name: Build release + run: ./gradlew assembleRelease + + # https://github.com/marketplace/actions/sign-android-release + - uses: r0adkll/sign-android-release@v1 + name: Sign app APK + # ID used to access action output + id: sign_app + with: + releaseDirectory: "app/build/outputs/apk/release" + signingKeyBase64: ${{ secrets.ANDROID_INTERNAL_SIGNING_KEYSTORE }} + alias: ${{ secrets.ANDROID_INTERNAL_SIGNING_KEY_ALIAS }} + keyStorePassword: ${{ secrets.ANDROID_INTERNAL_SIGNING_STORE_PASSWORD }} + keyPassword: ${{ secrets.ANDROID_INTERNAL_SIGNING_KEY_PASSWORD }} + env: + BUILD_TOOLS_VERSION: "34.0.0" + + - name: Upload artifact to Firebase App Distribution + uses: wzieba/Firebase-Distribution-Github-Action@v1 + env: + FIREBASE_APP_ID: ${{ secrets.FIREBASE_APP_ID }} + FIREBASE_TEST_TEAM: "anyone" + APP_PATH: ${{steps.sign_app.outputs.signedReleaseFile}} + + with: + appId: ${{env.FIREBASE_APP_ID}} + groups: ${{env.FIREBASE_TEST_TEAM}} + serviceCredentialsFile: "service_account.json" + file: ${{env.APP_PATH}} + releaseNotes: "πŸš€ New build!" 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..e69de29 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..c2d670f --- /dev/null +++ b/app/src/main/java/com/tinaciousdesign/interviews/stocks/StocksApplication.kt @@ -0,0 +1,22 @@ +package com.tinaciousdesign.interviews.stocks + +import android.app.Application +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..37b0346 --- /dev/null +++ b/app/src/main/java/com/tinaciousdesign/interviews/stocks/ui/utils/ObserveInternetConnectionState.kt @@ -0,0 +1,40 @@ +package com.tinaciousdesign.interviews.stocks.ui.utils + +import androidx.compose.material3.SnackbarDuration +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) + }, SnackbarDuration.Short) + ) + } + AppEvent.ConnectionRestored -> { + SnackBarController.sendEvent( + SnackBarEvent({ resources -> + resources.getString(R.string.connection_restored_message) + }, SnackbarDuration.Short) + ) + } + 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 @@ + + + +