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 6b764811..ecd81d84 100644 --- a/androidApp/src/main/java/fr/paug/androidmakers/MainActivity.kt +++ b/androidApp/src/main/java/fr/paug/androidmakers/MainActivity.kt @@ -1,11 +1,20 @@ 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.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 @@ -19,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 @@ -41,17 +52,47 @@ 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 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 { @@ -74,6 +115,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..d6c43a62 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,85 @@ 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 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) + } + } - 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 +98,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..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" @@ -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..e75c1ebe --- /dev/null +++ b/shared/data/src/commonMain/kotlin/fr/androidmakers/store/local/FeedLocalRepository.kt @@ -0,0 +1,82 @@ +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.decodeFromString +import kotlinx.serialization.encodeToString +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(), + imageUrl = message.imageUrl, + ) + 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, + val imageUrl: String? = null, +) { + 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), + 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 5acda825..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 @@ -4,56 +4,57 @@ 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 { - 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", - ), - ) - ) + + 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) } + } + + 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..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 @@ -2,66 +2,52 @@ 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 +import kotlinx.coroutines.flow.map 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>> = feedRepository.getFeedItems().map { result -> + result.map { repoItems -> repoItems + hardcodedItems } } - - 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 = "", - ) } + +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/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..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 @@ -1,5 +1,7 @@ package fr.androidmakers.domain.model +import kotlinx.datetime.Instant + sealed interface FeedItem { val id: String @@ -9,6 +11,15 @@ sealed interface FeedItem { val message: String, ) : FeedItem + data class Message( + override val id: String, + val type: MessageType, + val title: String, + val body: String, + val createdAt: Instant, + val imageUrl: String? = null, + ) : FeedItem + data class Article( override val id: String, val category: String, @@ -24,6 +35,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/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/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/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..b3804f93 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 @@ -82,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/MainLayout.kt b/shared/ui/src/commonMain/kotlin/com/androidmakers/ui/MainLayout.kt index a72ee0d4..7b315a0c 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,26 +9,24 @@ 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 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 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 @@ -62,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( @@ -83,7 +79,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/about/AboutScreen.kt b/shared/ui/src/commonMain/kotlin/com/androidmakers/ui/about/AboutScreen.kt index 8b9867a2..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 @@ -11,23 +11,18 @@ 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 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 @@ -47,11 +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.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 import androidx.compose.ui.unit.sp @@ -70,10 +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.logo_android_makers +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 @@ -89,28 +87,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 +117,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 +165,199 @@ 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.titleMediumEmphasized, + color = MaterialTheme.colorScheme.onSurface ) } @Composable -private fun AboutCard() { - Surface( - modifier = Modifier.fillMaxWidth().neoBrutalElevation(), - shape = MaterialTheme.shapes.large, - color = MaterialTheme.colorScheme.surfaceContainerHigh +internal fun AboutCard() { + ElevatedCard( + modifier = Modifier.fillMaxWidth().neoBrutalElevation(), + shape = MaterialTheme.shapes.large, + colors = CardDefaults.elevatedCardColors( + containerColor = 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 + ElevatedCard( + modifier = Modifier.fillMaxWidth().neoBrutalElevation(), + shape = MaterialTheme.shapes.large, + 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, - label = stringResource(Res.string.faq), - onClick = onFaqClick + icon = painterResource(Res.drawable.ic_question_answer), + 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 = painterResource(Res.drawable.ic_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 = painterResource(Res.drawable.ic_code), + label = stringResource(Res.string.github_repo), + onClick = onGithubRepoClick ) } } } @Composable -private fun LinkRow( - icon: ImageVector, - label: String, - onClick: () -> Unit +internal fun LinkRow( + 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), + ListItem( + modifier = Modifier.clickable(onClick = onClick), + headlineContent = { + Text( text = label, style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurface - ) - Icon( - imageVector = Icons.AutoMirrored.Rounded.KeyboardArrowRight, + ) + }, + leadingContent = { + Icon( + painter = icon, contentDescription = null, - tint = MaterialTheme.colorScheme.onSurfaceVariant - ) - } + 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 -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 + ElevatedCard( + modifier = Modifier.fillMaxWidth().neoBrutalElevation(), + shape = MaterialTheme.shapes.large, + colors = CardDefaults.elevatedCardColors( + containerColor = 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.labelLargeEmphasized, + 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 +366,80 @@ 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 + ElevatedCard( + modifier = Modifier.fillMaxWidth().neoBrutalElevation(), + shape = MaterialTheme.shapes.large, + colors = CardDefaults.elevatedCardColors( + containerColor = 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 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), - 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), + 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 2397a267..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 @@ -9,12 +9,10 @@ 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 @@ -33,8 +31,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 +46,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 +82,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 @@ -109,8 +114,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( - imageVector = imageVector, + painter = painterResource(bookmarkIcon), contentDescription = "favorite", tint = tint, modifier = Modifier.size(24.dp), @@ -135,22 +145,28 @@ 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") + } } } // 5. App Clinic if (uiSession.isAppClinic) { - Button( + FilledTonalButton( onClick = onApplyForAppClinicClick, modifier = Modifier.neoBrutalElevation(shadowOffset = 2.dp), ) { @@ -169,13 +185,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), + modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp), style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSecondaryContainer, + color = MaterialTheme.colorScheme.onSurfaceVariant, ) } } @@ -194,7 +210,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, @@ -218,7 +234,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..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 @@ -1,3 +1,5 @@ +@file:OptIn(ExperimentalMaterial3ExpressiveApi::class) + package com.androidmakers.ui.agenda import androidx.compose.animation.AnimatedVisibilityScope @@ -26,20 +28,14 @@ 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 @@ -57,7 +53,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 +82,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 @@ -154,6 +159,7 @@ fun SessionDetailScreen( onBackClick: () -> Unit, onSpeakerClick: (speakerId: String) -> Unit, showBackButton: Boolean = true, + showTopBar: Boolean = true, sharedTransitionScope: SharedTransitionScope? = null, animatedVisibilityScope: AnimatedVisibilityScope? = null, ) { @@ -178,6 +184,7 @@ fun SessionDetailScreen( }, onSpeakerClick = onSpeakerClick, showBackButton = showBackButton, + showTopBar = showTopBar, sharedTransitionScope = sharedTransitionScope, animatedVisibilityScope = animatedVisibilityScope, ) @@ -195,6 +202,7 @@ fun SessionDetailLayout( onApplyForAppClinic: () -> Unit, onSpeakerClick: (speakerId: String) -> Unit, showBackButton: Boolean = true, + showTopBar: Boolean = true, sharedTransitionScope: SharedTransitionScope? = null, animatedVisibilityScope: AnimatedVisibilityScope? = null, ) { @@ -203,13 +211,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) { @@ -260,7 +270,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 +296,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 +321,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 +362,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 +372,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 +395,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 +420,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 +440,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 +498,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 +521,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,23 +560,17 @@ private fun SessionHeaderTagsRow( @Composable private fun TagChip(text: String) { - val chipShape = MaterialTheme.shapes.small Surface( - shape = chipShape, + modifier = Modifier.neoBrutalBorder(), + shape = MaterialTheme.shapes.small, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant), 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 + modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp), + style = MaterialTheme.typography.labelMedium.copy(letterSpacing = 0.6.sp), + color = MaterialTheme.colorScheme.onSurfaceVariant, ) } } @@ -647,7 +651,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 +662,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 +688,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 +724,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 +747,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 +778,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 +835,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 2b3fa0e1..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 @@ -8,19 +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.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 import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme @@ -43,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 @@ -53,31 +41,40 @@ 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.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 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 @@ -139,7 +136,7 @@ fun AVALayout( AVABottomBar( navigator = navigator, navigationState = navigationState, - featureFlags + featureFlags = featureFlags ) } }, @@ -156,6 +153,7 @@ fun AVALayout( showAgendaFilterBottomSheet = showAgendaFilterBottomSheet, onDismissAgendaFilter = { showAgendaFilterBottomSheet = false }, isWideScreen = isWideScreen, + featureFlags = featureFlags, ) } } @@ -213,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), ) } @@ -229,60 +227,59 @@ private fun AVABottomBar( navigationState: NavigationState, featureFlags: FeatureFlags ) { - 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, - imageVector = Icons.Rounded.CalendarMonth, - 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, - imageVector = Icons.Rounded.Favorite, - 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 - ) - } + NavigationBar(containerColor = MaterialTheme.colorScheme.surfaceContainer) { + + 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, + 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.Info, - label = stringResource(Res.string.about), - destinationRoute = AboutKey + 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, + 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 + ) + } } -@OptIn(ExperimentalMaterial3AdaptiveApi::class) +@OptIn(ExperimentalMaterial3AdaptiveApi::class, ExperimentalMaterial3Api::class) @Composable private fun AVANavDisplay( versionCode: String, @@ -292,6 +289,7 @@ private fun AVANavDisplay( showAgendaFilterBottomSheet: Boolean, onDismissAgendaFilter: () -> Unit, isWideScreen: Boolean, + featureFlags: FeatureFlags, ) { SharedTransitionLayout { val sharedTransitionScope = this @@ -314,10 +312,6 @@ private fun AVANavDisplay( ) } - entry { - VenuePager() - } - entry { SpeakerScreen( viewModel = koinViewModel(), @@ -331,24 +325,35 @@ private fun AVANavDisplay( SponsorsScreen() } - entry { - AboutScreen( + entry { + InfoScreen( versionCode = versionCode, - versionName = versionName + versionName = versionName, + featureFlags = featureFlags, ) } // 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 + }, ) } @@ -362,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() } ) } @@ -376,19 +382,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/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" + } +} 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/feed/AlertBannerCard.kt b/shared/ui/src/commonMain/kotlin/com/androidmakers/ui/feed/AlertBannerCard.kt index 0596adb9..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 @@ -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,12 @@ 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 fr.paug.androidmakers.ui.dismiss +import org.jetbrains.compose.resources.painterResource +import org.jetbrains.compose.resources.stringResource @Composable fun AlertBannerCard( @@ -48,7 +51,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,8 +78,8 @@ fun AlertBannerCard( modifier = Modifier.size(32.dp), ) { Icon( - imageVector = Icons.Rounded.Close, - contentDescription = "Dismiss", + painter = painterResource(Res.drawable.ic_close), + contentDescription = stringResource(Res.string.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/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..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 @@ -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 { it + 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..08721d0a --- /dev/null +++ b/shared/ui/src/commonMain/kotlin/com/androidmakers/ui/feed/MessageCard.kt @@ -0,0 +1,92 @@ +@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.height +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.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 +import kotlin.time.Clock +import kotlin.time.Duration + +@Composable +fun MessageCard( + message: FeedItem.Message, + modifier: Modifier = Modifier, +) { + ElevatedCard( + modifier = modifier.fillMaxWidth().neoBrutalElevation(), + shape = MaterialTheme.shapes.large, + colors = CardDefaults.elevatedCardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, + ), + ) { + 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), + ) + } + } + } +} + +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" + } +} 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/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/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() 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 {