diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 2ae59a9..4320af5 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -62,7 +62,7 @@ android { compose = true } composeOptions { - kotlinCompilerExtensionVersion = libs.versions.compose.get() + kotlinCompilerExtensionVersion = libs.versions.compose.compiler.get() } } @@ -77,7 +77,6 @@ dependencies { implementation(libs.androidx.appcompat) implementation(libs.androidx.splash) implementation(libs.material) - implementation("androidx.constraintlayout:constraintlayout:2.0.4") implementation("androidx.legacy:legacy-support-v4:1.0.0") implementation("androidx.security:security-crypto:1.0.0-rc01") implementation(libs.preference) diff --git a/app/src/main/java/com/wbrawner/pihelper/AddScreen.kt b/app/src/main/java/com/wbrawner/pihelper/AddScreen.kt index 48c0616..5b5abd6 100644 --- a/app/src/main/java/com/wbrawner/pihelper/AddScreen.kt +++ b/app/src/main/java/com/wbrawner/pihelper/AddScreen.kt @@ -7,9 +7,10 @@ import android.os.Build import android.widget.Toast import androidx.compose.foundation.background import androidx.compose.foundation.layout.* -import androidx.compose.material.MaterialTheme -import androidx.compose.material.OutlinedTextField -import androidx.compose.material.Text +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -73,6 +74,7 @@ fun AddScreen(store: Store) { ) } +@OptIn(ExperimentalMaterial3Api::class) @Composable fun AddPiholeForm( scanNetwork: () -> Unit, @@ -125,7 +127,7 @@ fun OrDivider() { .weight(1f) .padding(end = 8.dp) .clip(RectangleShape) - .background(MaterialTheme.colors.onSurface), + .background(MaterialTheme.colorScheme.onSurface), ) Text("OR") Box( @@ -134,7 +136,7 @@ fun OrDivider() { .weight(1f) .padding(start = 8.dp) .clip(RectangleShape) - .background(MaterialTheme.colors.onSurface), + .background(MaterialTheme.colorScheme.onSurface), ) } } diff --git a/app/src/main/java/com/wbrawner/pihelper/AuthScreen.kt b/app/src/main/java/com/wbrawner/pihelper/AuthScreen.kt index 5cf47c6..a1f9f54 100644 --- a/app/src/main/java/com/wbrawner/pihelper/AuthScreen.kt +++ b/app/src/main/java/com/wbrawner/pihelper/AuthScreen.kt @@ -4,8 +4,9 @@ import androidx.compose.foundation.Image import androidx.compose.foundation.layout.* import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll -import androidx.compose.material.OutlinedTextField -import androidx.compose.material.Text +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -30,7 +31,7 @@ import com.wbrawner.pihelper.shared.AuthenticationString import com.wbrawner.pihelper.shared.Store import kotlinx.coroutines.launch -@OptIn(ExperimentalComposeUiApi::class) +@OptIn(ExperimentalComposeUiApi::class, ExperimentalMaterial3Api::class) @Composable fun AuthScreen(store: Store) { val (password: String, setPassword: (String) -> Unit) = remember { mutableStateOf("") } diff --git a/app/src/main/java/com/wbrawner/pihelper/InfoScreen.kt b/app/src/main/java/com/wbrawner/pihelper/InfoScreen.kt index 0496693..20e11a5 100644 --- a/app/src/main/java/com/wbrawner/pihelper/InfoScreen.kt +++ b/app/src/main/java/com/wbrawner/pihelper/InfoScreen.kt @@ -7,9 +7,10 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.foundation.text.ClickableText -import androidx.compose.material.* import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ArrowBack +import androidx.compose.material3.* +import androidx.compose.material3.TopAppBarDefaults.smallTopAppBarColors import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -33,13 +34,15 @@ fun InfoScreen(store: Store) { ) } +@OptIn(ExperimentalMaterial3Api::class) @Composable fun InfoScreen(onBackClicked: () -> Unit, onForgetPiholeClicked: () -> Unit) { Scaffold( topBar = { TopAppBar( - backgroundColor = MaterialTheme.colors.surface, - elevation = 0.dp, + colors = smallTopAppBarColors( + containerColor = MaterialTheme.colorScheme.surface + ), title = { Text("About Pi-helper") }, navigationIcon = { IconButton(onClick = onBackClicked) { @@ -73,7 +76,7 @@ fun InfoScreen(onBackClicked: () -> Unit, onForgetPiholeClicked: () -> Unit) { ) addStyle( style = SpanStyle( - color = MaterialTheme.colors.primary, + color = MaterialTheme.colorScheme.primary, textDecoration = TextDecoration.Underline ), start = name, @@ -87,7 +90,7 @@ fun InfoScreen(onBackClicked: () -> Unit, onForgetPiholeClicked: () -> Unit) { ) addStyle( style = SpanStyle( - color = MaterialTheme.colors.primary, + color = MaterialTheme.colorScheme.primary, textDecoration = TextDecoration.Underline ), start = github, @@ -98,7 +101,7 @@ fun InfoScreen(onBackClicked: () -> Unit, onForgetPiholeClicked: () -> Unit) { ClickableText( text = message, style = TextStyle.Default.copy( - color = MaterialTheme.colors.onSurface, + color = MaterialTheme.colorScheme.onSurface, textAlign = TextAlign.Center ), ) { diff --git a/app/src/main/java/com/wbrawner/pihelper/MainActivity.kt b/app/src/main/java/com/wbrawner/pihelper/MainActivity.kt index 7b95db5..c1b4814 100644 --- a/app/src/main/java/com/wbrawner/pihelper/MainActivity.kt +++ b/app/src/main/java/com/wbrawner/pihelper/MainActivity.kt @@ -13,7 +13,7 @@ import androidx.compose.animation.ExperimentalAnimationApi import androidx.compose.animation.core.* import androidx.compose.foundation.Image import androidx.compose.foundation.isSystemInDarkTheme -import androidx.compose.material.MaterialTheme +import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState @@ -27,7 +27,6 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.core.animation.doOnEnd import androidx.core.content.ContextCompat import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen -import androidx.navigation.NavController import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.compose.rememberNavController @@ -103,20 +102,22 @@ class MainActivity : AppCompatActivity() { } } } - splashScreen.setOnExitAnimationListener { splashScreenView -> - listOf(View.SCALE_X, View.SCALE_Y).forEach { axis -> - ObjectAnimator.ofFloat( - splashScreenView, - axis, - 1f, - 0.45f - ).apply { - interpolator = AnticipateInterpolator() - duration = 200L - doOnEnd { - splashScreenView.remove() + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + splashScreen.setOnExitAnimationListener { splashScreenView -> + listOf(View.SCALE_X, View.SCALE_Y).forEach { axis -> + ObjectAnimator.ofFloat( + splashScreenView, + axis, + 1f, + 0.45f + ).apply { + interpolator = AnticipateInterpolator() + duration = 200L + doOnEnd { + splashScreenView.remove() + } + start() } - start() } } } @@ -143,7 +144,7 @@ fun LoadingSpinner(animate: Boolean = false) { modifier = Modifier.rotate(if (animate) rotation else 0f), painter = painterResource(id = R.drawable.ic_app_logo), contentDescription = "Loading", - colorFilter = ColorFilter.tint(MaterialTheme.colors.onBackground) + colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.onBackground) ) } @@ -152,9 +153,3 @@ fun LoadingSpinner(animate: Boolean = false) { fun LoadingSpinner_Preview() { LoadingSpinner() } - -fun NavController.navigateIfNotAlreadyThere(route: String) { - if (currentDestination?.route != route) { - navigate(route) - } -} \ No newline at end of file diff --git a/app/src/main/java/com/wbrawner/pihelper/MainScreen.kt b/app/src/main/java/com/wbrawner/pihelper/MainScreen.kt index 483a26d..c15dfeb 100644 --- a/app/src/main/java/com/wbrawner/pihelper/MainScreen.kt +++ b/app/src/main/java/com/wbrawner/pihelper/MainScreen.kt @@ -1,3 +1,5 @@ +@file:OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3Api::class) + package com.wbrawner.pihelper import androidx.compose.animation.AnimatedContent @@ -6,9 +8,10 @@ import androidx.compose.foundation.layout.* import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.selection.selectable import androidx.compose.foundation.verticalScroll -import androidx.compose.material.* import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Settings +import androidx.compose.material3.* +import androidx.compose.material3.TopAppBarDefaults.smallTopAppBarColors import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.mutableStateOf @@ -17,17 +20,18 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.wbrawner.pihelper.shared.Action import com.wbrawner.pihelper.shared.Status import com.wbrawner.pihelper.shared.Store +import com.wbrawner.pihelper.ui.DayNightPreview import com.wbrawner.pihelper.ui.PihelperTheme import java.util.* import kotlin.math.pow import kotlin.math.roundToLong @ExperimentalAnimationApi +@OptIn(ExperimentalMaterial3Api::class) @Composable fun MainScreen(store: Store) { val state = store.state.collectAsState() @@ -35,34 +39,38 @@ fun MainScreen(store: Store) { topBar = { TopAppBar( title = { Text("Pi-helper") }, - backgroundColor = MaterialTheme.colors.background, - contentColor = MaterialTheme.colors.onBackground, - elevation = 0.dp, + colors = smallTopAppBarColors( + containerColor = MaterialTheme.colorScheme.background, + titleContentColor = MaterialTheme.colorScheme.onBackground + ), actions = { IconButton(onClick = { store.dispatch(Action.About) }) { Icon( imageVector = Icons.Default.Settings, contentDescription = "Settings", - tint = MaterialTheme.colors.onBackground + tint = MaterialTheme.colorScheme.onBackground ) } } ) } - ) { + ) { paddingValues -> val scrollState = rememberScrollState() Column( modifier = Modifier + .padding(paddingValues) .fillMaxSize() .verticalScroll(scrollState), verticalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterVertically), horizontalAlignment = Alignment.CenterHorizontally ) { + val status = state.value.status LoadingSpinner(state.value.loading) - AnimatedContent(targetState = state.value.status, contentAlignment = Alignment.Center) { - state.value.status?.let { - StatusLabel(it) - if (it == Status.ENABLED) { + if (status != null) { + val enabled = status is Status.Enabled + StatusLabel(status) + AnimatedContent(targetState = enabled, contentAlignment = Alignment.Center) { + if (enabled) { DisableControls { duration -> store.dispatch(Action.Disable(duration)) } } else { EnableControls { store.dispatch(Action.Enable) } @@ -76,21 +84,30 @@ fun MainScreen(store: Store) { @Composable fun StatusLabel(status: Status) { Row( - modifier = Modifier.fillMaxWidth(), + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 16.dp), horizontalArrangement = Arrangement.Center ) { - Text(color = MaterialTheme.colors.onSurface, text = "Status:") + Text(color = MaterialTheme.colorScheme.onSurface, text = "Status:") Spacer(modifier = Modifier.width(8.dp)) val color = when (status) { - Status.ENABLED -> MaterialTheme.colors.secondaryVariant - Status.DISABLED -> MaterialTheme.colors.primaryVariant + is Status.Enabled -> MaterialTheme.colorScheme.secondary + is Status.Disabled -> MaterialTheme.colorScheme.primary else -> Color(0x00000000) } Text( color = color, fontWeight = FontWeight.Bold, - text = status.name.toLowerCase(Locale.US).capitalize(Locale.US) + text = status.name.capitalize(Locale.US) ) + if (status is Status.Disabled && !status.timeRemaining.isNullOrBlank()) { + Text( + color = color, + fontWeight = FontWeight.Bold, + text = " (${status.timeRemaining})" + ) + } } } @@ -99,11 +116,10 @@ fun EnableControls(onClick: () -> Unit) { Button( modifier = Modifier .fillMaxWidth() - // The strange padding is to work around a bug with the animation - .padding(start = 16.dp, top = 48.dp, end = 16.dp), + .padding(16.dp), colors = ButtonDefaults.buttonColors( - backgroundColor = MaterialTheme.colors.secondary, - contentColor = MaterialTheme.colors.onSecondary + containerColor = MaterialTheme.colorScheme.secondary, + contentColor = MaterialTheme.colorScheme.onSecondary ), onClick = onClick ) { @@ -117,8 +133,7 @@ fun DisableControls(disable: (duration: Long?) -> Unit) { Column( modifier = Modifier .fillMaxWidth() - // The strange padding is to work around a bug with the animation - .padding(start = 16.dp, top = 48.dp, end = 16.dp), + .padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterVertically) ) { PrimaryButton("Disable for 10 seconds") { disable(10) } @@ -137,8 +152,8 @@ fun PrimaryButton(text: String, onClick: () -> Unit) { Button( modifier = Modifier.fillMaxWidth(), colors = ButtonDefaults.buttonColors( - backgroundColor = MaterialTheme.colors.primary, - contentColor = MaterialTheme.colors.onPrimary + containerColor = MaterialTheme.colorScheme.primary, + contentColor = MaterialTheme.colorScheme.onPrimary ), onClick = onClick ) { @@ -166,28 +181,26 @@ fun CustomTimeDialog( AlertDialog( shape = MaterialTheme.shapes.small, onDismissRequest = { setVisible(false) }, - buttons = { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.End - ) { - TextButton({ setVisible(false) }) { - Text("Cancel") - } - TextButton(onClick = { - // TODO: Move this math to the viewmodel or repository - onTimeSelected(time.toLong() * (60.0.pow(duration.ordinal)).roundToLong()) - setVisible(false) - }) { - Text("Disable") - } + dismissButton = { + TextButton({ setVisible(false) }) { + Text("Cancel") } }, - title = { Text("Disable for custom time: ") }, + confirmButton = { + TextButton(onClick = { + // TODO: Move this math to the viewmodel or repository + onTimeSelected(time.toLong() * (60.0.pow(duration.ordinal)).roundToLong()) + setVisible(false) + }) { + Text("Disable") + } + }, + title = { Text("Disable for custom time:") }, text = { Column( modifier = Modifier.fillMaxWidth(), - horizontalAlignment = Alignment.CenterHorizontally + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center ) { OutlinedTextField( value = time, @@ -195,17 +208,18 @@ fun CustomTimeDialog( placeholder = { Text("Time to disable") } ) Row( - modifier = Modifier.fillMaxWidth() + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center ) { DurationToggle( selected = duration == Duration.SECONDS, onClick = { selectDuration(Duration.SECONDS) }, - text = "Seconds" + text = "Secs" ) DurationToggle( selected = duration == Duration.MINUTES, onClick = { selectDuration(Duration.MINUTES) }, - text = "Minutes" + text = "Mins" ) DurationToggle( selected = duration == Duration.HOURS, @@ -227,14 +241,14 @@ fun DurationToggle(selected: Boolean, onClick: () -> Unit, text: String) { Button( onClick = onClick, colors = ButtonDefaults.buttonColors( - backgroundColor = if (selected) - MaterialTheme.colors.primary + containerColor = if (selected) + MaterialTheme.colorScheme.primary else - MaterialTheme.colors.background, + MaterialTheme.colorScheme.background, contentColor = if (selected) - MaterialTheme.colors.onPrimary + MaterialTheme.colorScheme.onPrimary else - MaterialTheme.colors.primary + MaterialTheme.colorScheme.primary ), elevation = null ) { @@ -244,77 +258,57 @@ fun DurationToggle(selected: Boolean, onClick: () -> Unit, text: String) { } @Composable -@Preview +@DayNightPreview fun CustomTimeDialog_Preview() { - CustomTimeDialog(true, {}) { } + PihelperTheme { + CustomTimeDialog(true, {}) { } + } } @Composable -@Preview +@DayNightPreview fun StatusLabelEnabled_Preview() { - PihelperTheme(false) { - StatusLabel(Status.ENABLED) + PihelperTheme { + StatusLabel(Status.Enabled) } } @Composable -@Preview -fun StatusLabelEnabled_DarkPreview() { - PihelperTheme(true) { - StatusLabel(Status.ENABLED) - } -} - -@Composable -@Preview +@DayNightPreview fun StatusLabelDisabled_Preview() { - PihelperTheme(false) { - StatusLabel(Status.DISABLED) + PihelperTheme { + StatusLabel(Status.Disabled()) } } @Composable -@Preview -fun StatusLabelDisabled_DarkPreview() { - PihelperTheme(true) { - StatusLabel(Status.DISABLED) +@DayNightPreview +fun StatusLabelDisabledWithTime_Preview() { + PihelperTheme { + StatusLabel(Status.Disabled("12:34:56")) } } @Composable -@Preview +@DayNightPreview fun PrimaryButton_Preview() { - PihelperTheme(false) { + PihelperTheme { PrimaryButton("Disable") {} } } @Composable -@Preview -fun PrimaryButton_DarkPreview() { - PihelperTheme(true) { - PrimaryButton("Disable") {} - } -} - -@Composable -@Preview +@DayNightPreview fun EnableControls_Preview() { - PihelperTheme(false) { + PihelperTheme { EnableControls {} } } @Composable -@Preview -fun EnableControls_DarkPreview() { - PihelperTheme(true) { - EnableControls {} - } -} - -@Composable -@Preview +@DayNightPreview fun DisableControls_Preview() { - DisableControls({}) + PihelperTheme { + DisableControls {} + } } diff --git a/app/src/main/java/com/wbrawner/pihelper/ui/Theme.kt b/app/src/main/java/com/wbrawner/pihelper/ui/Theme.kt index 0bcb2bf..479b66f 100644 --- a/app/src/main/java/com/wbrawner/pihelper/ui/Theme.kt +++ b/app/src/main/java/com/wbrawner/pihelper/ui/Theme.kt @@ -1,47 +1,62 @@ package com.wbrawner.pihelper.ui +import android.content.res.Configuration.UI_MODE_NIGHT_NO +import android.content.res.Configuration.UI_MODE_NIGHT_YES import androidx.compose.foundation.isSystemInDarkTheme -import androidx.compose.material.MaterialTheme -import androidx.compose.material.Surface -import androidx.compose.material.darkColors -import androidx.compose.material.lightColors +import androidx.compose.material3.* import androidx.compose.runtime.Composable import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.tooling.preview.Preview -private val DarkColorPalette = darkColors( +private val DarkColorPalette = darkColorScheme( background = Color.Black, surface = Color.Black, primary = Red500, - primaryVariant = Red900, +// primaryVariant = Red900, onPrimary = Color.White, secondary = Green500, - secondaryVariant = Green900, +// secondaryVariant = Green900, onSecondary = Color.White ) -private val LightColorPalette = lightColors( +private val LightColorPalette = lightColorScheme( primary = Red500, - primaryVariant = Red900, +// primaryVariant = Red900, onPrimary = Color.White, secondary = Green500, - secondaryVariant = Green900, +// secondaryVariant = Green900, onSecondary = Color.White ) @Composable fun PihelperTheme(darkTheme: Boolean = isSystemInDarkTheme(), content: @Composable () -> Unit) { - val colors = if (darkTheme) { - DarkColorPalette + val context = LocalContext.current + val dynamic = false + val colors = if (dynamic) { + if (darkTheme) { + dynamicDarkColorScheme(context) + } else { + dynamicLightColorScheme(context) + } } else { - LightColorPalette + if (darkTheme) { + DarkColorPalette + } else { + LightColorPalette + } } MaterialTheme( - colors = colors, - typography = Typography, - shapes = Shapes, + colorScheme = colors, +// typography = Typography, +// shapes = Shapes, content = { - Surface(color = MaterialTheme.colors.background, content = content) + Surface(color = MaterialTheme.colorScheme.background, content = content) } ) -} \ No newline at end of file +} + +@Preview(uiMode = UI_MODE_NIGHT_NO) +@Preview(uiMode = UI_MODE_NIGHT_YES) +annotation class DayNightPreview \ No newline at end of file diff --git a/app/src/main/java/com/wbrawner/pihelper/ui/Type.kt b/app/src/main/java/com/wbrawner/pihelper/ui/Type.kt index dde6ff2..5385789 100644 --- a/app/src/main/java/com/wbrawner/pihelper/ui/Type.kt +++ b/app/src/main/java/com/wbrawner/pihelper/ui/Type.kt @@ -1,6 +1,6 @@ package com.wbrawner.pihelper.ui -import androidx.compose.material.Typography +import androidx.compose.material3.Typography import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight @@ -8,9 +8,9 @@ import androidx.compose.ui.unit.sp // Set of Material typography styles to start with val Typography = Typography( - body1 = TextStyle( - fontFamily = FontFamily.Default, - fontWeight = FontWeight.Normal, - fontSize = 16.sp - ) + bodyMedium = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 16.sp + ) ) \ No newline at end of file diff --git a/app/src/main/res/drawable-v26/ic_shortcut_enable.xml b/app/src/main/res/drawable-v26/ic_shortcut_enable.xml index 59967ab..9400838 100644 --- a/app/src/main/res/drawable-v26/ic_shortcut_enable.xml +++ b/app/src/main/res/drawable-v26/ic_shortcut_enable.xml @@ -6,4 +6,5 @@ android:inset="15%" /> + \ No newline at end of file diff --git a/app/src/main/res/drawable-v26/ic_shortcut_pause.xml b/app/src/main/res/drawable-v26/ic_shortcut_pause.xml index 9300e9f..1c47093 100644 --- a/app/src/main/res/drawable-v26/ic_shortcut_pause.xml +++ b/app/src/main/res/drawable-v26/ic_shortcut_pause.xml @@ -6,4 +6,5 @@ android:inset="20%" /> + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_pause.xml b/app/src/main/res/drawable/ic_pause.xml index 8d33792..570229c 100644 --- a/app/src/main/res/drawable/ic_pause.xml +++ b/app/src/main/res/drawable/ic_pause.xml @@ -4,6 +4,6 @@ android:viewportWidth="24.0" android:viewportHeight="24.0"> diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml index bbd3e02..7ff3166 100644 --- a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -2,4 +2,5 @@ + \ 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 index bbd3e02..7ff3166 100644 --- a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -2,4 +2,5 @@ + \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 52ebc81..e5f80f4 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,19 +1,19 @@ [versions] -androidx-core = "1.3.2" -androidx-appcompat = "1.2.0" -androidx-splash = "1.0.0-beta02" -compose = "1.1.1" -coroutines = "1.4.3" +androidx-core = "1.9.0" +androidx-appcompat = "1.5.1" +androidx-splash = "1.0.0" +compose = "1.2.1" +compose-compiler = "1.3.2" +compose-material3 = "1.0.1" espresso = "3.3.0" -hilt-android = "2.38.1" +hilt-android = "2.44" kotlin = "1.7.20" -kotlinx-serialization = "1.3.2" -kotlinx-coroutines = "1.6.0-native-mt" -ktor = "2.0.0-beta-1" -lifecycle = "2.2.0" +kotlinx-serialization = "1.4.1" +kotlinx-coroutines = "1.6.4" +kotlinx-datetime = "0.4.0" +ktor = "2.1.2" material = "1.3.0" -maxSdk = "31" -moshi = "1.9.2" +maxSdk = "33" minSdk = "23" navigation = "2.4.1" okhttp = "4.2.2" @@ -22,17 +22,17 @@ versionCode = "1" versionName = "1.0" [libraries] -android-gradle = { module = "com.android.tools.build:gradle", version = "7.2.1" } +android-gradle = { module = "com.android.tools.build:gradle", version = "7.3.1" } androidx-appcompat = { module = "androidx.appcompat:appcompat", version.ref = "androidx-appcompat" } androidx-core = { module = "androidx.core:core-ktx", version.ref = "androidx-core" } androidx-splash = { module = "androidx.core:core-splashscreen", version.ref = "androidx-splash" } -compose-activity = { module = "androidx.activity:activity-compose", version = "1.4.0" } +compose-activity = { module = "androidx.activity:activity-compose", version = "1.6.0" } compose-material = { module = "androidx.compose.material:material", version.ref = "compose" } +compose-material3 = { module = "androidx.compose.material3:material3", version.ref = "compose-material3" } +compose-material3-window = { module = "androidx.compose.material3:material3-window-size-class", version.ref = "compose-material3" } compose-test = { module = "androidx.compose.ui:ui-test-junit4", version.ref = "compose" } compose-tooling = { module = "androidx.compose.ui:ui-tooling", version.ref = "compose" } compose-ui = { module = "androidx.compose.ui:ui", version.ref = "compose" } -coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "coroutines" } -coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutines" } dagger-hilt = { module = "com.google.dagger:hilt-android-gradle-plugin", version.ref = "hilt-android" } espresso = { module = "androidx.test.espresso:espresso-core", version.ref = "espresso" } hilt-android-core = { module = "com.google.dagger:hilt-android", version.ref = "hilt-android" } @@ -51,12 +51,11 @@ ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" } ktor-client-logging = { module = "io.ktor:ktor-client-logging", version.ref = "ktor" } kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinx-coroutines" } kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "kotlinx-coroutines" } +kotlinx-datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "kotlinx-datetime" } kotlin-serialization = { module = "org.jetbrains.kotlin:kotlin-serialization", version.ref = "kotlin" } kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization" } material = { module = "com.google.android.material:material", version.ref = "material" } multiplatform-settings = { module = "com.russhwolf:multiplatform-settings-no-arg", version.ref = "settings" } -moshi-codegen = { module = "com.squareup.moshi:moshi-kotlin-codegen", version.ref = "moshi" } -moshi-core = { module = "com.squareup.moshi:moshi", version.ref = "moshi" } navigation-compose = { module = "androidx.navigation:navigation-compose", version = "navigation" } navigation-fragment = { module = "androidx.navigation:navigation-fragment-ktx", version.ref = "navigation" } navigation-ui = { module = "androidx.navigation:navigation-ui-ktx", version.ref = "navigation" } @@ -64,8 +63,8 @@ preference = { module = "androidx.preference:preference-ktx", version = "1.1.1" test-ext = { module = "androidx.test.ext:junit", version = "1.1.2" } [bundles] -compose = ["compose-ui", "compose-material", "compose-tooling", "compose-activity", "navigation-compose"] -coroutines = ["coroutines-core", "coroutines-android"] +compose = ["compose-ui", "compose-material", "compose-material3", "compose-material3-window", "compose-tooling", "compose-activity", "navigation-compose"] +coroutines = ["kotlinx-coroutines-core", "kotlinx-coroutines-android"] plugins = ["android-gradle", "kotlin-gradle", "dagger-hilt", "kotlin-serialization"] diff --git a/shared/build.gradle.kts b/shared/build.gradle.kts index b17a135..8f61ed6 100644 --- a/shared/build.gradle.kts +++ b/shared/build.gradle.kts @@ -20,6 +20,7 @@ kotlin { implementation(libs.ktor.client.serialization) implementation(libs.ktor.client.content.negotiation) implementation(libs.kotlinx.coroutines.core) + implementation(libs.kotlinx.datetime) implementation(libs.kotlinx.serialization.json) api(libs.multiplatform.settings) } diff --git a/shared/src/commonMain/kotlin/com/wbrawner/pihelper/shared/PiholeAPIService.kt b/shared/src/commonMain/kotlin/com/wbrawner/pihelper/shared/PiholeAPIService.kt index c638b2a..e047706 100644 --- a/shared/src/commonMain/kotlin/com/wbrawner/pihelper/shared/PiholeAPIService.kt +++ b/shared/src/commonMain/kotlin/com/wbrawner/pihelper/shared/PiholeAPIService.kt @@ -4,6 +4,7 @@ import io.ktor.client.* import io.ktor.client.call.* import io.ktor.client.engine.* import io.ktor.client.plugins.* +import io.ktor.client.plugins.contentnegotiation.* import io.ktor.client.request.* import io.ktor.http.* import io.ktor.serialization.kotlinx.json.* @@ -22,6 +23,7 @@ abstract class PiholeAPIService { abstract suspend fun enable(): StatusResponse abstract suspend fun disable(duration: Long? = null): StatusResponse + abstract suspend fun getDisabledDuration(): Long companion object } @@ -84,8 +86,15 @@ class KtorPiholeAPIService(val httpClient: HttpClient) : PiholeAPIService() { url { host = baseUrl ?: error("baseUrl not set") encodedPath = BASE_PATH - parameter("disable", duration?.toString()?: "") + parameter("disable", duration?.toString() ?: "") parameter("auth", apiKey) } }.body() + + override suspend fun getDisabledDuration(): Long = httpClient.get { + url { + host = baseUrl ?: error("baseUrl not set") + encodedPath = "/custom_disable_timer" + } + }.body().toLong() } diff --git a/shared/src/commonMain/kotlin/com/wbrawner/pihelper/shared/Responses.kt b/shared/src/commonMain/kotlin/com/wbrawner/pihelper/shared/Responses.kt index b558948..0ba9ea1 100644 --- a/shared/src/commonMain/kotlin/com/wbrawner/pihelper/shared/Responses.kt +++ b/shared/src/commonMain/kotlin/com/wbrawner/pihelper/shared/Responses.kt @@ -1,7 +1,12 @@ package com.wbrawner.pihelper.shared +import kotlinx.serialization.KSerializer import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable +import kotlinx.serialization.builtins.serializer +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder @Serializable() data class Summary( @@ -42,18 +47,34 @@ data class Summary( val version: Int? = null ) : StatusProvider -@Serializable -enum class Status { +@Serializable(with = Status.Serializer::class) +sealed class Status(val name: String) { @SerialName("enabled") - ENABLED, + object Enabled : Status("enabled") + @SerialName("disabled") - DISABLED, - @kotlinx.serialization.Transient - LOADING, - @kotlinx.serialization.Transient - UNKNOWN, - @kotlinx.serialization.Transient - ERROR + data class Disabled(val timeRemaining: String? = null) : Status(name) { + companion object { + const val name: String = "disabled" + } + } + + class Serializer : KSerializer { + override val descriptor: SerialDescriptor + get() = String.serializer().descriptor + + override fun deserialize(decoder: Decoder): Status { + return when (decoder.decodeString()) { + Enabled.name -> Enabled + Disabled.name -> Disabled() + else -> throw IllegalArgumentException("Invalid status") + } + } + + override fun serialize(encoder: Encoder, value: Status) { + encoder.encodeString(value.name) + } + } } @Serializable() diff --git a/shared/src/commonMain/kotlin/com/wbrawner/pihelper/shared/Store.kt b/shared/src/commonMain/kotlin/com/wbrawner/pihelper/shared/Store.kt index 3276262..6034867 100644 --- a/shared/src/commonMain/kotlin/com/wbrawner/pihelper/shared/Store.kt +++ b/shared/src/commonMain/kotlin/com/wbrawner/pihelper/shared/Store.kt @@ -8,6 +8,8 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow +import kotlinx.datetime.Clock +import kotlin.math.floor enum class Route(val path: String) { CONNECT("connect"), @@ -54,6 +56,10 @@ sealed interface Effect { const val KEY_HOST = "baseUrl" const val KEY_API_KEY = "apiKey" +private const val ONE_HOUR = 3_600_000 +private const val ONE_MINUTE = 60_000 +private const val ONE_SECOND = 1_000 + class Store( private val apiService: PiholeAPIService, private val settings: Settings = Settings(), @@ -204,50 +210,54 @@ class Store( } private fun enable() { - val loadingJob = launch { - delay(500) - _state.value = _state.value.copy(loading = true) - } launch { + _state.value = _state.value.copy(loading = true) try { apiService.enable() } catch (e: Exception) { _state.value = _state.value.copy(loading = false) _effects.emit(Effect.Error(e.message ?: "Failed to enable Pi-hole")) - } finally { - loadingJob.cancel("") } } } private fun disable(duration: Long?) { - val loadingJob = launch { - delay(500) - _state.value = _state.value.copy(loading = true) - } launch { + _state.value = _state.value.copy(loading = true) try { apiService.disable(duration) } catch (e: Exception) { _state.value = _state.value.copy(loading = false) _effects.emit(Effect.Error(e.message ?: "Failed to disable Pi-hole")) - } finally { - loadingJob.cancel("") } } } private suspend fun getStatus() { - val loadingJob = launch { - delay(500) - _state.value = _state.value.copy(loading = true) + val loadingJob = coroutineScope { + launch { + delay(1000) + _state.value = _state.value.copy(loading = true) + } } try { val summary = apiService.getSummary() + var status = summary.status + if (status is Status.Disabled) { + try { + val until = apiService.getDisabledDuration() + val now = Clock.System.now().toEpochMilliseconds() + if (now > until) return + status = status.copy(timeRemaining = (until - now).toDurationString()) + } catch (ignored: Exception) { + // This isn't critical to the operation of the app so errors are unimportant + ignored.printStackTrace() + } + } loadingJob.cancel("") - // TODO: If status is disabled, check for how long - _state.value = _state.value.copy(status = summary.status, loading = false) + _state.value = _state.value.copy(status = status, loading = false) } catch (e: Exception) { + e.printStackTrace() _state.value = _state.value.copy(loading = false) _effects.emit(Effect.Error(e.message ?: "Failed to load status")) } finally { @@ -267,4 +277,31 @@ class Store( companion object } -expect fun String.hash(): String \ No newline at end of file +expect fun String.hash(): String + +fun Long.toDurationString(): String { + var timeRemaining = toDouble() + var formattedTimeRemaining = "" + if (timeRemaining > ONE_HOUR) { + formattedTimeRemaining += floor(timeRemaining / ONE_HOUR) + .toInt() + .toString() + ":" + timeRemaining %= ONE_HOUR + } + if (timeRemaining > ONE_MINUTE) { + val minutesLength = if (formattedTimeRemaining.isBlank()) 1 else 2 + formattedTimeRemaining += floor(timeRemaining / ONE_MINUTE) + .toInt() + .toString() + .padStart(minutesLength, '0') + ':' + timeRemaining %= ONE_MINUTE + } else if (formattedTimeRemaining.isNotBlank()) { + formattedTimeRemaining += "00:" + } + val secondsLength = if (formattedTimeRemaining.isBlank()) 1 else 2 + formattedTimeRemaining += floor(timeRemaining / ONE_SECOND) + .toInt() + .toString() + .padStart(secondsLength, '0') + return formattedTimeRemaining +} \ No newline at end of file