diff --git a/README.md b/README.md
index 887fef0..e852692 100644
--- a/README.md
+++ b/README.md
@@ -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.
@@ -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. |
@@ -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
diff --git a/app/src/main/java/duygu/yilmaz/campusnote/ui/auth/AuthErrorMessages.kt b/app/src/main/java/duygu/yilmaz/campusnote/ui/auth/AuthErrorMessages.kt
new file mode 100644
index 0000000..55db97e
--- /dev/null
+++ b/app/src/main/java/duygu/yilmaz/campusnote/ui/auth/AuthErrorMessages.kt
@@ -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
+ }
+}
diff --git a/app/src/main/java/duygu/yilmaz/campusnote/ui/auth/LoginActivity.kt b/app/src/main/java/duygu/yilmaz/campusnote/ui/auth/LoginActivity.kt
index ae80212..8c2dc32 100644
--- a/app/src/main/java/duygu/yilmaz/campusnote/ui/auth/LoginActivity.kt
+++ b/app/src/main/java/duygu/yilmaz/campusnote/ui/auth/LoginActivity.kt
@@ -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
@@ -16,13 +15,20 @@ 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
}
@@ -30,7 +36,7 @@ class LoginActivity : AppCompatActivity() {
binding = ActivityLoginBinding.inflate(layoutInflater)
setContentView(binding.root)
- observeAuthState(prefs)
+ observeAuthState()
setupClickListeners()
animateViews()
}
@@ -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()
@@ -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() {
@@ -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
}
}
diff --git a/app/src/main/java/duygu/yilmaz/campusnote/ui/auth/RegisterActivity.kt b/app/src/main/java/duygu/yilmaz/campusnote/ui/auth/RegisterActivity.kt
index 69fb2d1..d271713 100644
--- a/app/src/main/java/duygu/yilmaz/campusnote/ui/auth/RegisterActivity.kt
+++ b/app/src/main/java/duygu/yilmaz/campusnote/ui/auth/RegisterActivity.kt
@@ -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() {
diff --git a/app/src/main/java/duygu/yilmaz/campusnote/ui/leaderboard/LeaderboardFragment.kt b/app/src/main/java/duygu/yilmaz/campusnote/ui/leaderboard/LeaderboardFragment.kt
index 13f1c26..07bca91 100644
--- a/app/src/main/java/duygu/yilmaz/campusnote/ui/leaderboard/LeaderboardFragment.kt
+++ b/app/src/main/java/duygu/yilmaz/campusnote/ui/leaderboard/LeaderboardFragment.kt
@@ -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]
}
@@ -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()
}
@@ -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
@@ -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
}
}
diff --git a/app/src/main/java/duygu/yilmaz/campusnote/ui/profile/ProfileFragment.kt b/app/src/main/java/duygu/yilmaz/campusnote/ui/profile/ProfileFragment.kt
index 7181518..9207a1c 100644
--- a/app/src/main/java/duygu/yilmaz/campusnote/ui/profile/ProfileFragment.kt
+++ b/app/src/main/java/duygu/yilmaz/campusnote/ui/profile/ProfileFragment.kt
@@ -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?,
@@ -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()
}
@@ -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()
@@ -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) {
diff --git a/app/src/main/res/drawable/ic_password_toggle.xml b/app/src/main/res/drawable/ic_password_toggle.xml
new file mode 100644
index 0000000..6b81a5f
--- /dev/null
+++ b/app/src/main/res/drawable/ic_password_toggle.xml
@@ -0,0 +1,15 @@
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_visibility.xml b/app/src/main/res/drawable/ic_visibility.xml
new file mode 100644
index 0000000..1f85494
--- /dev/null
+++ b/app/src/main/res/drawable/ic_visibility.xml
@@ -0,0 +1,10 @@
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_visibility_off.xml b/app/src/main/res/drawable/ic_visibility_off.xml
new file mode 100644
index 0000000..13a1005
--- /dev/null
+++ b/app/src/main/res/drawable/ic_visibility_off.xml
@@ -0,0 +1,10 @@
+
+
+
+
diff --git a/app/src/main/res/layout/activity_login.xml b/app/src/main/res/layout/activity_login.xml
index 80ef524..a512a34 100644
--- a/app/src/main/res/layout/activity_login.xml
+++ b/app/src/main/res/layout/activity_login.xml
@@ -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"
@@ -138,16 +139,6 @@
android:fontFamily="@font/poppins_regular"/>
-
Hata: %1$s
İnternet bağlantınızı kontrol edin
Geçersiz email formatı
+ Çok fazla deneme yapıldı. Bir süre sonra tekrar deneyin.
+ Bu hesap devre dışı bırakılmış
+ Email ile giriş bu projede kapalı
%1$d oy
İptal
Tekrar dene
@@ -46,11 +49,17 @@
Hesabına giriş yap
- Oturumu açık tut
Giriş yap
Hoş geldin!
Bu email ile kayıtlı kullanıcı bulunamadı
Şifre hatalı
+
+ Email veya şifre hatalı
Giriş başarısız. Tekrar deneyin.
diff --git a/app/src/test/java/duygu/yilmaz/campusnote/ui/auth/AuthErrorMessagesTest.kt b/app/src/test/java/duygu/yilmaz/campusnote/ui/auth/AuthErrorMessagesTest.kt
new file mode 100644
index 0000000..ef9e0e6
--- /dev/null
+++ b/app/src/test/java/duygu/yilmaz/campusnote/ui/auth/AuthErrorMessagesTest.kt
@@ -0,0 +1,108 @@
+package duygu.yilmaz.campusnote.ui.auth
+
+import duygu.yilmaz.campusnote.R
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNotEquals
+import org.junit.Test
+
+/**
+ * Bu eşlemenin bozulması sessiz: yanlış kod hâlâ *bir* mesaj döndürür, sadece işe
+ * yaramaz olanını. Eski hâli tam olarak böyle bozulmuştu — Firebase'in İngilizce
+ * cümlesi değişti, `contains` tutmaz oldu, her hata "Tekrar deneyin."e düştü ve
+ * hiçbir test kırılmadı.
+ */
+class AuthErrorMessagesTest {
+
+ @Test
+ fun `yanlis sifre giris ekraninda kendi mesajini gosterir`() {
+ assertEquals(
+ R.string.login_error_wrong_password,
+ messageForAuthErrorCode("ERROR_WRONG_PASSWORD", AuthAction.SIGN_IN)
+ )
+ }
+
+ @Test
+ fun `kayitli olmayan kullanici kendi mesajini gosterir`() {
+ assertEquals(
+ R.string.login_error_user_not_found,
+ messageForAuthErrorCode("ERROR_USER_NOT_FOUND", AuthAction.SIGN_IN)
+ )
+ }
+
+ /**
+ * Asıl regresyon: e-posta sayım koruması açık bir projede Firebase artık yanlış
+ * şifre ile olmayan hesabı ayırmıyor, ikisine de bu kodu dönüyor. Eşleme bunu
+ * tanımazsa giriş hatalarının neredeyse tamamı genel mesaja düşer.
+ */
+ @Test
+ fun `gecersiz kimlik bilgisi birlesik mesaja duser`() {
+ assertEquals(
+ R.string.login_error_invalid_credentials,
+ messageForAuthErrorCode("ERROR_INVALID_CREDENTIAL", AuthAction.SIGN_IN)
+ )
+ }
+
+ @Test
+ fun `kullanilan eposta kayit ekraninda kendi mesajini gosterir`() {
+ assertEquals(
+ R.string.register_error_email_in_use,
+ messageForAuthErrorCode("ERROR_EMAIL_ALREADY_IN_USE", AuthAction.REGISTER)
+ )
+ }
+
+ @Test
+ fun `zayif sifre kayit ekraninda kendi mesajini gosterir`() {
+ assertEquals(
+ R.string.register_error_weak_password,
+ messageForAuthErrorCode("ERROR_WEAK_PASSWORD", AuthAction.REGISTER)
+ )
+ }
+
+ @Test
+ fun `ag hatasi baglanti mesajini gosterir`() {
+ assertEquals(
+ R.string.error_network,
+ messageForAuthErrorCode("ERROR_NETWORK_REQUEST_FAILED", AuthAction.SIGN_IN)
+ )
+ }
+
+ @Test
+ fun `cok fazla deneme kendi mesajini gosterir`() {
+ assertEquals(
+ R.string.error_too_many_requests,
+ messageForAuthErrorCode("ERROR_TOO_MANY_REQUESTS", AuthAction.SIGN_IN)
+ )
+ }
+
+ @Test
+ fun `bilinmeyen kod eylemin genel mesajina duser`() {
+ assertEquals(
+ R.string.login_error_generic,
+ messageForAuthErrorCode("ERROR_SOMETHING_NEW", AuthAction.SIGN_IN)
+ )
+ assertEquals(
+ R.string.register_error_generic,
+ messageForAuthErrorCode("ERROR_SOMETHING_NEW", AuthAction.REGISTER)
+ )
+ }
+
+ @Test
+ fun `kodsuz hata eylemin genel mesajina duser`() {
+ assertEquals(
+ R.string.login_error_generic,
+ messageForAuthErrorCode(null, AuthAction.SIGN_IN)
+ )
+ }
+
+ /**
+ * Genel mesaj iki ekran arasında paylaşılamaz: "Giriş başarısız" kayıt ekranında
+ * yanlış olur. Ayrı kaldıklarını sabitler.
+ */
+ @Test
+ fun `giris ve kayit ayri genel mesajlar kullanir`() {
+ assertNotEquals(
+ messageForAuthErrorCode(null, AuthAction.SIGN_IN),
+ messageForAuthErrorCode(null, AuthAction.REGISTER)
+ )
+ }
+}
diff --git a/app/src/test/java/duygu/yilmaz/campusnote/ui/auth/PasswordToggleTest.kt b/app/src/test/java/duygu/yilmaz/campusnote/ui/auth/PasswordToggleTest.kt
new file mode 100644
index 0000000..d25ce67
--- /dev/null
+++ b/app/src/test/java/duygu/yilmaz/campusnote/ui/auth/PasswordToggleTest.kt
@@ -0,0 +1,123 @@
+package duygu.yilmaz.campusnote.ui.auth
+
+import android.text.method.PasswordTransformationMethod
+import android.view.LayoutInflater
+import android.view.View
+import android.widget.Checkable
+import android.widget.CheckBox
+import androidx.appcompat.view.ContextThemeWrapper
+import com.google.android.material.textfield.TextInputLayout
+import duygu.yilmaz.campusnote.R
+import duygu.yilmaz.campusnote.databinding.ActivityLoginBinding
+import duygu.yilmaz.campusnote.databinding.ActivityRegisterBinding
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertNotNull
+import org.junit.Assert.assertNull
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.RuntimeEnvironment
+import org.robolectric.annotation.Config
+
+/**
+ * Şifre görünürlük düğmesinin ikonu *durumu* göstermeli: gizliyken üstü çizili göz,
+ * görünürken açık göz.
+ *
+ * Bu, Material'in varsayılanının tersi — varsayılan eylemi gösterir — ve düğme
+ * `endIconDrawable` ile özel bir selector'a bağlı olduğu için sessizce geri
+ * dönebilecek bir tercih: birisi `endIconDrawable` satırını silse düğme çalışmaya
+ * devam eder, sadece ikon yine ters görünür. Hiçbir ViewModel testi bunu görmez.
+ *
+ * `state_checked`, TextInputLayout'un password_toggle end icon'unda "şifre görünür"
+ * anlamına geliyor; test seçilen drawable yerine bu durumu doğruluyor, çünkü asıl
+ * sözleşme selector ile düğme arasında.
+ */
+@RunWith(RobolectricTestRunner::class)
+@Config(qualifiers = "w411dp-h891dp")
+class PasswordToggleTest {
+
+ private fun themedInflater(): LayoutInflater {
+ val themed = ContextThemeWrapper(
+ RuntimeEnvironment.getApplication(),
+ R.style.Theme_CampusNote
+ )
+ return LayoutInflater.from(themed)
+ }
+
+ /**
+ * End icon bir [CheckBox] değil, Material'in kendi `CheckableImageButton`'ı; tipi
+ * kütüphanenin iç paketinde olduğu için [Checkable] arayüzü üzerinden okunuyor.
+ */
+ private fun TextInputLayout.toggleButton(): View =
+ findViewById(com.google.android.material.R.id.text_input_end_icon)
+
+ private val View.checked: Boolean get() = (this as Checkable).isChecked
+
+ @Test
+ fun `giris ekraninda sifre once gizli ve ikon isaretsiz`() {
+ val binding = ActivityLoginBinding.inflate(themedInflater())
+
+ assertTrue(
+ "Şifre alanı gizli başlamalı",
+ binding.etPassword.transformationMethod is PasswordTransformationMethod
+ )
+ assertFalse(
+ "Şifre gizliyken ikon işaretsiz olmalı — üstü çizili göz",
+ binding.tilPassword.toggleButton().checked
+ )
+ }
+
+ @Test
+ fun `giris ekraninda dugmeye basinca sifre gorunur ve ikon isaretlenir`() {
+ val binding = ActivityLoginBinding.inflate(themedInflater())
+
+ binding.tilPassword.toggleButton().performClick()
+
+ assertFalse(
+ "Şifre alanı görünür olmalı",
+ binding.etPassword.transformationMethod is PasswordTransformationMethod
+ )
+ assertTrue(
+ "Şifre görünürken ikon işaretli olmalı — açık göz",
+ binding.tilPassword.toggleButton().checked
+ )
+ }
+
+ @Test
+ fun `kayit ekrani ayni davranisi paylasir`() {
+ val binding = ActivityRegisterBinding.inflate(themedInflater())
+
+ assertFalse(binding.tilPassword.toggleButton().checked)
+ binding.tilPassword.toggleButton().performClick()
+ assertTrue(binding.tilPassword.toggleButton().checked)
+ }
+
+ /**
+ * Selector'ın bağlı kaldığını sabitler: `endIconDrawable` düşerse Material kendi
+ * varsayılan ikonunu koyar ve görünüm sessizce eski ters hâline döner.
+ */
+ @Test
+ fun `iki ekran da ozel selector ikonunu kullanir`() {
+ assertNotNull(ActivityLoginBinding.inflate(themedInflater()).tilPassword.endIconDrawable)
+ assertNotNull(ActivityRegisterBinding.inflate(themedInflater()).tilPassword.endIconDrawable)
+ }
+
+ /**
+ * "Oturumu açık tut" kutusu kaldırıldı — oturum, kullanıcı Profil'den çıkana
+ * kadar zaten açık kalıyor. Kutunun geri gelmesi eski davranışın da geri
+ * gelmesi demek olurdu.
+ */
+ @Test
+ fun `giris ekraninda oturumu acik tut kutusu yok`() {
+ val root = ActivityLoginBinding.inflate(themedInflater()).root
+ assertNull(
+ "Beni hatırla kutusu kaldırıldı",
+ root.findViewById(
+ root.resources.getIdentifier("cbRememberMe", "id", "duygu.yilmaz.campusnote")
+ )
+ )
+ assertEquals(0, root.resources.getIdentifier("login_keep_signed_in", "string", "duygu.yilmaz.campusnote"))
+ }
+}