commit a4a42fc73f6362762ad367ae7535a3d70b2f1bd5
Author: Alexander Doerflinger
+ * This activity provides a comprehensive user interface for managing player information including:
+ *
+ *
+ *
+ * The activity features two distinct UI modes: + *
+ * Image Processing Pipeline: + *
+ * Usage: + * To edit an existing player, pass the player ID via intent extra using {@link #EXTRA_PLAYER_ID}. + * If no extra is provided, the activity operates in "create new player" mode. + *
+ * + * @see AppCompatActivity + * @see Player + * @see CropOverlayView + * @see AppDatabase + * @author Oche Companion Development Team + * @version 1.0 + * @since 1.0 + */ +public class AddPlayerActivity extends AppCompatActivity { + + /** + * Tag for logging and debugging purposes. + * Used to identify log messages originating from this activity. + */ + private static final String TAG = "Oche_AddPlayer"; + + /** + * Intent extra key for passing an existing player's ID for editing. + *+ * When this extra is present, the activity loads the player's existing data + * and operates in "edit mode". Without this extra, the activity creates a new player. + *
+ *+ * Usage example: + *
+ * Intent intent = new Intent(context, AddPlayerActivity.class); + * intent.putExtra(AddPlayerActivity.EXTRA_PLAYER_ID, playerId); + * startActivity(intent); + *+ * + */ + public static final String EXTRA_PLAYER_ID = "extra_player_id"; + + // ========== UI - Main Form Views ========== + + /** + * Container layout for the main player profile form. + * Visible during Form Mode when the user is entering player details. + */ + private View mLayoutForm; + + /** + * Container layout for the image cropping interface. + * Visible during Crop Mode when the user is adjusting their selected image. + */ + private View mLayoutCropper; + + /** + * ImageView displaying the player's profile picture in the main form. + * Clicking this view triggers the image selection process. + */ + private ShapeableImageView mProfilePictureView; + + /** + * EditText field for entering or editing the player's username. + */ + private EditText mUserNameInput; + + /** + * TextView displaying the activity title ("Add Player" or "Update Profile"). + * The title changes based on whether creating a new player or editing an existing one. + */ + private TextView mTitleView; + + /** + * Button to save the player profile (insert new or update existing). + * The button label changes based on the current mode ("Save" or "Update"). + */ + private MaterialButton mSaveButton; + + // ========== UI - Cropper Views ========== + + /** + * ImageView displaying the full selected image during Crop Mode. + * Supports pan and pinch-to-zoom gestures for precise positioning. + */ + private ImageView mIvCropPreview; + + /** + * Custom overlay view that renders the square crop area boundary. + * Shows the user exactly what portion of the image will be extracted. + */ + private CropOverlayView mCropOverlay; + + // ========== Data State ========== + + /** + * Absolute file path to the saved profile picture in internal storage. + * Set after the user confirms their cropped image. This path is persisted + * in the database with the player record. + */ + private String mInternalImagePath; + + /** + * URI of the original, unmodified image selected from the gallery. + * Used as the source for cropping operations. + */ + private Uri mRawSelectedUri; + + /** + * Database ID of the player being edited. + * Defaults to -1, indicating "create new player" mode. When >= 0, + * the activity loads and updates an existing player. + */ + private int mExistingPlayerId = -1; + + /** + * Player object loaded from the database when editing an existing player. + * Null when creating a new player. Used to update existing records. + */ + private Player mExistingPlayer; + + // ========== Gesture State ========== + + /** + * Last recorded X coordinate during pan gesture (drag). + * Used to calculate the delta movement between touch events. + */ + private float mLastTouchX; + + /** + * Last recorded Y coordinate during pan gesture (drag). + * Used to calculate the delta movement between touch events. + */ + private float mLastTouchY; + + /** + * Detector for handling pinch-to-zoom gestures on the crop preview image. + * Monitors multi-touch events to calculate scale changes. + */ + private ScaleGestureDetector mScaleDetector; + + /** + * Current scale factor applied to the crop preview image. + *
+ * Starts at 1.0 (no zoom) and is modified by pinch gestures. + * Clamped between 0.1 (minimum zoom) and 10.0 (maximum zoom) to prevent + * the image from becoming unusably small or excessively large. + *
+ */ + private float mScaleFactor = 1.0f; + + /** + * ActivityResultLauncher for selecting images from the device gallery. + *+ * Registered using the {@link ActivityResultContracts.GetContent} contract, + * which provides a standard way to pick content of a specific MIME type. + * Upon successful selection, automatically transitions to Crop Mode. + *
+ *+ * The launcher is triggered when the user taps the profile picture placeholder. + *
+ * + * @see ActivityResultContracts.GetContent + */ + private final ActivityResultLauncher+ * Performs the following initialization tasks: + *
+ * If {@link #EXTRA_PLAYER_ID} is present in the intent, the activity operates in + * "edit mode" and loads the existing player's data. Otherwise, it operates in + * "create new player" mode. + *
+ * + * @param savedInstanceState If the activity is being re-initialized after previously being shut down, + * this Bundle contains the data it most recently supplied in + * {@link #onSaveInstanceState(Bundle)}. Otherwise, it is null. + * @see #initViews() + * @see #setupGestures() + * @see #loadExistingPlayer() + */ + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setContentView(R.layout.activity_add_player); + Log.d(TAG, "AddPlayerActivity Created"); + + // Initialize all UI components and their click listeners + initViews(); + + // Set up touch gesture handlers for image cropping + setupGestures(); + + // Check if editing an existing player + if (getIntent().hasExtra(EXTRA_PLAYER_ID)) { + mExistingPlayerId = getIntent().getIntExtra(EXTRA_PLAYER_ID, -1); + loadExistingPlayer(); + } + } + + /** + * Initializes all UI component references and sets up click listeners. + *+ * This method performs the following operations: + *
+ * The profile picture view is configured to launch the gallery picker when clicked, + * filtering for image MIME types only ("image/*"). + *
+ * + * @see #mGetContent + * @see #savePlayer() + * @see #performCrop() + * @see #exitCropMode() + */ + private void initViews() { + // Get references to layout containers + mLayoutForm = findViewById(R.id.layoutForm); + mLayoutCropper = findViewById(R.id.layoutCropper); + + // Get references to form UI elements + mProfilePictureView = findViewById(R.id.ivAddPlayerProfile); + mUserNameInput = findViewById(R.id.etUsername); + mTitleView = findViewById(R.id.tvTitle); + mSaveButton = findViewById(R.id.btnSavePlayer); + + // Get references to cropper UI elements + mIvCropPreview = findViewById(R.id.ivCropPreview); + mCropOverlay = findViewById(R.id.cropOverlay); + + // Set up click listeners + mProfilePictureView.setOnClickListener(v -> mGetContent.launch("image/*")); + mSaveButton.setOnClickListener(v -> savePlayer()); + findViewById(R.id.btnConfirmCrop).setOnClickListener(v -> performCrop()); + findViewById(R.id.btnCancelCrop).setOnClickListener(v -> exitCropMode()); + } + + /** + * Initializes gesture detectors to handle pinch-to-zoom and pan (drag) gestures. + *+ * This method configures two types of touch interactions for the crop preview image: + *
+ * Scale Gesture Handling: + * The scale detector monitors multi-touch events and calculates scale changes. + * The scale factor is clamped between 0.1× (minimum) and 10.0× (maximum) to prevent + * the image from becoming unusably small or excessively large. + *
+ *+ * Pan Gesture Handling: + * Pan gestures are only processed when a scale gesture is not in progress, preventing + * conflicts between the two gesture types. The translation is calculated based on the + * delta between consecutive touch positions. + *
+ * + * @see ScaleGestureDetector + * @see MotionEvent + */ + private void setupGestures() { + // Initialize scale detector for pinch-to-zoom functionality + mScaleDetector = new ScaleGestureDetector(this, new ScaleGestureDetector.SimpleOnScaleGestureListener() { + @Override + public boolean onScale(ScaleGestureDetector detector) { + // Apply the scale factor from the gesture + mScaleFactor *= detector.getScaleFactor(); + + // Prevent the image from becoming too small or impossibly large + // Clamp between 0.1× (10% size) and 10.0× (1000% size) + mScaleFactor = Math.max(0.1f, Math.min(mScaleFactor, 10.0f)); + + // Apply the scale to both X and Y axes for uniform scaling + mIvCropPreview.setScaleX(mScaleFactor); + mIvCropPreview.setScaleY(mScaleFactor); + return true; + } + }); + + // Combined touch listener for both Panning and Scaling + mIvCropPreview.setOnTouchListener((v, event) -> { + // Pass touch event to scale detector first to handle pinch gestures + mScaleDetector.onTouchEvent(event); + + // Handle Panning (drag) if not currently performing a pinch-to-zoom + if (!mScaleDetector.isInProgress()) { + switch (event.getAction()) { + case MotionEvent.ACTION_DOWN: + // Record initial touch position + mLastTouchX = event.getRawX(); + mLastTouchY = event.getRawY(); + break; + case MotionEvent.ACTION_MOVE: + // Calculate movement delta + float dx = event.getRawX() - mLastTouchX; + float dy = event.getRawY() - mLastTouchY; + + // Apply translation to the view + v.setTranslationX(v.getTranslationX() + dx); + v.setTranslationY(v.getTranslationY() + dy); + + // Update last touch position for next delta calculation + mLastTouchX = event.getRawX(); + mLastTouchY = event.getRawY(); + break; + } + } + return true; + }); + } + + /** + * Transitions the UI from Form Mode to Crop Mode. + *+ * This method performs the following operations: + *
+ * Resetting transformations ensures that each crop session starts with the image + * in a predictable state (1:1 scale, centered position), providing a consistent + * user experience. + *
+ * + * @param uri The URI of the raw, unmodified image selected from the gallery. + * This image will be displayed in the crop preview for manipulation. + * @see #exitCropMode() + */ + private void enterCropMode(Uri uri) { + // Hide form layout and show cropper layout + mLayoutForm.setVisibility(View.GONE); + mLayoutCropper.setVisibility(View.VISIBLE); + + // Reset transformation state for a fresh start + mScaleFactor = 1.0f; // Reset zoom to 100% + mIvCropPreview.setScaleX(1.0f); + mIvCropPreview.setScaleY(1.0f); + mIvCropPreview.setTranslationX(0); // Reset horizontal position + mIvCropPreview.setTranslationY(0); // Reset vertical position + + // Load the selected image into the preview + mIvCropPreview.setImageURI(uri); + } + + /** + * Transitions the UI from Crop Mode back to Form Mode. + *+ * This method is called when the user either: + *
+ * The method simply toggles visibility between the two layout containers, + * hiding the cropper and showing the main form. + *
+ * + * @see #enterCropMode(Uri) + * @see #performCrop() + */ + private void exitCropMode() { + // Hide cropper layout and show form layout + mLayoutCropper.setVisibility(View.GONE); + mLayoutForm.setVisibility(View.VISIBLE); + } + + /** + * Performs the pixel-level mathematics to extract a square crop from the selected image. + *+ * This is the core image processing method that handles the complex coordinate transformations + * required to crop the image accurately. The calculation accounts for multiple transformation layers: + *
+ * Algorithm Overview: + *
+ * Error Handling: + * If any step fails (bitmap decoding, file I/O, etc.), an error is logged and a + * toast message is displayed to the user. The method gracefully handles errors + * without crashing the application. + *
+ * + * @see #saveBitmap(Bitmap) + * @see CropOverlayView#getCropRect() + */ + private void performCrop() { + Log.d(TAG, "Finalizing crop..."); + try (InputStream is = getContentResolver().openInputStream(mRawSelectedUri)) { + // Decode the full bitmap from the selected image URI + Bitmap fullBmp = BitmapFactory.decodeStream(is); + if (fullBmp == null) { + Log.e(TAG, "Failed to decode bitmap from URI"); + return; + } + + // Get the dimensions of the ImageView and the bitmap + float viewW = mIvCropPreview.getWidth(); + float viewH = mIvCropPreview.getHeight(); + float bmpW = fullBmp.getWidth(); + float bmpH = fullBmp.getHeight(); + + // Total scale combines the initial fit-center and the manual pinch-zoom + // Fit-center scale: minimum scale needed to fit the entire image in the view + float fitScale = Math.min(viewW / bmpW, viewH / bmpH); + // Total scale: fit-center scale × user's zoom factor + float totalScale = fitScale * mScaleFactor; + + // Current position of the top-left corner of the bitmap in screen space + // Accounts for both the centering offset and user's pan translation + float currentBmpLeft = (viewW - (bmpW * totalScale)) / 2f + mIvCropPreview.getTranslationX(); + float currentBmpTop = (viewH - (bmpH * totalScale)) / 2f + mIvCropPreview.getTranslationY(); + + // Get the crop box rectangle from the overlay (in screen coordinates) + RectF cropBox = mCropOverlay.getCropRect(); + + // Map screen coordinates to actual bitmap pixel coordinates + // Formula: (screenCoord - bitmapScreenPosition) / totalScale = bitmapPixelCoord + int cX = (int) ((cropBox.left - currentBmpLeft) / totalScale); + int cY = (int) ((cropBox.top - currentBmpTop) / totalScale); + int cSize = (int) (cropBox.width() / totalScale); + + Log.d(TAG, String.format("Crop Pixels: X=%d, Y=%d, Size=%d | UserZoom=%.2f", cX, cY, cSize, mScaleFactor)); + + // Bounds checks to prevent Bitmap.createBitmap from crashing with invalid coordinates + cX = Math.max(0, cX); // Ensure X is not negative + cY = Math.max(0, cY); // Ensure Y is not negative + + // Clamp crop size to not exceed bitmap boundaries + if (cX + cSize > bmpW) cSize = (int) bmpW - cX; + if (cY + cSize > bmpH) cSize = (int) bmpH - cY; + + // Ensure size is at least 1px to avoid crashes + cSize = Math.max(1, cSize); + + // Extract the square crop from the full bitmap + Bitmap cropped = Bitmap.createBitmap(fullBmp, cX, cY, cSize, cSize); + + // Save the cropped bitmap to internal storage + mInternalImagePath = saveBitmap(cropped); + + // Update the profile picture preview if save was successful + if (mInternalImagePath != null) { + mProfilePictureView.setImageTintList(null); // Remove any tint + mProfilePictureView.setImageBitmap(cropped); + } + + // Return to Form Mode + exitCropMode(); + + // Clean up the full bitmap to free memory + fullBmp.recycle(); + + } catch (Exception e) { + Log.e(TAG, "Crop execution failed", e); + Toast.makeText(this, "Failed to crop image", Toast.LENGTH_SHORT).show(); + } + } + + /** + * Saves a bitmap to the application's private internal storage directory. + *+ * This method generates a unique filename using a UUID and saves the bitmap + * as a JPEG file with 90% quality. The file is stored in the app's private + * files directory, which is only accessible to this application. + *
+ *+ * File Storage Details: + *
+ * Error Handling: + * If any I/O error occurs during the save operation, the exception is logged + * and null is returned, allowing the caller to handle the failure gracefully. + *
+ * + * @param bmp The bitmap image to save. Must not be null. + * @return The absolute file path to the saved image file, or null if saving failed. + * @see UUID#randomUUID() + * @see Bitmap#compress(Bitmap.CompressFormat, int, java.io.OutputStream) + */ + private String saveBitmap(Bitmap bmp) { + try { + // Generate a unique filename using UUID to prevent collisions + String name = "profile_" + UUID.randomUUID().toString() + ".jpg"; + + // Create file reference in app's private directory + File file = new File(getFilesDir(), name); + + // Write bitmap to file as JPEG with 90% quality (good balance of quality/size) + try (FileOutputStream fos = new FileOutputStream(file)) { + bmp.compress(Bitmap.CompressFormat.JPEG, 90, fos); + } + + // Return the absolute path for database storage + return file.getAbsolutePath(); + } catch (Exception e) { + Log.e(TAG, "IO Error saving bitmap", e); + return null; + } + } + + /** + * Loads an existing player's data from the database and populates the UI. + *+ * This method is called during {@link #onCreate(Bundle)} when {@link #EXTRA_PLAYER_ID} + * is present in the intent, indicating that the activity should edit an existing player + * rather than create a new one. + *
+ *+ * Operations performed: + *
+ * Threading: + * Database operations are performed on a background thread to avoid blocking the UI. + * UI updates are posted back to the main thread using {@link #runOnUiThread(Runnable)}. + *
+ * + * @see AppDatabase#playerDao() + * @see Player + */ + private void loadExistingPlayer() { + new Thread(() -> { + // Query the database for the player (background thread) + mExistingPlayer = AppDatabase.getDatabase(this).playerDao().getPlayerById(mExistingPlayerId); + + // Update UI on the main thread + runOnUiThread(() -> { + if (mExistingPlayer != null) { + // Populate username field + mUserNameInput.setText(mExistingPlayer.username); + + // Update UI labels for "edit mode" + mTitleView.setText(R.string.txt_update_profile_header); + mSaveButton.setText(R.string.txt_update_profile_username_save); + + // Load existing profile picture if available + if (mExistingPlayer.profilePictureUri != null) { + mInternalImagePath = mExistingPlayer.profilePictureUri; + mProfilePictureView.setImageTintList(null); // Remove placeholder tint + mProfilePictureView.setImageURI(Uri.fromFile(new File(mInternalImagePath))); + } + } + }); + }).start(); + } + + /** + * Validates and persists the player data to the database. + *+ * This method determines whether to insert a new player or update an existing one + * based on whether {@link #mExistingPlayer} is null. + *
+ *+ * Validation: + * The username field must not be empty (after trimming whitespace). If validation fails, + * a toast message is shown and the method returns without saving. + *
+ *+ * Database Operations: + *
+ * Threading: + * Database operations are performed on a background thread to prevent blocking the UI. + * After the save operation completes, the activity finishes on the main thread. + *
+ * + * @see Player + * @see AppDatabase#playerDao() + */ + private void savePlayer() { + // Validate username input + String name = mUserNameInput.getText().toString().trim(); + if (name.isEmpty()) { + Toast.makeText(this, "Please enter a name", Toast.LENGTH_SHORT).show(); + return; + } + + // Perform database operation on background thread + new Thread(() -> { + if (mExistingPlayer != null) { + // Update existing player + mExistingPlayer.username = name; + mExistingPlayer.profilePictureUri = mInternalImagePath; + AppDatabase.getDatabase(this).playerDao().update(mExistingPlayer); + } else { + // Create and insert new player + Player p = new Player(name, mInternalImagePath); + AppDatabase.getDatabase(this).playerDao().insert(p); + } + + // Close activity on main thread after save completes + runOnUiThread(() -> finish()); + }).start(); + } +} \ No newline at end of file diff --git a/app/src/main/java/com/aldo/apps/ochecompanion/GameActivity.java b/app/src/main/java/com/aldo/apps/ochecompanion/GameActivity.java new file mode 100644 index 0000000..0b3da4a --- /dev/null +++ b/app/src/main/java/com/aldo/apps/ochecompanion/GameActivity.java @@ -0,0 +1,1705 @@ +package com.aldo.apps.ochecompanion; + +import android.content.Context; +import android.content.Intent; +import android.content.res.ColorStateList; +import android.graphics.Color; +import android.os.Bundle; +import android.view.View; +import android.view.animation.AlphaAnimation; +import android.view.animation.Animation; +import android.widget.GridLayout; +import android.widget.LinearLayout; +import android.widget.TextView; +import android.widget.Toast; +import androidx.appcompat.app.AppCompatActivity; +import androidx.core.content.ContextCompat; +import com.aldo.apps.ochecompanion.database.objects.Player; +import com.google.android.material.button.MaterialButton; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * The main game activity for playing X01 darts games (501, 301, etc.) in the Oche Companion app. + *+ * This activity provides a high-performance, professional-grade scoring interface optimized for + * rapid dart entry during live gameplay. It features a specialized numeric keyboard with dynamic + * visual feedback, real-time checkout route suggestions, and strict enforcement of standard darts + * rules including Double Out finish requirements and bust conditions. + *
+ *+ * Game Features: + *
+ * X01 Game Rules Enforced: + *
+ * UI Layout Structure: + *
+ * ┌─────────────────────────────────────┐ + * │ Player Name AVG: 85.3 │ ← Header + * │ │ + * │ REMAINING: 301 │ ← Score Display + * │ │ + * │ [Checkout: T20 • D20] │ ← Smart Route (conditional) + * │ │ + * │ [Dart1] [Dart2] [Dart3] │ ← Turn Indicators + * │ │ + * │ [ 1×] [ 2×] [ 3×] │ ← Multiplier Buttons + * │ │ + * │ ┌───────────────────────────┐ │ + * │ │ 1 2 3 4 5 │ │ ← Numeric Keyboard + * │ │ 6 7 8 9 10 │ │ + * │ │ 11 12 13 14 15 │ │ + * │ │ 16 17 18 19 20 │ │ + * │ └───────────────────────────┘ │ + * │ [Undo] [Submit Turn] │ ← Action Buttons + * └─────────────────────────────────────┘ + *+ * + *
+ * Game Flow: + *
+ * Usage Example: + *
+ * // Start a 501 game with two players + * ArrayList<Player> players = new ArrayList<>(); + * players.add(player1); + * players.add(player2); + * GameActivity.start(context, players, 501); + * + * // Or start a 301 game + * GameActivity.start(context, players, 301); + *+ * + *
+ * Bust Conditions: + * A turn results in a bust (score reverts, turn ends) when: + *
+ * Multiplier System: + *
+ * Checkout Engine: + * Provides optimal finishing routes when player is within checkout range (≤170): + *
+ * Performance Optimizations: + *
+ * Future Enhancements: + * Consider adding: + *
+ * Value type: {@code ArrayList
+ * Value type: {@code int} (typically 501, 301, or 701) + *
+ */ + private static final String EXTRA_START_SCORE = "extra_start_score"; + + // ======================================================================================== + // Game Logic State + // ======================================================================================== + + /** + * Index of the player whose turn is currently active. + *+ * Cycles through player indices (0 to playerCount-1) as turns are completed. + * After each turn submission, this increments modulo player count to rotate turns. + *
+ *+ * Example for 2 players: + *
+ * Valid values: + *
+ * Automatically resets to 1 after each dart is thrown for safety, preventing + * accidental double/triple throws. Players must explicitly select multiplier + * before each dart. + *
+ */ + private int mMultiplier = 1; + + /** + * The starting score for this X01 game (typically 501, 301, or 701). + *+ * This value is set once at game initialization from the intent extra and + * determines each player's initial {@link X01State#remainingScore}. Used for + * calculating three-dart averages throughout the game. + *
+ */ + private int mStartingScore = 501; + + /** + * List of player game states, one for each participant. + *+ * Each {@link X01State} tracks an individual player's current score, darts thrown, + * and name. The list order determines turn order, and {@link #mActivePlayerIndex} + * references the current player's state. + *
+ *+ * Initialized in {@link #setupGame(List)} with all players starting at + * {@link #mStartingScore}. + *
+ */ + private List+ * Each dart's final point value (base × multiplier) is added to this list as + * it's thrown. The list is cleared when the turn is submitted or when switching + * to the next player. + *
+ *+ * Example: + *
+ * Used for: + *
+ * When {@code true}: + *
+ * Set to {@code true} when: + *
+ * Reset to {@code false} when turn is submitted and next player's turn begins. + *
+ */ + private boolean mIsTurnOver = false; + + /** + * Cached references to keyboard buttons as MaterialButtons for safe dynamic styling. + *+ * All 20 keyboard buttons (1-20) are stored in this list for efficient access + * during multiplier changes. This allows rapid visual updates (color, stroke, background) + * without repeated findViewById calls. + *
+ *+ * Populated once during {@link #setupKeyboard()} and reused throughout the activity + * lifecycle. Each button's onClick listener is set to call {@link #onNumberTap(int)} + * with the corresponding number value. + *
+ */ + private final List+ * Shows the number the player needs to reach zero (e.g., "301", "147", "32"). + * Updates after each dart and when switching players. Large, prominently displayed + * as it's the most critical piece of information during play. + *
+ */ + private TextView tvScorePrimary; + + /** + * TextView displaying the active player's name. + *+ * Shows in uppercase (e.g., "JOHN DOE") to clearly identify whose turn it is. + * Updates when switching to the next player's turn. + *
+ */ + private TextView tvPlayerName; + + /** + * TextView displaying the active player's current leg average. + *+ * Shows three-dart average for the current leg (e.g., "AVG: 85.3"). + * Calculated as: ((startingScore - remainingScore) / dartsThrown) × 3 + * Updates after each dart to provide real-time performance feedback. + *
+ */ + private TextView tvLegAvg; + + /** + * TextView displaying the suggested checkout route. + *+ * Shows optimal finishing path when player is within checkout range (≤170). + * Examples: "D16", "T20 • D20", "T20 • T20 • BULL" + * Text is set by {@link CheckoutEngine#getRoute(int, int)}. + *
+ */ + private TextView tvCheckout; + + /** + * Container layout for the checkout suggestion display. + *+ * Visibility controlled based on whether a checkout route is available: + *
+ * Visual state changes when selected (full opacity, active background). + * Default multiplier - automatically selected after each dart throw. + *
+ */ + private View btnSingle; + + /** + * Button view for selecting double (2×) multiplier. + *+ * Visual state changes when selected (full opacity, red background). + * Used for doubles ring on dartboard and finishing darts (Double Out). + *
+ */ + private View btnDouble; + + /** + * Button view for selecting triple (3×) multiplier. + *+ * Visual state changes when selected (full opacity, blue background). + * Used for triples ring on dartboard - highest scoring option. + *
+ */ + private View btnTriple; + + /** + * Array of three TextViews showing the darts thrown in the current turn. + *+ * Each TextView displays a dart's score (e.g., "60", "DB", "25") and changes + * appearance based on whether it's been thrown: + *
+ * Holds 20 MaterialButton instances (1-20) arranged in a grid pattern for + * easy dart entry. Buttons are dynamically created in {@link #setupKeyboard()} + * and added to this layout. Separate from Bull button which is in layout XML. + *
+ */ + private GridLayout glKeyboard; + + /** + * Static helper method to start GameActivity with the required game parameters. + *+ * This convenience method creates and configures the intent with all necessary extras, + * then starts the activity. Preferred over manually creating intents to ensure correct + * parameter passing. + *
+ *+ * Usage Example: + *
+ * // Start a 501 game with two players + * ArrayList<Player> players = new ArrayList<>(); + * players.add(player1); + * players.add(player2); + * GameActivity.start(this, players, 501); + * + * // Start a 301 game + * GameActivity.start(this, selectedPlayers, 301); + * + * // Start a 701 game + * GameActivity.start(this, tournamentPlayers, 701); + *+ * + *
+ * Player Requirements: + * The players list should: + *
+ * Common Start Scores: + *
+ * Initializes the game by: + *
+ * If no starting score is provided in the intent, defaults to 501. + * If no players are provided, creates a single guest player. + *
+ * + * @param savedInstanceState Bundle containing saved state (not currently used for restoration) + */ + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setContentView(R.layout.activity_game); + + // Extract game parameters from intent + mStartingScore = getIntent().getIntExtra(EXTRA_START_SCORE, 501); + ArrayList+ * This method: + *
+ * Called once during {@link #onCreate(Bundle)} before any game logic runs. + * All UI components must exist in the layout or this will throw NullPointerException. + *
+ */ + private void initViews() { + tvScorePrimary = findViewById(R.id.tvScorePrimary); + tvPlayerName = findViewById(R.id.tvPlayerName); + tvLegAvg = findViewById(R.id.tvLegAvg); + tvCheckout = findViewById(R.id.tvCheckoutSuggestion); + layoutCheckoutSuggestion = findViewById(R.id.layoutCheckoutSuggestion); + + btnSingle = findViewById(R.id.btnMultiplierSingle); + btnDouble = findViewById(R.id.btnMultiplierDouble); + btnTriple = findViewById(R.id.btnMultiplierTriple); + + tvDartPills[0] = findViewById(R.id.tvDart1); + tvDartPills[1] = findViewById(R.id.tvDart2); + tvDartPills[2] = findViewById(R.id.tvDart3); + + glKeyboard = findViewById(R.id.glKeyboard); + + btnSingle.setOnClickListener(v -> setMultiplier(1)); + btnDouble.setOnClickListener(v -> setMultiplier(2)); + btnTriple.setOnClickListener(v -> setMultiplier(3)); + + findViewById(R.id.btnSubmitTurn).setOnClickListener(v -> submitTurn()); + findViewById(R.id.btnUndoDart).setOnClickListener(v -> undoLastDart()); + } + + /** + * Dynamically creates and configures the numeric keyboard buttons (1-20). + *+ * This method: + *
+ * Buttons are created dynamically rather than in XML to allow flexible styling + * and consistent appearance across all keyboard numbers. Uses view_keyboard_button + * layout resource as a template for each button. + *
+ *+ * Called once during {@link #onCreate(Bundle)} to build the keyboard interface. + * The cached button list is used later by {@link #setMultiplier(int)} to update + * button colors based on selected multiplier. + *
+ */ + private void setupKeyboard() { + glKeyboard.removeAllViews(); + mKeyboardButtons.clear(); + + // Create buttons for numbers 1-20 + for (int i = 1; i <= 20; i++) { + // Inflate button from template layout + MaterialButton btn = (MaterialButton) getLayoutInflater().inflate( + R.layout.view_keyboard_button, glKeyboard, false); + + final int val = i; + btn.setText(String.valueOf(val)); + btn.setOnClickListener(v -> onNumberTap(val)); + + glKeyboard.addView(btn); + mKeyboardButtons.add(btn); // Cache for styling updates + } + } + + /** + * Initializes game state with player data and displays initial UI. + *+ * This method: + *
+ * Player List Handling: + *
+ * Called once during {@link #onCreate(Bundle)} after UI initialization. + *
+ * + * @param players List of Player objects participating in the game. Can be null or empty. + * Each player should have a valid username set. + * @see X01State + * @see #updateUI() + * @see #setMultiplier(int) + */ + private void setupGame(List+ * This is the core game logic method that handles: + *
+ * Scoring Calculation: + *
+ * points = baseValue × multiplier + * Special case: Bull (25) with Triple (3×) = 50 (Double Bull) + *+ * + *
+ * Bust Conditions (turn ends, score reverts): + *
+ * Win Condition (game ends): + * Player reaches exactly zero with a double or bullseye (50): + *
+ * Valid Throw (continues turn): + *
+ * Double Detection: + * A dart is considered a double if: + *
+ * Multiplier Reset: + * After processing the dart, multiplier automatically resets to 1 (Single) + * for safety, preventing accidental double/triple throws. + *
+ *+ * Example Scenarios: + *
+ * // Scenario 1: Valid throw + * Player on 301, multiplier=3, taps 20 + * → points = 60, scoreAfter = 241, valid + * → Dart added, UI updated, continue turn + * + * // Scenario 2: Bust (negative) + * Player on 32, multiplier=3, taps 20 + * → points = 60, scoreAfter = -28, BUST + * → Turn over, score stays 32 + * + * // Scenario 3: Bust (score of 1) + * Player on 33, multiplier=1, taps 32 + * → points = 32, scoreAfter = 1, BUST + * → Turn over, score stays 33 + * + * // Scenario 4: Bust (zero on single) + * Player on 20, multiplier=1, taps 20 + * → points = 20, scoreAfter = 0, isDouble=false, BUST + * → Turn over, score stays 20 + * + * // Scenario 5: Win! + * Player on 20, multiplier=2, taps 10 + * → points = 20, scoreAfter = 0, isDouble=true, WIN! + * → Game ends, winner announced + *+ * + * + * @param baseValue The face value of the number hit (1-20, or 25 for Bull). + * Must be positive. Multiplier is applied to calculate final points. + * @see #mMultiplier + * @see #mCurrentTurnDarts + * @see #mIsTurnOver + * @see #handleWin(X01State) + * @see #updateTurnIndicators() + * @see #updateUI() + */ + public void onNumberTap(int baseValue) { + if (mCurrentTurnDarts.size() >= 3 || mIsTurnOver) return; + + int points = baseValue * mMultiplier; + if (baseValue == 25 && mMultiplier == 3) points = 50; // Triple Bull is Double Bull + + X01State active = mPlayerStates.get(mActivePlayerIndex); + int scoreBeforeDart = active.remainingScore; + for (int d : mCurrentTurnDarts) scoreBeforeDart -= d; + + int scoreAfterDart = scoreBeforeDart - points; + boolean isDouble = (mMultiplier == 2) || (points == 50); + + // --- DOUBLE OUT LOGIC CHECK --- + if (scoreAfterDart < 0 || scoreAfterDart == 1 || (scoreAfterDart == 0 && !isDouble)) { + // BUST CONDITION: Score < 0, Score == 1, or Score == 0 on a non-double + mCurrentTurnDarts.add(points); + updateTurnIndicators(); + mIsTurnOver = true; + Toast.makeText(this, "BUST!", Toast.LENGTH_SHORT).show(); + // In a pro interface, we usually wait for "Submit" or auto-submit after a short delay + } else if (scoreAfterDart == 0 && isDouble) { + // VICTORY CONDITION + mCurrentTurnDarts.add(points); + updateTurnIndicators(); + mIsTurnOver = true; + handleWin(active); + } else { + // VALID THROW + mCurrentTurnDarts.add(points); + updateTurnIndicators(); + updateUI(); + + if (mCurrentTurnDarts.size() == 3) { + mIsTurnOver = true; + } + } + + setMultiplier(1); + } + + /** + * Handler for Bull button tap in the UI. + *
+ * This is a convenience method that delegates to {@link #onNumberTap(int)} with + * the Bull's base value of 25. The actual scoring is calculated based on the + * current multiplier: + *
+ * Note: Triple Bull is not a standard darts scoring area, so it's treated as + * Double Bull (50 points) in the scoring logic. + *
+ *+ * Can be called from XML layout via android:onClick="onBullTap" attribute. + *
+ * + * @param v The View that was clicked (Bull button), not used in logic + * @see #onNumberTap(int) + */ + public void onBullTap(View v) { + onNumberTap(25); + } + + /** + * Sets the current multiplier and updates all UI elements to reflect the selection. + *+ * This method handles both the logical state change and all visual feedback: + *
+ * Visual Feedback by Multiplier: + *
+ * MaterialButton Styling: + * The method uses MaterialButton-specific APIs: + *
+ * Performance: + * Efficiently updates all 20 keyboard buttons using cached {@link #mKeyboardButtons} + * list, avoiding repeated findViewById calls. Style updates are instant with no + * noticeable lag. + *
+ *+ * Automatic Reset: + * This method is automatically called with multiplier 1 after each dart throw + * (in {@link #onNumberTap(int)}), ensuring players must explicitly select Double + * or Triple for each dart. + *
+ * + * @param m The multiplier value to set. Valid values: + *+ * This method is called when the user explicitly taps "Submit Turn" button or + * when a turn ends automatically (3 darts thrown, bust, or win). It handles: + *
+ * Turn Processing: + *
+ * Player Rotation: + *
+ * mActivePlayerIndex = (mActivePlayerIndex + 1) % playerCount + *+ * This cycles through players: 0 → 1 → 2 → 0 → 1 → ... + * + *
+ * State Reset: + * After submission: + *
+ * Safety Check: + * Returns immediately if no darts have been thrown (empty turn), preventing + * submission of zero-dart turns. + *
+ *+ * Note: The {@code isFinishDart} check is largely redundant as win conditions + * are already handled in {@link #onNumberTap(int)} and would have ended the game + * before reaching this method. + *
+ * + * @see #mActivePlayerIndex + * @see #mCurrentTurnDarts + * @see #mIsTurnOver + * @see #updateUI() + * @see #updateTurnIndicators() + */ + private void submitTurn() { + // Don't submit if no darts thrown + if (mCurrentTurnDarts.isEmpty()) return; + + // Calculate turn total + int turnTotal = 0; + for (int d : mCurrentTurnDarts) turnTotal += d; + + X01State active = mPlayerStates.get(mActivePlayerIndex); + + // Calculate what final score would be + int finalScore = active.remainingScore - turnTotal; + + // Re-check logic for non-double finish or score of 1 + int lastDartValue = mCurrentTurnDarts.get(mCurrentTurnDarts.size() - 1); + // Note: this check is redundant but safe for manual "Submit" actions + boolean isBust = (finalScore < 0 || finalScore == 1 || (finalScore == 0 && !isFinishDart(mCurrentTurnDarts.size() - 1))); + + // Update score only if not bust + if (!isBust) { + active.remainingScore = finalScore; + active.dartsThrown += mCurrentTurnDarts.size(); + } + // If bust, score remains unchanged + + // Rotate to next player + mActivePlayerIndex = (mActivePlayerIndex + 1) % mPlayerStates.size(); + + // Reset turn state + mCurrentTurnDarts.clear(); + mIsTurnOver = false; + + // Update UI for next player + updateUI(); + updateTurnIndicators(); + } + + /** + * Determines if a specific dart in the current turn sequence was a finishing dart. + *+ * This is a placeholder method for future enhancement. Currently always returns + * {@code true} because win logic is handled immediately in {@link #onNumberTap(int)} + * when the dart is thrown. + *
+ *+ * Current Limitation: + * The implementation doesn't track which multiplier was used for each dart in the + * turn, making it impossible to retrospectively determine if a dart was a double. + * The immediate win detection in {@code onNumberTap} makes this method largely + * unnecessary in current flow. + *
+ *+ * Future Enhancement: + * To properly implement this method, would need to: + *
+ * This method provides undo functionality, allowing players to correct mistakes + * during their turn. It: + *
+ * Use Cases: + *
+ * Limitations: + *
+ * Example Usage: + *
+ * Turn state: [60, 60, 20] (T20, T20, D10) + * User taps Undo + * New state: [60, 60] (last dart removed) + * Turn can continue with new dart entry + *+ * + *
+ * Triggered by the "Undo" button in the UI. + *
+ * + * @see #mCurrentTurnDarts + * @see #mIsTurnOver + * @see #updateTurnIndicators() + * @see #updateUI() + */ + private void undoLastDart() { + if (!mCurrentTurnDarts.isEmpty()) { + // Remove last dart from list + mCurrentTurnDarts.remove(mCurrentTurnDarts.size() - 1); + + // Allow turn to continue + mIsTurnOver = false; + + // Update displays + updateTurnIndicators(); + updateUI(); + } + } + + /** + * Updates all UI elements to reflect the current game state. + *+ * This method refreshes all dynamic UI components based on the active player's + * current state and the darts thrown so far in the turn. It: + *
+ * Three-Dart Average Calculation: + *
+ * Average = ((startingScore - remainingScore) / dartsThrown) × 3 + * + * Example: + * Starting score: 501 + * Remaining score: 201 + * Darts thrown: 30 + * Points scored: 501 - 201 = 300 + * Average: (300 / 30) × 3 = 30.0 + *+ * Displayed as "AVG: 30.0" in the UI. + * + *
+ * Current Target Calculation: + * The target score considers darts thrown but not yet submitted: + *
+ * currentTarget = remainingScore - sumOfCurrentTurnDarts + *+ * This allows checkout suggestions to update in real-time as darts are thrown. + * + *
+ * When Called: + *
+ * Performance: + * This method is called frequently during gameplay, so it's optimized to: + *
+ * This method determines whether to show a checkout suggestion and what route to display. + * The checkout engine provides optimal finishing paths when a score is within achievable + * range (≤170 points with available darts). + *
+ *+ * Visibility Conditions: + * Checkout suggestion is shown when ALL conditions are met: + *
+ * Visual Effects: + * When a checkout route is available: + *
+ * Example Scenarios: + *
+ * Score = 32, Darts = 1 → Shows "D16" + * Score = 40, Darts = 2 → Shows "D20" or setup route + * Score = 170, Darts = 3 → Shows "T20 • T20 • BULL" + * Score = 1, Darts = 3 → Hidden (impossible) + * Score = 180, Darts = 3 → Hidden (too high) + *+ * + *
+ * Animation Details: + * The pulsing animation: + *
+ * Called automatically by {@link #updateUI()} whenever game state changes. + *
+ * + * @param score The target score to finish, accounting for darts already thrown in + * current turn. Must be non-negative. + * @param dartsLeft The number of darts remaining in the current turn (0-3). + * @see CheckoutEngine#getRoute(int, int) + * @see #layoutCheckoutSuggestion + * @see #tvCheckout + */ + private void updateCheckoutSuggestion(int score, int dartsLeft) { + if (score <= 170 && score > 1 && dartsLeft > 0) { + String route = CheckoutEngine.getRoute(score, dartsLeft); + + if (route != null) { + layoutCheckoutSuggestion.setVisibility(View.VISIBLE); + tvCheckout.setText(route); + + Animation pulse = new AlphaAnimation(0.5f, 1.0f); + pulse.setDuration(1000); + pulse.setRepeatMode(Animation.REVERSE); + pulse.setRepeatCount(Animation.INFINITE); + layoutCheckoutSuggestion.startAnimation(pulse); + return; + } + } + layoutCheckoutSuggestion.clearAnimation(); + layoutCheckoutSuggestion.setVisibility(View.GONE); + } + + /** + * Updates the three dart indicator pills to show the current turn's dart values. + *+ * This method provides visual feedback for the darts thrown in the current turn by + * updating the three "pill" TextViews. Each pill can be in one of two states: + *
+ * Visual States: + *
+ * Dart not thrown: [ ] (empty background) + * Dart thrown: [60] (green text, active background) + *+ * + *
+ * Display Labels: + * Dart values are formatted for clarity: + *
+ * Example Progression: + *
+ * No darts: [ ] [ ] [ ] + * 1 dart: [60] [ ] [ ] + * 2 darts: [60] [60] [ ] + * 3 darts: [60] [60] [20] + * After submit:[ ] [ ] [ ] (cleared for next turn) + *+ * + *
+ * When Called: + *
+ * The visual feedback helps players: + *
+ * This method formats dart scores for clarity in the turn indicator pills, + * using special abbreviations for Bull scores to save space and improve + * readability. + *
+ *+ * Label Formats: + *
+ * Example Conversions: + *
+ * getDartLabel(50) → "DB" + * getDartLabel(25) → "B" + * getDartLabel(60) → "60" + * getDartLabel(20) → "20" + * getDartLabel(1) → "1" + *+ * + * + * @param score The dart's point value to format. Typically 0-60, 25, or 50. + * @return A string label suitable for display in UI. Never returns null. + * @see #updateTurnIndicators() + */ + private String getDartLabel(int score) { + if (score == 50) return "DB"; // Double Bull / Bullseye + if (score == 25) return "B"; // Single Bull + // Return numeric value for all other scores + return String.valueOf(score); + } + + /** + * Handles the win condition when a player finishes the game. + *
+ * This method is called from {@link #onNumberTap(int)} when a player successfully + * finishes on exactly zero with a double. It: + *
+ * Current Implementation: + * The current implementation is minimal and immediately ends the game. Future + * enhancements could include: + *
+ * Toast Duration: + * Uses LENGTH_LONG (approximately 3.5 seconds) to ensure players see the win + * message before the activity closes. + *
+ * + * @param winner The {@link X01State} of the player who won the game. Used to + * display the winner's name in the toast notification. + * @see X01State + * @see #onNumberTap(int) + */ + private void handleWin(X01State winner) { + // Show win notification + Toast.makeText(this, winner.name + " WINS!", Toast.LENGTH_LONG).show(); + + // End game and return to previous screen + finish(); + + // TODO: Consider adding: + // - Win animation/sound + // - Statistics display + // - Save match to database + // - Offer rematch + } + + /** + * Internal state holder for a single player's X01 game progress. + *+ * This lightweight class tracks all necessary information for one player during + * an X01 game. Each player in the game has their own X01State instance stored + * in the {@link #mPlayerStates} list. + *
+ *+ * Fields: + *
+ * Usage Example: + *
+ * X01State player1 = new X01State("John Doe", 501);
+ * // Player throws T20, T20, T20 (180 points)
+ * player1.remainingScore -= 180; // Now 321
+ * player1.dartsThrown += 3;
+ *
+ * // Calculate three-dart average
+ * double avg = ((501 - player1.remainingScore) / player1.dartsThrown) * 3;
+ *
+ *
+ * + * State Lifecycle: + *
+ * Note: This is an inner class (not static) but could be made static as it + * doesn't access outer class members. + *
+ * + * @see #mPlayerStates + * @see #setupGame(List) + * @see #updateUI() + */ + private static class X01State { + /** + * The player's display name. + *+ * Copied from {@link Player#username} during game initialization. + * Used for UI display and win announcements. + *
+ */ + String name; + + /** + * The player's current remaining score. + *+ * Starts at the game's starting score (e.g., 501) and decreases as darts + * are scored. Game is won when this reaches exactly 0 with a double. + *
+ *+ * Updated in {@link GameActivity#submitTurn()} after each turn (unless bust). + *
+ */ + int remainingScore; + + /** + * Total number of darts thrown by this player so far in the game. + *+ * Used to calculate the three-dart average: + *
+ * average = ((startScore - remainingScore) / dartsThrown) × 3 + *+ * + *
+ * Incremented in {@link GameActivity#submitTurn()} after each valid turn. + * Bust turns don't increment this counter. + *
+ */ + int dartsThrown = 0; + + /** + * Constructs a new X01State for a player. + * + * @param name The player's display name + * @param startScore The game's starting score (e.g., 501, 301, 701) + */ + X01State(String name, int startScore) { + this.name = name; + 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: + *
+ * 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 { + /** + * Map of pre-calculated checkout routes for classic finishes. + *+ * Key: Target score (e.g., 170, 141) + * Value: Array of dart descriptions (e.g., ["T20", "T20", "BULL"]) + *
+ *+ * Current Routes: + *
+ * Additional routes can be added for other common finishes (e.g., 167, 164, 160). + *
+ */ + private static final Map+ * This method implements sophisticated checkout logic that considers: + *
+ * Return Format: + * Returns strings formatted as: + *
+ * Algorithm Steps: + *
+ * Setup Dart Logic: + * When score is odd with 2+ darts left, suggests setup darts to leave doubles: + *
+ * Score 41: Suggest "1 • D20" (leaves 40) + * Score 39: Suggest "7 • D16" (leaves 32) + * Score 47: Suggest "7 • D20" (leaves 40) + *+ * Prioritizes leaving 32 (D16) or 40 (D20) as these are common target doubles. + * + *
+ * Example Calls: + *
+ * CheckoutEngine.getRoute(32, 1); // Returns "D16" + * CheckoutEngine.getRoute(50, 1); // Returns "BULL" + * CheckoutEngine.getRoute(40, 2); // Returns "D20" + * CheckoutEngine.getRoute(41, 2); // Returns "1 • D20" + * CheckoutEngine.getRoute(170, 3); // Returns "T20 • T20 • BULL" + * CheckoutEngine.getRoute(180, 3); // Returns null (impossible) + * CheckoutEngine.getRoute(100, 2); // Returns "T20 Route" + * CheckoutEngine.getRoute(1, 1); // Returns null (impossible) + *+ * + *
+ * Limitations: + *
+ * This activity provides the following functionality: + *
+ * This view shows match information including players, scores, and match status. + * It can be clicked to cycle through different test data states for development purposes. + *
+ * + * @see MatchRecapView + */ + private MatchRecapView mMatchRecap; + + /** + * Counter used for cycling through different test data scenarios. + *+ * This counter increments each time the match recap view is clicked, allowing + * developers to cycle through different test states (null match, 1v1 match, group match). + * The counter value modulo 3 determines which test scenario is displayed. + *
+ */ + private int testCounter = 0; + + /** + * Called when the activity is first created. + *+ * This method performs the following initialization tasks: + *
+ * This method is called every time the activity comes to the foreground, making it + * the ideal place to refresh the squad view with the latest player data from the database. + *
+ * + * @see AppCompatActivity#onResume() + */ + @Override + protected void onResume() { + super.onResume(); + // Refresh the squad view with latest player data + initSquadView(); + } + + /** + * Initiates a quick-start 501 game with two test players. + *+ * This convenience method creates two test players with minimal configuration + * and immediately launches a 501 game. It's designed for rapid testing and + * development, allowing developers or users to quickly jump into a game without + * setting up real player profiles. + *
+ *+ * The method: + *
+ * Use Cases: + *
+ * Note: The test players created here are not persisted to the database. + * They exist only for the duration of the game session. + *
+ *+ * Triggered by the quick start button in the main menu UI. + *
+ * + * @see GameActivity#start(android.content.Context, ArrayList, int) + * @see Player + */ + private void quickStart() { + final Player playerOne = new Player("Test1", null); + final Player playerTwo = new Player("Test2", null); + final ArrayList+ * This method performs the following operations: + *
+ * Note: Database operations are performed on a background thread to prevent + * blocking the main UI thread, which ensures the application remains responsive. + *
+ * + * @see MainMenuPlayerAdapter + * @see AddPlayerActivity + * @see LinearLayoutManager + */ + private void initSquadView() { + // Get references to UI components + final TextView addPlayerBtn = findViewById(R.id.btnAddPlayer); + final RecyclerView squadView = findViewById(R.id.rvSquad); + + // Configure RecyclerView with linear layout + squadView.setLayoutManager(new LinearLayoutManager(MainMenuActivity.this)); + + // Create and attach adapter + final MainMenuPlayerAdapter adapter = new MainMenuPlayerAdapter(); + squadView.setAdapter(adapter); + + // Set up button to launch AddPlayerActivity + addPlayerBtn.setOnClickListener(v -> { + final Intent intent = new Intent(MainMenuActivity.this, AddPlayerActivity.class); + startActivity(intent); + }); + + // Database operations must be run on a background thread to keep the UI responsive. + new Thread(() -> { + // Access the singleton database and query all players + final List+ * This method creates sample player and match objects and cycles through different + * display states based on the provided counter value: + *
+ * Note: This method is intended for development and testing only + * and should be removed or disabled in production builds. + *
+ * + * @param counter The counter value used to determine which test scenario to display. + * The value is evaluated using modulo 3 to cycle through test states. + * @see Match + * @see Player + * @see MatchRecapView#setMatch(Match) + */ + private void applyTestData(final int counter) { + // Create test player objects + final Player playerOne = new Player("Test1", null); + final Player playerTwo = new Player("Test2", null); + final Player playerThree = new Player("Test3", null); + final Player playerFour = new Player("Test4", null); + + // Create test match objects with different player configurations + final Match match1on1 = new Match(playerOne, playerTwo); + final Match matchGroup = new Match(playerOne, playerTwo, playerThree, playerFour); + + // Cycle through different test scenarios based on counter value + if (counter % 3 == 0) { + // Scenario 1: No match (null state) + mMatchRecap.setMatch(null); + } else if (counter % 3 == 1) { + // Scenario 2: 1v1 match (two players) + mMatchRecap.setMatch(match1on1); + } else { + // Scenario 3: Group match (four players) + mMatchRecap.setMatch(matchGroup); + } + } +} \ 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 new file mode 100644 index 0000000..f2a78db --- /dev/null +++ b/app/src/main/java/com/aldo/apps/ochecompanion/database/AppDatabase.java @@ -0,0 +1,866 @@ +package com.aldo.apps.ochecompanion.database; + +import android.content.Context; +import androidx.room.Database; +import androidx.room.Room; +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.objects.Match; +import com.aldo.apps.ochecompanion.database.objects.Player; + +/** + * The main Room database class for the Oche Companion darts application. + *+ * This abstract class serves as the central database access point, managing all + * data persistence for players, matches, and related statistics. It implements + * the Singleton design pattern to ensure only one database instance exists + * throughout the application lifecycle, preventing resource conflicts and ensuring + * data consistency. + *
+ *+ * Room Database Architecture: + * Room is Android's SQLite object-relational mapping (ORM) library that provides + * an abstraction layer over SQLite, offering: + *
+ * Database Entities: + * This database manages two primary tables: + *
+ * Database Access Objects (DAOs): + * Database operations are performed through specialized DAO interfaces: + *
+ * Singleton Pattern Implementation: + * The class uses the thread-safe double-checked locking pattern to ensure a single + * database instance. This approach: + *
+ * Usage Example: + *
+ * // Get database instance (can be called from any activity/fragment)
+ * AppDatabase db = AppDatabase.getDatabase(context);
+ *
+ * // Access DAOs for database operations
+ * PlayerDao playerDao = db.playerDao();
+ * MatchDao matchDao = db.matchDao();
+ *
+ * // Perform database operations (must be on background thread)
+ * new Thread(() -> {
+ * // Insert a new player
+ * Player player = new Player("John Doe", "/path/to/pic.jpg");
+ * playerDao.insert(player);
+ *
+ * // Query all players
+ * List<Player> allPlayers = playerDao.getAllPlayers();
+ *
+ * // Insert a match
+ * Match match = new Match(System.currentTimeMillis(), "501", 2, participantJson);
+ * matchDao.insert(match);
+ *
+ * // Get recent matches
+ * List<Match> recentMatches = matchDao.getAllMatches();
+ * }).start();
+ *
+ *
+ * + * Database Versioning: + * Currently at version 2, indicating one schema change since initial creation. + * Version increments are required when: + *
+ * Migration Strategy: + * For production releases, replace destructive migration with proper migration paths: + *
+ * static final Migration MIGRATION_1_2 = new Migration(1, 2) {
+ * @Override
+ * public void migrate(@NonNull SupportSQLiteDatabase database) {
+ * // Example: Add new column to players table
+ * database.execSQL("ALTER TABLE players ADD COLUMN wins INTEGER NOT NULL DEFAULT 0");
+ * }
+ * };
+ *
+ * INSTANCE = Room.databaseBuilder(context.getApplicationContext(),
+ * AppDatabase.class, "oche_companion_db")
+ * .addMigrations(MIGRATION_1_2) // Add migration instead of destructive
+ * .build();
+ *
+ *
+ * + * Database File Location: + * The SQLite database file "oche_companion_db" is stored in the app's private + * storage at: + *
+ * /data/data/com.aldo.apps.ochecompanion/databases/oche_companion_db + *+ * This location is: + *
+ * Schema Export: + * The {@code exportSchema = false} setting disables automatic schema JSON export. + * For production apps, consider enabling this: + *
+ * @Database(entities = {...}, version = 2, exportSchema = true)
+ *
+ * And specify the export directory in build.gradle:
+ *
+ * android {
+ * defaultConfig {
+ * javaCompileOptions {
+ * annotationProcessorOptions {
+ * arguments += ["room.schemaLocation": "$projectDir/schemas"]
+ * }
+ * }
+ * }
+ * }
+ *
+ * This creates version-controlled schema files for tracking changes and testing migrations.
+ *
+ * + * Threading Requirements: + * Room enforces that database operations occur on background threads: + *
+ * Database Inspection: + * For debugging, you can inspect the database using: + *
+ * Performance Optimization: + *
@Entity(indices = {@Index(value = {"username"})})+ * Testing: + * For unit testing, create an in-memory database: + *
+ * @Before
+ * public void createDb() {
+ * Context context = ApplicationProvider.getApplicationContext();
+ * db = Room.inMemoryDatabaseBuilder(context, AppDatabase.class)
+ * .allowMainThreadQueries() // OK for tests
+ * .build();
+ * playerDao = db.playerDao();
+ * }
+ *
+ * @After
+ * public void closeDb() {
+ * db.close();
+ * }
+ *
+ *
+ * + * Data Backup and Restore: + * Consider implementing backup functionality: + *
+ * Security Considerations: + *
+ * Future Enhancements: + * Consider these improvements for future versions: + *
+ * This abstract method is implemented by Room at compile time, returning an + * instance of the {@link PlayerDao} interface. The DAO (Data Access Object) + * provides methods for all player-related database operations including + * creating, reading, updating, and managing player records. + *
+ *+ * Available Operations: + * The returned PlayerDao provides these methods: + *
+ * Usage Example: + *
+ * // Get database instance
+ * AppDatabase db = AppDatabase.getDatabase(context);
+ *
+ * // Get PlayerDao
+ * PlayerDao playerDao = db.playerDao();
+ *
+ * // Perform operations on background thread
+ * new Thread(() -> {
+ * // Create and insert new player
+ * Player newPlayer = new Player("Alice", "/path/to/pic.jpg");
+ * playerDao.insert(newPlayer);
+ *
+ * // Query all players
+ * List<Player> squad = playerDao.getAllPlayers();
+ *
+ * // Update player stats
+ * Player player = playerDao.getPlayerById(5);
+ * if (player != null) {
+ * player.careerAverage = 85.5;
+ * player.matchesPlayed++;
+ * playerDao.update(player);
+ * }
+ *
+ * // Update UI on main thread
+ * runOnUiThread(() -> updateSquadDisplay(squad));
+ * }).start();
+ *
+ *
+ * + * Thread Safety: + * While the DAO instance itself is thread-safe and can be reused across multiple + * threads, all database operations must be performed on background threads to + * comply with Room's threading policy. Attempting to execute queries on the + * main thread will result in an {@link IllegalStateException}. + *
+ *+ * DAO Lifecycle: + * The DAO instance is created once by Room and can be cached and reused: + *
+ * // Good: Reuse DAO instance + * PlayerDao playerDao = db.playerDao(); + * // Use playerDao for multiple operations + * + * // Also fine: Get DAO each time (Room caches internally) + * db.playerDao().insert(player1); + * db.playerDao().insert(player2); + *+ * + *
+ * Common Usage Patterns: + *
+ * // Pattern 1: Simple query and UI update
+ * ExecutorService executor = Executors.newSingleThreadExecutor();
+ * executor.execute(() -> {
+ * List<Player> players = db.playerDao().getAllPlayers();
+ * runOnUiThread(() -> adapter.setPlayers(players));
+ * });
+ *
+ * // Pattern 2: Insert and navigate
+ * new Thread(() -> {
+ * Player player = new Player("John", picUri);
+ * db.playerDao().insert(player);
+ * runOnUiThread(() -> {
+ * Toast.makeText(context, "Player added", Toast.LENGTH_SHORT).show();
+ * finish();
+ * });
+ * }).start();
+ *
+ * // Pattern 3: Edit flow (query, modify, update)
+ * new Thread(() -> {
+ * Player player = db.playerDao().getPlayerById(playerId);
+ * if (player != null) {
+ * player.username = newName;
+ * player.profilePictureUri = newPicUri;
+ * db.playerDao().update(player);
+ * runOnUiThread(() -> Toast.makeText(context, "Updated", Toast.LENGTH_SHORT).show());
+ * }
+ * }).start();
+ *
+ *
+ * + * Alternative Reactive Approaches: + * Consider using LiveData or Flow for automatic UI updates: + *
+ * // If PlayerDao had LiveData support:
+ * // @Query("SELECT * FROM players ORDER BY username ASC")
+ * // LiveData<List<Player>> getAllPlayersLive();
+ *
+ * // Usage in Activity/Fragment:
+ * db.playerDao().getAllPlayersLive().observe(this, players -> {
+ * // UI automatically updates when data changes
+ * adapter.setPlayers(players);
+ * });
+ *
+ *
+ * + * Error Handling: + *
+ * new Thread(() -> {
+ * try {
+ * playerDao.insert(player);
+ * runOnUiThread(() -> showSuccess());
+ * } catch (SQLiteConstraintException e) {
+ * Log.e(TAG, "Constraint violation", e);
+ * runOnUiThread(() -> showError("Failed to save player"));
+ * } catch (Exception e) {
+ * Log.e(TAG, "Database error", e);
+ * runOnUiThread(() -> showError("Unexpected error"));
+ * }
+ * }).start();
+ *
+ *
+ *
+ * @return The PlayerDao instance for accessing player-related database operations.
+ * Never returns null. The returned instance can be safely cached and reused.
+ * @see PlayerDao
+ * @see Player
+ */
+ public abstract PlayerDao playerDao();
+
+ /**
+ * Provides access to Match-related database operations.
+ * + * This abstract method is implemented by Room at compile time, returning an + * instance of the {@link MatchDao} interface. The DAO provides methods for + * storing and retrieving match records, enabling match history tracking and + * statistical analysis. + *
+ *+ * Available Operations: + * The returned MatchDao provides these methods: + *
+ * Usage Example: + *
+ * // Get database instance
+ * AppDatabase db = AppDatabase.getDatabase(context);
+ *
+ * // Get MatchDao
+ * MatchDao matchDao = db.matchDao();
+ *
+ * // Save completed match (background thread)
+ * new Thread(() -> {
+ * // Create match record with participant data
+ * String participantJson = buildParticipantData(players, scores);
+ * Match match = new Match(
+ * System.currentTimeMillis(),
+ * "501",
+ * 2,
+ * participantJson
+ * );
+ *
+ * // Insert into database
+ * matchDao.insert(match);
+ *
+ * // Get match history for display
+ * List<Match> allMatches = matchDao.getAllMatches();
+ *
+ * // Update UI with latest match
+ * Match lastMatch = matchDao.getLastMatch();
+ * runOnUiThread(() -> displayMatchRecap(lastMatch));
+ * }).start();
+ *
+ *
+ * + * Match History Display: + *
+ * // Load match history for main menu
+ * new Thread(() -> {
+ * List<Match> recentMatches = db.matchDao().getAllMatches();
+ *
+ * runOnUiThread(() -> {
+ * if (recentMatches.isEmpty()) {
+ * // Show empty state - no matches played yet
+ * showEmptyMatchHistory();
+ * } else {
+ * // Display most recent match in recap view
+ * Match lastMatch = recentMatches.get(0);
+ * matchRecapView.setMatch(lastMatch);
+ * }
+ * });
+ * }).start();
+ *
+ *
+ * + * Match Completion Flow: + *
+ * // After match ends, save to database
+ * private void saveMatchResult(List<Player> players, Map<Player, Score> scores) {
+ * new Thread(() -> {
+ * // Build participant data JSON
+ * JSONArray participants = new JSONArray();
+ * for (Player player : players) {
+ * JSONObject data = new JSONObject();
+ * data.put("id", player.id);
+ * data.put("username", player.username);
+ * data.put("rank", scores.get(player).rank);
+ * data.put("average", scores.get(player).average);
+ * participants.put(data);
+ * }
+ *
+ * // Create and save match
+ * Match match = new Match(
+ * System.currentTimeMillis(),
+ * currentGameMode,
+ * players.size(),
+ * participants.toString()
+ * );
+ *
+ * db.matchDao().insert(match);
+ *
+ * // Navigate to results screen
+ * runOnUiThread(() -> {
+ * Intent intent = new Intent(this, MatchResultsActivity.class);
+ * intent.putExtra("match", match);
+ * startActivity(intent);
+ * });
+ * }).start();
+ * }
+ *
+ *
+ * + * Thread Safety: + * Like PlayerDao, the MatchDao instance is thread-safe and can be reused across + * threads. However, all database operations must execute on background threads. + * Room will throw an {@link IllegalStateException} if database operations are + * attempted on the main thread. + *
+ *+ * Performance Considerations: + *
+ * Statistical Queries: + *
+ * // Get match history for analysis
+ * new Thread(() -> {
+ * List<Match> allMatches = db.matchDao().getAllMatches();
+ *
+ * // Calculate statistics
+ * int totalMatches = allMatches.size();
+ * Map<String, Integer> gameModeCount = new HashMap<>();
+ *
+ * for (Match match : allMatches) {
+ * gameModeCount.merge(match.gameMode, 1, Integer::sum);
+ * }
+ *
+ * // Display stats
+ * runOnUiThread(() -> showStatistics(totalMatches, gameModeCount));
+ * }).start();
+ *
+ *
+ * + * DAO Lifecycle: + * The DAO instance can be cached and reused throughout the app: + *
+ * // Cache in Application class or repository
+ * private MatchDao matchDao;
+ *
+ * public MatchDao getMatchDao() {
+ * if (matchDao == null) {
+ * matchDao = AppDatabase.getDatabase(context).matchDao();
+ * }
+ * return matchDao;
+ * }
+ *
+ *
+ * + * Error Handling: + *
+ * new Thread(() -> {
+ * try {
+ * matchDao.insert(match);
+ * runOnUiThread(() -> {
+ * Toast.makeText(context, "Match saved", Toast.LENGTH_SHORT).show();
+ * });
+ * } catch (Exception e) {
+ * Log.e(TAG, "Failed to save match", e);
+ * runOnUiThread(() -> {
+ * Toast.makeText(context, "Error saving match", Toast.LENGTH_SHORT).show();
+ * });
+ * }
+ * }).start();
+ *
+ *
+ *
+ * @return The MatchDao instance for accessing match-related database operations.
+ * Never returns null. The returned instance can be safely cached and reused.
+ * @see MatchDao
+ * @see Match
+ */
+ public abstract MatchDao matchDao();
+
+ /**
+ * The singleton instance of the AppDatabase.
+ * + * This static field holds the single database instance for the entire application. + * Using the volatile keyword ensures proper visibility across threads in the + * double-checked locking pattern, preventing potential issues where one thread's + * changes might not be visible to other threads. + *
+ *+ * Volatile Keyword: + * The volatile modifier guarantees: + *
+ * Initialization State: + *
+ * Memory Lifecycle: + * The database instance is retained in memory for the lifetime of the application + * process. It will be garbage collected only when: + *
+ * Testing Considerations: + * For unit tests, you may need to reset the singleton: + *
+ * // In test teardown (requires reflection or test-only setter)
+ * @After
+ * public void tearDown() {
+ * // Close database
+ * if (AppDatabase.INSTANCE != null) {
+ * AppDatabase.INSTANCE.close();
+ * // Reset singleton (would need package-private setter)
+ * AppDatabase.INSTANCE = null;
+ * }
+ * }
+ *
+ *
+ * + * Why Singleton: + * Using a singleton for the database instance provides: + *
+ * Thread Safety Analysis: + * The volatile keyword combined with synchronized block in {@link #getDatabase(Context)} + * ensures thread-safe lazy initialization: + *
+ * // Thread 1 checks: INSTANCE == null (true) + * // Thread 1 enters synchronized block + * // Thread 1 creates database instance + * // Thread 1 assigns to INSTANCE (volatile write) + * + * // Thread 2 checks: INSTANCE == null (false, sees Thread 1's write) + * // Thread 2 returns existing instance without entering synchronized block + *+ * + * + * @see #getDatabase(Context) + * @see RoomDatabase + */ + private static volatile AppDatabase INSTANCE; + + /** + * Gets the singleton instance of the AppDatabase, creating it if necessary. + *
+ * This method implements the thread-safe double-checked locking pattern to ensure + * only one database instance is created, even when called simultaneously from + * multiple threads. The method is safe to call from any thread and can be invoked + * from any component (Activity, Fragment, Service, etc.). + *
+ *+ * Double-Checked Locking Pattern: + * The implementation uses two null checks to optimize performance: + *
+ * Usage Example: + *
+ * // From Activity
+ * public class MainActivity extends AppCompatActivity {
+ * @Override
+ * protected void onCreate(Bundle savedInstanceState) {
+ * super.onCreate(savedInstanceState);
+ *
+ * // Get database instance
+ * AppDatabase db = AppDatabase.getDatabase(this);
+ *
+ * // Use DAOs for database operations
+ * new Thread(() -> {
+ * List<Player> players = db.playerDao().getAllPlayers();
+ * runOnUiThread(() -> displayPlayers(players));
+ * }).start();
+ * }
+ * }
+ *
+ * // From Fragment
+ * AppDatabase db = AppDatabase.getDatabase(requireContext());
+ *
+ * // From Service
+ * AppDatabase db = AppDatabase.getDatabase(getApplicationContext());
+ *
+ * // From anywhere with Context
+ * AppDatabase db = AppDatabase.getDatabase(context);
+ *
+ *
+ * + * Context Parameter: + * The method accepts any Context but internally uses {@code context.getApplicationContext()} + * to avoid memory leaks. This means: + *
+ * Database Builder Configuration: + * The database is created with these settings: + *
+ * Room.databaseBuilder( + * context.getApplicationContext(), // App context to prevent leaks + * AppDatabase.class, // Database class + * "oche_companion_db" // Database file name + * ) + * .fallbackToDestructiveMigration() // Drop tables on version change + * .build(); + *+ * + *
+ * Destructive Migration Strategy: + * The {@code fallbackToDestructiveMigration()} setting means: + *
.addMigrations(MIGRATION_1_2, MIGRATION_2_3)+ *
+ * First Call Initialization: + * On the first call, this method will: + *
+ * Subsequent Calls: + * After initialization, subsequent calls: + *
+ * Thread Safety Guarantee: + *
+ * // Safe to call from multiple threads simultaneously
+ * Thread thread1 = new Thread(() -> {
+ * AppDatabase db = AppDatabase.getDatabase(context);
+ * db.playerDao().insert(player1);
+ * });
+ *
+ * Thread thread2 = new Thread(() -> {
+ * AppDatabase db = AppDatabase.getDatabase(context);
+ * db.playerDao().insert(player2);
+ * });
+ *
+ * thread1.start();
+ * thread2.start();
+ * // Both threads will use the same database instance
+ *
+ *
+ * + * Best Practices: + *
+ * Proactive Initialization: + *
+ * public class OcheCompanionApplication extends Application {
+ * @Override
+ * public void onCreate() {
+ * super.onCreate();
+ *
+ * // Initialize database early to avoid delay on first access
+ * AppDatabase.getDatabase(this);
+ * }
+ * }
+ *
+ *
+ * + * Error Handling: + * In rare cases, database creation might fail due to: + *
+ * try {
+ * AppDatabase db = AppDatabase.getDatabase(context);
+ * // Use database
+ * } catch (Exception e) {
+ * Log.e(TAG, "Failed to open database", e);
+ * // Show error to user, attempt recovery, etc.
+ * }
+ *
+ *
+ *
+ * @param context The application context used to create the database. While any
+ * Context type can be passed (Activity, Fragment, Service, etc.),
+ * the method internally uses {@code context.getApplicationContext()}
+ * to prevent memory leaks. Must not be null.
+ * @return The singleton AppDatabase instance, fully initialized and ready for use.
+ * Never returns null. The same instance is returned on all subsequent calls.
+ * @throws IllegalArgumentException if context is null (thrown by Room.databaseBuilder)
+ * @throws RuntimeException if database creation fails due to filesystem or other errors
+ * @see Room#databaseBuilder(Context, Class, String)
+ * @see RoomDatabase.Builder#fallbackToDestructiveMigration()
+ */
+ public static AppDatabase getDatabase(final Context context) {
+ // First check (unsynchronized): Fast path when instance already exists
+ // Most calls will return here after first initialization
+ if (INSTANCE == null) {
+ // Synchronize on the class to ensure only one thread can create the instance
+ synchronized (AppDatabase.class) {
+ // Second check (synchronized): Prevent creation if another thread
+ // created the instance while we were waiting for the lock
+ if (INSTANCE == null) {
+ // Create the database instance
+ // Use application context to prevent memory leaks
+ INSTANCE = Room.databaseBuilder(context.getApplicationContext(),
+ AppDatabase.class, "oche_companion_db")
+ .fallbackToDestructiveMigration() // Drop tables on version change
+ .build();
+ }
+ }
+ }
+ // Return the singleton instance (thread-safe due to volatile)
+ return INSTANCE;
+ }
+}
+
+
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
new file mode 100644
index 0000000..d5c28f5
--- /dev/null
+++ b/app/src/main/java/com/aldo/apps/ochecompanion/database/dao/MatchDao.java
@@ -0,0 +1,289 @@
+package com.aldo.apps.ochecompanion.database.dao;
+
+import androidx.room.Dao;
+import androidx.room.Insert;
+import androidx.room.Query;
+import com.aldo.apps.ochecompanion.database.objects.Match;
+import java.util.List;
+
+/**
+ * Data Access Object (DAO) interface for performing database operations on Match entities.
+ * + * This interface defines the contract for accessing and manipulating match data in the + * Room database. It provides methods for inserting new match records and querying match + * history. Room generates the implementation of this interface at compile time. + *
+ *+ * Room Database Integration: + * The {@code @Dao} annotation indicates that this is a Room DAO interface. Room will + * automatically generate an implementation class that handles all the database operations, + * SQLite connections, cursor management, and data mapping. + *
+ *+ * Key Features: + *
+ * Thread Safety: + * All database operations should be performed on a background thread to avoid blocking + * the main UI thread. Room enforces this for most operations on main thread and will + * throw an exception if database operations are attempted on the main thread (unless + * explicitly configured otherwise). + *
+ *+ * Usage Example: + *
+ * // Get DAO instance from database
+ * MatchDao matchDao = AppDatabase.getDatabase(context).matchDao();
+ *
+ * // Insert a new match (on background thread)
+ * new Thread(() -> {
+ * matchDao.insert(newMatch);
+ * }).start();
+ *
+ * // Query last match (on background thread)
+ * new Thread(() -> {
+ * Match lastMatch = matchDao.getLastMatch();
+ * // Use lastMatch on UI thread
+ * runOnUiThread(() -> displayMatch(lastMatch));
+ * }).start();
+ *
+ *
+ * + * Database Table: + * This DAO operates on the "matches" table, which is defined by the {@link Match} + * entity class with its {@code @Entity} annotation. + *
+ * + * @see Match + * @see Dao + * @see com.aldo.apps.ochecompanion.database.AppDatabase + * @author Oche Companion Development Team + * @version 1.0 + * @since 1.0 + */ +@Dao +public interface MatchDao { + + /** + * Inserts a completed match record into the database. + *+ * This method persists a new match entry to the "matches" table. The match should + * represent a completed game with all necessary information (players, scores, timestamp, etc.). + * Room will handle the actual SQL INSERT operation and auto-increment primary keys if configured. + *
+ *+ * Threading: + * This operation must be performed on a background thread. Attempting to call this + * on the main thread will result in an exception (unless Room is configured to allow + * main thread queries, which is not recommended). + *
+ *+ * Transaction Behavior: + * By default, Room wraps this operation in a transaction. If the insert fails, the + * transaction will be rolled back, maintaining database consistency. + *
+ *+ * Conflict Strategy: + * The default conflict strategy is {@code OnConflictStrategy.ABORT}, which means if + * a conflict occurs (e.g., primary key violation), the insert will fail with an exception. + * This can be customized by adding a parameter to the {@code @Insert} annotation. + *
+ *+ * Return Value: + * While this method returns void, the {@code @Insert} annotation can be configured + * to return: + *
+ * Usage Example: + *
+ * Match match = new Match(...);
+ * match.setTimestamp(System.currentTimeMillis());
+ *
+ * new Thread(() -> {
+ * try {
+ * matchDao.insert(match);
+ * // Match successfully saved
+ * } catch (Exception e) {
+ * // Handle insert failure
+ * Log.e(TAG, "Failed to insert match", e);
+ * }
+ * }).start();
+ *
+ *
+ *
+ * @param match The Match entity to persist. Must not be null. Should contain all
+ * required fields including timestamp, player information, and scores.
+ * @throws IllegalStateException if called on the main thread (Room's default behavior)
+ * @throws SQLiteException if the database operation fails
+ * @see Insert
+ * @see Match
+ */
+ @Insert
+ void insert(Match match);
+
+ /**
+ * Retrieves all match records from the database, ordered by most recent first.
+ * + * This method queries the complete match history from the "matches" table and returns + * them in descending order by timestamp. The most recently played match will be the + * first element in the returned list. + *
+ *+ * SQL Query: + * Executes: {@code SELECT * FROM matches ORDER BY timestamp DESC} + *
+ *+ * Sorting: + * Matches are ordered by the "timestamp" field in descending order (DESC), meaning: + *
+ * Threading: + * This operation must be performed on a background thread. Attempting to call this + * on the main thread will result in an exception (unless Room is configured to allow + * main thread queries, which is not recommended). + *
+ *+ * Performance Considerations: + *
+ * Empty Result: + * If no matches exist in the database, this method returns an empty list (not null). + *
+ *+ * Usage Example: + *
+ * new Thread(() -> {
+ * List<Match> matches = matchDao.getAllMatches();
+ * runOnUiThread(() -> {
+ * if (matches.isEmpty()) {
+ * showEmptyState();
+ * } else {
+ * displayMatchHistory(matches);
+ * }
+ * });
+ * }).start();
+ *
+ *
+ * + * Suggested Improvements: + * Consider adding an index on the timestamp column for faster sorting: + *
+ * @Entity(tableName = "matches",
+ * indices = {@Index(value = {"timestamp"}, name = "index_timestamp")})
+ *
+ *
+ *
+ * @return A list of all Match entities sorted by timestamp in descending order
+ * (most recent first). Returns an empty list if no matches exist.
+ * Never returns null.
+ * @throws IllegalStateException if called on the main thread (Room's default behavior)
+ * @throws SQLiteException if the database query fails
+ * @see Query
+ * @see Match
+ * @see #getLastMatch()
+ */
+ @Query("SELECT * FROM matches ORDER BY timestamp DESC")
+ List+ * This method is specifically designed for dashboard and recap displays where only + * the last match needs to be shown. It queries the "matches" table and returns + * just the single most recent match based on timestamp. + *
+ *+ * SQL Query: + * Executes: {@code SELECT * FROM matches ORDER BY timestamp DESC LIMIT 1} + *
+ *+ * Query Optimization: + * The {@code LIMIT 1} clause ensures that only one record is retrieved, making this + * significantly more efficient than {@link #getAllMatches()} when you only need the + * last match. The database can stop searching after finding the first result. + *
+ *+ * Threading: + * This operation must be performed on a background thread. Attempting to call this + * on the main thread will result in an exception (unless Room is configured to allow + * main thread queries, which is not recommended). + *
+ *+ * Null Return Value: + * This method returns {@code null} if no matches exist in the database. Callers + * must check for null before using the returned value: + *
+ * Match lastMatch = matchDao.getLastMatch();
+ * if (lastMatch != null) {
+ * // Use the match
+ * } else {
+ * // Show empty state
+ * }
+ *
+ *
+ * + * Usage Example: + *
+ * new Thread(() -> {
+ * Match lastMatch = matchDao.getLastMatch();
+ * runOnUiThread(() -> {
+ * if (lastMatch != null) {
+ * matchRecapView.setMatch(lastMatch);
+ * } else {
+ * matchRecapView.setMatch(null); // Shows empty state
+ * }
+ * });
+ * }).start();
+ *
+ *
+ * + * Use Cases: + *
+ * Performance: + * This is an efficient query due to the {@code LIMIT 1} clause. If the timestamp + * column is indexed, performance will be excellent even with thousands of matches. + *
+ * + * @return The most recent Match entity based on timestamp, or {@code null} if + * no matches exist in the database. + * @throws IllegalStateException if called on the main thread (Room's default behavior) + * @throws SQLiteException if the database query fails + * @see Query + * @see Match + * @see #getAllMatches() + * @see com.aldo.apps.ochecompanion.ui.MatchRecapView#setMatch(com.aldo.apps.ochecompanion.models.Match) + */ + @Query("SELECT * FROM matches ORDER BY timestamp DESC LIMIT 1") + Match getLastMatch(); +} 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 new file mode 100644 index 0000000..e6b0553 --- /dev/null +++ b/app/src/main/java/com/aldo/apps/ochecompanion/database/dao/PlayerDao.java @@ -0,0 +1,457 @@ +package com.aldo.apps.ochecompanion.database.dao; + +import androidx.room.Dao; +import androidx.room.Insert; +import androidx.room.Query; +import androidx.room.Update; + +import com.aldo.apps.ochecompanion.database.objects.Player; + +import java.util.List; + +/** + * Data Access Object (DAO) interface for performing database operations on Player entities. + *+ * This interface defines the contract for accessing and manipulating player data in the + * Room database. It provides comprehensive CRUD (Create, Read, Update) operations for + * managing the squad roster. Room generates the implementation of this interface at + * compile time, handling all SQL generation, cursor management, and data mapping. + *
+ *+ * Room Database Integration: + * The {@code @Dao} annotation marks this as a Room DAO interface. Room's annotation + * processor will automatically generate an implementation class that handles: + *
+ * Key Features: + *
+ * Thread Safety: + * All database operations must be performed on a background thread to avoid blocking + * the main UI thread. Room enforces this requirement by default and will throw an + * {@link IllegalStateException} if database operations are attempted on the main thread + * (unless explicitly configured to allow it, which is not recommended). + *
+ *+ * Usage Example: + *
+ * // Get DAO instance from database
+ * PlayerDao playerDao = AppDatabase.getDatabase(context).playerDao();
+ *
+ * // Insert a new player (background thread)
+ * new Thread(() -> {
+ * Player newPlayer = new Player("John Doe", "/path/to/pic.jpg");
+ * playerDao.insert(newPlayer);
+ * }).start();
+ *
+ * // Query all players (background thread)
+ * new Thread(() -> {
+ * List<Player> players = playerDao.getAllPlayers();
+ * runOnUiThread(() -> updateUI(players));
+ * }).start();
+ *
+ * // Update existing player (background thread)
+ * new Thread(() -> {
+ * Player player = playerDao.getPlayerById(playerId);
+ * player.username = "New Name";
+ * playerDao.update(player);
+ * }).start();
+ *
+ *
+ * + * Database Table: + * This DAO operates on the "players" table, which is defined by the {@link Player} + * entity class with its {@code @Entity} annotation. + *
+ *+ * Missing Operations: + * Note that this DAO does not currently include a DELETE operation. If player deletion + * is required, consider adding: + *
+ * @Delete
+ * void delete(Player player);
+ *
+ * // or
+ * @Query("DELETE FROM players WHERE id = :playerId")
+ * void deleteById(int playerId);
+ *
+ *
+ *
+ * @see Player
+ * @see Dao
+ * @see com.aldo.apps.ochecompanion.database.AppDatabase
+ * @author Oche Companion Development Team
+ * @version 1.0
+ * @since 1.0
+ */
+@Dao
+public interface PlayerDao {
+ /**
+ * Inserts a new Player entity into the database.
+ * + * This method persists a new player to the "players" table, adding them to the + * squad roster. Room will handle the actual SQL INSERT operation and automatically + * generate a primary key ID for the player if the ID field is configured with + * {@code @PrimaryKey(autoGenerate = true)}. + *
+ *+ * Threading: + * This operation must be performed on a background thread. Attempting to call this + * on the main thread will result in an {@link IllegalStateException} (unless Room + * is explicitly configured to allow main thread queries, which is not recommended). + *
+ *+ * Transaction Behavior: + * By default, Room wraps this operation in a database transaction. If the insert + * fails for any reason, the transaction will be rolled back, ensuring database + * consistency and atomicity. + *
+ *+ * Conflict Strategy: + * The default conflict strategy is {@code OnConflictStrategy.ABORT}, which means + * if a conflict occurs (e.g., duplicate primary key), the insert will fail with + * an exception. This can be customized by adding a parameter to the {@code @Insert} + * annotation: + *
+ * @Insert(onConflict = OnConflictStrategy.REPLACE) + * void insert(Player player); + *+ * + *
+ * Auto-Generated ID: + * After insertion, if the Player entity's ID field is auto-generated, the passed + * player object's ID field will be updated with the generated value (assuming the + * ID field is not final). + *
+ *+ * Usage Example: + *
+ * Player newPlayer = new Player("Alice", "/path/to/profile.jpg");
+ * newPlayer.careerAverage = 45.5;
+ *
+ * new Thread(() -> {
+ * try {
+ * playerDao.insert(newPlayer);
+ * // After insert, newPlayer.id will contain the auto-generated ID
+ * Log.d(TAG, "Inserted player with ID: " + newPlayer.id);
+ * } catch (Exception e) {
+ * Log.e(TAG, "Failed to insert player", e);
+ * }
+ * }).start();
+ *
+ *
+ * + * Validation: + * Ensure the player object contains all required fields before insertion: + *
+ * This method modifies an existing player record in the "players" table. Room + * identifies the player to update using the primary key ID field in the provided + * Player object. All fields of the player will be updated to match the provided values. + *
+ *+ * Primary Key Matching: + * Room uses the {@code @PrimaryKey} field (typically "id") to identify which + * database row to update. The player object must have a valid ID that exists in + * the database, or the update will have no effect. + *
+ *+ * Threading: + * This operation must be performed on a background thread. Attempting to call this + * on the main thread will result in an {@link IllegalStateException} (unless Room + * is explicitly configured to allow main thread queries, which is not recommended). + *
+ *+ * Transaction Behavior: + * By default, Room wraps this operation in a database transaction. If the update + * fails, the transaction will be rolled back, preventing partial updates and + * maintaining database consistency. + *
+ *+ * Conflict Strategy: + * The default conflict strategy is {@code OnConflictStrategy.ABORT}. This can be + * customized if needed: + *
+ * @Update(onConflict = OnConflictStrategy.REPLACE) + * void update(Player player); + *+ * + *
+ * Return Value: + * While this method returns void, the {@code @Update} annotation can be configured + * to return {@code int} indicating the number of rows updated (typically 1 for + * successful single-player updates, 0 if no matching player was found). + *
+ *+ * Usage Example: + *
+ * // Typical update flow: query, modify, update
+ * new Thread(() -> {
+ * // First, retrieve the player
+ * Player player = playerDao.getPlayerById(playerId);
+ *
+ * if (player != null) {
+ * // Modify the player's data
+ * player.username = "Updated Name";
+ * player.profilePictureUri = "/path/to/new/pic.jpg";
+ * player.careerAverage = 52.3;
+ *
+ * // Update in database
+ * playerDao.update(player);
+ *
+ * runOnUiThread(() -> {
+ * Toast.makeText(context, "Player updated", Toast.LENGTH_SHORT).show();
+ * });
+ * }
+ * }).start();
+ *
+ *
+ * + * Common Use Cases: + *
+ * This method queries the "players" table for a player with the specified ID. + * It's primarily used when editing player information, as it provides the current + * player data to populate the edit form. + *
+ *+ * SQL Query: + * Executes: {@code SELECT * FROM players WHERE id = :id LIMIT 1} + *
+ *+ * Query Optimization: + * The {@code LIMIT 1} clause ensures that only one record is retrieved, even though + * the ID is a primary key and should be unique. This is a safeguard and optimization + * that tells the database to stop searching after finding the first match. + *
+ *+ * Threading: + * This operation must be performed on a background thread. Attempting to call this + * on the main thread will result in an {@link IllegalStateException} (unless Room + * is explicitly configured to allow main thread queries, which is not recommended). + *
+ *+ * Null Return Value: + * This method returns {@code null} if no player exists with the specified ID. + * Callers must always check for null before using the returned value: + *
+ * Player player = playerDao.getPlayerById(playerId);
+ * if (player != null) {
+ * // Use the player
+ * } else {
+ * // Handle player not found
+ * }
+ *
+ *
+ * + * Usage Example: + *
+ * // Load player for editing
+ * int playerIdToEdit = getIntent().getIntExtra(EXTRA_PLAYER_ID, -1);
+ *
+ * new Thread(() -> {
+ * Player existingPlayer = playerDao.getPlayerById(playerIdToEdit);
+ *
+ * runOnUiThread(() -> {
+ * if (existingPlayer != null) {
+ * // Populate edit form with existing data
+ * usernameEditText.setText(existingPlayer.username);
+ * loadProfilePicture(existingPlayer.profilePictureUri);
+ * } else {
+ * // Player not found - show error or close activity
+ * Toast.makeText(this, "Player not found", Toast.LENGTH_SHORT).show();
+ * finish();
+ * }
+ * });
+ * }).start();
+ *
+ *
+ * + * Common Use Cases: + *
+ * Performance: + * This is an efficient query as it uses the primary key index. Lookup by ID is + * O(log n) or better, making it suitable for frequent calls. + *
+ * + * @param id The unique primary key ID of the player to retrieve. Should be a + * positive integer representing an existing player's ID. + * @return The Player object if found, or {@code null} if no player exists with + * the specified ID. + * @throws IllegalStateException if called on the main thread (Room's default behavior) + * @throws android.database.sqlite.SQLiteException if the database query fails + * @see Query + * @see Player + * @see #update(Player) + * @see com.aldo.apps.ochecompanion.AddPlayerActivity + */ + @Query("SELECT * FROM players WHERE id = :id LIMIT 1") + Player getPlayerById(int id); + + /** + * Retrieves all players from the database, ordered alphabetically by username. + *+ * This method queries the complete player roster from the "players" table and + * returns them sorted alphabetically (A-Z) by username. This provides a consistent, + * user-friendly ordering for displaying the squad in lists and selection interfaces. + *
+ *+ * SQL Query: + * Executes: {@code SELECT * FROM players ORDER BY username ASC} + *
+ *+ * Sorting: + * Players are ordered by the "username" field in ascending alphabetical order (ASC): + *
+ * Threading: + * This operation must be performed on a background thread. Attempting to call this + * on the main thread will result in an {@link IllegalStateException} (unless Room + * is explicitly configured to allow main thread queries, which is not recommended). + *
+ *+ * Performance Considerations: + *
+ * Empty Result: + * If no players exist in the database, this method returns an empty list (not null). + * This makes it safe to iterate without null checking: + *
+ * List<Player> players = playerDao.getAllPlayers();
+ * for (Player player : players) {
+ * // Process each player
+ * }
+ *
+ *
+ * + * Usage Example: + *
+ * // Load squad for display in RecyclerView
+ * new Thread(() -> {
+ * List<Player> allPlayers = playerDao.getAllPlayers();
+ *
+ * runOnUiThread(() -> {
+ * if (allPlayers.isEmpty()) {
+ * // Show empty state - encourage user to add players
+ * showEmptySquadMessage();
+ * } else {
+ * // Update RecyclerView adapter with player list
+ * playerAdapter.updatePlayers(allPlayers);
+ * }
+ * });
+ * }).start();
+ *
+ *
+ * + * Common Use Cases: + *
+ * Alternative Implementations: + * Consider using LiveData for automatic UI updates: + *
+ * @Query("SELECT * FROM players ORDER BY username ASC")
+ * LiveData<List<Player>> getAllPlayersLive();
+ *
+ * Or Flow for Kotlin coroutines:
+ *
+ * @Query("SELECT * FROM players ORDER BY username ASC")
+ * Flow<List<Player>> getAllPlayersFlow();
+ *
+ *
+ * + * Suggested Improvements: + * Consider adding an index on the username column for faster sorting: + *
+ * @Entity(tableName = "players",
+ * indices = {@Index(value = {"username"}, name = "index_username")})
+ *
+ *
+ *
+ * @return A list of all Player entities sorted alphabetically by username in
+ * ascending order (A-Z). Returns an empty list if no players exist.
+ * Never returns null.
+ * @throws IllegalStateException if called on the main thread (Room's default behavior)
+ * @throws android.database.sqlite.SQLiteException if the database query fails
+ * @see Query
+ * @see Player
+ * @see com.aldo.apps.ochecompanion.ui.adapter.MainMenuPlayerAdapter#updatePlayers(List)
+ */
+ @Query("SELECT * FROM players ORDER BY username ASC")
+ List+ * This entity class stores comprehensive information about a finished darts match, + * including the game mode played, completion timestamp, participant count, and + * detailed performance data for all players involved. The Match entity serves as + * the primary record for match history and statistics tracking. + *
+ *+ * Room Database Entity: + * The {@code @Entity} annotation designates this class as a Room database table. + * Match records are stored in the "matches" table and can be queried using + * {@link com.aldo.apps.ochecompanion.database.dao.MatchDao} methods. + *
+ *+ * Serializable Interface: + * This class implements {@link Serializable} to allow Match objects to be passed + * between Android components using Intent extras or Bundle arguments. This is + * useful when navigating to match detail screens or sharing match data between + * activities. + *
+ *+ * Data Structure: + * Match data is stored with the following components: + *
+ * Participant Data Format: + * The {@code participantData} field contains a JSON array with detailed player performance: + *
+ * [
+ * {
+ * "id": 1,
+ * "username": "John Doe",
+ * "rank": 1,
+ * "score": 501,
+ * "average": 92.4,
+ * "highestCheckout": 170
+ * },
+ * {
+ * "id": 2,
+ * "username": "Jane Smith",
+ * "rank": 2,
+ * "score": 420,
+ * "average": 81.0,
+ * "highestCheckout": 120
+ * }
+ * ]
+ *
+ * This structure allows for flexible storage of various statistics while maintaining
+ * database normalization (avoiding separate tables for each match-player relationship).
+ *
+ * + * Game Mode Support: + * The application supports multiple darts game variants: + *
+ * Match Types: + * The system supports different match configurations: + *
+ * Usage Example: + *
+ * // Create a new match record after game completion
+ * String participantJson = buildParticipantJson(players, finalScores, rankings);
+ * Match newMatch = new Match(
+ * System.currentTimeMillis(), // Current time
+ * "501", // Game mode
+ * 2, // Two players
+ * participantJson // Serialized player data
+ * );
+ *
+ * // Insert into database (on background thread)
+ * new Thread(() -> {
+ * matchDao.insert(newMatch);
+ * // After insert, newMatch.id will contain the auto-generated ID
+ * }).start();
+ *
+ * // Query and display matches
+ * new Thread(() -> {
+ * List<Match> recentMatches = matchDao.getAllMatches();
+ * runOnUiThread(() -> updateMatchRecapUI(recentMatches));
+ * }).start();
+ *
+ * // Pass match to detail activity
+ * Intent intent = new Intent(this, MatchDetailActivity.class);
+ * intent.putExtra("match_object", matchToView); // Uses Serializable
+ * startActivity(intent);
+ *
+ *
+ * + * Database Relationships: + * While this entity doesn't use Room's {@code @Relation} annotations, it maintains + * logical relationships through the participant data: + *
+ * Performance Considerations: + *
+ * Data Integrity: + *
+ * Future Enhancements: + * Consider adding these fields for expanded functionality: + *
+ * This field is auto-generated by Room when a new match is inserted into the + * database. The ID is automatically assigned by SQLite's AUTOINCREMENT mechanism, + * ensuring that each match has a unique, sequential identifier. + *
+ *+ * Auto-Generation: + * The {@code @PrimaryKey(autoGenerate = true)} annotation tells Room to: + *
+ * Initial Value: + * Before insertion, this field typically has a value of 0. After the match is + * inserted into the database, Room updates this field with the generated ID + * (usually starting from 1 and incrementing). + *
+ *+ * Usage: + *
+ * Match match = new Match(timestamp, gameMode, playerCount, participantData); + * // match.id is 0 at this point + * + * matchDao.insert(match); + * // match.id now contains the auto-generated value (e.g., 42) + * + * // Use the ID for subsequent operations + * Match retrieved = matchDao.getLastMatch(); + * Log.d(TAG, "Match ID: " + retrieved.id); + *+ * + *
+ * Uniqueness Guarantee: + * SQLite's AUTOINCREMENT ensures that IDs are never reused, even if matches + * are deleted. This prevents conflicts and maintains referential integrity + * if match IDs are stored externally. + *
+ * + * @see PrimaryKey + */ + @PrimaryKey(autoGenerate = true) + public int id; + + /** + * Unix epoch timestamp indicating when this match was completed. + *+ * This field stores the precise moment the match ended, measured in milliseconds + * since January 1, 1970, 00:00:00 UTC (Unix epoch). The timestamp is used for: + *
+ * Format: + * The timestamp is stored as a {@code long} value representing milliseconds. + * This is the standard Java/Android time format obtained via: + *
+ * long timestamp = System.currentTimeMillis(); + *+ * + *
+ * Example Values: + *
+ * Conversion Examples: + *
+ * // Convert to Date object
+ * Date matchDate = new Date(match.timestamp);
+ *
+ * // Format for display
+ * SimpleDateFormat sdf = new SimpleDateFormat("MMM dd, yyyy HH:mm", Locale.getDefault());
+ * String displayDate = sdf.format(matchDate);
+ *
+ * // Calculate time ago
+ * long hoursAgo = (System.currentTimeMillis() - match.timestamp) / (1000 * 60 * 60);
+ *
+ * // Use with Calendar
+ * Calendar calendar = Calendar.getInstance();
+ * calendar.setTimeInMillis(match.timestamp);
+ * int dayOfMonth = calendar.get(Calendar.DAY_OF_MONTH);
+ *
+ *
+ * + * Sorting: + * Matches can be ordered by timestamp to show most recent first: + *
+ * @Query("SELECT * FROM matches ORDER BY timestamp DESC LIMIT 10")
+ * List<Match> getRecentMatches();
+ *
+ *
+ * + * Validation: + * The timestamp should always be: + *
+ * Timezone Considerations: + * While the timestamp is stored in UTC (Unix epoch), display formatting should + * consider the user's local timezone for proper date/time presentation. + *
+ * + * @see System#currentTimeMillis() + * @see java.util.Date + * @see java.text.SimpleDateFormat + */ + public long timestamp; + + /** + * The name or identifier of the game variant that was played in this match. + *+ * This field specifies which darts game mode was used, allowing the application + * to display appropriate statistics, rules, and UI elements for that particular + * game type. The game mode determines scoring rules, win conditions, and the + * overall structure of the match. + *
+ *+ * Supported Game Modes: + *
+ * String Format: + * Game mode strings should be: + *
+ * Recommended Usage: + *
+ * // Define constants for game modes + * public static final String GAME_MODE_501 = "501"; + * public static final String GAME_MODE_301 = "301"; + * public static final String GAME_MODE_CRICKET = "Cricket"; + * + * // Use constants when creating matches + * Match match = new Match( + * System.currentTimeMillis(), + * GAME_MODE_501, // Instead of hardcoded "501" + * 2, + * participantJson + * ); + *+ * + *
+ * UI Adaptation: + * The game mode affects how matches are displayed: + *
+ * switch (match.gameMode) {
+ * case "501":
+ * case "301":
+ * // Show countdown score display
+ * // Highlight checkout attempts
+ * break;
+ * case "Cricket":
+ * // Show cricket scoreboard with marks
+ * // Display closed numbers
+ * break;
+ * case "Around the Clock":
+ * // Show sequential progress indicator
+ * break;
+ * }
+ *
+ *
+ * + * Filtering and Statistics: + * Game mode enables targeted queries and statistics: + *
+ * // Get all 501 matches
+ * @Query("SELECT * FROM matches WHERE gameMode = '501'")
+ * List<Match> get501Matches();
+ *
+ * // Calculate average score by game mode
+ * Map<String, Double> averagesByMode = calculateAveragesByGameMode();
+ *
+ *
+ * + * Extensibility: + * New game modes can be added without schema changes: + *
+ * Validation: + * Consider validating game mode before inserting matches: + *
+ * private static final Set<String> VALID_GAME_MODES = new HashSet<>(
+ * Arrays.asList("501", "301", "Cricket", "Around the Clock", "Killer")
+ * );
+ *
+ * if (!VALID_GAME_MODES.contains(gameMode)) {
+ * throw new IllegalArgumentException("Invalid game mode: " + gameMode);
+ * }
+ *
+ *
+ *
+ * @see com.aldo.apps.ochecompanion.ui.view.MatchRecapView#setMatch(Match)
+ */
+ public String gameMode;
+
+ /**
+ * The total number of players who participated in this match.
+ * + * This field indicates how many individuals competed in the match, which + * determines the match type and affects how the UI displays the results. + * The player count must match the number of player entries in the + * {@link #participantData} JSON array. + *
+ *+ * Valid Range: + *
+ * Match Type Determination: + * The player count affects UI rendering and game logic: + *
+ * if (match.playerCount == 1) {
+ * // Show solo practice UI
+ * // Display personal stats only
+ * // No ranking or comparison
+ * } else if (match.playerCount == 2) {
+ * // Show 1v1 layout (MatchRecapView.setup1v1State)
+ * // Display winner/loser clearly
+ * // Show head-to-head comparison
+ * } else {
+ * // Show group match layout (MatchRecapView.setupGroupState)
+ * // Display ranked list of all players
+ * // Show leaderboard-style results
+ * }
+ *
+ *
+ * + * Data Consistency: + * The player count should always match the participant data: + *
+ * // Validate consistency
+ * JSONArray participants = new JSONArray(match.participantData);
+ * if (participants.length() != match.playerCount) {
+ * Log.w(TAG, "Mismatch: playerCount=" + match.playerCount +
+ * " but participantData has " + participants.length() + " entries");
+ * }
+ *
+ *
+ * + * Performance Implications: + *
+ * Usage in Queries: + *
+ * // Get all 1v1 matches
+ * @Query("SELECT * FROM matches WHERE playerCount = 2")
+ * List<Match> getDuelMatches();
+ *
+ * // Get group matches only
+ * @Query("SELECT * FROM matches WHERE playerCount >= 3")
+ * List<Match> getGroupMatches();
+ *
+ *
+ * + * Statistical Analysis: + * Player count enables performance tracking by match type: + *
+ * // Calculate win rate in 1v1 matches + * double winRate1v1 = calculateWinRate(playerId, 2); + * + * // Calculate average placement in group matches + * double avgPlacement = calculateAveragePlacement(playerId, 3); + *+ * + *
+ * Validation: + * Always validate player count before creating a match: + *
+ * if (playerCount < 1) {
+ * throw new IllegalArgumentException("playerCount must be at least 1");
+ * }
+ * if (playerCount > MAX_PLAYERS) {
+ * throw new IllegalArgumentException("Too many players: " + playerCount);
+ * }
+ *
+ *
+ *
+ * @see com.aldo.apps.ochecompanion.ui.view.MatchRecapView#setup1v1State()
+ * @see com.aldo.apps.ochecompanion.ui.view.MatchRecapView#setupGroupState()
+ */
+ public int playerCount;
+
+ /**
+ * Serialized JSON string containing detailed performance data for all match participants.
+ * + * This field stores comprehensive information about each player's performance in + * the match, including their identity, final ranking, scores, and various statistics. + * Using JSON serialization allows flexible storage of different metrics without + * requiring separate database tables for each match-player relationship. + *
+ *+ * JSON Structure: + * The participant data is stored as a JSON array of player objects: + *
+ * [
+ * {
+ * "id": 1, // Player database ID
+ * "username": "John Doe", // Player display name
+ * "rank": 1, // Final placement (1 = winner)
+ * "score": 501, // Final score or points
+ * "average": 92.4, // Three-dart average
+ * "highestCheckout": 170, // Largest finish
+ * "dartsThrown": 45, // Total darts thrown
+ * "profilePictureUri": "/path/to/pic.jpg" // Profile image
+ * },
+ * {
+ * "id": 2,
+ * "username": "Jane Smith",
+ * "rank": 2,
+ * "score": 420,
+ * "average": 81.0,
+ * "highestCheckout": 120,
+ * "dartsThrown": 51,
+ * "profilePictureUri": "/path/to/pic2.jpg"
+ * }
+ * ]
+ *
+ *
+ * + * Required Fields: + * Each participant object must contain: + *
+ * Optional Fields: + * Additional metrics that may be included: + *
+ * Parsing Example: + *
+ * try {
+ * JSONArray participants = new JSONArray(match.participantData);
+ *
+ * for (int i = 0; i < participants.length(); i++) {
+ * JSONObject player = participants.getJSONObject(i);
+ *
+ * int playerId = player.getInt("id");
+ * String username = player.getString("username");
+ * int rank = player.getInt("rank");
+ * double average = player.optDouble("average", 0.0);
+ *
+ * // Use the data to populate UI
+ * displayPlayerResult(username, rank, average);
+ * }
+ * } catch (JSONException e) {
+ * Log.e(TAG, "Failed to parse participant data", e);
+ * }
+ *
+ *
+ * + * Building Participant Data: + *
+ * // Create JSON array when match ends
+ * JSONArray participants = new JSONArray();
+ *
+ * for (Player player : matchPlayers) {
+ * JSONObject playerData = new JSONObject();
+ * playerData.put("id", player.id);
+ * playerData.put("username", player.username);
+ * playerData.put("rank", player.finalRank);
+ * playerData.put("score", player.finalScore);
+ * playerData.put("average", player.calculateAverage());
+ * playerData.put("highestCheckout", player.highestCheckout);
+ * playerData.put("profilePictureUri", player.profilePictureUri);
+ *
+ * participants.put(playerData);
+ * }
+ *
+ * String participantDataString = participants.toString();
+ * Match match = new Match(timestamp, gameMode, playerCount, participantDataString);
+ *
+ *
+ * + * Why JSON Instead of Relations: + *
+ * Data Integrity: + *
+ * Snapshot Advantage: + * Storing username and profile picture in the match data (rather than just ID) + * preserves the historical record. If a player later changes their username from + * "John Doe" to "JD_Pro", the old match will still show "John Doe" as they were + * known at that time. + *
+ *+ * Sorting Participants: + * Usually pre-sorted by rank before serialization, but can be sorted after parsing: + *
+ * // Sort by rank after parsing + * Collections.sort(playerList, (p1, p2) -> + * Integer.compare(p1.rank, p2.rank)); + *+ * + *
+ * Null Handling: + * This field should never be null. If a match has no valid participant data, + * consider using an empty array "[]" or not creating the match at all. + *
+ * + * @see org.json.JSONArray + * @see org.json.JSONObject + * @see com.aldo.apps.ochecompanion.database.objects.Player + */ + public String participantData; + + /** + * Constructs a new Match entity with the specified parameters. + *+ * This constructor creates a complete match record ready for insertion into the + * database. The match ID will be auto-generated by Room upon insertion; there is + * no need to set it manually. All parameters are required to create a valid match. + *
+ *+ * Constructor Parameters: + * Each parameter serves a specific purpose in defining the match: + *
+ * Usage Example: + *
+ * // Build participant data JSON
+ * JSONArray participants = new JSONArray();
+ * for (Player player : finalStandings) {
+ * JSONObject playerData = new JSONObject();
+ * playerData.put("id", player.id);
+ * playerData.put("username", player.username);
+ * playerData.put("rank", player.rank);
+ * playerData.put("average", player.calculateAverage());
+ * participants.put(playerData);
+ * }
+ *
+ * // Create match object
+ * Match completedMatch = new Match(
+ * System.currentTimeMillis(), // Current time in milliseconds
+ * "501", // Game mode identifier
+ * 2, // Number of players
+ * participants.toString() // Serialized participant data
+ * );
+ *
+ * // Insert into database (background thread required)
+ * new Thread(() -> {
+ * matchDao.insert(completedMatch);
+ * // completedMatch.id will now contain the auto-generated ID
+ * Log.d(TAG, "Match saved with ID: " + completedMatch.id);
+ * }).start();
+ *
+ *
+ * + * Parameter Validation: + * While the constructor doesn't enforce validation, consider checking parameters + * before construction: + *
+ * // Validate before creating match
+ * if (timestamp <= 0) {
+ * throw new IllegalArgumentException("Invalid timestamp");
+ * }
+ * if (gameMode == null || gameMode.isEmpty()) {
+ * throw new IllegalArgumentException("Game mode is required");
+ * }
+ * if (playerCount < 1) {
+ * throw new IllegalArgumentException("At least one player required");
+ * }
+ * if (participantData == null || participantData.isEmpty()) {
+ * throw new IllegalArgumentException("Participant data is required");
+ * }
+ *
+ * // Validate JSON format
+ * try {
+ * JSONArray test = new JSONArray(participantData);
+ * if (test.length() != playerCount) {
+ * throw new IllegalArgumentException("Player count mismatch");
+ * }
+ * } catch (JSONException e) {
+ * throw new IllegalArgumentException("Invalid JSON format", e);
+ * }
+ *
+ * // Create match after validation
+ * Match match = new Match(timestamp, gameMode, playerCount, participantData);
+ *
+ *
+ * + * Field Initialization: + * The constructor initializes all fields except {@code id}: + *
+ * Database Insertion: + * After construction, insert the match using the DAO: + *
+ * // Get DAO instance
+ * MatchDao matchDao = AppDatabase.getDatabase(context).matchDao();
+ *
+ * // Insert on background thread (required by Room)
+ * ExecutorService executor = Executors.newSingleThreadExecutor();
+ * executor.execute(() -> {
+ * matchDao.insert(completedMatch);
+ *
+ * // Update UI on main thread
+ * runOnUiThread(() -> {
+ * Toast.makeText(context, "Match saved!", Toast.LENGTH_SHORT).show();
+ * refreshMatchHistory();
+ * });
+ * });
+ *
+ *
+ * + * Immutability Consideration: + * Once created and inserted, match data should generally be treated as immutable + * (historical record). If corrections are needed, consider whether to: + *
+ * Thread Safety: + * The constructor itself is thread-safe, but database insertion must occur on + * a background thread due to Room's main thread restrictions. + *
+ * + * @param timestamp The Unix epoch timestamp in milliseconds indicating when the match + * was completed. Should be positive and not in the future. Typically + * obtained via {@link System#currentTimeMillis()}. + * @param gameMode The identifier for the darts game variant played (e.g., "501", + * "301", "Cricket"). Should match one of the supported game modes. + * Must not be null or empty. + * @param playerCount The total number of players who participated in the match. + * Must be at least 1. Should match the number of entries in + * the participantData JSON array. + * @param participantData A JSON-formatted string containing an array of player + * performance objects. Each object should include player ID, + * username, rank, and relevant statistics. Must be valid JSON + * and must not be null or empty. + * @see #timestamp + * @see #gameMode + * @see #playerCount + * @see #participantData + * @see com.aldo.apps.ochecompanion.database.dao.MatchDao#insert(Match) + */ + public Match(final long timestamp, final String gameMode, final int playerCount, final String participantData) { + // Initialize all fields with provided values + // The id field remains at default value (0) and will be auto-generated by Room upon insertion + this.timestamp = timestamp; + this.gameMode = gameMode; + this.playerCount = playerCount; + this.participantData = participantData; + } +} 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 new file mode 100644 index 0000000..eff9be9 --- /dev/null +++ b/app/src/main/java/com/aldo/apps/ochecompanion/database/objects/Player.java @@ -0,0 +1,1114 @@ +package com.aldo.apps.ochecompanion.database.objects; + +import android.os.Parcel; +import android.os.Parcelable; + +import androidx.room.Entity; +import androidx.room.PrimaryKey; + +/** + * Represents a player entity in the Oche Companion darts application. + *+ * This entity class stores comprehensive information about each player in the squad, + * including their identity, profile picture, and career statistics. Player objects + * serve as the foundation for roster management, match participation tracking, and + * statistical analysis throughout the application. + *
+ *+ * Room Database Entity: + * The {@code @Entity} annotation designates this class as a Room database table. + * Player records are stored in the "players" table and can be queried, inserted, + * updated, and managed using {@link com.aldo.apps.ochecompanion.database.dao.PlayerDao} + * methods. + *
+ *+ * Data Structure: + * Each player record contains: + *
+ * Player Lifecycle: + *
+ * Usage Example: + *
+ * // Create a new player
+ * Player newPlayer = new Player("John Doe", "/path/to/profile.jpg");
+ * newPlayer.careerAverage = 85.5; // Optional initial stats
+ * newPlayer.matchesPlayed = 0; // Starts at 0 by default
+ *
+ * // Insert into database (background thread required)
+ * new Thread(() -> {
+ * playerDao.insert(newPlayer);
+ * // After insert, newPlayer.id contains the auto-generated ID
+ * Log.d(TAG, "Player created with ID: " + newPlayer.id);
+ * }).start();
+ *
+ * // Query and display players
+ * new Thread(() -> {
+ * List<Player> allPlayers = playerDao.getAllPlayers();
+ * runOnUiThread(() -> updateSquadUI(allPlayers));
+ * }).start();
+ *
+ * // Update player statistics after match
+ * new Thread(() -> {
+ * Player player = playerDao.getPlayerById(playerId);
+ * player.matchesPlayed++;
+ * player.careerAverage = calculateNewAverage(player, matchAverage);
+ * playerDao.update(player);
+ * }).start();
+ *
+ *
+ * + * Profile Picture Management: + * Profile pictures are stored as URI strings pointing to local files in the app's + * private storage. This approach: + *
+ * Career Statistics: + * The {@code careerAverage} field tracks the player's overall performance: + *
+ * Match Count Tracking: + * The {@code matchesPlayed} counter: + *
+ * Database Relationships: + * While this entity doesn't use explicit Room relations, players are referenced in: + *
+ * Thread Safety: + * All database operations on Player objects must be performed on background threads + * to comply with Room's threading requirements. Direct field access is not + * thread-safe; use proper synchronization if accessing from multiple threads. + *
+ *+ * Validation Considerations: + * When creating or updating players, consider validating: + *
+ * Future Enhancements: + * Consider adding these fields for expanded functionality: + *
+ * UI Integration: + * Players are displayed in various UI components: + *
+ * Performance Notes: + *
+ * This field serves as the player's permanent identifier throughout the application + * and is used for all database operations, match participation tracking, and + * cross-referencing with match records. + *
+ *+ * Auto-Generation: + * The {@code @PrimaryKey(autoGenerate = true)} annotation instructs Room to: + *
+ * Initial State: + * Before database insertion, this field has a default value of 0. After the + * player is inserted via {@link com.aldo.apps.ochecompanion.database.dao.PlayerDao#insert(Player)}, + * Room automatically populates this field with the generated ID value. + *
+ *+ * Usage Examples: + *
+ * // Before insertion
+ * Player player = new Player("Alice", "/path/to/pic.jpg");
+ * Log.d(TAG, "ID before insert: " + player.id); // Output: 0
+ *
+ * // Insert into database
+ * playerDao.insert(player);
+ * Log.d(TAG, "ID after insert: " + player.id); // Output: 15 (or next available ID)
+ *
+ * // Use ID to query specific player
+ * Player retrieved = playerDao.getPlayerById(player.id);
+ *
+ * // Use ID in match participant data
+ * JSONObject participantData = new JSONObject();
+ * participantData.put("id", player.id); // Reference this player in match
+ *
+ *
+ * + * Uniqueness and Permanence: + * SQLite's AUTOINCREMENT mechanism ensures: + *
+ * Cross-References: + * The player ID is stored in: + *
+ * Integer Range: + * Using {@code int} supports up to 2,147,483,647 players, which is far beyond + * any realistic squad size. If migrating to multi-user cloud sync in the future, + * consider using {@code long} or UUID strings for globally unique identifiers. + *
+ * + * @see PrimaryKey + * @see com.aldo.apps.ochecompanion.database.dao.PlayerDao#getPlayerById(int) + * @see com.aldo.apps.ochecompanion.database.dao.PlayerDao#insert(Player) + */ + @PrimaryKey(autoGenerate = true) + public int id; + + /** + * The display name of the player shown throughout the application. + *+ * This field stores the player's chosen name or alias, which appears in squad + * lists, player selection screens, match results, leaderboards, and all other + * UI components where the player is identified. The username serves as the + * primary human-readable identifier for each player. + *
+ *+ * Display Locations: + * The username is prominently shown in: + *
+ * Character Guidelines: + * While not enforced by the database schema, usernames should ideally: + *
+ * Validation Example: + *
+ * // Recommended validation before creating player
+ * private boolean isValidUsername(String username) {
+ * if (username == null || username.trim().isEmpty()) {
+ * showError("Username cannot be empty");
+ * return false;
+ * }
+ *
+ * String trimmed = username.trim();
+ * if (trimmed.length() > 30) {
+ * showError("Username too long (max 30 characters)");
+ * return false;
+ * }
+ *
+ * // Optional: Check for duplicate names
+ * if (isUsernameTaken(trimmed)) {
+ * showError("Username already exists in squad");
+ * return false;
+ * }
+ *
+ * return true;
+ * }
+ *
+ * // Use validated username
+ * String validUsername = username.trim();
+ * Player player = new Player(validUsername, profilePicUri);
+ *
+ *
+ * + * Editing Usernames: + * Players can change their username via the edit interface: + *
+ * // Load player for editing + * Player player = playerDao.getPlayerById(playerId); + * player.username = "New Name"; + * playerDao.update(player); + * + * // Note: Historical match records store username snapshots, + * // so past matches will still show the old name + *+ * + *
+ * Database Storage: + * Stored as TEXT in SQLite, supporting any valid UTF-8 string. Room handles + * all string encoding/decoding automatically. Consider adding an index if + * implementing username search: + *
+ * @Entity(tableName = "players",
+ * indices = {@Index(value = {"username"}, name = "index_username")})
+ *
+ *
+ * + * Null Handling: + * This field should never be null in practice. If a player somehow has a null + * username, the UI should display a placeholder like "Unnamed Player" or + * "Player #[ID]" to prevent blank spaces or crashes. + *
+ *+ * Case Sensitivity: + * SQLite comparisons are case-insensitive by default for ASCII characters. + * "John" and "john" are considered different usernames, but queries like + * {@code WHERE username = 'john'} will match "JOHN" or "John" unless + * {@code COLLATE BINARY} is specified. + *
+ *+ * International Support: + * The field fully supports Unicode characters, allowing names in any language: + *
+ * This field stores a string representation of the file path where the player's + * avatar or profile picture is located in the app's private storage. Rather than + * storing image data directly in the database (which would bloat it), we store + * only the path reference and load the image on-demand using image loading + * libraries like Glide. + *
+ *+ * Storage Strategy: + * Profile pictures are stored as local files because: + *
+ * File Path Format: + * The URI typically follows this pattern: + *
+ * /data/data/com.aldo.apps.ochecompanion/files/profile_pictures/player_123_20250128.jpg + *+ * Or uses content URI format: + *
+ * content://media/external/images/media/456 + *+ * + *
+ * Image Creation Flow: + *
+ * // In AddPlayerActivity, after user crops image:
+ * File outputFile = new File(getFilesDir(), "profile_pictures/player_" +
+ * System.currentTimeMillis() + ".jpg");
+ *
+ * // Save cropped bitmap to file
+ * try (FileOutputStream out = new FileOutputStream(outputFile)) {
+ * croppedBitmap.compress(Bitmap.CompressFormat.JPEG, 90, out);
+ * }
+ *
+ * // Store file path in player object
+ * String profilePicUri = outputFile.getAbsolutePath();
+ * Player player = new Player(username, profilePicUri);
+ * playerDao.insert(player);
+ *
+ *
+ * + * Loading Images with Glide: + *
+ * // In PlayerItemView or adapter
+ * if (player.profilePictureUri != null && !player.profilePictureUri.isEmpty()) {
+ * // Load custom profile picture
+ * Glide.with(context)
+ * .load(new File(player.profilePictureUri))
+ * .placeholder(R.drawable.default_avatar)
+ * .error(R.drawable.default_avatar)
+ * .circleCrop()
+ * .into(profileImageView);
+ * } else {
+ * // Show default avatar
+ * profileImageView.setImageResource(R.drawable.default_avatar);
+ * }
+ *
+ *
+ * + * Null and Empty Values: + * This field can be null or empty, indicating the player has no custom profile + * picture. In such cases, the UI should display a default avatar image: + *
+ * File Management: + * Consider these file management practices: + *
+ * File Cleanup Example: + *
+ * // When updating player's profile picture
+ * Player player = playerDao.getPlayerById(playerId);
+ * String oldPicturePath = player.profilePictureUri;
+ *
+ * // Set new picture
+ * player.profilePictureUri = newPicturePath;
+ * playerDao.update(player);
+ *
+ * // Delete old picture file
+ * if (oldPicturePath != null && !oldPicturePath.isEmpty()) {
+ * File oldFile = new File(oldPicturePath);
+ * if (oldFile.exists()) {
+ * oldFile.delete();
+ * }
+ * }
+ *
+ *
+ * + * Image Optimization: + * Profile pictures should be: + *
+ * Migration Consideration: + * If implementing cloud sync in the future, consider: + *
+ * This field represents the player's overall skill level and consistency, + * calculated as the average score per three darts thrown throughout their + * entire match history. In darts, the three-dart average is the standard + * metric for measuring player performance and ability. + *
+ *+ * Calculation Method: + * The career average is typically calculated using one of these approaches: + *
+ * Update Example: + *
+ * // After a match completes, update player's career average + * Player player = playerDao.getPlayerById(playerId); + * + * // Method 1: Simple running average + * double totalPoints = player.careerAverage * player.matchesPlayed; + * totalPoints += matchAverage; // Add this match's average + * player.matchesPlayed++; + * player.careerAverage = totalPoints / player.matchesPlayed; + * + * // Method 2: Weighted average (70% old, 30% new) + * player.careerAverage = (player.careerAverage * 0.7) + (matchAverage * 0.3); + * player.matchesPlayed++; + * + * playerDao.update(player); + *+ * + *
+ * Typical Value Ranges: + * Three-dart averages in darts typically fall within these ranges: + *
+ * Display Format: + *
+ * // Format for display in UI + * String displayAverage = String.format(Locale.getDefault(), "%.1f", + * player.careerAverage); + * // Example output: "85.3" + * + * // Or with context + * String statText = "Average: " + displayAverage + " ppd"; + * // Example output: "Average: 85.3 ppd" (points per dart) + *+ * + *
+ * Default Value: + * Initialized to 0.0 by default, indicating no match history yet. When displaying + * a player with 0.0 average and 0 matches played, consider showing placeholder + * text like "No matches yet" or "N/A" instead of "0.0". + *
+ *+ * Statistical Significance: + * The reliability of the career average increases with match count: + *
+ * if (player.matchesPlayed < 5) {
+ * // Low sample size - show with disclaimer
+ * displayText = player.careerAverage + " (limited data)";
+ * } else if (player.matchesPlayed < 20) {
+ * // Moderate sample size
+ * displayText = player.careerAverage + " (" + player.matchesPlayed + " matches)";
+ * } else {
+ * // Reliable sample size
+ * displayText = String.valueOf(player.careerAverage);
+ * }
+ *
+ *
+ * + * Comparison and Ranking: + * Used to rank players in leaderboards and skill-based groupings: + *
+ * // Sort players by skill level + * List<Player> rankedPlayers = new ArrayList<>(allPlayers); + * Collections.sort(rankedPlayers, (p1, p2) -> + * Double.compare(p2.careerAverage, p1.careerAverage)); // Descending + * + * // Find players in similar skill range for balanced matches + * double targetAverage = 75.0; + * double tolerance = 10.0; + * List<Player> similarSkill = players.stream() + * .filter(p -> Math.abs(p.careerAverage - targetAverage) <= tolerance) + * .collect(Collectors.toList()); + *+ * + *
+ * Data Integrity: + *
+ * Alternative Metrics: + * Future enhancements might track additional averages: + *
+ * This counter tracks how many times the player has participated in and + * finished a match. It provides context for statistical significance, + * enables experience-based features, and supports achievement tracking. + * The count increases by 1 after each match completion, regardless of + * the outcome (win, loss, or placement in group matches). + *
+ *+ * Update Pattern: + *
+ * // After completing a match + * Player player = playerDao.getPlayerById(playerId); + * + * // Increment match count + * player.matchesPlayed++; + * + * // Also update career average at the same time + * player.careerAverage = calculateNewAverage(player, matchAverage); + * + * playerDao.update(player); + *+ * + *
+ * Default Value: + * Initialized to 0 by default, indicating a newly created player who hasn't + * yet participated in any matches. This is appropriate for new squad members + * added through {@link com.aldo.apps.ochecompanion.AddPlayerActivity}. + *
+ *+ * Display Usage: + *
+ * // Show experience level in player profile
+ * String experienceText;
+ * if (player.matchesPlayed == 0) {
+ * experienceText = "New Player";
+ * } else if (player.matchesPlayed == 1) {
+ * experienceText = "1 match played";
+ * } else {
+ * experienceText = player.matchesPlayed + " matches played";
+ * }
+ *
+ * // Use for statistical context
+ * if (player.matchesPlayed < 10) {
+ * showDisclaimer("Statistics based on limited match history");
+ * }
+ *
+ *
+ * + * Experience-Based Features: + * Match count enables various gameplay features: + *
+ * Statistical Significance Example: + *
+ * // Determine if statistics are reliable
+ * public String getSkillReliability(Player player) {
+ * if (player.matchesPlayed == 0) {
+ * return "No data";
+ * } else if (player.matchesPlayed < 5) {
+ * return "Very limited data";
+ * } else if (player.matchesPlayed < 10) {
+ * return "Limited data";
+ * } else if (player.matchesPlayed < 30) {
+ * return "Moderate data";
+ * } else {
+ * return "Reliable data";
+ * }
+ * }
+ *
+ *
+ * + * Filtering and Queries: + *
+ * // Get experienced players only
+ * @Query("SELECT * FROM players WHERE matchesPlayed >= 20 ORDER BY careerAverage DESC")
+ * List<Player> getExperiencedPlayers();
+ *
+ * // Get players who need more matches for stats
+ * @Query("SELECT * FROM players WHERE matchesPlayed < 10")
+ * List<Player> getNewPlayers();
+ *
+ *
+ * + * Data Consistency: + * The match count should align with the player's career average: + *
+ * Validation: + *
+ * // Validate data consistency
+ * if (player.matchesPlayed < 0) {
+ * Log.e(TAG, "Invalid match count: " + player.matchesPlayed);
+ * player.matchesPlayed = 0;
+ * }
+ *
+ * if (player.matchesPlayed == 0 && player.careerAverage != 0.0) {
+ * Log.w(TAG, "Inconsistent data: 0 matches but non-zero average");
+ * player.careerAverage = 0.0;
+ * }
+ *
+ *
+ * + * Match Type Consideration: + * This counter increments regardless of match type: + *
+ * UI Presentation: + * Match count is typically displayed alongside the career average: + *
+ * // In player card or profile
+ * averageText.setText(String.format("%.1f avg", player.careerAverage));
+ * matchesText.setText(player.matchesPlayed + " matches");
+ *
+ * // Or combined
+ * statsText.setText(String.format("%.1f avg • %d matches",
+ * player.careerAverage, player.matchesPlayed));
+ *
+ *
+ * + * Maximum Value: + * Using {@code int} supports up to 2,147,483,647 matches, which is essentially + * unlimited for any realistic usage. Even playing 10 matches per day, it would + * take 588,000+ years to overflow. + *
+ * + * @see #careerAverage + * @see com.aldo.apps.ochecompanion.database.objects.Match + */ + public int matchesPlayed = 0; + + /** + * Constructs a new Player with the specified username and profile picture. + *+ * This constructor creates a player object ready for insertion into the database. + * The player's ID will be auto-generated by Room upon insertion, and statistical + * fields (careerAverage, matchesPlayed) are initialized to their default values + * of 0.0 and 0 respectively. + *
+ *+ * Required Parameters: + *
+ * Usage Examples: + *
+ * // Create player with custom profile picture
+ * String picturePath = "/data/data/app/files/profile_pics/player_123.jpg";
+ * Player player1 = new Player("John Doe", picturePath);
+ *
+ * // Create player without profile picture (will use default avatar)
+ * Player player2 = new Player("Jane Smith", null);
+ *
+ * // Create player with empty string (equivalent to null for display purposes)
+ * Player player3 = new Player("Bob Wilson", "");
+ *
+ * // Insert into database (background thread required)
+ * new Thread(() -> {
+ * playerDao.insert(player1);
+ * // After insertion, player1.id contains auto-generated ID
+ * // careerAverage is 0.0, matchesPlayed is 0
+ * }).start();
+ *
+ *
+ * + * Parameter Validation: + * While the constructor doesn't enforce validation, it's recommended to validate + * parameters before construction: + *
+ * // Validate before creating player
+ * String trimmedName = username.trim();
+ * if (trimmedName.isEmpty()) {
+ * throw new IllegalArgumentException("Username cannot be empty");
+ * }
+ * if (trimmedName.length() > 30) {
+ * throw new IllegalArgumentException("Username too long (max 30 chars)");
+ * }
+ *
+ * // Validate profile picture path if provided
+ * if (profilePictureUri != null && !profilePictureUri.isEmpty()) {
+ * File pictureFile = new File(profilePictureUri);
+ * if (!pictureFile.exists()) {
+ * Log.w(TAG, "Profile picture file does not exist: " + profilePictureUri);
+ * // Decide whether to use null or keep invalid path
+ * }
+ * }
+ *
+ * // Create player with validated data
+ * Player player = new Player(trimmedName, profilePictureUri);
+ *
+ *
+ * + * Field Initialization: + * After construction, the player object has these values: + *
+ * Typical Creation Flow: + *
+ * // In AddPlayerActivity after user completes form
+ *
+ * // 1. Get username from input
+ * String username = usernameEditText.getText().toString().trim();
+ *
+ * // 2. Get profile picture path (or null if no picture selected)
+ * String profilePicUri = (croppedImageFile != null) ?
+ * croppedImageFile.getAbsolutePath() : null;
+ *
+ * // 3. Create player object
+ * final Player newPlayer = new Player(username, profilePicUri);
+ *
+ * // 4. Insert into database on background thread
+ * new Thread(() -> {
+ * playerDao.insert(newPlayer);
+ *
+ * runOnUiThread(() -> {
+ * Toast.makeText(this, "Player added to squad!", Toast.LENGTH_SHORT).show();
+ * finish(); // Return to main menu
+ * });
+ * }).start();
+ *
+ *
+ * + * Alternative Construction Approach: + * For more complex initialization, create and then modify: + *
+ * // Create base player
+ * Player player = new Player("Alice Johnson", profilePath);
+ *
+ * // Set initial statistics if importing from another system
+ * player.careerAverage = 78.5; // Imported average
+ * player.matchesPlayed = 42; // Imported match count
+ *
+ * // Insert with pre-populated stats
+ * playerDao.insert(player);
+ *
+ *
+ * + * Null Handling: + * The constructor accepts null for profilePictureUri, which is valid and indicates + * no custom profile picture. The UI should display a default avatar in this case. + * However, passing null for username will create a player with a null name, which + * should be avoided as it causes UI issues. + *
+ *+ * Database Insertion: + * Remember that the Player object is not persisted until explicitly inserted: + *
+ * Player player = new Player("Tom", null);
+ * // player exists only in memory at this point
+ *
+ * playerDao.insert(player);
+ * // now player is persisted in database and player.id is set
+ *
+ *
+ * + * Room Constructor Requirements: + * Room requires this constructor to instantiate Player objects when reading from + * the database. The constructor parameters must match the entity fields (excluding + * the auto-generated ID and default-initialized fields). + *
+ * + * @param username The display name for the player. Should be non-null, non-empty, + * and ideally 1-30 characters. Supports Unicode for international + * names. This will be shown throughout the app in player lists, + * match results, and statistics. + * @param profilePictureUri The file system path or content URI to the player's + * profile picture. Can be null or empty string, in which + * case the UI will display a default avatar. Should point + * to a valid image file if provided. + * @see #id + * @see #username + * @see #profilePictureUri + * @see #careerAverage + * @see #matchesPlayed + * @see com.aldo.apps.ochecompanion.AddPlayerActivity + * @see com.aldo.apps.ochecompanion.database.dao.PlayerDao#insert(Player) + */ + public Player(final String username, final String profilePictureUri) { + // Initialize the player's identity fields with provided values + // Statistical fields (careerAverage, matchesPlayed) use their default values (0.0 and 0) + // The id field remains 0 and will be auto-generated by Room upon database insertion + this.username = username; + this.profilePictureUri = profilePictureUri; + } + + + + /** + * Parcelable constructor used by the CREATOR to reconstruct the object. + * @param in The Parcel containing the serialized data. + */ + protected Player(final Parcel in) { + id = in.readInt(); + username = in.readString(); + profilePictureUri = in.readString(); + careerAverage = in.readDouble(); + matchesPlayed = in.readInt(); + } + + /** + * Required CREATOR field for Parcelable implementation. + */ + public static final Creator+ * This method provides a human-readable representation of the player's state, + * including all field values. It's primarily used for debugging, logging, and + * development purposes to quickly inspect player data without a debugger. + *
+ *+ * Output Format: + * The returned string follows this pattern: + *
+ * Player{id=15, username='John Doe', profilePictureUri='/path/to/pic.jpg', careerAverage=85.3, matchesPlayed=42}
+ *
+ *
+ * + * Usage Examples: + *
+ * // Logging player creation
+ * Player player = new Player("Alice", "/path/to/pic.jpg");
+ * Log.d(TAG, "Created: " + player.toString());
+ * // Output: Player{id=0, username='Alice', profilePictureUri='/path/to/pic.jpg', careerAverage=0.0, matchesPlayed=0}
+ *
+ * // Debugging database queries
+ * List<Player> players = playerDao.getAllPlayers();
+ * for (Player p : players) {
+ * Log.d(TAG, p.toString());
+ * }
+ *
+ * // Quick inspection during development
+ * System.out.println(player); // Implicitly calls toString()
+ *
+ * // Logging state changes
+ * Log.d(TAG, "Before update: " + player);
+ * player.careerAverage = 90.0;
+ * Log.d(TAG, "After update: " + player);
+ *
+ *
+ * + * Field Inclusion: + * All fields are included in the string representation: + *
+ * Null Handling: + * If profilePictureUri is null, it will be displayed as the literal string "null": + *
+ * Player{id=5, username='Bob', profilePictureUri='null', careerAverage=0.0, matchesPlayed=0}
+ *
+ *
+ * + * Not for UI Display: + * This method is NOT intended for user-facing text. For UI display, format + * individual fields appropriately: + *
+ * // DON'T use toString() in UI
+ * textView.setText(player.toString()); // Shows debug format
+ *
+ * // DO format for UI display
+ * textView.setText(player.username);
+ * statsText.setText(String.format("%.1f avg • %d matches",
+ * player.careerAverage, player.matchesPlayed));
+ *
+ *
+ * + * Logging Best Practices: + *
+ * // Verbose logging for detailed debugging
+ * Log.v(TAG, "Player loaded: " + player);
+ *
+ * // Debug logging for development
+ * Log.d(TAG, "Updated player stats: " + player);
+ *
+ * // Error logging with context
+ * Log.e(TAG, "Failed to save player: " + player, exception);
+ *
+ * // Conditional logging
+ * if (BuildConfig.DEBUG) {
+ * Log.d(TAG, "All players: " + players.toString());
+ * }
+ *
+ *
+ * + * Performance Note: + * String concatenation in toString() creates temporary objects. Avoid calling + * toString() in performance-critical loops unless necessary. For production builds, + * consider using Timber or similar libraries that can be disabled in release builds. + *
+ *+ * Privacy Consideration: + * Be cautious when logging player data in production: + *
+ * This class serves as a data container for match information in the Oche Companion app, + * maintaining a collection of players participating in a single darts game session. + * It provides convenient methods for accessing player information by position and + * retrieving match statistics. + *
+ *+ * Key Features: + *
+ * Match Types: + * This class supports different match configurations: + *
+ * Player Ordering: + * Players are stored in the order they are added. This ordering is significant for: + *
+ * Usage Examples: + *
+ * // Create a 1v1 match + * Match match = new Match(player1, player2); + * + * // Create a group match + * Match groupMatch = new Match(player1, player2, player3, player4); + * + * // Create an empty match and add players later + * Match emptyMatch = new Match(); + * // (Note: No public add method currently, consider adding if needed) + *+ * + *
+ * Design Notes: + *
+ * Players are stored in the order they were added during match creation. + * This ordering is preserved and can be used for position-based queries + * via {@link #getPlayerNameByPosition(int)} and {@link #getPlayerAverageByPosition(int)}. + *
+ *+ * The list is initialized as an ArrayList to provide efficient random access + * by index, which is the primary access pattern for this class. + *
+ *+ * Immutability Note: + * While the list reference is final, the list contents are mutable. However, + * no public methods currently allow modification after construction. + *
+ * + * @see #Match(Player...) + * @see #getAllPlayers() + * @see #getParticipantCount() + */ + private final List+ * This constructor creates a new match instance with an empty player list. + * It's useful for scenarios where players will be added dynamically later, + * or for placeholder/initialization purposes. + *
+ *+ * Usage Scenarios: + *
+ * Note: + * Currently, there are no public methods to add players after construction. + * Consider using {@link #Match(Player...)} if players are known at creation time. + *
+ *+ * A debug log message is generated when an empty match is created. + *
+ * + * @see #Match(Player...) + */ + public Match() { + // Initialize empty player list + mPlayers = new ArrayList<>(); + // Log creation for debugging purposes + Log.d(TAG, "Match: Creating new empty match."); + } + + /** + * Constructs a Match with the specified players. + *+ * This constructor creates a new match instance and populates it with the provided + * players. The players are added in the order they appear in the parameter list, + * which establishes their position indices for future queries. + *
+ *+ * Varargs Convenience: + * The varargs parameter allows flexible calling patterns: + *
+ * // 1v1 match
+ * Match match1 = new Match(player1, player2);
+ *
+ * // Group match
+ * Match match2 = new Match(player1, player2, player3, player4);
+ *
+ * // Single player (if supported)
+ * Match match3 = new Match(player1);
+ *
+ * // From array
+ * Player[] playerArray = {player1, player2, player3};
+ * Match match4 = new Match(playerArray);
+ *
+ *
+ * + * Player Ordering: + * Players are stored in the exact order they are passed. For example: + *
+ * Logging: + * Each player addition is logged at debug level for troubleshooting and verification. + *
+ *+ * Null Safety: + * This constructor does not explicitly check for null players. Callers should ensure + * all provided Player objects are non-null to avoid NullPointerExceptions in + * subsequent operations. + *
+ * + * @param players Variable number of Player objects to participate in the match. + * Can be empty (equivalent to calling {@link #Match()}), + * but typically contains 2 or more players for competitive games. + * Players should not be null. + * @see Player + * @see #Match() + * @see #getParticipantCount() + */ + public Match(final Player... players) { + // Initialize empty player list + mPlayers = new ArrayList<>(); + + // Add each player to the match in order + for (final Player player : players) { + // Log the addition for debugging + Log.d(TAG, "Match: Adding [" + player + "]"); + // Add player to the internal list + mPlayers.add(player); + } + } + + /** + * Returns the number of players participating in this match. + *+ * This method provides the count of players currently registered for the match. + * The count corresponds to the number of players added during match construction. + *
+ *+ * Usage Examples: + *
+ * Match match1v1 = new Match(player1, player2); + * int count1 = match1v1.getParticipantCount(); // Returns 2 + * + * Match groupMatch = new Match(p1, p2, p3, p4); + * int count2 = groupMatch.getParticipantCount(); // Returns 4 + * + * Match emptyMatch = new Match(); + * int count3 = emptyMatch.getParticipantCount(); // Returns 0 + *+ * + *
+ * Use Cases: + * This method is commonly used to: + *
+ * This method provides position-based access to player names, useful for displaying + * players in specific UI locations (e.g., player 1 on the left, player 2 on the right + * in a 1v1 display). + *
+ *+ * Position Indexing: + * Positions are zero-based indices: + *
+ * Bounds Checking: + * The method includes basic bounds validation. If the position is out of range + * (negative or greater than the number of players), it returns "INVALID" rather + * than throwing an exception. + *
+ *+ * Note on Bounds Check: + * The current implementation has a potential bug: the condition {@code position <= mPlayers.size()} + * should likely be {@code position < mPlayers.size()} since valid indices are 0 to (size-1). + * The current code may throw an IndexOutOfBoundsException when position equals size. + *
+ *+ * Usage Example: + *
+ * Match match = new Match(player1, player2, player3); + * String name0 = match.getPlayerNameByPosition(0); // Returns player1.username + * String name1 = match.getPlayerNameByPosition(1); // Returns player2.username + * String name2 = match.getPlayerNameByPosition(2); // Returns player3.username + * String invalid = match.getPlayerNameByPosition(3); // Returns "INVALID" + *+ * + * + * @param position The zero-based index of the player in the match. + * Should be in the range [0, participantCount). + * @return The username of the player at the specified position, or "INVALID" + * if the position is out of bounds. + * @see Player#username + * @see #getPlayerAverageByPosition(int) + * @see #getParticipantCount() + */ + public String getPlayerNameByPosition(final int position) { + // Validate position is within bounds + // Note: Consider changing <= to < to prevent IndexOutOfBoundsException + if (position >= 0 && position <= mPlayers.size()) { + // Return the username of the player at this position + return mPlayers.get(position).username; + } + // Return sentinel value for invalid position + return "INVALID"; + } + + /** + * Retrieves the career average of the player at the specified position. + *
+ * This method provides position-based access to player career statistics, useful for + * displaying performance metrics in match summaries and leaderboards. + *
+ *+ * Position Indexing: + * Positions are zero-based indices: + *
+ * Bounds Checking: + * The method includes basic bounds validation. If the position is out of range + * (negative or greater than the number of players), it returns -1 as a sentinel + * value rather than throwing an exception. + *
+ *+ * Note on Bounds Check: + * The current implementation has a potential bug: the condition {@code position <= mPlayers.size()} + * should likely be {@code position < mPlayers.size()} since valid indices are 0 to (size-1). + * The current code may throw an IndexOutOfBoundsException when position equals size. + *
+ *+ * Career Average: + * The returned value represents the player's career average score across all matches + * they've played, as stored in {@link Player#careerAverage}. This is typically + * calculated and updated by other parts of the application. + *
+ *+ * Usage Example: + *
+ * Match match = new Match(player1, player2, player3); + * double avg0 = match.getPlayerAverageByPosition(0); // Returns player1.careerAverage + * double avg1 = match.getPlayerAverageByPosition(1); // Returns player2.careerAverage + * double avg2 = match.getPlayerAverageByPosition(2); // Returns player3.careerAverage + * double invalid = match.getPlayerAverageByPosition(3); // Returns -1 + *+ * + * + * @param position The zero-based index of the player in the match. + * Should be in the range [0, participantCount). + * @return The career average of the player at the specified position, or -1 + * if the position is out of bounds. The average is a double value + * representing the player's historical performance. + * @see Player#careerAverage + * @see #getPlayerNameByPosition(int) + * @see #getParticipantCount() + */ + public double getPlayerAverageByPosition(final int position) { + // Validate position is within bounds + // Note: Consider changing <= to < to prevent IndexOutOfBoundsException + if (position >= 0 && position <= mPlayers.size()) { + // Return the career average of the player at this position + return mPlayers.get(position).careerAverage; + } + // Return sentinel value for invalid position + return -1; + } + + /** + * Returns a direct reference to the internal list of all players in this match. + *
+ * This method provides access to the complete player list, useful for operations + * that need to process all players (e.g., sorting, filtering, bulk display). + *
+ *+ * List Contents: + * The returned list contains players in the order they were added during match + * construction. The list is the same instance used internally by this Match object. + *
+ *+ * Mutability Warning: + * This method returns a direct reference to the internal list, not a copy. + * Modifications to the returned list will affect the match's internal state: + *
+ * List<Player> players = match.getAllPlayers(); + * players.clear(); // WARNING: This clears the match's player list! + *+ * If you need to modify the list without affecting the match, create a copy: + *
+ * List<Player> playersCopy = new ArrayList<>(match.getAllPlayers()); + * playersCopy.clear(); // Safe: Only affects the copy + *+ * + *
+ * Common Use Cases: + *
+ * Performance: + * This is an O(1) operation as it returns a reference, not a copy. + *
+ * + * @return A direct reference to the list of all Player objects in this match. + * The list maintains the order in which players were added. + * Never null, but may be empty if no players were added. + * @see Player + * @see #getParticipantCount() + * @see com.aldo.apps.ochecompanion.ui.adapter.MainMenuGroupMatchAdapter#updateMatch(Match) + */ + public List+ * This method generates a human-readable string that includes information about + * all players participating in the match. The format is designed to be concise + * yet informative for debugging purposes. + *
+ *+ * Output Format: + * The generated string follows this pattern: + *
+ * Match {[Player1][Player2][Player3]]
+ *
+ * Each player is represented by its own {@link Player#toString()} output,
+ * wrapped in square brackets.
+ *
+ * + * Example Output: + *
+ * Match {[Player{name='Alice', avg=45.5}][Player{name='Bob', avg=52.3}]]
+ *
+ *
+ * + * Note on Formatting: + * The method includes an extra closing bracket at the end, which appears to be + * unintentional. The string ends with "]]" instead of "}". Consider changing + * the final append from "].append("]")} to just "}") for proper bracket matching. + *
+ *+ * Performance: + * Uses {@link StringBuilder} for efficient string concatenation, which is important + * when the match contains many players. + *
+ *+ * Usage: + * This method is automatically called when: + *
+ * This view is designed for use in the Oche Companion app's image cropping interface, + * specifically within the {@link com.aldo.apps.ochecompanion.AddPlayerActivity}. + * It creates a semi-transparent dark overlay across the entire view with a transparent + * square "window" in the center, allowing users to see exactly which portion of their + * image will be captured as their profile picture. + *
+ *+ * Visual Design: + *
+ * Technical Implementation: + * The overlay uses a {@link Path} with clockwise and counter-clockwise rectangles to create + * a "hole punch" effect. The outer rectangle (entire view) is drawn clockwise, while the + * inner rectangle (crop area) is drawn counter-clockwise. This winding direction technique + * causes the inner rectangle to subtract from the outer one, creating a transparent window. + *
+ *+ * Usage: + * This view is typically overlaid on top of an {@link android.widget.ImageView} in crop mode. + * The parent activity can retrieve the crop rectangle coordinates via {@link #getCropRect()} + * to perform the actual pixel-level cropping calculations. + *
+ * + * @see com.aldo.apps.ochecompanion.AddPlayerActivity + * @see Path + * @see RectF + * @author Oche Companion Development Team + * @version 1.0 + * @since 1.0 + */ +public class CropOverlayView extends View { + + /** + * Paint object for rendering the semi-transparent dark overlay mask. + *+ * Configured with: + *
+ * This rectangle represents the "window" through which the user sees the unobscured + * portion of their image. The coordinates are calculated in {@link #onLayout(boolean, int, int, int, int)} + * and are used both for drawing the overlay and for providing crop coordinates to the + * parent activity. + *
+ *+ * The rectangle dimensions are: + *
+ * This path consists of two rectangles: + *
+ * The opposing winding directions create a "hole punch" effect where the inner + * rectangle subtracts from the outer one, resulting in a transparent window. + * This technique leverages Android's path fill-type rules (even-odd or winding). + *
+ *+ * The path is recalculated whenever the view's layout changes to ensure proper + * sizing and positioning. + *
+ * + * @see Path.Direction + * @see #onLayout(boolean, int, int, int, int) + */ + private final Path mPath = new Path(); + + /** + * The calculated side length of the square crop box in pixels. + *+ * This value is computed in {@link #onLayout(boolean, int, int, int, int)} as + * 80% of the view's width. It's stored for potential reuse and to maintain + * consistency between layout calculations. + *
+ *+ * The 80% size ensures adequate padding around the crop area while maximizing + * the useful cropping space. + *
+ */ + private float mBoxSize; + + /** + * Constructor for programmatic instantiation of the CropOverlayView. + *+ * This constructor is used when creating the view directly in Java/Kotlin code + * rather than inflating from XML. It initializes the view and configures all + * necessary paint and drawing resources. + *
+ * + * @param context The Context in which the view is running, through which it can + * access the current theme, resources, etc. + * @see #init() + */ + public CropOverlayView(Context context) { + super(context); + init(); + } + + /** + * Constructor for XML inflation of the CropOverlayView. + *+ * This constructor is called when the view is inflated from an XML layout file. + * It allows the view to be defined declaratively in layout resources. The AttributeSet + * parameter provides access to any XML attributes defined for this view. + *
+ * + * @param context The Context in which the view is running, through which it can + * access the current theme, resources, etc. + * @param attrs The attributes of the XML tag that is inflating the view. May be null + * if no attributes are specified. + * @see #init() + */ + public CropOverlayView(Context context, @Nullable AttributeSet attrs) { + super(context, attrs); + init(); + } + + /** + * Constructor for XML inflation of the CropOverlayView with a specific style. + *+ * This constructor is called when the view is inflated from an XML layout file + * with a style attribute. It allows for theme-based customization of the view's + * appearance, though this view currently uses hardcoded visual properties. + *
+ * + * @param context The Context in which the view is running, through which it can + * access the current theme, resources, etc. + * @param attrs The attributes of the XML tag that is inflating the view. May be null + * if no attributes are specified. + * @param defStyleAttr An attribute in the current theme that contains a reference to + * a style resource that supplies default values for the view. + * Can be 0 to not look for defaults. + * @see #init() + */ + public CropOverlayView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + init(); + } + + /** + * Initializes the Paint object used for rendering the overlay mask. + *+ * This method configures the visual properties of the semi-transparent overlay + * that will darken the non-crop area of the image. The configuration includes: + *
+ * Color Choice Rationale: + * The 85% opacity (0xD9) provides strong contrast to highlight the crop area + * while maintaining enough transparency for users to see the portions of the + * image that will be cropped out. This helps with precise positioning. + *
+ *+ * This method is called by all constructors to ensure consistent initialization + * regardless of how the view is instantiated. + *
+ * + * @see Paint.Style#FILL + */ + private void init() { + // Set darkened background color: Midnight Black (#0A0A0A) at 85% opacity + // Alpha value 0xD9 = 217/255 ≈ 85% + mMaskPaint.setColor(Color.parseColor("#D90A0A0A")); + + // Use FILL style to cover the entire path area (except the hole) + mMaskPaint.setStyle(Paint.Style.FILL); + } + + /** + * Called when the view's size or position changes to recalculate the crop area. + *+ * This method is responsible for: + *
+ * Crop Box Sizing: + * The crop box is sized at 80% of the view's width to provide: + *
+ * Path Construction: + * The path is built with two rectangles using opposite winding directions: + *
+ * This method is called by the Android framework whenever the view needs to be drawn. + * It draws the pre-calculated path that contains the full-screen mask with a + * transparent square cutout in the center. + *
+ *+ * Rendering Process: + * The single {@link Canvas#drawPath(Path, Paint)} call efficiently renders both: + *
+ * Performance: + * The path is pre-calculated in {@link #onLayout(boolean, int, int, int, int)} rather + * than being recalculated on every draw call, ensuring smooth rendering performance. + *
+ * + * @param canvas The Canvas on which the view will be drawn. This canvas is provided + * by the Android framework and is used to draw the overlay mask. + * @see #onLayout(boolean, int, int, int, int) + * @see Canvas#drawPath(Path, Paint) + */ + @Override + protected void onDraw(Canvas canvas) { + super.onDraw(canvas); + + // Draw the path which contains the full screen minus the square cutout + // The path was pre-calculated in onLayout() for performance + canvas.drawPath(mPath, mMaskPaint); + } + + /** + * Provides the coordinates of the crop box for pixel-level cropping calculations. + *+ * This method returns the rectangle that defines the transparent crop area in screen + * coordinates. The parent activity (typically {@link com.aldo.apps.ochecompanion.AddPlayerActivity}) + * uses these coordinates to calculate which pixels from the source image should be + * extracted for the cropped result. + *
+ *+ * Coordinate System: + * The returned RectF contains screen coordinates relative to this view: + *
+ * Usage: + * The parent activity must transform these screen coordinates to bitmap pixel coordinates + * by accounting for: + *
+ * This view is designed to provide users with a quick overview of their last game session + * in the Oche Companion app. It intelligently adapts its display based on the match type + * and data availability, supporting three distinct visual states. + *
+ *+ * Supported States: + *
+ * Key Features: + *
+ * Usage: + * This view is typically used in the Main Menu activity to display the most recent match. + * The parent activity calls {@link #setMatch(Match)} to update the display whenever the + * match data changes or when the activity resumes. + *
+ * + * @see Match + * @see MainMenuGroupMatchAdapter + * @see FrameLayout + * @author Oche Companion Development Team + * @version 1.0 + * @since 1.0 + */ +public class MatchRecapView extends FrameLayout { + + /** + * View container for the empty state display. + *+ * This view is shown when no match history exists in the database. + * It typically contains placeholder content, empty state illustrations, + * or encouraging messages to prompt users to start their first game. + *
+ *+ * Visibility is managed by {@link #updateVisibility(View)} to ensure + * only one state is visible at a time. + *
+ * + * @see #setMatch(Match) + * @see #updateVisibility(View) + */ + private View stateEmpty; + + /** + * View container for the 1v1 match state display. + *+ * This view is shown when the last match was a head-to-head game between + * exactly two players. It presents a side-by-side comparison showing both + * players' names and their respective scores. + *
+ *+ * Visibility is managed by {@link #updateVisibility(View)} to ensure + * only one state is visible at a time. + *
+ * + * @see #setup1v1State(Match) + * @see #updateVisibility(View) + */ + private View state1v1; + + /** + * View container for the group match state display. + *+ * This view is shown when the last match involved 3 or more players. + * It contains a RecyclerView that displays a mini-leaderboard with all + * participants sorted by their performance. + *
+ *+ * Visibility is managed by {@link #updateVisibility(View)} to ensure + * only one state is visible at a time. + *
+ * + * @see #setupGroupState(Match) + * @see #updateVisibility(View) + */ + private View stateGroup; + + // ========== 1v1 View References ========== + + /** + * TextView displaying the name of the first player in a 1v1 match. + * Used only in the 1v1 state. + */ + private TextView tvP1Name; + + /** + * TextView displaying the name of the second player in a 1v1 match. + * Used only in the 1v1 state. + */ + private TextView tvP2Name; + + /** + * TextView displaying the score/average of the first player in a 1v1 match. + * Used only in the 1v1 state. + */ + private TextView tvP1Score; + + /** + * TextView displaying the score/average of the second player in a 1v1 match. + * Used only in the 1v1 state. + */ + private TextView tvP2Score; + + // ========== Group View References ========== + + /** + * RecyclerView displaying the leaderboard for group matches. + *+ * This RecyclerView is configured with a {@link LinearLayoutManager} and uses + * a {@link MainMenuGroupMatchAdapter} to display all participants in the match, + * sorted by their performance scores. + *
+ *+ * Used only in the group state. + *
+ * + * @see MainMenuGroupMatchAdapter + * @see #setupGroupState(Match) + */ + private RecyclerView rvLeaderboard; + + /** + * Constructor for programmatic instantiation of the MatchRecapView. + *+ * This constructor delegates to the two-parameter constructor with a null + * AttributeSet, which in turn inflates the layout and initializes all child views. + *
+ * + * @param context The Context in which the view is running, through which it can + * access the current theme, resources, etc. + * @see #MatchRecapView(Context, AttributeSet) + */ + public MatchRecapView(@NonNull final Context context) { + this(context, null); + } + + /** + * Constructor for XML inflation of the MatchRecapView. + *+ * This constructor is called when the view is inflated from an XML layout file. + * It performs the following initialization: + *
+ * After construction, the view defaults to showing no content until + * {@link #setMatch(Match)} is called with valid match data. + *
+ * + * @param context The Context in which the view is running, through which it can + * access the current theme, resources, etc. + * @param attrs The attributes of the XML tag that is inflating the view. May be null + * if no attributes are specified. + * @see #initViews() + */ + public MatchRecapView(@NonNull final Context context, @Nullable final AttributeSet attrs) { + super(context, attrs); + // Inflate the layout for this composite view + inflate(context, R.layout.view_match_recap, this); + // Initialize all child view references + initViews(); + } + + /** + * Initializes references to all child views within the inflated layout. + *+ * This method retrieves and stores references to all UI components needed for + * the three different states: + *
+ * All views must exist in the R.layout.view_match_recap layout file, + * otherwise this method will throw a NullPointerException. + *
+ *+ * This method is called once during construction and does not need to be + * called again during the view's lifecycle. + *
+ * + * @see #MatchRecapView(Context, AttributeSet) + */ + private void initViews() { + // Initialize state container references + stateEmpty = findViewById(R.id.stateEmpty); + state1v1 = findViewById(R.id.state1v1); + stateGroup = findViewById(R.id.stateGroup); + + // Initialize 1v1 match view references + tvP1Name = findViewById(R.id.tvP1Name); + tvP1Score = findViewById(R.id.tvP1Score); + tvP2Name = findViewById(R.id.tvP2Name); + tvP2Score = findViewById(R.id.tvP2Score); + + // Initialize group match view references + rvLeaderboard = findViewById(R.id.rvLeaderboard); + } + + /** + * Binds a Match object to the view and updates the display accordingly. + *+ * This is the main entry point for updating the view's content. It analyzes the + * provided match data and automatically selects the appropriate state to display: + *
+ * State Selection Logic: + * The method first checks for null, then examines the participant count to determine + * which state is appropriate. Each state has its own setup method that handles the + * specific data binding and view configuration. + *
+ *+ * Usage: + * This method should be called whenever the match data changes, such as: + *
+ * This method sets up the view for displaying a head-to-head match between two players. + * It performs the following operations: + *
+ * Data Retrieval: + * Player information is retrieved by position index rather than by ID, assuming + * the match stores players in a predictable order. Position 0 corresponds to the + * first player (typically displayed on the left), and position 1 corresponds to + * the second player (typically displayed on the right). + *
+ *+ * Assumptions: + * This method assumes the match contains exactly 2 players. The caller + * ({@link #setMatch(Match)}) should verify the participant count before calling this method. + *
+ * + * @param match The Match object containing exactly 2 players. Must not be null. + * @see Match#getPlayerNameByPosition(int) + * @see Match#getPlayerAverageByPosition(int) + * @see #updateVisibility(View) + */ + private void setup1v1State(final Match match) { + // Switch to 1v1 state visibility + updateVisibility(state1v1); + + // Populate player 1 information (left side) + tvP1Name.setText(match.getPlayerNameByPosition(0)); + tvP1Score.setText(String.valueOf(match.getPlayerAverageByPosition(0))); + + // Populate player 2 information (right side) + tvP2Name.setText(match.getPlayerNameByPosition(1)); + tvP2Score.setText(String.valueOf(match.getPlayerAverageByPosition(1))); + } + + /** + * Configures and displays the group match state with a leaderboard. + *+ * This method sets up the view for displaying a match with 3 or more players. + * It performs the following operations: + *
+ * RecyclerView Configuration: + * The RecyclerView is configured with a {@link LinearLayoutManager} in vertical + * orientation, displaying players in a scrollable list. Each player entry shows + * their name, score/average, and profile picture. + *
+ *+ * Adapter Behavior: + * The {@link MainMenuGroupMatchAdapter} automatically sorts players by their + * career average when the match data is provided, displaying them in ascending + * order (lowest to highest scores). + *
+ *+ * Performance Note: + * A new adapter instance is created each time this method is called. For better + * performance in scenarios with frequent updates, consider reusing the adapter + * and calling only {@link MainMenuGroupMatchAdapter#updateMatch(Match)}. + *
+ * + * @param match The Match object containing 3 or more players. Must not be null. + * @see MainMenuGroupMatchAdapter + * @see LinearLayoutManager + * @see #updateVisibility(View) + */ + private void setupGroupState(final Match match) { + // Switch to group state visibility + updateVisibility(stateGroup); + + // Configure the RecyclerView with a vertical LinearLayoutManager + rvLeaderboard.setLayoutManager(new LinearLayoutManager(getContext())); + + // Create and configure the adapter for displaying the player leaderboard + final MainMenuGroupMatchAdapter adapter = new MainMenuGroupMatchAdapter(); + rvLeaderboard.setAdapter(adapter); + + // Populate the adapter with match data (players will be sorted automatically) + adapter.updateMatch(match); + } + + /** + * Updates the visibility of all state containers, showing only the specified active view. + *+ * This method implements a mutually exclusive visibility pattern, ensuring that only + * one state container is visible at any given time. It iterates through all three state + * containers (empty, 1v1, group) and sets each one to either VISIBLE or GONE based on + * whether it matches the activeView parameter. + *
+ *+ * Visibility Logic: + *
+ * Why GONE instead of INVISIBLE: + * Using {@link View#GONE} rather than {@link View#INVISIBLE} ensures that hidden + * states don't occupy any layout space, resulting in cleaner rendering and better + * performance. + *
+ *+ * This centralized visibility management prevents inconsistent states where multiple + * containers might be visible simultaneously. + *
+ * + * @param activeView The view container that should be made visible. Must be one of: + * {@link #stateEmpty}, {@link #state1v1}, or {@link #stateGroup}. + * All other state containers will be hidden. + * @see View#VISIBLE + * @see View#GONE + */ + private void updateVisibility(final View activeView) { + // Set empty state visibility + stateEmpty.setVisibility(activeView == stateEmpty ? VISIBLE : GONE); + + // Set 1v1 state visibility + state1v1.setVisibility(activeView == state1v1 ? VISIBLE : GONE); + + // Set group state visibility + stateGroup.setVisibility(activeView == stateGroup ? VISIBLE : GONE); + } +} diff --git a/app/src/main/java/com/aldo/apps/ochecompanion/ui/PlayerItemView.java b/app/src/main/java/com/aldo/apps/ochecompanion/ui/PlayerItemView.java new file mode 100644 index 0000000..554cda6 --- /dev/null +++ b/app/src/main/java/com/aldo/apps/ochecompanion/ui/PlayerItemView.java @@ -0,0 +1,284 @@ +package com.aldo.apps.ochecompanion.ui; + +import android.content.Context; +import android.util.AttributeSet; +import android.widget.TextView; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.aldo.apps.ochecompanion.database.objects.Player; +import com.bumptech.glide.Glide; +import com.google.android.material.card.MaterialCardView; +import com.google.android.material.imageview.ShapeableImageView; +import com.aldo.apps.ochecompanion.R; + +/** + * Reusable custom view component for displaying individual player information in a card format. + *+ * This view extends {@link MaterialCardView} to provide a consistent, styled card layout for + * displaying player information throughout the Oche Companion app. It encapsulates the UI + * components and binding logic needed to present a player's profile picture, username, and + * career statistics in a visually appealing and consistent manner. + *
+ *+ * Key Features: + *
+ * Usage Contexts: + * This view is used in multiple places throughout the app: + *
+ * Design Pattern: + * This view follows the ViewHolder pattern by encapsulating both the layout and binding logic, + * making it easy to reuse across different RecyclerView adapters without code duplication. + *
+ *+ * Styling: + * The card appearance is configured in {@link #initViews()} with: + *
+ * This ImageView is configured to display circular profile pictures. It uses + * the Glide library to load images from file URIs when available. If the player + * has no profile picture, a default user icon ({@code R.drawable.ic_users}) is displayed. + *
+ *+ * The ShapeableImageView type allows for easy customization of the image shape + * through XML attributes, supporting circular, rounded rectangle, or custom shapes. + *
+ * + * @see #bind(Player) + * @see ShapeableImageView + */ + private ShapeableImageView ivAvatar; + + /** + * TextView displaying the player's username. + *+ * Shows the {@link Player#username} field. This is the primary identifier + * for the player in the UI. + *
+ */ + private TextView tvUsername; + + /** + * TextView displaying the player's career statistics. + *+ * Shows the {@link Player#careerAverage} formatted using the string resource + * {@code R.string.txt_player_average_base}. This provides users with a quick + * overview of the player's performance history. + *
+ */ + private TextView tvStats; + + /** + * Constructor for programmatic instantiation of the PlayerItemView. + *+ * This constructor delegates to the two-parameter constructor with a null + * AttributeSet, which in turn inflates the layout and initializes all child views + * and styling. + *
+ *+ * Use this constructor when creating PlayerItemView instances directly in code + * rather than inflating from XML. + *
+ * + * @param context The Context in which the view is running, through which it can + * access the current theme, resources, etc. + * @see #PlayerItemView(Context, AttributeSet) + */ + public PlayerItemView(@NonNull Context context) { + this(context, null); + } + + /** + * Constructor for XML inflation of the PlayerItemView. + *+ * This constructor is called when the view is inflated from an XML layout file, + * or when programmatically created via the single-parameter constructor. + * It performs the following initialization: + *
+ * After construction, the view is ready to receive player data through the + * {@link #bind(Player)} method. + *
+ * + * @param context The Context in which the view is running, through which it can + * access the current theme, resources, etc. + * @param attrs The attributes of the XML tag that is inflating the view. May be null + * if instantiated programmatically. + * @see #initViews() + */ + public PlayerItemView(@NonNull Context context, @Nullable AttributeSet attrs) { + super(context, attrs); + // Inflate the player item layout into this card view + inflate(context, R.layout.item_player_small, this); + // Initialize child views and apply styling + initViews(); + } + + /** + * Initializes child view references and applies MaterialCardView styling. + *+ * This method performs two main tasks: + *
+ * Styling Details: + *
+ * This method is called once during construction and does not need to be called + * again during the view's lifecycle. + *
+ * + * @see #PlayerItemView(Context, AttributeSet) + * @see MaterialCardView#setCardBackgroundColor(int) + * @see MaterialCardView#setRadius(float) + * @see MaterialCardView#setCardElevation(float) + */ + private void initViews() { + // ========== Card Styling Configuration ========== + + // Set card background color from theme + setCardBackgroundColor(getContext().getColor(R.color.surface_primary)); + + // Set corner radius for rounded edges + setRadius(getResources().getDimension(R.dimen.radius_m)); + + // Set elevation for Material Design shadow effect + setCardElevation(getResources().getDimension(R.dimen.card_elevation)); + + // ========== Child View References ========== + + // Get reference to the avatar/profile picture ImageView + ivAvatar = findViewById(R.id.ivPlayerProfile); + + // Get reference to the username TextView + tvUsername = findViewById(R.id.tvPlayerName); + + // Get reference to the career stats TextView + tvStats = findViewById(R.id.tvPlayerAvg); + } + + /** + * Binds a Player object to this view, populating all UI components with player data. + *+ * This method updates the view's content to display information for the specified player: + *
+ * Image Loading Strategy: + * The method uses Glide library for efficient image loading: + *
+ * Text Formatting: + * The career average is formatted using {@code R.string.txt_player_average_base} which + * typically includes a format specifier (e.g., "Avg: %.2f") to ensure consistent + * numerical presentation across the app. + *
+ *+ * Performance: + * This method is designed to be called frequently (e.g., during RecyclerView scrolling) + * and uses efficient operations. Glide handles image caching automatically to minimize + * disk I/O and network requests. + *
+ *+ * Usage: + * This method should be called whenever: + *
+ * This prominent interactive view is designed to be the primary call-to-action on the + * dashboard, providing users with a fast and intuitive way to start a new match with + * pre-configured settings. The component features a distinctive visual design with: + *
+ * Design Philosophy: + * This button follows the "hero component" design pattern, where a single large, + * visually distinctive element serves as the primary action on a screen. This makes + * it immediately obvious to users how to start playing without navigating through + * multiple menus or configuration screens. + *
+ *+ * Key Features: + *
+ * Usage Example: + *
+ * QuickStartButton button = findViewById(R.id.quickStartButton);
+ * button.setMainText("Quick Start");
+ * button.updateContext("501", "Double Out");
+ * button.setOnClickListener(v -> startMatch());
+ *
+ *
+ * + * Text Formatting: + * Both main and sub text are automatically converted to uppercase to maintain + * consistent visual styling and match the high-impact design aesthetic. + *
+ *+ * Accessibility: + * The component is configured as clickable and focusable, ensuring it works + * properly with touch, keyboard navigation, and accessibility services. + *
+ * + * @see FrameLayout + * @see TextView + * @author Oche Companion Development Team + * @version 1.0 + * @since 1.0 + */ +public class QuickStartButton extends FrameLayout { + + /** + * TextView displaying the primary, bold label for the button. + *+ * This TextView typically shows the main action text such as "QUICK START". + * The text is rendered in a large, prominent font and is automatically + * converted to uppercase when set via {@link #setMainText(String)}. + *
+ *+ * This is the most visually prominent element of the component and should + * clearly communicate the primary action to the user. + *
+ * + * @see #setMainText(String) + */ + private TextView tvMainLabel; + + /** + * TextView displaying the secondary descriptive text below the main label. + *+ * This TextView shows additional context about the quick start action, + * such as the game mode and rules (e.g., "501 - DOUBLE OUT"). The text + * is automatically converted to uppercase when set via {@link #setSubText(String)} + * or {@link #updateContext(String, String)}. + *
+ *+ * This provides users with quick information about what configuration will + * be used for the match without requiring them to navigate to settings. + *
+ * + * @see #setSubText(String) + * @see #updateContext(String, String) + */ + private TextView tvSubLabel; + + /** + * Constructor for programmatic instantiation of the QuickStartButton. + *+ * This constructor delegates to the two-parameter constructor with a null + * AttributeSet, which handles the actual initialization including layout + * inflation and view setup. + *
+ *+ * Use this constructor when creating QuickStartButton instances directly in code + * rather than inflating from XML. + *
+ * + * @param context The Context in which the view is running, through which it can + * access the current theme, resources, etc. + * @see #QuickStartButton(Context, AttributeSet) + */ + public QuickStartButton(@NonNull Context context) { + this(context, null); + } + + /** + * Constructor for XML inflation of the QuickStartButton. + *+ * This constructor is called when the view is inflated from an XML layout file, + * or when programmatically created via the single-parameter constructor. + * It performs the following initialization: + *
+ * Merge Tag Pattern:
+ * The layout inflation uses {@code attachToRoot = true}, which is appropriate when
+ * using the {@code
+ * Interaction Configuration: + * The view is explicitly set as clickable and focusable to ensure: + *
+ * This method retrieves and stores references to the main label and sub-label + * TextViews that comprise the button's visual content. These references are + * used by the public setter methods to update the button's displayed text. + *
+ *+ * This method is called once during construction and does not need to be called + * again during the view's lifecycle. + *
+ *+ * Required Layout Elements: + * The R.layout.view_quick_start layout must contain: + *
+ * This method updates the main label TextView with the provided text, which is + * automatically converted to uppercase to maintain visual consistency with the + * component's bold, high-impact design aesthetic. + *
+ *+ * Typical Usage: + * Use this method to set action-oriented text that clearly communicates the + * button's purpose, such as: + *
+ * Text Transformation: + * The input text is converted to uppercase using {@link String#toUpperCase()} + * before being set on the TextView, ensuring consistent visual presentation + * regardless of how the input string is formatted. + *
+ *+ * Null Safety: + * The method includes a null check for the TextView reference to prevent + * NullPointerExceptions if called before the view is fully initialized, + * though this should not occur under normal circumstances. + *
+ * + * @param text The main title string to display. Should be concise and action-oriented. + * Will be converted to uppercase automatically. + * @see #setSubText(String) + */ + public void setMainText(final String text) { + // Check if TextView is initialized before setting text + if (tvMainLabel != null) { + // Convert text to uppercase for consistent styling + tvMainLabel.setText(text.toUpperCase()); + } + } + + /** + * Sets the secondary descriptive text displayed below the main label. + *+ * This method updates the subtitle TextView with the provided text, which is + * automatically converted to uppercase to maintain visual consistency. The + * subtitle typically provides additional context about the quick start action, + * such as game mode and rules information. + *
+ *+ * Typical Usage: + * Use this method to set descriptive text that provides details about the + * match configuration, such as: + *
+ * Text Transformation: + * The input text is converted to uppercase using {@link String#toUpperCase()} + * before being set on the TextView, ensuring consistent visual presentation + * with the main label. + *
+ *+ * Null Safety: + * The method includes a null check for the TextView reference to prevent + * NullPointerExceptions if called before the view is fully initialized, + * though this should not occur under normal circumstances. + *
+ * + * @param text The subtitle string to display. Should provide context about the + * match configuration. Will be converted to uppercase automatically. + * @see #setMainText(String) + * @see #updateContext(String, String) + */ + public void setSubText(final String text) { + // Check if TextView is initialized before setting text + if (tvSubLabel != null) { + // Convert text to uppercase for consistent styling + tvSubLabel.setText(text.toUpperCase()); + } + } + + /** + * Convenience method to update the subtitle based on game mode and rules. + *+ * This method provides a structured way to update the button's subtitle by combining + * game mode and rules information into a formatted string. It handles the concatenation + * logic and properly formats the output with a separator between mode and rules. + *
+ *+ * Formatting Behavior: + *
+ * Example Usage: + *
+ * // With rules
+ * button.updateContext("501", "Double Out");
+ * // Results in: "501 - DOUBLE OUT"
+ *
+ * // Without rules
+ * button.updateContext("301", null);
+ * // Results in: "301"
+ *
+ * button.updateContext("Cricket", "");
+ * // Results in: "CRICKET"
+ *
+ *
+ * + * Advantages over setSubText: + *
+ * Implementation Details: + * The method uses {@link StringBuilder} for efficient string concatenation, + * especially useful if called frequently. It checks if rules are empty using + * {@link TextUtils#isEmpty(CharSequence)} which handles both null and empty strings. + *
+ * + * @param mode The game mode identifier (e.g., "501", "301", "Cricket"). + * Should not be null or empty as this is the primary information. + * @param rules Optional summary of game rules (e.g., "Double Out", "Single In/Double Out"). + * Can be null or empty, in which case only the mode is displayed. + * @see #setSubText(String) + * @see TextUtils#isEmpty(CharSequence) + * @see StringBuilder + */ + public void updateContext(final String mode, final String rules) { + // Use StringBuilder for efficient string concatenation + final StringBuilder stringBuilder = new StringBuilder(mode); + + // Only append rules if they are provided (not null and not empty) + if (!TextUtils.isEmpty(rules)) { + // Add separator between mode and rules + stringBuilder.append(" - "); + // Append the rules text + stringBuilder.append(rules); + } + + // Set the combined text as the subtitle + setSubText(stringBuilder.toString()); + } +} diff --git a/app/src/main/java/com/aldo/apps/ochecompanion/ui/adapter/MainMenuGroupMatchAdapter.java b/app/src/main/java/com/aldo/apps/ochecompanion/ui/adapter/MainMenuGroupMatchAdapter.java new file mode 100644 index 0000000..f9cd7bb --- /dev/null +++ b/app/src/main/java/com/aldo/apps/ochecompanion/ui/adapter/MainMenuGroupMatchAdapter.java @@ -0,0 +1,372 @@ +package com.aldo.apps.ochecompanion.ui.adapter; + +import android.annotation.SuppressLint; +import android.util.Log; +import android.view.View; +import android.view.ViewGroup; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.recyclerview.widget.RecyclerView; + +import com.aldo.apps.ochecompanion.R; +import com.aldo.apps.ochecompanion.database.objects.Player; +import com.aldo.apps.ochecompanion.models.Match; +import com.aldo.apps.ochecompanion.ui.PlayerItemView; +import com.bumptech.glide.Glide; +import com.google.android.material.imageview.ShapeableImageView; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; + +/** + * RecyclerView adapter for displaying group match player information in the Main Menu. + *+ * This adapter is specifically designed for matches with 3 or more players (group matches). + * It displays a sorted list of players with their names, career averages, and profile pictures. + * Players are automatically sorted by their career average scores when the match data is updated. + *
+ *+ * Key Features: + *
+ * Usage: + * This adapter is used in the Main Menu to display the results of the last played group match, + * providing users with a quick overview of player standings. + *
+ * + * @see RecyclerView.Adapter + * @see GroupMatchHolder + * @see PlayerScoreComparator + * @see Match + * @see Player + * @author Oche Companion Development Team + * @version 1.0 + * @since 1.0 + */ +public class MainMenuGroupMatchAdapter extends RecyclerView.Adapter+ * This list is populated and sorted when {@link #updateMatch(Match)} is called. + * Players are sorted by their career average using {@link PlayerScoreComparator}. + * The list is cleared and repopulated on each match update. + *
+ * + * @see #updateMatch(Match) + * @see Player + */ + private final List+ * This method is called by the RecyclerView when it needs a new ViewHolder to represent + * a player item. The method creates a custom {@link PlayerItemView} and configures its + * layout parameters to match the parent's width and wrap its content height. + *
+ *+ * Layout Configuration: + *
+ * This method is called by the RecyclerView to display player data at the specified position. + * It retrieves the player from the internal list and delegates the display logic to the + * {@link GroupMatchHolder#setPlayer(Player)} method. + *
+ * + * @param holder The ViewHolder which should be updated to represent the player + * at the given position in the data set. + * @param position The position of the player within the adapter's data set. + * @see GroupMatchHolder#setPlayer(Player) + */ + @Override + public void onBindViewHolder(@NonNull final GroupMatchHolder holder, final int position) { + // Retrieve the player at this position and bind it to the holder + holder.setPlayer(mPlayersList.get(position)); + } + + /** + * Returns the total number of players in the data set held by the adapter. + *+ * This method is called by the RecyclerView to determine how many items to display. + * The count is based on the number of players currently stored in {@link #mPlayersList}. + *
+ * + * @return The total number of players in the list. + */ + @Override + public int getItemCount() { + return mPlayersList.size(); + } + + /** + * Updates the adapter with new match data and refreshes the display. + *+ * This method performs the following operations: + *
+ * Performance Consideration: + * This method uses {@link #notifyDataSetChanged()}, which triggers a full refresh + * of the RecyclerView. The {@code @SuppressLint("NotifyDataSetChanged")} annotation + * is used because this method is typically called only once per activity lifecycle, + * making the performance impact negligible. For frequent updates, more granular + * notification methods (like notifyItemInserted, notifyItemChanged) would be preferable. + *
+ * + * @param match The Match object containing the players to display. Must not be null + * and should contain at least one player. + * @see Match#getAllPlayers() + * @see PlayerScoreComparator + */ + @SuppressLint("NotifyDataSetChanged") + public void updateMatch(final Match match) { + if (match == null) { + Log.d(TAG, "updateMatch: match is null, aborting update."); + return; + } + // Clear any existing player data + mPlayersList.clear(); + + if (match.getAllPlayers() == null || match.getAllPlayers().isEmpty()) { + Log.d(TAG, "updateMatch: No players found in the match, just clearing."); + notifyDataSetChanged(); + return; + } + + // Get all players from the match + final List+ * This ViewHolder manages the UI components for a single player entry, including: + *
+ * The ViewHolder uses a {@link PlayerItemView} as its root view and automatically + * hides the chevron icon since group match items are not clickable/expandable. + *
+ *+ * Image Loading: + * Profile pictures are loaded using the Glide library for efficient memory management + * and caching. If a player has no profile picture, a default user icon is displayed. + *
+ * + * @see RecyclerView.ViewHolder + * @see PlayerItemView + * @see Player + */ + public static class GroupMatchHolder extends RecyclerView.ViewHolder { + + /** + * TextView displaying the player's username. + * Shows the {@link Player#username} field. + */ + private final TextView mPlayerNameView; + + /** + * TextView displaying the player's career average score. + *+ * Shows the {@link Player#careerAverage} field formatted according to + * the string resource {@code R.string.txt_player_average_base}. + *
+ */ + private final TextView mPlayerScoreView; + + /** + * ShapeableImageView displaying the player's profile picture. + *+ * Displays the image from {@link Player#profilePictureUri} if available, + * or a default user icon ({@code R.drawable.ic_users}) if no picture is set. + * Images are loaded using the Glide library for optimal performance. + *
+ */ + private final ShapeableImageView mPlayerImageView; + + /** + * Constructs a new GroupMatchHolder and initializes its child views. + *+ * This constructor performs the following setup: + *
+ * The chevron icon is hidden because players in group match view are displayed + * for informational purposes only and do not support click/expand actions. + *
+ * + * @param itemView The root view of the ViewHolder, expected to be a {@link PlayerItemView}. + * Must not be null and must contain the required child views. + */ + public GroupMatchHolder(@NonNull final View itemView) { + super(itemView); + + // Initialize references to child views + mPlayerNameView = itemView.findViewById(R.id.tvPlayerName); + mPlayerScoreView = itemView.findViewById(R.id.tvPlayerAvg); + mPlayerImageView = itemView.findViewById(R.id.ivPlayerProfile); + + // Hide the chevron icon as group match items are not interactive + itemView.findViewById(R.id.ivChevron).setVisibility(View.GONE); + } + + /** + * Binds a Player object to this ViewHolder, updating all displayed information. + *+ * This method updates the UI components with the player's data: + *
+ * Image Loading Strategy: + * If the player has a profile picture URI, Glide is used to load the image asynchronously + * with automatic caching and memory management. If no URI is available, a default user + * icon ({@code R.drawable.ic_users}) is displayed instead. + *
+ * + * @param player The Player object whose information should be displayed. + * Must not be null. + * @see Player#username + * @see Player#careerAverage + * @see Player#profilePictureUri + */ + public void setPlayer(final Player player) { + // Set player name + mPlayerNameView.setText(player.username); + + // Format and set career average score + mPlayerScoreView.setText(String.format( + itemView.getContext().getString(R.string.txt_player_average_base), + player.careerAverage)); + + // Load profile picture or show default icon + if (player.profilePictureUri != null) { + // Use Glide to load image from URI with caching and memory management + Glide.with(itemView.getContext()) + .load(player.profilePictureUri) + .into(mPlayerImageView); + } else { + // No profile picture available - show default user icon + mPlayerImageView.setImageResource(R.drawable.ic_users); + } + } + } + + /** + * Comparator for sorting Player objects based on their career average scores. + *+ * This comparator implements ascending order sorting, meaning players with lower + * career averages will appear before players with higher averages in the sorted list. + *
+ *+ * Sorting Behavior: + *
+ * Usage: + * This comparator is used in {@link #updateMatch(Match)} to sort the player list + * before displaying it in the RecyclerView. + *
+ * + * @see Comparator + * @see Player#careerAverage + * @see #updateMatch(Match) + */ + public static class PlayerScoreComparator implements Comparator+ * Uses {@link Double#compare(double, double)} to perform a numerical comparison + * of the career average values, which properly handles special cases like NaN and infinity. + *
+ * + * @param p1 The first Player to compare. + * @param p2 The second Player to compare. + * @return A negative integer if p1's average is less than p2's average, + * zero if they are equal, or a positive integer if p1's average is greater. + */ + @Override + public int compare(final Player p1, final Player p2) { + // Compare career averages in ascending order + return Double.compare(p1.careerAverage, p2.careerAverage); + } + } +} diff --git a/app/src/main/java/com/aldo/apps/ochecompanion/ui/adapter/MainMenuPlayerAdapter.java b/app/src/main/java/com/aldo/apps/ochecompanion/ui/adapter/MainMenuPlayerAdapter.java new file mode 100644 index 0000000..7de578b --- /dev/null +++ b/app/src/main/java/com/aldo/apps/ochecompanion/ui/adapter/MainMenuPlayerAdapter.java @@ -0,0 +1,364 @@ +package com.aldo.apps.ochecompanion.ui.adapter; + +import static com.aldo.apps.ochecompanion.AddPlayerActivity.EXTRA_PLAYER_ID; + +import android.annotation.SuppressLint; +import android.content.Context; +import android.content.Intent; +import android.util.Log; +import android.view.View; +import android.view.ViewGroup; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.recyclerview.widget.RecyclerView; + +import com.aldo.apps.ochecompanion.AddPlayerActivity; +import com.aldo.apps.ochecompanion.R; +import com.aldo.apps.ochecompanion.database.objects.Player; +import com.aldo.apps.ochecompanion.ui.PlayerItemView; +import com.bumptech.glide.Glide; +import com.google.android.material.imageview.ShapeableImageView; + +import java.util.ArrayList; +import java.util.List; + +/** + * RecyclerView adapter for displaying the squad of players in the Main Menu. + *+ * This adapter manages the display of all players in the user's squad, showing their names, + * career statistics, and profile pictures. Each player item is clickable, allowing users to + * navigate to the player edit screen to update their information. + *
+ *+ * Key Features: + *
+ * Usage: + * This adapter is used in the Main Menu activity to display the complete squad roster, + * allowing users to view and manage their players. + *
+ * + * @see RecyclerView.Adapter + * @see PlayerCardHolder + * @see Player + * @see AddPlayerActivity + * @author Oche Companion Development Team + * @version 1.0 + * @since 1.0 + */ +public class MainMenuPlayerAdapter extends RecyclerView.Adapter+ * This list is populated when {@link #updatePlayers(List)} is called. + * The list maintains the order in which players are added from the database. + * All modifications to this list trigger a full RecyclerView refresh. + *
+ * + * @see #updatePlayers(List) + * @see Player + */ + private final List+ * This method is called by the RecyclerView when it needs a new ViewHolder to represent + * a player item. The method creates a custom {@link PlayerItemView} and configures its + * layout parameters to match the parent's width and wrap its content height. + *
+ *+ * Layout Configuration: + *
+ * This method is called by the RecyclerView to display player data at the specified position. + * It retrieves the player from the internal list and delegates the display logic to the + * {@link PlayerCardHolder#setPlayer(Player)} method. + *
+ * + * @param holder The ViewHolder which should be updated to represent the player + * at the given position in the data set. + * @param position The position of the player within the adapter's data set. + * @see PlayerCardHolder#setPlayer(Player) + */ + @Override + public void onBindViewHolder(@NonNull final PlayerCardHolder holder, final int position) { + // Retrieve the player at this position and bind it to the holder + holder.setPlayer(mPlayersList.get(position)); + } + + /** + * Returns the total number of players in the data set held by the adapter. + *+ * This method is called by the RecyclerView to determine how many items to display. + * The count is based on the number of players currently stored in {@link #mPlayersList}. + *
+ * + * @return The total number of players in the list. + */ + @Override + public int getItemCount() { + return mPlayersList.size(); + } + + /** + * Updates the adapter with a new list of players and refreshes the display. + *+ * This method performs the following operations: + *
+ * Performance Consideration: + * This method uses {@link #notifyDataSetChanged()}, which triggers a full refresh + * of the RecyclerView. The {@code @SuppressLint("NotifyDataSetChanged")} annotation + * is used because this method is typically called only once per activity lifecycle + * (when the activity resumes), making the performance impact negligible. For frequent + * updates, more granular notification methods (like notifyItemInserted, notifyItemChanged) + * would be preferable. + *
+ * + * @param players The list of Player objects to display. Must not be null. + * Can be empty to clear the display. + * @see Player + */ + @SuppressLint("NotifyDataSetChanged") + public void updatePlayers(final List+ * This ViewHolder manages the UI components for a single player entry, including: + *
+ * The ViewHolder uses a {@link PlayerItemView} as its root view and supports click + * events that navigate to the {@link AddPlayerActivity} for editing player information. + *
+ *+ * Image Loading: + * Profile pictures are loaded using the Glide library for efficient memory management, + * caching, and smooth scrolling performance. If a player has no profile picture, + * a default user icon is displayed. + *
+ * + * @see RecyclerView.ViewHolder + * @see PlayerItemView + * @see Player + * @see AddPlayerActivity + */ + public static class PlayerCardHolder extends RecyclerView.ViewHolder { + + /** + * TextView displaying the player's username. + * Shows the {@link Player#username} field. + */ + private final TextView mPlayerNameView; + + /** + * TextView displaying the player's career average score. + *+ * Shows the {@link Player#careerAverage} field formatted according to + * the string resource {@code R.string.txt_player_average_base}. + *
+ */ + private final TextView mPlayerScoreView; + + /** + * ShapeableImageView displaying the player's profile picture. + *+ * Displays the image from {@link Player#profilePictureUri} if available, + * or a default user icon ({@code R.drawable.ic_users}) if no picture is set. + * Images are loaded using the Glide library for optimal performance. + *
+ */ + private final ShapeableImageView mPlayerImageView; + + /** + * Constructs a new PlayerCardHolder and initializes its child views. + *+ * This constructor performs the following setup: + *
+ * Unlike group match items, the chevron icon is kept visible since player items + * are interactive and clicking them navigates to the edit screen. + *
+ * + * @param itemView The root view of the ViewHolder, expected to be a {@link PlayerItemView}. + * Must not be null and must contain the required child views. + */ + public PlayerCardHolder(@NonNull final View itemView) { + super(itemView); + + // Initialize references to child views + mPlayerNameView = itemView.findViewById(R.id.tvPlayerName); + mPlayerScoreView = itemView.findViewById(R.id.tvPlayerAvg); + mPlayerImageView = itemView.findViewById(R.id.ivPlayerProfile); + } + + /** + * Binds a Player object to this ViewHolder, updating all displayed information. + *+ * This method updates the UI components with the player's data: + *
+ * Interaction: + * When the item is clicked, it launches {@link AddPlayerActivity} with the player's ID, + * allowing the user to edit the player's information. + *
+ *+ * Image Loading Strategy: + * If the player has a profile picture URI, Glide is used to load the image asynchronously + * with automatic caching and memory management. If no URI is available, a default user + * icon ({@code R.drawable.ic_users}) is displayed instead. + *
+ * + * @param player The Player object whose information should be displayed. + * Must not be null. + * @see Player#username + * @see Player#careerAverage + * @see Player#profilePictureUri + * @see Player#id + * @see #startEditPlayerActivity(Context, Player) + */ + public void setPlayer(final Player player) { + Log.d(TAG, "setPlayer() called with: player = [" + player + "]"); + + // Set up click listener to navigate to edit player screen + itemView.setOnClickListener(v -> startEditPlayerActivity(itemView.getContext(), player)); + + // Set player name + mPlayerNameView.setText(player.username); + + // Format and set career average score + mPlayerScoreView.setText(String.format( + itemView.getContext().getString(R.string.txt_player_average_base), + player.careerAverage)); + + // Load profile picture or show default icon + if (player.profilePictureUri != null) { + // Use Glide to load image from URI with caching and memory management + Glide.with(itemView.getContext()) + .load(player.profilePictureUri) + .into(mPlayerImageView); + } else { + // No profile picture available - show default user icon + mPlayerImageView.setImageResource(R.drawable.ic_users); + } + } + + /** + * Launches the AddPlayerActivity to edit the specified player's information. + *+ * This helper method creates an intent to start {@link AddPlayerActivity} in edit mode, + * passing the player's ID as an extra. The activity will load the player's existing data + * and allow the user to modify their username and profile picture. + *
+ *+ * Intent Configuration: + * The player's ID is passed via the {@link AddPlayerActivity#EXTRA_PLAYER_ID} extra, + * which signals to the activity that it should load and edit an existing player + * rather than creating a new one. + *
+ * + * @param context The Context from which the activity should be launched. + * Typically obtained from the item view. + * @param player The Player object whose information should be edited. + * The player's ID is passed to the edit activity. + * @see AddPlayerActivity + * @see AddPlayerActivity#EXTRA_PLAYER_ID + */ + private void startEditPlayerActivity(final Context context, final Player player) { + // Create intent for AddPlayerActivity + final Intent intent = new Intent(context, AddPlayerActivity.class); + + // Pass player ID to enable edit mode + intent.putExtra(EXTRA_PLAYER_ID, player.id); + + // Launch the activity + context.startActivity(intent); + } + } +} diff --git a/app/src/main/res/drawable/btn_grid_item.xml b/app/src/main/res/drawable/btn_grid_item.xml new file mode 100644 index 0000000..b1f6c34 --- /dev/null +++ b/app/src/main/res/drawable/btn_grid_item.xml @@ -0,0 +1,18 @@ + +