├── .idea
├── .name
├── .gitignore
├── codeStyles
│ └── codeStyleConfig.xml
└── inspectionProfiles
│ └── Project_Default.xml
├── shared
├── data
│ ├── .gitignore
│ ├── consumer-rules.pro
│ ├── proguard-rules.pro
│ ├── database
│ │ ├── src
│ │ │ ├── commonMain
│ │ │ │ ├── kotlin
│ │ │ │ │ └── dev
│ │ │ │ │ │ └── ohoussein
│ │ │ │ │ │ └── cryptoapp
│ │ │ │ │ │ └── data
│ │ │ │ │ │ └── database
│ │ │ │ │ │ ├── DatabaseDriverFactory.kt
│ │ │ │ │ │ ├── DatabaseFactory.kt
│ │ │ │ │ │ ├── DatabaseModule.kt
│ │ │ │ │ │ └── crypto
│ │ │ │ │ │ ├── CryptoDAO.kt
│ │ │ │ │ │ ├── CryptoDAOImpl.kt
│ │ │ │ │ │ └── DBModelMapper.kt
│ │ │ │ └── sqldelight
│ │ │ │ │ └── dev
│ │ │ │ │ └── ohoussein
│ │ │ │ │ └── cryptoapp
│ │ │ │ │ └── db
│ │ │ │ │ └── Crypto.sq
│ │ │ ├── androidUnitTest
│ │ │ │ └── kotlin
│ │ │ │ │ └── dev
│ │ │ │ │ └── ohoussein
│ │ │ │ │ └── cryptoapp
│ │ │ │ │ └── data
│ │ │ │ │ └── database
│ │ │ │ │ ├── DatabaseDriverFactory.kt
│ │ │ │ │ └── crypto
│ │ │ │ │ ├── mock
│ │ │ │ │ └── TestDataFactory.kt
│ │ │ │ │ └── CryptoDAOImplTest.kt
│ │ │ ├── iosMain
│ │ │ │ └── kotlin
│ │ │ │ │ └── dev
│ │ │ │ │ └── ohoussein
│ │ │ │ │ └── cryptoapp
│ │ │ │ │ └── data
│ │ │ │ │ └── database
│ │ │ │ │ ├── DatabaseDriverFactory.kt
│ │ │ │ │ └── PlatformDatabaseModule.kt
│ │ │ ├── androidMain
│ │ │ │ └── kotlin
│ │ │ │ │ └── dev
│ │ │ │ │ └── ohoussein
│ │ │ │ │ └── cryptoapp
│ │ │ │ │ └── data
│ │ │ │ │ └── database
│ │ │ │ │ ├── DatabaseDriverFactory.kt
│ │ │ │ │ └── PlatformDatabaseModule.kt
│ │ │ ├── desktopMain
│ │ │ │ └── kotlin
│ │ │ │ │ └── dev
│ │ │ │ │ └── ohoussein
│ │ │ │ │ └── cryptoapp
│ │ │ │ │ └── data
│ │ │ │ │ └── database
│ │ │ │ │ ├── PlatformDatabaseModule.kt
│ │ │ │ │ └── DatabaseDriverFactory.kt
│ │ │ └── main
│ │ │ │ └── sqldelight
│ │ │ │ └── dev
│ │ │ │ └── ohoussein
│ │ │ │ └── cryptoapp
│ │ │ │ └── db
│ │ │ │ └── Crypto.sq
│ │ └── build.gradle.kts
│ ├── cache
│ │ ├── build.gradle.kts
│ │ └── src
│ │ │ ├── commonMain
│ │ │ └── kotlin
│ │ │ │ └── dev
│ │ │ │ └── ohoussein
│ │ │ │ └── cryptoapp
│ │ │ │ └── data
│ │ │ │ └── cache
│ │ │ │ ├── CacheDataSource.kt
│ │ │ │ ├── CachedDataRepository.kt
│ │ │ │ └── InMemoryCacheDataSource.kt
│ │ │ └── commonTest
│ │ │ └── kotlin
│ │ │ └── dev
│ │ │ └── ohoussein
│ │ │ └── cryptoapp
│ │ │ └── data
│ │ │ └── cache
│ │ │ ├── utils
│ │ │ └── FakeTimeSource.kt
│ │ │ ├── CachedDataRepositoryImplTest.kt
│ │ │ └── InMemoryCacheDataSourceTest.kt
│ └── network
│ │ ├── src
│ │ ├── commonMain
│ │ │ └── kotlin
│ │ │ │ └── dev
│ │ │ │ └── ohoussein
│ │ │ │ └── cryptoapp
│ │ │ │ └── data
│ │ │ │ └── network
│ │ │ │ ├── NetworkModule.kt
│ │ │ │ ├── crypto
│ │ │ │ ├── service
│ │ │ │ │ ├── ApiCryptoService.kt
│ │ │ │ │ └── ApiCryptoServiceImpl.kt
│ │ │ │ └── model
│ │ │ │ │ └── CryptoApiResponse.kt
│ │ │ │ └── NetworkBuilder.kt
│ │ └── commonTest
│ │ │ └── kotlin
│ │ │ └── dev
│ │ │ └── ohoussein
│ │ │ └── cryptoapp
│ │ │ └── data
│ │ │ └── network
│ │ │ └── crypto
│ │ │ └── service
│ │ │ ├── utils
│ │ │ └── HttpMock.kt
│ │ │ ├── mocks
│ │ │ └── MockHistoricalPricesJson.kt
│ │ │ └── ApiCryptoServiceImplTest.kt
│ │ └── build.gradle.kts
├── core
│ ├── router
│ │ ├── src
│ │ │ ├── commonMain
│ │ │ │ └── kotlin
│ │ │ │ │ └── dev
│ │ │ │ │ └── ohoussein
│ │ │ │ │ └── cryptoapp
│ │ │ │ │ └── core
│ │ │ │ │ └── router
│ │ │ │ │ ├── Router.kt
│ │ │ │ │ └── RouterModule.kt
│ │ │ ├── androidMain
│ │ │ │ └── kotlin
│ │ │ │ │ └── dev
│ │ │ │ │ └── ohoussein
│ │ │ │ │ └── cryptoapp
│ │ │ │ │ └── core
│ │ │ │ │ └── router
│ │ │ │ │ ├── NativeRouterModule.android.kt
│ │ │ │ │ └── RouterImpl.android.kt
│ │ │ ├── iosMain
│ │ │ │ └── kotlin
│ │ │ │ │ └── dev
│ │ │ │ │ └── ohoussein
│ │ │ │ │ └── cryptoapp
│ │ │ │ │ └── core
│ │ │ │ │ └── router
│ │ │ │ │ ├── NativeRouterModule.ios.kt
│ │ │ │ │ └── RouterImpl.ios.kt
│ │ │ └── desktopMain
│ │ │ │ └── kotlin
│ │ │ │ └── dev
│ │ │ │ └── ohoussein
│ │ │ │ └── cryptoapp
│ │ │ │ └── core
│ │ │ │ └── router
│ │ │ │ ├── NativeRouterModule.desktop.kt
│ │ │ │ └── RouterImpl.desktop.kt
│ │ └── build.gradle.kts
│ └── formatter
│ │ ├── src
│ │ ├── commonMain
│ │ │ └── kotlin
│ │ │ │ └── dev
│ │ │ │ └── ohoussein
│ │ │ │ └── cryptoapp
│ │ │ │ └── core
│ │ │ │ └── formatter
│ │ │ │ ├── PercentFormatter.kt
│ │ │ │ ├── PriceFormatter.kt
│ │ │ │ └── FormatModule.kt
│ │ ├── commonTest
│ │ │ └── kotlin
│ │ │ │ └── dev
│ │ │ │ └── ohoussein
│ │ │ │ └── cryptoapp
│ │ │ │ └── core
│ │ │ │ └── formatter
│ │ │ │ ├── CommonPriceFormatterTest.kt
│ │ │ │ └── CommonPercentFormatterTest.kt
│ │ ├── androidMain
│ │ │ └── kotlin
│ │ │ │ └── dev
│ │ │ │ └── ohoussein
│ │ │ │ └── cryptoapp
│ │ │ │ └── core
│ │ │ │ └── formatter
│ │ │ │ ├── AndroidPercentFormatter.kt
│ │ │ │ └── AndroidPriceFormatter.kt
│ │ ├── desktopMain
│ │ │ └── kotlin
│ │ │ │ └── dev
│ │ │ │ └── ohoussein
│ │ │ │ └── cryptoapp
│ │ │ │ └── core
│ │ │ │ └── formatter
│ │ │ │ ├── DesktopPercentFormatter.kt
│ │ │ │ └── DesktopPriceFormatter.kt
│ │ └── iosMain
│ │ │ └── kotlin
│ │ │ └── dev
│ │ │ └── ohoussein
│ │ │ └── cryptoapp
│ │ │ └── core
│ │ │ └── formatter
│ │ │ ├── IOSPercentFormatter.kt
│ │ │ └── IOSPriceFormatter.kt
│ │ └── build.gradle.kts
├── designsystem
│ ├── src
│ │ ├── commonMain
│ │ │ ├── composeResources
│ │ │ │ ├── font
│ │ │ │ │ ├── montserrat_medium.ttf
│ │ │ │ │ └── paytone_one_regular.ttf
│ │ │ │ └── values
│ │ │ │ │ └── strings.xml
│ │ │ └── kotlin
│ │ │ │ └── dev
│ │ │ │ └── ohoussein
│ │ │ │ └── cryptoapp
│ │ │ │ └── designsystem
│ │ │ │ ├── graph
│ │ │ │ └── model
│ │ │ │ │ ├── GraphPoint.kt
│ │ │ │ │ └── GridPoint.kt
│ │ │ │ ├── theme
│ │ │ │ ├── Shape.kt
│ │ │ │ ├── Color.kt
│ │ │ │ ├── Type.kt
│ │ │ │ └── Theme.kt
│ │ │ │ ├── base
│ │ │ │ ├── LinkText.kt
│ │ │ │ ├── CryptoAppScaffold.kt
│ │ │ │ └── StateComponent.kt
│ │ │ │ └── core
│ │ │ │ └── AnnotatedString.kt
│ │ └── commonTest
│ │ │ └── kotlin
│ │ │ └── dev
│ │ │ └── ohoussein
│ │ │ └── cryptoapp
│ │ │ └── designsystem
│ │ │ └── core
│ │ │ └── AnnotatedStringTest.kt
│ └── build.gradle.kts
├── presentation
│ ├── src
│ │ ├── commonMain
│ │ │ ├── composeResources
│ │ │ │ └── drawable
│ │ │ │ │ └── app_icon_256.png
│ │ │ └── kotlin
│ │ │ │ └── dev
│ │ │ │ └── ohoussein
│ │ │ │ └── cryptoapp
│ │ │ │ └── presentation
│ │ │ │ ├── App.kt
│ │ │ │ └── SharedPresentationModules.kt
│ │ ├── androidMain
│ │ │ └── kotlin
│ │ │ │ └── dev
│ │ │ │ └── ohoussein
│ │ │ │ └── cryptoapp
│ │ │ │ └── AppFactory.kt
│ │ ├── iosMain
│ │ │ └── kotlin
│ │ │ │ └── presentation
│ │ │ │ └── MainViewController.kt
│ │ ├── desktopMain
│ │ │ └── kotlin
│ │ │ │ └── AppFactory.kt
│ │ ├── desktopTest
│ │ │ └── kotlin
│ │ │ │ └── dev
│ │ │ │ └── ohoussein
│ │ │ │ └── cryptoapp
│ │ │ │ └── presentation
│ │ │ │ └── SharedPresentationModulesTest.kt
│ │ └── androidUnitTest
│ │ │ └── kotlin
│ │ │ └── dev
│ │ │ └── ohoussein
│ │ │ └── cryptoapp
│ │ │ └── presentation
│ │ │ └── SharedPresentationModulesTest.kt
│ └── build.gradle.kts
└── crypto
│ ├── presentation
│ ├── src
│ │ ├── commonMain
│ │ │ ├── kotlin
│ │ │ │ └── dev
│ │ │ │ │ └── ohoussein
│ │ │ │ │ └── cryptoapp
│ │ │ │ │ └── crypto
│ │ │ │ │ └── presentation
│ │ │ │ │ ├── UIConfig.kt
│ │ │ │ │ ├── list
│ │ │ │ │ ├── CryptoListEvents.kt
│ │ │ │ │ ├── CryptoListState.kt
│ │ │ │ │ └── CryptoListViewModel.kt
│ │ │ │ │ ├── graph
│ │ │ │ │ ├── CryptoPriceGraphEvents.kt
│ │ │ │ │ ├── CryptoPriceGraphState.kt
│ │ │ │ │ ├── CryptoPriceGraphViewModel.kt
│ │ │ │ │ ├── CryptoPriceGraph.kt
│ │ │ │ │ └── GraphGridGenerator.kt
│ │ │ │ │ ├── model
│ │ │ │ │ ├── DataStatus.kt
│ │ │ │ │ ├── GraphInterval.kt
│ │ │ │ │ └── Crypto.kt
│ │ │ │ │ ├── CryptoFeatNavPath.kt
│ │ │ │ │ ├── details
│ │ │ │ │ ├── CryptoDetailsEvents.kt
│ │ │ │ │ ├── CryptoDetailsState.kt
│ │ │ │ │ └── CryptoDetailsViewModel.kt
│ │ │ │ │ ├── core
│ │ │ │ │ ├── DatetimeExt.kt
│ │ │ │ │ ├── ViewModel.kt
│ │ │ │ │ └── Math.kt
│ │ │ │ │ ├── di
│ │ │ │ │ └── CryptoPresentationModule.kt
│ │ │ │ │ ├── nav
│ │ │ │ │ └── CryptoNavGraph.kt
│ │ │ │ │ └── mapper
│ │ │ │ │ └── DomainModelMapper.kt
│ │ │ └── composeResources
│ │ │ │ ├── values
│ │ │ │ └── strings.xml
│ │ │ │ └── drawable
│ │ │ │ └── ic_bear.xml
│ │ └── commonTest
│ │ │ └── kotlin
│ │ │ └── dev
│ │ │ └── ohoussein
│ │ │ └── cryptoapp
│ │ │ └── crypto
│ │ │ └── presentation
│ │ │ ├── fake
│ │ │ ├── FakePercentFormatter.kt
│ │ │ ├── FakeRouter.kt
│ │ │ ├── FakePriceFormatter.kt
│ │ │ ├── FakeGetTopCryptoListUseCase.kt
│ │ │ └── FakeGetCryptoDetailsUseCase.kt
│ │ │ ├── core
│ │ │ └── MathTest.kt
│ │ │ ├── list
│ │ │ └── CryptoListViewModelTest.kt
│ │ │ └── graph
│ │ │ └── CryptoPriceGraphViewModelTest.kt
│ ├── build.gradle.kts
│ └── presentation.podspec
│ ├── domain
│ ├── src
│ │ ├── commonMain
│ │ │ └── kotlin
│ │ │ │ └── dev
│ │ │ │ └── ohoussein
│ │ │ │ └── cryptoapp
│ │ │ │ └── crypto
│ │ │ │ └── domain
│ │ │ │ ├── model
│ │ │ │ ├── HistoricalPrice.kt
│ │ │ │ ├── Locale.kt
│ │ │ │ ├── CryptoModel.kt
│ │ │ │ └── FakeCryptoModel.kt
│ │ │ │ ├── usecase
│ │ │ │ ├── GetTopCryptoListUseCase.kt
│ │ │ │ ├── GetTopCryptoListUseCaseImpl.kt
│ │ │ │ ├── GetCryptoDetailsUseCase.kt
│ │ │ │ └── GetCryptoDetailsUseCaseImpl.kt
│ │ │ │ ├── repo
│ │ │ │ └── ICryptoRepository.kt
│ │ │ │ └── CryptoDomainModule.kt
│ │ └── commonTest
│ │ │ └── kotlin
│ │ │ └── dev
│ │ │ └── ohoussein
│ │ │ └── cryptoapp
│ │ │ └── crypto
│ │ │ └── domain
│ │ │ └── usecase
│ │ │ ├── GetTopCryptoListUseCaseImplTest.kt
│ │ │ ├── stub
│ │ │ └── MockedCryptoRepository.kt
│ │ │ └── GetCryptoDetailsUseCaseImplTest.kt
│ └── build.gradle.kts
│ └── data
│ ├── build.gradle.kts
│ └── src
│ └── commonMain
│ └── kotlin
│ └── dev
│ └── ohoussein
│ └── cryptoapp
│ └── crypto
│ └── data
│ ├── CryptoDataModule.kt
│ ├── mapper
│ └── ApiDomainModelMapper.kt
│ └── repository
│ └── CryptoRepository.kt
├── e2e_tests
├── .gitignore
├── ios
│ ├── ios.yml
│ ├── ios-screenshots.yml
│ ├── crypto_list.yml
│ └── crypto_details.yml
├── android
│ ├── android.yml
│ ├── android-screenshots.yml
│ ├── crypto_list.yml
│ └── crypto_details.yml
├── test_android.sh
├── test_ios.sh
├── readme.MD
└── generate_screenshots.sh
├── .gitattributes
├── gradle.properties
├── app-iOS
├── Configuration
│ └── Config.xcconfig
├── appiOS
│ ├── Assets.xcassets
│ │ ├── Contents.json
│ │ ├── AppIcon.appiconset
│ │ │ ├── cryptoAppIconiOS.png
│ │ │ └── Contents.json
│ │ └── AccentColor.colorset
│ │ │ └── Contents.json
│ ├── Preview Content
│ │ └── Preview Assets.xcassets
│ │ │ └── Contents.json
│ ├── iOSApp.swift
│ ├── ContentView.swift
│ └── Info.plist
├── .gitignore
└── appiOS.xcodeproj
│ ├── project.xcworkspace
│ └── xcshareddata
│ │ └── IDEWorkspaceChecks.plist
│ └── xcshareddata
│ └── xcschemes
│ └── appiOS.xcscheme
├── design
├── ios_crypto_list_light.png
├── android_crypto_list_dark.png
├── desktop_crypto_list_dark.png
├── ios_crypto_details_light.png
├── android_crypto_details_dark.png
├── desktop_crypto_details_dark.png
└── architecture.drawio
├── gradle
└── wrapper
│ ├── gradle-wrapper.jar
│ └── gradle-wrapper.properties
├── app-android
├── src
│ └── main
│ │ ├── res
│ │ ├── values
│ │ │ ├── strings.xml
│ │ │ ├── ic_launcher_background.xml
│ │ │ ├── colors.xml
│ │ │ └── themes.xml
│ │ ├── xml
│ │ │ ├── backup_rules.xml
│ │ │ └── network_security_config.xml
│ │ ├── mipmap-hdpi
│ │ │ ├── ic_launcher.webp
│ │ │ └── ic_launcher_round.webp
│ │ ├── mipmap-mdpi
│ │ │ ├── ic_launcher.webp
│ │ │ └── ic_launcher_round.webp
│ │ ├── mipmap-xhdpi
│ │ │ ├── ic_launcher.webp
│ │ │ └── ic_launcher_round.webp
│ │ ├── mipmap-xxhdpi
│ │ │ ├── ic_launcher.webp
│ │ │ └── ic_launcher_round.webp
│ │ ├── mipmap-xxxhdpi
│ │ │ ├── ic_launcher.webp
│ │ │ └── ic_launcher_round.webp
│ │ ├── mipmap-anydpi-v26
│ │ │ ├── ic_launcher.xml
│ │ │ └── ic_launcher_round.xml
│ │ └── drawable-v24
│ │ │ └── ic_launcher_foreground.xml
│ │ ├── ic_launcher-playstore.png
│ │ ├── java
│ │ └── dev
│ │ │ └── ohoussein
│ │ │ └── cryptoapp
│ │ │ ├── MainActivity.kt
│ │ │ └── App.kt
│ │ └── AndroidManifest.xml
├── build.gradle.kts
└── proguard-rules.pro
├── app-desktop
├── src
│ └── main
│ │ ├── resources
│ │ └── icon
│ │ │ ├── app_icon.icns
│ │ │ ├── app_icon.ico
│ │ │ └── app_icon.png
│ │ └── kotlin
│ │ └── dev
│ │ └── ohoussein
│ │ └── cryptoapp
│ │ └── Main.kt
└── build.gradle.kts
├── .gitignore
├── .fleet
└── run.json
├── .github
├── dependabot.yml
└── workflows
│ └── main_ci.yml
├── settings.gradle.kts
├── gradlew.bat
└── README.md
/.idea/.name:
--------------------------------------------------------------------------------
1 | cryptoapp
--------------------------------------------------------------------------------
/shared/data/.gitignore:
--------------------------------------------------------------------------------
1 | /build
--------------------------------------------------------------------------------
/e2e_tests/.gitignore:
--------------------------------------------------------------------------------
1 | screenshots
--------------------------------------------------------------------------------
/shared/data/consumer-rules.pro:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/shared/data/proguard-rules.pro:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/.gitattributes:
--------------------------------------------------------------------------------
1 | **/snapshots/**/*.png filter=lfs diff=lfs merge=lfs -text
2 |
--------------------------------------------------------------------------------
/.idea/.gitignore:
--------------------------------------------------------------------------------
1 | # Default ignored files
2 | /shelf/
3 | /workspace.xml
4 | artifacts
5 | modules
--------------------------------------------------------------------------------
/gradle.properties:
--------------------------------------------------------------------------------
1 | android.useAndroidX=true
2 | org.gradle.jvmargs=-Xmx2536m
3 | # org.gradle.parallel=true
--------------------------------------------------------------------------------
/app-iOS/Configuration/Config.xcconfig:
--------------------------------------------------------------------------------
1 | TEAM_ID=
2 | BUNDLE_ID=com.ohoussein.cryptoapp
3 | APP_NAME=CryptoApp
4 |
--------------------------------------------------------------------------------
/design/ios_crypto_list_light.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/OHoussein/CryptoApp/HEAD/design/ios_crypto_list_light.png
--------------------------------------------------------------------------------
/gradle/wrapper/gradle-wrapper.jar:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/OHoussein/CryptoApp/HEAD/gradle/wrapper/gradle-wrapper.jar
--------------------------------------------------------------------------------
/app-android/src/main/res/values/strings.xml:
--------------------------------------------------------------------------------
1 |
2 | CryptoApp
3 |
4 |
--------------------------------------------------------------------------------
/app-iOS/appiOS/Assets.xcassets/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "info" : {
3 | "author" : "xcode",
4 | "version" : 1
5 | }
6 | }
--------------------------------------------------------------------------------
/design/android_crypto_list_dark.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/OHoussein/CryptoApp/HEAD/design/android_crypto_list_dark.png
--------------------------------------------------------------------------------
/design/desktop_crypto_list_dark.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/OHoussein/CryptoApp/HEAD/design/desktop_crypto_list_dark.png
--------------------------------------------------------------------------------
/design/ios_crypto_details_light.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/OHoussein/CryptoApp/HEAD/design/ios_crypto_details_light.png
--------------------------------------------------------------------------------
/design/android_crypto_details_dark.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/OHoussein/CryptoApp/HEAD/design/android_crypto_details_dark.png
--------------------------------------------------------------------------------
/design/desktop_crypto_details_dark.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/OHoussein/CryptoApp/HEAD/design/desktop_crypto_details_dark.png
--------------------------------------------------------------------------------
/app-android/src/main/res/xml/backup_rules.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
--------------------------------------------------------------------------------
/e2e_tests/ios/ios.yml:
--------------------------------------------------------------------------------
1 | appId: com.ohoussein.cryptoapp
2 | ---
3 | - launchApp
4 | - runFlow: crypto_list.yml
5 | - runFlow: crypto_details.yml
--------------------------------------------------------------------------------
/app-android/src/main/ic_launcher-playstore.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/OHoussein/CryptoApp/HEAD/app-android/src/main/ic_launcher-playstore.png
--------------------------------------------------------------------------------
/e2e_tests/android/android.yml:
--------------------------------------------------------------------------------
1 | appId: dev.ohoussein.cryptoapp
2 | ---
3 | - launchApp
4 | - runFlow: crypto_list.yml
5 | - runFlow: crypto_details.yml
--------------------------------------------------------------------------------
/app-desktop/src/main/resources/icon/app_icon.icns:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/OHoussein/CryptoApp/HEAD/app-desktop/src/main/resources/icon/app_icon.icns
--------------------------------------------------------------------------------
/app-desktop/src/main/resources/icon/app_icon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/OHoussein/CryptoApp/HEAD/app-desktop/src/main/resources/icon/app_icon.ico
--------------------------------------------------------------------------------
/app-desktop/src/main/resources/icon/app_icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/OHoussein/CryptoApp/HEAD/app-desktop/src/main/resources/icon/app_icon.png
--------------------------------------------------------------------------------
/app-iOS/appiOS/Preview Content/Preview Assets.xcassets/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "info" : {
3 | "author" : "xcode",
4 | "version" : 1
5 | }
6 | }
--------------------------------------------------------------------------------
/app-android/src/main/res/mipmap-hdpi/ic_launcher.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/OHoussein/CryptoApp/HEAD/app-android/src/main/res/mipmap-hdpi/ic_launcher.webp
--------------------------------------------------------------------------------
/app-android/src/main/res/mipmap-mdpi/ic_launcher.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/OHoussein/CryptoApp/HEAD/app-android/src/main/res/mipmap-mdpi/ic_launcher.webp
--------------------------------------------------------------------------------
/app-android/src/main/res/mipmap-xhdpi/ic_launcher.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/OHoussein/CryptoApp/HEAD/app-android/src/main/res/mipmap-xhdpi/ic_launcher.webp
--------------------------------------------------------------------------------
/app-android/src/main/res/mipmap-xxhdpi/ic_launcher.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/OHoussein/CryptoApp/HEAD/app-android/src/main/res/mipmap-xxhdpi/ic_launcher.webp
--------------------------------------------------------------------------------
/app-android/src/main/res/mipmap-xxxhdpi/ic_launcher.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/OHoussein/CryptoApp/HEAD/app-android/src/main/res/mipmap-xxxhdpi/ic_launcher.webp
--------------------------------------------------------------------------------
/app-android/src/main/res/mipmap-hdpi/ic_launcher_round.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/OHoussein/CryptoApp/HEAD/app-android/src/main/res/mipmap-hdpi/ic_launcher_round.webp
--------------------------------------------------------------------------------
/app-android/src/main/res/mipmap-mdpi/ic_launcher_round.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/OHoussein/CryptoApp/HEAD/app-android/src/main/res/mipmap-mdpi/ic_launcher_round.webp
--------------------------------------------------------------------------------
/app-desktop/src/main/kotlin/dev/ohoussein/cryptoapp/Main.kt:
--------------------------------------------------------------------------------
1 | package dev.ohoussein.cryptoapp
2 |
3 | import createApp
4 |
5 | fun main() {
6 | createApp()
7 | }
8 |
--------------------------------------------------------------------------------
/app-android/src/main/res/mipmap-xhdpi/ic_launcher_round.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/OHoussein/CryptoApp/HEAD/app-android/src/main/res/mipmap-xhdpi/ic_launcher_round.webp
--------------------------------------------------------------------------------
/app-android/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/OHoussein/CryptoApp/HEAD/app-android/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp
--------------------------------------------------------------------------------
/app-android/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/OHoussein/CryptoApp/HEAD/app-android/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp
--------------------------------------------------------------------------------
/app-android/src/main/res/values/ic_launcher_background.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 | #263238
4 |
--------------------------------------------------------------------------------
/app-iOS/.gitignore:
--------------------------------------------------------------------------------
1 | # Mac OS X
2 | *.DS_Store
3 |
4 | # Xcode
5 | *.pbxuser
6 | *.mode1v3
7 | *.mode2v3
8 | *.perspectivev3
9 | *.xcuserstate
10 | project.xcworkspace/
11 | xcuserdata/
--------------------------------------------------------------------------------
/.idea/codeStyles/codeStyleConfig.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
--------------------------------------------------------------------------------
/app-iOS/appiOS/Assets.xcassets/AppIcon.appiconset/cryptoAppIconiOS.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/OHoussein/CryptoApp/HEAD/app-iOS/appiOS/Assets.xcassets/AppIcon.appiconset/cryptoAppIconiOS.png
--------------------------------------------------------------------------------
/shared/core/router/src/commonMain/kotlin/dev/ohoussein/cryptoapp/core/router/Router.kt:
--------------------------------------------------------------------------------
1 | package dev.ohoussein.cryptoapp.core.router
2 |
3 | interface Router {
4 | fun openUrl(url: String)
5 | }
6 |
--------------------------------------------------------------------------------
/shared/designsystem/src/commonMain/composeResources/font/montserrat_medium.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/OHoussein/CryptoApp/HEAD/shared/designsystem/src/commonMain/composeResources/font/montserrat_medium.ttf
--------------------------------------------------------------------------------
/shared/presentation/src/commonMain/composeResources/drawable/app_icon_256.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/OHoussein/CryptoApp/HEAD/shared/presentation/src/commonMain/composeResources/drawable/app_icon_256.png
--------------------------------------------------------------------------------
/shared/designsystem/src/commonMain/composeResources/font/paytone_one_regular.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/OHoussein/CryptoApp/HEAD/shared/designsystem/src/commonMain/composeResources/font/paytone_one_regular.ttf
--------------------------------------------------------------------------------
/shared/designsystem/src/commonMain/kotlin/dev/ohoussein/cryptoapp/designsystem/graph/model/GraphPoint.kt:
--------------------------------------------------------------------------------
1 | package dev.ohoussein.cryptoapp.designsystem.graph.model
2 |
3 | data class GraphPoint(val x: Double, val y: Double)
4 |
--------------------------------------------------------------------------------
/app-iOS/appiOS/Assets.xcassets/AccentColor.colorset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "colors" : [
3 | {
4 | "idiom" : "universal"
5 | }
6 | ],
7 | "info" : {
8 | "author" : "xcode",
9 | "version" : 1
10 | }
11 | }
12 |
--------------------------------------------------------------------------------
/shared/crypto/presentation/src/commonMain/kotlin/dev/ohoussein/cryptoapp/crypto/presentation/UIConfig.kt:
--------------------------------------------------------------------------------
1 | package dev.ohoussein.cryptoapp.crypto.presentation
2 |
3 | object UIConfig {
4 | const val CRYPTO_DESCRIPTION_MAX_LINES = 4
5 | }
6 |
--------------------------------------------------------------------------------
/e2e_tests/test_android.sh:
--------------------------------------------------------------------------------
1 | bash ../gradlew :app-android:installRelease
2 | adb shell screenrecord /sdcard/tmp/crypto_test.mp4 &
3 | maestro test android/android.yml
4 | kill $!
5 | sleep 2
6 | adb pull /sdcard/tmp/crypto_test.mp4 screenshots/record_android.mp4
--------------------------------------------------------------------------------
/shared/designsystem/src/commonMain/kotlin/dev/ohoussein/cryptoapp/designsystem/graph/model/GridPoint.kt:
--------------------------------------------------------------------------------
1 | package dev.ohoussein.cryptoapp.designsystem.graph.model
2 |
3 | data class GridPoint(
4 | val position: Double,
5 | val label: String,
6 | )
7 |
--------------------------------------------------------------------------------
/shared/crypto/domain/src/commonMain/kotlin/dev/ohoussein/cryptoapp/crypto/domain/model/HistoricalPrice.kt:
--------------------------------------------------------------------------------
1 | package dev.ohoussein.cryptoapp.crypto.domain.model
2 |
3 | data class HistoricalPrice(
4 | val timestampMillis: Long,
5 | val price: Double,
6 | )
7 |
--------------------------------------------------------------------------------
/shared/crypto/presentation/src/commonMain/kotlin/dev/ohoussein/cryptoapp/crypto/presentation/list/CryptoListEvents.kt:
--------------------------------------------------------------------------------
1 | package dev.ohoussein.cryptoapp.crypto.presentation.list
2 |
3 | sealed interface CryptoListEvents {
4 | data object Refresh : CryptoListEvents
5 | }
6 |
--------------------------------------------------------------------------------
/shared/core/router/build.gradle.kts:
--------------------------------------------------------------------------------
1 | plugins {
2 | id("dev.ohoussein.cryptoapp.kotlin.multiplatform.library")
3 | }
4 |
5 | kotlin {
6 | sourceSets {
7 | commonMain.dependencies {
8 | implementation(libs.koin.core)
9 | }
10 | }
11 | }
12 |
--------------------------------------------------------------------------------
/gradle/wrapper/gradle-wrapper.properties:
--------------------------------------------------------------------------------
1 | #Wed Feb 05 10:35:00 CET 2025
2 | distributionBase=GRADLE_USER_HOME
3 | distributionPath=wrapper/dists
4 | distributionUrl=https\://services.gradle.org/distributions/gradle-8.10.2-bin.zip
5 | zipStoreBase=GRADLE_USER_HOME
6 | zipStorePath=wrapper/dists
7 |
--------------------------------------------------------------------------------
/shared/data/database/src/commonMain/kotlin/dev/ohoussein/cryptoapp/data/database/DatabaseDriverFactory.kt:
--------------------------------------------------------------------------------
1 | package dev.ohoussein.cryptoapp.data.database
2 |
3 | import app.cash.sqldelight.db.SqlDriver
4 |
5 | expect class DatabaseDriverFactory {
6 | fun createDriver(): SqlDriver
7 | }
8 |
--------------------------------------------------------------------------------
/shared/crypto/domain/src/commonMain/kotlin/dev/ohoussein/cryptoapp/crypto/domain/model/Locale.kt:
--------------------------------------------------------------------------------
1 | package dev.ohoussein.cryptoapp.crypto.domain.model
2 |
3 | val defaultLocale = Locale("USD", "en")
4 |
5 | data class Locale(
6 | val currencyCode: String,
7 | val languageCode: String,
8 | )
9 |
--------------------------------------------------------------------------------
/shared/designsystem/src/commonMain/composeResources/values/strings.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 | CryptoApp
4 |
5 | RETRY
6 | Back
7 |
--------------------------------------------------------------------------------
/e2e_tests/ios/ios-screenshots.yml:
--------------------------------------------------------------------------------
1 | appId: OHoussein.CryptoAppiOS
2 | ---
3 | - launchApp
4 | - assertVisible: "Bitcoin"
5 | - takeScreenshot: screenshots/ios_crypto_list_${test_name}
6 | - tapOn: "Bitcoin"
7 | - assertVisible: "Bitcoin (BTC)"
8 | - takeScreenshot: screenshots/ios_crypto_details_${test_name}
--------------------------------------------------------------------------------
/e2e_tests/android/android-screenshots.yml:
--------------------------------------------------------------------------------
1 | appId: dev.ohoussein.cryptoapp
2 | ---
3 | - launchApp
4 | - assertVisible: "Bitcoin"
5 | - takeScreenshot: screenshots/android_crypto_list_${test_name}
6 | - tapOn: "Bitcoin"
7 | - assertVisible: "Bitcoin (BTC)"
8 | - takeScreenshot: screenshots/android_crypto_details_${test_name}
--------------------------------------------------------------------------------
/app-android/src/main/res/values/colors.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 | #455A64
4 | #263238
5 | #4FC3F7
6 | #FFF
7 |
8 |
--------------------------------------------------------------------------------
/shared/core/formatter/src/commonMain/kotlin/dev/ohoussein/cryptoapp/core/formatter/PercentFormatter.kt:
--------------------------------------------------------------------------------
1 | package dev.ohoussein.cryptoapp.core.formatter
2 |
3 | interface PercentFormatter {
4 | operator fun invoke(percent: Double): String
5 | }
6 |
7 | expect fun getPercentFormatter(localeId: String): PercentFormatter
8 |
--------------------------------------------------------------------------------
/shared/core/formatter/src/commonMain/kotlin/dev/ohoussein/cryptoapp/core/formatter/PriceFormatter.kt:
--------------------------------------------------------------------------------
1 | package dev.ohoussein.cryptoapp.core.formatter
2 |
3 | interface PriceFormatter {
4 | operator fun invoke(price: Double, currencyCode: String): String
5 | }
6 |
7 | expect fun getPriceFormatter(localeId: String): PriceFormatter
8 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | **.iml
2 | **/local.properties
3 | .idea/*.xml
4 | .idea/libraries
5 | .idea/modules
6 | .idea/shelf
7 | .idea/sonarlint
8 | **.DS_Store
9 | **/build
10 | **/captures
11 | **.externalNativeBuild
12 | **/app/release/
13 | **/app/prod/
14 | **/app/mock/
15 | **/app/.cxx/
16 | **/apk
17 | *.jks
18 | .gradle
19 | .kotlin
20 |
--------------------------------------------------------------------------------
/app-android/src/main/res/mipmap-anydpi-v26/ic_launcher.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
--------------------------------------------------------------------------------
/shared/core/router/src/androidMain/kotlin/dev/ohoussein/cryptoapp/core/router/NativeRouterModule.android.kt:
--------------------------------------------------------------------------------
1 | package dev.ohoussein.cryptoapp.core.router
2 |
3 | import org.koin.core.module.Module
4 | import org.koin.dsl.module
5 |
6 | actual val nativeRouterModule: Module = module {
7 | factory { RouterImpl(get()) }
8 | }
9 |
--------------------------------------------------------------------------------
/.fleet/run.json:
--------------------------------------------------------------------------------
1 | {
2 | "configurations": [
3 | {
4 | "type": "xcode-app",
5 | "name": "Xcode-app configuration",
6 | "buildTarget": {
7 | "project": "",
8 | "target": ""
9 | },
10 | "configuration": ""
11 | }
12 | ]
13 | }
--------------------------------------------------------------------------------
/app-android/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
--------------------------------------------------------------------------------
/app-iOS/appiOS.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | IDEDidComputeMac32BitWarning
6 |
7 |
8 |
9 |
--------------------------------------------------------------------------------
/shared/core/formatter/build.gradle.kts:
--------------------------------------------------------------------------------
1 | plugins {
2 | id("dev.ohoussein.cryptoapp.kotlin.multiplatform.library")
3 | }
4 |
5 | kotlin {
6 | sourceSets {
7 | commonMain.dependencies {
8 | implementation(libs.koin.core)
9 | implementation(libs.core.kotlin.datetime)
10 | }
11 | }
12 | }
13 |
--------------------------------------------------------------------------------
/shared/core/router/src/commonMain/kotlin/dev/ohoussein/cryptoapp/core/router/RouterModule.kt:
--------------------------------------------------------------------------------
1 | package dev.ohoussein.cryptoapp.core.router
2 |
3 | import org.koin.core.module.Module
4 | import org.koin.dsl.module
5 |
6 | val routerModule = module {
7 | includes(nativeRouterModule)
8 | }
9 |
10 | expect val nativeRouterModule: Module
11 |
--------------------------------------------------------------------------------
/app-iOS/appiOS/Assets.xcassets/AppIcon.appiconset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "images" : [
3 | {
4 | "filename" : "cryptoAppIconiOS.png",
5 | "idiom" : "universal",
6 | "platform" : "ios",
7 | "size" : "1024x1024"
8 | }
9 | ],
10 | "info" : {
11 | "author" : "xcode",
12 | "version" : 1
13 | }
14 | }
15 |
--------------------------------------------------------------------------------
/e2e_tests/ios/crypto_list.yml:
--------------------------------------------------------------------------------
1 | appId: CryptoAppiOS
2 | ---
3 | - assertVisible: "Bitcoin"
4 | - assertVisible: "BTC"
5 | - assertVisible: "Ethereum"
6 | - assertVisible: "ETH"
7 | - assertVisible: \$[0-9]+,?[0-9].*(\.[0-9]+)? # crypto price
8 | - assertVisible: ^[0-9]+(\.[0-9])?% # 24h change %
9 | - takeScreenshot: screenshots/ios_crypto_list
10 |
--------------------------------------------------------------------------------
/shared/presentation/src/androidMain/kotlin/dev/ohoussein/cryptoapp/AppFactory.kt:
--------------------------------------------------------------------------------
1 | package dev.ohoussein.cryptoapp
2 |
3 | import SharedApp
4 | import androidx.activity.ComponentActivity
5 | import androidx.activity.compose.setContent
6 |
7 | fun ComponentActivity.createApp() {
8 | setContent {
9 | SharedApp()
10 | }
11 | }
12 |
--------------------------------------------------------------------------------
/e2e_tests/android/crypto_list.yml:
--------------------------------------------------------------------------------
1 | appId: dev.ohoussein.cryptoapp
2 | ---
3 | - assertVisible: "Bitcoin"
4 | - takeScreenshot: screenshots/android_crypto_list
5 | - assertVisible: "BTC"
6 | - assertVisible: "Ethereum"
7 | - assertVisible: "ETH"
8 | - assertVisible: \$[0-9]+,?[0-9].*(\.[0-9]+)? # crypto price
9 | - assertVisible: ^[0-9]+(\.[0-9])?% # 24h change %
--------------------------------------------------------------------------------
/shared/core/router/src/iosMain/kotlin/dev/ohoussein/cryptoapp/core/router/NativeRouterModule.ios.kt:
--------------------------------------------------------------------------------
1 | package dev.ohoussein.cryptoapp.core.router
2 |
3 | import org.koin.core.module.Module
4 | import org.koin.core.module.dsl.factoryOf
5 | import org.koin.dsl.module
6 |
7 | actual val nativeRouterModule: Module = module {
8 | factoryOf(::RouterImpl)
9 | }
10 |
--------------------------------------------------------------------------------
/shared/crypto/presentation/src/commonTest/kotlin/dev/ohoussein/cryptoapp/crypto/presentation/fake/FakePercentFormatter.kt:
--------------------------------------------------------------------------------
1 | package dev.ohoussein.cryptoapp.crypto.presentation.fake
2 |
3 | import dev.ohoussein.cryptoapp.core.formatter.PercentFormatter
4 |
5 | class FakePercentFormatter : PercentFormatter {
6 | override fun invoke(percent: Double): String = "$percent%"
7 | }
8 |
--------------------------------------------------------------------------------
/shared/core/router/src/desktopMain/kotlin/dev/ohoussein/cryptoapp/core/router/NativeRouterModule.desktop.kt:
--------------------------------------------------------------------------------
1 | package dev.ohoussein.cryptoapp.core.router
2 |
3 | import org.koin.core.module.Module
4 | import org.koin.core.module.dsl.factoryOf
5 | import org.koin.dsl.module
6 |
7 | actual val nativeRouterModule: Module = module {
8 | factoryOf(::RouterImpl)
9 | }
10 |
--------------------------------------------------------------------------------
/app-android/src/main/java/dev/ohoussein/cryptoapp/MainActivity.kt:
--------------------------------------------------------------------------------
1 | package dev.ohoussein.cryptoapp
2 |
3 | import android.os.Bundle
4 | import androidx.activity.ComponentActivity
5 |
6 | class MainActivity : ComponentActivity() {
7 | override fun onCreate(savedInstanceState: Bundle?) {
8 | super.onCreate(savedInstanceState)
9 | createApp()
10 | }
11 | }
12 |
--------------------------------------------------------------------------------
/app-iOS/appiOS/iOSApp.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 | import Presentation
3 |
4 | @main
5 | struct iOSApp: App {
6 | @UIApplicationDelegateAdaptor(AppDelegate.self)
7 | var appDelegate: AppDelegate
8 |
9 | var body: some Scene {
10 | WindowGroup {
11 | ContentView()
12 | }
13 | }
14 | }
15 |
16 |
17 | class AppDelegate: NSObject, UIApplicationDelegate {
18 |
19 | }
20 |
--------------------------------------------------------------------------------
/e2e_tests/test_ios.sh:
--------------------------------------------------------------------------------
1 | xcodebuild \
2 | -workspace ../app-iOS/appiOS.xcodeproj/project.xcworkspace \
3 | -configuration Release \
4 | -scheme appiOS \
5 | -sdk iphonesimulator \
6 | -derivedDataPath app-iOS/build
7 |
8 | xcrun simctl install Booted ../app-iOS/build/Build/Products/Debug-iphonesimulator/CryptoAppiOS.app
9 | maestro test ios/ios.yml
--------------------------------------------------------------------------------
/shared/crypto/presentation/src/commonMain/kotlin/dev/ohoussein/cryptoapp/crypto/presentation/graph/CryptoPriceGraphEvents.kt:
--------------------------------------------------------------------------------
1 | package dev.ohoussein.cryptoapp.crypto.presentation.graph
2 |
3 | import dev.ohoussein.cryptoapp.crypto.presentation.model.GraphInterval
4 |
5 | sealed interface CryptoPriceGraphEvents {
6 | data class SelectInterval(val interval: GraphInterval) : CryptoPriceGraphEvents
7 | }
8 |
--------------------------------------------------------------------------------
/shared/data/cache/build.gradle.kts:
--------------------------------------------------------------------------------
1 | plugins {
2 | id("dev.ohoussein.cryptoapp.kotlin.multiplatform.library")
3 | }
4 |
5 | kotlin {
6 | sourceSets {
7 | commonMain.dependencies {
8 | implementation(libs.core.kotlin.coroutines.core)
9 | }
10 | commonTest.dependencies {
11 | implementation(libs.test.coroutines)
12 | }
13 | }
14 | }
15 |
--------------------------------------------------------------------------------
/shared/data/database/src/commonMain/kotlin/dev/ohoussein/cryptoapp/data/database/DatabaseFactory.kt:
--------------------------------------------------------------------------------
1 | package dev.ohoussein.cryptoapp.data.database
2 |
3 | import app.cash.sqldelight.db.SqlDriver
4 | import dev.ohoussein.cryptoapp.database.CryptoDB
5 |
6 | fun buildCryptoDB(driver: SqlDriver): CryptoDB {
7 | runCatching { CryptoDB.Schema.create(driver) }
8 | return CryptoDB(driver)
9 | }
10 |
--------------------------------------------------------------------------------
/shared/data/cache/src/commonMain/kotlin/dev/ohoussein/cryptoapp/data/cache/CacheDataSource.kt:
--------------------------------------------------------------------------------
1 | package dev.ohoussein.cryptoapp.data.cache
2 |
3 | import kotlin.time.Duration
4 |
5 | interface CacheDataSource {
6 |
7 | suspend fun read(key: Key, ttl: Duration? = null): Data?
8 |
9 | suspend fun write(key: Key, data: Data?)
10 |
11 | suspend fun clearAll()
12 | }
13 |
--------------------------------------------------------------------------------
/shared/crypto/presentation/src/commonTest/kotlin/dev/ohoussein/cryptoapp/crypto/presentation/fake/FakeRouter.kt:
--------------------------------------------------------------------------------
1 | package dev.ohoussein.cryptoapp.crypto.presentation.fake
2 |
3 | import dev.ohoussein.cryptoapp.core.router.Router
4 |
5 | class FakeRouter : Router {
6 | val openedUrls = mutableListOf()
7 | override fun openUrl(url: String) {
8 | openedUrls.add(url)
9 | }
10 | }
11 |
--------------------------------------------------------------------------------
/shared/crypto/presentation/src/commonMain/kotlin/dev/ohoussein/cryptoapp/crypto/presentation/model/DataStatus.kt:
--------------------------------------------------------------------------------
1 | package dev.ohoussein.cryptoapp.crypto.presentation.model
2 |
3 | import androidx.compose.runtime.Stable
4 |
5 | @Stable
6 | sealed interface DataStatus {
7 | data object Success : DataStatus
8 | data class Error(val message: String) : DataStatus
9 | data object Loading : DataStatus
10 | }
11 |
--------------------------------------------------------------------------------
/shared/crypto/presentation/src/commonMain/kotlin/dev/ohoussein/cryptoapp/crypto/presentation/model/GraphInterval.kt:
--------------------------------------------------------------------------------
1 | package dev.ohoussein.cryptoapp.crypto.presentation.model
2 |
3 | @Suppress("MagicNumber")
4 | enum class GraphInterval(val countDays: Int) {
5 | INTERVAL_1_DAY(1),
6 | INTERVAL_7_DAYS(7),
7 | INTERVAL_1_MONTH(30),
8 | INTERVAL_3_MONTHS(30 * 3),
9 | INTERVAL_1_YEAR(30 * 12),
10 | }
11 |
--------------------------------------------------------------------------------
/shared/crypto/presentation/src/commonMain/kotlin/dev/ohoussein/cryptoapp/crypto/presentation/CryptoFeatNavPath.kt:
--------------------------------------------------------------------------------
1 | package dev.ohoussein.cryptoapp.crypto.presentation
2 |
3 | object CryptoFeatNavPath {
4 | const val HOME = "home"
5 |
6 | object CryptoDetailsPath {
7 | const val ARG_CRYPTO_ID = "id"
8 |
9 | const val PATH = "crypto/{$ARG_CRYPTO_ID}"
10 |
11 | fun path(id: String) = "crypto/$id"
12 | }
13 | }
14 |
--------------------------------------------------------------------------------
/shared/crypto/domain/build.gradle.kts:
--------------------------------------------------------------------------------
1 | plugins {
2 | id("dev.ohoussein.cryptoapp.kotlin.multiplatform.library")
3 | }
4 |
5 | kotlin {
6 | sourceSets {
7 | commonMain.dependencies {
8 | implementation(libs.core.kotlin.coroutines.core)
9 | implementation(libs.koin.core)
10 | }
11 | commonTest.dependencies {
12 | implementation(libs.test.coroutines)
13 | }
14 | }
15 | }
16 |
--------------------------------------------------------------------------------
/shared/crypto/presentation/src/commonMain/kotlin/dev/ohoussein/cryptoapp/crypto/presentation/details/CryptoDetailsEvents.kt:
--------------------------------------------------------------------------------
1 | package dev.ohoussein.cryptoapp.crypto.presentation.details
2 |
3 | sealed interface CryptoDetailsEvents {
4 | data object Refresh : CryptoDetailsEvents
5 | data object HomePageClicked : CryptoDetailsEvents
6 | data object BlockchainSiteClicked : CryptoDetailsEvents
7 | data object SourceCodeClicked : CryptoDetailsEvents
8 | }
9 |
--------------------------------------------------------------------------------
/shared/crypto/presentation/src/commonMain/kotlin/dev/ohoussein/cryptoapp/crypto/presentation/list/CryptoListState.kt:
--------------------------------------------------------------------------------
1 | package dev.ohoussein.cryptoapp.crypto.presentation.list
2 |
3 | import dev.ohoussein.cryptoapp.crypto.presentation.model.Crypto
4 | import dev.ohoussein.cryptoapp.crypto.presentation.model.DataStatus
5 |
6 | data class CryptoListState(
7 | val cryptoList: List? = null,
8 | val status: DataStatus = DataStatus.Loading,
9 | )
10 |
--------------------------------------------------------------------------------
/shared/designsystem/src/commonMain/kotlin/dev/ohoussein/cryptoapp/designsystem/theme/Shape.kt:
--------------------------------------------------------------------------------
1 | package dev.ohoussein.cryptoapp.designsystem.theme
2 |
3 | import androidx.compose.foundation.shape.RoundedCornerShape
4 | import androidx.compose.material.Shapes
5 | import androidx.compose.ui.unit.dp
6 |
7 | val Shapes = Shapes(
8 | small = RoundedCornerShape(4.dp),
9 | medium = RoundedCornerShape(24.dp),
10 | large = RoundedCornerShape(0.dp)
11 | )
12 |
--------------------------------------------------------------------------------
/e2e_tests/readme.MD:
--------------------------------------------------------------------------------
1 | ## Installation
2 |
3 | [Documentation](https://maestro.mobile.dev/getting-started/installing-maestro)
4 |
5 | ```
6 | curl -Ls "https://get.maestro.mobile.dev" | bash
7 | ```
8 |
9 | ## run end to end test
10 | ```
11 | cd e2e_tests/
12 | ./test_android.sh
13 | ```
14 | ## Build Android and run end to end test
15 | ```
16 | ./gradlew e2eTest
17 | ```
18 | ## Generate screenshots
19 | ```
20 | cd e2e_tests/
21 | ./generate_screenshots.sh
22 | ```
--------------------------------------------------------------------------------
/shared/data/database/src/androidUnitTest/kotlin/dev/ohoussein/cryptoapp/data/database/DatabaseDriverFactory.kt:
--------------------------------------------------------------------------------
1 | package dev.ohoussein.cryptoapp.data.database
2 |
3 | import app.cash.sqldelight.driver.jdbc.sqlite.JdbcSqliteDriver
4 | import dev.ohoussein.cryptoapp.database.CryptoDB
5 |
6 | fun createDatabase(): CryptoDB {
7 | val driver = JdbcSqliteDriver(JdbcSqliteDriver.IN_MEMORY)
8 | CryptoDB.Schema.create(driver)
9 | return CryptoDB(driver)
10 | }
11 |
--------------------------------------------------------------------------------
/shared/data/database/src/iosMain/kotlin/dev/ohoussein/cryptoapp/data/database/DatabaseDriverFactory.kt:
--------------------------------------------------------------------------------
1 | package dev.ohoussein.cryptoapp.data.database
2 |
3 | import app.cash.sqldelight.db.SqlDriver
4 | import app.cash.sqldelight.driver.native.NativeSqliteDriver
5 | import dev.ohoussein.cryptoapp.database.CryptoDB
6 |
7 | actual class DatabaseDriverFactory {
8 | actual fun createDriver(): SqlDriver = NativeSqliteDriver(CryptoDB.Schema, "CryptoDatabase.db")
9 | }
10 |
--------------------------------------------------------------------------------
/shared/crypto/presentation/src/commonTest/kotlin/dev/ohoussein/cryptoapp/crypto/presentation/fake/FakePriceFormatter.kt:
--------------------------------------------------------------------------------
1 | package dev.ohoussein.cryptoapp.crypto.presentation.fake
2 |
3 | import dev.ohoussein.cryptoapp.core.formatter.PriceFormatter
4 | import kotlin.math.roundToInt
5 |
6 | class FakePriceFormatter : PriceFormatter {
7 | override fun invoke(price: Double, currencyCode: String): String {
8 | return "${price.roundToInt()} $currencyCode"
9 | }
10 | }
11 |
--------------------------------------------------------------------------------
/shared/presentation/src/iosMain/kotlin/presentation/MainViewController.kt:
--------------------------------------------------------------------------------
1 | package presentation
2 |
3 | import SharedApp
4 | import androidx.compose.ui.window.ComposeUIViewController
5 | import dev.ohoussein.cryptoapp.presentation.sharedPresentationModules
6 | import org.koin.compose.KoinApplication
7 |
8 | fun mainViewController() = ComposeUIViewController {
9 | KoinApplication(application = {
10 | modules(sharedPresentationModules)
11 | }) {
12 | SharedApp()
13 | }
14 | }
15 |
--------------------------------------------------------------------------------
/shared/core/router/src/desktopMain/kotlin/dev/ohoussein/cryptoapp/core/router/RouterImpl.desktop.kt:
--------------------------------------------------------------------------------
1 | package dev.ohoussein.cryptoapp.core.router
2 |
3 | import java.awt.Desktop
4 | import java.net.URI
5 |
6 | class RouterImpl : Router {
7 | override fun openUrl(url: String) {
8 | runCatching { Desktop.getDesktop().browse(URI.create(url)) }
9 | .onFailure {
10 | println("Errr opening url $url: $it")
11 | it.printStackTrace()
12 | }
13 | }
14 | }
15 |
--------------------------------------------------------------------------------
/.github/dependabot.yml:
--------------------------------------------------------------------------------
1 | version: 2
2 | updates:
3 | - package-ecosystem: "gradle"
4 | directory: "/"
5 | schedule:
6 | interval: "weekly"
7 | open-pull-requests-limit: 4
8 | groups:
9 | android-build-tools:
10 | patterns:
11 | - "com.android.tools.build*"
12 | kotlin-dependencies:
13 | patterns:
14 | - "org.jetbrains.kotlinx*"
15 | - "org.jetbrains.kotlin*"
16 | app-dependencies:
17 | patterns:
18 | - "*"
19 |
--------------------------------------------------------------------------------
/shared/data/network/src/commonMain/kotlin/dev/ohoussein/cryptoapp/data/network/NetworkModule.kt:
--------------------------------------------------------------------------------
1 | package dev.ohoussein.cryptoapp.data.network
2 |
3 | import dev.ohoussein.cryptoapp.data.network.crypto.service.ApiCryptoService
4 | import dev.ohoussein.cryptoapp.data.network.crypto.service.ApiCryptoServiceImpl
5 | import org.koin.dsl.module
6 |
7 | val networkModule = module {
8 | single {
9 | NetworkBuilder.httpClient()
10 | }
11 |
12 | single {
13 | ApiCryptoServiceImpl(get())
14 | }
15 | }
16 |
--------------------------------------------------------------------------------
/shared/crypto/presentation/src/commonMain/kotlin/dev/ohoussein/cryptoapp/crypto/presentation/details/CryptoDetailsState.kt:
--------------------------------------------------------------------------------
1 | package dev.ohoussein.cryptoapp.crypto.presentation.details
2 |
3 | import cryptoapp.shared.crypto.presentation.generated.resources.*
4 | import dev.ohoussein.cryptoapp.crypto.presentation.model.CryptoDetails
5 | import dev.ohoussein.cryptoapp.crypto.presentation.model.DataStatus
6 |
7 | data class CryptoDetailsState(
8 | val cryptoDetails: CryptoDetails? = null,
9 | val status: DataStatus = DataStatus.Loading,
10 | )
11 |
--------------------------------------------------------------------------------
/shared/core/formatter/src/commonMain/kotlin/dev/ohoussein/cryptoapp/core/formatter/FormatModule.kt:
--------------------------------------------------------------------------------
1 | package dev.ohoussein.cryptoapp.core.formatter
2 |
3 | import kotlinx.datetime.TimeZone
4 | import org.koin.core.qualifier.named
5 | import org.koin.dsl.module
6 |
7 | val formatModule = module {
8 | factory {
9 | getPercentFormatter(get(named("languageCode")))
10 | }
11 | factory {
12 | getPriceFormatter(get(named("languageCode")))
13 | }
14 | factory {
15 | TimeZone.currentSystemDefault()
16 | }
17 | }
18 |
--------------------------------------------------------------------------------
/shared/data/database/src/androidMain/kotlin/dev/ohoussein/cryptoapp/data/database/DatabaseDriverFactory.kt:
--------------------------------------------------------------------------------
1 | package dev.ohoussein.cryptoapp.data.database
2 |
3 | import android.content.Context
4 | import app.cash.sqldelight.db.SqlDriver
5 | import app.cash.sqldelight.driver.android.AndroidSqliteDriver
6 | import dev.ohoussein.cryptoapp.database.CryptoDB
7 |
8 | actual class DatabaseDriverFactory(private val context: Context) {
9 | actual fun createDriver(): SqlDriver =
10 | AndroidSqliteDriver(CryptoDB.Schema, context, "CryptoDatabase.db")
11 | }
12 |
--------------------------------------------------------------------------------
/shared/designsystem/src/commonMain/kotlin/dev/ohoussein/cryptoapp/designsystem/theme/Color.kt:
--------------------------------------------------------------------------------
1 | @file:Suppress("MagicNumber")
2 |
3 | package dev.ohoussein.cryptoapp.designsystem.theme
4 |
5 | import androidx.compose.ui.graphics.Color
6 |
7 | val LightBlue300 = Color(0xFF4FC3F7)
8 | val LightBlue500 = Color(0xFF03A9F4)
9 |
10 | val BlueGrey50 = Color(0xFFECEFF1)
11 | val BlueGrey700 = Color(0xFF455A64)
12 | val BlueGrey900 = Color(0xFF263238)
13 |
14 | val LIME700 = Color(0xFFAFB42B)
15 |
16 | val Green500 = Color(0XFF4CAF50)
17 | val Red500 = Color(0XFFF44336)
18 |
--------------------------------------------------------------------------------
/app-android/src/main/res/xml/network_security_config.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | localhost
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
--------------------------------------------------------------------------------
/app-iOS/appiOS/ContentView.swift:
--------------------------------------------------------------------------------
1 | import UIKit
2 | import SwiftUI
3 | import Presentation
4 |
5 | struct ComposeView: UIViewControllerRepresentable {
6 |
7 | func makeUIViewController(context: Context) -> UIViewController {
8 | MainViewControllerKt.mainViewController()
9 | }
10 |
11 | func updateUIViewController(_ uiViewController: UIViewController, context: Context) {}
12 | }
13 |
14 | struct ContentView: View {
15 | var body: some View {
16 | ComposeView()
17 | .ignoresSafeArea(.keyboard)
18 | }
19 | }
20 |
21 |
22 |
23 |
--------------------------------------------------------------------------------
/shared/data/database/src/commonMain/kotlin/dev/ohoussein/cryptoapp/data/database/DatabaseModule.kt:
--------------------------------------------------------------------------------
1 | package dev.ohoussein.cryptoapp.data.database
2 |
3 | import dev.ohoussein.cryptoapp.data.database.crypto.DBModelMapper
4 | import org.koin.core.module.Module
5 | import org.koin.core.module.dsl.factoryOf
6 | import org.koin.core.module.dsl.singleOf
7 | import org.koin.dsl.module
8 |
9 | val databaseModule = module {
10 | includes(platformDatabaseModule)
11 |
12 | singleOf(::buildCryptoDB)
13 | factoryOf(::DBModelMapper)
14 | }
15 |
16 | expect val platformDatabaseModule: Module
17 |
--------------------------------------------------------------------------------
/shared/crypto/data/build.gradle.kts:
--------------------------------------------------------------------------------
1 | plugins {
2 | id("dev.ohoussein.cryptoapp.kotlin.multiplatform.library")
3 | }
4 |
5 | kotlin {
6 | sourceSets {
7 | commonMain.dependencies {
8 | implementation(libs.core.kotlin.coroutines.core)
9 | implementation(libs.koin.core)
10 | implementation(project(":shared:data:network"))
11 | implementation(project(":shared:data:database"))
12 | implementation(project(":shared:data:cache"))
13 | implementation(project(":shared:crypto:domain"))
14 | }
15 | }
16 | }
17 |
--------------------------------------------------------------------------------
/app-android/src/main/res/values/themes.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
10 |
11 |
12 |
13 |
14 |
--------------------------------------------------------------------------------
/shared/crypto/domain/src/commonMain/kotlin/dev/ohoussein/cryptoapp/crypto/domain/usecase/GetTopCryptoListUseCase.kt:
--------------------------------------------------------------------------------
1 | package dev.ohoussein.cryptoapp.crypto.domain.usecase
2 |
3 | import dev.ohoussein.cryptoapp.crypto.domain.model.CryptoModel
4 | import kotlinx.coroutines.flow.Flow
5 | import kotlin.experimental.ExperimentalObjCName
6 | import kotlin.native.ObjCName
7 |
8 | @OptIn(ExperimentalObjCName::class)
9 | @ObjCName(name = "GetTopCryptoListUseCase", exact = true)
10 | interface GetTopCryptoListUseCase {
11 | fun observe(): Flow>
12 |
13 | suspend fun refresh()
14 | }
15 |
--------------------------------------------------------------------------------
/app-android/src/main/java/dev/ohoussein/cryptoapp/App.kt:
--------------------------------------------------------------------------------
1 | package dev.ohoussein.cryptoapp
2 |
3 | import android.app.Application
4 | import dev.ohoussein.cryptoapp.presentation.sharedPresentationModules
5 | import org.koin.android.ext.koin.androidContext
6 | import org.koin.core.context.startKoin
7 |
8 | class App : Application() {
9 |
10 | override fun onCreate() {
11 | super.onCreate()
12 | startDI()
13 | }
14 |
15 | private fun startDI() {
16 | startKoin {
17 | androidContext(this@App)
18 | modules(sharedPresentationModules)
19 | }
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/shared/data/database/src/commonMain/kotlin/dev/ohoussein/cryptoapp/data/database/crypto/CryptoDAO.kt:
--------------------------------------------------------------------------------
1 | package dev.ohoussein.cryptoapp.data.database.crypto
2 |
3 | import dev.ohoussein.cryptoapp.crypto.domain.model.CryptoDetailsModel
4 | import dev.ohoussein.cryptoapp.crypto.domain.model.CryptoModel
5 | import kotlinx.coroutines.flow.Flow
6 |
7 | interface CryptoDAO {
8 | suspend fun insert(cryptoList: List)
9 | fun selectAll(): Flow>
10 |
11 | suspend fun insert(cryptoDetails: CryptoDetailsModel)
12 | fun selectDetails(cryptoDetailsId: String): Flow
13 | }
14 |
--------------------------------------------------------------------------------
/shared/data/cache/src/commonMain/kotlin/dev/ohoussein/cryptoapp/data/cache/CachedDataRepository.kt:
--------------------------------------------------------------------------------
1 | package dev.ohoussein.cryptoapp.data.cache
2 |
3 | import kotlinx.coroutines.flow.Flow
4 |
5 | class CachedDataRepository(
6 | private val updater: suspend (Key) -> Data,
7 | private val cacheStreamer: (Key) -> Flow,
8 | private val cacheWriter: suspend (Key, Data) -> Unit,
9 | ) {
10 |
11 | fun stream(key: Key): Flow = cacheStreamer(key)
12 |
13 | suspend fun refresh(key: Key) {
14 | val newData = updater(key)
15 | cacheWriter(key, newData)
16 | }
17 | }
18 |
--------------------------------------------------------------------------------
/shared/data/database/src/iosMain/kotlin/dev/ohoussein/cryptoapp/data/database/PlatformDatabaseModule.kt:
--------------------------------------------------------------------------------
1 | package dev.ohoussein.cryptoapp.data.database
2 |
3 | import app.cash.sqldelight.db.SqlDriver
4 | import dev.ohoussein.cryptoapp.data.database.crypto.CryptoDAO
5 | import dev.ohoussein.cryptoapp.data.database.crypto.CryptoDAOImpl
6 | import kotlinx.coroutines.Dispatchers
7 | import org.koin.dsl.module
8 |
9 | actual val platformDatabaseModule = module {
10 | factory { DatabaseDriverFactory().createDriver() }
11 | single {
12 | CryptoDAOImpl(get(), Dispatchers.Default, get())
13 | }
14 | }
15 |
--------------------------------------------------------------------------------
/e2e_tests/ios/crypto_details.yml:
--------------------------------------------------------------------------------
1 | appId: dev.ohoussein.cryptoapp
2 | ---
3 | - tapOn: "Bitcoin"
4 | - assertVisible: "Bitcoin (BTC)"
5 | - takeScreenshot: screenshots/ios_crypto_details
6 | - assertVisible: "Bitcoin is the first successful internet money.*"
7 | - assertVisible: "Expand"
8 | - assertVisible: "LINKS"
9 | - tapOn: "Home page"
10 | - assertVisible: "Buy Bitcoin"
11 | - tapOn: ".*CryptoAppiOS"
12 | - tapOn: "Blockchain"
13 | - assertVisible: "Blockchain size"
14 | - tapOn: ".*CryptoAppiOS"
15 | - tapOn: "Source code"
16 | - assertVisible: "Bitcoin Core integration/staging tree"
17 | - tapOn: ".*CryptoAppiOS"
18 | - tapOn: "Back"
19 |
--------------------------------------------------------------------------------
/shared/data/database/src/desktopMain/kotlin/dev/ohoussein/cryptoapp/data/database/PlatformDatabaseModule.kt:
--------------------------------------------------------------------------------
1 | package dev.ohoussein.cryptoapp.data.database
2 |
3 | import app.cash.sqldelight.db.SqlDriver
4 | import dev.ohoussein.cryptoapp.data.database.crypto.CryptoDAO
5 | import dev.ohoussein.cryptoapp.data.database.crypto.CryptoDAOImpl
6 | import kotlinx.coroutines.Dispatchers
7 | import org.koin.dsl.module
8 |
9 | actual val platformDatabaseModule = module {
10 | factory {
11 | DatabaseDriverFactory().createDriver()
12 | }
13 | single {
14 | CryptoDAOImpl(get(), Dispatchers.IO, get())
15 | }
16 | }
17 |
--------------------------------------------------------------------------------
/shared/data/database/src/androidMain/kotlin/dev/ohoussein/cryptoapp/data/database/PlatformDatabaseModule.kt:
--------------------------------------------------------------------------------
1 | package dev.ohoussein.cryptoapp.data.database
2 |
3 | import app.cash.sqldelight.db.SqlDriver
4 | import dev.ohoussein.cryptoapp.data.database.crypto.CryptoDAO
5 | import dev.ohoussein.cryptoapp.data.database.crypto.CryptoDAOImpl
6 | import kotlinx.coroutines.Dispatchers
7 | import org.koin.dsl.module
8 |
9 | actual val platformDatabaseModule = module {
10 | factory {
11 | DatabaseDriverFactory(get()).createDriver()
12 | }
13 | single {
14 | CryptoDAOImpl(get(), Dispatchers.IO, get())
15 | }
16 | }
17 |
--------------------------------------------------------------------------------
/e2e_tests/android/crypto_details.yml:
--------------------------------------------------------------------------------
1 | appId: dev.ohoussein.cryptoapp
2 | ---
3 | - tapOn: "Bitcoin"
4 | - assertVisible: "Bitcoin (BTC)"
5 | - takeScreenshot: screenshots/android_crypto_details
6 | - assertVisible: "Bitcoin is the first successful internet money.*"
7 | - assertVisible: "SEE MORE"
8 | - assertVisible: "Links"
9 | - tapOn: "Home page"
10 | - waitForAnimationToEnd
11 | - assertVisible: "bitcoin.org/.*"
12 | - back
13 | - tapOn: "Blockchain"
14 | - waitForAnimationToEnd
15 | - assertVisible: "blockchair.com/bitcoin.*"
16 | - back
17 | - tapOn: "Source code"
18 | - waitForAnimationToEnd
19 | - assertVisible: "github.com/bitcoin/.*"
20 | - back
21 | - back
22 |
--------------------------------------------------------------------------------
/shared/crypto/data/src/commonMain/kotlin/dev/ohoussein/cryptoapp/crypto/data/CryptoDataModule.kt:
--------------------------------------------------------------------------------
1 | package dev.ohoussein.cryptoapp.crypto.data
2 |
3 | import dev.ohoussein.cryptoapp.crypto.data.mapper.ApiDomainModelMapper
4 | import dev.ohoussein.cryptoapp.crypto.data.repository.CryptoRepository
5 | import dev.ohoussein.cryptoapp.crypto.domain.repo.ICryptoRepository
6 | import org.koin.core.module.dsl.bind
7 | import org.koin.core.module.dsl.factoryOf
8 | import org.koin.core.module.dsl.singleOf
9 | import org.koin.dsl.module
10 |
11 | val cryptoDataModule = module {
12 | singleOf(::CryptoRepository) { bind() }
13 | factoryOf(::ApiDomainModelMapper)
14 | }
15 |
--------------------------------------------------------------------------------
/shared/core/formatter/src/commonTest/kotlin/dev/ohoussein/cryptoapp/core/formatter/CommonPriceFormatterTest.kt:
--------------------------------------------------------------------------------
1 | package dev.ohoussein.cryptoapp.core.formatter
2 |
3 | import kotlin.test.Test
4 | import kotlin.test.assertEquals
5 |
6 | private const val CURRENCY = "USD"
7 |
8 | class CommonPriceFormatterTest {
9 |
10 | private val priceFormatter = getPriceFormatter("en")
11 |
12 | @Test
13 | fun should_format_a_price_without_fraction() {
14 | assertEquals("$120", priceFormatter(120.0, CURRENCY))
15 | }
16 |
17 | @Test
18 | fun should_format_a_price_with_fraction() {
19 | assertEquals("$120.51", priceFormatter(120.51, CURRENCY))
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/shared/crypto/domain/src/commonMain/kotlin/dev/ohoussein/cryptoapp/crypto/domain/usecase/GetTopCryptoListUseCaseImpl.kt:
--------------------------------------------------------------------------------
1 | package dev.ohoussein.cryptoapp.crypto.domain.usecase
2 |
3 | import dev.ohoussein.cryptoapp.crypto.domain.model.CryptoModel
4 | import dev.ohoussein.cryptoapp.crypto.domain.repo.ICryptoRepository
5 | import kotlinx.coroutines.flow.Flow
6 |
7 | class GetTopCryptoListUseCaseImpl(
8 | private val repository: ICryptoRepository,
9 | ) : GetTopCryptoListUseCase {
10 |
11 | override fun observe(): Flow> {
12 | return repository.getTopCryptoList()
13 | }
14 |
15 | override suspend fun refresh() {
16 | repository.refreshTopCryptoList()
17 | }
18 | }
19 |
--------------------------------------------------------------------------------
/shared/data/cache/src/commonTest/kotlin/dev/ohoussein/cryptoapp/data/cache/utils/FakeTimeSource.kt:
--------------------------------------------------------------------------------
1 | package dev.ohoussein.cryptoapp.data.cache.utils
2 |
3 | import kotlin.time.Duration
4 | import kotlin.time.Duration.Companion.milliseconds
5 | import kotlin.time.TimeMark
6 | import kotlin.time.TimeSource
7 |
8 | class FakeTimeSource : TimeSource {
9 | private var currentTimeMs: Long = 0
10 |
11 | override fun markNow(): TimeMark = object : TimeMark {
12 | override fun elapsedNow(): Duration {
13 | return currentTimeMs.milliseconds
14 | }
15 | }
16 |
17 | operator fun plusAssign(duration: Duration) {
18 | currentTimeMs += duration.inWholeMilliseconds
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/shared/core/router/src/iosMain/kotlin/dev/ohoussein/cryptoapp/core/router/RouterImpl.ios.kt:
--------------------------------------------------------------------------------
1 | package dev.ohoussein.cryptoapp.core.router
2 |
3 | import platform.Foundation.NSURL
4 | import platform.UIKit.UIApplication
5 |
6 | class RouterImpl : Router {
7 | override fun openUrl(url: String) {
8 | runCatching {
9 | UIApplication.sharedApplication.openURL(
10 | url = NSURL(string = url, encodingInvalidCharacters = true),
11 | options = emptyMap(),
12 | completionHandler = null
13 | )
14 | }.onFailure {
15 | println("Errr opening url $url: $it")
16 | it.printStackTrace()
17 | }
18 | }
19 | }
20 |
--------------------------------------------------------------------------------
/shared/data/network/src/commonTest/kotlin/dev/ohoussein/cryptoapp/data/network/crypto/service/utils/HttpMock.kt:
--------------------------------------------------------------------------------
1 | package dev.ohoussein.cryptoapp.data.network.crypto.service.utils
2 |
3 | import dev.ohoussein.cryptoapp.data.network.NetworkBuilder
4 | import io.ktor.client.engine.mock.MockEngine
5 | import io.ktor.client.engine.mock.respond
6 | import io.ktor.http.HttpHeaders
7 | import io.ktor.http.headersOf
8 | import io.ktor.utils.io.ByteReadChannel
9 |
10 | fun mockedHttpClient(response: String) = NetworkBuilder.httpClient(
11 | MockEngine {
12 | respond(
13 | content = ByteReadChannel(response),
14 | headers = headersOf(HttpHeaders.ContentType, "application/json")
15 | )
16 | }
17 | )
18 |
--------------------------------------------------------------------------------
/shared/data/network/src/commonMain/kotlin/dev/ohoussein/cryptoapp/data/network/crypto/service/ApiCryptoService.kt:
--------------------------------------------------------------------------------
1 | package dev.ohoussein.cryptoapp.data.network.crypto.service
2 |
3 | import dev.ohoussein.cryptoapp.data.network.crypto.model.CryptoDetailsResponse
4 | import dev.ohoussein.cryptoapp.data.network.crypto.model.HistoricalPricesDTO
5 | import dev.ohoussein.cryptoapp.data.network.crypto.model.TopCryptoResponse
6 |
7 | interface ApiCryptoService {
8 |
9 | suspend fun getTopCrypto(vsCurrency: String): List
10 |
11 | suspend fun getCryptoDetails(cryptoId: String): CryptoDetailsResponse
12 |
13 | suspend fun getHistoricalPrices(vsCurrency: String, cryptoId: String, days: Int): HistoricalPricesDTO
14 | }
15 |
--------------------------------------------------------------------------------
/shared/core/formatter/src/androidMain/kotlin/dev/ohoussein/cryptoapp/core/formatter/AndroidPercentFormatter.kt:
--------------------------------------------------------------------------------
1 | package dev.ohoussein.cryptoapp.core.formatter
2 |
3 | import java.text.NumberFormat
4 | import java.util.Locale
5 |
6 | class AndroidPercentFormatter(private val localeId: String) : PercentFormatter {
7 |
8 | override operator fun invoke(percent: Double): String {
9 | val local = Locale.forLanguageTag(localeId)
10 | return NumberFormat.getPercentInstance(local).run {
11 | minimumFractionDigits = 0
12 | maximumFractionDigits = 1
13 | format(percent)
14 | }
15 | }
16 | }
17 |
18 | actual fun getPercentFormatter(localeId: String): PercentFormatter = AndroidPercentFormatter(localeId)
19 |
--------------------------------------------------------------------------------
/shared/core/formatter/src/desktopMain/kotlin/dev/ohoussein/cryptoapp/core/formatter/DesktopPercentFormatter.kt:
--------------------------------------------------------------------------------
1 | package dev.ohoussein.cryptoapp.core.formatter
2 |
3 | import java.text.NumberFormat
4 | import java.util.Locale
5 |
6 | class DesktopPercentFormatter(private val localeId: String) : PercentFormatter {
7 |
8 | override operator fun invoke(percent: Double): String {
9 | val local = Locale.forLanguageTag(localeId)
10 | return NumberFormat.getPercentInstance(local).run {
11 | minimumFractionDigits = 0
12 | maximumFractionDigits = 1
13 | format(percent)
14 | }
15 | }
16 | }
17 |
18 | actual fun getPercentFormatter(localeId: String): PercentFormatter = DesktopPercentFormatter(localeId)
19 |
--------------------------------------------------------------------------------
/shared/crypto/presentation/src/commonMain/kotlin/dev/ohoussein/cryptoapp/crypto/presentation/core/DatetimeExt.kt:
--------------------------------------------------------------------------------
1 | package dev.ohoussein.cryptoapp.crypto.presentation.core
2 |
3 | import kotlinx.datetime.LocalDateTime
4 |
5 | @Suppress("LongParameterList")
6 | fun LocalDateTime.copy(
7 | year: Int = this.year,
8 | monthNumber: Int = this.monthNumber,
9 | dayOfMonth: Int = this.dayOfMonth,
10 | hour: Int = this.hour,
11 | minute: Int = this.minute,
12 | second: Int = this.second,
13 | nanosecond: Int = this.nanosecond,
14 | ) = LocalDateTime(
15 | year = year,
16 | monthNumber = monthNumber,
17 | dayOfMonth = dayOfMonth,
18 | hour = hour,
19 | minute = minute,
20 | second = second,
21 | nanosecond = nanosecond
22 | )
23 |
--------------------------------------------------------------------------------
/shared/presentation/src/commonMain/kotlin/dev/ohoussein/cryptoapp/presentation/App.kt:
--------------------------------------------------------------------------------
1 | import androidx.compose.runtime.Composable
2 | import androidx.navigation.compose.NavHost
3 | import androidx.navigation.compose.rememberNavController
4 | import dev.ohoussein.cryptoapp.crypto.presentation.CryptoFeatNavPath
5 | import dev.ohoussein.cryptoapp.crypto.presentation.nav.cryptoAppNavigation
6 | import org.koin.compose.KoinContext
7 |
8 | @Composable
9 | fun SharedApp() {
10 | KoinContext {
11 | val navController = rememberNavController()
12 | NavHost(
13 | navController = navController,
14 | startDestination = CryptoFeatNavPath.HOME,
15 | ) {
16 | cryptoAppNavigation(navController)
17 | }
18 | }
19 | }
20 |
--------------------------------------------------------------------------------
/shared/core/formatter/src/commonTest/kotlin/dev/ohoussein/cryptoapp/core/formatter/CommonPercentFormatterTest.kt:
--------------------------------------------------------------------------------
1 | package dev.ohoussein.cryptoapp.core.formatter
2 |
3 | import kotlin.test.Test
4 | import kotlin.test.assertEquals
5 |
6 | class CommonPercentFormatterTest {
7 |
8 | private val percentFormatter = getPercentFormatter("en")
9 |
10 | @Test
11 | fun should_format_a_percent_without_fraction() {
12 | assertEquals("41%", percentFormatter(0.41))
13 | }
14 |
15 | @Test
16 | fun should_format_a_percent_with_1_fractions() {
17 | assertEquals("12.3%", percentFormatter(0.123))
18 | }
19 |
20 | @Test
21 | fun should_format_a_percent_with_2_fractions() {
22 | assertEquals("12.3%", percentFormatter(0.1234))
23 | }
24 | }
25 |
--------------------------------------------------------------------------------
/shared/core/router/src/androidMain/kotlin/dev/ohoussein/cryptoapp/core/router/RouterImpl.android.kt:
--------------------------------------------------------------------------------
1 | package dev.ohoussein.cryptoapp.core.router
2 |
3 | import android.content.Context
4 | import android.content.Intent
5 | import android.content.Intent.FLAG_ACTIVITY_NEW_TASK
6 | import android.net.Uri
7 |
8 | class RouterImpl(private val context: Context) : Router {
9 | override fun openUrl(url: String) {
10 | runCatching {
11 | context.startActivity(
12 | Intent(Intent.ACTION_VIEW, Uri.parse(url)).apply {
13 | addFlags(FLAG_ACTIVITY_NEW_TASK)
14 | }
15 | )
16 | }.onFailure {
17 | println("Errr opening url $url: $it")
18 | it.printStackTrace()
19 | }
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/shared/core/formatter/src/androidMain/kotlin/dev/ohoussein/cryptoapp/core/formatter/AndroidPriceFormatter.kt:
--------------------------------------------------------------------------------
1 | package dev.ohoussein.cryptoapp.core.formatter
2 |
3 | import java.text.NumberFormat
4 | import java.util.Currency
5 | import java.util.Locale
6 |
7 | class AndroidPriceFormatter(private val localeId: String) : PriceFormatter {
8 |
9 | override fun invoke(price: Double, currencyCode: String): String {
10 | val local = Locale.forLanguageTag(localeId)
11 | return NumberFormat.getCurrencyInstance(local).run {
12 | currency = Currency.getInstance(currencyCode)
13 | minimumFractionDigits = 0
14 | format(price)
15 | }
16 | }
17 | }
18 |
19 | actual fun getPriceFormatter(localeId: String): PriceFormatter = AndroidPriceFormatter(localeId)
20 |
--------------------------------------------------------------------------------
/shared/core/formatter/src/desktopMain/kotlin/dev/ohoussein/cryptoapp/core/formatter/DesktopPriceFormatter.kt:
--------------------------------------------------------------------------------
1 | package dev.ohoussein.cryptoapp.core.formatter
2 |
3 | import java.text.NumberFormat
4 | import java.util.Currency
5 | import java.util.Locale
6 |
7 | class DesktopPriceFormatter(private val localeId: String) : PriceFormatter {
8 |
9 | override fun invoke(price: Double, currencyCode: String): String {
10 | val local = Locale.forLanguageTag(localeId)
11 | return NumberFormat.getCurrencyInstance(local).run {
12 | currency = Currency.getInstance(currencyCode)
13 | minimumFractionDigits = 0
14 | format(price)
15 | }
16 | }
17 | }
18 |
19 | actual fun getPriceFormatter(localeId: String): PriceFormatter = DesktopPriceFormatter(localeId)
20 |
--------------------------------------------------------------------------------
/shared/crypto/domain/src/commonMain/kotlin/dev/ohoussein/cryptoapp/crypto/domain/repo/ICryptoRepository.kt:
--------------------------------------------------------------------------------
1 | package dev.ohoussein.cryptoapp.crypto.domain.repo
2 |
3 | import dev.ohoussein.cryptoapp.crypto.domain.model.CryptoDetailsModel
4 | import dev.ohoussein.cryptoapp.crypto.domain.model.CryptoModel
5 | import dev.ohoussein.cryptoapp.crypto.domain.model.HistoricalPrice
6 | import kotlinx.coroutines.flow.Flow
7 |
8 | interface ICryptoRepository {
9 |
10 | fun getTopCryptoList(): Flow>
11 |
12 | suspend fun refreshTopCryptoList()
13 |
14 | fun getCryptoDetails(cryptoId: String): Flow
15 |
16 | suspend fun refreshCryptoDetails(cryptoId: String)
17 |
18 | suspend fun getHistoricalPrices(cryptoId: String, days: Int): Result>
19 | }
20 |
--------------------------------------------------------------------------------
/shared/crypto/domain/src/commonMain/kotlin/dev/ohoussein/cryptoapp/crypto/domain/usecase/GetCryptoDetailsUseCase.kt:
--------------------------------------------------------------------------------
1 | package dev.ohoussein.cryptoapp.crypto.domain.usecase
2 |
3 | import dev.ohoussein.cryptoapp.crypto.domain.model.CryptoDetailsModel
4 | import dev.ohoussein.cryptoapp.crypto.domain.model.HistoricalPrice
5 | import kotlinx.coroutines.flow.Flow
6 | import kotlin.experimental.ExperimentalObjCName
7 | import kotlin.native.ObjCName
8 |
9 | @OptIn(ExperimentalObjCName::class)
10 | @ObjCName(name = "GetCryptoDetailsUseCase", exact = true)
11 | interface GetCryptoDetailsUseCase {
12 | fun observe(cryptoId: String): Flow
13 |
14 | suspend fun refresh(cryptoId: String)
15 |
16 | suspend fun getHistoricalPrices(cryptoId: String, days: Int): Result>
17 | }
18 |
--------------------------------------------------------------------------------
/shared/crypto/presentation/src/commonMain/kotlin/dev/ohoussein/cryptoapp/crypto/presentation/core/ViewModel.kt:
--------------------------------------------------------------------------------
1 | package dev.ohoussein.cryptoapp.crypto.presentation.core
2 |
3 | import kotlinx.coroutines.CoroutineScope
4 | import kotlinx.coroutines.Dispatchers
5 | import kotlinx.coroutines.flow.MutableStateFlow
6 | import kotlinx.coroutines.flow.StateFlow
7 | import androidx.lifecycle.ViewModel as StateHolderViewModel
8 |
9 | abstract class ViewModel(initialState: State) : StateHolderViewModel() {
10 | val state: StateFlow
11 | get() = mutableState
12 |
13 | protected val mutableState: MutableStateFlow = MutableStateFlow(initialState)
14 |
15 | protected val viewModelScope = CoroutineScope(Dispatchers.Main)
16 |
17 | abstract fun dispatch(event: Event)
18 | }
19 |
--------------------------------------------------------------------------------
/settings.gradle.kts:
--------------------------------------------------------------------------------
1 | pluginManagement {
2 | includeBuild("build-logic")
3 | repositories {
4 | google()
5 | mavenCentral()
6 | gradlePluginPortal()
7 | }
8 | }
9 | plugins {
10 | id("org.gradle.toolchains.foojay-resolver-convention") version "0.9.0"
11 | }
12 | rootProject.name = "cryptoapp"
13 |
14 | include(":app-android")
15 | include(":app-desktop")
16 | include(":shared:data:cache")
17 | include(":shared:data:network")
18 | include(":shared:data:database")
19 | include(":shared:crypto:domain")
20 | include(":shared:crypto:data")
21 | include(":shared:core:formatter")
22 | include(":shared:core:router")
23 | include(":shared:presentation")
24 | include(":shared:designsystem")
25 | include("shared:crypto:presentation")
26 | findProject(":shared:crypto:presentation")?.name = "presentation"
27 |
--------------------------------------------------------------------------------
/shared/crypto/presentation/src/commonMain/composeResources/values/strings.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 | RETRY
4 | SEE MORE
5 | SEE LESS
6 | Links
7 | Home page
8 | Source code
9 | Blockchain
10 |
11 | 1D
12 | 7D
13 | 1M
14 | 3M
15 | 1Y
16 |
--------------------------------------------------------------------------------
/shared/presentation/src/desktopMain/kotlin/AppFactory.kt:
--------------------------------------------------------------------------------
1 | import androidx.compose.ui.window.Window
2 | import androidx.compose.ui.window.application
3 | import cryptoapp.shared.presentation.generated.resources.Res
4 | import cryptoapp.shared.presentation.generated.resources.app_icon_256
5 | import dev.ohoussein.cryptoapp.presentation.sharedPresentationModules
6 | import org.jetbrains.compose.resources.painterResource
7 | import org.koin.core.context.startKoin
8 |
9 | fun createApp() {
10 | startKoin {
11 | modules(sharedPresentationModules)
12 | }
13 | application {
14 | Window(
15 | title = "CryptoApp",
16 | onCloseRequest = ::exitApplication,
17 | icon = painterResource(Res.drawable.app_icon_256),
18 | ) {
19 | SharedApp()
20 | }
21 | }
22 | }
23 |
--------------------------------------------------------------------------------
/shared/crypto/presentation/src/commonTest/kotlin/dev/ohoussein/cryptoapp/crypto/presentation/fake/FakeGetTopCryptoListUseCase.kt:
--------------------------------------------------------------------------------
1 | package dev.ohoussein.cryptoapp.crypto.presentation.fake
2 |
3 | import dev.ohoussein.cryptoapp.crypto.domain.model.CryptoModel
4 | import dev.ohoussein.cryptoapp.crypto.domain.model.FakeCryptoModel
5 | import dev.ohoussein.cryptoapp.crypto.domain.usecase.GetTopCryptoListUseCase
6 | import kotlinx.coroutines.flow.Flow
7 | import kotlinx.coroutines.flow.flowOf
8 |
9 | class FakeGetTopCryptoListUseCase : GetTopCryptoListUseCase {
10 | val cryptoList = FakeCryptoModel.cryptoList(5)
11 | var shouldThrowOnRefresh = false
12 |
13 | override fun observe(): Flow> {
14 | return flowOf(cryptoList)
15 | }
16 |
17 | override suspend fun refresh() {
18 | if (shouldThrowOnRefresh) {
19 | throw Error()
20 | }
21 | }
22 | }
23 |
--------------------------------------------------------------------------------
/shared/core/formatter/src/iosMain/kotlin/dev/ohoussein/cryptoapp/core/formatter/IOSPercentFormatter.kt:
--------------------------------------------------------------------------------
1 | package dev.ohoussein.cryptoapp.core.formatter
2 |
3 | import platform.Foundation.NSLocale
4 | import platform.Foundation.NSNumber
5 | import platform.Foundation.NSNumberFormatter
6 | import platform.Foundation.NSNumberFormatterPercentStyle
7 |
8 | class IOSPercentFormatter(private val localeId: String) : PercentFormatter {
9 |
10 | override operator fun invoke(percent: Double): String {
11 | return NSNumberFormatter().run {
12 | numberStyle = NSNumberFormatterPercentStyle
13 | locale = NSLocale(localeId)
14 | minimumFractionDigits = 0u
15 | maximumFractionDigits = 1u
16 | stringFromNumber(NSNumber(percent))!!
17 | }
18 | }
19 | }
20 |
21 | actual fun getPercentFormatter(localeId: String): PercentFormatter = IOSPercentFormatter(localeId)
22 |
--------------------------------------------------------------------------------
/shared/presentation/src/commonMain/kotlin/dev/ohoussein/cryptoapp/presentation/SharedPresentationModules.kt:
--------------------------------------------------------------------------------
1 | package dev.ohoussein.cryptoapp.presentation
2 |
3 | import dev.ohoussein.cryptoapp.core.formatter.formatModule
4 | import dev.ohoussein.cryptoapp.core.router.routerModule
5 | import dev.ohoussein.cryptoapp.crypto.data.cryptoDataModule
6 | import dev.ohoussein.cryptoapp.crypto.domain.cryptoDomainModule
7 | import dev.ohoussein.cryptoapp.crypto.presentation.di.cryptoModule
8 | import dev.ohoussein.cryptoapp.data.database.databaseModule
9 | import dev.ohoussein.cryptoapp.data.network.networkModule
10 | import org.koin.dsl.module
11 |
12 | val sharedPresentationModules = module {
13 | includes(
14 | formatModule,
15 | databaseModule,
16 | networkModule,
17 | cryptoDomainModule,
18 | cryptoDataModule,
19 | routerModule,
20 | )
21 | includes(cryptoModule)
22 | }
23 |
--------------------------------------------------------------------------------
/shared/core/formatter/src/iosMain/kotlin/dev/ohoussein/cryptoapp/core/formatter/IOSPriceFormatter.kt:
--------------------------------------------------------------------------------
1 | package dev.ohoussein.cryptoapp.core.formatter
2 |
3 | import platform.Foundation.NSLocale
4 | import platform.Foundation.NSNumber
5 | import platform.Foundation.NSNumberFormatter
6 | import platform.Foundation.NSNumberFormatterCurrencyStyle
7 |
8 | class IOSPriceFormatter(private val localeId: String) : PriceFormatter {
9 |
10 | override fun invoke(price: Double, currencyCode: String): String {
11 | return NSNumberFormatter().run {
12 | numberStyle = NSNumberFormatterCurrencyStyle
13 | locale = NSLocale(localeId)
14 | minimumFractionDigits = 0u
15 | this.currencyCode = currencyCode
16 | stringFromNumber(NSNumber(price))!!
17 | }
18 | }
19 | }
20 |
21 | actual fun getPriceFormatter(localeId: String): PriceFormatter = IOSPriceFormatter(localeId)
22 |
--------------------------------------------------------------------------------
/shared/designsystem/build.gradle.kts:
--------------------------------------------------------------------------------
1 | plugins {
2 | id("dev.ohoussein.cryptoapp.kotlin.multiplatform.library")
3 | alias(libs.plugins.kotlinSerialization)
4 | alias(libs.plugins.jetbrainsCompose)
5 | alias(libs.plugins.compose.compiler)
6 | }
7 |
8 | kotlin {
9 | sourceSets {
10 | androidMain.dependencies {
11 | implementation(libs.compose.ui.tooling)
12 | implementation(compose.preview)
13 | }
14 | commonMain.dependencies {
15 | implementation(compose.runtime)
16 | implementation(compose.foundation)
17 | implementation(compose.material)
18 | implementation(compose.ui)
19 | implementation(compose.components.resources)
20 | implementation(compose.components.uiToolingPreview)
21 | implementation(libs.koin.compose)
22 | implementation(libs.koin.core)
23 | implementation(libs.coil.compose)
24 | }
25 | }
26 | }
27 |
--------------------------------------------------------------------------------
/shared/data/network/build.gradle.kts:
--------------------------------------------------------------------------------
1 | plugins {
2 | id("dev.ohoussein.cryptoapp.kotlin.multiplatform.library")
3 | kotlin("plugin.serialization") version libs.versions.kotlin
4 | }
5 |
6 | kotlin {
7 | sourceSets {
8 | commonMain.dependencies {
9 | implementation(libs.koin.core)
10 | implementation(libs.data.ktor.core)
11 | implementation(libs.data.ktor.logging)
12 | implementation(libs.data.ktor.content.negotiation)
13 | implementation(libs.data.ktor.json)
14 | }
15 |
16 | commonTest.dependencies {
17 | implementation(libs.data.ktor.mock)
18 | }
19 |
20 | androidMain.dependencies {
21 | implementation(libs.data.ktor.android)
22 | }
23 |
24 | iosMain.dependencies {
25 | implementation(libs.data.ktor.ios)
26 | }
27 |
28 | desktopMain.dependencies {
29 | implementation(libs.data.ktor.apache)
30 | }
31 | }
32 | }
33 |
--------------------------------------------------------------------------------
/shared/crypto/domain/src/commonMain/kotlin/dev/ohoussein/cryptoapp/crypto/domain/usecase/GetCryptoDetailsUseCaseImpl.kt:
--------------------------------------------------------------------------------
1 | package dev.ohoussein.cryptoapp.crypto.domain.usecase
2 |
3 | import dev.ohoussein.cryptoapp.crypto.domain.model.CryptoDetailsModel
4 | import dev.ohoussein.cryptoapp.crypto.domain.model.HistoricalPrice
5 | import dev.ohoussein.cryptoapp.crypto.domain.repo.ICryptoRepository
6 | import kotlinx.coroutines.flow.Flow
7 |
8 | class GetCryptoDetailsUseCaseImpl(
9 | private val repository: ICryptoRepository,
10 | ) : GetCryptoDetailsUseCase {
11 |
12 | override fun observe(cryptoId: String): Flow {
13 | return repository.getCryptoDetails(cryptoId)
14 | }
15 |
16 | override suspend fun refresh(cryptoId: String) {
17 | repository.refreshCryptoDetails(cryptoId)
18 | }
19 |
20 | override suspend fun getHistoricalPrices(cryptoId: String, days: Int): Result> {
21 | return repository.getHistoricalPrices(cryptoId, days)
22 | }
23 | }
24 |
--------------------------------------------------------------------------------
/shared/designsystem/src/commonMain/kotlin/dev/ohoussein/cryptoapp/designsystem/base/LinkText.kt:
--------------------------------------------------------------------------------
1 | package dev.ohoussein.cryptoapp.designsystem.base
2 |
3 | import androidx.compose.material.LocalTextStyle
4 | import androidx.compose.material.Text
5 | import androidx.compose.runtime.Composable
6 | import androidx.compose.runtime.remember
7 | import androidx.compose.ui.Modifier
8 | import androidx.compose.ui.text.TextLayoutResult
9 | import androidx.compose.ui.text.TextStyle
10 | import dev.ohoussein.cryptoapp.designsystem.core.htmlToAnnotatedString
11 |
12 | @Composable
13 | fun LinkText(
14 | htmlText: String,
15 | modifier: Modifier = Modifier,
16 | style: TextStyle = LocalTextStyle.current,
17 | maxLines: Int = Int.MAX_VALUE,
18 | onTextLayout: (TextLayoutResult) -> Unit = {},
19 | ) {
20 | val annotatedString = remember(htmlText) { htmlText.htmlToAnnotatedString() }
21 |
22 | Text(
23 | text = annotatedString,
24 | style = style,
25 | modifier = modifier,
26 | maxLines = maxLines,
27 | onTextLayout = onTextLayout,
28 | )
29 | }
30 |
--------------------------------------------------------------------------------
/shared/crypto/domain/src/commonMain/kotlin/dev/ohoussein/cryptoapp/crypto/domain/model/CryptoModel.kt:
--------------------------------------------------------------------------------
1 | @file:OptIn(ExperimentalObjCName::class)
2 |
3 | package dev.ohoussein.cryptoapp.crypto.domain.model
4 |
5 | import kotlin.experimental.ExperimentalObjCName
6 | import kotlin.native.ObjCName
7 |
8 | @ObjCName(name = "CryptoModel", exact = true)
9 | data class CryptoModel(
10 | val id: String,
11 | val name: String,
12 | val imageUrl: String,
13 | val price: Double,
14 | val symbol: String,
15 | val priceChangePercentIn24h: Double?,
16 | val order: Int,
17 | val sparkLine7d: List?,
18 | )
19 |
20 | @ObjCName(name = "CryptoDetailsModel", exact = true)
21 | data class CryptoDetailsModel(
22 | val id: String,
23 | val name: String,
24 | val symbol: String,
25 | val imageUrl: String,
26 | val hashingAlgorithm: String?,
27 | val homePageUrl: String?,
28 | val blockchainSite: String?,
29 | val mainRepoUrl: String?,
30 | val sentimentUpVotesPercentage: Double?,
31 | val sentimentDownVotesPercentage: Double?,
32 | val description: String,
33 | )
34 |
--------------------------------------------------------------------------------
/app-android/build.gradle.kts:
--------------------------------------------------------------------------------
1 | plugins {
2 | id("com.android.application")
3 | id("kotlin-android")
4 |
5 | id("dev.ohoussein.cryptoapp.android.app")
6 | }
7 |
8 | android {
9 | namespace = "dev.ohoussein.cryptoapp"
10 |
11 | buildTypes {
12 | debug {
13 | applicationIdSuffix = ".debug"
14 | }
15 |
16 | release {
17 | isMinifyEnabled = true
18 | isShrinkResources = true
19 | proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
20 | // TO-DO signing keys
21 | signingConfig = signingConfigs.getByName("debug")
22 | }
23 | }
24 | }
25 |
26 | dependencies {
27 | implementation(project(":shared:presentation"))
28 | // Common
29 | implementation(libs.core.kotlin.coroutines.core)
30 | implementation(libs.koin.android)
31 | implementation(libs.core.kotlin.coroutines.android)
32 |
33 | // Presentation
34 | implementation(libs.android.appcompat)
35 | implementation(libs.android.compose.activity)
36 | implementation(libs.android.material)
37 | }
38 |
--------------------------------------------------------------------------------
/shared/crypto/domain/src/commonMain/kotlin/dev/ohoussein/cryptoapp/crypto/domain/CryptoDomainModule.kt:
--------------------------------------------------------------------------------
1 | package dev.ohoussein.cryptoapp.crypto.domain
2 |
3 | import dev.ohoussein.cryptoapp.crypto.domain.model.Locale
4 | import dev.ohoussein.cryptoapp.crypto.domain.model.defaultLocale
5 | import dev.ohoussein.cryptoapp.crypto.domain.usecase.GetCryptoDetailsUseCase
6 | import dev.ohoussein.cryptoapp.crypto.domain.usecase.GetCryptoDetailsUseCaseImpl
7 | import dev.ohoussein.cryptoapp.crypto.domain.usecase.GetTopCryptoListUseCase
8 | import dev.ohoussein.cryptoapp.crypto.domain.usecase.GetTopCryptoListUseCaseImpl
9 | import org.koin.core.module.dsl.bind
10 | import org.koin.core.module.dsl.factoryOf
11 | import org.koin.core.qualifier.named
12 | import org.koin.dsl.module
13 |
14 | val cryptoDomainModule = module {
15 | factoryOf(::GetTopCryptoListUseCaseImpl) {
16 | bind()
17 | }
18 | factoryOf(::GetCryptoDetailsUseCaseImpl) {
19 | bind()
20 | }
21 | single {
22 | defaultLocale
23 | }
24 | factory(named("languageCode")) { get().languageCode }
25 | }
26 |
--------------------------------------------------------------------------------
/app-android/src/main/AndroidManifest.xml:
--------------------------------------------------------------------------------
1 |
2 |
5 |
6 |
7 |
8 |
18 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
--------------------------------------------------------------------------------
/e2e_tests/generate_screenshots.sh:
--------------------------------------------------------------------------------
1 | ## Android
2 | android_device_id=$(adb devices | grep -v "List of devices" | awk '{print $1}' | head -n 1| xargs)
3 | if [[ -z "$android_device_id" ]]; then
4 | echo -e "Error: No connected android device found !"
5 | else
6 | echo "Run android screenshots on $android_device_id"
7 | adb -s "$android_device_id" shell "cmd uimode night yes"
8 | maestro --device="$android_device_id" test android/android-screenshots.yml -e test_name="dark"
9 | adb -s "$android_device_id" shell "cmd uimode night no"
10 | maestro --device="$android_device_id" test android/android-screenshots.yml -e test_name="light"
11 | fi
12 |
13 | ## iOS
14 | ios_device_id=$(xcrun simctl list | grep -E '(Booted)' | sed 's/.*(\([a-zA-Z0-9\-]\{36\}\)).*/\1/' | xargs)
15 | if [[ -z "$ios_device_id" ]]; then
16 | echo -e "Error: No connected ios device found !"
17 | else
18 | echo "Run ios screenshots on: $ios_device_id"
19 | xcrun simctl ui booted appearance dark
20 | maestro --device="$ios_device_id" test ios/ios-screenshots.yml -e test_name="dark"
21 | xcrun simctl ui booted appearance light
22 | maestro --device="$ios_device_id" test ios/ios-screenshots.yml -e test_name="light"
23 | fi
--------------------------------------------------------------------------------
/app-desktop/build.gradle.kts:
--------------------------------------------------------------------------------
1 | import org.jetbrains.compose.desktop.application.dsl.TargetFormat
2 |
3 | plugins {
4 | kotlin("jvm")
5 | alias(libs.plugins.jetbrainsCompose)
6 | alias(libs.plugins.compose.compiler)
7 | id("dev.ohoussein.cryptoapp.kotlin.detekt")
8 | }
9 |
10 | group = "dev.ohoussein.cryptoapp"
11 | version = "1.0.0"
12 |
13 | compose.desktop {
14 | application {
15 | mainClass = "dev.ohoussein.cryptoapp.MainKt"
16 |
17 | nativeDistributions {
18 | targetFormats(TargetFormat.Dmg, TargetFormat.Msi, TargetFormat.Deb)
19 | packageName = "dev.ohoussein.cryptoapp"
20 | packageVersion = "1.0.0"
21 | macOS {
22 | iconFile.set(project.file("src/main/resources/icon/app_icon.ico"))
23 | }
24 | windows {
25 | iconFile.set(project.file("src/main/resources/icon/app_icon.ico"))
26 | }
27 | linux {
28 | iconFile.set(project.file("src/main/resources/icon/app_icon.ico"))
29 | }
30 | }
31 | }
32 | }
33 |
34 | dependencies {
35 | implementation(project(":shared:presentation"))
36 | implementation(compose.runtime)
37 | }
38 |
--------------------------------------------------------------------------------
/shared/crypto/presentation/src/commonMain/kotlin/dev/ohoussein/cryptoapp/crypto/presentation/core/Math.kt:
--------------------------------------------------------------------------------
1 | package dev.ohoussein.cryptoapp.crypto.presentation.core
2 |
3 | fun List.averageValues(maxValues: Int = 20): List {
4 | if (size <= maxValues) {
5 | return this
6 | }
7 |
8 | val groupSize = size / maxValues
9 | val averagedPrices = mutableListOf()
10 |
11 | for (i in indices step groupSize) {
12 | val group = subList(i, minOf(i + groupSize, size))
13 | val avgPrice = group.average()
14 | averagedPrices.add(avgPrice)
15 | }
16 |
17 | return averagedPrices
18 | }
19 |
20 | fun interpolateValues(
21 | start: Long,
22 | end: Long,
23 | countValues: Int,
24 | ): List {
25 | val stepSize = (end - start).toDouble() / (countValues - 1)
26 | return (0 until countValues).map {
27 | start + (it * stepSize).toLong()
28 | }
29 | }
30 |
31 | fun interpolateValues(
32 | start: Double,
33 | end: Double,
34 | countValues: Int,
35 | ): List {
36 | val stepSize = (end - start) / (countValues - 1)
37 | return (0 until countValues).map {
38 | start + (it * stepSize)
39 | }
40 | }
41 |
--------------------------------------------------------------------------------
/shared/data/network/src/commonTest/kotlin/dev/ohoussein/cryptoapp/data/network/crypto/service/mocks/MockHistoricalPricesJson.kt:
--------------------------------------------------------------------------------
1 | @file:Suppress("MaxLineLength")
2 |
3 | package dev.ohoussein.cryptoapp.data.network.crypto.service.mocks
4 |
5 | val mockHistoricalPricesJson = """
6 | {
7 | "prices": [
8 | [
9 | 1711843200000,
10 | 69702.3087473573
11 | ],
12 | [
13 | 1711929600000,
14 | 71246.95144060145
15 | ],
16 | [
17 | 1711983682000,
18 | 68887.74951585678
19 | ]
20 | ],
21 | "market_caps": [
22 | [
23 | 1711843200000,
24 | 1370247487960.0945
25 | ],
26 | [
27 | 1711929600000,
28 | 1401370211582.3662
29 | ],
30 | [
31 | 1711983682000,
32 | 1355701979725.1584
33 | ]
34 | ],
35 | "total_volumes": [
36 | [
37 | 1711843200000,
38 | 16408802301.837431
39 | ],
40 | [
41 | 1711929600000,
42 | 19723005998.21497
43 | ],
44 | [
45 | 1711983682000,
46 | 30137418199.643093
47 | ]
48 | ]
49 | }
50 | """.trim()
51 |
--------------------------------------------------------------------------------
/shared/crypto/presentation/src/commonMain/kotlin/dev/ohoussein/cryptoapp/crypto/presentation/model/Crypto.kt:
--------------------------------------------------------------------------------
1 | package dev.ohoussein.cryptoapp.crypto.presentation.model
2 |
3 | import androidx.compose.runtime.Immutable
4 | import dev.ohoussein.cryptoapp.designsystem.graph.model.GraphPoint
5 |
6 | @Immutable
7 | data class CryptoInfo(
8 | val id: String,
9 | val name: String,
10 | val symbol: String,
11 | val imageUrl: String,
12 | )
13 |
14 | @Immutable
15 | data class Crypto(
16 | val info: CryptoInfo,
17 | val price: CryptoPrice,
18 | val priceChangePercentIn24h: LabelValue?,
19 | val sparkline7d: List?,
20 | )
21 |
22 | @Immutable
23 | data class CryptoPrice(
24 | val labelValue: LabelValue,
25 | )
26 |
27 | @Immutable
28 | data class LabelValue(
29 | val value: V,
30 | val label: String,
31 | )
32 |
33 | @Immutable
34 | data class CryptoDetails(
35 | val base: CryptoInfo,
36 | val hashingAlgorithm: String?,
37 | val homePageUrl: String?,
38 | val blockchainSite: String?,
39 | val mainRepoUrl: String?,
40 | val sentimentUpVotesPercentage: LabelValue?,
41 | val sentimentDownVotesPercentage: LabelValue?,
42 | val description: String,
43 | )
44 |
--------------------------------------------------------------------------------
/shared/data/database/build.gradle.kts:
--------------------------------------------------------------------------------
1 | plugins {
2 | id("dev.ohoussein.cryptoapp.kotlin.multiplatform.library")
3 | id("app.cash.sqldelight")
4 | }
5 |
6 | sqldelight {
7 | databases {
8 | create("CryptoDB") {
9 | packageName.set("dev.ohoussein.cryptoapp.database")
10 | }
11 | }
12 | }
13 |
14 | kotlin {
15 | sourceSets {
16 | commonMain.dependencies {
17 | implementation(project(":shared:crypto:domain"))
18 | implementation(libs.core.kotlin.coroutines.core)
19 | implementation(libs.data.sqldelight.ext.coroutines)
20 | implementation(libs.koin.core)
21 | }
22 |
23 | commonTest.dependencies {
24 | implementation(libs.test.coroutines)
25 | }
26 |
27 | androidMain.dependencies {
28 | implementation(libs.data.sqldelight.android)
29 | }
30 |
31 | androidUnitTest.dependencies {
32 | implementation(libs.data.sqldelight.desktop)
33 | }
34 |
35 | iosMain.dependencies {
36 | implementation(libs.data.sqldelight.native)
37 | }
38 |
39 | desktopMain.dependencies {
40 | implementation(libs.data.sqldelight.desktop)
41 | }
42 | }
43 | }
44 |
--------------------------------------------------------------------------------
/shared/data/database/src/main/sqldelight/dev/ohoussein/cryptoapp/db/Crypto.sq:
--------------------------------------------------------------------------------
1 | CREATE TABLE Crypto (
2 | id TEXT NOT NULL PRIMARY KEY,
3 | name TEXT NOT NULL,
4 | imageUrl TEXT NOT NULL,
5 | price REAL NOT NULL,
6 | symbol TEXT NOT NULL,
7 | priceChangePercentIn24h REAL,
8 | orderInList INTEGER NOT NULL
9 | );
10 |
11 | CREATE TABLE CryptoDetails (
12 | id TEXT NOT NULL PRIMARY KEY,
13 | name TEXT NOT NULL,
14 | imageUrl TEXT NOT NULL,
15 | symbol TEXT NOT NULL,
16 | hashingAlgorithm TEXT,
17 | homePageUrl TEXT,
18 | blockchainSite TEXT,
19 | mainRepoUrl TEXT,
20 | sentimentUpVotesPercentage REAL,
21 | sentimentDownVotesPercentage REAL,
22 | description TEXT NOT NULL
23 | );
24 |
25 | insertCrypto:
26 | INSERT INTO Crypto(id, name, imageUrl, price, symbol, priceChangePercentIn24h, orderInList)
27 | VALUES ?;
28 |
29 | getAllCrypto:
30 | SELECT * FROM Crypto ORDER BY orderInList;
31 |
32 | deleteAllCrypto:
33 | DELETE FROM Crypto;
34 |
35 | insertCryptoDetails:
36 | INSERT OR REPLACE INTO CryptoDetails(id, name, imageUrl, symbol, hashingAlgorithm, homePageUrl,
37 | blockchainSite, mainRepoUrl, sentimentUpVotesPercentage, sentimentDownVotesPercentage,description)
38 | VALUES ?;
39 |
40 | selectDetails:
41 | SELECT * FROM CryptoDetails WHERE id = ?;
--------------------------------------------------------------------------------
/shared/crypto/domain/src/commonTest/kotlin/dev/ohoussein/cryptoapp/crypto/domain/usecase/GetTopCryptoListUseCaseImplTest.kt:
--------------------------------------------------------------------------------
1 | package dev.ohoussein.cryptoapp.crypto.domain.usecase
2 |
3 | import app.cash.turbine.test
4 | import dev.ohoussein.cryptoapp.crypto.domain.usecase.stub.MockedCryptoRepository
5 | import kotlinx.coroutines.test.runTest
6 | import kotlin.test.BeforeTest
7 | import kotlin.test.Test
8 | import kotlin.test.assertEquals
9 |
10 | class GetTopCryptoListUseCaseImplTest {
11 |
12 | private lateinit var topCryptoListUseCase: GetTopCryptoListUseCase
13 | private lateinit var cryptoRepository: MockedCryptoRepository
14 |
15 | @BeforeTest
16 | fun setup() {
17 | cryptoRepository = MockedCryptoRepository()
18 | topCryptoListUseCase = GetTopCryptoListUseCaseImpl(
19 | repository = cryptoRepository
20 | )
21 | }
22 |
23 | @Test
24 | fun `Given no data WHEN observe IT should return null`() = runTest {
25 | topCryptoListUseCase.observe().test {
26 | expectNoEvents()
27 | }
28 | }
29 |
30 | @Test
31 | fun `Given data WHEN refresh IT should return the data`() = runTest {
32 | topCryptoListUseCase.refresh()
33 | topCryptoListUseCase.observe().test {
34 | assertEquals(5, awaitItem().size)
35 | }
36 | }
37 | }
38 |
--------------------------------------------------------------------------------
/shared/data/database/src/commonMain/sqldelight/dev/ohoussein/cryptoapp/db/Crypto.sq:
--------------------------------------------------------------------------------
1 | CREATE TABLE Crypto (
2 | id TEXT NOT NULL PRIMARY KEY,
3 | name TEXT NOT NULL,
4 | imageUrl TEXT NOT NULL,
5 | price REAL NOT NULL,
6 | symbol TEXT NOT NULL,
7 | priceChangePercentIn24h REAL,
8 | orderInList INTEGER NOT NULL,
9 | sparkLine7d TEXT
10 | );
11 |
12 | CREATE TABLE CryptoDetails (
13 | id TEXT NOT NULL PRIMARY KEY,
14 | name TEXT NOT NULL,
15 | imageUrl TEXT NOT NULL,
16 | symbol TEXT NOT NULL,
17 | hashingAlgorithm TEXT,
18 | homePageUrl TEXT,
19 | blockchainSite TEXT,
20 | mainRepoUrl TEXT,
21 | sentimentUpVotesPercentage REAL,
22 | sentimentDownVotesPercentage REAL,
23 | description TEXT NOT NULL
24 | );
25 |
26 | insertCrypto:
27 | INSERT INTO Crypto(id, name, imageUrl, price, symbol, priceChangePercentIn24h, orderInList, sparkLine7d)
28 | VALUES ?;
29 |
30 | getAllCrypto:
31 | SELECT * FROM Crypto ORDER BY orderInList;
32 |
33 | deleteAllCrypto:
34 | DELETE FROM Crypto;
35 |
36 | insertCryptoDetails:
37 | INSERT OR REPLACE INTO CryptoDetails(id, name, imageUrl, symbol, hashingAlgorithm, homePageUrl,
38 | blockchainSite, mainRepoUrl, sentimentUpVotesPercentage, sentimentDownVotesPercentage,description)
39 | VALUES ?;
40 |
41 | selectDetails:
42 | SELECT * FROM CryptoDetails WHERE id = ?;
--------------------------------------------------------------------------------
/shared/crypto/presentation/src/commonMain/kotlin/dev/ohoussein/cryptoapp/crypto/presentation/di/CryptoPresentationModule.kt:
--------------------------------------------------------------------------------
1 | package dev.ohoussein.cryptoapp.crypto.presentation.di
2 |
3 | import dev.ohoussein.cryptoapp.crypto.data.cryptoDataModule
4 | import dev.ohoussein.cryptoapp.crypto.domain.cryptoDomainModule
5 | import dev.ohoussein.cryptoapp.crypto.presentation.details.CryptoDetailsViewModel
6 | import dev.ohoussein.cryptoapp.crypto.presentation.graph.CryptoPriceGraphViewModel
7 | import dev.ohoussein.cryptoapp.crypto.presentation.graph.GraphGridGenerator
8 | import dev.ohoussein.cryptoapp.crypto.presentation.list.CryptoListViewModel
9 | import dev.ohoussein.cryptoapp.crypto.presentation.mapper.DomainModelMapper
10 | import org.koin.core.module.dsl.factoryOf
11 | import org.koin.dsl.module
12 |
13 | val cryptoPresentationModule = module {
14 | factoryOf(::DomainModelMapper)
15 | factoryOf(::CryptoListViewModel)
16 | factory { GraphGridGenerator(get(), get()) }
17 | factory { params ->
18 | CryptoDetailsViewModel(get(), get(), get(), params.get())
19 | }
20 | factory { params ->
21 | CryptoPriceGraphViewModel(get(), get(), get(), params.get())
22 | }
23 | }
24 |
25 | val cryptoModule = module {
26 | includes(cryptoDomainModule)
27 | includes(cryptoDataModule)
28 | includes(cryptoPresentationModule)
29 | }
30 |
--------------------------------------------------------------------------------
/shared/crypto/presentation/src/commonTest/kotlin/dev/ohoussein/cryptoapp/crypto/presentation/fake/FakeGetCryptoDetailsUseCase.kt:
--------------------------------------------------------------------------------
1 | package dev.ohoussein.cryptoapp.crypto.presentation.fake
2 |
3 | import dev.ohoussein.cryptoapp.crypto.domain.model.CryptoDetailsModel
4 | import dev.ohoussein.cryptoapp.crypto.domain.model.FakeCryptoModel
5 | import dev.ohoussein.cryptoapp.crypto.domain.model.HistoricalPrice
6 | import dev.ohoussein.cryptoapp.crypto.domain.usecase.GetCryptoDetailsUseCase
7 | import kotlinx.coroutines.flow.Flow
8 | import kotlinx.coroutines.flow.flowOf
9 |
10 | class FakeGetCryptoDetailsUseCase : GetCryptoDetailsUseCase {
11 | var shouldThrowOnRefresh = false
12 |
13 | override fun observe(cryptoId: String): Flow {
14 | val cryptoDetails = FakeCryptoModel.cryptoDetails(cryptoId)
15 | return flowOf(cryptoDetails)
16 | }
17 |
18 | override suspend fun refresh(cryptoId: String) {
19 | if (shouldThrowOnRefresh) {
20 | throw Error()
21 | }
22 | }
23 |
24 | override suspend fun getHistoricalPrices(cryptoId: String, days: Int): Result> {
25 | return if (shouldThrowOnRefresh) {
26 | Result.failure(Exception())
27 | } else {
28 | Result.success(FakeCryptoModel.historicalPrices(days))
29 | }
30 | }
31 | }
32 |
--------------------------------------------------------------------------------
/shared/crypto/presentation/src/commonTest/kotlin/dev/ohoussein/cryptoapp/crypto/presentation/core/MathTest.kt:
--------------------------------------------------------------------------------
1 | package dev.ohoussein.cryptoapp.crypto.presentation.core
2 |
3 | import kotlin.test.Test
4 | import kotlin.test.assertEquals
5 |
6 | class MathTest {
7 |
8 | @Test
9 | fun `Given values less than max value When call averageValues it it should return the same list`() {
10 | val prices = listOf(100.0, 110.0, 105.0)
11 | val result = prices.averageValues(maxValues = 20)
12 | val expected = listOf(100.0, 110.0, 105.0)
13 |
14 | assertEquals(expected, result)
15 | }
16 |
17 | @Test
18 | fun `Given values When call averageValues it it should return the average values`() {
19 | val prices = (1..40).map { it.toDouble() }
20 | val result = prices.averageValues(maxValues = 20)
21 | val expected = listOf(
22 | 1.5, 3.5, 5.5, 7.5, 9.5, 11.5, 13.5, 15.5, 17.5, 19.5,
23 | 21.5, 23.5, 25.5, 27.5, 29.5, 31.5, 33.5, 35.5, 37.5, 39.5
24 | )
25 |
26 | assertEquals(expected, result)
27 | }
28 |
29 | @Test
30 | fun `Given empty values When call averageValues it it should return an empty list`() {
31 | val prices = emptyList()
32 | val result = prices.averageValues(maxValues = 20)
33 | val expected = emptyList()
34 |
35 | assertEquals(expected, result)
36 | }
37 | }
38 |
--------------------------------------------------------------------------------
/shared/presentation/src/desktopTest/kotlin/dev/ohoussein/cryptoapp/presentation/SharedPresentationModulesTest.kt:
--------------------------------------------------------------------------------
1 | package dev.ohoussein.cryptoapp.presentation
2 |
3 | import dev.ohoussein.cryptoapp.core.formatter.formatModule
4 | import dev.ohoussein.cryptoapp.core.router.routerModule
5 | import dev.ohoussein.cryptoapp.crypto.data.cryptoDataModule
6 | import dev.ohoussein.cryptoapp.crypto.domain.cryptoDomainModule
7 | import dev.ohoussein.cryptoapp.crypto.presentation.di.cryptoModule
8 | import dev.ohoussein.cryptoapp.data.database.databaseModule
9 | import dev.ohoussein.cryptoapp.data.network.networkModule
10 | import io.ktor.client.HttpClientConfig
11 | import io.ktor.client.engine.HttpClientEngine
12 | import org.koin.core.annotation.KoinExperimentalAPI
13 | import org.koin.dsl.module
14 | import org.koin.test.verify.verify
15 | import kotlin.test.Test
16 |
17 | val sharedPresentationModules = module {
18 | includes(
19 | formatModule,
20 | databaseModule,
21 | networkModule,
22 | cryptoDomainModule,
23 | cryptoDataModule,
24 | routerModule,
25 | )
26 | includes(cryptoModule)
27 | }
28 |
29 | class SharedPresentationModulesTest {
30 |
31 | @OptIn(KoinExperimentalAPI::class)
32 | @Test
33 | fun checkDependencies() {
34 | sharedPresentationModules.verify(
35 | extraTypes = listOf(HttpClientEngine::class, HttpClientConfig::class)
36 | )
37 | }
38 | }
39 |
--------------------------------------------------------------------------------
/shared/crypto/presentation/src/commonMain/kotlin/dev/ohoussein/cryptoapp/crypto/presentation/graph/CryptoPriceGraphState.kt:
--------------------------------------------------------------------------------
1 | package dev.ohoussein.cryptoapp.crypto.presentation.graph
2 |
3 | import androidx.compose.runtime.Composable
4 | import cryptoapp.shared.crypto.presentation.generated.resources.*
5 | import dev.ohoussein.cryptoapp.crypto.presentation.model.GraphInterval
6 | import dev.ohoussein.cryptoapp.designsystem.graph.model.GraphPoint
7 | import dev.ohoussein.cryptoapp.designsystem.graph.model.GridPoint
8 | import org.jetbrains.compose.resources.stringResource
9 |
10 | data class CryptoPriceGraphState(
11 | val graphPrices: List = emptyList(),
12 | val selectedInterval: GraphInterval = GraphInterval.INTERVAL_7_DAYS,
13 | val allIntervals: List = GraphInterval.entries,
14 | val horizontalGridPoints: List = emptyList(),
15 | val verticalGridPoints: List = emptyList(),
16 | )
17 |
18 | val GraphInterval.asString
19 | @Composable
20 | get() = stringResource(
21 | when (this) {
22 | GraphInterval.INTERVAL_1_DAY -> Res.string.interval_1_day
23 | GraphInterval.INTERVAL_7_DAYS -> Res.string.interval_7_days
24 | GraphInterval.INTERVAL_1_MONTH -> Res.string.interval_1_month
25 | GraphInterval.INTERVAL_3_MONTHS -> Res.string.interval_3_months
26 | GraphInterval.INTERVAL_1_YEAR -> Res.string.interval_1_year
27 | }
28 | )
29 |
--------------------------------------------------------------------------------
/shared/data/database/src/desktopMain/kotlin/dev/ohoussein/cryptoapp/data/database/DatabaseDriverFactory.kt:
--------------------------------------------------------------------------------
1 | package dev.ohoussein.cryptoapp.data.database
2 |
3 | import app.cash.sqldelight.db.SqlDriver
4 | import app.cash.sqldelight.driver.jdbc.sqlite.JdbcSqliteDriver
5 | import java.io.File
6 |
7 | actual class DatabaseDriverFactory {
8 | actual fun createDriver(): SqlDriver {
9 | println("Create database to ${getDatabaseFile().absolutePath}")
10 | return JdbcSqliteDriver("jdbc:sqlite:${getDatabaseFile().absolutePath}")
11 | }
12 |
13 | private fun getDatabaseFile(): File {
14 | return File(
15 | appDir.also { if (!it.exists()) it.mkdirs() },
16 | "cryptoApp.db",
17 | )
18 | }
19 |
20 | private val appDir: File
21 | get() {
22 | val os = System.getProperty("os.name").lowercase()
23 | return when {
24 | os.contains("win") -> {
25 | File(System.getenv("AppData"), "cryptoApp/db")
26 | }
27 |
28 | os.contains("nix") || os.contains("nux") || os.contains("aix") -> {
29 | File(System.getProperty("user.home"), ".cryptoApp")
30 | }
31 |
32 | os.contains("mac") -> {
33 | File(System.getProperty("user.home"), "Library/Application Support/cryptoApp")
34 | }
35 |
36 | else -> error("Unsupported operating system")
37 | }
38 | }
39 | }
40 |
--------------------------------------------------------------------------------
/shared/crypto/domain/src/commonMain/kotlin/dev/ohoussein/cryptoapp/crypto/domain/model/FakeCryptoModel.kt:
--------------------------------------------------------------------------------
1 | package dev.ohoussein.cryptoapp.crypto.domain.model
2 |
3 | object FakeCryptoModel {
4 |
5 | fun crypto(
6 | id: String = "bitcoin",
7 | order: Int = 1,
8 | ) = CryptoModel(
9 | id = id,
10 | name = "crypto $id",
11 | imageUrl = "https://$id.com",
12 | symbol = "CR-$id",
13 | price = 70.0,
14 | priceChangePercentIn24h = -2.0,
15 | order = order,
16 | sparkLine7d = null,
17 | )
18 |
19 | fun cryptoList(count: Int): List =
20 | (1..count).map {
21 | crypto(it.toString(), order = it)
22 | }
23 |
24 | fun cryptoDetails(
25 | id: String = "bitcoin",
26 | ) = CryptoDetailsModel(
27 | id = id,
28 | name = "crypto $id",
29 | imageUrl = "https://image-$id.com",
30 | symbol = "CR-$id",
31 | hashingAlgorithm = "SHA-256",
32 | homePageUrl = "http://home-$id.com",
33 | blockchainSite = "http://blockchain-$id.com",
34 | mainRepoUrl = "http://repo-$id.com",
35 | sentimentUpVotesPercentage = 22.0,
36 | sentimentDownVotesPercentage = 100 - 22.0,
37 | description = "details $id",
38 | )
39 |
40 | @Suppress("MagicNumber")
41 | fun historicalPrices(count: Int = 7): List = (1..count).map {
42 | HistoricalPrice(1711843200000 + it, 69702.0 + it * 1000)
43 | }
44 | }
45 |
--------------------------------------------------------------------------------
/shared/data/network/src/commonMain/kotlin/dev/ohoussein/cryptoapp/data/network/NetworkBuilder.kt:
--------------------------------------------------------------------------------
1 | package dev.ohoussein.cryptoapp.data.network
2 |
3 | import io.ktor.client.HttpClient
4 | import io.ktor.client.HttpClientConfig
5 | import io.ktor.client.engine.HttpClientEngine
6 | import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
7 | import io.ktor.client.plugins.logging.LogLevel
8 | import io.ktor.client.plugins.logging.Logger
9 | import io.ktor.client.plugins.logging.Logging
10 | import io.ktor.serialization.kotlinx.json.json
11 | import kotlinx.serialization.json.Json
12 |
13 | object NetworkBuilder {
14 | fun httpClient(engine: HttpClientEngine? = null): HttpClient {
15 | val config: HttpClientConfig<*>.() -> Unit = {
16 | install(Logging) {
17 | level = LogLevel.INFO
18 | logger = object : Logger {
19 | override fun log(message: String) {
20 | println("---------------HTTP---------------")
21 | println(message)
22 | println("----------------------------------")
23 | }
24 | }
25 | }
26 | install(ContentNegotiation) {
27 | json(
28 | Json {
29 | ignoreUnknownKeys = true
30 | }
31 | )
32 | }
33 | }
34 | return engine?.let { HttpClient(it, config) } ?: HttpClient(config)
35 | }
36 | }
37 |
--------------------------------------------------------------------------------
/shared/crypto/domain/src/commonTest/kotlin/dev/ohoussein/cryptoapp/crypto/domain/usecase/stub/MockedCryptoRepository.kt:
--------------------------------------------------------------------------------
1 | package dev.ohoussein.cryptoapp.crypto.domain.usecase.stub
2 |
3 | import dev.ohoussein.cryptoapp.crypto.domain.model.CryptoDetailsModel
4 | import dev.ohoussein.cryptoapp.crypto.domain.model.CryptoModel
5 | import dev.ohoussein.cryptoapp.crypto.domain.model.FakeCryptoModel
6 | import dev.ohoussein.cryptoapp.crypto.domain.repo.ICryptoRepository
7 | import kotlinx.coroutines.flow.Flow
8 | import kotlinx.coroutines.flow.MutableStateFlow
9 | import kotlinx.coroutines.flow.filterNotNull
10 |
11 | internal class MockedCryptoRepository : ICryptoRepository {
12 |
13 | private val cryptoList = MutableStateFlow?>(null)
14 | private val cryptoDetails = MutableStateFlow(null)
15 |
16 | override fun getTopCryptoList(): Flow> {
17 | return cryptoList.filterNotNull()
18 | }
19 |
20 | override suspend fun refreshTopCryptoList() {
21 | cryptoList.value = FakeCryptoModel.cryptoList(5)
22 | }
23 |
24 | override fun getCryptoDetails(cryptoId: String): Flow {
25 | return cryptoDetails.filterNotNull()
26 | }
27 |
28 | override suspend fun refreshCryptoDetails(cryptoId: String) {
29 | cryptoDetails.value = FakeCryptoModel.cryptoDetails()
30 | }
31 |
32 | override suspend fun getHistoricalPrices(cryptoId: String, days: Int) =
33 | Result.success(FakeCryptoModel.historicalPrices())
34 | }
35 |
--------------------------------------------------------------------------------
/shared/crypto/presentation/build.gradle.kts:
--------------------------------------------------------------------------------
1 | plugins {
2 | id("dev.ohoussein.cryptoapp.kotlin.multiplatform.library")
3 | alias(libs.plugins.kotlinSerialization)
4 | alias(libs.plugins.jetbrainsCompose)
5 | alias(libs.plugins.compose.compiler)
6 | }
7 |
8 | kotlin {
9 | sourceSets {
10 | commonMain.dependencies {
11 | implementation(compose.runtime)
12 | implementation(compose.foundation)
13 | implementation(compose.material)
14 | implementation(compose.ui)
15 | implementation(compose.components.uiToolingPreview)
16 | implementation(compose.materialIconsExtended)
17 | implementation(compose.components.resources)
18 | implementation(libs.compose.navigation)
19 | implementation(libs.compose.lifecycle)
20 | implementation(libs.koin.core)
21 | implementation(libs.koin.compose)
22 | implementation(libs.coil.compose)
23 | implementation(libs.coil.network)
24 | implementation(libs.core.kotlin.datetime)
25 |
26 | implementation(project(":shared:designsystem"))
27 | implementation(project(":shared:crypto:domain"))
28 | implementation(project(":shared:crypto:data"))
29 | implementation(project(":shared:core:formatter"))
30 | implementation(project(":shared:core:router"))
31 | }
32 |
33 | commonTest.dependencies {
34 | implementation(libs.test.turbine)
35 | }
36 | }
37 | }
38 |
--------------------------------------------------------------------------------
/shared/presentation/src/androidUnitTest/kotlin/dev/ohoussein/cryptoapp/presentation/SharedPresentationModulesTest.kt:
--------------------------------------------------------------------------------
1 | package dev.ohoussein.cryptoapp.presentation
2 |
3 | import android.content.Context
4 | import dev.ohoussein.cryptoapp.core.formatter.formatModule
5 | import dev.ohoussein.cryptoapp.core.router.routerModule
6 | import dev.ohoussein.cryptoapp.crypto.data.cryptoDataModule
7 | import dev.ohoussein.cryptoapp.crypto.domain.cryptoDomainModule
8 | import dev.ohoussein.cryptoapp.crypto.presentation.di.cryptoModule
9 | import dev.ohoussein.cryptoapp.data.database.databaseModule
10 | import dev.ohoussein.cryptoapp.data.network.networkModule
11 | import io.ktor.client.HttpClientConfig
12 | import io.ktor.client.engine.HttpClientEngine
13 | import org.koin.core.annotation.KoinExperimentalAPI
14 | import org.koin.dsl.module
15 | import org.koin.test.verify.Verify.verify
16 | import org.koin.test.verify.verify
17 | import kotlin.test.Test
18 |
19 | val sharedPresentationModules = module {
20 | includes(
21 | formatModule,
22 | databaseModule,
23 | networkModule,
24 | cryptoDomainModule,
25 | cryptoDataModule,
26 | routerModule,
27 | )
28 | includes(cryptoModule)
29 | }
30 |
31 | class SharedPresentationModulesTest {
32 |
33 | @OptIn(KoinExperimentalAPI::class)
34 | @Test
35 | fun checkDependencies() {
36 | sharedPresentationModules.verify(
37 | extraTypes = listOf(Context::class, HttpClientEngine::class, HttpClientConfig::class)
38 | )
39 | }
40 | }
41 |
--------------------------------------------------------------------------------
/shared/data/database/src/androidUnitTest/kotlin/dev/ohoussein/cryptoapp/data/database/crypto/mock/TestDataFactory.kt:
--------------------------------------------------------------------------------
1 | package dev.ohoussein.cryptoapp.data.database.crypto.mock
2 |
3 | import dev.ohoussein.cryptoapp.crypto.domain.model.CryptoDetailsModel
4 | import dev.ohoussein.cryptoapp.crypto.domain.model.CryptoModel
5 | import java.util.concurrent.atomic.AtomicLong
6 | import kotlin.random.Random
7 |
8 | object TestDataFactory {
9 | private val idIndex = AtomicLong()
10 |
11 | fun randomCrypto(suffix: String) = CryptoModel(
12 | id = idIndex.getAndIncrement().toString(),
13 | name = "Crypto $suffix",
14 | imageUrl = "Http://$suffix.com",
15 | symbol = suffix,
16 | price = Random.nextDouble(),
17 | priceChangePercentIn24h = Random.nextDouble(-12.0, 12.0),
18 | order = idIndex.toInt(),
19 | sparkLine7d = null,
20 | )
21 |
22 | fun makeCryptoList(count: Int) = (0..count).map {
23 | randomCrypto(it.toString())
24 | }
25 |
26 | fun randomCryptoDetails(
27 | suffix: String,
28 | id: String = idIndex.getAndIncrement().toString(),
29 | ) = CryptoDetailsModel(
30 | id = id,
31 | name = "Crypto $suffix",
32 | imageUrl = "Http://image-$suffix.com",
33 | symbol = suffix,
34 | hashingAlgorithm = "SHA-256",
35 | homePageUrl = "http://home-$suffix.com",
36 | blockchainSite = "http://blockchain-$suffix.com",
37 | mainRepoUrl = "http://repo-$suffix.com",
38 | sentimentUpVotesPercentage = 22.0,
39 | sentimentDownVotesPercentage = 100 - 22.0,
40 | description = "details $suffix",
41 | )
42 | }
43 |
--------------------------------------------------------------------------------
/shared/data/database/src/androidUnitTest/kotlin/dev/ohoussein/cryptoapp/data/database/crypto/CryptoDAOImplTest.kt:
--------------------------------------------------------------------------------
1 | package dev.ohoussein.cryptoapp.data.database.crypto
2 |
3 | import dev.ohoussein.cryptoapp.data.database.createDatabase
4 | import dev.ohoussein.cryptoapp.data.database.crypto.mock.TestDataFactory
5 | import kotlinx.coroutines.flow.first
6 | import kotlinx.coroutines.test.UnconfinedTestDispatcher
7 | import kotlinx.coroutines.test.runTest
8 | import org.junit.Before
9 | import org.junit.Test
10 | import kotlin.test.assertEquals
11 |
12 | class CryptoDAOImplTest {
13 |
14 | lateinit var cryptoDAO: CryptoDAO
15 |
16 | @Before
17 | fun setup() {
18 | val database = createDatabase()
19 | cryptoDAO = CryptoDAOImpl(
20 | database = database,
21 | ioDispatcher = UnconfinedTestDispatcher(),
22 | dbModelMapper = DBModelMapper(),
23 | )
24 | }
25 |
26 | @Test
27 | fun should_insert_and_get_crypto_list() = runTest {
28 | // Given
29 | val dbData = TestDataFactory.makeCryptoList(100)
30 | cryptoDAO.insert(dbData)
31 | // Given
32 | val listFromDB = cryptoDAO.selectAll().first()
33 | // Then
34 | assertEquals(dbData, listFromDB)
35 | }
36 |
37 | @Test
38 | fun should_insert_and_get_crypto_details() = runTest {
39 | // Given
40 | val id = "crypto_id"
41 | val cryptoDetails = TestDataFactory.randomCryptoDetails(id, id)
42 | cryptoDAO.insert(cryptoDetails)
43 | // When
44 | val cryptoDetailsFromDB = cryptoDAO.selectDetails(id).first()
45 | // Then
46 | assertEquals(cryptoDetails, cryptoDetailsFromDB)
47 | }
48 | }
49 |
--------------------------------------------------------------------------------
/shared/crypto/domain/src/commonTest/kotlin/dev/ohoussein/cryptoapp/crypto/domain/usecase/GetCryptoDetailsUseCaseImplTest.kt:
--------------------------------------------------------------------------------
1 | package dev.ohoussein.cryptoapp.crypto.domain.usecase
2 |
3 | import app.cash.turbine.test
4 | import dev.ohoussein.cryptoapp.crypto.domain.model.FakeCryptoModel
5 | import dev.ohoussein.cryptoapp.crypto.domain.usecase.stub.MockedCryptoRepository
6 | import kotlinx.coroutines.test.runTest
7 | import kotlin.test.BeforeTest
8 | import kotlin.test.Test
9 | import kotlin.test.assertEquals
10 |
11 | class GetCryptoDetailsUseCaseImplTest {
12 |
13 | private val cryptoId = "bitcoin"
14 |
15 | private lateinit var useCase: GetCryptoDetailsUseCase
16 | private lateinit var cryptoRepository: MockedCryptoRepository
17 |
18 | @BeforeTest
19 | fun setup() {
20 | cryptoRepository = MockedCryptoRepository()
21 | useCase = GetCryptoDetailsUseCaseImpl(
22 | repository = cryptoRepository
23 | )
24 | }
25 |
26 | @Test
27 | fun `Given no data WHEN observe IT should return null`() = runTest {
28 | useCase.observe(cryptoId).test {
29 | expectNoEvents()
30 | }
31 | }
32 |
33 | @Test
34 | fun `Given data WHEN refresh IT should return the data`() = runTest {
35 | useCase.refresh(cryptoId)
36 | useCase.observe(cryptoId).test {
37 | val item = awaitItem()
38 | assertEquals(FakeCryptoModel.cryptoDetails(), item)
39 | }
40 | }
41 |
42 | @Test
43 | fun `Given historical prices WHEN getHistoricalPrices IT should return the data`() = runTest {
44 | val result = useCase.getHistoricalPrices(cryptoId, 7)
45 |
46 | assertEquals(7, result.getOrThrow().size)
47 | }
48 | }
49 |
--------------------------------------------------------------------------------
/app-iOS/appiOS/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 | CADisableMinimumFrameDurationOnPhone
24 |
25 | UIApplicationSceneManifest
26 |
27 | UIApplicationSupportsMultipleScenes
28 |
29 |
30 | UILaunchScreen
31 |
32 | UIRequiredDeviceCapabilities
33 |
34 | armv7
35 |
36 | UISupportedInterfaceOrientations
37 |
38 | UIInterfaceOrientationPortrait
39 | UIInterfaceOrientationLandscapeLeft
40 | UIInterfaceOrientationLandscapeRight
41 |
42 | UISupportedInterfaceOrientations~ipad
43 |
44 | UIInterfaceOrientationPortrait
45 | UIInterfaceOrientationPortraitUpsideDown
46 | UIInterfaceOrientationLandscapeLeft
47 | UIInterfaceOrientationLandscapeRight
48 |
49 |
50 |
51 |
--------------------------------------------------------------------------------
/shared/designsystem/src/commonMain/kotlin/dev/ohoussein/cryptoapp/designsystem/theme/Type.kt:
--------------------------------------------------------------------------------
1 | @file:OptIn(ExperimentalResourceApi::class, ExperimentalResourceApi::class, ExperimentalResourceApi::class)
2 |
3 | package dev.ohoussein.cryptoapp.designsystem.theme
4 |
5 | import androidx.compose.material.Typography
6 | import androidx.compose.runtime.Composable
7 | import androidx.compose.ui.text.TextStyle
8 | import androidx.compose.ui.text.font.FontFamily
9 | import androidx.compose.ui.text.font.FontWeight
10 | import androidx.compose.ui.unit.sp
11 | import cryptoapp.shared.designsystem.generated.resources.Res
12 | import cryptoapp.shared.designsystem.generated.resources.montserrat_medium
13 | import cryptoapp.shared.designsystem.generated.resources.paytone_one_regular
14 | import org.jetbrains.compose.resources.ExperimentalResourceApi
15 | import org.jetbrains.compose.resources.Font
16 |
17 | val PaytoneOneFontFamily
18 | @Composable get() = FontFamily(
19 | Font(Res.font.paytone_one_regular),
20 | )
21 |
22 | val MontserratFontFamily
23 | @Composable get() = FontFamily(
24 | Font(Res.font.montserrat_medium),
25 | )
26 |
27 | val Typography
28 | @Composable get() = Typography(
29 | body1 = TextStyle(
30 | fontFamily = MontserratFontFamily,
31 | fontWeight = FontWeight.Normal,
32 | fontSize = 14.sp,
33 | lineHeight = 24.sp,
34 | ),
35 | body2 = TextStyle(
36 | fontFamily = MontserratFontFamily,
37 | fontWeight = FontWeight.Light,
38 | fontSize = 14.sp,
39 | lineHeight = 24.sp,
40 | ),
41 | caption = TextStyle(
42 | fontFamily = FontFamily.Default,
43 | fontWeight = FontWeight.Light,
44 | fontSize = 12.sp
45 | ),
46 | )
47 |
--------------------------------------------------------------------------------
/shared/designsystem/src/commonMain/kotlin/dev/ohoussein/cryptoapp/designsystem/theme/Theme.kt:
--------------------------------------------------------------------------------
1 | package dev.ohoussein.cryptoapp.designsystem.theme
2 |
3 | import androidx.compose.foundation.isSystemInDarkTheme
4 | import androidx.compose.material.Colors
5 | import androidx.compose.material.MaterialTheme
6 | import androidx.compose.material.darkColors
7 | import androidx.compose.material.lightColors
8 | import androidx.compose.runtime.Composable
9 | import androidx.compose.ui.graphics.Color
10 |
11 | private val DarkColorPalette = darkColors(
12 | primary = BlueGrey700,
13 | primaryVariant = LIME700,
14 | secondary = LightBlue300,
15 | surface = BlueGrey900,
16 | onPrimary = Color.White,
17 | )
18 |
19 | private val LightColorPalette = lightColors(
20 | primary = BlueGrey700,
21 | primaryVariant = LIME700,
22 | secondary = LightBlue500,
23 | onPrimary = Color.White,
24 | surface = BlueGrey50,
25 | /* Other default colors to override
26 | background = Color.White,
27 | surface = Color.White,
28 | onPrimary = Color.White,
29 | onSecondary = Color.Black,
30 | onBackground = Color.Black,
31 | onSurface = Color.Black,
32 | */
33 | )
34 |
35 | val AppbarFontFamily
36 | @Composable
37 | get() = PaytoneOneFontFamily
38 |
39 | val PositiveColor = Green500
40 | val NegativeColor = Red500
41 |
42 | @Composable
43 | fun CryptoAppTheme(
44 | darkTheme: Boolean = isSystemInDarkTheme(),
45 | colors: Colors? = null,
46 | content: @Composable () -> Unit,
47 | ) {
48 | val themeColors = colors ?: if (darkTheme) {
49 | DarkColorPalette
50 | } else {
51 | LightColorPalette
52 | }
53 |
54 | MaterialTheme(
55 | colors = themeColors,
56 | typography = Typography,
57 | shapes = Shapes,
58 | content = content
59 | )
60 | }
61 |
--------------------------------------------------------------------------------
/shared/crypto/presentation/src/commonMain/kotlin/dev/ohoussein/cryptoapp/crypto/presentation/list/CryptoListViewModel.kt:
--------------------------------------------------------------------------------
1 | package dev.ohoussein.cryptoapp.crypto.presentation.list
2 |
3 | import dev.ohoussein.cryptoapp.crypto.domain.usecase.GetTopCryptoListUseCase
4 | import dev.ohoussein.cryptoapp.crypto.presentation.core.ViewModel
5 | import dev.ohoussein.cryptoapp.crypto.presentation.mapper.DomainModelMapper
6 | import dev.ohoussein.cryptoapp.crypto.presentation.model.DataStatus
7 | import kotlinx.coroutines.flow.launchIn
8 | import kotlinx.coroutines.flow.map
9 | import kotlinx.coroutines.flow.onEach
10 | import kotlinx.coroutines.flow.update
11 | import kotlinx.coroutines.launch
12 |
13 | class CryptoListViewModel(
14 | private val useCase: GetTopCryptoListUseCase,
15 | private val modelMapper: DomainModelMapper,
16 | ) : ViewModel(CryptoListState()) {
17 |
18 | init {
19 | useCase.observe()
20 | .map(modelMapper::convert)
21 | .onEach { data ->
22 | mutableState.update { it.copy(cryptoList = data) }
23 | }
24 | .launchIn(viewModelScope)
25 | refresh()
26 | }
27 |
28 | override fun dispatch(event: CryptoListEvents) {
29 | when (event) {
30 | CryptoListEvents.Refresh -> refresh()
31 | }
32 | }
33 |
34 | private fun refresh() {
35 | viewModelScope.launch {
36 | mutableState.update { it.copy(status = DataStatus.Loading) }
37 | runCatching {
38 | useCase.refresh()
39 | }.onSuccess {
40 | mutableState.update { it.copy(status = DataStatus.Success) }
41 | }.onFailure { error ->
42 | mutableState.update { it.copy(status = DataStatus.Error(error.message ?: "Error")) }
43 | }
44 | }
45 | }
46 | }
47 |
--------------------------------------------------------------------------------
/app-android/src/main/res/drawable-v24/ic_launcher_foreground.xml:
--------------------------------------------------------------------------------
1 |
7 |
11 |
13 |
14 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
--------------------------------------------------------------------------------
/shared/data/cache/src/commonTest/kotlin/dev/ohoussein/cryptoapp/data/cache/CachedDataRepositoryImplTest.kt:
--------------------------------------------------------------------------------
1 | package dev.ohoussein.cryptoapp.data.cache
2 |
3 | import kotlinx.coroutines.flow.first
4 | import kotlinx.coroutines.flow.flowOf
5 | import kotlinx.coroutines.runBlocking
6 | import kotlin.test.Test
7 | import kotlin.test.assertEquals
8 |
9 | class CachedDataRepositoryImplTest {
10 |
11 | @Test
12 | fun stream(): Unit = runBlocking {
13 | val data = listOf("data1", "data2")
14 | val cacheStreamerParamCalls = mutableListOf()
15 | val cachedDataRepository = CachedDataRepository>(
16 | updater = { listOf() },
17 | cacheStreamer = { key ->
18 | cacheStreamerParamCalls.add(key)
19 | flowOf(data)
20 | },
21 | cacheWriter = { _, _ -> },
22 | )
23 |
24 | val steamedData = cachedDataRepository.stream("key")
25 |
26 | assertEquals(data, steamedData.first())
27 | assertEquals(listOf("key"), cacheStreamerParamCalls)
28 | }
29 |
30 | @Test
31 | fun refresh(): Unit = runBlocking {
32 | val data = listOf("data1", "data2")
33 | val updaterParamCalls = mutableListOf()
34 | val cacheWriterParamCalls = mutableListOf>>()
35 |
36 | val cachedDataRepository = CachedDataRepository>(
37 | updater = {
38 | updaterParamCalls.add(it)
39 | data
40 | },
41 | cacheStreamer = { flowOf(data) },
42 | cacheWriter = { key, value ->
43 | cacheWriterParamCalls.add(key to value)
44 | },
45 | )
46 |
47 | cachedDataRepository.refresh("key")
48 |
49 | assertEquals(listOf("key"), updaterParamCalls)
50 | assertEquals(listOf("key" to data), cacheWriterParamCalls)
51 | }
52 | }
53 |
--------------------------------------------------------------------------------
/shared/data/cache/src/commonTest/kotlin/dev/ohoussein/cryptoapp/data/cache/InMemoryCacheDataSourceTest.kt:
--------------------------------------------------------------------------------
1 | package dev.ohoussein.cryptoapp.data.cache
2 |
3 | import dev.ohoussein.cryptoapp.data.cache.utils.FakeTimeSource
4 | import kotlinx.coroutines.test.runTest
5 | import kotlin.test.BeforeTest
6 | import kotlin.test.Test
7 | import kotlin.test.assertEquals
8 | import kotlin.time.Duration.Companion.seconds
9 |
10 | class InMemoryCacheDataSourceTest {
11 |
12 | private lateinit var cache: InMemoryCacheDataSource
13 | private lateinit var fetcher: (String) -> String
14 | private lateinit var testTimeSource: FakeTimeSource
15 |
16 | @BeforeTest
17 | fun setUp() {
18 | fetcher = { key -> "fetched_$key" }
19 | testTimeSource = FakeTimeSource()
20 |
21 | cache = InMemoryCacheDataSource(fetcher, testTimeSource)
22 | }
23 |
24 | @Test
25 | fun `Given a cached value When read before expiry Then it should return the cached value`() = runTest {
26 | // Given
27 | cache.write("key1", "value1")
28 | // When
29 | val result = cache.read("key1")
30 | // Then
31 | assertEquals("value1", result)
32 | }
33 |
34 | @Test
35 | fun `Given a cached value When read after the expiry Then it should return the fetched value`() = runTest {
36 | // Given
37 | cache.write("key1", "value1")
38 | testTimeSource += 5.seconds
39 | // When
40 | val resultAfterExpiry = cache.read("key1", 2.seconds)
41 | // Then
42 | assertEquals("fetched_key1", resultAfterExpiry)
43 | }
44 |
45 | @Test
46 | fun `Given a cached values When clearAll and read Then it should return the fetched value`() = runTest {
47 | // Given
48 | cache.write("key1", "value1")
49 | cache.write("key2", "value2")
50 | // When
51 | cache.clearAll()
52 | val result = cache.read("key1", 2.seconds)
53 | // Then
54 | assertEquals("fetched_key1", result)
55 | }
56 | }
57 |
--------------------------------------------------------------------------------
/shared/data/network/src/commonMain/kotlin/dev/ohoussein/cryptoapp/data/network/crypto/model/CryptoApiResponse.kt:
--------------------------------------------------------------------------------
1 | package dev.ohoussein.cryptoapp.data.network.crypto.model
2 |
3 | import kotlinx.serialization.SerialName
4 | import kotlinx.serialization.Serializable
5 |
6 | @Serializable
7 | data class TopCryptoResponse(
8 | @SerialName("id") val id: String,
9 | @SerialName("symbol") val symbol: String,
10 | @SerialName("name") val name: String,
11 | @SerialName("image") val image: String,
12 | @SerialName("current_price") val currentPrice: Double,
13 | @SerialName("price_change_percentage_24h") val priceChangePercentIn24h: Double?,
14 | @SerialName("sparkline_in_7d") val sparklineIn7d: SparkLineDTO? = null,
15 | )
16 |
17 | @Serializable
18 | data class CryptoDetailsResponse(
19 | @SerialName("id") val id: String,
20 | @SerialName("symbol") val symbol: String,
21 | @SerialName("name") val name: String,
22 | @SerialName("hashing_algorithm") val hashingAlgorithm: String?,
23 | @SerialName("description") val description: Map,
24 | @SerialName("sentiment_votes_up_percentage") val sentimentUpVotesPercentage: Double,
25 | @SerialName("sentiment_votes_down_percentage") val sentimentDownVotesPercentage: Double,
26 | @SerialName("image") val image: CryptoImageResponse,
27 | @SerialName("links") val links: CryptoLinksResponse,
28 | )
29 |
30 | @Serializable
31 | data class CryptoImageResponse(
32 | @SerialName("thumb") val thumb: String,
33 | @SerialName("small") val small: String,
34 | @SerialName("large") val large: String,
35 | )
36 |
37 | @Serializable
38 | data class CryptoLinksResponse(
39 | @SerialName("homepage") val homepage: List,
40 | @SerialName("blockchain_site") val blockchainSite: List,
41 | @SerialName("repos_url") val reposUrl: Map>,
42 | )
43 |
44 | @Serializable
45 | data class SparkLineDTO(
46 | val price: List,
47 | )
48 |
49 | @Serializable
50 | data class HistoricalPricesDTO(
51 | val prices: List>,
52 | )
53 |
--------------------------------------------------------------------------------
/shared/data/database/src/commonMain/kotlin/dev/ohoussein/cryptoapp/data/database/crypto/CryptoDAOImpl.kt:
--------------------------------------------------------------------------------
1 | package dev.ohoussein.cryptoapp.data.database.crypto
2 |
3 | import app.cash.sqldelight.coroutines.asFlow
4 | import app.cash.sqldelight.coroutines.mapToList
5 | import app.cash.sqldelight.coroutines.mapToOneOrNull
6 | import dev.ohoussein.cryptoapp.crypto.domain.model.CryptoDetailsModel
7 | import dev.ohoussein.cryptoapp.crypto.domain.model.CryptoModel
8 | import dev.ohoussein.cryptoapp.database.CryptoDB
9 | import kotlinx.coroutines.CoroutineDispatcher
10 | import kotlinx.coroutines.flow.Flow
11 | import kotlinx.coroutines.withContext
12 |
13 | class CryptoDAOImpl(
14 | private val database: CryptoDB,
15 | private val ioDispatcher: CoroutineDispatcher,
16 | private val dbModelMapper: DBModelMapper,
17 | ) : CryptoDAO {
18 |
19 | override suspend fun insert(cryptoList: List) {
20 | withContext(ioDispatcher) {
21 | database.cryptoQueries.transaction {
22 | database.cryptoQueries.deleteAllCrypto()
23 | cryptoList.forEach { crypto ->
24 | val dbCrypto = dbModelMapper.toDB(crypto)
25 | database.cryptoQueries.insertCrypto(dbCrypto)
26 | }
27 | }
28 | }
29 | }
30 |
31 | override fun selectAll(): Flow> {
32 | return database.cryptoQueries.getAllCrypto(dbModelMapper::toCryptoModel)
33 | .asFlow()
34 | .mapToList(ioDispatcher)
35 | }
36 |
37 | override suspend fun insert(cryptoDetails: CryptoDetailsModel): Unit = withContext(ioDispatcher) {
38 | val dbCryptoDetails = dbModelMapper.toDB(cryptoDetails)
39 | database.cryptoQueries.insertCryptoDetails(dbCryptoDetails)
40 | }
41 |
42 | override fun selectDetails(cryptoDetailsId: String): Flow {
43 | return database.cryptoQueries.selectDetails(cryptoDetailsId, dbModelMapper::toCryptoDetailsModel)
44 | .asFlow()
45 | .mapToOneOrNull(ioDispatcher)
46 | }
47 | }
48 |
--------------------------------------------------------------------------------
/shared/data/cache/src/commonMain/kotlin/dev/ohoussein/cryptoapp/data/cache/InMemoryCacheDataSource.kt:
--------------------------------------------------------------------------------
1 | package dev.ohoussein.cryptoapp.data.cache
2 |
3 | import kotlinx.coroutines.sync.Mutex
4 | import kotlinx.coroutines.sync.withLock
5 | import kotlin.time.Duration
6 | import kotlin.time.TimeSource
7 |
8 | class InMemoryCacheDataSource(
9 | private val fetcher: suspend (Key) -> Data,
10 | private val timeSource: TimeSource = TimeSource.Monotonic,
11 | ) : CacheDataSource {
12 |
13 | private data class CacheEntry(
14 | val data: Data,
15 | val writeTimeMs: Long,
16 | )
17 |
18 | private val cache = mutableMapOf>()
19 | private val keyLocks = mutableMapOf()
20 | private val cacheLock = Mutex()
21 |
22 | private val now
23 | get() = timeSource.markNow().elapsedNow()
24 |
25 | override suspend fun read(key: Key, ttl: Duration?): Data {
26 | getKeyMutex(key).withLock {
27 | val entry = cache[key] ?: return fetchAndWriteToCache(key)
28 | if (ttl != null && now.inWholeMilliseconds - entry.writeTimeMs > ttl.inWholeMilliseconds) {
29 | cache.remove(key)
30 | return fetchAndWriteToCache(key)
31 | }
32 | return entry.data
33 | }
34 | }
35 |
36 | override suspend fun write(key: Key, data: Data?) {
37 | if (data == null) {
38 | cache.remove(key)
39 | } else {
40 | val writeTime = now.inWholeMilliseconds
41 | println("new CacheEntry = ${CacheEntry(data, writeTime)}")
42 | cache[key] = CacheEntry(data, writeTime)
43 | }
44 | }
45 |
46 | private suspend fun fetchAndWriteToCache(key: Key): Data {
47 | val data = fetcher(key)
48 | write(key, data)
49 | return data
50 | }
51 |
52 | override suspend fun clearAll() {
53 | cache.clear()
54 | }
55 |
56 | private suspend fun getKeyMutex(key: Key): Mutex {
57 | return cacheLock.withLock {
58 | keyLocks.getOrPut(key) { Mutex() }
59 | }
60 | }
61 | }
62 |
--------------------------------------------------------------------------------
/shared/crypto/presentation/src/commonMain/kotlin/dev/ohoussein/cryptoapp/crypto/presentation/nav/CryptoNavGraph.kt:
--------------------------------------------------------------------------------
1 | package dev.ohoussein.cryptoapp.crypto.presentation.nav
2 |
3 | import androidx.navigation.NavGraphBuilder
4 | import androidx.navigation.NavHostController
5 | import androidx.navigation.NavType
6 | import androidx.navigation.compose.composable
7 | import androidx.navigation.navArgument
8 | import dev.ohoussein.cryptoapp.crypto.presentation.CryptoFeatNavPath
9 | import dev.ohoussein.cryptoapp.crypto.presentation.CryptoFeatNavPath.CryptoDetailsPath.ARG_CRYPTO_ID
10 | import dev.ohoussein.cryptoapp.crypto.presentation.details.CryptoDetailsScreen
11 | import dev.ohoussein.cryptoapp.crypto.presentation.list.CryptoListScreen
12 | import dev.ohoussein.cryptoapp.designsystem.base.StateError
13 |
14 | fun NavGraphBuilder.cryptoAppNavigation(navController: NavHostController) {
15 | homeEntry(navController)
16 | cryptoDetailsEntry(navController)
17 | }
18 |
19 | private fun NavGraphBuilder.homeEntry(navController: NavHostController) {
20 | composable(CryptoFeatNavPath.HOME) {
21 | CryptoListScreen(
22 | navigateToCryptoDetails = {
23 | navController.navigate(CryptoFeatNavPath.CryptoDetailsPath.path(it.info.id))
24 | }
25 | )
26 | }
27 | }
28 |
29 | private fun NavGraphBuilder.cryptoDetailsEntry(navController: NavHostController) {
30 | composable(
31 | CryptoFeatNavPath.CryptoDetailsPath.PATH,
32 | arguments = listOf(
33 | navArgument(ARG_CRYPTO_ID) {
34 | type = NavType.StringType
35 | }
36 | )
37 | ) { entry ->
38 | entry.arguments?.getString(ARG_CRYPTO_ID)?.let {
39 | CryptoDetailsScreen(
40 | cryptoId = entry.arguments!!.getString(ARG_CRYPTO_ID)!!,
41 | onBackClicked = { navController.popBackStack() }
42 | )
43 | } ?: run {
44 | StateError(
45 | message = "Invalid arguments",
46 | onRetryClick = {
47 | navController.popBackStack()
48 | }
49 | )
50 | }
51 | }
52 | }
53 |
--------------------------------------------------------------------------------
/shared/crypto/data/src/commonMain/kotlin/dev/ohoussein/cryptoapp/crypto/data/mapper/ApiDomainModelMapper.kt:
--------------------------------------------------------------------------------
1 | package dev.ohoussein.cryptoapp.crypto.data.mapper
2 |
3 | import dev.ohoussein.cryptoapp.crypto.domain.model.CryptoDetailsModel
4 | import dev.ohoussein.cryptoapp.crypto.domain.model.CryptoModel
5 | import dev.ohoussein.cryptoapp.crypto.domain.model.HistoricalPrice
6 | import dev.ohoussein.cryptoapp.crypto.domain.model.Locale
7 | import dev.ohoussein.cryptoapp.data.network.crypto.model.CryptoDetailsResponse
8 | import dev.ohoussein.cryptoapp.data.network.crypto.model.HistoricalPricesDTO
9 | import dev.ohoussein.cryptoapp.data.network.crypto.model.TopCryptoResponse
10 |
11 | class ApiDomainModelMapper(private val locale: Locale) {
12 |
13 | fun convert(data: List): List =
14 | data.mapIndexed { index, item -> convert(item, index) }
15 |
16 | fun convert(data: TopCryptoResponse, index: Int) = CryptoModel(
17 | id = data.id,
18 | symbol = data.symbol,
19 | name = data.name,
20 | imageUrl = data.image,
21 | price = data.currentPrice,
22 | priceChangePercentIn24h = data.priceChangePercentIn24h,
23 | order = index,
24 | sparkLine7d = data.sparklineIn7d?.price?.takeIf { it.isNotEmpty() },
25 | )
26 |
27 | fun convert(data: CryptoDetailsResponse) = CryptoDetailsModel(
28 | id = data.id,
29 | symbol = data.symbol,
30 | name = data.name,
31 | imageUrl = data.image.large,
32 | hashingAlgorithm = data.hashingAlgorithm,
33 | homePageUrl = data.links.homepage.firstOrNull(),
34 | blockchainSite = data.links.blockchainSite.firstOrNull(),
35 | mainRepoUrl = data.links.reposUrl.firstNotNullOfOrNull { entry ->
36 | entry.value.firstOrNull { it.isNotEmpty() }
37 | },
38 | sentimentUpVotesPercentage = data.sentimentUpVotesPercentage,
39 | sentimentDownVotesPercentage = data.sentimentDownVotesPercentage,
40 | description = data.description[locale.languageCode]
41 | ?: data.description["en"] ?: "",
42 | )
43 |
44 | fun convert(dto: HistoricalPricesDTO): List =
45 | dto.prices.map { volumePrice ->
46 | HistoricalPrice(volumePrice[0].toLong(), volumePrice[1])
47 | }
48 | }
49 |
--------------------------------------------------------------------------------
/shared/designsystem/src/commonMain/kotlin/dev/ohoussein/cryptoapp/designsystem/base/CryptoAppScaffold.kt:
--------------------------------------------------------------------------------
1 | package dev.ohoussein.cryptoapp.designsystem.base
2 |
3 | import androidx.compose.foundation.background
4 | import androidx.compose.foundation.layout.*
5 | import androidx.compose.material.*
6 | import androidx.compose.material.icons.Icons
7 | import androidx.compose.material.icons.automirrored.filled.ArrowBack
8 | import androidx.compose.runtime.Composable
9 | import androidx.compose.ui.Modifier
10 | import androidx.compose.ui.unit.dp
11 | import cryptoapp.shared.designsystem.generated.resources.Res
12 | import cryptoapp.shared.designsystem.generated.resources.core_back
13 | import dev.ohoussein.cryptoapp.designsystem.theme.AppbarFontFamily
14 | import dev.ohoussein.cryptoapp.designsystem.theme.CryptoAppTheme
15 | import org.jetbrains.compose.resources.stringResource
16 |
17 | @Composable
18 | fun CryptoAppScaffold(
19 | scaffoldState: ScaffoldState = rememberScaffoldState(),
20 | topBar: @Composable () -> Unit = {},
21 | content: @Composable (PaddingValues) -> Unit,
22 | ) {
23 | CryptoAppTheme {
24 | Scaffold(
25 | scaffoldState = scaffoldState,
26 | topBar = topBar,
27 | content = content,
28 | modifier = Modifier
29 | .fillMaxHeight()
30 | .background(MaterialTheme.colors.background),
31 | )
32 | }
33 | }
34 |
35 | @Composable
36 | fun CryptoAppTopBar(
37 | title: String,
38 | titlePrefix: @Composable (() -> Unit)? = null,
39 | onBackButton: (() -> Unit)? = null,
40 | ) {
41 | TopAppBar(
42 | title = {
43 | titlePrefix?.let {
44 | titlePrefix()
45 | Spacer(Modifier.width(24.dp))
46 | }
47 | Text(
48 | text = title,
49 | style = LocalTextStyle.current.copy(fontFamily = AppbarFontFamily),
50 | )
51 | },
52 | navigationIcon = {
53 | if (onBackButton != null) {
54 | IconButton(onClick = onBackButton) {
55 | Icon(
56 | Icons.AutoMirrored.Filled.ArrowBack,
57 | contentDescription = stringResource(Res.string.core_back)
58 | )
59 | }
60 | }
61 | },
62 | contentColor = MaterialTheme.colors.onPrimary,
63 | backgroundColor = MaterialTheme.colors.primary,
64 | )
65 | }
66 |
--------------------------------------------------------------------------------
/shared/crypto/presentation/src/commonMain/kotlin/dev/ohoussein/cryptoapp/crypto/presentation/details/CryptoDetailsViewModel.kt:
--------------------------------------------------------------------------------
1 | package dev.ohoussein.cryptoapp.crypto.presentation.details
2 |
3 | import dev.ohoussein.cryptoapp.core.router.Router
4 | import dev.ohoussein.cryptoapp.crypto.domain.usecase.GetCryptoDetailsUseCase
5 | import dev.ohoussein.cryptoapp.crypto.presentation.core.ViewModel
6 | import dev.ohoussein.cryptoapp.crypto.presentation.mapper.DomainModelMapper
7 | import dev.ohoussein.cryptoapp.crypto.presentation.model.DataStatus
8 | import kotlinx.coroutines.flow.launchIn
9 | import kotlinx.coroutines.flow.map
10 | import kotlinx.coroutines.flow.onEach
11 | import kotlinx.coroutines.flow.update
12 | import kotlinx.coroutines.launch
13 |
14 | class CryptoDetailsViewModel(
15 | private val useCase: GetCryptoDetailsUseCase,
16 | private val modelMapper: DomainModelMapper,
17 | private val router: Router,
18 | private val cryptoId: String,
19 | ) : ViewModel(CryptoDetailsState()) {
20 |
21 | init {
22 | useCase.observe(cryptoId)
23 | .map(modelMapper::convert)
24 | .onEach { cryptoDetails ->
25 | mutableState.update { it.copy(cryptoDetails = cryptoDetails) }
26 | }
27 | .launchIn(viewModelScope)
28 |
29 | refresh()
30 | }
31 |
32 | override fun dispatch(event: CryptoDetailsEvents) {
33 | when (event) {
34 | CryptoDetailsEvents.Refresh -> refresh()
35 |
36 | CryptoDetailsEvents.BlockchainSiteClicked ->
37 | state.value.cryptoDetails?.blockchainSite?.let { router.openUrl(it) }
38 |
39 | CryptoDetailsEvents.HomePageClicked ->
40 | state.value.cryptoDetails?.homePageUrl?.let { router.openUrl(it) }
41 |
42 | CryptoDetailsEvents.SourceCodeClicked ->
43 | state.value.cryptoDetails?.mainRepoUrl?.let { router.openUrl(it) }
44 | }
45 | }
46 |
47 | private fun refresh() {
48 | viewModelScope.launch {
49 | mutableState.update { it.copy(status = DataStatus.Loading) }
50 | runCatching {
51 | useCase.refresh(cryptoId)
52 | }.onSuccess {
53 | mutableState.update { it.copy(status = DataStatus.Success) }
54 | }.onFailure { error ->
55 | mutableState.update { it.copy(status = DataStatus.Error(error.message ?: "Error")) }
56 | }
57 | }
58 | }
59 | }
60 |
--------------------------------------------------------------------------------
/gradlew.bat:
--------------------------------------------------------------------------------
1 | @if "%DEBUG%" == "" @echo off
2 | @rem ##########################################################################
3 | @rem
4 | @rem Gradle startup script for Windows
5 | @rem
6 | @rem ##########################################################################
7 |
8 | @rem Set local scope for the variables with windows NT shell
9 | if "%OS%"=="Windows_NT" setlocal
10 |
11 | set DIRNAME=%~dp0
12 | if "%DIRNAME%" == "" set DIRNAME=.
13 | set APP_BASE_NAME=%~n0
14 | set APP_HOME=%DIRNAME%
15 |
16 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
17 | set DEFAULT_JVM_OPTS=
18 |
19 | @rem Find java.exe
20 | if defined JAVA_HOME goto findJavaFromJavaHome
21 |
22 | set JAVA_EXE=java.exe
23 | %JAVA_EXE% -version >NUL 2>&1
24 | if "%ERRORLEVEL%" == "0" goto init
25 |
26 | echo.
27 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
28 | echo.
29 | echo Please set the JAVA_HOME variable in your environment to match the
30 | echo location of your Java installation.
31 |
32 | goto fail
33 |
34 | :findJavaFromJavaHome
35 | set JAVA_HOME=%JAVA_HOME:"=%
36 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe
37 |
38 | if exist "%JAVA_EXE%" goto init
39 |
40 | echo.
41 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
42 | echo.
43 | echo Please set the JAVA_HOME variable in your environment to match the
44 | echo location of your Java installation.
45 |
46 | goto fail
47 |
48 | :init
49 | @rem Get command-line arguments, handling Windows variants
50 |
51 | if not "%OS%" == "Windows_NT" goto win9xME_args
52 |
53 | :win9xME_args
54 | @rem Slurp the command line arguments.
55 | set CMD_LINE_ARGS=
56 | set _SKIP=2
57 |
58 | :win9xME_args_slurp
59 | if "x%~1" == "x" goto execute
60 |
61 | set CMD_LINE_ARGS=%*
62 |
63 | :execute
64 | @rem Setup the command line
65 |
66 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
67 |
68 | @rem Execute Gradle
69 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS%
70 |
71 | :end
72 | @rem End local scope for the variables with windows NT shell
73 | if "%ERRORLEVEL%"=="0" goto mainEnd
74 |
75 | :fail
76 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
77 | rem the _cmd.exe /c_ return code!
78 | if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
79 | exit /b 1
80 |
81 | :mainEnd
82 | if "%OS%"=="Windows_NT" endlocal
83 |
84 | :omega
85 |
--------------------------------------------------------------------------------
/shared/crypto/presentation/presentation.podspec:
--------------------------------------------------------------------------------
1 | Pod::Spec.new do |spec|
2 | spec.name = 'presentation'
3 | spec.version = '1.0.0'
4 | spec.homepage = ''
5 | spec.source = { :http=> ''}
6 | spec.authors = ''
7 | spec.license = ''
8 | spec.summary = ''
9 | spec.vendored_frameworks = 'build/cocoapods/framework/presentation.framework'
10 | spec.libraries = 'c++'
11 |
12 |
13 |
14 | if !Dir.exist?('build/cocoapods/framework/presentation.framework') || Dir.empty?('build/cocoapods/framework/presentation.framework')
15 | raise "
16 |
17 | Kotlin framework 'presentation' doesn't exist yet, so a proper Xcode project can't be generated.
18 | 'pod install' should be executed after running ':generateDummyFramework' Gradle task:
19 |
20 | ./gradlew :shared:crypto:presentation:generateDummyFramework
21 |
22 | Alternatively, proper pod installation is performed during Gradle sync in the IDE (if Podfile location is set)"
23 | end
24 |
25 | spec.xcconfig = {
26 | 'ENABLE_USER_SCRIPT_SANDBOXING' => 'NO',
27 | }
28 |
29 | spec.pod_target_xcconfig = {
30 | 'KOTLIN_PROJECT_PATH' => ':shared:crypto:presentation',
31 | 'PRODUCT_MODULE_NAME' => 'presentation',
32 | }
33 |
34 | spec.script_phases = [
35 | {
36 | :name => 'Build presentation',
37 | :execution_position => :before_compile,
38 | :shell_path => '/bin/sh',
39 | :script => <<-SCRIPT
40 | if [ "YES" = "$OVERRIDE_KOTLIN_BUILD_IDE_SUPPORTED" ]; then
41 | echo "Skipping Gradle build task invocation due to OVERRIDE_KOTLIN_BUILD_IDE_SUPPORTED environment variable set to \"YES\""
42 | exit 0
43 | fi
44 | set -ev
45 | REPO_ROOT="$PODS_TARGET_SRCROOT"
46 | "$REPO_ROOT/../../../gradlew" -p "$REPO_ROOT" $KOTLIN_PROJECT_PATH:syncFramework \
47 | -Pkotlin.native.cocoapods.platform=$PLATFORM_NAME \
48 | -Pkotlin.native.cocoapods.archs="$ARCHS" \
49 | -Pkotlin.native.cocoapods.configuration="$CONFIGURATION"
50 | SCRIPT
51 | }
52 | ]
53 | spec.resources = ['build/compose/cocoapods/compose-resources']
54 | end
--------------------------------------------------------------------------------
/shared/presentation/build.gradle.kts:
--------------------------------------------------------------------------------
1 | plugins {
2 | id("dev.ohoussein.cryptoapp.kotlin.multiplatform.library")
3 | alias(libs.plugins.kotlinSerialization)
4 | alias(libs.plugins.jetbrainsCompose)
5 | alias(libs.plugins.compose.compiler)
6 | }
7 |
8 | kotlin {
9 | listOf(
10 | iosX64(),
11 | iosArm64(),
12 | iosSimulatorArm64()
13 | ).forEach { iosTarget ->
14 | iosTarget.binaries.framework {
15 | baseName = "Presentation"
16 | isStatic = true
17 | binaryOption("bundleId", "dev.ohoussein.cryptoapp")
18 | binaryOption("bundleVersion", "1.0.0")
19 | }
20 | }
21 |
22 | sourceSets {
23 | androidMain.dependencies {
24 | implementation(libs.compose.ui.tooling.preview)
25 | implementation(libs.android.compose.activity)
26 |
27 | implementation(libs.compose.ui.tooling)
28 | implementation(libs.compose.ui.tooling.preview)
29 | implementation(libs.koin.android)
30 | }
31 | commonMain.dependencies {
32 | implementation(compose.runtime)
33 | implementation(compose.foundation)
34 | implementation(compose.material)
35 | implementation(compose.ui)
36 | implementation(compose.components.resources)
37 | implementation(libs.compose.navigation)
38 |
39 | implementation(libs.koin.compose)
40 | implementation(libs.koin.core)
41 | implementation(libs.coil.compose)
42 | implementation(libs.coil.network)
43 |
44 | implementation(project(":shared:crypto:presentation"))
45 | implementation(project(":shared:designsystem"))
46 |
47 | // For DI injection
48 | implementation(project(":shared:core:formatter"))
49 | implementation(project(":shared:core:router"))
50 | implementation(project(":shared:data:database"))
51 | implementation(project(":shared:data:network"))
52 | implementation(project(":shared:crypto:domain"))
53 | implementation(project(":shared:crypto:data"))
54 | }
55 |
56 | androidUnitTest.dependencies {
57 | implementation(libs.koin.test)
58 | }
59 |
60 | desktopMain.dependencies {
61 | implementation(compose.desktop.currentOs)
62 | implementation(libs.core.kotlin.coroutines.swing)
63 | }
64 |
65 | desktopTest.dependencies {
66 | implementation(libs.koin.test)
67 | }
68 | }
69 | }
70 |
--------------------------------------------------------------------------------
/.idea/inspectionProfiles/Project_Default.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
--------------------------------------------------------------------------------
/shared/data/network/src/commonMain/kotlin/dev/ohoussein/cryptoapp/data/network/crypto/service/ApiCryptoServiceImpl.kt:
--------------------------------------------------------------------------------
1 | package dev.ohoussein.cryptoapp.data.network.crypto.service
2 |
3 | import dev.ohoussein.cryptoapp.data.network.NetworkBuilder
4 | import dev.ohoussein.cryptoapp.data.network.crypto.model.CryptoDetailsResponse
5 | import dev.ohoussein.cryptoapp.data.network.crypto.model.HistoricalPricesDTO
6 | import dev.ohoussein.cryptoapp.data.network.crypto.model.TopCryptoResponse
7 | import io.ktor.client.HttpClient
8 | import io.ktor.client.call.body
9 | import io.ktor.client.request.forms.formData
10 | import io.ktor.client.request.get
11 | import io.ktor.client.request.parameter
12 | import io.ktor.http.URLProtocol
13 | import io.ktor.http.path
14 |
15 | private const val API_BASE_URL = "api.coingecko.com"
16 |
17 | internal class ApiCryptoServiceImpl(
18 | private val httpClient: HttpClient = NetworkBuilder.httpClient(),
19 | private val baseUrl: String = API_BASE_URL,
20 | ) : ApiCryptoService {
21 |
22 | companion object {
23 | fun create(): ApiCryptoService = ApiCryptoServiceImpl()
24 | }
25 |
26 | override suspend fun getTopCrypto(vsCurrency: String): List = httpClient.get {
27 | url {
28 | protocol = URLProtocol.HTTPS
29 | host = baseUrl
30 | path("/api/v3/coins/markets")
31 | formData {
32 | parameter("vs_currency", vsCurrency)
33 | parameter("sparkline", true)
34 | }
35 | }
36 | }.body()
37 |
38 | override suspend fun getCryptoDetails(cryptoId: String): CryptoDetailsResponse = httpClient.get {
39 | url {
40 | protocol = URLProtocol.HTTPS
41 | host = baseUrl
42 | path("/api/v3/coins/$cryptoId")
43 | formData {
44 | parameter("tickers", false)
45 | parameter("market_data", false)
46 | parameter("community_data", false)
47 | parameter("developer_data", false)
48 | parameter("sparkline", false)
49 | }
50 | }
51 | }.body()
52 |
53 | override suspend fun getHistoricalPrices(
54 | vsCurrency: String,
55 | cryptoId: String,
56 | days: Int
57 | ): HistoricalPricesDTO = httpClient.get {
58 | url {
59 | protocol = URLProtocol.HTTPS
60 | host = baseUrl
61 | path("/api/v3/coins/$cryptoId/market_chart")
62 | formData {
63 | parameter("vs_currency", vsCurrency)
64 | parameter("days", days)
65 | }
66 | }
67 | }.body()
68 | }
69 |
--------------------------------------------------------------------------------
/app-android/proguard-rules.pro:
--------------------------------------------------------------------------------
1 | -keepattributes LineNumberTable,SourceFile
2 | -keep class androidx.core.app.NotificationCompat$* { *; }
3 | -keep public class * extends androidx.fragment.app.Fragment
4 |
5 | -keep class kotlinx.** { *; }
6 |
7 | # This is generated automatically by the Android Gradle plugin.-dontwarn org.bouncycastle.jsse.BCSSLParameters
8 | -dontwarn edu.umd.cs.findbugs.annotations.SuppressFBWarnings
9 | -dontwarn java.lang.instrument.ClassDefinition
10 | -dontwarn java.lang.instrument.IllegalClassFormatException
11 | -dontwarn java.lang.instrument.UnmodifiableClassException
12 | -dontwarn java.lang.management.ManagementFactory
13 | -dontwarn java.lang.management.RuntimeMXBean
14 | -dontwarn org.junit.jupiter.api.extension.ExtendWith
15 | -dontwarn org.junit.jupiter.api.extension.ExtensionContext$Namespace
16 | -dontwarn org.junit.jupiter.api.extension.ExtensionContext$Store$CloseableResource
17 | -dontwarn org.junit.jupiter.api.extension.ExtensionContext$Store
18 | -dontwarn org.junit.jupiter.api.extension.ExtensionContext
19 | -dontwarn org.junit.jupiter.api.extension.InvocationInterceptor$Invocation
20 | -dontwarn org.junit.jupiter.api.extension.InvocationInterceptor
21 | -dontwarn org.junit.jupiter.api.extension.ReflectiveInvocationContext
22 | -dontwarn org.junit.jupiter.api.parallel.ResourceAccessMode
23 | -dontwarn org.junit.jupiter.api.parallel.ResourceLock
24 | -dontwarn org.junit.platform.commons.support.AnnotationSupport
25 | -dontwarn reactor.blockhound.BlockHound$Builder
26 | -dontwarn reactor.blockhound.integration.BlockHoundIntegration
27 |
28 |
29 | # Keep `Companion` object fields of serializable classes.
30 | # This avoids serializer lookup through `getDeclaredClasses` as done for named companion objects.
31 | -if @kotlinx.serialization.Serializable class **
32 | -keepclassmembers class <1> {
33 | static <1>$Companion Companion;
34 | }
35 |
36 | # Keep `serializer()` on companion objects (both default and named) of serializable classes.
37 | -if @kotlinx.serialization.Serializable class ** {
38 | static **$* *;
39 | }
40 | -keepclassmembers class <2>$<3> {
41 | kotlinx.serialization.KSerializer serializer(...);
42 | }
43 |
44 | # Keep `INSTANCE.serializer()` of serializable objects.
45 | -if @kotlinx.serialization.Serializable class ** {
46 | public static ** INSTANCE;
47 | }
48 | -keepclassmembers class <1> {
49 | public static <1> INSTANCE;
50 | kotlinx.serialization.KSerializer serializer(...);
51 | }
52 |
53 | # @Serializable and @Polymorphic are used at runtime for polymorphic serialization.
54 | -keepattributes RuntimeVisibleAnnotations,AnnotationDefault
55 |
56 | -dontwarn org.slf4j.impl.StaticLoggerBinder
57 | -dontwarn org.slf4j.impl.StaticMDCBinder
58 | -dontwarn io.ktor.utils.io.jvm.nio.WritingKt
--------------------------------------------------------------------------------
/shared/crypto/data/src/commonMain/kotlin/dev/ohoussein/cryptoapp/crypto/data/repository/CryptoRepository.kt:
--------------------------------------------------------------------------------
1 | package dev.ohoussein.cryptoapp.crypto.data.repository
2 |
3 | import dev.ohoussein.cryptoapp.crypto.data.mapper.ApiDomainModelMapper
4 | import dev.ohoussein.cryptoapp.crypto.domain.model.CryptoDetailsModel
5 | import dev.ohoussein.cryptoapp.crypto.domain.model.CryptoModel
6 | import dev.ohoussein.cryptoapp.crypto.domain.model.HistoricalPrice
7 | import dev.ohoussein.cryptoapp.crypto.domain.model.Locale
8 | import dev.ohoussein.cryptoapp.crypto.domain.repo.ICryptoRepository
9 | import dev.ohoussein.cryptoapp.data.cache.CachedDataRepository
10 | import dev.ohoussein.cryptoapp.data.database.crypto.CryptoDAO
11 | import dev.ohoussein.cryptoapp.data.network.crypto.service.ApiCryptoService
12 | import kotlinx.coroutines.flow.Flow
13 | import kotlinx.coroutines.flow.filterNot
14 | import kotlinx.coroutines.flow.filterNotNull
15 |
16 | class CryptoRepository(
17 | private val service: ApiCryptoService,
18 | private val cryptoDao: CryptoDAO,
19 | private val apiMapper: ApiDomainModelMapper,
20 | private val locale: Locale,
21 | ) : ICryptoRepository {
22 |
23 | private val topCryptoListCache: CachedDataRepository> = CachedDataRepository(
24 | updater = {
25 | val apiResponse = service.getTopCrypto(locale.currencyCode)
26 | apiMapper.convert(apiResponse)
27 | },
28 | cacheStreamer = {
29 | cryptoDao.selectAll()
30 | .filterNot { it.isEmpty() }
31 | },
32 | cacheWriter = { _, data ->
33 | cryptoDao.insert(data)
34 | },
35 | )
36 |
37 | private val cryptoDetailsCache: CachedDataRepository = CachedDataRepository(
38 | updater = { id ->
39 | val response = service.getCryptoDetails(id)
40 | apiMapper.convert(response)
41 | },
42 | cacheStreamer = { id ->
43 | cryptoDao.selectDetails(id)
44 | .filterNotNull()
45 | },
46 | cacheWriter = { _, data ->
47 | cryptoDao.insert(data)
48 | },
49 | )
50 |
51 | override fun getTopCryptoList(): Flow> = topCryptoListCache.stream(Unit)
52 |
53 | override suspend fun refreshTopCryptoList() = topCryptoListCache.refresh(Unit)
54 |
55 | override suspend fun refreshCryptoDetails(cryptoId: String) = cryptoDetailsCache.refresh(cryptoId)
56 |
57 | override fun getCryptoDetails(cryptoId: String): Flow = cryptoDetailsCache.stream(cryptoId)
58 |
59 | override suspend fun getHistoricalPrices(cryptoId: String, days: Int): Result> {
60 | return runCatching {
61 | val dto = service.getHistoricalPrices(locale.currencyCode, cryptoId, days)
62 | apiMapper.convert(dto)
63 | }
64 | }
65 | }
66 |
--------------------------------------------------------------------------------
/shared/designsystem/src/commonMain/kotlin/dev/ohoussein/cryptoapp/designsystem/core/AnnotatedString.kt:
--------------------------------------------------------------------------------
1 | package dev.ohoussein.cryptoapp.designsystem.core
2 |
3 | import androidx.compose.ui.text.*
4 | import androidx.compose.ui.text.font.FontWeight
5 | import androidx.compose.ui.text.style.TextDecoration
6 |
7 | internal const val TAG_URL = "URL"
8 |
9 | private data class HtmlTag(
10 | val name: String,
11 | val params: Map,
12 | val content: String,
13 | val startPosition: Int,
14 | val endPosition: Int,
15 | )
16 |
17 | @Suppress("MagicNumber")
18 | private fun String.getAllTags(): List {
19 | val regex = Regex("<([a-zA-Z0-9]+)([^>]*)>(.*?)\\1>")
20 | val tags = mutableListOf()
21 |
22 | regex.findAll(this).forEach { matchResult ->
23 | val tagName = matchResult.groups[1]?.value ?: return@forEach
24 | val rawAttributes = matchResult.groups[2]?.value?.trim().orEmpty()
25 | val content = matchResult.groups[3]?.value.orEmpty()
26 | val startPosition = matchResult.range.first
27 | val endPosition = matchResult.range.last + 1
28 |
29 | val attributes = rawAttributes
30 | .split(Regex("\\s+"))
31 | .filter { it.contains("=") }
32 | .associate {
33 | val parts = it.split("=", limit = 2)
34 | val key = parts[0]
35 | val value = parts.getOrNull(1)?.removeSurrounding("\"", "\"") ?: ""
36 | key to value
37 | }
38 |
39 | tags.add(HtmlTag(tagName, attributes, content, startPosition, endPosition))
40 | }
41 | return tags
42 | }
43 |
44 | fun String.htmlToAnnotatedString(): AnnotatedString {
45 | val annotatedString = buildAnnotatedString {
46 | var currentIndex = 0
47 | getAllTags().forEach { tag ->
48 | append(this@htmlToAnnotatedString.substring(currentIndex, tag.startPosition))
49 | when (tag.name) {
50 | "a" -> withLink(LinkAnnotation.Url(tag.params["href"]?.toString().orEmpty())) {
51 | withStyle(SpanStyle(textDecoration = TextDecoration.Underline)) {
52 | append(tag.content)
53 | }
54 | }
55 | "b" -> withStyle(SpanStyle(fontWeight = FontWeight.Bold)) {
56 | append(tag.content)
57 | }
58 | "p" -> {
59 | append("\n")
60 | append(tag.content)
61 | append("\n")
62 | }
63 | }
64 | currentIndex = tag.endPosition
65 | }
66 | append(this@htmlToAnnotatedString.substring(currentIndex))
67 | }
68 | return annotatedString
69 | }
70 |
71 | fun AnnotatedString.getLinkUrl(offset: Int): String? =
72 | getStringAnnotations(
73 | tag = TAG_URL,
74 | start = offset,
75 | end = offset,
76 | ).firstOrNull { offset >= it.start && offset <= it.end }?.item
77 |
--------------------------------------------------------------------------------
/shared/crypto/presentation/src/commonMain/kotlin/dev/ohoussein/cryptoapp/crypto/presentation/mapper/DomainModelMapper.kt:
--------------------------------------------------------------------------------
1 | package dev.ohoussein.cryptoapp.crypto.presentation.mapper
2 |
3 | import dev.ohoussein.cryptoapp.core.formatter.PercentFormatter
4 | import dev.ohoussein.cryptoapp.core.formatter.PriceFormatter
5 | import dev.ohoussein.cryptoapp.crypto.domain.model.CryptoDetailsModel
6 | import dev.ohoussein.cryptoapp.crypto.domain.model.CryptoModel
7 | import dev.ohoussein.cryptoapp.crypto.domain.model.HistoricalPrice
8 | import dev.ohoussein.cryptoapp.crypto.domain.model.Locale
9 | import dev.ohoussein.cryptoapp.crypto.presentation.core.averageValues
10 | import dev.ohoussein.cryptoapp.crypto.presentation.model.*
11 | import dev.ohoussein.cryptoapp.designsystem.graph.model.GraphPoint
12 |
13 | private const val SPARKLINE_7D_MAX_VALUES = 7 * 4
14 |
15 | class DomainModelMapper(
16 | private val priceFormatter: PriceFormatter,
17 | private val percentFormatter: PercentFormatter,
18 | private val locale: Locale,
19 | ) {
20 | fun convert(domain: List): List {
21 | return domain.map { convert(it) }
22 | }
23 |
24 | private fun convert(domain: CryptoModel): Crypto {
25 | return Crypto(
26 | info = CryptoInfo(
27 | id = domain.id,
28 | name = domain.name,
29 | imageUrl = domain.imageUrl,
30 | symbol = domain.symbol.uppercase(),
31 | ),
32 | price = CryptoPrice(
33 | labelValue = LabelValue(domain.price, priceFormatter(domain.price, locale.currencyCode))
34 | ),
35 | priceChangePercentIn24h = domain.priceChangePercentIn24h?.let {
36 | LabelValue(it, percentFormatter(it / 100.0))
37 | },
38 | sparkline7d = domain.sparkLine7d?.averageValues(SPARKLINE_7D_MAX_VALUES)?.mapIndexed { index, value ->
39 | GraphPoint(index.toDouble(), value)
40 | }
41 | )
42 | }
43 |
44 | fun convert(domain: CryptoDetailsModel) =
45 | CryptoDetails(
46 | base = CryptoInfo(
47 | id = domain.id,
48 | name = domain.name,
49 | symbol = domain.symbol.uppercase(),
50 | imageUrl = domain.imageUrl,
51 | ),
52 | hashingAlgorithm = domain.hashingAlgorithm,
53 | homePageUrl = domain.homePageUrl,
54 | blockchainSite = domain.blockchainSite,
55 | mainRepoUrl = domain.mainRepoUrl,
56 | sentimentUpVotesPercentage = domain.sentimentUpVotesPercentage?.let {
57 | LabelValue(it, percentFormatter(it))
58 | },
59 | sentimentDownVotesPercentage = domain.sentimentDownVotesPercentage?.let {
60 | LabelValue(it, percentFormatter(it))
61 | },
62 | description = domain.description,
63 | )
64 |
65 | fun convertHistoricalPrices(domain: List): List = domain
66 | .map { GraphPoint(it.timestampMillis.toDouble(), it.price) }
67 | }
68 |
--------------------------------------------------------------------------------
/app-iOS/appiOS.xcodeproj/xcshareddata/xcschemes/appiOS.xcscheme:
--------------------------------------------------------------------------------
1 |
2 |
5 |
8 |
9 |
15 |
21 |
22 |
23 |
24 |
25 |
31 |
32 |
42 |
44 |
50 |
51 |
52 |
53 |
59 |
61 |
67 |
68 |
69 |
70 |
72 |
73 |
76 |
77 |
78 |
--------------------------------------------------------------------------------
/shared/designsystem/src/commonMain/kotlin/dev/ohoussein/cryptoapp/designsystem/base/StateComponent.kt:
--------------------------------------------------------------------------------
1 | package dev.ohoussein.cryptoapp.designsystem.base
2 |
3 | import androidx.compose.foundation.Image
4 | import androidx.compose.foundation.layout.Arrangement
5 | import androidx.compose.foundation.layout.Box
6 | import androidx.compose.foundation.layout.Column
7 | import androidx.compose.foundation.layout.Spacer
8 | import androidx.compose.foundation.layout.fillMaxSize
9 | import androidx.compose.foundation.layout.padding
10 | import androidx.compose.foundation.layout.size
11 | import androidx.compose.material.CircularProgressIndicator
12 | import androidx.compose.material.MaterialTheme
13 | import androidx.compose.material.Text
14 | import androidx.compose.material.TextButton
15 | import androidx.compose.runtime.Composable
16 | import androidx.compose.ui.Alignment
17 | import androidx.compose.ui.Modifier
18 | import androidx.compose.ui.graphics.painter.Painter
19 | import androidx.compose.ui.text.style.TextAlign
20 | import androidx.compose.ui.unit.dp
21 | import cryptoapp.shared.designsystem.generated.resources.Res
22 | import cryptoapp.shared.designsystem.generated.resources.core_retry
23 | import cryptoapp.shared.designsystem.generated.resources.ic_error
24 | import org.jetbrains.compose.resources.ExperimentalResourceApi
25 | import org.jetbrains.compose.resources.painterResource
26 | import org.jetbrains.compose.resources.stringResource
27 |
28 | @OptIn(ExperimentalResourceApi::class)
29 | @Composable
30 | fun StateError(
31 | modifier: Modifier = Modifier,
32 | message: String,
33 | icon: Painter = painterResource(Res.drawable.ic_error),
34 | onRetryClick: (() -> Unit)? = null,
35 | ) {
36 | Column(
37 | modifier.fillMaxSize(),
38 | horizontalAlignment = Alignment.CenterHorizontally,
39 | verticalArrangement = Arrangement.Center,
40 | ) {
41 | Image(
42 | painter = icon,
43 | contentDescription = message,
44 | modifier = Modifier.padding(12.dp),
45 | )
46 | Text(
47 | text = message,
48 | style = MaterialTheme.typography.h6,
49 | color = MaterialTheme.colors.error,
50 | textAlign = TextAlign.Center,
51 | )
52 | if (onRetryClick != null) {
53 | Spacer(modifier = Modifier.padding(24.dp))
54 | TextButton(onClick = onRetryClick) {
55 | Text(
56 | text = stringResource(Res.string.core_retry),
57 | )
58 | }
59 | }
60 | }
61 | }
62 |
63 | // @Preview(showSystemUi = true, showBackground = true)
64 | @Composable
65 | fun StateLoading(modifier: Modifier = Modifier) {
66 | Box(
67 | modifier = modifier.fillMaxSize(),
68 | contentAlignment = Alignment.Center,
69 | ) {
70 | CircularProgressIndicator(
71 | Modifier
72 | .size(32.dp)
73 | )
74 | }
75 | }
76 |
77 | // @Preview(showSystemUi = true, showBackground = true)
78 | // @Composable
79 | // private fun PreviewErrorState() {
80 | // StateError(
81 | // message = "No internet connection",
82 | // onRetryClick = {}
83 | // )
84 | // }
85 |
--------------------------------------------------------------------------------
/shared/crypto/presentation/src/commonMain/kotlin/dev/ohoussein/cryptoapp/crypto/presentation/graph/CryptoPriceGraphViewModel.kt:
--------------------------------------------------------------------------------
1 | package dev.ohoussein.cryptoapp.crypto.presentation.graph
2 |
3 | import dev.ohoussein.cryptoapp.crypto.domain.model.HistoricalPrice
4 | import dev.ohoussein.cryptoapp.crypto.domain.usecase.GetCryptoDetailsUseCase
5 | import dev.ohoussein.cryptoapp.crypto.presentation.core.ViewModel
6 | import dev.ohoussein.cryptoapp.crypto.presentation.mapper.DomainModelMapper
7 | import dev.ohoussein.cryptoapp.crypto.presentation.model.GraphInterval
8 | import dev.ohoussein.cryptoapp.designsystem.graph.model.GraphPoint
9 | import kotlinx.coroutines.flow.update
10 | import kotlinx.coroutines.launch
11 |
12 | class CryptoPriceGraphViewModel(
13 | private val useCase: GetCryptoDetailsUseCase,
14 | private val modelMapper: DomainModelMapper,
15 | private val graphGridGenerator: GraphGridGenerator,
16 | private val cryptoId: String,
17 | ) : ViewModel(CryptoPriceGraphState()) {
18 |
19 | private val historicalPricesCachedData = mutableMapOf>()
20 |
21 | init {
22 | loadHistoricalPrices(state.value.selectedInterval)
23 | }
24 |
25 | override fun dispatch(event: CryptoPriceGraphEvents) {
26 | when (event) {
27 | is CryptoPriceGraphEvents.SelectInterval -> loadHistoricalPrices(event.interval)
28 | }
29 | }
30 |
31 | private fun loadHistoricalPrices(interval: GraphInterval, forceRefresh: Boolean = false) = viewModelScope.launch {
32 | mutableState.update {
33 | it.copy(selectedInterval = interval)
34 | }
35 | val intervalInDays = interval.countDays
36 | if (forceRefresh) {
37 | historicalPricesCachedData.clear()
38 | }
39 | historicalPricesCachedData[intervalInDays]?.let { prices ->
40 | val graphPoints = modelMapper.convertHistoricalPrices(prices)
41 | selectHistoricalValues(graphPoints, prices)
42 | return@launch
43 | }
44 | useCase.getHistoricalPrices(cryptoId, intervalInDays)
45 | .onSuccess { prices ->
46 | val graphPoints = modelMapper.convertHistoricalPrices(prices)
47 | historicalPricesCachedData[intervalInDays] = prices
48 | selectHistoricalValues(graphPoints, prices)
49 | }
50 | .onFailure { error ->
51 | println(error)
52 | }
53 | }
54 |
55 | private fun selectHistoricalValues(graphPoints: List, prices: List) {
56 | val horizontalGrid = graphGridGenerator.getPriceGridInstants(
57 | prices = prices,
58 | countValues = 5,
59 | )
60 | val verticalGrid = graphGridGenerator.getTimeGridInstants(
61 | prices,
62 | countValues = 5,
63 | timeInterval = state.value.selectedInterval,
64 | )
65 | mutableState.update {
66 | it.copy(
67 | graphPrices = graphPoints,
68 | horizontalGridPoints = horizontalGrid,
69 | verticalGridPoints = verticalGrid,
70 | )
71 | }
72 | }
73 | }
74 |
--------------------------------------------------------------------------------
/shared/data/database/src/commonMain/kotlin/dev/ohoussein/cryptoapp/data/database/crypto/DBModelMapper.kt:
--------------------------------------------------------------------------------
1 | package dev.ohoussein.cryptoapp.data.database.crypto
2 |
3 | import dev.ohoussein.cryptoapp.crypto.domain.model.CryptoDetailsModel
4 | import dev.ohoussein.cryptoapp.crypto.domain.model.CryptoModel
5 | import dev.ohoussein.cryptoapp.db.Crypto as DBCrypto
6 | import dev.ohoussein.cryptoapp.db.CryptoDetails as DBCryptoDetails
7 |
8 | class DBModelMapper {
9 |
10 | fun toCryptoModel(
11 | id: String,
12 | name: String,
13 | imageUrl: String,
14 | price: Double,
15 | symbol: String,
16 | priceChangePercentIn24h: Double?,
17 | orderInList: Long,
18 | sparLines7dList: String?,
19 | ) = CryptoModel(
20 | id = id,
21 | symbol = symbol,
22 | name = name,
23 | imageUrl = imageUrl,
24 | price = price,
25 | priceChangePercentIn24h = priceChangePercentIn24h,
26 | order = orderInList.toInt(),
27 | sparkLine7d = sparLines7dList?.let(::convertSparLinesStringList),
28 | )
29 |
30 | fun toDB(domain: CryptoModel) = DBCrypto(
31 | id = domain.id,
32 | symbol = domain.symbol,
33 | name = domain.name,
34 | imageUrl = domain.imageUrl,
35 | price = domain.price,
36 | priceChangePercentIn24h = domain.priceChangePercentIn24h,
37 | orderInList = domain.order.toLong(),
38 | sparkLine7d = domain.sparkLine7d?.joinToString("|")
39 | )
40 |
41 | fun toDB(cryptoDetails: CryptoDetailsModel): DBCryptoDetails = DBCryptoDetails(
42 | id = cryptoDetails.id,
43 | name = cryptoDetails.name,
44 | symbol = cryptoDetails.symbol,
45 | imageUrl = cryptoDetails.imageUrl,
46 | hashingAlgorithm = cryptoDetails.hashingAlgorithm,
47 | homePageUrl = cryptoDetails.homePageUrl,
48 | blockchainSite = cryptoDetails.blockchainSite,
49 | mainRepoUrl = cryptoDetails.mainRepoUrl,
50 | sentimentUpVotesPercentage = cryptoDetails.sentimentUpVotesPercentage,
51 | sentimentDownVotesPercentage = cryptoDetails.sentimentDownVotesPercentage,
52 | description = cryptoDetails.description,
53 | )
54 |
55 | fun toCryptoDetailsModel(
56 | id: String,
57 | name: String,
58 | imageUrl: String,
59 | symbol: String,
60 | hashingAlgorithm: String?,
61 | homePageUrl: String?,
62 | blockchainSite: String?,
63 | mainRepoUrl: String?,
64 | sentimentUpVotesPercentage: Double?,
65 | sentimentDownVotesPercentage: Double?,
66 | description: String,
67 | ) = CryptoDetailsModel(
68 | id = id,
69 | name = name,
70 | symbol = symbol,
71 | imageUrl = imageUrl,
72 | hashingAlgorithm = hashingAlgorithm,
73 | homePageUrl = homePageUrl,
74 | blockchainSite = blockchainSite,
75 | mainRepoUrl = mainRepoUrl,
76 | sentimentUpVotesPercentage = sentimentUpVotesPercentage,
77 | sentimentDownVotesPercentage = sentimentDownVotesPercentage,
78 | description = description,
79 | )
80 |
81 | private fun convertSparLinesStringList(list: String) = runCatching {
82 | list.split("|")
83 | .map { it.toDouble() }
84 | }.getOrNull()
85 | }
86 |
--------------------------------------------------------------------------------
/shared/crypto/presentation/src/commonMain/kotlin/dev/ohoussein/cryptoapp/crypto/presentation/graph/CryptoPriceGraph.kt:
--------------------------------------------------------------------------------
1 | package dev.ohoussein.cryptoapp.crypto.presentation.graph
2 |
3 | import androidx.compose.foundation.clickable
4 | import androidx.compose.foundation.layout.*
5 | import androidx.compose.material.MaterialTheme
6 | import androidx.compose.material.Text
7 | import androidx.compose.runtime.Composable
8 | import androidx.compose.runtime.collectAsState
9 | import androidx.compose.runtime.getValue
10 | import androidx.compose.ui.Modifier
11 | import androidx.compose.ui.graphics.Color
12 | import androidx.compose.ui.unit.dp
13 | import androidx.lifecycle.viewmodel.compose.viewModel
14 | import dev.ohoussein.cryptoapp.crypto.presentation.model.GraphInterval
15 | import dev.ohoussein.cryptoapp.designsystem.graph.ui.LinearGraph
16 | import org.koin.compose.getKoin
17 | import org.koin.core.Koin
18 | import org.koin.core.parameter.parametersOf
19 |
20 | @Composable
21 | fun CryptoPriceGraph(
22 | cryptoId: String,
23 | modifier: Modifier = Modifier,
24 | koin: Koin = getKoin(),
25 | viewModel: CryptoPriceGraphViewModel = viewModel {
26 | koin.get { parametersOf(cryptoId) }
27 | },
28 | ) {
29 | val state: CryptoPriceGraphState by viewModel.state.collectAsState()
30 |
31 | CryptoPriceGraphContent(
32 | modifier = modifier,
33 | graphState = state,
34 | onSelectInterval = { viewModel.dispatch(CryptoPriceGraphEvents.SelectInterval(it)) },
35 | )
36 | }
37 |
38 | @Composable
39 | private fun CryptoPriceGraphContent(
40 | graphState: CryptoPriceGraphState,
41 | onSelectInterval: (GraphInterval) -> Unit,
42 | modifier: Modifier = Modifier,
43 | ) {
44 | Column(modifier = modifier) {
45 | LinearGraph(
46 | values = graphState.graphPrices,
47 | color = MaterialTheme.colors.primaryVariant,
48 | stroke = 2.dp,
49 | gridColor = Color.Gray.copy(alpha = 0.8f),
50 | gridTextStyle = MaterialTheme.typography.caption,
51 | horizontalGridPoints = graphState.horizontalGridPoints,
52 | verticalGridPoints = graphState.verticalGridPoints,
53 | modifier = Modifier
54 | .fillMaxWidth()
55 | .height(250.dp),
56 | )
57 |
58 | GraphIntervals(
59 | modifier = Modifier.padding(horizontal = 12.dp),
60 | allIntervals = graphState.allIntervals,
61 | selectedInterval = graphState.selectedInterval,
62 | onSelectInterval = onSelectInterval,
63 | )
64 | }
65 | }
66 |
67 | @Composable
68 | private fun GraphIntervals(
69 | allIntervals: List,
70 | selectedInterval: GraphInterval,
71 | onSelectInterval: (GraphInterval) -> Unit,
72 | modifier: Modifier = Modifier,
73 | ) {
74 | Row(modifier) {
75 | allIntervals.forEach { interval ->
76 | val isSelected = selectedInterval == interval
77 | Text(
78 | text = interval.asString,
79 | color = if (isSelected) MaterialTheme.colors.primaryVariant else Color.Unspecified,
80 | modifier = Modifier
81 | .padding(12.dp)
82 | .clickable { onSelectInterval(interval) }
83 |
84 | )
85 | }
86 | }
87 | }
88 |
--------------------------------------------------------------------------------
/design/architecture.drawio:
--------------------------------------------------------------------------------
1 | 7V3fc6u2Ev5rPHP7EAYQPx8dO2nPTE7baab39jx1iI1tGkBcwIndv74CJCwJgbEtiHNq58FGCAGrT7vfrlbKBMyi3Y+pl2y+wqUfTnR1uZuA+UTXNUsD6Kso2eMSTdWqknUaLHHZoeA5+NvHhSou3QZLP2Mq5hCGeZCwhQsYx/4iZ8q8NIXvbLUVDNm7Jt7abxQ8L7ywWfq/YJlvqlJHtw/lP/nBekPurFludSbySGX8JtnGW8J3qgg8TMAshTCvfkW7mR8W0iNyqa57bDlbP1jqx3mfC56es+nD3/mXr6tHN139/Pb057fdnV618uaFW/zC+GHzPZEAeu6k+Bnt1kUfKy9JFCvbzE//zL3sdQLuN3kUovMa+gm3eRjE/qzuDBUVroIwnMEQpmV7wLYs1Zmi8ixP4atPnXksP8WZVz9fbPDltdyKg+ZLYzm8+Wnu76giLIQffRj5ebpHVfBZw8AdQiCpOtXx+6F/LVxlQ3WtZWBUYUSt65YPQkc/sNxP6ANwZh+gLngLFv6Z3WAZtuXYnd0gQdgaUDlpk4F9RNrmUNI2+0t76SHR5jBFv+/fN0HuPyfeojjxjjqBlfgKxjklwlX5aQpdVY2Z6w4tdATp84ROyuQL3W0I2V8iPYsPYxgXMl5s0zd/iSX61zZKuPNlUWUhSpSn+QauYeyFTxAm5DI/z/e4krfNIdtN/i7I/6B+fyvGhmLio/kOD5XyYE8OYiSBP+gD6qri8HBZeUSuY/RWlntpPi3s0eF1yrLHoJAkvnxJaixCL8uCRVVIValgQwyRhYH36EVBWNx2BiN0ka4+e3GGvr4+4wpEaEYTqe3auD6DHgE3oNevQnVDray1LvRmcJsu/A6IYIOKGl/7eZfFwhUL/HQOhtQPvTx4Y+24dGSTIcPoEyvMsaQZzFv/30Jy4i4rJThFFTQn2R1Ool9r/F228kIK5n6CesKPF3tyCj3wC18dlVX3JcWCYffkvSCaxgwMLwzWcQE81G8+AsB9oW0CRIOm+EQULJdFG/epj57ceynbK+CQwCDOS6ma9xNz3oK4oxAVAS8snvPeW7yuU7iNl6QKHj5NqHWqnob6rDkjfhmGlonU6p2q6JamM5oV2+7eWMON/1oIjbKSDtNojXHSAlytMjQoeKzWj3g+fO3j1rCUfamW1eN2sKFFgAVcsOTVBGcWH6ziTxLjcDnb55pN2+eIGIc5lPVzWlVEwg9frDMWtWwOaoGQCqqoh26xRLoFDcIEZv5En9VaJj2qSKriLPHi8288XaBREuT78s7qY+qto6KnyyNFUah7V/dpPFLSqtgug6kB9LlptzgrXhfBO9X+yuB3gIU4cJv0TtMFEK95oXSIa1qjQ2iCd6BqD4fScwjcpXRHRZ/KxaQJ2YFu0ZxMa5A4WoUxZLWBp7kxnT3aFxEiwtmPMiLnqggRMZCUtvs9KAND2Y2MaG1j/Rwyolksa7iQiwxPNoAo2sSx3MmDPpnOJg4y2pYXFWo7fsmKry9RtK2ko6tP6PXmyDkXE+EbyCSCTLUAS04J6i6kvHecDdO4eM9wKNTaPbYPomP/Dfx3HDrv5F6j8B9ZPKct6iuB/1g6ix3d7Ml/Botv6SP6Ua2MhJP4rPxIcqpUvYdXJRL5cF6Vfi0xRRIfrA8ujQ+209Hhon2uea/OXB5S57NX3enJXok6lkdfy0uRLL09VQEb7VZ75GicTgEqh9CqRbmE6GjwUGh58NRNw47MYeQFMeHat3hgp9qQEw/UDFsOHdJ0hUwwYgQaQDH1sUgR0KQisUnrOTZP0Xz1MURa7xbS/iAIA9XRGNzdGXIQbTuKC9iWTcVyxkK00R2RGo0efKyZp415u80fwcwb0s38ReTRaIYiWOv5CXRMty7hIFGne/TUMYa0IAKykpJMJKtNNM6xGFCViDJ0agMn8PqFBvEM+/cJQNgGMymGTiYIVQew+JFj5jjbSaJjw4NSv7pZxd8zf+YVs4ofFcQCmgFMgTmUFsTqUKJnhFQczuM0BFEsbcwoFrg+SAVHvNlbYJTGlMkFMeo8h2OB0cGCdMDtD6nLUgtagh8j5xGMp4KYHOl14UpMBHFFGYpKt1lUGSJFNWq43WiPm0lG1W9+ArMgh+n+S0GuVgUQ/gXgkgEbA/Swb6JErOFgYwhgI7WHXgzLVA3e8R4y2825vokZQ5RiP4zO93KPaHz1P9OkZgtFPtn8nj5SFOWH72nk9lP/bcsPJCCvscjAdG2liT1NtM7AHQx61ljQQ2B7rtbffFds4wpgxbtFQGA2RnWLzKaV+NiJZlWx68nlb/TJkVaiCLHILW0ia1RkhLObnJdey0MW7PRZy9MK0KOBbqPv+hRDejrmWfPZusotczTGmNA2m5FS2kZ/vnhmjWo5a1HIrIOEeKamuzrTwWQt74UBTdNWAN+woyBaS310ttHhgpymgLE3Iuc3VElElcslIUjBlG4pDtuuBhTHoiEFxoKUIUrVG8Y7mf5y44ZyuaHbIxI1Kjc02uObXRFyJJUFkkoDMv4uRzTrpOSt7yDfsYWFSqONFJ19CeHitYXMEspqdNNbmoOeqr97s03paRWHeVBVdVlVLCnfRzNZ1nBUo59KaTWbo7TcjicDUVpRrPkqxuEZexmck4xEjR787KI9CT7SExxjnFmnjjNddTgaTVZ5X+8CLbOZBfrJ3LeTcqI6uEm3KpBBtLk1/3KctztX4RJSdE0hc7Aj4EcQ+afctRlEnZIHqCs+A5SG9dmkQalYowc42ytnVwqgKw639s9WRlumZ5yQjCJtjjdKwpvHJtFj0xwud6DOJaA9NnPUaP54/v8hI+WrlyRIgd2gNSC0LMEmZ8LZR8scClpXlz/XBcFbIl3HCmNuElKYSGcLsDVYUoXVnoPed58x5GEzEBGDD+94c3xJ16+IcyEJI4YBCyX55O0ZeJ28jAt1Td6FIcLhqV7HRf1pogi0LKwZ9UXrteOexWX70sjTjMBl2aAtAK8uAq8xGHhFiVdDgLf3esRKMd5ge0Ww1bTrw217Jtu5uG3DY7mbzQ2NV4NGxKjAlaHRNhrd/aGB51MSbz7ZZrhHtrw9ewqs36xSK3yPx7MxRD5gF92WORvLZCf+TX7ZZvVO+LLDCBHM/ugt/h5pqXrpRkvStkq93Bj863f6HTQ8T/TjNe/0W+zz4fD79knIfUGHh/8kUVU//EMO8PAP
--------------------------------------------------------------------------------
/shared/crypto/presentation/src/commonTest/kotlin/dev/ohoussein/cryptoapp/crypto/presentation/list/CryptoListViewModelTest.kt:
--------------------------------------------------------------------------------
1 | package dev.ohoussein.cryptoapp.crypto.presentation.list
2 |
3 | import app.cash.turbine.test
4 | import dev.ohoussein.cryptoapp.crypto.domain.model.Locale
5 | import dev.ohoussein.cryptoapp.crypto.presentation.fake.FakeGetTopCryptoListUseCase
6 | import dev.ohoussein.cryptoapp.crypto.presentation.fake.FakePercentFormatter
7 | import dev.ohoussein.cryptoapp.crypto.presentation.fake.FakePriceFormatter
8 | import dev.ohoussein.cryptoapp.crypto.presentation.mapper.DomainModelMapper
9 | import dev.ohoussein.cryptoapp.crypto.presentation.model.DataStatus
10 | import kotlinx.coroutines.Dispatchers
11 | import kotlinx.coroutines.ExperimentalCoroutinesApi
12 | import kotlinx.coroutines.test.UnconfinedTestDispatcher
13 | import kotlinx.coroutines.test.resetMain
14 | import kotlinx.coroutines.test.runTest
15 | import kotlinx.coroutines.test.setMain
16 | import kotlin.test.*
17 |
18 | @ExperimentalCoroutinesApi
19 | class CryptoListViewModelTest {
20 | private lateinit var useCase: FakeGetTopCryptoListUseCase
21 | private lateinit var modelMapper: DomainModelMapper
22 |
23 | @BeforeTest
24 | fun setUp() {
25 | Dispatchers.setMain(UnconfinedTestDispatcher())
26 | useCase = FakeGetTopCryptoListUseCase()
27 | modelMapper = DomainModelMapper(
28 | priceFormatter = FakePriceFormatter(),
29 | percentFormatter = FakePercentFormatter(),
30 | locale = Locale("USD", "en"),
31 | )
32 | }
33 |
34 | @AfterTest
35 | fun tearDown() {
36 | Dispatchers.resetMain()
37 | }
38 |
39 | @Test
40 | fun `Given a list of crypto When observe the state Then should set the state of the crypto list`() = runTest {
41 | val viewModel = CryptoListViewModel(
42 | useCase = useCase,
43 | modelMapper = modelMapper,
44 | )
45 |
46 | viewModel.state.test {
47 | awaitItem().apply {
48 | assertNotNull(cryptoList)
49 | assertEquals(5, cryptoList!!.size)
50 | assertIs(status)
51 | val firstCrypto = cryptoList!!.first()
52 | assertEquals("70 USD", firstCrypto.price.labelValue.label)
53 | assertEquals(70.0, firstCrypto.price.labelValue.value)
54 | assertEquals(-2.0, firstCrypto.priceChangePercentIn24h?.value)
55 | assertEquals("1", firstCrypto.info.id)
56 | assertEquals("crypto 1", firstCrypto.info.name)
57 | assertEquals("CR-1", firstCrypto.info.symbol)
58 | }
59 | }
60 | }
61 |
62 | @Test
63 | fun `Given an error then success When observe and refresh Then it should set the error then the success state`() =
64 | runTest {
65 | useCase.shouldThrowOnRefresh = true
66 | val viewModel = CryptoListViewModel(
67 | useCase = useCase,
68 | modelMapper = modelMapper,
69 | )
70 |
71 | viewModel.state.test {
72 | assertIs(awaitItem().status)
73 |
74 | useCase.shouldThrowOnRefresh = false
75 | viewModel.dispatch(CryptoListEvents.Refresh)
76 |
77 | awaitItem().apply {
78 | assertIs(status)
79 | assertNotNull(cryptoList)
80 | assertEquals(5, cryptoList!!.size)
81 | }
82 | }
83 | }
84 | }
85 |
--------------------------------------------------------------------------------
/shared/crypto/presentation/src/commonMain/composeResources/drawable/ic_bear.xml:
--------------------------------------------------------------------------------
1 |
6 |
10 |
11 |
--------------------------------------------------------------------------------
/.github/workflows/main_ci.yml:
--------------------------------------------------------------------------------
1 | name: Main CI
2 |
3 | on:
4 | pull_request:
5 | branches:
6 | - 'main'
7 | push:
8 | branches:
9 | - 'main'
10 |
11 | jobs:
12 | detekt:
13 | runs-on: ubuntu-latest
14 |
15 | steps:
16 | - uses: actions/checkout@v3
17 | - name: Set up JDK
18 | uses: actions/setup-java@v3
19 | with:
20 | distribution: 'adopt'
21 | java-version: 20
22 |
23 | - name: Run Detekt
24 | run: ./gradlew detekt
25 |
26 | - name: Run Lint
27 | run: ./gradlew lint
28 |
29 | unit-tests:
30 | name: Run Unit Tests with coverage
31 | runs-on: ubuntu-latest
32 |
33 | steps:
34 | - name: Checkout
35 | uses: actions/checkout@v3
36 |
37 | - name: Set up JDK
38 | uses: actions/setup-java@v3
39 | with:
40 | distribution: 'adopt'
41 | java-version: 20
42 |
43 | - name: Run Release Unit Tests
44 | run: bash ./gradlew koverMergedReport
45 |
46 | - name: Upload coverage report
47 | uses: actions/upload-artifact@v4
48 | with:
49 | name: coverage-report
50 | path: build/reports/kover/merged/html/index.html
51 |
52 | Android:
53 | name: Android Build
54 | runs-on: ubuntu-latest
55 |
56 | steps:
57 | - name: Checkout
58 | uses: actions/checkout@v3
59 |
60 | - name: Set up JDK
61 | uses: actions/setup-java@v3
62 | with:
63 | distribution: 'adopt'
64 | java-version: 20
65 |
66 | - name: Build app
67 | run: bash ./gradlew app-android:assembleRelease
68 |
69 | - name: Upload Release APK
70 | uses: actions/upload-artifact@v4
71 | with:
72 | name: apk
73 | path: ./app-android/build/outputs/apk/release/app-android-release.apk
74 |
75 | - name: Upload mapping file
76 | uses: actions/upload-artifact@v4
77 | with:
78 | name: mapping
79 | path: ./app-android/build/outputs/mapping/release/mapping.txt
80 |
81 | iOS:
82 | name: iOS Build
83 | runs-on: macOS-latest
84 | timeout-minutes: 20
85 |
86 | steps:
87 | - name: Checkout
88 | uses: actions/checkout@v3
89 |
90 | - name: Set up JDK
91 | uses: actions/setup-java@v3
92 | with:
93 | distribution: 'adopt'
94 | java-version: 20
95 |
96 | - name: Build
97 | run: |
98 | xcodebuild \
99 | -workspace app-iOS/appiOS.xcodeproj/project.xcworkspace \
100 | -configuration Debug \
101 | -scheme appiOS \
102 | -sdk iphonesimulator \
103 | -derivedDataPath app-iOS/build
104 |
105 | - name: Upload iOS APP
106 | uses: actions/upload-artifact@v4
107 | with:
108 | name: 'ios_app'
109 | path: app-iOS/build/Build/Products/Debug-iphonesimulator/CryptoApp.app
110 |
111 | Desktop:
112 | name: Desktop Build
113 | runs-on: macOS-latest
114 |
115 | steps:
116 | - name: Checkout
117 | uses: actions/checkout@v3
118 |
119 | - name: Set up JDK
120 | uses: actions/setup-java@v3
121 | with:
122 | distribution: 'adopt'
123 | java-version: 20
124 |
125 | - name: Build app
126 | run: bash ./gradlew app-desktop:packageDmg
127 |
128 | - name: Upload Desktop binaries
129 | uses: actions/upload-artifact@v4
130 | with:
131 | name: 'desktop_app'
132 | path: ./app-desktop/build/compose/binaries/main/dmg/dev.ohoussein.cryptoapp-1.0.0.dmg
133 |
134 |
--------------------------------------------------------------------------------
/shared/crypto/presentation/src/commonTest/kotlin/dev/ohoussein/cryptoapp/crypto/presentation/graph/CryptoPriceGraphViewModelTest.kt:
--------------------------------------------------------------------------------
1 | package dev.ohoussein.cryptoapp.crypto.presentation.graph
2 |
3 | import app.cash.turbine.test
4 | import dev.ohoussein.cryptoapp.crypto.domain.model.Locale
5 | import dev.ohoussein.cryptoapp.crypto.domain.model.defaultLocale
6 | import dev.ohoussein.cryptoapp.crypto.presentation.fake.FakeGetCryptoDetailsUseCase
7 | import dev.ohoussein.cryptoapp.crypto.presentation.fake.FakePercentFormatter
8 | import dev.ohoussein.cryptoapp.crypto.presentation.fake.FakePriceFormatter
9 | import dev.ohoussein.cryptoapp.crypto.presentation.mapper.DomainModelMapper
10 | import dev.ohoussein.cryptoapp.crypto.presentation.model.GraphInterval
11 | import kotlinx.coroutines.Dispatchers
12 | import kotlinx.coroutines.ExperimentalCoroutinesApi
13 | import kotlinx.coroutines.test.UnconfinedTestDispatcher
14 | import kotlinx.coroutines.test.resetMain
15 | import kotlinx.coroutines.test.runTest
16 | import kotlinx.coroutines.test.setMain
17 | import kotlin.test.*
18 |
19 | @ExperimentalCoroutinesApi
20 | class CryptoPriceGraphViewModelTest {
21 | private val testDispatcher = UnconfinedTestDispatcher()
22 |
23 | private lateinit var useCase: FakeGetCryptoDetailsUseCase
24 | private lateinit var modelMapper: DomainModelMapper
25 |
26 | @BeforeTest
27 | fun setUp() {
28 | Dispatchers.setMain(testDispatcher)
29 | useCase = FakeGetCryptoDetailsUseCase()
30 | modelMapper = DomainModelMapper(
31 | priceFormatter = FakePriceFormatter(),
32 | percentFormatter = FakePercentFormatter(),
33 | locale = Locale("USD", "en"),
34 | )
35 | }
36 |
37 | @AfterTest
38 | fun tearDown() {
39 | Dispatchers.resetMain()
40 | }
41 |
42 | @Test
43 | fun `Given a crypto When observe the state Then should set the state`() = runTest {
44 | val viewModel = viewModel()
45 |
46 | viewModel.state.test {
47 | awaitItem().apply {
48 | assertTrue(graphPrices.isNotEmpty())
49 | assertEquals(GraphInterval.entries.size, allIntervals.size)
50 | }
51 | }
52 | }
53 |
54 | @Test
55 | fun `Given a crypto When SelectInterval Then it should set the new interval`() = runTest {
56 | val viewModel = viewModel()
57 |
58 | viewModel.dispatch(CryptoPriceGraphEvents.SelectInterval(GraphInterval.INTERVAL_1_MONTH))
59 |
60 | with(viewModel.state.value) {
61 | assertEquals(GraphInterval.INTERVAL_1_MONTH, selectedInterval)
62 | assertEquals(30, graphPrices.size)
63 | }
64 | }
65 |
66 | @Test
67 | fun `Given a cached historical prices When Select the same Interval twice Then it should set the value from the cache`() =
68 | runTest {
69 | val viewModel = viewModel()
70 |
71 | viewModel.dispatch(CryptoPriceGraphEvents.SelectInterval(GraphInterval.INTERVAL_1_MONTH))
72 | viewModel.dispatch(CryptoPriceGraphEvents.SelectInterval(GraphInterval.INTERVAL_1_DAY))
73 | useCase.shouldThrowOnRefresh = true
74 | viewModel.dispatch(CryptoPriceGraphEvents.SelectInterval(GraphInterval.INTERVAL_1_MONTH))
75 |
76 | with(viewModel.state.value) {
77 | assertEquals(GraphInterval.INTERVAL_1_MONTH, selectedInterval)
78 | assertEquals(30, graphPrices.size)
79 | }
80 | }
81 |
82 | private fun viewModel() = CryptoPriceGraphViewModel(
83 | useCase = useCase,
84 | modelMapper = modelMapper,
85 | graphGridGenerator = GraphGridGenerator(FakePriceFormatter(), defaultLocale),
86 | cryptoId = "bitcoin",
87 | )
88 | }
89 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | [](https://github.com/OHoussein/CryptoApp/actions/workflows/main_ci.yml)
2 | [](https://sonarcloud.io/dashboard?id=OHoussein_android-crypto-app)
3 |
4 |
5 | A cross-platform app to display cryptocurrency prices, built using Kotlin Multiplatform and Compose Multiplatform. The app targets Android, iOS, and Desktop (JVM) platforms.
6 |
7 | ## Features
8 |
9 | - View real-time cryptocurrency prices.
10 | - Offline first.
11 | - Cross-platform support: Android, iOS, and Desktop.
12 | - Coroutine and Flow for asynchronous operations.
13 | - Comprehensive testing with Maestro and JUnit.
14 | - Test coverage reports with Kover.
15 |
16 | ## Tech Stack
17 |
18 | - **Kotlin Multiplatform**: Share code between Android, iOS, and Desktop.
19 | - **Compose Multiplatform**: Build UI across platforms with Jetpack Compose.
20 | - **Multi-module clean architecture**: Maintainable and scalable project structure.
21 | - **Koin**: Lightweight dependency injection framework.
22 | - **Coroutines/Flow**: Asynchronous programming.
23 | - **Ktor**: HTTP client.
24 | - **SQLDelight**: Type-safe SQL, and multiplatform persistence library.
25 | - **JUnit**: Unit testing framework.
26 | - **Kover**: Code coverage tool for Kotlin.
27 | - **Maestro**: End-to-end test automation framework.
28 |
29 | ## Architecture
30 |
31 | The app follows the clean architecture principle, which includes:
32 | - **Domain Layer**: Contains business logic.
33 | - **Data Layer**: Handles data operations, including API calls and local database.
34 | - **Presentation Layer**: Contains UI components built with Compose Multiplatform.
35 |
36 | ## Setup and Installation
37 | - **JDK 20** to build the app
38 | - [JVM](https://www.java.com/en/download/help/download_options.html) to run the desktop app
39 | - [Xcode](https://developer.apple.com/xcode/) to build the iOS app
40 | - Recommended IDE: [Intellij](https://www.jetbrains.com/idea/) or [Fleet](https://www.jetbrains.com/fleet/)
41 | - [Maestro CLI](https://maestro.mobile.dev/) if you want to run the end to end tests.
42 |
43 | ## Build the app
44 | ### Android
45 | ```shell
46 | ./gradlew app-android:assembleRelease
47 | ```
48 | ### iOS
49 | ```shell
50 | xcodebuild \
51 | -workspace app-iOS/appiOS.xcodeproj/project.xcworkspace \
52 | -configuration Debug \
53 | -scheme appiOS \
54 | -sdk iphonesimulator \
55 | -derivedDataPath app-iOS/build
56 | ```
57 | ### Desktop
58 | ```shell
59 | ./gradlew app-desktop:run
60 | ```
61 |
62 | ## Screenshots 📸
63 | ## Android
64 |
65 |
66 |
67 |
68 |
69 |
70 | ## iOS
71 |
72 |
73 |
74 |
75 |
76 |
77 | ## Desktop
78 |
79 |
80 |
81 |
82 |
83 |
84 | ## Credit
85 |
86 | Data are provided by the awesome [CoinGecko API](https://www.coingecko.com/en/api)
87 |
--------------------------------------------------------------------------------
/shared/data/network/src/commonTest/kotlin/dev/ohoussein/cryptoapp/data/network/crypto/service/ApiCryptoServiceImplTest.kt:
--------------------------------------------------------------------------------
1 | package dev.ohoussein.cryptoapp.data.network.crypto.service
2 |
3 | import dev.ohoussein.cryptoapp.data.network.crypto.model.CryptoImageResponse
4 | import dev.ohoussein.cryptoapp.data.network.crypto.model.HistoricalPricesDTO
5 | import dev.ohoussein.cryptoapp.data.network.crypto.model.SparkLineDTO
6 | import dev.ohoussein.cryptoapp.data.network.crypto.model.TopCryptoResponse
7 | import dev.ohoussein.cryptoapp.data.network.crypto.service.mocks.mockCryptoDetailsJson
8 | import dev.ohoussein.cryptoapp.data.network.crypto.service.mocks.mockHistoricalPricesJson
9 | import dev.ohoussein.cryptoapp.data.network.crypto.service.mocks.mockTopCryptoListJson
10 | import dev.ohoussein.cryptoapp.data.network.crypto.service.utils.mockedHttpClient
11 | import kotlinx.coroutines.runBlocking
12 | import kotlinx.coroutines.test.runTest
13 | import kotlin.test.Test
14 | import kotlin.test.assertContains
15 | import kotlin.test.assertEquals
16 | import kotlin.test.assertNotNull
17 |
18 | class ApiCryptoServiceImplTest {
19 |
20 | @Test
21 | fun test_getTopCrypto(): Unit = runBlocking {
22 | val httpClient = mockedHttpClient(mockTopCryptoListJson)
23 | val apiCryptoService: ApiCryptoService = ApiCryptoServiceImpl(httpClient)
24 |
25 | val topCryptoList = apiCryptoService.getTopCrypto("USD")
26 |
27 | val expectedFirstItem = TopCryptoResponse(
28 | id = "bitcoin",
29 | symbol = "btc",
30 | name = "Bitcoin",
31 | image = "https://assets.coingecko.com/coins/images/1/large/bitcoin.png?1547033579",
32 | currentPrice = 31827.0,
33 | priceChangePercentIn24h = -0.4901,
34 | sparklineIn7d = SparkLineDTO(
35 | price = listOf(30000.0, 31000.0, 32000.0, 33000.0)
36 | )
37 | )
38 | assertEquals(expectedFirstItem, topCryptoList.first())
39 | }
40 |
41 | @Test
42 | fun test_getCryptoDetails(): Unit = runBlocking {
43 | val httpClient = mockedHttpClient(mockCryptoDetailsJson)
44 | val apiCryptoService: ApiCryptoService = ApiCryptoServiceImpl(httpClient)
45 |
46 | val details = apiCryptoService.getCryptoDetails("bitcoin")
47 |
48 | assertNotNull(details)
49 | with(details) {
50 | assertEquals("bitcoin", id)
51 | assertEquals("Bitcoin", name)
52 | assertEquals("btc", symbol)
53 | assertEquals(32.67, sentimentDownVotesPercentage)
54 | assertEquals(67.33, sentimentUpVotesPercentage)
55 | assertEquals("SHA-256", hashingAlgorithm)
56 | assertContains(links.blockchainSite, "https://blockchair.com/bitcoin/")
57 | assertContains(links.homepage, "http://www.bitcoin.org")
58 | assertContains(links.reposUrl, "github")
59 | assertContains(links.reposUrl["github"]!!, "https://github.com/bitcoin/bitcoin")
60 |
61 | assertEquals(
62 | CryptoImageResponse(
63 | thumb = "https://assets.coingecko.com/coins/images/1/thumb/bitcoin.png?1547033579",
64 | small = "https://assets.coingecko.com/coins/images/1/small/bitcoin.png?1547033579",
65 | large = "https://assets.coingecko.com/coins/images/1/large/bitcoin.png?1547033579"
66 | ),
67 | image
68 | )
69 | }
70 | }
71 |
72 | @Test
73 | fun `Given a Json of historical prices When getHistoricalPrices it should return the right response`() = runTest {
74 | val httpClient = mockedHttpClient(mockHistoricalPricesJson)
75 | val apiCryptoService: ApiCryptoService = ApiCryptoServiceImpl(httpClient)
76 |
77 | val response = apiCryptoService.getHistoricalPrices("USD", "bitcoin", 7)
78 |
79 | val expectedResponse = HistoricalPricesDTO(
80 | prices = listOf(
81 | listOf(1711843200000.0, 69702.3087473573),
82 | listOf(1711929600000.0, 71246.95144060145),
83 | listOf(1711983682000.0, 68887.74951585678)
84 | )
85 | )
86 | assertEquals(expectedResponse, response)
87 | }
88 | }
89 |
--------------------------------------------------------------------------------
/shared/crypto/presentation/src/commonMain/kotlin/dev/ohoussein/cryptoapp/crypto/presentation/graph/GraphGridGenerator.kt:
--------------------------------------------------------------------------------
1 | package dev.ohoussein.cryptoapp.crypto.presentation.graph
2 |
3 | import dev.ohoussein.cryptoapp.core.formatter.PriceFormatter
4 | import dev.ohoussein.cryptoapp.crypto.domain.model.HistoricalPrice
5 | import dev.ohoussein.cryptoapp.crypto.domain.model.Locale
6 | import dev.ohoussein.cryptoapp.crypto.presentation.core.copy
7 | import dev.ohoussein.cryptoapp.crypto.presentation.core.interpolateValues
8 | import dev.ohoussein.cryptoapp.crypto.presentation.model.GraphInterval
9 | import dev.ohoussein.cryptoapp.designsystem.graph.model.GridPoint
10 | import kotlinx.datetime.*
11 | import kotlinx.datetime.format.FormatStringsInDatetimeFormats
12 | import kotlinx.datetime.format.byUnicodePattern
13 | import kotlin.time.Duration.Companion.days
14 | import kotlin.time.Duration.Companion.milliseconds
15 |
16 | private const val THRESHOLD_IGNORE_MINUTES_IN_DAYS = 2
17 | private const val THRESHOLD_IGNORE_HOURS_IN_DAYS = 4 * 30
18 |
19 | class GraphGridGenerator(
20 | private val priceFormatter: PriceFormatter,
21 | private val locale: Locale,
22 | private val timeZone: TimeZone = TimeZone.currentSystemDefault(),
23 | ) {
24 |
25 | @OptIn(FormatStringsInDatetimeFormats::class)
26 | fun getTimeGridInstants(
27 | prices: List,
28 | countValues: Int,
29 | timeInterval: GraphInterval,
30 | ): List {
31 | val start = prices.first().timestampMillis
32 | val end = prices.last().timestampMillis
33 |
34 | return getTimeGridInstants(start, end, countValues).map { instant ->
35 | val date = instant.toLocalDateTime(timeZone)
36 | val string = date.format(LocalDateTime.Format { byUnicodePattern(timeInterval.timeFormat) })
37 | GridPoint(instant.toEpochMilliseconds().toDouble(), string)
38 | }
39 | }
40 |
41 | fun getPriceGridInstants(
42 | prices: List,
43 | countValues: Int,
44 | ): List {
45 | return interpolateValues(
46 | start = prices.minOf { it.price },
47 | end = prices.maxOf { it.price },
48 | countValues = countValues
49 | ).map { price ->
50 | GridPoint(
51 | position = price,
52 | label = priceFormatter(price, locale.currencyCode),
53 | )
54 | }
55 | }
56 |
57 | private fun getTimeGridInstants(
58 | startEpochMilliseconds: Long,
59 | endEpochMilliseconds: Long,
60 | countValues: Int,
61 | ): List {
62 | return interpolateValues(startEpochMilliseconds, endEpochMilliseconds, countValues)
63 | .mapIndexed { index, time ->
64 | val duration = (endEpochMilliseconds - startEpochMilliseconds).milliseconds
65 | when {
66 | index == 0 -> Instant.fromEpochMilliseconds(startEpochMilliseconds)
67 | index == countValues - 1 -> Instant.fromEpochMilliseconds(endEpochMilliseconds)
68 |
69 | duration < THRESHOLD_IGNORE_MINUTES_IN_DAYS.days -> {
70 | Instant.fromEpochMilliseconds(time)
71 | .toLocalDateTime(timeZone)
72 | .copy(minute = 0, second = 0, nanosecond = 0)
73 | .toInstant(timeZone)
74 | }
75 |
76 | duration < THRESHOLD_IGNORE_HOURS_IN_DAYS.days -> {
77 | Instant.fromEpochMilliseconds(time)
78 | .toLocalDateTime(timeZone)
79 | .copy(hour = 0, minute = 0, second = 0, nanosecond = 0)
80 | .toInstant(timeZone)
81 | }
82 |
83 | else -> {
84 | Instant.fromEpochMilliseconds(time)
85 | .toLocalDateTime(timeZone)
86 | .copy(dayOfMonth = 1, hour = 0, minute = 0, second = 0, nanosecond = 0)
87 | .toInstant(timeZone)
88 | }
89 | }
90 | }
91 | }
92 |
93 | private val GraphInterval.timeFormat: String
94 | get() = when (this) {
95 | GraphInterval.INTERVAL_1_DAY -> "HH:mm"
96 | else -> "MM-dd"
97 | }
98 | }
99 |
--------------------------------------------------------------------------------
/shared/designsystem/src/commonTest/kotlin/dev/ohoussein/cryptoapp/designsystem/core/AnnotatedStringTest.kt:
--------------------------------------------------------------------------------
1 | package dev.ohoussein.cryptoapp.designsystem.core
2 |
3 | import androidx.compose.ui.text.LinkAnnotation
4 | import androidx.compose.ui.text.buildAnnotatedString
5 | import androidx.compose.ui.text.font.FontWeight
6 | import kotlin.test.Test
7 | import kotlin.test.assertEquals
8 |
9 | class AnnotatedStringTest {
10 |
11 | @Test
12 | fun `Given a html with bold tag When htmlToAnnotatedString it should return the right annotatedString`() {
13 | val pureText = "This is bold text."
14 | val html = "This is bold text."
15 |
16 | val annotatedString = html.htmlToAnnotatedString()
17 |
18 | val linkAnnotations = annotatedString.getLinkAnnotations(0, html.length)
19 | assertEquals(pureText, annotatedString.text)
20 | assertEquals(0, linkAnnotations.size)
21 | assertEquals(1, annotatedString.spanStyles.size)
22 | with(annotatedString.spanStyles.first()) {
23 | assertEquals(FontWeight.Bold, item.fontWeight)
24 | assertEquals(pureText.indexOf("bold"), start)
25 | assertEquals(pureText.indexOf(" text."), end)
26 | }
27 | }
28 |
29 | @Test
30 | fun `Given a html with paragraph tags When htmlToAnnotatedString it should return the right annotatedString`() {
31 | val html = "First paragraph.
Second paragraph.
"
32 | val annotatedString = html.htmlToAnnotatedString()
33 | val expected = buildAnnotatedString {
34 | append("\nFirst paragraph.\n")
35 | append("\nSecond paragraph.\n")
36 | }
37 | assertEquals(expected, annotatedString)
38 | }
39 |
40 | @Test
41 | fun `Given a html with link tag When htmlToAnnotatedString it should return the right annotatedString`() {
42 | val pureText = "This is an example link."
43 | val html = """This is an example link ."""
44 |
45 | val annotatedString = html.htmlToAnnotatedString()
46 |
47 | val linkAnnotation = annotatedString.getLinkAnnotations(
48 | start = html.indexOf(""),
50 | )
51 | assertEquals(1, linkAnnotation.size)
52 | assertEquals(0, annotatedString.getStringAnnotations(0, html.length).size)
53 | with(linkAnnotation.first()) {
54 | assertEquals(LinkAnnotation.Url("https://example.com"), item)
55 | assertEquals(pureText.indexOf("example link"), start)
56 | assertEquals(pureText.lastIndexOf("."), end)
57 | }
58 | assertEquals(pureText, annotatedString.text)
59 | }
60 |
61 | @Test
62 | fun `Given a html with 2 links tag When getLinkUrl it should return the correct URL`() {
63 | val pureText = "This is the first link and this is the second one."
64 | val html =
65 | """This is the first link and this is the second one ."""
66 |
67 | val annotatedString = html.htmlToAnnotatedString()
68 |
69 | val linkAnnotations = annotatedString.getLinkAnnotations(0, html.length)
70 | val stringAnnotations = annotatedString.getStringAnnotations(0, html.length)
71 | assertEquals(pureText, annotatedString.text)
72 | assertEquals(2, linkAnnotations.size)
73 | assertEquals(0, stringAnnotations.size)
74 | with(linkAnnotations[0]) {
75 | assertEquals(LinkAnnotation.Url("https://first.com"), item)
76 | assertEquals(pureText.indexOf("first link"), start)
77 | assertEquals(pureText.lastIndexOf(" and this is"), end)
78 | }
79 | with(linkAnnotations[1]) {
80 | assertEquals(LinkAnnotation.Url("https://second.com"), item)
81 | assertEquals(pureText.indexOf("second one"), start)
82 | assertEquals(pureText.lastIndexOf("."), end)
83 | }
84 | }
85 |
86 | @Test
87 | fun `Given a plain text When getLinkUrl it should return null`() {
88 | val html = "This is plain text."
89 | val annotatedString = html.htmlToAnnotatedString()
90 |
91 | val linkAnnotations = annotatedString.getLinkAnnotations(0, html.length)
92 | val stringAnnotations = annotatedString.getStringAnnotations(0, html.length)
93 | assertEquals("This is plain text.", annotatedString.text)
94 | assertEquals(0, linkAnnotations.size)
95 | assertEquals(0, stringAnnotations.size)
96 | }
97 | }
98 |
--------------------------------------------------------------------------------