Fixed the Day/Night Mode handling in the app

This commit is contained in:
Alexander Doerflinger
2026-02-03 09:21:42 +01:00
parent 689bf2808a
commit cd0ee3ed4d
12 changed files with 750 additions and 160 deletions

View File

@@ -24,22 +24,30 @@
android:roundIcon="@mipmap/ic_launcher_round" android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true" android:supportsRtl="true"
android:theme="@style/Theme.OcheCompanion"> android:theme="@style/Theme.OcheCompanion">
<activity android:name=".BaseActivity"
android:exported="false"
android:configChanges="uiMode"/>
<activity <activity
android:name=".TestActivity" android:name=".TestActivity"
android:exported="false" /> android:exported="false"
android:configChanges="uiMode" />
<activity <activity
android:name=".SettingsActivity" android:name=".SettingsActivity"
android:exported="false" android:exported="false"
android:label="@string/title_activity_settings" /> android:label="@string/title_activity_settings"
android:configChanges="uiMode" />
<activity <activity
android:name=".GameActivity" android:name=".GameActivity"
android:exported="false" /> android:exported="false"
android:configChanges="uiMode" />
<activity <activity
android:name=".AddPlayerActivity" android:name=".AddPlayerActivity"
android:exported="false" /> android:exported="false"
android:configChanges="uiMode" />
<activity <activity
android:name=".MainMenuActivity" android:name=".MainMenuActivity"
android:exported="true"> android:exported="true"
android:configChanges="uiMode">
<intent-filter> <intent-filter>
<action android:name="android.intent.action.MAIN" /> <action android:name="android.intent.action.MAIN" />

View File

