From 40d254c1e73613b777dcfa0303addb7ae1e4df3a Mon Sep 17 00:00:00 2001 From: Renaud Mathieu Date: Fri, 27 Mar 2026 23:55:26 +0100 Subject: [PATCH 1/7] 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. --- .../fr/paug/androidmakers/MainActivity.kt | 23 +++- .../AndroidMakersMessagingService.kt | 76 +++++++---- gradle/libs.versions.toml | 1 + iosApp/AndroidMakers/AppDelegate.swift | 81 ++++++++++-- shared/data/build.gradle.kts | 2 + .../store/graphql/VenueGraphQLRepository.kt | 4 +- .../store/local/FeedLocalRepository.kt | 77 +++++++++++ .../store/mock/MockFeedRepository.kt | 59 ++------- .../kotlin/fr/androidmakers/di/DataModule.kt | 4 +- .../kotlin/fr/androidmakers/di/FeedHelper.kt | 40 ++++++ .../domain/interactor/GetFeedUseCase.kt | 58 +------- .../domain/model/FeatureFlags.kt | 5 +- .../fr/androidmakers/domain/model/FeedItem.kt | 16 +++ .../domain/repo/FeedRepository.kt | 1 + .../domain/interactor/FakeFeedRepository.kt | 5 + .../domain/interactor/FakeVenueRepository.kt | 17 --- .../domain/interactor/GetFeedUseCaseTest.kt | 124 +++--------------- .../kotlin/com/androidmakers/ui/MainLayout.kt | 6 +- .../com/androidmakers/ui/agenda/AgendaRow.kt | 14 +- .../ui/common/navigation/AVALayout.kt | 9 +- .../com/androidmakers/ui/feed/FeedScreen.kt | 10 +- .../androidmakers/ui/feed/FeedViewModel.kt | 3 +- .../com/androidmakers/ui/feed/MessageCard.kt | 71 ++++++++++ 23 files changed, 425 insertions(+), 281 deletions(-) create mode 100644 shared/data/src/commonMain/kotlin/fr/androidmakers/store/local/FeedLocalRepository.kt create mode 100644 shared/di/src/iosMain/kotlin/fr/androidmakers/di/FeedHelper.kt delete mode 100644 shared/domain/src/commonTest/kotlin/fr/androidmakers/domain/interactor/FakeVenueRepository.kt create mode 100644 shared/ui/src/commonMain/kotlin/com/androidmakers/ui/feed/MessageCard.kt diff --git a/androidApp/src/main/java/fr/paug/androidmakers/MainActivity.kt b/androidApp/src/main/java/fr/paug/androidmakers/MainActivity.kt index 6b764811..9a298b4a 100644 --- a/androidApp/src/main/java/fr/paug/androidmakers/MainActivity.kt +++ b/androidApp/src/main/java/fr/paug/androidmakers/MainActivity.kt @@ -1,11 +1,16 @@ package fr.paug.androidmakers +import android.Manifest import android.content.Intent +import android.content.pm.PackageManager +import android.os.Build import android.os.Bundle import android.util.Log import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge +import androidx.activity.result.contract.ActivityResultContracts +import androidx.core.content.ContextCompat import androidx.compose.runtime.getValue import androidx.compose.runtime.produceState import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen @@ -41,12 +46,18 @@ class MainActivity : ComponentActivity() { private val mergeBookmarksUseCase: MergeBookmarksUseCase by inject(mode = LazyThreadSafetyMode.NONE) + private val notificationPermissionLauncher = registerForActivityResult( + ActivityResultContracts.RequestPermission() + ) { granted -> + Log.d(TAG, "POST_NOTIFICATIONS permission ${if (granted) "granted" else "denied"}") + } + override fun onCreate(savedInstanceState: Bundle?) { installSplashScreen() super.onCreate(savedInstanceState) enableEdgeToEdge() - + requestNotificationPermission() logFCMToken() val initialDeepLink: String? = if (savedInstanceState == null) intent.dataString else null @@ -74,6 +85,16 @@ class MainActivity : ComponentActivity() { } } + private fun requestNotificationPermission() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + if (ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) + != PackageManager.PERMISSION_GRANTED + ) { + notificationPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS) + } + } + } + @Suppress("TooGenericExceptionCaught") private fun logFCMToken() { lifecycleScope.launch { diff --git a/androidApp/src/main/java/fr/paug/androidmakers/messaging/AndroidMakersMessagingService.kt b/androidApp/src/main/java/fr/paug/androidmakers/messaging/AndroidMakersMessagingService.kt index d472037a..35f02dde 100644 --- a/androidApp/src/main/java/fr/paug/androidmakers/messaging/AndroidMakersMessagingService.kt +++ b/androidApp/src/main/java/fr/paug/androidmakers/messaging/AndroidMakersMessagingService.kt @@ -11,53 +11,82 @@ import android.util.Log import androidx.core.app.NotificationCompat import com.google.firebase.messaging.FirebaseMessagingService import com.google.firebase.messaging.RemoteMessage +import fr.androidmakers.domain.model.FeedItem +import fr.androidmakers.domain.model.MessageType +import fr.androidmakers.domain.repo.FeedRepository import fr.paug.androidmakers.MainActivity import fr.paug.androidmakers.R +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.launch +import kotlinx.datetime.Instant +import org.koin.android.ext.android.inject +import java.util.UUID class AndroidMakersMessagingService : FirebaseMessagingService() { + private val feedRepository: FeedRepository by inject() + private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + + override fun onDestroy() { + super.onDestroy() + serviceScope.cancel() + } + override fun onNewToken(token: String) { Log.d(TAG, "Refreshed token: $token") } override fun onMessageReceived(remoteMessage: RemoteMessage) { - Log.d(TAG, "From: ${remoteMessage.from}") - // Check if message contains a data payload. - if (remoteMessage.data.isNotEmpty()) { - Log.d(TAG, "Message data payload: ${remoteMessage.data}") - - // Check if message contains a notification payload. - remoteMessage.notification?.let { - Log.d(TAG, "Message Notification Body: ${it.body}") - - } - - - // Also if you intend on generating your own notifications as a result of a received FCM - // message, here is where that should be initiated. See sendNotification method below. - + val data = remoteMessage.data + if (data.isNotEmpty()) { + Log.d(TAG, "Message data payload: $data") + saveFeedItem(data) } - remoteMessage.notification?.body?.let { - sendNotification(it) + val title = data["feed_title"] ?: remoteMessage.notification?.title ?: "Android Makers" + val body = data["feed_body"] ?: remoteMessage.notification?.body + if (body != null) { + sendNotification(title, body) } } + private fun saveFeedItem(data: Map) { + val title = data["feed_title"] ?: return + val body = data["feed_body"] ?: return + val id = data["feed_id"] ?: UUID.randomUUID().toString() + val type = data["feed_type"]?.let { typeName -> + MessageType.entries.firstOrNull { it.name == typeName } + } ?: MessageType.INFO + + val feedItem = FeedItem.Message( + id = id, + type = type, + title = title, + body = body, + createdAt = Instant.fromEpochMilliseconds(System.currentTimeMillis()), + ) + serviceScope.launch { + feedRepository.addFeedItem(feedItem) + } + } - private fun sendNotification(messageBody: String) { + private fun sendNotification(title: String, messageBody: String) { val intent = Intent(this, MainActivity::class.java) intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP) val pendingIntent = PendingIntent.getActivity( - this, 0 /* Request code */, intent, + this, 0, intent, PendingIntent.FLAG_IMMUTABLE ) val channelId = "fcm_default_channel" val defaultSoundUri = RingtoneManager.getDefaultUri(RingtoneManager.TYPE_NOTIFICATION) val notificationBuilder = NotificationCompat.Builder(this, channelId) - .setContentTitle("FCM Message") + .setContentTitle(title) .setContentText(messageBody) .setSmallIcon(R.drawable.ic_notification_small) .setAutoCancel(true) @@ -66,22 +95,19 @@ class AndroidMakersMessagingService : FirebaseMessagingService() { val notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager - // Since android Oreo notification channel is needed. if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { val channel = NotificationChannel( channelId, - "Channel human readable title", + "Android Makers", NotificationManager.IMPORTANCE_DEFAULT ) notificationManager.createNotificationChannel(channel) } - notificationManager.notify(0 /* ID of notification */, notificationBuilder.build()) + notificationManager.notify(messageBody.hashCode(), notificationBuilder.build()) } - companion object { const val TAG = "MessagingService" } - } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index daffd467..c18785fe 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -83,6 +83,7 @@ kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-t kotlinx-coroutines-play-services = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-play-services", version.ref = "kotlinx-coroutines" } kotlinx-coroutines-swing = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-swing", version.ref = "kotlinx-coroutines" } kotlinx-datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "kotlinx-datetime" } +kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version = "1.8.1" } ktor-client-darwin = { module = "io.ktor:ktor-client-darwin", version.ref = "ktor" } ktor-client-java = { module = "io.ktor:ktor-client-java", version.ref = "ktor" } okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" } diff --git a/iosApp/AndroidMakers/AppDelegate.swift b/iosApp/AndroidMakers/AppDelegate.swift index 237c8221..78690dc7 100644 --- a/iosApp/AndroidMakers/AppDelegate.swift +++ b/iosApp/AndroidMakers/AppDelegate.swift @@ -5,36 +5,101 @@ import UIKit import shared import Firebase +import FirebaseMessaging +import UserNotifications @UIApplicationMain -class AppDelegate: UIResponder, UIApplicationDelegate { +class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterDelegate, MessagingDelegate { var window: UIWindow? + private let feedHelper = FeedHelper() func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { - // Override point for customization after application launch. FirebaseApp.configure() OpenFeedbackFirebaseConfigKt.initializeOpenFeedback(config: OpenFeedbackFirebaseConfig.companion.default(context: nil)) DependenciesBuilder().inject(platformModules: [ViewModelModuleKt.viewModelModule]) + // Push notifications setup + UNUserNotificationCenter.current().delegate = self + UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .badge, .sound]) { granted, error in + if let error = error { + print("Notification permission error: \(error)") + } + } + application.registerForRemoteNotifications() + Messaging.messaging().delegate = self + return true } + // MARK: APNs Token + + func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) { + Messaging.messaging().apnsToken = deviceToken + } + + func application(_ application: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: Error) { + print("Failed to register for remote notifications: \(error)") + } + + // MARK: MessagingDelegate + + func messaging(_ messaging: Messaging, didReceiveRegistrationToken fcmToken: String?) { + print("FCM token: \(fcmToken ?? "nil")") + } + + // MARK: UNUserNotificationCenterDelegate + + func userNotificationCenter(_ center: UNUserNotificationCenter, + willPresent notification: UNNotification, + withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) { + let userInfo = notification.request.content.userInfo + saveFeedItemFromPayload(userInfo) + completionHandler([.banner, .badge, .sound]) + } + + func userNotificationCenter(_ center: UNUserNotificationCenter, + didReceive response: UNNotificationResponse, + withCompletionHandler completionHandler: @escaping () -> Void) { + // Feed item already saved in willPresent or didReceiveRemoteNotification — no duplicate save here. + completionHandler() + } + + // MARK: Background data-only push + + func application(_ application: UIApplication, + didReceiveRemoteNotification userInfo: [AnyHashable: Any], + fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void) { + // Only handle in background; foreground is covered by willPresent + if application.applicationState != .active { + saveFeedItemFromPayload(userInfo) + } + completionHandler(.newData) + } + + // MARK: Feed storage + + private func saveFeedItemFromPayload(_ userInfo: [AnyHashable: Any]) { + guard let title = userInfo["feed_title"] as? String, + let body = userInfo["feed_body"] as? String else { return } + let id = userInfo["feed_id"] as? String + let type = userInfo["feed_type"] as? String + feedHelper.saveFeedItem(id: id, type: type, title: title, body: body) + } + + func applicationWillTerminate(_ application: UIApplication) { + feedHelper.cancel() + } + // MARK: UISceneSession Lifecycle func application(_ application: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession, options: UIScene.ConnectionOptions) -> UISceneConfiguration { - // Called when a new scene session is being created. - // Use this method to select a configuration to create the new scene with. return UISceneConfiguration(name: "Default Configuration", sessionRole: connectingSceneSession.role) } func application(_ application: UIApplication, didDiscardSceneSessions sceneSessions: Set) { - // Called when the user discards a scene session. - // If any sessions were discarded while the application was not running, this will be called shortly after - // application:didFinishLaunchingWithOptions. - // Use this method to release any resources that were specific to the discarded scenes, as they will not return. } } diff --git a/shared/data/build.gradle.kts b/shared/data/build.gradle.kts index 6a1b584b..9643931c 100644 --- a/shared/data/build.gradle.kts +++ b/shared/data/build.gradle.kts @@ -3,6 +3,7 @@ import com.apollographql.apollo.annotations.ApolloExperimental plugins { alias(libs.plugins.androidmakers.kmp.library) alias(libs.plugins.apollo) + alias(libs.plugins.kotlin.serialization) } kotlin { @@ -21,6 +22,7 @@ kotlin { api(libs.apollo.runtime) implementation(libs.apollo.adapters.kotlinx.datetime) implementation(libs.apollo.normalized.cache.sqlite) + implementation(libs.kotlinx.serialization.json) api(libs.androidx.datastore.preferences) api(libs.androidx.datastore.preferences.core) diff --git a/shared/data/src/commonMain/kotlin/fr/androidmakers/store/graphql/VenueGraphQLRepository.kt b/shared/data/src/commonMain/kotlin/fr/androidmakers/store/graphql/VenueGraphQLRepository.kt index d8c12279..f5891aef 100644 --- a/shared/data/src/commonMain/kotlin/fr/androidmakers/store/graphql/VenueGraphQLRepository.kt +++ b/shared/data/src/commonMain/kotlin/fr/androidmakers/store/graphql/VenueGraphQLRepository.kt @@ -6,7 +6,9 @@ import fr.androidmakers.domain.repo.VenueRepository import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map -class VenueGraphQLRepository(private val apolloClient: ApolloClient) : VenueRepository { +class VenueGraphQLRepository( + private val apolloClient: ApolloClient +) : VenueRepository { override fun getVenue(id: String): Flow> { return apolloClient.query(GetVenueQuery(id)) .cacheAndNetwork() diff --git a/shared/data/src/commonMain/kotlin/fr/androidmakers/store/local/FeedLocalRepository.kt b/shared/data/src/commonMain/kotlin/fr/androidmakers/store/local/FeedLocalRepository.kt new file mode 100644 index 00000000..fa49cd33 --- /dev/null +++ b/shared/data/src/commonMain/kotlin/fr/androidmakers/store/local/FeedLocalRepository.kt @@ -0,0 +1,77 @@ +package fr.androidmakers.store.local + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey +import fr.androidmakers.domain.model.FeedItem +import fr.androidmakers.domain.model.MessageType +import fr.androidmakers.domain.repo.FeedRepository +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import kotlinx.datetime.Instant +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json + +class FeedLocalRepository( + private val dataStore: DataStore, +) : FeedRepository { + + private val json = Json { ignoreUnknownKeys = true } + + override fun getFeedItems(): Flow>> { + return dataStore.data.map { prefs -> + val raw = prefs[PREF_KEY_FEED_ITEMS] ?: "[]" + val stored = runCatching { json.decodeFromString>(raw) } + .getOrDefault(emptyList()) + Result.success(stored.sortedByDescending { it.createdAtEpochMillis }.map { it.toFeedItem() }) + } + } + + override suspend fun addFeedItem(item: FeedItem) { + val message = item as? FeedItem.Message ?: return + val stored = StoredFeedItem( + id = message.id, + type = message.type.name, + title = message.title, + body = message.body, + createdAtEpochMillis = message.createdAt.toEpochMilliseconds(), + ) + dataStore.edit { prefs -> + val raw = prefs[PREF_KEY_FEED_ITEMS] ?: "[]" + val existing = runCatching { json.decodeFromString>(raw) } + .getOrElse { + prefs.remove(PREF_KEY_FEED_ITEMS) + emptyList() + } + .toMutableList() + if (existing.none { it.id == stored.id }) { + existing.add(stored) + } + val trimmed = existing.sortedByDescending { it.createdAtEpochMillis }.take(MAX_ITEMS) + prefs[PREF_KEY_FEED_ITEMS] = json.encodeToString(trimmed) + } + } + + companion object { + private val PREF_KEY_FEED_ITEMS = stringPreferencesKey("feed_items") + private const val MAX_ITEMS = 100 + } +} + +@Serializable +private data class StoredFeedItem( + val id: String, + val type: String, + val title: String, + val body: String, + val createdAtEpochMillis: Long, +) { + fun toFeedItem(): FeedItem.Message = FeedItem.Message( + id = id, + type = MessageType.entries.firstOrNull { it.name == type } ?: MessageType.INFO, + title = title, + body = body, + createdAt = Instant.fromEpochMilliseconds(createdAtEpochMillis), + ) +} 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..5486e92b 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,59 +1,20 @@ package fr.androidmakers.store.mock import fr.androidmakers.domain.model.FeedItem -import fr.androidmakers.domain.model.LocationInfo import fr.androidmakers.domain.repo.FeedRepository import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.map class MockFeedRepository : FeedRepository { + + private val items = MutableStateFlow>(emptyList()) + override fun getFeedItems(): Flow>> { - return flowOf( - Result.success( - listOf( - FeedItem.Alert( - id = "alert-1", - 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", - title = "Opening Keynote: The Future of Android Development", - description = "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", - ), - FeedItem.Article( - id = "article-2", - category = "EVENT", - timeAgo = "5h ago", - 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", - ), - ), - FeedItem.Article( - id = "article-3", - category = "ANNOUNCEMENT", - timeAgo = "1d ago", - title = "Swag Alert: Limited Edition T-Shirts", - description = "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", - ), - ) - ) - ) + return items.map { Result.success(it) } + } + + override suspend fun addFeedItem(item: FeedItem) { + items.value = listOf(item) + items.value } } 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 ae52a6d1..3bd26ea0 100644 --- a/shared/di/src/commonMain/kotlin/fr/androidmakers/di/DataModule.kt +++ b/shared/di/src/commonMain/kotlin/fr/androidmakers/di/DataModule.kt @@ -22,7 +22,7 @@ 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.local.FeedLocalRepository import fr.androidmakers.store.wear.WearMessagingRepository import org.koin.core.module.Module import org.koin.dsl.module @@ -43,5 +43,5 @@ val dataModule = module { single { BookmarksDataStoreRepository(get()) } single { ThemeDataStoreRepository(get()) } single { WearMessagingRepository(get()) } - single { MockFeedRepository() } + single { FeedLocalRepository(get()) } } diff --git a/shared/di/src/iosMain/kotlin/fr/androidmakers/di/FeedHelper.kt b/shared/di/src/iosMain/kotlin/fr/androidmakers/di/FeedHelper.kt new file mode 100644 index 00000000..6677f128 --- /dev/null +++ b/shared/di/src/iosMain/kotlin/fr/androidmakers/di/FeedHelper.kt @@ -0,0 +1,40 @@ +package fr.androidmakers.di + +import fr.androidmakers.domain.model.FeedItem +import fr.androidmakers.domain.model.MessageType +import fr.androidmakers.domain.repo.FeedRepository +import kotlinx.coroutines.MainScope +import kotlinx.coroutines.cancel +import kotlinx.coroutines.launch +import kotlinx.datetime.Instant +import platform.Foundation.NSDate +import platform.Foundation.timeIntervalSince1970 +import org.koin.core.component.KoinComponent +import org.koin.core.component.inject +import platform.Foundation.NSUUID + +class FeedHelper : KoinComponent { + private val feedRepository: FeedRepository by inject() + private val scope = MainScope() + + fun saveFeedItem(id: String?, type: String?, title: String, body: String) { + val messageType = type?.let { typeName -> + MessageType.entries.firstOrNull { it.name == typeName } + } ?: MessageType.INFO + + val feedItem = FeedItem.Message( + id = id ?: NSUUID().UUIDString(), + type = messageType, + title = title, + body = body, + createdAt = Instant.fromEpochMilliseconds((NSDate().timeIntervalSince1970 * 1000).toLong()), + ) + scope.launch { + feedRepository.addFeedItem(feedItem) + } + } + + fun cancel() { + scope.cancel() + } +} diff --git a/shared/domain/src/commonMain/kotlin/fr/androidmakers/domain/interactor/GetFeedUseCase.kt b/shared/domain/src/commonMain/kotlin/fr/androidmakers/domain/interactor/GetFeedUseCase.kt index cdc9e9dd..51f9a3a3 100644 --- a/shared/domain/src/commonMain/kotlin/fr/androidmakers/domain/interactor/GetFeedUseCase.kt +++ b/shared/domain/src/commonMain/kotlin/fr/androidmakers/domain/interactor/GetFeedUseCase.kt @@ -1,67 +1,11 @@ package fr.androidmakers.domain.interactor import fr.androidmakers.domain.model.FeedItem -import fr.androidmakers.domain.model.LocationInfo -import fr.androidmakers.domain.model.Venue import fr.androidmakers.domain.repo.FeedRepository -import fr.androidmakers.domain.repo.VenueRepository import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.combine class GetFeedUseCase( private val feedRepository: FeedRepository, - private val venueRepository: VenueRepository, ) { - companion object { - const val CATEGORY_VENUE = "VENUE" - const val CATEGORY_EVENT = "EVENT" - const val TITLE_FLOOR_PLAN = "Floor Plan" - } - - operator fun invoke(): Flow>> = combine( - feedRepository.getFeedItems(), - venueRepository.getVenue("conference"), - venueRepository.getVenue("afterparty"), - ) { feedResult, conferenceResult, afterpartyResult -> - val feedItems = feedResult.getOrNull().orEmpty() - val venueItems = buildList { - conferenceResult.getOrNull()?.let { venue -> - add(venue.toConferenceFeedItem()) - venue.floorPlanUrl?.let { add(venue.toFloorPlanFeedItem()) } - } - afterpartyResult.getOrNull()?.let { venue -> - add(venue.toAfterpartyFeedItem()) - } - } - Result.success(feedItems + venueItems) - } - - private fun Venue.toConferenceFeedItem() = FeedItem.Article( - id = "venue-conference", - category = CATEGORY_VENUE, - categoryBadge = CATEGORY_VENUE, - title = name, - description = description, - imageUrl = imageUrl, - timeAgo = "", - location = LocationInfo(name = address), - ) - - private fun Venue.toAfterpartyFeedItem() = FeedItem.Article( - id = "venue-afterparty", - category = CATEGORY_EVENT, - title = name, - description = description, - timeAgo = "", - location = LocationInfo(name = address), - ) - - private fun Venue.toFloorPlanFeedItem() = FeedItem.Article( - id = "venue-floorplan", - category = CATEGORY_VENUE, - title = TITLE_FLOOR_PLAN, - description = name, - thumbnailUrl = floorPlanUrl, - timeAgo = "", - ) + operator fun invoke(): Flow>> = feedRepository.getFeedItems() } diff --git a/shared/domain/src/commonMain/kotlin/fr/androidmakers/domain/model/FeatureFlags.kt b/shared/domain/src/commonMain/kotlin/fr/androidmakers/domain/model/FeatureFlags.kt index fd8f4892..01a1f7ec 100644 --- a/shared/domain/src/commonMain/kotlin/fr/androidmakers/domain/model/FeatureFlags.kt +++ b/shared/domain/src/commonMain/kotlin/fr/androidmakers/domain/model/FeatureFlags.kt @@ -1,3 +1,6 @@ package fr.androidmakers.domain.model -class FeatureFlags (val feed: Boolean, val venue: Boolean) +class FeatureFlags( + val feed: Boolean, + val venue: Boolean +) 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..34163daa 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,14 @@ 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 + data class Article( override val id: String, val category: String, @@ -24,6 +34,12 @@ sealed interface FeedItem { ) : FeedItem } +enum class MessageType { + INFO, + ALERT, + ANNOUNCEMENT, +} + data class LocationInfo( val name: String, val time: String? = null, diff --git a/shared/domain/src/commonMain/kotlin/fr/androidmakers/domain/repo/FeedRepository.kt b/shared/domain/src/commonMain/kotlin/fr/androidmakers/domain/repo/FeedRepository.kt index 2f309c75..fbcd9cae 100644 --- a/shared/domain/src/commonMain/kotlin/fr/androidmakers/domain/repo/FeedRepository.kt +++ b/shared/domain/src/commonMain/kotlin/fr/androidmakers/domain/repo/FeedRepository.kt @@ -5,4 +5,5 @@ import kotlinx.coroutines.flow.Flow interface FeedRepository { fun getFeedItems(): Flow>> + suspend fun addFeedItem(item: FeedItem) } diff --git a/shared/domain/src/commonTest/kotlin/fr/androidmakers/domain/interactor/FakeFeedRepository.kt b/shared/domain/src/commonTest/kotlin/fr/androidmakers/domain/interactor/FakeFeedRepository.kt index 41d00021..43e06177 100644 --- a/shared/domain/src/commonTest/kotlin/fr/androidmakers/domain/interactor/FakeFeedRepository.kt +++ b/shared/domain/src/commonTest/kotlin/fr/androidmakers/domain/interactor/FakeFeedRepository.kt @@ -9,4 +9,9 @@ class FakeFeedRepository : FeedRepository { val feedItemsFlow = MutableStateFlow>>(Result.success(emptyList())) override fun getFeedItems(): Flow>> = feedItemsFlow + + override suspend fun addFeedItem(item: FeedItem) { + val current = feedItemsFlow.value.getOrDefault(emptyList()) + feedItemsFlow.value = Result.success(listOf(item) + current) + } } diff --git a/shared/domain/src/commonTest/kotlin/fr/androidmakers/domain/interactor/FakeVenueRepository.kt b/shared/domain/src/commonTest/kotlin/fr/androidmakers/domain/interactor/FakeVenueRepository.kt deleted file mode 100644 index 88e2b670..00000000 --- a/shared/domain/src/commonTest/kotlin/fr/androidmakers/domain/interactor/FakeVenueRepository.kt +++ /dev/null @@ -1,17 +0,0 @@ -package fr.androidmakers.domain.interactor - -import fr.androidmakers.domain.model.Venue -import fr.androidmakers.domain.repo.VenueRepository -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.MutableStateFlow - -class FakeVenueRepository : VenueRepository { - val conferenceFlow = MutableStateFlow>(Result.failure(NoSuchElementException())) - val afterpartyFlow = MutableStateFlow>(Result.failure(NoSuchElementException())) - - override fun getVenue(id: String): Flow> = when (id) { - "conference" -> conferenceFlow - "afterparty" -> afterpartyFlow - else -> MutableStateFlow(Result.failure(NoSuchElementException("Unknown venue: $id"))) - } -} diff --git a/shared/domain/src/commonTest/kotlin/fr/androidmakers/domain/interactor/GetFeedUseCaseTest.kt b/shared/domain/src/commonTest/kotlin/fr/androidmakers/domain/interactor/GetFeedUseCaseTest.kt index 82931879..0ab425fc 100644 --- a/shared/domain/src/commonTest/kotlin/fr/androidmakers/domain/interactor/GetFeedUseCaseTest.kt +++ b/shared/domain/src/commonTest/kotlin/fr/androidmakers/domain/interactor/GetFeedUseCaseTest.kt @@ -1,9 +1,10 @@ package fr.androidmakers.domain.interactor import fr.androidmakers.domain.model.FeedItem -import fr.androidmakers.domain.model.Venue +import fr.androidmakers.domain.model.MessageType import kotlinx.coroutines.flow.first import kotlinx.coroutines.test.runTest +import kotlinx.datetime.Instant import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertTrue @@ -11,129 +12,44 @@ import kotlin.test.assertTrue class GetFeedUseCaseTest { private val feedRepo = FakeFeedRepository() - private val venueRepo = FakeVenueRepository() - private val useCase = GetFeedUseCase(feedRepo, venueRepo) - - private fun testVenue( - name: String = "Conference Center", - address: String = "123 Main St", - description: String = "A great venue", - floorPlanUrl: String? = null, - ) = Venue( - name = name, - address = address, - description = description, - floorPlanUrl = floorPlanUrl, - ) - - private fun testFeedItem(id: String = "feed-1") = FeedItem.Article( - id = id, - category = "NEWS", - timeAgo = "2h ago", - title = "Test Article", - description = "Test description", - ) + private val useCase = GetFeedUseCase(feedRepo) @Test - fun returnsMergedFeedAndVenueItems() = runTest { - val feedItems = listOf(testFeedItem("feed-1")) - val conference = testVenue(name = "Beffroi de Montrouge") - val afterparty = testVenue(name = "Party Place") - - feedRepo.feedItemsFlow.value = Result.success(feedItems) - venueRepo.conferenceFlow.value = Result.success(conference) - venueRepo.afterpartyFlow.value = Result.success(afterparty) + fun returnsFeedItemsFromRepository() = runTest { + val items = listOf( + FeedItem.Message( + id = "msg-1", + type = MessageType.INFO, + title = "Test", + body = "Body", + createdAt = Instant.fromEpochMilliseconds(1711800000000L), + ) + ) + feedRepo.feedItemsFlow.value = Result.success(items) val result = useCase().first() assertTrue(result.isSuccess) - val items = result.getOrThrow() - // 1 feed item + 1 conference venue + 1 afterparty venue = 3 - assertEquals(3, items.size) - assertEquals("feed-1", items[0].id) - assertEquals("venue-conference", items[1].id) - assertEquals("venue-afterparty", items[2].id) + assertEquals(1, result.getOrThrow().size) + assertEquals("msg-1", result.getOrThrow()[0].id) } @Test - fun returnsOnlyVenueItemsWhenFeedIsEmpty() = runTest { - val conference = testVenue() - val afterparty = testVenue(name = "Party") - + fun returnsEmptyListWhenNoItems() = runTest { feedRepo.feedItemsFlow.value = Result.success(emptyList()) - venueRepo.conferenceFlow.value = Result.success(conference) - venueRepo.afterpartyFlow.value = Result.success(afterparty) val result = useCase().first() assertTrue(result.isSuccess) - val items = result.getOrThrow() - assertEquals(2, items.size) - assertEquals("venue-conference", items[0].id) - assertEquals("venue-afterparty", items[1].id) + assertTrue(result.getOrThrow().isEmpty()) } @Test - fun returnsSuccessWithEmptyListWhenFeedFailsAndVenuesFail() = runTest { + fun propagatesFailureFromRepository() = runTest { feedRepo.feedItemsFlow.value = Result.failure(RuntimeException("Network error")) - venueRepo.conferenceFlow.value = Result.failure(RuntimeException("Venue error")) - venueRepo.afterpartyFlow.value = Result.failure(RuntimeException("Venue error")) - - val result = useCase().first() - - assertTrue(result.isSuccess) - val items = result.getOrThrow() - assertTrue(items.isEmpty()) - } - - @Test - fun includesFloorPlanWhenVenueHasFloorPlanUrl() = runTest { - val conference = testVenue(floorPlanUrl = "https://example.com/floorplan.png") - - feedRepo.feedItemsFlow.value = Result.success(emptyList()) - venueRepo.conferenceFlow.value = Result.success(conference) - venueRepo.afterpartyFlow.value = Result.failure(NoSuchElementException()) - - val result = useCase().first() - - assertTrue(result.isSuccess) - val items = result.getOrThrow() - assertEquals(2, items.size) - assertEquals("venue-conference", items[0].id) - assertEquals("venue-floorplan", items[1].id) - val floorPlan = items[1] as FeedItem.Article - assertEquals("https://example.com/floorplan.png", floorPlan.thumbnailUrl) - } - - @Test - fun doesNotIncludeFloorPlanWhenVenueHasNoFloorPlanUrl() = runTest { - val conference = testVenue(floorPlanUrl = null) - - feedRepo.feedItemsFlow.value = Result.success(emptyList()) - venueRepo.conferenceFlow.value = Result.success(conference) - venueRepo.afterpartyFlow.value = Result.failure(NoSuchElementException()) - - val result = useCase().first() - - assertTrue(result.isSuccess) - val items = result.getOrThrow() - assertEquals(1, items.size) - assertEquals("venue-conference", items[0].id) - } - - @Test - fun venueConferenceItemHasLocationWithAddress() = runTest { - val conference = testVenue(address = "10 rue de la Paix") - - feedRepo.feedItemsFlow.value = Result.success(emptyList()) - venueRepo.conferenceFlow.value = Result.success(conference) - venueRepo.afterpartyFlow.value = Result.failure(NoSuchElementException()) val result = useCase().first() - assertTrue(result.isSuccess) - val item = result.getOrThrow().first() as FeedItem.Article - assertEquals("10 rue de la Paix", item.location?.name) - assertEquals("", item.timeAgo) + assertTrue(result.isFailure) } } diff --git a/shared/ui/src/commonMain/kotlin/com/androidmakers/ui/MainLayout.kt b/shared/ui/src/commonMain/kotlin/com/androidmakers/ui/MainLayout.kt index a72ee0d4..0459ac38 100644 --- a/shared/ui/src/commonMain/kotlin/com/androidmakers/ui/MainLayout.kt +++ b/shared/ui/src/commonMain/kotlin/com/androidmakers/ui/MainLayout.kt @@ -2,7 +2,6 @@ package com.androidmakers.ui import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.material3.CircularProgressIndicator import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -10,10 +9,8 @@ import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.lifecycle.compose.collectAsStateWithLifecycle import coil3.ImageLoader import coil3.compose.setSingletonImageLoaderFactory import coil3.request.crossfade @@ -30,6 +27,7 @@ import com.androidmakers.ui.common.navigation.parseDeepLink import com.androidmakers.ui.common.navigation.rememberNavigationState import com.androidmakers.ui.theme.AndroidMakersTheme import fr.androidmakers.domain.PlatformContext +import fr.androidmakers.domain.model.FeatureFlags import fr.androidmakers.domain.model.ThemePreference import fr.androidmakers.domain.repo.FeatureFlagsRepository import fr.androidmakers.domain.repo.ThemeRepository @@ -83,7 +81,7 @@ fun MainLayout( } } - val featureFlags = remember { mutableStateOf(null) } + val featureFlags = remember { mutableStateOf(null) } LaunchedEffect(Unit) { featureFlags.value = featureFlagsRepository.flags() } diff --git a/shared/ui/src/commonMain/kotlin/com/androidmakers/ui/agenda/AgendaRow.kt b/shared/ui/src/commonMain/kotlin/com/androidmakers/ui/agenda/AgendaRow.kt index 2397a267..47925caf 100644 --- a/shared/ui/src/commonMain/kotlin/com/androidmakers/ui/agenda/AgendaRow.kt +++ b/shared/ui/src/commonMain/kotlin/com/androidmakers/ui/agenda/AgendaRow.kt @@ -135,16 +135,22 @@ internal fun SessionRow( language = uiSession.language, ) - // 4. Tags + // 4. Tags (max 3 visible + overflow indicator) if (uiSession.tags.isNotEmpty()) { + val maxVisible = 3 + val visibleTags = uiSession.tags.take(maxVisible) + val overflow = uiSession.tags.size - maxVisible FlowRow( modifier = Modifier.padding(top = 2.dp), horizontalArrangement = Arrangement.spacedBy(6.dp), verticalArrangement = Arrangement.spacedBy(6.dp), ) { - uiSession.tags.forEach { tag -> + visibleTags.forEach { tag -> TagChip(tag) } + if (overflow > 0) { + TagChip("+$overflow") + } } } @@ -169,13 +175,13 @@ private fun TagChip(tag: String) { Surface( modifier = Modifier.neoBrutalBorder(), shape = MaterialTheme.shapes.small, - color = MaterialTheme.colorScheme.secondaryContainer, + color = MaterialTheme.colorScheme.surfaceContainerHighest, ) { Text( text = tag, modifier = Modifier.padding(horizontal = 8.dp, vertical = 2.dp), style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSecondaryContainer, + color = MaterialTheme.colorScheme.onSurfaceVariant, ) } } diff --git a/shared/ui/src/commonMain/kotlin/com/androidmakers/ui/common/navigation/AVALayout.kt b/shared/ui/src/commonMain/kotlin/com/androidmakers/ui/common/navigation/AVALayout.kt index 2b3fa0e1..63473260 100644 --- a/shared/ui/src/commonMain/kotlin/com/androidmakers/ui/common/navigation/AVALayout.kt +++ b/shared/ui/src/commonMain/kotlin/com/androidmakers/ui/common/navigation/AVALayout.kt @@ -10,14 +10,11 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.CalendarMonth -import androidx.compose.material.icons.rounded.Diamond import androidx.compose.material.icons.rounded.DynamicFeed import androidx.compose.material.icons.rounded.Favorite import androidx.compose.material.icons.rounded.FilterList import androidx.compose.material.icons.rounded.Groups import androidx.compose.material.icons.rounded.Info -import androidx.compose.material.icons.rounded.LocationCity -import androidx.compose.material.icons.rounded.Pin import androidx.compose.material.icons.rounded.PinDrop import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.HorizontalDivider @@ -139,7 +136,7 @@ fun AVALayout( AVABottomBar( navigator = navigator, navigationState = navigationState, - featureFlags + featureFlags = featureFlags ) } }, @@ -232,7 +229,7 @@ private fun AVABottomBar( Column { HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant) NavigationBar(containerColor = MaterialTheme.colorScheme.surfaceContainer) { - if (featureFlags.feed) { + //if (featureFlags.feed) { NavigationBarItem( navigator = navigator, navigationState = navigationState, @@ -240,7 +237,7 @@ private fun AVABottomBar( label = stringResource(Res.string.feed), destinationRoute = FeedKey ) - } + //} NavigationBarItem( navigator = navigator, navigationState = navigationState, 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..b63cc289 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 @@ -27,7 +27,10 @@ fun FeedScreen() { contentPadding = PaddingValues(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp), ) { - items(visibleItems, key = { it.id }) { item -> + items( + items = visibleItems, + key = { it.id } + ) { item -> when (item) { is FeedItem.Alert -> { AlertBannerCard( @@ -35,6 +38,11 @@ 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/FeedViewModel.kt b/shared/ui/src/commonMain/kotlin/com/androidmakers/ui/feed/FeedViewModel.kt index 1a774dbd..31633a44 100644 --- a/shared/ui/src/commonMain/kotlin/com/androidmakers/ui/feed/FeedViewModel.kt +++ b/shared/ui/src/commonMain/kotlin/com/androidmakers/ui/feed/FeedViewModel.kt @@ -6,6 +6,7 @@ import fr.androidmakers.domain.model.FeedItem import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update class FeedViewModel( getFeedUseCase: GetFeedUseCase, @@ -16,6 +17,6 @@ class FeedViewModel( val dismissedAlertIds: StateFlow> = _dismissedAlertIds.asStateFlow() fun dismissAlert(alertId: String) { - _dismissedAlertIds.value = _dismissedAlertIds.value + alertId + _dismissedAlertIds.update { _dismissedAlertIds.value + alertId } } } 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..6d1aff05 --- /dev/null +++ b/shared/ui/src/commonMain/kotlin/com/androidmakers/ui/feed/MessageCard.kt @@ -0,0 +1,71 @@ +package com.androidmakers.ui.feed + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +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 +import kotlin.time.Duration + +@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 duration: Duration = now - this + val durationMinutes = duration.inWholeMinutes + return when { + durationMinutes < 1 -> "just now" + durationMinutes < 60 -> "${durationMinutes}m ago" + durationMinutes < 1440 -> "${durationMinutes / 60}h ago" + else -> "${durationMinutes / 1440}d ago" + } +} From be27bf72f52ec1e8d5cfebc3f13ef42fb05e7182 Mon Sep 17 00:00:00 2001 From: Renaud Mathieu Date: Sat, 28 Mar 2026 09:46:05 +0100 Subject: [PATCH 2/7] 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`. --- gradle/libs.versions.toml | 8 +- .../composeResources/values-fr/strings.xml | 1 + .../composeResources/values/strings.xml | 1 + .../kotlin/com/androidmakers/ui/MainLayout.kt | 6 +- .../com/androidmakers/ui/about/AboutScreen.kt | 346 +++++++++--------- .../ui/common/navigation/AVALayout.kt | 57 ++- .../ui/common/navigation/Routes.kt | 12 +- .../com/androidmakers/ui/info/InfoScreen.kt | 126 +++++++ .../com/androidmakers/ui/venue/VenuePager.kt | 14 +- 9 files changed, 348 insertions(+), 223 deletions(-) create mode 100644 shared/ui/src/commonMain/kotlin/com/androidmakers/ui/info/InfoScreen.kt diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index c18785fe..d5ebdb08 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -11,21 +11,21 @@ androidx-core-splashscreen = "1.2.0" androidx-credentials = "1.5.0" androidx-datastore = "1.2.1" androidx-lifecycle = "2.10.0" # Used by the Wear OS app only -androidx-wear-compose = "1.5.6" +androidx-wear-compose = "1.6.0" apollo = "4.4.2" apollo-adapters = "0.7.0" apollo-cache = "1.0.0" coil = "3.4.0" -compose = "1.10.5" # Used by the Wear OS app only +compose = "1.10.6" # Used by the Wear OS app only compose-material-icons-extended = "1.7.8" crashlytics-plugin = "3.0.6" firebase-auth = "2.4.0" -firebase-bom = "34.10.0" +firebase-bom = "34.11.0" google-services-plugin = "4.4.4" googleid = "1.2.0" horologist = "0.7.15" jetbrains-compose = "1.11.0-alpha04" -jetbrains-lifecycle = "2.10.0-beta01" +jetbrains-lifecycle = "2.10.0" jetbrains-material3-adaptive-nav3 = "1.3.0-alpha06" jetbrains-compose-material-icons-extended = "1.7.3" nav3-ui = "1.1.0-alpha04" diff --git a/shared/ui/src/commonMain/composeResources/values-fr/strings.xml b/shared/ui/src/commonMain/composeResources/values-fr/strings.xml index 69102567..35b3e595 100644 --- a/shared/ui/src/commonMain/composeResources/values-fr/strings.xml +++ b/shared/ui/src/commonMain/composeResources/values-fr/strings.xml @@ -7,6 +7,7 @@ Agenda Lieu À propos + Infos Sponsors Speakers diff --git a/shared/ui/src/commonMain/composeResources/values/strings.xml b/shared/ui/src/commonMain/composeResources/values/strings.xml index f48e88a9..05ed60f4 100644 --- a/shared/ui/src/commonMain/composeResources/values/strings.xml +++ b/shared/ui/src/commonMain/composeResources/values/strings.xml @@ -9,6 +9,7 @@ Agenda Venue About + Info Sponsors Speakers diff --git a/shared/ui/src/commonMain/kotlin/com/androidmakers/ui/MainLayout.kt b/shared/ui/src/commonMain/kotlin/com/androidmakers/ui/MainLayout.kt index 0459ac38..7b315a0c 100644 --- a/shared/ui/src/commonMain/kotlin/com/androidmakers/ui/MainLayout.kt +++ b/shared/ui/src/commonMain/kotlin/com/androidmakers/ui/MainLayout.kt @@ -16,13 +16,12 @@ import coil3.compose.setSingletonImageLoaderFactory import coil3.request.crossfade import com.androidmakers.ui.common.SigninCallbacks import com.androidmakers.ui.common.navigation.AVALayout -import com.androidmakers.ui.common.navigation.AboutKey import com.androidmakers.ui.common.navigation.AgendaKey import com.androidmakers.ui.common.navigation.FeedKey +import com.androidmakers.ui.common.navigation.InfoKey import com.androidmakers.ui.common.navigation.Navigator import com.androidmakers.ui.common.navigation.SpeakersKey import com.androidmakers.ui.common.navigation.SponsorsKey -import com.androidmakers.ui.common.navigation.VenueKey import com.androidmakers.ui.common.navigation.parseDeepLink import com.androidmakers.ui.common.navigation.rememberNavigationState import com.androidmakers.ui.theme.AndroidMakersTheme @@ -60,10 +59,9 @@ fun MainLayout( val topLevelRoutes = buildSet { add(FeedKey) add(AgendaKey) - add(VenueKey) add(SpeakersKey) add(SponsorsKey) - add(AboutKey) + add(InfoKey) } val navigationState = rememberNavigationState( diff --git a/shared/ui/src/commonMain/kotlin/com/androidmakers/ui/about/AboutScreen.kt b/shared/ui/src/commonMain/kotlin/com/androidmakers/ui/about/AboutScreen.kt index 8b9867a2..2a4deea5 100644 --- a/shared/ui/src/commonMain/kotlin/com/androidmakers/ui/about/AboutScreen.kt +++ b/shared/ui/src/commonMain/kotlin/com/androidmakers/ui/about/AboutScreen.kt @@ -11,7 +11,6 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.rememberScrollState @@ -48,9 +47,7 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.platform.LocalUriHandler -import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp @@ -73,7 +70,6 @@ import fr.paug.androidmakers.ui.github_repo import fr.paug.androidmakers.ui.ic_network_bluesky import fr.paug.androidmakers.ui.ic_network_x import fr.paug.androidmakers.ui.ic_network_youtube -import fr.paug.androidmakers.ui.logo_android_makers import fr.paug.androidmakers.ui.settings import fr.paug.androidmakers.ui.social import fr.paug.androidmakers.ui.theme @@ -89,28 +85,28 @@ import org.koin.compose.viewmodel.koinViewModel @Composable fun AboutScreen( - versionName: String, - versionCode: String, + versionName: String, + versionCode: String, ) { val viewModel = koinViewModel() val urlOpener = LocalUriHandler.current.toUrlOpener() val themePreference by viewModel.themePreference.collectAsStateWithLifecycle() Column( - modifier = Modifier - .fillMaxSize() - .verticalScroll(state = rememberScrollState()) - .padding(16.dp), - verticalArrangement = Arrangement.spacedBy(16.dp) + modifier = Modifier + .fillMaxSize() + .verticalScroll(state = rememberScrollState()) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) ) { HeroSection() AboutCard() LinksCard( - onFaqClick = { viewModel.openFaq(urlOpener) }, - onCocClick = { viewModel.openCoc(urlOpener) }, - onGithubRepoClick = { viewModel.openGithubRepo(urlOpener) } + onFaqClick = { viewModel.openFaq(urlOpener) }, + onCocClick = { viewModel.openCoc(urlOpener) }, + onGithubRepoClick = { viewModel.openGithubRepo(urlOpener) } ) SettingsCard( @@ -119,32 +115,31 @@ fun AboutScreen( ) SocialCard( - onXHashtagClick = { viewModel.openXHashtag(urlOpener) }, - onBlueskyLogoClick = { viewModel.openBlueSkyAccount(urlOpener) }, - onXLogoClick = { viewModel.openXAccount(urlOpener) }, - onYouTubeLogoClick = { viewModel.openYoutube(urlOpener) } + onXHashtagClick = { viewModel.openXHashtag(urlOpener) }, + onBlueskyLogoClick = { viewModel.openBlueSkyAccount(urlOpener) }, + onXLogoClick = { viewModel.openXAccount(urlOpener) }, + onYouTubeLogoClick = { viewModel.openYoutube(urlOpener) } ) val showDebugInfo by viewModel.showDebugInfo.collectAsStateWithLifecycle() var versionTapCount by remember { mutableIntStateOf(0) } Text( - modifier = Modifier - .fillMaxWidth() - .clickable { - if (!showDebugInfo) { - versionTapCount++ - if (versionTapCount >= 3) { - viewModel.setShowDebugInfo(true) - } - } + modifier = Modifier + .fillMaxWidth() + .clickable { + if (!showDebugInfo) { + versionTapCount++ + if (versionTapCount >= 3) { + viewModel.setShowDebugInfo(true) } - .padding(8.dp) - , - textAlign = TextAlign.Center, - text = stringResource(Res.string.version, versionName, versionCode), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant + } + } + .padding(8.dp), + textAlign = TextAlign.Center, + text = stringResource(Res.string.version, versionName, versionCode), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant ) if (showDebugInfo) { @@ -168,195 +163,196 @@ fun AboutScreen( } @Composable -private fun HeroSection() { +internal fun HeroSection() { Column( - modifier = Modifier.fillMaxWidth(), - horizontalAlignment = Alignment.CenterHorizontally + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally ) { - Image( - modifier = Modifier - .heightIn(max = 96.dp) - .fillMaxWidth() - .padding(horizontal = 32.dp), - painter = painterResource(Res.drawable.logo_android_makers), - contentDescription = "Android Makers" - ) + // Note Renaud: redundant with top app bar +// Image( +// modifier = Modifier +// .heightIn(max = 96.dp) +// .fillMaxWidth() +// .padding(horizontal = 32.dp), +// painter = painterResource(Res.drawable.logo_android_makers), +// contentDescription = "Android Makers" +// ) Text( - modifier = Modifier.padding(top = 8.dp), - text = stringResource(Res.string.about_event_tagline), - style = MaterialTheme.typography.headlineSmall, - color = MaterialTheme.colorScheme.onBackground, - textAlign = TextAlign.Center + modifier = Modifier.padding(top = 8.dp), + text = stringResource(Res.string.about_event_tagline), + style = MaterialTheme.typography.headlineSmall, + color = MaterialTheme.colorScheme.onBackground, + textAlign = TextAlign.Start ) } } @Composable -private fun SectionHeader(title: String) { +internal fun SectionHeader(title: String) { Text( - modifier = Modifier.padding(bottom = 8.dp), - text = title, - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.onSurface + modifier = Modifier.padding(bottom = 8.dp), + text = title, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurface ) } @Composable -private fun AboutCard() { +internal fun AboutCard() { Surface( - modifier = Modifier.fillMaxWidth().neoBrutalElevation(), - shape = MaterialTheme.shapes.large, - color = MaterialTheme.colorScheme.surfaceContainerHigh + modifier = Modifier.fillMaxWidth().neoBrutalElevation(), + shape = MaterialTheme.shapes.large, + color = MaterialTheme.colorScheme.surfaceContainerHigh ) { Column(modifier = Modifier.padding(16.dp)) { SectionHeader(title = stringResource(Res.string.about)) Text( - text = stringResource(Res.string.about_android_makers), - style = MaterialTheme.typography.bodyMedium.copy(lineHeight = 22.sp), - color = MaterialTheme.colorScheme.onSurfaceVariant + text = stringResource(Res.string.about_android_makers), + style = MaterialTheme.typography.bodyMedium.copy(lineHeight = 22.sp), + color = MaterialTheme.colorScheme.onSurfaceVariant ) } } } @Composable -private fun LinksCard( - onFaqClick: () -> Unit, - onCocClick: () -> Unit, - onGithubRepoClick: () -> Unit +internal fun LinksCard( + onFaqClick: () -> Unit, + onCocClick: () -> Unit, + onGithubRepoClick: () -> Unit ) { Surface( - modifier = Modifier.fillMaxWidth().neoBrutalElevation(), - shape = MaterialTheme.shapes.large, - color = MaterialTheme.colorScheme.surfaceContainerHigh + modifier = Modifier.fillMaxWidth().neoBrutalElevation(), + shape = MaterialTheme.shapes.large, + color = MaterialTheme.colorScheme.surfaceContainerHigh ) { Column(modifier = Modifier.padding(16.dp)) { SectionHeader(title = stringResource(Res.string.about_links)) LinkRow( - icon = Icons.Rounded.QuestionAnswer, - label = stringResource(Res.string.faq), - onClick = onFaqClick + icon = Icons.Rounded.QuestionAnswer, + label = stringResource(Res.string.faq), + onClick = onFaqClick ) HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant) LinkRow( - icon = Icons.Rounded.Gavel, - label = stringResource(Res.string.code_of_conduct), - onClick = onCocClick + icon = Icons.Rounded.Gavel, + label = stringResource(Res.string.code_of_conduct), + onClick = onCocClick ) HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant) LinkRow( - icon = Icons.Rounded.Code, - label = stringResource(Res.string.github_repo), - onClick = onGithubRepoClick + icon = Icons.Rounded.Code, + label = stringResource(Res.string.github_repo), + onClick = onGithubRepoClick ) } } } @Composable -private fun LinkRow( - icon: ImageVector, - label: String, - onClick: () -> Unit +internal fun LinkRow( + icon: ImageVector, + label: String, + onClick: () -> Unit ) { Row( - modifier = Modifier - .fillMaxWidth() - .clickable(onClick = onClick) - .padding(vertical = 12.dp), - verticalAlignment = Alignment.CenterVertically + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically ) { Icon( - imageVector = icon, - contentDescription = null, - tint = MaterialTheme.colorScheme.primary, - modifier = Modifier.size(24.dp) + imageVector = icon, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(24.dp) ) Text( - modifier = Modifier - .weight(1f) - .padding(horizontal = 16.dp), - text = label, - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurface + modifier = Modifier + .weight(1f) + .padding(horizontal = 16.dp), + text = label, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface ) Icon( - imageVector = Icons.AutoMirrored.Rounded.KeyboardArrowRight, - contentDescription = null, - tint = MaterialTheme.colorScheme.onSurfaceVariant + imageVector = Icons.AutoMirrored.Rounded.KeyboardArrowRight, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant ) } } @Composable -private fun SocialCard( - onXHashtagClick: () -> Unit, - onBlueskyLogoClick: () -> Unit, - onXLogoClick: () -> Unit, - onYouTubeLogoClick: () -> Unit +internal fun SocialCard( + onXHashtagClick: () -> Unit, + onBlueskyLogoClick: () -> Unit, + onXLogoClick: () -> Unit, + onYouTubeLogoClick: () -> Unit ) { val darkMode = LocalIsDarkTheme.current Surface( - modifier = Modifier.fillMaxWidth().neoBrutalElevation(), - shape = MaterialTheme.shapes.large, - color = MaterialTheme.colorScheme.surfaceContainerHigh + modifier = Modifier.fillMaxWidth().neoBrutalElevation(), + shape = MaterialTheme.shapes.large, + color = MaterialTheme.colorScheme.surfaceContainerHigh ) { Column( - modifier = Modifier.padding(16.dp), - horizontalAlignment = Alignment.CenterHorizontally + modifier = Modifier.padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally ) { SectionHeader(title = stringResource(Res.string.social)) Box( - modifier = Modifier - .clip(MaterialTheme.shapes.small) - .background(MaterialTheme.colorScheme.primaryContainer) - .clickable(onClick = onXHashtagClick) - .padding(horizontal = 16.dp, vertical = 8.dp) + modifier = Modifier + .clip(MaterialTheme.shapes.small) + .background(MaterialTheme.colorScheme.primaryContainer) + .clickable(onClick = onXHashtagClick) + .padding(horizontal = 16.dp, vertical = 8.dp) ) { Text( - text = stringResource(Res.string.x_hashtag), - style = MaterialTheme.typography.labelLarge, - fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.onPrimaryContainer + text = stringResource(Res.string.x_hashtag), + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onPrimaryContainer ) } Row( - modifier = Modifier.padding(top = 16.dp), - horizontalArrangement = Arrangement.spacedBy(24.dp) + modifier = Modifier.padding(top = 16.dp), + horizontalArrangement = Arrangement.spacedBy(24.dp) ) { SocialIconWithLabel( - onClick = onBlueskyLogoClick, - label = "Bluesky" + onClick = onBlueskyLogoClick, + label = "Bluesky" ) { Icon( - modifier = Modifier.size(24.dp), - painter = painterResource(Res.drawable.ic_network_bluesky), - contentDescription = "Bluesky" + modifier = Modifier.size(24.dp), + painter = painterResource(Res.drawable.ic_network_bluesky), + contentDescription = "Bluesky" ) } SocialIconWithLabel( - onClick = onXLogoClick, - label = "X" + onClick = onXLogoClick, + label = "X" ) { Icon( - modifier = Modifier.size(24.dp), - painter = painterResource(Res.drawable.ic_network_x), - tint = if (darkMode) Color.White else Color.Black, - contentDescription = "X" + modifier = Modifier.size(24.dp), + painter = painterResource(Res.drawable.ic_network_x), + tint = if (darkMode) Color.White else Color.Black, + contentDescription = "X" ) } SocialIconWithLabel( - onClick = onYouTubeLogoClick, - label = "YouTube" + onClick = onYouTubeLogoClick, + label = "YouTube" ) { Image( - modifier = Modifier.size(24.dp), - painter = painterResource(Res.drawable.ic_network_youtube), - contentDescription = "YouTube" + modifier = Modifier.size(24.dp), + painter = painterResource(Res.drawable.ic_network_youtube), + contentDescription = "YouTube" ) } } @@ -365,78 +361,78 @@ private fun SocialCard( } @Composable -private fun SocialIconWithLabel( - onClick: () -> Unit, - label: String, - content: @Composable () -> Unit, +internal fun SocialIconWithLabel( + onClick: () -> Unit, + label: String, + content: @Composable () -> Unit, ) { Column( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(4.dp) + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(4.dp) ) { val circularShape = if (LocalIsNeobrutalism.current) RectangleShape else CircleShape Surface( - modifier = Modifier.size(56.dp).neoBrutalBorder(), - shape = circularShape, - color = MaterialTheme.colorScheme.surfaceContainer, - onClick = onClick + modifier = Modifier.size(56.dp).neoBrutalBorder(), + shape = circularShape, + color = MaterialTheme.colorScheme.surfaceContainer, + onClick = onClick ) { Box(contentAlignment = Alignment.Center) { content() } } Text( - text = label, - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant + text = label, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant ) } } @OptIn(ExperimentalMaterial3Api::class) @Composable -private fun SettingsCard( - themePreference: ThemePreference, - onThemePreferenceChange: (ThemePreference) -> Unit +internal fun SettingsCard( + themePreference: ThemePreference, + onThemePreferenceChange: (ThemePreference) -> Unit ) { Surface( - modifier = Modifier.fillMaxWidth().neoBrutalElevation(), - shape = MaterialTheme.shapes.large, - color = MaterialTheme.colorScheme.surfaceContainerHigh + modifier = Modifier.fillMaxWidth().neoBrutalElevation(), + shape = MaterialTheme.shapes.large, + color = MaterialTheme.colorScheme.surfaceContainerHigh ) { Column(modifier = Modifier.padding(16.dp)) { SectionHeader(title = stringResource(Res.string.settings)) Text( - text = stringResource(Res.string.theme), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(bottom = 8.dp) + text = stringResource(Res.string.theme), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(bottom = 8.dp) ) val options = ThemePreference.entries val icons = mapOf( - ThemePreference.System to Icons.Rounded.SettingsBrightness, - ThemePreference.Light to Icons.Rounded.LightMode, - ThemePreference.Dark to Icons.Rounded.DarkMode, - ThemePreference.Neobrutalism to Icons.Rounded.AutoAwesome, + ThemePreference.System to Icons.Rounded.SettingsBrightness, + ThemePreference.Light to Icons.Rounded.LightMode, + ThemePreference.Dark to Icons.Rounded.DarkMode, + ThemePreference.Neobrutalism to Icons.Rounded.AutoAwesome, ) val labels = mapOf( - ThemePreference.System to stringResource(Res.string.theme_system), - ThemePreference.Light to stringResource(Res.string.theme_light), - ThemePreference.Dark to stringResource(Res.string.theme_dark), - ThemePreference.Neobrutalism to stringResource(Res.string.theme_neobrutalism), + ThemePreference.System to stringResource(Res.string.theme_system), + ThemePreference.Light to stringResource(Res.string.theme_light), + ThemePreference.Dark to stringResource(Res.string.theme_dark), + ThemePreference.Neobrutalism to stringResource(Res.string.theme_neobrutalism), ) SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) { options.forEachIndexed { index, option -> SegmentedButton( - selected = themePreference == option, - onClick = { onThemePreferenceChange(option) }, - shape = SegmentedButtonDefaults.itemShape(index = index, count = options.size), - icon = {}, + selected = themePreference == option, + onClick = { onThemePreferenceChange(option) }, + shape = SegmentedButtonDefaults.itemShape(index = index, count = options.size), + icon = {}, ) { Icon( - imageVector = icons[option]!!, - contentDescription = labels[option], - modifier = Modifier.size(20.dp), + imageVector = icons[option]!!, + contentDescription = labels[option], + modifier = Modifier.size(20.dp), ) } } diff --git a/shared/ui/src/commonMain/kotlin/com/androidmakers/ui/common/navigation/AVALayout.kt b/shared/ui/src/commonMain/kotlin/com/androidmakers/ui/common/navigation/AVALayout.kt index 63473260..0f6588e7 100644 --- a/shared/ui/src/commonMain/kotlin/com/androidmakers/ui/common/navigation/AVALayout.kt +++ b/shared/ui/src/commonMain/kotlin/com/androidmakers/ui/common/navigation/AVALayout.kt @@ -15,7 +15,6 @@ import androidx.compose.material.icons.rounded.Favorite import androidx.compose.material.icons.rounded.FilterList import androidx.compose.material.icons.rounded.Groups import androidx.compose.material.icons.rounded.Info -import androidx.compose.material.icons.rounded.PinDrop import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon @@ -50,31 +49,29 @@ import androidx.navigation3.runtime.entryProvider import androidx.navigation3.ui.LocalNavAnimatedContentScope import androidx.navigation3.ui.NavDisplay import androidx.window.core.layout.WindowSizeClass.Companion.WIDTH_DP_MEDIUM_LOWER_BOUND -import com.androidmakers.ui.about.AboutScreen import com.androidmakers.ui.agenda.AgendaLayout import com.androidmakers.ui.agenda.SessionDetailScreen import com.androidmakers.ui.common.BackHandlerCompat import com.androidmakers.ui.common.SigninButton import com.androidmakers.ui.common.SigninCallbacks import com.androidmakers.ui.feed.FeedScreen +import com.androidmakers.ui.info.InfoScreen import com.androidmakers.ui.speakers.SpeakerDetailsRoute import com.androidmakers.ui.speakers.SpeakerScreen import com.androidmakers.ui.sponsors.SponsorsScreen -import com.androidmakers.ui.venue.VenuePager import fr.androidmakers.domain.model.FeatureFlags import fr.androidmakers.domain.model.User import fr.androidmakers.domain.repo.UserRepository import fr.paug.androidmakers.ui.Res -import fr.paug.androidmakers.ui.about import fr.paug.androidmakers.ui.agenda import fr.paug.androidmakers.ui.feed import fr.paug.androidmakers.ui.filter +import fr.paug.androidmakers.ui.info import fr.paug.androidmakers.ui.notification import fr.paug.androidmakers.ui.speakers import fr.paug.androidmakers.ui.sponsors import fr.paug.androidmakers.ui.top_bar_subtitle import fr.paug.androidmakers.ui.top_bar_title -import fr.paug.androidmakers.ui.venue import org.jetbrains.compose.resources.painterResource import org.jetbrains.compose.resources.stringResource import org.koin.compose.koinInject @@ -153,6 +150,7 @@ fun AVALayout( showAgendaFilterBottomSheet = showAgendaFilterBottomSheet, onDismissAgendaFilter = { showAgendaFilterBottomSheet = false }, isWideScreen = isWideScreen, + featureFlags = featureFlags, ) } } @@ -229,15 +227,7 @@ private fun AVABottomBar( Column { HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant) NavigationBar(containerColor = MaterialTheme.colorScheme.surfaceContainer) { - //if (featureFlags.feed) { - NavigationBarItem( - navigator = navigator, - navigationState = navigationState, - imageVector = Icons.Rounded.DynamicFeed, - label = stringResource(Res.string.feed), - destinationRoute = FeedKey - ) - //} + NavigationBarItem( navigator = navigator, navigationState = navigationState, @@ -245,6 +235,7 @@ private fun AVABottomBar( label = stringResource(Res.string.agenda), destinationRoute = AgendaKey ) + NavigationBarItem( navigator = navigator, navigationState = navigationState, @@ -252,6 +243,17 @@ private fun AVABottomBar( label = stringResource(Res.string.speakers), destinationRoute = SpeakersKey ) + + if (featureFlags.feed) { + NavigationBarItem( + navigator = navigator, + navigationState = navigationState, + imageVector = Icons.Rounded.DynamicFeed, + label = stringResource(Res.string.feed), + destinationRoute = FeedKey + ) + } + NavigationBarItem( navigator = navigator, navigationState = navigationState, @@ -259,22 +261,15 @@ private fun AVABottomBar( label = stringResource(Res.string.sponsors), destinationRoute = SponsorsKey ) - if (featureFlags.venue) { - NavigationBarItem( - navigator = navigator, - navigationState = navigationState, - imageVector = Icons.Rounded.PinDrop, - label = stringResource(Res.string.venue), - destinationRoute = VenueKey - ) - } + NavigationBarItem( navigator = navigator, navigationState = navigationState, imageVector = Icons.Rounded.Info, - label = stringResource(Res.string.about), - destinationRoute = AboutKey + label = stringResource(Res.string.info), + destinationRoute = InfoKey ) + } } } @@ -289,6 +284,7 @@ private fun AVANavDisplay( showAgendaFilterBottomSheet: Boolean, onDismissAgendaFilter: () -> Unit, isWideScreen: Boolean, + featureFlags: FeatureFlags, ) { SharedTransitionLayout { val sharedTransitionScope = this @@ -311,10 +307,6 @@ private fun AVANavDisplay( ) } - entry { - VenuePager() - } - entry { SpeakerScreen( viewModel = koinViewModel(), @@ -328,10 +320,11 @@ private fun AVANavDisplay( SponsorsScreen() } - entry { - AboutScreen( + entry { + InfoScreen( versionCode = versionCode, - versionName = versionName + versionName = versionName, + featureFlags = featureFlags, ) } diff --git a/shared/ui/src/commonMain/kotlin/com/androidmakers/ui/common/navigation/Routes.kt b/shared/ui/src/commonMain/kotlin/com/androidmakers/ui/common/navigation/Routes.kt index 81c12319..1eed3505 100644 --- a/shared/ui/src/commonMain/kotlin/com/androidmakers/ui/common/navigation/Routes.kt +++ b/shared/ui/src/commonMain/kotlin/com/androidmakers/ui/common/navigation/Routes.kt @@ -9,9 +9,12 @@ import kotlinx.serialization.modules.polymorphic // Top-level tab routes (bottom nav) @Serializable data object FeedKey : NavKey @Serializable data object AgendaKey : NavKey -@Serializable data object VenueKey : NavKey @Serializable data object SpeakersKey : NavKey @Serializable data object SponsorsKey : NavKey +@Serializable data object InfoKey : NavKey + +// Legacy keys kept for serialization backward compat +@Serializable data object VenueKey : NavKey @Serializable data object AboutKey : NavKey // Detail routes (full-screen, no bottom nav) @@ -23,9 +26,10 @@ val navKeySavedStateConfig = SavedStateConfiguration { polymorphic(NavKey::class) { subclass(FeedKey::class, FeedKey.serializer()) subclass(AgendaKey::class, AgendaKey.serializer()) - subclass(VenueKey::class, VenueKey.serializer()) subclass(SpeakersKey::class, SpeakersKey.serializer()) subclass(SponsorsKey::class, SponsorsKey.serializer()) + subclass(InfoKey::class, InfoKey.serializer()) + subclass(VenueKey::class, VenueKey.serializer()) subclass(AboutKey::class, AboutKey.serializer()) subclass(SessionDetailKey::class, SessionDetailKey.serializer()) subclass(SpeakerDetailKey::class, SpeakerDetailKey.serializer()) @@ -34,5 +38,5 @@ val navKeySavedStateConfig = SavedStateConfiguration { } fun NavKey.isTabKey(): Boolean = - this is FeedKey || this is AgendaKey || this is VenueKey || - this is SpeakersKey || this is SponsorsKey || this is AboutKey + this is FeedKey || this is AgendaKey || + this is SpeakersKey || this is SponsorsKey || this is InfoKey diff --git a/shared/ui/src/commonMain/kotlin/com/androidmakers/ui/info/InfoScreen.kt b/shared/ui/src/commonMain/kotlin/com/androidmakers/ui/info/InfoScreen.kt new file mode 100644 index 00000000..449d8b6b --- /dev/null +++ b/shared/ui/src/commonMain/kotlin/com/androidmakers/ui/info/InfoScreen.kt @@ -0,0 +1,126 @@ +package com.androidmakers.ui.info + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.androidmakers.ui.about.AboutCard +import com.androidmakers.ui.about.AboutViewModel +import com.androidmakers.ui.about.HeroSection +import com.androidmakers.ui.about.LinksCard +import com.androidmakers.ui.about.SettingsCard +import com.androidmakers.ui.about.SocialCard +import com.androidmakers.ui.common.toUrlOpener +import com.androidmakers.ui.theme.neoBrutalElevation +import com.androidmakers.ui.venue.VenuePager +import fr.androidmakers.domain.model.FeatureFlags +import fr.paug.androidmakers.ui.Res +import fr.paug.androidmakers.ui.version +import org.jetbrains.compose.resources.stringResource +import org.koin.compose.viewmodel.koinViewModel + +@Composable +fun InfoScreen( + versionName: String, + versionCode: String, + featureFlags: FeatureFlags, +) { + + val viewModel = koinViewModel() + val urlOpener = LocalUriHandler.current.toUrlOpener() + val themePreference by viewModel.themePreference.collectAsStateWithLifecycle() + + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(state = rememberScrollState()) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + + HeroSection() + + AboutCard() + + if (featureFlags.venue) { + Surface( + modifier = Modifier.fillMaxWidth().neoBrutalElevation(), + shape = MaterialTheme.shapes.large, + color = MaterialTheme.colorScheme.surfaceContainerHigh + ) { + VenuePager( + modifier = Modifier + .fillMaxWidth() + .height(500.dp) + ) + } + } + + LinksCard( + onFaqClick = { viewModel.openFaq(urlOpener) }, + onCocClick = { viewModel.openCoc(urlOpener) }, + onGithubRepoClick = { viewModel.openGithubRepo(urlOpener) } + ) + + SettingsCard( + themePreference = themePreference, + onThemePreferenceChange = { viewModel.setThemePreference(it) } + ) + + SocialCard( + onXHashtagClick = { viewModel.openXHashtag(urlOpener) }, + onBlueskyLogoClick = { viewModel.openBlueSkyAccount(urlOpener) }, + onXLogoClick = { viewModel.openXAccount(urlOpener) }, + onYouTubeLogoClick = { viewModel.openYoutube(urlOpener) } + ) + + val showDebugInfo by viewModel.showDebugInfo.collectAsStateWithLifecycle() + var versionTapCount by remember { mutableIntStateOf(0) } + + Text( + modifier = Modifier + .fillMaxWidth() + .clickable { + if (!showDebugInfo) { + versionTapCount++ + if (versionTapCount >= 3) { + viewModel.setShowDebugInfo(true) + } + } + } + .padding(8.dp), + textAlign = TextAlign.Center, + text = stringResource(Res.string.version, versionName, versionCode), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + if (showDebugInfo) { + Text( + modifier = Modifier.align(Alignment.CenterHorizontally), + text = "UID: ${viewModel.userUid ?: "Not signed in"}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } +} diff --git a/shared/ui/src/commonMain/kotlin/com/androidmakers/ui/venue/VenuePager.kt b/shared/ui/src/commonMain/kotlin/com/androidmakers/ui/venue/VenuePager.kt index ce2234d8..bede1378 100644 --- a/shared/ui/src/commonMain/kotlin/com/androidmakers/ui/venue/VenuePager.kt +++ b/shared/ui/src/commonMain/kotlin/com/androidmakers/ui/venue/VenuePager.kt @@ -2,6 +2,7 @@ package com.androidmakers.ui.venue import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.material3.MaterialTheme @@ -13,6 +14,7 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.androidmakers.ui.common.LceLayout import com.androidmakers.ui.getPlatformContext @@ -29,10 +31,14 @@ import org.jetbrains.compose.resources.stringResource import org.koin.compose.viewmodel.koinViewModel @Composable -fun VenuePager() { +fun VenuePager(modifier: Modifier = Modifier) { val viewModel = koinViewModel() - Column(modifier = Modifier.fillMaxWidth()) { + Column( + modifier = modifier + .fillMaxWidth() + .padding(16.dp) + ) { val titles = listOf( Res.string.venue_conference_tab, Res.string.venue_afterparty_tab, @@ -58,8 +64,8 @@ private fun VenueTabRow( ) { TabRow( selectedTabIndex = pagerState.currentPage, - containerColor = MaterialTheme.colorScheme.background, - contentColor = MaterialTheme.colorScheme.onBackground + containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, + contentColor = MaterialTheme.colorScheme.onSurface ) { repeat(titles.size) { val coroutineScope = rememberCoroutineScope() From b9d560abf82e23b0085f17c1afd01624a51837fb Mon Sep 17 00:00:00 2001 From: Renaud Mathieu Date: Sat, 28 Mar 2026 11:20:50 +0100 Subject: [PATCH 3/7] 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`. --- androidApp/build.gradle.kts | 1 - .../fr/paug/androidmakers/MainActivity.kt | 32 ++++- shared/ui/build.gradle.kts | 1 - .../ui/common/SigninButton.android.kt | 6 +- .../composeResources/drawable/0.xml | 11 ++ .../drawable/ic_account_circle.xml | 11 ++ .../drawable/ic_arrow_back.xml | 11 ++ .../drawable/ic_auto_awesome.xml | 11 ++ .../composeResources/drawable/ic_bookmark.xml | 11 ++ .../drawable/ic_bookmark_add.xml | 11 ++ .../drawable/ic_bookmark_added.xml | 11 ++ .../drawable/ic_calendar_month.xml | 11 ++ .../drawable/ic_calendar_month_outlined.xml | 11 ++ .../composeResources/drawable/ic_clear.xml | 11 ++ .../composeResources/drawable/ic_close.xml | 11 ++ .../composeResources/drawable/ic_code.xml | 11 ++ .../drawable/ic_dark_mode.xml | 11 ++ .../drawable/ic_dynamic_feed.xml | 11 ++ .../drawable/ic_dynamic_feed_outlined.xml | 11 ++ .../composeResources/drawable/ic_favorite.xml | 11 ++ .../drawable/ic_favorite_border.xml | 11 ++ .../drawable/ic_filter_list.xml | 11 ++ .../composeResources/drawable/ic_gavel.xml | 11 ++ .../composeResources/drawable/ic_groups.xml | 11 ++ .../drawable/ic_groups_outlined.xml | 11 ++ .../composeResources/drawable/ic_info.xml | 11 ++ .../drawable/ic_info_outlined.xml | 11 ++ .../drawable/ic_keyboard_arrow_right.xml | 11 ++ .../drawable/ic_light_mode.xml | 11 ++ .../drawable/ic_location_on.xml | 11 ++ .../composeResources/drawable/ic_person.xml | 11 ++ .../drawable/ic_play_arrow.xml | 11 ++ .../composeResources/drawable/ic_public.xml | 11 ++ .../drawable/ic_question_answer.xml | 11 ++ .../composeResources/drawable/ic_schedule.xml | 11 ++ .../composeResources/drawable/ic_search.xml | 11 ++ .../drawable/ic_settings_brightness.xml | 11 ++ .../composeResources/drawable/ic_share.xml | 11 ++ .../composeResources/drawable/ic_warning.xml | 11 ++ .../com/androidmakers/ui/about/AboutScreen.kt | 125 +++++++++-------- .../androidmakers/ui/agenda/AgendaColumn.kt | 7 +- .../androidmakers/ui/agenda/AgendaLayout.kt | 10 +- .../com/androidmakers/ui/agenda/AgendaRow.kt | 63 +++++---- .../ui/agenda/SessionDetailLayout.kt | 127 +++++++++--------- .../ui/common/GraphQLFeedbackLayout.kt | 7 +- .../androidmakers/ui/common/SocialIcons.kt | 5 +- .../ui/common/navigation/AVALayout.kt | 109 ++++++++------- .../androidmakers/ui/feed/AlertBannerCard.kt | 11 +- .../com/androidmakers/ui/feed/ArticleCards.kt | 42 +++--- .../com/androidmakers/ui/feed/MessageCard.kt | 16 ++- .../ui/speakers/SpeakerDetailsScreen.kt | 6 +- .../ui/speakers/SpeakerListScreen.kt | 14 +- .../ui/sponsors/SponsorScreen.kt | 15 ++- .../ui/common/SigninButton.jvm.kt | 6 +- 54 files changed, 724 insertions(+), 264 deletions(-) create mode 100644 shared/ui/src/commonMain/composeResources/drawable/0.xml create mode 100644 shared/ui/src/commonMain/composeResources/drawable/ic_account_circle.xml create mode 100644 shared/ui/src/commonMain/composeResources/drawable/ic_arrow_back.xml create mode 100644 shared/ui/src/commonMain/composeResources/drawable/ic_auto_awesome.xml create mode 100644 shared/ui/src/commonMain/composeResources/drawable/ic_bookmark.xml create mode 100644 shared/ui/src/commonMain/composeResources/drawable/ic_bookmark_add.xml create mode 100644 shared/ui/src/commonMain/composeResources/drawable/ic_bookmark_added.xml create mode 100644 shared/ui/src/commonMain/composeResources/drawable/ic_calendar_month.xml create mode 100644 shared/ui/src/commonMain/composeResources/drawable/ic_calendar_month_outlined.xml create mode 100644 shared/ui/src/commonMain/composeResources/drawable/ic_clear.xml create mode 100644 shared/ui/src/commonMain/composeResources/drawable/ic_close.xml create mode 100644 shared/ui/src/commonMain/composeResources/drawable/ic_code.xml create mode 100644 shared/ui/src/commonMain/composeResources/drawable/ic_dark_mode.xml create mode 100644 shared/ui/src/commonMain/composeResources/drawable/ic_dynamic_feed.xml create mode 100644 shared/ui/src/commonMain/composeResources/drawable/ic_dynamic_feed_outlined.xml create mode 100644 shared/ui/src/commonMain/composeResources/drawable/ic_favorite.xml create mode 100644 shared/ui/src/commonMain/composeResources/drawable/ic_favorite_border.xml create mode 100644 shared/ui/src/commonMain/composeResources/drawable/ic_filter_list.xml create mode 100644 shared/ui/src/commonMain/composeResources/drawable/ic_gavel.xml create mode 100644 shared/ui/src/commonMain/composeResources/drawable/ic_groups.xml create mode 100644 shared/ui/src/commonMain/composeResources/drawable/ic_groups_outlined.xml create mode 100644 shared/ui/src/commonMain/composeResources/drawable/ic_info.xml create mode 100644 shared/ui/src/commonMain/composeResources/drawable/ic_info_outlined.xml create mode 100644 shared/ui/src/commonMain/composeResources/drawable/ic_keyboard_arrow_right.xml create mode 100644 shared/ui/src/commonMain/composeResources/drawable/ic_light_mode.xml create mode 100644 shared/ui/src/commonMain/composeResources/drawable/ic_location_on.xml create mode 100644 shared/ui/src/commonMain/composeResources/drawable/ic_person.xml create mode 100644 shared/ui/src/commonMain/composeResources/drawable/ic_play_arrow.xml create mode 100644 shared/ui/src/commonMain/composeResources/drawable/ic_public.xml create mode 100644 shared/ui/src/commonMain/composeResources/drawable/ic_question_answer.xml create mode 100644 shared/ui/src/commonMain/composeResources/drawable/ic_schedule.xml create mode 100644 shared/ui/src/commonMain/composeResources/drawable/ic_search.xml create mode 100644 shared/ui/src/commonMain/composeResources/drawable/ic_settings_brightness.xml create mode 100644 shared/ui/src/commonMain/composeResources/drawable/ic_share.xml create mode 100644 shared/ui/src/commonMain/composeResources/drawable/ic_warning.xml diff --git a/androidApp/build.gradle.kts b/androidApp/build.gradle.kts index d92fc97a..e3eb16d7 100644 --- a/androidApp/build.gradle.kts +++ b/androidApp/build.gradle.kts @@ -50,7 +50,6 @@ dependencies { testImplementation(libs.junit) implementation(libs.jetbrains.compose.material3) - implementation(libs.jetbrains.compose.material.icons.extended) implementation(libs.jetbrains.compose.ui.tooling) coreLibraryDesugaring(libs.desugar.jdk.libs) diff --git a/androidApp/src/main/java/fr/paug/androidmakers/MainActivity.kt b/androidApp/src/main/java/fr/paug/androidmakers/MainActivity.kt index 9a298b4a..ecd81d84 100644 --- a/androidApp/src/main/java/fr/paug/androidmakers/MainActivity.kt +++ b/androidApp/src/main/java/fr/paug/androidmakers/MainActivity.kt @@ -7,10 +7,14 @@ import android.os.Build import android.os.Bundle import android.util.Log import androidx.activity.ComponentActivity +import androidx.activity.SystemBarStyle import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.activity.result.contract.ActivityResultContracts import androidx.core.content.ContextCompat +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.produceState import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen @@ -24,6 +28,8 @@ import androidx.credentials.exceptions.NoCredentialException import androidx.lifecycle.lifecycleScope import com.androidmakers.ui.MainLayout import com.androidmakers.ui.common.SigninCallbacks +import fr.androidmakers.domain.model.ThemePreference +import fr.androidmakers.domain.repo.ThemeRepository import com.google.android.libraries.identity.googleid.GetSignInWithGoogleOption import com.google.android.libraries.identity.googleid.GoogleIdTokenCredential import com.google.android.libraries.identity.googleid.GoogleIdTokenParsingException @@ -56,13 +62,37 @@ class MainActivity : ComponentActivity() { installSplashScreen() super.onCreate(savedInstanceState) - enableEdgeToEdge() requestNotificationPermission() logFCMToken() val initialDeepLink: String? = if (savedInstanceState == null) intent.dataString else null setContent { + val themeRepository: ThemeRepository = org.koin.compose.koinInject() + val themePreference by themeRepository.themePreference.collectAsState(ThemePreference.System) + val isDark = when (themePreference) { + ThemePreference.System -> isSystemInDarkTheme() + ThemePreference.Light -> false + ThemePreference.Dark -> true + ThemePreference.Neobrutalism -> false + } + + DisposableEffect(isDark) { + enableEdgeToEdge( + statusBarStyle = if (isDark) { + SystemBarStyle.dark(android.graphics.Color.TRANSPARENT) + } else { + SystemBarStyle.light(android.graphics.Color.TRANSPARENT, android.graphics.Color.TRANSPARENT) + }, + navigationBarStyle = if (isDark) { + SystemBarStyle.dark(android.graphics.Color.TRANSPARENT) + } else { + SystemBarStyle.light(android.graphics.Color.TRANSPARENT, android.graphics.Color.TRANSPARENT) + }, + ) + onDispose {} + } + val deeplink: String? by produceState(initialDeepLink) { val listener = Consumer { newIntent -> newIntent.dataString?.let { diff --git a/shared/ui/build.gradle.kts b/shared/ui/build.gradle.kts index a932ab85..24d35902 100644 --- a/shared/ui/build.gradle.kts +++ b/shared/ui/build.gradle.kts @@ -17,7 +17,6 @@ kotlin { implementation(libs.jetbrains.compose.foundation) implementation(libs.jetbrains.compose.ui) implementation(libs.jetbrains.compose.material3) - implementation(libs.jetbrains.compose.material.icons.extended) implementation(libs.jetbrains.compose.components.resources) implementation(libs.jetbrains.compose.ui.tooling.preview) implementation(libs.coil.compose) diff --git a/shared/ui/src/androidMain/kotlin/com/androidmakers/ui/common/SigninButton.android.kt b/shared/ui/src/androidMain/kotlin/com/androidmakers/ui/common/SigninButton.android.kt index f8b9b7f9..3e844322 100644 --- a/shared/ui/src/androidMain/kotlin/com/androidmakers/ui/common/SigninButton.android.kt +++ b/shared/ui/src/androidMain/kotlin/com/androidmakers/ui/common/SigninButton.android.kt @@ -1,8 +1,6 @@ package com.androidmakers.ui.common import androidx.compose.foundation.shape.CircleShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.rounded.AccountCircle import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.Icon @@ -16,6 +14,8 @@ import androidx.compose.ui.draw.clip import coil3.compose.AsyncImage import fr.androidmakers.domain.model.User import fr.paug.androidmakers.ui.Res +import fr.paug.androidmakers.ui.ic_account_circle +import org.jetbrains.compose.resources.painterResource import fr.paug.androidmakers.ui.signin import fr.paug.androidmakers.ui.signout import org.jetbrains.compose.resources.stringResource @@ -34,7 +34,7 @@ actual fun SigninButton( ) { if (user == null) { Icon( - imageVector = Icons.Rounded.AccountCircle, + painter = painterResource(Res.drawable.ic_account_circle), contentDescription = stringResource(Res.string.signin) ) } else { diff --git a/shared/ui/src/commonMain/composeResources/drawable/0.xml b/shared/ui/src/commonMain/composeResources/drawable/0.xml new file mode 100644 index 00000000..a00635de --- /dev/null +++ b/shared/ui/src/commonMain/composeResources/drawable/0.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/shared/ui/src/commonMain/composeResources/drawable/ic_account_circle.xml b/shared/ui/src/commonMain/composeResources/drawable/ic_account_circle.xml new file mode 100644 index 00000000..23a07e30 --- /dev/null +++ b/shared/ui/src/commonMain/composeResources/drawable/ic_account_circle.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/shared/ui/src/commonMain/composeResources/drawable/ic_arrow_back.xml b/shared/ui/src/commonMain/composeResources/drawable/ic_arrow_back.xml new file mode 100644 index 00000000..4dc51e26 --- /dev/null +++ b/shared/ui/src/commonMain/composeResources/drawable/ic_arrow_back.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/shared/ui/src/commonMain/composeResources/drawable/ic_auto_awesome.xml b/shared/ui/src/commonMain/composeResources/drawable/ic_auto_awesome.xml new file mode 100644 index 00000000..4eddeb1a --- /dev/null +++ b/shared/ui/src/commonMain/composeResources/drawable/ic_auto_awesome.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/shared/ui/src/commonMain/composeResources/drawable/ic_bookmark.xml b/shared/ui/src/commonMain/composeResources/drawable/ic_bookmark.xml new file mode 100644 index 00000000..0d2fd58b --- /dev/null +++ b/shared/ui/src/commonMain/composeResources/drawable/ic_bookmark.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/shared/ui/src/commonMain/composeResources/drawable/ic_bookmark_add.xml b/shared/ui/src/commonMain/composeResources/drawable/ic_bookmark_add.xml new file mode 100644 index 00000000..a4a64250 --- /dev/null +++ b/shared/ui/src/commonMain/composeResources/drawable/ic_bookmark_add.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/shared/ui/src/commonMain/composeResources/drawable/ic_bookmark_added.xml b/shared/ui/src/commonMain/composeResources/drawable/ic_bookmark_added.xml new file mode 100644 index 00000000..e8361f25 --- /dev/null +++ b/shared/ui/src/commonMain/composeResources/drawable/ic_bookmark_added.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/shared/ui/src/commonMain/composeResources/drawable/ic_calendar_month.xml b/shared/ui/src/commonMain/composeResources/drawable/ic_calendar_month.xml new file mode 100644 index 00000000..4ea738f6 --- /dev/null +++ b/shared/ui/src/commonMain/composeResources/drawable/ic_calendar_month.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/shared/ui/src/commonMain/composeResources/drawable/ic_calendar_month_outlined.xml b/shared/ui/src/commonMain/composeResources/drawable/ic_calendar_month_outlined.xml new file mode 100644 index 00000000..7424b426 --- /dev/null +++ b/shared/ui/src/commonMain/composeResources/drawable/ic_calendar_month_outlined.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/shared/ui/src/commonMain/composeResources/drawable/ic_clear.xml b/shared/ui/src/commonMain/composeResources/drawable/ic_clear.xml new file mode 100644 index 00000000..d2be7b86 --- /dev/null +++ b/shared/ui/src/commonMain/composeResources/drawable/ic_clear.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/shared/ui/src/commonMain/composeResources/drawable/ic_close.xml b/shared/ui/src/commonMain/composeResources/drawable/ic_close.xml new file mode 100644 index 00000000..d2be7b86 --- /dev/null +++ b/shared/ui/src/commonMain/composeResources/drawable/ic_close.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/shared/ui/src/commonMain/composeResources/drawable/ic_code.xml b/shared/ui/src/commonMain/composeResources/drawable/ic_code.xml new file mode 100644 index 00000000..de2c03ac --- /dev/null +++ b/shared/ui/src/commonMain/composeResources/drawable/ic_code.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/shared/ui/src/commonMain/composeResources/drawable/ic_dark_mode.xml b/shared/ui/src/commonMain/composeResources/drawable/ic_dark_mode.xml new file mode 100644 index 00000000..d6ffd10e --- /dev/null +++ b/shared/ui/src/commonMain/composeResources/drawable/ic_dark_mode.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/shared/ui/src/commonMain/composeResources/drawable/ic_dynamic_feed.xml b/shared/ui/src/commonMain/composeResources/drawable/ic_dynamic_feed.xml new file mode 100644 index 00000000..af3aea8e --- /dev/null +++ b/shared/ui/src/commonMain/composeResources/drawable/ic_dynamic_feed.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/shared/ui/src/commonMain/composeResources/drawable/ic_dynamic_feed_outlined.xml b/shared/ui/src/commonMain/composeResources/drawable/ic_dynamic_feed_outlined.xml new file mode 100644 index 00000000..af3aea8e --- /dev/null +++ b/shared/ui/src/commonMain/composeResources/drawable/ic_dynamic_feed_outlined.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/shared/ui/src/commonMain/composeResources/drawable/ic_favorite.xml b/shared/ui/src/commonMain/composeResources/drawable/ic_favorite.xml new file mode 100644 index 00000000..d63e54a7 --- /dev/null +++ b/shared/ui/src/commonMain/composeResources/drawable/ic_favorite.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/shared/ui/src/commonMain/composeResources/drawable/ic_favorite_border.xml b/shared/ui/src/commonMain/composeResources/drawable/ic_favorite_border.xml new file mode 100644 index 00000000..be40b72c --- /dev/null +++ b/shared/ui/src/commonMain/composeResources/drawable/ic_favorite_border.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/shared/ui/src/commonMain/composeResources/drawable/ic_filter_list.xml b/shared/ui/src/commonMain/composeResources/drawable/ic_filter_list.xml new file mode 100644 index 00000000..eecbec9f --- /dev/null +++ b/shared/ui/src/commonMain/composeResources/drawable/ic_filter_list.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/shared/ui/src/commonMain/composeResources/drawable/ic_gavel.xml b/shared/ui/src/commonMain/composeResources/drawable/ic_gavel.xml new file mode 100644 index 00000000..e199e779 --- /dev/null +++ b/shared/ui/src/commonMain/composeResources/drawable/ic_gavel.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/shared/ui/src/commonMain/composeResources/drawable/ic_groups.xml b/shared/ui/src/commonMain/composeResources/drawable/ic_groups.xml new file mode 100644 index 00000000..6fab8f6c --- /dev/null +++ b/shared/ui/src/commonMain/composeResources/drawable/ic_groups.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/shared/ui/src/commonMain/composeResources/drawable/ic_groups_outlined.xml b/shared/ui/src/commonMain/composeResources/drawable/ic_groups_outlined.xml new file mode 100644 index 00000000..976d338b --- /dev/null +++ b/shared/ui/src/commonMain/composeResources/drawable/ic_groups_outlined.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/shared/ui/src/commonMain/composeResources/drawable/ic_info.xml b/shared/ui/src/commonMain/composeResources/drawable/ic_info.xml new file mode 100644 index 00000000..4f0f9975 --- /dev/null +++ b/shared/ui/src/commonMain/composeResources/drawable/ic_info.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/shared/ui/src/commonMain/composeResources/drawable/ic_info_outlined.xml b/shared/ui/src/commonMain/composeResources/drawable/ic_info_outlined.xml new file mode 100644 index 00000000..8ce5c60a --- /dev/null +++ b/shared/ui/src/commonMain/composeResources/drawable/ic_info_outlined.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/shared/ui/src/commonMain/composeResources/drawable/ic_keyboard_arrow_right.xml b/shared/ui/src/commonMain/composeResources/drawable/ic_keyboard_arrow_right.xml new file mode 100644 index 00000000..930e6a63 --- /dev/null +++ b/shared/ui/src/commonMain/composeResources/drawable/ic_keyboard_arrow_right.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/shared/ui/src/commonMain/composeResources/drawable/ic_light_mode.xml b/shared/ui/src/commonMain/composeResources/drawable/ic_light_mode.xml new file mode 100644 index 00000000..898060a6 --- /dev/null +++ b/shared/ui/src/commonMain/composeResources/drawable/ic_light_mode.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/shared/ui/src/commonMain/composeResources/drawable/ic_location_on.xml b/shared/ui/src/commonMain/composeResources/drawable/ic_location_on.xml new file mode 100644 index 00000000..58efa9df --- /dev/null +++ b/shared/ui/src/commonMain/composeResources/drawable/ic_location_on.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/shared/ui/src/commonMain/composeResources/drawable/ic_person.xml b/shared/ui/src/commonMain/composeResources/drawable/ic_person.xml new file mode 100644 index 00000000..07290ad1 --- /dev/null +++ b/shared/ui/src/commonMain/composeResources/drawable/ic_person.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/shared/ui/src/commonMain/composeResources/drawable/ic_play_arrow.xml b/shared/ui/src/commonMain/composeResources/drawable/ic_play_arrow.xml new file mode 100644 index 00000000..83a0ef59 --- /dev/null +++ b/shared/ui/src/commonMain/composeResources/drawable/ic_play_arrow.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/shared/ui/src/commonMain/composeResources/drawable/ic_public.xml b/shared/ui/src/commonMain/composeResources/drawable/ic_public.xml new file mode 100644 index 00000000..78e4bd48 --- /dev/null +++ b/shared/ui/src/commonMain/composeResources/drawable/ic_public.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/shared/ui/src/commonMain/composeResources/drawable/ic_question_answer.xml b/shared/ui/src/commonMain/composeResources/drawable/ic_question_answer.xml new file mode 100644 index 00000000..68244219 --- /dev/null +++ b/shared/ui/src/commonMain/composeResources/drawable/ic_question_answer.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/shared/ui/src/commonMain/composeResources/drawable/ic_schedule.xml b/shared/ui/src/commonMain/composeResources/drawable/ic_schedule.xml new file mode 100644 index 00000000..24e704db --- /dev/null +++ b/shared/ui/src/commonMain/composeResources/drawable/ic_schedule.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/shared/ui/src/commonMain/composeResources/drawable/ic_search.xml b/shared/ui/src/commonMain/composeResources/drawable/ic_search.xml new file mode 100644 index 00000000..1716a2c4 --- /dev/null +++ b/shared/ui/src/commonMain/composeResources/drawable/ic_search.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/shared/ui/src/commonMain/composeResources/drawable/ic_settings_brightness.xml b/shared/ui/src/commonMain/composeResources/drawable/ic_settings_brightness.xml new file mode 100644 index 00000000..1f316e7e --- /dev/null +++ b/shared/ui/src/commonMain/composeResources/drawable/ic_settings_brightness.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/shared/ui/src/commonMain/composeResources/drawable/ic_share.xml b/shared/ui/src/commonMain/composeResources/drawable/ic_share.xml new file mode 100644 index 00000000..bd5d88f5 --- /dev/null +++ b/shared/ui/src/commonMain/composeResources/drawable/ic_share.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/shared/ui/src/commonMain/composeResources/drawable/ic_warning.xml b/shared/ui/src/commonMain/composeResources/drawable/ic_warning.xml new file mode 100644 index 00000000..a00635de --- /dev/null +++ b/shared/ui/src/commonMain/composeResources/drawable/ic_warning.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/shared/ui/src/commonMain/kotlin/com/androidmakers/ui/about/AboutScreen.kt b/shared/ui/src/commonMain/kotlin/com/androidmakers/ui/about/AboutScreen.kt index 2a4deea5..bb625f45 100644 --- a/shared/ui/src/commonMain/kotlin/com/androidmakers/ui/about/AboutScreen.kt +++ b/shared/ui/src/commonMain/kotlin/com/androidmakers/ui/about/AboutScreen.kt @@ -17,16 +17,12 @@ import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.text.selection.SelectionContainer import androidx.compose.foundation.verticalScroll -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.rounded.KeyboardArrowRight -import androidx.compose.material.icons.rounded.AutoAwesome -import androidx.compose.material.icons.rounded.Code -import androidx.compose.material.icons.rounded.DarkMode -import androidx.compose.material.icons.rounded.Gavel -import androidx.compose.material.icons.rounded.LightMode -import androidx.compose.material.icons.rounded.QuestionAnswer -import androidx.compose.material.icons.rounded.SettingsBrightness +import androidx.compose.ui.graphics.painter.Painter import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.ElevatedCard +import androidx.compose.material3.ListItem +import androidx.compose.material3.ListItemDefaults import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon @@ -46,9 +42,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.RectangleShape -import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.LocalUriHandler -import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp @@ -67,9 +61,17 @@ import fr.paug.androidmakers.ui.about_links import fr.paug.androidmakers.ui.code_of_conduct import fr.paug.androidmakers.ui.faq import fr.paug.androidmakers.ui.github_repo +import fr.paug.androidmakers.ui.ic_auto_awesome +import fr.paug.androidmakers.ui.ic_code +import fr.paug.androidmakers.ui.ic_dark_mode +import fr.paug.androidmakers.ui.ic_gavel +import fr.paug.androidmakers.ui.ic_keyboard_arrow_right +import fr.paug.androidmakers.ui.ic_light_mode import fr.paug.androidmakers.ui.ic_network_bluesky import fr.paug.androidmakers.ui.ic_network_x import fr.paug.androidmakers.ui.ic_network_youtube +import fr.paug.androidmakers.ui.ic_question_answer +import fr.paug.androidmakers.ui.ic_settings_brightness import fr.paug.androidmakers.ui.settings import fr.paug.androidmakers.ui.social import fr.paug.androidmakers.ui.theme @@ -192,18 +194,19 @@ internal fun SectionHeader(title: String) { Text( modifier = Modifier.padding(bottom = 8.dp), text = title, - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Bold, + style = MaterialTheme.typography.titleMediumEmphasized, color = MaterialTheme.colorScheme.onSurface ) } @Composable internal fun AboutCard() { - Surface( + ElevatedCard( modifier = Modifier.fillMaxWidth().neoBrutalElevation(), shape = MaterialTheme.shapes.large, - color = MaterialTheme.colorScheme.surfaceContainerHigh + colors = CardDefaults.elevatedCardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, + ), ) { Column(modifier = Modifier.padding(16.dp)) { SectionHeader(title = stringResource(Res.string.about)) @@ -222,27 +225,29 @@ internal fun LinksCard( onCocClick: () -> Unit, onGithubRepoClick: () -> Unit ) { - Surface( + ElevatedCard( modifier = Modifier.fillMaxWidth().neoBrutalElevation(), shape = MaterialTheme.shapes.large, - color = MaterialTheme.colorScheme.surfaceContainerHigh + colors = CardDefaults.elevatedCardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, + ), ) { Column(modifier = Modifier.padding(16.dp)) { SectionHeader(title = stringResource(Res.string.about_links)) LinkRow( - icon = Icons.Rounded.QuestionAnswer, + icon = painterResource(Res.drawable.ic_question_answer), label = stringResource(Res.string.faq), onClick = onFaqClick ) HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant) LinkRow( - icon = Icons.Rounded.Gavel, + icon = painterResource(Res.drawable.ic_gavel), label = stringResource(Res.string.code_of_conduct), onClick = onCocClick ) HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant) LinkRow( - icon = Icons.Rounded.Code, + icon = painterResource(Res.drawable.ic_code), label = stringResource(Res.string.github_repo), onClick = onGithubRepoClick ) @@ -252,37 +257,36 @@ internal fun LinksCard( @Composable internal fun LinkRow( - icon: ImageVector, + icon: Painter, label: String, onClick: () -> Unit ) { - Row( - modifier = Modifier - .fillMaxWidth() - .clickable(onClick = onClick) - .padding(vertical = 12.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Icon( - imageVector = icon, - contentDescription = null, - tint = MaterialTheme.colorScheme.primary, - modifier = Modifier.size(24.dp) - ) - Text( - modifier = Modifier - .weight(1f) - .padding(horizontal = 16.dp), - text = label, - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurface - ) - Icon( - imageVector = Icons.AutoMirrored.Rounded.KeyboardArrowRight, - contentDescription = null, - tint = MaterialTheme.colorScheme.onSurfaceVariant - ) - } + ListItem( + modifier = Modifier.clickable(onClick = onClick), + headlineContent = { + Text( + text = label, + style = MaterialTheme.typography.bodyLarge, + ) + }, + leadingContent = { + Icon( + painter = icon, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + }, + trailingContent = { + Icon( + painter = painterResource(Res.drawable.ic_keyboard_arrow_right), + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + }, + colors = ListItemDefaults.colors( + containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, + ), + ) } @Composable @@ -294,10 +298,12 @@ internal fun SocialCard( ) { val darkMode = LocalIsDarkTheme.current - Surface( + ElevatedCard( modifier = Modifier.fillMaxWidth().neoBrutalElevation(), shape = MaterialTheme.shapes.large, - color = MaterialTheme.colorScheme.surfaceContainerHigh + colors = CardDefaults.elevatedCardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, + ), ) { Column( modifier = Modifier.padding(16.dp), @@ -314,8 +320,7 @@ internal fun SocialCard( ) { Text( text = stringResource(Res.string.x_hashtag), - style = MaterialTheme.typography.labelLarge, - fontWeight = FontWeight.Bold, + style = MaterialTheme.typography.labelLargeEmphasized, color = MaterialTheme.colorScheme.onPrimaryContainer ) } @@ -395,10 +400,12 @@ internal fun SettingsCard( themePreference: ThemePreference, onThemePreferenceChange: (ThemePreference) -> Unit ) { - Surface( + ElevatedCard( modifier = Modifier.fillMaxWidth().neoBrutalElevation(), shape = MaterialTheme.shapes.large, - color = MaterialTheme.colorScheme.surfaceContainerHigh + colors = CardDefaults.elevatedCardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, + ), ) { Column(modifier = Modifier.padding(16.dp)) { SectionHeader(title = stringResource(Res.string.settings)) @@ -410,10 +417,10 @@ internal fun SettingsCard( ) val options = ThemePreference.entries val icons = mapOf( - ThemePreference.System to Icons.Rounded.SettingsBrightness, - ThemePreference.Light to Icons.Rounded.LightMode, - ThemePreference.Dark to Icons.Rounded.DarkMode, - ThemePreference.Neobrutalism to Icons.Rounded.AutoAwesome, + ThemePreference.System to painterResource(Res.drawable.ic_settings_brightness), + ThemePreference.Light to painterResource(Res.drawable.ic_light_mode), + ThemePreference.Dark to painterResource(Res.drawable.ic_dark_mode), + ThemePreference.Neobrutalism to painterResource(Res.drawable.ic_auto_awesome), ) val labels = mapOf( ThemePreference.System to stringResource(Res.string.theme_system), @@ -430,7 +437,7 @@ internal fun SettingsCard( icon = {}, ) { Icon( - imageVector = icons[option]!!, + painter = icons[option]!!, contentDescription = labels[option], modifier = Modifier.size(20.dp), ) diff --git a/shared/ui/src/commonMain/kotlin/com/androidmakers/ui/agenda/AgendaColumn.kt b/shared/ui/src/commonMain/kotlin/com/androidmakers/ui/agenda/AgendaColumn.kt index def6e221..d9011b21 100644 --- a/shared/ui/src/commonMain/kotlin/com/androidmakers/ui/agenda/AgendaColumn.kt +++ b/shared/ui/src/commonMain/kotlin/com/androidmakers/ui/agenda/AgendaColumn.kt @@ -1,3 +1,5 @@ +@file:OptIn(ExperimentalMaterial3ExpressiveApi::class) + package com.androidmakers.ui.agenda import androidx.compose.foundation.ExperimentalFoundationApi @@ -9,6 +11,7 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface @@ -16,7 +19,6 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.androidmakers.ui.model.UISession @@ -80,8 +82,7 @@ fun TimeSeparator(prettyTime: String) { Text( text = prettyTime, color = MaterialTheme.colorScheme.primary, - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Bold, + style = MaterialTheme.typography.titleMediumEmphasized, ) HorizontalDivider( modifier = Modifier.weight(1f), diff --git a/shared/ui/src/commonMain/kotlin/com/androidmakers/ui/agenda/AgendaLayout.kt b/shared/ui/src/commonMain/kotlin/com/androidmakers/ui/agenda/AgendaLayout.kt index bf39eec0..d5fde73c 100644 --- a/shared/ui/src/commonMain/kotlin/com/androidmakers/ui/agenda/AgendaLayout.kt +++ b/shared/ui/src/commonMain/kotlin/com/androidmakers/ui/agenda/AgendaLayout.kt @@ -10,9 +10,6 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.rounded.Bookmark -import androidx.compose.material.icons.rounded.Close import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.FilterChip import androidx.compose.material3.Icon @@ -39,6 +36,8 @@ import com.androidmakers.ui.model.UISession import fr.androidmakers.domain.model.Room import fr.androidmakers.domain.utils.eventTimeZone import fr.paug.androidmakers.ui.Res +import fr.paug.androidmakers.ui.ic_bookmark +import fr.paug.androidmakers.ui.ic_close import fr.paug.androidmakers.ui.bookmarked import fr.paug.androidmakers.ui.english import fr.paug.androidmakers.ui.filter @@ -47,6 +46,7 @@ import fr.paug.androidmakers.ui.language import fr.paug.androidmakers.ui.rooms import fr.paug.androidmakers.ui.tags import kotlinx.datetime.todayIn +import org.jetbrains.compose.resources.painterResource import org.jetbrains.compose.resources.stringResource import org.koin.compose.viewmodel.koinViewModel import kotlin.time.Clock @@ -156,7 +156,7 @@ private fun AgendaFilterDrawer( label = { Text(stringResource(Res.string.bookmarked)) }, leadingIcon = { Icon( - imageVector = Icons.Rounded.Bookmark, + painter = painterResource(Res.drawable.ic_bookmark), contentDescription = null, ) }, @@ -213,7 +213,7 @@ private fun FilterDrawerHeader( if (hasActiveFilters) { TextButton(onClick = onClearFilters) { Icon( - imageVector = Icons.Rounded.Close, + painter = painterResource(Res.drawable.ic_close), contentDescription = null, modifier = Modifier.padding(end = 4.dp), ) diff --git a/shared/ui/src/commonMain/kotlin/com/androidmakers/ui/agenda/AgendaRow.kt b/shared/ui/src/commonMain/kotlin/com/androidmakers/ui/agenda/AgendaRow.kt index 47925caf..2661a538 100644 --- a/shared/ui/src/commonMain/kotlin/com/androidmakers/ui/agenda/AgendaRow.kt +++ b/shared/ui/src/commonMain/kotlin/com/androidmakers/ui/agenda/AgendaRow.kt @@ -9,16 +9,15 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.rounded.BookmarkAdd -import androidx.compose.material.icons.rounded.BookmarkAdded -import androidx.compose.material.icons.rounded.LocationOn -import androidx.compose.material.icons.rounded.Schedule -import androidx.compose.material3.Button +import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.ElevatedCard import androidx.compose.material3.Icon import androidx.compose.material3.IconToggleButton import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface +import androidx.compose.material3.SuggestionChip +import androidx.compose.material3.SuggestionChipDefaults import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue @@ -33,8 +32,13 @@ import com.androidmakers.ui.theme.AMColor import com.androidmakers.ui.theme.neoBrutalBorder import com.androidmakers.ui.theme.neoBrutalElevation import fr.paug.androidmakers.ui.Res +import fr.paug.androidmakers.ui.ic_bookmark_add +import fr.paug.androidmakers.ui.ic_bookmark_added +import fr.paug.androidmakers.ui.ic_location_on +import fr.paug.androidmakers.ui.ic_schedule import fr.paug.androidmakers.ui.session_app_clinic_apply import kotlinx.datetime.Instant +import org.jetbrains.compose.resources.painterResource import org.jetbrains.compose.resources.stringResource import kotlin.time.Duration.Companion.hours @@ -43,10 +47,12 @@ internal fun ServiceSessionRow( session: UISession, modifier: Modifier = Modifier, ) { - Surface( + Card( modifier = modifier.neoBrutalElevation(), shape = MaterialTheme.shapes.large, - color = MaterialTheme.colorScheme.surfaceContainer, + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainer, + ), ) { Column( modifier = Modifier @@ -77,15 +83,15 @@ internal fun SessionRow( onSessionBookmark: (UISession, Boolean) -> Unit, onApplyForAppClinicClick: () -> Unit, ) { - Surface( + ElevatedCard( modifier = modifier.neoBrutalElevation(), onClick = { onSessionClick(uiSession) }, shape = MaterialTheme.shapes.large, - color = MaterialTheme.colorScheme.surfaceContainerHigh, + colors = CardDefaults.elevatedCardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, + ), ) { val isBookmarked = uiSession.isFavorite - val imageVector = if (isBookmarked) Icons.Rounded.BookmarkAdded - else Icons.Rounded.BookmarkAdd val tint by animateColorAsState( if (isBookmarked) AMColor.bookmarked else MaterialTheme.colorScheme.outline @@ -110,7 +116,7 @@ internal fun SessionRow( onCheckedChange = { onSessionBookmark(uiSession, it) }, ) { Icon( - imageVector = imageVector, + painter = painterResource(if (isBookmarked) Res.drawable.ic_bookmark_added else Res.drawable.ic_bookmark_add), contentDescription = "favorite", tint = tint, modifier = Modifier.size(24.dp), @@ -156,7 +162,7 @@ internal fun SessionRow( // 5. App Clinic if (uiSession.isAppClinic) { - Button( + FilledTonalButton( onClick = onApplyForAppClinicClick, modifier = Modifier.neoBrutalElevation(shadowOffset = 2.dp), ) { @@ -172,18 +178,21 @@ internal fun SessionRow( @Composable private fun TagChip(tag: String) { - Surface( + SuggestionChip( modifier = Modifier.neoBrutalBorder(), + onClick = {}, + label = { + Text( + text = tag, + style = MaterialTheme.typography.labelSmall, + ) + }, shape = MaterialTheme.shapes.small, - color = MaterialTheme.colorScheme.surfaceContainerHighest, - ) { - Text( - text = tag, - modifier = Modifier.padding(horizontal = 8.dp, vertical = 2.dp), - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } + colors = SuggestionChipDefaults.suggestionChipColors( + containerColor = MaterialTheme.colorScheme.surfaceContainerHighest, + labelColor = MaterialTheme.colorScheme.onSurfaceVariant, + ), + ) } @Composable @@ -200,7 +209,7 @@ private fun MetaRow( verticalAlignment = Alignment.CenterVertically ) { Icon( - imageVector = Icons.Rounded.LocationOn, + painter = painterResource(Res.drawable.ic_location_on), contentDescription = null, modifier = Modifier.size(14.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant, @@ -224,7 +233,7 @@ private fun MetaRow( verticalAlignment = Alignment.CenterVertically ) { Icon( - imageVector = Icons.Rounded.Schedule, + painter = painterResource(Res.drawable.ic_schedule), contentDescription = null, modifier = Modifier.size(14.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant, diff --git a/shared/ui/src/commonMain/kotlin/com/androidmakers/ui/agenda/SessionDetailLayout.kt b/shared/ui/src/commonMain/kotlin/com/androidmakers/ui/agenda/SessionDetailLayout.kt index 187a4829..c3f2b96f 100644 --- a/shared/ui/src/commonMain/kotlin/com/androidmakers/ui/agenda/SessionDetailLayout.kt +++ b/shared/ui/src/commonMain/kotlin/com/androidmakers/ui/agenda/SessionDetailLayout.kt @@ -1,3 +1,5 @@ +@file:OptIn(ExperimentalMaterial3ExpressiveApi::class) + package com.androidmakers.ui.agenda import androidx.compose.animation.AnimatedVisibilityScope @@ -26,24 +28,20 @@ import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.text.selection.SelectionContainer import androidx.compose.foundation.verticalScroll -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.rounded.ArrowBack -import androidx.compose.material.icons.automirrored.rounded.KeyboardArrowRight -import androidx.compose.material.icons.rounded.BookmarkAdd -import androidx.compose.material.icons.rounded.BookmarkAdded -import androidx.compose.material.icons.rounded.Info -import androidx.compose.material.icons.rounded.LocationOn -import androidx.compose.material.icons.rounded.Person -import androidx.compose.material.icons.rounded.PlayArrow -import androidx.compose.material.icons.rounded.Schedule -import androidx.compose.material.icons.rounded.Share -import androidx.compose.material3.Button +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.ElevatedCard import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.OutlinedCard import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold +import androidx.compose.material3.SuggestionChip +import androidx.compose.material3.SuggestionChipDefaults import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar @@ -57,7 +55,6 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.RectangleShape -import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.text.font.FontWeight @@ -87,6 +84,16 @@ import fr.androidmakers.domain.model.Speaker import fr.androidmakers.domain.model.isAppClinic import fr.androidmakers.domain.utils.formatTimeInterval import fr.paug.androidmakers.ui.Res +import fr.paug.androidmakers.ui.ic_arrow_back +import fr.paug.androidmakers.ui.ic_bookmark_add +import fr.paug.androidmakers.ui.ic_bookmark_added +import fr.paug.androidmakers.ui.ic_info +import fr.paug.androidmakers.ui.ic_keyboard_arrow_right +import fr.paug.androidmakers.ui.ic_location_on +import fr.paug.androidmakers.ui.ic_person +import fr.paug.androidmakers.ui.ic_play_arrow +import fr.paug.androidmakers.ui.ic_schedule +import fr.paug.androidmakers.ui.ic_share import fr.paug.androidmakers.ui.about_this_talk import fr.paug.androidmakers.ui.back import fr.paug.androidmakers.ui.bookmarked @@ -260,7 +267,7 @@ private fun SessionDetailTopAppBar( if (showBackButton) { IconButton(onClick = onBackClick) { Icon( - Icons.AutoMirrored.Rounded.ArrowBack, + painter = painterResource(Res.drawable.ic_arrow_back), contentDescription = stringResource(Res.string.back) ) } @@ -286,7 +293,7 @@ private fun SessionDetailTopAppBar( if (hasContent) { IconButton(onClick = onShareSession) { Icon( - Icons.Rounded.Share, + painter = painterResource(Res.drawable.ic_share), contentDescription = stringResource(Res.string.share) ) } @@ -311,7 +318,7 @@ private fun SessionDetailFab( ) { Crossfade(isBookmarked) { bookmarked -> Icon( - imageVector = if (bookmarked) Icons.Rounded.BookmarkAdded else Icons.Rounded.BookmarkAdd, + painter = painterResource(if (bookmarked) Res.drawable.ic_bookmark_added else Res.drawable.ic_bookmark_add), contentDescription = stringResource(Res.string.bookmarked) ) } @@ -352,7 +359,7 @@ private fun SessionDetails( // 4. App Clinic apply button if (sessionDetails.session.isAppClinic()) { - Button( + FilledTonalButton( onClick = onApplyForAppClinic, modifier = Modifier .padding(horizontal = 16.dp) @@ -362,8 +369,7 @@ private fun SessionDetails( ) { Text( text = stringResource(Res.string.session_app_clinic_apply), - style = MaterialTheme.typography.bodyLarge, - fontWeight = FontWeight.Bold, + style = MaterialTheme.typography.bodyLargeEmphasized, ) } } @@ -386,7 +392,7 @@ private fun SessionDetails( if (Clock.System.now() > sessionDetails.startTimestamp) { GraphQLFeedbackLayout(sessionId = sessionDetails.session.id) } else { - Surface( + OutlinedCard( shape = MaterialTheme.shapes.large, border = BorderStroke(1.dp, separatorColor()), modifier = Modifier.fillMaxWidth().neoBrutalElevation() @@ -411,13 +417,15 @@ private fun SessionDetails( private fun HeaderCard(sessionDetails: SessionDetailState) { val session = sessionDetails.session - Surface( + ElevatedCard( modifier = Modifier .fillMaxWidth() .padding(horizontal = 16.dp) .neoBrutalElevation(), shape = MaterialTheme.shapes.large, - color = MaterialTheme.colorScheme.surfaceContainerHigh + colors = CardDefaults.elevatedCardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, + ), ) { Column(modifier = Modifier.padding(25.dp)) { SessionHeaderBadgesRow( @@ -429,8 +437,7 @@ private fun HeaderCard(sessionDetails: SessionDetailState) { SelectionContainer { Text( text = session.title, - style = MaterialTheme.typography.headlineSmall, - fontWeight = FontWeight.Bold, + style = MaterialTheme.typography.headlineSmallEmphasized, color = MaterialTheme.colorScheme.onSurface, modifier = Modifier.padding(top = 16.dp) ) @@ -488,7 +495,7 @@ private fun SessionHeaderDateRoomRow( verticalAlignment = Alignment.CenterVertically ) { Icon( - imageVector = Icons.Rounded.Schedule, + painter = painterResource(Res.drawable.ic_schedule), contentDescription = null, modifier = Modifier.size(16.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant @@ -511,7 +518,7 @@ private fun SessionHeaderDateRoomRow( verticalAlignment = Alignment.CenterVertically ) { Icon( - imageVector = Icons.Rounded.LocationOn, + painter = painterResource(Res.drawable.ic_location_on), contentDescription = null, modifier = Modifier.size(16.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant @@ -550,25 +557,24 @@ private fun SessionHeaderTagsRow( @Composable private fun TagChip(text: String) { - val chipShape = MaterialTheme.shapes.small - Surface( - shape = chipShape, - color = MaterialTheme.colorScheme.surfaceContainerHigh, - modifier = Modifier - .border(1.dp, MaterialTheme.colorScheme.outlineVariant, chipShape) - .neoBrutalBorder() - ) { - Text( - text = text.uppercase(), - modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp), - style = MaterialTheme.typography.labelMedium.copy( - fontWeight = FontWeight.Medium, - fontSize = 12.sp, - letterSpacing = 0.6.sp - ), - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } + SuggestionChip( + onClick = {}, + label = { + Text( + text = text.uppercase(), + style = MaterialTheme.typography.labelMedium.copy( + letterSpacing = 0.6.sp + ), + ) + }, + shape = MaterialTheme.shapes.small, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant), + modifier = Modifier.neoBrutalBorder(), + colors = SuggestionChipDefaults.suggestionChipColors( + containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, + labelColor = MaterialTheme.colorScheme.onSurfaceVariant, + ), + ) } @Composable @@ -647,7 +653,7 @@ private fun VideoSection( ) { Box(contentAlignment = Alignment.Center) { Icon( - imageVector = Icons.Rounded.PlayArrow, + painter = painterResource(Res.drawable.ic_play_arrow), contentDescription = null, modifier = Modifier.size(32.dp), tint = MaterialTheme.colorScheme.onSurface @@ -658,23 +664,21 @@ private fun VideoSection( } @Composable -private fun SectionHeader(icon: ImageVector, title: String) { +private fun SectionHeader(icon: Painter, title: String) { Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp), modifier = Modifier.padding(bottom = 12.dp) ) { Icon( - imageVector = icon, + painter = icon, contentDescription = null, modifier = Modifier.size(16.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant ) Text( text = title.uppercase(), - style = MaterialTheme.typography.labelMedium.copy( - fontWeight = FontWeight.Bold, - fontSize = 12.sp, + style = MaterialTheme.typography.labelMediumEmphasized.copy( letterSpacing = 0.6.sp ), color = MaterialTheme.colorScheme.onSurfaceVariant @@ -686,13 +690,15 @@ private fun SectionHeader(icon: ImageVector, title: String) { private fun DescriptionSection(description: String) { Column(modifier = Modifier.padding(horizontal = 16.dp)) { SectionHeader( - icon = Icons.Rounded.Info, + icon = painterResource(Res.drawable.ic_info), title = stringResource(Res.string.about_this_talk) ) - Surface( + ElevatedCard( modifier = Modifier.neoBrutalElevation(), shape = MaterialTheme.shapes.large, - color = MaterialTheme.colorScheme.surfaceContainerHigh + colors = CardDefaults.elevatedCardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, + ), ) { SelectionContainer { Text( @@ -720,7 +726,7 @@ private fun SpeakersSection( ) { Column(modifier = Modifier.padding(horizontal = 16.dp)) { SectionHeader( - icon = Icons.Rounded.Person, + icon = painterResource(Res.drawable.ic_person), title = pluralStringResource(Res.plurals.speaker_section, speakers.size) ) Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { @@ -743,11 +749,13 @@ private fun SpeakerCard( sharedTransitionScope: SharedTransitionScope?, animatedVisibilityScope: AnimatedVisibilityScope?, ) { - Surface( + ElevatedCard( modifier = Modifier.neoBrutalElevation(), onClick = onClick, shape = MaterialTheme.shapes.large, - color = MaterialTheme.colorScheme.surfaceContainerHigh + colors = CardDefaults.elevatedCardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, + ), ) { Row( modifier = Modifier @@ -772,7 +780,7 @@ private fun SpeakerCard( // Chevron Icon( - imageVector = Icons.AutoMirrored.Rounded.KeyboardArrowRight, + painter = painterResource(Res.drawable.ic_keyboard_arrow_right), contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant ) @@ -829,8 +837,7 @@ private fun SpeakerTextInfo( speaker.name?.let { name -> Text( text = name, - style = MaterialTheme.typography.bodyLarge.copy(fontSize = 16.sp), - fontWeight = FontWeight.Bold, + style = MaterialTheme.typography.bodyLargeEmphasized, color = MaterialTheme.colorScheme.onSurface ) } diff --git a/shared/ui/src/commonMain/kotlin/com/androidmakers/ui/common/GraphQLFeedbackLayout.kt b/shared/ui/src/commonMain/kotlin/com/androidmakers/ui/common/GraphQLFeedbackLayout.kt index 802a4e8e..6ebd6943 100644 --- a/shared/ui/src/commonMain/kotlin/com/androidmakers/ui/common/GraphQLFeedbackLayout.kt +++ b/shared/ui/src/commonMain/kotlin/com/androidmakers/ui/common/GraphQLFeedbackLayout.kt @@ -1,3 +1,5 @@ +@file:OptIn(ExperimentalMaterial3ExpressiveApi::class) + package com.androidmakers.ui.common import androidx.compose.animation.animateColorAsState @@ -10,6 +12,7 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.material3.Button +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Surface @@ -23,7 +26,6 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.androidmakers.ui.theme.neoBrutalElevation @@ -118,8 +120,7 @@ private fun FeedbackContent( ) { Text( text = stringResource(Res.string.feedback_title), - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Bold, + style = MaterialTheme.typography.titleMediumEmphasized, ) Spacer(modifier = Modifier.height(16.dp)) diff --git a/shared/ui/src/commonMain/kotlin/com/androidmakers/ui/common/SocialIcons.kt b/shared/ui/src/commonMain/kotlin/com/androidmakers/ui/common/SocialIcons.kt index 67aeb6c6..0856b625 100644 --- a/shared/ui/src/commonMain/kotlin/com/androidmakers/ui/common/SocialIcons.kt +++ b/shared/ui/src/commonMain/kotlin/com/androidmakers/ui/common/SocialIcons.kt @@ -3,8 +3,6 @@ package com.androidmakers.ui.common import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.size -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.rounded.Public import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.runtime.Composable @@ -13,6 +11,7 @@ import androidx.compose.ui.unit.dp import fr.androidmakers.domain.model.SocialsItem import fr.androidmakers.domain.model.Speaker import fr.paug.androidmakers.ui.Res +import fr.paug.androidmakers.ui.ic_public import fr.paug.androidmakers.ui.ic_network_blog import fr.paug.androidmakers.ui.ic_network_linkedin import fr.paug.androidmakers.ui.ic_network_x @@ -59,7 +58,7 @@ fun SocialButtons( else -> { Icon( - imageVector = Icons.Rounded.Public, + painter = painterResource(Res.drawable.ic_public), contentDescription = socialsItem.name ) } diff --git a/shared/ui/src/commonMain/kotlin/com/androidmakers/ui/common/navigation/AVALayout.kt b/shared/ui/src/commonMain/kotlin/com/androidmakers/ui/common/navigation/AVALayout.kt index 0f6588e7..b1e4ac2c 100644 --- a/shared/ui/src/commonMain/kotlin/com/androidmakers/ui/common/navigation/AVALayout.kt +++ b/shared/ui/src/commonMain/kotlin/com/androidmakers/ui/common/navigation/AVALayout.kt @@ -8,15 +8,7 @@ import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.consumeWindowInsets import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.rounded.CalendarMonth -import androidx.compose.material.icons.rounded.DynamicFeed -import androidx.compose.material.icons.rounded.Favorite -import androidx.compose.material.icons.rounded.FilterList -import androidx.compose.material.icons.rounded.Groups -import androidx.compose.material.icons.rounded.Info import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme @@ -39,7 +31,7 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp @@ -66,6 +58,17 @@ import fr.paug.androidmakers.ui.Res import fr.paug.androidmakers.ui.agenda import fr.paug.androidmakers.ui.feed import fr.paug.androidmakers.ui.filter +import fr.paug.androidmakers.ui.ic_calendar_month +import fr.paug.androidmakers.ui.ic_calendar_month_outlined +import fr.paug.androidmakers.ui.ic_dynamic_feed +import fr.paug.androidmakers.ui.ic_dynamic_feed_outlined +import fr.paug.androidmakers.ui.ic_favorite +import fr.paug.androidmakers.ui.ic_favorite_border +import fr.paug.androidmakers.ui.ic_filter_list +import fr.paug.androidmakers.ui.ic_groups +import fr.paug.androidmakers.ui.ic_groups_outlined +import fr.paug.androidmakers.ui.ic_info +import fr.paug.androidmakers.ui.ic_info_outlined import fr.paug.androidmakers.ui.info import fr.paug.androidmakers.ui.notification import fr.paug.androidmakers.ui.speakers @@ -208,7 +211,7 @@ private fun AVATopAppBar( onClick = onToggleFilter ) { Icon( - imageVector = Icons.Rounded.FilterList, + painter = painterResource(Res.drawable.ic_filter_list), contentDescription = stringResource(Res.string.filter), ) } @@ -224,53 +227,55 @@ private fun AVABottomBar( navigationState: NavigationState, featureFlags: FeatureFlags ) { - Column { - HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant) - NavigationBar(containerColor = MaterialTheme.colorScheme.surfaceContainer) { + NavigationBar(containerColor = MaterialTheme.colorScheme.surfaceContainer) { - NavigationBarItem( - navigator = navigator, - navigationState = navigationState, - imageVector = Icons.Rounded.CalendarMonth, - label = stringResource(Res.string.agenda), - destinationRoute = AgendaKey - ) + NavigationBarItem( + navigator = navigator, + navigationState = navigationState, + selectedIcon = painterResource(Res.drawable.ic_calendar_month), + unselectedIcon = painterResource(Res.drawable.ic_calendar_month_outlined), + label = stringResource(Res.string.agenda), + destinationRoute = AgendaKey + ) - NavigationBarItem( - navigator = navigator, - navigationState = navigationState, - imageVector = Icons.Rounded.Groups, - label = stringResource(Res.string.speakers), - destinationRoute = SpeakersKey - ) + NavigationBarItem( + navigator = navigator, + navigationState = navigationState, + selectedIcon = painterResource(Res.drawable.ic_groups), + unselectedIcon = painterResource(Res.drawable.ic_groups_outlined), + label = stringResource(Res.string.speakers), + destinationRoute = SpeakersKey + ) - if (featureFlags.feed) { - NavigationBarItem( - navigator = navigator, - navigationState = navigationState, - imageVector = Icons.Rounded.DynamicFeed, - label = stringResource(Res.string.feed), - destinationRoute = FeedKey - ) - } - + if (featureFlags.feed) { NavigationBarItem( navigator = navigator, navigationState = navigationState, - imageVector = Icons.Rounded.Favorite, - label = stringResource(Res.string.sponsors), - destinationRoute = SponsorsKey + selectedIcon = painterResource(Res.drawable.ic_dynamic_feed), + unselectedIcon = painterResource(Res.drawable.ic_dynamic_feed_outlined), + label = stringResource(Res.string.feed), + destinationRoute = FeedKey ) + } - NavigationBarItem( - navigator = navigator, - navigationState = navigationState, - imageVector = Icons.Rounded.Info, - label = stringResource(Res.string.info), - destinationRoute = InfoKey - ) + NavigationBarItem( + navigator = navigator, + navigationState = navigationState, + selectedIcon = painterResource(Res.drawable.ic_favorite), + unselectedIcon = painterResource(Res.drawable.ic_favorite_border), + label = stringResource(Res.string.sponsors), + destinationRoute = SponsorsKey + ) + + NavigationBarItem( + navigator = navigator, + navigationState = navigationState, + selectedIcon = painterResource(Res.drawable.ic_info), + unselectedIcon = painterResource(Res.drawable.ic_info_outlined), + label = stringResource(Res.string.info), + destinationRoute = InfoKey + ) - } } } @@ -366,19 +371,21 @@ private fun AVANavDisplay( private fun RowScope.NavigationBarItem( navigator: Navigator, navigationState: NavigationState, - imageVector: ImageVector, + selectedIcon: Painter, + unselectedIcon: Painter, label: String, destinationRoute: NavKey, ) { + val isSelected = navigationState.topLevelRoute == destinationRoute NavigationBarItem( icon = { Icon( - imageVector = imageVector, + painter = if (isSelected) selectedIcon else unselectedIcon, contentDescription = label ) }, label = { Text(label, maxLines = 1, overflow = TextOverflow.Ellipsis) }, - selected = navigationState.topLevelRoute == destinationRoute, + selected = isSelected, colors = NavigationBarItemDefaults.colors( selectedIconColor = MaterialTheme.colorScheme.primary, selectedTextColor = MaterialTheme.colorScheme.primary, diff --git a/shared/ui/src/commonMain/kotlin/com/androidmakers/ui/feed/AlertBannerCard.kt b/shared/ui/src/commonMain/kotlin/com/androidmakers/ui/feed/AlertBannerCard.kt index 0596adb9..488ab0fc 100644 --- a/shared/ui/src/commonMain/kotlin/com/androidmakers/ui/feed/AlertBannerCard.kt +++ b/shared/ui/src/commonMain/kotlin/com/androidmakers/ui/feed/AlertBannerCard.kt @@ -9,9 +9,6 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.rounded.Close -import androidx.compose.material.icons.rounded.Warning import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme @@ -25,6 +22,10 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.androidmakers.ui.theme.neoBrutalBorder import fr.androidmakers.domain.model.FeedItem +import fr.paug.androidmakers.ui.Res +import fr.paug.androidmakers.ui.ic_close +import fr.paug.androidmakers.ui.ic_warning +import org.jetbrains.compose.resources.painterResource @Composable fun AlertBannerCard( @@ -48,7 +49,7 @@ fun AlertBannerCard( verticalAlignment = Alignment.Top, ) { Icon( - imageVector = Icons.Rounded.Warning, + painter = painterResource(Res.drawable.ic_warning), contentDescription = null, tint = MaterialTheme.colorScheme.error, modifier = Modifier.size(24.dp), @@ -75,7 +76,7 @@ fun AlertBannerCard( modifier = Modifier.size(32.dp), ) { Icon( - imageVector = Icons.Rounded.Close, + painter = painterResource(Res.drawable.ic_close), contentDescription = "Dismiss", tint = contentColor, modifier = Modifier.size(18.dp), 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..2b649464 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,3 +1,5 @@ +@file:OptIn(ExperimentalMaterial3ExpressiveApi::class) + package com.androidmakers.ui.feed import androidx.compose.foundation.background @@ -14,11 +16,11 @@ 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 +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.ElevatedCard +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi 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 @@ -37,6 +39,8 @@ 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.ic_location_on +import org.jetbrains.compose.resources.painterResource import fr.paug.androidmakers.ui.feed_read_more import org.jetbrains.compose.resources.stringResource @@ -96,10 +100,12 @@ fun ArticleCardWithImage( article: FeedItem.Article, modifier: Modifier = Modifier, ) { - Surface( + ElevatedCard( modifier = modifier.fillMaxWidth().neoBrutalElevation(), shape = MaterialTheme.shapes.extraLarge, - color = MaterialTheme.colorScheme.surfaceContainerHigh, + colors = CardDefaults.elevatedCardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, + ), ) { Column { ArticleHeroImage( @@ -111,8 +117,7 @@ fun ArticleCardWithImage( Text( text = article.title, color = MaterialTheme.colorScheme.onSurface, - fontWeight = FontWeight.Bold, - fontSize = 18.sp, + style = MaterialTheme.typography.titleMediumEmphasized, modifier = Modifier.padding(top = 8.dp), ) Text( @@ -153,8 +158,7 @@ private fun ArticleHeroImage( Text( text = categoryBadge, color = MaterialTheme.colorScheme.onPrimary, - fontSize = 11.sp, - fontWeight = FontWeight.Bold, + style = MaterialTheme.typography.labelSmallEmphasized, letterSpacing = 0.5.sp, modifier = Modifier .padding(12.dp) @@ -203,18 +207,19 @@ fun ArticleCardWithLocation( article: FeedItem.Article, modifier: Modifier = Modifier, ) { - Surface( + ElevatedCard( modifier = modifier.fillMaxWidth().neoBrutalElevation(), shape = MaterialTheme.shapes.large, - color = MaterialTheme.colorScheme.surfaceContainerHigh, + colors = CardDefaults.elevatedCardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, + ), ) { Column(modifier = Modifier.padding(16.dp)) { CategoryTimeRow(article.category, article.timeAgo) Text( text = article.title, color = MaterialTheme.colorScheme.onSurface, - fontWeight = FontWeight.Bold, - fontSize = 16.sp, + style = MaterialTheme.typography.titleSmallEmphasized, modifier = Modifier.padding(top = 8.dp), ) Text( @@ -239,7 +244,7 @@ fun ArticleCardWithLocation( verticalAlignment = Alignment.CenterVertically, ) { Icon( - imageVector = Icons.Rounded.LocationOn, + painter = painterResource(Res.drawable.ic_location_on), contentDescription = null, tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(20.dp), @@ -272,10 +277,12 @@ fun ArticleCardWithThumbnail( article: FeedItem.Article, modifier: Modifier = Modifier, ) { - Surface( + ElevatedCard( modifier = modifier.fillMaxWidth().neoBrutalElevation(), shape = MaterialTheme.shapes.large, - color = MaterialTheme.colorScheme.surfaceContainerHigh, + colors = CardDefaults.elevatedCardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, + ), ) { Row( modifier = Modifier.padding(16.dp), @@ -286,8 +293,7 @@ fun ArticleCardWithThumbnail( Text( text = article.title, color = MaterialTheme.colorScheme.onSurface, - fontWeight = FontWeight.Bold, - fontSize = 16.sp, + style = MaterialTheme.typography.titleSmallEmphasized, modifier = Modifier.padding(top = 8.dp), ) Text( 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 index 6d1aff05..d09b58b9 100644 --- a/shared/ui/src/commonMain/kotlin/com/androidmakers/ui/feed/MessageCard.kt +++ b/shared/ui/src/commonMain/kotlin/com/androidmakers/ui/feed/MessageCard.kt @@ -1,14 +1,17 @@ +@file:OptIn(ExperimentalMaterial3ExpressiveApi::class) + package com.androidmakers.ui.feed import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.ElevatedCard +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi 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 @@ -23,10 +26,12 @@ fun MessageCard( message: FeedItem.Message, modifier: Modifier = Modifier, ) { - Surface( + ElevatedCard( modifier = modifier.fillMaxWidth().neoBrutalElevation(), shape = MaterialTheme.shapes.large, - color = MaterialTheme.colorScheme.surfaceContainerHigh, + colors = CardDefaults.elevatedCardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, + ), ) { Column(modifier = Modifier.padding(16.dp)) { CategoryTimeRow( @@ -36,8 +41,7 @@ fun MessageCard( Text( text = message.title, color = MaterialTheme.colorScheme.onSurface, - fontWeight = FontWeight.Bold, - fontSize = 16.sp, + style = MaterialTheme.typography.titleSmallEmphasized, modifier = Modifier.padding(top = 8.dp), ) Text( diff --git a/shared/ui/src/commonMain/kotlin/com/androidmakers/ui/speakers/SpeakerDetailsScreen.kt b/shared/ui/src/commonMain/kotlin/com/androidmakers/ui/speakers/SpeakerDetailsScreen.kt index 22a90270..a812cc9d 100644 --- a/shared/ui/src/commonMain/kotlin/com/androidmakers/ui/speakers/SpeakerDetailsScreen.kt +++ b/shared/ui/src/commonMain/kotlin/com/androidmakers/ui/speakers/SpeakerDetailsScreen.kt @@ -11,8 +11,6 @@ import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.text.selection.SelectionContainer import androidx.compose.foundation.verticalScroll -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.rounded.ArrowBack import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton @@ -43,8 +41,10 @@ import com.androidmakers.ui.model.Lce import com.androidmakers.ui.theme.LocalIsNeobrutalism import fr.androidmakers.domain.model.SocialsItem import fr.paug.androidmakers.ui.Res +import fr.paug.androidmakers.ui.ic_arrow_back import fr.paug.androidmakers.ui.back import fr.paug.androidmakers.ui.speakers +import org.jetbrains.compose.resources.painterResource import org.jetbrains.compose.resources.stringResource @@ -97,7 +97,7 @@ fun SpeakerDetailsScreen( navigationIcon = { IconButton(onClick = onBackClick) { Icon( - Icons.AutoMirrored.Rounded.ArrowBack, + painter = painterResource(Res.drawable.ic_arrow_back), contentDescription = stringResource(Res.string.back) ) } diff --git a/shared/ui/src/commonMain/kotlin/com/androidmakers/ui/speakers/SpeakerListScreen.kt b/shared/ui/src/commonMain/kotlin/com/androidmakers/ui/speakers/SpeakerListScreen.kt index ef4f34d8..843c76b6 100644 --- a/shared/ui/src/commonMain/kotlin/com/androidmakers/ui/speakers/SpeakerListScreen.kt +++ b/shared/ui/src/commonMain/kotlin/com/androidmakers/ui/speakers/SpeakerListScreen.kt @@ -19,10 +19,6 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.CircleShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.rounded.ArrowBack -import androidx.compose.material.icons.rounded.Clear -import androidx.compose.material.icons.rounded.Search import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton @@ -54,9 +50,13 @@ import com.androidmakers.ui.theme.LocalIsNeobrutalism import com.androidmakers.ui.theme.neoBrutalElevation import fr.androidmakers.domain.model.Speaker import fr.paug.androidmakers.ui.Res +import fr.paug.androidmakers.ui.ic_arrow_back +import fr.paug.androidmakers.ui.ic_clear +import fr.paug.androidmakers.ui.ic_search import fr.paug.androidmakers.ui.back import fr.paug.androidmakers.ui.speaker_search_placeholder import fr.paug.androidmakers.ui.speakers +import org.jetbrains.compose.resources.painterResource import org.jetbrains.compose.resources.stringResource @OptIn(ExperimentalMaterial3Api::class) @@ -148,18 +148,18 @@ private fun SpeakerSearchBar( if (expanded) { IconButton(onClick = { onExpandedChange(false) }) { Icon( - Icons.AutoMirrored.Rounded.ArrowBack, + painter = painterResource(Res.drawable.ic_arrow_back), contentDescription = stringResource(Res.string.back) ) } } else { - Icon(Icons.Rounded.Search, contentDescription = null) + Icon(painter = painterResource(Res.drawable.ic_search), contentDescription = null) } }, trailingIcon = { if (hasQuery) { IconButton(onClick = { onTextChange("") }) { - Icon(Icons.Rounded.Clear, contentDescription = null) + Icon(painter = painterResource(Res.drawable.ic_clear), contentDescription = null) } } } diff --git a/shared/ui/src/commonMain/kotlin/com/androidmakers/ui/sponsors/SponsorScreen.kt b/shared/ui/src/commonMain/kotlin/com/androidmakers/ui/sponsors/SponsorScreen.kt index 82a8fc97..0ae36f68 100644 --- a/shared/ui/src/commonMain/kotlin/com/androidmakers/ui/sponsors/SponsorScreen.kt +++ b/shared/ui/src/commonMain/kotlin/com/androidmakers/ui/sponsors/SponsorScreen.kt @@ -1,3 +1,5 @@ +@file:OptIn(ExperimentalMaterial3ExpressiveApi::class) + package com.androidmakers.ui.sponsors import androidx.compose.foundation.background @@ -13,6 +15,9 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.ElevatedCard +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text @@ -23,7 +28,6 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalUriHandler -import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp @@ -92,10 +96,12 @@ private fun TierCard( partnerGroup: PartnerGroup, onSponsorClick: (Partner) -> Unit ) { - Surface( + ElevatedCard( modifier = Modifier.fillMaxWidth().neoBrutalElevation(), shape = MaterialTheme.shapes.large, - color = MaterialTheme.colorScheme.surfaceContainerHigh + colors = CardDefaults.elevatedCardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, + ), ) { Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { SectionHeader( @@ -126,8 +132,7 @@ private fun SectionHeader(title: String) { Text( modifier = Modifier.padding(bottom = 8.dp), text = title, - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Bold, + style = MaterialTheme.typography.titleMediumEmphasized, color = MaterialTheme.colorScheme.onSurface ) } diff --git a/shared/ui/src/jvmMain/kotlin/com/androidmakers/ui/common/SigninButton.jvm.kt b/shared/ui/src/jvmMain/kotlin/com/androidmakers/ui/common/SigninButton.jvm.kt index f8b9b7f9..3e844322 100644 --- a/shared/ui/src/jvmMain/kotlin/com/androidmakers/ui/common/SigninButton.jvm.kt +++ b/shared/ui/src/jvmMain/kotlin/com/androidmakers/ui/common/SigninButton.jvm.kt @@ -1,8 +1,6 @@ package com.androidmakers.ui.common import androidx.compose.foundation.shape.CircleShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.rounded.AccountCircle import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.Icon @@ -16,6 +14,8 @@ import androidx.compose.ui.draw.clip import coil3.compose.AsyncImage import fr.androidmakers.domain.model.User import fr.paug.androidmakers.ui.Res +import fr.paug.androidmakers.ui.ic_account_circle +import org.jetbrains.compose.resources.painterResource import fr.paug.androidmakers.ui.signin import fr.paug.androidmakers.ui.signout import org.jetbrains.compose.resources.stringResource @@ -34,7 +34,7 @@ actual fun SigninButton( ) { if (user == null) { Icon( - imageVector = Icons.Rounded.AccountCircle, + painter = painterResource(Res.drawable.ic_account_circle), contentDescription = stringResource(Res.string.signin) ) } else { From abc26cffc2056bf8c1874b0be93b3a8581e0a5f2 Mon Sep 17 00:00:00 2001 From: Renaud Mathieu Date: Mon, 30 Mar 2026 17:58:49 +0200 Subject: [PATCH 4/7] 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. --- .../store/local/FeedLocalRepository.kt | 1 + .../composeResources/drawable/0.xml | 11 --- .../com/androidmakers/ui/agenda/AgendaRow.kt | 7 +- .../ui/agenda/SessionDetailLayout.kt | 19 +++-- .../ui/common/navigation/AVALayout.kt | 21 ++++-- .../navigation/BottomSheetSceneStrategy.kt | 73 +++++++++++++++++++ 6 files changed, 108 insertions(+), 24 deletions(-) delete mode 100644 shared/ui/src/commonMain/composeResources/drawable/0.xml create mode 100644 shared/ui/src/commonMain/kotlin/com/androidmakers/ui/common/navigation/BottomSheetSceneStrategy.kt diff --git a/shared/data/src/commonMain/kotlin/fr/androidmakers/store/local/FeedLocalRepository.kt b/shared/data/src/commonMain/kotlin/fr/androidmakers/store/local/FeedLocalRepository.kt index fa49cd33..97f29ae9 100644 --- a/shared/data/src/commonMain/kotlin/fr/androidmakers/store/local/FeedLocalRepository.kt +++ b/shared/data/src/commonMain/kotlin/fr/androidmakers/store/local/FeedLocalRepository.kt @@ -11,6 +11,7 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map import kotlinx.datetime.Instant import kotlinx.serialization.Serializable +import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json class FeedLocalRepository( diff --git a/shared/ui/src/commonMain/composeResources/drawable/0.xml b/shared/ui/src/commonMain/composeResources/drawable/0.xml deleted file mode 100644 index a00635de..00000000 --- a/shared/ui/src/commonMain/composeResources/drawable/0.xml +++ /dev/null @@ -1,11 +0,0 @@ - - - - - diff --git a/shared/ui/src/commonMain/kotlin/com/androidmakers/ui/agenda/AgendaRow.kt b/shared/ui/src/commonMain/kotlin/com/androidmakers/ui/agenda/AgendaRow.kt index 2661a538..6b169b7a 100644 --- a/shared/ui/src/commonMain/kotlin/com/androidmakers/ui/agenda/AgendaRow.kt +++ b/shared/ui/src/commonMain/kotlin/com/androidmakers/ui/agenda/AgendaRow.kt @@ -115,8 +115,13 @@ internal fun SessionRow( checked = isBookmarked, onCheckedChange = { onSessionBookmark(uiSession, it) }, ) { + val bookmarkIcon = if (isBookmarked) { + Res.drawable.ic_bookmark_added + } else { + Res.drawable.ic_bookmark_add + } Icon( - painter = painterResource(if (isBookmarked) Res.drawable.ic_bookmark_added else Res.drawable.ic_bookmark_add), + painter = painterResource(bookmarkIcon), contentDescription = "favorite", tint = tint, modifier = Modifier.size(24.dp), diff --git a/shared/ui/src/commonMain/kotlin/com/androidmakers/ui/agenda/SessionDetailLayout.kt b/shared/ui/src/commonMain/kotlin/com/androidmakers/ui/agenda/SessionDetailLayout.kt index c3f2b96f..a9296036 100644 --- a/shared/ui/src/commonMain/kotlin/com/androidmakers/ui/agenda/SessionDetailLayout.kt +++ b/shared/ui/src/commonMain/kotlin/com/androidmakers/ui/agenda/SessionDetailLayout.kt @@ -161,6 +161,7 @@ fun SessionDetailScreen( onBackClick: () -> Unit, onSpeakerClick: (speakerId: String) -> Unit, showBackButton: Boolean = true, + showTopBar: Boolean = true, sharedTransitionScope: SharedTransitionScope? = null, animatedVisibilityScope: AnimatedVisibilityScope? = null, ) { @@ -185,6 +186,7 @@ fun SessionDetailScreen( }, onSpeakerClick = onSpeakerClick, showBackButton = showBackButton, + showTopBar = showTopBar, sharedTransitionScope = sharedTransitionScope, animatedVisibilityScope = animatedVisibilityScope, ) @@ -202,6 +204,7 @@ fun SessionDetailLayout( onApplyForAppClinic: () -> Unit, onSpeakerClick: (speakerId: String) -> Unit, showBackButton: Boolean = true, + showTopBar: Boolean = true, sharedTransitionScope: SharedTransitionScope? = null, animatedVisibilityScope: AnimatedVisibilityScope? = null, ) { @@ -210,13 +213,15 @@ fun SessionDetailLayout( modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), containerColor = MaterialTheme.colorScheme.background, topBar = { - SessionDetailTopAppBar( - scrollBehavior = scrollBehavior, - showBackButton = showBackButton, - onBackClick = onBackClick, - hasContent = sessionDetailState is Lce.Content, - onShareSession = onShareSession, - ) + if (showTopBar) { + SessionDetailTopAppBar( + scrollBehavior = scrollBehavior, + showBackButton = showBackButton, + onBackClick = onBackClick, + hasContent = sessionDetailState is Lce.Content, + onShareSession = onShareSession, + ) + } }, floatingActionButton = { if (sessionDetailState is Lce.Content) { diff --git a/shared/ui/src/commonMain/kotlin/com/androidmakers/ui/common/navigation/AVALayout.kt b/shared/ui/src/commonMain/kotlin/com/androidmakers/ui/common/navigation/AVALayout.kt index b1e4ac2c..e8f230e3 100644 --- a/shared/ui/src/commonMain/kotlin/com/androidmakers/ui/common/navigation/AVALayout.kt +++ b/shared/ui/src/commonMain/kotlin/com/androidmakers/ui/common/navigation/AVALayout.kt @@ -279,7 +279,7 @@ private fun AVABottomBar( } } -@OptIn(ExperimentalMaterial3AdaptiveApi::class) +@OptIn(ExperimentalMaterial3AdaptiveApi::class, ExperimentalMaterial3Api::class) @Composable private fun AVANavDisplay( versionCode: String, @@ -335,15 +335,25 @@ private fun AVANavDisplay( // Detail entries entry( - metadata = ListDetailSceneStrategy.detailPane() + metadata = if (isWideScreen) { + ListDetailSceneStrategy.detailPane() + } else { + BottomSheetSceneStrategy.bottomSheet() + } ) { key -> SessionDetailScreen( viewModel = koinViewModel(key = key.sessionId) { parametersOf(key.sessionId) }, onBackClick = { navigator.goBack() }, onSpeakerClick = { speakerId -> navigator.navigate(SpeakerDetailKey(speakerId)) }, - showBackButton = !isWideScreen, + showBackButton = isWideScreen, + showTopBar = isWideScreen, sharedTransitionScope = sharedTransitionScope, - animatedVisibilityScope = LocalNavAnimatedContentScope.current, + // LocalNavAnimatedContentScope is unavailable in OverlayScene (bottom sheet) + animatedVisibilityScope = if (isWideScreen) { + LocalNavAnimatedContentScope.current + } else { + null + }, ) } @@ -357,11 +367,12 @@ private fun AVANavDisplay( } } + val bottomSheetStrategy = remember { BottomSheetSceneStrategy() } val listDetailStrategy = rememberListDetailSceneStrategy() NavDisplay( entries = navigationState.toDecoratedEntries(entryProvider), - sceneStrategies = listOf(listDetailStrategy), + sceneStrategies = listOf(bottomSheetStrategy, listDetailStrategy), onBack = { navigator.goBack() } ) } diff --git a/shared/ui/src/commonMain/kotlin/com/androidmakers/ui/common/navigation/BottomSheetSceneStrategy.kt b/shared/ui/src/commonMain/kotlin/com/androidmakers/ui/common/navigation/BottomSheetSceneStrategy.kt new file mode 100644 index 00000000..d0609d39 --- /dev/null +++ b/shared/ui/src/commonMain/kotlin/com/androidmakers/ui/common/navigation/BottomSheetSceneStrategy.kt @@ -0,0 +1,73 @@ +package com.androidmakers.ui.common.navigation + +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.ModalBottomSheetProperties +import androidx.compose.runtime.Composable +import androidx.navigation3.runtime.NavEntry +import androidx.navigation3.scene.OverlayScene +import androidx.navigation3.scene.Scene +import androidx.navigation3.scene.SceneStrategy +import androidx.navigation3.scene.SceneStrategyScope + +/** An [OverlayScene] that renders an [entry] within a [ModalBottomSheet]. */ +@OptIn(ExperimentalMaterial3Api::class) +internal class BottomSheetScene( + override val key: T, + override val previousEntries: List>, + override val overlaidEntries: List>, + private val entry: NavEntry, + private val modalBottomSheetProperties: ModalBottomSheetProperties, + private val onBack: () -> Unit, +) : OverlayScene { + + override val entries: List> = listOf(entry) + + override val content: @Composable (() -> Unit) = { + ModalBottomSheet( + onDismissRequest = onBack, + properties = modalBottomSheetProperties, + ) { + entry.Content() + } + } +} + +/** + * A [SceneStrategy] that displays entries that have added [bottomSheet] to their [NavEntry.metadata] + * within a [ModalBottomSheet] instance. + * + * This strategy should always be added before any non-overlay scene strategies. + */ +@OptIn(ExperimentalMaterial3Api::class) +class BottomSheetSceneStrategy : SceneStrategy { + + override fun SceneStrategyScope.calculateScene(entries: List>): Scene? { + val lastEntry = entries.lastOrNull() + val bottomSheetProperties = + lastEntry?.metadata?.get(BOTTOM_SHEET_KEY) as? ModalBottomSheetProperties + return bottomSheetProperties?.let { properties -> + @Suppress("UNCHECKED_CAST") + BottomSheetScene( + key = lastEntry.contentKey as T, + previousEntries = entries.dropLast(1), + overlaidEntries = entries.dropLast(1), + entry = lastEntry, + modalBottomSheetProperties = properties, + onBack = onBack + ) + } + } + + companion object { + /** + * Mark this entry to be displayed within a [ModalBottomSheet]. + */ + @OptIn(ExperimentalMaterial3Api::class) + fun bottomSheet( + modalBottomSheetProperties: ModalBottomSheetProperties = ModalBottomSheetProperties() + ): Map = mapOf(BOTTOM_SHEET_KEY to modalBottomSheetProperties) + + internal const val BOTTOM_SHEET_KEY = "bottomsheet" + } +} From 491d16f53daebbf2377a974291594cbe41ee9c5e Mon Sep 17 00:00:00 2001 From: Renaud Mathieu Date: Tue, 31 Mar 2026 09:43:50 +0200 Subject: [PATCH 5/7] 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. --- shared/ui/src/commonMain/composeResources/values/strings.xml | 2 ++ .../kotlin/com/androidmakers/ui/feed/AlertBannerCard.kt | 4 +++- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/shared/ui/src/commonMain/composeResources/values/strings.xml b/shared/ui/src/commonMain/composeResources/values/strings.xml index 05ed60f4..b3804f93 100644 --- a/shared/ui/src/commonMain/composeResources/values/strings.xml +++ b/shared/ui/src/commonMain/composeResources/values/strings.xml @@ -83,6 +83,8 @@ Add to My Agenda Remove from My Agenda + Dismiss + Feed Read More VENUE diff --git a/shared/ui/src/commonMain/kotlin/com/androidmakers/ui/feed/AlertBannerCard.kt b/shared/ui/src/commonMain/kotlin/com/androidmakers/ui/feed/AlertBannerCard.kt index 488ab0fc..dbb1f011 100644 --- a/shared/ui/src/commonMain/kotlin/com/androidmakers/ui/feed/AlertBannerCard.kt +++ b/shared/ui/src/commonMain/kotlin/com/androidmakers/ui/feed/AlertBannerCard.kt @@ -25,7 +25,9 @@ import fr.androidmakers.domain.model.FeedItem import fr.paug.androidmakers.ui.Res import fr.paug.androidmakers.ui.ic_close import fr.paug.androidmakers.ui.ic_warning +import fr.paug.androidmakers.ui.dismiss import org.jetbrains.compose.resources.painterResource +import org.jetbrains.compose.resources.stringResource @Composable fun AlertBannerCard( @@ -77,7 +79,7 @@ fun AlertBannerCard( ) { Icon( painter = painterResource(Res.drawable.ic_close), - contentDescription = "Dismiss", + contentDescription = stringResource(Res.string.dismiss), tint = contentColor, modifier = Modifier.size(18.dp), ) From 0ce447ab6bfb9891c100b553683d97d48a3a7746 Mon Sep 17 00:00:00 2001 From: Renaud Mathieu Date: Tue, 31 Mar 2026 09:46:23 +0200 Subject: [PATCH 6/7] 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. --- .../store/local/FeedLocalRepository.kt | 1 + .../com/androidmakers/ui/agenda/AgendaRow.kt | 26 +++++++---------- .../ui/agenda/SessionDetailLayout.kt | 29 +++++++------------ .../androidmakers/ui/feed/FeedViewModel.kt | 2 +- 4 files changed, 24 insertions(+), 34 deletions(-) diff --git a/shared/data/src/commonMain/kotlin/fr/androidmakers/store/local/FeedLocalRepository.kt b/shared/data/src/commonMain/kotlin/fr/androidmakers/store/local/FeedLocalRepository.kt index 97f29ae9..236f9e17 100644 --- a/shared/data/src/commonMain/kotlin/fr/androidmakers/store/local/FeedLocalRepository.kt +++ b/shared/data/src/commonMain/kotlin/fr/androidmakers/store/local/FeedLocalRepository.kt @@ -11,6 +11,7 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map import kotlinx.datetime.Instant import kotlinx.serialization.Serializable +import kotlinx.serialization.decodeFromString import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json diff --git a/shared/ui/src/commonMain/kotlin/com/androidmakers/ui/agenda/AgendaRow.kt b/shared/ui/src/commonMain/kotlin/com/androidmakers/ui/agenda/AgendaRow.kt index 6b169b7a..ed1ca131 100644 --- a/shared/ui/src/commonMain/kotlin/com/androidmakers/ui/agenda/AgendaRow.kt +++ b/shared/ui/src/commonMain/kotlin/com/androidmakers/ui/agenda/AgendaRow.kt @@ -16,8 +16,7 @@ import androidx.compose.material3.ElevatedCard import androidx.compose.material3.Icon import androidx.compose.material3.IconToggleButton import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.SuggestionChip -import androidx.compose.material3.SuggestionChipDefaults +import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue @@ -183,21 +182,18 @@ internal fun SessionRow( @Composable private fun TagChip(tag: String) { - SuggestionChip( + Surface( modifier = Modifier.neoBrutalBorder(), - onClick = {}, - label = { - Text( - text = tag, - style = MaterialTheme.typography.labelSmall, - ) - }, shape = MaterialTheme.shapes.small, - colors = SuggestionChipDefaults.suggestionChipColors( - containerColor = MaterialTheme.colorScheme.surfaceContainerHighest, - labelColor = MaterialTheme.colorScheme.onSurfaceVariant, - ), - ) + color = MaterialTheme.colorScheme.surfaceContainerHighest, + ) { + Text( + text = tag, + modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } } @Composable diff --git a/shared/ui/src/commonMain/kotlin/com/androidmakers/ui/agenda/SessionDetailLayout.kt b/shared/ui/src/commonMain/kotlin/com/androidmakers/ui/agenda/SessionDetailLayout.kt index a9296036..c75d0e91 100644 --- a/shared/ui/src/commonMain/kotlin/com/androidmakers/ui/agenda/SessionDetailLayout.kt +++ b/shared/ui/src/commonMain/kotlin/com/androidmakers/ui/agenda/SessionDetailLayout.kt @@ -40,8 +40,6 @@ import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold -import androidx.compose.material3.SuggestionChip -import androidx.compose.material3.SuggestionChipDefaults import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar @@ -562,24 +560,19 @@ private fun SessionHeaderTagsRow( @Composable private fun TagChip(text: String) { - SuggestionChip( - onClick = {}, - label = { - Text( - text = text.uppercase(), - style = MaterialTheme.typography.labelMedium.copy( - letterSpacing = 0.6.sp - ), - ) - }, + Surface( + modifier = Modifier.neoBrutalBorder(), shape = MaterialTheme.shapes.small, border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant), - modifier = Modifier.neoBrutalBorder(), - colors = SuggestionChipDefaults.suggestionChipColors( - containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, - labelColor = MaterialTheme.colorScheme.onSurfaceVariant, - ), - ) + color = MaterialTheme.colorScheme.surfaceContainerHigh, + ) { + Text( + text = text.uppercase(), + modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp), + style = MaterialTheme.typography.labelMedium.copy(letterSpacing = 0.6.sp), + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } } @Composable diff --git a/shared/ui/src/commonMain/kotlin/com/androidmakers/ui/feed/FeedViewModel.kt b/shared/ui/src/commonMain/kotlin/com/androidmakers/ui/feed/FeedViewModel.kt index 31633a44..2a114529 100644 --- a/shared/ui/src/commonMain/kotlin/com/androidmakers/ui/feed/FeedViewModel.kt +++ b/shared/ui/src/commonMain/kotlin/com/androidmakers/ui/feed/FeedViewModel.kt @@ -17,6 +17,6 @@ class FeedViewModel( val dismissedAlertIds: StateFlow> = _dismissedAlertIds.asStateFlow() fun dismissAlert(alertId: String) { - _dismissedAlertIds.update { _dismissedAlertIds.value + alertId } + _dismissedAlertIds.update { it + alertId } } } From 7263a5ac96d68286b8713d9cbb4836cdd1fab17f Mon Sep 17 00:00:00 2001 From: Renaud Mathieu Date: Tue, 31 Mar 2026 10:26:01 +0200 Subject: [PATCH 7/7] 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 --- .../AndroidMakersMessagingService.kt | 3 + .../store/local/FeedLocalRepository.kt | 3 + .../store/mock/MockFeedRepository.kt | 42 +++++++++++++- .../domain/interactor/GetFeedUseCase.kt | 44 ++++++++++++++- .../fr/androidmakers/domain/model/FeedItem.kt | 1 + .../com/androidmakers/ui/feed/MessageCard.kt | 55 ++++++++++++------- 6 files changed, 127 insertions(+), 21 deletions(-) diff --git a/androidApp/src/main/java/fr/paug/androidmakers/messaging/AndroidMakersMessagingService.kt b/androidApp/src/main/java/fr/paug/androidmakers/messaging/AndroidMakersMessagingService.kt index 35f02dde..d6c43a62 100644 --- a/androidApp/src/main/java/fr/paug/androidmakers/messaging/AndroidMakersMessagingService.kt +++ b/androidApp/src/main/java/fr/paug/androidmakers/messaging/AndroidMakersMessagingService.kt @@ -63,12 +63,15 @@ class AndroidMakersMessagingService : FirebaseMessagingService() { MessageType.entries.firstOrNull { it.name == typeName } } ?: MessageType.INFO + val imageUrl = data["feed_image_url"] + val feedItem = FeedItem.Message( id = id, type = type, title = title, body = body, createdAt = Instant.fromEpochMilliseconds(System.currentTimeMillis()), + imageUrl = imageUrl, ) serviceScope.launch { feedRepository.addFeedItem(feedItem) diff --git a/shared/data/src/commonMain/kotlin/fr/androidmakers/store/local/FeedLocalRepository.kt b/shared/data/src/commonMain/kotlin/fr/androidmakers/store/local/FeedLocalRepository.kt index 236f9e17..e75c1ebe 100644 --- a/shared/data/src/commonMain/kotlin/fr/androidmakers/store/local/FeedLocalRepository.kt +++ b/shared/data/src/commonMain/kotlin/fr/androidmakers/store/local/FeedLocalRepository.kt @@ -38,6 +38,7 @@ class FeedLocalRepository( title = message.title, body = message.body, createdAtEpochMillis = message.createdAt.toEpochMilliseconds(), + imageUrl = message.imageUrl, ) dataStore.edit { prefs -> val raw = prefs[PREF_KEY_FEED_ITEMS] ?: "[]" @@ -68,6 +69,7 @@ private data class StoredFeedItem( val title: String, val body: String, val createdAtEpochMillis: Long, + val imageUrl: String? = null, ) { fun toFeedItem(): FeedItem.Message = FeedItem.Message( id = id, @@ -75,5 +77,6 @@ private data class StoredFeedItem( title = title, body = body, createdAt = Instant.fromEpochMilliseconds(createdAtEpochMillis), + imageUrl = imageUrl, ) } 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 5486e92b..ac46593f 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,6 +1,7 @@ package fr.androidmakers.store.mock import fr.androidmakers.domain.model.FeedItem +import fr.androidmakers.domain.model.LocationInfo import fr.androidmakers.domain.repo.FeedRepository import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow @@ -8,7 +9,46 @@ import kotlinx.coroutines.flow.map class MockFeedRepository : FeedRepository { - private val items = MutableStateFlow>(emptyList()) + private val items = MutableStateFlow>( + listOf( + FeedItem.Article( + id = "article-1", + category = "KEYNOTE", + timeAgo = "2h ago", + title = "Opening Keynote: The Future of Android Development", + description = "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", + ), + FeedItem.Article( + id = "article-2", + category = "EVENT", + timeAgo = "5h ago", + 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", + ), + ), + FeedItem.Article( + id = "article-3", + category = "ANNOUNCEMENT", + timeAgo = "1d ago", + title = "Swag Alert: Limited Edition T-Shirts", + description = "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", + ), + ) + ) override fun getFeedItems(): Flow>> { return items.map { Result.success(it) } diff --git a/shared/domain/src/commonMain/kotlin/fr/androidmakers/domain/interactor/GetFeedUseCase.kt b/shared/domain/src/commonMain/kotlin/fr/androidmakers/domain/interactor/GetFeedUseCase.kt index 51f9a3a3..06f99068 100644 --- a/shared/domain/src/commonMain/kotlin/fr/androidmakers/domain/interactor/GetFeedUseCase.kt +++ b/shared/domain/src/commonMain/kotlin/fr/androidmakers/domain/interactor/GetFeedUseCase.kt @@ -1,11 +1,53 @@ package fr.androidmakers.domain.interactor import fr.androidmakers.domain.model.FeedItem +import fr.androidmakers.domain.model.LocationInfo import fr.androidmakers.domain.repo.FeedRepository import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map class GetFeedUseCase( private val feedRepository: FeedRepository, ) { - operator fun invoke(): Flow>> = feedRepository.getFeedItems() + operator fun invoke(): Flow>> = feedRepository.getFeedItems().map { result -> + result.map { repoItems -> repoItems + hardcodedItems } + } } + +private val hardcodedItems = listOf( + FeedItem.Article( + id = "article-1", + category = "KEYNOTE", + timeAgo = "2h ago", + title = "Opening Keynote: Thriving in an AI era \uD83D\uDD2E", + description = "Feeling stuck or unsure about your future as an Android developer? You’re not alone. Between GenAI and Agents getting better and better, sloth in hiring; the role of an Android engineer is shifting fast and it’s easy to feel “Am I still relevant? Should I be learning AI? Will Android still be Android in 5 years?”", + 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", + ), + FeedItem.Article( + id = "article-2", + category = "EVENT", + timeAgo = "5h ago", + title = "After-Hours Party", + description = "Don't miss tonight's networking event with drinks and live music.", + location = LocationInfo( + name = "Café Oz Denfert", + time = "7:00 PM - 11:00 PM", + ), + ), + FeedItem.Article( + id = "article-3", + category = "ANNOUNCEMENT", + timeAgo = "1d ago", + title = "Swag Alert: Limited Edition T-Shirts", + description = "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", + ), +) 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 34163daa..d57f29a8 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 @@ -17,6 +17,7 @@ sealed interface FeedItem { val title: String, val body: String, val createdAt: Instant, + val imageUrl: String? = null, ) : FeedItem data class Article( 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 index d09b58b9..08721d0a 100644 --- a/shared/ui/src/commonMain/kotlin/com/androidmakers/ui/feed/MessageCard.kt +++ b/shared/ui/src/commonMain/kotlin/com/androidmakers/ui/feed/MessageCard.kt @@ -4,6 +4,7 @@ package com.androidmakers.ui.feed import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.material3.CardDefaults import androidx.compose.material3.ElevatedCard @@ -12,9 +13,12 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import coil3.compose.AsyncImage import com.androidmakers.ui.theme.neoBrutalElevation import fr.androidmakers.domain.model.FeedItem import fr.androidmakers.domain.model.MessageType @@ -33,25 +37,38 @@ fun MessageCard( containerColor = 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, - style = MaterialTheme.typography.titleSmallEmphasized, - 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), - ) + Column { + message.imageUrl?.let { url -> + AsyncImage( + model = url, + contentDescription = null, + modifier = Modifier + .fillMaxWidth() + .height(180.dp) + .clip(MaterialTheme.shapes.large), + contentScale = ContentScale.Crop, + ) + } + Column(modifier = Modifier.padding(16.dp)) { + CategoryTimeRow( + category = message.type.label(), + timeAgo = message.createdAt.toRelativeTime(), + ) + Text( + text = message.title, + color = MaterialTheme.colorScheme.onSurface, + style = MaterialTheme.typography.titleSmallEmphasized, + 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), + ) + } } } }