diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 253c6aca..6f6d2220 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -21,7 +21,7 @@ android { applicationId = "ltd.grunt.brainwallet" minSdk = 29 targetSdk = 36 - versionCode = 202506295 + versionCode = 202506296 versionName = "v4.7.2" multiDexEnabled = true @@ -71,6 +71,13 @@ android { firebaseCrashlytics { nativeSymbolUploadEnabled = true } + buildConfigField("String[]", "SCREENGRAB_PAPERKEY", + "new String[] {${ + localProperties.getProperty("SCREENGRAB_PAPERKEY", "") + .split(" ") + .joinToString { "\"$it\"" } + }}" + ) } val release by getting { @@ -125,13 +132,6 @@ android { applicationId = "ltd.grunt.brainwallet.screengrab" versionNameSuffix = "-screengrab" resValue("string", "app_name", "Brainwallet (screengrab)") - buildConfigField("String[]", "SCREENGRAB_PAPERKEY", - "new String[] {${ - localProperties.getProperty("SCREENGRAB_PAPERKEY", "") - .split(" ") - .joinToString { "\"$it\"" } - }}" - ) externalNativeBuild { cmake { @@ -220,6 +220,7 @@ dependencies { implementation(libs.bundles.google.play.feature.delivery) implementation(libs.bundles.google.play.review) implementation(libs.kotlinx.serialization.json) + implementation(libs.kotlinx.coroutines.android) implementation (libs.airbnb.lottie.compose) implementation(platform(libs.koin.bom)) implementation(libs.bundles.koin) @@ -243,11 +244,15 @@ dependencies { testImplementation(libs.junit) testImplementation(libs.mockk) + testImplementation(libs.turbine) + testImplementation(libs.slf4j.android) + testImplementation(libs.kotlinx.coroutines.tests) androidTestImplementation(platform(libs.androidx.compose.bom)) androidTestImplementation(libs.bundles.androidx.compose.ui.test) androidTestImplementation(libs.bundles.android.test) androidTestImplementation(libs.fastlane.screengrab) + androidTestImplementation(libs.slf4j.android) } val ktlintCheck by tasks.registering(JavaExec::class) { @@ -280,4 +285,8 @@ tasks.register("ktlintFormat") { "**.kts", "!**/build/**", ) -} \ No newline at end of file +} + +tasks.withType { + jvmArgs("-XX:+EnableDynamicAgentLoading") +} diff --git a/app/src/main/java/com/brainwallet/BrainwalletApp.kt b/app/src/main/java/com/brainwallet/BrainwalletApp.kt index 9143a90f..e2f1db99 100644 --- a/app/src/main/java/com/brainwallet/BrainwalletApp.kt +++ b/app/src/main/java/com/brainwallet/BrainwalletApp.kt @@ -8,6 +8,7 @@ import android.content.res.Resources import com.appsflyer.AppsFlyerLib import com.brainwallet.data.source.RemoteConfigSource import com.brainwallet.di.AppModule +import com.brainwallet.domain.MessagingTopicUseCase import com.brainwallet.notification.NotificationHandler import com.brainwallet.presenter.activities.util.BRActivity import com.brainwallet.presenter.entities.ServiceItems @@ -32,6 +33,7 @@ open class BrainwalletApp : Application() { private val remoteConfigSource: RemoteConfigSource by inject() private val notificationHandler: NotificationHandler by inject() + private val messagingTopicHandler: MessagingTopicUseCase by inject() override fun onCreate() { super.onCreate() @@ -80,6 +82,7 @@ open class BrainwalletApp : Application() { modules(AppModule.dataModule, AppModule.module) } thread { remoteConfigSource.initialize() } + thread { messagingTopicHandler.initialize() } } // override fun attachBaseContext(base: Context) { diff --git a/app/src/main/java/com/brainwallet/data/repository/MessagingTopicRepository.kt b/app/src/main/java/com/brainwallet/data/repository/MessagingTopicRepository.kt new file mode 100644 index 00000000..25947009 --- /dev/null +++ b/app/src/main/java/com/brainwallet/data/repository/MessagingTopicRepository.kt @@ -0,0 +1,20 @@ +package com.brainwallet.data.repository + +import com.brainwallet.data.model.Language +import com.brainwallet.data.source.MessagingTopicDataSource +import org.koin.core.annotation.Single + +@Single +class MessagingTopicRepository( + private val settingRepository: SettingRepository, + private val topicDataSource: MessagingTopicDataSource +) { + fun getCurrentTopics(): List { + val currentLanguage = settingRepository.getCurrentLanguage() + return topicDataSource.getTopicsByLanguageCode(currentLanguage.code) + } + + fun getTopicsByLanguage(language: Language): List { + return topicDataSource.getTopicsByLanguageCode(language.code) + } +} diff --git a/app/src/main/java/com/brainwallet/data/repository/SyncAnalyticsRepository.kt b/app/src/main/java/com/brainwallet/data/repository/SyncAnalyticsRepository.kt new file mode 100644 index 00000000..3486dd02 --- /dev/null +++ b/app/src/main/java/com/brainwallet/data/repository/SyncAnalyticsRepository.kt @@ -0,0 +1,106 @@ +package com.brainwallet.data.repository + +import android.content.SharedPreferences +import androidx.core.content.edit +import com.brainwallet.data.source.AnalyticsSource +import com.brainwallet.data.source.PeerManagerSource +import org.koin.core.annotation.Single +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale +import java.util.UUID + +@Single +class SyncAnalyticsRepository( + private val analyticsSource: AnalyticsSource, + private val peerManagerSource: PeerManagerSource, + private val prefs: SharedPreferences, +) { + fun startSync() { + // Record the start time of the current sync segment. + prefs.edit { + putLong(KEY_CURRENT_SYNC_START_TIMESTAMP, peerManagerSource.getLastBlockTimestamp()) + } + } + + fun stopSync() { + val start = prefs.getLong(KEY_CURRENT_SYNC_START_TIMESTAMP, 0L) + if (start != 0L) { + val duration = peerManagerSource.getLastBlockTimestamp() - start + val accumulated = prefs.getLong(KEY_ACCUMULATED_DURATION, 0L) + prefs.edit { + putLong(KEY_ACCUMULATED_DURATION, accumulated + duration) + remove(KEY_CURRENT_SYNC_START_TIMESTAMP) + } + } + } + + fun completeSync() { + // Capture the duration of the final segment. + stopSync() + + val totalDuration = prefs.getLong(KEY_ACCUMULATED_DURATION, 0L) + if (totalDuration == 0L) return + + val endTimestamp = peerManagerSource.getLastBlockTimestamp() + val endBlockHeight = peerManagerSource.getCurrentBlockHeight() + val uuid = UUID.randomUUID().toString() + val totalDurationInMillis = totalDuration * 1000 + + prefs.edit { + putString(KEY_LAST_UUID, uuid) + putLong(KEY_LAST_DURATION, totalDurationInMillis) + putLong(KEY_LAST_END, endTimestamp) + remove(KEY_ACCUMULATED_DURATION) + } + + val params = mapOf( + KEY_UUID to uuid, + KEY_DURATION_MILLIS to (totalDurationInMillis), + KEY_END_TIMESTAMP to endTimestamp, + KEY_END_BLOCK_HEIGHT to endBlockHeight + ) + analyticsSource.logEventWithParams(KEY_EVENT, params) + } + + fun getLastSyncMetadata(): SyncMetadata? { + val uuid = prefs.getString(KEY_LAST_UUID, null) ?: return null + val duration = prefs.getLong(KEY_LAST_DURATION, 0L) + val end = prefs.getLong(KEY_LAST_END, 0L) + return SyncMetadata(uuid, duration, end) + } + + data class SyncMetadata( + val uuid: String, + val durationMillis: Long, + val endTimestamp: Long + ) { + class Formatter( + private val dateFormat: SimpleDateFormat = SimpleDateFormat( + "MMMM dd, yyyy h:mm:ss a", + Locale.getDefault() + ) + ) { + fun format(syncMetadata: SyncMetadata): String { + val durationSeconds = syncMetadata.durationMillis / 1000.0 + val date = Date(syncMetadata.endTimestamp * 1000) + val dateString = dateFormat.format(date) + + return "Duration: %.1f seconds\nTimestamp: %s".format(durationSeconds, dateString) + } + } + } + + companion object { + private const val KEY_CURRENT_SYNC_START_TIMESTAMP = "current_sync_start_timestamp" + private const val KEY_ACCUMULATED_DURATION = "accumulated_sync_duration" + private const val KEY_LAST_UUID = "last_sync_uuid" + private const val KEY_LAST_DURATION = "last_sync_duration" + private const val KEY_LAST_END = "last_sync_end_timestamp" + private const val KEY_DURATION_MILLIS = "duration_millis" + private const val KEY_END_TIMESTAMP = "end_timestamp" + private const val KEY_END_BLOCK_HEIGHT = "end_block_height" + private const val KEY_UUID = "uuid" + private const val KEY_EVENT = "user_did_complete_sync" + } +} diff --git a/app/src/main/java/com/brainwallet/data/source/AnalyticsSource.kt b/app/src/main/java/com/brainwallet/data/source/AnalyticsSource.kt new file mode 100644 index 00000000..8449f188 --- /dev/null +++ b/app/src/main/java/com/brainwallet/data/source/AnalyticsSource.kt @@ -0,0 +1,6 @@ +package com.brainwallet.data.source + +interface AnalyticsSource { + fun logEvent(event: String) + fun logEventWithParams(event: String, params: Map) +} diff --git a/app/src/main/java/com/brainwallet/data/source/FirebaseAnalyticsSource.kt b/app/src/main/java/com/brainwallet/data/source/FirebaseAnalyticsSource.kt new file mode 100644 index 00000000..0360d489 --- /dev/null +++ b/app/src/main/java/com/brainwallet/data/source/FirebaseAnalyticsSource.kt @@ -0,0 +1,33 @@ +package com.brainwallet.data.source + +import android.content.Context +import android.os.Bundle +import com.google.firebase.analytics.FirebaseAnalytics +import org.koin.core.annotation.Single + +@Single +class FirebaseAnalyticsSource( + private val context: Context, + private val analytics: FirebaseAnalytics = FirebaseAnalytics.getInstance(context) +) : AnalyticsSource { + + override fun logEvent(event: String) { + analytics.logEvent(event, null) + } + + override fun logEventWithParams(event: String, params: Map) { + val bundle = Bundle().apply { + params.forEach { (key, value) -> + when (value) { + is String -> putString(key, value) + is Int -> putInt(key, value) + is Long -> putLong(key, value) + is Double -> putDouble(key, value) + is Boolean -> putBoolean(key, value) + // add more types if needed + } + } + } + analytics.logEvent(event, bundle) + } +} diff --git a/app/src/main/java/com/brainwallet/data/source/MessagingTopicDataSource.kt b/app/src/main/java/com/brainwallet/data/source/MessagingTopicDataSource.kt new file mode 100644 index 00000000..530d77a8 --- /dev/null +++ b/app/src/main/java/com/brainwallet/data/source/MessagingTopicDataSource.kt @@ -0,0 +1,46 @@ +package com.brainwallet.data.source + +import com.brainwallet.data.model.Language +import org.koin.core.annotation.Single + +@Single +class MessagingTopicDataSource( + private val supportedLanguages: List = Language.entries, + private val defaultLanguage: Language = Language.ENGLISH +) { + fun getTopicsByLanguageCode(languageCode: String): List { + val baseLanguageCode = getBaseLanguageCode(languageCode) + return listOf( + "initial_$baseLanguageCode", + "news_$baseLanguageCode", + "promo_$baseLanguageCode", + "warn_$baseLanguageCode" + ) + } + + /** + * Note: Indonesian language code is normalized from "in" to "id" to maintain + * consistency with backend batch messaging server which uses ISO 639-1 standard. + * Supports both "in" and "id" as input for Indonesian language. + */ + private fun getBaseLanguageCode(languageCode: String): String { + val normalizedLanguageCode = languageCode.split("_", "-").first().lowercase() + + // Handle reverse mapping: if "id" is passed, treat it as Indonesian + val searchCode = when (normalizedLanguageCode) { + "id" -> "in" + else -> normalizedLanguageCode + } + + val targetLanguage = supportedLanguages.find { + it.code.split("-").first().lowercase() == searchCode + } ?: defaultLanguage + + val baseCode = targetLanguage.code.split("-").first().lowercase() + + return when (baseCode) { + "in" -> "id" + else -> baseCode + } + } +} diff --git a/app/src/main/java/com/brainwallet/data/source/PeerManagerSource.kt b/app/src/main/java/com/brainwallet/data/source/PeerManagerSource.kt new file mode 100644 index 00000000..10eaf13b --- /dev/null +++ b/app/src/main/java/com/brainwallet/data/source/PeerManagerSource.kt @@ -0,0 +1,17 @@ +package com.brainwallet.data.source + +import com.brainwallet.wallet.BRPeerManager +import org.koin.core.annotation.Single + +interface BRPeerManagerProxy { + fun getCurrentBlockHeight(): Int + fun getLastBlockTimestamp(): Long +} + +@Single +class PeerManagerSource( + private val proxy: BRPeerManagerProxy = object : BRPeerManagerProxy { + override fun getCurrentBlockHeight(): Int = BRPeerManager.getCurrentBlockHeight() + override fun getLastBlockTimestamp(): Long = BRPeerManager.getInstance().lastBlockTimestamp + } +) : BRPeerManagerProxy by proxy diff --git a/app/src/main/java/com/brainwallet/domain/LanguageSwitcherUseCase.kt b/app/src/main/java/com/brainwallet/domain/LanguageSwitcherUseCase.kt new file mode 100644 index 00000000..e1464759 --- /dev/null +++ b/app/src/main/java/com/brainwallet/domain/LanguageSwitcherUseCase.kt @@ -0,0 +1,16 @@ +package com.brainwallet.domain + +import com.brainwallet.data.model.Language +import com.brainwallet.data.repository.SettingRepository +import org.koin.core.annotation.Single + +@Single +class LanguageSwitcherUseCase( + private val settingRepository: SettingRepository, + private val messagingTopicUseCase: MessagingTopicUseCase +) { + fun switchLanguage(newLanguage: Language) { + messagingTopicUseCase.subscribeByLanguage(newLanguage) + settingRepository.updateCurrentLanguage(newLanguage.code) + } +} diff --git a/app/src/main/java/com/brainwallet/domain/MessagingTopicUseCase.kt b/app/src/main/java/com/brainwallet/domain/MessagingTopicUseCase.kt new file mode 100644 index 00000000..7b1641cb --- /dev/null +++ b/app/src/main/java/com/brainwallet/domain/MessagingTopicUseCase.kt @@ -0,0 +1,36 @@ +package com.brainwallet.domain + +import com.brainwallet.data.model.Language +import com.brainwallet.data.repository.MessagingTopicRepository +import com.google.firebase.messaging.FirebaseMessaging +import org.koin.core.annotation.Single + +@Single +class MessagingTopicUseCase( + private val messagingTopicRepository: MessagingTopicRepository, + private val firebaseMessaging: FirebaseMessaging = FirebaseMessaging.getInstance() +) { + fun initialize() { + val topics = messagingTopicRepository.getCurrentTopics() + subscribeToTopics(topics) + } + + fun subscribeByLanguage(newLanguage: Language) { + val currentTopics = messagingTopicRepository.getCurrentTopics() + unsubscribeFromTopics(currentTopics) + val newTopics = messagingTopicRepository.getTopicsByLanguage(newLanguage) + subscribeToTopics(newTopics) + } + + private fun subscribeToTopics(topics: List) { + topics.forEach { topic -> + firebaseMessaging.subscribeToTopic(topic) + } + } + + private fun unsubscribeFromTopics(topics: List) { + topics.forEach { topic -> + firebaseMessaging.unsubscribeFromTopic(topic) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/brainwallet/navigation/LegacyNavigation.kt b/app/src/main/java/com/brainwallet/navigation/LegacyNavigation.kt index a46e3261..21c82cda 100644 --- a/app/src/main/java/com/brainwallet/navigation/LegacyNavigation.kt +++ b/app/src/main/java/com/brainwallet/navigation/LegacyNavigation.kt @@ -1,26 +1,14 @@ package com.brainwallet.navigation import android.app.Activity -import android.app.ProgressDialog import android.content.Context import android.content.Intent -import android.widget.Toast -import androidx.browser.customtabs.CustomTabsIntent -import androidx.core.net.toUri -import com.brainwallet.BuildConfig import com.brainwallet.R -import com.brainwallet.data.repository.LtcRepository -import com.brainwallet.di.AppModule.getKoinInstance import com.brainwallet.presenter.activities.BreadActivity import com.brainwallet.ui.BrainwalletActivity -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext import timber.log.Timber import com.google.firebase.analytics.FirebaseAnalytics - //provide old navigation using intent activity object LegacyNavigation { @@ -67,52 +55,4 @@ object LegacyNavigation { ) = BrainwalletActivity.createIntent(context, destination).also { context.startActivity(it) } - - @JvmOverloads - @JvmStatic - fun showMoonPayWidget( - context: Context, - params: Map = mapOf(), - isDarkMode: Boolean = true, - ) { - val ltcRepository: LtcRepository = getKoinInstance() - val progressDialog = ProgressDialog(context).apply { - setMessage(context.getString(R.string.loading)) - setCancelable(false) - show() - } - - CoroutineScope(Dispatchers.Main).launch { - try { - val result = withContext(Dispatchers.IO) { - ltcRepository.fetchMoonpaySignedUrl( - params = params.toMutableMap().apply { - put("theme", if (isDarkMode) "dark" else "light") - } - ) - } - - val widgetUri = result.toUri().buildUpon() - .apply { - if (BuildConfig.DEBUG) { - authority("buy-sandbox.moonpay.com")//replace base url from buy.moonpay.com - } - } - .build() - val intent = CustomTabsIntent.Builder() - .setColorScheme(if (isDarkMode) CustomTabsIntent.COLOR_SCHEME_DARK else CustomTabsIntent.COLOR_SCHEME_LIGHT) - .build() - intent.launchUrl(context, widgetUri) - } catch (e: Exception) { - Toast.makeText( - context, - "Failed to load: ${e.message}, please try again later", - Toast.LENGTH_LONG - ).show() - } finally { - progressDialog.dismiss() - } - } - } - -} \ No newline at end of file +} diff --git a/app/src/main/java/com/brainwallet/navigation/MoonPayWidgeLauncher.kt b/app/src/main/java/com/brainwallet/navigation/MoonPayWidgeLauncher.kt new file mode 100644 index 00000000..7677a4de --- /dev/null +++ b/app/src/main/java/com/brainwallet/navigation/MoonPayWidgeLauncher.kt @@ -0,0 +1,103 @@ +package com.brainwallet.navigation + +import android.net.Uri +import android.widget.Toast +import androidx.browser.customtabs.CustomTabsIntent +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.core.net.toUri +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.brainwallet.BuildConfig +import com.brainwallet.data.repository.LtcRepository +import com.brainwallet.data.repository.SettingRepository +import com.brainwallet.ui.composable.LoadingDialog +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import org.koin.android.annotation.KoinViewModel +import org.koin.compose.viewmodel.koinViewModel + +@KoinViewModel +class MoonPayWidgetLauncherViewModel( + private val settingRepository: SettingRepository, + private val ltcRepository: LtcRepository, + private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO +) : ViewModel() { + + private val _isLoading = MutableStateFlow(false) + val isLoading = _isLoading.asStateFlow() + + private val _result = Channel>>() + val result = _result.receiveAsFlow() + + fun launch(params: Map) { + _isLoading.update { true } + viewModelScope.launch(ioDispatcher) { + val isDarkMode = settingRepository.isDarkMode() + _result.send(ltcRepository.runCatching { + val result = ltcRepository.fetchMoonpaySignedUrl( + params = params.toMutableMap().apply { + put("theme", if (isDarkMode) "dark" else "light") + } + ) + isDarkMode to result.toUri().buildUpon() + .apply { + if (BuildConfig.DEBUG) { + authority("buy-sandbox.moonpay.com")//replace base url from buy.moonpay.com + } + } + .build() + }) + _isLoading.update { false } + } + } +} + +@Composable +fun MoonPayWidgetLauncher( + modifier: Modifier = Modifier, + viewModel: MoonPayWidgetLauncherViewModel = koinViewModel(), + onResult: () -> Unit = {} +) { + val context = LocalContext.current + val isLoading by viewModel.isLoading.collectAsState() + + AnimatedVisibility(isLoading, modifier = modifier) { + LoadingDialog() + } + + LaunchedEffect(Unit) { + viewModel.result.collect { result -> + result.fold( + onSuccess = { (isDarkMode, uri) -> + val intent = CustomTabsIntent.Builder() + .setColorScheme( + if (isDarkMode) CustomTabsIntent.COLOR_SCHEME_DARK + else CustomTabsIntent.COLOR_SCHEME_LIGHT + ) + .build() + intent.launchUrl(context, uri) + }, + onFailure = { e -> + Toast.makeText( + context, + "Failed to load: ${e.message}, please try again later", + Toast.LENGTH_LONG + ).show() + } + ) + onResult.invoke() + } + } +} diff --git a/app/src/main/java/com/brainwallet/navigation/UiEffect.kt b/app/src/main/java/com/brainwallet/navigation/UiEffect.kt index 4ac64282..6e28b2e5 100644 --- a/app/src/main/java/com/brainwallet/navigation/UiEffect.kt +++ b/app/src/main/java/com/brainwallet/navigation/UiEffect.kt @@ -19,6 +19,7 @@ sealed class UiEffect { } data class ShowDialog(val name: String): UiEffect() + data object ShowMoonPayDialog: UiEffect() data class ShowMessage( val message: String, diff --git a/app/src/main/java/com/brainwallet/presenter/language/ChangeLanguageBottomSheet.kt b/app/src/main/java/com/brainwallet/presenter/language/ChangeLanguageBottomSheet.kt index 77bb688b..a1ff0249 100644 --- a/app/src/main/java/com/brainwallet/presenter/language/ChangeLanguageBottomSheet.kt +++ b/app/src/main/java/com/brainwallet/presenter/language/ChangeLanguageBottomSheet.kt @@ -10,6 +10,7 @@ import com.brainwallet.R import com.brainwallet.data.model.Language import com.brainwallet.data.repository.SettingRepository import com.brainwallet.databinding.ChangeLanguageBottomSheetBinding +import com.brainwallet.domain.LanguageSwitcherUseCase import com.brainwallet.navigation.LegacyNavigation import com.brainwallet.tools.util.Utils import com.brainwallet.tools.util.getString @@ -23,6 +24,7 @@ class ChangeLanguageBottomSheet : RoundedBottomSheetDialogFragment() { lateinit var binding: ChangeLanguageBottomSheetBinding private val settingRepository by inject() + private val languageSwitcherUseCase by inject() override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -65,7 +67,7 @@ class ChangeLanguageBottomSheet : RoundedBottomSheetDialogFragment() { binding.okButton.setOnClickListener { if (settingRepository.getCurrentLanguage() != adapter.selectedLanguage()) { - settingRepository.updateCurrentLanguage(adapter.selectedLanguage().code) + languageSwitcherUseCase.switchLanguage(adapter.selectedLanguage()) LegacyNavigation.openComposeScreen( context = requireContext(), ) diff --git a/app/src/main/java/com/brainwallet/tools/manager/SyncManager.java b/app/src/main/java/com/brainwallet/tools/manager/SyncManager.java index ace9dcc1..c33fc2c5 100644 --- a/app/src/main/java/com/brainwallet/tools/manager/SyncManager.java +++ b/app/src/main/java/com/brainwallet/tools/manager/SyncManager.java @@ -1,5 +1,6 @@ package com.brainwallet.tools.manager; +import static com.brainwallet.data.source.RemoteConfigSource.KEY_FEATURE_SELECTED_PEERS_ENABLED; import static com.brainwallet.tools.manager.BRSharedPrefs.putSyncMetadata; import android.app.AlarmManager; @@ -9,6 +10,8 @@ import android.os.Build; import android.os.Bundle; +import com.brainwallet.data.repository.SyncAnalyticsRepository; +import com.brainwallet.data.source.RemoteConfigSource; import com.brainwallet.tools.listeners.SyncReceiver; import com.brainwallet.tools.util.BRConstants; import com.brainwallet.tools.util.Utils; @@ -16,6 +19,8 @@ import com.brainwallet.presenter.activities.BreadActivity; import com.brainwallet.wallet.BRPeerManager; +import org.koin.java.KoinJavaComponent; + import java.util.concurrent.TimeUnit; import timber.log.Timber; @@ -34,6 +39,10 @@ public static SyncManager getInstance() { private SyncManager() { } + public static SyncAnalyticsRepository getSyncAnalyticsRepository() { + return KoinJavaComponent.get(SyncAnalyticsRepository.class); + } + public synchronized void startSyncingProgressThread(Context app) { try { if (syncTask != null) { @@ -45,6 +54,7 @@ public synchronized void startSyncingProgressThread(Context app) { syncTask = null; } syncTask = new SyncProgressTask(); + getSyncAnalyticsRepository().startSync(); syncTask.start(); updateStartSyncData(app); } catch (IllegalThreadStateException ex) { @@ -59,10 +69,11 @@ private synchronized void updateStartSyncData(Context app) { private synchronized void markFinishedSyncData(Context app) { Timber.d("timber: || SYNC ELAPSE markFinish threadname:%s", Thread.currentThread().getName()); final double progress = BRPeerManager.syncProgress(BRSharedPrefs.getStartHeight(app)); + getSyncAnalyticsRepository().completeSync(); } public synchronized void stopSyncingProgressThread(Context app) { - + getSyncAnalyticsRepository().stopSync(); if (app == null) { Timber.i("timber: || stopSyncingProgressThread: ctx is null"); return; diff --git a/app/src/main/java/com/brainwallet/tools/qrcode/QRUtils.java b/app/src/main/java/com/brainwallet/tools/qrcode/QRUtils.java index a0e9fd5f..d426ea80 100644 --- a/app/src/main/java/com/brainwallet/tools/qrcode/QRUtils.java +++ b/app/src/main/java/com/brainwallet/tools/qrcode/QRUtils.java @@ -7,6 +7,7 @@ import android.content.Context; import android.content.Intent; import android.graphics.Bitmap; +import android.graphics.Color; import android.graphics.Point; import android.view.Display; import android.view.WindowManager; @@ -66,6 +67,51 @@ public static Bitmap encodeAsBitmap(String content, int dimension) { return bitmap; } + public static Bitmap encodeAsTransparentBitmap(String content, int dimension) { + if (content == null) { + return null; + } + + Map hints = new EnumMap<>(EncodeHintType.class); + String encoding = guessAppropriateEncoding(content); + if (encoding != null) { + hints.put(EncodeHintType.CHARACTER_SET, encoding); + } + hints.put(EncodeHintType.MARGIN, 1); + + BitMatrix result; + try { + result = new MultiFormatWriter().encode( + content, + BarcodeFormat.QR_CODE, + dimension, + dimension, + hints + ); + } catch (IllegalArgumentException | WriterException e) { + Timber.e(e); + return null; + } + + if (result == null) return null; + + int width = result.getWidth(); + int height = result.getHeight(); + int[] pixels = new int[width * height]; + + for (int y = 0; y < height; y++) { + int offset = y * width; + for (int x = 0; x < width; x++) { + // Black pixels stay black, white becomes transparent + pixels[offset + x] = result.get(x, y) ? Color.BLACK : Color.TRANSPARENT; + } + } + + Bitmap bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888); + bitmap.setPixels(pixels, 0, width, 0, 0, width, height); + return bitmap; + } + public static boolean generateQR(Context ctx, String bitcoinURL, ImageView qrcode) { if (qrcode == null || bitcoinURL == null || bitcoinURL.isEmpty()) return false; WindowManager manager = (WindowManager) ctx.getSystemService(Activity.WINDOW_SERVICE); @@ -94,7 +140,7 @@ public static Bitmap generateQR(Context ctx, String litecoinUrl) { int smallerDimension = Math.min(width, height); smallerDimension = (int) (smallerDimension * 0.45f); Bitmap bitmap = null; - bitmap = QRUtils.encodeAsBitmap(litecoinUrl, smallerDimension); + bitmap = QRUtils.encodeAsTransparentBitmap(litecoinUrl, smallerDimension); return bitmap; } diff --git a/app/src/main/java/com/brainwallet/ui/composable/BrainWalletLogo.kt b/app/src/main/java/com/brainwallet/ui/composable/BrainWalletLogo.kt new file mode 100644 index 00000000..b22a053c --- /dev/null +++ b/app/src/main/java/com/brainwallet/ui/composable/BrainWalletLogo.kt @@ -0,0 +1,39 @@ +package com.brainwallet.ui.composable + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.Box +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.tooling.preview.PreviewLightDark +import com.brainwallet.R +import com.brainwallet.data.model.AppSetting +import com.brainwallet.ui.theme.BrainwalletAppTheme +import com.brainwallet.ui.theme.BrainwalletTheme +import com.brainwallet.ui.theme.LocalDarkModeFlag + +@Composable +fun BrainWalletLogo(modifier: Modifier = Modifier) { + val iconLogo = if (LocalDarkModeFlag.current) { + R.drawable.brainwallet_logotype_white + } else { + R.drawable.brainwallet_logotype_color + } + Image( + painterResource(iconLogo), + contentDescription = "brainwallet_logo", + modifier = modifier + ) +} + +@PreviewLightDark +@Composable +private fun BrainWalletLogoPreview() { + BrainwalletAppTheme(appSetting = AppSetting(isDarkMode = isSystemInDarkTheme())) { + Box(modifier = Modifier.background(BrainwalletTheme.colors.background)) { + BrainWalletLogo() + } + } +} diff --git a/app/src/main/java/com/brainwallet/ui/composable/DarkModeToggleButton.kt b/app/src/main/java/com/brainwallet/ui/composable/DarkModeToggleButton.kt index 05b89e53..2a9688d5 100644 --- a/app/src/main/java/com/brainwallet/ui/composable/DarkModeToggleButton.kt +++ b/app/src/main/java/com/brainwallet/ui/composable/DarkModeToggleButton.kt @@ -1,10 +1,12 @@ package com.brainwallet.ui.composable + import androidx.compose.foundation.background import androidx.compose.foundation.border +import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.Icon import androidx.compose.material3.IconToggleButton @@ -14,8 +16,11 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.PreviewLightDark import androidx.compose.ui.unit.dp import com.brainwallet.R +import com.brainwallet.data.model.AppSetting +import com.brainwallet.ui.theme.BrainwalletAppTheme import com.brainwallet.ui.theme.BrainwalletTheme @Composable @@ -23,18 +28,17 @@ fun DarkModeToggleButton( checked: Boolean, onCheckedChange: (Boolean) -> Unit, modifier: Modifier = Modifier, + iconButtonSizeInDp: Int = 32 ) { - val iconButtonSize = 32 - IconToggleButton( checked = checked, onCheckedChange = onCheckedChange, - modifier = modifier, + modifier = modifier.size(iconButtonSizeInDp.dp), ) { Box( modifier = Modifier .fillMaxSize() - .width(iconButtonSize.dp) + .size(iconButtonSizeInDp.dp) .aspectRatio(1f) .clip(CircleShape) .border( @@ -47,12 +51,21 @@ fun DarkModeToggleButton( Icon( modifier = Modifier .align(Alignment.Center) - .width(iconButtonSize.dp) - .aspectRatio(1f), + .size((iconButtonSizeInDp * 0.6).dp), tint = if (checked) BrainwalletTheme.colors.warn else BrainwalletTheme.colors.surface, painter = painterResource(if (checked) R.drawable.ic_light_mode else R.drawable.ic_dark_mode), contentDescription = stringResource(R.string.toggle_dark_mode), ) } } -} \ No newline at end of file +} + +@PreviewLightDark +@Composable +private fun DarkModeToggleButtonPreview() { + BrainwalletAppTheme(AppSetting(isDarkMode = isSystemInDarkTheme())) { + Box(modifier = Modifier.background(BrainwalletTheme.colors.background)) { + DarkModeToggleButton(checked = isSystemInDarkTheme(), onCheckedChange = {}, ) + } + } +} diff --git a/app/src/main/java/com/brainwallet/ui/composable/Foundation.kt b/app/src/main/java/com/brainwallet/ui/composable/Foundation.kt index f72137f5..16f92a0e 100644 --- a/app/src/main/java/com/brainwallet/ui/composable/Foundation.kt +++ b/app/src/main/java/com/brainwallet/ui/composable/Foundation.kt @@ -27,6 +27,7 @@ import com.brainwallet.ui.theme.BrainwalletTheme fun BrainwalletScaffold( modifier: Modifier = Modifier, topBar: @Composable () -> Unit = {}, + bottomBar: @Composable () -> Unit = {}, floatingActionButton: @Composable () -> Unit = {}, content: @Composable (PaddingValues) -> Unit ) { @@ -35,6 +36,7 @@ fun BrainwalletScaffold( containerColor = BrainwalletTheme.colors.surface, contentColor = BrainwalletTheme.colors.content, topBar = topBar, + bottomBar = bottomBar, floatingActionButton = floatingActionButton, content = content ) diff --git a/app/src/main/java/com/brainwallet/ui/composable/PasscodeIndicator.kt b/app/src/main/java/com/brainwallet/ui/composable/PasscodeIndicator.kt index 32aead18..9837d962 100644 --- a/app/src/main/java/com/brainwallet/ui/composable/PasscodeIndicator.kt +++ b/app/src/main/java/com/brainwallet/ui/composable/PasscodeIndicator.kt @@ -3,17 +3,20 @@ package com.brainwallet.ui.composable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Row import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp @Composable fun PasscodeIndicator( - passcode: List + passcode: List, + modifier: Modifier = Modifier, ) { Row( + modifier = modifier, horizontalArrangement = Arrangement.spacedBy(12.dp) ) { passcode.forEach { digit -> PinDotItem(checked = digit > -1) } } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/brainwallet/ui/screens/buylitecoin/BuyLitecoinScreen.kt b/app/src/main/java/com/brainwallet/ui/screens/buylitecoin/BuyLitecoinScreen.kt index ad6b3cf5..e1aa80a0 100644 --- a/app/src/main/java/com/brainwallet/ui/screens/buylitecoin/BuyLitecoinScreen.kt +++ b/app/src/main/java/com/brainwallet/ui/screens/buylitecoin/BuyLitecoinScreen.kt @@ -9,7 +9,6 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack -import androidx.compose.material.icons.filled.Done import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme @@ -26,22 +25,22 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.unit.dp import com.brainwallet.R -import com.brainwallet.navigation.LegacyNavigation +import com.brainwallet.navigation.MoonPayWidgetLauncher +import com.brainwallet.navigation.MoonPayWidgetLauncherViewModel import com.brainwallet.navigation.OnNavigate import com.brainwallet.navigation.UiEffect import com.brainwallet.ui.composable.BrainwalletScaffold import com.brainwallet.ui.composable.BrainwalletTopAppBar import com.brainwallet.ui.composable.LargeButton -import com.brainwallet.ui.composable.LoadingDialog -import com.brainwallet.ui.screens.home.receive.ReceiveDialogEvent import com.brainwallet.ui.theme.BrainwalletTheme -import org.koin.compose.koinInject +import org.koin.compose.viewmodel.koinViewModel //TODO: wip @Composable fun BuyLitecoinScreen( onNavigate: OnNavigate, - viewModel: BuyLitecoinViewModel = koinInject() + viewModel: BuyLitecoinViewModel = koinViewModel(), + moonPayWidgetLauncherViewModel: MoonPayWidgetLauncherViewModel = koinViewModel() ) { val state by viewModel.state.collectAsState() val loadingState by viewModel.loadingState.collectAsState() @@ -148,21 +147,20 @@ fun BuyLitecoinScreen( modifier = Modifier.align(Alignment.BottomCenter), enabled = loadingState.visible.not(), onClick = { - //open bread activity first then open moonpay widget - LegacyNavigation.restartBreadActivity(context) - LegacyNavigation.showMoonPayWidget( - context = context, + moonPayWidgetLauncherViewModel.launch( params = mapOf( "baseCurrencyCode" to appSetting.currency.code, "baseCurrencyAmount" to state.fiatAmount.toString(), "language" to appSetting.languageCode, "walletAddress" to state.address - ) + ), + ) } ) { Text(stringResource(R.string.buy_litecoin_button_moonpay)) } } + MoonPayWidgetLauncher(viewModel = moonPayWidgetLauncherViewModel) } } \ No newline at end of file diff --git a/app/src/main/java/com/brainwallet/ui/screens/home/SettingsEvent.kt b/app/src/main/java/com/brainwallet/ui/screens/home/SettingsEvent.kt index cf6b1d31..36ef087f 100644 --- a/app/src/main/java/com/brainwallet/ui/screens/home/SettingsEvent.kt +++ b/app/src/main/java/com/brainwallet/ui/screens/home/SettingsEvent.kt @@ -3,10 +3,12 @@ package com.brainwallet.ui.screens.home import com.brainwallet.data.model.CurrencyEntity import com.brainwallet.data.model.Language +import com.brainwallet.data.repository.SyncAnalyticsRepository + sealed class SettingsEvent { data class OnLoad( val shareAnalyticsDataEnabled: Boolean = false, - val lastSyncMetadata: String? = null, + val lastSyncMetadata: SyncAnalyticsRepository.SyncMetadata? = null, ) : SettingsEvent() object OnSecurityUpdatePinClick : SettingsEvent() object OnSecuritySeedPhraseClick : SettingsEvent() diff --git a/app/src/main/java/com/brainwallet/ui/screens/home/SettingsState.kt b/app/src/main/java/com/brainwallet/ui/screens/home/SettingsState.kt index d0336bff..b1e82c75 100644 --- a/app/src/main/java/com/brainwallet/ui/screens/home/SettingsState.kt +++ b/app/src/main/java/com/brainwallet/ui/screens/home/SettingsState.kt @@ -7,6 +7,8 @@ import com.brainwallet.data.model.Language import com.brainwallet.data.model.toFeeOptions import com.brainwallet.tools.manager.FeeManager +import com.brainwallet.data.repository.SyncAnalyticsRepository + data class SettingsState( val darkMode: Boolean = true, val selectedLanguage: Language = Language.ENGLISH, @@ -19,7 +21,7 @@ data class SettingsState( val languageSelectorBottomSheetVisible: Boolean = false, val fiatSelectorBottomSheetVisible: Boolean = false, val shareAnalyticsDataEnabled: Boolean = false, - val lastSyncMetadata: String? = null, + val lastSyncMetadata: SyncAnalyticsRepository.SyncMetadata? = null, val currentFeeOptions: List = Fee.Default.toFeeOptions(), val selectedFeeType: String = FeeManager.LUXURY ) \ No newline at end of file diff --git a/app/src/main/java/com/brainwallet/ui/screens/home/SettingsViewModel.kt b/app/src/main/java/com/brainwallet/ui/screens/home/SettingsViewModel.kt index 46f5c426..75f59e71 100644 --- a/app/src/main/java/com/brainwallet/ui/screens/home/SettingsViewModel.kt +++ b/app/src/main/java/com/brainwallet/ui/screens/home/SettingsViewModel.kt @@ -8,6 +8,7 @@ import com.brainwallet.data.model.Language import com.brainwallet.data.model.toFeeOptions import com.brainwallet.data.repository.LtcRepository import com.brainwallet.data.repository.SettingRepository +import com.brainwallet.domain.LanguageSwitcherUseCase import com.brainwallet.tools.manager.FeeManager import com.brainwallet.ui.BrainwalletViewModel import com.brainwallet.util.EventBus @@ -27,6 +28,7 @@ import org.koin.android.annotation.KoinViewModel @KoinViewModel class SettingsViewModel( private val settingRepository: SettingRepository, + private val languageSwitcherUseCase: LanguageSwitcherUseCase, private val ltcRepository: LtcRepository ) : BrainwalletViewModel() { @@ -127,7 +129,7 @@ class SettingsViewModel( ) }.let { viewModelScope.launch { - settingRepository.save(appSetting.value.copy(languageCode = event.language.code)) + languageSwitcherUseCase.switchLanguage(event.language) AppCompatDelegate.setApplicationLocales( LocaleListCompat.forLanguageTags( event.language.code diff --git a/app/src/main/java/com/brainwallet/ui/screens/home/composable/HomeSettingDrawerSheet.kt b/app/src/main/java/com/brainwallet/ui/screens/home/composable/HomeSettingDrawerSheet.kt index d96cda42..fbb01438 100644 --- a/app/src/main/java/com/brainwallet/ui/screens/home/composable/HomeSettingDrawerSheet.kt +++ b/app/src/main/java/com/brainwallet/ui/screens/home/composable/HomeSettingDrawerSheet.kt @@ -28,6 +28,7 @@ import androidx.lifecycle.coroutineScope import androidx.lifecycle.findViewTreeLifecycleOwner import com.brainwallet.R import com.brainwallet.data.model.AppSetting +import com.brainwallet.data.repository.SyncAnalyticsRepository import com.brainwallet.tools.manager.BRSharedPrefs import com.brainwallet.tools.util.BRConstants import com.brainwallet.ui.screens.home.SettingsEvent @@ -48,12 +49,14 @@ import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach import org.koin.compose.koinInject +import org.koin.compose.viewmodel.koinViewModel import org.koin.java.KoinJavaComponent.inject @Composable fun HomeSettingDrawerSheet( modifier: Modifier = Modifier, - viewModel: SettingsViewModel = koinInject() + syncAnalyticsRepository: SyncAnalyticsRepository = koinInject(), + viewModel: SettingsViewModel = koinViewModel() ) { val state by viewModel.state.collectAsState() val context = LocalContext.current @@ -61,7 +64,7 @@ fun HomeSettingDrawerSheet( LaunchedEffect(Unit) { viewModel.onEvent(SettingsEvent.OnLoad( shareAnalyticsDataEnabled = BRSharedPrefs.getShareData(context), //currently just load analytics share data here - lastSyncMetadata = BRSharedPrefs.getSyncMetadata(context), //currently just load sync metadata here + lastSyncMetadata = syncAnalyticsRepository.getLastSyncMetadata(), //currently just load sync metadata here )) } @@ -192,10 +195,15 @@ fun HomeSettingDrawerSheet( } item { + val description = state.lastSyncMetadata?.let { + SyncAnalyticsRepository.SyncMetadata.Formatter() + .format(it) + } ?: "No sync metadata" + SettingRowItem( modifier = Modifier.height(100.dp), title = stringResource(R.string.settings_title_sync_metadata), - description = state.lastSyncMetadata ?: "No sync metadata" + description = description ) } diff --git a/app/src/main/java/com/brainwallet/ui/screens/home/receive/ReceiveDialog.kt b/app/src/main/java/com/brainwallet/ui/screens/home/receive/ReceiveDialog.kt index e96a1f2a..64f7446e 100644 --- a/app/src/main/java/com/brainwallet/ui/screens/home/receive/ReceiveDialog.kt +++ b/app/src/main/java/com/brainwallet/ui/screens/home/receive/ReceiveDialog.kt @@ -2,15 +2,12 @@ package com.brainwallet.ui.screens.home.receive -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup import android.widget.Toast import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.border +import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -49,25 +46,28 @@ import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.graphics.asImageBitmap -import androidx.compose.ui.platform.ComposeView import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.PreviewLightDark import androidx.compose.ui.unit.dp -import androidx.fragment.app.DialogFragment -import androidx.fragment.app.FragmentManager import com.brainwallet.R +import com.brainwallet.data.model.AppSetting import com.brainwallet.data.model.getFormattedText import com.brainwallet.data.model.isCustom -import com.brainwallet.navigation.LegacyNavigation +import com.brainwallet.navigation.MoonPayWidgetLauncher +import com.brainwallet.navigation.MoonPayWidgetLauncherViewModel import com.brainwallet.navigation.UiEffect +import com.brainwallet.ui.LoadingState import com.brainwallet.ui.composable.MoonpayBuyButton import com.brainwallet.ui.composable.VerticalWheelPicker import com.brainwallet.ui.composable.WheelPickerFocusVertical +import com.brainwallet.ui.composable.WheelPickerState import com.brainwallet.ui.composable.rememberWheelPickerState import com.brainwallet.ui.theme.BrainwalletAppTheme import com.brainwallet.ui.theme.BrainwalletTheme @@ -76,14 +76,15 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.filter -import org.koin.android.ext.android.inject -import org.koin.compose.koinInject +import org.koin.compose.viewmodel.koinViewModel import timber.log.Timber @Composable fun ReceiveDialog( onDismissRequest: () -> Unit, - viewModel: ReceiveDialogViewModel = koinInject() + modifier: Modifier = Modifier, + viewModel: ReceiveDialogViewModel = koinViewModel(), + moonPayWidgetLauncherViewModel: MoonPayWidgetLauncherViewModel = koinViewModel() ) { val state by viewModel.state.collectAsState() val loadingState by viewModel.loadingState.collectAsState() @@ -121,52 +122,94 @@ fun ReceiveDialog( viewModel.onEvent(ReceiveDialogEvent.OnFiatCurrencyChange(state.fiatCurrencies[it])) } } + ReceiveDialog( + state = state, + loadingState = loadingState, + modifier = modifier, + appSetting = appSetting, + wheelPickerFiatCurrencyState = wheelPickerFiatCurrencyState, + onDismissRequest = onDismissRequest, + onMoonPayLaunch = moonPayWidgetLauncherViewModel::launch, + onEvent = viewModel::onEvent + ) + MoonPayWidgetLauncher( + viewModel = moonPayWidgetLauncherViewModel, + onResult = onDismissRequest + ) +} - Column( - modifier = Modifier - .verticalScroll(rememberScrollState()) - .fillMaxSize(), - horizontalAlignment = Alignment.CenterHorizontally - ) { - CenterAlignedTopAppBar( - colors = TopAppBarDefaults.centerAlignedTopAppBarColors( - containerColor = BrainwalletTheme.colors.content //invert surface +@Composable +private fun ReceiveDialog( + state: ReceiveDialogState, + modifier: Modifier = Modifier, + appSetting: AppSetting = AppSetting(), + loadingState: LoadingState = LoadingState(), + wheelPickerFiatCurrencyState: WheelPickerState = rememberWheelPickerState(0), + onDismissRequest: () -> Unit = {}, + onMoonPayLaunch: (Map) -> Unit = {}, + onEvent: (ReceiveDialogEvent) -> Unit = {} +) { + val context = LocalContext.current + Box( + modifier = modifier + .background( + BrainwalletTheme.colors.content, + shape = BrainwalletTheme.shapes.large + ) + .border( + width = 1.dp, + color = BrainwalletTheme.colors.surface, + shape = BrainwalletTheme.shapes.large ), - expandedHeight = 56.dp, - title = { - Text( - text = stringResource(R.string.bottom_nav_item_buy_receive_title).uppercase(), - style = BrainwalletTheme.typography.titleSmall - ) - }, - navigationIcon = { - if (state.moonpayWidgetVisible()) { - IconButton(onClick = { - viewModel.onEvent(ReceiveDialogEvent.OnSignedUrlClear) - }) { + ) { + Column( + modifier = Modifier + .padding(12.dp) + .verticalScroll(rememberScrollState()) + .fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + CenterAlignedTopAppBar( + colors = TopAppBarDefaults.centerAlignedTopAppBarColors( + containerColor = BrainwalletTheme.colors.content //invert surface + ), + expandedHeight = 56.dp, + title = { + Text( + text = stringResource(R.string.bottom_nav_item_buy_receive_title).uppercase(), + style = BrainwalletTheme.typography.titleSmall.copy( + color = BrainwalletTheme.colors.surface + ) + ) + }, + navigationIcon = { + if (state.moonpayWidgetVisible()) { + IconButton(onClick = { + onEvent(ReceiveDialogEvent.OnSignedUrlClear) + }) { + Icon( + Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = stringResource(R.string.back) + ) + } + } + }, + actions = { + IconButton( + modifier = Modifier.testTag("buttonClose"), + onClick = onDismissRequest + ) { Icon( - Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = stringResource(R.string.back) + Icons.Default.Close, + contentDescription = stringResource(R.string.AccessibilityLabels_close), + tint = BrainwalletTheme.colors.surface ) } } - }, - actions = { - IconButton( - modifier = Modifier.testTag("buttonClose"), - onClick = onDismissRequest - ) { - Icon( - Icons.Default.Close, - contentDescription = stringResource(R.string.AccessibilityLabels_close), - tint = BrainwalletTheme.colors.surface - ) - } - } - ) + ) - //moonpay widget - //todo: revisit this later + //moonpay widget + //todo: revisit this later // AnimatedVisibility(visible = state.moonpayWidgetVisible()) { // state.moonpayBuySignedUrl?.let { signedUrl -> // MoonpayBuyWidget( @@ -177,279 +220,227 @@ fun ReceiveDialog( // } - //buy / receive + //buy / receive // AnimatedVisibility(visible = state.moonpayWidgetVisible().not()) { - Column { - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically - ) { - state.qrBitmap?.asImageBitmap()?.let { imageBitmap -> - Image( + Column { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + state.qrBitmap?.asImageBitmap()?.let { imageBitmap -> + Image( + modifier = Modifier + .weight(1f), + bitmap = imageBitmap, + contentDescription = "address", + colorFilter = ColorFilter.tint(BrainwalletTheme.colors.surface) + ) + } ?: Box( modifier = Modifier - .weight(1f), - bitmap = imageBitmap, - contentDescription = "address" + .weight(1f) + .height(180.dp) + .background(Color.Gray) ) - } ?: Box( - modifier = Modifier - .weight(1f) - .height(180.dp) - .background(Color.Gray) - ) - Column( - modifier = Modifier - .weight(1f), - verticalArrangement = Arrangement.spacedBy(8.dp) - ) { - Text( - text = state.address, - style = BrainwalletTheme.typography.bodyLarge.copy( - fontWeight = FontWeight.Bold, - color = BrainwalletTheme.colors.surface - ), - maxLines = 4, - overflow = TextOverflow.Ellipsis - ) - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(4.dp) + Column( + modifier = Modifier + .weight(1f), + verticalArrangement = Arrangement.spacedBy(8.dp) ) { Text( - text = stringResource(R.string.new_address).uppercase(), - style = BrainwalletTheme.typography.bodySmall.copy( - color = BrainwalletTheme.colors.surface, + text = state.address, + style = BrainwalletTheme.typography.bodyLarge.copy( + fontWeight = FontWeight.Bold, + color = BrainwalletTheme.colors.surface ), - modifier = Modifier.weight(1f) + maxLines = 4, + overflow = TextOverflow.Ellipsis ) - OutlinedIconButton( - modifier = Modifier.size(32.dp), - onClick = { - viewModel.onEvent(ReceiveDialogEvent.OnCopyClick(context)) - Toast.makeText( - context, - R.string.Receive_copied, - Toast.LENGTH_SHORT - ) - .show() - }, - colors = IconButtonDefaults.outlinedIconButtonColors( - containerColor = BrainwalletTheme.colors.content.copy(alpha = 0.5f) - ), + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp) ) { - Icon( - painter = androidx.compose.ui.res.painterResource(id = R.drawable.ic_copy), - contentDescription = stringResource(R.string.URLHandling_copy), - tint = BrainwalletTheme.colors.surface + Text( + text = stringResource(R.string.new_address).uppercase(), + style = BrainwalletTheme.typography.bodySmall.copy( + color = BrainwalletTheme.colors.surface, + ), + modifier = Modifier.weight(1f) ) + OutlinedIconButton( + modifier = Modifier.size(32.dp), + onClick = { + onEvent(ReceiveDialogEvent.OnCopyClick(context)) + Toast.makeText( + context, + R.string.Receive_copied, + Toast.LENGTH_SHORT + ) + .show() + }, + colors = IconButtonDefaults.outlinedIconButtonColors( + containerColor = BrainwalletTheme.colors.content.copy(alpha = 0.5f) + ), + ) { + Icon( + painter = androidx.compose.ui.res.painterResource(id = R.drawable.ic_copy), + contentDescription = stringResource(R.string.URLHandling_copy), + tint = BrainwalletTheme.colors.surface + ) + } } } } - } - HorizontalDivider() + HorizontalDivider() - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.Center - ) { - VerticalWheelPicker( - modifier = Modifier.weight(1f), - focus = { - WheelPickerFocusVertical( - dividerColor = BrainwalletTheme.colors.surface.copy( - alpha = 0.5f - ) - ) - }, - unfocusedCount = 1, - count = state.fiatCurrencies.size, - state = wheelPickerFiatCurrencyState, - ) { index -> - Text( - text = state.fiatCurrencies[index].code, - fontWeight = FontWeight.Bold, - color = BrainwalletTheme.colors.surface - ) - } - - Column( - modifier = Modifier.weight(1f), - horizontalAlignment = Alignment.CenterHorizontally + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center ) { - Text( - text = state.getLtcAmountFormatted(loadingState.visible), - style = BrainwalletTheme.typography.titleLarge.copy( + VerticalWheelPicker( + modifier = Modifier.weight(1f), + focus = { + WheelPickerFocusVertical( + dividerColor = BrainwalletTheme.colors.surface.copy( + alpha = 0.5f + ) + ) + }, + unfocusedCount = 1, + count = state.fiatCurrencies.size, + state = wheelPickerFiatCurrencyState, + ) { index -> + Text( + text = state.fiatCurrencies[index].code, fontWeight = FontWeight.Bold, color = BrainwalletTheme.colors.surface ) - ) - Text( - text = state.getRatesUpdatedAtFormatted(), - style = BrainwalletTheme.typography.bodySmall.copy( - color = BrainwalletTheme.colors.surface + } + + Column( + modifier = Modifier.weight(1f), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = state.getLtcAmountFormatted(loadingState.visible), + style = BrainwalletTheme.typography.titleLarge.copy( + fontWeight = FontWeight.Bold, + color = BrainwalletTheme.colors.surface + ) ) - ) + Text( + text = state.getRatesUpdatedAtFormatted(), + style = BrainwalletTheme.typography.bodySmall.copy( + color = BrainwalletTheme.colors.surface + ) + ) + } } - } - LazyRow( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - itemsIndexed(items = state.getQuickFiatAmountOptions()) { index, quickFiatAmountOption -> - AssistChip( - enabled = loadingState.visible.not(), - onClick = { - viewModel.onEvent( - ReceiveDialogEvent.OnFiatAmountOptionIndexChange( - index, - quickFiatAmountOption + LazyRow( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + itemsIndexed(items = state.getQuickFiatAmountOptions()) { index, quickFiatAmountOption -> + AssistChip( + enabled = loadingState.visible.not(), + onClick = { + onEvent( + ReceiveDialogEvent.OnFiatAmountOptionIndexChange( + index, + quickFiatAmountOption + ) ) - ) - }, - label = { + }, + label = { + Text( + text = if (quickFiatAmountOption.isCustom()) + stringResource(R.string.custom) + else quickFiatAmountOption.getFormattedText(), + style = BrainwalletTheme.typography.bodyMedium.copy( + color = BrainwalletTheme.colors.surface + ) + ) + }, + leadingIcon = { + if (index == state.selectedQuickFiatAmountOptionIndex) { + Icon(Icons.Default.Check, contentDescription = null) + } + } + ) + } + } + + + AnimatedVisibility(visible = state.isQuickFiatAmountOptionCustom()) { + OutlinedTextField( + modifier = Modifier.fillMaxWidth(), + prefix = { Text( - text = if (quickFiatAmountOption.isCustom()) - stringResource(R.string.custom) - else quickFiatAmountOption.getFormattedText() + text = state.selectedFiatCurrency.symbol, + style = BrainwalletTheme.typography.bodyMedium.copy(color = BrainwalletTheme.colors.surface) ) }, - leadingIcon = { - if (index == state.selectedQuickFiatAmountOptionIndex) { - Icon(Icons.Default.Check, contentDescription = null) + trailingIcon = { + IconButton(onClick = { + onEvent(ReceiveDialogEvent.OnFiatAmountChange(state.fiatAmount)) + }) { + Icon(Icons.Default.Done, contentDescription = null) + } + }, + textStyle = BrainwalletTheme.typography.bodyMedium.copy(color = BrainwalletTheme.colors.surface), + value = "${if (state.fiatAmount < 1) "" else state.fiatAmount}", + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + onValueChange = { input -> + val amount = input.toFloatOrNull() ?: 0f + onEvent(ReceiveDialogEvent.OnFiatAmountChange(amount, false)) + }, + shape = BrainwalletTheme.shapes.large, + isError = state.errorFiatAmountStringId != null, + supportingText = { + state.errorFiatAmountStringId?.let { + Text(stringResource(it, state.fiatAmount)) } } ) } - } - - AnimatedVisibility(visible = state.isQuickFiatAmountOptionCustom()) { - OutlinedTextField( + MoonpayBuyButton( modifier = Modifier.fillMaxWidth(), - prefix = { - Text( - text = state.selectedFiatCurrency.symbol, - style = BrainwalletTheme.typography.bodyMedium.copy(color = BrainwalletTheme.colors.surface) + enabled = loadingState.visible.not(), + onClick = { + + //todo: revisit this later + //viewModel.onEvent(ReceiveDialogEvent.OnMoonpayButtonClick) + onMoonPayLaunch( + mapOf( + "baseCurrencyCode" to state.selectedFiatCurrency.code, + "baseCurrencyAmount" to state.fiatAmount.toString(), + "language" to appSetting.languageCode, + "walletAddress" to state.address, + ) ) }, - trailingIcon = { - IconButton(onClick = { - viewModel.onEvent(ReceiveDialogEvent.OnFiatAmountChange(state.fiatAmount)) - }) { - Icon(Icons.Default.Done, contentDescription = null) - } - }, - textStyle = BrainwalletTheme.typography.bodyMedium.copy(color = BrainwalletTheme.colors.surface), - value = "${if (state.fiatAmount < 1) "" else state.fiatAmount}", - singleLine = true, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), - onValueChange = { input -> - val amount = input.toFloatOrNull() ?: 0f - viewModel.onEvent(ReceiveDialogEvent.OnFiatAmountChange(amount, false)) - }, - shape = BrainwalletTheme.shapes.large, - isError = state.errorFiatAmountStringId != null, - supportingText = { - state.errorFiatAmountStringId?.let { - Text(stringResource(it, state.fiatAmount)) - } - } ) - } - - MoonpayBuyButton( - modifier = Modifier.fillMaxWidth(), - enabled = loadingState.visible.not(), - onClick = { - - //todo: revisit this later - //viewModel.onEvent(ReceiveDialogEvent.OnMoonpayButtonClick) - - LegacyNavigation.showMoonPayWidget( - context = context, - params = mapOf( - "baseCurrencyCode" to state.selectedFiatCurrency.code, - "baseCurrencyAmount" to state.fiatAmount.toString(), - "language" to appSetting.languageCode, - "walletAddress" to state.address, - ), - isDarkMode = appSetting.isDarkMode - ) - onDismissRequest.invoke() - }, - ) // } - } - - - } -} - -/** - * describe [ReceiveDialogFragment] for backward compat, - * since we are still using [com.brainwallet.presenter.activities.BreadActivity] - */ -class ReceiveDialogFragment : DialogFragment() { - - private val viewModel: ReceiveDialogViewModel by inject() - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View { - return ComposeView(requireContext()).apply { - setContent { - val appSetting by viewModel.appSetting.collectAsState() - /** - * we need this theme inside this fragment, - * because we are still using fragment to display ReceiveDialog composable - * pls check BreadActivity.handleNavigationItemSelected - */ - BrainwalletAppTheme(appSetting = appSetting) { - Box( - modifier = Modifier - .padding(12.dp) - .background( - BrainwalletTheme.colors.content, - shape = BrainwalletTheme.shapes.large - ) - .border( - width = 1.dp, - color = BrainwalletTheme.colors.surface, - shape = BrainwalletTheme.shapes.large - ) - .padding(12.dp), - ) { - ReceiveDialog( - viewModel = viewModel, - onDismissRequest = { dismiss() } - ) - } - } } } } +} - override fun onStart() { - super.onStart() - dialog?.window?.setLayout( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.WRAP_CONTENT +@PreviewLightDark +@Composable +private fun ReceiveDialogPreview() { + val appSetting = AppSetting(isDarkMode = isSystemInDarkTheme()) + BrainwalletAppTheme(appSetting) { + ReceiveDialog( + modifier = Modifier.padding(12.dp), + state = ReceiveDialogState(), + appSetting = appSetting ) - dialog?.window?.setBackgroundDrawableResource(android.R.color.transparent) - isCancelable = false } - - companion object { - @JvmStatic - fun show(manager: FragmentManager) { - ReceiveDialogFragment().show(manager, "ReceiveDialogFragment") - } - } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/brainwallet/ui/screens/home/receive/ReceiveDialogFragment.kt b/app/src/main/java/com/brainwallet/ui/screens/home/receive/ReceiveDialogFragment.kt new file mode 100644 index 00000000..6cfb2a19 --- /dev/null +++ b/app/src/main/java/com/brainwallet/ui/screens/home/receive/ReceiveDialogFragment.kt @@ -0,0 +1,66 @@ +package com.brainwallet.ui.screens.home.receive + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.unit.dp +import androidx.fragment.app.DialogFragment +import androidx.fragment.app.FragmentManager +import androidx.fragment.app.viewModels +import com.brainwallet.ui.theme.BrainwalletAppTheme +import org.koin.compose.viewmodel.koinViewModel + +/** + * describe [ReceiveDialogFragment] for backward compat, + * since we are still using [com.brainwallet.presenter.activities.BreadActivity] + */ +class ReceiveDialogFragment : DialogFragment() { + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + return ComposeView(requireContext()).apply { + setContent { + val viewModel = koinViewModel() + val appSetting by viewModel.appSetting.collectAsState() + /** + * we need this theme inside this fragment, + * because we are still using fragment to display ReceiveDialog composable + * pls check BreadActivity.handleNavigationItemSelected + */ + BrainwalletAppTheme(appSetting = appSetting) { + ReceiveDialog( + modifier = Modifier.padding(12.dp), + viewModel = viewModel, + onDismissRequest = { dismiss() } + ) + } + } + } + } + + override fun onStart() { + super.onStart() + dialog?.window?.setLayout( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT + ) + dialog?.window?.setBackgroundDrawableResource(android.R.color.transparent) + isCancelable = false + } + + companion object { + @JvmStatic + fun show(manager: FragmentManager) { + ReceiveDialogFragment().show(manager, "ReceiveDialogFragment") + } + } +} diff --git a/app/src/main/java/com/brainwallet/ui/screens/ready/ReadyScreen.kt b/app/src/main/java/com/brainwallet/ui/screens/ready/ReadyScreen.kt index 1201b0a7..4be9e0dc 100644 --- a/app/src/main/java/com/brainwallet/ui/screens/ready/ReadyScreen.kt +++ b/app/src/main/java/com/brainwallet/ui/screens/ready/ReadyScreen.kt @@ -55,10 +55,10 @@ fun ReadyScreen( } /// Layout values - val leadingCopyPadding = 16 + val leadingCopyPadding = 18 val horizontalVerticalSpacing = 8 - val spacerHeight = 90 + val spacerHeight = 40 val activeRowHeight = 70 @@ -89,7 +89,7 @@ fun ReadyScreen( horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(horizontalVerticalSpacing.dp), ) { - Spacer(modifier = Modifier.weight(1f)) + Spacer(modifier = Modifier.weight(0.5f)) Row { Icon( Icons.AutoMirrored.Filled.ArrowForward, @@ -101,28 +101,27 @@ fun ReadyScreen( scaleY = 2f ) ) - Spacer(modifier = Modifier.weight(1f)) + Spacer(modifier = Modifier.weight(0.3f)) } + Spacer(modifier = Modifier.height(16.dp)) + Text( text = stringResource(R.string.ready_setup), style = MaterialTheme.typography.displaySmall.copy(textAlign = TextAlign.Left), - modifier = Modifier.fillMaxWidth() + modifier = Modifier.fillMaxWidth(), + fontWeight = FontWeight.Bold ) Text( text = buildAnnotatedString { append(stringResource(R.string.ready_setup_details_1)) - append(" ") - withStyle( - style = SpanStyle( - fontWeight = FontWeight.Bold, - ) - ) { - append(stringResource(R.string.ready_setup_details_2)) + append("\n\n") + append(stringResource(R.string.ready_setup_details_2)) + append("\n\n") + withStyle(style = SpanStyle(fontWeight = FontWeight.Bold)) { + append(stringResource(R.string.ready_setup_details_3)) } - append(". ") - append(stringResource(R.string.ready_setup_details_3)) }, style = MaterialTheme.typography.bodyLarge, modifier = Modifier.fillMaxWidth() diff --git a/app/src/main/java/com/brainwallet/ui/screens/unlock/UnLockEvent.kt b/app/src/main/java/com/brainwallet/ui/screens/unlock/UnLockEvent.kt index c83406f9..bd090257 100644 --- a/app/src/main/java/com/brainwallet/ui/screens/unlock/UnLockEvent.kt +++ b/app/src/main/java/com/brainwallet/ui/screens/unlock/UnLockEvent.kt @@ -1,16 +1,15 @@ package com.brainwallet.ui.screens.unlock -import android.content.Context - sealed class UnLockEvent { data class OnLoad( - val context: Context, val isUpdatePin: Boolean = false, ) : UnLockEvent() data class OnPinDigitChange( val digit: Int, val isValidPin: (String) -> Boolean ) : UnLockEvent() + object OnToggleDarkMode : UnLockEvent() + object OnQrClicked : UnLockEvent() object OnDeletePinDigit : UnLockEvent() } diff --git a/app/src/main/java/com/brainwallet/ui/screens/unlock/UnLockScreen.kt b/app/src/main/java/com/brainwallet/ui/screens/unlock/UnLockScreen.kt index 0ea4da7e..204716e8 100644 --- a/app/src/main/java/com/brainwallet/ui/screens/unlock/UnLockScreen.kt +++ b/app/src/main/java/com/brainwallet/ui/screens/unlock/UnLockScreen.kt @@ -2,168 +2,105 @@ package com.brainwallet.ui.screens.unlock - -import android.widget.Toast -import androidx.compose.foundation.Image +import androidx.activity.ComponentActivity +import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.PreviewLightDark import androidx.compose.ui.unit.dp +import androidx.fragment.app.FragmentActivity import com.brainwallet.R +import com.brainwallet.data.model.AppSetting import com.brainwallet.navigation.OnNavigate import com.brainwallet.navigation.UiEffect -import com.brainwallet.tools.manager.AnalyticsManager -import com.brainwallet.tools.security.AuthManager -import com.brainwallet.tools.util.BRConstants import com.brainwallet.ui.composable.BrainwalletScaffold -import com.brainwallet.ui.composable.BrainwalletTopAppBar -import com.brainwallet.ui.composable.PasscodeIndicator -import com.brainwallet.ui.composable.PasscodeKeypad -import com.brainwallet.ui.composable.PasscodeKeypadEvent -import com.brainwallet.ui.theme.BrainwalletTheme -import org.koin.compose.koinInject +import com.brainwallet.ui.screens.home.receive.ReceiveDialogFragment +import com.brainwallet.ui.screens.unlock.components.UnLockScreenBody +import com.brainwallet.ui.screens.unlock.components.UnLockScreenFooter +import com.brainwallet.ui.screens.unlock.components.UnLockScreenHeader +import com.brainwallet.ui.theme.BrainwalletAppTheme +import org.koin.compose.viewmodel.koinViewModel @Composable fun UnLockScreen( onNavigate: OnNavigate, + modifier: Modifier = Modifier, isUpdatePin: Boolean = false, - viewModel: UnLockViewModel = koinInject() + viewModel: UnLockViewModel = koinViewModel() ) { - val state by viewModel.state.collectAsState() - val context = LocalContext.current - + val fragmentManager = (LocalContext.current as? FragmentActivity)?.supportFragmentManager + val uiState by viewModel.state.collectAsState() LaunchedEffect(Unit) { - viewModel.onEvent(UnLockEvent.OnLoad(context, isUpdatePin)) + viewModel.onEvent(UnLockEvent.OnLoad(isUpdatePin)) viewModel.uiEffect.collect { effect -> when (effect) { is UiEffect.Navigate -> onNavigate.invoke(effect) + is UiEffect.ShowMoonPayDialog -> fragmentManager?.let { + ReceiveDialogFragment.show(it) + } else -> Unit } } } + UnLockScreen(uiState = uiState, modifier = modifier, onEvent = viewModel::onEvent) +} - LaunchedEffect(state.passcode.all { it > -1 }) { +@Composable +private fun UnLockScreen( + uiState: UnLockState, + modifier: Modifier = Modifier, + onEvent: (UnLockEvent) -> Unit = {}, +) { + LaunchedEffect(uiState.passcode.all { it > -1 }) { // } - - /// Layout values - val columnPadding = 18 val horizontalVerticalSpacing = 8 - BrainwalletScaffold( + modifier = modifier, topBar = { - BrainwalletTopAppBar( - navigationIcon = { - /// No-op Retained spacing for remote banner - }) - } - ) { paddingValues -> - Column( - modifier = Modifier - .padding(paddingValues) - .padding(columnPadding.dp) - .fillMaxSize() - .verticalScroll(rememberScrollState()), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(horizontalVerticalSpacing.dp), - ) { - - Image( - modifier = Modifier - .fillMaxWidth(0.80f) - .padding(horizontalVerticalSpacing.dp), - painter = painterResource(R.drawable.brainwallet_logotype_white), - contentDescription = stringResource(R.string.logo), - colorFilter = ColorFilter.tint( - BrainwalletTheme.colors.content, + UnLockScreenHeader( + modifier = Modifier.padding(16.dp), + formattedLtcPrice = stringResource( + R.string.Login_ltcPrice, + uiState.formattedCurrency ), ) - Spacer(modifier = Modifier.weight(1f)) - - if (state.isUpdatePin) { - Text(stringResource(R.string.UpdatePin_enterCurrent)) - } else { - Text(stringResource(R.string.Login_ltcPrice, state.formattedCurrency)) - Text(stringResource(R.string.Login_currentLtcPrice, state.iso)) - } - - // TODO - // https://developer.android.com/develop/ui/compose/animation/customize - // Box( - // modifier = Modifier - // .fillMaxWidth() - // .height(100.dp) - // .background(Color.White) - // ) { - // //todo - // Text("todo") - // } - - Spacer(modifier = Modifier.weight(1f)) - - PasscodeIndicator(passcode = state.passcode) - - Spacer(Modifier.height(16.dp)) - - PasscodeKeypad { passcodeKeypadEvent -> - when (passcodeKeypadEvent) { - PasscodeKeypadEvent.OnDelete -> viewModel.onEvent(UnLockEvent.OnDeletePinDigit) - is PasscodeKeypadEvent.OnPressed -> viewModel.onEvent( - UnLockEvent.OnPinDigitChange( - digit = passcodeKeypadEvent.digit, - isValidPin = { pin -> - - //provide old logic here, its like on the BrainwalletActivity.onUnlock - return@OnPinDigitChange AuthManager.getInstance() - .checkAuth(pin, context).also { isValid -> - if (isValid) { - AuthManager.getInstance().authSuccess(context) - AnalyticsManager.logCustomEvent(BRConstants._20200217_DUWB) - AnalyticsManager.logCustomEvent(BRConstants._20200217_DUWB) - - } else { - AuthManager.getInstance().authFail(context) - //for now just toast - Toast.makeText( - context, - R.string.incorrect_passcode, - Toast.LENGTH_SHORT - ).show() - } - } - } - ) - ) - } - } - - Spacer(modifier = Modifier.weight(1f)) - - //app version - Text( - text = BRConstants.APP_VERSION_NAME_CODE, - style = MaterialTheme.typography.bodyMedium.copy(textAlign = TextAlign.Center) + }, + bottomBar = { + UnLockScreenFooter( + uiState.formattedVersion, + modifier = Modifier.padding(bottom = 26.dp), + onEvent = onEvent ) } + ) { paddingValues -> + UnLockScreenBody( + passcode = uiState.passcode, + isUpdatePin = uiState.isUpdatePin, + verticalArrangement = Arrangement.spacedBy(horizontalVerticalSpacing.dp), + modifier = Modifier.padding(paddingValues) + .padding(top = 16.dp), + onEvent = onEvent + ) + } +} + +@PreviewLightDark +@Composable +private fun UnLockScreenPreview() { + BrainwalletAppTheme(appSetting = AppSetting(isDarkMode = isSystemInDarkTheme())) { + UnLockScreen(uiState = UnLockState( + isUpdatePin = true, + formattedCurrency = "$90.00", + formattedVersion = "v4.0.0 (202501201)" + )) } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/brainwallet/ui/screens/unlock/UnLockState.kt b/app/src/main/java/com/brainwallet/ui/screens/unlock/UnLockState.kt index ff12dab1..7483e3ab 100644 --- a/app/src/main/java/com/brainwallet/ui/screens/unlock/UnLockState.kt +++ b/app/src/main/java/com/brainwallet/ui/screens/unlock/UnLockState.kt @@ -6,6 +6,7 @@ data class UnLockState( val iso: String = "USD", val formattedCurrency: String = "", val isUpdatePin: Boolean = false, + val formattedVersion: String = "" ) fun UnLockState.isPasscodeFilled(): Boolean = passcode.all { it > -1 } diff --git a/app/src/main/java/com/brainwallet/ui/screens/unlock/UnLockViewModel.kt b/app/src/main/java/com/brainwallet/ui/screens/unlock/UnLockViewModel.kt index 30e3274a..0405c4ba 100644 --- a/app/src/main/java/com/brainwallet/ui/screens/unlock/UnLockViewModel.kt +++ b/app/src/main/java/com/brainwallet/ui/screens/unlock/UnLockViewModel.kt @@ -1,17 +1,18 @@ package com.brainwallet.ui.screens.unlock import androidx.lifecycle.viewModelScope +import com.brainwallet.data.repository.SettingRepository import com.brainwallet.navigation.Route import com.brainwallet.navigation.UiEffect -import com.brainwallet.tools.manager.BRSharedPrefs -import com.brainwallet.tools.sqlite.CurrencyDataSource import com.brainwallet.tools.util.BRConstants -import com.brainwallet.tools.util.BRCurrency import com.brainwallet.ui.BrainwalletViewModel +import com.brainwallet.util.CurrencyDataGetter import com.brainwallet.util.EventBus +import com.brainwallet.util.VersionCodeProvider import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.updateAndGet import kotlinx.coroutines.launch @@ -20,9 +21,14 @@ import timber.log.Timber import java.math.BigDecimal @KoinViewModel -class UnLockViewModel : BrainwalletViewModel() { +class UnLockViewModel( + versionCodeProvider: VersionCodeProvider, + private val settingRepository: SettingRepository, + private val currencyDataGetter: CurrencyDataGetter +) : BrainwalletViewModel() { - private val _state = MutableStateFlow(UnLockState()) + private val _state = + MutableStateFlow(UnLockState(formattedVersion = versionCodeProvider.getFormatted())) val state: StateFlow = _state.asStateFlow() override fun onEvent(event: UnLockEvent) { @@ -73,17 +79,16 @@ class UnLockViewModel : BrainwalletViewModel() { } is UnLockEvent.OnLoad -> { - val iso = BRSharedPrefs.getIsoSymbol(event.context) + val iso = currencyDataGetter.getIsoSymbol() var formattedCurrency: String? = null - val currency = CurrencyDataSource.getInstance(event.context).getCurrencyByIso(iso) + val currency = currencyDataGetter.getCurrencyByIso(iso) if (currency != null) { val roundedPriceAmount: BigDecimal = BigDecimal(currency.rate.toDouble()).multiply(BigDecimal(100)) .divide(BigDecimal(100), 2, BRConstants.ROUNDING_MODE) formattedCurrency = - BRCurrency.getFormattedCurrencyString( - event.context, + currencyDataGetter.getFormattedCurrencyString( iso, roundedPriceAmount ) @@ -102,6 +107,16 @@ class UnLockViewModel : BrainwalletViewModel() { } } } + + UnLockEvent.OnToggleDarkMode -> viewModelScope.launch { + settingRepository.settings.firstOrNull()?.let { + settingRepository.save( + it.copy(isDarkMode = it.isDarkMode.not()) + ) + } + } + + UnLockEvent.OnQrClicked -> sendUiEffect(UiEffect.ShowMoonPayDialog) } } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/brainwallet/ui/screens/unlock/components/UnLockScreenBody.kt b/app/src/main/java/com/brainwallet/ui/screens/unlock/components/UnLockScreenBody.kt new file mode 100644 index 00000000..cdf2f09c --- /dev/null +++ b/app/src/main/java/com/brainwallet/ui/screens/unlock/components/UnLockScreenBody.kt @@ -0,0 +1,111 @@ +package com.brainwallet.ui.screens.unlock.components + +import android.widget.Toast +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.background +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.PreviewLightDark +import androidx.compose.ui.unit.dp +import com.brainwallet.R +import com.brainwallet.data.model.AppSetting +import com.brainwallet.tools.manager.AnalyticsManager +import com.brainwallet.tools.security.AuthManager +import com.brainwallet.tools.util.BRConstants +import com.brainwallet.ui.composable.PasscodeIndicator +import com.brainwallet.ui.composable.PasscodeKeypad +import com.brainwallet.ui.composable.PasscodeKeypadEvent +import com.brainwallet.ui.screens.unlock.UnLockEvent +import com.brainwallet.ui.theme.BrainwalletAppTheme +import com.brainwallet.ui.theme.BrainwalletTheme +import com.google.common.collect.ImmutableList + +@Composable +fun UnLockScreenBody( + passcode: List, + isUpdatePin: Boolean, + modifier: Modifier = Modifier, + verticalArrangement: Arrangement.Vertical = Arrangement.Center, + onEvent: (UnLockEvent) -> Unit = {} +) { + val context = LocalContext.current + Column( + modifier = modifier + .padding(18.dp) + .fillMaxSize() + .verticalScroll(rememberScrollState()), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = verticalArrangement, + ) { + AnimatedVisibility(isUpdatePin) { + Text( + stringResource(R.string.UpdatePin_enterCurrent), + modifier = Modifier + ) + } + Spacer(modifier = Modifier.weight(1f)) + PasscodeIndicator(passcode = passcode, modifier = Modifier) + Spacer(modifier = Modifier.weight(1f)) + PasscodeKeypad { passcodeKeypadEvent -> + when (passcodeKeypadEvent) { + PasscodeKeypadEvent.OnDelete -> onEvent(UnLockEvent.OnDeletePinDigit) + is PasscodeKeypadEvent.OnPressed -> onEvent( + UnLockEvent.OnPinDigitChange( + digit = passcodeKeypadEvent.digit, + isValidPin = { pin -> + + //provide old logic here, its like on the BrainwalletActivity.onUnlock + return@OnPinDigitChange AuthManager.getInstance() + .checkAuth(pin, context).also { isValid -> + if (isValid) { + AuthManager.getInstance().authSuccess(context) + AnalyticsManager.logCustomEvent(BRConstants._20200217_DUWB) + AnalyticsManager.logCustomEvent(BRConstants._20200217_DUWB) + + } else { + AuthManager.getInstance().authFail(context) + //for now just toast + Toast.makeText( + context, + R.string.incorrect_passcode, + Toast.LENGTH_SHORT + ).show() + } + } + } + ) + ) + } + } + Spacer(modifier = Modifier.weight(1f)) + } +} + +@PreviewLightDark +@Composable +private fun UnLockScreenBodyPreview() { + BrainwalletAppTheme(AppSetting(isDarkMode = isSystemInDarkTheme())) { + Box( + modifier = Modifier + .background(BrainwalletTheme.colors.background) + .fillMaxWidth() + ) { + UnLockScreenBody(isUpdatePin = true, passcode = ImmutableList.of(1, 2)) + } + } +} diff --git a/app/src/main/java/com/brainwallet/ui/screens/unlock/components/UnLockScreenFooter.kt b/app/src/main/java/com/brainwallet/ui/screens/unlock/components/UnLockScreenFooter.kt new file mode 100644 index 00000000..733f2068 --- /dev/null +++ b/app/src/main/java/com/brainwallet/ui/screens/unlock/components/UnLockScreenFooter.kt @@ -0,0 +1,89 @@ +package com.brainwallet.ui.screens.unlock.components + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.PreviewLightDark +import androidx.compose.ui.unit.dp +import com.brainwallet.R +import com.brainwallet.data.model.AppSetting +import com.brainwallet.ui.composable.DarkModeToggleButton +import com.brainwallet.ui.screens.unlock.UnLockEvent +import com.brainwallet.ui.theme.BrainwalletAppTheme +import com.brainwallet.ui.theme.BrainwalletTheme +import com.brainwallet.ui.theme.LocalDarkModeFlag + +@Composable +fun UnLockScreenFooter( + version: String, + modifier: Modifier = Modifier, + onEvent: (UnLockEvent) -> Unit = {} +) { + Column( + modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Row(modifier = Modifier + .padding(bottom = 24.dp) + .padding(horizontal = 85.dp)) { + DarkModeToggleButton( + checked = LocalDarkModeFlag.current, + onCheckedChange = { + onEvent(UnLockEvent.OnToggleDarkMode) + }, + iconButtonSizeInDp = 43 + ) + Spacer(modifier = Modifier.weight(1f)) + Image( + painterResource(R.drawable.ic_clickable_qr), + contentDescription = "clickable_qr", + modifier = Modifier.size(39.dp).clickable { + onEvent(UnLockEvent.OnQrClicked) + }, + colorFilter = ColorFilter.tint( + BrainwalletTheme.colors.border + ) + ) + } + Text( + version, + modifier = Modifier.fillMaxWidth(), + style = MaterialTheme.typography + .bodyMedium + .copy( + textAlign = TextAlign.Center, + color = BrainwalletTheme.colors.border + ) + ) + } +} + +@PreviewLightDark +@Composable +private fun UnLockScreenFooterPreview() { + BrainwalletAppTheme(AppSetting(isDarkMode = isSystemInDarkTheme())) { + Box( + modifier = Modifier + .background(BrainwalletTheme.colors.background) + .fillMaxWidth() + ) { + UnLockScreenFooter(version = "v4.0.0 (202501201)") + } + } +} diff --git a/app/src/main/java/com/brainwallet/ui/screens/unlock/components/UnLockScreenHeader.kt b/app/src/main/java/com/brainwallet/ui/screens/unlock/components/UnLockScreenHeader.kt new file mode 100644 index 00000000..81aa128e --- /dev/null +++ b/app/src/main/java/com/brainwallet/ui/screens/unlock/components/UnLockScreenHeader.kt @@ -0,0 +1,51 @@ +package com.brainwallet.ui.screens.unlock.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.PreviewLightDark +import androidx.compose.ui.unit.dp +import com.brainwallet.data.model.AppSetting +import com.brainwallet.ui.composable.BrainWalletLogo +import com.brainwallet.ui.theme.BrainwalletAppTheme +import com.brainwallet.ui.theme.BrainwalletTheme + +@Composable +fun UnLockScreenHeader( + formattedLtcPrice: String, + modifier: Modifier = Modifier, +) { + Column( + modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + modifier = Modifier + .padding(bottom = 52.dp) + .fillMaxWidth(), + text = formattedLtcPrice, + textAlign = TextAlign.End, + color = BrainwalletTheme.colors.border + ) + BrainWalletLogo(modifier = Modifier.width(268.dp)) + } +} + +@PreviewLightDark +@Composable +private fun UnLockScreenHeaderPreview() { + BrainwalletAppTheme(appSetting = AppSetting(isDarkMode = isSystemInDarkTheme())) { + Box(modifier = Modifier.background(BrainwalletTheme.colors.background)) { + UnLockScreenHeader(formattedLtcPrice = "100") + } + } +} diff --git a/app/src/main/java/com/brainwallet/ui/screens/welcome/WelcomeScreen.kt b/app/src/main/java/com/brainwallet/ui/screens/welcome/WelcomeScreen.kt index d5a1fd2b..3c9773fb 100644 --- a/app/src/main/java/com/brainwallet/ui/screens/welcome/WelcomeScreen.kt +++ b/app/src/main/java/com/brainwallet/ui/screens/welcome/WelcomeScreen.kt @@ -205,7 +205,7 @@ fun WelcomeScreen( ) { Text( - text = stringResource(R.string.ready), + text = stringResource(R.string.MenuViewController_createButton), fontSize = buttonFontSize.sp, fontWeight = FontWeight.SemiBold, ) diff --git a/app/src/main/java/com/brainwallet/ui/theme/Theme.kt b/app/src/main/java/com/brainwallet/ui/theme/Theme.kt index 36afabbe..9b421ce5 100644 --- a/app/src/main/java/com/brainwallet/ui/theme/Theme.kt +++ b/app/src/main/java/com/brainwallet/ui/theme/Theme.kt @@ -59,6 +59,10 @@ val LocalLanguageCode = staticCompositionLocalOf { Language.ENGLISH.code //default } +val LocalDarkModeFlag = staticCompositionLocalOf { + false +} + @Composable fun BrainwalletAppTheme( appSetting: AppSetting = AppSetting(), @@ -68,7 +72,8 @@ fun BrainwalletAppTheme( CompositionLocalProvider( LocalBrainwalletColors provides colors, - LocalLanguageCode provides appSetting.languageCode + LocalLanguageCode provides appSetting.languageCode, + LocalDarkModeFlag provides appSetting.isDarkMode, ) { MaterialTheme( typography = AppTypography, diff --git a/app/src/main/java/com/brainwallet/util/CurrencyDataGetter.kt b/app/src/main/java/com/brainwallet/util/CurrencyDataGetter.kt new file mode 100644 index 00000000..4c6ebf21 --- /dev/null +++ b/app/src/main/java/com/brainwallet/util/CurrencyDataGetter.kt @@ -0,0 +1,41 @@ +package com.brainwallet.util + +import android.content.Context +import com.brainwallet.data.model.CurrencyEntity +import com.brainwallet.tools.manager.BRSharedPrefs +import com.brainwallet.tools.sqlite.CurrencyDataSource +import com.brainwallet.tools.util.BRCurrency +import org.koin.core.annotation.Single +import java.math.BigDecimal + +@Single +class CurrencyDataGetter( + private val context: Context, + private val currencyDataSource: CurrencyDataSource = CurrencyDataSource.getInstance(context), + private val isoSymbolGetter: (Context) -> String = { BRSharedPrefs.getIsoSymbol(context) }, + private val formattedCurrencyStringGetter: (Context, String, BigDecimal) -> String? = + { context, isoCurrencyCode, amount -> + BRCurrency.getFormattedCurrencyString( + context, + isoCurrencyCode, + amount + ) + } +) { + fun getIsoSymbol(): String { + return isoSymbolGetter.invoke(context) + } + + fun getCurrencyByIso( + iso: String + ): CurrencyEntity? { + return currencyDataSource.getCurrencyByIso(iso) + } + + fun getFormattedCurrencyString( + isoCurrencyCode: String, + amount: BigDecimal + ): String? { + return formattedCurrencyStringGetter.invoke(context, isoCurrencyCode, amount) + } +} diff --git a/app/src/main/java/com/brainwallet/util/TimeProvider.kt b/app/src/main/java/com/brainwallet/util/TimeProvider.kt new file mode 100644 index 00000000..0fdebf7d --- /dev/null +++ b/app/src/main/java/com/brainwallet/util/TimeProvider.kt @@ -0,0 +1,12 @@ +package com.brainwallet.util + +import org.koin.core.annotation.Single + +@Single +class TimeProvider( + private val nowGetter: () -> Long = { System.currentTimeMillis() } +) { + fun now(): Long { + return nowGetter() + } +} diff --git a/app/src/main/java/com/brainwallet/util/VersionCodeProvider.kt b/app/src/main/java/com/brainwallet/util/VersionCodeProvider.kt new file mode 100644 index 00000000..264ccccf --- /dev/null +++ b/app/src/main/java/com/brainwallet/util/VersionCodeProvider.kt @@ -0,0 +1,22 @@ +package com.brainwallet.util + +import com.brainwallet.BuildConfig +import org.koin.core.annotation.Factory + +@Factory +class VersionCodeProvider( + private val versionCodeGetter: () -> Int = { BuildConfig.VERSION_CODE }, + private val versionNameGetter: () -> String = { BuildConfig.VERSION_NAME } +) { + fun getVersionCode(): Int { + return versionCodeGetter() + } + + fun getVersionName(): String { + return versionNameGetter() + } + + fun getFormatted(): String { + return "${getVersionName()} (${getVersionCode()})" + } +} diff --git a/app/src/main/res/drawable-hdpi/brainwallet_logotype_color.webp b/app/src/main/res/drawable-hdpi/brainwallet_logotype_color.webp new file mode 100644 index 00000000..dfc5fa0c Binary files /dev/null and b/app/src/main/res/drawable-hdpi/brainwallet_logotype_color.webp differ diff --git a/app/src/main/res/drawable-ldpi/brainwallet_logotype_color.webp b/app/src/main/res/drawable-ldpi/brainwallet_logotype_color.webp new file mode 100644 index 00000000..d20accb5 Binary files /dev/null and b/app/src/main/res/drawable-ldpi/brainwallet_logotype_color.webp differ diff --git a/app/src/main/res/drawable-mdpi/brainwallet_logotype_color.webp b/app/src/main/res/drawable-mdpi/brainwallet_logotype_color.webp new file mode 100644 index 00000000..47c272bc Binary files /dev/null and b/app/src/main/res/drawable-mdpi/brainwallet_logotype_color.webp differ diff --git a/app/src/main/res/drawable-xhdpi/brainwallet_logotype_color.webp b/app/src/main/res/drawable-xhdpi/brainwallet_logotype_color.webp new file mode 100644 index 00000000..acc02566 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/brainwallet_logotype_color.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/brainwallet_logotype_color.webp b/app/src/main/res/drawable-xxhdpi/brainwallet_logotype_color.webp new file mode 100644 index 00000000..868fac8e Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/brainwallet_logotype_color.webp differ diff --git a/app/src/main/res/drawable-xxxhdpi/brainwallet_logotype_color.webp b/app/src/main/res/drawable-xxxhdpi/brainwallet_logotype_color.webp new file mode 100644 index 00000000..9b64a7af Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/brainwallet_logotype_color.webp differ diff --git a/app/src/main/res/drawable/ic_clickable_qr.xml b/app/src/main/res/drawable/ic_clickable_qr.xml new file mode 100644 index 00000000..a8b28c3c --- /dev/null +++ b/app/src/main/res/drawable/ic_clickable_qr.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index 4c1e559c..82a13420 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -386,10 +386,10 @@ لقد أنقذته، أليس كذلك؟ أثبت ذلك!\nاسحب الكلمات بالترتيب الصحيح. اللعبة والمزامنة - مستعد؟ - افعل هذا من أجلك. من فضلك افعلها - وحيد - أحضر قلمًا وورقة و5 دقائق + ؟هل أنت مستعد للبدء؟ + هذا لك وحدك. + قم بإعداد رمز مرور التطبيق، ثم افتح مدير كلمات المرور الخاص بك أو استخدم قلمًا لتسجيله وعبارة البذرة الجديدة. + نحن لا نعرفه وليس لدينا نسخة منه! رمز مرور تطبيق الإعداد اختر رمز مرور لفتح Brainwallet الخاص بك ليس رمز قفل الهاتف! @@ -406,7 +406,7 @@ يتأكد أنت لم تنسى أليس كذلك؟ أدخله مرة أخرى. أو، العودة للبدء من جديد. مستعد - يعيد + استعادة باستخدام عبارة البذور رمز المرور غير صحيح، يرجى المحاولة مرة أخرى! سهم لأسفل لليسار شعار diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 31708d56..cd1386df 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -748,10 +748,10 @@ Du hast es gespeichert, oder? Beweisen Sie es!\nZiehen Sie die Wörter in die richtige Reihenfolge. Spiel & Synchronisierung - Bereit? - Tun Sie dies für Sie. Bitte tun Sie es - allein - Schnappen Sie sich einen Stift, Papier und 5 Minuten + Bereit zum Start? + Das ist nur für dich. + Richten Sie den App-Passcode ein, öffnen Sie Ihren Passwort-Manager oder nehmen Sie einen Stift, um ihn und Ihre neue Seed-Phrase aufzuzeichnen. + Wir kennen es nicht und haben auch keine Kopie! App-Passcode einrichten Wählen Sie einen Passcode, um Ihr Brainwallet zu entsperren Kein Telefonsperrcode! @@ -768,7 +768,7 @@ Bestätigen Du hast es nicht vergessen, oder? Geben Sie es erneut ein. Oder gehen Sie zurück, um von vorne zu beginnen. Bereit - Wiederherstellen + Mit Seed-Phrase wiederherstellen Falscher Passcode, bitte versuchen Sie es erneut! Pfeil nach unten links Logo diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 3eaded9b..e4363ff4 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -747,10 +747,10 @@ Lo guardaste, ¿verdad? ¡Pruébalo!\nArrastra las palabras en el orden correcto. Juego y sincronización - ¿Listo? - Haz esto por ti. por favor hazlo - solo - Coge bolígrafo, papel y 5 minutos. + ¿Listo para empezar? + Esto es solo para ti. + Configura el código de acceso de la aplicación, abre tu administrador de contraseñas o toma un bolígrafo para registrarlo junto con tu nueva frase inicial. + ¡No lo sabemos ni tenemos copia! Configurar el código de acceso de la aplicación Elija una contraseña para desbloquear su Brainwallet ¡No es un código de bloqueo del teléfono! @@ -767,7 +767,7 @@ Confirmar No lo olvidaste ¿verdad? Ingrese nuevamente. O regrese para empezar de nuevo. Listo - Restaurar + Restaurar con frase semilla Código de acceso incorrecto, ¡inténtelo de nuevo! flecha abajo-izquierda logo diff --git a/app/src/main/res/values-fa/strings.xml b/app/src/main/res/values-fa/strings.xml index 8f82d491..b70bfedc 100644 --- a/app/src/main/res/values-fa/strings.xml +++ b/app/src/main/res/values-fa/strings.xml @@ -777,10 +777,10 @@ اثباتش کن! کلمات را به ترتیب صحیح بکشید. بازی و همگام‌سازی - آماده‌اید؟ - برای شما انجام می‌دهم. لطفاً انجامش بده - تنها - یک قلم، کاغذ و ۵ دقیقه بردارید + آماده برای شروع؟ + این فقط برای توئه. + کد عبور برنامه را تنظیم کنید، مدیر رمز عبور خود را باز کنید یا یک خودکار بردارید و آن و عبارت بازیابی جدید خود را یادداشت کنید. + ما نه آن را می‌شناسیم و نه نمونه‌ای از آن داریم! تنظیم رمز عبور برنامه یک رمز عبور را برای باز کردن قفل کیف پول مغزی خود انتخاب کنید قفل تلفن نیست! @@ -803,7 +803,7 @@ تایید فراموش نکردی، مگه نه؟ وارد کنید دوباره. یا، به عقب برگردید تا از ابتدا شروع کنید. آماده - بازیابی + بازیابی با عبارت Seed رمز عبور نادرست، لطفاً دوباره تلاش کنید! فلش به سمت پایین و چپ لوگو diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 2d0343e3..92b63a25 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -748,10 +748,10 @@ Vous l\'avez sauvegardé, n\'est-ce pas ? Prouvez-le !\nFaites glisser les mots dans le bon ordre. Jeu et synchronisation - Prêt? - Faites cela pour vous. S\'il te plaît, fais-le - seul - Prenez un stylo, du papier et 5 minutes + Prêt à commencer? + Ceci est pour toi seul. + Configurez le mot de passe de l\'application, ouvrez votre gestionnaire de mots de passe ou prenez un stylo pour l\'enregistrer ainsi que votre nouvelle phrase de départ. + Nous ne le savons pas et n’en avons pas de copie ! Configurer le mot de passe de l\'application Choisissez un mot de passe pour déverrouiller votre Brainwallet Pas un code de verrouillage du téléphone ! @@ -768,7 +768,7 @@ Confirmer Vous n\'avez pas oublié, n\'est-ce pas ? Entrez-le à nouveau. Ou revenez en arrière pour recommencer. Prêt - Restaurer + Restaurer avec une phrase de départ Code d\'accès incorrect, veuillez réessayer ! flèche bas-gauche logo diff --git a/app/src/main/res/values-hi/strings.xml b/app/src/main/res/values-hi/strings.xml index cb889a37..73407e9f 100644 --- a/app/src/main/res/values-hi/strings.xml +++ b/app/src/main/res/values-hi/strings.xml @@ -386,10 +386,10 @@ आपने इसे सहेज लिया, है ना? इसे साबित करें!\nशब्दों को सही क्रम में खींचें। गेम और सिंक - तैयार? - यह आपके लिए करें. कृपया इसे करते हैं - अकेला - एक कलम, कागज़ और 5 मिनट पकड़ें + शुरू करने के लिए तैयार हैं? + यह केवल आपके लिए है. + ऐप पासकोड सेट करें, अपना पासवर्ड मैनेजर खोलें या इसे और अपने नए बीज वाक्यांश को रिकॉर्ड करने के लिए एक पेन लें। + हमें इसकी जानकारी नहीं है और न ही हमारे पास इसकी कोई प्रति है! ऐप पासकोड सेटअप करें अपने ब्रेनवॉलेट को अनलॉक करने के लिए एक पासकोड चुनें फ़ोन लॉक कोड नहीं! @@ -406,7 +406,7 @@ पुष्टि करना तुम भूले तो नहीं? इसे दोबारा दर्ज करें. या, दोबारा शुरू करने के लिए वापस जाएं। तैयार - पुनर्स्थापित करना + बीज वाक्यांश के साथ पुनर्स्थापित करें ग़लत पासकोड, कृपया पुनः प्रयास करें! नीचे-बाएँ-तीर प्रतीक चिन्ह diff --git a/app/src/main/res/values-in/strings.xml b/app/src/main/res/values-in/strings.xml index 73f60f4d..e89fb5f4 100644 --- a/app/src/main/res/values-in/strings.xml +++ b/app/src/main/res/values-in/strings.xml @@ -750,10 +750,10 @@ Anda menyimpannya, kan? Buktikan!\nSeret kata-kata dalam urutan yang benar. Permainan & Sinkronisasi - Siap? + Siap untuk memulai? Lakukan ini untukmu. Tolong lakukan itu - sendiri - Ambil pena, kertas, dan 5 menit + Atur kode sandi aplikasi, buka pengelola kata sandi Anda atau ambil pena untuk mencatatnya & frasa benih baru Anda. + Kami tidak mengetahuinya dan kami tidak memiliki salinannya! Siapkan kode sandi aplikasi Pilih kode sandi untuk membuka kunci Brainwallet Anda Bukan kode kunci telepon! @@ -770,7 +770,7 @@ Mengonfirmasi Kamu tidak lupa kan? Masukkan lagi. Atau, kembali untuk memulai kembali. Siap - Memulihkan + Pulihkan dengan Seed Phrase Kode Sandi salah, silakan coba lagi! panah kiri bawah logo diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 69116bf1..3a15ed68 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -748,10 +748,10 @@ L\'hai salvato, vero? Dimostralo!\nTrascina le parole nell\'ordine corretto. Gioco e sincronizzazione - Pronto? - Fallo per te. Per favore, fallo - solo - Prendi carta, penna e 5 minuti + Pronto per iniziare? + Questo è solo per te. + Imposta il codice di accesso dell\'app, apri il tuo gestore di password o prendi una penna per registrarlo insieme alla tua nuova frase seed. + Non lo sappiamo e non ne abbiamo una copia! Imposta il codice di accesso dell\'app Scegli un passcode per sbloccare il tuo Brainwallet Non è un codice di blocco del telefono! @@ -768,7 +768,7 @@ Confermare Non te ne sei dimenticato, vero? Inseriscilo di nuovo. Oppure torna indietro per ricominciare da capo. Pronto - Ripristinare + Ripristina con la frase Seed Codice di accesso errato, riprova! freccia giù a sinistra logo diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index 5550e8f1..097163d0 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -748,10 +748,10 @@ 保存したんですよね? 証明してください!\n正しい順序で単語をドラッグしてください。 ゲームと同期 - 準備ができて? - あなたのためにこれをしてください。やってください - 一人で - ペンと紙を用意して 5 分 + 시작할 준비가 되셨나요? + これはあなただけのためのものです。 + アプリのパスコードを設定し、パスワード マネージャーを開くか、ペンを用意してパスコードと新しいシード フレーズを記録します。 + 私たちはそれを知りませんし、コピーも持っていません! アプリのパスコードを設定する パスコードを選択してブレインウォレットのロックを解除します 携帯電話のロックコードではありません。 @@ -768,7 +768,7 @@ 確認する 忘れてませんでしたよね?もう一度入力してください。または、戻って最初からやり直します。 準備ができて - 復元する + シードフレーズで復元 パスコードが間違っています。もう一度お試しください。 左下矢印 ロゴ diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index 693a92ae..aafc1c57 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -749,9 +749,9 @@ 증명해보세요!\n올바른 순서로 단어를 드래그하세요. 게임 및 동기화 준비가 된? - 당신을 위해 이것을하십시오. 꼭 해주세요 - 홀로 - 펜과 종이를 들고 5분 + 이건 당신만을 위한 거예요. + 앱 비밀번호를 설정하고 비밀번호 관리자를 열거나 펜을 가져와서 비밀번호와 새로운 시드 문구를 기록하세요. + 우리는 그것을 모르고 사본도 없습니다! 앱 비밀번호 설정 Brainwallet을 잠금 해제하려면 비밀번호를 선택하세요 전화 잠금 코드가 아닙니다! @@ -768,7 +768,7 @@ 확인하다 잊지 않았지? 다시 입력하세요. 아니면 돌아가서 다시 시작하세요. 준비가 된 - 복원하다 + 시드 프레이즈로 복원 잘못된 비밀번호입니다. 다시 시도해 주세요! 아래쪽 왼쪽 화살표 심벌 마크 diff --git a/app/src/main/res/values-pa/strings.xml b/app/src/main/res/values-pa/strings.xml index 4e8aa15c..98248e3e 100644 --- a/app/src/main/res/values-pa/strings.xml +++ b/app/src/main/res/values-pa/strings.xml @@ -769,10 +769,10 @@ تُسیں اِس نُوں بچا لیا، ٹھیک؟ ثابت کرو! \nالفاظ نوں صحیح ترتیب وچ کھچو۔ گیم اینڈ سنک - تیار؟ - اے اپنے لئی کرو۔ براہ کرم یہ کریں۔ - اکیلا - قلم، کاغذ تے 5 منٹ لَے لو۔ + شروع کرنے کے لیے تیار ہیں؟ + یہ صرف آپ کے لیے ہے۔ + ایپ پاس کوڈ سیٹ اپ کریں، اپنا پاس ورڈ مینیجر کھولیں یا اسے اور اپنے نئے سیڈ فقرے کو ریکارڈ کرنے کے لیے قلم پکڑیں۔ + ہم اسے نہیں جانتے اور نہ ہی ہمارے پاس کوئی کاپی ہے! ایپ پاسکوڈ سیٹ کریں اپنے برین والٹ کو ان لاک کرنے کے لیے ایک پاس کوڈ منتخب کریں۔ فون لاک کوڈ نہیں! @@ -796,7 +796,7 @@ تُہانوں 5,444,517,950,000,000,000,000,000,000,000,000,000,000,000,000,000 کوششاں لگن گیاں۔ تصدیق کریں۔ تُسیں بھلّے تاں نئیں؟ اسے دوبارہ داخل کریں۔ یا، واپس جا کر دوبارہ شروع کرو۔ - تیار + بیج کے جملے کے ساتھ بحال کریں۔ بحال کرو غلط پاسکوڈ، براہ کرم دوبارہ کوشش کریں! نیچے-کھبے-تیر diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index d1646132..fe6e4805 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -766,10 +766,10 @@ Uratowałeś go, prawda? Udowodnij to! \nPrzeciągnij słowa w odpowiedniej kolejności. Gra i synchronizacja - Gotowy? - Zrób to dla siebie. Proszę, zrób to - sam - Weź długopis, papier i 5 minut + Gotowy zacząć? + Detta är bara för dig. + Ställ in appens lösenord, öppna lösenordshanteraren eller ta en penna för att anteckna det och din nya fröfras. + Vi vet det inte och har inte heller någon kopia! Konfiguracja kodu aplikacji Wybierz hasło, aby odblokować Brainwallet Nie kod blokady telefonu! @@ -792,7 +792,7 @@ Potwierdzenie Nie zapomniałeś, prawda? Wprowadź go ponownie. Albo wrócić i zacząć od nowa. Gotowy - Przywracanie + Przywróć za pomocą frazy początkowej Nieprawidłowe hasło, spróbuj ponownie! strzałka w dół-lewo logo diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml index 1e68ec84..dcbb7c14 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -747,10 +747,10 @@ Você salvou, certo? Prove!\nArraste as palavras na ordem correta. Jogo e sincronização - Preparar? - Faça isso por você. Por favor faça isso - sozinho - Pegue uma caneta, papel e 5 minutos + Pronto para começar? + Isto é somente para você. + Configure a senha do aplicativo, abra seu gerenciador de senhas ou pegue uma caneta para registrá-la junto com sua nova frase-semente. + Não sabemos e nem temos uma cópia! Configurar senha do aplicativo Escolha uma senha para desbloquear seu Brainwallet Não é um código de bloqueio do telefone! @@ -767,7 +767,7 @@ Confirmar Você não esqueceu, não é? Digite novamente. Ou volte para começar de novo. Preparar - Restaurar + Restaurar com frase semente Senha incorreta, tente novamente! seta para baixo à esquerda logotipo diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 183a5c30..ad65ef57 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -748,10 +748,10 @@ Ты сохранил это, да? Докажите это!\nПеретащите слова в правильном порядке. Игра и синхронизация - Готовый? - Сделайте это для себя. Пожалуйста, сделай это - один - Возьмите ручку, бумагу и 5 минут. + Готовы начать? + Это только для тебя. + Установите код доступа к приложению, откройте менеджер паролей или возьмите ручку, чтобы записать его и новую начальную фразу. + Мы этого не знаем и у нас нет копии! Установить пароль приложения Выберите пароль, чтобы разблокировать свой Brainwallet Это не код блокировки телефона! @@ -768,7 +768,7 @@ Подтверждать Ты ведь не забыл? Введите его еще раз. Или вернитесь, чтобы начать все сначала. Готовый - Восстановить + Восстановить с помощью Seed-фразы Неправильный пароль, попробуйте еще раз! стрелка вниз-влево логотип diff --git a/app/src/main/res/values-sv/strings.xml b/app/src/main/res/values-sv/strings.xml index 1e7f0342..6e155cf2 100644 --- a/app/src/main/res/values-sv/strings.xml +++ b/app/src/main/res/values-sv/strings.xml @@ -386,10 +386,10 @@ Du sparade det, eller hur? Bevisa det!\nDra orden i rätt ordning. Spel och synkronisering - Redo? - Gör det här åt dig. Snälla gör det - ensam - Ta en penna, papper och 5 minuter + Redo att börja? + Detta är bara för dig. + Ställ in appens lösenord, öppna lösenordshanteraren eller ta en penna för att anteckna det och din nya fröfras. + Vi vet det inte och vi har ingen kopia! Konfigurera appens lösenord Välj ett lösenord för att låsa upp din Brainwallet Inte en telefonlåskod! @@ -406,7 +406,7 @@ Bekräfta Du glömde väl inte? Skriv in det igen. Eller gå tillbaka för att börja om. Redo - Återställa + Återställ med Seed Phrase Felaktigt lösenord, försök igen! ned-vänster-pil logotyp diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index cd3a9eaf..71f767b1 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -770,10 +770,10 @@ Onu kurtardın, değil mi? Kanıtlayın!\nKelimeleri doğru sıraya göre sürükleyin. Oyun ve Senkronizasyon - Hazır? - Bunu kendin için yap. Lütfen yap - yalnız - Bir kalem, kağıt alın ve 5 dakika + Başlamaya hazır mısınız? + Bu sadece sana özel. + Uygulama şifrenizi ayarlayın, şifre yöneticinizi açın veya bir kalem alıp şifrenizi ve yeni başlangıç ​​cümlenizi kaydedin. + Biz bunu bilmiyoruz, kopyası da bizde yok! Uygulama şifresini ayarla Beyin Cüzdanınızın kilidini açmak için bir şifre seçin Telefon kilit kodu değil! @@ -790,7 +790,7 @@ Onaylamak Unutmadın değil mi? Tekrar girin. Veya baştan başlamak için geri dönün. Hazır - Eski haline getirmek + Tohum İfadesi ile Geri Yükle Yanlış Şifre, lütfen tekrar deneyin! sol aşağı ok logo diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml index cd63237d..311e84e9 100644 --- a/app/src/main/res/values-uk/strings.xml +++ b/app/src/main/res/values-uk/strings.xml @@ -754,10 +754,10 @@ Ви зберегли його, правда? Доведіть це!\nПеретягніть слова в правильному порядку. Гра та синхронізація - готовий - Зробіть це для вас. Будь ласка, зробіть це - поодинці - Візьміть ручку, папір і 5 хвилин + Готові почати? + Це тільки для тебе. + Налаштуйте пароль програми, відкрийте менеджер паролів або візьміть ручку, щоб записати його та нову початкову фразу. + Ми цього не знаємо і не маємо копії! Налаштуйте пароль програми Виберіть пароль, щоб розблокувати свій Brainwallet Не код блокування телефону! @@ -774,7 +774,7 @@ Підтвердити Ви не забули? Введіть його знову. Або поверніться, щоб почати спочатку. Готовий - Відновити + Відновлення за початкової фрази Неправильний пароль, спробуйте ще раз! стрілка вниз-ліворуч логотип diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 4f28b512..e43d6b0f 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -748,10 +748,10 @@ 你救了它,对吗? 证明一下!\n按正确的顺序拖动单词。 游戏与同步 - 准备好? - 为你做这件事。请这样做 - 独自的 - 拿起笔、纸和 5 分钟 + 准备好开始了吗? + 这是只属于你一个人的。 + 设置应用程序密码,打开密码管理器或拿一支笔记录它和你的新种子短语。 + 我们不知道,也没有副本! 设置应用程序密码 选择密码来解锁您的 Brainwallet 不是手机锁码! @@ -768,7 +768,7 @@ 确认 你没有忘记吧?再次输入。或者,返回重新开始。 准备好 - 恢复 + 使用种子短语恢复 密码错误,请重试! 左下箭头 标识 diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index 378bc1aa..8e29fb69 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -747,10 +747,10 @@ 你救了它,對嗎? 證明一下! 遊戲與同步 - 準備好? - 為你做這件事。請這樣做 - 獨自的 - 拿起筆、紙和 5 分鐘 + 準備好開始了嗎? + 我們不知道,也沒有副本! + 設定應用程式密碼,打開密碼管理器或拿一支筆記錄它和你的新種子短語。 + 我們不知道,也沒有副本! 設定應用程式密碼 選擇密碼來解鎖您的 Brainwallet 不是手機鎖碼! @@ -767,7 +767,7 @@ 確認 你沒有忘記吧?再次輸入。或者,返回重新開始。 準備好 - 恢復 + 使用種子短語恢復 密碼錯誤,請重試! 左下箭頭 標識 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 8323d2ac..cb798919 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -740,7 +740,7 @@ Balance - 1 LTC = %1$s + %1$s = 1Ł Current LTC value in %1$s Languages Are you sure you want to change the language to English? @@ -785,10 +785,10 @@ You saved it, right? Prove it!\nDrag the words in the correct order. Game & Sync - Ready? - Do this for you. Please do it - alone - Grab a pen, paper and 5 minutes + Ready to start? + This is for you alone. + Setup the app passcode, open your password manager or grab a pen to record it & your new seed phrase. + We do not know it nor do we have a copy! Setup app passcode Pick a passcode to unlock your Brainwallet Not a phone lock code! @@ -811,7 +811,7 @@ Confirm You didn’t forget did you? Enter it again. Or, go back to start over. Ready - Restore + Restore With Seed Phrase Incorrect Passcode, please try again! down-left-arrow logo diff --git a/app/src/screengrab/kotlin/com/brainwallet/BrainwalletScreengrabApp.kt b/app/src/screengrab/kotlin/com/brainwallet/BrainwalletScreengrabApp.kt index 6e14bcf3..c181d83e 100644 --- a/app/src/screengrab/kotlin/com/brainwallet/BrainwalletScreengrabApp.kt +++ b/app/src/screengrab/kotlin/com/brainwallet/BrainwalletScreengrabApp.kt @@ -1,9 +1,6 @@ package com.brainwallet import com.brainwallet.data.source.RemoteConfigSource -import com.brainwallet.di.appModule -import com.brainwallet.di.dataModule -import com.brainwallet.di.viewModelModule import org.koin.android.ext.koin.androidContext import org.koin.android.ext.koin.androidLogger import org.koin.core.context.GlobalContext.startKoin diff --git a/app/src/test/java/com/brainwallet/data/repository/FirebaseAnalyticsSourceTest.kt b/app/src/test/java/com/brainwallet/data/repository/FirebaseAnalyticsSourceTest.kt new file mode 100644 index 00000000..c8d79346 --- /dev/null +++ b/app/src/test/java/com/brainwallet/data/repository/FirebaseAnalyticsSourceTest.kt @@ -0,0 +1,46 @@ +package com.brainwallet.data.source + +import android.content.Context +import com.google.firebase.analytics.FirebaseAnalytics +import io.mockk.MockKAnnotations +import io.mockk.impl.annotations.MockK +import io.mockk.impl.annotations.RelaxedMockK +import io.mockk.verify +import org.junit.Before +import org.junit.Test + +class FirebaseAnalyticsSourceTest { + + @RelaxedMockK + private lateinit var context: Context + + @MockK + private lateinit var firebaseAnalytics: FirebaseAnalytics + private lateinit var sut: FirebaseAnalyticsSource + + @Before + fun setUp() { + MockKAnnotations.init(this, relaxUnitFun = true) + sut = FirebaseAnalyticsSource(context, firebaseAnalytics) + } + + @Test + fun `given valid event name when logCustomEvent is called then event should be logged without params`() { + val eventName = "test_event" + + sut.logEvent(eventName) + + verify(exactly = 1) { firebaseAnalytics.logEvent(eventName, null) } + assert(true) { "Expected logEvent to be called with eventName=$eventName and params=null" } + } + + @Test + fun `given valid event name and params when logCustomEventWithParams is called then event should be logged with params`() { + val eventName = "test_event_with_params" + + sut.logEventWithParams(eventName, mapOf()) + + verify(exactly = 1) { firebaseAnalytics.logEvent(eventName, any()) } + assert(true) { "Expected logEvent to be called with eventName=$eventName and provided params" } + } +} diff --git a/app/src/test/java/com/brainwallet/data/repository/MessagingTopicDataSourceTest.kt b/app/src/test/java/com/brainwallet/data/repository/MessagingTopicDataSourceTest.kt new file mode 100644 index 00000000..57e51bd5 --- /dev/null +++ b/app/src/test/java/com/brainwallet/data/repository/MessagingTopicDataSourceTest.kt @@ -0,0 +1,140 @@ +package com.brainwallet.data.source + +import org.junit.Before +import org.junit.Test + +class MessagingTopicDataSourceTest { + private lateinit var dataSource: MessagingTopicDataSource + + @Before + fun setUp() { + dataSource = MessagingTopicDataSource() + } + + @Test + fun `given locale with underscore when getTopicsByLanguageCode called then it should extract base language only`() { + val result = dataSource.getTopicsByLanguageCode("fr_FR") + + val expectedTopics = listOf("initial_fr", "news_fr", "promo_fr", "warn_fr") + assert(result == expectedTopics) { + "Expected topics for French language from 'fr_FR' but got $result" + } + } + + @Test + fun `given locale with hyphen when getTopicsByLanguageCode called then it should extract base language only`() { + val result = dataSource.getTopicsByLanguageCode("zh-CN") + + val expectedTopics = listOf("initial_zh", "news_zh", "promo_zh", "warn_zh") + assert(result == expectedTopics) { + "Expected topics for Chinese language from 'zh-CN' but got $result" + } + } + + @Test + fun `given uppercase language code when getTopicsByLanguageCode called then it should normalize to lowercase`() { + val result = dataSource.getTopicsByLanguageCode("FR") + + val expectedTopics = listOf("initial_fr", "news_fr", "promo_fr", "warn_fr") + assert(result == expectedTopics) { + "Expected topics for French language from uppercase 'FR' but got $result" + } + } + + @Test + fun `given mixed case locale when getTopicsByLanguageCode called then it should normalize to lowercase base`() { + val result = dataSource.getTopicsByLanguageCode("Es_ES") + + val expectedTopics = listOf("initial_es", "news_es", "promo_es", "warn_es") + assert(result == expectedTopics) { + "Expected topics for Spanish language from 'Es_ES' but got $result" + } + } + + @Test + fun `given empty string when getTopicsByLanguageCode called then it should default to English`() { + val result = dataSource.getTopicsByLanguageCode("") + + val expectedTopics = listOf("initial_en", "news_en", "promo_en", "warn_en") + assert(result == expectedTopics) { + "Expected topics to default to English for empty string but got $result" + } + } + + @Test + fun `given unsupported language code when getTopicsByLanguageCode called then it should default to English`() { + val result = dataSource.getTopicsByLanguageCode("unsupported") + + val expectedTopics = listOf("initial_en", "news_en", "promo_en", "warn_en") + assert(result == expectedTopics) { + "Expected topics to default to English for unsupported language but got $result" + } + } + + @Test + fun `given Indonesian language code when getTopicsByLanguageCode called then it should normalize to id for backend consistency`() { + val result = dataSource.getTopicsByLanguageCode("in") + + val expectedTopics = listOf("initial_id", "news_id", "promo_id", "warn_id") + assert(result == expectedTopics) { + "Expected Indonesian language code 'in' to be normalized to 'id' for backend consistency but got $result" + } + } + + @Test + fun `given Indonesian locale with region when getTopicsByLanguageCode called then it should normalize to id`() { + val result = dataSource.getTopicsByLanguageCode("in_ID") + + val expectedTopics = listOf("initial_id", "news_id", "promo_id", "warn_id") + assert(result == expectedTopics) { + "Expected Indonesian locale 'in_ID' to be normalized to 'id' for backend consistency but got $result" + } + } + + @Test + fun `given id language code when getTopicsByLanguageCode called then it should be recognized as Indonesian`() { + val result = dataSource.getTopicsByLanguageCode("id") + + val expectedTopics = listOf("initial_id", "news_id", "promo_id", "warn_id") + assert(result == expectedTopics) { + "Expected 'id' language code to be recognized as Indonesian and return Indonesian topics but got $result" + } + } + + @Test + fun `given all supported languages when getTopicsByLanguageCode called then it should return correct base language topics`() { + val testCases = mapOf( + "en" to "en", + "es" to "es", + // Check All possible indonesian combination + "in" to "id", + "in_ID" to "id", + "id" to "id", + "ar" to "ar", + "uk" to "uk", + "ru" to "ru", + "pt" to "pt", + "hi" to "hi", + "de" to "de", + "fa" to "fa", + "pa" to "pa", + "pl" to "pl", + "ko" to "ko", + "fr" to "fr", + "zh-TW" to "zh", + "zh-CN" to "zh", + "tr" to "tr", + "ja" to "ja", + "it" to "it", + "sv" to "sv" + ) + + testCases.forEach { (input, expectedBase) -> + val result = dataSource.getTopicsByLanguageCode(input) + val expectedTopics = listOf("initial_$expectedBase", "news_$expectedBase", "promo_$expectedBase", "warn_$expectedBase") + assert(result == expectedTopics) { + "Expected topics for '$input' to have base '$expectedBase' but got $result" + } + } + } +} diff --git a/app/src/test/java/com/brainwallet/data/repository/MessagingTopicRepositoryTest.kt b/app/src/test/java/com/brainwallet/data/repository/MessagingTopicRepositoryTest.kt new file mode 100644 index 00000000..9e221314 --- /dev/null +++ b/app/src/test/java/com/brainwallet/data/repository/MessagingTopicRepositoryTest.kt @@ -0,0 +1,71 @@ +package com.brainwallet.data.repository + +import com.brainwallet.data.model.Language +import com.brainwallet.data.source.MessagingTopicDataSource +import io.mockk.MockKAnnotations +import io.mockk.every +import io.mockk.impl.annotations.MockK +import io.mockk.verify +import org.junit.Before +import org.junit.Test + +class MessagingTopicRepositoryTest { + + @MockK + private lateinit var mockSettingRepository: SettingRepository + + @MockK + private lateinit var mockTopicDataSource: MessagingTopicDataSource + + private lateinit var repository: MessagingTopicRepository + + private lateinit var fakeIndonesianTopics: List + private lateinit var fakeEnglishTopics: List + + @Before + fun setUp() { + MockKAnnotations.init(this) + fakeIndonesianTopics = listOf("initial_in", "news_in", "promo_in", "warn_in") + fakeEnglishTopics = listOf("initial_en", "news_en", "promo_en", "warn_en") + repository = MessagingTopicRepository(mockSettingRepository, mockTopicDataSource) + } + + @Test + fun `given current language code is in when getCurrentTopics called then repository should return topics for Indonesian`() { + every { mockSettingRepository.getCurrentLanguage().code } returns "in" + every { mockTopicDataSource.getTopicsByLanguageCode("in") } returns fakeIndonesianTopics + + val result = repository.getCurrentTopics() + + assert(result == fakeIndonesianTopics) { + "Expected repository to return topics for Indonesian but got $result" + } + verify(exactly = 1) { mockTopicDataSource.getTopicsByLanguageCode("in") } + } + + @Test + fun `given language object when getTopicsByLanguage called then repository should return topics for that language`() { + val language = Language.INDONESIAN + every { mockTopicDataSource.getTopicsByLanguageCode(language.code) } returns fakeIndonesianTopics + + val result = repository.getTopicsByLanguage(language) + + assert(result == fakeIndonesianTopics) { + "Expected repository to return topics for Indonesian language but got $result" + } + verify(exactly = 1) { mockTopicDataSource.getTopicsByLanguageCode(language.code) } + } + + @Test + fun `given English language when getTopicsByLanguage called then repository should return English topics`() { + val language = Language.ENGLISH + every { mockTopicDataSource.getTopicsByLanguageCode(language.code) } returns fakeEnglishTopics + + val result = repository.getTopicsByLanguage(language) + + assert(result == fakeEnglishTopics) { + "Expected repository to return topics for English language but got $result" + } + verify(exactly = 1) { mockTopicDataSource.getTopicsByLanguageCode(language.code) } + } +} diff --git a/app/src/test/java/com/brainwallet/data/repository/PeerManagerSourceTest.kt b/app/src/test/java/com/brainwallet/data/repository/PeerManagerSourceTest.kt new file mode 100644 index 00000000..a415847b --- /dev/null +++ b/app/src/test/java/com/brainwallet/data/repository/PeerManagerSourceTest.kt @@ -0,0 +1,42 @@ +package com.brainwallet.data.source + +import io.mockk.every +import io.mockk.mockk +import org.junit.Before +import org.junit.Test + +class PeerManagerSourceTest { + + private lateinit var proxy: BRPeerManagerProxy + private lateinit var peerManagerSource: PeerManagerSource + + @Before + fun setUp() { + proxy = mockk() + peerManagerSource = PeerManagerSource(proxy) + } + + @Test + fun `Given proxy returns block height When calling getCurrentBlockHeight Then it should return expected block height`() { + val expectedHeight = 12345 + every { proxy.getCurrentBlockHeight() } returns expectedHeight + + val actualHeight = peerManagerSource.getCurrentBlockHeight() + + assert(actualHeight == expectedHeight) { + "Expected block height to be $expectedHeight but was $actualHeight" + } + } + + @Test + fun `Given proxy returns last block timestamp When calling getLastBlockTimestamp Then it should return expected timestamp`() { + val expectedTimestamp = 1699999999L + every { proxy.getLastBlockTimestamp() } returns expectedTimestamp + + val actualTimestamp = peerManagerSource.getLastBlockTimestamp() + + assert(actualTimestamp == expectedTimestamp) { + "Expected last block timestamp to be $expectedTimestamp but was $actualTimestamp" + } + } +} diff --git a/app/src/test/java/com/brainwallet/data/repository/SyncAnalyticsRepositoryTest.kt b/app/src/test/java/com/brainwallet/data/repository/SyncAnalyticsRepositoryTest.kt new file mode 100644 index 00000000..0167e25f --- /dev/null +++ b/app/src/test/java/com/brainwallet/data/repository/SyncAnalyticsRepositoryTest.kt @@ -0,0 +1,192 @@ +package com.brainwallet.data.repository + +import android.content.SharedPreferences +import com.brainwallet.data.source.AnalyticsSource +import com.brainwallet.data.source.PeerManagerSource +import com.brainwallet.util.FakeSharedPreferences +import io.mockk.MockKAnnotations +import io.mockk.every +import io.mockk.impl.annotations.MockK +import io.mockk.mockkStatic +import io.mockk.slot +import io.mockk.unmockkAll +import io.mockk.verify +import org.junit.After +import org.junit.Before +import org.junit.Test +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale +import java.util.TimeZone +import java.util.UUID + +class SyncAnalyticsRepositoryTest { + private lateinit var prefs: SharedPreferences + + @MockK + private lateinit var analyticsSource: AnalyticsSource + + @MockK + private lateinit var peerManagerSource: PeerManagerSource + + private lateinit var repository: SyncAnalyticsRepository + + @Before + fun setUp() { + MockKAnnotations.init(this, relaxUnitFun = true) + prefs = FakeSharedPreferences() + repository = SyncAnalyticsRepository( + analyticsSource = analyticsSource, + peerManagerSource = peerManagerSource, + prefs = prefs + ) + } + + @After + fun tearDown() { + unmockkAll() + } + + @Test + fun `given sync started when startSync called then start timestamp stored`() { + every { peerManagerSource.getLastBlockTimestamp() } returns 1000L + + repository.startSync() + + val stored = prefs.getLong("current_sync_start_timestamp", -1L) + assert(stored == 1000L) { "expected 1000L but was $stored" } + } + + @Test + fun `given sync started and stopped when stopSync called then duration accumulated`() { + prefs.edit().putLong("current_sync_start_timestamp", 1000L).apply() + prefs.edit().putLong("accumulated_sync_duration", 200L).apply() + every { peerManagerSource.getLastBlockTimestamp() } returns 1500L + + repository.stopSync() + + val accumulated = prefs.getLong("accumulated_sync_duration", 0L) + val startRemoved = prefs.getLong("current_sync_start_timestamp", -1L) + assert(accumulated == 700L) { "expected 700L but was $accumulated" } + assert(startRemoved == -1L) { "expected current_sync_start_timestamp to be removed" } + } + + @Test + fun `given sync never started when stopSync called then nothing stored`() { + prefs.edit().putLong("current_sync_start_timestamp", 0L).apply() + + repository.stopSync() + + val accumulated = prefs.getLong("accumulated_sync_duration", 0L) + assert(accumulated == 0L) { "expected no accumulation but was $accumulated" } + } + + @Test + fun `given accumulated duration when completeSync called then metadata persisted and event logged`() { + prefs.edit().putLong("accumulated_sync_duration", 5000L).apply() + every { peerManagerSource.getLastBlockTimestamp() } returns 6000L + every { peerManagerSource.getCurrentBlockHeight() } returns 456 + + mockkStatic(UUID::class) + every { UUID.randomUUID().toString() } returns "uuid-123" + + repository.completeSync() + + val lastUuid = prefs.getString("last_sync_uuid", null) + val lastDuration = prefs.getLong("last_sync_duration", 0L) + val lastEnd = prefs.getLong("last_sync_end_timestamp", 0L) + val accumulatedCleared = prefs.getLong("accumulated_sync_duration", -1L) + + assert(lastUuid == "uuid-123") { "expected uuid-123 but was $lastUuid" } + assert(lastDuration == 5000000L) { "expected duration=5000000 but was $lastDuration" } + assert(lastEnd == 6000L) { "expected end=6000 but was $lastEnd" } + assert(accumulatedCleared == -1L) { "expected accumulated_sync_duration removed" } + + val paramsSlot = slot>() + + verify { + analyticsSource.logEventWithParams( + "user_did_complete_sync", + capture(paramsSlot) + ) + } + + val captured = paramsSlot.captured + println("captured: $captured") + + assert(captured["uuid"] == "uuid-123") { "uuid should match" } + assert(captured["duration_millis"] == 5000000L) { "duration should match" } + assert(captured["end_timestamp"] == 6000L) { "end timestamp should match" } + assert(captured["end_block_height"] == 456) { "end block height should match" } + } + + @Test + fun `given metadata stored when getLastSyncMetadata called then correct SyncMetadata returned`() { + prefs.edit() + .putString("last_sync_uuid", "uuid-999") + .putLong("last_sync_duration", 8888L) + .putLong("last_sync_end_timestamp", 9999L) + .apply() + + val metadata = repository.getLastSyncMetadata() + + assert(metadata != null) { "expected metadata to be not null" } + assert(metadata!!.uuid == "uuid-999") { "expected uuid=uuid-999 but was ${metadata.uuid}" } + assert(metadata.durationMillis == 8888L) { "expected duration=8888 but was ${metadata.durationMillis}" } + assert(metadata.endTimestamp == 9999L) { "expected endTimestamp=9999 but was ${metadata.endTimestamp}" } + } + + @Test + fun `given no metadata stored when getLastSyncMetadata called then null returned`() { + val metadata = repository.getLastSyncMetadata() + assert(metadata == null) { "expected metadata to be null" } + } + + @Test + fun `given sync metadata when format then return formatted string containing correct duration and timestamp`() { + val fixedDateFormat = SimpleDateFormat("MMMM dd, yyyy h:mm:ss a", Locale.US).apply { + timeZone = TimeZone.getTimeZone("UTC") + } + val formatter = SyncAnalyticsRepository.SyncMetadata.Formatter(fixedDateFormat) + + val syncMetadata = SyncAnalyticsRepository.SyncMetadata( + uuid = "1234", + durationMillis = 2500L, + endTimestamp = 1633072800L + ) + + val formattedOutput = formatter.format(syncMetadata) + + assert(formattedOutput.contains("Duration: 2.5 seconds")) { + "Expected formatted string to contain 'Duration: 2.5 seconds' but got '$formattedOutput'" + } + + val expectedDatePart = fixedDateFormat.format(Date(syncMetadata.endTimestamp * 1000)) + assert(formattedOutput.contains(expectedDatePart)) { + "Expected formatted string to contain '$expectedDatePart' but got '$formattedOutput'" + } + } + + @Test + fun `given sync metadata when format then return exactly formatted string with correct structure`() { + val fixedDateFormat = SimpleDateFormat("MMMM dd, yyyy h:mm:ss a", Locale.US).apply { + timeZone = TimeZone.getTimeZone("UTC") + } + val formatter = SyncAnalyticsRepository.SyncMetadata.Formatter(fixedDateFormat) + + val syncMetadata = SyncAnalyticsRepository.SyncMetadata( + uuid = "1234", + durationMillis = 2500L, + endTimestamp = 1633072800L + ) + + val actual = formatter.format(syncMetadata) + + val expectedDate = fixedDateFormat.format(Date(syncMetadata.endTimestamp * 1000)) + val expected = "Duration: 2.5 seconds\nTimestamp: $expectedDate" + + assert(actual == expected) { + "Expected exactly '$expected' but got '$actual'" + } + } +} diff --git a/app/src/test/java/com/brainwallet/domain/LanguageSwitcherUseCaseTest.kt b/app/src/test/java/com/brainwallet/domain/LanguageSwitcherUseCaseTest.kt new file mode 100644 index 00000000..15dd7efd --- /dev/null +++ b/app/src/test/java/com/brainwallet/domain/LanguageSwitcherUseCaseTest.kt @@ -0,0 +1,39 @@ +package com.brainwallet.domain + +import com.brainwallet.data.model.Language +import com.brainwallet.data.repository.SettingRepository +import io.mockk.MockKAnnotations +import io.mockk.impl.annotations.MockK +import io.mockk.verifyOrder +import org.junit.Before +import org.junit.Test + +class LanguageSwitcherUseCaseTest { + + private lateinit var useCase: LanguageSwitcherUseCase + + @MockK + private lateinit var mockSettingRepository: SettingRepository + + @MockK + private lateinit var mockMessagingTopicUseCase: MessagingTopicUseCase + + @Before + fun setUp() { + MockKAnnotations.init(this, relaxUnitFun = true) + useCase = LanguageSwitcherUseCase(mockSettingRepository, mockMessagingTopicUseCase) + } + + @Test + fun `given new language when switchLanguage called then it should subscribe by language and update current language`() { + val newLanguage = Language.FRENCH + + useCase.switchLanguage(newLanguage) + + // Order is important to make old subscribed language still valid + verifyOrder { + mockMessagingTopicUseCase.subscribeByLanguage(newLanguage) + mockSettingRepository.updateCurrentLanguage(newLanguage.code) + } + } +} diff --git a/app/src/test/java/com/brainwallet/domain/MessagingTopicUseCaseTest.kt b/app/src/test/java/com/brainwallet/domain/MessagingTopicUseCaseTest.kt new file mode 100644 index 00000000..a026737f --- /dev/null +++ b/app/src/test/java/com/brainwallet/domain/MessagingTopicUseCaseTest.kt @@ -0,0 +1,66 @@ +package com.brainwallet.domain + +import com.brainwallet.data.model.Language +import com.brainwallet.data.repository.MessagingTopicRepository +import com.google.firebase.messaging.FirebaseMessaging +import io.mockk.MockKAnnotations +import io.mockk.every +import io.mockk.impl.annotations.MockK +import io.mockk.impl.annotations.RelaxedMockK +import io.mockk.verify +import org.junit.Before +import org.junit.Test + +class MessagingTopicUseCaseTest { + + private lateinit var useCase: MessagingTopicUseCase + + @MockK + private lateinit var mockRepository: MessagingTopicRepository + + @RelaxedMockK + private lateinit var mockFirebaseMessaging: FirebaseMessaging + private lateinit var currentTopics: List + private lateinit var newTopics: List + + @Before + fun setUp() { + MockKAnnotations.init(this) + currentTopics = listOf("initial_en", "news_en", "promo_en", "warn_en") + newTopics = listOf("initial_fr", "news_fr", "promo_fr", "warn_fr") + + useCase = MessagingTopicUseCase(mockRepository, mockFirebaseMessaging) + } + + @Test + fun `given current topics when initialize called then it should subscribe to all current topics`() { + every { mockRepository.getCurrentTopics() } returns currentTopics + + useCase.initialize() + + verify(exactly = 1) { mockFirebaseMessaging.subscribeToTopic("initial_en") } + verify(exactly = 1) { mockFirebaseMessaging.subscribeToTopic("news_en") } + verify(exactly = 1) { mockFirebaseMessaging.subscribeToTopic("promo_en") } + verify(exactly = 1) { mockFirebaseMessaging.subscribeToTopic("warn_en") } + } + + @Test + fun `given current topics and new language when subscribeByLanguage called then it should unsubscribe old and subscribe new`() { + every { mockRepository.getCurrentTopics() } returns currentTopics + every { mockRepository.getTopicsByLanguage(any()) } returns newTopics + + val newLanguage = Language.FRENCH + + useCase.subscribeByLanguage(newLanguage) + + verify(exactly = 1) { mockFirebaseMessaging.unsubscribeFromTopic("initial_en") } + verify(exactly = 1) { mockFirebaseMessaging.unsubscribeFromTopic("news_en") } + verify(exactly = 1) { mockFirebaseMessaging.unsubscribeFromTopic("promo_en") } + verify(exactly = 1) { mockFirebaseMessaging.unsubscribeFromTopic("warn_en") } + + verify(exactly = 1) { mockFirebaseMessaging.subscribeToTopic("initial_fr") } + verify(exactly = 1) { mockFirebaseMessaging.subscribeToTopic("news_fr") } + verify(exactly = 1) { mockFirebaseMessaging.subscribeToTopic("promo_fr") } + verify(exactly = 1) { mockFirebaseMessaging.subscribeToTopic("warn_fr") } + } +} diff --git a/app/src/test/java/com/brainwallet/ui/screens/unlock/UnLockViewModelTest.kt b/app/src/test/java/com/brainwallet/ui/screens/unlock/UnLockViewModelTest.kt new file mode 100644 index 00000000..fe266cc9 --- /dev/null +++ b/app/src/test/java/com/brainwallet/ui/screens/unlock/UnLockViewModelTest.kt @@ -0,0 +1,359 @@ +package com.brainwallet.ui.screens.unlock + +import app.cash.turbine.test +import com.brainwallet.data.model.AppSetting +import com.brainwallet.data.model.CurrencyEntity +import com.brainwallet.data.repository.SettingRepository +import com.brainwallet.navigation.Route +import com.brainwallet.navigation.UiEffect +import com.brainwallet.util.CurrencyDataGetter +import com.brainwallet.util.EventBus +import com.brainwallet.util.MainDispatcherRule +import com.brainwallet.util.VersionCodeProvider +import io.mockk.MockKAnnotations +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.impl.annotations.MockK +import io.mockk.impl.annotations.RelaxedMockK +import io.mockk.mockkObject +import io.mockk.unmockkAll +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Before +import org.junit.Rule +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class UnLockViewModelTest { + + @get:Rule + val mainDispatcherRule = MainDispatcherRule() + + @MockK + private lateinit var mockVersionCodeProvider: VersionCodeProvider + + @MockK + private lateinit var mockSettingRepository: SettingRepository + + @RelaxedMockK + private lateinit var mockCurrencyDataGetter: CurrencyDataGetter + + private lateinit var viewModel: UnLockViewModel + + @Before + fun setUp() { + MockKAnnotations.init(this, relaxUnitFun = true) + every { mockVersionCodeProvider.getFormatted() } returns "v4.7.1" + + viewModel = UnLockViewModel( + versionCodeProvider = mockVersionCodeProvider, + settingRepository = mockSettingRepository, + currencyDataGetter = mockCurrencyDataGetter + ) + } + + @After + fun tearDown() { + unmockkAll() + } + + @Test + fun `given viewModel initialization when created then state contains formatted version`() = + runTest { + viewModel.state.test { + val initialState = awaitItem() + + assert(initialState.formattedVersion == "v4.7.1") { + "Expected formatted version to be 'v4.7.1' but was '${initialState.formattedVersion}'" + } + assert(initialState.passcode == List(4) { -1 }) { + "Expected passcode to be empty list of -1 values but was '${initialState.passcode}'" + } + assert(initialState.iso == "USD") { + "Expected default ISO to be 'USD' but was '${initialState.iso}'" + } + assert(!initialState.isUpdatePin) { + "Expected isUpdatePin to be false but was '${initialState.isUpdatePin}'" + } + } + } + + @Test + fun `given valid pin digit when OnPinDigitChange event then passcode updates correctly`() = + runTest { + viewModel.state.test { + awaitItem() // Skip initial state + + viewModel.onEvent(UnLockEvent.OnPinDigitChange(digit = 5, isValidPin = { false })) + + val updatedState = awaitItem() + assert(updatedState.passcode[0] == 5) { + "Expected first digit to be 5 but was '${updatedState.passcode[0]}'" + } + assert(updatedState.passcode.drop(1).all { it == -1 }) { + "Expected remaining digits to be -1 but were '${updatedState.passcode.drop(1)}'" + } + } + } + + @Test + fun `given multiple pin digits when OnPinDigitChange events then passcode fills sequentially`() = + runTest { + viewModel.state.test { + awaitItem() + + viewModel.onEvent(UnLockEvent.OnPinDigitChange(digit = 1, isValidPin = { false })) + awaitItem() + + viewModel.onEvent(UnLockEvent.OnPinDigitChange(digit = 2, isValidPin = { false })) + awaitItem() + + viewModel.onEvent(UnLockEvent.OnPinDigitChange(digit = 3, isValidPin = { false })) + val thirdDigitState = awaitItem() + + assert(thirdDigitState.passcode == listOf(1, 2, 3, -1)) { + "Expected passcode to be [1, 2, 3, -1] but was '${thirdDigitState.passcode}'" + } + } + } + + @Test + fun `given invalid digit when OnPinDigitChange with digit less than -1 then passcode remains unchanged`() = + runTest { + viewModel.state.test { + val initialState = awaitItem() + + viewModel.onEvent(UnLockEvent.OnPinDigitChange(digit = -5, isValidPin = { false })) + + expectNoEvents() + assert(initialState.passcode == List(4) { -1 }) { + "Expected passcode to remain unchanged but was modified" + } + } + } + + @Test + fun `given full passcode when OnPinDigitChange then no additional digits accepted`() = runTest { + viewModel.state.test { + awaitItem() + + repeat(4) { index -> + viewModel.onEvent( + UnLockEvent.OnPinDigitChange( + digit = index + 1, + isValidPin = { false }) + ) + awaitItem() + } + + viewModel.onEvent(UnLockEvent.OnPinDigitChange(digit = 9, isValidPin = { false })) + + expectNoEvents() + } + } + + @Test + fun `given filled passcode in update mode with valid pin when OnPinDigitChange then navigates to SetPasscode`() = + runTest { + mockkObject(EventBus) + + viewModel.state.test { + awaitItem() + + viewModel.onEvent(UnLockEvent.OnLoad(isUpdatePin = true)) + awaitItem() + + repeat(3) { index -> + viewModel.onEvent( + UnLockEvent.OnPinDigitChange( + digit = index + 1, + isValidPin = { true }) + ) + awaitItem() + } + + viewModel.onEvent( + UnLockEvent.OnPinDigitChange( + digit = 4, + isValidPin = { true }) + ) + awaitItem() + } + + viewModel.uiEffect.test { + val effect = awaitItem() + assert(effect is UiEffect.Navigate) { + "Expected Navigate effect but was '${effect::class.simpleName}'" + } + val navigateEffect = effect as UiEffect.Navigate + assert(navigateEffect.destinationRoute is Route.SetPasscode) { + "Expected navigation to SetPasscode but was '${ + navigateEffect.destinationRoute!!::class.simpleName + }'" + } + } + } + + @Test + fun `given filled passcode in normal mode when OnPinDigitChange then emits LegacyUnLock event`() = + runTest { + mockkObject(EventBus) + coEvery { EventBus.emit(any()) } returns Unit + + viewModel.state.test { + awaitItem() + + repeat(3) { index -> + viewModel.onEvent( + UnLockEvent.OnPinDigitChange( + digit = index + 1, + isValidPin = { false }) + ) + awaitItem() + } + + viewModel.onEvent(UnLockEvent.OnPinDigitChange(digit = 4, isValidPin = { false })) + awaitItem() + } + + testScheduler.advanceUntilIdle() + + coVerify { + EventBus.emit(match { event -> + event.passcode == listOf(1, 2, 3, 4) + }) + } + } + + @Test + fun `given passcode with digits when OnDeletePinDigit then removes last entered digit`() = + runTest { + viewModel.state.test { + awaitItem() + + viewModel.onEvent(UnLockEvent.OnPinDigitChange(digit = 7, isValidPin = { false })) + awaitItem() + viewModel.onEvent(UnLockEvent.OnPinDigitChange(digit = 8, isValidPin = { false })) + awaitItem() + + viewModel.onEvent(UnLockEvent.OnDeletePinDigit) + val stateAfterDelete = awaitItem() + + assert(stateAfterDelete.passcode == listOf(7, -1, -1, -1)) { + "Expected passcode to be [7, -1, -1, -1] after deletion but was '${stateAfterDelete.passcode}'" + } + } + } + + @Test + fun `given empty passcode when OnDeletePinDigit then passcode remains unchanged`() = runTest { + viewModel.state.test { + val initialState = awaitItem() + + viewModel.onEvent(UnLockEvent.OnDeletePinDigit) + + expectNoEvents() + assert(initialState.passcode == List(4) { -1 }) { + "Expected empty passcode to remain unchanged after delete" + } + } + } + + @Test + fun `given OnLoad event with context when processed then updates state with currency information`() = + runTest { + val mockCurrency = CurrencyEntity(rate = 1.2f) + + every { mockCurrencyDataGetter.getIsoSymbol() } returns "EUR" + every { mockCurrencyDataGetter.getCurrencyByIso("EUR") } returns mockCurrency + every { + mockCurrencyDataGetter.getFormattedCurrencyString( + "EUR", + any() + ) + } returns "€1.20" + + viewModel.state.test { + awaitItem() + + viewModel.onEvent(UnLockEvent.OnLoad(isUpdatePin = false)) + + val updatedState = awaitItem() + assert(updatedState.iso == "EUR") { + "Expected ISO to be 'EUR' but was '${updatedState.iso}'" + } + assert(updatedState.formattedCurrency == "€1.20") { + "Expected formatted currency to be '€1.20' but was '${updatedState.formattedCurrency}'" + } + assert(updatedState.isUpdatePin == false) { + "Expected isUpdatePin to be false but was '${updatedState.isUpdatePin}'" + } + } + } + + + @Test + fun `given OnLoad event with null currency when processed then state remains unchanged`() = + runTest { + every { mockCurrencyDataGetter.getIsoSymbol() } returns "INVALID" + every { mockCurrencyDataGetter.getCurrencyByIso("INVALID") } returns null + + viewModel.state.test { + val initialState = awaitItem() + + viewModel.onEvent(UnLockEvent.OnLoad(isUpdatePin = false)) + + expectNoEvents() + assert(initialState.iso == "USD") { + "Expected ISO to remain 'USD' when currency is null" + } + } + } + + @Test + fun `given OnToggleDarkMode event when settings exist then toggles dark mode setting`() = + runTest { + val mockSettings = AppSetting(isDarkMode = false) + + coEvery { mockSettingRepository.settings } returns flowOf(mockSettings) + coEvery { mockSettingRepository.save(any()) } returns Unit + + viewModel.onEvent(UnLockEvent.OnToggleDarkMode) + testScheduler.advanceUntilIdle() + + coVerify { + mockSettingRepository.save(match { it.isDarkMode }) + } + } + + @Test + fun `given OnQrClicked event when processed then sends ShowMoonPayDialog effect`() = runTest { + viewModel.onEvent(UnLockEvent.OnQrClicked) + + viewModel.uiEffect.test { + val effect = awaitItem() + assert(effect is UiEffect.ShowMoonPayDialog) { + "Expected ShowMoonPayDialog effect but was '${effect::class.simpleName}'" + } + } + } + + @Test + fun `given isPasscodeFilled extension when all digits are filled then returns true`() { + val filledState = UnLockState(passcode = listOf(1, 2, 3, 4)) + assert(filledState.isPasscodeFilled()) { + "Expected isPasscodeFilled to return true for filled passcode" + } + } + + @Test + fun `given isPasscodeFilled extension when passcode has empty digits then returns false`() { + val partialState = UnLockState(passcode = listOf(1, 2, -1, -1)) + assert(!partialState.isPasscodeFilled()) { + "Expected isPasscodeFilled to return false for partially filled passcode" + } + } +} diff --git a/app/src/test/java/com/brainwallet/util/CurrencyDataGetterTest.kt b/app/src/test/java/com/brainwallet/util/CurrencyDataGetterTest.kt new file mode 100644 index 00000000..0bdf4cd0 --- /dev/null +++ b/app/src/test/java/com/brainwallet/util/CurrencyDataGetterTest.kt @@ -0,0 +1,125 @@ +package com.brainwallet.util + +import android.content.Context +import com.brainwallet.data.model.CurrencyEntity +import com.brainwallet.tools.sqlite.CurrencyDataSource +import io.mockk.MockKAnnotations +import io.mockk.every +import io.mockk.impl.annotations.MockK +import io.mockk.unmockkAll +import io.mockk.verify +import org.junit.After +import org.junit.Before +import org.junit.Test +import java.math.BigDecimal + +class CurrencyDataGetterTest { + + @MockK + private lateinit var mockContext: Context + + @MockK + private lateinit var mockCurrencyDataSource: CurrencyDataSource + + @MockK + private lateinit var mockIsoSymbolGetter: (Context) -> String + + @MockK + private lateinit var mockFormattedCurrencyStringGetter: (Context, String, BigDecimal) -> String? + + private lateinit var currencyDataGetter: CurrencyDataGetter + + @Before + fun setUp() { + MockKAnnotations.init(this, relaxUnitFun = true) + currencyDataGetter = CurrencyDataGetter( + context = mockContext, + currencyDataSource = mockCurrencyDataSource, + isoSymbolGetter = mockIsoSymbolGetter, + formattedCurrencyStringGetter = mockFormattedCurrencyStringGetter + ) + } + + @After + fun tearDown() { + unmockkAll() + } + + @Test + fun `given valid context when getting iso symbol then returns expected symbol`() { + val expectedIsoSymbol = "USD" + every { mockIsoSymbolGetter.invoke(mockContext) } returns expectedIsoSymbol + + val actualIsoSymbol = currencyDataGetter.getIsoSymbol() + assert(actualIsoSymbol == expectedIsoSymbol) { + "Expected ISO symbol to be '$expectedIsoSymbol' but was '$actualIsoSymbol'" + } + verify(exactly = 1) { mockIsoSymbolGetter.invoke(mockContext) } + } + + @Test + fun `given valid iso code when getting currency by iso then returns currency entity`() { + val isoCode = "EUR" + val expectedCurrency = CurrencyEntity().apply { + code = isoCode + name = "Euro" + } + every { mockCurrencyDataSource.getCurrencyByIso(isoCode) } returns expectedCurrency + + val actualCurrency = currencyDataGetter.getCurrencyByIso(isoCode) + + assert(actualCurrency == expectedCurrency) { + "Expected currency entity with code '$isoCode' but was '$actualCurrency'" + } + verify(exactly = 1) { mockCurrencyDataSource.getCurrencyByIso(isoCode) } + } + + @Test + fun `given invalid iso code when getting currency by iso then returns null`() { + val invalidIsoCode = "INVALID" + every { mockCurrencyDataSource.getCurrencyByIso(invalidIsoCode) } returns null + + val actualCurrency = currencyDataGetter.getCurrencyByIso(invalidIsoCode) + + assert(actualCurrency == null) { + "Expected null for invalid ISO code '$invalidIsoCode' but was '$actualCurrency'" + } + verify(exactly = 1) { mockCurrencyDataSource.getCurrencyByIso(invalidIsoCode) } + } + + @Test + fun `given valid currency code and amount when getting formatted string then returns formatted currency`() { + val currencyCode = "GBP" + val amount = BigDecimal("123.45") + val expectedFormattedString = "£123.45" + every { + mockFormattedCurrencyStringGetter.invoke(mockContext, currencyCode, amount) + } returns expectedFormattedString + + val actualFormattedString = currencyDataGetter.getFormattedCurrencyString(currencyCode, amount) + + assert(actualFormattedString == expectedFormattedString) { + "Expected formatted currency '$expectedFormattedString' but was '$actualFormattedString'" + } + verify(exactly = 1) { + mockFormattedCurrencyStringGetter.invoke(mockContext, currencyCode, amount) + } + } + + @Test + fun `given invalid currency code when getting formatted string then returns null`() { + val invalidCurrencyCode = "INVALID" + val amount = BigDecimal("100.00") + every { + mockFormattedCurrencyStringGetter.invoke(mockContext, invalidCurrencyCode, amount) + } returns null + val actualFormattedString = currencyDataGetter.getFormattedCurrencyString(invalidCurrencyCode, amount) + + assert(actualFormattedString == null) { + "Expected null for invalid currency code '$invalidCurrencyCode' but was '$actualFormattedString'" + } + verify(exactly = 1) { + mockFormattedCurrencyStringGetter.invoke(mockContext, invalidCurrencyCode, amount) + } + } +} diff --git a/app/src/test/java/com/brainwallet/util/FakeSharedPreferences.kt b/app/src/test/java/com/brainwallet/util/FakeSharedPreferences.kt new file mode 100644 index 00000000..bebdffb3 --- /dev/null +++ b/app/src/test/java/com/brainwallet/util/FakeSharedPreferences.kt @@ -0,0 +1,107 @@ +package com.brainwallet.util + +import android.content.SharedPreferences + +class FakeSharedPreferences : SharedPreferences { + + private val data = mutableMapOf() + private val listeners = mutableSetOf() + + override fun getAll(): MutableMap = data.toMutableMap() + + override fun getString(key: String?, defValue: String?): String? = + data[key] as? String ?: defValue + + override fun getLong(key: String?, defValue: Long): Long = + (data[key] as? Long) ?: defValue + + override fun getInt(key: String?, defValue: Int): Int = + (data[key] as? Int) ?: defValue + + override fun getBoolean(key: String?, defValue: Boolean): Boolean = + (data[key] as? Boolean) ?: defValue + + override fun getFloat(key: String?, defValue: Float): Float = + (data[key] as? Float) ?: defValue + + override fun getStringSet(key: String?, defValues: MutableSet?): MutableSet? = + (data[key] as? MutableSet) ?: defValues + + override fun contains(key: String?): Boolean = data.containsKey(key) + + override fun edit(): SharedPreferences.Editor = FakeEditor() + + override fun registerOnSharedPreferenceChangeListener(listener: SharedPreferences.OnSharedPreferenceChangeListener?) { + listener?.let { listeners.add(it) } + } + + override fun unregisterOnSharedPreferenceChangeListener(listener: SharedPreferences.OnSharedPreferenceChangeListener?) { + listeners.remove(listener) + } + + inner class FakeEditor : SharedPreferences.Editor { + private val temp = mutableMapOf() + private val toRemove = mutableSetOf() + private var clear = false + + override fun putString(key: String?, value: String?): SharedPreferences.Editor { + temp[key!!] = value + return this + } + + override fun putLong(key: String?, value: Long): SharedPreferences.Editor { + temp[key!!] = value + return this + } + + override fun putInt(key: String?, value: Int): SharedPreferences.Editor { + temp[key!!] = value + return this + } + + override fun putBoolean(key: String?, value: Boolean): SharedPreferences.Editor { + temp[key!!] = value + return this + } + + override fun putFloat(key: String?, value: Float): SharedPreferences.Editor { + temp[key!!] = value + return this + } + + override fun putStringSet( + key: String?, + values: MutableSet? + ): SharedPreferences.Editor { + temp[key!!] = values + return this + } + + override fun remove(key: String?): SharedPreferences.Editor { + toRemove.add(key!!) + return this + } + + override fun clear(): SharedPreferences.Editor { + clear = true + return this + } + + override fun commit(): Boolean { + apply() + return true + } + + override fun apply() { + if (clear) { + data.clear() + } + toRemove.forEach { data.remove(it) } + data.putAll(temp) + // notify listeners + (temp.keys + toRemove).forEach { key -> + listeners.forEach { it.onSharedPreferenceChanged(this@FakeSharedPreferences, key) } + } + } + } +} diff --git a/app/src/test/java/com/brainwallet/util/MainDispatcherRule.kt b/app/src/test/java/com/brainwallet/util/MainDispatcherRule.kt new file mode 100644 index 00000000..a06c45c8 --- /dev/null +++ b/app/src/test/java/com/brainwallet/util/MainDispatcherRule.kt @@ -0,0 +1,23 @@ +package com.brainwallet.util + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.TestDispatcher +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import org.junit.rules.TestWatcher +import org.junit.runner.Description + +@OptIn(ExperimentalCoroutinesApi::class) +class MainDispatcherRule( + val testDispatcher: TestDispatcher = UnconfinedTestDispatcher(), +) : TestWatcher() { + override fun starting(description: Description) { + Dispatchers.setMain(testDispatcher) + } + + override fun finished(description: Description) { + Dispatchers.resetMain() + } +} diff --git a/app/src/test/java/com/brainwallet/util/TimeProviderTest.kt b/app/src/test/java/com/brainwallet/util/TimeProviderTest.kt new file mode 100644 index 00000000..1278c010 --- /dev/null +++ b/app/src/test/java/com/brainwallet/util/TimeProviderTest.kt @@ -0,0 +1,16 @@ +package com.brainwallet.util + +import org.junit.Test + +class TimeProviderTest { + + @Test + fun `returns provided nowGetter value`() { + val fixedTime = 123456789L + val timeProvider = TimeProvider { fixedTime } + + val result = timeProvider.now() + + assert(result == fixedTime) { "Expected $fixedTime but got $result" } + } +} diff --git a/app/src/test/java/com/brainwallet/util/VersionCodeProviderTest.kt b/app/src/test/java/com/brainwallet/util/VersionCodeProviderTest.kt new file mode 100644 index 00000000..31503883 --- /dev/null +++ b/app/src/test/java/com/brainwallet/util/VersionCodeProviderTest.kt @@ -0,0 +1,44 @@ +package com.brainwallet.util + +import io.mockk.every +import io.mockk.mockk +import junit.framework.TestCase.assertEquals +import org.junit.Test + +class VersionCodeProviderTest { + + @Test + fun `Given a version code, When getVersionCode called, Then return that version code`() { + val versionCodeGetter: () -> Int = mockk() + every { versionCodeGetter.invoke() } returns 123 + val provider = VersionCodeProvider(versionCodeGetter = versionCodeGetter) + + val result = provider.getVersionCode() + assertEquals(123, result) + } + + @Test + fun `Given a version name, When getVersionName called, Then return that version name`() { + val versionNameGetter: () -> String = mockk() + every { versionNameGetter.invoke() } returns "1.2.3" + val provider = VersionCodeProvider(versionNameGetter = versionNameGetter) + + val result = provider.getVersionName() + assertEquals("1.2.3", result) + } + + @Test + fun `Given version code and name, When getFormatted called, Then return formatted string`() { + val versionCodeGetter: () -> Int = mockk() + val versionNameGetter: () -> String = mockk() + every { versionCodeGetter.invoke() } returns 456 + every { versionNameGetter.invoke() } returns "2.0.0" + val provider = VersionCodeProvider( + versionCodeGetter = versionCodeGetter, + versionNameGetter = versionNameGetter + ) + + val result = provider.getFormatted() + assertEquals("2.0.0 (456)", result) + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index f1ff0cc4..a0f2aba6 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -33,6 +33,9 @@ mockk = "1.13.13" koin-bom = "4.0.2" koin-annotation-bom = "2.1.0" ksp = "2.0.0-1.0.24" +turbine = "1.2.1" +coroutines = "1.10.2" +slf4j = "1.7.36" [libraries] androidx-core = { module = "androidx.core:core-ktx", version.ref = "androidx-core-ktx" } @@ -117,6 +120,10 @@ koin-compose-viewmodel = { module = "io.insert-koin:koin-compose-viewmodel" } koin-annotation-bom = { module = "io.insert-koin:koin-annotations-bom", version.ref = "koin-annotation-bom" } koin-annotation = { module = "io.insert-koin:koin-annotations" } koin-annotation-compiler = { module = "io.insert-koin:koin-ksp-compiler", version.ref = "koin-annotation-bom" } +turbine = { module = "app.cash.turbine:turbine", version.ref = "turbine" } +kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "coroutines" } +kotlinx-coroutines-tests = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "coroutines" } +slf4j-android = { module = "org.slf4j:slf4j-android", version.ref = "slf4j" } [bundles] androidx-lifecycle = ["androidx-lifecycle-runtime", "androidx-lifecycle-viewmodel", "androidx-lifecycle-viewmodel-compose"]