WIP: Migrate UI to Jetpack Compose

This commit is contained in:
William Brawner 2021-06-10 21:20:31 -06:00
parent 3f98551b9d
commit 65838d6905
48 changed files with 1023 additions and 1861 deletions

View file

@ -0,0 +1,8 @@
<component name="InspectionProjectProfileManager">
<profile version="1.0">
<option name="myName" value="Project Default" />
<inspection_tool class="PreviewAnnotationInFunctionWithParameters" enabled="true" level="ERROR" enabled_by_default="true">
<option name="previewFile" value="true" />
</inspection_tool>
</profile>
</component>

View file

@ -5,6 +5,48 @@
<configuration PROFILE_NAME="Debug" CONFIG_NAME="Debug" /> <configuration PROFILE_NAME="Debug" CONFIG_NAME="Debug" />
</configurations> </configurations>
</component> </component>
<component name="DesignSurface">
<option name="filePathToZoomLevelMap">
<map>
<entry key="../../../../layout/compose-model-1622823449309.xml" value="0.1523829431438127" />
<entry key="../../../../layout/compose-model-1622824814438.xml" value="0.33" />
<entry key="../../../../layout/compose-model-1622917622395.xml" value="0.2170608108108108" />
<entry key="../../../../layout/compose-model-1623014109717.xml" value="0.337037037037037" />
<entry key="../../../../layout/compose-model-1623014799493.xml" value="0.10015558148580318" />
<entry key="../../../../layout/compose-model-1623015496605.xml" value="0.1" />
<entry key="../../../../layout/compose-model-1623015889966.xml" value="0.1072324414715719" />
<entry key="../../../../layout/compose-model-1623016793844.xml" value="0.1" />
<entry key="../../../../layout/compose-model-1623332506723.xml" value="0.1" />
<entry key="../../../../layout/compose-model-1623332556375.xml" value="0.1" />
<entry key="../../../../layout/compose-model-1623353007224.xml" value="0.1" />
<entry key="../../../../layout/compose-model-1623353013891.xml" value="2.0" />
<entry key="../../../../layout/compose-model-1623358629334.xml" value="0.10618436406067679" />
<entry key="../../../../layout/compose-model-1623358815178.xml" value="1.6830769230769231" />
<entry key="../../../../layout/compose-model-1623359670724.xml" value="0.11413043478260869" />
<entry key="../../../../layout/compose-model-1623360495184.xml" value="0.20880752102919348" />
<entry key="app/src/main/res/drawable-v26/ic_shortcut_enable.xml" value="0.27447916666666666" />
<entry key="app/src/main/res/drawable-v26/ic_shortcut_pause.xml" value="0.27447916666666666" />
<entry key="app/src/main/res/drawable/background_splash.xml" value="0.409375" />
<entry key="app/src/main/res/drawable/horizontal_rule.xml" value="0.1" />
<entry key="app/src/main/res/drawable/ic_launcher_background.xml" value="0.27447916666666666" />
<entry key="app/src/main/res/drawable/ic_pause.xml" value="0.409375" />
<entry key="app/src/main/res/drawable/ic_play.xml" value="0.27447916666666666" />
<entry key="app/src/main/res/drawable/ic_play_arrow.xml" value="0.27447916666666666" />
<entry key="app/src/main/res/drawable/ic_settings.xml" value="0.409375" />
<entry key="app/src/main/res/drawable/ic_shortcut_background.xml" value="0.27447916666666666" />
<entry key="app/src/main/res/drawable/ic_shortcut_enable.xml" value="0.409375" />
<entry key="app/src/main/res/drawable/ic_shortcut_pause.xml" value="0.409375" />
<entry key="app/src/main/res/layout/activity_main.xml" value="0.16111111111111112" />
<entry key="app/src/main/res/layout/dialog_disable_custom_time.xml" value="0.22708333333333333" />
<entry key="app/src/main/res/layout/fragment_add_pi_hole.xml" value="0.3493975903614458" />
<entry key="app/src/main/res/layout/fragment_info.xml" value="0.24010416666666667" />
<entry key="app/src/main/res/layout/fragment_main.xml" value="0.14175724637681159" />
<entry key="app/src/main/res/layout/fragment_retrieve_api_key.xml" value="0.24010416666666667" />
<entry key="app/src/main/res/layout/or_divider.xml" value="0.17147922998986828" />
<entry key="app/src/main/res/menu/main.xml" value="0.3932291666666667" />
</map>
</option>
</component>
<component name="ProjectRootManager" version="2" languageLevel="JDK_11" default="true" project-jdk-name="11" project-jdk-type="JavaSDK"> <component name="ProjectRootManager" version="2" languageLevel="JDK_11" default="true" project-jdk-name="11" project-jdk-type="JavaSDK">
<output url="file://$PROJECT_DIR$/build/classes" /> <output url="file://$PROJECT_DIR$/build/classes" />
</component> </component>

View file

