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