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
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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);
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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));
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ public class SettingsActivity extends BaseActivity {

private CandidateDb db;
private Button currencyButton;
private Button themeButton;
private Button payeeAliasesButton;
private Button batteryButton;

Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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) + ")";
Expand Down
Original file line number Diff line number Diff line change
@@ -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);
}
}
7 changes: 7 additions & 0 deletions android_app/app/src/main/res/values-night/styles.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
<resources>
<style name="AppTheme" parent="android:style/Theme.Material.NoActionBar">
<item name="android:fontFamily">sans</item>
<item name="android:windowLightStatusBar">false</item>
<item name="android:colorAccent">#7fb3ef</item>
</style>
</resources>
Original file line number Diff line number Diff line change
@@ -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));
}
}
Loading