diff --git a/TestCheckoutGeneration.java b/TestCheckoutGeneration.java new file mode 100644 index 0000000..fe5750e --- /dev/null +++ b/TestCheckoutGeneration.java @@ -0,0 +1,38 @@ +import com.aldo.apps.ochecompanion.utils.CheckoutConstants; +import java.util.List; + +/** + * Quick test to verify checkout generation statistics. + */ +public class TestCheckoutGeneration { + public static void main(String[] args) { + System.out.println("=== Checkout Generation Statistics ==="); + System.out.println("Total unique scores with checkouts: " + CheckoutConstants.getAvailableCheckouts().size()); + System.out.println("Total checkout combinations: " + CheckoutConstants.getTotalRoutesCount()); + + System.out.println("\n=== Sample Checkouts ==="); + + // Test some common scores + int[] testScores = {2, 40, 50, 100, 120, 141, 170}; + for (int score : testScores) { + String[] optimal = CheckoutConstants.getCheckoutRoute(score); + List all = CheckoutConstants.getAllCheckoutRoutes(score); + + if (optimal != null) { + System.out.println("\nScore " + score + ":"); + System.out.println(" Optimal: " + String.join(", ", optimal)); + System.out.println(" Total routes: " + (all != null ? all.size() : 0)); + + // Show first 3 alternatives if available + if (all != null && all.size() > 1) { + System.out.println(" Alternatives:"); + for (int i = 0; i < Math.min(3, all.size()); i++) { + if (!java.util.Arrays.equals(all.get(i), optimal)) { + System.out.println(" - " + String.join(", ", all.get(i))); + } + } + } + } + } + } +} diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 03e7243..ec791f8 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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) } \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 14ef756..192a615 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -16,6 +16,10 @@ android:roundIcon="@mipmap/ic_launcher_round" android:supportsRtl="true" android:theme="@style/Theme.OcheCompanion"> + diff --git a/app/src/main/java/com/aldo/apps/ochecompanion/AddPlayerActivity.java b/app/src/main/java/com/aldo/apps/ochecompanion/AddPlayerActivity.java index 1b4cd3f..cb62c97 100644 --- a/app/src/main/java/com/aldo/apps/ochecompanion/AddPlayerActivity.java +++ b/app/src/main/java/com/aldo/apps/ochecompanion/AddPlayerActivity.java @@ -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 diff --git a/app/src/main/java/com/aldo/apps/ochecompanion/GameActivity.java b/app/src/main/java/com/aldo/apps/ochecompanion/GameActivity.java index 3edef79..61c6c1c 100644 --- a/app/src/main/java/com/aldo/apps/ochecompanion/GameActivity.java +++ b/app/src/main/java/com/aldo/apps/ochecompanion/GameActivity.java @@ -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 */ @@ -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 participants = getIntent().getParcelableArrayListExtra(EXTRA_PLAYERS); - // Initialize activity components in order initViews(); setupKeyboard(); - setupGame(participants); + new Thread(() -> { + final List 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 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. - *

- * 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. - *

- *

- * Checkout Logic Priority: - *

    - *
  1. Direct Doubles: For scores ≤40 and even, suggest immediate double
  2. - *
  3. Bullseye: For score of 50, suggest BULL (double bull)
  4. - *
  5. Setup Darts: For odd scores with multiple darts, suggest setup + double
  6. - *
  7. Pre-calculated Routes: For common finishes like 170, 141
  8. - *
  9. Fallback: Generic high-score route (T20 Route)
  10. - *
- *

- *

- * Key Rule: Never suggests a route that would leave a score of 1, as - * this is impossible to finish (no double equals 1). - *

- *

- * Example Suggestions: - *

-     * 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)
-     * 
- *

- *

- * Design Pattern: - * 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. - *

- * - * @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; - } - } } \ No newline at end of file diff --git a/app/src/main/java/com/aldo/apps/ochecompanion/MainMenuActivity.java b/app/src/main/java/com/aldo/apps/ochecompanion/MainMenuActivity.java index c13a14e..f9421b6 100644 --- a/app/src/main/java/com/aldo/apps/ochecompanion/MainMenuActivity.java +++ b/app/src/main/java/com/aldo/apps/ochecompanion/MainMenuActivity.java @@ -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); } diff --git a/app/src/main/java/com/aldo/apps/ochecompanion/SettingsActivity.java b/app/src/main/java/com/aldo/apps/ochecompanion/SettingsActivity.java new file mode 100644 index 0000000..a9eb815 --- /dev/null +++ b/app/src/main/java/com/aldo/apps/ochecompanion/SettingsActivity.java @@ -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); + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/aldo/apps/ochecompanion/database/AppDatabase.java b/app/src/main/java/com/aldo/apps/ochecompanion/database/AppDatabase.java index 748c968..158c128 100644 --- a/app/src/main/java/com/aldo/apps/ochecompanion/database/AppDatabase.java +++ b/app/src/main/java/com/aldo/apps/ochecompanion/database/AppDatabase.java @@ -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. diff --git a/app/src/main/java/com/aldo/apps/ochecompanion/database/dao/MatchDao.java b/app/src/main/java/com/aldo/apps/ochecompanion/database/dao/MatchDao.java index 64c7de4..fcce018 100644 --- a/app/src/main/java/com/aldo/apps/ochecompanion/database/dao/MatchDao.java +++ b/app/src/main/java/com/aldo/apps/ochecompanion/database/dao/MatchDao.java @@ -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 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 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 getMatchesByState(final String state); } diff --git a/app/src/main/java/com/aldo/apps/ochecompanion/database/dao/PlayerDao.java b/app/src/main/java/com/aldo/apps/ochecompanion/database/dao/PlayerDao.java index 1e89e29..e0827ca 100644 --- a/app/src/main/java/com/aldo/apps/ochecompanion/database/dao/PlayerDao.java +++ b/app/src/main/java/com/aldo/apps/ochecompanion/database/dao/PlayerDao.java @@ -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. diff --git a/app/src/main/java/com/aldo/apps/ochecompanion/database/dao/StatisticsDao.java b/app/src/main/java/com/aldo/apps/ochecompanion/database/dao/StatisticsDao.java new file mode 100644 index 0000000..7bb7154 --- /dev/null +++ b/app/src/main/java/com/aldo/apps/ochecompanion/database/dao/StatisticsDao.java @@ -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); +} diff --git a/app/src/main/java/com/aldo/apps/ochecompanion/database/objects/Match.java b/app/src/main/java/com/aldo/apps/ochecompanion/database/objects/Match.java index 5030ea4..6b5217f 100644 --- a/app/src/main/java/com/aldo/apps/ochecompanion/database/objects/Match.java +++ b/app/src/main/java/com/aldo/apps/ochecompanion/database/objects/Match.java @@ -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; + } } diff --git a/app/src/main/java/com/aldo/apps/ochecompanion/database/objects/Player.java b/app/src/main/java/com/aldo/apps/ochecompanion/database/objects/Player.java index 7fbcbdd..75adbe4 100644 --- a/app/src/main/java/com/aldo/apps/ochecompanion/database/objects/Player.java +++ b/app/src/main/java/com/aldo/apps/ochecompanion/database/objects/Player.java @@ -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); diff --git a/app/src/main/java/com/aldo/apps/ochecompanion/database/objects/Statistics.java b/app/src/main/java/com/aldo/apps/ochecompanion/database/objects/Statistics.java new file mode 100644 index 0000000..f270549 --- /dev/null +++ b/app/src/main/java/com/aldo/apps/ochecompanion/database/objects/Statistics.java @@ -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 + + '}'; + } +} diff --git a/app/src/main/java/com/aldo/apps/ochecompanion/ui/MainMenuPreferencesFragment.java b/app/src/main/java/com/aldo/apps/ochecompanion/ui/MainMenuPreferencesFragment.java new file mode 100644 index 0000000..ef00d29 --- /dev/null +++ b/app/src/main/java/com/aldo/apps/ochecompanion/ui/MainMenuPreferencesFragment.java @@ -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); + } +} diff --git a/app/src/main/java/com/aldo/apps/ochecompanion/utils/CheckoutConstants.java b/app/src/main/java/com/aldo/apps/ochecompanion/utils/CheckoutConstants.java index 46e2754..7248691 100644 --- a/app/src/main/java/com/aldo/apps/ochecompanion/utils/CheckoutConstants.java +++ b/app/src/main/java/com/aldo/apps/ochecompanion/utils/CheckoutConstants.java @@ -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 CHECKOUT_MAP = new HashMap<>(); + private static final Map> 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 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> entry : ALL_CHECKOUTS.entrySet()) { + final int score = entry.getKey(); + final List 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 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 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 routes : ALL_CHECKOUTS.values()) { + total += routes.size(); + } + return total; } } diff --git a/app/src/main/java/com/aldo/apps/ochecompanion/utils/CheckoutEngine.java b/app/src/main/java/com/aldo/apps/ochecompanion/utils/CheckoutEngine.java new file mode 100644 index 0000000..9109508 --- /dev/null +++ b/app/src/main/java/com/aldo/apps/ochecompanion/utils/CheckoutEngine.java @@ -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 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; + } +} diff --git a/app/src/main/res/drawable/ic_day_night_mode.xml b/app/src/main/res/drawable/ic_day_night_mode.xml new file mode 100644 index 0000000..d142a93 --- /dev/null +++ b/app/src/main/res/drawable/ic_day_night_mode.xml @@ -0,0 +1,31 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_standard_game_mode.xml b/app/src/main/res/drawable/ic_standard_game_mode.xml new file mode 100644 index 0000000..38ea93d --- /dev/null +++ b/app/src/main/res/drawable/ic_standard_game_mode.xml @@ -0,0 +1,21 @@ + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/activity_add_player.xml b/app/src/main/res/layout/activity_add_player.xml index 645c2ed..1288d7b 100644 --- a/app/src/main/res/layout/activity_add_player.xml +++ b/app/src/main/res/layout/activity_add_player.xml @@ -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"> + tools:context=".GameActivity" + android:background="@color/background_primary" + android:id="@+id/main"> + + + \ No newline at end of file diff --git a/app/src/main/res/values/arrays.xml b/app/src/main/res/values/arrays.xml new file mode 100644 index 0000000..b4e7989 --- /dev/null +++ b/app/src/main/res/values/arrays.xml @@ -0,0 +1,19 @@ + + + + + + 701 + 501 + 301 + Cricket + + + + + @string/pref_game_mode_701_value + @string/pref_game_mode_501_value + @string/pref_game_mode_301_value + @string/pref_game_mode_cricket_value + + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index db79c0b..520c6d8 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -29,8 +29,36 @@ Bull Submit Turn + + day_night_mode + standard_game_mode + 701 + 501 + 301 + Cricket + Day/Night Mode + Standard Game Mode + The Standard Game Mode to be selected for the Quick Start\nCurrently selected: %s + + Application Logo Settings Match History + SettingsActivity + + + Messages + Sync + + + Your signature + Default reply action + + + Sync email periodically + Download incoming attachments + Automatically download attachments for incoming emails + + Only download attachments when manually requested \ No newline at end of file diff --git a/app/src/main/res/xml/main_menu_preferences.xml b/app/src/main/res/xml/main_menu_preferences.xml new file mode 100644 index 0000000..07d769d --- /dev/null +++ b/app/src/main/res/xml/main_menu_preferences.xml @@ -0,0 +1,19 @@ + + + + + + + + \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 4e76b86..e9f7907 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -9,6 +9,8 @@ activity = "1.12.2" constraintlayout = "2.2.1" glide = "5.0.5" room = "2.8.4" +preferences = "1.2.0" +preference = "1.2.1" [libraries] junit = { group = "junit", name = "junit", version.ref = "junit" } @@ -21,6 +23,8 @@ constraintlayout = { group = "androidx.constraintlayout", name = "constraintlayo glide = { group = "com.github.bumptech.glide", name = "glide", version.ref = "glide"} room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room"} room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room"} +preferences = { group = "androidx.preference", name="preference-ktx", version.ref="preferences" } +preference = { group = "androidx.preference", name = "preference", version.ref = "preference" } [plugins] android-application = { id = "com.android.application", version.ref = "agp" }