Skip to content

Commit 61fcd6f

Browse files
authored
Implement a ThemeSwitcher to alternate between dark and light mode (#16)
* Implement a ThemeSwitcher to alternate between dark and light mode * Remove unnecessary Suppress * Use Activity context for the ThemeSwitcher (addressing PR comments)
1 parent 2ed89c1 commit 61fcd6f

File tree

11 files changed

+299
-21
lines changed

11 files changed

+299
-21
lines changed

app/src/main/java/com/kinandcarta/create/proxytoggle/android/SharedPrefsAppSettings.kt

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package com.kinandcarta.create.proxytoggle.android
22

33
import android.content.Context
4+
import androidx.appcompat.app.AppCompatDelegate
45
import androidx.core.content.edit
56
import com.kinandcarta.create.proxytoggle.model.Proxy
67
import com.kinandcarta.create.proxytoggle.model.ProxyMapper
@@ -16,6 +17,7 @@ class SharedPrefsAppSettings @Inject constructor(
1617
companion object {
1718
private const val SHARED_PREF_NAME = "AppSettings"
1819
private const val PREF_PROXY = "proxy"
20+
private const val PREF_THEME = "theme"
1921
}
2022

2123
private val prefs by lazy {
@@ -25,4 +27,8 @@ class SharedPrefsAppSettings @Inject constructor(
2527
override var lastUsedProxy: Proxy
2628
get() = proxyMapper.from(prefs.getString(PREF_PROXY, null))
2729
set(value) = prefs.edit { putString(PREF_PROXY, value.toString()) }
30+
31+
override var themeMode: Int
32+
get() = prefs.getInt(PREF_THEME, AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM)
33+
set(value) = prefs.edit { putInt(PREF_THEME, value) }
2834
}
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
package com.kinandcarta.create.proxytoggle.android
2+
3+
import android.content.Context
4+
import android.content.res.Configuration.UI_MODE_NIGHT_MASK
5+
import android.content.res.Configuration.UI_MODE_NIGHT_YES
6+
import androidx.appcompat.app.AppCompatDelegate
7+
import com.kinandcarta.create.proxytoggle.settings.AppSettings
8+
import dagger.hilt.android.qualifiers.ActivityContext
9+
import dagger.hilt.android.scopes.ActivityScoped
10+
import javax.inject.Inject
11+
12+
@ActivityScoped
13+
class ThemeSwitcher @Inject constructor(
14+
@ActivityContext context: Context,
15+
private val appSettings: AppSettings
16+
) {
17+
18+
init {
19+
val mode =
20+
when {
21+
appSettings.themeMode.isNightMode() || appSettings.themeMode.isLightMode() -> {
22+
appSettings.themeMode
23+
}
24+
context.isSetToDarkMode() -> {
25+
AppCompatDelegate.MODE_NIGHT_YES
26+
}
27+
else -> {
28+
AppCompatDelegate.MODE_NIGHT_NO
29+
}
30+
}
31+
setTheme(mode)
32+
}
33+
34+
fun toggleTheme() {
35+
if (appSettings.themeMode.isNightMode()) {
36+
setTheme(AppCompatDelegate.MODE_NIGHT_NO)
37+
} else {
38+
setTheme(AppCompatDelegate.MODE_NIGHT_YES)
39+
}
40+
}
41+
42+
private fun setTheme(mode: Int) {
43+
appSettings.themeMode = mode
44+
AppCompatDelegate.setDefaultNightMode(mode)
45+
}
46+
47+
private fun Int.isNightMode() = this == AppCompatDelegate.MODE_NIGHT_YES
48+
private fun Int.isLightMode() = this == AppCompatDelegate.MODE_NIGHT_NO
49+
private fun Context.isSetToDarkMode() =
50+
this.resources.configuration.uiMode and UI_MODE_NIGHT_MASK == UI_MODE_NIGHT_YES
51+
}

app/src/main/java/com/kinandcarta/create/proxytoggle/feature/manager/view/ProxyManagerFragment.kt

Lines changed: 31 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -33,14 +33,14 @@ class ProxyManagerFragment : Fragment() {
3333

3434
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
3535
super.onViewCreated(view, savedInstanceState)
36-
binding.info.requestFocusFromTouch()
37-
binding.info.setOnClickListener { showInfoDialog() }
38-
viewModel.proxyState.observe(viewLifecycleOwner, Observer { proxyState ->
39-
when (proxyState) {
40-
is ProxyState.Enabled -> showProxyEnabled(proxyState.address, proxyState.port)
41-
is ProxyState.Disabled -> showProxyDisabled()
42-
}
43-
})
36+
37+
setupIcons()
38+
39+
observeProxyState()
40+
observeProxyEvent()
41+
}
42+
43+
private fun observeProxyEvent() {
4444
viewModel.proxyEvent.observe(viewLifecycleOwner, Observer { proxyEvent ->
4545
hideErrors()
4646
when (proxyEvent) {
@@ -50,6 +50,29 @@ class ProxyManagerFragment : Fragment() {
5050
})
5151
}
5252

53+
private fun observeProxyState() {
54+
viewModel.proxyState.observe(viewLifecycleOwner, Observer { proxyState ->
55+
when (proxyState) {
56+
is ProxyState.Enabled -> showProxyEnabled(proxyState.address, proxyState.port)
57+
is ProxyState.Disabled -> showProxyDisabled()
58+
}
59+
})
60+
}
61+
62+
private fun setupIcons() {
63+
// Info icon
64+
binding.info.setOnClickListener {
65+
dialog?.dismiss()
66+
dialog = AlertDialog.Builder(requireContext())
67+
.setMessage(R.string.dialog_message_information)
68+
.setPositiveButton(getString(R.string.dialog_action_close)) { _, _ -> }
69+
.show()
70+
}
71+
72+
// Theme mode icon
73+
binding.themeMode.setOnClickListener { viewModel.toggleTheme() }
74+
}
75+
5376
private fun showProxyEnabled(proxyAddress: String, proxyPort: String) {
5477
hideErrors()
5578
with(binding) {
@@ -107,12 +130,4 @@ class ProxyManagerFragment : Fragment() {
107130
inputLayoutPort.error = null
108131
}
109132
}
110-
111-
private fun showInfoDialog() {
112-
dialog?.dismiss()
113-
dialog = AlertDialog.Builder(requireContext())
114-
.setMessage(R.string.dialog_message_information)
115-
.setPositiveButton(getString(R.string.dialog_action_close)) { _, _ -> }
116-
.show()
117-
}
118133
}

app/src/main/java/com/kinandcarta/create/proxytoggle/feature/manager/viewmodel/ProxyManagerViewModel.kt

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import androidx.lifecycle.Transformations
55
import androidx.lifecycle.ViewModel
66
import com.kinandcarta.create.proxytoggle.android.DeviceSettingsManager
77
import com.kinandcarta.create.proxytoggle.android.ProxyValidator
8+
import com.kinandcarta.create.proxytoggle.android.ThemeSwitcher
89
import com.kinandcarta.create.proxytoggle.extensions.SingleLiveEvent
910
import com.kinandcarta.create.proxytoggle.feature.manager.view.ProxyManagerEvent
1011
import com.kinandcarta.create.proxytoggle.feature.manager.view.ProxyState
@@ -14,7 +15,8 @@ import com.kinandcarta.create.proxytoggle.settings.AppSettings
1415
class ProxyManagerViewModel @ViewModelInject constructor(
1516
private val deviceSettingsManager: DeviceSettingsManager,
1617
private val proxyValidator: ProxyValidator,
17-
private val appSettings: AppSettings
18+
private val appSettings: AppSettings,
19+
private val themeSwitcher: ThemeSwitcher
1820
) : ViewModel() {
1921

2022
val proxyEvent = SingleLiveEvent<ProxyManagerEvent>()
@@ -45,4 +47,6 @@ class ProxyManagerViewModel @ViewModelInject constructor(
4547
fun disableProxy() {
4648
deviceSettingsManager.disableProxy()
4749
}
50+
51+
fun toggleTheme() = themeSwitcher.toggleTheme()
4852
}

app/src/main/java/com/kinandcarta/create/proxytoggle/settings/AppSettings.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,4 +5,6 @@ import com.kinandcarta.create.proxytoggle.model.Proxy
55
interface AppSettings {
66

77
var lastUsedProxy: Proxy
8+
9+
var themeMode: Int
810
}
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
<vector android:height="24dp" android:viewportHeight="24"
2+
android:viewportWidth="24" android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
3+
<path android:fillColor="#000000" android:pathData="M20,8.69V4h-4.69L12,0.69 8.69,4H4v4.69L0.69,12 4,15.31V20h4.69L12,23.31 15.31,20H20v-4.69L23.31,12 20,8.69zM12,18c-0.89,0 -1.74,-0.2 -2.5,-0.55C11.56,16.5 13,14.42 13,12s-1.44,-4.5 -3.5,-5.45C10.26,6.2 11.11,6 12,6c3.31,0 6,2.69 6,6s-2.69,6 -6,6z"/>
4+
</vector>

app/src/main/res/layout/fragment_proxy_manager.xml

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,16 @@
55
android:layout_height="match_parent"
66
android:orientation="vertical">
77

8+
<ImageView
9+
android:id="@+id/theme_mode"
10+
style="@style/Icon"
11+
android:layout_width="wrap_content"
12+
android:layout_height="wrap_content"
13+
android:contentDescription="@string/a11y_switch_theme"
14+
android:src="@drawable/ic_switch_theme"
15+
app:layout_constraintStart_toStartOf="parent"
16+
app:layout_constraintTop_toTopOf="parent" />
17+
818
<ImageView
919
android:id="@+id/info"
1020
style="@style/Icon"
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
<?xml version="1.0" encoding="utf-8"?>
22
<resources>
33
<string name="a11y_information">Information</string>
4+
<string name="a11y_switch_theme">Switch theme</string>
45
<string name="a11y_enable_proxy">Enable proxy</string>
56
<string name="a11y_disable_proxy">Disable proxy</string>
67
</resources>

app/src/test/java/com/kinandcarta/create/proxytoggle/android/SharedPrefsAppSettingsTest.kt

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ class SharedPrefsAppSettingsTest {
4040
}
4141

4242
@Test
43-
fun `getProxy() - fetches from sharedPrefs and maps the value`() {
43+
fun `getLastUsedProxy() - fetches from sharedPrefs and maps the value`() {
4444
every { mockSharedPreferences.getString("proxy", any()) } returns VALID_PROXY
4545

4646
val result = subject.lastUsedProxy
@@ -54,7 +54,7 @@ class SharedPrefsAppSettingsTest {
5454
}
5555

5656
@Test
57-
fun `setProxy() - stores in sharedPrefs the string version of the proxy`() {
57+
fun `setLastUsedProxy() - stores in sharedPrefs the string version of the proxy`() {
5858
val string = slot<String>()
5959
every { mockSharedPreferences.edit { putString("proxy", capture(string)) } } returns Unit
6060

@@ -66,4 +66,29 @@ class SharedPrefsAppSettingsTest {
6666
}
6767
confirmVerified(mockProxyMapper, mockSharedPreferences)
6868
}
69+
70+
@Test
71+
fun `getThemeMode() - fetches value from sharedPrefs`() {
72+
every { mockSharedPreferences.getInt("theme", any()) } returns 515
73+
74+
val result = subject.themeMode
75+
76+
verify {
77+
mockSharedPreferences.getInt("theme", any())
78+
assertThat(result).isEqualTo(515)
79+
}
80+
}
81+
82+
@Test
83+
fun `setThemeMode() - stores value in sharedPrefs`() {
84+
val mode = slot<Int>()
85+
every { mockSharedPreferences.edit { putInt("theme", capture(mode)) } } returns Unit
86+
87+
subject.themeMode = 515
88+
89+
verify {
90+
mockSharedPreferences.edit { putInt("theme", any()) }
91+
assertThat(mode.captured).isEqualTo(515)
92+
}
93+
}
6994
}
Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
package com.kinandcarta.create.proxytoggle.android
2+
3+
import android.content.Context
4+
import android.content.res.Configuration
5+
import androidx.appcompat.app.AppCompatDelegate
6+
import com.kinandcarta.create.proxytoggle.settings.AppSettings
7+
import io.mockk.MockKAnnotations
8+
import io.mockk.every
9+
import io.mockk.impl.annotations.MockK
10+
import io.mockk.impl.annotations.RelaxedMockK
11+
import io.mockk.mockk
12+
import io.mockk.mockkStatic
13+
import io.mockk.verify
14+
import org.junit.Before
15+
import org.junit.Test
16+
17+
class ThemeSwitcherTest {
18+
19+
companion object {
20+
private const val DARK_MODE = AppCompatDelegate.MODE_NIGHT_YES
21+
private const val LIGHT_MODE = AppCompatDelegate.MODE_NIGHT_NO
22+
private const val NO_MODE_SELECTED = AppCompatDelegate.MODE_NIGHT_UNSPECIFIED
23+
}
24+
25+
@RelaxedMockK
26+
private lateinit var mockContext: Context
27+
28+
@MockK
29+
private lateinit var mockAppSettings: AppSettings
30+
31+
private lateinit var subject: ThemeSwitcher
32+
33+
@Before
34+
fun setUp() {
35+
MockKAnnotations.init(this, relaxed = true)
36+
mockkStatic(AppCompatDelegate::class)
37+
38+
subject = ThemeSwitcher(mockContext, mockAppSettings)
39+
}
40+
41+
@Test
42+
fun `init - GIVEN I have dark mode selected THEN dark mode is set`() {
43+
every { mockAppSettings.themeMode } returns DARK_MODE
44+
45+
subject = ThemeSwitcher(mockContext, mockAppSettings)
46+
47+
verify {
48+
mockAppSettings.themeMode = DARK_MODE
49+
AppCompatDelegate.setDefaultNightMode(DARK_MODE)
50+
}
51+
}
52+
53+
@Test
54+
fun `init - GIVEN I have light mode selected THEN light mode is set`() {
55+
every { mockAppSettings.themeMode } returns LIGHT_MODE
56+
57+
subject = ThemeSwitcher(mockContext, mockAppSettings)
58+
59+
verify {
60+
mockAppSettings.themeMode = LIGHT_MODE
61+
AppCompatDelegate.setDefaultNightMode(LIGHT_MODE)
62+
}
63+
}
64+
65+
@Test
66+
fun `init - GIVEN I have no mode selected AND dark configuration THEN dark mode is set`() {
67+
every { mockAppSettings.themeMode } returns NO_MODE_SELECTED
68+
every { mockContext.resources } returns mockk {
69+
every { configuration } returns mockk {
70+
uiMode = Configuration.UI_MODE_NIGHT_YES
71+
}
72+
}
73+
74+
subject = ThemeSwitcher(mockContext, mockAppSettings)
75+
76+
verify {
77+
mockAppSettings.themeMode = DARK_MODE
78+
AppCompatDelegate.setDefaultNightMode(DARK_MODE)
79+
}
80+
}
81+
82+
@Test
83+
fun `init - GIVEN I have no mode selected AND light configuration THEN light mode is set`() {
84+
every { mockAppSettings.themeMode } returns NO_MODE_SELECTED
85+
every { mockContext.resources } returns mockk {
86+
every { configuration } returns mockk {
87+
uiMode = Configuration.UI_MODE_NIGHT_NO
88+
}
89+
}
90+
91+
subject = ThemeSwitcher(mockContext, mockAppSettings)
92+
93+
verify {
94+
mockAppSettings.themeMode = LIGHT_MODE
95+
AppCompatDelegate.setDefaultNightMode(LIGHT_MODE)
96+
}
97+
}
98+
99+
@Test
100+
fun `init - GIVEN I have no mode selected AND no configuration THEN light mode is set`() {
101+
every { mockAppSettings.themeMode } returns NO_MODE_SELECTED
102+
every { mockContext.resources } returns mockk {
103+
every { configuration } returns mockk {
104+
uiMode = Configuration.UI_MODE_NIGHT_UNDEFINED
105+
}
106+
}
107+
108+
subject = ThemeSwitcher(mockContext, mockAppSettings)
109+
110+
verify {
111+
mockAppSettings.themeMode = LIGHT_MODE
112+
AppCompatDelegate.setDefaultNightMode(LIGHT_MODE)
113+
}
114+
}
115+
116+
@Test
117+
fun `toggleTheme() - GIVEN I have dark mode THEN light mode is enabled AND stored in settings`() {
118+
every { mockAppSettings.themeMode } returns DARK_MODE
119+
120+
subject.toggleTheme()
121+
122+
verify {
123+
mockAppSettings.themeMode = LIGHT_MODE
124+
AppCompatDelegate.setDefaultNightMode(LIGHT_MODE)
125+
}
126+
}
127+
128+
@Test
129+
fun `toggleTheme() - GIVEN I have light mode THEN dark mode is enabled AND stored in settings`() {
130+
every { mockAppSettings.themeMode } returns LIGHT_MODE
131+
132+
subject.toggleTheme()
133+
134+
verify {
135+
mockAppSettings.themeMode = DARK_MODE
136+
AppCompatDelegate.setDefaultNightMode(DARK_MODE)
137+
}
138+
}
139+
}

0 commit comments

Comments
 (0)