Skip to content

Commit 3c95b21

Browse files
Add messaging session widget sample (#52)
1 parent 7c9f2d2 commit 3c95b21

File tree

3 files changed

+245
-16
lines changed

3 files changed

+245
-16
lines changed

sample-app/src/main/java/com/salesforce/android/smi/sampleapp/MainActivity.kt

Lines changed: 53 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import androidx.compose.foundation.layout.Box
88
import androidx.compose.foundation.layout.fillMaxSize
99
import androidx.compose.foundation.layout.padding
1010
import androidx.compose.material.icons.Icons
11-
import androidx.compose.material.icons.automirrored.filled.Send
11+
import androidx.compose.material.icons.automirrored.filled.Chat
1212
import androidx.compose.material.icons.filled.Delete
1313
import androidx.compose.material3.ExperimentalMaterial3Api
1414
import androidx.compose.material3.Icon
@@ -17,23 +17,30 @@ import androidx.compose.material3.Scaffold
1717
import androidx.compose.material3.Text
1818
import androidx.compose.material3.TopAppBar
1919
import androidx.compose.runtime.Composable
20+
import androidx.compose.runtime.getValue
21+
import androidx.compose.runtime.mutableStateOf
2022
import androidx.compose.runtime.remember
2123
import androidx.compose.runtime.rememberCoroutineScope
24+
import androidx.compose.runtime.setValue
2225
import androidx.compose.ui.Modifier
2326
import androidx.compose.ui.platform.LocalContext
2427
import androidx.compose.ui.tooling.preview.Preview
2528
import androidx.compose.ui.unit.dp
29+
import androidx.lifecycle.compose.LifecycleStartEffect
30+
import androidx.lifecycle.lifecycleScope
2631
import com.salesforce.android.smi.core.CoreClient
2732
import com.salesforce.android.smi.messaging.SalesforceMessaging
28-
import com.salesforce.android.smi.sampleapp.ui.theme.SampleappTheme
33+
import com.salesforce.android.smi.messaging.features.components.MessagingSessionWidget
34+
import com.salesforce.android.smi.sampleapp.ui.theme.SampleAppTheme
2935
import kotlinx.coroutines.launch
36+
import java.util.UUID
3037

3138
class MainActivity : ComponentActivity() {
3239
override fun onCreate(savedInstanceState: Bundle?) {
3340
super.onCreate(savedInstanceState)
3441
enableEdgeToEdge()
3542
setContent {
36-
SampleappTheme {
43+
SampleAppTheme {
3744
MainScreen()
3845
}
3946
}
@@ -46,22 +53,50 @@ fun MainScreen() {
4653
val context = LocalContext.current
4754
val scope = rememberCoroutineScope()
4855

56+
var conversationId: UUID by remember { mutableStateOf(UUID.randomUUID()) }
57+
val salesforceMessaging = remember(conversationId) {
58+
SalesforceMessaging(context, conversationId = conversationId)
59+
}
60+
61+
var maintainEventStream: Boolean by remember { mutableStateOf(false) }
62+
63+
LifecycleStartEffect(Unit) {
64+
maintainEventStream = false
65+
salesforceMessaging.coreClient.start(this.lifecycleScope)
66+
onStopOrDispose {
67+
if (!maintainEventStream) salesforceMessaging.coreClient.stop()
68+
}
69+
}
70+
71+
val openConversation: () -> Unit = {
72+
maintainEventStream = true
73+
salesforceMessaging.uiClient.openConversationActivity(context)
74+
}
75+
4976
Scaffold(
5077
modifier = Modifier.fillMaxSize(),
5178
topBar = {
5279
TopAppBar(
5380
title = { Text(text = "Sample App") },
5481
actions = {
55-
IconButton(onClick = {
56-
SalesforceMessaging(context.applicationContext)
57-
.uiClient.openConversationActivity(context)
58-
}) {
59-
Icon(Icons.AutoMirrored.Default.Send, Icons.AutoMirrored.Default.Send.name)
60-
}
61-
IconButton(onClick = { scope.launch { CoreClient.clearStorage(context) } }) {
62-
Icon(Icons.Default.Delete, Icons.Default.Delete.name)
63-
}
64-
})
82+
IconButton(
83+
onClick = { openConversation() },
84+
content = { Icon(Icons.AutoMirrored.Default.Chat, Icons.AutoMirrored.Default.Chat.name) }
85+
)
86+
IconButton(
87+
onClick = {
88+
scope.launch {
89+
CoreClient.clearStorage(context)
90+
conversationId = UUID.randomUUID()
91+
}
92+
},
93+
content = { Icon(Icons.Default.Delete, Icons.Default.Delete.name) }
94+
)
95+
}
96+
)
97+
},
98+
floatingActionButton = {
99+
MessagingSessionWidget(salesforceMessaging.conversationClient, openConversation)
65100
}
66101
) { innerPadding ->
67102
Box(
@@ -75,7 +110,10 @@ fun MainScreen() {
75110
}
76111

77112
@Composable
78-
fun Greeting(name: String, modifier: Modifier = Modifier) {
113+
fun Greeting(
114+
name: String,
115+
modifier: Modifier = Modifier
116+
) {
79117
Text(
80118
text = "Hello $name!",
81119
modifier = modifier
@@ -85,7 +123,7 @@ fun Greeting(name: String, modifier: Modifier = Modifier) {
85123
@Preview(showBackground = true)
86124
@Composable
87125
fun GreetingPreview() {
88-
SampleappTheme {
126+
SampleAppTheme {
89127
MainScreen()
90128
}
91129
}

sample-app/src/main/java/com/salesforce/android/smi/sampleapp/ui/theme/Theme.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ private val LightColorScheme = lightColorScheme(
3333
)
3434

3535
@Composable
36-
fun SampleappTheme(
36+
fun SampleAppTheme(
3737
darkTheme: Boolean = isSystemInDarkTheme(),
3838
// Dynamic color is available on Android 12+
3939
dynamicColor: Boolean = true,
Lines changed: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,191 @@
1+
package com.salesforce.android.smi.messaging.features.components
2+
3+
import androidx.compose.animation.AnimatedContent
4+
import androidx.compose.animation.animateContentSize
5+
import androidx.compose.animation.core.FastOutSlowInEasing
6+
import androidx.compose.animation.core.tween
7+
import androidx.compose.foundation.layout.Arrangement
8+
import androidx.compose.foundation.layout.Box
9+
import androidx.compose.foundation.layout.Row
10+
import androidx.compose.foundation.layout.fillMaxHeight
11+
import androidx.compose.foundation.layout.heightIn
12+
import androidx.compose.foundation.layout.size
13+
import androidx.compose.material.icons.Icons
14+
import androidx.compose.material.icons.automirrored.filled.Chat
15+
import androidx.compose.material.icons.filled.Close
16+
import androidx.compose.material3.Badge
17+
import androidx.compose.material3.BadgedBox
18+
import androidx.compose.material3.FloatingActionButtonDefaults
19+
import androidx.compose.material3.Icon
20+
import androidx.compose.material3.IconButton
21+
import androidx.compose.material3.MaterialTheme
22+
import androidx.compose.material3.Surface
23+
import androidx.compose.material3.Text
24+
import androidx.compose.material3.TextButton
25+
import androidx.compose.runtime.Composable
26+
import androidx.compose.runtime.derivedStateOf
27+
import androidx.compose.runtime.getValue
28+
import androidx.compose.runtime.remember
29+
import androidx.compose.runtime.rememberCoroutineScope
30+
import androidx.compose.ui.Alignment
31+
import androidx.compose.ui.Modifier
32+
import androidx.compose.ui.tooling.preview.Preview
33+
import androidx.compose.ui.unit.dp
34+
import androidx.lifecycle.compose.collectAsStateWithLifecycle
35+
import com.salesforce.android.smi.common.api.Result
36+
import com.salesforce.android.smi.common.api.data
37+
import com.salesforce.android.smi.core.ConversationClient
38+
import com.salesforce.android.smi.network.data.domain.conversation.Conversation
39+
import com.salesforce.android.smi.network.data.domain.conversation.CoreConversation
40+
import com.salesforce.android.smi.network.data.domain.conversationEntry.ConversationEntry
41+
import com.salesforce.android.smi.network.data.domain.conversationEntry.entryPayload.EntryPayload
42+
import com.salesforce.android.smi.network.data.domain.conversationEntry.entryPayload.SessionStatus
43+
import com.salesforce.android.smi.network.data.domain.participant.ParticipantRoleType
44+
import kotlinx.coroutines.flow.map
45+
import kotlinx.coroutines.launch
46+
import java.util.UUID
47+
48+
@Composable
49+
fun MessagingSessionWidget(
50+
conversationClient: ConversationClient,
51+
openConversation: () -> Unit
52+
) {
53+
val coroutineScope = rememberCoroutineScope()
54+
55+
val endSession: () -> Unit = {
56+
coroutineScope.launch { conversationClient.endSession() }
57+
}
58+
59+
val conversation: Conversation? by remember {
60+
conversationClient.conversation.map { it.data }
61+
}.collectAsStateWithLifecycle(null)
62+
63+
val conversationEntries: Result<List<ConversationEntry>> by remember {
64+
conversationClient.conversationEntriesFlow()
65+
}.collectAsStateWithLifecycle(Result.Loading)
66+
67+
val sessionStatus: SessionStatus by remember {
68+
derivedStateOf {
69+
conversationEntries.latestPayloadOrNull<EntryPayload.SessionStatusChangedPayload>()?.sessionStatus ?: SessionStatus.Inactive
70+
}
71+
}
72+
val queuePosition: Int by remember {
73+
derivedStateOf {
74+
conversationEntries.latestPayloadOrNull<EntryPayload.QueuePositionPayload>()?.position ?: 0
75+
}
76+
}
77+
78+
MessagingSessionWidget(
79+
sessionStatus = sessionStatus,
80+
queuePosition = queuePosition,
81+
conversation = conversation,
82+
openConversation = openConversation,
83+
endSession = endSession
84+
)
85+
}
86+
87+
@Composable
88+
private fun MessagingSessionWidget(
89+
sessionStatus: SessionStatus,
90+
queuePosition: Int,
91+
conversation: Conversation?,
92+
openConversation: () -> Unit,
93+
endSession: () -> Unit
94+
) {
95+
Surface(
96+
modifier = Modifier.heightIn(max = 64.dp),
97+
shape = FloatingActionButtonDefaults.shape,
98+
shadowElevation = 2.dp
99+
) {
100+
Row(
101+
modifier = Modifier.fillMaxHeight().animateContentSize(
102+
animationSpec = tween(durationMillis = 200, delayMillis = 0, easing = FastOutSlowInEasing)
103+
),
104+
horizontalArrangement = Arrangement.End,
105+
verticalAlignment = Alignment.CenterVertically
106+
) {
107+
Box(contentAlignment = Alignment.Center) {
108+
IconButton(onClick = endSession) {
109+
Icon(Icons.Default.Close, Icons.Default.Close.name)
110+
}
111+
}
112+
Box(contentAlignment = Alignment.Center) {
113+
SessionStatusInfo(sessionStatus, queuePosition, conversation, openConversation)
114+
}
115+
}
116+
}
117+
}
118+
119+
@Composable
120+
private fun SessionStatusInfo(
121+
sessionStatus: SessionStatus,
122+
queuePosition: Int,
123+
conversation: Conversation?,
124+
openConversation: () -> Unit
125+
) {
126+
AnimatedContent(sessionStatus) { sessionStatus ->
127+
TextButton(onClick = openConversation) {
128+
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) {
129+
when (sessionStatus) {
130+
SessionStatus.Active -> ActiveSessionText(conversation, queuePosition)
131+
else -> Text(text = sessionStatus.name, style = MaterialTheme.typography.bodyLarge)
132+
}
133+
134+
val unreadMessageCount = if (sessionStatus == SessionStatus.Active) conversation?.unreadMessageCount ?: 0 else 0
135+
SessionBadgeIcon(unreadMessageCount)
136+
}
137+
}
138+
}
139+
}
140+
141+
@Composable
142+
private fun ActiveSessionText(
143+
conversation: Conversation?,
144+
queuePosition: Int
145+
) {
146+
val activeSessionText = if (queuePosition > 0) {
147+
"Your position in queue is $queuePosition."
148+
} else {
149+
conversation?.activeParticipants?.firstOrNull {
150+
it.roleType == ParticipantRoleType.Agent || it.roleType == ParticipantRoleType.Chatbot
151+
}?.displayName ?: "Agent"
152+
}
153+
154+
Text(text = activeSessionText, style = MaterialTheme.typography.bodyLarge)
155+
}
156+
157+
@Composable
158+
private fun SessionBadgeIcon(unreadMessageCount: Int) {
159+
BadgedBox(
160+
badge = {
161+
unreadMessageCount.takeIf { it > 0 }?.let {
162+
Badge { Text(it.toString()) }
163+
}
164+
},
165+
content = {
166+
Icon(Icons.AutoMirrored.Filled.Chat, Icons.AutoMirrored.Filled.Chat.name, Modifier.size(32.dp))
167+
}
168+
)
169+
}
170+
171+
private inline fun <reified T : EntryPayload> Result<List<ConversationEntry>>.latestPayloadOrNull(): T? =
172+
this.data?.firstNotNullOfOrNull { it.payload as? T }
173+
174+
@Composable
175+
@Preview
176+
private fun InactiveSessionWidgetPreview() {
177+
MessagingSessionWidget(SessionStatus.Inactive, 0, null, {}) {}
178+
}
179+
180+
@Composable
181+
@Preview
182+
private fun ActiveSessionWidgetPreview() {
183+
val fakeConversation = CoreConversation(UUID.randomUUID(), "dev", emptyList(), unreadMessageCount = 10)
184+
MessagingSessionWidget(SessionStatus.Active, 0, fakeConversation, {}) {}
185+
}
186+
187+
@Composable
188+
@Preview
189+
private fun QueuedSessionWidgetPreview() {
190+
MessagingSessionWidget(SessionStatus.Active, 10, null, {}) {}
191+
}

0 commit comments

Comments
 (0)