diff --git a/.gitignore b/.gitignore index 32ca72bb..9ff68fb9 100644 --- a/.gitignore +++ b/.gitignore @@ -47,3 +47,5 @@ RobotConf/OpenFeedback-Info.plist RobotConf/GoogleService-Info.plist apollo-ios-cli *.xcuserstate + +firebase-debug.log diff --git a/shared/data/build.gradle.kts b/shared/data/build.gradle.kts index 3385d003..6b084846 100644 --- a/shared/data/build.gradle.kts +++ b/shared/data/build.gradle.kts @@ -51,6 +51,8 @@ apollo { @OptIn(ApolloExperimental::class) generateDataBuilders.set(true) mapScalar("GraphQLLocalDateTime", "kotlinx.datetime.LocalDateTime", "com.apollographql.adapter.datetime.KotlinxLocalDateTimeAdapter") + mapScalar("GraphQLInstant", "kotlinx.datetime.Instant", "fr.androidmakers.store.graphql.KotlinxInstantAdapter") + mapScalarToKotlinString("Markdown") @OptIn(ApolloExperimental::class) plugin("com.apollographql.cache:normalized-cache-apollo-compiler-plugin:${libs.versions.apollo.cache.get()}") { diff --git a/shared/data/src/commonMain/graphql/feed.graphql b/shared/data/src/commonMain/graphql/feed.graphql new file mode 100644 index 00000000..6c35d268 --- /dev/null +++ b/shared/data/src/commonMain/graphql/feed.graphql @@ -0,0 +1,15 @@ +query GetFeedMessages { + feedItemsConnection(first: 1000) { + nodes { + ...FeedMessageDetails + } + } +} + +fragment FeedMessageDetails on FeedItem { + id + type + title + body + createdAt +} diff --git a/shared/data/src/commonMain/graphql/schema.graphqls b/shared/data/src/commonMain/graphql/schema.graphqls index 1161806c..abdb1f74 100644 --- a/shared/data/src/commonMain/graphql/schema.graphqls +++ b/shared/data/src/commonMain/graphql/schema.graphqls @@ -162,6 +162,14 @@ enum ConferenceField { DAYS } +enum FeedItemType { + INFO + + ALERT + + ANNOUNCEMENT +} + enum LinkType { YouTube @@ -207,11 +215,13 @@ type FeatureFlags { type FeedItem { id: ID! + type: FeedItemType! + createdAt: GraphQLInstant! title: String! - markdown: Markdown! + body: Markdown! } type FeedItemFailure { @@ -463,7 +473,7 @@ input ConferenceOrderBy { input FeedItemInput { title: String - markdown: Markdown + body: Markdown } input SessionOrderBy { diff --git a/shared/data/src/commonMain/kotlin/fr/androidmakers/store/graphql/ApolloClient.kt b/shared/data/src/commonMain/kotlin/fr/androidmakers/store/graphql/ApolloClient.kt index 36eb4805..2a8ec1a2 100644 --- a/shared/data/src/commonMain/kotlin/fr/androidmakers/store/graphql/ApolloClient.kt +++ b/shared/data/src/commonMain/kotlin/fr/androidmakers/store/graphql/ApolloClient.kt @@ -5,6 +5,7 @@ import com.apollographql.apollo.api.http.HttpRequest import com.apollographql.apollo.api.http.HttpResponse import com.apollographql.apollo.network.http.HttpInterceptor import com.apollographql.apollo.network.http.HttpInterceptorChain +import com.apollographql.apollo.network.ws.WebSocketNetworkTransport import com.apollographql.cache.normalized.api.NormalizedCacheFactory import com.apollographql.cache.normalized.memory.MemoryCacheFactory import fr.androidmakers.domain.repo.UserRepository @@ -24,9 +25,6 @@ fun ApolloClient( return chain.proceed( request.newBuilder() .apply { - /** - * - */ val token = getIdToken(userRepository) if (token != null) { addHeader("Authorization", "Bearer $token") diff --git a/shared/data/src/commonMain/kotlin/fr/androidmakers/store/graphql/FeedGraphQLRepository.kt b/shared/data/src/commonMain/kotlin/fr/androidmakers/store/graphql/FeedGraphQLRepository.kt new file mode 100644 index 00000000..df631728 --- /dev/null +++ b/shared/data/src/commonMain/kotlin/fr/androidmakers/store/graphql/FeedGraphQLRepository.kt @@ -0,0 +1,22 @@ +package fr.androidmakers.store.graphql + +import com.apollographql.apollo.ApolloClient +import fr.androidmakers.domain.model.FeedItem +import fr.androidmakers.domain.repo.FeedRepository +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.mapNotNull +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.merge +import kotlinx.coroutines.flow.scan + +class FeedGraphQLRepository( + private val apolloClient: ApolloClient, +) : FeedRepository { + + override fun getFeedItems(): Flow>> { + return apolloClient.query(GetFeedMessagesQuery()) + .cacheAndNetwork() + .map { result -> result.map { data -> data.feedItemsConnection.nodes.map { it.feedMessageDetails.toFeedItem() } } } + } +} diff --git a/shared/data/src/commonMain/kotlin/fr/androidmakers/store/graphql/FeedMappers.kt b/shared/data/src/commonMain/kotlin/fr/androidmakers/store/graphql/FeedMappers.kt new file mode 100644 index 00000000..719ab58d --- /dev/null +++ b/shared/data/src/commonMain/kotlin/fr/androidmakers/store/graphql/FeedMappers.kt @@ -0,0 +1,26 @@ +package fr.androidmakers.store.graphql + +import fr.androidmakers.domain.model.FeedItem +import fr.androidmakers.domain.model.MessageType +import fr.androidmakers.store.graphql.fragment.FeedMessageDetails +import fr.androidmakers.store.graphql.type.FeedItemType + +fun FeedMessageDetails.toFeedItem(): FeedItem { + return when (type) { + FeedItemType.ALERT -> FeedItem.Alert( + id = id, + title = title, + message = body, + ) + else -> FeedItem.Message( + id = id, + type = when (type) { + FeedItemType.ANNOUNCEMENT -> MessageType.ANNOUNCEMENT + else -> MessageType.INFO + }, + title = title, + body = body, + createdAt = createdAt, + ) + } +} diff --git a/shared/data/src/commonMain/kotlin/fr/androidmakers/store/graphql/KotlinxInstantAdapter.kt b/shared/data/src/commonMain/kotlin/fr/androidmakers/store/graphql/KotlinxInstantAdapter.kt new file mode 100644 index 00000000..ed8fc819 --- /dev/null +++ b/shared/data/src/commonMain/kotlin/fr/androidmakers/store/graphql/KotlinxInstantAdapter.kt @@ -0,0 +1,18 @@ +package fr.androidmakers.store.graphql + +import com.apollographql.apollo.api.Adapter +import com.apollographql.apollo.api.CustomScalarAdapters +import com.apollographql.apollo.api.json.JsonReader +import com.apollographql.apollo.api.json.JsonWriter +import kotlinx.datetime.Instant + +val KotlinxInstantAdapter = object : Adapter { + override fun fromJson(reader: JsonReader, customScalarAdapters: CustomScalarAdapters): Instant { + val str = reader.nextString() ?: throw IllegalStateException("Expected non-null Instant string") + return Instant.parse(str) + } + + override fun toJson(writer: JsonWriter, customScalarAdapters: CustomScalarAdapters, value: Instant) { + writer.value(value.toString()) + } +} diff --git a/shared/data/src/commonMain/kotlin/fr/androidmakers/store/mock/MockFeedRepository.kt b/shared/data/src/commonMain/kotlin/fr/androidmakers/store/mock/MockFeedRepository.kt index 5acda825..e542f51a 100644 --- a/shared/data/src/commonMain/kotlin/fr/androidmakers/store/mock/MockFeedRepository.kt +++ b/shared/data/src/commonMain/kotlin/fr/androidmakers/store/mock/MockFeedRepository.kt @@ -1,13 +1,17 @@ package fr.androidmakers.store.mock import fr.androidmakers.domain.model.FeedItem -import fr.androidmakers.domain.model.LocationInfo +import fr.androidmakers.domain.model.MessageType import fr.androidmakers.domain.repo.FeedRepository import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flowOf +import kotlin.time.Clock +import kotlin.time.Duration.Companion.hours +import kotlin.time.Duration.Companion.days class MockFeedRepository : FeedRepository { override fun getFeedItems(): Flow>> { + val now = Clock.System.now() return flowOf( Result.success( listOf( @@ -16,41 +20,28 @@ class MockFeedRepository : FeedRepository { title = "Room Change Alert", message = "The Kotlin Workshop has moved from Room 3 to Room 7. Please update your schedule accordingly.", ), - FeedItem.Article( - id = "article-1", - category = "KEYNOTE", - timeAgo = "2h ago", + FeedItem.Message( + id = "message-1", + type = MessageType.ANNOUNCEMENT, title = "Opening Keynote: The Future of Android Development", - description = "Join us for an exciting keynote session exploring the latest " + + body = "Join us for an exciting keynote session exploring the latest " + "innovations in Android development, from Compose Multiplatform to AI-powered tools.", - imageUrl = "https://images.unsplash.com/photo-1540575467063-178a50c2df87?w=800", - categoryBadge = "KEYNOTE", - avatarUrls = listOf( - "https://i.pravatar.cc/150?img=1", - "https://i.pravatar.cc/150?img=2", - "https://i.pravatar.cc/150?img=3", - ), - readMoreUrl = "https://androidmakers.droidcon.com", + createdAt = now - 2.hours, ), - FeedItem.Article( - id = "article-2", - category = "EVENT", - timeAgo = "5h ago", + FeedItem.Message( + id = "message-2", + type = MessageType.INFO, title = "After-Hours Party", - description = "Don't miss tonight's networking event with drinks and live music.", - location = LocationInfo( - name = "Le Café des Makers", - time = "7:00 PM - 11:00 PM", - ), + body = "Don't miss tonight's networking event with drinks and live music.", + createdAt = now - 5.hours, ), - FeedItem.Article( - id = "article-3", - category = "ANNOUNCEMENT", - timeAgo = "1d ago", + FeedItem.Message( + id = "message-3", + type = MessageType.ANNOUNCEMENT, title = "Swag Alert: Limited Edition T-Shirts", - description = "Pick up your exclusive Android Makers t-shirt at the registration desk. " + + body = "Pick up your exclusive Android Makers t-shirt at the registration desk. " + "Available while supplies last!", - thumbnailUrl = "https://images.unsplash.com/photo-1521572163474-6864f9cf17ab?w=200", + createdAt = now - 1.days, ), ) ) diff --git a/shared/di/src/commonMain/kotlin/fr/androidmakers/di/DataModule.kt b/shared/di/src/commonMain/kotlin/fr/androidmakers/di/DataModule.kt index e87d3c5f..f2c639ff 100644 --- a/shared/di/src/commonMain/kotlin/fr/androidmakers/di/DataModule.kt +++ b/shared/di/src/commonMain/kotlin/fr/androidmakers/di/DataModule.kt @@ -13,6 +13,7 @@ import fr.androidmakers.domain.repo.UserRepository import fr.androidmakers.domain.repo.VenueRepository import fr.androidmakers.store.firebase.FirebaseUserRepository import fr.androidmakers.store.graphql.FeatureFlagsGraphQLRepository +import fr.androidmakers.store.graphql.FeedGraphQLRepository import fr.androidmakers.store.graphql.PartnersGraphQLRepository import fr.androidmakers.store.graphql.RoomsGraphQLRepository import fr.androidmakers.store.graphql.SessionsGraphQLRepository @@ -20,25 +21,24 @@ import fr.androidmakers.store.graphql.SpeakersGraphQLRepository import fr.androidmakers.store.graphql.VenueGraphQLRepository import fr.androidmakers.store.local.BookmarksDataStoreRepository import fr.androidmakers.store.local.ThemeDataStoreRepository -import fr.androidmakers.store.mock.MockFeedRepository import fr.androidmakers.store.wear.WearMessagingRepository import org.koin.core.module.Module +import org.koin.core.module.dsl.singleOf +import org.koin.dsl.bind import org.koin.dsl.module - expect val dataPlatformModule: Module val dataModule = module { - single { PartnersGraphQLRepository(get()) } - single { RoomsGraphQLRepository(get()) } - single { SessionsGraphQLRepository(get()) } - single { SpeakersGraphQLRepository(get()) } - single { FirebaseUserRepository() } - single { VenueGraphQLRepository(get()) } - single { FeatureFlagsGraphQLRepository(get()) } - - single { BookmarksDataStoreRepository(get()) } - single { ThemeDataStoreRepository(get()) } - single { WearMessagingRepository(get()) } - single { MockFeedRepository() } + singleOf(::BookmarksDataStoreRepository) bind BookmarksRepository::class + singleOf(::FeedGraphQLRepository) bind FeedRepository::class + singleOf(::FirebaseUserRepository) bind UserRepository::class + singleOf(::PartnersGraphQLRepository) bind PartnersRepository::class + singleOf(::RoomsGraphQLRepository) bind RoomsRepository::class + singleOf(::SessionsGraphQLRepository) bind SessionsRepository::class + singleOf(::SpeakersGraphQLRepository) bind SpeakersRepository::class + singleOf(::ThemeDataStoreRepository) bind ThemeRepository::class + singleOf(::VenueGraphQLRepository) bind VenueRepository::class + singleOf(::WearMessagingRepository) bind MessagingRepository::class + singleOf(::FeatureFlagsGraphQLRepository) bind FeatureFlagsRepository::class } diff --git a/shared/domain/src/commonMain/kotlin/fr/androidmakers/domain/model/FeedItem.kt b/shared/domain/src/commonMain/kotlin/fr/androidmakers/domain/model/FeedItem.kt index 8779d2d1..429d2ca9 100644 --- a/shared/domain/src/commonMain/kotlin/fr/androidmakers/domain/model/FeedItem.kt +++ b/shared/domain/src/commonMain/kotlin/fr/androidmakers/domain/model/FeedItem.kt @@ -1,5 +1,7 @@ package fr.androidmakers.domain.model +import kotlinx.datetime.Instant + sealed interface FeedItem { val id: String @@ -9,6 +11,15 @@ sealed interface FeedItem { val message: String, ) : FeedItem + data class Message( + override val id: String, + val type: MessageType, + val title: String, + val body: String, + val createdAt: Instant, + ) : FeedItem + + // Conservé pour les items enrichis côté client (venues, floor plan) data class Article( override val id: String, val category: String, @@ -19,11 +30,15 @@ sealed interface FeedItem { val thumbnailUrl: String? = null, val categoryBadge: String? = null, val location: LocationInfo? = null, - val avatarUrls: List = emptyList(), - val readMoreUrl: String? = null, ) : FeedItem } +enum class MessageType { + INFO, + ALERT, + ANNOUNCEMENT; +} + data class LocationInfo( val name: String, val time: String? = null, diff --git a/shared/ui/src/commonMain/composeResources/values-fr/strings.xml b/shared/ui/src/commonMain/composeResources/values-fr/strings.xml index 5049690a..df98498b 100644 --- a/shared/ui/src/commonMain/composeResources/values-fr/strings.xml +++ b/shared/ui/src/commonMain/composeResources/values-fr/strings.xml @@ -76,7 +76,6 @@ Retirer de mon agenda Fil - Lire la suite Liens Deux jours dédiés à Android à Paris diff --git a/shared/ui/src/commonMain/composeResources/values/strings.xml b/shared/ui/src/commonMain/composeResources/values/strings.xml index 3ab7b9c7..0862ccf3 100644 --- a/shared/ui/src/commonMain/composeResources/values/strings.xml +++ b/shared/ui/src/commonMain/composeResources/values/strings.xml @@ -83,8 +83,7 @@ Remove from My Agenda Feed - Read More - VENUE +VENUE EVENT Floor Plan diff --git a/shared/ui/src/commonMain/kotlin/com/androidmakers/ui/feed/ArticleCards.kt b/shared/ui/src/commonMain/kotlin/com/androidmakers/ui/feed/ArticleCards.kt index f78eb59f..bff99fd0 100644 --- a/shared/ui/src/commonMain/kotlin/com/androidmakers/ui/feed/ArticleCards.kt +++ b/shared/ui/src/commonMain/kotlin/com/androidmakers/ui/feed/ArticleCards.kt @@ -1,18 +1,15 @@ package com.androidmakers.ui.feed import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width -import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.LocationOn @@ -20,7 +17,6 @@ import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text -import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -36,9 +32,6 @@ import com.androidmakers.ui.theme.LocalIsNeobrutalism import com.androidmakers.ui.theme.neoBrutalBorder import com.androidmakers.ui.theme.neoBrutalElevation import fr.androidmakers.domain.model.FeedItem -import fr.paug.androidmakers.ui.Res -import fr.paug.androidmakers.ui.feed_read_more -import org.jetbrains.compose.resources.stringResource @Composable fun CategoryTimeRow( @@ -70,27 +63,6 @@ fun CategoryTimeRow( } } -@Composable -fun OverlappingAvatars( - avatarUrls: List, - modifier: Modifier = Modifier, -) { - val circularShape = if (LocalIsNeobrutalism.current) RectangleShape else CircleShape - Row(modifier = modifier) { - avatarUrls.forEachIndexed { index, url -> - AsyncImage( - model = url, - contentDescription = null, - contentScale = ContentScale.Crop, - modifier = Modifier - .size(28.dp) - .offset(x = (-8 * index).dp) - .clip(circularShape), - ) - } - } -} - @Composable fun ArticleCardWithImage( article: FeedItem.Article, @@ -123,10 +95,6 @@ fun ArticleCardWithImage( overflow = TextOverflow.Ellipsis, modifier = Modifier.padding(top = 4.dp), ) - ArticleCardFooter( - avatarUrls = article.avatarUrls, - readMoreUrl = article.readMoreUrl, - ) } } } @@ -169,35 +137,6 @@ private fun ArticleHeroImage( } } -@Composable -private fun ArticleCardFooter( - avatarUrls: List, - readMoreUrl: String?, -) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(top = 12.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - if (avatarUrls.isNotEmpty()) { - OverlappingAvatars(avatarUrls) - } else { - Spacer(Modifier) - } - if (readMoreUrl != null) { - TextButton(onClick = { }) { - Text( - text = stringResource(Res.string.feed_read_more), - color = MaterialTheme.colorScheme.primary, - fontWeight = FontWeight.SemiBold, - ) - } - } - } -} - @Composable fun ArticleCardWithLocation( article: FeedItem.Article, diff --git a/shared/ui/src/commonMain/kotlin/com/androidmakers/ui/feed/FeedScreen.kt b/shared/ui/src/commonMain/kotlin/com/androidmakers/ui/feed/FeedScreen.kt index bbf219fc..16c37a3d 100644 --- a/shared/ui/src/commonMain/kotlin/com/androidmakers/ui/feed/FeedScreen.kt +++ b/shared/ui/src/commonMain/kotlin/com/androidmakers/ui/feed/FeedScreen.kt @@ -35,6 +35,9 @@ fun FeedScreen() { onDismiss = { viewModel.dismissAlert(item.id) }, ) } + is FeedItem.Message -> { + MessageCard(message = item) + } is FeedItem.Article -> { when { item.imageUrl != null -> ArticleCardWithImage(article = item) diff --git a/shared/ui/src/commonMain/kotlin/com/androidmakers/ui/feed/MessageCard.kt b/shared/ui/src/commonMain/kotlin/com/androidmakers/ui/feed/MessageCard.kt new file mode 100644 index 00000000..238c3a90 --- /dev/null +++ b/shared/ui/src/commonMain/kotlin/com/androidmakers/ui/feed/MessageCard.kt @@ -0,0 +1,69 @@ +package com.androidmakers.ui.feed + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +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 androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.androidmakers.ui.theme.neoBrutalElevation +import fr.androidmakers.domain.model.FeedItem +import fr.androidmakers.domain.model.MessageType +import kotlin.time.Clock + +@Composable +fun MessageCard( + message: FeedItem.Message, + modifier: Modifier = Modifier, +) { + Surface( + modifier = modifier.fillMaxWidth().neoBrutalElevation(), + shape = MaterialTheme.shapes.large, + color = MaterialTheme.colorScheme.surfaceContainerHigh, + ) { + Column(modifier = Modifier.padding(16.dp)) { + CategoryTimeRow( + category = message.type.label(), + timeAgo = message.createdAt.toRelativeTime(), + ) + Text( + text = message.title, + color = MaterialTheme.colorScheme.onSurface, + fontWeight = FontWeight.Bold, + fontSize = 16.sp, + modifier = Modifier.padding(top = 8.dp), + ) + Text( + text = message.body, + color = MaterialTheme.colorScheme.onSurfaceVariant, + fontSize = 14.sp, + maxLines = 5, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(top = 4.dp), + ) + } + } +} + +private fun MessageType.label(): String = when (this) { + MessageType.INFO -> "INFO" + MessageType.ALERT -> "ALERT" + MessageType.ANNOUNCEMENT -> "ANNOUNCEMENT" +} + +private fun kotlinx.datetime.Instant.toRelativeTime(): String { + val now = Clock.System.now() + val durationMinutes = (now - this).inWholeMinutes + return when { + durationMinutes < 1 -> "just now" + durationMinutes < 60 -> "${durationMinutes}m ago" + durationMinutes < 1440 -> "${durationMinutes / 60}h ago" + else -> "${durationMinutes / 1440}d ago" + } +}