diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 911a657..af91f3a 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -67,6 +67,9 @@ dependencies { implementation(libs.hilt.android) ksp(libs.hilt.android.compiler) +// Splash screen + implementation(libs.androidx.core.splashscreen) + implementation(project(":common")) implementation(project(":scaffold")) implementation(project(":localData")) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 306c7bd..dcca169 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -11,16 +11,16 @@ android:allowBackup="true" android:dataExtractionRules="@xml/data_extraction_rules" android:fullBackupContent="@xml/backup_rules" - android:icon="@mipmap/mvp_icon" + android:icon="@mipmap/ic_launcher" android:label="@string/app_name" - android:roundIcon="@mipmap/mvp_icon" + android:roundIcon="@mipmap/ic_launcher_round" android:supportsRtl="true" - android:theme="@style/Theme.SayItAgain" + android:theme="@style/Theme.Sia.Start" tools:targetApi="31"> + android:theme="@style/Theme.Sia.Start"> diff --git a/app/src/main/ic_launcher-playstore.png b/app/src/main/ic_launcher-playstore.png new file mode 100644 index 0000000..d95331b Binary files /dev/null and b/app/src/main/ic_launcher-playstore.png differ diff --git a/app/src/main/java/eu/project/sayitagain/MainActivity.kt b/app/src/main/java/eu/project/sayitagain/MainActivity.kt index 6afa7f6..afa1c66 100644 --- a/app/src/main/java/eu/project/sayitagain/MainActivity.kt +++ b/app/src/main/java/eu/project/sayitagain/MainActivity.kt @@ -5,6 +5,7 @@ import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge +import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope import androidx.lifecycle.repeatOnLifecycle @@ -53,6 +54,7 @@ class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + installSplashScreen() enableEdgeToEdge() setContent { diff --git a/app/src/main/res/drawable/sia_icon.xml b/app/src/main/res/drawable/sia_icon.xml new file mode 100644 index 0000000..31e00db --- /dev/null +++ b/app/src/main/res/drawable/sia_icon.xml @@ -0,0 +1,12 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..65291b9 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 0000000..65291b9 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher.webp b/app/src/main/res/mipmap-hdpi/ic_launcher.webp new file mode 100644 index 0000000..d1e8b59 Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp b/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp new file mode 100644 index 0000000..413c8e3 Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp differ diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp new file mode 100644 index 0000000..b3d7a9e Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-hdpi/mvp_icon.png b/app/src/main/res/mipmap-hdpi/mvp_icon.png deleted file mode 100644 index b3bafd5..0000000 Binary files a/app/src/main/res/mipmap-hdpi/mvp_icon.png and /dev/null differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher.webp b/app/src/main/res/mipmap-mdpi/ic_launcher.webp new file mode 100644 index 0000000..03d8149 Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp b/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp new file mode 100644 index 0000000..ecdc67b Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp new file mode 100644 index 0000000..5d79778 Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher.webp new file mode 100644 index 0000000..56a5ddf Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp new file mode 100644 index 0000000..116eef9 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..c25ca7c Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp new file mode 100644 index 0000000..8093dee Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp new file mode 100644 index 0000000..f032fca Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..5827cff Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp new file mode 100644 index 0000000..9ebefaf Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp new file mode 100644 index 0000000..9bb953d Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..4a00075 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml index f8c6127..edcaa6a 100644 --- a/app/src/main/res/values/colors.xml +++ b/app/src/main/res/values/colors.xml @@ -7,4 +7,5 @@ #FF018786 #FF000000 #FFFFFFFF + #FF1E1E1E \ No newline at end of file diff --git a/app/src/main/res/values/ic_launcher_background.xml b/app/src/main/res/values/ic_launcher_background.xml new file mode 100644 index 0000000..927922e --- /dev/null +++ b/app/src/main/res/values/ic_launcher_background.xml @@ -0,0 +1,4 @@ + + + #1E1E1E + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 35c022d..f56d82f 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,3 +1,3 @@ - Say It Again + Sia \ No newline at end of file diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml index eb443aa..6533238 100644 --- a/app/src/main/res/values/themes.xml +++ b/app/src/main/res/values/themes.xml @@ -1,4 +1,15 @@ - \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index ae14ba2..dfc26b2 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,5 +1,6 @@ [versions] converterGsonVersion = "3.0.0" +coreSplashscreenVersion = "1.2.0" hiltNavigationComposeVersion = "1.2.0" activityComposeVersion = "1.10.1" composeBom = "2026.03.00" @@ -36,6 +37,7 @@ ksp = "2.2.21-2.0.4" [libraries] androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "activityComposeVersion" } androidx-compose-bom = { module = "androidx.compose:compose-bom", version.ref = "composeBom" } +androidx-core-splashscreen = { module = "androidx.core:core-splashscreen", version.ref = "coreSplashscreenVersion" } androidx-espresso-core = { module = "androidx.test.espresso:espresso-core", version.ref = "espressoCore" } androidx-hilt-navigation-compose = { module = "androidx.hilt:hilt-navigation-compose", version.ref = "hiltNavigationComposeVersion" } androidx-junit = { module = "androidx.test.ext:junit", version.ref = "junitVersion" } diff --git a/ui/build.gradle.kts b/ui/build.gradle.kts index 58ea617..0352f52 100644 --- a/ui/build.gradle.kts +++ b/ui/build.gradle.kts @@ -55,4 +55,8 @@ dependencies { androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.espresso.core) androidTestImplementation(libs.androidx.ui.test.junit4) + +// Compose UI test + androidTestImplementation(libs.ui.test.junit4) + debugImplementation(libs.ui.test.manifest) } \ No newline at end of file diff --git a/ui/src/androidTest/java/eu/project/design_system/component/BottomSheetTest.kt b/ui/src/androidTest/java/eu/project/design_system/component/BottomSheetTest.kt new file mode 100644 index 0000000..10a7188 --- /dev/null +++ b/ui/src/androidTest/java/eu/project/design_system/component/BottomSheetTest.kt @@ -0,0 +1,156 @@ +package eu.project.design_system.component + +import android.annotation.SuppressLint +import androidx.compose.material3.Text +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performTouchInput +import androidx.compose.ui.test.swipeDown +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test + +class BottomSheetTest { + + @get:Rule + val composeTestRule = createComposeRule() + + private val testTag = "test_bottom_sheet" + private val testContentTag = "test_bottom_sheet_content" + private val testData = "Test Content" + + + +//- Visibility ------------------------------------------------------------------------------------- + + @Test + fun whenStateIsShown_contentIsDisplayed() { + composeTestRule.setContent { + BottomSheet( + state = BottomSheetState.Shown(testData), + onDismissRequest = {}, + testTag = testTag + ) { data -> + Text( + text = data, + modifier = Modifier.testTag(testContentTag) + ) + } + } + + composeTestRule.onNodeWithTag(testContentTag).assertIsDisplayed() + composeTestRule.onNodeWithText(testData).assertIsDisplayed() + } + + + +//- Dismiss behavior ------------------------------------------------------------------------------- + + @SuppressLint("CheckResult") + @Test + fun whenDismissed_onDismissRequestIsInvoked() { + var dismissed = false + + composeTestRule.setContent { + BottomSheet( + state = BottomSheetState.Shown(testData), + onDismissRequest = { dismissed = true }, + testTag = testTag + ) { data -> + Text(text = data) + } + } + + composeTestRule.onNodeWithTag(testTag).performTouchInput { this.swipeDown() } + + composeTestRule.runOnIdle { + assertTrue(dismissed) + } + } + + + +//- Data passing ----------------------------------------------------------------------------------- + + @Test + fun whenStateIsShownWithData_dataIsPassedToContent() { + val specificData = "Specific Test Data" + var receivedData: String? = null + + composeTestRule.setContent { + BottomSheet( + state = BottomSheetState.Shown(specificData), + onDismissRequest = {}, + testTag = testTag + ) { data -> + receivedData = data + Text(text = data) + } + } + + composeTestRule.runOnIdle { + assertEquals(specificData, receivedData) + } + } + + + +//- State transitions ------------------------------------------------------------------------------ + + @Test + fun whenStateChangesFromHiddenToShown_contentBecomesVisible() { + var state: BottomSheetState by mutableStateOf(BottomSheetState.Hidden) + + composeTestRule.setContent { + BottomSheet( + state = state, + onDismissRequest = {}, + testTag = testTag + ) { data -> + Text( + text = data, + modifier = Modifier.testTag(testContentTag) + ) + } + } + + composeTestRule.onNodeWithTag(testContentTag).assertDoesNotExist() + + state = BottomSheetState.Shown(testData) + + composeTestRule.onNodeWithTag(testContentTag).assertIsDisplayed() + } + + @Test + fun whenStateChangesFromShownToHidden_contentBecomesInvisible() { + var state: BottomSheetState by mutableStateOf(BottomSheetState.Shown(testData)) + + composeTestRule.setContent { + BottomSheet( + state = state, + onDismissRequest = {}, + testTag = testTag + ) { data -> + Text( + text = data, + modifier = Modifier.testTag(testContentTag) + ) + } + } + + composeTestRule.onNodeWithTag(testContentTag).assertIsDisplayed() + + state = BottomSheetState.Hidden + + composeTestRule.onNodeWithTag(testTag).assertDoesNotExist() + composeTestRule.onNodeWithTag(testContentTag).assertDoesNotExist() + } +} \ No newline at end of file diff --git a/ui/src/androidTest/java/eu/project/design_system/component/CheckboxTest.kt b/ui/src/androidTest/java/eu/project/design_system/component/CheckboxTest.kt new file mode 100644 index 0000000..6abb906 --- /dev/null +++ b/ui/src/androidTest/java/eu/project/design_system/component/CheckboxTest.kt @@ -0,0 +1,75 @@ +package eu.project.design_system.component + +import androidx.compose.ui.test.assertIsOff +import androidx.compose.ui.test.assertIsOn +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performClick +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test + +class CheckboxTest { + + @get:Rule + val composeTestRule = createComposeRule() + + private val testTag = "test_checkbox" + + + +//- State renders correct content ------------------------------------------------------------------ + + @Test + fun whenStateIsSelected_checkboxIsOn() { + composeTestRule.setContent { + Checkbox( + state = CheckboxState.Selected, + onToggle = {}, + testTag = testTag + ) + } + + composeTestRule + .onNodeWithTag(testTag) + .assertIsOn() + } + + @Test + fun whenStateIsUnselected_checkboxIsOff() { + composeTestRule.setContent { + Checkbox( + state = CheckboxState.Unselected, + onToggle = {}, + testTag = testTag + ) + } + + composeTestRule + .onNodeWithTag(testTag) + .assertIsOff() + } + + + +//- Click behavior --------------------------------------------------------------------------------- + + @Test + fun whenClicked_onToggleIsInvoked() { + var toggled = false + + composeTestRule.setContent { + Checkbox( + state = CheckboxState.Unselected, + onToggle = { toggled = true }, + testTag = testTag + ) + } + + composeTestRule + .onNodeWithTag(testTag) + .performClick() + + assertTrue(toggled) + } +} \ No newline at end of file diff --git a/ui/src/androidTest/java/eu/project/design_system/component/DialogTest.kt b/ui/src/androidTest/java/eu/project/design_system/component/DialogTest.kt new file mode 100644 index 0000000..af06961 --- /dev/null +++ b/ui/src/androidTest/java/eu/project/design_system/component/DialogTest.kt @@ -0,0 +1,284 @@ +package eu.project.design_system.component + +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsEnabled +import androidx.compose.ui.test.assertIsNotEnabled +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import eu.project.design_system.TestTag +import eu.project.design_system.component.button.ButtonState +import org.junit.Assert +import org.junit.Rule +import org.junit.Test + +class DialogTest { + + @get:Rule + val composeTestRule = createComposeRule() + + private val testTag = "test_dialog" + private val headline = "Delete Item" + private val supportingText = "Are you sure you want to delete this item?" + private val confirmLabel = "Delete" + private val dismissLabel = "Cancel" + + + +//- Visibility ------------------------------------------------------------------------------------- + + @Test + fun whenStateIsVisible_contentIsDisplayed() { + composeTestRule.setContent { + Dialog( + state = DialogState.Visible(), + headline = headline, + supportingText = supportingText, + dismissButtonLabel = dismissLabel, + onDismiss = {}, + confirmButtonLabel = confirmLabel, + onConfirm = {}, + testTag = testTag + ) + } + + composeTestRule.onNodeWithText(headline).assertIsDisplayed() + composeTestRule.onNodeWithText(supportingText).assertIsDisplayed() + composeTestRule.onNodeWithText(confirmLabel).assertIsDisplayed() + composeTestRule.onNodeWithText(dismissLabel).assertIsDisplayed() + } + + @Test + fun whenStateIsHidden_contentIsNotDisplayed() { + composeTestRule.setContent { + Dialog( + state = DialogState.Hidden, + headline = headline, + supportingText = supportingText, + dismissButtonLabel = dismissLabel, + onDismiss = {}, + confirmButtonLabel = confirmLabel, + onConfirm = {}, + testTag = testTag + ) + } + + composeTestRule.onNodeWithText(headline).assertDoesNotExist() + composeTestRule.onNodeWithText(supportingText).assertDoesNotExist() + composeTestRule.onNodeWithText(confirmLabel).assertDoesNotExist() + composeTestRule.onNodeWithText(dismissLabel).assertDoesNotExist() + } + + + +//- Click behavior --------------------------------------------------------------------------------- + + @Test + fun whenConfirmClicked_onConfirmIsInvoked() { + var confirmed = false + + composeTestRule.setContent { + Dialog( + state = DialogState.Visible(), + headline = headline, + supportingText = supportingText, + dismissButtonLabel = dismissLabel, + onDismiss = {}, + confirmButtonLabel = confirmLabel, + onConfirm = { confirmed = true }, + testTag = testTag + ) + } + + composeTestRule + .onNodeWithTag(TestTag.Component.dialogConfirmButton(testTag)) + .performClick() + + Assert.assertTrue(confirmed) + } + + @Test + fun whenDismissClicked_onDismissIsInvoked() { + var dismissed = false + + composeTestRule.setContent { + Dialog( + state = DialogState.Visible(), + headline = headline, + supportingText = supportingText, + dismissButtonLabel = dismissLabel, + onDismiss = { dismissed = true }, + confirmButtonLabel = confirmLabel, + onConfirm = {}, + testTag = testTag + ) + } + + composeTestRule + .onNodeWithTag(TestTag.Component.dialogDismissButton(testTag)) + .performClick() + + Assert.assertTrue(dismissed) + } + + + +//- Dismissable ------------------------------------------------------------------------------------ + + @Test + fun whenDismissable_onDismissRequestInvokesOnDismiss() { + var dismissed = false + + composeTestRule.setContent { + Dialog( + state = DialogState.Visible(dismissable = true), + headline = headline, + supportingText = supportingText, + dismissButtonLabel = dismissLabel, + onDismiss = { dismissed = true }, + confirmButtonLabel = confirmLabel, + onConfirm = {}, + testTag = testTag + ) + } + + composeTestRule + .onNodeWithTag(TestTag.Component.dialogDismissButton(testTag)) + .performClick() + + Assert.assertTrue(dismissed) + } + + + +//- Button states ---------------------------------------------------------------------------------- + + @Test + fun whenConfirmButtonStateIsEnabled_confirmButtonIsEnabled() { + composeTestRule.setContent { + Dialog( + state = DialogState.Visible(confirmButtonState = ButtonState.Enabled), + headline = headline, + supportingText = supportingText, + dismissButtonLabel = dismissLabel, + onDismiss = {}, + confirmButtonLabel = confirmLabel, + onConfirm = {}, + testTag = testTag + ) + } + + composeTestRule + .onNodeWithTag(TestTag.Component.dialogConfirmButton(testTag)) + .assertIsEnabled() + } + + @Test + fun whenConfirmButtonStateIsDisabled_confirmButtonIsNotEnabled() { + composeTestRule.setContent { + Dialog( + state = DialogState.Visible(confirmButtonState = ButtonState.Disabled), + headline = headline, + supportingText = supportingText, + dismissButtonLabel = dismissLabel, + onDismiss = {}, + confirmButtonLabel = confirmLabel, + onConfirm = {}, + testTag = testTag + ) + } + + composeTestRule + .onNodeWithTag(TestTag.Component.dialogConfirmButton(testTag)) + .assertIsNotEnabled() + } + + @Test + fun whenConfirmButtonStateIsDisabled_onConfirmIsNotInvoked() { + var confirmed = false + + composeTestRule.setContent { + Dialog( + state = DialogState.Visible(confirmButtonState = ButtonState.Disabled), + headline = headline, + supportingText = supportingText, + dismissButtonLabel = dismissLabel, + onDismiss = {}, + confirmButtonLabel = confirmLabel, + onConfirm = { confirmed = true }, + testTag = testTag + ) + } + + composeTestRule + .onNodeWithTag(TestTag.Component.dialogConfirmButton(testTag)) + .performClick() + + Assert.assertFalse(confirmed) + } + + @Test + fun whenDismissButtonStateIsEnabled_dismissButtonIsEnabled() { + composeTestRule.setContent { + Dialog( + state = DialogState.Visible(dismissButtonState = ButtonState.Enabled), + headline = headline, + supportingText = supportingText, + dismissButtonLabel = dismissLabel, + onDismiss = {}, + confirmButtonLabel = confirmLabel, + onConfirm = {}, + testTag = testTag + ) + } + + composeTestRule + .onNodeWithTag(TestTag.Component.dialogDismissButton(testTag)) + .assertIsEnabled() + } + + @Test + fun whenDismissButtonStateIsDisabled_dismissButtonIsNotEnabled() { + composeTestRule.setContent { + Dialog( + state = DialogState.Visible(dismissButtonState = ButtonState.Disabled), + headline = headline, + supportingText = supportingText, + dismissButtonLabel = dismissLabel, + onDismiss = {}, + confirmButtonLabel = confirmLabel, + onConfirm = {}, + testTag = testTag + ) + } + + composeTestRule + .onNodeWithTag(TestTag.Component.dialogDismissButton(testTag)) + .assertIsNotEnabled() + } + + @Test + fun whenDismissButtonStateIsDisabled_onDismissIsNotInvoked() { + var dismissed = false + + composeTestRule.setContent { + Dialog( + state = DialogState.Visible(dismissButtonState = ButtonState.Disabled), + headline = headline, + supportingText = supportingText, + dismissButtonLabel = dismissLabel, + onDismiss = { dismissed = true }, + confirmButtonLabel = confirmLabel, + onConfirm = {}, + testTag = testTag + ) + } + + composeTestRule + .onNodeWithTag(TestTag.Component.dialogDismissButton(testTag)) + .performClick() + + Assert.assertFalse(dismissed) + } +} \ No newline at end of file diff --git a/ui/src/androidTest/java/eu/project/design_system/component/button/FilledButtonTest.kt b/ui/src/androidTest/java/eu/project/design_system/component/button/FilledButtonTest.kt new file mode 100644 index 0000000..01be2ea --- /dev/null +++ b/ui/src/androidTest/java/eu/project/design_system/component/button/FilledButtonTest.kt @@ -0,0 +1,231 @@ +package eu.project.design_system.component.button + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.width +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsEnabled +import androidx.compose.ui.test.assertWidthIsEqualTo +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.unit.dp +import eu.project.design_system.ContentDescription +import eu.project.design_system.TestTag +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test + +class FilledButtonTest { + + @get:Rule + val composeTestRule = createComposeRule() + + + +//- State renders correct content ------------------------------------------------------------------ + + @Test + fun whenStateIsEnabled_labelIsDisplayed() { + composeTestRule.setContent { + FilledButton( + onClick = {}, + label = "Confirm", + type = FilledButtonType.Primary, + size = ButtonSize.Medium, + state = ButtonState.Enabled, + testTag = "test_button" + ) + } + + composeTestRule + .onNodeWithText("Confirm") + .assertIsDisplayed() + } + + @Test + fun whenStateIsDisabled_labelIsDisplayed() { + composeTestRule.setContent { + FilledButton( + onClick = {}, + label = "Confirm", + type = FilledButtonType.Primary, + size = ButtonSize.Medium, + state = ButtonState.Disabled, + testTag = "test_button" + ) + } + + composeTestRule + .onNodeWithText("Confirm") + .assertIsDisplayed() + } + + @Test + fun whenStateIsLoading_loadingIndicatorIsDisplayed() { + composeTestRule.setContent { + FilledButton( + onClick = {}, + label = "Confirm", + type = FilledButtonType.Primary, + size = ButtonSize.Medium, + state = ButtonState.Loading, + testTag = "test_button" + ) + } + + composeTestRule + .onNodeWithTag(TestTag.Component.loadingIndicator("test_button")) + .assertIsDisplayed() + } + + @Test + fun whenStateIsError_errorIconIsDisplayed() { + composeTestRule.setContent { + FilledButton( + onClick = {}, + label = "Confirm", + type = FilledButtonType.Primary, + size = ButtonSize.Medium, + state = ButtonState.Error, + testTag = "test_button" + ) + } + + composeTestRule + .onNodeWithContentDescription(ContentDescription.Icon.ERROR) + .assertIsDisplayed() + } + + @Test + fun whenStateIsSuccess_successIconIsDisplayed() { + composeTestRule.setContent { + FilledButton( + onClick = {}, + label = "Confirm", + type = FilledButtonType.Primary, + size = ButtonSize.Medium, + state = ButtonState.Success, + testTag = "test_button" + ) + } + + composeTestRule + .onNodeWithContentDescription(ContentDescription.Icon.SUCCESS) + .assertIsDisplayed() + } + + + +//- Enabled policy --------------------------------------------------------------------------------- + + @Test + fun whenStateIsEnabled_buttonIsEnabled() { + composeTestRule.setContent { + FilledButton( + onClick = {}, + label = "Confirm", + type = FilledButtonType.Primary, + size = ButtonSize.Medium, + state = ButtonState.Enabled, + testTag = "test_button" + ) + } + + composeTestRule + .onNodeWithTag("test_button") + .assertIsEnabled() + } + + + +//- Click behavior --------------------------------------------------------------------------------- + + @Test + fun whenStateIsEnabled_onClickIsInvoked() { + var clicked = false + + composeTestRule.setContent { + FilledButton( + onClick = { clicked = true }, + label = "Confirm", + type = FilledButtonType.Primary, + size = ButtonSize.Medium, + state = ButtonState.Enabled, + testTag = "test_button" + ) + } + + composeTestRule + .onNodeWithTag("test_button") + .performClick() + + assertTrue(clicked) + } + + @Test + fun whenStateIsNotEnabled_onClickIsNotInvoked() { + val nonEnabledStates = listOf( + ButtonState.Disabled, + ButtonState.Loading, + ButtonState.Error, + ButtonState.Success + ) + + var currentState by mutableStateOf(nonEnabledStates.first()) + var clicked = false + + composeTestRule.setContent { + FilledButton( + onClick = { clicked = true }, + label = "Confirm", + type = FilledButtonType.Primary, + size = ButtonSize.Medium, + state = currentState, + testTag = "test_button" + ) + } + + nonEnabledStates.forEach { state -> + clicked = false + currentState = state + + composeTestRule + .onNodeWithTag("test_button") + .performClick() + + assertFalse("onClick should not fire in state $state", clicked) + } + } + + + +//- Width behavior --------------------------------------------------------------------------------- + + @Test + fun whenFullWidthIsTrue_buttonFillsMaxWidth() { + composeTestRule.setContent { + Box(modifier = Modifier.width(300.dp)) { + FilledButton( + onClick = {}, + label = "Confirm", + type = FilledButtonType.Primary, + size = ButtonSize.Medium, + state = ButtonState.Enabled, + testTag = "test_button", + fullWidth = true + ) + } + } + + composeTestRule + .onNodeWithTag("test_button") + .assertWidthIsEqualTo(300.dp) + } +} \ No newline at end of file diff --git a/ui/src/androidTest/java/eu/project/design_system/component/button/IconButtonTest.kt b/ui/src/androidTest/java/eu/project/design_system/component/button/IconButtonTest.kt new file mode 100644 index 0000000..2eb6d70 --- /dev/null +++ b/ui/src/androidTest/java/eu/project/design_system/component/button/IconButtonTest.kt @@ -0,0 +1,134 @@ +package eu.project.design_system.component.button + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsEnabled +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performClick +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test + +class IconButtonTest { + + @get:Rule + val composeTestRule = createComposeRule() + + private val testIcon = Icons.Default.Add + private val testLabel = "Add Item" + private val testTag = "test_icon_button" + + + +//- State renders correct content ------------------------------------------------------------------ + + @Test + fun whenStateIsEnabled_iconIsDisplayed() { + composeTestRule.setContent { + IconButton( + onClick = {}, + icon = testIcon, + label = testLabel, + type = IconButtonType.Primary, + state = IconButtonState.Enabled, + testTag = testTag + ) + } + + composeTestRule + .onNodeWithContentDescription(testLabel) + .assertIsDisplayed() + } + + @Test + fun whenStateIsDisabled_iconIsDisplayed() { + composeTestRule.setContent { + IconButton( + onClick = {}, + icon = testIcon, + label = testLabel, + type = IconButtonType.Primary, + state = IconButtonState.Disabled, + testTag = testTag + ) + } + + composeTestRule + .onNodeWithContentDescription(testLabel) + .assertIsDisplayed() + } + + + +//- Enabled policy --------------------------------------------------------------------------------- + + @Test + fun whenStateIsEnabled_buttonIsEnabled() { + composeTestRule.setContent { + IconButton( + onClick = {}, + icon = testIcon, + label = testLabel, + type = IconButtonType.Primary, + state = IconButtonState.Enabled, + testTag = testTag + ) + } + + composeTestRule + .onNodeWithTag(testTag) + .assertIsEnabled() + } + + + +//- Click behavior --------------------------------------------------------------------------------- + + @Test + fun whenStateIsEnabled_onClickIsInvoked() { + var clicked = false + + composeTestRule.setContent { + IconButton( + onClick = { clicked = true }, + icon = testIcon, + label = testLabel, + type = IconButtonType.Primary, + state = IconButtonState.Enabled, + testTag = testTag + ) + } + + composeTestRule + .onNodeWithTag(testTag) + .performClick() + + assertTrue(clicked) + } + + @Test + fun whenStateIsDisabled_onClickIsNotInvoked() { + var clicked = false + + composeTestRule.setContent { + IconButton( + onClick = { clicked = true }, + icon = testIcon, + label = testLabel, + type = IconButtonType.Primary, + state = IconButtonState.Disabled, + testTag = testTag + ) + } + + composeTestRule + .onNodeWithTag(testTag) + .performClick() + + assertFalse("onClick should not fire when state is Disabled", clicked) + } +} \ No newline at end of file diff --git a/ui/src/androidTest/java/eu/project/design_system/component/button/OutlinedButtonTest.kt b/ui/src/androidTest/java/eu/project/design_system/component/button/OutlinedButtonTest.kt new file mode 100644 index 0000000..9c3d07b --- /dev/null +++ b/ui/src/androidTest/java/eu/project/design_system/component/button/OutlinedButtonTest.kt @@ -0,0 +1,231 @@ +package eu.project.design_system.component.button + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.width +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsEnabled +import androidx.compose.ui.test.assertWidthIsEqualTo +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.unit.dp +import eu.project.design_system.ContentDescription +import eu.project.design_system.TestTag +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test + +class OutlinedButtonTest { + + @get:Rule + val composeTestRule = createComposeRule() + + + +//- State renders correct content ------------------------------------------------------------------ + + @Test + fun whenStateIsEnabled_labelIsDisplayed() { + composeTestRule.setContent { + OutlinedButton( + onClick = {}, + label = "Outlined", + type = OutlinedButtonType.Secondary, + size = ButtonSize.Medium, + state = ButtonState.Enabled, + testTag = "test_outlined_button" + ) + } + + composeTestRule + .onNodeWithText("Outlined") + .assertIsDisplayed() + } + + @Test + fun whenStateIsDisabled_labelIsDisplayed() { + composeTestRule.setContent { + OutlinedButton( + onClick = {}, + label = "Outlined", + type = OutlinedButtonType.Secondary, + size = ButtonSize.Medium, + state = ButtonState.Disabled, + testTag = "test_outlined_button" + ) + } + + composeTestRule + .onNodeWithText("Outlined") + .assertIsDisplayed() + } + + @Test + fun whenStateIsLoading_loadingIndicatorIsDisplayed() { + composeTestRule.setContent { + OutlinedButton( + onClick = {}, + label = "Outlined", + type = OutlinedButtonType.Secondary, + size = ButtonSize.Medium, + state = ButtonState.Loading, + testTag = "test_outlined_button" + ) + } + + composeTestRule + .onNodeWithTag(TestTag.Component.loadingIndicator("test_outlined_button")) + .assertIsDisplayed() + } + + @Test + fun whenStateIsError_errorIconIsDisplayed() { + composeTestRule.setContent { + OutlinedButton( + onClick = {}, + label = "Outlined", + type = OutlinedButtonType.Secondary, + size = ButtonSize.Medium, + state = ButtonState.Error, + testTag = "test_outlined_button" + ) + } + + composeTestRule + .onNodeWithContentDescription(ContentDescription.Icon.ERROR) + .assertIsDisplayed() + } + + @Test + fun whenStateIsSuccess_successIconIsDisplayed() { + composeTestRule.setContent { + OutlinedButton( + onClick = {}, + label = "Outlined", + type = OutlinedButtonType.Secondary, + size = ButtonSize.Medium, + state = ButtonState.Success, + testTag = "test_outlined_button" + ) + } + + composeTestRule + .onNodeWithContentDescription(ContentDescription.Icon.SUCCESS) + .assertIsDisplayed() + } + + + +//- Enabled policy --------------------------------------------------------------------------------- + + @Test + fun whenStateIsEnabled_buttonIsEnabled() { + composeTestRule.setContent { + OutlinedButton( + onClick = {}, + label = "Outlined", + type = OutlinedButtonType.Secondary, + size = ButtonSize.Medium, + state = ButtonState.Enabled, + testTag = "test_outlined_button" + ) + } + + composeTestRule + .onNodeWithTag("test_outlined_button") + .assertIsEnabled() + } + + + +//- Click behavior --------------------------------------------------------------------------------- + + @Test + fun whenStateIsEnabled_onClickIsInvoked() { + var clicked = false + + composeTestRule.setContent { + OutlinedButton( + onClick = { clicked = true }, + label = "Outlined", + type = OutlinedButtonType.Secondary, + size = ButtonSize.Medium, + state = ButtonState.Enabled, + testTag = "test_outlined_button" + ) + } + + composeTestRule + .onNodeWithTag("test_outlined_button") + .performClick() + + assertTrue(clicked) + } + + @Test + fun whenStateIsNotEnabled_onClickIsNotInvoked() { + val nonEnabledStates = listOf( + ButtonState.Disabled, + ButtonState.Loading, + ButtonState.Error, + ButtonState.Success + ) + + var currentState by mutableStateOf(nonEnabledStates.first()) + var clicked = false + + composeTestRule.setContent { + OutlinedButton( + onClick = { clicked = true }, + label = "Outlined", + type = OutlinedButtonType.Secondary, + size = ButtonSize.Medium, + state = currentState, + testTag = "test_outlined_button" + ) + } + + nonEnabledStates.forEach { state -> + clicked = false + currentState = state + + composeTestRule + .onNodeWithTag("test_outlined_button") + .performClick() + + assertFalse("onClick should not fire in state $state", clicked) + } + } + + + +//- Width behavior --------------------------------------------------------------------------------- + + @Test + fun whenFullWidthIsTrue_buttonFillsMaxWidth() { + composeTestRule.setContent { + Box(modifier = Modifier.width(300.dp)) { + OutlinedButton( + onClick = {}, + label = "Outlined", + type = OutlinedButtonType.Secondary, + size = ButtonSize.Medium, + state = ButtonState.Enabled, + testTag = "test_outlined_button", + fullWidth = true + ) + } + } + + composeTestRule + .onNodeWithTag("test_outlined_button") + .assertWidthIsEqualTo(300.dp) + } +} \ No newline at end of file diff --git a/ui/src/androidTest/java/eu/project/design_system/component/button/TextButtonTest.kt b/ui/src/androidTest/java/eu/project/design_system/component/button/TextButtonTest.kt new file mode 100644 index 0000000..d112cde --- /dev/null +++ b/ui/src/androidTest/java/eu/project/design_system/component/button/TextButtonTest.kt @@ -0,0 +1,231 @@ +package eu.project.design_system.component.button + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.width +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsEnabled +import androidx.compose.ui.test.assertWidthIsEqualTo +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.unit.dp +import eu.project.design_system.ContentDescription +import eu.project.design_system.TestTag +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test + +class TextButtonTest { + + @get:Rule + val composeTestRule = createComposeRule() + + + +//- State renders correct content ------------------------------------------------------------------ + + @Test + fun whenStateIsEnabled_labelIsDisplayed() { + composeTestRule.setContent { + TextButton( + onClick = {}, + label = "Cancel", + type = TextButtonType.Primary, + size = ButtonSize.Medium, + state = ButtonState.Enabled, + testTag = "test_text_button" + ) + } + + composeTestRule + .onNodeWithText("Cancel") + .assertIsDisplayed() + } + + @Test + fun whenStateIsDisabled_labelIsDisplayed() { + composeTestRule.setContent { + TextButton( + onClick = {}, + label = "Cancel", + type = TextButtonType.Primary, + size = ButtonSize.Medium, + state = ButtonState.Disabled, + testTag = "test_text_button" + ) + } + + composeTestRule + .onNodeWithText("Cancel") + .assertIsDisplayed() + } + + @Test + fun whenStateIsLoading_loadingIndicatorIsDisplayed() { + composeTestRule.setContent { + TextButton( + onClick = {}, + label = "Cancel", + type = TextButtonType.Primary, + size = ButtonSize.Medium, + state = ButtonState.Loading, + testTag = "test_text_button" + ) + } + + composeTestRule + .onNodeWithTag(TestTag.Component.loadingIndicator("test_text_button")) + .assertIsDisplayed() + } + + @Test + fun whenStateIsError_errorIconIsDisplayed() { + composeTestRule.setContent { + TextButton( + onClick = {}, + label = "Cancel", + type = TextButtonType.Primary, + size = ButtonSize.Medium, + state = ButtonState.Error, + testTag = "test_text_button" + ) + } + + composeTestRule + .onNodeWithContentDescription(ContentDescription.Icon.ERROR) + .assertIsDisplayed() + } + + @Test + fun whenStateIsSuccess_successIconIsDisplayed() { + composeTestRule.setContent { + TextButton( + onClick = {}, + label = "Cancel", + type = TextButtonType.Primary, + size = ButtonSize.Medium, + state = ButtonState.Success, + testTag = "test_text_button" + ) + } + + composeTestRule + .onNodeWithContentDescription(ContentDescription.Icon.SUCCESS) + .assertIsDisplayed() + } + + + +//- Enabled policy --------------------------------------------------------------------------------- + + @Test + fun whenStateIsEnabled_buttonIsEnabled() { + composeTestRule.setContent { + TextButton( + onClick = {}, + label = "Cancel", + type = TextButtonType.Primary, + size = ButtonSize.Medium, + state = ButtonState.Enabled, + testTag = "test_text_button" + ) + } + + composeTestRule + .onNodeWithTag("test_text_button") + .assertIsEnabled() + } + + + +//- Click behavior --------------------------------------------------------------------------------- + + @Test + fun whenStateIsEnabled_onClickIsInvoked() { + var clicked = false + + composeTestRule.setContent { + TextButton( + onClick = { clicked = true }, + label = "Cancel", + type = TextButtonType.Primary, + size = ButtonSize.Medium, + state = ButtonState.Enabled, + testTag = "test_text_button" + ) + } + + composeTestRule + .onNodeWithTag("test_text_button") + .performClick() + + assertTrue(clicked) + } + + @Test + fun whenStateIsNotEnabled_onClickIsNotInvoked() { + val nonEnabledStates = listOf( + ButtonState.Disabled, + ButtonState.Loading, + ButtonState.Error, + ButtonState.Success + ) + + var currentState by mutableStateOf(nonEnabledStates.first()) + var clicked = false + + composeTestRule.setContent { + TextButton( + onClick = { clicked = true }, + label = "Cancel", + type = TextButtonType.Primary, + size = ButtonSize.Medium, + state = currentState, + testTag = "test_text_button" + ) + } + + nonEnabledStates.forEach { state -> + clicked = false + currentState = state + + composeTestRule + .onNodeWithTag("test_text_button") + .performClick() + + assertFalse("onClick should not fire in state $state", clicked) + } + } + + + +//- Width behavior --------------------------------------------------------------------------------- + + @Test + fun whenFullWidthIsTrue_buttonFillsMaxWidth() { + composeTestRule.setContent { + Box(modifier = Modifier.width(300.dp)) { + TextButton( + onClick = {}, + label = "Cancel", + type = TextButtonType.Primary, + size = ButtonSize.Medium, + state = ButtonState.Enabled, + testTag = "test_text_button", + fullWidth = true + ) + } + } + + composeTestRule + .onNodeWithTag("test_text_button") + .assertWidthIsEqualTo(300.dp) + } +} \ No newline at end of file diff --git a/ui/src/main/java/eu/project/design_system/ContentDescription.kt b/ui/src/main/java/eu/project/design_system/ContentDescription.kt new file mode 100644 index 0000000..94463e1 --- /dev/null +++ b/ui/src/main/java/eu/project/design_system/ContentDescription.kt @@ -0,0 +1,42 @@ +package eu.project.design_system + +/** + * Central registry of accessibility content descriptions for use with + * [androidx.compose.ui.Modifier.semantics] `contentDescription` and + * [androidx.compose.ui.Modifier.clearAndSetSemantics]. + * + * ## Purpose + * Content descriptions are read aloud by TalkBack and other accessibility + * services. Centralising them ensures consistency across the UI and makes + * accessibility audits and updates straightforward. + * + * ## Naming conventions + * - Mirrors the structure of [TestTag] exactly — same object hierarchy, + * same element names — so the two files stay in sync. + * - String values are **human-readable sentences** (capitalised, no trailing + * period) that TalkBack can read naturally, e.g. `"Navigate back"`. + * - Do **not** include the screen name in the string value; the OS provides + * enough context. Keep descriptions short (≤ 5 words where possible). + * + * ## Usage + * ```kotlin + * Icon( + * painter = painterResource(R.drawable.ic_back), + * contentDescription = ContentDescription.HomeScreen.BACK_BUTTON + * ) + * ``` + * + * ## When to provide a description + * - **Always** for actionable elements (buttons, icons, FABs). + * - **Always** for images that convey meaning. + * - Set to `null` for purely decorative elements so TalkBack skips them. + * + * @see TestTag for UI test tag constants. + */ +object ContentDescription { + + object Icon { + const val ERROR = "Icon Error" + const val SUCCESS = "Icon Success" + } +} \ No newline at end of file diff --git a/ui/src/main/java/eu/project/design_system/TestTag.kt b/ui/src/main/java/eu/project/design_system/TestTag.kt new file mode 100644 index 0000000..f2263b0 --- /dev/null +++ b/ui/src/main/java/eu/project/design_system/TestTag.kt @@ -0,0 +1,53 @@ +package eu.project.design_system + +/** + * Central registry of UI test tags for use with [androidx.compose.ui.Modifier.testTag]. + * + * ## Purpose + * Provides a single source of truth for all test tag strings used in UI tests + * (Espresso, Compose UI Test). Centralising them prevents typos, silent collisions, + * and makes refactoring safe — change the constant once, tests follow automatically. + * + * ## Naming conventions + * - Object names use **PascalCase** and reflect the screen or component name + * (e.g. [HomeScreen], [ProductCard]). + * - Constant names use **SCREAMING_SNAKE_CASE** (e.g. `SUBMIT_BUTTON`). + * - String values use **snake_case** and are prefixed with their full path to + * guarantee global uniqueness (e.g. `"home_screen_submit_button"`). + * + * ## Structure rules + * - **Screens** → nest a dedicated object per screen directly inside [TestTag]. + * - **Shared / reusable components** → nest inside a dedicated object and expose + * a function that accepts a unique identifier (e.g. item ID) to avoid collisions + * when the component appears multiple times in the hierarchy. + * - **Do not** define raw string literals outside this file. Always reference a + * constant from here. + * + * ## Usage + * ```kotlin + * // In a composable: + * Modifier.testTag(TestTag.HomeScreen.SUBMIT_BUTTON) + * + * // In a UI test: + * composeTestRule + * .onNodeWithTag(TestTag.HomeScreen.SUBMIT_BUTTON) + * .performClick() + * ``` + * + * ## Adding new tags + * 1. Locate or create the matching nested object for the screen/component. + * 2. Add a `const val` whose string value follows `_` pattern. + * 3. For list/grid items use a function: `fun root(id: String) = "my_card_root_$id"`. + * + * @see ContentDescription for accessibility label constants. + */ +object TestTag { + + object Component { + fun loadingIndicator(parentTag: String) = "${parentTag}_loading_indicator" + fun animatedContent(parentTag: String) = "${parentTag}_animated_content" + + fun dialogConfirmButton(parentTag: String) = "${parentTag}_confirm_text_button" + fun dialogDismissButton(parentTag: String) = "${parentTag}_dismiss_text_button" + } +} \ No newline at end of file diff --git a/ui/src/main/java/eu/project/design_system/component/BottomSheet.kt b/ui/src/main/java/eu/project/design_system/component/BottomSheet.kt new file mode 100644 index 0000000..3dfae7e --- /dev/null +++ b/ui/src/main/java/eu/project/design_system/component/BottomSheet.kt @@ -0,0 +1,59 @@ +package eu.project.design_system.component + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import eu.project.design_system.theme.Radius +import eu.project.design_system.theme.SiaTheme +import eu.project.design_system.theme.Space + +sealed class BottomSheetState { + data object Hidden : BottomSheetState() + data class Shown(val data: T) : BottomSheetState() +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun BottomSheet( + state: BottomSheetState, + onDismissRequest: () -> Unit, + testTag: String, + content: @Composable ColumnScope.(T) -> Unit +) { + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + val isVisible = state is BottomSheetState.Shown + + LaunchedEffect(isVisible) { + if (isVisible) sheetState.show() else sheetState.hide() + } + + if (isVisible || sheetState.isVisible) { + ModalBottomSheet( + modifier = Modifier.testTag(testTag), + onDismissRequest = onDismissRequest, + sheetState = sheetState, + containerColor = SiaTheme.color.surface.background, + shape = RoundedCornerShape(topStart = Radius.R24.value, topEnd = Radius.R24.value), + dragHandle = {} + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = Space.S16.value, vertical = Space.S24.value), + ) { + if (state is BottomSheetState.Shown) { + content(state.data) + } + } + } + } +} \ No newline at end of file diff --git a/ui/src/main/java/eu/project/design_system/component/Checkbox.kt b/ui/src/main/java/eu/project/design_system/component/Checkbox.kt new file mode 100644 index 0000000..394cd6b --- /dev/null +++ b/ui/src/main/java/eu/project/design_system/component/Checkbox.kt @@ -0,0 +1,33 @@ +package eu.project.design_system.component + +import androidx.compose.material3.Checkbox +import androidx.compose.material3.CheckboxDefaults +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import eu.project.design_system.theme.SiaTheme + +enum class CheckboxState { Selected, Unselected } + +@Composable +fun Checkbox( + state: CheckboxState, + onToggle: () -> Unit, + testTag: String +) { + val isSelected = state == CheckboxState.Selected + + val checkboxColors = CheckboxDefaults.colors( + checkedColor = SiaTheme.color.button.primary, + uncheckedColor = SiaTheme.color.text.secondary, + checkmarkColor = SiaTheme.color.text.onPrimary + ) + + Checkbox( + modifier = Modifier + .testTag(testTag), + checked = isSelected, + onCheckedChange = { onToggle() }, + colors = checkboxColors + ) +} \ No newline at end of file diff --git a/ui/src/main/java/eu/project/design_system/component/Dialog.kt b/ui/src/main/java/eu/project/design_system/component/Dialog.kt new file mode 100644 index 0000000..4cb7041 --- /dev/null +++ b/ui/src/main/java/eu/project/design_system/component/Dialog.kt @@ -0,0 +1,79 @@ +package eu.project.design_system.component + +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import eu.project.design_system.TestTag +import eu.project.design_system.component.button.ButtonSize +import eu.project.design_system.component.button.ButtonState +import eu.project.design_system.component.button.TextButton +import eu.project.design_system.component.button.TextButtonType +import eu.project.design_system.theme.SiaTheme + +sealed class DialogState { + data object Hidden: DialogState() + data class Visible( + val dismissable: Boolean = true, + val confirmButtonState: ButtonState = ButtonState.Enabled, + val dismissButtonState: ButtonState = ButtonState.Enabled + ): DialogState() +} + +@Composable +fun Dialog( + state: DialogState, + + headline: String, + supportingText: String, + + dismissButtonLabel: String, + onDismiss: () -> Unit, + + confirmButtonLabel: String, + onConfirm: () -> Unit, + + testTag: String +) { + if (state is DialogState.Visible) { + AlertDialog( + containerColor = SiaTheme.color.surface.background, + onDismissRequest = { if (state.dismissable) { onDismiss() } }, + title = { + Text( + text = headline, + style = SiaTheme.typography.headlineSmall, + color = SiaTheme.color.text.primary + ) + }, + text = { + Text( + text = supportingText, + style = SiaTheme.typography.bodyMedium, + color = SiaTheme.color.text.secondary + ) + }, + confirmButton = { + TextButton( + onClick = onConfirm, + label = confirmButtonLabel, + type = TextButtonType.Destructive, + size = ButtonSize.Small, + state = state.confirmButtonState, + testTag = TestTag.Component.dialogConfirmButton(testTag), + fullWidth = true + ) + }, + dismissButton = { + TextButton( + onClick = onDismiss, + label = dismissButtonLabel, + type = TextButtonType.Primary, + size = ButtonSize.Small, + state = state.dismissButtonState, + testTag = TestTag.Component.dialogDismissButton(testTag), + fullWidth = true + ) + } + ) + } +} \ No newline at end of file diff --git a/ui/src/main/java/eu/project/design_system/component/LoadingIndicator.kt b/ui/src/main/java/eu/project/design_system/component/LoadingIndicator.kt new file mode 100644 index 0000000..3d16b49 --- /dev/null +++ b/ui/src/main/java/eu/project/design_system/component/LoadingIndicator.kt @@ -0,0 +1,32 @@ +package eu.project.design_system.component + +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.platform.testTag +import eu.project.design_system.theme.Opacity +import eu.project.design_system.theme.SiaTheme + +enum class LoadingIndicatorType { Primary, Secondary } + +@Composable +fun LoadingIndicator( + modifier: Modifier = Modifier, + type: LoadingIndicatorType = LoadingIndicatorType.Primary, + testTag: String +) { + CircularProgressIndicator( + modifier = modifier + .testTag(testTag), + color = when(type) { + LoadingIndicatorType.Primary -> SiaTheme.color.icon.primary + LoadingIndicatorType.Secondary -> SiaTheme.color.icon.onPrimary.copy(alpha = Opacity.Disabled.value) + }, + trackColor = when(type) { + LoadingIndicatorType.Primary -> SiaTheme.color.icon.secondary + LoadingIndicatorType.Secondary -> SiaTheme.color.icon.onSecondary.copy(alpha = Opacity.Disabled.value) + }, + strokeCap = StrokeCap.Round + ) +} \ No newline at end of file diff --git a/ui/src/main/java/eu/project/design_system/component/Spacer.kt b/ui/src/main/java/eu/project/design_system/component/Spacer.kt new file mode 100644 index 0000000..157a52c --- /dev/null +++ b/ui/src/main/java/eu/project/design_system/component/Spacer.kt @@ -0,0 +1,18 @@ +package eu.project.design_system.component + +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.width +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import eu.project.design_system.theme.Space + +@Composable +fun HorizontalSpacer(space: Space) { + Spacer(modifier = Modifier.width(space.value)) +} + +@Composable +fun VerticalSpacer(space: Space) { + Spacer(modifier = Modifier.height(space.value)) +} \ No newline at end of file diff --git a/ui/src/main/java/eu/project/design_system/component/button/ButtonUtils.kt b/ui/src/main/java/eu/project/design_system/component/button/ButtonUtils.kt new file mode 100644 index 0000000..a73e781 --- /dev/null +++ b/ui/src/main/java/eu/project/design_system/component/button/ButtonUtils.kt @@ -0,0 +1,102 @@ +package eu.project.design_system.component.button + +import androidx.compose.animation.AnimatedContent +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Check +import androidx.compose.material.icons.rounded.Error +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import eu.project.design_system.ContentDescription +import eu.project.design_system.TestTag +import eu.project.design_system.component.LoadingIndicator +import eu.project.design_system.component.LoadingIndicatorType +import eu.project.design_system.theme.Radius +import eu.project.design_system.theme.SiaTypography +import eu.project.design_system.theme.Space + +enum class ButtonSize { Medium, Small } +enum class ButtonState { Enabled, Disabled, Loading, Error, Success } + +internal fun buttonShape(size: ButtonSize): RoundedCornerShape = when (size) { + ButtonSize.Medium -> RoundedCornerShape(Radius.R16.value) + ButtonSize.Small -> RoundedCornerShape(Radius.R12.value) +} + +internal fun buttonHeight(size: ButtonSize): Dp = when (size) { + ButtonSize.Medium -> 56.dp + ButtonSize.Small -> 40.dp +} + +internal fun buttonTextStyle(size: ButtonSize): TextStyle = when (size) { + ButtonSize.Medium -> SiaTypography.titleMedium + ButtonSize.Small -> SiaTypography.labelLarge +} + +internal fun buttonContentPadding(size: ButtonSize): PaddingValues = when (size) { + ButtonSize.Medium -> PaddingValues(horizontal = Space.S24.value, vertical = Space.S16.value) + ButtonSize.Small -> PaddingValues(horizontal = Space.S16.value, vertical = Space.S8.value) +} + +@Composable +internal fun ButtonAnimatedContent( + label: String, + state: ButtonState, + size: ButtonSize, + testTag: String, + horizontalArrangement: Arrangement.Horizontal = Arrangement.Center +) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = horizontalArrangement, + verticalAlignment = androidx.compose.ui.Alignment.CenterVertically + ) { + AnimatedContent( + targetState = state, + label = TestTag.Component.animatedContent(testTag) + ) { currentState -> + when (currentState) { + ButtonState.Enabled, ButtonState.Disabled -> { + Text( + text = label, + overflow = TextOverflow.Ellipsis, + maxLines = 1, + style = buttonTextStyle(size) + ) + } + ButtonState.Loading -> { + LoadingIndicator( + modifier = Modifier.size(20.dp), + type = LoadingIndicatorType.Secondary, + testTag = TestTag.Component.loadingIndicator(testTag) + ) + } + ButtonState.Error -> { + Icon( + imageVector = Icons.Rounded.Error, + contentDescription = ContentDescription.Icon.ERROR, + modifier = Modifier.size(20.dp) + ) + } + ButtonState.Success -> { + Icon( + imageVector = Icons.Rounded.Check, + contentDescription = ContentDescription.Icon.SUCCESS, + modifier = Modifier.size(20.dp) + ) + } + } + } + } +} \ No newline at end of file diff --git a/ui/src/main/java/eu/project/design_system/component/button/FilledButton.kt b/ui/src/main/java/eu/project/design_system/component/button/FilledButton.kt new file mode 100644 index 0000000..e95f73e --- /dev/null +++ b/ui/src/main/java/eu/project/design_system/component/button/FilledButton.kt @@ -0,0 +1,93 @@ +package eu.project.design_system.component.button + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.wrapContentWidth +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.dp +import eu.project.design_system.TestTag +import eu.project.design_system.theme.Opacity +import eu.project.design_system.theme.SiaTheme + +/** + * A filled button with support for multiple visual types, sizes, and interactive states. + * + * ## State → enabled mapping + * | State | Clickable | + * |----------|-----------| + * | Enabled | ✅ | + * | Disabled | ❌ | + * | Loading | ❌ | + * | Error | ❌ | + * | Success | ❌ | + * + * @param testTag Required for UI testing. See [TestTag.Component]. + */ + +enum class FilledButtonType { Primary, Secondary } + +@Composable +fun FilledButton( + onClick: () -> Unit, + label: String, + type: FilledButtonType, + size: ButtonSize, + state: ButtonState, + testTag: String, + modifier: Modifier = Modifier, + fullWidth: Boolean = false +) { + val isEnabled = state == ButtonState.Enabled + + Button( + onClick = onClick, + modifier = Modifier + .height(buttonHeight(size)) + .then(if (fullWidth) Modifier.fillMaxWidth() else Modifier.wrapContentWidth()) + .testTag(testTag) + .semantics { contentDescription = label } + .then(modifier), + enabled = isEnabled, + shape = buttonShape(size), + border = when (type) { + FilledButtonType.Primary -> null + FilledButtonType.Secondary -> BorderStroke( + width = 1.dp, + color = SiaTheme.color.border.regular + ) + }, + contentPadding = buttonContentPadding(size), + colors = ButtonDefaults.buttonColors( + containerColor = when (type) { + FilledButtonType.Primary -> SiaTheme.color.button.primary + FilledButtonType.Secondary -> SiaTheme.color.button.secondary + }, + contentColor = when (type) { + FilledButtonType.Primary -> SiaTheme.color.text.onPrimary + FilledButtonType.Secondary -> SiaTheme.color.text.onSecondary + }, + disabledContainerColor = when (type) { + FilledButtonType.Primary -> SiaTheme.color.button.primary.copy(alpha = Opacity.Disabled.value) + FilledButtonType.Secondary -> SiaTheme.color.button.secondary.copy(alpha = Opacity.Disabled.value) + }, + disabledContentColor = when (type) { + FilledButtonType.Primary -> SiaTheme.color.text.onPrimary.copy(alpha = Opacity.Disabled.value) + FilledButtonType.Secondary -> SiaTheme.color.text.onSecondary.copy(alpha = Opacity.Disabled.value) + } + ) + ) { + ButtonAnimatedContent( + label = label, + state = state, + size = size, + testTag = testTag + ) + } +} \ No newline at end of file diff --git a/ui/src/main/java/eu/project/design_system/component/button/IconButton.kt b/ui/src/main/java/eu/project/design_system/component/button/IconButton.kt new file mode 100644 index 0000000..79065af --- /dev/null +++ b/ui/src/main/java/eu/project/design_system/component/button/IconButton.kt @@ -0,0 +1,50 @@ +package eu.project.design_system.component.button + +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.IconButtonDefaults +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import eu.project.design_system.theme.Opacity +import eu.project.design_system.theme.SiaTheme + +enum class IconButtonType { Primary, Secondary, Destructive } +enum class IconButtonState { Enabled, Disabled } + +@Composable +fun IconButton( + onClick: () -> Unit, + icon: ImageVector, + label: String, + type: IconButtonType, + state: IconButtonState, + testTag: String, + modifier: Modifier = Modifier +) { + val contentColor = when (type) { + IconButtonType.Primary -> SiaTheme.color.text.primary + IconButtonType.Secondary -> SiaTheme.color.text.secondary + IconButtonType.Destructive -> SiaTheme.color.text.destructive + } + + IconButton( + onClick = onClick, + modifier = modifier + .testTag(testTag) + .semantics { contentDescription = label }, + enabled = state == IconButtonState.Enabled, + colors = IconButtonDefaults.iconButtonColors( + contentColor = contentColor, + disabledContentColor = contentColor.copy(alpha = Opacity.Disabled.value) + ) + ) { + Icon( + imageVector = icon, + contentDescription = null + ) + } +} \ No newline at end of file diff --git a/ui/src/main/java/eu/project/design_system/component/button/OutlinedButton.kt b/ui/src/main/java/eu/project/design_system/component/button/OutlinedButton.kt new file mode 100644 index 0000000..0b58162 --- /dev/null +++ b/ui/src/main/java/eu/project/design_system/component/button/OutlinedButton.kt @@ -0,0 +1,73 @@ +package eu.project.design_system.component.button + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.wrapContentWidth +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.OutlinedButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.dp +import eu.project.design_system.theme.Opacity +import eu.project.design_system.theme.SiaTheme + +enum class OutlinedButtonType { Secondary, Destructive } + +@Composable +fun OutlinedButton( + onClick: () -> Unit, + label: String, + type: OutlinedButtonType, + size: ButtonSize, + state: ButtonState, + testTag: String, + modifier: Modifier = Modifier, + fullWidth: Boolean = false, + leftAligned: Boolean = false +) { + val contentColor = when (type) { + OutlinedButtonType.Secondary -> SiaTheme.color.text.secondary + OutlinedButtonType.Destructive -> SiaTheme.color.text.destructive + } + + val borderColor = when (type) { + OutlinedButtonType.Secondary -> SiaTheme.color.border.regular + OutlinedButtonType.Destructive -> SiaTheme.color.border.destructive + } + + val isFullWidth = fullWidth || leftAligned + + OutlinedButton( + onClick = onClick, + modifier = Modifier + .height(buttonHeight(size)) + .then(if (isFullWidth) Modifier.fillMaxWidth() else Modifier.wrapContentWidth()) + .testTag(testTag) + .semantics { contentDescription = label } + .then(modifier), + enabled = state == ButtonState.Enabled, + shape = buttonShape(size), + contentPadding = buttonContentPadding(size), + border = BorderStroke( + width = 1.dp, + color = if (state == ButtonState.Enabled) borderColor else borderColor.copy(alpha = Opacity.Disabled.value) + ), + colors = ButtonDefaults.outlinedButtonColors( + contentColor = contentColor, + disabledContentColor = contentColor.copy(alpha = Opacity.Disabled.value) + ) + ) { + ButtonAnimatedContent( + label = label, + state = state, + size = size, + testTag = testTag, + horizontalArrangement = if (leftAligned) Arrangement.Start else Arrangement.Center + ) + } +} \ No newline at end of file diff --git a/ui/src/main/java/eu/project/design_system/component/button/TextButton.kt b/ui/src/main/java/eu/project/design_system/component/button/TextButton.kt new file mode 100644 index 0000000..115ba12 --- /dev/null +++ b/ui/src/main/java/eu/project/design_system/component/button/TextButton.kt @@ -0,0 +1,61 @@ +package eu.project.design_system.component.button + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.wrapContentWidth +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import eu.project.design_system.theme.Opacity +import eu.project.design_system.theme.SiaTheme + +enum class TextButtonType { Primary, Secondary, Destructive } + +@Composable +fun TextButton( + onClick: () -> Unit, + label: String, + type: TextButtonType, + size: ButtonSize, + state: ButtonState, + testTag: String, + modifier: Modifier = Modifier, + fullWidth: Boolean = false, + leftAligned: Boolean = false +) { + val contentColor = when (type) { + TextButtonType.Primary -> SiaTheme.color.text.primary + TextButtonType.Secondary -> SiaTheme.color.text.secondary + TextButtonType.Destructive -> SiaTheme.color.text.destructive + } + + TextButton( + onClick = onClick, + modifier = Modifier + .height(buttonHeight(size)) + .then(if (fullWidth) Modifier.fillMaxWidth() else Modifier.wrapContentWidth()) + .testTag(testTag) + .semantics { contentDescription = label } + .then(modifier), + enabled = state == ButtonState.Enabled, + shape = buttonShape(size), + contentPadding = buttonContentPadding(size), + colors = ButtonDefaults.textButtonColors( + contentColor = contentColor, + disabledContentColor = contentColor.copy(alpha = Opacity.Disabled.value) + ) + ) { + ButtonAnimatedContent( + label = label, + state = state, + size = size, + testTag = testTag, + horizontalArrangement = if (leftAligned) Arrangement.Start else Arrangement.Center + ) + } +} \ No newline at end of file diff --git a/ui/src/main/java/eu/project/design_system/theme/Color.kt b/ui/src/main/java/eu/project/design_system/theme/Color.kt new file mode 100644 index 0000000..40e178f --- /dev/null +++ b/ui/src/main/java/eu/project/design_system/theme/Color.kt @@ -0,0 +1,141 @@ +package eu.project.design_system.theme + +import androidx.compose.ui.graphics.Color + +internal object PrimitiveColorsDarkTheme { + val neutral950 = Color(0xFF161616) + val neutral900 = Color(0xFF1E1E1E) + val neutral800 = Color(0xFF2A2A2A) + val neutral300 = Color(0xFFB3B3B3) + val neutral50 = Color(0xFFDBDBDB) + val red500 = Color(0xFFE5484D) + val yellow500 = Color(0xFFE5A448) +} + +data class AppColors( + val surface: SurfaceColors, + val text: TextColors, + val border: BorderColors, + val icon: IconColors, + val button: ButtonColors, + val outlinedTextField: OutlinedTextFieldColors +) + +data class SurfaceColors( + val background: Color, + val card: Color +) + +data class TextColors( + val primary: Color, + val secondary: Color, + val onPrimary: Color, + val onSecondary: Color, + val destructive: Color, + val highlighted: Color, +) + +data class BorderColors( + val regular: Color, + val destructive: Color +) + +data class IconColors( + val primary: Color, + val secondary: Color, + val onPrimary: Color, + val onSecondary: Color, + val destructive: Color +) + +data class ButtonColors( + val primary: Color, + val secondary: Color, +) + +data class OutlinedTextFieldColors( + val text: TextFieldTextColors,val container: TextFieldContainerColors, + val border: TextFieldBorderColors, + val cursor: Color, + val errorCursor: Color +) { + data class TextFieldTextColors( + val focused: Color, + val unfocused: Color, + val disabled: Color, + val error: Color, + val label: Color, + val placeholder: Color + ) + + data class TextFieldContainerColors( + val focused: Color, + val unfocused: Color, + val disabled: Color, + val error: Color + ) + + data class TextFieldBorderColors( + val focused: Color, + val unfocused: Color, + val disabled: Color, + val error: Color + ) +} + +fun siaDarkColors(): AppColors { + val colors = PrimitiveColorsDarkTheme + return AppColors( + surface = SurfaceColors( + background = colors.neutral900, + card = colors.neutral950 + ), + text = TextColors( + primary = colors.neutral50, + secondary = colors.neutral300, + onPrimary = colors.neutral800, + onSecondary = colors.neutral300, + destructive = colors.red500, + highlighted = colors.yellow500 + ), + border = BorderColors( + regular = colors.neutral800, + destructive = colors.red500 + ), + icon = IconColors( + primary = colors.neutral50, + secondary = colors.neutral300, + onPrimary = colors.neutral800, + onSecondary = colors.neutral300, + destructive = colors.red500 + ), + button = ButtonColors( + primary = colors.neutral50, + secondary = colors.neutral950 + ), + outlinedTextField = OutlinedTextFieldColors( + text = OutlinedTextFieldColors.TextFieldTextColors( + focused = colors.neutral50, + unfocused = colors.neutral300.copy(alpha = Opacity.Unfocused.value), + disabled = colors.neutral50.copy(alpha = Opacity.Disabled.value), + error = colors.red500, + label = colors.neutral300, + placeholder = colors.neutral300 + ), + container = OutlinedTextFieldColors.TextFieldContainerColors( + focused = colors.neutral950, + unfocused = colors.neutral950.copy(alpha = Opacity.Unfocused.value), + disabled = colors.neutral950.copy(alpha = Opacity.Disabled.value), + error = colors.red500.copy(alpha = Opacity.Disabled.value) + ), + border = OutlinedTextFieldColors.TextFieldBorderColors( + focused = colors.neutral50, + unfocused = colors.neutral50.copy(alpha = Opacity.Unfocused.value), + disabled = colors.neutral50.copy(alpha = Opacity.Disabled.value), + error = colors.red500.copy(alpha = Opacity.Disabled.value) + ), + cursor = colors.neutral50, + errorCursor = colors.red500 + ) + ) +} \ No newline at end of file diff --git a/ui/src/main/java/eu/project/design_system/theme/Opacity.kt b/ui/src/main/java/eu/project/design_system/theme/Opacity.kt new file mode 100644 index 0000000..e82f708 --- /dev/null +++ b/ui/src/main/java/eu/project/design_system/theme/Opacity.kt @@ -0,0 +1,6 @@ +package eu.project.design_system.theme + +enum class Opacity(val value: Float) { + Unfocused(value = 0.7f), + Disabled(value = 0.8f) +} \ No newline at end of file diff --git a/ui/src/main/java/eu/project/design_system/theme/Radius.kt b/ui/src/main/java/eu/project/design_system/theme/Radius.kt new file mode 100644 index 0000000..e3ae897 --- /dev/null +++ b/ui/src/main/java/eu/project/design_system/theme/Radius.kt @@ -0,0 +1,11 @@ +package eu.project.design_system.theme + +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp + +enum class Radius(val value: Dp) { + R4(4.dp), + R12(12.dp), + R16(16.dp), + R24(24.dp) +} \ No newline at end of file diff --git a/ui/src/main/java/eu/project/design_system/theme/Space.kt b/ui/src/main/java/eu/project/design_system/theme/Space.kt new file mode 100644 index 0000000..017d10a --- /dev/null +++ b/ui/src/main/java/eu/project/design_system/theme/Space.kt @@ -0,0 +1,16 @@ +package eu.project.design_system.theme + +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp + +enum class Space(val value: Dp) { + S4(4.dp), + S8(8.dp), + S12(12.dp), + S16(16.dp), + S24(24.dp), + S32(32.dp), + S40(40.dp), + S48(48.dp), + S64(64.dp) +} \ No newline at end of file diff --git a/ui/src/main/java/eu/project/design_system/theme/Theme.kt b/ui/src/main/java/eu/project/design_system/theme/Theme.kt new file mode 100644 index 0000000..14e4f39 --- /dev/null +++ b/ui/src/main/java/eu/project/design_system/theme/Theme.kt @@ -0,0 +1,35 @@ +package eu.project.design_system.theme + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.ReadOnlyComposable +import androidx.compose.runtime.staticCompositionLocalOf + +val LocalAppColors = staticCompositionLocalOf { siaDarkColors() } +val LocalAppTypography = staticCompositionLocalOf { SiaTypography } + +object SiaTheme { + + val color: AppColors + @Composable + @ReadOnlyComposable + get() = LocalAppColors.current + + val typography + @Composable + @ReadOnlyComposable + get() = LocalAppTypography.current +} + +@Composable +fun SiaTheme( + colors: AppColors = siaDarkColors(), + content: @Composable () -> Unit +) { + CompositionLocalProvider( + LocalAppColors provides colors, + LocalAppTypography provides SiaTypography + ) { + content() + } +} \ No newline at end of file diff --git a/ui/src/main/java/eu/project/design_system/theme/Typography.kt b/ui/src/main/java/eu/project/design_system/theme/Typography.kt new file mode 100644 index 0000000..2aaf08c --- /dev/null +++ b/ui/src/main/java/eu/project/design_system/theme/Typography.kt @@ -0,0 +1,127 @@ +package eu.project.design_system.theme + +import androidx.compose.material3.Typography +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.Font +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.sp +import eu.project.ui.R + +private val RobotoFontFamily = FontFamily( + Font(R.font.roboto_medium, FontWeight.Medium), + Font(R.font.roboto_normal, FontWeight.Normal) +) + +private val RobotoSlabFontFamily = FontFamily( + Font(R.font.roboto_slab_bold, FontWeight.Bold), + Font(R.font.roboto_slab_semibold, FontWeight.SemiBold) +) + +val SiaTypography = Typography( + displayLarge = TextStyle( + fontFamily = RobotoSlabFontFamily, + fontWeight = FontWeight.Bold, + fontSize = 57.sp, + lineHeight = 64.sp, + letterSpacing = (-0.25).sp + ), + displayMedium = TextStyle( + fontFamily = RobotoSlabFontFamily, + fontWeight = FontWeight.Bold, + fontSize = 45.sp, + lineHeight = 52.sp, + letterSpacing = 0.sp + ), + displaySmall = TextStyle( + fontFamily = RobotoSlabFontFamily, + fontWeight = FontWeight.Bold, + fontSize = 36.sp, + lineHeight = 44.sp, + letterSpacing = 0.sp + ), + headlineLarge = TextStyle( + fontFamily = RobotoSlabFontFamily, + fontWeight = FontWeight.SemiBold, + fontSize = 32.sp, + lineHeight = 40.sp, + letterSpacing = 0.sp + ), + headlineMedium = TextStyle( + fontFamily = RobotoSlabFontFamily, + fontWeight = FontWeight.SemiBold, + fontSize = 28.sp, + lineHeight = 36.sp, + letterSpacing = 0.sp + ), + headlineSmall = TextStyle( + fontFamily = RobotoSlabFontFamily, + fontWeight = FontWeight.SemiBold, + fontSize = 24.sp, + lineHeight = 32.sp, + letterSpacing = 0.sp + ), + titleLarge = TextStyle( + fontFamily = RobotoFontFamily, + fontWeight = FontWeight.SemiBold, + fontSize = 22.sp, + lineHeight = 28.sp, + letterSpacing = 0.sp + ), + titleMedium = TextStyle( + fontFamily = RobotoFontFamily, + fontWeight = FontWeight.Medium, + fontSize = 16.sp, + lineHeight = 24.sp, + letterSpacing = 0.15.sp + ), + titleSmall = TextStyle( + fontFamily = RobotoFontFamily, + fontWeight = FontWeight.Medium, + fontSize = 14.sp, + lineHeight = 20.sp, + letterSpacing = 0.1.sp + ), + bodyLarge = TextStyle( + fontFamily = RobotoFontFamily, + fontWeight = FontWeight.Normal, + fontSize = 16.sp, + lineHeight = 24.sp, + letterSpacing = 0.5.sp + ), + bodyMedium = TextStyle( + fontFamily = RobotoFontFamily, + fontWeight = FontWeight.Normal, + fontSize = 14.sp, + lineHeight = 20.sp, + letterSpacing = 0.25.sp + ), + bodySmall = TextStyle( + fontFamily = RobotoFontFamily, + fontWeight = FontWeight.Normal, + fontSize = 12.sp, + lineHeight = 16.sp, + letterSpacing = 0.4.sp + ), + labelLarge = TextStyle( + fontFamily = RobotoFontFamily, + fontWeight = FontWeight.Medium, + fontSize = 14.sp, + lineHeight = 20.sp, + letterSpacing = 0.1.sp + ), + labelMedium = TextStyle( + fontFamily = RobotoFontFamily, + fontWeight = FontWeight.Medium, + fontSize = 12.sp, + lineHeight = 16.sp, + letterSpacing = 0.5.sp + ), + labelSmall = TextStyle( + fontFamily = RobotoFontFamily, + fontWeight = FontWeight.Medium, + fontSize = 11.sp, + lineHeight = 16.sp, + letterSpacing = 0.5.sp + ), +) \ No newline at end of file diff --git a/ui/src/main/res/font/roboto_medium.ttf b/ui/src/main/res/font/roboto_medium.ttf new file mode 100644 index 0000000..3e87dbd Binary files /dev/null and b/ui/src/main/res/font/roboto_medium.ttf differ diff --git a/ui/src/main/res/font/roboto_normal.ttf b/ui/src/main/res/font/roboto_normal.ttf new file mode 100644 index 0000000..440843a Binary files /dev/null and b/ui/src/main/res/font/roboto_normal.ttf differ diff --git a/ui/src/main/res/font/roboto_slab_bold.ttf b/ui/src/main/res/font/roboto_slab_bold.ttf new file mode 100644 index 0000000..87b8e2d Binary files /dev/null and b/ui/src/main/res/font/roboto_slab_bold.ttf differ diff --git a/ui/src/main/res/font/roboto_slab_semibold.ttf b/ui/src/main/res/font/roboto_slab_semibold.ttf new file mode 100644 index 0000000..002b4c2 Binary files /dev/null and b/ui/src/main/res/font/roboto_slab_semibold.ttf differ