diff --git a/conferences-app/build.gradle.kts b/conferences-app/build.gradle.kts index 09244dc6d..23a59b932 100644 --- a/conferences-app/build.gradle.kts +++ b/conferences-app/build.gradle.kts @@ -153,6 +153,8 @@ kotlin { implementation(libs.ktor.io) implementation(libs.slack.circuit.foundation) implementation(libs.slack.circuit.overlay) + implementation(libs.sqldelight.coroutines.extensions) + implementation(libs.sqldelight.paging3.extensions) implementation(libs.sqldelight.runtime) } diff --git a/conferences-app/src/commonMain/kotlin/io/ashdavies/party/config/CircuitConfig.kt b/conferences-app/src/commonMain/kotlin/io/ashdavies/party/config/CircuitConfig.kt index 2ca10eee7..a69785c98 100644 --- a/conferences-app/src/commonMain/kotlin/io/ashdavies/party/config/CircuitConfig.kt +++ b/conferences-app/src/commonMain/kotlin/io/ashdavies/party/config/CircuitConfig.kt @@ -38,6 +38,7 @@ import io.ashdavies.playground.PlaygroundDatabase import io.ashdavies.sql.LocalTransacter import io.ktor.client.HttpClient import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers import io.ashdavies.party.events.Event as DatabaseEvent @Composable @@ -81,8 +82,11 @@ public fun rememberCircuit( .addCircuit( presenterFactory = { _, _, _ -> presenterOf { - val callable = PastConferencesCallable(LocalHttpClient.current) - PastEventsPresenter(callable) + PastEventsPresenter( + pastConferencesCallable = PastConferencesCallable(LocalHttpClient.current), + attendanceQueries = playgroundDatabase.attendanceQueries, + ioDispatcher = Dispatchers.IO, + ) } }, uiFactory = { state, modifier -> diff --git a/conferences-app/src/commonMain/kotlin/io/ashdavies/party/past/PastEventsPresenter.kt b/conferences-app/src/commonMain/kotlin/io/ashdavies/party/past/PastEventsPresenter.kt index a097533f2..555e2d632 100644 --- a/conferences-app/src/commonMain/kotlin/io/ashdavies/party/past/PastEventsPresenter.kt +++ b/conferences-app/src/commonMain/kotlin/io/ashdavies/party/past/PastEventsPresenter.kt @@ -1,35 +1,58 @@ package io.ashdavies.party.past import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.produceState -import io.ashdavies.aggregator.AsgConference +import app.cash.sqldelight.coroutines.asFlow +import app.cash.sqldelight.coroutines.mapToList import io.ashdavies.aggregator.callable.PastConferencesCallable -import io.ashdavies.party.events.Event +import io.ashdavies.party.events.AttendanceQueries import kotlinx.collections.immutable.toImmutableList -import kotlinx.serialization.encodeToString +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.datetime.Clock +import kotlinx.datetime.LocalDate import kotlinx.serialization.json.Json import okio.ByteString.Companion.encode @Composable internal fun PastEventsPresenter( pastConferencesCallable: PastConferencesCallable, + attendanceQueries: AttendanceQueries, + ioDispatcher: CoroutineDispatcher, ): PastEventsScreen.State { - val itemList by produceState(emptyList()) { - value = pastConferencesCallable(Unit).map { it.toEvent() } + val attendanceList by attendanceQueries + .selectAll { id, _ -> id } + .asFlow() + .mapToList(ioDispatcher) + .collectAsState(emptyList()) + + val itemList by produceState(emptyList(), attendanceList) { + value = pastConferencesCallable(Unit).map { + val startDate = LocalDate.parse(it.dateStart) + val uuid = Json.encodeToString(it) + .encode() + .md5() + .hex() + + PastEventsScreen.State.Item( + uuid = uuid, + title = "${it.name} ${startDate.year}", + subtitle = it.location, + group = "${startDate.year}", + attended = uuid in attendanceList, + ) + } } return PastEventsScreen.State( itemList = itemList.toImmutableList(), - ) + ) { event -> + when (event) { + is PastEventsScreen.Event.MarkAttendance -> when (event.value) { + true -> attendanceQueries.insert(event.id, "${Clock.System.now()}") + false -> attendanceQueries.delete(event.id) + } + } + } } - -internal fun AsgConference.toEvent(): Event = Event( - id = hash(), name = name, website = website, location = location, dateStart = dateStart, - dateEnd = dateEnd, imageUrl = imageUrl, status = status, online = online, - cfpStart = cfp?.start, cfpEnd = cfp?.end, cfpSite = cfp?.site, -) - -private inline fun T.hash() = Json - .encodeToString(this) - .encode().md5().hex() diff --git a/conferences-app/src/commonMain/kotlin/io/ashdavies/party/past/PastEventsScreen.kt b/conferences-app/src/commonMain/kotlin/io/ashdavies/party/past/PastEventsScreen.kt index f3eeeacf6..478e63d42 100644 --- a/conferences-app/src/commonMain/kotlin/io/ashdavies/party/past/PastEventsScreen.kt +++ b/conferences-app/src/commonMain/kotlin/io/ashdavies/party/past/PastEventsScreen.kt @@ -1,14 +1,15 @@ package io.ashdavies.party.past import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.exclude import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.GridItemSpan import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.items import androidx.compose.material3.BottomAppBarDefaults @@ -28,7 +29,6 @@ import com.slack.circuit.runtime.CircuitUiState import com.slack.circuit.runtime.screen.Screen import io.ashdavies.parcelable.Parcelable import io.ashdavies.parcelable.Parcelize -import io.ashdavies.party.events.Event import io.ashdavies.party.events.EventsTopBar import io.ashdavies.party.material.LocalWindowSizeClass import io.ashdavies.party.material.padding @@ -40,12 +40,32 @@ import playground.conferences_app.generated.resources.Res import playground.conferences_app.generated.resources.past_events internal object PastEventsDefaults { - const val ASPECT_RATIO = 3 / 1f + const val MIN_COLUMN_COUNT = 2 + const val MAX_COLUMN_COUNT = 5 } @Parcelize internal object PastEventsScreen : Parcelable, Screen { - data class State(val itemList: ImmutableList) : CircuitUiState + sealed interface Event { + data class MarkAttendance( + val id: String, + val value: Boolean, + ) : Event + } + + data class State( + val itemList: ImmutableList, + val eventSink: (Event) -> Unit, + ) : CircuitUiState { + + data class Item( + val uuid: String, + val title: String, + val group: String, + val subtitle: String, + val attended: Boolean, + ) + } } @Composable @@ -53,6 +73,13 @@ internal fun PastEventsScreen( state: PastEventsScreen.State, modifier: Modifier = Modifier, ) { + val columnCount = when (LocalWindowSizeClass.current.widthSizeClass) { + WindowWidthSizeClass.Compact -> PastEventsDefaults.MIN_COLUMN_COUNT + else -> PastEventsDefaults.MAX_COLUMN_COUNT + } + + val eventSink = state.eventSink + Scaffold( modifier = modifier, topBar = { EventsTopBar(stringResource(Res.string.past_events)) }, @@ -61,52 +88,68 @@ internal fun PastEventsScreen( ), ) { contentPadding -> LazyVerticalGrid( - columns = GridCells.Fixed( - count = when (LocalWindowSizeClass.current.widthSizeClass) { - WindowWidthSizeClass.Compact -> 3 - else -> 5 - }, - ), + columns = GridCells.Fixed(columnCount), modifier = Modifier.padding(contentPadding), contentPadding = MaterialTheme.spacing.large.values, verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.small.vertical), horizontalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.small.horizontal), ) { - items(state.itemList) { event -> - EventItemContent( - name = event.name, - modifier = Modifier - .aspectRatio(PastEventsDefaults.ASPECT_RATIO) - .animateItem(), - ) + state.itemList.groupBy { it.group }.forEach { (group, items) -> + item(span = { GridItemSpan(columnCount) }) { + Text( + text = group, + modifier = Modifier.padding(MaterialTheme.spacing.medium), + style = MaterialTheme.typography.labelLarge, + ) + } + + items(items) { item -> + PastEventItem( + item = item, + modifier = Modifier + .clickable { eventSink(PastEventsScreen.Event.MarkAttendance(item.uuid, !item.attended)) } + .animateItem(), + ) + } } } } } @Composable -private fun EventItemContent( - name: String, +private fun PastEventItem( + item: PastEventsScreen.State.Item, modifier: Modifier = Modifier, ) { Surface( modifier = modifier, shape = MaterialTheme.shapes.small, - color = Color.Transparent, + color = when (item.attended) { + true -> MaterialTheme.colorScheme.surfaceContainerHighest + false -> Color.Transparent + }, border = BorderStroke( width = 1.0.dp, color = MaterialTheme.colorScheme.outline, ), ) { Column( - modifier = Modifier.fillMaxHeight(), + modifier = Modifier + .padding(MaterialTheme.spacing.medium) + .fillMaxHeight(), verticalArrangement = Arrangement.Center, ) { Text( - text = name, - modifier = Modifier - .padding(MaterialTheme.spacing.small) - .fillMaxWidth(), + text = item.title, + modifier = Modifier.fillMaxWidth(), + color = LocalContentColor.current, + textAlign = TextAlign.Center, + style = MaterialTheme.typography.labelMedium, + ) + + Text( + text = item.subtitle, + modifier = Modifier.fillMaxWidth(), color = LocalContentColor.current, textAlign = TextAlign.Center, style = MaterialTheme.typography.labelSmall, diff --git a/conferences-app/src/commonMain/sqldelight/io/ashdavies/party/events/Attendance.sq b/conferences-app/src/commonMain/sqldelight/io/ashdavies/party/events/Attendance.sq new file mode 100644 index 000000000..dfdbc7991 --- /dev/null +++ b/conferences-app/src/commonMain/sqldelight/io/ashdavies/party/events/Attendance.sq @@ -0,0 +1,18 @@ +CREATE TABLE attendance( + id TEXT NOT NULL UNIQUE PRIMARY KEY, + registeredOn TEXT +); + +selectAll: + SELECT * + FROM attendance + ORDER BY registeredOn; + +insert: + INSERT INTO attendance VALUES (?, ?); + +delete: + DELETE FROM attendance WHERE id = :id; + +deleteAll: + DELETE FROM attendance;