feat: implement comprehensive statistics tracking and refactor checkout engine

BREAKING CHANGE: Database schema updated from v3 to v9 (destructive migration)

Major Features:
- Statistics system: Track player performance (darts, points, matches, double-outs)
- Match state management: ONGOING/COMPLETED/CANCELED with state-based queries
- Settings activity: Day/night mode and standard game mode preferences
- CheckoutEngine refactored to standalone utility class with 1/2/3-dart methods

CheckoutConstants Overhaul:
- Generate all possible double-out combinations (~37,200 routes)
- Intelligent route selection (fewer darts > T20/T19 > higher doubles)
- Store both optimal routes and complete alternatives

GameActivity Enhancements:
- Automatic statistics tracking on turn submission and win
- Career average calculation and database updates
- Fixed race condition with dart value capture

Database Changes:
- Added Statistics entity and StatisticsDao
- Player ID migration: int → long for consistency
- Match entity: added MatchState enum and helper methods
- MatchDao: new state-based query methods

Developer Experience:
- Comprehensive JavaDoc across all new/modified classes
- Test harness for checkout generation validation
- Improved code organization with utils package separation
This commit is contained in:
Alexander Doerflinger
2026-01-30 08:21:26 +01:00
parent 2953a1bf67
commit c2f18d9328
26 changed files with 1246 additions and 185 deletions

View File

@@ -38,10 +38,12 @@ dependencies {
implementation(libs.material)
implementation(libs.activity)
implementation(libs.constraintlayout)
implementation(libs.preference)
testImplementation(libs.junit)
androidTestImplementation(libs.ext.junit)
androidTestImplementation(libs.espresso.core)
implementation(libs.glide)
implementation(libs.room.runtime)
annotationProcessor(libs.room.compiler)
implementation(libs.preferences)
}

View File

@@ -16,6 +16,10 @@
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.OcheCompanion">
<activity
android:name=".SettingsActivity"
android:exported="false"
android:label="@string/title_activity_settings" />
<activity
android:name=".GameActivity"
android:exported="false" />

View File

@@ -17,8 +17,13 @@ import androidx.activity.result.ActivityResultLauncher;
import androidx.activity.result.contract.ActivityResultContracts;
import androidx.annotation.NonNull;
import androidx.appcompat.app.AppCompatActivity;
import androidx.core.graphics.Insets;
import androidx.core.view.ViewCompat;
import androidx.core.view.WindowInsetsCompat;
import com.aldo.apps.ochecompanion.database.AppDatabase;
import com.aldo.apps.ochecompanion.database.objects.Player;
import com.aldo.apps.ochecompanion.database.objects.Statistics;
import com.aldo.apps.ochecompanion.ui.CropOverlayView;
import com.aldo.apps.ochecompanion.utils.UIConstants;
import com.google.android.material.button.MaterialButton;
@@ -110,7 +115,7 @@ public class AddPlayerActivity extends AppCompatActivity {
/**
* Database ID of the player being edited (-1 for new player).
*/
private int mExistingPlayerId = -1;
private long mExistingPlayerId = -1;
/**
* Player object loaded from the database (null when creating new player).
@@ -162,6 +167,12 @@ public class AddPlayerActivity extends AppCompatActivity {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_add_player);
Log.d(TAG, "AddPlayerActivity Created");
// Configure window insets to properly handle system bars (status bar, navigation bar)
ViewCompat.setOnApplyWindowInsetsListener(findViewById(R.id.main), (v, insets) -> {
Insets systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars());
v.setPadding(systemBars.left, systemBars.top, systemBars.right, systemBars.bottom);
return insets;
});
// Initialize all UI components and their click listeners
initViews();
@@ -171,7 +182,7 @@ public class AddPlayerActivity extends AppCompatActivity {
// Check if editing an existing player
if (getIntent().hasExtra(EXTRA_PLAYER_ID)) {
mExistingPlayerId = getIntent().getIntExtra(EXTRA_PLAYER_ID, -1);
mExistingPlayerId = getIntent().getLongExtra(EXTRA_PLAYER_ID, -1);
loadExistingPlayer();
}
}
@@ -459,8 +470,14 @@ public class AddPlayerActivity extends AppCompatActivity {
AppDatabase.getDatabase(this).playerDao().update(mExistingPlayer);
} else {
// Create and insert new player
Player p = new Player(name, mInternalImagePath);
AppDatabase.getDatabase(this).playerDao().insert(p);
final Player p = new Player(name, mInternalImagePath);
final long newUserId = AppDatabase.getDatabase(this).playerDao().insert(p);
final Player dbPlayer = AppDatabase.getDatabase(this).playerDao().getPlayerById(newUserId);
if (dbPlayer != null) {
Log.d(TAG, "savePlayer: Player has been created, create stats as well.");
final Statistics playerStats = new Statistics(dbPlayer.id);
AppDatabase.getDatabase(this).statisticsDao().insertStatistics(playerStats);
}
}
// Close activity on main thread after save completes

View File

@@ -5,6 +5,7 @@ import android.content.Intent;
import android.content.res.ColorStateList;
import android.graphics.Color;
import android.os.Bundle;
import android.util.Log;
import android.view.View;
import android.view.animation.AlphaAnimation;
import android.view.animation.Animation;
@@ -14,8 +15,16 @@ import android.widget.TextView;
import android.widget.Toast;
import androidx.appcompat.app.AppCompatActivity;
import androidx.core.content.ContextCompat;
import androidx.core.graphics.Insets;
import androidx.core.view.ViewCompat;
import androidx.core.view.WindowInsetsCompat;
import com.aldo.apps.ochecompanion.database.AppDatabase;
import com.aldo.apps.ochecompanion.database.objects.Match;
import com.aldo.apps.ochecompanion.database.objects.Player;
import com.aldo.apps.ochecompanion.database.objects.Statistics;
import com.aldo.apps.ochecompanion.utils.CheckoutConstants;
import com.aldo.apps.ochecompanion.utils.CheckoutEngine;
import com.aldo.apps.ochecompanion.utils.DartsConstants;
import com.aldo.apps.ochecompanion.utils.UIConstants;
import com.google.android.material.button.MaterialButton;
@@ -30,6 +39,8 @@ import java.util.UUID;
*/
public class GameActivity extends AppCompatActivity {
private static final String TAG = "GameActivity";
/**
* Intent extra key for player list. Type: ArrayList<Player>
*/
@@ -175,21 +186,33 @@ public class GameActivity extends AppCompatActivity {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_game);
// Configure window insets to properly handle system bars (status bar, navigation bar)
ViewCompat.setOnApplyWindowInsetsListener(findViewById(R.id.main), (v, insets) -> {
Insets systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars());
v.setPadding(systemBars.left, systemBars.top, systemBars.right, systemBars.bottom);
return insets;
});
// Extract game parameters from intent
mStartingScore = getIntent().getIntExtra(EXTRA_START_SCORE, DartsConstants.DEFAULT_GAME_SCORE);
mMatchUuid = getIntent().getStringExtra(EXTRA_MATCH_UUID);
ArrayList<Player> participants = getIntent().getParcelableArrayListExtra(EXTRA_PLAYERS);
// Initialize activity components in order
initViews();
setupKeyboard();
setupGame(participants);
new Thread(() -> {
final List<Player> allAvailablePlayers = AppDatabase.getDatabase(GameActivity.this).playerDao().getAllPlayers();
Log.d(TAG, "onCreate: allAvailablePlayers = [" + allAvailablePlayers + "]");
runOnUiThread(() -> {
setupGame(allAvailablePlayers);
});
}).start();
}
/**
* Initializes UI component references and sets up click listeners.
*/
private void initViews() {
Log.d(TAG, "initViews() called");
tvScorePrimary = findViewById(R.id.tvScorePrimary);
tvPlayerName = findViewById(R.id.tvPlayerName);
tvLegAvg = findViewById(R.id.tvLegAvg);
@@ -218,6 +241,7 @@ public class GameActivity extends AppCompatActivity {
* Dynamically creates and configures numeric keyboard buttons (1-20).
*/
private void setupKeyboard() {
Log.d(TAG, "setupKeyboard() called");
glKeyboard.removeAllViews();
mKeyboardButtons.clear();
@@ -243,13 +267,15 @@ public class GameActivity extends AppCompatActivity {
* @param players List of Player objects (can be null/empty)
*/
private void setupGame(final List<Player> players) {
Log.d(TAG, "setupGame() called with: players = [" + players + "]");
mPlayerStates = new ArrayList<>();
if (players != null && !players.isEmpty()) {
for (Player p : players) {
mPlayerStates.add(new X01State(p.username, mStartingScore));
mPlayerStates.add(new X01State(p, mStartingScore));
}
} else {
mPlayerStates.add(new X01State("GUEST 1", mStartingScore));
final Player guest = new Player("GUEST", null);
mPlayerStates.add(new X01State(guest, mStartingScore));
}
updateUI();
setMultiplier(DartsConstants.MULTIPLIER_SINGLE);
@@ -289,7 +315,7 @@ public class GameActivity extends AppCompatActivity {
mCurrentTurnDarts.add(points);
updateTurnIndicators();
mIsTurnOver = true;
handleWin(active);
handleWin(active, mCurrentTurnDarts.size(), scoreBeforeDart);
} else {
// VALID THROW
mCurrentTurnDarts.add(points);
@@ -350,6 +376,31 @@ public class GameActivity extends AppCompatActivity {
}
}
private void updatePlayerStats(GameActivity.X01State active, int dartsThrown, int pointsMade, boolean wasBust) {
new Thread(() -> {
final Player player = active.player;
if (player != null && player.id != 0) {
final Statistics playerStats = AppDatabase.getDatabase(GameActivity.this).statisticsDao().getStatisticsForPlayer(player.id);
playerStats.saveDartsThrown(dartsThrown, pointsMade);
Log.d(TAG, "submitTurn: dartsThrown = [" + dartsThrown + "], pointsMade = [" + pointsMade + "]");
if (!wasBust && dartsThrown < 3) {
playerStats.addMissedDarts(3 - dartsThrown);
}
Log.d(TAG, "submitTurn: statistics = [" + playerStats + "]");
AppDatabase.getDatabase(GameActivity.this).statisticsDao().updateStatistics(playerStats);
// Calculate career average: total points / total darts thrown
final long totalDarts = playerStats.getDartsThrown();
if (totalDarts > 0) {
player.careerAverage = (double) playerStats.getOverallPointsMade() / totalDarts;
} else {
player.careerAverage = 0.0;
}
AppDatabase.getDatabase(GameActivity.this).playerDao().update(player);
}
}).start();
}
/**
* Finalizes current turn and advances to next player.
* Updates player score (unless bust), rotates to next player, resets turn state.
@@ -377,7 +428,7 @@ public class GameActivity extends AppCompatActivity {
active.remainingScore = finalScore;
active.dartsThrown += mCurrentTurnDarts.size();
}
// If bust, score remains unchanged
updatePlayerStats(active, mCurrentTurnDarts.size(), turnTotal, isBust);
// Rotate to next player
mActivePlayerIndex = (mActivePlayerIndex + 1) % mPlayerStates.size();
@@ -468,7 +519,7 @@ public class GameActivity extends AppCompatActivity {
*/
private void updateCheckoutSuggestion(final int score, final int dartsLeft) {
if (score <= DartsConstants.MAX_CHECKOUT_SCORE && score > DartsConstants.BUST_SCORE && dartsLeft > 0) {
String route = CheckoutEngine.getRoute(score, dartsLeft);
String route = CheckoutEngine.calculateCheckout(score, dartsLeft);
if (route != null) {
layoutCheckoutSuggestion.setVisibility(View.VISIBLE);
@@ -524,7 +575,8 @@ public class GameActivity extends AppCompatActivity {
*
* @param winner X01State of the winning player
*/
private void handleWin(final X01State winner) {
private void handleWin(final X01State winner, final int dartsThrown, final int pointsMade) {
updatePlayerStats(winner, dartsThrown, pointsMade, false);
// Show win notification
Toast.makeText(this, winner.name + " WINS!", Toast.LENGTH_LONG).show();
@@ -543,10 +595,16 @@ public class GameActivity extends AppCompatActivity {
* Tracks name, remaining score, and darts thrown.
*/
private static class X01State {
/**
* Player's display name.
* The {@link Player} object to update stats as well.
*/
String name;
final Player player;
/**
* Player's display name for convenience purpose, as extracted from the player object.
*/
final String name;
/**
* Player's current remaining score.
@@ -561,103 +619,13 @@ public class GameActivity extends AppCompatActivity {
/**
* Constructs X01State for a player.
*
* @param name Player's display name
* @param player The actual {@link Player} instance.
* @param startScore Starting score for the game
*/
X01State(final String name, final int startScore) {
this.name = name;
X01State(final Player player, final int startScore) {
this.player = player;
this.name = player.username;
this.remainingScore = startScore;
}
}
/**
* Static helper class that provides optimal checkout route suggestions for X01 games.
* <p>
* The CheckoutEngine calculates the best way to finish a game given a target score
* and number of darts remaining. It combines pre-calculated routes for classic
* finishes with intelligent logic for direct doubles and setup darts.
* </p>
* <p>
* <strong>Checkout Logic Priority:</strong>
* <ol>
* <li><strong>Direct Doubles:</strong> For scores ≤40 and even, suggest immediate double</li>
* <li><strong>Bullseye:</strong> For score of 50, suggest BULL (double bull)</li>
* <li><strong>Setup Darts:</strong> For odd scores with multiple darts, suggest setup + double</li>
* <li><strong>Pre-calculated Routes:</strong> For common finishes like 170, 141</li>
* <li><strong>Fallback:</strong> Generic high-score route (T20 Route)</li>
* </ol>
* </p>
* <p>
* <strong>Key Rule:</strong> Never suggests a route that would leave a score of 1, as
* this is impossible to finish (no double equals 1).
* </p>
* <p>
* <strong>Example Suggestions:</strong>
* <pre>
* getRoute(32, 1) → "D16" (Direct double)
* getRoute(50, 1) → "BULL" (Bullseye)
* getRoute(40, 2) → "D20" (Direct double)
* getRoute(41, 2) → "1 • D20" (Setup dart to leave 40)
* getRoute(170, 3) → "T20 • T20 • BULL" (Pre-calculated route)
* getRoute(100, 2) → "T20 Route" (General high-score advice)
* </pre>
* </p>
* <p>
* <strong>Design Pattern:</strong>
* This is a pure static utility class with no instance state. All methods are
* static and thread-safe. The checkout map is initialized once in a static block.
* </p>
*
* @see #getRoute(int, int)
*/
private static class CheckoutEngine {
/**
* Returns optimal checkout route for given score and darts remaining.
* Considers direct doubles, setup darts, and pre-calculated routes.
*
* @param score Target score to finish
* @param dartsLeft Number of darts remaining (1-3)
* @return Checkout route string or null if unavailable
*/
public static String getRoute(final int score, final int dartsLeft) {
// 1. Direct Out check (highest priority)
if (score <= DartsConstants.MAX_DIRECT_DOUBLE && score % 2 == 0) {
return DartsConstants.PREFIX_DOUBLE + (score / 2);
}
if (score == DartsConstants.DOUBLE_BULL_VALUE) return DartsConstants.LABEL_BULLSEYE;
// 2. Logic for Setup Darts (preventing score of 1)
if (dartsLeft >= 2) {
// Example: Score is 7. Suggesting D3 leaves 1 (Bust).
// Suggesting 1 leaves 6 (D3). Correct.
if (score <= 41 && score % 2 != 0) {
// Try to leave a common double (32, 40, 16)
if (score - DartsConstants.SETUP_DOUBLE_32 > 0 && score - DartsConstants.SETUP_DOUBLE_32 <= DartsConstants.MAX_DARTBOARD_NUMBER) {
return (score - DartsConstants.SETUP_DOUBLE_32) + DartsConstants.CHECKOUT_SEPARATOR + DartsConstants.PREFIX_DOUBLE + "16";
}
if (score - DartsConstants.SETUP_DOUBLE_40 > 0 && score - DartsConstants.SETUP_DOUBLE_40 <= DartsConstants.MAX_DARTBOARD_NUMBER) {
return (score - DartsConstants.SETUP_DOUBLE_40) + DartsConstants.CHECKOUT_SEPARATOR + DartsConstants.PREFIX_DOUBLE + "20";
}
return "1" + DartsConstants.CHECKOUT_SEPARATOR + DartsConstants.PREFIX_DOUBLE + ((score - 1) / 2); // Default setup
}
}
// 3. Fallback to Map or High Scoring Route
if (CheckoutConstants.hasCheckoutRoute(score)) {
String[] parts = CheckoutConstants.getCheckoutRoute(score);
if (parts != null && parts.length <= dartsLeft) {
StringBuilder sb = new StringBuilder();
for (int i = 0; i < parts.length; i++) {
sb.append(parts[i]);
if (i < parts.length - 1) sb.append(DartsConstants.CHECKOUT_SEPARATOR);
}
return sb.toString();
}
}
if (score > DartsConstants.HIGH_SCORE_THRESHOLD) return DartsConstants.LABEL_T20_ROUTE;
return null;
}
}
}

View File

@@ -1,7 +1,9 @@
package com.aldo.apps.ochecompanion;
import android.content.Intent;
import android.content.SharedPreferences;
import android.os.Bundle;
import android.util.Log;
import android.view.View;
import android.widget.TextView;
@@ -10,6 +12,7 @@ import androidx.appcompat.app.AppCompatActivity;
import androidx.core.graphics.Insets;
import androidx.core.view.ViewCompat;
import androidx.core.view.WindowInsetsCompat;
import androidx.preference.PreferenceManager;
import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.recyclerview.widget.RecyclerView;
@@ -47,6 +50,11 @@ public class MainMenuActivity extends AppCompatActivity {
*/
private int mTestCounter = 0;
/**
* The {@link SharedPreferences} containing the currently selected settings.
*/
private SharedPreferences mSettingsPref;
/**
* Initializes the activity: enables edge-to-edge display, configures window insets,
* and sets up the match recap view with test data click listener.
@@ -60,6 +68,7 @@ public class MainMenuActivity extends AppCompatActivity {
// Enable edge-to-edge display for immersive UI experience
EdgeToEdge.enable(this);
setContentView(R.layout.activity_main);
mSettingsPref = PreferenceManager.getDefaultSharedPreferences(this);
// Configure window insets to properly handle system bars (status bar, navigation bar)
ViewCompat.setOnApplyWindowInsetsListener(findViewById(R.id.main), (v, insets) -> {
@@ -69,16 +78,14 @@ public class MainMenuActivity extends AppCompatActivity {
});
findViewById(R.id.quick_start_btn).setOnClickListener(v -> quickStart());
findViewById(R.id.btnSettings).setOnClickListener(v -> launchSettings());
// Set up match recap view with test data functionality
mMatchRecap = findViewById(R.id.match_recap);
mMatchRecap.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(final View v) {
// Cycle through test data scenarios on each click
applyTestData(mTestCounter);
mTestCounter++;
}
mMatchRecap.setOnClickListener(v -> {
// Cycle through test data scenarios on each click
applyTestData(mTestCounter);
mTestCounter++;
});
}
@@ -90,6 +97,16 @@ public class MainMenuActivity extends AppCompatActivity {
super.onResume();
// Refresh the squad view with latest player data
initSquadView();
// Apply the last finished match if available.
applyLastMatch();
}
/**
* Launches the settings activity when the settings button is clicked.
*/
private void launchSettings() {
final Intent intent = new Intent(MainMenuActivity.this, SettingsActivity.class);
startActivity(intent);
}
/**
@@ -144,6 +161,20 @@ public class MainMenuActivity extends AppCompatActivity {
}).start();
}
/**
* Applies the last completed match from the database to the match recap view.
*/
private void applyLastMatch() {
// Database operations must be run on a background thread to keep the UI responsive.
new Thread(() -> {
final Match lastMatch = AppDatabase.getDatabase(getApplicationContext())
.matchDao()
.getLastCompletedMatch();
// Post-database query UI updates must happen back on the main (UI) thread
runOnUiThread(() -> mMatchRecap.setMatch(lastMatch));
}).start();
}
/**
* Applies test data to the match recap view for development and testing.
@@ -153,6 +184,7 @@ public class MainMenuActivity extends AppCompatActivity {
* @param counter Counter value used to determine which test scenario to display.
*/
private void applyTestData(final int counter) {
Log.d(TAG, "applyTestData: Applying Test Data [" + counter + "]");
// Create test player objects
final Player playerOne = new Player(DartsConstants.TEST_PLAYER_1, null);
playerOne.id = 1;
@@ -176,16 +208,22 @@ public class MainMenuActivity extends AppCompatActivity {
// Create test match objects with different player configurations and scores
final Match match1on1 = new Match("501", java.util.Arrays.asList(playerOne, playerTwo), scores1v1);
match1on1.markCompleted(); // Mark as completed for test data
final Match matchGroup = new Match("501", java.util.Arrays.asList(playerOne, playerTwo, playerThree, playerFour), scoresGroup);
matchGroup.markCompleted(); // Mark as completed for test data
// Cycle through different test scenarios based on counter value
if (counter % UIConstants.TEST_CYCLE_MODULO == 0) {
Log.d(TAG, "applyTestData: No recent match selected.");
// Scenario 1: No match (null state)
mMatchRecap.setMatch(null);
} else if (counter % UIConstants.TEST_CYCLE_MODULO == 1) {
Log.d(TAG, "applyTestData: 1 on 1 Match");
// Scenario 2: 1v1 match (two players)
mMatchRecap.setMatch(match1on1);
} else {
Log.d(TAG, "applyTestData: Group Match.");
// Scenario 3: Group match (four players)
mMatchRecap.setMatch(matchGroup);
}

View File

@@ -0,0 +1,47 @@
package com.aldo.apps.ochecompanion;
import android.os.Bundle;
import androidx.appcompat.app.ActionBar;
import androidx.appcompat.app.AppCompatActivity;
import androidx.core.graphics.Insets;
import androidx.core.view.ViewCompat;
import androidx.core.view.WindowInsetsCompat;
import com.aldo.apps.ochecompanion.ui.MainMenuPreferencesFragment;
/**
* Settings activity for configuring app-wide preferences.
* Hosts the MainMenuPreferencesFragment which displays available settings
* including day/night mode and standard game mode selection.
*/
public class SettingsActivity extends AppCompatActivity {
/**
* Initializes the settings activity and loads the preferences fragment.
* Configures window insets and enables the back button in the action bar.
*
* @param savedInstanceState Bundle containing saved state, or null if none exists
*/
@Override
protected void onCreate(final Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.settings_activity);
// Configure window insets to properly handle system bars (status bar, navigation bar)
ViewCompat.setOnApplyWindowInsetsListener(findViewById(R.id.main), (v, insets) -> {
Insets systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars());
v.setPadding(systemBars.left, systemBars.top, systemBars.right, systemBars.bottom);
return insets;
});
if (savedInstanceState == null) {
getSupportFragmentManager()
.beginTransaction()
.replace(R.id.settings, new MainMenuPreferencesFragment())
.commit();
}
ActionBar actionBar = getSupportActionBar();
if (actionBar != null) {
actionBar.setDisplayHomeAsUpEnabled(true);
}
}
}

View File

@@ -7,8 +7,10 @@ import androidx.room.RoomDatabase;
import com.aldo.apps.ochecompanion.database.dao.MatchDao;
import com.aldo.apps.ochecompanion.database.dao.PlayerDao;
import com.aldo.apps.ochecompanion.database.dao.StatisticsDao;
import com.aldo.apps.ochecompanion.database.objects.Match;
import com.aldo.apps.ochecompanion.database.objects.Player;
import com.aldo.apps.ochecompanion.database.objects.Statistics;
/**
* Main Room database class for the Oche Companion darts application.
@@ -21,7 +23,7 @@ import com.aldo.apps.ochecompanion.database.objects.Player;
* @see Player
* @see Match
*/
@Database(entities = {Player.class, Match.class}, version = 3, exportSchema = false)
@Database(entities = {Player.class, Match.class, Statistics.class}, version = 10, exportSchema = false)
public abstract class AppDatabase extends RoomDatabase {
/**
@@ -42,6 +44,15 @@ public abstract class AppDatabase extends RoomDatabase {
*/
public abstract MatchDao matchDao();
/**
* Returns the StatisticsDao for managing statistics records for players.
* Thread-safe and can be reused. Operations must run on background threads.
*
* @return StatisticsDao instance for player stats database operations
* @see StatisticsDao
*/
public abstract StatisticsDao statisticsDao();
/**
* Singleton instance of the AppDatabase.
* Volatile ensures thread-safe visibility in double-checked locking pattern.

View File

@@ -39,4 +39,41 @@ public interface MatchDao {
*/
@Query("SELECT * FROM matches ORDER BY timestamp DESC LIMIT 1")
Match getLastMatch();
/**
* Retrieves the most recently completed match.
* Must be called on a background thread.
*
* @return The most recent completed match, or null if no completed matches exist
*/
@Query("SELECT * FROM matches WHERE state = 'COMPLETED' ORDER BY timestamp DESC LIMIT 1")
Match getLastCompletedMatch();
/**
* Retrieves all completed matches ordered by most recent first.
* Must be called on a background thread.
*
* @return List of completed matches sorted by timestamp descending
*/
@Query("SELECT * FROM matches WHERE state = 'COMPLETED' ORDER BY timestamp DESC")
List<Match> getCompletedMatches();
/**
* Retrieves all ongoing matches.
* Must be called on a background thread.
*
* @return List of ongoing matches
*/
@Query("SELECT * FROM matches WHERE state = 'ONGOING' ORDER BY timestamp DESC")
List<Match> getOngoingMatches();
/**
* Retrieves matches by state.
* Must be called on a background thread.
*
* @param state The match state to filter by ("ONGOING", "COMPLETED", or "CANCELED")
* @return List of matches with the specified state
*/
@Query("SELECT * FROM matches WHERE state = :state ORDER BY timestamp DESC")
List<Match> getMatchesByState(final String state);
}

View File

@@ -1,8 +1,11 @@
package com.aldo.apps.ochecompanion.database.dao;
import static androidx.room.OnConflictStrategy.REPLACE;
import androidx.room.Dao;
import androidx.room.Delete;
import androidx.room.Insert;
import androidx.room.OnConflictStrategy;
import androidx.room.Query;
import androidx.room.Update;
@@ -22,8 +25,8 @@ public interface PlayerDao {
*
* @param player The player to insert
*/
@Insert
void insert(final Player player);
@Insert(onConflict = REPLACE)
long insert(final Player player);
/**
* Updates an existing player in the database.
@@ -53,7 +56,7 @@ public interface PlayerDao {
* @return The player, or null if not found
*/
@Query("SELECT * FROM players WHERE id = :id LIMIT 1")
Player getPlayerById(final int id);
Player getPlayerById(final long id);
/**
* Retrieves all players ordered alphabetically by username.

View File

@@ -0,0 +1,56 @@
package com.aldo.apps.ochecompanion.database.dao;
import androidx.room.Dao;
import androidx.room.Delete;
import androidx.room.Insert;
import androidx.room.Query;
import androidx.room.Update;
import com.aldo.apps.ochecompanion.database.objects.Statistics;
/**
* Data Access Object for Statistics entities in the Room database.
* Provides CRUD operations for managing player performance statistics.
* All operations must be executed on background threads.
*/
@Dao
public interface StatisticsDao {
/**
* Inserts a new statistics record into the database.
* Must be called on a background thread.
*
* @param statistics The statistics entity to persist
*/
@Insert
void insertStatistics(final Statistics statistics);
/**
* Retrieves statistics for a specific player by their ID.
* Must be called on a background thread.
*
* @param playerId The unique player ID
* @return Statistics record for the player, or null if not found
*/
@Query("SELECT * FROM statistics WHERE playerId = :playerId")
Statistics getStatisticsForPlayer(final long playerId);
/**
* Updates an existing statistics record in the database.
* Statistics is identified by its primary key (playerId).
* Must be called on a background thread.
*
* @param statistics The statistics entity with updated data
*/
@Update
void updateStatistics(final Statistics statistics);
/**
* Deletes a statistics record from the database.
* Must be called on a background thread.
*
* @param statistics The statistics entity to delete
*/
@Delete
void deleteStatistics(final Statistics statistics);
}

View File

@@ -13,11 +13,11 @@ import java.util.List;
import java.util.Map;
/**
* Represents a completed darts match in the Oche Companion application.
* Represents a darts match in the Oche Companion application.
* Room entity storing match information including game mode, timestamp, player count,
* and detailed performance data for all participants. Implements Serializable for
* passing between Android components. Provides helper methods to parse participant
* JSON data and reconstruct Player objects.
* state (ongoing/completed/canceled), and detailed performance data for all participants.
* Implements Serializable for passing between Android components. Provides helper methods
* to parse participant JSON data and reconstruct Player objects.
*
* @see com.aldo.apps.ochecompanion.database.dao.MatchDao
* @see com.aldo.apps.ochecompanion.database.objects.Player
@@ -27,6 +27,20 @@ import java.util.Map;
@Entity(tableName = "matches")
public class Match implements Serializable {
/**
* Represents the current state of a match.
*/
public enum MatchState {
/** Match is currently in progress */
ONGOING,
/** Match has been completed successfully */
COMPLETED,
/** Match was canceled before completion */
CANCELED
}
/**
* Auto-generated unique primary key for this match.
* Value is 0 before insertion, then assigned by Room using SQLite AUTOINCREMENT.
@@ -67,27 +81,35 @@ public class Match implements Serializable {
*/
public String participantData;
/**
* Current state of the match (ONGOING, COMPLETED, or CANCELED).
* New matches start as ONGOING and transition to COMPLETED or CANCELED.
*/
public MatchState state;
/**
* Constructs a new Match entity ready for database insertion.
* The match ID will be auto-generated by Room upon insertion.
*
* @param timestamp Unix epoch timestamp in milliseconds when match completed
* @param timestamp Unix epoch timestamp in milliseconds when match was created/completed
* @param gameMode Identifier for the darts game variant (e.g., "501", "Cricket")
* @param playerCount Number of players who participated (must be at least 1)
* @param participantData JSON string containing player performance data
* @param state Current state of the match (ONGOING, COMPLETED, or CANCELED)
* @see com.aldo.apps.ochecompanion.database.dao.MatchDao#insert(Match)
*/
public Match(final long timestamp, final String gameMode, final int playerCount, final String participantData) {
public Match(final long timestamp, final String gameMode, final int playerCount, final String participantData, final MatchState state) {
this.timestamp = timestamp;
this.gameMode = gameMode;
this.playerCount = playerCount;
this.participantData = participantData;
this.state = state;
}
/**
* Convenience constructor for creating a Match from a list of Player objects.
* Automatically generates JSON participant data and sets timestamp to current time.
* All players will have a score of 0.
* All players will have a score of 0. Match state is set to ONGOING.
*
* @param gameMode Identifier for the darts game variant (e.g., "501", "Cricket")
* @param players List of Player objects to include in this match
@@ -98,11 +120,13 @@ public class Match implements Serializable {
this.gameMode = gameMode;
this.playerCount = players.size();
this.participantData = generateParticipantJson(players, null);
this.state = MatchState.ONGOING;
}
/**
* Convenience constructor for creating a Match from players with their scores.
* Automatically generates JSON participant data and sets timestamp to current time.
* Match state is set to ONGOING by default.
*
* @param gameMode Identifier for the darts game variant (e.g., "501", "Cricket")
* @param players List of Player objects to include in this match
@@ -114,6 +138,7 @@ public class Match implements Serializable {
this.gameMode = gameMode;
this.playerCount = players.size();
this.participantData = generateParticipantJson(players, scores);
this.state = MatchState.ONGOING;
}
/**
@@ -293,4 +318,46 @@ public class Match implements Serializable {
}
return participants;
}
/**
* Marks this match as completed and updates the timestamp.
*/
public void markCompleted() {
this.state = MatchState.COMPLETED;
this.timestamp = System.currentTimeMillis();
}
/**
* Marks this match as canceled.
*/
public void markCanceled() {
this.state = MatchState.CANCELED;
}
/**
* Checks if the match is currently ongoing.
*
* @return true if the match is in ONGOING state
*/
public boolean isOngoing() {
return this.state == MatchState.ONGOING;
}
/**
* Checks if the match is completed.
*
* @return true if the match is in COMPLETED state
*/
public boolean isCompleted() {
return this.state == MatchState.COMPLETED;
}
/**
* Checks if the match was canceled.
*
* @return true if the match is in CANCELED state
*/
public boolean isCanceled() {
return this.state == MatchState.CANCELED;
}
}

View File

@@ -19,7 +19,7 @@ public class Player implements Parcelable {
* Room auto-populates on insert.
*/
@PrimaryKey(autoGenerate = true)
public int id;
public long id;
/**
* Player's display name shown throughout the app. Should be non-null and
@@ -66,7 +66,7 @@ public class Player implements Parcelable {
* @param in Parcel containing serialized Player data
*/
protected Player(final Parcel in) {
id = in.readInt();
id = in.readLong();
username = in.readString();
profilePictureUri = in.readString();
careerAverage = in.readDouble();
@@ -101,7 +101,7 @@ public class Player implements Parcelable {
*/
@Override
public void writeToParcel(final Parcel dest, final int flags) {
dest.writeInt(id);
dest.writeLong(id);
dest.writeString(username);
dest.writeString(profilePictureUri);
dest.writeDouble(careerAverage);

View File

@@ -0,0 +1,186 @@
package com.aldo.apps.ochecompanion.database.objects;
import androidx.room.Entity;
import androidx.room.ForeignKey;
import androidx.room.PrimaryKey;
/**
* Room database entity representing player performance statistics.
* Tracks comprehensive career metrics including darts thrown, accuracy,
* matches played, and double-out success rates. Each player has exactly
* one Statistics record, identified by the player's ID.
*
* @see com.aldo.apps.ochecompanion.database.dao.StatisticsDao
* @see Player
*/
@Entity(tableName = "statistics")
public class Statistics {
/**
* Player ID this statistics record belongs to (primary key).
* References the Player entity's ID field.
*/
@PrimaryKey
private long playerId;
/**
* Total number of darts thrown across all matches.
* Used to calculate career average and accuracy metrics.
*/
private long dartsThrown;
/**
* Total number of darts that missed the intended target.
* Contributes to accuracy calculations and player skill assessment.
*/
private long dartsMissed;
/**
* Cumulative points scored across all darts thrown.
* Used with dartsThrown to calculate career average.
*/
private long overallPointsMade;
/**
* Total number of completed matches for this player.
* Provides context for statistical significance.
*/
private int matchesPlayed;
/**
* Total number of double-out attempts (both successful and missed).
* Tracks how often player attempts to finish on doubles.
*/
private int doubleOutsTargeted;
/**
* Number of failed double-out attempts.
* Used to calculate double-out success rate.
*/
private int doubleOutsMissed;
/**
* Constructs a new Statistics record for a player.
* Initializes all counters to zero.
*
* @param playerId The unique player ID this statistics belongs to
*/
public Statistics(final long playerId) {
this.playerId = playerId;
dartsThrown = 0;
overallPointsMade = 0;
matchesPlayed = 0;
doubleOutsTargeted = 0;
doubleOutsMissed = 0;
}
/**
* Records darts thrown in a turn with their point value.
*
* @param dartsThrown Number of darts thrown in the turn
* @param pointsMade Total points scored with those darts
*/
public void saveDartsThrown(final long dartsThrown, final long pointsMade) {
this.dartsThrown += dartsThrown;
this.overallPointsMade += pointsMade;
}
/**
* Adds missed darts to the statistics.
* Used when a player doesn't throw all 3 darts in a turn (e.g., checkout or bust).
*
* @param dartsMissed Number of darts not thrown
*/
public void addMissedDarts(final long dartsMissed) {
this.dartsMissed += dartsMissed;
}
/**
* Increments the completed matches counter.
* Should be called when a match finishes.
*/
public void addCompletedMatch() {
matchesPlayed++;
}
/**
* Records a double-out attempt.
*
* @param isMissed true if the double-out was missed, false if successful
*/
public void addDoubleOutTarget(final boolean isMissed) {
doubleOutsTargeted++;
if (isMissed) {
doubleOutsMissed++;
}
}
public long getPlayerId() {
return playerId;
}
public void setPlayerId(long playerId) {
this.playerId = playerId;
}
public long getDartsThrown() {
return dartsThrown;
}
public void setDartsThrown(long dartsThrown) {
this.dartsThrown = dartsThrown;
}
public long getDartsMissed() {
return dartsMissed;
}
public void setDartsMissed(long dartsMissed) {
this.dartsMissed = dartsMissed;
}
public long getOverallPointsMade() {
return overallPointsMade;
}
public void setOverallPointsMade(long overallPointsMade) {
this.overallPointsMade = overallPointsMade;
}
public int getMatchesPlayed() {
return matchesPlayed;
}
public void setMatchesPlayed(int matchesPlayed) {
this.matchesPlayed = matchesPlayed;
}
public int getDoubleOutsTargeted() {
return doubleOutsTargeted;
}
public void setDoubleOutsTargeted(int doubleOutsTargeted) {
this.doubleOutsTargeted = doubleOutsTargeted;
}
public int getDoubleOutsMissed() {
return doubleOutsMissed;
}
public void setDoubleOutsMissed(int doubleOutsMissed) {
this.doubleOutsMissed = doubleOutsMissed;
}
@Override
public String toString() {
return "Statistics{" +
"playerId=" + playerId +
", dartsThrown=" + dartsThrown +
", dartsMissed=" + dartsMissed +
", overallPointsMade=" + overallPointsMade +
", matchesPlayed=" + matchesPlayed +
", doubleOutsTargeted=" + doubleOutsTargeted +
", doubleOutsMissed=" + doubleOutsMissed +
'}';
}
}

View File

@@ -0,0 +1,27 @@
package com.aldo.apps.ochecompanion.ui;
import android.os.Bundle;
import androidx.preference.PreferenceFragmentCompat;
import com.aldo.apps.ochecompanion.R;
/**
* Preference fragment for the main menu settings screen.
* Displays app-wide preferences including day/night mode and standard game mode selection.
* Preferences are automatically persisted to SharedPreferences by the AndroidX Preference library.
*/
public class MainMenuPreferencesFragment extends PreferenceFragmentCompat {
/**
* Initializes the preference screen from the main_menu_preferences XML resource.
* Called automatically by the fragment lifecycle.
*
* @param savedInstanceState Bundle containing saved state, or null if none exists
* @param rootKey Optional preference hierarchy root key
*/
@Override
public void onCreatePreferences(final Bundle savedInstanceState, final String rootKey) {
setPreferencesFromResource(R.xml.main_menu_preferences, rootKey);
}
}

View File

@@ -1,80 +1,191 @@
package com.aldo.apps.ochecompanion.utils;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* Pre-calculated checkout routes for classic darts finishes.
* Contains standard 3-dart checkout combinations for common scores.
* Pre-calculated checkout routes for all possible double-out combinations.
* Generates all valid 1-dart, 2-dart, and 3-dart checkouts during class initialization.
* Stores multiple routes per score for flexibility, with optimal route selection.
*/
public final class CheckoutConstants {
private static final int SINGLE = 1;
private static final int DOUBLE = 2;
private static final int TRIPLE = 3;
// Prevent instantiation
private CheckoutConstants() {
throw new UnsupportedOperationException("Constants class cannot be instantiated");
}
/**
* Map of pre-calculated checkout routes.
* Key: Target score, Value: Array of dart notations to achieve the checkout
* Map of all possible checkout routes.
* Key: Target score, Value: List of all possible routes (each route is an array of dart notations)
*/
private static final Map<Integer, String[]> CHECKOUT_MAP = new HashMap<>();
private static final Map<Integer, List<String[]>> ALL_CHECKOUTS = new HashMap<>();
/**
* Map of optimal checkout routes (best route per score).
* Key: Target score, Value: Optimal dart notation array
*/
private static final Map<Integer, String[]> OPTIMAL_CHECKOUTS = new HashMap<>();
static {
// Maximum 3-dart checkouts
CHECKOUT_MAP.put(170, new String[]{"T20", "T20", "BULL"});
CHECKOUT_MAP.put(167, new String[]{"T20", "T19", "BULL"});
CHECKOUT_MAP.put(164, new String[]{"T20", "T18", "BULL"});
CHECKOUT_MAP.put(161, new String[]{"T20", "T17", "BULL"});
CHECKOUT_MAP.put(160, new String[]{"T20", "T20", "D20"});
// Common high finishes
CHECKOUT_MAP.put(141, new String[]{"T20", "T19", "D12"});
CHECKOUT_MAP.put(140, new String[]{"T20", "T20", "D10"});
CHECKOUT_MAP.put(139, new String[]{"T20", "T19", "D11"});
CHECKOUT_MAP.put(138, new String[]{"T20", "T18", "D12"});
CHECKOUT_MAP.put(137, new String[]{"T20", "T19", "D10"});
CHECKOUT_MAP.put(136, new String[]{"T20", "T20", "D8"});
CHECKOUT_MAP.put(135, new String[]{"T20", "T17", "D12"});
CHECKOUT_MAP.put(134, new String[]{"T20", "T14", "D16"});
CHECKOUT_MAP.put(133, new String[]{"T20", "T19", "D8"});
CHECKOUT_MAP.put(132, new String[]{"T20", "T16", "D12"});
CHECKOUT_MAP.put(131, new String[]{"T20", "T13", "D16"});
CHECKOUT_MAP.put(130, new String[]{"T20", "T20", "D5"});
// Mid-range finishes
CHECKOUT_MAP.put(121, new String[]{"T17", "T18", "D10"});
CHECKOUT_MAP.put(120, new String[]{"T20", "20", "D20"});
CHECKOUT_MAP.put(119, new String[]{"T19", "T12", "D13"});
CHECKOUT_MAP.put(118, new String[]{"T18", "T14", "D14"});
CHECKOUT_MAP.put(117, new String[]{"T20", "17", "D20"});
CHECKOUT_MAP.put(116, new String[]{"T19", "19", "D20"});
CHECKOUT_MAP.put(115, new String[]{"T19", "18", "D20"});
CHECKOUT_MAP.put(114, new String[]{"T18", "18", "D20"});
CHECKOUT_MAP.put(113, new String[]{"T19", "16", "D20"});
CHECKOUT_MAP.put(112, new String[]{"T20", "12", "D20"});
CHECKOUT_MAP.put(111, new String[]{"T20", "11", "D20"});
CHECKOUT_MAP.put(110, new String[]{"T20", "10", "D20"});
// Lower finishes
CHECKOUT_MAP.put(107, new String[]{"T19", "10", "D20"});
CHECKOUT_MAP.put(106, new String[]{"T20", "10", "D18"});
CHECKOUT_MAP.put(105, new String[]{"T20", "13", "D16"});
CHECKOUT_MAP.put(104, new String[]{"T18", "18", "D16"});
CHECKOUT_MAP.put(103, new String[]{"T17", "12", "D20"});
CHECKOUT_MAP.put(102, new String[]{"T20", "10", "D16"});
CHECKOUT_MAP.put(101, new String[]{"T17", "10", "D20"});
CHECKOUT_MAP.put(100, new String[]{"T20", "D20"});
generateAllCheckouts();
selectOptimalCheckouts();
}
/**
* Gets the pre-calculated checkout route for a given score.
* Generates all possible 1-dart, 2-dart, and 3-dart double-out combinations.
*/
private static void generateAllCheckouts() {
// Generate 1-dart checkouts (D1-D20, plus Bull)
for (int first = 1; first <= 20; first++) {
final int score = first * DOUBLE;
addCheckout(score, new String[]{DartsConstants.PREFIX_DOUBLE + first});
}
addCheckout(50, new String[]{DartsConstants.LABEL_BULLSEYE});
// Generate 2-dart checkouts
for (int doubleOut = 1; doubleOut <= 20; doubleOut++) {
final int outValue = doubleOut * DOUBLE;
for (int setup = 1; setup <= 20; setup++) {
// Single + Double
final int singleScore = (setup * SINGLE) + outValue;
addCheckout(singleScore, new String[]{String.valueOf(setup), DartsConstants.PREFIX_DOUBLE + doubleOut});
// Double + Double
final int doubleScore = (setup * DOUBLE) + outValue;
addCheckout(doubleScore, new String[]{DartsConstants.PREFIX_DOUBLE + setup, DartsConstants.PREFIX_DOUBLE + doubleOut});
// Triple + Double
final int tripleScore = (setup * TRIPLE) + outValue;
addCheckout(tripleScore, new String[]{DartsConstants.PREFIX_TRIPLE + setup, DartsConstants.PREFIX_DOUBLE + doubleOut});
}
}
// Generate 3-dart checkouts
for (int doubleOut = 1; doubleOut <= 20; doubleOut++) {
final int outValue = doubleOut * DOUBLE;
for (int second = 1; second <= 20; second++) {
final int singleSecond = second * SINGLE;
final int doubleSecond = second * DOUBLE;
final int tripleSecond = second * TRIPLE;
for (int first = 1; first <= 20; first++) {
// All 9 combinations of (single/double/triple) + (single/double/triple) + double
addCheckout((first * SINGLE) + singleSecond + outValue,
new String[]{String.valueOf(first), String.valueOf(second), DartsConstants.PREFIX_DOUBLE + doubleOut});
addCheckout((first * SINGLE) + doubleSecond + outValue,
new String[]{String.valueOf(first), DartsConstants.PREFIX_DOUBLE + second, DartsConstants.PREFIX_DOUBLE + doubleOut});
addCheckout((first * SINGLE) + tripleSecond + outValue,
new String[]{String.valueOf(first), DartsConstants.PREFIX_TRIPLE + second, DartsConstants.PREFIX_DOUBLE + doubleOut});
addCheckout((first * DOUBLE) + singleSecond + outValue,
new String[]{DartsConstants.PREFIX_DOUBLE + first, String.valueOf(second), DartsConstants.PREFIX_DOUBLE + doubleOut});
addCheckout((first * DOUBLE) + doubleSecond + outValue,
new String[]{DartsConstants.PREFIX_DOUBLE + first, DartsConstants.PREFIX_DOUBLE + second, DartsConstants.PREFIX_DOUBLE + doubleOut});
addCheckout((first * DOUBLE) + tripleSecond + outValue,
new String[]{DartsConstants.PREFIX_DOUBLE + first, DartsConstants.PREFIX_TRIPLE + second, DartsConstants.PREFIX_DOUBLE + doubleOut});
addCheckout((first * TRIPLE) + singleSecond + outValue,
new String[]{DartsConstants.PREFIX_TRIPLE + first, String.valueOf(second), DartsConstants.PREFIX_DOUBLE + doubleOut});
addCheckout((first * TRIPLE) + doubleSecond + outValue,
new String[]{DartsConstants.PREFIX_TRIPLE + first, DartsConstants.PREFIX_DOUBLE + second, DartsConstants.PREFIX_DOUBLE + doubleOut});
addCheckout((first * TRIPLE) + tripleSecond + outValue,
new String[]{DartsConstants.PREFIX_TRIPLE + first, DartsConstants.PREFIX_TRIPLE + second, DartsConstants.PREFIX_DOUBLE + doubleOut});
}
}
}
}
/**
* Adds a checkout route to the map.
*/
private static void addCheckout(final int score, final String[] route) {
ALL_CHECKOUTS.computeIfAbsent(score, k -> new ArrayList<>()).add(route);
}
/**
* Selects the optimal route for each score based on preferred strategies.
* Preferences: Fewer darts > T20/T19 usage > Higher finishing double
*/
private static void selectOptimalCheckouts() {
for (Map.Entry<Integer, List<String[]>> entry : ALL_CHECKOUTS.entrySet()) {
final int score = entry.getKey();
final List<String[]> routes = entry.getValue();
String[] bestRoute = routes.get(0);
int bestScore = scoreRoute(bestRoute);
for (String[] route : routes) {
final int routeScore = scoreRoute(route);
if (routeScore > bestScore) {
bestScore = routeScore;
bestRoute = route;
}
}
OPTIMAL_CHECKOUTS.put(score, bestRoute);
}
}
/**
* Scores a route based on desirability.
* Higher score = better route
*/
private static int scoreRoute(final String[] route) {
int score = 0;
// Prefer fewer darts (most important)
score += (4 - route.length) * 10000;
// Prefer T20 (second most important)
for (String dart : route) {
if ("T20".equals(dart)) score += 100;
else if ("T19".equals(dart)) score += 90;
else if (dart.startsWith("T")) score += 50;
}
// Prefer higher finishing doubles
final String lastDart = route[route.length - 1];
if (lastDart.startsWith("D")) {
try {
final int doubleValue = Integer.parseInt(lastDart.substring(1));
score += doubleValue;
} catch (NumberFormatException e) {
// Ignore parsing errors
}
} else if (DartsConstants.LABEL_BULLSEYE.equals(lastDart)) {
score += 25; // Bull gets high score
}
return score;
}
/**
* Gets the optimal pre-calculated checkout route for a given score.
*
* @param score The target score to checkout
* @return Array of dart notations, or null if no route exists
*/
public static String[] getCheckoutRoute(final int score) {
return CHECKOUT_MAP.get(score);
return OPTIMAL_CHECKOUTS.get(score);
}
/**
* Gets all possible checkout routes for a given score.
*
* @param score The target score to checkout
* @return List of all possible routes, or null if no routes exist
*/
public static List<String[]> getAllCheckoutRoutes(final int score) {
return ALL_CHECKOUTS.get(score);
}
/**
@@ -84,7 +195,7 @@ public final class CheckoutConstants {
* @return true if a route exists, false otherwise
*/
public static boolean hasCheckoutRoute(final int score) {
return CHECKOUT_MAP.containsKey(score);
return OPTIMAL_CHECKOUTS.containsKey(score);
}
/**
@@ -93,6 +204,19 @@ public final class CheckoutConstants {
* @return Set of scores with pre-calculated routes
*/
public static java.util.Set<Integer> getAvailableCheckouts() {
return CHECKOUT_MAP.keySet();
return OPTIMAL_CHECKOUTS.keySet();
}
/**
* Gets the total number of unique checkout combinations generated.
*
* @return Total number of routes across all scores
*/
public static int getTotalRoutesCount() {
int total = 0;
for (List<String[]> routes : ALL_CHECKOUTS.values()) {
total += routes.size();
}
return total;
}
}

View File

@@ -0,0 +1,301 @@
package com.aldo.apps.ochecompanion.utils;
import java.util.HashMap;
import java.util.Map;
/**
* Checkout calculation engine for X01 darts games.
* Provides optimal double-out routes for any score with 1-3 darts remaining.
* Tracks if players are following suggested paths and identifies double-out attempts.
*/
public final class CheckoutEngine {
// Prevent instantiation
private CheckoutEngine() {
throw new UnsupportedOperationException("Utility class cannot be instantiated");
}
/** All possible double-out finishes: D1 through D20, plus Bull (50) */
private static final int[] DOUBLE_OUT_VALUES = {
2, 4, 6, 8, 10, 12, 14, 16, 18, 20, // D1-D10
22, 24, 26, 28, 30, 32, 34, 36, 38, 40, // D11-D20
50 // Double Bull
};
/** Map of double values to their display labels (e.g., 40 -> "D20") */
private static final Map<Integer, String> DOUBLE_LABELS = new HashMap<>();
static {
// Initialize double labels
for (int i = 1; i <= 20; i++) {
DOUBLE_LABELS.put(i * 2, DartsConstants.PREFIX_DOUBLE + i);
}
DOUBLE_LABELS.put(50, DartsConstants.LABEL_BULLSEYE);
}
/**
* Calculates optimal checkout route for given score and darts remaining.
*
* @param score Target score to finish
* @param dartsLeft Number of darts remaining (1-3)
* @return Checkout route string or null if no route available
*/
public static String calculateCheckout(final int score, final int dartsLeft) {
if (dartsLeft <= 0 || score <= 0 || score == 1) return null;
switch (dartsLeft) {
case 1:
return calculateOneDartDoubleOut(score);
case 2:
return calculateTwoDartDoubleOut(score);
case 3:
return calculateThreeDartDoubleOut(score);
default:
return null;
}
}
/**
* Calculates checkout for exactly one dart remaining.
* Only suggests if score is a valid double (2-40 even, or 50).
*
* @param score Target score
* @return Double-out dart label or null
*/
public static String calculateOneDartDoubleOut(final int score) {
if (isValidDoubleOut(score)) {
return DOUBLE_LABELS.get(score);
}
return null;
}
/**
* Calculates checkout for exactly two darts remaining.
* Tries: direct double, then setup dart + double.
*
* @param score Target score
* @return Checkout route or null
*/
public static String calculateTwoDartDoubleOut(final int score) {
// Try direct double out
if (isValidDoubleOut(score)) {
return DOUBLE_LABELS.get(score);
}
// Check pre-calculated routes from CheckoutConstants
if (CheckoutConstants.hasCheckoutRoute(score)) {
final String[] parts = CheckoutConstants.getCheckoutRoute(score);
if (parts != null && parts.length <= 2) {
return String.join(DartsConstants.CHECKOUT_SEPARATOR, parts);
}
}
// Try setup dart logic for odd scores
if (score % 2 != 0 && score <= 41) {
return calculateSetupDart(score);
}
// Try to leave a valid double
for (int doubleValue : DOUBLE_OUT_VALUES) {
final int setup = score - doubleValue;
if (setup > 0 && setup <= 60) { // Single segment max is 20, triple is 60
return setup + DartsConstants.CHECKOUT_SEPARATOR + DOUBLE_LABELS.get(doubleValue);
}
}
return null;
}
/**
* Calculates checkout for exactly three darts remaining.
* Tries: direct double, two-dart checkout, then three-dart combinations.
*
* @param score Target score
* @return Checkout route or null
*/
public static String calculateThreeDartDoubleOut(final int score) {
// Try simpler options first
final String oneDart = calculateOneDartDoubleOut(score);
if (oneDart != null) return oneDart;
final String twoDart = calculateTwoDartDoubleOut(score);
if (twoDart != null) return twoDart;
// Check pre-calculated three-dart routes
if (CheckoutConstants.hasCheckoutRoute(score)) {
final String[] parts = CheckoutConstants.getCheckoutRoute(score);
if (parts != null && parts.length <= 3) {
return String.join(DartsConstants.CHECKOUT_SEPARATOR, parts);
}
}
// Try to build a three-dart route
// Strategy: High score + setup + double
if (score > 50 && score <= 170) {
// Try T20 (60) + remainder
int remainder = score - 60;
if (remainder > 0) {
final String twoRemaining = calculateTwoDartDoubleOut(remainder);
if (twoRemaining != null) {
return "T20" + DartsConstants.CHECKOUT_SEPARATOR + twoRemaining;
}
}
// Try T19 (57) + remainder
remainder = score - 57;
if (remainder > 0) {
final String twoRemaining = calculateTwoDartDoubleOut(remainder);
if (twoRemaining != null) {
return "T19" + DartsConstants.CHECKOUT_SEPARATOR + twoRemaining;
}
}
}
return null;
}
/**
* Calculates optimal setup dart to leave a common double.
* Prefers leaving D20 (40) or D16 (32).
*
* @param score Odd score requiring setup
* @return Setup route (e.g., "1 • D20")
*/
private static String calculateSetupDart(final int score) {
// Try to leave D20 (40)
if (score > 40 && score - 40 <= 20) {
return (score - 40) + DartsConstants.CHECKOUT_SEPARATOR + DartsConstants.PREFIX_DOUBLE + "20";
}
// Try to leave D16 (32)
if (score > 32 && score - 32 <= 20) {
return (score - 32) + DartsConstants.CHECKOUT_SEPARATOR + DartsConstants.PREFIX_DOUBLE + "16";
}
// General solution: throw 1 to leave an even score
if (score > 1) {
final int remainder = score - 1;
if (isValidDoubleOut(remainder)) {
return "1" + DartsConstants.CHECKOUT_SEPARATOR + DOUBLE_LABELS.get(remainder);
}
}
return null;
}
/**
* Checks if a score is a valid double-out finish.
*
* @param score Score to check
* @return true if score can be finished with a double
*/
public static boolean isValidDoubleOut(final int score) {
return DOUBLE_LABELS.containsKey(score);
}
/**
* Checks if a dart value represents a double-out attempt.
* Used for tracking double attempts in statistics.
*
* @param dartValue Point value of the dart thrown
* @return true if dart was a double (2-40 even, or 50)
*/
public static boolean isDoubleOutDart(final int dartValue) {
return isValidDoubleOut(dartValue);
}
/**
* Extracts the first dart from a checkout route string.
*
* @param checkoutRoute Full route (e.g., "T20 • T20 • BULL")
* @return First dart string (e.g., "T20") or null
*/
public static String getFirstDartFromRoute(final String checkoutRoute) {
if (checkoutRoute == null || checkoutRoute.isEmpty()) return null;
final String[] parts = checkoutRoute.split(DartsConstants.CHECKOUT_SEPARATOR);
return parts.length > 0 ? parts[0].trim() : null;
}
/**
* Checks if a thrown dart matches the suggested first dart in a route.
* Used to determine if player is following the recommended path.
*
* @param checkoutRoute Suggested route (e.g., "T20 • D20")
* @param dartThrown Point value thrown (e.g., 60)
* @return true if dart follows the route
*/
public static boolean isFollowingSuggestedRoute(final String checkoutRoute, final int dartThrown) {
final String firstDart = getFirstDartFromRoute(checkoutRoute);
if (firstDart == null) return false;
// Parse the expected dart value
final int expectedValue = parseDartValue(firstDart);
return expectedValue == dartThrown;
}
/**
* Parses a dart label into its point value.
*
* @param dartLabel Label like "T20", "D16", "BULL", or "25"
* @return Point value (e.g., 60, 32, 50, 25)
*/
private static int parseDartValue(final String dartLabel) {
if (dartLabel == null || dartLabel.isEmpty()) return 0;
final String label = dartLabel.trim();
// Handle bullseye
if (label.equals(DartsConstants.LABEL_BULLSEYE) || label.equals(DartsConstants.LABEL_DOUBLE_BULL)) {
return DartsConstants.DOUBLE_BULL_VALUE;
}
if (label.equals(DartsConstants.LABEL_BULL)) {
return DartsConstants.BULL_VALUE;
}
// Handle prefixed darts (D16, T20, etc.)
if (label.startsWith(DartsConstants.PREFIX_TRIPLE)) {
try {
final int base = Integer.parseInt(label.substring(1));
return base * DartsConstants.MULTIPLIER_TRIPLE;
} catch (NumberFormatException e) {
return 0;
}
}
if (label.startsWith(DartsConstants.PREFIX_DOUBLE)) {
try {
final int base = Integer.parseInt(label.substring(1));
return base * DartsConstants.MULTIPLIER_DOUBLE;
} catch (NumberFormatException e) {
return 0;
}
}
// Plain number
try {
return Integer.parseInt(label);
} catch (NumberFormatException e) {
return 0;
}
}
/**
* Gets all possible double-out values.
*
* @return Array of valid double finish scores
*/
public static int[] getAllDoubleOutValues() {
return DOUBLE_OUT_VALUES.clone();
}
/**
* Checks if a remaining score can be finished with the given number of darts.
*
* @param score Remaining score
* @param dartsLeft Darts available
* @return true if checkout is possible
*/
public static boolean hasCheckoutRoute(final int score, final int dartsLeft) {
return calculateCheckout(score, dartsLeft) != null;
}
}

View File

@@ -0,0 +1,31 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<!-- Sun (Top-Left Position) -->
<path
android:fillColor="@android:color/white"
android:pathData="M6.5,6.5m-2.5,0a2.5,2.5 0,1 1,5 0a2.5,2.5 0,1 1,-5 0" />
<!-- Sun Rays (Using strokes to ensure visibility) -->
<path
android:strokeColor="@android:color/white"
android:strokeWidth="1.2"
android:strokeLineCap="round"
android:pathData="M6.5,1.5v1.5 M6.5,11.5v-1.5 M1.5,6.5h1.5 M11.5,6.5h-1.5 M3,3l1,1 M10,10l-1,-1 M3,10l1,-1 M10,3l-1,1" />
<!-- Diagonal Separator (Top-Right to Bottom-Left) -->
<path
android:strokeColor="@android:color/white"
android:strokeWidth="1.2"
android:strokeLineCap="round"
android:pathData="M18.5,5.5 L5.5,18.5" />
<!-- Moon Crescent (Bottom-Right Position) -->
<path
android:fillColor="@android:color/white"
android:pathData="M17.5,12.5c-2.48,0 -4.5,2.02 -4.5,4.5s2.02,4.5 4.5,4.5s4.5,-2.02 4.5,-4.5c0,-0.23 -0.02,-0.46 -0.05,-0.68c-0.49,0.68 -1.29,1.13 -2.2,1.13c-1.49,0 -2.7,-1.21 -2.7,-2.7c0,-0.91 0.45,-1.71 1.13,-2.2c-0.22,-0.03 -0.45,-0.05 -0.68,-0.05z" />
</vector>

View File

@@ -0,0 +1,21 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<!-- Stylized Dartboard Section for Game Modes -->
<path
android:fillColor="@android:color/white"
android:pathData="M12,2C6.48,2 2,6.48 2,12s4.48,10 10,10s10,-4.48 10,-10S17.52,2 12,2zM12,20c-4.41,0 -8,-3.59 -8,-8s3.59,-8 8,-8s8,3.59 8,8S16.41,20 12,20z" />
<path
android:fillColor="@android:color/white"
android:pathData="M12,4.5c-4.14,0 -7.5,3.36 -7.5,7.5s3.36,7.5 7.5,7.5s7.5,-3.36 7.5,-7.5S16.14,4.5 12,4.5zM12,17c-2.76,0 -5,-2.24 -5,-5s2.24,-5 5,-5s5,2.24 5,5S14.76,17 12,17z" />
<!-- Center Point (Bullseye) -->
<path
android:fillColor="@android:color/white"
android:pathData="M12,10.5c-0.83,0 -1.5,0.67 -1.5,1.5s0.67,1.5 1.5,1.5s1.5,-0.67 1.5,-1.5S12.83,10.5 12,10.5z" />
<!-- Radial Lines (Segments) -->
<path
android:fillColor="@android:color/white"
android:pathData="M12,4V2M12,22v-2M4,12H2m20,0h-2M6.34,6.34L4.93,4.93m14.14,14.14l-1.41,-1.41M6.34,17.66l-1.41,1.41M19.07,4.93l-1.41,1.41" />
</vector>

View File

@@ -3,7 +3,8 @@
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/midnight_black">
android:background="@color/surface_primary"
android:id="@+id/main">
<ImageView
android:id="@+id/btnDeletePlayer"

View File

@@ -4,7 +4,9 @@
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/background_primary">
tools:context=".GameActivity"
android:background="@color/background_primary"
android:id="@+id/main">
<!-- 1. HIGH-IMPACT SCOREBOARD (TOP) -->
<LinearLayout

View File

@@ -0,0 +1,10 @@
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:id="@+id/main">
<FrameLayout
android:id="@+id/settings"
android:layout_width="match_parent"
android:layout_height="match_parent" />
</LinearLayout>

View File

@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Labels of the standard game mode preference -->
<string-array name="pref_standard_game_mode_labels">
<item>701</item>
<item>501</item>
<item>301</item>
<item>Cricket</item>
</string-array>
<!-- Values of the standard game mode preference -->
<string-array name="pref_standard_game_mode_values">
<item>@string/pref_game_mode_701_value</item>
<item>@string/pref_game_mode_501_value</item>
<item>@string/pref_game_mode_301_value</item>
<item>@string/pref_game_mode_cricket_value</item>
</string-array>
</resources>

View File

@@ -29,8 +29,36 @@
<string name="txt_game_btn_bull">Bull</string>
<string name="txt_game_btn_submit">Submit Turn</string>
<!-- Preference Strings -->
<string name="pref_key_day_night_mode">day_night_mode</string>
<string name="pref_key_standard_game_mode">standard_game_mode</string>
<string name="pref_game_mode_701_value">701</string>
<string name="pref_game_mode_501_value">501</string>
<string name="pref_game_mode_301_value">301</string>
<string name="pref_game_mode_cricket_value">Cricket</string>
<string name="pref_desc_day_night_mode">Day/Night Mode</string>
<string name="pref_title_standard_game_mode">Standard Game Mode</string>
<string name="pref_desc_standard_game_mode">The Standard Game Mode to be selected for the Quick Start\nCurrently selected: %s</string>
<!-- Image Content description -->
<string name="cd_txt_oche_logo">Application Logo</string>
<string name="cd_txt_settings_button">Settings</string>
<string name="cd_text_historic_record">Match History</string>
<string name="title_activity_settings">SettingsActivity</string>
<!-- Preference Titles -->
<string name="messages_header">Messages</string>
<string name="sync_header">Sync</string>
<!-- Messages Preferences -->
<string name="signature_title">Your signature</string>
<string name="reply_title">Default reply action</string>
<!-- Sync Preferences -->
<string name="sync_title">Sync email periodically</string>
<string name="attachment_title">Download incoming attachments</string>
<string name="attachment_summary_on">Automatically download attachments for incoming emails
</string>
<string name="attachment_summary_off">Only download attachments when manually requested</string>
</resources>

View File

@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?>
<PreferenceScreen xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<SwitchPreference
app:key="@string/pref_key_day_night_mode"
app:title="@string/pref_desc_day_night_mode"
android:icon="@drawable/ic_day_night_mode"/>
<ListPreference
app:key="@string/pref_key_standard_game_mode"
app:title="@string/pref_title_standard_game_mode"
app:summary="@string/pref_desc_standard_game_mode"
android:icon="@drawable/ic_standard_game_mode"
android:defaultValue="@string/pref_game_mode_501_value"
android:entries="@array/pref_standard_game_mode_labels"
android:entryValues="@array/pref_standard_game_mode_values" />
</PreferenceScreen>