feat(feed): implement real-time GraphQL feed with subscriptions#401
feat(feed): implement real-time GraphQL feed with subscriptions#401renaudmathieu wants to merge 1 commit intomainfrom
Conversation
- Replace `MockFeedRepository` with `FeedGraphQLRepository` using Apollo. - Add GraphQL queries and subscriptions for real-time feed updates. - Update domain model to include `FeedItem.Message` and `MessageType`. - Implement `MessageCard` UI component with relative time formatting. - Configure Apollo `WebSocketNetworkTransport` and custom `Instant` scalar adapter. - Refactor `DataModule` to use `singleOf` for dependency injection. - Update schema and mappers to support the new feed message types.
|
|
||
| val KotlinxInstantAdapter = object : Adapter<Instant> { | ||
| override fun fromJson(reader: JsonReader, customScalarAdapters: CustomScalarAdapters): Instant { | ||
| val str = reader.nextString() ?: throw IllegalStateException("Expected non-null Instant string") |
Check warning
Code scanning / detekt
Use check() or error() instead of throwing an IllegalStateException. Warning
There was a problem hiding this comment.
Pull request overview
Implements a real-time feed backed by Apollo GraphQL, replacing the mock feed source and extending the domain/UI layers to support a new message-based feed item type.
Changes:
- Added GraphQL query + subscription for feed messages, plus Apollo WebSocket transport and an
Instantscalar adapter. - Extended the feed domain model with
FeedItem.MessageandMessageType, and mapped GraphQL feed messages into domain items. - Updated feed UI to render message items via a new
MessageCardcomponent and removed the unused “Read More” footer/strings.
Reviewed changes
Copilot reviewed 15 out of 16 changed files in this pull request and generated 7 comments.
Show a summary per file
| File | Description |
|---|---|
| shared/ui/src/commonMain/kotlin/com/androidmakers/ui/feed/MessageCard.kt | New UI card for FeedItem.Message, including relative time display. |
| shared/ui/src/commonMain/kotlin/com/androidmakers/ui/feed/FeedScreen.kt | Renders FeedItem.Message using MessageCard. |
| shared/ui/src/commonMain/kotlin/com/androidmakers/ui/feed/ArticleCards.kt | Removes unused article footer/avatar/read-more UI. |
| shared/ui/src/commonMain/composeResources/values/strings.xml | Removes unused feed strings; minor formatting change. |
| shared/ui/src/commonMain/composeResources/values-fr/strings.xml | Removes unused French “Read More” string. |
| shared/domain/src/commonMain/kotlin/fr/androidmakers/domain/model/FeedItem.kt | Adds FeedItem.Message + MessageType; simplifies Article model. |
| shared/di/src/commonMain/kotlin/fr/androidmakers/di/DataModule.kt | Switches DI to bind FeedRepository to FeedGraphQLRepository using singleOf. |
| shared/data/src/commonMain/kotlin/fr/androidmakers/store/mock/MockFeedRepository.kt | Updates mock data to use FeedItem.Message (though no longer wired as default). |
| shared/data/src/commonMain/kotlin/fr/androidmakers/store/graphql/KotlinxInstantAdapter.kt | Adds Apollo custom scalar adapter for kotlinx.datetime.Instant. |
| shared/data/src/commonMain/kotlin/fr/androidmakers/store/graphql/FeedMappers.kt | Maps GraphQL FeedMessageDetails to domain feed items. |
| shared/data/src/commonMain/kotlin/fr/androidmakers/store/graphql/FeedGraphQLRepository.kt | New repository merging initial query results with subscription updates. |
| shared/data/src/commonMain/kotlin/fr/androidmakers/store/graphql/ApolloClient.kt | Configures Apollo WebSocket transport for subscriptions. |
| shared/data/src/commonMain/graphql/schema.graphqls | Adds feed query, types, and subscription to the schema. |
| shared/data/src/commonMain/graphql/feed.graphql | Adds feed query/subscription operations + fragment. |
| shared/data/build.gradle.kts | Registers GraphQLInstant scalar mapping to the new adapter. |
| .gitignore | Ignores firebase-debug.log. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| return when { | ||
| durationMinutes < 1 -> "just now" | ||
| durationMinutes < 60 -> "${durationMinutes}m ago" | ||
| durationMinutes < 1440 -> "${durationMinutes / 60}h ago" | ||
| else -> "${durationMinutes / 1440}d ago" |
There was a problem hiding this comment.
The relative-time strings here are hardcoded in English ("just now", "m ago", etc.). Please move these to compose resources (ideally using plurals/format strings) so the feed UI remains localizable.
| val unique = newData.filter { it.id !in existingIds } | ||
| Result.success(existing + unique) |
There was a problem hiding this comment.
Because cacheAndNetwork() emits multiple snapshots (cache then network), accumulating with existing + unique means items with the same ID are never updated when newer server data arrives. Consider treating query emissions as authoritative snapshots (replace/update by ID) rather than only appending unseen IDs.
| val unique = newData.filter { it.id !in existingIds } | |
| Result.success(existing + unique) | |
| val newById = newData.associateBy { it.id } | |
| // Update existing items with newer versions when IDs match | |
| val updatedExisting = existing.map { item -> | |
| newById[item.id] ?: item | |
| } | |
| // Append only truly new items that did not exist before | |
| val brandNew = newData.filter { it.id !in existingIds } | |
| Result.success(updatedExisting + brandNew) |
| <string name="feed">Feed</string> | ||
| <string name="feed_read_more">Read More</string> | ||
| <string name="feed_category_venue">VENUE</string> | ||
| <string name="feed_category_venue">VENUE</string> |
There was a problem hiding this comment.
The indentation on this line is inconsistent with the surrounding XML (missing the usual leading spaces). If the project runs XML formatting/lint checks, this can create noisy diffs or even fail formatting checks; please align it with the rest of the file’s indentation.
| <string name="feed_category_venue">VENUE</string> | |
| <string name="feed_category_venue">VENUE</string> |
| val existing = acc.getOrNull().orEmpty() | ||
| val existingIds = existing.map { it.id }.toSet() | ||
| val unique = newData.filter { it.id !in existingIds } | ||
| Result.success(existing + unique) |
There was a problem hiding this comment.
New items are appended to the end of the list (existing + unique). For a real-time feed, subscription items are typically newest and should be inserted at the top (or the whole list should be re-sorted by createdAt) to avoid surprising ordering in the UI.
| Result.success(existing + unique) | |
| Result.success(unique + existing) |
| .mapNotNull { response -> | ||
| response.data?.let { data -> | ||
| Result.success(listOf(data.feedMessageAdded.feedMessageDetails.toFeedItem())) | ||
| } | ||
| } | ||
| .catch { /* subscription error — silently stop, initial data still shown */ } |
There was a problem hiding this comment.
This mapping drops any subscription events that don’t have data and ignores GraphQL errors/response.exception, so failures won’t be visible to callers. Please convert the subscription responses into Result consistently (similar to toResultFlow() used elsewhere) so errors can be propagated or handled explicitly.
| .mapNotNull { response -> | |
| response.data?.let { data -> | |
| Result.success(listOf(data.feedMessageAdded.feedMessageDetails.toFeedItem())) | |
| } | |
| } | |
| .catch { /* subscription error — silently stop, initial data still shown */ } | |
| .map { response -> | |
| val exception = response.exception | |
| val errors = response.errors | |
| val data = response.data | |
| when { | |
| exception != null -> { | |
| Result.failure<List<FeedItem>>(exception) | |
| } | |
| errors != null && errors.isNotEmpty() -> { | |
| val message = errors.joinToString(separator = "; ") { it.message } | |
| Result.failure<List<FeedItem>>(RuntimeException("GraphQL subscription error(s): $message")) | |
| } | |
| data != null -> { | |
| Result.success( | |
| listOf(data.feedMessageAdded.feedMessageDetails.toFeedItem()) | |
| ) | |
| } | |
| else -> { | |
| Result.failure<List<FeedItem>>( | |
| IllegalStateException("Subscription response has no data, errors or exception") | |
| ) | |
| } | |
| } | |
| } | |
| .catch { throwable -> | |
| emit(Result.failure<List<FeedItem>>(throwable)) | |
| } |
| Result.success(listOf(data.feedMessageAdded.feedMessageDetails.toFeedItem())) | ||
| } | ||
| } | ||
| .catch { /* subscription error — silently stop, initial data still shown */ } |
There was a problem hiding this comment.
Swallowing subscription exceptions here makes real-time failures silent and hard to diagnose. Please at least log/report the exception, or emit a Result.failure so the UI can reflect connectivity issues appropriately.
| .catch { /* subscription error — silently stop, initial data still shown */ } | |
| .catch { emit(Result.failure(it)) } |
| private fun MessageType.label(): String = when (this) { | ||
| MessageType.INFO -> "INFO" | ||
| MessageType.ALERT -> "ALERT" | ||
| MessageType.ANNOUNCEMENT -> "ANNOUNCEMENT" | ||
| } |
There was a problem hiding this comment.
The message type labels are hardcoded ("INFO"/"ALERT"/"ANNOUNCEMENT"). Since the app ships with localized string resources (including values-fr), these user-visible labels should be sourced from compose resources so they can be translated consistently.
MockFeedRepositorywithFeedGraphQLRepositoryusing Apollo.FeedItem.MessageandMessageType.MessageCardUI component with relative time formatting.WebSocketNetworkTransportand customInstantscalar adapter.DataModuleto usesingleOffor dependency injection.Checklist
./gradlew assembleDebug)