diff --git a/android_app/app/src/main/java/dev/fanis/expensenotification/BaseActivity.java b/android_app/app/src/main/java/dev/fanis/expensenotification/BaseActivity.java index e16297b..9dd4e51 100644 --- a/android_app/app/src/main/java/dev/fanis/expensenotification/BaseActivity.java +++ b/android_app/app/src/main/java/dev/fanis/expensenotification/BaseActivity.java @@ -2,8 +2,10 @@ import android.annotation.SuppressLint; import android.app.Activity; +import android.content.Context; import android.content.Intent; import android.content.res.ColorStateList; +import android.content.res.Configuration; import android.graphics.Color; import android.graphics.Insets; import android.graphics.Typeface; @@ -26,20 +28,69 @@ /** Shared UI scaffolding and battery-optimization helpers for the app's screens. */ abstract class BaseActivity extends Activity { - protected static final int COLOR_TEAL = 0xff13696a; - protected static final int COLOR_BG = 0xfff6f8f9; - protected static final int COLOR_CARD = 0xffffffff; - protected static final int COLOR_BORDER = 0xffd6dde2; - protected static final int COLOR_TEXT = 0xff0d1924; - protected static final int COLOR_MUTED = 0xff485460; - protected static final int COLOR_DANGER = 0xff9f2f44; + // Palette fields keep their constant-era names; they are resolved per activity + // in attachBaseContext once the effective light/dark mode is known. + protected int COLOR_TEAL = 0xff13696a; + protected int COLOR_TEAL_TEXT = 0xff13696a; + protected int COLOR_BG = 0xfff6f8f9; + protected int COLOR_CARD = 0xffffffff; + protected int COLOR_BORDER = 0xffd6dde2; + protected int COLOR_TEXT = 0xff0d1924; + protected int COLOR_MUTED = 0xff485460; + protected int COLOR_HINT = 0xff788692; + protected int COLOR_DANGER = 0xff9f2f44; + + private String appliedThemeMode; + private boolean darkMode; + + @Override + protected void attachBaseContext(Context newBase) { + super.attachBaseContext(newBase); + appliedThemeMode = ThemePreferences.mode(newBase); + int forcedNight = ThemePreferences.forcedNightBits(appliedThemeMode); + if (forcedNight != 0) { + // Overriding uiMode makes this activity's resources (values-night theme, + // dialogs) and the isDarkMode() check below follow the forced choice. + Configuration override = new Configuration(); + int baseUiMode = newBase.getResources().getConfiguration().uiMode; + override.uiMode = forcedNight | (baseUiMode & ~Configuration.UI_MODE_NIGHT_MASK); + applyOverrideConfiguration(override); + } + darkMode = (getResources().getConfiguration().uiMode & Configuration.UI_MODE_NIGHT_MASK) + == Configuration.UI_MODE_NIGHT_YES; + if (darkMode) { + COLOR_TEAL_TEXT = 0xff7ecfd0; + COLOR_BG = 0xff10171c; + COLOR_CARD = 0xff1a232a; + COLOR_BORDER = 0xff32404a; + COLOR_TEXT = 0xffe8edf1; + COLOR_MUTED = 0xffa4b2bc; + COLOR_HINT = 0xff6f7f8b; + COLOR_DANGER = 0xffb43a52; + } + } + + @Override + protected void onResume() { + super.onResume(); + // A stacked activity resumes with a stale palette after the theme changed + // in Settings; rebuild it with the new choice applied. + if (appliedThemeMode != null && !appliedThemeMode.equals(ThemePreferences.mode(this))) { + recreate(); + } + } + + protected boolean isDarkMode() { + return darkMode; + } /** Wraps a vertical content column in a scroller with status/navigation-bar insets applied. */ protected ScrollView scrollRoot(LinearLayout root) { Window window = getWindow(); window.setStatusBarColor(COLOR_TEAL); - window.setNavigationBarColor(Color.WHITE); - window.getDecorView().setSystemUiVisibility(View.SYSTEM_UI_FLAG_LIGHT_NAVIGATION_BAR); + window.setNavigationBarColor(darkMode ? COLOR_BG : Color.WHITE); + window.getDecorView().setSystemUiVisibility( + darkMode ? 0 : View.SYSTEM_UI_FLAG_LIGHT_NAVIGATION_BAR); ScrollView scroll = new ScrollView(this); scroll.setFillViewport(true); scroll.setBackgroundColor(COLOR_BG); @@ -198,7 +249,7 @@ protected Button button(String label) { protected Button secondaryButton(String label) { Button button = button(label); - button.setTextColor(COLOR_TEAL); + button.setTextColor(COLOR_TEAL_TEXT); button.setBackground(rounded(COLOR_CARD, COLOR_BORDER, 5)); return button; } @@ -252,7 +303,7 @@ protected void styleCard(LinearLayout card) { protected void styleInput(EditText input) { input.setTextColor(COLOR_TEXT); - input.setHintTextColor(0xff788692); + input.setHintTextColor(COLOR_HINT); input.setTextSize(15); input.setBackgroundTintList(ColorStateList.valueOf(COLOR_BORDER)); } diff --git a/android_app/app/src/main/java/dev/fanis/expensenotification/SettingsActivity.java b/android_app/app/src/main/java/dev/fanis/expensenotification/SettingsActivity.java index ee56956..65f8570 100644 --- a/android_app/app/src/main/java/dev/fanis/expensenotification/SettingsActivity.java +++ b/android_app/app/src/main/java/dev/fanis/expensenotification/SettingsActivity.java @@ -19,6 +19,7 @@ public class SettingsActivity extends BaseActivity { private CandidateDb db; private Button currencyButton; + private Button themeButton; private Button payeeAliasesButton; private Button batteryButton; @@ -72,6 +73,10 @@ private View buildUi() { currencyButton.setOnClickListener(v -> showCurrencyDialog()); root.addView(currencyButton); + themeButton = button(themeLabel()); + themeButton.setOnClickListener(v -> showThemeDialog()); + root.addView(themeButton); + payeeAliasesButton = button(payeeAliasesLabel()); payeeAliasesButton.setOnClickListener(v -> startActivity(new Intent(this, PayeeAliasesActivity.class))); root.addView(payeeAliasesButton); @@ -166,6 +171,44 @@ private void showCurrencyDialog() { .show(); } + private String themeLabel() { + return "Theme: " + ThemePreferences.label(ThemePreferences.mode(this)); + } + + private void showThemeDialog() { + String[] modes = new String[]{ + ThemePreferences.MODE_AUTO, + ThemePreferences.MODE_LIGHT, + ThemePreferences.MODE_DARK, + }; + CharSequence[] labels = new CharSequence[modes.length]; + int currentIndex = 0; + String current = ThemePreferences.mode(this); + for (int i = 0; i < modes.length; i++) { + labels[i] = ThemePreferences.label(modes[i]); + if (modes[i].equals(current)) { + currentIndex = i; + } + } + new AlertDialog.Builder(this) + .setTitle("Theme") + .setSingleChoiceItems(labels, currentIndex, (dialog, which) -> { + boolean changed = !modes[which].equals(current); + ThemePreferences.setMode(this, modes[which]); + if (themeButton != null) { + themeButton.setText(themeLabel()); + } + dialog.dismiss(); + if (changed) { + // Rebuild this screen with the new palette; stacked + // activities catch up in BaseActivity.onResume. + recreate(); + } + }) + .setNegativeButton("Cancel", null) + .show(); + } + private String payeeAliasesLabel() { int blocked = PayeeAliases.blacklistCount(this); String label = "Payee aliases (" + PayeeAliases.count(this) + ")"; diff --git a/android_app/app/src/main/java/dev/fanis/expensenotification/ThemePreferences.java b/android_app/app/src/main/java/dev/fanis/expensenotification/ThemePreferences.java new file mode 100644 index 0000000..bffd0eb --- /dev/null +++ b/android_app/app/src/main/java/dev/fanis/expensenotification/ThemePreferences.java @@ -0,0 +1,55 @@ +package dev.fanis.expensenotification; + +import android.content.Context; +import android.content.SharedPreferences; +import android.content.res.Configuration; + +/** Stores the user's theme choice: follow the system, or force light/dark. */ +final class ThemePreferences { + static final String MODE_AUTO = "AUTO"; + static final String MODE_LIGHT = "LIGHT"; + static final String MODE_DARK = "DARK"; + + private static final String PREFS = "theme_preferences"; + private static final String KEY_MODE = "mode"; + + private ThemePreferences() { + } + + static String mode(Context context) { + String value = prefs(context).getString(KEY_MODE, MODE_AUTO); + if (MODE_LIGHT.equals(value) || MODE_DARK.equals(value)) { + return value; + } + return MODE_AUTO; + } + + static void setMode(Context context, String mode) { + prefs(context).edit().putString(KEY_MODE, mode).apply(); + } + + static String label(String mode) { + if (MODE_LIGHT.equals(mode)) { + return "Light"; + } + if (MODE_DARK.equals(mode)) { + return "Dark"; + } + return "Auto (follow system)"; + } + + /** The UI_MODE_NIGHT_* bits the given mode forces, or 0 when following the system. */ + static int forcedNightBits(String mode) { + if (MODE_LIGHT.equals(mode)) { + return Configuration.UI_MODE_NIGHT_NO; + } + if (MODE_DARK.equals(mode)) { + return Configuration.UI_MODE_NIGHT_YES; + } + return 0; + } + + private static SharedPreferences prefs(Context context) { + return context.getSharedPreferences(PREFS, Context.MODE_PRIVATE); + } +} diff --git a/android_app/app/src/main/res/values-night/styles.xml b/android_app/app/src/main/res/values-night/styles.xml new file mode 100644 index 0000000..6e5112d --- /dev/null +++ b/android_app/app/src/main/res/values-night/styles.xml @@ -0,0 +1,7 @@ + + + diff --git a/android_app/app/src/test/java/dev/fanis/expensenotification/ThemePreferencesRobolectricTest.java b/android_app/app/src/test/java/dev/fanis/expensenotification/ThemePreferencesRobolectricTest.java new file mode 100644 index 0000000..4c0a853 --- /dev/null +++ b/android_app/app/src/test/java/dev/fanis/expensenotification/ThemePreferencesRobolectricTest.java @@ -0,0 +1,59 @@ +package dev.fanis.expensenotification; + +import static org.junit.Assert.assertEquals; + +import android.content.Context; +import android.content.res.Configuration; + +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.RuntimeEnvironment; + +@RunWith(RobolectricTestRunner.class) +public class ThemePreferencesRobolectricTest { + + private Context context; + + @Before + public void setUp() { + context = RuntimeEnvironment.getApplication(); + } + + @Test + public void defaultsToAuto() { + assertEquals(ThemePreferences.MODE_AUTO, ThemePreferences.mode(context)); + } + + @Test + public void persistsSelectedMode() { + ThemePreferences.setMode(context, ThemePreferences.MODE_DARK); + assertEquals(ThemePreferences.MODE_DARK, ThemePreferences.mode(context)); + + ThemePreferences.setMode(context, ThemePreferences.MODE_LIGHT); + assertEquals(ThemePreferences.MODE_LIGHT, ThemePreferences.mode(context)); + } + + @Test + public void unknownStoredValueFallsBackToAuto() { + ThemePreferences.setMode(context, "PURPLE"); + assertEquals(ThemePreferences.MODE_AUTO, ThemePreferences.mode(context)); + } + + @Test + public void forcedNightBitsMatchModes() { + assertEquals(0, ThemePreferences.forcedNightBits(ThemePreferences.MODE_AUTO)); + assertEquals(Configuration.UI_MODE_NIGHT_NO, + ThemePreferences.forcedNightBits(ThemePreferences.MODE_LIGHT)); + assertEquals(Configuration.UI_MODE_NIGHT_YES, + ThemePreferences.forcedNightBits(ThemePreferences.MODE_DARK)); + } + + @Test + public void labelsAreHumanReadable() { + assertEquals("Auto (follow system)", ThemePreferences.label(ThemePreferences.MODE_AUTO)); + assertEquals("Light", ThemePreferences.label(ThemePreferences.MODE_LIGHT)); + assertEquals("Dark", ThemePreferences.label(ThemePreferences.MODE_DARK)); + } +}