Material3, Disabled duration check, dependency updates

Got a bit carried away here 😅
This commit is contained in:
William Brawner 2022-12-15 08:56:05 -06:00
parent c40e8f0274
commit 8a2ae66b2f
18 changed files with 283 additions and 203 deletions

View file

@ -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)

View file

@ -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),
)
}
}

View file

@ -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("") }

View file

@ -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
),
) {

View file

@ -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)
}
}

View file

@ -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 {}
}
}

View file

@ -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)
}
)
}
}
@Preview(uiMode = UI_MODE_NIGHT_NO)
@Preview(uiMode = UI_MODE_NIGHT_YES)
annotation class DayNightPreview

View file

@ -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
)
)

View file

@ -6,4 +6,5 @@
android:inset="15%" />
</foreground>
<background android:drawable="@color/colorSurface" />
<monochrome android:drawable="@drawable/ic_play_arrow" />
</adaptive-icon>

View file

@ -6,4 +6,5 @@
android:inset="20%" />
</foreground>
<background android:drawable="@color/colorSurface" />
<monochrome android:drawable="@drawable/ic_pause" />
</adaptive-icon>

View file

@ -4,6 +4,6 @@
android:viewportWidth="24.0"
android:viewportHeight="24.0">
<path
android:fillColor="@color/colorRedDark"
android:fillColor="@color/colorWhite"
android:pathData="M6,19h4L10,5L6,5v14zM14,5v14h4L18,5h-4z"/>
</vector>

View file

@ -2,4 +2,5 @@
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>

View file

@ -2,4 +2,5 @@
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>

View file

@ -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"]

View file

@ -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)
}

View file

@ -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<String>().toLong()
}

View file

@ -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<Status> {
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()

View file

@ -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
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
}