diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 0848349e..4d750217 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -23,12 +23,12 @@ plugins { android { namespace = "com.example.platform" - compileSdk = 35 + compileSdk = 36 defaultConfig { applicationId = "com.example.platform" minSdk = 21 - targetSdk = 35 + targetSdk = 36 versionCode = 1 versionName = "1.0" @@ -92,6 +92,7 @@ dependencies { implementation(project(":samples:user-interface:constraintlayout")) implementation(project(":samples:user-interface:draganddrop")) implementation(project(":samples:user-interface:haptics")) + implementation(project(":samples:user-interface:live-updates")) implementation(project(":samples:user-interface:picture-in-picture")) implementation(project(":samples:user-interface:predictiveback")) implementation(project(":samples:user-interface:quicksettings")) diff --git a/app/src/main/java/com/example/platform/app/ApiSurface.kt b/app/src/main/java/com/example/platform/app/ApiSurface.kt index a98278fe..ea995c2d 100644 --- a/app/src/main/java/com/example/platform/app/ApiSurface.kt +++ b/app/src/main/java/com/example/platform/app/ApiSurface.kt @@ -141,6 +141,12 @@ val UserInterfaceHapticsApiSurface = ApiSurface( null, ) +val UserInterfaceLiveUpdatesApiSurface = ApiSurface( + "live-updates", + "User Interface - Live Updates", + null, +) + val UserInterfacePictureInPictureApiSurface = ApiSurface( "user-interface-picture-in-picture", "User Interface - Picture In Picture", @@ -204,6 +210,7 @@ val API_SURFACES = listOf( UserInterfaceConstraintLayoutApiSurface, UserInterfaceDragAndDropApiSurface, UserInterfaceHapticsApiSurface, + UserInterfaceLiveUpdatesApiSurface, UserInterfacePictureInPictureApiSurface, UserInterfacePredictiveBackApiSurface, UserInterfaceQuickSettingsApiSurface, diff --git a/app/src/main/java/com/example/platform/app/SampleDemo.kt b/app/src/main/java/com/example/platform/app/SampleDemo.kt index 1c87b11a..4b27c359 100644 --- a/app/src/main/java/com/example/platform/app/SampleDemo.kt +++ b/app/src/main/java/com/example/platform/app/SampleDemo.kt @@ -111,6 +111,7 @@ import com.example.platform.ui.haptics.Resist import com.example.platform.ui.haptics.Wobble import com.example.platform.ui.insets.ImmersiveMode import com.example.platform.ui.insets.WindowInsetsAnimationActivity +import com.example.platform.ui.live_updates.LiveUpdateSample import com.example.platform.ui.predictiveback.PBHostingActivity import com.example.platform.ui.quicksettings.QuickSettings import com.example.platform.ui.share.receiver.ShareReceiverActivity @@ -993,6 +994,20 @@ val SAMPLE_DEMOS by lazy { tags = listOf("Haptics"), content = { Wobble() } ), + ComposableSampleDemo( + id = "live-updates", + name = "Live Updates - ProgressStyle implementation", + description = "Usage of ProgressStyle with Live update treatment", + documentation = "https://developer.android.com/about/versions/16/features/progress-centric-notifications", + minSdk = Build.VERSION_CODES.BAKLAVA, + apiSurface = UserInterfaceLiveUpdatesApiSurface, + content = { + MinSdkBox(minSdk = Build.VERSION_CODES.BAKLAVA) { + //noinspection NewApi + LiveUpdateSample() + } + }, + ), ActivitySampleDemo( id = "picture-in-picture-video-playback", name = "Picture in Picture (PiP) - Video playback", diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 23b2ce6d..3e3dbbd9 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -14,7 +14,7 @@ # limitations under the License. # [versions] -agp = "8.8.1" +agp = "8.9.1" fragmentCompose = "1.8.6" kotlin = "2.1.10" coreKtx = "1.15.0" @@ -51,6 +51,7 @@ androidxTestExtTruth = "1.5.0" androidxTestRules = "1.5.0" androidxTestRunner = "1.5.2" androidxUiAutomator = "2.2.0" +material3Android = "1.3.2" media3 = "1.5.0" constraintlayout = "2.1.4" glide-compose = "1.0.0-beta01" @@ -162,6 +163,7 @@ androidx-constraintlayout = "androidx.constraintlayout:constraintlayout:2.1.4" androidx-datastore-preferences = { module = "androidx.datastore:datastore-preferences", version.ref = "androidx-datastore" } androidx-draganddrop = "androidx.draganddrop:draganddrop:1.0.0" androidx-dynamicanimation = "androidx.dynamicanimation:dynamicanimation-ktx:1.0.0-alpha03" +androidx-material3-android = { group = "androidx.compose.material3", name = "material3-android", version.ref = "material3Android" } androidx-media3-common = { module = "androidx.media3:media3-common", version.ref = "media3" } androidx-media3-effect = { module = "androidx.media3:media3-effect", version.ref = "media3" } androidx-media3-exoplayer = { module = "androidx.media3:media3-exoplayer", version.ref = "media3" } @@ -185,6 +187,7 @@ android-application = { id = "com.android.application", version.ref = "agp" } kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } android-library = { id = "com.android.library", version.ref = "agp" } +compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } affectedmoduledetector = { id = "com.dropbox.affectedmoduledetector", version = "0.2.0" } versionCatalogUpdate = { id = "nl.littlerobots.version-catalog-update", version = "0.7.0" } diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar index e708b1c0..a4b76b95 100644 Binary files a/gradle/wrapper/gradle-wrapper.jar and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 4a69aff0..e2847c82 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,7 @@ -#Fri Feb 14 20:10:12 KST 2025 distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.10.2-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-bin.zip +networkTimeout=10000 +validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/samples/user-interface/live-updates/build.gradle.kts b/samples/user-interface/live-updates/build.gradle.kts new file mode 100644 index 00000000..b03e52b1 --- /dev/null +++ b/samples/user-interface/live-updates/build.gradle.kts @@ -0,0 +1,32 @@ +plugins { + alias(libs.plugins.android.library) + alias(libs.plugins.kotlin.android) + alias(libs.plugins.compose) +} + +android { + namespace = "com.example.platform.ui.live_updates" + compileSdk = 36 + + defaultConfig { + minSdk = 21 + targetSdk = 36 + } + kotlinOptions { + jvmTarget = "1.8" + } + + buildFeatures { + viewBinding = true + } +} + +dependencies { + implementation(libs.androidx.core.ktx) + implementation(libs.androidx.appcompat) + implementation(libs.androidx.activity.compose) + implementation(libs.material) + implementation(libs.accompanist.permissions) + implementation(libs.androidx.constraintlayout) + implementation(libs.androidx.material3.android) +} \ No newline at end of file diff --git a/samples/user-interface/live-updates/src/main/AndroidManifest.xml b/samples/user-interface/live-updates/src/main/AndroidManifest.xml new file mode 100644 index 00000000..165bdb3a --- /dev/null +++ b/samples/user-interface/live-updates/src/main/AndroidManifest.xml @@ -0,0 +1,22 @@ + + + + + + + + \ No newline at end of file diff --git a/samples/user-interface/live-updates/src/main/java/com/example/platform/ui/live_updates/LiveUpdateSample.kt b/samples/user-interface/live-updates/src/main/java/com/example/platform/ui/live_updates/LiveUpdateSample.kt new file mode 100644 index 00000000..8eebd009 --- /dev/null +++ b/samples/user-interface/live-updates/src/main/java/com/example/platform/ui/live_updates/LiveUpdateSample.kt @@ -0,0 +1,137 @@ +/* + * Copyright 2025 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.platform.ui.live_updates + +import android.annotation.SuppressLint +import android.app.NotificationManager +import android.content.Context +import android.os.Build +import androidx.annotation.RequiresApi +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +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.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.google.accompanist.permissions.ExperimentalPermissionsApi +import com.google.accompanist.permissions.isGranted +import com.google.accompanist.permissions.rememberPermissionState +import com.google.accompanist.permissions.shouldShowRationale +import kotlinx.coroutines.launch + +@RequiresApi(Build.VERSION_CODES.O) +@Composable +fun LiveUpdateSample() { + val notificationManager = + LocalContext.current.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + SnackbarNotificationManager.initialize(LocalContext.current.applicationContext, notificationManager) + val scope = rememberCoroutineScope() + val snackbarHostState = remember { SnackbarHostState() } + Scaffold( + snackbarHost = { + SnackbarHost(hostState = snackbarHostState) + }, + ) { contentPadding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(contentPadding), + ) { + Text(stringResource( R.string.live_update_summary_text)) + Spacer(modifier = Modifier.height(4.dp)) + NotificationPermission() + Button(onClick = { + onCheckout() + scope.launch { + snackbarHostState.showSnackbar("Order placed") + } + }) { + Text("Checkout") + } + } + } +} + +fun onCheckout() { + SnackbarNotificationManager.start() +} + +@OptIn(ExperimentalPermissionsApi::class) +@Composable +fun NotificationPermission() { + @SuppressLint("InlinedApi") // Granted at install time on API <33. + val notificationPermissionState = rememberPermissionState( + android.Manifest.permission.POST_NOTIFICATIONS, + ) + if (!notificationPermissionState.status.isGranted) { + NotificationPermissionCard( + shouldShowRationale = notificationPermissionState.status.shouldShowRationale, + onGrantClick = { + notificationPermissionState.launchPermissionRequest() + }, + modifier = Modifier + .fillMaxWidth() + ) + } +} + +@Composable +private fun NotificationPermissionCard( + shouldShowRationale: Boolean, + onGrantClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Card( + modifier = modifier, + ) { + Text( + text = stringResource(R.string.permission_message), + modifier = Modifier.padding(16.dp), + ) + if (shouldShowRationale) { + Text( + text = stringResource(R.string.permission_rationale), + modifier = Modifier.padding(horizontal = 10.dp), + ) + } + Box( + modifier = Modifier + .fillMaxWidth() + .padding(10.dp), + contentAlignment = Alignment.BottomEnd, + ) { + Button(onClick = onGrantClick) { + Text(text = stringResource(R.string.permission_grant)) + } + } + } +} \ No newline at end of file diff --git a/samples/user-interface/live-updates/src/main/java/com/example/platform/ui/live_updates/SnackbarNotificationManager.kt b/samples/user-interface/live-updates/src/main/java/com/example/platform/ui/live_updates/SnackbarNotificationManager.kt new file mode 100644 index 00000000..ddcfc312 --- /dev/null +++ b/samples/user-interface/live-updates/src/main/java/com/example/platform/ui/live_updates/SnackbarNotificationManager.kt @@ -0,0 +1,229 @@ +/* + * Copyright 2025 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.platform.ui.live_updates + +import android.app.Notification +import android.app.Notification.ProgressStyle +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.NotificationManager.IMPORTANCE_DEFAULT +import android.content.Context +import android.graphics.Color +import android.os.Build +import android.os.Handler +import android.os.Looper +import androidx.annotation.RequiresApi +import androidx.core.graphics.drawable.IconCompat + +object SnackbarNotificationManager { + private lateinit var notificationManager: NotificationManager + private lateinit var appContext: Context + const val CHANNEL_ID = "live_updates_channel_id" + private const val CHANNEL_NAME = "live_updates_channel_name" + private const val NOTIFICATION_ID = 1234 + + + @RequiresApi(Build.VERSION_CODES.O) + fun initialize(context: Context, notifManager: NotificationManager) { + notificationManager = notifManager + val channel = NotificationChannel(CHANNEL_ID, CHANNEL_NAME, IMPORTANCE_DEFAULT) + appContext = context + notificationManager.createNotificationChannel(channel) + } + + private enum class OrderState(val delay: Long) { + INITIALIZING(5000) { + @RequiresApi(Build.VERSION_CODES.BAKLAVA) + override fun buildNotification(): Notification.Builder { + return buildBaseNotification(appContext, INITIALIZING) + .setSmallIcon(R.drawable.ic_launcher_foreground) + .setContentTitle("You order is being placed") + .setContentText("Confirming with bakery...") + .setStyle(buildBaseProgressStyle(INITIALIZING).setProgressIndeterminate(true)) + } + }, + FOOD_PREPARATION(9000) { + @RequiresApi(Build.VERSION_CODES.BAKLAVA) + override fun buildNotification(): Notification.Builder { + return buildBaseNotification(appContext, FOOD_PREPARATION) + .setContentTitle("Your order is being prepared") + .setContentText("Next step will be delivery") + .setLargeIcon( + IconCompat.createWithResource( + appContext, R.drawable.cupcake + ).toIcon(appContext) + ) + .setStyle(buildBaseProgressStyle(FOOD_PREPARATION).setProgress(25)) + } + }, + FOOD_ENROUTE(13000) { + @RequiresApi(Build.VERSION_CODES.BAKLAVA) + override fun buildNotification(): Notification.Builder { + return buildBaseNotification(appContext, FOOD_ENROUTE) + .setContentTitle("Your order is on its way") + .setContentText("Enroute to destination") + .setStyle( + buildBaseProgressStyle(FOOD_ENROUTE) + .setProgressTrackerIcon( + IconCompat.createWithResource( + appContext, R.drawable.shopping_bag + ).toIcon(appContext) + ) + .setProgress(50) + ) + .setLargeIcon( + IconCompat.createWithResource( + appContext, R.drawable.cupcake + ).toIcon(appContext) + ) + } + }, + FOOD_ARRIVING(18000) { + @RequiresApi(Build.VERSION_CODES.BAKLAVA) + override fun buildNotification(): Notification.Builder { + return buildBaseNotification(appContext, FOOD_ARRIVING) + .setContentTitle("Your order is arriving and has been dropped off") + .setContentText("Enjoy & don't forget to refrigerate any perishable items.") + .setStyle( + buildBaseProgressStyle(FOOD_ARRIVING) + .setProgressTrackerIcon( + IconCompat.createWithResource( + appContext, R.drawable.delivery_truck + ).toIcon(appContext) + ) + .setProgress(75) + ) + .setLargeIcon( + IconCompat.createWithResource( + appContext, R.drawable.cupcake + ).toIcon(appContext) + ) + } + }, + ORDER_COMPLETE(21000) { + @RequiresApi(Build.VERSION_CODES.BAKLAVA) + override fun buildNotification(): Notification.Builder { + return buildBaseNotification(appContext, ORDER_COMPLETE) + .setContentTitle("Your order is complete.") + .setContentText("Thank you for using JetSnack for your snacking needs.") + .setStyle( + buildBaseProgressStyle(ORDER_COMPLETE) + .setProgressTrackerIcon( + IconCompat.createWithResource( + appContext, R.drawable.check_circle + ).toIcon(appContext) + ) + .setProgress(100) + ) + .setLargeIcon( + IconCompat.createWithResource( + appContext, R.drawable.cupcake + ).toIcon(appContext) + ) + } + }; + + + @RequiresApi(Build.VERSION_CODES.BAKLAVA) + fun buildBaseProgressStyle(orderState: OrderState): ProgressStyle { + val pointColor = Color.valueOf(236f, 183f, 255f, 1f).toArgb() + val segmentColor = Color.valueOf(134f, 247f, 250f, 1f).toArgb() + var progressStyle = ProgressStyle() + .setProgressPoints( + listOf( + ProgressStyle.Point(25).setColor(pointColor), + ProgressStyle.Point(50).setColor(pointColor), + ProgressStyle.Point(75).setColor(pointColor), + ProgressStyle.Point(100).setColor(pointColor) + ) + ).setProgressSegments( + listOf( + ProgressStyle.Segment(25).setColor(segmentColor), + ProgressStyle.Segment(25).setColor(segmentColor), + ProgressStyle.Segment(25).setColor(segmentColor), + ProgressStyle.Segment(25).setColor(segmentColor) + + ) + ) + when (orderState) { + INITIALIZING -> {} + FOOD_PREPARATION -> {} + FOOD_ENROUTE -> progressStyle.setProgressPoints( + listOf( + ProgressStyle.Point(25).setColor(pointColor) + ) + ) + + FOOD_ARRIVING -> progressStyle.setProgressPoints( + listOf( + ProgressStyle.Point(25).setColor(pointColor), + ProgressStyle.Point(50).setColor(pointColor) + ) + ) + + ORDER_COMPLETE -> progressStyle.setProgressPoints( + listOf( + ProgressStyle.Point(25).setColor(pointColor), + ProgressStyle.Point(50).setColor(pointColor), + ProgressStyle.Point(75).setColor(pointColor) + ) + ) + } + return progressStyle + } + + @RequiresApi(Build.VERSION_CODES.O) + fun buildBaseNotification(appContext: Context, orderState: OrderState): Notification.Builder { + var notificationBuilder = Notification.Builder(appContext, CHANNEL_ID) + .setSmallIcon(R.drawable.ic_launcher_foreground) + .setOngoing(true) + .setColorized(true) + + when (orderState) { + INITIALIZING -> {} + FOOD_PREPARATION -> {} + FOOD_ENROUTE -> {} + FOOD_ARRIVING -> + notificationBuilder + .addAction( + Notification.Action.Builder(null, "Got it", null).build() + ) + .addAction( + Notification.Action.Builder(null, "Tip", null).build() + ) + ORDER_COMPLETE -> + notificationBuilder + .addAction( + Notification.Action.Builder( + null, "Rate delivery", null).build() + ) + } + return notificationBuilder + } + + abstract fun buildNotification(): Notification.Builder + } + + fun start() { + for (state in OrderState.entries) { + val notification = state.buildNotification().build() + Handler(Looper.getMainLooper()).postDelayed({ + notificationManager.notify(NOTIFICATION_ID, notification) + }, state.delay) + } + } +} diff --git a/samples/user-interface/live-updates/src/main/res/drawable/check_circle.png b/samples/user-interface/live-updates/src/main/res/drawable/check_circle.png new file mode 100644 index 00000000..6a89600e Binary files /dev/null and b/samples/user-interface/live-updates/src/main/res/drawable/check_circle.png differ diff --git a/samples/user-interface/live-updates/src/main/res/drawable/cupcake.jpg b/samples/user-interface/live-updates/src/main/res/drawable/cupcake.jpg new file mode 100644 index 00000000..42e766d8 Binary files /dev/null and b/samples/user-interface/live-updates/src/main/res/drawable/cupcake.jpg differ diff --git a/samples/user-interface/live-updates/src/main/res/drawable/delivery_car.png b/samples/user-interface/live-updates/src/main/res/drawable/delivery_car.png new file mode 100644 index 00000000..e748e59d Binary files /dev/null and b/samples/user-interface/live-updates/src/main/res/drawable/delivery_car.png differ diff --git a/samples/user-interface/live-updates/src/main/res/drawable/delivery_truck.png b/samples/user-interface/live-updates/src/main/res/drawable/delivery_truck.png new file mode 100644 index 00000000..7c607115 Binary files /dev/null and b/samples/user-interface/live-updates/src/main/res/drawable/delivery_truck.png differ diff --git a/samples/user-interface/live-updates/src/main/res/drawable/ic_launcher_foreground.xml b/samples/user-interface/live-updates/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 00000000..86d0b4a9 --- /dev/null +++ b/samples/user-interface/live-updates/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,42 @@ + + + + + + + + + + + + diff --git a/samples/user-interface/live-updates/src/main/res/drawable/shopping_bag.png b/samples/user-interface/live-updates/src/main/res/drawable/shopping_bag.png new file mode 100644 index 00000000..753dddba Binary files /dev/null and b/samples/user-interface/live-updates/src/main/res/drawable/shopping_bag.png differ diff --git a/samples/user-interface/live-updates/src/main/res/values/colors.xml b/samples/user-interface/live-updates/src/main/res/values/colors.xml new file mode 100644 index 00000000..c8524cd9 --- /dev/null +++ b/samples/user-interface/live-updates/src/main/res/values/colors.xml @@ -0,0 +1,5 @@ + + + #FF000000 + #FFFFFFFF + \ No newline at end of file diff --git a/samples/user-interface/live-updates/src/main/res/values/strings.xml b/samples/user-interface/live-updates/src/main/res/values/strings.xml new file mode 100644 index 00000000..9ee41a09 --- /dev/null +++ b/samples/user-interface/live-updates/src/main/res/values/strings.xml @@ -0,0 +1,11 @@ + + Live Updates + + Grant + Please grant the notification permission. + Notifications are used for order tracking. + Clicking the checkout button will simulate the tracking of an order with notifications styled with ProgressStyle. + Checkout + Order placed + + \ No newline at end of file diff --git a/samples/user-interface/live-updates/src/main/res/values/themes.xml b/samples/user-interface/live-updates/src/main/res/values/themes.xml new file mode 100644 index 00000000..9fa09aba --- /dev/null +++ b/samples/user-interface/live-updates/src/main/res/values/themes.xml @@ -0,0 +1,9 @@ + + + + +