diff --git a/example/app/build.gradle b/example/app/build.gradle index 47f8ba569..51cd5c602 100644 --- a/example/app/build.gradle +++ b/example/app/build.gradle @@ -29,7 +29,7 @@ android { applicationId "com.mindbox.example" minSdk 21 targetSdk 35 - versionCode 8 + versionCode 14 versionName "2.15.2" multiDexEnabled true @@ -46,11 +46,13 @@ android { kotlinOptions { jvmTarget = '17' } - viewBinding { - enabled = true - } buildFeatures { buildConfig = true + compose = true + } + composeOptions { + // Compose compiler compatible with Kotlin 1.9.22 + kotlinCompilerExtensionVersion = '1.5.10' } signingConfigs { debug { @@ -95,7 +97,17 @@ dependencies { implementation 'androidx.core:core-ktx:1.12.0' implementation 'androidx.appcompat:appcompat:1.6.1' implementation 'com.google.android.material:material:1.11.0' - implementation 'androidx.constraintlayout:constraintlayout:2.1.4' + + implementation platform('androidx.compose:compose-bom:2024.06.00') + implementation 'androidx.compose.ui:ui' + implementation 'androidx.compose.ui:ui-graphics' + implementation 'androidx.compose.foundation:foundation' + implementation 'androidx.compose.material3:material3' + implementation 'androidx.compose.material:material-icons-extended' + implementation 'androidx.activity:activity-compose:1.9.0' + implementation 'androidx.lifecycle:lifecycle-runtime-compose:2.8.2' + // Coil for async image loading in Compose + implementation 'io.coil-kt:coil-compose:2.6.0' // Push services implementation platform('com.google.firebase:firebase-bom:32.7.1') diff --git a/example/app/src/main/java/com/mindbox/example/ActivityTransitionByPush.kt b/example/app/src/main/java/com/mindbox/example/ActivityTransitionByPush.kt index ef8e391b6..28018787d 100644 --- a/example/app/src/main/java/com/mindbox/example/ActivityTransitionByPush.kt +++ b/example/app/src/main/java/com/mindbox/example/ActivityTransitionByPush.kt @@ -2,42 +2,52 @@ package com.mindbox.example import android.content.Intent import android.os.Bundle +import androidx.activity.compose.setContent import androidx.appcompat.app.AppCompatActivity +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue import cloud.mindbox.mobile_sdk.Mindbox -import com.mindbox.example.databinding.ActivityTrasitionByPushBinding - +import com.mindbox.example.ui.SecondActivityScreen +import com.mindbox.example.ui.theme.MindboxTheme class ActivityTransitionByPush : AppCompatActivity() { - private var _binding: ActivityTrasitionByPushBinding? = null - private val binding: ActivityTrasitionByPushBinding - get() = _binding!! + + private var pushUrl by mutableStateOf("") + private var pushPayload by mutableStateOf("") override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - _binding = ActivityTrasitionByPushBinding.inflate(layoutInflater) - setContentView(binding.root) - //Get data from push after click on push or button in push - processMindboxIntent(intent, this)?.let { (url, payload) -> - binding.tvPushUrlResultSecondActivity.text = url - binding.tvPushPayloadResultSecondActivity.text = payload + // Get data from push after click on push or button in push + applyPushIntent(intent) + + setContent { + MindboxTheme { + SecondActivityScreen( + pushUrl = pushUrl, + pushPayload = pushPayload, + triggerUrl = SECOND_ACTIVITY_PUSH_URL, + darkTheme = isSystemInDarkTheme(), + onBack = { finish() }, + ) + } } } override fun onNewIntent(intent: Intent) { super.onNewIntent(intent) - - //Get data from push after click on push or button in push - processMindboxIntent(intent, this)?.let { (url, payload) -> - binding.tvPushUrlResultSecondActivity.text = url - binding.tvPushPayloadResultSecondActivity.text = payload - } - //https://developers.mindbox.ru/docs/android-app-start-tracking + // Get data from push after click on push or button in push + applyPushIntent(intent) + // https://developers.mindbox.ru/docs/android-app-start-tracking Mindbox.onNewIntent(intent) } - override fun onDestroy() { - super.onDestroy() - _binding = null + private fun applyPushIntent(intent: Intent?) { + processMindboxIntent(intent, this)?.let { (url, payload) -> + pushUrl = url.orEmpty() + pushPayload = payload.orEmpty() + } } -} \ No newline at end of file +} diff --git a/example/app/src/main/java/com/mindbox/example/CustomInAppCallback.kt b/example/app/src/main/java/com/mindbox/example/CustomInAppCallback.kt index 6b18fd7db..abebc78a4 100644 --- a/example/app/src/main/java/com/mindbox/example/CustomInAppCallback.kt +++ b/example/app/src/main/java/com/mindbox/example/CustomInAppCallback.kt @@ -31,4 +31,4 @@ object CustomInAppCallback : InAppCallback { ) Log.d(Utils.TAG, "onInAppDismissed id=$id") } -} \ No newline at end of file +} diff --git a/example/app/src/main/java/com/mindbox/example/FragmentForNavigation.kt b/example/app/src/main/java/com/mindbox/example/FragmentForNavigation.kt deleted file mode 100644 index 618fc943f..000000000 --- a/example/app/src/main/java/com/mindbox/example/FragmentForNavigation.kt +++ /dev/null @@ -1,5 +0,0 @@ -package com.mindbox.example - -import androidx.fragment.app.Fragment - -class FragmentForNavigation : Fragment(R.layout.fragment_for_navigation) \ No newline at end of file diff --git a/example/app/src/main/java/com/mindbox/example/MainActivity.kt b/example/app/src/main/java/com/mindbox/example/MainActivity.kt index 5b8cf6716..b3126e83b 100644 --- a/example/app/src/main/java/com/mindbox/example/MainActivity.kt +++ b/example/app/src/main/java/com/mindbox/example/MainActivity.kt @@ -1,28 +1,48 @@ package com.mindbox.example import android.Manifest +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context import android.content.Intent import android.content.pm.PackageManager import android.os.Build import android.os.Bundle import android.util.Log +import androidx.activity.compose.setContent import androidx.activity.result.contract.ActivityResultContracts import androidx.annotation.RequiresApi import androidx.appcompat.app.AppCompatActivity +import androidx.compose.foundation.background +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.systemBarsPadding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import androidx.core.content.ContextCompat import cloud.mindbox.mobile_sdk.Mindbox -import com.mindbox.example.databinding.ActivityMainBinding +import com.mindbox.example.ui.MainScreen +import com.mindbox.example.ui.SdkInfoState +import com.mindbox.example.ui.theme.MindboxTheme class MainActivity : AppCompatActivity() { - private var _binding: ActivityMainBinding? = null - private val binding: ActivityMainBinding - get() = _binding!! + private var sdkInfo by mutableStateOf(SdkInfoState()) + private var showInAppSheet by mutableStateOf(false) + private var showNavFragment by mutableStateOf(false) override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - _binding = ActivityMainBinding.inflate(layoutInflater) - setContentView(binding.root) showSdkDataOnScreen() @@ -30,49 +50,69 @@ class MainActivity : AppCompatActivity() { checkAndRequestPostNotificationsPermission() } - processMindboxIntent(intent = intent, context = this)?.let { (url, payload) -> - binding.tvPushUrlResult.text = url - binding.tvPushPayloadResult.text = payload - proceedUrl(url = url) - } - - binding.btnAsyncOperation.setOnClickListener { - //https://developers.mindbox.ru/docs/android-integration-of-actions - sendAsync(type = AsyncOperationType.OPERATION_BODY_JSON, context = this) - showToast(context = this, message = "Operation was sent") - } - - binding.btnSyncOperation.setOnClickListener { - //https://developers.mindbox.ru/docs/android-integration-of-actions - sendSync(type = SyncOperationType.OPERATION_BODY_WITH_CUSTOM_RESPONSE, context = this) - showToast(context = this, message = "Sync operation was sent") - } - binding.btnOpenActivity.setOnClickListener { - val intent = Intent(this, ActivityTransitionByPush::class.java) - this.startActivity(intent) - } - - binding.btnOpenPushList.setOnClickListener { - startActivity(Intent(this, NotificationHistoryActivity::class.java)) + handlePushIntent(intent) + + setContent { + MindboxTheme { + val darkTheme = isSystemInDarkTheme() + Box(Modifier.fillMaxSize()) { + MainScreen( + state = sdkInfo, + darkTheme = darkTheme, + onCopy = ::copyToClipboard, + onShowInApp = { showInAppSheet = true }, + onSendAsync = { + // https://developers.mindbox.ru/docs/android-integration-of-actions + sendAsync(type = AsyncOperationType.OPERATION_BODY_JSON, context = this@MainActivity) + showToast(this@MainActivity, getString(R.string.toast_operation_sent)) + }, + onSendSync = { + // https://developers.mindbox.ru/docs/android-integration-of-actions + sendSync(type = SyncOperationType.OPERATION_BODY_WITH_CUSTOM_RESPONSE, context = this@MainActivity) + showToast(this@MainActivity, getString(R.string.toast_sync_operation_sent)) + }, + onOpenSecondActivity = { + startActivity(Intent(this@MainActivity, ActivityTransitionByPush::class.java)) + }, + onOpenHistory = { + startActivity(Intent(this@MainActivity, NotificationHistoryActivity::class.java)) + }, + showInAppSheet = showInAppSheet, + onDismissInAppSheet = { showInAppSheet = false }, + onPickInApp = { option -> + showInAppSheet = false + // https://developers.mindbox.ru/docs/android-integration-of-actions + // Each option triggers its in-app via an async operation. + sendAsyncOperationWithEmptyBody(this@MainActivity, option.operationSystemName) + Log.d( + "InApp", + getString(R.string.toast_inapp_shown, getString(option.titleRes)), + ) + }, + ) + + if (showNavFragment) { + NavFragmentOverlay(darkTheme = darkTheme) { showNavFragment = false } + } + } + } } } override fun onNewIntent(intent: Intent) { super.onNewIntent(intent) - processMindboxIntent(intent = intent, context = this)?.let { (url, payload) -> - binding.tvPushUrlResult.text = url - binding.tvPushPayloadResult.text = payload - proceedUrl(url = url) - } + handlePushIntent(intent) Mindbox.onNewIntent(intent) } - override fun onDestroy() { - super.onDestroy() - _binding = null + private fun handlePushIntent(intent: Intent?) { + processMindboxIntent(intent = intent, context = this)?.let { (url, payload) -> + sdkInfo = sdkInfo.copy(pushUrl = url.orEmpty(), pushPayload = payload.orEmpty()) + proceedUrl(url = url) + } } - //https://developers.mindbox.ru/docs/android-sdk-methods#updatenotificationpermissionstatus-since-281 + // https://developers.mindbox.ru/docs/android-sdk-methods#updatenotificationpermissionstatus-since-281 private val permissionLauncher = registerForActivityResult(ActivityResultContracts.RequestPermission()) { isGranted -> if (isGranted) { @@ -95,39 +135,68 @@ class MainActivity : AppCompatActivity() { } } - //navigation to fragments after click on push. Check url and open the required fragment + // navigation after click on push: check url and show the in-app fragment screen private fun proceedUrl(url: String?) { if (url == "https://gotofragment.com") { - supportFragmentManager.beginTransaction() - .replace(R.id.fragmentContainer, FragmentForNavigation()) - .addToBackStack(null) - .commit() + showNavFragment = true } } private fun showSdkDataOnScreen() { - //https://developers.mindbox.ru/docs/android-sdk-methods#subscribedeviceuuid-%D0%B8-disposedeviceuuidsubscription + // https://developers.mindbox.ru/docs/android-sdk-methods#subscribedeviceuuid-%D0%B8-disposedeviceuuidsubscription var subscriptionDeviceUuid = "" subscriptionDeviceUuid = Mindbox.subscribeDeviceUuid { deviceUUID -> - runOnUiThread { - binding.tvDeviceUUIDResult.text = deviceUUID - } + runOnUiThread { sdkInfo = sdkInfo.copy(deviceUuid = deviceUUID) } Mindbox.disposeDeviceUuidSubscription(subscriptionDeviceUuid) } - //https://developers.mindbox.ru/docs/android-sdk-methods#subscribepushtoken-%D0%B8-disposepushtokensubscription + // https://developers.mindbox.ru/docs/android-sdk-methods#subscribepushtoken-%D0%B8-disposepushtokensubscription var subscriptionPushToken = "" - subscriptionPushToken = - Mindbox.subscribePushTokens { tokens -> - runOnUiThread { - binding.tvTokenResult.text = tokens - //https://developers.mindbox.ru/docs/android-sdk-methods#getpushtokensavedate - binding.tvTokenDateResult.text = Mindbox.getPushTokensSaveDate().toString() - } - Mindbox.disposePushTokenSubscription(subscriptionPushToken) + subscriptionPushToken = Mindbox.subscribePushTokens { tokens -> + runOnUiThread { + sdkInfo = sdkInfo.copy( + token = tokens.orEmpty(), + // https://developers.mindbox.ru/docs/android-sdk-methods#getpushtokensavedate + tokenDate = Mindbox.getPushTokensSaveDate().toString(), + ) } + Mindbox.disposePushTokenSubscription(subscriptionPushToken) + } + + // https://developers.mindbox.ru/docs/android-sdk-methods#getsdkversion + sdkInfo = sdkInfo.copy(sdkVersion = Mindbox.getSdkVersion()) + } + + private fun copyToClipboard(label: String, value: String) { + if (value.isEmpty()) return + val clipboard = getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + clipboard.setPrimaryClip(ClipData.newPlainText(label, value)) + // Android 13+ shows its own copy confirmation bubble — avoid double toast + if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.S_V2) { + showToast(this, getString(R.string.toast_copied)) + } + } +} - //https://developers.mindbox.ru/docs/android-sdk-methods#getsdkversion - binding.tvSdkVersionResult.text = Mindbox.getSdkVersion() +/** Replaces the old FragmentForNavigation — shown when a push deep-links to gotofragment.com. */ +@androidx.compose.runtime.Composable +private fun NavFragmentOverlay(darkTheme: Boolean, onBack: () -> Unit) { + com.mindbox.example.ui.theme.SetStatusBarAppearance(darkIcons = !darkTheme) + androidx.activity.compose.BackHandler(onBack = onBack) + Column( + Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.surface) + .systemBarsPadding() + .padding(32.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = androidx.compose.foundation.layout.Arrangement.Center, + ) { + Text( + androidx.compose.ui.res.stringResource(R.string.nav_fragment_message), + color = MaterialTheme.colorScheme.onSurface, + fontSize = 18.sp, + textAlign = androidx.compose.ui.text.style.TextAlign.Center, + ) } } diff --git a/example/app/src/main/java/com/mindbox/example/MindboxFirebaseMessagingService.kt b/example/app/src/main/java/com/mindbox/example/MindboxFirebaseMessagingService.kt index b68e51092..a2d551905 100644 --- a/example/app/src/main/java/com/mindbox/example/MindboxFirebaseMessagingService.kt +++ b/example/app/src/main/java/com/mindbox/example/MindboxFirebaseMessagingService.kt @@ -17,7 +17,7 @@ class MindboxFirebaseMessagingService : FirebaseMessagingService() { super.onMessageReceived(message) // Listing of links and activities that should be opened by different links val activities = mapOf( - "https://newActivity.com" to ActivityTransitionByPush::class.java + SECOND_ACTIVITY_PUSH_URL to ActivityTransitionByPush::class.java ) // Default Active. It will open if a link that is not in the list is received val defaultActivity = MainActivity::class.java diff --git a/example/app/src/main/java/com/mindbox/example/MindboxHuaweiMessagingService.kt b/example/app/src/main/java/com/mindbox/example/MindboxHuaweiMessagingService.kt index 68f342ea3..712e0a568 100644 --- a/example/app/src/main/java/com/mindbox/example/MindboxHuaweiMessagingService.kt +++ b/example/app/src/main/java/com/mindbox/example/MindboxHuaweiMessagingService.kt @@ -33,7 +33,7 @@ class MindboxHuaweiMessagingService : HmsMessageService() { val pushSmallIcon = R.mipmap.ic_launcher // Listing of links and activites that should be opened by different links val activities = mapOf( - "https://newActivity.com" to ActivityTransitionByPush::class.java + SECOND_ACTIVITY_PUSH_URL to ActivityTransitionByPush::class.java ) // Default Active. It will open if a link that is not in the list is received val defaultActivity = MainActivity::class.java diff --git a/example/app/src/main/java/com/mindbox/example/MindboxRuStoreMessagingService.kt b/example/app/src/main/java/com/mindbox/example/MindboxRuStoreMessagingService.kt index 2067efacb..19d583137 100644 --- a/example/app/src/main/java/com/mindbox/example/MindboxRuStoreMessagingService.kt +++ b/example/app/src/main/java/com/mindbox/example/MindboxRuStoreMessagingService.kt @@ -20,7 +20,7 @@ class MindboxRuStoreMessagingService : RuStoreMessagingService() { super.onMessageReceived(message) // Listing of links and activities that should be opened by different links val activities = mapOf( - "https://newActivity.com" to ActivityTransitionByPush::class.java + SECOND_ACTIVITY_PUSH_URL to ActivityTransitionByPush::class.java ) // Default Active. It will open if a link that is not in the list is received val defaultActivity = MainActivity::class.java diff --git a/example/app/src/main/java/com/mindbox/example/NotificationAdapter.kt b/example/app/src/main/java/com/mindbox/example/NotificationAdapter.kt deleted file mode 100644 index def96ccaf..000000000 --- a/example/app/src/main/java/com/mindbox/example/NotificationAdapter.kt +++ /dev/null @@ -1,68 +0,0 @@ -package com.mindbox.example - -import android.view.LayoutInflater -import android.view.ViewGroup -import androidx.recyclerview.widget.AsyncListDiffer -import androidx.recyclerview.widget.DiffUtil -import androidx.recyclerview.widget.RecyclerView -import cloud.mindbox.mobile_sdk.pushes.MindboxRemoteMessage -import com.bumptech.glide.Glide -import com.mindbox.example.databinding.ItemNotificationBinding - -class NotificationAdapter(private val onItemClick:(MindboxRemoteMessage) -> Unit): RecyclerView.Adapter() { - - private val differ = AsyncListDiffer(this, MindboxRemoteMessageItemCallback()) - - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): NotificationViewHolder { - return NotificationViewHolder( - ItemNotificationBinding.inflate( - LayoutInflater.from(parent.context), - parent, - false - ) - ) - } - - override fun getItemCount(): Int { - return differ.currentList.size - } - - override fun onBindViewHolder(holder: NotificationViewHolder, position: Int) { - holder.bind(differ.currentList[position]) - } - - fun updateNotifications(newList: List) { - differ.submitList(newList) - } - inner class NotificationViewHolder(private val binding: ItemNotificationBinding) : - RecyclerView.ViewHolder(binding.root) { - - fun bind(item: MindboxRemoteMessage) { - binding.root.setOnClickListener { - onItemClick(item) - } - binding.tvTitle.text = item.title - binding.tvDescription.text = item.description - binding.tvPushLink.text = item.pushLink - binding.tvUniqueKey.text = item.uniqueKey - Glide.with(itemView).load(item.imageUrl).into(binding.ivImage) - } - } - - class MindboxRemoteMessageItemCallback : DiffUtil.ItemCallback() { - override fun areItemsTheSame( - oldItem: MindboxRemoteMessage, - newItem: MindboxRemoteMessage - ): Boolean { - return oldItem.uniqueKey == newItem.uniqueKey - } - - override fun areContentsTheSame( - oldItem: MindboxRemoteMessage, - newItem: MindboxRemoteMessage - ): Boolean { - return oldItem == newItem - } - } - -} diff --git a/example/app/src/main/java/com/mindbox/example/NotificationHistoryActivity.kt b/example/app/src/main/java/com/mindbox/example/NotificationHistoryActivity.kt index f2e9980c6..799f248d6 100644 --- a/example/app/src/main/java/com/mindbox/example/NotificationHistoryActivity.kt +++ b/example/app/src/main/java/com/mindbox/example/NotificationHistoryActivity.kt @@ -2,20 +2,23 @@ package com.mindbox.example import android.os.Bundle import android.widget.Toast +import androidx.activity.compose.setContent import androidx.appcompat.app.AppCompatActivity +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue import androidx.lifecycle.lifecycleScope -import androidx.recyclerview.widget.LinearLayoutManager import cloud.mindbox.mobile_sdk.Mindbox import com.google.gson.Gson -import com.mindbox.example.databinding.ActivityNotificationHistoryActivivityBinding +import com.mindbox.example.ui.NotificationHistoryScreen +import com.mindbox.example.ui.theme.MindboxTheme import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch class NotificationHistoryActivity : AppCompatActivity() { - private var _binding: ActivityNotificationHistoryActivivityBinding? = null - private val binding: ActivityNotificationHistoryActivivityBinding - get() = _binding!! + private var notifications by mutableStateOf(NotificationStorage.notifications) private fun getPushOpenOperationBody( pushName: String, @@ -34,45 +37,53 @@ class NotificationHistoryActivity : AppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - _binding = ActivityNotificationHistoryActivivityBinding.inflate(layoutInflater) Mindbox.executeAsyncOperation( applicationContext, "mobileapp.NCOpen", "" ) - setContentView(binding.root) - Toast.makeText(applicationContext, "Notification center opened", Toast.LENGTH_LONG).show() - binding.rvList.apply { - setHasFixedSize(true) - layoutManager = LinearLayoutManager(applicationContext) - adapter = NotificationAdapter { - /*Assuming payload of push notification has this structure: - { - "pushName":"", - "pushDate":"" - }*/ - val pushPayload = Gson().fromJson(it.payload, PushPayload::class.java) - Mindbox.executeAsyncOperation( - applicationContext, - "mobileapp.NCPushOpen", - getPushOpenOperationBody( - pushPayload.pushName, - pushPayload.pushDate - ) + Toast.makeText( + applicationContext, + getString(R.string.toast_notification_center_opened), + Toast.LENGTH_LONG + ).show() + + setContent { + MindboxTheme { + NotificationHistoryScreen( + notifications = notifications, + darkTheme = isSystemInDarkTheme(), + onBack = { finish() }, + onItemClick = ::onNotificationClick, ) - Toast.makeText( - applicationContext, - "Click on notification with unique key ${it.uniqueKey}, title = ${it.title} and description = ${it.description}", - Toast.LENGTH_LONG - ).show() } } - (binding.rvList.adapter as NotificationAdapter).updateNotifications(NotificationStorage.notifications) - //Don't listen to storage in your actual app inside activity. + + // Don't listen to storage in your actual app inside activity. lifecycleScope.launch(Dispatchers.IO) { - NotificationStorage.notificationsFlow.collect { - (binding.rvList.adapter as NotificationAdapter).updateNotifications(it) - } + NotificationStorage.notificationsFlow.collect { notifications = it } } } -} \ No newline at end of file + + private fun onNotificationClick(item: cloud.mindbox.mobile_sdk.pushes.MindboxRemoteMessage) { + /*Assuming payload of push notification has this structure: + { + "pushName":"", + "pushDate":"" + }*/ + val pushPayload = Gson().fromJson(item.payload, PushPayload::class.java) + Mindbox.executeAsyncOperation( + applicationContext, + "mobileapp.NCPushOpen", + getPushOpenOperationBody( + pushPayload.pushName, + pushPayload.pushDate + ) + ) + Toast.makeText( + applicationContext, + getString(R.string.toast_notification_click, item.uniqueKey, item.title, item.description), + Toast.LENGTH_LONG + ).show() + } +} diff --git a/example/app/src/main/java/com/mindbox/example/Operations.kt b/example/app/src/main/java/com/mindbox/example/Operations.kt index a3d9a7fb8..8732f8a2a 100644 --- a/example/app/src/main/java/com/mindbox/example/Operations.kt +++ b/example/app/src/main/java/com/mindbox/example/Operations.kt @@ -11,6 +11,16 @@ import cloud.mindbox.mobile_sdk.models.operation.request.RecommendationRequest import cloud.mindbox.mobile_sdk.models.operation.request.ViewProductRequest import cloud.mindbox.mobile_sdk.models.operation.response.OperationResponse +// https://developers.mindbox.ru/docs/android-integration-of-actions +// Fire-and-forget async operation with an empty JSON body. +fun sendAsyncOperationWithEmptyBody(context: Context, operationSystemName: String) { + Mindbox.executeAsyncOperation( + context = context, + operationSystemName = operationSystemName, + operationBodyJson = "{}" + ) +} + fun sendAsync(type: AsyncOperationType, context: Context) { when (type) { AsyncOperationType.OPERATION_BODY -> viewProductAsync(context) diff --git a/example/app/src/main/java/com/mindbox/example/Utils.kt b/example/app/src/main/java/com/mindbox/example/Utils.kt index 0ce16e2e7..5a424e548 100644 --- a/example/app/src/main/java/com/mindbox/example/Utils.kt +++ b/example/app/src/main/java/com/mindbox/example/Utils.kt @@ -11,6 +11,13 @@ import cloud.mindbox.mobile_sdk.pushes.handler.image.MindboxImageFailureHandler import cloud.mindbox.mobile_sdk.pushes.handler.image.retryOrDefaultStrategy +/** + * Push click-URL that routes to [ActivityTransitionByPush] (the "second activity"). + * Used both in the `activities` map of the messaging services and in the screen's hint, + * so the displayed value always matches the real routing. + */ +const val SECOND_ACTIVITY_PUSH_URL = "https://newActivity.com" + class Utils { companion object { const val TAG = "ExampleApp" diff --git a/example/app/src/main/java/com/mindbox/example/ui/Components.kt b/example/app/src/main/java/com/mindbox/example/ui/Components.kt new file mode 100644 index 000000000..7930217b4 --- /dev/null +++ b/example/app/src/main/java/com/mindbox/example/ui/Components.kt @@ -0,0 +1,98 @@ +package com.mindbox.example.ui + +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.spring +import androidx.compose.foundation.background +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsPressedAsState +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.AdsClick +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.scale +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp + +/** + * M3 Expressive press feedback: a quick squish on press and a springy overshoot + * back to rest on release. Mirrors the `press` handler in the source design. + * + * Pass the same [interactionSource] you give to `clickable` so the scale tracks + * the real press state. + */ +@Composable +fun Modifier.pressScale(interactionSource: MutableInteractionSource): Modifier { + val pressed by interactionSource.collectIsPressedAsState() + val scale by animateFloatAsState( + targetValue = if (pressed) 0.945f else 1f, + animationSpec = spring( + dampingRatio = if (pressed) Spring.DampingRatioNoBouncy else 0.45f, + stiffness = Spring.StiffnessMediumLow, + ), + label = "pressScale", + ) + return this.scale(scale) +} + +@Composable +fun rememberPressSource(): MutableInteractionSource = remember { MutableInteractionSource() } + +/** Uppercase, wide-tracked section label ("SDK Info", "Actions"). */ +@Composable +fun SectionLabel(text: String, modifier: Modifier = Modifier) { + Text( + text = text.uppercase(), + color = MaterialTheme.colorScheme.onSurfaceVariant, + fontSize = 12.sp, + fontWeight = FontWeight.Bold, + letterSpacing = 1.sp, + modifier = modifier, + ) +} + +/** + * Empty-state chip shown when Url/Payload from push are not filled yet — + * a pill with the `ads_click` icon and a hint to open from a push. + */ +@Composable +fun EmptyFromPushChip( + text: String, + modifier: Modifier = Modifier, +) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + modifier = modifier + .background( + color = MaterialTheme.colorScheme.surfaceContainerHigh, + shape = RoundedCornerShape(999.dp), + ) + .padding(start = 8.dp, end = 11.dp, top = 5.dp, bottom = 5.dp), + ) { + Icon( + imageVector = Icons.Filled.AdsClick, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(15.dp), + ) + Text( + text = text, + color = MaterialTheme.colorScheme.onSurfaceVariant, + fontSize = 12.sp, + fontWeight = FontWeight.Medium, + ) + } +} diff --git a/example/app/src/main/java/com/mindbox/example/ui/MainScreen.kt b/example/app/src/main/java/com/mindbox/example/ui/MainScreen.kt new file mode 100644 index 000000000..991add09a --- /dev/null +++ b/example/app/src/main/java/com/mindbox/example/ui/MainScreen.kt @@ -0,0 +1,622 @@ +package com.mindbox.example.ui + +import androidx.annotation.StringRes +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.animateFloatAsState +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.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.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.OpenInNew +import androidx.compose.material.icons.automirrored.filled.Send +import androidx.compose.material.icons.filled.Campaign +import androidx.compose.material.icons.filled.ChevronRight +import androidx.compose.material.icons.filled.ContentCopy +import androidx.compose.material.icons.filled.ExpandMore +import androidx.compose.material.icons.filled.Info +import androidx.compose.material.icons.filled.Notifications +import androidx.compose.material.icons.filled.Sync +import androidx.compose.material.icons.filled.Visibility +import androidx.compose.material.icons.filled.VisibilityOff +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +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.draw.rotate +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.stringResource +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.unit.dp +import androidx.compose.ui.unit.sp +import com.mindbox.example.R +import com.mindbox.example.ui.theme.SetStatusBarAppearance + +/** Plain data carrier for everything the SDK Info card displays. */ +data class SdkInfoState( + val deviceUuid: String = "", + val token: String = "", + val tokenDate: String = "", + val pushUrl: String = "", + val pushPayload: String = "", + val sdkVersion: String = "", +) + +/** In-app options offered by the picker bottom sheet. Each entry carries the + * Mindbox operation system name that triggers the corresponding in-app when + * tapped, plus a stable id so callers don't compare localized display titles. */ +enum class InAppOption( + @StringRes val titleRes: Int, + val operationSystemName: String, +) { + WheelOfFortune(R.string.inapp_wheel_of_fortune, "showInAppWheelOfFortune"), + LuckyFeed(R.string.inapp_lucky_feed, "showInAppLuckyFeed"), + ScratchCard(R.string.inapp_scratch_card, "showInAppScratchCard"), + MysteryGift(R.string.inapp_mystery_gift, "showInAppMysteryGift"), + CustomHtml(R.string.inapp_custom_html, "showInAppCustomHtml"), + Onboarding(R.string.inapp_onboarding, "showInAppOnboarding"), + Modal(R.string.inapp_modal, "showInAppModal"), + Snackbar(R.string.inapp_snackbar, "showInAppSnackbar"), +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun MainScreen( + state: SdkInfoState, + darkTheme: Boolean, + onCopy: (label: String, value: String) -> Unit, + onShowInApp: () -> Unit, + onSendAsync: () -> Unit, + onSendSync: () -> Unit, + onOpenSecondActivity: () -> Unit, + onOpenHistory: () -> Unit, + showInAppSheet: Boolean, + onDismissInAppSheet: () -> Unit, + onPickInApp: (InAppOption) -> Unit, +) { + SetStatusBarAppearance(darkIcons = darkTheme) + + Column( + Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.background), + ) { + HeroHeader(sdkVersion = state.sdkVersion) + + Column( + Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(18.dp), + ) { + SdkInfoCard(state = state, onCopy = onCopy) + + Spacer(Modifier.height(24.dp)) + SectionLabel(stringResource(R.string.section_actions), Modifier.padding(start = 6.dp, bottom = 12.dp)) + + ShowInAppButton(onClick = onShowInApp) + Spacer(Modifier.height(12.dp)) + ActionsGroup( + onSendAsync = onSendAsync, + onSendSync = onSendSync, + onOpenSecondActivity = onOpenSecondActivity, + onOpenHistory = onOpenHistory, + ) + Spacer(Modifier.height(24.dp)) + } + } + + if (showInAppSheet) { + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + ModalBottomSheet( + onDismissRequest = onDismissInAppSheet, + sheetState = sheetState, + containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, + ) { + InAppPickerContent(onPick = onPickInApp, onClose = onDismissInAppSheet) + } + } +} + +@Composable +private fun HeroHeader(sdkVersion: String) { + Column( + Modifier + .fillMaxWidth() + .background( + color = MaterialTheme.colorScheme.primary, + shape = RoundedCornerShape(bottomStart = 30.dp, bottomEnd = 30.dp), + ) + .statusBarsPadding() + .padding(start = 22.dp, end = 22.dp, top = 14.dp, bottom = 22.dp), + ) { + Row( + Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.Top, + ) { + Column { + Text( + text = stringResource(R.string.brand_name), + color = MaterialTheme.colorScheme.onPrimary, + fontSize = 27.sp, + fontWeight = FontWeight.Bold, + letterSpacing = (-1).sp, + ) + Text( + text = stringResource(R.string.hero_subtitle), + color = MaterialTheme.colorScheme.onPrimary.copy(alpha = 0.82f), + fontSize = 13.sp, + fontWeight = FontWeight.Medium, + modifier = Modifier.padding(top = 4.dp), + ) + } + Box( + Modifier + .background( + color = MaterialTheme.colorScheme.onPrimary.copy(alpha = 0.18f), + shape = RoundedCornerShape(999.dp), + ) + .padding(horizontal = 13.dp, vertical = 6.dp), + ) { + Text( + text = stringResource(R.string.version_format, sdkVersion), + color = MaterialTheme.colorScheme.onPrimary, + fontSize = 12.5f.sp, + fontWeight = FontWeight.SemiBold, + ) + } + } + } +} + +private const val SECRET_MASK = "••••••••••••••••••••" + +/** How many leading characters of the device UUID stay visible on screen. */ +private const val UUID_VISIBLE_PREFIX = 9 + +/** + * Masks the device UUID for display: keeps the first [UUID_VISIBLE_PREFIX] + * characters and replaces the remainder with "****". Per legal guidance the full + * UUID must not be readable or copyable from the example app's main screen. + */ +private fun maskUuid(value: String): String = + if (value.length <= UUID_VISIBLE_PREFIX) value else value.take(UUID_VISIBLE_PREFIX) + "****-****-****-************" + +@Composable +private fun SdkInfoCard(state: SdkInfoState, onCopy: (String, String) -> Unit) { + Column( + Modifier + .fillMaxWidth() + .background( + color = MaterialTheme.colorScheme.surfaceContainerHigh, + shape = RoundedCornerShape(28.dp), + ) + .padding(horizontal = 18.dp, vertical = 14.dp), + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + Icons.Filled.Info, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(19.dp), + ) + Spacer(Modifier.width(9.dp)) + SectionLabel(stringResource(R.string.section_sdk_info)) + } + + Spacer(Modifier.height(10.dp)) + UuidField( + label = stringResource(R.string.label_device_uuid), + value = state.deviceUuid, + ) + + Spacer(Modifier.height(4.dp)) + ServiceDataSection(state = state, onCopy = onCopy) + } +} + +/** Device UUID: label + masked value in a single-line monospace box. Per legal + * guidance the full UUID must not be copyable or fully visible here, so there is + * no copy button and only the first [UUID_VISIBLE_PREFIX] characters are shown. */ +@Composable +private fun UuidField(label: String, value: String) { + Text(label, color = MaterialTheme.colorScheme.onSurfaceVariant, fontSize = 13.sp) + Spacer(Modifier.height(6.dp)) + Box( + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(13.dp)) + .background(MaterialTheme.colorScheme.surfaceContainer), + ) { + Text( + text = if (value.isEmpty()) stringResource(R.string.value_placeholder) else maskUuid(value), + color = MaterialTheme.colorScheme.onSurface, + fontFamily = FontFamily.Monospace, + fontSize = 13.sp, + maxLines = 1, + softWrap = false, + modifier = Modifier.padding(horizontal = 13.dp, vertical = 10.dp), + ) + } +} + +/** Collapsible "Service data" section: Token (masked + reveal), Token date, + * Url from push, Payload. */ +@Composable +private fun ServiceDataSection(state: SdkInfoState, onCopy: (String, String) -> Unit) { + var open by rememberSaveable { mutableStateOf(false) } + val rotation by animateFloatAsState(if (open) 180f else 0f, label = "serviceChevron") + + Row( + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(12.dp)) + .clickable { open = !open } + .padding(vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + stringResource(R.string.section_service_data), + color = MaterialTheme.colorScheme.onSurfaceVariant, + fontSize = 13.sp, + fontWeight = FontWeight.SemiBold, + modifier = Modifier.weight(1f), + ) + Icon( + Icons.Filled.ExpandMore, + contentDescription = stringResource(R.string.toggle_service_data), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.rotate(rotation), + ) + } + + AnimatedVisibility(visible = open) { + Column { + val tokenLabel = stringResource(R.string.label_token) + TokenRow( + label = tokenLabel, + value = state.token, + onCopy = { onCopy(tokenLabel, state.token) }, + ) + Spacer(Modifier.height(9.dp)) + InlineValueRow(label = stringResource(R.string.label_token_date), value = state.tokenDate) + Spacer(Modifier.height(9.dp)) + FromPushRow(label = stringResource(R.string.label_url_from_push), value = state.pushUrl) + Spacer(Modifier.height(9.dp)) + FromPushRow(label = stringResource(R.string.label_payload), value = state.pushPayload) + Spacer(Modifier.height(4.dp)) + } + } +} + +/** Token row: label + masked/revealed value + eye toggle + copy. */ +@Composable +private fun TokenRow(label: String, value: String, onCopy: () -> Unit) { + var revealed by rememberSaveable { mutableStateOf(false) } + Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + Text( + label, + color = MaterialTheme.colorScheme.onSurfaceVariant, + fontSize = 13.sp, + modifier = Modifier.width(78.dp), + ) + Text( + text = if (revealed) value.ifEmpty { stringResource(R.string.value_placeholder) } else SECRET_MASK, + color = MaterialTheme.colorScheme.onSurface, + fontFamily = FontFamily.Monospace, + fontSize = 12.5f.sp, + maxLines = 1, + softWrap = false, + overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + IconCircleButton( + icon = if (revealed) Icons.Filled.VisibilityOff else Icons.Filled.Visibility, + contentDescription = stringResource(if (revealed) R.string.hide_token else R.string.reveal_token), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + onClick = { revealed = !revealed }, + ) + IconCircleButton( + icon = Icons.Filled.ContentCopy, + contentDescription = stringResource(R.string.copy_content_description, label), + tint = MaterialTheme.colorScheme.primary, + onClick = onCopy, + ) + } +} + +@Composable +private fun IconCircleButton( + icon: ImageVector, + contentDescription: String, + tint: androidx.compose.ui.graphics.Color, + onClick: () -> Unit, +) { + Box( + Modifier + .size(32.dp) + .clip(androidx.compose.foundation.shape.CircleShape) + .clickable(onClick = onClick), + contentAlignment = Alignment.Center, + ) { + Icon(icon, contentDescription = contentDescription, tint = tint, modifier = Modifier.size(17.dp)) + } +} + +@Composable +private fun InlineValueRow(label: String, value: String) { + Row( + Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text(label, color = MaterialTheme.colorScheme.onSurfaceVariant, fontSize = 14.sp) + Text( + text = value.ifEmpty { stringResource(R.string.value_placeholder) }, + color = MaterialTheme.colorScheme.onSurface, + fontFamily = FontFamily.Monospace, + fontSize = 12.5f.sp, + modifier = Modifier + .background( + color = MaterialTheme.colorScheme.surfaceContainer, + shape = RoundedCornerShape(11.dp), + ) + .padding(horizontal = 11.dp, vertical = 7.dp), + ) + } +} + +@Composable +private fun FromPushRow(label: String, value: String) { + Row( + Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text(label, color = MaterialTheme.colorScheme.onSurfaceVariant, fontSize = 14.sp) + if (value.isEmpty()) { + EmptyFromPushChip(stringResource(R.string.chip_filled_from_push)) + } else { + Text( + text = value, + color = MaterialTheme.colorScheme.onSurface, + fontFamily = FontFamily.Monospace, + fontSize = 12.5f.sp, + textAlign = TextAlign.End, + modifier = Modifier.weight(1f, fill = false).padding(start = 12.dp), + ) + } + } +} + +@Composable +private fun ShowInAppButton(onClick: () -> Unit) { + val source = rememberPressSource() + Row( + Modifier + .fillMaxWidth() + .height(64.dp) + .pressScale(source) + .background( + color = MaterialTheme.colorScheme.primary, + shape = RoundedCornerShape(22.dp), + ) + .clickable(interactionSource = source, indication = null, onClick = onClick), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + Icons.Filled.Campaign, + contentDescription = null, + tint = MaterialTheme.colorScheme.onPrimary, + modifier = Modifier.size(23.dp), + ) + Spacer(Modifier.width(11.dp)) + Text( + stringResource(R.string.action_show_inapp), + color = MaterialTheme.colorScheme.onPrimary, + fontSize = 16.sp, + fontWeight = FontWeight.Bold, + ) + } +} + +@Composable +private fun ActionsGroup( + onSendAsync: () -> Unit, + onSendSync: () -> Unit, + onOpenSecondActivity: () -> Unit, + onOpenHistory: () -> Unit, +) { + val big = 22.dp + val small = 7.dp + Column(verticalArrangement = Arrangement.spacedBy(3.dp)) { + ActionRow( + icon = Icons.AutoMirrored.Filled.Send, + label = stringResource(R.string.action_send_async), + shape = RoundedCornerShape(topStart = big, topEnd = big, bottomStart = small, bottomEnd = small), + onClick = onSendAsync, + ) + ActionRow( + icon = Icons.Filled.Sync, + label = stringResource(R.string.action_send_sync), + shape = RoundedCornerShape(small), + onClick = onSendSync, + ) + ActionRow( + icon = Icons.AutoMirrored.Filled.OpenInNew, + label = stringResource(R.string.action_open_second_activity), + shape = RoundedCornerShape(small), + onClick = onOpenSecondActivity, + ) + ActionRow( + icon = Icons.Filled.Notifications, + label = stringResource(R.string.action_open_notification_history), + shape = RoundedCornerShape(topStart = small, topEnd = small, bottomStart = big, bottomEnd = big), + onClick = onOpenHistory, + ) + } +} + +@Composable +private fun ActionRow( + icon: ImageVector, + label: String, + shape: RoundedCornerShape, + onClick: () -> Unit, +) { + val source = rememberPressSource() + Row( + Modifier + .fillMaxWidth() + .pressScale(source) + .background(color = MaterialTheme.colorScheme.secondaryContainer, shape = shape) + .clickable(interactionSource = source, indication = null, onClick = onClick) + .padding(horizontal = 18.dp, vertical = 17.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + icon, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSecondaryContainer, + modifier = Modifier.size(21.dp), + ) + Spacer(Modifier.width(14.dp)) + Text( + label, + color = MaterialTheme.colorScheme.onSecondaryContainer, + fontSize = 14.5f.sp, + fontWeight = FontWeight.SemiBold, + modifier = Modifier.weight(1f), + ) + Icon( + Icons.Filled.ChevronRight, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSecondaryContainer.copy(alpha = 0.6f), + modifier = Modifier.size(20.dp), + ) + } +} + +@Composable +private fun InAppPickerContent(onPick: (InAppOption) -> Unit, onClose: () -> Unit) { + Column( + Modifier + .fillMaxWidth() + .padding(start = 22.dp, end = 22.dp, bottom = 30.dp), + ) { + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) { + CloseButton(onClick = onClose) + } + + Spacer(Modifier.height(6.dp)) + Text( + text = stringResource(R.string.inapp_picker_title), + color = MaterialTheme.colorScheme.onSurface, + fontSize = 30.sp, + fontWeight = FontWeight.Bold, + letterSpacing = (-1).sp, + ) + Spacer(Modifier.height(8.dp)) + Text( + text = stringResource(R.string.inapp_picker_subtitle), + color = MaterialTheme.colorScheme.onSurfaceVariant, + fontSize = 15.sp, + lineHeight = 21.sp, + ) + + Spacer(Modifier.height(20.dp)) + Column( + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(18.dp)) + .background(MaterialTheme.colorScheme.surfaceContainerLowest), + ) { + InAppOption.entries.forEachIndexed { index, option -> + if (index > 0) { + HorizontalDivider( + thickness = 0.5.dp, + color = MaterialTheme.colorScheme.outlineVariant, + modifier = Modifier.padding(start = 18.dp), + ) + } + InAppRow( + title = stringResource(option.titleRes), + onClick = { onPick(option) }, + ) + } + } + } +} + +/** A single tappable row in the in-app picker list: title on the left, chevron + * on the right, matching the grouped-list style of the reference design. */ +@Composable +private fun InAppRow(title: String, onClick: () -> Unit) { + Row( + Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(horizontal = 18.dp, vertical = 17.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + title, + color = MaterialTheme.colorScheme.onSurface, + fontSize = 16.sp, + modifier = Modifier.weight(1f), + ) + Icon( + Icons.Filled.ChevronRight, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f), + modifier = Modifier.size(20.dp), + ) + } +} + +/** Pill "Close" button shown at the top-right of the picker sheet. */ +@Composable +private fun CloseButton(onClick: () -> Unit) { + Box( + Modifier + .clip(RoundedCornerShape(999.dp)) + .background(MaterialTheme.colorScheme.surfaceContainerLowest) + .clickable(onClick = onClick) + .padding(horizontal = 20.dp, vertical = 10.dp), + ) { + Text( + stringResource(R.string.inapp_picker_close), + color = MaterialTheme.colorScheme.primary, + fontSize = 16.sp, + fontWeight = FontWeight.SemiBold, + ) + } +} diff --git a/example/app/src/main/java/com/mindbox/example/ui/NotificationHistoryScreen.kt b/example/app/src/main/java/com/mindbox/example/ui/NotificationHistoryScreen.kt new file mode 100644 index 000000000..450401724 --- /dev/null +++ b/example/app/src/main/java/com/mindbox/example/ui/NotificationHistoryScreen.kt @@ -0,0 +1,265 @@ +package com.mindbox.example.ui + +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.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.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Info +import androidx.compose.material.icons.filled.Link +import androidx.compose.material.icons.filled.NotificationsNone +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import cloud.mindbox.mobile_sdk.pushes.MindboxRemoteMessage +import coil.compose.AsyncImage +import com.mindbox.example.R +import com.mindbox.example.ui.theme.SetStatusBarAppearance + +@Composable +fun NotificationHistoryScreen( + notifications: List, + darkTheme: Boolean, + onBack: () -> Unit, + onItemClick: (MindboxRemoteMessage) -> Unit, +) { + SetStatusBarAppearance(darkIcons = !darkTheme) + + Column( + Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.surfaceContainerLow), + ) { + Row( + Modifier + .fillMaxWidth() + .statusBarsPadding() + .padding(start = 12.dp, end = 12.dp, top = 6.dp, bottom = 14.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Box( + Modifier + .size(42.dp) + .clip(CircleShape) + .clickable(onClick = onBack), + contentAlignment = Alignment.Center, + ) { + Icon( + Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = stringResource(R.string.back), + tint = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.size(22.dp), + ) + } + Spacer(Modifier.width(6.dp)) + Text( + stringResource(R.string.notification_history_title), + color = MaterialTheme.colorScheme.onSurface, + fontSize = 21.sp, + fontWeight = FontWeight.SemiBold, + letterSpacing = (-0.6).sp, + ) + } + + HintBanner( + Modifier.padding(start = 16.dp, end = 16.dp, bottom = 12.dp), + ) + + if (notifications.isEmpty()) { + EmptyHistory() + } else { + LazyColumn( + Modifier.fillMaxSize(), + contentPadding = PaddingValues(start = 16.dp, end = 16.dp, top = 4.dp, bottom = 22.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + items(notifications, key = { it.uniqueKey }) { item -> + NotificationCard(item = item, onClick = { onItemClick(item) }) + } + } + } + } +} + +@Composable +private fun NotificationCard(item: MindboxRemoteMessage, onClick: () -> Unit) { + val source = rememberPressSource() + Column( + Modifier + .fillMaxWidth() + .pressScale(source) + .clip(RoundedCornerShape(26.dp)) + .background(MaterialTheme.colorScheme.surface) + .clickable(interactionSource = source, indication = null, onClick = onClick), + ) { + Column(Modifier.padding(start = 18.dp, end = 18.dp, top = 16.dp, bottom = 12.dp)) { + Text( + text = item.uniqueKey, + color = MaterialTheme.colorScheme.onTertiaryContainer, + fontSize = 11.sp, + fontWeight = FontWeight.SemiBold, + modifier = Modifier + .background(MaterialTheme.colorScheme.tertiaryContainer, RoundedCornerShape(999.dp)) + .padding(horizontal = 10.dp, vertical = 3.dp), + ) + item.title.takeIf { it.isNotEmpty() }?.let { + Text( + it, + color = MaterialTheme.colorScheme.onSurface, + fontSize = 18.sp, + fontWeight = FontWeight.Bold, + letterSpacing = (-0.4).sp, + modifier = Modifier.padding(top = 9.dp), + ) + } + item.description.takeIf { it.isNotEmpty() }?.let { + Text( + it, + color = MaterialTheme.colorScheme.onSurfaceVariant, + fontSize = 13.5f.sp, + modifier = Modifier.padding(top = 2.dp), + ) + } + } + + item.imageUrl?.takeIf { it.isNotEmpty() }?.let { url -> + AsyncImage( + model = url, + contentDescription = null, + contentScale = ContentScale.FillWidth, + modifier = Modifier + .padding(horizontal = 18.dp) + .fillMaxWidth() + .clip(RoundedCornerShape(18.dp)) + .background(MaterialTheme.colorScheme.surfaceContainer), + ) + } + + item.pushLink?.takeIf { it.isNotEmpty() }?.let { link -> + Row( + Modifier.padding(start = 18.dp, end = 18.dp, top = 13.dp, bottom = 15.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + Icons.Filled.Link, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(18.dp), + ) + Spacer(Modifier.width(7.dp)) + Text( + text = link, + color = MaterialTheme.colorScheme.primary, + fontSize = 14.sp, + fontWeight = FontWeight.SemiBold, + ) + } + } + } +} + +@Composable +private fun HintBanner(modifier: Modifier = Modifier) { + Column( + modifier + .fillMaxWidth() + .background(MaterialTheme.colorScheme.surfaceContainerHigh, RoundedCornerShape(22.dp)) + .padding(16.dp), + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + Icons.Filled.Info, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(18.dp), + ) + Spacer(Modifier.width(8.dp)) + Text( + stringResource(R.string.notification_history_hint_title), + color = MaterialTheme.colorScheme.onSurface, + fontSize = 14.sp, + fontWeight = FontWeight.Bold, + ) + } + Spacer(Modifier.height(8.dp)) + Text( + stringResource(R.string.notification_history_hint), + color = MaterialTheme.colorScheme.onSurfaceVariant, + fontSize = 13.sp, + lineHeight = 18.sp, + ) + Spacer(Modifier.height(10.dp)) + Text( + stringResource(R.string.notification_history_payload_example), + color = MaterialTheme.colorScheme.primary, + fontFamily = FontFamily.Monospace, + fontSize = 12.5f.sp, + modifier = Modifier + .fillMaxWidth() + .background(MaterialTheme.colorScheme.surfaceContainer, RoundedCornerShape(12.dp)) + .padding(horizontal = 12.dp, vertical = 10.dp), + ) + } +} + +@Composable +private fun EmptyHistory() { + Column( + Modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Box( + Modifier + .size(96.dp) + .background(MaterialTheme.colorScheme.surfaceContainerHigh, RoundedCornerShape(32.dp)), + contentAlignment = Alignment.Center, + ) { + Icon( + Icons.Filled.NotificationsNone, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(44.dp), + ) + } + Spacer(Modifier.height(18.dp)) + Text( + stringResource(R.string.notifications_empty_title), + color = MaterialTheme.colorScheme.onSurface, + fontSize = 18.sp, + fontWeight = FontWeight.Bold, + ) + Text( + stringResource(R.string.notifications_empty_subtitle), + color = MaterialTheme.colorScheme.onSurfaceVariant, + fontSize = 14.sp, + modifier = Modifier.padding(top = 6.dp), + ) + } +} diff --git a/example/app/src/main/java/com/mindbox/example/ui/SecondActivityScreen.kt b/example/app/src/main/java/com/mindbox/example/ui/SecondActivityScreen.kt new file mode 100644 index 000000000..844b19b6c --- /dev/null +++ b/example/app/src/main/java/com/mindbox/example/ui/SecondActivityScreen.kt @@ -0,0 +1,205 @@ +package com.mindbox.example.ui + +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.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.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.layout.systemBarsPadding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Link +import androidx.compose.material.icons.filled.TouchApp +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.stringResource +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.unit.dp +import androidx.compose.ui.unit.sp +import com.mindbox.example.R +import com.mindbox.example.ui.theme.SetStatusBarAppearance + +@Composable +fun SecondActivityScreen( + pushUrl: String, + pushPayload: String, + triggerUrl: String, + darkTheme: Boolean, + onBack: () -> Unit, +) { + SetStatusBarAppearance(darkIcons = !darkTheme) + + Column( + Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.surface) + .systemBarsPadding() + .padding(horizontal = 18.dp), + ) { + // App bar + Row( + Modifier + .fillMaxWidth() + .statusBarsPadding() + .padding(vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + BackButton(onBack) + Spacer(Modifier.width(6.dp)) + Text( + stringResource(R.string.second_activity_title), + color = MaterialTheme.colorScheme.onSurface, + fontSize = 21.sp, + fontWeight = FontWeight.SemiBold, + letterSpacing = (-0.6).sp, + ) + } + + // Centered hero + Column( + Modifier + .weight(1f) + .fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Box( + Modifier + .size(96.dp) + .background(MaterialTheme.colorScheme.primaryContainer, RoundedCornerShape(32.dp)), + contentAlignment = Alignment.Center, + ) { + Icon( + Icons.Filled.TouchApp, + contentDescription = null, + tint = MaterialTheme.colorScheme.onPrimaryContainer, + modifier = Modifier.size(46.dp), + ) + } + Spacer(Modifier.height(18.dp)) + Text( + stringResource(R.string.second_activity_hero_title), + color = MaterialTheme.colorScheme.onSurface, + fontSize = 22.sp, + fontWeight = FontWeight.Bold, + letterSpacing = (-0.6).sp, + ) + Text( + stringResource(R.string.second_activity_subtitle), + color = MaterialTheme.colorScheme.onSurfaceVariant, + fontSize = 14.sp, + textAlign = TextAlign.Center, + modifier = Modifier.padding(top = 6.dp, start = 24.dp, end = 24.dp), + ) + TriggerUrlHint(triggerUrl) + } + + // Info card + Column( + Modifier + .fillMaxWidth() + .padding(bottom = 22.dp) + .background(MaterialTheme.colorScheme.surfaceContainer, RoundedCornerShape(24.dp)), + ) { + PushField(label = stringResource(R.string.label_url_from_push), value = pushUrl) + HorizontalDivider( + color = MaterialTheme.colorScheme.outlineVariant, + modifier = Modifier.padding(horizontal = 18.dp), + ) + PushField(label = stringResource(R.string.label_payload), value = pushPayload) + } + } +} + +@Composable +private fun TriggerUrlHint(triggerUrl: String) { + Column( + Modifier.padding(top = 20.dp, start = 24.dp, end = 24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + stringResource(R.string.second_activity_trigger_hint), + color = MaterialTheme.colorScheme.onSurfaceVariant, + fontSize = 12.sp, + textAlign = TextAlign.Center, + ) + Spacer(Modifier.height(8.dp)) + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .background(MaterialTheme.colorScheme.surfaceContainerHigh, RoundedCornerShape(999.dp)) + .padding(start = 11.dp, end = 14.dp, top = 7.dp, bottom = 7.dp), + ) { + Icon( + Icons.Filled.Link, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(16.dp), + ) + Spacer(Modifier.width(7.dp)) + Text( + triggerUrl, + color = MaterialTheme.colorScheme.primary, + fontFamily = FontFamily.Monospace, + fontSize = 13.sp, + fontWeight = FontWeight.Medium, + ) + } + } +} + +@Composable +private fun BackButton(onBack: () -> Unit) { + Box( + Modifier + .size(42.dp) + .clip(CircleShape) + .clickable(onClick = onBack), + contentAlignment = Alignment.Center, + ) { + Icon( + Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = stringResource(R.string.back), + tint = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.size(22.dp), + ) + } +} + +@Composable +private fun PushField(label: String, value: String) { + Column(Modifier.padding(horizontal = 18.dp, vertical = 16.dp)) { + SectionLabel(label) + Spacer(Modifier.height(8.dp)) + if (value.isEmpty()) { + EmptyFromPushChip(stringResource(R.string.chip_open_from_push)) + } else { + Text( + value, + color = MaterialTheme.colorScheme.onSurface, + fontFamily = FontFamily.Monospace, + fontSize = 13.sp, + lineHeight = 19.sp, + ) + } + } +} diff --git a/example/app/src/main/java/com/mindbox/example/ui/theme/Color.kt b/example/app/src/main/java/com/mindbox/example/ui/theme/Color.kt new file mode 100644 index 000000000..c7b6a1238 --- /dev/null +++ b/example/app/src/main/java/com/mindbox/example/ui/theme/Color.kt @@ -0,0 +1,57 @@ +package com.mindbox.example.ui.theme + +import androidx.compose.ui.graphics.Color + +/** + * Mindbox brand-book tokens (mindbox.ru/visual-style), as used in the + * "Mindbox Material3" design (Variant B · Expressive Hero). + * + * Green #39AA5D · Dark green #268644 · Light green #56D67F (In-App product color) + * Text #292B32 / #757987 · cool blue-gray neutrals. + */ + +// ---- Light ---- +val LightPrimary = Color(0xFF39AA5D) +val LightOnPrimary = Color(0xFFFFFFFF) +val LightPrimaryContainer = Color(0xFFC6EFD2) +val LightOnPrimaryContainer = Color(0xFF063A1D) +val LightSecondaryContainer = Color(0xFFDBE9DF) +val LightOnSecondaryContainer = Color(0xFF1C2E22) +val LightTertiaryContainer = Color(0xFFD3F3DE) +val LightOnTertiaryContainer = Color(0xFF0C4424) +val LightBackground = Color(0xFFE8EBEE) +val LightSurface = Color(0xFFF7F9FB) +val LightSurfaceDim = Color(0xFFD2D8DE) +val LightSurfaceContainerLow = Color(0xFFF0F3F6) +val LightSurfaceContainer = Color(0xFFE8EBEE) +val LightSurfaceContainerHigh = Color(0xFFDFE3E8) +val LightSurfaceContainerHighest = Color(0xFFD2D8DE) +val LightOnSurface = Color(0xFF292B32) +val LightOnSurfaceVariant = Color(0xFF757987) +val LightOutline = Color(0xFF8C9AAC) +val LightOutlineVariant = Color(0xFFD2D8DE) +val LightInverseSurface = Color(0xFF282A30) +val LightInverseOnSurface = Color(0xFFF0F3F6) + +// ---- Dark ---- +val DarkPrimary = Color(0xFF56D67F) +val DarkOnPrimary = Color(0xFF06351A) +val DarkPrimaryContainer = Color(0xFF268644) +val DarkOnPrimaryContainer = Color(0xFFD3F3DE) +val DarkSecondaryContainer = Color(0xFF2F3A33) +val DarkOnSecondaryContainer = Color(0xFFCFE6D6) +val DarkTertiaryContainer = Color(0xFF1F4A30) +val DarkOnTertiaryContainer = Color(0xFFBDECCC) +val DarkBackground = Color(0xFF0C0D10) +val DarkSurface = Color(0xFF1E1F24) +val DarkSurfaceDim = Color(0xFF15161B) +val DarkSurfaceContainerLow = Color(0xFF1E1F24) +val DarkSurfaceContainer = Color(0xFF212329) +val DarkSurfaceContainerHigh = Color(0xFF282A30) +val DarkSurfaceContainerHighest = Color(0xFF36373F) +val DarkOnSurface = Color(0xFFF0F3F6) +val DarkOnSurfaceVariant = Color(0xFFB4BECA) +val DarkOutline = Color(0xFF8C9AAC) +val DarkOutlineVariant = Color(0xFF36373F) +val DarkInverseSurface = Color(0xFFF0F3F6) +val DarkInverseOnSurface = Color(0xFF282A30) diff --git a/example/app/src/main/java/com/mindbox/example/ui/theme/Theme.kt b/example/app/src/main/java/com/mindbox/example/ui/theme/Theme.kt new file mode 100644 index 000000000..418c42b05 --- /dev/null +++ b/example/app/src/main/java/com/mindbox/example/ui/theme/Theme.kt @@ -0,0 +1,128 @@ +package com.mindbox.example.ui.theme + +import android.app.Activity +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Typography +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.SideEffect +import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.unit.sp +import androidx.core.view.WindowCompat + +private val LightColors = lightColorScheme( + primary = LightPrimary, + onPrimary = LightOnPrimary, + primaryContainer = LightPrimaryContainer, + onPrimaryContainer = LightOnPrimaryContainer, + secondary = LightPrimary, + onSecondary = LightOnPrimary, + secondaryContainer = LightSecondaryContainer, + onSecondaryContainer = LightOnSecondaryContainer, + tertiary = LightPrimary, + onTertiary = LightOnPrimary, + tertiaryContainer = LightTertiaryContainer, + onTertiaryContainer = LightOnTertiaryContainer, + background = LightBackground, + onBackground = LightOnSurface, + surface = LightSurface, + onSurface = LightOnSurface, + surfaceVariant = LightSurfaceContainerHigh, + onSurfaceVariant = LightOnSurfaceVariant, + surfaceDim = LightSurfaceDim, + surfaceContainerLowest = LightSurface, + surfaceContainerLow = LightSurfaceContainerLow, + surfaceContainer = LightSurfaceContainer, + surfaceContainerHigh = LightSurfaceContainerHigh, + surfaceContainerHighest = LightSurfaceContainerHighest, + outline = LightOutline, + outlineVariant = LightOutlineVariant, + inverseSurface = LightInverseSurface, + inverseOnSurface = LightInverseOnSurface, +) + +private val DarkColors = darkColorScheme( + primary = DarkPrimary, + onPrimary = DarkOnPrimary, + primaryContainer = DarkPrimaryContainer, + onPrimaryContainer = DarkOnPrimaryContainer, + secondary = DarkPrimary, + onSecondary = DarkOnPrimary, + secondaryContainer = DarkSecondaryContainer, + onSecondaryContainer = DarkOnSecondaryContainer, + tertiary = DarkPrimary, + onTertiary = DarkOnPrimary, + tertiaryContainer = DarkTertiaryContainer, + onTertiaryContainer = DarkOnTertiaryContainer, + background = DarkBackground, + onBackground = DarkOnSurface, + surface = DarkSurface, + onSurface = DarkOnSurface, + surfaceVariant = DarkSurfaceContainerHigh, + onSurfaceVariant = DarkOnSurfaceVariant, + surfaceDim = DarkSurfaceDim, + surfaceContainerLowest = DarkBackground, + surfaceContainerLow = DarkSurfaceContainerLow, + surfaceContainer = DarkSurfaceContainer, + surfaceContainerHigh = DarkSurfaceContainerHigh, + surfaceContainerHighest = DarkSurfaceContainerHighest, + outline = DarkOutline, + outlineVariant = DarkOutlineVariant, + inverseSurface = DarkInverseSurface, + inverseOnSurface = DarkInverseOnSurface, +) + +// Tight tracking on display/headline/title styles, as in the brand book (−3…−4%). +private val MindboxTypography: Typography + @Composable get() { + val base = MaterialTheme.typography + return base.copy( + headlineSmall = base.headlineSmall.copy(letterSpacing = (-0.5).sp), + titleLarge = base.titleLarge.copy(letterSpacing = (-0.5).sp), + titleMedium = base.titleMedium.copy(letterSpacing = (-0.2).sp), + ) + } + +/** + * App theme. Follows the system light/dark setting (per product decision). + * Sets up edge-to-edge system bars; individual screens control the status-bar + * icon appearance via [SetStatusBarAppearance]. + */ +@Composable +fun MindboxTheme( + darkTheme: Boolean = isSystemInDarkTheme(), + content: @Composable () -> Unit, +) { + val colorScheme = if (darkTheme) DarkColors else LightColors + + val view = LocalView.current + if (!view.isInEditMode) { + SideEffect { + val window = (view.context as Activity).window + WindowCompat.setDecorFitsSystemWindows(window, false) + } + } + + MaterialTheme( + colorScheme = colorScheme, + typography = MindboxTypography, + content = content, + ) +} + +/** + * Controls whether status-bar icons are dark. Call from a screen: pass `true` + * for light backgrounds (dark icons), `false` for the green hero (white icons). + */ +@Composable +fun SetStatusBarAppearance(darkIcons: Boolean) { + val view = LocalView.current + if (!view.isInEditMode) { + SideEffect { + val window = (view.context as Activity).window + WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = darkIcons + } + } +} diff --git a/example/app/src/main/res/drawable/ic_launcher_background.xml b/example/app/src/main/res/drawable/ic_launcher_background.xml index 07d5da9cb..229528ad2 100644 --- a/example/app/src/main/res/drawable/ic_launcher_background.xml +++ b/example/app/src/main/res/drawable/ic_launcher_background.xml @@ -5,7 +5,7 @@ android:viewportWidth="108" android:viewportHeight="108"> - - - - - - - - - - \ No newline at end of file + + + + + + + diff --git a/example/app/src/main/res/layout/activity_main.xml b/example/app/src/main/res/layout/activity_main.xml deleted file mode 100644 index 67eaa34f2..000000000 --- a/example/app/src/main/res/layout/activity_main.xml +++ /dev/null @@ -1,184 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - -