Skip to content

Commit cac3c9f

Browse files
committed
Add self-hosted support.
1 parent 67ba5b5 commit cac3c9f

File tree

10 files changed

+315
-23
lines changed

10 files changed

+315
-23
lines changed

Habitica/res/xml/network_security_config.xml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,5 +7,6 @@
77
</debug-overrides>
88
<domain-config cleartextTrafficPermitted="true">
99
<domain includeSubdomains="true">10.0.0.107</domain>
10+
<domain includeSubdomains="true">localhost</domain>
1011
</domain-config>
11-
</network-security-config>
12+
</network-security-config>

Habitica/res/xml/preferences_fragment.xml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -408,4 +408,9 @@
408408
android:entryValues="@array/server_urls"
409409
android:layout="@layout/preference_child_summary" />
410410
</PreferenceCategory>
411+
412+
<PreferenceCategory
413+
android:key="custom_server"
414+
android:layout="@layout/preference_category"
415+
app:isPreferenceVisible="false"/>
411416
</PreferenceScreen>

Habitica/src/main/java/com/habitrpg/android/habitica/ui/fragments/preferences/PreferencesFragment.kt

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,8 @@ class PreferencesFragment :
7171
private var pushNotificationsPreference: PreferenceScreen? = null
7272
private var emailNotificationsPreference: PreferenceScreen? = null
7373
private var classSelectionPreference: Preference? = null
74-
private var serverUrlPreference: ListPreference? = null
74+
private var customServerUrlDebugPreference: ListPreference? = null
75+
private var customServerUrlReleaseCategory: PreferenceCategory? = null
7576
private var taskListPreference: ListPreference? = null
7677

7778
private val classSelectionResult =
@@ -104,11 +105,13 @@ class PreferencesFragment :
104105
val weekdayPreference = findPreference("FirstDayOfTheWeek") as? ListPreference
105106
weekdayPreference?.summary = weekdayPreference.entry
106107

107-
serverUrlPreference = findPreference("server_url") as? ListPreference
108-
serverUrlPreference?.isVisible = false
109-
serverUrlPreference?.summary =
110-
preferenceManager.sharedPreferences?.getString("server_url", "")
111-
108+
val serverUrl = preferenceManager.sharedPreferences?.getString("server_url", "")
109+
customServerUrlDebugPreference = findPreference("server_url") as? ListPreference
110+
customServerUrlDebugPreference?.isVisible = false
111+
customServerUrlDebugPreference?.summary = serverUrl
112+
customServerUrlReleaseCategory = findPreference("custom_server")
113+
customServerUrlReleaseCategory?.title = serverUrl
114+
112115
val themePreference = findPreference("theme_name") as? ListPreference
113116
themePreference?.summary = themePreference.entry ?: "Default"
114117
val themeModePreference = findPreference("theme_mode") as? ListPreference
@@ -594,8 +597,11 @@ class PreferencesFragment :
594597
}
595598

596599
if (configManager.testingLevel() == AppTestingLevel.STAFF || BuildConfig.DEBUG) {
597-
serverUrlPreference?.isVisible = true
600+
customServerUrlDebugPreference?.isVisible = true
598601
taskListPreference?.isVisible = true
599602
}
603+
if (BuildConfig.DEBUG.not()) {
604+
customServerUrlReleaseCategory?.isVisible = customServerUrlReleaseCategory?.title.isNullOrEmpty().not()
605+
}
600606
}
601607
}

Habitica/src/main/java/com/habitrpg/android/habitica/ui/viewmodels/AuthenticationViewModel.kt

Lines changed: 26 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
package com.habitrpg.android.habitica.ui.viewmodels
22

