Implement desktop builds

This commit is contained in:
William Brawner 2023-08-22 22:13:17 -06:00
parent 731faf7894
commit 7bbbb022e3
Signed by: wbrawner
GPG key ID: 8FF12381C6C90D35
39 changed files with 611 additions and 380 deletions

View file

@ -126,6 +126,7 @@
<option name="preferredColumnWidths"> <option name="preferredColumnWidths">
<map> <map>
<entry key="Duration" value="90" /> <entry key="Duration" value="90" />
<entry key="Pixel_3a_API_31_arm64-v8a" value="120" />
<entry key="Pixel_3a_API_33_arm64-v8a" value="120" /> <entry key="Pixel_3a_API_33_arm64-v8a" value="120" />
<entry key="Tests" value="360" /> <entry key="Tests" value="360" />
</map> </map>
@ -147,6 +148,20 @@
</AndroidTestResultsTableState> </AndroidTestResultsTableState>
</value> </value>
</entry> </entry>
<entry key="28629151">
<value>
<AndroidTestResultsTableState>
<option name="preferredColumnWidths">
<map>
<entry key="Duration" value="90" />
<entry key="Pixel_3a_API_31_arm64-v8a" value="120" />
<entry key="Pixel_3a_API_33_arm64-v8a" value="120" />
<entry key="Tests" value="360" />
</map>
</option>
</AndroidTestResultsTableState>
</value>
</entry>
<entry key="302221053"> <entry key="302221053">
<value> <value>
<AndroidTestResultsTableState> <AndroidTestResultsTableState>
@ -160,6 +175,20 @@
</AndroidTestResultsTableState> </AndroidTestResultsTableState>
</value> </value>
</entry> </entry>
<entry key="496416765">
<value>
<AndroidTestResultsTableState>
<option name="preferredColumnWidths">
<map>
<entry key="Duration" value="90" />
<entry key="Pixel_3a_API_31_arm64-v8a" value="120" />
<entry key="Pixel_3a_API_33_arm64-v8a" value="120" />
<entry key="Tests" value="360" />
</map>
</option>
</AndroidTestResultsTableState>
</value>
</entry>
<entry key="730894960"> <entry key="730894960">
<value> <value>
<AndroidTestResultsTableState> <AndroidTestResultsTableState>
@ -168,6 +197,7 @@
<entry key="2A221FDH200B53" value="120" /> <entry key="2A221FDH200B53" value="120" />
<entry key="Duration" value="90" /> <entry key="Duration" value="90" />
<entry key="Google&#10; Pixel 7" value="120" /> <entry key="Google&#10; Pixel 7" value="120" />
<entry key="Pixel_3a_API_31_arm64-v8a" value="120" />
<entry key="Pixel_3a_API_33_arm64-v8a" value="120" /> <entry key="Pixel_3a_API_33_arm64-v8a" value="120" />
<entry key="Tests" value="360" /> <entry key="Tests" value="360" />
</map> </map>
@ -201,6 +231,19 @@
</AndroidTestResultsTableState> </AndroidTestResultsTableState>
</value> </value>
</entry> </entry>
<entry key="2093109046">
<value>
<AndroidTestResultsTableState>
<option name="preferredColumnWidths">
<map>
<entry key="Duration" value="90" />
<entry key="Pixel_3a_API_33_arm64-v8a" value="120" />
<entry key="Tests" value="360" />
</map>
</option>
</AndroidTestResultsTableState>
</value>
</entry>
</map> </map>
</option> </option>
</component> </component>

View file

@ -0,0 +1,8 @@
<component name="ArtifactManager">
<artifact type="jar" name="shared-desktop">
<output-path>$PROJECT_DIR$/shared/build/libs</output-path>
<root id="archive" name="shared-desktop.jar">
<element id="module-output" name="Pi-helper.shared.desktopMain" />
</root>
</artifact>
</component>

View file

@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<project version="4"> <project version="4">
<component name="CompilerConfiguration"> <component name="CompilerConfiguration">
<bytecodeTargetLevel target="11" /> <bytecodeTargetLevel target="17" />
</component> </component>
</project> </project>

View file

@ -0,0 +1,22 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="deploymentTargetDropDown">
<value>
<entry key="app">
<State />
</entry>
<entry key="app.androidTest">
<State />
</entry>
<entry key="app.unitTest">
<State />
</entry>
<entry key="pihelper-android.Pi-helper.app">
<State />
</entry>
<entry key="testInvalidHost()">
<State />
</entry>
</value>
</component>
</project>

View file

@ -4,17 +4,17 @@
<component name="GradleSettings"> <component name="GradleSettings">
<option name="linkedExternalProjectsSettings"> <option name="linkedExternalProjectsSettings">
<GradleProjectSettings> <GradleProjectSettings>
<option name="testRunner" value="GRADLE" />
<option name="distributionType" value="DEFAULT_WRAPPED" />
<option name="externalProjectPath" value="$PROJECT_DIR$" /> <option name="externalProjectPath" value="$PROJECT_DIR$" />
<option name="gradleJvm" value="Android Studio default JDK" /> <option name="gradleJvm" value="#GRADLE_LOCAL_JAVA_HOME" />
<option name="modules"> <option name="modules">
<set> <set>
<option value="$PROJECT_DIR$" /> <option value="$PROJECT_DIR$" />
<option value="$PROJECT_DIR$/app" /> <option value="$PROJECT_DIR$/app" />
<option value="$PROJECT_DIR$/desktop" />
<option value="$PROJECT_DIR$/shared" /> <option value="$PROJECT_DIR$/shared" />
</set> </set>
</option> </option>
<option name="resolveExternalAnnotations" value="false" />
</GradleProjectSettings> </GradleProjectSettings>
</option> </option>
</component> </component>

View file

@ -31,5 +31,10 @@
<option name="name" value="maven" /> <option name="name" value="maven" />
<option name="url" value="https://s01.oss.sonatype.org/content/repositories/snapshots/" /> <option name="url" value="https://s01.oss.sonatype.org/content/repositories/snapshots/" />
</remote-repository> </remote-repository>
<remote-repository>
<option name="id" value="maven" />
<option name="name" value="maven" />
<option name="url" value="https://maven.pkg.jetbrains.space/public/p/compose/dev" />
</remote-repository>
</component> </component>
</project> </project>

View file

@ -1,9 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="KotlinScriptingSettings">
<scriptDefinition className="org.jetbrains.kotlin.scripting.resolve.KotlinScriptDefinitionFromAnnotatedTemplate" definitionName="KotlinBuildScript">
<order>2147483647</order>
<autoReloadConfigurations>true</autoReloadConfigurations>
</scriptDefinition>
</component>
</project>

6
.idea/kotlinc.xml Normal file
View file

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="KotlinJpsPluginSettings">
<option name="version" value="1.8.20" />
</component>
</project>

View file

@ -1,4 +1,3 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4"> <project version="4">
<component name="CMakeSettings"> <component name="CMakeSettings">
<configurations> <configurations>
@ -61,7 +60,11 @@
<item index="0" class="java.lang.String" itemvalue="dagger.hilt.android.testing.BindValue" /> <item index="0" class="java.lang.String" itemvalue="dagger.hilt.android.testing.BindValue" />
</list> </list>
</component> </component>
<component name="ProjectRootManager" version="2" languageLevel="JDK_11" default="true" project-jdk-name="11" project-jdk-type="JavaSDK"> <component name="ExternalStorageConfigurationManager" enabled="true" />
<component name="FrameworkDetectionExcludesConfiguration">
<file type="web" url="file://$PROJECT_DIR$" />
</component>
<component name="ProjectRootManager" version="2" languageLevel="JDK_17" default="true" project-jdk-name="jbr-17" project-jdk-type="JavaSDK">
<output url="file://$PROJECT_DIR$/build/classes" /> <output url="file://$PROJECT_DIR$/build/classes" />
</component> </component>
<component name="ProjectType"> <component name="ProjectType">

124
.idea/uiDesigner.xml Normal file
View file

@ -0,0 +1,124 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="Palette2">
<group name="Swing">
<item class="com.intellij.uiDesigner.HSpacer" tooltip-text="Horizontal Spacer" icon="/com/intellij/uiDesigner/icons/hspacer.svg" removable="false" auto-create-binding="false" can-attach-label="false">
<default-constraints vsize-policy="1" hsize-policy="6" anchor="0" fill="1" />
</item>
<item class="com.intellij.uiDesigner.VSpacer" tooltip-text="Vertical Spacer" icon="/com/intellij/uiDesigner/icons/vspacer.svg" removable="false" auto-create-binding="false" can-attach-label="false">
<default-constraints vsize-policy="6" hsize-policy="1" anchor="0" fill="2" />
</item>
<item class="javax.swing.JPanel" icon="/com/intellij/uiDesigner/icons/panel.svg" removable="false" auto-create-binding="false" can-attach-label="false">
<default-constraints vsize-policy="3" hsize-policy="3" anchor="0" fill="3" />
</item>
<item class="javax.swing.JScrollPane" icon="/com/intellij/uiDesigner/icons/scrollPane.svg" removable="false" auto-create-binding="false" can-attach-label="true">
<default-constraints vsize-policy="7" hsize-policy="7" anchor="0" fill="3" />
</item>
<item class="javax.swing.JButton" icon="/com/intellij/uiDesigner/icons/button.svg" removable="false" auto-create-binding="true" can-attach-label="false">
<default-constraints vsize-policy="0" hsize-policy="3" anchor="0" fill="1" />
<initial-values>
<property name="text" value="Button" />
</initial-values>
</item>
<item class="javax.swing.JRadioButton" icon="/com/intellij/uiDesigner/icons/radioButton.svg" removable="false" auto-create-binding="true" can-attach-label="false">
<default-constraints vsize-policy="0" hsize-policy="3" anchor="8" fill="0" />
<initial-values>
<property name="text" value="RadioButton" />
</initial-values>
</item>
<item class="javax.swing.JCheckBox" icon="/com/intellij/uiDesigner/icons/checkBox.svg" removable="false" auto-create-binding="true" can-attach-label="false">
<default-constraints vsize-policy="0" hsize-policy="3" anchor="8" fill="0" />
<initial-values>
<property name="text" value="CheckBox" />
</initial-values>
</item>
<item class="javax.swing.JLabel" icon="/com/intellij/uiDesigner/icons/label.svg" removable="false" auto-create-binding="false" can-attach-label="false">
<default-constraints vsize-policy="0" hsize-policy="0" anchor="8" fill="0" />
<initial-values>
<property name="text" value="Label" />
</initial-values>
</item>
<item class="javax.swing.JTextField" icon="/com/intellij/uiDesigner/icons/textField.svg" removable="false" auto-create-binding="true" can-attach-label="true">
<default-constraints vsize-policy="0" hsize-policy="6" anchor="8" fill="1">
<preferred-size width="150" height="-1" />
</default-constraints>
</item>
<item class="javax.swing.JPasswordField" icon="/com/intellij/uiDesigner/icons/passwordField.svg" removable="false" auto-create-binding="true" can-attach-label="true">
<default-constraints vsize-policy="0" hsize-policy="6" anchor="8" fill="1">
<preferred-size width="150" height="-1" />
</default-constraints>
</item>
<item class="javax.swing.JFormattedTextField" icon="/com/intellij/uiDesigner/icons/formattedTextField.svg" removable="false" auto-create-binding="true" can-attach-label="true">
<default-constraints vsize-policy="0" hsize-policy="6" anchor="8" fill="1">
<preferred-size width="150" height="-1" />
</default-constraints>
</item>
<item class="javax.swing.JTextArea" icon="/com/intellij/uiDesigner/icons/textArea.svg" removable="false" auto-create-binding="true" can-attach-label="true">
<default-constraints vsize-policy="6" hsize-policy="6" anchor="0" fill="3">
<preferred-size width="150" height="50" />
</default-constraints>
</item>
<item class="javax.swing.JTextPane" icon="/com/intellij/uiDesigner/icons/textPane.svg" removable="false" auto-create-binding="true" can-attach-label="true">
<default-constraints vsize-policy="6" hsize-policy="6" anchor="0" fill="3">
<preferred-size width="150" height="50" />
</default-constraints>
</item>
<item class="javax.swing.JEditorPane" icon="/com/intellij/uiDesigner/icons/editorPane.svg" removable="false" auto-create-binding="true" can-attach-label="true">
<default-constraints vsize-policy="6" hsize-policy="6" anchor="0" fill="3">
<preferred-size width="150" height="50" />
</default-constraints>
</item>
<item class="javax.swing.JComboBox" icon="/com/intellij/uiDesigner/icons/comboBox.svg" removable="false" auto-create-binding="true" can-attach-label="true">
<default-constraints vsize-policy="0" hsize-policy="2" anchor="8" fill="1" />
</item>
<item class="javax.swing.JTable" icon="/com/intellij/uiDesigner/icons/table.svg" removable="false" auto-create-binding="true" can-attach-label="false">
<default-constraints vsize-policy="6" hsize-policy="6" anchor="0" fill="3">
<preferred-size width="150" height="50" />
</default-constraints>
</item>
<item class="javax.swing.JList" icon="/com/intellij/uiDesigner/icons/list.svg" removable="false" auto-create-binding="true" can-attach-label="false">
<default-constraints vsize-policy="6" hsize-policy="2" anchor="0" fill="3">
<preferred-size width="150" height="50" />
</default-constraints>
</item>
<item class="javax.swing.JTree" icon="/com/intellij/uiDesigner/icons/tree.svg" removable="false" auto-create-binding="true" can-attach-label="false">
<default-constraints vsize-policy="6" hsize-policy="6" anchor="0" fill="3">
<preferred-size width="150" height="50" />
</default-constraints>
</item>
<item class="javax.swing.JTabbedPane" icon="/com/intellij/uiDesigner/icons/tabbedPane.svg" removable="false" auto-create-binding="true" can-attach-label="false">
<default-constraints vsize-policy="3" hsize-policy="3" anchor="0" fill="3">
<preferred-size width="200" height="200" />
</default-constraints>
</item>
<item class="javax.swing.JSplitPane" icon="/com/intellij/uiDesigner/icons/splitPane.svg" removable="false" auto-create-binding="false" can-attach-label="false">
<default-constraints vsize-policy="3" hsize-policy="3" anchor="0" fill="3">
<preferred-size width="200" height="200" />
</default-constraints>
</item>
<item class="javax.swing.JSpinner" icon="/com/intellij/uiDesigner/icons/spinner.svg" removable="false" auto-create-binding="true" can-attach-label="true">
<default-constraints vsize-policy="0" hsize-policy="6" anchor="8" fill="1" />
</item>
<item class="javax.swing.JSlider" icon="/com/intellij/uiDesigner/icons/slider.svg" removable="false" auto-create-binding="true" can-attach-label="false">
<default-constraints vsize-policy="0" hsize-policy="6" anchor="8" fill="1" />
</item>
<item class="javax.swing.JSeparator" icon="/com/intellij/uiDesigner/icons/separator.svg" removable="false" auto-create-binding="false" can-attach-label="false">
<default-constraints vsize-policy="6" hsize-policy="6" anchor="0" fill="3" />
</item>
<item class="javax.swing.JProgressBar" icon="/com/intellij/uiDesigner/icons/progressbar.svg" removable="false" auto-create-binding="true" can-attach-label="false">
<default-constraints vsize-policy="0" hsize-policy="6" anchor="0" fill="1" />
</item>
<item class="javax.swing.JToolBar" icon="/com/intellij/uiDesigner/icons/toolbar.svg" removable="false" auto-create-binding="false" can-attach-label="false">
<default-constraints vsize-policy="0" hsize-policy="6" anchor="0" fill="1">
<preferred-size width="-1" height="20" />
</default-constraints>
</item>
<item class="javax.swing.JToolBar$Separator" icon="/com/intellij/uiDesigner/icons/toolbarSeparator.svg" removable="false" auto-create-binding="false" can-attach-label="false">
<default-constraints vsize-policy="0" hsize-policy="0" anchor="0" fill="1" />
</item>
<item class="javax.swing.JScrollBar" icon="/com/intellij/uiDesigner/icons/scrollbar.svg" removable="false" auto-create-binding="true" can-attach-label="false">
<default-constraints vsize-policy="6" hsize-policy="0" anchor="0" fill="2" />
</item>
</group>
</component>
</project>

