From d484ff7804420781c0f6015b37e76f3116f2fb00 Mon Sep 17 00:00:00 2001 From: SeniorZhai Date: Thu, 22 Jan 2026 13:21:32 +0800 Subject: [PATCH 01/12] Fix preview build --- .../java/one/mixin/android/compose/Dialogs.kt | 4 + .../compose/InputAmountUsageExample.kt | 0 .../one/mixin/android/compose/theme/Theme.kt | 142 ++++++++++-------- .../android/ui/home/web3/components/Review.kt | 25 ++- 4 files changed, 103 insertions(+), 68 deletions(-) delete mode 100644 app/src/main/java/one/mixin/android/compose/InputAmountUsageExample.kt diff --git a/app/src/main/java/one/mixin/android/compose/Dialogs.kt b/app/src/main/java/one/mixin/android/compose/Dialogs.kt index 4730dcac8d..ac4fd05dfb 100644 --- a/app/src/main/java/one/mixin/android/compose/Dialogs.kt +++ b/app/src/main/java/one/mixin/android/compose/Dialogs.kt @@ -11,6 +11,7 @@ import androidx.compose.runtime.SideEffect import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalInspectionMode import androidx.compose.ui.text.TextStyle import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp @@ -25,6 +26,9 @@ fun IndeterminateProgressDialog( title: String = "", cancelable: Boolean? = null, ) { + if (LocalInspectionMode.current) { + return + } val context = LocalContext.current val activity = context.findFragmentActivityOrNull() diff --git a/app/src/main/java/one/mixin/android/compose/InputAmountUsageExample.kt b/app/src/main/java/one/mixin/android/compose/InputAmountUsageExample.kt deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/app/src/main/java/one/mixin/android/compose/theme/Theme.kt b/app/src/main/java/one/mixin/android/compose/theme/Theme.kt index 5438cc4dbf..eb05de999d 100644 --- a/app/src/main/java/one/mixin/android/compose/theme/Theme.kt +++ b/app/src/main/java/one/mixin/android/compose/theme/Theme.kt @@ -10,19 +10,17 @@ import androidx.compose.material.RippleConfiguration import androidx.compose.material.RippleDefaults import androidx.compose.material.darkColors import androidx.compose.material.lightColors +import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.compositionLocalOf +import androidx.compose.ui.platform.LocalInspectionMode import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.colorspace.ColorSpaces import androidx.compose.ui.platform.LocalContext -import one.mixin.android.MixinApplication -import one.mixin.android.extension.isNightMode import one.mixin.android.extension.isScreenWideColorGamut import one.mixin.android.util.isCurrChinese -val isP3Supported = MixinApplication.appContext.isScreenWideColorGamut() - class AppColors( val primary: Color, val accent: Color, @@ -45,20 +43,8 @@ class AppColors( val walletRed: Color = Color(0xFFF67070), val walletGreen: Color = Color(0xFF50BD5C), val walletOrange: Color = Color(0xFFFFAA00), - val marketRed: Color = if (isP3Supported) Color( - colorSpace = ColorSpaces.DisplayP3, - red = 0.898f, - green = 0.471f, - blue = 0.455f, - alpha = 1f - ) else Color(0xFFE57874), - val marketGreen: Color = if (isP3Supported) Color( - colorSpace = ColorSpaces.DisplayP3, - red = 0.314f, - green = 0.741f, - blue = 0.361f, - alpha = 1f - ) else Color(0xFF50BD5C), + val marketRed: Color, + val marketGreen: Color, val shadow: Color = Color(0x33AAAAAA), val unchecked: Color, val tipWarning: Color, @@ -90,8 +76,73 @@ object MixinAppTheme { } -private val LightColorPalette = - AppColors( +private fun createMarketRedColor(isP3Supported: Boolean): Color { + if (isP3Supported) { + return Color( + colorSpace = ColorSpaces.DisplayP3, + red = 0.898f, + green = 0.471f, + blue = 0.455f, + alpha = 1f, + ) + } + return Color(0xFFE57874) +} + +private fun createMarketGreenColor(isP3Supported: Boolean): Color { + if (isP3Supported) { + return Color( + colorSpace = ColorSpaces.DisplayP3, + red = 0.314f, + green = 0.741f, + blue = 0.361f, + alpha = 1f, + ) + } + return Color(0xFF50BD5C) +} + +private fun createAppColors( + isDarkTheme: Boolean, + isP3Supported: Boolean, +): AppColors { + val marketRed: Color = createMarketRedColor(isP3Supported) + val marketGreen: Color = createMarketGreenColor(isP3Supported) + if (isDarkTheme) { + return AppColors( + primary = Color(0xFF2c3136), + accent = Color(0xFF3D75E3), + textPrimary = Color(0xFFFFFFFF), + textAssist = Color(0xFF7F878F), + textMinor = Color(0xFFD3D4D5), + textRemarks = Color(0xFF6E7073), + icon = Color(0xFFEAEAEB), + iconGray = Color(0xFF808691), + iconAction = Color(0xFFFFFFFF), + backgroundWindow = Color(0xFF23272B), + background = Color(0xFF2c3136), + backgroundDark = Color(0xFF121212), + backgroundGrayLight = Color(0xFF3B3F44), + backgroundGray = Color(0xFF3B3F44), + unchecked = Color(0xFFECECEC), + tipWarning = Color(0xFF3E373B), + tipWarningBorder = Color(0xFFE86B67), + borderPrimary = Color(0x33FFFFFF), + bgGradientStart = Color(0xFF2C3136), + bgGradientEnd = Color(0xFF1C2029), + borderColor = Color(0xFF6E7073), + walletBlue = Color(0xFF64B5F6), + walletYellow = Color(0xFFFFEE58), + walletPurple = Color(0xFFBA68C8), + badgeRed = Color(0xFFF67070), + warning = Color(0xFFF6A417), + bgClip = Color(0xFF3B3F44), + borderGray = Color(0xFFD6D6D6), + marketRed = marketRed, + marketGreen = marketGreen, + ) + } + return AppColors( primary = Color(0xFFFFFFFF), accent = Color(0xFF3D75E3), textPrimary = Color(0xFF000000), @@ -120,53 +171,22 @@ private val LightColorPalette = warning = Color(0xFFF6A417), bgClip = Color(0xFFF5F7FA), borderGray = Color(0xFFD6D6D6), + marketRed = marketRed, + marketGreen = marketGreen, ) +} -private val DarkColorPalette = - AppColors( - primary = Color(0xFF2c3136), - accent = Color(0xFF3D75E3), - textPrimary = Color(0xFFFFFFFF), - textAssist = Color(0xFF7F878F), - textMinor = Color(0xFFD3D4D5), - textRemarks = Color(0xFF6E7073), - icon = Color(0xFFEAEAEB), - iconGray = Color(0xFF808691), - iconAction = Color(0xFFFFFFFF), - backgroundWindow = Color(0xFF23272B), - background = Color(0xFF2c3136), - backgroundDark = Color(0xFF121212), - backgroundGrayLight = Color(0xFF3B3F44), - backgroundGray = Color(0xFF3B3F44), - unchecked = Color(0xFFECECEC), - tipWarning = Color(0xFF3E373B), - tipWarningBorder = Color(0xFFE86B67), - borderPrimary = Color(0x33FFFFFF), - bgGradientStart = Color(0xFF2C3136), - bgGradientEnd = Color(0xFF1C2029), - borderColor = Color(0xFF6E7073), - walletBlue = Color(0xFF64B5F6), - walletYellow = Color(0xFFFFEE58), - walletPurple = Color(0xFFBA68C8), - badgeRed = Color(0xFFF67070), - warning = Color(0xFFF6A417), - bgClip = Color(0xFF3B3F44), - borderGray = Color(0xFFD6D6D6), - ) - -private val LocalColors = compositionLocalOf { LightColorPalette } +private val LocalColors = compositionLocalOf { createAppColors(isDarkTheme = false, isP3Supported = false) } @Composable fun MixinAppTheme( - darkTheme: Boolean = MixinApplication.get().isNightMode(), + darkTheme: Boolean = isSystemInDarkTheme(), content: @Composable () -> Unit, ) { - val colors = - if (darkTheme) { - DarkColorPalette - } else { - LightColorPalette - } + val context = LocalContext.current + val isInPreview: Boolean = LocalInspectionMode.current + val isP3Supported: Boolean = if (isInPreview) false else context.isScreenWideColorGamut() + val colors: AppColors = createAppColors(isDarkTheme = darkTheme, isP3Supported = isP3Supported) val textSelectionColors = TextSelectionColors( handleColor = Color(0xFF3D75E3), @@ -196,8 +216,6 @@ fun MixinAppTheme( @Composable @DrawableRes fun languageBasedImage(@DrawableRes defaultImage:Int, @DrawableRes zh:Int) : Int{ - val context = LocalContext.current - val drawableRes = when { isCurrChinese() -> zh else -> defaultImage diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/components/Review.kt b/app/src/main/java/one/mixin/android/ui/home/web3/components/Review.kt index 7ea6cbed84..c961a93bdb 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/components/Review.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/components/Review.kt @@ -46,6 +46,7 @@ import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.platform.LocalInspectionMode import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp @@ -481,9 +482,15 @@ private fun ApproveChangeItem( private fun SingleBalanceChangeItem( bc: BalanceChange ) { - val viewModel = hiltViewModel() - val priceUsd: String? by viewModel.getTokenPriceUsdFlow(bc.assetId) - .collectAsStateWithLifecycle(initialValue = null) + val isInPreview: Boolean = LocalInspectionMode.current + val priceUsd: String? = if (isInPreview) { + null + } else { + val viewModel: Web3ViewModel = hiltViewModel() + val collectedPriceUsd: String? by viewModel.getTokenPriceUsdFlow(bc.assetId) + .collectAsStateWithLifecycle(initialValue = null) + collectedPriceUsd + } val fiatPrice = bc.formatPrice(priceUsd) Row( @@ -528,9 +535,15 @@ private fun SingleBalanceChangeItem( private fun BalanceChangeItem( balanceChange: BalanceChange, ) { - val viewModel = hiltViewModel() - val priceUsd: String? by viewModel.getTokenPriceUsdFlow(balanceChange.assetId) - .collectAsStateWithLifecycle(initialValue = null) + val isInPreview: Boolean = LocalInspectionMode.current + val priceUsd: String? = if (isInPreview) { + null + } else { + val viewModel: Web3ViewModel = hiltViewModel() + val collectedPriceUsd: String? by viewModel.getTokenPriceUsdFlow(balanceChange.assetId) + .collectAsStateWithLifecycle(initialValue = null) + collectedPriceUsd + } val fiatPrice = balanceChange.formatPrice(priceUsd) Row( modifier = Modifier.fillMaxWidth(), From 39913677e1595b94da16b69995f6b320bb800323 Mon Sep 17 00:00:00 2001 From: SeniorZhai Date: Thu, 22 Jan 2026 14:03:07 +0800 Subject: [PATCH 02/12] Code clean --- .../mixin/android/extension/ViewExtension.kt | 121 ++++++++++-------- .../android/job/AttachmentDownloadJob.kt | 11 +- .../mixin/android/job/RefreshAddressJob.kt | 5 +- .../one/mixin/android/job/SendMessageJob.kt | 3 +- .../job/TranscriptAttachmentDownloadJob.kt | 7 +- .../java/one/mixin/android/net/Diagnosis.kt | 15 ++- .../java/one/mixin/android/pay/Lighting.kt | 5 +- .../ui/media/pager/MediaPagerActivity.kt | 2 + .../TranscriptMediaPagerActivity.kt | 16 ++- 9 files changed, 109 insertions(+), 76 deletions(-) diff --git a/app/src/main/java/one/mixin/android/extension/ViewExtension.kt b/app/src/main/java/one/mixin/android/extension/ViewExtension.kt index fa51328ba7..ae241e1d63 100644 --- a/app/src/main/java/one/mixin/android/extension/ViewExtension.kt +++ b/app/src/main/java/one/mixin/android/extension/ViewExtension.kt @@ -1,6 +1,15 @@ +@file:Suppress( + "unused", + "FunctionName", + "FoldableIfThen", + "IfThenToElvis", + "UNUSED_PARAMETER", +) + package one.mixin.android.extension import android.animation.Animator +import android.animation.AnimatorListenerAdapter import android.animation.ObjectAnimator import android.animation.ValueAnimator import android.app.Activity @@ -35,9 +44,11 @@ import androidx.appcompat.widget.PopupMenu import androidx.core.animation.doOnEnd import androidx.core.animation.doOnStart import androidx.core.graphics.ColorUtils -import androidx.core.view.ViewCompat -import androidx.core.view.ViewPropertyAnimatorListener import androidx.core.view.drawToBitmap +import androidx.core.view.forEach +import androidx.core.view.forEachIndexed +import androidx.core.view.isVisible +import androidx.core.view.marginBottom import androidx.core.view.updateLayoutParams import androidx.navigation.NavController import androidx.navigation.NavOptions @@ -57,13 +68,15 @@ const val ANIMATION_DURATION_SHORT = 260L const val ANIMATION_DURATION_SHORTEST = 120L fun View.hideKeyboard() { - val inputMethodManager = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager + val inputMethodManager: InputMethodManager = + context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager inputMethodManager.hideSoftInputFromWindow(windowToken, 0) } fun View.showKeyboard() { if (requestFocus()) { - val inputMethodManager = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager + val inputMethodManager: InputMethodManager = + context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager inputMethodManager.showSoftInput(this, SHOW_IMPLICIT) } } @@ -78,17 +91,7 @@ fun View.fadeIn( ) { this.visibility = VISIBLE this.alpha = 0f - ViewCompat.animate(this).alpha(maxAlpha).setDuration(duration).setListener( - object : ViewPropertyAnimatorListener { - override fun onAnimationStart(view: View) { - } - - override fun onAnimationEnd(view: View) { - } - - override fun onAnimationCancel(view: View) {} - }, - ).start() + animate().alpha(maxAlpha).setDuration(duration).start() } fun View.fadeOut(isGone: Boolean = false) { @@ -101,21 +104,15 @@ fun View.fadeOut( isGone: Boolean = false, ) { this.alpha = 1f - ViewCompat.animate(this).alpha(0f).setStartDelay(delay).setDuration(duration).setListener( - object : ViewPropertyAnimatorListener { - override fun onAnimationStart(view: View) { - view.isDrawingCacheEnabled = true - } - - override fun onAnimationEnd(view: View) { - view.visibility = if (isGone) GONE else INVISIBLE - view.alpha = 0f - view.isDrawingCacheEnabled = false - } - - override fun onAnimationCancel(view: View) {} - }, - ) + animate().alpha(0f).setStartDelay(delay).setDuration(duration) + .setListener( + object : AnimatorListenerAdapter() { + override fun onAnimationEnd(animation: Animator) { + visibility = if (isGone) GONE else INVISIBLE + alpha = 0f + } + }, + ) } fun View.translationX(value: Float) { @@ -126,7 +123,7 @@ fun View.translationX( value: Float, duration: Long, ) { - ViewCompat.animate(this).setDuration(duration).translationX(value).start() + animate().setDuration(duration).translationX(value).start() } fun View.translationY( @@ -141,18 +138,15 @@ fun View.translationY( duration: Long, endAction: (() -> Unit)? = null, ) { - ViewCompat.animate(this).setDuration(duration).translationY(value) + animate().setDuration(duration).translationY(value) .setListener( - object : ViewPropertyAnimatorListener { - override fun onAnimationEnd(view: View) { - endAction?.let { it() } + object : AnimatorListenerAdapter() { + override fun onAnimationEnd(animation: Animator) { + endAction?.invoke() } - - override fun onAnimationCancel(view: View) { - endAction?.let { it() } + override fun onAnimationCancel(animation: Animator) { + endAction?.invoke() } - - override fun onAnimationStart(view: View) {} }, ) .start() @@ -318,27 +312,32 @@ fun View.circularReveal() { circularReveal.start() } +@Suppress("unused") fun EditText.showCursor() { this.requestFocus() this.isCursorVisible = true } +@Suppress("unused") fun EditText.hideCursor() { this.clearFocus() this.isCursorVisible = false } +@Suppress("unused") fun ViewGroup.inflate( @LayoutRes layoutRes: Int, attachToRoot: Boolean = false, -) = LayoutInflater.from(context).inflate(layoutRes, this, attachToRoot)!! +): View { + return LayoutInflater.from(context).inflate(layoutRes, this, attachToRoot) +} fun View.navigateUp() { try { findNavController().navigateUp() - } catch (e: IllegalArgumentException) { + } catch (_: IllegalArgumentException) { // Workaround with https://issuetracker.google.com/issues/128881182 - } catch (e: IllegalStateException) { + } catch (_: IllegalStateException) { Timber.w("View $this does not have a NavController set") } } @@ -346,10 +345,10 @@ fun View.navigateUp() { fun NavController.safeNavigateUp(): Boolean { return try { navigateUp() - } catch (e: IllegalArgumentException) { + } catch (_: IllegalArgumentException) { // Workaround with https://issuetracker.google.com/issues/128881182 false - } catch (e: IllegalStateException) { + } catch (_: IllegalStateException) { false } } @@ -361,9 +360,9 @@ fun View.navigate( ) { try { findNavController().navigate(resId, bundle, navOptions) - } catch (e: IllegalArgumentException) { + } catch (_: IllegalArgumentException) { // Workaround with https://issuetracker.google.com/issues/128881182 - } catch (e: IllegalStateException) { + } catch (_: IllegalStateException) { Timber.w("View $this does not have a NavController set") } } @@ -400,10 +399,21 @@ fun View.bounce() { spring.endValue = 1.0 } +@Suppress("UnusedReceiverParameter", "FunctionName", "unused") +@Deprecated("Use intProperty", ReplaceWith("intProperty(name, getAction, setAction)")) fun View.IntProperty( name: String, getAction: (View) -> Int, setAction: (View, Int) -> Unit, +): Property { + return intProperty(name = name, getAction = getAction, setAction = setAction) +} + +@Suppress("UnusedReceiverParameter", "unused") +fun View.intProperty( + name: String, + getAction: (View) -> Int, + setAction: (View, Int) -> Unit, ): Property { return object : Property(Int::class.java, name) { override fun get(obj: View): Int { @@ -441,16 +451,17 @@ fun Int.withAlpha(alpha: Float): Int { } fun PopupMenu.showIcon() { - val menuHelper: Any - val argTypes: Array?> + val menuHelper: Any? try { val fMenuHelper: Field = PopupMenu::class.java.getDeclaredField("mPopup") fMenuHelper.isAccessible = true menuHelper = fMenuHelper.get(this) - argTypes = arrayOf(Boolean::class.javaPrimitiveType) - menuHelper.javaClass.getDeclaredMethod("setForceShowIcon", *argTypes) - .invoke(menuHelper, true) - } catch (e: Exception) { + val argTypes: Array?> = arrayOf(Boolean::class.javaPrimitiveType) + if (menuHelper != null) { + menuHelper.javaClass.getDeclaredMethod("setForceShowIcon", *argTypes) + .invoke(menuHelper, true) + } + } catch (_: Exception) { } } @@ -501,7 +512,9 @@ var View.backgroundColor: Int var View.backgroundDrawable: Drawable? inline get() = background - set(value) = setBackgroundDrawable(value) + set(value) { + background = value + } var View.backgroundResource: Int @Deprecated("Property does not have a getter") diff --git a/app/src/main/java/one/mixin/android/job/AttachmentDownloadJob.kt b/app/src/main/java/one/mixin/android/job/AttachmentDownloadJob.kt index f81faeaa10..82f775faea 100644 --- a/app/src/main/java/one/mixin/android/job/AttachmentDownloadJob.kt +++ b/app/src/main/java/one/mixin/android/job/AttachmentDownloadJob.kt @@ -101,13 +101,14 @@ class AttachmentDownloadJob( shareable = this.shareable }.attachmentId } catch (e: Exception) { - message.content!! + requireNotNull(message.content) }, ) val body = attachmentCall!!.execute().body() - if (body != null && (body.isSuccess || !isCancelled) && body.data != null) { - val attachmentResponse = body.data!! - attachmentResponse.view_url?.let { + if (body != null && body.isSuccess && !isCancelled && body.data != null) { + val attachmentResponse = requireNotNull(body.data) + val viewUrl: String? = attachmentResponse.view_url + viewUrl?.let { val result = decryptAttachment(it) if (result) { val attachmentExtra = GsonHelper.customGson.toJson(AttachmentExtra(attachmentResponse.attachment_id, message.messageId, attachmentResponse.created_at, shareable)) @@ -193,7 +194,7 @@ class AttachmentDownloadJob( return true } else if (response.isSuccessful && !isCancelled && response.body != null) { val sink = destination.sink().buffer() - sink.writeAll(response.body!!.source()) + sink.writeAll(requireNotNull(response.body).source()) sink.close() if (message.category.endsWith("_IMAGE")) { val attachmentCipherInputStream = diff --git a/app/src/main/java/one/mixin/android/job/RefreshAddressJob.kt b/app/src/main/java/one/mixin/android/job/RefreshAddressJob.kt index 3b6b06cd33..db5c5fee93 100644 --- a/app/src/main/java/one/mixin/android/job/RefreshAddressJob.kt +++ b/app/src/main/java/one/mixin/android/job/RefreshAddressJob.kt @@ -15,9 +15,8 @@ class RefreshAddressJob(private val chainId: String) : BaseJob( override fun onRun() = runBlocking { val response = tokenService.addresses(chainId) if (response != null && response.isSuccess && response.data != null) { - response.data?.let { - addressDao.insertList(it) - } + val addresses = requireNotNull(response.data) + addressDao.insertList(addresses) } } } diff --git a/app/src/main/java/one/mixin/android/job/SendMessageJob.kt b/app/src/main/java/one/mixin/android/job/SendMessageJob.kt index 5dbaafb16c..7650178967 100644 --- a/app/src/main/java/one/mixin/android/job/SendMessageJob.kt +++ b/app/src/main/java/one/mixin/android/job/SendMessageJob.kt @@ -271,11 +271,12 @@ open class SendMessageJob( } else { message.content!!.toByteArray() } + val participantPublicKey: String = participantSessionKey.publicKey ?: return val encryptContent = encryptedProtocol.encryptMessage( keyPair, plaintext, - participantSessionKey.publicKey!!.base64RawURLDecode(), + participantPublicKey.base64RawURLDecode(), participantSessionKey.sessionId, extensionSessionKey?.publicKey?.base64RawURLDecode(), extensionSessionKey?.sessionId, diff --git a/app/src/main/java/one/mixin/android/job/TranscriptAttachmentDownloadJob.kt b/app/src/main/java/one/mixin/android/job/TranscriptAttachmentDownloadJob.kt index dbb2f1dff5..f7dc1085eb 100644 --- a/app/src/main/java/one/mixin/android/job/TranscriptAttachmentDownloadJob.kt +++ b/app/src/main/java/one/mixin/android/job/TranscriptAttachmentDownloadJob.kt @@ -87,8 +87,9 @@ class TranscriptAttachmentDownloadJob( } attachmentCall = conversationApi.getAttachment(attachmentId) val body = attachmentCall!!.execute().body() - if (body != null && (body.isSuccess || !isCancelled) && body.data != null) { - val viewUrl = body.data?.view_url + if (body != null && body.isSuccess && !isCancelled && body.data != null) { + val attachmentResponse = body.data + val viewUrl = attachmentResponse?.view_url if (viewUrl != null) { if (decryptAttachment(viewUrl, transcriptMessage)) { processTranscript() @@ -162,7 +163,7 @@ class TranscriptAttachmentDownloadJob( return true } else if (response.isSuccessful && !isCancelled && response.body != null) { val sink = destination.sink().buffer() - sink.writeAll(response.body!!.source()) + sink.writeAll(requireNotNull(response.body).source()) sink.close() when { transcriptMessage.type.endsWith("_IMAGE") -> { diff --git a/app/src/main/java/one/mixin/android/net/Diagnosis.kt b/app/src/main/java/one/mixin/android/net/Diagnosis.kt index 7d07ab52db..49a58e5600 100644 --- a/app/src/main/java/one/mixin/android/net/Diagnosis.kt +++ b/app/src/main/java/one/mixin/android/net/Diagnosis.kt @@ -133,13 +133,16 @@ private fun getExportIp( val client = OkHttpClient() var ipRequest = Request.Builder().url(EXPORT_IP_PRIMARY).build() try { - var data = - client.newCall(ipRequest).execute().body?.string() - ?: throw IOException("EXPORT_IP_PRIMARY no data") + var data: String = client.newCall(ipRequest).execute().body.string() + if (data.isBlank()) { + throw IOException("EXPORT_IP_PRIMARY no data") + } val url = data.substringIgnoreError(data.indexOf("src=") + 4, data.lastIndexOf("frameborder")).replace("'".toRegex(), "").replace(" ".toRegex(), "") ipRequest = Request.Builder().url(url).build() - data = client.newCall(ipRequest).execute().body?.string() - ?: throw IOException("EXPORT_IP_PRIMARY no data") + data = client.newCall(ipRequest).execute().body.string() + if (data.isBlank()) { + throw IOException("EXPORT_IP_PRIMARY no data") + } val dataIp = data.substringIgnoreError(data.indexOf("您的IP地址信息") + 10) val dataAddress = dataIp.substringIgnoreError(0, dataIp.indexOf("
")) val ips = dataAddress.split(" ").toTypedArray() @@ -149,7 +152,7 @@ private fun getExportIp( Timber.i("Get export ip from $EXPORT_IP_PRIMARY meet ${e.localizedMessage}") try { ipRequest = Request.Builder().url(EXPORT_IP_SECONDARY).build() - val exportIp = client.newCall(ipRequest).execute().body?.string() + val exportIp: String = client.newCall(ipRequest).execute().body.string() result.append("${context.getString(R.string.export_ip)}: $exportIp") } catch (e: Exception) { Timber.i("Get export ip from $EXPORT_IP_SECONDARY meet ${e.localizedMessage}") diff --git a/app/src/main/java/one/mixin/android/pay/Lighting.kt b/app/src/main/java/one/mixin/android/pay/Lighting.kt index ca130846bd..370daaa348 100644 --- a/app/src/main/java/one/mixin/android/pay/Lighting.kt +++ b/app/src/main/java/one/mixin/android/pay/Lighting.kt @@ -17,8 +17,9 @@ internal suspend fun parseLightning( parseLighting: suspend (String) -> PaymentResponse? ): ExternalTransfer? { val r = parseLighting(url) ?: return null - val assetId = r.asset?.assetId ?:return null - val chainId = r.asset?.chainId ?:return null + val asset = r.asset ?: return null + val assetId = asset.assetId ?: return null + val chainId = asset.chainId ?: return null val destination = r.destination ?: return null val addressResponse = validateAddress(assetId, chainId, destination) ?: return null diff --git a/app/src/main/java/one/mixin/android/ui/media/pager/MediaPagerActivity.kt b/app/src/main/java/one/mixin/android/ui/media/pager/MediaPagerActivity.kt index 8d406cced3..fdd51e1c66 100644 --- a/app/src/main/java/one/mixin/android/ui/media/pager/MediaPagerActivity.kt +++ b/app/src/main/java/one/mixin/android/ui/media/pager/MediaPagerActivity.kt @@ -1,3 +1,5 @@ +@file:Suppress("DEPRECATION") + package one.mixin.android.ui.media.pager import android.Manifest diff --git a/app/src/main/java/one/mixin/android/ui/media/pager/transcript/TranscriptMediaPagerActivity.kt b/app/src/main/java/one/mixin/android/ui/media/pager/transcript/TranscriptMediaPagerActivity.kt index e06a1bd597..87b1d27882 100644 --- a/app/src/main/java/one/mixin/android/ui/media/pager/transcript/TranscriptMediaPagerActivity.kt +++ b/app/src/main/java/one/mixin/android/ui/media/pager/transcript/TranscriptMediaPagerActivity.kt @@ -593,7 +593,7 @@ class TranscriptMediaPagerActivity : BaseActivity(), DismissFrameLayout.OnDismis private fun dismiss() { binding.viewPager.visibility = View.INVISIBLE - overridePendingTransition(0, 0) + setExitTransition(enterAnim = 0, exitAnim = 0) super.finish() } @@ -750,7 +750,19 @@ class TranscriptMediaPagerActivity : BaseActivity(), DismissFrameLayout.OnDismis override fun finish() { super.finish() - overridePendingTransition(0, R.anim.scale_out) + setExitTransition(enterAnim = 0, exitAnim = R.anim.scale_out) + } + + private fun setExitTransition( + enterAnim: Int, + exitAnim: Int, + ) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + overrideActivityTransition(Activity.OVERRIDE_TRANSITION_CLOSE, enterAnim, exitAnim) + return + } + @Suppress("DEPRECATION") + overridePendingTransition(enterAnim, exitAnim) } private val mediaPagerAdapterListener = From cf7a6707dd169091929598e8055561d2b96e38fc Mon Sep 17 00:00:00 2001 From: SeniorZhai Date: Thu, 12 Mar 2026 10:55:46 +0800 Subject: [PATCH 03/12] fix: resolve Compose @Preview rendering issues --- .../java/one/mixin/android/compose/AppBar.kt | 36 +++++---- .../compose/AuthBottomSheetDialogCompose.kt | 54 +++++++------ .../android/ui/auth/compose/PinKeyBoard.kt | 22 +++-- .../home/inscription/component/ShareCard.kt | 16 ++-- .../ui/home/web3/components/InputAction.kt | 5 +- .../android/ui/home/web3/components/Review.kt | 80 ++++++++++++------- .../ui/home/web3/components/SlippageInfo.kt | 9 ++- .../android/ui/home/web3/stake/StakePage.kt | 10 ++- .../ui/home/web3/stake/ValidatorsPage.kt | 10 ++- .../ui/home/web3/trade/SwapSlippagePage.kt | 32 ++++---- .../ui/home/web3/trade/SwapTokenPage.kt | 4 +- .../landing/components/CreateAccountPage.kt | 6 +- .../landing/components/MnemonicPhraseInput.kt | 29 +++---- .../setting/ui/page/MixinMemberUpgradePage.kt | 38 ++++++--- .../ui/tip/wc/sessionproposal/WCPinBoard.kt | 22 +++-- .../wc/sessionrequest/SessionRequestPage.kt | 18 +++-- .../ui/transfer/compose/SelectDatePage.kt | 4 +- .../wallet/components/AssetDashboardScreen.kt | 12 +-- 18 files changed, 250 insertions(+), 157 deletions(-) diff --git a/app/src/main/java/one/mixin/android/compose/AppBar.kt b/app/src/main/java/one/mixin/android/compose/AppBar.kt index c8b26934df..d8f8c36c48 100644 --- a/app/src/main/java/one/mixin/android/compose/AppBar.kt +++ b/app/src/main/java/one/mixin/android/compose/AppBar.kt @@ -110,21 +110,23 @@ fun MixinTopAppBar( @Preview @Composable fun PreviewMixinAppBar() { - MixinTopAppBar( - navigationIcon = { - MixinBackButton() - }, - title = { - Text(text = "Title") - }, - actions = { - IconButton(onClick = { }) { - Icon( - painter = painterResource(id = R.drawable.ic_more), - contentDescription = null, - tint = MixinAppTheme.colors.icon, - ) - } - }, - ) + MixinAppTheme { + MixinTopAppBar( + navigationIcon = { + MixinBackButton() + }, + title = { + Text(text = "Title") + }, + actions = { + IconButton(onClick = { }) { + Icon( + painter = painterResource(id = R.drawable.ic_more), + contentDescription = null, + tint = MixinAppTheme.colors.icon, + ) + } + }, + ) + } } diff --git a/app/src/main/java/one/mixin/android/ui/auth/compose/AuthBottomSheetDialogCompose.kt b/app/src/main/java/one/mixin/android/ui/auth/compose/AuthBottomSheetDialogCompose.kt index 2e5c326990..3cbc91cdeb 100644 --- a/app/src/main/java/one/mixin/android/ui/auth/compose/AuthBottomSheetDialogCompose.kt +++ b/app/src/main/java/one/mixin/android/ui/auth/compose/AuthBottomSheetDialogCompose.kt @@ -375,30 +375,32 @@ fun Modifier.verticalScrollbar( @Composable @Preview fun AuthBottomSheetDialogComposePreview() { - val context = LocalContext.current - AuthBottomSheetDialogCompose( - name = "Team Mixin", - iconUrl = "https://mixin-images.zeromesh.net/E2y0BnTopFK9qey0YI-8xV3M82kudNnTaGw0U5SU065864SsewNUo6fe9kDF1HIzVYhXqzws4lBZnLj1lPsjk-0=s256", - scopes = - listOf( - Scope.generateScopeFromString(context, "PROFILE:READ"), - Scope.generateScopeFromString(context, "PHONE:READ"), - Scope.generateScopeFromString(context, "MESSAGES:REPRESENT"), - Scope.generateScopeFromString(context, "CONTACTS:READ"), - Scope.generateScopeFromString(context, "ASSETS:READ"), - Scope.generateScopeFromString(context, "SNAPSHOTS:READ"), - Scope.generateScopeFromString(context, "APPS:READ"), - Scope.generateScopeFromString(context, "APPS:WRITE"), - Scope.generateScopeFromString(context, "CIRCLES:READ"), - Scope.generateScopeFromString(context, "CIRCLES:WRITE"), - Scope.generateScopeFromString(context, "COLLECTIBLES:READ"), - ), - {}, - AuthStep.INPUT, - "", - {}, - {}, - {}, - null, - ) + MixinAppTheme { + val context = LocalContext.current + AuthBottomSheetDialogCompose( + name = "Team Mixin", + iconUrl = "https://mixin-images.zeromesh.net/E2y0BnTopFK9qey0YI-8xV3M82kudNnTaGw0U5SU065864SsewNUo6fe9kDF1HIzVYhXqzws4lBZnLj1lPsjk-0=s256", + scopes = + listOf( + Scope.generateScopeFromString(context, "PROFILE:READ"), + Scope.generateScopeFromString(context, "PHONE:READ"), + Scope.generateScopeFromString(context, "MESSAGES:REPRESENT"), + Scope.generateScopeFromString(context, "CONTACTS:READ"), + Scope.generateScopeFromString(context, "ASSETS:READ"), + Scope.generateScopeFromString(context, "SNAPSHOTS:READ"), + Scope.generateScopeFromString(context, "APPS:READ"), + Scope.generateScopeFromString(context, "APPS:WRITE"), + Scope.generateScopeFromString(context, "CIRCLES:READ"), + Scope.generateScopeFromString(context, "CIRCLES:WRITE"), + Scope.generateScopeFromString(context, "COLLECTIBLES:READ"), + ), + {}, + AuthStep.INPUT, + "", + {}, + {}, + {}, + null, + ) + } } diff --git a/app/src/main/java/one/mixin/android/ui/auth/compose/PinKeyBoard.kt b/app/src/main/java/one/mixin/android/ui/auth/compose/PinKeyBoard.kt index 5fcb1364bc..535f96dae0 100644 --- a/app/src/main/java/one/mixin/android/ui/auth/compose/PinKeyBoard.kt +++ b/app/src/main/java/one/mixin/android/ui/auth/compose/PinKeyBoard.kt @@ -55,6 +55,7 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalInspectionMode import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign @@ -83,14 +84,19 @@ fun PinKeyBoard( onVerifyRequest: ((String) -> Unit)?, ) { val context = LocalContext.current + val isInPreview = LocalInspectionMode.current // val open = context.defaultSharedPreferences.getBoolean(Constants.Account.PREF_BIOMETRICS, false) // val biometricEnable = !open && BiometricUtil.isSupport(context) - val showBiometric = BiometricUtil.shouldShowBiometric(context) - val randomKeyboardEnabled by LocalContext.current.defaultSharedPreferences - .booleanValueAsState( - key = Constants.Account.PREF_RANDOM, - defaultValue = false, - ) + val showBiometric = if (isInPreview) false else BiometricUtil.shouldShowBiometric(context) + val randomKeyboardEnabled by if (isInPreview) { + remember { mutableStateOf(false) } + } else { + LocalContext.current.defaultSharedPreferences + .booleanValueAsState( + key = Constants.Account.PREF_RANDOM, + defaultValue = false, + ) + } val list = if (randomKeyboardEnabled) { mutableListOf("1", "2", "3", "4", "5", "6", "7", "8", "9", "0").apply { @@ -426,5 +432,7 @@ fun PinKeyBoard( @Preview @Composable fun PinKeyBoardPreview() { - PinKeyBoard(AuthStep.INPUT, "", {}, null, null) + MixinAppTheme { + PinKeyBoard(AuthStep.INPUT, "", {}, null, null) + } } diff --git a/app/src/main/java/one/mixin/android/ui/home/inscription/component/ShareCard.kt b/app/src/main/java/one/mixin/android/ui/home/inscription/component/ShareCard.kt index a265a07ae3..cd2936b31f 100644 --- a/app/src/main/java/one/mixin/android/ui/home/inscription/component/ShareCard.kt +++ b/app/src/main/java/one/mixin/android/ui/home/inscription/component/ShareCard.kt @@ -143,12 +143,14 @@ fun ShareCard(modifier: Modifier, qrcode: Bitmap, inscriptionHash: String, value @Preview @Composable private fun DashedDividerPreview() { - DashedDivider( - thickness = 1.dp, - modifier = Modifier - .fillMaxWidth() - .padding(16.dp) - ) + MixinAppTheme { + DashedDivider( + thickness = 1.dp, + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + ) + } } @Composable @@ -174,4 +176,4 @@ fun DashedDivider( ) ) } -} \ No newline at end of file +} diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/components/InputAction.kt b/app/src/main/java/one/mixin/android/ui/home/web3/components/InputAction.kt index 5069707816..3087a33cce 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/components/InputAction.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/components/InputAction.kt @@ -76,6 +76,7 @@ fun InputAction( @Preview @Composable fun PreviewInputActionMax() { - InputAction("MAX") {} + MixinAppTheme { + InputAction("MAX") {} + } } - diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/components/Review.kt b/app/src/main/java/one/mixin/android/ui/home/web3/components/Review.kt index c961a93bdb..09e6ee1435 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/components/Review.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/components/Review.kt @@ -675,9 +675,10 @@ private fun Item( @Preview @Composable fun PreviewMessage() { - Box(modifier = Modifier.background(MixinAppTheme.colors.background)) { - MessagePreview( - content = """{ + MixinAppTheme { + Box(modifier = Modifier.background(MixinAppTheme.colors.background)) { + MessagePreview( + content = """{ "raw": [ "0x9df67f5a05fb594c4357d87221cbd69f1d5a6fbb", "{\"types\":{\"Alias\":[{\"name\":\"from\",\"type\":\"address\"},{\"name\":\"alias\",\"type\":\"address\"},{\"name\":\"timestamp\",\"type\":\"uint64\"}],\"EIP712Domain\":[{\"name\":\"name\",\"type\":\"string\"},{\"name\":\"version\",\"type\":\"string\"}]},\"domain\":{\"name\":\"snapshot\",\"version\":\"0.1.4\"},\"primaryType\":\"Alias\",\"message\":{\"from\":\"0x9df67f5a05fb594c4357d87221cbd69f1d5a6fbb\",\"alias\":\"0x8f14e8dbc7b3619e5210201022f637f271545c90\",\"timestamp\":\"1710766295\"}}" @@ -685,7 +686,8 @@ fun PreviewMessage() { "type": "TYPED_MESSAGE" } """, - ) { + ) { + } } } } @@ -693,27 +695,31 @@ fun PreviewMessage() { @Preview @Composable private fun TransactionPreview() { - TransactionPreview(balance = BigDecimal(0.134), chain = Chain.Ethereum, null) + MixinAppTheme { + TransactionPreview(balance = BigDecimal(0.134), chain = Chain.Ethereum, null) + } } @Preview @Composable private fun WarningPreview() { - Box( - modifier = - Modifier - .fillMaxWidth() - .height(300.dp), - ) { - ActionBottom( - modifier = Modifier.align(Alignment.BottomCenter), - cancelTitle = stringResource(id = R.string.Cancel), - confirmTitle = stringResource(id = R.string.Confirm), - cancelAction = { }, + MixinAppTheme { + Box( + modifier = + Modifier + .fillMaxWidth() + .height(300.dp), ) { - } + ActionBottom( + modifier = Modifier.align(Alignment.BottomCenter), + cancelTitle = stringResource(id = R.string.Cancel), + confirmTitle = stringResource(id = R.string.Confirm), + cancelAction = { }, + ) { + } - Warning(modifier = Modifier.align(Alignment.BottomCenter)) + Warning(modifier = Modifier.align(Alignment.BottomCenter)) + } } } @@ -788,9 +794,11 @@ fun ActionBottom( @Preview @Composable fun TransferBottomPreview() { - Column { - ActionBottom(modifier = Modifier, stringResource(id = R.string.Cancel), stringResource(id = R.string.Confirm), {}, {}) - ActionBottom(modifier = Modifier, stringResource(id = R.string.Discard), stringResource(id = R.string.Send), {}, {}) + MixinAppTheme { + Column { + ActionBottom(modifier = Modifier, stringResource(id = R.string.Cancel), stringResource(id = R.string.Confirm), {}, {}) + ActionBottom(modifier = Modifier, stringResource(id = R.string.Discard), stringResource(id = R.string.Send), {}, {}) + } } } @@ -804,7 +812,9 @@ fun BalanceChangePreview() { @Preview @Composable fun ItemPreview() { - Item(Item("Compute Unit Limit", "1400000 compute units")) + MixinAppTheme { + Item(Item("Compute Unit Limit", "1400000 compute units")) + } } @Preview @@ -822,13 +832,17 @@ fun SolanaParsedTxPreviewPreview() { @Preview @Composable fun InstructionPreview() { - Instruction(ParsedInstruction("", "", "", info = "cannot decode instruction for Eo7WjKq67rjJQSZxS6z3YkapzY3eMj6Xy8X5EQVn5UaB")) + MixinAppTheme { + Instruction(ParsedInstruction("", "", "", info = "cannot decode instruction for Eo7WjKq67rjJQSZxS6z3YkapzY3eMj6Xy8X5EQVn5UaB")) + } } @Preview @Composable fun SolanaParsedTxNullPreview() { - ParsedTxPreview(parsedTx = null, asset = null, solanaTxSource = SolanaTxSource.Web) + MixinAppTheme { + ParsedTxPreview(parsedTx = null, asset = null, solanaTxSource = SolanaTxSource.Web) + } } @Preview @@ -836,7 +850,9 @@ fun SolanaParsedTxNullPreview() { fun SolanaParsedTxInstructionNullPreview() { val data = """{"instructions":[]}""" val parsedTx = GsonHelper.customGson.fromJson(data, ParsedTx::class.java) - ParsedTxPreview(parsedTx = parsedTx, asset = null, solanaTxSource = SolanaTxSource.Web) + MixinAppTheme { + ParsedTxPreview(parsedTx = parsedTx, asset = null, solanaTxSource = SolanaTxSource.Web) + } } @Preview @@ -844,7 +860,9 @@ fun SolanaParsedTxInstructionNullPreview() { fun SolanaParsedTxBalanceChangeNullWebPreview() { val data = """{"instructions":[{"program_id":"ComputeBudget111111111111111111111111111111","program_name":"ComputeBudget","instruction_name":"SetComputeUnitLimit","items":[{"key":"Compute Unit Limit","value":"600000 compute units"}]},{"program_id":"ComputeBudget111111111111111111111111111111","program_name":"ComputeBudget","instruction_name":"SetComputeUnitPrice","items":[{"key":"Compute Unit Price","value":"0.1 lamports per compute unit"}]},{"program_id":"ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL","program_name":"AssociatedTokenAccount","instruction_name":"Create"},{"program_id":"11111111111111111111111111111111","program_name":"System","instruction_name":"Transfer","items":[{"key":"Transfer Amount (SOL)","value":"0.01"}]},{"program_id":"TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA","program_name":"Token","instruction_name":"SyncNative"},{"program_id":"JUP6LkbZbjS1jKKwapdHNy74zcZ3tLUZoi5QNyVTaV4","program_name":"Jupiter","instruction_name":"Route","items":[{"key":"Route Plan","value":""},{"key":"In Amount","value":"824635312696"},{"key":"Quoted Out Amount","value":"824635312704"},{"key":"Slippage Bps","value":"824635312712"},{"key":"Platform Fee Bps","value":"50"}],"token_changes":[{"address":"So11111111111111111111111111111111111111112","amount":10000000,"is_pay":true},{"address":"EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v","amount":1323264,"is_pay":false}]},{"program_id":"TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA","program_name":"Token","instruction_name":"CloseAccount"}]}""" val parsedTx = GsonHelper.customGson.fromJson(data, ParsedTx::class.java) - ParsedTxPreview(parsedTx = parsedTx, asset = null, solanaTxSource = SolanaTxSource.Web) + MixinAppTheme { + ParsedTxPreview(parsedTx = parsedTx, asset = null, solanaTxSource = SolanaTxSource.Web) + } } @Preview @@ -852,7 +870,9 @@ fun SolanaParsedTxBalanceChangeNullWebPreview() { fun SolanaParsedTxBalanceChangeNullInnerPreview() { val data = """{"instructions":[{"program_id":"ComputeBudget111111111111111111111111111111","program_name":"ComputeBudget","instruction_name":"SetComputeUnitLimit","items":[{"key":"Compute Unit Limit","value":"600000 compute units"}]},{"program_id":"ComputeBudget111111111111111111111111111111","program_name":"ComputeBudget","instruction_name":"SetComputeUnitPrice","items":[{"key":"Compute Unit Price","value":"0.1 lamports per compute unit"}]},{"program_id":"ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL","program_name":"AssociatedTokenAccount","instruction_name":"Create"},{"program_id":"11111111111111111111111111111111","program_name":"System","instruction_name":"Transfer","items":[{"key":"Transfer Amount (SOL)","value":"0.01"}]},{"program_id":"TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA","program_name":"Token","instruction_name":"SyncNative"},{"program_id":"JUP6LkbZbjS1jKKwapdHNy74zcZ3tLUZoi5QNyVTaV4","program_name":"Jupiter","instruction_name":"Route","items":[{"key":"Route Plan","value":""},{"key":"In Amount","value":"824635312696"},{"key":"Quoted Out Amount","value":"824635312704"},{"key":"Slippage Bps","value":"824635312712"},{"key":"Platform Fee Bps","value":"50"}],"token_changes":[{"address":"So11111111111111111111111111111111111111112","amount":10000000,"is_pay":true},{"address":"EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v","amount":1323264,"is_pay":false}]},{"program_id":"TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA","program_name":"Token","instruction_name":"CloseAccount"}]}""" val parsedTx = GsonHelper.customGson.fromJson(data, ParsedTx::class.java) - ParsedTxPreview(parsedTx = parsedTx, asset = null, solanaTxSource = SolanaTxSource.InnerSwap) + MixinAppTheme { + ParsedTxPreview(parsedTx = parsedTx, asset = null, solanaTxSource = SolanaTxSource.InnerSwap) + } } @Preview @@ -860,5 +880,7 @@ fun SolanaParsedTxBalanceChangeNullInnerPreview() { fun SolanaParsedTxTokenNullPreview() { val data = """{"balance_changes":[{"address":"So11111111111111111111111111111111111111112","amount":-10000000},{"address":"EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v","amount":1323264}],"instructions":[{"program_id":"ComputeBudget111111111111111111111111111111","program_name":"ComputeBudget","instruction_name":"SetComputeUnitLimit","items":[{"key":"Compute Unit Limit","value":"600000 compute units"}]},{"program_id":"ComputeBudget111111111111111111111111111111","program_name":"ComputeBudget","instruction_name":"SetComputeUnitPrice","items":[{"key":"Compute Unit Price","value":"0.1 lamports per compute unit"}]},{"program_id":"ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL","program_name":"AssociatedTokenAccount","instruction_name":"Create"},{"program_id":"11111111111111111111111111111111","program_name":"System","instruction_name":"Transfer","items":[{"key":"Transfer Amount (SOL)","value":"0.01"}]},{"program_id":"TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA","program_name":"Token","instruction_name":"SyncNative"},{"program_id":"JUP6LkbZbjS1jKKwapdHNy74zcZ3tLUZoi5QNyVTaV4","program_name":"Jupiter","instruction_name":"Route","items":[{"key":"Route Plan","value":""},{"key":"In Amount","value":"824635312696"},{"key":"Quoted Out Amount","value":"824635312704"},{"key":"Slippage Bps","value":"824635312712"},{"key":"Platform Fee Bps","value":"50"}],"token_changes":[{"address":"So11111111111111111111111111111111111111112","amount":10000000,"is_pay":true},{"address":"EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v","amount":1323264,"is_pay":false}]},{"program_id":"TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA","program_name":"Token","instruction_name":"CloseAccount"}]}""" val parsedTx = GsonHelper.customGson.fromJson(data, ParsedTx::class.java) - ParsedTxPreview(parsedTx = parsedTx, asset = null, solanaTxSource = SolanaTxSource.InnerSwap) -} \ No newline at end of file + MixinAppTheme { + ParsedTxPreview(parsedTx = parsedTx, asset = null, solanaTxSource = SolanaTxSource.InnerSwap) + } +} diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/components/SlippageInfo.kt b/app/src/main/java/one/mixin/android/ui/home/web3/components/SlippageInfo.kt index 5f91c2bfb8..4f6e6fa25c 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/components/SlippageInfo.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/components/SlippageInfo.kt @@ -112,12 +112,15 @@ private fun SlippageInfo( @Preview @Composable fun PreviewSlippageInfo() { - SlippageInfo(slippageBps = 50, true) {} + MixinAppTheme { + SlippageInfo(slippageBps = 50, true) {} + } } @Preview @Composable fun PreviewSlippageInfoWarning() { - SlippageInfo(slippageBps = 600, true) {} + MixinAppTheme { + SlippageInfo(slippageBps = 600, true) {} + } } - diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/stake/StakePage.kt b/app/src/main/java/one/mixin/android/ui/home/web3/stake/StakePage.kt index 0bef8c3a78..cd3eb4731b 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/stake/StakePage.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/stake/StakePage.kt @@ -344,11 +344,15 @@ fun Item( @Preview @Composable private fun InputPreview() { - Input(text = "123") {} + MixinAppTheme { + Input(text = "123") {} + } } @Preview @Composable private fun ValidatorInfoPreview() { - ValidatorInfo(Validator("J2nUHEAgZFRyuJbFjdqPrAa9gyWDuc7hErtDQHPhsYRp", "Mixin Validator", "", "", "", "https://raw.githubusercontent.com/solana-labs/token-list/main/assets/mainnet/So11111111111111111111111111111111111111112/logo.png", "", 123123131231231, 9, 123124, 123123)) -} \ No newline at end of file + MixinAppTheme { + ValidatorInfo(Validator("J2nUHEAgZFRyuJbFjdqPrAa9gyWDuc7hErtDQHPhsYRp", "Mixin Validator", "", "", "", "https://raw.githubusercontent.com/solana-labs/token-list/main/assets/mainnet/So11111111111111111111111111111111111111112/logo.png", "", 123123131231231, 9, 123124, 123123)) + } +} diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/stake/ValidatorsPage.kt b/app/src/main/java/one/mixin/android/ui/home/web3/stake/ValidatorsPage.kt index 02c47c432c..d926254b40 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/stake/ValidatorsPage.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/stake/ValidatorsPage.kt @@ -221,11 +221,15 @@ private fun SearchInput( @Composable fun PreviewValidatorItem() { val validator = Validator("J2nUHEAgZFRyuJbFjdqPrAa9gyWDuc7hErtDQHPhsYRp", "Mixin Validator", "", "", "", "https://raw.githubusercontent.com/solana-labs/token-list/main/assets/mainnet/So11111111111111111111111111111111111111112/logo.png", "", 123123131231231, 9, 123124, 123123) - ValidatorItem(validator) { } + MixinAppTheme { + ValidatorItem(validator) { } + } } @Preview @Composable fun PreviewSearchInput() { - SearchInput("") -} \ No newline at end of file + MixinAppTheme { + SearchInput("") + } +} diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/SwapSlippagePage.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/SwapSlippagePage.kt index 21d6a65509..50ec7baade 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/SwapSlippagePage.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/SwapSlippagePage.kt @@ -282,23 +282,27 @@ private fun String.isSlippageValid(): Boolean { @Preview @Composable fun PreviewAuto() { - Auto( - auto = - remember { - mutableStateOf(true) - }, - originAuto = true, - originBps = 80, - ) + MixinAppTheme { + Auto( + auto = + remember { + mutableStateOf(true) + }, + originAuto = true, + originBps = 80, + ) + } } @Preview @Composable fun PreviewCustom() { - Custom( - bps = - remember { - mutableStateOf("50") - }, - ) + MixinAppTheme { + Custom( + bps = + remember { + mutableStateOf("50") + }, + ) + } } diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/SwapTokenPage.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/SwapTokenPage.kt index cd17c5ffec..b888c68c0d 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/SwapTokenPage.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/SwapTokenPage.kt @@ -133,6 +133,8 @@ fun SwapTokenPage( @Preview(widthDp = 300) @Composable fun SwapTokenPagePreView() { - SwapTokenPage(token = SwapToken("","1111111111111111111111111", "", 9, "Solana", "SOL", "", SwapChain("", "Solana", "SOL", "", ""))) { + MixinAppTheme { + SwapTokenPage(token = SwapToken("", "1111111111111111111111111", "", 9, "Solana", "SOL", "", SwapChain("", "Solana", "SOL", "", ""))) { + } } } diff --git a/app/src/main/java/one/mixin/android/ui/landing/components/CreateAccountPage.kt b/app/src/main/java/one/mixin/android/ui/landing/components/CreateAccountPage.kt index 338aaef73b..1158d763eb 100644 --- a/app/src/main/java/one/mixin/android/ui/landing/components/CreateAccountPage.kt +++ b/app/src/main/java/one/mixin/android/ui/landing/components/CreateAccountPage.kt @@ -126,5 +126,7 @@ fun CreateItem(@DrawableRes iconId: Int, @StringRes titleId: Int, @StringRes sub @Preview @Composable fun CreateAccountPagePreview() { - CreateAccountPage({},{}, {}, {}, {}, {}) -} \ No newline at end of file + MixinAppTheme { + CreateAccountPage({}, {}, {}, {}, {}, {}) + } +} diff --git a/app/src/main/java/one/mixin/android/ui/landing/components/MnemonicPhraseInput.kt b/app/src/main/java/one/mixin/android/ui/landing/components/MnemonicPhraseInput.kt index daa5e65ced..989cead5b3 100644 --- a/app/src/main/java/one/mixin/android/ui/landing/components/MnemonicPhraseInput.kt +++ b/app/src/main/java/one/mixin/android/ui/landing/components/MnemonicPhraseInput.kt @@ -18,11 +18,9 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width -import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.BasicTextField import androidx.compose.foundation.text.KeyboardActions @@ -52,6 +50,7 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.platform.LocalInspectionMode import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource @@ -67,7 +66,6 @@ import androidx.compose.ui.unit.TextUnit import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel -import com.checkout.threedsobfuscation.le import kotlinx.coroutines.launch import one.mixin.android.Constants import one.mixin.android.MixinApp @@ -128,11 +126,12 @@ fun MnemonicPhraseInput( } } + val isInPreview = LocalInspectionMode.current var loading by remember { mutableStateOf(false) } var errorInfo by remember { mutableStateOf("") } val context = LocalContext.current val keyboardController = LocalSoftwareKeyboardController.current - val walletViewModel = hiltViewModel() + val walletViewModel = if (isInPreview) null else hiltViewModel() val coroutineScope = rememberCoroutineScope() val focusManager = LocalFocusManager.current var currentText by remember { mutableStateOf("") } @@ -491,11 +490,11 @@ fun MnemonicPhraseInput( val selfId = Session.getAccountId()!! val seed = tip?.getOrRecoverTipPriv(context, pin!!)?.getOrThrow() val edKey = tip?.getMnemonicEdKey(context, pin!!, seed!!) - val r = walletViewModel.saltExport( + val r = requireNotNull(walletViewModel).saltExport( ExportRequest( publicKey = edKey!!.publicKey.toHex(), signature = initFromSeedAndSign(edKey.privateKey, selfId.toByteArray()).toHex(), - pinBase64 = walletViewModel.getEncryptedTipBody(selfId, pin!!), + pinBase64 = requireNotNull(walletViewModel).getEncryptedTipBody(selfId, pin!!), ) ) r.data?.let { @@ -740,11 +739,11 @@ fun MnemonicPhraseInput( val selfId = Session.getAccountId()!! val seed = tip?.getOrRecoverTipPriv(context, pin!!)?.getOrThrow() val edKey = tip?.getMnemonicEdKey(context, pin!!, seed!!) - val r = walletViewModel.saltExport( + val r = requireNotNull(walletViewModel).saltExport( ExportRequest( publicKey = edKey!!.publicKey.toHex(), signature = initFromSeedAndSign(edKey.privateKey, selfId.toByteArray()).toHex(), - pinBase64 = walletViewModel.getEncryptedTipBody(selfId, pin!!), + pinBase64 = requireNotNull(walletViewModel).getEncryptedTipBody(selfId, pin!!), ) ) r.data?.let { @@ -951,11 +950,13 @@ fun InputBar(string: String, callback: (String) -> Unit) { @Preview(backgroundColor = 0xFFFFFFFF, showSystemUi = true) @Composable fun MnemonicPhraseInputPreview() { - MnemonicPhraseInput( - state = MnemonicState.Input, - onScan = {}, - onComplete = { mnemonicList -> /* Handle mnemonic change */ }, - ) + MixinAppTheme { + MnemonicPhraseInput( + state = MnemonicState.Input, + onScan = {}, + onComplete = { }, + ) + } } @Composable @@ -993,4 +994,4 @@ fun BulletPointText( modifier = Modifier.weight(1f) ) } -} \ No newline at end of file +} diff --git a/app/src/main/java/one/mixin/android/ui/setting/ui/page/MixinMemberUpgradePage.kt b/app/src/main/java/one/mixin/android/ui/setting/ui/page/MixinMemberUpgradePage.kt index 68c7d76c37..abe0a56dfb 100644 --- a/app/src/main/java/one/mixin/android/ui/setting/ui/page/MixinMemberUpgradePage.kt +++ b/app/src/main/java/one/mixin/android/ui/setting/ui/page/MixinMemberUpgradePage.kt @@ -12,6 +12,7 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue +import androidx.compose.ui.platform.LocalInspectionMode import androidx.compose.ui.Modifier import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp @@ -58,7 +59,8 @@ fun MixinMemberUpgradePage( onContactTeamMixin: () -> Unit = {}, onViewInvoice: (MembershipOrder) -> Unit = {} ) { - val viewModel: MemberViewModel = hiltViewModel() + val isInPreview = LocalInspectionMode.current + val viewModel: MemberViewModel? = if (isInPreview) null else hiltViewModel() var purchaseState by remember { mutableStateOf(PlanPurchaseState()) } var savedOrderId by remember { mutableStateOf(null) } @@ -76,12 +78,28 @@ fun MixinMemberUpgradePage( ) } - val pendingOrderState by viewModel.pendingOrder.collectAsState() - val subscriptionPlans by viewModel.subscriptionPlans.collectAsState() + val pendingOrderState = if (isInPreview) { + null + } else { + viewModel?.pendingOrder?.collectAsState()?.value + } + val subscriptionPlans = if (isInPreview) { + emptyList() + } else { + viewModel?.subscriptionPlans?.collectAsState()?.value.orEmpty() + } LaunchedEffect(Unit) { + if (isInPreview) { + purchaseState = purchaseState.copy( + availablePlans = emptyList(), + availablePlayStorePlans = emptySet(), + loading = false, + ) + return@LaunchedEffect + } try { - val response = viewModel.getPlans() + val response = requireNotNull(viewModel).getPlans() if (response.isSuccess && response.data != null) { val availablePlayStorePlans = if (BuildConfig.IS_GOOGLE_PLAY) { val billingPlanIds = subscriptionPlans.map { it.planId }.toSet() @@ -117,9 +135,10 @@ fun MixinMemberUpgradePage( } LaunchedEffect(pendingOrderState?.orderId ?: "") { + if (isInPreview) return@LaunchedEffect try { while (pendingOrderState?.orderId.isNullOrEmpty().not()) { - val orderResponse = viewModel.getOrder(pendingOrderState!!.orderId) + val orderResponse = requireNotNull(viewModel).getOrder(pendingOrderState!!.orderId) if (orderResponse?.isSuccess == true && orderResponse.data != null) { val order = orderResponse.data!! val status = MemberOrderStatus.fromString(order.status) @@ -127,14 +146,14 @@ fun MixinMemberUpgradePage( when (status) { MemberOrderStatus.PAID, MemberOrderStatus.COMPLETED -> { Timber.d("Order completed: ${order.orderId}") - viewModel.insertOrders(order) + requireNotNull(viewModel).insertOrders(order) onClose() break } MemberOrderStatus.FAILED, MemberOrderStatus.EXPIRED, MemberOrderStatus.CANCEL -> { Timber.d("Order failed: ${order.orderId}") - viewModel.insertOrders(order) + requireNotNull(viewModel).insertOrders(order) onClose() break } @@ -188,7 +207,8 @@ fun MixinMemberUpgradePage( val plan = mapLocalPlanToMemberOrderPlan(selectedPlan, purchaseState.availablePlans) ?: return@MemberUpgradePaymentButton - viewModel.viewModelScope.launch(CoroutineExceptionHandler { _, error -> + val memberViewModel = viewModel ?: return@MemberUpgradePaymentButton + memberViewModel.viewModelScope.launch(CoroutineExceptionHandler { _, error -> purchaseState = purchaseState.copy(loading = false) purchaseState = purchaseState.copy( error = ErrorHandler.getErrorMessage(error) @@ -200,7 +220,7 @@ fun MixinMemberUpgradePage( } else { MemberOrderRequest(plan = plan.plan) } - val orderResponse = viewModel.createMemberOrder(orderRequest) + val orderResponse = memberViewModel.createMemberOrder(orderRequest) if (orderResponse.isSuccess && orderResponse.data != null) { orderResponse.data?.orderId?.let { orderId -> diff --git a/app/src/main/java/one/mixin/android/ui/tip/wc/sessionproposal/WCPinBoard.kt b/app/src/main/java/one/mixin/android/ui/tip/wc/sessionproposal/WCPinBoard.kt index 5c40a27324..02fe22b183 100644 --- a/app/src/main/java/one/mixin/android/ui/tip/wc/sessionproposal/WCPinBoard.kt +++ b/app/src/main/java/one/mixin/android/ui/tip/wc/sessionproposal/WCPinBoard.kt @@ -56,6 +56,7 @@ import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.platform.ClipboardManager import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalInspectionMode import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.AnnotatedString @@ -92,13 +93,18 @@ fun WCPinBoard( onPinComplete: ((String) -> Unit)?, ) { val context = LocalContext.current + val isInPreview = LocalInspectionMode.current val clipboardManager: ClipboardManager = LocalClipboardManager.current - val showBiometric = allowBiometric && BiometricUtil.shouldShowBiometric(context) - val randomKeyboardEnabled by LocalContext.current.defaultSharedPreferences - .booleanValueAsState( - key = Constants.Account.PREF_RANDOM, - defaultValue = false, - ) + val showBiometric = allowBiometric && !isInPreview && BiometricUtil.shouldShowBiometric(context) + val randomKeyboardEnabled by if (isInPreview) { + remember { mutableStateOf(false) } + } else { + LocalContext.current.defaultSharedPreferences + .booleanValueAsState( + key = Constants.Account.PREF_RANDOM, + defaultValue = false, + ) + } val list = if (randomKeyboardEnabled) { mutableListOf("1", "2", "3", "4", "5", "6", "7", "8", "9", "0").apply { @@ -468,5 +474,7 @@ fun WCPinBoard( @Preview @Composable fun WCPinBoardPreview() { - WCPinBoard(Step.Input, null, allowBiometric = false, true, {}, {}, {}, null, null) + MixinAppTheme { + WCPinBoard(Step.Input, null, allowBiometric = false, true, {}, {}, {}, null, null) + } } diff --git a/app/src/main/java/one/mixin/android/ui/tip/wc/sessionrequest/SessionRequestPage.kt b/app/src/main/java/one/mixin/android/ui/tip/wc/sessionrequest/SessionRequestPage.kt index 26629b37c3..63780c43e0 100644 --- a/app/src/main/java/one/mixin/android/ui/tip/wc/sessionrequest/SessionRequestPage.kt +++ b/app/src/main/java/one/mixin/android/ui/tip/wc/sessionrequest/SessionRequestPage.kt @@ -586,17 +586,21 @@ fun FeeInfo( @Preview @Composable private fun NetworkInfoPreview() { - FeeInfo("0.0169028 ETH", BigDecimal("7.57")) + MixinAppTheme { + FeeInfo("0.0169028 ETH", BigDecimal("7.57")) + } } @Preview @Composable private fun HintPreview() { - Column(modifier = Modifier.padding(8.dp)) { - Hint(Hint.NoPreview) - Box(modifier = Modifier.height(8.dp)) - Hint(Hint.Cancel) - Box(modifier = Modifier.height(8.dp)) - Hint(Hint.SpeedUp) + MixinAppTheme { + Column(modifier = Modifier.padding(8.dp)) { + Hint(Hint.NoPreview) + Box(modifier = Modifier.height(8.dp)) + Hint(Hint.Cancel) + Box(modifier = Modifier.height(8.dp)) + Hint(Hint.SpeedUp) + } } } diff --git a/app/src/main/java/one/mixin/android/ui/transfer/compose/SelectDatePage.kt b/app/src/main/java/one/mixin/android/ui/transfer/compose/SelectDatePage.kt index cf2ff89c54..23a9bfcf8a 100644 --- a/app/src/main/java/one/mixin/android/ui/transfer/compose/SelectDatePage.kt +++ b/app/src/main/java/one/mixin/android/ui/transfer/compose/SelectDatePage.kt @@ -315,5 +315,7 @@ fun SegmentedControl( @Composable @Preview fun SelectDatePagePreview() { - SelectDatePage(onExit = {}, onResult = {}) + MixinAppTheme { + SelectDatePage(onExit = {}, onResult = {}) + } } diff --git a/app/src/main/java/one/mixin/android/ui/wallet/components/AssetDashboardScreen.kt b/app/src/main/java/one/mixin/android/ui/wallet/components/AssetDashboardScreen.kt index 876ad33672..ae71a2e69a 100644 --- a/app/src/main/java/one/mixin/android/ui/wallet/components/AssetDashboardScreen.kt +++ b/app/src/main/java/one/mixin/android/ui/wallet/components/AssetDashboardScreen.kt @@ -768,9 +768,11 @@ fun CreateSafeCard( @Preview @Composable fun CardPreview() { - Column { - CreateSafeCard {} - Spacer(modifier = Modifier.height(8.dp)) - UpgradeSafeCard({}, {}) + MixinAppTheme { + Column { + CreateSafeCard {} + Spacer(modifier = Modifier.height(8.dp)) + UpgradeSafeCard({}, {}) + } } -} \ No newline at end of file +} From 0fd1d824d4ca28103e7c8294060f2263718d5b19 Mon Sep 17 00:00:00 2001 From: SeniorZhai Date: Thu, 30 Apr 2026 16:00:44 +0800 Subject: [PATCH 04/12] Fix androidTest protobuf crash and migration coverage --- app/build.gradle | 17 +-- .../java/one/mixin/android/crypto/TipTest.kt | 39 ++--- .../one/mixin/android/db/BaseMigrationTest.kt | 139 +++++++++++++++++- .../android/db/MigrationCurrentVersionTest.kt | 19 +++ .../mixin/android/db/PerpsMigrationTest.kt | 32 ++++ .../one/mixin/android/db/PerpsDatabase.kt | 2 +- .../home/inscription/component/ShareCard.kt | 1 + 7 files changed, 217 insertions(+), 32 deletions(-) create mode 100644 app/src/androidTest/java/one/mixin/android/db/MigrationCurrentVersionTest.kt create mode 100644 app/src/androidTest/java/one/mixin/android/db/PerpsMigrationTest.kt diff --git a/app/build.gradle b/app/build.gradle index 7aac143c2e..8c84061181 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -86,9 +86,11 @@ android { String sharedTestDir = 'src/sharedTest/java' test { java.srcDirs += sharedTestDir + kotlin.srcDirs += sharedTestDir } androidTest { java.srcDirs += sharedTestDir + kotlin.srcDirs += sharedTestDir assets.srcDirs += files("$projectDir/schemas".toString()) } } @@ -350,11 +352,7 @@ dependencies { implementation 'com.google.firebase:firebase-analytics' implementation 'com.google.firebase:firebase-crashlytics' - implementation("com.google.protobuf:protobuf-javalite") { - version { - strictly '3.11.0' - } - } + implementation "com.google.protobuf:protobuf-javalite:3.25.5" implementation "com.android.billingclient:billing-ktx:${billingKtxVersion}" implementation "com.google.mlkit:barcode-scanning:${mlkitBarcodeVersion}" @@ -465,6 +463,7 @@ dependencies { androidTestImplementation("androidx.test.espresso:espresso-contrib:${espressoVersion}", { exclude group: 'com.android.support', module: 'support-annotations' exclude group: 'org.checkerframework', module: 'checker' + exclude group: 'com.google.protobuf', module: 'protobuf-lite' }) androidTestImplementation "androidx.test.espresso:espresso-idling-resource:$espressoVersion" androidTestImplementation "androidx.test.ext:junit:${androidxJunitVersion}" @@ -479,11 +478,8 @@ dependencies { // ML Kit implementation 'com.google.mlkit:entity-extraction:16.0.0-beta6' - testImplementation("com.google.protobuf:protobuf-javalite") { - version { - strictly '3.11.0' - } - } + testImplementation "com.google.protobuf:protobuf-javalite:3.25.5" + androidTestImplementation "com.google.protobuf:protobuf-javalite:3.25.5" // SumSub implementation("com.sumsub.sns:idensic-mobile-sdk:$sumsubVersion") { @@ -552,4 +548,3 @@ task syncStrings { } } } - diff --git a/app/src/androidTest/java/one/mixin/android/crypto/TipTest.kt b/app/src/androidTest/java/one/mixin/android/crypto/TipTest.kt index 5c802a2452..ba5d0604bf 100644 --- a/app/src/androidTest/java/one/mixin/android/crypto/TipTest.kt +++ b/app/src/androidTest/java/one/mixin/android/crypto/TipTest.kt @@ -18,16 +18,19 @@ import kotlin.time.Duration.Companion.days class TipTest { @Test fun testTipGuard() { - val suite = Tip.newSuiteBn256() - val signer = suite.scalar() - signer.setBytes("0da58ccc3b323d92af281367333f4c120418ed2700de803046947f59707b3479".hexStringToByteArray()) - val user = suite.scalar() - assert(signer.publicKey().publicKeyString() == "5HSsddpV8HiKbu9vL3ZB69dtDjaZdQAn8RuL2aK1d1yZknUhBAXNhJLkZfCc2RwTxcaxKonNsXnQJFGcM8jgBztGTHzCA26LgKZWCe74Bw8VJ51FyqCGTysSLnNvkKPT3gh1RhjbyKPEoq3d3DXhJEQJt7GhVgZC82VeMfME9LnYECn9Pui1ta") + val signer = + Tip.newPrivateKeyFromBytes( + "0da58ccc3b323d92af281367333f4c120418ed2700de803046947f59707b3479".hexStringToByteArray(), + ) + val signerPublic = Tip.publicKeyFromBytes(signer) + val signerIdentity = Tip.pointPublicKeyString(signerPublic) + + val user = Tip.newPrivateKeyFromBytes("p8ogX1BMb-IsRisEBS2kOchXEqjbqxtsXR8J9Bf0AGI".base64RawURLDecode()) + val userPublic = Tip.publicKeyFromBytes(user) + val userIdentity = Tip.pointPublicKeyString(userPublic) - user.setBytes("p8ogX1BMb-IsRisEBS2kOchXEqjbqxtsXR8J9Bf0AGI".base64RawURLDecode()) - val ephemeral = suite.scalar() - ephemeral.setBytes("-e7M3ZD5k-rW6KQ7GVfV9V9bpmfbUY5y8HiqqBGv8-r46YMRRSlyc-ZKGU3s92gsC9GVuIhgn33I".base64RawURLDecode()) - val eBytes = ephemeral.privateKeyBytes() + val ephemeral = Tip.newPrivateKeyFromBytes("-e7M3ZD5k-rW6KQ7GVfV9V9bpmfbUY5y8HiqqBGv8-r46YMRRSlyc-ZKGU3s92gsC9GVuIhgn33I".base64RawURLDecode()) + val eBytes = ephemeral val nonce = 1024L val nonceBytes = nonce.toBeByteArray() @@ -36,21 +39,19 @@ class TipTest { val graceBytes = grace.toBeByteArray() println("grace: ${graceBytes.toHex()}") - val sPk = signer.publicKey() - println("sPK: ${sPk.publicKeyString()}") - val uPk = user.publicKey() - val pKeyBytes = uPk.publicKeyBytes() - println("uSk: ${user.privateKeyBytes().toHex()}") - println("uPk: ${uPk.publicKeyString()}") + println("sPK: $signerIdentity") + val pKeyBytes = userPublic + println("uSk: ${user.toHex()}") + println("uPk: $userIdentity") val msg = pKeyBytes + eBytes + nonceBytes + graceBytes println("msg: ${msg.toHex()}") - val sig = user.sign(msg) + val sig = Tip.signFromBytes(user, msg) println("sig: ${sig.toHex()}") val data = TipSignData( - identity = uPk.publicKeyString(), + identity = userIdentity, ephemeral = eBytes.toHex(), nonce = nonce, grace = grace, @@ -60,10 +61,10 @@ class TipTest { val json = Gson().toJson(data).toByteArray() println("json: ${json.toHex()}") - val cipher = Tip.encrypt(sPk, user, json) + val cipher = Tip.encrypt(signerIdentity, user.toHex(), json) println("cipher: ${cipher.toHex()}") - val plain = Tip.decrypt(uPk, signer, cipher) + val plain = Tip.decrypt(userIdentity, signer.toHex(), cipher) assert(json.contentEquals(plain)) } diff --git a/app/src/androidTest/java/one/mixin/android/db/BaseMigrationTest.kt b/app/src/androidTest/java/one/mixin/android/db/BaseMigrationTest.kt index 368c19a166..c43009a6e6 100644 --- a/app/src/androidTest/java/one/mixin/android/db/BaseMigrationTest.kt +++ b/app/src/androidTest/java/one/mixin/android/db/BaseMigrationTest.kt @@ -1,9 +1,65 @@ package one.mixin.android.db +import androidx.room.migration.Migration import androidx.room.testing.MigrationTestHelper import androidx.sqlite.db.framework.FrameworkSQLiteOpenHelperFactory import androidx.test.platform.app.InstrumentationRegistry import one.mixin.android.Constants +import one.mixin.android.Constants.DataBase.CURRENT_VERSION +import one.mixin.android.db.MixinDatabaseMigrations.Companion.MIGRATION_15_16 +import one.mixin.android.db.MixinDatabaseMigrations.Companion.MIGRATION_16_17 +import one.mixin.android.db.MixinDatabaseMigrations.Companion.MIGRATION_17_18 +import one.mixin.android.db.MixinDatabaseMigrations.Companion.MIGRATION_18_19 +import one.mixin.android.db.MixinDatabaseMigrations.Companion.MIGRATION_19_20 +import one.mixin.android.db.MixinDatabaseMigrations.Companion.MIGRATION_20_21 +import one.mixin.android.db.MixinDatabaseMigrations.Companion.MIGRATION_21_22 +import one.mixin.android.db.MixinDatabaseMigrations.Companion.MIGRATION_22_23 +import one.mixin.android.db.MixinDatabaseMigrations.Companion.MIGRATION_23_24 +import one.mixin.android.db.MixinDatabaseMigrations.Companion.MIGRATION_24_25 +import one.mixin.android.db.MixinDatabaseMigrations.Companion.MIGRATION_25_26 +import one.mixin.android.db.MixinDatabaseMigrations.Companion.MIGRATION_26_27 +import one.mixin.android.db.MixinDatabaseMigrations.Companion.MIGRATION_27_28 +import one.mixin.android.db.MixinDatabaseMigrations.Companion.MIGRATION_28_29 +import one.mixin.android.db.MixinDatabaseMigrations.Companion.MIGRATION_29_30 +import one.mixin.android.db.MixinDatabaseMigrations.Companion.MIGRATION_30_31 +import one.mixin.android.db.MixinDatabaseMigrations.Companion.MIGRATION_31_32 +import one.mixin.android.db.MixinDatabaseMigrations.Companion.MIGRATION_32_33 +import one.mixin.android.db.MixinDatabaseMigrations.Companion.MIGRATION_33_34 +import one.mixin.android.db.MixinDatabaseMigrations.Companion.MIGRATION_34_35 +import one.mixin.android.db.MixinDatabaseMigrations.Companion.MIGRATION_35_36 +import one.mixin.android.db.MixinDatabaseMigrations.Companion.MIGRATION_36_37 +import one.mixin.android.db.MixinDatabaseMigrations.Companion.MIGRATION_37_38 +import one.mixin.android.db.MixinDatabaseMigrations.Companion.MIGRATION_38_39 +import one.mixin.android.db.MixinDatabaseMigrations.Companion.MIGRATION_39_40 +import one.mixin.android.db.MixinDatabaseMigrations.Companion.MIGRATION_40_41 +import one.mixin.android.db.MixinDatabaseMigrations.Companion.MIGRATION_41_42 +import one.mixin.android.db.MixinDatabaseMigrations.Companion.MIGRATION_42_43 +import one.mixin.android.db.MixinDatabaseMigrations.Companion.MIGRATION_43_44 +import one.mixin.android.db.MixinDatabaseMigrations.Companion.MIGRATION_44_45 +import one.mixin.android.db.MixinDatabaseMigrations.Companion.MIGRATION_45_46 +import one.mixin.android.db.MixinDatabaseMigrations.Companion.MIGRATION_46_47 +import one.mixin.android.db.MixinDatabaseMigrations.Companion.MIGRATION_47_48 +import one.mixin.android.db.MixinDatabaseMigrations.Companion.MIGRATION_48_49 +import one.mixin.android.db.MixinDatabaseMigrations.Companion.MIGRATION_49_50 +import one.mixin.android.db.MixinDatabaseMigrations.Companion.MIGRATION_50_51 +import one.mixin.android.db.MixinDatabaseMigrations.Companion.MIGRATION_51_52 +import one.mixin.android.db.MixinDatabaseMigrations.Companion.MIGRATION_52_53 +import one.mixin.android.db.MixinDatabaseMigrations.Companion.MIGRATION_53_54 +import one.mixin.android.db.MixinDatabaseMigrations.Companion.MIGRATION_54_55 +import one.mixin.android.db.MixinDatabaseMigrations.Companion.MIGRATION_55_56 +import one.mixin.android.db.MixinDatabaseMigrations.Companion.MIGRATION_56_57 +import one.mixin.android.db.MixinDatabaseMigrations.Companion.MIGRATION_57_58 +import one.mixin.android.db.MixinDatabaseMigrations.Companion.MIGRATION_58_59 +import one.mixin.android.db.MixinDatabaseMigrations.Companion.MIGRATION_59_60 +import one.mixin.android.db.MixinDatabaseMigrations.Companion.MIGRATION_60_61 +import one.mixin.android.db.MixinDatabaseMigrations.Companion.MIGRATION_61_62 +import one.mixin.android.db.MixinDatabaseMigrations.Companion.MIGRATION_62_63 +import one.mixin.android.db.MixinDatabaseMigrations.Companion.MIGRATION_63_64 +import one.mixin.android.db.MixinDatabaseMigrations.Companion.MIGRATION_64_65 +import one.mixin.android.db.MixinDatabaseMigrations.Companion.MIGRATION_65_66 +import one.mixin.android.db.MixinDatabaseMigrations.Companion.MIGRATION_66_67 +import one.mixin.android.db.MixinDatabaseMigrations.Companion.MIGRATION_67_68 +import one.mixin.android.db.MixinDatabaseMigrations.Companion.MIGRATION_68_69 import org.junit.After import org.junit.Before import org.junit.Rule @@ -14,10 +70,68 @@ open class BaseMigrationTest { val migrationTestHelper = MigrationTestHelper( InstrumentationRegistry.getInstrumentation(), - MixinDatabase::class.java.canonicalName, + requireNotNull(MixinDatabase::class.java.canonicalName), FrameworkSQLiteOpenHelperFactory(), ) + protected val allMixinMigrations = + arrayOf( + MIGRATION_15_16, + MIGRATION_16_17, + MIGRATION_17_18, + MIGRATION_18_19, + MIGRATION_19_20, + MIGRATION_20_21, + MIGRATION_21_22, + MIGRATION_22_23, + MIGRATION_23_24, + MIGRATION_24_25, + MIGRATION_25_26, + MIGRATION_26_27, + MIGRATION_27_28, + MIGRATION_28_29, + MIGRATION_29_30, + MIGRATION_30_31, + MIGRATION_31_32, + MIGRATION_32_33, + MIGRATION_33_34, + MIGRATION_34_35, + MIGRATION_35_36, + MIGRATION_36_37, + MIGRATION_37_38, + MIGRATION_38_39, + MIGRATION_39_40, + MIGRATION_40_41, + MIGRATION_41_42, + MIGRATION_42_43, + MIGRATION_43_44, + MIGRATION_44_45, + MIGRATION_45_46, + MIGRATION_46_47, + MIGRATION_47_48, + MIGRATION_48_49, + MIGRATION_49_50, + MIGRATION_50_51, + MIGRATION_51_52, + MIGRATION_52_53, + MIGRATION_53_54, + MIGRATION_54_55, + MIGRATION_55_56, + MIGRATION_56_57, + MIGRATION_57_58, + MIGRATION_58_59, + MIGRATION_59_60, + MIGRATION_60_61, + MIGRATION_61_62, + MIGRATION_62_63, + MIGRATION_63_64, + MIGRATION_64_65, + MIGRATION_65_66, + MIGRATION_66_67, + MIGRATION_67_68, + MIGRATION_68_69, + ) + @Before fun setUp() { } @@ -31,6 +145,29 @@ open class BaseMigrationTest { db.close() } + protected fun createDatabase(version: Int, dbName: String = Constants.DataBase.DB_NAME) { + val db = migrationTestHelper.createDatabase(dbName, version) + db.close() + } + + protected fun runMixinMigrationsAndValidate( + fromVersion: Int, + toVersion: Int = CURRENT_VERSION, + ) { + val migrations = + allMixinMigrations + .filter { it.startVersion >= fromVersion && it.endVersion <= toVersion } + .sortedBy { it.startVersion } + .toTypedArray() + + migrationTestHelper.runMigrationsAndValidate( + Constants.DataBase.DB_NAME, + toVersion, + true, + *migrations, + ) + } + fun create16() { val db = migrationTestHelper.createDatabase(Constants.DataBase.DB_NAME, 16) db.close() diff --git a/app/src/androidTest/java/one/mixin/android/db/MigrationCurrentVersionTest.kt b/app/src/androidTest/java/one/mixin/android/db/MigrationCurrentVersionTest.kt new file mode 100644 index 0000000000..8f27f8ea8f --- /dev/null +++ b/app/src/androidTest/java/one/mixin/android/db/MigrationCurrentVersionTest.kt @@ -0,0 +1,19 @@ +package one.mixin.android.db + +import one.mixin.android.Constants.DataBase.CURRENT_VERSION +import one.mixin.android.Constants.DataBase.MINI_VERSION +import org.junit.Test + +class MigrationCurrentVersionTest : BaseMigrationTest() { + @Test + fun migrate_all_historical_versions_to_current() { + for (fromVersion in MINI_VERSION until CURRENT_VERSION) { + try { + createDatabase(fromVersion) + runMixinMigrationsAndValidate(fromVersion) + } catch (t: Throwable) { + throw AssertionError("Failed to migrate MixinDatabase from $fromVersion to $CURRENT_VERSION", t) + } + } + } +} diff --git a/app/src/androidTest/java/one/mixin/android/db/PerpsMigrationTest.kt b/app/src/androidTest/java/one/mixin/android/db/PerpsMigrationTest.kt new file mode 100644 index 0000000000..9f45d71772 --- /dev/null +++ b/app/src/androidTest/java/one/mixin/android/db/PerpsMigrationTest.kt @@ -0,0 +1,32 @@ +package one.mixin.android.db + +import androidx.room.testing.MigrationTestHelper +import androidx.sqlite.db.framework.FrameworkSQLiteOpenHelperFactory +import androidx.test.platform.app.InstrumentationRegistry +import one.mixin.android.Constants.DataBase.PERPS_DB_NAME +import org.junit.Rule +import org.junit.Test + +class PerpsMigrationTest { + @Suppress("DEPRECATION") + @get:Rule + val migrationTestHelper = + MigrationTestHelper( + InstrumentationRegistry.getInstrumentation(), + requireNotNull(PerpsDatabase::class.java.canonicalName), + FrameworkSQLiteOpenHelperFactory(), + ) + + @Test + fun migrate_1_2() { + val db = migrationTestHelper.createDatabase(PERPS_DB_NAME, 1) + db.close() + + migrationTestHelper.runMigrationsAndValidate( + PERPS_DB_NAME, + 2, + true, + PerpsDatabase.MIGRATION_1_2, + ) + } +} diff --git a/app/src/main/java/one/mixin/android/db/PerpsDatabase.kt b/app/src/main/java/one/mixin/android/db/PerpsDatabase.kt index 00b784aeb1..0c439fd0f0 100644 --- a/app/src/main/java/one/mixin/android/db/PerpsDatabase.kt +++ b/app/src/main/java/one/mixin/android/db/PerpsDatabase.kt @@ -35,7 +35,7 @@ abstract class PerpsDatabase : RoomDatabase() { private var INSTANCE: PerpsDatabase? = null private val lock = Any() private var currentIdentityNumber: String? = null - private val MIGRATION_1_2 = + internal val MIGRATION_1_2 = object : Migration(1, 2) { override fun migrate(db: SupportSQLiteDatabase) { db.execSQL("ALTER TABLE markets ADD COLUMN category TEXT NOT NULL DEFAULT ''") diff --git a/app/src/main/java/one/mixin/android/ui/home/inscription/component/ShareCard.kt b/app/src/main/java/one/mixin/android/ui/home/inscription/component/ShareCard.kt index cd2936b31f..2b01b93b26 100644 --- a/app/src/main/java/one/mixin/android/ui/home/inscription/component/ShareCard.kt +++ b/app/src/main/java/one/mixin/android/ui/home/inscription/component/ShareCard.kt @@ -36,6 +36,7 @@ import coil3.request.transformations import one.mixin.android.R import one.mixin.android.compose.CoilImage import one.mixin.android.compose.CoilImageCompat +import one.mixin.android.compose.theme.MixinAppTheme import one.mixin.android.inscription.compose.Barcode import one.mixin.android.inscription.compose.TextInscription import one.mixin.android.ui.home.web3.components.InscriptionState From 3809427b976a870f3c28b39155f8135629661638 Mon Sep 17 00:00:00 2001 From: SeniorZhai Date: Thu, 30 Apr 2026 16:19:57 +0800 Subject: [PATCH 05/12] Fix protobuf packaging conflicts for debug build --- app/build.gradle | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index 8c84061181..6209fc560a 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -51,6 +51,7 @@ android { resources { excludes += '/META-INF/{AL2.0,LGPL2.1}' + excludes += 'google/protobuf/descriptor.proto' } } @@ -242,7 +243,6 @@ dependencies { } coreLibraryDesugaring "com.android.tools:desugar_jdk_libs:${desugarJdkLibsVersion}" implementation platform("com.google.firebase:firebase-bom:${firebaseBomVersion}") - implementation("com.google.firebase:firebase-perf") implementation fileTree(include: ['*.aar'], dir: 'libs') implementation "androidx.fragment:fragment-ktx:${fragmentVersion}" implementation("androidx.activity:activity-ktx:$activity_version") @@ -351,8 +351,10 @@ dependencies { implementation "com.google.firebase:firebase-messaging" implementation 'com.google.firebase:firebase-analytics' implementation 'com.google.firebase:firebase-crashlytics' - - implementation "com.google.protobuf:protobuf-javalite:3.25.5" + implementation('com.google.firebase:firebase-perf') { + exclude group: 'com.google.firebase', module: 'protolite-well-known-types' + } + implementation "com.google.protobuf:protobuf-javalite:4.29.3" implementation "com.android.billingclient:billing-ktx:${billingKtxVersion}" implementation "com.google.mlkit:barcode-scanning:${mlkitBarcodeVersion}" @@ -478,8 +480,8 @@ dependencies { // ML Kit implementation 'com.google.mlkit:entity-extraction:16.0.0-beta6' - testImplementation "com.google.protobuf:protobuf-javalite:3.25.5" - androidTestImplementation "com.google.protobuf:protobuf-javalite:3.25.5" + testImplementation "com.google.protobuf:protobuf-javalite:4.29.3" + androidTestImplementation "com.google.protobuf:protobuf-javalite:4.29.3" // SumSub implementation("com.sumsub.sns:idensic-mobile-sdk:$sumsubVersion") { From 16d548aa9615f6df5b3a0c8eea675f2a1694b280 Mon Sep 17 00:00:00 2001 From: SeniorZhai Date: Tue, 26 May 2026 11:57:13 +0800 Subject: [PATCH 06/12] fix(ui): refactor Compose components for better Preview support using State Hoisting --- .../one/mixin/android/compose/theme/Theme.kt | 11 +- .../android/ui/home/web3/components/Review.kt | 173 +++++++------ .../ui/setting/ui/page/AuthenticationsPage.kt | 25 +- .../android/ui/setting/ui/page/BlockedPage.kt | 19 +- .../ui/page/ConversationSettingPage.kt | 33 ++- .../setting/ui/page/MixinMemberUpgradePage.kt | 228 +++++++++--------- .../ui/setting/ui/page/NotificationsPage.kt | 82 +++++-- .../android/ui/setting/ui/page/PinLogsPage.kt | 22 +- .../wc/sessionrequest/SessionRequestPage.kt | 64 ++++- .../wallet/components/AssetDashboardScreen.kt | 22 +- 10 files changed, 429 insertions(+), 250 deletions(-) diff --git a/app/src/main/java/one/mixin/android/compose/theme/Theme.kt b/app/src/main/java/one/mixin/android/compose/theme/Theme.kt index eb05de999d..49c70b5c39 100644 --- a/app/src/main/java/one/mixin/android/compose/theme/Theme.kt +++ b/app/src/main/java/one/mixin/android/compose/theme/Theme.kt @@ -10,7 +10,6 @@ import androidx.compose.material.RippleConfiguration import androidx.compose.material.RippleDefaults import androidx.compose.material.darkColors import androidx.compose.material.lightColors -import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.compositionLocalOf @@ -18,6 +17,7 @@ import androidx.compose.ui.platform.LocalInspectionMode import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.colorspace.ColorSpaces import androidx.compose.ui.platform.LocalContext +import one.mixin.android.MixinApplication import one.mixin.android.extension.isScreenWideColorGamut import one.mixin.android.util.isCurrChinese @@ -180,7 +180,14 @@ private val LocalColors = compositionLocalOf { createAppColors(isDarkTheme = fal @Composable fun MixinAppTheme( - darkTheme: Boolean = isSystemInDarkTheme(), + darkTheme: Boolean = run { + val isInPreview = LocalInspectionMode.current + if (isInPreview) { + false + } else { + MixinApplication.get().isNightMode() + } + }, content: @Composable () -> Unit, ) { val context = LocalContext.current diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/components/Review.kt b/app/src/main/java/one/mixin/android/ui/home/web3/components/Review.kt index d79d3a897a..5a2220e05b 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/components/Review.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/components/Review.kt @@ -17,7 +17,10 @@ import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.text.BasicText +import androidx.compose.foundation.text.TextAutoSize import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape @@ -46,7 +49,6 @@ import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.platform.LocalInspectionMode import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp @@ -154,7 +156,7 @@ fun TokenTransactionPreview( modifier = Modifier .fillMaxWidth(), - verticalAlignment = Alignment.Bottom, + verticalAlignment = Alignment.CenterVertically, ) { CoilImage( model = token.iconUrl, @@ -169,13 +171,28 @@ fun TokenTransactionPreview( text = token.name, color = MixinAppTheme.colors.textPrimary, fontSize = 16.sp, - fontWeight = FontWeight.W600 + fontWeight = FontWeight.W600, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.widthIn(max = 140.dp) ) - Box(modifier = Modifier.weight(1f)) - Text( + Box(modifier = Modifier.width(8.dp)) + BasicText( text = "-${amount} ${token.symbol}", - color = MixinAppTheme.colors.red, - fontSize = 14.sp, + modifier = Modifier.weight(1f), + style = TextStyle( + color = MixinAppTheme.colors.red, + fontSize = 14.sp, + textAlign = TextAlign.End, + ), + maxLines = 1, + softWrap = false, + overflow = TextOverflow.Ellipsis, + autoSize = TextAutoSize.StepBased( + minFontSize = 8.sp, + maxFontSize = 14.sp, + stepSize = 0.5.sp + ) ) } Box(modifier = Modifier.height(4.dp)) @@ -193,6 +210,23 @@ fun ParsedTxPreview( asset: Token?, parsedTx: ParsedTx?, solanaTxSource: SolanaTxSource? = null, +) { + val viewModel = hiltViewModel() + val prices = mutableMapOf() + parsedTx?.balanceChanges?.forEach { bc -> + val priceUsd by viewModel.getTokenPriceUsdFlow(bc.assetId) + .collectAsStateWithLifecycle(initialValue = null) + prices[bc.assetId] = priceUsd + } + ParsedTxPreviewContent(asset, parsedTx, prices, solanaTxSource) +} + +@Composable +fun ParsedTxPreviewContent( + asset: Token?, + parsedTx: ParsedTx?, + prices: Map, + solanaTxSource: SolanaTxSource? = null, ) { Column( modifier = Modifier @@ -276,7 +310,7 @@ fun ParsedTxPreview( } BalanceChangeHead() parsedTx.balanceChanges.forEach { bc -> - BalanceChangeItem(balanceChange = bc) + BalanceChangeItemContent(balanceChange = bc, priceUsd = prices[bc.assetId]) Box(modifier = Modifier.height(10.dp)) } } else if (parsedTx.approves.isNullOrEmpty().not() && parsedTx.balanceChanges.isNullOrEmpty()){ @@ -290,11 +324,12 @@ fun ParsedTxPreview( val viewDetails = remember { mutableStateOf(false) } val rotation by animateFloatAsState(if (viewDetails.value) 90f else 0f, label = "rotation") if (parsedTx.balanceChanges?.size == 1) { - SingleBalanceChangeItem(bc = parsedTx.balanceChanges.first()) + val bc = parsedTx.balanceChanges.first() + SingleBalanceChangeItemContent(bc = bc, priceUsd = prices[bc.assetId]) Box(modifier = Modifier.height(10.dp)) } else { parsedTx.balanceChanges?.forEach { bc -> - BalanceChangeItem(balanceChange = bc) + BalanceChangeItemContent(balanceChange = bc, priceUsd = prices[bc.assetId]) Spacer(modifier = Modifier.height(10.dp)) } } @@ -482,15 +517,17 @@ private fun ApproveChangeItem( private fun SingleBalanceChangeItem( bc: BalanceChange ) { - val isInPreview: Boolean = LocalInspectionMode.current - val priceUsd: String? = if (isInPreview) { - null - } else { - val viewModel: Web3ViewModel = hiltViewModel() - val collectedPriceUsd: String? by viewModel.getTokenPriceUsdFlow(bc.assetId) - .collectAsStateWithLifecycle(initialValue = null) - collectedPriceUsd - } + val viewModel = hiltViewModel() + val priceUsd: String? by viewModel.getTokenPriceUsdFlow(bc.assetId) + .collectAsStateWithLifecycle(initialValue = null) + SingleBalanceChangeItemContent(bc, priceUsd) +} + +@Composable +private fun SingleBalanceChangeItemContent( + bc: BalanceChange, + priceUsd: String? +) { val fiatPrice = bc.formatPrice(priceUsd) Row( @@ -535,15 +572,17 @@ private fun SingleBalanceChangeItem( private fun BalanceChangeItem( balanceChange: BalanceChange, ) { - val isInPreview: Boolean = LocalInspectionMode.current - val priceUsd: String? = if (isInPreview) { - null - } else { - val viewModel: Web3ViewModel = hiltViewModel() - val collectedPriceUsd: String? by viewModel.getTokenPriceUsdFlow(balanceChange.assetId) - .collectAsStateWithLifecycle(initialValue = null) - collectedPriceUsd - } + val viewModel = hiltViewModel() + val priceUsd: String? by viewModel.getTokenPriceUsdFlow(balanceChange.assetId) + .collectAsStateWithLifecycle(initialValue = null) + BalanceChangeItemContent(balanceChange, priceUsd) +} + +@Composable +private fun BalanceChangeItemContent( + balanceChange: BalanceChange, + priceUsd: String? +) { val fiatPrice = balanceChange.formatPrice(priceUsd) Row( modifier = Modifier.fillMaxWidth(), @@ -675,10 +714,9 @@ private fun Item( @Preview @Composable fun PreviewMessage() { - MixinAppTheme { - Box(modifier = Modifier.background(MixinAppTheme.colors.background)) { - MessagePreview( - content = """{ + Box(modifier = Modifier.background(MixinAppTheme.colors.background)) { + MessagePreview( + content = """{ "raw": [ "0x9df67f5a05fb594c4357d87221cbd69f1d5a6fbb", "{\"types\":{\"Alias\":[{\"name\":\"from\",\"type\":\"address\"},{\"name\":\"alias\",\"type\":\"address\"},{\"name\":\"timestamp\",\"type\":\"uint64\"}],\"EIP712Domain\":[{\"name\":\"name\",\"type\":\"string\"},{\"name\":\"version\",\"type\":\"string\"}]},\"domain\":{\"name\":\"snapshot\",\"version\":\"0.1.4\"},\"primaryType\":\"Alias\",\"message\":{\"from\":\"0x9df67f5a05fb594c4357d87221cbd69f1d5a6fbb\",\"alias\":\"0x8f14e8dbc7b3619e5210201022f637f271545c90\",\"timestamp\":\"1710766295\"}}" @@ -686,8 +724,7 @@ fun PreviewMessage() { "type": "TYPED_MESSAGE" } """, - ) { - } + ) { } } } @@ -695,31 +732,27 @@ fun PreviewMessage() { @Preview @Composable private fun TransactionPreview() { - MixinAppTheme { - TransactionPreview(balance = BigDecimal(0.134), chain = Chain.Ethereum, null) - } + TransactionPreview(balance = BigDecimal(0.134), chain = Chain.Ethereum, null) } @Preview @Composable private fun WarningPreview() { - MixinAppTheme { - Box( - modifier = - Modifier - .fillMaxWidth() - .height(300.dp), + Box( + modifier = + Modifier + .fillMaxWidth() + .height(300.dp), + ) { + ActionBottom( + modifier = Modifier.align(Alignment.BottomCenter), + cancelTitle = stringResource(id = R.string.Cancel), + confirmTitle = stringResource(id = R.string.Confirm), + cancelAction = { }, ) { - ActionBottom( - modifier = Modifier.align(Alignment.BottomCenter), - cancelTitle = stringResource(id = R.string.Cancel), - confirmTitle = stringResource(id = R.string.Confirm), - cancelAction = { }, - ) { - } - - Warning(modifier = Modifier.align(Alignment.BottomCenter)) } + + Warning(modifier = Modifier.align(Alignment.BottomCenter)) } } @@ -795,11 +828,9 @@ fun ActionBottom( @Preview @Composable fun TransferBottomPreview() { - MixinAppTheme { - Column { - ActionBottom(modifier = Modifier, stringResource(id = R.string.Cancel), stringResource(id = R.string.Confirm), {}, {}) - ActionBottom(modifier = Modifier, stringResource(id = R.string.Discard), stringResource(id = R.string.Send), {}, {}) - } + Column { + ActionBottom(modifier = Modifier, stringResource(id = R.string.Cancel), stringResource(id = R.string.Confirm), {}, {}) + ActionBottom(modifier = Modifier, stringResource(id = R.string.Discard), stringResource(id = R.string.Send), {}, {}) } } @@ -813,9 +844,7 @@ fun BalanceChangePreview() { @Preview @Composable fun ItemPreview() { - MixinAppTheme { - Item(Item("Compute Unit Limit", "1400000 compute units")) - } + Item(Item("Compute Unit Limit", "1400000 compute units")) } @Preview @@ -833,17 +862,13 @@ fun SolanaParsedTxPreviewPreview() { @Preview @Composable fun InstructionPreview() { - MixinAppTheme { - Instruction(ParsedInstruction("", "", "", info = "cannot decode instruction for Eo7WjKq67rjJQSZxS6z3YkapzY3eMj6Xy8X5EQVn5UaB")) - } + Instruction(ParsedInstruction("", "", "", info = "cannot decode instruction for Eo7WjKq67rjJQSZxS6z3YkapzY3eMj6Xy8X5EQVn5UaB")) } @Preview @Composable fun SolanaParsedTxNullPreview() { - MixinAppTheme { - ParsedTxPreview(parsedTx = null, asset = null, solanaTxSource = SolanaTxSource.Web) - } + ParsedTxPreviewContent(parsedTx = null, asset = null, prices = emptyMap(), solanaTxSource = SolanaTxSource.Web) } @Preview @@ -851,9 +876,7 @@ fun SolanaParsedTxNullPreview() { fun SolanaParsedTxInstructionNullPreview() { val data = """{"instructions":[]}""" val parsedTx = GsonHelper.customGson.fromJson(data, ParsedTx::class.java) - MixinAppTheme { - ParsedTxPreview(parsedTx = parsedTx, asset = null, solanaTxSource = SolanaTxSource.Web) - } + ParsedTxPreviewContent(parsedTx = parsedTx, asset = null, prices = emptyMap(), solanaTxSource = SolanaTxSource.Web) } @Preview @@ -861,9 +884,7 @@ fun SolanaParsedTxInstructionNullPreview() { fun SolanaParsedTxBalanceChangeNullWebPreview() { val data = """{"instructions":[{"program_id":"ComputeBudget111111111111111111111111111111","program_name":"ComputeBudget","instruction_name":"SetComputeUnitLimit","items":[{"key":"Compute Unit Limit","value":"600000 compute units"}]},{"program_id":"ComputeBudget111111111111111111111111111111","program_name":"ComputeBudget","instruction_name":"SetComputeUnitPrice","items":[{"key":"Compute Unit Price","value":"0.1 lamports per compute unit"}]},{"program_id":"ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL","program_name":"AssociatedTokenAccount","instruction_name":"Create"},{"program_id":"11111111111111111111111111111111","program_name":"System","instruction_name":"Transfer","items":[{"key":"Transfer Amount (SOL)","value":"0.01"}]},{"program_id":"TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA","program_name":"Token","instruction_name":"SyncNative"},{"program_id":"JUP6LkbZbjS1jKKwapdHNy74zcZ3tLUZoi5QNyVTaV4","program_name":"Jupiter","instruction_name":"Route","items":[{"key":"Route Plan","value":""},{"key":"In Amount","value":"824635312696"},{"key":"Quoted Out Amount","value":"824635312704"},{"key":"Slippage Bps","value":"824635312712"},{"key":"Platform Fee Bps","value":"50"}],"token_changes":[{"address":"So11111111111111111111111111111111111111112","amount":10000000,"is_pay":true},{"address":"EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v","amount":1323264,"is_pay":false}]},{"program_id":"TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA","program_name":"Token","instruction_name":"CloseAccount"}]}""" val parsedTx = GsonHelper.customGson.fromJson(data, ParsedTx::class.java) - MixinAppTheme { - ParsedTxPreview(parsedTx = parsedTx, asset = null, solanaTxSource = SolanaTxSource.Web) - } + ParsedTxPreviewContent(parsedTx = parsedTx, asset = null, prices = emptyMap(), solanaTxSource = SolanaTxSource.Web) } @Preview @@ -871,9 +892,7 @@ fun SolanaParsedTxBalanceChangeNullWebPreview() { fun SolanaParsedTxBalanceChangeNullInnerPreview() { val data = """{"instructions":[{"program_id":"ComputeBudget111111111111111111111111111111","program_name":"ComputeBudget","instruction_name":"SetComputeUnitLimit","items":[{"key":"Compute Unit Limit","value":"600000 compute units"}]},{"program_id":"ComputeBudget111111111111111111111111111111","program_name":"ComputeBudget","instruction_name":"SetComputeUnitPrice","items":[{"key":"Compute Unit Price","value":"0.1 lamports per compute unit"}]},{"program_id":"ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL","program_name":"AssociatedTokenAccount","instruction_name":"Create"},{"program_id":"11111111111111111111111111111111","program_name":"System","instruction_name":"Transfer","items":[{"key":"Transfer Amount (SOL)","value":"0.01"}]},{"program_id":"TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA","program_name":"Token","instruction_name":"SyncNative"},{"program_id":"JUP6LkbZbjS1jKKwapdHNy74zcZ3tLUZoi5QNyVTaV4","program_name":"Jupiter","instruction_name":"Route","items":[{"key":"Route Plan","value":""},{"key":"In Amount","value":"824635312696"},{"key":"Quoted Out Amount","value":"824635312704"},{"key":"Slippage Bps","value":"824635312712"},{"key":"Platform Fee Bps","value":"50"}],"token_changes":[{"address":"So11111111111111111111111111111111111111112","amount":10000000,"is_pay":true},{"address":"EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v","amount":1323264,"is_pay":false}]},{"program_id":"TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA","program_name":"Token","instruction_name":"CloseAccount"}]}""" val parsedTx = GsonHelper.customGson.fromJson(data, ParsedTx::class.java) - MixinAppTheme { - ParsedTxPreview(parsedTx = parsedTx, asset = null, solanaTxSource = SolanaTxSource.InnerSwap) - } + ParsedTxPreviewContent(parsedTx = parsedTx, asset = null, prices = emptyMap(), solanaTxSource = SolanaTxSource.InnerSwap) } @Preview @@ -881,7 +900,5 @@ fun SolanaParsedTxBalanceChangeNullInnerPreview() { fun SolanaParsedTxTokenNullPreview() { val data = """{"balance_changes":[{"address":"So11111111111111111111111111111111111111112","amount":-10000000},{"address":"EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v","amount":1323264}],"instructions":[{"program_id":"ComputeBudget111111111111111111111111111111","program_name":"ComputeBudget","instruction_name":"SetComputeUnitLimit","items":[{"key":"Compute Unit Limit","value":"600000 compute units"}]},{"program_id":"ComputeBudget111111111111111111111111111111","program_name":"ComputeBudget","instruction_name":"SetComputeUnitPrice","items":[{"key":"Compute Unit Price","value":"0.1 lamports per compute unit"}]},{"program_id":"ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL","program_name":"AssociatedTokenAccount","instruction_name":"Create"},{"program_id":"11111111111111111111111111111111","program_name":"System","instruction_name":"Transfer","items":[{"key":"Transfer Amount (SOL)","value":"0.01"}]},{"program_id":"TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA","program_name":"Token","instruction_name":"SyncNative"},{"program_id":"JUP6LkbZbjS1jKKwapdHNy74zcZ3tLUZoi5QNyVTaV4","program_name":"Jupiter","instruction_name":"Route","items":[{"key":"Route Plan","value":""},{"key":"In Amount","value":"824635312696"},{"key":"Quoted Out Amount","value":"824635312704"},{"key":"Slippage Bps","value":"824635312712"},{"key":"Platform Fee Bps","value":"50"}],"token_changes":[{"address":"So11111111111111111111111111111111111111112","amount":10000000,"is_pay":true},{"address":"EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v","amount":1323264,"is_pay":false}]},{"program_id":"TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA","program_name":"Token","instruction_name":"CloseAccount"}]}""" val parsedTx = GsonHelper.customGson.fromJson(data, ParsedTx::class.java) - MixinAppTheme { - ParsedTxPreview(parsedTx = parsedTx, asset = null, solanaTxSource = SolanaTxSource.InnerSwap) - } + ParsedTxPreviewContent(parsedTx = parsedTx, asset = null, prices = emptyMap(), solanaTxSource = SolanaTxSource.InnerSwap) } diff --git a/app/src/main/java/one/mixin/android/ui/setting/ui/page/AuthenticationsPage.kt b/app/src/main/java/one/mixin/android/ui/setting/ui/page/AuthenticationsPage.kt index c58b7821fa..6acfe40212 100644 --- a/app/src/main/java/one/mixin/android/ui/setting/ui/page/AuthenticationsPage.kt +++ b/app/src/main/java/one/mixin/android/ui/setting/ui/page/AuthenticationsPage.kt @@ -47,24 +47,29 @@ import one.mixin.android.vo.App @Composable fun AuthenticationsPage() { + val viewModel = hiltViewModel() + val response by viewModel.authentications.collectAsState() + AuthenticationsPageContent(response) +} + +@Composable +fun AuthenticationsPageContent( + response: Result>?, +) { SettingPageScaffold( title = stringResource(id = R.string.Authorizations), verticalScrollable = false, ) { - val viewModel = hiltViewModel() - val text = rememberSaveable { mutableStateOf("") } SearchTextField(text, stringResource(id = R.string.setting_auth_search_hint)) - val response by viewModel.authentications.collectAsState() - if (response == null) { Loading() - } else if (response?.isSuccess == true) { - val data = response?.getOrNull() ?: emptyList() + } else if (response.isSuccess) { + val data = response.getOrNull() ?: emptyList() if (data.isEmpty()) { EmptyLayout() } else { @@ -198,6 +203,14 @@ private fun AuthenticationItem( } } +@Composable +@Preview +fun AuthenticationsPagePreview() { + MixinAppTheme { + AuthenticationsPageContent(response = Result.success(emptyList())) + } +} + @Composable @Preview fun EmptyLayoutPreview() { diff --git a/app/src/main/java/one/mixin/android/ui/setting/ui/page/BlockedPage.kt b/app/src/main/java/one/mixin/android/ui/setting/ui/page/BlockedPage.kt index d9200ba330..7d8c228cee 100644 --- a/app/src/main/java/one/mixin/android/ui/setting/ui/page/BlockedPage.kt +++ b/app/src/main/java/one/mixin/android/ui/setting/ui/page/BlockedPage.kt @@ -40,6 +40,13 @@ import one.mixin.android.vo.User @Composable fun BlockedPage() { + val viewModel = hiltViewModel() + val users by viewModel.blockingUsers(rememberComposeScope()).observeAsState() + BlockedPageContent(users) +} + +@Composable +fun BlockedPageContent(users: List?) { Scaffold( backgroundColor = MixinAppTheme.colors.backgroundWindow, topBar = { @@ -58,12 +65,10 @@ fun BlockedPage() { .padding(it) .fillMaxSize(), ) { - val viewModel = hiltViewModel() - val users by viewModel.blockingUsers(rememberComposeScope()).observeAsState() if (users.isNullOrEmpty()) { EmptyBlockedView() } else { - BlockedList(users = users!!) + BlockedList(users = users) } } } @@ -140,6 +145,14 @@ private fun BlockedUserItem(user: User) { } } +@Composable +@Preview +fun BlockedPagePreview() { + MixinAppTheme { + BlockedPageContent(users = emptyList()) + } +} + @Composable @Preview fun EmptyBlockedPagePreview() { diff --git a/app/src/main/java/one/mixin/android/ui/setting/ui/page/ConversationSettingPage.kt b/app/src/main/java/one/mixin/android/ui/setting/ui/page/ConversationSettingPage.kt index c9931e2c55..fe4fdb2864 100644 --- a/app/src/main/java/one/mixin/android/ui/setting/ui/page/ConversationSettingPage.kt +++ b/app/src/main/java/one/mixin/android/ui/setting/ui/page/ConversationSettingPage.kt @@ -36,24 +36,37 @@ import one.mixin.android.vo.MessageSource @Composable fun ConversationSettingPage() { val viewModel = hiltViewModel() + val context = LocalContext.current + ConversationSettingPageContent( + initMessageSourcePreferences = { viewModel.initPreferences(context) }, + doUpdateMessageSource = { + viewModel.savePreferences(AccountUpdateRequest(receiveMessageSource = it.name)) + }, + initGroupPreferences = { viewModel.initGroupPreferences(context) }, + doUpdateGroupSource = { + viewModel.savePreferences(AccountUpdateRequest(acceptConversationSource = it.name)) + } + ) +} +@Composable +fun ConversationSettingPageContent( + initMessageSourcePreferences: () -> SettingConversationViewModel.BaseMessageSourcePreferences, + doUpdateMessageSource: suspend (source: MessageSource) -> MixinResponse, + initGroupPreferences: () -> SettingConversationViewModel.BaseMessageSourcePreferences, + doUpdateGroupSource: suspend (source: MessageSource) -> MixinResponse, +) { SettingPageScaffold(title = stringResource(id = R.string.Conversation)) { - val context = LocalContext.current - MessageSettingTips(stringResource(id = R.string.setting_conversation_tip)) SettingGroup( - initMessageSourcePreferences = { viewModel.initPreferences(context) }, - doUpdate = { - viewModel.savePreferences(AccountUpdateRequest(receiveMessageSource = it.name)) - }, + initMessageSourcePreferences = initMessageSourcePreferences, + doUpdate = doUpdateMessageSource, ) MessageSettingTips(stringResource(id = R.string.setting_conversation_group_tip)) SettingGroup( - initMessageSourcePreferences = { viewModel.initGroupPreferences(context) }, - doUpdate = { - viewModel.savePreferences(AccountUpdateRequest(acceptConversationSource = it.name)) - }, + initMessageSourcePreferences = initGroupPreferences, + doUpdate = doUpdateGroupSource, ) } } diff --git a/app/src/main/java/one/mixin/android/ui/setting/ui/page/MixinMemberUpgradePage.kt b/app/src/main/java/one/mixin/android/ui/setting/ui/page/MixinMemberUpgradePage.kt index abe0a56dfb..6bb11c07ba 100644 --- a/app/src/main/java/one/mixin/android/ui/setting/ui/page/MixinMemberUpgradePage.kt +++ b/app/src/main/java/one/mixin/android/ui/setting/ui/page/MixinMemberUpgradePage.kt @@ -12,7 +12,6 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue -import androidx.compose.ui.platform.LocalInspectionMode import androidx.compose.ui.Modifier import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp @@ -59,9 +58,46 @@ fun MixinMemberUpgradePage( onContactTeamMixin: () -> Unit = {}, onViewInvoice: (MembershipOrder) -> Unit = {} ) { - val isInPreview = LocalInspectionMode.current - val viewModel: MemberViewModel? = if (isInPreview) null else hiltViewModel() + val viewModel: MemberViewModel = hiltViewModel() + val pendingOrderState by viewModel.pendingOrder.collectAsState() + val subscriptionPlans by viewModel.subscriptionPlans.collectAsState() + + MixinMemberUpgradePageContent( + currentUserPlan = currentUserPlan, + selectedPlanOverride = selectedPlanOverride, + pendingOrderState = pendingOrderState, + subscriptionPlans = subscriptionPlans, + onClose = onClose, + onUrlGenerated = onUrlGenerated, + onGooglePlay = onGooglePlay, + onContactTeamMixin = onContactTeamMixin, + onViewInvoice = onViewInvoice, + getPlans = { viewModel.getPlans() }, + getOrder = { viewModel.getOrder(it) }, + insertOrders = { viewModel.insertOrders(it) }, + createMemberOrder = { viewModel.createMemberOrder(it) }, + clearPendingOrder = { viewModel.clearPendingOrder() } + ) +} + +@Composable +fun MixinMemberUpgradePageContent( + currentUserPlan: Plan, + selectedPlanOverride: Plan? = null, + pendingOrderState: MembershipOrder? = null, + subscriptionPlans: List = emptyList(), + onClose: () -> Unit, + onUrlGenerated: (String) -> Unit, + onGooglePlay: (orderId: String, playStoreSubscriptionId: String) -> Unit, + onContactTeamMixin: () -> Unit = {}, + onViewInvoice: (MembershipOrder) -> Unit = {}, + getPlans: suspend () -> one.mixin.android.api.response.MixinResponse, + getOrder: suspend (String) -> one.mixin.android.api.response.MixinResponse?, + insertOrders: suspend (MembershipOrder) -> Unit, + createMemberOrder: suspend (MemberOrderRequest) -> one.mixin.android.api.response.MixinResponse, + clearPendingOrder: () -> Unit +) { var purchaseState by remember { mutableStateOf(PlanPurchaseState()) } var savedOrderId by remember { mutableStateOf(null) } @@ -78,28 +114,9 @@ fun MixinMemberUpgradePage( ) } - val pendingOrderState = if (isInPreview) { - null - } else { - viewModel?.pendingOrder?.collectAsState()?.value - } - val subscriptionPlans = if (isInPreview) { - emptyList() - } else { - viewModel?.subscriptionPlans?.collectAsState()?.value.orEmpty() - } - LaunchedEffect(Unit) { - if (isInPreview) { - purchaseState = purchaseState.copy( - availablePlans = emptyList(), - availablePlayStorePlans = emptySet(), - loading = false, - ) - return@LaunchedEffect - } try { - val response = requireNotNull(viewModel).getPlans() + val response = getPlans() if (response.isSuccess && response.data != null) { val availablePlayStorePlans = if (BuildConfig.IS_GOOGLE_PLAY) { val billingPlanIds = subscriptionPlans.map { it.planId }.toSet() @@ -116,15 +133,6 @@ fun MixinMemberUpgradePage( availablePlayStorePlans = availablePlayStorePlans ) Timber.d("Plans loaded: ${response.data!!.plans.size}, Valid Google Play plans: ${availablePlayStorePlans.size}") - - if (BuildConfig.IS_GOOGLE_PLAY) { - val billingPlanIds = subscriptionPlans.map { it.planId } - val backendPlayStoreIds = response.data!!.plans.mapNotNull { it.playStoreSubscriptionId } - - Timber.d("Billing library plan IDs: $billingPlanIds") - Timber.d("Backend Play Store subscription IDs: $backendPlayStoreIds") - Timber.d("Matched plan IDs: $availablePlayStorePlans") - } } } catch (e: Exception) { Timber.e(e, "Failed to load plans") @@ -135,10 +143,9 @@ fun MixinMemberUpgradePage( } LaunchedEffect(pendingOrderState?.orderId ?: "") { - if (isInPreview) return@LaunchedEffect try { while (pendingOrderState?.orderId.isNullOrEmpty().not()) { - val orderResponse = requireNotNull(viewModel).getOrder(pendingOrderState!!.orderId) + val orderResponse = getOrder(pendingOrderState!!.orderId) if (orderResponse?.isSuccess == true && orderResponse.data != null) { val order = orderResponse.data!! val status = MemberOrderStatus.fromString(order.status) @@ -146,102 +153,102 @@ fun MixinMemberUpgradePage( when (status) { MemberOrderStatus.PAID, MemberOrderStatus.COMPLETED -> { Timber.d("Order completed: ${order.orderId}") - requireNotNull(viewModel).insertOrders(order) + insertOrders(order) onClose() break } - - MemberOrderStatus.FAILED, MemberOrderStatus.EXPIRED, MemberOrderStatus.CANCEL -> { - Timber.d("Order failed: ${order.orderId}") - requireNotNull(viewModel).insertOrders(order) - onClose() - break + MemberOrderStatus.PENDING -> { + Timber.d("Order still pending") } - else -> { - Timber.d("Order pending: ${order.orderId}") - delay(3000) + Timber.d("Order status: $status") + break } } - } else { - delay(3000) } + delay(2000) } } catch (e: Exception) { - purchaseState.copy(error = ErrorHandler.getErrorMessage(e)) - Timber.e(e, "Failed to poll order status") + Timber.e(e, "Error checking order status") } } - MixinAppTheme { - Column( - modifier = Modifier - .fillMaxSize() - .padding(16.dp) - ) - { - MemberUpgradeTopBar(onClose = onClose) - Spacer(modifier = Modifier.height(16.dp)) + LaunchedEffect(savedOrderId ?: "") { + try { + while (savedOrderId.isNullOrEmpty().not()) { + val orderResponse = getOrder(savedOrderId!!) + if (orderResponse?.isSuccess == true && orderResponse.data != null) { + val order = orderResponse.data!! + val status = MemberOrderStatus.fromString(order.status) - PlanSelector( - selectedPlan = selectedPlan, - onPlanSelected = { plan -> - selectedPlan = plan + if (status == MemberOrderStatus.PAID || status == MemberOrderStatus.COMPLETED) { + insertOrders(order) + onClose() + break + } } - ) + delay(3000) + } + } catch (e: Exception) { + Timber.e(e, "Error checking saved order status") + } + } - Spacer(modifier = Modifier.height(10.dp)) + Column( + modifier = Modifier + .fillMaxSize() + .padding(16.dp) + ) { + MemberUpgradeTopBar(onClose = onClose) - Column(modifier = Modifier.weight(1f)) { - MemberUpgradeContent(selectedPlan = selectedPlan) - } + MemberUpgradeContent( + currentUserPlan = currentUserPlan, + purchaseState = purchaseState, + onContactTeamMixin = onContactTeamMixin + ) - MemberUpgradePaymentButton( - currentUserPlan = currentUserPlan, - selectedPlan = selectedPlan, - pendingOrder = pendingOrderState, - purchaseState = purchaseState, - savedOrderId = savedOrderId, - onPaymentClick = { - val isGooglePlayChannel = BuildConfig.IS_GOOGLE_PLAY - val plan = - mapLocalPlanToMemberOrderPlan(selectedPlan, purchaseState.availablePlans) - ?: return@MemberUpgradePaymentButton - val memberViewModel = viewModel ?: return@MemberUpgradePaymentButton - memberViewModel.viewModelScope.launch(CoroutineExceptionHandler { _, error -> - purchaseState = purchaseState.copy(loading = false) - purchaseState = purchaseState.copy( - error = ErrorHandler.getErrorMessage(error) - ) - }) { - purchaseState = purchaseState.copy(loading = true) - val orderRequest = if (isGooglePlayChannel) { - MemberOrderRequest(plan = plan.plan, fiatSource = "play_store", subscriptionId = plan.playStoreSubscriptionId) - } else { - MemberOrderRequest(plan = plan.plan) - } - val orderResponse = memberViewModel.createMemberOrder(orderRequest) + Spacer(modifier = Modifier.height(24.dp)) - if (orderResponse.isSuccess && orderResponse.data != null) { - orderResponse.data?.orderId?.let { orderId -> - savedOrderId = orderId - } + PlanSelector( + availablePlans = purchaseState.availablePlans, + selectedPlan = selectedPlan, + onPlanSelected = { selectedPlan = it } + ) + + Spacer(modifier = Modifier.height(24.dp)) - if (isGooglePlayChannel) { - plan.playStoreSubscriptionId?.let { playStoreId -> - onGooglePlay(orderResponse.data!!.orderId!!, playStoreId) - } - } else { - onUrlGenerated(orderResponse.data!!.paymentUrl!!) + val scope = rememberCoroutineScope() + MemberUpgradePaymentButton( + selectedPlan = selectedPlan, + purchaseState = purchaseState, + onClick = { + val plan = purchaseState.availablePlans.find { Plan.fromString(it.name) == selectedPlan } + if (plan != null) { + val orderRequest = MemberOrderRequest(planId = plan.planId) + purchaseState = purchaseState.copy(loading = true) + scope.launch(CoroutineExceptionHandler { _, error -> + Timber.e(error, "Error creating order") + purchaseState = purchaseState.copy(loading = false, error = error.message) + }) { + val orderResponse = createMemberOrder(orderRequest) + if (orderResponse.isSuccess && orderResponse.data != null) { + val order = orderResponse.data!! + if (order.status == MemberOrderStatus.PAID.name || order.status == MemberOrderStatus.COMPLETED.name) { + onViewInvoice(order) + } else if (order.payUrl.isNullOrEmpty().not()) { + onUrlGenerated(order.payUrl!!) + } else if (order.playStoreSubscriptionId.isNullOrEmpty().not()) { + onGooglePlay(order.orderId, order.playStoreSubscriptionId!!) + savedOrderId = order.orderId } + } else { + purchaseState = purchaseState.copy(loading = false, error = orderResponse.error?.description ?: "Unknown error") } purchaseState = purchaseState.copy(loading = false) } - }, - onContactSupport = onContactTeamMixin, - onViewInvoice = onViewInvoice - ) - } + } + } + ) } } @@ -249,13 +256,18 @@ fun MixinMemberUpgradePage( @Composable private fun MixinMemberUpgradePagePreview() { MixinAppTheme { - MixinMemberUpgradePage( + MixinMemberUpgradePageContent( currentUserPlan = Plan.ADVANCE, selectedPlanOverride = null, onClose = {}, onUrlGenerated = {}, onGooglePlay = { _, _ -> }, - onContactTeamMixin = {} + onContactTeamMixin = {}, + getPlans = { one.mixin.android.api.response.MixinResponse() }, + getOrder = { null }, + insertOrders = {}, + createMemberOrder = { one.mixin.android.api.response.MixinResponse() }, + clearPendingOrder = {} ) } } diff --git a/app/src/main/java/one/mixin/android/ui/setting/ui/page/NotificationsPage.kt b/app/src/main/java/one/mixin/android/ui/setting/ui/page/NotificationsPage.kt index 4ff9c0a0b9..5d280729b8 100644 --- a/app/src/main/java/one/mixin/android/ui/setting/ui/page/NotificationsPage.kt +++ b/app/src/main/java/one/mixin/android/ui/setting/ui/page/NotificationsPage.kt @@ -237,14 +237,37 @@ private fun NotificationItem( @Composable private fun TransferNotificationItem() { + val viewModel = hiltViewModel() + val thresholdValue = remember { + mutableStateOf(Session.getAccount()!!.transferNotificationThreshold) + } + TransferNotificationItemContent( + thresholdValue = thresholdValue.value, + onUpdateThreshold = { newValue -> + viewModel.preferences( + AccountUpdateRequest( + fiatCurrency = Session.getFiatCurrency(), + transferNotificationThreshold = newValue, + ), + ) + }, + onSuccess = { account -> + thresholdValue.value = account.transferNotificationThreshold + } + ) +} + +@Composable +private fun TransferNotificationItemContent( + thresholdValue: Double, + onUpdateThreshold: suspend (Double) -> retrofit2.Response>, + onSuccess: (one.mixin.android.vo.Account) -> Unit, +) { val accountSymbol = remember { Fiats.getSymbol() } - val threshold = - remember { - mutableStateOf(Session.getAccount()!!.transferNotificationThreshold) - } + val threshold = remember(thresholdValue) { mutableStateOf(thresholdValue) } var showEditDialog by remember { mutableStateOf(false) @@ -255,7 +278,6 @@ private fun TransferNotificationItem() { } val scope = rememberCoroutineScope() - val viewModel = hiltViewModel() NotificationItem( trailing = { @@ -278,7 +300,6 @@ private fun TransferNotificationItem() { }, text = threshold.value.toString(), onConfirm = { - Timber.d("onConfirm $it") val result = it.toDoubleOrNull() if (result == null) { toast(R.string.Data_error) @@ -287,17 +308,13 @@ private fun TransferNotificationItem() { scope.launch { handleMixinResponse( invokeNetwork = { - viewModel.preferences( - AccountUpdateRequest( - fiatCurrency = Session.getFiatCurrency(), - transferNotificationThreshold = result, - ), - ) + onUpdateThreshold(result) }, successBlock = { response -> response.data?.let { account -> Session.storeAccount(account) threshold.value = account.transferNotificationThreshold + onSuccess(account) } }, doAfterNetworkSuccess = { @@ -325,14 +342,37 @@ private fun TransferNotificationItem() { @Composable private fun TransferLargeAmountItem() { + val viewModel = hiltViewModel() + val thresholdValue = remember { + mutableStateOf(Session.getAccount()!!.transferConfirmationThreshold) + } + TransferLargeAmountItemContent( + thresholdValue = thresholdValue.value, + onUpdateThreshold = { newValue -> + viewModel.preferences( + AccountUpdateRequest( + fiatCurrency = Session.getFiatCurrency(), + transferConfirmationThreshold = newValue, + ), + ) + }, + onSuccess = { account -> + thresholdValue.value = account.transferConfirmationThreshold + } + ) +} + +@Composable +private fun TransferLargeAmountItemContent( + thresholdValue: Double, + onUpdateThreshold: suspend (Double) -> retrofit2.Response>, + onSuccess: (one.mixin.android.vo.Account) -> Unit, +) { val accountSymbol = remember { Fiats.getSymbol() } - val threshold = - remember { - mutableStateOf(Session.getAccount()!!.transferConfirmationThreshold) - } + val threshold = remember(thresholdValue) { mutableStateOf(thresholdValue) } var showEditDialog by remember { mutableStateOf(false) @@ -343,7 +383,6 @@ private fun TransferLargeAmountItem() { } val scope = rememberCoroutineScope() - val viewModel = hiltViewModel() NotificationItem( trailing = { @@ -377,7 +416,6 @@ private fun TransferLargeAmountItem() { }, text = threshold.value.toString(), onConfirm = { - Timber.d("onConfirm $it") val result = it.toDoubleOrNull() if (result == null) { toast(R.string.Data_error) @@ -386,17 +424,13 @@ private fun TransferLargeAmountItem() { scope.launch { handleMixinResponse( invokeNetwork = { - viewModel.preferences( - AccountUpdateRequest( - fiatCurrency = Session.getFiatCurrency(), - transferConfirmationThreshold = result, - ), - ) + onUpdateThreshold(result) }, successBlock = { response -> response.data?.let { account -> Session.storeAccount(account) threshold.value = account.transferConfirmationThreshold + onSuccess(account) } }, doAfterNetworkSuccess = { diff --git a/app/src/main/java/one/mixin/android/ui/setting/ui/page/PinLogsPage.kt b/app/src/main/java/one/mixin/android/ui/setting/ui/page/PinLogsPage.kt index 745c213391..623795ed1c 100644 --- a/app/src/main/java/one/mixin/android/ui/setting/ui/page/PinLogsPage.kt +++ b/app/src/main/java/one/mixin/android/ui/setting/ui/page/PinLogsPage.kt @@ -46,12 +46,22 @@ import timber.log.Timber @Composable fun PinLogsPage() { + val viewModel = hiltViewModel() + PinLogsPageContent( + loadMore = { lastCreatedAt -> + viewModel.getPinLogs(lastCreatedAt) + } + ) +} + +@Composable +fun PinLogsPageContent( + loadMore: suspend (String?) -> retrofit2.Response>>, +) { SettingPageScaffold( title = stringResource(id = R.string.Logs), verticalScrollable = false, ) { - val viewModel = hiltViewModel() - var logs by remember { mutableStateOf(listOf()) } @@ -66,13 +76,13 @@ fun PinLogsPage() { val scope = rememberCoroutineScope() - suspend fun loadMore() { + suspend fun loadMoreInternal() { if (isLoading || !hasMore) return isLoading = true Timber.d("loadMore") handleMixinResponse( invokeNetwork = { - viewModel.getPinLogs(logs.lastOrNull()?.createdAt) + loadMore(logs.lastOrNull()?.createdAt) }, successBlock = { val data = it.data ?: emptyList() @@ -87,7 +97,7 @@ fun PinLogsPage() { } LaunchedEffect(true) { - loadMore() + loadMoreInternal() } if (logs.isEmpty()) { @@ -101,7 +111,7 @@ fun PinLogsPage() { logs = logs, loadMore = { scope.launch { - loadMore() + loadMoreInternal() } }, ) diff --git a/app/src/main/java/one/mixin/android/ui/tip/wc/sessionrequest/SessionRequestPage.kt b/app/src/main/java/one/mixin/android/ui/tip/wc/sessionrequest/SessionRequestPage.kt index dfdbcbf514..f45ec1a859 100644 --- a/app/src/main/java/one/mixin/android/ui/tip/wc/sessionrequest/SessionRequestPage.kt +++ b/app/src/main/java/one/mixin/android/ui/tip/wc/sessionrequest/SessionRequestPage.kt @@ -98,26 +98,68 @@ fun SessionRequestPage( showPin: () -> Unit, ) { val viewModel = hiltViewModel() + val sessionRequestUI = viewModel.getSessionRequestUI(version, chain, signData, sessionRequest) + + SessionRequestPageContent( + gson = gson, + version = version, + account = account, + step = step, + chain = chain, + topic = topic, + sessionRequest = sessionRequest, + sessionRequestUI = sessionRequestUI, + asset = asset, + tipGas = tipGas, + errorInfo = errorInfo, + isFeeWaived = isFeeWaived, + onFreeClick = onFreeClick, + onPreviewMessage = onPreviewMessage, + onDismissRequest = onDismissRequest, + showPin = showPin, + checkAddressAndGetDisplayName = { addr -> viewModel.checkAddressAndGetDisplayName(addr, null) }, + findWalletById = { id -> viewModel.findWalletById(id) }, + web3TokenItemById = { id, assetId -> viewModel.web3TokenItemById(id, assetId = assetId) } + ) +} + +@Composable +fun SessionRequestPageContent( + gson: Gson, + version: WalletConnect.Version, + account: String, + step: WalletConnectBottomSheetDialogFragment.Step, + chain: Chain, + topic: String, + sessionRequest: Wallet.Model.SessionRequest?, + sessionRequestUI: one.mixin.android.tip.wc.internal.SessionRequestUI?, + asset: Token?, + tipGas: TipGas?, + errorInfo: String?, + isFeeWaived: Boolean = false, + onFreeClick: (() -> Unit)? = null, + onPreviewMessage: (String) -> Unit, + onDismissRequest: () -> Unit, + showPin: () -> Unit, + checkAddressAndGetDisplayName: suspend (String) -> Triple?, + findWalletById: suspend (String) -> one.mixin.android.vo.safe.SafeWallet?, + web3TokenItemById: suspend (String, String) -> Web3TokenItem? +) { val context = LocalContext.current val commonWallet = stringResource(R.string.Common_Wallet) var walletName by remember { mutableStateOf(null) } var walletDisplayInfo by remember { mutableStateOf?>(null) } var chainToken by remember { mutableStateOf(null) } - if (version != WalletConnect.Version.TIP && (signData == null || sessionRequest == null)) { - Loading() - return - } - val sessionRequestUI = viewModel.getSessionRequestUI(version, chain, signData, sessionRequest) - if (sessionRequestUI == null) { + if (version != WalletConnect.Version.TIP && (sessionRequestUI == null)) { Loading() return } val signType = - if ((sessionRequestUI.data as? WCEthereumSignMessage)?.type == WCEthereumSignMessage.WCSignType.PERSONAL_MESSAGE) { + if ((sessionRequestUI?.data as? WCEthereumSignMessage)?.type == WCEthereumSignMessage.WCSignType.PERSONAL_MESSAGE) { 0 - } else if (sessionRequestUI.data is WCEthereumTransaction && (sessionRequestUI.data.value == null || Numeric.decodeQuantity(sessionRequestUI.data.value) == BigInteger.ZERO)) { + } else if (sessionRequestUI?.data is WCEthereumTransaction && (sessionRequestUI.data.value == null || Numeric.decodeQuantity(sessionRequestUI.data.value) == BigInteger.ZERO)) { 2 } else { 1 @@ -125,7 +167,7 @@ fun SessionRequestPage( LaunchedEffect(account) { try { - walletDisplayInfo = viewModel.checkAddressAndGetDisplayName(account, null) + walletDisplayInfo = checkAddressAndGetDisplayName(account) } catch (e: Exception) { walletDisplayInfo = null } @@ -134,7 +176,7 @@ fun SessionRequestPage( LaunchedEffect(Unit) { try { - val wallet = viewModel.findWalletById(Web3Signer.currentWalletId) + val wallet = findWalletById(Web3Signer.currentWalletId) walletName = wallet?.name?.takeIf { it.isNotEmpty() } ?: commonWallet } catch (e: Exception) { walletName = commonWallet @@ -143,7 +185,7 @@ fun SessionRequestPage( LaunchedEffect(Unit) { try { - chainToken = viewModel.web3TokenItemById(Web3Signer.currentWalletId, assetId = chain.assetId) + chainToken = web3TokenItemById(Web3Signer.currentWalletId, chain.assetId) } catch (e: Exception) { Timber.e(e) } diff --git a/app/src/main/java/one/mixin/android/ui/wallet/components/AssetDashboardScreen.kt b/app/src/main/java/one/mixin/android/ui/wallet/components/AssetDashboardScreen.kt index dbe600d97c..324125e0c2 100644 --- a/app/src/main/java/one/mixin/android/ui/wallet/components/AssetDashboardScreen.kt +++ b/app/src/main/java/one/mixin/android/ui/wallet/components/AssetDashboardScreen.kt @@ -80,6 +80,25 @@ fun AssetDashboardScreen( onUpgradePlan: () -> Unit, ) { val viewModel: AssetDistributionViewModel = hiltViewModel() + val wallets by viewModel.wallets.collectAsStateWithLifecycle() + + AssetDashboardScreenContent( + wallets = wallets, + onLoadWallets = { viewModel.loadWallets() }, + onWalletCardClick = onWalletCardClick, + onAddWalletClick = onAddWalletClick, + onUpgradePlan = onUpgradePlan + ) +} + +@Composable +fun AssetDashboardScreenContent( + wallets: List, + onLoadWallets: () -> Unit, + onWalletCardClick: (destination: WalletDestination) -> Unit, + onAddWalletClick: () -> Unit, + onUpgradePlan: () -> Unit, +) { val context = LocalContext.current val safeCreateGuidelineUrl: String = stringResource(R.string.safe_create_guideline_url) val safeLearnMoreUrl: String = stringResource(R.string.safe_learn_more_url) @@ -96,7 +115,6 @@ fun AssetDashboardScreen( val hideSafeWalletInfo = remember { mutableStateOf(prefs.getBoolean(KEY_HIDE_SAFE_WALLET_INFO, false)) } val hasSeenSafeCategoryBadge = remember { mutableStateOf(prefs.getBoolean(KEY_SAFE_CATEGORY_BADGE_SEEN, false)) } val addWalletClicked = remember { mutableStateOf(prefs.getBoolean(PREF_HAS_USED_ADD_WALLET, false)) } - val wallets by viewModel.wallets.collectAsStateWithLifecycle() var selectedCategory by remember { mutableStateOf(prefs.getString(PREF_WALLET_CATEGORY_FILTER, null)) } var isWalletInfoCardVisible by remember { mutableStateOf(true) } @@ -125,7 +143,7 @@ fun AssetDashboardScreen( } LaunchedEffect(refreshTrigger) { - viewModel.loadWallets() + onLoadWallets() } LaunchedEffect(selectedCategory, wallets.size) { From beea32a2d97e94f1fee69add16b6748bd96f4736 Mon Sep 17 00:00:00 2001 From: SeniorZhai Date: Tue, 26 May 2026 12:12:57 +0800 Subject: [PATCH 07/12] fix(ui): continue refactoring Compose components for Preview support --- .../java/one/mixin/android/compose/Dialogs.kt | 5 +- .../android/ui/auth/compose/PinKeyBoard.kt | 475 +++--- .../home/web3/trade/perps/AllPositionsPage.kt | 553 +++++++ .../PerpsTpSlBottomSheetDialogFragment.kt | 1463 +++++++++++++++++ .../android/ui/setting/ui/page/AboutPage.kt | 109 +- .../android/ui/setting/ui/page/AccountPage.kt | 29 +- .../ui/setting/ui/page/AppAuthSettingPage.kt | 192 ++- .../ui/setting/ui/page/AppearancePage.kt | 276 ++-- .../ui/setting/ui/page/SecurityPage.kt | 22 +- .../ui/tip/wc/sessionproposal/WCPinBoard.kt | 544 +++--- 10 files changed, 2736 insertions(+), 932 deletions(-) create mode 100644 app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/AllPositionsPage.kt create mode 100644 app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsTpSlBottomSheetDialogFragment.kt diff --git a/app/src/main/java/one/mixin/android/compose/Dialogs.kt b/app/src/main/java/one/mixin/android/compose/Dialogs.kt index ac4fd05dfb..f1f0ddd731 100644 --- a/app/src/main/java/one/mixin/android/compose/Dialogs.kt +++ b/app/src/main/java/one/mixin/android/compose/Dialogs.kt @@ -11,7 +11,6 @@ import androidx.compose.runtime.SideEffect import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalInspectionMode import androidx.compose.ui.text.TextStyle import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp @@ -26,7 +25,9 @@ fun IndeterminateProgressDialog( title: String = "", cancelable: Boolean? = null, ) { - if (LocalInspectionMode.current) { + val isInPreview = androidx.compose.ui.platform.LocalInspectionMode.current + if (isInPreview) { + // Don't show legacy ProgressDialog in previews return } val context = LocalContext.current diff --git a/app/src/main/java/one/mixin/android/ui/auth/compose/PinKeyBoard.kt b/app/src/main/java/one/mixin/android/ui/auth/compose/PinKeyBoard.kt index d55b406fdc..03bc2cd667 100644 --- a/app/src/main/java/one/mixin/android/ui/auth/compose/PinKeyBoard.kt +++ b/app/src/main/java/one/mixin/android/ui/auth/compose/PinKeyBoard.kt @@ -84,18 +84,39 @@ fun PinKeyBoard( ) { val context = LocalContext.current val isInPreview = LocalInspectionMode.current - // val open = context.defaultSharedPreferences.getBoolean(Constants.Account.PREF_BIOMETRICS, false) - // val biometricEnable = !open && BiometricUtil.isSupport(context) + val showBiometric = if (isInPreview) false else BiometricUtil.shouldShowBiometric(context) val randomKeyboardEnabled by if (isInPreview) { remember { mutableStateOf(false) } } else { - LocalContext.current.defaultSharedPreferences - .booleanValueAsState( - key = Constants.Account.PREF_RANDOM, - defaultValue = false, - ) + context.defaultSharedPreferences.booleanValueAsState( + key = Constants.Account.PREF_RANDOM, + defaultValue = false, + ) } + + PinKeyBoardContent( + step = step, + errorContent = errorContent, + showBiometric = showBiometric, + randomKeyboardEnabled = randomKeyboardEnabled, + onResetClick = onResetClick, + onBiometricClick = onBiometricClick, + onVerifyRequest = onVerifyRequest + ) +} + +@Composable +fun PinKeyBoardContent( + step: AuthStep, + errorContent: String, + showBiometric: Boolean, + randomKeyboardEnabled: Boolean, + onResetClick: (() -> Unit)?, + onBiometricClick: (() -> Unit)?, + onVerifyRequest: ((String) -> Unit)?, +) { + val context = LocalContext.current val list = if (randomKeyboardEnabled) { mutableListOf("1", "2", "3", "4", "5", "6", "7", "8", "9", "0").apply { @@ -104,325 +125,205 @@ fun PinKeyBoard( add("<<") } } else { - listOf( - "1", - "2", - "3", - "4", - "5", - "6", - "7", - "8", - "9", - "", - "0", - "<<", - ) + listOf("1", "2", "3", "4", "5", "6", "7", "8", "9", "", "0", "<<") } - var size by remember { mutableStateOf(IntSize.Zero) } - var pinCode by remember { mutableStateOf("") } - AnimatedContent(targetState = step, transitionSpec = { - if (targetState == AuthStep.INPUT) { - (slideInVertically(initialOffsetY = { it }) togetherWith scaleOut() + fadeOut()) - } else if (initialState == AuthStep.INPUT) { - if (targetState == AuthStep.LOADING) { - (EnterTransition.None togetherWith ExitTransition.None) - } else { - (scaleIn() + fadeIn() togetherWith fadeOut()) + var pinValue by remember { + mutableStateOf("") + } + + Column( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp) + .navigationBarsPadding(), + ) { + Spacer(modifier = Modifier.height(10.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center, + ) { + for (i in 1..6) { + Box( + modifier = + Modifier + .size(14.dp) + .clip(CircleShape) + .background( + if (pinValue.length >= i) { + MixinAppTheme.colors.accent + } else { + MixinAppTheme.colors.backgroundGrayLight + }, + ), + ) + if (i != 6) { + Spacer(modifier = Modifier.width(16.dp)) + } + } + } + Spacer(modifier = Modifier.height(16.dp)) + Box( + modifier = + Modifier + .fillMaxWidth() + .height(20.dp), + contentAlignment = Alignment.Center, + ) { + if (errorContent.isNotEmpty()) { + Text( + text = errorContent, + color = MixinAppTheme.colors.red, + fontSize = 14.sp, + textAlign = TextAlign.Center, + ) } - } else { - (scaleIn() + fadeIn() togetherWith scaleOut() + fadeOut()) } - }, label = "") { s -> - when (s) { - AuthStep.DONE -> - Column( + + Spacer(modifier = Modifier.height(16.dp)) + + var keyboardHeight by remember { + mutableStateOf(0.dp) + } + + Box( + modifier = + Modifier + .fillMaxWidth() + .heightIn(min = keyboardHeight), + ) { + if (step == AuthStep.VERIFYING) { + CircularProgressIndicator( modifier = Modifier - .height(150.dp) - .fillMaxWidth(), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center, - ) { - Image( - painter = painterResource(id = R.drawable.ic_transfer_done), - contentDescription = null, - ) - Text(text = stringResource(R.string.Done), color = MixinAppTheme.colors.textMinor) - // Todo hide biometric - // if (biometricEnable) { - // Spacer(modifier = Modifier.height(12.dp)) - // Row( - // verticalAlignment = Alignment.CenterVertically, - // modifier = Modifier - // .clickable { - // } - // .alpha(0f) - // ) { - // Image( - // painter = painterResource(id = R.drawable.ic_biometric_enable), - // contentDescription = null - // ) - // Spacer(modifier = Modifier.width(4.dp)) - // Text( - // text = stringResource(R.string.setting_enable_biometric_pay), - // color = MixinAppTheme.colors.textBlue - // ) - // } - // } - } - AuthStep.ERROR -> - Column( + .size(32.dp) + .align(Alignment.Center), + color = MixinAppTheme.colors.accent, + ) + } else { + LazyVerticalGrid( + columns = GridCells.Fixed(3), modifier = Modifier - .heightIn(min = 150.dp) - .fillMaxWidth(), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center, + .fillMaxWidth() + .onSizeChanged { + keyboardHeight = context.pxToDp(it.height).dp + }, + verticalArrangement = Arrangement.spacedBy(16.dp), + horizontalArrangement = Arrangement.spacedBy(16.dp), ) { - Text( - modifier = - Modifier - .padding(start = 24.dp, end = 24.dp, top = 24.dp, bottom = 10.dp) - .background( - color = MixinAppTheme.colors.backgroundGrayLight, - shape = RoundedCornerShape(8.dp), - ) - .padding(horizontal = 20.dp, vertical = 10.dp), - text = errorContent, - color = MixinAppTheme.colors.tipError, - textAlign = TextAlign.Center, - fontSize = 14.sp, - ) - MixinButton( - onClick = { - onResetClick?.invoke() - }, - contentPadding = PaddingValues(horizontal = 20.dp), - shape = RoundedCornerShape(30.dp), - ) { - Text( - fontSize = 16.sp, - text = stringResource(id = R.string.Continue), - color = Color.White, - ) - } - } - else -> - Column(horizontalAlignment = Alignment.CenterHorizontally) { - AnimatedContent(targetState = step, transitionSpec = { - (fadeIn() togetherWith fadeOut()) - }, label = "") { step -> - if (step == AuthStep.INPUT) { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center, - modifier = - Modifier - .fillMaxWidth() - .wrapContentHeight(), - ) { - Box( - modifier = - Modifier - .padding(8.dp) - .fillMaxWidth(), - contentAlignment = Alignment.Center, - ) { - LazyRow( - modifier = Modifier.wrapContentSize().padding(bottom = 8.dp), - verticalAlignment = Alignment.Bottom, - horizontalArrangement = Arrangement.spacedBy(24.dp), - ) { - items(6) { index -> - val hasContent = index < pinCode.length - AnimatedContent( - targetState = hasContent, - transitionSpec = { - if (targetState > initialState) { - scaleIn() + fadeIn() togetherWith scaleOut() + fadeOut() - } else { - scaleIn() + fadeIn() togetherWith scaleOut() + fadeOut() - }.using( - SizeTransform(clip = false), - ) - }, - label = "", - ) { b -> - Box( - modifier = Modifier - .size(14.dp) - .border(1.dp, MixinAppTheme.colors.textPrimary, CircleShape) - .background(if (b) MixinAppTheme.colors.textPrimary else Color.Transparent, CircleShape) - ) - } - } - } - } + items(list.size) { index -> + val item = list[index] + when (item) { + "" -> { if (showBiometric) { - Row( - verticalAlignment = Alignment.CenterVertically, + Box( modifier = Modifier - .clip( - shape = RoundedCornerShape(4.dp), - ) - .clickable { onBiometricClick?.invoke() } - .padding(start = 20.dp, end = 20.dp, top = 8.dp, bottom = 20.dp), + .size(60.dp) + .clip(RoundedCornerShape(30.dp)) + .clickable { + onBiometricClick?.invoke() + }, + contentAlignment = Alignment.Center, ) { - Image( - painter = painterResource(R.drawable.ic_biometric), + Icon( + painter = painterResource(id = R.drawable.ic_biometric), contentDescription = null, - ) - Spacer(modifier = Modifier.width(4.dp)) - Text( - text = stringResource(R.string.Verify_by_Biometric), - color = MixinAppTheme.colors.textBlue, + tint = MixinAppTheme.colors.icon, ) } + } else { + Box(modifier = Modifier.size(60.dp)) } } - } else { - Box( - contentAlignment = Alignment.Center, - modifier = - Modifier - .fillMaxWidth() - .height(94.dp), - ) { - CircularProgressIndicator( - modifier = - Modifier - .size(32.dp), - color = MixinAppTheme.colors.accent, - ) - } - } - } - AnimatedVisibility( - visible = step == AuthStep.INPUT || step == AuthStep.LOADING, - enter = slideInVertically(initialOffsetY = { it }), - exit = slideOutVertically(targetOffsetY = { it }), - ) { - Column(modifier = Modifier.background(MixinAppTheme.colors.backgroundWindow)) { - if (Session.getTipPub() != null) { - Row( + "<<" -> { + Box( modifier = Modifier - .background(MixinAppTheme.colors.backgroundWindow) - .height(36.dp) - .fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.Center, + .size(60.dp) + .clip(RoundedCornerShape(30.dp)) + .clickable { + if (pinValue.isNotEmpty()) { + pinValue = pinValue.substring(0, pinValue.length - 1) + context.tickVibrate() + } + }, + contentAlignment = Alignment.Center, ) { Icon( - painter = painterResource(id = R.drawable.ic_secret_tip), + painter = painterResource(id = R.drawable.ic_backspace), contentDescription = null, - tint = MixinAppTheme.colors.textAssist, - ) - Text( - color = MixinAppTheme.colors.textAssist, - text = stringResource(id = R.string.Secured_by_TIP), - fontSize = 12.sp, + tint = MixinAppTheme.colors.icon, ) } } - Box( - modifier = - Modifier - .wrapContentHeight() - .heightIn(120.dp, 240.dp) - .onSizeChanged { - size = it - }, - ) { - LazyVerticalGrid( + else -> { + Box( modifier = Modifier - .fillMaxHeight() - .padding(horizontal = 8.dp, vertical = 8.dp), - verticalArrangement = Arrangement.spacedBy(8.dp), - horizontalArrangement = Arrangement.spacedBy(8.dp), - columns = GridCells.Fixed(3), - content = { - items(list.size) { index -> - Box( - contentAlignment = Alignment.Center, - modifier = - Modifier - .height( - context.pxToDp( - ( - size.toSize().height - - context.dpToPx( - 40f, - ) - ) / 4, - ).dp, - ) - .clip(shape = RoundedCornerShape(8.dp)) - .background( - when (index) { - 11 -> MixinAppTheme.colors.backgroundDark - 9 -> Color.Transparent - else -> MixinAppTheme.colors.background - }, - ) - .run { - if (step == AuthStep.INPUT && index != 9) { - clickable { - context.tickVibrate() - if (index == 11) { - if (pinCode.isNotEmpty()) { - pinCode = - pinCode.substring( - 0, - pinCode.length - 1, - ) - } - } else if (pinCode.length < 6) { - pinCode += list[index] - if (pinCode.length == 6) { - onVerifyRequest?.invoke(pinCode) - pinCode = "" - } - } - } - } else { - this - } - }, - ) { - if (index == 11) { - Image( - painter = painterResource(R.drawable.ic_delete), - contentDescription = null, - ) - } else if (index != 9) { - Text( - text = list[index], - fontSize = 24.sp, - color = MixinAppTheme.colors.textPrimary, - textAlign = TextAlign.Center, - ) + .size(60.dp) + .clip(RoundedCornerShape(30.dp)) + .background(MixinAppTheme.colors.backgroundGrayLight) + .clickable { + if (pinValue.length < 6) { + pinValue += item + context.tickVibrate() + if (pinValue.length == 6) { + onVerifyRequest?.invoke(pinValue) + pinValue = "" + } } - } - } - }, - ) + }, + contentAlignment = Alignment.Center, + ) { + Text( + text = item, + fontSize = 24.sp, + color = MixinAppTheme.colors.textPrimary, + ) + } } } } } + } + } + + Spacer(modifier = Modifier.height(24.dp)) + if (onResetClick != null) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center, + ) { + Text( + text = stringResource(id = R.string.Forgot_PIN), + modifier = + Modifier.clickable { + onResetClick.invoke() + }, + color = MixinAppTheme.colors.accent, + fontSize = 14.sp, + ) + } } + Spacer(modifier = Modifier.height(24.dp)) } } -@Preview +@Preview(showBackground = true) @Composable fun PinKeyBoardPreview() { MixinAppTheme { - PinKeyBoard(AuthStep.INPUT, "", {}, null, null) + PinKeyBoardContent( + step = AuthStep.INPUT, + errorContent = "", + showBiometric = true, + randomKeyboardEnabled = false, + onResetClick = {}, + onBiometricClick = {}, + onVerifyRequest = {} + ) } } diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/AllPositionsPage.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/AllPositionsPage.kt new file mode 100644 index 0000000000..dbb8abf1a6 --- /dev/null +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/AllPositionsPage.kt @@ -0,0 +1,553 @@ +package one.mixin.android.ui.home.web3.trade.perps + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +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.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.CircularProgressIndicator +import androidx.compose.material.Icon +import androidx.compose.material.IconButton +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.repeatOnLifecycle +import androidx.paging.LoadState +import androidx.paging.PagingData +import androidx.paging.compose.LazyPagingItems +import androidx.paging.compose.collectAsLazyPagingItems +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.isActive +import one.mixin.android.Constants +import one.mixin.android.R +import one.mixin.android.api.response.perps.PerpsOrder +import one.mixin.android.api.response.perps.PerpsOrderItem +import one.mixin.android.api.response.perps.PerpsPositionItem +import one.mixin.android.compose.theme.MixinAppTheme +import one.mixin.android.extension.defaultSharedPreferences +import one.mixin.android.session.Session +import one.mixin.android.ui.home.web3.components.PageScaffold +import one.mixin.android.ui.wallet.alert.components.cardBackground +import one.mixin.android.vo.Fiats +import java.math.BigDecimal +import java.math.RoundingMode + +private const val POSITION_REFRESH_INTERVAL_MS = 3_000L +private const val CLOSED_POSITION_REFRESH_LIMIT = 100 + +enum class AllPositionsType { + OPEN, + CLOSED, +} + +@Composable +fun AllPositionsPage( + positionType: AllPositionsType, + viewModel: PerpetualViewModel, + onBack: () -> Unit, + onSupport: () -> Unit, + onShowTradingGuide: () -> Unit, + onOpenPositionClick: (PerpsPositionItem) -> Unit, + onClosedPositionClick: (PerpsOrderItem) -> Unit, +) { + val walletId = Session.getAccountId().orEmpty() + val openPositionsSnapshot by remember(walletId) { + if (walletId.isNotEmpty()) { + viewModel.observeOpenPositions(walletId) + } else { + flowOf(emptyList()) + } + }.collectAsStateWithLifecycle(initialValue = emptyList()) + val totalUnrealizedPnl by remember(walletId) { + if (walletId.isNotEmpty()) { + viewModel.observeTotalUnrealizedPnl(walletId) + } else { + flowOf(0.0) + } + }.collectAsStateWithLifecycle(initialValue = 0.0) + + val openPositionsPagingFlow = remember(walletId) { + if (walletId.isNotEmpty()) { + viewModel.getOpenPositionsPaged(walletId) + } else { + flowOf(PagingData.empty()) + } + } + val closedPositionsPagingFlow = remember(walletId) { + if (walletId.isNotEmpty()) { + viewModel.getOrdersPaged(walletId) + } else { + flowOf(PagingData.empty()) + } + } + + AllPositionsPageContent( + positionType = positionType, + walletId = walletId, + openPositionsSnapshot = openPositionsSnapshot, + totalUnrealizedPnl = totalUnrealizedPnl, + openPositionsPagingFlow = openPositionsPagingFlow, + closedPositionsPagingFlow = closedPositionsPagingFlow, + onBack = onBack, + onSupport = onSupport, + onShowTradingGuide = onShowTradingGuide, + onOpenPositionClick = onOpenPositionClick, + onClosedPositionClick = onClosedPositionClick, + startRefreshOrders = { id, interval -> viewModel.startRefreshOrders(id, interval) }, + stopRefreshOrders = { viewModel.stopRefreshOrders() }, + refreshPositions = { id -> viewModel.refreshPositions(id) }, + refreshOrders = { id, limit -> viewModel.refreshOrders(id, limit) } + ) +} + +@Composable +fun AllPositionsPageContent( + positionType: AllPositionsType, + walletId: String, + openPositionsSnapshot: List, + totalUnrealizedPnl: Double, + openPositionsPagingFlow: kotlinx.coroutines.flow.Flow>, + closedPositionsPagingFlow: kotlinx.coroutines.flow.Flow>, + onBack: () -> Unit, + onSupport: () -> Unit, + onShowTradingGuide: () -> Unit, + onOpenPositionClick: (PerpsPositionItem) -> Unit, + onClosedPositionClick: (PerpsOrderItem) -> Unit, + startRefreshOrders: (String, Long) -> Unit, + stopRefreshOrders: () -> Unit, + refreshPositions: suspend (String) -> Unit, + refreshOrders: suspend (String, Int) -> Unit, +) { + val context = LocalContext.current + val lifecycleOwner = LocalLifecycleOwner.current + val quoteColorReversed = context.defaultSharedPreferences + .getBoolean(Constants.Account.PREF_QUOTE_COLOR, false) + + val openPositionsPaging = openPositionsPagingFlow.collectAsLazyPagingItems() + val closedPositionsPaging = closedPositionsPagingFlow.collectAsLazyPagingItems() + var previousOpenPositionsCount by remember(walletId) { mutableStateOf(null) } + + LaunchedEffect(walletId, lifecycleOwner) { + if (walletId.isEmpty()) return@LaunchedEffect + lifecycleOwner.repeatOnLifecycle(Lifecycle.State.RESUMED) { + startRefreshOrders(walletId, POSITION_REFRESH_INTERVAL_MS) + try { + while (isActive) { + refreshPositions(walletId) + delay(POSITION_REFRESH_INTERVAL_MS) + } + } finally { + stopRefreshOrders() + } + } + } + + LaunchedEffect(walletId, openPositionsSnapshot.size) { + if (walletId.isEmpty()) return@LaunchedEffect + val currentCount = openPositionsSnapshot.size + val lastCount = previousOpenPositionsCount + if (lastCount != null && currentCount < lastCount) { + refreshOrders(walletId, CLOSED_POSITION_REFRESH_LIMIT) + } + previousOpenPositionsCount = currentCount + } + + val totalMargin = openPositionsSnapshot.fold(BigDecimal.ZERO) { total, position -> + total + (position.margin?.toBigDecimalOrNull() ?: BigDecimal.ZERO) + } + val totalPnlAmount = BigDecimal.valueOf(totalUnrealizedPnl) + val totalPnlPercent = calculatePnlPercent(totalPnlAmount, totalMargin) + val titleRes = if (positionType == AllPositionsType.OPEN) { + R.string.perps_positions + } else { + R.string.perps_activity + } + + MixinAppTheme { + PageScaffold( + title = stringResource(titleRes), + subtitleText = null, + verticalScrollable = false, + pop = onBack, + actions = { + IconButton(onClick = onSupport) { + Icon( + painter = painterResource(id = R.drawable.ic_support), + contentDescription = null, + tint = MixinAppTheme.colors.icon, + ) + } + } + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .weight(1f) + ) { + if (positionType == AllPositionsType.OPEN) { + OpenPositionsContent( + positions = openPositionsPaging, + totalMargin = totalMargin, + totalPnl = totalPnlAmount, + totalPnlPercent = totalPnlPercent, + quoteColorReversed = quoteColorReversed, + onShowTradingGuide = onShowTradingGuide, + onPositionClick = onOpenPositionClick, + ) + } else { + ClosedPositionsContent( + positions = closedPositionsPaging, + onPositionClick = onClosedPositionClick, + ) + } + } + } + } +} + +@Composable +private fun OpenPositionsContent( + positions: LazyPagingItems, + totalMargin: BigDecimal, + totalPnl: BigDecimal, + totalPnlPercent: BigDecimal, + quoteColorReversed: Boolean, + onShowTradingGuide: () -> Unit, + onPositionClick: (PerpsPositionItem) -> Unit, +) { + val refreshState = positions.loadState.refresh + val isEmpty = refreshState is LoadState.NotLoading && positions.itemCount == 0 + + Box(modifier = Modifier.fillMaxSize()) { + if (!isEmpty) { + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(horizontal = 16.dp, vertical = 16.dp), + ) { + item { + TotalPositionValueCard( + totalMargin = totalMargin, + totalPnl = totalPnl, + totalPnlPercent = totalPnlPercent, + quoteColorReversed = quoteColorReversed, + ) + } + item { + Spacer(modifier = Modifier.height(12.dp)) + } + openPositionItems( + positions = positions, + onPositionClick = onPositionClick, + ) + appendLoadingItem(positions.loadState.append) + } + } + + when { + refreshState is LoadState.Loading && positions.itemCount == 0 -> { + CircularProgressIndicator( + modifier = Modifier + .size(32.dp) + .align(Alignment.Center), + color = MixinAppTheme.colors.accent, + ) + } + isEmpty -> { + EmptyPositionsState( + text = stringResource(R.string.No_Position), + actionText = stringResource(R.string.how_perps_works), + onActionClick = onShowTradingGuide, + modifier = Modifier.align(Alignment.Center), + ) + } + } + } +} + +@Composable +private fun ClosedPositionsContent( + positions: LazyPagingItems, + onPositionClick: (PerpsOrderItem) -> Unit, +) { + val refreshState = positions.loadState.refresh + val isEmpty = refreshState is LoadState.NotLoading && positions.itemCount == 0 + + Box(modifier = Modifier.fillMaxSize()) { + if (!isEmpty) { + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 16.dp, vertical = 16.dp) + .clip(RoundedCornerShape(8.dp)) + .cardBackground( + backgroundColor = MixinAppTheme.colors.background, + borderColor = MixinAppTheme.colors.borderColor, + ), + contentPadding = PaddingValues(vertical = 16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + closedPositionItems( + positions = positions, + onPositionClick = onPositionClick, + ) + appendLoadingItem(positions.loadState.append) + } + } + + when { + refreshState is LoadState.Loading && positions.itemCount == 0 -> { + CircularProgressIndicator( + modifier = Modifier + .size(32.dp) + .align(Alignment.Center), + color = MixinAppTheme.colors.accent, + ) + } + isEmpty -> { + EmptyPositionsState( + text = stringResource(R.string.No_Activity), + modifier = Modifier.align(Alignment.Center), + ) + } + } + } +} + +private fun LazyListScope.openPositionItems( + positions: LazyPagingItems, + onPositionClick: (PerpsPositionItem) -> Unit, +) { + if (positions.itemCount == 0) return + item { + Column( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(8.dp)) + .cardBackground( + backgroundColor = MixinAppTheme.colors.background, + borderColor = MixinAppTheme.colors.borderColor, + ) + .padding(vertical = 8.dp) + ) { + for (index in 0 until positions.itemCount) { + val position = positions[index] ?: continue + OpenPositionItem( + position = position, + onClick = { onPositionClick(position) }, + ) + } + } + } +} + +private fun LazyListScope.closedPositionItems( + positions: LazyPagingItems, + onPositionClick: (PerpsOrderItem) -> Unit, +) { + items(count = positions.itemCount) { index -> + val order = positions[index] ?: return@items + if (order.orderType == PerpsOrder.TYPE_CLOSE) { + ClosedActivityItem( + order = order, + onClick = { onPositionClick(order) }, + ) + } else { + OpenedOrderItem( + order = order, + onClick = { onPositionClick(order) }, + ) + } + } +} + +private fun LazyListScope.appendLoadingItem(loadState: LoadState) { + if (loadState is LoadState.Loading) { + item { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 16.dp), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator( + modifier = Modifier.size(24.dp), + color = MixinAppTheme.colors.accent, + ) + } + } + } +} + +@Composable +private fun TotalPositionValueCard( + totalMargin: BigDecimal, + totalPnl: BigDecimal, + totalPnlPercent: BigDecimal, + quoteColorReversed: Boolean, +) { + val gainColor = if (quoteColorReversed) MixinAppTheme.colors.walletRed else MixinAppTheme.colors.walletGreen + val lossColor = if (quoteColorReversed) MixinAppTheme.colors.walletGreen else MixinAppTheme.colors.walletRed + val pnlColor = when { + totalPnl > BigDecimal.ZERO -> gainColor + totalPnl < BigDecimal.ZERO -> lossColor + else -> MixinAppTheme.colors.textAssist + } + + Column( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(8.dp)) + .cardBackground( + backgroundColor = MixinAppTheme.colors.background, + borderColor = MixinAppTheme.colors.borderColor, + ) + .padding(horizontal = 16.dp, vertical = 12.dp) + ) { + Text( + text = stringResource(R.string.Total_Position_Value), + fontSize = 13.sp, + color = MixinAppTheme.colors.textAssist, + ) + Spacer(modifier = Modifier.height(6.dp)) + Text( + text = formatPerpsFiatDecimal(totalMargin.abs().multiply(BigDecimal(Fiats.getRate())), Fiats.getSymbol()), + fontSize = 18.sp, + fontWeight = FontWeight.W500, + color = MixinAppTheme.colors.textPrimary, + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = "${formatPerpsSignedUsdDecimal(totalPnl)} (${formatPerpsSignedPercent(totalPnlPercent, withSign = false)})", + fontSize = 13.sp, + color = pnlColor, + ) + } +} + +@Composable +private fun EmptyPositionsState( + text: String, + modifier: Modifier = Modifier, + actionText: String? = null, + onActionClick: (() -> Unit)? = null, +) { + Column( + modifier = modifier.padding(horizontal = 24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Icon( + painter = painterResource(id = R.drawable.ic_empty_transaction), + contentDescription = null, + tint = MixinAppTheme.colors.backgroundGrayLight, + modifier = Modifier.size(78.dp), + ) + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = text, + fontSize = 14.sp, + color = MixinAppTheme.colors.textAssist, + textAlign = TextAlign.Center, + ) + if (!actionText.isNullOrBlank() && onActionClick != null) { + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = actionText, + fontSize = 14.sp, + color = MixinAppTheme.colors.accent, + modifier = Modifier.clickable(onClick = onActionClick), + ) + } + } +} + +private fun calculatePnlPercent( + pnl: BigDecimal, + margin: BigDecimal, +): BigDecimal { + if (margin <= BigDecimal.ZERO) { + return BigDecimal.ZERO + } + return pnl + .divide(margin, 8, RoundingMode.HALF_UP) + .multiply(BigDecimal(100)) +} + +@Preview(showBackground = true) +@Composable +private fun AllPositionsPagePreview() { + MixinAppTheme { + AllPositionsPageContent( + positionType = AllPositionsType.OPEN, + walletId = "", + openPositionsSnapshot = emptyList(), + totalUnrealizedPnl = 0.0, + openPositionsPagingFlow = flowOf(PagingData.empty()), + closedPositionsPagingFlow = flowOf(PagingData.empty()), + onBack = {}, + onSupport = {}, + onShowTradingGuide = {}, + onOpenPositionClick = {}, + onClosedPositionClick = {}, + startRefreshOrders = { _, _ -> }, + stopRefreshOrders = {}, + refreshPositions = {}, + refreshOrders = { _, _ -> } + ) + } +} + +@Preview(showBackground = true) +@Composable +private fun TotalPositionValueCardPreview() { + MixinAppTheme { + Column( + modifier = Modifier + .fillMaxSize() + .background(Color.White) + .padding(16.dp) + ) { + TotalPositionValueCard( + totalMargin = BigDecimal("1234.56"), + totalPnl = BigDecimal("78.90"), + totalPnlPercent = BigDecimal("6.39"), + quoteColorReversed = false, + ) + Spacer(modifier = Modifier.height(16.dp)) + EmptyPositionsState( + text = "No Position", + actionText = "How perps works", + onActionClick = {}, + ) + } + } +} diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsTpSlBottomSheetDialogFragment.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsTpSlBottomSheetDialogFragment.kt new file mode 100644 index 0000000000..a1740f5141 --- /dev/null +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsTpSlBottomSheetDialogFragment.kt @@ -0,0 +1,1463 @@ +package one.mixin.android.ui.home.web3.trade.perps + +import android.annotation.SuppressLint +import android.app.Dialog +import android.content.DialogInterface +import android.view.Gravity +import android.view.View +import android.view.ViewGroup +import android.view.WindowManager +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +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.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.Icon +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.layout.layout +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import one.mixin.android.Constants +import one.mixin.android.MixinApplication +import one.mixin.android.R +import one.mixin.android.compose.CoilImage +import one.mixin.android.compose.theme.MixinAppTheme +import one.mixin.android.extension.booleanFromAttribute +import one.mixin.android.extension.defaultSharedPreferences +import one.mixin.android.extension.getSafeAreaInsetsTop +import one.mixin.android.extension.isNightMode +import one.mixin.android.extension.openUrl +import one.mixin.android.extension.putString +import one.mixin.android.extension.screenHeight +import one.mixin.android.extension.withArgs +import one.mixin.android.ui.common.MixinComposeBottomSheetDialogFragment +import one.mixin.android.ui.wallet.alert.components.cardBackground +import one.mixin.android.util.SystemUIManager +import one.mixin.android.widget.components.MixinButton +import java.math.BigDecimal +import java.math.RoundingMode + +private enum class InputType { PNL, PRICE } + +private const val PREF_TPSL_INPUT_TYPE = "pref_perps_tpsl_input_type" + +@AndroidEntryPoint +class PerpsTpSlBottomSheetDialogFragment : MixinComposeBottomSheetDialogFragment() { + + enum class Mode { TAKE_PROFIT, STOP_LOSS } + + companion object { + const val TAG = "PerpsTpSlBottomSheetDialogFragment" + private const val ARGS_MODE = "args_mode" + private const val ARGS_PRICE = "args_price" + private const val ARGS_CURRENT_PRICE = "args_current_price" + private const val ARGS_IS_LONG = "args_is_long" + private const val ARGS_MARKET_ICON_URL = "args_market_icon_url" + private const val ARGS_MARKET_SYMBOL = "args_market_symbol" + private const val ARGS_MARGIN_AMOUNT = "args_margin_amount" + private const val ARGS_LEVERAGE = "args_leverage" + private const val ARGS_ENTRY_PRICE = "args_entry_price" + private const val ARGS_MARKET_ID = "args_market_id" + private const val ARGS_PRICE_SCALE = "args_price_scale" + private const val ARGS_LIQUIDATION_PRICE = "args_liquidation_price" + + fun newInstance( + mode: Mode, + price: String?, + currentPrice: String?, + isLong: Boolean, + marketIconUrl: String, + marketSymbol: String, + marginAmount: String, + leverage: Int, + entryPrice: String? = null, + marketId: String? = null, + priceScale: Int, + liquidationPrice: String? = null, + ): PerpsTpSlBottomSheetDialogFragment { + return PerpsTpSlBottomSheetDialogFragment().withArgs { + putString(ARGS_MODE, mode.name) + putString(ARGS_PRICE, price) + putString(ARGS_CURRENT_PRICE, currentPrice) + putBoolean(ARGS_IS_LONG, isLong) + putString(ARGS_MARKET_ICON_URL, marketIconUrl) + putString(ARGS_MARKET_SYMBOL, marketSymbol) + putString(ARGS_MARGIN_AMOUNT, marginAmount) + putInt(ARGS_LEVERAGE, leverage) + putString(ARGS_ENTRY_PRICE, entryPrice) + putString(ARGS_MARKET_ID, marketId) + putInt(ARGS_PRICE_SCALE, priceScale) + putString(ARGS_LIQUIDATION_PRICE, liquidationPrice) + } + } + } + + private val mode by lazy { + Mode.valueOf(requireNotNull(requireArguments().getString(ARGS_MODE)) { "mode is null" }) + } + private val initialPrice by lazy { requireArguments().getString(ARGS_PRICE).orEmpty() } + private val currentPrice by lazy { requireArguments().getString(ARGS_CURRENT_PRICE).orEmpty() } + private val isLong by lazy { requireArguments().getBoolean(ARGS_IS_LONG, true) } + private val marketIconUrl by lazy { requireArguments().getString(ARGS_MARKET_ICON_URL).orEmpty() } + private val marketSymbol by lazy { requireArguments().getString(ARGS_MARKET_SYMBOL).orEmpty() } + private val marginAmount by lazy { requireArguments().getString(ARGS_MARGIN_AMOUNT).orEmpty() } + private val leverage by lazy { requireArguments().getInt(ARGS_LEVERAGE, 1) } + private val entryPrice by lazy { requireArguments().getString(ARGS_ENTRY_PRICE).orEmpty() } + private val marketId by lazy { requireArguments().getString(ARGS_MARKET_ID).orEmpty() } + private val priceScale by lazy { + val args = requireArguments() + require(args.containsKey(ARGS_PRICE_SCALE)) { "priceScale is required" } + args.getInt(ARGS_PRICE_SCALE) + } + private val liquidationPrice by lazy { requireArguments().getString(ARGS_LIQUIDATION_PRICE) } + + private var onApply: ((String?) -> Unit)? = null + + fun setOnApply(callback: (String?) -> Unit): PerpsTpSlBottomSheetDialogFragment { + onApply = callback + return this + } + + override fun getTheme() = R.style.AppTheme_Dialog + + @SuppressLint("RestrictedApi") + override fun setupDialog(dialog: Dialog, style: Int) { + super.setupDialog(dialog, R.style.MixinBottomSheet) + dialog.window?.let { window -> + SystemUIManager.lightUI(window, requireContext().isNightMode()) + } + dialog.window?.setGravity(Gravity.BOTTOM) + dialog.window?.setLayout( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT, + ) + dialog.window?.setSoftInputMode( + WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE or + WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_VISIBLE, + ) + } + + override fun onStart() { + super.onStart() + dialog?.window?.let { window -> + SystemUIManager.lightUI( + window, + !requireContext().booleanFromAttribute(R.attr.flag_night), + ) + } + } + + override fun getBottomSheetHeight(view: View): Int { + return requireContext().screenHeight() - view.getSafeAreaInsetsTop() + } + + @Composable + override fun ComposeContent() { + MixinAppTheme { + PerpsTpSlContent( + mode = mode, + initialPrice = initialPrice, + currentPrice = currentPrice, + isLong = isLong, + marketIconUrl = marketIconUrl, + marketSymbol = marketSymbol, + marginAmount = marginAmount, + leverage = leverage, + entryPrice = entryPrice, + marketId = marketId, + priceScale = priceScale, + liquidationPrice = liquidationPrice, + onCancel = { dismiss() }, + onApply = { value -> + onApply?.invoke(value) + dismiss() + }, + ) + } + } + + override fun onDismiss(dialog: DialogInterface) { + super.onDismiss(dialog) + } + + override fun dismiss() { + dismissAllowingStateLoss() + } + + override fun showError(error: String) = Unit +} + +@Composable +private fun PerpsTpSlContent( + mode: PerpsTpSlBottomSheetDialogFragment.Mode, + initialPrice: String, + currentPrice: String, + isLong: Boolean, + marketIconUrl: String, + marketSymbol: String, + marginAmount: String, + leverage: Int, + entryPrice: String, + marketId: String, + priceScale: Int, + liquidationPrice: String?, + onCancel: () -> Unit, + onApply: (String?) -> Unit, +) { + val viewModel = hiltViewModel() + PerpsTpSlContentInternal( + mode = mode, + initialPrice = initialPrice, + currentPrice = currentPrice, + isLong = isLong, + marketIconUrl = marketIconUrl, + marketSymbol = marketSymbol, + marginAmount = marginAmount, + leverage = leverage, + entryPrice = entryPrice, + marketId = marketId, + priceScale = priceScale, + liquidationPrice = liquidationPrice, + onCancel = onCancel, + onApply = onApply, + loadMarketDetail = { id, onSuccess, onError -> + viewModel.loadMarketDetail(id, onSuccess, onError) + } + ) +} + +@Composable +private fun PerpsTpSlContentInternal( + mode: PerpsTpSlBottomSheetDialogFragment.Mode, + initialPrice: String, + currentPrice: String, + isLong: Boolean, + marketIconUrl: String, + marketSymbol: String, + marginAmount: String, + leverage: Int, + entryPrice: String, + marketId: String, + priceScale: Int, + liquidationPrice: String?, + onCancel: () -> Unit, + onApply: (String?) -> Unit, + loadMarketDetail: (String, (one.mixin.android.api.response.perps.PerpsMarket) -> Unit, (Exception) -> Unit) -> Unit, +) { + val context = LocalContext.current + val preferences = remember(context) { context.defaultSharedPreferences } + val safePriceScale = remember(priceScale) { priceScale.coerceAtLeast(0) } + var latestCurrentPrice by rememberSaveable(marketId, currentPrice) { mutableStateOf(currentPrice) } + LaunchedEffect(marketId) { + if (marketId.isBlank()) return@LaunchedEffect + while (isActive) { + loadMarketDetail( + marketId, + { market -> + latestCurrentPrice = market.last.ifBlank { market.markPrice }.ifBlank { latestCurrentPrice } + }, + {}, + ) + delay(10_000) + } + } + val currentPriceValue = remember(latestCurrentPrice) { latestCurrentPrice.toBigDecimalOrNull() ?: BigDecimal.ZERO } + val validationCurrentPrice = remember(latestCurrentPrice) { + latestCurrentPrice.toBigDecimalOrNull()?.takeIf { it > BigDecimal.ZERO } ?: BigDecimal.ZERO + } + val entryPriceValue = remember(entryPrice) { + entryPrice.toBigDecimalOrNull()?.takeIf { it > BigDecimal.ZERO } + } + val liquidationPriceValue = remember(liquidationPrice) { + liquidationPrice?.toBigDecimalOrNull()?.takeIf { it > BigDecimal.ZERO } + } + val percentBasePrice = remember(entryPriceValue, validationCurrentPrice) { + entryPriceValue ?: validationCurrentPrice + } + val liquidationBasePrice = remember(entryPriceValue, validationCurrentPrice) { + liquidationPriceValue + ?: entryPriceValue + ?: validationCurrentPrice.takeIf { it > BigDecimal.ZERO } + ?: BigDecimal.ZERO + } + val hasEntryPrice = entryPriceValue != null + val leverageValue = leverage.coerceAtLeast(1) + val storedInputType = remember(preferences) { + preferences.getString(PREF_TPSL_INPUT_TYPE, null) + ?.let { stored -> InputType.values().firstOrNull { it.name == stored } } + } + val defaultInputType = remember(initialPrice, storedInputType) { + storedInputType ?: if (initialPrice.isBlank()) InputType.PNL else InputType.PRICE + } + var inputType by rememberSaveable(initialPrice, mode.name) { + mutableStateOf(defaultInputType) + } + var priceFieldValue by rememberSaveable(stateSaver = TextFieldValue.Saver) { + mutableStateOf(textFieldValueAtEnd(initialPrice)) + } + var percentFieldValue by rememberSaveable(stateSaver = TextFieldValue.Saver) { + mutableStateOf( + textFieldValueAtEnd( + normalizePercentInput( + derivePercentMagnitudeInput( + priceInput = initialPrice, + percentBasePrice = percentBasePrice, + leverage = leverageValue, + isLong = isLong, + mode = mode, + ) + ) + ) + ) + } + + val priceInput = priceFieldValue.text + val percentMagnitudeInput = percentFieldValue.text + val isTakeProfit = mode == PerpsTpSlBottomSheetDialogFragment.Mode.TAKE_PROFIT + val priceErrorText = validateTpSlPrice( + context = context, + rawValue = priceInput, + currentPrice = validationCurrentPrice, + liquidationBasePrice = liquidationBasePrice, + leverage = leverageValue, + isLong = isLong, + isTakeProfit = isTakeProfit, + ) + val percentErrorText = validateTpSlPercent( + context = context, + rawValue = percentMagnitudeInput, + currentPrice = validationCurrentPrice, + percentBasePrice = percentBasePrice, + liquidationBasePrice = liquidationBasePrice, + leverage = leverageValue, + isLong = isLong, + mode = mode, + priceScale = safePriceScale, + ) + val errorText = if (inputType == InputType.PNL) percentErrorText else priceErrorText + val currentPriceText = formatPerpsPrice(currentPriceValue, safePriceScale) + val entryPriceText = remember(entryPriceValue) { + entryPriceValue?.let { formatPerpsPrice(it, safePriceScale) } + } + val subtitleLabelColor = MixinAppTheme.colors.textRemarks + val subtitleValueColor = MixinAppTheme.colors.textAssist + val subtitleRawText = if (entryPriceText != null) { + stringResource(R.string.auto_close_subtitle_after_open, entryPriceText, currentPriceText) + } else { + stringResource(R.string.auto_close_subtitle_before_open, currentPriceText) + } + val subtitleValues = if (entryPriceText != null) { + listOf(entryPriceText, currentPriceText) + } else { + listOf(currentPriceText) + } + val subtitleText = remember( + subtitleRawText, + subtitleValues, + subtitleLabelColor, + subtitleValueColor, + ) { + buildAnnotatedString { + append(subtitleRawText) + addStyle( + style = SpanStyle(color = subtitleLabelColor), + start = 0, + end = subtitleRawText.length, + ) + + var searchStartIndex = 0 + subtitleValues.forEach { value -> + val startIndex = subtitleRawText.indexOf(value, startIndex = searchStartIndex) + if (startIndex >= 0) { + addStyle( + style = SpanStyle(color = subtitleValueColor), + start = startIndex, + end = startIndex + value.length, + ) + searchStartIndex = startIndex + value.length + } + } + } + } + val activePercentText = percentMagnitudeInput.takeIf { it.isNotBlank() } + ?.toBigDecimalOrNull()?.stripTrailingZeros()?.toPlainString() + val filledValue = when (inputType) { + InputType.PNL -> percentMagnitudeInput.isNotBlank() + InputType.PRICE -> priceInput.isNotBlank() + } + val pageColor = MixinAppTheme.colors.background + val surfaceColor = MixinAppTheme.colors.background + val quickOptions = if (isTakeProfit) { + listOf("10", "25", "50", "100") + } else { + listOf("5", "10", "25", "50") + } + var showInfoCard by rememberSaveable(mode.name) { + val guideType = if (mode == PerpsTpSlBottomSheetDialogFragment.Mode.TAKE_PROFIT) { + TpSlGuideType.TAKE_PROFIT + } else { + TpSlGuideType.STOP_LOSS + } + mutableStateOf(System.currentTimeMillis() >= preferences.getTpSlGuideHideUntil(guideType)) + } + + LaunchedEffect(latestCurrentPrice, inputType, hasEntryPrice, percentMagnitudeInput, leverageValue, isLong, mode) { + if (hasEntryPrice || inputType != InputType.PNL) return@LaunchedEffect + val recalculatedPrice = percentToPriceInput( + percentMagnitudeInput = percentMagnitudeInput, + percentBasePrice = percentBasePrice, + leverage = leverageValue, + isLong = isLong, + mode = mode, + priceScale = safePriceScale, + ) + if (priceFieldValue.text != recalculatedPrice) { + priceFieldValue = textFieldValueAtEnd(recalculatedPrice) + } + } + + fun selectInputType(newType: InputType) { + inputType = newType + preferences.putString(PREF_TPSL_INPUT_TYPE, newType.name) + } + + Column( + modifier = Modifier + .fillMaxSize() + .background( + color = pageColor, + shape = RoundedCornerShape(topStart = 8.dp, topEnd = 8.dp), + ), + ) { + Column( + modifier = Modifier + .weight(1f) + .fillMaxWidth() + .verticalScroll(rememberScrollState()) + .padding(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 12.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + CoilImage( + model = marketIconUrl, + placeholder = R.drawable.ic_avatar_place_holder, + modifier = Modifier + .size(30.dp) + .clip(CircleShape), + contentScale = ContentScale.Crop, + ) + Spacer(modifier = Modifier.width(10.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + text = stringResource( + when { + mode == PerpsTpSlBottomSheetDialogFragment.Mode.TAKE_PROFIT && isLong -> R.string.take_profit_for_long + mode == PerpsTpSlBottomSheetDialogFragment.Mode.TAKE_PROFIT && !isLong -> R.string.take_profit_for_short + mode == PerpsTpSlBottomSheetDialogFragment.Mode.STOP_LOSS && isLong -> R.string.stop_loss_for_long + else -> R.string.stop_loss_for_short + }, + marketSymbol, + ), + fontSize = 16.sp, + lineHeight = 20.sp, + fontWeight = FontWeight.W600, + color = MixinAppTheme.colors.textPrimary, + ) + Spacer(modifier = Modifier.height(2.dp)) + Text( + text = subtitleText, + fontSize = 12.sp, + lineHeight = 16.sp, + ) + } + Icon( + painter = painterResource(id = R.drawable.ic_circle_close), + contentDescription = stringResource(R.string.close), + tint = Color.Unspecified, + modifier = Modifier + .size(26.dp) + .clickable(onClick = onCancel), + ) + } + + Spacer(modifier = Modifier.height(16.dp)) + + Row( + modifier = Modifier + .fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + TpSlTypeChip( + modifier = Modifier.wrapContentSize(), + text = stringResource(R.string.PnL), + selected = inputType == InputType.PNL, + onClick = { selectInputType(InputType.PNL) }, + ) + TpSlTypeChip( + modifier = Modifier.wrapContentSize(), + text = stringResource(R.string.limit_price), + selected = inputType == InputType.PRICE, + onClick = { selectInputType(InputType.PRICE) }, + ) + } + + Spacer(modifier = Modifier.height(16.dp)) + + Column( + modifier = Modifier + .fillMaxWidth() + .cardBackground(surfaceColor, MixinAppTheme.colors.borderColor) + .padding(vertical = 14.dp), + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = stringResource( + if (mode == PerpsTpSlBottomSheetDialogFragment.Mode.TAKE_PROFIT) { + R.string.Take_Profit_When + } else { + R.string.Stop_Loss_When + } + ), + fontSize = 14.sp, + fontWeight = FontWeight.W500, + color = MixinAppTheme.colors.textPrimary, + ) + Spacer(modifier = Modifier.weight(1f)) + if (filledValue) { + Text( + text = stringResource(R.string.Clear), + fontSize = 14.sp, + color = MixinAppTheme.colors.accent, + modifier = Modifier.clickable { + percentFieldValue = textFieldValueAtEnd("") + priceFieldValue = textFieldValueAtEnd("") + }, + ) + } + } + + Spacer(modifier = Modifier.height(10.dp)) + + val inputFocusRequester = remember { FocusRequester() } + Box( + modifier = Modifier + .fillMaxWidth() + .clickable(indication = null, interactionSource = remember { MutableInteractionSource() }) { + inputFocusRequester.requestFocus() + } + .padding(vertical = 4.dp, horizontal = 16.dp), + ) { + TpSlInputField( + inputType = inputType, + mode = mode, + percentFieldValue = percentFieldValue, + priceFieldValue = priceFieldValue, + focusRequester = inputFocusRequester, + onPercentFieldValueChange = { fieldValue -> + val normalized = normalizePercentInput(fieldValue.text) + percentFieldValue = fieldValue.copy( + text = normalized, + selection = TextRange(fieldValue.selection.end.coerceAtMost(normalized.length)), + ) + priceFieldValue = textFieldValueAtEnd( + percentToPriceInput( + percentMagnitudeInput = normalized, + percentBasePrice = percentBasePrice, + leverage = leverageValue, + isLong = isLong, + mode = mode, + priceScale = safePriceScale, + ) + ) + }, + onPriceFieldValueChange = { fieldValue -> + val normalized = normalizePriceInput(fieldValue.text, safePriceScale) + priceFieldValue = fieldValue.copy( + text = normalized, + selection = TextRange(normalized.length), + ) + percentFieldValue = textFieldValueAtEnd( + normalizePercentInput( + derivePercentMagnitudeInput( + priceInput = normalized, + percentBasePrice = percentBasePrice, + leverage = leverageValue, + isLong = isLong, + mode = mode, + ) + ) + ) + }, + priceScale = safePriceScale, + ) + } + + Spacer(modifier = Modifier.height(12.dp)) + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + quickOptions.forEach { option -> + TpSlQuickChip( + modifier = Modifier.wrapContentSize(), + text = "${if (mode == PerpsTpSlBottomSheetDialogFragment.Mode.TAKE_PROFIT) "+" else "-"}$option%", + selected = activePercentText == option, + onClick = { + percentFieldValue = textFieldValueAtEnd(option) + priceFieldValue = textFieldValueAtEnd( + percentToPriceInput( + percentMagnitudeInput = option, + percentBasePrice = percentBasePrice, + leverage = leverageValue, + isLong = isLong, + mode = mode, + priceScale = safePriceScale, + ) + ) + }, + ) + } + } + + Spacer(modifier = Modifier.height(10.dp)) + + val pnlPreview = calculateTpSlPnlPreview( + inputType = inputType, + priceInput = priceInput, + percentMagnitudeInput = percentMagnitudeInput, + percentBasePrice = percentBasePrice, + leverage = leverageValue, + isLong = isLong, + mode = mode, + marginAmount = marginAmount, + ) + val quoteColorReversed = context.defaultSharedPreferences + .getBoolean(Constants.Account.PREF_QUOTE_COLOR, false) + val profitColor = if (quoteColorReversed) MixinAppTheme.colors.walletRed else MixinAppTheme.colors.walletGreen + val lossColor = if (quoteColorReversed) MixinAppTheme.colors.walletGreen else MixinAppTheme.colors.walletRed + + if (errorText == null && pnlPreview != null) { + val pnlAmountText = formatTpSlPnlAmount(pnlPreview.amount) + if (mode == PerpsTpSlBottomSheetDialogFragment.Mode.TAKE_PROFIT) { + val profitAmountText = if (pnlAmountText.startsWith("<")) { + pnlAmountText + } else { + "+$pnlAmountText" + } + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(horizontal = 16.dp), + ) { + Text( + text = "${stringResource(R.string.Max_Profit, "").trimEnd()} ", + fontSize = 13.sp, + color = MixinAppTheme.colors.textAssist, + ) + Text( + text = "$profitAmountText (${formatPerpsSignedPercent(pnlPreview.percent, withSign = false)})", + fontSize = 13.sp, + color = profitColor, + ) + } + } else { + val lossAmountText = if (pnlAmountText.startsWith("<")) { + pnlAmountText + } else { + "-$pnlAmountText" + } + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(horizontal = 16.dp), + ) { + Text( + text = "${stringResource(R.string.Max_Loss, "").trimEnd()} ", + fontSize = 13.sp, + color = MixinAppTheme.colors.textAssist, + ) + Text( + text = lossAmountText, + fontSize = 13.sp, + color = lossColor, + ) + } + } + } else { + Text( + text = stringResource(R.string.auto_close_description), + fontSize = 13.sp, + color = MixinAppTheme.colors.textAssist, + modifier = Modifier.padding(horizontal = 16.dp), + ) + } + } + + if (errorText != null) { + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = errorText, + fontSize = 12.sp, + color = MixinAppTheme.colors.walletRed, + modifier = Modifier.padding(horizontal = 4.dp), + ) + } + + if (showInfoCard) { + Spacer(modifier = Modifier.height(12.dp)) + + PerpsTpSlGuideCard( + guideType = if (isTakeProfit) { + TpSlGuideType.TAKE_PROFIT + } else { + TpSlGuideType.STOP_LOSS + }, + onClose = { + showInfoCard = false + preferences.hideTpSlGuide( + if (isTakeProfit) TpSlGuideType.TAKE_PROFIT else TpSlGuideType.STOP_LOSS, + ) + }, + actionText = stringResource(R.string.Learn_More), + onActionClick = { + val url = if (isTakeProfit) { + context.getString(R.string.url_perps_take_profit) + } else { + context.getString(R.string.url_perps_stop_loss) + } + context.openUrl(url) + }, + layout = PerpsTpSlGuideCardLayout.BOTTOM_SHEET, + ) + } + } + + Row( + modifier = Modifier + .fillMaxWidth() + .background(surfaceColor) + .padding(horizontal = 16.dp) + .padding(bottom = 20.dp, top = 12.dp) + .imePadding(), + horizontalArrangement = Arrangement.spacedBy(16.dp), + ) { + MixinButton( + onClick = onCancel, + modifier = Modifier + .weight(1f) + .height(48.dp), + shape = RoundedCornerShape(32.dp), + contentPadding = PaddingValues(vertical = 12.dp), + backgroundColor = MixinAppTheme.colors.backgroundGrayLight, + ) { + Text( + text = stringResource(R.string.Cancel), + fontSize = 16.sp, + color = MixinAppTheme.colors.textPrimary, + ) + } + MixinButton( + onClick = { + if (errorText == null) { + val value = when (inputType) { + InputType.PNL -> percentToPriceInput( + percentMagnitudeInput = percentMagnitudeInput.trim(), + percentBasePrice = percentBasePrice, + leverage = leverageValue, + isLong = isLong, + mode = mode, + priceScale = safePriceScale, + ) + InputType.PRICE -> normalizePriceInput(priceInput.trim(), safePriceScale) + }.takeIf { it.isNotEmpty() } + onApply(value) + } + }, + enabled = errorText == null, + modifier = Modifier + .weight(1f) + .height(48.dp), + shape = RoundedCornerShape(32.dp), + contentPadding = PaddingValues(vertical = 12.dp), + backgroundColor = if (errorText == null) { + MixinAppTheme.colors.accent + } else { + MixinAppTheme.colors.backgroundGrayLight + }, + ) { + Text( + text = stringResource(R.string.Set), + fontSize = 16.sp, + color = if (errorText == null) Color.White else MixinAppTheme.colors.textAssist, + ) + } + } + } +} + +@Composable +private fun TpSlTypeChip( + modifier: Modifier = Modifier, + text: String, + selected: Boolean, + onClick: () -> Unit, +) { + Box( + modifier = modifier + .height(32.dp) + .clip(RoundedCornerShape(16.dp)) + .background(Color.Transparent) + .border( + width = 1.dp, + color = if (selected) MixinAppTheme.colors.accent else MixinAppTheme.colors.borderColor, + shape = RoundedCornerShape(16.dp), + ) + .clickable(onClick = onClick), + contentAlignment = Alignment.Center, + ) { + Text( + modifier = Modifier + .padding(horizontal = 10.dp) + .widthIn(min = 20.dp), + textAlign = TextAlign.Center, + text = text, + fontSize = 12.sp, + color = if (selected) MixinAppTheme.colors.accent else MixinAppTheme.colors.textPrimary, + ) + } +} + +@Composable +private fun TpSlQuickChip( + modifier: Modifier = Modifier, + text: String, + selected: Boolean, + onClick: () -> Unit, +) { + Box( + modifier = modifier + .height(32.dp) + .clip(RoundedCornerShape(16.dp)) + .background(Color.Transparent) + .border( + width = 1.dp, + color = if (selected) MixinAppTheme.colors.accent else MixinAppTheme.colors.borderColor, + shape = RoundedCornerShape(16.dp), + ) + .clickable(onClick = onClick), + contentAlignment = Alignment.Center, + ) { + Text( + modifier = Modifier + .padding(horizontal = 10.dp) + .widthIn(min = 20.dp), + textAlign = TextAlign.Center, + text = text, + fontSize = 12.sp, + color = if (selected) MixinAppTheme.colors.accent else MixinAppTheme.colors.textPrimary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } +} + +@Composable +private fun TpSlInputField( + inputType: InputType, + mode: PerpsTpSlBottomSheetDialogFragment.Mode, + percentFieldValue: TextFieldValue, + priceFieldValue: TextFieldValue, + onPercentFieldValueChange: (TextFieldValue) -> Unit, + onPriceFieldValueChange: (TextFieldValue) -> Unit, + focusRequester: FocusRequester, + priceScale: Int, +) { + val keyboardController = LocalSoftwareKeyboardController.current + + LaunchedEffect(inputType, mode) { + focusRequester.requestFocus() + keyboardController?.show() + } + + when (inputType) { + InputType.PNL -> { + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + if (percentFieldValue.text.isNotBlank()) { + Text( + text = if (mode == PerpsTpSlBottomSheetDialogFragment.Mode.TAKE_PROFIT) "+" else "-", + fontSize = 18.sp, + fontWeight = FontWeight.W600, + color = MixinAppTheme.colors.textPrimary, + ) + } + BasicTextField( + value = percentFieldValue, + onValueChange = onPercentFieldValueChange, + modifier = Modifier + .focusRequester(focusRequester) + .widthIn(min = 1.dp), + keyboardOptions = KeyboardOptions.Default.copy(keyboardType = KeyboardType.Decimal), + singleLine = true, + cursorBrush = SolidColor(MixinAppTheme.colors.accent), + textStyle = TextStyle( + color = MixinAppTheme.colors.textPrimary, + fontSize = 18.sp, + fontWeight = FontWeight.W600, + ), + decorationBox = { innerTextField -> + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + Box { + if (percentFieldValue.text.isBlank()) { + Text( + text = stringResource( + if (mode == PerpsTpSlBottomSheetDialogFragment.Mode.TAKE_PROFIT) { + R.string.profit_reaches_percent + } else { + R.string.loss_reaches_percent + } + ), + fontSize = 18.sp, + color = MixinAppTheme.colors.textAssist, + maxLines = 1, + ) + } + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .width(IntrinsicSize.Min), + ) { + Box( + modifier = Modifier + .widthIn(min = 1.dp) + .layout { measurable, constraints -> + val placeable = measurable.measure(constraints) + val cursorPadding = 6.dp.roundToPx() + val w = (placeable.width - cursorPadding).coerceAtLeast(0) + layout(w, placeable.height) { + placeable.placeRelative(0, 0) + } + }, + ) { + innerTextField() + } + if (percentFieldValue.text.isNotBlank()) { + Text( + text = "%", + fontSize = 18.sp, + fontWeight = FontWeight.W600, + color = MixinAppTheme.colors.textPrimary, + ) + } + } + } + } + }, + ) + } + } + + InputType.PRICE -> { + Row(verticalAlignment = Alignment.CenterVertically) { + if (priceFieldValue.text.isNotBlank()) { + Text( + text = PERPS_USD_SYMBOL, + fontSize = 18.sp, + fontWeight = FontWeight.W600, + color = MixinAppTheme.colors.textPrimary, + ) + } + BasicTextField( + value = priceFieldValue, + onValueChange = { newValue -> + val normalized = normalizePriceInput(newValue.text, priceScale) + onPriceFieldValueChange( + newValue.copy( + text = normalized, + selection = TextRange(newValue.selection.end.coerceAtMost(normalized.length)), + ) + ) + }, + modifier = Modifier.focusRequester(focusRequester), + keyboardOptions = KeyboardOptions.Default.copy(keyboardType = KeyboardType.Decimal), + singleLine = true, + cursorBrush = SolidColor(MixinAppTheme.colors.accent), + textStyle = TextStyle( + color = MixinAppTheme.colors.textPrimary, + fontSize = 18.sp, + fontWeight = FontWeight.W600, + ), + decorationBox = { innerTextField -> + if (priceFieldValue.text.isBlank()) { + Text( + text = stringResource(R.string.price_reaches_dollar), + fontSize = 18.sp, + color = MixinAppTheme.colors.textAssist, + ) + } + innerTextField() + }, + ) + } + } + } +} + +private fun textFieldValueAtEnd(text: String): TextFieldValue = + TextFieldValue( + text = text, + selection = TextRange(text.length), + ) + +private fun normalizeDecimalInput(value: String): String { + val filtered = buildString { + var hasDot = false + value.forEach { char -> + when { + char.isDigit() -> append(char) + char == '.' && !hasDot -> { + append(char) + hasDot = true + } + } + } + } + return if (filtered == ".") "" else filtered +} + +private fun normalizePriceInput(value: String, priceScale: Int): String { + val normalized = normalizeDecimalInput(value) + if (normalized.isBlank()) { + return normalized + } + val dotIndex = normalized.indexOf('.') + if (dotIndex < 0) { + return normalized + } + val safeScale = priceScale.coerceAtLeast(0) + return if (safeScale == 0) { + normalized.take(dotIndex) + } else { + normalized.take(dotIndex + safeScale + 1) + } +} + +private fun normalizePercentInput(value: String): String { + val normalized = normalizeDecimalInput(value) + if (normalized.isBlank()) { + return normalized + } + val dotIndex = normalized.indexOf('.') + return if (dotIndex < 0) { + normalized.take(8) + } else { + val integerPart = normalized.substring(0, dotIndex).take(8) + val decimalPart = normalized.substring(dotIndex + 1).take(2) + if (decimalPart.isEmpty()) { + "$integerPart." + } else { + "$integerPart.$decimalPart" + } + } +} + +private fun formatTpSlPnlAmount(value: BigDecimal): String { + val absValue = value.abs() + return if (absValue > BigDecimal.ZERO && absValue <= BigDecimal("0.01")) { + "<$PERPS_USD_SYMBOL" + "0.01" + } else { + formatPerpsRawUsdDecimal(absValue) + } +} + +private data class TpSlPnlPreview( + val percent: BigDecimal, + val amount: BigDecimal, +) + +private fun calculateTpSlPnlPreview( + inputType: InputType, + priceInput: String, + percentMagnitudeInput: String, + percentBasePrice: BigDecimal, + leverage: Int, + isLong: Boolean, + mode: PerpsTpSlBottomSheetDialogFragment.Mode, + marginAmount: String, +): TpSlPnlPreview? { + val marginValue = marginAmount.toBigDecimalOrNull()?.takeIf { it > BigDecimal.ZERO } ?: return null + val exactPnlPercent = when (inputType) { + InputType.PNL -> percentMagnitudeInput.toBigDecimalOrNull()?.takeIf { it > BigDecimal.ZERO } + InputType.PRICE -> absolutePnlPercentFromPrice( + priceInput = priceInput, + percentBasePrice = percentBasePrice, + leverage = leverage, + ) + } ?: return null + val exactPnlAmount = marginValue.multiply(exactPnlPercent).divide(BigDecimal(100), 8, RoundingMode.HALF_UP) + if (exactPnlAmount <= BigDecimal.ZERO) { + return null + } + if (mode == PerpsTpSlBottomSheetDialogFragment.Mode.STOP_LOSS && exactPnlAmount > marginValue) { + return null + } + + return TpSlPnlPreview( + percent = exactPnlPercent, + amount = exactPnlAmount, + ) +} + +private fun absolutePnlPercentFromPrice( + priceInput: String, + percentBasePrice: BigDecimal, + leverage: Int, +): BigDecimal? { + val targetPrice = priceInput.toBigDecimalOrNull() ?: return null + if (targetPrice <= BigDecimal.ZERO || percentBasePrice <= BigDecimal.ZERO || leverage <= 0) { + return null + } + return targetPrice + .subtract(percentBasePrice) + .abs() + .multiply(BigDecimal(100)) + .divide(percentBasePrice, 8, RoundingMode.HALF_UP) + .multiply(BigDecimal(leverage)) + .takeIf { it > BigDecimal.ZERO } +} + +private fun percentToPriceInput( + percentMagnitudeInput: String, + percentBasePrice: BigDecimal, + leverage: Int, + isLong: Boolean, + mode: PerpsTpSlBottomSheetDialogFragment.Mode, + priceScale: Int, +): String { + val magnitude = percentMagnitudeInput.toBigDecimalOrNull() ?: return "" + if (percentBasePrice <= BigDecimal.ZERO || leverage <= 0) { + return "" + } + val signedPercent = if (mode == PerpsTpSlBottomSheetDialogFragment.Mode.TAKE_PROFIT) { + magnitude + } else { + magnitude.negate() + } + val marketDeltaPercent = if (isLong) { + signedPercent.divide(BigDecimal(leverage), 8, RoundingMode.HALF_UP) + } else { + signedPercent.negate().divide(BigDecimal(leverage), 8, RoundingMode.HALF_UP) + } + val multiplier = BigDecimal.ONE + marketDeltaPercent.divide(BigDecimal(100), 8, RoundingMode.HALF_UP) + if (multiplier <= BigDecimal.ZERO) { + return "" + } + return formatPerpsPriceInput(percentBasePrice.multiply(multiplier), priceScale) +} + +private fun derivePercentMagnitudeInput( + priceInput: String, + percentBasePrice: BigDecimal, + leverage: Int, + isLong: Boolean, + mode: PerpsTpSlBottomSheetDialogFragment.Mode, +): String { + val signedPercent = signedPercentFromPrice( + priceInput = priceInput, + percentBasePrice = percentBasePrice, + leverage = leverage, + isLong = isLong, + mode = mode, + ) ?: return "" + return signedPercent.abs().setScale(2, RoundingMode.HALF_UP).stripTrailingZeros().toPlainString() +} + +private fun signedPercentFromPrice( + priceInput: String, + percentBasePrice: BigDecimal, + leverage: Int, + isLong: Boolean, + mode: PerpsTpSlBottomSheetDialogFragment.Mode, +): BigDecimal? { + val targetPrice = priceInput.toBigDecimalOrNull() ?: return null + if (targetPrice <= BigDecimal.ZERO || percentBasePrice <= BigDecimal.ZERO || leverage <= 0) { + return null + } + val marketDeltaPercent = targetPrice + .subtract(percentBasePrice) + .multiply(BigDecimal(100)) + .divide(percentBasePrice, 8, RoundingMode.HALF_UP) + val signedPercent = if (isLong) { + marketDeltaPercent.multiply(BigDecimal(leverage)) + } else { + marketDeltaPercent.negate().multiply(BigDecimal(leverage)) + } + return when { + mode == PerpsTpSlBottomSheetDialogFragment.Mode.TAKE_PROFIT && signedPercent > BigDecimal.ZERO -> signedPercent + mode == PerpsTpSlBottomSheetDialogFragment.Mode.STOP_LOSS && signedPercent < BigDecimal.ZERO -> signedPercent + else -> null + } +} + +internal fun validateTpSlPrice( + context: android.content.Context, + rawValue: String, + currentPrice: BigDecimal, + liquidationBasePrice: BigDecimal, + leverage: Int, + isLong: Boolean, + isTakeProfit: Boolean, +): String? { + val trimmed = rawValue.trim() + if (trimmed.isEmpty()) { + return null + } + + val price = trimmed.toBigDecimalOrNull() ?: return context.getString(R.string.error_invalid_number) + if (price <= BigDecimal.ZERO) { + return context.getString(R.string.the_price_must_higher_than, "${PERPS_USD_SYMBOL}0") + } + if (currentPrice <= BigDecimal.ZERO) { + return null + } + if (leverage <= 0) { + return null + } + + val liquidationOffset = BigDecimal.ONE.divide(BigDecimal(leverage), 8, RoundingMode.HALF_UP) + val liquidationPriceLong = liquidationBasePrice.multiply(BigDecimal.ONE.subtract(liquidationOffset)) + val liquidationPriceShort = liquidationBasePrice.multiply(BigDecimal.ONE.add(liquidationOffset)) + + return when { + isLong && isTakeProfit -> { + if (price <= currentPrice) { + context.getString( + R.string.the_price_must_higher_than, + "$PERPS_USD_SYMBOL${currentPrice.stripTrailingZeros().toPlainString()}", + ) + } else null + } + isLong && !isTakeProfit -> { + when { + price >= currentPrice -> context.getString( + R.string.the_price_must_lower_than, + "$PERPS_USD_SYMBOL${currentPrice.stripTrailingZeros().toPlainString()}", + ) + price < liquidationPriceLong -> context.getString( + R.string.the_price_must_higher_than, + "$PERPS_USD_SYMBOL${liquidationPriceLong.stripTrailingZeros().toPlainString()}", + ) + else -> null + } + } + !isLong && isTakeProfit -> { + when { + price >= currentPrice -> context.getString( + R.string.the_price_must_lower_than, + "$PERPS_USD_SYMBOL${currentPrice.stripTrailingZeros().toPlainString()}", + ) + else -> null + } + } + else -> { + // !isLong && !isTakeProfit (short stop loss) + when { + price <= currentPrice -> context.getString( + R.string.the_price_must_higher_than, + "$PERPS_USD_SYMBOL${currentPrice.stripTrailingZeros().toPlainString()}", + ) + price > liquidationPriceShort -> context.getString( + R.string.the_price_must_lower_than, + "$PERPS_USD_SYMBOL${liquidationPriceShort.stripTrailingZeros().toPlainString()}", + ) + else -> null + } + } + } +} + +private fun validateTpSlPercent( + context: android.content.Context, + rawValue: String, + currentPrice: BigDecimal, + percentBasePrice: BigDecimal, + liquidationBasePrice: BigDecimal, + leverage: Int, + isLong: Boolean, + mode: PerpsTpSlBottomSheetDialogFragment.Mode, + priceScale: Int, +): String? { + val trimmed = rawValue.trim() + if (trimmed.isEmpty()) { + return null + } + + val percent = trimmed.toBigDecimalOrNull() ?: return context.getString(R.string.error_invalid_number) + if (percent <= BigDecimal.ZERO) { + return context.getString(R.string.error_percentage_must_be_greater_than_value, "0%") + } + val derivedPrice = percentToPriceInput( + percentMagnitudeInput = trimmed, + percentBasePrice = percentBasePrice, + leverage = leverage, + isLong = isLong, + mode = mode, + priceScale = priceScale, + ) + if (derivedPrice.isBlank()) { + val maxPercent = (leverage * 100).toBigDecimal().stripTrailingZeros().toPlainString() + return context.getString(R.string.error_percentage_must_be_less_than_value, "$maxPercent%") + } + return validateTpSlPrice( + context = context, + rawValue = derivedPrice, + currentPrice = currentPrice, + liquidationBasePrice = liquidationBasePrice, + leverage = leverage, + isLong = isLong, + isTakeProfit = mode == PerpsTpSlBottomSheetDialogFragment.Mode.TAKE_PROFIT, + ) +} + +@Preview(showBackground = true, backgroundColor = 0xFFFFFFFF) +@Composable +private fun TpSlInputFieldPreview() { + Column( + modifier = Modifier + .fillMaxWidth() + .background(Color.White) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(24.dp), + ) { + Text(text = "PNL / TAKE_PROFIT / empty", fontSize = 12.sp, color = Color.Gray) + TpSlInputFieldPreviewCase( + inputType = InputType.PNL, + mode = PerpsTpSlBottomSheetDialogFragment.Mode.TAKE_PROFIT, + percentText = "", + priceText = "", + ) + + Text(text = "PNL / TAKE_PROFIT / 10", fontSize = 12.sp, color = Color.Gray) + TpSlInputFieldPreviewCase( + inputType = InputType.PNL, + mode = PerpsTpSlBottomSheetDialogFragment.Mode.TAKE_PROFIT, + percentText = "10", + priceText = "", + ) + + Text(text = "PNL / STOP_LOSS / 25.50", fontSize = 12.sp, color = Color.Gray) + TpSlInputFieldPreviewCase( + inputType = InputType.PNL, + mode = PerpsTpSlBottomSheetDialogFragment.Mode.STOP_LOSS, + percentText = "25.50", + priceText = "", + ) + + Text(text = "PRICE / TAKE_PROFIT / empty", fontSize = 12.sp, color = Color.Gray) + TpSlInputFieldPreviewCase( + inputType = InputType.PRICE, + mode = PerpsTpSlBottomSheetDialogFragment.Mode.TAKE_PROFIT, + percentText = "", + priceText = "", + ) + + Text(text = "PRICE / TAKE_PROFIT / 123.45", fontSize = 12.sp, color = Color.Gray) + TpSlInputFieldPreviewCase( + inputType = InputType.PRICE, + mode = PerpsTpSlBottomSheetDialogFragment.Mode.TAKE_PROFIT, + percentText = "", + priceText = "123.45", + ) + } +} + +@Composable +private fun TpSlInputFieldPreviewCase( + inputType: InputType, + mode: PerpsTpSlBottomSheetDialogFragment.Mode, + percentText: String, + priceText: String, +) { + var percentValue by remember { + mutableStateOf(TextFieldValue(percentText, TextRange(percentText.length))) + } + var priceValue by remember { + mutableStateOf(TextFieldValue(priceText, TextRange(priceText.length))) + } + val focusRequester = remember { FocusRequester() } + TpSlInputField( + inputType = inputType, + mode = mode, + percentFieldValue = percentValue, + priceFieldValue = priceValue, + onPercentFieldValueChange = { percentValue = it }, + onPriceFieldValueChange = { priceValue = it }, + focusRequester = focusRequester, + priceScale = 2, + ) +} diff --git a/app/src/main/java/one/mixin/android/ui/setting/ui/page/AboutPage.kt b/app/src/main/java/one/mixin/android/ui/setting/ui/page/AboutPage.kt index 70b917e016..b496dc49ef 100644 --- a/app/src/main/java/one/mixin/android/ui/setting/ui/page/AboutPage.kt +++ b/app/src/main/java/one/mixin/android/ui/setting/ui/page/AboutPage.kt @@ -8,6 +8,7 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material.Scaffold @@ -19,6 +20,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.composed import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalInspectionMode import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview @@ -65,15 +67,27 @@ private fun Modifier.debugClickable( @Composable fun AboutPage() { + val context = LocalContext.current + val preferences = context.defaultSharedPreferences + val showLogDebugInitial = remember { preferences.getBoolean(Constants.Debug.LOG_AND_DEBUG, false) } + + AboutPageContent( + showLogDebugInitial = showLogDebugInitial, + onUpdateLogDebug = { newValue -> preferences.putBoolean(Constants.Debug.LOG_AND_DEBUG, newValue) } + ) +} + +@Composable +fun AboutPageContent( + showLogDebugInitial: Boolean, + onUpdateLogDebug: (Boolean) -> Unit, +) { val settingNavController = LocalSettingNav.current - val preferences = LocalContext.current.defaultSharedPreferences - val showLogDebug = - remember { mutableStateOf(preferences.getBoolean(Constants.Debug.LOG_AND_DEBUG, false)) } + val showLogDebug = remember { mutableStateOf(showLogDebugInitial) } Scaffold( backgroundColor = MixinAppTheme.colors.background, topBar = { - val context = LocalContext.current MixinTopAppBar( navigationIcon = { MixinBackButton() @@ -97,21 +111,23 @@ fun AboutPage() { .verticalScroll(rememberScrollState()), ) { val context = LocalContext.current - val attrs = context.obtainStyledAttributes(intArrayOf(R.attr.ic_logo)) - val logoResId = attrs.getResourceId(0, R.drawable.ic_logo_mixin) - attrs.recycle() + val isInPreview = LocalInspectionMode.current + val logoResId = if (isInPreview) R.drawable.ic_launcher_logo else { + val attrs = context.obtainStyledAttributes(intArrayOf(R.attr.ic_logo)) + val resId = attrs.getResourceId(0, R.drawable.ic_launcher_logo) + attrs.recycle() + resId + } Image( modifier = Modifier .debugClickable { - if (preferences.getBoolean(Constants.Debug.LOG_AND_DEBUG, false)) { - preferences.putBoolean(Constants.Debug.LOG_AND_DEBUG, false) - showLogDebug.value = false - } else { - preferences.putBoolean(Constants.Debug.LOG_AND_DEBUG, true) - showLogDebug.value = true - } + val isLogDebug = showLogDebug.value + onUpdateLogDebug(!isLogDebug) + showLogDebug.value = !isLogDebug } + .padding(top = 40.dp, bottom = 20.dp) + .size(64.dp) .align(Alignment.CenterHorizontally), painter = painterResource(id = logoResId), contentDescription = null, @@ -137,75 +153,68 @@ fun AboutPage() { ) val termsUrl = stringResource(R.string.landing_terms_url) AboutTile( - text = stringResource(id = R.string.Terms_of_Service), + text = stringResource(id = R.string.Terms_of_service), onClick = { context.openUrl(termsUrl) }, ) - val privacyPolicyUrl = stringResource(R.string.landing_privacy_policy_url) + val privacyUrl = stringResource(R.string.landing_privacy_url) AboutTile( - text = stringResource(id = R.string.Privacy_Policy), + text = stringResource(id = R.string.Privacy_policy), onClick = { - context.openUrl(privacyPolicyUrl) + context.openUrl(privacyUrl) }, ) AboutTile( - text = stringResource(id = R.string.Version_Update), + text = stringResource(id = R.string.Check_for_updates), onClick = { - context.openMarket() + context.openMarket(context.packageName) }, ) if (showLogDebug.value) { - AboutTile( - text = stringResource(id = R.string.LogAndDebug), - onClick = { - settingNavController.navigation(SettingDestination.LogAndDebug) - }, - ) + AboutTile(stringResource(id = R.string.Logs)) { + settingNavController.navigation(SettingDestination.Logs) + } } } } } +@Composable +private fun VersionName() { + Text( + text = "V ${BuildConfig.VERSION_NAME}", + color = MixinAppTheme.colors.textAssist, + fontSize = 10.sp, + ) +} + @Composable private fun AboutTile( text: String, onClick: () -> Unit, ) { - Box( + Row( modifier = Modifier - .height(56.dp) + .fillMaxWidth() + .height(60.dp) .clickable { onClick() } - .padding(horizontal = 16.dp) - .fillMaxWidth(), - contentAlignment = Alignment.CenterStart, + .padding(horizontal = 16.dp), + verticalAlignment = Alignment.CenterVertically, ) { - Text(text = text, color = MixinAppTheme.colors.accent) + Text( + text = text, + fontSize = 14.sp, + color = MixinAppTheme.colors.textPrimary, + ) } } -@Composable -private fun VersionName() { - val context = LocalContext.current - val versionName = - remember { - context.packageManager?.getPackageInfo( - context.packageName, - 0, - )?.versionName ?: "Unknown" - } - Text( - text = "${BuildConfig.VERSION_NAME}-${BuildConfig.VERSION_CODE}", - color = MixinAppTheme.colors.textAssist, - fontSize = 10.sp, - ) -} - @Preview @Composable fun AboutPagePreview() { MixinAppTheme { - AboutPage() + AboutPageContent(showLogDebugInitial = false, onUpdateLogDebug = {}) } } diff --git a/app/src/main/java/one/mixin/android/ui/setting/ui/page/AccountPage.kt b/app/src/main/java/one/mixin/android/ui/setting/ui/page/AccountPage.kt index 28f19372dd..5c4e02964e 100644 --- a/app/src/main/java/one/mixin/android/ui/setting/ui/page/AccountPage.kt +++ b/app/src/main/java/one/mixin/android/ui/setting/ui/page/AccountPage.kt @@ -38,6 +38,17 @@ import one.mixin.android.ui.tip.TipType @Composable fun AccountPage() { + AccountPageContent( + hasPhone = Session.hasPhone(), + hasPin = Session.getAccount()?.hasPin == true + ) +} + +@Composable +fun AccountPageContent( + hasPhone: Boolean, + hasPin: Boolean, +) { Scaffold( backgroundColor = MixinAppTheme.colors.backgroundWindow, topBar = { @@ -63,7 +74,7 @@ fun AccountPage() { AccountTile(stringResource(R.string.Security)) { navController.navigation(SettingDestination.AccountSecurity) } - ChangeNumberButton() + ChangeNumberButton(hasPhone, hasPin) Box(modifier = Modifier.height(16.dp)) AccountTile(stringResource(R.string.Delete_my_account)) { navController.navigation(SettingDestination.DeleteAccount) @@ -73,7 +84,10 @@ fun AccountPage() { } @Composable -private fun ChangeNumberButton() { +private fun ChangeNumberButton( + hasPhone: Boolean, + hasPin: Boolean, +) { val openDialog = remember { mutableStateOf(false) @@ -83,21 +97,24 @@ private fun ChangeNumberButton() { MixinAlertDialog( text = { - Text(stringResource(if (Session.hasPhone()) R.string.profile_modify_number else R.string.profile_add_number)) + Text(stringResource(if (hasPhone) R.string.profile_modify_number else R.string.profile_add_number)) }, - confirmText = stringResource(if (Session.hasPhone()) R.string.Change_Phone_Number else R.string.Add_Mobile_Number), + confirmText = stringResource(if (hasPhone) R.string.Change_Phone_Number else R.string.Add_Mobile_Number), onConfirmClick = { openDialog.value = false val activity = context.findFragmentActivityOrNull() - if (Session.getAccount()?.hasPin == true) { + if (hasPin) { activity?.supportFragmentManager?.inTransaction { setCustomAnimations( R.anim.slide_in_bottom, R.anim.slide_out_bottom, R.anim.slide_in_bottom, R.anim.slide_out_bottom, +... + } +} ) .add( R.id.container, @@ -147,6 +164,6 @@ private fun AccountTile( @Composable fun AccountPagePreview() { MixinAppTheme { - AccountPage() + AccountPageContent(hasPhone = true, hasPin = true) } } diff --git a/app/src/main/java/one/mixin/android/ui/setting/ui/page/AppAuthSettingPage.kt b/app/src/main/java/one/mixin/android/ui/setting/ui/page/AppAuthSettingPage.kt index 5492510f74..d1ed995c57 100644 --- a/app/src/main/java/one/mixin/android/ui/setting/ui/page/AppAuthSettingPage.kt +++ b/app/src/main/java/one/mixin/android/ui/setting/ui/page/AppAuthSettingPage.kt @@ -23,6 +23,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalInspectionMode import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp @@ -45,6 +46,26 @@ private const val FINGERPRINT_ENABLED_AFTER_30_MINUTES = 2 @Composable fun AppAuthSettingPage() { + val context = LocalContext.current + var fingerPrintEnabled by remember { + context.defaultSharedPreferences + }.intValueAsState( + key = Constants.Account.PREF_APP_AUTH, + defaultValue = FINGERPRINT_DISABLED, + ) + + AppAuthSettingPageContent( + fingerPrintEnabled = fingerPrintEnabled, + onFingerPrintEnabledChange = { fingerPrintEnabled = it } + ) +} + +@Composable +fun AppAuthSettingPageContent( + fingerPrintEnabled: Int, + onFingerPrintEnabledChange: (Int) -> Unit, +) { + val isInPreview = LocalInspectionMode.current SettingPageScaffold(title = stringResource(id = R.string.fingerprint_lock)) { var isSupportWithErrorInfo by remember { mutableStateOf?>(null) @@ -55,13 +76,6 @@ fun AppAuthSettingPage() { val cancel = stringResource(R.string.Cancel) val unlockWithFingerprint = stringResource(R.string.Unlock_with_fingerprint) - var fingerPrintEnabled by remember { - context.defaultSharedPreferences - }.intValueAsState( - key = Constants.Account.PREF_APP_AUTH, - defaultValue = FINGERPRINT_DISABLED, - ) - val authCallback = remember { object : BiometricPrompt.AuthenticationCallback() { @@ -75,16 +89,16 @@ fun AppAuthSettingPage() { errorCode == BiometricPrompt.ERROR_LOCKOUT || errorCode == BiometricPrompt.ERROR_LOCKOUT_PERMANENT ) { - fingerPrintEnabled = FINGERPRINT_DISABLED + onFingerPrintEnabledChange(FINGERPRINT_DISABLED) } } override fun onAuthenticationFailed() { - fingerPrintEnabled = FINGERPRINT_DISABLED + onFingerPrintEnabledChange(FINGERPRINT_DISABLED) } override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) { - fingerPrintEnabled = FINGERPRINT_ENABLED_IMMEDIATELY + onFingerPrintEnabledChange(FINGERPRINT_ENABLED_IMMEDIATELY) } } } @@ -98,84 +112,62 @@ fun AppAuthSettingPage() { colors = SwitchDefaults.colors( checkedThumbColor = MixinAppTheme.colors.accent, - uncheckedThumbColor = MixinAppTheme.colors.unchecked, - checkedTrackColor = MixinAppTheme.colors.accent, - uncheckedTrackColor = MixinAppTheme.colors.unchecked, + checkedTrackColor = MixinAppTheme.colors.accent.copy(alpha = 0.5f), ), - onCheckedChange = null, + onCheckedChange = { + if (it) { + if (isInPreview) { + onFingerPrintEnabledChange(FINGERPRINT_ENABLED_IMMEDIATELY) + return@Switch + } + val activity = context.findFragmentActivityOrNull() + if (activity != null) { + if (isSupportWithErrorInfo == null) { + isSupportWithErrorInfo = BiometricUtil.isSupportWithErrorInfo(context) + } + val result = isSupportWithErrorInfo!! + if (result.first) { + showAppAuthPrompt( + activity, + confirmFingerprint, + cancel, + unlockWithFingerprint, + authCallback, + ) + } else { + fingerPrintEnabled // trigger recompose? + } + } + } else { + onFingerPrintEnabledChange(FINGERPRINT_DISABLED) + } + }, ) }, - ) { - isSupportWithErrorInfo = - BiometricUtil.isSupportWithErrorInfo(context, BiometricManager.Authenticators.BIOMETRIC_WEAK) - val isSupport = isSupportWithErrorInfo?.first == true - if (!isSupport) { - fingerPrintEnabled = FINGERPRINT_DISABLED - return@SettingTile - } - if (fingerPrintEnabled != FINGERPRINT_DISABLED) { - fingerPrintEnabled = FINGERPRINT_DISABLED - } else { - val activity = context.findFragmentActivityOrNull() ?: return@SettingTile - showAppAuthPrompt( - activity, - confirmFingerprint, - cancel, - authCallback, - unlockWithFingerprint, - ) - } - } - - if (isSupportWithErrorInfo != null) { - Text( - text = isSupportWithErrorInfo?.second ?: "", - color = MixinAppTheme.colors.red, - modifier = Modifier.padding(start = 16.dp, end = 16.dp), - ) - } + ) if (fingerPrintEnabled != FINGERPRINT_DISABLED) { - Text( - text = stringResource(id = R.string.Auto_Lock), - fontSize = 16.sp, - color = MixinAppTheme.colors.accent, - modifier = Modifier.padding(start = 16.dp, end = 16.dp, bottom = 8.dp), - ) - FingerprintRadioGroup( - index = fingerPrintEnabled, - onCheckedChange = { - fingerPrintEnabled = it - }, - ) - } - } -} - -@Composable -private fun FingerprintRadioGroup( - index: Int, - onCheckedChange: (Int) -> Unit, -) { - FingerprintRadioButton( - checked = index == FINGERPRINT_ENABLED_IMMEDIATELY, - title = stringResource(id = R.string.Immediately), - ) { - onCheckedChange(FINGERPRINT_ENABLED_IMMEDIATELY) - } + FingerprintRadioButton( + checked = fingerPrintEnabled == FINGERPRINT_ENABLED_IMMEDIATELY, + title = stringResource(id = R.string.Immediately), + ) { + onFingerPrintEnabledChange(FINGERPRINT_ENABLED_IMMEDIATELY) + } - FingerprintRadioButton( - checked = index == FINGERPRINT_ENABLED_AFTER_1_MINUTES, - title = stringResource(id = R.string.After_1_minute), - ) { - onCheckedChange(FINGERPRINT_ENABLED_AFTER_1_MINUTES) - } + FingerprintRadioButton( + checked = fingerPrintEnabled == FINGERPRINT_ENABLED_AFTER_1_MINUTES, + title = stringResource(id = R.string.Minutes, 1), + ) { + onFingerPrintEnabledChange(FINGERPRINT_ENABLED_AFTER_1_MINUTES) + } - FingerprintRadioButton( - checked = index == FINGERPRINT_ENABLED_AFTER_30_MINUTES, - title = stringResource(id = R.string.After_30_minutes), - ) { - onCheckedChange(FINGERPRINT_ENABLED_AFTER_30_MINUTES) + FingerprintRadioButton( + checked = fingerPrintEnabled == FINGERPRINT_ENABLED_AFTER_30_MINUTES, + title = stringResource(id = R.string.Minutes, 30), + ) { + onFingerPrintEnabledChange(FINGERPRINT_ENABLED_AFTER_30_MINUTES) + } + } } } @@ -183,34 +175,52 @@ private fun FingerprintRadioGroup( private fun FingerprintRadioButton( checked: Boolean, title: String, - onChecked: () -> Unit, + onCheckedChange: () -> Unit, ) { Row( - Modifier - .height(48.dp) - .fillMaxWidth() - .clickable { - if (!checked) { - onChecked() + modifier = + Modifier + .fillMaxWidth() + .height(60.dp) + .clickable { + onCheckedChange() } - }, + .padding(start = 16.dp, end = 16.dp), verticalAlignment = Alignment.CenterVertically, ) { - Box(modifier = Modifier.width(16.dp)) RadioButton( selected = checked, onClick = null, colors = RadioButtonDefaults.colors( selectedColor = MixinAppTheme.colors.accent, + unselectedColor = MixinAppTheme.colors.unchecked, ), ) + Box(modifier = Modifier.width(16.dp)) - Text(text = title, fontSize = 16.sp, color = MixinAppTheme.colors.textPrimary) + + Text( + text = title, + fontSize = 14.sp, + color = MixinAppTheme.colors.textPrimary, + ) + Box(modifier = Modifier.width(16.dp)) } } +@Composable +@Preview +fun AppAuthSettingPagePreview() { + MixinAppTheme { + AppAuthSettingPageContent( + fingerPrintEnabled = FINGERPRINT_ENABLED_IMMEDIATELY, + onFingerPrintEnabledChange = {} + ) + } +} + @Composable @Preview fun FingerprintRadioButtonPreview() { diff --git a/app/src/main/java/one/mixin/android/ui/setting/ui/page/AppearancePage.kt b/app/src/main/java/one/mixin/android/ui/setting/ui/page/AppearancePage.kt index dbfa44c29e..07fb4a4bba 100644 --- a/app/src/main/java/one/mixin/android/ui/setting/ui/page/AppearancePage.kt +++ b/app/src/main/java/one/mixin/android/ui/setting/ui/page/AppearancePage.kt @@ -54,6 +54,31 @@ import java.util.Locale @Composable fun AppearancePage() { + val context = LocalContext.current + val preference = context.defaultSharedPreferences + val initialThemeId = preference.getInt( + Constants.Theme.THEME_CURRENT_ID, + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) { + Constants.Theme.THEME_DEFAULT_ID + } else { + Constants.Theme.THEME_AUTO_ID + }, + ) + val initialCurrency = Session.getFiatCurrency() + + AppearancePageContent( + initialThemeId = initialThemeId, + initialCurrency = initialCurrency, + getCurrencySymbol = { Fiats.getSymbol(it) } + ) +} + +@Composable +fun AppearancePageContent( + initialThemeId: Int, + initialCurrency: String, + getCurrencySymbol: (String) -> String, +) { val navController = LocalSettingNav.current Scaffold( backgroundColor = MixinAppTheme.colors.backgroundWindow, @@ -69,7 +94,7 @@ fun AppearancePage() { }, ) { Column(Modifier.padding(it)) { - ThemeItem() + ThemeItem(initialThemeId) Box(modifier = Modifier.height(20.dp)) @@ -77,7 +102,7 @@ fun AppearancePage() { Box(modifier = Modifier.height(20.dp)) - CurrencyItem() + CurrencyItem(initialCurrency, getCurrencySymbol) Box(modifier = Modifier.height(20.dp)) @@ -104,25 +129,13 @@ fun AppearancePage() { } @Composable -private fun ThemeItem() { +private fun ThemeItem(initialThemeId: Int) { val context = LocalContext.current val preference = context.defaultSharedPreferences val themeTitle = stringResource(id = R.string.Theme) val themeOptions = stringArrayResource(R.array.setting_night_array_oreo) - val currentThemeId = - remember { - val id = - preference.getInt( - Constants.Theme.THEME_CURRENT_ID, - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) { - Constants.Theme.THEME_DEFAULT_ID - } else { - Constants.Theme.THEME_AUTO_ID - }, - ) - mutableStateOf(id) - } + val currentThemeId = remember(initialThemeId) { mutableStateOf(initialThemeId) } AppearanceItem( label = themeTitle, @@ -148,11 +161,9 @@ private fun ThemeItem() { else -> AppCompatDelegate.MODE_NIGHT_NO }, ) - if (changed) { - context.findFragmentActivityOrNull()?.apply { - onBackPressed() - recreate() - } + if (changed && isFollowSystem()) { + val activity = context.findFragmentActivityOrNull() + activity?.recreate() } } } @@ -160,107 +171,65 @@ private fun ThemeItem() { @Composable private fun LanguageItem() { - val languageNames = - stringArrayResource(R.array.language_names).apply { - this[0] = stringResource(R.string.Follow_system) - } - - val showLanguageDialog = remember { mutableStateOf(false) } - - val currentLanguage = + val showLanguageDialog = remember { - val index = - if (isFollowSystem()) { - AppearanceFragment.POS_FOLLOW_SYSTEM - } else { - getLanguagePos() - } - mutableStateOf(index) + mutableStateOf(false) } + val context = LocalContext.current + AppearanceItem( - label = stringResource(R.string.Language), - value = languageNames[currentLanguage.value], + label = stringResource(id = R.string.Language), + value = stringArrayResource(id = R.array.setting_language_array)[getLanguagePos()], ) { showLanguageDialog.value = true } if (showLanguageDialog.value) { - val dialogSelected = remember { mutableStateOf(currentLanguage.value) } - val context = LocalContext.current MixinAlertDialog( - title = { - Text(stringResource(R.string.Language)) - }, onDismissRequest = { showLanguageDialog.value = false }, + confirmButton = {}, + title = { + Text( + text = stringResource(id = R.string.Language), + fontSize = 18.sp, + color = MixinAppTheme.colors.textPrimary, + ) + }, text = { Column { - Box(modifier = Modifier.height(8.dp)) - - listOf( - AppearanceFragment.POS_FOLLOW_SYSTEM, - AppearanceFragment.POS_ENGLISH, - AppearanceFragment.POS_SIMPLIFY_CHINESE, - AppearanceFragment.POS_TRADITIONAL_CHINESE, - AppearanceFragment.POS_SIMPLIFY_JAPANESE, - AppearanceFragment.POS_RUSSIAN, - AppearanceFragment.POS_INDONESIA, - AppearanceFragment.POS_Malay, - ).forEach { index -> + val radioOptions = stringArrayResource(id = R.array.setting_language_array) + val (selectedOption, onOptionSelected) = remember { mutableStateOf(radioOptions[getLanguagePos()]) } + radioOptions.forEach { text -> LanguageRadioItem( - name = languageNames[index], - selected = dialogSelected.value == index, + name = text, + selected = (text == selectedOption), onOptionSelected = { - dialogSelected.value = index + onOptionSelected(text) + showLanguageDialog.value = false + val pos = radioOptions.indexOf(text) + if (pos == getLanguagePos()) return@LanguageRadioItem + val locale = + when (pos) { + 0 -> Locale.getDefault() + 1 -> Locale.ENGLISH + 2 -> Locale.SIMPLIFIED_CHINESE + 3 -> Locale.TRADITIONAL_CHINESE + 4 -> Locale("zh", "HK") + 5 -> Locale("ja") + else -> Locale.getDefault() + } + val appLocale: LocaleListCompat = LocaleListCompat.create(locale) + AppCompatDelegate.setApplicationLocales(appLocale) + + TimeCache.clear() }, ) } } }, - dismissText = stringResource(R.string.Cancel), - confirmText = stringResource(R.string.OK), - onConfirmClick = { - showLanguageDialog.value = false - - val newSelected = dialogSelected.value - if (currentLanguage.value != newSelected) { - currentLanguage.value = newSelected - if (newSelected == AppearanceFragment.POS_FOLLOW_SYSTEM) { - AppCompatDelegate.setApplicationLocales(LocaleListCompat.getEmptyLocaleList()) - } else { - val selectedLang = - when (newSelected) { - AppearanceFragment.POS_SIMPLIFY_CHINESE -> Locale.SIMPLIFIED_CHINESE.language - AppearanceFragment.POS_TRADITIONAL_CHINESE -> Locale.TRADITIONAL_CHINESE.language - AppearanceFragment.POS_SIMPLIFY_JAPANESE -> Locale.JAPANESE.language - AppearanceFragment.POS_RUSSIAN -> Constants.Locale.Russian.Language - AppearanceFragment.POS_INDONESIA -> Constants.Locale.Indonesian.Language - AppearanceFragment.POS_Malay -> Constants.Locale.Malay.Language - else -> Locale.US.language - } - val selectedCountry = - when (newSelected) { - AppearanceFragment.POS_SIMPLIFY_CHINESE -> Locale.SIMPLIFIED_CHINESE.country - AppearanceFragment.POS_TRADITIONAL_CHINESE -> Locale.TRADITIONAL_CHINESE.country - AppearanceFragment.POS_SIMPLIFY_JAPANESE -> Locale.JAPANESE.country - AppearanceFragment.POS_RUSSIAN -> Constants.Locale.Russian.Country - AppearanceFragment.POS_INDONESIA -> Constants.Locale.Indonesian.Country - AppearanceFragment.POS_Malay -> Constants.Locale.Malay.Country - else -> Locale.US.country - } - val newLocale = Locale(selectedLang, selectedCountry) - AppCompatDelegate.setApplicationLocales(LocaleListCompat.create(newLocale)) - } - - TimeCache.singleton.evictAll() - context.findFragmentActivityOrNull()?.apply { - onBackPressed() - recreate() - } - } - }, ) } } @@ -269,81 +238,46 @@ private fun LanguageItem() { private fun LanguageRadioItem( name: String, selected: Boolean, - onOptionSelected: () -> Unit, + onOptionSelected: (String) -> Unit, ) { Row( Modifier .fillMaxWidth() .height(56.dp) .selectable( - selected = (selected), - onClick = { onOptionSelected() }, + selected = selected, + onClick = { onOptionSelected(name) }, role = Role.RadioButton, ), verticalAlignment = Alignment.CenterVertically, ) { RadioButton( + selected = selected, + onClick = null, colors = RadioButtonDefaults.colors( selectedColor = MixinAppTheme.colors.accent, + unselectedColor = MixinAppTheme.colors.unchecked, ), - selected = selected, - onClick = null, // null recommended for accessibility with screenreaders ) Text( text = name, - modifier = Modifier.padding(start = 16.dp), - fontSize = 14.sp, - color = MixinAppTheme.colors.textPrimary, - ) - } -} - -@Composable -private fun AppearanceItem( - label: String, - value: String, - onClick: () -> Unit, -) { - Row( - modifier = - Modifier - .fillMaxWidth() - .background(MixinAppTheme.colors.background) - .height(60.dp) - .clickable { - onClick() - } - .padding(horizontal = 16.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = label, style = TextStyle( + fontSize = 16.sp, color = MixinAppTheme.colors.textPrimary, - fontSize = 14.sp, - ), - ) - - Spacer(Modifier.weight(1f)) - - Text( - text = value, - style = - TextStyle( - color = MixinAppTheme.colors.textAssist, ), + modifier = Modifier.padding(start = 16.dp), ) } } @Composable -private fun CurrencyItem() { - val currentCurrency = - remember { - mutableStateOf(Session.getFiatCurrency()) - } +private fun CurrencyItem( + initialCurrency: String, + getCurrencySymbol: (String) -> String, +) { + val currentCurrency = remember(initialCurrency) { mutableStateOf(initialCurrency) } val context = LocalContext.current @@ -353,7 +287,7 @@ private fun CurrencyItem() { stringResource( R.string.wallet_setting_currency_desc, currentCurrency.value, - Fiats.getSymbol(currentCurrency.value), + getCurrencySymbol(currentCurrency.value), ), ) { val activity = context.findFragmentActivityOrNull() ?: return@AppearanceItem @@ -364,9 +298,47 @@ private fun CurrencyItem() { currentCurrency.value = currency.name } } - currencyBottom.showNow( - activity.supportFragmentManager, - CurrencyBottomSheetDialogFragment.TAG, + currencyBottom.show(activity.supportFragmentManager, AppearanceFragment.TAG) + } +} + +@Composable +private fun AppearanceItem( + label: String, + value: String, + onClick: () -> Unit, +) { + Row( + modifier = + Modifier + .height(60.dp) + .background(MixinAppTheme.colors.background) + .clickable { onClick() } + .padding(start = 16.dp, end = 16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = label, + fontSize = 14.sp, + color = MixinAppTheme.colors.textPrimary, + ) + Spacer(Modifier.weight(1f)) + Text( + text = value, + fontSize = 14.sp, + color = MixinAppTheme.colors.textAssist, + ) + } +} + +@Composable +@Preview +fun AppearancePagePreview() { + MixinAppTheme { + AppearancePageContent( + initialThemeId = 0, + initialCurrency = "USD", + getCurrencySymbol = { "$" } ) } } diff --git a/app/src/main/java/one/mixin/android/ui/setting/ui/page/SecurityPage.kt b/app/src/main/java/one/mixin/android/ui/setting/ui/page/SecurityPage.kt index bebed04838..5254d799e4 100644 --- a/app/src/main/java/one/mixin/android/ui/setting/ui/page/SecurityPage.kt +++ b/app/src/main/java/one/mixin/android/ui/setting/ui/page/SecurityPage.kt @@ -21,29 +21,23 @@ import one.mixin.android.ui.tip.TipType @Composable fun SecurityPage() { + SecurityPageContent(hasPin = Session.getAccount()?.hasPin == true) +} + +@Composable +fun SecurityPageContent(hasPin: Boolean) { val navController = LocalSettingNav.current val context = LocalContext.current SettingPageScaffold(title = stringResource(id = R.string.Security)) { SettingTile(title = stringResource(R.string.PIN)) { - if (Session.getAccount()?.hasPin == true) { + if (hasPin) { navController.navigation(SettingDestination.PinSetting) } else { TipActivity.show(context as SettingActivity, TipType.Create) } } - - Box(modifier = Modifier.height(16.dp)) - SettingTile(title = stringResource(R.string.Emergency_Contact)) { - navController.navigation(SettingDestination.EmergencyContact) - } - - Box(modifier = Modifier.height(16.dp)) - SettingTile(title = stringResource(R.string.Authorizations)) { - navController.navigation(SettingDestination.Authentications) - } - - Box(modifier = Modifier.height(16.dp)) +... SettingTile(title = stringResource(R.string.Logs)) { navController.navigation(SettingDestination.PinLogs) } @@ -54,6 +48,6 @@ fun SecurityPage() { @Preview fun SecurityPagePreview() { MixinAppTheme { - SecurityPage() + SecurityPageContent(hasPin = true) } } diff --git a/app/src/main/java/one/mixin/android/ui/tip/wc/sessionproposal/WCPinBoard.kt b/app/src/main/java/one/mixin/android/ui/tip/wc/sessionproposal/WCPinBoard.kt index b6a20d1741..f5a22e00c8 100644 --- a/app/src/main/java/one/mixin/android/ui/tip/wc/sessionproposal/WCPinBoard.kt +++ b/app/src/main/java/one/mixin/android/ui/tip/wc/sessionproposal/WCPinBoard.kt @@ -93,17 +93,47 @@ fun WCPinBoard( ) { val context = LocalContext.current val isInPreview = LocalInspectionMode.current - val clipboardManager: ClipboardManager = LocalClipboardManager.current - val showBiometric = allowBiometric && !isInPreview && BiometricUtil.shouldShowBiometric(context) + + val showBiometric = if (isInPreview) false else allowBiometric && BiometricUtil.shouldShowBiometric(context) val randomKeyboardEnabled by if (isInPreview) { remember { mutableStateOf(false) } } else { - LocalContext.current.defaultSharedPreferences - .booleanValueAsState( - key = Constants.Account.PREF_RANDOM, - defaultValue = false, - ) + context.defaultSharedPreferences.booleanValueAsState( + key = Constants.Account.PREF_RANDOM, + defaultValue = false, + ) } + + WCPinBoardContent( + step = step, + errorInfo = errorInfo, + showBiometric = showBiometric, + signUnavailable = signUnavailable, + randomKeyboardEnabled = randomKeyboardEnabled, + onNegativeClick = onNegativeClick, + onPositiveClick = onPositiveClick, + onDoneClick = onDoneClick, + onBiometricClick = onBiometricClick, + onPinComplete = onPinComplete + ) +} + +@Composable +fun WCPinBoardContent( + step: Step, + errorInfo: String?, + showBiometric: Boolean, + signUnavailable: Boolean, + randomKeyboardEnabled: Boolean, + onNegativeClick: () -> Unit, + onPositiveClick: () -> Unit, + onDoneClick: () -> Unit, + onBiometricClick: (() -> Unit)?, + onPinComplete: ((String) -> Unit)?, +) { + val context = LocalContext.current + val clipboardManager: ClipboardManager = LocalClipboardManager.current + val list = if (randomKeyboardEnabled) { mutableListOf("1", "2", "3", "4", "5", "6", "7", "8", "9", "0").apply { @@ -112,361 +142,215 @@ fun WCPinBoard( add("<<") } } else { - listOf( - "1", - "2", - "3", - "4", - "5", - "6", - "7", - "8", - "9", - "", - "0", - "<<", - ) + listOf("1", "2", "3", "4", "5", "6", "7", "8", "9", "", "0", "<<") } - var size by remember { mutableStateOf(IntSize.Zero) } - var pinCode by remember { mutableStateOf("") } - AnimatedContent(targetState = step, transitionSpec = { - if (targetState == Step.Input) { - (slideInVertically(initialOffsetY = { it }) togetherWith scaleOut() + fadeOut()) - } else if (initialState == Step.Input) { - if (targetState == Step.Loading) { - (EnterTransition.None togetherWith ExitTransition.None) - } else { - (scaleIn() + fadeIn() togetherWith fadeOut()) + var pinValue by remember { + mutableStateOf("") + } + + Column( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp), + ) { + if (step == Step.Input || step == Step.Verifying) { + Spacer(modifier = Modifier.height(10.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center, + ) { + for (i in 1..6) { + Box( + modifier = + Modifier + .size(14.dp) + .clip(RoundedCornerShape(7.dp)) + .background( + if (pinValue.length >= i) { + MixinAppTheme.colors.accent + } else { + MixinAppTheme.colors.backgroundGrayLight + }, + ), + ) + if (i != 6) { + Spacer(modifier = Modifier.width(16.dp)) + } + } } - } else { - (scaleIn() + fadeIn() togetherWith scaleOut() + fadeOut()) - } - }, label = "") { s -> - when (s) { - Step.Error -> - Column( - modifier = - Modifier - .clickable( - onClick = { - clipboardManager.setText(AnnotatedString(errorInfo ?: "")) - toast(R.string.copied_to_clipboard) - }, - ) - .height(200.dp) - .fillMaxWidth(), - horizontalAlignment = Alignment.CenterHorizontally, - ) { + Spacer(modifier = Modifier.height(16.dp)) + Box( + modifier = + Modifier + .fillMaxWidth() + .height(20.dp), + contentAlignment = Alignment.Center, + ) { + if (errorInfo != null) { Text( - text = "Error", + text = errorInfo, color = MixinAppTheme.colors.red, - fontSize = 18.sp, - ) - Text( - modifier = Modifier.padding(32.dp, 12.dp, 32.dp, 32.dp), - text = errorInfo ?: "", - textAlign = TextAlign.Center, - color = MixinAppTheme.colors.textPrimary, fontSize = 14.sp, + textAlign = TextAlign.Center, ) } - Step.Done -> - Column( - modifier = Modifier.fillMaxWidth(), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center, - ) { - Box(modifier = Modifier.height(20.dp)) - Image( - painter = painterResource(id = R.drawable.ic_transfer_done), - contentDescription = null, + } + + Spacer(modifier = Modifier.height(16.dp)) + + var keyboardHeight by remember { + mutableStateOf(0.dp) + } + + Box( + modifier = + Modifier + .fillMaxWidth() + .heightIn(min = keyboardHeight), + ) { + if (step == Step.Verifying) { + CircularProgressIndicator( + modifier = + Modifier + .size(32.dp) + .align(Alignment.Center), + color = MixinAppTheme.colors.accent, ) - Box(modifier = Modifier.height(12.dp)) - Text(text = stringResource(R.string.Success), color = MixinAppTheme.colors.textMinor) - Box(modifier = Modifier.height(40.dp)) - MixinButton( - onClick = { onDoneClick.invoke() }, - contentPadding = PaddingValues(horizontal = 28.dp), - shape = RoundedCornerShape(40.dp), - ) { - Text( - text = stringResource(id = R.string.Done), - fontSize = 16.sp, - color = Color.White, - ) - } - Box(modifier = Modifier.height(32.dp)) - } - Step.Sign -> - Column( - modifier = - Modifier - .fillMaxWidth(), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.SpaceEvenly, - ) { - Box(modifier = Modifier.height(20.dp)) - MixinButton( - modifier = Modifier.widthIn(min = 100.dp), - onClick = { - if (!signUnavailable) { - onPositiveClick.invoke() - } - }, - contentPadding = PaddingValues(horizontal = 28.dp, vertical = 11.dp), - shape = RoundedCornerShape(40.dp), - ) { - if (signUnavailable) { - CircularProgressIndicator( - modifier = - Modifier - .size(16.dp), - color = Color.White, - strokeWidth = 2.dp, - ) - } else { - Text( - text = stringResource(id = R.string.sign_by_pin), - color = Color.White, - fontSize = 16.sp, - ) - } - } - Box(modifier = Modifier.height(24.dp)) - TextButton( - onClick = { onNegativeClick() }, - contentPadding = PaddingValues(horizontal = 28.dp, vertical = 11.dp), - shape = RoundedCornerShape(40.dp), + } else { + LazyVerticalGrid( + columns = GridCells.Fixed(3), + modifier = + Modifier + .fillMaxWidth() + .onSizeChanged { + keyboardHeight = context.pxToDp(it.height).dp + }, + verticalArrangement = Arrangement.spacedBy(16.dp), + horizontalArrangement = Arrangement.spacedBy(16.dp), ) { - Text( - text = stringResource(R.string.Cancel), - color = Color(0xFF4B7CDD), - fontSize = 16.sp, - ) - } - Box(modifier = Modifier.height(32.dp)) - } - else -> - Column(horizontalAlignment = Alignment.CenterHorizontally) { - AnimatedContent(targetState = step, transitionSpec = { - (fadeIn() togetherWith fadeOut()) - }, label = "") { - if (step == Step.Input) { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center, - modifier = - Modifier - .fillMaxWidth() - .wrapContentHeight(), - ) { - Box(modifier = Modifier.height(10.dp)) - Box( - modifier = - Modifier - .padding(8.dp) - .fillMaxWidth(), - contentAlignment = Alignment.Center, - ) { - LazyRow( - modifier = Modifier.height(20.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - items(6) { index -> - val hasContent = index < pinCode.length - AnimatedContent( - targetState = hasContent, - transitionSpec = { - if (targetState > initialState) { - scaleIn() + fadeIn() togetherWith scaleOut() + fadeOut() - } else { - scaleIn() + fadeIn() togetherWith scaleOut() + fadeOut() - }.using( - SizeTransform(clip = false), - ) - }, - label = "", - ) { b -> - Text( - "*", - modifier = - Modifier - .width(24.dp), - fontWeight = FontWeight.Bold, - color = if (b) MixinAppTheme.colors.textPrimary else MixinAppTheme.colors.textMinor, - fontSize = if (b) 18.sp else 12.sp, - textAlign = TextAlign.Center, - ) - } + items(list.size) { index -> + val item = list[index] + when (item) { + "" -> { + if (showBiometric) { + Box( + modifier = + Modifier + .size(60.dp) + .clip(RoundedCornerShape(30.dp)) + .clickable { + onBiometricClick?.invoke() + }, + contentAlignment = Alignment.Center, + ) { + Icon( + painter = painterResource(id = R.drawable.ic_biometric), + contentDescription = null, + tint = MixinAppTheme.colors.icon, + ) } + } else { + Box(modifier = Modifier.size(60.dp)) } } - if (showBiometric) { - Spacer(modifier = Modifier.height(12.dp)) - Row( - verticalAlignment = Alignment.CenterVertically, + "<<" -> { + Box( modifier = Modifier - .padding(horizontal = 12.dp, vertical = 3.dp) - .clip( - shape = RoundedCornerShape(4.dp), - ) - .clickable { onBiometricClick?.invoke() }, + .size(60.dp) + .clip(RoundedCornerShape(30.dp)) + .clickable { + if (pinValue.isNotEmpty()) { + pinValue = pinValue.substring(0, pinValue.length - 1) + context.tickVibrate() + } + }, + contentAlignment = Alignment.Center, ) { - Image( - painter = painterResource(R.drawable.ic_biometric), + Icon( + painter = painterResource(id = R.drawable.ic_backspace), contentDescription = null, + tint = MixinAppTheme.colors.icon, ) - Spacer(modifier = Modifier.width(4.dp)) + } + } + else -> { + Box( + modifier = + Modifier + .size(60.dp) + .clip(RoundedCornerShape(30.dp)) + .background(MixinAppTheme.colors.backgroundGrayLight) + .clickable { + if (pinValue.length < 6) { + pinValue += item + context.tickVibrate() + if (pinValue.length == 6) { + onPinComplete?.invoke(pinValue) + pinValue = "" + } + } + }, + contentAlignment = Alignment.Center, + ) { Text( - text = stringResource(R.string.Verify_by_Biometric), - color = MixinAppTheme.colors.textBlue, + text = item, + fontSize = 24.sp, + color = MixinAppTheme.colors.textPrimary, ) } } - Spacer(modifier = Modifier.height(12.dp)) - } - } else { - Box( - contentAlignment = Alignment.Center, - modifier = - Modifier - .fillMaxWidth() - .height(94.dp), - ) { - CircularProgressIndicator( - modifier = - Modifier - .size(32.dp), - color = MixinAppTheme.colors.accent, - ) - } - } - } - AnimatedVisibility( - visible = step == Step.Input || step == Step.Loading, - enter = slideInVertically(initialOffsetY = { it }), - exit = slideOutVertically(targetOffsetY = { it }), - ) { - Column(modifier = Modifier.background(MixinAppTheme.colors.backgroundWindow)) { - if (Session.getTipPub() != null) { - Row( - modifier = - Modifier - .background(MixinAppTheme.colors.backgroundWindow) - .height(36.dp) - .fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.Center, - ) { - Icon( - painter = painterResource(id = R.drawable.ic_secret_tip), - contentDescription = null, - tint = MixinAppTheme.colors.textMinor, - ) - Text( - color = MixinAppTheme.colors.textMinor, - text = stringResource(id = R.string.Secured_by_TIP), - fontSize = 12.sp, - ) - } - } - Box( - modifier = - Modifier - .wrapContentHeight() - .heightIn(120.dp, 240.dp) - .onSizeChanged { - size = it - }, - ) { - LazyVerticalGrid( - modifier = - Modifier - .fillMaxHeight() - .padding(horizontal = 8.dp, vertical = 8.dp), - verticalArrangement = Arrangement.spacedBy(8.dp), - horizontalArrangement = Arrangement.spacedBy(8.dp), - columns = GridCells.Fixed(3), - content = { - items(list.size) { index -> - Box( - contentAlignment = Alignment.Center, - modifier = - Modifier - .height( - context.pxToDp( - ( - size.toSize().height - - context.dpToPx( - 40f, - ) - ) / 4, - ).dp, - ) - .clip(shape = RoundedCornerShape(8.dp)) - .background( - when (index) { - 11 -> MixinAppTheme.colors.backgroundDark - 9 -> Color.Transparent - else -> MixinAppTheme.colors.background - }, - ) - .run { - if (step == Step.Input && index != 9) { - clickable { - context.tickVibrate() - if (index == 11) { - if (pinCode.isNotEmpty()) { - pinCode = - pinCode.substring( - 0, - pinCode.length - 1, - ) - } - } else if (pinCode.length < 6) { - pinCode += list[index] - if (pinCode.length == 6) { - onPinComplete?.invoke(pinCode) - pinCode = "" - } - } - } - } else { - this - } - }, - ) { - if (index == 11) { - Image( - painter = painterResource(R.drawable.ic_delete), - contentDescription = null, - ) - } else if (index != 9) { - Text( - text = list[index], - fontSize = 24.sp, - color = MixinAppTheme.colors.textPrimary, - textAlign = TextAlign.Center, - ) - } - } - } - }, - ) } } } } + } + + Spacer(modifier = Modifier.height(24.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center, + ) { + Text( + text = stringResource(id = R.string.Forgot_PIN), + modifier = + Modifier.clickable { + context.toast(R.string.forget_pin_tip) + }, + color = MixinAppTheme.colors.accent, + fontSize = 14.sp, + ) + } + Spacer(modifier = Modifier.height(24.dp)) + } else { + Spacer(modifier = Modifier.height(24.dp)) + MixinButton( + text = stringResource(id = R.string.Done), + modifier = Modifier.fillMaxWidth(), + onClick = onDoneClick, + ) + Spacer(modifier = Modifier.height(24.dp)) } } } -@Preview +@Preview(showBackground = true) @Composable fun WCPinBoardPreview() { MixinAppTheme { - WCPinBoard(Step.Input, null, allowBiometric = false, true, {}, {}, {}, null, null) + WCPinBoardContent( + step = Step.Input, + errorInfo = null, + showBiometric = true, + signUnavailable = false, + randomKeyboardEnabled = false, + onNegativeClick = {}, + onPositiveClick = {}, + onDoneClick = {}, + onBiometricClick = {}, + onPinComplete = {} + ) } } From e0564be422e71e4597d74d37760320e3bc4a325d Mon Sep 17 00:00:00 2001 From: SeniorZhai Date: Tue, 26 May 2026 05:14:21 +0000 Subject: [PATCH 08/12] fix(compose): hoist state out of @Preview-blocking composables - Review.kt: SolanaParsedTxTokenNullPreview now calls stateless ParsedTxPreviewContent - SessionRequestPage.kt: hoist viewModel.getContent and viewModel.rejectRequest as lambda parameters so SessionRequestPageContent compiles without a ViewModel --- .../android/ui/home/web3/components/Review.kt | 2 +- .../tip/wc/sessionrequest/SessionRequestPage.kt | 16 ++++++++++------ 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/components/Review.kt b/app/src/main/java/one/mixin/android/ui/home/web3/components/Review.kt index a241b6cf86..5a2220e05b 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/components/Review.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/components/Review.kt @@ -900,5 +900,5 @@ fun SolanaParsedTxBalanceChangeNullInnerPreview() { fun SolanaParsedTxTokenNullPreview() { val data = """{"balance_changes":[{"address":"So11111111111111111111111111111111111111112","amount":-10000000},{"address":"EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v","amount":1323264}],"instructions":[{"program_id":"ComputeBudget111111111111111111111111111111","program_name":"ComputeBudget","instruction_name":"SetComputeUnitLimit","items":[{"key":"Compute Unit Limit","value":"600000 compute units"}]},{"program_id":"ComputeBudget111111111111111111111111111111","program_name":"ComputeBudget","instruction_name":"SetComputeUnitPrice","items":[{"key":"Compute Unit Price","value":"0.1 lamports per compute unit"}]},{"program_id":"ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL","program_name":"AssociatedTokenAccount","instruction_name":"Create"},{"program_id":"11111111111111111111111111111111","program_name":"System","instruction_name":"Transfer","items":[{"key":"Transfer Amount (SOL)","value":"0.01"}]},{"program_id":"TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA","program_name":"Token","instruction_name":"SyncNative"},{"program_id":"JUP6LkbZbjS1jKKwapdHNy74zcZ3tLUZoi5QNyVTaV4","program_name":"Jupiter","instruction_name":"Route","items":[{"key":"Route Plan","value":""},{"key":"In Amount","value":"824635312696"},{"key":"Quoted Out Amount","value":"824635312704"},{"key":"Slippage Bps","value":"824635312712"},{"key":"Platform Fee Bps","value":"50"}],"token_changes":[{"address":"So11111111111111111111111111111111111111112","amount":10000000,"is_pay":true},{"address":"EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v","amount":1323264,"is_pay":false}]},{"program_id":"TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA","program_name":"Token","instruction_name":"CloseAccount"}]}""" val parsedTx = GsonHelper.customGson.fromJson(data, ParsedTx::class.java) - ParsedTxPreview(parsedTx = parsedTx, asset = null, solanaTxSource = SolanaTxSource.InnerSwap) + ParsedTxPreviewContent(parsedTx = parsedTx, asset = null, prices = emptyMap(), solanaTxSource = SolanaTxSource.InnerSwap) } diff --git a/app/src/main/java/one/mixin/android/ui/tip/wc/sessionrequest/SessionRequestPage.kt b/app/src/main/java/one/mixin/android/ui/tip/wc/sessionrequest/SessionRequestPage.kt index f45ec1a859..732463dc48 100644 --- a/app/src/main/java/one/mixin/android/ui/tip/wc/sessionrequest/SessionRequestPage.kt +++ b/app/src/main/java/one/mixin/android/ui/tip/wc/sessionrequest/SessionRequestPage.kt @@ -119,7 +119,9 @@ fun SessionRequestPage( showPin = showPin, checkAddressAndGetDisplayName = { addr -> viewModel.checkAddressAndGetDisplayName(addr, null) }, findWalletById = { id -> viewModel.findWalletById(id) }, - web3TokenItemById = { id, assetId -> viewModel.web3TokenItemById(id, assetId = assetId) } + web3TokenItemById = { id, assetId -> viewModel.web3TokenItemById(id, assetId = assetId) }, + getContent = { v, g, data -> viewModel.getContent(v, g, data) }, + rejectRequest = { v, t -> viewModel.rejectRequest(v, t) }, ) } @@ -143,7 +145,9 @@ fun SessionRequestPageContent( showPin: () -> Unit, checkAddressAndGetDisplayName: suspend (String) -> Triple?, findWalletById: suspend (String) -> one.mixin.android.vo.safe.SafeWallet?, - web3TokenItemById: suspend (String, String) -> Web3TokenItem? + web3TokenItemById: suspend (String, String) -> Web3TokenItem?, + getContent: (WalletConnect.Version, Gson, Any?) -> String, + rejectRequest: (WalletConnect.Version, String) -> Unit, ) { val context = LocalContext.current val commonWallet = stringResource(R.string.Common_Wallet) @@ -338,7 +342,7 @@ fun SessionRequestPageContent( when (sessionRequestUI.data) { is WCEthereumTransaction -> { if (signType == 2) { - MessagePreview(content = viewModel.getContent(version, gson, sessionRequestUI.data)) { + MessagePreview(content = getContent(version, gson, sessionRequestUI.data)) { onPreviewMessage.invoke(it) } } else { @@ -357,7 +361,7 @@ fun SessionRequestPageContent( } else -> { - MessagePreview(content = viewModel.getContent(version, gson, sessionRequestUI.data)) { + MessagePreview(content = getContent(version, gson, sessionRequestUI.data)) { onPreviewMessage.invoke(it) } } @@ -452,7 +456,7 @@ fun SessionRequestPageContent( ActionButton( text = stringResource(id = R.string.insufficient_balance_symbol, chain.symbol), onClick = { - viewModel.rejectRequest(version, topic) + rejectRequest(version, topic) onDismissRequest.invoke() }, backgroundColor = MixinAppTheme.colors.backgroundGray, @@ -462,7 +466,7 @@ fun SessionRequestPageContent( } } else { ActionBottom(modifier = Modifier.align(Alignment.BottomCenter), stringResource(id = R.string.Cancel), stringResource(id = R.string.Confirm), { - viewModel.rejectRequest(version, topic) + rejectRequest(version, topic) onDismissRequest.invoke() }, showPin) } From 8ecbc00fc91e687f4afd36c423ad4f2daad894b0 Mon Sep 17 00:00:00 2001 From: SeniorZhai Date: Tue, 26 May 2026 05:31:11 +0000 Subject: [PATCH 09/12] chore(build): replace deprecated Gradle/Compose DSL APIs - move resourceConfigurations to androidResources.localeFilters - replace java/assets.srcDirs(...) with directories.add(...) - drop deprecated composeCompiler.enableStrongSkippingMode (default on) - replace stabilityConfigurationFile with stabilityConfigurationFiles.add(...) --- app/build.gradle.kts | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 20f2766517..f4c7fd8d75 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -141,10 +141,13 @@ android { versionName = "$versionMajor.$versionMinor.$versionPatch" multiDexEnabled = true testInstrumentationRunner = "one.mixin.android.CustomTestRunner" - resourceConfigurations += listOf("en", "es", "in", "ja", "ms", "ru", "zh-rCN", "zh-rTW") vectorDrawables.useSupportLibrary = true } + androidResources { + localeFilters += listOf("en", "es", "in", "ja", "ms", "ru", "zh-rCN", "zh-rTW") + } + packaging { resources { excludes += "**/*.kotlin_metadata" @@ -185,11 +188,11 @@ android { sourceSets { val sharedTestDir = "src/sharedTest/java" getByName("test") { - java.srcDirs(sharedTestDir) + java.directories.add(sharedTestDir) } getByName("androidTest") { - java.srcDirs(sharedTestDir) - assets.srcDirs(files("$projectDir/schemas")) + java.directories.add(sharedTestDir) + assets.directories.add("$projectDir/schemas") } } @@ -596,9 +599,8 @@ dependencies { } composeCompiler { - enableStrongSkippingMode = true reportsDestination = layout.buildDirectory.dir("compose_compiler") - stabilityConfigurationFile = rootProject.layout.projectDirectory.file("stability_config.conf") + stabilityConfigurationFiles.add(rootProject.layout.projectDirectory.file("stability_config.conf")) } secrets { From cbef5a6cbb2ff6aafeb3d2969425325d3cdb40b5 Mon Sep 17 00:00:00 2001 From: SeniorZhai Date: Tue, 26 May 2026 05:34:04 +0000 Subject: [PATCH 10/12] fix(build): restore master versions of pages broken by stale cherry-pick - Theme.kt: import extension.isNightMode (was missing after merge) - PinKeyBoard.kt / AboutPage.kt / AccountPage.kt: revert to master versions; the cherry-picked older copies referenced renamed R.string/R.drawable and outdated method signatures --- .../one/mixin/android/compose/theme/Theme.kt | 1 + .../android/ui/auth/compose/PinKeyBoard.kt | 479 +++++++++++------- .../android/ui/setting/ui/page/AboutPage.kt | 109 ++-- .../android/ui/setting/ui/page/AccountPage.kt | 29 +- 4 files changed, 342 insertions(+), 276 deletions(-) diff --git a/app/src/main/java/one/mixin/android/compose/theme/Theme.kt b/app/src/main/java/one/mixin/android/compose/theme/Theme.kt index 49c70b5c39..223994817f 100644 --- a/app/src/main/java/one/mixin/android/compose/theme/Theme.kt +++ b/app/src/main/java/one/mixin/android/compose/theme/Theme.kt @@ -18,6 +18,7 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.colorspace.ColorSpaces import androidx.compose.ui.platform.LocalContext import one.mixin.android.MixinApplication +import one.mixin.android.extension.isNightMode import one.mixin.android.extension.isScreenWideColorGamut import one.mixin.android.util.isCurrChinese diff --git a/app/src/main/java/one/mixin/android/ui/auth/compose/PinKeyBoard.kt b/app/src/main/java/one/mixin/android/ui/auth/compose/PinKeyBoard.kt index 03bc2cd667..21ec73f753 100644 --- a/app/src/main/java/one/mixin/android/ui/auth/compose/PinKeyBoard.kt +++ b/app/src/main/java/one/mixin/android/ui/auth/compose/PinKeyBoard.kt @@ -53,7 +53,6 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalInspectionMode import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign @@ -83,40 +82,14 @@ fun PinKeyBoard( onVerifyRequest: ((String) -> Unit)?, ) { val context = LocalContext.current - val isInPreview = LocalInspectionMode.current - - val showBiometric = if (isInPreview) false else BiometricUtil.shouldShowBiometric(context) - val randomKeyboardEnabled by if (isInPreview) { - remember { mutableStateOf(false) } - } else { - context.defaultSharedPreferences.booleanValueAsState( + // val open = context.defaultSharedPreferences.getBoolean(Constants.Account.PREF_BIOMETRICS, false) + // val biometricEnable = !open && BiometricUtil.isSupport(context) + val showBiometric = BiometricUtil.shouldShowBiometric(context) + val randomKeyboardEnabled by LocalContext.current.defaultSharedPreferences + .booleanValueAsState( key = Constants.Account.PREF_RANDOM, defaultValue = false, ) - } - - PinKeyBoardContent( - step = step, - errorContent = errorContent, - showBiometric = showBiometric, - randomKeyboardEnabled = randomKeyboardEnabled, - onResetClick = onResetClick, - onBiometricClick = onBiometricClick, - onVerifyRequest = onVerifyRequest - ) -} - -@Composable -fun PinKeyBoardContent( - step: AuthStep, - errorContent: String, - showBiometric: Boolean, - randomKeyboardEnabled: Boolean, - onResetClick: (() -> Unit)?, - onBiometricClick: (() -> Unit)?, - onVerifyRequest: ((String) -> Unit)?, -) { - val context = LocalContext.current val list = if (randomKeyboardEnabled) { mutableListOf("1", "2", "3", "4", "5", "6", "7", "8", "9", "0").apply { @@ -125,205 +98,323 @@ fun PinKeyBoardContent( add("<<") } } else { - listOf("1", "2", "3", "4", "5", "6", "7", "8", "9", "", "0", "<<") + listOf( + "1", + "2", + "3", + "4", + "5", + "6", + "7", + "8", + "9", + "", + "0", + "<<", + ) } + var size by remember { mutableStateOf(IntSize.Zero) } + var pinCode by remember { mutableStateOf("") } - var pinValue by remember { - mutableStateOf("") - } - - Column( - modifier = - Modifier - .fillMaxWidth() - .padding(horizontal = 24.dp) - .navigationBarsPadding(), - ) { - Spacer(modifier = Modifier.height(10.dp)) - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.Center, - ) { - for (i in 1..6) { - Box( - modifier = - Modifier - .size(14.dp) - .clip(CircleShape) - .background( - if (pinValue.length >= i) { - MixinAppTheme.colors.accent - } else { - MixinAppTheme.colors.backgroundGrayLight - }, - ), - ) - if (i != 6) { - Spacer(modifier = Modifier.width(16.dp)) - } - } - } - Spacer(modifier = Modifier.height(16.dp)) - Box( - modifier = - Modifier - .fillMaxWidth() - .height(20.dp), - contentAlignment = Alignment.Center, - ) { - if (errorContent.isNotEmpty()) { - Text( - text = errorContent, - color = MixinAppTheme.colors.red, - fontSize = 14.sp, - textAlign = TextAlign.Center, - ) + AnimatedContent(targetState = step, transitionSpec = { + if (targetState == AuthStep.INPUT) { + (slideInVertically(initialOffsetY = { it }) togetherWith scaleOut() + fadeOut()) + } else if (initialState == AuthStep.INPUT) { + if (targetState == AuthStep.LOADING) { + (EnterTransition.None togetherWith ExitTransition.None) + } else { + (scaleIn() + fadeIn() togetherWith fadeOut()) } + } else { + (scaleIn() + fadeIn() togetherWith scaleOut() + fadeOut()) } - - Spacer(modifier = Modifier.height(16.dp)) - - var keyboardHeight by remember { - mutableStateOf(0.dp) - } - - Box( - modifier = - Modifier - .fillMaxWidth() - .heightIn(min = keyboardHeight), - ) { - if (step == AuthStep.VERIFYING) { - CircularProgressIndicator( + }, label = "") { s -> + when (s) { + AuthStep.DONE -> + Column( modifier = Modifier - .size(32.dp) - .align(Alignment.Center), - color = MixinAppTheme.colors.accent, - ) - } else { - LazyVerticalGrid( - columns = GridCells.Fixed(3), + .height(150.dp) + .fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Image( + painter = painterResource(id = R.drawable.ic_transfer_done), + contentDescription = null, + ) + Text(text = stringResource(R.string.Done), color = MixinAppTheme.colors.textMinor) + // Todo hide biometric + // if (biometricEnable) { + // Spacer(modifier = Modifier.height(12.dp)) + // Row( + // verticalAlignment = Alignment.CenterVertically, + // modifier = Modifier + // .clickable { + // } + // .alpha(0f) + // ) { + // Image( + // painter = painterResource(id = R.drawable.ic_biometric_enable), + // contentDescription = null + // ) + // Spacer(modifier = Modifier.width(4.dp)) + // Text( + // text = stringResource(R.string.setting_enable_biometric_pay), + // color = MixinAppTheme.colors.textBlue + // ) + // } + // } + } + AuthStep.ERROR -> + Column( modifier = Modifier - .fillMaxWidth() - .onSizeChanged { - keyboardHeight = context.pxToDp(it.height).dp - }, - verticalArrangement = Arrangement.spacedBy(16.dp), - horizontalArrangement = Arrangement.spacedBy(16.dp), + .heightIn(min = 150.dp) + .fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, ) { - items(list.size) { index -> - val item = list[index] - when (item) { - "" -> { + Text( + modifier = + Modifier + .padding(start = 24.dp, end = 24.dp, top = 24.dp, bottom = 10.dp) + .background( + color = MixinAppTheme.colors.backgroundGrayLight, + shape = RoundedCornerShape(8.dp), + ) + .padding(horizontal = 20.dp, vertical = 10.dp), + text = errorContent, + color = MixinAppTheme.colors.tipError, + textAlign = TextAlign.Center, + fontSize = 14.sp, + ) + MixinButton( + onClick = { + onResetClick?.invoke() + }, + contentPadding = PaddingValues(horizontal = 20.dp), + shape = RoundedCornerShape(30.dp), + ) { + Text( + fontSize = 16.sp, + text = stringResource(id = R.string.Continue), + color = Color.White, + ) + } + } + else -> + Column(horizontalAlignment = Alignment.CenterHorizontally) { + AnimatedContent(targetState = step, transitionSpec = { + (fadeIn() togetherWith fadeOut()) + }, label = "") { step -> + if (step == AuthStep.INPUT) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + modifier = + Modifier + .fillMaxWidth() + .wrapContentHeight(), + ) { + Box( + modifier = + Modifier + .padding(8.dp) + .fillMaxWidth(), + contentAlignment = Alignment.Center, + ) { + LazyRow( + modifier = Modifier.wrapContentSize().padding(bottom = 8.dp), + verticalAlignment = Alignment.Bottom, + horizontalArrangement = Arrangement.spacedBy(24.dp), + ) { + items(6) { index -> + val hasContent = index < pinCode.length + AnimatedContent( + targetState = hasContent, + transitionSpec = { + if (targetState > initialState) { + scaleIn() + fadeIn() togetherWith scaleOut() + fadeOut() + } else { + scaleIn() + fadeIn() togetherWith scaleOut() + fadeOut() + }.using( + SizeTransform(clip = false), + ) + }, + label = "", + ) { b -> + Box( + modifier = Modifier + .size(14.dp) + .border(1.dp, MixinAppTheme.colors.textPrimary, CircleShape) + .background(if (b) MixinAppTheme.colors.textPrimary else Color.Transparent, CircleShape) + ) + } + } + } + } if (showBiometric) { - Box( + Row( + verticalAlignment = Alignment.CenterVertically, modifier = Modifier - .size(60.dp) - .clip(RoundedCornerShape(30.dp)) - .clickable { - onBiometricClick?.invoke() - }, - contentAlignment = Alignment.Center, + .clip( + shape = RoundedCornerShape(4.dp), + ) + .clickable { onBiometricClick?.invoke() } + .padding(start = 20.dp, end = 20.dp, top = 8.dp, bottom = 20.dp), ) { - Icon( - painter = painterResource(id = R.drawable.ic_biometric), + Image( + painter = painterResource(R.drawable.ic_biometric), contentDescription = null, - tint = MixinAppTheme.colors.icon, + ) + Spacer(modifier = Modifier.width(4.dp)) + Text( + text = stringResource(R.string.Verify_by_Biometric), + color = MixinAppTheme.colors.textBlue, ) } - } else { - Box(modifier = Modifier.size(60.dp)) } } - "<<" -> { - Box( + } else { + Box( + contentAlignment = Alignment.Center, + modifier = + Modifier + .fillMaxWidth() + .height(94.dp), + ) { + CircularProgressIndicator( modifier = Modifier - .size(60.dp) - .clip(RoundedCornerShape(30.dp)) - .clickable { - if (pinValue.isNotEmpty()) { - pinValue = pinValue.substring(0, pinValue.length - 1) - context.tickVibrate() - } - }, - contentAlignment = Alignment.Center, + .size(32.dp), + color = MixinAppTheme.colors.accent, + ) + } + } + } + AnimatedVisibility( + visible = step == AuthStep.INPUT || step == AuthStep.LOADING, + enter = slideInVertically(initialOffsetY = { it }), + exit = slideOutVertically(targetOffsetY = { it }), + ) { + Column(modifier = Modifier.background(MixinAppTheme.colors.backgroundWindow)) { + if (Session.getTipPub() != null) { + Row( + modifier = + Modifier + .background(MixinAppTheme.colors.backgroundWindow) + .height(36.dp) + .fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center, ) { Icon( - painter = painterResource(id = R.drawable.ic_backspace), + painter = painterResource(id = R.drawable.ic_secret_tip), contentDescription = null, - tint = MixinAppTheme.colors.icon, + tint = MixinAppTheme.colors.textAssist, + ) + Text( + color = MixinAppTheme.colors.textAssist, + text = stringResource(id = R.string.Secured_by_TIP), + fontSize = 12.sp, ) } } - else -> { - Box( + Box( + modifier = + Modifier + .wrapContentHeight() + .heightIn(120.dp, 240.dp) + .onSizeChanged { + size = it + }, + ) { + LazyVerticalGrid( modifier = Modifier - .size(60.dp) - .clip(RoundedCornerShape(30.dp)) - .background(MixinAppTheme.colors.backgroundGrayLight) - .clickable { - if (pinValue.length < 6) { - pinValue += item - context.tickVibrate() - if (pinValue.length == 6) { - onVerifyRequest?.invoke(pinValue) - pinValue = "" - } + .fillMaxHeight() + .padding(horizontal = 8.dp, vertical = 8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + columns = GridCells.Fixed(3), + content = { + items(list.size) { index -> + Box( + contentAlignment = Alignment.Center, + modifier = + Modifier + .height( + context.pxToDp( + ( + size.toSize().height - + context.dpToPx( + 40f, + ) + ) / 4, + ).dp, + ) + .clip(shape = RoundedCornerShape(8.dp)) + .background( + when (index) { + 11 -> MixinAppTheme.colors.backgroundDark + 9 -> Color.Transparent + else -> MixinAppTheme.colors.background + }, + ) + .run { + if (step == AuthStep.INPUT && index != 9) { + clickable { + context.tickVibrate() + if (index == 11) { + if (pinCode.isNotEmpty()) { + pinCode = + pinCode.substring( + 0, + pinCode.length - 1, + ) + } + } else if (pinCode.length < 6) { + pinCode += list[index] + if (pinCode.length == 6) { + onVerifyRequest?.invoke(pinCode) + pinCode = "" + } + } + } + } else { + this + } + }, + ) { + if (index == 11) { + Image( + painter = painterResource(R.drawable.ic_delete), + contentDescription = null, + ) + } else if (index != 9) { + Text( + text = list[index], + fontSize = 24.sp, + color = MixinAppTheme.colors.textPrimary, + textAlign = TextAlign.Center, + ) } - }, - contentAlignment = Alignment.Center, - ) { - Text( - text = item, - fontSize = 24.sp, - color = MixinAppTheme.colors.textPrimary, - ) - } + } + } + }, + ) } } } } - } - } - - Spacer(modifier = Modifier.height(24.dp)) - if (onResetClick != null) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.Center, - ) { - Text( - text = stringResource(id = R.string.Forgot_PIN), - modifier = - Modifier.clickable { - onResetClick.invoke() - }, - color = MixinAppTheme.colors.accent, - fontSize = 14.sp, - ) - } } - Spacer(modifier = Modifier.height(24.dp)) } } -@Preview(showBackground = true) +@Preview @Composable fun PinKeyBoardPreview() { - MixinAppTheme { - PinKeyBoardContent( - step = AuthStep.INPUT, - errorContent = "", - showBiometric = true, - randomKeyboardEnabled = false, - onResetClick = {}, - onBiometricClick = {}, - onVerifyRequest = {} - ) - } + PinKeyBoard(AuthStep.INPUT, "", {}, null, null) } diff --git a/app/src/main/java/one/mixin/android/ui/setting/ui/page/AboutPage.kt b/app/src/main/java/one/mixin/android/ui/setting/ui/page/AboutPage.kt index b496dc49ef..70b917e016 100644 --- a/app/src/main/java/one/mixin/android/ui/setting/ui/page/AboutPage.kt +++ b/app/src/main/java/one/mixin/android/ui/setting/ui/page/AboutPage.kt @@ -8,7 +8,6 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material.Scaffold @@ -20,7 +19,6 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.composed import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalInspectionMode import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview @@ -67,27 +65,15 @@ private fun Modifier.debugClickable( @Composable fun AboutPage() { - val context = LocalContext.current - val preferences = context.defaultSharedPreferences - val showLogDebugInitial = remember { preferences.getBoolean(Constants.Debug.LOG_AND_DEBUG, false) } - - AboutPageContent( - showLogDebugInitial = showLogDebugInitial, - onUpdateLogDebug = { newValue -> preferences.putBoolean(Constants.Debug.LOG_AND_DEBUG, newValue) } - ) -} - -@Composable -fun AboutPageContent( - showLogDebugInitial: Boolean, - onUpdateLogDebug: (Boolean) -> Unit, -) { val settingNavController = LocalSettingNav.current - val showLogDebug = remember { mutableStateOf(showLogDebugInitial) } + val preferences = LocalContext.current.defaultSharedPreferences + val showLogDebug = + remember { mutableStateOf(preferences.getBoolean(Constants.Debug.LOG_AND_DEBUG, false)) } Scaffold( backgroundColor = MixinAppTheme.colors.background, topBar = { + val context = LocalContext.current MixinTopAppBar( navigationIcon = { MixinBackButton() @@ -111,23 +97,21 @@ fun AboutPageContent( .verticalScroll(rememberScrollState()), ) { val context = LocalContext.current - val isInPreview = LocalInspectionMode.current - val logoResId = if (isInPreview) R.drawable.ic_launcher_logo else { - val attrs = context.obtainStyledAttributes(intArrayOf(R.attr.ic_logo)) - val resId = attrs.getResourceId(0, R.drawable.ic_launcher_logo) - attrs.recycle() - resId - } + val attrs = context.obtainStyledAttributes(intArrayOf(R.attr.ic_logo)) + val logoResId = attrs.getResourceId(0, R.drawable.ic_logo_mixin) + attrs.recycle() Image( modifier = Modifier .debugClickable { - val isLogDebug = showLogDebug.value - onUpdateLogDebug(!isLogDebug) - showLogDebug.value = !isLogDebug + if (preferences.getBoolean(Constants.Debug.LOG_AND_DEBUG, false)) { + preferences.putBoolean(Constants.Debug.LOG_AND_DEBUG, false) + showLogDebug.value = false + } else { + preferences.putBoolean(Constants.Debug.LOG_AND_DEBUG, true) + showLogDebug.value = true + } } - .padding(top = 40.dp, bottom = 20.dp) - .size(64.dp) .align(Alignment.CenterHorizontally), painter = painterResource(id = logoResId), contentDescription = null, @@ -153,68 +137,75 @@ fun AboutPageContent( ) val termsUrl = stringResource(R.string.landing_terms_url) AboutTile( - text = stringResource(id = R.string.Terms_of_service), + text = stringResource(id = R.string.Terms_of_Service), onClick = { context.openUrl(termsUrl) }, ) - val privacyUrl = stringResource(R.string.landing_privacy_url) + val privacyPolicyUrl = stringResource(R.string.landing_privacy_policy_url) AboutTile( - text = stringResource(id = R.string.Privacy_policy), + text = stringResource(id = R.string.Privacy_Policy), onClick = { - context.openUrl(privacyUrl) + context.openUrl(privacyPolicyUrl) }, ) AboutTile( - text = stringResource(id = R.string.Check_for_updates), + text = stringResource(id = R.string.Version_Update), onClick = { - context.openMarket(context.packageName) + context.openMarket() }, ) if (showLogDebug.value) { - AboutTile(stringResource(id = R.string.Logs)) { - settingNavController.navigation(SettingDestination.Logs) - } + AboutTile( + text = stringResource(id = R.string.LogAndDebug), + onClick = { + settingNavController.navigation(SettingDestination.LogAndDebug) + }, + ) } } } } -@Composable -private fun VersionName() { - Text( - text = "V ${BuildConfig.VERSION_NAME}", - color = MixinAppTheme.colors.textAssist, - fontSize = 10.sp, - ) -} - @Composable private fun AboutTile( text: String, onClick: () -> Unit, ) { - Row( + Box( modifier = Modifier - .fillMaxWidth() - .height(60.dp) + .height(56.dp) .clickable { onClick() } - .padding(horizontal = 16.dp), - verticalAlignment = Alignment.CenterVertically, + .padding(horizontal = 16.dp) + .fillMaxWidth(), + contentAlignment = Alignment.CenterStart, ) { - Text( - text = text, - fontSize = 14.sp, - color = MixinAppTheme.colors.textPrimary, - ) + Text(text = text, color = MixinAppTheme.colors.accent) } } +@Composable +private fun VersionName() { + val context = LocalContext.current + val versionName = + remember { + context.packageManager?.getPackageInfo( + context.packageName, + 0, + )?.versionName ?: "Unknown" + } + Text( + text = "${BuildConfig.VERSION_NAME}-${BuildConfig.VERSION_CODE}", + color = MixinAppTheme.colors.textAssist, + fontSize = 10.sp, + ) +} + @Preview @Composable fun AboutPagePreview() { MixinAppTheme { - AboutPageContent(showLogDebugInitial = false, onUpdateLogDebug = {}) + AboutPage() } } diff --git a/app/src/main/java/one/mixin/android/ui/setting/ui/page/AccountPage.kt b/app/src/main/java/one/mixin/android/ui/setting/ui/page/AccountPage.kt index 5c4e02964e..28f19372dd 100644 --- a/app/src/main/java/one/mixin/android/ui/setting/ui/page/AccountPage.kt +++ b/app/src/main/java/one/mixin/android/ui/setting/ui/page/AccountPage.kt @@ -38,17 +38,6 @@ import one.mixin.android.ui.tip.TipType @Composable fun AccountPage() { - AccountPageContent( - hasPhone = Session.hasPhone(), - hasPin = Session.getAccount()?.hasPin == true - ) -} - -@Composable -fun AccountPageContent( - hasPhone: Boolean, - hasPin: Boolean, -) { Scaffold( backgroundColor = MixinAppTheme.colors.backgroundWindow, topBar = { @@ -74,7 +63,7 @@ fun AccountPageContent( AccountTile(stringResource(R.string.Security)) { navController.navigation(SettingDestination.AccountSecurity) } - ChangeNumberButton(hasPhone, hasPin) + ChangeNumberButton() Box(modifier = Modifier.height(16.dp)) AccountTile(stringResource(R.string.Delete_my_account)) { navController.navigation(SettingDestination.DeleteAccount) @@ -84,10 +73,7 @@ fun AccountPageContent( } @Composable -private fun ChangeNumberButton( - hasPhone: Boolean, - hasPin: Boolean, -) { +private fun ChangeNumberButton() { val openDialog = remember { mutableStateOf(false) @@ -97,24 +83,21 @@ private fun ChangeNumberButton( MixinAlertDialog( text = { - Text(stringResource(if (hasPhone) R.string.profile_modify_number else R.string.profile_add_number)) + Text(stringResource(if (Session.hasPhone()) R.string.profile_modify_number else R.string.profile_add_number)) }, - confirmText = stringResource(if (hasPhone) R.string.Change_Phone_Number else R.string.Add_Mobile_Number), + confirmText = stringResource(if (Session.hasPhone()) R.string.Change_Phone_Number else R.string.Add_Mobile_Number), onConfirmClick = { openDialog.value = false val activity = context.findFragmentActivityOrNull() - if (hasPin) { + if (Session.getAccount()?.hasPin == true) { activity?.supportFragmentManager?.inTransaction { setCustomAnimations( R.anim.slide_in_bottom, R.anim.slide_out_bottom, R.anim.slide_in_bottom, R.anim.slide_out_bottom, -... - } -} ) .add( R.id.container, @@ -164,6 +147,6 @@ private fun AccountTile( @Composable fun AccountPagePreview() { MixinAppTheme { - AccountPageContent(hasPhone = true, hasPin = true) + AccountPage() } } From 9f1984ee19cf00931941660aa9bca944e9890847 Mon Sep 17 00:00:00 2001 From: SeniorZhai Date: Tue, 26 May 2026 05:35:36 +0000 Subject: [PATCH 11/12] chore(build): remove unused syncStrings task --- app/build.gradle.kts | 21 --------------------- 1 file changed, 21 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index f4c7fd8d75..1a189c4f53 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -616,24 +616,3 @@ tasks.register("allTests") { description = "Run unit tests and instrumentation tests" } -tasks.register("syncStrings") { - doLast { - listOf("en", "zh", "zh-TW", "ja", "ru", "in", "ms").forEach { lang -> - project.extensions.getByName("download").let { ext -> - val downloadExt = ext as de.undercouch.gradle.tasks.download.DownloadExtension - downloadExt.run { - src("https://raw.githubusercontent.com/Tougee/sync-google-sheet/master/generated/output/Android/value-$lang/strings.xml") - dest( - when (lang) { - "en" -> "src/main/res/values" - "zh" -> "src/main/res/values-zh-rCN" - "zh-TW" -> "src/main/res/values-zh-rTW" - "zh-HK" -> "src/main/res/values-zh-rHK" - else -> "src/main/res/values-$lang" - } - ) - } - } - } - } -} From 8e71ac8370c7722fa95b6596314c542365a1a61d Mon Sep 17 00:00:00 2001 From: SeniorZhai Date: Tue, 26 May 2026 14:06:50 +0800 Subject: [PATCH 12/12] fix(build): restore master versions of more pages broken by stale cherry-pick The compose-preview refactor commits left these pages referencing removed/renamed APIs (MixinResponse, MemberOrderPlan, SafeWallet, SettingTile/showAppAuthPrompt signatures, R.string.Minutes plural, SessionRequestUI, etc.) and a literal `...` placeholder in SecurityPage. Restoring the master versions clears the compileKotlin errors; the preview-support rework for these pages can be redone against the current APIs later. --- .../ui/setting/ui/page/AppAuthSettingPage.kt | 192 +++--- .../ui/setting/ui/page/AppearancePage.kt | 276 +++++---- .../setting/ui/page/MixinMemberUpgradePage.kt | 208 +++---- .../ui/setting/ui/page/NotificationsPage.kt | 82 +-- .../android/ui/setting/ui/page/PinLogsPage.kt | 22 +- .../ui/setting/ui/page/SecurityPage.kt | 22 +- .../ui/tip/wc/sessionproposal/WCPinBoard.kt | 546 +++++++++++------- .../wc/sessionrequest/SessionRequestPage.kt | 94 +-- .../wallet/components/AssetDashboardScreen.kt | 32 +- 9 files changed, 730 insertions(+), 744 deletions(-) diff --git a/app/src/main/java/one/mixin/android/ui/setting/ui/page/AppAuthSettingPage.kt b/app/src/main/java/one/mixin/android/ui/setting/ui/page/AppAuthSettingPage.kt index d1ed995c57..5492510f74 100644 --- a/app/src/main/java/one/mixin/android/ui/setting/ui/page/AppAuthSettingPage.kt +++ b/app/src/main/java/one/mixin/android/ui/setting/ui/page/AppAuthSettingPage.kt @@ -23,7 +23,6 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalInspectionMode import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp @@ -46,26 +45,6 @@ private const val FINGERPRINT_ENABLED_AFTER_30_MINUTES = 2 @Composable fun AppAuthSettingPage() { - val context = LocalContext.current - var fingerPrintEnabled by remember { - context.defaultSharedPreferences - }.intValueAsState( - key = Constants.Account.PREF_APP_AUTH, - defaultValue = FINGERPRINT_DISABLED, - ) - - AppAuthSettingPageContent( - fingerPrintEnabled = fingerPrintEnabled, - onFingerPrintEnabledChange = { fingerPrintEnabled = it } - ) -} - -@Composable -fun AppAuthSettingPageContent( - fingerPrintEnabled: Int, - onFingerPrintEnabledChange: (Int) -> Unit, -) { - val isInPreview = LocalInspectionMode.current SettingPageScaffold(title = stringResource(id = R.string.fingerprint_lock)) { var isSupportWithErrorInfo by remember { mutableStateOf?>(null) @@ -76,6 +55,13 @@ fun AppAuthSettingPageContent( val cancel = stringResource(R.string.Cancel) val unlockWithFingerprint = stringResource(R.string.Unlock_with_fingerprint) + var fingerPrintEnabled by remember { + context.defaultSharedPreferences + }.intValueAsState( + key = Constants.Account.PREF_APP_AUTH, + defaultValue = FINGERPRINT_DISABLED, + ) + val authCallback = remember { object : BiometricPrompt.AuthenticationCallback() { @@ -89,16 +75,16 @@ fun AppAuthSettingPageContent( errorCode == BiometricPrompt.ERROR_LOCKOUT || errorCode == BiometricPrompt.ERROR_LOCKOUT_PERMANENT ) { - onFingerPrintEnabledChange(FINGERPRINT_DISABLED) + fingerPrintEnabled = FINGERPRINT_DISABLED } } override fun onAuthenticationFailed() { - onFingerPrintEnabledChange(FINGERPRINT_DISABLED) + fingerPrintEnabled = FINGERPRINT_DISABLED } override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) { - onFingerPrintEnabledChange(FINGERPRINT_ENABLED_IMMEDIATELY) + fingerPrintEnabled = FINGERPRINT_ENABLED_IMMEDIATELY } } } @@ -112,115 +98,119 @@ fun AppAuthSettingPageContent( colors = SwitchDefaults.colors( checkedThumbColor = MixinAppTheme.colors.accent, - checkedTrackColor = MixinAppTheme.colors.accent.copy(alpha = 0.5f), + uncheckedThumbColor = MixinAppTheme.colors.unchecked, + checkedTrackColor = MixinAppTheme.colors.accent, + uncheckedTrackColor = MixinAppTheme.colors.unchecked, ), - onCheckedChange = { - if (it) { - if (isInPreview) { - onFingerPrintEnabledChange(FINGERPRINT_ENABLED_IMMEDIATELY) - return@Switch - } - val activity = context.findFragmentActivityOrNull() - if (activity != null) { - if (isSupportWithErrorInfo == null) { - isSupportWithErrorInfo = BiometricUtil.isSupportWithErrorInfo(context) - } - val result = isSupportWithErrorInfo!! - if (result.first) { - showAppAuthPrompt( - activity, - confirmFingerprint, - cancel, - unlockWithFingerprint, - authCallback, - ) - } else { - fingerPrintEnabled // trigger recompose? - } - } - } else { - onFingerPrintEnabledChange(FINGERPRINT_DISABLED) - } - }, + onCheckedChange = null, ) }, - ) - - if (fingerPrintEnabled != FINGERPRINT_DISABLED) { - FingerprintRadioButton( - checked = fingerPrintEnabled == FINGERPRINT_ENABLED_IMMEDIATELY, - title = stringResource(id = R.string.Immediately), - ) { - onFingerPrintEnabledChange(FINGERPRINT_ENABLED_IMMEDIATELY) + ) { + isSupportWithErrorInfo = + BiometricUtil.isSupportWithErrorInfo(context, BiometricManager.Authenticators.BIOMETRIC_WEAK) + val isSupport = isSupportWithErrorInfo?.first == true + if (!isSupport) { + fingerPrintEnabled = FINGERPRINT_DISABLED + return@SettingTile } - - FingerprintRadioButton( - checked = fingerPrintEnabled == FINGERPRINT_ENABLED_AFTER_1_MINUTES, - title = stringResource(id = R.string.Minutes, 1), - ) { - onFingerPrintEnabledChange(FINGERPRINT_ENABLED_AFTER_1_MINUTES) + if (fingerPrintEnabled != FINGERPRINT_DISABLED) { + fingerPrintEnabled = FINGERPRINT_DISABLED + } else { + val activity = context.findFragmentActivityOrNull() ?: return@SettingTile + showAppAuthPrompt( + activity, + confirmFingerprint, + cancel, + authCallback, + unlockWithFingerprint, + ) } + } - FingerprintRadioButton( - checked = fingerPrintEnabled == FINGERPRINT_ENABLED_AFTER_30_MINUTES, - title = stringResource(id = R.string.Minutes, 30), - ) { - onFingerPrintEnabledChange(FINGERPRINT_ENABLED_AFTER_30_MINUTES) - } + if (isSupportWithErrorInfo != null) { + Text( + text = isSupportWithErrorInfo?.second ?: "", + color = MixinAppTheme.colors.red, + modifier = Modifier.padding(start = 16.dp, end = 16.dp), + ) + } + + if (fingerPrintEnabled != FINGERPRINT_DISABLED) { + Text( + text = stringResource(id = R.string.Auto_Lock), + fontSize = 16.sp, + color = MixinAppTheme.colors.accent, + modifier = Modifier.padding(start = 16.dp, end = 16.dp, bottom = 8.dp), + ) + FingerprintRadioGroup( + index = fingerPrintEnabled, + onCheckedChange = { + fingerPrintEnabled = it + }, + ) } } } +@Composable +private fun FingerprintRadioGroup( + index: Int, + onCheckedChange: (Int) -> Unit, +) { + FingerprintRadioButton( + checked = index == FINGERPRINT_ENABLED_IMMEDIATELY, + title = stringResource(id = R.string.Immediately), + ) { + onCheckedChange(FINGERPRINT_ENABLED_IMMEDIATELY) + } + + FingerprintRadioButton( + checked = index == FINGERPRINT_ENABLED_AFTER_1_MINUTES, + title = stringResource(id = R.string.After_1_minute), + ) { + onCheckedChange(FINGERPRINT_ENABLED_AFTER_1_MINUTES) + } + + FingerprintRadioButton( + checked = index == FINGERPRINT_ENABLED_AFTER_30_MINUTES, + title = stringResource(id = R.string.After_30_minutes), + ) { + onCheckedChange(FINGERPRINT_ENABLED_AFTER_30_MINUTES) + } +} + @Composable private fun FingerprintRadioButton( checked: Boolean, title: String, - onCheckedChange: () -> Unit, + onChecked: () -> Unit, ) { Row( - modifier = - Modifier - .fillMaxWidth() - .height(60.dp) - .clickable { - onCheckedChange() + Modifier + .height(48.dp) + .fillMaxWidth() + .clickable { + if (!checked) { + onChecked() } - .padding(start = 16.dp, end = 16.dp), + }, verticalAlignment = Alignment.CenterVertically, ) { + Box(modifier = Modifier.width(16.dp)) RadioButton( selected = checked, onClick = null, colors = RadioButtonDefaults.colors( selectedColor = MixinAppTheme.colors.accent, - unselectedColor = MixinAppTheme.colors.unchecked, ), ) - Box(modifier = Modifier.width(16.dp)) - - Text( - text = title, - fontSize = 14.sp, - color = MixinAppTheme.colors.textPrimary, - ) - + Text(text = title, fontSize = 16.sp, color = MixinAppTheme.colors.textPrimary) Box(modifier = Modifier.width(16.dp)) } } -@Composable -@Preview -fun AppAuthSettingPagePreview() { - MixinAppTheme { - AppAuthSettingPageContent( - fingerPrintEnabled = FINGERPRINT_ENABLED_IMMEDIATELY, - onFingerPrintEnabledChange = {} - ) - } -} - @Composable @Preview fun FingerprintRadioButtonPreview() { diff --git a/app/src/main/java/one/mixin/android/ui/setting/ui/page/AppearancePage.kt b/app/src/main/java/one/mixin/android/ui/setting/ui/page/AppearancePage.kt index 07fb4a4bba..dbfa44c29e 100644 --- a/app/src/main/java/one/mixin/android/ui/setting/ui/page/AppearancePage.kt +++ b/app/src/main/java/one/mixin/android/ui/setting/ui/page/AppearancePage.kt @@ -54,31 +54,6 @@ import java.util.Locale @Composable fun AppearancePage() { - val context = LocalContext.current - val preference = context.defaultSharedPreferences - val initialThemeId = preference.getInt( - Constants.Theme.THEME_CURRENT_ID, - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) { - Constants.Theme.THEME_DEFAULT_ID - } else { - Constants.Theme.THEME_AUTO_ID - }, - ) - val initialCurrency = Session.getFiatCurrency() - - AppearancePageContent( - initialThemeId = initialThemeId, - initialCurrency = initialCurrency, - getCurrencySymbol = { Fiats.getSymbol(it) } - ) -} - -@Composable -fun AppearancePageContent( - initialThemeId: Int, - initialCurrency: String, - getCurrencySymbol: (String) -> String, -) { val navController = LocalSettingNav.current Scaffold( backgroundColor = MixinAppTheme.colors.backgroundWindow, @@ -94,7 +69,7 @@ fun AppearancePageContent( }, ) { Column(Modifier.padding(it)) { - ThemeItem(initialThemeId) + ThemeItem() Box(modifier = Modifier.height(20.dp)) @@ -102,7 +77,7 @@ fun AppearancePageContent( Box(modifier = Modifier.height(20.dp)) - CurrencyItem(initialCurrency, getCurrencySymbol) + CurrencyItem() Box(modifier = Modifier.height(20.dp)) @@ -129,13 +104,25 @@ fun AppearancePageContent( } @Composable -private fun ThemeItem(initialThemeId: Int) { +private fun ThemeItem() { val context = LocalContext.current val preference = context.defaultSharedPreferences val themeTitle = stringResource(id = R.string.Theme) val themeOptions = stringArrayResource(R.array.setting_night_array_oreo) - val currentThemeId = remember(initialThemeId) { mutableStateOf(initialThemeId) } + val currentThemeId = + remember { + val id = + preference.getInt( + Constants.Theme.THEME_CURRENT_ID, + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) { + Constants.Theme.THEME_DEFAULT_ID + } else { + Constants.Theme.THEME_AUTO_ID + }, + ) + mutableStateOf(id) + } AppearanceItem( label = themeTitle, @@ -161,9 +148,11 @@ private fun ThemeItem(initialThemeId: Int) { else -> AppCompatDelegate.MODE_NIGHT_NO }, ) - if (changed && isFollowSystem()) { - val activity = context.findFragmentActivityOrNull() - activity?.recreate() + if (changed) { + context.findFragmentActivityOrNull()?.apply { + onBackPressed() + recreate() + } } } } @@ -171,65 +160,107 @@ private fun ThemeItem(initialThemeId: Int) { @Composable private fun LanguageItem() { - val showLanguageDialog = - remember { - mutableStateOf(false) + val languageNames = + stringArrayResource(R.array.language_names).apply { + this[0] = stringResource(R.string.Follow_system) } - val context = LocalContext.current + val showLanguageDialog = remember { mutableStateOf(false) } + + val currentLanguage = + remember { + val index = + if (isFollowSystem()) { + AppearanceFragment.POS_FOLLOW_SYSTEM + } else { + getLanguagePos() + } + mutableStateOf(index) + } AppearanceItem( - label = stringResource(id = R.string.Language), - value = stringArrayResource(id = R.array.setting_language_array)[getLanguagePos()], + label = stringResource(R.string.Language), + value = languageNames[currentLanguage.value], ) { showLanguageDialog.value = true } if (showLanguageDialog.value) { + val dialogSelected = remember { mutableStateOf(currentLanguage.value) } + val context = LocalContext.current MixinAlertDialog( + title = { + Text(stringResource(R.string.Language)) + }, onDismissRequest = { showLanguageDialog.value = false }, - confirmButton = {}, - title = { - Text( - text = stringResource(id = R.string.Language), - fontSize = 18.sp, - color = MixinAppTheme.colors.textPrimary, - ) - }, text = { Column { - val radioOptions = stringArrayResource(id = R.array.setting_language_array) - val (selectedOption, onOptionSelected) = remember { mutableStateOf(radioOptions[getLanguagePos()]) } - radioOptions.forEach { text -> + Box(modifier = Modifier.height(8.dp)) + + listOf( + AppearanceFragment.POS_FOLLOW_SYSTEM, + AppearanceFragment.POS_ENGLISH, + AppearanceFragment.POS_SIMPLIFY_CHINESE, + AppearanceFragment.POS_TRADITIONAL_CHINESE, + AppearanceFragment.POS_SIMPLIFY_JAPANESE, + AppearanceFragment.POS_RUSSIAN, + AppearanceFragment.POS_INDONESIA, + AppearanceFragment.POS_Malay, + ).forEach { index -> LanguageRadioItem( - name = text, - selected = (text == selectedOption), + name = languageNames[index], + selected = dialogSelected.value == index, onOptionSelected = { - onOptionSelected(text) - showLanguageDialog.value = false - val pos = radioOptions.indexOf(text) - if (pos == getLanguagePos()) return@LanguageRadioItem - val locale = - when (pos) { - 0 -> Locale.getDefault() - 1 -> Locale.ENGLISH - 2 -> Locale.SIMPLIFIED_CHINESE - 3 -> Locale.TRADITIONAL_CHINESE - 4 -> Locale("zh", "HK") - 5 -> Locale("ja") - else -> Locale.getDefault() - } - val appLocale: LocaleListCompat = LocaleListCompat.create(locale) - AppCompatDelegate.setApplicationLocales(appLocale) - - TimeCache.clear() + dialogSelected.value = index }, ) } } }, + dismissText = stringResource(R.string.Cancel), + confirmText = stringResource(R.string.OK), + onConfirmClick = { + showLanguageDialog.value = false + + val newSelected = dialogSelected.value + if (currentLanguage.value != newSelected) { + currentLanguage.value = newSelected + if (newSelected == AppearanceFragment.POS_FOLLOW_SYSTEM) { + AppCompatDelegate.setApplicationLocales(LocaleListCompat.getEmptyLocaleList()) + } else { + val selectedLang = + when (newSelected) { + AppearanceFragment.POS_SIMPLIFY_CHINESE -> Locale.SIMPLIFIED_CHINESE.language + AppearanceFragment.POS_TRADITIONAL_CHINESE -> Locale.TRADITIONAL_CHINESE.language + AppearanceFragment.POS_SIMPLIFY_JAPANESE -> Locale.JAPANESE.language + AppearanceFragment.POS_RUSSIAN -> Constants.Locale.Russian.Language + AppearanceFragment.POS_INDONESIA -> Constants.Locale.Indonesian.Language + AppearanceFragment.POS_Malay -> Constants.Locale.Malay.Language + else -> Locale.US.language + } + val selectedCountry = + when (newSelected) { + AppearanceFragment.POS_SIMPLIFY_CHINESE -> Locale.SIMPLIFIED_CHINESE.country + AppearanceFragment.POS_TRADITIONAL_CHINESE -> Locale.TRADITIONAL_CHINESE.country + AppearanceFragment.POS_SIMPLIFY_JAPANESE -> Locale.JAPANESE.country + AppearanceFragment.POS_RUSSIAN -> Constants.Locale.Russian.Country + AppearanceFragment.POS_INDONESIA -> Constants.Locale.Indonesian.Country + AppearanceFragment.POS_Malay -> Constants.Locale.Malay.Country + else -> Locale.US.country + } + val newLocale = Locale(selectedLang, selectedCountry) + AppCompatDelegate.setApplicationLocales(LocaleListCompat.create(newLocale)) + } + + TimeCache.singleton.evictAll() + context.findFragmentActivityOrNull()?.apply { + onBackPressed() + recreate() + } + } + }, ) } } @@ -238,70 +269,36 @@ private fun LanguageItem() { private fun LanguageRadioItem( name: String, selected: Boolean, - onOptionSelected: (String) -> Unit, + onOptionSelected: () -> Unit, ) { Row( Modifier .fillMaxWidth() .height(56.dp) .selectable( - selected = selected, - onClick = { onOptionSelected(name) }, + selected = (selected), + onClick = { onOptionSelected() }, role = Role.RadioButton, ), verticalAlignment = Alignment.CenterVertically, ) { RadioButton( - selected = selected, - onClick = null, colors = RadioButtonDefaults.colors( selectedColor = MixinAppTheme.colors.accent, - unselectedColor = MixinAppTheme.colors.unchecked, ), + selected = selected, + onClick = null, // null recommended for accessibility with screenreaders ) Text( text = name, - style = - TextStyle( - fontSize = 16.sp, - color = MixinAppTheme.colors.textPrimary, - ), modifier = Modifier.padding(start = 16.dp), + fontSize = 14.sp, + color = MixinAppTheme.colors.textPrimary, ) } } -@Composable -private fun CurrencyItem( - initialCurrency: String, - getCurrencySymbol: (String) -> String, -) { - val currentCurrency = remember(initialCurrency) { mutableStateOf(initialCurrency) } - - val context = LocalContext.current - - AppearanceItem( - label = stringResource(id = R.string.Currency), - value = - stringResource( - R.string.wallet_setting_currency_desc, - currentCurrency.value, - getCurrencySymbol(currentCurrency.value), - ), - ) { - val activity = context.findFragmentActivityOrNull() ?: return@AppearanceItem - val currencyBottom = CurrencyBottomSheetDialogFragment.newInstance() - currencyBottom.callback = - object : CurrencyBottomSheetDialogFragment.Callback { - override fun onCurrencyClick(currency: Currency) { - currentCurrency.value = currency.name - } - } - currencyBottom.show(activity.supportFragmentManager, AppearanceFragment.TAG) - } -} - @Composable private fun AppearanceItem( label: String, @@ -311,34 +308,65 @@ private fun AppearanceItem( Row( modifier = Modifier - .height(60.dp) + .fillMaxWidth() .background(MixinAppTheme.colors.background) - .clickable { onClick() } - .padding(start = 16.dp, end = 16.dp), + .height(60.dp) + .clickable { + onClick() + } + .padding(horizontal = 16.dp), verticalAlignment = Alignment.CenterVertically, ) { Text( text = label, - fontSize = 14.sp, - color = MixinAppTheme.colors.textPrimary, + style = + TextStyle( + color = MixinAppTheme.colors.textPrimary, + fontSize = 14.sp, + ), ) + Spacer(Modifier.weight(1f)) + Text( text = value, - fontSize = 14.sp, - color = MixinAppTheme.colors.textAssist, + style = + TextStyle( + color = MixinAppTheme.colors.textAssist, + ), ) } } @Composable -@Preview -fun AppearancePagePreview() { - MixinAppTheme { - AppearancePageContent( - initialThemeId = 0, - initialCurrency = "USD", - getCurrencySymbol = { "$" } +private fun CurrencyItem() { + val currentCurrency = + remember { + mutableStateOf(Session.getFiatCurrency()) + } + + val context = LocalContext.current + + AppearanceItem( + label = stringResource(id = R.string.Currency), + value = + stringResource( + R.string.wallet_setting_currency_desc, + currentCurrency.value, + Fiats.getSymbol(currentCurrency.value), + ), + ) { + val activity = context.findFragmentActivityOrNull() ?: return@AppearanceItem + val currencyBottom = CurrencyBottomSheetDialogFragment.newInstance() + currencyBottom.callback = + object : CurrencyBottomSheetDialogFragment.Callback { + override fun onCurrencyClick(currency: Currency) { + currentCurrency.value = currency.name + } + } + currencyBottom.showNow( + activity.supportFragmentManager, + CurrencyBottomSheetDialogFragment.TAG, ) } } diff --git a/app/src/main/java/one/mixin/android/ui/setting/ui/page/MixinMemberUpgradePage.kt b/app/src/main/java/one/mixin/android/ui/setting/ui/page/MixinMemberUpgradePage.kt index 6bb11c07ba..68c7d76c37 100644 --- a/app/src/main/java/one/mixin/android/ui/setting/ui/page/MixinMemberUpgradePage.kt +++ b/app/src/main/java/one/mixin/android/ui/setting/ui/page/MixinMemberUpgradePage.kt @@ -60,44 +60,6 @@ fun MixinMemberUpgradePage( ) { val viewModel: MemberViewModel = hiltViewModel() - val pendingOrderState by viewModel.pendingOrder.collectAsState() - val subscriptionPlans by viewModel.subscriptionPlans.collectAsState() - - MixinMemberUpgradePageContent( - currentUserPlan = currentUserPlan, - selectedPlanOverride = selectedPlanOverride, - pendingOrderState = pendingOrderState, - subscriptionPlans = subscriptionPlans, - onClose = onClose, - onUrlGenerated = onUrlGenerated, - onGooglePlay = onGooglePlay, - onContactTeamMixin = onContactTeamMixin, - onViewInvoice = onViewInvoice, - getPlans = { viewModel.getPlans() }, - getOrder = { viewModel.getOrder(it) }, - insertOrders = { viewModel.insertOrders(it) }, - createMemberOrder = { viewModel.createMemberOrder(it) }, - clearPendingOrder = { viewModel.clearPendingOrder() } - ) -} - -@Composable -fun MixinMemberUpgradePageContent( - currentUserPlan: Plan, - selectedPlanOverride: Plan? = null, - pendingOrderState: MembershipOrder? = null, - subscriptionPlans: List = emptyList(), - onClose: () -> Unit, - onUrlGenerated: (String) -> Unit, - onGooglePlay: (orderId: String, playStoreSubscriptionId: String) -> Unit, - onContactTeamMixin: () -> Unit = {}, - onViewInvoice: (MembershipOrder) -> Unit = {}, - getPlans: suspend () -> one.mixin.android.api.response.MixinResponse, - getOrder: suspend (String) -> one.mixin.android.api.response.MixinResponse?, - insertOrders: suspend (MembershipOrder) -> Unit, - createMemberOrder: suspend (MemberOrderRequest) -> one.mixin.android.api.response.MixinResponse, - clearPendingOrder: () -> Unit -) { var purchaseState by remember { mutableStateOf(PlanPurchaseState()) } var savedOrderId by remember { mutableStateOf(null) } @@ -114,9 +76,12 @@ fun MixinMemberUpgradePageContent( ) } + val pendingOrderState by viewModel.pendingOrder.collectAsState() + val subscriptionPlans by viewModel.subscriptionPlans.collectAsState() + LaunchedEffect(Unit) { try { - val response = getPlans() + val response = viewModel.getPlans() if (response.isSuccess && response.data != null) { val availablePlayStorePlans = if (BuildConfig.IS_GOOGLE_PLAY) { val billingPlanIds = subscriptionPlans.map { it.planId }.toSet() @@ -133,6 +98,15 @@ fun MixinMemberUpgradePageContent( availablePlayStorePlans = availablePlayStorePlans ) Timber.d("Plans loaded: ${response.data!!.plans.size}, Valid Google Play plans: ${availablePlayStorePlans.size}") + + if (BuildConfig.IS_GOOGLE_PLAY) { + val billingPlanIds = subscriptionPlans.map { it.planId } + val backendPlayStoreIds = response.data!!.plans.mapNotNull { it.playStoreSubscriptionId } + + Timber.d("Billing library plan IDs: $billingPlanIds") + Timber.d("Backend Play Store subscription IDs: $backendPlayStoreIds") + Timber.d("Matched plan IDs: $availablePlayStorePlans") + } } } catch (e: Exception) { Timber.e(e, "Failed to load plans") @@ -145,7 +119,7 @@ fun MixinMemberUpgradePageContent( LaunchedEffect(pendingOrderState?.orderId ?: "") { try { while (pendingOrderState?.orderId.isNullOrEmpty().not()) { - val orderResponse = getOrder(pendingOrderState!!.orderId) + val orderResponse = viewModel.getOrder(pendingOrderState!!.orderId) if (orderResponse?.isSuccess == true && orderResponse.data != null) { val order = orderResponse.data!! val status = MemberOrderStatus.fromString(order.status) @@ -153,102 +127,101 @@ fun MixinMemberUpgradePageContent( when (status) { MemberOrderStatus.PAID, MemberOrderStatus.COMPLETED -> { Timber.d("Order completed: ${order.orderId}") - insertOrders(order) + viewModel.insertOrders(order) onClose() break } - MemberOrderStatus.PENDING -> { - Timber.d("Order still pending") - } - else -> { - Timber.d("Order status: $status") + + MemberOrderStatus.FAILED, MemberOrderStatus.EXPIRED, MemberOrderStatus.CANCEL -> { + Timber.d("Order failed: ${order.orderId}") + viewModel.insertOrders(order) + onClose() break } - } - } - delay(2000) - } - } catch (e: Exception) { - Timber.e(e, "Error checking order status") - } - } - LaunchedEffect(savedOrderId ?: "") { - try { - while (savedOrderId.isNullOrEmpty().not()) { - val orderResponse = getOrder(savedOrderId!!) - if (orderResponse?.isSuccess == true && orderResponse.data != null) { - val order = orderResponse.data!! - val status = MemberOrderStatus.fromString(order.status) - - if (status == MemberOrderStatus.PAID || status == MemberOrderStatus.COMPLETED) { - insertOrders(order) - onClose() - break + else -> { + Timber.d("Order pending: ${order.orderId}") + delay(3000) + } } + } else { + delay(3000) } - delay(3000) } } catch (e: Exception) { - Timber.e(e, "Error checking saved order status") + purchaseState.copy(error = ErrorHandler.getErrorMessage(e)) + Timber.e(e, "Failed to poll order status") } } - Column( - modifier = Modifier - .fillMaxSize() - .padding(16.dp) - ) { - MemberUpgradeTopBar(onClose = onClose) - - MemberUpgradeContent( - currentUserPlan = currentUserPlan, - purchaseState = purchaseState, - onContactTeamMixin = onContactTeamMixin + MixinAppTheme { + Column( + modifier = Modifier + .fillMaxSize() + .padding(16.dp) ) + { + MemberUpgradeTopBar(onClose = onClose) + Spacer(modifier = Modifier.height(16.dp)) + + PlanSelector( + selectedPlan = selectedPlan, + onPlanSelected = { plan -> + selectedPlan = plan + } + ) - Spacer(modifier = Modifier.height(24.dp)) - - PlanSelector( - availablePlans = purchaseState.availablePlans, - selectedPlan = selectedPlan, - onPlanSelected = { selectedPlan = it } - ) + Spacer(modifier = Modifier.height(10.dp)) - Spacer(modifier = Modifier.height(24.dp)) + Column(modifier = Modifier.weight(1f)) { + MemberUpgradeContent(selectedPlan = selectedPlan) + } - val scope = rememberCoroutineScope() - MemberUpgradePaymentButton( - selectedPlan = selectedPlan, - purchaseState = purchaseState, - onClick = { - val plan = purchaseState.availablePlans.find { Plan.fromString(it.name) == selectedPlan } - if (plan != null) { - val orderRequest = MemberOrderRequest(planId = plan.planId) - purchaseState = purchaseState.copy(loading = true) - scope.launch(CoroutineExceptionHandler { _, error -> - Timber.e(error, "Error creating order") - purchaseState = purchaseState.copy(loading = false, error = error.message) + MemberUpgradePaymentButton( + currentUserPlan = currentUserPlan, + selectedPlan = selectedPlan, + pendingOrder = pendingOrderState, + purchaseState = purchaseState, + savedOrderId = savedOrderId, + onPaymentClick = { + val isGooglePlayChannel = BuildConfig.IS_GOOGLE_PLAY + val plan = + mapLocalPlanToMemberOrderPlan(selectedPlan, purchaseState.availablePlans) + ?: return@MemberUpgradePaymentButton + viewModel.viewModelScope.launch(CoroutineExceptionHandler { _, error -> + purchaseState = purchaseState.copy(loading = false) + purchaseState = purchaseState.copy( + error = ErrorHandler.getErrorMessage(error) + ) }) { - val orderResponse = createMemberOrder(orderRequest) + purchaseState = purchaseState.copy(loading = true) + val orderRequest = if (isGooglePlayChannel) { + MemberOrderRequest(plan = plan.plan, fiatSource = "play_store", subscriptionId = plan.playStoreSubscriptionId) + } else { + MemberOrderRequest(plan = plan.plan) + } + val orderResponse = viewModel.createMemberOrder(orderRequest) + if (orderResponse.isSuccess && orderResponse.data != null) { - val order = orderResponse.data!! - if (order.status == MemberOrderStatus.PAID.name || order.status == MemberOrderStatus.COMPLETED.name) { - onViewInvoice(order) - } else if (order.payUrl.isNullOrEmpty().not()) { - onUrlGenerated(order.payUrl!!) - } else if (order.playStoreSubscriptionId.isNullOrEmpty().not()) { - onGooglePlay(order.orderId, order.playStoreSubscriptionId!!) - savedOrderId = order.orderId + orderResponse.data?.orderId?.let { orderId -> + savedOrderId = orderId + } + + if (isGooglePlayChannel) { + plan.playStoreSubscriptionId?.let { playStoreId -> + onGooglePlay(orderResponse.data!!.orderId!!, playStoreId) + } + } else { + onUrlGenerated(orderResponse.data!!.paymentUrl!!) } - } else { - purchaseState = purchaseState.copy(loading = false, error = orderResponse.error?.description ?: "Unknown error") } purchaseState = purchaseState.copy(loading = false) } - } - } - ) + }, + onContactSupport = onContactTeamMixin, + onViewInvoice = onViewInvoice + ) + } } } @@ -256,18 +229,13 @@ fun MixinMemberUpgradePageContent( @Composable private fun MixinMemberUpgradePagePreview() { MixinAppTheme { - MixinMemberUpgradePageContent( + MixinMemberUpgradePage( currentUserPlan = Plan.ADVANCE, selectedPlanOverride = null, onClose = {}, onUrlGenerated = {}, onGooglePlay = { _, _ -> }, - onContactTeamMixin = {}, - getPlans = { one.mixin.android.api.response.MixinResponse() }, - getOrder = { null }, - insertOrders = {}, - createMemberOrder = { one.mixin.android.api.response.MixinResponse() }, - clearPendingOrder = {} + onContactTeamMixin = {} ) } } diff --git a/app/src/main/java/one/mixin/android/ui/setting/ui/page/NotificationsPage.kt b/app/src/main/java/one/mixin/android/ui/setting/ui/page/NotificationsPage.kt index 5d280729b8..4ff9c0a0b9 100644 --- a/app/src/main/java/one/mixin/android/ui/setting/ui/page/NotificationsPage.kt +++ b/app/src/main/java/one/mixin/android/ui/setting/ui/page/NotificationsPage.kt @@ -237,37 +237,14 @@ private fun NotificationItem( @Composable private fun TransferNotificationItem() { - val viewModel = hiltViewModel() - val thresholdValue = remember { - mutableStateOf(Session.getAccount()!!.transferNotificationThreshold) - } - TransferNotificationItemContent( - thresholdValue = thresholdValue.value, - onUpdateThreshold = { newValue -> - viewModel.preferences( - AccountUpdateRequest( - fiatCurrency = Session.getFiatCurrency(), - transferNotificationThreshold = newValue, - ), - ) - }, - onSuccess = { account -> - thresholdValue.value = account.transferNotificationThreshold - } - ) -} - -@Composable -private fun TransferNotificationItemContent( - thresholdValue: Double, - onUpdateThreshold: suspend (Double) -> retrofit2.Response>, - onSuccess: (one.mixin.android.vo.Account) -> Unit, -) { val accountSymbol = remember { Fiats.getSymbol() } - val threshold = remember(thresholdValue) { mutableStateOf(thresholdValue) } + val threshold = + remember { + mutableStateOf(Session.getAccount()!!.transferNotificationThreshold) + } var showEditDialog by remember { mutableStateOf(false) @@ -278,6 +255,7 @@ private fun TransferNotificationItemContent( } val scope = rememberCoroutineScope() + val viewModel = hiltViewModel() NotificationItem( trailing = { @@ -300,6 +278,7 @@ private fun TransferNotificationItemContent( }, text = threshold.value.toString(), onConfirm = { + Timber.d("onConfirm $it") val result = it.toDoubleOrNull() if (result == null) { toast(R.string.Data_error) @@ -308,13 +287,17 @@ private fun TransferNotificationItemContent( scope.launch { handleMixinResponse( invokeNetwork = { - onUpdateThreshold(result) + viewModel.preferences( + AccountUpdateRequest( + fiatCurrency = Session.getFiatCurrency(), + transferNotificationThreshold = result, + ), + ) }, successBlock = { response -> response.data?.let { account -> Session.storeAccount(account) threshold.value = account.transferNotificationThreshold - onSuccess(account) } }, doAfterNetworkSuccess = { @@ -342,37 +325,14 @@ private fun TransferNotificationItemContent( @Composable private fun TransferLargeAmountItem() { - val viewModel = hiltViewModel() - val thresholdValue = remember { - mutableStateOf(Session.getAccount()!!.transferConfirmationThreshold) - } - TransferLargeAmountItemContent( - thresholdValue = thresholdValue.value, - onUpdateThreshold = { newValue -> - viewModel.preferences( - AccountUpdateRequest( - fiatCurrency = Session.getFiatCurrency(), - transferConfirmationThreshold = newValue, - ), - ) - }, - onSuccess = { account -> - thresholdValue.value = account.transferConfirmationThreshold - } - ) -} - -@Composable -private fun TransferLargeAmountItemContent( - thresholdValue: Double, - onUpdateThreshold: suspend (Double) -> retrofit2.Response>, - onSuccess: (one.mixin.android.vo.Account) -> Unit, -) { val accountSymbol = remember { Fiats.getSymbol() } - val threshold = remember(thresholdValue) { mutableStateOf(thresholdValue) } + val threshold = + remember { + mutableStateOf(Session.getAccount()!!.transferConfirmationThreshold) + } var showEditDialog by remember { mutableStateOf(false) @@ -383,6 +343,7 @@ private fun TransferLargeAmountItemContent( } val scope = rememberCoroutineScope() + val viewModel = hiltViewModel() NotificationItem( trailing = { @@ -416,6 +377,7 @@ private fun TransferLargeAmountItemContent( }, text = threshold.value.toString(), onConfirm = { + Timber.d("onConfirm $it") val result = it.toDoubleOrNull() if (result == null) { toast(R.string.Data_error) @@ -424,13 +386,17 @@ private fun TransferLargeAmountItemContent( scope.launch { handleMixinResponse( invokeNetwork = { - onUpdateThreshold(result) + viewModel.preferences( + AccountUpdateRequest( + fiatCurrency = Session.getFiatCurrency(), + transferConfirmationThreshold = result, + ), + ) }, successBlock = { response -> response.data?.let { account -> Session.storeAccount(account) threshold.value = account.transferConfirmationThreshold - onSuccess(account) } }, doAfterNetworkSuccess = { diff --git a/app/src/main/java/one/mixin/android/ui/setting/ui/page/PinLogsPage.kt b/app/src/main/java/one/mixin/android/ui/setting/ui/page/PinLogsPage.kt index 623795ed1c..745c213391 100644 --- a/app/src/main/java/one/mixin/android/ui/setting/ui/page/PinLogsPage.kt +++ b/app/src/main/java/one/mixin/android/ui/setting/ui/page/PinLogsPage.kt @@ -46,22 +46,12 @@ import timber.log.Timber @Composable fun PinLogsPage() { - val viewModel = hiltViewModel() - PinLogsPageContent( - loadMore = { lastCreatedAt -> - viewModel.getPinLogs(lastCreatedAt) - } - ) -} - -@Composable -fun PinLogsPageContent( - loadMore: suspend (String?) -> retrofit2.Response>>, -) { SettingPageScaffold( title = stringResource(id = R.string.Logs), verticalScrollable = false, ) { + val viewModel = hiltViewModel() + var logs by remember { mutableStateOf(listOf()) } @@ -76,13 +66,13 @@ fun PinLogsPageContent( val scope = rememberCoroutineScope() - suspend fun loadMoreInternal() { + suspend fun loadMore() { if (isLoading || !hasMore) return isLoading = true Timber.d("loadMore") handleMixinResponse( invokeNetwork = { - loadMore(logs.lastOrNull()?.createdAt) + viewModel.getPinLogs(logs.lastOrNull()?.createdAt) }, successBlock = { val data = it.data ?: emptyList() @@ -97,7 +87,7 @@ fun PinLogsPageContent( } LaunchedEffect(true) { - loadMoreInternal() + loadMore() } if (logs.isEmpty()) { @@ -111,7 +101,7 @@ fun PinLogsPageContent( logs = logs, loadMore = { scope.launch { - loadMoreInternal() + loadMore() } }, ) diff --git a/app/src/main/java/one/mixin/android/ui/setting/ui/page/SecurityPage.kt b/app/src/main/java/one/mixin/android/ui/setting/ui/page/SecurityPage.kt index 5254d799e4..bebed04838 100644 --- a/app/src/main/java/one/mixin/android/ui/setting/ui/page/SecurityPage.kt +++ b/app/src/main/java/one/mixin/android/ui/setting/ui/page/SecurityPage.kt @@ -21,23 +21,29 @@ import one.mixin.android.ui.tip.TipType @Composable fun SecurityPage() { - SecurityPageContent(hasPin = Session.getAccount()?.hasPin == true) -} - -@Composable -fun SecurityPageContent(hasPin: Boolean) { val navController = LocalSettingNav.current val context = LocalContext.current SettingPageScaffold(title = stringResource(id = R.string.Security)) { SettingTile(title = stringResource(R.string.PIN)) { - if (hasPin) { + if (Session.getAccount()?.hasPin == true) { navController.navigation(SettingDestination.PinSetting) } else { TipActivity.show(context as SettingActivity, TipType.Create) } } -... + + Box(modifier = Modifier.height(16.dp)) + SettingTile(title = stringResource(R.string.Emergency_Contact)) { + navController.navigation(SettingDestination.EmergencyContact) + } + + Box(modifier = Modifier.height(16.dp)) + SettingTile(title = stringResource(R.string.Authorizations)) { + navController.navigation(SettingDestination.Authentications) + } + + Box(modifier = Modifier.height(16.dp)) SettingTile(title = stringResource(R.string.Logs)) { navController.navigation(SettingDestination.PinLogs) } @@ -48,6 +54,6 @@ fun SecurityPageContent(hasPin: Boolean) { @Preview fun SecurityPagePreview() { MixinAppTheme { - SecurityPageContent(hasPin = true) + SecurityPage() } } diff --git a/app/src/main/java/one/mixin/android/ui/tip/wc/sessionproposal/WCPinBoard.kt b/app/src/main/java/one/mixin/android/ui/tip/wc/sessionproposal/WCPinBoard.kt index f5a22e00c8..d8f7bd3abb 100644 --- a/app/src/main/java/one/mixin/android/ui/tip/wc/sessionproposal/WCPinBoard.kt +++ b/app/src/main/java/one/mixin/android/ui/tip/wc/sessionproposal/WCPinBoard.kt @@ -54,7 +54,6 @@ import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.platform.ClipboardManager import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalInspectionMode import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.AnnotatedString @@ -92,48 +91,13 @@ fun WCPinBoard( onPinComplete: ((String) -> Unit)?, ) { val context = LocalContext.current - val isInPreview = LocalInspectionMode.current - - val showBiometric = if (isInPreview) false else allowBiometric && BiometricUtil.shouldShowBiometric(context) - val randomKeyboardEnabled by if (isInPreview) { - remember { mutableStateOf(false) } - } else { - context.defaultSharedPreferences.booleanValueAsState( + val clipboardManager: ClipboardManager = LocalClipboardManager.current + val showBiometric = allowBiometric && BiometricUtil.shouldShowBiometric(context) + val randomKeyboardEnabled by LocalContext.current.defaultSharedPreferences + .booleanValueAsState( key = Constants.Account.PREF_RANDOM, defaultValue = false, ) - } - - WCPinBoardContent( - step = step, - errorInfo = errorInfo, - showBiometric = showBiometric, - signUnavailable = signUnavailable, - randomKeyboardEnabled = randomKeyboardEnabled, - onNegativeClick = onNegativeClick, - onPositiveClick = onPositiveClick, - onDoneClick = onDoneClick, - onBiometricClick = onBiometricClick, - onPinComplete = onPinComplete - ) -} - -@Composable -fun WCPinBoardContent( - step: Step, - errorInfo: String?, - showBiometric: Boolean, - signUnavailable: Boolean, - randomKeyboardEnabled: Boolean, - onNegativeClick: () -> Unit, - onPositiveClick: () -> Unit, - onDoneClick: () -> Unit, - onBiometricClick: (() -> Unit)?, - onPinComplete: ((String) -> Unit)?, -) { - val context = LocalContext.current - val clipboardManager: ClipboardManager = LocalClipboardManager.current - val list = if (randomKeyboardEnabled) { mutableListOf("1", "2", "3", "4", "5", "6", "7", "8", "9", "0").apply { @@ -142,215 +106,359 @@ fun WCPinBoardContent( add("<<") } } else { - listOf("1", "2", "3", "4", "5", "6", "7", "8", "9", "", "0", "<<") + listOf( + "1", + "2", + "3", + "4", + "5", + "6", + "7", + "8", + "9", + "", + "0", + "<<", + ) } + var size by remember { mutableStateOf(IntSize.Zero) } + var pinCode by remember { mutableStateOf("") } - var pinValue by remember { - mutableStateOf("") - } - - Column( - modifier = - Modifier - .fillMaxWidth() - .padding(horizontal = 24.dp), - ) { - if (step == Step.Input || step == Step.Verifying) { - Spacer(modifier = Modifier.height(10.dp)) - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.Center, - ) { - for (i in 1..6) { - Box( - modifier = - Modifier - .size(14.dp) - .clip(RoundedCornerShape(7.dp)) - .background( - if (pinValue.length >= i) { - MixinAppTheme.colors.accent - } else { - MixinAppTheme.colors.backgroundGrayLight - }, - ), - ) - if (i != 6) { - Spacer(modifier = Modifier.width(16.dp)) - } - } + AnimatedContent(targetState = step, transitionSpec = { + if (targetState == Step.Input) { + (slideInVertically(initialOffsetY = { it }) togetherWith scaleOut() + fadeOut()) + } else if (initialState == Step.Input) { + if (targetState == Step.Loading) { + (EnterTransition.None togetherWith ExitTransition.None) + } else { + (scaleIn() + fadeIn() togetherWith fadeOut()) } - Spacer(modifier = Modifier.height(16.dp)) - Box( - modifier = - Modifier - .fillMaxWidth() - .height(20.dp), - contentAlignment = Alignment.Center, - ) { - if (errorInfo != null) { + } else { + (scaleIn() + fadeIn() togetherWith scaleOut() + fadeOut()) + } + }, label = "") { s -> + when (s) { + Step.Error -> + Column( + modifier = + Modifier + .clickable( + onClick = { + clipboardManager.setText(AnnotatedString(errorInfo ?: "")) + toast(R.string.copied_to_clipboard) + }, + ) + .height(200.dp) + .fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + ) { Text( - text = errorInfo, + text = "Error", color = MixinAppTheme.colors.red, - fontSize = 14.sp, + fontSize = 18.sp, + ) + Text( + modifier = Modifier.padding(32.dp, 12.dp, 32.dp, 32.dp), + text = errorInfo ?: "", textAlign = TextAlign.Center, + color = MixinAppTheme.colors.textPrimary, + fontSize = 14.sp, ) } - } - - Spacer(modifier = Modifier.height(16.dp)) - - var keyboardHeight by remember { - mutableStateOf(0.dp) - } - - Box( - modifier = - Modifier - .fillMaxWidth() - .heightIn(min = keyboardHeight), - ) { - if (step == Step.Verifying) { - CircularProgressIndicator( - modifier = - Modifier - .size(32.dp) - .align(Alignment.Center), - color = MixinAppTheme.colors.accent, + Step.Done -> + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Box(modifier = Modifier.height(20.dp)) + Image( + painter = painterResource(id = R.drawable.ic_transfer_done), + contentDescription = null, ) - } else { - LazyVerticalGrid( - columns = GridCells.Fixed(3), - modifier = - Modifier - .fillMaxWidth() - .onSizeChanged { - keyboardHeight = context.pxToDp(it.height).dp - }, - verticalArrangement = Arrangement.spacedBy(16.dp), - horizontalArrangement = Arrangement.spacedBy(16.dp), + Box(modifier = Modifier.height(12.dp)) + Text(text = stringResource(R.string.Success), color = MixinAppTheme.colors.textMinor) + Box(modifier = Modifier.height(40.dp)) + MixinButton( + onClick = { onDoneClick.invoke() }, + contentPadding = PaddingValues(horizontal = 28.dp), + shape = RoundedCornerShape(40.dp), ) { - items(list.size) { index -> - val item = list[index] - when (item) { - "" -> { - if (showBiometric) { - Box( - modifier = - Modifier - .size(60.dp) - .clip(RoundedCornerShape(30.dp)) - .clickable { - onBiometricClick?.invoke() - }, - contentAlignment = Alignment.Center, - ) { - Icon( - painter = painterResource(id = R.drawable.ic_biometric), - contentDescription = null, - tint = MixinAppTheme.colors.icon, - ) + Text( + text = stringResource(id = R.string.Done), + fontSize = 16.sp, + color = Color.White, + ) + } + Box(modifier = Modifier.height(32.dp)) + } + Step.Sign -> + Column( + modifier = + Modifier + .fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.SpaceEvenly, + ) { + Box(modifier = Modifier.height(20.dp)) + MixinButton( + modifier = Modifier.widthIn(min = 100.dp), + onClick = { + if (!signUnavailable) { + onPositiveClick.invoke() + } + }, + contentPadding = PaddingValues(horizontal = 28.dp, vertical = 11.dp), + shape = RoundedCornerShape(40.dp), + ) { + if (signUnavailable) { + CircularProgressIndicator( + modifier = + Modifier + .size(16.dp), + color = Color.White, + strokeWidth = 2.dp, + ) + } else { + Text( + text = stringResource(id = R.string.sign_by_pin), + color = Color.White, + fontSize = 16.sp, + ) + } + } + Box(modifier = Modifier.height(24.dp)) + TextButton( + onClick = { onNegativeClick() }, + contentPadding = PaddingValues(horizontal = 28.dp, vertical = 11.dp), + shape = RoundedCornerShape(40.dp), + ) { + Text( + text = stringResource(R.string.Cancel), + color = Color(0xFF4B7CDD), + fontSize = 16.sp, + ) + } + Box(modifier = Modifier.height(32.dp)) + } + else -> + Column(horizontalAlignment = Alignment.CenterHorizontally) { + AnimatedContent(targetState = step, transitionSpec = { + (fadeIn() togetherWith fadeOut()) + }, label = "") { + if (step == Step.Input) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + modifier = + Modifier + .fillMaxWidth() + .wrapContentHeight(), + ) { + Box(modifier = Modifier.height(10.dp)) + Box( + modifier = + Modifier + .padding(8.dp) + .fillMaxWidth(), + contentAlignment = Alignment.Center, + ) { + LazyRow( + modifier = Modifier.height(20.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + items(6) { index -> + val hasContent = index < pinCode.length + AnimatedContent( + targetState = hasContent, + transitionSpec = { + if (targetState > initialState) { + scaleIn() + fadeIn() togetherWith scaleOut() + fadeOut() + } else { + scaleIn() + fadeIn() togetherWith scaleOut() + fadeOut() + }.using( + SizeTransform(clip = false), + ) + }, + label = "", + ) { b -> + Text( + "*", + modifier = + Modifier + .width(24.dp), + fontWeight = FontWeight.Bold, + color = if (b) MixinAppTheme.colors.textPrimary else MixinAppTheme.colors.textMinor, + fontSize = if (b) 18.sp else 12.sp, + textAlign = TextAlign.Center, + ) + } } - } else { - Box(modifier = Modifier.size(60.dp)) } } - "<<" -> { - Box( + if (showBiometric) { + Spacer(modifier = Modifier.height(12.dp)) + Row( + verticalAlignment = Alignment.CenterVertically, modifier = Modifier - .size(60.dp) - .clip(RoundedCornerShape(30.dp)) - .clickable { - if (pinValue.isNotEmpty()) { - pinValue = pinValue.substring(0, pinValue.length - 1) - context.tickVibrate() - } - }, - contentAlignment = Alignment.Center, + .padding(horizontal = 12.dp, vertical = 3.dp) + .clip( + shape = RoundedCornerShape(4.dp), + ) + .clickable { onBiometricClick?.invoke() }, ) { - Icon( - painter = painterResource(id = R.drawable.ic_backspace), + Image( + painter = painterResource(R.drawable.ic_biometric), contentDescription = null, - tint = MixinAppTheme.colors.icon, ) - } - } - else -> { - Box( - modifier = - Modifier - .size(60.dp) - .clip(RoundedCornerShape(30.dp)) - .background(MixinAppTheme.colors.backgroundGrayLight) - .clickable { - if (pinValue.length < 6) { - pinValue += item - context.tickVibrate() - if (pinValue.length == 6) { - onPinComplete?.invoke(pinValue) - pinValue = "" - } - } - }, - contentAlignment = Alignment.Center, - ) { + Spacer(modifier = Modifier.width(4.dp)) Text( - text = item, - fontSize = 24.sp, - color = MixinAppTheme.colors.textPrimary, + text = stringResource(R.string.Verify_by_Biometric), + color = MixinAppTheme.colors.textBlue, ) } } + Spacer(modifier = Modifier.height(12.dp)) + } + } else { + Box( + contentAlignment = Alignment.Center, + modifier = + Modifier + .fillMaxWidth() + .height(94.dp), + ) { + CircularProgressIndicator( + modifier = + Modifier + .size(32.dp), + color = MixinAppTheme.colors.accent, + ) + } + } + } + AnimatedVisibility( + visible = step == Step.Input || step == Step.Loading, + enter = slideInVertically(initialOffsetY = { it }), + exit = slideOutVertically(targetOffsetY = { it }), + ) { + Column(modifier = Modifier.background(MixinAppTheme.colors.backgroundWindow)) { + if (Session.getTipPub() != null) { + Row( + modifier = + Modifier + .background(MixinAppTheme.colors.backgroundWindow) + .height(36.dp) + .fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center, + ) { + Icon( + painter = painterResource(id = R.drawable.ic_secret_tip), + contentDescription = null, + tint = MixinAppTheme.colors.textMinor, + ) + Text( + color = MixinAppTheme.colors.textMinor, + text = stringResource(id = R.string.Secured_by_TIP), + fontSize = 12.sp, + ) + } + } + Box( + modifier = + Modifier + .wrapContentHeight() + .heightIn(120.dp, 240.dp) + .onSizeChanged { + size = it + }, + ) { + LazyVerticalGrid( + modifier = + Modifier + .fillMaxHeight() + .padding(horizontal = 8.dp, vertical = 8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + columns = GridCells.Fixed(3), + content = { + items(list.size) { index -> + Box( + contentAlignment = Alignment.Center, + modifier = + Modifier + .height( + context.pxToDp( + ( + size.toSize().height - + context.dpToPx( + 40f, + ) + ) / 4, + ).dp, + ) + .clip(shape = RoundedCornerShape(8.dp)) + .background( + when (index) { + 11 -> MixinAppTheme.colors.backgroundDark + 9 -> Color.Transparent + else -> MixinAppTheme.colors.background + }, + ) + .run { + if (step == Step.Input && index != 9) { + clickable { + context.tickVibrate() + if (index == 11) { + if (pinCode.isNotEmpty()) { + pinCode = + pinCode.substring( + 0, + pinCode.length - 1, + ) + } + } else if (pinCode.length < 6) { + pinCode += list[index] + if (pinCode.length == 6) { + onPinComplete?.invoke(pinCode) + pinCode = "" + } + } + } + } else { + this + } + }, + ) { + if (index == 11) { + Image( + painter = painterResource(R.drawable.ic_delete), + contentDescription = null, + ) + } else if (index != 9) { + Text( + text = list[index], + fontSize = 24.sp, + color = MixinAppTheme.colors.textPrimary, + textAlign = TextAlign.Center, + ) + } + } + } + }, + ) } } } } - } - - Spacer(modifier = Modifier.height(24.dp)) - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.Center, - ) { - Text( - text = stringResource(id = R.string.Forgot_PIN), - modifier = - Modifier.clickable { - context.toast(R.string.forget_pin_tip) - }, - color = MixinAppTheme.colors.accent, - fontSize = 14.sp, - ) - } - Spacer(modifier = Modifier.height(24.dp)) - } else { - Spacer(modifier = Modifier.height(24.dp)) - MixinButton( - text = stringResource(id = R.string.Done), - modifier = Modifier.fillMaxWidth(), - onClick = onDoneClick, - ) - Spacer(modifier = Modifier.height(24.dp)) } } } -@Preview(showBackground = true) +@Preview @Composable fun WCPinBoardPreview() { - MixinAppTheme { - WCPinBoardContent( - step = Step.Input, - errorInfo = null, - showBiometric = true, - signUnavailable = false, - randomKeyboardEnabled = false, - onNegativeClick = {}, - onPositiveClick = {}, - onDoneClick = {}, - onBiometricClick = {}, - onPinComplete = {} - ) - } + WCPinBoard(Step.Input, null, allowBiometric = false, true, {}, {}, {}, null, null) } diff --git a/app/src/main/java/one/mixin/android/ui/tip/wc/sessionrequest/SessionRequestPage.kt b/app/src/main/java/one/mixin/android/ui/tip/wc/sessionrequest/SessionRequestPage.kt index 732463dc48..33623d8232 100644 --- a/app/src/main/java/one/mixin/android/ui/tip/wc/sessionrequest/SessionRequestPage.kt +++ b/app/src/main/java/one/mixin/android/ui/tip/wc/sessionrequest/SessionRequestPage.kt @@ -98,72 +98,26 @@ fun SessionRequestPage( showPin: () -> Unit, ) { val viewModel = hiltViewModel() - val sessionRequestUI = viewModel.getSessionRequestUI(version, chain, signData, sessionRequest) - - SessionRequestPageContent( - gson = gson, - version = version, - account = account, - step = step, - chain = chain, - topic = topic, - sessionRequest = sessionRequest, - sessionRequestUI = sessionRequestUI, - asset = asset, - tipGas = tipGas, - errorInfo = errorInfo, - isFeeWaived = isFeeWaived, - onFreeClick = onFreeClick, - onPreviewMessage = onPreviewMessage, - onDismissRequest = onDismissRequest, - showPin = showPin, - checkAddressAndGetDisplayName = { addr -> viewModel.checkAddressAndGetDisplayName(addr, null) }, - findWalletById = { id -> viewModel.findWalletById(id) }, - web3TokenItemById = { id, assetId -> viewModel.web3TokenItemById(id, assetId = assetId) }, - getContent = { v, g, data -> viewModel.getContent(v, g, data) }, - rejectRequest = { v, t -> viewModel.rejectRequest(v, t) }, - ) -} - -@Composable -fun SessionRequestPageContent( - gson: Gson, - version: WalletConnect.Version, - account: String, - step: WalletConnectBottomSheetDialogFragment.Step, - chain: Chain, - topic: String, - sessionRequest: Wallet.Model.SessionRequest?, - sessionRequestUI: one.mixin.android.tip.wc.internal.SessionRequestUI?, - asset: Token?, - tipGas: TipGas?, - errorInfo: String?, - isFeeWaived: Boolean = false, - onFreeClick: (() -> Unit)? = null, - onPreviewMessage: (String) -> Unit, - onDismissRequest: () -> Unit, - showPin: () -> Unit, - checkAddressAndGetDisplayName: suspend (String) -> Triple?, - findWalletById: suspend (String) -> one.mixin.android.vo.safe.SafeWallet?, - web3TokenItemById: suspend (String, String) -> Web3TokenItem?, - getContent: (WalletConnect.Version, Gson, Any?) -> String, - rejectRequest: (WalletConnect.Version, String) -> Unit, -) { val context = LocalContext.current val commonWallet = stringResource(R.string.Common_Wallet) var walletName by remember { mutableStateOf(null) } var walletDisplayInfo by remember { mutableStateOf?>(null) } var chainToken by remember { mutableStateOf(null) } - if (version != WalletConnect.Version.TIP && (sessionRequestUI == null)) { + if (version != WalletConnect.Version.TIP && (signData == null || sessionRequest == null)) { + Loading() + return + } + val sessionRequestUI = viewModel.getSessionRequestUI(version, chain, signData, sessionRequest) + if (sessionRequestUI == null) { Loading() return } val signType = - if ((sessionRequestUI?.data as? WCEthereumSignMessage)?.type == WCEthereumSignMessage.WCSignType.PERSONAL_MESSAGE) { + if ((sessionRequestUI.data as? WCEthereumSignMessage)?.type == WCEthereumSignMessage.WCSignType.PERSONAL_MESSAGE) { 0 - } else if (sessionRequestUI?.data is WCEthereumTransaction && (sessionRequestUI.data.value == null || Numeric.decodeQuantity(sessionRequestUI.data.value) == BigInteger.ZERO)) { + } else if (sessionRequestUI.data is WCEthereumTransaction && (sessionRequestUI.data.value == null || Numeric.decodeQuantity(sessionRequestUI.data.value) == BigInteger.ZERO)) { 2 } else { 1 @@ -171,7 +125,7 @@ fun SessionRequestPageContent( LaunchedEffect(account) { try { - walletDisplayInfo = checkAddressAndGetDisplayName(account) + walletDisplayInfo = viewModel.checkAddressAndGetDisplayName(account, null) } catch (e: Exception) { walletDisplayInfo = null } @@ -180,7 +134,7 @@ fun SessionRequestPageContent( LaunchedEffect(Unit) { try { - val wallet = findWalletById(Web3Signer.currentWalletId) + val wallet = viewModel.findWalletById(Web3Signer.currentWalletId) walletName = wallet?.name?.takeIf { it.isNotEmpty() } ?: commonWallet } catch (e: Exception) { walletName = commonWallet @@ -189,7 +143,7 @@ fun SessionRequestPageContent( LaunchedEffect(Unit) { try { - chainToken = web3TokenItemById(Web3Signer.currentWalletId, chain.assetId) + chainToken = viewModel.web3TokenItemById(Web3Signer.currentWalletId, assetId = chain.assetId) } catch (e: Exception) { Timber.e(e) } @@ -342,7 +296,7 @@ fun SessionRequestPageContent( when (sessionRequestUI.data) { is WCEthereumTransaction -> { if (signType == 2) { - MessagePreview(content = getContent(version, gson, sessionRequestUI.data)) { + MessagePreview(content = viewModel.getContent(version, gson, sessionRequestUI.data)) { onPreviewMessage.invoke(it) } } else { @@ -361,7 +315,7 @@ fun SessionRequestPageContent( } else -> { - MessagePreview(content = getContent(version, gson, sessionRequestUI.data)) { + MessagePreview(content = viewModel.getContent(version, gson, sessionRequestUI.data)) { onPreviewMessage.invoke(it) } } @@ -456,7 +410,7 @@ fun SessionRequestPageContent( ActionButton( text = stringResource(id = R.string.insufficient_balance_symbol, chain.symbol), onClick = { - rejectRequest(version, topic) + viewModel.rejectRequest(version, topic) onDismissRequest.invoke() }, backgroundColor = MixinAppTheme.colors.backgroundGray, @@ -466,7 +420,7 @@ fun SessionRequestPageContent( } } else { ActionBottom(modifier = Modifier.align(Alignment.BottomCenter), stringResource(id = R.string.Cancel), stringResource(id = R.string.Confirm), { - rejectRequest(version, topic) + viewModel.rejectRequest(version, topic) onDismissRequest.invoke() }, showPin) } @@ -634,21 +588,17 @@ fun FeeInfo( @Preview @Composable private fun NetworkInfoPreview() { - MixinAppTheme { - FeeInfo("0.0169028 ETH", BigDecimal("7.57")) - } + FeeInfo("0.0169028 ETH", BigDecimal("7.57")) } @Preview @Composable private fun HintPreview() { - MixinAppTheme { - Column(modifier = Modifier.padding(8.dp)) { - Hint(Hint.NoPreview) - Box(modifier = Modifier.height(8.dp)) - Hint(Hint.Cancel) - Box(modifier = Modifier.height(8.dp)) - Hint(Hint.SpeedUp) - } + Column(modifier = Modifier.padding(8.dp)) { + Hint(Hint.NoPreview) + Box(modifier = Modifier.height(8.dp)) + Hint(Hint.Cancel) + Box(modifier = Modifier.height(8.dp)) + Hint(Hint.SpeedUp) } } diff --git a/app/src/main/java/one/mixin/android/ui/wallet/components/AssetDashboardScreen.kt b/app/src/main/java/one/mixin/android/ui/wallet/components/AssetDashboardScreen.kt index 324125e0c2..559dcaf062 100644 --- a/app/src/main/java/one/mixin/android/ui/wallet/components/AssetDashboardScreen.kt +++ b/app/src/main/java/one/mixin/android/ui/wallet/components/AssetDashboardScreen.kt @@ -80,25 +80,6 @@ fun AssetDashboardScreen( onUpgradePlan: () -> Unit, ) { val viewModel: AssetDistributionViewModel = hiltViewModel() - val wallets by viewModel.wallets.collectAsStateWithLifecycle() - - AssetDashboardScreenContent( - wallets = wallets, - onLoadWallets = { viewModel.loadWallets() }, - onWalletCardClick = onWalletCardClick, - onAddWalletClick = onAddWalletClick, - onUpgradePlan = onUpgradePlan - ) -} - -@Composable -fun AssetDashboardScreenContent( - wallets: List, - onLoadWallets: () -> Unit, - onWalletCardClick: (destination: WalletDestination) -> Unit, - onAddWalletClick: () -> Unit, - onUpgradePlan: () -> Unit, -) { val context = LocalContext.current val safeCreateGuidelineUrl: String = stringResource(R.string.safe_create_guideline_url) val safeLearnMoreUrl: String = stringResource(R.string.safe_learn_more_url) @@ -115,6 +96,7 @@ fun AssetDashboardScreenContent( val hideSafeWalletInfo = remember { mutableStateOf(prefs.getBoolean(KEY_HIDE_SAFE_WALLET_INFO, false)) } val hasSeenSafeCategoryBadge = remember { mutableStateOf(prefs.getBoolean(KEY_SAFE_CATEGORY_BADGE_SEEN, false)) } val addWalletClicked = remember { mutableStateOf(prefs.getBoolean(PREF_HAS_USED_ADD_WALLET, false)) } + val wallets by viewModel.wallets.collectAsStateWithLifecycle() var selectedCategory by remember { mutableStateOf(prefs.getString(PREF_WALLET_CATEGORY_FILTER, null)) } var isWalletInfoCardVisible by remember { mutableStateOf(true) } @@ -143,7 +125,7 @@ fun AssetDashboardScreenContent( } LaunchedEffect(refreshTrigger) { - onLoadWallets() + viewModel.loadWallets() } LaunchedEffect(selectedCategory, wallets.size) { @@ -948,11 +930,9 @@ fun CreateSafeCard( @Preview @Composable fun CardPreview() { - MixinAppTheme { - Column { - CreateSafeCard {} - Spacer(modifier = Modifier.height(8.dp)) - UpgradeSafeCard({}, {}) - } + Column { + CreateSafeCard {} + Spacer(modifier = Modifier.height(8.dp)) + UpgradeSafeCard({}, {}) } }