3-
43
import android.content.Context
54
import android.content.SharedPreferences
65
import android.util.Log
@@ -33,15 +32,13 @@ import com.habitrpg.android.habitica.helpers.HitType
3332
import com.habitrpg.android.habitica.models.user.User
3433
import com.habitrpg.android.habitica.modules.AuthenticationHandler
3534
import com.habitrpg.common.habitica.api.HostConfig
36-
import com.habitrpg.common.habitica.helpers.ExceptionHandler
35+
import com.habitrpg.common.habitica.api.ServerSettings
3736
import com.habitrpg.common.habitica.helpers.KeyHelper
38-
import com.habitrpg.common.habitica.helpers.launchCatching
3937
import com.habitrpg.common.habitica.models.auth.UserAuthResponse
4038
import dagger.hilt.android.lifecycle.HiltViewModel
4139
import kotlinx.coroutines.flow.Flow
4240
import kotlinx.coroutines.flow.MutableSharedFlow
4341
import kotlinx.coroutines.flow.MutableStateFlow
44-
import kotlinx.coroutines.flow.filter
4542
import kotlinx.coroutines.flow.filterNotNull
4643
import kotlinx.coroutines.flow.onEach
4744
import kotlinx.coroutines.launch
@@ -69,6 +66,7 @@ class AuthenticationViewModel @Inject constructor(
6966
private val _authenticationSuccess = MutableStateFlow<Boolean?>(null)
7067
private val _isUsernameValid = MutableStateFlow<Boolean?>(null)
7168
private var _usernameIssues = MutableStateFlow<String?>(null)
69+
private val _showServerSettingsDialog: MutableStateFlow<ServerSettings?> = MutableStateFlow(null)
7270

7371
val showAuthProgress: Flow<Boolean> = _showAuthProgress
7472
val authenticationError: Flow<AuthenticationErrors> = _authenticationError
@@ -78,6 +76,7 @@ class AuthenticationViewModel @Inject constructor(
7876
.onEach { _showAuthProgress.value = false }
7977
val isUsernameValid: Flow<Boolean?> = _isUsernameValid
8078
val usernameIssues: Flow<String?> = _usernameIssues
79+
val showServerSettingsDialog: Flow<ServerSettings?> = _showServerSettingsDialog
8180

8281
fun clearAuthenticationState() {
8382
_showAuthProgress.value = false
@@ -341,4 +340,27 @@ class AuthenticationViewModel @Inject constructor(
341340
_isUsernameValid.value = null
342341
}
343342
}
343+
344+
fun onServerSettingsUnlocked() {
345+
_showServerSettingsDialog.value = ServerSettings(
346+
baseUrl = BuildConfig.BASE_URL,
347+
customUrl = sharedPrefs.getString("server_url", null)
348+
)
349+
}
350+
351+
fun onServerSettingsChanged(newUrl: String) {
352+
sharedPrefs.edit { putString("server_url", newUrl) }
353+
apiClient.updateServerUrl(newAddress = newUrl)
354+
onServerSettingsDismissed()
355+
}
356+
357+
fun onServerSettingsReset(baseUrl: String) {
358+
sharedPrefs.edit { remove("server_url") }
359+
apiClient.updateServerUrl(newAddress = baseUrl)
360+
onServerSettingsDismissed()
361+
}
362+
363+
fun onServerSettingsDismissed() {
364+
_showServerSettingsDialog.value = null
365+
}
344366
}

Habitica/src/main/java/com/habitrpg/android/habitica/ui/views/login/LoginScreen.kt

Lines changed: 37 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package com.habitrpg.android.habitica.ui.views.login
22

33
import android.util.Patterns
4+
import android.widget.Toast
45
import androidx.compose.animation.AnimatedVisibility
56
import androidx.compose.animation.core.EaseInOut
67
import androidx.compose.animation.core.animateDpAsState
@@ -22,7 +23,6 @@ import androidx.compose.foundation.layout.fillMaxWidth
2223
import androidx.compose.foundation.layout.height
2324
import androidx.compose.foundation.layout.padding
2425
import androidx.compose.foundation.layout.systemBars
25-
import androidx.compose.foundation.layout.width
2626
import androidx.compose.material3.Button
2727
import androidx.compose.material3.ButtonDefaults
2828
import androidx.compose.material3.ProvideTextStyle
@@ -51,10 +51,13 @@ import androidx.compose.ui.text.style.TextAlign
5151
import androidx.compose.ui.unit.dp
5252
import androidx.compose.ui.unit.sp
5353
import androidx.compose.ui.viewinterop.AndroidView
54+
import androidx.lifecycle.compose.collectAsStateWithLifecycle
5455
import com.habitrpg.android.habitica.R
5556
import com.habitrpg.android.habitica.ui.viewmodels.AuthenticationViewModel
5657
import com.habitrpg.android.habitica.ui.views.LoginFieldState
58+
import com.habitrpg.common.habitica.api.ServerSettings
5759
import com.habitrpg.common.habitica.extensions.layoutInflater
60+
import com.habitrpg.common.habitica.helpers.SequentialClickBox
5861
import com.habitrpg.common.habitica.helpers.launchCatching
5962

6063
enum class LoginScreenState {
@@ -161,12 +164,29 @@ fun LoginScreen(authenticationViewModel: AuthenticationViewModel, useNewAuthFlow
161164
horizontalAlignment = Alignment.CenterHorizontally,
162165
modifier = Modifier.fillMaxWidth().padding(WindowInsets.systemBars.asPaddingValues()).padding(horizontal = 20.dp)
163166
) {
164-
Image(
165-
painterResource(R.drawable.login_logo),
166-
contentScale = ContentScale.Fit,
167-
contentDescription = null,
168-
modifier = Modifier.padding(top = logoPadding).scale(logoScale)
169-
)
167+
var toast: Toast? by remember { mutableStateOf(null) }
168+
fun showToast(message: String) {
169+
toast?.cancel()
170+
toast = Toast.makeText(context, message, Toast.LENGTH_SHORT).apply { show() }
171+
}
172+
SequentialClickBox(
173+
onTrigger = {
174+
showToast("Server settings unlocked!")
175+
authenticationViewModel.onServerSettingsUnlocked()
176+
},
177+
onRemainingClicks = { remainingClicks ->
178+
if (remainingClicks > 0) {
179+
showToast("You are $remainingClicks steps away from unlocking server settings.")
180+
}
181+
},
182+
) { modifier ->
183+
Image(
184+
painterResource(R.drawable.login_logo),
185+
contentScale = ContentScale.Fit,
186+
contentDescription = null,
187+
modifier = modifier.padding(top = logoPadding).scale(logoScale)
188+
)
189+
}
170190
AnimatedVisibility(
171191
loginScreenState == LoginScreenState.INITIAL,
172192
enter = fadeIn(tween(300, 500)) + expandVertically(tween(300, 500)),
@@ -263,4 +283,14 @@ fun LoginScreen(authenticationViewModel: AuthenticationViewModel, useNewAuthFlow
263283
}
264284
}
265285
}
286+
val showServerSettingDialog: ServerSettings? by authenticationViewModel.showServerSettingsDialog.collectAsStateWithLifecycle(null)
287+
showServerSettingDialog?.let { serverSettings ->
288+
val baseUrl = context.getString(com.habitrpg.common.habitica.R.string.base_url)
289+
ServerSettingsDialog(
290+
serverSettings = serverSettings.copy(baseUrl),
291+
onApply = authenticationViewModel::onServerSettingsChanged,
292+
onReset = { authenticationViewModel.onServerSettingsReset(baseUrl) },
293+
onDismissRequest = authenticationViewModel::onServerSettingsDismissed,
294+
)
295+
}
266296
}
Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
1+
package com.habitrpg.android.habitica.ui.views.login
2+
3+
import android.content.res.Configuration
4+
import androidx.compose.foundation.layout.Arrangement
5+
import androidx.compose.foundation.layout.Column
6+
import androidx.compose.foundation.layout.Row
7+
import androidx.compose.foundation.layout.Spacer
8+
import androidx.compose.foundation.layout.fillMaxWidth
9+
import androidx.compose.foundation.layout.height
10+
import androidx.compose.foundation.layout.padding
11+
import androidx.compose.foundation.layout.width
12+
import androidx.compose.foundation.selection.selectable
13+
import androidx.compose.foundation.selection.selectableGroup
14+
import androidx.compose.material3.Card
15+
import androidx.compose.material3.CardDefaults
16+
import androidx.compose.material3.MaterialTheme
17+
import androidx.compose.material3.OutlinedTextField
18+
import androidx.compose.material3.RadioButton
19+
import androidx.compose.material3.Text
20+
import androidx.compose.material3.TextButton
21+
import androidx.compose.runtime.Composable
22+
import androidx.compose.runtime.getValue
23+
import androidx.compose.runtime.mutableStateOf
24+
import androidx.compose.runtime.remember
25+
import androidx.compose.runtime.setValue
26+
import androidx.compose.ui.Alignment
27+
import androidx.compose.ui.Modifier
28+
import androidx.compose.ui.semantics.Role
29+
import androidx.compose.ui.tooling.preview.Preview
30+
import androidx.compose.ui.unit.dp
31+
import androidx.compose.ui.unit.sp
32+
import androidx.compose.ui.window.Dialog
33+
import androidx.compose.ui.window.DialogProperties
34+
import com.habitrpg.android.habitica.ui.theme.colors
35+
import com.habitrpg.common.habitica.api.ServerSettings
36+
import com.habitrpg.common.habitica.theme.HabiticaTheme
37+
38+
@Composable
39+
fun ServerSettingsDialog(
40+
serverSettings: ServerSettings,
41+
onApply: (String) -> Unit,
42+
onReset: () -> Unit,
43+
onDismissRequest: () -> Unit,
44+
) {
45+
Dialog(
46+
onDismissRequest = onDismissRequest,
47+
properties = DialogProperties(
48+
dismissOnClickOutside = false,
49+
),
50+
) {
51+
Card(
52+
modifier = Modifier.fillMaxWidth(),
53+
colors = CardDefaults.cardColors(
54+
containerColor = HabiticaTheme.colors.windowBackground,
55+
contentColor = MaterialTheme.colorScheme.onSurface,
56+
)
57+
) {
58+
Column(
59+
modifier = Modifier
60+
.fillMaxWidth()
61+
.padding(horizontal = 24.dp)
62+
.padding(top = 24.dp, bottom = 16.dp),
63+
) {
64+
Text(
65+
text = "Server",
66+
fontSize = 24.sp,
67+
lineHeight = 32.sp,
68+
)
69+
val (baseUrl, customUrl) = serverSettings
70+
val radioOptions = listOf("$baseUrl (Base URL)", "Custom")
71+
val (selectedOption, onOptionSelected) = remember {
72+
mutableStateOf(
73+
if (customUrl.isNullOrEmpty()) radioOptions.first() else radioOptions.last()
74+
)
75+
}
76+
Column(
77+
modifier = Modifier
78+
.padding(top = 16.dp)
79+
.selectableGroup()
80+
) {
81+
radioOptions.forEach { option ->
82+
Row(
83+
modifier = Modifier
84+
.fillMaxWidth()
85+
.height(56.dp)
86+
.selectable(
87+
selected = (option == selectedOption),
88+
onClick = { onOptionSelected(option) },
89+
role = Role.RadioButton
90+
),
91+
verticalAlignment = Alignment.CenterVertically
92+
) {
93+
RadioButton(
94+
selected = (option == selectedOption),
95+
onClick = null,
96+
)
97+
Text(
98+
text = option,
99+
style = MaterialTheme.typography.bodyLarge,
100+
modifier = Modifier.padding(start = 16.dp)
101+
)
102+
}
103+
}
104+
}
105+
var input by remember { mutableStateOf(customUrl ?: "") }
106+
OutlinedTextField(
107+
value = input,
108+
onValueChange = { input = it },
109+
label = { Text("Enter custom server address") },
110+
supportingText = { Text("e.g. http://localhost:3000") },
111+
enabled = selectedOption == radioOptions.last(),
112+
)
113+
Row(
114+
modifier = Modifier
115+
.fillMaxWidth()
116+
.padding(top = 16.dp),
117+
horizontalArrangement = Arrangement.End,
118+
) {
119+
TextButton(
120+
onClick = onDismissRequest,
121+
) {
122+
Text("Cancel")
123+
}
124+
Spacer(modifier = Modifier.width(8.dp))
125+
val applyEnabled = if (customUrl == null) {
126+
selectedOption == radioOptions.last() && input.isNotBlank()
127+
} else {
128+
selectedOption == radioOptions.first() || input != customUrl
129+
}
130+
TextButton(
131+
onClick = {
132+
if (selectedOption == radioOptions.last()) {
133+
onApply(input)
134+
} else {
135+
onReset()
136+
}
137+
},
138+
enabled = applyEnabled,
139+
) {
140+
Text("Apply")
141+
}
142+
}
143+
}
144+
}
145+
}
146+
}
147+
148+
@Preview
149+
@Preview(
150+
name = "Night", uiMode = Configuration.UI_MODE_NIGHT_YES or Configuration.UI_MODE_TYPE_NORMAL
151+
)
152+
@Composable
153+
private fun ServerSettingsDialogPreview() {
154+
HabiticaTheme {
155+
ServerSettingsDialog(
156+
serverSettings = ServerSettings(
157+
baseUrl = "https://habitica.com/",
158+
customUrl = null
159+
),
160+
onApply = {},
161+
onReset = {},
162+
onDismissRequest = {},
163+
)
164+
}
165+
}
166+
167+
@Preview
168+
@Preview(
169+
name = "Night", uiMode = Configuration.UI_MODE_NIGHT_YES or Configuration.UI_MODE_TYPE_NORMAL
170+
)
171+
@Composable
172+
private fun ServerSettingsDialogPreviewCustomUrl() {
173+
HabiticaTheme {
174+
ServerSettingsDialog(
175+
serverSettings = ServerSettings(
176+
baseUrl = "https://habitica.com/",
177+
customUrl = "localhost:3000",
178+
),
179+
onApply = {},
180+
onReset = {},
181+
onDismissRequest = {},
182+
)
183+
}
184+
}

0 commit comments

Comments
 (0)