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:
@@ -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)
|
||||||
}
|
}
|
||||||
@@ -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 android:tag="oche_gameplay" android:label="@string/attribution_label" />
|
|
||||||
|
<attribution
|
||||||
|
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"
|
||||||
|
|||||||
@@ -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;
|
||||||
|
if (mIsAudioEnabled) {
|
||||||
mSoundEngine.playBustedSound();
|
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);
|
||||||
|
|
||||||
|
// 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);
|
playerStats.saveDartsThrown(dartsThrown, pointsMade);
|
||||||
Log.d(TAG, "submitTurn: dartsThrown = [" + dartsThrown + "], pointsMade = [" + pointsMade + "]");
|
Log.d(TAG, "updatePlayerStats: dartsThrown = [" + dartsThrown + "], pointsMade = [" + pointsMade + "]");
|
||||||
if (!wasBust && dartsThrown < 3) {
|
|
||||||
|
// Track missed darts if turn ended early (less than 3 darts)
|
||||||
|
if (dartsThrown < 3) {
|
||||||
playerStats.addMissedDarts(3 - dartsThrown);
|
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,6 +583,7 @@ 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) {
|
||||||
@@ -458,8 +597,11 @@ public class GameActivity extends AppCompatActivity {
|
|||||||
} 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
|
||||||
int lastDartValue = mCurrentTurnDarts.get(mCurrentTurnDarts.size() - 1);
|
int lastDartValue = mCurrentTurnDarts.get(mCurrentTurnDarts.size() - 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);
|
||||||
|
if (mIsAudioEnabled) {
|
||||||
mSoundEngine.playWinnerSound();
|
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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -44,4 +44,6 @@ public class SettingsActivity extends AppCompatActivity {
|
|||||||
actionBar.setDisplayHomeAsUpEnabled(true);
|
actionBar.setDisplayHomeAsUpEnabled(true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -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,10 +55,40 @@ 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.
|
||||||
@@ -47,11 +96,23 @@ public class Statistics {
|
|||||||
*/
|
*/
|
||||||
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") +
|
||||||
'}';
|
'}';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
11
app/src/main/res/drawable/ic_audio_off.xml
Normal file
11
app/src/main/res/drawable/ic_audio_off.xml
Normal 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>
|
||||||
11
app/src/main/res/drawable/ic_audio_on.xml
Normal file
11
app/src/main/res/drawable/ic_audio_on.xml
Normal 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>
|
||||||
11
app/src/main/res/drawable/ic_vibration_off.xml
Normal file
11
app/src/main/res/drawable/ic_vibration_off.xml
Normal 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>
|
||||||
11
app/src/main/res/drawable/ic_vibration_on.xml
Normal file
11
app/src/main/res/drawable/ic_vibration_on.xml
Normal 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>
|
||||||
@@ -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"
|
||||||
|
|||||||
16
app/src/main/res/layout/activity_test.xml
Normal file
16
app/src/main/res/layout/activity_test.xml
Normal 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>
|
||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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" }
|
||||||
|
|||||||
Reference in New Issue
Block a user