Last active
October 1, 2024 13:05
-
-
Save jpetitto/0e1ed12ac89fbda012563709d4f4176d to your computer and use it in GitHub Desktop.
Composable Presenters Case Study: https://technology.doximity.com/articles/composable-presenters-part-2-case-study
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
//region model | |
sealed interface NotesUiModel : UiModel { | |
object Loading : NotesUiModel | |
data class Data( | |
val notes: List<Note>, | |
val events: EventHandler<Event> | |
) : NotesUiModel { | |
data class Note( | |
val text: String, | |
val isChecked: Boolean, | |
val events: EventHandler<Event> | |
) : UiModel { | |
sealed interface Event : UiEvent { | |
object OnCheck : Event | |
data class OnUpdateText(val text: String) : Event | |
object OnDelete : Event | |
} | |
} | |
sealed interface Event : UiEvent { | |
object OnAdd : Event | |
object OnSave : Event | |
} | |
} | |
} | |
//endregion | |
//region presenter | |
class NotesListPresenter( | |
val appScope: AppScope, | |
val noteItemPresenter: NoteItemPresenter, | |
val observeNotesUseCase: ObserveNotesUseCase, | |
val createNoteUseCase: CreateNoteUseCase, | |
val saveNotesUseCase: SaveNotesUseCase | |
) : Presenter<NotesUiModel, Unit> { | |
@Composable | |
override fun present(params: Unit): NotesUiModel { | |
val scope = rememberCoroutineScope() | |
val notesResult by observeNotesUseCase.launchUseCase() | |
val notes = notesResult ?: return NotesUiModel.Loading | |
// create an initial note if there are none | |
LaunchedEffect(Unit) { | |
if (notes.isEmpty()) { | |
createNoteUseCase() | |
} | |
} | |
return NotesUiModel.Data( | |
notes = notes.map { note -> | |
noteItemPresenter.present(NoteItemPresenter.Params(note)) | |
}, | |
events = EventHandler { event -> | |
when (event) { | |
Event.OnAdd -> scope.launch { | |
createNoteUseCase() | |
} | |
Event.OnSave -> { | |
appScope.launch { | |
saveNotesUseCase() | |
} | |
} | |
} | |
} | |
) | |
} | |
} | |
class NoteItemPresenter( | |
val updateNoteUseCase: UpdateNoteUseCase, | |
val deleteNoteUseCase: DeleteNoteUseCase | |
) : Presenter<Note, NoteItemPresenter.Params> { | |
data class Params(val note: NoteDataModel) | |
@Composable | |
override fun present(params: Params): Note { | |
val scope = rememberCoroutineScope() | |
val note = params.note | |
var text by remember(note.id) { mutableStateOf(note.text) } | |
var isChecked by remember(note.id) { mutableStateOf(note.isFinished) } | |
return Note( | |
text = text, | |
isChecked = isChecked, | |
events = EventHandler { event -> | |
when (event) { | |
Note.Event.OnCheck -> { | |
isChecked = isChecked.not() | |
scope.launch { | |
updateNoteUseCase(NoteDataModel(note.id, text, isChecked)) | |
} | |
} | |
is Note.Event.OnUpdateText -> { | |
text = event.text | |
scope.launch { | |
updateNoteUseCase(NoteDataModel(note.id, text, isChecked)) | |
} | |
} | |
Note.Event.OnDelete -> scope.launch { | |
deleteNoteUseCase(note.id) | |
} | |
} | |
} | |
) | |
} | |
} | |
//endregion | |
//region view | |
@Composable | |
fun NotesScreen() { | |
val presenter: NotesListPresenter = koinInject() | |
Column { | |
TopAppBar( | |
title = { Text("✏️ Notes") }, | |
backgroundColor = MaterialTheme.colors.background, | |
modifier = Modifier.zIndex(1f) | |
) | |
when (val uiModel = presenter.present(Unit)) { | |
is NotesUiModel.Data -> Notes(uiModel) | |
NotesUiModel.Loading -> LinearProgressIndicator(Modifier.fillMaxWidth()) | |
} | |
} | |
} | |
@Composable | |
fun Notes(uiModel: NotesUiModel.Data) { | |
LifecycleEffect { | |
if (it == Lifecycle.Event.ON_PAUSE) { | |
uiModel.events.handle(Event.OnSave) | |
} | |
} | |
Box( | |
Modifier | |
.fillMaxSize() | |
.padding(16.dp) | |
) { | |
LazyColumn { | |
items(uiModel.notes) { note -> | |
Note(note) | |
} | |
} | |
FloatingActionButton( | |
onClick = { uiModel.events.handle(Event.OnAdd) }, | |
backgroundColor = MaterialTheme.colors.primary, | |
modifier = Modifier.align(Alignment.BottomEnd) | |
) { | |
Icon( | |
imageVector = Icons.Rounded.Add, | |
contentDescription = "Add" | |
) | |
} | |
} | |
} | |
@Composable | |
fun Note(note: Note) { | |
Row( | |
modifier = Modifier.fillMaxWidth() | |
) { | |
Checkbox( | |
checked = note.isChecked, | |
colors = CheckboxDefaults.colors(checkedColor = MaterialTheme.colors.primary), | |
onCheckedChange = { note.events.handle(Note.Event.OnCheck) }, | |
modifier = Modifier.padding(top = 4.dp) | |
) | |
TextField( | |
value = note.text, | |
textStyle = TextStyle( | |
fontSize = 18.sp, | |
textDecoration = if (note.isChecked) { | |
TextDecoration.LineThrough | |
} else { | |
TextDecoration.None | |
} | |
), | |
placeholder = { Text("Add note") }, | |
colors = TextFieldDefaults.textFieldColors( | |
backgroundColor = Color.Transparent, | |
focusedIndicatorColor = Color.Transparent, | |
unfocusedIndicatorColor = Color.Transparent | |
), | |
onValueChange = { note.events.handle(Note.Event.OnUpdateText(it)) }, | |
modifier = Modifier.weight(1f) | |
) | |
IconButton( | |
onClick = { note.events.handle(Note.Event.OnDelete) }, | |
modifier = Modifier.padding(top = 4.dp) | |
) { | |
Icon( | |
imageVector = Icons.Default.Delete, | |
tint = MaterialTheme.colors.onSurface.copy( | |
alpha = ContentAlpha.medium | |
), | |
contentDescription = "Delete" | |
) | |
} | |
} | |
} | |
//endregion | |
//region data | |
data class NoteDataModel(val id: Long, val text: String, val isFinished: Boolean) | |
class CreateNoteUseCase(private val notesCache: NotesCache) : AsyncUseCase { | |
override suspend fun run() { | |
notesCache.upsert(NoteDataModel(id = Random.nextLong(), text = "", isFinished = false)) | |
} | |
} | |
class UpdateNoteUseCase(private val notesCache: NotesCache) : AsyncParamsUseCase<NoteDataModel> { | |
override suspend fun run(params: NoteDataModel) { | |
notesCache.upsert(params, commit = false) | |
} | |
} | |
class DeleteNoteUseCase(private val notesCache: NotesCache) : AsyncParamsUseCase<Long> { | |
override suspend fun run(params: Long) { | |
notesCache.remove(params) | |
} | |
} | |
class ObserveNotesUseCase(private val notesCache: NotesCache) : FlowableUseCase<List<NoteDataModel>> { | |
override fun run(): Flow<List<NoteDataModel>> { | |
return notesCache.observeNotes() | |
} | |
} | |
class SaveNotesUseCase(private val notesCache: NotesCache) : AsyncUseCase { | |
override suspend fun run() { | |
notesCache.save() | |
} | |
} | |
class NotesCache { | |
private val notes = mutableListOf<NoteDataModel>() | |
private val notesFlow = MutableStateFlow(notes.toImmutableList()) | |
suspend fun upsert(note: NoteDataModel, commit: Boolean = true) { | |
notes.find { it.id == note.id }?.let { oldNote -> | |
val index = notes.indexOf(oldNote) | |
notes.removeAt(index) | |
notes.add(index, note) | |
} ?: notes.add(note) | |
if (commit) save() | |
} | |
suspend fun remove(noteId: Long) { | |
notes.removeIf { it.id == noteId } | |
save() | |
} | |
suspend fun save() { | |
notesFlow.emit(notes.toImmutableList()) | |
} | |
fun observeNotes() = notesFlow | |
} | |
//endregion | |
//region infrastructure | |
@Immutable | |
interface UiModel | |
interface Presenter<Model : UiModel, Params> { | |
@Composable fun present(params: Params): Model | |
} | |
interface UiEvent | |
@Immutable | |
data class EventHandler<E : UiEvent>(val key: Any? = Unit, val handle: (E) -> Unit) { | |
override fun equals(other: Any?): Boolean = key == (other as? EventHandler<E>)?.key | |
override fun hashCode(): Int = handle.hashCode() | |
} | |
interface FlowableUseCase<T> { | |
fun run(): Flow<T> | |
operator fun invoke() = run() | |
} | |
interface AsyncUseCase { | |
suspend fun run() | |
suspend operator fun invoke() = run() | |
} | |
interface AsyncParamsUseCase<P> { | |
suspend fun run(params: P) | |
suspend operator fun invoke(params: P) = run(params) | |
} | |
@Composable | |
fun <T : Any> FlowableUseCase<T>.launchUseCase(initial: T? = null): State<T?> { | |
return produceState(initialValue = initial, producer = { | |
invoke().collect { | |
value = it | |
} | |
}) | |
} | |
@Composable | |
fun LifecycleEffect(onEvent: (Lifecycle.Event) -> Unit) { | |
val lifecycleOwner = rememberUpdatedState(LocalLifecycleOwner.current) | |
DisposableEffect(lifecycleOwner.value) { | |
val lifecycle = lifecycleOwner.value.lifecycle | |
val observer = LifecycleEventObserver { _, event -> | |
onEvent(event) | |
} | |
lifecycle.addObserver(observer) | |
onDispose { | |
lifecycle.removeObserver(observer) | |
} | |
} | |
} | |
class AppScope( | |
dispatcher: CoroutineDispatcher = Dispatchers.Main | |
) : CoroutineScope, DefaultLifecycleObserver { | |
override val coroutineContext = SupervisorJob() + dispatcher | |
init { | |
this.launch(dispatcher) { | |
ProcessLifecycleOwner.get().lifecycle.addObserver(this@AppScope) | |
} | |
} | |
override fun onDestroy(owner: LifecycleOwner) { | |
coroutineContext.cancelChildren() | |
} | |
} | |
//endregion |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment