├── androidApp
├── faved
│ ├── .gitignore
│ ├── src
│ │ └── main
│ │ │ ├── AndroidManifest.xml
│ │ │ ├── res
│ │ │ └── values
│ │ │ │ └── strings.xml
│ │ │ └── java
│ │ │ └── com
│ │ │ └── rafag
│ │ │ └── stonks
│ │ │ └── android
│ │ │ └── faved
│ │ │ ├── presentation
│ │ │ └── FavedQuotesViewModel.kt
│ │ │ └── view
│ │ │ └── FavedQuotesScreen.kt
│ └── build.gradle.kts
├── search
│ ├── .gitignore
│ ├── src
│ │ └── main
│ │ │ ├── AndroidManifest.xml
│ │ │ ├── res
│ │ │ └── values
│ │ │ │ └── strings.xml
│ │ │ └── java
│ │ │ └── com
│ │ │ └── rafag
│ │ │ └── stonks
│ │ │ └── android
│ │ │ └── search
│ │ │ ├── presentation
│ │ │ └── SearchViewModel.kt
│ │ │ └── view
│ │ │ └── SearchStonksScreen.kt
│ └── build.gradle.kts
├── design
│ ├── .gitignore
│ ├── src
│ │ └── main
│ │ │ ├── AndroidManifest.xml
│ │ │ ├── res
│ │ │ └── values
│ │ │ │ └── strings.xml
│ │ │ └── java
│ │ │ └── com
│ │ │ └── rafag
│ │ │ └── stonks
│ │ │ └── android
│ │ │ └── design
│ │ │ ├── TintStatusBar.kt
│ │ │ ├── theming
│ │ │ ├── StonksTheme.kt
│ │ │ ├── Palette.kt
│ │ │ ├── Typography.kt
│ │ │ └── StonksText.kt
│ │ │ └── views
│ │ │ ├── Delete.kt
│ │ │ ├── Loading.kt
│ │ │ ├── Faved.kt
│ │ │ ├── StonkQuote.kt
│ │ │ ├── Error.kt
│ │ │ └── SearchBar.kt
│ └── build.gradle.kts
├── navigation
│ ├── .gitignore
│ ├── src
│ │ └── main
│ │ │ ├── AndroidManifest.xml
│ │ │ └── java
│ │ │ └── com
│ │ │ └── rafag
│ │ │ └── stonks
│ │ │ └── android
│ │ │ └── navigation
│ │ │ └── ComposeNavigation.kt
│ └── build.gradle.kts
├── src
│ └── main
│ │ ├── res
│ │ └── values
│ │ │ ├── styles.xml
│ │ │ └── strings.xml
│ │ ├── AndroidManifest.xml
│ │ └── java
│ │ └── com
│ │ └── rafag
│ │ └── stonks
│ │ └── android
│ │ ├── StonksApplication.kt
│ │ ├── di
│ │ └── ViewModelsProvider.kt
│ │ └── MainActivity.kt
└── build.gradle.kts
├── shared
├── src
│ ├── androidMain
│ │ ├── AndroidManifest.xml
│ │ └── kotlin
│ │ │ └── com
│ │ │ └── rafag
│ │ │ └── stonks
│ │ │ └── data
│ │ │ └── db
│ │ │ └── DatabaseDriverFactory.kt
│ ├── commonMain
│ │ ├── kotlin
│ │ │ └── com
│ │ │ │ └── rafag
│ │ │ │ └── stonks
│ │ │ │ ├── data
│ │ │ │ └── db
│ │ │ │ │ └── DatabaseDriverFactory.kt
│ │ │ │ ├── domain
│ │ │ │ └── usecases
│ │ │ │ │ ├── ToggleFavouriteUseCase.kt
│ │ │ │ │ ├── FetchFavedQuotesUseCase.kt
│ │ │ │ │ ├── SearchStonksUseCase.kt
│ │ │ │ │ └── UseCasesProvider.kt
│ │ │ │ └── internal
│ │ │ │ ├── domain
│ │ │ │ ├── repositories
│ │ │ │ │ ├── FavouritesRepository.kt
│ │ │ │ │ ├── QuoteRepository.kt
│ │ │ │ │ └── SearchRepository.kt
│ │ │ │ └── usecases
│ │ │ │ │ ├── ToggleFavouriteUseCaseImpl.kt
│ │ │ │ │ ├── SearchStonksUseCaseImpl.kt
│ │ │ │ │ └── FetchFavedQuotesUseCaseImpl.kt
│ │ │ │ └── data
│ │ │ │ ├── httpclient
│ │ │ │ ├── HttpRequest.kt
│ │ │ │ └── StonksHttpClient.kt
│ │ │ │ └── repositories
│ │ │ │ ├── favourites
│ │ │ │ ├── FavouritesPersistence.kt
│ │ │ │ └── FavouritesRepositoryImpl.kt
│ │ │ │ ├── quote
│ │ │ │ ├── QuotePersistence.kt
│ │ │ │ ├── QuoteApi.kt
│ │ │ │ └── QuoteRepositoryImpl.kt
│ │ │ │ └── search
│ │ │ │ ├── SearchApi.kt
│ │ │ │ └── SearchRepositoryImpl.kt
│ │ └── sqldelight
│ │ │ └── com
│ │ │ └── stonks
│ │ │ └── db
│ │ │ ├── DbFavourites.sq
│ │ │ └── DbQuote.sq
│ ├── iosMain
│ │ └── kotlin
│ │ │ └── com
│ │ │ └── rafag
│ │ │ └── stonks
│ │ │ └── data
│ │ │ └── db
│ │ │ └── DatabaseDriverFactory.kt
│ ├── iosTest
│ │ └── kotlin
│ │ │ └── com
│ │ │ └── rafag
│ │ │ └── stonks
│ │ │ └── ExpectIos.kt
│ ├── commonTest
│ │ └── kotlin
│ │ │ └── com
│ │ │ └── rafag
│ │ │ └── stonks
│ │ │ ├── Expect.kt
│ │ │ └── internal
│ │ │ ├── fixtures
│ │ │ ├── QuoteFixture.kt
│ │ │ └── SearchFixture.kt
│ │ │ ├── data
│ │ │ ├── repositories
│ │ │ │ ├── search
│ │ │ │ │ └── SearchRepositoryImplTest.kt
│ │ │ │ ├── quote
│ │ │ │ │ └── QuoteRepositoryImplTest.kt
│ │ │ │ └── favourites
│ │ │ │ │ └── FavouritesRepositoryImplTest.kt
│ │ │ └── httpclient
│ │ │ │ └── MockHttpClient.kt
│ │ │ └── domain
│ │ │ └── usecases
│ │ │ ├── ToggleFavouriteUseCaseImplTest.kt
│ │ │ ├── FetchFavedQuotesUseCaseImplTest.kt
│ │ │ └── SearchStonksUseCaseImplTest.kt
│ └── androidTest
│ │ └── kotlin
│ │ └── com
│ │ └── rafag
│ │ └── stonks
│ │ └── ExpectJvm.kt
└── build.gradle.kts
├── .gitignore
├── iosApp
├── iosApp.xcodeproj
│ ├── project.xcworkspace
│ │ ├── contents.xcworkspacedata
│ │ └── xcshareddata
│ │ │ └── IDEWorkspaceChecks.plist
│ ├── xcuserdata
│ │ └── rafa.xcuserdatad
│ │ │ └── xcschemes
│ │ │ └── xcschememanagement.plist
│ └── project.pbxproj
└── iosApp
│ ├── Data
│ ├── Stock.swift
│ ├── Money.swift
│ └── Data.swift
│ ├── StockLookupView copy.swift
│ ├── Views
│ ├── StockLookupView.swift
│ ├── ContentView.swift
│ └── StockView.swift
│ ├── StockView copy.swift
│ ├── App
│ ├── AppDelegate.swift
│ └── SceneDelegate.swift
│ └── Info.plist
├── gradle.properties
├── gradle
└── wrapper
│ └── gradle-wrapper.properties
├── settings.gradle.kts
└── README.md
/androidApp/faved/.gitignore:
--------------------------------------------------------------------------------
1 | /build
--------------------------------------------------------------------------------
/androidApp/search/.gitignore:
--------------------------------------------------------------------------------
1 | /build
--------------------------------------------------------------------------------
/androidApp/design/.gitignore:
--------------------------------------------------------------------------------
1 | /build
2 |
--------------------------------------------------------------------------------
/androidApp/navigation/.gitignore:
--------------------------------------------------------------------------------
1 | /build
2 |
--------------------------------------------------------------------------------
/shared/src/androidMain/AndroidManifest.xml:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/androidApp/design/src/main/AndroidManifest.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
--------------------------------------------------------------------------------
/androidApp/search/src/main/AndroidManifest.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
--------------------------------------------------------------------------------
/androidApp/src/main/res/values/styles.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
--------------------------------------------------------------------------------
/androidApp/faved/src/main/AndroidManifest.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
--------------------------------------------------------------------------------
/androidApp/navigation/src/main/AndroidManifest.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | *.iml
2 | .gradle
3 | /local.properties
4 | /.idea/*
5 | .DS_Store
6 | /build
7 | */build
8 | /captures
9 | .externalNativeBuild
10 | .cxx
11 | local.properties
--------------------------------------------------------------------------------
/androidApp/src/main/res/values/strings.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 | Stonks
4 | My Stonks
5 |
--------------------------------------------------------------------------------
/iosApp/iosApp.xcodeproj/project.xcworkspace/contents.xcworkspacedata:
--------------------------------------------------------------------------------
1 |
2 |
4 |
6 |
7 |
8 |
--------------------------------------------------------------------------------
/androidApp/faved/src/main/res/values/strings.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 | My stonks
4 | No stonks added yet! 📈
5 |
--------------------------------------------------------------------------------
/androidApp/design/src/main/res/values/strings.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 | Oops something went wrong!
4 | Search symbol...
5 |
--------------------------------------------------------------------------------
/shared/src/commonMain/kotlin/com/rafag/stonks/data/db/DatabaseDriverFactory.kt:
--------------------------------------------------------------------------------
1 | package com.rafag.stonks.data.db
2 |
3 | import com.squareup.sqldelight.db.SqlDriver
4 |
5 | expect class DatabaseDriverFactory {
6 |
7 | fun createDriver(): SqlDriver
8 | }
--------------------------------------------------------------------------------
/androidApp/search/src/main/res/values/strings.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 | Type to search stonks 📈
4 | No stonks found 😭
5 |
--------------------------------------------------------------------------------
/shared/src/commonMain/kotlin/com/rafag/stonks/domain/usecases/ToggleFavouriteUseCase.kt:
--------------------------------------------------------------------------------
1 | package com.rafag.stonks.domain.usecases
2 |
3 | interface ToggleFavouriteUseCase {
4 |
5 | suspend fun saved(symbol: String)
6 | suspend fun unsaved(symbol: String)
7 | }
8 |
9 |
--------------------------------------------------------------------------------
/gradle.properties:
--------------------------------------------------------------------------------
1 | #Gradle
2 | org.gradle.jvmargs=-Xmx2048M -Dkotlin.daemon.jvm.options\="-Xmx2048M"
3 |
4 | #Kotlin
5 | kotlin.code.style=official
6 |
7 | #Android
8 | android.useAndroidX=true
9 |
10 | version.kotlinx.serialization=1.2.0
11 | version.ktor=1.6.4
12 | version.kotlinx.coroutines=1.5.2-native-mt
--------------------------------------------------------------------------------
/iosApp/iosApp.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | IDEDidComputeMac32BitWarning
6 |
7 |
8 |
9 |
--------------------------------------------------------------------------------
/shared/src/commonMain/sqldelight/com/stonks/db/DbFavourites.sq:
--------------------------------------------------------------------------------
1 | CREATE TABLE DbFavourites (
2 | symbol TEXT NOT NULL PRIMARY KEY
3 | );
4 |
5 | getAll:
6 | SELECT *
7 | FROM DbFavourites;
8 |
9 | save:
10 | INSERT OR REPLACE INTO DbFavourites
11 | VALUES (?);
12 |
13 | unsave:
14 | DELETE FROM DbFavourites
15 | WHERE symbol = ?;
16 |
17 |
--------------------------------------------------------------------------------
/iosApp/iosApp/Data/Stock.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Stock.swift
3 | // iosApp
4 | //
5 | // Created by Rafael Garcia Fernandez on 11/04/2021.
6 | // Copyright © 2021 orgName. All rights reserved.
7 | //
8 |
9 | import Foundation
10 |
11 | struct Stock: Identifiable {
12 | let id: String
13 | let name: String
14 | let price: Money
15 | }
16 |
--------------------------------------------------------------------------------
/shared/src/commonMain/kotlin/com/rafag/stonks/internal/domain/repositories/FavouritesRepository.kt:
--------------------------------------------------------------------------------
1 | package com.rafag.stonks.internal.domain.repositories
2 |
3 | import kotlinx.coroutines.flow.Flow
4 |
5 | internal interface FavouritesRepository {
6 |
7 | suspend fun getAll(): Flow>
8 | suspend fun save(symbol: String)
9 | suspend fun unsave(symbol: String)
10 | }
--------------------------------------------------------------------------------
/shared/src/commonMain/kotlin/com/rafag/stonks/domain/usecases/FetchFavedQuotesUseCase.kt:
--------------------------------------------------------------------------------
1 | package com.rafag.stonks.domain.usecases
2 |
3 | import kotlinx.coroutines.flow.Flow
4 |
5 | interface FetchFavedQuotesUseCase {
6 |
7 | suspend fun invoke(): Flow>
8 | }
9 |
10 | data class FavedQuote(
11 | val symbol: String,
12 | val current: Double,
13 | val open: Double,
14 | )
--------------------------------------------------------------------------------
/gradle/wrapper/gradle-wrapper.properties:
--------------------------------------------------------------------------------
1 | #Mon May 03 13:17:02 BST 2021
2 | distributionBase=GRADLE_USER_HOME
3 | #AS Preview
4 | distributionUrl=https\://services.gradle.org/distributions/gradle-7.0.2-bin.zip
5 | #AS Stable
6 | #distributionUrl=https\://services.gradle.org/distributions/gradle-6.8.3-bin.zip
7 | distributionPath=wrapper/dists
8 | zipStorePath=wrapper/dists
9 | zipStoreBase=GRADLE_USER_HOME
10 |
--------------------------------------------------------------------------------
/shared/src/commonMain/kotlin/com/rafag/stonks/domain/usecases/SearchStonksUseCase.kt:
--------------------------------------------------------------------------------
1 | package com.rafag.stonks.domain.usecases
2 |
3 | import kotlinx.coroutines.flow.Flow
4 |
5 | interface SearchStonksUseCase {
6 |
7 | suspend fun invoke(query: String): Flow>
8 | }
9 |
10 | data class StonkSearch(
11 | val name: String,
12 | val symbol: String,
13 | val faved: Boolean
14 | )
--------------------------------------------------------------------------------
/settings.gradle.kts:
--------------------------------------------------------------------------------
1 | pluginManagement {
2 | repositories {
3 | google()
4 | jcenter()
5 | gradlePluginPortal()
6 | mavenCentral()
7 | }
8 | }
9 |
10 | rootProject.name = "stonks"
11 | include(":androidApp")
12 | include(":shared")
13 | include(":androidApp:design")
14 | include(":androidApp:navigation")
15 | include(":androidApp:search")
16 | include(":androidApp:faved")
17 |
--------------------------------------------------------------------------------
/iosApp/iosApp/Data/Money.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Money.swift
3 | // iosApp
4 | //
5 | // Created by Rafael Garcia Fernandez on 11/04/2021.
6 | // Copyright © 2021 orgName. All rights reserved.
7 | //
8 |
9 | import Foundation
10 |
11 | struct Money {
12 | let whole: Int
13 | let cents: Int
14 |
15 | func toString() -> String {
16 | return "\(whole) \(String(format: "%02d", cents))$"
17 | }
18 | };
19 |
--------------------------------------------------------------------------------
/shared/src/commonMain/kotlin/com/rafag/stonks/internal/data/httpclient/HttpRequest.kt:
--------------------------------------------------------------------------------
1 | package com.rafag.stonks.internal.data.httpclient
2 |
3 | import io.ktor.client.utils.EmptyContent
4 | import io.ktor.http.content.OutgoingContent
5 |
6 | internal data class HttpRequest(
7 | val url: String,
8 | val method: Method,
9 | val body: OutgoingContent = EmptyContent,
10 | )
11 |
12 | enum class Method { GET, POST, DELETE }
--------------------------------------------------------------------------------
/shared/src/commonMain/sqldelight/com/stonks/db/DbQuote.sq:
--------------------------------------------------------------------------------
1 | CREATE TABLE DbQuote (
2 | symbol TEXT NOT NULL PRIMARY KEY,
3 | current REAL NOT NULL,
4 | high REAL NOT NULL,
5 | low REAL NOT NULL,
6 | open REAL NOT NULL,
7 | previousClose REAL NOT NULL
8 | );
9 |
10 | get:
11 | SELECT *
12 | FROM DbQuote
13 | WHERE symbol = ?;
14 |
15 | upsert:
16 | INSERT OR REPLACE INTO DbQuote
17 | VALUES (?, ?, ?, ?, ?, ?);
18 |
19 |
--------------------------------------------------------------------------------
/shared/src/iosMain/kotlin/com/rafag/stonks/data/db/DatabaseDriverFactory.kt:
--------------------------------------------------------------------------------
1 | package com.rafag.stonks.data.db
2 |
3 | import com.squareup.sqldelight.db.SqlDriver
4 | import com.squareup.sqldelight.drivers.native.NativeSqliteDriver
5 | import com.stonks.db.StonksDatabase
6 |
7 | actual class DatabaseDriverFactory {
8 |
9 | actual fun createDriver(): SqlDriver {
10 | return NativeSqliteDriver(StonksDatabase.Schema, "test.db")
11 | }
12 | }
--------------------------------------------------------------------------------
/shared/src/commonMain/kotlin/com/rafag/stonks/internal/domain/repositories/QuoteRepository.kt:
--------------------------------------------------------------------------------
1 | package com.rafag.stonks.internal.domain.repositories
2 |
3 | internal interface QuoteRepository {
4 |
5 | suspend fun quote(symbol: String): Quote
6 | }
7 |
8 | data class Quote(
9 | val symbol: String,
10 | val current: Double,
11 | val high: Double,
12 | val low: Double,
13 | val open: Double,
14 | val previousClose: Double,
15 | )
16 |
--------------------------------------------------------------------------------
/iosApp/iosApp.xcodeproj/xcuserdata/rafa.xcuserdatad/xcschemes/xcschememanagement.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | SchemeUserState
6 |
7 | iosApp.xcscheme_^#shared#^_
8 |
9 | orderHint
10 | 0
11 |
12 |
13 |
14 |
15 |
--------------------------------------------------------------------------------
/shared/src/commonMain/kotlin/com/rafag/stonks/internal/domain/repositories/SearchRepository.kt:
--------------------------------------------------------------------------------
1 | package com.rafag.stonks.internal.domain.repositories
2 |
3 | internal interface SearchRepository {
4 |
5 | suspend fun search(symbol: String): Search
6 | }
7 |
8 | data class Search(
9 | val list: List,
10 | )
11 |
12 | data class SearchItem(
13 | val name: String,
14 | val displaySymbol: String,
15 | val symbol: String,
16 | val type: String,
17 | )
--------------------------------------------------------------------------------
/shared/src/androidMain/kotlin/com/rafag/stonks/data/db/DatabaseDriverFactory.kt:
--------------------------------------------------------------------------------
1 | package com.rafag.stonks.data.db
2 |
3 | import android.content.Context
4 | import com.squareup.sqldelight.android.AndroidSqliteDriver
5 | import com.squareup.sqldelight.db.SqlDriver
6 | import com.stonks.db.StonksDatabase
7 |
8 | actual class DatabaseDriverFactory(private val context: Context) {
9 |
10 | actual fun createDriver(): SqlDriver {
11 | return AndroidSqliteDriver(StonksDatabase.Schema, context, "test.db")
12 | }
13 | }
--------------------------------------------------------------------------------
/androidApp/design/src/main/java/com/rafag/stonks/android/design/TintStatusBar.kt:
--------------------------------------------------------------------------------
1 | package com.rafag.stonks.android.design
2 |
3 | import androidx.compose.material.MaterialTheme
4 | import androidx.compose.runtime.Composable
5 | import androidx.compose.ui.graphics.Color
6 | import com.google.accompanist.systemuicontroller.rememberSystemUiController
7 |
8 | @Composable
9 | fun TintStatusBar(color: Color = MaterialTheme.colors.primaryVariant) {
10 | val systemUiController = rememberSystemUiController()
11 | systemUiController.setStatusBarColor(color = color)
12 | }
--------------------------------------------------------------------------------
/iosApp/iosApp/StockLookupView copy.swift:
--------------------------------------------------------------------------------
1 | //
2 | // LookupView.swift
3 | // iosApp
4 | //
5 | // Created by Rafael Garcia Fernandez on 11/04/2021.
6 | // Copyright © 2021 orgName. All rights reserved.
7 | //
8 |
9 | import Foundation
10 | import SwiftUI
11 |
12 | struct StockLookupView: View {
13 | var body: some View {
14 | Text("Here search will happen")
15 | }
16 | }
17 |
18 | struct StockLookupView_Previews: PreviewProvider {
19 | static var previews: some View {
20 | Group {
21 | StockLookupView()
22 | }
23 | }
24 | }
25 |
--------------------------------------------------------------------------------
/iosApp/iosApp/Views/StockLookupView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // LookupView.swift
3 | // iosApp
4 | //
5 | // Created by Rafael Garcia Fernandez on 11/04/2021.
6 | // Copyright © 2021 orgName. All rights reserved.
7 | //
8 |
9 | import Foundation
10 | import SwiftUI
11 |
12 | struct StockLookupView: View {
13 | var body: some View {
14 | Text("Here search will happen")
15 | }
16 | }
17 |
18 | struct StockLookupView_Previews: PreviewProvider {
19 | static var previews: some View {
20 | Group {
21 | StockLookupView()
22 | }
23 | }
24 | }
25 |
--------------------------------------------------------------------------------
/iosApp/iosApp/Data/Data.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Data.swift
3 | // iosApp
4 | //
5 | // Created by Rafael Garcia Fernandez on 11/04/2021.
6 | // Copyright © 2021 orgName. All rights reserved.
7 | //
8 |
9 | import Foundation
10 |
11 | class Data {
12 | static func getData() -> [Stock] {
13 | return [
14 | Stock(id: "APL", name: "Apple", price: Money(whole: 1399, cents: 23)),
15 | Stock(id: "FIVE", name: "Five Below", price: Money(whole: 123, cents: 23)),
16 | Stock(id: "ABDP", name: "Animal Dynamics", price: Money(whole: 12, cents: 23))
17 | ]
18 | }
19 | }
20 |
--------------------------------------------------------------------------------
/shared/src/commonMain/kotlin/com/rafag/stonks/internal/data/repositories/favourites/FavouritesPersistence.kt:
--------------------------------------------------------------------------------
1 | package com.rafag.stonks.internal.data.repositories.favourites
2 |
3 | import com.squareup.sqldelight.runtime.coroutines.asFlow
4 | import com.squareup.sqldelight.runtime.coroutines.mapToList
5 | import com.stonks.db.StonksDatabase
6 |
7 | internal class FavouritesPersistence(
8 | private val db: StonksDatabase,
9 | ) {
10 |
11 | fun getAll() = db.dbFavouritesQueries.getAll().asFlow().mapToList()
12 |
13 | fun save(symbol: String) = db.dbFavouritesQueries.save(symbol)
14 |
15 | fun unsave(symbol: String) = db.dbFavouritesQueries.unsave(symbol)
16 | }
--------------------------------------------------------------------------------
/shared/src/commonMain/kotlin/com/rafag/stonks/internal/domain/usecases/ToggleFavouriteUseCaseImpl.kt:
--------------------------------------------------------------------------------
1 | package com.rafag.stonks.internal.domain.usecases
2 |
3 | import com.rafag.stonks.domain.usecases.ToggleFavouriteUseCase
4 | import com.rafag.stonks.internal.domain.repositories.FavouritesRepository
5 |
6 | internal class ToggleFavouriteUseCaseImpl(
7 | private val favouritesRepository: FavouritesRepository
8 | ): ToggleFavouriteUseCase {
9 |
10 | override suspend fun saved(symbol: String) {
11 | favouritesRepository.save(symbol)
12 | }
13 |
14 | override suspend fun unsaved(symbol: String) {
15 | favouritesRepository.unsave(symbol)
16 | }
17 | }
--------------------------------------------------------------------------------
/androidApp/design/src/main/java/com/rafag/stonks/android/design/theming/StonksTheme.kt:
--------------------------------------------------------------------------------
1 | package com.rafag.stonks.android.design.theming
2 |
3 | import androidx.compose.foundation.isSystemInDarkTheme
4 | import androidx.compose.material.MaterialTheme
5 | import androidx.compose.material.Shapes
6 | import androidx.compose.runtime.Composable
7 |
8 | @Composable
9 | fun StonksTheme(
10 | darkTheme: Boolean = isSystemInDarkTheme(),
11 | content: @Composable () -> Unit
12 | ) {
13 | MaterialTheme(
14 | colors = if (darkTheme) darkColors else lightColors,
15 | typography = typography,
16 | shapes = Shapes(),
17 | content = content
18 | )
19 | }
20 |
--------------------------------------------------------------------------------
/shared/src/iosTest/kotlin/com/rafag/stonks/ExpectIos.kt:
--------------------------------------------------------------------------------
1 | package com.rafag.stonks
2 |
3 | actual fun runBlocking(block: suspend () -> T): T {
4 | throw Throwable("To be implemented")
5 | }
6 |
7 | actual inline fun mock(): T {
8 | throw Throwable("To be implemented")
9 | }
10 |
11 | actual fun whenever(methodCall: T): OngoingStubbing {
12 | throw Throwable("To be implemented")
13 | }
14 |
15 | actual fun verify(methodCall: T): T {
16 | throw Throwable("To be implemented")
17 | }
18 |
19 | actual fun verifyNever(methodCall: T): T {
20 | throw Throwable("To be implemented")
21 | }
22 |
23 | actual fun verifyZeroInteractions(mock: T) {
24 | throw Throwable("To be implemented")
25 | }
26 |
--------------------------------------------------------------------------------
/androidApp/design/src/main/java/com/rafag/stonks/android/design/views/Delete.kt:
--------------------------------------------------------------------------------
1 | package com.rafag.stonks.android.design.views
2 |
3 | import androidx.compose.foundation.layout.size
4 | import androidx.compose.material.Icon
5 | import androidx.compose.material.icons.Icons.Filled
6 | import androidx.compose.material.icons.filled.Delete
7 | import androidx.compose.runtime.Composable
8 | import androidx.compose.ui.Modifier
9 | import androidx.compose.ui.unit.dp
10 | import com.rafag.stonks.android.design.theming.StonksColors
11 |
12 | @Composable
13 | fun Delete(modifier: Modifier) {
14 | Icon(
15 | Filled.Delete,
16 | contentDescription = "",
17 | tint = StonksColors.gray600,
18 | modifier = modifier
19 | .size(32.dp)
20 | )
21 | }
--------------------------------------------------------------------------------
/androidApp/src/main/AndroidManifest.xml:
--------------------------------------------------------------------------------
1 |
2 |
4 |
5 |
10 |
11 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
--------------------------------------------------------------------------------
/shared/src/commonTest/kotlin/com/rafag/stonks/Expect.kt:
--------------------------------------------------------------------------------
1 | package com.rafag.stonks
2 |
3 | import com.rafag.stonks.internal.data.httpclient.MockHttpClient
4 |
5 | expect fun runBlocking(block: suspend () -> T): T
6 |
7 | expect inline fun mock(): T
8 |
9 | expect fun whenever(methodCall: T): OngoingStubbing
10 |
11 | expect fun verify(methodCall: T): T
12 |
13 | expect fun verifyNever(methodCall: T): T
14 |
15 | expect fun verifyZeroInteractions(mock: T)
16 |
17 | interface OngoingStubbing {
18 |
19 | fun thenReturn(value: T): OngoingStubbing
20 | fun thenReturn(value: T, vararg values: T): OngoingStubbing
21 | fun thenThrow(vararg throwables: Throwable): OngoingStubbing
22 | }
23 |
24 | fun mockHttp(): MockHttpClient {
25 | return MockHttpClient()
26 | }
27 |
--------------------------------------------------------------------------------
/shared/src/commonMain/kotlin/com/rafag/stonks/internal/data/repositories/quote/QuotePersistence.kt:
--------------------------------------------------------------------------------
1 | package com.rafag.stonks.internal.data.repositories.quote
2 |
3 | import com.stonks.db.DbQuote
4 | import com.stonks.db.StonksDatabase
5 |
6 | internal class QuotePersistence(
7 | private val db: StonksDatabase,
8 | ) {
9 |
10 | fun upsert(symbol: String, apiResponse: ApiQuoteResponse) {
11 | db.dbQuoteQueries.upsert(
12 | symbol = symbol,
13 | current = apiResponse.current,
14 | high = apiResponse.high,
15 | low = apiResponse.low,
16 | open_ = apiResponse.open,
17 | previousClose = apiResponse.previousClose
18 | )
19 | }
20 |
21 | fun get(symbol: String): DbQuote {
22 | return db.dbQuoteQueries.get(symbol).executeAsOne()
23 | }
24 | }
--------------------------------------------------------------------------------
/shared/src/commonMain/kotlin/com/rafag/stonks/internal/data/repositories/quote/QuoteApi.kt:
--------------------------------------------------------------------------------
1 | package com.rafag.stonks.internal.data.repositories.quote
2 |
3 | import com.rafag.stonks.internal.data.httpclient.HttpRequest
4 | import com.rafag.stonks.internal.data.httpclient.Method.*
5 | import kotlinx.serialization.SerialName
6 | import kotlinx.serialization.Serializable
7 |
8 | internal object QuoteApi {
9 |
10 | fun quoteRequest(symbol: String) = HttpRequest(
11 | method = GET,
12 | url = "quote?symbol=${symbol}"
13 | )
14 | }
15 |
16 | @Serializable
17 | internal data class ApiQuoteResponse(
18 | @SerialName("c") val current: Double,
19 | @SerialName("h") val high: Double,
20 | @SerialName("l") val low: Double,
21 | @SerialName("o") val open: Double,
22 | @SerialName("pc") val previousClose: Double,
23 | )
--------------------------------------------------------------------------------
/androidApp/src/main/java/com/rafag/stonks/android/StonksApplication.kt:
--------------------------------------------------------------------------------
1 | package com.rafag.stonks.android
2 |
3 | import android.app.Application
4 | import com.rafag.stonks.android.di.ViewModelsProvider
5 | import com.rafag.stonks.data.db.DatabaseDriverFactory
6 | import com.rafag.stonks.domain.usecases.UseCasesProvider
7 | import com.stonks.db.StonksDatabase
8 |
9 | /**
10 | * Doing Manual DI for simplicity, migrate to Dagger Hilt
11 | */
12 | class StonksApplication : Application() {
13 |
14 |
15 | lateinit var viewModelsProvider: ViewModelsProvider
16 |
17 | override fun onCreate() {
18 | super.onCreate()
19 | setupDI()
20 | }
21 |
22 | private fun setupDI() {
23 | val driver = DatabaseDriverFactory(applicationContext).createDriver()
24 | val db = StonksDatabase(driver)
25 | val useCaseProvider = UseCasesProvider(db = db)
26 | viewModelsProvider = ViewModelsProvider(useCaseProvider)
27 | }
28 | }
--------------------------------------------------------------------------------
/androidApp/design/src/main/java/com/rafag/stonks/android/design/views/Loading.kt:
--------------------------------------------------------------------------------
1 | package com.rafag.stonks.android.design.views
2 |
3 | import androidx.compose.foundation.layout.Box
4 | import androidx.compose.foundation.layout.fillMaxSize
5 | import androidx.compose.material.CircularProgressIndicator
6 | import androidx.compose.material.MaterialTheme
7 | import androidx.compose.runtime.Composable
8 | import androidx.compose.ui.Alignment
9 | import androidx.compose.ui.Modifier
10 | import androidx.compose.ui.tooling.preview.Preview
11 |
12 | @Composable
13 | fun Loading() {
14 | Box(
15 | modifier = Modifier.fillMaxSize(),
16 | ) {
17 | CircularProgressIndicator(
18 | modifier = Modifier.align(Alignment.Center),
19 | color = MaterialTheme.colors.primary
20 | )
21 | }
22 | }
23 |
24 | @Preview
25 | @Composable
26 | fun FullscreenCircularProgressIndicatorPreview() {
27 | MaterialTheme {
28 | Loading()
29 | }
30 | }
31 |
--------------------------------------------------------------------------------
/shared/src/commonTest/kotlin/com/rafag/stonks/internal/fixtures/QuoteFixture.kt:
--------------------------------------------------------------------------------
1 | package com.rafag.stonks.internal.fixtures
2 |
3 | import com.rafag.stonks.internal.data.repositories.quote.ApiQuoteResponse
4 | import com.rafag.stonks.internal.domain.repositories.Quote
5 | import com.stonks.db.DbQuote
6 |
7 | internal object QuoteFixture {
8 |
9 | fun anApiQuote() = ApiQuoteResponse(
10 | current = 3.0,
11 | high = 4.0,
12 | low = 2.0,
13 | open = 2.3,
14 | previousClose = 3.4
15 | )
16 |
17 | fun aDbQuote(symbol: String) = DbQuote(
18 | symbol = symbol,
19 | current = 3.0,
20 | high = 4.0,
21 | low = 2.0,
22 | open_ = 2.3,
23 | previousClose = 3.4
24 | )
25 |
26 | fun aQuote(symbol: String) = Quote(
27 | symbol = symbol,
28 | current = 3.0,
29 | high = 4.0,
30 | low = 2.0,
31 | open = 2.3,
32 | previousClose = 3.4
33 | )
34 | }
35 |
--------------------------------------------------------------------------------
/shared/src/commonMain/kotlin/com/rafag/stonks/internal/data/repositories/search/SearchApi.kt:
--------------------------------------------------------------------------------
1 | package com.rafag.stonks.internal.data.repositories.search
2 |
3 | import com.rafag.stonks.internal.data.httpclient.HttpRequest
4 | import com.rafag.stonks.internal.data.httpclient.Method.*
5 | import kotlinx.serialization.SerialName
6 | import kotlinx.serialization.Serializable
7 |
8 | internal object SearchApi {
9 |
10 | fun searchRequest(symbol: String) = HttpRequest(
11 | method = GET,
12 | url = "search?q=${symbol}"
13 | )
14 | }
15 |
16 | @Serializable
17 | internal data class ApiSearchResponse(
18 | @SerialName("result") val result: List,
19 | )
20 |
21 | @Serializable
22 | internal data class ApiSearchItemResponse(
23 | @SerialName("description") val description: String,
24 | @SerialName("displaySymbol") val displaySymbol: String,
25 | @SerialName("symbol") val symbol: String,
26 | @SerialName("type") val type: String,
27 | )
--------------------------------------------------------------------------------
/shared/src/commonTest/kotlin/com/rafag/stonks/internal/data/repositories/search/SearchRepositoryImplTest.kt:
--------------------------------------------------------------------------------
1 | package com.rafag.stonks.internal.data.repositories.search
2 |
3 | import com.rafag.stonks.internal.fixtures.SearchFixture
4 | import com.rafag.stonks.mockHttp
5 | import com.rafag.stonks.runBlocking
6 | import kotlin.test.Test
7 | import kotlin.test.assertEquals
8 |
9 | private const val A_SEARCH = "GOO"
10 | private val AN_API_SEARCH = SearchFixture.anApiSearch()
11 |
12 | class SearchRepositoryImplTest {
13 |
14 | private val httpClient = mockHttp()
15 | private val repository = SearchRepositoryImpl(httpClient.instance)
16 |
17 | @Test
18 | fun `given api success when searching then return data`() {
19 | val request = SearchApi.searchRequest(A_SEARCH)
20 | httpClient.addHandler(request, AN_API_SEARCH)
21 |
22 | runBlocking {
23 | val result = repository.search(A_SEARCH)
24 | assertEquals(result, AN_API_SEARCH.toModel())
25 | }
26 | }
27 | }
--------------------------------------------------------------------------------
/shared/src/commonTest/kotlin/com/rafag/stonks/internal/domain/usecases/ToggleFavouriteUseCaseImplTest.kt:
--------------------------------------------------------------------------------
1 | package com.rafag.stonks.internal.domain.usecases
2 |
3 | import com.rafag.stonks.internal.domain.repositories.FavouritesRepository
4 | import com.rafag.stonks.mock
5 | import com.rafag.stonks.runBlocking
6 | import com.rafag.stonks.verify
7 | import kotlin.test.Test
8 |
9 | private const val A_SYMBOL = "GOOG"
10 |
11 | class ToggleFavouriteUseCaseImplTest {
12 |
13 | private val favouritesRepository = mock()
14 | private val useCase = ToggleFavouriteUseCaseImpl(favouritesRepository)
15 |
16 | @Test
17 | fun `when saved then propagate to repository`() = runBlocking {
18 | useCase.saved(A_SYMBOL)
19 |
20 | verify(favouritesRepository).save(A_SYMBOL)
21 | }
22 |
23 | @Test
24 | fun `when unsaved then propagate to repository`() = runBlocking {
25 | useCase.unsaved(A_SYMBOL)
26 |
27 | verify(favouritesRepository).unsave(A_SYMBOL)
28 | }
29 | }
--------------------------------------------------------------------------------
/shared/src/commonTest/kotlin/com/rafag/stonks/internal/fixtures/SearchFixture.kt:
--------------------------------------------------------------------------------
1 | package com.rafag.stonks.internal.fixtures
2 |
3 | import com.rafag.stonks.internal.domain.repositories.Search
4 | import com.rafag.stonks.internal.domain.repositories.SearchItem
5 | import com.rafag.stonks.internal.data.repositories.search.ApiSearchItemResponse
6 | import com.rafag.stonks.internal.data.repositories.search.ApiSearchResponse
7 |
8 | internal object SearchFixture {
9 |
10 | fun anApiSearch() = ApiSearchResponse(
11 | listOf(anApiSearchItem())
12 | )
13 |
14 | private fun anApiSearchItem() = ApiSearchItemResponse(
15 | description = "a-description",
16 | displaySymbol = "a-display-symbol",
17 | symbol = "symbol",
18 | type = "a-type"
19 | )
20 |
21 | fun aSearch(list: List = emptyList()) = Search(
22 | list = list
23 | )
24 |
25 | fun aSearchItem(symbol: String, name: String) = SearchItem(
26 | symbol = symbol,
27 | name = name,
28 | displaySymbol = symbol,
29 | type = "type"
30 | )
31 | }
32 |
--------------------------------------------------------------------------------
/androidApp/design/src/main/java/com/rafag/stonks/android/design/views/Faved.kt:
--------------------------------------------------------------------------------
1 | package com.rafag.stonks.android.design.views
2 |
3 | import androidx.compose.foundation.layout.padding
4 | import androidx.compose.foundation.layout.size
5 | import androidx.compose.material.Icon
6 | import androidx.compose.material.icons.Icons.Filled
7 | import androidx.compose.material.icons.filled.Favorite
8 | import androidx.compose.runtime.Composable
9 | import androidx.compose.ui.Modifier
10 | import androidx.compose.ui.unit.dp
11 | import com.rafag.stonks.android.design.theming.StonksColors
12 |
13 | @Composable
14 | fun Faved() {
15 | Icon(
16 | Filled.Favorite,
17 | contentDescription = "",
18 | tint = StonksColors.red400,
19 | modifier = Modifier
20 | .padding(15.dp)
21 | .size(24.dp)
22 | )
23 | }
24 |
25 | @Composable
26 | fun NotFaved() {
27 | Icon(
28 | Filled.Favorite,
29 | contentDescription = "",
30 | tint = StonksColors.gray200,
31 | modifier = Modifier
32 | .padding(16.dp)
33 | .size(24.dp)
34 | )
35 | }
36 |
37 |
--------------------------------------------------------------------------------
/iosApp/iosApp/Views/ContentView.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | struct ContentView: View {
4 | @State var stocks: [Stock]
5 | @State var isStockLookupShowing: Bool = false
6 | var body: some View {
7 | NavigationView {
8 | List {
9 | ForEach(stocks) { stock in
10 | StockView(stock: stock)
11 | }
12 | }
13 | .navigationBarTitle(Text("📈 Stonks tracker"))
14 | .navigationBarItems(trailing:
15 | Button(action: {
16 | isStockLookupShowing = true
17 | }) {
18 | Image(systemName: "plus.circle").imageScale(.large)
19 | }.sheet(isPresented: $isStockLookupShowing, onDismiss: {}, content: {
20 | StockLookupView()
21 | })
22 | )
23 | }
24 | }
25 | }
26 |
27 | struct ContentView_Previews: PreviewProvider {
28 | static var previews: some View {
29 | Group {
30 | ContentView(stocks: [Stock(id: "APL", name: "Apple", price: Money(whole: 1399, cents: 23))]
31 | )
32 |
33 | }
34 | }
35 | }
36 |
37 |
--------------------------------------------------------------------------------
/androidApp/src/main/java/com/rafag/stonks/android/di/ViewModelsProvider.kt:
--------------------------------------------------------------------------------
1 | package com.rafag.stonks.android.di
2 |
3 | import android.app.Application
4 | import com.rafag.stonks.android.StonksApplication
5 | import com.rafag.stonks.android.faved.presentation.FavedViewModel
6 | import com.rafag.stonks.android.search.presentation.SearchViewModel
7 | import com.rafag.stonks.domain.usecases.UseCasesProvider
8 |
9 | /**
10 | * Doing Manual DI for simplicity, migrate to Dagger Hilt
11 | */
12 | class ViewModelsProvider(useCasesProvider: UseCasesProvider) {
13 |
14 | val searchViewModel = SearchViewModel(
15 | searchUseCase = useCasesProvider.searchStonksUseCase(),
16 | toggleFavouriteUseCase = useCasesProvider.toggleFavouritesUseCase(),
17 | )
18 |
19 | val favedViewModel = FavedViewModel(
20 | fetchFavedQuotesUseCase = useCasesProvider.fetchFavedQuotesUseCase(),
21 | toggleFavouriteUseCase = useCasesProvider.toggleFavouritesUseCase(),
22 | )
23 | }
24 |
25 | fun Application.searchViewModel() = (this as StonksApplication).viewModelsProvider.searchViewModel
26 | fun Application.favedViewModel() = (this as StonksApplication).viewModelsProvider.favedViewModel
--------------------------------------------------------------------------------
/iosApp/iosApp/StockView copy.swift:
--------------------------------------------------------------------------------
1 | //
2 | // StockView.swift
3 | // iosApp
4 | //
5 | // Created by Rafael Garcia Fernandez on 11/04/2021.
6 | // Copyright © 2021 orgName. All rights reserved.
7 | //
8 |
9 | import Foundation
10 | import SwiftUI
11 |
12 | struct StockView: View {
13 | @State var stock: Stock
14 | var body: some View {
15 | HStack {
16 | Text(stock.id)
17 | .bold()
18 | .frame(alignment: .leading)
19 | .padding(.leading, 24)
20 | Text(stock.name)
21 | .frame(alignment: .leading)
22 | Spacer()
23 | Text(stock.price.toString())
24 | Button(action: {
25 | print("clicked")
26 | }, label: {
27 | Image(systemName:"trash").imageScale(.large)
28 | })
29 | .buttonStyle(BorderlessButtonStyle())
30 | }
31 | }
32 | }
33 |
34 | struct StockView_Previews: PreviewProvider {
35 | static var previews: some View {
36 | Group {
37 | StockView(stock: Stock(id: "APL", name: "Apple", price: Money(whole: 1399, cents: 23))
38 | )
39 |
40 | }
41 | }
42 | }
43 |
44 |
--------------------------------------------------------------------------------
/iosApp/iosApp/Views/StockView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // StockView.swift
3 | // iosApp
4 | //
5 | // Created by Rafael Garcia Fernandez on 11/04/2021.
6 | // Copyright © 2021 orgName. All rights reserved.
7 | //
8 |
9 | import Foundation
10 | import SwiftUI
11 |
12 | struct StockView: View {
13 | @State var stock: Stock
14 | var body: some View {
15 | HStack {
16 | Text(stock.id)
17 | .bold()
18 | .frame(alignment: .leading)
19 | .padding(.leading, 24)
20 | Text(stock.name)
21 | .frame(alignment: .leading)
22 | Spacer()
23 | Text(stock.price.toString())
24 | Button(action: {
25 | print("clicked")
26 | }, label: {
27 | Image(systemName:"trash").imageScale(.large)
28 | })
29 | .buttonStyle(BorderlessButtonStyle())
30 | }
31 | }
32 | }
33 |
34 | struct StockView_Previews: PreviewProvider {
35 | static var previews: some View {
36 | Group {
37 | StockView(stock: Stock(id: "APL", name: "Apple", price: Money(whole: 1399, cents: 23))
38 | )
39 |
40 | }
41 | }
42 | }
43 |
44 |
--------------------------------------------------------------------------------
/androidApp/design/build.gradle.kts:
--------------------------------------------------------------------------------
1 | plugins {
2 | id("com.android.library")
3 | kotlin("android")
4 | }
5 |
6 | dependencies {
7 | implementation("com.google.android.material:material:1.4.0")
8 | implementation("androidx.compose.compiler:compiler:1.1.0-alpha04")
9 | implementation("androidx.compose.ui:ui:1.0.2")
10 | implementation("androidx.compose.ui:ui-text:1.0.2")
11 | implementation("androidx.compose.foundation:foundation:1.0.2")
12 | implementation("androidx.compose.material:material:1.0.2")
13 | implementation("com.google.accompanist:accompanist-systemuicontroller:0.18.0")
14 | implementation("androidx.compose.ui:ui-tooling:1.0.1") {
15 | version {
16 | strictly("1.0.0-beta09")
17 | }
18 | }
19 | }
20 |
21 | android {
22 | compileSdkVersion(31)
23 | defaultConfig {
24 | minSdkVersion(21)
25 | targetSdkVersion(31)
26 | }
27 | buildTypes {
28 | getByName("release") {
29 | isMinifyEnabled = false
30 | }
31 | }
32 |
33 | buildFeatures {
34 | compose = true
35 | }
36 |
37 | composeOptions {
38 | kotlinCompilerExtensionVersion = "1.1.0-alpha05"
39 | }
40 |
41 | kotlinOptions {
42 | jvmTarget = "1.8"
43 | }
44 | }
45 |
--------------------------------------------------------------------------------
/shared/src/commonMain/kotlin/com/rafag/stonks/internal/data/repositories/search/SearchRepositoryImpl.kt:
--------------------------------------------------------------------------------
1 | package com.rafag.stonks.internal.data.repositories.search
2 |
3 | import com.rafag.stonks.internal.data.httpclient.StonksHttpClient
4 | import com.rafag.stonks.internal.domain.repositories.Search
5 | import com.rafag.stonks.internal.domain.repositories.SearchItem
6 | import com.rafag.stonks.internal.domain.repositories.SearchRepository
7 |
8 | internal class SearchRepositoryImpl(
9 | private val httpClient: StonksHttpClient
10 | ) : SearchRepository {
11 |
12 | override suspend fun search(symbol: String): Search {
13 | try {
14 | return httpClient.execute(SearchApi.searchRequest(symbol)).toModel()
15 | } catch (exception: Exception) {
16 | throw ErrorSearching(symbol, exception)
17 | }
18 | }
19 | }
20 |
21 | data class ErrorSearching(val symbol: String, override val cause: Throwable) : Throwable("Could not fetch symbols - $cause")
22 |
23 |
24 | internal fun ApiSearchResponse.toModel() = Search(
25 | list = result.map { it.toModel() }
26 | )
27 |
28 | private fun ApiSearchItemResponse.toModel() = SearchItem(
29 | name = description,
30 | displaySymbol = displaySymbol,
31 | symbol = symbol,
32 | type = type
33 | )
34 |
--------------------------------------------------------------------------------
/shared/src/androidTest/kotlin/com/rafag/stonks/ExpectJvm.kt:
--------------------------------------------------------------------------------
1 | package com.rafag.stonks
2 |
3 | actual fun runBlocking(block: suspend () -> T): T = kotlinx.coroutines.runBlocking { block() }
4 |
5 | actual inline fun mock(): T {
6 | return com.nhaarman.mockitokotlin2.mock()
7 | }
8 |
9 | actual fun whenever(methodCall: T): OngoingStubbing {
10 | val mock = com.nhaarman.mockitokotlin2.whenever(methodCall)
11 | return wrapMock(mock)
12 | }
13 |
14 | fun wrapMock(mock: org.mockito.stubbing.OngoingStubbing): OngoingStubbing {
15 | return object : OngoingStubbing {
16 | override fun thenReturn(value: T) = wrapMock(mock.thenReturn(value))
17 | override fun thenReturn(value: T, vararg values: T) = wrapMock(mock.thenReturn(value, *values))
18 | override fun thenThrow(vararg throwables: Throwable) = wrapMock(mock.thenThrow(*throwables))
19 | }
20 | }
21 |
22 | actual fun verify(methodCall: T): T {
23 | return com.nhaarman.mockitokotlin2.verify(methodCall)
24 | }
25 |
26 | actual fun verifyNever(methodCall: T): T {
27 | return com.nhaarman.mockitokotlin2.verify(methodCall, com.nhaarman.mockitokotlin2.never())
28 | }
29 |
30 | actual fun verifyZeroInteractions(mock: T) {
31 | return com.nhaarman.mockitokotlin2.verifyZeroInteractions(mock)
32 | }
33 |
--------------------------------------------------------------------------------
/androidApp/faved/build.gradle.kts:
--------------------------------------------------------------------------------
1 | plugins {
2 | id("com.android.library")
3 | kotlin("android")
4 | }
5 |
6 | dependencies {
7 | implementation(project(":shared"))
8 | implementation(project(":androidApp:design"))
9 | implementation("com.google.android.material:material:1.4.0")
10 | implementation("androidx.compose.compiler:compiler:1.1.0-alpha04")
11 | implementation("androidx.compose.ui:ui:1.0.2")
12 | implementation("androidx.compose.ui:ui-text:1.0.2")
13 | implementation("androidx.compose.foundation:foundation:1.0.2")
14 | implementation("androidx.compose.material:material:1.0.2")
15 | implementation("androidx.activity:activity-compose:1.3.1")
16 | implementation("androidx.compose.ui:ui-tooling:1.0.1") {
17 | version {
18 | strictly("1.0.0-beta09")
19 | }
20 | }
21 | }
22 |
23 | android {
24 | compileSdkVersion(31)
25 | defaultConfig {
26 | minSdkVersion(21)
27 | targetSdkVersion(31)
28 | }
29 | buildTypes {
30 | getByName("release") {
31 | isMinifyEnabled = false
32 | }
33 | }
34 |
35 | buildFeatures {
36 | compose = true
37 | }
38 |
39 | composeOptions {
40 | kotlinCompilerExtensionVersion = "1.1.0-alpha05"
41 | }
42 |
43 | kotlinOptions {
44 | jvmTarget = "1.8"
45 | }
46 | }
47 |
--------------------------------------------------------------------------------
/androidApp/src/main/java/com/rafag/stonks/android/MainActivity.kt:
--------------------------------------------------------------------------------
1 | package com.rafag.stonks.android
2 |
3 | import android.os.Bundle
4 | import androidx.activity.ComponentActivity
5 | import androidx.activity.compose.setContent
6 | import androidx.compose.material.Scaffold
7 | import androidx.navigation.compose.rememberNavController
8 | import com.rafag.stonks.android.design.TintStatusBar
9 | import com.rafag.stonks.android.design.theming.StonksTheme
10 | import com.rafag.stonks.android.di.favedViewModel
11 | import com.rafag.stonks.android.di.searchViewModel
12 | import com.rafag.stonks.android.navigation.ComposeNavigation
13 |
14 | class MainActivity : ComponentActivity() {
15 |
16 | override fun onCreate(savedInstanceState: Bundle?) {
17 | super.onCreate(savedInstanceState)
18 | setContent {
19 | StonksTheme {
20 | TintStatusBar()
21 | val navController = rememberNavController()
22 | val application = application as StonksApplication
23 | Scaffold {
24 | ComposeNavigation(
25 | navController = navController,
26 | searchViewModel = application.searchViewModel(),
27 | favedQuotesViewModel = application.favedViewModel()
28 | )
29 | }
30 | }
31 | }
32 | }
33 | }
34 |
--------------------------------------------------------------------------------
/iosApp/iosApp/App/AppDelegate.swift:
--------------------------------------------------------------------------------
1 | import UIKit
2 |
3 | @UIApplicationMain
4 | class AppDelegate: UIResponder, UIApplicationDelegate {
5 |
6 |
7 |
8 | func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
9 | // Override point for customization after application launch.
10 | return true
11 | }
12 |
13 | // MARK: UISceneSession Lifecycle
14 |
15 | func application(_ application: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession, options: UIScene.ConnectionOptions) -> UISceneConfiguration {
16 | // Called when a new scene session is being created.
17 | // Use this method to select a configuration to create the new scene with.
18 | return UISceneConfiguration(name: "Default Configuration", sessionRole: connectingSceneSession.role)
19 | }
20 |
21 | func application(_ application: UIApplication, didDiscardSceneSessions sceneSessions: Set) {
22 | // Called when the user discards a scene session.
23 | // If any sessions were discarded while the application was not running, this will be called shortly after application:didFinishLaunchingWithOptions.
24 | // Use this method to release any resources that were specific to the discarded scenes, as they will not return.
25 | }
26 |
27 |
28 | }
29 |
30 |
--------------------------------------------------------------------------------
/androidApp/search/build.gradle.kts:
--------------------------------------------------------------------------------
1 | plugins {
2 | id("com.android.library")
3 | kotlin("android")
4 | }
5 |
6 | dependencies {
7 | implementation(project(":shared"))
8 | implementation(project(":androidApp:design"))
9 | implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:${properties["version.kotlinx.coroutines"]}")
10 | implementation("androidx.compose.compiler:compiler:1.1.0-alpha04")
11 | implementation("androidx.compose.ui:ui:1.0.2")
12 | implementation("androidx.compose.ui:ui-text:1.0.2")
13 | implementation("androidx.compose.foundation:foundation:1.0.2")
14 | implementation("androidx.compose.material:material:1.0.2")
15 | implementation("androidx.activity:activity-compose:1.3.1")
16 | implementation("androidx.compose.ui:ui-tooling:1.0.1") {
17 | version {
18 | strictly("1.0.0-beta09")
19 | }
20 | }
21 | }
22 |
23 | android {
24 | compileSdkVersion(31)
25 | defaultConfig {
26 | minSdkVersion(21)
27 | targetSdkVersion(31)
28 | }
29 | buildTypes {
30 | getByName("release") {
31 | isMinifyEnabled = false
32 | }
33 | }
34 |
35 | buildFeatures {
36 | compose = true
37 | }
38 |
39 | composeOptions {
40 | kotlinCompilerExtensionVersion = "1.1.0-alpha05"
41 | }
42 |
43 | kotlinOptions {
44 | jvmTarget = "1.8"
45 | }
46 | }
47 |
--------------------------------------------------------------------------------
/androidApp/navigation/build.gradle.kts:
--------------------------------------------------------------------------------
1 | plugins {
2 | id("com.android.library")
3 | kotlin("android")
4 | }
5 |
6 | dependencies {
7 | implementation(project(":androidApp:search"))
8 | implementation(project(":androidApp:faved"))
9 | implementation("com.google.android.material:material:1.4.0")
10 | implementation("androidx.compose.compiler:compiler:1.1.0-alpha04")
11 | implementation("androidx.compose.ui:ui:1.0.2")
12 | implementation("androidx.compose.ui:ui-text:1.0.2")
13 | implementation("androidx.compose.foundation:foundation:1.0.2")
14 | implementation("androidx.compose.material:material:1.0.2")
15 | implementation("com.google.accompanist:accompanist-systemuicontroller:0.18.0")
16 | implementation("androidx.navigation:navigation-compose:2.4.0-alpha09")
17 | implementation("androidx.compose.ui:ui-tooling:1.0.1") {
18 | version {
19 | strictly("1.0.0-beta09")
20 | }
21 | }
22 | }
23 |
24 | android {
25 | compileSdkVersion(31)
26 | defaultConfig {
27 | minSdkVersion(21)
28 | targetSdkVersion(31)
29 | }
30 | buildTypes {
31 | getByName("release") {
32 | isMinifyEnabled = false
33 | }
34 | }
35 |
36 | buildFeatures {
37 | compose = true
38 | }
39 |
40 | composeOptions {
41 | kotlinCompilerExtensionVersion = "1.1.0-alpha05"
42 | }
43 |
44 | kotlinOptions {
45 | jvmTarget = "1.8"
46 | }
47 | }
48 |
--------------------------------------------------------------------------------
/shared/src/commonTest/kotlin/com/rafag/stonks/internal/domain/usecases/FetchFavedQuotesUseCaseImplTest.kt:
--------------------------------------------------------------------------------
1 | package com.rafag.stonks.internal.domain.usecases
2 |
3 | import com.rafag.stonks.internal.domain.repositories.FavouritesRepository
4 | import com.rafag.stonks.internal.fixtures.QuoteFixture
5 | import com.rafag.stonks.internal.data.repositories.quote.QuoteRepositoryImpl
6 | import com.rafag.stonks.mock
7 | import com.rafag.stonks.runBlocking
8 | import com.rafag.stonks.whenever
9 | import kotlinx.coroutines.flow.first
10 | import kotlinx.coroutines.flow.flowOf
11 | import kotlin.test.Test
12 | import kotlin.test.assertEquals
13 |
14 | private const val A_SYMBOL = "GOOG"
15 | private val A_FAVED_SYMBOLS = listOf("GOOG")
16 | private val A_QUOTE = QuoteFixture.aQuote(A_SYMBOL)
17 |
18 | class FetchFavedQuotesUseCaseImplTest {
19 |
20 | private val favouritesRepository = mock()
21 | private val quoteRepository = mock()
22 |
23 | private val useCase = FetchFavedQuotesUseCaseImpl(quoteRepository, favouritesRepository)
24 |
25 | @Test
26 | fun `when invoked then fetch faved symbols and its quotes`() = runBlocking {
27 | whenever(favouritesRepository.getAll()).thenReturn(flowOf(A_FAVED_SYMBOLS))
28 | whenever(quoteRepository.quote(A_SYMBOL)).thenReturn(A_QUOTE)
29 |
30 | val result = useCase.invoke()
31 |
32 | assertEquals(result.first(), listOf(A_QUOTE.toFavedQuote()))
33 | }
34 |
35 | }
--------------------------------------------------------------------------------
/shared/src/commonMain/kotlin/com/rafag/stonks/internal/domain/usecases/SearchStonksUseCaseImpl.kt:
--------------------------------------------------------------------------------
1 | package com.rafag.stonks.internal.domain.usecases
2 |
3 | import com.rafag.stonks.domain.usecases.SearchStonksUseCase
4 | import com.rafag.stonks.domain.usecases.StonkSearch
5 | import com.rafag.stonks.internal.domain.repositories.FavouritesRepository
6 | import com.rafag.stonks.internal.domain.repositories.SearchItem
7 | import com.rafag.stonks.internal.domain.repositories.SearchRepository
8 | import kotlinx.coroutines.flow.Flow
9 | import kotlinx.coroutines.flow.combine
10 | import kotlinx.coroutines.flow.flowOf
11 |
12 | internal class SearchStonksUseCaseImpl(
13 | private val searchRepository: SearchRepository,
14 | private val favouritesRepository: FavouritesRepository
15 | ): SearchStonksUseCase {
16 |
17 | override suspend fun invoke(query: String): Flow> {
18 | if (query.isEmpty()) return flowOf(emptyList())
19 | val searchSource = flowOf(searchRepository.search(query))
20 | val favsSource = favouritesRepository.getAll()
21 | return searchSource.combine(favsSource) { search, favs ->
22 | search.list.map { searchItem ->
23 | searchItem.toStonkSearch(
24 | faved = favs.contains(searchItem.symbol)
25 | )
26 | }
27 | }
28 | }
29 | }
30 |
31 | private fun SearchItem.toStonkSearch(faved: Boolean) = StonkSearch(
32 | name = name,
33 | symbol = displaySymbol,
34 | faved = faved,
35 | )
--------------------------------------------------------------------------------
/androidApp/navigation/src/main/java/com/rafag/stonks/android/navigation/ComposeNavigation.kt:
--------------------------------------------------------------------------------
1 | package com.rafag.stonks.android.navigation
2 |
3 | import androidx.compose.runtime.Composable
4 | import androidx.navigation.NavHostController
5 | import androidx.navigation.compose.NavHost
6 | import androidx.navigation.compose.composable
7 | import com.rafag.stonks.android.faved.presentation.FavedViewModel
8 | import com.rafag.stonks.android.faved.view.FavedQuotesScreen
9 | import com.rafag.stonks.android.search.presentation.SearchViewModel
10 | import com.rafag.stonks.android.search.view.SearchStonksScreen
11 |
12 | const val NAVIGATE_TO_FAVED_QUOTES_SCREEN = "faved_quotes_screen"
13 | const val NAVIGATE_TO_SEARCH_STONKS_SCREEN = "search_stonks_screen"
14 |
15 | @Composable
16 | fun ComposeNavigation(
17 | navController: NavHostController,
18 | searchViewModel: SearchViewModel,
19 | favedQuotesViewModel: FavedViewModel,
20 | ) {
21 | NavHost(
22 | navController = navController,
23 | startDestination = NAVIGATE_TO_FAVED_QUOTES_SCREEN
24 | ) {
25 | composable(NAVIGATE_TO_FAVED_QUOTES_SCREEN) {
26 | FavedQuotesScreen(
27 | viewModel = favedQuotesViewModel,
28 | onNavigateToSearch = {
29 | navController.navigate(NAVIGATE_TO_SEARCH_STONKS_SCREEN)
30 | }
31 | )
32 | }
33 | composable(NAVIGATE_TO_SEARCH_STONKS_SCREEN) {
34 | SearchStonksScreen(viewModel = searchViewModel)
35 | }
36 | }
37 | }
38 |
--------------------------------------------------------------------------------
/shared/src/commonMain/kotlin/com/rafag/stonks/internal/data/httpclient/StonksHttpClient.kt:
--------------------------------------------------------------------------------
1 | package com.rafag.stonks.internal.data.httpclient
2 |
3 | import io.ktor.client.HttpClient
4 | import io.ktor.client.features.json.JsonFeature
5 | import io.ktor.client.features.json.serializer.KotlinxSerializer
6 | import io.ktor.client.features.logging.LogLevel
7 | import io.ktor.client.features.logging.Logger
8 | import io.ktor.client.features.logging.Logging
9 | import io.ktor.client.request.request
10 | import io.ktor.http.HttpMethod
11 | import kotlinx.serialization.json.Json
12 |
13 | private const val BASE_URL = "https://finnhub.io/api/v1/"
14 |
15 | internal class StonksHttpClient(
16 | private val client: HttpClient = defaultHttpClient(),
17 | private val baseUrl: String = BASE_URL,
18 | ) {
19 |
20 | internal suspend inline fun execute(request: HttpRequest): T {
21 | return client.request("$baseUrl${request.url}$API_TOKEN") {
22 | this.method = HttpMethod.Get
23 | this.body = request.body
24 | }
25 | }
26 | }
27 |
28 | private fun defaultHttpClient() = HttpClient {
29 | install(JsonFeature) {
30 | serializer = KotlinxSerializer(Json {
31 | prettyPrint = true
32 | isLenient = true
33 | ignoreUnknownKeys = true
34 | })
35 | }
36 | install(Logging) {
37 | level = LogLevel.HEADERS
38 | logger = object : Logger {
39 | override fun log(message: String) {
40 | print("Api: message = $message")
41 | }
42 | }
43 | }
44 | }
45 |
--------------------------------------------------------------------------------
/shared/src/commonMain/kotlin/com/rafag/stonks/internal/data/repositories/favourites/FavouritesRepositoryImpl.kt:
--------------------------------------------------------------------------------
1 | package com.rafag.stonks.internal.data.repositories.favourites
2 |
3 | import com.rafag.stonks.internal.domain.repositories.FavouritesRepository
4 | import kotlinx.coroutines.flow.Flow
5 |
6 | internal class FavouritesRepositoryImpl(
7 | private val persistence: FavouritesPersistence
8 | ) : FavouritesRepository {
9 |
10 | override suspend fun getAll(): Flow> {
11 | return try {
12 | persistence.getAll()
13 | } catch (exception: Exception) {
14 | throw ErrorFetchingFavourites(exception)
15 | }
16 | }
17 |
18 | override suspend fun save(symbol: String) {
19 | try {
20 | persistence.save(symbol)
21 | } catch (exception: Exception) {
22 | throw ErrorSavingFavourite(symbol, exception)
23 | }
24 | }
25 |
26 | override suspend fun unsave(symbol: String) {
27 | try {
28 | persistence.unsave(symbol)
29 | } catch (exception: Exception) {
30 | throw ErrorUnSaveFavourite(symbol, exception)
31 | }
32 | }
33 |
34 | data class ErrorFetchingFavourites(override val cause: Throwable) : Throwable("Could not fetch favourites - $cause")
35 | data class ErrorSavingFavourite(val symbol: String, override val cause: Throwable) : Throwable("Symbol $symbol couldn't be saved - $cause")
36 | data class ErrorUnSaveFavourite(val symbol: String, override val cause: Throwable) : Throwable("Symbol $symbol couldn't be unsaved - $cause")
37 | }
--------------------------------------------------------------------------------
/androidApp/design/src/main/java/com/rafag/stonks/android/design/theming/Palette.kt:
--------------------------------------------------------------------------------
1 | package com.rafag.stonks.android.design.theming
2 |
3 | import androidx.compose.material.darkColors
4 | import androidx.compose.material.lightColors
5 | import androidx.compose.ui.graphics.Color
6 |
7 | object StonksColors {
8 | val yellow600 = Color(0xFFbba353)
9 | val yellow800 = Color(0xFF977e4a)
10 | val red400 = Color(0xFFc6233e)
11 | val green400 = Color(0xFF82cf63)
12 | val white = Color(0xFFFFFFFF)
13 | val black200 = Color(0xFF001122)
14 | val gray200 = Color(0xFFd6d8d8)
15 | val gray600 = Color(0xFF6a6c6c)
16 | }
17 |
18 | val lightColors = lightColors(
19 | primary = StonksColors.yellow600,
20 | primaryVariant = StonksColors.yellow800,
21 | secondary = StonksColors.yellow600,
22 | secondaryVariant = StonksColors.yellow600,
23 | background = StonksColors.white,
24 | surface = StonksColors.white,
25 | error = StonksColors.red400,
26 | onPrimary = StonksColors.white,
27 | onSecondary = StonksColors.white,
28 | onBackground = StonksColors.yellow600,
29 | onSurface = StonksColors.yellow600,
30 | onError = StonksColors.white,
31 | )
32 |
33 | val darkColors = darkColors(
34 | primary = StonksColors.yellow600,
35 | primaryVariant = StonksColors.yellow800,
36 | secondary = StonksColors.yellow600,
37 | secondaryVariant = StonksColors.yellow600,
38 | background = StonksColors.black200,
39 | surface = StonksColors.white,
40 | error = StonksColors.red400,
41 | onPrimary = StonksColors.white,
42 | onSecondary = StonksColors.white,
43 | onBackground = StonksColors.yellow600,
44 | onSurface = StonksColors.yellow600,
45 | onError = StonksColors.white,
46 | )
--------------------------------------------------------------------------------
/androidApp/design/src/main/java/com/rafag/stonks/android/design/views/StonkQuote.kt:
--------------------------------------------------------------------------------
1 | package com.rafag.stonks.android.design.views
2 |
3 | import androidx.compose.foundation.background
4 | import androidx.compose.foundation.layout.Column
5 | import androidx.compose.foundation.layout.fillMaxWidth
6 | import androidx.compose.foundation.layout.padding
7 | import androidx.compose.foundation.shape.RoundedCornerShape
8 | import androidx.compose.material.Colors
9 | import androidx.compose.material.MaterialTheme
10 | import androidx.compose.runtime.Composable
11 | import androidx.compose.ui.Alignment.Companion.CenterHorizontally
12 | import androidx.compose.ui.Modifier
13 | import androidx.compose.ui.draw.clip
14 | import androidx.compose.ui.graphics.Color
15 | import androidx.compose.ui.unit.dp
16 | import com.rafag.stonks.android.design.theming.StonksColors
17 | import com.rafag.stonks.android.design.theming.StonksText
18 |
19 | @Composable
20 | fun StonkQuote(isUp: Boolean, price: String, change: String) {
21 | Column(
22 | modifier = Modifier
23 | .fillMaxWidth()
24 | .clip(RoundedCornerShape(16.dp))
25 | .background(if (isUp) StonksColors.green400 else StonksColors.red400)
26 | .padding(4.dp)
27 | ) {
28 | StonksText.BodyMediumBold(
29 | modifier = Modifier.align(CenterHorizontally),
30 | color = MaterialTheme.colors.quotesTextColor,
31 | text = price,
32 | )
33 | StonksText.BodySmall(
34 | modifier = Modifier.align(CenterHorizontally),
35 | color = MaterialTheme.colors.quotesTextColor,
36 | text = change,
37 | )
38 | }
39 | }
40 |
41 | private val Colors.quotesTextColor: Color
42 | @Composable get() = StonksColors.white
--------------------------------------------------------------------------------
/androidApp/build.gradle.kts:
--------------------------------------------------------------------------------
1 | plugins {
2 | id("com.android.application")
3 | kotlin("android")
4 | }
5 |
6 | dependencies {
7 | implementation(project(":shared"))
8 | implementation(project(":androidApp:design"))
9 | implementation(project(":androidApp:navigation"))
10 | implementation(project(":androidApp:search"))
11 | implementation(project(":androidApp:faved"))
12 | implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:${properties["version.kotlinx.coroutines"]}")
13 | implementation("androidx.compose.compiler:compiler:1.1.0-alpha04")
14 | implementation("androidx.compose.ui:ui:1.0.2")
15 | implementation("androidx.compose.ui:ui-text:1.0.2")
16 | implementation("androidx.compose.foundation:foundation:1.0.2")
17 | implementation("androidx.compose.runtime:runtime-livedata:1.0.2")
18 | implementation("androidx.compose.material:material:1.0.2")
19 | implementation("androidx.activity:activity-compose:1.3.1")
20 | implementation("androidx.navigation:navigation-compose:2.4.0-alpha09")
21 | implementation("androidx.compose.ui:ui-tooling:1.0.1") {
22 | version {
23 | strictly("1.0.0-beta09")
24 | }
25 | }
26 | }
27 |
28 | android {
29 | compileSdkVersion(31)
30 | defaultConfig {
31 | applicationId = "com.rafag.stonks.android"
32 | minSdkVersion(21)
33 | targetSdkVersion(31)
34 | versionCode = 1
35 | versionName = "1.0"
36 | }
37 | buildTypes {
38 | getByName("release") {
39 | isMinifyEnabled = false
40 | }
41 | }
42 |
43 | buildFeatures {
44 | compose = true
45 | }
46 |
47 | composeOptions {
48 | kotlinCompilerExtensionVersion = "1.1.0-alpha05"
49 | }
50 |
51 | kotlinOptions {
52 | jvmTarget = "1.8"
53 | }
54 | }
55 |
--------------------------------------------------------------------------------
/shared/src/commonMain/kotlin/com/rafag/stonks/internal/data/repositories/quote/QuoteRepositoryImpl.kt:
--------------------------------------------------------------------------------
1 | package com.rafag.stonks.internal.data.repositories.quote
2 |
3 | import com.rafag.stonks.internal.data.httpclient.StonksHttpClient
4 | import com.rafag.stonks.internal.domain.repositories.Quote
5 | import com.rafag.stonks.internal.domain.repositories.QuoteRepository
6 | import com.stonks.db.DbQuote
7 |
8 | internal class QuoteRepositoryImpl(
9 | private val httpClient: StonksHttpClient,
10 | private val persistence: QuotePersistence,
11 | ) : QuoteRepository {
12 |
13 | override suspend fun quote(symbol: String): Quote {
14 | return try {
15 | fromApi(symbol)
16 | } catch (exception: Exception) {
17 | fromDb(symbol)
18 | }
19 | }
20 |
21 | private suspend fun fromApi(symbol: String): Quote {
22 | val apiResponse = httpClient.execute(QuoteApi.quoteRequest(symbol))
23 | persistence.upsert(symbol, apiResponse)
24 | return apiResponse.toModel(symbol)
25 | }
26 |
27 | private fun fromDb(symbol: String): Quote {
28 | return try {
29 | persistence.get(symbol).toModel()
30 | } catch (exception: Exception) {
31 | throw ErrorFetchingQuote(symbol, exception)
32 | }
33 | }
34 | }
35 |
36 | data class ErrorFetchingQuote(val symbol: String, override val cause: Throwable) : Throwable("Could not fetch favourites - $cause")
37 |
38 | internal fun ApiQuoteResponse.toModel(symbol: String) = Quote(
39 | symbol = symbol,
40 | current = current,
41 | high = high,
42 | low = low,
43 | open = open,
44 | previousClose = previousClose
45 | )
46 |
47 | internal fun DbQuote.toModel() = Quote(
48 | symbol = symbol,
49 | current = current,
50 | high = high,
51 | low = low,
52 | open = open_,
53 | previousClose = previousClose
54 | )
55 |
--------------------------------------------------------------------------------
/androidApp/design/src/main/java/com/rafag/stonks/android/design/theming/Typography.kt:
--------------------------------------------------------------------------------
1 | package com.rafag.stonks.android.design.theming
2 |
3 | import androidx.compose.material.Typography
4 | import androidx.compose.ui.text.TextStyle
5 | import androidx.compose.ui.text.font.FontFamily
6 | import androidx.compose.ui.text.font.FontWeight
7 | import androidx.compose.ui.unit.sp
8 |
9 | val typography = Typography(
10 | defaultFontFamily = FontFamily.SansSerif,
11 | h1 = TextStyle(
12 | fontWeight = FontWeight.Bold,
13 | fontSize = 40.sp
14 | ),
15 | h2 = TextStyle(
16 | fontWeight = FontWeight.Bold,
17 | fontSize = 32.sp
18 | ),
19 | h3 = TextStyle(
20 | fontWeight = FontWeight.Bold,
21 | fontSize = 24.sp
22 | ),
23 | h4 = TextStyle(
24 | fontWeight = FontWeight.Bold,
25 | fontSize = 20.sp
26 | ),
27 | h5 = TextStyle(
28 | fontWeight = FontWeight.Bold,
29 | fontSize = 20.sp
30 | ),
31 | h6 = TextStyle(
32 | fontWeight = FontWeight.Bold,
33 | fontSize = 18.sp
34 | ),
35 | button = TextStyle(
36 | fontWeight = FontWeight.Bold,
37 | fontSize = 16.sp,
38 | ),
39 | body1 = TextStyle(
40 | fontWeight = FontWeight.Medium,
41 | fontSize = 16.sp
42 | ),
43 | subtitle1 = TextStyle(
44 | fontWeight = FontWeight.Medium,
45 | fontSize = 16.sp
46 | ),
47 | body2 = TextStyle(
48 | fontWeight = FontWeight.Medium,
49 | fontSize = 14.sp
50 | ),
51 | subtitle2 = TextStyle(
52 | fontWeight = FontWeight.Medium,
53 | fontSize = 14.sp
54 | ),
55 | caption = TextStyle(
56 | fontWeight = FontWeight.Medium,
57 | fontSize = 12.sp
58 | ),
59 | overline = TextStyle(
60 | fontWeight = FontWeight.Medium,
61 | fontSize = 12.sp
62 | )
63 | )
64 |
65 |
--------------------------------------------------------------------------------
/androidApp/design/src/main/java/com/rafag/stonks/android/design/views/Error.kt:
--------------------------------------------------------------------------------
1 | package com.rafag.stonks.android.design.views
2 |
3 | import androidx.compose.foundation.clickable
4 | import androidx.compose.foundation.layout.Arrangement
5 | import androidx.compose.foundation.layout.Column
6 | import androidx.compose.foundation.layout.fillMaxSize
7 | import androidx.compose.foundation.layout.padding
8 | import androidx.compose.foundation.layout.size
9 | import androidx.compose.material.Icon
10 | import androidx.compose.material.MaterialTheme
11 | import androidx.compose.material.Text
12 | import androidx.compose.material.icons.Icons.Filled
13 | import androidx.compose.material.icons.filled.Refresh
14 | import androidx.compose.runtime.Composable
15 | import androidx.compose.ui.Alignment
16 | import androidx.compose.ui.Alignment.*
17 | import androidx.compose.ui.Modifier
18 | import androidx.compose.ui.res.stringResource
19 | import androidx.compose.ui.tooling.preview.Preview
20 | import androidx.compose.ui.unit.dp
21 | import com.rafag.stonks.android.design.R
22 | import com.rafag.stonks.android.design.theming.StonksText
23 |
24 | @Composable
25 | fun Error(onClick: () -> Unit) {
26 | Column(
27 | modifier = Modifier
28 | .fillMaxSize()
29 | .clickable { onClick() },
30 | verticalArrangement = Arrangement.Center,
31 | horizontalAlignment = Alignment.CenterHorizontally
32 | ) {
33 | StonksText.BodyMediumBold(
34 | modifier = Modifier
35 | .align(Alignment.CenterHorizontally)
36 | .padding(bottom = 32.dp),
37 | color = MaterialTheme.colors.error,
38 | text = stringResource(id = R.string.error_title),
39 | )
40 | Icon(
41 | Filled.Refresh,
42 | contentDescription = "",
43 | tint = MaterialTheme.colors.error,
44 | modifier = Modifier.size(48.dp)
45 | )
46 | }
47 | }
48 |
49 | @Preview
50 | @Composable
51 | fun ErrorPreview() {
52 | MaterialTheme {
53 | Error {
54 |
55 | }
56 | }
57 | }
58 |
--------------------------------------------------------------------------------
/shared/src/commonMain/kotlin/com/rafag/stonks/internal/domain/usecases/FetchFavedQuotesUseCaseImpl.kt:
--------------------------------------------------------------------------------
1 | package com.rafag.stonks.internal.domain.usecases
2 |
3 | import com.rafag.stonks.domain.usecases.FavedQuote
4 | import com.rafag.stonks.domain.usecases.FetchFavedQuotesUseCase
5 | import com.rafag.stonks.internal.domain.repositories.FavouritesRepository
6 | import com.rafag.stonks.internal.domain.repositories.Quote
7 | import com.rafag.stonks.internal.domain.repositories.QuoteRepository
8 | import com.rafag.stonks.internal.data.repositories.quote.ErrorFetchingQuote
9 | import kotlinx.coroutines.FlowPreview
10 | import kotlinx.coroutines.flow.Flow
11 | import kotlinx.coroutines.flow.flatMapConcat
12 | import kotlinx.coroutines.flow.flowOf
13 | import kotlinx.coroutines.flow.map
14 |
15 | internal class FetchFavedQuotesUseCaseImpl(
16 | private val quoteRepository: QuoteRepository,
17 | private val favouritesRepository: FavouritesRepository
18 | ): FetchFavedQuotesUseCase {
19 |
20 | @OptIn(FlowPreview::class)
21 | override suspend fun invoke(): Flow> {
22 | return favouritesRepository.getAll().flatMapConcat { favedSymbols ->
23 | quotes(favedSymbols).map { quotes ->
24 | quotes.toFavedQuote()
25 | }
26 | }
27 | }
28 |
29 | private suspend fun quotes(symbols: List): Flow> {
30 | return flowOf(symbols.mapNotNull {
31 | /**
32 | * The stock provider (FinnHub) returns randoms 403 for some foreign stocks in their free tier.
33 | * We ignore the stock because this is a pet project so we look simplicity and we don't want
34 | * to fail the whole page because of it - https://github.com/finnhubio/Finnhub-API/issues/372
35 | */
36 | try {
37 | quoteRepository.quote(it)
38 | } catch (exception: ErrorFetchingQuote) {
39 | null
40 | }
41 | })
42 | }
43 | }
44 |
45 | fun List.toFavedQuote() = this.map { it.toFavedQuote() }
46 |
47 | fun Quote.toFavedQuote() = FavedQuote(
48 | symbol = this.symbol,
49 | current = this.current,
50 | open = this.open,
51 | )
52 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Stonks
2 |
3 | [
](https://androidweekly.net/issues/issue-490)
4 |
5 | Stonks is a pet project app to track your favorite stocks.
6 |
7 | It uses [Kotlin Multiplatform Mobile (KMM)](https://kotlinlang.org/docs/kmm-overview.html) to share code across iOS and Android.
8 |
9 | *Note: [This article](https://www.rafagarcia.dev/development/architecting-mobile-apps-with-kotlin-multiplatform/) about Stonks has been featured in both [Android Weekly (#490)](https://androidweekly.net/issues/issue-490) and [Kotlin Weeekly (#273)](https://mailchi.mp/kotlinweekly/kotlin-weekly-273). Thanks to both!* 🙏
10 |
11 | ### KMM library
12 | - Repositories with both local and remote data sources (Data layer).
13 | - UseCases exposed to clients to fetch and combine data for the different app features (Domain layer).
14 | - Libraries used: [ktor-client](https://ktor.io/docs/client.html), [kotlin-coroutines](https://kotlinlang.org/docs/coroutines-overview.html), [kotlin-serialization](https://github.com/Kotlin/kotlinx.serialization) and [sql-delight](https://github.com/cashapp/sqldelight)
15 |
16 |
17 | ### Android app
18 | - Home screen with saved stocks and search screen to find stocks (Presentation and UI layers).
19 | - Single activity architecture
20 | - Light/Dark theme
21 | - Libraries used: Stonks shared library, [Jetpack Compose](https://developer.android.com/jetpack/compose) and [kotlin-coroutines](https://kotlinlang.org/docs/coroutines-overview.html)
22 |
23 | | Home screen (Light) | Home screen (Dark) | Search screen (Light) | Search screen (Dark) |
24 | | ----------- | ----------- | ----------- | ----------- |
25 | | ||  |
26 |
27 | ### Next steps
28 | 1. Decoupling apps and library into different repositories, updating library dependencies with [Dependabot](https://dependabot.com/)
29 | 2. iOS app
30 |
--------------------------------------------------------------------------------
/shared/src/commonMain/kotlin/com/rafag/stonks/domain/usecases/UseCasesProvider.kt:
--------------------------------------------------------------------------------
1 | package com.rafag.stonks.domain.usecases
2 |
3 | import com.rafag.stonks.internal.domain.repositories.FavouritesRepository
4 | import com.rafag.stonks.internal.domain.repositories.QuoteRepository
5 | import com.rafag.stonks.internal.domain.repositories.SearchRepository
6 | import com.rafag.stonks.internal.data.httpclient.StonksHttpClient
7 | import com.rafag.stonks.internal.data.repositories.favourites.FavouritesPersistence
8 | import com.rafag.stonks.internal.data.repositories.favourites.FavouritesRepositoryImpl
9 | import com.rafag.stonks.internal.data.repositories.quote.QuotePersistence
10 | import com.rafag.stonks.internal.data.repositories.quote.QuoteRepositoryImpl
11 | import com.rafag.stonks.internal.data.repositories.search.SearchRepositoryImpl
12 | import com.rafag.stonks.internal.domain.usecases.FetchFavedQuotesUseCaseImpl
13 | import com.rafag.stonks.internal.domain.usecases.SearchStonksUseCaseImpl
14 | import com.rafag.stonks.internal.domain.usecases.ToggleFavouriteUseCaseImpl
15 | import com.stonks.db.StonksDatabase
16 |
17 | class UseCasesProvider(
18 | val db: StonksDatabase,
19 | ) {
20 |
21 | private val httpClient: StonksHttpClient = StonksHttpClient()
22 |
23 | fun fetchFavedQuotesUseCase(): FetchFavedQuotesUseCase = FetchFavedQuotesUseCaseImpl(
24 | quoteRepository = quoteRepository(),
25 | favouritesRepository = favouritesRepository(),
26 | )
27 |
28 | fun searchStonksUseCase(): SearchStonksUseCase = SearchStonksUseCaseImpl(
29 | searchRepository = searchRepository(),
30 | favouritesRepository = favouritesRepository(),
31 | )
32 |
33 | fun toggleFavouritesUseCase(): ToggleFavouriteUseCase = ToggleFavouriteUseCaseImpl(
34 | favouritesRepository = favouritesRepository()
35 | )
36 |
37 | private fun quoteRepository(): QuoteRepository {
38 | return QuoteRepositoryImpl(
39 | httpClient = httpClient,
40 | persistence = QuotePersistence(db)
41 | )
42 | }
43 |
44 | private fun favouritesRepository(): FavouritesRepository {
45 | return FavouritesRepositoryImpl(
46 | persistence = FavouritesPersistence(db)
47 | )
48 | }
49 |
50 | private fun searchRepository(): SearchRepository {
51 | return SearchRepositoryImpl(httpClient)
52 | }
53 | }
--------------------------------------------------------------------------------
/shared/src/commonTest/kotlin/com/rafag/stonks/internal/data/repositories/quote/QuoteRepositoryImplTest.kt:
--------------------------------------------------------------------------------
1 | package com.rafag.stonks.internal.data.repositories.quote
2 |
3 | import com.rafag.stonks.internal.fixtures.QuoteFixture
4 | import com.rafag.stonks.mock
5 | import com.rafag.stonks.mockHttp
6 | import com.rafag.stonks.runBlocking
7 | import com.rafag.stonks.verify
8 | import com.rafag.stonks.whenever
9 | import kotlin.test.Test
10 | import kotlin.test.assertEquals
11 | import kotlin.test.assertFailsWith
12 |
13 | private const val A_SYMBOL = "AAPL"
14 |
15 | private val AN_API_QUOTE = QuoteFixture.anApiQuote()
16 | private val A_DB_QUOTE = QuoteFixture.aDbQuote(A_SYMBOL)
17 |
18 | class QuoteRepositoryImplTest {
19 |
20 | private val httpClient = mockHttp()
21 | private val persistence = mock()
22 |
23 | private val repository = QuoteRepositoryImpl(httpClient.instance, persistence)
24 |
25 | @Test
26 | fun `given api success when fetching quote then return data and update persistence`() {
27 | val request = QuoteApi.quoteRequest(A_SYMBOL)
28 | httpClient.addHandler(request, AN_API_QUOTE)
29 |
30 | val result = runBlocking { repository.quote(A_SYMBOL) }
31 |
32 | verify(persistence).upsert(A_SYMBOL, AN_API_QUOTE)
33 | assertEquals(result, AN_API_QUOTE.toModel(A_SYMBOL))
34 | }
35 |
36 | @Test
37 | fun `given api error but db success when fetching quote then return data`() {
38 | val request = QuoteApi.quoteRequest(A_SYMBOL)
39 | val apiResponse = RuntimeException("Error")
40 | httpClient.addHandler(request, apiResponse)
41 | whenever(persistence.get(A_SYMBOL)).thenReturn(A_DB_QUOTE)
42 |
43 | val result = runBlocking { repository.quote(A_SYMBOL) }
44 |
45 | assertEquals(result, A_DB_QUOTE.toModel())
46 | }
47 |
48 | @Test
49 | fun `given api and db error when fetching quote then throw exception`() {
50 | val request = QuoteApi.quoteRequest(A_SYMBOL)
51 | val apiResponse = RuntimeException("Error api")
52 | val dbResponse = RuntimeException("Error db")
53 | httpClient.addHandler(request, apiResponse)
54 | whenever(persistence.get(A_SYMBOL)).thenThrow(dbResponse)
55 |
56 | assertFailsWith {
57 | runBlocking { repository.quote(A_SYMBOL) }
58 | }
59 | }
60 | }
--------------------------------------------------------------------------------
/shared/src/commonTest/kotlin/com/rafag/stonks/internal/domain/usecases/SearchStonksUseCaseImplTest.kt:
--------------------------------------------------------------------------------
1 | package com.rafag.stonks.internal.domain.usecases
2 |
3 | import com.rafag.stonks.domain.usecases.StonkSearch
4 | import com.rafag.stonks.internal.domain.repositories.FavouritesRepository
5 | import com.rafag.stonks.internal.domain.repositories.SearchRepository
6 | import com.rafag.stonks.internal.fixtures.SearchFixture
7 | import com.rafag.stonks.mock
8 | import com.rafag.stonks.runBlocking
9 | import com.rafag.stonks.whenever
10 | import kotlinx.coroutines.flow.first
11 | import kotlinx.coroutines.flow.flowOf
12 | import kotlin.test.Test
13 | import kotlin.test.assertEquals
14 |
15 | private const val FAVED_SYMBOL = "GOOG"
16 | private const val FAVED_NAME = "google"
17 | private const val UNFAVED_SYMBOL = "GOOGF"
18 | private const val UNFAVED_NAME = "googles fake inc"
19 | private const val A_SEARCH_QUERY = FAVED_SYMBOL
20 | private val A_FAVED_SYMBOLS = listOf(FAVED_SYMBOL)
21 | private val A_FAVED_SEARCH_ITEM = SearchFixture.aSearchItem(symbol = FAVED_SYMBOL, name = FAVED_NAME)
22 | private val A_NOT_FAVED_SEARCH_ITEM = SearchFixture.aSearchItem(UNFAVED_SYMBOL, name = UNFAVED_NAME)
23 | private val A_SEARCH_ITEMS = SearchFixture.aSearch(listOf(A_FAVED_SEARCH_ITEM, A_NOT_FAVED_SEARCH_ITEM))
24 |
25 | class SearchStonksUseCaseImplTest {
26 |
27 | private val searchRepository = mock()
28 | private val favouritesRepository = mock()
29 | private val useCase = SearchStonksUseCaseImpl(searchRepository, favouritesRepository)
30 |
31 | @Test
32 | fun `given query when invoked then fetch all matching symbols and its faved state`() = runBlocking {
33 | whenever(searchRepository.search(A_SEARCH_QUERY)).thenReturn(A_SEARCH_ITEMS)
34 | whenever(favouritesRepository.getAll()).thenReturn(flowOf(A_FAVED_SYMBOLS))
35 |
36 | val expected = listOf(
37 | StonkSearch(
38 | symbol = FAVED_SYMBOL,
39 | name = FAVED_NAME,
40 | faved = true,
41 | ),
42 | StonkSearch(
43 | symbol = UNFAVED_SYMBOL,
44 | name = UNFAVED_NAME,
45 | faved = false,
46 | ),
47 | )
48 |
49 | val result = useCase.invoke(A_SEARCH_QUERY)
50 |
51 | assertEquals(result.first(), expected)
52 | }
53 | }
--------------------------------------------------------------------------------
/shared/src/commonTest/kotlin/com/rafag/stonks/internal/data/repositories/favourites/FavouritesRepositoryImplTest.kt:
--------------------------------------------------------------------------------
1 | package com.rafag.stonks.internal.data.repositories.favourites
2 |
3 | import com.rafag.stonks.internal.data.repositories.favourites.FavouritesRepositoryImpl.*
4 | import com.rafag.stonks.mock
5 | import com.rafag.stonks.runBlocking
6 | import com.rafag.stonks.whenever
7 | import kotlinx.coroutines.flow.flowOf
8 | import kotlin.test.Test
9 | import kotlin.test.assertEquals
10 | import kotlin.test.assertFailsWith
11 |
12 | private const val A_SYMBOL = "AAPL"
13 |
14 | class FavouritesRepositoryImplTest {
15 |
16 | private val persistence = mock()
17 |
18 | private val repository = FavouritesRepositoryImpl(persistence)
19 |
20 | @Test
21 | fun `given success when getting all saved then return list`() {
22 | val expectedList = flowOf(listOf(A_SYMBOL))
23 | whenever(persistence.getAll()).thenReturn(expectedList)
24 |
25 | runBlocking {
26 | val returnedList = repository.getAll()
27 | assertEquals(returnedList, expectedList)
28 | }
29 | }
30 |
31 | @Test
32 | fun `given error when getting all saved then throw exception`() {
33 | val throwable = RuntimeException("foo")
34 | whenever(persistence.getAll()).thenThrow(throwable)
35 |
36 | assertFailsWith {
37 | runBlocking { repository.getAll() }
38 | }
39 | }
40 |
41 | @Test
42 | fun `given success when saving then do nothing`() {
43 | runBlocking {
44 | repository.save(A_SYMBOL)
45 | }
46 | }
47 |
48 | @Test
49 | fun `given error when saving then throw exception`() {
50 | val throwable = RuntimeException("foo")
51 | whenever(persistence.save(A_SYMBOL)).thenThrow(throwable)
52 |
53 | assertFailsWith {
54 | runBlocking { repository.save(A_SYMBOL) }
55 | }
56 | }
57 |
58 | @Test
59 | fun `given success when unsaving then do nothing`() {
60 | runBlocking { repository.unsave(A_SYMBOL) }
61 | }
62 |
63 | @Test
64 | fun `given error when unsaving then throw exception`() {
65 | val throwable = RuntimeException("foo")
66 | whenever(persistence.unsave(A_SYMBOL)).thenThrow(throwable)
67 |
68 | assertFailsWith {
69 | runBlocking { repository.unsave(A_SYMBOL) }
70 | }
71 | }
72 | }
--------------------------------------------------------------------------------
/androidApp/faved/src/main/java/com/rafag/stonks/android/faved/presentation/FavedQuotesViewModel.kt:
--------------------------------------------------------------------------------
1 | package com.rafag.stonks.android.faved.presentation
2 |
3 | import androidx.lifecycle.ViewModel
4 | import androidx.lifecycle.viewModelScope
5 | import com.rafag.stonks.domain.usecases.FavedQuote
6 | import com.rafag.stonks.domain.usecases.FetchFavedQuotesUseCase
7 | import com.rafag.stonks.domain.usecases.ToggleFavouriteUseCase
8 | import kotlinx.coroutines.flow.MutableStateFlow
9 | import kotlinx.coroutines.flow.StateFlow
10 | import kotlinx.coroutines.flow.catch
11 | import kotlinx.coroutines.flow.collect
12 | import kotlinx.coroutines.launch
13 |
14 | class FavedViewModel(
15 | private val fetchFavedQuotesUseCase: FetchFavedQuotesUseCase,
16 | private val toggleFavouriteUseCase: ToggleFavouriteUseCase,
17 | ) : ViewModel() {
18 |
19 | private val _state = MutableStateFlow(FavedState.Loading)
20 | val state: StateFlow get() = _state
21 |
22 | fun load() {
23 | viewModelScope.launch {
24 | fetchFavedQuotesUseCase.invoke()
25 | .catch { _state.value = FavedState.Error }
26 | .collect { favedQuotes ->
27 | _state.value = FavedState.Content(
28 | quotes = favedQuotes.map {
29 | it.toFavedQuoteUi()
30 | }
31 | )
32 | }
33 | }
34 | }
35 |
36 | fun onDeleteStonkClicked(item: FavedQuoteUi) {
37 | viewModelScope.launch {
38 | toggleFavouriteUseCase.unsaved(item.symbol)
39 | }
40 | }
41 | }
42 |
43 | private fun FavedQuote.toFavedQuoteUi() = FavedQuoteUi(
44 | symbol = symbol,
45 | current = current.roundTo(2).toString(),
46 | change = "${open.roundTo(2)} (${formatPercentageChange(percentageChange(open, current))})",
47 | isUp = percentageChange(open, current) > 0
48 | )
49 |
50 | private fun formatPercentageChange(percentageChange: Double): String {
51 | val rounded = percentageChange.roundTo(2)
52 | return if (rounded > 0) "+$rounded%" else "$rounded%"
53 | }
54 |
55 | private fun percentageChange(initial: Double, final: Double) = ((final - initial) / initial) * 100
56 |
57 | fun Double.roundTo(n: Int): Double {
58 | return "%.${n}f".format(this).toDouble()
59 | }
60 |
61 | data class FavedQuoteUi(
62 | val symbol: String,
63 | val current: String,
64 | val change: String,
65 | val isUp: Boolean
66 | )
67 |
68 | sealed class FavedState {
69 | object Loading : FavedState()
70 | object Error : FavedState()
71 | data class Content(val quotes: List) : FavedState()
72 | }
73 |
--------------------------------------------------------------------------------
/iosApp/iosApp/App/SceneDelegate.swift:
--------------------------------------------------------------------------------
1 | import UIKit
2 | import SwiftUI
3 | import shared
4 |
5 | class SceneDelegate: UIResponder, UIWindowSceneDelegate {
6 |
7 | var window: UIWindow?
8 |
9 | func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
10 | // Use this method to optionally configure and attach the UIWindow `window` to the provided UIWindowScene `scene`.
11 | // If using a storyboard, the `window` property will automatically be initialized and attached to the scene.
12 | // This delegate does not imply the connecting scene or session are new (see `application:configurationForConnectingSceneSession` instead).
13 | let contentView = ContentView(stocks: Data.getData())
14 | print(apiResponse)
15 | if let windowScene = scene as? UIWindowScene {
16 | let window = UIWindow(windowScene: windowScene)
17 | window.rootViewController = UIHostingController(rootView: contentView)
18 | self.window = window
19 | window.makeKeyAndVisible()
20 | }
21 | }
22 |
23 | func sceneDidDisconnect(_ scene: UIScene) {
24 | // Called as the scene is being released by the system.
25 | // This occurs shortly after the scene enters the background, or when its session is discarded.
26 | // Release any resources associated with this scene that can be re-created the next time the scene connects.
27 | // The scene may re-connect later, as its session was not neccessarily discarded (see `application:didDiscardSceneSessions` instead).
28 | }
29 |
30 | func sceneDidBecomeActive(_ scene: UIScene) {
31 | // Called when the scene has moved from an inactive state to an active state.
32 | // Use this method to restart any tasks that were paused (or not yet started) when the scene was inactive.
33 | }
34 |
35 | func sceneWillResignActive(_ scene: UIScene) {
36 | // Called when the scene will move from an active state to an inactive state.
37 | // This may occur due to temporary interruptions (ex. an incoming phone call).
38 | }
39 |
40 | func sceneWillEnterForeground(_ scene: UIScene) {
41 | // Called as the scene transitions from the background to the foreground.
42 | // Use this method to undo the changes made on entering the background.
43 | }
44 |
45 | func sceneDidEnterBackground(_ scene: UIScene) {
46 | // Called as the scene transitions from the foreground to the background.
47 | // Use this method to save data, release shared resources, and store enough scene-specific state information
48 | // to restore the scene back to its current state.
49 | }
50 | }
51 |
52 |
--------------------------------------------------------------------------------
/iosApp/iosApp/Info.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | CFBundleDevelopmentRegion
6 | $(DEVELOPMENT_LANGUAGE)
7 | CFBundleExecutable
8 | $(EXECUTABLE_NAME)
9 | CFBundleIdentifier
10 | $(PRODUCT_BUNDLE_IDENTIFIER)
11 | CFBundleInfoDictionaryVersion
12 | 6.0
13 | CFBundleName
14 | $(PRODUCT_NAME)
15 | CFBundlePackageType
16 | $(PRODUCT_BUNDLE_PACKAGE_TYPE)
17 | CFBundleShortVersionString
18 | 1.0
19 | CFBundleVersion
20 | 1
21 | LSRequiresIPhoneOS
22 |
23 | UIApplicationSceneManifest
24 |
25 | UIApplicationSupportsMultipleScenes
26 |
27 | UISceneConfigurations
28 |
29 | UIWindowSceneSessionRoleApplication
30 |
31 |
32 | UISceneConfigurationName
33 | Default Configuration
34 | UISceneDelegateClassName
35 | $(PRODUCT_MODULE_NAME).SceneDelegate
36 |
37 |
38 |
39 |
40 | UILaunchStoryboardName
41 | SceneDelegate
42 | UIRequiredDeviceCapabilities
43 |
44 | armv7
45 |
46 | UISupportedInterfaceOrientations
47 |
48 | UIInterfaceOrientationPortrait
49 | UIInterfaceOrientationLandscapeLeft
50 | UIInterfaceOrientationLandscapeRight
51 |
52 | UISupportedInterfaceOrientations~ipad
53 |
54 | UIInterfaceOrientationPortrait
55 | UIInterfaceOrientationPortraitUpsideDown
56 | UIInterfaceOrientationLandscapeLeft
57 | UIInterfaceOrientationLandscapeRight
58 |
59 | UIApplicationSceneManifest
60 |
61 | UIApplicationSupportsMultipleScenes
62 |
63 | UISceneConfigurations
64 |
65 | UIWindowSceneSessionRoleApplication
66 |
67 |
68 | UISceneConfigurationName
69 | Default Configuration
70 | UISceneDelegateClassName
71 | $(PRODUCT_MODULE_NAME).SceneDelegate
72 |
73 |
74 |
75 |
76 |
77 |
78 |
--------------------------------------------------------------------------------
/androidApp/design/src/main/java/com/rafag/stonks/android/design/views/SearchBar.kt:
--------------------------------------------------------------------------------
1 | package com.rafag.stonks.android.design.views
2 |
3 | import androidx.compose.foundation.layout.fillMaxWidth
4 | import androidx.compose.foundation.layout.padding
5 | import androidx.compose.foundation.layout.size
6 | import androidx.compose.material.Icon
7 | import androidx.compose.material.IconButton
8 | import androidx.compose.material.MaterialTheme
9 | import androidx.compose.material.Text
10 | import androidx.compose.material.TextField
11 | import androidx.compose.material.icons.Icons
12 | import androidx.compose.material.icons.filled.Close
13 | import androidx.compose.material.icons.filled.Search
14 | import androidx.compose.runtime.Composable
15 | import androidx.compose.runtime.DisposableEffect
16 | import androidx.compose.runtime.MutableState
17 | import androidx.compose.ui.Modifier
18 | import androidx.compose.ui.focus.FocusRequester
19 | import androidx.compose.ui.focus.focusRequester
20 | import androidx.compose.ui.graphics.RectangleShape
21 | import androidx.compose.ui.res.stringResource
22 | import androidx.compose.ui.text.input.TextFieldValue
23 | import androidx.compose.ui.unit.dp
24 | import com.rafag.stonks.android.design.R
25 |
26 | //Kudos to https://johncodeos.com/how-to-add-search-in-list-with-jetpack-compose/
27 |
28 | @Composable
29 | fun SearchBar(state: MutableState, onSearchQueryChanged: (String) -> Unit) {
30 | val focusRequester = FocusRequester()
31 | TextField(
32 | value = state.value,
33 | onValueChange = { value ->
34 | state.value = value
35 | onSearchQueryChanged(value.text)
36 | },
37 | modifier = Modifier
38 | .fillMaxWidth()
39 | .focusRequester(focusRequester),
40 | textStyle = MaterialTheme.typography.h3,
41 | leadingIcon = {
42 | Icon(
43 | Icons.Default.Search,
44 | contentDescription = "",
45 | modifier = Modifier
46 | .padding(15.dp)
47 | .size(24.dp)
48 | )
49 | },
50 | trailingIcon = {
51 | if (state.value != TextFieldValue("")) {
52 | IconButton(
53 | onClick = { state.value = TextFieldValue("") }
54 | ) {
55 | Icon(
56 | Icons.Default.Close,
57 | contentDescription = "",
58 | modifier = Modifier
59 | .padding(15.dp)
60 | .size(24.dp)
61 | )
62 | }
63 | }
64 | },
65 | singleLine = true,
66 | shape = RectangleShape,
67 | )
68 |
69 | DisposableEffect(Unit) {
70 | focusRequester.requestFocus()
71 | onDispose { }
72 | }
73 | }
74 |
75 |
--------------------------------------------------------------------------------
/shared/src/commonTest/kotlin/com/rafag/stonks/internal/data/httpclient/MockHttpClient.kt:
--------------------------------------------------------------------------------
1 | package com.rafag.stonks.internal.data.httpclient
2 |
3 | import io.ktor.client.HttpClient
4 | import io.ktor.client.engine.HttpClientEngineBase
5 | import io.ktor.client.engine.HttpClientEngineConfig
6 | import io.ktor.client.engine.callContext
7 | import io.ktor.client.features.HttpTimeout
8 | import io.ktor.client.request.HttpRequestData
9 | import io.ktor.client.request.HttpResponseData
10 | import io.ktor.http.Headers
11 | import io.ktor.http.HttpProtocolVersion
12 | import io.ktor.http.HttpStatusCode
13 | import io.ktor.http.Url
14 | import io.ktor.util.InternalAPI
15 | import io.ktor.util.KtorExperimentalAPI
16 | import io.ktor.util.date.GMTDate
17 | import kotlinx.coroutines.CoroutineDispatcher
18 | import kotlinx.coroutines.Dispatchers
19 | import kotlin.collections.set
20 |
21 | //All Kudos to my awesome co-worker Adam Brown https://github.com/ouchadam
22 |
23 | class MockHttpClient {
24 |
25 | private val handlers = mutableMapOf, Any>()
26 |
27 | private val httpClient = HttpClient(object : InMemoryEngine() {
28 | override fun handler(requestData: HttpRequestData): Any {
29 | return handlers.getValue(handlers.keys.first {
30 | it.url == requestData.url.path()
31 | })
32 | }
33 | })
34 |
35 | internal val instance = StonksHttpClient(
36 | httpClient,
37 | baseUrl = "https://foo.com/",
38 | )
39 |
40 | internal fun addHandler(request: HttpRequest, value: T) {
41 | handlers[request] = value
42 | }
43 |
44 | internal fun addHandler(request: HttpRequest, value: Exception) {
45 | handlers[request] = value
46 | }
47 |
48 | private fun Url.path(): String {
49 | return this.toString().substringAfter(this.host).removePrefix("/").removeToken(API_TOKEN)
50 | }
51 |
52 | private fun String.removeToken(token: CharSequence): String {
53 | if (endsWith(token)) {
54 | return substring(0, length - token.length)
55 | }
56 | return this
57 | }
58 | }
59 |
60 | abstract class InMemoryEngine : HttpClientEngineBase("in-memory") {
61 |
62 | override val config = HttpClientEngineConfig()
63 | override val dispatcher: CoroutineDispatcher = Dispatchers.Unconfined
64 |
65 | @KtorExperimentalAPI
66 | override val supportedCapabilities = setOf(HttpTimeout)
67 |
68 | @InternalAPI
69 | override suspend fun execute(data: HttpRequestData): HttpResponseData {
70 | val response = handler(data)
71 | if (response is Exception) {
72 | throw response
73 | }
74 | return HttpResponseData(HttpStatusCode.OK, GMTDate(0), Headers.Empty, HttpProtocolVersion.HTTP_2_0, response, callContext())
75 | }
76 |
77 | abstract fun handler(requestData: HttpRequestData): Any
78 | }
79 |
--------------------------------------------------------------------------------
/androidApp/search/src/main/java/com/rafag/stonks/android/search/presentation/SearchViewModel.kt:
--------------------------------------------------------------------------------
1 | package com.rafag.stonks.android.search.presentation
2 |
3 | import androidx.lifecycle.ViewModel
4 | import androidx.lifecycle.viewModelScope
5 | import com.rafag.stonks.domain.usecases.SearchStonksUseCase
6 | import com.rafag.stonks.domain.usecases.StonkSearch
7 | import com.rafag.stonks.domain.usecases.ToggleFavouriteUseCase
8 | import kotlinx.coroutines.flow.MutableStateFlow
9 | import kotlinx.coroutines.flow.StateFlow
10 | import kotlinx.coroutines.flow.catch
11 | import kotlinx.coroutines.flow.collect
12 | import kotlinx.coroutines.flow.debounce
13 | import kotlinx.coroutines.flow.distinctUntilChanged
14 | import kotlinx.coroutines.flow.flatMapLatest
15 | import kotlinx.coroutines.launch
16 |
17 | private const val DEBOUNCE_DELAY = 1000L
18 |
19 | class SearchViewModel(
20 | private val searchUseCase: SearchStonksUseCase,
21 | private val toggleFavouriteUseCase: ToggleFavouriteUseCase,
22 | ) : ViewModel() {
23 |
24 | private val _state = MutableStateFlow(
25 | SearchState.Content(
26 | searchStonks = emptyList(),
27 | searchQuery = MutableStateFlow("")
28 | )
29 | )
30 | val state: StateFlow get() = _state
31 |
32 | init {
33 | setSearchQueryListener()
34 | }
35 |
36 | private fun setSearchQueryListener() {
37 | viewModelScope.launch {
38 | _state.value.searchQuery
39 | .debounce(DEBOUNCE_DELAY)
40 | .distinctUntilChanged()
41 | .flatMapLatest { query ->
42 | _state.value = SearchState.Loading(_state.value.searchQuery)
43 | searchUseCase.invoke(query)
44 | }
45 | .catch { _state.value = SearchState.Error(_state.value.searchQuery) }
46 | .collect {
47 | _state.value = SearchState.Content(
48 | searchStonks = it.toSearchStonkUi(),
49 | searchQuery = _state.value.searchQuery
50 | )
51 | }
52 | }
53 | }
54 |
55 | fun onStonkFaved(item: SearchStonkUi) {
56 | viewModelScope.launch {
57 | toggleFavouriteUseCase.saved(item.symbol)
58 | }
59 | }
60 |
61 | fun onStonkUnfaved(item: SearchStonkUi) {
62 | viewModelScope.launch {
63 | toggleFavouriteUseCase.unsaved(item.symbol)
64 | }
65 | }
66 |
67 | private fun List.toSearchStonkUi() = this.map { it.toSearchStonkUiItem() }
68 |
69 | private fun StonkSearch.toSearchStonkUiItem() = SearchStonkUi(
70 | name = name,
71 | symbol = symbol,
72 | faved = faved
73 | )
74 | }
75 |
76 | data class SearchStonkUi(
77 | val name: String,
78 | val symbol: String,
79 | val faved: Boolean,
80 | )
81 |
82 | sealed class SearchState(open val searchQuery: MutableStateFlow) {
83 | data class Loading(override val searchQuery: MutableStateFlow) : SearchState(searchQuery)
84 | data class Error(override val searchQuery: MutableStateFlow) : SearchState(searchQuery)
85 | data class Content(
86 | val searchStonks: List,
87 | override val searchQuery: MutableStateFlow
88 | ) : SearchState(searchQuery)
89 | }
90 |
--------------------------------------------------------------------------------
/shared/build.gradle.kts:
--------------------------------------------------------------------------------
1 | import org.jetbrains.kotlin.gradle.plugin.mpp.KotlinNativeTarget
2 |
3 | plugins {
4 | kotlin("multiplatform")
5 | kotlin("plugin.serialization") version "1.5.0"
6 | id("com.android.library")
7 | id("com.squareup.sqldelight")
8 | }
9 |
10 | sqldelight {
11 | database("StonksDatabase") {
12 | packageName = "com.stonks.db"
13 | }
14 | }
15 |
16 | kotlin {
17 | android()
18 |
19 | val iosTarget: (String, KotlinNativeTarget.() -> Unit) -> KotlinNativeTarget =
20 | if (System.getenv("SDK_NAME")?.startsWith("iphoneos") == true)
21 | ::iosArm64
22 | else
23 | ::iosX64
24 |
25 | iosTarget("ios") {
26 | binaries {
27 | framework {
28 | baseName = "shared"
29 | }
30 | }
31 | }
32 | sourceSets {
33 | val commonMain by getting {
34 | dependencies {
35 | //Network
36 | implementation("io.ktor:ktor-client-core:${properties["version.ktor"]}")
37 | implementation ("io.ktor:ktor-client-serialization:${properties["version.ktor"]}")
38 | implementation("io.ktor:ktor-client-logging:${properties["version.ktor"]}")
39 | //Coroutines
40 | implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:${properties["version.kotlinx.coroutines"]}")
41 | //JSON
42 | implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:${properties["version.kotlinx.serialization"]}")
43 | //SQL Delight
44 | implementation("com.squareup.sqldelight:runtime:1.5.1")
45 | implementation("com.squareup.sqldelight:coroutines-extensions:1.5.0")
46 | }
47 | }
48 | val commonTest by getting {
49 | dependencies {
50 | implementation(kotlin("test-common"))
51 | implementation(kotlin("test-annotations-common"))
52 | }
53 | }
54 | val androidMain by getting {
55 | dependencies {
56 | //Network
57 | implementation("io.ktor:ktor-client-okhttp:${properties["version.ktor"]}")
58 | implementation("com.squareup.sqldelight:android-driver:1.5.1")
59 |
60 | }
61 | }
62 | val androidTest by getting {
63 | dependencies {
64 | implementation(kotlin("test-junit"))
65 | implementation("junit:junit:4.13.2")
66 | implementation("com.nhaarman.mockitokotlin2:mockito-kotlin:2.2.0")
67 | implementation("org.mockito:mockito-inline:3.11.2")
68 | }
69 | }
70 | val iosMain by getting {
71 | dependencies {
72 | //Network
73 | implementation("io.ktor:ktor-client-ios:${properties["version.ktor"]}")
74 | implementation("com.squareup.sqldelight:native-driver:1.5.1")
75 | }
76 | }
77 | val iosTest by getting
78 | }
79 | }
80 |
81 | android {
82 | compileSdkVersion(31)
83 | sourceSets["main"].manifest.srcFile("src/androidMain/AndroidManifest.xml")
84 | defaultConfig {
85 | minSdkVersion(21)
86 | targetSdkVersion(31)
87 | }
88 | }
89 |
90 | val packForXcode by tasks.creating(Sync::class) {
91 | val mode = System.getenv("CONFIGURATION") ?: "DEBUG"
92 | val framework = kotlin.targets.getByName("ios").binaries.getFramework(mode)
93 | val targetDir = File(buildDir, "xcode-frameworks")
94 |
95 | group = "build"
96 | dependsOn(framework.linkTask)
97 | inputs.property("mode", mode)
98 |
99 | from({ framework.outputDirectory })
100 | into(targetDir)
101 | }
102 |
103 | tasks.getByName("build").dependsOn(packForXcode)
104 |
--------------------------------------------------------------------------------
/androidApp/design/src/main/java/com/rafag/stonks/android/design/theming/StonksText.kt:
--------------------------------------------------------------------------------
1 | package com.rafag.stonks.android.design.theming
2 |
3 | import androidx.compose.material.MaterialTheme
4 | import androidx.compose.material.Text
5 | import androidx.compose.runtime.Composable
6 | import androidx.compose.ui.Modifier
7 | import androidx.compose.ui.graphics.Color
8 | import androidx.compose.ui.text.TextStyle
9 | import androidx.compose.ui.text.font.FontWeight
10 | import androidx.compose.ui.text.style.TextAlign
11 |
12 | object StonksText {
13 |
14 | @Composable
15 | fun BodyBig(
16 | text: String,
17 | modifier: Modifier = Modifier,
18 | textAlign: TextAlign? = null,
19 | color: Color = MaterialTheme.colors.onSurface,
20 | maxLines: Int = Int.MAX_VALUE,
21 | ) {
22 | Text(
23 | text = text,
24 | style = MaterialTheme.typography.h3,
25 | modifier = modifier,
26 | textAlign = textAlign,
27 | color = color,
28 | maxLines = maxLines,
29 | )
30 | }
31 |
32 | @Composable
33 | fun BodyBigBold(
34 | text: String,
35 | modifier: Modifier = Modifier,
36 | textAlign: TextAlign? = null,
37 | color: Color = MaterialTheme.colors.onSurface,
38 | maxLines: Int = Int.MAX_VALUE,
39 | ) {
40 | Text(
41 | text = text,
42 | style = MaterialTheme.typography.h3.withFontWeight(FontWeight.ExtraBold),
43 | modifier = modifier,
44 | textAlign = textAlign,
45 | color = color,
46 | maxLines = maxLines,
47 | )
48 | }
49 |
50 | @Composable
51 | fun BodyMedium(
52 | text: String,
53 | modifier: Modifier = Modifier,
54 | textAlign: TextAlign? = null,
55 | color: Color = MaterialTheme.colors.onSurface,
56 | maxLines: Int = Int.MAX_VALUE,
57 | ) {
58 | Text(
59 | text = text,
60 | style = MaterialTheme.typography.body1,
61 | modifier = modifier,
62 | textAlign = textAlign,
63 | color = color,
64 | maxLines = maxLines,
65 | )
66 | }
67 |
68 | @Composable
69 | fun BodyMediumBold(
70 | text: String,
71 | modifier: Modifier = Modifier,
72 | textAlign: TextAlign? = null,
73 | color: Color = MaterialTheme.colors.onSurface,
74 | maxLines: Int = Int.MAX_VALUE,
75 | ) {
76 | Text(
77 | text = text,
78 | style = MaterialTheme.typography.body1.withFontWeight(FontWeight.ExtraBold),
79 | modifier = modifier,
80 | textAlign = textAlign,
81 | color = color,
82 | maxLines = maxLines,
83 | )
84 | }
85 |
86 | @Composable
87 | fun BodySmall(
88 | text: String,
89 | modifier: Modifier = Modifier,
90 | textAlign: TextAlign? = null,
91 | color: Color = MaterialTheme.colors.onSurface,
92 | maxLines: Int = Int.MAX_VALUE,
93 | ) {
94 | Text(
95 | text = text,
96 | style = MaterialTheme.typography.body2,
97 | modifier = modifier,
98 | textAlign = textAlign,
99 | color = color,
100 | maxLines = maxLines,
101 | )
102 | }
103 |
104 | @Composable
105 | fun BigButtonBold(
106 | text: String,
107 | modifier: Modifier = Modifier,
108 | textAlign: TextAlign? = null,
109 | color: Color = MaterialTheme.colors.onSurface,
110 | maxLines: Int = Int.MAX_VALUE,
111 | ) {
112 | Text(
113 | text = text,
114 | style = MaterialTheme.typography.h3.withFontWeight(FontWeight.ExtraBold),
115 | modifier = modifier,
116 | textAlign = textAlign,
117 | color = color,
118 | maxLines = maxLines,
119 | )
120 | }
121 | }
122 |
123 | fun TextStyle.withFontWeight(fontWeight: FontWeight) = this.copy(
124 | fontWeight = fontWeight
125 | )
126 |
127 |
--------------------------------------------------------------------------------
/androidApp/search/src/main/java/com/rafag/stonks/android/search/view/SearchStonksScreen.kt:
--------------------------------------------------------------------------------
1 | package com.rafag.stonks.android.search.view
2 |
3 | import androidx.compose.foundation.clickable
4 | import androidx.compose.foundation.layout.Box
5 | import androidx.compose.foundation.layout.Column
6 | import androidx.compose.foundation.layout.Row
7 | import androidx.compose.foundation.layout.fillMaxSize
8 | import androidx.compose.foundation.layout.fillMaxWidth
9 | import androidx.compose.foundation.layout.padding
10 | import androidx.compose.foundation.lazy.LazyColumn
11 | import androidx.compose.foundation.lazy.items
12 | import androidx.compose.material.Text
13 | import androidx.compose.runtime.Composable
14 | import androidx.compose.runtime.collectAsState
15 | import androidx.compose.runtime.getValue
16 | import androidx.compose.runtime.mutableStateOf
17 | import androidx.compose.runtime.remember
18 | import androidx.compose.ui.Alignment
19 | import androidx.compose.ui.Modifier
20 | import androidx.compose.ui.res.stringResource
21 | import androidx.compose.ui.text.input.TextFieldValue
22 | import androidx.compose.ui.unit.dp
23 | import com.rafag.stonks.android.design.theming.StonksText
24 | import com.rafag.stonks.android.design.theming.StonksText.BodyMedium
25 | import com.rafag.stonks.android.design.views.Error
26 | import com.rafag.stonks.android.design.views.Faved
27 | import com.rafag.stonks.android.design.views.Loading
28 | import com.rafag.stonks.android.design.views.NotFaved
29 | import com.rafag.stonks.android.design.views.SearchBar
30 | import com.rafag.stonks.android.search.R
31 | import com.rafag.stonks.android.search.presentation.SearchState.*
32 | import com.rafag.stonks.android.search.presentation.SearchStonkUi
33 | import com.rafag.stonks.android.search.presentation.SearchViewModel
34 |
35 | @Composable
36 | fun SearchStonksScreen(viewModel: SearchViewModel) {
37 | val textState = remember { mutableStateOf(TextFieldValue("")) }
38 | val state by viewModel.state.collectAsState()
39 |
40 | Column {
41 | SearchBar(textState) { query ->
42 | viewModel.state.value.searchQuery.value = query
43 | }
44 | when (state) {
45 | is Content -> Content(state as Content, viewModel::onStonkFaved, viewModel::onStonkUnfaved)
46 | is Loading -> Loading()
47 | is Error -> Error {
48 | viewModel.state.value.searchQuery.value = ""
49 | }
50 | }
51 | }
52 | }
53 |
54 | @Composable
55 | private fun Content(
56 | state: Content,
57 | onToggleFaved: (SearchStonkUi) -> Unit,
58 | onToggleUnfaved: (SearchStonkUi) -> Unit
59 | ) {
60 | if (state.searchStonks.isEmpty()) {
61 | EmptyState(state.searchQuery.value.isBlank())
62 | } else {
63 | LazyColumn(modifier = Modifier.fillMaxWidth()) {
64 | items(state.searchStonks) { stonk ->
65 | ListItem(
66 | item = stonk,
67 | onToggleFaved = onToggleFaved,
68 | onToggleUnfaved = onToggleUnfaved,
69 | )
70 | }
71 | }
72 | }
73 | }
74 |
75 | @Composable
76 | private fun ListItem(
77 | item: SearchStonkUi,
78 | onToggleFaved: (SearchStonkUi) -> Unit,
79 | onToggleUnfaved: (SearchStonkUi) -> Unit
80 | ) {
81 | Row(
82 | modifier = Modifier
83 | .clickable(onClick = {
84 | if (item.faved) onToggleUnfaved(item) else onToggleFaved(item)
85 | })
86 | .fillMaxWidth(),
87 | verticalAlignment = Alignment.CenterVertically
88 | ) {
89 | Column(
90 | modifier = Modifier
91 | .weight(0.8f)
92 | .padding(16.dp)
93 | ) {
94 | StonksText.BodyMediumBold(text = item.name)
95 | BodyMedium(text = item.symbol)
96 | }
97 | if (item.faved) Faved() else NotFaved()
98 | }
99 | }
100 |
101 | @Composable
102 | private fun EmptyState(blank: Boolean) {
103 | val text = if (blank) {
104 | stringResource(id = R.string.search_empty_state)
105 | } else stringResource(id = R.string.search_no_matches)
106 |
107 | Box(Modifier.fillMaxSize()) {
108 | StonksText.BodyBig(
109 | text = text,
110 | modifier = Modifier.align(Alignment.Center)
111 | )
112 | }
113 | }
114 |
--------------------------------------------------------------------------------
/androidApp/faved/src/main/java/com/rafag/stonks/android/faved/view/FavedQuotesScreen.kt:
--------------------------------------------------------------------------------
1 | package com.rafag.stonks.android.faved.view
2 |
3 | import androidx.compose.foundation.clickable
4 | import androidx.compose.foundation.interaction.MutableInteractionSource
5 | import androidx.compose.foundation.layout.Box
6 | import androidx.compose.foundation.layout.BoxScope
7 | import androidx.compose.foundation.layout.Row
8 | import androidx.compose.foundation.layout.fillMaxSize
9 | import androidx.compose.foundation.layout.fillMaxWidth
10 | import androidx.compose.foundation.layout.padding
11 | import androidx.compose.foundation.lazy.LazyColumn
12 | import androidx.compose.foundation.lazy.items
13 | import androidx.compose.material.FloatingActionButton
14 | import androidx.compose.material.FloatingActionButtonDefaults
15 | import androidx.compose.material.Icon
16 | import androidx.compose.material.MaterialTheme
17 | import androidx.compose.material.Scaffold
18 | import androidx.compose.material.Text
19 | import androidx.compose.material.TopAppBar
20 | import androidx.compose.material.icons.Icons
21 | import androidx.compose.material.icons.filled.Search
22 | import androidx.compose.material.ripple.rememberRipple
23 | import androidx.compose.runtime.Composable
24 | import androidx.compose.runtime.LaunchedEffect
25 | import androidx.compose.runtime.collectAsState
26 | import androidx.compose.runtime.getValue
27 | import androidx.compose.runtime.remember
28 | import androidx.compose.ui.Alignment
29 | import androidx.compose.ui.Alignment.Companion.Center
30 | import androidx.compose.ui.Modifier
31 | import androidx.compose.ui.res.stringResource
32 | import androidx.compose.ui.unit.dp
33 | import com.rafag.stonks.android.design.theming.StonksText
34 | import com.rafag.stonks.android.design.theming.StonksText.BodyBigBold
35 | import com.rafag.stonks.android.design.views.Delete
36 | import com.rafag.stonks.android.design.views.Error
37 | import com.rafag.stonks.android.design.views.Loading
38 | import com.rafag.stonks.android.design.views.StonkQuote
39 | import com.rafag.stonks.android.faved.R
40 | import com.rafag.stonks.android.faved.presentation.FavedQuoteUi
41 | import com.rafag.stonks.android.faved.presentation.FavedState
42 | import com.rafag.stonks.android.faved.presentation.FavedState.*
43 | import com.rafag.stonks.android.faved.presentation.FavedViewModel
44 |
45 | @Composable
46 | fun FavedQuotesScreen(
47 | viewModel: FavedViewModel,
48 | onNavigateToSearch: () -> Unit
49 | ) {
50 | val state by viewModel.state.collectAsState()
51 |
52 | LaunchedEffect("load") {
53 | viewModel.load()
54 | }
55 |
56 | Scaffold(
57 | topBar = {
58 | TopAppBar(
59 | title = { Text(text = stringResource(id = R.string.my_stonks)) },
60 | backgroundColor = MaterialTheme.colors.primary,
61 | elevation = 12.dp
62 | )
63 | }, content = {
64 | Box(modifier = Modifier.fillMaxSize()) {
65 | when (state) {
66 | is Content -> Content(state as Content, viewModel::onDeleteStonkClicked)
67 | Error -> Error { viewModel.load() }
68 | Loading -> Loading()
69 | }
70 | SearchButton {
71 | onNavigateToSearch()
72 | }
73 | }
74 | })
75 | }
76 |
77 | @Composable
78 | private fun BoxScope.SearchButton(onClick: () -> Unit) {
79 | FloatingActionButton(
80 | modifier = Modifier
81 | .align(Alignment.BottomEnd)
82 | .padding(16.dp),
83 | onClick = { onClick() },
84 | elevation = FloatingActionButtonDefaults.elevation(8.dp)
85 | ) {
86 | Icon(Icons.Filled.Search, "")
87 | }
88 | }
89 |
90 | @Composable
91 | private fun Content(state: FavedState.Content, onDeleteClicked: (FavedQuoteUi) -> Unit) {
92 | if (state.quotes.isEmpty()) {
93 | EmptyState()
94 | } else {
95 | LazyColumn {
96 | items(state.quotes) { item ->
97 | StonkItem(item, onDeleteClicked)
98 | }
99 | }
100 | }
101 | }
102 |
103 | @Composable
104 | private fun EmptyState() {
105 | Box(Modifier.fillMaxSize()) {
106 | StonksText.BodyBig(
107 | text = stringResource(id = R.string.no_stonks_yet),
108 | modifier = Modifier.align(Alignment.Center)
109 | )
110 | }
111 | }
112 |
113 | @Composable
114 | private fun StonkItem(
115 | item: FavedQuoteUi,
116 | onDeleteStonkClicked: (FavedQuoteUi) -> Unit,
117 | ) {
118 | Row(
119 | modifier = Modifier
120 | .fillMaxWidth()
121 | .padding(16.dp),
122 | verticalAlignment = Alignment.CenterVertically
123 | ) {
124 | BodyBigBold(
125 | modifier = Modifier.weight(0.5f),
126 | text = item.symbol,
127 | )
128 | Box(
129 | modifier = Modifier
130 | .weight(0.35f)
131 | .padding(end = 8.dp)
132 | ) {
133 | StonkQuote(
134 | isUp = item.isUp,
135 | price = item.current,
136 | change = item.change
137 | )
138 | }
139 | Box(
140 | modifier = Modifier.weight(0.1f)
141 | ) {
142 | Delete(
143 | modifier = Modifier
144 | .align(alignment = Center)
145 | .clickable(
146 | interactionSource = remember { MutableInteractionSource() },
147 | indication = rememberRipple(bounded = false),
148 | ) {
149 | onDeleteStonkClicked(item)
150 | }
151 | )
152 | }
153 | }
154 | }
155 |
--------------------------------------------------------------------------------
/iosApp/iosApp.xcodeproj/project.pbxproj:
--------------------------------------------------------------------------------
1 | // !$*UTF8*$!
2 | {
3 | archiveVersion = 1;
4 | classes = {
5 | };
6 | objectVersion = 50;
7 | objects = {
8 |
9 | /* Begin PBXBuildFile section */
10 | 058F8A3B26403325008F3A59 /* StockLookupView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 058F8A3726403325008F3A59 /* StockLookupView.swift */; };
11 | 058F8A3C26403325008F3A59 /* StockView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 058F8A3926403325008F3A59 /* StockView.swift */; };
12 | 058F8A4C264035C2008F3A59 /* Money.swift in Sources */ = {isa = PBXBuildFile; fileRef = 058F8A4626403546008F3A59 /* Money.swift */; };
13 | 058F8A4D264035C2008F3A59 /* Data.swift in Sources */ = {isa = PBXBuildFile; fileRef = 058F8A4726403548008F3A59 /* Data.swift */; };
14 | 058F8A4E264035C2008F3A59 /* Stock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 058F8A4926403557008F3A59 /* Stock.swift */; };
15 | 058F8A5726403BAB008F3A59 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 058F8A5626403BA3008F3A59 /* AppDelegate.swift */; };
16 | 058F8A5B26403BBC008F3A59 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 058F8A5A26403BBC008F3A59 /* SceneDelegate.swift */; };
17 | 7555FF83242A565900829871 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7555FF82242A565900829871 /* ContentView.swift */; };
18 | 7555FFB2242A642300829871 /* shared.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 7555FFB1242A642300829871 /* shared.framework */; };
19 | 7555FFB3242A642300829871 /* shared.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 7555FFB1242A642300829871 /* shared.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; };
20 | /* End PBXBuildFile section */
21 |
22 | /* Begin PBXCopyFilesBuildPhase section */
23 | 7555FFB4242A642300829871 /* Embed Frameworks */ = {
24 | isa = PBXCopyFilesBuildPhase;
25 | buildActionMask = 2147483647;
26 | dstPath = "";
27 | dstSubfolderSpec = 10;
28 | files = (
29 | 7555FFB3242A642300829871 /* shared.framework in Embed Frameworks */,
30 | );
31 | name = "Embed Frameworks";
32 | runOnlyForDeploymentPostprocessing = 0;
33 | };
34 | /* End PBXCopyFilesBuildPhase section */
35 |
36 | /* Begin PBXFileReference section */
37 | 058F8A3726403325008F3A59 /* StockLookupView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StockLookupView.swift; sourceTree = ""; };
38 | 058F8A3926403325008F3A59 /* StockView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StockView.swift; sourceTree = ""; };
39 | 058F8A4626403546008F3A59 /* Money.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Money.swift; sourceTree = ""; };
40 | 058F8A4726403548008F3A59 /* Data.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Data.swift; sourceTree = ""; };
41 | 058F8A4926403557008F3A59 /* Stock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Stock.swift; sourceTree = ""; };
42 | 058F8A5626403BA3008F3A59 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; };
43 | 058F8A5A26403BBC008F3A59 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = ""; };
44 | 7555FF7B242A565900829871 /* iosApp.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = iosApp.app; sourceTree = BUILT_PRODUCTS_DIR; };
45 | 7555FF82242A565900829871 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; };
46 | 7555FF8C242A565B00829871 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; };
47 | 7555FFB1242A642300829871 /* shared.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = shared.framework; path = "../shared/build/xcode-frameworks/shared.framework"; sourceTree = ""; };
48 | /* End PBXFileReference section */
49 |
50 | /* Begin PBXFrameworksBuildPhase section */
51 | 7555FF78242A565900829871 /* Frameworks */ = {
52 | isa = PBXFrameworksBuildPhase;
53 | buildActionMask = 2147483647;
54 | files = (
55 | 7555FFB2242A642300829871 /* shared.framework in Frameworks */,
56 | );
57 | runOnlyForDeploymentPostprocessing = 0;
58 | };
59 | /* End PBXFrameworksBuildPhase section */
60 |
61 | /* Begin PBXGroup section */
62 | 058F8A4526403541008F3A59 /* Data */ = {
63 | isa = PBXGroup;
64 | children = (
65 | 058F8A4926403557008F3A59 /* Stock.swift */,
66 | 058F8A4726403548008F3A59 /* Data.swift */,
67 | 058F8A4626403546008F3A59 /* Money.swift */,
68 | );
69 | path = Data;
70 | sourceTree = "";
71 | };
72 | 058F8A4A26403568008F3A59 /* Views */ = {
73 | isa = PBXGroup;
74 | children = (
75 | 7555FF82242A565900829871 /* ContentView.swift */,
76 | 058F8A3726403325008F3A59 /* StockLookupView.swift */,
77 | 058F8A3926403325008F3A59 /* StockView.swift */,
78 | );
79 | path = Views;
80 | sourceTree = "";
81 | };
82 | 058F8A4B26403577008F3A59 /* App */ = {
83 | isa = PBXGroup;
84 | children = (
85 | 058F8A5A26403BBC008F3A59 /* SceneDelegate.swift */,
86 | 058F8A5626403BA3008F3A59 /* AppDelegate.swift */,
87 | );
88 | path = App;
89 | sourceTree = "";
90 | };
91 | 7555FF72242A565900829871 = {
92 | isa = PBXGroup;
93 | children = (
94 | 7555FF7D242A565900829871 /* iosApp */,
95 | 7555FF7C242A565900829871 /* Products */,
96 | 7555FFB0242A642200829871 /* Frameworks */,
97 | );
98 | sourceTree = "";
99 | };
100 | 7555FF7C242A565900829871 /* Products */ = {
101 | isa = PBXGroup;
102 | children = (
103 | 7555FF7B242A565900829871 /* iosApp.app */,
104 | );
105 | name = Products;
106 | sourceTree = "";
107 | };
108 | 7555FF7D242A565900829871 /* iosApp */ = {
109 | isa = PBXGroup;
110 | children = (
111 | 7555FF8C242A565B00829871 /* Info.plist */,
112 | 058F8A4B26403577008F3A59 /* App */,
113 | 058F8A4A26403568008F3A59 /* Views */,
114 | 058F8A4526403541008F3A59 /* Data */,
115 | );
116 | path = iosApp;
117 | sourceTree = "";
118 | };
119 | 7555FFB0242A642200829871 /* Frameworks */ = {
120 | isa = PBXGroup;
121 | children = (
122 | 7555FFB1242A642300829871 /* shared.framework */,
123 | );
124 | name = Frameworks;
125 | sourceTree = "";
126 | };
127 | /* End PBXGroup section */
128 |
129 | /* Begin PBXNativeTarget section */
130 | 7555FF7A242A565900829871 /* iosApp */ = {
131 | isa = PBXNativeTarget;
132 | buildConfigurationList = 7555FFA5242A565B00829871 /* Build configuration list for PBXNativeTarget "iosApp" */;
133 | buildPhases = (
134 | 7555FFB5242A651A00829871 /* ShellScript */,
135 | 7555FF77242A565900829871 /* Sources */,
136 | 7555FF78242A565900829871 /* Frameworks */,
137 | 7555FF79242A565900829871 /* Resources */,
138 | 7555FFB4242A642300829871 /* Embed Frameworks */,
139 | );
140 | buildRules = (
141 | );
142 | dependencies = (
143 | );
144 | name = iosApp;
145 | productName = iosApp;
146 | productReference = 7555FF7B242A565900829871 /* iosApp.app */;
147 | productType = "com.apple.product-type.application";
148 | };
149 | /* End PBXNativeTarget section */
150 |
151 | /* Begin PBXProject section */
152 | 7555FF73242A565900829871 /* Project object */ = {
153 | isa = PBXProject;
154 | attributes = {
155 | LastSwiftUpdateCheck = 1130;
156 | LastUpgradeCheck = 1130;
157 | ORGANIZATIONNAME = orgName;
158 | TargetAttributes = {
159 | 7555FF7A242A565900829871 = {
160 | CreatedOnToolsVersion = 11.3.1;
161 | };
162 | };
163 | };
164 | buildConfigurationList = 7555FF76242A565900829871 /* Build configuration list for PBXProject "iosApp" */;
165 | compatibilityVersion = "Xcode 9.3";
166 | developmentRegion = en;
167 | hasScannedForEncodings = 0;
168 | knownRegions = (
169 | en,
170 | Base,
171 | );
172 | mainGroup = 7555FF72242A565900829871;
173 | productRefGroup = 7555FF7C242A565900829871 /* Products */;
174 | projectDirPath = "";
175 | projectRoot = "";
176 | targets = (
177 | 7555FF7A242A565900829871 /* iosApp */,
178 | );
179 | };
180 | /* End PBXProject section */
181 |
182 | /* Begin PBXResourcesBuildPhase section */
183 | 7555FF79242A565900829871 /* Resources */ = {
184 | isa = PBXResourcesBuildPhase;
185 | buildActionMask = 2147483647;
186 | files = (
187 | );
188 | runOnlyForDeploymentPostprocessing = 0;
189 | };
190 | /* End PBXResourcesBuildPhase section */
191 |
192 | /* Begin PBXShellScriptBuildPhase section */
193 | 7555FFB5242A651A00829871 /* ShellScript */ = {
194 | isa = PBXShellScriptBuildPhase;
195 | buildActionMask = 2147483647;
196 | files = (
197 | );
198 | inputFileListPaths = (
199 | );
200 | inputPaths = (
201 | );
202 | outputFileListPaths = (
203 | );
204 | outputPaths = (
205 | );
206 | runOnlyForDeploymentPostprocessing = 0;
207 | shellPath = /bin/sh;
208 | shellScript = "cd \"$SRCROOT/..\"\n./gradlew :shared:packForXCode -PXCODE_CONFIGURATION=${CONFIGURATION}\n";
209 | };
210 | /* End PBXShellScriptBuildPhase section */
211 |
212 | /* Begin PBXSourcesBuildPhase section */
213 | 7555FF77242A565900829871 /* Sources */ = {
214 | isa = PBXSourcesBuildPhase;
215 | buildActionMask = 2147483647;
216 | files = (
217 | 058F8A3C26403325008F3A59 /* StockView.swift in Sources */,
218 | 058F8A4D264035C2008F3A59 /* Data.swift in Sources */,
219 | 058F8A4C264035C2008F3A59 /* Money.swift in Sources */,
220 | 058F8A5726403BAB008F3A59 /* AppDelegate.swift in Sources */,
221 | 058F8A5B26403BBC008F3A59 /* SceneDelegate.swift in Sources */,
222 | 058F8A4E264035C2008F3A59 /* Stock.swift in Sources */,
223 | 058F8A3B26403325008F3A59 /* StockLookupView.swift in Sources */,
224 | 7555FF83242A565900829871 /* ContentView.swift in Sources */,
225 | );
226 | runOnlyForDeploymentPostprocessing = 0;
227 | };
228 | /* End PBXSourcesBuildPhase section */
229 |
230 | /* Begin XCBuildConfiguration section */
231 | 7555FFA3242A565B00829871 /* Debug */ = {
232 | isa = XCBuildConfiguration;
233 | buildSettings = {
234 | ALWAYS_SEARCH_USER_PATHS = NO;
235 | CLANG_ANALYZER_NONNULL = YES;
236 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
237 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
238 | CLANG_CXX_LIBRARY = "libc++";
239 | CLANG_ENABLE_MODULES = YES;
240 | CLANG_ENABLE_OBJC_ARC = YES;
241 | CLANG_ENABLE_OBJC_WEAK = YES;
242 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
243 | CLANG_WARN_BOOL_CONVERSION = YES;
244 | CLANG_WARN_COMMA = YES;
245 | CLANG_WARN_CONSTANT_CONVERSION = YES;
246 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
247 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
248 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
249 | CLANG_WARN_EMPTY_BODY = YES;
250 | CLANG_WARN_ENUM_CONVERSION = YES;
251 | CLANG_WARN_INFINITE_RECURSION = YES;
252 | CLANG_WARN_INT_CONVERSION = YES;
253 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
254 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
255 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
256 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
257 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
258 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
259 | CLANG_WARN_STRICT_PROTOTYPES = YES;
260 | CLANG_WARN_SUSPICIOUS_MOVE = YES;
261 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
262 | CLANG_WARN_UNREACHABLE_CODE = YES;
263 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
264 | COPY_PHASE_STRIP = NO;
265 | DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
266 | ENABLE_STRICT_OBJC_MSGSEND = YES;
267 | ENABLE_TESTABILITY = YES;
268 | GCC_C_LANGUAGE_STANDARD = gnu11;
269 | GCC_DYNAMIC_NO_PIC = NO;
270 | GCC_NO_COMMON_BLOCKS = YES;
271 | GCC_OPTIMIZATION_LEVEL = 0;
272 | GCC_PREPROCESSOR_DEFINITIONS = (
273 | "DEBUG=1",
274 | "$(inherited)",
275 | );
276 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
277 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
278 | GCC_WARN_UNDECLARED_SELECTOR = YES;
279 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
280 | GCC_WARN_UNUSED_FUNCTION = YES;
281 | GCC_WARN_UNUSED_VARIABLE = YES;
282 | IPHONEOS_DEPLOYMENT_TARGET = 14.1;
283 | MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
284 | MTL_FAST_MATH = YES;
285 | ONLY_ACTIVE_ARCH = YES;
286 | SDKROOT = iphoneos;
287 | SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
288 | SWIFT_OPTIMIZATION_LEVEL = "-Onone";
289 | };
290 | name = Debug;
291 | };
292 | 7555FFA4242A565B00829871 /* Release */ = {
293 | isa = XCBuildConfiguration;
294 | buildSettings = {
295 | ALWAYS_SEARCH_USER_PATHS = NO;
296 | CLANG_ANALYZER_NONNULL = YES;
297 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
298 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
299 | CLANG_CXX_LIBRARY = "libc++";
300 | CLANG_ENABLE_MODULES = YES;
301 | CLANG_ENABLE_OBJC_ARC = YES;
302 | CLANG_ENABLE_OBJC_WEAK = YES;
303 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
304 | CLANG_WARN_BOOL_CONVERSION = YES;
305 | CLANG_WARN_COMMA = YES;
306 | CLANG_WARN_CONSTANT_CONVERSION = YES;
307 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
308 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
309 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
310 | CLANG_WARN_EMPTY_BODY = YES;
311 | CLANG_WARN_ENUM_CONVERSION = YES;
312 | CLANG_WARN_INFINITE_RECURSION = YES;
313 | CLANG_WARN_INT_CONVERSION = YES;
314 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
315 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
316 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
317 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
318 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
319 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
320 | CLANG_WARN_STRICT_PROTOTYPES = YES;
321 | CLANG_WARN_SUSPICIOUS_MOVE = YES;
322 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
323 | CLANG_WARN_UNREACHABLE_CODE = YES;
324 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
325 | COPY_PHASE_STRIP = NO;
326 | DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
327 | ENABLE_NS_ASSERTIONS = NO;
328 | ENABLE_STRICT_OBJC_MSGSEND = YES;
329 | GCC_C_LANGUAGE_STANDARD = gnu11;
330 | GCC_NO_COMMON_BLOCKS = YES;
331 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
332 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
333 | GCC_WARN_UNDECLARED_SELECTOR = YES;
334 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
335 | GCC_WARN_UNUSED_FUNCTION = YES;
336 | GCC_WARN_UNUSED_VARIABLE = YES;
337 | IPHONEOS_DEPLOYMENT_TARGET = 14.1;
338 | MTL_ENABLE_DEBUG_INFO = NO;
339 | MTL_FAST_MATH = YES;
340 | SDKROOT = iphoneos;
341 | SWIFT_COMPILATION_MODE = wholemodule;
342 | SWIFT_OPTIMIZATION_LEVEL = "-O";
343 | VALIDATE_PRODUCT = YES;
344 | };
345 | name = Release;
346 | };
347 | 7555FFA6242A565B00829871 /* Debug */ = {
348 | isa = XCBuildConfiguration;
349 | buildSettings = {
350 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
351 | CODE_SIGN_STYLE = Automatic;
352 | ENABLE_PREVIEWS = YES;
353 | FRAMEWORK_SEARCH_PATHS = "$(SRCROOT)/../shared/build/xcode-frameworks";
354 | INFOPLIST_FILE = iosApp/Info.plist;
355 | LD_RUNPATH_SEARCH_PATHS = (
356 | "$(inherited)",
357 | "@executable_path/Frameworks",
358 | );
359 | PRODUCT_BUNDLE_IDENTIFIER = orgIdentifier.iosApp;
360 | PRODUCT_NAME = "$(TARGET_NAME)";
361 | SWIFT_VERSION = 5.0;
362 | TARGETED_DEVICE_FAMILY = "1,2";
363 | };
364 | name = Debug;
365 | };
366 | 7555FFA7242A565B00829871 /* Release */ = {
367 | isa = XCBuildConfiguration;
368 | buildSettings = {
369 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
370 | CODE_SIGN_STYLE = Automatic;
371 | ENABLE_PREVIEWS = YES;
372 | FRAMEWORK_SEARCH_PATHS = "$(SRCROOT)/../shared/build/xcode-frameworks";
373 | INFOPLIST_FILE = iosApp/Info.plist;
374 | LD_RUNPATH_SEARCH_PATHS = (
375 | "$(inherited)",
376 | "@executable_path/Frameworks",
377 | );
378 | PRODUCT_BUNDLE_IDENTIFIER = orgIdentifier.iosApp;
379 | PRODUCT_NAME = "$(TARGET_NAME)";
380 | SWIFT_VERSION = 5.0;
381 | TARGETED_DEVICE_FAMILY = "1,2";
382 | };
383 | name = Release;
384 | };
385 | /* End XCBuildConfiguration section */
386 |
387 | /* Begin XCConfigurationList section */
388 | 7555FF76242A565900829871 /* Build configuration list for PBXProject "iosApp" */ = {
389 | isa = XCConfigurationList;
390 | buildConfigurations = (
391 | 7555FFA3242A565B00829871 /* Debug */,
392 | 7555FFA4242A565B00829871 /* Release */,
393 | );
394 | defaultConfigurationIsVisible = 0;
395 | defaultConfigurationName = Release;
396 | };
397 | 7555FFA5242A565B00829871 /* Build configuration list for PBXNativeTarget "iosApp" */ = {
398 | isa = XCConfigurationList;
399 | buildConfigurations = (
400 | 7555FFA6242A565B00829871 /* Debug */,
401 | 7555FFA7242A565B00829871 /* Release */,
402 | );
403 | defaultConfigurationIsVisible = 0;
404 | defaultConfigurationName = Release;
405 | };
406 | /* End XCConfigurationList section */
407 | };
408 | rootObject = 7555FF73242A565900829871 /* Project object */;
409 | }
410 |
--------------------------------------------------------------------------------