View file

@ -2,5 +2,6 @@
<project version="4"> <project version="4">
<component name="VcsDirectoryMappings"> <component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$" vcs="Git" /> <mapping directory="$PROJECT_DIR$" vcs="Git" />
<mapping directory="$PROJECT_DIR$/desktop" vcs="Git" />
</component> </component>
</project> </project>

View file

@ -4,9 +4,7 @@ import android.content.Context
import androidx.compose.ui.test.* import androidx.compose.ui.test.*
import androidx.compose.ui.test.junit4.ComposeTestRule import androidx.compose.ui.test.junit4.ComposeTestRule
import androidx.test.platform.app.InstrumentationRegistry import androidx.test.platform.app.InstrumentationRegistry
import com.wbrawner.pihelper.ADD_SCREEN_TAG
import com.wbrawner.pihelper.CONNECT_BUTTON_TAG import com.wbrawner.pihelper.CONNECT_BUTTON_TAG
import com.wbrawner.pihelper.HOST_TAG
fun onAddScreen(testRule: ComposeTestRule, actions: AddScreenRobot.() -> Unit) = fun onAddScreen(testRule: ComposeTestRule, actions: AddScreenRobot.() -> Unit) =
AddScreenRobot(testRule).apply { actions() } AddScreenRobot(testRule).apply { actions() }
@ -14,30 +12,22 @@ fun onAddScreen(testRule: ComposeTestRule, actions: AddScreenRobot.() -> Unit) =
class AddScreenRobot(private val testRule: ComposeTestRule) { class AddScreenRobot(private val testRule: ComposeTestRule) {
val context: Context = InstrumentationRegistry.getInstrumentation().context val context: Context = InstrumentationRegistry.getInstrumentation().context
init {
testRule.waitUntil {
testRule
.onAllNodesWithTag(ADD_SCREEN_TAG)
.fetchSemanticsNodes().size == 1
}
}
infix fun onAuthScreen(actions: AuthScreenRobot.() -> Unit) = AuthScreenRobot(testRule).run { infix fun onAuthScreen(actions: AuthScreenRobot.() -> Unit) = AuthScreenRobot(testRule).run {
actions() actions()
} }
fun clearHost() = fun clearHost() =
testRule.onNode(hasTestTag(HOST_TAG)).performTextClearance() testRule.onNodeWithContentDescription("Pi-hole host input").performTextClearance()
fun inputHost(host: String) = fun inputHost(host: String) =
testRule.onNode(hasTestTag(HOST_TAG)).performTextInput(host) testRule.onNodeWithContentDescription("Pi-hole host input").performTextInput(host)
fun clickConnect() = testRule.onNode(hasTestTag(CONNECT_BUTTON_TAG)).performClick() fun clickConnect() = testRule.onNode(hasTestTag(CONNECT_BUTTON_TAG)).performClick()
fun verifyErrorMessageIsDisplayed(message: String) { fun verifyErrorMessageIsDisplayed(message: String) {
testRule.waitUntil(2_000) { testRule.waitUntil(2_000) {
testRule testRule
.onAllNodesWithText(message, substring = true) .onAllNodesWithContentDescription(message, substring = true)
.fetchSemanticsNodes().size == 1 .fetchSemanticsNodes().size == 1
} }
} }

View file

@ -4,7 +4,7 @@ import android.content.Context
import androidx.compose.ui.test.* import androidx.compose.ui.test.*
import androidx.compose.ui.test.junit4.ComposeTestRule import androidx.compose.ui.test.junit4.ComposeTestRule
import androidx.test.platform.app.InstrumentationRegistry import androidx.test.platform.app.InstrumentationRegistry
import com.wbrawner.pihelper.* import com.wbrawner.pihelper.shared.ui.*
class AuthScreenRobot(private val testRule: ComposeTestRule) { class AuthScreenRobot(private val testRule: ComposeTestRule) {
val context: Context = InstrumentationRegistry.getInstrumentation().context val context: Context = InstrumentationRegistry.getInstrumentation().context

View file

@ -4,10 +4,10 @@ import android.content.Context
import androidx.compose.ui.test.* import androidx.compose.ui.test.*
import androidx.compose.ui.test.junit4.ComposeTestRule import androidx.compose.ui.test.junit4.ComposeTestRule
import androidx.test.platform.app.InstrumentationRegistry import androidx.test.platform.app.InstrumentationRegistry
import com.wbrawner.pihelper.DISABLE_PERMANENT_BUTTON_TAG import com.wbrawner.pihelper.shared.ui.DISABLE_PERMANENT_BUTTON_TAG
import com.wbrawner.pihelper.ENABLE_BUTTON_TAG import com.wbrawner.pihelper.shared.ui.ENABLE_BUTTON_TAG
import com.wbrawner.pihelper.MAIN_SCREEN_TAG import com.wbrawner.pihelper.shared.ui.MAIN_SCREEN_TAG
import com.wbrawner.pihelper.STATUS_TEXT_TAG import com.wbrawner.pihelper.shared.ui.STATUS_TEXT_TAG
fun onMainScreen(testRule: ComposeTestRule, actions: MainScreenRobot.() -> Unit) = fun onMainScreen(testRule: ComposeTestRule, actions: MainScreenRobot.() -> Unit) =
MainScreenRobot(testRule).apply { actions() } MainScreenRobot(testRule).apply { actions() }

View file

@ -5,28 +5,16 @@ import android.net.ConnectivityManager
import android.net.NetworkCapabilities import android.net.NetworkCapabilities
import android.os.Build import android.os.Build
import android.widget.Toast import android.widget.Toast
import androidx.compose.foundation.background import androidx.compose.runtime.Composable
import androidx.compose.foundation.layout.* import androidx.compose.runtime.collectAsState
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.runtime.getValue
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.ExperimentalComposeUiApi
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.platform.LocalContext
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import com.wbrawner.pihelper.shared.Action import com.wbrawner.pihelper.shared.Action
import com.wbrawner.pihelper.shared.Effect import com.wbrawner.pihelper.shared.Effect
import com.wbrawner.pihelper.shared.Store import com.wbrawner.pihelper.shared.Store
import com.wbrawner.pihelper.ui.DayNightPreview import com.wbrawner.pihelper.shared.ui.AddScreen
import com.wbrawner.pihelper.ui.PihelperTheme import com.wbrawner.pihelper.shared.ui.OrDivider
import com.wbrawner.pihelper.shared.ui.theme.PihelperTheme
import java.net.Inet4Address import java.net.Inet4Address
val emulatorBuildModels = listOf( val emulatorBuildModels = listOf(
@ -34,11 +22,6 @@ val emulatorBuildModels = listOf(
"sdk_gphone64_arm64" "sdk_gphone64_arm64"
) )
const val ADD_SCREEN_TAG = "addScreen"
const val CONNECT_BUTTON_TAG = "connectButton"
const val HOST_TAG = "hostInput"
const val SCAN_BUTTON_TAG = "scanButton"
@Composable @Composable
fun AddScreen(store: Store) { fun AddScreen(store: Store) {
val effect by store.effects.collectAsState(initial = Effect.Empty) val effect by store.effects.collectAsState(initial = Effect.Empty)
@ -83,96 +66,6 @@ fun AddScreen(store: Store) {
) )
} }
@OptIn(ExperimentalMaterial3Api::class, ExperimentalComposeUiApi::class)
@Composable
fun AddScreen(
scanNetwork: () -> Unit,
connectToPihole: (String) -> Unit,
loading: Boolean = false,
error: Effect.Error? = null
) {
val (host: String, setHost: (String) -> Unit) = remember { mutableStateOf("pi.hole") }
val keyboardController = LocalSoftwareKeyboardController.current
Column(
modifier = Modifier
.testTag(ADD_SCREEN_TAG)
.padding(16.dp)
.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterVertically),
) {
LoadingSpinner(loading)
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(
modifier = Modifier.testTag(SCAN_BUTTON_TAG),
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
.testTag(HOST_TAG)
.fillMaxWidth(),
value = host,
onValueChange = setHost,
label = { Text("Pi-hole Host") }
)
PrimaryButton(
modifier = Modifier.testTag(CONNECT_BUTTON_TAG),
text = "Connect to Pi-hole",
onClick = {
keyboardController?.hide()
connectToPihole(host)
}
)
error?.let {
Text(
color = MaterialTheme.colorScheme.primary,
textAlign = TextAlign.Center,
text = "Connection failed: ${it.message}"
)
}
}
}
@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.colorScheme.onSurface),
)
Text("OR")
Box(
modifier = Modifier
.height(2.dp)
.weight(1f)
.padding(start = 8.dp)
.clip(RectangleShape)
.background(MaterialTheme.colorScheme.onSurface),
)
}
}
@Composable @Composable
@DayNightPreview @DayNightPreview
fun AddScreen_Preview() { fun AddScreen_Preview() {

View file

@ -0,0 +1,8 @@
package com.wbrawner.pihelper
import android.content.res.Configuration
import androidx.compose.ui.tooling.preview.Preview
@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO)
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES)
annotation class DayNightPreview

