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:
@@ -28,7 +28,6 @@ import androidx.preference.PreferenceManager;
|
|||||||
|
|
||||||
import com.aldo.apps.ochecompanion.database.DatabaseHelper;
|
import com.aldo.apps.ochecompanion.database.DatabaseHelper;
|
||||||
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.game.GameManager;
|
import com.aldo.apps.ochecompanion.game.GameManager;
|
||||||
import com.aldo.apps.ochecompanion.ui.PlayerStatsView;
|
import com.aldo.apps.ochecompanion.ui.PlayerStatsView;
|
||||||
import com.aldo.apps.ochecompanion.utils.CheckoutEngine;
|
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.SoundEngine;
|
||||||
import com.aldo.apps.ochecompanion.utils.UIConstants;
|
import com.aldo.apps.ochecompanion.utils.UIConstants;
|
||||||
import com.google.android.material.button.MaterialButton;
|
import com.google.android.material.button.MaterialButton;
|
||||||
|
import android.widget.ImageButton;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.Arrays;
|
import java.util.Arrays;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
@@ -72,6 +72,17 @@ public class GameActivity extends BaseActivity implements GameManager.GameStateC
|
|||||||
*/
|
*/
|
||||||
private boolean mAreStatsShown = false;
|
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)
|
// Game Manager (Singleton Business Logic Handler)
|
||||||
// ========================================================================================
|
// ========================================================================================
|
||||||
@@ -157,6 +168,18 @@ public class GameActivity extends BaseActivity implements GameManager.GameStateC
|
|||||||
*/
|
*/
|
||||||
private PlayerStatsView mStatsView;
|
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.
|
* 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() {
|
private void handleBackPressed() {
|
||||||
if (mAreStatsShown) {
|
if (mAreStatsShown) {
|
||||||
mStatsView.setVisibility(View.GONE);
|
mStatsView.setVisibility(View.GONE);
|
||||||
|
mStatsNavBar.setVisibility(View.GONE);
|
||||||
mShowStatsBtn.setVisibility(View.VISIBLE);
|
mShowStatsBtn.setVisibility(View.VISIBLE);
|
||||||
mAreStatsShown = false;
|
mAreStatsShown = false;
|
||||||
return;
|
return;
|
||||||
@@ -314,10 +338,34 @@ public class GameActivity extends BaseActivity implements GameManager.GameStateC
|
|||||||
|
|
||||||
mShowStatsBtn = findViewById(R.id.show_stats_btn);
|
mShowStatsBtn = findViewById(R.id.show_stats_btn);
|
||||||
mStatsView = findViewById(R.id.player_stats_view);
|
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 -> {
|
mShowStatsBtn.setOnClickListener(v -> {
|
||||||
mStatsView.setVisibility(View.VISIBLE);
|
mStatsView.setVisibility(View.VISIBLE);
|
||||||
mShowStatsBtn.setVisibility(View.GONE);
|
mShowStatsBtn.setVisibility(View.GONE);
|
||||||
|
mStatsNavBar.setVisibility(View.VISIBLE);
|
||||||
mAreStatsShown = true;
|
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());
|
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
|
* Caches all player states from the completed match and shows stats for the
|
||||||
* the stats view.
|
* winner (first player in the list, index 0, which is typically the active
|
||||||
* Runs database query on a background thread to avoid blocking the UI.
|
* player at match end). The stats view will show match-specific stats.
|
||||||
*/
|
*/
|
||||||
private void attachPlayerStats() {
|
private void attachPlayerStats() {
|
||||||
new Thread(() -> {
|
mMatchPlayerStates = mGameManager.getPlayerStates();
|
||||||
try {
|
if (mMatchPlayerStates == null || mMatchPlayerStates.isEmpty()) {
|
||||||
final GameManager.PlayerState activePlayer = mGameManager.getActivePlayer();
|
Log.e(TAG, "attachPlayerStats: No player states available");
|
||||||
if (activePlayer == null)
|
return;
|
||||||
return;
|
}
|
||||||
|
|
||||||
final Player player = activePlayer.player;
|
// Find the winner (remainingScore == 0) and start with them
|
||||||
final Statistics statistics = mDatabaseHelper.getStatisticsForPlayer(player.id);
|
mStatsPlayerIndex = 0;
|
||||||
runOnUiThread(() -> mStatsView.bind(player, statistics));
|
for (int i = 0; i < mMatchPlayerStates.size(); i++) {
|
||||||
} catch (Exception e) {
|
if (mMatchPlayerStates.get(i).remainingScore == 0) {
|
||||||
Log.e(TAG, "attachPlayerStats: Failed to retrieve player statistics", e);
|
mStatsPlayerIndex = i;
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
}).start();
|
}
|
||||||
|
|
||||||
|
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()));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -351,7 +351,8 @@ public class DatabaseHelper {
|
|||||||
player.id,
|
player.id,
|
||||||
player.username,
|
player.username,
|
||||||
startingScore, // Initial score equals starting score
|
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 {
|
} else {
|
||||||
@@ -360,7 +361,9 @@ public class DatabaseHelper {
|
|||||||
0L, // Guest has ID 0
|
0L, // Guest has ID 0
|
||||||
"GUEST",
|
"GUEST",
|
||||||
startingScore,
|
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
|
// Convert to JSON
|
||||||
|
|||||||
@@ -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.DartsConstants;
|
||||||
import com.aldo.apps.ochecompanion.utils.Log;
|
import com.aldo.apps.ochecompanion.utils.Log;
|
||||||
import com.aldo.apps.ochecompanion.utils.MatchProgress;
|
import com.aldo.apps.ochecompanion.utils.MatchProgress;
|
||||||
|
import com.aldo.apps.ochecompanion.utils.converters.HitDistributionConverter;
|
||||||
import com.aldo.apps.ochecompanion.utils.converters.MatchProgressConverter;
|
import com.aldo.apps.ochecompanion.utils.converters.MatchProgressConverter;
|
||||||
|
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
|
import java.util.HashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GameManager: Singleton manager for handling all X01 game business logic.
|
* 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.
|
* 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 {
|
public static class PlayerState {
|
||||||
/**
|
/**
|
||||||
@@ -228,6 +231,35 @@ public class GameManager {
|
|||||||
*/
|
*/
|
||||||
public int dartsThrown = 0;
|
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.
|
* Constructs a PlayerState for a player with the specified starting score.
|
||||||
*
|
*
|
||||||
@@ -240,6 +272,28 @@ public class GameManager {
|
|||||||
this.name = player.username;
|
this.name = player.username;
|
||||||
this.remainingScore = startScore;
|
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
|
// Restore active player index
|
||||||
mActivePlayerIndex = progress.activePlayerIndex;
|
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++) {
|
for (int i = 0; i < progress.players.size() && i < mPlayerStates.size(); i++) {
|
||||||
MatchProgress.PlayerStateSnapshot snapshot = progress.players.get(i);
|
MatchProgress.PlayerStateSnapshot snapshot = progress.players.get(i);
|
||||||
PlayerState state = mPlayerStates.get(i);
|
PlayerState state = mPlayerStates.get(i);
|
||||||
state.remainingScore = snapshot.remainingScore;
|
state.remainingScore = snapshot.remainingScore;
|
||||||
state.dartsThrown = snapshot.dartsThrown;
|
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");
|
Log.d(TAG, "loadMatchProgress: Match progress loaded successfully");
|
||||||
@@ -503,12 +570,32 @@ public class GameManager {
|
|||||||
winner.remainingScore = 0;
|
winner.remainingScore = 0;
|
||||||
winner.dartsThrown += dartsThrown;
|
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);
|
updatePlayerStats(winner, dartsThrown, pointsMade, false, checkoutValue);
|
||||||
|
|
||||||
// Record dart hits
|
// Record dart hits to career stats (async DB)
|
||||||
recordTurnHitsToStatistics(winner, new ArrayList<>(mCurrentTurnDartHits));
|
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
|
// Increment matches played for all players
|
||||||
incrementMatchesPlayed();
|
incrementMatchesPlayed();
|
||||||
|
|
||||||
@@ -557,13 +644,35 @@ public class GameManager {
|
|||||||
if (!isBust) {
|
if (!isBust) {
|
||||||
active.remainingScore = finalScore;
|
active.remainingScore = finalScore;
|
||||||
active.dartsThrown += mCurrentTurnDarts.size();
|
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);
|
updatePlayerStats(active, mCurrentTurnDarts.size(), turnTotal, isBust);
|
||||||
|
|
||||||
// Record dart hits
|
// Record dart hits to career stats (async DB)
|
||||||
recordTurnHitsToStatistics(active, new ArrayList<>(mCurrentTurnDartHits));
|
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
|
// Rotate to next player
|
||||||
mActivePlayerIndex = (mActivePlayerIndex + 1) % mPlayerStates.size();
|
mActivePlayerIndex = (mActivePlayerIndex + 1) % mPlayerStates.size();
|
||||||
|
|
||||||
@@ -628,7 +737,16 @@ public class GameManager {
|
|||||||
state.playerId,
|
state.playerId,
|
||||||
state.name,
|
state.name,
|
||||||
state.remainingScore,
|
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);
|
String progressJson = MatchProgressConverter.fromProgress(progress);
|
||||||
@@ -680,7 +798,16 @@ public class GameManager {
|
|||||||
state.playerId,
|
state.playerId,
|
||||||
state.name,
|
state.name,
|
||||||
state.remainingScore,
|
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);
|
match.participantData = MatchProgressConverter.fromProgress(finalProgress);
|
||||||
|
|||||||
@@ -22,9 +22,12 @@ import java.util.Map;
|
|||||||
* HeatmapView: A custom high-performance rendering component that draws a
|
* HeatmapView: A custom high-performance rendering component that draws a
|
||||||
* dartboard and overlays player performance data as a color-coded heatmap.
|
* dartboard and overlays player performance data as a color-coded heatmap.
|
||||||
* <p>
|
* <p>
|
||||||
* This custom View renders a professional dartboard visualization with color-coded
|
* This custom View renders a professional dartboard visualization with
|
||||||
* performance data overlaid on each segment (singles, doubles, triples, and bulls).
|
* color-coded
|
||||||
* The heatmap uses a gradient from "cold" (low frequency) to "hot" (high frequency)
|
* 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.
|
* based on how often a player hits each segment.
|
||||||
* <p>
|
* <p>
|
||||||
* Optimized Palette:
|
* Optimized Palette:
|
||||||
@@ -46,7 +49,7 @@ public class HeatmapView extends View {
|
|||||||
* Reused across all drawing operations for efficiency.
|
* Reused across all drawing operations for efficiency.
|
||||||
*/
|
*/
|
||||||
private final Paint mBasePaint = new Paint(Paint.ANTI_ALIAS_FLAG);
|
private final Paint mBasePaint = new Paint(Paint.ANTI_ALIAS_FLAG);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Color evaluator for interpolating between cold (green) and hot (red) colors.
|
* Color evaluator for interpolating between cold (green) and hot (red) colors.
|
||||||
* Evaluates intermediate colors based on normalized hit frequency (0.0 to 1.0).
|
* Evaluates intermediate colors based on normalized hit frequency (0.0 to 1.0).
|
||||||
@@ -58,28 +61,29 @@ public class HeatmapView extends View {
|
|||||||
* Calculated from view width in onSizeChanged.
|
* Calculated from view width in onSizeChanged.
|
||||||
*/
|
*/
|
||||||
private float mCenterX;
|
private float mCenterX;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Y-coordinate of the dartboard center in pixels.
|
* Y-coordinate of the dartboard center in pixels.
|
||||||
* Calculated from view height in onSizeChanged.
|
* Calculated from view height in onSizeChanged.
|
||||||
*/
|
*/
|
||||||
private float mCenterY;
|
private float mCenterY;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Radius of the dartboard in pixels.
|
* Radius of the dartboard in pixels.
|
||||||
* Calculated as 95% of the minimum dimension (width or height) to leave margin.
|
* Calculated as 95% of the minimum dimension (width or height) to leave margin.
|
||||||
* All segment boundaries are defined as factors of this radius.
|
* All segment boundaries are defined as factors of this radius.
|
||||||
*/
|
*/
|
||||||
private float mRadius;
|
private float mRadius;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Cache of pre-calculated Path objects for all dartboard segments.
|
* Cache of pre-calculated Path objects for all dartboard segments.
|
||||||
* Key format: "d" + number (doubles), "t" + number (triples),
|
* 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.
|
* Calculated once in calculatePaths() and reused for efficient rendering.
|
||||||
*/
|
*/
|
||||||
private final Map<String, Path> mSegmentPaths = new HashMap<>();
|
private final Map<String, Path> mSegmentPaths = new HashMap<>();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Player statistics object containing hit distribution data.
|
* Player statistics object containing hit distribution data.
|
||||||
* Used to determine heatmap colors for each segment.
|
* Used to determine heatmap colors for each segment.
|
||||||
@@ -87,6 +91,12 @@ public class HeatmapView extends View {
|
|||||||
*/
|
*/
|
||||||
private Statistics mStats;
|
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).
|
* Standard dartboard segment order (clockwise starting from 20 at the top).
|
||||||
* This array defines the physical layout of numbers around the dartboard
|
* This array defines the physical layout of numbers around the dartboard
|
||||||
@@ -100,19 +110,21 @@ public class HeatmapView extends View {
|
|||||||
* Constructs a new HeatmapView programmatically.
|
* Constructs a new HeatmapView programmatically.
|
||||||
* Used when creating the view from code rather than XML inflation.
|
* 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) {
|
public HeatmapView(final Context context) {
|
||||||
super(context);
|
super(context);
|
||||||
init();
|
init();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Constructs a new HeatmapView from XML.
|
* Constructs a new HeatmapView from XML.
|
||||||
* Used when inflating the view from an XML layout.
|
* 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
|
||||||
* @param attrs The attributes of the XML tag that is inflating the view
|
* access resources
|
||||||
|
* @param attrs The attributes of the XML tag that is inflating the view
|
||||||
*/
|
*/
|
||||||
public HeatmapView(final Context context, @Nullable final AttributeSet attrs) {
|
public HeatmapView(final Context context, @Nullable final AttributeSet attrs) {
|
||||||
super(context, attrs);
|
super(context, attrs);
|
||||||
@@ -132,6 +144,19 @@ public class HeatmapView extends View {
|
|||||||
*/
|
*/
|
||||||
public void setStats(final Statistics stats) {
|
public void setStats(final Statistics stats) {
|
||||||
this.mStats = 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();
|
invalidate();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -140,8 +165,8 @@ public class HeatmapView extends View {
|
|||||||
* Recalculates the dartboard geometry (center point and radius) and regenerates
|
* Recalculates the dartboard geometry (center point and radius) and regenerates
|
||||||
* all segment paths based on the new dimensions.
|
* all segment paths based on the new dimensions.
|
||||||
*
|
*
|
||||||
* @param w Current width of this view
|
* @param w Current width of this view
|
||||||
* @param h Current height of this view
|
* @param h Current height of this view
|
||||||
* @param oldw Old width of this view (before size change)
|
* @param oldw Old width of this view (before size change)
|
||||||
* @param oldh Old height of this view (before size change)
|
* @param oldh Old height of this view (before size change)
|
||||||
*/
|
*/
|
||||||
@@ -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>
|
* <p>
|
||||||
* Clears the existing path cache and regenerates all paths for:
|
* Clears the existing path cache and regenerates all paths for:
|
||||||
* - 20 double ring segments (90-100% radius)
|
* - 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>
|
* <p>
|
||||||
* Generates a path for a single dartboard segment (e.g., the triple 20 region)
|
* 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.
|
* in reverse, creating a closed wedge shape.
|
||||||
* <p>
|
* <p>
|
||||||
* The path is constructed by:
|
* The path is constructed by:
|
||||||
* 1. Drawing an outer arc from startAngle to startAngle + sweep
|
* 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
|
* 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 innerFactor Ratio of inner radius to base radius (0.0 to 1.0). E.g.,
|
||||||
* @param outerFactor Ratio of outer radius to base radius (0.0 to 1.0). E.g., 1.00 = 100% of radius
|
* 0.50 = 50% of radius
|
||||||
* @param startAngle Starting angle in degrees (0° = right, increasing clockwise)
|
* @param outerFactor Ratio of outer radius to base radius (0.0 to 1.0). E.g.,
|
||||||
* @param sweep Angular sweep in degrees (typically 18° for standard dartboard segments)
|
* 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
|
* @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 Path path = new Path();
|
||||||
final RectF outerRect = new RectF(
|
final RectF outerRect = new RectF(
|
||||||
mCenterX - mRadius * outerFactor, mCenterY - mRadius * outerFactor,
|
mCenterX - mRadius * outerFactor, mCenterY - mRadius * outerFactor,
|
||||||
mCenterX + mRadius * outerFactor, mCenterY + mRadius * outerFactor
|
mCenterX + mRadius * outerFactor, mCenterY + mRadius * outerFactor);
|
||||||
);
|
|
||||||
final RectF innerRect = new RectF(
|
final RectF innerRect = new RectF(
|
||||||
mCenterX - mRadius * innerFactor, mCenterY - mRadius * innerFactor,
|
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(outerRect, startAngle, sweep);
|
||||||
path.arcTo(innerRect, startAngle + sweep, -sweep);
|
path.arcTo(innerRect, startAngle + sweep, -sweep);
|
||||||
@@ -238,10 +270,11 @@ public class HeatmapView extends View {
|
|||||||
* 1. Early exit if no statistics are loaded
|
* 1. Early exit if no statistics are loaded
|
||||||
* 2. Resolve color palette from resources (cold/hot) and hardcoded (empty)
|
* 2. Resolve color palette from resources (cold/hot) and hardcoded (empty)
|
||||||
* 3. For each dartboard segment:
|
* 3. For each dartboard segment:
|
||||||
* - Determine hit count from statistics
|
* - Determine hit count from statistics
|
||||||
* - If zero hits: use subtle ghost color
|
* - 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
|
||||||
* - Draw the filled path with the calculated color
|
* frequency
|
||||||
|
* - Draw the filled path with the calculated color
|
||||||
* 4. Draw wireframe overlay on all segments for visual definition
|
* 4. Draw wireframe overlay on all segments for visual definition
|
||||||
* <p>
|
* <p>
|
||||||
* Color mapping:
|
* Color mapping:
|
||||||
@@ -255,7 +288,15 @@ public class HeatmapView extends View {
|
|||||||
@Override
|
@Override
|
||||||
protected void onDraw(@NonNull final Canvas canvas) {
|
protected void onDraw(@NonNull final Canvas canvas) {
|
||||||
super.onDraw(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
|
// Resolve branding colors from resources
|
||||||
final int coldColor = ContextCompat.getColor(getContext(), R.color.volt_green);
|
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;
|
final String statsKey = key.contains("_") ? key.substring(0, key.indexOf("_")) : key;
|
||||||
|
|
||||||
// Check if there are any hits recorded for this segment
|
// 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;
|
final int color;
|
||||||
|
|
||||||
if (hitCount == null || hitCount == 0) {
|
if (hitCount == null || hitCount == 0) {
|
||||||
color = emptyColor;
|
color = emptyColor;
|
||||||
} else {
|
} else {
|
||||||
// Fetch the normalized heat (0.0 to 1.0) and evaluate against Green -> Red gradient
|
// Normalize hit count against max and interpolate Green -> Red
|
||||||
final float weight = mStats.getNormalizedWeight(statsKey);
|
final float weight = maxHits > 0 ? (float) hitCount / maxHits : 0f;
|
||||||
color = (int) mColorEvaluator.evaluate(weight, coldColor, hotColor);
|
color = (int) mColorEvaluator.evaluate(weight, coldColor, hotColor);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -124,17 +124,15 @@ public class PlayerItemView extends MaterialCardView {
|
|||||||
* @param player The Player object containing data to display
|
* @param player The Player object containing data to display
|
||||||
* @param gameAverage The game average to display (points per 3 darts)
|
* @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 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,
|
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
|
Log.d(TAG, "bindWithGameAverageAndPosition() called with: player = [" + player
|
||||||
+ "], gameAverage = [" + gameAverage + "], position = [" + position + "], isWinner = [" + isWinner
|
+ "], gameAverage = [" + gameAverage + "], position = [" + position + "]");
|
||||||
+ "]");
|
|
||||||
|
|
||||||
// Build username with position indicator
|
// Build username with position indicator
|
||||||
final String positionSuffix = getPositionSuffix(position);
|
final String positionSuffix = getPositionSuffix(position);
|
||||||
final String displayName = isWinner
|
final String displayName = position == 1
|
||||||
? "🏆 " + player.username + " (" + positionSuffix + ")"
|
? "🏆 " + player.username + " (" + positionSuffix + ")"
|
||||||
: player.username + " (" + positionSuffix + ")";
|
: player.username + " (" + positionSuffix + ")";
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import android.content.Context;
|
|||||||
import android.util.AttributeSet;
|
import android.util.AttributeSet;
|
||||||
import android.util.Log;
|
import android.util.Log;
|
||||||
import android.view.LayoutInflater;
|
import android.view.LayoutInflater;
|
||||||
|
import android.view.View;
|
||||||
import android.widget.ScrollView;
|
import android.widget.ScrollView;
|
||||||
import android.widget.TextView;
|
import android.widget.TextView;
|
||||||
import androidx.annotation.NonNull;
|
import androidx.annotation.NonNull;
|
||||||
@@ -11,6 +12,7 @@ import androidx.annotation.Nullable;
|
|||||||
import com.aldo.apps.ochecompanion.R;
|
import com.aldo.apps.ochecompanion.R;
|
||||||
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.game.GameManager;
|
||||||
import com.bumptech.glide.Glide;
|
import com.bumptech.glide.Glide;
|
||||||
import com.google.android.material.imageview.ShapeableImageView;
|
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
|
* PlayerStatsView: A complete dashboard component that visualizes a player's
|
||||||
* career performance, including a heatmap and detailed metrics.
|
* career performance, including a heatmap and detailed metrics.
|
||||||
* <p>
|
* <p>
|
||||||
* This custom {@link ScrollView} component provides a comprehensive player statistics
|
* This custom {@link ScrollView} component provides a comprehensive player
|
||||||
|
* statistics
|
||||||
* display that includes:
|
* display that includes:
|
||||||
* <ul>
|
* <ul>
|
||||||
* <li>Player identity information (username and profile picture)</li>
|
* <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
|
||||||
* <li>Threshold score counters (60+, 100+, 140+, and 180 counts)</li>
|
* percentage, best finish)</li>
|
||||||
* <li>Interactive dartboard heatmap visualization via {@link HeatmapView}</li>
|
* <li>Threshold score counters (60+, 100+, 140+, and 180 counts)</li>
|
||||||
|
* <li>Interactive dartboard heatmap visualization via {@link HeatmapView}</li>
|
||||||
* </ul>
|
* </ul>
|
||||||
* <p>
|
* <p>
|
||||||
* The view is inflated from a layout resource and automatically initializes all
|
* 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.
|
* method, which populates all fields with formatted player statistics.
|
||||||
*
|
*
|
||||||
* @see HeatmapView
|
* @see HeatmapView
|
||||||
@@ -49,6 +54,11 @@ public class PlayerStatsView extends ScrollView {
|
|||||||
*/
|
*/
|
||||||
private HeatmapView mHeatmap;
|
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.
|
* The circular profile image view that displays the player's avatar.
|
||||||
* Shows either a custom profile picture or a default user icon.
|
* Shows either a custom profile picture or a default user icon.
|
||||||
@@ -85,23 +95,29 @@ public class PlayerStatsView extends ScrollView {
|
|||||||
*/
|
*/
|
||||||
private TextView mTvBestFinish;
|
private TextView mTvBestFinish;
|
||||||
|
|
||||||
/**
|
// Dynamic labels for metric cards (can switch between career/match mode)
|
||||||
* The {@link TextView} displaying the count of turns scoring 60 points or more.
|
private TextView mTvCareerAvgLabel;
|
||||||
*/
|
private TextView mTvFirst9Label;
|
||||||
|
private TextView mTvCheckoutPctLabel;
|
||||||
|
private TextView mTvBestFinishLabel;
|
||||||
|
|
||||||
private TextView mTvCount60;
|
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;
|
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;
|
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;
|
private TextView mTvCount180;
|
||||||
|
|
||||||
@@ -110,14 +126,17 @@ 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>
|
* <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:
|
* It performs the following operations:
|
||||||
* <ol>
|
* <ol>
|
||||||
* <li>Calls the superclass constructor with context and attributes</li>
|
* <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
|
||||||
* <li>Initializes all child views via {@link #initViews()}</li>
|
* view</li>
|
||||||
|
* <li>Initializes all child views via {@link #initViews()}</li>
|
||||||
* </ol>
|
* </ol>
|
||||||
* <p>
|
* <p>
|
||||||
* This constructor is typically invoked when the view is inflated from XML.
|
* This constructor is typically invoked when the view is inflated from XML.
|
||||||
@@ -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>
|
* <p>
|
||||||
* This method binds the following UI components:
|
* This method binds the following UI components:
|
||||||
* <ul>
|
* <ul>
|
||||||
* <li><b>Identity:</b> Profile image and username text view</li>
|
* <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
|
||||||
* and best finish text views</li>
|
* percentage,
|
||||||
* <li><b>Threshold Counters:</b> Four text views for 60+, 100+, 140+, and 180 counts</li>
|
* and best finish text views</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>
|
* </ul>
|
||||||
* <p>
|
* <p>
|
||||||
* This method is called once during construction and establishes references
|
* This method is called once during construction and establishes references
|
||||||
@@ -150,6 +173,7 @@ public class PlayerStatsView extends ScrollView {
|
|||||||
*/
|
*/
|
||||||
private void initViews() {
|
private void initViews() {
|
||||||
mHeatmap = findViewById(R.id.statsHeatmap);
|
mHeatmap = findViewById(R.id.statsHeatmap);
|
||||||
|
mTvHeatmapLabel = findViewById(R.id.tvHeatmapLabel);
|
||||||
mIvAvatar = findViewById(R.id.ivPlayerAvatar);
|
mIvAvatar = findViewById(R.id.ivPlayerAvatar);
|
||||||
mTvUsername = findViewById(R.id.tvUsername);
|
mTvUsername = findViewById(R.id.tvUsername);
|
||||||
mTvCareerAvg = findViewById(R.id.tvCareerAvgValue);
|
mTvCareerAvg = findViewById(R.id.tvCareerAvgValue);
|
||||||
@@ -157,6 +181,12 @@ public class PlayerStatsView extends ScrollView {
|
|||||||
mTvCheckoutPct = findViewById(R.id.tvCheckoutPctValue);
|
mTvCheckoutPct = findViewById(R.id.tvCheckoutPctValue);
|
||||||
mTvBestFinish = findViewById(R.id.tvBestFinishValue);
|
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
|
// Threshold counters
|
||||||
mTvCount60 = findViewById(R.id.tvCount60);
|
mTvCount60 = findViewById(R.id.tvCount60);
|
||||||
mTvCount100 = findViewById(R.id.tvCount100);
|
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.
|
* Binds both the player identity and their accumulated stats to the UI.
|
||||||
* <p>
|
* <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:
|
* The binding process is organized into four logical sections:
|
||||||
* <ol>
|
* <ol>
|
||||||
* <li><b>Identity:</b> Displays the player's username in uppercase and loads their
|
* <li><b>Identity:</b> Displays the player's username in uppercase and loads
|
||||||
* profile picture using Glide (or shows a default icon if no picture is set)</li>
|
* their
|
||||||
* <li><b>High-Level Metrics:</b> Formats and displays career average, first-9 average,
|
* profile picture using Glide (or shows a default icon if no picture is
|
||||||
* checkout percentage (with % symbol), and highest checkout value</li>
|
* set)</li>
|
||||||
* <li><b>Threshold Totals:</b> Displays counts for 60+, 100+, 140+, and 180 point turns</li>
|
* <li><b>High-Level Metrics:</b> Formats and displays career average, first-9
|
||||||
* <li><b>Heatmap Rendering:</b> Passes statistics to the {@link HeatmapView} for
|
* average,
|
||||||
* visual dartboard representation</li>
|
* 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
|
||||||
|
* visual dartboard representation</li>
|
||||||
* </ol>
|
* </ol>
|
||||||
* <p>
|
* <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.
|
* without modifying the UI.
|
||||||
*
|
*
|
||||||
* @param player The {@link Player} object containing identity information (username, profile picture)
|
* @param player The {@link Player} object containing identity information
|
||||||
* @param stats The {@link Statistics} object containing all accumulated performance metrics
|
* (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) {
|
public void bind(@NonNull final Player player, @NonNull final Statistics stats) {
|
||||||
if (player == null || stats == null) {
|
if (player == null || stats == null) {
|
||||||
Log.e(TAG, "bind: Cannot bind, return");
|
Log.e(TAG, "bind: Cannot bind, return");
|
||||||
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
|
// 1. Identity
|
||||||
mTvUsername.setText(player.username.toUpperCase());
|
mTvUsername.setText(player.username.toUpperCase());
|
||||||
if (player.profilePictureUri != null) {
|
if (player.profilePictureUri != null) {
|
||||||
@@ -211,6 +257,66 @@ public class PlayerStatsView extends ScrollView {
|
|||||||
mTvCount180.setText(String.valueOf(stats.getCount180()));
|
mTvCount180.setText(String.valueOf(stats.getCount180()));
|
||||||
|
|
||||||
// 4. Heatmap Rendering
|
// 4. Heatmap Rendering
|
||||||
|
mTvHeatmapLabel.setText("Impact Heatmap");
|
||||||
|
mTvHeatmapLabel.setVisibility(View.VISIBLE);
|
||||||
|
mHeatmap.setVisibility(View.VISIBLE);
|
||||||
mHeatmap.setStats(stats);
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -176,7 +176,7 @@ public class MainMenuGroupMatchAdapter extends RecyclerView.Adapter<MainMenuGrou
|
|||||||
final Player player = participantData.player;
|
final Player player = participantData.player;
|
||||||
final double gameAverage = participantData.gameAverage;
|
final double gameAverage = participantData.gameAverage;
|
||||||
final boolean isWinner = participantData.score == 0; // Winner has 0 remaining score
|
final boolean isWinner = participantData.score == 0; // Winner has 0 remaining score
|
||||||
mItemView.bindWithGameAverageAndPosition(player, gameAverage, position, isWinner);
|
mItemView.bindWithGameAverageAndPosition(player, gameAverage, position);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
package com.aldo.apps.ochecompanion.utils;
|
package com.aldo.apps.ochecompanion.utils;
|
||||||
|
|
||||||
|
import java.util.HashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* MatchProgress: A serializable snapshot of an ongoing X01 session.
|
* 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. */
|
/** The total number of darts thrown by the player so far. */
|
||||||
public int dartsThrown;
|
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. */
|
/** Default constructor required for serialization/deserialization. */
|
||||||
public PlayerStateSnapshot() {}
|
public PlayerStateSnapshot() {
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Constructs a new PlayerStateSnapshot with the specified values.
|
* Constructs a new PlayerStateSnapshot from a full set of match state 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
|
|
||||||
*/
|
*/
|
||||||
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.playerId = playerId;
|
||||||
this.name = name;
|
this.name = name;
|
||||||
this.remainingScore = remainingScore;
|
this.remainingScore = remainingScore;
|
||||||
this.dartsThrown = dartsThrown;
|
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<>();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
10
app/src/main/res/drawable/ic_chevron_left.xml
Normal file
10
app/src/main/res/drawable/ic_chevron_left.xml
Normal 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>
|
||||||
@@ -210,4 +210,47 @@
|
|||||||
android:layout_height="match_parent"
|
android:layout_height="match_parent"
|
||||||
android:visibility="gone"/>
|
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>
|
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||||
@@ -53,6 +53,7 @@
|
|||||||
|
|
||||||
<!-- CENTERPIECE: HEATMAP -->
|
<!-- CENTERPIECE: HEATMAP -->
|
||||||
<TextView
|
<TextView
|
||||||
|
android:id="@+id/tvHeatmapLabel"
|
||||||
android:layout_width="wrap_content"
|
android:layout_width="wrap_content"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:text="Impact Heatmap"
|
android:text="Impact Heatmap"
|
||||||
@@ -74,12 +75,12 @@
|
|||||||
|
|
||||||
<LinearLayout style="@style/StatsCardStyle">
|
<LinearLayout style="@style/StatsCardStyle">
|
||||||
<TextView android:id="@+id/tvCareerAvgValue" style="@style/StatsValueStyle" tools:text="94.2" />
|
<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>
|
||||||
|
|
||||||
<LinearLayout style="@style/StatsCardStyle" android:layout_marginStart="12dp">
|
<LinearLayout style="@style/StatsCardStyle" android:layout_marginStart="12dp">
|
||||||
<TextView android:id="@+id/tvFirst9Value" style="@style/StatsValueStyle" tools:text="102.5" />
|
<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>
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
|
|
||||||
@@ -93,12 +94,12 @@
|
|||||||
|
|
||||||
<LinearLayout style="@style/StatsCardStyle">
|
<LinearLayout style="@style/StatsCardStyle">
|
||||||
<TextView android:id="@+id/tvCheckoutPctValue" style="@style/StatsValueStyle" tools:text="38.5%" />
|
<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>
|
||||||
|
|
||||||
<LinearLayout style="@style/StatsCardStyle" android:layout_marginStart="12dp">
|
<LinearLayout style="@style/StatsCardStyle" android:layout_marginStart="12dp">
|
||||||
<TextView android:id="@+id/tvBestFinishValue" style="@style/StatsValueStyle" tools:text="170" />
|
<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>
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user