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.Context
import android.content.Intent import android.content.Intent
import android.util.Log import android.util.Log
import com.google.gson.Gson
import com.p_vacho.neat_calendar.activities.ApiUnreachableActivity import com.p_vacho.neat_calendar.activities.ApiUnreachableActivity
import com.p_vacho.neat_calendar.activities.LoginActivity 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.ExceptionSerializer
import com.p_vacho.neat_calendar.util.SerializedException import com.p_vacho.neat_calendar.util.SerializedException
import retrofit2.HttpException import retrofit2.HttpException
@ -43,6 +45,19 @@ class GlobalExceptionHandler(
return 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) Log.e("GlobalExceptionHandler", "Propgating unhandled exception", e)
defaultHandler?.uncaughtException(t, e) defaultHandler?.uncaughtException(t, e)
return return

View file

@ -1,13 +1,40 @@
package com.p_vacho.neat_calendar.activities package com.p_vacho.neat_calendar.activities
import android.content.Intent
import android.os.Bundle import android.os.Bundle
import android.widget.Toast
import androidx.activity.enableEdgeToEdge import androidx.activity.enableEdgeToEdge
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.core.view.ViewCompat import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat 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.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() { 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?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
enableEdgeToEdge() enableEdgeToEdge()
@ -17,5 +44,114 @@ class SettingsActivity : AppCompatActivity() {
v.setPadding(systemBars.left, systemBars.top, systemBars.right, systemBars.bottom) v.setPadding(systemBars.left, systemBars.top, systemBars.right, systemBars.bottom)
insets 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 username: String,
val email: String, val email: String,
val created_at: OffsetDateTime, 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 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 com.p_vacho.neat_calendar.api.models.UserResponse
import retrofit2.http.Body
import retrofit2.http.DELETE
import retrofit2.http.GET import retrofit2.http.GET
import retrofit2.http.PATCH
import retrofit2.http.Path import retrofit2.http.Path
interface UsersService { interface UsersService {
@GET("users") @GET("users")
suspend fun getUsers(): List<UserResponse> suspend fun getUsers(): List<UserResponse>
@GET("users/me")
suspend fun getCurrentUser(): UserResponse
@GET("users/{user_id}") @GET("users/{user_id}")
suspend fun getUser(@Path("user_id") userId: String): UserResponse 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"?> <?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:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools" xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/main" android:id="@+id/main"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="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="delete_event">Delete this event</string>
<string name="edit_event">Edit this event</string> <string name="edit_event">Edit this event</string>
<string name="add_event">Add a new 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_title">Event Title</string>
<string name="event_description">Event Description</string> <string name="event_description">Event Description</string>
<string name="instant_event">Instant Event</string> <string name="instant_event">Instant Event</string>
@ -47,4 +47,10 @@
<string name="leave_invited_event">Leave invited event</string> <string name="leave_invited_event">Leave invited event</string>
<string name="open_app_settings">Open Settings</string> <string name="open_app_settings">Open Settings</string>
<string name="open_notifications">Open Notifications</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> </resources>