diff --git a/app/src/main/java/com/android/developers/androidify/MainActivity.kt b/app/src/main/java/com/android/developers/androidify/MainActivity.kt index cf258f6c..9d2e5e24 100644 --- a/app/src/main/java/com/android/developers/androidify/MainActivity.kt +++ b/app/src/main/java/com/android/developers/androidify/MainActivity.kt @@ -17,7 +17,6 @@ package com.android.developers.androidify import android.os.Build import android.os.Bundle -import android.util.Log import android.view.WindowManager import android.window.TrustedPresentationThresholds import androidx.activity.ComponentActivity @@ -73,7 +72,9 @@ class MainActivity : ComponentActivity() { val minFractionRendered = 0.25f val stabilityRequirements = 500 val presentationThreshold = TrustedPresentationThresholds( - minAlpha, minFractionRendered, stabilityRequirements + minAlpha, + minFractionRendered, + stabilityRequirements, ) val windowManager = getSystemService(WINDOW_SERVICE) as WindowManager @@ -81,7 +82,7 @@ class MainActivity : ComponentActivity() { window.decorView.windowToken, presentationThreshold, mainExecutor, - presentationListener + presentationListener, ) } } @@ -93,5 +94,4 @@ class MainActivity : ComponentActivity() { windowManager.unregisterTrustedPresentationListener(presentationListener) } } - } diff --git a/app/src/main/java/com/android/developers/androidify/navigation/MainNavigation.kt b/app/src/main/java/com/android/developers/androidify/navigation/MainNavigation.kt index 208a415a..8eab6f36 100644 --- a/app/src/main/java/com/android/developers/androidify/navigation/MainNavigation.kt +++ b/app/src/main/java/com/android/developers/androidify/navigation/MainNavigation.kt @@ -26,7 +26,6 @@ import androidx.compose.animation.scaleOut import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable -import androidx.compose.runtime.MutableState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -121,7 +120,7 @@ fun MainNavigation() { }, onLicensesClicked = { context.startActivity(Intent(context, OssLicensesMenuActivity::class.java)) - } + }, ) } }, diff --git a/core/network/src/main/res/xml/remote_config_defaults.xml b/core/network/src/main/res/xml/remote_config_defaults.xml index ebe098d2..afe70370 100644 --- a/core/network/src/main/res/xml/remote_config_defaults.xml +++ b/core/network/src/main/res/xml/remote_config_defaults.xml @@ -17,22 +17,40 @@ prompt_image_validation - You are to analyze the provided image and determine if it is acceptable and appropriate based on specific criteria. - In the JSON response, respond with the result 'success' as set to true or false based on results. - If the image is considered invalid, include the relevant reason as to why it is invalid in the 'error' property. A photo is only valid if: - - it is a photo of a person, at least showing their shoulders and head, it can be a full body photo + You are to analyze the provided image and determine if it is acceptable and + appropriate based on specific criteria. + In the JSON response, respond with the result 'success' as set to true or false based on + results. + If the image is considered invalid, include the relevant reason as to why it is invalid + in the 'error' property. A photo is only valid if: + - it is a photo of a person, at least showing their shoulders and head, it can be a full + body photo - it must be a photo of a person - - the photo has a clear main person in it, if there are people in the background ignore them + - the photo has a clear main person in it, if there are people in the background ignore + them - it cannot contain nudity or explicit content - it cannot contain illegal weapons or violent references - it cannot contain references to drugs or other illicit substances - - it cannot contain hate speech or other offensive language - -it cannot contain blood or gore or violence. + - it cannot contain hate speech or other offensive language + -it cannot contain blood or gore or violence. + prompt_image_generation - This 3D rendered, cartoonish Android mascot rendered in a photorealistic style, the pose is relaxed and straightforward, facing directly forward with his shoulders at ease, as if posing for a photo. The cartoonish exaggeration is subtle, lending a playful touch to the otherwise realistic rendering of the figure. The figure is centered against a muted, neutral warm cream coloured background (#F8F2E4) gives the figurine a unique and collectible appeal. - The bot should take on the body shape of the newest Google Android Robot (Shape: It has the distinctive rounded body. The main body is a slightly barrel-shaped form with a smooth, continuous surface connecting to the head without a distinct neck, a semi-circular head with Two short, straight antennae protrude vertically from the top of the dome, positioned towards the sides, and simple, cylindrical arms and legs.) but the characteristics of the description should be used. It should NOT use the model shape or color from pre 2024. + This 3D rendered, cartoonish Android mascot rendered in a photorealistic style, the + pose is relaxed and straightforward, facing directly forward with his shoulders at ease, + as if posing for a photo. The cartoonish exaggeration is subtle, lending a playful touch + to the otherwise realistic rendering of the figure. The figure is centered against a + muted, neutral warm cream coloured background (#F8F2E4) gives the figurine a unique and + collectible appeal. + The bot should take on the body shape of the newest Google Android Robot (Shape: It has + the distinctive rounded body. The main body is a slightly barrel-shaped form with a + smooth, continuous surface connecting to the head without a distinct neck, a + semi-circular head with Two short, straight antennae protrude vertically from the top of + the dome, positioned towards the sides, and simple, cylindrical arms and legs.) but the + characteristics of the description should be used. It should NOT use the model shape or + color from pre 2024. + use_gemini_nano @@ -44,31 +62,53 @@ system_prompt_image_description - Extract detailed information from the provided image. THE GOAL is to using this information to recreate the image with image generation model. + Extract detailed information from the provided image. THE GOAL is to using this + information to recreate the image with image generation model. - | Category | Attribute | Description (Focus on visual, descriptive language for image generation) | + | Category | Attribute | Description (Focus on visual, descriptive language for image + generation) | |---|---|---| | **Subject** | Type | The central figure in the image. | - | | Hair | The color, length, and style of the hair. Use concise, descriptive adjectives to detail its appearance. | - | | Facial Hair | The style, and color (if applicable) of any facial hair present. Be visually specific about its form and length (if applicable). | - | | Headwear | The type, color, material, and any visually distinct details of the headwear. Describe any patterns, textures, or embellishments, and its position on the head. | - | | Skin Color | The apparent color of the skin, using terms commonly associated with skin tones followed by the approximate hex code for accuracy. | - | | Clothing - Top | The type, color, and pattern of the upper garment. Describe its fit and any observable visual details such as closures, necklines, or textures. | - | | Clothing - Bottom | The type, color, and pattern of the lower garment. Describe its fit and any visual details like rips, pleats, or pockets. | - | | Footwear | The type, color, and material of the footwear. Be visually specific about any details such as laces, buckles, or straps. | - | | Accessories | The type, color, and material of any accessories. Explicitly state their position on or relative to the subject, as well as their arrangement if multiple items are present. | - | | Gadgets | The type, color, and material of any gadgets present. Be specific about their appearance and their position relative to the subject. | - | | Additional Notes | Any other visually distinct details of the subject that have not been covered in the above attributes. | - - * Remember to replace the bracketed information with your detailed visual analysis of a specific image. + | | Hair | The color, length, and style of the hair. Use concise, descriptive adjectives + to detail its appearance. | + | | Facial Hair | The style, and color (if applicable) of any facial hair present. Be + visually specific about its form and length (if applicable). | + | | Headwear | The type, color, material, and any visually distinct details of the + headwear. Describe any patterns, textures, or embellishments, and its position on the + head. | + | | Skin Color | The apparent color of the skin, using terms commonly associated with + skin tones followed by the approximate hex code for accuracy. | + | | Clothing - Top | The type, color, and pattern of the upper garment. Describe its fit + and any observable visual details such as closures, necklines, or textures. | + | | Clothing - Bottom | The type, color, and pattern of the lower garment. Describe its + fit and any visual details like rips, pleats, or pockets. | + | | Footwear | The type, color, and material of the footwear. Be visually specific about + any details such as laces, buckles, or straps. | + | | Accessories | The type, color, and material of any accessories. Explicitly state + their position on or relative to the subject, as well as their arrangement if multiple + items are present. | + | | Gadgets | The type, color, and material of any gadgets present. Be specific about + their appearance and their position relative to the subject. | + | | Additional Notes | Any other visually distinct details of the subject that have not + been covered in the above attributes. | + + * Remember to replace the bracketed information with your detailed visual analysis of a + specific image. * Provide highly descriptive details for all attributes of the subject. - * For accessories, ensure the descriptions are highly detailed and explicitly state their position on or relative to the subject, as well as their arrangement if applicable. - * Describe hair and facial hair with rich visual detail, including color, style, and length. - * Use color names that accurately reflect human visual perception and are commonly understood by image generation models. + * For accessories, ensure the descriptions are highly detailed and explicitly state + their position on or relative to the subject, as well as their arrangement if + applicable. + * Describe hair and facial hair with rich visual detail, including color, style, and + length. + * Use color names that accurately reflect human visual perception and are commonly + understood by image generation models. * Do not include any brand logos or brand names in your descriptions. * Do not include descriptions of any emblems present. - * Do not include any reference to facial features or facial expressions in the description. - * Do not include descriptions of anything that appears in the background. Only describe the appearance of the person and what the subject may be wearing or holding. + * Do not include any reference to facial features or facial expressions in the + description. + * Do not include descriptions of anything that appears in the background. Only describe + the appearance of the person and what the subject may be wearing or holding. + is_android_app_inactive @@ -76,7 +116,9 @@ prompt_image_description - Create a new image description where the subject(s) and surroundings are transformed into subject(s) attributes hair color, clothing, accessories are important for human subjects. + Create a new image description where the subject(s) and surroundings are transformed + into subject(s) attributes hair color, clothing, accessories are important for human + subjects. The goal is a near perfect recreation. Describe details of the subject, accessories. @@ -90,23 +132,40 @@ Dont say rendered, rendering, or digital. Do not mention the background. - The detailed description should become the 'user_description' value in the JSON schema that will be used for the generative image model. + The detailed description should become the 'user_description' value in the JSON schema + that will be used for the generative image model. For the 'user_description' value, describe attributes such as: -headwear; for example: hats, hair accessories, glasses, or headphones - -hair color, hair style, and hair length; for example: chin-length straight brown hair, long curly blonde hair, short spiky red hair, bald with no hair - -facial hair, if applicable; for example: full brown bushy beard, blonde mustache, short black chin patch - -upper body clothing style and color; for example: white t-shirt, black dress shirt under a white and pink striped blazer, green lace dress with spaghetti straps - -lower body clothing style and color; for example: blue cuffed jeans with a brown leather belt, black slacks, white knee-length skirt - -footwear; for example: pink leather sandals, blue flip flops, brown suede ankle boots, black high heels, brown loafers, green sneakers, red socks - -accessories; for example: holding a navy blue backpack, holding a tennis racket, using a walking cane, holding a coffee mug - - - For all those attributes, include details such as color, shape, length, style, accessories, and textures and materials. When describing clothing on the upper body, - include details such as neckline, type of sleeves or straps that go over the shoulders as well as the exact colors in hex codes. + -hair color, hair style, and hair length; for example: chin-length straight brown hair, + long curly blonde hair, short spiky red hair, bald with no hair + -facial hair, if applicable; for example: full brown bushy beard, blonde mustache, short + black chin patch + -upper body clothing style and color; for example: white t-shirt, black dress shirt + under a white and pink striped blazer, green lace dress with spaghetti straps + -lower body clothing style and color; for example: blue cuffed jeans with a brown + leather belt, black slacks, white knee-length skirt + -footwear; for example: pink leather sandals, blue flip flops, brown suede ankle boots, + black high heels, brown loafers, green sneakers, red socks + -accessories; for example: holding a navy blue backpack, holding a tennis racket, using + a walking cane, holding a coffee mug + + + For all those attributes, include details such as color, shape, length, style, + accessories, and textures and materials. When describing clothing on the upper body, + include details such as neckline, type of sleeves or straps that go over the shoulders + as well as the exact colors in hex codes. For example: v-neck tank top with thin straps, or scoop neck dress with cap sleeves. - When describing clothing, use this phrase structure: "wearing (describe clothing) on its body - - Do not include brand logos or brand names in the description. Do not include descriptions of emblems. The description should be gender neutral. Do not use gendered terms like 'he' or 'she'. Do not describe skin color or include any reference to skin. Do not include any reference to facial features or facial expressions in the description. Dn not include descriptions of anything that appears in the background. Only describe the appearance of the person, and what the subject may be wearing or holding. + When describing clothing, use this phrase structure: "wearing (describe clothing) on its + body + + Do not include brand logos or brand names in the description. Do not include + descriptions of emblems. The description should be gender neutral. Do not use gendered + terms like 'he' or 'she'. Do not describe skin color or include any reference to skin. + Do not include any reference to facial features or facial expressions in the + description. Dn not include descriptions of anything that appears in the background. + Only describe the appearance of the person, and what the subject may be wearing or + holding. + promo_video_link @@ -114,7 +173,7 @@ text_model_name - gemini-2.5-pro-preview-03-25 + gemini-2.5-pro is_app_active @@ -122,7 +181,12 @@ generate_bot_prompt - Generate 10 different random prompts as a comma separated list for a description of what a person looks like for android bot generation: include hair color texture and length, clothing including colors and details (like the persons shirt and pants or dress and collar types), with accessories. Make them, fun, safe and all different, dont include gender or ethnicity or dangerous content. For example "wearing blue jeans, gray ruffly blouse, holding a magnifying glass with sparkly shoes and brown wavy hair." + Generate 10 different random prompts as a comma separated list for a description of + what a person looks like for android bot generation: include hair color texture and + length, clothing including colors and details (like the persons shirt and pants or dress + and collar types), with accessories. Make them, fun, safe and all different, dont + include gender or ethnicity or dangerous content. For example "wearing blue jeans, gray + ruffly blouse, holding a magnifying glass with sparkly shoes and brown wavy hair." The prompt should: - it cannot contain gender or ethnicity or dangerous content. @@ -131,26 +195,55 @@ - it cannot contain references to drugs or other illicit substances. - it cannot contain hate speech or other offensive language. - it cannot contain blood or gore or violence. - - it cannot contain political symbolism. + - it cannot contain political symbolism. + prompt_text_verify - You are to evaluate the given text string, restructure it, and return it in a JSON format for use with a backend application. First, check that the text describes the attributes of a person and includes some attributes describing what they look like and are wearing. For example, valid attributes may include hair color and style, facial hair, clothing, shoes, and objects and accessories that the person is holding or wearing. If no valid attributes describing a person and their appearance exist in the text, set the value for the 'success' property in the JSON to 'false'. - - If the text string includes a valid description of a person, set the value for the 'success' property in the JSON to 'true' and restructure the input text to be part of the response as 'user_description' in the following ways,: - -strip out any phrases or descriptors that are inappropriate for a general audience such as racial or cultural stereotypes, political or hate symbols, sexual references, hateful comments, or inappropriate language. - -strip out descriptions of weapons and guns, and references to drugs or drug paraphernalia + You are to evaluate the given text string, restructure it, and return it in a JSON + format for use with a backend application. First, check that the text describes the + attributes of a person and includes some attributes describing what they look like and + are wearing. For example, valid attributes may include hair color and style, facial + hair, clothing, shoes, and objects and accessories that the person is holding or + wearing. If no valid attributes describing a person and their appearance exist in the + text, set the value for the 'success' property in the JSON to 'false'. + + If the text string includes a valid description of a person, set the value for the + 'success' property in the JSON to 'true' and restructure the input text to be part of + the response as 'user_description' in the following ways,: + -strip out any phrases or descriptors that are inappropriate for a general audience such + as racial or cultural stereotypes, political or hate symbols, sexual references, hateful + comments, or inappropriate language. + -strip out descriptions of weapons and guns, and references to drugs or drug + paraphernalia -strip out descriptions of logos or brand names - -strip out descriptions of anything that might describe the background behind or around the person or subject in the text + -strip out descriptions of anything that might describe the background behind or around + the person or subject in the text - Keep all words and descriptors that describe details such as colors, styles, size and materials of hair, clothing, shoes, accessories that do not allude to any of the inappropriate content listed above. + Keep all words and descriptors that describe details such as colors, styles, size and + materials of hair, clothing, shoes, accessories that do not allude to any of the + inappropriate content listed above. + image_model_name - imagen-3.0-generate-002 + imagen-4.0-ultra-generate-preview-06-06 prompt_image_generation_skin_tone - This 3D rendered, cartoonish Android mascot rendered in a photorealistic style, with the {skinTone} skin color and {prompt}, the pose is relaxed and straightforward, facing directly forward with his shoulders at ease, as if posing for a photo. The cartoonish exaggeration is subtle, lending a playful touch to the otherwise realistic rendering of the figure. The figure is centered against a muted, neutral warm cream coloured background (#F8F2E4) gives the figurine a unique and collectible appeal. The bot should take on the body shape of the newest Google Android Robot (Shape: It has the distinctive rounded body. The main body is a slightly barrel-shaped form with a smooth, continuous surface connecting to the head without a distinct neck, a semi-circular head with Two short, straight antennae protrude vertically from the top of the dome, positioned towards the sides, and simple, cylindrical arms and legs.) but the characteristics of the description should be used. It should NOT use the model shape or color from pre 2024. + This 3D rendered, cartoonish Android mascot rendered in a photorealistic style, with + the {skinTone} skin color and {prompt}, the pose is relaxed and straightforward, facing + directly forward with his shoulders at ease, as if posing for a photo. The cartoonish + exaggeration is subtle, lending a playful touch to the otherwise realistic rendering of + the figure. The figure is centered against a muted, neutral warm cream coloured + background (#F8F2E4) gives the figurine a unique and collectible appeal. The bot should + take on the body shape of the newest Google Android Robot (Shape: It has the distinctive + rounded body. The main body is a slightly barrel-shaped form with a smooth, continuous + surface connecting to the head without a distinct neck, a semi-circular head with Two + short, straight antennae protrude vertically from the top of the dome, positioned + towards the sides, and simple, cylindrical arms and legs.) but the characteristics of + the description should be used. It should NOT use the model shape or color from pre + 2024. + \ No newline at end of file diff --git a/core/testing/build.gradle.kts b/core/testing/build.gradle.kts index 3d316635..98a27156 100644 --- a/core/testing/build.gradle.kts +++ b/core/testing/build.gradle.kts @@ -58,6 +58,8 @@ dependencies { implementation(projects.data) implementation(projects.core.network) implementation(projects.core.util) + implementation(projects.feature.results) + ksp(libs.hilt.compiler) androidTestImplementation(platform(libs.androidx.compose.bom)) diff --git a/core/testing/src/main/java/com/android/developers/testing/repository/FakeDropImageFactory.kt b/core/testing/src/main/java/com/android/developers/testing/repository/FakeDropImageFactory.kt index 783d349b..0ccff138 100644 --- a/core/testing/src/main/java/com/android/developers/testing/repository/FakeDropImageFactory.kt +++ b/core/testing/src/main/java/com/android/developers/testing/repository/FakeDropImageFactory.kt @@ -1,3 +1,18 @@ +/* + * 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.android.developers.testing.repository import android.net.Uri @@ -18,7 +33,5 @@ class FakeDropImageFactory : DropBehaviourFactory { override fun onDrop(event: DragAndDropEvent): Boolean { return false } - } - -} \ No newline at end of file +} diff --git a/core/testing/src/main/java/com/android/developers/testing/util/FakeComposableBitmapRenderer.kt b/core/testing/src/main/java/com/android/developers/testing/util/FakeComposableBitmapRenderer.kt new file mode 100644 index 00000000..04afd7ca --- /dev/null +++ b/core/testing/src/main/java/com/android/developers/testing/util/FakeComposableBitmapRenderer.kt @@ -0,0 +1,37 @@ +/* + * 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.android.developers.testing.util + +import android.graphics.Bitmap +import androidx.compose.runtime.Composable +import androidx.compose.ui.geometry.Size +import androidx.core.graphics.createBitmap +import com.android.developers.androidify.customize.ComposableBitmapRenderer + +class FakeComposableBitmapRenderer : ComposableBitmapRenderer { + override fun initialize() { + } + + override fun dispose() { + } + + override suspend fun renderComposableToBitmap( + canvasSize: Size, + composableContent: @Composable (() -> Unit), + ): Bitmap? { + return createBitmap(1, 1) + } +} diff --git a/core/theme/src/main/java/com/android/developers/androidify/theme/SharedElementsConfig.kt b/core/theme/src/main/java/com/android/developers/androidify/theme/SharedElementsConfig.kt index 5d249476..a053eeb0 100644 --- a/core/theme/src/main/java/com/android/developers/androidify/theme/SharedElementsConfig.kt +++ b/core/theme/src/main/java/com/android/developers/androidify/theme/SharedElementsConfig.kt @@ -54,6 +54,7 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Matrix import androidx.compose.ui.graphics.Path import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.layout.LookaheadScope import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.LayoutDirection @@ -77,6 +78,10 @@ val LocalSharedTransitionScope = compositionLocalOf { throw IllegalStateException("No SharedTransitionScope provided") } +val LocalAnimateBoundsScope = compositionLocalOf { + null +} + @OptIn( ExperimentalMaterial3ExpressiveApi::class, ExperimentalSharedTransitionApi::class, diff --git a/core/theme/src/main/java/com/android/developers/androidify/theme/components/TopAppBar.kt b/core/theme/src/main/java/com/android/developers/androidify/theme/components/TopAppBar.kt index 4f681c4b..41d40f7e 100644 --- a/core/theme/src/main/java/com/android/developers/androidify/theme/components/TopAppBar.kt +++ b/core/theme/src/main/java/com/android/developers/androidify/theme/components/TopAppBar.kt @@ -54,6 +54,7 @@ import com.android.developers.androidify.theme.sharedBoundsReveal @OptIn(ExperimentalMaterial3Api::class) fun AndroidifyTopAppBar( modifier: Modifier = Modifier, + titleText: String = stringResource(R.string.androidify_title), isMediumWindowSize: Boolean = false, backEnabled: Boolean = false, aboutEnabled: Boolean = true, @@ -84,7 +85,7 @@ fun AndroidifyTopAppBar( } else { Spacer(modifier.size(16.dp)) } - AndroidifyTitle() + AndroidifyTitle(titleText) } Box( @@ -109,7 +110,7 @@ fun AndroidifyTopAppBar( } else { CenterAlignedTopAppBar( title = { - AndroidifyTitle() + AndroidifyTitle(titleText) }, modifier = modifier .statusBarsPadding() @@ -146,13 +147,14 @@ private fun BackButton(onBackPressed: () -> Unit) { @Composable fun AndroidifyTranslucentTopAppBar( modifier: Modifier = Modifier, + titleText: String = stringResource(R.string.androidify_title), isMediumSizeLayout: Boolean = false, ) { if (isMediumSizeLayout) { TopAppBar( title = { Spacer(Modifier.statusBarsPadding()) - AndroidifyTitle() + AndroidifyTitle(titleText) }, modifier = modifier.clip( MaterialTheme.shapes.large.copy(topStart = CornerSize(0f), topEnd = CornerSize(0f)), @@ -163,7 +165,7 @@ fun AndroidifyTranslucentTopAppBar( CenterAlignedTopAppBar( title = { Spacer(Modifier.statusBarsPadding()) - AndroidifyTitle() + AndroidifyTitle(titleText) }, modifier = modifier.clip( MaterialTheme.shapes.large.copy(topStart = CornerSize(0f), topEnd = CornerSize(0f)), @@ -174,8 +176,8 @@ fun AndroidifyTranslucentTopAppBar( } @Composable -private fun AndroidifyTitle() { - Text(stringResource(R.string.androidify_title), fontWeight = FontWeight.Bold) +private fun AndroidifyTitle(text: String) { + Text(text, fontWeight = FontWeight.Bold) } @OptIn(ExperimentalSharedTransitionApi::class) diff --git a/core/theme/src/main/res/drawable/outline_share_24.xml b/core/theme/src/main/res/drawable/outline_share_24.xml new file mode 100644 index 00000000..21795086 --- /dev/null +++ b/core/theme/src/main/res/drawable/outline_share_24.xml @@ -0,0 +1,28 @@ + + + + + + + diff --git a/core/util/src/main/java/com/android/developers/androidify/util/LocalFileProvider.kt b/core/util/src/main/java/com/android/developers/androidify/util/LocalFileProvider.kt index 1eddf32b..150fe769 100644 --- a/core/util/src/main/java/com/android/developers/androidify/util/LocalFileProvider.kt +++ b/core/util/src/main/java/com/android/developers/androidify/util/LocalFileProvider.kt @@ -58,7 +58,7 @@ interface LocalFileProvider { @Singleton class LocalFileProviderImpl @Inject constructor( private val application: Application, - @Named("IO") private val ioDispatcher: CoroutineDispatcher + @Named("IO") private val ioDispatcher: CoroutineDispatcher, ) : LocalFileProvider { override suspend fun saveBitmapToFile(bitmap: Bitmap, file: File) = withContext(ioDispatcher) { diff --git a/core/util/src/main/java/com/android/developers/androidify/util/LocalOcclusion.kt b/core/util/src/main/java/com/android/developers/androidify/util/LocalOcclusion.kt index 8b700cea..f7bbf1ab 100644 --- a/core/util/src/main/java/com/android/developers/androidify/util/LocalOcclusion.kt +++ b/core/util/src/main/java/com/android/developers/androidify/util/LocalOcclusion.kt @@ -1,7 +1,21 @@ +/* + * 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.android.developers.androidify.util import androidx.compose.runtime.compositionLocalOf import androidx.compose.runtime.mutableStateOf val LocalOcclusion = compositionLocalOf { mutableStateOf(false) } - diff --git a/data/src/main/java/com/android/developers/androidify/data/DropBehaviourFactory.kt b/data/src/main/java/com/android/developers/androidify/data/DropBehaviourFactory.kt index 944caf1d..6b0a3fb0 100644 --- a/data/src/main/java/com/android/developers/androidify/data/DropBehaviourFactory.kt +++ b/data/src/main/java/com/android/developers/androidify/data/DropBehaviourFactory.kt @@ -1,3 +1,18 @@ +/* + * 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.android.developers.androidify.data import android.graphics.BitmapFactory @@ -13,7 +28,6 @@ import kotlinx.coroutines.launch import java.util.UUID import javax.inject.Inject - interface DropBehaviourFactory { fun shouldStartDragAndDrop(event: DragAndDropEvent): Boolean fun createTargetCallback( @@ -83,4 +97,4 @@ class DropBehaviourFactoryImpl @Inject constructor(val localFileProvider: LocalF return true } } -} \ No newline at end of file +} diff --git a/feature/creation/src/main/java/com/android/developers/androidify/creation/CreationScreen.kt b/feature/creation/src/main/java/com/android/developers/androidify/creation/CreationScreen.kt index e55c684f..40767c56 100644 --- a/feature/creation/src/main/java/com/android/developers/androidify/creation/CreationScreen.kt +++ b/feature/creation/src/main/java/com/android/developers/androidify/creation/CreationScreen.kt @@ -25,6 +25,7 @@ package com.android.developers.androidify.creation import android.net.Uri import androidx.activity.ComponentActivity import androidx.activity.compose.BackHandler +import androidx.activity.compose.LocalActivity import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.PickVisualMediaRequest import androidx.activity.result.contract.ActivityResultContracts.PickVisualMedia @@ -68,7 +69,6 @@ import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.input.TextFieldState import androidx.compose.foundation.verticalScroll import androidx.compose.material3.ButtonDefaults @@ -127,6 +127,8 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import coil3.compose.AsyncImage import coil3.request.ImageRequest import coil3.request.crossfade +import com.android.developers.androidify.customize.CustomizeAndExportScreen +import com.android.developers.androidify.customize.CustomizeExportViewModel import com.android.developers.androidify.data.DropBehaviourFactory import com.android.developers.androidify.results.ResultsScreen import com.android.developers.androidify.theme.AndroidifyTheme @@ -228,8 +230,27 @@ fun CreationScreen( viewModel = hiltViewModel(key = key), onAboutPress = onAboutPressed, onBackPress = onBackPressed, + onNextPress = creationViewModel::customizeExportClicked, ) } + + ScreenState.CUSTOMIZE -> { + val prompt = uiState.descriptionText.text.toString() + val key = if (uiState.descriptionText.text.isBlank()) { + uiState.imageUri.toString() + } else { + prompt + } + uiState.resultBitmap?.let { bitmap -> + CustomizeAndExportScreen( + resultImage = bitmap, + originalImageUri = uiState.imageUri, + onBackPress = onBackPressed, + onInfoPress = onAboutPressed, + viewModel = hiltViewModel(key = key), + ) + } + } } } @@ -416,7 +437,7 @@ private fun MainCreationPane( val alternateDropAreaBackgroundColor = MaterialTheme.colorScheme.surfaceVariant var background by remember { mutableStateOf(defaultDropAreaBackgroundColor) } - val activity = LocalContext.current as ComponentActivity + val activity = LocalActivity.current as ComponentActivity val externalAppCallback = remember { dropBehaviourFactory.createTargetCallback( activity = activity, @@ -426,7 +447,6 @@ private fun MainCreationPane( ) } - Box( modifier = modifier, ) { diff --git a/feature/creation/src/main/java/com/android/developers/androidify/creation/CreationViewModel.kt b/feature/creation/src/main/java/com/android/developers/androidify/creation/CreationViewModel.kt index d187c3af..b9e34558 100644 --- a/feature/creation/src/main/java/com/android/developers/androidify/creation/CreationViewModel.kt +++ b/feature/creation/src/main/java/com/android/developers/androidify/creation/CreationViewModel.kt @@ -179,6 +179,7 @@ class CreationViewModel @Inject constructor( else -> context.getString(R.string.error_image_generation_other) } } + is InsufficientInformationException -> context.getString(R.string.error_provide_more_descriptive_bot) is NoInternetException -> context.getString(R.string.error_connectivity) is ImageDescriptionFailedGenerationException -> context.getString(R.string.error_image_validation) @@ -216,14 +217,28 @@ class CreationViewModel @Inject constructor( ScreenState.LOADING -> { cancelInProgressTask() } + ScreenState.RESULT -> { _uiState.update { it.copy(screenState = ScreenState.EDIT, resultBitmap = null) } } + ScreenState.EDIT -> { // do nothing, back press handled outside } + + ScreenState.CUSTOMIZE -> { + _uiState.update { + it.copy(screenState = ScreenState.RESULT) + } + } + } + } + + fun customizeExportClicked() { + _uiState.update { + it.copy(screenState = ScreenState.CUSTOMIZE) } } } @@ -244,6 +259,7 @@ enum class ScreenState { EDIT, LOADING, RESULT, + CUSTOMIZE, } data class BotColor( diff --git a/feature/home/src/androidTest/java/com/android/developers/androidify/home/HomeScreenTest.kt b/feature/home/src/androidTest/java/com/android/developers/androidify/home/HomeScreenTest.kt index 43e6c7ba..450f2581 100644 --- a/feature/home/src/androidTest/java/com/android/developers/androidify/home/HomeScreenTest.kt +++ b/feature/home/src/androidTest/java/com/android/developers/androidify/home/HomeScreenTest.kt @@ -54,7 +54,7 @@ class HomeScreenTest { }, onAboutClicked = {}, // Provide a default or mock value, videoLink = "", - dancingBotLink = "" + dancingBotLink = "", ) } } @@ -78,7 +78,7 @@ class HomeScreenTest { onClickLetsGo = { }, onAboutClicked = {}, videoLink = "", - dancingBotLink = "" + dancingBotLink = "", ) } } diff --git a/feature/home/src/main/java/com/android/developers/androidify/home/AboutScreen.kt b/feature/home/src/main/java/com/android/developers/androidify/home/AboutScreen.kt index 06210250..6d26b6f5 100644 --- a/feature/home/src/main/java/com/android/developers/androidify/home/AboutScreen.kt +++ b/feature/home/src/main/java/com/android/developers/androidify/home/AboutScreen.kt @@ -86,7 +86,7 @@ private fun AboutPreviewLargeScreens() { fun AboutScreen( isMediumWindowSize: Boolean = isAtLeastMedium(), onBackPressed: () -> Unit, - onLicensesClicked: () -> Unit + onLicensesClicked: () -> Unit, ) { val sharedElementScope = LocalSharedTransitionScope.current val navScope = LocalNavAnimatedContentScope.current @@ -165,8 +165,10 @@ fun AboutScreen( ) } Spacer(Modifier.size(48.dp)) - FooterButtons(modifier = Modifier.padding(bottom = 8.dp), - onLicensesClicked) + FooterButtons( + modifier = Modifier.padding(bottom = 8.dp), + onLicensesClicked, + ) } } } else { @@ -208,13 +210,15 @@ fun AboutScreen( } @Composable -private fun FooterButtons(modifier: Modifier = Modifier, - onLicensesClicked: () -> Unit) { +private fun FooterButtons( + modifier: Modifier = Modifier, + onLicensesClicked: () -> Unit, +) { val uriHandler = LocalUriHandler.current FlowRow( modifier = modifier, horizontalArrangement = Arrangement.spacedBy(16.dp), - verticalArrangement = Arrangement.spacedBy(8.dp) + verticalArrangement = Arrangement.spacedBy(8.dp), ) { SecondaryOutlinedButton( onClick = { diff --git a/feature/home/src/main/java/com/android/developers/androidify/home/HomeScreen.kt b/feature/home/src/main/java/com/android/developers/androidify/home/HomeScreen.kt index 753504d0..5e6fc429 100644 --- a/feature/home/src/main/java/com/android/developers/androidify/home/HomeScreen.kt +++ b/feature/home/src/main/java/com/android/developers/androidify/home/HomeScreen.kt @@ -539,7 +539,7 @@ private fun VideoPlayer( } var videoFullyOnScreen by remember { mutableStateOf(false) } - val isWindowOccluded = LocalOcclusion.current + val isWindowOccluded = LocalOcclusion.current Box( Modifier .background(MaterialTheme.colorScheme.surfaceContainerLowest) diff --git a/feature/home/src/screenshotTest/java/com/android/developers/androidify/home/HomeScreenScreenshotTest.kt b/feature/home/src/screenshotTest/java/com/android/developers/androidify/home/HomeScreenScreenshotTest.kt index e6dfa8f7..5cdf0e62 100644 --- a/feature/home/src/screenshotTest/java/com/android/developers/androidify/home/HomeScreenScreenshotTest.kt +++ b/feature/home/src/screenshotTest/java/com/android/developers/androidify/home/HomeScreenScreenshotTest.kt @@ -35,7 +35,7 @@ class HomeScreenScreenshotTest { onClickLetsGo = { }, onAboutClicked = {}, videoLink = "", - dancingBotLink = "" + dancingBotLink = "", ) } } diff --git a/feature/results/build.gradle.kts b/feature/results/build.gradle.kts index 5c422f1a..cdf6d306 100644 --- a/feature/results/build.gradle.kts +++ b/feature/results/build.gradle.kts @@ -57,6 +57,7 @@ dependencies { implementation(libs.androidx.hilt.navigation.compose) implementation(libs.coil.compose) implementation(libs.accompanist.permissions) + implementation(libs.androidx.lifecycle.process) ksp(libs.hilt.compiler) implementation(libs.androidx.ui.tooling) diff --git a/feature/results/src/androidTest/java/com/android/developers/androidify/results/ResultsScreenTest.kt b/feature/results/src/androidTest/java/com/android/developers/androidify/results/ResultsScreenTest.kt index 6c9f8567..cad23c8c 100644 --- a/feature/results/src/androidTest/java/com/android/developers/androidify/results/ResultsScreenTest.kt +++ b/feature/results/src/androidTest/java/com/android/developers/androidify/results/ResultsScreenTest.kt @@ -30,7 +30,7 @@ import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick import androidx.compose.ui.unit.dp import androidx.test.ext.junit.runners.AndroidJUnit4 -import org.junit.Assert.assertTrue +import junit.framework.TestCase.assertTrue import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith @@ -46,7 +46,7 @@ class ResultsScreenTest { @Test fun resultsScreenContents_displaysActionButtons() { - val shareButtonText = composeTestRule.activity.getString(R.string.share_your_bot) + val shareButtonText = composeTestRule.activity.getString(R.string.customize_and_share) // Note: Download button is identified by icon, harder to test reliably without tags/desc val initialState = ResultState(resultImageBitmap = testBitmap, promptText = "test") @@ -58,8 +58,7 @@ class ResultsScreenTest { ResultsScreenContents( contentPadding = PaddingValues(0.dp), state = state, - downloadClicked = {}, - shareClicked = {}, + onCustomizeShareClicked = {}, ) } } @@ -88,8 +87,7 @@ class ResultsScreenTest { ResultsScreenContents( contentPadding = PaddingValues(0.dp), state = state, - downloadClicked = {}, - shareClicked = {}, + onCustomizeShareClicked = {}, ) } } @@ -119,8 +117,7 @@ class ResultsScreenTest { ResultsScreenContents( contentPadding = PaddingValues(0.dp), state = state, - downloadClicked = {}, - shareClicked = {}, + onCustomizeShareClicked = {}, ) } } @@ -155,8 +152,7 @@ class ResultsScreenTest { ResultsScreenContents( contentPadding = PaddingValues(0.dp), state = state, - downloadClicked = {}, - shareClicked = {}, + onCustomizeShareClicked = {}, ) } } @@ -188,8 +184,7 @@ class ResultsScreenTest { ResultsScreenContents( contentPadding = PaddingValues(0.dp), state = state, - downloadClicked = {}, - shareClicked = {}, + onCustomizeShareClicked = {}, ) } } @@ -210,8 +205,8 @@ class ResultsScreenTest { } @Test - fun actionButton_Share_invokesCallback() { - val shareButtonText = composeTestRule.activity.getString(R.string.share_your_bot) + fun actionButton_CustomizeExport_invokesCallback() { + val shareButtonText = composeTestRule.activity.getString(R.string.customize_and_share) var shareClicked = false // Ensure promptText is non-null when bitmap is present @@ -224,47 +219,15 @@ class ResultsScreenTest { ResultsScreenContents( contentPadding = PaddingValues(0.dp), state = state, - downloadClicked = {}, - shareClicked = { shareClicked = true }, // Callback to test + onCustomizeShareClicked = { + shareClicked = true // Callback to test + }, ) } } composeTestRule.onNodeWithText(shareButtonText).performClick() - assertTrue("shareClicked callback should have been invoked", shareClicked) - } - - @Test - fun actionButton_Download_invokesCallback() { - val downloadButtonDesc = composeTestRule.activity.getString(R.string.download_bot) // Use the new content description - var downloadClicked = false - - // Ensure promptText is non-null when bitmap is present - val initialState = ResultState(resultImageBitmap = testBitmap, promptText = "test") - val state = mutableStateOf(initialState) - - composeTestRule.setContent { - // Disable animation - CompositionLocalProvider(LocalInspectionMode provides true) { - ResultsScreenContents( - contentPadding = PaddingValues(0.dp), - state = state, - downloadClicked = { downloadClicked = true }, // Callback to test - shareClicked = {}, - ) - } - } - - // Click the download button - using a sibling finder relative to Share is complex. - // A more robust approach needs test tags. - // As a placeholder, we'll just assert the callback wasn't called initially. - // To make this test pass, manual interaction or a better finder is needed. - // Find the node by its content description and click it - // Note: We find the Icon, but click its parent (the Button) - composeTestRule.onNodeWithContentDescription(downloadButtonDesc).performClick() - - // Assert the callback was invoked - assertTrue("downloadClicked callback should have been invoked", downloadClicked) + assertTrue("onCustomizeShareClicked callback should have been invoked", shareClicked) } } diff --git a/feature/results/src/main/java/com/android/developers/androidify/customize/AspectRatioTool.kt b/feature/results/src/main/java/com/android/developers/androidify/customize/AspectRatioTool.kt new file mode 100644 index 00000000..150b65cc --- /dev/null +++ b/feature/results/src/main/java/com/android/developers/androidify/customize/AspectRatioTool.kt @@ -0,0 +1,95 @@ +/* + * 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.android.developers.androidify.customize + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.android.developers.androidify.theme.AndroidifyTheme + +@Composable +fun AspectRatioTool( + sizeOptions: List, + selectedOption: SizeOption, + onSizeOptionSelected: (SizeOption) -> Unit, + modifier: Modifier = Modifier, + singleLine: Boolean = true, +) { + GenericTool( + modifier = modifier.wrapContentSize(), + tools = sizeOptions, + singleLine = singleLine, + selectedOption = selectedOption, + onToolSelected = { + onSizeOptionSelected(it) + }, + individualToolContent = { tool -> + Box( + modifier = Modifier + .size(70.dp), + contentAlignment = Alignment.Center, + ) { + Box( + modifier = Modifier + .aspectRatio(tool.aspectRatio) + .border( + 2.dp, + color = MaterialTheme.colorScheme.primary, + shape = MaterialTheme.shapes.medium, + ) + .background( + MaterialTheme.colorScheme.background, + shape = MaterialTheme.shapes.medium, + ) + .padding(6.dp) + .fillMaxSize() + .clip(MaterialTheme.shapes.small) + .background(MaterialTheme.colorScheme.surfaceBright), + ) + } + }, + ) +} + +@Preview(showBackground = true) +@Composable +private fun AspectRatioToolPreview() { + AndroidifyTheme { + AspectRatioTool( + sizeOptions = listOf( + SizeOption.Square, + SizeOption.Banner, + SizeOption.SocialHeader, + SizeOption.Wallpaper, + SizeOption.WallpaperTablet, + ), + selectedOption = SizeOption.Square, + onSizeOptionSelected = {}, + ) + } +} diff --git a/feature/results/src/main/java/com/android/developers/androidify/customize/BackgroundTool.kt b/feature/results/src/main/java/com/android/developers/androidify/customize/BackgroundTool.kt new file mode 100644 index 00000000..ec9cb56b --- /dev/null +++ b/feature/results/src/main/java/com/android/developers/androidify/customize/BackgroundTool.kt @@ -0,0 +1,96 @@ +/* + * 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.android.developers.androidify.customize + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.material3.MaterialTheme +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.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import coil3.compose.rememberAsyncImagePainter +import com.android.developers.androidify.theme.AndroidifyTheme + +@Composable +fun BackgroundTool( + backgroundOptions: List, + selectedOption: BackgroundOption, + onBackgroundOptionSelected: (BackgroundOption) -> Unit, + modifier: Modifier = Modifier, + singleLine: Boolean = false, +) { + GenericTool( + modifier = modifier.wrapContentSize(), + tools = backgroundOptions, + singleLine = singleLine, + selectedOption = selectedOption, + onToolSelected = { + onBackgroundOptionSelected(it) + }, + individualToolContent = { tool -> + Box( + modifier = Modifier + .aspectRatio(1f) + .border( + 2.dp, + color = MaterialTheme.colorScheme.primary, + shape = MaterialTheme.shapes.medium, + ) + .background( + MaterialTheme.colorScheme.background, + shape = MaterialTheme.shapes.medium, + ) + .padding(6.dp), + ) { + if (tool.previewDrawableInt != null) { + Image( + rememberAsyncImagePainter(tool.previewDrawableInt), + contentDescription = null, // described below + contentScale = ContentScale.Crop, + modifier = Modifier + .aspectRatio(1f) + .clip(MaterialTheme.shapes.small), + ) + } + } + }, + ) +} + +@Preview(showBackground = true) +@Composable +private fun BackgroundToolPreview() { + AndroidifyTheme { + BackgroundTool( + backgroundOptions = listOf( + BackgroundOption.None, + BackgroundOption.Plain, + BackgroundOption.Lightspeed, + BackgroundOption.IO, + ), + selectedOption = BackgroundOption.Lightspeed, + onBackgroundOptionSelected = {}, + ) + } +} diff --git a/feature/results/src/main/java/com/android/developers/androidify/customize/ComposableBitmapRendererImpl.kt b/feature/results/src/main/java/com/android/developers/androidify/customize/ComposableBitmapRendererImpl.kt new file mode 100644 index 00000000..97674806 --- /dev/null +++ b/feature/results/src/main/java/com/android/developers/androidify/customize/ComposableBitmapRendererImpl.kt @@ -0,0 +1,226 @@ +/* + * 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.android.developers.androidify.customize + +import android.app.Application +import android.app.Presentation +import android.content.Context +import android.content.Context.DISPLAY_SERVICE +import android.graphics.Bitmap +import android.graphics.SurfaceTexture +import android.hardware.display.DisplayManager +import android.hardware.display.VirtualDisplay +import android.util.Log +import android.view.Display +import android.view.Surface +import android.view.ViewGroup +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.asAndroidBitmap +import androidx.compose.ui.graphics.layer.drawLayer +import androidx.compose.ui.graphics.rememberGraphicsLayer +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.dp +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleObserver +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.ProcessLifecycleOwner +import androidx.lifecycle.setViewTreeLifecycleOwner +import androidx.savedstate.SavedStateRegistry +import androidx.savedstate.SavedStateRegistryController +import androidx.savedstate.SavedStateRegistryOwner +import androidx.savedstate.setViewTreeSavedStateRegistryOwner +import kotlinx.coroutines.launch +import kotlinx.coroutines.suspendCancellableCoroutine +import javax.inject.Inject +import javax.inject.Singleton + +interface ComposableBitmapRenderer { + fun initialize() + + fun dispose() + + suspend fun renderComposableToBitmap(canvasSize: Size, composableContent: @Composable () -> Unit): Bitmap? +} + +/** + * Use a virtual display to capture composable content thats on a display. + * This is necessary because Compose doesn't yet support offscreen bitmap creation (https://issuetracker.google.com/298037598) + * + * Original source: https://gist.github.com/iamcalledrob/871568679ad58e64959b097d4ef30738 + * Adapted to use new GraphicsLayer commands (record and toBitmap()) + * Usage example: + * val offscreenBitmapManager = OffscreenBitmapManager(context) + * val bitmap = offscreenBitmapManager.renderComposableToBitmap { + * ImageResult() // etc + * } + */ +@Singleton +class ComposableBitmapRendererImpl @Inject constructor(private val application: Application) : ComposableBitmapRenderer { + private val texture = SurfaceTexture(false) + private val surface = Surface(texture) + private var virtualDisplay: VirtualDisplay? = null + + override fun initialize() { + virtualDisplay = + (application.getSystemService(DISPLAY_SERVICE) as DisplayManager).createVirtualDisplay( + "virtualDisplay", + 1, + 1, + 72, + surface, + DisplayManager.VIRTUAL_DISPLAY_FLAG_OWN_CONTENT_ONLY, + ) + } + + override fun dispose() { + virtualDisplay?.release() + surface.release() + texture.release() + } + + private suspend fun useVirtualDisplay(callback: suspend (display: Display) -> T): T? { + if (virtualDisplay == null) { + Log.e("OffscreenBitmapManager", "virtualDisplay is null") + initialize() + } + return callback(virtualDisplay!!.display) + } + + override suspend fun renderComposableToBitmap(canvasSize: Size, composableContent: @Composable () -> Unit): Bitmap? { + val bitmap = useVirtualDisplay { display -> + val outputDensity = Density(1f) + + val logicalHeightDp = canvasSize.height.dp + val logicalWidthDp = canvasSize.width.dp + + val captureDpSize = DpSize(width = logicalWidthDp, height = logicalHeightDp) + + captureComposable( + context = application, + size = captureDpSize, + density = outputDensity, + display = display, + ) { + LaunchedEffect(Unit) { + capture() + } + composableContent() + } + } + return bitmap + } + private data class CaptureComposableScope(val capture: () -> Unit) + + private fun Size.roundedToIntSize(): IntSize = + IntSize(width.toInt(), height.toInt()) + + private class EmptySavedStateRegistryOwner : SavedStateRegistryOwner { + private val controller = SavedStateRegistryController.create(this).apply { + performRestore(null) + } + + private val lifecycleOwner: LifecycleOwner = ProcessLifecycleOwner.get() + + override val lifecycle: Lifecycle + get() = + object : Lifecycle() { + @Suppress("UNNECESSARY_SAFE_CALL") + override fun addObserver(observer: LifecycleObserver) { + lifecycleOwner?.lifecycle?.addObserver(observer) + } + + @Suppress("UNNECESSARY_SAFE_CALL") + override fun removeObserver(observer: LifecycleObserver) { + lifecycleOwner?.lifecycle?.removeObserver(observer) + } + + override val currentState = State.INITIALIZED + } + + override val savedStateRegistry: SavedStateRegistry + get() = controller.savedStateRegistry + } + + /** Captures composable content, by default using a hidden window on the default display. + * + * Be sure to invoke capture() within the composable content (e.g. in a LaunchedEffect) to perform the capture. + * This gives some level of control over when the capture occurs, so it's possible to wait for async resources */ + private suspend fun captureComposable( + context: Context, + size: DpSize, + density: Density = Density(density = 1f), + display: Display = (context.getSystemService(DISPLAY_SERVICE) as DisplayManager) + .getDisplay(Display.DEFAULT_DISPLAY), + content: @Composable CaptureComposableScope.() -> Unit, + ): Bitmap { + val presentation = Presentation(context.applicationContext, display).apply { + window?.decorView?.let { view -> + view.setViewTreeLifecycleOwner(ProcessLifecycleOwner.get()) + view.setViewTreeSavedStateRegistryOwner(EmptySavedStateRegistryOwner()) + view.alpha = + 0f // If using default display, to ensure this does not appear on top of content. + } + } + + val composeView = ComposeView(context).apply { + val intSize = with(density) { size.toSize().roundedToIntSize() } + require(intSize.width > 0 && intSize.height > 0) { "pixel size must not have zero dimension" } + + layoutParams = ViewGroup.LayoutParams(intSize.width, intSize.height) + } + + presentation.setContentView(composeView, composeView.layoutParams) + presentation.show() + + val androidBitmap = suspendCancellableCoroutine { continuation -> + composeView.setContent { + val coroutineScope = rememberCoroutineScope() + val graphicsLayer = rememberGraphicsLayer() + Box( + modifier = Modifier + .size(size) + .drawWithContent { + graphicsLayer.record { + this@drawWithContent.drawContent() + } + drawLayer(graphicsLayer) + }, + ) { + CaptureComposableScope( + capture = { + coroutineScope.launch { + val composeImageBitmap = graphicsLayer.toImageBitmap() + continuation.resumeWith(Result.success(composeImageBitmap.asAndroidBitmap())) + } + }, + ).content() + } + } + } + presentation.dismiss() + return androidBitmap + } +} diff --git a/feature/results/src/main/java/com/android/developers/androidify/customize/CustomizeExportScreen.kt b/feature/results/src/main/java/com/android/developers/androidify/customize/CustomizeExportScreen.kt new file mode 100644 index 00000000..5f8483af --- /dev/null +++ b/feature/results/src/main/java/com/android/developers/androidify/customize/CustomizeExportScreen.kt @@ -0,0 +1,465 @@ +/* + * 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. + */ +@file:OptIn(ExperimentalPermissionsApi::class) + +package com.android.developers.androidify.customize + +import android.Manifest +import android.graphics.Bitmap +import android.net.Uri +import android.os.Build +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.ExperimentalSharedTransitionApi +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.requiredSizeIn +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Snackbar +import androidx.compose.material3.SnackbarDefaults +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.movableContentWithReceiverOf +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.compose.ui.draw.clip +import androidx.compose.ui.draw.dropShadow +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.asAndroidBitmap +import androidx.compose.ui.graphics.shadow.Shadow +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.layout.LookaheadScope +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.imageResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.navigation3.ui.LocalNavAnimatedContentScope +import com.android.developers.androidify.results.PermissionRationaleDialog +import com.android.developers.androidify.results.R +import com.android.developers.androidify.results.shareImage +import com.android.developers.androidify.theme.AndroidifyTheme +import com.android.developers.androidify.theme.LocalAnimateBoundsScope +import com.android.developers.androidify.theme.components.AndroidifyTopAppBar +import com.android.developers.androidify.theme.components.PrimaryButton +import com.android.developers.androidify.theme.components.SecondaryOutlinedButton +import com.android.developers.androidify.util.LargeScreensPreview +import com.android.developers.androidify.util.PhonePreview +import com.android.developers.androidify.util.allowsFullContent +import com.android.developers.androidify.util.isAtLeastMedium +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 com.android.developers.androidify.theme.R as ThemeR + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun CustomizeAndExportScreen( + resultImage: Bitmap, + originalImageUri: Uri?, + onBackPress: () -> Unit, + onInfoPress: () -> Unit, + isMediumWindowSize: Boolean = isAtLeastMedium(), + viewModel: CustomizeExportViewModel = hiltViewModel(), +) { + LaunchedEffect(resultImage, originalImageUri) { + viewModel.setArguments(resultImage, originalImageUri) + } + val state = viewModel.state.collectAsStateWithLifecycle() + val context = LocalContext.current + LaunchedEffect(state.value.savedUri) { + val savedImageUri = state.value.savedUri + if (savedImageUri != null) { + shareImage(context, savedImageUri) + viewModel.onSavedUriConsumed() + } + } + CustomizeExportContents( + state.value, + onBackPress, + onInfoPress, + onToolSelected = { tool -> + viewModel.changeSelectedTool(tool) + }, + onShareClicked = viewModel::shareClicked, + onDownloadClicked = viewModel::downloadClicked, + onSelectedToolStateChanged = viewModel::selectedToolStateChanged, + isMediumWindowSize = isMediumWindowSize, + snackbarHostState = viewModel.snackbarHostState.collectAsStateWithLifecycle().value, + ) +} + +@OptIn(ExperimentalSharedTransitionApi::class, ExperimentalMaterial3ExpressiveApi::class) +@Composable +private fun CustomizeExportContents( + state: CustomizeExportState, + onBackPress: () -> Unit, + onInfoPress: () -> Unit, + onShareClicked: () -> Unit, + onDownloadClicked: () -> Unit, + onToolSelected: (CustomizeTool) -> Unit, + onSelectedToolStateChanged: (ToolState) -> Unit, + isMediumWindowSize: Boolean, + snackbarHostState: SnackbarHostState, +) { + Scaffold( + snackbarHost = { + SnackbarHost( + hostState = snackbarHostState, + snackbar = { snackbarData -> + Snackbar(snackbarData, shape = SnackbarDefaults.shape) + }, + ) + }, + topBar = { + AndroidifyTopAppBar( + backEnabled = true, + titleText = stringResource(R.string.customize_and_export), + isMediumWindowSize = isMediumWindowSize, + onBackPressed = onBackPress, + onAboutClicked = onInfoPress, + ) + }, + containerColor = MaterialTheme.colorScheme.surface, + ) { paddingValues -> + val imageResult = remember { + movableContentWithReceiverOf { + ImageResult( + this, + modifier = Modifier + .padding(16.dp), + outerChromeModifier = Modifier + .dropShadow( + RoundedCornerShape(6), + shadow = Shadow( + radius = 26.dp, + spread = 10.dp, + color = MaterialTheme.colorScheme.inverseSurface.copy(alpha = 0.2f), + ), + ) + .clip(RoundedCornerShape(6)), + ) + } + } + val toolSelector = @Composable { modifier: Modifier, horizontal: Boolean -> + ToolSelector( + tools = state.tools, + selectedOption = state.selectedTool, + modifier = modifier, + horizontal = horizontal, + onToolSelected = { tool -> + onToolSelected(tool) + }, + ) + } + val toolDetail = @Composable { modifier: Modifier, singleLine: Boolean -> + SelectedToolDetail( + state, + onSelectedToolStateChanged = { toolState -> + onSelectedToolStateChanged(toolState) + }, + singleLine = singleLine, + modifier = modifier, + ) + } + val actionButtons = @Composable { modifier: Modifier -> + BotActionsButtonRow( + onShareClicked = { + onShareClicked() + }, + onDownloadClicked = { + onDownloadClicked() + }, + modifier = modifier, + ) + } + LookaheadScope { + CompositionLocalProvider(LocalAnimateBoundsScope provides this) { + if (isMediumWindowSize) { + Row( + Modifier + .fillMaxSize() + .padding(paddingValues), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Box(modifier = Modifier.weight(0.6f), + contentAlignment = Alignment.Center) { + imageResult( + state.exportImageCanvas, + ) + } + Column( + Modifier + .weight(0.4f) + .fillMaxHeight(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.SpaceBetween, + ) { + Row( + Modifier + .weight(1f) + .fillMaxSize(), + horizontalArrangement = Arrangement.SpaceAround, + verticalAlignment = Alignment.CenterVertically, + + ) { + Box(modifier = Modifier.weight(1f)) { + toolDetail(Modifier.align(Alignment.CenterEnd), false) + } + Spacer(modifier = Modifier.size(16.dp)) + toolSelector(Modifier.requiredSizeIn(minWidth = 56.dp), false) + Spacer(modifier = Modifier.size(16.dp)) + } + Spacer(modifier = Modifier.size(16.dp)) + actionButtons( + Modifier + .align(Alignment.End) + .padding(end = 16.dp), + ) + Spacer(modifier = Modifier.size(24.dp)) + } + } + } else { + Column( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.SpaceBetween, + ) { + Box( + modifier = Modifier + .weight(1f, fill = true), + contentAlignment = Alignment.Center, + ) { + imageResult( + state.exportImageCanvas, + ) + } + Spacer(modifier = Modifier.height(16.dp)) + toolSelector(Modifier, true) + Spacer(modifier = Modifier.height(16.dp)) + toolDetail(Modifier, true) + Spacer(modifier = Modifier.height(16.dp)) + actionButtons(Modifier) + Spacer(modifier = Modifier.height(24.dp)) + } + } + } + } + } +} + +@Composable +fun SelectedToolDetail( + state: CustomizeExportState, + singleLine: Boolean, + onSelectedToolStateChanged: (ToolState) -> Unit, + modifier: Modifier = Modifier, +) { + AnimatedContent( + state.selectedTool, + modifier = modifier + .wrapContentSize() + .padding(8.dp) + .background( + MaterialTheme.colorScheme.surfaceContainerLowest, + shape = MaterialTheme.shapes.medium, + ), + ) { targetState -> + val toolState = state.toolState[targetState] + when (targetState) { + CustomizeTool.Size -> { + val aspectRatioToolState = toolState as AspectRatioToolState + AspectRatioTool( + aspectRatioToolState.options, + aspectRatioToolState.selectedToolOption, + singleLine = singleLine, + onSizeOptionSelected = { + onSelectedToolStateChanged(aspectRatioToolState.copy(selectedToolOption = it)) + }, + ) + } + + CustomizeTool.Background -> { + val backgroundToolState = toolState as BackgroundToolState + BackgroundTool( + backgroundToolState.options, + backgroundToolState.selectedToolOption, + singleLine = singleLine, + onBackgroundOptionSelected = { + onSelectedToolStateChanged(backgroundToolState.copy(selectedToolOption = it)) + }, + ) + } + } + } +} + +@Composable +private fun BotActionsButtonRow( + onShareClicked: () -> Unit, + onDownloadClicked: () -> Unit, + modifier: Modifier = Modifier, + verboseLayout: Boolean = allowsFullContent(), +) { + Row(modifier.height(IntrinsicSize.Min)) { + PrimaryButton( + onClick = { + onShareClicked() + }, + leadingIcon = { + Row { + Icon( + ImageVector + .vectorResource(ThemeR.drawable.sharp_share_24), + contentDescription = null, // decorative element + ) + Spacer(modifier = Modifier.width(8.dp)) + } + }, + buttonText = if (verboseLayout) stringResource(R.string.share_your_bot) else null, + ) + Spacer(Modifier.width(8.dp)) + val externalStoragePermission = rememberPermissionState( + permission = Manifest.permission.WRITE_EXTERNAL_STORAGE, + ) + val mustGrantPermission = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + false + } else { + !externalStoragePermission.status.isGranted + } + var showRationaleDialog by remember { + mutableStateOf(false) + } + SecondaryOutlinedButton( + onClick = { + if (mustGrantPermission) { + if (externalStoragePermission.status.shouldShowRationale) { + showRationaleDialog = true + } else { + externalStoragePermission.launchPermissionRequest() + } + externalStoragePermission.launchPermissionRequest() + } else { + onDownloadClicked() + } + }, + leadingIcon = { + Icon( + ImageVector + .vectorResource(R.drawable.rounded_download_24), + contentDescription = stringResource(R.string.download_bot), + ) + }, + modifier = Modifier.fillMaxHeight(), + ) + PermissionRationaleDialog( + showRationaleDialog, + onDismiss = { + showRationaleDialog = false + }, + externalStoragePermission, + ) + } +} + +@Preview(showBackground = true) +@PhonePreview +@Composable +fun CustomizeExportPreview() { + AndroidifyTheme { + AnimatedContent(true) { targetState -> + targetState + CompositionLocalProvider(LocalNavAnimatedContentScope provides this@AnimatedContent) { + val bitmap = ImageBitmap.imageResource(R.drawable.placeholderbot) + val state = CustomizeExportState( + exportImageCanvas = ExportImageCanvas(imageBitmap = bitmap.asAndroidBitmap()), + ) + CustomizeExportContents( + state = state, + onDownloadClicked = {}, + onShareClicked = {}, + onBackPress = {}, + onInfoPress = {}, + onToolSelected = {}, + snackbarHostState = SnackbarHostState(), + isMediumWindowSize = false, + onSelectedToolStateChanged = {}, + ) + } + } + } +} + +@LargeScreensPreview +@Composable +fun CustomizeExportPreviewLarge() { + AndroidifyTheme { + AnimatedContent(true) { targetState -> + targetState + CompositionLocalProvider(LocalNavAnimatedContentScope provides this@AnimatedContent) { + val bitmap = ImageBitmap.imageResource(R.drawable.placeholderbot) + val state = CustomizeExportState( + exportImageCanvas = ExportImageCanvas( + imageBitmap = bitmap.asAndroidBitmap(), + aspectRatioOption = SizeOption.Square, + ), + selectedTool = CustomizeTool.Background, + ) + CustomizeExportContents( + state = state, + onDownloadClicked = {}, + onShareClicked = {}, + onBackPress = {}, + onInfoPress = {}, + onToolSelected = {}, + snackbarHostState = SnackbarHostState(), + isMediumWindowSize = true, + onSelectedToolStateChanged = {}, + ) + } + } + } +} diff --git a/feature/results/src/main/java/com/android/developers/androidify/customize/CustomizeExportViewModel.kt b/feature/results/src/main/java/com/android/developers/androidify/customize/CustomizeExportViewModel.kt new file mode 100644 index 00000000..1907e18e --- /dev/null +++ b/feature/results/src/main/java/com/android/developers/androidify/customize/CustomizeExportViewModel.kt @@ -0,0 +1,146 @@ +/* + * 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.android.developers.androidify.customize + +import android.app.Application +import android.graphics.Bitmap +import android.net.Uri +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.SnackbarHostState +import androidx.compose.ui.Modifier +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.viewModelScope +import com.android.developers.androidify.data.ImageGenerationRepository +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class CustomizeExportViewModel @Inject constructor( + val imageGenerationRepository: ImageGenerationRepository, + val composableBitmapRenderer: ComposableBitmapRenderer, + application: Application, +) : AndroidViewModel(application) { + + private val _state = MutableStateFlow(CustomizeExportState()) + val state = _state.asStateFlow() + + private var _snackbarHostState = MutableStateFlow(SnackbarHostState()) + + val snackbarHostState: StateFlow + get() = _snackbarHostState + + override fun onCleared() { + super.onCleared() + composableBitmapRenderer.dispose() + } + fun setArguments( + resultImageUrl: Bitmap, + originalImageUrl: Uri?, + ) { + _state.update { + CustomizeExportState( + originalImageUrl, + exportImageCanvas = it.exportImageCanvas.copy(imageBitmap = resultImageUrl), + ) + } + } + + fun shareClicked() { + viewModelScope.launch { + val exportImageCanvas = state.value.exportImageCanvas + val resultBitmap = composableBitmapRenderer.renderComposableToBitmap(exportImageCanvas.canvasSize) { + ImageResult( + exportImageCanvas = exportImageCanvas, + modifier = Modifier.fillMaxSize(), + ) + } + if (resultBitmap != null) { + val imageFileUri = imageGenerationRepository.saveImage(resultBitmap) + + _state.update { + it.copy(savedUri = imageFileUri) + } + } + } + } + fun onSavedUriConsumed() { + _state.update { + it.copy(savedUri = null) + } + } + fun selectedToolStateChanged(toolState: ToolState) { + _state.update { + it.copy( + toolState = it.toolState + (it.selectedTool to toolState), + exportImageCanvas = + when (toolState.selectedToolOption) { + is BackgroundOption -> { + val backgroundOption = toolState.selectedToolOption as BackgroundOption + it.exportImageCanvas.updateAspectRatioAndBackground( + backgroundOption, + it.exportImageCanvas.aspectRatioOption, + ) + } + is SizeOption -> { + it.exportImageCanvas.updateAspectRatioAndBackground( + it.exportImageCanvas.selectedBackgroundOption, + (toolState.selectedToolOption as SizeOption), + ) + } + else -> throw IllegalArgumentException("Unknown tool option") + }, + ) + } + } + + fun downloadClicked() { + viewModelScope.launch { + val exportImageCanvas = state.value.exportImageCanvas + val resultBitmap = composableBitmapRenderer.renderComposableToBitmap(exportImageCanvas.canvasSize) { + ImageResult( + exportImageCanvas = exportImageCanvas, + modifier = Modifier.fillMaxSize(), + ) + } + val originalImage = state.value.originalImageUrl + if (originalImage != null) { + val savedOriginalUri = + imageGenerationRepository.saveImageToExternalStorage(originalImage) + _state.update { + it.copy(externalOriginalSavedUri = savedOriginalUri) + } + } + if (resultBitmap != null) { + val imageUri = imageGenerationRepository.saveImageToExternalStorage(resultBitmap) + _state.update { + it.copy(externalSavedUri = imageUri) + } + snackbarHostState.value.showSnackbar("Download complete") + } + } + } + + fun changeSelectedTool(tool: CustomizeTool) { + _state.update { + it.copy(selectedTool = tool) + } + } +} diff --git a/feature/results/src/main/java/com/android/developers/androidify/customize/CustomizeState.kt b/feature/results/src/main/java/com/android/developers/androidify/customize/CustomizeState.kt new file mode 100644 index 00000000..d9963976 --- /dev/null +++ b/feature/results/src/main/java/com/android/developers/androidify/customize/CustomizeState.kt @@ -0,0 +1,185 @@ +/* + * 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.android.developers.androidify.customize + +import android.R.attr.rotation +import android.graphics.Bitmap +import android.net.Uri +import androidx.annotation.DrawableRes +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.R +import androidx.compose.ui.geometry.Size + +data class CustomizeExportState( + val originalImageUrl: Uri? = null, + val savedUri: Uri? = null, + val externalSavedUri: Uri? = null, + val externalOriginalSavedUri: Uri? = null, + val selectedTool: CustomizeTool = CustomizeTool.Size, + val tools: List = CustomizeTool.entries, + val toolState: Map = mapOf( + CustomizeTool.Size to AspectRatioToolState(), + CustomizeTool.Background to BackgroundToolState(), + ), + val exportImageCanvas: ExportImageCanvas = ExportImageCanvas(), + +) + +interface ToolState { + val selectedToolOption: ToolOption + val options: List +} + +data class AspectRatioToolState( + override val selectedToolOption: SizeOption = SizeOption.Square, + override val options: List = listOf( + SizeOption.Square, + SizeOption.Wallpaper, + SizeOption.WallpaperTablet, + SizeOption.Banner, + SizeOption.SocialHeader, + ), +) : ToolState + +data class BackgroundToolState( + override val selectedToolOption: BackgroundOption = BackgroundOption.IO, + override val options: List = listOf( + BackgroundOption.None, + BackgroundOption.Plain, + BackgroundOption.Lightspeed, + BackgroundOption.IO, + ), +) : ToolState + +data class ExportImageCanvas( + val imageBitmap: Bitmap? = null, + val aspectRatioOption: SizeOption = SizeOption.Square, + val canvasSize: Size = Size(1000f, 1000f), + val mainImageUri: Uri? = null, + val imageSize: Size = Size(600f, 600f), + val imageOffset: Offset = Offset(canvasSize.width * 0.2f, canvasSize.height * 0.16f), + val imageRotation: Float = 0f, + val imageOriginalBitmapSize: Size? = Size(1024f, 1024f), + val selectedBackgroundOption: BackgroundOption = BackgroundOption.IO, + @param:DrawableRes + val selectedBackgroundDrawable: Int? = com.android.developers.androidify.results.R.drawable.background_square_blocks, + val includeWatermark: Boolean = true, +) { + fun updateAspectRatioAndBackground( + backgroundOption: BackgroundOption, + sizeOption: SizeOption, + ): ExportImageCanvas { + val newCanvasSize = sizeOption.dimensions + var imageSize: Size + + var offset = Offset.Zero + var image: Int? + var rotation: Float + when (sizeOption) { + SizeOption.Square -> { + offset = Offset(newCanvasSize.width * 0.2f, newCanvasSize.height * 0.16f) + imageSize = Size(newCanvasSize.width * 0.6f, newCanvasSize.width * 0.6f) + rotation = 0f + image = when (backgroundOption) { + BackgroundOption.IO -> com.android.developers.androidify.results.R.drawable.background_square_blocks + BackgroundOption.Lightspeed -> com.android.developers.androidify.results.R.drawable.background_square_lightspeed + BackgroundOption.None -> { + offset = Offset(0f, 0f) + imageSize = Size(newCanvasSize.width, newCanvasSize.height) + null + } + BackgroundOption.Plain -> com.android.developers.androidify.results.R.drawable.background_square_none + } + } + SizeOption.Banner -> { + offset = Offset(newCanvasSize.width * 0.51f, newCanvasSize.height * -0.03f) + imageSize = Size(newCanvasSize.width * 0.26f, newCanvasSize.width * 0.26f) + rotation = -11f + image = when (backgroundOption) { + BackgroundOption.IO -> com.android.developers.androidify.results.R.drawable.background_banner_square + BackgroundOption.Lightspeed -> com.android.developers.androidify.results.R.drawable.background_banner_lightspeed + BackgroundOption.None -> { + offset = Offset(0f, 0f) + imageSize = Size(newCanvasSize.width, newCanvasSize.height) + + rotation = 0f + null + } + BackgroundOption.Plain -> com.android.developers.androidify.results.R.drawable.background_banner_plain + } + } + SizeOption.SocialHeader -> { + offset = Offset(newCanvasSize.width * 0.49f, newCanvasSize.height * 0.01f) + imageSize = Size(newCanvasSize.width * 0.26f, newCanvasSize.width * 0.3f) + rotation = -9f + image = when (backgroundOption) { + BackgroundOption.IO -> com.android.developers.androidify.results.R.drawable.background_social_header_shape + BackgroundOption.Lightspeed -> com.android.developers.androidify.results.R.drawable.background_social_header_lightspeed + BackgroundOption.None -> { + offset = Offset(0f, 0f) + imageSize = Size(newCanvasSize.width, newCanvasSize.height) + rotation = 0f + null + } + BackgroundOption.Plain -> com.android.developers.androidify.results.R.drawable.background_social_header_plain + } + } + + SizeOption.Wallpaper -> { + offset = Offset(newCanvasSize.width * -0.02f, newCanvasSize.height * 0.1f) + imageSize = Size(newCanvasSize.width * 1.1f, newCanvasSize.width * 1.3f) + rotation = -9f + image = when (backgroundOption) { + BackgroundOption.IO -> com.android.developers.androidify.results.R.drawable.background_wallpaper_shapes + BackgroundOption.Lightspeed -> com.android.developers.androidify.results.R.drawable.background_wallpaper_lightspeed + BackgroundOption.None -> { + offset = Offset(0f, 0f) + imageSize = Size(newCanvasSize.width, newCanvasSize.height) + rotation = 0f + null + } + BackgroundOption.Plain -> com.android.developers.androidify.results.R.drawable.background_wallpaper_plain + } + } + + SizeOption.WallpaperTablet -> { + offset = Offset(newCanvasSize.width * 0.24f, newCanvasSize.height * 0.06f) + imageSize = Size(newCanvasSize.width * 0.52f, newCanvasSize.width * 0.52f) + rotation = -10f + image = when (backgroundOption) { + BackgroundOption.IO -> com.android.developers.androidify.results.R.drawable.background_wallpaper_tablet_shapes + BackgroundOption.Lightspeed -> com.android.developers.androidify.results.R.drawable.background_wallpaper_tablet_lightspeed + BackgroundOption.None -> { + offset = Offset(0f, 0f) + imageSize = Size(newCanvasSize.width, newCanvasSize.height) + rotation = 0f + null + } + BackgroundOption.Plain -> com.android.developers.androidify.results.R.drawable.background_wallpaper_tablet_light + } + } + } + return copy( + selectedBackgroundDrawable = image, + imageRotation = rotation, + imageSize = imageSize, + imageOffset = offset, + canvasSize = newCanvasSize, + aspectRatioOption = sizeOption, + selectedBackgroundOption = backgroundOption, + ) + } +} diff --git a/feature/results/src/main/java/com/android/developers/androidify/customize/CustomizeTool.kt b/feature/results/src/main/java/com/android/developers/androidify/customize/CustomizeTool.kt new file mode 100644 index 00000000..d749c78c --- /dev/null +++ b/feature/results/src/main/java/com/android/developers/androidify/customize/CustomizeTool.kt @@ -0,0 +1,64 @@ +/* + * 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.android.developers.androidify.customize + +import androidx.compose.ui.geometry.Size +import com.android.developers.androidify.results.R + +enum class CustomizeTool(val icon: Int, val displayName: String) { + Size(R.drawable.size_tool_icon, "Size tool"), + Background(R.drawable.outline_background_replace_24, "Background tool"), +} + +interface ToolOption { + val displayName: String + val key: String +} + +sealed class SizeOption( + val aspectRatio: Float, + val dimensions: Size, + override val displayName: String, + override val key: String, +) : ToolOption { + + object Square : SizeOption(1f, Size(1000f, 1000f), "1:1", "square") + object Banner : SizeOption(4f, Size(4000f, 1000f), "Banner", "banner") + object Wallpaper : SizeOption(9 / 16f, Size(900f, 1600f), "Wallpaper", "wallpaper") + object SocialHeader : SizeOption(3f, Size(3000f, 1000f), "3:1", "social_header") + object WallpaperTablet : SizeOption(1280 / 800f, Size(1280f, 800f), "Large wallpaper", "wallpaper_large") +} + +sealed class BackgroundOption( + override val displayName: String, + override val key: String, + val previewDrawableInt: Int?, +) : ToolOption { + object None : BackgroundOption("None", "None", null) + object Plain : BackgroundOption("Plain", "Plain", null) + object Lightspeed : BackgroundOption( + "Lightspeed", + "Lightspeed", + R.drawable.light_speed_dots, + ) + object IO : BackgroundOption( + "I/O", + "IO", + R.drawable.background_option_io, + ) + // todo add Create with AI background option + /* object Create : BackgroundOption("Create", "Create", R.drawable.background_create)*/ +} diff --git a/feature/results/src/main/java/com/android/developers/androidify/customize/GenericTool.kt b/feature/results/src/main/java/com/android/developers/androidify/customize/GenericTool.kt new file mode 100644 index 00000000..9ca91b93 --- /dev/null +++ b/feature/results/src/main/java/com/android/developers/androidify/customize/GenericTool.kt @@ -0,0 +1,191 @@ +/* + * 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.android.developers.androidify.customize + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import coil3.compose.rememberAsyncImagePainter +import com.android.developers.androidify.theme.AndroidifyTheme + +@Composable +fun GenericTool( + tools: List, + selectedOption: T, + onToolSelected: (T) -> Unit, + individualToolContent: @Composable (T) -> Unit, + modifier: Modifier = Modifier, + singleLine: Boolean = true, +) { + val scrollModifier = if (singleLine) Modifier.horizontalScroll(rememberScrollState()) else Modifier + FlowRow( + modifier = modifier.then(scrollModifier), + maxLines = if (singleLine) 1 else Int.MAX_VALUE, + ) { + tools.forEach { tool -> + GenericToolButton( + isSelected = tool == selectedOption, + toolContent = { + individualToolContent(tool) + }, + onToolSelected = onToolSelected, + tool = tool + ) + } + } +} + +@Composable +fun GenericToolButton( + tool: T, + toolContent: @Composable () -> Unit, + isSelected: Boolean, + onToolSelected: (T) -> Unit, + modifier: Modifier = Modifier, +) { + Surface( + modifier.height(128.dp) + .padding(8.dp) + .clip(MaterialTheme.shapes.medium) + .clickable { + onToolSelected(tool) + }, + color = MaterialTheme.colorScheme.surfaceContainerLowest, + ) { + val backgroundModifier = if (isSelected) { + Modifier.background( + MaterialTheme.colorScheme.surfaceBright, + MaterialTheme.shapes.medium, + ) + } else { + Modifier + } + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = backgroundModifier.padding(8.dp), + ) { + Box( + modifier = Modifier + .weight(1f), + ) { + toolContent() + } + Spacer(modifier = Modifier.height(4.dp)) + Text( + tool.displayName, + textAlign = TextAlign.Center, + style = MaterialTheme.typography.bodyMedium, + ) + } + } +} + +@Preview(showBackground = true) +@Composable +private fun GenericToolPreview() { + AndroidifyTheme { + GenericTool( + tools = listOf( + BackgroundOption.None, + BackgroundOption.Lightspeed, + BackgroundOption.IO, + ), + singleLine = false, + selectedOption = BackgroundOption.Lightspeed, + onToolSelected = { + }, + individualToolContent = { tool -> + Box( + modifier = Modifier + .aspectRatio(1f) + .border( + 2.dp, + color = MaterialTheme.colorScheme.primary, + shape = MaterialTheme.shapes.medium, + ) + .padding(6.dp), + ) { + Image( + rememberAsyncImagePainter(tool.previewDrawableInt), + contentDescription = null, // described below + contentScale = ContentScale.Crop, + modifier = Modifier.aspectRatio(1f) + .clip(MaterialTheme.shapes.small), + ) + } + }, + ) + } +} + +@Preview(showBackground = true) +@Composable +private fun GenericToolPreviewSingleLine() { + AndroidifyTheme { + GenericTool( + tools = listOf( + BackgroundOption.None, + BackgroundOption.Lightspeed, + BackgroundOption.IO, + ), + selectedOption = BackgroundOption.Lightspeed, + singleLine = true, + onToolSelected = { + }, + individualToolContent = { tool -> + Box( + modifier = Modifier + .aspectRatio(1f) + .border( + 2.dp, + color = MaterialTheme.colorScheme.primary, + shape = MaterialTheme.shapes.medium, + ) + .padding(6.dp), + ) { + Image( + rememberAsyncImagePainter(tool.previewDrawableInt), + contentDescription = null, // described below + contentScale = ContentScale.Crop, + modifier = Modifier.aspectRatio(1f) + .clip(MaterialTheme.shapes.small), + ) + } + }, + ) + } +} diff --git a/feature/results/src/main/java/com/android/developers/androidify/customize/ImageRenderer.kt b/feature/results/src/main/java/com/android/developers/androidify/customize/ImageRenderer.kt new file mode 100644 index 00000000..05e7f926 --- /dev/null +++ b/feature/results/src/main/java/com/android/developers/androidify/customize/ImageRenderer.kt @@ -0,0 +1,317 @@ +/* + * 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. + */ +@file:OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalSharedTransitionApi::class) + +package com.android.developers.androidify.customize + +import androidx.compose.animation.ExperimentalSharedTransitionApi +import androidx.compose.animation.animateBounds +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.clipToBounds +import androidx.compose.ui.draw.rotate +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.asAndroidBitmap +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.layout.layout +import androidx.compose.ui.res.imageResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.util.fastRoundToInt +import com.android.developers.androidify.results.R +import com.android.developers.androidify.theme.AndroidifyTheme +import com.android.developers.androidify.theme.LocalAnimateBoundsScope +@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalSharedTransitionApi::class) +@Composable +fun ImageResult( + exportImageCanvas: ExportImageCanvas, + modifier: Modifier = Modifier, + outerChromeModifier: Modifier = Modifier, +) { + Box( + modifier = modifier, + contentAlignment = Alignment.Center, + ) { + Box( + modifier = Modifier + .aspectRatio( + exportImageCanvas.aspectRatioOption.aspectRatio, + matchHeightConstraintsFirst = true, + ) + .then(Modifier.safeAnimateBounds()) + .then(outerChromeModifier) + .clipToBounds(), + ) { + BackgroundLayout( + exportImageCanvas, + modifier = Modifier.fillMaxSize(), + ) { + if (exportImageCanvas.imageBitmap != null) { + Image( + bitmap = exportImageCanvas.imageBitmap.asImageBitmap(), + modifier = Modifier + .fillMaxSize(), + contentScale = ContentScale.Crop, + contentDescription = null, + ) + } + } + } + } +} + +@Composable +fun BackgroundLayout( + exportImageCanvas: ExportImageCanvas, + modifier: Modifier = Modifier, + content: @Composable () -> Unit, +) { + Box( + modifier = modifier.fillMaxSize() + .background(Color.White), + ) { + if (exportImageCanvas.selectedBackgroundDrawable != null) { + Image( + bitmap = ImageBitmap.imageResource(id = exportImageCanvas.selectedBackgroundDrawable), + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Crop, + contentDescription = null, + ) + } + + val rotationAnimation by animateFloatAsState( + targetValue = exportImageCanvas.imageRotation, + label = "rotation", + animationSpec = MaterialTheme.motionScheme.slowEffectsSpec(), + ) + val safeAnimateBounds = Modifier.safeAnimateBounds() + Box( + modifier = Modifier + .fillMaxSize() + .layout { measurable, constraints -> + val offsetValue = exportImageCanvas.imageOffset + val imageSizeValue = exportImageCanvas.imageSize + val exportCanvasSizeAnimation = exportImageCanvas.canvasSize + + val actualWidth = constraints.maxWidth + val actualHeight = constraints.maxHeight + + val scale = if (exportCanvasSizeAnimation.width > 0f) { + actualWidth / exportCanvasSizeAnimation.width + } else { + 1f + } + + val scaledImageWidth = imageSizeValue.width * scale + val scaledImageHeight = imageSizeValue.height * scale + val scaledOffsetX = offsetValue.x * scale + val scaledOffsetY = offsetValue.y * scale + + val placeable = measurable.measure( + constraints.copy( + minWidth = scaledImageWidth.fastRoundToInt(), + maxWidth = scaledImageWidth.fastRoundToInt(), + minHeight = scaledImageHeight.fastRoundToInt(), + maxHeight = scaledImageHeight.fastRoundToInt(), + ), + ) + layout(actualWidth, actualHeight) { + placeable.placeRelative(scaledOffsetX.fastRoundToInt(), scaledOffsetY.fastRoundToInt()) + } + } + .then(safeAnimateBounds) + .rotate(rotationAnimation), + ) { + val clip = if (exportImageCanvas.selectedBackgroundOption == BackgroundOption.None) { + Modifier + } else { + Modifier.clip(RoundedCornerShape(6)) + } + Box( + modifier = Modifier + .fillMaxSize() + .then(clip), + contentAlignment = Alignment.Center, + ) { + content() + } + } + } +} + +@Composable +private fun Modifier.safeAnimateBounds(): Modifier { + val spec = MaterialTheme.motionScheme.slowEffectsSpec() + return if (LocalAnimateBoundsScope.current != null) { + this.animateBounds( + LocalAnimateBoundsScope.current!!, + boundsTransform = { _, _ -> + spec + }, + ) + } else { + this + } +} + +@Preview +@Composable +private fun ImageRendererPreviewSquare() { + val bitmap = ImageBitmap.imageResource(R.drawable.placeholderbot) + + AndroidifyTheme { + ImageResult( + ExportImageCanvas( + imageBitmap = bitmap.asAndroidBitmap(), + canvasSize = Size(1000f, 1000f), + aspectRatioOption = SizeOption.Square, + selectedBackgroundOption = BackgroundOption.IO, + ) + .updateAspectRatioAndBackground( + backgroundOption = BackgroundOption.IO, + sizeOption = SizeOption.Square, + ), + modifier = Modifier + .fillMaxSize(), + ) + } +} + +@Preview +@Composable +private fun ImageRendererPreviewBanner() { + val bitmap = ImageBitmap.imageResource(R.drawable.placeholderbot) + AndroidifyTheme { + ImageResult( + ExportImageCanvas( + imageBitmap = bitmap.asAndroidBitmap(), + canvasSize = Size(1000f, 1000f), + aspectRatioOption = SizeOption.Banner, + selectedBackgroundOption = BackgroundOption.Lightspeed, + ).updateAspectRatioAndBackground( + backgroundOption = BackgroundOption.Lightspeed, + sizeOption = SizeOption.Banner, + ), + modifier = Modifier + .fillMaxSize() + .aspectRatio(SizeOption.Banner.aspectRatio), + ) + } +} + +@Preview +@Composable +private fun ImageRendererPreviewWallpaper() { + val bitmap = ImageBitmap.imageResource(R.drawable.placeholderbot) + AndroidifyTheme { + ImageResult( + ExportImageCanvas( + imageBitmap = bitmap.asAndroidBitmap(), + canvasSize = Size(1000f, 1000f), + aspectRatioOption = SizeOption.Wallpaper, + selectedBackgroundOption = BackgroundOption.Lightspeed, + ).updateAspectRatioAndBackground( + backgroundOption = BackgroundOption.Lightspeed, + sizeOption = SizeOption.Wallpaper, + ), + modifier = Modifier + .fillMaxSize() + .aspectRatio(SizeOption.Wallpaper.aspectRatio), + ) + } +} + +@Preview(widthDp = 1280, heightDp = 800) +@Composable +private fun ImageRendererPreviewWallpaperTablet() { + val bitmap = ImageBitmap.imageResource(R.drawable.placeholderbot) + AndroidifyTheme { + ImageResult( + ExportImageCanvas( + imageBitmap = bitmap.asAndroidBitmap(), + canvasSize = Size(1280f, 800f), + aspectRatioOption = SizeOption.WallpaperTablet, + selectedBackgroundOption = BackgroundOption.Lightspeed, + ).updateAspectRatioAndBackground( + backgroundOption = BackgroundOption.Lightspeed, + sizeOption = SizeOption.WallpaperTablet, + ), + modifier = Modifier + .fillMaxSize() + .aspectRatio(SizeOption.WallpaperTablet.aspectRatio), + ) + } +} + +@Preview +@Composable +private fun ImageRendererPreviewWallpaperSocial() { + val bitmap = ImageBitmap.imageResource(R.drawable.placeholderbot) + AndroidifyTheme { + ImageResult( + ExportImageCanvas( + imageBitmap = bitmap.asAndroidBitmap(), + canvasSize = Size(1600f, 900f), + aspectRatioOption = SizeOption.SocialHeader, + selectedBackgroundOption = BackgroundOption.Lightspeed, + ).updateAspectRatioAndBackground( + backgroundOption = BackgroundOption.Lightspeed, + sizeOption = SizeOption.SocialHeader, + ), + modifier = Modifier + .fillMaxSize() + .aspectRatio(SizeOption.SocialHeader.aspectRatio), + ) + } +} + +@Preview +@Composable +fun ImageRendererPreviewWallpaperIO() { + val bitmap = ImageBitmap.imageResource(R.drawable.placeholderbot) + AndroidifyTheme { + ImageResult( + ExportImageCanvas( + imageBitmap = bitmap.asAndroidBitmap(), + canvasSize = Size(1600f, 900f), + aspectRatioOption = SizeOption.SocialHeader, + selectedBackgroundOption = BackgroundOption.IO, + + ).updateAspectRatioAndBackground( + backgroundOption = BackgroundOption.IO, + sizeOption = SizeOption.SocialHeader, + ), + modifier = Modifier + .fillMaxSize() + .aspectRatio(SizeOption.SocialHeader.aspectRatio), + ) + } +} diff --git a/feature/results/src/main/java/com/android/developers/androidify/customize/ToolSelector.kt b/feature/results/src/main/java/com/android/developers/androidify/customize/ToolSelector.kt new file mode 100644 index 00000000..a29aaf70 --- /dev/null +++ b/feature/results/src/main/java/com/android/developers/androidify/customize/ToolSelector.kt @@ -0,0 +1,145 @@ +/* + * 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.android.developers.androidify.customize + +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.FloatingToolbarColors +import androidx.compose.material3.HorizontalFloatingToolbar +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ToggleButton +import androidx.compose.material3.ToggleButtonDefaults +import androidx.compose.material3.VerticalFloatingToolbar +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.android.developers.androidify.theme.AndroidifyTheme + +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Composable +fun ToolSelector( + tools: List, + selectedOption: CustomizeTool, + onToolSelected: (CustomizeTool) -> Unit, + horizontal: Boolean, + modifier: Modifier = Modifier, +) { + if (horizontal) { + HorizontalFloatingToolbar( + modifier = modifier.border( + 2.dp, + color = MaterialTheme.colorScheme.outline, + shape = MaterialTheme.shapes.large, + ).padding(2.dp), + colors = FloatingToolbarColors( + toolbarContainerColor = MaterialTheme.colorScheme.surface, + toolbarContentColor = MaterialTheme.colorScheme.onSurfaceVariant, + fabContainerColor = MaterialTheme.colorScheme.tertiary, + fabContentColor = MaterialTheme.colorScheme.onTertiary, + ), + expanded = true, + ) { + tools.forEachIndexed { index, tool -> + ToggleButton( + modifier = Modifier, + checked = selectedOption == tool, + onCheckedChange = { onToolSelected(tool) }, + shapes = ToggleButtonDefaults.shapes(checkedShape = MaterialTheme.shapes.large), + colors = ToggleButtonDefaults.toggleButtonColors( + checkedContainerColor = MaterialTheme.colorScheme.onSurface, + containerColor = MaterialTheme.colorScheme.surface, + ), + ) { + Icon( + painterResource(tool.icon), + contentDescription = tool.displayName, + ) + } + if (index != tools.size - 1) { + Spacer(Modifier.width(8.dp)) + } + } + } + } else { + VerticalFloatingToolbar( + modifier = modifier.border( + 2.dp, + color = MaterialTheme.colorScheme.outline, + shape = MaterialTheme.shapes.large, + ).padding(2.dp), + colors = FloatingToolbarColors( + toolbarContainerColor = MaterialTheme.colorScheme.surface, + toolbarContentColor = MaterialTheme.colorScheme.onSurfaceVariant, + fabContainerColor = MaterialTheme.colorScheme.tertiary, + fabContentColor = MaterialTheme.colorScheme.onTertiary, + ), + expanded = true, + ) { + tools.forEachIndexed { index, tool -> + ToggleButton( + modifier = Modifier, + checked = selectedOption == tool, + onCheckedChange = { onToolSelected(tool) }, + shapes = ToggleButtonDefaults.shapes(checkedShape = MaterialTheme.shapes.large), + colors = ToggleButtonDefaults.toggleButtonColors( + checkedContainerColor = MaterialTheme.colorScheme.onSurface, + containerColor = MaterialTheme.colorScheme.surface, + ), + ) { + Icon( + painterResource(tool.icon), + contentDescription = tool.displayName, + ) + } + if (index != tools.size - 1) { + Spacer(Modifier.width(8.dp)) + } + } + } + } +} + +@Preview +@Composable +private fun ToolsPreviewHorizontal() { + AndroidifyTheme { + ToolSelector( + tools = listOf(CustomizeTool.Size, CustomizeTool.Background), + selectedOption = CustomizeTool.Size, + horizontal = true, + onToolSelected = {}, + ) + } +} + +@Preview +@Composable +private fun ToolsPreviewVertical() { + AndroidifyTheme { + ToolSelector( + tools = listOf(CustomizeTool.Size, CustomizeTool.Background), + selectedOption = CustomizeTool.Size, + horizontal = false, + onToolSelected = {}, + ) + } +} diff --git a/feature/results/src/main/java/com/android/developers/androidify/customize/di/BitmapRendererModule.kt b/feature/results/src/main/java/com/android/developers/androidify/customize/di/BitmapRendererModule.kt new file mode 100644 index 00000000..7e5344f9 --- /dev/null +++ b/feature/results/src/main/java/com/android/developers/androidify/customize/di/BitmapRendererModule.kt @@ -0,0 +1,35 @@ +/* + * 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.android.developers.androidify.customize.di + +import com.android.developers.androidify.customize.ComposableBitmapRenderer +import com.android.developers.androidify.customize.ComposableBitmapRendererImpl +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +abstract class BitmapRendererModule { + + @Binds + @Singleton + abstract fun bindComposableBitmapRenderer( + impl: ComposableBitmapRendererImpl, + ): ComposableBitmapRenderer +} diff --git a/feature/results/src/main/java/com/android/developers/androidify/results/BotResultCard.kt b/feature/results/src/main/java/com/android/developers/androidify/results/BotResultCard.kt index 1b952919..0934b485 100644 --- a/feature/results/src/main/java/com/android/developers/androidify/results/BotResultCard.kt +++ b/feature/results/src/main/java/com/android/developers/androidify/results/BotResultCard.kt @@ -60,7 +60,7 @@ fun BotResultCard( FlippableCard( modifier = modifier .rotate(-5f) - .aspectRatio(ASPECT_RATIO) + .aspectRatio(BOT_ASPECT_RATIO) .padding(16.dp) .safeContentPadding(), flippableState = flippableState, @@ -86,7 +86,7 @@ private fun FrontCard(bitmap: Bitmap) { contentScale = ContentScale.Crop, modifier = Modifier .fillMaxSize() - .aspectRatio(ASPECT_RATIO) + .aspectRatio(BOT_ASPECT_RATIO) .shadow(8.dp, shape = MaterialTheme.shapes.large) .clip(MaterialTheme.shapes.large), ) @@ -100,7 +100,7 @@ private fun BackCard(originalImageUrl: Uri) { contentScale = ContentScale.Crop, modifier = Modifier .fillMaxSize() - .aspectRatio(ASPECT_RATIO) + .aspectRatio(BOT_ASPECT_RATIO) .shadow(8.dp, shape = MaterialTheme.shapes.large) .clip(MaterialTheme.shapes.large), ) @@ -119,7 +119,7 @@ private fun BackCardPrompt(promptText: String) { Column( modifier = Modifier .fillMaxSize() - .aspectRatio(ASPECT_RATIO) + .aspectRatio(BOT_ASPECT_RATIO) .shadow(8.dp, shape = MaterialTheme.shapes.large) .clip(MaterialTheme.shapes.large) .background(MaterialTheme.colorScheme.background) @@ -140,4 +140,4 @@ private fun BackCardPrompt(promptText: String) { } } -private const val ASPECT_RATIO = 3f / 4f +const val BOT_ASPECT_RATIO = 3f / 4f diff --git a/feature/results/src/main/java/com/android/developers/androidify/results/ResultsScreen.kt b/feature/results/src/main/java/com/android/developers/androidify/results/ResultsScreen.kt index 24d3cff2..a6c2036c 100644 --- a/feature/results/src/main/java/com/android/developers/androidify/results/ResultsScreen.kt +++ b/feature/results/src/main/java/com/android/developers/androidify/results/ResultsScreen.kt @@ -17,10 +17,8 @@ package com.android.developers.androidify.results -import android.Manifest import android.graphics.Bitmap import android.net.Uri -import android.os.Build import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.core.EaseOutBack import androidx.compose.animation.core.tween @@ -57,7 +55,6 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.graphics.asAndroidBitmap import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalInspectionMode import androidx.compose.ui.res.imageResource import androidx.compose.ui.res.stringArrayResource @@ -78,9 +75,6 @@ import com.android.developers.androidify.util.SmallPhonePreview import com.android.developers.androidify.util.allowsFullContent import com.android.developers.androidify.util.isAtLeastMedium import com.google.accompanist.permissions.ExperimentalPermissionsApi -import com.google.accompanist.permissions.isGranted -import com.google.accompanist.permissions.rememberPermissionState -import com.google.accompanist.permissions.shouldShowRationale @Composable fun ResultsScreen( @@ -91,19 +85,13 @@ fun ResultsScreen( verboseLayout: Boolean = allowsFullContent(), onBackPress: () -> Unit, onAboutPress: () -> Unit, + onNextPress: () -> Unit, viewModel: ResultsViewModel = hiltViewModel(), ) { val state = viewModel.state.collectAsStateWithLifecycle() LaunchedEffect(resultImage, originalImageUri, promptText) { viewModel.setArguments(resultImage, originalImageUri, promptText) } - val context = LocalContext.current - LaunchedEffect(state.value.savedUri) { - val savedImageUri = state.value.savedUri - if (savedImageUri != null) { - shareImage(context, savedImageUri) - } - } val snackbarHostState by viewModel.snackbarHostState.collectAsStateWithLifecycle() Scaffold( snackbarHost = { @@ -133,12 +121,7 @@ fun ResultsScreen( contentPadding, state, verboseLayout = verboseLayout, - { - viewModel.downloadClicked() - }, - shareClicked = { - viewModel.shareClicked() - }, + onCustomizeShareClicked = onNextPress, ) } } @@ -162,8 +145,7 @@ private fun ResultsScreenPreview() { ResultsScreenContents( contentPadding = PaddingValues(0.dp), state = state, - downloadClicked = {}, - shareClicked = {}, + onCustomizeShareClicked = {}, ) } } @@ -186,8 +168,7 @@ private fun ResultsScreenPreviewSmall() { contentPadding = PaddingValues(0.dp), state = state, verboseLayout = false, - downloadClicked = {}, - shareClicked = {}, + onCustomizeShareClicked = {}, ) } } @@ -197,8 +178,7 @@ fun ResultsScreenContents( contentPadding: PaddingValues, state: State, verboseLayout: Boolean = allowsFullContent(), - downloadClicked: () -> Unit, - shareClicked: () -> Unit, + onCustomizeShareClicked: () -> Unit, defaultSelectedResult: ResultOption = ResultOption.ResultImage, ) { ResultsBackground() @@ -247,11 +227,8 @@ fun ResultsScreenContents( } val buttonRow = @Composable { modifier: Modifier -> BotActionsButtonRow( - onShareClicked = { - shareClicked() - }, - onDownloadClicked = { - downloadClicked() + onCustomizeShareClicked = { + onCustomizeShareClicked() }, modifier = modifier, verboseLayout = verboseLayout, @@ -327,7 +304,12 @@ private fun BackgroundRandomQuotes(verboseLayout: Boolean = true) { fontSize = 120.sp, modifier = Modifier .align(if (verboseLayout) Alignment.TopCenter else Alignment.Center) - .basicMarquee(iterations = iterations, repeatDelayMillis = 0, velocity = 80.dp, initialDelayMillis = 500), + .basicMarquee( + iterations = iterations, + repeatDelayMillis = 0, + velocity = 80.dp, + initialDelayMillis = 500, + ), ) if (verboseLayout) { val listMinusOther = listResultCompliments.asList().minus(randomQuote) @@ -344,7 +326,12 @@ private fun BackgroundRandomQuotes(verboseLayout: Boolean = true) { fontSize = 110.sp, modifier = Modifier .align(Alignment.BottomCenter) - .basicMarquee(iterations = iterations, repeatDelayMillis = 0, velocity = 60.dp, initialDelayMillis = 500), + .basicMarquee( + iterations = iterations, + repeatDelayMillis = 0, + velocity = 60.dp, + initialDelayMillis = 500, + ), ) } } @@ -352,67 +339,26 @@ private fun BackgroundRandomQuotes(verboseLayout: Boolean = true) { @Composable private fun BotActionsButtonRow( - onShareClicked: () -> Unit, - onDownloadClicked: () -> Unit, + onCustomizeShareClicked: () -> Unit, modifier: Modifier = Modifier, verboseLayout: Boolean = false, ) { Row(modifier) { PrimaryButton( onClick = { - onShareClicked() + onCustomizeShareClicked() }, - leadingIcon = { + trailingIcon = { Row { + Spacer(modifier = Modifier.width(8.dp)) Icon( ImageVector - .vectorResource(com.android.developers.androidify.theme.R.drawable.sharp_share_24), + .vectorResource(com.android.developers.androidify.theme.R.drawable.rounded_arrow_forward_24), contentDescription = null, // decorative element ) - Spacer(modifier = Modifier.width(8.dp)) } }, - buttonText = if (verboseLayout) stringResource(R.string.share_your_bot) else null, - ) - Spacer(Modifier.width(8.dp)) - val externalStoragePermission = rememberPermissionState( - permission = Manifest.permission.WRITE_EXTERNAL_STORAGE, - ) - val mustGrantPermission = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - false - } else { - !externalStoragePermission.status.isGranted - } - var showRationaleDialog by remember { - mutableStateOf(false) - } - PrimaryButton( - onClick = { - if (mustGrantPermission) { - if (externalStoragePermission.status.shouldShowRationale) { - showRationaleDialog = true - } else { - externalStoragePermission.launchPermissionRequest() - } - externalStoragePermission.launchPermissionRequest() - } else { - onDownloadClicked() - } - }, - leadingIcon = { - Icon( - ImageVector - .vectorResource(R.drawable.rounded_download_24), - contentDescription = stringResource(R.string.download_bot), - ) - }, - ) - PermissionRationaleDialog( - showRationaleDialog, - onDismiss = { - showRationaleDialog = false - }, - externalStoragePermission, + buttonText = if (verboseLayout) stringResource(R.string.customize_and_share) else null, ) } } diff --git a/feature/results/src/main/java/com/android/developers/androidify/results/ResultsViewModel.kt b/feature/results/src/main/java/com/android/developers/androidify/results/ResultsViewModel.kt index 4b083e40..a54af8ea 100644 --- a/feature/results/src/main/java/com/android/developers/androidify/results/ResultsViewModel.kt +++ b/feature/results/src/main/java/com/android/developers/androidify/results/ResultsViewModel.kt @@ -19,20 +19,15 @@ import android.graphics.Bitmap import android.net.Uri import androidx.compose.material3.SnackbarHostState import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import com.android.developers.androidify.data.ImageGenerationRepository import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update -import kotlinx.coroutines.launch import javax.inject.Inject @HiltViewModel -class ResultsViewModel @Inject constructor( - val imageGenerationRepository: ImageGenerationRepository, -) : ViewModel() { +class ResultsViewModel @Inject constructor() : ViewModel() { private val _state = MutableStateFlow(ResultState()) val state = _state.asStateFlow() @@ -51,45 +46,10 @@ class ResultsViewModel @Inject constructor( ResultState(resultImageUrl, originalImageUrl, promptText = promptText) } } - - fun shareClicked() { - viewModelScope.launch { - val resultUrl = state.value.resultImageBitmap - if (resultUrl != null) { - val imageFileUri = imageGenerationRepository.saveImage(resultUrl) - - _state.update { - it.copy(savedUri = imageFileUri) - } - } - } - } - fun downloadClicked() { - viewModelScope.launch { - val resultBitmap = state.value.resultImageBitmap - val originalImage = state.value.originalImageUrl - if (originalImage != null) { - val savedOriginalUri = imageGenerationRepository.saveImageToExternalStorage(originalImage) - _state.update { - it.copy(externalOriginalSavedUri = savedOriginalUri) - } - } - if (resultBitmap != null) { - val imageUri = imageGenerationRepository.saveImageToExternalStorage(resultBitmap) - _state.update { - it.copy(externalSavedUri = imageUri) - } - snackbarHostState.value.showSnackbar("Download complete") - } - } - } } data class ResultState( val resultImageBitmap: Bitmap? = null, val originalImageUrl: Uri? = null, - val savedUri: Uri? = null, - val externalSavedUri: Uri? = null, - val externalOriginalSavedUri: Uri? = null, val promptText: String? = null, ) diff --git a/feature/results/src/main/res/drawable-nodpi/background_banner_lightspeed.png b/feature/results/src/main/res/drawable-nodpi/background_banner_lightspeed.png new file mode 100644 index 00000000..d7319403 Binary files /dev/null and b/feature/results/src/main/res/drawable-nodpi/background_banner_lightspeed.png differ diff --git a/feature/results/src/main/res/drawable-nodpi/background_banner_plain.png b/feature/results/src/main/res/drawable-nodpi/background_banner_plain.png new file mode 100644 index 00000000..db24e0a0 Binary files /dev/null and b/feature/results/src/main/res/drawable-nodpi/background_banner_plain.png differ diff --git a/feature/results/src/main/res/drawable-nodpi/background_banner_shapes.png b/feature/results/src/main/res/drawable-nodpi/background_banner_shapes.png new file mode 100644 index 00000000..d055ce95 Binary files /dev/null and b/feature/results/src/main/res/drawable-nodpi/background_banner_shapes.png differ diff --git a/feature/results/src/main/res/drawable-nodpi/background_banner_square.png b/feature/results/src/main/res/drawable-nodpi/background_banner_square.png new file mode 100644 index 00000000..d676f34d Binary files /dev/null and b/feature/results/src/main/res/drawable-nodpi/background_banner_square.png differ diff --git a/feature/results/src/main/res/drawable-nodpi/background_social_header_lightspeed.png b/feature/results/src/main/res/drawable-nodpi/background_social_header_lightspeed.png new file mode 100644 index 00000000..6249c640 Binary files /dev/null and b/feature/results/src/main/res/drawable-nodpi/background_social_header_lightspeed.png differ diff --git a/feature/results/src/main/res/drawable-nodpi/background_social_header_plain.png b/feature/results/src/main/res/drawable-nodpi/background_social_header_plain.png new file mode 100644 index 00000000..97fb831c Binary files /dev/null and b/feature/results/src/main/res/drawable-nodpi/background_social_header_plain.png differ diff --git a/feature/results/src/main/res/drawable-nodpi/background_social_header_shape.png b/feature/results/src/main/res/drawable-nodpi/background_social_header_shape.png new file mode 100644 index 00000000..a83804da Binary files /dev/null and b/feature/results/src/main/res/drawable-nodpi/background_social_header_shape.png differ diff --git a/feature/results/src/main/res/drawable-nodpi/background_square_blocks.png b/feature/results/src/main/res/drawable-nodpi/background_square_blocks.png new file mode 100644 index 00000000..4d72b39d Binary files /dev/null and b/feature/results/src/main/res/drawable-nodpi/background_square_blocks.png differ diff --git a/feature/results/src/main/res/drawable-nodpi/background_square_lightspeed.png b/feature/results/src/main/res/drawable-nodpi/background_square_lightspeed.png new file mode 100644 index 00000000..10d35ccb Binary files /dev/null and b/feature/results/src/main/res/drawable-nodpi/background_square_lightspeed.png differ diff --git a/feature/results/src/main/res/drawable-nodpi/background_square_none.png b/feature/results/src/main/res/drawable-nodpi/background_square_none.png new file mode 100644 index 00000000..87d14c86 Binary files /dev/null and b/feature/results/src/main/res/drawable-nodpi/background_square_none.png differ diff --git a/feature/results/src/main/res/drawable-nodpi/background_wallpaper_lightspeed.png b/feature/results/src/main/res/drawable-nodpi/background_wallpaper_lightspeed.png new file mode 100644 index 00000000..88cb1f9a Binary files /dev/null and b/feature/results/src/main/res/drawable-nodpi/background_wallpaper_lightspeed.png differ diff --git a/feature/results/src/main/res/drawable-nodpi/background_wallpaper_plain.png b/feature/results/src/main/res/drawable-nodpi/background_wallpaper_plain.png new file mode 100644 index 00000000..b10f16e4 Binary files /dev/null and b/feature/results/src/main/res/drawable-nodpi/background_wallpaper_plain.png differ diff --git a/feature/results/src/main/res/drawable-nodpi/background_wallpaper_shapes.png b/feature/results/src/main/res/drawable-nodpi/background_wallpaper_shapes.png new file mode 100644 index 00000000..d8514fc8 Binary files /dev/null and b/feature/results/src/main/res/drawable-nodpi/background_wallpaper_shapes.png differ diff --git a/feature/results/src/main/res/drawable-nodpi/background_wallpaper_tablet_light.png b/feature/results/src/main/res/drawable-nodpi/background_wallpaper_tablet_light.png new file mode 100644 index 00000000..e7748c5b Binary files /dev/null and b/feature/results/src/main/res/drawable-nodpi/background_wallpaper_tablet_light.png differ diff --git a/feature/results/src/main/res/drawable-nodpi/background_wallpaper_tablet_lightspeed.png b/feature/results/src/main/res/drawable-nodpi/background_wallpaper_tablet_lightspeed.png new file mode 100644 index 00000000..59e29a35 Binary files /dev/null and b/feature/results/src/main/res/drawable-nodpi/background_wallpaper_tablet_lightspeed.png differ diff --git a/feature/results/src/main/res/drawable-nodpi/background_wallpaper_tablet_shapes.png b/feature/results/src/main/res/drawable-nodpi/background_wallpaper_tablet_shapes.png new file mode 100644 index 00000000..27474557 Binary files /dev/null and b/feature/results/src/main/res/drawable-nodpi/background_wallpaper_tablet_shapes.png differ diff --git a/feature/results/src/main/res/drawable/background_create.xml b/feature/results/src/main/res/drawable/background_create.xml new file mode 100644 index 00000000..a3562193 --- /dev/null +++ b/feature/results/src/main/res/drawable/background_create.xml @@ -0,0 +1,28 @@ + + + + + + diff --git a/feature/results/src/main/res/drawable/background_option_io.xml b/feature/results/src/main/res/drawable/background_option_io.xml new file mode 100644 index 00000000..90d27006 --- /dev/null +++ b/feature/results/src/main/res/drawable/background_option_io.xml @@ -0,0 +1,38 @@ + + + + + + + + + + + diff --git a/feature/results/src/main/res/drawable/light_speed_dots.xml b/feature/results/src/main/res/drawable/light_speed_dots.xml new file mode 100644 index 00000000..d1b80e58 --- /dev/null +++ b/feature/results/src/main/res/drawable/light_speed_dots.xml @@ -0,0 +1,99 @@ + + + + + + + + + + + + + + + diff --git a/feature/results/src/main/res/drawable/outline_background_replace_24.xml b/feature/results/src/main/res/drawable/outline_background_replace_24.xml new file mode 100644 index 00000000..0916226d --- /dev/null +++ b/feature/results/src/main/res/drawable/outline_background_replace_24.xml @@ -0,0 +1,21 @@ + + + + + + + diff --git a/feature/results/src/main/res/drawable/rounded_arrow_forward_24.xml b/feature/results/src/main/res/drawable/rounded_arrow_forward_24.xml new file mode 100644 index 00000000..0b45b054 --- /dev/null +++ b/feature/results/src/main/res/drawable/rounded_arrow_forward_24.xml @@ -0,0 +1,29 @@ + + + + + + + diff --git a/feature/results/src/main/res/drawable/size_tool_icon.xml b/feature/results/src/main/res/drawable/size_tool_icon.xml new file mode 100644 index 00000000..9ff65759 --- /dev/null +++ b/feature/results/src/main/res/drawable/size_tool_icon.xml @@ -0,0 +1,21 @@ + + + + + + + diff --git a/feature/results/src/main/res/values/strings.xml b/feature/results/src/main/res/values/strings.xml index 0c8d65c9..b437a279 100644 --- a/feature/results/src/main/res/values/strings.xml +++ b/feature/results/src/main/res/values/strings.xml @@ -29,6 +29,10 @@ Share your customized Android bot Download bot Prompt + Customize and Share + Info + Back + Customize and Export You are in your bot era! Say hello to mini you! diff --git a/feature/results/src/screenshotTest/java/com/android/developers/androidify/results/ResultsScreenScreenshotTest.kt b/feature/results/src/screenshotTest/java/com/android/developers/androidify/results/ResultsScreenScreenshotTest.kt index ddf58b25..f03cfbd3 100644 --- a/feature/results/src/screenshotTest/java/com/android/developers/androidify/results/ResultsScreenScreenshotTest.kt +++ b/feature/results/src/screenshotTest/java/com/android/developers/androidify/results/ResultsScreenScreenshotTest.kt @@ -54,8 +54,7 @@ class ResultsScreenScreenshotTest { contentPadding = PaddingValues(0.dp), state = state, verboseLayout = true, // Replicates ResultsScreenPreview - downloadClicked = {}, - shareClicked = {}, + onCustomizeShareClicked = {}, ) } } @@ -80,8 +79,7 @@ class ResultsScreenScreenshotTest { contentPadding = PaddingValues(0.dp), state = state, verboseLayout = false, // Replicates ResultsScreenPreviewSmall - downloadClicked = {}, - shareClicked = {}, + onCustomizeShareClicked = {}, ) } } @@ -105,8 +103,7 @@ class ResultsScreenScreenshotTest { contentPadding = PaddingValues(0.dp), state = state, verboseLayout = true, - downloadClicked = {}, - shareClicked = {}, + onCustomizeShareClicked = {}, defaultSelectedResult = ResultOption.OriginalInput, // Set the non-default option ) } diff --git a/feature/results/src/test/kotlin/com/android/developers/androidify/customize/CustomizeStateTest.kt b/feature/results/src/test/kotlin/com/android/developers/androidify/customize/CustomizeStateTest.kt new file mode 100644 index 00000000..73ff8cda --- /dev/null +++ b/feature/results/src/test/kotlin/com/android/developers/androidify/customize/CustomizeStateTest.kt @@ -0,0 +1,236 @@ +/* + * 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.android.developers.androidify.customize + +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import com.android.developers.androidify.results.R +import org.junit.Assert +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class CustomizeStateTest { + + @Test + fun customizeExportState_defaultValues() { + val state = CustomizeExportState() + Assert.assertNull(state.originalImageUrl) + Assert.assertNull(state.savedUri) + Assert.assertNull(state.externalSavedUri) + Assert.assertNull(state.externalOriginalSavedUri) + Assert.assertEquals(CustomizeTool.Size, state.selectedTool) + Assert.assertTrue(state.tools.containsAll(CustomizeTool.entries)) + Assert.assertTrue(state.toolState.containsKey(CustomizeTool.Size)) + Assert.assertTrue(state.toolState.containsKey(CustomizeTool.Background)) + Assert.assertTrue(state.toolState[CustomizeTool.Size] is AspectRatioToolState) + Assert.assertTrue(state.toolState[CustomizeTool.Background] is BackgroundToolState) + Assert.assertEquals(ExportImageCanvas(), state.exportImageCanvas) + } + + @Test + fun aspectRatioToolState_defaultValues() { + val state = AspectRatioToolState() + Assert.assertEquals(SizeOption.Square, state.selectedToolOption) + Assert.assertEquals( + listOf( + SizeOption.Square, + SizeOption.Wallpaper, + SizeOption.WallpaperTablet, + SizeOption.Banner, + SizeOption.SocialHeader, + ), + state.options, + ) + } + + @Test + fun backgroundToolState_defaultValues() { + val state = BackgroundToolState() + Assert.assertEquals(BackgroundOption.IO, state.selectedToolOption) + Assert.assertEquals( + listOf( + BackgroundOption.None, + BackgroundOption.Plain, + BackgroundOption.Lightspeed, + BackgroundOption.IO, + ), + state.options, + ) + } + + @Test + fun exportImageCanvas_defaultValues() { + val canvas = ExportImageCanvas() + Assert.assertNull(canvas.imageBitmap) + Assert.assertEquals(SizeOption.Square, canvas.aspectRatioOption) + Assert.assertEquals(Size(1000f, 1000f), canvas.canvasSize) + Assert.assertNull(canvas.mainImageUri) + Assert.assertEquals(Size(600f, 600f), canvas.imageSize) + Assert.assertEquals(Offset(200f, 160f), canvas.imageOffset) + Assert.assertEquals(0f, canvas.imageRotation) + Assert.assertEquals(Size(1024f, 1024f), canvas.imageOriginalBitmapSize) + Assert.assertEquals(BackgroundOption.IO, canvas.selectedBackgroundOption) + Assert.assertEquals(com.android.developers.androidify.results.R.drawable.background_square_blocks, canvas.selectedBackgroundDrawable) + Assert.assertTrue(canvas.includeWatermark) + } + + @Test + fun updateAspectRatioAndBackground_Square_None() { + val initialCanvas = ExportImageCanvas() + val updatedCanvas = initialCanvas.updateAspectRatioAndBackground( + backgroundOption = BackgroundOption.None, + sizeOption = SizeOption.Square, + ) + + Assert.assertEquals(SizeOption.Square, updatedCanvas.aspectRatioOption) + Assert.assertEquals(BackgroundOption.None, updatedCanvas.selectedBackgroundOption) + Assert.assertEquals(SizeOption.Square.dimensions, updatedCanvas.canvasSize) + Assert.assertNull(updatedCanvas.selectedBackgroundDrawable) + Assert.assertEquals(0f, updatedCanvas.imageRotation) + + // For None background, imageSize should match canvasSize and offset be Zero + Assert.assertEquals(updatedCanvas.canvasSize, updatedCanvas.imageSize) + Assert.assertEquals(Offset.Companion.Zero, updatedCanvas.imageOffset) + } + + @Test + fun updateAspectRatioAndBackground_Square_IO() { + val initialCanvas = ExportImageCanvas() + val updatedCanvas = initialCanvas.updateAspectRatioAndBackground( + backgroundOption = BackgroundOption.IO, + sizeOption = SizeOption.Square, + ) + val newCanvasSize = SizeOption.Square.dimensions + + Assert.assertEquals(SizeOption.Square, updatedCanvas.aspectRatioOption) + Assert.assertEquals(BackgroundOption.IO, updatedCanvas.selectedBackgroundOption) + Assert.assertEquals(newCanvasSize, updatedCanvas.canvasSize) + Assert.assertEquals( + R.drawable.background_square_blocks, + updatedCanvas.selectedBackgroundDrawable, + ) + Assert.assertEquals(0f, updatedCanvas.imageRotation) + Assert.assertEquals( + Size(newCanvasSize.width * 0.6f, newCanvasSize.width * 0.6f), + updatedCanvas.imageSize, + ) + Assert.assertEquals( + Offset(newCanvasSize.width * 0.2f, newCanvasSize.height * 0.16f), + updatedCanvas.imageOffset, + ) + } + + @Test + fun updateAspectRatioAndBackground_Banner_Lightspeed() { + val initialCanvas = ExportImageCanvas() + val updatedCanvas = initialCanvas.updateAspectRatioAndBackground( + backgroundOption = BackgroundOption.Lightspeed, + sizeOption = SizeOption.Banner, + ) + val newCanvasSize = SizeOption.Banner.dimensions + + Assert.assertEquals(SizeOption.Banner, updatedCanvas.aspectRatioOption) + Assert.assertEquals(BackgroundOption.Lightspeed, updatedCanvas.selectedBackgroundOption) + Assert.assertEquals(newCanvasSize, updatedCanvas.canvasSize) + Assert.assertEquals( + R.drawable.background_banner_lightspeed, + updatedCanvas.selectedBackgroundDrawable, + ) + Assert.assertEquals(-11f, updatedCanvas.imageRotation) + Assert.assertEquals( + Size(newCanvasSize.width * 0.26f, newCanvasSize.width * 0.26f), + updatedCanvas.imageSize, + ) + Assert.assertEquals( + Offset(newCanvasSize.width * 0.51f, newCanvasSize.height * -0.03f), + updatedCanvas.imageOffset, + ) + } + + @Test + fun updateAspectRatioAndBackground_SocialHeader_Plain() { + val initialCanvas = ExportImageCanvas() + val updatedCanvas = initialCanvas.updateAspectRatioAndBackground( + backgroundOption = BackgroundOption.Plain, + sizeOption = SizeOption.SocialHeader, + ) + val newCanvasSize = SizeOption.SocialHeader.dimensions + + Assert.assertEquals(SizeOption.SocialHeader, updatedCanvas.aspectRatioOption) + Assert.assertEquals(BackgroundOption.Plain, updatedCanvas.selectedBackgroundOption) + Assert.assertEquals(newCanvasSize, updatedCanvas.canvasSize) + Assert.assertEquals( + R.drawable.background_social_header_plain, + updatedCanvas.selectedBackgroundDrawable, + ) + Assert.assertEquals(-9f, updatedCanvas.imageRotation) + Assert.assertEquals( + Size(newCanvasSize.width * 0.26f, newCanvasSize.width * 0.3f), + updatedCanvas.imageSize, + ) + Assert.assertEquals( + Offset(newCanvasSize.width * 0.49f, newCanvasSize.height * 0.01f), + updatedCanvas.imageOffset, + ) + } + + @Test + fun updateAspectRatioAndBackground_Wallpaper_IO() { + val initialCanvas = ExportImageCanvas() + val updatedCanvas = initialCanvas.updateAspectRatioAndBackground( + backgroundOption = BackgroundOption.IO, + sizeOption = SizeOption.Wallpaper, + ) + val newCanvasSize = SizeOption.Wallpaper.dimensions + + Assert.assertEquals(SizeOption.Wallpaper, updatedCanvas.aspectRatioOption) + Assert.assertEquals(BackgroundOption.IO, updatedCanvas.selectedBackgroundOption) + Assert.assertEquals(newCanvasSize, updatedCanvas.canvasSize) + Assert.assertEquals( + R.drawable.background_wallpaper_shapes, + updatedCanvas.selectedBackgroundDrawable, + ) + Assert.assertEquals(-9f, updatedCanvas.imageRotation) + Assert.assertEquals( + Size(newCanvasSize.width * 1.1f, newCanvasSize.width * 1.3f), + updatedCanvas.imageSize, + ) + Assert.assertEquals( + Offset(newCanvasSize.width * -0.02f, newCanvasSize.height * 0.1f), + updatedCanvas.imageOffset, + ) + } + + @Test + fun updateAspectRatioAndBackground_WallpaperTablet_None() { + val initialCanvas = ExportImageCanvas() + val updatedCanvas = initialCanvas.updateAspectRatioAndBackground( + backgroundOption = BackgroundOption.None, + sizeOption = SizeOption.WallpaperTablet, + ) + val newCanvasSize = SizeOption.WallpaperTablet.dimensions + + Assert.assertEquals(SizeOption.WallpaperTablet, updatedCanvas.aspectRatioOption) + Assert.assertEquals(BackgroundOption.None, updatedCanvas.selectedBackgroundOption) + Assert.assertEquals(newCanvasSize, updatedCanvas.canvasSize) + Assert.assertNull(updatedCanvas.selectedBackgroundDrawable) + Assert.assertEquals(0f, updatedCanvas.imageRotation) + Assert.assertEquals(newCanvasSize, updatedCanvas.imageSize) + Assert.assertEquals(Offset.Companion.Zero, updatedCanvas.imageOffset) + } +} diff --git a/feature/results/src/test/kotlin/com/android/developers/androidify/customize/CustomizeViewModelTest.kt b/feature/results/src/test/kotlin/com/android/developers/androidify/customize/CustomizeViewModelTest.kt new file mode 100644 index 00000000..73cf7f8c --- /dev/null +++ b/feature/results/src/test/kotlin/com/android/developers/androidify/customize/CustomizeViewModelTest.kt @@ -0,0 +1,138 @@ +/* + * 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. + */ +@file:OptIn(ExperimentalCoroutinesApi::class) + +package com.android.developers.androidify.customize + +import android.graphics.Bitmap +import android.net.Uri +import androidx.test.core.app.ApplicationProvider +import com.android.developers.testing.repository.FakeImageGenerationRepository +import com.android.developers.testing.util.FakeComposableBitmapRenderer +import com.android.developers.testing.util.MainDispatcherRule +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import kotlin.test.DefaultAsserter.assertNotNull +import kotlin.test.assertNotNull + +@RunWith(RobolectricTestRunner::class) +class CustomizeViewModelTest { + @get:Rule + val mainDispatcherRule = MainDispatcherRule() + + private lateinit var viewModel: CustomizeExportViewModel + + private val fakeBitmap = Bitmap.createBitmap(1, 1, Bitmap.Config.ARGB_8888) + private val originalFakeUri = Uri.parse("content://com.example.app/images/original.jpg") + + @Before + fun setup() { + viewModel = CustomizeExportViewModel( + FakeImageGenerationRepository(), + composableBitmapRenderer = FakeComposableBitmapRenderer(), + application = ApplicationProvider.getApplicationContext(), + ) + } + + @Test + fun stateInitialEmpty() = runTest { + assertEquals( + CustomizeExportState(), + viewModel.state.value, + ) + } + + @Test + fun setArgumentsWithOriginalImage() = runTest { + viewModel.setArguments( + fakeBitmap, + originalFakeUri, + ) + assertEquals( + CustomizeExportState( + exportImageCanvas = ExportImageCanvas(imageBitmap = fakeBitmap), + originalImageUrl = originalFakeUri, + ), + viewModel.state.value, + ) + } + + @Test + fun setArgumentsWithPrompt() = runTest { + viewModel.setArguments( + fakeBitmap, + null, + ) + assertEquals( + CustomizeExportState( + exportImageCanvas = ExportImageCanvas(imageBitmap = fakeBitmap), + originalImageUrl = null, + ), + viewModel.state.value, + ) + } + + @Test + fun downloadClicked() = runTest { + val values = mutableListOf() + backgroundScope.launch(UnconfinedTestDispatcher()) { + viewModel.state.collect { + values.add(it) + } + } + + viewModel.setArguments( + fakeBitmap, + originalFakeUri, + ) + + viewModel.downloadClicked() + assertNotNull(values.last().externalOriginalSavedUri) + assertEquals( + originalFakeUri, + values.last().externalOriginalSavedUri, + ) + } + + @Test + fun shareClicked() = runTest { + val values = mutableListOf() + // Launch collector on the backgroundScope directly to use runTest's scheduler + backgroundScope.launch(UnconfinedTestDispatcher()) { + viewModel.state.collect { + values.add(it) + } + } + viewModel.setArguments( + fakeBitmap, + originalFakeUri, + ) + advanceUntilIdle() + viewModel.shareClicked() + // Ensure all coroutines on the test scheduler complete + advanceUntilIdle() + assertNotNull(values.last().savedUri) + } +} diff --git a/feature/results/src/test/kotlin/com/android/developers/androidify/results/ResultsViewModelTest.kt b/feature/results/src/test/kotlin/com/android/developers/androidify/results/ResultsViewModelTest.kt index 87672a47..5f425658 100644 --- a/feature/results/src/test/kotlin/com/android/developers/androidify/results/ResultsViewModelTest.kt +++ b/feature/results/src/test/kotlin/com/android/developers/androidify/results/ResultsViewModelTest.kt @@ -19,14 +19,10 @@ package com.android.developers.androidify.results import android.graphics.Bitmap import android.net.Uri -import com.android.developers.testing.repository.FakeImageGenerationRepository import com.android.developers.testing.util.MainDispatcherRule import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.launch -import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.runTest import org.junit.Assert.assertEquals -import org.junit.Assert.assertNotNull import org.junit.Before import org.junit.Rule import org.junit.Test @@ -46,9 +42,7 @@ class ResultsViewModelTest { @Before fun setup() { - viewModel = ResultsViewModel( - FakeImageGenerationRepository(), - ) + viewModel = ResultsViewModel() } @Test @@ -91,45 +85,4 @@ class ResultsViewModelTest { viewModel.state.value, ) } - - @Test - fun downloadClicked() = runTest { - val values = mutableListOf() - backgroundScope.launch(UnconfinedTestDispatcher()) { - viewModel.state.collect { - values.add(it) - } - } - - viewModel.setArguments( - fakeBitmap, - originalFakeUri, - promptText = null, - ) - - viewModel.downloadClicked() - assertNotNull(values.last().externalOriginalSavedUri) - assertEquals( - originalFakeUri, - values.last().externalOriginalSavedUri, - ) - } - - @Test - fun shareClicked() = runTest { - val values = mutableListOf() - backgroundScope.launch(UnconfinedTestDispatcher()) { - viewModel.state.collect { - values.add(it) - } - } - viewModel.setArguments( - fakeBitmap, - originalFakeUri, - promptText = null, - ) - - viewModel.shareClicked() - assertNotNull(values.last().savedUri) - } } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 8a0135d0..08a050d4 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,6 +1,6 @@ [versions] # build -agp = "8.9.1" +agp = "8.11.0" compileSdk = "36" leakcanaryAndroid = "2.14" minSdk = "26" @@ -11,42 +11,42 @@ jvmTarget = "17" accompanist = "0.37.3" activityCompose = "1.10.1" adaptive = "1.1.0" -animationAndroid = "1.8.0" -appcompat = "1.7.0" +animationAndroid = "1.8.3" +appcompat = "1.7.1" baselineprofile = "1.3.4" benchmarkMacroJunit4 = "1.3.4" -camerax = "1.5.0-SNAPSHOT" +camerax = "1.5.0-beta01" coilCompose = "3.2.0" coilGif = "3.2.0" -composeBom = "2025.05.00" +composeBom = "2025.06.02" concurrent = "1.2.0" converterGson = "2.11.0" -coreKtx = "1.15.0" +coreKtx = "1.16.0" coreSplashscreen = "1.0.1" -crashlytics = "3.0.3" +crashlytics = "3.0.4" espressoCore = "3.6.1" -firebaseBom = "33.14.0" -firebaseConfigKtx = "22.1.1" -googleServices = "4.4.2" -googleOss = "17.1.0" +firebaseBom = "33.16.0" +firebaseConfigKtx = "22.1.2" +googleServices = "4.4.3" +googleOss = "17.2.0" googleOssPlugin = "0.10.6" hiltAndroid = "2.56.2" hiltLifecycleViewmodel = "1.0.0-alpha03" hiltNavigationCompose = "1.2.0" junit = "4.13.2" junitVersion = "1.2.1" -kotlin = "2.1.20" -ksp = "2.1.20-1.0.32" +kotlin = "2.2.0" +ksp = "2.2.0-2.0.2" kotlinxCoroutines = "1.10.2" -kotlinxSerialization = "2.1.20" -kotlinxSerializationJson = "1.8.1" +kotlinxSerialization = "2.2.0" +kotlinxSerializationJson = "1.9.0" ktlint = "1.5.0" -lifecycleRuntimeKtx = "2.8.7" -lifecycleViewmodelNavigation3 = "1.0.0-SNAPSHOT" -loggingInterceptor = "5.0.0-alpha.14" +lifecycleRuntimeKtx = "2.9.1" +lifecycleViewmodelNavigation3 = "1.0.0-alpha03" +loggingInterceptor = "5.1.0" material = "1.12.0" -media3 = "1.6.1" -navigation3 = "1.0.0-SNAPSHOT" +media3 = "1.7.1" +navigation3 = "1.0.0-alpha05" okhttp = "4.12.0" poseDetection = "18.0.0-beta5" profileinstaller = "1.4.1" @@ -55,11 +55,12 @@ robolectric = "4.14.1" spotless = "7.0.2" startup = "1.2.0" runner = "1.6.2" -uiTextGoogleFonts = "1.8.0" -uiautomator = "2.4.0-SNAPSHOT" -uiTooling = "1.8.0" -window = "1.4.0-rc02" +uiTextGoogleFonts = "1.8.3" +uiautomator = "2.4.0-alpha05" +uiTooling = "1.8.3" +window = "1.4.0" aiEdge = "0.0.1-exp02" +lifecycleProcess = "2.9.1" [libraries] accompanist-permissions = { module = "com.google.accompanist:accompanist-permissions", version.ref = "accompanist" } @@ -121,7 +122,6 @@ kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-cor kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinxSerializationJson" } leakcanary-android = { module = "com.squareup.leakcanary:leakcanary-android", version.ref = "leakcanaryAndroid" } logging-interceptor = { group = "com.squareup.okhttp3", name = "logging-interceptor", version.ref = "loggingInterceptor" } -material = { group = "com.google.android.material", name = "material", version.ref = "material" } mlkit-pose-detection = { module = "com.google.mlkit:pose-detection", version.ref = "poseDetection" } okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" } retrofit = { group = "com.squareup.retrofit2", name = "retrofit", version.ref = "retrofit" } @@ -134,6 +134,7 @@ androidx-profileinstaller = { group = "androidx.profileinstaller", name = "profi ai-edge = { group = "com.google.ai.edge.aicore", name = "aicore", version.ref = "aiEdge" } google-oss-licenses = { group = "com.google.android.gms", name = "play-services-oss-licenses", version.ref = "googleOss" } google-oss-licenses-plugin = { group = "com.google.android.gms", name = "oss-licenses-plugin", version.ref = "googleOssPlugin" } +androidx-lifecycle-process = { group = "androidx.lifecycle", name = "lifecycle-process", version.ref = "lifecycleProcess" } [plugins] android-application = { id = "com.android.application", version.ref = "agp" } android-library = { id = "com.android.library", version.ref = "agp" } @@ -146,5 +147,5 @@ kotlin-ksp = { id ="com.google.devtools.ksp", version.ref = "ksp" } serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlinxSerialization" } android-test = { id = "com.android.test", version.ref = "agp" } baselineprofile = { id = "androidx.baselineprofile", version.ref = "baselineprofile" } -composeScreenshot = { id = "com.android.compose.screenshot", version = "0.0.1-alpha09" } +composeScreenshot = { id = "com.android.compose.screenshot", version = "0.0.1-alpha10" } spotless = { id = "com.diffplug.spotless", version.ref = "spotless" } \ No newline at end of file diff --git a/settings.gradle.kts b/settings.gradle.kts index 8b8103ae..9cf587c9 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -16,9 +16,6 @@ dependencyResolutionManagement { repositories { google() mavenCentral() - maven { - url = uri("https://androidx.dev/snapshots/builds/13511472/artifacts/repository") - } } }