diff --git a/app/build.gradle b/app/build.gradle index 2d11243dd9..e42b7c012c 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -105,6 +105,7 @@ dependencies { androidTestImplementation rootProject.ext.espressoContrib androidTestImplementation rootProject.ext.testRules androidTestImplementation rootProject.ext.coroutinesTest + androidTestImplementation rootProject.ext.composeTestJUnit implementation project(':Backpack') implementation project(':backpack-compose') } diff --git a/app/screenshots/default/compose.dialog.BpkDialogTest_destructive.png b/app/screenshots/default/compose.dialog.BpkDialogTest_destructive.png new file mode 100644 index 0000000000..b0ab81bfcb Binary files /dev/null and b/app/screenshots/default/compose.dialog.BpkDialogTest_destructive.png differ diff --git a/app/screenshots/default/compose.dialog.BpkDialogTest_noIcon.png b/app/screenshots/default/compose.dialog.BpkDialogTest_noIcon.png new file mode 100644 index 0000000000..485358e215 Binary files /dev/null and b/app/screenshots/default/compose.dialog.BpkDialogTest_noIcon.png differ diff --git a/app/screenshots/default/compose.dialog.BpkDialogTest_successOneButton.png b/app/screenshots/default/compose.dialog.BpkDialogTest_successOneButton.png new file mode 100644 index 0000000000..723490f7c4 Binary files /dev/null and b/app/screenshots/default/compose.dialog.BpkDialogTest_successOneButton.png differ diff --git a/app/screenshots/default/compose.dialog.BpkDialogTest_successThreeButtons.png b/app/screenshots/default/compose.dialog.BpkDialogTest_successThreeButtons.png new file mode 100644 index 0000000000..bc46eaf360 Binary files /dev/null and b/app/screenshots/default/compose.dialog.BpkDialogTest_successThreeButtons.png differ diff --git a/app/screenshots/default/compose.dialog.BpkDialogTest_successTwoButtons.png b/app/screenshots/default/compose.dialog.BpkDialogTest_successTwoButtons.png new file mode 100644 index 0000000000..d39a4689b7 Binary files /dev/null and b/app/screenshots/default/compose.dialog.BpkDialogTest_successTwoButtons.png differ diff --git a/app/screenshots/default/compose.dialog.BpkDialogTest_warning.png b/app/screenshots/default/compose.dialog.BpkDialogTest_warning.png new file mode 100644 index 0000000000..77dfbf4645 Binary files /dev/null and b/app/screenshots/default/compose.dialog.BpkDialogTest_warning.png differ diff --git a/app/screenshots/dm/compose.dialog.BpkDialogTest_destructive.png b/app/screenshots/dm/compose.dialog.BpkDialogTest_destructive.png new file mode 100644 index 0000000000..8d0a22af53 Binary files /dev/null and b/app/screenshots/dm/compose.dialog.BpkDialogTest_destructive.png differ diff --git a/app/screenshots/dm/compose.dialog.BpkDialogTest_noIcon.png b/app/screenshots/dm/compose.dialog.BpkDialogTest_noIcon.png new file mode 100644 index 0000000000..c7012912b7 Binary files /dev/null and b/app/screenshots/dm/compose.dialog.BpkDialogTest_noIcon.png differ diff --git a/app/screenshots/dm/compose.dialog.BpkDialogTest_successOneButton.png b/app/screenshots/dm/compose.dialog.BpkDialogTest_successOneButton.png new file mode 100644 index 0000000000..c1c37aa1fb Binary files /dev/null and b/app/screenshots/dm/compose.dialog.BpkDialogTest_successOneButton.png differ diff --git a/app/screenshots/dm/compose.dialog.BpkDialogTest_successThreeButtons.png b/app/screenshots/dm/compose.dialog.BpkDialogTest_successThreeButtons.png new file mode 100644 index 0000000000..eaa1b2c42b Binary files /dev/null and b/app/screenshots/dm/compose.dialog.BpkDialogTest_successThreeButtons.png differ diff --git a/app/screenshots/dm/compose.dialog.BpkDialogTest_successTwoButtons.png b/app/screenshots/dm/compose.dialog.BpkDialogTest_successTwoButtons.png new file mode 100644 index 0000000000..6e5642cf54 Binary files /dev/null and b/app/screenshots/dm/compose.dialog.BpkDialogTest_successTwoButtons.png differ diff --git a/app/screenshots/dm/compose.dialog.BpkDialogTest_warning.png b/app/screenshots/dm/compose.dialog.BpkDialogTest_warning.png new file mode 100644 index 0000000000..e95023dfc4 Binary files /dev/null and b/app/screenshots/dm/compose.dialog.BpkDialogTest_warning.png differ diff --git a/app/screenshots/rtl/compose.dialog.BpkDialogTest_successOneButton.png b/app/screenshots/rtl/compose.dialog.BpkDialogTest_successOneButton.png new file mode 100644 index 0000000000..723490f7c4 Binary files /dev/null and b/app/screenshots/rtl/compose.dialog.BpkDialogTest_successOneButton.png differ diff --git a/app/src/androidTest/java/net/skyscanner/backpack/compose/dialog/BpkDialogTest.kt b/app/src/androidTest/java/net/skyscanner/backpack/compose/dialog/BpkDialogTest.kt new file mode 100644 index 0000000000..5041092451 --- /dev/null +++ b/app/src/androidTest/java/net/skyscanner/backpack/compose/dialog/BpkDialogTest.kt @@ -0,0 +1,175 @@ +/** + * Backpack for Android - Skyscanner's Design System + * + * Copyright 2018 Skyscanner Ltd + * + * 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 + * + * http://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 net.skyscanner.backpack.compose.dialog + +import android.view.ViewGroup +import android.view.ViewParent +import android.widget.FrameLayout +import androidx.activity.compose.setContent +import androidx.appcompat.app.AppCompatActivity +import androidx.compose.runtime.Composable +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.isDialog +import androidx.compose.ui.test.junit4.AndroidComposeTestRule +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.rule.ActivityTestRule +import net.skyscanner.backpack.BpkSnapshotTest +import net.skyscanner.backpack.BpkTestVariant +import net.skyscanner.backpack.demo.R +import net.skyscanner.backpack.demo.compose.BackpackPreview +import net.skyscanner.backpack.demo.compose.DestructiveDialogExample +import net.skyscanner.backpack.demo.compose.NoIconDialogExample +import net.skyscanner.backpack.demo.compose.SuccessOneButtonDialogExample +import net.skyscanner.backpack.demo.compose.SuccessThreeButtonsDialogExample +import net.skyscanner.backpack.demo.compose.SuccessTwoButtonsDialogExample +import net.skyscanner.backpack.demo.compose.WarningDialogExample +import org.junit.Assume +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import java.lang.reflect.Field + +@RunWith(AndroidJUnit4::class) +class BpkDialogTest : BpkSnapshotTest() { + + @get:Rule + var activityRule = ActivityTestRule(AppCompatActivity::class.java, true, false) + + @get:Rule + val composeTestRule = AndroidComposeTestRule(activityRule) { it.activity } + + @Before + fun setup() { + setDimensions(height = 600, width = 420) + } + + @Test + fun successOneButton() = record { + SuccessOneButtonDialogExample() + } + + @Test + fun successTwoButtons() { + assumeVariant(BpkTestVariant.Default, BpkTestVariant.DarkMode) + record { + SuccessTwoButtonsDialogExample() + } + } + + @Test + fun successThreeButtons() { + assumeVariant(BpkTestVariant.Default, BpkTestVariant.DarkMode) + record { + SuccessThreeButtonsDialogExample() + } + } + + @Test + fun warning() { + assumeVariant(BpkTestVariant.Default, BpkTestVariant.DarkMode) + record { + WarningDialogExample() + } + } + + @Test + fun destructive() { + assumeVariant(BpkTestVariant.Default, BpkTestVariant.DarkMode) + record { + DestructiveDialogExample() + } + } + + @Test + fun noIcon() { + assumeVariant(BpkTestVariant.Default, BpkTestVariant.DarkMode) + record { + NoIconDialogExample() + } + } + + private fun record(content: @Composable () -> Unit) { + // we don't run Compose tests in Themed variant – Compose uses it own theming engine + Assume.assumeFalse(BpkTestVariant.current == BpkTestVariant.Themed) + + val asyncScreenshot = prepareForAsyncTest() + with(activityRule.launchActivity(null)) { + runOnUiThread { + setContent { + BackpackPreview( + content = content, + ) + } + } + + composeTestRule.onNode(isDialog()).assertIsDisplayed() + + runOnUiThread { + // This is not ideal but we need to see the background contrast as well + val viewRoot = getViewRoots().first { it.hasWindowFocus() } + val view = viewRoot.getChildAt(0) + + viewRoot.removeView(view) + + val wrapper = FrameLayout(this) + wrapper.layoutParams = FrameLayout.LayoutParams( + FrameLayout.LayoutParams.WRAP_CONTENT, + FrameLayout.LayoutParams.WRAP_CONTENT + ) + wrapper.setPadding(20, 20, 20, 20) + wrapper.setBackgroundColor(getColor(R.color.bpkTextSecondary)) + wrapper.addView(view) + + setupView(wrapper) + asyncScreenshot.record(wrapper) + } + } + } + + // we need this to be able to get the dialog root, rather than the window root + private fun getViewRoots(): List { + val viewRoots: MutableList = ArrayList() + try { + val windowManager: Any = Class.forName("android.view.WindowManagerGlobal") + .getMethod("getInstance").invoke(null) as Any + val rootsField: Field = windowManager.javaClass.getDeclaredField("mRoots") + rootsField.isAccessible = true + val stoppedField: Field = Class.forName("android.view.ViewRootImpl") + .getDeclaredField("mStopped") + stoppedField.isAccessible = true + + val viewField: Field = Class.forName("android.view.ViewRootImpl") + .getDeclaredField("mView") + viewField.isAccessible = true + val viewParents = rootsField.get(windowManager) as List + // Filter out inactive view roots + for (viewParent in viewParents) { + val stopped = stoppedField.get(viewParent) as Boolean + val view = viewField.get(viewParent) as ViewGroup? + if (!stopped && view != null) { + viewRoots.add(view) + } + } + } catch (e: Exception) { + e.printStackTrace() + } + return viewRoots + } +} diff --git a/app/src/androidTest/java/net/skyscanner/backpack/docs/DocsRegistry.kt b/app/src/androidTest/java/net/skyscanner/backpack/docs/DocsRegistry.kt index bc15df5411..4ef03d6a70 100644 --- a/app/src/androidTest/java/net/skyscanner/backpack/docs/DocsRegistry.kt +++ b/app/src/androidTest/java/net/skyscanner/backpack/docs/DocsRegistry.kt @@ -18,6 +18,10 @@ package net.skyscanner.backpack.docs +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.ComposeTestRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick import androidx.test.espresso.Espresso import androidx.test.espresso.action.ViewActions import androidx.test.espresso.matcher.ViewMatchers @@ -28,6 +32,7 @@ import net.skyscanner.backpack.calendar.model.CalendarRange import net.skyscanner.backpack.calendar2.CalendarSelection import net.skyscanner.backpack.calendar2.data.CalendarDispatchers import net.skyscanner.backpack.demo.R +import net.skyscanner.backpack.demo.compose.ShownDialog import net.skyscanner.backpack.util.InternalBackpackApi import org.threeten.bp.LocalDate @@ -44,12 +49,12 @@ object DocsRegistry { ComposeScreenshot("Button - Compose - Default", "default"), ComposeScreenshot("Button - Compose - Large", "large"), ComposeScreenshot("Button - Compose - Link", "link"), - ViewScreenshot("Calendar - Default", "range", ::setupCalendar), - ViewScreenshot("Calendar - Colored", "colored", ::setupCalendar), - ViewScreenshot("Calendar - Labeled", "labeled", ::setupCalendar), - ViewScreenshot("Calendar 2 - Pre-selected range", "range", ::setupCalendar2), - ViewScreenshot("Calendar 2 - Day colours", "colored", ::setupCalendar2), - ViewScreenshot("Calendar 2 - Day labels", "labeled", ::setupCalendar2), + ViewScreenshot("Calendar - Default", "range") { setupCalendar() }, + ViewScreenshot("Calendar - Colored", "colored") { setupCalendar() }, + ViewScreenshot("Calendar - Labeled", "labeled") { setupCalendar() }, + ViewScreenshot("Calendar 2 - Pre-selected range", "range") { setupCalendar2() }, + ViewScreenshot("Calendar 2 - Day colours", "colored") { setupCalendar2() }, + ViewScreenshot("Calendar 2 - Day labels", "labeled") { setupCalendar2() }, ViewScreenshot("Card - View - Default", "default"), ViewScreenshot("Card - View - Without padding", "without-padding"), ViewScreenshot("Card - View - Selected", "selected"), @@ -65,9 +70,12 @@ object DocsRegistry { ViewScreenshot("Chip - With icon", "with-icon"), ViewScreenshot("Checkbox - View", "default"), ComposeScreenshot("Checkbox - Compose", "default"), - ViewScreenshot("Dialog - With call to action", "with-cta", ::setupDialog), - ViewScreenshot("Dialog - Delete confirmation", "delete-confirmation", ::setupDialog), - ViewScreenshot("Dialog - Flare", "with-flare", ::setupDialog), + ViewScreenshot("Dialog - View - With call to action", "with-cta") { setupDialog() }, + ViewScreenshot("Dialog - View - Delete confirmation", "delete-confirmation") { setupDialog() }, + ViewScreenshot("Dialog - View - Flare", "with-flare") { setupDialog() }, + ComposeScreenshot("Dialog - Compose", "success") { setupComposeDialog(it, ShownDialog.SuccessThreeButtons) }, + ComposeScreenshot("Dialog - Compose", "warning") { setupComposeDialog(it, ShownDialog.Warning) }, + ComposeScreenshot("Dialog - Compose", "destructive") { setupComposeDialog(it, ShownDialog.Destructive) }, ViewScreenshot("Flare - Default", "default"), ViewScreenshot("Flare - Pointing up", "pointing-up"), ViewScreenshot("Flare - Pointer offset", "pointer-offset"), @@ -76,8 +84,8 @@ object DocsRegistry { ViewScreenshot("Horizontal Nav", "default"), ViewScreenshot("Floating Action Button", "default"), ViewScreenshot("Nav Bar - Default", "expanded"), - ViewScreenshot("Nav Bar - Default", "collapsed", ::setupNavBarCollapsed), - ViewScreenshot("Nav Bar - With Menu", "navigation", ::setupNavBarCollapsed), + ViewScreenshot("Nav Bar - Default", "collapsed") { setupNavBarCollapsed() }, + ViewScreenshot("Nav Bar - With Menu", "navigation") { setupNavBarCollapsed() }, ViewScreenshot("Nudger", "all"), ViewScreenshot("Overlay", "all"), ViewScreenshot("Panel - View", "all"), @@ -89,8 +97,8 @@ object DocsRegistry { ViewScreenshot("Rating - Vertical", "vertical"), ViewScreenshot("Rating - Pill", "pill"), ViewScreenshot("Slider", "all"), - ViewScreenshot("Snackbar", "default", ::setupSnackbar), - ViewScreenshot("Snackbar", "icon", ::setupSnackbarIconAction), + ViewScreenshot("Snackbar", "default") { setupSnackbar() }, + ViewScreenshot("Snackbar", "icon") { setupSnackbarIconAction() }, ViewScreenshot("Star Rating - Default", "default"), ViewScreenshot("Star Rating Interactive", "default"), ViewScreenshot("Switch - View", "default"), @@ -107,7 +115,7 @@ object DocsRegistry { ViewScreenshot("Spinner - Default", "default"), ViewScreenshot("Spinner - Small", "small"), // Leave toast last as it stays visible in the screen for a while - ViewScreenshot("Toast", "default", ::setupToast) + ViewScreenshot("Toast", "default") { setupToast() } ) init { @@ -118,14 +126,14 @@ object DocsRegistry { fun ComposeScreenshot( name: String, screenshotName: String, - setup: (() -> Unit)? = null, + setup: ((ComposeTestRule) -> Unit)? = null, ): Array = arrayOf(name, screenshotName, "docs/compose", setup) fun ViewScreenshot( name: String, screenshotName: String, - setup: (() -> Unit)? = null, + setup: ((ComposeTestRule) -> Unit)? = null, ): Array = arrayOf(name, screenshotName, "docs/view", setup) @@ -170,6 +178,10 @@ private fun setupDialog() { Thread.sleep(50) } +private fun setupComposeDialog(testRule: ComposeTestRule, dialog: ShownDialog) { + testRule.onNodeWithText(dialog.buttonText).performClick().assertIsDisplayed() +} + private fun setupSnackbar() { Espresso.onView(ViewMatchers.withText("Message (Duration Indefinite)")) .perform(ViewActions.click()) diff --git a/app/src/androidTest/java/net/skyscanner/backpack/docs/GenerateScreenshots.kt b/app/src/androidTest/java/net/skyscanner/backpack/docs/GenerateScreenshots.kt index d1264809f8..c9c8bac329 100644 --- a/app/src/androidTest/java/net/skyscanner/backpack/docs/GenerateScreenshots.kt +++ b/app/src/androidTest/java/net/skyscanner/backpack/docs/GenerateScreenshots.kt @@ -20,6 +20,8 @@ package net.skyscanner.backpack.docs import android.content.Intent import androidx.appcompat.app.AppCompatDelegate +import androidx.compose.ui.test.junit4.AndroidComposeTestRule +import androidx.compose.ui.test.junit4.ComposeTestRule import androidx.test.platform.app.InstrumentationRegistry import androidx.test.rule.ActivityTestRule import net.skyscanner.backpack.demo.ComponentDetailActivity @@ -34,7 +36,7 @@ open class GenerateScreenshots( private val componentPath: String, private val screenshotName: String, private val path: String, - private val setup: (() -> Unit)? + private val setup: ((ComposeTestRule) -> Unit)? ) { companion object { @@ -46,6 +48,9 @@ open class GenerateScreenshots( @get:Rule var activityRule = ActivityTestRule(ComponentDetailActivity::class.java, true, false) + @get:Rule + val composeTestRule = AndroidComposeTestRule(activityRule) { it.activity } + private val screenshotFullName: String get() { val componentName = componentPath.split(" - ").first() @@ -76,7 +81,7 @@ open class GenerateScreenshots( intent.putExtra(ComponentDetailFragment.ARG_ITEM_ID, componentPath) intent.putExtra(ComponentDetailFragment.AUTOMATION_MODE, true) activityRule.launchActivity(intent) - setup?.invoke() + setup?.invoke(composeTestRule) takeScreenshot(suffix) activityRule.finishActivity() } diff --git a/app/src/main/java/net/skyscanner/backpack/demo/compose/DialogStory.kt b/app/src/main/java/net/skyscanner/backpack/demo/compose/DialogStory.kt new file mode 100644 index 0000000000..93a7260673 --- /dev/null +++ b/app/src/main/java/net/skyscanner/backpack/demo/compose/DialogStory.kt @@ -0,0 +1,170 @@ +/** + * Backpack for Android - Skyscanner's Design System + * + * Copyright 2018 Skyscanner Ltd + * + * 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 + * + * http://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 net.skyscanner.backpack.demo.compose + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import net.skyscanner.backpack.compose.button.BpkButton +import net.skyscanner.backpack.compose.dialog.BpkDestructiveDialog +import net.skyscanner.backpack.compose.dialog.BpkSuccessDialog +import net.skyscanner.backpack.compose.dialog.BpkWarningDialog +import net.skyscanner.backpack.compose.dialog.DialogButton +import net.skyscanner.backpack.compose.icons.BpkIcons +import net.skyscanner.backpack.compose.icons.lg.AlertAdd +import net.skyscanner.backpack.compose.icons.lg.Tick +import net.skyscanner.backpack.compose.icons.lg.Trash +import net.skyscanner.backpack.compose.tokens.BpkSpacing +import net.skyscanner.backpack.demo.R + +@Composable +fun DialogStory() { + Column( + modifier = Modifier.padding(BpkSpacing.Base), + verticalArrangement = Arrangement.spacedBy(BpkSpacing.Base), + ) { + var shownDialog by rememberSaveable { mutableStateOf(ShownDialog.None) } + ShownDialog.values().forEach { + if (it != ShownDialog.None) { + BpkButton(it.buttonText) { + shownDialog = it + } + } + } + + when (shownDialog) { + ShownDialog.SuccessOneButton -> SuccessOneButtonDialogExample { shownDialog = ShownDialog.None } + ShownDialog.SuccessTwoButtons -> SuccessTwoButtonsDialogExample { shownDialog = ShownDialog.None } + ShownDialog.SuccessThreeButtons -> SuccessThreeButtonsDialogExample { shownDialog = ShownDialog.None } + ShownDialog.Warning -> WarningDialogExample { shownDialog = ShownDialog.None } + ShownDialog.Destructive -> DestructiveDialogExample { shownDialog = ShownDialog.None } + ShownDialog.NoIcon -> NoIconDialogExample { shownDialog = ShownDialog.None } + ShownDialog.None -> {} + } + } +} + +enum class ShownDialog(val buttonText: String) { + SuccessOneButton("Success One Button"), + SuccessTwoButtons("Success Two Buttons"), + SuccessThreeButtons("Success Three Buttons"), + Warning("Warning"), + Destructive("Destructive"), + NoIcon("No Icon"), + None(""), +} + +@Preview +@Composable +fun SuccessOneButtonDialogExample(onDismiss: () -> Unit = {}) { + BackpackPreview { + BpkSuccessDialog( + icon = BpkIcons.Lg.Tick, + title = stringResource(id = R.string.dialog_title), + text = stringResource(id = R.string.dialog_text), + confirmButton = DialogButton(stringResource(id = R.string.dialog_confirmation), onDismiss), + onDismissRequest = onDismiss, + ) + } +} + +@Preview +@Composable +fun SuccessTwoButtonsDialogExample(onDismiss: () -> Unit = {}) { + BackpackPreview { + BpkSuccessDialog( + icon = BpkIcons.Lg.Tick, + title = stringResource(id = R.string.dialog_title), + text = stringResource(id = R.string.dialog_text), + confirmButton = DialogButton(stringResource(id = R.string.dialog_confirmation), onDismiss), + secondaryButton = DialogButton(stringResource(id = R.string.dialog_skip), onDismiss), + onDismissRequest = onDismiss, + ) + } +} + +@Preview +@Composable +fun SuccessThreeButtonsDialogExample(onDismiss: () -> Unit = {}) { + BackpackPreview { + BpkSuccessDialog( + icon = BpkIcons.Lg.Tick, + title = stringResource(id = R.string.dialog_title), + text = stringResource(id = R.string.dialog_text), + confirmButton = DialogButton(stringResource(id = R.string.dialog_confirmation), onDismiss), + secondaryButton = DialogButton(stringResource(id = R.string.dialog_skip), onDismiss), + linkButton = DialogButton(stringResource(id = R.string.dialog_link_optional), onDismiss), + onDismissRequest = onDismiss, + ) + } +} + +@Preview +@Composable +fun WarningDialogExample(onDismiss: () -> Unit = {}) { + BackpackPreview { + BpkWarningDialog( + icon = BpkIcons.Lg.AlertAdd, + title = stringResource(id = R.string.dialog_title), + text = stringResource(id = R.string.dialog_text), + confirmButton = DialogButton(stringResource(id = R.string.dialog_confirmation), onDismiss), + secondaryButton = DialogButton(stringResource(id = R.string.dialog_skip), onDismiss), + linkButton = DialogButton(stringResource(id = R.string.dialog_link_optional), onDismiss), + onDismissRequest = onDismiss, + ) + } +} + +@Preview +@Composable +fun DestructiveDialogExample(onDismiss: () -> Unit = {}) { + BackpackPreview { + BpkDestructiveDialog( + icon = BpkIcons.Lg.Trash, + title = stringResource(id = R.string.dialog_title), + text = stringResource(id = R.string.dialog_text), + confirmButton = DialogButton(stringResource(id = R.string.dialog_delete), onDismiss), + linkButton = DialogButton(stringResource(id = R.string.dialog_cancel), onDismiss), + onDismissRequest = onDismiss, + ) + } +} + +@Preview +@Composable +fun NoIconDialogExample(onDismiss: () -> Unit = {}) { + BackpackPreview { + BpkSuccessDialog( + icon = null, + title = stringResource(id = R.string.dialog_title), + text = stringResource(id = R.string.dialog_text), + confirmButton = DialogButton(stringResource(id = R.string.dialog_confirmation), onDismiss), + secondaryButton = DialogButton(stringResource(id = R.string.dialog_skip), onDismiss), + onDismissRequest = onDismiss, + ) + } +} diff --git a/app/src/main/java/net/skyscanner/backpack/demo/data/ComponentRegistry.kt b/app/src/main/java/net/skyscanner/backpack/demo/data/ComponentRegistry.kt index c4344ce96c..742878b807 100644 --- a/app/src/main/java/net/skyscanner/backpack/demo/data/ComponentRegistry.kt +++ b/app/src/main/java/net/skyscanner/backpack/demo/data/ComponentRegistry.kt @@ -68,6 +68,7 @@ import net.skyscanner.backpack.demo.stories.SubStory import net.skyscanner.backpack.demo.stories.TabStory import net.skyscanner.backpack.demo.stories.TextSpansStory import net.skyscanner.backpack.demo.stories.ToastStory +import net.skyscanner.backpack.demo.compose.DialogStory as ComposeDialogStory interface RegistryItem { val name: String @@ -234,17 +235,23 @@ object ComponentRegistry { ) ), "Dialog" story NodeData( - { children -> SubStory of children }, + { children -> TabStory of children }, mapOf( - "With call to action" story NodeData { DialogStory of "Normal" }, - "Warning" story NodeData { DialogStory of "Warning" }, - "Delete confirmation" story NodeData { DialogStory of "Delete" }, - "Success" story NodeData { DialogStory of "Confirmation" }, - "With Links" story NodeData { DialogStory of "Links" }, - "Long Text" story NodeData { DialogStory of "Long" }, - "Flare" story NodeData { DialogStory of "Flare" }, - "Flare with image" story NodeData { DialogStory of "FlareWithImage" } - ) + TAB_TITLE_VIEW story NodeData( + { children -> SubStory of children }, + mapOf( + "With call to action" story NodeData { DialogStory of "Normal" }, + "Warning" story NodeData { DialogStory of "Warning" }, + "Delete confirmation" story NodeData { DialogStory of "Delete" }, + "Success" story NodeData { DialogStory of "Confirmation" }, + "With Links" story NodeData { DialogStory of "Links" }, + "Long Text" story NodeData { DialogStory of "Long" }, + "Flare" story NodeData { DialogStory of "Flare" }, + "Flare with image" story NodeData { DialogStory of "FlareWithImage" } + ) + ), + TAB_TITLE_COMPOSE composeStory { ComposeDialogStory() }, + ), ), "Flare" story NodeData( { children -> SubStory of children }, diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 686aabffa2..d94412b8e4 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -112,4 +112,12 @@ Default No padding + + Title in here + Description that goes two lines ideally, but sometimes it can go longer + Confirmation + Skip + Link optional + Delete + Cancel diff --git a/backpack-compose/src/main/kotlin/net/skyscanner/backpack/compose/dialog/BpkDialog.kt b/backpack-compose/src/main/kotlin/net/skyscanner/backpack/compose/dialog/BpkDialog.kt new file mode 100644 index 0000000000..03b0c793bc --- /dev/null +++ b/backpack-compose/src/main/kotlin/net/skyscanner/backpack/compose/dialog/BpkDialog.kt @@ -0,0 +1,147 @@ +/** + * Backpack for Android - Skyscanner's Design System + * + * Copyright 2018 Skyscanner Ltd + * + * 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 + * + * http://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 net.skyscanner.backpack.compose.dialog + +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.window.DialogProperties +import net.skyscanner.backpack.compose.button.BpkButtonType +import net.skyscanner.backpack.compose.dialog.internal.BpkDialogImpl +import net.skyscanner.backpack.compose.dialog.internal.Dialog + +@Composable +fun BpkSuccessDialog( + onDismissRequest: () -> Unit, + icon: Painter?, + title: String, + text: String, + confirmButton: DialogButton, + secondaryButton: DialogButton? = null, + properties: DialogProperties = DialogProperties(), +) { + BpkDialogImpl( + icon = icon?.let { Dialog.Icon.Success(icon) }, + title = title, + text = text, + buttons = listOfNotNull( + Dialog.Button(BpkButtonType.Primary, confirmButton), + secondaryButton?.let { Dialog.Button(BpkButtonType.Secondary, secondaryButton) }, + ), + onDismissRequest = onDismissRequest, + properties = properties + ) +} + +@Composable +fun BpkSuccessDialog( + onDismissRequest: () -> Unit, + icon: Painter?, + title: String, + text: String, + confirmButton: DialogButton, + secondaryButton: DialogButton, + linkButton: DialogButton? = null, + properties: DialogProperties = DialogProperties(), +) { + BpkDialogImpl( + icon = icon?.let { Dialog.Icon.Success(icon) }, + title = title, + text = text, + buttons = listOfNotNull( + Dialog.Button(BpkButtonType.Primary, confirmButton), + Dialog.Button(BpkButtonType.Secondary, secondaryButton), + linkButton?.let { Dialog.Button(BpkButtonType.Link, linkButton) }, + ), + onDismissRequest = onDismissRequest, + properties = properties + ) +} + +@Composable +fun BpkWarningDialog( + onDismissRequest: () -> Unit, + icon: Painter?, + title: String, + text: String, + confirmButton: DialogButton, + secondaryButton: DialogButton? = null, + properties: DialogProperties = DialogProperties(), +) { + BpkDialogImpl( + icon = icon?.let { Dialog.Icon.Warning(icon) }, + title = title, + text = text, + buttons = listOfNotNull( + Dialog.Button(BpkButtonType.Primary, confirmButton), + secondaryButton?.let { Dialog.Button(BpkButtonType.Secondary, secondaryButton) }, + ), + onDismissRequest = onDismissRequest, + properties = properties + ) +} + +@Composable +fun BpkWarningDialog( + onDismissRequest: () -> Unit, + icon: Painter?, + title: String, + text: String, + confirmButton: DialogButton, + secondaryButton: DialogButton, + linkButton: DialogButton? = null, + properties: DialogProperties = DialogProperties(), +) { + BpkDialogImpl( + icon = icon?.let { Dialog.Icon.Warning(icon) }, + title = title, + text = text, + buttons = listOfNotNull( + Dialog.Button(BpkButtonType.Primary, confirmButton), + Dialog.Button(BpkButtonType.Secondary, secondaryButton), + linkButton?.let { Dialog.Button(BpkButtonType.Link, linkButton) }, + ), + onDismissRequest = onDismissRequest, + properties = properties + ) +} + +@Composable +fun BpkDestructiveDialog( + onDismissRequest: () -> Unit, + icon: Painter?, + title: String, + text: String, + confirmButton: DialogButton, + linkButton: DialogButton? = null, + properties: DialogProperties = DialogProperties(), +) { + BpkDialogImpl( + icon = icon?.let { Dialog.Icon.Destructive(icon) }, + title = title, + text = text, + buttons = listOfNotNull( + Dialog.Button(BpkButtonType.Destructive, confirmButton), + linkButton?.let { Dialog.Button(BpkButtonType.Link, linkButton) }, + ), + onDismissRequest = onDismissRequest, + properties = properties + ) +} + +data class DialogButton(internal val text: String, internal val onClick: () -> Unit) diff --git a/backpack-compose/src/main/kotlin/net/skyscanner/backpack/compose/dialog/internal/BpkDialogImpl.kt b/backpack-compose/src/main/kotlin/net/skyscanner/backpack/compose/dialog/internal/BpkDialogImpl.kt new file mode 100644 index 0000000000..6b7e179deb --- /dev/null +++ b/backpack-compose/src/main/kotlin/net/skyscanner/backpack/compose/dialog/internal/BpkDialogImpl.kt @@ -0,0 +1,125 @@ +/** + * Backpack for Android - Skyscanner's Design System + * + * Copyright 2018 Skyscanner Ltd + * + * 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 + * + * http://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 net.skyscanner.backpack.compose.dialog.internal + +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.defaultMinSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.Icon +import androidx.compose.material.Surface +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.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import net.skyscanner.backpack.compose.button.BpkButton +import net.skyscanner.backpack.compose.button.BpkButtonSize +import net.skyscanner.backpack.compose.text.BpkText +import net.skyscanner.backpack.compose.theme.BpkTheme +import net.skyscanner.backpack.compose.tokens.BpkColor +import net.skyscanner.backpack.compose.tokens.BpkDimension +import net.skyscanner.backpack.compose.tokens.BpkSpacing + +@Composable +internal fun BpkDialogImpl( + onDismissRequest: () -> Unit, + icon: Dialog.Icon?, + title: String, + text: String, + buttons: List, + properties: DialogProperties, +) { + Dialog(onDismissRequest = onDismissRequest, properties = properties) { + Box(contentAlignment = Alignment.TopCenter) { + Surface( + modifier = Modifier.padding(top = IconPadding), + shape = BpkTheme.shapes.medium, + color = BpkTheme.colors.backgroundElevation01, + ) { + Column( + modifier = Modifier + .padding(top = DialogPaddingTop, bottom = BpkDimension.Spacing.Lg) + .padding(horizontal = BpkDimension.Spacing.Lg), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + DialogTextContent(title = title, text = text) + DialogButtons(buttons) + } + } + DialogIcon(icon = icon) + } + } +} + +@Composable +private fun DialogTextContent(title: String, text: String) { + BpkText(text = title, style = BpkTheme.typography.heading3, textAlign = TextAlign.Center) + BpkText( + modifier = Modifier.padding(top = BpkDimension.Spacing.Base, bottom = BpkDimension.Spacing.Lg), + text = text, + textAlign = TextAlign.Center, + ) +} + +@Composable +private fun DialogButtons(buttons: List) { + Column( + verticalArrangement = Arrangement.spacedBy(BpkSpacing.Md), + ) { + buttons.forEach { + BpkButton( + modifier = Modifier.fillMaxWidth(), + text = it.button.text, + onClick = it.button.onClick, + size = BpkButtonSize.Large, + type = it.type, + ) + } + } +} + +@Composable +private fun DialogIcon(icon: Dialog.Icon?) { + icon?.let { + Box( + modifier = Modifier + .clip(CircleShape) + .background(BpkTheme.colors.backgroundElevation01) + .padding(IconBorder) + .background(icon.backgroundColor, CircleShape) + .defaultMinSize(minWidth = IconSize, minHeight = IconSize), + contentAlignment = Alignment.Center, + ) { + Icon(painter = icon.icon, contentDescription = null, tint = BpkColor.White) + } + } +} + +private val IconSize = 64.dp +private val IconBorder = 4.dp +private val IconPadding = 40.dp +private val DialogPaddingTop = 40.dp diff --git a/backpack-compose/src/main/kotlin/net/skyscanner/backpack/compose/dialog/internal/Types.kt b/backpack-compose/src/main/kotlin/net/skyscanner/backpack/compose/dialog/internal/Types.kt new file mode 100644 index 0000000000..da61d86354 --- /dev/null +++ b/backpack-compose/src/main/kotlin/net/skyscanner/backpack/compose/dialog/internal/Types.kt @@ -0,0 +1,46 @@ +/** + * Backpack for Android - Skyscanner's Design System + * + * Copyright 2018 Skyscanner Ltd + * + * 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 + * + * http://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 net.skyscanner.backpack.compose.dialog.internal + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.painter.Painter +import net.skyscanner.backpack.compose.button.BpkButtonType +import net.skyscanner.backpack.compose.dialog.DialogButton +import net.skyscanner.backpack.compose.tokens.BpkColor + +internal object Dialog { + internal sealed class Icon { + abstract val icon: Painter + abstract val backgroundColor: Color + + data class Success(override val icon: Painter) : Icon() { + override val backgroundColor = BpkColor.Monteverde + } + + data class Warning(override val icon: Painter) : Icon() { + override val backgroundColor = BpkColor.Kolkata + } + + data class Destructive(override val icon: Painter) : Icon() { + override val backgroundColor = BpkColor.Panjin + } + } + + internal data class Button(val type: BpkButtonType, val button: DialogButton) +} diff --git a/docs/compose/Dialog/README.md b/docs/compose/Dialog/README.md new file mode 100644 index 0000000000..01bd330fd2 --- /dev/null +++ b/docs/compose/Dialog/README.md @@ -0,0 +1,62 @@ +# Dialog + +## Installation + +Backpack Compose is available +through [Maven Central](https://search.maven.org/artifact/net.skyscanner.backpack/backpack-compose). Check the +main [Readme](https://github.com/skyscanner/backpack-android#installation) for a complete installation guide. + +## Usage + +Example of a success dialog with three buttons + +```Kotlin +import net.skyscanner.backpack.compose.dialog.BpkDialog +import net.skyscanner.backpack.compose.icons.BpkIcons + +BpkSuccessDialog( + icon = BpkIcons.Lg.Tick, + title = stringResource(id = R.string.dialog_title), + text = stringResource(id = R.string.dialog_text), + confirmButton = DialogButton(stringResource(id = R.string.dialog_confirmation)) { /** onClick **/ }, + secondaryButton = DialogButton(stringResource(id = R.string.dialog_skip)) { /** onClick **/ }, + linkButton = DialogButton(stringResource(id = R.string.dialog_link_optional)) { /** onClick **/ }, +) { + // onDismiss +} +``` + +Example of a warning dialog with two buttons + +```Kotlin +import net.skyscanner.backpack.compose.dialog.BpkDialog +import net.skyscanner.backpack.compose.icons.BpkIcons + +BpkWarningDialog( + icon = BpkIcons.Lg.AlertAdd, + title = stringResource(id = R.string.dialog_title), + text = stringResource(id = R.string.dialog_text), + confirmButton = DialogButton(stringResource(id = R.string.dialog_confirmation)) { /** onClick **/ }, + secondaryButton = DialogButton(stringResource(id = R.string.dialog_skip)) { /** onClick **/ }, +) { + // onDismiss +} +``` + +Example of a destructive dialog with two buttons + +```Kotlin +import net.skyscanner.backpack.compose.dialog.BpkDialog +import net.skyscanner.backpack.compose.icons.BpkIcons + +BpkDestructiveDialog( + icon = BpkIcons.Lg.Trash, + title = stringResource(id = R.string.dialog_title), + text = stringResource(id = R.string.dialog_text), + confirmButton = DialogButton(stringResource(id = R.string.dialog_delete)) { /** onClick **/ }, + linkButton = DialogButton(stringResource(id = R.string.dialog_cancel)) { /** onClick **/ }, + onDismissRequest = onDismiss, +) { + // onDismiss +} +``` diff --git a/docs/compose/Dialog/screenshots/destructive.png b/docs/compose/Dialog/screenshots/destructive.png new file mode 100644 index 0000000000..05df2d6920 Binary files /dev/null and b/docs/compose/Dialog/screenshots/destructive.png differ diff --git a/docs/compose/Dialog/screenshots/destructive_dm.png b/docs/compose/Dialog/screenshots/destructive_dm.png new file mode 100644 index 0000000000..06e4125605 Binary files /dev/null and b/docs/compose/Dialog/screenshots/destructive_dm.png differ diff --git a/docs/compose/Dialog/screenshots/success.png b/docs/compose/Dialog/screenshots/success.png new file mode 100644 index 0000000000..ebb7d26e16 Binary files /dev/null and b/docs/compose/Dialog/screenshots/success.png differ diff --git a/docs/compose/Dialog/screenshots/success_dm.png b/docs/compose/Dialog/screenshots/success_dm.png new file mode 100644 index 0000000000..3d5e913f42 Binary files /dev/null and b/docs/compose/Dialog/screenshots/success_dm.png differ diff --git a/docs/compose/Dialog/screenshots/warning.png b/docs/compose/Dialog/screenshots/warning.png new file mode 100644 index 0000000000..cf23a7ae1f Binary files /dev/null and b/docs/compose/Dialog/screenshots/warning.png differ diff --git a/docs/compose/Dialog/screenshots/warning_dm.png b/docs/compose/Dialog/screenshots/warning_dm.png new file mode 100644 index 0000000000..de96930736 Binary files /dev/null and b/docs/compose/Dialog/screenshots/warning_dm.png differ