diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index eece14c607..bf910edcb3 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -260,7 +260,7 @@
android:label="@string/healthconnect_settings"
android:parentActivityName=".activities.SettingsActivity" />
. */
-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("pref_key_network_required")
- networkWarning?.isVisible = !GBApplication.hasInternetAccess()
-
- // Wire up the login button
- val loginPref = findPreference("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("pref_key_log_out")
- logoffPref?.isVisible = false
-
- // TODO: Fill the status preference text
- val statusPref = findPreference("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)
- }
-
- }
- }
-}
\ No newline at end of file
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/SettingsActivity.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/SettingsActivity.java
index 852230a75f..10c9dbd07e 100644
--- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/SettingsActivity.java
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/SettingsActivity.java
@@ -66,6 +66,7 @@ import nodomain.freeyourgadget.gadgetbridge.R;
import nodomain.freeyourgadget.gadgetbridge.activities.automations.AutomationsSettingsActivity;
import nodomain.freeyourgadget.gadgetbridge.activities.charts.ChartsPreferencesActivity;
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.preferences.HealthConnectPreferencesActivity;
import nodomain.freeyourgadget.gadgetbridge.externalevents.TimeChangeReceiver;
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/endurain/EndurainPreferencesActivity.kt b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/endurain/EndurainPreferencesActivity.kt
new file mode 100644
index 0000000000..2162142fdc
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/endurain/EndurainPreferencesActivity.kt
@@ -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 . */
+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("pref_key_network_required")?.isVisible =
+ !GBApplication.hasInternetAccess()
+ }
+
+ private fun wireLoginPreference() {
+ findPreference("pref_key_log_in")?.setOnPreferenceClickListener {
+ EndurainSetupBottomSheet()
+ .show(parentFragmentManager, "endurain_setup")
+ true
+ }
+ }
+
+ private fun hideLogoffPreference() {
+ findPreference("pref_key_log_out")?.isVisible = false
+ }
+
+ private fun updateStatus() {
+ findPreference("pref_key_status")?.summary =
+ "Not logged in, integration is disabled"
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/endurain/EndurainSetupBottomSheet.kt b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/endurain/EndurainSetupBottomSheet.kt
new file mode 100644
index 0000000000..9c66d34708
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/endurain/EndurainSetupBottomSheet.kt
@@ -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 . */
+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(R.id.server_layout)
+ val serverInput = view.findViewById(R.id.server_input)
+
+ val loginTypeGroup =
+ view.findViewById(R.id.login_type_group)
+ val localButton = view.findViewById(R.id.local_login_button)
+ val ssoButton = view.findViewById(R.id.sso_login_button)
+
+ val userLayout = view.findViewById(R.id.user_layout)
+ val passLayout = view.findViewById(R.id.password_layout)
+ val userInput = view.findViewById(R.id.user_input)
+ val passInput = view.findViewById(R.id.password_input)
+
+ val progress = view.findViewById(R.id.progress)
+ val next = view.findViewById(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()
+ }
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/endurain/EndurainSetupViewModel.kt b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/endurain/EndurainSetupViewModel.kt
new file mode 100644
index 0000000000..8d986f1656
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/endurain/EndurainSetupViewModel.kt
@@ -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 . */
+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)
+ }
+ }
+}
diff --git a/app/src/main/res/layout/endurain_bottomsheet_setup_wizard.xml b/app/src/main/res/layout/endurain_bottomsheet_setup_wizard.xml
new file mode 100644
index 0000000000..76b8358f7a
--- /dev/null
+++ b/app/src/main/res/layout/endurain_bottomsheet_setup_wizard.xml
@@ -0,0 +1,104 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file