Skip to content

Commit 19ff229

Browse files
✨ Design fine tuning (#405)
* feat(feed): implement local persistence and push notification integration This change introduces a persistent local feed powered by DataStore, allowing push notifications to be saved and displayed to users. - Add `FeedLocalRepository` using DataStore and Kotlin Serialization for local message storage. - Integrate push notification handling on Android (`FirebaseMessagingService`) and iOS (`AppDelegate`) to capture and persist incoming feed items. - Introduce `FeedItem.Message` type and `MessageCard` UI component for displaying dynamic feed content. - Refactor `GetFeedUseCase` to fetch directly from the repository, removing hardcoded venue-to-article mapping. - Add POST_NOTIFICATIONS permission request for Android 13+ in `MainActivity`. - Update `AgendaRow` to limit visible tags to 3 with an overflow indicator and refine chip styling. - Replace `MockFeedRepository` with `FeedLocalRepository` in the dependency injection module. * feat(ui): consolidate About and Venue into Info screen - Introduce `InfoScreen` to combine about information and venue details. - Update bottom navigation and routes to replace `VenueKey` and `AboutKey` with `InfoKey`. - Refactor `AboutScreen` components to be reusable within the new `InfoScreen`. - Adjust `VenuePager` styling and add modifier support. - Bump dependency versions for `androidx-wear-compose`, `compose`, `firebase-bom`, and `jetbrains-lifecycle`. * refactor(ui): replace material-icons-extended with local resources and modernize UI - Replaces the `material-icons-extended` dependency with local XML drawables in `composeResources`. - Updates UI components to use Material 3 Expressive APIs, including `ElevatedCard`, `OutlinedCard`, and `FilledTonalButton`. - Migrates standard typography to `Emphasized` styles (e.g., `headlineSmallEmphasized`, `titleMediumEmphasized`). - Replaces custom tag surfaces with `SuggestionChip`. - Implements dynamic edge-to-edge support in `MainActivity` based on the user's theme preference. - Refactors icon usage to use `painterResource` instead of `ImageVector`. * feat(ui): implement bottom sheet navigation for session details Introduces `BottomSheetSceneStrategy` to handle navigation entries as modal overlays using Navigation3. Updates the application layout to provide a responsive experience: session details now appear in a bottom sheet on narrow screens and remain in the detail pane on wide screens. - Added `BottomSheetSceneStrategy` and `BottomSheetScene` for Material3 `ModalBottomSheet` integration. - Updated `AVALayout` to toggle between `ListDetailSceneStrategy` and `BottomSheetSceneStrategy` based on screen size. - Added `showTopBar` and `showBackButton` configuration to `SessionDetailLayout`. - Refactored `AgendaRow` icon logic for better readability. - Removed unused `0.xml` vector resource. * refactor(ui): localize dismiss string in AlertBannerCard - Added `dismiss` string resource to `strings.xml`. - Updated `AlertBannerCard` to use `stringResource` for the close icon's content description instead of a hardcoded string. * refactor(ui): replace SuggestionChip with Surface implementation for TagChip - Replaces `SuggestionChip` with a custom `Surface` and `Text` layout in `AgendaRow` and `SessionDetailLayout` to improve styling control. - Enables the Feed navigation item in `AVALayout` by commenting out the feature flag check. * feat: support images in feed items - Add optional `imageUrl` to `FeedItem.Message` domain model - Update `MessageCard` UI to display images using Coil - Parse `feed_image_url` from FCM payloads in `AndroidMakersMessagingService` - Update local persistence and repositories to handle image metadata - Supplement feed with mock/hardcoded article items for testing
1 parent 62d0b1c commit 19ff229

78 files changed

Lines changed: 1615 additions & 702 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

androidApp/build.gradle.kts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,6 @@ dependencies {
5050
testImplementation(libs.junit)
5151

5252
implementation(libs.jetbrains.compose.material3)
53-
implementation(libs.jetbrains.compose.material.icons.extended)
5453
implementation(libs.jetbrains.compose.ui.tooling)
5554
coreLibraryDesugaring(libs.desugar.jdk.libs)
5655

androidApp/src/main/java/fr/paug/androidmakers/MainActivity.kt

Lines changed: 53 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,20 @@
11
package fr.paug.androidmakers
22

3+
import android.Manifest
34
import android.content.Intent
5+
import android.content.pm.PackageManager
6+
import android.os.Build
47
import android.os.Bundle
58
import android.util.Log
69
import androidx.activity.ComponentActivity
10+
import androidx.activity.SystemBarStyle
711
import androidx.activity.compose.setContent
812
import androidx.activity.enableEdgeToEdge
13+
import androidx.activity.result.contract.ActivityResultContracts
14+
import androidx.core.content.ContextCompat
15+
import androidx.compose.foundation.isSystemInDarkTheme
16+
import androidx.compose.runtime.DisposableEffect
17+
import androidx.compose.runtime.collectAsState
918
import androidx.compose.runtime.getValue
1019
import androidx.compose.runtime.produceState
1120
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
@@ -19,6 +28,8 @@ import androidx.credentials.exceptions.NoCredentialException
1928
import androidx.lifecycle.lifecycleScope
2029
import com.androidmakers.ui.MainLayout
2130
import com.androidmakers.ui.common.SigninCallbacks
31+
import fr.androidmakers.domain.model.ThemePreference
32+
import fr.androidmakers.domain.repo.ThemeRepository
2233
import com.google.android.libraries.identity.googleid.GetSignInWithGoogleOption
2334
import com.google.android.libraries.identity.googleid.GoogleIdTokenCredential
2435
import com.google.android.libraries.identity.googleid.GoogleIdTokenParsingException
@@ -41,17 +52,47 @@ class MainActivity : ComponentActivity() {
4152

4253
private val mergeBookmarksUseCase: MergeBookmarksUseCase by inject(mode = LazyThreadSafetyMode.NONE)
4354

55+
private val notificationPermissionLauncher = registerForActivityResult(
56+
ActivityResultContracts.RequestPermission()
57+
) { granted ->
58+
Log.d(TAG, "POST_NOTIFICATIONS permission ${if (granted) "granted" else "denied"}")
59+
}
60+
4461
override fun onCreate(savedInstanceState: Bundle?) {
4562
installSplashScreen()
4663
super.onCreate(savedInstanceState)
4764

48-
enableEdgeToEdge()
49-
65+
requestNotificationPermission()
5066
logFCMToken()
5167

5268
val initialDeepLink: String? = if (savedInstanceState == null) intent.dataString else null
5369

5470
setContent {
71+
val themeRepository: ThemeRepository = org.koin.compose.koinInject()
72+
val themePreference by themeRepository.themePreference.collectAsState(ThemePreference.System)
73+
val isDark = when (themePreference) {
74+
ThemePreference.System -> isSystemInDarkTheme()
75+
ThemePreference.Light -> false
76+
ThemePreference.Dark -> true
77+
ThemePreference.Neobrutalism -> false
78+
}
79+
80+
DisposableEffect(isDark) {
81+
enableEdgeToEdge(
82+
statusBarStyle = if (isDark) {
83+
SystemBarStyle.dark(android.graphics.Color.TRANSPARENT)
84+
} else {
85+
SystemBarStyle.light(android.graphics.Color.TRANSPARENT, android.graphics.Color.TRANSPARENT)
86+
},
87+
navigationBarStyle = if (isDark) {
88+
SystemBarStyle.dark(android.graphics.Color.TRANSPARENT)
89+
} else {
90+
SystemBarStyle.light(android.graphics.Color.TRANSPARENT, android.graphics.Color.TRANSPARENT)
91+
},
92+
)
93+
onDispose {}
94+
}
95+
5596
val deeplink: String? by produceState(initialDeepLink) {
5697
val listener = Consumer<Intent> { newIntent ->
5798
newIntent.dataString?.let {
@@ -74,6 +115,16 @@ class MainActivity : ComponentActivity() {
74115
}
75116
}
76117

118+
private fun requestNotificationPermission() {
119+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
120+
if (ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS)
121+
!= PackageManager.PERMISSION_GRANTED
122+
) {
123+
notificationPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
124+
}
125+
}
126+
}
127+
77128
@Suppress("TooGenericExceptionCaught")
78129
private fun logFCMToken() {
79130
lifecycleScope.launch {

androidApp/src/main/java/fr/paug/androidmakers/messaging/AndroidMakersMessagingService.kt

Lines changed: 54 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -11,53 +11,85 @@ import android.util.Log
1111
import androidx.core.app.NotificationCompat
1212
import com.google.firebase.messaging.FirebaseMessagingService
1313
import com.google.firebase.messaging.RemoteMessage
14+
import fr.androidmakers.domain.model.FeedItem
15+
import fr.androidmakers.domain.model.MessageType
16+
import fr.androidmakers.domain.repo.FeedRepository
1417
import fr.paug.androidmakers.MainActivity
1518
import fr.paug.androidmakers.R
19+
import kotlinx.coroutines.CoroutineScope
20+
import kotlinx.coroutines.Dispatchers
21+
import kotlinx.coroutines.SupervisorJob
22+
import kotlinx.coroutines.cancel
23+
import kotlinx.coroutines.launch
24+
import kotlinx.datetime.Instant
25+
import org.koin.android.ext.android.inject
26+
import java.util.UUID
1627

1728
class AndroidMakersMessagingService : FirebaseMessagingService() {
1829

30+
private val feedRepository: FeedRepository by inject()
31+
private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
32+
33+
override fun onDestroy() {
34+
super.onDestroy()
35+
serviceScope.cancel()
36+
}
37+
1938
override fun onNewToken(token: String) {
2039
Log.d(TAG, "Refreshed token: $token")
2140
}
2241

2342
override fun onMessageReceived(remoteMessage: RemoteMessage) {
24-
2543
Log.d(TAG, "From: ${remoteMessage.from}")
2644

27-
// Check if message contains a data payload.
28-
if (remoteMessage.data.isNotEmpty()) {
29-
Log.d(TAG, "Message data payload: ${remoteMessage.data}")
30-
31-
// Check if message contains a notification payload.
32-
remoteMessage.notification?.let {
33-
Log.d(TAG, "Message Notification Body: ${it.body}")
34-
35-
}
36-
37-
38-
// Also if you intend on generating your own notifications as a result of a received FCM
39-
// message, here is where that should be initiated. See sendNotification method below.
40-
45+
val data = remoteMessage.data
46+
if (data.isNotEmpty()) {
47+
Log.d(TAG, "Message data payload: $data")
48+
saveFeedItem(data)
4149
}
4250

43-
remoteMessage.notification?.body?.let {
44-
sendNotification(it)
51+
val title = data["feed_title"] ?: remoteMessage.notification?.title ?: "Android Makers"
52+
val body = data["feed_body"] ?: remoteMessage.notification?.body
53+
if (body != null) {
54+
sendNotification(title, body)
4555
}
4656
}
4757

58+
private fun saveFeedItem(data: Map<String, String>) {
59+
val title = data["feed_title"] ?: return
60+
val body = data["feed_body"] ?: return
61+
val id = data["feed_id"] ?: UUID.randomUUID().toString()
62+
val type = data["feed_type"]?.let { typeName ->
63+
MessageType.entries.firstOrNull { it.name == typeName }
64+
} ?: MessageType.INFO
65+
66+
val imageUrl = data["feed_image_url"]
67+
68+
val feedItem = FeedItem.Message(
69+
id = id,
70+
type = type,
71+
title = title,
72+
body = body,
73+
createdAt = Instant.fromEpochMilliseconds(System.currentTimeMillis()),
74+
imageUrl = imageUrl,
75+
)
76+
serviceScope.launch {
77+
feedRepository.addFeedItem(feedItem)
78+
}
79+
}
4880

49-
private fun sendNotification(messageBody: String) {
81+
private fun sendNotification(title: String, messageBody: String) {
5082
val intent = Intent(this, MainActivity::class.java)
5183
intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP)
5284
val pendingIntent = PendingIntent.getActivity(
53-
this, 0 /* Request code */, intent,
85+
this, 0, intent,
5486
PendingIntent.FLAG_IMMUTABLE
5587
)
5688

5789
val channelId = "fcm_default_channel"
5890
val defaultSoundUri = RingtoneManager.getDefaultUri(RingtoneManager.TYPE_NOTIFICATION)
5991
val notificationBuilder = NotificationCompat.Builder(this, channelId)
60-
.setContentTitle("FCM Message")
92+
.setContentTitle(title)
6193
.setContentText(messageBody)
6294
.setSmallIcon(R.drawable.ic_notification_small)
6395
.setAutoCancel(true)
@@ -66,22 +98,19 @@ class AndroidMakersMessagingService : FirebaseMessagingService() {
6698

6799
val notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
68100

69-
// Since android Oreo notification channel is needed.
70101
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
71102
val channel = NotificationChannel(
72103
channelId,
73-
"Channel human readable title",
104+
"Android Makers",
74105
NotificationManager.IMPORTANCE_DEFAULT
75106
)
76107
notificationManager.createNotificationChannel(channel)
77108
}
78109

79-
notificationManager.notify(0 /* ID of notification */, notificationBuilder.build())
110+
notificationManager.notify(messageBody.hashCode(), notificationBuilder.build())
80111
}
81112

82-
83113
companion object {
84114
const val TAG = "MessagingService"
85115
}
86-
87116
}

gradle/libs.versions.toml

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,21 +11,21 @@ androidx-core-splashscreen = "1.2.0"
1111
androidx-credentials = "1.5.0"
1212
androidx-datastore = "1.2.1"
1313
androidx-lifecycle = "2.10.0" # Used by the Wear OS app only
14-
androidx-wear-compose = "1.5.6"
14+
androidx-wear-compose = "1.6.0"
1515
apollo = "4.4.2"
1616
apollo-adapters = "0.7.0"
1717
apollo-cache = "1.0.0"
1818
coil = "3.4.0"
19-
compose = "1.10.5" # Used by the Wear OS app only
19+
compose = "1.10.6" # Used by the Wear OS app only
2020
compose-material-icons-extended = "1.7.8"
2121
crashlytics-plugin = "3.0.6"
2222
firebase-auth = "2.4.0"
23-
firebase-bom = "34.10.0"
23+
firebase-bom = "34.11.0"
2424
google-services-plugin = "4.4.4"
2525
googleid = "1.2.0"
2626
horologist = "0.7.15"
2727
jetbrains-compose = "1.11.0-alpha04"
28-
jetbrains-lifecycle = "2.10.0-beta01"
28+
jetbrains-lifecycle = "2.10.0"
2929
jetbrains-material3-adaptive-nav3 = "1.3.0-alpha06"
3030
jetbrains-compose-material-icons-extended = "1.7.3"
3131
nav3-ui = "1.1.0-alpha04"
@@ -83,6 +83,7 @@ kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-t
8383
kotlinx-coroutines-play-services = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-play-services", version.ref = "kotlinx-coroutines" }
8484
kotlinx-coroutines-swing = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-swing", version.ref = "kotlinx-coroutines" }
8585
kotlinx-datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "kotlinx-datetime" }
86+
kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version = "1.8.1" }
8687
ktor-client-darwin = { module = "io.ktor:ktor-client-darwin", version.ref = "ktor" }
8788
ktor-client-java = { module = "io.ktor:ktor-client-java", version.ref = "ktor" }
8889
okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" }

iosApp/AndroidMakers/AppDelegate.swift

Lines changed: 73 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,36 +5,101 @@
55
import UIKit
66
import shared
77
import Firebase
8+
import FirebaseMessaging
9+
import UserNotifications
810

911
@UIApplicationMain
10-
class AppDelegate: UIResponder, UIApplicationDelegate {
12+
class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterDelegate, MessagingDelegate {
1113

1214
var window: UIWindow?
15+
private let feedHelper = FeedHelper()
1316

1417
func application(_ application: UIApplication, didFinishLaunchingWithOptions
1518
launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
16-
// Override point for customization after application launch.
1719

1820
FirebaseApp.configure()
1921
OpenFeedbackFirebaseConfigKt.initializeOpenFeedback(config: OpenFeedbackFirebaseConfig.companion.default(context: nil))
2022
DependenciesBuilder().inject(platformModules: [ViewModelModuleKt.viewModelModule])
2123

24+
// Push notifications setup
25+
UNUserNotificationCenter.current().delegate = self
26+
UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .badge, .sound]) { granted, error in
27+
if let error = error {
28+
print("Notification permission error: \(error)")
29+
}
30+
}
31+
application.registerForRemoteNotifications()
32+
Messaging.messaging().delegate = self
33+
2234
return true
2335
}
2436

37+
// MARK: APNs Token
38+
39+
func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
40+
Messaging.messaging().apnsToken = deviceToken
41+
}
42+
43+
func application(_ application: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: Error) {
44+
print("Failed to register for remote notifications: \(error)")
45+
}
46+
47+
// MARK: MessagingDelegate
48+
49+
func messaging(_ messaging: Messaging, didReceiveRegistrationToken fcmToken: String?) {
50+
print("FCM token: \(fcmToken ?? "nil")")
51+
}
52+
53+
// MARK: UNUserNotificationCenterDelegate
54+
55+
func userNotificationCenter(_ center: UNUserNotificationCenter,
56+
willPresent notification: UNNotification,
57+
withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) {
58+
let userInfo = notification.request.content.userInfo
59+
saveFeedItemFromPayload(userInfo)
60+
completionHandler([.banner, .badge, .sound])
61+
}
62+
63+
func userNotificationCenter(_ center: UNUserNotificationCenter,
64+
didReceive response: UNNotificationResponse,
65+
withCompletionHandler completionHandler: @escaping () -> Void) {
66+
// Feed item already saved in willPresent or didReceiveRemoteNotification — no duplicate save here.
67+
completionHandler()
68+
}
69+
70+
// MARK: Background data-only push
71+
72+
func application(_ application: UIApplication,
73+
didReceiveRemoteNotification userInfo: [AnyHashable: Any],
74+
fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void) {
75+
// Only handle in background; foreground is covered by willPresent
76+
if application.applicationState != .active {
77+
saveFeedItemFromPayload(userInfo)
78+
}
79+
completionHandler(.newData)
80+
}
81+
82+
// MARK: Feed storage
83+
84+
private func saveFeedItemFromPayload(_ userInfo: [AnyHashable: Any]) {
85+
guard let title = userInfo["feed_title"] as? String,
86+
let body = userInfo["feed_body"] as? String else { return }
87+
let id = userInfo["feed_id"] as? String
88+
let type = userInfo["feed_type"] as? String
89+
feedHelper.saveFeedItem(id: id, type: type, title: title, body: body)
90+
}
91+
92+
func applicationWillTerminate(_ application: UIApplication) {
93+
feedHelper.cancel()
94+
}
95+
2596
// MARK: UISceneSession Lifecycle
2697

2798
func application(_ application: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession,
2899
options: UIScene.ConnectionOptions) -> UISceneConfiguration {
29-
// Called when a new scene session is being created.
30-
// Use this method to select a configuration to create the new scene with.
31100
return UISceneConfiguration(name: "Default Configuration", sessionRole: connectingSceneSession.role)
32101
}
33102

34103
func application(_ application: UIApplication, didDiscardSceneSessions sceneSessions: Set<UISceneSession>) {
35-
// Called when the user discards a scene session.
36-
// If any sessions were discarded while the application was not running, this will be called shortly after
37-
// application:didFinishLaunchingWithOptions.
38-
// Use this method to release any resources that were specific to the discarded scenes, as they will not return.
39104
}
40105
}

shared/data/build.gradle.kts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import com.apollographql.apollo.annotations.ApolloExperimental
33
plugins {
44
alias(libs.plugins.androidmakers.kmp.library)
55
alias(libs.plugins.apollo)
6+
alias(libs.plugins.kotlin.serialization)
67
}
78

89
kotlin {
@@ -21,6 +22,7 @@ kotlin {
2122
api(libs.apollo.runtime)
2223
implementation(libs.apollo.adapters.kotlinx.datetime)
2324
implementation(libs.apollo.normalized.cache.sqlite)
25+
implementation(libs.kotlinx.serialization.json)
2426

2527
api(libs.androidx.datastore.preferences)
2628
api(libs.androidx.datastore.preferences.core)

0 commit comments

Comments
 (0)