From a4a42fc73f6362762ad367ae7535a3d70b2f1bd5 Mon Sep 17 00:00:00 2001
From: Alexander Doerflinger
+ * This activity provides a comprehensive user interface for managing player information including:
+ * He&?3jDNMDdsaXN>?`hr~HDJ&@P%4xLQQ%?
z_lV0ZKKiMA&bi3Smr;PtOHenvHRFU|Aw1y!spEo%eDnbCixFD6a5W|Q)vxiit1&cu
z+xJ_2aPCpijW+P(OHWVrcpAatLlV|Eu2JPPpE|kKi{~DWlIJJ8@ne>CIoZ#Z8^&0)
zpS1t0Lk+Zr&YJm%{_Mvj&)?Q~4xOg8QD@+ZM{-+BmY)*glNTBM4H_oMGP%zeE}>@rivZE^
zx+?0CcMlD
5Sx?i9bu)e>p&-{P7I`crN*8h*68T&pVJ7X+K5v8nQ
zh>)#PO4KNOmWWWq*h2P5lr0sxm9g)}*vT$s>|03orLpsSM&0|p_t&2~XU_6GXL+8_
z>-9c6rAVAC(&^4mRS_l_1STr_CfnfPZG~<-7*8(uY(?YW8qSzL9%BIW)L4WrA=Liu
zT~S&I1f{6!j<}}lw-4VE*N01GS1si|_8kfV(a_RfMWvMtX4=S8EB%uOY+G1-8UPUf
z)Q`407XXAuo?u}@54&G3d)C1p?9phU#xq<{53Sq$wH>d>wkM%^7e1<+bU5dmlm&;Q
z8o-$Tz$Wg#bSUtejSe~DS(B^j`m3!PrtrPd#&E}R#MEM8XY8(PZ)ex@d)J$~jNs
zPiv*2^EyW0OzCYA1pA-Kz9YX*zXa&h48>N>0uojC8JwmrN
l@EHD^ft+?n34cUrqQpq_zULbV*u~(MnUu|rS$SrNUT8&iQy`N#}
zK1a8A$6B|U5%+=zs3f;GzX`ayHdJFz0NcxempCs(F5Yan0$Xunb$Rzg0xK~uf8~Xj
zuC<#U=vWf+8}HLgmlsMb^w?ei
+ *
+ *
+ * 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 @@ + +3HF~zBe(vB8;`S|pG{9rq^Np-6zFtC!p66C zJWouZ^6i!{4Z3U`Gv7a4lb@9lm)dxHWyGbjC)dmmPnS(YgCoi(+}7!A>=5{LmvlWd zxr3|3C$Zn}oz$&~4hzQ&x6f#6jWOz4`yyrbKxZDAUD zrEs2zvA2tp;jFz4+xIPuLMydCK-Z?*uVXB!PfwxVdmGy%N5#qFyo{~SzE&?a`cU1R zuaU}(u~8;FDbrp(k;ZASpHWsC#~xQ0Bd_?!n42X?W|HpC_xV!MZOd?uywXNpZR6~e zG1i-t!#T!AD^%)t&K}x_Lzm}^6P@ZfI3?=zxWTRL1 DH5c)f6lQ|IwWcNP}uoCYkrl0{=qg&7Drp93-m9pWrdy(HQ6BD!S!(5+2cU6 znc(M(1EmJJ-t5&4WKNdh7uq!pM9}NaQkr1`{N!uN(y}bz4PA?BgB+p|GX?7fImGh9 z9HL;%KaqvLUi$4^4$B?g4w(8;lHk|d&!@5j7;`QSHft6n7pwNhdCc4U(gq}ejrkZ~ z%*+PVX)SEmpfM}hmCsHm#`@t<8xdHwI}Rp&V@VkMGJXcOU5vKJ<>Y)y$)aX3tK^>Q zJat(Rmt@EkmOFn}Vr}PjILLNYj-#Fbs-dmzs@!<9g#-fBo#xKpWh$KCTHEgI>8a#= zdJ1W4yE6sSA2;4?JS`3%0@R*{03#}#Pk7r2S_{uj?q)W}c?-J(F!@o~O8Lc7CE;%f zF#9x_U^_tsXhwiuCE;nR!l__xw`e22Fkz^f)>J#&Ev8Zh(nAF3YY;;K0b1u!626Lu zHKn?2IN_3lc+#~*u(#_>RT8E^x`6<_2+&5rg#e8R(2W3d6i6TF%GlezeuHoxlW_gi z?|&m*rdU>{3Z$
a!nvkELQ_*?Bk=O0lZk;cXB{sx83CQ4&OC`}nA>rurqZQ7g?DHE zqRFyLPjJO)b0EMh1X$!Lyz^%-P4afJZ5?(3h3tf|t!)VuyQLLK-#i4o@}CxSoED?k zlblUPfFT5!j{r;Dc(cJ%cz2bApDB=jDv {csKh=(Lnl@5y&ALtH@wXtp3g>+;EhZu#Xc4GP8?Sev+m WoQR1r=s+ 1bG3M>s@KfAKo90oJLph@ZV@^9Zv)kA&qC=xVu9i7T|?s7I7XnJ` Smlh{E0Z^bGKs5qDWs_4tn3 $Akm!2 z$aR@hR|B~h6@?Ytq=aYBN1gqm4=M<$g9^ejDmJq+j4JX-uDgj%0g-Wk9J%%3Yy&r` zAy-0V=I`KYNC#1AAT=<9yEzp_cJCzFFm=G#07o&Cy9UBg3^S(z{M5P748#Bc(l+`3 zzoocK+!qLvC;$L}`2YX^|NsC0|NsB`&%kXXMeZJhn17(m_$`1ZfTud(nLu>iOy&JF zz;jzO-QF4gzUXFZtl66IE({CQ6!0PB&B2fYJBDkf&9C>|8+@j)pJu13lBv1Y8iI@9 zOTh#afhk9fU^XxpnD>|;m_L|*7+_ZDh%Y%(qgx;MvTJ_Q>|9HlnTj&{dQ&jN oZwKN= Xf2oPw;PXddO5wxS>eF}=Je 6Ou~7`m^g#IS_m>d5s4 -Y8MEP^DZ#n8zw;_z6is#170aMrT#+!YnD8X2txh7sx{i&keakwXz!;G zg3MT$h33Ff3@ZK>5c>@Cn-P-m>O*IkKyu2_f>5r-AO{@OicLWq!i>2iry`Ls!GR }s3LAqtl%fYeLAD?|b;TrgBF4nB5F?83V9juDK{O$TNHH}+-4U9MMTjE$ zymvS3?ikY!Q4z)vhKfO?!xk{_n2!-fCVP1&m BFthd7DVG?FX5n5G!mp5(m9du z9Lo_YaEdgM?zKKmKF2mOk;FCpdf%`Rk#q%d&XqJOgf nods~jS#i`_J5EL4aeodEb zA(9k)VQQPw$XUEekZ)BvRHPbR$8<9vdBpatcZf;ltR>E8g*+dF3~Rd7I-~I=w d&Rl=+lOgQGw*+{OTu z9W#pp%-bUrI6A;&Yf)e^$RPmBU8w2TU{oX^$HBAQiUf_Q9BS@D+s4FEiA6n98c%&v zk_Mm3kz?C#;xJ~03PNgrI36DI_f2I0VmT@v>Uy7Zq%u3fFpcX2DicG#B*UR=; v1Kf|2M+afgcQURHbYNl8PfP=ZB5DXDXx!S*5h|x1Z z>IES73J`R?M~HGRtVwPnp$7}}b~;D>9z#&L@*(a3B)TpVYcstTX=aGuZ Yw=v+yY7bFB&y3Ec&1>y@VbCky|_&xeUQWHYr!y*X#F}XyTA$ aQ(9-^!y~-kpk4Wo zCICpb2!fbJSwR@@`_XYctLYuJS#Jdsy41u`%;489r9=wk*>fPI_9^-K@=N*gJo^~+ ze1&aX=Jm2BA~pc1wxE@61JHVxjtJ^qd~rVMFqqStsp6)5Z`CHFh4ub}>*EL8JAmu; zhY-xd!YiyTM&llPQOD6OpqmR`jSq4Hz_kUfG#fyGxt4^Gn%~IFxv%8^dvjk>@|$A{ z9Ya+GDWUhOM@i2BR=ojuU3YNM^(igDgScKLwY`#MeX BKO+HNJ>d1Jn$Y+lGSXG>=t?uN#Kt!;bvY!@mO=&u*b@FPAj`F1~d )vf~J U;3=3fRi!r}k zu*Yd>olUtf$tw9&>`YtZ42E_?q}J9MeWliz!eVdXBFQiuX}JV%R7nW?!$Ks(=?cFG zrhPUZKtcK5vQZ`21;*{Gg$B)ZS>uAgB4 jI^$(X@@M=ZsW23EIsU@?1OehlQ@ AMMjMolN$CU=8cJsv|4>6U7L(EjqkE;e!AFU`Or&YUc{;k<#%CjK9R|?* zZ6N^Im_6zH)H4ah*<|njoY!=0-mhU`d_OT($C#)sO7rFuV?E)Ufm!|&Y~$=^(Aw69 z{d|LreF{M6*97|}00Dr7n3uVaKOFpuf~E*5E1n%g#Ab(PIcGD1>rESmNHc=F=+Pg8 zuaE!zT;@K`v0hRy*f-6304AbC{yAs0tK8G=Ij6`Wux6u*(hj9+L<%<~L>(JeB4#MH zXq6r1o^uMAqtZR<;*AUIlz-0Q7!HU4fCRvR jCS`t)Lg0@Ym5oWJT z$vHaiG# ^>KtwQWM*+Fv4 zV#vD`(Z> 3xr=}0E&55Mnvmif{kT0tj!UiA% z$T8 nc6dvig)y`1oHiwRQa(HFX}pI*ZfY_Ok#|XF0`M`z zR34{Cn-6_z)6QqtDr0OwN@y@`!x` Aa^G1~rk#=`jEfBAp5>C)5nt zNrNC3qM#`vwC|J3hcE|a)iGTJ!2raJMHmIiZp)QuFhq8xu?7*9!z~aLf$0aJ=V0DZ zdE#kOTye!^LFLy#5~3~Fn<6ms0K|w%DhTQO!F4(nkYxZ_9?ujK%K!w31UgU5Er(}g zI-lh@iV}IE&lBpI02~fgACo>Z@PDxWP8e;pXRFvSYB01C_|6_dz4S)*3R;lW6y zduagLTE8g@ySsrOL Cg2M@wZpd#3 z`t9)*(Skp%95GMDq sNcX6t(#^2}picAaiXs{sfq@}`RItv?%bQ*tgpr<)KqZDG z44lSy6%S+W0XQ)CG{%XhlZHXwi+iJCA+G`a?UiYY;w~)d3sTB_ 7cQ%_FQA}$$0>xBoq+8Xpl!UUBH=~&7Xw|y8&8lwk+Wt{aQ2upgI=~GlJ_4YSyZ9 zPBo(Y1!!1~;fzF7Qxvzx2;9Jd#;Pfp6#r12(UNn!MieaM@4;^1WJ`b+ECkRX+Gv=3 zrx-aCZJI#Cat!ob7?kv8emxw>YJei-8wHchJ>U%Ow^yZLIlebQJ1Z@$D~c48IdIPo z$|#u8YS0>Jf4(4%E BzL7HNd>zLnT}pzUv1-w%*q^AHaJ;jC(5 zXZMXWZ&2qTNF~1(|Ah7@3R{AM*gBz=RIV)mFhHirzcnkVdkAe*>kW{9TOVWzz*tZ# ztV?QAnb80h$amr?d$it&=O8~Cpq=5`MXE9K%4-nuDrtmMpUUyJlEW{=W2#vi=1V-s zkx^5gXXuI|-P8y20Zfn);tez1$}~hy+lX{W$h0> Y&0b2!!Hx9mDcPAZz#vlo%9 zM#ydtBf#cDc0-6jfFs-6bDH4Qb$O}wdIqpKXi~|ho`j9HUjtzhM^_PmraeJUW~!{s zjsUD2<77v|VbU4o2V@m817PoH;4+t-T%-GI`>HuG$Eq44h?vrLRaOtw$^nQjz#Llu z?wY=8{kloy+Q29PE2Fe2
wMWt@U+Uv-h zgz&3XAE_Jt=jVA4drx}Ar*dq5+1pWjF@Ap{ILZ 4`Ff=qEdvdbq4I-COLSS!F&Vc*H >+E7@CZs>($g0ZiIpM0LN30~j34je^!*QirBv|vEMxKDqI-ARK zNzg2{c@D>_CK&+SnArMDbV4QoC#y+z8yur5k(27=$|7{u+2qRtkXT;W;E;(V#IuXC zsxt*Mh`UBGt-xDU>>vs8mBfuX3wI=OYml$Wq!kx6DM$-SQ$h=Jw7WLbv#T7abIhpB zXUW9UJL_x|=N-uPd~9&YoCB?@Yye%G-9>Gk;$`Lad5Q9(>>LN-kZY$Ck$3i*Ru+S4 zr%{micH)SSsgPR-Q90Q~<%v9(b120Tj-}n8CcEm(km_z#c~{>z`uhZ$K0IFD758el zC*5&XcIge$VqtNm(;`}1=}Z=P>YxkpQ(l|1jB(t9tUl_0IeHvu^4ZGTsIUjsq`$2C z2I=v5jsqi(zMfPJL?pNV*9fmyklfp1YEMshV3HG$r{AEu%&!SN>dBT9S)~;fInSSU z^Qy`$i`XGLVp)u-%|mJ&Xy;T^l=hWq*=?3?%gpX7`^wFZ=X4E;c^Styl`+dFzZ9{2 zd}jMEJ6sz%@$B5bD!a>c_VlDrJ@Ur_h2^F3cyVI~h8%^6O^zR`Ye_0KV!70q`sP*b z`!0=( %4lHQ`uK~c12arL6R}$$c4-z9#vIkJk{s3#Kh%pFutZj9(Q%5 zWXzM{&8e!$PVcKS5UUq>9_G}?x@+$S8D1wnr&$tmH(S9m5hsvODnHeYs7|@k!jHKE z4)-MF8|OIr-i#Zx*PRH%1D$X%ueva+&Ks|&a+Z=2=#;~iBN0-`(F>X9*yT8H9`X9q zh!5NQ^wpc+ CQx2qRp)Q zj&h6Q2d=27sH& ?uA`dV zT(37bx2B`IjQqX|2Cgg#)RqOsGpQk=*@i!w8Oy5;q*a%+m#B0~qP>#pv_NfMEHmmi sY)veadP=%x+eX-5AB_fGBpR*vhYj1w6|bc>s q zK_HZ&B0&JaGLq(e*R<1Qr I zGf^Wgdd0ju4C#VbV&=gfIc(cTl9Rk=CXRO _nK{6$ mj=&BVmvZ6jSNj=fZZdO@Q{f&?ZmRO zsZN=DohU5-wZ)|~Mvklv+?$GXlqBfgb<+YKX`v0` TS5JAv4R7 zOsnRPbaiewZJ&skHe|18kkyCsN>j8_WvwK}D+bRB0j9YBKfgESwJ5}`C@cwuLWmfJ z<;QEmxoMGA(?-Nr8W4%JrK^%6Ndhtr9tfP`8W#wgk!z(gqG^W?4f74g5GSNGMC4vP zG8K7{YSrE0{`rSW#kBn~L@od#Qj#PR;{4UdLkpzJ5|09TQ|X3)N98<--UVtVI|2k( z>4M(fkX7U;j SK$od#OMq z`cK>yj3bQ`z57|YS644N0I1Tm4%7N9h{mE1Wm&rPwwWDCy4k6-;}FTkNHV)6OP3rw z2bB0INiG4JgMll0H@YM9sn0b `rMIqtwrMH1TAc-ncO^QAS?qsB?&1>q%2U5 z 5W42l?QArshPYi4{%yDRAHD_lAMryEWYS9TakS2kyb!?N>+Fj1aPGtw*3I< zb b%Wlq!oEoV{p( z-4M)m56Pw-L^rPp!5YY0vM6?F;Z+KpJ6%39Lw>;3Qmewiw9vA2i6H{vd@P|bnTBxr zvvetk6|^9n1Z3YWewc_Zgv$~HHHf!?c0)inIU$>M5|imSIUxwxk|sdbpaSW^57&-E z#F!m)&1}#Lg8msp`bb=1=0(V?9tBE2&ISvHkm5-DY2`z)%P=gX_?L)~HPizUgYBSh zdFE` jzh$T+z8E`rAyQn6*-BtXG0X0KzrHW!Om)EjSUGd zDsnPZ&0b_cOF;33^ukW|9BAIb!WB80nx+bEZUH+5bD^mz;uSHQK(zs!CqgTf*^vvO zVG^a%5_gc2#C3}$JfTHn@oLY;)*?GJ4Nz@D)j`StuG{I*dfV)?sf8`HKr<6l04`Ir zt$K*9q7HDx9>n2l;JRhD >x()Ah1{V~9Bn^vR?mF}B+n=oLzgl{gJFqi18aU$CyYLvDKV z^t|}bc O=|W07QWMxk*?ZRcFRK^ttY)*FZ?xzn1f@l z^=?0|cuu0)Zx-A&lH_(%J^|YQk?d+G$5uK{Ub132U&UD?MY5~zmiyf~k9)md@N*0? z2Z@F|$4BGK_JcsZUn5D;CE zMTVFIq_+mO&ALdD6y1$7C{m4Sp%bLY82@FriZ=!iFd7gUs@(3vdDxS6+you2w+FZE zy{F-jB{*UgsE$~Hk+XhW`Mf>2IpJE;9*UTS=xxF68+lKz`(zq-uH{^40(|gBzxK;o z+Oiim?WE1SX^(NBDp_yasu#{KhI}B%w|pY0{IP{h+YZ2{on$i=>J^%7w(SPM6!<65 zTLe0yH~6-17e0UMpiMi;woN~5*#o*^=V2%<-FcXFx2~BsTSe;`#7899>E OdRGM1gD!qo^N@zXZvc-^XE3QQQD%Lw(W;4 zdcbB|w8&;#uxPxElFRyP&qijxMd0&|?+k4}uX#4@Bz=^-Gb`I%uC~gVCL6u_S)At^ zg+Jdu+&O+#_iWlpjG6w@PQR!ntHWettZFis`F10*-1NiphuL4+>01TQ_e!3!4hJ)p z?Q)YbS#JG6bYYhLm8?=$wmh4e!fLtI$gDS5>SFTidiru!(esUG;#z8Wy@ByrZ!^Me z)HC=~D}6Knp~~?vy=4XSdK)9P-t4 kIGk8LAMfqy_pc!TTZ0=MGbu=yU^8H z=_bOjw-my9 Sw9waci;vD%CF;yrIlg0~*9fk+kB9aeujcz|PWevZ z^Zk =6{xW>}>U?yJpN5P^(MAd%6Q(xufFQOT zUUsW}A-%AdEec}0p%Y#_vRAk@_D-}4XuC7;PJ`?%K!Lab+>MVP_AU_0ehUTQl12M$ zYGDg2;P)F5%6>cXC|=>-*lH_<-*1Q5ep9hlPiXk$cZ)4ELet=?dco|scHM)*mc)bo z)TPU=m}WPb=)0TjBQ`B)H$E7n+ugS9vsc)@aFq-)~bu`^`T3`C_Mo2m8$mXusua z?H6{=7tnsor%yTynikp@GYcE;a%g8w64HMA8a&$AXj9<#+ZWVv6A!q**m%JG1%KQG z;p0}4B~yW2=b(HsqZrm+34Pp3c5lideB4kT^j}G=H9nVC3{&0Ef5~~Zq>tMgED8d5 zqG2Z@u-tGXVHyy5S{T6Yb9)&U23D76CcfO1NLz|zz^;EpR72Daz^q{;E*iut;NwQ4 zvG{0QGo;%N%ax}BEVp{nRYBN>{2PWWL|6e20#K7h3B$U_jbq3J;Q8nx73jEiKkSs} zGbx4HzbircL5^FeoNH^4V4#}b6W)CKzEkwf_vzI%fwHeaAWDWR9^e-kTL?SH648JF zTkoWW7Sw Kt2_qPhEkxh2Xc6{-KsUBB1ljF zxTw|%?!28f6gTxo#}3s}p!25oWejq=VeQ1N&D5-&vc{ a6W `@$}-Sy3irS;NAvlnl72;5vOz>}rVZM~?dOg@=ktjOaNd4k0Q-xQc++~* zgZ4zqWDD^PSm%?zZi3B@>`~%VGr7NVmbm>{ rzO?xWyHIp|amWUCqEw7#QbwfBY?H%A}038?r2tc~6 zcSkKjtB2%uTORRr_3-DqE%SO8sF{V^lRXvy`8FkGwUjsKD!XL~pRSvuW*Tk-3J0;5 z0S1uh-4p3p-_j&Y63Hhd29Gg> UNWJ ztjRgHga3bY<=#?C%GFF2lX~ITwP^a3UgUKRc0EF{t`$?Jgd${8Gu7qbsc+KlUMOxq zAE-yRl%D_tNVf~10D54N jxy$0<3SJ7PluidtB~fIBf4m7RT;4t^ymd0|%=v zZoz)>4cK+L2z*{dTiNP1?vE{wygTe6Iz}4+1V9BWzz1R=eL8mGq}SNfnZG;jhrb4n zaz?9PS$_WTT^n#mx&+^;(`~KpbDOFr&~BWaS2nX>lb0@J!B!_kW uOL&OkaMI)}1Lh8aXEs+gs}pJ|SDb{n6GGhxR9nk*59F;zppk#)A_oAm?Av<) literal 0 HcmV?d00001 diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp new file mode 100644 index 0000000000000000000000000000000000000000..c2b577912bdf66acf1b584479ba1a3eb9c5e7a83 GIT binary patch literal 6338 zcmV;z7(M4wNk&Gx7ytlQMM6+kP&iDk7ytk-zrZgLO*m{MH*T;;+Idd>H!PA;2@(CD z01iLH6Cmp%Vv~=e(Z(Q7Xkwy*6AjpON4kW1z>?z#uq6bjq0Pt$>dk-v(wUVlNtBi( zS6KfJS-!MSzE||q|NH%3_!U_pM-{~ Th zKfF%nI-=`DuF-WM*U+1>8*eMLpesHfD5| oZ*jd z+1xv $Ig)^ jOwrVRq-*I<$CtLC&2x4$q;lPa~M=?3~ z-NEkc0v`Yr|7U`W2u3eJqr0D*?L`3MbuE(sigM1WMg)7>1I+i}zJ_fvJj2*xR1D9+ z3Ijlpk5C1nfj@$85MEIFAw?jcZmyI#k0=p9AxQ#upV~d90jDw )d{BuPNK19Gn96kkXJ!?&5bVy>`An4`=CwV `2mnZc0afdHQQs?BzQe4R70Oz$6wCp3k~}>`^eUdRBf6G= z3>X0Ig14dN#|#0|HTldlaUp@}6-$Nf%4T5^s3OV5haZT;j}sF~UMXF|Tjk|z+CjNv z_n96kro{=ESF8~Bfn8uR(09s?n@D1 2d>PpVFtiKDz6*9_$hyYG6SHq)y~LYz;eKOaQlwO z3dEh@Xf}TVS57481fRU*djjR`2vHUS_8oWt<^k|!N^q^cT}iINWeS46H=MiVQsQjn zE{uH+gX^gzaHut04g!e;Fz@0OhXTNI1~Ma@`)q8u43^Vtq9>^{vtdm%4g%u*EL_=V zBRz7@oJM|yqv15zLN3V$sya1=C0UOa(m!trlqu_6lEQYd59pHi?Wx0vHR*G5^ZAx! z 45b4@*~HRuPm%pHj+R~+N+jS7jRqt8%X)gIH_zD z)@CY(^9NXk&7eN{W$_SA0gg4Tr4AI%!j{i;K;<}EA21KBOMgw!=?-5Md{JQckp9{R zYYv#trR1k3ECkaG#w5F*2>|II4K8nG`b~aXMVJjZi>~muu`A~fCvcm-kEFl}v!~3$ zcd>14o``$HMD^rvZC+;E?_kf0H&g%@m!JV}M*B<_e&OxcRA$u19#&9cG!O|ip?3Y& zXqeqLSTkwDDcByY#?hV(m0EpEBM;aHJ3Wh_u)t(yvisCW?dHMEaK+TZX5b?fJTa_z z2Sfn7D`o<87-xA l?grV^Z0cD)oP#g{Ep{BITuLG8RFiyARK#7JtTZrcF z?I%m?h0;6I^ol$v((T~Xabu!&G43pfEv2*~>vksZlL8r9x|%%ppfbF_Hthh&z92<6 z3O^(Z{0vK6>K4lNWIJzzz)wh eC>!%NygRhmcpg(wpi(V-SqeY07x|Z)kOa_81OFaGG| z`1p k7}xu;cR%L3~`|`j_M4Jo~h2(Jz4lkc#+CLfPP8>{GY*ow}IKCpX8e_ z_B%KQz>C*}N|IefIQaPyU_i{N3Pw9U@64$aDF56`lGn#;n!!s}n)6QZqS$*X9}alW z1W}>#O$K#<(M)jE56qwn_yLgO8wY>QBjDvibDD!S)A8V>eHGApz#l-{7{CA#e3nBD z52rUczPV-&5%~7;u5NnI`DgqB+H)v}_J%dl5fgJLR(fCa4Uhrej0sTSAt#_^z^mYf z$sn3$;j<_k#y|%exX>O}g9pz;Fo)uMz>d>K0O}R{vnYorZcOl8yki!{`y7wh=x%ut zu5w`pJO_T99HJRq1oX0j-^D9dgPnlRLAxgH!F73^gYr(;_>!#wm`%2O!r2>V26SXP zxVG<^L)M*eE|&FUe}w}a%p=`$gu?aoHjAVH!j6|r_w}O?_JT?D9C#UmKv^6J91Vv7 zsx-EajBCw7n2L|H4@O}C1Kn@fEz>0lV#hO!( zhp-b^Y8E`4Ch%SVWgrWxzzKVzuDRm9YuTMgIg{>X5Jk$OSO8@!FgZZ$rN3ftr5th0Ms|Ej#%T>3=mks zJX#&Sksgg+c{EC8d8vcGoZ!rX&i%JR#-`3;9*qi%fzAh$5ifBGv&bxXzJH{#o=+y7 z6Q(?8dc-hbI&d bh_=CZ!X;V^t|7u(<>*I zS>R8HFcp}FXU$jGgvq95oY*#*c515QumW(=qaI@no1whDpw^{YYBN-?oe$sMJ#Vz_ z95PAS6!Ip)@sjGHnKJ^o6+6QSS`F!qz);+!3E?c4dKX~aruA$adY4<}D*(W%I};Z` z&=v@1b^ItPRL|LB0n-K)1K Qq{V(7=L8F%2mK2Z5FxtDm8&O?#Zu%hGtznD9;QMGY4lm<9mJY%8J#mdngHr{Wws zNglwJwx(5`o(PPjxh#x47i*;xg9~RlPlf%QxCj_K)5Dy?wMI;4ULPnphW36HpeY?T zxx#BIZE;mSOfUzg?& js!9#vD-RpwLQ|3@qeZ2(Z!(92i(&37a~bjO{IKaDFaKi6;8#2gtSqro z*(U;wFR=isR|1GDSio*FNVqdp0)25;{swFj76-AClmL{t9ONFS^?3?8dCPAGSy8wa z2+G7K7+W|3)GsK8r!{flzFbVEE%tIMzoX5x{9MVQ#sC0Qg5@%psu(BS-X@-6%w*E+ zaEL?&CslZWUNJ_02t@~UYdqa-xamCORHrdj1^M5}@g;%`038|tu&gs@EDHe2W%4R6 zA8Fv?_!QHTY_9AjVd_#CpdT9Mo z;mbRkxm%vKn9y4FEas9ToicYJ2dYhdgI68S>OTW=s5)n$pgSL7Q3IBBOS9OP1;*!@ zwoW4EQmV71UM}xt(z%dp*^ab|LJuUU;8O_DoXJy5M;$Mu3cqvlrvniN#CuJd|1bU7 z!6h?riefHqc^K)+p?ww&T>zfgN00;>Gjm;ONPBEz!b;l!X7DNTS)UCR*>XAA>pcVw zsHe{D9z}9N;DPh#87fw(SZY#26A=vm)^KhX*#)3Fpu9h^D5P)ZErPp1cP6isZIK01 z#HfNRcqS$QUKU?MD2nPXiUt%8R6If!q@f`{D$9nTDl=+(E&yK{Q2>A#6E68Veqdsx zN|LiWbq9**i5jF8%FwGA$Ut0w-kfZC?>DV>#;#oTYePbY0zi%!C1E8109!643V@O8 z)L|7*zqtj{&`8lB0AZtb%TNURS{~~by9P3T)Df8|0RY3XAO!&Mp_N@?Q2@X>LA; z>J~*5_@Toa1W?Xo-AKpH{Z$)F69LW|U~vKyj1yGSWoio9&Qo=)BB>hze@MCi00==D z6qBvn39%ZQAsj+&ITUMz#dDemViQh7flL~&(2ZZCM z-b@{I3b7I;o=TRRmD^`4$S^mq{kfJzHg5WNEBv HO0gA5hFJ3vI0)ik<&*8r1deGMe zqG>NG7t9>}Xh9YfuGR5NNui2HrKkklhrTvMi7F}U`^N23%^PP{3WSo8SlS5JjS|A$ zO8_8cYem`4AqqQhtmGA+*WPNZ{)Rm8(Q~+O@66spb$;Q4SXrf;A_ym 4AZmVcgSO9aZo_;9}%iR z^&v`CIx;EnCL1ybxARC-=><|*1|zR4x`2{L!KwKnbNy5vC~8Y*<*?#Xl|IE!=0L0h zA`{T XN z zT{C`=~~f0j=t7mjHTr{0jfE4Y#DrY&HzBl-0}*@WxK|K5mC4z38v){{Q`L) z>$e=Ohy$&x`6anQK#$L{3RPL)uNb)A&RGC#4Rt`=t$m9o0Op-AJBy^3g~D~~S)@OL zJa=n`2r^po4VBD+MEMu4hxa+4z#er Dh8-u1k8M>!j!wM3*b-Gf zWHI;uEzF+t@h*!wUukU#FxHnz?n3!#o-&AJHU`h_RR-}y 9PQ5fDrQRqg$u*#XB* zwGV(qgL%bh-TXxfa0m(DEHo!b_=e-@!oD}Rphn0N&?&YhEs+qgn@8p%I4)=N*eAiz z;-_;H?XF41g|RPTU*+2kD~f#`ffDSB-zPG-VPKtF@&XT7nv*PyU+K&r?E9ja6j@14 zm799HJAFluqTc~EjEQKj|N1WPtyw}awshBUcK}Qf)dv3;^D60Jl!$(BJ!ReswuZ>q zjT0zVZwV`fsagxacRi3n&-L$J1I!;n!Gh2-!J24FcMo6DCNOkkM1h)uD9&9(gLR7n z0NoL-9_~y;1$Yrgh3NuV7cZk$=`q8Y2}}}vKn56)L^$a{VN1ASIWSaX|Fd*sSmz0P zVMU9dKyfmOk4}RX(86N6t@kArw9l8udE8?mV7e^^lVz3ij2bY7G-YMB2JPbBxD$$V z4`+#PUOJYk>@fpW+3G|}r=Phmwo0Jl8FK*wcu9=c5(P{ZjI_l36}0!Zi!Q+cCYLBR z`ERaGJBH%2)%h3X;PvtE0b3>D3f@hn1k+N=E{ae`HzW;m{{DV4-RlW80LO`xHUwEp znc_J{V6znC_3xlu{ M^S{T{g@qhr8R$AW*5x`1~iYgE~pcu59JX9ipDDYO6N4Tba z0V^5s_fdGd4cm-{VoQPH7VKm_nqS&on8=}kQWT2aoG_Q@JfZ}0vx$!@B( j#4n}}~R;bi#XECb&e&?yX8EgrFAqTj2 zx=S*fqUwieEpeqAlv6*fxtHVj^)e@=#@tdCdRn4~Xn-u8oQz=Y^h0O? ycC+f^E`EOGCEYRwF1gl^ng~tr~P8g;EIQWKMicteyyb KqcP1}&?@||`27MXCq3(t^Al>V 9|YFc%sBiLsuNfW$W~o_bopu-Vd{aqq1yh?6)Ln zJ7WF-T9iLq-X3>qyNqL3c4q5p?QSBY1@ECz@i)X3MyOIuAwXrsCNx{H@zo5F0<(~A z@d*E{X!Uvs4P+n%8KqWc?cB*8A(SW>-wK>Cvv68$HaG4LFcnyoQRSWTNbxx!hfrG} zl< l?O$#X>y=U?n3GpZ>ym1`*KN@}}j3z&j5iBalXt z5v3Gn4oUW8&|A>m9wef{p{!|G3+CREQ?egFclRir9MIp=C^WPL-48)P1=0xQ@nh#c zQ3QEb0_mi)7J#3lMX >s6bqR zJZ`sRM2Su~O9I_VK<@xkPBIkB9&^9~VGfuKYJqtoC$2l`9iJI;<_roT27JmO=vgr! z )Dcevgri4 F*nwNAyK1OW zY=T)~fgb24fu7=couep=ASuGVk{7Zq2_-E=lweEdzS8Lc9Rka?F5^j9#=7{anet2u z?%g%LS3d;ZMB#JbltB?i(F4;SIz?9sYZasw5-1B|cOmO!IRsc8NHt1jXP{<@!r<9; zetJ*B;K|{ll!?3Nz!JXB+g3gSg-fmV !v6tJhPn z5S}uyn-dH_4 + + + \ No newline at end of file diff --git a/app/src/main/res/values-night/styles.xml b/app/src/main/res/values-night/styles.xml new file mode 100644 index 0000000..e5f8fdc --- /dev/null +++ b/app/src/main/res/values-night/styles.xml @@ -0,0 +1,2 @@ +#D4FF00 +#0A0A0A +#121212 +#1E1E1E + + +#007AFF +#FF3B30 + + +#FFFFFF +#A0A0A0 +#48484A +#1AFFFFFF ++ \ No newline at end of file diff --git a/app/src/main/res/values-night/themes.xml b/app/src/main/res/values-night/themes.xml new file mode 100644 index 0000000..631a2fa --- /dev/null +++ b/app/src/main/res/values-night/themes.xml @@ -0,0 +1,7 @@ ++ + + \ No newline at end of file diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..b8947fa --- /dev/null +++ b/app/src/main/res/values/colors.xml @@ -0,0 +1,24 @@ + ++ + \ No newline at end of file diff --git a/app/src/main/res/values/dimens.xml b/app/src/main/res/values/dimens.xml new file mode 100644 index 0000000..367211d --- /dev/null +++ b/app/src/main/res/values/dimens.xml @@ -0,0 +1,23 @@ + +#B3D900 +#F2F2F7 +#FFFFFF +#E5E5EA +#0A0A0A + + +#0056B3 +#D70015 + + +#1C1C1E +#6E6E73 +#AEAEB2 +#1C1C1E +#1F000000 +#FF29B6F6 +#FF039BE5 +#FFBDBDBD +#FF757575 ++ + \ No newline at end of file diff --git a/app/src/main/res/values/ic_launcher_background.xml b/app/src/main/res/values/ic_launcher_background.xml new file mode 100644 index 0000000..635da4b --- /dev/null +++ b/app/src/main/res/values/ic_launcher_background.xml @@ -0,0 +1,4 @@ + +48sp +24sp +16sp +14sp +12sp +10sp + + +4dp +8dp +16dp +24dp +32dp + + +12dp +24dp +56dp +4dp ++ \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..db79c0b --- /dev/null +++ b/app/src/main/res/values/strings.xml @@ -0,0 +1,36 @@ +#0A0A0A ++ \ No newline at end of file diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml new file mode 100644 index 0000000..7275ee8 --- /dev/null +++ b/app/src/main/res/values/styles.xml @@ -0,0 +1,90 @@ + +Oche Companion + + +Immediate Action +Quick start +Start %s +Your Squad ++ Add New +Last Match +No matches yet +VS +Group Standings +AVG: %.1f + + +Create Profile +Add your username +Save to Squad +Update Profile +Update Squad +Cancel +Confirm Crop + + +Single +Double +Triple +Bull +Submit Turn + + +Application Logo +Settings +Match History ++ + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml new file mode 100644 index 0000000..f40ee32 --- /dev/null +++ b/app/src/main/res/values/themes.xml @@ -0,0 +1,9 @@ ++ + + + + \ No newline at end of file diff --git a/app/src/main/res/xml/backup_rules.xml b/app/src/main/res/xml/backup_rules.xml new file mode 100644 index 0000000..4df9255 --- /dev/null +++ b/app/src/main/res/xml/backup_rules.xml @@ -0,0 +1,13 @@ + ++ + \ No newline at end of file diff --git a/app/src/main/res/xml/data_extraction_rules.xml b/app/src/main/res/xml/data_extraction_rules.xml new file mode 100644 index 0000000..9ee9997 --- /dev/null +++ b/app/src/main/res/xml/data_extraction_rules.xml @@ -0,0 +1,19 @@ + ++ \ No newline at end of file diff --git a/app/src/test/java/com/aldo/apps/ochecompanion/ExampleUnitTest.java b/app/src/test/java/com/aldo/apps/ochecompanion/ExampleUnitTest.java new file mode 100644 index 0000000..da07845 --- /dev/null +++ b/app/src/test/java/com/aldo/apps/ochecompanion/ExampleUnitTest.java @@ -0,0 +1,17 @@ +package com.aldo.apps.ochecompanion; + +import org.junit.Test; + +import static org.junit.Assert.*; + +/** + * Example local unit test, which will execute on the development machine (host). + * + * @see Testing documentation + */ +public class ExampleUnitTest { + @Test + public void addition_isCorrect() { + assertEquals(4, 2 + 2); + } +} \ No newline at end of file diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 0000000..3756278 --- /dev/null +++ b/build.gradle.kts @@ -0,0 +1,4 @@ +// Top-level build file where you can add configuration options common to all sub-projects/modules. +plugins { + alias(libs.plugins.android.application) apply false +} \ No newline at end of file diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..4387edc --- /dev/null +++ b/gradle.properties @@ -0,0 +1,21 @@ +# Project-wide Gradle settings. +# IDE (e.g. Android Studio) users: +# Gradle settings configured through the IDE *will override* +# any settings specified in this file. +# For more details on how to configure your build environment visit +# http://www.gradle.org/docs/current/userguide/build_environment.html +# Specifies the JVM arguments used for the daemon process. +# The setting is particularly useful for tweaking memory settings. +org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 +# When configured, Gradle will run in incubating parallel mode. +# This option should only be used with decoupled projects. For more details, visit +# https://developer.android.com/r/tools/gradle-multi-project-decoupled-projects +# org.gradle.parallel=true +# AndroidX package structure to make it clearer which packages are bundled with the +# Android operating system, and which are packaged with your app's APK +# https://developer.android.com/topic/libraries/support-library/androidx-rn +android.useAndroidX=true +# Enables namespacing of each library's R class so that its R class includes only the +# resources declared in the library itself and none from the library's dependencies, +# thereby reducing the size of the R class for that library +android.nonTransitiveRClass=true \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml new file mode 100644 index 0000000..4e76b86 --- /dev/null +++ b/gradle/libs.versions.toml @@ -0,0 +1,27 @@ +[versions] +agp = "9.0.0" +junit = "4.13.2" +junitVersion = "1.3.0" +espressoCore = "3.7.0" +appcompat = "1.7.1" +material = "1.13.0" +activity = "1.12.2" +constraintlayout = "2.2.1" +glide = "5.0.5" +room = "2.8.4" + +[libraries] +junit = { group = "junit", name = "junit", version.ref = "junit" } +ext-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" } +espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" } +appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" } +material = { group = "com.google.android.material", name = "material", version.ref = "material" } +activity = { group = "androidx.activity", name = "activity", version.ref = "activity" } +constraintlayout = { group = "androidx.constraintlayout", name = "constraintlayout", version.ref = "constraintlayout" } +glide = { group = "com.github.bumptech.glide", name = "glide", version.ref = "glide"} +room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room"} +room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room"} + +[plugins] +android-application = { id = "com.android.application", version.ref = "agp" } + diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000000000000000000000000000000000..8bdaf60c75ab801e22807dde59e12a8735a34077 GIT binary patch literal 45457 zcma&NW0YlEwk;ePwr$(aux;D69T}N{9ky*d!_2U4+qUuIRNZ#Jck8}7U+vcB{+ + + +`IjNZqX3eq5;s6ddAkU&5{L|^Ow`ym2B0m+K02+~Q)i807X3X94qi>j)C0e$=H zm31v`=T&y}ACuKx7G~yWSYncG=NFB>O2);i9EmJ(9jSamq?Crj$g~1l3m-4M7;BWn zau2S&sSA0b0Rhg>6YlVLQa;D#)1yw+eGs~36Q$}5?avIRne3TQZXb<^e}?T69w<9~ zUmx1cG0uZ?Kd;Brd$$>r>&MrY*3$t^PWF1+J+G_xmpHW=>mly$<>~wHH+Bt3mzN7W zhR)g{_veH6>*KxLJ~~s{9HZm!UeC86d_>42NRqd$ev8zSMq4kt)q*>8kJ8p|^w uKx zq2Is_HJPoQ_apSoT?zJj7vXBp!xejBc^7F|zU0rhy%Ub*Dy#jJs!>1?CmJ-gulPVX zKit>RVmjL=G?>jytf^U@mfnC*1-7EVag@%ROu*#kA+)Rxq?MGK0v-dp^kM?nyMngb z_poL>GLTh B7xAO*I7&?4^Nj`<@O@>&0M-QxIi zD@n}s%CYI4Be19C $lAb9Bbm6!R{&A;=yh=#fnFyb`s7S5W3?arZf?$khCwkGN!+GY~GT8-`!6pFr zbFBVEF`kAgtecfjJ`flN2Z!$$ 8}6hV>Tu;+rN%$X^t8fI>tXQnRn^$UhXO8Gu zt$~QON8`doV&{h}=2!}+xJKrNPcIQid?WuHUC-i%P^F(^z#XB`&&`xTK&L+i8a3a@ zkV-Jy;AnyQ`N=&KONV_^-0WJA{b|c#_l=v!19U@hS~M-*ix16$r01GN3#naZ|DxY2 z76nbjbOnFcx4bKbEoH~^=EikiZ)_*kOb>nW6>_vjf-UCf0uUy~QBb7~WfVO6qN@ns zz=XEG0s5Yp`mlmUad)8!(QDgIzY=OK%_hhPStbyYYd| ~zDIc3J4 zy9y%wZOW>}eG4&&;Z> vj&Mjg+>4gL! z(@oCTFf-I^54t=*4AhKRoE-0Ky=qg3XK2Mu!Bmw@z>y(|a#(6PcfbVTw-dUqyx4x4 z3O#+hW1ANwSv-U+9otHE#U9T>(nWx>^7RO_aI>${jvfZQ{mUwiaxHau!H z0Nc}ucJu+bKux?l!dQ2QA(r@(5KZl(Or=U!=2K*8?D=ZT-IAcAX!5OI3w@`sF@$($ zbDk0p&3X0P%B0aKdijO|s})70K&mk1DC|P##b =k@fcJ|lo@JNWRUc>KL?6dJpvtSUK zxR|w8Bo6K&y~Bd}gv uz*3z z@sPJr{(!?mi@okhudaM{t3gp9TJ!|@j4eO1C&=@ h#|QLCUKLaKVL z!lls$%N&ZG7yO#jK?U>bJ+^F@K#A4d&Jz4boGmptagnK!Qu{Ob>%+60xRYK>iffd_ z>6%0K)p!VwP$^@Apm%NrS6TpKJwj_Q=k~?4=_*NIe~eh_QtRaqX4t-rJAGYdB{pGq zSXX)-dR8mQ)X|;8@_=J6Dk7MfMp;x)^aZeCt ScHs12t3vL+p-6!qhPkOM1OYQ z8YXW5tWp)Th(+$m7SnV_hNGKAP`JF4URkkNc@ YV9} FK$9k zR&qgi$Cj#4bC1VK%#U)f%(+oQJ+EqvV{uAq1YG0riLvGxW@)m;*ayU-BSW61COFy0 z(-l>GJqYl;*x1PnRZ (p3Lm}* zlkpWyCoYtg9pAZ5RU^%w=vN{3Y<6WImxj(*SCcJsFj?o6CZ~>cWW^foliM#qN#We{ zwsL!u1$rzC1#4~bILZm*a!T{^kCci$XOJADm)P;y^%x 5)#G#_!2uNp^S;cE`*ASCn;}H7pP^RRA z6lfXK(r4dy<_}R|(7%Lyo>QFP#s31E8zsYA${gSUykUV@?lyDNF=KhTeF^*lu7C*{ zBCIjy;bIE;9inJ$IT8_jL%)Q{7itmncYlkf2`lHl(gTwD%LmEPo^gskydVxMd~Do` zO8EzF!yn!r|BEgPjhW#>g(unY#n}=#4J;3FD2ThN5LpO0tI2~pqICaFAGT%%;3Xx$ z>~Ng(64xH-RV^Rj4=A_q1Ee8kcF}8HN{5kjYX0ADh}jq{q18x(pV!23pVsK5S}{M#p8|+LvfKx|_3;9{+6cu7%5o-+R@z>TlTft#kcJ`s2-j zUe4dgpInZU!<}aTGuwgdWJZ#8TPiV9QW< -o!ib Bn&)?!ZDomECehvT7GSCRyF#VN2&5GShch9*}4p;8TX~cW*<#( zv-HmU7&+YUWO__NN3UbTFJ&^#3vxW4U9q5=&ORa+2M$4rskA4xV$rFSEYBGy55b{z z!)$_fYXiY ?-GWDhGZXgTw}#ilrw=BiN(DGO*W7Vw(} zjUexksYLt_Nq?pl_nVa@c1W#edQKbT>VSN1NK?DulHkFpI-LXl7{;dl@z0#v?x%U& z8k8M1X6%TwR4BQ_eEWJASvMTy?@fQubBU__A_US567I-~;_VcX^NJ-E(ZPR^NASj1 zVP!LIf8QKtcdeH#w6ak50At)e={eF_Ns6J2Iko6dn8 Qwa6!NQHZMGsD zhzWeSFK<{hJV*!cIHxjgR+e#lkUHCss -j)$g zF}DyS531TUXKPPIoePo{yH%qEr-dLMOhv^sC &@9YI~uvl?rBp^A-57{aH _wLg0&a|UxKLlYZQ24fpb24Qjil`4OCyt0<1eu>5i1Acv zaZtQRF)Q;?Aw3idg;8Yg9Cb#)03?pQ @O*bCloG zC^|TnJl`GXN*8iI;Ql&_QIY0ik}rqB;cNZ-qagp=qmci9eScHsRXG$zRNdf4SleJ} z7||<#PCW~0>3u8PP=-DjNhD(^(B0AFF+(oKOiQyO5#v4nI|v_D5@c2;zE`}DK!%;H zUn|IZ6P;rl*5`E(srr6@-hpa e!jW=-G zC<*R?RLwL;#+hxN4fJ!oP4f X`vC3&)o!#l4y@MrmbmL{t;VP%7tMA-&vju_L zhtHbOL4`O;h*5^e3F{b9(mDwY6JwL8w`oi28xOyj`pVo!75hngQDNg7^D$h4t&1p2 ziWD_!ap3GM(S)?@UwWk=Szym^eDxSx3NaR}+l1~(@0car6t fP#sZRTb~w!WAS{+|SgUN3Tv`J4OMf z9ta_f>-`!`I@KA=CXj_J>CE7T`yGmej0}61sE(%nZa1WC_tV6odiysHA5gzfWN-` uXF46mhJGLpvNTBmx$!i zF67bAz~E|P{L6t1B+K|Cutp&h$fDjyq9JFy$7c_tB(Q$sR )#iMQH3{Og1AyD^lyQwX6#B|*ecl{-_;*B>~WSFInaRE_q6 zpK#uCprrCb`MU^AGddA#SS{P7-OS9h%+1`~9v-s^{s8faWNpt*Pmk_E Cjt(wrpr{C_xd AqR(@!ERTSs@F%^DkE@No}wqol~pS^e7>ksF_NhL0?6R4g`P- zk8lMrVir~b(KY+hk5LQngwm`ZQT5t1^7AzHB2My6o)_ejR0{VxU<*r-Gld`l6tfA` zKoj%x9=>Ce|1R|1*aC}|F0R32^KMLAHN}MA<8NNaZ^j?HKxSwxz`N2hK8lEb{jE0& zg4G_6F@#NyDN?=i@=)eidKhlg !nQoA{`PgaH{;t|M#5z}a`u?^gy{5L~I2smLR z*4RmNxHqf9>D>sXSemHK!h4uPwMRb+W`6F>Q6 j@isZ>-F=)B2*sTCD9A^jjUy)hjAw71B&$u}R(^R; zY9H3k8$|ounk>)EOi_;JAKV8U8ICSD@NrqB!&=)Ah_5hzp?L9Sw@c>>#f_kUhhm=p z1jRz8X7)~|VwO(MF3PS(|CL++1 n|KT3*dhGjg!t_vR|8Yg($ z+$S$K=J`K6eG#^(J54=4&X#+7Car=_aeAuC>dHE+%v9HFu>r%ry|rwkrO-XPhR_#K zS{2Unv!_CvS7}Mb6IIT$D4Gq5v$Pvi5nbYB+1Yc&RY;3;XDihlvhhIG6AhAHsBYsm zK@M gSzs~y|+f|j-lsXKT0(%E2SkEb)p+|EkV5w8=F^!r1&0#0^tGhf9yPZ)iLJ^ zIXOg)HW_Vt{|r0W(`NmMLF$?3ZQpq+^OtjR-DaVLHpz%1+GZ7QGFA?(BIqBlVQ;)k zu)oO|KG&++gD9oL7aK4Zwjwi ~5jqk6+w%{T$1`2>3Znh=OFg|kZ z>1cn>CZ>P|iQO%-Pic8wE9c*e%=3qNYKJ+z1{2=QHHFe=u3rqCWNhV_N*qzneN8A5 zj`1Ir7- 5`33rjDmyI GvTx4K3qsks(I(;Kgmn%p#p3K zn8r9H8kQu+n@D$<#RZtmp$*T4B&QvT{K&qx(?>t@m X%3Lh}sr?gI#vNi=vV5d(D<=Cp5-y!a{~&y|Uz*PU{qe zI7g}mt!txT)U(q<+Xg_sSY%1wVH y;Dv3uze zJ>BIdSB2a|aK+?o63lR8QZhhP)KyQvV`J3)5q^j1-G}fq=E4&){*&hiam>ssYm!ya z#PsY0F}vT#twY1mXkGYmdd%_Uh12x0*6lN-HS-&5XWbJ^%su)-vffvKZ%rvLHVA<; zJP=h13;x?$v30`T)M )htph`=if# r#O5iC^ZHeXc6J8gewn zL!49!)>3I-q6XOZRG0=zjyQc`tl|RFCR}f-sNtc)I^~?Vv2t7tZZHvgU2Mfc9$LqG z!(iz&xb=q#4otDBO4p)KtEq}8NaIVcL3&pbvm@0Kk-~C@y3I{K61VDF_=}c`VN)3P z+{nBy^;=1N`A=xH$01dPesY_na*zrcnssA}Ix60C=sWg9EY=2>-yH&iqhhm28qq9Z z;}znS4ktr40Lf~G@6D5QxW&? q^R|=1+h!1%G4LhQs 54c2Wo~4% zCA||d==lv2bP=9%hd0Dw_a$cz9kk)(Vo}NpSPx!vnV*0Bh9$CYP~ia#lEoLRJ8D#5 zSJS?}ABn1LX>8(Mfg&eefX*c0I5bf4<`gCy6VC{e>$&BbwFSJ0CgVa;0-U7=F81R+ zUmzz&c;H|%G&mSQ0K16Vosh?sjJW(Gp+1Yw+Yf4qOi|BFVbMrdO6~-U8Hr|L@LHeZ z0ALmXH sVm137&xnt#yYF$H%&AU!lf{W436Wq87nC16b%)p?r z70Wua59%7Quak50G7m3lOjtvcS>5}YL_~?Pti_pfAfQ!OxkX$arHRg|VrNx>R_Xyi z`N|Y7KV`z3(ZB2wT9{Dl8mt l zg^UOBv~k>Z(E)O>Z;~Z)W&4 FhzwiPjUHE9&T#nlM)@hvAZL>cha-< zQ8_RL#P1?&2Qhk#c9fK9+xM#AneqzE-g(>chLp _Q2Xh$=MAsW z2ScEKr+YOD*R~mzy{bOJjs;X2y1}DVFZi7d_df^~((5a2%p%^4cf>vM_4Sn@@ssVJ z9Ch Ghs zbanJ+h74)3tWOviXI|v!=HU2mE%3Th$Mpx&lEeGFEBWRy8ogJY`BCXj@7s~bjrOY! z4nIU5S>_NrpN}|waZBC)$6ST8x91U2n?FGV8lS{&LFhHbuHU?SVU{p7yFSP_f#Eyh zJhI@o9lAeEwbZYC=~<(FZ$sJx^6j@gtl{yTOAz`Gj!A b^y})eG&`Qt2cXdog2^~oOH^K@oHcE(L;wu2QiMv zJuGdhNd+H{t#Tjd<$PknMSfbI>L1YIdZ+uFf*Z=BEM)UPG3oDFe@8roB0h(*XAqRc zoxw`wQD@^nxGFxQXN9@GpkLqd?9@(_ZRS@EFRCO8J5{iuNAQO=!Lo5cCsPtt4=1qZN8z`EA2{ge@SjTyhiJE %ttk{~`SEl%5>s=9E~dUW0uws>&~3PwXJ!f>ShhP~U9dLvE8ElNt3g(6-d zdgtD;rgd^>1URef?*=8BkE&+HmzXD-4w61(p6o~Oxm`XexcHmnR*B~5a|u-Qz$2lf zXc$p91T~E4psJxhf^rdR!b_XmNv*?}!PK9@-asDTaen;p{Rxsa=1E}4kZ*}yQPoT0 zvM}t!CpJvk<`m~^$^1C^o1yM(BzY-Wz2q7C^+wfg-?}1bF?5Hk?S{^#U%wX4&lv0j zkNb)byI+nql(&65xV?_L<0tj!KMHX8Hmh2(udEG>@OPQ}KPtdwEuEb$?acp~yT1&r z|7YU<(v!0as6Xff5^XbKQIR&MpjSE)pmub+ECMZzn7c!|hnm_Rl&H_oXWU2!h7hhf zo&-@cLkZr#eNgUN9>b=QLE1V^b`($EX3RQIyg#45A^=G !jMY`qJ z8qjZ$*-V|?y0=zIM>! 2q!Gi*t4J5Otr^OT3XzQ_GjATc(*eM zqllux#QtHhc>YtnswBNiS^t(dTDn|RYSI%i%-|sv1wh&|9jfeyx|IH owW)6uZWR<%n8I}6NidBm zJ>P7#5m`gnXLu;?7jQZ!PwA80d|AS*+mtrU6z+lzms6^vc4)6Zf+$l+Lk3AsEK7`_ zQ9LsS!2o#-pK+V`g#3hC$6*Z~PD%cwtOT8;7K3O= gHdC=WLK-i_DjPO#WN__#YLX|Akw3LnqUJUw8&7pUR;K zqJ98?rKMXE(tnmT`#080w %l1bGno7wXHQbl?QFU=GoK@d!Ov=IgsdHd-iIs4ahcgS j(L@F96=LKZ zeb5cJOVlcKBudawbz~AYk@!^p+E=dT^UhPE`96Q5J~cT-8^tp`J43nLbFD*Nf!w;6 zs>V!5#;?bwYflf0HtFvX_6_jh4GEpa0_s8UUe02@%$w^ym&%wI5_APD?9S4r9O@4m zq^Z5Br8# K)y@z*fo08@XCs;wKBydn+60ks4Z>_+PFD+PVTGNPFPg-V-