Match Statistics

Made the Statistics View for after a Match "MultiUser" capable and filled with actual Match Statistics instead of the Player Stats.
This commit is contained in:
Alexander Doerflinger
2026-02-18 11:28:51 +01:00
parent 543a6283de
commit 847b8e1d7e
11 changed files with 538 additions and 112 deletions

View File

@@ -28,7 +28,6 @@ import androidx.preference.PreferenceManager;
import com.aldo.apps.ochecompanion.database.DatabaseHelper;
import com.aldo.apps.ochecompanion.database.objects.Player;
import com.aldo.apps.ochecompanion.database.objects.Statistics;
import com.aldo.apps.ochecompanion.game.GameManager;
import com.aldo.apps.ochecompanion.ui.PlayerStatsView;
import com.aldo.apps.ochecompanion.utils.CheckoutEngine;
@@ -37,6 +36,7 @@ import com.aldo.apps.ochecompanion.utils.Log;
import com.aldo.apps.ochecompanion.utils.SoundEngine;
import com.aldo.apps.ochecompanion.utils.UIConstants;
import com.google.android.material.button.MaterialButton;
import android.widget.ImageButton;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
@@ -72,6 +72,17 @@ public class GameActivity extends BaseActivity implements GameManager.GameStateC
*/
private boolean mAreStatsShown = false;
/**
* Index of the player currently shown in the stats view.
* Used for cycling through players with the navigation arrows.
*/
private int mStatsPlayerIndex = 0;
/**
* Cached list of all player states for stats navigation.
*/
private List<GameManager.PlayerState> mMatchPlayerStates;
// ========================================================================================
// Game Manager (Singleton Business Logic Handler)
// ========================================================================================
@@ -157,6 +168,18 @@ public class GameActivity extends BaseActivity implements GameManager.GameStateC
*/
private PlayerStatsView mStatsView;
/**
* Navigation bar for switching between player stats.
*/
private View mStatsNavBar;
/**
* Previous/Next buttons and indicator for stats navigation.
*/
private ImageButton mBtnStatsPrev;
private ImageButton mBtnStatsNext;
private TextView mTvStatsPlayerIndicator;
/**
* Array of three TextViews showing darts thrown in current turn.
*/
@@ -277,6 +300,7 @@ public class GameActivity extends BaseActivity implements GameManager.GameStateC
private void handleBackPressed() {
if (mAreStatsShown) {
mStatsView.setVisibility(View.GONE);
mStatsNavBar.setVisibility(View.GONE);
mShowStatsBtn.setVisibility(View.VISIBLE);
mAreStatsShown = false;
return;
@@ -314,10 +338,34 @@ public class GameActivity extends BaseActivity implements GameManager.GameStateC
mShowStatsBtn = findViewById(R.id.show_stats_btn);
mStatsView = findViewById(R.id.player_stats_view);
// Stats navigation bar
mStatsNavBar = findViewById(R.id.stats_nav_bar);
mBtnStatsPrev = findViewById(R.id.btn_stats_prev);
mBtnStatsNext = findViewById(R.id.btn_stats_next);
mTvStatsPlayerIndicator = findViewById(R.id.tv_stats_player_indicator);
mShowStatsBtn.setOnClickListener(v -> {
mStatsView.setVisibility(View.VISIBLE);
mShowStatsBtn.setVisibility(View.GONE);
mStatsNavBar.setVisibility(View.VISIBLE);
mAreStatsShown = true;
showStatsForPlayer(mStatsPlayerIndex);
});
mBtnStatsPrev.setOnClickListener(v -> {
if (mMatchPlayerStates != null && !mMatchPlayerStates.isEmpty()) {
mStatsPlayerIndex = (mStatsPlayerIndex - 1 + mMatchPlayerStates.size())
% mMatchPlayerStates.size();
showStatsForPlayer(mStatsPlayerIndex);
}
});
mBtnStatsNext.setOnClickListener(v -> {
if (mMatchPlayerStates != null && !mMatchPlayerStates.isEmpty()) {
mStatsPlayerIndex = (mStatsPlayerIndex + 1) % mMatchPlayerStates.size();
showStatsForPlayer(mStatsPlayerIndex);
}
});
mSubmitTurnBtn.setOnClickListener(v -> submitTurn());
@@ -542,24 +590,47 @@ public class GameActivity extends BaseActivity implements GameManager.GameStateC
}
/**
* Retrieves the winning player's statistics from the database and binds them to
* the stats view.
* Runs database query on a background thread to avoid blocking the UI.
* Caches all player states from the completed match and shows stats for the
* winner (first player in the list, index 0, which is typically the active
* player at match end). The stats view will show match-specific stats.
*/
private void attachPlayerStats() {
new Thread(() -> {
try {
final GameManager.PlayerState activePlayer = mGameManager.getActivePlayer();
if (activePlayer == null)
mMatchPlayerStates = mGameManager.getPlayerStates();
if (mMatchPlayerStates == null || mMatchPlayerStates.isEmpty()) {
Log.e(TAG, "attachPlayerStats: No player states available");
return;
final Player player = activePlayer.player;
final Statistics statistics = mDatabaseHelper.getStatisticsForPlayer(player.id);
runOnUiThread(() -> mStatsView.bind(player, statistics));
} catch (Exception e) {
Log.e(TAG, "attachPlayerStats: Failed to retrieve player statistics", e);
}
}).start();
// Find the winner (remainingScore == 0) and start with them
mStatsPlayerIndex = 0;
for (int i = 0; i < mMatchPlayerStates.size(); i++) {
if (mMatchPlayerStates.get(i).remainingScore == 0) {
mStatsPlayerIndex = i;
break;
}
}
showStatsForPlayer(mStatsPlayerIndex);
}
/**
* Binds match-specific stats for the player at the given index to the stats
* view.
* Updates the navigation indicator to show current position (e.g., "1 / 3").
*
* @param index Index of the player in {@link #mMatchPlayerStates}
*/
private void showStatsForPlayer(final int index) {
if (mMatchPlayerStates == null || index < 0 || index >= mMatchPlayerStates.size()) {
return;
}
final GameManager.PlayerState playerState = mMatchPlayerStates.get(index);
mStatsView.bindMatchStats(playerState);
// Update navigation indicator
mTvStatsPlayerIndicator.setText(
String.format("%s (%d / %d)", playerState.name, index + 1, mMatchPlayerStates.size()));
}
/**

View File

@@ -351,7 +351,8 @@ public class DatabaseHelper {
player.id,
player.username,
startingScore, // Initial score equals starting score
0 // No darts thrown yet
0, // No darts thrown yet
0, 0, 0, 0, 0, 0, 0, 0, null // Per-match stats start at zero
));
}
} else {
@@ -360,7 +361,9 @@ public class DatabaseHelper {
0L, // Guest has ID 0
"GUEST",
startingScore,
0));
0, // No darts thrown yet
0, 0, 0, 0, 0, 0, 0, 0, null // Per-match stats start at zero
));
}
// Convert to JSON

View File

@@ -8,10 +8,13 @@ import com.aldo.apps.ochecompanion.database.objects.Player;
import com.aldo.apps.ochecompanion.utils.DartsConstants;
import com.aldo.apps.ochecompanion.utils.Log;
import com.aldo.apps.ochecompanion.utils.MatchProgress;
import com.aldo.apps.ochecompanion.utils.converters.HitDistributionConverter;
import com.aldo.apps.ochecompanion.utils.converters.MatchProgressConverter;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* GameManager: Singleton manager for handling all X01 game business logic.
@@ -195,7 +198,7 @@ public class GameManager {
/**
* State holder for a single player's X01 game progress.
* Tracks current match state for an individual player.
* Tracks current match state and per-match statistics for an individual player.
*/
public static class PlayerState {
/**
@@ -228,6 +231,35 @@ public class GameManager {
*/
public int dartsThrown = 0;
// ---- Per-Match Statistics ----
/** Total points scored in this match (for match average calculation). */
public int matchPointsScored = 0;
/** Number of busted turns in this match. */
public int matchBustCount = 0;
/** Highest single-turn score in this match. */
public int matchHighestTurnScore = 0;
/** Checkout value if this player won, 0 otherwise. */
public int matchCheckoutValue = 0;
/** Count of turns scoring 60+ points in this match. */
public int matchCount60Plus = 0;
/** Count of turns scoring 100+ points in this match. */
public int matchCount100Plus = 0;
/** Count of turns scoring 140+ points in this match. */
public int matchCount140Plus = 0;
/** Count of 180s scored in this match. */
public int matchCount180 = 0;
/** Per-match dart hit distribution for heatmap rendering. */
public Map<String, Integer> matchHitDistribution = new HashMap<>();
/**
* Constructs a PlayerState for a player with the specified starting score.
*
@@ -240,6 +272,28 @@ public class GameManager {
this.name = player.username;
this.remainingScore = startScore;
}
/**
* Returns the match average (points per 3 darts) for this match.
*
* @return Match average, or 0.0 if no darts have been thrown
*/
public double getMatchAverage() {
if (dartsThrown == 0)
return 0.0;
return (double) matchPointsScored / dartsThrown * 3;
}
/**
* Records a dart hit to the per-match hit 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 recordMatchDartHit(final int baseValue, final int multiplier) {
final String key = HitDistributionConverter.getSegmentKey(baseValue, multiplier);
matchHitDistribution = HitDistributionConverter.recordHit(matchHitDistribution, key);
}
}
/**
@@ -407,12 +461,25 @@ public class GameManager {
// Restore active player index
mActivePlayerIndex = progress.activePlayerIndex;
// Restore player scores and darts thrown
// Restore player scores, darts, and per-match stats
for (int i = 0; i < progress.players.size() && i < mPlayerStates.size(); i++) {
MatchProgress.PlayerStateSnapshot snapshot = progress.players.get(i);
PlayerState state = mPlayerStates.get(i);
state.remainingScore = snapshot.remainingScore;
state.dartsThrown = snapshot.dartsThrown;
// Restore per-match stats
state.matchPointsScored = snapshot.matchPointsScored;
state.matchBustCount = snapshot.matchBustCount;
state.matchHighestTurnScore = snapshot.matchHighestTurnScore;
state.matchCheckoutValue = snapshot.matchCheckoutValue;
state.matchCount60Plus = snapshot.matchCount60Plus;
state.matchCount100Plus = snapshot.matchCount100Plus;
state.matchCount140Plus = snapshot.matchCount140Plus;
state.matchCount180 = snapshot.matchCount180;
state.matchHitDistribution = snapshot.matchHitDistribution != null
? new HashMap<>(snapshot.matchHitDistribution)
: new HashMap<>();
}
Log.d(TAG, "loadMatchProgress: Match progress loaded successfully");
@@ -503,12 +570,32 @@ public class GameManager {
winner.remainingScore = 0;
winner.dartsThrown += dartsThrown;
// Update statistics
// Accumulate per-match stats for the winning turn
winner.matchPointsScored += pointsMade;
winner.matchCheckoutValue = checkoutValue;
if (pointsMade > winner.matchHighestTurnScore) {
winner.matchHighestTurnScore = pointsMade;
}
if (pointsMade >= 60)
winner.matchCount60Plus++;
if (pointsMade >= 100)
winner.matchCount100Plus++;
if (pointsMade >= 140)
winner.matchCount140Plus++;
if (pointsMade == 180)
winner.matchCount180++;
// Update career statistics
updatePlayerStats(winner, dartsThrown, pointsMade, false, checkoutValue);
// Record dart hits
// Record dart hits to career stats (async DB)
recordTurnHitsToStatistics(winner, new ArrayList<>(mCurrentTurnDartHits));
// Record dart hits to per-match distribution (local)
for (final DartHit hit : mCurrentTurnDartHits) {
winner.recordMatchDartHit(hit.baseValue, hit.multiplier);
}
// Increment matches played for all players
incrementMatchesPlayed();
@@ -557,13 +644,35 @@ public class GameManager {
if (!isBust) {
active.remainingScore = finalScore;
active.dartsThrown += mCurrentTurnDarts.size();
// Accumulate per-match stats
active.matchPointsScored += turnTotal;
if (turnTotal > active.matchHighestTurnScore) {
active.matchHighestTurnScore = turnTotal;
}
if (turnTotal >= 60)
active.matchCount60Plus++;
if (turnTotal >= 100)
active.matchCount100Plus++;
if (turnTotal >= 140)
active.matchCount140Plus++;
if (turnTotal == 180)
active.matchCount180++;
} else {
// Track bust in per-match stats
active.matchBustCount++;
}
updatePlayerStats(active, mCurrentTurnDarts.size(), turnTotal, isBust);
// Record dart hits
// Record dart hits to career stats (async DB)
recordTurnHitsToStatistics(active, new ArrayList<>(mCurrentTurnDartHits));
// Record dart hits to per-match distribution (local, even on bust for tracking)
for (final DartHit hit : mCurrentTurnDartHits) {
active.recordMatchDartHit(hit.baseValue, hit.multiplier);
}
// Rotate to next player
mActivePlayerIndex = (mActivePlayerIndex + 1) % mPlayerStates.size();
@@ -628,7 +737,16 @@ public class GameManager {
state.playerId,
state.name,
state.remainingScore,
state.dartsThrown));
state.dartsThrown,
state.matchPointsScored,
state.matchBustCount,
state.matchHighestTurnScore,
state.matchCheckoutValue,
state.matchCount60Plus,
state.matchCount100Plus,
state.matchCount140Plus,
state.matchCount180,
state.matchHitDistribution));
}
String progressJson = MatchProgressConverter.fromProgress(progress);
@@ -680,7 +798,16 @@ public class GameManager {
state.playerId,
state.name,
state.remainingScore,
state.dartsThrown));
state.dartsThrown,
state.matchPointsScored,
state.matchBustCount,
state.matchHighestTurnScore,
state.matchCheckoutValue,
state.matchCount60Plus,
state.matchCount100Plus,
state.matchCount140Plus,
state.matchCount180,
state.matchHitDistribution));
}
match.participantData = MatchProgressConverter.fromProgress(finalProgress);

View File

@@ -22,9 +22,12 @@ 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.
* <p>
* This custom View renders a professional dartboard visualization with color-coded
* performance data overlaid on each segment (singles, doubles, triples, and bulls).
* The heatmap uses a gradient from "cold" (low frequency) to "hot" (high frequency)
* This custom View renders a professional dartboard visualization with
* color-coded
* performance data overlaid on each segment (singles, doubles, triples, and
* bulls).
* The heatmap uses a gradient from "cold" (low frequency) to "hot" (high
* frequency)
* based on how often a player hits each segment.
* <p>
* Optimized Palette:
@@ -75,7 +78,8 @@ public class HeatmapView extends View {
/**
* Cache of pre-calculated Path objects for all dartboard segments.
* Key format: "d" + number (doubles), "t" + number (triples),
* "s" + number + "_inner/outer" (singles), "sb" (single bull), "db" (double bull).
* "s" + number + "_inner/outer" (singles), "sb" (single bull), "db" (double
* bull).
* Calculated once in calculatePaths() and reused for efficient rendering.
*/
private final Map<String, Path> mSegmentPaths = new HashMap<>();
@@ -87,6 +91,12 @@ public class HeatmapView extends View {
*/
private Statistics mStats;
/**
* Raw hit distribution map. Populated by either {@link #setStats(Statistics)}
* or {@link #setHitDistribution(Map)} for per-match rendering.
*/
private Map<String, Integer> mHitDistribution;
/**
* Standard dartboard segment order (clockwise starting from 20 at the top).
* This array defines the physical layout of numbers around the dartboard
@@ -100,7 +110,8 @@ public class HeatmapView extends View {
* Constructs a new HeatmapView programmatically.
* Used when creating the view from code rather than XML inflation.
*
* @param context The Context the view is running in, through which it can access resources
* @param context The Context the view is running in, through which it can
* access resources
*/
public HeatmapView(final Context context) {
super(context);
@@ -111,7 +122,8 @@ public class HeatmapView extends View {
* Constructs a new HeatmapView from XML.
* Used when inflating the view from an XML layout.
*
* @param context The Context the view is running in, through which it can access resources
* @param context The Context the view is running in, through which it can
* access resources
* @param attrs The attributes of the XML tag that is inflating the view
*/
public HeatmapView(final Context context, @Nullable final AttributeSet attrs) {
@@ -132,6 +144,19 @@ public class HeatmapView extends View {
*/
public void setStats(final Statistics stats) {
this.mStats = stats;
this.mHitDistribution = stats != null ? stats.getHitDistribution() : null;
invalidate();
}
/**
* Binds a raw hit distribution map to the view and triggers a redraw.
* Used for per-match heatmap rendering where no full Statistics object exists.
*
* @param hitDistribution Map of segment keys (e.g. "t20", "s5") to hit counts
*/
public void setHitDistribution(final Map<String, Integer> hitDistribution) {
this.mStats = null;
this.mHitDistribution = hitDistribution;
invalidate();
}
@@ -157,7 +182,8 @@ public class HeatmapView extends View {
}
/**
* Calculates the Path objects for every segment on the dartboard based on current view size.
* Calculates the Path objects for every segment on the dartboard based on
* current view size.
* <p>
* Clears the existing path cache and regenerates all paths for:
* - 20 double ring segments (90-100% radius)
@@ -197,33 +223,39 @@ public class HeatmapView extends View {
}
/**
* Creates a closed Path representing a wedge-shaped arc segment of the dartboard.
* Creates a closed Path representing a wedge-shaped arc segment of the
* dartboard.
* <p>
* Generates a path for a single dartboard segment (e.g., the triple 20 region)
* by drawing an arc along the outer boundary, then an arc along the inner boundary
* by drawing an arc along the outer boundary, then an arc along the inner
* boundary
* in reverse, creating a closed wedge shape.
* <p>
* The path is constructed by:
* 1. Drawing an outer arc from startAngle to startAngle + sweep
* 2. Drawing an inner arc from startAngle + sweep back to startAngle (negative sweep)
* 2. Drawing an inner arc from startAngle + sweep back to startAngle (negative
* sweep)
* 3. Closing the path to connect the endpoints
*
* @param innerFactor Ratio of inner radius to base radius (0.0 to 1.0). E.g., 0.50 = 50% of radius
* @param outerFactor Ratio of outer radius to base radius (0.0 to 1.0). E.g., 1.00 = 100% of radius
* @param startAngle Starting angle in degrees (0° = right, increasing clockwise)
* @param sweep Angular sweep in degrees (typically 18° for standard dartboard segments)
* @param innerFactor Ratio of inner radius to base radius (0.0 to 1.0). E.g.,
* 0.50 = 50% of radius
* @param outerFactor Ratio of outer radius to base radius (0.0 to 1.0). E.g.,
* 1.00 = 100% of radius
* @param startAngle Starting angle in degrees (0° = right, increasing
* clockwise)
* @param sweep Angular sweep in degrees (typically 18° for standard
* dartboard segments)
* @return A closed Path object representing the arc segment
*/
private Path createArcPath(final float innerFactor, final float outerFactor, final float startAngle, final float sweep) {
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
);
mCenterX + mRadius * outerFactor, mCenterY + mRadius * outerFactor);
final RectF innerRect = new RectF(
mCenterX - mRadius * innerFactor, mCenterY - mRadius * innerFactor,
mCenterX + mRadius * innerFactor, mCenterY + mRadius * innerFactor
);
mCenterX + mRadius * innerFactor, mCenterY + mRadius * innerFactor);
path.arcTo(outerRect, startAngle, sweep);
path.arcTo(innerRect, startAngle + sweep, -sweep);
@@ -240,7 +272,8 @@ public class HeatmapView extends View {
* 3. For each dartboard segment:
* - Determine hit count from statistics
* - If zero hits: use subtle ghost color
* - If hits exist: interpolate between cold and hot based on normalized frequency
* - If hits exist: interpolate between cold and hot based on normalized
* frequency
* - Draw the filled path with the calculated color
* 4. Draw wireframe overlay on all segments for visual definition
* <p>
@@ -255,7 +288,15 @@ public class HeatmapView extends View {
@Override
protected void onDraw(@NonNull final Canvas canvas) {
super.onDraw(canvas);
if (mStats == null) return;
if (mHitDistribution == null)
return;
// Calculate max hits for normalization
int maxHits = 0;
for (final Integer count : mHitDistribution.values()) {
if (count != null && count > maxHits)
maxHits = count;
}
// Resolve branding colors from resources
final int coldColor = ContextCompat.getColor(getContext(), R.color.volt_green);
@@ -268,14 +309,14 @@ public class HeatmapView extends View {
final String statsKey = key.contains("_") ? key.substring(0, key.indexOf("_")) : key;
// Check if there are any hits recorded for this segment
final Integer hitCount = mStats.getHitDistribution().get(statsKey);
final Integer hitCount = mHitDistribution.get(statsKey);
final int color;
if (hitCount == null || hitCount == 0) {
color = emptyColor;
} else {
// Fetch the normalized heat (0.0 to 1.0) and evaluate against Green -> Red gradient
final float weight = mStats.getNormalizedWeight(statsKey);
// Normalize hit count against max and interpolate Green -> Red
final float weight = maxHits > 0 ? (float) hitCount / maxHits : 0f;
color = (int) mColorEvaluator.evaluate(weight, coldColor, hotColor);
}

View File

@@ -124,17 +124,15 @@ public class PlayerItemView extends MaterialCardView {
* @param player The Player object containing data to display
* @param gameAverage The game average to display (points per 3 darts)
* @param position The player's position (1 = 1st place, 2 = 2nd place, etc.)
* @param isWinner Whether this player won (remaining score = 0)
*/
public void bindWithGameAverageAndPosition(@NonNull final Player player, final double gameAverage,
final int position, final boolean isWinner) {
final int position) {
Log.d(TAG, "bindWithGameAverageAndPosition() called with: player = [" + player
+ "], gameAverage = [" + gameAverage + "], position = [" + position + "], isWinner = [" + isWinner
+ "]");
+ "], gameAverage = [" + gameAverage + "], position = [" + position + "]");
// Build username with position indicator
final String positionSuffix = getPositionSuffix(position);
final String displayName = isWinner
final String displayName = position == 1
? "🏆 " + player.username + " (" + positionSuffix + ")"
: player.username + " (" + positionSuffix + ")";

View File

@@ -4,6 +4,7 @@ import android.content.Context;
import android.util.AttributeSet;
import android.util.Log;
import android.view.LayoutInflater;
import android.view.View;
import android.widget.ScrollView;
import android.widget.TextView;
import androidx.annotation.NonNull;
@@ -11,6 +12,7 @@ import androidx.annotation.Nullable;
import com.aldo.apps.ochecompanion.R;
import com.aldo.apps.ochecompanion.database.objects.Player;
import com.aldo.apps.ochecompanion.database.objects.Statistics;
import com.aldo.apps.ochecompanion.game.GameManager;
import com.bumptech.glide.Glide;
import com.google.android.material.imageview.ShapeableImageView;
@@ -18,17 +20,20 @@ import com.google.android.material.imageview.ShapeableImageView;
* PlayerStatsView: A complete dashboard component that visualizes a player's
* career performance, including a heatmap and detailed metrics.
* <p>
* This custom {@link ScrollView} component provides a comprehensive player statistics
* This custom {@link ScrollView} component provides a comprehensive player
* statistics
* display that includes:
* <ul>
* <li>Player identity information (username and profile picture)</li>
* <li>High-level performance metrics (career average, first-9 average, checkout percentage, best finish)</li>
* <li>High-level performance metrics (career average, first-9 average, checkout
* percentage, best finish)</li>
* <li>Threshold score counters (60+, 100+, 140+, and 180 counts)</li>
* <li>Interactive dartboard heatmap visualization via {@link HeatmapView}</li>
* </ul>
* <p>
* The view is inflated from a layout resource and automatically initializes all
* UI components. Data binding is performed via the {@link #bind(Player, Statistics)}
* UI components. Data binding is performed via the
* {@link #bind(Player, Statistics)}
* method, which populates all fields with formatted player statistics.
*
* @see HeatmapView
@@ -49,6 +54,11 @@ public class PlayerStatsView extends ScrollView {
*/
private HeatmapView mHeatmap;
/**
* The label above the heatmap, hidden when showing match stats.
*/
private TextView mTvHeatmapLabel;
/**
* The circular profile image view that displays the player's avatar.
* Shows either a custom profile picture or a default user icon.
@@ -85,23 +95,29 @@ public class PlayerStatsView extends ScrollView {
*/
private TextView mTvBestFinish;
/**
* The {@link TextView} displaying the count of turns scoring 60 points or more.
*/
// Dynamic labels for metric cards (can switch between career/match mode)
private TextView mTvCareerAvgLabel;
private TextView mTvFirst9Label;
private TextView mTvCheckoutPctLabel;
private TextView mTvBestFinishLabel;
private TextView mTvCount60;
/**
* The {@link TextView} displaying the count of turns scoring 100 points or more (century).
* The {@link TextView} displaying the count of turns scoring 100 points or more
* (century).
*/
private TextView mTvCount100;
/**
* The {@link TextView} displaying the count of turns scoring 140 points or more.
* The {@link TextView} displaying the count of turns scoring 140 points or
* more.
*/
private TextView mTvCount140;
/**
* The {@link TextView} displaying the count of perfect 180-point turns (maximum score).
* The {@link TextView} displaying the count of perfect 180-point turns (maximum
* score).
*/
private TextView mTvCount180;
@@ -110,13 +126,16 @@ public class PlayerStatsView extends ScrollView {
}
/**
* Constructs a new {@link PlayerStatsView} with the specified context and attributes.
* Constructs a new {@link PlayerStatsView} with the specified context and
* attributes.
* <p>
* This constructor inflates the player stats layout and initializes all UI components.
* This constructor inflates the player stats layout and initializes all UI
* components.
* It performs the following operations:
* <ol>
* <li>Calls the superclass constructor with context and attributes</li>
* <li>Inflates the {@code R.layout.player_stats_layout} resource into this view</li>
* <li>Inflates the {@code R.layout.player_stats_layout} resource into this
* view</li>
* <li>Initializes all child views via {@link #initViews()}</li>
* </ol>
* <p>
@@ -134,15 +153,19 @@ public class PlayerStatsView extends ScrollView {
}
/**
* Initializes all UI component references by finding views in the inflated layout.
* Initializes all UI component references by finding views in the inflated
* layout.
* <p>
* This method binds the following UI components:
* <ul>
* <li><b>Identity:</b> Profile image and username text view</li>
* <li><b>Performance Metrics:</b> Career average, first-9 average, checkout percentage,
* <li><b>Performance Metrics:</b> Career average, first-9 average, checkout
* percentage,
* and best finish text views</li>
* <li><b>Threshold Counters:</b> Four text views for 60+, 100+, 140+, and 180 counts</li>
* <li><b>Visualization:</b> The {@link HeatmapView} for dartboard hit distribution</li>
* <li><b>Threshold Counters:</b> Four text views for 60+, 100+, 140+, and 180
* counts</li>
* <li><b>Visualization:</b> The {@link HeatmapView} for dartboard hit
* distribution</li>
* </ul>
* <p>
* This method is called once during construction and establishes references
@@ -150,6 +173,7 @@ public class PlayerStatsView extends ScrollView {
*/
private void initViews() {
mHeatmap = findViewById(R.id.statsHeatmap);
mTvHeatmapLabel = findViewById(R.id.tvHeatmapLabel);
mIvAvatar = findViewById(R.id.ivPlayerAvatar);
mTvUsername = findViewById(R.id.tvUsername);
mTvCareerAvg = findViewById(R.id.tvCareerAvgValue);
@@ -157,6 +181,12 @@ public class PlayerStatsView extends ScrollView {
mTvCheckoutPct = findViewById(R.id.tvCheckoutPctValue);
mTvBestFinish = findViewById(R.id.tvBestFinishValue);
// Dynamic labels
mTvCareerAvgLabel = findViewById(R.id.tvCareerAvgLabel);
mTvFirst9Label = findViewById(R.id.tvFirst9Label);
mTvCheckoutPctLabel = findViewById(R.id.tvCheckoutPctLabel);
mTvBestFinishLabel = findViewById(R.id.tvBestFinishLabel);
// Threshold counters
mTvCount60 = findViewById(R.id.tvCount60);
mTvCount100 = findViewById(R.id.tvCount100);
@@ -167,29 +197,45 @@ public class PlayerStatsView extends ScrollView {
/**
* Binds both the player identity and their accumulated stats to the UI.
* <p>
* This method populates all UI components with the provided player and statistics data.
* This method populates all UI components with the provided player and
* statistics data.
* The binding process is organized into four logical sections:
* <ol>
* <li><b>Identity:</b> Displays the player's username in uppercase and loads their
* profile picture using Glide (or shows a default icon if no picture is set)</li>
* <li><b>High-Level Metrics:</b> Formats and displays career average, first-9 average,
* <li><b>Identity:</b> Displays the player's username in uppercase and loads
* their
* profile picture using Glide (or shows a default icon if no picture is
* set)</li>
* <li><b>High-Level Metrics:</b> Formats and displays career average, first-9
* average,
* checkout percentage (with % symbol), and highest checkout value</li>
* <li><b>Threshold Totals:</b> Displays counts for 60+, 100+, 140+, and 180 point turns</li>
* <li><b>Heatmap Rendering:</b> Passes statistics to the {@link HeatmapView} for
* <li><b>Threshold Totals:</b> Displays counts for 60+, 100+, 140+, and 180
* point turns</li>
* <li><b>Heatmap Rendering:</b> Passes statistics to the {@link HeatmapView}
* for
* visual dartboard representation</li>
* </ol>
* <p>
* If either parameter is {@code null}, the method logs an error and returns early
* If either parameter is {@code null}, the method logs an error and returns
* early
* without modifying the UI.
*
* @param player The {@link Player} object containing identity information (username, profile picture)
* @param stats The {@link Statistics} object containing all accumulated performance metrics
* @param player The {@link Player} object containing identity information
* (username, profile picture)
* @param stats The {@link Statistics} object containing all accumulated
* performance metrics
*/
public void bind(@NonNull final Player player, @NonNull final Statistics stats) {
if (player == null || stats == null) {
Log.e(TAG, "bind: Cannot bind, return");
return;
}
// Restore labels to career mode
mTvCareerAvgLabel.setText("Career Avg");
mTvFirst9Label.setText("First 9 Avg");
mTvCheckoutPctLabel.setText("Checkout %");
mTvBestFinishLabel.setText("High Finish");
// 1. Identity
mTvUsername.setText(player.username.toUpperCase());
if (player.profilePictureUri != null) {
@@ -211,6 +257,66 @@ public class PlayerStatsView extends ScrollView {
mTvCount180.setText(String.valueOf(stats.getCount180()));
// 4. Heatmap Rendering
mTvHeatmapLabel.setText("Impact Heatmap");
mTvHeatmapLabel.setVisibility(View.VISIBLE);
mHeatmap.setVisibility(View.VISIBLE);
mHeatmap.setStats(stats);
}
/**
* Binds match-specific statistics from a {@link GameManager.PlayerState} to the
* UI.
* <p>
* Unlike {@link #bind(Player, Statistics)} which shows career stats, this
* method
* shows stats from the just-completed match only. Labels are updated to reflect
* match context (e.g., "Match Avg" instead of "Career Avg").
* The heatmap is hidden since per-match dart distribution is not tracked.
*
* @param playerState The player's match state containing per-match statistics
*/
public void bindMatchStats(@NonNull final GameManager.PlayerState playerState) {
if (playerState == null) {
Log.e(TAG, "bindMatchStats: Cannot bind null playerState");
return;
}
final Player player = playerState.player;
// 1. Identity
mTvUsername.setText(player.username.toUpperCase());
if (player.profilePictureUri != null) {
Glide.with(getContext()).load(player.profilePictureUri)
.placeholder(R.drawable.ic_users)
.into(mIvAvatar);
} else {
mIvAvatar.setImageResource(R.drawable.ic_users);
}
// 2. Switch labels to match mode
mTvCareerAvgLabel.setText("Match Avg");
mTvFirst9Label.setText("Darts Thrown");
mTvCheckoutPctLabel.setText("Checkout");
mTvBestFinishLabel.setText("High Turn");
// 3. Match-specific metrics
mTvCareerAvg.setText(String.format("%.1f", playerState.getMatchAverage()));
mTvFirst9.setText(String.valueOf(playerState.dartsThrown));
mTvCheckoutPct.setText(playerState.matchCheckoutValue > 0
? String.valueOf(playerState.matchCheckoutValue)
: "");
mTvBestFinish.setText(String.valueOf(playerState.matchHighestTurnScore));
// 4. Threshold Totals (match-specific)
mTvCount60.setText(String.valueOf(playerState.matchCount60Plus));
mTvCount100.setText(String.valueOf(playerState.matchCount100Plus));
mTvCount140.setText(String.valueOf(playerState.matchCount140Plus));
mTvCount180.setText(String.valueOf(playerState.matchCount180));
// 5. Heatmap — render per-match dart hit distribution
mTvHeatmapLabel.setVisibility(View.VISIBLE);
mTvHeatmapLabel.setText("Match Heatmap");
mHeatmap.setVisibility(View.VISIBLE);
mHeatmap.setHitDistribution(playerState.matchHitDistribution);
}
}

View File

@@ -176,7 +176,7 @@ public class MainMenuGroupMatchAdapter extends RecyclerView.Adapter<MainMenuGrou
final Player player = participantData.player;
final double gameAverage = participantData.gameAverage;
final boolean isWinner = participantData.score == 0; // Winner has 0 remaining score
mItemView.bindWithGameAverageAndPosition(player, gameAverage, position, isWinner);
mItemView.bindWithGameAverageAndPosition(player, gameAverage, position);
}
}
}

View File

@@ -1,6 +1,8 @@
package com.aldo.apps.ochecompanion.utils;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* MatchProgress: A serializable snapshot of an ongoing X01 session.
@@ -28,22 +30,46 @@ public class MatchProgress {
/** The total number of darts thrown by the player so far. */
public int dartsThrown;
// ---- Per-Match Statistics ----
public int matchPointsScored;
public int matchBustCount;
public int matchHighestTurnScore;
public int matchCheckoutValue;
public int matchCount60Plus;
public int matchCount100Plus;
public int matchCount140Plus;
public int matchCount180;
public Map<String, Integer> matchHitDistribution;
/** Default constructor required for serialization/deserialization. */
public PlayerStateSnapshot() {}
public PlayerStateSnapshot() {
}
/**
* Constructs a new PlayerStateSnapshot with the specified values.
*
* @param playerId The unique ID of the player (0 for guests)
* @param name The display name of the player
* @param remainingScore The player's current remaining score in the match
* @param dartsThrown The total number of darts thrown by the player so far
* Constructs a new PlayerStateSnapshot from a full set of match state values.
*/
public PlayerStateSnapshot(final long playerId, final String name, final int remainingScore, final int dartsThrown) {
public PlayerStateSnapshot(final long playerId, final String name,
final int remainingScore, final int dartsThrown,
final int matchPointsScored, final int matchBustCount,
final int matchHighestTurnScore, final int matchCheckoutValue,
final int matchCount60Plus, final int matchCount100Plus,
final int matchCount140Plus, final int matchCount180,
final Map<String, Integer> matchHitDistribution) {
this.playerId = playerId;
this.name = name;
this.remainingScore = remainingScore;
this.dartsThrown = dartsThrown;
this.matchPointsScored = matchPointsScored;
this.matchBustCount = matchBustCount;
this.matchHighestTurnScore = matchHighestTurnScore;
this.matchCheckoutValue = matchCheckoutValue;
this.matchCount60Plus = matchCount60Plus;
this.matchCount100Plus = matchCount100Plus;
this.matchCount140Plus = matchCount140Plus;
this.matchCount180 = matchCount180;
this.matchHitDistribution = matchHitDistribution != null
? new HashMap<>(matchHitDistribution)
: new HashMap<>();
}
}
}

View File

@@ -0,0 +1,10 @@
<?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">
<path
android:fillColor="@android:color/white"
android:pathData="M15.41,16.59L10.83,12L15.41,7.41L14,6L8,12L14,18L15.41,16.59Z" />
</vector>

View File

@@ -210,4 +210,47 @@
android:layout_height="match_parent"
android:visibility="gone"/>
<!-- Player switching navigation (overlaid on stats view) -->
<LinearLayout
android:id="@+id/stats_nav_bar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center_vertical"
android:orientation="horizontal"
android:paddingHorizontal="16dp"
android:paddingVertical="10dp"
android:background="@color/surface_primary"
android:visibility="gone"
app:layout_constraintBottom_toBottomOf="parent">
<ImageButton
android:id="@+id/btn_stats_prev"
android:layout_width="48dp"
android:layout_height="48dp"
android:background="?attr/selectableItemBackgroundBorderless"
android:src="@drawable/ic_chevron_left"
android:contentDescription="Previous player"
app:tint="@color/volt_green" />
<TextView
android:id="@+id/tv_stats_player_indicator"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:gravity="center"
android:fontFamily="sans-serif-medium"
android:textColor="@color/text_secondary"
android:textSize="13sp"
tools:text="1 / 3" />
<ImageButton
android:id="@+id/btn_stats_next"
android:layout_width="48dp"
android:layout_height="48dp"
android:background="?attr/selectableItemBackgroundBorderless"
android:src="@drawable/ic_chevron_right"
android:contentDescription="Next player"
app:tint="@color/volt_green" />
</LinearLayout>
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@@ -53,6 +53,7 @@
<!-- CENTERPIECE: HEATMAP -->
<TextView
android:id="@+id/tvHeatmapLabel"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Impact Heatmap"
@@ -74,12 +75,12 @@
<LinearLayout style="@style/StatsCardStyle">
<TextView android:id="@+id/tvCareerAvgValue" style="@style/StatsValueStyle" tools:text="94.2" />
<TextView android:text="Career Avg" style="@style/StatsLabelStyle" />
<TextView android:id="@+id/tvCareerAvgLabel" android:text="Career Avg" style="@style/StatsLabelStyle" />
</LinearLayout>
<LinearLayout style="@style/StatsCardStyle" android:layout_marginStart="12dp">
<TextView android:id="@+id/tvFirst9Value" style="@style/StatsValueStyle" tools:text="102.5" />
<TextView android:text="First 9 Avg" style="@style/StatsLabelStyle" />
<TextView android:id="@+id/tvFirst9Label" android:text="First 9 Avg" style="@style/StatsLabelStyle" />
</LinearLayout>
</LinearLayout>
@@ -93,12 +94,12 @@
<LinearLayout style="@style/StatsCardStyle">
<TextView android:id="@+id/tvCheckoutPctValue" style="@style/StatsValueStyle" tools:text="38.5%" />
<TextView android:text="Checkout %" style="@style/StatsLabelStyle" />
<TextView android:id="@+id/tvCheckoutPctLabel" android:text="Checkout %" style="@style/StatsLabelStyle" />
</LinearLayout>
<LinearLayout style="@style/StatsCardStyle" android:layout_marginStart="12dp">
<TextView android:id="@+id/tvBestFinishValue" style="@style/StatsValueStyle" tools:text="170" />
<TextView android:text="High Finish" style="@style/StatsLabelStyle" />
<TextView android:id="@+id/tvBestFinishLabel" android:text="High Finish" style="@style/StatsLabelStyle" />
</LinearLayout>
</LinearLayout>