diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 13eba5d..eabea54 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -24,22 +24,30 @@
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.OcheCompanion">
+
+ android:exported="false"
+ android:configChanges="uiMode" />
+ android:label="@string/title_activity_settings"
+ android:configChanges="uiMode" />
+ android:exported="false"
+ android:configChanges="uiMode" />
+ android:exported="false"
+ android:configChanges="uiMode" />
+ android:exported="true"
+ android:configChanges="uiMode">
diff --git a/app/src/main/java/com/aldo/apps/ochecompanion/AddPlayerActivity.java b/app/src/main/java/com/aldo/apps/ochecompanion/AddPlayerActivity.java
index 60cdc1f..4087f05 100644
--- a/app/src/main/java/com/aldo/apps/ochecompanion/AddPlayerActivity.java
+++ b/app/src/main/java/com/aldo/apps/ochecompanion/AddPlayerActivity.java
@@ -44,7 +44,7 @@ import java.util.UUID;
* 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.
*/
-public class AddPlayerActivity extends AppCompatActivity {
+public class AddPlayerActivity extends BaseActivity {
/**
* Tag for logging.
diff --git a/app/src/main/java/com/aldo/apps/ochecompanion/BaseActivity.java b/app/src/main/java/com/aldo/apps/ochecompanion/BaseActivity.java
new file mode 100644
index 0000000..d901583
--- /dev/null
+++ b/app/src/main/java/com/aldo/apps/ochecompanion/BaseActivity.java
@@ -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
+ );
+ }
+ }
+}
diff --git a/app/src/main/java/com/aldo/apps/ochecompanion/GameActivity.java b/app/src/main/java/com/aldo/apps/ochecompanion/GameActivity.java
index ee37acb..d8c86bc 100644
--- a/app/src/main/java/com/aldo/apps/ochecompanion/GameActivity.java
+++ b/app/src/main/java/com/aldo/apps/ochecompanion/GameActivity.java
@@ -26,7 +26,7 @@ import androidx.core.view.ViewCompat;
import androidx.core.view.WindowInsetsCompat;
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.Statistics;
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,
* 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";
@@ -237,15 +237,9 @@ public class GameActivity extends AppCompatActivity {
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;
-
- /**
- * 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 mPlayerStatsLocks = new java.util.HashMap<>();
+ private DatabaseHelper mDatabaseHelper;
/**
* Starts GameActivity with specified players and starting score.
@@ -279,19 +273,41 @@ public class GameActivity extends AppCompatActivity {
return insets;
});
mSoundEngine = SoundEngine.getInstance(this);
- mDatabase = AppDatabase.getDatabase(this);
+ mDatabaseHelper = DatabaseHelper.getInstance(this);
// Extract game parameters from intent
mStartingScore = getIntent().getIntExtra(EXTRA_START_SCORE, DartsConstants.DEFAULT_GAME_SCORE);
mMatchUuid = getIntent().getStringExtra(EXTRA_MATCH_UUID);
+
// Initialize activity components in order
initViews();
setupKeyboard();
- new Thread(() -> {
- final List allAvailablePlayers = AppDatabase.getDatabase(GameActivity.this).playerDao().getAllPlayers();
- Log.d(TAG, "onCreate: allAvailablePlayers = [" + allAvailablePlayers + "]");
- runOnUiThread(() -> setupGame(allAvailablePlayers));
- }).start();
+
+ // Only setup a new game if we're not restoring from saved state
+ if (savedInstanceState == null) {
+ new Thread(() -> {
+ final List allAvailablePlayers = (List) 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 allAvailablePlayers = (List) 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
@@ -302,6 +318,113 @@ public class GameActivity extends AppCompatActivity {
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.
*/
@@ -497,7 +620,7 @@ public class GameActivity extends AppCompatActivity {
/**
* Updates player statistics in the database after a turn.
* 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 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)
*/
private void updatePlayerStats(final GameActivity.X01State active, final int dartsThrown, final int pointsMade, final boolean wasBust, final int checkoutValue) {
- new Thread(() -> {
- final Player player = active.player;
- if (player != null && player.id != 0) {
- final Statistics playerStats = AppDatabase.getDatabase(GameActivity.this).statisticsDao().getStatisticsForPlayer(player.id);
-
- // Track darts thrown or missed
- if (wasBust) {
- // On bust, all darts in the turn are wasted
- 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();
+ if (active.player != null && active.player.id != 0) {
+ mDatabaseHelper.updatePlayerStatistics(
+ active.player.id,
+ dartsThrown,
+ pointsMade,
+ wasBust,
+ checkoutValue,
+ active.dartsThrown
+ );
+ }
}
/**
@@ -781,50 +853,33 @@ public class GameActivity extends AppCompatActivity {
/**
* Tracks a double-out attempt in player statistics.
* Records whether the attempt was successful or missed.
+ * Delegates to DatabaseHelper for thread-safe execution.
*
* @param playerState X01State of the player
* @param isMissed true if the double-out was missed, false if successful
*/
private void trackDoubleAttempt(final X01State playerState, final boolean isMissed) {
- new Thread(() -> {
- 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();
+ mDatabaseHelper.trackDoubleAttempt(playerState.playerId, isMissed);
}
/**
* Increments matchesPlayed counter for all players in the current match.
* Called when a match is completed (someone wins).
+ * Delegates to DatabaseHelper for thread-safe execution.
*/
private void incrementMatchesPlayed() {
- new Thread(() -> {
- try {
- for (X01State playerState : mPlayerStates) {
- Statistics playerStats = mDatabase.statisticsDao().getStatisticsForPlayer(playerState.playerId);
- if (playerStats != null) {
- 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();
+ final List playerIds = new ArrayList<>();
+ for (X01State playerState : mPlayerStates) {
+ playerIds.add(playerState.playerId);
+ }
+ mDatabaseHelper.incrementMatchesPlayed(playerIds);
}
/**
* Records all dart hits from a confirmed turn to the player's statistics.
* Updates the hit distribution map for heat map visualization.
* 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 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 dartHits) {
if (dartHits.isEmpty()) return;
- new Thread(() -> {
- // Get or create lock object for this player to prevent race conditions
- final Object lock;
- synchronized (mPlayerStatsLocks) {
- lock = mPlayerStatsLocks.computeIfAbsent(playerState.playerId, k -> new Object());
- }
-
- // Synchronize the read-modify-write operation for this player
- 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();
+ // Convert local DartHit to DatabaseHelper.DartHit
+ final List dbDartHits = new ArrayList<>();
+ for (DartHit hit : dartHits) {
+ dbDartHits.add(new DatabaseHelper.DartHit(hit.baseValue, hit.multiplier));
+ }
+
+ mDatabaseHelper.recordDartHits(playerState.playerId, dbDartHits);
}
/**
@@ -906,12 +941,12 @@ public class GameActivity extends AppCompatActivity {
new Thread(() -> {
try {
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(() -> {
mStatsView.bind(player, statistics);
});
} catch (Exception e) {
- Log.e(TAG, "attachPlayerStats: Failed to increment matches", e);
+ Log.e(TAG, "attachPlayerStats: Failed to retrieve player statistics", e);
}
}).start();
}
diff --git a/app/src/main/java/com/aldo/apps/ochecompanion/MainMenuActivity.java b/app/src/main/java/com/aldo/apps/ochecompanion/MainMenuActivity.java
index f5d70c7..5634277 100644
--- a/app/src/main/java/com/aldo/apps/ochecompanion/MainMenuActivity.java
+++ b/app/src/main/java/com/aldo/apps/ochecompanion/MainMenuActivity.java
@@ -8,7 +8,6 @@ import android.view.View;
import android.widget.TextView;
import androidx.activity.EdgeToEdge;
-import androidx.appcompat.app.AppCompatActivity;
import androidx.core.graphics.Insets;
import androidx.core.view.ViewCompat;
import androidx.core.view.WindowInsetsCompat;
@@ -32,7 +31,7 @@ import java.util.List;
* 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.
*/
-public class MainMenuActivity extends AppCompatActivity {
+public class MainMenuActivity extends BaseActivity {
/**
* Tag for debugging and logging purposes.
@@ -70,7 +69,7 @@ public class MainMenuActivity extends AppCompatActivity {
EdgeToEdge.enable(this);
setContentView(R.layout.activity_main);
mSettingsPref = PreferenceManager.getDefaultSharedPreferences(this);
-
+
// Configure window insets to properly handle system bars (status bar, navigation bar)
ViewCompat.setOnApplyWindowInsetsListener(findViewById(R.id.main), (v, insets) -> {
Insets systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars());
diff --git a/app/src/main/java/com/aldo/apps/ochecompanion/SettingsActivity.java b/app/src/main/java/com/aldo/apps/ochecompanion/SettingsActivity.java
index 72e0358..5a924bb 100644
--- a/app/src/main/java/com/aldo/apps/ochecompanion/SettingsActivity.java
+++ b/app/src/main/java/com/aldo/apps/ochecompanion/SettingsActivity.java
@@ -15,7 +15,7 @@ import com.aldo.apps.ochecompanion.ui.MainMenuPreferencesFragment;
* Hosts the MainMenuPreferencesFragment which displays available settings
* 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.
diff --git a/app/src/main/java/com/aldo/apps/ochecompanion/TestActivity.java b/app/src/main/java/com/aldo/apps/ochecompanion/TestActivity.java
index 12203d4..d7d374b 100644
--- a/app/src/main/java/com/aldo/apps/ochecompanion/TestActivity.java
+++ b/app/src/main/java/com/aldo/apps/ochecompanion/TestActivity.java
@@ -16,7 +16,7 @@ import com.aldo.apps.ochecompanion.ui.HeatmapView;
import java.util.List;
-public class TestActivity extends AppCompatActivity {
+public class TestActivity extends BaseActivity {
private static final String TAG = "TestActivity";
diff --git a/app/src/main/java/com/aldo/apps/ochecompanion/database/DatabaseHelper.java b/app/src/main/java/com/aldo/apps/ochecompanion/database/DatabaseHelper.java
new file mode 100644
index 0000000..131a4e4
--- /dev/null
+++ b/app/src/main/java/com/aldo/apps/ochecompanion/database/DatabaseHelper.java
@@ -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 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 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 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();
+ }
+}
diff --git a/app/src/main/java/com/aldo/apps/ochecompanion/ui/HeatmapView.java b/app/src/main/java/com/aldo/apps/ochecompanion/ui/HeatmapView.java
index 6bd5504..7ff4c19 100644
--- a/app/src/main/java/com/aldo/apps/ochecompanion/ui/HeatmapView.java
+++ b/app/src/main/java/com/aldo/apps/ochecompanion/ui/HeatmapView.java
@@ -21,6 +21,10 @@ import java.util.Map;
/**
* HeatmapView: A custom high-performance rendering component that draws a
* 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 {
@@ -114,27 +118,35 @@ public class HeatmapView extends View {
super.onDraw(canvas);
if (mStats == null) return;
- final int coldColor = Color.parseColor("#1AFFFFFF"); // Faded ghost board
- final int hotColor = ContextCompat.getColor(getContext(), R.color.volt_green);
+ // Resolve branding colors from resources
+ 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 entry : mSegmentPaths.entrySet()) {
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 float weight = mStats.getNormalizedWeight(statsKey);
- final int color = (int) mColorEvaluator.evaluate(weight, coldColor, hotColor);
+ // Check if there are any hits recorded for this segment
+ 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);
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.setStrokeWidth(1f);
- mBasePaint.setColor(Color.parseColor("#33FFFFFF"));
+ mBasePaint.setStrokeWidth(1.2f);
+ mBasePaint.setColor(Color.parseColor("#26FFFFFF"));
for (final Path p : mSegmentPaths.values()) {
canvas.drawPath(p, mBasePaint);
}
diff --git a/app/src/main/java/com/aldo/apps/ochecompanion/ui/MainMenuPreferencesFragment.java b/app/src/main/java/com/aldo/apps/ochecompanion/ui/MainMenuPreferencesFragment.java
index 3f62e48..4d1961c 100644
--- a/app/src/main/java/com/aldo/apps/ochecompanion/ui/MainMenuPreferencesFragment.java
+++ b/app/src/main/java/com/aldo/apps/ochecompanion/ui/MainMenuPreferencesFragment.java
@@ -1,7 +1,9 @@
package com.aldo.apps.ochecompanion.ui;
import android.os.Bundle;
+import android.util.Log;
+import androidx.appcompat.app.AppCompatDelegate;
import androidx.preference.Preference;
import androidx.preference.PreferenceFragmentCompat;
import androidx.preference.SwitchPreference;
@@ -15,6 +17,8 @@ import com.aldo.apps.ochecompanion.R;
*/
public class MainMenuPreferencesFragment extends PreferenceFragmentCompat {
+ private static final String TAG = "PreferencesFragment";
+
/**
* Initializes the preference screen from the main_menu_preferences XML resource.
* Called automatically by the fragment lifecycle.
@@ -36,7 +40,38 @@ public class MainMenuPreferencesFragment extends PreferenceFragmentCompat {
autoPref.setOnPreferenceChangeListener((preference, newValue) -> {
boolean isAutoEnabled = (Boolean) newValue;
+ Log.d(TAG, "Auto mode changed to: " + 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;
});
}
diff --git a/app/src/main/res/values-night/themes.xml b/app/src/main/res/values-night/themes.xml
index 631a2fa..32ef5b1 100644
--- a/app/src/main/res/values-night/themes.xml
+++ b/app/src/main/res/values-night/themes.xml
@@ -1,7 +1,28 @@
-
\ No newline at end of file
diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml
index f40ee32..1e35174 100644
--- a/app/src/main/res/values/themes.xml
+++ b/app/src/main/res/values/themes.xml
@@ -1,8 +1,29 @@
-