diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index d3873181..8c17b10d 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -73,6 +73,8 @@ jsoup = { module = "org.jsoup:jsoup", version.ref = "jsoup" } junit = { module = "junit:junit", version.ref = "junit" } koin-android = { module = "io.insert-koin:koin-android", version.ref = "koin" } koin-core = { module = "io.insert-koin:koin-core", version.ref = "koin" } +koin-compose = { module = "io.insert-koin:koin-compose", version.ref = "koin" } +koin-compose-viewmodel = { module = "io.insert-koin:koin-compose-viewmodel", version.ref = "koin" } koin-test = { module = "io.insert-koin:koin-test", version.ref = "koin" } kotlin-gradle-plugin = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", version.ref = "kotlin" } kotlin-serialization = { module = "org.jetbrains.kotlin:kotlin-serialization", version.ref = "kotlin" } diff --git a/shared/app/build.gradle.kts b/shared/app/build.gradle.kts index a6456734..ac56e755 100644 --- a/shared/app/build.gradle.kts +++ b/shared/app/build.gradle.kts @@ -26,9 +26,12 @@ kotlin { implementation(compose.runtime) implementation(compose.foundation) implementation(compose.material3) + implementation(compose.materialIconsExtended) implementation(libs.androidx.lifecycle.viewmodel) implementation(libs.cketti.codepoints) implementation(libs.koin.core) + implementation(libs.koin.compose) + implementation(libs.koin.compose.viewmodel) implementation(libs.kotlinx.datetime) implementation(libs.ktor.client.content.negotiation) implementation(libs.ktor.client.core) diff --git a/shared/app/src/commonMain/kotlin/com/adammcneilly/pocketleague/shared/app/domain/usecases/GetOngoingEventsUseCase.kt b/shared/app/src/commonMain/kotlin/com/adammcneilly/pocketleague/shared/app/domain/usecases/GetOngoingEventsUseCase.kt new file mode 100644 index 00000000..baa63cb8 --- /dev/null +++ b/shared/app/src/commonMain/kotlin/com/adammcneilly/pocketleague/shared/app/domain/usecases/GetOngoingEventsUseCase.kt @@ -0,0 +1,43 @@ +package com.adammcneilly.pocketleague.shared.app.domain.usecases + +import com.adammcneilly.pocketleague.shared.app.core.datetime.DateTimeFormatter +import com.adammcneilly.pocketleague.shared.app.core.datetime.TimeProvider +import com.adammcneilly.pocketleague.shared.app.core.displaymodels.EventGroupDisplayModel +import com.adammcneilly.pocketleague.shared.app.core.displaymodels.EventSummaryDisplayModel +import com.adammcneilly.pocketleague.shared.app.core.locale.LocaleHelper +import com.adammcneilly.pocketleague.shared.app.data.event.EventListRequest +import com.adammcneilly.pocketleague.shared.app.data.event.EventRepository +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map + +/** + * Return an observable type of events that are currently happening on today's date. + */ +class GetOngoingEventsUseCase( + private val dateTimeFormatter: DateTimeFormatter, + private val eventRepository: EventRepository, + private val localeHelper: LocaleHelper, + private val timeProvider: TimeProvider, +) { + /** + * @see [GetOngoingEventsUseCase] + */ + fun invoke(): Flow> { + val request = EventListRequest.OnDate( + dateUtc = timeProvider.now(), + ) + + return eventRepository + .getEvents(request) + .map { eventList -> + eventList.map { event -> + EventSummaryDisplayModel( + event = event, + dateTimeFormatter = dateTimeFormatter, + localeHelper = localeHelper, + ) + } + } + .map(EventGroupDisplayModel.Companion::mapFromEventList) + } +} diff --git a/shared/app/src/commonMain/kotlin/com/adammcneilly/pocketleague/shared/app/domain/usecases/GetPastWeeksMatchesUseCase.kt b/shared/app/src/commonMain/kotlin/com/adammcneilly/pocketleague/shared/app/domain/usecases/GetPastWeeksMatchesUseCase.kt new file mode 100644 index 00000000..ee4cef56 --- /dev/null +++ b/shared/app/src/commonMain/kotlin/com/adammcneilly/pocketleague/shared/app/domain/usecases/GetPastWeeksMatchesUseCase.kt @@ -0,0 +1,44 @@ +package com.adammcneilly.pocketleague.shared.app.domain.usecases + +import com.adammcneilly.pocketleague.shared.app.core.datetime.DateTimeFormatter +import com.adammcneilly.pocketleague.shared.app.core.datetime.TimeProvider +import com.adammcneilly.pocketleague.shared.app.core.displaymodels.MatchDetailDisplayModel +import com.adammcneilly.pocketleague.shared.app.data.match.MatchListRequest +import com.adammcneilly.pocketleague.shared.app.data.match.MatchRepository +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map + +/** + * Return an observable type of matches for the past week. + */ +class GetPastWeeksMatchesUseCase( + private val dateTimeFormatter: DateTimeFormatter, + private val matchRepository: MatchRepository, + private val timeProvider: TimeProvider, +) { + /** + * @see [GetPastWeeksMatchesUseCase]. + */ + fun invoke(): Flow> { + val request = MatchListRequest.DateRange( + startDateUTC = timeProvider.daysAgo(DAYS_PER_WEEK), + endDateUTC = timeProvider.now(), + ) + + return matchRepository + .getMatches(request) + .map { matchList -> + matchList.map { match -> + MatchDetailDisplayModel( + match = match, + dateTimeFormatter = dateTimeFormatter, + timeProvider = timeProvider, + ) + } + } + } + + companion object { + private const val DAYS_PER_WEEK = 7 + } +} diff --git a/shared/app/src/commonMain/kotlin/com/adammcneilly/pocketleague/shared/app/domain/usecases/GetUpcomingEventsUseCase.kt b/shared/app/src/commonMain/kotlin/com/adammcneilly/pocketleague/shared/app/domain/usecases/GetUpcomingEventsUseCase.kt new file mode 100644 index 00000000..c6929a2c --- /dev/null +++ b/shared/app/src/commonMain/kotlin/com/adammcneilly/pocketleague/shared/app/domain/usecases/GetUpcomingEventsUseCase.kt @@ -0,0 +1,43 @@ +package com.adammcneilly.pocketleague.shared.app.domain.usecases + +import com.adammcneilly.pocketleague.shared.app.core.datetime.DateTimeFormatter +import com.adammcneilly.pocketleague.shared.app.core.datetime.TimeProvider +import com.adammcneilly.pocketleague.shared.app.core.displaymodels.EventGroupDisplayModel +import com.adammcneilly.pocketleague.shared.app.core.displaymodels.EventSummaryDisplayModel +import com.adammcneilly.pocketleague.shared.app.core.locale.LocaleHelper +import com.adammcneilly.pocketleague.shared.app.data.event.EventListRequest +import com.adammcneilly.pocketleague.shared.app.data.event.EventRepository +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map + +/** + * Return an observable type of events that are upcoming from today's date. + */ +class GetUpcomingEventsUseCase( + private val dateTimeFormatter: DateTimeFormatter, + private val eventRepository: EventRepository, + private val localeHelper: LocaleHelper, + private val timeProvider: TimeProvider, +) { + /** + * @see [GetUpcomingEventsUseCase] + */ + fun invoke(): Flow> { + val request = EventListRequest.AfterDate( + dateUtc = timeProvider.now(), + ) + + return eventRepository + .getEvents(request) + .map { eventList -> + eventList.map { event -> + EventSummaryDisplayModel( + event = event, + dateTimeFormatter = dateTimeFormatter, + localeHelper = localeHelper, + ) + } + } + .map(EventGroupDisplayModel.Companion::mapFromEventList) + } +} diff --git a/shared/app/src/commonMain/kotlin/com/adammcneilly/pocketleague/shared/app/feature/feed/FeedContent.kt b/shared/app/src/commonMain/kotlin/com/adammcneilly/pocketleague/shared/app/feature/feed/FeedContent.kt new file mode 100644 index 00000000..9e8e0f73 --- /dev/null +++ b/shared/app/src/commonMain/kotlin/com/adammcneilly/pocketleague/shared/app/feature/feed/FeedContent.kt @@ -0,0 +1,149 @@ +package com.adammcneilly.pocketleague.shared.app.feature.feed + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.adammcneilly.pocketleague.shared.app.core.displaymodels.EventGroupDisplayModel +import com.adammcneilly.pocketleague.shared.app.core.displaymodels.MatchDetailDisplayModel +import com.adammcneilly.pocketleague.shared.app.ui.event.EventSummaryListCard +import com.adammcneilly.pocketleague.shared.app.ui.event.LanEventSummaryCard +import com.adammcneilly.pocketleague.shared.app.ui.match.MatchCarousel +import com.adammcneilly.pocketleague.shared.app.ui.modifiers.screenHorizontalPadding +import com.adammcneilly.pocketleague.shared.app.ui.theme.PocketLeagueTheme + +/** + * The main list of events and matches to show within the feed screen + * that is the landing page when opening the app. + */ +@Composable +fun FeedContent( + recentMatches: List, + ongoingEvents: List, + upcomingEvents: List, + onMatchClicked: (String) -> Unit, + onEventClicked: (String) -> Unit, + modifier: Modifier = Modifier, +) { + LazyColumn( + modifier = modifier, + contentPadding = PaddingValues( + vertical = PocketLeagueTheme.sizes.screenPadding, + ), + verticalArrangement = Arrangement.spacedBy(PocketLeagueTheme.sizes.listItemSpacing), + ) { + recentMatchesHeader() + + recentMatchesCarousel(recentMatches, onMatchClicked) + + happeningNowHeader() + + eventGroupList(ongoingEvents, onEventClicked) + + upcomingHeader() + + eventGroupList(upcomingEvents, onEventClicked) + } +} + +private fun LazyListScope.upcomingHeader() { + item { + FeedSectionHeader( + text = "Upcoming", + ) + } +} + +private fun LazyListScope.eventGroupList( + events: List, + onEventClicked: (String) -> Unit, +) { + events.forEach { group -> + item { + FeedEventGroup( + group, + onEventClicked, + ) + } + } +} + +private fun LazyListScope.happeningNowHeader() { + item { + FeedSectionHeader( + text = "Happening Now", + ) + } +} + +private fun LazyListScope.recentMatchesCarousel( + recentMatches: List, + onMatchClicked: (String) -> Unit, +) { + item { + MatchCarousel( + matches = recentMatches, + contentPadding = PaddingValues( + horizontal = PocketLeagueTheme.sizes.screenPadding, + ), + onMatchClicked = onMatchClicked, + ) + } +} + +private fun LazyListScope.recentMatchesHeader() { + item { + FeedSectionHeader( + text = "Recent Matches", + ) + } +} + +@Composable +private fun FeedSectionHeader( + text: String, + modifier: Modifier = Modifier, +) { + Text( + text = text, + style = MaterialTheme.typography.headlineSmall, + modifier = modifier + .screenHorizontalPadding(), + ) +} + +/** + * For a given [displayModel], determine how to render + * that collection for our [FeedContent]. + */ +@Composable +private fun FeedEventGroup( + displayModel: EventGroupDisplayModel, + onEventClicked: (String) -> Unit, + modifier: Modifier = Modifier, +) { + val groupModifier = modifier + .screenHorizontalPadding() + + when (displayModel) { + is EventGroupDisplayModel.Regionals -> { + EventSummaryListCard( + events = displayModel.events, + onEventClicked = onEventClicked, + modifier = groupModifier, + ) + } + + is EventGroupDisplayModel.Major -> { + LanEventSummaryCard( + event = displayModel.event, + onEventClicked = onEventClicked, + modifier = groupModifier, + ) + } + } +} diff --git a/shared/app/src/commonMain/kotlin/com/adammcneilly/pocketleague/shared/app/feature/feed/FeedScreen.kt b/shared/app/src/commonMain/kotlin/com/adammcneilly/pocketleague/shared/app/feature/feed/FeedScreen.kt new file mode 100644 index 00000000..1ccfe3f9 --- /dev/null +++ b/shared/app/src/commonMain/kotlin/com/adammcneilly/pocketleague/shared/app/feature/feed/FeedScreen.kt @@ -0,0 +1,27 @@ +package com.adammcneilly.pocketleague.shared.app.feature.feed + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.ui.Modifier +import org.koin.compose.viewmodel.koinViewModel + +/** + * Top level container for the Feed screen inside the application. This is a stateful + * wrapper around [FeedContent]. + */ +@Composable +fun FeedScreen( + modifier: Modifier = Modifier, + viewModel: FeedViewModel = koinViewModel(), +) { + val state = viewModel.state.collectAsState() + + FeedContent( + recentMatches = state.value.recentMatches, + ongoingEvents = state.value.ongoingEvents, + upcomingEvents = state.value.upcomingEvents, + onMatchClicked = { /*TODO*/ }, + onEventClicked = { /*TODO*/ }, + modifier = modifier, + ) +} diff --git a/shared/app/src/commonMain/kotlin/com/adammcneilly/pocketleague/shared/app/feature/feed/FeedViewModel.kt b/shared/app/src/commonMain/kotlin/com/adammcneilly/pocketleague/shared/app/feature/feed/FeedViewModel.kt new file mode 100644 index 00000000..553e5c4f --- /dev/null +++ b/shared/app/src/commonMain/kotlin/com/adammcneilly/pocketleague/shared/app/feature/feed/FeedViewModel.kt @@ -0,0 +1,68 @@ +package com.adammcneilly.pocketleague.shared.app.feature.feed + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.adammcneilly.pocketleague.shared.app.domain.usecases.GetOngoingEventsUseCase +import com.adammcneilly.pocketleague.shared.app.domain.usecases.GetPastWeeksMatchesUseCase +import com.adammcneilly.pocketleague.shared.app.domain.usecases.GetUpcomingEventsUseCase +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +class FeedViewModel( + private val getPastWeeksMatchesUseCase: GetPastWeeksMatchesUseCase, + private val getOngoingEventsUseCase: GetOngoingEventsUseCase, + private val getUpcomingEventsUseCase: GetUpcomingEventsUseCase, +) : ViewModel() { + private val mutableState = MutableStateFlow(FeedViewState.placeholderState()) + val state = mutableState.asStateFlow() + + init { + observePastWeeksMatches() + observeOngoingEvents() + observeUpcomingEvents() + } + + private fun observePastWeeksMatches() { + viewModelScope.launch { + getPastWeeksMatchesUseCase + .invoke() + .collect { matchList -> + mutableState.update { currentState -> + currentState.copy( + recentMatches = matchList, + ) + } + } + } + } + + private fun observeOngoingEvents() { + viewModelScope.launch { + getOngoingEventsUseCase + .invoke() + .collect { eventList -> + mutableState.update { currentState -> + currentState.copy( + ongoingEvents = eventList, + ) + } + } + } + } + + private fun observeUpcomingEvents() { + viewModelScope.launch { + getUpcomingEventsUseCase + .invoke() + .collect { eventList -> + mutableState.update { currentState -> + currentState.copy( + ongoingEvents = eventList, + ) + } + } + } + } +} diff --git a/shared/app/src/commonMain/kotlin/com/adammcneilly/pocketleague/shared/app/feature/feed/FeedViewState.kt b/shared/app/src/commonMain/kotlin/com/adammcneilly/pocketleague/shared/app/feature/feed/FeedViewState.kt new file mode 100644 index 00000000..0bddfca5 --- /dev/null +++ b/shared/app/src/commonMain/kotlin/com/adammcneilly/pocketleague/shared/app/feature/feed/FeedViewState.kt @@ -0,0 +1,33 @@ +package com.adammcneilly.pocketleague.shared.app.feature.feed + +import com.adammcneilly.pocketleague.shared.app.core.displaymodels.EventGroupDisplayModel +import com.adammcneilly.pocketleague.shared.app.core.displaymodels.MatchDetailDisplayModel + +/** + * User friendly representation of the state of the [FeedScreen]. + */ +data class FeedViewState( + val recentMatches: List, + val ongoingEvents: List, + val upcomingEvents: List, +) { + companion object { + private const val PLACEHOLDER_LIST_COUNT = 3 + + /** + * Returns a default instance of [FeedViewState] where all display models are set + * to placeholders to display during a default loading state. + */ + fun placeholderState(): FeedViewState { + val recentMatches = List(PLACEHOLDER_LIST_COUNT) { + MatchDetailDisplayModel.placeholder + } + + return FeedViewState( + recentMatches = recentMatches, + ongoingEvents = EventGroupDisplayModel.placeholder, + upcomingEvents = EventGroupDisplayModel.placeholder, + ) + } + } +} diff --git a/shared/app/src/commonMain/kotlin/com/adammcneilly/pocketleague/shared/app/ui/components/InlineIconText.kt b/shared/app/src/commonMain/kotlin/com/adammcneilly/pocketleague/shared/app/ui/components/InlineIconText.kt new file mode 100644 index 00000000..085ae697 --- /dev/null +++ b/shared/app/src/commonMain/kotlin/com/adammcneilly/pocketleague/shared/app/ui/components/InlineIconText.kt @@ -0,0 +1,128 @@ +package com.adammcneilly.pocketleague.shared.app.ui.components + +import androidx.compose.foundation.text.InlineTextContent +import androidx.compose.foundation.text.appendInlineContent +import androidx.compose.material3.Icon +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.LocalTextStyle +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.Placeholder +import androidx.compose.ui.text.PlaceholderVerticalAlign +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow + +private const val INLINE_CONTENT_ID = "inlineContent" + +/** + * Creates a text composable to show an inline icon. This is usually used to show a trophy + * inline with a winning team's name. + * + * @param[text] The string to render inside the text composable. + * @param[icon] The icon that will be added to this text. + * @param[modifier] An optional [Modifier] to customize this text. + * @param[leadingIcon] If true, the icon will show before the text. If false, the icon + * appears after. + * @param[showIcon] If true, we'll actually show the icon. If false, we'll ignore it. + * @param[textColor] If supplied, use this color for our text component. + * @param[iconTint] If supplied, we provide a tint to our [icon] using this color. + * @param[textAlign] @see [Text] + * @param[style] @see [Text] + * @param[fontWeight] @see [Text] + * @param[maxLines] @see [Text] + */ +@Composable +fun InlineIconText( + text: String, + icon: ImageVector, + modifier: Modifier = Modifier, + leadingIcon: Boolean = false, + showIcon: Boolean = true, + textColor: Color = LocalContentColor.current, + iconTint: Color = LocalContentColor.current, + textAlign: TextAlign? = null, + style: TextStyle = LocalTextStyle.current, + fontWeight: FontWeight? = null, + maxLines: Int = Int.MAX_VALUE, +) { + Text( + text = text.getAnnotatedString( + leadingIcon = leadingIcon, + showIcon = showIcon, + ), + inlineContent = getInlineContent( + icon = icon, + showIcon = showIcon, + iconTint = iconTint, + ), + modifier = modifier, + textAlign = textAlign, + style = style, + color = textColor, + fontWeight = fontWeight, + maxLines = maxLines, + overflow = TextOverflow.Ellipsis, + ) +} + +/** + * Builds the [AnnotatedString] that has the inline content for our text. + */ +private fun String.getAnnotatedString( + leadingIcon: Boolean, + showIcon: Boolean, +): AnnotatedString { + return buildAnnotatedString { + if (leadingIcon && showIcon) { + appendInlineContent(INLINE_CONTENT_ID, "[icon]") + append(" ") + } + + append(this@getAnnotatedString) + + if (!leadingIcon && showIcon) { + append(" ") + appendInlineContent(INLINE_CONTENT_ID, "[icon]") + } + } +} + +/** + * Builds the inline text content for an [InlineIconText]. + */ +@Composable +private fun getInlineContent( + icon: ImageVector, + showIcon: Boolean, + iconTint: Color, +): Map { + return if (!showIcon) { + emptyMap() + } else { + mapOf( + Pair( + INLINE_CONTENT_ID, + InlineTextContent( + Placeholder( + width = LocalTextStyle.current.fontSize, + height = LocalTextStyle.current.fontSize, + placeholderVerticalAlign = PlaceholderVerticalAlign.TextCenter, + ), + ) { + Icon( + imageVector = icon, + contentDescription = null, + tint = iconTint, + ) + }, + ), + ) + } +} diff --git a/shared/app/src/commonMain/kotlin/com/adammcneilly/pocketleague/shared/app/ui/components/ListItemDividerCard.kt b/shared/app/src/commonMain/kotlin/com/adammcneilly/pocketleague/shared/app/ui/components/ListItemDividerCard.kt new file mode 100644 index 00000000..9ff3e900 --- /dev/null +++ b/shared/app/src/commonMain/kotlin/com/adammcneilly/pocketleague/shared/app/ui/components/ListItemDividerCard.kt @@ -0,0 +1,31 @@ +package com.adammcneilly.pocketleague.shared.app.ui.components + +import androidx.compose.material3.Card +import androidx.compose.material3.Divider +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier + +/** + * This will create a custom [Card] component that consumes a + * list of [items], and sets them up vertically with dividers in between + * items. The content for each item is defined by the [listItemContent] function + * passed in. + */ +@Composable +fun ListItemDividerCard( + items: List, + modifier: Modifier = Modifier, + listItemContent: @Composable (T) -> Unit, +) { + Card( + modifier = modifier, + ) { + items.forEachIndexed { index, item -> + listItemContent.invoke(item) + + if (index != items.lastIndex) { + Divider() + } + } + } +} diff --git a/shared/app/src/commonMain/kotlin/com/adammcneilly/pocketleague/shared/app/ui/components/VerticalSpacer.kt b/shared/app/src/commonMain/kotlin/com/adammcneilly/pocketleague/shared/app/ui/components/VerticalSpacer.kt new file mode 100644 index 00000000..cc9a3cb3 --- /dev/null +++ b/shared/app/src/commonMain/kotlin/com/adammcneilly/pocketleague/shared/app/ui/components/VerticalSpacer.kt @@ -0,0 +1,21 @@ +package com.adammcneilly.pocketleague.shared.app.ui.components + +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.Dp + +/** + * Wrapper component around a [Spacer] that is used to create one + * for a fixed [height]. + */ +@Composable +fun VerticalSpacer( + height: Dp, +) { + Spacer( + modifier = Modifier + .height(height), + ) +} diff --git a/shared/app/src/commonMain/kotlin/com/adammcneilly/pocketleague/shared/app/ui/event/EventSummaryListCard.kt b/shared/app/src/commonMain/kotlin/com/adammcneilly/pocketleague/shared/app/ui/event/EventSummaryListCard.kt new file mode 100644 index 00000000..af6776d2 --- /dev/null +++ b/shared/app/src/commonMain/kotlin/com/adammcneilly/pocketleague/shared/app/ui/event/EventSummaryListCard.kt @@ -0,0 +1,32 @@ +package com.adammcneilly.pocketleague.shared.app.ui.event + +import androidx.compose.foundation.clickable +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.adammcneilly.pocketleague.shared.app.core.displaymodels.EventSummaryDisplayModel +import com.adammcneilly.pocketleague.shared.app.ui.components.ListItemDividerCard + +/** + * A card component that shows a list of event summaries. + */ +@Composable +fun EventSummaryListCard( + events: List, + onEventClicked: (String) -> Unit, + modifier: Modifier = Modifier, +) { + ListItemDividerCard( + items = events, + modifier = modifier, + ) { event -> + EventSummaryListItem( + event = event, + containerColor = MaterialTheme.colorScheme.surfaceVariant, + modifier = Modifier + .clickable { + onEventClicked.invoke(event.eventId) + }, + ) + } +} diff --git a/shared/app/src/commonMain/kotlin/com/adammcneilly/pocketleague/shared/app/ui/event/EventSummaryListItem.kt b/shared/app/src/commonMain/kotlin/com/adammcneilly/pocketleague/shared/app/ui/event/EventSummaryListItem.kt new file mode 100644 index 00000000..807e3239 --- /dev/null +++ b/shared/app/src/commonMain/kotlin/com/adammcneilly/pocketleague/shared/app/ui/event/EventSummaryListItem.kt @@ -0,0 +1,81 @@ +package com.adammcneilly.pocketleague.shared.app.ui.event + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.EmojiEvents +import androidx.compose.material3.ListItem +import androidx.compose.material3.ListItemDefaults +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import com.adammcneilly.pocketleague.shared.app.core.displaymodels.EventSummaryDisplayModel +import com.adammcneilly.pocketleague.shared.app.ui.components.InlineIconText +import com.adammcneilly.pocketleague.shared.app.ui.placeholder.PlaceholderDefaults +import com.adammcneilly.pocketleague.shared.app.ui.placeholder.placeholderMaterial + +/** + * Shows high level summary information about an event. + * + * @param[event] The event summary to show inside of this list item. + * @param[modifier] Any optional modifications to apply to this component. + * @param[containerColor] If supplied, use this color to specify the container color of the + * list item, different from what is the default. This may be used in situations where a list item is shown + * on a card component, so the color should not be the default surface. + */ +@Composable +fun EventSummaryListItem( + event: EventSummaryDisplayModel, + modifier: Modifier = Modifier, + containerColor: Color = Color.Unspecified, +) { + val colorsToUse = if (containerColor != Color.Unspecified) { + ListItemDefaults.colors( + containerColor = containerColor, + ) + } else { + ListItemDefaults.colors() + } + + val placeholderColor = if (containerColor != Color.Unspecified) { + PlaceholderDefaults.color( + backgroundColor = containerColor, + ) + } else { + PlaceholderDefaults.color() + } + + val winningTeam = event.winningTeam + + val dateRangeText = @Composable { + Text( + text = event.dateRange, + ) + } + + val winningTeamText = @Composable { + InlineIconText( + text = winningTeam?.name.orEmpty(), + icon = Icons.Default.EmojiEvents, + leadingIcon = true, + ) + } + + ListItem( + headlineContent = { + Text( + text = event.name, + modifier = Modifier + .fillMaxWidth() + .placeholderMaterial( + visible = event.isPlaceholder, + color = placeholderColor, + ), + ) + }, + overlineContent = dateRangeText.takeIf { winningTeam == null }, + supportingContent = winningTeamText.takeIf { winningTeam != null }, + colors = colorsToUse, + modifier = modifier, + ) +} diff --git a/shared/app/src/commonMain/kotlin/com/adammcneilly/pocketleague/shared/app/ui/event/LanEventSummaryCard.kt b/shared/app/src/commonMain/kotlin/com/adammcneilly/pocketleague/shared/app/ui/event/LanEventSummaryCard.kt new file mode 100644 index 00000000..218eada3 --- /dev/null +++ b/shared/app/src/commonMain/kotlin/com/adammcneilly/pocketleague/shared/app/ui/event/LanEventSummaryCard.kt @@ -0,0 +1,136 @@ +package com.adammcneilly.pocketleague.shared.app.ui.event + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.EmojiEvents +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import com.adammcneilly.pocketleague.shared.app.core.displaymodels.EventSummaryDisplayModel +import com.adammcneilly.pocketleague.shared.app.core.displaymodels.TeamOverviewDisplayModel +import com.adammcneilly.pocketleague.shared.app.ui.components.InlineIconText +import com.adammcneilly.pocketleague.shared.app.ui.components.VerticalSpacer +import com.adammcneilly.pocketleague.shared.app.ui.theme.PocketLeagueTheme +import com.adammcneilly.pocketleague.shared.app.ui.theme.md_theme_dark_onSurface +import com.adammcneilly.pocketleague.shared.app.ui.theme.rlcsBlue +import com.adammcneilly.pocketleague.shared.app.ui.theme.rlcsOrange + +/** + * A custom card component used to highlight a LAN event. Unlike an online + * regional, a LAN event is an in-person international competition and deserves + * special highlighting within [com.adammcneilly.pocketleague.shared.app.feature.feed.FeedContent]. + */ +@Composable +fun LanEventSummaryCard( + event: EventSummaryDisplayModel, + onEventClicked: (String) -> Unit, + modifier: Modifier = Modifier, +) { + val rlcsGradientBrush = Brush.horizontalGradient( + colors = listOf(rlcsBlue, rlcsOrange), + ) + + CompositionLocalProvider( + LocalContentColor provides md_theme_dark_onSurface, + ) { + Box( + modifier = modifier + .background( + brush = rlcsGradientBrush, + shape = MaterialTheme.shapes.medium, + ) + .fillMaxWidth() + .clickable { + onEventClicked.invoke(event.eventId) + }, + ) { + Column( + modifier = Modifier + .padding(PocketLeagueTheme.sizes.cardPadding), + ) { + EventName(event.name) + + VerticalSpacer(PocketLeagueTheme.sizes.cardPadding) + + // If we have a winning team, it implies + // the event is over, so we'll render it slightly + // differently + val winningTeam = event.winningTeam + + if (winningTeam != null) { + EventWinner(winningTeam) + } else { + EventDates(event.dateRange) + + EventLocation(event.arenaLocation) + } + } + } + } +} + +@Composable +private fun EventWinner( + winningTeam: TeamOverviewDisplayModel, +) { + InlineIconText( + text = winningTeam.name, + icon = Icons.Default.EmojiEvents, + leadingIcon = true, + modifier = Modifier + .fillMaxWidth(), + textAlign = TextAlign.Center, + style = MaterialTheme.typography.headlineSmall, + ) +} + +@Composable +private fun EventLocation( + location: String, +) { + Text( + text = location, + style = MaterialTheme.typography.labelMedium, + textAlign = TextAlign.Center, + modifier = Modifier + .fillMaxWidth(), + ) +} + +@Composable +private fun EventDates( + dateRange: String, +) { + Text( + text = dateRange, + style = MaterialTheme.typography.labelMedium, + textAlign = TextAlign.Center, + modifier = Modifier + .fillMaxWidth(), + ) +} + +@Composable +private fun EventName( + name: String, +) { + Text( + text = name, + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + textAlign = TextAlign.Center, + modifier = Modifier + .fillMaxWidth(), + ) +} diff --git a/shared/app/src/commonMain/kotlin/com/adammcneilly/pocketleague/shared/app/ui/match/MatchCard.kt b/shared/app/src/commonMain/kotlin/com/adammcneilly/pocketleague/shared/app/ui/match/MatchCard.kt new file mode 100644 index 00000000..705b19d5 --- /dev/null +++ b/shared/app/src/commonMain/kotlin/com/adammcneilly/pocketleague/shared/app/ui/match/MatchCard.kt @@ -0,0 +1,150 @@ +package com.adammcneilly.pocketleague.shared.app.ui.match + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.EmojiEvents +import androidx.compose.material3.Card +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import com.adammcneilly.pocketleague.shared.app.core.displaymodels.MatchDetailDisplayModel +import com.adammcneilly.pocketleague.shared.app.core.displaymodels.MatchTeamResultDisplayModel +import com.adammcneilly.pocketleague.shared.app.ui.components.InlineIconText +import com.adammcneilly.pocketleague.shared.app.ui.components.VerticalSpacer +import com.adammcneilly.pocketleague.shared.app.ui.placeholder.PlaceholderDefaults +import com.adammcneilly.pocketleague.shared.app.ui.placeholder.placeholderMaterial +import com.adammcneilly.pocketleague.shared.app.ui.theme.PocketLeagueTheme + +/** + * Renders a [match] inside a card component. Likely to be used in a carousel of recent matches, + * but is intentionally agnostic of where it could be used. + */ +@Composable +fun MatchCard( + match: MatchDetailDisplayModel, + onClick: (String) -> Unit, + modifier: Modifier = Modifier, +) { + Card( + modifier = modifier + .clickable( + enabled = !match.isPlaceholder, + onClick = { + onClick.invoke(match.matchId) + }, + ), + ) { + Column( + modifier = Modifier + .padding(PocketLeagueTheme.sizes.cardPadding), + ) { + EventName(match) + + RelativeTime(match) + + VerticalSpacer(PocketLeagueTheme.sizes.cardPadding) + + Column( + modifier = Modifier + .placeholderMaterial( + visible = match.isPlaceholder, + color = PlaceholderDefaults.cardColor(), + ), + ) { + BlueTeamResult(match) + + OrangeTeamResult(match) + } + } + } +} + +@Composable +private fun EventName( + match: MatchDetailDisplayModel, +) { + Text( + text = match.eventName, + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier + .fillMaxWidth() + .placeholderMaterial( + visible = match.isPlaceholder, + color = PlaceholderDefaults.cardColor(), + ), + ) +} + +@Composable +private fun RelativeTime( + match: MatchDetailDisplayModel, +) { + Text( + text = match.relativeDateTime, + style = MaterialTheme.typography.labelSmall, + ) +} + +/** + * NOTE: For showing the little star icon after the team name, we referenced + * this StackOverflow answer. https://stackoverflow.com/a/67611627/3131147 + */ +@Composable +private fun MatchTeamResultRow( + teamResult: MatchTeamResultDisplayModel, +) { + val fontWeight: FontWeight? = if (teamResult.winner) { + FontWeight.Bold + } else { + null + } + + Row( + horizontalArrangement = Arrangement.spacedBy(PocketLeagueTheme.sizes.cardPadding), + modifier = Modifier + .fillMaxWidth(), + ) { + Text( + text = teamResult.score.toString(), + fontWeight = fontWeight, + ) + + InlineIconText( + text = teamResult.team.name, + icon = Icons.Default.EmojiEvents, + showIcon = teamResult.winner, + fontWeight = fontWeight, + maxLines = 1, + modifier = Modifier + .weight(1F), + ) + } +} + +@Composable +private fun OrangeTeamResult( + match: MatchDetailDisplayModel, +) { + MatchTeamResultRow( + teamResult = match.orangeTeamResult, + ) +} + +@Composable +private fun BlueTeamResult( + match: MatchDetailDisplayModel, +) { + MatchTeamResultRow( + teamResult = match.blueTeamResult, + ) +} diff --git a/shared/app/src/commonMain/kotlin/com/adammcneilly/pocketleague/shared/app/ui/match/MatchCarousel.kt b/shared/app/src/commonMain/kotlin/com/adammcneilly/pocketleague/shared/app/ui/match/MatchCarousel.kt new file mode 100644 index 00000000..94b48acc --- /dev/null +++ b/shared/app/src/commonMain/kotlin/com/adammcneilly/pocketleague/shared/app/ui/match/MatchCarousel.kt @@ -0,0 +1,37 @@ +package com.adammcneilly.pocketleague.shared.app.ui.match + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.adammcneilly.pocketleague.shared.app.core.displaymodels.MatchDetailDisplayModel +import com.adammcneilly.pocketleague.shared.app.ui.theme.PocketLeagueTheme + +private const val MATCH_CARD_WIDTH_RATIO = 0.8F + +/** + * Given a collection of [matches], render them in a horizontally + * scrolling UI. + */ +@Composable +fun MatchCarousel( + matches: List, + contentPadding: PaddingValues, + onMatchClicked: (String) -> Unit, +) { + LazyRow( + contentPadding = contentPadding, + horizontalArrangement = Arrangement.spacedBy(PocketLeagueTheme.sizes.listItemSpacing), + ) { + items(matches) { match -> + MatchCard( + match = match, + onClick = onMatchClicked, + modifier = Modifier + .fillParentMaxWidth(MATCH_CARD_WIDTH_RATIO), + ) + } + } +} diff --git a/shared/app/src/commonMain/kotlin/com/adammcneilly/pocketleague/shared/app/ui/modifiers/ScreenHorizontalPadding.kt b/shared/app/src/commonMain/kotlin/com/adammcneilly/pocketleague/shared/app/ui/modifiers/ScreenHorizontalPadding.kt new file mode 100644 index 00000000..2d16a603 --- /dev/null +++ b/shared/app/src/commonMain/kotlin/com/adammcneilly/pocketleague/shared/app/ui/modifiers/ScreenHorizontalPadding.kt @@ -0,0 +1,21 @@ +package com.adammcneilly.pocketleague.shared.app.ui.modifiers + +import androidx.compose.foundation.layout.padding +import androidx.compose.ui.Modifier +import androidx.compose.ui.composed +import com.adammcneilly.pocketleague.shared.app.ui.theme.PocketLeagueTheme + +/** + * A custom [Modifier] that applies the screen padding as the horizontal + * padding for some component. + * + * This is useful for situations like the feed screen, where the content is shown + * in a lazy list, and we don't want to apply horizontal padding to the list itself + * since that will cut off any carousels on the screen. + */ +fun Modifier.screenHorizontalPadding() = + composed { + this.padding( + horizontal = PocketLeagueTheme.sizes.screenPadding, + ) + } diff --git a/shared/app/src/commonMain/kotlin/com/adammcneilly/pocketleague/shared/app/ui/placeholder/Placeholder.kt b/shared/app/src/commonMain/kotlin/com/adammcneilly/pocketleague/shared/app/ui/placeholder/Placeholder.kt new file mode 100644 index 00000000..4d4e9a8e --- /dev/null +++ b/shared/app/src/commonMain/kotlin/com/adammcneilly/pocketleague/shared/app/ui/placeholder/Placeholder.kt @@ -0,0 +1,279 @@ +@file:Suppress("MagicNumber") + +package com.adammcneilly.pocketleague.shared.app.ui.placeholder + +import androidx.compose.animation.core.FiniteAnimationSpec +import androidx.compose.animation.core.MutableTransitionState +import androidx.compose.animation.core.Transition +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.spring +import androidx.compose.animation.core.updateTransition +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.composed +import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.geometry.toRect +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Outline +import androidx.compose.ui.graphics.Paint +import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.graphics.drawOutline +import androidx.compose.ui.graphics.drawscope.DrawScope +import androidx.compose.ui.graphics.drawscope.drawIntoCanvas +import androidx.compose.ui.graphics.isSpecified +import androidx.compose.ui.node.Ref +import androidx.compose.ui.platform.debugInspectorInfo +import androidx.compose.ui.unit.LayoutDirection + +/** + * Draws some skeleton UI which is typically used whilst content is 'loading'. + * + * A version of this modifier which uses appropriate values for Material themed apps is available + * in the 'Placeholder Material' library. + * + * You can provide a [PlaceholderHighlight] which runs an highlight animation on the placeholder. + * The [shimmer] and [fade] implementations are provided for easy usage. + * + * A cross-fade transition will be applied to the content and placeholder UI when the [visible] + * value changes. The transition can be customized via the [contentFadeTransitionSpec] and + * [placeholderFadeTransitionSpec] parameters. + * + * You can find more information on the pattern at the Material Theming + * [Placeholder UI](https://material.io/design/communication/launch-screen.html#placeholder-ui) + * guidelines. + * + * @param visible whether the placeholder should be visible or not. + * @param color the color used to draw the placeholder UI. + * @param shape desired shape of the placeholder. Defaults to [RectangleShape]. + * @param highlight optional highlight animation. + * @param placeholderFadeTransitionSpec The transition spec to use when fading the placeholder + * on/off screen. The boolean parameter defined for the transition is [visible]. + * @param contentFadeTransitionSpec The transition spec to use when fading the content + * on/off screen. The boolean parameter defined for the transition is [visible]. + */ +@Suppress("LongMethod", "LongParameterList") +private fun Modifier.placeholder( + visible: Boolean, + color: Color, + shape: Shape = RectangleShape, + highlight: PlaceholderHighlight? = null, + placeholderFadeTransitionSpec: @Composable Transition.Segment.() -> FiniteAnimationSpec = { + spring() + }, + contentFadeTransitionSpec: @Composable Transition.Segment.() -> FiniteAnimationSpec = { + spring() + }, +): Modifier = + composed( + inspectorInfo = debugInspectorInfo { + name = "placeholder" + value = visible + properties["visible"] = visible + properties["color"] = color + properties["highlight"] = highlight + properties["shape"] = shape + }, + ) { + // Values used for caching purposes + val lastSize = remember { Ref() } + val lastLayoutDirection = remember { Ref() } + val lastOutline = remember { Ref() } + + // The current highlight animation progress + var highlightProgress: Float by remember { mutableStateOf(0f) } + + // This is our crossfade transition + val transitionState = remember { MutableTransitionState(visible) }.apply { + targetState = visible + } + val transition = updateTransition(transitionState, "placeholder_crossfade") + + val placeholderAlpha by transition.animateFloat( + transitionSpec = placeholderFadeTransitionSpec, + label = "placeholder_fade", + targetValueByState = { placeholderVisible -> if (placeholderVisible) 1f else 0f }, + ) + val contentAlpha by transition.animateFloat( + transitionSpec = contentFadeTransitionSpec, + label = "content_fade", + targetValueByState = { placeholderVisible -> if (placeholderVisible) 0f else 1f }, + ) + + // Run the optional animation spec and update the progress if the placeholder is visible + val animationSpec = highlight?.animationSpec + if (animationSpec != null && (visible || placeholderAlpha >= 0.01f)) { + val infiniteTransition = rememberInfiniteTransition() + highlightProgress = infiniteTransition.animateFloat( + initialValue = 0f, + targetValue = 1f, + animationSpec = animationSpec, + ).value + } + + val paint = remember { Paint() } + remember(color, shape, highlight) { + drawWithContent { + // Draw the composable content first + if (contentAlpha in 0.01f..0.99f) { + // If the content alpha is between 1% and 99%, draw it in a layer with + // the alpha applied + paint.alpha = contentAlpha + withLayer(paint) { + with(this@drawWithContent) { + drawContent() + } + } + } else if (contentAlpha >= 0.99f) { + // If the content alpha is > 99%, draw it with no alpha + drawContent() + } + + if (placeholderAlpha in 0.01f..0.99f) { + // If the placeholder alpha is between 1% and 99%, draw it in a layer with + // the alpha applied + paint.alpha = placeholderAlpha + withLayer(paint) { + lastOutline.value = drawPlaceholder( + shape = shape, + color = color, + highlight = highlight, + progress = highlightProgress, + lastOutline = lastOutline.value, + lastLayoutDirection = lastLayoutDirection.value, + lastSize = lastSize.value, + ) + } + } else if (placeholderAlpha >= 0.99f) { + // If the placeholder alpha is > 99%, draw it with no alpha + lastOutline.value = drawPlaceholder( + shape = shape, + color = color, + highlight = highlight, + progress = highlightProgress, + lastOutline = lastOutline.value, + lastLayoutDirection = lastLayoutDirection.value, + lastSize = lastSize.value, + ) + } + + // Keep track of the last size & layout direction + lastSize.value = size + lastLayoutDirection.value = layoutDirection + } + } + } + +@Suppress("LongParameterList") +private fun DrawScope.drawPlaceholder( + shape: Shape, + color: Color, + highlight: PlaceholderHighlight?, + progress: Float, + lastOutline: Outline?, + lastLayoutDirection: LayoutDirection?, + lastSize: Size?, +): Outline? { + // shortcut to avoid Outline calculation and allocation + if (shape === RectangleShape) { + // Draw the initial background color + drawRect(color = color) + + if (highlight != null) { + drawRect( + brush = highlight.brush(progress, size), + alpha = highlight.alpha(progress), + ) + } + // We didn't create an outline so return null + return null + } + + // Otherwise we need to create an outline from the shape + val outline = lastOutline.takeIf { + size == lastSize && layoutDirection == lastLayoutDirection + } ?: shape.createOutline(size, layoutDirection, this) + + // Draw the placeholder color + drawOutline(outline = outline, color = color) + + if (highlight != null) { + drawOutline( + outline = outline, + brush = highlight.brush(progress, size), + alpha = highlight.alpha(progress), + ) + } + + // Return the outline we used + return outline +} + +private inline fun DrawScope.withLayer( + paint: Paint, + drawBlock: DrawScope.() -> Unit, +) = drawIntoCanvas { canvas -> + canvas.saveLayer(size.toRect(), paint) + drawBlock() + canvas.restore() +} + +/** + * Draws some skeleton UI which is typically used whilst content is 'loading'. + * + * To customize the color and shape of the placeholder, you can use the foundation version of + * [Modifier.placeholder], along with the values provided by [PlaceholderDefaults]. + * + * A cross-fade transition will be applied to the content and placeholder UI when the [visible] + * value changes. The transition can be customized via the [contentFadeTransitionSpec] and + * [placeholderFadeTransitionSpec] parameters. + * + * You can provide a [PlaceholderHighlight] which runs an highlight animation on the placeholder. + * The [shimmer] and [fade] implementations are provided for easy usage. + * + * You can find more information on the pattern at the Material Theming + * [Placeholder UI](https://material.io/design/communication/launch-screen.html#placeholder-ui) + * guidelines. + * + * @param visible whether the placeholder should be visible or not. + * @param color the color used to draw the placeholder UI. If [Color.Unspecified] is provided, + * the placeholder will use [PlaceholderDefaults.color]. + * @param shape desired shape of the placeholder. If null is provided the placeholder + * will use the small shape set in [MaterialTheme.shapes]. + * @param highlight optional highlight animation. + * @param placeholderFadeTransitionSpec The transition spec to use when fading the placeholder + * on/off screen. The boolean parameter defined for the transition is [visible]. + * @param contentFadeTransitionSpec The transition spec to use when fading the content + * on/off screen. The boolean parameter defined for the transition is [visible]. + */ +@Composable +fun Modifier.placeholderMaterial( + visible: Boolean, + color: Color = Color.Unspecified, + shape: Shape = CircleShape, + highlight: PlaceholderHighlight = PlaceholderHighlight.shimmer(), + placeholderFadeTransitionSpec: @Composable Transition.Segment.() -> FiniteAnimationSpec = { + spring() + }, + contentFadeTransitionSpec: @Composable Transition.Segment.() -> FiniteAnimationSpec = { + spring() + }, +): Modifier = + composed { + Modifier.placeholder( + visible = visible, + color = if (color.isSpecified) color else PlaceholderDefaults.color(), + shape = shape, + highlight = highlight, + placeholderFadeTransitionSpec = placeholderFadeTransitionSpec, + contentFadeTransitionSpec = contentFadeTransitionSpec, + ) + } diff --git a/shared/app/src/commonMain/kotlin/com/adammcneilly/pocketleague/shared/app/ui/placeholder/PlaceholderDefaults.kt b/shared/app/src/commonMain/kotlin/com/adammcneilly/pocketleague/shared/app/ui/placeholder/PlaceholderDefaults.kt new file mode 100644 index 00000000..07ad90c4 --- /dev/null +++ b/shared/app/src/commonMain/kotlin/com/adammcneilly/pocketleague/shared/app/ui/placeholder/PlaceholderDefaults.kt @@ -0,0 +1,93 @@ +package com.adammcneilly.pocketleague.shared.app.ui.placeholder + +import androidx.compose.animation.core.InfiniteRepeatableSpec +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.tween +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.contentColorFor +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.compositeOver + +/** + * Contains default values used by [Modifier.placeholder] and [PlaceholderHighlight]. + */ +object PlaceholderDefaults { + /** + * The default [InfiniteRepeatableSpec] to use for [fade]. + */ + val fadeAnimationSpec: InfiniteRepeatableSpec by lazy { + infiniteRepeatable( + animation = tween(delayMillis = 200, durationMillis = 600), + repeatMode = RepeatMode.Reverse, + ) + } + + /** + * The default [InfiniteRepeatableSpec] to use for [shimmer]. + */ + val shimmerAnimationSpec: InfiniteRepeatableSpec by lazy { + infiniteRepeatable( + animation = tween(durationMillis = 1700, delayMillis = 200), + repeatMode = RepeatMode.Restart, + ) + } + + /** + * Returns the value used as the the `color` parameter value on [Modifier.placeholder]. + * + * @param backgroundColor The current background color of the layout. Defaults to + * `MaterialTheme.colors.surface`. + * @param contentColor The content color to be used on top of [backgroundColor]. + * @param contentAlpha The alpha component to set on [contentColor] when compositing the color + * on top of [backgroundColor]. Defaults to `0.1f`. + */ + @Composable + fun color( + backgroundColor: Color = MaterialTheme.colorScheme.surface, + contentColor: Color = contentColorFor(backgroundColor), + contentAlpha: Float = 0.1f, + ): Color = contentColor.copy(contentAlpha).compositeOver(backgroundColor) + + /** + * Proxies into [color] for a card component, which uses [androidx.compose.material3.ColorScheme.surfaceVariant]. + */ + @Composable + fun cardColor(): Color { + return color( + backgroundColor = MaterialTheme.colorScheme.surfaceVariant, + ) + } + + /** + * Returns the value used as the the `highlightColor` parameter value of + * [PlaceholderHighlight.Companion.fade]. + * + * @param backgroundColor The current background color of the layout. Defaults to + * `MaterialTheme.colors.surface`. + * @param alpha The alpha component to set on [backgroundColor]. Defaults to `0.3f`. + */ + @Composable + fun fadeHighlightColor( + backgroundColor: Color = MaterialTheme.colorScheme.surface, + alpha: Float = 0.3f, + ): Color = backgroundColor.copy(alpha = alpha) + + /** + * Returns the value used as the the `highlightColor` parameter value of + * [PlaceholderHighlight.Companion.shimmer]. + * + * @param backgroundColor The current background color of the layout. Defaults to + * `MaterialTheme.colors.surface`. + * @param alpha The alpha component to set on [backgroundColor]. Defaults to `0.75f`. + */ + @Composable + fun shimmerHighlightColor( + backgroundColor: Color = MaterialTheme.colorScheme.surface, + alpha: Float = 0.75f, + ): Color { + return backgroundColor.copy(alpha = alpha) + } +} diff --git a/shared/app/src/commonMain/kotlin/com/adammcneilly/pocketleague/shared/app/ui/placeholder/PlaceholderHighlight.kt b/shared/app/src/commonMain/kotlin/com/adammcneilly/pocketleague/shared/app/ui/placeholder/PlaceholderHighlight.kt new file mode 100644 index 00000000..828cfa39 --- /dev/null +++ b/shared/app/src/commonMain/kotlin/com/adammcneilly/pocketleague/shared/app/ui/placeholder/PlaceholderHighlight.kt @@ -0,0 +1,188 @@ +@file:Suppress("MagicNumber") + +package com.adammcneilly.pocketleague.shared.app.ui.placeholder + +import androidx.compose.animation.core.InfiniteRepeatableSpec +import androidx.compose.runtime.Composable +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import kotlin.math.max + +/** + * A class which provides a brush to paint placeholder based on progress. + */ +interface PlaceholderHighlight { + /** + * The optional [AnimationSpec] to use when running the animation for this highlight. + */ + val animationSpec: InfiniteRepeatableSpec? + + /** + * Return a [Brush] to draw for the given [progress] and [size]. + * + * @param progress the current animated progress in the range of 0f..1f. + * @param size The size of the current layout to draw in. + */ + fun brush( + progress: Float, + size: Size, + ): Brush + + /** + * Return the desired alpha value used for drawing the [Brush] returned from [brush]. + * + * @param progress the current animated progress in the range of 0f..1f. + */ + fun alpha( + progress: Float, + ): Float + + companion object +} + +/** + * Creates a [Fade] brush with the given initial and target colors. + * + * @param highlightColor the color of the highlight which is faded in/out. + * @param animationSpec the [AnimationSpec] to configure the animation. + */ +fun PlaceholderHighlight.Companion.fade( + highlightColor: Color, + animationSpec: InfiniteRepeatableSpec = PlaceholderDefaults.fadeAnimationSpec, +): PlaceholderHighlight = + Fade( + highlightColor = highlightColor, + animationSpec = animationSpec, + ) + +/** + * Creates a [PlaceholderHighlight] which 'shimmers', using the given [highlightColor]. + * + * The highlight starts at the top-start, and then grows to the bottom-end during the animation. + * During that time it is also faded in, from 0f..progressForMaxAlpha, and then faded out from + * progressForMaxAlpha..1f. + * + * @param highlightColor the color of the highlight 'shimmer'. + * @param animationSpec the [AnimationSpec] to configure the animation. + * @param progressForMaxAlpha The progress where the shimmer should be at it's peak opacity. + * Defaults to 0.6f. + */ +fun PlaceholderHighlight.Companion.shimmer( + highlightColor: Color, + animationSpec: InfiniteRepeatableSpec = PlaceholderDefaults.shimmerAnimationSpec, + progressForMaxAlpha: Float = 0.6f, +): PlaceholderHighlight = + Shimmer( + highlightColor = highlightColor, + animationSpec = animationSpec, + progressForMaxAlpha = progressForMaxAlpha, + ) + +private data class Fade( + private val highlightColor: Color, + override val animationSpec: InfiniteRepeatableSpec, +) : PlaceholderHighlight { + private val brush = SolidColor(highlightColor) + + override fun brush( + progress: Float, + size: Size, + ): Brush = brush + + override fun alpha( + progress: Float, + ): Float = progress +} + +private data class Shimmer( + private val highlightColor: Color, + override val animationSpec: InfiniteRepeatableSpec, + private val progressForMaxAlpha: Float = 0.6f, +) : PlaceholderHighlight { + override fun brush( + progress: Float, + size: Size, + ): Brush = + Brush.radialGradient( + colors = listOf( + highlightColor.copy(alpha = 0f), + highlightColor, + highlightColor.copy(alpha = 0f), + ), + center = Offset(x = 0f, y = 0f), + radius = (max(size.width, size.height) * progress * 2).coerceAtLeast(0.01f), + ) + + override fun alpha( + progress: Float, + ): Float = + when { + // From 0f...ProgressForOpaqueAlpha we animate from 0..1 + progress <= progressForMaxAlpha -> { + lerp( + start = 0f, + stop = 1f, + fraction = progress / progressForMaxAlpha, + ) + } + // From ProgressForOpaqueAlpha..1f we animate from 1..0 + else -> { + lerp( + start = 1f, + stop = 0f, + fraction = (progress - progressForMaxAlpha) / (1f - progressForMaxAlpha), + ) + } + } +} + +/** + * Creates a [PlaceholderHighlight] which fades in an appropriate color, using the + * given [animationSpec]. + * + * @param animationSpec the [AnimationSpec] to configure the animation. + */ +@Composable +fun PlaceholderHighlight.Companion.fade( + animationSpec: InfiniteRepeatableSpec = PlaceholderDefaults.fadeAnimationSpec, +): PlaceholderHighlight = + PlaceholderHighlight.fade( + highlightColor = PlaceholderDefaults.fadeHighlightColor(), + animationSpec = animationSpec, + ) + +/** + * Creates a [PlaceholderHighlight] which 'shimmers', using a default color. + * + * The highlight starts at the top-start, and then grows to the bottom-end during the animation. + * During that time it is also faded in, from 0f..progressForMaxAlpha, and then faded out from + * progressForMaxAlpha..1f. + * + * @param animationSpec the [AnimationSpec] to configure the animation. + * @param progressForMaxAlpha The progress where the shimmer should be at it's peak opacity. + * Defaults to 0.6f. + */ +@Composable +fun PlaceholderHighlight.Companion.shimmer( + animationSpec: InfiniteRepeatableSpec = PlaceholderDefaults.shimmerAnimationSpec, + progressForMaxAlpha: Float = 0.6f, +): PlaceholderHighlight = + PlaceholderHighlight.shimmer( + highlightColor = PlaceholderDefaults.shimmerHighlightColor(), + animationSpec = animationSpec, + progressForMaxAlpha = progressForMaxAlpha, + ) + +/** + * Linearly interpolate between [start] and [stop] with [fraction] fraction between them. + */ +private fun lerp( + start: Float, + stop: Float, + fraction: Float, +): Float { + return (1 - fraction) * start + fraction * stop +} diff --git a/shared/app/src/commonMain/kotlin/com/adammcneilly/pocketleague/shared/app/ui/theme/Color.kt b/shared/app/src/commonMain/kotlin/com/adammcneilly/pocketleague/shared/app/ui/theme/Color.kt new file mode 100644 index 00000000..30068cb7 --- /dev/null +++ b/shared/app/src/commonMain/kotlin/com/adammcneilly/pocketleague/shared/app/ui/theme/Color.kt @@ -0,0 +1,67 @@ +@file:Suppress("MagicNumber") + +package com.adammcneilly.pocketleague.shared.app.ui.theme + +import androidx.compose.ui.graphics.Color + +val md_theme_light_primary = Color(0xFF3d5ba9) +val md_theme_light_onPrimary = Color(0xFFffffff) +val md_theme_light_primaryContainer = Color(0xFFdae2ff) +val md_theme_light_onPrimaryContainer = Color(0xFF00174d) +val md_theme_light_secondary = Color(0xFF585e71) +val md_theme_light_onSecondary = Color(0xFFffffff) +val md_theme_light_secondaryContainer = Color(0xFFdce1f9) +val md_theme_light_onSecondaryContainer = Color(0xFF151b2c) +val md_theme_light_tertiary = Color(0xFF735470) +val md_theme_light_onTertiary = Color(0xFFffffff) +val md_theme_light_tertiaryContainer = Color(0xFFffd6f9) +val md_theme_light_onTertiaryContainer = Color(0xFF2b122b) +val md_theme_light_error = Color(0xFFba1b1b) +val md_theme_light_errorContainer = Color(0xFFffdad4) +val md_theme_light_onError = Color(0xFFffffff) +val md_theme_light_onErrorContainer = Color(0xFF410001) +val md_theme_light_background = Color(0xFFfefbff) +val md_theme_light_onBackground = Color(0xFF1b1b1f) +val md_theme_light_surface = Color(0xFFfefbff) +val md_theme_light_onSurface = Color(0xFF1b1b1f) +val md_theme_light_surfaceVariant = Color(0xFFe2e2ec) +val md_theme_light_onSurfaceVariant = Color(0xFF44464e) +val md_theme_light_outline = Color(0xFF75767f) +val md_theme_light_inverseOnSurface = Color(0xFFf2f0f5) +val md_theme_light_inverseSurface = Color(0xFF303033) +val md_theme_light_inversePrimary = Color(0xFFb1c5ff) +val md_theme_light_shadow = Color(0xFF000000) + +val md_theme_dark_primary = Color(0xFFb1c5ff) +val md_theme_dark_onPrimary = Color(0xFF002a78) +val md_theme_dark_primaryContainer = Color(0xFF214290) +val md_theme_dark_onPrimaryContainer = Color(0xFFdae2ff) +val md_theme_dark_secondary = Color(0xFFc1c6dd) +val md_theme_dark_onSecondary = Color(0xFF2a3042) +val md_theme_dark_secondaryContainer = Color(0xFF414659) +val md_theme_dark_onSecondaryContainer = Color(0xFFdce1f9) +val md_theme_dark_tertiary = Color(0xFFe1bbdc) +val md_theme_dark_onTertiary = Color(0xFF412740) +val md_theme_dark_tertiaryContainer = Color(0xFF5a3d58) +val md_theme_dark_onTertiaryContainer = Color(0xFFffd6f9) +val md_theme_dark_error = Color(0xFFffb4a9) +val md_theme_dark_errorContainer = Color(0xFF930006) +val md_theme_dark_onError = Color(0xFF680003) +val md_theme_dark_onErrorContainer = Color(0xFFffdad4) +val md_theme_dark_background = Color(0xFF1b1b1f) +val md_theme_dark_onBackground = Color(0xFFe3e1e6) +val md_theme_dark_surface = Color(0xFF1b1b1f) +val md_theme_dark_onSurface = Color(0xFFe3e1e6) +val md_theme_dark_surfaceVariant = Color(0xFF44464e) +val md_theme_dark_onSurfaceVariant = Color(0xFFc6c6d0) +val md_theme_dark_outline = Color(0xFF8f909a) +val md_theme_dark_inverseOnSurface = Color(0xFF1b1b1f) +val md_theme_dark_inverseSurface = Color(0xFFe3e1e6) +val md_theme_dark_inversePrimary = Color(0xFF3d5ba9) +val md_theme_dark_shadow = Color(0xFF000000) + +val rlcsBlue = Color(0xFF0069c1) +val rlcsOrange = Color(0xFFee7d13) + +val seed = Color(0xFF3d5ba9) +val error = Color(0xFFba1b1b) diff --git a/shared/app/src/commonMain/kotlin/com/adammcneilly/pocketleague/shared/app/ui/theme/Sizes.kt b/shared/app/src/commonMain/kotlin/com/adammcneilly/pocketleague/shared/app/ui/theme/Sizes.kt new file mode 100644 index 00000000..5e7a3c47 --- /dev/null +++ b/shared/app/src/commonMain/kotlin/com/adammcneilly/pocketleague/shared/app/ui/theme/Sizes.kt @@ -0,0 +1,33 @@ +package com.adammcneilly.pocketleague.shared.app.ui.theme + +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp + +/** + * Special size properties that are used within the Pocket League application. + * + * @property[screenPadding] The padding between a screen component and the edge of the device + * screen. + * @property[listItemSpacing] The default spacing between items inside of a list. + * @property[cardPadding] The spacing between text/components and the edge of a card component. + * @property[textSpacing] When two text components are placed on top of each other in a column, + * we may want to apply this so they don't seem too cramped. + */ +@Immutable +data class Sizes( + val screenPadding: Dp, + val listItemSpacing: Dp, + val cardPadding: Dp, + val textSpacing: Dp, +) + +val LocalPocketLeagueSizes = staticCompositionLocalOf { + Sizes( + screenPadding = 0.dp, + listItemSpacing = 0.dp, + cardPadding = 0.dp, + textSpacing = 0.dp, + ) +} diff --git a/shared/app/src/commonMain/kotlin/com/adammcneilly/pocketleague/shared/app/ui/theme/Theme.kt b/shared/app/src/commonMain/kotlin/com/adammcneilly/pocketleague/shared/app/ui/theme/Theme.kt new file mode 100644 index 00000000..eeebc6c0 --- /dev/null +++ b/shared/app/src/commonMain/kotlin/com/adammcneilly/pocketleague/shared/app/ui/theme/Theme.kt @@ -0,0 +1,111 @@ +package com.adammcneilly.pocketleague.shared.app.ui.theme + +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.unit.dp + +private val LightColorScheme = lightColorScheme( + primary = md_theme_light_primary, + onPrimary = md_theme_light_onPrimary, + secondary = md_theme_light_secondary, + onSecondary = md_theme_light_onSecondary, + error = md_theme_light_error, + onError = md_theme_light_onError, + background = md_theme_light_background, + onBackground = md_theme_light_onBackground, + surface = md_theme_light_surface, + onSurface = md_theme_light_onSurface, + primaryContainer = md_theme_light_primaryContainer, + onPrimaryContainer = md_theme_light_onPrimaryContainer, + inversePrimary = md_theme_light_inversePrimary, + secondaryContainer = md_theme_light_secondaryContainer, + onSecondaryContainer = md_theme_light_onSecondaryContainer, + tertiary = md_theme_light_tertiary, + onTertiary = md_theme_light_onTertiary, + tertiaryContainer = md_theme_light_tertiaryContainer, + onTertiaryContainer = md_theme_light_onTertiaryContainer, + surfaceVariant = md_theme_light_surfaceVariant, + onSurfaceVariant = md_theme_light_onSurfaceVariant, + inverseSurface = md_theme_light_inverseSurface, + inverseOnSurface = md_theme_light_inverseOnSurface, + errorContainer = md_theme_light_errorContainer, + onErrorContainer = md_theme_light_onErrorContainer, + outline = md_theme_light_outline, + outlineVariant = md_theme_light_outline, +) + +private val DarkColorScheme = darkColorScheme( + primary = md_theme_dark_primary, + onPrimary = md_theme_dark_onPrimary, + secondary = md_theme_dark_secondary, + onSecondary = md_theme_dark_onSecondary, + error = md_theme_dark_error, + onError = md_theme_dark_onError, + background = md_theme_dark_background, + onBackground = md_theme_dark_onBackground, + surface = md_theme_dark_surface, + onSurface = md_theme_dark_onSurface, + primaryContainer = md_theme_dark_primaryContainer, + onPrimaryContainer = md_theme_dark_onPrimaryContainer, + inversePrimary = md_theme_dark_inversePrimary, + secondaryContainer = md_theme_dark_secondaryContainer, + onSecondaryContainer = md_theme_dark_onSecondaryContainer, + tertiary = md_theme_dark_tertiary, + onTertiary = md_theme_dark_onTertiary, + tertiaryContainer = md_theme_dark_tertiaryContainer, + onTertiaryContainer = md_theme_dark_onTertiaryContainer, + surfaceVariant = md_theme_dark_surfaceVariant, + onSurfaceVariant = md_theme_dark_onSurfaceVariant, + inverseSurface = md_theme_dark_inverseSurface, + inverseOnSurface = md_theme_dark_inverseOnSurface, + errorContainer = md_theme_dark_errorContainer, + onErrorContainer = md_theme_dark_onErrorContainer, + outline = md_theme_dark_outline, + outlineVariant = md_theme_dark_outline, +) + +/** + * Creates our custom [MaterialTheme] for the pocket league application. + */ +@Composable +fun PocketLeagueTheme( + useDarkTheme: Boolean = isSystemInDarkTheme(), + content: @Composable () -> Unit, +) { + val colorScheme = if (!useDarkTheme) { + LightColorScheme + } else { + DarkColorScheme + } + + val sizes = Sizes( + screenPadding = 16.dp, + listItemSpacing = 16.dp, + cardPadding = 16.dp, + textSpacing = 4.dp, + ) + + CompositionLocalProvider( + LocalPocketLeagueSizes provides sizes, + ) { + MaterialTheme( + colorScheme = colorScheme, + content = content, + ) + } +} + +/** + * Wrapper object for special theme properties used in the Pocket League design system. + * + * Inspiration: https://developer.android.com/jetpack/compose/designsystems/custom#extending-material + */ +object PocketLeagueTheme { + val sizes: Sizes + @Composable + get() = LocalPocketLeagueSizes.current +}