feat: Add settings activity

This commit is contained in:
Peter Vacho 2025-01-03 17:15:33 +01:00
parent 49ca0f79a2
commit 5cd48cfed6
Signed by: school
GPG key ID: 8CFC3837052871B4
7 changed files with 372 additions and 4 deletions

View file

@ -3,8 +3,10 @@ package com.p_vacho.neat_calendar
import android.content.Context
import android.content.Intent
import android.util.Log
import com.google.gson.Gson
import com.p_vacho.neat_calendar.activities.ApiUnreachableActivity
import com.p_vacho.neat_calendar.activities.LoginActivity
import com.p_vacho.neat_calendar.api.models.ValidationError
import com.p_vacho.neat_calendar.util.ExceptionSerializer
import com.p_vacho.neat_calendar.util.SerializedException
import retrofit2.HttpException
@ -43,6 +45,19 @@ class GlobalExceptionHandler(
return
}
if (e.code() == 422) {
Log.e("GlobalExceptionHandler", "Caught HTTP 422 Exception")
val errorBody = e.response()?.errorBody()?.string()
val validationError = Gson().fromJson(errorBody, ValidationError::class.java)
Log.e("GlobalExceptionHandler", "Details: $validationError")
// This exception is still unhandled, the above just logs it in a nicer way
// so we can see what exactly went wrong. We still want to propagate the exception
// up to default handler now.
defaultHandler?.uncaughtException(t, e)
return
}
Log.e("GlobalExceptionHandler", "Propgating unhandled exception", e)
defaultHandler?.uncaughtException(t, e)
return

View file

@ -1,13 +1,40 @@
package com.p_vacho.neat_calendar.activities
import android.content.Intent
import android.os.Bundle
import android.widget.Toast
import androidx.activity.enableEdgeToEdge
import androidx.appcompat.app.AppCompatActivity
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import androidx.lifecycle.lifecycleScope
import com.google.android.material.button.MaterialButton
import com.google.android.material.textfield.TextInputEditText
import com.p_vacho.neat_calendar.MyApplication
import com.p_vacho.neat_calendar.R
import com.p_vacho.neat_calendar.api.RetrofitClient
import com.p_vacho.neat_calendar.api.models.PartialUserRequest
import com.p_vacho.neat_calendar.api.models.UserResponse
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import retrofit2.HttpException
class SettingsActivity : AppCompatActivity() {
private lateinit var etBaseUrl: TextInputEditText
private lateinit var etUsername: TextInputEditText
private lateinit var etEmail: TextInputEditText
private lateinit var etNewPassword: TextInputEditText
private lateinit var btnLogout: MaterialButton
private lateinit var btnDeleteAccount: MaterialButton
private lateinit var btnSave: MaterialButton
private lateinit var btnBack: MaterialButton
private lateinit var user: UserResponse
private lateinit var oldBaseUrl: String
private lateinit var oldUsername: String
private lateinit var oldEmail: String
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
@ -17,5 +44,114 @@ class SettingsActivity : AppCompatActivity() {
v.setPadding(systemBars.left, systemBars.top, systemBars.right, systemBars.bottom)
insets
}
etBaseUrl = findViewById(R.id.etBaseUrl)
etUsername = findViewById(R.id.etUsername)
etEmail = findViewById(R.id.etEmail)
etNewPassword = findViewById(R.id.etNewPassword)
btnLogout = findViewById(R.id.btnLogout)
btnDeleteAccount = findViewById(R.id.btnDeleteAccount)
btnSave = findViewById(R.id.btnSave)
btnBack = findViewById(R.id.btnBack)
oldBaseUrl = RetrofitClient.getCurrentBaseUrl()
etBaseUrl.setText(oldBaseUrl)
lifecycleScope.launch {
user = withContext(Dispatchers.IO) {
RetrofitClient.usersService.getCurrentUser()
}
oldEmail = user.email
etEmail.setText(oldEmail)
oldUsername = user.username
etUsername.setText(oldUsername)
}
btnBack.setOnClickListener { finish() }
btnSave.setOnClickListener { onSave() }
btnLogout.setOnClickListener { onLogout() }
btnDeleteAccount.setOnClickListener { onDeleteAccount() }
}
private fun onSave() {
var changed = false
val newBaseUrl = etBaseUrl.text.toString()
if (newBaseUrl != oldBaseUrl) {
RetrofitClient.updateBaseUrl(newBaseUrl)
oldBaseUrl = newBaseUrl
changed = true
}
val newUsername = etUsername.text.toString()
val newEmail = etEmail.text.toString()
val newPassword = etNewPassword.text.toString()
lifecycleScope.launch() {
val req = PartialUserRequest(
username = if (newUsername != oldUsername) newUsername else null,
email = if (newEmail != oldEmail) newEmail else null,
password = if (newPassword.isNotEmpty()) newPassword else null,
)
if (req.username != null || req.email != null || req.password != null) {
try {
val response = withContext(Dispatchers.IO) {
RetrofitClient.usersService.updateUser(user.user_id, req)
}
oldUsername = response.username
oldEmail = response.email
etNewPassword.setText("")
changed = true
} catch (e: HttpException) {
if (e.code() != 409) {
throw e
}
Toast.makeText(this@SettingsActivity, "This username or email is already taken", Toast.LENGTH_SHORT).show()
}
}
if (changed) {
Toast.makeText(this@SettingsActivity, "Changes saved", Toast.LENGTH_SHORT).show()
// Make a ping request, to re-check api connectivity
// TODO: Ping isn't a good enough check here, we should also validate tokens
withContext(Dispatchers.IO) {
RetrofitClient.ping()
}
} else {
Toast.makeText(this@SettingsActivity, "No changes made", Toast.LENGTH_SHORT).show()
}
}
}
private fun onLogout() {
lifecycleScope.launch(Dispatchers.IO) {
(application as MyApplication).authRepository.logout()
navigateToLoginActivity()
}
}
private fun onDeleteAccount() {
// TODO: Add a modal asking for confirmation
lifecycleScope.launch {
withContext(Dispatchers.IO) {
RetrofitClient.usersService.deleteUser(user.user_id)
}
(application as MyApplication).tokenManager.clearTokens()
navigateToLoginActivity()
}
}
private fun navigateToLoginActivity() {
val intent = Intent(this, LoginActivity::class.java)
intent.flags = Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_NEW_TASK
startActivity(intent)
finish()
}
}

