Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -322,7 +322,7 @@ emulator on API 24+.
./gradlew testDebugUnitTest
```

114 JVM tests, no emulator and no network. They cover the layers where a bug would be
129 JVM tests, no emulator and no network. They cover the layers where a bug would be
invisible rather than loud: the scoring arithmetic, the decision logic in every
ViewModel, the list diffing that decides which rows get redrawn, and — for the feature
the app is built around — what the screen actually shows.
Expand All @@ -336,6 +336,8 @@ the app is built around — what the screen actually shows.
| `EditNoteViewModelTest` | Ownership and session handling on load and save. |
| `ProfileViewModelTest` | Total points summed from the user's own notes, and note deletion. |
| `LoginViewModelTest`, `RegisterViewModelTest` | State transitions, and which of registration's two steps — auth account or profile document — failed. |
| `AuthErrorMessagesTest` | That each Firebase auth error code reaches its own Turkish message rather than the catch-all. The mapping used to match on Firebase's English error *sentence*; those strings are not API, they changed, and every failure quietly collapsed into "Tekrar deneyin." with no test to notice. `ERROR_INVALID_CREDENTIAL` is the sharp case — with email enumeration protection on, it is what both a wrong password and an unknown account now return. |
| `PasswordToggleTest` | That the password field starts masked and its toggle icon reports state, not action: unchecked (crossed-out eye) while hidden, checked (open eye) while visible. The icon is a custom selector wired through `endIconDrawable`, so dropping that one attribute leaves a working button with the inverted icon — invisible to every other test. |
| `LeaderboardViewModelTest` | Empty vs. content, and the department scoping: which department the ranking is asked for, and the three cases — no session, no profile, blank department — where the query must not be built at all. |
| `MainViewModelTest`, `UploaderNameTest` | Session routing, and the uploader-name masking shared by three screens. |
| `PostAdapterTest`, `LeaderboardAdapterTest` | Which rows a `DiffUtil` pass marks as changed. The leaderboard case is the sharp one: a note's medal depends on its rank, so a note that swapped places without changing must still be rebound, or the gold medal stays on the row it left. |
Expand Down Expand Up @@ -377,7 +379,7 @@ test through a `FragmentFactory`, which is what lets the existing repository fak
a real screen.

The inverted-gate mutation above is not hypothetical: applying it turns five of these
six tests red and leaves all 96 other tests green.
six tests red and leaves every other test in the suite green.

### Rules tests

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
package duygu.yilmaz.campusnote.ui.auth

import androidx.annotation.StringRes
import com.google.firebase.FirebaseNetworkException
import com.google.firebase.auth.FirebaseAuthException
import duygu.yilmaz.campusnote.R

/**
* Giriş ve kayıt hatalarını kullanıcıya gösterilecek Türkçe metne çevirir.
*
* Eskiden bu eşleme Firebase'in İngilizce hata *cümlesinde* `contains` araması
* yapıyordu ("password is invalid", "no user record"). O cümleler API'nin bir parçası
* değil; SDK sürümleriyle değiştiler ve eşleşme sessizce bozuldu — her hata "Tekrar
* deneyin."e düştü, yani kullanıcı şifresini mi yanlış yazdığını yoksa internetinin mi
* gittiğini anlayamadı. [FirebaseAuthException.errorCode] ise sabit bir sözleşme.
*/
enum class AuthAction { SIGN_IN, REGISTER }

fun authErrorMessage(exception: Throwable, action: AuthAction): Int = when (exception) {
is FirebaseNetworkException -> R.string.error_network
is FirebaseAuthException -> messageForAuthErrorCode(exception.errorCode, action)
else -> messageForAuthErrorCode(null, action)
}

/**
* Kodu metne çeviren saf fonksiyon — Firebase tipine ihtiyaç duymadığı için JVM
* testinden doğrudan çağrılabiliyor.
*
* Bilinmeyen kod, eylemin kendi genel mesajına düşer: kayıt ile giriş aynı cümleyi
* paylaşamaz, "Giriş başarısız" kayıt ekranında yanlış olur.
*/
@StringRes
fun messageForAuthErrorCode(errorCode: String?, action: AuthAction): Int = when (errorCode) {
"ERROR_INVALID_EMAIL" -> R.string.error_invalid_email_format
"ERROR_EMAIL_ALREADY_IN_USE" -> R.string.register_error_email_in_use
"ERROR_WEAK_PASSWORD" -> R.string.register_error_weak_password
"ERROR_USER_NOT_FOUND" -> R.string.login_error_user_not_found
"ERROR_WRONG_PASSWORD" -> R.string.login_error_wrong_password
// Güncel Firebase, yanlış şifre ile olmayan hesabı bu tek kodda birleştiriyor.
"ERROR_INVALID_CREDENTIAL" -> R.string.login_error_invalid_credentials
"ERROR_USER_DISABLED" -> R.string.error_user_disabled
"ERROR_TOO_MANY_REQUESTS" -> R.string.error_too_many_requests
"ERROR_OPERATION_NOT_ALLOWED" -> R.string.error_operation_not_allowed
"ERROR_NETWORK_REQUEST_FAILED" -> R.string.error_network
else -> when (action) {
AuthAction.SIGN_IN -> R.string.login_error_generic
AuthAction.REGISTER -> R.string.register_error_generic
}
}
36 changes: 14 additions & 22 deletions app/src/main/java/duygu/yilmaz/campusnote/ui/auth/LoginActivity.kt
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
package duygu.yilmaz.campusnote.ui.auth

import android.content.Intent
import android.content.SharedPreferences
import android.os.Bundle
import android.view.View
import android.widget.Toast
Expand All @@ -16,21 +15,28 @@ class LoginActivity : AppCompatActivity() {
private val viewModel: LoginViewModel by viewModels()
private lateinit var binding: ActivityLoginBinding

/**
* Oturum, kullanıcı Profil'den çıkış yapana kadar açık kalır.
*
* Eskiden burada bir "Oturumu açık tut" kutusu vardı ve atlama yalnızca kutu
* işaretliyse yapılıyordu. Firebase oturumu zaten diske yazdığı için kutu
* işaretsizken kullanıcı hâlâ giriş yapmış oluyordu — uygulama sadece bunu yok
* sayıp formu gösteriyordu. Sonuç: uygulamadan yanlışlıkla çıkan biri her
* açılışta şifresini yeniden yazmak zorunda kalıyordu, üstelik "çıkış yapmış"
* da değildi. Kutu kaldırıldı; çıkış tek yerden, Profil'deki düğmeden yapılıyor.
*/
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)

val prefs = getSharedPreferences(PREFS_NAME, MODE_PRIVATE)

val rememberMe = prefs.getBoolean(KEY_REMEMBER_ME, false)
if (rememberMe && viewModel.hasAuthenticatedUser()) {
if (viewModel.hasAuthenticatedUser()) {
goToMainActivity()
return
}

binding = ActivityLoginBinding.inflate(layoutInflater)
setContentView(binding.root)

observeAuthState(prefs)
observeAuthState()
setupClickListeners()
animateViews()
}
Expand All @@ -50,14 +56,13 @@ class LoginActivity : AppCompatActivity() {
}
}

private fun observeAuthState(prefs: SharedPreferences) {
private fun observeAuthState() {
viewModel.uiState.observe(this) { state ->
when (state) {
AuthUiState.Idle -> showLoading(false)
AuthUiState.Loading -> showLoading(true)
AuthUiState.Success -> {
viewModel.resetState()
prefs.edit().putBoolean(KEY_REMEMBER_ME, binding.cbRememberMe.isChecked).apply()
showLoading(false)
Toast.makeText(this, R.string.login_welcome, Toast.LENGTH_SHORT).show()
goToMainActivity()
Expand Down Expand Up @@ -155,18 +160,7 @@ class LoginActivity : AppCompatActivity() {

/** Firebase hata metinleri yerelleştirilmemiş geliyor; kullanıcıya Türkçesini gösteriyoruz. */
private fun showFirebaseError(e: Exception) {
val messageId = when {
e.message?.contains("no user record") == true ->
R.string.login_error_user_not_found
e.message?.contains("password is invalid") == true ->
R.string.login_error_wrong_password
e.message?.contains("badly formatted") == true ->
R.string.error_invalid_email_format
e.message?.contains("network") == true ->
R.string.error_network
else -> R.string.login_error_generic
}
Toast.makeText(this, messageId, Toast.LENGTH_LONG).show()
Toast.makeText(this, authErrorMessage(e, AuthAction.SIGN_IN), Toast.LENGTH_LONG).show()
}

private fun goToMainActivity() {
Expand All @@ -175,8 +169,6 @@ class LoginActivity : AppCompatActivity() {
}

private companion object {
const val PREFS_NAME = "campusnote_prefs"
const val KEY_REMEMBER_ME = "remember_me"
const val MIN_PASSWORD_LENGTH = 6
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -187,18 +187,7 @@ class RegisterActivity : AppCompatActivity() {

/** Firebase hata metinleri yerelleştirilmemiş geliyor; kullanıcıya Türkçesini gösteriyoruz. */
private fun showFirebaseError(e: Exception) {
val messageId = when {
e.message?.contains("email address is already") == true ->
R.string.register_error_email_in_use
e.message?.contains("badly formatted") == true ->
R.string.error_invalid_email_format
e.message?.contains("weak password") == true ->
R.string.register_error_weak_password
e.message?.contains("network") == true ->
R.string.error_network
else -> R.string.register_error_generic
}
Toast.makeText(this, messageId, Toast.LENGTH_LONG).show()
Toast.makeText(this, authErrorMessage(e, AuthAction.REGISTER), Toast.LENGTH_LONG).show()
}

private fun goToMainActivity() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,17 @@ class LeaderboardFragment : Fragment() {

private lateinit var adapter: LeaderboardAdapter

/**
* Ekranda duran hata snackbar'ı.
*
* Referans tutuluyor çünkü snackbar bu fragment'ın değil, [MainActivity]'nin
* CoordinatorLayout'una bağlanıyor — sekme değiştirmek fragment'ı yok etse de
* snackbar'ı yok etmiyordu. [Snackbar.LENGTH_INDEFINITE] ile birleşince sıralama
* hatası kullanıcıyı yükleme ve profil sekmelerine kadar takip ediyor, hiç
* kapanmıyordu; index eksikken "Tekrar dene" de her seferinde yenisini açıyordu.
*/
private var errorSnackbar: Snackbar? = null

private val viewModel: LeaderboardViewModel by lazy {
ViewModelProvider(this)[LeaderboardViewModel::class.java]
}
Expand Down Expand Up @@ -56,6 +67,8 @@ class LeaderboardFragment : Fragment() {
}

override fun onStop() {
// Dinleyiciyle birlikte hata mesajı da gitmeli: ikisi de bu ekrana ait.
dismissError()
viewModel.stopLeaderboard()
super.onStop()
}
Expand Down Expand Up @@ -106,12 +119,15 @@ class LeaderboardFragment : Fragment() {
}

LeaderboardUiState.Empty -> {
dismissError()
adapter.refresh(emptyList())
binding.rvLeaderboard.visibility = View.GONE
binding.layoutEmpty.visibility = View.VISIBLE
}

is LeaderboardUiState.Content -> {
// Yeniden deneme tuttuğunda eski hata kendiliğinden kapanmalı.
dismissError()
adapter.refresh(state.entries)
binding.rvLeaderboard.visibility = View.VISIBLE
binding.layoutEmpty.visibility = View.GONE
Expand Down Expand Up @@ -148,9 +164,17 @@ class LeaderboardFragment : Fragment() {
* çizilir ve kapatılana kadar sekme değiştirmeyi engellerdi.
*/
private fun showRetryableError(message: String) {
Snackbar.make(binding.root, message, Snackbar.LENGTH_INDEFINITE)
// Üst üste binen snackbar'lar yerine tek bir tane: hata her tekrar denemede
// yeniden geliyor, biriktirmenin anlamı yok.
dismissError()
errorSnackbar = Snackbar.make(binding.root, message, Snackbar.LENGTH_INDEFINITE)
.setAnchorView(R.id.bottomNav)
.setAction(R.string.action_retry) { viewModel.startLeaderboard() }
.show()
.also { it.show() }
}

private fun dismissError() {
errorSnackbar?.dismiss()
errorSnackbar = null
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,14 @@ class ProfileFragment : Fragment() {

private lateinit var myNotesAdapter: PostAdapter

/**
* Ekranda duran hata snackbar'ı; sebebi için bkz. [showRetryableError].
*
* Snackbar bu fragment'ın değil, [MainActivity]'nin CoordinatorLayout'una
* bağlanıyor, dolayısıyla sekme değiştirmek onu kapatmıyor.
*/
private var errorSnackbar: Snackbar? = null

override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
Expand Down Expand Up @@ -132,6 +140,8 @@ class ProfileFragment : Fragment() {
}

override fun onStop() {
// Dinleyiciyle birlikte hata mesajı da gitmeli: ikisi de bu ekrana ait.
dismissError()
viewModel.stopProfile()
super.onStop()
}
Expand All @@ -147,6 +157,8 @@ class ProfileFragment : Fragment() {
}

is ProfileUiState.Content -> {
// Yeniden deneme tuttuğunda eski hata kendiliğinden kapanmalı.
dismissError()
binding.tvEmail.text = state.email
binding.tvDepartment.text = state.department
binding.tvAvatar.text = state.email.firstOrNull()?.uppercaseChar()?.toString()
Expand Down Expand Up @@ -191,10 +203,16 @@ class ProfileFragment : Fragment() {
* çizilir ve kapatılana kadar sekme değiştirmeyi engellerdi.
*/
private fun showRetryableError(message: String) {
Snackbar.make(binding.root, message, Snackbar.LENGTH_INDEFINITE)
dismissError()
errorSnackbar = Snackbar.make(binding.root, message, Snackbar.LENGTH_INDEFINITE)
.setAnchorView(R.id.bottomNav)
.setAction(R.string.action_retry) { viewModel.startProfile() }
.show()
.also { it.show() }
}

private fun dismissError() {
errorSnackbar?.dismiss()
errorSnackbar = null
}

private fun renderActionState(state: ProfileActionState) {
Expand Down
15 changes: 15 additions & 0 deletions app/src/main/res/drawable/ic_password_toggle.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Şifre görünürlük düğmesinin ikonu.

Material'in varsayılanı *eylemi* gösterir: şifre gizliyken açık göz ("göstermek
için bas"). Bu uygulama *durumu* gösteriyor — gizliyken üstü çizili göz, görünürken
açık göz — çünkü kullanıcılar ikonu "şu an ne olduğu" olarak okuyor.

`state_checked`, TextInputLayout'un password_toggle end icon'unda "şifre görünür"
demek; işaretsiz hâli gizli şifredir.
-->
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:state_checked="true" android:drawable="@drawable/ic_visibility"/>
<item android:drawable="@drawable/ic_visibility_off"/>
</selector>
10 changes: 10 additions & 0 deletions app/src/main/res/drawable/ic_visibility.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@color/white"
android:pathData="M12,4.5C7,4.5 2.73,7.61 1,12c1.73,4.39 6,7.5 11,7.5s9.27,-3.11 11,-7.5c-1.73,-4.39 -6,-7.5 -11,-7.5zM12,17c-2.76,0 -5,-2.24 -5,-5s2.24,-5 5,-5s5,2.24 5,5s-2.24,5 -5,5zM12,9c-1.66,0 -3,1.34 -3,3s1.34,3 3,3s3,-1.34 3,-3s-1.34,-3 -3,-3z"/>
</vector>
10 changes: 10 additions & 0 deletions app/src/main/res/drawable/ic_visibility_off.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@color/white"
android:pathData="M12,7c2.76,0 5,2.24 5,5c0,0.65 -0.13,1.26 -0.36,1.83l2.92,2.92c1.51,-1.26 2.7,-2.89 3.43,-4.75c-1.73,-4.39 -6,-7.5 -11,-7.5c-1.4,0 -2.74,0.25 -3.98,0.7l2.16,2.16C10.74,7.13 11.35,7 12,7zM2,4.27l2.28,2.28l0.46,0.46C3.08,8.3 1.78,10.02 1,12c1.73,4.39 6,7.5 11,7.5c1.55,0 3.03,-0.3 4.38,-0.84l0.42,0.42L19.73,22L21,20.73L3.27,3L2,4.27zM7.53,9.8l1.55,1.55c-0.05,0.21 -0.08,0.43 -0.08,0.65c0,1.66 1.34,3 3,3c0.22,0 0.44,-0.03 0.65,-0.08l1.55,1.55c-0.67,0.33 -1.41,0.53 -2.2,0.53c-2.76,0 -5,-2.24 -5,-5c0,-0.79 0.2,-1.53 0.53,-2.2zM11.84,9.02l3.15,3.15l0.02,-0.16c0,-1.66 -1.34,-3 -3,-3L11.84,9.02z"/>
</vector>
15 changes: 3 additions & 12 deletions app/src/main/res/layout/activity_login.xml
Original file line number Diff line number Diff line change
Expand Up @@ -104,8 +104,9 @@
android:id="@+id/tilPassword"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:passwordToggleEnabled="true"
app:passwordToggleTint="@color/input_text"
app:endIconMode="password_toggle"
app:endIconDrawable="@drawable/ic_password_toggle"
app:endIconTint="@color/input_text"
style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox"
app:startIconDrawable="@drawable/ic_lock"
app:startIconTint="@color/soft_orange"
Expand Down Expand Up @@ -138,16 +139,6 @@
android:fontFamily="@font/poppins_regular"/>
</com.google.android.material.textfield.TextInputLayout>

<CheckBox
android:id="@+id/cbRememberMe"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/login_keep_signed_in"
android:textColor="@color/white"
android:textSize="13sp"
android:fontFamily="@font/poppins_regular"
android:buttonTint="@color/soft_orange"
android:layout_marginTop="8dp"/>
</LinearLayout>

<ProgressBar
Expand Down
5 changes: 3 additions & 2 deletions app/src/main/res/layout/activity_register.xml
Original file line number Diff line number Diff line change
Expand Up @@ -104,8 +104,9 @@
android:id="@+id/tilPassword"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:passwordToggleEnabled="true"
app:passwordToggleTint="@color/input_text"
app:endIconMode="password_toggle"
app:endIconDrawable="@drawable/ic_password_toggle"
app:endIconTint="@color/input_text"
style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox"
app:startIconDrawable="@drawable/ic_lock"
app:startIconTint="@color/soft_orange"
Expand Down
Loading
Loading