View file

@ -9,15 +9,8 @@ import android.view.animation.AnticipateInterpolator
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.compose.animation.ExperimentalAnimationApi import androidx.compose.animation.ExperimentalAnimationApi
import androidx.compose.animation.core.*
import androidx.compose.foundation.Image
import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.core.animation.doOnEnd import androidx.core.animation.doOnEnd
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
@ -29,7 +22,11 @@ import com.wbrawner.pihelper.shared.Action
import com.wbrawner.pihelper.shared.Effect import com.wbrawner.pihelper.shared.Effect
import com.wbrawner.pihelper.shared.Route import com.wbrawner.pihelper.shared.Route
import com.wbrawner.pihelper.shared.Store import com.wbrawner.pihelper.shared.Store
import com.wbrawner.pihelper.ui.PihelperTheme import com.wbrawner.pihelper.shared.ui.AuthScreen
import com.wbrawner.pihelper.shared.ui.InfoScreen
import com.wbrawner.pihelper.shared.ui.MainScreen
import com.wbrawner.pihelper.shared.ui.component.LoadingSpinner
import com.wbrawner.pihelper.shared.ui.theme.PihelperTheme
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject import javax.inject.Inject
@ -130,26 +127,6 @@ class MainActivity : AppCompatActivity() {
} }
} }
@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.colorScheme.onBackground)
)
}
@Composable @Composable
@Preview @Preview
fun LoadingSpinner_Preview() { fun LoadingSpinner_Preview() {
@ -170,3 +147,11 @@ enum class ShortcutActions(val fullName: String) {
} }
const val DURATION: String = "com.wbrawner.pihelper.MainActivityKt.DURATION" const val DURATION: String = "com.wbrawner.pihelper.MainActivityKt.DURATION"
@Composable
@DayNightPreview
fun InfoScreen_Preview() {
PihelperTheme {
InfoScreen({}, {})
}
}

View file

@ -12,7 +12,7 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.wbrawner.pihelper.shared.Store import com.wbrawner.pihelper.shared.Store
import com.wbrawner.pihelper.ui.PihelperTheme import com.wbrawner.pihelper.shared.ui.theme.PihelperTheme
@Composable @Composable
fun ScanScreen(store: Store) { fun ScanScreen(store: Store) {

View file

@ -18,5 +18,8 @@ allprojects {
maven { maven {
url = URI("https://s01.oss.sonatype.org/content/repositories/snapshots/") url = URI("https://s01.oss.sonatype.org/content/repositories/snapshots/")
} }
maven {
url = URI("https://maven.pkg.jetbrains.space/public/p/compose/dev/")
}
} }
} }

View file

@ -22,3 +22,4 @@ kotlin.code.style=official
kotlin.mpp.enableGranularSourceSetsMetadata=true kotlin.mpp.enableGranularSourceSetsMetadata=true
kotlin.native.enableDependencyPropagation=false kotlin.native.enableDependencyPropagation=false
org.jetbrains.compose.experimental.uikit.enabled=true

View file

