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<String, Integer>) 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
This commit is contained in:
Alexander Doerflinger
2026-02-02 14:27:14 +01:00
parent 60e707b9f6
commit 5e627aa50c
21 changed files with 1152 additions and 68 deletions

View File

@@ -47,4 +47,5 @@ dependencies {
annotationProcessor(libs.room.compiler) annotationProcessor(libs.room.compiler)
implementation(libs.preferences) implementation(libs.preferences)
implementation(libs.konfetti) implementation(libs.konfetti)
implementation(libs.gson)
} }

View File

@@ -6,11 +6,14 @@
that other apps created. that other apps created.
--> -->
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" /> <uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" <uses-permission
android:name="android.permission.READ_EXTERNAL_STORAGE"
android:maxSdkVersion="32" /> android:maxSdkVersion="32" />
<uses-permission android:name="android.permission.VIBRATE" />
<uses-permission android:name="android.permission.VIBRATE"/> <attribution
<attribution android:tag="oche_gameplay" android:label="@string/attribution_label" /> android:label="@string/attribution_label"
android:tag="oche_gameplay" />
<application <application
android:allowBackup="true" android:allowBackup="true"
@@ -21,6 +24,9 @@
android:roundIcon="@mipmap/ic_launcher_round" android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true" android:supportsRtl="true"
android:theme="@style/Theme.OcheCompanion"> android:theme="@style/Theme.OcheCompanion">
<activity
android:name=".TestActivity"
android:exported="false" />
<activity <activity
android:name=".SettingsActivity" android:name=".SettingsActivity"
android:exported="false" android:exported="false"

View File

