Skip to content

Commit 3045ccc

Browse files
committed
feat(currencycreator): implement moderation for user provided content (name, icon, description)
Signed-off-by: Brandon McAnsh <git@bmcreations.dev>
1 parent 442b795 commit 3045ccc

6 files changed

Lines changed: 275 additions & 19 deletions

File tree

apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/extensions/Result.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ fun <T> Flow<Result<T>>.onResult(onError: (Throwable) -> Unit = { }, onSuccess:
99
}
1010
}
1111

12-
fun <T, R> Flow<Result<T>>.mapResult(block: (T) -> R): Flow<Result<R>> {
12+
fun <T, R> Flow<Result<T>>.mapResult(block: suspend (T) -> R): Flow<Result<R>> {
1313
return this.map {
1414
if (it.isSuccess) {
1515
Result.success(block(it.getOrNull()!!))

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

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -571,4 +571,25 @@
571571
<string name="error_title_onrampUnknownFailure">Something Went Wrong</string>
572572
<string name="error_description_onrampUnknownFailure">Please contact support@flipcash.com</string>
573573

574+
<string name="error_title_moderationFailed">Something Went Wrong</string>
575+
<string name="error_description_moderationFailed">Please try again</string>
576+
577+
<string name="error_title_nameCheckFailed">Something Went Wrong</string>
578+
<string name="error_description_nameCheckFailed">Please try again</string>
579+
580+
<string name="error_title_nameNotAllowed">This Name is Not Allowed</string>
581+
<string name="error_description_nameNotAllowed">Try a different currency name</string>
582+
583+
<string name="error_title_nameAlreadyTaken">This Name is Taken</string>
584+
<string name="error_description_nameAlreadyTaken">Try a different currency name</string>
585+
586+
<string name="error_title_imageNotAllowed">This Image is Not Allowed</string>
587+
<string name="error_description_imageNotAllowed">Try a different image</string>
588+
589+
<string name="error_title_imageNotSupported">This Image is Not Supported</string>
590+
<string name="error_description_imageNotSupported">Try a different image format</string>
591+
592+
<string name="error_title_descriptionNotAllowed">This Description is Not Allowed</string>
593+
<string name="error_description_descriptionNotAllowed">Try a different currency description</string>
594+
574595
</resources>

apps/flipcash/features/currency-creator/src/main/kotlin/com/flipcash/app/currencycreator/internal/CurrencyCreatorViewModel.kt

Lines changed: 202 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,38 +4,56 @@ import android.net.Uri
44
import androidx.compose.foundation.text.input.TextFieldState
55
import androidx.core.text.trimmedLength
66
import androidx.lifecycle.viewModelScope
7-
import com.flipcash.app.core.AppRoute
87
import com.flipcash.app.core.bill.Bill
98
import com.flipcash.app.core.tokens.CurrencyCreatorStep
109
import com.flipcash.app.currencycreator.internal.components.CurrencyCreatorTopBarController
1110
import com.flipcash.app.userflags.UserFlagsCoordinator
1211
import com.flipcash.libs.coroutines.DispatcherProvider
1312
import com.getcode.opencode.model.financial.Fiat
14-
import com.getcode.opencode.model.financial.Token
1513
import com.getcode.opencode.model.financial.toFiat
1614
import com.getcode.opencode.model.ui.TokenBillCustomizations
1715
import com.flipcash.app.core.data.Loadable
18-
import com.flipcash.app.core.data.isLoaded
19-
import com.flipcash.app.core.tokens.SwapPurpose
16+
import com.flipcash.app.core.extensions.flatMapResult
17+
import com.flipcash.app.core.extensions.onResult
2018
import com.flipcash.app.payments.PurchaseMethod
2119
import com.flipcash.app.payments.PurchaseMethodController
20+
import com.flipcash.features.currencycreator.R
21+
import com.flipcash.services.controllers.ModerationController
22+
import com.flipcash.services.models.ImageModerationError
23+
import com.flipcash.services.models.ModerationResult
24+
import com.flipcash.services.models.TextModerationError
25+
import com.getcode.manager.BottomBarManager
26+
import com.getcode.opencode.controllers.CurrencyController
27+
import com.getcode.opencode.model.core.errors.CheckTokenAvailabilityError
2228
import com.getcode.util.resources.ContentReader
29+
import com.getcode.util.resources.ResourceHelper
2330
import com.getcode.view.BaseViewModel2
2431
import com.getcode.view.LoadingSuccessState
2532
import dagger.hilt.android.lifecycle.HiltViewModel
33+
import kotlinx.coroutines.delay
2634
import kotlinx.coroutines.flow.distinctUntilChanged
2735
import kotlinx.coroutines.flow.filterIsInstance
2836
import kotlinx.coroutines.flow.flowOn
2937
import kotlinx.coroutines.flow.launchIn
3038
import kotlinx.coroutines.flow.map
3139
import kotlinx.coroutines.flow.mapNotNull
3240
import kotlinx.coroutines.flow.onEach
41+
import kotlinx.coroutines.launch
3342
import javax.inject.Inject
3443

44+
internal data class ModerationAttestations(
45+
val name: ModerationResult.Attestation? = null,
46+
val icon: ModerationResult.Attestation? = null,
47+
val description: ModerationResult.Attestation? = null,
48+
)
49+
3550
@HiltViewModel
3651
internal class CurrencyCreatorViewModel @Inject constructor(
3752
dispatchers: DispatcherProvider,
3853
userFlags: UserFlagsCoordinator,
54+
moderationController: ModerationController,
55+
currencyController: CurrencyController,
56+
resources: ResourceHelper,
3957
val contentReader: ContentReader,
4058
val purchaseMethodController: PurchaseMethodController,
4159
) : BaseViewModel2<CurrencyCreatorViewModel.State, CurrencyCreatorViewModel.Event>(
@@ -55,6 +73,7 @@ internal class CurrencyCreatorViewModel @Inject constructor(
5573
val bill: Bill? = null,
5674
val purchaseAmount: Fiat = 20.toFiat(),
5775
val processingState: LoadingSuccessState = LoadingSuccessState(),
76+
val attestations: ModerationAttestations = ModerationAttestations(),
5877
) {
5978
val hasName: Boolean
6079
get() = nameFieldState.text.isNotBlank()
@@ -77,6 +96,13 @@ internal class CurrencyCreatorViewModel @Inject constructor(
7796
return (index + 1).toFloat() / PROGRESS_STEPS.size
7897
}
7998

99+
val hasAllAttestations: Boolean
100+
get() {
101+
return attestations.name != null
102+
&& attestations.icon != null
103+
&& attestations.description != null
104+
}
105+
80106
private companion object {
81107
private const val MAX_DESCRIPTION = 500
82108

@@ -93,6 +119,14 @@ internal class CurrencyCreatorViewModel @Inject constructor(
93119
internal sealed interface Event {
94120
data class OnStepChanged(val step: CurrencyCreatorStep) : Event
95121

122+
data object CheckName: Event
123+
data object CheckDescription: Event
124+
data object CheckImage: Event
125+
126+
data class OnNameApproved(val attestation: ModerationResult.Attestation): Event
127+
data class OnDescriptionApproved(val attestation: ModerationResult.Attestation): Event
128+
data class OnImageApproved(val attestation: ModerationResult.Attestation): Event
129+
96130
data class OnIconSelected(val image: Uri) : Event
97131
data class OnIconCached(val image: Uri) : Event
98132

@@ -116,12 +150,160 @@ internal class CurrencyCreatorViewModel @Inject constructor(
116150
eventFlow
117151
.filterIsInstance<Event.OnIconSelected>()
118152
.mapNotNull { event ->
119-
contentReader.copyToCache(event.image, "currency_icon_${System.nanoTime()}")
153+
contentReader.copyToCache(
154+
uri = event.image,
155+
fileName = "currency_icon_${System.nanoTime()}",
156+
maxSize = 500
157+
)
120158
}
121159
.flowOn(dispatchers.IO)
122160
.onEach { cached -> dispatchEvent(Event.OnIconCached(cached)) }
123161
.launchIn(viewModelScope)
124162

163+
eventFlow
164+
.filterIsInstance<Event.CheckName>()
165+
.map { stateFlow.value.nameFieldState.text.toString() }
166+
.onEach { dispatchEvent(Event.UpdateProcessingState(loading = true)) }
167+
.map { moderationController.moderateText(it) }
168+
.flatMapResult { result ->
169+
when (result.flaggedCategory) {
170+
ModerationResult.FlaggedCategory.NONE -> {
171+
currencyController.checkTokenAvailability(result.text)
172+
.map { result.attestation }
173+
}
174+
else -> Result.failure(TextModerationError.Flagged(result.flaggedCategory))
175+
}
176+
}
177+
.onResult(
178+
onSuccess = { attestation ->
179+
viewModelScope.launch {
180+
dispatchEvent(Event.UpdateProcessingState(success = true))
181+
delay(500)
182+
dispatchEvent(Event.OnNameApproved(attestation))
183+
dispatchEvent(Event.UpdateProcessingState())
184+
}
185+
},
186+
onError = { cause ->
187+
dispatchEvent(Event.UpdateProcessingState())
188+
when (cause) {
189+
is TextModerationError.Flagged,
190+
is TextModerationError.Denied -> {
191+
BottomBarManager.showAlert(
192+
title = resources.getString(R.string.error_title_nameNotAllowed),
193+
message = resources.getString(R.string.error_description_nameNotAllowed)
194+
)
195+
}
196+
is CheckTokenAvailabilityError.Unavailable -> {
197+
BottomBarManager.showAlert(
198+
title = resources.getString(R.string.error_title_nameAlreadyTaken),
199+
message = resources.getString(R.string.error_description_nameAlreadyTaken)
200+
)
201+
}
202+
else -> {
203+
BottomBarManager.showError(
204+
title = resources.getString(R.string.error_title_nameCheckFailed),
205+
message = resources.getString(R.string.error_description_nameCheckFailed),
206+
)
207+
}
208+
}
209+
}
210+
)
211+
.launchIn(viewModelScope)
212+
213+
eventFlow
214+
.filterIsInstance<Event.OnIconCached>()
215+
.onEach { dispatchEvent(Event.CheckImage) }
216+
.launchIn(viewModelScope)
217+
218+
eventFlow
219+
.filterIsInstance<Event.CheckImage>()
220+
.mapNotNull { stateFlow.value.icon.dataOrNull }
221+
.onEach { dispatchEvent(Event.UpdateProcessingState(loading = true)) }
222+
.map { moderationController.moderateImage(it) }
223+
.flatMapResult { result ->
224+
when (result.flaggedCategory) {
225+
ModerationResult.FlaggedCategory.NONE -> Result.success(result.attestation)
226+
else -> Result.failure(ImageModerationError.Flagged(result.flaggedCategory))
227+
}
228+
}
229+
.onResult(
230+
onSuccess = { attestation ->
231+
viewModelScope.launch {
232+
dispatchEvent(Event.UpdateProcessingState(success = true))
233+
delay(500)
234+
dispatchEvent(Event.OnImageApproved(attestation))
235+
dispatchEvent(Event.UpdateProcessingState())
236+
}
237+
},
238+
onError = { cause ->
239+
dispatchEvent(Event.UpdateProcessingState())
240+
stateFlow.value.icon.dataOrNull?.let { contentReader.removeFromCache(it) }
241+
when (cause) {
242+
is ImageModerationError.Flagged,
243+
is ImageModerationError.Denied -> {
244+
BottomBarManager.showAlert(
245+
title = resources.getString(R.string.error_title_imageNotAllowed),
246+
message = resources.getString(R.string.error_description_nameNotAllowed)
247+
)
248+
}
249+
is ImageModerationError.UnsupportedFormat -> {
250+
BottomBarManager.showAlert(
251+
title = resources.getString(R.string.error_title_imageNotSupported),
252+
message = resources.getString(R.string.error_description_imageNotSupported)
253+
)
254+
}
255+
else -> {
256+
BottomBarManager.showError(
257+
title = resources.getString(R.string.error_title_moderationFailed),
258+
message = resources.getString(R.string.error_description_moderationFailed),
259+
)
260+
}
261+
}
262+
}
263+
)
264+
.launchIn(viewModelScope)
265+
266+
eventFlow
267+
.filterIsInstance<Event.CheckDescription>()
268+
.map { stateFlow.value.descriptionFieldState.text.toString() }
269+
.onEach { dispatchEvent(Event.UpdateProcessingState(loading = true)) }
270+
.map { moderationController.moderateText(it) }
271+
.flatMapResult { result ->
272+
when (result.flaggedCategory) {
273+
ModerationResult.FlaggedCategory.NONE -> Result.success(result.attestation)
274+
else -> Result.failure(TextModerationError.Flagged(result.flaggedCategory))
275+
}
276+
}
277+
.onResult(
278+
onSuccess = { attestation ->
279+
viewModelScope.launch {
280+
dispatchEvent(Event.UpdateProcessingState(success = true))
281+
delay(500)
282+
dispatchEvent(Event.OnDescriptionApproved(attestation))
283+
dispatchEvent(Event.UpdateProcessingState())
284+
}
285+
},
286+
onError = { cause ->
287+
dispatchEvent(Event.UpdateProcessingState())
288+
when (cause) {
289+
is TextModerationError.Flagged,
290+
is TextModerationError.Denied -> {
291+
BottomBarManager.showAlert(
292+
title = resources.getString(R.string.error_title_descriptionNotAllowed),
293+
message = resources.getString(R.string.error_description_descriptionNotAllowed)
294+
)
295+
}
296+
else -> {
297+
BottomBarManager.showError(
298+
title = resources.getString(R.string.error_title_moderationFailed),
299+
message = resources.getString(R.string.error_description_moderationFailed),
300+
)
301+
}
302+
}
303+
}
304+
)
305+
.launchIn(viewModelScope)
306+
125307
eventFlow
126308
.filterIsInstance<Event.Purchase>()
127309
.onEach { purchaseMethodController.present() }
@@ -195,6 +377,21 @@ internal class CurrencyCreatorViewModel @Inject constructor(
195377
}
196378

197379
is Event.Purchase -> { state -> state }
380+
is Event.CheckName -> { state -> state }
381+
is Event.CheckDescription -> { state -> state }
382+
is Event.CheckImage -> { state -> state }
383+
is Event.OnNameApproved -> { state ->
384+
val attestations = state.attestations
385+
state.copy(attestations = attestations.copy(name = event.attestation))
386+
}
387+
is Event.OnDescriptionApproved -> { state ->
388+
val attestations = state.attestations
389+
state.copy(attestations = attestations.copy(description = event.attestation))
390+
}
391+
is Event.OnImageApproved -> { state ->
392+
val attestations = state.attestations
393+
state.copy(attestations = attestations.copy(icon = event.attestation))
394+
}
198395
}
199396
}
200397
}

apps/flipcash/features/currency-creator/src/main/kotlin/com/flipcash/app/currencycreator/internal/screens/DescriptionSelectionScreen.kt

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -45,12 +45,24 @@ import com.getcode.ui.core.verticalScrollStateGradient
4545
import com.getcode.ui.theme.CodeButton
4646
import com.getcode.ui.theme.CodeScaffold
4747
import com.getcode.ui.utils.rememberKeyboardController
48+
import kotlinx.coroutines.flow.filterIsInstance
49+
import kotlinx.coroutines.flow.launchIn
50+
import kotlinx.coroutines.flow.onEach
4851

4952
@Composable
5053
internal fun DescriptionSelectionScreen() {
54+
val flowNavigator = rememberFlowNavigator<CurrencyCreatorStep, CurrencyCreatorResult>()
55+
5156
val viewModel = flowSharedViewModel<CurrencyCreatorViewModel>()
5257
val state by viewModel.stateFlow.collectAsStateWithLifecycle()
5358
DescriptionSelectionContent(state, viewModel::dispatchEvent)
59+
60+
LaunchedEffect(viewModel) {
61+
viewModel.eventFlow
62+
.filterIsInstance<CurrencyCreatorViewModel.Event.OnDescriptionApproved>()
63+
.onEach { flowNavigator.navigateTo(CurrencyCreatorStep.BillCustomization()) }
64+
.launchIn(this)
65+
}
5466
}
5567

5668
@OptIn(ExperimentalSharedTransitionApi::class)
@@ -59,7 +71,6 @@ internal fun DescriptionSelectionContent(
5971
state: CurrencyCreatorViewModel.State,
6072
dispatch: (CurrencyCreatorViewModel.Event) -> Unit
6173
) {
62-
val flowNavigator = rememberFlowNavigator<CurrencyCreatorStep, CurrencyCreatorResult>()
6374
val keyboard = rememberKeyboardController()
6475

6576
CodeScaffold(
@@ -114,10 +125,12 @@ internal fun DescriptionSelectionContent(
114125
bottom = CodeTheme.dimens.grid.x3
115126
),
116127
text = stringResource(R.string.action_next),
117-
enabled = state.hasDescription,
128+
enabled = state.hasDescription && state.processingState.isIdle,
129+
isLoading = state.processingState.loading,
130+
isSuccess = state.processingState.success,
118131
onClick = {
119132
keyboard.hideIfVisible {
120-
flowNavigator.navigateTo(CurrencyCreatorStep.BillCustomization())
133+
dispatch(CurrencyCreatorViewModel.Event.CheckDescription)
121134
}
122135
},
123136
)
@@ -181,7 +194,7 @@ internal fun DescriptionSelectionContent(
181194
maxLines = Int.MAX_VALUE,
182195
onKeyboardAction = {
183196
keyboard.hideIfVisible {
184-
flowNavigator.navigateTo(CurrencyCreatorStep.BillCustomization())
197+
dispatch(CurrencyCreatorViewModel.Event.CheckDescription)
185198
}
186199
},
187200
)

0 commit comments

Comments
 (0)