diff --git a/app/src/main/java/me/rezapour/intervaltimer/MainActivity.kt b/app/src/main/java/me/rezapour/intervaltimer/MainActivity.kt index e5b8be7..a7cbdd4 100644 --- a/app/src/main/java/me/rezapour/intervaltimer/MainActivity.kt +++ b/app/src/main/java/me/rezapour/intervaltimer/MainActivity.kt @@ -4,7 +4,7 @@ import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge -import me.rezapour.designsystem.theme.IntervalTimerTheme +import me.rezapour.designsystem.theme.IniTheme import me.rezapour.intervaltimer.compose.AppRoot class MainActivity : ComponentActivity() { @@ -12,7 +12,7 @@ class MainActivity : ComponentActivity() { super.onCreate(savedInstanceState) enableEdgeToEdge() setContent { - IntervalTimerTheme { + IniTheme { AppRoot() } } diff --git a/app/src/main/java/me/rezapour/intervaltimer/compose/AppRoot.kt b/app/src/main/java/me/rezapour/intervaltimer/compose/AppRoot.kt index 121f9e5..4e5406c 100644 --- a/app/src/main/java/me/rezapour/intervaltimer/compose/AppRoot.kt +++ b/app/src/main/java/me/rezapour/intervaltimer/compose/AppRoot.kt @@ -22,6 +22,6 @@ fun AppRoot() { backStack.add(AddTimerScreen) }) } - addTimerScreen() + addTimerScreen(backStack) }) } \ No newline at end of file diff --git a/app/src/main/java/me/rezapour/intervaltimer/compose/MainScreen.kt b/app/src/main/java/me/rezapour/intervaltimer/compose/MainScreen.kt index 7d8047a..4a8329e 100644 --- a/app/src/main/java/me/rezapour/intervaltimer/compose/MainScreen.kt +++ b/app/src/main/java/me/rezapour/intervaltimer/compose/MainScreen.kt @@ -3,11 +3,10 @@ package me.rezapour.intervaltimer.compose import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.material3.Button -import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import me.rezapour.designsystem.components.button.IniButtonPicker @Composable @@ -17,9 +16,9 @@ fun MainScreen( Box(modifier = Modifier.fillMaxSize()) { Column(modifier = Modifier.align(Alignment.Center)) { - Button(onClick = { + IniButtonPicker (onClick = { onAddTimerClicked() - }) { Text(text = "AddTimer") } + }) } } diff --git a/core/common/src/main/java/me/rezapour/common/extentionfunctions/StringExtensionFunctions.kt b/core/common/src/main/java/me/rezapour/common/extentionfunctions/StringExtensionFunctions.kt deleted file mode 100644 index a6ba7c6..0000000 --- a/core/common/src/main/java/me/rezapour/common/extentionfunctions/StringExtensionFunctions.kt +++ /dev/null @@ -1,10 +0,0 @@ -package me.rezapour.common.extentionfunctions - -fun String.toIntOrZero(): Int = this.toIntOrNull() ?: 0 - -fun String.toLongOrZero(): Long = this.toLongOrNull() ?: 0L - -fun String.digitOnly(maxLen: Int? = null): String { - val digits = this.filter { it.isDigit() } - return if (maxLen != null) digits.take(maxLen) else digits -} \ No newline at end of file diff --git a/core/designsystem/src/main/java/me/rezapour/designsystem/components/button/IniButton.kt b/core/designsystem/src/main/java/me/rezapour/designsystem/components/button/IniButton.kt new file mode 100644 index 0000000..c9e18fe --- /dev/null +++ b/core/designsystem/src/main/java/me/rezapour/designsystem/components/button/IniButton.kt @@ -0,0 +1,54 @@ +package me.rezapour.designsystem.components.button + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.dp +import me.rezapour.designsystem.theme.IniTheme +import me.rezapour.designsystem.util.IniPreview +import me.rezapour.designsystem.R as res + + +@Composable +fun IniButtonPicker( + modifier: Modifier = Modifier, + increaseMode: Boolean = true, + onClick: () -> Unit +) { + + IconButton( + modifier = modifier + .size(54.dp) + .clip(RoundedCornerShape(IniTheme.appShapes.medium)) + .background( + color = IniTheme.colors.primaryContainer, + ), + onClick = onClick + ) { + Icon( + painter = if (increaseMode) + painterResource(res.drawable.ic_plus) + else + painterResource(res.drawable.ic_minus), + contentDescription = null, + tint = IniTheme.colors.primary, + ) + } +} + +@IniPreview +@Composable +fun IniButtonPreview() { + IniTheme() { + IniButtonPicker(increaseMode = true) { + + } + } + +} diff --git a/core/designsystem/src/main/java/me/rezapour/designsystem/theme/Color.kt b/core/designsystem/src/main/java/me/rezapour/designsystem/theme/Color.kt index 6df8e9e..3ebb546 100644 --- a/core/designsystem/src/main/java/me/rezapour/designsystem/theme/Color.kt +++ b/core/designsystem/src/main/java/me/rezapour/designsystem/theme/Color.kt @@ -2,10 +2,46 @@ package me.rezapour.designsystem.theme import androidx.compose.ui.graphics.Color -val Purple80 = Color(0xFFD0BCFF) -val PurpleGrey80 = Color(0xFFCCC2DC) -val Pink80 = Color(0xFFEFB8C8) +// Brand / Accent palette +val GreenPrimary = Color(0xFF22C55E) +val GreenPrimaryDark = Color(0xFF15803D) +val GreenContainerDark = Color(0xFF163320) +val GreenContainerLight = Color(0xFFDCFCE7) -val Purple40 = Color(0xFF6650a4) -val PurpleGrey40 = Color(0xFF625b71) -val Pink40 = Color(0xFF7D5260) \ No newline at end of file +val OrangeSecondary = Color(0xFFF97316) +val OrangeSecondaryDark = Color(0xFFC2410C) +val OrangeContainerDark = Color(0xFF3A2314) +val OrangeContainerLight = Color(0xFFFFE0CC) + +val SkyTertiary = Color(0xFF0EA5E9) +val SkyTertiaryDark = Color(0xFF0369A1) +val SkyContainerDark = Color(0xFF132C3A) +val SkyContainerLight = Color(0xFFE0F2FE) + +// Neutral dark surfaces +val BackgroundDark = Color(0xFF0F172A) +val SurfaceDark = Color(0xFF1E293B) +val SurfaceVariantDark = Color(0xFF1A2236) +val OutlineDark = Color(0xFF334155) +val OutlineVariantDark = Color(0xFF475569) + +// Neutral light surfaces +val BackgroundLight = Color(0xFFF8FAFC) +val SurfaceLight = Color(0xFFFFFFFF) +val SurfaceVariantLight = Color(0xFFE2E8F0) +val OutlineLight = Color(0xFF94A3B8) +val OutlineVariantLight = Color(0xFFCBD5E1) + +// Text / content +val OnDark = Color(0xFFF8FAFC) +val OnLight = Color(0xFF0F172A) +val MutedDark = Color(0xFF94A3B8) +val MutedLight = Color(0xFF64748B) + +// Error +val ErrorLight = Color(0xFFB3261E) +val ErrorDark = Color(0xFFFFB4AB) +val ErrorContainerLight = Color(0xFFF9DEDC) +val ErrorContainerDark = Color(0xFF8C1D18) +val OnErrorLight = Color(0xFFFFFFFF) +val OnErrorDark = Color(0xFF690005) diff --git a/core/designsystem/src/main/java/me/rezapour/designsystem/theme/Theme.kt b/core/designsystem/src/main/java/me/rezapour/designsystem/theme/Theme.kt index 897174f..04c4e8a 100644 --- a/core/designsystem/src/main/java/me/rezapour/designsystem/theme/Theme.kt +++ b/core/designsystem/src/main/java/me/rezapour/designsystem/theme/Theme.kt @@ -1,42 +1,93 @@ package me.rezapour.designsystem.theme import android.os.Build -import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.ColorScheme import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Shapes +import androidx.compose.material3.Typography import androidx.compose.material3.darkColorScheme import androidx.compose.material3.dynamicDarkColorScheme import androidx.compose.material3.dynamicLightColorScheme import androidx.compose.material3.lightColorScheme import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.ReadOnlyComposable +import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext private val DarkColorScheme = darkColorScheme( - primary = Purple80, - secondary = PurpleGrey80, - tertiary = Pink80 + primary = GreenPrimary, + onPrimary = OnLight, + primaryContainer = GreenContainerDark, + onPrimaryContainer = GreenContainerLight, + + secondary = OrangeSecondary, + onSecondary = OnLight, + secondaryContainer = OrangeContainerDark, + onSecondaryContainer = Color(0xFFFFDCC2), + + tertiary = SkyTertiary, + onTertiary = OnLight, + tertiaryContainer = SkyContainerDark, + onTertiaryContainer = Color(0xFFBFE9FF), + + background = BackgroundDark, + onBackground = OnDark, + + surface = SurfaceDark, + onSurface = OnDark, + + surfaceVariant = SurfaceVariantDark, + onSurfaceVariant = MutedDark, + + outline = OutlineDark, + outlineVariant = OutlineVariantDark, + + error = ErrorDark, + onError = OnErrorDark, + errorContainer = ErrorContainerDark, + onErrorContainer = Color(0xFFFFDAD6), ) private val LightColorScheme = lightColorScheme( - primary = Purple40, - secondary = PurpleGrey40, - tertiary = Pink40 - - /* Other default colors to override - background = Color(0xFFFFFBFE), - surface = Color(0xFFFFFBFE), + primary = GreenPrimaryDark, onPrimary = Color.White, + primaryContainer = GreenContainerLight, + onPrimaryContainer = Color(0xFF052E16), + + secondary = OrangeSecondaryDark, onSecondary = Color.White, + secondaryContainer = OrangeContainerLight, + onSecondaryContainer = Color(0xFF431407), + + tertiary = SkyTertiaryDark, onTertiary = Color.White, - onBackground = Color(0xFF1C1B1F), - onSurface = Color(0xFF1C1B1F), - */ + tertiaryContainer = SkyContainerLight, + onTertiaryContainer = Color(0xFF082F49), + + background = BackgroundLight, + onBackground = OnLight, + + surface = SurfaceLight, + onSurface = OnLight, + + surfaceVariant = SurfaceVariantLight, + onSurfaceVariant = MutedLight, + + outline = OutlineLight, + outlineVariant = OutlineVariantLight, + + error = ErrorLight, + onError = OnErrorLight, + errorContainer = ErrorContainerLight, + onErrorContainer = Color(0xFF410E0B), ) @Composable -fun IntervalTimerTheme( - darkTheme: Boolean = isSystemInDarkTheme(), +fun IniTheme( + darkTheme: Boolean = true, // Dynamic color is available on Android 12+ - dynamicColor: Boolean = true, + dynamicColor: Boolean = false, content: @Composable () -> Unit ) { val colorScheme = when { @@ -49,9 +100,38 @@ fun IntervalTimerTheme( else -> LightColorScheme } - MaterialTheme( - colorScheme = colorScheme, - typography = Typography, - content = content - ) -} \ No newline at end of file + val spacing = Spacing() + val appShapes = AppShapes() + val sizes = Sizes() + + CompositionLocalProvider( + LocalSpacing provides spacing, + LocalShapes provides appShapes, + LocalSizes provides sizes + ) { + MaterialTheme( + colorScheme = colorScheme, + typography = Typography, + content = content + ) + } +} + +object IniTheme { + val spacing: Spacing + @Composable @ReadOnlyComposable get() = LocalSpacing.current + val sizes: Sizes + @Composable @ReadOnlyComposable get() = LocalSizes.current + + val appShapes: AppShapes + @Composable @ReadOnlyComposable get() = LocalShapes.current + + val colors: ColorScheme + @Composable @ReadOnlyComposable get() = MaterialTheme.colorScheme + + val typography: Typography + @Composable @ReadOnlyComposable get() = MaterialTheme.typography + + val shapes: Shapes + @Composable @ReadOnlyComposable get() = MaterialTheme.shapes +} diff --git a/core/designsystem/src/main/java/me/rezapour/designsystem/theme/Tokens.kt b/core/designsystem/src/main/java/me/rezapour/designsystem/theme/Tokens.kt new file mode 100644 index 0000000..165dc85 --- /dev/null +++ b/core/designsystem/src/main/java/me/rezapour/designsystem/theme/Tokens.kt @@ -0,0 +1,34 @@ +package me.rezapour.designsystem.theme + +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp + +val LocalSpacing = staticCompositionLocalOf { Spacing() } +val LocalShapes = staticCompositionLocalOf { AppShapes() } +val LocalSizes = staticCompositionLocalOf { Sizes() } + +@Immutable +data class Spacing( + val xs: Dp = 4.dp, + val s: Dp = 8.dp, + val m: Dp = 16.dp, + val l: Dp = 24.dp, + val xl: Dp = 32.dp +) + +@Immutable +data class AppShapes( + val extraLarge: Dp = 24.dp, + val large: Dp = 16.dp, + val medium: Dp = 12.dp, + val circle: Float = 0.5f // 50% +) + +@Immutable +data class Sizes( + val touchTarget: Dp = 48.dp, + val primaryFab: Dp = 80.dp, + val secondaryFab: Dp = 64.dp +) \ No newline at end of file diff --git a/core/designsystem/src/main/java/me/rezapour/designsystem/theme/Type.kt b/core/designsystem/src/main/java/me/rezapour/designsystem/theme/Type.kt index 0dc411c..56ebc2b 100644 --- a/core/designsystem/src/main/java/me/rezapour/designsystem/theme/Type.kt +++ b/core/designsystem/src/main/java/me/rezapour/designsystem/theme/Type.kt @@ -2,33 +2,59 @@ package me.rezapour.designsystem.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 me.rezapour.designsystem.R // Set of Material typography styles to start with + +val InterFontFamily = FontFamily( + Font(R.font.inter, FontWeight.Normal), + Font(R.font.inter_semibold, FontWeight.SemiBold), + Font(R.font.inter_bold, FontWeight.Bold), + Font(R.font.inter_extrabold, FontWeight.ExtraBold) +) + +val JetBrainsMonoFontFamily = FontFamily( + Font(R.font.jetbrains_mono_extrabold, FontWeight.ExtraBold) +) val Typography = Typography( - bodyLarge = TextStyle( - fontFamily = FontFamily.Default, - fontWeight = FontWeight.Normal, - fontSize = 16.sp, - lineHeight = 24.sp, - letterSpacing = 0.5.sp - ) - /* Other default text styles to override + displayLarge = TextStyle( + fontFamily = JetBrainsMonoFontFamily, + fontWeight = FontWeight.ExtraBold, + fontSize = 72.sp + ), + displaySmall = TextStyle( + fontFamily = JetBrainsMonoFontFamily, + fontWeight = FontWeight.ExtraBold, + fontSize = 42.sp + ), + headlineLarge = TextStyle( + fontFamily = InterFontFamily, + fontWeight = FontWeight.ExtraBold, + fontSize = 32.sp + ), + headlineMedium = TextStyle( + fontFamily = InterFontFamily, + fontWeight = FontWeight.Bold, + fontSize = 24.sp + ), titleLarge = TextStyle( - fontFamily = FontFamily.Default, + fontFamily = InterFontFamily, + fontWeight = FontWeight.SemiBold, + fontSize = 20.sp + ), + bodyMedium = TextStyle( + fontFamily = InterFontFamily, fontWeight = FontWeight.Normal, - fontSize = 22.sp, - lineHeight = 28.sp, - letterSpacing = 0.sp + fontSize = 14.sp ), labelSmall = TextStyle( - fontFamily = FontFamily.Default, - fontWeight = FontWeight.Medium, + fontFamily = InterFontFamily, + fontWeight = FontWeight.SemiBold, fontSize = 11.sp, - lineHeight = 16.sp, - letterSpacing = 0.5.sp + letterSpacing = 0.3.sp ) - */ ) \ No newline at end of file diff --git a/core/designsystem/src/main/java/me/rezapour/designsystem/util/IniPreview.kt b/core/designsystem/src/main/java/me/rezapour/designsystem/util/IniPreview.kt new file mode 100644 index 0000000..10da7fa --- /dev/null +++ b/core/designsystem/src/main/java/me/rezapour/designsystem/util/IniPreview.kt @@ -0,0 +1,26 @@ +package me.rezapour.designsystem.util + +import android.content.res.Configuration.UI_MODE_NIGHT_NO +import android.content.res.Configuration.UI_MODE_NIGHT_YES +import androidx.compose.ui.tooling.preview.Preview + +@Preview( + name = "LightMode", + group = "LightMode", + showBackground = true, + uiMode = UI_MODE_NIGHT_NO +) +internal annotation class LightMode + +@Preview( + name = "DarkMode", + group = "LightMode", + showBackground = true, + uiMode = UI_MODE_NIGHT_YES +) +internal annotation class DarkMode + + +@LightMode +@DarkMode +annotation class IniPreview \ No newline at end of file diff --git a/core/designsystem/src/main/res/drawable/ic_minus.xml b/core/designsystem/src/main/res/drawable/ic_minus.xml new file mode 100644 index 0000000..6c40605 --- /dev/null +++ b/core/designsystem/src/main/res/drawable/ic_minus.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/core/designsystem/src/main/res/drawable/ic_plus.xml b/core/designsystem/src/main/res/drawable/ic_plus.xml new file mode 100644 index 0000000..6d98b23 --- /dev/null +++ b/core/designsystem/src/main/res/drawable/ic_plus.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/core/designsystem/src/main/res/font/inter.ttf b/core/designsystem/src/main/res/font/inter.ttf new file mode 100644 index 0000000..ce0f305 Binary files /dev/null and b/core/designsystem/src/main/res/font/inter.ttf differ diff --git a/core/designsystem/src/main/res/font/inter_bold.ttf b/core/designsystem/src/main/res/font/inter_bold.ttf new file mode 100644 index 0000000..713c476 Binary files /dev/null and b/core/designsystem/src/main/res/font/inter_bold.ttf differ diff --git a/core/designsystem/src/main/res/font/inter_extrabold.ttf b/core/designsystem/src/main/res/font/inter_extrabold.ttf new file mode 100644 index 0000000..2c0298b Binary files /dev/null and b/core/designsystem/src/main/res/font/inter_extrabold.ttf differ diff --git a/core/designsystem/src/main/res/font/inter_semibold.ttf b/core/designsystem/src/main/res/font/inter_semibold.ttf new file mode 100644 index 0000000..60e9041 Binary files /dev/null and b/core/designsystem/src/main/res/font/inter_semibold.ttf differ diff --git a/core/designsystem/src/main/res/font/jetbrains_mono_extrabold.ttf b/core/designsystem/src/main/res/font/jetbrains_mono_extrabold.ttf new file mode 100644 index 0000000..31d2ed2 Binary files /dev/null and b/core/designsystem/src/main/res/font/jetbrains_mono_extrabold.ttf differ diff --git a/core/ui/.gitignore b/core/ui/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/core/ui/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/core/ui/build.gradle.kts b/core/ui/build.gradle.kts new file mode 100644 index 0000000..00070d8 --- /dev/null +++ b/core/ui/build.gradle.kts @@ -0,0 +1,42 @@ +plugins { + alias(libs.plugins.android.library) + alias(libs.plugins.kotlin.android) +} + +android { + namespace = "me.rezapour.ui" + compileSdk = 36 + + defaultConfig { + minSdk = 24 + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + consumerProguardFiles("consumer-rules.pro") + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + } + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + } + kotlinOptions { + jvmTarget = "11" + } +} + +dependencies { + implementation(libs.androidx.core.ktx) + implementation(libs.androidx.appcompat) + implementation(libs.material) + testImplementation(libs.junit) + androidTestImplementation(libs.androidx.junit) + androidTestImplementation(libs.androidx.espresso.core) +} \ No newline at end of file diff --git a/core/ui/consumer-rules.pro b/core/ui/consumer-rules.pro new file mode 100644 index 0000000..e69de29 diff --git a/core/ui/proguard-rules.pro b/core/ui/proguard-rules.pro new file mode 100644 index 0000000..481bb43 --- /dev/null +++ b/core/ui/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/core/ui/src/androidTest/java/me/rezapour/ui/ExampleInstrumentedTest.kt b/core/ui/src/androidTest/java/me/rezapour/ui/ExampleInstrumentedTest.kt new file mode 100644 index 0000000..0b653c4 --- /dev/null +++ b/core/ui/src/androidTest/java/me/rezapour/ui/ExampleInstrumentedTest.kt @@ -0,0 +1,24 @@ +package me.rezapour.ui + +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.ext.junit.runners.AndroidJUnit4 + +import org.junit.Test +import org.junit.runner.RunWith + +import org.junit.Assert.* + +/** + * Instrumented test, which will execute on an Android device. + * + * See [testing documentation](http://d.android.com/tools/testing). + */ +@RunWith(AndroidJUnit4::class) +class ExampleInstrumentedTest { + @Test + fun useAppContext() { + // Context of the app under test. + val appContext = InstrumentationRegistry.getInstrumentation().targetContext + assertEquals("me.rezapour.ui.test", appContext.packageName) + } +} \ No newline at end of file diff --git a/core/ui/src/main/AndroidManifest.xml b/core/ui/src/main/AndroidManifest.xml new file mode 100644 index 0000000..a5918e6 --- /dev/null +++ b/core/ui/src/main/AndroidManifest.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/core/ui/src/main/java/me/rezapour/ui/formatter/TimerDurationFormatter.kt b/core/ui/src/main/java/me/rezapour/ui/formatter/TimerDurationFormatter.kt new file mode 100644 index 0000000..af6ca73 --- /dev/null +++ b/core/ui/src/main/java/me/rezapour/ui/formatter/TimerDurationFormatter.kt @@ -0,0 +1,14 @@ +package me.rezapour.ui.formatter + +object TimerDurationFormatter { + fun formatForPicker(seconds:Long):String { + val mins = seconds / 60 + val seconds = seconds % 60 + + return when { + mins != 0L && seconds != 0L -> "${mins}min\n${seconds}s" + mins != 0L -> "${mins}min" + else -> "${seconds}s" + } + } +} \ No newline at end of file diff --git a/core/ui/src/test/java/me/rezapour/ui/ExampleUnitTest.kt b/core/ui/src/test/java/me/rezapour/ui/ExampleUnitTest.kt new file mode 100644 index 0000000..218c9f3 --- /dev/null +++ b/core/ui/src/test/java/me/rezapour/ui/ExampleUnitTest.kt @@ -0,0 +1,17 @@ +package me.rezapour.ui + +import org.junit.Test + +import org.junit.Assert.* + +/** + * Example local unit test, which will execute on the development machine (host). + * + * See [testing documentation](http://d.android.com/tools/testing). + */ +class ExampleUnitTest { + @Test + fun addition_isCorrect() { + assertEquals(4, 2 + 2) + } +} \ No newline at end of file diff --git a/feature/add-timer/build.gradle.kts b/feature/add-timer/build.gradle.kts index 53e4370..0ba5f04 100644 --- a/feature/add-timer/build.gradle.kts +++ b/feature/add-timer/build.gradle.kts @@ -42,6 +42,7 @@ dependencies { implementation(project(":core:domain")) implementation(project(":core:designsystem")) implementation(project(":core:common")) + implementation(project(":core:ui")) implementation(libs.androidx.core.ktx) implementation(libs.androidx.lifecycle.runtime.ktx) implementation(libs.androidx.activity.compose) diff --git a/feature/add-timer/src/main/java/me/rezapour/add_timer/compose/AddTimerScreen.kt b/feature/add-timer/src/main/java/me/rezapour/add_timer/compose/AddTimerScreen.kt index 762d5a9..5503ee1 100644 --- a/feature/add-timer/src/main/java/me/rezapour/add_timer/compose/AddTimerScreen.kt +++ b/feature/add-timer/src/main/java/me/rezapour/add_timer/compose/AddTimerScreen.kt @@ -6,19 +6,21 @@ package me.rezapour.add_timer.compose import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.sizeIn import androidx.compose.foundation.layout.width -import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ArrowBackIosNew import androidx.compose.material3.Button import androidx.compose.material3.CenterAlignedTopAppBar import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.SnackbarHost @@ -28,39 +30,57 @@ import androidx.compose.material3.TextField import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle -import kotlinx.coroutines.launch -import me.rezapour.add_timer.viewmodel.AddTimerUiEvent +import me.rezapour.add_timer.viewmodel.AddTimerAction +import me.rezapour.add_timer.viewmodel.AddTimerUiEffect import me.rezapour.add_timer.viewmodel.AddTimerUiState import me.rezapour.add_timer.viewmodel.AddTimerViewModel -import me.rezapour.designsystem.theme.IntervalTimerTheme +import me.rezapour.designsystem.components.button.IniButtonPicker +import me.rezapour.designsystem.theme.IniTheme +import me.rezapour.designsystem.util.IniPreview +import me.rezapour.ui.formatter.TimerDurationFormatter import org.koin.compose.viewmodel.koinViewModel @Composable -fun AddTimerScreen(viewModel: AddTimerViewModel = koinViewModel()) { +fun AddTimerScreen(viewModel: AddTimerViewModel = koinViewModel(), onNavigationBack: () -> Unit) { val uiState = viewModel.uiState.collectAsStateWithLifecycle().value + val snackbarHost = remember { SnackbarHostState() } - AddTimerContent(uiState) { event -> - viewModel.updateUiEvent(event) + + + LaunchedEffect(viewModel) { + viewModel.uiEffect.collect { effect -> + when (effect) { + AddTimerUiEffect.NavigationBack -> onNavigationBack() + is AddTimerUiEffect.ShowSnackBar -> snackbarHost.showSnackbar(effect.errorMessage) + } + } } + + AddTimerContent( + uiState = uiState, + snackBarHostState = snackbarHost, + onAction = viewModel::onAction, + onNavigationBack = onNavigationBack + ) } @Composable fun AddTimerContent( uiState: AddTimerUiState, - uiEvent: (AddTimerUiEvent) -> Unit + snackBarHostState: SnackbarHostState, + onAction: (AddTimerAction) -> Unit, + onNavigationBack: () -> Unit ) { - val snackbarHostState = remember { SnackbarHostState() } - val scope = rememberCoroutineScope() + Scaffold( modifier = Modifier.fillMaxSize(), topBar = { @@ -69,17 +89,20 @@ fun AddTimerContent( Text("Add Timer") }, navigationIcon = { - Icon( - imageVector = Icons.Filled.ArrowBackIosNew, - modifier = Modifier, - tint = MaterialTheme.colorScheme.primary, - contentDescription = null, - ) + IconButton(onClick = onNavigationBack) { + Icon( + imageVector = Icons.Filled.ArrowBackIosNew, + modifier = Modifier, + tint = MaterialTheme.colorScheme.primary, + contentDescription = null, + ) + } + } ) }, snackbarHost = { - SnackbarHost(hostState = snackbarHostState) + SnackbarHost(hostState = snackBarHostState) } ) { Content( @@ -87,15 +110,9 @@ fun AddTimerContent( .padding(top = it.calculateTopPadding()) .navigationBarsPadding(), uiState = uiState, - uiEvent = uiEvent + uiEvent = onAction ) - if (uiState.errorMessage != null) - LaunchedEffect(Unit) { - scope.launch { - snackbarHostState.showSnackbar(uiState.errorMessage) - } - } } } @@ -104,14 +121,13 @@ fun AddTimerContent( fun Content( modifier: Modifier, uiState: AddTimerUiState, - uiEvent: (AddTimerUiEvent) -> Unit + uiEvent: (AddTimerAction) -> Unit ) { - Column( modifier = modifier .fillMaxWidth() .padding(horizontal = 16.dp, vertical = 16.dp), - verticalArrangement = Arrangement.spacedBy(16.dp), + verticalArrangement = Arrangement.spacedBy(IniTheme.spacing.m), horizontalAlignment = Alignment.CenterHorizontally ) { @@ -126,115 +142,84 @@ fun Content( TextField( value = uiState.name ?: "", onValueChange = { - uiEvent(AddTimerUiEvent.OnNameChanged(it)) + uiEvent(AddTimerAction.OnNameChanged(it)) }, singleLine = true, ) } + Spacer(modifier = Modifier.height(IniTheme.spacing.xl)) - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically - ) { - Text( - modifier = Modifier.width(80.dp), - text = "Timer" - ) + Picker( + title = "Run", + value = TimerDurationFormatter.formatForPicker(uiState.workoutSecond), + increaseValue = { uiEvent(AddTimerAction.OnWorkoutIncreased) }, + decreaseValue = { uiEvent(AddTimerAction.OnWorkoutDecreased) } + ) - TextField( - modifier = Modifier.size(60.dp), - value = uiState.workMin, - onValueChange = { - uiEvent(AddTimerUiEvent.OnWorkMinChanged(it)) - }, - maxLines = 1, - singleLine = true, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), - label = { - Text("Min") - } - ) - Text(":") - TextField( - modifier = Modifier.size(60.dp), - value = uiState.workSec, - onValueChange = { - uiEvent(AddTimerUiEvent.OnWorkSecChanged(it)) - }, - maxLines = 1, - singleLine = true, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), - label = { - Text("Sec") - } - ) + Spacer(modifier = Modifier.height(IniTheme.spacing.xl)) - } + Picker( + title = "Rest", + value = TimerDurationFormatter.formatForPicker(uiState.restSecond), + increaseValue = { uiEvent(AddTimerAction.OnRestIncreased) }, + decreaseValue = { uiEvent(AddTimerAction.OnRestDecreased) } + ) - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically - ) { - Text( - modifier = Modifier.width(80.dp), - text = "Interval" - ) + Spacer(modifier = Modifier.height(IniTheme.spacing.xl)) - TextField( - modifier = Modifier.size(60.dp), - value = uiState.restMin, - onValueChange = { - uiEvent(AddTimerUiEvent.OnRestMinChanged(it)) - }, - maxLines = 1, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), - label = { - Text("Min") - } - ) - Text(":") - TextField( - modifier = Modifier.size(60.dp), - value = uiState.restSec, - onValueChange = { - uiEvent(AddTimerUiEvent.OnRestSecChanged(it)) - }, - maxLines = 1, - singleLine = true, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), - label = { - Text("Sec") - } - ) + Picker( + title = "Round", + value = uiState.rounds.toString(), + increaseValue = { uiEvent(AddTimerAction.OnRoundIncreased) }, + decreaseValue = { uiEvent(AddTimerAction.OnRoundDecreased) } + ) + Spacer(modifier = Modifier.height(IniTheme.spacing.xl)) + + Button(onClick = { + uiEvent(AddTimerAction.SaveTimer) + }) { + Text("Save") } + } +} +@Composable +fun Picker( + modifier: Modifier = Modifier, + title: String, + value: String, + increaseValue: () -> Unit, + decreaseValue: () -> Unit +) { + + Column( + modifier = modifier.fillMaxWidth(), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = title, + style = IniTheme.typography.titleLarge + ) + Spacer(modifier = Modifier.height(IniTheme.spacing.s)) Row( modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center, verticalAlignment = Alignment.CenterVertically ) { + IniButtonPicker(increaseMode = false) { decreaseValue() } Text( - modifier = Modifier.width(80.dp), - text = "Set" - ) - - TextField( - modifier = Modifier.size(60.dp), - value = uiState.rounds, - onValueChange = { - uiEvent(AddTimerUiEvent.OnRoundsChanged(it)) - }, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), - maxLines = 1, - singleLine = true, + modifier = Modifier + .padding(start = IniTheme.spacing.s, end = IniTheme.spacing.s) + .sizeIn(minWidth = 80.dp), + text = value, + textAlign = TextAlign.Center, + color = IniTheme.colors.onPrimaryContainer, + style = IniTheme.typography.displaySmall ) - } - - Button(onClick = { - uiEvent(AddTimerUiEvent.SaveTimer) - }) { - Text("Save") + IniButtonPicker { increaseValue() } } @@ -246,7 +231,20 @@ fun Content( @Preview @Composable fun AddTimerContentPreview() { - IntervalTimerTheme { - AddTimerScreen() + IniTheme { + AddTimerContent(AddTimerUiState(), SnackbarHostState(), onAction = {}, onNavigationBack = {}) + } +} + +@IniPreview +@Composable +fun PickerPreview() { + IniTheme { + Picker( + title = "run", + value = "2 min", + increaseValue = {}, + decreaseValue = {}, + ) } } \ No newline at end of file diff --git a/feature/add-timer/src/main/java/me/rezapour/add_timer/navigation/AddTimerNavigation.kt b/feature/add-timer/src/main/java/me/rezapour/add_timer/navigation/AddTimerNavigation.kt index 1b91351..76f4924 100644 --- a/feature/add-timer/src/main/java/me/rezapour/add_timer/navigation/AddTimerNavigation.kt +++ b/feature/add-timer/src/main/java/me/rezapour/add_timer/navigation/AddTimerNavigation.kt @@ -1,12 +1,16 @@ package me.rezapour.add_timer.navigation +import androidx.compose.runtime.snapshots.SnapshotStateList import androidx.navigation3.runtime.EntryProviderScope import me.rezapour.add_timer.compose.AddTimerScreen data object AddTimerScreen -fun EntryProviderScope.addTimerScreen() { +fun EntryProviderScope.addTimerScreen(backStack: SnapshotStateList) { entry { - AddTimerScreen() + + AddTimerScreen(onNavigationBack = { + backStack.removeLastOrNull() + }) } } \ No newline at end of file diff --git a/feature/add-timer/src/main/java/me/rezapour/add_timer/viewmodel/AddTimerViewModel.kt b/feature/add-timer/src/main/java/me/rezapour/add_timer/viewmodel/AddTimerViewModel.kt index 6eeabf9..f152e7d 100644 --- a/feature/add-timer/src/main/java/me/rezapour/add_timer/viewmodel/AddTimerViewModel.kt +++ b/feature/add-timer/src/main/java/me/rezapour/add_timer/viewmodel/AddTimerViewModel.kt @@ -2,14 +2,15 @@ package me.rezapour.add_timer.viewmodel import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch -import me.rezapour.common.extentionfunctions.digitOnly -import me.rezapour.common.extentionfunctions.toIntOrZero -import me.rezapour.common.extentionfunctions.toLongOrZero import me.rezapour.domain.model.Timer import me.rezapour.domain.usecase.InsertTimerUseCase @@ -18,94 +19,147 @@ class AddTimerViewModel(private val insertUseCase: InsertTimerUseCase) : ViewMod private val _uiState: MutableStateFlow = MutableStateFlow(AddTimerUiState()) val uiState: StateFlow = _uiState.asStateFlow() - fun saveTimer() { + private val _uiEffect: MutableSharedFlow = MutableSharedFlow() + val uiEffect: SharedFlow = _uiEffect.asSharedFlow() + + private fun saveTimer() { val state = uiState.value - if (state.name == null) { + if (state.name.isBlank()) { showError("Name can't be empty") return } - val workSeconds = minuteSecondsToTotalSeconds(state.workMin, state.workSec) - val restSeconds = minuteSecondsToTotalSeconds(state.restMin, state.restSec) val timer = Timer( name = state.name, - workSeconds = workSeconds, - restSeconds = restSeconds, - rounds = state.rounds.toIntOrZero() + workSeconds = state.workoutSecond, + restSeconds = state.restSecond, + rounds = state.rounds ) viewModelScope.launch { - insertUseCase(timer) + isSaving(true) + try { + insertUseCase(timer) + _uiEffect.emit(AddTimerUiEffect.NavigationBack) + } catch (e: Exception) { + if (e is CancellationException) throw e + showError(e.message.toString()) + } finally { + isSaving(false) + } + + } } - fun updateUiEvent(e: AddTimerUiEvent) { - when (e) { - is AddTimerUiEvent.OnRestMinChanged -> onIntervalMinChanged(e.min) - is AddTimerUiEvent.OnRestSecChanged -> onIntervalSecChange(e.sec) - AddTimerUiEvent.SaveTimer -> saveTimer() - is AddTimerUiEvent.OnNameChanged -> onNameChange(e.name) - is AddTimerUiEvent.OnRoundsChanged -> onSetChange(e.set) - is AddTimerUiEvent.OnWorkMinChanged -> onTimerMinChange(e.min) - is AddTimerUiEvent.OnWorkSecChanged -> onTimerSecChange(e.sec) + fun onAction(event: AddTimerAction) { + when (event) { + AddTimerAction.SaveTimer -> saveTimer() + is AddTimerAction.OnNameChanged -> onNameChange(event.name) + AddTimerAction.OnRestDecreased -> restDecreaseValue() + AddTimerAction.OnRestIncreased -> restIncreaseValue() + AddTimerAction.OnRoundDecreased -> roundDecreaseValue() + AddTimerAction.OnRoundIncreased -> roundIncreaseValue() + AddTimerAction.OnWorkoutDecreased -> workoutDecreaseValue() + AddTimerAction.OnWorkoutIncreased -> workoutIncreaseValue() + AddTimerAction.BackClicked -> emitBackNavigation() } } - private fun showError(message: String) { - _uiState.update { it.copy(errorMessage = message) } + private fun isSaving(isSaving: Boolean) { + _uiState.update { it.copy(isSaving = isSaving) } } - private fun onNameChange(newNameValue: String) { - _uiState.update { it.copy(name = newNameValue, errorMessage = null) } + private fun workoutIncreaseValue() { + _uiState.update { + it.copy(workoutSecond = it.workoutSecond + 30) + } } - private fun onTimerMinChange(newMin: String) { - _uiState.update { it.copy(workMin = validateTimeInput(newMin)) } + private fun workoutDecreaseValue() { + _uiState.update { + if (it.workoutSecond > AddTimerUiState.MIN_WORK_OUT) + it.copy(workoutSecond = it.workoutSecond - 30) + else + it.copy(workoutSecond = AddTimerUiState.MIN_WORK_OUT) + } } - private fun onTimerSecChange(newSec: String) { - _uiState.update { it.copy(workSec = validateTimeInput(newSec)) } + private fun restIncreaseValue() { + _uiState.update { + it.copy(restSecond = it.restSecond + 30) + } } - private fun onIntervalMinChanged(newMin: String) { - _uiState.update { it.copy(restMin = validateTimeInput(newMin)) } + private fun restDecreaseValue() { + _uiState.update { + if (it.restSecond > AddTimerUiState.MIN_REST) + it.copy(restSecond = it.restSecond - 30) + else + it.copy(restSecond = AddTimerUiState.MIN_REST) + } } - private fun onIntervalSecChange(newSec: String) { - _uiState.update { it.copy(restSec = validateTimeInput(newSec)) } + private fun roundIncreaseValue() { + _uiState.update { + it.copy(rounds = it.rounds + 1) + } } - private fun onSetChange(newSet: String) { - _uiState.update { it.copy(rounds = newSet.digitOnly()) } + private fun roundDecreaseValue() { + _uiState.update { + if (it.rounds > AddTimerUiState.MIN_ROUNDS) + it.copy(rounds = it.rounds - 1) + else + it.copy(rounds = AddTimerUiState.MIN_ROUNDS) + } } - private fun validateTimeInput(value: String) = - value.digitOnly(2).toIntOrZero().coerceIn(0, 59).toString() + private fun showError(message: String) { + viewModelScope.launch { + _uiEffect.emit(AddTimerUiEffect.ShowSnackBar(message)) + } + } - private fun minuteSecondsToTotalSeconds(minText: String, secText: String): Long { - val min = minText.toLongOrZero() * 60L - val sec = secText.toLongOrZero() - return min + sec + private fun onNameChange(newNameValue: String) { + _uiState.update { it.copy(name = newNameValue) } } -} -sealed class AddTimerUiEvent { + private fun emitBackNavigation() { + viewModelScope.launch { + _uiEffect.emit(AddTimerUiEffect.NavigationBack) + } + } +} - object SaveTimer : AddTimerUiEvent() - data class OnNameChanged(val name: String) : AddTimerUiEvent() - data class OnWorkMinChanged(val min: String) : AddTimerUiEvent() - data class OnWorkSecChanged(val sec: String) : AddTimerUiEvent() - data class OnRestMinChanged(val min: String) : AddTimerUiEvent() - data class OnRestSecChanged(val sec: String) : AddTimerUiEvent() - data class OnRoundsChanged(val set: String) : AddTimerUiEvent() +sealed class AddTimerAction { + + object SaveTimer : AddTimerAction() + data class OnNameChanged(val name: String) : AddTimerAction() + object OnWorkoutIncreased : AddTimerAction() + object OnWorkoutDecreased : AddTimerAction() + object OnRestIncreased : AddTimerAction() + object OnRestDecreased : AddTimerAction() + object OnRoundIncreased : AddTimerAction() + object OnRoundDecreased : AddTimerAction() + object BackClicked : AddTimerAction() } data class AddTimerUiState( - val isLoading: Boolean = false, - val errorMessage: String? = null, - val name: String? = null, - val workMin: String = "", - val workSec: String = "", - val restMin: String = "", - val restSec: String = "", - val rounds: String = "" -) \ No newline at end of file + val isSaving: Boolean = false, + val name: String = "", + val workoutSecond: Long = MIN_WORK_OUT, + val restSecond: Long = MIN_REST, + val rounds: Int = MIN_ROUNDS, +) { + companion object { + const val MIN_REST = 30L + const val MIN_WORK_OUT = 30L + const val MIN_ROUNDS = 1 + } +} + +sealed class AddTimerUiEffect { + data class ShowSnackBar(val errorMessage: String) : AddTimerUiEffect() + object NavigationBack : AddTimerUiEffect() + +} \ No newline at end of file diff --git a/settings.gradle.kts b/settings.gradle.kts index 330fb3f..b6ea29b 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -30,3 +30,4 @@ include(":feature:add-timer") include(":feature:di") include(":core:designsystem") include(":core:timer-core") +include(":core:ui")