Skip to content

Instantly share code, notes, and snippets.

@jpetitto
Last active July 3, 2023 19:03
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save jpetitto/0e1ed12ac89fbda012563709d4f4176d to your computer and use it in GitHub Desktop.
Save jpetitto/0e1ed12ac89fbda012563709d4f4176d to your computer and use it in GitHub Desktop.
//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