From 5e627aa50cea45a0bef863d3c9c7e41da4178e85 Mon Sep 17 00:00:00 2001 From: Alexander Doerflinger Date: Mon, 2 Feb 2026 14:27:14 +0100 Subject: [PATCH] feat: Implement comprehensive player statistics with hit distribution heatmap This commit introduces a complete statistics tracking and visualization system for the Oche Companion darts app, addressing multiple race condition issues and enhancing user experience with audio/vibration feedback controls. MAJOR FEATURES: Statistics & Database (Breaking Changes) - Upgraded Room database from v10 to v12 with destructive migration - Added hit distribution map (Map) to Statistics entity - Implemented HitDistributionConverter with Gson for Map<->JSON persistence - Registered TypeConverter at database level for automatic conversion - Expanded Statistics entity to track: * Scoring milestones (60+, 100+, 140+, 180) * First 9 darts average (starting consistency metric) * Checkout statistics (successful finishes, highest checkout) * Double-out attempt tracking (success/miss rates) * Hit distribution for heat map visualization * Matches played counter GameActivity Enhancements - Added DartHit inner class to track baseValue and multiplier per dart - Implemented parallel tracking with mCurrentTurnDartHits list - Created recordTurnHitsToStatistics() for hit distribution updates - Added trackDoubleAttempt() for double-out success/failure recording - Added incrementMatchesPlayed() called on match completion - Fixed checkout value calculation (final dart, not full turn score) - Fixed bust tracking (addMissedDarts instead of saveDartsThrown) - Renamed Statistics getters/setters for consistency: * dartsThrown -> totalDartsThrown * overallPointsMade -> totalPoints * doubleOutsTargeted -> totalDartsAtDouble CRITICAL BUG FIXES: Race Condition Resolution - Fixed list clearing race: Pass copies (new ArrayList<>) to background threads - Fixed database race: Added per-player synchronization locks (mPlayerStatsLocks HashMap) - Implemented double synchronized block pattern: 1. synchronized(mPlayerStatsLocks) to get/create player lock 2. synchronized(lock) to protect entire READ-MODIFY-WRITE operation - Allows concurrent updates for different players while preventing data corruption User Feedback System - Added audio/vibration feedback toggle preferences - Implemented SharedPreferences reading in onResume() - Added conditional checks (mIsAudioEnabled, mIsVibrationEnabled) throughout - Created preference UI with toggle buttons and dynamic icons - Added Day/Night auto mode with mutual exclusion logic Visualization Components - Created HeatmapView custom view extending View - Implements dartboard rendering with Canvas path calculations - Color interpolation from cold (faded) to hot (volt green) - Splits board into concentric rings: doubles, outer singles, triples, inner singles - Added TestActivity for heatmap development/debugging - Added navigation from MainMenuActivity title click to TestActivity UI/UX Improvements - Added vector drawables for audio/vibration states (on/off) - Enhanced preferences screen with categorized sections - Improved settings fragment with preference interaction logic - Added Gson dependency (v2.13.2) for JSON serialization Code Quality - Added comprehensive JavaDoc to all new Statistics methods - Made all method parameters final for immutability - Added detailed logging for statistics operations - Improved error handling with try-catch blocks in background threads TECHNICAL NOTES: - Database migration is DESTRUCTIVE (all data lost on schema change) - Per-player locks enable parallel statistics updates across players - Hit distribution keys use format: "t20", "d16", "s5", "sb", "db" - Heatmap normalizes weights against max hits for consistent coloring - Statistics now tracks 15+ distinct performance metrics TESTING RECOMMENDATIONS: - Verify hit distribution persists correctly across matches - Test concurrent multi-player statistics updates - Confirm checkout values reflect final dart, not turn total - Validate milestone counters increment accurately - Test heatmap visualization with varied hit patterns --- app/build.gradle.kts | 1 + app/src/main/AndroidManifest.xml | 12 +- .../aldo/apps/ochecompanion/GameActivity.java | 304 ++++++++++- .../apps/ochecompanion/MainMenuActivity.java | 3 + .../apps/ochecompanion/SettingsActivity.java | 2 + .../aldo/apps/ochecompanion/TestActivity.java | 57 +++ .../ochecompanion/database/AppDatabase.java | 5 +- .../database/objects/Player.java | 2 - .../database/objects/Statistics.java | 476 ++++++++++++++++-- .../apps/ochecompanion/ui/HeatmapView.java | 143 ++++++ .../ui/MainMenuPreferencesFragment.java | 42 ++ .../converters/HitDistributionConverter.java | 68 +++ app/src/main/res/drawable/ic_audio_off.xml | 11 + app/src/main/res/drawable/ic_audio_on.xml | 11 + .../main/res/drawable/ic_vibration_off.xml | 11 + app/src/main/res/drawable/ic_vibration_on.xml | 11 + app/src/main/res/layout/activity_main.xml | 1 + app/src/main/res/layout/activity_test.xml | 16 + app/src/main/res/values/strings.xml | 10 +- .../main/res/xml/main_menu_preferences.xml | 32 +- gradle/libs.versions.toml | 2 + 21 files changed, 1152 insertions(+), 68 deletions(-) create mode 100644 app/src/main/java/com/aldo/apps/ochecompanion/TestActivity.java create mode 100644 app/src/main/java/com/aldo/apps/ochecompanion/ui/HeatmapView.java create mode 100644 app/src/main/java/com/aldo/apps/ochecompanion/utils/converters/HitDistributionConverter.java create mode 100644 app/src/main/res/drawable/ic_audio_off.xml create mode 100644 app/src/main/res/drawable/ic_audio_on.xml create mode 100644 app/src/main/res/drawable/ic_vibration_off.xml create mode 100644 app/src/main/res/drawable/ic_vibration_on.xml create mode 100644 app/src/main/res/layout/activity_test.xml diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 74ec68d..a688c31 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -47,4 +47,5 @@ dependencies { annotationProcessor(libs.room.compiler) implementation(libs.preferences) implementation(libs.konfetti) + implementation(libs.gson) } \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 0dff777..13eba5d 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -6,11 +6,14 @@ that other apps created. --> - + - - + + mCurrentTurnDarts = new ArrayList<>(); + /** + * Dart hit details (base value and multiplier) for current turn. + * Parallel to mCurrentTurnDarts, used for hit distribution tracking. + * Cleared when turn is submitted. + */ + private final List mCurrentTurnDartHits = new ArrayList<>(); + /** * Flag indicating turn has ended (bust, win, or 3 darts thrown). * Prevents additional dart entry until turn is submitted. */ private boolean mIsTurnOver = false; + /** + * Helper class to track dart hit details for statistics. + */ + private static class DartHit { + /** + * The dartboard number hit (1-20 or 25 for bull). + */ + final int baseValue; + + /** + * The multiplier applied to the base value (1=single, 2=double, 3=triple). + */ + 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) + */ + DartHit(final int baseValue, final int multiplier) { + this.baseValue = baseValue; + this.multiplier = multiplier; + } + } + /** * Cached references to keyboard buttons (1-20) for efficient styling updates. */ @@ -173,8 +208,34 @@ public class GameActivity extends AppCompatActivity { */ private GridLayout glKeyboard; + /** + * Singleton instance of SoundEngine for playing game sound effects. + */ private SoundEngine mSoundEngine; + /** + * Flag indicating whether haptic feedback is enabled. + * Controlled by user preferences. + */ + private boolean mIsVibrationEnabled; + + /** + * Flag indicating whether audio feedback is enabled. + * Controlled by user preferences. + */ + private boolean mIsAudioEnabled; + + /** + * Reference to the Room database instance for statistics and player data access. + */ + 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<>(); + /** * Starts GameActivity with specified players and starting score. * @@ -207,6 +268,7 @@ public class GameActivity extends AppCompatActivity { return insets; }); mSoundEngine = SoundEngine.getInstance(this); + mDatabase = AppDatabase.getDatabase(this); // Extract game parameters from intent mStartingScore = getIntent().getIntExtra(EXTRA_START_SCORE, DartsConstants.DEFAULT_GAME_SCORE); @@ -221,6 +283,14 @@ public class GameActivity extends AppCompatActivity { }).start(); } + @Override + protected void onResume() { + super.onResume(); + final SharedPreferences settingPrefs = PreferenceManager.getDefaultSharedPreferences(this); + mIsAudioEnabled = settingPrefs.getBoolean(getString(R.string.pref_key_audio_feedback), true); + mIsVibrationEnabled = settingPrefs.getBoolean(getString(R.string.pref_key_vibration_feedback), true); + } + /** * Initializes UI component references and sets up click listeners. */ @@ -319,20 +389,35 @@ public class GameActivity extends AppCompatActivity { if (scoreAfterDart < 0 || scoreAfterDart == DartsConstants.BUST_SCORE || (scoreAfterDart == 0 && !isDouble)) { // BUST CONDITION: Score < 0, Score == 1, or Score == 0 on a non-double mCurrentTurnDarts.add(points); + mCurrentTurnDartHits.add(new DartHit(baseValue, mMultiplier)); + + // Track double-out miss if trying to finish but failed + if (scoreBeforeDart <= 40 && isDouble) { + trackDoubleAttempt(active, true); // Missed double-out attempt + } + updateTurnIndicators(); mIsTurnOver = true; - mSoundEngine.playBustedSound(); + if (mIsAudioEnabled) { + mSoundEngine.playBustedSound(); + } Toast.makeText(this, "BUST!", Toast.LENGTH_SHORT).show(); // In a pro interface, we usually wait for "Submit" or auto-submit after a short delay } else if (scoreAfterDart == 0 && isDouble) { // VICTORY CONDITION mCurrentTurnDarts.add(points); + mCurrentTurnDartHits.add(new DartHit(baseValue, mMultiplier)); + + // Track successful double-out + trackDoubleAttempt(active, false); + updateTurnIndicators(); mIsTurnOver = true; handleWin(active, mCurrentTurnDarts.size(), scoreBeforeDart); } else { // VALID THROW mCurrentTurnDarts.add(points); + mCurrentTurnDartHits.add(new DartHit(baseValue, mMultiplier)); updateTurnIndicators(); updateUI(); @@ -400,23 +485,76 @@ public class GameActivity extends AppCompatActivity { * @param pointsMade Total points scored this turn * @param wasBust Whether the turn resulted in a bust */ - private void updatePlayerStats(GameActivity.X01State active, int dartsThrown, int pointsMade, boolean wasBust) { + private void updatePlayerStats(final GameActivity.X01State active, final int dartsThrown, final int pointsMade, final boolean wasBust) { + updatePlayerStats(active, dartsThrown, pointsMade, wasBust, 0); + } + + /** + * Updates player statistics in the database after a turn. + * Overload that accepts checkout value for tracking successful finishes. + * + * @param active Current player's game state + * @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) + */ + 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); - playerStats.saveDartsThrown(dartsThrown, pointsMade); - Log.d(TAG, "submitTurn: dartsThrown = [" + dartsThrown + "], pointsMade = [" + pointsMade + "]"); - if (!wasBust && dartsThrown < 3) { - playerStats.addMissedDarts(3 - dartsThrown); + + // 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); + } } - Log.d(TAG, "submitTurn: statistics = [" + playerStats + "]"); + + // 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.getDartsThrown(); + final long totalDarts = playerStats.getTotalDartsThrown(); if (totalDarts > 0) { - player.careerAverage = (double) playerStats.getOverallPointsMade() / totalDarts * 3; + player.careerAverage = (double) playerStats.getTotalPoints() / totalDarts * 3; } else { player.careerAverage = 0.0; } @@ -445,20 +583,24 @@ public class GameActivity extends AppCompatActivity { final Animation shakeAnimation = AnimationUtils.loadAnimation(this, R.anim.shake); final View main = findViewById(R.id.main); main.startAnimation(shakeAnimation); + if (mIsVibrationEnabled) { - final Vibrator vibrator = (Vibrator) getSystemService(VIBRATOR_SERVICE); - if (vibrator != null && Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - Log.d(TAG, "submitTurn: Pattern vibration"); - // Pattern that should match the 180 shout. - long[] pattern = {0, 150, 100, 1650, 50, 150, 10, 500, 300, 200}; - vibrator.vibrate(VibrationEffect.createWaveform(pattern, -1)); - } else if (vibrator != null) { - Log.d(TAG, "submitTurn: Vibrating legacy mode"); - vibrator.vibrate(500); - } else { - Log.e(TAG, "submitTurn: Vibrator not available"); + final Vibrator vibrator = (Vibrator) getSystemService(VIBRATOR_SERVICE); + if (vibrator != null && Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + Log.d(TAG, "submitTurn: Pattern vibration"); + // Pattern that should match the 180 shout. + long[] pattern = {0, 150, 100, 1650, 50, 150, 10, 500, 300, 200}; + vibrator.vibrate(VibrationEffect.createWaveform(pattern, -1)); + } else if (vibrator != null) { + Log.d(TAG, "submitTurn: Vibrating legacy mode"); + vibrator.vibrate(500); + } else { + Log.e(TAG, "submitTurn: Vibrator not available"); + } + } + if (mIsAudioEnabled) { + mSoundEngine.playOneHundredAndEightySound(); } - mSoundEngine.playOneHundredAndEightySound(); } // Re-check logic for non-double finish or score of 1 @@ -473,11 +615,16 @@ public class GameActivity extends AppCompatActivity { } updatePlayerStats(active, mCurrentTurnDarts.size(), turnTotal, isBust); + // Record all dart hits to statistics now that turn is confirmed + // IMPORTANT: Pass a copy to avoid race condition with clearing the list + recordTurnHitsToStatistics(active, new ArrayList<>(mCurrentTurnDartHits)); + // Rotate to next player mActivePlayerIndex = (mActivePlayerIndex + 1) % mPlayerStates.size(); // Reset turn state mCurrentTurnDarts.clear(); + mCurrentTurnDartHits.clear(); mIsTurnOver = false; // Update UI for next player @@ -612,21 +759,122 @@ public class GameActivity extends AppCompatActivity { return String.valueOf(score); } + /** + * Tracks a double-out attempt in player statistics. + * Records whether the attempt was successful or missed. + * + * @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(); + } + + /** + * Increments matchesPlayed counter for all players in the current match. + * Called when a match is completed (someone wins). + */ + 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(); + } + + /** + * 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. + * + * @param playerState X01State of the player + * @param dartHits List of dart hit details from the turn + */ + 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(); + } + /** * Handles win condition when a player finishes on zero with a double. * Updates statistics, displays win toast, and plays celebration animation. * * @param winner X01State of the winning player * @param dartsThrown Number of darts thrown in the winning turn - * @param pointsMade Points scored in the winning turn + * @param pointsMade Points scored in the winning turn (total turn score) */ private void handleWin(final X01State winner, final int dartsThrown, final int pointsMade) { - updatePlayerStats(winner, dartsThrown, pointsMade, false); + // Calculate checkout value (the score of the FINAL dart, not the whole turn) + final int checkoutValue = mCurrentTurnDarts.get(mCurrentTurnDarts.size() - 1); + + // Update statistics with correct checkout value + updatePlayerStats(winner, dartsThrown, pointsMade, false, checkoutValue); + + // Record all dart hits from winning turn to hit distribution + // IMPORTANT: Pass a copy to avoid race condition with clearing the list + recordTurnHitsToStatistics(winner, new ArrayList<>(mCurrentTurnDartHits)); + + // Increment matchesPlayed for all players in the match + incrementMatchesPlayed(); + + // Clear turn state after recording + mCurrentTurnDarts.clear(); + mCurrentTurnDartHits.clear(); + // Show win notification - Toast.makeText(this, winner.name + " WINS!", Toast.LENGTH_LONG).show(); + Toast.makeText(this, winner.name + " WINS! Checkout: " + checkoutValue, Toast.LENGTH_LONG).show(); playWinnerAnimation(winner.name); - mSoundEngine.playWinnerSound(); + if (mIsAudioEnabled) { + mSoundEngine.playWinnerSound(); + } // TODO: Consider adding: // - Statistics display @@ -682,6 +930,11 @@ public class GameActivity extends AppCompatActivity { */ final Player player; + /** + * Player's ID from the database for statistics lookup. + */ + final long playerId; + /** * Player's display name for convenience purpose, as extracted from the player object. */ @@ -705,6 +958,7 @@ public class GameActivity extends AppCompatActivity { */ X01State(final Player player, final int startScore) { this.player = player; + this.playerId = player.id; this.name = player.username; this.remainingScore = startScore; } 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 fd46fbf..f5d70c7 100644 --- a/app/src/main/java/com/aldo/apps/ochecompanion/MainMenuActivity.java +++ b/app/src/main/java/com/aldo/apps/ochecompanion/MainMenuActivity.java @@ -4,6 +4,7 @@ import android.content.Intent; import android.content.SharedPreferences; import android.os.Bundle; import android.util.Log; +import android.view.View; import android.widget.TextView; import androidx.activity.EdgeToEdge; @@ -91,6 +92,8 @@ public class MainMenuActivity extends AppCompatActivity { applyTestData(mTestCounter); mTestCounter++; }); + + findViewById(R.id.title_view).setOnClickListener(v -> startActivity(new Intent(MainMenuActivity.this, TestActivity.class))); } /** 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 a9eb815..72e0358 100644 --- a/app/src/main/java/com/aldo/apps/ochecompanion/SettingsActivity.java +++ b/app/src/main/java/com/aldo/apps/ochecompanion/SettingsActivity.java @@ -44,4 +44,6 @@ public class SettingsActivity extends AppCompatActivity { actionBar.setDisplayHomeAsUpEnabled(true); } } + + } \ No newline at end of file diff --git a/app/src/main/java/com/aldo/apps/ochecompanion/TestActivity.java b/app/src/main/java/com/aldo/apps/ochecompanion/TestActivity.java new file mode 100644 index 0000000..12203d4 --- /dev/null +++ b/app/src/main/java/com/aldo/apps/ochecompanion/TestActivity.java @@ -0,0 +1,57 @@ +package com.aldo.apps.ochecompanion; + +import android.os.Bundle; +import android.util.Log; + +import androidx.activity.EdgeToEdge; +import androidx.appcompat.app.AppCompatActivity; +import androidx.core.graphics.Insets; +import androidx.core.view.ViewCompat; +import androidx.core.view.WindowInsetsCompat; + +import com.aldo.apps.ochecompanion.database.AppDatabase; +import com.aldo.apps.ochecompanion.database.objects.Player; +import com.aldo.apps.ochecompanion.database.objects.Statistics; +import com.aldo.apps.ochecompanion.ui.HeatmapView; + +import java.util.List; + +public class TestActivity extends AppCompatActivity { + + private static final String TAG = "TestActivity"; + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + EdgeToEdge.enable(this); + setContentView(R.layout.activity_test); + ViewCompat.setOnApplyWindowInsetsListener(findViewById(R.id.main), (v, insets) -> { + Insets systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars()); + v.setPadding(systemBars.left, systemBars.top, systemBars.right, systemBars.bottom); + return insets; + }); + + final HeatmapView heatmap = findViewById(R.id.heatmap); + new Thread(() -> { + // Access the singleton database and query all players + final List allPlayers = AppDatabase.getDatabase(getApplicationContext()) + .playerDao() + .getAllPlayers(); + + if (allPlayers == null || allPlayers.isEmpty()) { + Log.d(TAG, "onCreate: Cannot continue"); + return; + } + + final Player firstPlayer = allPlayers.get(0); + final Statistics stats = AppDatabase.getDatabase(this) + .statisticsDao() + .getStatisticsForPlayer(firstPlayer.id); + + runOnUiThread(() -> { + Log.d(TAG, "onCreate: Applying stats [" + stats + "]"); + heatmap.setStats(stats); + }); + }).start(); + } +} \ No newline at end of file diff --git a/app/src/main/java/com/aldo/apps/ochecompanion/database/AppDatabase.java b/app/src/main/java/com/aldo/apps/ochecompanion/database/AppDatabase.java index 158c128..14aae1e 100644 --- a/app/src/main/java/com/aldo/apps/ochecompanion/database/AppDatabase.java +++ b/app/src/main/java/com/aldo/apps/ochecompanion/database/AppDatabase.java @@ -4,6 +4,7 @@ import android.content.Context; import androidx.room.Database; import androidx.room.Room; import androidx.room.RoomDatabase; +import androidx.room.TypeConverters; import com.aldo.apps.ochecompanion.database.dao.MatchDao; import com.aldo.apps.ochecompanion.database.dao.PlayerDao; @@ -11,6 +12,7 @@ import com.aldo.apps.ochecompanion.database.dao.StatisticsDao; import com.aldo.apps.ochecompanion.database.objects.Match; import com.aldo.apps.ochecompanion.database.objects.Player; import com.aldo.apps.ochecompanion.database.objects.Statistics; +import com.aldo.apps.ochecompanion.utils.converters.HitDistributionConverter; /** * Main Room database class for the Oche Companion darts application. @@ -23,7 +25,8 @@ import com.aldo.apps.ochecompanion.database.objects.Statistics; * @see Player * @see Match */ -@Database(entities = {Player.class, Match.class, Statistics.class}, version = 10, exportSchema = false) +@Database(entities = {Player.class, Match.class, Statistics.class}, version = 12, exportSchema = false) +@TypeConverters({HitDistributionConverter.class}) public abstract class AppDatabase extends RoomDatabase { /** diff --git a/app/src/main/java/com/aldo/apps/ochecompanion/database/objects/Player.java b/app/src/main/java/com/aldo/apps/ochecompanion/database/objects/Player.java index 75adbe4..9f89e42 100644 --- a/app/src/main/java/com/aldo/apps/ochecompanion/database/objects/Player.java +++ b/app/src/main/java/com/aldo/apps/ochecompanion/database/objects/Player.java @@ -58,8 +58,6 @@ public class Player implements Parcelable { this.profilePictureUri = profilePictureUri; } - - /** * Parcelable constructor to reconstruct Player from Parcel. * diff --git a/app/src/main/java/com/aldo/apps/ochecompanion/database/objects/Statistics.java b/app/src/main/java/com/aldo/apps/ochecompanion/database/objects/Statistics.java index f270549..26faea3 100644 --- a/app/src/main/java/com/aldo/apps/ochecompanion/database/objects/Statistics.java +++ b/app/src/main/java/com/aldo/apps/ochecompanion/database/objects/Statistics.java @@ -1,8 +1,16 @@ package com.aldo.apps.ochecompanion.database.objects; +import android.util.Log; + +import androidx.annotation.NonNull; import androidx.room.Entity; -import androidx.room.ForeignKey; import androidx.room.PrimaryKey; +import androidx.room.TypeConverters; + +import com.aldo.apps.ochecompanion.utils.converters.HitDistributionConverter; + +import java.util.HashMap; +import java.util.Map; /** * Room database entity representing player performance statistics. @@ -16,6 +24,11 @@ import androidx.room.PrimaryKey; @Entity(tableName = "statistics") public class Statistics { + /** + * Tag for debugging purposes. + */ + private static final String TAG = "Statistics"; + /** * Player ID this statistics record belongs to (primary key). * References the Player entity's ID field. @@ -23,11 +36,17 @@ public class Statistics { @PrimaryKey private long playerId; + /** + * Cumulative points scored across all darts thrown. + * Used with dartsThrown to calculate career average. + */ + private long totalPoints; + /** * Total number of darts thrown across all matches. * Used to calculate career average and accuracy metrics. */ - private long dartsThrown; + private long totalDartsThrown; /** * Total number of darts that missed the intended target. @@ -36,22 +55,64 @@ public class Statistics { private long dartsMissed; /** - * Cumulative points scored across all darts thrown. - * Used with dartsThrown to calculate career average. + * Total points scored in the first 9 darts of matches. + * Used to calculate the "first 9" average, measuring starting consistency. */ - private long overallPointsMade; + private long totalFirst9Points; + + /** + * Total number of first 9 darts thrown in matches. + * Used with totalFirst9Points to calculate first 9 average. + */ + private long totalFirst9Darts; + + /** + * Count of turns scoring 60 or more points. + * Tracks high-scoring consistency (e.g., T20). + */ + private int count60Plus; + + /** + * Count of turns scoring 100 or more points. + * Tracks century+ scoring frequency. + */ + private int count100Plus; + + /** + * Count of turns scoring 140 or more points. + * Tracks elite-level scoring (T20-T20-T20 = 140+). + */ + private int count140Plus; + + /** + * Count of perfect 180 scores (T20-T20-T20). + * The highest possible three-dart score. + */ + private int count180; /** * Total number of completed matches for this player. * Provides context for statistical significance. */ private int matchesPlayed; + + /** + * Number of successful checkout finishes on doubles. + * Used with totalDartsAtDouble to calculate checkout percentage. + */ + private int successfulCheckouts; + + /** + * Highest checkout score achieved by the player. + * Tracks the player's best finishing performance. + */ + private int highestCheckout; /** * Total number of double-out attempts (both successful and missed). * Tracks how often player attempts to finish on doubles. */ - private int doubleOutsTargeted; + private int totalDartsAtDouble; /** * Number of failed double-out attempts. @@ -59,6 +120,13 @@ public class Statistics { */ private int doubleOutsMissed; + /** + * Hit distribution map tracking frequency of each board segment hit. + * Keys are segment identifiers (e.g., "t20", "d16", "s5", "sb", "db"). + * Values are hit counts. Used for heat map visualization and accuracy analysis. + */ + private Map hitDistribution; + /** * Constructs a new Statistics record for a player. * Initializes all counters to zero. @@ -67,11 +135,12 @@ public class Statistics { */ public Statistics(final long playerId) { this.playerId = playerId; - dartsThrown = 0; - overallPointsMade = 0; + totalDartsThrown = 0; + totalPoints = 0; matchesPlayed = 0; - doubleOutsTargeted = 0; + totalDartsAtDouble = 0; doubleOutsMissed = 0; + hitDistribution = new HashMap<>(); } /** @@ -81,8 +150,8 @@ public class Statistics { * @param pointsMade Total points scored with those darts */ public void saveDartsThrown(final long dartsThrown, final long pointsMade) { - this.dartsThrown += dartsThrown; - this.overallPointsMade += pointsMade; + this.totalDartsThrown += dartsThrown; + this.totalPoints += pointsMade; } /** @@ -109,78 +178,423 @@ public class Statistics { * @param isMissed true if the double-out was missed, false if successful */ public void addDoubleOutTarget(final boolean isMissed) { - doubleOutsTargeted++; + totalDartsAtDouble++; if (isMissed) { doubleOutsMissed++; } } + /** + * Records a dart hit on a specific board segment. + * Increments the hit count for the given segment in the distribution map. + * + * @param baseValue The dartboard number (1-20 or 25 for bull) + * @param multiplier The multiplier (1=single, 2=double, 3=triple) + */ + public void recordDartHit(final int baseValue, final int multiplier) { + if (hitDistribution == null) { + hitDistribution = new HashMap<>(); + } + final String segmentKey = HitDistributionConverter.getSegmentKey(baseValue, multiplier); + hitDistribution = HitDistributionConverter.recordHit(hitDistribution, segmentKey); + Log.d(TAG, "recordDartHit: Recorded hit on " + segmentKey + ", total hits: " + hitDistribution.get(segmentKey)); + } + + /** + * Gets the hit distribution map. + * + * @return Map of segment keys to hit counts + */ + public Map getHitDistribution() { + if (hitDistribution == null) { + hitDistribution = new HashMap<>(); + } + return hitDistribution; + } + + /** + * Sets the hit distribution map. + * Used by Room when loading from database. + * + * @param hitDistribution Map of segment keys to hit counts + */ + public void setHitDistribution(final Map hitDistribution) { + this.hitDistribution = hitDistribution != null ? hitDistribution : new HashMap<>(); + } + + /** + * Gets the player ID this statistics record belongs to. + * + * @return Player ID (primary key) + */ public long getPlayerId() { return playerId; } - public void setPlayerId(long playerId) { + /** + * Sets the player ID for this statistics record. + * Used by Room when loading from database. + * + * @param playerId Player ID to set + */ + public void setPlayerId(final long playerId) { this.playerId = playerId; } - public long getDartsThrown() { - return dartsThrown; + /** + * Gets the total number of darts thrown across all matches. + * + * @return Total darts thrown + */ + public long getTotalDartsThrown() { + return totalDartsThrown; } - public void setDartsThrown(long dartsThrown) { - this.dartsThrown = dartsThrown; + /** + * Sets the total number of darts thrown. + * Used by Room when loading from database. + * + * @param totalDartsThrown Total darts thrown to set + */ + public void setTotalDartsThrown(final long totalDartsThrown) { + this.totalDartsThrown = totalDartsThrown; } + /** + * Gets the total number of darts that missed the target. + * + * @return Total darts missed + */ public long getDartsMissed() { return dartsMissed; } - public void setDartsMissed(long dartsMissed) { + /** + * Sets the total number of darts missed. + * Used by Room when loading from database. + * + * @param dartsMissed Total darts missed to set + */ + public void setDartsMissed(final long dartsMissed) { this.dartsMissed = dartsMissed; } - public long getOverallPointsMade() { - return overallPointsMade; + /** + * Gets the cumulative points scored across all darts thrown. + * + * @return Total points made + */ + public long getTotalPoints() { + return totalPoints; } - public void setOverallPointsMade(long overallPointsMade) { - this.overallPointsMade = overallPointsMade; + /** + * Sets the cumulative points scored. + * Used by Room when loading from database. + * + * @param totalPoints Total points made to set + */ + public void setTotalPoints(final long totalPoints) { + this.totalPoints = totalPoints; } + /** + * Gets the total number of completed matches for this player. + * + * @return Total matches played + */ public int getMatchesPlayed() { return matchesPlayed; } - public void setMatchesPlayed(int matchesPlayed) { + /** + * Sets the total number of completed matches. + * Used by Room when loading from database. + * + * @param matchesPlayed Total matches played to set + */ + public void setMatchesPlayed(final int matchesPlayed) { this.matchesPlayed = matchesPlayed; } - public int getDoubleOutsTargeted() { - return doubleOutsTargeted; + /** + * Gets the total number of double-out attempts (successful and missed). + * + * @return Total double-out attempts + */ + public int getTotalDartsAtDouble() { + return totalDartsAtDouble; } - public void setDoubleOutsTargeted(int doubleOutsTargeted) { - this.doubleOutsTargeted = doubleOutsTargeted; + /** + * Sets the total number of double-out attempts. + * Used by Room when loading from database. + * + * @param totalDartsAtDouble Total double-out attempts to set + */ + public void setTotalDartsAtDouble(final int totalDartsAtDouble) { + this.totalDartsAtDouble = totalDartsAtDouble; } + /** + * Gets the number of failed double-out attempts. + * + * @return Total double-outs missed + */ public int getDoubleOutsMissed() { return doubleOutsMissed; } - public void setDoubleOutsMissed(int doubleOutsMissed) { + /** + * Gets the total points scored in first 9 darts. + * + * @return Total first 9 points + */ + public long getTotalFirst9Points() { + return totalFirst9Points; + } + + /** + * Sets the total points scored in first 9 darts. + * Used by Room when loading from database. + * + * @param totalFirst9Points Total first 9 points to set + */ + public void setTotalFirst9Points(final long totalFirst9Points) { + this.totalFirst9Points = totalFirst9Points; + } + + /** + * Gets the total number of first 9 darts thrown. + * + * @return Total first 9 darts + */ + public long getTotalFirst9Darts() { + return totalFirst9Darts; + } + + /** + * Sets the total number of first 9 darts thrown. + * Used by Room when loading from database. + * + * @param totalFirst9Darts Total first 9 darts to set + */ + public void setTotalFirst9Darts(final long totalFirst9Darts) { + this.totalFirst9Darts = totalFirst9Darts; + } + + /** + * Gets the count of 60+ point turns. + * + * @return Count of 60+ scores + */ + public int getCount60Plus() { + return count60Plus; + } + + /** + * Sets the count of 60+ point turns. + * Used by Room when loading from database. + * + * @param count60Plus Count of 60+ scores to set + */ + public void setCount60Plus(final int count60Plus) { + this.count60Plus = count60Plus; + } + + /** + * Gets the count of 100+ point turns. + * + * @return Count of 100+ scores + */ + public int getCount100Plus() { + return count100Plus; + } + + /** + * Sets the count of 100+ point turns. + * Used by Room when loading from database. + * + * @param count100Plus Count of 100+ scores to set + */ + public void setCount100Plus(final int count100Plus) { + this.count100Plus = count100Plus; + } + + /** + * Gets the count of 140+ point turns. + * + * @return Count of 140+ scores + */ + public int getCount140Plus() { + return count140Plus; + } + + /** + * Sets the count of 140+ point turns. + * Used by Room when loading from database. + * + * @param count140Plus Count of 140+ scores to set + */ + public void setCount140Plus(final int count140Plus) { + this.count140Plus = count140Plus; + } + + /** + * Gets the count of perfect 180 scores. + * + * @return Count of 180 scores + */ + public int getCount180() { + return count180; + } + + /** + * Sets the count of perfect 180 scores. + * Used by Room when loading from database. + * + * @param count180 Count of 180 scores to set + */ + public void setCount180(final int count180) { + this.count180 = count180; + } + + /** + * Gets the number of successful checkout finishes. + * + * @return Count of successful checkouts + */ + public int getSuccessfulCheckouts() { + return successfulCheckouts; + } + + /** + * Sets the number of successful checkout finishes. + * Used by Room when loading from database. + * + * @param successfulCheckouts Count of successful checkouts to set + */ + public void setSuccessfulCheckouts(final int successfulCheckouts) { + this.successfulCheckouts = successfulCheckouts; + } + + /** + * Gets the highest checkout score achieved. + * + * @return Highest checkout value + */ + public int getHighestCheckout() { + return highestCheckout; + } + + /** + * Sets the highest checkout score achieved. + * Used by Room when loading from database. + * + * @param highestCheckout Highest checkout value to set + */ + public void setHighestCheckout(final int highestCheckout) { + if (highestCheckout > this.highestCheckout) { + this.highestCheckout = highestCheckout; + } else { + Log.i(TAG, "setHighestCheckout: New checkout [" + + highestCheckout + "] not higher than existing [" + this.highestCheckout + "], do not set"); + } + } + + /** + * Calculates the career three-dart average. + * + * @return Three-dart average (0.0 if no darts thrown) + */ + public double getAverage() { + if (totalDartsThrown == 0) return 0.0; + return ((double) totalPoints / totalDartsThrown) * 3; + } + + /** + * Calculates the "First 9" scoring average. + * Measures consistency in the opening phase of matches. + * + * @return Three-dart first 9 average (0.0 if no first 9 darts thrown) + */ + public double getFirst9Average() { + if (totalFirst9Darts == 0) return 0.0; + return ((double) totalFirst9Points / totalFirst9Darts) * 3; + } + + /** + * Calculates the checkout success percentage. + * Measures efficiency at finishing on doubles. + * + * @return Checkout percentage (0.0-100.0, or 0.0 if no attempts) + */ + public double getCheckoutPercentage() { + if (totalDartsAtDouble == 0) return 0.0; + return ((double) successfulCheckouts / totalDartsAtDouble) * 100; + } + + /** + * Sets the number of failed double-out attempts. + * Used by Room when loading from database. + * + * @param doubleOutsMissed Total double-outs missed to set + */ + public void setDoubleOutsMissed(final int doubleOutsMissed) { this.doubleOutsMissed = doubleOutsMissed; } + /** + * Finds the highest hit count across all segments. + * Useful for normalizing heatmap colors based on the player's most frequent target. + */ + public int getMaxHits() { + if (hitDistribution == null || hitDistribution.isEmpty()) return 0; + int max = 0; + for (int val : hitDistribution.values()) { + if (val > max) max = val; + } + return max; + } + + /** + * Returns a value between 0.0 and 1.0 representing how frequently + * this segment was hit compared to the most-hit segment. + * * @param segmentKey The segment to check (e.g. "t20") + * @return A float weight for color interpolation. + */ + public float getNormalizedWeight(final String segmentKey) { + final int max = getMaxHits(); + if (max == 0) return 0.0f; + final Integer hits = hitDistribution.get(segmentKey); + return hits == null ? 0.0f : (float) hits / max; + } + + /** + * Returns a string representation of the statistics for debugging. + * + * @return String containing all statistical fields and their values + */ + @NonNull @Override public String toString() { return "Statistics{" + "playerId=" + playerId + - ", dartsThrown=" + dartsThrown + + ", totalPoints=" + totalPoints + + ", totalDartsThrown=" + totalDartsThrown + ", dartsMissed=" + dartsMissed + - ", overallPointsMade=" + overallPointsMade + + ", totalFirst9Points=" + totalFirst9Points + + ", totalFirst9Darts=" + totalFirst9Darts + + ", count60Plus=" + count60Plus + + ", count100Plus=" + count100Plus + + ", count140Plus=" + count140Plus + + ", count180=" + count180 + ", matchesPlayed=" + matchesPlayed + - ", doubleOutsTargeted=" + doubleOutsTargeted + + ", successfulCheckouts=" + successfulCheckouts + + ", highestCheckout=" + highestCheckout + + ", totalDartsAtDouble=" + totalDartsAtDouble + ", doubleOutsMissed=" + doubleOutsMissed + + ", hitDistribution=" + (hitDistribution != null ? hitDistribution.size() + " segments" : "null") + '}'; } } 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 new file mode 100644 index 0000000..6bd5504 --- /dev/null +++ b/app/src/main/java/com/aldo/apps/ochecompanion/ui/HeatmapView.java @@ -0,0 +1,143 @@ +package com.aldo.apps.ochecompanion.ui; + +import android.animation.ArgbEvaluator; +import android.content.Context; +import android.graphics.Canvas; +import android.graphics.Color; +import android.graphics.Paint; +import android.graphics.Path; +import android.graphics.RectF; +import android.util.AttributeSet; +import android.view.View; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.core.content.ContextCompat; +import com.aldo.apps.ochecompanion.R; +import com.aldo.apps.ochecompanion.database.objects.Statistics; +import java.util.HashMap; +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. + */ +public class HeatmapView extends View { + + private final Paint mBasePaint = new Paint(Paint.ANTI_ALIAS_FLAG); + private final ArgbEvaluator mColorEvaluator = new ArgbEvaluator(); + + // Geometry configuration + private float mCenterX, mCenterY, mRadius; + private final Map mSegmentPaths = new HashMap<>(); + private Statistics mStats; + + // Standard Dartboard Segment Order (clockwise starting from 20 at the top) + private static final int[] BOARD_NUMBERS = { + 20, 1, 18, 4, 13, 6, 10, 15, 2, 17, 3, 19, 7, 16, 8, 11, 14, 9, 12, 5 + }; + + public HeatmapView(final Context context) { super(context); init(); } + public HeatmapView(final Context context, @Nullable final AttributeSet attrs) { super(context, attrs); init(); } + + private void init() { + mBasePaint.setStyle(Paint.Style.FILL); + } + + /** + * Binds the player's statistics to the view and triggers a redraw. + */ + public void setStats(final Statistics stats) { + this.mStats = stats; + invalidate(); + } + + @Override + protected void onSizeChanged(final int w, final int h, final int oldw, final int oldh) { + super.onSizeChanged(w, h, oldw, oldh); + + mCenterX = w / 2f; + mCenterY = h / 2f; + mRadius = Math.min(w, h) / 2.1f; // Leave a small margin + + calculatePaths(); + } + + /** + * Calculates the Path for every segment on the board based on the view size. + */ + private void calculatePaths() { + mSegmentPaths.clear(); + final float angleStep = 360f / 20f; + final float startOffset = -90f - (angleStep / 2f); // Center 20 at the top + + for (int i = 0; i < BOARD_NUMBERS.length; i++) { + final int num = BOARD_NUMBERS[i]; + final float startAngle = startOffset + (i * angleStep); + + // Define concentric ring boundaries as percentages of radius + mSegmentPaths.put("d" + num, createArcPath(0.90f, 1.00f, startAngle, angleStep)); // Double + mSegmentPaths.put("s" + num + "_outer", createArcPath(0.60f, 0.90f, startAngle, angleStep)); // Outer Single + mSegmentPaths.put("t" + num, createArcPath(0.50f, 0.60f, startAngle, angleStep)); // Triple + mSegmentPaths.put("s" + num + "_inner", createArcPath(0.15f, 0.50f, startAngle, angleStep)); // Inner Single + } + + // Bulls are simple circles + final Path sbPath = new Path(); + sbPath.addCircle(mCenterX, mCenterY, mRadius * 0.15f, Path.Direction.CW); + mSegmentPaths.put("sb", sbPath); + + final Path dbPath = new Path(); + dbPath.addCircle(mCenterX, mCenterY, mRadius * 0.07f, Path.Direction.CW); + mSegmentPaths.put("db", dbPath); + } + + private Path createArcPath(final float innerFactor, final float outerFactor, final float startAngle, final float sweep) { + final Path path = new Path(); + final RectF outerRect = new RectF( + mCenterX - mRadius * outerFactor, mCenterY - mRadius * outerFactor, + mCenterX + mRadius * outerFactor, mCenterY + mRadius * outerFactor + ); + final RectF innerRect = new RectF( + mCenterX - mRadius * innerFactor, mCenterY - mRadius * innerFactor, + mCenterX + mRadius * innerFactor, mCenterY + mRadius * innerFactor + ); + + path.arcTo(outerRect, startAngle, sweep); + path.arcTo(innerRect, startAngle + sweep, -sweep); + path.close(); + return path; + } + + @Override + protected void onDraw(@NonNull final Canvas canvas) { + 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); + + // 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); + + mBasePaint.setColor(color); + canvas.drawPath(entry.getValue(), mBasePaint); + } + + // Final aesthetic: draw a thin wireframe over the segments + mBasePaint.setStyle(Paint.Style.STROKE); + mBasePaint.setStrokeWidth(1f); + mBasePaint.setColor(Color.parseColor("#33FFFFFF")); + for (final Path p : mSegmentPaths.values()) { + canvas.drawPath(p, mBasePaint); + } + mBasePaint.setStyle(Paint.Style.FILL); + } +} \ No newline at end of file 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 ef00d29..3f62e48 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 @@ -2,7 +2,9 @@ package com.aldo.apps.ochecompanion.ui; import android.os.Bundle; +import androidx.preference.Preference; import androidx.preference.PreferenceFragmentCompat; +import androidx.preference.SwitchPreference; import com.aldo.apps.ochecompanion.R; @@ -23,5 +25,45 @@ public class MainMenuPreferencesFragment extends PreferenceFragmentCompat { @Override public void onCreatePreferences(final Bundle savedInstanceState, final String rootKey) { setPreferencesFromResource(R.xml.main_menu_preferences, rootKey); + // --- 1. Day/Night Auto-Disable Logic --- + final SwitchPreference autoPref = findPreference(getString(R.string.pref_key_day_night_mode_auto)); + // Use your string resource key here + final SwitchPreference manualPref = findPreference(getString(R.string.pref_key_day_night_mode)); + + if (autoPref != null && manualPref != null) { + // Set initial state: If Auto is ON, Manual is DISABLED + manualPref.setEnabled(!autoPref.isChecked()); + + autoPref.setOnPreferenceChangeListener((preference, newValue) -> { + boolean isAutoEnabled = (Boolean) newValue; + manualPref.setEnabled(!isAutoEnabled); + return true; + }); + } + + // --- 2. Button Toggles for Audio and Vibration --- + setupButtonToggle(getString(R.string.pref_key_audio_feedback), R.drawable.ic_audio_on, R.drawable.ic_audio_off); + setupButtonToggle(getString(R.string.pref_key_vibration_feedback), R.drawable.ic_vibration_on, R.drawable.ic_vibration_off); + } + + private void setupButtonToggle(String key, int iconOn, int iconOff) { + final Preference pref = findPreference(key); + if (pref != null) { + // Initialize icon based on current saved value + final boolean isEnabled = getPreferenceManager().getSharedPreferences().getBoolean(key, true); + pref.setIcon(isEnabled ? iconOn : iconOff); + + pref.setOnPreferenceClickListener(p -> { + boolean currentState = getPreferenceManager().getSharedPreferences().getBoolean(key, true); + boolean newState = !currentState; + + // Save the new state + getPreferenceManager().getSharedPreferences().edit().putBoolean(key, newState).apply(); + + // Update the icon visually + p.setIcon(newState ? iconOn : iconOff); + return true; + }); + } } } diff --git a/app/src/main/java/com/aldo/apps/ochecompanion/utils/converters/HitDistributionConverter.java b/app/src/main/java/com/aldo/apps/ochecompanion/utils/converters/HitDistributionConverter.java new file mode 100644 index 0000000..ffb248c --- /dev/null +++ b/app/src/main/java/com/aldo/apps/ochecompanion/utils/converters/HitDistributionConverter.java @@ -0,0 +1,68 @@ +package com.aldo.apps.ochecompanion.utils.converters; + +import androidx.room.TypeConverter; + +import com.google.gson.Gson; +import com.google.gson.reflect.TypeToken; + +import java.lang.reflect.Type; +import java.util.HashMap; +import java.util.Map; + +public class HitDistributionConverter { + + private static final Gson gson = new Gson(); + + /** + * Converts a JSON string from the database back into a Map for logic processing. + */ + @TypeConverter + public static Map fromString(final String value) { + if (value == null || value.isEmpty()) { + return new HashMap<>(); + } + final Type mapType = new TypeToken>() {}.getType(); + return gson.fromJson(value, mapType); + } + + /** + * Converts the Map into a JSON string to be stored in a single database column. + */ + @TypeConverter + public static String fromMap(final Map map) { + return gson.toJson(map == null ? new HashMap<>() : map); + } + + // ======================================================================================== + // Utility Methods for Stats Tracking + // ======================================================================================== + + /** + * Increments the count for a specific board segment (e.g., "t20", "d16", "sb"). + * * @param distribution The map to update. + * @param segmentKey The identifier for the hit segment. + */ + public static Map recordHit(final Map distribution, final String segmentKey) { + if (distribution == null || segmentKey == null) return distribution; + int currentCount = distribution.getOrDefault(segmentKey, 0); + distribution.put(segmentKey, currentCount + 1); + return distribution; + } + + /** + * Helper to generate standardized keys for the map based on value and multiplier. + * Use this in GameActivity to ensure consistent naming (e.g., 20 & 3 -> "t20"). + */ + public static String getSegmentKey(final int baseValue, final int multiplier) { + if (baseValue == 25) { + return multiplier == 2 ? "db" : "sb"; + } + + String prefix = ""; + if (multiplier == 3) prefix = "t"; + else if (multiplier == 2) prefix = "d"; + else prefix = "s"; + + return prefix + baseValue; + } +} \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_audio_off.xml b/app/src/main/res/drawable/ic_audio_off.xml new file mode 100644 index 0000000..23e5908 --- /dev/null +++ b/app/src/main/res/drawable/ic_audio_off.xml @@ -0,0 +1,11 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_audio_on.xml b/app/src/main/res/drawable/ic_audio_on.xml new file mode 100644 index 0000000..02e90be --- /dev/null +++ b/app/src/main/res/drawable/ic_audio_on.xml @@ -0,0 +1,11 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_vibration_off.xml b/app/src/main/res/drawable/ic_vibration_off.xml new file mode 100644 index 0000000..71985d6 --- /dev/null +++ b/app/src/main/res/drawable/ic_vibration_off.xml @@ -0,0 +1,11 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_vibration_on.xml b/app/src/main/res/drawable/ic_vibration_on.xml new file mode 100644 index 0000000..436e211 --- /dev/null +++ b/app/src/main/res/drawable/ic_vibration_on.xml @@ -0,0 +1,11 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml index 040b3f9..cb0c69b 100644 --- a/app/src/main/res/layout/activity_main.xml +++ b/app/src/main/res/layout/activity_main.xml @@ -26,6 +26,7 @@ android:contentDescription="@string/cd_txt_oche_logo"/> + + + + + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index b789a9a..547cd54 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -34,13 +34,21 @@ Submit Turn + day_night_mode_auto day_night_mode standard_game_mode + audio_feedback + vibration_feedback Standard 701 - Double Out Standard 501 - Double Out Standard 301 - Double Out Cricket - Day/Night Mode + Day/Night Mode (Automatic) + Manual Day/Night Mode + Audio Feedback + Toggle announcer sounds + Vibration Feedback + Toggle haptic effects Standard Game Mode The Standard Game Mode to be selected for the Quick Start\nCurrently selected: %s diff --git a/app/src/main/res/xml/main_menu_preferences.xml b/app/src/main/res/xml/main_menu_preferences.xml index 07d769d..b574a72 100644 --- a/app/src/main/res/xml/main_menu_preferences.xml +++ b/app/src/main/res/xml/main_menu_preferences.xml @@ -2,11 +2,6 @@ - - + + + + + + + + + + + + \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index e843518..ce22b0e 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -12,6 +12,7 @@ room = "2.8.4" preferences = "1.2.1" preference = "1.2.1" konfetti = "2.0.5" +gson = "2.13.2" [libraries] @@ -28,6 +29,7 @@ room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = preferences = { group = "androidx.preference", name="preference-ktx", version.ref="preferences" } preference = { group = "androidx.preference", name = "preference", version.ref = "preference" } konfetti = { group = "nl.dionsegijn", name = "konfetti-xml", version.ref = "konfetti" } +gson = { group = "com.google.code.gson", name = "gson", version.ref = "gson" } [plugins] android-application = { id = "com.android.application", version.ref = "agp" }