Endurain: First try with bottomsheet instead of dialogs

This commit is contained in:
Arjan Schrijver
2026-06-19 23:31:23 +02:00
parent 256d2eb59a
commit f92d8c8ebb
7 changed files with 386 additions and 137 deletions
+1 -1
View File
@@ -260,7 +260,7 @@
android:label="@string/healthconnect_settings" android:label="@string/healthconnect_settings"
android:parentActivityName=".activities.SettingsActivity" /> android:parentActivityName=".activities.SettingsActivity" />
<activity <activity
android:name=".activities.EndurainPreferencesActivity" android:name=".activities.endurain.EndurainPreferencesActivity"
android:label="Endurain" android:label="Endurain"
android:parentActivityName=".activities.SettingsActivity" /> android:parentActivityName=".activities.SettingsActivity" />
<activity <activity
@@ -1,136 +0,0 @@
/* Copyright (C) 2025 Arjan Schrijver
This file is part of Gadgetbridge.
Gadgetbridge is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published
by the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
Gadgetbridge is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>. */
package nodomain.freeyourgadget.gadgetbridge.activities
import android.os.Bundle
import android.widget.LinearLayout
import android.widget.Toast
import androidx.appcompat.app.AlertDialog
import androidx.core.content.edit
import androidx.core.net.toUri
import androidx.lifecycle.lifecycleScope
import androidx.preference.Preference
import androidx.preference.PreferenceFragmentCompat
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.textfield.TextInputEditText
import com.google.android.material.textfield.TextInputLayout
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import nodomain.freeyourgadget.gadgetbridge.GBApplication
import nodomain.freeyourgadget.gadgetbridge.R
import nodomain.freeyourgadget.gadgetbridge.util.GB
import nodomain.freeyourgadget.gadgetbridge.util.InternetUtils
class EndurainPreferencesActivity : AbstractSettingsActivityV2() {
override fun newFragment(): PreferenceFragmentCompat {
return EndurainPreferencesFragment()
}
companion object {
class EndurainPreferencesFragment : AbstractPreferenceFragment() {
override fun onCreatePreferences(
savedInstanceState: Bundle?,
rootKey: String?
) {
setPreferencesFromResource(R.xml.endurain_preferences, rootKey)
val prefs = GBApplication.getPrefs().preferences
// Hide network required warning when network is available
val networkWarning = findPreference<Preference>("pref_key_network_required")
networkWarning?.isVisible = !GBApplication.hasInternetAccess()
// Wire up the login button
val loginPref = findPreference<Preference>("pref_key_log_in")
loginPref?.onPreferenceClickListener = Preference.OnPreferenceClickListener {
val currentServer = prefs.getString("endurain_server", "https://")
val input = TextInputEditText(requireContext()).apply {
setText(currentServer)
layoutParams = LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT,
LinearLayout.LayoutParams.WRAP_CONTENT
)
}
val layout = TextInputLayout(requireContext()).apply {
hint = "Example: https://endurain.example.com"
addView(input)
}
val dialog = MaterialAlertDialogBuilder(requireContext())
.setTitle("Endurain server")
.setView(layout)
.setNegativeButton(R.string.Cancel, null)
.setPositiveButton(R.string.ok, null) // ← no listener yet
.create()
dialog.setOnShowListener {
dialog.getButton(AlertDialog.BUTTON_POSITIVE).setOnClickListener {
val newUrl = input.text.toString().toUri()
val newServer =
if (newUrl.scheme == null || newUrl.host == null) {
null
} else {
"${newUrl.scheme}://${newUrl.host}"
}
prefs.edit {
putString("endurain_server", newServer)
}
dialog.dismiss()
lifecycleScope.launch(Dispatchers.IO) {
doLogin()
}
}
}
dialog.show()
true
}
// TODO: Wire up the logoff button
val logoffPref = findPreference<Preference>("pref_key_log_out")
logoffPref?.isVisible = false
// TODO: Fill the status preference text
val statusPref = findPreference<Preference>("pref_key_status")
statusPref?.summary = "Not logged in, integration is disabled"
}
private fun doLogin() {
val prefs = GBApplication.getPrefs().preferences
val server = prefs.getString("endurain_server", "").orEmpty()
if (server.isBlank()) return
val settingsUri = "${server}/api/v1/public/server_settings".toUri()
val serverSettings = InternetUtils.doJsonRequest(settingsUri)
if (serverSettings == null) {
GB.toast("Endurain server is offline", Toast.LENGTH_LONG, GB.ERROR)
return
}
val localLogin = serverSettings.optBoolean("local_login_enabled")
val ssoLogin = serverSettings.optBoolean("sso_enabled")
GB.toast("Endurain local=$localLogin sso=$ssoLogin", Toast.LENGTH_LONG, GB.INFO)
}
}
}
}
@@ -66,6 +66,7 @@ import nodomain.freeyourgadget.gadgetbridge.R;
import nodomain.freeyourgadget.gadgetbridge.activities.automations.AutomationsSettingsActivity; import nodomain.freeyourgadget.gadgetbridge.activities.automations.AutomationsSettingsActivity;
import nodomain.freeyourgadget.gadgetbridge.activities.charts.ChartsPreferencesActivity; import nodomain.freeyourgadget.gadgetbridge.activities.charts.ChartsPreferencesActivity;
import nodomain.freeyourgadget.gadgetbridge.activities.discovery.DiscoveryPairingPreferenceActivity; import nodomain.freeyourgadget.gadgetbridge.activities.discovery.DiscoveryPairingPreferenceActivity;
import nodomain.freeyourgadget.gadgetbridge.activities.endurain.EndurainPreferencesActivity;
import nodomain.freeyourgadget.gadgetbridge.activities.maps.MapsSettingsActivity; import nodomain.freeyourgadget.gadgetbridge.activities.maps.MapsSettingsActivity;
import nodomain.freeyourgadget.gadgetbridge.activities.preferences.HealthConnectPreferencesActivity; import nodomain.freeyourgadget.gadgetbridge.activities.preferences.HealthConnectPreferencesActivity;
import nodomain.freeyourgadget.gadgetbridge.externalevents.TimeChangeReceiver; import nodomain.freeyourgadget.gadgetbridge.externalevents.TimeChangeReceiver;
@@ -0,0 +1,65 @@
/* Copyright (C) 2026 Arjan Schrijver
This file is part of Gadgetbridge.
Gadgetbridge is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published
by the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
Gadgetbridge is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>. */
package nodomain.freeyourgadget.gadgetbridge.activities.endurain
import android.os.Bundle
import androidx.preference.Preference
import androidx.preference.PreferenceFragmentCompat
import nodomain.freeyourgadget.gadgetbridge.GBApplication
import nodomain.freeyourgadget.gadgetbridge.R
import nodomain.freeyourgadget.gadgetbridge.activities.AbstractPreferenceFragment
import nodomain.freeyourgadget.gadgetbridge.activities.AbstractSettingsActivityV2
class EndurainPreferencesActivity : AbstractSettingsActivityV2() {
override fun newFragment(): PreferenceFragmentCompat =
EndurainPreferencesFragment()
class EndurainPreferencesFragment : AbstractPreferenceFragment() {
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
setPreferencesFromResource(R.xml.endurain_preferences, rootKey)
updateNetworkWarning()
wireLoginPreference()
hideLogoffPreference()
updateStatus()
}
private fun updateNetworkWarning() {
findPreference<Preference>("pref_key_network_required")?.isVisible =
!GBApplication.hasInternetAccess()
}
private fun wireLoginPreference() {
findPreference<Preference>("pref_key_log_in")?.setOnPreferenceClickListener {
EndurainSetupBottomSheet()
.show(parentFragmentManager, "endurain_setup")
true
}
}
private fun hideLogoffPreference() {
findPreference<Preference>("pref_key_log_out")?.isVisible = false
}
private fun updateStatus() {
findPreference<Preference>("pref_key_status")?.summary =
"Not logged in, integration is disabled"
}
}
}
@@ -0,0 +1,146 @@
/* Copyright (C) 2026 Arjan Schrijver
This file is part of Gadgetbridge.
Gadgetbridge is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published
by the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
Gadgetbridge is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>. */
package nodomain.freeyourgadget.gadgetbridge.activities.endurain
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.core.content.edit
import androidx.core.net.toUri
import androidx.fragment.app.viewModels
import com.google.android.material.bottomsheet.BottomSheetDialogFragment
import com.google.android.material.button.MaterialButton
import com.google.android.material.button.MaterialButtonToggleGroup
import com.google.android.material.textfield.TextInputEditText
import com.google.android.material.textfield.TextInputLayout
import nodomain.freeyourgadget.gadgetbridge.GBApplication
import nodomain.freeyourgadget.gadgetbridge.R
class EndurainSetupBottomSheet : BottomSheetDialogFragment() {
private val prefs get() = GBApplication.getPrefs().preferences
private val vm: EndurainSetupViewModel by viewModels()
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View = inflater.inflate(
R.layout.endurain_bottomsheet_setup_wizard,
container,
false
)
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
val serverLayout = view.findViewById<TextInputLayout>(R.id.server_layout)
val serverInput = view.findViewById<TextInputEditText>(R.id.server_input)
val loginTypeGroup =
view.findViewById<MaterialButtonToggleGroup>(R.id.login_type_group)
val localButton = view.findViewById<MaterialButton>(R.id.local_login_button)
val ssoButton = view.findViewById<MaterialButton>(R.id.sso_login_button)
val userLayout = view.findViewById<TextInputLayout>(R.id.user_layout)
val passLayout = view.findViewById<TextInputLayout>(R.id.password_layout)
val userInput = view.findViewById<TextInputEditText>(R.id.user_input)
val passInput = view.findViewById<TextInputEditText>(R.id.password_input)
val progress = view.findViewById<View>(R.id.progress)
val next = view.findViewById<MaterialButton>(R.id.next_button)
serverInput.setText(prefs.getString("endurain_server", ""))
fun showProgress(show: Boolean) {
progress.visibility = if (show) View.VISIBLE else View.GONE
next.isEnabled = !show
}
next.setOnClickListener {
when (vm.step) {
EndurainSetupViewModel.Step.SERVER -> {
val uri = serverInput.text.toString().toUri()
if (uri.scheme == null || uri.host == null) {
serverLayout.error = "Invalid server URL"
return@setOnClickListener
}
serverLayout.error = null
val server = "${uri.scheme}://${uri.host}"
vm.server = server
prefs.edit { putString("endurain_server", server) }
showProgress(true)
vm.fetchServerCapabilities(server) { ok ->
showProgress(false)
if (!ok) return@fetchServerCapabilities
vm.step = EndurainSetupViewModel.Step.LOGIN_TYPE
loginTypeGroup.visibility = View.VISIBLE
localButton.visibility =
if (vm.localLoginEnabled) View.VISIBLE else View.GONE
ssoButton.visibility =
if (vm.ssoEnabled) View.VISIBLE else View.GONE
}
}
EndurainSetupViewModel.Step.LOCAL_LOGIN -> {
val user = userInput.text.toString()
val pass = passInput.text.toString()
if (user.isBlank()) {
userLayout.error = "Required"
return@setOnClickListener
}
if (pass.isBlank()) {
passLayout.error = "Required"
return@setOnClickListener
}
userLayout.error = null
passLayout.error = null
showProgress(true)
vm.performLocalLogin(vm.server, user, pass) { success ->
showProgress(false)
if (success) {
prefs.edit {
putString("endurain_user", user)
putString("endurain_password", pass)
}
dismiss()
}
}
}
else -> {}
}
}
loginTypeGroup.addOnButtonCheckedListener { _, checkedId, isChecked ->
if (!isChecked) return@addOnButtonCheckedListener
when (checkedId) {
R.id.local_login_button -> {
vm.step = EndurainSetupViewModel.Step.LOCAL_LOGIN
userLayout.visibility = View.VISIBLE
passLayout.visibility = View.VISIBLE
}
R.id.sso_login_button -> {
vm.step = EndurainSetupViewModel.Step.SSO_LOGIN
// TODO launch SSO flow
dismiss()
}
}
}
}
}
@@ -0,0 +1,69 @@
/* Copyright (C) 2026 Arjan Schrijver
This file is part of Gadgetbridge.
Gadgetbridge is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published
by the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
Gadgetbridge is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>. */
package nodomain.freeyourgadget.gadgetbridge.activities.endurain
import androidx.core.net.toUri
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import nodomain.freeyourgadget.gadgetbridge.util.InternetUtils
import org.json.JSONObject
class EndurainSetupViewModel : ViewModel() {
enum class Step { SERVER, LOGIN_TYPE, LOCAL_LOGIN, SSO_LOGIN, DONE }
var step: Step = Step.SERVER
var server: String = ""
var localLoginEnabled: Boolean = false
var ssoEnabled: Boolean = false
fun fetchServerCapabilities(
server: String,
onResult: (Boolean) -> Unit
) {
viewModelScope.launch {
val ok = withContext(Dispatchers.IO) {
val uri = "$server/api/v1/public/server_settings"
val json: JSONObject? =
InternetUtils.doJsonRequest(uri.toUri())
if (json == null) return@withContext false
localLoginEnabled = json.optBoolean("local_login_enabled")
ssoEnabled = json.optBoolean("sso_enabled")
true
}
onResult(ok)
}
}
fun performLocalLogin(
server: String,
user: String,
pass: String,
onResult: (Boolean) -> Unit
) {
viewModelScope.launch {
val success = withContext(Dispatchers.IO) {
// TODO real API call
user.isNotBlank() && pass.isNotBlank()
}
onResult(success)
}
}
}
@@ -0,0 +1,104 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:id="@+id/root"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp">
<TextView
android:id="@+id/title"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Endurain setup"
android:textAppearance="?attr/textAppearanceHeadlineSmall" />
<!-- SERVER -->
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/server_layout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="Endurain server">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/server_input"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="textUri" />
</com.google.android.material.textfield.TextInputLayout>
<!-- LOGIN TYPE -->
<com.google.android.material.button.MaterialButtonToggleGroup
android:id="@+id/login_type_group"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:visibility="gone"
app:singleSelection="true">
<com.google.android.material.button.MaterialButton
android:id="@+id/local_login_button"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="Local" />
<com.google.android.material.button.MaterialButton
android:id="@+id/sso_login_button"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="SSO" />
</com.google.android.material.button.MaterialButtonToggleGroup>
<!-- LOCAL LOGIN -->
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/user_layout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="@string/username"
android:visibility="gone">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/user_input"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/password_layout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="@string/prefs_password"
android:visibility="gone">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/password_input"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="textPassword" />
</com.google.android.material.textfield.TextInputLayout>
<ProgressBar
android:id="@+id/progress"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:visibility="gone" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="end"
android:orientation="horizontal"
android:paddingTop="12dp">
<com.google.android.material.button.MaterialButton
android:id="@+id/next_button"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Next" />
</LinearLayout>
</LinearLayout>