@ -1,23 +1,24 @@
[versions] [versions]
androidx-core = "1.9.0" androidx-core = "1.10.1"
androidx-appcompat = "1.5.1" androidx-appcompat = "1.6.1"
androidx-splash = "1.0.0" androidx-splash = "1.0.1"
androidx-test-runner = "1.5.1" androidx-test-runner = "1.5.2"
androidx-test-orchestrator = "1.4.2" androidx-test-orchestrator = "1.4.2"
compose = "1.2.1" compose = "1.5.0"
compose-compiler = "1.3.2" compose-compiler = "1.4.2"
compose-material3 = "1.0.1" compose-material = "1.5.0"
espresso = "3.3.0" compose-material3 = "1.1.1"
espresso = "3.5.1"
hilt-android = "2.44" hilt-android = "2.44"
kotlin = "1.7.20" kotlin = "1.8.20"
kotlinx-serialization = "1.4.1" kotlinx-serialization = "1.4.1"
kotlinx-coroutines = "1.6.4" kotlinx-coroutines = "1.6.4"
kotlinx-datetime = "0.4.0" kotlinx-datetime = "0.4.0"
ktor = "2.1.2" ktor = "2.1.2"
material = "1.3.0" material = "1.9.0"
maxSdk = "33" maxSdk = "33"
minSdk = "23" minSdk = "23"
navigation = "2.4.1" navigation = "2.7.0"
okhttp = "4.10.0" okhttp = "4.10.0"
plausible = "0.1.0-SNAPSHOT" plausible = "0.1.0-SNAPSHOT"
settings = "0.8.1" settings = "0.8.1"
@ -25,16 +26,17 @@ versionCode = "5"
versionName = "1.1.1" versionName = "1.1.1"
[libraries] [libraries]
android-gradle = { module = "com.android.tools.build:gradle", version = "7.3.1" } android-gradle = { module = "com.android.tools.build:gradle", version = "7.4.2" }
androidx-appcompat = { module = "androidx.appcompat:appcompat", version.ref = "androidx-appcompat" } androidx-appcompat = { module = "androidx.appcompat:appcompat", version.ref = "androidx-appcompat" }
androidx-core = { module = "androidx.core:core-ktx", version.ref = "androidx-core" } androidx-core = { module = "androidx.core:core-ktx", version.ref = "androidx-core" }
androidx-splash = { module = "androidx.core:core-splashscreen", version.ref = "androidx-splash" } androidx-splash = { module = "androidx.core:core-splashscreen", version.ref = "androidx-splash" }
androidx-test-runner = { module = "androidx.test:runner", version.ref = "androidx-test-runner" } androidx-test-runner = { module = "androidx.test:runner", version.ref = "androidx-test-runner" }
androidx-test-orchestrator = { module = "androidx.test:orchestrator", version.ref = "androidx-test-orchestrator" } androidx-test-orchestrator = { module = "androidx.test:orchestrator", version.ref = "androidx-test-orchestrator" }
compose-activity = { module = "androidx.activity:activity-compose", version = "1.6.0" } compose-activity = { module = "androidx.activity:activity-compose", version = "1.7.2" }
compose-material = { module = "androidx.compose.material:material", version.ref = "compose" } compose-material = { module = "androidx.compose.material:material", version.ref = "compose-material" }
compose-material3 = { module = "androidx.compose.material3:material3", version.ref = "compose-material3" } compose-material3 = { module = "androidx.compose.material3:material3", version.ref = "compose-material3" }
compose-material3-window = { module = "androidx.compose.material3:material3-window-size-class", version.ref = "compose-material3" } compose-material3-window = { module = "androidx.compose.material3:material3-window-size-class", version.ref = "compose-material3" }
compose-plugin-jetbrains = { module = "org.jetbrains.compose:compose-gradle-plugin", version = "1.5.0-rc04" }
compose-test-junit = { module = "androidx.compose.ui:ui-test-junit4", version.ref = "compose" } compose-test-junit = { module = "androidx.compose.ui:ui-test-junit4", version.ref = "compose" }
compose-test-manifest = { module = "androidx.compose.ui:ui-test-manifest", version.ref = "compose" } compose-test-manifest = { module = "androidx.compose.ui:ui-test-manifest", version.ref = "compose" }
compose-tooling = { module = "androidx.compose.ui:ui-tooling", version.ref = "compose" } compose-tooling = { module = "androidx.compose.ui:ui-tooling", version.ref = "compose" }
@ -45,12 +47,13 @@ hilt-android-core = { module = "com.google.dagger:hilt-android", version.ref = "
hilt-android-kapt = { module = "com.google.dagger:hilt-android-compiler", version.ref = "hilt-android" } hilt-android-kapt = { module = "com.google.dagger:hilt-android-compiler", version.ref = "hilt-android" }
hilt-android-testing = { module = "com.google.dagger:hilt-android-testing", version.ref = "hilt-android" } hilt-android-testing = { module = "com.google.dagger:hilt-android-testing", version.ref = "hilt-android" }
hilt-navigation-compose = { module = "androidx.hilt:hilt-navigation-compose", version = "1.0.0" } hilt-navigation-compose = { module = "androidx.hilt:hilt-navigation-compose", version = "1.0.0" }
junit = { module = "junit:junit", version = "4.12" } junit = { module = "junit:junit", version = "4.13.2" }
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-reflect = { module = "org.jetbrains.kotlin:kotlin-reflect", version.ref = "kotlin" } kotlin-reflect = { module = "org.jetbrains.kotlin:kotlin-reflect", 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" }
ktor-client-ios = { module = "io.ktor:ktor-client-ios", version.ref = "ktor" } ktor-client-ios = { module = "io.ktor:ktor-client-ios", version.ref = "ktor" }
ktor-client-js = { module = "io.ktor:ktor-client-js", version.ref = "ktor" } ktor-client-js = { module = "io.ktor:ktor-client-js", version.ref = "ktor" }
ktor-client-cio = { module = "io.ktor:ktor-client-cio", version.ref = "ktor" }
ktor-client-android = { module = "io.ktor:ktor-client-android", version.ref = "ktor" } ktor-client-android = { module = "io.ktor:ktor-client-android", version.ref = "ktor" }
ktor-client-content-negotiation = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktor" } ktor-client-content-negotiation = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktor" }
ktor-client-serialization = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" } ktor-client-serialization = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" }
@ -58,6 +61,7 @@ ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" }
ktor-client-logging = { module = "io.ktor:ktor-client-logging", version.ref = "ktor" } ktor-client-logging = { module = "io.ktor:ktor-client-logging", version.ref = "ktor" }
kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinx-coroutines" } kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinx-coroutines" }
kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "kotlinx-coroutines" } kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "kotlinx-coroutines" }
kotlinx-coroutines-jvm = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-swing", version.ref = "kotlinx-coroutines" }
kotlinx-datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "kotlinx-datetime" } kotlinx-datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "kotlinx-datetime" }
kotlin-serialization = { module = "org.jetbrains.kotlin:kotlin-serialization", version.ref = "kotlin" } kotlin-serialization = { module = "org.jetbrains.kotlin:kotlin-serialization", version.ref = "kotlin" }
kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization" } kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization" }
@ -68,10 +72,10 @@ navigation-compose = { module = "androidx.navigation:navigation-compose", versio
navigation-fragment = { module = "androidx.navigation:navigation-fragment-ktx", version.ref = "navigation" } navigation-fragment = { module = "androidx.navigation:navigation-fragment-ktx", version.ref = "navigation" }
navigation-ui = { module = "androidx.navigation:navigation-ui-ktx", version.ref = "navigation" } navigation-ui = { module = "androidx.navigation:navigation-ui-ktx", version.ref = "navigation" }
plausible = { module = "com.wbrawner.plausible:plausible-android", version.ref = "plausible" } plausible = { module = "com.wbrawner.plausible:plausible-android", version.ref = "plausible" }
preference = { module = "androidx.preference:preference-ktx", version = "1.1.1" } preference = { module = "androidx.preference:preference-ktx", version = "1.2.0" }
test-ext = { module = "androidx.test.ext:junit", version = "1.1.4" } test-ext = { module = "androidx.test.ext:junit", version = "1.1.5" }
[bundles] [bundles]
compose = ["compose-ui", "compose-material", "compose-material3", "compose-material3-window", "compose-tooling", "compose-activity", "navigation-compose"] compose = ["compose-ui", "compose-material", "compose-material3", "compose-material3-window", "compose-tooling", "compose-activity", "navigation-compose"]
coroutines = ["kotlinx-coroutines-core", "kotlinx-coroutines-android"] coroutines = ["kotlinx-coroutines-core", "kotlinx-coroutines-android"]
plugins = ["android-gradle", "kotlin-gradle", "dagger-hilt", "kotlin-serialization"] plugins = ["android-gradle", "compose-plugin-jetbrains", "kotlin-gradle", "dagger-hilt", "kotlin-serialization"]

View file

@ -1,4 +1,5 @@
enableFeaturePreview("VERSION_CATALOGS") enableFeaturePreview("VERSION_CATALOGS")
rootProject.name = "Pi-helper" rootProject.name = "Pi-helper"
include(":app") include(":app")
include(":desktop")
include(":shared") include(":shared")

View file

@ -2,6 +2,7 @@ plugins {
kotlin("multiplatform") kotlin("multiplatform")
id("com.android.library") id("com.android.library")
kotlin("plugin.serialization") kotlin("plugin.serialization")
id("org.jetbrains.compose")
} }
kotlin { kotlin {
@ -11,11 +12,13 @@ kotlin {
baseName = "Pihelper" baseName = "Pihelper"
} }
} }
jvm("desktop")
sourceSets { sourceSets {
val commonMain by getting { val commonMain by getting {
dependencies { dependencies {
implementation(libs.ktor.client.core) implementation(libs.ktor.client.core)
implementation(libs.ktor.client.cio)
implementation(libs.ktor.client.logging) implementation(libs.ktor.client.logging)
implementation(libs.ktor.client.serialization) implementation(libs.ktor.client.serialization)
implementation(libs.ktor.client.content.negotiation) implementation(libs.ktor.client.content.negotiation)
@ -23,12 +26,16 @@ kotlin {
implementation(libs.kotlinx.datetime) implementation(libs.kotlinx.datetime)
implementation(libs.kotlinx.serialization.json) implementation(libs.kotlinx.serialization.json)
api(libs.multiplatform.settings) api(libs.multiplatform.settings)
api(compose.runtime)
api(compose.foundation)
api(compose.material3)
@OptIn(org.jetbrains.compose.ExperimentalComposeLibrary::class)
implementation(compose.components.resources)
} }
} }
val androidMain by getting { val androidMain by getting {
dependencies { dependencies {
implementation(libs.ktor.client.android)
implementation(libs.plausible) implementation(libs.plausible)
} }
} }
@ -40,7 +47,13 @@ kotlin {
iosArm64Main.dependsOn(this) iosArm64Main.dependsOn(this)
iosSimulatorArm64Main.dependsOn(this) iosSimulatorArm64Main.dependsOn(this)
dependencies { dependencies {
implementation(libs.ktor.client.ios) }
}
val desktopMain by getting {
dependencies {
implementation(compose.desktop.common)
implementation(compose.uiTooling)
} }
} }
} }

