diff --git a/app/src/main/java/com/p_vacho/neat_calendar/GlobalExceptionHandler.kt b/app/src/main/java/com/p_vacho/neat_calendar/GlobalExceptionHandler.kt index 67b6423..c5f59ac 100644 --- a/app/src/main/java/com/p_vacho/neat_calendar/GlobalExceptionHandler.kt +++ b/app/src/main/java/com/p_vacho/neat_calendar/GlobalExceptionHandler.kt @@ -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 diff --git a/app/src/main/java/com/p_vacho/neat_calendar/activities/SettingsActivity.kt b/app/src/main/java/com/p_vacho/neat_calendar/activities/SettingsActivity.kt index 4c7440d..ceed7b7 100644 --- a/app/src/main/java/com/p_vacho/neat_calendar/activities/SettingsActivity.kt +++ b/app/src/main/java/com/p_vacho/neat_calendar/activities/SettingsActivity.kt @@ -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() } } \ No newline at end of file diff --git a/app/src/main/java/com/p_vacho/neat_calendar/api/models/UserModels.kt b/app/src/main/java/com/p_vacho/neat_calendar/api/models/UserModels.kt index 5956f74..2a8248f 100644 --- a/app/src/main/java/com/p_vacho/neat_calendar/api/models/UserModels.kt +++ b/app/src/main/java/com/p_vacho/neat_calendar/api/models/UserModels.kt @@ -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?, ) \ No newline at end of file diff --git a/app/src/main/java/com/p_vacho/neat_calendar/api/services/UsersService.kt b/app/src/main/java/com/p_vacho/neat_calendar/api/services/UsersService.kt index e5961c8..2084053 100644 --- a/app/src/main/java/com/p_vacho/neat_calendar/api/services/UsersService.kt +++ b/app/src/main/java/com/p_vacho/neat_calendar/api/services/UsersService.kt @@ -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 + @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 } \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_warning.xml b/app/src/main/res/drawable/ic_warning.xml new file mode 100644 index 0000000..fc633b5 --- /dev/null +++ b/app/src/main/res/drawable/ic_warning.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/layout/activity_settings.xml b/app/src/main/res/layout/activity_settings.xml index 87d76b3..fd7d26e 100644 --- a/app/src/main/res/layout/activity_settings.xml +++ b/app/src/main/res/layout/activity_settings.xml @@ -1,10 +1,193 @@ - + android:fillViewport="true" + android:padding="16dp"> - \ No newline at end of file + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index cfae891..c70648e 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -32,7 +32,7 @@ Delete this event Edit this event Add a new event - Go to the previous page + Go back Event Title Event Description Instant Event @@ -47,4 +47,10 @@ Leave invited event Open Settings Open Notifications + Connection + User Settings + New Password + Delete Account + Controls + Log out \ No newline at end of file