@@ -44,7 +44,7 @@ import java.util.UUID;
* Operates in Form Mode (profile editing) or Crop Mode (interactive image cropping with pan and zoom). * Operates in Form Mode (profile editing) or Crop Mode (interactive image cropping with pan and zoom).
* Pass EXTRA_PLAYER_ID to edit existing player; otherwise creates new player. * Pass EXTRA_PLAYER_ID to edit existing player; otherwise creates new player.
*/ */
public class AddPlayerActivity extends AppCompatActivity { public class AddPlayerActivity extends BaseActivity {
/** /**
* Tag for logging. * Tag for logging.

View File

@@ -0,0 +1,117 @@
package com.aldo.apps.ochecompanion;
import android.content.SharedPreferences;
import android.content.res.Configuration;
import android.os.Bundle;
import android.util.Log;
import androidx.appcompat.app.AppCompatActivity;
import androidx.appcompat.app.AppCompatDelegate;
import androidx.preference.PreferenceManager;
/**
* Base activity that handles theme management for all activities in the app.
* All activities should extend this class to ensure consistent theme behavior.
*/
public abstract class BaseActivity extends AppCompatActivity {
private static final String TAG = "BaseActivity";
/**
* SharedPreferences instance for accessing app settings.
*/
protected SharedPreferences mSettingsPref;
@Override
protected void onCreate(final Bundle savedInstanceState) {
// Apply theme before calling super to ensure correct theme is set
mSettingsPref = PreferenceManager.getDefaultSharedPreferences(this);
// Log current system UI mode
final int currentNightMode = getResources().getConfiguration().uiMode & Configuration.UI_MODE_NIGHT_MASK;
Log.d(TAG, getClass().getSimpleName() + " - onCreate: Current system night mode = " +
(currentNightMode == Configuration.UI_MODE_NIGHT_YES ? "NIGHT" : "DAY"));
Log.d(TAG, getClass().getSimpleName() + " - onCreate: Current AppCompatDelegate mode = " +
AppCompatDelegate.getDefaultNightMode());
applyThemeMode();
super.onCreate(savedInstanceState);
}
@Override
protected void onStart() {
super.onStart();
final int currentNightMode = getResources().getConfiguration().uiMode & Configuration.UI_MODE_NIGHT_MASK;
Log.d(TAG, getClass().getSimpleName() + " - onStart: System night mode = " +
(currentNightMode == Configuration.UI_MODE_NIGHT_YES ? "NIGHT" : "DAY"));
}
@Override
protected void onResume() {
super.onResume();
final int currentNightMode = getResources().getConfiguration().uiMode & Configuration.UI_MODE_NIGHT_MASK;
Log.d(TAG, getClass().getSimpleName() + " - onResume: System night mode = " +
(currentNightMode == Configuration.UI_MODE_NIGHT_YES ? "NIGHT" : "DAY"));
// Reapply theme in case preferences changed
applyThemeMode();
}
@Override
protected void onStop() {
super.onStop();
Log.d(TAG, getClass().getSimpleName() + " - onStop");
}
@Override
public void onConfigurationChanged(final Configuration newConfig) {
super.onConfigurationChanged(newConfig);
Log.d(TAG, "========================================");
Log.d(TAG, getClass().getSimpleName() + " - onConfigurationChanged CALLED!");
// Check if night mode configuration changed
final int currentNightMode = newConfig.uiMode & Configuration.UI_MODE_NIGHT_MASK;
final boolean isNightMode = currentNightMode == Configuration.UI_MODE_NIGHT_YES;
Log.d(TAG, getClass().getSimpleName() + " - onConfigurationChanged: System night mode = " + isNightMode);
Log.d(TAG, getClass().getSimpleName() + " - onConfigurationChanged: uiMode value = " + currentNightMode);
// Check if we're in auto mode
final boolean isAutoMode = mSettingsPref.getBoolean(getString(R.string.pref_key_day_night_mode_auto), true);
Log.d(TAG, getClass().getSimpleName() + " - onConfigurationChanged: Auto mode enabled = " + isAutoMode);
if (isAutoMode) {
// In auto mode - recreate activity to apply system theme
Log.d(TAG, getClass().getSimpleName() + " - onConfigurationChanged: Recreating activity to apply system theme");
recreate();
} else {
Log.d(TAG, getClass().getSimpleName() + " - onConfigurationChanged: Not in auto mode, ignoring system change");
}
Log.d(TAG, "========================================");
}
/**
* Applies the user's theme preference (auto, light, or dark mode).
* Called automatically by BaseActivity, no need to call manually.
*/
private void applyThemeMode() {
final boolean isAutoMode = mSettingsPref.getBoolean(getString(R.string.pref_key_day_night_mode_auto), true);
Log.d(TAG, getClass().getSimpleName() + " - applyThemeMode: Auto mode = " + isAutoMode);
if (isAutoMode) {
// Follow system theme
Log.d(TAG, getClass().getSimpleName() + " - applyThemeMode: Setting MODE_NIGHT_FOLLOW_SYSTEM");
AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM);
} else {
// Use manual preference
final boolean isDarkMode = mSettingsPref.getBoolean(getString(R.string.pref_key_day_night_mode), false);
Log.d(TAG, getClass().getSimpleName() + " - applyThemeMode: Manual mode - dark mode = " + isDarkMode);
AppCompatDelegate.setDefaultNightMode(
isDarkMode ? AppCompatDelegate.MODE_NIGHT_YES : AppCompatDelegate.MODE_NIGHT_NO
);
}
}
}

View File

@@ -26,7 +26,7 @@ import androidx.core.view.ViewCompat;
import androidx.core.view.WindowInsetsCompat; import androidx.core.view.WindowInsetsCompat;
import androidx.preference.PreferenceManager; import androidx.preference.PreferenceManager;
import com.aldo.apps.ochecompanion.database.AppDatabase; import com.aldo.apps.ochecompanion.database.DatabaseHelper;
import com.aldo.apps.ochecompanion.database.objects.Player; import com.aldo.apps.ochecompanion.database.objects.Player;
import com.aldo.apps.ochecompanion.database.objects.Statistics; import com.aldo.apps.ochecompanion.database.objects.Statistics;
import com.aldo.apps.ochecompanion.ui.PlayerStatsView; import com.aldo.apps.ochecompanion.ui.PlayerStatsView;
@@ -52,7 +52,7 @@ import nl.dionsegijn.konfetti.xml.KonfettiView;
* Provides numeric keyboard, real-time checkout suggestions, Double Out enforcement, * Provides numeric keyboard, real-time checkout suggestions, Double Out enforcement,
* and bust detection. Enforces standard darts rules including finishing on doubles. * and bust detection. Enforces standard darts rules including finishing on doubles.
*/ */
public class GameActivity extends AppCompatActivity { public class GameActivity extends BaseActivity {
private static final String TAG = "GameActivity"; private static final String TAG = "GameActivity";
@@ -237,15 +237,9 @@ public class GameActivity extends AppCompatActivity {
private boolean mIsAudioEnabled; private boolean mIsAudioEnabled;
/** /**
* Reference to the Room database instance for statistics and player data access. * Centralized database helper that manages all database operations with proper synchronization.
*/ */
private AppDatabase mDatabase; private DatabaseHelper mDatabaseHelper;
/**
* Locks for synchronizing statistics updates per player to prevent race conditions.
* Each player gets their own lock object to allow concurrent updates for different players.
*/
private final java.util.Map<Long, Object> mPlayerStatsLocks = new java.util.HashMap<>();
/** /**
* Starts GameActivity with specified players and starting score. * Starts GameActivity with specified players and starting score.
@@ -279,19 +273,41 @@ public class GameActivity extends AppCompatActivity {
return insets; return insets;
}); });
mSoundEngine = SoundEngine.getInstance(this); mSoundEngine = SoundEngine.getInstance(this);
mDatabase = AppDatabase.getDatabase(this); mDatabaseHelper = DatabaseHelper.getInstance(this);
// Extract game parameters from intent // Extract game parameters from intent
mStartingScore = getIntent().getIntExtra(EXTRA_START_SCORE, DartsConstants.DEFAULT_GAME_SCORE); mStartingScore = getIntent().getIntExtra(EXTRA_START_SCORE, DartsConstants.DEFAULT_GAME_SCORE);
mMatchUuid = getIntent().getStringExtra(EXTRA_MATCH_UUID); mMatchUuid = getIntent().getStringExtra(EXTRA_MATCH_UUID);
// Initialize activity components in order // Initialize activity components in order
initViews(); initViews();
setupKeyboard(); setupKeyboard();
new Thread(() -> {
final List<Player> allAvailablePlayers = AppDatabase.getDatabase(GameActivity.this).playerDao().getAllPlayers(); // Only setup a new game if we're not restoring from saved state
Log.d(TAG, "onCreate: allAvailablePlayers = [" + allAvailablePlayers + "]"); if (savedInstanceState == null) {
runOnUiThread(() -> setupGame(allAvailablePlayers)); new Thread(() -> {
}).start(); final List<Player> allAvailablePlayers = (List<Player>) mDatabaseHelper.getAllPlayers();
Log.d(TAG, "onCreate: allAvailablePlayers = [" + allAvailablePlayers + "]");
runOnUiThread(() -> setupGame(allAvailablePlayers));
}).start();
} else {
// We're restoring - load players synchronously since onRestoreInstanceState is called immediately
Log.d(TAG, "onCreate: Loading players for state restoration");
final List<Player> allAvailablePlayers = (List<Player>) mDatabaseHelper.getAllPlayers();
Log.d(TAG, "onCreate: restoring with players = [" + allAvailablePlayers + "]");
// Initialize player states structure
mPlayerStates = new ArrayList<>();
if (allAvailablePlayers != null && !allAvailablePlayers.isEmpty()) {
for (Player p : allAvailablePlayers) {
mPlayerStates.add(new X01State(p, mStartingScore));
}
} else {
final Player guest = new Player("GUEST", null);
mPlayerStates.add(new X01State(guest, mStartingScore));
}
// The actual state restoration happens in onRestoreInstanceState
}
} }
@Override @Override
@@ -302,6 +318,113 @@ public class GameActivity extends AppCompatActivity {
mIsVibrationEnabled = settingPrefs.getBoolean(getString(R.string.pref_key_vibration_feedback), true); mIsVibrationEnabled = settingPrefs.getBoolean(getString(R.string.pref_key_vibration_feedback), true);
} }
@Override
protected void onSaveInstanceState(Bundle outState) {
super.onSaveInstanceState(outState);
Log.d(TAG, "onSaveInstanceState: Saving game state");
// Save basic game state
outState.putInt("activePlayerIndex", mActivePlayerIndex);
outState.putInt("multiplier", mMultiplier);
outState.putInt("startingScore", mStartingScore);
outState.putString("matchUuid", mMatchUuid);
outState.putBoolean("isTurnOver", mIsTurnOver);
// Save current turn darts
int[] dartsArray = new int[mCurrentTurnDarts.size()];
for (int i = 0; i < mCurrentTurnDarts.size(); i++) {
dartsArray[i] = mCurrentTurnDarts.get(i);
}
outState.putIntArray("currentTurnDarts", dartsArray);
// Save current turn dart hits (base values and multipliers)
if (!mCurrentTurnDartHits.isEmpty()) {
int[] baseValues = new int[mCurrentTurnDartHits.size()];
int[] multipliers = new int[mCurrentTurnDartHits.size()];
for (int i = 0; i < mCurrentTurnDartHits.size(); i++) {
baseValues[i] = mCurrentTurnDartHits.get(i).baseValue;
multipliers[i] = mCurrentTurnDartHits.get(i).multiplier;
}
outState.putIntArray("dartHitBaseValues", baseValues);
outState.putIntArray("dartHitMultipliers", multipliers);
}
// Save player states
if (mPlayerStates != null && !mPlayerStates.isEmpty()) {
int playerCount = mPlayerStates.size();
outState.putInt("playerCount", playerCount);
long[] playerIds = new long[playerCount];
int[] remainingScores = new int[playerCount];
int[] dartsThrown = new int[playerCount];
for (int i = 0; i < playerCount; i++) {
X01State state = mPlayerStates.get(i);
playerIds[i] = state.playerId;
remainingScores[i] = state.remainingScore;
dartsThrown[i] = state.dartsThrown;
}
outState.putLongArray("playerIds", playerIds);
outState.putIntArray("remainingScores", remainingScores);
outState.putIntArray("dartsThrown", dartsThrown);
}
}
@Override
protected void onRestoreInstanceState(Bundle savedInstanceState) {
super.onRestoreInstanceState(savedInstanceState);
Log.d(TAG, "onRestoreInstanceState: Restoring game state");
// Restore basic game state
mActivePlayerIndex = savedInstanceState.getInt("activePlayerIndex", 0);
mMultiplier = savedInstanceState.getInt("multiplier", 1);
mStartingScore = savedInstanceState.getInt("startingScore", DartsConstants.DEFAULT_GAME_SCORE);
mMatchUuid = savedInstanceState.getString("matchUuid");
mIsTurnOver = savedInstanceState.getBoolean("isTurnOver", false);
// Restore current turn darts
mCurrentTurnDarts.clear();
int[] dartsArray = savedInstanceState.getIntArray("currentTurnDarts");
if (dartsArray != null) {
for (int dart : dartsArray) {
mCurrentTurnDarts.add(dart);
}
}
// Restore current turn dart hits
mCurrentTurnDartHits.clear();
int[] baseValues = savedInstanceState.getIntArray("dartHitBaseValues");
int[] multipliers = savedInstanceState.getIntArray("dartHitMultipliers");
if (baseValues != null && multipliers != null) {
for (int i = 0; i < baseValues.length; i++) {
mCurrentTurnDartHits.add(new DartHit(baseValues[i], multipliers[i]));
}
}
// Restore player states
int playerCount = savedInstanceState.getInt("playerCount", 0);
if (playerCount > 0 && mPlayerStates != null) {
long[] playerIds = savedInstanceState.getLongArray("playerIds");
int[] remainingScores = savedInstanceState.getIntArray("remainingScores");
int[] dartsThrown = savedInstanceState.getIntArray("dartsThrown");
if (playerIds != null && remainingScores != null && dartsThrown != null) {
for (int i = 0; i < playerCount && i < mPlayerStates.size(); i++) {
mPlayerStates.get(i).remainingScore = remainingScores[i];
mPlayerStates.get(i).dartsThrown = dartsThrown[i];
}
}
}
// Update UI to reflect restored state (only if mPlayerStates is initialized)
if (mPlayerStates != null && !mPlayerStates.isEmpty()) {
updateUI();
updateTurnIndicators();
setMultiplier(mMultiplier);
}
}
/** /**
* Initializes UI component references and sets up click listeners. * Initializes UI component references and sets up click listeners.
*/ */
@@ -497,7 +620,7 @@ public class GameActivity extends AppCompatActivity {
/** /**
* Updates player statistics in the database after a turn. * Updates player statistics in the database after a turn.
* Tracks darts thrown, points made, missed darts, and updates career average. * Tracks darts thrown, points made, missed darts, and updates career average.
* Runs on background thread to avoid blocking UI. * Delegates to DatabaseHelper for thread-safe execution.
* *
* @param active Current player's game state * @param active Current player's game state
* @param dartsThrown Number of darts thrown this turn * @param dartsThrown Number of darts thrown this turn
@@ -519,67 +642,16 @@ public class GameActivity extends AppCompatActivity {
* @param checkoutValue The checkout score if this was a winning turn (0 if not a checkout) * @param checkoutValue The checkout score if this was a winning turn (0 if not a checkout)
*/ */
private void updatePlayerStats(final GameActivity.X01State active, final int dartsThrown, final int pointsMade, final boolean wasBust, final int checkoutValue) { private void updatePlayerStats(final GameActivity.X01State active, final int dartsThrown, final int pointsMade, final boolean wasBust, final int checkoutValue) {
new Thread(() -> { if (active.player != null && active.player.id != 0) {
final Player player = active.player; mDatabaseHelper.updatePlayerStatistics(
if (player != null && player.id != 0) { active.player.id,
final Statistics playerStats = AppDatabase.getDatabase(GameActivity.this).statisticsDao().getStatisticsForPlayer(player.id); dartsThrown,
pointsMade,
// Track darts thrown or missed wasBust,
if (wasBust) { checkoutValue,
// On bust, all darts in the turn are wasted active.dartsThrown
playerStats.addMissedDarts(dartsThrown); );
Log.d(TAG, "updatePlayerStats: Bust! Recorded " + dartsThrown + " missed darts"); }
} else {
// Normal turn - record darts and points
playerStats.saveDartsThrown(dartsThrown, pointsMade);
Log.d(TAG, "updatePlayerStats: dartsThrown = [" + dartsThrown + "], pointsMade = [" + pointsMade + "]");
// Track missed darts if turn ended early (less than 3 darts)
if (dartsThrown < 3) {
playerStats.addMissedDarts(3 - dartsThrown);
}
}
// Track scoring milestones (60+, 100+, 140+, 180)
if (pointsMade >= 180) {
playerStats.setCount180(playerStats.getCount180() + 1);
Log.d(TAG, "updatePlayerStats: Perfect 180! Total: " + playerStats.getCount180());
} else if (pointsMade >= 140) {
playerStats.setCount140Plus(playerStats.getCount140Plus() + 1);
} else if (pointsMade >= 100) {
playerStats.setCount100Plus(playerStats.getCount100Plus() + 1);
} else if (pointsMade >= 60) {
playerStats.setCount60Plus(playerStats.getCount60Plus() + 1);
}
// Track first 9 darts statistics (first 3 turns of the match)
if (active.dartsThrown < 9) {
final long dartsToAdd = Math.min(dartsThrown, 9 - active.dartsThrown);
playerStats.setTotalFirst9Darts(playerStats.getTotalFirst9Darts() + dartsToAdd);
playerStats.setTotalFirst9Points(playerStats.getTotalFirst9Points() + pointsMade);
Log.d(TAG, "updatePlayerStats: First 9 tracking - darts: " + dartsToAdd + ", points: " + pointsMade);
}
// Track successful checkout
if (checkoutValue > 0) {
playerStats.setSuccessfulCheckouts(playerStats.getSuccessfulCheckouts() + 1);
playerStats.setHighestCheckout(checkoutValue);
Log.d(TAG, "updatePlayerStats: Checkout tracked - value: " + checkoutValue);
}
Log.d(TAG, "updatePlayerStats: statistics = [" + playerStats + "]");
AppDatabase.getDatabase(GameActivity.this).statisticsDao().updateStatistics(playerStats);
// Calculate career average: total points / total darts thrown
final long totalDarts = playerStats.getTotalDartsThrown();
if (totalDarts > 0) {
player.careerAverage = (double) playerStats.getTotalPoints() / totalDarts * 3;
} else {
player.careerAverage = 0.0;
}
AppDatabase.getDatabase(GameActivity.this).playerDao().update(player);
}
}).start();
} }
/** /**
@@ -781,50 +853,33 @@ public class GameActivity extends AppCompatActivity {
/** /**
* Tracks a double-out attempt in player statistics. * Tracks a double-out attempt in player statistics.
* Records whether the attempt was successful or missed. * Records whether the attempt was successful or missed.
* Delegates to DatabaseHelper for thread-safe execution.
* *
* @param playerState X01State of the player * @param playerState X01State of the player
* @param isMissed true if the double-out was missed, false if successful * @param isMissed true if the double-out was missed, false if successful
*/ */
private void trackDoubleAttempt(final X01State playerState, final boolean isMissed) { private void trackDoubleAttempt(final X01State playerState, final boolean isMissed) {
new Thread(() -> { mDatabaseHelper.trackDoubleAttempt(playerState.playerId, isMissed);
try {
Statistics playerStats = mDatabase.statisticsDao().getStatisticsForPlayer(playerState.playerId);
if (playerStats != null) {
playerStats.addDoubleOutTarget(isMissed);
mDatabase.statisticsDao().updateStatistics(playerStats);
Log.d(TAG, "trackDoubleAttempt: Recorded double attempt (missed=" + isMissed + ") for player " + playerState.name);
}
} catch (Exception e) {
Log.e(TAG, "trackDoubleAttempt: Failed to track double attempt", e);
}
}).start();
} }
/** /**
* Increments matchesPlayed counter for all players in the current match. * Increments matchesPlayed counter for all players in the current match.
* Called when a match is completed (someone wins). * Called when a match is completed (someone wins).
* Delegates to DatabaseHelper for thread-safe execution.
*/ */
private void incrementMatchesPlayed() { private void incrementMatchesPlayed() {
new Thread(() -> { final List<Long> playerIds = new ArrayList<>();
try { for (X01State playerState : mPlayerStates) {
for (X01State playerState : mPlayerStates) { playerIds.add(playerState.playerId);
Statistics playerStats = mDatabase.statisticsDao().getStatisticsForPlayer(playerState.playerId); }
if (playerStats != null) { mDatabaseHelper.incrementMatchesPlayed(playerIds);
playerStats.addCompletedMatch();
mDatabase.statisticsDao().updateStatistics(playerStats);
Log.d(TAG, "incrementMatchesPlayed: Incremented for player " + playerState.name + ", total: " + playerStats.getMatchesPlayed());
}
}
} catch (Exception e) {
Log.e(TAG, "incrementMatchesPlayed: Failed to increment matches", e);
}
}).start();
} }
/** /**
* Records all dart hits from a confirmed turn to the player's statistics. * Records all dart hits from a confirmed turn to the player's statistics.
* Updates the hit distribution map for heat map visualization. * Updates the hit distribution map for heat map visualization.
* Only called after turn is submitted to avoid recording unconfirmed throws. * Only called after turn is submitted to avoid recording unconfirmed throws.
* Delegates to DatabaseHelper for thread-safe execution.
* *
* @param playerState X01State of the player * @param playerState X01State of the player
* @param dartHits List of dart hit details from the turn * @param dartHits List of dart hit details from the turn
@@ -832,33 +887,13 @@ public class GameActivity extends AppCompatActivity {
private void recordTurnHitsToStatistics(final X01State playerState, final List<DartHit> dartHits) { private void recordTurnHitsToStatistics(final X01State playerState, final List<DartHit> dartHits) {
if (dartHits.isEmpty()) return; if (dartHits.isEmpty()) return;
new Thread(() -> { // Convert local DartHit to DatabaseHelper.DartHit
// Get or create lock object for this player to prevent race conditions final List<DatabaseHelper.DartHit> dbDartHits = new ArrayList<>();
final Object lock; for (DartHit hit : dartHits) {
synchronized (mPlayerStatsLocks) { dbDartHits.add(new DatabaseHelper.DartHit(hit.baseValue, hit.multiplier));
lock = mPlayerStatsLocks.computeIfAbsent(playerState.playerId, k -> new Object()); }
}
// Synchronize the read-modify-write operation for this player mDatabaseHelper.recordDartHits(playerState.playerId, dbDartHits);
synchronized (lock) {
try {
Statistics playerStats = mDatabase.statisticsDao().getStatisticsForPlayer(playerState.playerId);
if (playerStats != null) {
Log.d(TAG, "recordTurnHitsToStatistics: Before recording - hitDistribution size: " + playerStats.getHitDistribution().size());
// Record all darts from this turn
for (DartHit hit : dartHits) {
playerStats.recordDartHit(hit.baseValue, hit.multiplier);
}
Log.d(TAG, "recordTurnHitsToStatistics: After recording - hitDistribution size: " + playerStats.getHitDistribution().size());
Log.d(TAG, "recordTurnHitsToStatistics: hitDistribution contents: " + playerStats.getHitDistribution());
mDatabase.statisticsDao().updateStatistics(playerStats);
Log.d(TAG, "recordTurnHitsToStatistics: Recorded " + dartHits.size() + " darts for player " + playerState.name);
}
} catch (Exception e) {
Log.e(TAG, "recordTurnHitsToStatistics: Failed to record hits", e);
}
}
}).start();
} }
/** /**
@@ -906,12 +941,12 @@ public class GameActivity extends AppCompatActivity {
new Thread(() -> { new Thread(() -> {
try { try {
final Player player = mPlayerStates.get(mActivePlayerIndex).player; final Player player = mPlayerStates.get(mActivePlayerIndex).player;
final Statistics statistics = AppDatabase.getDatabase(GameActivity.this).statisticsDao().getStatisticsForPlayer(player.id); final Statistics statistics = mDatabaseHelper.getStatisticsForPlayer(player.id);
runOnUiThread(() -> { runOnUiThread(() -> {
mStatsView.bind(player, statistics); mStatsView.bind(player, statistics);
}); });
} catch (Exception e) { } catch (Exception e) {
Log.e(TAG, "attachPlayerStats: Failed to increment matches", e); Log.e(TAG, "attachPlayerStats: Failed to retrieve player statistics", e);
} }
}).start(); }).start();
} }

View File

@@ -8,7 +8,6 @@ import android.view.View;
import android.widget.TextView; import android.widget.TextView;
import androidx.activity.EdgeToEdge; import androidx.activity.EdgeToEdge;
import androidx.appcompat.app.AppCompatActivity;
import androidx.core.graphics.Insets; import androidx.core.graphics.Insets;
import androidx.core.view.ViewCompat; import androidx.core.view.ViewCompat;
import androidx.core.view.WindowInsetsCompat; import androidx.core.view.WindowInsetsCompat;
@@ -32,7 +31,7 @@ import java.util.List;
* Main entry point and home screen of the Oche Companion application. * Main entry point and home screen of the Oche Companion application.
* Displays the squad of players, allows adding new players, and shows match recap with test data. * Displays the squad of players, allows adding new players, and shows match recap with test data.
*/ */
public class MainMenuActivity extends AppCompatActivity { public class MainMenuActivity extends BaseActivity {
/** /**
* Tag for debugging and logging purposes. * Tag for debugging and logging purposes.

View File

@@ -15,7 +15,7 @@ import com.aldo.apps.ochecompanion.ui.MainMenuPreferencesFragment;
* Hosts the MainMenuPreferencesFragment which displays available settings * Hosts the MainMenuPreferencesFragment which displays available settings
* including day/night mode and standard game mode selection. * including day/night mode and standard game mode selection.
*/ */
public class SettingsActivity extends AppCompatActivity { public class SettingsActivity extends BaseActivity {
/** /**
* Initializes the settings activity and loads the preferences fragment. * Initializes the settings activity and loads the preferences fragment.

View File

@@ -16,7 +16,7 @@ import com.aldo.apps.ochecompanion.ui.HeatmapView;
import java.util.List; import java.util.List;
public class TestActivity extends AppCompatActivity { public class TestActivity extends BaseActivity {
private static final String TAG = "TestActivity"; private static final String TAG = "TestActivity";

View File

@@ -0,0 +1,342 @@
package com.aldo.apps.ochecompanion.database;
import android.content.Context;
import android.util.Log;
import com.aldo.apps.ochecompanion.database.objects.Player;
import com.aldo.apps.ochecompanion.database.objects.Statistics;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
/**
* Centralized database helper that manages all database operations with proper synchronization.
* Handles threading internally to prevent race conditions and simplify database access.
* All database operations are executed on a background thread pool with per-player locking
* to ensure data integrity while allowing concurrent updates for different players.
*/
public class DatabaseHelper {
/**
* Tag for debugging purposes.
*/
private static final String TAG = "DatabaseHelper";
/**
* Singleton instance of DatabaseHelper.
*/
private static volatile DatabaseHelper sInstance;
/**
* Reference to the Room database.
*/
private final AppDatabase mDatabase;
/**
* Single-threaded executor for database operations to ensure sequential execution.
* Prevents race conditions by serializing all database writes.
*/
private final ExecutorService mExecutor;
/**
* Per-player locks for fine-grained synchronization.
* Allows concurrent updates for different players while preventing conflicts for the same player.
*/
private final Map<Long, Object> mPlayerLocks = new HashMap<>();
/**
* Private constructor for singleton pattern.
*
* @param context Application context
*/
private DatabaseHelper(final Context context) {
mDatabase = AppDatabase.getDatabase(context.getApplicationContext());
mExecutor = Executors.newSingleThreadExecutor();
}
/**
* Gets the singleton DatabaseHelper instance.
*
* @param context Context used to initialize the database
* @return Singleton DatabaseHelper instance
*/
public static DatabaseHelper getInstance(final Context context) {
if (sInstance == null) {
synchronized (DatabaseHelper.class) {
if (sInstance == null) {
sInstance = new DatabaseHelper(context);
}
}
}
return sInstance;
}
/**
* Gets or creates a lock object for a specific player.
* Thread-safe method to retrieve player-specific locks.
*
* @param playerId The player's ID
* @return Lock object for the specified player
*/
private Object getPlayerLock(final long playerId) {
synchronized (mPlayerLocks) {
return mPlayerLocks.computeIfAbsent(playerId, k -> new Object());
}
}
/**
* Updates player statistics after a turn.
* Handles darts thrown, points made, bust tracking, and milestone counting.
*
* @param playerId Player's database ID
* @param dartsThrown Number of darts thrown this turn
* @param pointsMade Total points scored this turn
* @param wasBust Whether the turn resulted in a bust
* @param checkoutValue The checkout score if this was a winning turn (0 if not a checkout)
* @param totalDartsThrownInMatch Total darts thrown by player in current match (for first 9 tracking)
*/
public void updatePlayerStatistics(final long playerId, final int dartsThrown, final int pointsMade,
final boolean wasBust, final int checkoutValue, final int totalDartsThrownInMatch) {
mExecutor.execute(() -> {
final Object lock = getPlayerLock(playerId);
synchronized (lock) {
try {
final Statistics playerStats = mDatabase.statisticsDao().getStatisticsForPlayer(playerId);
if (playerStats == null) {
Log.w(TAG, "updatePlayerStatistics: No statistics found for player " + playerId);
return;
}
// Track darts thrown or missed
if (wasBust) {
// On bust, all darts in the turn are wasted
playerStats.addMissedDarts(dartsThrown);
Log.d(TAG, "updatePlayerStatistics: Bust! Recorded " + dartsThrown + " missed darts");
} else {
// Normal turn - record darts and points
playerStats.saveDartsThrown(dartsThrown, pointsMade);
Log.d(TAG, "updatePlayerStatistics: dartsThrown = [" + dartsThrown + "], pointsMade = [" + pointsMade + "]");
// Track missed darts if turn ended early (less than 3 darts)
if (dartsThrown < 3) {
playerStats.addMissedDarts(3 - dartsThrown);
}
}
// Track scoring milestones (60+, 100+, 140+, 180)
if (pointsMade >= 180) {
playerStats.setCount180(playerStats.getCount180() + 1);
Log.d(TAG, "updatePlayerStatistics: Perfect 180! Total: " + playerStats.getCount180());
} else if (pointsMade >= 140) {
playerStats.setCount140Plus(playerStats.getCount140Plus() + 1);
} else if (pointsMade >= 100) {
playerStats.setCount100Plus(playerStats.getCount100Plus() + 1);
} else if (pointsMade >= 60) {
playerStats.setCount60Plus(playerStats.getCount60Plus() + 1);
}
// Track first 9 darts statistics (first 3 turns of the match)
if (totalDartsThrownInMatch < 9) {
final long dartsToAdd = Math.min(dartsThrown, 9 - totalDartsThrownInMatch);
playerStats.setTotalFirst9Darts(playerStats.getTotalFirst9Darts() + dartsToAdd);
playerStats.setTotalFirst9Points(playerStats.getTotalFirst9Points() + pointsMade);
Log.d(TAG, "updatePlayerStatistics: First 9 tracking - darts: " + dartsToAdd + ", points: " + pointsMade);
}
// Track successful checkout
if (checkoutValue > 0) {
playerStats.setSuccessfulCheckouts(playerStats.getSuccessfulCheckouts() + 1);
playerStats.setHighestCheckout(checkoutValue);
Log.d(TAG, "updatePlayerStatistics: Checkout tracked - value: " + checkoutValue);
}
Log.d(TAG, "updatePlayerStatistics: statistics = [" + playerStats + "]");
mDatabase.statisticsDao().updateStatistics(playerStats);
// Update player's career average
final Player player = mDatabase.playerDao().getPlayerById(playerId);
if (player != null) {
final long totalDarts = playerStats.getTotalDartsThrown();
if (totalDarts > 0) {
player.careerAverage = (double) playerStats.getTotalPoints() / totalDarts * 3;
} else {
player.careerAverage = 0.0;
}
mDatabase.playerDao().update(player);
}
} catch (Exception e) {
Log.e(TAG, "updatePlayerStatistics: Failed to update statistics", e);
}
}
});
}
/**
* Tracks a double-out attempt in player statistics.
* Records whether the attempt was successful or missed.
*
* @param playerId Player's database ID
* @param isMissed true if the double-out was missed, false if successful
*/
public void trackDoubleAttempt(final long playerId, final boolean isMissed) {
mExecutor.execute(() -> {
final Object lock = getPlayerLock(playerId);
synchronized (lock) {
try {
final Statistics playerStats = mDatabase.statisticsDao().getStatisticsForPlayer(playerId);
if (playerStats != null) {
playerStats.addDoubleOutTarget(isMissed);
mDatabase.statisticsDao().updateStatistics(playerStats);
Log.d(TAG, "trackDoubleAttempt: Recorded double attempt (missed=" + isMissed + ") for player " + playerId);
}
} catch (Exception e) {
Log.e(TAG, "trackDoubleAttempt: Failed to track double attempt", e);
}
}
});
}
/**
* Increments matchesPlayed counter for all specified players.
* Called when a match is completed (someone wins).
*
* @param playerIds List of player IDs to update
*/
public void incrementMatchesPlayed(final List<Long> playerIds) {
mExecutor.execute(() -> {
for (final long playerId : playerIds) {
final Object lock = getPlayerLock(playerId);
synchronized (lock) {
try {
final Statistics playerStats = mDatabase.statisticsDao().getStatisticsForPlayer(playerId);
if (playerStats != null) {
playerStats.addCompletedMatch();
mDatabase.statisticsDao().updateStatistics(playerStats);
Log.d(TAG, "incrementMatchesPlayed: Incremented for player " + playerId + ", total: " + playerStats.getMatchesPlayed());
}
} catch (Exception e) {
Log.e(TAG, "incrementMatchesPlayed: Failed to increment matches for player " + playerId, e);
}
}
}
});
}
/**
* Represents a single dart hit with base value and multiplier.
*/
public static class DartHit {
/**
* The dartboard number hit (1-20 or 25 for bull).
*/
public final int baseValue;
/**
* The multiplier applied to the base value (1=single, 2=double, 3=triple).
*/
public final int multiplier;
/**
* Constructs a DartHit with the specified base value and multiplier.
*
* @param baseValue The dartboard number (1-20 or 25 for bull)
* @param multiplier The multiplier (1=single, 2=double, 3=triple)
*/
public DartHit(final int baseValue, final int multiplier) {
this.baseValue = baseValue;
this.multiplier = multiplier;
}
}
/**
* Records all dart hits from a confirmed turn to the player's statistics.
* Updates the hit distribution map for heat map visualization.
*
* @param playerId Player's database ID
* @param dartHits List of dart hit details from the turn
*/
public void recordDartHits(final long playerId, final List<DartHit> dartHits) {
if (dartHits == null || dartHits.isEmpty()) return;
mExecutor.execute(() -> {
final Object lock = getPlayerLock(playerId);
synchronized (lock) {
try {
final Statistics playerStats = mDatabase.statisticsDao().getStatisticsForPlayer(playerId);
if (playerStats != null) {
Log.d(TAG, "recordDartHits: Before recording - hitDistribution size: " + playerStats.getHitDistribution().size());
// Record all darts from this turn
for (final DartHit hit : dartHits) {
playerStats.recordDartHit(hit.baseValue, hit.multiplier);
}
Log.d(TAG, "recordDartHits: After recording - hitDistribution size: " + playerStats.getHitDistribution().size());
Log.d(TAG, "recordDartHits: hitDistribution contents: " + playerStats.getHitDistribution());
mDatabase.statisticsDao().updateStatistics(playerStats);
Log.d(TAG, "recordDartHits: Recorded " + dartHits.size() + " darts for player " + playerId);
}
} catch (Exception e) {
Log.e(TAG, "recordDartHits: Failed to record hits", e);
}
}
});
}
/**
* Retrieves all players from the database synchronously.
* Blocks until the operation completes to ensure consistency with any pending writes.
*
* @return List of all players, or empty list if none exist
*/
public List<?> getAllPlayers() {
try {
return mExecutor.submit(() -> {
try {
return mDatabase.playerDao().getAllPlayers();
} catch (Exception e) {
Log.e(TAG, "getAllPlayers: Failed to retrieve players", e);
return new java.util.ArrayList<>();
}
}).get();
} catch (Exception e) {
Log.e(TAG, "getAllPlayers: Failed to submit task", e);
return new java.util.ArrayList<>();
}
}
/**
* Retrieves statistics for a specific player synchronously.
* Blocks until the operation completes to ensure consistency with any pending writes.
*
* @param playerId The player's database ID
* @return Statistics object for the player, or null if not found
*/
public Statistics getStatisticsForPlayer(final long playerId) {
final Object lock = getPlayerLock(playerId);
try {
return mExecutor.submit(() -> {
synchronized (lock) {
try {
return mDatabase.statisticsDao().getStatisticsForPlayer(playerId);
} catch (Exception e) {
Log.e(TAG, "getStatisticsForPlayer: Failed to retrieve statistics for player " + playerId, e);
return null;
}
}
}).get();
} catch (Exception e) {
Log.e(TAG, "getStatisticsForPlayer: Failed to submit task", e);
return null;
}
}
/**
* Shuts down the executor service.
* Should be called when the helper is no longer needed.
*/
public void shutdown() {
mExecutor.shutdown();
}
}

View File

@@ -21,6 +21,10 @@ import java.util.Map;
/** /**
* HeatmapView: A custom high-performance rendering component that draws a * HeatmapView: A custom high-performance rendering component that draws a
* dartboard and overlays player performance data as a color-coded heatmap. * dartboard and overlays player performance data as a color-coded heatmap.
* Optimized Palette:
* - Zero hits: Subtle semi-transparent "ghost" segments.
* - Low frequency: Volt Green (Starting "cool" color).
* - High frequency: Double Red (Intense "hot" color).
*/ */
public class HeatmapView extends View { public class HeatmapView extends View {
@@ -114,27 +118,35 @@ public class HeatmapView extends View {
super.onDraw(canvas); super.onDraw(canvas);
if (mStats == null) return; if (mStats == null) return;
final int coldColor = Color.parseColor("#1AFFFFFF"); // Faded ghost board // Resolve branding colors from resources
final int hotColor = ContextCompat.getColor(getContext(), R.color.volt_green); int coldColor = ContextCompat.getColor(getContext(), R.color.volt_green);
int hotColor = ContextCompat.getColor(getContext(), R.color.double_red);
int emptyColor = Color.parseColor("#1AFFFFFF"); // Subtle ghost segments for zero data
// Draw every calculated path with heat-weighted colors
for (final Map.Entry<String, Path> entry : mSegmentPaths.entrySet()) { for (final Map.Entry<String, Path> entry : mSegmentPaths.entrySet()) {
final String key = entry.getKey(); final String key = entry.getKey();
// Handle the "Split Single" segments by mapping them to the same "sX" key
final String statsKey = key.contains("_") ? key.substring(0, key.indexOf("_")) : key; final String statsKey = key.contains("_") ? key.substring(0, key.indexOf("_")) : key;
final float weight = mStats.getNormalizedWeight(statsKey); // Check if there are any hits recorded for this segment
final int color = (int) mColorEvaluator.evaluate(weight, coldColor, hotColor); final Integer hitCount = mStats.getHitDistribution().get(statsKey);
int color;
if (hitCount == null || hitCount == 0) {
color = emptyColor;
} else {
// Fetch the normalized heat (0.0 to 1.0) and evaluate against Green -> Red
final float weight = mStats.getNormalizedWeight(statsKey);
color = (int) mColorEvaluator.evaluate(weight, coldColor, hotColor);
}
mBasePaint.setColor(color); mBasePaint.setColor(color);
canvas.drawPath(entry.getValue(), mBasePaint); canvas.drawPath(entry.getValue(), mBasePaint);
} }
// Final aesthetic: draw a thin wireframe over the segments // Final wireframe overlay for professional aesthetics
mBasePaint.setStyle(Paint.Style.STROKE); mBasePaint.setStyle(Paint.Style.STROKE);
mBasePaint.setStrokeWidth(1f); mBasePaint.setStrokeWidth(1.2f);
mBasePaint.setColor(Color.parseColor("#33FFFFFF")); mBasePaint.setColor(Color.parseColor("#26FFFFFF"));
for (final Path p : mSegmentPaths.values()) { for (final Path p : mSegmentPaths.values()) {
canvas.drawPath(p, mBasePaint); canvas.drawPath(p, mBasePaint);
} }

View File

@@ -1,7 +1,9 @@
package com.aldo.apps.ochecompanion.ui; package com.aldo.apps.ochecompanion.ui;
import android.os.Bundle; import android.os.Bundle;
import android.util.Log;
import androidx.appcompat.app.AppCompatDelegate;
import androidx.preference.Preference; import androidx.preference.Preference;
import androidx.preference.PreferenceFragmentCompat; import androidx.preference.PreferenceFragmentCompat;
import androidx.preference.SwitchPreference; import androidx.preference.SwitchPreference;
@@ -15,6 +17,8 @@ import com.aldo.apps.ochecompanion.R;
*/ */
public class MainMenuPreferencesFragment extends PreferenceFragmentCompat { public class MainMenuPreferencesFragment extends PreferenceFragmentCompat {
private static final String TAG = "PreferencesFragment";
/** /**
* Initializes the preference screen from the main_menu_preferences XML resource. * Initializes the preference screen from the main_menu_preferences XML resource.
* Called automatically by the fragment lifecycle. * Called automatically by the fragment lifecycle.
@@ -36,7 +40,38 @@ public class MainMenuPreferencesFragment extends PreferenceFragmentCompat {
autoPref.setOnPreferenceChangeListener((preference, newValue) -> { autoPref.setOnPreferenceChangeListener((preference, newValue) -> {
boolean isAutoEnabled = (Boolean) newValue; boolean isAutoEnabled = (Boolean) newValue;
Log.d(TAG, "Auto mode changed to: " + isAutoEnabled);
manualPref.setEnabled(!isAutoEnabled); manualPref.setEnabled(!isAutoEnabled);
// Apply theme immediately and recreate activity to ensure system theme is detected
if (isAutoEnabled) {
Log.d(TAG, "Switching to MODE_NIGHT_FOLLOW_SYSTEM and recreating");
AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM);
// Recreate activity to properly apply system theme
requireActivity().recreate();
} else {
// Use current manual preference
boolean isDarkMode = manualPref.isChecked();
Log.d(TAG, "Switching to manual mode, dark mode = " + isDarkMode);
AppCompatDelegate.setDefaultNightMode(
isDarkMode ? AppCompatDelegate.MODE_NIGHT_YES : AppCompatDelegate.MODE_NIGHT_NO
);
}
return true;
});
manualPref.setOnPreferenceChangeListener((preference, newValue) -> {
boolean isDarkMode = (Boolean) newValue;
Log.d(TAG, "Manual dark mode changed to: " + isDarkMode);
// Only apply if auto mode is disabled
if (!autoPref.isChecked()) {
Log.d(TAG, "Applying manual theme change");
AppCompatDelegate.setDefaultNightMode(
isDarkMode ? AppCompatDelegate.MODE_NIGHT_YES : AppCompatDelegate.MODE_NIGHT_NO
);
} else {
Log.d(TAG, "Ignoring manual change - auto mode is enabled");
}
return true; return true;
}); });
} }

View File

@@ -1,7 +1,28 @@
<resources xmlns:tools="http://schemas.android.com/tools"> <resources xmlns:tools="http://schemas.android.com/tools">
<!-- Base application theme. --> <!-- Base application theme. -->
<style name="Base.Theme.OcheCompanion" parent="Theme.Material3.DayNight.NoActionBar"> <style name="Base.Theme.OcheCompanion" parent="Theme.Material3.Dark.NoActionBar">
<!-- Customize your dark theme here. --> <!-- Primary brand color -->
<!-- <item name="colorPrimary">@color/my_dark_primary</item> --> <item name="colorPrimary">@color/volt_green</item>
<item name="colorPrimaryVariant">@color/volt_green</item>
<item name="colorOnPrimary">@color/midnight_black</item>
<!-- Secondary brand color -->
<item name="colorSecondary">@color/volt_green</item>
<item name="colorSecondaryVariant">@color/volt_green</item>
<item name="colorOnSecondary">@color/midnight_black</item>
<!-- Background colors -->
<item name="android:colorBackground">@color/background_primary</item>
<item name="colorSurface">@color/surface_primary</item>
<item name="colorOnBackground">@color/text_primary</item>
<item name="colorOnSurface">@color/text_primary</item>
<!-- Status bar -->
<item name="android:statusBarColor">@color/background_primary</item>
<item name="android:windowLightStatusBar">false</item>
<!-- Navigation bar -->
<item name="android:navigationBarColor">@color/surface_primary</item>
<item name="android:windowLightNavigationBar">false</item>
</style> </style>
</resources> </resources>

View File

@@ -1,8 +1,29 @@
<resources xmlns:tools="http://schemas.android.com/tools"> <resources xmlns:tools="http://schemas.android.com/tools">
<!-- Base application theme. --> <!-- Base application theme. -->
<style name="Base.Theme.OcheCompanion" parent="Theme.Material3.DayNight.NoActionBar"> <style name="Base.Theme.OcheCompanion" parent="Theme.Material3.Light.NoActionBar">
<!-- Customize your light theme here. --> <!-- Primary brand color -->
<!-- <item name="colorPrimary">@color/my_light_primary</item> --> <item name="colorPrimary">@color/volt_green</item>
<item name="colorPrimaryVariant">@color/volt_green</item>
<item name="colorOnPrimary">@color/text_on_volt</item>
<!-- Secondary brand color -->
<item name="colorSecondary">@color/volt_green</item>
<item name="colorSecondaryVariant">@color/volt_green</item>
<item name="colorOnSecondary">@color/text_on_volt</item>
<!-- Background colors -->
<item name="android:colorBackground">@color/background_primary</item>
<item name="colorSurface">@color/surface_primary</item>
<item name="colorOnBackground">@color/text_primary</item>
<item name="colorOnSurface">@color/text_primary</item>
<!-- Status bar -->
<item name="android:statusBarColor">@color/background_primary</item>
<item name="android:windowLightStatusBar">true</item>
<!-- Navigation bar -->
<item name="android:navigationBarColor">@color/surface_primary</item>
<item name="android:windowLightNavigationBar">true</item>
</style> </style>
<style name="Theme.OcheCompanion" parent="Base.Theme.OcheCompanion" /> <style name="Theme.OcheCompanion" parent="Base.Theme.OcheCompanion" />