@ -59,14 +59,16 @@ android {
jvmTarget = "1.8" jvmTarget = "1.8"
} }
buildFeatures { buildFeatures {
viewBinding = true compose = true
} }
} }
dependencies { dependencies {
implementation(project(":piholeclient")) implementation(project(":piholeclient"))
implementation(libs.bundles.coroutines) implementation(libs.bundles.coroutines)
implementation(libs.bundles.compose)
implementation(libs.hilt.android.core) implementation(libs.hilt.android.core)
implementation(libs.hilt.navigation.compose)
kapt(libs.hilt.android.kapt) kapt(libs.hilt.android.kapt)
implementation(libs.androidx.core) implementation(libs.androidx.core)
implementation(libs.androidx.appcompat) implementation(libs.androidx.appcompat)
@ -78,7 +80,5 @@ dependencies {
testImplementation(libs.junit) testImplementation(libs.junit)
androidTestImplementation(libs.test.ext) androidTestImplementation(libs.test.ext)
androidTestImplementation(libs.espresso) androidTestImplementation(libs.espresso)
implementation(libs.bundles.navigation)
implementation(libs.bundles.lifecycle)
} }

View file

@ -3,12 +3,12 @@ package com.wbrawner.pihelper
import android.content.SharedPreferences import android.content.SharedPreferences
import android.util.Log import android.util.Log
import androidx.core.content.edit import androidx.core.content.edit
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import com.wbrawner.piholeclient.PiHoleApiService import com.wbrawner.piholeclient.PiHoleApiService
import com.wbrawner.piholeclient.VersionResponse import com.wbrawner.piholeclient.VersionResponse
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import java.net.ConnectException import java.net.ConnectException
import java.net.SocketTimeoutException import java.net.SocketTimeoutException
@ -48,11 +48,10 @@ class AddPiHelperViewModel @Inject constructor(
apiService.apiKey = this.apiKey apiService.apiKey = this.apiKey
} }
val piHoleIpAddress = MutableLiveData<String?>() val loadingMessage = MutableStateFlow<String?>(null)
val scanningIp = MutableLiveData<String?>() val errorMessage = MutableStateFlow<String?>(null)
val authenticated = MutableLiveData<Boolean>()
suspend fun beginScanning(deviceIpAddress: String) { suspend fun beginScanning(deviceIpAddress: String, onSuccess: () -> Unit, onFailure: () -> Unit) {
val addressParts = deviceIpAddress.split(".").toMutableList() val addressParts = deviceIpAddress.split(".").toMutableList()
var chunks = 1 var chunks = 1
// If the Pi-hole is correctly set up, then there should be a special host for it as // 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 chunks *= 2
} }
scan(ipAddresses) if (scan(ipAddresses)) {
onSuccess()
} else {
onFailure()
}
} }
private suspend fun scan(ipAddresses: MutableList<String>) { private suspend fun scan(ipAddresses: MutableList<String>): Boolean {
if (ipAddresses.isEmpty()) { if (ipAddresses.isEmpty()) {
scanningIp.postValue(null) loadingMessage.value = null
piHoleIpAddress.postValue(null) return false
return
} }
val ipAddress = ipAddresses.removeAt(0) val ipAddress = ipAddresses.removeAt(0)
scanningIp.postValue(ipAddress) loadingMessage.value = "Scanning $ipAddress..."
if (!connectToIpAddress(ipAddress)) { return if (!connectToIpAddress(ipAddress)) {
scan(ipAddresses) scan(ipAddresses)
} else {
true
} }
} }
@ -102,33 +106,32 @@ class AddPiHelperViewModel @Inject constructor(
null null
} }
} }
return if (version == null) { if (version != null) {
false
} else {
piHoleIpAddress.postValue(ipAddress)
baseUrl = ipAddress 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 // 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 // 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 // authentication and is fairly simple to determine whether or not the request was
// successful // successful
errorMessage.value = null
apiService.apiKey = apiKey apiService.apiKey = apiKey
try { return try {
apiService.getTopItems() apiService.getTopItems()
this.apiKey = apiKey this.apiKey = apiKey
authenticated.postValue(true) true
} catch (e: Exception) { } catch (e: Exception) {
Log.e("Pi-helper", "Unable to authenticate with API key", e) Log.e("Pi-helper", "Unable to authenticate with API key", e)
authenticated.postValue(false) errorMessage.value = "Authentication failed"
throw e false
} }
} }

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,99 +1,109 @@
package com.wbrawner.pihelper package com.wbrawner.pihelper
import android.graphics.drawable.ColorDrawable
import android.os.Bundle import android.os.Bundle
import android.widget.Toast import androidx.activity.compose.setContent
import androidx.activity.viewModels import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.core.content.ContextCompat import androidx.compose.animation.ExperimentalAnimationApi
import androidx.navigation.NavController import androidx.compose.animation.core.*
import androidx.navigation.findNavController import androidx.compose.foundation.Image
import com.wbrawner.pihelper.MainFragment.Companion.ACTION_DISABLE import androidx.compose.material.MaterialTheme
import com.wbrawner.pihelper.MainFragment.Companion.ACTION_ENABLE import androidx.compose.material.Surface
import com.wbrawner.pihelper.MainFragment.Companion.EXTRA_DURATION import androidx.compose.runtime.Composable
import com.wbrawner.pihelper.databinding.ActivityMainBinding 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 import dagger.hilt.android.AndroidEntryPoint
@ExperimentalAnimationApi
@AndroidEntryPoint @AndroidEntryPoint
class MainActivity : AppCompatActivity() { 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?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
val binding = ActivityMainBinding.inflate(layoutInflater) setContent {
setContentView(binding.root) val navController = rememberNavController()
window.setBackgroundDrawable( val addPiHoleViewModel: AddPiHelperViewModel = viewModel()
ColorDrawable( val startDestination = when {
ContextCompat.getColor( addPiHoleViewModel.baseUrl == null -> {
this, Screens.ADD
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) }
} }
} addPiHoleViewModel.apiKey == null -> {
ACTION_DISABLE -> { Screens.AUTH
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())
}
}
}
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 -> { else -> {
navController.navigate(R.id.mainFragment, args) Screens.MAIN
}
}
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
)
} }
} }
} }
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()
}

View file

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

View file

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

View file

