@@ -28,7 +28,16 @@ import androidx.compose.foundation.shape.RoundedCornerShape
2828import androidx.compose.foundation.text.input.rememberTextFieldState
2929import androidx.compose.material.Text
3030import androidx.compose.material3.HorizontalDivider
31+ import androidx.compose.foundation.text.input.TextFieldState
3132import 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
3241import androidx.compose.runtime.getValue
3342import androidx.compose.runtime.remember
3443import androidx.compose.ui.Alignment
@@ -66,29 +75,38 @@ import com.getcode.ui.theme.CodeCircularProgressIndicator
6675import 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
7990fun 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
268293private 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
303376private fun ThumbnailStack (images : List <String >) {
0 commit comments