View file

@ -7,4 +7,10 @@ data class UserResponse(
val username: String,
val email: String,
val created_at: OffsetDateTime,
)
data class PartialUserRequest(
val username: String?,
val password: String?,
val email: String?,
)

View file

@ -1,13 +1,26 @@
package com.p_vacho.neat_calendar.api.services
import com.p_vacho.neat_calendar.api.models.PartialUserRequest
import com.p_vacho.neat_calendar.api.models.UserResponse
import retrofit2.http.Body
import retrofit2.http.DELETE
import retrofit2.http.GET
import retrofit2.http.PATCH
import retrofit2.http.Path
interface UsersService {
@GET("users")
suspend fun getUsers(): List<UserResponse>
@GET("users/me")
suspend fun getCurrentUser(): UserResponse
@GET("users/{user_id}")
suspend fun getUser(@Path("user_id") userId: String): UserResponse
@DELETE("users/{user_id}")
suspend fun deleteUser(@Path("user_id") userId: String): Unit
@PATCH("users/{user_id}")
suspend fun updateUser(@Path("user_id") userId: String, @Body userData: PartialUserRequest): UserResponse
}

View file

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:pathData="m40,840 l440,-760 440,760L40,840ZM178,760h604L480,240 178,760ZM480,720q17,0 28.5,-11.5T520,680q0,-17 -11.5,-28.5T480,640q-17,0 -28.5,11.5T440,680q0,17 11.5,28.5T480,720ZM440,600h80v-200h-80v200ZM480,500Z"
android:fillColor="#e8eaed"/>
</vector>

View file

