HarvestCircleShellPresenter.kt (6811B)
1 package org.harvestcircle.application 2 3 import kotlinx.coroutines.CoroutineScope 4 import kotlinx.coroutines.Job 5 import kotlinx.coroutines.flow.MutableStateFlow 6 import kotlinx.coroutines.flow.StateFlow 7 import kotlinx.coroutines.flow.asStateFlow 8 import kotlinx.coroutines.flow.collect 9 import kotlinx.coroutines.flow.update 10 import kotlinx.coroutines.launch 11 import org.harvestcircle.appearance.AppearanceState 12 import org.harvestcircle.appearance.MotionPreference 13 import org.harvestcircle.appearance.TextSizePreference 14 import org.harvestcircle.appearance.ThemePreference 15 import org.harvestcircle.navigation.AppRoute 16 import org.harvestcircle.navigation.NavigationIntent 17 import org.harvestcircle.product.ScreenKey 18 19 interface IdentityPresentationPort { 20 val state: StateFlow<HarvestCirclePresenterState> 21 22 fun dispatch(intent: HarvestCircleIntent) 23 } 24 25 data class HarvestCircleShellState( 26 val identity: HarvestCirclePresenterState, 27 val buildInfo: BuildInfo, 28 val session: ShellSessionState = ShellSessionState(), 29 val localUsability: LocalUsability = deriveLocalUsability(identity.snapshot), 30 val root: ShellRoot = deriveShellRoot(identity, session), 31 val appearance: AppearanceState = AppearanceState(), 32 val overlays: OverlayState = OverlayState(), 33 ) 34 35 sealed interface HarvestCircleShellIntent { 36 data class Identity( 37 val intent: HarvestCircleIntent, 38 ) : HarvestCircleShellIntent 39 40 data object EnterReadOnly : HarvestCircleShellIntent 41 42 data object ManageIdentity : HarvestCircleShellIntent 43 44 data class Navigate( 45 val screenKey: ScreenKey, 46 ) : HarvestCircleShellIntent 47 48 data class Navigation( 49 val intent: NavigationIntent, 50 ) : HarvestCircleShellIntent 51 52 data class Overlay( 53 val intent: OverlayIntent, 54 ) : HarvestCircleShellIntent 55 56 data class SetTheme( 57 val theme: ThemePreference, 58 ) : HarvestCircleShellIntent 59 60 data class SetTextSize( 61 val textSize: TextSizePreference, 62 ) : HarvestCircleShellIntent 63 64 data class SetMotion( 65 val motion: MotionPreference, 66 ) : HarvestCircleShellIntent 67 } 68 69 class HarvestCircleShellPresenter( 70 private val identityPresenter: IdentityPresentationPort, 71 buildInfo: BuildInfo, 72 scope: CoroutineScope, 73 private val referenceParser: NostrReferenceParser = RejectingNostrReferenceParser, 74 ) { 75 private val mutableState = MutableStateFlow(HarvestCircleShellState(identityPresenter.state.value, buildInfo)) 76 val state: StateFlow<HarvestCircleShellState> = mutableState.asStateFlow() 77 private val observation: Job = 78 scope.launch { 79 identityPresenter.state.collect { identity -> updateIdentity(identity) } 80 } 81 82 fun dispatch(intent: HarvestCircleShellIntent) { 83 when (intent) { 84 is HarvestCircleShellIntent.Identity -> identityPresenter.dispatch(intent.intent) 85 HarvestCircleShellIntent.EnterReadOnly -> reduce(ShellEvent.EnterReadOnly) 86 HarvestCircleShellIntent.ManageIdentity -> reduce(ShellEvent.ManageIdentity) 87 is HarvestCircleShellIntent.Navigate -> reduce(ShellEvent.Navigate(intent.screenKey)) 88 is HarvestCircleShellIntent.Navigation -> reduce(ShellEvent.Navigation(intent.intent)) 89 is HarvestCircleShellIntent.Overlay -> dispatchOverlay(intent.intent) 90 is HarvestCircleShellIntent.SetTheme -> reduce(ShellEvent.SetTheme(intent.theme)) 91 is HarvestCircleShellIntent.SetTextSize -> reduce(ShellEvent.SetTextSize(intent.textSize)) 92 is HarvestCircleShellIntent.SetMotion -> reduce(ShellEvent.SetMotion(intent.motion)) 93 } 94 } 95 96 fun close() { 97 observation.cancel() 98 } 99 100 private fun updateIdentity(identity: HarvestCirclePresenterState) { 101 reduce(ShellEvent.IdentityObserved(identity)) 102 } 103 104 private fun dispatchOverlay(intent: OverlayIntent) { 105 when (intent) { 106 OverlayIntent.SubmitReference -> { 107 val overlay = mutableState.value.overlays.current as? FoundationOverlay.OpenNostrReference ?: return 108 val admitted = ReferenceInputPolicy.admit(overlay.input) 109 if (admitted == ReferenceInputAdmission.PrivateKeyShaped) { 110 applyReferenceResult(ReferenceResult.PrivateKeyRejected, clearInput = true) 111 return 112 } 113 if (admitted == ReferenceInputAdmission.TooLarge) { 114 applyReferenceResult(ReferenceResult.Invalid, clearInput = true) 115 return 116 } 117 if (admitted == ReferenceInputAdmission.AmbiguousHex) { 118 applyReferenceResult(ReferenceResult.AmbiguousHex, clearInput = true) 119 return 120 } 121 val parsed = referenceParser.parse((admitted as ReferenceInputAdmission.Accepted).value) 122 when (parsed.classification) { 123 NostrReferenceClassification.Invalid -> applyReferenceResult(ReferenceResult.Invalid) 124 NostrReferenceClassification.PrivateKeyRejected -> 125 applyReferenceResult(ReferenceResult.PrivateKeyRejected, clearInput = true) 126 NostrReferenceClassification.EventId, 127 NostrReferenceClassification.PublicKey, 128 NostrReferenceClassification.Profile, 129 NostrReferenceClassification.Note, 130 NostrReferenceClassification.Event, 131 NostrReferenceClassification.Address, 132 -> applyReferenceResult(ReferenceResult.Unsupported) 133 } 134 return 135 } 136 else -> Unit 137 } 138 admitOverlay(intent) 139 } 140 141 private fun admitOverlay(intent: OverlayIntent) { 142 while (true) { 143 val current = mutableState.value 144 val transition = OverlayReducer.transition(current, intent) 145 if (transition.state == current && transition.effects.isEmpty()) return 146 if (!mutableState.compareAndSet(current, transition.state)) continue 147 transition.effects.forEach(::execute) 148 return 149 } 150 } 151 152 private fun execute(effect: ShellEffect) { 153 when (effect) { 154 is ShellEffect.DispatchIdentity -> identityPresenter.dispatch(effect.intent) 155 } 156 } 157 158 private fun applyReferenceResult( 159 result: ReferenceResult, 160 clearInput: Boolean = false, 161 ) { 162 admitOverlay(OverlayIntent.ApplyReferenceResult(result, clearInput)) 163 } 164 165 private fun reduce(event: ShellEvent) { 166 mutableState.update { current -> ShellReducer.reduce(current, event) } 167 } 168 } 169 170 val HarvestCircleShellState.currentRoute: AppRoute? 171 get() = (root as? ShellRoot.Dashboard)?.navigation?.current