@ -1,12 +1,13 @@
package com.wbrawner.pihelper package com.wbrawner.pihelper
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import com.wbrawner.piholeclient.PiHoleApiService import com.wbrawner.piholeclient.PiHoleApiService
import com.wbrawner.piholeclient.Status import com.wbrawner.piholeclient.Status
import com.wbrawner.piholeclient.StatusProvider import com.wbrawner.piholeclient.StatusProvider
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.isActive import kotlinx.coroutines.isActive
import javax.inject.Inject import javax.inject.Inject
import kotlin.coroutines.coroutineContext import kotlin.coroutines.coroutineContext
@ -15,7 +16,8 @@ import kotlin.coroutines.coroutineContext
class PiHelperViewModel @Inject constructor( class PiHelperViewModel @Inject constructor(
private val apiService: PiHoleApiService private val apiService: PiHoleApiService
) : ViewModel() { ) : ViewModel() {
val status = MutableLiveData<Status>() private val _status = MutableStateFlow(Status.LOADING)
val status = _status.asStateFlow()
private var action: (suspend () -> StatusProvider)? = null private var action: (suspend () -> StatusProvider)? = null
get() = field ?: defaultAction get() = field ?: defaultAction
private var defaultAction = suspend { private var defaultAction = suspend {
@ -25,7 +27,7 @@ class PiHelperViewModel @Inject constructor(
suspend fun monitorSummary() { suspend fun monitorSummary() {
while (coroutineContext.isActive) { while (coroutineContext.isActive) {
try { try {
status.postValue(action!!.invoke().status) _status.value = action!!.invoke().status
action = null action = null
} catch (ignored: Exception) { } catch (ignored: Exception) {
break break
@ -34,13 +36,15 @@ class PiHelperViewModel @Inject constructor(
} }
} }
suspend fun enablePiHole() { fun enablePiHole() {
_status.value = Status.LOADING
action = { action = {
apiService.enable() apiService.enable()
} }
} }
suspend fun disablePiHole(duration: Long? = null) { fun disablePiHole(duration: Long? = null) {
_status.value = Status.LOADING
action = { action = {
apiService.disable(duration) apiService.disable(duration)
} }

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<foreground>
<inset
android:drawable="@drawable/ic_play_arrow"
android:inset="15%" />
</foreground>
<background android:drawable="@color/colorSurface" />
</adaptive-icon>

View file

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<foreground>
<inset
android:drawable="@drawable/ic_pause"
android:inset="20%" />
</foreground>
<background android:drawable="@color/colorSurface" />
</adaptive-icon>

View file

@ -1,11 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item
android:width="500dp"
android:height="1dp"
android:gravity="center">
<color android:color="@color/colorOnSurface" />
<shape android:shape="rectangle" />
</item>
</layer-list>

View file

@ -1,9 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@color/colorOnSurface"
android:pathData="M19.1,12.9a2.8,2.8 0,0 0,0.1 -0.9,2.8 2.8,0 0,0 -0.1,-0.9l2.1,-1.6a0.7,0.7 0,0 0,0.1 -0.6L19.4,5.5a0.7,0.7 0,0 0,-0.6 -0.2l-2.4,1a6.5,6.5 0,0 0,-1.6 -0.9l-0.4,-2.6a0.5,0.5 0,0 0,-0.5 -0.4H10.1a0.5,0.5 0,0 0,-0.5 0.4L9.3,5.4a5.6,5.6 0,0 0,-1.7 0.9l-2.4,-1a0.4,0.4 0,0 0,-0.5 0.2l-2,3.4c-0.1,0.2 0,0.4 0.2,0.6l2,1.6a2.8,2.8 0,0 0,-0.1 0.9,2.8 2.8,0 0,0 0.1,0.9L2.8,14.5a0.7,0.7 0,0 0,-0.1 0.6l1.9,3.4a0.7,0.7 0,0 0,0.6 0.2l2.4,-1a6.5,6.5 0,0 0,1.6 0.9l0.4,2.6a0.5,0.5 0,0 0,0.5 0.4h3.8a0.5,0.5 0,0 0,0.5 -0.4l0.3,-2.6a5.6,5.6 0,0 0,1.7 -0.9l2.4,1a0.4,0.4 0,0 0,0.5 -0.2l2,-3.4c0.1,-0.2 0,-0.4 -0.2,-0.6ZM12,15.6A3.6,3.6 0,1 1,15.6 12,3.6 3.6,0 0,1 12,15.6Z"/>
</vector>

View file

@ -2,8 +2,8 @@
<layer-list xmlns:android="http://schemas.android.com/apk/res/android"> <layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item> <item>
<shape <shape
android:shape="oval" android:shape="rectangle"
android:tint="@color/colorWhite" /> android:tint="@color/colorSurface" />
</item> </item>
<item <item
android:bottom="0dp" android:bottom="0dp"

View file

@ -2,8 +2,8 @@
<layer-list xmlns:android="http://schemas.android.com/apk/res/android"> <layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item> <item>
<shape <shape
android:shape="oval" android:shape="rectangle"
android:tint="@color/colorWhite" /> android:tint="@color/colorSurface" />
</item> </item>
<item <item
android:bottom="0dp" android:bottom="0dp"

View file

@ -1,11 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item
android:width="1dp"
android:height="500dp"
android:gravity="center">
<color android:color="@color/colorOnSurface" />
<shape android:shape="rectangle" />
</item>
</layer-list>

View file

@ -1,118 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:animateLayoutChanges="true"
android:fillViewport="true"
tools:background="@color/colorSurface"
tools:context=".AddPiHoleFragment">
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:padding="16dp">
<ImageView
android:id="@+id/piHelperLogo"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:contentDescription="@string/app_name"
android:src="@drawable/ic_app_logo"
android:tint="@color/colorOnSurface"
android:transitionName="piHelperLogo"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/connectionForm"
android:layout_width="match_parent"
android:layout_height="0dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/piHelperLogo">
<TextView
android:id="@+id/scanNetwork"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:text="@string/info_scan_network"
android:textAlignment="center"
app:layout_constraintBottom_toTopOf="@+id/scanNetworkButton"
app:layout_constraintEnd_toStartOf="@+id/orDivider"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<com.google.android.material.button.MaterialButton
android:id="@+id/scanNetworkButton"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:text="@string/action_scan_network"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toStartOf="@+id/orDivider"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/scanNetwork" />
<include
android:id="@+id/orDivider"
layout="@layout/or_divider"
android:layout_width="wrap_content"
android:layout_height="0dp"
android:layout_marginTop="16dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<TextView
android:id="@+id/connectDirectly"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:text="@string/info_connect"
android:textAlignment="center"
app:layout_constraintBottom_toTopOf="@+id/scanNetworkButton"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@+id/orDivider"
app:layout_constraintTop_toTopOf="parent" />
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/ipAddressContainer"
style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:hint="@string/prompt_ip_address"
app:layout_constraintBottom_toTopOf="@+id/connectButton"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@+id/orDivider"
app:layout_constraintTop_toBottomOf="@+id/connectDirectly"
app:layout_constraintVertical_chainStyle="packed">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/ipAddress"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:imeOptions="actionGo"
android:inputType="text"
android:maxLines="1"
android:text="pi.hole"
tools:ignore="HardcodedText" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.button.MaterialButton
android:id="@+id/connectButton"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:text="@string/action_connect_pihole"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@+id/orDivider"
app:layout_constraintTop_toBottomOf="@+id/ipAddressContainer" />
</androidx.constraintlayout.widget.ConstraintLayout>
</androidx.constraintlayout.widget.ConstraintLayout>
</ScrollView>

View file

@ -1,113 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:animateLayoutChanges="true"
tools:background="@color/colorSurface">
<androidx.appcompat.widget.Toolbar
android:id="@+id/toolbar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:layout_constraintTop_toTopOf="parent" />
<ScrollView
android:id="@+id/statusContent"
android:layout_width="match_parent"
android:layout_height="0dp"
android:visibility="gone"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintTop_toBottomOf="@+id/toolbar"
tools:visibility="visible">
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:orientation="horizontal"
android:padding="16dp"
tools:context=".MainFragment">
<TextView
android:id="@+id/status"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:padding="4dp"
android:textAlignment="center"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toStartOf="@+id/actionButtons"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:text="Enabled"
tools:textColor="@color/colorGreenDark" />
<LinearLayout
android:id="@+id/actionButtons"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:orientation="vertical"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@+id/status"
app:layout_constraintTop_toTopOf="parent">
<com.google.android.material.button.MaterialButton
android:id="@+id/enableButton"
style="@style/AppTheme.Button.Green"
android:text="@string/action_enable" />
<LinearLayout
android:id="@+id/disableButtons"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:visibility="gone"
tools:visibility="visible">
<com.google.android.material.button.MaterialButton
android:id="@+id/disable10SecondsButton"
style="@style/AppTheme.Button.Red"
android:text="@string/action_disable_10_seconds" />
<com.google.android.material.button.MaterialButton
android:id="@+id/disable30SecondsButton"
style="@style/AppTheme.Button.Red"
android:text="@string/action_disable_30_seconds" />
<com.google.android.material.button.MaterialButton
android:id="@+id/disable5MinutesButton"
style="@style/AppTheme.Button.Red"
android:text="@string/action_disable_5_minutes" />
<com.google.android.material.button.MaterialButton
android:id="@+id/disableCustomTimeButton"
style="@style/AppTheme.Button.Red"
android:text="@string/action_disable_custom" />
<com.google.android.material.button.MaterialButton
android:id="@+id/disablePermanentlyButton"
style="@style/AppTheme.Button.Red"
android:text="@string/action_disable_permanently" />
</LinearLayout>
</LinearLayout>
</androidx.constraintlayout.widget.ConstraintLayout>
</ScrollView>
<ImageView
android:id="@+id/progressBar"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:contentDescription="@string/app_name"
android:src="@drawable/ic_app_logo"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/toolbar"
tools:visibility="gone" />
</androidx.constraintlayout.widget.ConstraintLayout>

View file

@ -1,141 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:animateLayoutChanges="true"
android:clipChildren="false"
android:clipToPadding="false"
android:fillViewport="true"
android:padding="16dp"
tools:background="@color/colorSurface"
tools:context=".RetrieveApiKeyFragment">
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:minHeight="400dp"
android:layout_gravity="center">
<ImageView
android:id="@+id/piHelperLogo"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:contentDescription="@string/accessibility_description_pi_helper_logo"
android:src="@drawable/ic_app_logo"
android:tint="@color/colorOnSurface"
android:transitionName="piHelperLogo"
app:layout_constraintBottom_toTopOf="@+id/authenticationForm"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintVertical_chainStyle="packed" />
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/authenticationForm"
android:layout_width="0dp"
android:layout_height="0dp"
app:layout_constraintHeight_min="wrap"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/piHelperLogo">
<TextView
android:id="@+id/connectionSuccess"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/info_connection_success"
android:textAlignment="center"
app:layout_constraintBottom_toTopOf="@+id/authRequired"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<TextView
android:id="@+id/authRequired"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/info_authentication_required"
android:textAlignment="center"
app:layout_constraintBottom_toTopOf="@+id/orDivider"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/connectionSuccess" />
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/passwordContainer"
style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:hint="@string/prompt_password"
app:layout_constraintBottom_toTopOf="@+id/connectWithPasswordButton"
app:layout_constraintEnd_toStartOf="@+id/orDivider"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="@+id/orDivider"
app:layout_constraintVertical_chainStyle="packed">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/password"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:imeOptions="actionGo"
android:inputType="textPassword" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.button.MaterialButton
android:id="@+id/connectWithPasswordButton"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:text="@string/action_authenticate_password"
app:layout_constraintBottom_toBottomOf="@+id/orDivider"
app:layout_constraintEnd_toStartOf="@+id/orDivider"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/passwordContainer" />
<include
android:id="@+id/orDivider"
layout="@layout/or_divider"
android:layout_width="wrap_content"
android:layout_height="0dp"
android:layout_marginTop="16dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHeight_min="200dp"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/authRequired" />
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/apiKeyContainer"
style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:hint="@string/prompt_api_key"
app:layout_constraintBottom_toTopOf="@+id/connectWithApiKeyButton"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@+id/orDivider"
app:layout_constraintTop_toTopOf="@+id/orDivider"
app:layout_constraintVertical_chainStyle="packed">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/apiKey"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:imeOptions="actionGo"
android:inputType="textPassword" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.button.MaterialButton
android:id="@+id/connectWithApiKeyButton"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:text="@string/action_authenticate_api_key"
app:layout_constraintBottom_toBottomOf="@+id/orDivider"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@+id/orDivider"
app:layout_constraintTop_toBottomOf="@+id/apiKeyContainer" />
</androidx.constraintlayout.widget.ConstraintLayout>
</androidx.constraintlayout.widget.ConstraintLayout>
</ScrollView>

View file

@ -1,36 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
tools:background="@color/colorSurface">
<ImageView
android:layout_width="wrap_content"
android:layout_height="0dp"
android:layout_gravity="center"
android:layout_weight="1"
android:scaleType="fitXY"
android:src="@drawable/vertical_rule"
tools:ignore="ContentDescription" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:layout_margin="16dp"
android:text="@string/or"
android:textAlignment="center"
android:textAllCaps="true" />
<ImageView
android:layout_width="wrap_content"
android:layout_height="0dp"
android:layout_gravity="center"
android:layout_weight="1"
android:scaleType="fitXY"
android:src="@drawable/vertical_rule"
tools:ignore="ContentDescription" />
</LinearLayout>

View file

@ -1,12 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<fragment xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/content_main"
android:name="androidx.navigation.fragment.NavHostFragment"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:fitsSystemWindows="true"
app:defaultNavHost="true"
app:navGraph="@navigation/nav_graph"
tools:background="@color/colorSurface" />

View file

@ -1,50 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:paddingStart="16dp"
android:paddingEnd="16dp"
tools:background="@color/colorSurface">
<com.google.android.material.textfield.TextInputLayout
style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="@string/hint_disable_duration">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/time"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="number|numberSigned" />
</com.google.android.material.textfield.TextInputLayout>
<RadioGroup
android:id="@+id/timeUnit"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:orientation="horizontal">
<RadioButton
android:id="@+id/seconds"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:checked="true"
android:text="@string/duration_seconds" />
<RadioButton
android:id="@+id/minutes"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/duration_minutes" />
<RadioButton
android:id="@+id/hours"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/duration_hours" />
</RadioGroup>
</LinearLayout>

View file

@ -1,78 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:animateLayoutChanges="true"
tools:background="@color/colorSurface"
tools:context=".AddPiHoleFragment">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:orientation="vertical"
android:padding="16dp">
<ImageView
android:id="@+id/piHelperLogo"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:contentDescription="@string/app_name"
android:src="@drawable/ic_app_logo"
android:tint="@color/colorOnSurface"
android:transitionName="piHelperLogo" />
<LinearLayout
android:id="@+id/connectionForm"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/info_scan_network"
android:textAlignment="center" />
<com.google.android.material.button.MaterialButton
android:id="@+id/scanNetworkButton"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/action_scan_network" />
<include layout="@layout/or_divider" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/info_connect"
android:textAlignment="center" />
<com.google.android.material.textfield.TextInputLayout
style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="@string/prompt_ip_address">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/ipAddress"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:imeOptions="actionGo"
android:inputType="text"
android:maxLines="1"
android:text="pi.hole"
tools:ignore="HardcodedText" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.button.MaterialButton
android:id="@+id/connectButton"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/action_connect_pihole" />
</LinearLayout>
</LinearLayout>
</ScrollView>

View file

@ -1,53 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:background="@color/colorSurface"
tools:context=".InfoFragment">
<androidx.appcompat.widget.Toolbar
android:id="@+id/toolbar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:layout_constraintTop_toTopOf="parent" />
<ScrollView
android:layout_width="match_parent"
android:layout_height="0dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintTop_toBottomOf="@+id/toolbar">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:orientation="vertical"
android:padding="16dp">
<ImageView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:contentDescription="@string/app_name"
android:src="@drawable/ic_app_logo" />
<TextView
android:id="@+id/infoContent"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/content_info"
android:textAlignment="center" />
<com.google.android.material.button.MaterialButton
android:id="@+id/forgetPiHoleButton"
style="@style/Widget.MaterialComponents.Button.TextButton"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/action_forget_pihole" />
</LinearLayout>
</ScrollView>
</androidx.constraintlayout.widget.ConstraintLayout>

View file

@ -1,96 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:animateLayoutChanges="true"
tools:background="@color/colorSurface">
<androidx.appcompat.widget.Toolbar
android:id="@+id/toolbar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:layout_constraintTop_toTopOf="parent" />
<ScrollView
android:id="@+id/statusContent"
android:layout_width="match_parent"
android:layout_height="0dp"
android:visibility="gone"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintTop_toBottomOf="@+id/toolbar">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:orientation="vertical"
android:padding="16dp"
tools:context=".MainFragment">
<TextView
android:id="@+id/status"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:padding="4dp"
android:textAlignment="center"
tools:text="Enabled"
tools:textColor="@color/colorGreenDark" />
<com.google.android.material.button.MaterialButton
android:id="@+id/enableButton"
style="@style/AppTheme.Button.Green"
android:text="@string/action_enable" />
<LinearLayout
android:id="@+id/disableButtons"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:visibility="gone"
tools:visibility="visible">
<com.google.android.material.button.MaterialButton
android:id="@+id/disable10SecondsButton"
style="@style/AppTheme.Button.Red"
android:text="@string/action_disable_10_seconds" />
<com.google.android.material.button.MaterialButton
android:id="@+id/disable30SecondsButton"
style="@style/AppTheme.Button.Red"
android:text="@string/action_disable_30_seconds" />
<com.google.android.material.button.MaterialButton
android:id="@+id/disable5MinutesButton"
style="@style/AppTheme.Button.Red"
android:text="@string/action_disable_5_minutes" />
<com.google.android.material.button.MaterialButton
android:id="@+id/disableCustomTimeButton"
style="@style/AppTheme.Button.Red"
android:text="@string/action_disable_custom" />
<com.google.android.material.button.MaterialButton
android:id="@+id/disablePermanentlyButton"
style="@style/AppTheme.Button.Red"
android:text="@string/action_disable_permanently" />
</LinearLayout>
</LinearLayout>
</ScrollView>
<ImageView
android:id="@+id/progressBar"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:contentDescription="@string/app_name"
android:src="@drawable/ic_app_logo"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/toolbar" />
</androidx.constraintlayout.widget.ConstraintLayout>

View file

@ -1,102 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:animateLayoutChanges="true"
android:fillViewport="true"
tools:background="@color/colorSurface"
tools:context=".RetrieveApiKeyFragment">
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="center">
<ImageView
android:id="@+id/piHelperLogo"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:contentDescription="@string/accessibility_description_pi_helper_logo"
android:src="@drawable/ic_app_logo"
android:tint="@color/colorOnSurface"
android:transitionName="piHelperLogo"
app:layout_constraintBottom_toTopOf="@+id/authenticationForm"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintVertical_chainStyle="packed" />
<LinearLayout
android:id="@+id/authenticationForm"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/piHelperLogo">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/info_connection_success"
android:textAlignment="center" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="32dp"
android:text="@string/info_authentication_required"
android:textAlignment="center" />
<com.google.android.material.textfield.TextInputLayout
style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="@string/prompt_password">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/password"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:imeOptions="actionGo"
android:inputType="textPassword" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.button.MaterialButton
android:id="@+id/connectWithPasswordButton"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/action_authenticate_password" />
<include layout="@layout/or_divider" />
<com.google.android.material.textfield.TextInputLayout
style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="@string/prompt_api_key">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/apiKey"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:imeOptions="actionGo"
android:inputType="textPassword" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.button.MaterialButton
android:id="@+id/connectWithApiKeyButton"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/action_authenticate_api_key" />
</LinearLayout>
</androidx.constraintlayout.widget.ConstraintLayout>
</ScrollView>

View file

@ -1,35 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:gravity="center"
android:orientation="vertical"
android:padding="16dp"
tools:background="@color/colorSurface"
tools:context=".ScanNetworkFragment">
<ImageView
android:id="@+id/piHelperLogo"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:src="@drawable/ic_app_logo"
android:tint="@color/colorOnSurface"
android:transitionName="piHelperLogo"
android:contentDescription="@string/accessibility_description_pi_helper_logo" />
<TextView
android:id="@+id/ipAddressScanningInfo"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/scanning_ip_address"
android:textAlignment="center" />
<TextView
android:id="@+id/ipAddress"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textAlignment="center" />
</LinearLayout>

View file

@ -1,36 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="horizontal"
tools:background="@color/colorSurface">
<ImageView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:layout_weight="1"
android:scaleType="fitXY"
android:src="@drawable/horizontal_rule"
tools:ignore="ContentDescription" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:layout_margin="16dp"
android:text="@string/or"
android:textAlignment="center"
android:textAllCaps="true" />
<ImageView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:layout_weight="1"
android:scaleType="fitXY"
android:src="@drawable/horizontal_rule"
tools:ignore="ContentDescription" />
</LinearLayout>

View file

@ -1,9 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<item
android:id="@+id/settings"
android:icon="@drawable/ic_settings"
android:title="@string/action_settings"
app:showAsAction="ifRoom" />
</menu>

View file

@ -1,78 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<navigation xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/nav_graph"
app:startDestination="@id/placeholder">
<fragment
android:id="@+id/addPiHoleFragment"
android:name="com.wbrawner.pihelper.AddPiHoleFragment"
android:label="fragment_add_pi_hole"
tools:layout="@layout/fragment_add_pi_hole" >
<action
android:id="@+id/action_addPiHoleFragment_to_scanNetworkFragment"
app:destination="@id/scanNetworkFragment"
app:enterAnim="@anim/nav_default_enter_anim"
app:exitAnim="@anim/nav_default_exit_anim"
app:popEnterAnim="@anim/nav_default_pop_enter_anim"
app:popExitAnim="@anim/nav_default_pop_exit_anim" />
<action
android:id="@+id/action_addPiHoleFragment_to_retrieveApiKeyFragment"
app:destination="@id/retrieveApiKeyFragment"
app:enterAnim="@anim/nav_default_enter_anim"
app:exitAnim="@anim/nav_default_exit_anim"
app:popEnterAnim="@anim/nav_default_pop_enter_anim"
app:popExitAnim="@anim/nav_default_pop_exit_anim"
app:popUpTo="@+id/addPiHoleFragment" />
</fragment>
<fragment
android:id="@+id/placeholder"
android:name="androidx.fragment.app.Fragment" />
<fragment
android:id="@+id/scanNetworkFragment"
android:name="com.wbrawner.pihelper.ScanNetworkFragment"
android:label="fragment_scan_network"
tools:layout="@layout/fragment_scan_network" >
<action
android:id="@+id/action_scanNetworkFragment_to_retrieveApiKeyFragment"
app:destination="@id/retrieveApiKeyFragment"
app:enterAnim="@anim/nav_default_enter_anim"
app:exitAnim="@anim/nav_default_exit_anim"
app:popEnterAnim="@anim/nav_default_pop_enter_anim"
app:popExitAnim="@anim/nav_default_pop_exit_anim"
app:popUpTo="@+id/addPiHoleFragment" />
</fragment>
<fragment
android:id="@+id/retrieveApiKeyFragment"
android:name="com.wbrawner.pihelper.RetrieveApiKeyFragment"
android:label="fragment_retrieve_api_key"
tools:layout="@layout/fragment_retrieve_api_key" >
<action
android:id="@+id/action_retrieveApiKeyFragment_to_mainFragment"
app:destination="@id/mainFragment"
app:enterAnim="@anim/nav_default_enter_anim"
app:exitAnim="@anim/nav_default_exit_anim"
app:popEnterAnim="@anim/nav_default_pop_enter_anim"
app:popExitAnim="@anim/nav_default_pop_exit_anim" />
</fragment>
<fragment
android:id="@+id/mainFragment"
android:name="com.wbrawner.pihelper.MainFragment"
android:label="@string/app_name"
tools:layout="@layout/fragment_main" >
<action
android:id="@+id/action_mainFragment_to_settingsFragment"
app:destination="@id/settingsFragment"
app:enterAnim="@anim/nav_default_enter_anim"
app:exitAnim="@anim/nav_default_exit_anim"
app:popEnterAnim="@anim/nav_default_pop_enter_anim"
app:popExitAnim="@anim/nav_default_pop_exit_anim" />
</fragment>
<fragment
android:id="@+id/settingsFragment"
android:name="com.wbrawner.pihelper.InfoFragment"
android:label="@string/action_settings"
tools:layout="@layout/fragment_info" />
</navigation>

View file

@ -1,8 +1,5 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources> <resources>
<color name="colorSurface">#333333</color> <color name="colorSurface">#000000</color>
<color name="colorOnSurface">#f1f1f1</color> <color name="colorOnSurface">#f1f1f1</color>
<color name="colorEnabled">@color/colorGreenLight</color>
<color name="colorDisabled">@color/colorRedLight</color>
<color name="colorButtonSecondary">#999999</color>
</resources> </resources>

View file

@ -3,11 +3,4 @@
<style name="AppTheme" parent="BaseTheme"> <style name="AppTheme" parent="BaseTheme">
<item name="android:windowLightStatusBar">false</item> <item name="android:windowLightStatusBar">false</item>
</style> </style>
<style name="AppTheme.Button.Red" parent="Widget.MaterialComponents.Button">
<item name="android:layout_width">match_parent</item>
<item name="android:layout_height">wrap_content</item>
<item name="backgroundTint">@color/colorRedLight</item>
<item name="android:textColor">@color/colorWhite</item>
</style>
</resources> </resources>

View file

@ -1,18 +1,15 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources> <resources>
<color name="colorPrimary">@color/colorRedLight</color> <color name="colorPrimary">@color/colorAccent</color>
<color name="colorPrimaryDark">@color/colorRedDark</color> <color name="colorPrimaryDark">@color/colorRedDark</color>
<color name="colorAccent">@color/colorRedLight</color> <color name="colorAccent">@color/colorRedLight</color>
<color name="colorRedLight">#f60d1a</color> <color name="colorRedLight">#F44336</color>
<color name="colorRedDark">#96060c</color> <color name="colorRedDark">#B71C1C</color>
<color name="colorGreenLight">#30d158</color> <color name="colorGreenLight">#4CAF50</color>
<color name="colorGreenDark">#34c759</color> <color name="colorGreenDark">#1B5E20</color>
<color name="colorShortcutBackground">#FFFFFFFF</color>
<color name="colorOnSurface">#000000</color> <color name="colorOnSurface">#000000</color>
<color name="colorWhite">#ffffff</color> <color name="colorWhite">#ffffff</color>
<color name="colorEnabled">@color/colorGreenDark</color>
<color name="colorUnknown">@color/colorButtonSecondary</color>
<color name="colorDisabled">@color/colorRedDark</color>
<color name="colorSurface">@color/colorWhite</color> <color name="colorSurface">@color/colorWhite</color>
<color name="colorTransparent">#00000000</color> <color name="colorTransparent">#00000000</color>
<color name="colorButtonSecondary">#666666</color>
</resources> </resources>

View file

@ -1,50 +1,10 @@
<resources> <resources>
<string name="app_name">Pi-helper</string> <string name="app_name">Pi-helper</string>
<string name="scanning_ip_address">Scanning IP Address:</string>
<string name="accessibility_description_pi_helper_logo">Pi-helper Logo</string>
<string name="label_status">Status: %1$s</string>
<string name="action_enable">Enable</string> <string name="action_enable">Enable</string>
<string name="action_disable">Disable</string>
<string name="action_disable_10_seconds">Disable for 10 seconds</string> <string name="action_disable_10_seconds">Disable for 10 seconds</string>
<string name="action_disable_10_seconds_short">Disable for 10 seconds</string> <string name="action_disable_10_seconds_short">Disable for 10 seconds</string>
<string name="action_disable_30_seconds">Disable for 30 seconds</string> <string name="action_disable_30_seconds">Disable for 30 seconds</string>
<string name="action_disable_30_seconds_short">Disable for 30 seconds</string> <string name="action_disable_30_seconds_short">Disable for 30 seconds</string>
<string name="action_disable_5_minutes">Disable for 5 minutes</string> <string name="action_disable_5_minutes">Disable for 5 minutes</string>
<string name="action_disable_5_minutes_short">Disable for 5 minutes</string> <string name="action_disable_5_minutes_short">Disable for 5 minutes</string>
<string name="action_disable_custom">Disable for custom time</string>
<string name="action_disable_permanently">Disable Permanently</string>
<string name="status_disabled">Disabled</string>
<string name="status_enabled">Enabled</string>
<string name="scan_failed">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.</string>
<string name="scan_failed_title">Pi-helper failed to find your Pi-Hole</string>
<string name="connection_failed">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.</string>
<string name="connection_failed_title">Pi-helper failed to connect to your Pi-Hole</string>
<string name="configure_pihelper">Please configure Pi-helper before using shortcuts</string>
<string name="or">or</string>
<string name="action_settings">Settings</string>
<string name="action_forget_pihole">Forget Pi-hole</string>
<string name="content_info"><![CDATA[Pi-helper was made with ❤ by <a href=\"https://wbrawner.com\">William Brawner</a>. You can find the source code or report issues on the <a href=\"https://github.com/wbrawner/PiHelperAndroid\">GitHub page</a> for the project.]]></string>
<string name="confirm_forget_pihole">Are you sure you want to forget your Pi-hole?</string>
<string name="warning_cannot_be_undone">This cannot be undone.</string>
<string name="title_crash_notification">Pi-helper Crashed!</string>
<string name="text_crash_notification">Would you please consider sending the crash report to me?</string>
<string name="channel_crash_notification">Crash Reports</string>
<string name="status_unknown">Unknown</string>
<string name="duration_seconds">Secs</string>
<string name="duration_minutes">Mins</string>
<string name="hint_disable_duration">Time to disable</string>
<string name="info_scan_network">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.</string>
<string name="action_scan_network">Scan Network</string>
<string name="info_connect">If you already know the IP address or host of your Pi-Hole, you can also enter it below:</string>
<string name="prompt_ip_address">Pi-Hole IP Address/Host</string>
<string name="action_connect_pihole">Connect to Pi-Hole</string>
<string name="info_connection_success">Pi-helper has successfully connected to your Pi-Hole!</string>
<string name="info_authentication_required">You\'ll need to authenticate in order to enable and disable the Pi-hole.</string>
<string name="prompt_password">Pi-Hole Web Password</string>
<string name="action_authenticate_password">Authenticate with Password</string>
<string name="prompt_api_key">Pi-Hole API Key</string>
<string name="action_authenticate_api_key">Authenticate with API Key</string>
<string name="connecting_to_pihole">Connecting to Pi-hole…</string>
<string name="action_cancel">Cancel</string>
<string name="duration_hours">Hours</string>
</resources> </resources>

View file

@ -5,24 +5,9 @@
<item name="colorAccent">@color/colorAccent</item> <item name="colorAccent">@color/colorAccent</item>
<item name="android:windowBackground">@drawable/background_splash</item> <item name="android:windowBackground">@drawable/background_splash</item>
<item name="android:statusBarColor">@color/colorTransparent</item> <item name="android:statusBarColor">@color/colorTransparent</item>
<item name="android:textColor">@color/colorOnSurface</item>
</style> </style>
<style name="AppTheme" parent="BaseTheme"> <style name="AppTheme" parent="BaseTheme">
<item name="android:windowLightStatusBar">true</item> <item name="android:windowLightStatusBar">true</item>
</style> </style>
<style name="AppTheme.Button" parent="Widget.MaterialComponents.Button">
<item name="android:layout_width">match_parent</item>
<item name="android:layout_height">wrap_content</item>
<item name="android:textColor">@color/colorWhite</item>
</style>
<style name="AppTheme.Button.Green" parent="AppTheme.Button">
<item name="backgroundTint">@color/colorGreenDark</item>
</style>
<style name="AppTheme.Button.Red" parent="AppTheme.Button">
<item name="backgroundTint">@color/colorAccent</item>
</style>
</resources> </resources>

View file

@ -1,6 +1,7 @@
[versions] [versions]
androidx-core = "1.3.2" androidx-core = "1.3.2"
androidx-appcompat = "1.2.0" androidx-appcompat = "1.2.0"
compose = "1.0.0-beta07"
coroutines = "1.4.3" coroutines = "1.4.3"
espresso = "3.3.0" espresso = "3.3.0"
hilt-android = "2.36" hilt-android = "2.36"
@ -18,8 +19,15 @@ versionName = "1.0"
[libraries] [libraries]
androidx-core = { module = "androidx.core:core-ktx", version.ref = "androidx-core" } androidx-core = { module = "androidx.core:core-ktx", version.ref = "androidx-core" }
androidx-appcompat = { module = "androidx.appcompat:appcompat", version.ref = "androidx-appcompat" } 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-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-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" } material = { module = "com.google.android.material:material", version.ref = "material" }
moshi-core = { module = "com.squareup.moshi:moshi", version.ref = "moshi" } moshi-core = { module = "com.squareup.moshi:moshi", version.ref = "moshi" }
moshi-codegen = { module = "com.squareup.moshi:moshi-kotlin-codegen", 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-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutines" }
coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", 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" } 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" } junit = { module = "junit:junit", version = "4.12" }
kotlin-gradle = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", version.ref = "kotlin" } kotlin-gradle = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", version.ref = "kotlin" }
kotlin-stdlib = { module = "org.jetbrains.kotlin:kotlin-stdlib", 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" } espresso = { module = "androidx.test.espresso:espresso-core", version.ref = "espresso" }
[bundles] [bundles]
compose = ["compose-ui", "compose-material", "compose-tooling", "compose-activity", "navigation-compose"]
coroutines = ["coroutines-core", "coroutines-android"] coroutines = ["coroutines-core", "coroutines-android"]
lifecycle = ["lifecycle-livedata", "lifecycle-viewmodel"]
navigation = ["navigation-fragment", "navigation-ui"]

View file

@ -48,7 +48,11 @@ enum class Status {
@Json(name = "enabled") @Json(name = "enabled")
ENABLED, ENABLED,
@Json(name = "disabled") @Json(name = "disabled")
DISABLED DISABLED,
@Transient
LOADING,
@Transient
UNKNOWN
} }
@JsonClass(generateAdapter = true) @JsonClass(generateAdapter = true)