@ -1,10 +1,193 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
<ScrollView
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/main"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".activities.SettingsActivity">
android:fillViewport="true"
android:padding="16dp">
</androidx.constraintlayout.widget.ConstraintLayout>
<LinearLayout
android:layout_marginTop="16dp"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<!-- A single card containing all settings sections -->
<com.google.android.material.card.MaterialCardView
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:cardCornerRadius="8dp"
app:cardElevation="4dp"
android:layout_marginBottom="16dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp">
<!-- ============= Section 1: Connection ============= -->
<TextView
android:id="@+id/tvConnectionSectionHeader"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/connection_settings_section"
android:textAppearance="@style/TextAppearance.MaterialComponents.Headline6"
android:paddingTop="4dp"
android:paddingBottom="4dp" />
<!-- Divider for the 'Connection' section -->
<com.google.android.material.divider.MaterialDivider
android:layout_width="match_parent"
android:layout_height="1dp"
android:layout_marginBottom="8dp"
app:dividerColor="@android:color/darker_gray" />
<!-- Base URL field -->
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/tilBaseUrl"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="@string/base_url"
app:boxStrokeWidth="1dp"
app:boxStrokeColor="?attr/colorPrimary">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/etBaseUrl"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
</com.google.android.material.textfield.TextInputLayout>
<!-- ========== Section 2: User Management ========== -->
<TextView
android:id="@+id/tvUserManagementHeader"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/user_settings_section"
android:textAppearance="@style/TextAppearance.MaterialComponents.Headline6"
android:layout_marginTop="16dp"
android:paddingTop="4dp"
android:paddingBottom="4dp" />
<!-- Divider for the 'User Management' section -->
<com.google.android.material.divider.MaterialDivider
android:layout_width="match_parent"
android:layout_height="1dp"
android:layout_marginBottom="8dp"
app:dividerColor="@android:color/darker_gray" />
<!-- Username field -->
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/tilUsername"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="@string/username"
app:boxStrokeWidth="1dp"
app:boxStrokeColor="?attr/colorPrimary"
android:layout_marginTop="4dp">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/etUsername"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
</com.google.android.material.textfield.TextInputLayout>
<!-- Email field -->
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/tilEmail"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="@string/email"
app:boxStrokeWidth="1dp"
app:boxStrokeColor="?attr/colorPrimary"
android:layout_marginTop="4dp">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/etEmail"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="textEmailAddress" />
</com.google.android.material.textfield.TextInputLayout>
<!-- Password field (for changing password) -->
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/tilPassword"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="@string/new_password"
app:boxStrokeWidth="1dp"
app:boxStrokeColor="?attr/colorPrimary"
android:layout_marginTop="4dp">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/etNewPassword"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="textPassword" />
</com.google.android.material.textfield.TextInputLayout>
<!-- Logout Button -->
<com.google.android.material.button.MaterialButton
android:id="@+id/btnLogout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="5dp"
android:text="@string/logout"
app:icon="@drawable/ic_exit"
app:iconGravity="textStart"
app:iconPadding="8dp"
style="@style/Widget.MaterialComponents.Button.OutlinedButton" />
<!-- Account Deletion Button -->
<com.google.android.material.button.MaterialButton
android:id="@+id/btnDeleteAccount"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/delete_account"
android:textColor="?android:attr/textColorPrimary"
android:layout_marginTop="16dp"
app:icon="@drawable/ic_warning"
app:iconGravity="textStart"
app:iconPadding="8dp"
app:iconTint="@android:color/holo_red_dark"
app:strokeColor="@android:color/holo_red_dark"
app:strokeWidth="2dp"
style="@style/Widget.MaterialComponents.Button.OutlinedButton" />
<!-- ========== Section 3: Controls ========== -->
<com.google.android.material.divider.MaterialDivider
android:layout_width="match_parent"
android:layout_height="1dp"
android:layout_marginBottom="16dp"
android:layout_marginTop="8dp"
app:dividerColor="@android:color/darker_gray" />
<!-- Save Settings Button -->
<com.google.android.material.button.MaterialButton
android:id="@+id/btnSave"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/save"
app:cornerRadius="8dp" />
<!-- Go back Button -->
<com.google.android.material.button.MaterialButton
android:id="@+id/btnBack"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="5dp"
android:text="@string/back"
app:icon="@drawable/ic_arrow_back"
app:iconGravity="textStart"
app:iconPadding="8dp"
style="@style/Widget.MaterialComponents.Button.OutlinedButton" />
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
</LinearLayout>
</ScrollView>

View file

@ -32,7 +32,7 @@
<string name="delete_event">Delete this event</string>
<string name="edit_event">Edit this event</string>
<string name="add_event">Add a new event</string>
<string name="back">Go to the previous page</string>
<string name="back">Go back</string>
<string name="event_title">Event Title</string>
<string name="event_description">Event Description</string>
<string name="instant_event">Instant Event</string>
@ -47,4 +47,10 @@
<string name="leave_invited_event">Leave invited event</string>
<string name="open_app_settings">Open Settings</string>
<string name="open_notifications">Open Notifications</string>
<string name="connection_settings_section">Connection</string>
<string name="user_settings_section">User Settings</string>
<string name="new_password">New Password</string>
<string name="delete_account">Delete Account</string>
<string name="controls">Controls</string>
<string name="logout">Log out</string>
</resources>