diff --git a/.github/auto_assign.yml b/.github/auto_assign.yml new file mode 100644 index 0000000000..717af2a0c8 --- /dev/null +++ b/.github/auto_assign.yml @@ -0,0 +1 @@ +addAssignees: author diff --git a/.github/workflows/board.yml b/.github/workflows/board.yml index 07d05e8425..ad29196a90 100644 --- a/.github/workflows/board.yml +++ b/.github/workflows/board.yml @@ -48,17 +48,7 @@ jobs: status_value: "🚧 In Progress" - name: 'Move PR to "🏗 PR Review"' - if: github.event_name == 'pull_request' && (github.event.action == 'opened' || github.event.action == 'reopened' || github.event.action == 'review_requested') - uses: leonsteinhaeuser/project-beta-automations@v1.2.1 - with: - gh_token: ${{ secrets.PERSONAL_ACCESS_TOKEN }} - organization: Oztechan - project_id: 2 - resource_node_id: ${{ github.event.pull_request.node_id }} - status_value: "🏗 PR Review" - - - name: 'Move PR to "🏗 PR Review"' - if: github.event_name == 'pull_request' && github.event.action == 'ready_for_review' + if: github.event_name == 'pull_request' && (github.event.action == 'opened' || github.event.action == 'reopened' || github.event.action == 'ready_for_review') uses: leonsteinhaeuser/project-beta-automations@v1.2.1 with: gh_token: ${{ secrets.PERSONAL_ACCESS_TOKEN }} diff --git a/.github/workflows/dependency.yml b/.github/workflows/dependency.yml index b628cbc9b7..2570ec4808 100644 --- a/.github/workflows/dependency.yml +++ b/.github/workflows/dependency.yml @@ -17,20 +17,35 @@ jobs: with: fetch-depth: 0 + - name: Adding secret files + run: | + mkdir android/src/release + echo "${{ secrets.RELEASE_GOOGLE_SERVICES_JSON_ASC }}" > google-services.json.asc + gpg -d --passphrase "${{ secrets.SECRET_PASSWORD }}" --batch google-services.json.asc > android/src/release/google-services.json + mkdir android/src/debug + echo "${{ secrets.DEBUG_GOOGLE_SERVICES_JSON_ASC }}" > google-services.json.asc + gpg -d --passphrase "${{ secrets.SECRET_PASSWORD }}" --batch google-services.json.asc > android/src/debug/google-services.json + - name: Set up JDK 11 uses: actions/setup-java@v1 with: java-version: 11 - - name: Run dependencyUpdates Task - run: ./gradlew dependencyUpdates --parallel + - name: Run dependencyUpdates and buildHealth tasks + run: ./gradlew dependencyUpdates buildHealth --parallel - - name: Upload dependencies report + - name: Upload Dependency Updates report uses: actions/upload-artifact@v2 with: - name: report.txt + name: dependency-updates path: build/dependencyUpdates/report.txt + - name: Upload Build Healt Report + uses: actions/upload-artifact@v2 + with: + name: build-health + path: build/reports/dependency-analysis/build-health-report.txt + - name: Set Job Status id: status run: echo "::set-output name=status::success" diff --git a/.gitignore b/.gitignore index f4d12a97c0..cde9d5941b 100755 --- a/.gitignore +++ b/.gitignore @@ -17,10 +17,3 @@ .cxx local.properties secret.properties - -# Xcode -*.xcworkspacedata -*.xcuserstate -*.xcscheme -xcschememanagement.plist -*.xcbkptlist \ No newline at end of file diff --git a/ad/src/google/kotlin/com/oztechan/ccc/ad/AdManagerImpl.kt b/ad/src/google/kotlin/com/oztechan/ccc/ad/AdManagerImpl.kt index e1961ba92f..e67f183d13 100644 --- a/ad/src/google/kotlin/com/oztechan/ccc/ad/AdManagerImpl.kt +++ b/ad/src/google/kotlin/com/oztechan/ccc/ad/AdManagerImpl.kt @@ -36,9 +36,11 @@ class AdManagerImpl : AdManager { width.toFloat() } - adSize = AdSize.getCurrentOrientationAnchoredAdaptiveBannerAdSize( - context, - (adWidthPixels / resources.displayMetrics.density).toInt() + setAdSize( + AdSize.getCurrentOrientationAnchoredAdaptiveBannerAdSize( + context, + (adWidthPixels / resources.displayMetrics.density).toInt() + ) ) adUnitId = adId adListener = object : AdListener() { diff --git a/android/src/main/kotlin/com/oztechan/ccc/android/ui/calculator/CalculatorFragment.kt b/android/src/main/kotlin/com/oztechan/ccc/android/ui/calculator/CalculatorFragment.kt index 64947a15da..49161645ce 100755 --- a/android/src/main/kotlin/com/oztechan/ccc/android/ui/calculator/CalculatorFragment.kt +++ b/android/src/main/kotlin/com/oztechan/ccc/android/ui/calculator/CalculatorFragment.kt @@ -148,7 +148,7 @@ class CalculatorFragment : BaseVBFragment() { } CalculatorEffect.MaximumInput -> showSnack( requireView(), - R.string.max_input + R.string.text_max_input ) CalculatorEffect.OpenBar -> navigate( R.id.calculatorFragment, diff --git a/android/src/main/kotlin/com/oztechan/ccc/android/ui/settings/SettingsFragment.kt b/android/src/main/kotlin/com/oztechan/ccc/android/ui/settings/SettingsFragment.kt index 4ebacd9b06..86005d9be1 100644 --- a/android/src/main/kotlin/com/oztechan/ccc/android/ui/settings/SettingsFragment.kt +++ b/android/src/main/kotlin/com/oztechan/ccc/android/ui/settings/SettingsFragment.kt @@ -112,7 +112,7 @@ class SettingsFragment : BaseVBFragment() { with(it) { binding.loadingView.visibleIf(loading) binding.itemCurrencies.settingsItemValue.text = requireContext().getString( - R.string.settings_item_currencies_value, + R.string.settings_active_item_value, activeCurrencyCount ) binding.itemTheme.settingsItemValue.text = appThemeType.typeName @@ -184,6 +184,7 @@ class SettingsFragment : BaseVBFragment() { requireView(), R.string.txt_ads_already_disabled ) + SettingsEffect.OpenWatchers -> TODO("No Android implementation yet") } }.launchIn(viewLifecycleOwner.lifecycleScope) diff --git a/backend/src/jvmMain/kotlin/com/oztechan/ccc/backend/util/SourceUtil.kt b/backend/src/jvmMain/kotlin/com/oztechan/ccc/backend/util/SourceUtil.kt index d30db68a74..349cf8f7c4 100644 --- a/backend/src/jvmMain/kotlin/com/oztechan/ccc/backend/util/SourceUtil.kt +++ b/backend/src/jvmMain/kotlin/com/oztechan/ccc/backend/util/SourceUtil.kt @@ -6,4 +6,4 @@ package com.oztechan.ccc.backend.util fun ClassLoader.getResourceByName( source: String -) = getResource(source)?.readText() ?: "" +) = getResource(source)?.readText().orEmpty() diff --git a/buildSrc/src/main/kotlin/ProjectSettings.kt b/buildSrc/src/main/kotlin/ProjectSettings.kt index d1dc6db933..6bf6b78232 100644 --- a/buildSrc/src/main/kotlin/ProjectSettings.kt +++ b/buildSrc/src/main/kotlin/ProjectSettings.kt @@ -8,10 +8,10 @@ import java.io.File object ProjectSettings { private const val MAYOR_VERSION = 2 - private const val MINOR_VERSION = 6 + private const val MINOR_VERSION = 7 // git rev-list --first-parent --count master +1 - private const val VERSION_DIF = 713 + private const val VERSION_DIF = 728 private const val BASE_VERSION_CODE = 316 const val PROJECT_ID = "com.oztechan.ccc" diff --git a/buildSrc/src/main/kotlin/Versions.kt b/buildSrc/src/main/kotlin/Versions.kt index 9f87404771..c83af445d8 100644 --- a/buildSrc/src/main/kotlin/Versions.kt +++ b/buildSrc/src/main/kotlin/Versions.kt @@ -4,11 +4,11 @@ @Suppress("SpellCheckingInspection") object Versions { - const val KOTLIN = "1.6.21" - const val KSP = "$KOTLIN-1.0.5" - const val ANDROID_GRADLE_PLUGIN = "7.1.3" - const val ANDROID_MATERIAL = "1.6.0" - const val CONSTRAINT_LAYOUT = "2.1.3" + const val KOTLIN = "1.7.0" + const val KSP = "$KOTLIN-1.0.6" + const val ANDROID_GRADLE_PLUGIN = "7.2.1" + const val ANDROID_MATERIAL = "1.6.1" + const val CONSTRAINT_LAYOUT = "2.1.4" const val KTOR = "1.6.8" const val LOG_BACK = "1.2.11" const val KOIN = "3.1.6" @@ -17,8 +17,8 @@ object Versions { const val FIREBASE_REMOTE_CONFIG = "21.1.0" const val DESUGARING = "1.1.5" const val GSM = "4.3.10" - const val CRASHLYTICS = "2.8.1" - const val ADMOB = "20.6.0" + const val CRASHLYTICS = "2.9.0" + const val ADMOB = "21.0.0" const val NAVIGATION = "2.3.5" const val PLAY_CORE = "1.10.3" const val KOTLIN_X_DATE_TIME = "0.3.2" @@ -27,15 +27,15 @@ object Versions { const val LEAK_CANARY = "2.9.1" const val SQL_DELIGHT = "1.5.3" const val LIFECYCLE = "2.4.1" - const val MOKO_RESOURCES = "0.19.1" + const val MOKO_RESOURCES = "0.20.1" const val DEPENDENCY_UPDATES = "0.42.0" - const val BUILD_HEALTH = "1.1.0" - const val BUILD_KONFIG = "0.11.0" + const val BUILD_HEALTH = "1.5.0" + const val BUILD_KONFIG = "0.12.0" const val WORK_RUNTIME = "2.7.1" const val SPLASH_SCREEN = "1.0.0-alpha02" - const val KOVER = "0.5.0" + const val KOVER = "0.5.1" const val ROOT_BEER = "0.1.0" - const val MOCKATIVE = "1.1.4" + const val MOCKATIVE = "1.2.5" const val SCOPE_MOB = "2.1.5" const val PARSER_MOB = "1.1.6" const val BASE_MOB = "2.1.4" diff --git a/client/src/androidMain/kotlin/com/oztechan/ccc/client/util/AndroidCalculatorUtil.kt b/client/src/androidMain/kotlin/com/oztechan/ccc/client/util/AndroidCalculatorUtil.kt index 3b6b5a2f29..8c30387ca8 100644 --- a/client/src/androidMain/kotlin/com/oztechan/ccc/client/util/AndroidCalculatorUtil.kt +++ b/client/src/androidMain/kotlin/com/oztechan/ccc/client/util/AndroidCalculatorUtil.kt @@ -4,6 +4,7 @@ package com.oztechan.ccc.client.util +import com.oztechan.ccc.client.viewmodel.watchers.WatchersData import java.text.DecimalFormat import java.text.DecimalFormatSymbols import java.util.Locale @@ -25,3 +26,8 @@ actual fun Double.getFormatted(): String { decimalFormat = "$decimalFormat#" return DecimalFormat(decimalFormat, symbols).format(this) } + +actual fun Double.removeScientificNotation() = DecimalFormat("#.#").apply { + maximumFractionDigits = WatchersData.MAXIMUM_INPUT + decimalFormatSymbols = DecimalFormatSymbols.getInstance(Locale.ENGLISH) +}.format(this) diff --git a/client/src/commonMain/kotlin/com/oztechan/ccc/client/di/module/ClientModule.kt b/client/src/commonMain/kotlin/com/oztechan/ccc/client/di/module/ClientModule.kt index 12fd97a1eb..1a31f503c4 100644 --- a/client/src/commonMain/kotlin/com/oztechan/ccc/client/di/module/ClientModule.kt +++ b/client/src/commonMain/kotlin/com/oztechan/ccc/client/di/module/ClientModule.kt @@ -1,26 +1,31 @@ package com.oztechan.ccc.client.di.module import com.oztechan.ccc.client.di.viewModelDefinition -import com.oztechan.ccc.client.helper.SessionManager -import com.oztechan.ccc.client.helper.SessionManagerImpl +import com.oztechan.ccc.client.manager.background.BackgroundManager +import com.oztechan.ccc.client.manager.background.BackgroundManagerImpl +import com.oztechan.ccc.client.manager.session.SessionManager +import com.oztechan.ccc.client.manager.session.SessionManagerImpl import com.oztechan.ccc.client.viewmodel.adremove.AdRemoveViewModel import com.oztechan.ccc.client.viewmodel.calculator.CalculatorViewModel import com.oztechan.ccc.client.viewmodel.currencies.CurrenciesViewModel import com.oztechan.ccc.client.viewmodel.main.MainViewModel import com.oztechan.ccc.client.viewmodel.selectcurrency.SelectCurrencyViewModel import com.oztechan.ccc.client.viewmodel.settings.SettingsViewModel +import com.oztechan.ccc.client.viewmodel.watchers.WatchersViewModel import com.oztechan.ccc.config.ConfigManager import com.oztechan.ccc.config.ConfigManagerImpl import org.koin.dsl.module var clientModule = module { - viewModelDefinition { SettingsViewModel(get(), get(), get(), get(), get()) } + viewModelDefinition { SettingsViewModel(get(), get(), get(), get(), get(), get()) } viewModelDefinition { MainViewModel(get(), get(), get()) } viewModelDefinition { CurrenciesViewModel(get(), get(), get()) } viewModelDefinition { CalculatorViewModel(get(), get(), get(), get(), get()) } viewModelDefinition { SelectCurrencyViewModel(get()) } viewModelDefinition { AdRemoveViewModel(get()) } + viewModelDefinition { WatchersViewModel(get(), get()) } single { ConfigManagerImpl() } single { SessionManagerImpl(get(), get()) } + single { BackgroundManagerImpl(get(), get()) } } diff --git a/client/src/commonMain/kotlin/com/oztechan/ccc/client/manager/background/BackgroundManager.kt b/client/src/commonMain/kotlin/com/oztechan/ccc/client/manager/background/BackgroundManager.kt new file mode 100644 index 0000000000..da5eee9a77 --- /dev/null +++ b/client/src/commonMain/kotlin/com/oztechan/ccc/client/manager/background/BackgroundManager.kt @@ -0,0 +1,5 @@ +package com.oztechan.ccc.client.manager.background + +interface BackgroundManager { + fun shouldSendNotification(): Boolean +} diff --git a/client/src/commonMain/kotlin/com/oztechan/ccc/client/manager/background/BackgroundManagerImpl.kt b/client/src/commonMain/kotlin/com/oztechan/ccc/client/manager/background/BackgroundManagerImpl.kt new file mode 100644 index 0000000000..4b0f98195a --- /dev/null +++ b/client/src/commonMain/kotlin/com/oztechan/ccc/client/manager/background/BackgroundManagerImpl.kt @@ -0,0 +1,41 @@ +package com.oztechan.ccc.client.manager.background + +import co.touchlab.kermit.Logger +import com.oztechan.ccc.client.util.getConversionByName +import com.oztechan.ccc.common.api.repo.ApiRepository +import com.oztechan.ccc.common.db.watcher.WatcherRepository +import kotlinx.coroutines.runBlocking + +class BackgroundManagerImpl( + private val watchersRepository: WatcherRepository, + private val apiRepository: ApiRepository +) : BackgroundManager { + + init { + Logger.d { "BackgroundManagerImpl init" } + } + + @Suppress("LabeledExpression", "TooGenericExceptionCaught") + override fun shouldSendNotification() = try { + Logger.d { "BackgroundManagerImpl shouldSendNotification" } + + runBlocking { + watchersRepository.getWatchers().forEach { watcher -> + apiRepository + .getRatesByBackend(watcher.base) + .rates + .getConversionByName(watcher.target) + ?.let { conversionRate -> + when { + watcher.isGreater && conversionRate > watcher.rate -> return@runBlocking true + !watcher.isGreater && conversionRate < watcher.rate -> return@runBlocking true + } + } + } + return@runBlocking false + } + } catch (e: Exception) { + Logger.w { "BackgroundManagerImpl shouldSendNotification error: $e" } + false + } +} diff --git a/client/src/commonMain/kotlin/com/oztechan/ccc/client/helper/SessionManager.kt b/client/src/commonMain/kotlin/com/oztechan/ccc/client/manager/session/SessionManager.kt similarity index 81% rename from client/src/commonMain/kotlin/com/oztechan/ccc/client/helper/SessionManager.kt rename to client/src/commonMain/kotlin/com/oztechan/ccc/client/manager/session/SessionManager.kt index 1df5ad3e04..3f1ade2bc7 100644 --- a/client/src/commonMain/kotlin/com/oztechan/ccc/client/helper/SessionManager.kt +++ b/client/src/commonMain/kotlin/com/oztechan/ccc/client/manager/session/SessionManager.kt @@ -1,4 +1,4 @@ -package com.oztechan.ccc.client.helper +package com.oztechan.ccc.client.manager.session interface SessionManager { fun shouldShowBannerAd(): Boolean diff --git a/client/src/commonMain/kotlin/com/oztechan/ccc/client/helper/SessionManagerImpl.kt b/client/src/commonMain/kotlin/com/oztechan/ccc/client/manager/session/SessionManagerImpl.kt similarity index 96% rename from client/src/commonMain/kotlin/com/oztechan/ccc/client/helper/SessionManagerImpl.kt rename to client/src/commonMain/kotlin/com/oztechan/ccc/client/manager/session/SessionManagerImpl.kt index 77fc059e3e..92689e6059 100644 --- a/client/src/commonMain/kotlin/com/oztechan/ccc/client/helper/SessionManagerImpl.kt +++ b/client/src/commonMain/kotlin/com/oztechan/ccc/client/manager/session/SessionManagerImpl.kt @@ -1,4 +1,4 @@ -package com.oztechan.ccc.client.helper +package com.oztechan.ccc.client.manager.session import com.github.submob.scopemob.mapTo import com.github.submob.scopemob.whether diff --git a/client/src/commonMain/kotlin/com/oztechan/ccc/client/mapper/Notification.kt b/client/src/commonMain/kotlin/com/oztechan/ccc/client/mapper/Notification.kt new file mode 100644 index 0000000000..eb39dbca55 --- /dev/null +++ b/client/src/commonMain/kotlin/com/oztechan/ccc/client/mapper/Notification.kt @@ -0,0 +1,17 @@ +package com.oztechan.ccc.client.mapper + +import com.oztechan.ccc.client.util.removeScientificNotation +import com.oztechan.ccc.common.model.Watcher +import com.oztechan.ccc.client.model.Watcher as WatcherUIModel + +fun Watcher.toUIModel() = WatcherUIModel( + id = id, + base = base, + target = target, + isGreater = isGreater, + rate = rate.removeScientificNotation() +) + +fun List.toUIModelList() = map { + it.toUIModel() +} \ No newline at end of file diff --git a/client/src/commonMain/kotlin/com/oztechan/ccc/client/model/Watcher.kt b/client/src/commonMain/kotlin/com/oztechan/ccc/client/model/Watcher.kt new file mode 100644 index 0000000000..3483b21e4f --- /dev/null +++ b/client/src/commonMain/kotlin/com/oztechan/ccc/client/model/Watcher.kt @@ -0,0 +1,9 @@ +package com.oztechan.ccc.client.model + +data class Watcher( + val id: Long, + val base: String, + val target: String, + val isGreater: Boolean, + val rate: String +) diff --git a/client/src/commonMain/kotlin/com/oztechan/ccc/client/util/CalculatorUtil.kt b/client/src/commonMain/kotlin/com/oztechan/ccc/client/util/CalculatorUtil.kt index c116294588..fbc81ca2d6 100644 --- a/client/src/commonMain/kotlin/com/oztechan/ccc/client/util/CalculatorUtil.kt +++ b/client/src/commonMain/kotlin/com/oztechan/ccc/client/util/CalculatorUtil.kt @@ -13,6 +13,8 @@ import com.oztechan.ccc.common.model.Rates expect fun Double.getFormatted(): String +expect fun Double.removeScientificNotation(): String + fun Rates?.calculateResult(name: String, input: String?) = this ?.whetherNot { input.isNullOrEmpty() } ?.getConversionByName(name) diff --git a/client/src/commonMain/kotlin/com/oztechan/ccc/client/viewmodel/calculator/CalculatorViewModel.kt b/client/src/commonMain/kotlin/com/oztechan/ccc/client/viewmodel/calculator/CalculatorViewModel.kt index 4133b72d1a..46287f712b 100755 --- a/client/src/commonMain/kotlin/com/oztechan/ccc/client/viewmodel/calculator/CalculatorViewModel.kt +++ b/client/src/commonMain/kotlin/com/oztechan/ccc/client/viewmodel/calculator/CalculatorViewModel.kt @@ -8,7 +8,7 @@ import com.github.submob.scopemob.mapTo import com.github.submob.scopemob.whether import com.github.submob.scopemob.whetherNot import com.oztechan.ccc.client.base.BaseSEEDViewModel -import com.oztechan.ccc.client.helper.SessionManager +import com.oztechan.ccc.client.manager.session.SessionManager import com.oztechan.ccc.client.mapper.toRates import com.oztechan.ccc.client.mapper.toTodayResponse import com.oztechan.ccc.client.mapper.toUIModelList @@ -90,9 +90,8 @@ class CalculatorViewModel( .launchIn(clientScope) } - private fun getRates() = data.rates?.let { rates -> - calculateConversions(rates) - _state.update(rateState = RateState.Cached(rates.date)) + private fun getRates() = data.rates?.let { + calculateConversions(it, RateState.Cached(it.date)) } ?: clientScope.launch { runCatching { apiRepository.getRatesByBackend(settingsRepository.currentBase) } .onFailure(::getRatesFailed) @@ -102,8 +101,7 @@ class CalculatorViewModel( private fun getRatesSuccess(currencyResponse: CurrencyResponse) = currencyResponse .toRates().let { data.rates = it - calculateConversions(it) - _state.update(rateState = RateState.Online(it.date)) + calculateConversions(it, RateState.Online(it.date)) }.also { offlineRatesRepository.insertOfflineRates(currencyResponse.toTodayResponse()) } @@ -112,9 +110,8 @@ class CalculatorViewModel( Logger.w(t) { "CalculatorViewModel getRatesFailed" } offlineRatesRepository.getOfflineRatesByBase( settingsRepository.currentBase - )?.let { offlineRates -> - calculateConversions(offlineRates) - _state.update(rateState = RateState.Offline(offlineRates.date)) + )?.let { + calculateConversions(it, RateState.Offline(it.date)) } ?: clientScope.launch { Logger.w(Exception("No offline rates")) { this@CalculatorViewModel::class.simpleName.toString() } @@ -153,10 +150,11 @@ class CalculatorViewModel( } } - private fun calculateConversions(rates: Rates?) = _state.update( + private fun calculateConversions(rates: Rates, rateState: RateState) = _state.update( currencyList = _state.value.currencyList.onEach { it.rate = rates.calculateResult(it.name, _state.value.output) }, + rateState = rateState, loading = false ) @@ -166,7 +164,7 @@ class CalculatorViewModel( _state.update( base = newBase, input = _state.value.input, - symbol = currencyRepository.getCurrencyByName(newBase)?.symbol ?: "" + symbol = currencyRepository.getCurrencyByName(newBase)?.symbol.orEmpty() ) } diff --git a/client/src/commonMain/kotlin/com/oztechan/ccc/client/viewmodel/currencies/CurrenciesViewModel.kt b/client/src/commonMain/kotlin/com/oztechan/ccc/client/viewmodel/currencies/CurrenciesViewModel.kt index d85cf77ba0..bb546874e3 100755 --- a/client/src/commonMain/kotlin/com/oztechan/ccc/client/viewmodel/currencies/CurrenciesViewModel.kt +++ b/client/src/commonMain/kotlin/com/oztechan/ccc/client/viewmodel/currencies/CurrenciesViewModel.kt @@ -9,7 +9,7 @@ import com.github.submob.scopemob.mapTo import com.github.submob.scopemob.whether import com.github.submob.scopemob.whetherNot import com.oztechan.ccc.client.base.BaseSEEDViewModel -import com.oztechan.ccc.client.helper.SessionManager +import com.oztechan.ccc.client.manager.session.SessionManager import com.oztechan.ccc.client.mapper.toUIModelList import com.oztechan.ccc.client.model.Currency import com.oztechan.ccc.client.util.launchIgnored @@ -77,11 +77,11 @@ class CurrenciesViewModel( .filter { it.name == base } .toList().firstOrNull()?.isActive == false } - )?.let { - (state.value.currencyList.firstOrNull { it.isActive }?.name ?: "").let { newBase -> - settingsRepository.currentBase = newBase - clientScope.launch { _effect.emit(CurrenciesEffect.ChangeBase(newBase)) } - } + )?.mapTo { + state.value.currencyList.firstOrNull { it.isActive }?.name.orEmpty() + }?.let { newBase -> + settingsRepository.currentBase = newBase + clientScope.launch { _effect.emit(CurrenciesEffect.ChangeBase(newBase)) } } private fun filterList(txt: String) = data.unFilteredList diff --git a/client/src/commonMain/kotlin/com/oztechan/ccc/client/viewmodel/main/MainViewModel.kt b/client/src/commonMain/kotlin/com/oztechan/ccc/client/viewmodel/main/MainViewModel.kt index 4c58c7dad1..de02e80b84 100755 --- a/client/src/commonMain/kotlin/com/oztechan/ccc/client/viewmodel/main/MainViewModel.kt +++ b/client/src/commonMain/kotlin/com/oztechan/ccc/client/viewmodel/main/MainViewModel.kt @@ -6,7 +6,7 @@ package com.oztechan.ccc.client.viewmodel.main import co.touchlab.kermit.Logger import com.oztechan.ccc.client.base.BaseSEEDViewModel import com.oztechan.ccc.client.base.BaseState -import com.oztechan.ccc.client.helper.SessionManager +import com.oztechan.ccc.client.manager.session.SessionManager import com.oztechan.ccc.client.util.isRewardExpired import com.oztechan.ccc.common.settings.SettingsRepository import com.oztechan.ccc.config.ConfigManager diff --git a/client/src/commonMain/kotlin/com/oztechan/ccc/client/viewmodel/settings/SettingsSEED.kt b/client/src/commonMain/kotlin/com/oztechan/ccc/client/viewmodel/settings/SettingsSEED.kt index 2629ce3a26..27c0e3750c 100644 --- a/client/src/commonMain/kotlin/com/oztechan/ccc/client/viewmodel/settings/SettingsSEED.kt +++ b/client/src/commonMain/kotlin/com/oztechan/ccc/client/viewmodel/settings/SettingsSEED.kt @@ -10,6 +10,7 @@ import kotlinx.coroutines.flow.MutableStateFlow // State data class SettingsState( val activeCurrencyCount: Int = 0, + val activeWatcherCount: Int = 0, val appThemeType: AppTheme = AppTheme.SYSTEM_DEFAULT, val addFreeEndDate: String = "", val loading: Boolean = false @@ -19,6 +20,7 @@ data class SettingsState( interface SettingsEvent : BaseEvent { fun onBackClick() fun onCurrenciesClick() + fun onWatchersClicked() fun onFeedBackClick() fun onShareClick() fun onSupportUsClick() @@ -32,6 +34,7 @@ interface SettingsEvent : BaseEvent { sealed class SettingsEffect : BaseEffect() { object Back : SettingsEffect() object OpenCurrencies : SettingsEffect() + object OpenWatchers : SettingsEffect() object FeedBack : SettingsEffect() object Share : SettingsEffect() object SupportUs : SettingsEffect() @@ -55,12 +58,14 @@ data class SettingsData(var synced: Boolean = false) : BaseData() { // Extension fun MutableStateFlow.update( activeCurrencyCount: Int = value.activeCurrencyCount, + activeWatcherCount: Int = value.activeWatcherCount, appThemeType: AppTheme = value.appThemeType, addFreeEndDate: String = value.addFreeEndDate, loading: Boolean = value.loading ) { value = value.copy( activeCurrencyCount = activeCurrencyCount, + activeWatcherCount = activeWatcherCount, appThemeType = appThemeType, addFreeEndDate = addFreeEndDate, loading = loading diff --git a/client/src/commonMain/kotlin/com/oztechan/ccc/client/viewmodel/settings/SettingsViewModel.kt b/client/src/commonMain/kotlin/com/oztechan/ccc/client/viewmodel/settings/SettingsViewModel.kt index e8cb5a7c84..8513dee92b 100644 --- a/client/src/commonMain/kotlin/com/oztechan/ccc/client/viewmodel/settings/SettingsViewModel.kt +++ b/client/src/commonMain/kotlin/com/oztechan/ccc/client/viewmodel/settings/SettingsViewModel.kt @@ -6,7 +6,7 @@ package com.oztechan.ccc.client.viewmodel.settings import co.touchlab.kermit.Logger import com.github.submob.logmob.e import com.oztechan.ccc.client.base.BaseSEEDViewModel -import com.oztechan.ccc.client.helper.SessionManager +import com.oztechan.ccc.client.manager.session.SessionManager import com.oztechan.ccc.client.model.AppTheme import com.oztechan.ccc.client.model.RemoveAdType import com.oztechan.ccc.client.util.calculateAdRewardEnd @@ -17,6 +17,7 @@ import com.oztechan.ccc.client.viewmodel.settings.SettingsData.Companion.SYNC_DE import com.oztechan.ccc.common.api.repo.ApiRepository import com.oztechan.ccc.common.db.currency.CurrencyRepository import com.oztechan.ccc.common.db.offlinerates.OfflineRatesRepository +import com.oztechan.ccc.common.db.watcher.WatcherRepository import com.oztechan.ccc.common.settings.SettingsRepository import com.oztechan.ccc.common.util.nowAsLong import kotlinx.coroutines.delay @@ -33,6 +34,7 @@ class SettingsViewModel( private val apiRepository: ApiRepository, private val currencyRepository: CurrencyRepository, private val offlineRatesRepository: OfflineRatesRepository, + watcherRepository: WatcherRepository, private val sessionManager: SessionManager ) : BaseSEEDViewModel(), SettingsEvent { // region SEED @@ -57,6 +59,11 @@ class SettingsViewModel( .onEach { _state.update(activeCurrencyCount = it.size) }.launchIn(clientScope) + + watcherRepository.collectWatchers() + .onEach { + _state.update(activeWatcherCount = it.size) + }.launchIn(clientScope) } private suspend fun synchroniseRates() { @@ -93,8 +100,7 @@ class SettingsViewModel( fun getAppTheme() = settingsRepository.appTheme - // used in ios - @Suppress("unused") + @Suppress("unused") // used in iOS fun updateAddFreeDate() = RemoveAdType.VIDEO.calculateAdRewardEnd(nowAsLong()).let { settingsRepository.adFreeEndDate = it _state.update(addFreeEndDate = it.toDateString()) @@ -111,6 +117,11 @@ class SettingsViewModel( _effect.emit(SettingsEffect.OpenCurrencies) } + override fun onWatchersClicked() = clientScope.launchIgnored { + Logger.d { "SettingsViewModel onWatchersClicked" } + _effect.emit(SettingsEffect.OpenWatchers) + } + override fun onFeedBackClick() = clientScope.launchIgnored { Logger.d { "SettingsViewModel onFeedBackClick" } _effect.emit(SettingsEffect.FeedBack) diff --git a/client/src/commonMain/kotlin/com/oztechan/ccc/client/viewmodel/watchers/WatchersSEED.kt b/client/src/commonMain/kotlin/com/oztechan/ccc/client/viewmodel/watchers/WatchersSEED.kt new file mode 100644 index 0000000000..d396977a9b --- /dev/null +++ b/client/src/commonMain/kotlin/com/oztechan/ccc/client/viewmodel/watchers/WatchersSEED.kt @@ -0,0 +1,52 @@ +package com.oztechan.ccc.client.viewmodel.watchers + + +import com.oztechan.ccc.client.base.BaseData +import com.oztechan.ccc.client.base.BaseEffect +import com.oztechan.ccc.client.base.BaseEvent +import com.oztechan.ccc.client.base.BaseState +import com.oztechan.ccc.client.model.Watcher +import kotlinx.coroutines.flow.MutableStateFlow + +data class WatchersState( + val watcherList: List = emptyList(), + val base: String = "", + val target: String = "" +) : BaseState() + +sealed class WatchersEffect : BaseEffect() { + object Back : WatchersEffect() + data class SelectBase(val watcher: Watcher) : WatchersEffect() + data class SelectTarget(val watcher: Watcher) : WatchersEffect() + object MaximumInput : WatchersEffect() + object InvalidInput : WatchersEffect() + object MaximumNumberOfWatchers : WatchersEffect() +} + +interface WatchersEvent : BaseEvent { + fun onBackClick() + fun onBaseClick(watcher: Watcher) + fun onTargetClick(watcher: Watcher) + fun onBaseChanged(watcher: Watcher?, newBase: String) + fun onTargetChanged(watcher: Watcher?, newTarget: String) + fun onAddClick() + fun onDeleteClick(watcher: Watcher) + fun onRelationChange(watcher: Watcher, isGreater: Boolean) + fun onRateChange(watcher: Watcher, rate: String): String +} + +class WatchersData : BaseData() { + companion object { + const val MAXIMUM_INPUT = 9 + const val MAXIMUM_NUMBER_OF_WATCHER = 5 + } +} + +// Extension +fun MutableStateFlow.update( + watcherList: List = value.watcherList, +) { + value = value.copy( + watcherList = watcherList + ) +} \ No newline at end of file diff --git a/client/src/commonMain/kotlin/com/oztechan/ccc/client/viewmodel/watchers/WatchersViewModel.kt b/client/src/commonMain/kotlin/com/oztechan/ccc/client/viewmodel/watchers/WatchersViewModel.kt new file mode 100644 index 0000000000..38c1e0d2e4 --- /dev/null +++ b/client/src/commonMain/kotlin/com/oztechan/ccc/client/viewmodel/watchers/WatchersViewModel.kt @@ -0,0 +1,119 @@ +package com.oztechan.ccc.client.viewmodel.watchers + +import co.touchlab.kermit.Logger +import com.oztechan.ccc.client.base.BaseSEEDViewModel +import com.oztechan.ccc.client.mapper.toUIModelList +import com.oztechan.ccc.client.model.Watcher +import com.oztechan.ccc.client.util.launchIgnored +import com.oztechan.ccc.client.util.toStandardDigits +import com.oztechan.ccc.client.util.toSupportedCharacters +import com.oztechan.ccc.client.viewmodel.watchers.WatchersData.Companion.MAXIMUM_INPUT +import com.oztechan.ccc.client.viewmodel.watchers.WatchersData.Companion.MAXIMUM_NUMBER_OF_WATCHER +import com.oztechan.ccc.common.db.currency.CurrencyRepository +import com.oztechan.ccc.common.db.watcher.WatcherRepository +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch + +class WatchersViewModel( + private val currencyRepository: CurrencyRepository, + private val watcherRepository: WatcherRepository +) : BaseSEEDViewModel(), WatchersEvent { + // region SEED + private val _state = MutableStateFlow(WatchersState()) + override val state = _state.asStateFlow() + + override val event = this as WatchersEvent + + private val _effect = MutableSharedFlow() + override val effect = _effect.asSharedFlow() + + override val data = WatchersData() + + init { + watcherRepository.collectWatchers() + .onEach { + _state.update(watcherList = it.toUIModelList()) + }.launchIn(clientScope) + } + + override fun onBackClick() = clientScope.launchIgnored { + Logger.d { "WatcherViewModel onBackClick" } + _effect.emit(WatchersEffect.Back) + } + + override fun onBaseClick(watcher: Watcher) = clientScope.launchIgnored { + Logger.d { "WatcherViewModel onBaseClick $watcher" } + _effect.emit(WatchersEffect.SelectBase(watcher)) + } + + override fun onTargetClick(watcher: Watcher) = clientScope.launchIgnored { + Logger.d { "WatcherViewModel onTargetClick $watcher" } + _effect.emit(WatchersEffect.SelectTarget(watcher)) + } + + override fun onBaseChanged(watcher: Watcher?, newBase: String) { + Logger.d { "WatcherViewModel onBaseChanged $watcher $newBase" } + watcher?.id?.let { + watcherRepository.updateBaseById(newBase, it) + } + } + + override fun onTargetChanged(watcher: Watcher?, newTarget: String) { + Logger.d { "WatcherViewModel onTargetChanged $watcher $newTarget" } + watcher?.id?.let { + watcherRepository.updateTargetById(newTarget, it) + } + } + + override fun onAddClick() { + Logger.d { "WatcherViewModel onAddClick" } + if (watcherRepository.getWatchers().size >= MAXIMUM_NUMBER_OF_WATCHER) { + clientScope.launch { _effect.emit(WatchersEffect.MaximumNumberOfWatchers) } + } else { + currencyRepository.getActiveCurrencies().let { list -> + watcherRepository.addWatcher( + base = list.firstOrNull()?.name.orEmpty(), + target = list.lastOrNull()?.name.orEmpty() + ) + } + } + } + + override fun onDeleteClick(watcher: Watcher) { + Logger.d { "WatcherViewModel onDeleteClick $watcher" } + watcherRepository.deleteWatcher(watcher.id) + } + + override fun onRelationChange(watcher: Watcher, isGreater: Boolean) { + Logger.d { "WatcherViewModel onRelationChange $watcher $isGreater" } + watcherRepository.updateRelationById(isGreater, watcher.id) + } + + override fun onRateChange(watcher: Watcher, rate: String): String { + Logger.d { "WatcherViewModel onRateChange $watcher $rate" } + + return when { + rate.length > MAXIMUM_INPUT -> { + clientScope.launch { _effect.emit(WatchersEffect.MaximumInput) } + rate.dropLast(1) + } + rate.toDoubleOrNull()?.isNaN() != false -> { + clientScope.launch { _effect.emit(WatchersEffect.InvalidInput) } + rate + } + else -> { + watcherRepository.updateRateById( + rate.toSupportedCharacters().toStandardDigits().toDoubleOrNull() ?: 0.0, + watcher.id + ) + rate + } + } + } + // endregion +} diff --git a/client/src/commonTest/kotlin/com/oztechan/ccc/client/helper/SessionManagerTest.kt b/client/src/commonTest/kotlin/com/oztechan/ccc/client/manager/SessionManagerTest.kt similarity index 99% rename from client/src/commonTest/kotlin/com/oztechan/ccc/client/helper/SessionManagerTest.kt rename to client/src/commonTest/kotlin/com/oztechan/ccc/client/manager/SessionManagerTest.kt index 5ce4f2ea12..497012a956 100644 --- a/client/src/commonTest/kotlin/com/oztechan/ccc/client/helper/SessionManagerTest.kt +++ b/client/src/commonTest/kotlin/com/oztechan/ccc/client/manager/SessionManagerTest.kt @@ -1,7 +1,8 @@ -package com.oztechan.ccc.client.helper +package com.oztechan.ccc.client.manager import com.oztechan.ccc.client.BuildKonfig import com.oztechan.ccc.client.device +import com.oztechan.ccc.client.manager.session.SessionManagerImpl import com.oztechan.ccc.common.settings.SettingsRepository import com.oztechan.ccc.common.util.nowAsLong import com.oztechan.ccc.config.ConfigManager diff --git a/client/src/commonTest/kotlin/com/oztechan/ccc/client/util/CalculatorUtilTest.kt b/client/src/commonTest/kotlin/com/oztechan/ccc/client/util/CalculatorUtilTest.kt index b83a21c6de..4795f90219 100644 --- a/client/src/commonTest/kotlin/com/oztechan/ccc/client/util/CalculatorUtilTest.kt +++ b/client/src/commonTest/kotlin/com/oztechan/ccc/client/util/CalculatorUtilTest.kt @@ -75,6 +75,15 @@ class CalculatorUtilTest { assertEquals("1 234 567.789", actualDouble.getFormatted()) } + @Test + fun removeScientificNotation() { + assertEquals("1234567.789", 1234567.7890.removeScientificNotation()) + assertEquals("1234567.789123", 1234567.7891230.removeScientificNotation()) + assertEquals("1234567.7", 1234567.7.removeScientificNotation()) + assertEquals("0.7", .7.removeScientificNotation()) + assertEquals("7.7", 7.7.removeScientificNotation()) + } + @Test fun toStandardDigits() { // https://en.wikipedia.org/w/index.php?title=Hindu%E2%80%93Arabic_numeral_system diff --git a/client/src/commonTest/kotlin/com/oztechan/ccc/client/viewmodel/CalculatorViewModelTest.kt b/client/src/commonTest/kotlin/com/oztechan/ccc/client/viewmodel/CalculatorViewModelTest.kt index 6a83c1107c..5b1ee4c526 100644 --- a/client/src/commonTest/kotlin/com/oztechan/ccc/client/viewmodel/CalculatorViewModelTest.kt +++ b/client/src/commonTest/kotlin/com/oztechan/ccc/client/viewmodel/CalculatorViewModelTest.kt @@ -4,7 +4,7 @@ package com.oztechan.ccc.client.viewmodel import com.github.submob.logmob.initLogger -import com.oztechan.ccc.client.helper.SessionManager +import com.oztechan.ccc.client.manager.session.SessionManager import com.oztechan.ccc.client.mapper.toUIModel import com.oztechan.ccc.client.util.after import com.oztechan.ccc.client.util.before diff --git a/client/src/commonTest/kotlin/com/oztechan/ccc/client/viewmodel/CurrenciesViewModelTest.kt b/client/src/commonTest/kotlin/com/oztechan/ccc/client/viewmodel/CurrenciesViewModelTest.kt index f2fa056bce..2aa9131b65 100644 --- a/client/src/commonTest/kotlin/com/oztechan/ccc/client/viewmodel/CurrenciesViewModelTest.kt +++ b/client/src/commonTest/kotlin/com/oztechan/ccc/client/viewmodel/CurrenciesViewModelTest.kt @@ -4,7 +4,7 @@ package com.oztechan.ccc.client.viewmodel import com.github.submob.logmob.initLogger -import com.oztechan.ccc.client.helper.SessionManager +import com.oztechan.ccc.client.manager.session.SessionManager import com.oztechan.ccc.client.mapper.toUIModel import com.oztechan.ccc.client.util.after import com.oztechan.ccc.client.util.before diff --git a/client/src/commonTest/kotlin/com/oztechan/ccc/client/viewmodel/MainViewModelTest.kt b/client/src/commonTest/kotlin/com/oztechan/ccc/client/viewmodel/MainViewModelTest.kt index 288b4da225..2db1e33c7c 100644 --- a/client/src/commonTest/kotlin/com/oztechan/ccc/client/viewmodel/MainViewModelTest.kt +++ b/client/src/commonTest/kotlin/com/oztechan/ccc/client/viewmodel/MainViewModelTest.kt @@ -8,7 +8,7 @@ import com.github.submob.logmob.initLogger import com.github.submob.scopemob.castTo import com.oztechan.ccc.client.BuildKonfig import com.oztechan.ccc.client.device -import com.oztechan.ccc.client.helper.SessionManager +import com.oztechan.ccc.client.manager.session.SessionManager import com.oztechan.ccc.client.util.after import com.oztechan.ccc.client.util.before import com.oztechan.ccc.client.viewmodel.main.MainEffect diff --git a/client/src/commonTest/kotlin/com/oztechan/ccc/client/viewmodel/SettingsViewModelTest.kt b/client/src/commonTest/kotlin/com/oztechan/ccc/client/viewmodel/SettingsViewModelTest.kt index 8869b00af4..f0d1ccd7b7 100644 --- a/client/src/commonTest/kotlin/com/oztechan/ccc/client/viewmodel/SettingsViewModelTest.kt +++ b/client/src/commonTest/kotlin/com/oztechan/ccc/client/viewmodel/SettingsViewModelTest.kt @@ -4,7 +4,7 @@ package com.oztechan.ccc.client.viewmodel import com.github.submob.logmob.initLogger -import com.oztechan.ccc.client.helper.SessionManager +import com.oztechan.ccc.client.manager.session.SessionManager import com.oztechan.ccc.client.model.AppTheme import com.oztechan.ccc.client.model.RemoveAdType import com.oztechan.ccc.client.util.after @@ -18,7 +18,9 @@ import com.oztechan.ccc.client.viewmodel.settings.update import com.oztechan.ccc.common.api.repo.ApiRepository import com.oztechan.ccc.common.db.currency.CurrencyRepository import com.oztechan.ccc.common.db.offlinerates.OfflineRatesRepository +import com.oztechan.ccc.common.db.watcher.WatcherRepository import com.oztechan.ccc.common.model.Currency +import com.oztechan.ccc.common.model.Watcher import com.oztechan.ccc.common.runTest import com.oztechan.ccc.common.settings.SettingsRepository import com.oztechan.ccc.common.util.DAY @@ -53,6 +55,9 @@ class SettingsViewModelTest { @Mock private val offlineRatesRepository = mock(classOf()) + @Mock + private val watcherRepository = mock(classOf()) + @Mock private val sessionManager = mock(classOf()) @@ -62,6 +67,7 @@ class SettingsViewModelTest { apiRepository, currencyRepository, offlineRatesRepository, + watcherRepository, sessionManager ) } @@ -71,6 +77,11 @@ class SettingsViewModelTest { Currency("", "", "") ) + private val watcherLists = listOf( + Watcher(1, "EUR", "USD", true, 1.1), + Watcher(2, "USD", "EUR", false, 2.3) + ) + @BeforeTest fun setup() { initLogger(true) @@ -86,6 +97,10 @@ class SettingsViewModelTest { given(currencyRepository) .invocation { collectActiveCurrencies() } .thenReturn(flowOf(currencyList)) + + given(watcherRepository) + .invocation { collectWatchers() } + .then { flowOf(watcherLists) } } // SEED @@ -95,6 +110,7 @@ class SettingsViewModelTest { val state = MutableStateFlow(SettingsState()) val activeCurrencyCount = Random.nextInt() + val activeWatcherCount = Random.nextInt() val appThemeType = AppTheme.getThemeByOrderOrDefault(Random.nextInt() % 3) val addFreeEndDate = "23.12.2121" val loading = Random.nextBoolean() @@ -102,12 +118,14 @@ class SettingsViewModelTest { state.before { state.update( activeCurrencyCount = activeCurrencyCount, + activeWatcherCount = activeWatcherCount, appThemeType = appThemeType, addFreeEndDate = addFreeEndDate, loading = loading ) }.after { assertEquals(activeCurrencyCount, it?.activeCurrencyCount) + assertEquals(activeWatcherCount, it?.activeWatcherCount) assertEquals(appThemeType, it?.appThemeType) assertEquals(addFreeEndDate, it?.addFreeEndDate) assertEquals(loading, it?.loading) @@ -120,6 +138,7 @@ class SettingsViewModelTest { viewModel.state.firstOrNull().let { assertEquals(AppTheme.SYSTEM_DEFAULT, it?.appThemeType) // mocked -1 assertEquals(currencyList.size, it?.activeCurrencyCount) + assertEquals(watcherLists.size, it?.activeWatcherCount) } } @@ -248,6 +267,13 @@ class SettingsViewModelTest { assertTrue { it is SettingsEffect.OpenCurrencies } } + @Test + fun onWatchersClicked() = viewModel.effect.before { + viewModel.event.onWatchersClicked() + }.after { + assertEquals(SettingsEffect.OpenWatchers, it) + } + @Test fun onFeedBackClick() = viewModel.effect.before { viewModel.event.onFeedBackClick() diff --git a/client/src/iosMain/kotlin/com/oztechan/ccc/client/base/BaseViewModel.kt b/client/src/iosMain/kotlin/com/oztechan/ccc/client/base/BaseViewModel.kt index 62e6c6e7ec..31cd849cc3 100644 --- a/client/src/iosMain/kotlin/com/oztechan/ccc/client/base/BaseViewModel.kt +++ b/client/src/iosMain/kotlin/com/oztechan/ccc/client/base/BaseViewModel.kt @@ -5,15 +5,10 @@ package com.oztechan.ccc.client.base import co.touchlab.kermit.Logger -import io.ktor.utils.io.core.Closeable import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancelChildren -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.onEach @Suppress("EmptyDefaultConstructor", "unused") actual open class BaseViewModel actual constructor() { @@ -33,18 +28,4 @@ actual open class BaseViewModel actual constructor() { Logger.d { "${this::class.simpleName} onCleared" } viewModelJob.cancelChildren() } - - fun Flow.observe(onChange: ((T) -> Unit)): Closeable { - val job = Job() - onEach { - onChange(it) - }.launchIn( - CoroutineScope(Dispatchers.Main + job) - ) - return object : Closeable { - override fun close() { - job.cancel() - } - } - } } diff --git a/client/src/iosMain/kotlin/com/oztechan/ccc/client/di/KoinIOS.kt b/client/src/iosMain/kotlin/com/oztechan/ccc/client/di/KoinIOS.kt index 38b5ac7ec3..7e9a8f0346 100644 --- a/client/src/iosMain/kotlin/com/oztechan/ccc/client/di/KoinIOS.kt +++ b/client/src/iosMain/kotlin/com/oztechan/ccc/client/di/KoinIOS.kt @@ -15,6 +15,7 @@ import com.oztechan.ccc.common.di.modules.apiModule import com.oztechan.ccc.common.di.modules.getDatabaseModule import com.oztechan.ccc.common.di.modules.getSettingsModule import kotlinx.cinterop.ObjCClass +import kotlinx.cinterop.ObjCProtocol import kotlinx.cinterop.getOriginalKotlinClass import org.koin.core.Koin import org.koin.core.context.startKoin @@ -49,3 +50,7 @@ actual inline fun Module.viewModelDefinition( fun Koin.getDependency(objCClass: ObjCClass): T? = getOriginalKotlinClass(objCClass)?.let { getDependency(it) } + +fun Koin.getDependency(objCProtocol: ObjCProtocol): T? = getOriginalKotlinClass(objCProtocol)?.let { + getDependency(it) +} diff --git a/client/src/iosMain/kotlin/com/oztechan/ccc/client/util/IOSCalculatorUtil.kt b/client/src/iosMain/kotlin/com/oztechan/ccc/client/util/IOSCalculatorUtil.kt index 3eb35e7499..4259b7fe8e 100644 --- a/client/src/iosMain/kotlin/com/oztechan/ccc/client/util/IOSCalculatorUtil.kt +++ b/client/src/iosMain/kotlin/com/oztechan/ccc/client/util/IOSCalculatorUtil.kt @@ -4,6 +4,7 @@ package com.oztechan.ccc.client.util +import com.oztechan.ccc.client.viewmodel.watchers.WatchersData import platform.Foundation.NSNumber import platform.Foundation.NSNumberFormatter import platform.Foundation.NSNumberFormatterDecimalStyle @@ -12,4 +13,11 @@ actual fun Double.getFormatted() = NSNumberFormatter().apply { setNumberStyle(NSNumberFormatterDecimalStyle) setGroupingSeparator(" ") setDecimalSeparator(".") -}.stringFromNumber(NSNumber(this)) ?: "" +}.stringFromNumber(NSNumber(this)).orEmpty() + +actual fun Double.removeScientificNotation() = NSNumberFormatter().apply { + setNumberStyle(NSNumberFormatterDecimalStyle) + setGroupingSeparator("") + setDecimalSeparator(".") + setMaximumFractionDigits(WatchersData.MAXIMUM_INPUT.toULong()) +}.stringFromNumber(NSNumber(this)).orEmpty() diff --git a/client/src/iosMain/kotlin/com/oztechan/ccc/client/util/IOSCoroutineUtil.kt b/client/src/iosMain/kotlin/com/oztechan/ccc/client/util/IOSCoroutineUtil.kt new file mode 100644 index 0000000000..1e20bf2bf4 --- /dev/null +++ b/client/src/iosMain/kotlin/com/oztechan/ccc/client/util/IOSCoroutineUtil.kt @@ -0,0 +1,24 @@ +package com.oztechan.ccc.client.util + +import com.squareup.sqldelight.db.Closeable +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach + +@Suppress("unused") // used in iOS +fun Flow.observeWithCloseable(onChange: ((T) -> Unit)): Closeable { + val job = Job() + onEach { + onChange(it) + }.launchIn( + CoroutineScope(Dispatchers.Main + job) + ) + return object : Closeable { + override fun close() { + job.cancel() + } + } +} diff --git a/common/src/commonMain/kotlin/com/oztechan/ccc/common/db/currency/CurrencyRepositoryImpl.kt b/common/src/commonMain/kotlin/com/oztechan/ccc/common/db/currency/CurrencyRepositoryImpl.kt index af0d64b3fc..441b81904e 100644 --- a/common/src/commonMain/kotlin/com/oztechan/ccc/common/db/currency/CurrencyRepositoryImpl.kt +++ b/common/src/commonMain/kotlin/com/oztechan/ccc/common/db/currency/CurrencyRepositoryImpl.kt @@ -3,9 +3,9 @@ package com.oztechan.ccc.common.db.currency import co.touchlab.kermit.Logger import com.oztechan.ccc.common.db.sql.CurrencyQueries import com.oztechan.ccc.common.mapper.mapToModel +import com.oztechan.ccc.common.mapper.toLong import com.oztechan.ccc.common.mapper.toModel import com.oztechan.ccc.common.mapper.toModelList -import com.oztechan.ccc.common.util.toDatabaseBoolean import com.squareup.sqldelight.runtime.coroutines.asFlow import com.squareup.sqldelight.runtime.coroutines.mapToList import kotlinx.coroutines.flow.map @@ -37,11 +37,11 @@ internal class CurrencyRepositoryImpl( .also { Logger.v { "CurrencyRepositoryImpl getActiveCurrencies" } } override fun updateCurrencyStateByName(name: String, isActive: Boolean) = currencyQueries - .updateCurrencyStateByName(isActive.toDatabaseBoolean(), name) + .updateCurrencyStateByName(isActive.toLong(), name) .also { Logger.v { "CurrencyRepositoryImpl updateCurrencyStateByName $name $isActive" } } override fun updateAllCurrencyState(value: Boolean) = currencyQueries - .updateAllCurrencyState(value.toDatabaseBoolean()) + .updateAllCurrencyState(value.toLong()) .also { Logger.v { "CurrencyRepositoryImpl updateAllCurrencyState $value" } } override fun getCurrencyByName(name: String) = currencyQueries diff --git a/common/src/commonMain/kotlin/com/oztechan/ccc/common/db/migrate/4.sqm b/common/src/commonMain/kotlin/com/oztechan/ccc/common/db/migrate/4.sqm new file mode 100644 index 0000000000..65001132e7 --- /dev/null +++ b/common/src/commonMain/kotlin/com/oztechan/ccc/common/db/migrate/4.sqm @@ -0,0 +1,7 @@ +CREATE TABLE IF NOT EXISTS watcher( +id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, +base TEXT NOT NULL, +target TEXT NOT NULL, +isGreater INTEGER NOT NULL DEFAULT 1, +rate REAL NOT NULL DEFAULT 0.0 +); \ No newline at end of file diff --git a/common/src/commonMain/kotlin/com/oztechan/ccc/common/db/sql/Watcher.sq b/common/src/commonMain/kotlin/com/oztechan/ccc/common/db/sql/Watcher.sq new file mode 100644 index 0000000000..8c8e5294e1 --- /dev/null +++ b/common/src/commonMain/kotlin/com/oztechan/ccc/common/db/sql/Watcher.sq @@ -0,0 +1,28 @@ +CREATE TABLE IF NOT EXISTS watcher( +id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, +base TEXT NOT NULL, +target TEXT NOT NULL, +isGreater INTEGER NOT NULL DEFAULT 1, +rate REAL NOT NULL DEFAULT 0.0 +); + +addWatcher: +INSERT OR REPLACE INTO watcher(base, target) VALUES (?,?); + +deleteWatcher: +DELETE FROM watcher WHERE id = ?; + +updateBaseById: +UPDATE watcher SET base=? WHERE id=?; + +updateTargetById: +UPDATE watcher SET target=? WHERE id=?; + +updateRelationById: +UPDATE watcher SET isGreater=? WHERE id=?; + +updateRateById: +UPDATE watcher SET rate=? WHERE id=?; + +getWatchers: +SELECT * FROM watcher; \ No newline at end of file diff --git a/common/src/commonMain/kotlin/com/oztechan/ccc/common/db/watcher/WatcherRepository.kt b/common/src/commonMain/kotlin/com/oztechan/ccc/common/db/watcher/WatcherRepository.kt new file mode 100644 index 0000000000..e40a883097 --- /dev/null +++ b/common/src/commonMain/kotlin/com/oztechan/ccc/common/db/watcher/WatcherRepository.kt @@ -0,0 +1,15 @@ +package com.oztechan.ccc.common.db.watcher + +import com.oztechan.ccc.common.model.Watcher +import kotlinx.coroutines.flow.Flow + +interface WatcherRepository { + fun addWatcher(base: String, target: String) + fun collectWatchers(): Flow> + fun getWatchers(): List + fun deleteWatcher(id: Long) + fun updateBaseById(base: String, id: Long) + fun updateTargetById(target: String, id: Long) + fun updateRelationById(isGreater: Boolean, id: Long) + fun updateRateById(rate: Double, id: Long) +} \ No newline at end of file diff --git a/common/src/commonMain/kotlin/com/oztechan/ccc/common/db/watcher/WatcherRepositoryImpl.kt b/common/src/commonMain/kotlin/com/oztechan/ccc/common/db/watcher/WatcherRepositoryImpl.kt new file mode 100644 index 0000000000..36e726781c --- /dev/null +++ b/common/src/commonMain/kotlin/com/oztechan/ccc/common/db/watcher/WatcherRepositoryImpl.kt @@ -0,0 +1,53 @@ +package com.oztechan.ccc.common.db.watcher + +import co.touchlab.kermit.Logger +import com.oztechan.ccc.common.db.sql.WatcherQueries +import com.oztechan.ccc.common.mapper.mapToModel +import com.oztechan.ccc.common.mapper.toLong +import com.oztechan.ccc.common.mapper.toModelList +import com.squareup.sqldelight.runtime.coroutines.asFlow +import com.squareup.sqldelight.runtime.coroutines.mapToList + +class WatcherRepositoryImpl( + private val watcherQueries: WatcherQueries +) : WatcherRepository { + + override fun addWatcher( + base: String, + target: String + ) = watcherQueries.addWatcher(base, target) + .also { Logger.v { "WatcherRepositoryImpl addWatcher $base $target" } } + + override fun collectWatchers() = watcherQueries + .getWatchers() + .asFlow() + .mapToList() + .mapToModel() + .also { Logger.v { "WatcherRepositoryImpl collectWatchers" } } + + override fun getWatchers() = watcherQueries + .getWatchers() + .executeAsList() + .toModelList() + .also { Logger.v { "WatcherRepositoryImpl getWatchers" } } + + override fun deleteWatcher(id: Long) = watcherQueries + .deleteWatcher(id) + .also { Logger.v { "WatcherRepositoryImpl addWatcher $id" } } + + override fun updateBaseById(base: String, id: Long) = watcherQueries + .updateBaseById(base, id) + .also { Logger.v { "WatcherRepositoryImpl updateBaseById $base $id" } } + + override fun updateTargetById(target: String, id: Long) = watcherQueries + .updateTargetById(target, id) + .also { Logger.v { "WatcherRepositoryImpl updateTargetById $target $id" } } + + override fun updateRelationById(isGreater: Boolean, id: Long) = watcherQueries + .updateRelationById(isGreater.toLong(), id) + .also { Logger.v { "WatcherRepositoryImpl updateRelationById $isGreater $id" } } + + override fun updateRateById(rate: Double, id: Long) = watcherQueries + .updateRateById(rate, id) + .also { Logger.v { "WatcherRepositoryImpl updateRateById $rate $id" } } +} diff --git a/common/src/commonMain/kotlin/com/oztechan/ccc/common/di/modules/DatabaseModule.kt b/common/src/commonMain/kotlin/com/oztechan/ccc/common/di/modules/DatabaseModule.kt index d6ebbfca16..3e7c8991e2 100644 --- a/common/src/commonMain/kotlin/com/oztechan/ccc/common/di/modules/DatabaseModule.kt +++ b/common/src/commonMain/kotlin/com/oztechan/ccc/common/di/modules/DatabaseModule.kt @@ -5,6 +5,8 @@ import com.oztechan.ccc.common.db.currency.CurrencyRepositoryImpl import com.oztechan.ccc.common.db.offlinerates.OfflineRatesRepository import com.oztechan.ccc.common.db.offlinerates.OfflineRatesRepositoryImpl import com.oztechan.ccc.common.db.sql.CurrencyConverterCalculatorDatabase +import com.oztechan.ccc.common.db.watcher.WatcherRepository +import com.oztechan.ccc.common.db.watcher.WatcherRepositoryImpl import org.koin.core.scope.Scope import org.koin.dsl.module @@ -13,9 +15,11 @@ private const val DATABASE_NAME = "application_database.sqlite" fun getDatabaseModule() = module { single { get().currencyQueries } single { get().offlineRatesQueries } + single { get().watcherQueries } single { CurrencyRepositoryImpl(get()) } single { OfflineRatesRepositoryImpl(get()) } + single { WatcherRepositoryImpl(get()) } single { provideDatabase(DATABASE_NAME) } } diff --git a/common/src/commonMain/kotlin/com/oztechan/ccc/common/mapper/Boolean.kt b/common/src/commonMain/kotlin/com/oztechan/ccc/common/mapper/Boolean.kt new file mode 100644 index 0000000000..ca771fa4c7 --- /dev/null +++ b/common/src/commonMain/kotlin/com/oztechan/ccc/common/mapper/Boolean.kt @@ -0,0 +1,3 @@ +package com.oztechan.ccc.common.mapper + +fun Boolean.toLong() = if (this) 1L else 0L diff --git a/common/src/commonMain/kotlin/com/oztechan/ccc/common/mapper/Long.kt b/common/src/commonMain/kotlin/com/oztechan/ccc/common/mapper/Long.kt new file mode 100644 index 0000000000..d7ca828c0b --- /dev/null +++ b/common/src/commonMain/kotlin/com/oztechan/ccc/common/mapper/Long.kt @@ -0,0 +1,7 @@ +package com.oztechan.ccc.common.mapper + +fun Long.toBoolean() = when (this) { + 1L -> true + 0L -> false + else -> throw IllegalStateException("Value can not be boolean") +} diff --git a/common/src/commonMain/kotlin/com/oztechan/ccc/common/mapper/Notification.kt b/common/src/commonMain/kotlin/com/oztechan/ccc/common/mapper/Notification.kt new file mode 100644 index 0000000000..04f3b5a1ba --- /dev/null +++ b/common/src/commonMain/kotlin/com/oztechan/ccc/common/mapper/Notification.kt @@ -0,0 +1,22 @@ +package com.oztechan.ccc.common.mapper + +import com.oztechan.ccc.common.model.Watcher +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import com.oztechan.ccc.common.db.sql.Watcher as WatcherEntity + +fun WatcherEntity.toModel() = Watcher( + id = id, + base = base, + target = target, + isGreater = isGreater.toBoolean(), + rate = rate, +) + +internal fun List.toModelList(): List { + return map { it.toModel() } +} + +internal fun Flow>.mapToModel(): Flow> { + return this.map { it.toModelList() } +} \ No newline at end of file diff --git a/common/src/commonMain/kotlin/com/oztechan/ccc/common/model/Watcher.kt b/common/src/commonMain/kotlin/com/oztechan/ccc/common/model/Watcher.kt new file mode 100644 index 0000000000..a3f95bf8c0 --- /dev/null +++ b/common/src/commonMain/kotlin/com/oztechan/ccc/common/model/Watcher.kt @@ -0,0 +1,9 @@ +package com.oztechan.ccc.common.model + +data class Watcher( + val id: Long, + val base: String, + val target: String, + val isGreater: Boolean, + val rate: Double, +) \ No newline at end of file diff --git a/common/src/commonMain/kotlin/com/oztechan/ccc/common/util/DatabaseUtil.kt b/common/src/commonMain/kotlin/com/oztechan/ccc/common/util/DatabaseUtil.kt deleted file mode 100644 index 082758ad12..0000000000 --- a/common/src/commonMain/kotlin/com/oztechan/ccc/common/util/DatabaseUtil.kt +++ /dev/null @@ -1,3 +0,0 @@ -package com.oztechan.ccc.common.util - -fun Boolean.toDatabaseBoolean() = if (this) 1L else 0L diff --git a/common/src/commonTest/kotlin/com/oztechan/ccc/common/mapper/BooleanTest.kt b/common/src/commonTest/kotlin/com/oztechan/ccc/common/mapper/BooleanTest.kt new file mode 100644 index 0000000000..a98eec7f2e --- /dev/null +++ b/common/src/commonTest/kotlin/com/oztechan/ccc/common/mapper/BooleanTest.kt @@ -0,0 +1,12 @@ +package com.oztechan.ccc.common.mapper + +import kotlin.test.Test +import kotlin.test.assertEquals + +class BooleanTest { + @Test + fun toLong() { + assertEquals(0L, false.toLong()) + assertEquals(1L, true.toLong()) + } +} diff --git a/common/src/commonTest/kotlin/com/oztechan/ccc/common/mapper/LongTest.kt b/common/src/commonTest/kotlin/com/oztechan/ccc/common/mapper/LongTest.kt new file mode 100644 index 0000000000..a05c5cf9b2 --- /dev/null +++ b/common/src/commonTest/kotlin/com/oztechan/ccc/common/mapper/LongTest.kt @@ -0,0 +1,16 @@ +package com.oztechan.ccc.common.mapper + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith + +class LongTest { + @Test + fun toBoolean() { + assertEquals(true, 1L.toBoolean()) + assertEquals(false, 0L.toBoolean()) + assertFailsWith(IllegalStateException::class) { + 2L.toBoolean() + } + } +} diff --git a/common/src/commonTest/kotlin/com/oztechan/ccc/common/repo/CurrencyRepositoryTest.kt b/common/src/commonTest/kotlin/com/oztechan/ccc/common/repo/CurrencyRepositoryTest.kt index 36fa1fa7bc..6bbefcc385 100644 --- a/common/src/commonTest/kotlin/com/oztechan/ccc/common/repo/CurrencyRepositoryTest.kt +++ b/common/src/commonTest/kotlin/com/oztechan/ccc/common/repo/CurrencyRepositoryTest.kt @@ -4,7 +4,7 @@ import com.github.submob.logmob.initLogger import com.oztechan.ccc.common.db.currency.CurrencyRepository import com.oztechan.ccc.common.db.currency.CurrencyRepositoryImpl import com.oztechan.ccc.common.db.sql.CurrencyQueries -import com.oztechan.ccc.common.util.toDatabaseBoolean +import com.oztechan.ccc.common.mapper.toLong import io.mockative.Mock import io.mockative.classOf import io.mockative.mock @@ -35,7 +35,7 @@ class CurrencyRepositoryTest { repository.updateCurrencyStateByName(mockName, mockState) verify(currencyQueries) - .invocation { updateCurrencyStateByName(mockState.toDatabaseBoolean(), mockName) } + .invocation { updateCurrencyStateByName(mockState.toLong(), mockName) } .wasInvoked() } @@ -46,7 +46,7 @@ class CurrencyRepositoryTest { repository.updateAllCurrencyState(mockState) verify(currencyQueries) - .invocation { updateAllCurrencyState(mockState.toDatabaseBoolean()) } + .invocation { updateAllCurrencyState(mockState.toLong()) } .wasInvoked() } } diff --git a/common/src/commonTest/kotlin/com/oztechan/ccc/common/util/DatabaseUtilTest.kt b/common/src/commonTest/kotlin/com/oztechan/ccc/common/util/DatabaseUtilTest.kt deleted file mode 100644 index 8a1d443616..0000000000 --- a/common/src/commonTest/kotlin/com/oztechan/ccc/common/util/DatabaseUtilTest.kt +++ /dev/null @@ -1,12 +0,0 @@ -package com.oztechan.ccc.common.util - -import kotlin.test.Test -import kotlin.test.assertEquals - -class DatabaseUtilTest { - @Test - fun toDatabaseBoolean() { - assertEquals(0L, false.toDatabaseBoolean()) - assertEquals(1L, true.toDatabaseBoolean()) - } -} diff --git a/ios/.gitignore b/ios/.gitignore index 1ec657ac73..5b873e0a77 100644 --- a/ios/.gitignore +++ b/ios/.gitignore @@ -5,3 +5,4 @@ CCC/Resources/Release.xcconfig fastlane/.env fastlane/README.md fastlane/report.xml +xcuserdata diff --git a/ios/CCC.xcodeproj/project.pbxproj b/ios/CCC.xcodeproj/project.pbxproj index 018399a9df..8b21bbf413 100644 --- a/ios/CCC.xcodeproj/project.pbxproj +++ b/ios/CCC.xcodeproj/project.pbxproj @@ -24,6 +24,8 @@ 5C31E4362814308B008C42B9 /* SettingsItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C31E4352814308B008C42B9 /* SettingsItemView.swift */; }; 5C31E439281431A3008C42B9 /* SelectCurrencyItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C31E438281431A3008C42B9 /* SelectCurrencyItemView.swift */; }; 5C31E43F28145D32008C42B9 /* Launch Screen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 5C31E43E28145D32008C42B9 /* Launch Screen.storyboard */; }; + 5C4B53692818057F00D10185 /* WatchersView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C4B53682818057F00D10185 /* WatchersView.swift */; }; + 5C4B536B2818066000D10185 /* WatchersToolbarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C4B536A2818066000D10185 /* WatchersToolbarView.swift */; }; 5C5D09332562EB9E00DA9C4A /* Application.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C5D09322562EB9E00DA9C4A /* Application.swift */; }; 5C5D09362562EBDE00DA9C4A /* Koin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C5D09352562EBDE00DA9C4A /* Koin.swift */; }; 5C5D09392562EC0100DA9C4A /* Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C5D09382562EC0100DA9C4A /* Extensions.swift */; }; @@ -33,12 +35,15 @@ 5C6E674D25C602BE001CC0D6 /* SnackBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C6E674C25C602BE001CC0D6 /* SnackBar.swift */; }; 5C8EB4A9260CB5E200DC4A90 /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = 5C8EB4A8260CB5E200DC4A90 /* GoogleService-Info.plist */; }; 5C8FDBDD25BF3FBE00F280FF /* ObservableSEED.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C8FDBDC25BF3FBE00F280FF /* ObservableSEED.swift */; }; + 5C94AC32282FA4B2004C9B3D /* CurrencyImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C94AC31282FA4B2004C9B3D /* CurrencyImageView.swift */; }; 5C9A59BB25C350DE006745B0 /* MainView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C9A59BA25C350DE006745B0 /* MainView.swift */; }; 5C9C75C82603A36A00D66FDD /* ToolbarButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C9C75C72603A36A00D66FDD /* ToolbarButton.swift */; }; 5CB954BF26932408007632DC /* BannerAdView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CB954BE26932408007632DC /* BannerAdView.swift */; }; 5CDE468425BC3B2000CA0FB1 /* SelectCurrencyView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CDE468325BC3B2000CA0FB1 /* SelectCurrencyView.swift */; }; + 5CEA86F52840CF65001386FB /* NotificationManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CEA86F42840CF65001386FB /* NotificationManager.swift */; }; 5CF57E3A269588060081E4BB /* RewardedAd.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CF57E39269588060081E4BB /* RewardedAd.swift */; }; 5CF57E3C2695A3B20081E4BB /* InterstitialAd.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CF57E3B2695A3B20081E4BB /* InterstitialAd.swift */; }; + 5CF898D42823C1F900712580 /* WatcherItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CF898D32823C1F900712580 /* WatcherItem.swift */; }; 5CF8BE4227DE205B00E441F5 /* MailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CF8BE4127DE205B00E441F5 /* MailView.swift */; }; 5CF8BE4627DE334100E441F5 /* WebView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CF8BE4527DE334100E441F5 /* WebView.swift */; }; 7555FF85242A565B00829871 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 7555FF84242A565B00829871 /* Assets.xcassets */; }; @@ -76,6 +81,8 @@ 5C31E4352814308B008C42B9 /* SettingsItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsItemView.swift; sourceTree = ""; }; 5C31E438281431A3008C42B9 /* SelectCurrencyItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SelectCurrencyItemView.swift; sourceTree = ""; }; 5C31E43E28145D32008C42B9 /* Launch Screen.storyboard */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; path = "Launch Screen.storyboard"; sourceTree = ""; }; + 5C4B53682818057F00D10185 /* WatchersView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchersView.swift; sourceTree = ""; }; + 5C4B536A2818066000D10185 /* WatchersToolbarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchersToolbarView.swift; sourceTree = ""; }; 5C5D09322562EB9E00DA9C4A /* Application.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Application.swift; sourceTree = ""; }; 5C5D09352562EBDE00DA9C4A /* Koin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Koin.swift; sourceTree = ""; }; 5C5D09382562EC0100DA9C4A /* Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Extensions.swift; sourceTree = ""; }; @@ -85,14 +92,17 @@ 5C6E674C25C602BE001CC0D6 /* SnackBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SnackBar.swift; sourceTree = ""; }; 5C8EB4A8260CB5E200DC4A90 /* GoogleService-Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = "GoogleService-Info.plist"; sourceTree = ""; }; 5C8FDBDC25BF3FBE00F280FF /* ObservableSEED.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ObservableSEED.swift; sourceTree = ""; }; + 5C94AC31282FA4B2004C9B3D /* CurrencyImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CurrencyImageView.swift; sourceTree = ""; }; 5C9A59BA25C350DE006745B0 /* MainView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainView.swift; sourceTree = ""; }; 5C9C75C72603A36A00D66FDD /* ToolbarButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ToolbarButton.swift; sourceTree = ""; }; 5CB954BE26932408007632DC /* BannerAdView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BannerAdView.swift; sourceTree = ""; }; 5CB954C526934EFC007632DC /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = ""; }; 5CB954CD269362E2007632DC /* Debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = ""; }; 5CDE468325BC3B2000CA0FB1 /* SelectCurrencyView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SelectCurrencyView.swift; sourceTree = ""; }; + 5CEA86F42840CF65001386FB /* NotificationManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationManager.swift; sourceTree = ""; }; 5CF57E39269588060081E4BB /* RewardedAd.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RewardedAd.swift; sourceTree = ""; }; 5CF57E3B2695A3B20081E4BB /* InterstitialAd.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InterstitialAd.swift; sourceTree = ""; }; + 5CF898D32823C1F900712580 /* WatcherItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatcherItem.swift; sourceTree = ""; }; 5CF8BE4127DE205B00E441F5 /* MailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MailView.swift; sourceTree = ""; }; 5CF8BE4527DE334100E441F5 /* WebView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebView.swift; sourceTree = ""; }; 7555FF7B242A565900829871 /* CCC.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = CCC.app; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -124,6 +134,7 @@ 5C9C75C72603A36A00D66FDD /* ToolbarButton.swift */, 5CF8BE4127DE205B00E441F5 /* MailView.swift */, 5CF8BE4527DE334100E441F5 /* WebView.swift */, + 5C94AC31282FA4B2004C9B3D /* CurrencyImageView.swift */, ); path = SubView; sourceTree = ""; @@ -179,6 +190,16 @@ path = Settings; sourceTree = ""; }; + 5C4B53652818053E00D10185 /* Watchers */ = { + isa = PBXGroup; + children = ( + 5C4B53682818057F00D10185 /* WatchersView.swift */, + 5C4B536A2818066000D10185 /* WatchersToolbarView.swift */, + 5CF898D32823C1F900712580 /* WatcherItem.swift */, + ); + path = Watchers; + sourceTree = ""; + }; 5C4B536E28184AEA00D10185 /* SelectCurrency */ = { isa = PBXGroup; children = ( @@ -204,6 +225,7 @@ 5C31E41C28141C61008C42B9 /* Calculator */, 5C31E42B28142033008C42B9 /* Currencies */, 5C31E4322814304F008C42B9 /* Settings */, + 5C4B53652818053E00D10185 /* Watchers */, 5C4B536E28184AEA00D10185 /* SelectCurrency */, 5C039FD425C1B6A2008350A3 /* SubView */, ); @@ -231,6 +253,7 @@ 5C6E674C25C602BE001CC0D6 /* SnackBar.swift */, 5CF57E39269588060081E4BB /* RewardedAd.swift */, 5CF57E3B2695A3B20081E4BB /* InterstitialAd.swift */, + 5CEA86F42840CF65001386FB /* NotificationManager.swift */, ); path = Util; sourceTree = ""; @@ -448,12 +471,16 @@ buildActionMask = 2147483647; files = ( 5C314CBE25BA0AC0007B22D8 /* CurrenciesView.swift in Sources */, + 5C94AC32282FA4B2004C9B3D /* CurrencyImageView.swift in Sources */, + 5C4B536B2818066000D10185 /* WatchersToolbarView.swift in Sources */, + 5CF898D42823C1F900712580 /* WatcherItem.swift in Sources */, 5CB954BF26932408007632DC /* BannerAdView.swift in Sources */, 5C31E42428141D1B008C42B9 /* RateStateView.swift in Sources */, 5CF57E3A269588060081E4BB /* RewardedAd.swift in Sources */, 5C9C75C82603A36A00D66FDD /* ToolbarButton.swift in Sources */, 5CF8BE4227DE205B00E441F5 /* MailView.swift in Sources */, 5C31E4362814308B008C42B9 /* SettingsItemView.swift in Sources */, + 5C4B53692818057F00D10185 /* WatchersView.swift in Sources */, 5C6E674025C5A711001CC0D6 /* SliderView.swift in Sources */, 5C31E42628141D3E008C42B9 /* CalculatorItemView.swift in Sources */, 5C039FD625C1B705008350A3 /* FormProgressView.swift in Sources */, @@ -473,6 +500,7 @@ 5C5D09392562EC0100DA9C4A /* Extensions.swift in Sources */, 5C17581A25BC74BD00D16BD9 /* SettingsView.swift in Sources */, 5C5D09332562EB9E00DA9C4A /* Application.swift in Sources */, + 5CEA86F52840CF65001386FB /* NotificationManager.swift in Sources */, 5C693EBA25C4AFF800C9373E /* SelectCurrenciesBottomView.swift in Sources */, 5C31E41E28141C7B008C42B9 /* InputView.swift in Sources */, 5C31E42A28141F1B008C42B9 /* SlideView.swift in Sources */, diff --git a/ios/CCC.xcodeproj/xcshareddata/xcschemes/CCC.xcscheme b/ios/CCC.xcodeproj/xcshareddata/xcschemes/CCC.xcscheme new file mode 100644 index 0000000000..10aec321bc --- /dev/null +++ b/ios/CCC.xcodeproj/xcshareddata/xcschemes/CCC.xcscheme @@ -0,0 +1,78 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ios/CCC.xcworkspace/contents.xcworkspacedata b/ios/CCC.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000000..b0966d756f --- /dev/null +++ b/ios/CCC.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,30 @@ + + + + + + + + + + + + + + + + + + + diff --git a/ios/CCC/Application.swift b/ios/CCC/Application.swift index 385f2a4085..9edef8b34b 100644 --- a/ios/CCC/Application.swift +++ b/ios/CCC/Application.swift @@ -11,11 +11,20 @@ import Resources import Client import Firebase import GoogleMobileAds +import BackgroundTasks let logger = LoggerKt.doInitLogger() @main struct Application: App { + @Environment(\.scenePhase) private var scenePhase + @State var alertVisibility: Bool = false + + private let notificationManager = NotificationManager() + private let backgroundManager: BackgroundManager + + private let taskID = "com.oztechan.ccc.CCC.fetch" + private let earliestTaskPeriod: Double = 1 * 60 * 60 // 1 hour init() { logger.i(message: {"Application init"}) @@ -35,11 +44,80 @@ struct Application: App { height: Double.leastNonzeroMagnitude )) UITableView.appearance().backgroundColor = MR.colors().transparent.get() + + self.backgroundManager = koin.get() + + registerAppRefresh() } var body: some Scene { WindowGroup { MainView() + .alert(isPresented: $alertVisibility) { + Alert( + title: Text(MR.strings().txt_watcher_alert_title.get()), + message: Text(MR.strings().txt_watcher_alert_sub_title.get()), + dismissButton: .destructive(Text(MR.strings().txt_ok.get())) + ) + } + }.onChange(of: scenePhase) { phase in + logger.i(message: {"Application onChange scenePhase \(phase)"}) + + if phase == .background { + scheduleAppRefresh() + } + } + } + + private func scheduleAppRefresh() { + logger.i(message: {"Application scheduleAppRefresh"}) + + let request = BGAppRefreshTaskRequest(identifier: taskID) + request.earliestBeginDate = Date(timeIntervalSinceNow: earliestTaskPeriod) + + do { + try BGTaskScheduler.shared.submit(request) + } catch { + logger.i(message: {"Application scheduleAppRefresh Could not schedule app refresh: \(error)"}) + } + } + + private func registerAppRefresh() { + logger.i(message: {"Application registerAppRefresh"}) + + BGTaskScheduler.shared.cancelAllTaskRequests() + + // swiftlint:disable force_cast + BGTaskScheduler.shared.register(forTaskWithIdentifier: taskID, using: nil) { task in + handleAppRefresh(task: task as! BGAppRefreshTask) + + task.expirationHandler = { + logger.i(message: {"Application registerAppRefresh BackgroundTask Expired"}) + + task.setTaskCompleted(success: false) + } + } + } + + private func handleAppRefresh(task: BGAppRefreshTask) { + logger.i(message: {"Application handleAppRefresh"}) + + scheduleAppRefresh() + + if backgroundManager.shouldSendNotification() { + + if scenePhase == .background { + self.notificationManager.sendNotification( + title: MR.strings().txt_watcher_alert_title.get(), + body: MR.strings().txt_watcher_alert_sub_title.get() + ) + } else { + self.alertVisibility = true + } + + task.setTaskCompleted(success: true) + } else { + task.setTaskCompleted(success: true) } } } diff --git a/ios/CCC/DI/Koin.swift b/ios/CCC/DI/Koin.swift index 9f60fc32a9..cb01dcd436 100644 --- a/ios/CCC/DI/Koin.swift +++ b/ios/CCC/DI/Koin.swift @@ -47,6 +47,14 @@ extension Koin_coreKoin { return koin.getDependency(objCClass: SettingsViewModel.self) as! SettingsViewModel } + func get() -> WatchersViewModel { + return koin.getDependency(objCClass: WatchersViewModel.self) as! WatchersViewModel + } + + func get() -> BackgroundManager { + return koin.getDependency(objCProtocol: BackgroundManager.self) as! BackgroundManager + } + // Observable func get() -> MainObservable { return MainObservable(viewModel: get()) @@ -64,6 +72,10 @@ extension Koin_coreKoin { return SettingsObservable(viewModel: get()) } + func get() -> WatchersObservable { + return WatchersObservable(viewModel: get()) + } + func get() -> CurrenciesObservable { return CurrenciesObservable(viewModel: get()) } diff --git a/ios/CCC/Resources/Info.plist b/ios/CCC/Resources/Info.plist index fca327e95b..ff045547f0 100644 --- a/ios/CCC/Resources/Info.plist +++ b/ios/CCC/Resources/Info.plist @@ -2,6 +2,10 @@ + BGTaskSchedulerPermittedIdentifiers + + com.oztechan.ccc.CCC.fetch + BANNER_AD_UNIT_ID_CALCULATOR $(BANNER_AD_UNIT_ID_CALCULATOR) BANNER_AD_UNIT_ID_CURRENCIES @@ -188,6 +192,10 @@ UIApplicationSupportsMultipleScenes + UIBackgroundModes + + fetch + UILaunchStoryboardName Launch Screen UIRequiredDeviceCapabilities diff --git a/ios/CCC/UI/Calculator/CalculatorItemView.swift b/ios/CCC/UI/Calculator/CalculatorItemView.swift index 8c1defd83b..f4edb9070f 100644 --- a/ios/CCC/UI/Calculator/CalculatorItemView.swift +++ b/ios/CCC/UI/Calculator/CalculatorItemView.swift @@ -37,10 +37,7 @@ struct CalculatorItemView: View { .onTapGesture { onItemClick(item) } .onLongPressGesture { onItemImageLongClick(item) } - Image(uiImage: item.name.getImage()) - .resizable() - .frame(width: 36, height: 36, alignment: .center) - .shadow(radius: 3) + CurrencyImageView(imageName: item.name) .onTapGesture { onItemClick(item) } .onLongPressGesture { onItemImageLongClick(item) } diff --git a/ios/CCC/UI/Calculator/CalculatorView.swift b/ios/CCC/UI/Calculator/CalculatorView.swift index 554d4b8b41..95c3581295 100644 --- a/ios/CCC/UI/Calculator/CalculatorView.swift +++ b/ios/CCC/UI/Calculator/CalculatorView.swift @@ -90,7 +90,7 @@ struct CalculatorView: View { content: { SelectCurrencyView( isBarShown: $isBarShown, - onSelectCurrency: { observable.event.onBaseChange(base: $0)} + onCurrencySelected: { observable.event.onBaseChange(base: $0)} ).environmentObject(navigationStack) } ) @@ -113,7 +113,7 @@ struct CalculatorView: View { } ) case is CalculatorEffect.MaximumInput: - showSnack(text: MR.strings().max_input.get()) + showSnack(text: MR.strings().text_max_input.get()) case is CalculatorEffect.OpenBar: isBarShown = true case is CalculatorEffect.OpenSettings: diff --git a/ios/CCC/UI/Calculator/OutputView.swift b/ios/CCC/UI/Calculator/OutputView.swift index 9a3cb2717f..59b1eb75cd 100644 --- a/ios/CCC/UI/Calculator/OutputView.swift +++ b/ios/CCC/UI/Calculator/OutputView.swift @@ -20,10 +20,7 @@ struct OutputView: View { VStack(alignment: .leading) { HStack { - Image(uiImage: baseCurrency.getImage()) - .resizable() - .frame(width: 36, height: 36, alignment: .center) - .shadow(radius: 3) + CurrencyImageView(imageName: baseCurrency) Text(baseCurrency).foregroundColor(MR.colors().text.get()) diff --git a/ios/CCC/UI/Currencies/CurrenciesItemView.swift b/ios/CCC/UI/Currencies/CurrenciesItemView.swift index b39186f90b..33bafa70cb 100644 --- a/ios/CCC/UI/Currencies/CurrenciesItemView.swift +++ b/ios/CCC/UI/Currencies/CurrenciesItemView.swift @@ -20,10 +20,8 @@ struct CurrenciesItemView: View { var body: some View { HStack { - Image(uiImage: item.name.getImage()) - .resizable() - .frame(width: 36, height: 36, alignment: .center) - .shadow(radius: 3) + CurrencyImageView(imageName: item.name) + Text(item.name) .frame(width: 45) .foregroundColor(MR.colors().text.get()) diff --git a/ios/CCC/UI/Currencies/SelectionView.swift b/ios/CCC/UI/Currencies/SelectionView.swift index e69d044ca9..f210a693b1 100644 --- a/ios/CCC/UI/Currencies/SelectionView.swift +++ b/ios/CCC/UI/Currencies/SelectionView.swift @@ -31,5 +31,6 @@ struct SelectionView: View { } .padding(EdgeInsets(top: 15, leading: 10, bottom: 15, trailing: 20)) .background(MR.colors().background_weak.get()) + .frame(maxHeight: 50) } } diff --git a/ios/CCC/UI/SelectCurrency/SelectCurrencyItemView.swift b/ios/CCC/UI/SelectCurrency/SelectCurrencyItemView.swift index 94a7432654..271173687a 100644 --- a/ios/CCC/UI/SelectCurrency/SelectCurrencyItemView.swift +++ b/ios/CCC/UI/SelectCurrency/SelectCurrencyItemView.swift @@ -17,10 +17,8 @@ struct SelectCurrencyItemView: View { var body: some View { HStack { - Image(uiImage: item.name.getImage()) - .resizable() - .frame(width: 36, height: 36, alignment: .center) - .shadow(radius: 3) + CurrencyImageView(imageName: item.name) + Text(item.name) .frame(width: 45) .foregroundColor(MR.colors().text.get()) diff --git a/ios/CCC/UI/SelectCurrency/SelectCurrencyView.swift b/ios/CCC/UI/SelectCurrency/SelectCurrencyView.swift index 114474bb48..001cc0c1a0 100644 --- a/ios/CCC/UI/SelectCurrency/SelectCurrencyView.swift +++ b/ios/CCC/UI/SelectCurrency/SelectCurrencyView.swift @@ -21,7 +21,7 @@ struct SelectCurrencyView: View { @StateObject var observable: SelectCurrencyObservable = koin.get() @Binding var isBarShown: Bool - var onSelectCurrency: (String) -> Void + var onCurrencySelected: (String) -> Void var body: some View { @@ -71,10 +71,10 @@ struct SelectCurrencyView: View { switch effect { // swiftlint:disable force_cast case is SelectCurrencyEffect.CurrencyChange: - onSelectCurrency((effect as! SelectCurrencyEffect.CurrencyChange).newBase) + onCurrencySelected((effect as! SelectCurrencyEffect.CurrencyChange).newBase) isBarShown = false case is SelectCurrencyEffect.OpenCurrencies: - navigationStack.push(CurrenciesView(onBaseChange: onSelectCurrency)) + navigationStack.push(CurrenciesView(onBaseChange: onCurrencySelected)) default: logger.i(message: {"BarView unknown effect"}) } diff --git a/ios/CCC/UI/Settings/SettingsItemView.swift b/ios/CCC/UI/Settings/SettingsItemView.swift index 66b0259ce9..4b1199a02a 100644 --- a/ios/CCC/UI/Settings/SettingsItemView.swift +++ b/ios/CCC/UI/Settings/SettingsItemView.swift @@ -24,8 +24,7 @@ struct SettingsItemView: View { .font(.system(size: 24)) .imageScale(.large) .accentColor(MR.colors().text.get()) - .padding(.bottom, 8) - .padding(.top, 8) + .padding(EdgeInsets(top: 8, leading: 0, bottom: 8, trailing: 8)) VStack { HStack { diff --git a/ios/CCC/UI/Settings/SettingsView.swift b/ios/CCC/UI/Settings/SettingsView.swift index cb28ec2512..c85337be2a 100644 --- a/ios/CCC/UI/Settings/SettingsView.swift +++ b/ios/CCC/UI/Settings/SettingsView.swift @@ -44,12 +44,22 @@ struct SettingsView: View { imgName: "dollarsign.circle.fill", title: MR.strings().settings_item_currencies_title.get(), subTitle: MR.strings().settings_item_currencies_sub_title.get(), - value: MR.strings().settings_item_currencies_value.get( + value: MR.strings().settings_active_item_value.get( parameter: observable.state.activeCurrencyCount ), onClick: observable.event.onCurrenciesClick ) + SettingsItemView( + imgName: "eyeglasses", + title: MR.strings().settings_item_watchers_title.get(), + subTitle: MR.strings().settings_item_watchers_sub_title.get(), + value: MR.strings().settings_active_item_value.get( + parameter: observable.state.activeWatcherCount + ), + onClick: observable.event.onWatchersClicked + ) + // SettingsItemView( // imgName: "eye.slash.fill", // title: MR.strings().settings_item_remove_ads_title.get(), @@ -83,7 +93,9 @@ struct SettingsView: View { value: "", onClick: observable.event.onOnGitHubClick ) - }.background(MR.colors().background.get()) + } + .background(MR.colors().background.get()) + .edgesIgnoringSafeArea(.bottom) // if observable.viewModel.shouldShowBannerAd() { // BannerAdView( @@ -131,6 +143,7 @@ struct SettingsView: View { .onReceive(observable.effect) { onEffect(effect: $0) } } + // swiftlint:disable cyclomatic_complexity private func onEffect(effect: SettingsEffect) { logger.i(message: {"SettingsView onEffect \(effect.description)"}) switch effect { @@ -138,6 +151,8 @@ struct SettingsView: View { navigationStack.pop() case is SettingsEffect.OpenCurrencies: navigationStack.push(CurrenciesView(onBaseChange: onBaseChange)) + case is SettingsEffect.OpenWatchers: + navigationStack.push(WatchersView()) case is SettingsEffect.FeedBack: emailViewVisibility.toggle() case is SettingsEffect.OnGitHub: diff --git a/ios/CCC/UI/Slider/SliderView.swift b/ios/CCC/UI/Slider/SliderView.swift index 33f01bc845..a9f2737150 100644 --- a/ios/CCC/UI/Slider/SliderView.swift +++ b/ios/CCC/UI/Slider/SliderView.swift @@ -25,20 +25,20 @@ struct SliderView: View { buttonAction: { navigationStack.push( - SlideView( - title: MR.strings().slide_bug_report_title.get(), - image: Image(systemName: "ant.fill"), - subTitle1: MR.strings().slide_bug_report_text_1.get(), - subTitle2: MR.strings().slide_bug_report_text_2.get(), - buttonText: MR.strings().next.get(), - buttonAction: { - navigationStack.push( +// SlideView( +// title: MR.strings().slide_disable_ads_title.get(), +// image: Image(systemName: "eye.slash.fill"), +// subTitle1: MR.strings().slide_disable_ads_text_1.get(), +// subTitle2: MR.strings().slide_disable_ads_text_2.get(), +// buttonText: MR.strings().next.get(), +// buttonAction: { +// navigationStack.push( SlideView( - title: MR.strings().slide_disable_ads_title.get(), - image: Image(systemName: "eye.slash.fill"), - subTitle1: MR.strings().slide_disable_ads_text_1.get(), - subTitle2: MR.strings().slide_disable_ads_text_2.get(), + title: MR.strings().slide_bug_report_title.get(), + image: Image(systemName: "ant.fill"), + subTitle1: MR.strings().slide_bug_report_text_1.get(), + subTitle2: MR.strings().slide_bug_report_text_2.get(), buttonText: MR.strings().next.get(), buttonAction: { navigationStack.push( @@ -47,9 +47,9 @@ struct SliderView: View { } ) - ) - } - ) +// ) +// } +// ) ) } diff --git a/ios/CCC/UI/SubView/CurrencyImageView.swift b/ios/CCC/UI/SubView/CurrencyImageView.swift new file mode 100644 index 0000000000..c9c475159d --- /dev/null +++ b/ios/CCC/UI/SubView/CurrencyImageView.swift @@ -0,0 +1,20 @@ +// +// CurrencyImageView.swift +// CCC +// +// Created by Mustafa Ozhan on 14.05.22. +// Copyright © 2022 orgName. All rights reserved. +// + +import SwiftUI + +struct CurrencyImageView: View { + let imageName: String + + var body: some View { + Image(uiImage: imageName.getImage()) + .resizable() + .frame(width: 36, height: 36, alignment: .center) + .shadow(radius: 3) + } +} diff --git a/ios/CCC/UI/Watchers/WatcherItem.swift b/ios/CCC/UI/Watchers/WatcherItem.swift new file mode 100644 index 0000000000..a732e4b6b6 --- /dev/null +++ b/ios/CCC/UI/Watchers/WatcherItem.swift @@ -0,0 +1,75 @@ +// +// WatcherItem.swift +// CCC +// +// Created by Mustafa Ozhan on 05.05.22. +// Copyright © 2022 orgName. All rights reserved. +// + +import SwiftUI +import Client +import Resources +import NavigationStack +import Combine + +struct WatcherItem: View { + @State private var relationSelection = 0 + @State private var amount = "" + + @Binding var isBaseBarShown: Bool + @Binding var isTargetBarShown: Bool + + let watcher: Client.Watcher + let event: WatchersEvent + + var body: some View { + HStack { + Text(MR.strings().one.get()).font(.body) + + CurrencyImageView(imageName: watcher.base) + .onTapGesture { event.onBaseClick(watcher: watcher) } + + Picker("", selection: $relationSelection) { + Text(MR.strings().txt_smaller.get()) + .font(.title) + .tag(0) + Text(MR.strings().txt_grater.get()) + .font(.title) + .tag(1) + } + .pickerStyle(.segmented) + .frame(maxWidth: 80) + .onChange(of: relationSelection) { + event.onRelationChange(watcher: watcher, isGreater: $0 == 1) + } + + Spacer() + + TextField(MR.strings().txt_rate.get(), text: $amount) + .keyboardType(.decimalPad) + .font(.body) + .multilineTextAlignment(TextAlignment.center) + .fixedSize() + .lineLimit(1) + .padding(EdgeInsets(top: 5, leading: 15, bottom: 5, trailing: 15)) + .background(MR.colors().background_weak.get()) + .cornerRadius(7) + .onChange(of: amount) { + amount = event.onRateChange(watcher: watcher, rate: $0) + } + + Spacer() + + CurrencyImageView(imageName: watcher.target) + .onTapGesture { event.onTargetClick(watcher: watcher) } + + Image(systemName: "trash") + .padding(.leading, 10) + .onTapGesture { event.onDeleteClick(watcher: watcher) } + + }.onAppear { + relationSelection = watcher.isGreater ? 1 : 0 + amount = "\(watcher.rate)" + } + } +} diff --git a/ios/CCC/UI/Watchers/WatchersToolbarView.swift b/ios/CCC/UI/Watchers/WatchersToolbarView.swift new file mode 100644 index 0000000000..afec7ca264 --- /dev/null +++ b/ios/CCC/UI/Watchers/WatchersToolbarView.swift @@ -0,0 +1,38 @@ +// +// WatchersToolbarView.swift +// CCC +// +// Created by Mustafa Ozhan on 26.04.22. +// Copyright © 2022 orgName. All rights reserved. +// + +import SwiftUI +import Resources + +struct WatchersToolbarView: View { + var backEvent: () -> Void + + var body: some View { + VStack { + HStack { + ToolbarButton(clickEvent: backEvent, imgName: "chevron.left") + + Text(MR.strings().txt_watchers.get()) + .font(.title3) + + Spacer() + } + + Text(MR.strings().txt_watchers_description.get()) + .contentShape(Rectangle()) + .font(.footnote) + .multilineTextAlignment(.center) + .background(MR.colors().background_strong.get()) + .foregroundColor(MR.colors().text_weak.get()) + .padding(10) + } + .frame(width: .infinity, height: .nan) + .padding(EdgeInsets(top: 15, leading: 10, bottom: 5, trailing: 20)) + .background(MR.colors().background_strong.get()) + } +} diff --git a/ios/CCC/UI/Watchers/WatchersView.swift b/ios/CCC/UI/Watchers/WatchersView.swift new file mode 100644 index 0000000000..d6ac2e200c --- /dev/null +++ b/ios/CCC/UI/Watchers/WatchersView.swift @@ -0,0 +1,178 @@ +// +// WatchersView.swift +// CCC +// +// Created by Mustafa Ozhan on 26.04.22. +// Copyright © 2022 orgName. All rights reserved. +// + +import SwiftUI +import Client +import Resources +import NavigationStack + +typealias WatchersObservable = ObservableSEED + + +struct WatchersView: View { + @EnvironmentObject private var navigationStack: NavigationStack + @StateObject var observable: WatchersObservable = koin.get() + @StateObject var notificationManager = NotificationManager() + @State var baseBarInfo = BarInfo(isShown: false, watcher: nil) + @State var targetBarInfo = BarInfo(isShown: false, watcher: nil) + + var watcher: Client.Watcher? + + var body: some View { + ZStack { + MR.colors().background_strong.get().edgesIgnoringSafeArea(.all) + + VStack { + WatchersToolbarView(backEvent: observable.event.onBackClick) + + if notificationManager.authorizationStatus == .authorized { + + Form { + List(observable.state.watcherList, id: \.id) { watcher in + WatcherItem( + isBaseBarShown: $baseBarInfo.isShown, + isTargetBarShown: $targetBarInfo.isShown, + watcher: watcher, + event: observable.event + ) + } + .listRowInsets(.init()) + .listRowBackground(MR.colors().background.get()) + } + + if observable.state.watcherList.count == 0 { + Text(MR.strings().txt_click_to_add.get()) + .font(.footnote) + .frame(width: .infinity, height: .infinity, alignment: .center) + .padding() + } + + VStack { + Button { + observable.event.onAddClick() + } label: { + Label(MR.strings().txt_add.get(), systemImage: "plus") + } + .foregroundColor(MR.colors().text.get()) + .padding(.top, 10) + .padding(.bottom, 20) + } + .padding(12) + .frame(maxWidth: .infinity, alignment: .center) + .background(MR.colors().background_strong.get()) + + } else { + VStack { + Text(MR.strings().txt_enable_notification_permission.get()) + .multilineTextAlignment(.center) + Button { + if let url = URL( + string: UIApplication.openSettingsURLString + ), UIApplication.shared.canOpenURL(url) { + UIApplication.shared.open(url) + } + } label: { + Label(MR.strings().txt_settings.get(), systemImage: "gear") + } + .padding() + .background(MR.colors().background_weak.get()) + .foregroundColor(MR.colors().text.get()) + .cornerRadius(5) + + }.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center) + } + } + .background(MR.colors().background.get()) + .edgesIgnoringSafeArea(.bottom) + } + .sheet( + isPresented: $baseBarInfo.isShown, + content: { + SelectCurrencyView( + isBarShown: $baseBarInfo.isShown, + onCurrencySelected: { + observable.event.onBaseChanged( + watcher: baseBarInfo.watcher, + newBase: $0 + ) + } + ).environmentObject(navigationStack) + } + ) + .sheet( + isPresented: $targetBarInfo.isShown, + content: { + SelectCurrencyView( + isBarShown: $targetBarInfo.isShown, + onCurrencySelected: { + observable.event.onTargetChanged( + watcher: targetBarInfo.watcher, + newTarget: $0 + ) + } + ).environmentObject(navigationStack) + } + ) + .onAppear { + observable.startObserving() + notificationManager.reloadAuthorisationStatus() + } + .onDisappear { observable.stopObserving() } + .onReceive(observable.effect) { onEffect(effect: $0) } + .onReceive(NotificationCenter.default.publisher( + for: UIApplication.willEnterForegroundNotification + )) { _ in + notificationManager.reloadAuthorisationStatus() + } + .onChange(of: notificationManager.authorizationStatus) { + onAuthorisationChange(authorizationStatus: $0) + } + .animation(.default) + } + + private func onEffect(effect: WatchersEffect) { + logger.i(message: {"WatchersView onEffect \(effect.description)"}) + switch effect { + case is WatchersEffect.Back: + navigationStack.pop() + // swiftlint:disable force_cast + case is WatchersEffect.SelectBase: + baseBarInfo.watcher = (effect as! WatchersEffect.SelectBase).watcher + baseBarInfo.isShown.toggle() + // swiftlint:disable force_cast + case is WatchersEffect.SelectTarget: + targetBarInfo.watcher = (effect as! WatchersEffect.SelectTarget).watcher + targetBarInfo.isShown.toggle() + case is WatchersEffect.MaximumInput: + showSnack(text: MR.strings().text_max_input.get(), isTop: true) + case is WatchersEffect.InvalidInput: + showSnack(text: MR.strings().text_invalid_input.get(), isTop: true) + case is WatchersEffect.MaximumNumberOfWatchers: + showSnack(text: MR.strings().text_maximum_number_of_watchers.get(), isTop: true) + default: + logger.i(message: {"WatchersView unknown effect"}) + } + } + + private func onAuthorisationChange(authorizationStatus: UNAuthorizationStatus?) { + logger.i(message: {"WatchersView onAuthorisationChange \(String(describing: authorizationStatus?.rawValue))"}) + switch authorizationStatus { + case .notDetermined: + notificationManager.requestAuthorisation() + case .authorized: + notificationManager.reloadAuthorisationStatus() + default: + break + } + } + + struct BarInfo { + var isShown: Bool + var watcher: Client.Watcher? + } +} diff --git a/ios/CCC/Util/NotificationManager.swift b/ios/CCC/Util/NotificationManager.swift new file mode 100644 index 0000000000..595f9e4eb0 --- /dev/null +++ b/ios/CCC/Util/NotificationManager.swift @@ -0,0 +1,67 @@ +// +// NotificationManager.swift +// CCC +// +// Created by Mustafa Ozhan on 27.05.22. +// Copyright © 2022 orgName. All rights reserved. +// + +import Client +import Resources +import UserNotifications + +final class NotificationManager: ObservableObject { + @Published var authorizationStatus: UNAuthorizationStatus? + + init() { + logger.i(message: {"NotificationManager init"}) + } + + func reloadAuthorisationStatus() { + logger.i(message: {"NotificationManager reloadAuthorisationStatus"}) + + UNUserNotificationCenter.current().getNotificationSettings { settings in + DispatchQueue.main.async { + self.authorizationStatus = settings.authorizationStatus + } + } + } + + func requestAuthorisation() { + logger.i(message: {"NotificationManager requestAuthorisation"}) + UNUserNotificationCenter.current().requestAuthorization( + options: [.badge, .alert, .sound] + ) { isGranted, error in + + logger.i(message: { + "NotificationManager requestAuthorisation error: \(String(describing: error)) isGradted: \(isGranted)" + }) + DispatchQueue.main.async { + self.authorizationStatus = isGranted ? .authorized : .denied + } + } + } + + func sendNotification(title: String, body: String) { + logger.i(message: {"NotificationManager sendNotification title:\(title) body:\(body)"}) + + let trigger = UNTimeIntervalNotificationTrigger(timeInterval: 3, repeats: false) + + let content = UNMutableNotificationContent() + content.sound = .default + content.title = title + content.body = body + + let request = UNNotificationRequest( + identifier: UUID().uuidString, + content: content, + trigger: trigger + ) + + UNUserNotificationCenter.current().add(request) { error in + logger.i(message: { + "NotificationManager sendNotification error: \(String(describing: error))" + }) + } + } +} diff --git a/ios/CCC/Util/ObservableSEED.swift b/ios/CCC/Util/ObservableSEED.swift index 79a3aa20a0..c9095e1a79 100644 --- a/ios/CCC/Util/ObservableSEED.swift +++ b/ios/CCC/Util/ObservableSEED.swift @@ -26,7 +26,7 @@ final class ObservableSEED< let data: Data? - private var closeable: Ktor_ioCloseable! + private var closeable: RuntimeCloseable! // swiftlint:disable force_cast init(viewModel: ViewModel) { @@ -46,12 +46,12 @@ final class ObservableSEED< logger.i(message: {"ObservableSEED \(ViewModel.description()) startObserving"}) if viewModel.state != nil { - closeable = viewModel.observe(viewModel.state!, onChange: { + closeable = IOSCoroutineUtilKt.observeWithCloseable(viewModel.state!, onChange: { self.state = $0 as! State }) } if viewModel.effect != nil { - closeable = viewModel.observe(viewModel.effect!, onChange: { + closeable = IOSCoroutineUtilKt.observeWithCloseable(viewModel.effect!, onChange: { self.effect.send($0 as! Effect) }) } diff --git a/ios/CCC/Util/SnackBar.swift b/ios/CCC/Util/SnackBar.swift index 9c5206fdb1..92859954d1 100644 --- a/ios/CCC/Util/SnackBar.swift +++ b/ios/CCC/Util/SnackBar.swift @@ -14,8 +14,10 @@ func showSnack( text: String, buttonText: String? = nil, action: (() -> Void)? = nil, - iconImage: UIImage = MR.images().ic_app_logo.get() + iconImage: UIImage = MR.images().ic_app_logo.get(), + isTop: Bool = false ) { + SwiftMessages.hide(animated: false) let view = MessageView.viewFromNib(layout: .cardView) view.configureTheme( @@ -54,10 +56,9 @@ func showSnack( } var config = SwiftMessages.defaultConfig - config.presentationStyle = .bottom + config.presentationStyle = isTop ? .top : .bottom config.presentationContext = .window(windowLevel: UIWindow.Level.normal) - SwiftMessages.hide(animated: false) SwiftMessages.show(config: config, view: view) } diff --git a/resources/src/commonMain/resources/MR/base/strings.xml b/resources/src/commonMain/resources/MR/base/strings.xml index fc3eab78ff..6ee81bb3c3 100644 --- a/resources/src/commonMain/resources/MR/base/strings.xml +++ b/resources/src/commonMain/resources/MR/base/strings.xml @@ -35,14 +35,12 @@ Done Select your currencies - + Please Select at least 2 currencies. OK Select Cancel - - - Maximum number of input exceeded. + Maximum number of input exceeded. Online! Last update: %s Cached! Last update: %s Offline! Last update: %s @@ -50,6 +48,8 @@ No data! Check your connection or try again later. Copied to clipboard! + Invalid input! + Maximum number of watchers exceeded You can rate us and review our app in the market :) @@ -86,10 +86,13 @@ Settings + %d active Currencies Set your currencies - %d active + + Watchers + Check in the background Theme Change app theme @@ -99,8 +102,6 @@ Disable ads Expired\n Will expire\n%s - Active - Not active Ads are already disabled Synchronise @@ -124,6 +125,18 @@ Currencies + + Watchers + Watchers will be checked on the background and if the threshold is reached then you will get a notification! + Please enable notification permission in Settings + Watcher Alert! + The rate threshold is reached. + Rate + > + < + Add + Click Add button to add a new watcher + Remove Ads Would you like to watch a video to stop ads for 2 days ? @@ -133,4 +146,4 @@ New Version Available Looks like you have an older version of the app. Please update to get latest features and bug fixes to get best experience. - \ No newline at end of file +