@@ -2,6 +2,7 @@ package com.aldo.apps.ochecompanion;
import android.content.Context; import android.content.Context;
import android.content.Intent; import android.content.Intent;
import android.content.SharedPreferences;
import android.content.res.ColorStateList; import android.content.res.ColorStateList;
import android.graphics.Color; import android.graphics.Color;
import android.os.Build; import android.os.Build;
@@ -23,6 +24,7 @@ import androidx.core.content.ContextCompat;
import androidx.core.graphics.Insets; import androidx.core.graphics.Insets;
import androidx.core.view.ViewCompat; import androidx.core.view.ViewCompat;
import androidx.core.view.WindowInsetsCompat; import androidx.core.view.WindowInsetsCompat;
import androidx.preference.PreferenceManager;
import com.aldo.apps.ochecompanion.database.AppDatabase; import com.aldo.apps.ochecompanion.database.AppDatabase;
import com.aldo.apps.ochecompanion.database.objects.Player; import com.aldo.apps.ochecompanion.database.objects.Player;
@@ -107,12 +109,45 @@ public class GameActivity extends AppCompatActivity {
*/ */
private final List<Integer> mCurrentTurnDarts = new ArrayList<>(); private final List<Integer> 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<DartHit> mCurrentTurnDartHits = new ArrayList<>();
/** /**
* Flag indicating turn has ended (bust, win, or 3 darts thrown). * Flag indicating turn has ended (bust, win, or 3 darts thrown).
* Prevents additional dart entry until turn is submitted. * Prevents additional dart entry until turn is submitted.
*/ */
private boolean mIsTurnOver = false; 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. * Cached references to keyboard buttons (1-20) for efficient styling updates.
*/ */
@@ -173,8 +208,34 @@ public class GameActivity extends AppCompatActivity {
*/ */
private GridLayout glKeyboard; private GridLayout glKeyboard;
/**
* Singleton instance of SoundEngine for playing game sound effects.
*/
private SoundEngine mSoundEngine; 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<Long, Object> mPlayerStatsLocks = new java.util.HashMap<>();
/** /**
* Starts GameActivity with specified players and starting score. * Starts GameActivity with specified players and starting score.
* *
@@ -207,6 +268,7 @@ public class GameActivity extends AppCompatActivity {
return insets; return insets;
}); });
mSoundEngine = SoundEngine.getInstance(this); mSoundEngine = SoundEngine.getInstance(this);
mDatabase = AppDatabase.getDatabase(this);
// Extract game parameters from intent // Extract game parameters from intent
mStartingScore = getIntent().getIntExtra(EXTRA_START_SCORE, DartsConstants.DEFAULT_GAME_SCORE); mStartingScore = getIntent().getIntExtra(EXTRA_START_SCORE, DartsConstants.DEFAULT_GAME_SCORE);
@@ -221,6 +283,14 @@ public class GameActivity extends AppCompatActivity {
}).start(); }).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. * 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)) { if (scoreAfterDart < 0 || scoreAfterDart == DartsConstants.BUST_SCORE || (scoreAfterDart == 0 && !isDouble)) {
// BUST CONDITION: Score < 0, Score == 1, or Score == 0 on a non-double // BUST CONDITION: Score < 0, Score == 1, or Score == 0 on a non-double
mCurrentTurnDarts.add(points); 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(); updateTurnIndicators();
mIsTurnOver = true; mIsTurnOver = true;
mSoundEngine.playBustedSound(); if (mIsAudioEnabled) {
mSoundEngine.playBustedSound();
}
Toast.makeText(this, "BUST!", Toast.LENGTH_SHORT).show(); Toast.makeText(this, "BUST!", Toast.LENGTH_SHORT).show();
// In a pro interface, we usually wait for "Submit" or auto-submit after a short delay // In a pro interface, we usually wait for "Submit" or auto-submit after a short delay
} else if (scoreAfterDart == 0 && isDouble) { } else if (scoreAfterDart == 0 && isDouble) {
// VICTORY CONDITION // VICTORY CONDITION
mCurrentTurnDarts.add(points); mCurrentTurnDarts.add(points);
mCurrentTurnDartHits.add(new DartHit(baseValue, mMultiplier));
// Track successful double-out
trackDoubleAttempt(active, false);
updateTurnIndicators(); updateTurnIndicators();
mIsTurnOver = true; mIsTurnOver = true;
handleWin(active, mCurrentTurnDarts.size(), scoreBeforeDart); handleWin(active, mCurrentTurnDarts.size(), scoreBeforeDart);
} else { } else {
// VALID THROW // VALID THROW
mCurrentTurnDarts.add(points); mCurrentTurnDarts.add(points);
mCurrentTurnDartHits.add(new DartHit(baseValue, mMultiplier));
updateTurnIndicators(); updateTurnIndicators();
updateUI(); updateUI();
@@ -400,23 +485,76 @@ public class GameActivity extends AppCompatActivity {
* @param pointsMade Total points scored this turn * @param pointsMade Total points scored this turn
* @param wasBust Whether the turn resulted in a bust * @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(() -> { new Thread(() -> {
final Player player = active.player; final Player player = active.player;
if (player != null && player.id != 0) { if (player != null && player.id != 0) {
final Statistics playerStats = AppDatabase.getDatabase(GameActivity.this).statisticsDao().getStatisticsForPlayer(player.id); final Statistics playerStats = AppDatabase.getDatabase(GameActivity.this).statisticsDao().getStatisticsForPlayer(player.id);
playerStats.saveDartsThrown(dartsThrown, pointsMade);
Log.d(TAG, "submitTurn: dartsThrown = [" + dartsThrown + "], pointsMade = [" + pointsMade + "]"); // Track darts thrown or missed
if (!wasBust && dartsThrown < 3) { if (wasBust) {
playerStats.addMissedDarts(3 - dartsThrown); // 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); AppDatabase.getDatabase(GameActivity.this).statisticsDao().updateStatistics(playerStats);
// Calculate career average: total points / total darts thrown // Calculate career average: total points / total darts thrown
final long totalDarts = playerStats.getDartsThrown(); final long totalDarts = playerStats.getTotalDartsThrown();
if (totalDarts > 0) { if (totalDarts > 0) {
player.careerAverage = (double) playerStats.getOverallPointsMade() / totalDarts * 3; player.careerAverage = (double) playerStats.getTotalPoints() / totalDarts * 3;
} else { } else {
player.careerAverage = 0.0; player.careerAverage = 0.0;
} }
@@ -445,20 +583,24 @@ public class GameActivity extends AppCompatActivity {
final Animation shakeAnimation = AnimationUtils.loadAnimation(this, R.anim.shake); final Animation shakeAnimation = AnimationUtils.loadAnimation(this, R.anim.shake);
final View main = findViewById(R.id.main); final View main = findViewById(R.id.main);
main.startAnimation(shakeAnimation); main.startAnimation(shakeAnimation);
if (mIsVibrationEnabled) {
final Vibrator vibrator = (Vibrator) getSystemService(VIBRATOR_SERVICE); final Vibrator vibrator = (Vibrator) getSystemService(VIBRATOR_SERVICE);
if (vibrator != null && Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { if (vibrator != null && Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
Log.d(TAG, "submitTurn: Pattern vibration"); Log.d(TAG, "submitTurn: Pattern vibration");
// Pattern that should match the 180 shout. // Pattern that should match the 180 shout.
long[] pattern = {0, 150, 100, 1650, 50, 150, 10, 500, 300, 200}; long[] pattern = {0, 150, 100, 1650, 50, 150, 10, 500, 300, 200};
vibrator.vibrate(VibrationEffect.createWaveform(pattern, -1)); vibrator.vibrate(VibrationEffect.createWaveform(pattern, -1));
} else if (vibrator != null) { } else if (vibrator != null) {
Log.d(TAG, "submitTurn: Vibrating legacy mode"); Log.d(TAG, "submitTurn: Vibrating legacy mode");
vibrator.vibrate(500); vibrator.vibrate(500);
} else { } else {
Log.e(TAG, "submitTurn: Vibrator not available"); Log.e(TAG, "submitTurn: Vibrator not available");
}
}
if (mIsAudioEnabled) {
mSoundEngine.playOneHundredAndEightySound();
} }
mSoundEngine.playOneHundredAndEightySound();
} }
// Re-check logic for non-double finish or score of 1 // 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); 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 // Rotate to next player
mActivePlayerIndex = (mActivePlayerIndex + 1) % mPlayerStates.size(); mActivePlayerIndex = (mActivePlayerIndex + 1) % mPlayerStates.size();
// Reset turn state // Reset turn state
mCurrentTurnDarts.clear(); mCurrentTurnDarts.clear();
mCurrentTurnDartHits.clear();
mIsTurnOver = false; mIsTurnOver = false;
// Update UI for next player // Update UI for next player
@@ -612,21 +759,122 @@ public class GameActivity extends AppCompatActivity {
return String.valueOf(score); 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<DartHit> 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. * Handles win condition when a player finishes on zero with a double.
* Updates statistics, displays win toast, and plays celebration animation. * Updates statistics, displays win toast, and plays celebration animation.
* *
* @param winner X01State of the winning player * @param winner X01State of the winning player
* @param dartsThrown Number of darts thrown in the winning turn * @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) { 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 // 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); playWinnerAnimation(winner.name);
mSoundEngine.playWinnerSound(); if (mIsAudioEnabled) {
mSoundEngine.playWinnerSound();
}
// TODO: Consider adding: // TODO: Consider adding:
// - Statistics display // - Statistics display
@@ -682,6 +930,11 @@ public class GameActivity extends AppCompatActivity {
*/ */
final Player player; 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. * 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) { X01State(final Player player, final int startScore) {
this.player = player; this.player = player;
this.playerId = player.id;
this.name = player.username; this.name = player.username;
this.remainingScore = startScore; this.remainingScore = startScore;
} }

View File

@@ -4,6 +4,7 @@ import android.content.Intent;
import android.content.SharedPreferences; import android.content.SharedPreferences;
import android.os.Bundle; import android.os.Bundle;
import android.util.Log; import android.util.Log;
import android.view.View;
import android.widget.TextView; import android.widget.TextView;
import androidx.activity.EdgeToEdge; import androidx.activity.EdgeToEdge;
@@ -91,6 +92,8 @@ public class MainMenuActivity extends AppCompatActivity {
applyTestData(mTestCounter); applyTestData(mTestCounter);
mTestCounter++; mTestCounter++;
}); });
findViewById(R.id.title_view).setOnClickListener(v -> startActivity(new Intent(MainMenuActivity.this, TestActivity.class)));
} }
/** /**

View File

@@ -44,4 +44,6 @@ public class SettingsActivity extends AppCompatActivity {
actionBar.setDisplayHomeAsUpEnabled(true); actionBar.setDisplayHomeAsUpEnabled(true);
} }
} }
} }

View File

@@ -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<Player> 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();
}
}

View File

@@ -4,6 +4,7 @@ import android.content.Context;
import androidx.room.Database; import androidx.room.Database;
import androidx.room.Room; import androidx.room.Room;
import androidx.room.RoomDatabase; import androidx.room.RoomDatabase;
import androidx.room.TypeConverters;
import com.aldo.apps.ochecompanion.database.dao.MatchDao; import com.aldo.apps.ochecompanion.database.dao.MatchDao;
import com.aldo.apps.ochecompanion.database.dao.PlayerDao; 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.Match;
import com.aldo.apps.ochecompanion.database.objects.Player; import com.aldo.apps.ochecompanion.database.objects.Player;
import com.aldo.apps.ochecompanion.database.objects.Statistics; import com.aldo.apps.ochecompanion.database.objects.Statistics;
import com.aldo.apps.ochecompanion.utils.converters.HitDistributionConverter;
/** /**
* Main Room database class for the Oche Companion darts application. * 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 Player
* @see Match * @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 { public abstract class AppDatabase extends RoomDatabase {
/** /**

View File

@@ -58,8 +58,6 @@ public class Player implements Parcelable {
this.profilePictureUri = profilePictureUri; this.profilePictureUri = profilePictureUri;
} }
/** /**
* Parcelable constructor to reconstruct Player from Parcel. * Parcelable constructor to reconstruct Player from Parcel.
* *

View File

@@ -1,8 +1,16 @@
package com.aldo.apps.ochecompanion.database.objects; package com.aldo.apps.ochecompanion.database.objects;
import android.util.Log;
import androidx.annotation.NonNull;
import androidx.room.Entity; import androidx.room.Entity;
import androidx.room.ForeignKey;
import androidx.room.PrimaryKey; 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. * Room database entity representing player performance statistics.
@@ -16,6 +24,11 @@ import androidx.room.PrimaryKey;
@Entity(tableName = "statistics") @Entity(tableName = "statistics")
public class Statistics { public class Statistics {
/**
* Tag for debugging purposes.
*/
private static final String TAG = "Statistics";
/** /**
* Player ID this statistics record belongs to (primary key). * Player ID this statistics record belongs to (primary key).
* References the Player entity's ID field. * References the Player entity's ID field.
@@ -23,11 +36,17 @@ public class Statistics {
@PrimaryKey @PrimaryKey
private long playerId; 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. * Total number of darts thrown across all matches.
* Used to calculate career average and accuracy metrics. * Used to calculate career average and accuracy metrics.
*/ */
private long dartsThrown; private long totalDartsThrown;
/** /**
* Total number of darts that missed the intended target. * Total number of darts that missed the intended target.
@@ -36,22 +55,64 @@ public class Statistics {
private long dartsMissed; private long dartsMissed;
/** /**
* Cumulative points scored across all darts thrown. * Total points scored in the first 9 darts of matches.
* Used with dartsThrown to calculate career average. * 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. * Total number of completed matches for this player.
* Provides context for statistical significance. * Provides context for statistical significance.
*/ */
private int matchesPlayed; 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). * Total number of double-out attempts (both successful and missed).
* Tracks how often player attempts to finish on doubles. * Tracks how often player attempts to finish on doubles.
*/ */
private int doubleOutsTargeted; private int totalDartsAtDouble;
/** /**
* Number of failed double-out attempts. * Number of failed double-out attempts.
@@ -59,6 +120,13 @@ public class Statistics {
*/ */
private int doubleOutsMissed; 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<String, Integer> hitDistribution;
/** /**
* Constructs a new Statistics record for a player. * Constructs a new Statistics record for a player.
* Initializes all counters to zero. * Initializes all counters to zero.
@@ -67,11 +135,12 @@ public class Statistics {
*/ */
public Statistics(final long playerId) { public Statistics(final long playerId) {
this.playerId = playerId; this.playerId = playerId;
dartsThrown = 0; totalDartsThrown = 0;
overallPointsMade = 0; totalPoints = 0;
matchesPlayed = 0; matchesPlayed = 0;
doubleOutsTargeted = 0; totalDartsAtDouble = 0;
doubleOutsMissed = 0; doubleOutsMissed = 0;
hitDistribution = new HashMap<>();
} }
/** /**
@@ -81,8 +150,8 @@ public class Statistics {
* @param pointsMade Total points scored with those darts * @param pointsMade Total points scored with those darts
*/ */
public void saveDartsThrown(final long dartsThrown, final long pointsMade) { public void saveDartsThrown(final long dartsThrown, final long pointsMade) {
this.dartsThrown += dartsThrown; this.totalDartsThrown += dartsThrown;
this.overallPointsMade += pointsMade; this.totalPoints += pointsMade;
} }
/** /**
@@ -109,78 +178,423 @@ public class Statistics {
* @param isMissed true if the double-out was missed, false if successful * @param isMissed true if the double-out was missed, false if successful
*/ */
public void addDoubleOutTarget(final boolean isMissed) { public void addDoubleOutTarget(final boolean isMissed) {
doubleOutsTargeted++; totalDartsAtDouble++;
if (isMissed) { if (isMissed) {
doubleOutsMissed++; 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<String, Integer> 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<String, Integer> 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() { public long getPlayerId() {
return playerId; 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; 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() { public long getDartsMissed() {
return dartsMissed; 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; 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() { public int getMatchesPlayed() {
return matchesPlayed; 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; 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() { public int getDoubleOutsMissed() {
return doubleOutsMissed; 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; 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 @Override
public String toString() { public String toString() {
return "Statistics{" + return "Statistics{" +
"playerId=" + playerId + "playerId=" + playerId +
", dartsThrown=" + dartsThrown + ", totalPoints=" + totalPoints +
", totalDartsThrown=" + totalDartsThrown +
", dartsMissed=" + dartsMissed + ", dartsMissed=" + dartsMissed +
", overallPointsMade=" + overallPointsMade + ", totalFirst9Points=" + totalFirst9Points +
", totalFirst9Darts=" + totalFirst9Darts +
", count60Plus=" + count60Plus +
", count100Plus=" + count100Plus +
", count140Plus=" + count140Plus +
", count180=" + count180 +
", matchesPlayed=" + matchesPlayed + ", matchesPlayed=" + matchesPlayed +
", doubleOutsTargeted=" + doubleOutsTargeted + ", successfulCheckouts=" + successfulCheckouts +
", highestCheckout=" + highestCheckout +
", totalDartsAtDouble=" + totalDartsAtDouble +
", doubleOutsMissed=" + doubleOutsMissed + ", doubleOutsMissed=" + doubleOutsMissed +
", hitDistribution=" + (hitDistribution != null ? hitDistribution.size() + " segments" : "null") +
'}'; '}';
} }
} }

View File

@@ -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<String, Path> 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<String, Path> 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);
}
}

View File

@@ -2,7 +2,9 @@ package com.aldo.apps.ochecompanion.ui;
import android.os.Bundle; import android.os.Bundle;
import androidx.preference.Preference;
import androidx.preference.PreferenceFragmentCompat; import androidx.preference.PreferenceFragmentCompat;
import androidx.preference.SwitchPreference;
import com.aldo.apps.ochecompanion.R; import com.aldo.apps.ochecompanion.R;
@@ -23,5 +25,45 @@ public class MainMenuPreferencesFragment extends PreferenceFragmentCompat {
@Override @Override
public void onCreatePreferences(final Bundle savedInstanceState, final String rootKey) { public void onCreatePreferences(final Bundle savedInstanceState, final String rootKey) {
setPreferencesFromResource(R.xml.main_menu_preferences, 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;
});
}
} }
} }

View File

@@ -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<String, Integer> fromString(final String value) {
if (value == null || value.isEmpty()) {
return new HashMap<>();
}
final Type mapType = new TypeToken<Map<String, Integer>>() {}.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<String, Integer> 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<String, Integer> recordHit(final Map<String, Integer> 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;
}
}

View File

@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M16.5,12c0,-1.77 -1.02,-3.29 -2.5,-4.03v2.21l2.45,2.45c0.03,-0.2 0.05,-0.41 0.05,-0.63zM19,12c0,0.94 -0.2,1.82 -0.54,2.64l1.51,1.51C20.63,14.91 21,13.5 21,12c0,-4.28 -2.99,-7.86 -7,-8.77v2.06c2.89,0.86 5,3.54 5,6.71zM4.27,3L3,4.27 7.73,9H3v6h4l5,5v-6.73l4.25,4.25c-0.67,0.52 -1.42,0.93 -2.25,1.18v2.06c1.38,-0.31 2.63,-0.95 3.69,-1.81L19.73,21 21,19.73l-9,-9L4.27,3zM12,4L9.91,6.09 12,8.18V4z"/>
</vector>

View File

@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M3,9v6h4l5,5V4L7,9H3zM16.5,12c0,-1.77 -1.02,-3.29 -2.5,-4.03v8.05c1.48,-0.73 2.5,-2.25 2.5,-4.02zM14,3.23v2.06c2.89,0.86 5,3.54 5,6.71s-2.11,5.85 -5,6.71v2.06c4.01,-0.91 7,-4.49 7,-8.77s-2.99,-7.86 -7,-8.77z"/>
</vector>

View File

@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M16.5,3h-9C6.67,3 6,3.67 6,4.5v15c0,0.83 0.67,1.5 1.5,1.5h9c0.83,0 1.5,-0.67 1.5,-1.5v-15c0,-0.83 -0.67,-1.5 -1.5,-1.5zM16,19H8V5h8v14z"/>
</vector>

View File

@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M0,15h2V9H0v6zm3,2h2V7H3v10zm19,-8v6h2V9h-2zm-3,8h2V7h-2v10zM16.5,3h-9C6.67,3 6,3.67 6,4.5v15c0,0.83 0.67,1.5 1.5,1.5h9c0.83,0,1.5,-0.67 1.5,-1.5v-15c0,-0.83 -0.67,-1.5 -1.5,-1.5zM16,19H8V5h8v14z"/>
</vector>

View File

@@ -26,6 +26,7 @@
android:contentDescription="@string/cd_txt_oche_logo"/> android:contentDescription="@string/cd_txt_oche_logo"/>
<TextView <TextView
android:id="@+id/title_view"
android:layout_width="0dp" android:layout_width="0dp"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginStart="@dimen/spacing_s" android:layout_marginStart="@dimen/spacing_s"

View File

@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/main"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".TestActivity">
<com.aldo.apps.ochecompanion.ui.HeatmapView
android:id="@+id/heatmap"
android:layout_width="300dp"
android:layout_height="300dp"
android:layout_gravity="center" />
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@@ -34,13 +34,21 @@
<string name="txt_game_btn_submit">Submit Turn</string> <string name="txt_game_btn_submit">Submit Turn</string>
<!-- Preference Strings --> <!-- Preference Strings -->
<string name="pref_key_day_night_mode_auto">day_night_mode_auto</string>
<string name="pref_key_day_night_mode">day_night_mode</string> <string name="pref_key_day_night_mode">day_night_mode</string>
<string name="pref_key_standard_game_mode">standard_game_mode</string> <string name="pref_key_standard_game_mode">standard_game_mode</string>
<string name="pref_key_audio_feedback">audio_feedback</string>
<string name="pref_key_vibration_feedback">vibration_feedback</string>
<string name="pref_game_mode_701_value">Standard 701 - Double Out</string> <string name="pref_game_mode_701_value">Standard 701 - Double Out</string>
<string name="pref_game_mode_501_value">Standard 501 - Double Out</string> <string name="pref_game_mode_501_value">Standard 501 - Double Out</string>
<string name="pref_game_mode_301_value">Standard 301 - Double Out</string> <string name="pref_game_mode_301_value">Standard 301 - Double Out</string>
<string name="pref_game_mode_cricket_value">Cricket</string> <string name="pref_game_mode_cricket_value">Cricket</string>
<string name="pref_desc_day_night_mode">Day/Night Mode</string> <string name="pref_desc_day_night_mode_auto">Day/Night Mode (Automatic)</string>
<string name="pref_desc_day_night_mode">Manual Day/Night Mode</string>
<string name="pref_title_audio_feedback">Audio Feedback</string>
<string name="pref_desc_audio_feedback">Toggle announcer sounds</string>
<string name="pref_title_vibration_feedback">Vibration Feedback</string>
<string name="pref_desc_vibration_feedback">Toggle haptic effects</string>
<string name="pref_title_standard_game_mode">Standard Game Mode</string> <string name="pref_title_standard_game_mode">Standard Game Mode</string>
<string name="pref_desc_standard_game_mode">The Standard Game Mode to be selected for the Quick Start\nCurrently selected: %s</string> <string name="pref_desc_standard_game_mode">The Standard Game Mode to be selected for the Quick Start\nCurrently selected: %s</string>

View File

@@ -2,11 +2,6 @@
<PreferenceScreen xmlns:android="http://schemas.android.com/apk/res/android" <PreferenceScreen xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"> xmlns:app="http://schemas.android.com/apk/res-auto">
<SwitchPreference
app:key="@string/pref_key_day_night_mode"
app:title="@string/pref_desc_day_night_mode"
android:icon="@drawable/ic_day_night_mode"/>
<ListPreference <ListPreference
app:key="@string/pref_key_standard_game_mode" app:key="@string/pref_key_standard_game_mode"
app:title="@string/pref_title_standard_game_mode" app:title="@string/pref_title_standard_game_mode"
@@ -16,4 +11,31 @@
android:entries="@array/pref_standard_game_mode_labels" android:entries="@array/pref_standard_game_mode_labels"
android:entryValues="@array/pref_standard_game_mode_values" /> android:entryValues="@array/pref_standard_game_mode_values" />
<PreferenceCategory app:title="Game Feedback">
<Preference
app:key="@string/pref_key_audio_feedback"
app:title="@string/pref_title_audio_feedback"
app:summary="@string/pref_desc_audio_feedback"
android:icon="@drawable/ic_audio_on" />
<Preference
app:key="@string/pref_key_vibration_feedback"
app:title="@string/pref_title_vibration_feedback"
app:summary="@string/pref_desc_vibration_feedback"
android:icon="@drawable/ic_vibration_on" />
</PreferenceCategory>
<PreferenceCategory app:title="Appearance">
<SwitchPreference
app:key="@string/pref_key_day_night_mode_auto"
app:title="@string/pref_desc_day_night_mode_auto"
android:defaultValue="true"
android:icon="@drawable/ic_day_night_mode" />
<SwitchPreference
app:key="@string/pref_key_day_night_mode"
app:title="@string/pref_desc_day_night_mode"
android:icon="@drawable/ic_day_night_mode" />
</PreferenceCategory>
</PreferenceScreen> </PreferenceScreen>

View File

@@ -12,6 +12,7 @@ room = "2.8.4"
preferences = "1.2.1" preferences = "1.2.1"
preference = "1.2.1" preference = "1.2.1"
konfetti = "2.0.5" konfetti = "2.0.5"
gson = "2.13.2"
[libraries] [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" } preferences = { group = "androidx.preference", name="preference-ktx", version.ref="preferences" }
preference = { group = "androidx.preference", name = "preference", version.ref = "preference" } preference = { group = "androidx.preference", name = "preference", version.ref = "preference" }
konfetti = { group = "nl.dionsegijn", name = "konfetti-xml", version.ref = "konfetti" } konfetti = { group = "nl.dionsegijn", name = "konfetti-xml", version.ref = "konfetti" }
gson = { group = "com.google.code.gson", name = "gson", version.ref = "gson" }
[plugins] [plugins]
android-application = { id = "com.android.application", version.ref = "agp" } android-application = { id = "com.android.application", version.ref = "agp" }