Material3, Disabled duration check, dependency updates
Got a bit carried away here 😅
This commit is contained in:
parent
c40e8f0274
commit
8a2ae66b2f
18 changed files with 283 additions and 203 deletions
|
@ -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)
|
||||
|
|
|
@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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("") }
|
||||
|
|
|
@ -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
|
||||
),
|
||||
) {
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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 {}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
|
@ -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
|
||||
)
|
||||
)
|
|
@ -6,4 +6,5 @@
|
|||
android:inset="15%" />
|
||||
</foreground>
|
||||
<background android:drawable="@color/colorSurface" />
|
||||
<monochrome android:drawable="@drawable/ic_play_arrow" />
|
||||
</adaptive-icon>
|
|
@ -6,4 +6,5 @@
|
|||
android:inset="20%" />
|
||||
</foreground>
|
||||
<background android:drawable="@color/colorSurface" />
|
||||
<monochrome android:drawable="@drawable/ic_pause" />
|
||||
</adaptive-icon>
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
|
@ -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>
|
|
@ -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"]
|
||||
|
||||
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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
|
||||
}
|
Loading…
Reference in a new issue