View file

@ -1,13 +1,8 @@
package com.wbrawner.pihelper.shared package com.wbrawner.pihelper.shared
import io.ktor.client.*
import io.ktor.client.engine.android.*
import java.math.BigInteger import java.math.BigInteger
import java.security.MessageDigest import java.security.MessageDigest
fun PiholeAPIService.Companion.create() = KtorPiholeAPIService(HttpClient(Android) {
commonConfig()
})
actual fun String.hash(): String = BigInteger( actual fun String.hash(): String = BigInteger(
1, 1,

View file

@ -3,6 +3,7 @@ package com.wbrawner.pihelper.shared
import io.ktor.client.* import io.ktor.client.*
import io.ktor.client.call.* import io.ktor.client.call.*
import io.ktor.client.engine.* import io.ktor.client.engine.*
import io.ktor.client.engine.cio.*
import io.ktor.client.plugins.* import io.ktor.client.plugins.*
import io.ktor.client.plugins.contentnegotiation.* import io.ktor.client.plugins.contentnegotiation.*
import io.ktor.client.request.* import io.ktor.client.request.*
@ -26,6 +27,10 @@ interface PiholeAPIService {
companion object companion object
} }
fun PiholeAPIService.Companion.create() = KtorPiholeAPIService(HttpClient(CIO) {
commonConfig()
})
fun <T : HttpClientEngineConfig> HttpClientConfig<T>.commonConfig() { fun <T : HttpClientEngineConfig> HttpClientConfig<T>.commonConfig() {
install(ContentNegotiation) { install(ContentNegotiation) {
json(Json { json(Json {

View file

@ -8,41 +8,9 @@ import kotlinx.serialization.descriptors.SerialDescriptor
import kotlinx.serialization.encoding.Decoder import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder import kotlinx.serialization.encoding.Encoder
@Serializable() @Serializable
data class Summary( data class Summary(
@SerialName("domains_being_blocked")
val domainsBeingBlocked: String? = null,
@SerialName("dns_queries_today")
val dnsQueriesToday: String? = null,
@SerialName("ads_blocked_today")
val adsBlockedToday: String? = null,
@SerialName("ads_percentage_today")
val adsPercentageToday: String? = null,
@SerialName("unique_domains")
val uniqueDomains: String? = null,
@SerialName("queries_forwarded")
val queriesForwarded: String? = null,
@SerialName("clients_ever_seen")
val clientsEverSeen: String? = null,
@SerialName("unique_clients")
val uniqueClients: String? = null,
@SerialName("dns_queries_all_types")
val dnsQueriesAllTypes: String? = null,
@SerialName("queries_cached")
val queriesCached: String? = null,
@SerialName("no_data_replies")
val noDataReplies: String? = null,
@SerialName("nx_domain_replies")
val nxDomainReplies: String? = null,
@SerialName("cname_replies")
val cnameReplies: String? = null,
@SerialName("in_replies")
val ipReplies: String? = null,
@SerialName("privacy_level")
val privacyLevel: String,
override val status: Status, override val status: Status,
@SerialName("gravity_last_updated")
val gravity: Gravity? = null,
val type: String? = null, val type: String? = null,
val version: Int? = null val version: Int? = null
) : StatusProvider ) : StatusProvider
@ -77,21 +45,6 @@ sealed class Status(val name: String) {
} }
} }
@Serializable()
data class Gravity(
@SerialName("file_exists")
val fileExists: Boolean,
val absolute: Int,
val relative: Relative
)
@Serializable()
data class Relative(
val days: String,
val hours: String,
val minutes: String
)
@Serializable() @Serializable()
data class VersionResponse(val version: Int) data class VersionResponse(val version: Int)

View file

@ -230,6 +230,7 @@ class Store(
route = Route.AUTH, route = Route.AUTH,
) )
} catch (e: Exception) { } catch (e: Exception) {
e.printStackTrace()
_state.value = _state.value.copy(loading = false) _state.value = _state.value.copy(loading = false)
if (emitError) { if (emitError) {
_effects.emit(Effect.Error(e.message ?: "Failed to connect to $host")) _effects.emit(Effect.Error(e.message ?: "Failed to connect to $host"))

View file

@ -0,0 +1,120 @@
package com.wbrawner.pihelper.shared.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import com.wbrawner.pihelper.shared.Effect
import com.wbrawner.pihelper.shared.ui.component.LoadingSpinner
import com.wbrawner.pihelper.shared.ui.component.PrimaryButton
const val ADD_SCREEN_TAG = "addScreen"
const val CONNECT_BUTTON_TAG = "connectButton"
const val HOST_TAG = "hostInput"
const val SCAN_BUTTON_TAG = "scanButton"
@OptIn(ExperimentalComposeUiApi::class, ExperimentalMaterial3Api::class)
@Composable
fun AddScreen(
scanNetwork: () -> Unit,
connectToPihole: (String) -> Unit,
loading: Boolean = false,
error: Effect.Error? = null
) {
val (host: String, setHost: (String) -> Unit) = remember { mutableStateOf("pi.hole") }
val keyboardController = LocalSoftwareKeyboardController.current
Column(
modifier = Modifier
.testTag(ADD_SCREEN_TAG)
.padding(16.dp)
.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterVertically),
) {
LoadingSpinner(loading)
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(
modifier = Modifier.semantics { contentDescription = "Scan Network button" },
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
.semantics { contentDescription = "Pi-hole host input" }
.fillMaxWidth(),
value = host,
onValueChange = setHost,
label = { Text("Pi-hole Host") }
)
PrimaryButton(
modifier = Modifier.testTag(CONNECT_BUTTON_TAG),
text = "Connect to Pi-hole",
onClick = {
keyboardController?.hide()
connectToPihole(host)
}
)
error?.let {
Text(
color = MaterialTheme.colorScheme.primary,
textAlign = TextAlign.Center,
text = "Connection failed: ${it.message}"
)
}
}
}
@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.colorScheme.onSurface),
)
Text("OR")
Box(
modifier = Modifier
.height(2.dp)
.weight(1f)
.padding(start = 8.dp)
.clip(RectangleShape)
.background(MaterialTheme.colorScheme.onSurface),
)
}
}

View file

@ -1,6 +1,5 @@
package com.wbrawner.pihelper package com.wbrawner.pihelper.shared.ui
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
@ -22,11 +21,15 @@ import androidx.compose.ui.platform.LocalAutofill
import androidx.compose.ui.platform.LocalAutofillTree import androidx.compose.ui.platform.LocalAutofillTree
import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.platform.testTag import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.wbrawner.pihelper.shared.* import com.wbrawner.pihelper.shared.Action
import com.wbrawner.pihelper.shared.AuthenticationString
import com.wbrawner.pihelper.shared.Effect
import com.wbrawner.pihelper.shared.Store
import com.wbrawner.pihelper.shared.ui.component.LoadingSpinner
import com.wbrawner.pihelper.shared.ui.component.PrimaryButton
const val AUTH_SCREEN_TAG = "authScreen" const val AUTH_SCREEN_TAG = "authScreen"
const val SUCCESS_TEXT_TAG = "successText" const val SUCCESS_TEXT_TAG = "successText"
@ -63,10 +66,7 @@ fun AuthScreen(
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterVertically), verticalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterVertically),
) { ) {
Image( LoadingSpinner(animate = false)
painter = painterResource(id = R.drawable.ic_app_logo),
contentDescription = null
)
Text( Text(
modifier = Modifier.testTag(SUCCESS_TEXT_TAG), modifier = Modifier.testTag(SUCCESS_TEXT_TAG),
text = "Pi-helper has successfully connected to your Pi-Hole!", text = "Pi-helper has successfully connected to your Pi-Hole!",

View file

@ -1,7 +1,5 @@
package com.wbrawner.pihelper package com.wbrawner.pihelper.shared.ui
import android.content.res.Configuration.UI_MODE_NIGHT_NO
import android.content.res.Configuration.UI_MODE_NIGHT_YES
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
@ -20,10 +18,10 @@ import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.wbrawner.pihelper.shared.* import com.wbrawner.pihelper.shared.Action
import com.wbrawner.pihelper.ui.PihelperTheme import com.wbrawner.pihelper.shared.Store
import com.wbrawner.pihelper.shared.ui.component.LoadingSpinner
@Composable @Composable
fun InfoScreen(store: Store) { fun InfoScreen(store: Store) {
@ -107,10 +105,10 @@ fun InfoScreen(onBackClicked: () -> Unit, onForgetPiholeClicked: () -> Unit) {
message.getStringAnnotations(it, it).firstOrNull()?.let { annotation -> message.getStringAnnotations(it, it).firstOrNull()?.let { annotation ->
uriHandler.openUri(annotation.item) uriHandler.openUri(annotation.item)
// TODO: Move this to the store? // TODO: Move this to the store?
PlausibleAnalyticsHelper.event( // PlausibleAnalyticsHelper.event(
AnalyticsEvent.LinkClicked(annotation.item), // AnalyticsEvent.LinkClicked(annotation.item),
Route.ABOUT // Route.ABOUT
) // )
} }
} }
TextButton(onClick = onForgetPiholeClicked) { TextButton(onClick = onForgetPiholeClicked) {
@ -119,12 +117,3 @@ fun InfoScreen(onBackClicked: () -> Unit, onForgetPiholeClicked: () -> Unit) {
} }
} }
} }
@Composable
@Preview(showSystemUi = true, uiMode = UI_MODE_NIGHT_NO)
@Preview(showSystemUi = true, uiMode = UI_MODE_NIGHT_YES)
fun InfoScreen_Preview() {
PihelperTheme {
InfoScreen({}, {})
}
}

View file

@ -1,6 +1,6 @@
@file:OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3Api::class) @file:OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3Api::class)
package com.wbrawner.pihelper package com.wbrawner.pihelper.shared.ui
import androidx.compose.animation.AnimatedContent import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.ExperimentalAnimationApi import androidx.compose.animation.ExperimentalAnimationApi
@ -24,9 +24,8 @@ import com.wbrawner.pihelper.shared.Action
import com.wbrawner.pihelper.shared.Effect import com.wbrawner.pihelper.shared.Effect
import com.wbrawner.pihelper.shared.Status import com.wbrawner.pihelper.shared.Status
import com.wbrawner.pihelper.shared.Store import com.wbrawner.pihelper.shared.Store
import com.wbrawner.pihelper.ui.DayNightPreview import com.wbrawner.pihelper.shared.ui.component.LoadingSpinner
import com.wbrawner.pihelper.ui.PihelperTheme import com.wbrawner.pihelper.shared.ui.component.PrimaryButton
import java.util.*
import kotlin.math.pow import kotlin.math.pow
import kotlin.math.roundToLong import kotlin.math.roundToLong
import com.wbrawner.pihelper.shared.State as PihelperState import com.wbrawner.pihelper.shared.State as PihelperState
@ -51,7 +50,6 @@ const val DISABLE_PERMANENT_BUTTON_TAG = "disablePermanentButton"
fun MainScreen(store: Store) { fun MainScreen(store: Store) {
val state by store.state.collectAsState() val state by store.state.collectAsState()
val effect by store.effects.collectAsState(initial = Effect.Empty) val effect by store.effects.collectAsState(initial = Effect.Empty)
println(effect)
MainScreen(state = state, error = effect as? Effect.Error, dispatch = store::dispatch) MainScreen(state = state, error = effect as? Effect.Error, dispatch = store::dispatch)
} }
@ -137,7 +135,7 @@ fun StatusLabel(status: Status) {
modifier = Modifier.testTag(STATUS_TEXT_TAG), modifier = Modifier.testTag(STATUS_TEXT_TAG),
color = color, color = color,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
text = status.name.capitalize(Locale.US) text = status.name.replaceFirstChar { if (it.isLowerCase()) it.titlecase() else it.toString() }
) )
if (status is Status.Disabled && !status.timeRemaining.isNullOrBlank()) { if (status is Status.Disabled && !status.timeRemaining.isNullOrBlank()) {
Text( Text(
@ -201,30 +199,13 @@ fun DisableControls(disable: (duration: Long?) -> Unit) {
} }
} }
@Composable
fun PrimaryButton(
modifier: Modifier = Modifier,
text: String,
onClick: () -> Unit
) {
Button(
modifier = modifier.fillMaxWidth(),
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.primary,
contentColor = MaterialTheme.colorScheme.onPrimary
),
onClick = onClick
) {
Text(text)
}
}
enum class Duration { enum class Duration {
SECONDS, SECONDS,
MINUTES, MINUTES,
HOURS HOURS
} }
@OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun CustomTimeDialog( fun CustomTimeDialog(
visible: Boolean, visible: Boolean,
@ -330,58 +311,58 @@ fun DurationToggle(
} }
} }
@Composable //@Composable
@DayNightPreview //@DayNightPreview
fun CustomTimeDialog_Preview() { //fun CustomTimeDialog_Preview() {
PihelperTheme { // PihelperTheme {
CustomTimeDialog(true, {}) { } // CustomTimeDialog(true, {}) { }
} // }
} //}
//
@Composable //@Composable
@DayNightPreview //@DayNightPreview
fun StatusLabelEnabled_Preview() { //fun StatusLabelEnabled_Preview() {
PihelperTheme { // PihelperTheme {
StatusLabel(Status.Enabled) // StatusLabel(Status.Enabled)
} // }
} //}
//
@Composable //@Composable
@DayNightPreview //@DayNightPreview
fun StatusLabelDisabled_Preview() { //fun StatusLabelDisabled_Preview() {
PihelperTheme { // PihelperTheme {
StatusLabel(Status.Disabled()) // StatusLabel(Status.Disabled())
} // }
} //}
//
@Composable //@Composable
@DayNightPreview //@DayNightPreview
fun StatusLabelDisabledWithTime_Preview() { //fun StatusLabelDisabledWithTime_Preview() {
PihelperTheme { // PihelperTheme {
StatusLabel(Status.Disabled("12:34:56")) // StatusLabel(Status.Disabled("12:34:56"))
} // }
} //}
//
@Composable //@Composable
@DayNightPreview //@DayNightPreview
fun PrimaryButton_Preview() { //fun PrimaryButton_Preview() {
PihelperTheme { // PihelperTheme {
PrimaryButton(text = "Disable") {} // PrimaryButton(text = "Disable") {}
} // }
} //}
//
@Composable //@Composable
@DayNightPreview //@DayNightPreview
fun EnableControls_Preview() { //fun EnableControls_Preview() {
PihelperTheme { // PihelperTheme {
EnableControls {} // EnableControls {}
} // }
} //}
//
@Composable //@Composable
@DayNightPreview //@DayNightPreview
fun DisableControls_Preview() { //fun DisableControls_Preview() {
PihelperTheme { // PihelperTheme {
DisableControls {} // DisableControls {}
} // }
} //}

