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 @@ -