diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml new file mode 100644 index 0000000..60eed15 --- /dev/null +++ b/.idea/inspectionProfiles/Project_Default.xml @@ -0,0 +1,8 @@ + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml index a740c72..89ababe 100644 --- a/.idea/misc.xml +++ b/.idea/misc.xml @@ -5,6 +5,48 @@ + + + diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 721fc23..1a060f9 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -59,14 +59,16 @@ android { jvmTarget = "1.8" } buildFeatures { - viewBinding = true + compose = true } } dependencies { implementation(project(":piholeclient")) implementation(libs.bundles.coroutines) + implementation(libs.bundles.compose) implementation(libs.hilt.android.core) + implementation(libs.hilt.navigation.compose) kapt(libs.hilt.android.kapt) implementation(libs.androidx.core) implementation(libs.androidx.appcompat) @@ -78,7 +80,5 @@ dependencies { testImplementation(libs.junit) androidTestImplementation(libs.test.ext) androidTestImplementation(libs.espresso) - implementation(libs.bundles.navigation) - implementation(libs.bundles.lifecycle) } diff --git a/app/src/main/java/com/wbrawner/pihelper/AddPiHelperViewModel.kt b/app/src/main/java/com/wbrawner/pihelper/AddPiHelperViewModel.kt index 3e824ae..e829370 100644 --- a/app/src/main/java/com/wbrawner/pihelper/AddPiHelperViewModel.kt +++ b/app/src/main/java/com/wbrawner/pihelper/AddPiHelperViewModel.kt @@ -3,12 +3,12 @@ package com.wbrawner.pihelper import android.content.SharedPreferences import android.util.Log import androidx.core.content.edit -import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import com.wbrawner.piholeclient.PiHoleApiService import com.wbrawner.piholeclient.VersionResponse import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.withContext import java.net.ConnectException import java.net.SocketTimeoutException @@ -48,11 +48,10 @@ class AddPiHelperViewModel @Inject constructor( apiService.apiKey = this.apiKey } - val piHoleIpAddress = MutableLiveData() - val scanningIp = MutableLiveData() - val authenticated = MutableLiveData() + val loadingMessage = MutableStateFlow(null) + val errorMessage = MutableStateFlow(null) - suspend fun beginScanning(deviceIpAddress: String) { + suspend fun beginScanning(deviceIpAddress: String, onSuccess: () -> Unit, onFailure: () -> Unit) { val addressParts = deviceIpAddress.split(".").toMutableList() var chunks = 1 // If the Pi-hole is correctly set up, then there should be a special host for it as @@ -71,20 +70,25 @@ class AddPiHelperViewModel @Inject constructor( } chunks *= 2 } - scan(ipAddresses) + if (scan(ipAddresses)) { + onSuccess() + } else { + onFailure() + } } - private suspend fun scan(ipAddresses: MutableList) { + private suspend fun scan(ipAddresses: MutableList): Boolean { if (ipAddresses.isEmpty()) { - scanningIp.postValue(null) - piHoleIpAddress.postValue(null) - return + loadingMessage.value = null + return false } val ipAddress = ipAddresses.removeAt(0) - scanningIp.postValue(ipAddress) - if (!connectToIpAddress(ipAddress)) { + loadingMessage.value = "Scanning $ipAddress..." + return if (!connectToIpAddress(ipAddress)) { scan(ipAddresses) + } else { + true } } @@ -102,33 +106,32 @@ class AddPiHelperViewModel @Inject constructor( null } } - return if (version == null) { - false - } else { - piHoleIpAddress.postValue(ipAddress) + if (version != null) { baseUrl = ipAddress - true + return true } + return false } - suspend fun authenticateWithPassword(password: String) { + suspend fun authenticateWithPassword(password: String): Boolean { // The Pi-hole API key is just the web password hashed twice with SHA-256 - authenticateWithApiKey(password.hash().hash()) + return authenticateWithApiKey(password.hash().hash()) } - suspend fun authenticateWithApiKey(apiKey: String) { + suspend fun authenticateWithApiKey(apiKey: String): Boolean { // This uses the topItems endpoint to test that the API key is working since it requires // authentication and is fairly simple to determine whether or not the request was // successful + errorMessage.value = null apiService.apiKey = apiKey - try { + return try { apiService.getTopItems() this.apiKey = apiKey - authenticated.postValue(true) + true } catch (e: Exception) { Log.e("Pi-helper", "Unable to authenticate with API key", e) - authenticated.postValue(false) - throw e + errorMessage.value = "Authentication failed" + false } } diff --git a/app/src/main/java/com/wbrawner/pihelper/AddPiHoleFragment.kt b/app/src/main/java/com/wbrawner/pihelper/AddPiHoleFragment.kt deleted file mode 100644 index cbe6f6b..0000000 --- a/app/src/main/java/com/wbrawner/pihelper/AddPiHoleFragment.kt +++ /dev/null @@ -1,97 +0,0 @@ -package com.wbrawner.pihelper - - -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import android.view.animation.Animation -import android.view.animation.LinearInterpolator -import android.view.animation.RotateAnimation -import androidx.appcompat.app.AlertDialog -import androidx.fragment.app.Fragment -import androidx.fragment.app.activityViewModels -import androidx.lifecycle.lifecycleScope -import androidx.navigation.fragment.FragmentNavigatorExtras -import androidx.navigation.fragment.findNavController -import com.wbrawner.pihelper.databinding.FragmentAddPiHoleBinding -import dagger.hilt.android.AndroidEntryPoint - -@AndroidEntryPoint -class AddPiHoleFragment : Fragment() { - - private val viewModel: AddPiHelperViewModel by activityViewModels() - private var _binding: FragmentAddPiHoleBinding? = null - private val binding get() = _binding!! - - override fun onCreateView( - inflater: LayoutInflater, container: ViewGroup?, - savedInstanceState: Bundle? - ): View { - _binding = FragmentAddPiHoleBinding.inflate(inflater, container, false) - return binding.root - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - val navController = findNavController() - binding.scanNetworkButton.setOnClickListener { - navController.navigate( - R.id.action_addPiHoleFragment_to_scanNetworkFragment, - null, - null, - FragmentNavigatorExtras(binding.piHelperLogo to "piHelperLogo") - ) - } - binding.ipAddress.setOnEditorActionListener { _, _, _ -> - binding.connectButton.performClick() - } - binding.connectButton.setSuspendingOnClickListener(lifecycleScope) { - showProgress(true) - if (viewModel.connectToIpAddress(binding.ipAddress.text.toString())) { - navController.navigate( - R.id.action_addPiHoleFragment_to_retrieveApiKeyFragment, - null, - null, - FragmentNavigatorExtras(binding.piHelperLogo to "piHelperLogo") - ) - } else { - AlertDialog.Builder(view.context) - .setTitle(R.string.connection_failed_title) - .setMessage(R.string.connection_failed) - .setPositiveButton(android.R.string.ok) { _, _ -> } - .show() - } - showProgress(false) - } - } - - private fun showProgress(show: Boolean) { - if (show) { - binding.piHelperLogo.startAnimation( - RotateAnimation( - 0f, - 360f, - Animation.RELATIVE_TO_SELF, - 0.5f, - Animation.RELATIVE_TO_SELF, - 0.5f - ).apply { - duration = - resources.getInteger(android.R.integer.config_longAnimTime).toLong() * 2 - repeatMode = Animation.RESTART - repeatCount = Animation.INFINITE - interpolator = LinearInterpolator() - fillAfter = true - } - ) - } else { - binding.piHelperLogo.clearAnimation() - } - binding.connectionForm.visibility = if (show) View.GONE else View.VISIBLE - } - - override fun onDestroyView() { - super.onDestroyView() - _binding = null - } -} diff --git a/app/src/main/java/com/wbrawner/pihelper/AddScreen.kt b/app/src/main/java/com/wbrawner/pihelper/AddScreen.kt new file mode 100644 index 0000000..5f11e0e --- /dev/null +++ b/app/src/main/java/com/wbrawner/pihelper/AddScreen.kt @@ -0,0 +1,135 @@ +package com.wbrawner.pihelper + +import android.util.Log +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.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.navigation.NavController +import com.wbrawner.pihelper.ui.PihelperTheme +import kotlinx.coroutines.launch + +@Composable +fun AddScreen(navController: NavController, addPiHelperViewModel: AddPiHelperViewModel) { + val coroutineScope = rememberCoroutineScope() + val loadingMessage by addPiHelperViewModel.loadingMessage.collectAsState() + AddPiholeForm( + scanNetwork = { navController.navigate(Screens.SCAN.route) }, + connectToPihole = { + coroutineScope.launch { + if (addPiHelperViewModel.connectToIpAddress(it)) { + Log.d("AddScreen", "Connected, going to auth") + navController.navigate(Screens.AUTH.route) + } + } + }, + loadingMessage = loadingMessage + ) +} + +@Composable +fun AddPiholeForm( + scanNetwork: () -> Unit, + connectToPihole: (String) -> Unit, + loadingMessage: String? = null +) { + val (host: String, setHost: (String) -> Unit) = remember { mutableStateOf("pi.hole") } + Column( + modifier = Modifier + .padding(16.dp) + .fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterVertically), + ) { + LoadingSpinner(loadingMessage != null) + Text( + text = "If you're not sure what the IP address for your Pi-hole is, Pi-helper can " + + "attempt to find it for you by scanning your network.", + textAlign = TextAlign.Center + ) + PrimaryButton(text = "Scan Network", onClick = scanNetwork) + OrDivider() + Text( + text = "If you already know the IP address or host of your Pi-hole, you can also " + + "enter it below:", + textAlign = TextAlign.Center + ) + OutlinedTextField( + modifier = Modifier.fillMaxWidth(), + value = host, + onValueChange = setHost, + label = { Text("Pi-hole Host") } + ) + PrimaryButton(text = "Connect to Pi-hole", onClick = { connectToPihole(host) }) + } +} + +@Composable +fun OrDivider() { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 8.dp), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically + ) { + Box( + modifier = Modifier + .height(2.dp) + .weight(1f) + .padding(end = 8.dp) + .clip(RectangleShape) + .background(MaterialTheme.colors.onSurface), + ) + Text("OR") + Box( + modifier = Modifier + .height(2.dp) + .weight(1f) + .padding(start = 8.dp) + .clip(RectangleShape) + .background(MaterialTheme.colors.onSurface), + ) + } +} + +@Composable +@Preview +fun AddPiholeForm_Preview() { + PihelperTheme(false) { + AddPiholeForm(scanNetwork = {}, {}) + } +} + +@Composable +@Preview +fun AddPiholeForm_DarkPreview() { + PihelperTheme(true) { + AddPiholeForm(scanNetwork = {}, {}) + } +} + +@Composable +@Preview +fun OrDivider_Preview() { + PihelperTheme(false) { + OrDivider() + } +} + +@Composable +@Preview +fun OrDivider_DarkPreview() { + PihelperTheme(true) { + OrDivider() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/wbrawner/pihelper/AuthScreen.kt b/app/src/main/java/com/wbrawner/pihelper/AuthScreen.kt new file mode 100644 index 0000000..1478af6 --- /dev/null +++ b/app/src/main/java/com/wbrawner/pihelper/AuthScreen.kt @@ -0,0 +1,78 @@ +package com.wbrawner.pihelper + +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.runtime.Composable +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.navigation.NavController +import kotlinx.coroutines.launch + +@Composable +fun AuthScreen(navController: NavController, addPiHelperViewModel: AddPiHelperViewModel) { + val (password: String, setPassword: (String) -> Unit) = remember { mutableStateOf("") } + val (apiKey: String, setApiKey: (String) -> Unit) = remember { mutableStateOf("") } + val coroutineScope = rememberCoroutineScope() + val scrollState = rememberScrollState() + Column( + modifier = Modifier + .padding(16.dp) + .fillMaxSize() + .verticalScroll(scrollState), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterVertically), + ) { + Image( + painter = painterResource(id = R.drawable.ic_app_logo), + contentDescription = null + ) + Text( + text = "Pi-helper has successfully connected to your Pi-Hole!", + textAlign = TextAlign.Center + ) + Text( + text = "You'll need to authenticate in order to enable and disable the Pi-hole.", + textAlign = TextAlign.Center + ) + OutlinedTextField( + modifier = Modifier.fillMaxWidth(), + value = password, + onValueChange = setPassword, + label = { Text("Pi-hole Password") }, + visualTransformation = PasswordVisualTransformation() + ) + PrimaryButton(text = "Authenticate with Password") { + coroutineScope.launch { + if (addPiHelperViewModel.authenticateWithPassword(password)) { + navController.navigate(Screens.MAIN.route) + } + } + } + OrDivider() + OutlinedTextField( + modifier = Modifier.fillMaxWidth(), + value = apiKey, + onValueChange = setApiKey, + label = { Text("Pi-hole API Key") }, + visualTransformation = PasswordVisualTransformation(), + ) + PrimaryButton(text = "Authenticate with API Key") { + coroutineScope.launch { + if (addPiHelperViewModel.authenticateWithApiKey(password)) { + navController.navigate(Screens.MAIN.route) + } + } + } + } +} diff --git a/app/src/main/java/com/wbrawner/pihelper/InfoFragment.kt b/app/src/main/java/com/wbrawner/pihelper/InfoFragment.kt deleted file mode 100644 index bc4e0e1..0000000 --- a/app/src/main/java/com/wbrawner/pihelper/InfoFragment.kt +++ /dev/null @@ -1,87 +0,0 @@ -package com.wbrawner.pihelper - - -import android.content.Intent -import android.os.Build -import android.os.Bundle -import android.text.Html -import android.text.method.LinkMovementMethod -import android.view.LayoutInflater -import android.view.MenuItem -import android.view.View -import android.view.ViewGroup -import androidx.appcompat.app.AlertDialog -import androidx.appcompat.app.AppCompatActivity -import androidx.fragment.app.Fragment -import androidx.fragment.app.activityViewModels -import androidx.navigation.fragment.findNavController -import com.wbrawner.pihelper.MainActivity.Companion.ACTION_FORGET_PIHOLE -import com.wbrawner.pihelper.databinding.FragmentInfoBinding -import dagger.hilt.android.AndroidEntryPoint - -@AndroidEntryPoint -class InfoFragment : Fragment() { - private val viewModel: AddPiHelperViewModel by activityViewModels() - private var _binding: FragmentInfoBinding? = null - private val binding get() = _binding!! - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - setHasOptionsMenu(true) - } - - override fun onCreateView( - inflater: LayoutInflater, container: ViewGroup?, - savedInstanceState: Bundle? - ): View { - _binding = FragmentInfoBinding.inflate(inflater, container, false) - return binding.root - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - (activity as? AppCompatActivity)?.setSupportActionBar(binding.toolbar) - (activity as? AppCompatActivity)?.supportActionBar?.setDisplayHomeAsUpEnabled(true) - (activity as? AppCompatActivity)?.supportActionBar?.setTitle(R.string.action_settings) - val html = getString(R.string.content_info) - @Suppress("DEPRECATION") - binding.infoContent.text = if (Build.VERSION.SDK_INT < 24) - Html.fromHtml(html) - else - Html.fromHtml(html, 0) - binding.infoContent.movementMethod = LinkMovementMethod.getInstance() - binding.forgetPiHoleButton.setOnClickListener { - AlertDialog.Builder(view.context) - .setTitle(R.string.confirm_forget_pihole) - .setMessage(R.string.warning_cannot_be_undone) - .setNegativeButton(android.R.string.cancel) { _, _ -> } - .setPositiveButton(R.string.action_forget_pihole) { _, _ -> - viewModel.forgetPihole() - val refreshIntent = Intent( - view.context.applicationContext, - MainActivity::class.java - ).apply { - action = ACTION_FORGET_PIHOLE - addFlags( - Intent.FLAG_ACTIVITY_CLEAR_TASK - and Intent.FLAG_ACTIVITY_NEW_TASK - ) - } - activity?.startActivity(refreshIntent) - } - .show() - } - } - - override fun onOptionsItemSelected(item: MenuItem): Boolean { - if (item.itemId == android.R.id.home) { - findNavController().navigateUp() - return true - } - return super.onOptionsItemSelected(item) - } - - override fun onDestroyView() { - super.onDestroyView() - _binding = null - } -} diff --git a/app/src/main/java/com/wbrawner/pihelper/InfoScreen.kt b/app/src/main/java/com/wbrawner/pihelper/InfoScreen.kt new file mode 100644 index 0000000..c06a53e --- /dev/null +++ b/app/src/main/java/com/wbrawner/pihelper/InfoScreen.kt @@ -0,0 +1,88 @@ +package com.wbrawner.pihelper + +import androidx.compose.foundation.layout.Arrangement +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.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.material.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.unit.dp +import androidx.core.content.ContextCompat.startActivity +import androidx.navigation.NavController +import androidx.navigation.NavOptions + +@Composable +fun InfoScreen(navController: NavController, addPiHelperViewModel: AddPiHelperViewModel) { + Column( + modifier = Modifier + .padding(16.dp) + .fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterVertically), + ) { + LoadingSpinner() + val message = buildAnnotatedString { + val text = "Pi-helper was made with ❤ by William Brawner. You can find the source " + + "code or report issues on the GitHub page for the project." + val name = text.indexOf("William") + val github = text.indexOf("GitHub") + append(text) + addStringAnnotation( + "me", + annotation = "https://wbrawner.com", + start = name, + end = name + 15 + ) + addStyle( + style = SpanStyle( + color = MaterialTheme.colors.primary, + textDecoration = TextDecoration.Underline + ), + start = name, + end = name + 15 + ) + addStringAnnotation( + "github", + annotation = "https://github.com/wbrawner/pihelper-android", + start = github, + end = github + 11, + ) + addStyle( + style = SpanStyle( + color = MaterialTheme.colors.primary, + textDecoration = TextDecoration.Underline + ), + start = github, + end = github + 11, + ) + } + val uriHandler = LocalUriHandler.current + ClickableText( + text = message, + style = TextStyle.Default.copy(textAlign = TextAlign.Center) + ) { + message.getStringAnnotations(it, it).firstOrNull()?.let { annotation -> + uriHandler.openUri(annotation.item) + } + } + TextButton(onClick = { + addPiHelperViewModel.forgetPihole() + navController.navigate(Screens.ADD.route) + navController.backQueue.clear() + }) { + Text(text = "Forget Pi-hole") + } + } +} diff --git a/app/src/main/java/com/wbrawner/pihelper/MainActivity.kt b/app/src/main/java/com/wbrawner/pihelper/MainActivity.kt index 2bf92d3..f870519 100644 --- a/app/src/main/java/com/wbrawner/pihelper/MainActivity.kt +++ b/app/src/main/java/com/wbrawner/pihelper/MainActivity.kt @@ -1,99 +1,109 @@ package com.wbrawner.pihelper -import android.graphics.drawable.ColorDrawable import android.os.Bundle -import android.widget.Toast +import androidx.activity.compose.setContent import androidx.activity.viewModels import androidx.appcompat.app.AppCompatActivity -import androidx.core.content.ContextCompat -import androidx.navigation.NavController -import androidx.navigation.findNavController -import com.wbrawner.pihelper.MainFragment.Companion.ACTION_DISABLE -import com.wbrawner.pihelper.MainFragment.Companion.ACTION_ENABLE -import com.wbrawner.pihelper.MainFragment.Companion.EXTRA_DURATION -import com.wbrawner.pihelper.databinding.ActivityMainBinding +import androidx.compose.animation.ExperimentalAnimationApi +import androidx.compose.animation.core.* +import androidx.compose.foundation.Image +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Surface +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.rotate +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.rememberNavController +import com.wbrawner.pihelper.ui.PihelperTheme import dagger.hilt.android.AndroidEntryPoint +@ExperimentalAnimationApi @AndroidEntryPoint class MainActivity : AppCompatActivity() { - private val addPiHoleViewModel: AddPiHelperViewModel by viewModels() - private val navController: NavController by lazy { - findNavController(R.id.content_main) - } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - val binding = ActivityMainBinding.inflate(layoutInflater) - setContentView(binding.root) - window.setBackgroundDrawable( - ColorDrawable( - ContextCompat.getColor( - this, - R.color.colorSurface - ) - ) - ) - val args = when (intent.action) { - ACTION_ENABLE -> { - if (addPiHoleViewModel.apiKey == null) { - Toast.makeText(this, R.string.configure_pihelper, Toast.LENGTH_SHORT).show() - null - } else { - Bundle().apply { putBoolean(ACTION_ENABLE, true) } + setContent { + val navController = rememberNavController() + val addPiHoleViewModel: AddPiHelperViewModel = viewModel() + val startDestination = when { + addPiHoleViewModel.baseUrl == null -> { + Screens.ADD + } + addPiHoleViewModel.apiKey == null -> { + Screens.AUTH + } + else -> { + Screens.MAIN } } - ACTION_DISABLE -> { - if (addPiHoleViewModel.apiKey == null) { - Toast.makeText(this, R.string.configure_pihelper, Toast.LENGTH_SHORT).show() - null - } else { - Bundle().apply { - putBoolean(ACTION_DISABLE, true) - putLong(EXTRA_DURATION, intent.getIntExtra(EXTRA_DURATION, 10).toLong()) + PihelperTheme { + NavHost(navController, startDestination = startDestination.route) { + composable(Screens.ADD.route) { + AddScreen(navController, addPiHoleViewModel) + } + composable(Screens.SCAN.route) { + ScanScreen(navController, addPiHoleViewModel) + } + composable(Screens.AUTH.route) { + AuthScreen( + navController = navController, + addPiHelperViewModel = addPiHoleViewModel + ) + } + composable(Screens.MAIN.route) { + MainScreen(navController = navController) + } + composable(Screens.INFO.route) { + InfoScreen( + navController = navController, + addPiHelperViewModel = addPiHoleViewModel + ) } } } - ACTION_FORGET_PIHOLE -> { - if (intent.component?.packageName == packageName) { - while (navController.popBackStack()) { - // Do nothing, just pop all the items off the back stack - } - // Just return an empty bundle so that the navigation branch below will load - // the correct screen - Bundle() - } else { - null - } - } - else -> null } - when { - navController.currentDestination?.id != R.id.placeholder && args == null -> { - return - } - addPiHoleViewModel.baseUrl.isNullOrBlank() -> { - navController.navigate(R.id.addPiHoleFragment, args) - } - addPiHoleViewModel.apiKey.isNullOrBlank() -> { - navController.navigate(R.id.addPiHoleFragment) - navController.navigate(R.id.retrieveApiKeyFragment, args) - } - else -> { - navController.navigate(R.id.mainFragment, args) - } - } - } - - override fun onBackPressed() { - if (!navController.navigateUp()) { - finish() - } - if (navController.currentDestination?.id == R.id.placeholder) { - finish() - } - } - - companion object { - const val ACTION_FORGET_PIHOLE = "com.wbrawner.pihelper.ACTION_FORGET_PIHOLE" } } + +enum class Screens(val route: String) { + ADD("add"), + SCAN("scan"), + AUTH("auth"), + MAIN("main"), + INFO("info"), +} + +@Composable +fun LoadingSpinner(animate: Boolean = false) { + val animation = rememberInfiniteTransition() + val rotation by animation.animateValue( + initialValue = 0f, + targetValue = 360f, + typeConverter = Float.VectorConverter, + animationSpec = infiniteRepeatable( + animation = tween(1000, easing = LinearEasing), + repeatMode = RepeatMode.Restart + ) + ) + Image( + modifier = Modifier.rotate(if (animate) rotation else 0f), + painter = painterResource(id = R.drawable.ic_app_logo), + contentDescription = "Loading", + colorFilter = ColorFilter.tint(MaterialTheme.colors.onBackground) + ) +} + +@Composable +@Preview +fun LoadingSpinner_Preview() { + LoadingSpinner() +} \ No newline at end of file diff --git a/app/src/main/java/com/wbrawner/pihelper/MainFragment.kt b/app/src/main/java/com/wbrawner/pihelper/MainFragment.kt deleted file mode 100644 index aa0dc2b..0000000 --- a/app/src/main/java/com/wbrawner/pihelper/MainFragment.kt +++ /dev/null @@ -1,216 +0,0 @@ -package com.wbrawner.pihelper - - -import android.graphics.Typeface -import android.os.Bundle -import android.text.SpannableString -import android.text.style.ForegroundColorSpan -import android.text.style.StyleSpan -import android.util.Log -import android.view.* -import android.view.animation.Animation -import android.view.animation.LinearInterpolator -import android.view.animation.RotateAnimation -import androidx.appcompat.app.AlertDialog -import androidx.appcompat.app.AppCompatActivity -import androidx.core.content.ContextCompat.getColor -import androidx.core.text.set -import androidx.fragment.app.Fragment -import androidx.fragment.app.activityViewModels -import androidx.lifecycle.lifecycleScope -import androidx.navigation.fragment.findNavController -import com.wbrawner.pihelper.databinding.DialogDisableCustomTimeBinding -import com.wbrawner.pihelper.databinding.FragmentMainBinding -import com.wbrawner.piholeclient.Status -import dagger.hilt.android.AndroidEntryPoint -import kotlinx.coroutines.launch - -@AndroidEntryPoint -class MainFragment : Fragment() { - private val viewModel: PiHelperViewModel by activityViewModels() - private var _binding: FragmentMainBinding? = null - private val binding get() = _binding!! - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - setHasOptionsMenu(true) - lifecycleScope.launch { - if (arguments?.getBoolean(ACTION_ENABLE) == true) { - viewModel.enablePiHole() - } else if (arguments?.getBoolean(ACTION_DISABLE) == true) { - viewModel.disablePiHole(arguments?.getLong(EXTRA_DURATION)) - } - viewModel.monitorSummary() - } - viewModel.status.observe(this, { - showProgress(false) - val (statusColor, statusText) = when (it) { - Status.DISABLED -> { - binding.enableButton.visibility = View.VISIBLE - binding.disableButtons.visibility = View.GONE - Pair(R.color.colorDisabled, R.string.status_disabled) - } - Status.ENABLED -> { - binding.enableButton.visibility = View.GONE - binding.disableButtons.visibility = View.VISIBLE - Pair(R.color.colorEnabled, R.string.status_enabled) - } - else -> { - binding.enableButton.visibility = View.GONE - binding.disableButtons.visibility = View.GONE - Pair(R.color.colorUnknown, R.string.status_unknown) - } - } - val status = getString(statusText) - val statusLabel = getString(R.string.label_status, status) - val start = statusLabel.indexOf(status) - val end = start + status.length - val statusSpan = SpannableString(statusLabel) - statusSpan[start, end] = StyleSpan(Typeface.BOLD) - statusSpan[start, end] = - ForegroundColorSpan(getColor(binding.status.context, statusColor)) - binding.status.text = statusSpan - }) - } - - override fun onCreateView( - inflater: LayoutInflater, container: ViewGroup?, - savedInstanceState: Bundle? - ): View { - _binding = FragmentMainBinding.inflate(inflater, container, false) - return binding.root - } - - override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) { - inflater.inflate(R.menu.main, menu) - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - (activity as? AppCompatActivity)?.setSupportActionBar(binding.toolbar) - showProgress(true) - binding.enableButton.setSuspendingOnClickListener(lifecycleScope) { - showProgress(true) - try { - viewModel.enablePiHole() - } catch (ignored: Exception) { - Log.e("Pi-helper", "Failed to enable Pi-Hole", ignored) - } - } - binding.disable10SecondsButton.setSuspendingOnClickListener(lifecycleScope) { - showProgress(true) - try { - viewModel.disablePiHole(10) - } catch (ignored: Exception) { - Log.e("Pi-helper", "Failed to disable Pi-Hole", ignored) - } - } - binding.disable30SecondsButton.setSuspendingOnClickListener(lifecycleScope) { - showProgress(true) - try { - viewModel.disablePiHole(30) - } catch (ignored: Exception) { - Log.e("Pi-helper", "Failed to disable Pi-Hole", ignored) - } - } - binding.disable5MinutesButton.setSuspendingOnClickListener(lifecycleScope) { - showProgress(true) - try { - viewModel.disablePiHole(300) - } catch (ignored: Exception) { - Log.e("Pi-helper", "Failed to disable Pi-Hole", ignored) - } - } - binding.disableCustomTimeButton.setOnClickListener { - val dialogView = DialogDisableCustomTimeBinding.inflate( - LayoutInflater.from(it.context), - view as ViewGroup, - false - ) - AlertDialog.Builder(it.context) - .setTitle(R.string.action_disable_custom) - .setNegativeButton(android.R.string.cancel) { _, _ -> } - .setPositiveButton(R.string.action_disable, null) - .setView(dialogView.root) - .create() - .apply { - setOnShowListener { - getButton(AlertDialog.BUTTON_POSITIVE) - .setSuspendingOnClickListener(lifecycleScope) { - try { - val rawTime = dialogView.time - .text - .toString() - .toLong() - val computedTime = - when (dialogView.timeUnit.checkedRadioButtonId) { - R.id.seconds -> rawTime - R.id.minutes -> rawTime * 60 - else -> rawTime * 3600 - } - viewModel.disablePiHole(computedTime) - dismiss() - } catch (e: Exception) { - dialogView.time.error = "Failed to disable Pi-hole" - } - } - } - } - .show() - } - binding.disablePermanentlyButton.setSuspendingOnClickListener(lifecycleScope) { - showProgress(true) - try { - viewModel.disablePiHole() - } catch (ignored: Exception) { - Log.e("Pi-helper", "Failed to disable Pi-Hole", ignored) - } - } - } - - override fun onOptionsItemSelected(item: MenuItem): Boolean { - if (item.itemId == R.id.settings) { - findNavController().navigate(R.id.action_mainFragment_to_settingsFragment) - return true - } - return super.onOptionsItemSelected(item) - } - - private fun showProgress(show: Boolean) { - binding.progressBar.visibility = if (show) { - binding.progressBar.startAnimation(RotateAnimation( - 0f, - 360f, - Animation.RELATIVE_TO_SELF, - 0.5f, - Animation.RELATIVE_TO_SELF, - 0.5f - ).apply { - duration = resources.getInteger(android.R.integer.config_longAnimTime).toLong() * 2 - repeatMode = Animation.RESTART - repeatCount = Animation.INFINITE - interpolator = LinearInterpolator() - fillAfter = true - }) - View.VISIBLE - } else { - binding.progressBar.clearAnimation() - View.GONE - } - binding.statusContent.visibility = if (show) { - View.GONE - } else { - View.VISIBLE - } - } - - override fun onDestroyView() { - _binding = null - super.onDestroyView() - } - - companion object { - const val ACTION_DISABLE = "com.wbrawner.pihelper.MainFragment.ACTION_DISABLE" - const val ACTION_ENABLE = "com.wbrawner.pihelper.MainFragment.ACTION_ENABLE" - const val EXTRA_DURATION = "com.wbrawner.pihelper.MainFragment.EXTRA_DURATION" - } -} diff --git a/app/src/main/java/com/wbrawner/pihelper/MainScreen.kt b/app/src/main/java/com/wbrawner/pihelper/MainScreen.kt new file mode 100644 index 0000000..bb85343 --- /dev/null +++ b/app/src/main/java/com/wbrawner/pihelper/MainScreen.kt @@ -0,0 +1,309 @@ +package com.wbrawner.pihelper + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.ExperimentalAnimationApi +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.runtime.* +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 androidx.hilt.navigation.compose.hiltViewModel +import androidx.navigation.NavController +import androidx.navigation.compose.rememberNavController +import com.wbrawner.pihelper.ui.PihelperTheme +import com.wbrawner.piholeclient.Status +import java.util.* +import kotlin.math.pow +import kotlin.math.roundToLong + +@ExperimentalAnimationApi +@Composable +fun MainScreen(navController: NavController, viewModel: PiHelperViewModel = hiltViewModel()) { + LaunchedEffect(key1 = viewModel) { + viewModel.monitorSummary() + } + val status by viewModel.status.collectAsState() + Scaffold( + topBar = { + TopAppBar( + title = { Text("Pi-helper") }, + backgroundColor = MaterialTheme.colors.background, + contentColor = MaterialTheme.colors.onBackground, + elevation = 0.dp, + actions = { + IconButton(onClick = { navController.navigate(Screens.INFO.route) }) { + Icon( + imageVector = Icons.Default.Settings, + contentDescription = "Settings", + tint = MaterialTheme.colors.onBackground + ) + } + } + ) + } + ) { + val scrollState = rememberScrollState() + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(scrollState), + verticalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterVertically), + horizontalAlignment = Alignment.CenterHorizontally + ) { + LoadingSpinner(status == Status.LOADING) + AnimatedVisibility(visible = status != Status.LOADING) { + StatusLabel(status) + if (status == Status.ENABLED) { + DisableControls(viewModel) + } else { + EnableControls(viewModel::enablePiHole) + } + } + } + } +} + +@ExperimentalAnimationApi +@Composable +@Preview +fun MainScreen_Preview() { + val navController = rememberNavController() + PihelperTheme(false) { + MainScreen(navController = navController) + } +} + +@Composable +fun StatusLabel(status: Status) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center + ) { + Text(color = MaterialTheme.colors.onSurface, text = "Status:") + Spacer(modifier = Modifier.width(8.dp)) + val color = when (status) { + Status.ENABLED -> MaterialTheme.colors.secondaryVariant + Status.DISABLED -> MaterialTheme.colors.primaryVariant + else -> Color(0x00000000) + } + Text( + color = color, + fontWeight = FontWeight.Bold, + text = status.name.toLowerCase(Locale.US).capitalize(Locale.US) + ) + } +} + +@Composable +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), + colors = ButtonDefaults.buttonColors( + backgroundColor = MaterialTheme.colors.secondary, + contentColor = MaterialTheme.colors.onSecondary + ), + onClick = onClick + ) { + Text("Enable") + } +} + +@Composable +fun DisableControls(viewModel: PiHelperViewModel = hiltViewModel()) { + val (dialogVisible, setDialogVisible) = remember { mutableStateOf(false) } + 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), + verticalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterVertically) + ) { + PrimaryButton("Disable for 10 seconds") { viewModel.disablePiHole(10) } + PrimaryButton("Disable for 30 seconds") { viewModel.disablePiHole(30) } + PrimaryButton("Disable for 5 minutes") { viewModel.disablePiHole(300) } + PrimaryButton("Disable for custom time") { /* TODO: Show dialog for custom input */ } + PrimaryButton("Disable permanently") { viewModel.disablePiHole() } + CustomTimeDialog(dialogVisible, setDialogVisible) { + + } + } +} + +@Composable +fun PrimaryButton(text: String, onClick: () -> Unit) { + Button( + modifier = Modifier.fillMaxWidth(), + colors = ButtonDefaults.buttonColors( + backgroundColor = MaterialTheme.colors.primary, + contentColor = MaterialTheme.colors.onPrimary + ), + onClick = onClick + ) { + Text(text) + } +} + +enum class Duration { + SECONDS, + MINUTES, + HOURS +} + +@Composable +fun CustomTimeDialog( + visible: Boolean, + setVisible: (Boolean) -> Unit, + onTimeSelected: (Long) -> Unit +) { + if (!visible) return + val (time: Long, setTime: (Long) -> Unit) = remember { mutableStateOf(0L) } + val (duration, selectDuration: (Duration) -> Unit) = remember { + mutableStateOf(Duration.SECONDS) + } + AlertDialog( + onDismissRequest = { setVisible(false) }, + buttons = { + Row { + TextButton({ setVisible(false) }) { + Text("Cancel") + } + TextButton(onClick = { + // TODO: Move this math to the viewmodel or repository + onTimeSelected(time * (60.0.pow(duration.ordinal)).roundToLong()) + setVisible(false) + }) { + Text("Disable") + } + } + }, + title = { Text("Disable for custom time") }, + text = { + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally + ) { + TextField( + value = time.toString(), + onValueChange = { setTime(it.toLong()) }, + placeholder = { Text("Time to disable") } + ) + Row(modifier = Modifier.fillMaxWidth()) { + DurationRadio( + selected = duration == Duration.SECONDS, + onClick = { selectDuration(Duration.SECONDS) }, + text = "Seconds" + ) + DurationRadio( + selected = duration == Duration.MINUTES, + onClick = { selectDuration(Duration.MINUTES) }, + text = "Minutes" + ) + DurationRadio( + selected = duration == Duration.HOURS, + onClick = { selectDuration(Duration.HOURS) }, + text = "Hours" + ) + } + } + } + ) +} + +@Composable +fun DurationRadio(selected: Boolean, onClick: () -> Unit, text: String) { + Row(modifier = Modifier.selectable(selected = selected, onClick = onClick)) { + RadioButton( + selected = selected, + onClick = onClick + ) + Text(text = text) + } +} + +@Composable +@Preview +fun CustomTimeDialog_Preview() { + CustomTimeDialog(true, {}) { } +} + +@Composable +@Preview +fun StatusLabelEnabled_Preview() { + PihelperTheme(false) { + StatusLabel(Status.ENABLED) + } +} + +@Composable +@Preview +fun StatusLabelEnabled_DarkPreview() { + PihelperTheme(true) { + StatusLabel(Status.ENABLED) + } +} + +@Composable +@Preview +fun StatusLabelDisabled_Preview() { + PihelperTheme(false) { + StatusLabel(Status.DISABLED) + } +} + +@Composable +@Preview +fun StatusLabelDisabled_DarkPreview() { + PihelperTheme(true) { + StatusLabel(Status.DISABLED) + } +} + +@Composable +@Preview +fun PrimaryButton_Preview() { + PihelperTheme(false) { + PrimaryButton("Disable") {} + } +} + +@Composable +@Preview +fun PrimaryButton_DarkPreview() { + PihelperTheme(true) { + PrimaryButton("Disable") {} + } +} + +@Composable +@Preview +fun EnableControls_Preview() { + PihelperTheme(false) { + EnableControls {} + } +} + +@Composable +@Preview +fun EnableControls_DarkPreview() { + PihelperTheme(true) { + EnableControls {} + } +} + +@Composable +@Preview +fun DisableControls_Preview() { + DisableControls() +} diff --git a/app/src/main/java/com/wbrawner/pihelper/PiHelperViewModel.kt b/app/src/main/java/com/wbrawner/pihelper/PiHelperViewModel.kt index 384b405..af61f11 100644 --- a/app/src/main/java/com/wbrawner/pihelper/PiHelperViewModel.kt +++ b/app/src/main/java/com/wbrawner/pihelper/PiHelperViewModel.kt @@ -1,12 +1,13 @@ package com.wbrawner.pihelper -import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import com.wbrawner.piholeclient.PiHoleApiService import com.wbrawner.piholeclient.Status import com.wbrawner.piholeclient.StatusProvider import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.isActive import javax.inject.Inject import kotlin.coroutines.coroutineContext @@ -15,7 +16,8 @@ import kotlin.coroutines.coroutineContext class PiHelperViewModel @Inject constructor( private val apiService: PiHoleApiService ) : ViewModel() { - val status = MutableLiveData() + private val _status = MutableStateFlow(Status.LOADING) + val status = _status.asStateFlow() private var action: (suspend () -> StatusProvider)? = null get() = field ?: defaultAction private var defaultAction = suspend { @@ -25,7 +27,7 @@ class PiHelperViewModel @Inject constructor( suspend fun monitorSummary() { while (coroutineContext.isActive) { try { - status.postValue(action!!.invoke().status) + _status.value = action!!.invoke().status action = null } catch (ignored: Exception) { break @@ -34,13 +36,15 @@ class PiHelperViewModel @Inject constructor( } } - suspend fun enablePiHole() { + fun enablePiHole() { + _status.value = Status.LOADING action = { apiService.enable() } } - suspend fun disablePiHole(duration: Long? = null) { + fun disablePiHole(duration: Long? = null) { + _status.value = Status.LOADING action = { apiService.disable(duration) } diff --git a/app/src/main/java/com/wbrawner/pihelper/RetrieveApiKeyFragment.kt b/app/src/main/java/com/wbrawner/pihelper/RetrieveApiKeyFragment.kt deleted file mode 100644 index 0d2a2f7..0000000 --- a/app/src/main/java/com/wbrawner/pihelper/RetrieveApiKeyFragment.kt +++ /dev/null @@ -1,105 +0,0 @@ -package com.wbrawner.pihelper - - -import android.os.Bundle -import android.util.Log -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import android.view.animation.Animation -import android.view.animation.LinearInterpolator -import android.view.animation.RotateAnimation -import androidx.fragment.app.Fragment -import androidx.fragment.app.activityViewModels -import androidx.lifecycle.Observer -import androidx.lifecycle.lifecycleScope -import androidx.navigation.fragment.findNavController -import androidx.transition.TransitionInflater -import com.wbrawner.pihelper.databinding.FragmentRetrieveApiKeyBinding -import dagger.hilt.android.AndroidEntryPoint - -@AndroidEntryPoint -class RetrieveApiKeyFragment : Fragment() { - private val viewModel: AddPiHelperViewModel by activityViewModels() - private var _binding: FragmentRetrieveApiKeyBinding? = null - private val binding get() = _binding!! - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - sharedElementEnterTransition = TransitionInflater.from(context) - .inflateTransition(android.R.transition.move) - viewModel.authenticated.observe(this, Observer { - if (!it) return@Observer - findNavController().navigate(R.id.action_retrieveApiKeyFragment_to_mainFragment) - }) - } - - override fun onCreateView( - inflater: LayoutInflater, container: ViewGroup?, - savedInstanceState: Bundle? - ): View { - _binding = FragmentRetrieveApiKeyBinding.inflate(inflater, container, false) - return binding.root - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - binding.password.setOnEditorActionListener { _, _, _ -> - binding.connectWithPasswordButton.performClick() - } - binding.connectWithPasswordButton.setSuspendingOnClickListener(lifecycleScope) { - showProgress(true) - try { - viewModel.authenticateWithPassword(binding.password.text.toString()) - } catch (ignored: Exception) { - Log.e("Pi-helper", "Failed to authenticate with password", ignored) - binding.password.error = - "Failed to authenticate with given password. Please verify " + - "you've entered it correctly and try again." - showProgress(false) - } - } - binding.apiKey.setOnEditorActionListener { _, _, _ -> - binding.connectWithApiKeyButton.performClick() - } - binding.connectWithApiKeyButton.setSuspendingOnClickListener(lifecycleScope) { - showProgress(true) - try { - viewModel.authenticateWithApiKey(binding.apiKey.text.toString()) - } catch (ignored: Exception) { - binding.apiKey.error = "Failed to authenticate with given API key. Please verify " + - "you've entered it correctly and try again." - showProgress(false) - } - } - } - - private fun showProgress(show: Boolean) { - if (show) { - binding.piHelperLogo.startAnimation( - RotateAnimation( - 0f, - 360f, - Animation.RELATIVE_TO_SELF, - 0.5f, - Animation.RELATIVE_TO_SELF, - 0.5f - ).apply { - duration = - resources.getInteger(android.R.integer.config_longAnimTime).toLong() * 2 - repeatMode = Animation.RESTART - repeatCount = Animation.INFINITE - interpolator = LinearInterpolator() - fillAfter = true - } - ) - } else { - binding.piHelperLogo.clearAnimation() - } - binding.authenticationForm.visibility = if (show) View.GONE else View.VISIBLE - } - - override fun onDestroyView() { - _binding = null - super.onDestroyView() - } -} diff --git a/app/src/main/java/com/wbrawner/pihelper/ScanNetworkFragment.kt b/app/src/main/java/com/wbrawner/pihelper/ScanNetworkFragment.kt deleted file mode 100644 index 21e746a..0000000 --- a/app/src/main/java/com/wbrawner/pihelper/ScanNetworkFragment.kt +++ /dev/null @@ -1,173 +0,0 @@ -package com.wbrawner.pihelper - - -import android.content.Context -import android.net.ConnectivityManager -import android.net.NetworkCapabilities -import android.os.Build -import android.os.Bundle -import android.util.Log -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import android.view.animation.Animation -import android.view.animation.LinearInterpolator -import android.view.animation.RotateAnimation -import androidx.appcompat.app.AlertDialog -import androidx.fragment.app.Fragment -import androidx.fragment.app.activityViewModels -import androidx.lifecycle.Observer -import androidx.lifecycle.lifecycleScope -import androidx.navigation.fragment.FragmentNavigatorExtras -import androidx.navigation.fragment.findNavController -import androidx.transition.Transition -import androidx.transition.TransitionInflater -import com.wbrawner.pihelper.databinding.FragmentScanNetworkBinding -import dagger.hilt.android.AndroidEntryPoint -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.delay -import kotlinx.coroutines.launch -import java.net.Inet4Address - -@AndroidEntryPoint -class ScanNetworkFragment : Fragment() { - - private val viewModel: AddPiHelperViewModel by activityViewModels() - private var _binding: FragmentScanNetworkBinding? = null - private val binding get() = _binding!! - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - sharedElementEnterTransition = TransitionInflater.from(context) - .inflateTransition(android.R.transition.move) - .addListener(object : Transition.TransitionListener { - override fun onTransitionEnd(transition: Transition) { - animatePiHelperLogo() - } - - override fun onTransitionResume(transition: Transition) { - } - - override fun onTransitionPause(transition: Transition) { - } - - override fun onTransitionCancel(transition: Transition) { - } - - override fun onTransitionStart(transition: Transition) { - } - }) - } - - override fun onCreateView( - inflater: LayoutInflater, container: ViewGroup?, - savedInstanceState: Bundle? - ): View { - _binding = FragmentScanNetworkBinding.inflate(inflater, container, false) - return binding.root - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - viewModel.scanningIp.observe(viewLifecycleOwner, Observer { - binding.ipAddress.text = it - }) - viewModel.piHoleIpAddress.observe(viewLifecycleOwner, Observer { ipAddress -> - if (ipAddress == null) { - AlertDialog.Builder(view.context) - .setTitle(R.string.scan_failed_title) - .setMessage(R.string.scan_failed) - .setPositiveButton(android.R.string.ok) { _, _ -> - findNavController().navigateUp() - } - .show() - return@Observer - } - binding.piHelperLogo.animation?.let { - it.setAnimationListener(object : Animation.AnimationListener { - override fun onAnimationRepeat(animation: Animation?) { - } - - override fun onAnimationEnd(animation: Animation?) { - navigateToApiKeyScreen() - } - - override fun onAnimationStart(animation: Animation?) { - } - }) - it.repeatCount = 0 - } ?: navigateToApiKeyScreen() - }) - lifecycleScope.launch(Dispatchers.IO) { - if (BuildConfig.DEBUG && Build.MODEL == "Android SDK built for x86") { - // For emulators, just begin scanning the host machine directly - viewModel.beginScanning("10.0.2.2") - return@launch - } - (view.context.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager)?.let { connectivityManager -> - connectivityManager.allNetworks - .filter { - connectivityManager.getNetworkCapabilities(it) - ?.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) - ?: false - } - .forEach { network -> - connectivityManager.getLinkProperties(network) - ?.linkAddresses - ?.filter { !it.address.isLoopbackAddress && it.address is Inet4Address } - ?.forEach { address -> - Log.d( - "Pi-helper", - "Found link address: ${address.address.hostName}" - ) - viewModel.beginScanning(address.address.hostAddress) - } - } - } - } - lifecycleScope.launch { - delay(500) - if (binding.piHelperLogo.animation == null) { - animatePiHelperLogo() - } - } - } - - private fun navigateToApiKeyScreen() { - val extras = FragmentNavigatorExtras( - binding.piHelperLogo to "piHelperLogo" - ) - - findNavController().navigate( - R.id.action_scanNetworkFragment_to_retrieveApiKeyFragment, - null, - null, - extras - ) - } - - override fun onDestroyView() { - binding.piHelperLogo.clearAnimation() - _binding = null - super.onDestroyView() - } - - private fun animatePiHelperLogo() { - binding.piHelperLogo.startAnimation( - RotateAnimation( - 0f, - 360f, - Animation.RELATIVE_TO_SELF, - 0.5f, - Animation.RELATIVE_TO_SELF, - 0.5f - ).apply { - duration = resources.getInteger(android.R.integer.config_longAnimTime).toLong() * 2 - repeatMode = Animation.RESTART - repeatCount = Animation.INFINITE - interpolator = LinearInterpolator() - fillAfter = true - } - ) - } -} diff --git a/app/src/main/java/com/wbrawner/pihelper/ScanScreen.kt b/app/src/main/java/com/wbrawner/pihelper/ScanScreen.kt new file mode 100644 index 0000000..0876ab0 --- /dev/null +++ b/app/src/main/java/com/wbrawner/pihelper/ScanScreen.kt @@ -0,0 +1,110 @@ +package com.wbrawner.pihelper + +import android.content.Context +import android.net.ConnectivityManager +import android.net.NetworkCapabilities +import android.os.Build +import android.util.Log +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.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.navigation.NavController +import com.wbrawner.pihelper.ui.PihelperTheme +import kotlinx.coroutines.launch +import java.net.Inet4Address + +@Composable +fun ScanScreen(navController: NavController, addPiHelperViewModel: AddPiHelperViewModel) { + val coroutineScope = rememberCoroutineScope() + val loadingMessage by addPiHelperViewModel.loadingMessage.collectAsState() + val context = LocalContext.current + LaunchedEffect(key1 = addPiHelperViewModel) { + val onSuccess: () -> Unit = { + navController.navigate(Screens.AUTH.route) + } + val onFailure: () -> Unit = { + navController.popBackStack() + } + if (BuildConfig.DEBUG && Build.MODEL == "Android SDK built for x86") { + // For emulators, just begin scanning the host machine directly + coroutineScope.launch { + addPiHelperViewModel.beginScanning("10.0.2.2", onSuccess, onFailure) + } + return@LaunchedEffect + } + (context.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager)?.let { connectivityManager -> + connectivityManager.allNetworks + .filter { + connectivityManager.getNetworkCapabilities(it) + ?.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) + ?: false + } + .forEach { network -> + connectivityManager.getLinkProperties(network) + ?.linkAddresses + ?.filter { !it.address.isLoopbackAddress && it.address is Inet4Address } + ?.forEach { address -> + Log.d( + "Pi-helper", + "Found link address: ${address.address.hostName}" + ) + addPiHelperViewModel.beginScanning( + address.address.hostAddress, + onSuccess, + onFailure + ) + } + } + } + } + ScanningStatus(loadingMessage) +} + +@Composable +fun ScanningStatus( + loadingMessage: String? = null +) { + val (host: String, setHost: (String) -> Unit) = remember { mutableStateOf("pi.hole") } + Column( + modifier = Modifier + .padding(16.dp) + .fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterVertically), + ) { + LoadingSpinner(loadingMessage != null) + loadingMessage?.let { + Text( + text = loadingMessage, + textAlign = TextAlign.Center + ) + } + } +} + +@Composable +@Preview +fun ScanningStatus_Preview() { + PihelperTheme(false) { + ScanningStatus(loadingMessage = "Scanning 127.0.0.1") + } +} + +@Composable +@Preview +fun ScanningStatus_DarkPreview() { + PihelperTheme(true) { + ScanningStatus(loadingMessage = "Scanning 127.0.0.1") + } +} diff --git a/app/src/main/java/com/wbrawner/pihelper/ui/Color.kt b/app/src/main/java/com/wbrawner/pihelper/ui/Color.kt new file mode 100644 index 0000000..19502cf --- /dev/null +++ b/app/src/main/java/com/wbrawner/pihelper/ui/Color.kt @@ -0,0 +1,8 @@ +package com.wbrawner.pihelper.ui + +import androidx.compose.ui.graphics.Color + +val Red500 = Color(0xFFF44336) +val Red900 = Color(0xFFB71C1C) +val Green500 = Color(0xFF4CAF50) +val Green900 = Color(0xFF1B5E20) \ No newline at end of file diff --git a/app/src/main/java/com/wbrawner/pihelper/ui/Shape.kt b/app/src/main/java/com/wbrawner/pihelper/ui/Shape.kt new file mode 100644 index 0000000..a9354b7 --- /dev/null +++ b/app/src/main/java/com/wbrawner/pihelper/ui/Shape.kt @@ -0,0 +1,11 @@ +package com.wbrawner.pihelper.ui + +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.Shapes +import androidx.compose.ui.unit.dp + +val Shapes = Shapes( + small = RoundedCornerShape(4.dp), + medium = RoundedCornerShape(4.dp), + large = RoundedCornerShape(0.dp) +) \ No newline at end of file diff --git a/app/src/main/java/com/wbrawner/pihelper/ui/Theme.kt b/app/src/main/java/com/wbrawner/pihelper/ui/Theme.kt new file mode 100644 index 0000000..0bcb2bf --- /dev/null +++ b/app/src/main/java/com/wbrawner/pihelper/ui/Theme.kt @@ -0,0 +1,47 @@ +package com.wbrawner.pihelper.ui + +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.runtime.Composable +import androidx.compose.ui.graphics.Color + +private val DarkColorPalette = darkColors( + background = Color.Black, + surface = Color.Black, + primary = Red500, + primaryVariant = Red900, + onPrimary = Color.White, + secondary = Green500, + secondaryVariant = Green900, + onSecondary = Color.White +) + +private val LightColorPalette = lightColors( + primary = Red500, + primaryVariant = Red900, + onPrimary = Color.White, + secondary = Green500, + secondaryVariant = Green900, + onSecondary = Color.White +) + +@Composable +fun PihelperTheme(darkTheme: Boolean = isSystemInDarkTheme(), content: @Composable () -> Unit) { + val colors = if (darkTheme) { + DarkColorPalette + } else { + LightColorPalette + } + + MaterialTheme( + colors = colors, + typography = Typography, + shapes = Shapes, + content = { + Surface(color = MaterialTheme.colors.background, content = content) + } + ) +} \ 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 new file mode 100644 index 0000000..dde6ff2 --- /dev/null +++ b/app/src/main/java/com/wbrawner/pihelper/ui/Type.kt @@ -0,0 +1,16 @@ +package com.wbrawner.pihelper.ui + +import androidx.compose.material.Typography +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +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 + ) +) \ 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 new file mode 100644 index 0000000..59967ab --- /dev/null +++ b/app/src/main/res/drawable-v26/ic_shortcut_enable.xml @@ -0,0 +1,9 @@ + + + + + + + \ 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 new file mode 100644 index 0000000..9300e9f --- /dev/null +++ b/app/src/main/res/drawable-v26/ic_shortcut_pause.xml @@ -0,0 +1,9 @@ + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/horizontal_rule.xml b/app/src/main/res/drawable/horizontal_rule.xml deleted file mode 100644 index 7f702b0..0000000 --- a/app/src/main/res/drawable/horizontal_rule.xml +++ /dev/null @@ -1,11 +0,0 @@ - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_settings.xml b/app/src/main/res/drawable/ic_settings.xml deleted file mode 100644 index f673399..0000000 --- a/app/src/main/res/drawable/ic_settings.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_shortcut_enable.xml b/app/src/main/res/drawable/ic_shortcut_enable.xml index 5e57f74..604dbc2 100644 --- a/app/src/main/res/drawable/ic_shortcut_enable.xml +++ b/app/src/main/res/drawable/ic_shortcut_enable.xml @@ -2,8 +2,8 @@ + android:shape="rectangle" + android:tint="@color/colorSurface" /> + android:shape="rectangle" + android:tint="@color/colorSurface" /> - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout-w800dp/fragment_add_pi_hole.xml b/app/src/main/res/layout-w800dp/fragment_add_pi_hole.xml deleted file mode 100644 index 8017952..0000000 --- a/app/src/main/res/layout-w800dp/fragment_add_pi_hole.xml +++ /dev/null @@ -1,118 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout-w800dp/fragment_main.xml b/app/src/main/res/layout-w800dp/fragment_main.xml deleted file mode 100644 index cdda992..0000000 --- a/app/src/main/res/layout-w800dp/fragment_main.xml +++ /dev/null @@ -1,113 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout-w800dp/fragment_retrieve_api_key.xml b/app/src/main/res/layout-w800dp/fragment_retrieve_api_key.xml deleted file mode 100644 index 87d42bb..0000000 --- a/app/src/main/res/layout-w800dp/fragment_retrieve_api_key.xml +++ /dev/null @@ -1,141 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/layout-w800dp/or_divider.xml b/app/src/main/res/layout-w800dp/or_divider.xml deleted file mode 100644 index b35939b..0000000 --- a/app/src/main/res/layout-w800dp/or_divider.xml +++ /dev/null @@ -1,36 +0,0 @@ - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml deleted file mode 100644 index 8c354b6..0000000 --- a/app/src/main/res/layout/activity_main.xml +++ /dev/null @@ -1,12 +0,0 @@ - - diff --git a/app/src/main/res/layout/dialog_disable_custom_time.xml b/app/src/main/res/layout/dialog_disable_custom_time.xml deleted file mode 100644 index dc6c14a..0000000 --- a/app/src/main/res/layout/dialog_disable_custom_time.xml +++ /dev/null @@ -1,50 +0,0 @@ - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_add_pi_hole.xml b/app/src/main/res/layout/fragment_add_pi_hole.xml deleted file mode 100644 index a34476a..0000000 --- a/app/src/main/res/layout/fragment_add_pi_hole.xml +++ /dev/null @@ -1,78 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_info.xml b/app/src/main/res/layout/fragment_info.xml deleted file mode 100644 index 7f7bbb9..0000000 --- a/app/src/main/res/layout/fragment_info.xml +++ /dev/null @@ -1,53 +0,0 @@ - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_main.xml b/app/src/main/res/layout/fragment_main.xml deleted file mode 100644 index fdf3abc..0000000 --- a/app/src/main/res/layout/fragment_main.xml +++ /dev/null @@ -1,96 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_retrieve_api_key.xml b/app/src/main/res/layout/fragment_retrieve_api_key.xml deleted file mode 100644 index d9f9d84..0000000 --- a/app/src/main/res/layout/fragment_retrieve_api_key.xml +++ /dev/null @@ -1,102 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_scan_network.xml b/app/src/main/res/layout/fragment_scan_network.xml deleted file mode 100644 index 408f2cb..0000000 --- a/app/src/main/res/layout/fragment_scan_network.xml +++ /dev/null @@ -1,35 +0,0 @@ - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/or_divider.xml b/app/src/main/res/layout/or_divider.xml deleted file mode 100644 index fd429ef..0000000 --- a/app/src/main/res/layout/or_divider.xml +++ /dev/null @@ -1,36 +0,0 @@ - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/menu/main.xml b/app/src/main/res/menu/main.xml deleted file mode 100644 index 9cc65f6..0000000 --- a/app/src/main/res/menu/main.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - - \ No newline at end of file diff --git a/app/src/main/res/navigation/nav_graph.xml b/app/src/main/res/navigation/nav_graph.xml deleted file mode 100644 index da9eb6f..0000000 --- a/app/src/main/res/navigation/nav_graph.xml +++ /dev/null @@ -1,78 +0,0 @@ - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/values-night/colors.xml b/app/src/main/res/values-night/colors.xml index 175ae8f..578d767 100644 --- a/app/src/main/res/values-night/colors.xml +++ b/app/src/main/res/values-night/colors.xml @@ -1,8 +1,5 @@ - #333333 + #000000 #f1f1f1 - @color/colorGreenLight - @color/colorRedLight - #999999 \ No newline at end of file diff --git a/app/src/main/res/values-night/styles.xml b/app/src/main/res/values-night/styles.xml index c754cb1..6bb7e56 100644 --- a/app/src/main/res/values-night/styles.xml +++ b/app/src/main/res/values-night/styles.xml @@ -3,11 +3,4 @@ - - \ No newline at end of file diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml index 3faa826..e0ec3a2 100644 --- a/app/src/main/res/values/colors.xml +++ b/app/src/main/res/values/colors.xml @@ -1,18 +1,15 @@ - @color/colorRedLight + @color/colorAccent @color/colorRedDark @color/colorRedLight - #f60d1a - #96060c - #30d158 - #34c759 + #F44336 + #B71C1C + #4CAF50 + #1B5E20 + #FFFFFFFF #000000 #ffffff - @color/colorGreenDark - @color/colorButtonSecondary - @color/colorRedDark @color/colorWhite #00000000 - #666666 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 5106b73..bede7b7 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,50 +1,10 @@ Pi-helper - Scanning IP Address: - Pi-helper Logo - Status: %1$s Enable - Disable Disable for 10 seconds Disable for 10 seconds Disable for 30 seconds Disable for 30 seconds Disable for 5 minutes Disable for 5 minutes - Disable for custom time - Disable Permanently - Disabled - Enabled - Please ensure you are connected to the same Wi-Fi network that the Pi-Hole is running on and try again, or enter the Pi-Hole\'s IP address manually. - Pi-helper failed to find your Pi-Hole - Please ensure you are connected to the same Wi-Fi network that the Pi-Hole is running on, and that you\'re using the correct IP address and try again. - Pi-helper failed to connect to your Pi-Hole - Please configure Pi-helper before using shortcuts - or - Settings - Forget Pi-hole - William Brawner. You can find the source code or report issues on the GitHub page for the project.]]> - Are you sure you want to forget your Pi-hole? - This cannot be undone. - Pi-helper Crashed! - Would you please consider sending the crash report to me? - Crash Reports - Unknown - Secs - Mins - Time to disable - If you\'re not sure what the IP address for your Pi-Hole is, Pi-helper can attempt to find it for you by scanning your network. - Scan Network - If you already know the IP address or host of your Pi-Hole, you can also enter it below: - Pi-Hole IP Address/Host - Connect to Pi-Hole - Pi-helper has successfully connected to your Pi-Hole! - You\'ll need to authenticate in order to enable and disable the Pi-hole. - Pi-Hole Web Password - Authenticate with Password - Pi-Hole API Key - Authenticate with API Key - Connecting to Pi-hole… - Cancel - Hours diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml index 2551663..47db077 100644 --- a/app/src/main/res/values/styles.xml +++ b/app/src/main/res/values/styles.xml @@ -5,24 +5,9 @@ @color/colorAccent @drawable/background_splash @color/colorTransparent - @color/colorOnSurface - - - - - - diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index c94defd..4ad83e9 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,6 +1,7 @@ [versions] androidx-core = "1.3.2" androidx-appcompat = "1.2.0" +compose = "1.0.0-beta07" coroutines = "1.4.3" espresso = "3.3.0" hilt-android = "2.36" @@ -18,8 +19,15 @@ versionName = "1.0" [libraries] androidx-core = { module = "androidx.core:core-ktx", version.ref = "androidx-core" } androidx-appcompat = { module = "androidx.appcompat:appcompat", version.ref = "androidx-appcompat" } +compose-ui = { module = "androidx.compose.ui:ui", version.ref = "compose" } +compose-material = { module = "androidx.compose.material:material", version.ref = "compose" } +compose-tooling = { module = "androidx.compose.ui:ui-tooling", version.ref = "compose" } +compose-activity = { module = "androidx.activity:activity-compose", version = "1.3.0-alpha07" } +compose-test = { module = "androidx.compose.ui:ui-test-junit4", version.ref = "compose" } hilt-android-core = { module = "com.google.dagger:hilt-android", version.ref = "hilt-android" } hilt-android-kapt = { module = "com.google.dagger:hilt-compiler", version.ref = "hilt-android" } +hilt-navigation-compose = { module = "androidx.hilt:hilt-navigation-compose", version = "1.0.0-alpha02" } +navigation-compose = { module = "androidx.navigation:navigation-compose", version = "2.4.0-alpha01" } material = { module = "com.google.android.material:material", version.ref = "material" } moshi-core = { module = "com.squareup.moshi:moshi", version.ref = "moshi" } moshi-codegen = { module = "com.squareup.moshi:moshi-kotlin-codegen", version.ref = "moshi" } @@ -28,8 +36,6 @@ navigation-ui = { module = "androidx.navigation:navigation-ui-ktx", version.ref coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutines" } coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "coroutines" } preference = { module = "androidx.preference:preference-ktx", version = "1.1.1" } -lifecycle-livedata = { module = "androidx.lifecycle:lifecycle-livedata-ktx", version.ref = "lifecycle" } -lifecycle-viewmodel = { module = "androidx.lifecycle:lifecycle-viewmodel-ktx", version.ref = "lifecycle" } junit = { module = "junit:junit", version = "4.12" } kotlin-gradle = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", version.ref = "kotlin" } kotlin-stdlib = { module = "org.jetbrains.kotlin:kotlin-stdlib", version.ref = "kotlin" } @@ -38,8 +44,7 @@ test-ext = { module = "androidx.test.ext:junit", version = "1.1.2" } espresso = { module = "androidx.test.espresso:espresso-core", version.ref = "espresso" } [bundles] +compose = ["compose-ui", "compose-material", "compose-tooling", "compose-activity", "navigation-compose"] coroutines = ["coroutines-core", "coroutines-android"] -lifecycle = ["lifecycle-livedata", "lifecycle-viewmodel"] -navigation = ["navigation-fragment", "navigation-ui"] diff --git a/piholeclient/src/main/java/com/wbrawner/piholeclient/Responses.kt b/piholeclient/src/main/java/com/wbrawner/piholeclient/Responses.kt index 91061a1..c6bb17c 100644 --- a/piholeclient/src/main/java/com/wbrawner/piholeclient/Responses.kt +++ b/piholeclient/src/main/java/com/wbrawner/piholeclient/Responses.kt @@ -48,7 +48,11 @@ enum class Status { @Json(name = "enabled") ENABLED, @Json(name = "disabled") - DISABLED + DISABLED, + @Transient + LOADING, + @Transient + UNKNOWN } @JsonClass(generateAdapter = true)