View file

@ -0,0 +1,33 @@
package com.wbrawner.pihelper.shared.ui.component
import androidx.compose.animation.core.*
import androidx.compose.foundation.Image
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.graphics.ColorFilter
import org.jetbrains.compose.resources.ExperimentalResourceApi
import org.jetbrains.compose.resources.painterResource
@OptIn(ExperimentalResourceApi::class)
@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("img/ic_app_logo.xml"),
contentDescription = "Loading",
colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.onBackground)
)
}

View file

@ -0,0 +1,27 @@
package com.wbrawner.pihelper.shared.ui.component
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
@Composable
fun PrimaryButton(
modifier: Modifier = Modifier,
text: String,
onClick: () -> Unit
) {
Button(
modifier = modifier.fillMaxWidth(),
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.primary,
contentColor = MaterialTheme.colorScheme.onPrimary
),
onClick = onClick
) {
Text(text)
}
}

View file

@ -1,4 +1,4 @@
package com.wbrawner.pihelper.ui package com.wbrawner.pihelper.shared.ui.theme
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color

View file

@ -1,4 +1,4 @@
package com.wbrawner.pihelper.ui package com.wbrawner.pihelper.shared.ui.theme
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.Shapes import androidx.compose.material.Shapes

View file

@ -1,13 +1,12 @@
package com.wbrawner.pihelper.ui package com.wbrawner.pihelper.shared.ui.theme
import android.content.res.Configuration.UI_MODE_NIGHT_NO
import android.content.res.Configuration.UI_MODE_NIGHT_YES
import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.* import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.tooling.preview.Preview
private val DarkColorPalette = darkColorScheme( private val DarkColorPalette = darkColorScheme(
background = Color.Black, background = Color.Black,
@ -31,20 +30,10 @@ private val LightColorPalette = lightColorScheme(
@Composable @Composable
fun PihelperTheme(darkTheme: Boolean = isSystemInDarkTheme(), content: @Composable () -> Unit) { fun PihelperTheme(darkTheme: Boolean = isSystemInDarkTheme(), content: @Composable () -> Unit) {
val context = LocalContext.current val colors = if (darkTheme) {
val dynamic = false DarkColorPalette
val colors = if (dynamic) {
if (darkTheme) {
dynamicDarkColorScheme(context)
} else {
dynamicLightColorScheme(context)
}
} else { } else {
if (darkTheme) { LightColorPalette
DarkColorPalette
} else {
LightColorPalette
}
} }
MaterialTheme( MaterialTheme(
@ -56,7 +45,3 @@ fun PihelperTheme(darkTheme: Boolean = isSystemInDarkTheme(), content: @Composab
} }
) )
} }
@Preview(uiMode = UI_MODE_NIGHT_NO)
@Preview(uiMode = UI_MODE_NIGHT_YES)
annotation class DayNightPreview

View file

@ -1,4 +1,4 @@
package com.wbrawner.pihelper.ui package com.wbrawner.pihelper.shared.ui.theme
import androidx.compose.material3.Typography import androidx.compose.material3.Typography
import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.TextStyle

View file

@ -0,0 +1,18 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="404.37808"
android:viewportHeight="404.37808">
<group android:translateX="66.72238"
android:translateY="66.72238">
<path
android:pathData="m135.467,61.19c-40.93,0 -74.277,33.347 -74.277,74.277 0,12.31 3.02,23.932 8.352,34.169a33.872,33.872 0,0 0,-8.351 22.235,33.872 33.872,0 0,0 33.872,33.872 33.872,33.872 0,0 0,29.24 -16.839,33.872 33.872,0 0,0 0.001,-0.001c3.642,0.552 7.37,0.84 11.163,0.84 40.93,0 74.277,-33.346 74.277,-74.276 0,-40.93 -33.347,-74.277 -74.277,-74.277zM135.467,76.748c32.523,0 58.725,26.195 58.725,58.718 0,32.523 -26.202,58.725 -58.725,58.725 -2.242,0 -4.453,-0.129 -6.628,-0.372a33.872,33.872 0,0 0,0 -0.007,33.872 33.872,0 0,0 0.097,-1.943A33.872,33.872 0,0 0,95.063 157.999,33.872 33.872,0 0,0 82.318,160.512c-0,-0 -0,-0.001 -0.001,-0.001a33.872,33.872 0,0 0,-0.006 0.003c-3.568,-7.591 -5.564,-16.077 -5.564,-25.047 0,-32.523 26.195,-58.718 58.718,-58.718z"
android:strokeAlpha="1"
android:strokeLineJoin="miter"
android:strokeWidth="1.72941"
android:fillColor="#000000"
android:strokeColor="#00000000"
android:fillAlpha="1"
android:strokeLineCap="butt"/>
</group>
</vector>

View file

@ -0,0 +1,23 @@
package com.wbrawner.pihelper.shared
import androidx.compose.desktop.ui.tooling.preview.Preview
import androidx.compose.runtime.Composable
import com.wbrawner.pihelper.shared.ui.OrDivider
import com.wbrawner.pihelper.shared.ui.theme.PihelperTheme
import io.ktor.client.*
import io.ktor.client.engine.*
import java.math.BigInteger
import java.security.MessageDigest
actual fun String.hash(): String = BigInteger(
1,
MessageDigest.getInstance("SHA-256").digest(this.toByteArray())
).toString(16).padStart(64, '0')
@Composable
@Preview
fun OrDivider_Preview() {
PihelperTheme {
OrDivider()
}
}