Skip to content

Commit 8c9fa8a

Browse files
committed
feat(share): send from the share picker to several chats
The picker now sends directly, as the iOS share extension does: up to 5 chats, up to 10 images, the message as the caption of the last photo, progress per chat, and a retry that goes only to the chats that failed. Sends reuse the chat screen's own text and photo paths, so OutgoingEncryption decides sealing per chat. The composer staging path is removed.
1 parent 1d8d204 commit 8c9fa8a

16 files changed

Lines changed: 1010 additions & 216 deletions

File tree

‎apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/App.kt‎

Lines changed: 4 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -326,8 +326,8 @@ internal fun App(
326326
}
327327

328328
// A share from another app: once the app is past its cold-start Loading
329-
// route and signed in, open the chat picker (or the chat itself when
330-
// the share already names one, which is where direct share lands).
329+
// route and signed in, open the chat picker, which selects the chat the
330+
// share already names (where direct share lands).
331331
// Anyone who isn't signed in just gets the app: the share is dropped.
332332
val pendingShares = LocalPendingChatShares.current
333333
val pendingShare = pendingShares?.pending?.collectAsStateWithLifecycle()?.value
@@ -336,16 +336,9 @@ internal fun App(
336336
if (pendingShare == null || currentRoute is AppRoute.Loading) return@LaunchedEffect
337337
when (userState.authState) {
338338
AuthState.Ready -> {
339-
val share = shares.beginPresenting() ?: return@LaunchedEffect
339+
shares.beginPresenting() ?: return@LaunchedEffect
340340
keyboard.hide()
341-
val target = share.chatId
342-
codeNavigator.navigate(
343-
if (target != null) {
344-
AppRoute.Messaging.Chat(ChatIdentifier.ByChatId(target))
345-
} else {
346-
AppRoute.Messaging.SharePicker
347-
}
348-
)
341+
codeNavigator.navigate(AppRoute.Messaging.SharePicker)
349342
}
350343
is AuthState.Onboarding, AuthState.LoggedOut -> shares.discard()
351344
else -> Unit

‎apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/MainRoot.kt‎

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -203,13 +203,8 @@ private fun List<NavKey>.startsWith(prefix: List<NavKey>): Boolean {
203203
private suspend fun claimShareRoute(shares: PendingChatShares): AppRoute? {
204204
// Bounded: a copy that never finishes must not leave the app on its splash screen.
205205
withTimeoutOrNull(ShareArrivalTimeout) { shares.incoming.first { !it } }
206-
val share = shares.beginPresenting() ?: return null
207-
val target = share.chatId
208-
return if (target != null) {
209-
AppRoute.Messaging.Chat(ChatIdentifier.ByChatId(target))
210-
} else {
211-
AppRoute.Messaging.SharePicker
212-
}
206+
shares.beginPresenting() ?: return null
207+
return AppRoute.Messaging.SharePicker
213208
}
214209

215210
private val ShareArrivalTimeout = 3.seconds

‎apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/AppRoute.kt‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -471,8 +471,8 @@ sealed interface AppRoute : NavKey, Parcelable {
471471

472472
/**
473473
* Where a share from another app picks the conversation it goes to. Opened by the app
474-
* itself when a share is waiting in `PendingChatShares`, and replaced by the chosen
475-
* [Chat]. Carries nothing: what was shared is held there, not on a route a back stack
474+
* itself when a share is waiting in `PendingChatShares`, and sends the share from there
475+
* to the chats picked. Carries nothing: what was shared is held there, not on a route a back stack
476476
* would save and restore.
477477
*/
478478
@Serializable

‎apps/flipcash/core/src/main/res/values/strings.xml‎

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1200,12 +1200,18 @@
12001200
<string name="label_recentChats">Recent Chats</string>
12011201
<string name="title_shareToChat">Send to</string>
12021202
<string name="label_chats">Chats</string>
1203-
<string name="label_shareTo">To %1$s</string>
1203+
<string name="label_suggested">Suggested</string>
1204+
<string name="label_shareImageLimit">Only the first %1$d images are sent.</string>
1205+
<string name="label_shareSendingProgress">Sending %1$d of %2$d</string>
1206+
<string name="label_shareFailure">%1$s: %2$s</string>
1207+
<string name="error_shareSendFailed">Couldn\'t send. Check your connection and try again.</string>
1208+
<string name="error_shareImageRejected">This image can\'t be sent.</string>
1209+
<string name="error_shareImageUnprepared">This image couldn\'t be prepared for sending.</string>
12041210
<string name="label_shareNoMatches">No chats match \u201C%1$s\u201D</string>
12051211
<string name="title_shareNoChats">No chats yet</string>
12061212
<string name="subtitle_shareNoChats">Start a chat in Flipcash, then share again.</string>
12071213
<string name="label_shareAttachedCount">%1$d attached</string>
1208-
<string name="hint_addAMessage">Add a message</string>
1214+
<string name="hint_addAMessage">Add a message\u2026</string>
12091215
<string name="title_tipIntro">Receive Tips From Everyone</string>
12101216
<string name="subtitle_tipIntro">Add your name to receive tips</string>
12111217
<string name="action_startReceivingTips">Start Receiving Tips</string>

‎apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/SharePickerScreen.kt‎

Lines changed: 136 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,16 @@ import androidx.compose.foundation.shape.RoundedCornerShape
2828
import androidx.compose.foundation.text.input.rememberTextFieldState
2929
import androidx.compose.material.Text
3030
import androidx.compose.material3.HorizontalDivider
31+
import androidx.compose.foundation.text.input.TextFieldState
3132
import androidx.compose.runtime.Composable
33+
import androidx.compose.runtime.LaunchedEffect
34+
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
35+
import androidx.compose.ui.platform.LocalHapticFeedback
36+
import com.flipcash.shared.chat.share.MAX_SHARE_IMAGES
37+
import com.flipcash.shared.chat.share.ShareChatStatus
38+
import com.flipcash.shared.chat.share.ShareFailure
39+
import com.flipcash.shared.chat.share.ShareSending
40+
import com.flipcash.shared.chat.share.ShareSendProgress
3241
import androidx.compose.runtime.getValue
3342
import androidx.compose.runtime.remember
3443
import androidx.compose.ui.Alignment
@@ -66,29 +75,38 @@ import com.getcode.ui.theme.CodeCircularProgressIndicator
6675
import com.getcode.ui.utils.rememberKeyboardController
6776

6877
/**
69-
* Where a share from another app picks its conversation, behind `AppRoute.Messaging.SharePicker`.
70-
* The iOS share extension's "Send to" screen: a title with a close button, a search field, the
71-
* chats as a radio list, and, once one is picked, a bar with what is being shared, a message field
72-
* and the action.
78+
* Where a share from another app picks its conversations and is sent, behind
79+
* `AppRoute.Messaging.SharePicker`. The iOS share extension's "Send to" screen: a title with a
80+
* close button, a search field, the chats grouped under "Suggested" and "Recent chats", and, once
81+
* one is picked, a bar with the shared images, a message field, who it goes to and Send (Retry
82+
* after a failure). Up to five chats can be picked; it closes when every one has the share.
7383
*
74-
* Differs from iOS where the app does: one chat at a time, and the action opens that chat with the
75-
* share staged in its composer rather than sending it from here, so it reads "Next", not "Send".
76-
* Nothing is sent from this screen.
84+
* Differs from iOS where Android does: the search field and bar use the app's own components, the
85+
* Send button sits inside the message field as it does in the chat composer, and the selection
86+
* indicator is the app's check mark. Failure lines, the image limit notice and "Sending X of Y"
87+
* are as on iOS.
7788
*/
7889
@Composable
7990
fun SharePickerScreen() {
8091
val viewModel = hiltViewModel<SharePickerViewModel>()
8192
val allChats by viewModel.chats.collectAsStateWithLifecycle()
8293
val chats by viewModel.visibleChats.collectAsStateWithLifecycle()
8394
val selected by viewModel.selected.collectAsStateWithLifecycle()
84-
val share by viewModel.share.collectAsStateWithLifecycle()
95+
val progress by viewModel.progress.collectAsStateWithLifecycle()
96+
val sending by viewModel.sending.collectAsStateWithLifecycle()
97+
val done by viewModel.done.collectAsStateWithLifecycle()
98+
val limitHits by viewModel.limitHits.collectAsStateWithLifecycle()
8599
val navigator = LocalCodeNavigator.current
86-
val keyboard = rememberKeyboardController()
87-
88-
// The message starts as the text that was shared, as it does on iOS, and is what goes on.
89-
val message = rememberTextFieldState(remember(viewModel) { viewModel.share.value?.text.orEmpty() })
100+
val haptics = LocalHapticFeedback.current
90101
val query = viewModel.queryState.text.toString().trim()
91102

103+
LaunchedEffect(done, viewModel.hasShare) {
104+
if (done || !viewModel.hasShare) navigator.pop()
105+
}
106+
LaunchedEffect(limitHits) {
107+
if (limitHits > 0) haptics.performHapticFeedback(HapticFeedbackType.Reject)
108+
}
109+
92110
Column(
93111
modifier = Modifier
94112
.fillMaxSize()
@@ -119,65 +137,72 @@ fun SharePickerScreen() {
119137
title = stringResource(R.string.title_shareNoChats),
120138
detail = stringResource(R.string.subtitle_shareNoChats),
121139
)
122-
else -> LazyColumn(modifier = Modifier.fillMaxSize()) {
123-
item(key = "search") {
124-
SearchInput(
125-
state = viewModel.queryState,
126-
modifier = Modifier
127-
.fillMaxWidth()
128-
.padding(horizontal = ListInset)
129-
.padding(top = 20.dp, bottom = CodeTheme.dimens.grid.x2),
130-
)
131-
}
132-
if (loaded.isEmpty()) {
133-
item(key = "empty") {
134-
Text(
135-
modifier = Modifier.fillMaxWidth().padding(horizontal = ListInset).padding(top = 24.dp),
136-
text = stringResource(R.string.label_shareNoMatches, query),
137-
style = CodeTheme.typography.textSmall,
138-
color = White50,
139-
textAlign = TextAlign.Center,
140+
else -> {
141+
// Suggested chat first and not repeated below; a search is one flat list.
142+
val suggested = viewModel.suggested
143+
?.takeIf { query.isEmpty() }
144+
?.let { id -> loaded.firstOrNull { it.chatId == id } }
145+
val recent = if (suggested == null) loaded else loaded.filter { it.chatId != suggested.chatId }
146+
LazyColumn(modifier = Modifier.fillMaxSize()) {
147+
item(key = "search") {
148+
SearchInput(
149+
state = viewModel.queryState,
150+
modifier = Modifier
151+
.fillMaxWidth()
152+
.padding(horizontal = ListInset)
153+
.padding(top = 20.dp, bottom = CodeTheme.dimens.grid.x2),
140154
)
141155
}
142-
} else {
143-
item(key = "header") {
144-
SectionHeader(
145-
stringResource(if (query.isEmpty()) R.string.label_recentChats else R.string.label_chats)
146-
)
156+
if (suggested != null) {
157+
item(key = "suggestedHeader") { SectionHeader(stringResource(R.string.label_suggested)) }
158+
item(key = "suggested:${suggested.chatId}") {
159+
ShareTargetRow(suggested, suggested.chatId in selected) { viewModel.toggle(suggested.chatId) }
160+
}
147161
}
148-
items(loaded, key = { it.chatId.toString() }) { chat ->
149-
ShareTargetRow(
150-
chat = chat,
151-
selected = chat.chatId == selected,
152-
onClick = { viewModel.toggle(chat.chatId) },
153-
)
162+
if (loaded.isEmpty()) {
163+
item(key = "empty") {
164+
Text(
165+
modifier = Modifier.fillMaxWidth().padding(horizontal = ListInset).padding(top = 24.dp),
166+
text = stringResource(R.string.label_shareNoMatches, query),
167+
style = CodeTheme.typography.textSmall,
168+
color = White50,
169+
textAlign = TextAlign.Center,
170+
)
171+
}
172+
} else if (recent.isNotEmpty()) {
173+
item(key = "header") {
174+
SectionHeader(
175+
stringResource(if (query.isEmpty()) R.string.label_recentChats else R.string.label_chats)
176+
)
177+
}
178+
items(recent, key = { it.chatId.toString() }) { chat ->
179+
ShareTargetRow(chat, chat.chatId in selected) { viewModel.toggle(chat.chatId) }
180+
}
154181
}
155182
}
156183
}
157184
}
158185
}
159186

160187
// Slides up once a chat is picked, as the iOS compose bar does.
161-
val picked = chats.orEmpty().firstOrNull { it.chatId == selected }
188+
val names = allChats.orEmpty().associate { it.chatId to it.name.orEmpty() }
162189
AnimatedVisibility(
163-
visible = picked != null && share != null,
190+
visible = selected.isNotEmpty(),
164191
enter = slideInVertically { it },
165192
exit = slideOutVertically { it },
166193
) {
194+
val isSending = sending != null
195+
val hasMessage = viewModel.messageState.text.isNotBlank()
167196
ComposeBar(
168-
recipient = picked?.name.orEmpty(),
169-
images = share?.images.orEmpty().map { it.toString() },
170-
messageState = message,
171-
onNext = {
172-
val target = selected
173-
if (target != null && viewModel.confirm(message.text.toString())) {
174-
keyboard.hideIfVisible {
175-
navigator.replace(AppRoute.Messaging.Chat(ChatIdentifier.ByChatId(target)))
176-
}
177-
} else {
178-
navigator.pop()
179-
}
180-
},
197+
recipients = selected.mapNotNull { names[it] }.joinToString(", "),
198+
images = viewModel.images.map { it.toString() },
199+
droppedImages = viewModel.droppedImageCount,
200+
messageState = viewModel.messageState,
201+
sending = sending,
202+
progress = progress,
203+
names = names,
204+
canSend = viewModel.canSend(selected, isSending, progress, hasMessage),
205+
onSend = { viewModel.send() },
181206
)
182207
}
183208
}
@@ -261,16 +286,23 @@ private fun ShareTargetRow(chat: ConversationReference, selected: Boolean, onCli
261286
}
262287

263288
/**
264-
* Who it goes to, then the field, with the shared images stacked in it, and the action. The field
265-
* is the chat composer's own so typing here feels the same.
289+
* Notices, then the field with the shared images stacked in it and the action, then who it goes
290+
* to. The field is the chat composer's own so typing here feels the same.
266291
*/
267292
@Composable
268293
private fun ComposeBar(
269-
recipient: String,
294+
recipients: String,
270295
images: List<String>,
271-
messageState: androidx.compose.foundation.text.input.TextFieldState,
272-
onNext: () -> Unit,
296+
droppedImages: Int,
297+
messageState: TextFieldState,
298+
sending: ShareSending?,
299+
progress: ShareSendProgress?,
300+
names: Map<ChatId, String>,
301+
canSend: Boolean,
302+
onSend: () -> Unit,
273303
) {
304+
val failed = progress?.takeIf { sending == null }?.failed.orEmpty()
305+
val isRetry = failed.isNotEmpty()
274306
Column(
275307
modifier = Modifier
276308
.fillMaxWidth()
@@ -280,16 +312,41 @@ private fun ComposeBar(
280312
.padding(top = 12.dp, bottom = 8.dp),
281313
verticalArrangement = Arrangement.spacedBy(8.dp),
282314
) {
315+
if (droppedImages > 0) {
316+
Notice(stringResource(R.string.label_shareImageLimit, MAX_SHARE_IMAGES))
317+
}
318+
if (sending != null && sending.total > 1) {
319+
Notice(stringResource(R.string.label_shareSendingProgress, sending.position, sending.total))
320+
}
321+
failed.forEach { id ->
322+
val status = progress?.statuses?.get(id) as? ShareChatStatus.Failed ?: return@forEach
323+
Notice(
324+
text = stringResource(
325+
R.string.label_shareFailure,
326+
names[id].orEmpty(),
327+
stringResource(status.failure.message()),
328+
),
329+
color = CodeTheme.colors.error,
330+
)
331+
}
283332
ChatInput(
284333
hint = stringResource(R.string.hint_addAMessage),
285334
state = messageState,
335+
enabled = sending == null,
286336
hasAttachments = images.isNotEmpty(),
287-
submit = ChatInputSubmit.Action(label = stringResource(com.flipcash.core.R.string.action_next), perform = onNext),
337+
sendEnabled = canSend,
338+
submit = ChatInputSubmit.Action(
339+
label = stringResource(
340+
if (isRetry) com.flipcash.core.R.string.action_retry else com.flipcash.core.R.string.action_send
341+
),
342+
busy = sending != null,
343+
perform = { if (canSend) onSend() },
344+
),
288345
header = if (images.isEmpty()) null else ({ ThumbnailStack(images) }),
289346
)
290347
Text(
291348
modifier = Modifier.fillMaxWidth().padding(horizontal = 4.dp),
292-
text = stringResource(R.string.label_shareTo, recipient),
349+
text = recipients,
293350
style = CodeTheme.typography.textSmall,
294351
color = White50,
295352
maxLines = 1,
@@ -298,6 +355,22 @@ private fun ComposeBar(
298355
}
299356
}
300357

358+
@Composable
359+
private fun Notice(text: String, color: Color = White50) {
360+
Text(
361+
modifier = Modifier.fillMaxWidth().padding(horizontal = 4.dp),
362+
text = text,
363+
style = CodeTheme.typography.textSmall,
364+
color = color,
365+
)
366+
}
367+
368+
private fun ShareFailure.message(): Int = when (this) {
369+
ShareFailure.Send -> R.string.error_shareSendFailed
370+
ShareFailure.ImageRejected -> R.string.error_shareImageRejected
371+
ShareFailure.ImageUnprepared -> R.string.error_shareImageUnprepared
372+
}
373+
301374
/** Up to three of the images, fanned, with the count over them when there are more than one. */
302375
@Composable
303376
private fun ThumbnailStack(images: List<String>) {

0 commit comments

Comments
 (0)