Initial Commit

This commit is contained in:
Alexander Doerflinger
2026-01-28 12:33:38 +01:00
commit a4a42fc73f
93 changed files with 10832 additions and 0 deletions

15
.gitignore vendored Normal file
View File

@@ -0,0 +1,15 @@
*.iml
.gradle
/local.properties
/.idea/caches
/.idea/libraries
/.idea/modules.xml
/.idea/workspace.xml
/.idea/navEditor.xml
/.idea/assetWizardSettings.xml
.DS_Store
/build
/captures
.externalNativeBuild
.cxx
local.properties

3
.idea/.gitignore generated vendored Normal file
View File

@@ -0,0 +1,3 @@
# Default ignored files
/shelf/
/workspace.xml

1
.idea/.name generated Normal file
View File

@@ -0,0 +1 @@
Oche Companion

6
.idea/AndroidProjectSystem.xml generated Normal file
View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="AndroidProjectSystem">
<option name="providerId" value="com.android.tools.idea.GradleProjectSystem" />
</component>
</project>

6
.idea/compiler.xml generated Normal file
View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="CompilerConfiguration">
<bytecodeTargetLevel target="21" />
</component>
</project>

18
.idea/deploymentTargetSelector.xml generated Normal file
View File

@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="deploymentTargetSelector">
<selectionStates>
<SelectionState runConfigName="app">
<option name="selectionMode" value="DROPDOWN" />
<DropdownSelection timestamp="2026-01-26T12:37:47.296146997Z">
<Target type="DEFAULT_BOOT">
<handle>
<DeviceId pluginId="LocalEmulator" identifier="path=/home/aldo270717/.android/avd/Pixel_9_Pro_API_36.avd" />
</handle>
</Target>
</DropdownSelection>
<DialogSelection />
</SelectionState>
</selectionStates>
</component>
</project>

13
.idea/deviceManager.xml generated Normal file
View File

@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="DeviceTable">
<option name="columnSorters">
<list>
<ColumnSorterState>
<option name="column" value="Name" />
<option name="order" value="ASCENDING" />
</ColumnSorterState>
</list>
</option>
</component>
</project>

19
.idea/gradle.xml generated Normal file
View File

@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="GradleMigrationSettings" migrationVersion="1" />
<component name="GradleSettings">
<option name="linkedExternalProjectsSettings">
<GradleProjectSettings>
<option name="testRunner" value="CHOOSE_PER_TEST" />
<option name="externalProjectPath" value="$PROJECT_DIR$" />
<option name="gradleJvm" value="#GRADLE_LOCAL_JAVA_HOME" />
<option name="modules">
<set>
<option value="$PROJECT_DIR$" />
<option value="$PROJECT_DIR$/app" />
</set>
</option>
</GradleProjectSettings>
</option>
</component>
</project>

10
.idea/migrations.xml generated Normal file
View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectMigrations">
<option name="MigrateToGradleLocalJavaHome">
<set>
<option value="$PROJECT_DIR$" />
</set>
</option>
</component>
</project>

9
.idea/misc.xml generated Normal file
View File

@@ -0,0 +1,9 @@
<project version="4">
<component name="ExternalStorageConfigurationManager" enabled="true" />
<component name="ProjectRootManager" version="2" languageLevel="JDK_21" default="true" project-jdk-name="jbr-21" project-jdk-type="JavaSDK">
<output url="file://$PROJECT_DIR$/build/classes" />
</component>
<component name="ProjectType">
<option name="id" value="Android" />
</component>
</project>

17
.idea/runConfigurations.xml generated Normal file
View File

@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="RunConfigurationProducerService">
<option name="ignoredProducers">
<set>
<option value="com.intellij.execution.junit.AbstractAllInDirectoryConfigurationProducer" />
<option value="com.intellij.execution.junit.AllInPackageConfigurationProducer" />
<option value="com.intellij.execution.junit.PatternConfigurationProducer" />
<option value="com.intellij.execution.junit.TestInClassConfigurationProducer" />
<option value="com.intellij.execution.junit.UniqueIdConfigurationProducer" />
<option value="com.intellij.execution.junit.testDiscovery.JUnitTestDiscoveryConfigurationProducer" />
<option value="org.jetbrains.kotlin.idea.junit.KotlinJUnitRunConfigurationProducer" />
<option value="org.jetbrains.kotlin.idea.junit.KotlinPatternConfigurationProducer" />
</set>
</option>
</component>
</project>

6
.idea/studiobot.xml generated Normal file
View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="StudioBotProjectSettings">
<option name="shareContext" value="OptedIn" />
</component>
</project>

1
app/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
/build

47
app/build.gradle.kts Normal file
View File

@@ -0,0 +1,47 @@
plugins {
alias(libs.plugins.android.application)
}
android {
namespace = "com.aldo.apps.ochecompanion"
compileSdk {
version = release(36)
}
defaultConfig {
applicationId = "com.aldo.apps.ochecompanion"
minSdk = 24
targetSdk = 36
versionCode = 1
versionName = "1.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
buildTypes {
release {
isMinifyEnabled = false
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
}
}
dependencies {
implementation(libs.appcompat)
implementation(libs.material)
implementation(libs.activity)
implementation(libs.constraintlayout)
testImplementation(libs.junit)
androidTestImplementation(libs.ext.junit)
androidTestImplementation(libs.espresso.core)
implementation(libs.glide)
implementation(libs.room.runtime)
annotationProcessor(libs.room.compiler)
}

21
app/proguard-rules.pro vendored Normal file
View File

@@ -0,0 +1,21 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile

View File

@@ -0,0 +1,26 @@
package com.aldo.apps.ochecompanion;
import android.content.Context;
import androidx.test.platform.app.InstrumentationRegistry;
import androidx.test.ext.junit.runners.AndroidJUnit4;
import org.junit.Test;
import org.junit.runner.RunWith;
import static org.junit.Assert.*;
/**
* Instrumented test, which will execute on an Android device.
*
* @see <a href="http://d.android.com/tools/testing">Testing documentation</a>
*/
@RunWith(AndroidJUnit4.class)
public class ExampleInstrumentedTest {
@Test
public void useAppContext() {
// Context of the app under test.
Context appContext = InstrumentationRegistry.getInstrumentation().getTargetContext();
assertEquals("com.aldo.apps.ochecompanion", appContext.getPackageName());
}
}

View File

@@ -0,0 +1,36 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<!--
Required only if your app needs to access images or photos
that other apps created.
-->
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
<application
android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.OcheCompanion">
<activity
android:name=".GameActivity"
android:exported="false" />
<activity
android:name=".AddPlayerActivity"
android:exported="false" />
<activity
android:name=".MainMenuActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

View File

@@ -0,0 +1,716 @@
package com.aldo.apps.ochecompanion;
import android.graphics.Bitmap;
import android.graphics.BitmapFactory;
import android.graphics.RectF;
import android.net.Uri;
import android.os.Bundle;
import android.util.Log;
import android.view.MotionEvent;
import android.view.ScaleGestureDetector;
import android.view.View;
import android.widget.EditText;
import android.widget.ImageView;
import android.widget.TextView;
import android.widget.Toast;
import androidx.activity.result.ActivityResultLauncher;
import androidx.activity.result.contract.ActivityResultContracts;
import androidx.appcompat.app.AppCompatActivity;
import com.aldo.apps.ochecompanion.database.AppDatabase;
import com.aldo.apps.ochecompanion.database.objects.Player;
import com.aldo.apps.ochecompanion.ui.CropOverlayView;
import com.google.android.material.button.MaterialButton;
import com.google.android.material.imageview.ShapeableImageView;
import java.io.File;
import java.io.FileOutputStream;
import java.io.InputStream;
import java.util.UUID;
/**
* AddPlayerActivity manages the creation and editing of player profiles in the Oche Companion application.
* <p>
* This activity provides a comprehensive user interface for managing player information including:
* <ul>
* <li>Creating new player profiles with username and profile picture</li>
* <li>Editing existing player profiles</li>
* <li>Advanced image cropping functionality with pan and pinch-to-zoom controls</li>
* <li>Saving profile pictures to internal storage</li>
* </ul>
* </p>
* <p>
* The activity features two distinct UI modes:
* <ul>
* <li><strong>Form Mode:</strong> Standard profile editing with username input and profile picture selection</li>
* <li><strong>Crop Mode:</strong> Interactive image cropping interface with gesture controls for precise framing</li>
* </ul>
* </p>
* <p>
* <strong>Image Processing Pipeline:</strong>
* <ol>
* <li>User selects an image from their gallery</li>
* <li>Activity switches to Crop Mode with the selected image</li>
* <li>User can pan (drag) and pinch-to-zoom to frame the desired area</li>
* <li>A square crop overlay shows the final cutout area</li>
* <li>Upon confirmation, the cropped image is saved to internal storage</li>
* <li>Activity returns to Form Mode with the cropped image displayed</li>
* </ol>
* </p>
* <p>
* <strong>Usage:</strong>
* 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.
* </p>
*
* @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.
* <p>
* 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.
* </p>
* <p>
* <strong>Usage example:</strong>
* <pre>
* Intent intent = new Intent(context, AddPlayerActivity.class);
* intent.putExtra(AddPlayerActivity.EXTRA_PLAYER_ID, playerId);
* startActivity(intent);
* </pre>
* </p>
*/
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.
* <p>
* 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.
* </p>
*/
private float mScaleFactor = 1.0f;
/**
* ActivityResultLauncher for selecting images from the device gallery.
* <p>
* 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.
* </p>
* <p>
* The launcher is triggered when the user taps the profile picture placeholder.
* </p>
*
* @see ActivityResultContracts.GetContent
*/
private final ActivityResultLauncher<String> mGetContent = registerForActivityResult(
new ActivityResultContracts.GetContent(),
uri -> {
if (uri != null) {
Log.d(TAG, "Image selected, entering Crop Mode: " + uri);
mRawSelectedUri = uri;
enterCropMode(uri);
}
});
/**
* Called when the activity is first created.
* <p>
* Performs the following initialization tasks:
* <ul>
* <li>Sets the content view to the add player layout</li>
* <li>Initializes all UI component references</li>
* <li>Sets up gesture detectors for image manipulation</li>
* <li>Checks for existing player ID in intent extras and loads player data if present</li>
* </ul>
* </p>
* <p>
* 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.
* </p>
*
* @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.
* <p>
* This method performs the following operations:
* <ul>
* <li>Retrieves references to all UI elements using findViewById</li>
* <li>Configures click listener for profile picture to launch image picker</li>
* <li>Configures click listener for save button to persist player data</li>
* <li>Configures click listener for crop confirmation button</li>
* <li>Configures click listener for crop cancellation button</li>
* </ul>
* </p>
* <p>
* The profile picture view is configured to launch the gallery picker when clicked,
* filtering for image MIME types only ("image/*").
* </p>
*
* @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.
* <p>
* This method configures two types of touch interactions for the crop preview image:
* <ol>
* <li><strong>Pinch-to-Zoom:</strong> Two-finger pinch gestures to scale the image</li>
* <li><strong>Pan (Drag):</strong> Single-finger drag to reposition the image</li>
* </ol>
* </p>
* <p>
* <strong>Scale Gesture Handling:</strong>
* 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.
* </p>
* <p>
* <strong>Pan Gesture Handling:</strong>
* 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.
* </p>
*
* @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.
* <p>
* This method performs the following operations:
* <ul>
* <li>Hides the main form layout</li>
* <li>Shows the cropper layout</li>
* <li>Resets all image transformations (scale, translation) to default values</li>
* <li>Loads the selected image into the crop preview ImageView</li>
* </ul>
* </p>
* <p>
* 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.
* </p>
*
* @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.
* <p>
* This method is called when the user either:
* <ul>
* <li>Confirms their crop selection (after {@link #performCrop()} completes)</li>
* <li>Cancels the crop operation without saving</li>
* </ul>
* </p>
* <p>
* The method simply toggles visibility between the two layout containers,
* hiding the cropper and showing the main form.
* </p>
*
* @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.
* <p>
* 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:
* <ol>
* <li><strong>Original Image Dimensions:</strong> The actual pixel dimensions of the source bitmap</li>
* <li><strong>ImageView Fit-Center Scale:</strong> The automatic scaling applied by Android to fit the image in the view</li>
* <li><strong>User Translation (Panning):</strong> The X/Y offset from user drag gestures</li>
* <li><strong>User Scale (Zoom):</strong> The scale factor from user pinch-to-zoom gestures</li>
* </ol>
* </p>
* <p>
* <strong>Algorithm Overview:</strong>
* <ol>
* <li>Decode the full bitmap from the URI</li>
* <li>Calculate the fit-center scale applied by the ImageView</li>
* <li>Combine fit-center scale with user's manual zoom scale</li>
* <li>Determine the current position of the bitmap in screen space</li>
* <li>Get the crop box coordinates from the overlay</li>
* <li>Transform screen coordinates to bitmap pixel coordinates</li>
* <li>Apply bounds checking to ensure valid crop dimensions</li>
* <li>Extract the cropped region and save to internal storage</li>
* <li>Update the profile picture preview with the cropped image</li>
* </ol>
* </p>
* <p>
* <strong>Error Handling:</strong>
* 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.
* </p>
*
* @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.
* <p>
* 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.
* </p>
* <p>
* <strong>File Storage Details:</strong>
* <ul>
* <li><strong>Location:</strong> Application's private files directory ({@code getFilesDir()})</li>
* <li><strong>Format:</strong> JPEG with 90% compression quality</li>
* <li><strong>Naming:</strong> "profile_" + UUID + ".jpg"</li>
* <li><strong>Security:</strong> Files are private to this app and not accessible by other apps</li>
* </ul>
* </p>
* <p>
* <strong>Error Handling:</strong>
* 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.
* </p>
*
* @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.
* <p>
* 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.
* </p>
* <p>
* <strong>Operations performed:</strong>
* <ol>
* <li>Queries the database for the player by ID (on background thread)</li>
* <li>Updates the username field with the player's current username</li>
* <li>Changes the title to "Update Profile" instead of "Add Player"</li>
* <li>Changes the save button text to "Update" instead of "Save"</li>
* <li>Loads and displays the player's profile picture if one exists</li>
* </ol>
* </p>
* <p>
* <strong>Threading:</strong>
* 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)}.
* </p>
*
* @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.
* <p>
* This method determines whether to insert a new player or update an existing one
* based on whether {@link #mExistingPlayer} is null.
* </p>
* <p>
* <strong>Validation:</strong>
* The username field must not be empty (after trimming whitespace). If validation fails,
* a toast message is shown and the method returns without saving.
* </p>
* <p>
* <strong>Database Operations:</strong>
* <ul>
* <li><strong>Update Mode:</strong> If {@link #mExistingPlayer} is not null, updates the
* existing player's username and profile picture URI</li>
* <li><strong>Insert Mode:</strong> If {@link #mExistingPlayer} is null, creates a new
* Player object and inserts it into the database</li>
* </ul>
* </p>
* <p>
* <strong>Threading:</strong>
* 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.
* </p>
*
* @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();
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,284 @@
package com.aldo.apps.ochecompanion;
import android.content.Intent;
import android.os.Bundle;
import android.view.View;
import android.widget.TextView;
import android.widget.Toast;
import androidx.activity.EdgeToEdge;
import androidx.appcompat.app.AppCompatActivity;
import androidx.core.graphics.Insets;
import androidx.core.view.ViewCompat;
import androidx.core.view.WindowInsetsCompat;
import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.recyclerview.widget.RecyclerView;
import com.aldo.apps.ochecompanion.database.AppDatabase;
import com.aldo.apps.ochecompanion.database.objects.Player;
import com.aldo.apps.ochecompanion.models.Match;
import com.aldo.apps.ochecompanion.ui.MatchRecapView;
import com.aldo.apps.ochecompanion.ui.adapter.MainMenuPlayerAdapter;
import java.util.ArrayList;
import java.util.List;
/**
* MainMenuActivity serves as the primary entry point and main screen of the Oche Companion application.
* <p>
* This activity provides the following functionality:
* <ul>
* <li>Displays the squad of players in a RecyclerView</li>
* <li>Allows adding new players to the squad</li>
* <li>Shows a match recap view with test data for development purposes</li>
* <li>Manages the application's database connection</li>
* </ul>
* </p>
*
* @see AppCompatActivity
* @see AppDatabase
* @see MatchRecapView
* @see MainMenuPlayerAdapter
* @author Oche Companion Development Team
* @version 1.0
* @since 1.0
*/
public class MainMenuActivity extends AppCompatActivity {
/**
* Tag for debugging purposes.
* Used for logging and identifying this activity in debug output.
*/
private static final String TAG = "MainMenuActivity";
/**
* Custom view component that displays a summary of a match.
* <p>
* 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.
* </p>
*
* @see MatchRecapView
*/
private MatchRecapView mMatchRecap;
/**
* Counter used for cycling through different test data scenarios.
* <p>
* 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.
* </p>
*/
private int testCounter = 0;
/**
* Called when the activity is first created.
* <p>
* This method performs the following initialization tasks:
* <ul>
* <li>Enables edge-to-edge display for modern Android UI</li>
* <li>Sets the activity's content view to the main layout</li>
* <li>Configures window insets for proper system bar handling</li>
* <li>Initializes the database connection</li>
* <li>Sets up the match recap view with a test data click listener</li>
* </ul>
* </p>
*
* @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 AppCompatActivity#onCreate(Bundle)
*/
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
// Enable edge-to-edge display for immersive UI experience
EdgeToEdge.enable(this);
setContentView(R.layout.activity_main);
// Configure window insets to properly handle system bars (status bar, navigation bar)
ViewCompat.setOnApplyWindowInsetsListener(findViewById(R.id.main), (v, insets) -> {
Insets systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars());
v.setPadding(systemBars.left, systemBars.top, systemBars.right, systemBars.bottom);
return insets;
});
findViewById(R.id.quick_start_btn).setOnClickListener(v -> quickStart());
// Set up match recap view with test data functionality
mMatchRecap = findViewById(R.id.match_recap);
mMatchRecap.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
// Cycle through test data scenarios on each click
applyTestData(testCounter);
testCounter++;
}
});
}
/**
* Called after {@link #onStart} when the activity is becoming visible to the user.
* <p>
* 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.
* </p>
*
* @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.
* <p>
* 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.
* </p>
* <p>
* The method:
* <ul>
* <li>Creates two test players named "Test1" and "Test2" with no profile pictures</li>
* <li>Adds them to a player list</li>
* <li>Launches {@link GameActivity} with a starting score of 501</li>
* </ul>
* </p>
* <p>
* <strong>Use Cases:</strong>
* <ul>
* <li>Quick testing of game functionality during development</li>
* <li>Demonstrating the app without setting up a full squad</li>
* <li>Rapid game start for users who want to practice scoring</li>
* </ul>
* </p>
* <p>
* <strong>Note:</strong> The test players created here are not persisted to the database.
* They exist only for the duration of the game session.
* </p>
* <p>
* Triggered by the quick start button in the main menu UI.
* </p>
*
* @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<Player> players = new ArrayList<>();
players.add(playerOne);
players.add(playerTwo);
GameActivity.start(MainMenuActivity.this, players, 501);
}
/**
* Initializes and configures the squad view component.
* <p>
* This method performs the following operations:
* <ul>
* <li>Retrieves references to UI components (add player button and squad RecyclerView)</li>
* <li>Sets up the RecyclerView with a LinearLayoutManager</li>
* <li>Initializes and attaches the MainMenuPlayerAdapter to the RecyclerView</li>
* <li>Configures the add player button to launch the AddPlayerActivity</li>
* <li>Loads all players from the database on a background thread</li>
* <li>Updates the adapter with player data on the UI thread</li>
* </ul>
* </p>
* <p>
* <strong>Note:</strong> Database operations are performed on a background thread to prevent
* blocking the main UI thread, which ensures the application remains responsive.
* </p>
*
* @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<Player> allPlayers = AppDatabase.getDatabase(getApplicationContext())
.playerDao()
.getAllPlayers();
// Post-database query UI updates must happen back on the main (UI) thread
runOnUiThread(() -> {
// Update the adapter with the retrieved player data
adapter.updatePlayers(allPlayers);
});
}).start();
}
/**
* Applies test data to the match recap view for development and testing purposes.
* <p>
* This method creates sample player and match objects and cycles through different
* display states based on the provided counter value:
* <ul>
* <li>When counter % 3 == 0: Displays null (no match)</li>
* <li>When counter % 3 == 1: Displays a 1v1 match (two players)</li>
* <li>When counter % 3 == 2: Displays a group match (four players)</li>
* </ul>
* </p>
* <p>
* <strong>Note:</strong> This method is intended for development and testing only
* and should be removed or disabled in production builds.
* </p>
*
* @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);
}
}
}

View File

@@ -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.
* <p>
* 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.
* </p>
* <p>
* <strong>Room Database Architecture:</strong>
* Room is Android's SQLite object-relational mapping (ORM) library that provides
* an abstraction layer over SQLite, offering:
* <ul>
* <li>Compile-time SQL query verification</li>
* <li>Convenient annotation-based entity and DAO definitions</li>
* <li>Automatic conversion between SQLite and Java/Kotlin objects</li>
* <li>LiveData and Flow support for reactive UI updates</li>
* <li>Migration support for database schema changes</li>
* </ul>
* </p>
* <p>
* <strong>Database Entities:</strong>
* This database manages two primary tables:
* <ul>
* <li><strong>players:</strong> Squad roster with player profiles and career statistics
* (defined by {@link Player} entity)</li>
* <li><strong>matches:</strong> Completed match records with participant data and results
* (defined by {@link Match} entity)</li>
* </ul>
* </p>
* <p>
* <strong>Database Access Objects (DAOs):</strong>
* Database operations are performed through specialized DAO interfaces:
* <ul>
* <li>{@link PlayerDao}: CRUD operations for player management (insert, update, query)</li>
* <li>{@link MatchDao}: Match record operations (insert, query all, query last match)</li>
* </ul>
* </p>
* <p>
* <strong>Singleton Pattern Implementation:</strong>
* The class uses the thread-safe double-checked locking pattern to ensure a single
* database instance. This approach:
* <ul>
* <li>Prevents multiple database connections that waste resources</li>
* <li>Ensures data consistency across the application</li>
* <li>Reduces memory overhead by sharing one connection pool</li>
* <li>Thread-safe initialization prevents race conditions</li>
* </ul>
* </p>
* <p>
* <strong>Usage Example:</strong>
* <pre>
* // 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&lt;Player&gt; allPlayers = playerDao.getAllPlayers();
*
* // Insert a match
* Match match = new Match(System.currentTimeMillis(), "501", 2, participantJson);
* matchDao.insert(match);
*
* // Get recent matches
* List&lt;Match&gt; recentMatches = matchDao.getAllMatches();
* }).start();
* </pre>
* </p>
* <p>
* <strong>Database Versioning:</strong>
* Currently at version 2, indicating one schema change since initial creation.
* Version increments are required when:
* <ul>
* <li>Adding or removing tables</li>
* <li>Adding or removing columns</li>
* <li>Changing column types or constraints</li>
* <li>Modifying primary keys or indices</li>
* </ul>
* The {@code fallbackToDestructiveMigration()} strategy means schema changes will
* drop and recreate all tables, losing existing data. This is acceptable during
* development but should be replaced with proper migrations for production.
* </p>
* <p>
* <strong>Migration Strategy:</strong>
* For production releases, replace destructive migration with proper migration paths:
* <pre>
* 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();
* </pre>
* </p>
* <p>
* <strong>Database File Location:</strong>
* The SQLite database file "oche_companion_db" is stored in the app's private
* storage at:
* <pre>
* /data/data/com.aldo.apps.ochecompanion/databases/oche_companion_db
* </pre>
* This location is:
* <ul>
* <li>Private to the application (other apps cannot access)</li>
* <li>Automatically backed up with Auto Backup (if enabled)</li>
* <li>Cleared when the app is uninstalled</li>
* <li>Accessible for inspection via Android Studio Database Inspector</li>
* </ul>
* </p>
* <p>
* <strong>Schema Export:</strong>
* The {@code exportSchema = false} setting disables automatic schema JSON export.
* For production apps, consider enabling this:
* <pre>
* @Database(entities = {...}, version = 2, exportSchema = true)
* </pre>
* And specify the export directory in build.gradle:
* <pre>
* android {
* defaultConfig {
* javaCompileOptions {
* annotationProcessorOptions {
* arguments += ["room.schemaLocation": "$projectDir/schemas"]
* }
* }
* }
* }
* </pre>
* This creates version-controlled schema files for tracking changes and testing migrations.
* </p>
* <p>
* <strong>Threading Requirements:</strong>
* Room enforces that database operations occur on background threads:
* <ul>
* <li>Attempting database operations on the main thread throws {@link IllegalStateException}</li>
* <li>Use {@link Thread}, {@link java.util.concurrent.ExecutorService}, or Kotlin coroutines</li>
* <li>UI updates after database operations must use {@code runOnUiThread()} or similar</li>
* <li>Consider using LiveData or Flow for automatic thread handling and UI updates</li>
* </ul>
* </p>
* <p>
* <strong>Database Inspection:</strong>
* For debugging, you can inspect the database using:
* <ul>
* <li><strong>Android Studio Database Inspector:</strong> View > Tool Windows > Database Inspector</li>
* <li><strong>ADB:</strong> {@code adb shell} and SQLite command line</li>
* <li><strong>Third-party tools:</strong> DB Browser for SQLite (requires root or backup)</li>
* </ul>
* </p>
* <p>
* <strong>Performance Optimization:</strong>
* <ul>
* <li>Database queries are optimized with SQLite indices on primary keys</li>
* <li>Connection pooling is handled automatically by Room</li>
* <li>Consider adding custom indices for frequently queried columns:
* <pre>@Entity(indices = {@Index(value = {"username"})})</pre></li>
* <li>Use transactions for batch operations to improve performance</li>
* <li>Avoid N+1 query problems by using JOIN queries or @Relation</li>
* </ul>
* </p>
* <p>
* <strong>Testing:</strong>
* For unit testing, create an in-memory database:
* <pre>
* @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();
* }
* </pre>
* </p>
* <p>
* <strong>Data Backup and Restore:</strong>
* Consider implementing backup functionality:
* <ul>
* <li>Export database to external storage or cloud</li>
* <li>Use Android's Auto Backup for automatic cloud backup</li>
* <li>Provide manual export/import for user control</li>
* <li>Validate data integrity after restore operations</li>
* </ul>
* </p>
* <p>
* <strong>Security Considerations:</strong>
* <ul>
* <li>Database is stored in app's private directory (secure by default)</li>
* <li>For sensitive data, consider using SQLCipher for encryption</li>
* <li>Be cautious when exporting database (may contain user data)</li>
* <li>Validate and sanitize all data before insertion to prevent SQL injection
* (Room's parameterized queries provide protection)</li>
* </ul>
* </p>
* <p>
* <strong>Future Enhancements:</strong>
* Consider these improvements for future versions:
* <ul>
* <li>Add proper migration strategies instead of destructive migration</li>
* <li>Implement database encryption with SQLCipher</li>
* <li>Add support for exporting/importing data</li>
* <li>Create additional entities for tournaments, achievements, settings</li>
* <li>Implement multi-user support with user profiles table</li>
* <li>Add full-text search capabilities with FTS tables</li>
* </ul>
* </p>
*
* @see RoomDatabase
* @see Database
* @see Player
* @see Match
* @see PlayerDao
* @see MatchDao
* @author Oche Companion Development Team
* @version 2.0
* @since 1.0
*/
@Database(entities = {Player.class, Match.class}, version = 2, exportSchema = false)
public abstract class AppDatabase extends RoomDatabase {
/**
* Provides access to Player-related database operations.
* <p>
* 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.
* </p>
* <p>
* <strong>Available Operations:</strong>
* The returned PlayerDao provides these methods:
* <ul>
* <li>{@link PlayerDao#insert(Player)} - Add new player to squad</li>
* <li>{@link PlayerDao#update(Player)} - Update existing player information</li>
* <li>{@link PlayerDao#getPlayerById(int)} - Retrieve specific player by ID</li>
* <li>{@link PlayerDao#getAllPlayers()} - Get all players sorted alphabetically</li>
* </ul>
* </p>
* <p>
* <strong>Usage Example:</strong>
* <pre>
* // 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&lt;Player&gt; 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();
* </pre>
* </p>
* <p>
* <strong>Thread Safety:</strong>
* 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}.
* </p>
* <p>
* <strong>DAO Lifecycle:</strong>
* The DAO instance is created once by Room and can be cached and reused:
* <pre>
* // 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);
* </pre>
* </p>
* <p>
* <strong>Common Usage Patterns:</strong>
* <pre>
* // Pattern 1: Simple query and UI update
* ExecutorService executor = Executors.newSingleThreadExecutor();
* executor.execute(() -> {
* List&lt;Player&gt; 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();
* </pre>
* </p>
* <p>
* <strong>Alternative Reactive Approaches:</strong>
* Consider using LiveData or Flow for automatic UI updates:
* <pre>
* // If PlayerDao had LiveData support:
* // @Query("SELECT * FROM players ORDER BY username ASC")
* // LiveData&lt;List&lt;Player&gt;&gt; getAllPlayersLive();
*
* // Usage in Activity/Fragment:
* db.playerDao().getAllPlayersLive().observe(this, players -> {
* // UI automatically updates when data changes
* adapter.setPlayers(players);
* });
* </pre>
* </p>
* <p>
* <strong>Error Handling:</strong>
* <pre>
* 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();
* </pre>
* </p>
*
* @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.
* <p>
* 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.
* </p>
* <p>
* <strong>Available Operations:</strong>
* The returned MatchDao provides these methods:
* <ul>
* <li>{@link MatchDao#insert(Match)} - Save completed match to history</li>
* <li>{@link MatchDao#getAllMatches()} - Retrieve all matches ordered by most recent</li>
* <li>{@link MatchDao#getLastMatch()} - Get the most recently completed match</li>
* </ul>
* </p>
* <p>
* <strong>Usage Example:</strong>
* <pre>
* // 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&lt;Match&gt; allMatches = matchDao.getAllMatches();
*
* // Update UI with latest match
* Match lastMatch = matchDao.getLastMatch();
* runOnUiThread(() -> displayMatchRecap(lastMatch));
* }).start();
* </pre>
* </p>
* <p>
* <strong>Match History Display:</strong>
* <pre>
* // Load match history for main menu
* new Thread(() -> {
* List&lt;Match&gt; 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();
* </pre>
* </p>
* <p>
* <strong>Match Completion Flow:</strong>
* <pre>
* // After match ends, save to database
* private void saveMatchResult(List&lt;Player&gt; players, Map&lt;Player, Score&gt; 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();
* }
* </pre>
* </p>
* <p>
* <strong>Thread Safety:</strong>
* 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.
* </p>
* <p>
* <strong>Performance Considerations:</strong>
* <ul>
* <li>getAllMatches() loads all match records - consider pagination for users
* with hundreds of matches</li>
* <li>getLastMatch() uses LIMIT 1 for efficient querying</li>
* <li>Matches are ordered by timestamp DESC for chronological display</li>
* <li>Consider adding date range queries for filtering match history</li>
* </ul>
* </p>
* <p>
* <strong>Statistical Queries:</strong>
* <pre>
* // Get match history for analysis
* new Thread(() -> {
* List&lt;Match&gt; allMatches = db.matchDao().getAllMatches();
*
* // Calculate statistics
* int totalMatches = allMatches.size();
* Map&lt;String, Integer&gt; gameModeCount = new HashMap&lt;&gt;();
*
* for (Match match : allMatches) {
* gameModeCount.merge(match.gameMode, 1, Integer::sum);
* }
*
* // Display stats
* runOnUiThread(() -> showStatistics(totalMatches, gameModeCount));
* }).start();
* </pre>
* </p>
* <p>
* <strong>DAO Lifecycle:</strong>
* The DAO instance can be cached and reused throughout the app:
* <pre>
* // Cache in Application class or repository
* private MatchDao matchDao;
*
* public MatchDao getMatchDao() {
* if (matchDao == null) {
* matchDao = AppDatabase.getDatabase(context).matchDao();
* }
* return matchDao;
* }
* </pre>
* </p>
* <p>
* <strong>Error Handling:</strong>
* <pre>
* 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();
* </pre>
* </p>
*
* @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.
* <p>
* 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.
* </p>
* <p>
* <strong>Volatile Keyword:</strong>
* The volatile modifier guarantees:
* <ul>
* <li>Happens-before relationship: Writes to INSTANCE are visible to all threads</li>
* <li>Prevents instruction reordering that could break double-checked locking</li>
* <li>Ensures thread sees the fully constructed object, not a partially initialized one</li>
* <li>No caching of the variable value in CPU registers</li>
* </ul>
* </p>
* <p>
* <strong>Initialization State:</strong>
* <ul>
* <li><strong>null:</strong> Before first call to {@link #getDatabase(Context)}</li>
* <li><strong>non-null:</strong> After database is created, remains set for app lifetime</li>
* </ul>
* </p>
* <p>
* <strong>Memory Lifecycle:</strong>
* The database instance is retained in memory for the lifetime of the application
* process. It will be garbage collected only when:
* <ul>
* <li>The app process is terminated by Android</li>
* <li>The app is killed by the user</li>
* <li>System needs to reclaim memory and kills the app's process</li>
* </ul>
* </p>
* <p>
* <strong>Testing Considerations:</strong>
* For unit tests, you may need to reset the singleton:
* <pre>
* // 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;
* }
* }
* </pre>
* </p>
* <p>
* <strong>Why Singleton:</strong>
* Using a singleton for the database instance provides:
* <ul>
* <li>Single connection pool shared across the app</li>
* <li>Consistent data state across all components</li>
* <li>Reduced memory overhead (no duplicate instances)</li>
* <li>Prevention of conflicting database access</li>
* <li>Simplified database access (no need to pass instance around)</li>
* </ul>
* </p>
* <p>
* <strong>Thread Safety Analysis:</strong>
* The volatile keyword combined with synchronized block in {@link #getDatabase(Context)}
* ensures thread-safe lazy initialization:
* <pre>
* // 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
* </pre>
* </p>
*
* @see #getDatabase(Context)
* @see RoomDatabase
*/
private static volatile AppDatabase INSTANCE;
/**
* Gets the singleton instance of the AppDatabase, creating it if necessary.
* <p>
* 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.).
* </p>
* <p>
* <strong>Double-Checked Locking Pattern:</strong>
* The implementation uses two null checks to optimize performance:
* <ol>
* <li><strong>First check (unsynchronized):</strong> Fast path for when instance exists.
* Avoids expensive synchronization on subsequent calls.</li>
* <li><strong>Synchronized block:</strong> Ensures only one thread creates the instance.
* Prevents race conditions during initialization.</li>
* <li><strong>Second check (synchronized):</strong> Prevents multiple creation if several
* threads passed the first check simultaneously.</li>
* </ol>
* </p>
* <p>
* <strong>Usage Example:</strong>
* <pre>
* // 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&lt;Player&gt; 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);
* </pre>
* </p>
* <p>
* <strong>Context Parameter:</strong>
* The method accepts any Context but internally uses {@code context.getApplicationContext()}
* to avoid memory leaks. This means:
* <ul>
* <li>Activity context is safe to pass (converted to app context)</li>
* <li>Fragment context is safe to pass (converted to app context)</li>
* <li>Application context is ideal but not required</li>
* <li>No risk of leaking Activity or Fragment references</li>
* </ul>
* </p>
* <p>
* <strong>Database Builder Configuration:</strong>
* The database is created with these settings:
* <pre>
* 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();
* </pre>
* </p>
* <p>
* <strong>Destructive Migration Strategy:</strong>
* The {@code fallbackToDestructiveMigration()} setting means:
* <ul>
* <li><strong>Advantage:</strong> No need to write migration code during development</li>
* <li><strong>Disadvantage:</strong> All data is lost when database version changes</li>
* <li><strong>Development:</strong> Acceptable for testing and iteration</li>
* <li><strong>Production:</strong> Should be replaced with proper migrations:
* <pre>.addMigrations(MIGRATION_1_2, MIGRATION_2_3)</pre>
* </li>
* </ul>
* </p>
* <p>
* <strong>First Call Initialization:</strong>
* On the first call, this method will:
* <ol>
* <li>Create the SQLite database file in app's private storage</li>
* <li>Create all tables defined by entity classes (players, matches)</li>
* <li>Set up indices and constraints</li>
* <li>Initialize Room's internal structures</li>
* <li>Return the ready-to-use database instance</li>
* </ol>
* This initialization is typically fast (< 100ms) but happens on the calling thread,
* so consider calling from a background thread if concerned about main thread performance.
* </p>
* <p>
* <strong>Subsequent Calls:</strong>
* After initialization, subsequent calls:
* <ul>
* <li>Return immediately (fast path, no synchronization)</li>
* <li>No database access or initialization overhead</li>
* <li>Thread-safe from all threads</li>
* <li>Essentially free performance-wise</li>
* </ul>
* </p>
* <p>
* <strong>Thread Safety Guarantee:</strong>
* <pre>
* // 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
* </pre>
* </p>
* <p>
* <strong>Best Practices:</strong>
* <ul>
* <li>Call early in app lifecycle (Application.onCreate()) to avoid first-call delay</li>
* <li>Use application context when possible for clarity</li>
* <li>No need to cache the returned instance (method is fast)</li>
* <li>Safe to call repeatedly throughout the app</li>
* </ul>
* </p>
* <p>
* <strong>Proactive Initialization:</strong>
* <pre>
* public class OcheCompanionApplication extends Application {
* @Override
* public void onCreate() {
* super.onCreate();
*
* // Initialize database early to avoid delay on first access
* AppDatabase.getDatabase(this);
* }
* }
* </pre>
* </p>
* <p>
* <strong>Error Handling:</strong>
* In rare cases, database creation might fail due to:
* <ul>
* <li>Insufficient storage space</li>
* <li>Corrupted database file</li>
* <li>File system errors</li>
* </ul>
* Room will throw RuntimeException in these cases. Consider adding error handling:
* <pre>
* 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.
* }
* </pre>
* </p>
*
* @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;
}
}

View File

@@ -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.
* <p>
* 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.
* </p>
* <p>
* <strong>Room Database Integration:</strong>
* 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.
* </p>
* <p>
* <strong>Key Features:</strong>
* <ul>
* <li>Insert completed match records into the database</li>
* <li>Retrieve complete match history sorted by most recent first</li>
* <li>Query the last played match for dashboard/recap displays</li>
* <li>Thread-safe operations managed by Room</li>
* </ul>
* </p>
* <p>
* <strong>Thread Safety:</strong>
* 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).
* </p>
* <p>
* <strong>Usage Example:</strong>
* <pre>
* // 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();
* </pre>
* </p>
* <p>
* <strong>Database Table:</strong>
* This DAO operates on the "matches" table, which is defined by the {@link Match}
* entity class with its {@code @Entity} annotation.
* </p>
*
* @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.
* <p>
* 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.
* </p>
* <p>
* <strong>Threading:</strong>
* 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).
* </p>
* <p>
* <strong>Transaction Behavior:</strong>
* By default, Room wraps this operation in a transaction. If the insert fails, the
* transaction will be rolled back, maintaining database consistency.
* </p>
* <p>
* <strong>Conflict Strategy:</strong>
* 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.
* </p>
* <p>
* <strong>Return Value:</strong>
* While this method returns void, the {@code @Insert} annotation can be configured
* to return:
* <ul>
* <li>{@code long} - The row ID of the newly inserted match</li>
* <li>{@code Long} - Same as above, but nullable</li>
* <li>{@code long[]} or {@code Long[]} - For bulk inserts</li>
* <li>{@code List<Long>} - Alternative for bulk inserts</li>
* </ul>
* </p>
* <p>
* <strong>Usage Example:</strong>
* <pre>
* 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();
* </pre>
* </p>
*
* @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.
* <p>
* 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.
* </p>
* <p>
* <strong>SQL Query:</strong>
* Executes: {@code SELECT * FROM matches ORDER BY timestamp DESC}
* </p>
* <p>
* <strong>Sorting:</strong>
* Matches are ordered by the "timestamp" field in descending order (DESC), meaning:
* <ul>
* <li>Index 0: Most recent match</li>
* <li>Index 1: Second most recent match</li>
* <li>Last index: Oldest match</li>
* </ul>
* </p>
* <p>
* <strong>Threading:</strong>
* 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).
* </p>
* <p>
* <strong>Performance Considerations:</strong>
* <ul>
* <li>This query loads ALL matches into memory, which could be problematic for
* large datasets (thousands of matches)</li>
* <li>Consider using pagination (e.g., {@code LIMIT} and {@code OFFSET}) for better
* performance with large history</li>
* <li>Consider using LiveData or Flow for reactive updates</li>
* </ul>
* </p>
* <p>
* <strong>Empty Result:</strong>
* If no matches exist in the database, this method returns an empty list (not null).
* </p>
* <p>
* <strong>Usage Example:</strong>
* <pre>
* new Thread(() -> {
* List&lt;Match&gt; matches = matchDao.getAllMatches();
* runOnUiThread(() -> {
* if (matches.isEmpty()) {
* showEmptyState();
* } else {
* displayMatchHistory(matches);
* }
* });
* }).start();
* </pre>
* </p>
* <p>
* <strong>Suggested Improvements:</strong>
* Consider adding an index on the timestamp column for faster sorting:
* <pre>
* @Entity(tableName = "matches",
* indices = {@Index(value = {"timestamp"}, name = "index_timestamp")})
* </pre>
* </p>
*
* @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<Match> getAllMatches();
/**
* Retrieves the most recently played match from the database.
* <p>
* 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.
* </p>
* <p>
* <strong>SQL Query:</strong>
* Executes: {@code SELECT * FROM matches ORDER BY timestamp DESC LIMIT 1}
* </p>
* <p>
* <strong>Query Optimization:</strong>
* 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.
* </p>
* <p>
* <strong>Threading:</strong>
* 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).
* </p>
* <p>
* <strong>Null Return Value:</strong>
* This method returns {@code null} if no matches exist in the database. Callers
* must check for null before using the returned value:
* <pre>
* Match lastMatch = matchDao.getLastMatch();
* if (lastMatch != null) {
* // Use the match
* } else {
* // Show empty state
* }
* </pre>
* </p>
* <p>
* <strong>Usage Example:</strong>
* <pre>
* new Thread(() -> {
* Match lastMatch = matchDao.getLastMatch();
* runOnUiThread(() -> {
* if (lastMatch != null) {
* matchRecapView.setMatch(lastMatch);
* } else {
* matchRecapView.setMatch(null); // Shows empty state
* }
* });
* }).start();
* </pre>
* </p>
* <p>
* <strong>Use Cases:</strong>
* <ul>
* <li>Displaying the last match on the dashboard/main menu</li>
* <li>Showing a "play again" option with the most recent configuration</li>
* <li>Populating match recap views</li>
* <li>Checking if any matches have been played</li>
* </ul>
* </p>
* <p>
* <strong>Performance:</strong>
* 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.
* </p>
*
* @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();
}

View File

@@ -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.
* <p>
* 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.
* </p>
* <p>
* <strong>Room Database Integration:</strong>
* The {@code @Dao} annotation marks this as a Room DAO interface. Room's annotation
* processor will automatically generate an implementation class that handles:
* <ul>
* <li>SQL query construction and execution</li>
* <li>SQLite connection management</li>
* <li>Result cursor handling and mapping to Player objects</li>
* <li>Transaction management and error handling</li>
* </ul>
* </p>
* <p>
* <strong>Key Features:</strong>
* <ul>
* <li><strong>Insert:</strong> Add new players to the squad roster</li>
* <li><strong>Update:</strong> Modify existing player information (username, profile picture, stats)</li>
* <li><strong>Query by ID:</strong> Retrieve a specific player for editing</li>
* <li><strong>Query All:</strong> Get the complete squad list, alphabetically sorted</li>
* </ul>
* </p>
* <p>
* <strong>Thread Safety:</strong>
* 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).
* </p>
* <p>
* <strong>Usage Example:</strong>
* <pre>
* // 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&lt;Player&gt; 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();
* </pre>
* </p>
* <p>
* <strong>Database Table:</strong>
* This DAO operates on the "players" table, which is defined by the {@link Player}
* entity class with its {@code @Entity} annotation.
* </p>
* <p>
* <strong>Missing Operations:</strong>
* Note that this DAO does not currently include a DELETE operation. If player deletion
* is required, consider adding:
* <pre>
* @Delete
* void delete(Player player);
*
* // or
* @Query("DELETE FROM players WHERE id = :playerId")
* void deleteById(int playerId);
* </pre>
* </p>
*
* @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.
* <p>
* 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)}.
* </p>
* <p>
* <strong>Threading:</strong>
* 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).
* </p>
* <p>
* <strong>Transaction Behavior:</strong>
* 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.
* </p>
* <p>
* <strong>Conflict Strategy:</strong>
* 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:
* <pre>
* @Insert(onConflict = OnConflictStrategy.REPLACE)
* void insert(Player player);
* </pre>
* </p>
* <p>
* <strong>Auto-Generated ID:</strong>
* 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).
* </p>
* <p>
* <strong>Usage Example:</strong>
* <pre>
* 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();
* </pre>
* </p>
* <p>
* <strong>Validation:</strong>
* Ensure the player object contains all required fields before insertion:
* <ul>
* <li>Username should not be null or empty</li>
* <li>Profile picture URI can be null (default avatar will be used)</li>
* <li>Career average should be initialized (default to 0.0 if not set)</li>
* </ul>
* </p>
*
* @param player The Player object to be inserted into the database. Must not be null.
* Should contain valid username and optionally profile picture URI.
* @throws IllegalStateException if called on the main thread (Room's default behavior)
* @throws android.database.sqlite.SQLiteConstraintException if a constraint is violated
* @throws android.database.sqlite.SQLiteException if the database operation fails
* @see Insert
* @see Player
*/
@Insert
void insert(final Player player);
/**
* Updates an existing player's information in the database.
* <p>
* 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.
* </p>
* <p>
* <strong>Primary Key Matching:</strong>
* 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.
* </p>
* <p>
* <strong>Threading:</strong>
* 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).
* </p>
* <p>
* <strong>Transaction Behavior:</strong>
* 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.
* </p>
* <p>
* <strong>Conflict Strategy:</strong>
* The default conflict strategy is {@code OnConflictStrategy.ABORT}. This can be
* customized if needed:
* <pre>
* @Update(onConflict = OnConflictStrategy.REPLACE)
* void update(Player player);
* </pre>
* </p>
* <p>
* <strong>Return Value:</strong>
* 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).
* </p>
* <p>
* <strong>Usage Example:</strong>
* <pre>
* // 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();
* </pre>
* </p>
* <p>
* <strong>Common Use Cases:</strong>
* <ul>
* <li>Updating player username after editing in {@link com.aldo.apps.ochecompanion.AddPlayerActivity}</li>
* <li>Changing profile picture after cropping and saving new image</li>
* <li>Updating career statistics after completing a match</li>
* <li>Modifying any player information through the edit interface</li>
* </ul>
* </p>
*
* @param player The Player object containing updated values. Must not be null.
* The object's ID field must match an existing player in the database.
* @throws IllegalStateException if called on the main thread (Room's default behavior)
* @throws android.database.sqlite.SQLiteException if the database operation fails
* @see Update
* @see Player
* @see #getPlayerById(int)
*/
@Update
void update(Player player);
/**
* Retrieves a specific player from the database by their unique identifier.
* <p>
* 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.
* </p>
* <p>
* <strong>SQL Query:</strong>
* Executes: {@code SELECT * FROM players WHERE id = :id LIMIT 1}
* </p>
* <p>
* <strong>Query Optimization:</strong>
* 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.
* </p>
* <p>
* <strong>Threading:</strong>
* 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).
* </p>
* <p>
* <strong>Null Return Value:</strong>
* This method returns {@code null} if no player exists with the specified ID.
* Callers must always check for null before using the returned value:
* <pre>
* Player player = playerDao.getPlayerById(playerId);
* if (player != null) {
* // Use the player
* } else {
* // Handle player not found
* }
* </pre>
* </p>
* <p>
* <strong>Usage Example:</strong>
* <pre>
* // 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();
* </pre>
* </p>
* <p>
* <strong>Common Use Cases:</strong>
* <ul>
* <li>Loading player data in {@link com.aldo.apps.ochecompanion.AddPlayerActivity}
* when editing an existing player</li>
* <li>Retrieving player information before updating</li>
* <li>Validating that a player exists before performing operations</li>
* <li>Displaying detailed player information in a profile view</li>
* </ul>
* </p>
* <p>
* <strong>Performance:</strong>
* 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.
* </p>
*
* @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.
* <p>
* 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.
* </p>
* <p>
* <strong>SQL Query:</strong>
* Executes: {@code SELECT * FROM players ORDER BY username ASC}
* </p>
* <p>
* <strong>Sorting:</strong>
* Players are ordered by the "username" field in ascending alphabetical order (ASC):
* <ul>
* <li>Players with names starting with 'A' appear first</li>
* <li>Players with names starting with 'Z' appear last</li>
* <li>Case-insensitive sorting depends on database collation settings</li>
* </ul>
* </p>
* <p>
* <strong>Threading:</strong>
* 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).
* </p>
* <p>
* <strong>Performance Considerations:</strong>
* <ul>
* <li>This query loads ALL players into memory at once</li>
* <li>For small to medium squads (10-100 players), this is efficient</li>
* <li>For very large datasets, consider pagination or filtering</li>
* <li>Consider using LiveData or Flow for automatic UI updates when data changes</li>
* </ul>
* </p>
* <p>
* <strong>Empty Result:</strong>
* If no players exist in the database, this method returns an empty list (not null).
* This makes it safe to iterate without null checking:
* <pre>
* List&lt;Player&gt; players = playerDao.getAllPlayers();
* for (Player player : players) {
* // Process each player
* }
* </pre>
* </p>
* <p>
* <strong>Usage Example:</strong>
* <pre>
* // Load squad for display in RecyclerView
* new Thread(() -> {
* List&lt;Player&gt; 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();
* </pre>
* </p>
* <p>
* <strong>Common Use Cases:</strong>
* <ul>
* <li>Displaying the squad roster in {@link com.aldo.apps.ochecompanion.MainMenuActivity}</li>
* <li>Populating player selection lists when creating a new match</li>
* <li>Showing all players in management/roster views</li>
* <li>Calculating squad-wide statistics</li>
* </ul>
* </p>
* <p>
* <strong>Alternative Implementations:</strong>
* Consider using LiveData for automatic UI updates:
* <pre>
* @Query("SELECT * FROM players ORDER BY username ASC")
* LiveData&lt;List&lt;Player&gt;&gt; getAllPlayersLive();
* </pre>
* Or Flow for Kotlin coroutines:
* <pre>
* @Query("SELECT * FROM players ORDER BY username ASC")
* Flow&lt;List&lt;Player&gt;&gt; getAllPlayersFlow();
* </pre>
* </p>
* <p>
* <strong>Suggested Improvements:</strong>
* Consider adding an index on the username column for faster sorting:
* <pre>
* @Entity(tableName = "players",
* indices = {@Index(value = {"username"}, name = "index_username")})
* </pre>
* </p>
*
* @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<Player> getAllPlayers();
}

View File

@@ -0,0 +1,804 @@
package com.aldo.apps.ochecompanion.database.objects;
import androidx.room.Entity;
import androidx.room.PrimaryKey;
import java.io.Serializable;
/**
* Represents a completed match or game leg in the Oche Companion application.
* <p>
* 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.
* </p>
* <p>
* <strong>Room Database Entity:</strong>
* 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.
* </p>
* <p>
* <strong>Serializable Interface:</strong>
* 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.
* </p>
* <p>
* <strong>Data Structure:</strong>
* Match data is stored with the following components:
* <ul>
* <li><strong>ID:</strong> Auto-generated primary key for database uniqueness</li>
* <li><strong>Timestamp:</strong> Unix epoch time marking match completion</li>
* <li><strong>Game Mode:</strong> The variant of darts played (e.g., "501", "301", "Cricket")</li>
* <li><strong>Player Count:</strong> Number of participants (supports 1v1 and group matches)</li>
* <li><strong>Participant Data:</strong> JSON-serialized performance metrics for all players</li>
* </ul>
* </p>
* <p>
* <strong>Participant Data Format:</strong>
* The {@code participantData} field contains a JSON array with detailed player performance:
* <pre>
* [
* {
* "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
* }
* ]
* </pre>
* This structure allows for flexible storage of various statistics while maintaining
* database normalization (avoiding separate tables for each match-player relationship).
* </p>
* <p>
* <strong>Game Mode Support:</strong>
* The application supports multiple darts game variants:
* <ul>
* <li><strong>501:</strong> Standard countdown game starting at 501 points</li>
* <li><strong>301:</strong> Faster countdown game starting at 301 points</li>
* <li><strong>Cricket:</strong> Number-based strategy game (15-20 and bulls)</li>
* <li><strong>Around the Clock:</strong> Sequential number hitting game</li>
* <li>Custom game modes can be added by extending the gameMode field</li>
* </ul>
* </p>
* <p>
* <strong>Match Types:</strong>
* The system supports different match configurations:
* <ul>
* <li><strong>1v1 Matches:</strong> Two-player head-to-head games (playerCount = 2)</li>
* <li><strong>Group Matches:</strong> Three or more players (playerCount >= 3)</li>
* <li><strong>Solo Practice:</strong> Single player practice sessions (playerCount = 1)</li>
* </ul>
* The UI adapts based on player count, showing specialized layouts for each match type.
* </p>
* <p>
* <strong>Usage Example:</strong>
* <pre>
* // 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&lt;Match&gt; 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);
* </pre>
* </p>
* <p>
* <strong>Database Relationships:</strong>
* While this entity doesn't use Room's {@code @Relation} annotations, it maintains
* logical relationships through the participant data:
* <ul>
* <li>Each match references multiple players via IDs in participantData JSON</li>
* <li>Player objects are stored separately in the "players" table</li>
* <li>The denormalized JSON approach optimizes read performance for match history</li>
* <li>Updates to player usernames won't automatically reflect in historical matches</li>
* </ul>
* </p>
* <p>
* <strong>Performance Considerations:</strong>
* <ul>
* <li>JSON parsing adds minimal overhead for typical match sizes (2-8 players)</li>
* <li>The timestamp field can be indexed for efficient chronological queries</li>
* <li>Consider paginating match history for users with hundreds of matches</li>
* <li>The Serializable interface has some overhead; consider Parcelable for better performance</li>
* </ul>
* </p>
* <p>
* <strong>Data Integrity:</strong>
* <ul>
* <li>The auto-generated ID ensures each match is uniquely identifiable</li>
* <li>Timestamp should always be positive and in milliseconds (Unix epoch)</li>
* <li>Player count should match the number of entries in participantData JSON</li>
* <li>Game mode string should be validated against supported game types</li>
* </ul>
* </p>
* <p>
* <strong>Future Enhancements:</strong>
* Consider adding these fields for expanded functionality:
* <ul>
* <li><strong>duration:</strong> Match duration in seconds for time tracking</li>
* <li><strong>location:</strong> Venue or location where match was played</li>
* <li><strong>notes:</strong> User-added comments or observations</li>
* <li><strong>isRanked:</strong> Boolean flag for competitive vs casual matches</li>
* <li><strong>tournamentId:</strong> Reference to tournament entity for organized play</li>
* </ul>
* </p>
*
* @see com.aldo.apps.ochecompanion.database.dao.MatchDao
* @see com.aldo.apps.ochecompanion.database.objects.Player
* @see com.aldo.apps.ochecompanion.ui.view.MatchRecapView
* @see Entity
* @see Serializable
* @author Oche Companion Development Team
* @version 1.0
* @since 1.0
*/
@Entity(tableName = "matches")
public class Match implements Serializable {
/**
* The unique primary key identifier for this match in the database.
* <p>
* 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.
* </p>
* <p>
* <strong>Auto-Generation:</strong>
* The {@code @PrimaryKey(autoGenerate = true)} annotation tells Room to:
* <ul>
* <li>Automatically assign a unique ID when inserting a new match</li>
* <li>Increment the ID value for each subsequent insert</li>
* <li>Update the Match object's ID field after successful insertion</li>
* <li>Use this ID for update and delete operations</li>
* </ul>
* </p>
* <p>
* <strong>Initial Value:</strong>
* 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).
* </p>
* <p>
* <strong>Usage:</strong>
* <pre>
* 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);
* </pre>
* </p>
* <p>
* <strong>Uniqueness Guarantee:</strong>
* 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.
* </p>
*
* @see PrimaryKey
*/
@PrimaryKey(autoGenerate = true)
public int id;
/**
* Unix epoch timestamp indicating when this match was completed.
* <p>
* 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:
* <ul>
* <li>Sorting matches chronologically in match history views</li>
* <li>Displaying relative time ("2 hours ago", "Yesterday")</li>
* <li>Filtering matches by date range</li>
* <li>Calculating statistics over time periods</li>
* </ul>
* </p>
* <p>
* <strong>Format:</strong>
* The timestamp is stored as a {@code long} value representing milliseconds.
* This is the standard Java/Android time format obtained via:
* <pre>
* long timestamp = System.currentTimeMillis();
* </pre>
* </p>
* <p>
* <strong>Example Values:</strong>
* <ul>
* <li>1737158400000L = January 17, 2025, 00:00:00 UTC</li>
* <li>1704067200000L = January 1, 2024, 00:00:00 UTC</li>
* </ul>
* </p>
* <p>
* <strong>Conversion Examples:</strong>
* <pre>
* // 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);
* </pre>
* </p>
* <p>
* <strong>Sorting:</strong>
* Matches can be ordered by timestamp to show most recent first:
* <pre>
* @Query("SELECT * FROM matches ORDER BY timestamp DESC LIMIT 10")
* List&lt;Match&gt; getRecentMatches();
* </pre>
* </p>
* <p>
* <strong>Validation:</strong>
* The timestamp should always be:
* <ul>
* <li>Positive (non-zero)</li>
* <li>Not in the future (unless testing)</li>
* <li>Reasonable (not before app creation date)</li>
* </ul>
* </p>
* <p>
* <strong>Timezone Considerations:</strong>
* While the timestamp is stored in UTC (Unix epoch), display formatting should
* consider the user's local timezone for proper date/time presentation.
* </p>
*
* @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.
* <p>
* 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.
* </p>
* <p>
* <strong>Supported Game Modes:</strong>
* <ul>
* <li><strong>"501":</strong> The classic countdown game starting at 501 points.
* Players subtract their scores and must finish exactly on zero with a double.</li>
* <li><strong>"301":</strong> A faster variant starting at 301 points, following
* the same rules as 501 but requiring quicker gameplay.</li>
* <li><strong>"Cricket":</strong> Strategic game focusing on numbers 15-20 and
* the bullseye. Players must "close" numbers by hitting them three times.</li>
* <li><strong>"Around the Clock":</strong> Sequential game where players must
* hit numbers 1-20 in order, then finish with a bullseye.</li>
* <li><strong>"Killer":</strong> Multiplayer elimination game where each player
* has a designated number and tries to eliminate opponents.</li>
* </ul>
* </p>
* <p>
* <strong>String Format:</strong>
* Game mode strings should be:
* <ul>
* <li>Non-null and non-empty</li>
* <li>Consistent in naming (avoid "501", "Five-Oh-One", "501 Game" variations)</li>
* <li>Preferably using predefined constants to avoid typos</li>
* </ul>
* </p>
* <p>
* <strong>Recommended Usage:</strong>
* <pre>
* // 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
* );
* </pre>
* </p>
* <p>
* <strong>UI Adaptation:</strong>
* The game mode affects how matches are displayed:
* <pre>
* 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;
* }
* </pre>
* </p>
* <p>
* <strong>Filtering and Statistics:</strong>
* Game mode enables targeted queries and statistics:
* <pre>
* // Get all 501 matches
* @Query("SELECT * FROM matches WHERE gameMode = '501'")
* List&lt;Match&gt; get501Matches();
*
* // Calculate average score by game mode
* Map&lt;String, Double&gt; averagesByMode = calculateAveragesByGameMode();
* </pre>
* </p>
* <p>
* <strong>Extensibility:</strong>
* New game modes can be added without schema changes:
* <ul>
* <li>Simply use a new string identifier</li>
* <li>Implement game-specific logic in the game engine</li>
* <li>Update UI to handle the new mode</li>
* <li>Ensure participant data JSON includes mode-specific metrics</li>
* </ul>
* </p>
* <p>
* <strong>Validation:</strong>
* Consider validating game mode before inserting matches:
* <pre>
* private static final Set&lt;String&gt; VALID_GAME_MODES = new HashSet&lt;&gt;(
* Arrays.asList("501", "301", "Cricket", "Around the Clock", "Killer")
* );
*
* if (!VALID_GAME_MODES.contains(gameMode)) {
* throw new IllegalArgumentException("Invalid game mode: " + gameMode);
* }
* </pre>
* </p>
*
* @see com.aldo.apps.ochecompanion.ui.view.MatchRecapView#setMatch(Match)
*/
public String gameMode;
/**
* The total number of players who participated in this match.
* <p>
* 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.
* </p>
* <p>
* <strong>Valid Range:</strong>
* <ul>
* <li><strong>1:</strong> Solo practice session (single player)</li>
* <li><strong>2:</strong> Head-to-head match (1v1 duel)</li>
* <li><strong>3+:</strong> Group match or multiplayer game</li>
* </ul>
* Typically ranges from 1 to 8 players, though the system can support more.
* </p>
* <p>
* <strong>Match Type Determination:</strong>
* The player count affects UI rendering and game logic:
* <pre>
* 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
* }
* </pre>
* </p>
* <p>
* <strong>Data Consistency:</strong>
* The player count should always match the participant data:
* <pre>
* // 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");
* }
* </pre>
* </p>
* <p>
* <strong>Performance Implications:</strong>
* <ul>
* <li>Higher player counts require more complex UI layouts</li>
* <li>Sorting and ranking algorithms scale with player count</li>
* <li>JSON parsing time increases with more participants</li>
* <li>Consider pagination for matches with many players</li>
* </ul>
* </p>
* <p>
* <strong>Usage in Queries:</strong>
* <pre>
* // Get all 1v1 matches
* @Query("SELECT * FROM matches WHERE playerCount = 2")
* List&lt;Match&gt; getDuelMatches();
*
* // Get group matches only
* @Query("SELECT * FROM matches WHERE playerCount >= 3")
* List&lt;Match&gt; getGroupMatches();
* </pre>
* </p>
* <p>
* <strong>Statistical Analysis:</strong>
* Player count enables performance tracking by match type:
* <pre>
* // Calculate win rate in 1v1 matches
* double winRate1v1 = calculateWinRate(playerId, 2);
*
* // Calculate average placement in group matches
* double avgPlacement = calculateAveragePlacement(playerId, 3);
* </pre>
* </p>
* <p>
* <strong>Validation:</strong>
* Always validate player count before creating a match:
* <pre>
* if (playerCount < 1) {
* throw new IllegalArgumentException("playerCount must be at least 1");
* }
* if (playerCount > MAX_PLAYERS) {
* throw new IllegalArgumentException("Too many players: " + playerCount);
* }
* </pre>
* </p>
*
* @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.
* <p>
* 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.
* </p>
* <p>
* <strong>JSON Structure:</strong>
* The participant data is stored as a JSON array of player objects:
* <pre>
* [
* {
* "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"
* }
* ]
* </pre>
* </p>
* <p>
* <strong>Required Fields:</strong>
* Each participant object must contain:
* <ul>
* <li><strong>id:</strong> Player database ID for linking to Player entity</li>
* <li><strong>username:</strong> Display name at time of match (snapshot)</li>
* <li><strong>rank:</strong> Final placement (1 = winner, 2 = second, etc.)</li>
* </ul>
* </p>
* <p>
* <strong>Optional Fields:</strong>
* Additional metrics that may be included:
* <ul>
* <li><strong>score:</strong> Final score or remaining points</li>
* <li><strong>average:</strong> Three-dart average throughout the match</li>
* <li><strong>highestCheckout:</strong> Largest checkout/finish</li>
* <li><strong>dartsThrown:</strong> Total number of darts thrown</li>
* <li><strong>profilePictureUri:</strong> Profile image path (snapshot)</li>
* <li><strong>180s:</strong> Number of maximum scores (180) hit</li>
* <li><strong>checkoutPercentage:</strong> Success rate on finish attempts</li>
* </ul>
* </p>
* <p>
* <strong>Parsing Example:</strong>
* <pre>
* try {
* JSONArray participants = new JSONArray(match.participantData);
*
* for (int i = 0; i &lt; 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);
* }
* </pre>
* </p>
* <p>
* <strong>Building Participant Data:</strong>
* <pre>
* // 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);
* </pre>
* </p>
* <p>
* <strong>Why JSON Instead of Relations:</strong>
* <ul>
* <li><strong>Performance:</strong> Single query retrieves complete match data</li>
* <li><strong>Historical Accuracy:</strong> Captures player data as it was at match time</li>
* <li><strong>Flexibility:</strong> Can store game-specific metrics without schema changes</li>
* <li><strong>Simplicity:</strong> Avoids complex join queries and relationship management</li>
* <li><strong>Immutability:</strong> Match data remains unchanged if player profiles are updated</li>
* </ul>
* </p>
* <p>
* <strong>Data Integrity:</strong>
* <ul>
* <li>Array length should match {@link #playerCount}</li>
* <li>Ranks should be sequential (1, 2, 3, ...) without gaps</li>
* <li>Player IDs should reference valid players (though not enforced by foreign key)</li>
* <li>JSON must be well-formed and parseable</li>
* </ul>
* </p>
* <p>
* <strong>Snapshot Advantage:</strong>
* 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.
* </p>
* <p>
* <strong>Sorting Participants:</strong>
* Usually pre-sorted by rank before serialization, but can be sorted after parsing:
* <pre>
* // Sort by rank after parsing
* Collections.sort(playerList, (p1, p2) ->
* Integer.compare(p1.rank, p2.rank));
* </pre>
* </p>
* <p>
* <strong>Null Handling:</strong>
* 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.
* </p>
*
* @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.
* <p>
* 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.
* </p>
* <p>
* <strong>Constructor Parameters:</strong>
* Each parameter serves a specific purpose in defining the match:
* <ul>
* <li><strong>timestamp:</strong> Records when the match was completed for chronological ordering</li>
* <li><strong>gameMode:</strong> Identifies which darts variant was played</li>
* <li><strong>playerCount:</strong> Specifies how many players participated</li>
* <li><strong>participantData:</strong> Contains detailed performance data for all players</li>
* </ul>
* </p>
* <p>
* <strong>Usage Example:</strong>
* <pre>
* // 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();
* </pre>
* </p>
* <p>
* <strong>Parameter Validation:</strong>
* While the constructor doesn't enforce validation, consider checking parameters
* before construction:
* <pre>
* // 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);
* </pre>
* </p>
* <p>
* <strong>Field Initialization:</strong>
* The constructor initializes all fields except {@code id}:
* <ul>
* <li><strong>id:</strong> Remains 0 (default) until Room assigns auto-generated value</li>
* <li><strong>timestamp:</strong> Set to the provided value (milliseconds since epoch)</li>
* <li><strong>gameMode:</strong> Set to the provided game identifier string</li>
* <li><strong>playerCount:</strong> Set to the provided player count</li>
* <li><strong>participantData:</strong> Set to the provided JSON string</li>
* </ul>
* </p>
* <p>
* <strong>Database Insertion:</strong>
* After construction, insert the match using the DAO:
* <pre>
* // 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();
* });
* });
* </pre>
* </p>
* <p>
* <strong>Immutability Consideration:</strong>
* Once created and inserted, match data should generally be treated as immutable
* (historical record). If corrections are needed, consider whether to:
* <ul>
* <li>Update the existing match (rare, only for data corrections)</li>
* <li>Delete and recreate (for significant errors)</li>
* <li>Leave as-is and add a note field (preserves history)</li>
* </ul>
* </p>
* <p>
* <strong>Thread Safety:</strong>
* The constructor itself is thread-safe, but database insertion must occur on
* a background thread due to Room's main thread restrictions.
* </p>
*
* @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;
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,485 @@
package com.aldo.apps.ochecompanion.models;
import android.util.Log;
import androidx.annotation.NonNull;
import java.util.ArrayList;
import java.util.List;
import com.aldo.apps.ochecompanion.database.objects.Player;
/**
* Model class representing a darts match with multiple participants.
* <p>
* 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.
* </p>
* <p>
* <strong>Key Features:</strong>
* <ul>
* <li>Support for matches with any number of players (1v1, group matches, etc.)</li>
* <li>Position-based player access for ordered operations</li>
* <li>Convenient retrieval of player names and career averages</li>
* <li>Flexible construction with varargs or default empty initialization</li>
* </ul>
* </p>
* <p>
* <strong>Match Types:</strong>
* This class supports different match configurations:
* <ul>
* <li><strong>1v1 Match:</strong> Exactly 2 players for head-to-head competition</li>
* <li><strong>Group Match:</strong> 3 or more players for multi-player games</li>
* <li><strong>Solo Practice:</strong> Single player for practice sessions (if supported)</li>
* </ul>
* </p>
* <p>
* <strong>Player Ordering:</strong>
* Players are stored in the order they are added. This ordering is significant for:
* <ul>
* <li>Displaying players in consistent positions (left/right, top/bottom)</li>
* <li>Turn order during gameplay</li>
* <li>Result display and leaderboards</li>
* </ul>
* </p>
* <p>
* <strong>Usage Examples:</strong>
* <pre>
* // 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)
* </pre>
* </p>
* <p>
* <strong>Design Notes:</strong>
* <ul>
* <li>The class is mutable in that it stores player references, but doesn't provide
* methods to add/remove players after construction</li>
* <li>Player data (names, averages) is accessed directly from Player objects</li>
* <li>Currently focused on displaying match information rather than tracking live gameplay</li>
* </ul>
* </p>
*
* @see Player
* @see com.aldo.apps.ochecompanion.ui.MatchRecapView
* @author Oche Companion Development Team
* @version 1.0
* @since 1.0
*/
public class Match {
/**
* Tag for logging and debugging purposes.
* Used to identify log messages originating from the Match class.
*/
private static final String TAG = "Match";
/**
* Internal list of players participating in this match.
* <p>
* 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)}.
* </p>
* <p>
* The list is initialized as an ArrayList to provide efficient random access
* by index, which is the primary access pattern for this class.
* </p>
* <p>
* <strong>Immutability Note:</strong>
* While the list reference is final, the list contents are mutable. However,
* no public methods currently allow modification after construction.
* </p>
*
* @see #Match(Player...)
* @see #getAllPlayers()
* @see #getParticipantCount()
*/
private final List<Player> mPlayers;
/**
* Constructs an empty Match with no participants.
* <p>
* 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.
* </p>
* <p>
* <strong>Usage Scenarios:</strong>
* <ul>
* <li>Creating a match template before players are selected</li>
* <li>Initialization in builders or factory methods</li>
* <li>Default construction in frameworks requiring no-arg constructors</li>
* </ul>
* </p>
* <p>
* <strong>Note:</strong>
* Currently, there are no public methods to add players after construction.
* Consider using {@link #Match(Player...)} if players are known at creation time.
* </p>
* <p>
* A debug log message is generated when an empty match is created.
* </p>
*
* @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.
* <p>
* 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.
* </p>
* <p>
* <strong>Varargs Convenience:</strong>
* The varargs parameter allows flexible calling patterns:
* <pre>
* // 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);
* </pre>
* </p>
* <p>
* <strong>Player Ordering:</strong>
* Players are stored in the exact order they are passed. For example:
* <ul>
* <li>First player: position 0</li>
* <li>Second player: position 1</li>
* <li>And so on...</li>
* </ul>
* This ordering is important for methods like {@link #getPlayerNameByPosition(int)}.
* </p>
* <p>
* <strong>Logging:</strong>
* Each player addition is logged at debug level for troubleshooting and verification.
* </p>
* <p>
* <strong>Null Safety:</strong>
* 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.
* </p>
*
* @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.
* <p>
* This method provides the count of players currently registered for the match.
* The count corresponds to the number of players added during match construction.
* </p>
* <p>
* <strong>Usage Examples:</strong>
* <pre>
* 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
* </pre>
* </p>
* <p>
* <strong>Use Cases:</strong>
* This method is commonly used to:
* <ul>
* <li>Determine which UI layout to display (1v1 vs group match views)</li>
* <li>Validate match configuration before starting gameplay</li>
* <li>Loop through players without accessing the list directly</li>
* <li>Check if the match has any participants</li>
* </ul>
* </p>
*
* @return The number of players in this match. Returns 0 if the match is empty.
* @see #getAllPlayers()
* @see com.aldo.apps.ochecompanion.ui.MatchRecapView#setMatch(Match)
*/
public int getParticipantCount() {
return mPlayers.size();
}
/**
* Retrieves the username of the player at the specified position.
* <p>
* 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).
* </p>
* <p>
* <strong>Position Indexing:</strong>
* Positions are zero-based indices:
* <ul>
* <li>Position 0: First player added to the match</li>
* <li>Position 1: Second player added to the match</li>
* <li>Position n: (n+1)th player added to the match</li>
* </ul>
* </p>
* <p>
* <strong>Bounds Checking:</strong>
* 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.
* </p>
* <p>
* <strong>Note on Bounds Check:</strong>
* 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.
* </p>
* <p>
* <strong>Usage Example:</strong>
* <pre>
* 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"
* </pre>
* </p>
*
* @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.
* <p>
* This method provides position-based access to player career statistics, useful for
* displaying performance metrics in match summaries and leaderboards.
* </p>
* <p>
* <strong>Position Indexing:</strong>
* Positions are zero-based indices:
* <ul>
* <li>Position 0: First player added to the match</li>
* <li>Position 1: Second player added to the match</li>
* <li>Position n: (n+1)th player added to the match</li>
* </ul>
* </p>
* <p>
* <strong>Bounds Checking:</strong>
* 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.
* </p>
* <p>
* <strong>Note on Bounds Check:</strong>
* 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.
* </p>
* <p>
* <strong>Career Average:</strong>
* 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.
* </p>
* <p>
* <strong>Usage Example:</strong>
* <pre>
* 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
* </pre>
* </p>
*
* @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.
* <p>
* This method provides access to the complete player list, useful for operations
* that need to process all players (e.g., sorting, filtering, bulk display).
* </p>
* <p>
* <strong>List Contents:</strong>
* 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.
* </p>
* <p>
* <strong>Mutability Warning:</strong>
* 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:
* <pre>
* List&lt;Player&gt; players = match.getAllPlayers();
* players.clear(); // WARNING: This clears the match's player list!
* </pre>
* If you need to modify the list without affecting the match, create a copy:
* <pre>
* List&lt;Player&gt; playersCopy = new ArrayList&lt;&gt;(match.getAllPlayers());
* playersCopy.clear(); // Safe: Only affects the copy
* </pre>
* </p>
* <p>
* <strong>Common Use Cases:</strong>
* <ul>
* <li>Populating adapters for RecyclerViews (e.g., {@link com.aldo.apps.ochecompanion.ui.adapter.MainMenuGroupMatchAdapter})</li>
* <li>Sorting players by score for leaderboard display</li>
* <li>Iterating through all players for statistics calculation</li>
* <li>Filtering players based on specific criteria</li>
* </ul>
* </p>
* <p>
* <strong>Performance:</strong>
* This is an O(1) operation as it returns a reference, not a copy.
* </p>
*
* @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<Player> getAllPlayers() {
return mPlayers;
}
/**
* Returns a string representation of this Match for debugging and logging.
* <p>
* 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.
* </p>
* <p>
* <strong>Output Format:</strong>
* The generated string follows this pattern:
* <pre>
* Match {[Player1][Player2][Player3]]
* </pre>
* Each player is represented by its own {@link Player#toString()} output,
* wrapped in square brackets.
* </p>
* <p>
* <strong>Example Output:</strong>
* <pre>
* Match {[Player{name='Alice', avg=45.5}][Player{name='Bob', avg=52.3}]]
* </pre>
* </p>
* <p>
* <strong>Note on Formatting:</strong>
* 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.
* </p>
* <p>
* <strong>Performance:</strong>
* Uses {@link StringBuilder} for efficient string concatenation, which is important
* when the match contains many players.
* </p>
* <p>
* <strong>Usage:</strong>
* This method is automatically called when:
* <ul>
* <li>Logging a Match object: {@code Log.d(TAG, "Match: " + match)}</li>
* <li>Concatenating with strings: {@code "Current match: " + match}</li>
* <li>Debugging in IDE debugger (some IDEs display toString() output)</li>
* </ul>
* </p>
*
* @return A string representation of this match including all participating players.
* Never null.
* @see Player#toString()
* @see StringBuilder
*/
@NonNull
@Override
public String toString() {
// Use StringBuilder for efficient concatenation
final StringBuilder sb = new StringBuilder();
// Start the match representation
sb.append("Match {");
// Append each player's string representation
for (final Player player : mPlayers) {
sb.append("[").append(player).append("]");
}
// Close the match representation
// Note: This adds "]]" instead of "}". Consider fixing to sb.append("}");
sb.append("]");
return sb.toString();
}
}

View File

@@ -0,0 +1,357 @@
package com.aldo.apps.ochecompanion.ui;
import android.content.Context;
import android.graphics.Canvas;
import android.graphics.Color;
import android.graphics.Paint;
import android.graphics.Path;
import android.graphics.RectF;
import android.util.AttributeSet;
import android.view.View;
import androidx.annotation.Nullable;
/**
* Custom view that provides a visual cropping guide overlay for image selection.
* <p>
* 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.
* </p>
* <p>
* <strong>Visual Design:</strong>
* <ul>
* <li>Dark semi-transparent mask (85% opacity Midnight Black) covers the entire view</li>
* <li>Transparent square cutout in the center shows the crop area</li>
* <li>Crop box is 80% of the view's width, maintaining a square aspect ratio</li>
* <li>Automatically centers the crop box within the view</li>
* </ul>
* </p>
* <p>
* <strong>Technical Implementation:</strong>
* 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.
* </p>
* <p>
* <strong>Usage:</strong>
* 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.
* </p>
*
* @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.
* <p>
* Configured with:
* <ul>
* <li>Anti-aliasing enabled for smooth edges</li>
* <li>Color: Midnight Black (#0A0A0A) at 85% opacity (#D90A0A0A)</li>
* <li>Style: FILL to cover the entire path area</li>
* </ul>
* </p>
* The high opacity (85%) provides good contrast while still allowing the
* underlying image to be visible enough for positioning.
*/
private final Paint mMaskPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
/**
* Rectangle defining the boundaries of the transparent crop area in screen coordinates.
* <p>
* 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.
* </p>
* <p>
* The rectangle dimensions are:
* <ul>
* <li>Width: 80% of the view's width</li>
* <li>Height: Equal to width (square aspect ratio)</li>
* <li>Position: Centered horizontally and vertically</li>
* </ul>
* </p>
*
* @see #getCropRect()
* @see #onLayout(boolean, int, int, int, int)
*/
private final RectF mCropRect = new RectF();
/**
* Path object used to create the overlay mask with a transparent center hole.
* <p>
* This path consists of two rectangles:
* <ol>
* <li><strong>Outer Rectangle (Clockwise):</strong> Covers the entire view area</li>
* <li><strong>Inner Rectangle (Counter-Clockwise):</strong> Defines the crop area</li>
* </ol>
* </p>
* <p>
* 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).
* </p>
* <p>
* The path is recalculated whenever the view's layout changes to ensure proper
* sizing and positioning.
* </p>
*
* @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.
* <p>
* 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.
* </p>
* <p>
* The 80% size ensures adequate padding around the crop area while maximizing
* the useful cropping space.
* </p>
*/
private float mBoxSize;
/**
* Constructor for programmatic instantiation of the CropOverlayView.
* <p>
* 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.
* </p>
*
* @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.
* <p>
* 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.
* </p>
*
* @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.
* <p>
* 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.
* </p>
*
* @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.
* <p>
* This method configures the visual properties of the semi-transparent overlay
* that will darken the non-crop area of the image. The configuration includes:
* <ul>
* <li><strong>Color:</strong> Midnight Black (#0A0A0A) with 85% opacity</li>
* <li><strong>Style:</strong> FILL to cover the entire masked area</li>
* <li><strong>Anti-aliasing:</strong> Already enabled via constructor flag</li>
* </ul>
* </p>
* <p>
* <strong>Color Choice Rationale:</strong>
* 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.
* </p>
* <p>
* This method is called by all constructors to ensure consistent initialization
* regardless of how the view is instantiated.
* </p>
*
* @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.
* <p>
* This method is responsible for:
* <ol>
* <li>Calculating the size of the square crop box (80% of view width)</li>
* <li>Centering the crop box both horizontally and vertically</li>
* <li>Updating the crop rectangle coordinates</li>
* <li>Rebuilding the path used for rendering the overlay mask</li>
* </ol>
* </p>
* <p>
* <strong>Crop Box Sizing:</strong>
* The crop box is sized at 80% of the view's width to provide:
* <ul>
* <li>Adequate padding (10% on each side) for visual clarity</li>
* <li>Sufficient space for users to see what will be cropped out</li>
* <li>Maximum useful cropping area without overwhelming the interface</li>
* </ul>
* </p>
* <p>
* <strong>Path Construction:</strong>
* The path is built with two rectangles using opposite winding directions:
* <ul>
* <li><strong>Outer (CW):</strong> Full view bounds - creates the mask base</li>
* <li><strong>Inner (CCW):</strong> Crop area - subtracts from the mask</li>
* </ul>
* This winding technique creates the transparent "hole" effect.
* </p>
*
* @param changed True if this view's size or position has changed since the last layout.
* @param left The left position, relative to the parent.
* @param top The top position, relative to the parent.
* @param right The right position, relative to the parent.
* @param bottom The bottom position, relative to the parent.
* @see Path.Direction
*/
@Override
protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
super.onLayout(changed, left, top, right, bottom);
// Calculate the crop box size as 80% of the view's width
mBoxSize = getWidth() * 0.8f;
// Calculate the left position to center the box horizontally
float l = (getWidth() - mBoxSize) / 2;
// Calculate the top position to center the box vertically
float t = (getHeight() - mBoxSize) / 2;
// Set the crop rectangle coordinates
mCropRect.set(l, t, l + mBoxSize, t + mBoxSize);
// Pre-calculate the path for the mask with a transparent center hole
mPath.reset(); // Clear any previous path data
// Add outer rectangle covering the entire view (clockwise)
mPath.addRect(0, 0, getWidth(), getHeight(), Path.Direction.CW);
// Add inner rectangle for the crop area (counter-clockwise)
// The opposite direction creates a "hole" in the mask
mPath.addRect(mCropRect, Path.Direction.CCW);
}
/**
* Renders the semi-transparent overlay mask onto the canvas.
* <p>
* 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.
* </p>
* <p>
* <strong>Rendering Process:</strong>
* The single {@link Canvas#drawPath(Path, Paint)} call efficiently renders both:
* <ul>
* <li>The dark semi-transparent overlay covering the entire view</li>
* <li>The transparent crop area in the center (created by the CCW inner rectangle)</li>
* </ul>
* </p>
* <p>
* <strong>Performance:</strong>
* 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.
* </p>
*
* @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.
* <p>
* 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.
* </p>
* <p>
* <strong>Coordinate System:</strong>
* The returned RectF contains screen coordinates relative to this view:
* <ul>
* <li><strong>left:</strong> X-coordinate of the left edge of the crop box</li>
* <li><strong>top:</strong> Y-coordinate of the top edge of the crop box</li>
* <li><strong>right:</strong> X-coordinate of the right edge of the crop box</li>
* <li><strong>bottom:</strong> Y-coordinate of the bottom edge of the crop box</li>
* </ul>
* </p>
* <p>
* <strong>Usage:</strong>
* The parent activity must transform these screen coordinates to bitmap pixel coordinates
* by accounting for:
* <ul>
* <li>ImageView fit-center scaling</li>
* <li>User's manual pan (translation) gestures</li>
* <li>User's pinch-to-zoom (scale) gestures</li>
* </ul>
* </p>
*
* @return A RectF representing the transparent square crop area in screen coordinates.
* The rectangle defines a square centered in the view with dimensions equal to
* 80% of the view's width.
* @see com.aldo.apps.ochecompanion.AddPlayerActivity#performCrop()
* @see RectF
*/
public RectF getCropRect() {
return mCropRect;
}
}

View File

@@ -0,0 +1,424 @@
package com.aldo.apps.ochecompanion.ui;
import android.content.Context;
import android.util.AttributeSet;
import android.view.View;
import android.widget.FrameLayout;
import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.recyclerview.widget.RecyclerView;
import com.aldo.apps.ochecompanion.R;
import com.aldo.apps.ochecompanion.models.Match;
import com.aldo.apps.ochecompanion.ui.adapter.MainMenuGroupMatchAdapter;
/**
* Custom composite view that displays a summary of the most recently played match.
* <p>
* 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.
* </p>
* <p>
* <strong>Supported States:</strong>
* <ol>
* <li><strong>Empty State:</strong> Displayed when no match history exists yet.
* Shows a placeholder or welcome message to encourage users to start playing.</li>
* <li><strong>1v1 State:</strong> Displayed for head-to-head matches (exactly 2 players).
* Shows a side-by-side comparison of player names and scores.</li>
* <li><strong>Group State:</strong> Displayed for group matches (3 or more players).
* Shows a mini-leaderboard with all participants sorted by their performance.</li>
* </ol>
* </p>
* <p>
* <strong>Key Features:</strong>
* <ul>
* <li>Automatic state switching based on match data</li>
* <li>Clean state management ensuring only one view state is visible at a time</li>
* <li>Efficient RecyclerView implementation for group matches</li>
* <li>Graceful handling of null/missing match data</li>
* </ul>
* </p>
* <p>
* <strong>Usage:</strong>
* 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.
* </p>
*
* @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.
* <p>
* 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.
* </p>
* <p>
* Visibility is managed by {@link #updateVisibility(View)} to ensure
* only one state is visible at a time.
* </p>
*
* @see #setMatch(Match)
* @see #updateVisibility(View)
*/
private View stateEmpty;
/**
* View container for the 1v1 match state display.
* <p>
* 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.
* </p>
* <p>
* Visibility is managed by {@link #updateVisibility(View)} to ensure
* only one state is visible at a time.
* </p>
*
* @see #setup1v1State(Match)
* @see #updateVisibility(View)
*/
private View state1v1;
/**
* View container for the group match state display.
* <p>
* 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.
* </p>
* <p>
* Visibility is managed by {@link #updateVisibility(View)} to ensure
* only one state is visible at a time.
* </p>
*
* @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.
* <p>
* 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.
* </p>
* <p>
* Used only in the group state.
* </p>
*
* @see MainMenuGroupMatchAdapter
* @see #setupGroupState(Match)
*/
private RecyclerView rvLeaderboard;
/**
* Constructor for programmatic instantiation of the MatchRecapView.
* <p>
* This constructor delegates to the two-parameter constructor with a null
* AttributeSet, which in turn inflates the layout and initializes all child views.
* </p>
*
* @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.
* <p>
* This constructor is called when the view is inflated from an XML layout file.
* It performs the following initialization:
* <ol>
* <li>Calls the parent FrameLayout constructor</li>
* <li>Inflates the view_match_recap layout into this container</li>
* <li>Initializes all child view references via {@link #initViews()}</li>
* </ol>
* </p>
* <p>
* After construction, the view defaults to showing no content until
* {@link #setMatch(Match)} is called with valid match data.
* </p>
*
* @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.
* <p>
* This method retrieves and stores references to all UI components needed for
* the three different states:
* <ul>
* <li><strong>State containers:</strong> Empty, 1v1, and Group view containers</li>
* <li><strong>1v1 components:</strong> Player name and score TextViews</li>
* <li><strong>Group components:</strong> Leaderboard RecyclerView</li>
* </ul>
* </p>
* <p>
* All views must exist in the R.layout.view_match_recap layout file,
* otherwise this method will throw a NullPointerException.
* </p>
* <p>
* This method is called once during construction and does not need to be
* called again during the view's lifecycle.
* </p>
*
* @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.
* <p>
* 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:
* <ul>
* <li><strong>Null match:</strong> Shows the empty state</li>
* <li><strong>2 players:</strong> Shows the 1v1 state with head-to-head comparison</li>
* <li><strong>3+ players:</strong> Shows the group state with leaderboard</li>
* </ul>
* </p>
* <p>
* <strong>State Selection Logic:</strong>
* 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.
* </p>
* <p>
* <strong>Usage:</strong>
* This method should be called whenever the match data changes, such as:
* <ul>
* <li>When the activity first loads</li>
* <li>After a new match is completed</li>
* <li>When resuming the activity to refresh with latest data</li>
* </ul>
* </p>
*
* @param match The Match object from the database, or null if no match history exists.
* The match should contain all necessary player and score information.
* @see Match#getParticipantCount()
* @see #setup1v1State(Match)
* @see #setupGroupState(Match)
* @see #updateVisibility(View)
*/
public void setMatch(@Nullable final Match match) {
// Handle null case - no match history exists
if (match == null) {
updateVisibility(stateEmpty);
return;
}
// Determine which state to show based on participant count
if (match.getParticipantCount() > 2) {
// 3+ players: Show group leaderboard
setupGroupState(match);
} else {
// Exactly 2 players: Show 1v1 comparison
setup1v1State(match);
}
}
/**
* Configures and displays the 1v1 match state.
* <p>
* This method sets up the view for displaying a head-to-head match between two players.
* It performs the following operations:
* <ol>
* <li>Switches visibility to show only the 1v1 state container</li>
* <li>Retrieves player data by position (0 for player 1, 1 for player 2)</li>
* <li>Populates the player name TextViews</li>
* <li>Populates the player score/average TextViews</li>
* </ol>
* </p>
* <p>
* <strong>Data Retrieval:</strong>
* 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).
* </p>
* <p>
* <strong>Assumptions:</strong>
* This method assumes the match contains exactly 2 players. The caller
* ({@link #setMatch(Match)}) should verify the participant count before calling this method.
* </p>
*
* @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.
* <p>
* This method sets up the view for displaying a match with 3 or more players.
* It performs the following operations:
* <ol>
* <li>Switches visibility to show only the group state container</li>
* <li>Configures the RecyclerView with a LinearLayoutManager</li>
* <li>Creates and attaches a MainMenuGroupMatchAdapter</li>
* <li>Populates the adapter with match data (players are automatically sorted by score)</li>
* </ol>
* </p>
* <p>
* <strong>RecyclerView Configuration:</strong>
* 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.
* </p>
* <p>
* <strong>Adapter Behavior:</strong>
* 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).
* </p>
* <p>
* <strong>Performance Note:</strong>
* 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)}.
* </p>
*
* @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.
* <p>
* 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.
* </p>
* <p>
* <strong>Visibility Logic:</strong>
* <ul>
* <li>The view matching activeView is set to {@link View#VISIBLE}</li>
* <li>All other views are set to {@link View#GONE} (completely removed from layout)</li>
* </ul>
* </p>
* <p>
* <strong>Why GONE instead of INVISIBLE:</strong>
* 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.
* </p>
* <p>
* This centralized visibility management prevents inconsistent states where multiple
* containers might be visible simultaneously.
* </p>
*
* @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);
}
}

View File

@@ -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.
* <p>
* 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.
* </p>
* <p>
* <strong>Key Features:</strong>
* <ul>
* <li>Styled MaterialCardView with custom colors, elevation, and corner radius</li>
* <li>Profile picture display with Glide integration for efficient image loading</li>
* <li>Automatic fallback to default avatar icon when no profile picture exists</li>
* <li>Career average statistics display with formatted text</li>
* <li>Reusable across different contexts (squad lists, match recaps, leaderboards)</li>
* </ul>
* </p>
* <p>
* <strong>Usage Contexts:</strong>
* This view is used in multiple places throughout the app:
* <ul>
* <li><strong>Squad List:</strong> In {@link com.aldo.apps.ochecompanion.ui.adapter.MainMenuPlayerAdapter}
* for displaying the user's roster of players</li>
* <li><strong>Group Matches:</strong> In {@link com.aldo.apps.ochecompanion.ui.adapter.MainMenuGroupMatchAdapter}
* for displaying match participants in leaderboard format</li>
* <li><strong>Match Recaps:</strong> Anywhere player information needs to be displayed consistently</li>
* </ul>
* </p>
* <p>
* <strong>Design Pattern:</strong>
* 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.
* </p>
* <p>
* <strong>Styling:</strong>
* The card appearance is configured in {@link #initViews()} with:
* <ul>
* <li>Background color from {@code R.color.surface_primary}</li>
* <li>Corner radius from {@code R.dimen.radius_m}</li>
* <li>Elevation from {@code R.dimen.card_elevation}</li>
* </ul>
* </p>
*
* @see MaterialCardView
* @see Player
* @see com.aldo.apps.ochecompanion.ui.adapter.MainMenuPlayerAdapter
* @see com.aldo.apps.ochecompanion.ui.adapter.MainMenuGroupMatchAdapter
* @author Oche Companion Development Team
* @version 1.0
* @since 1.0
*/
public class PlayerItemView extends MaterialCardView {
/**
* ShapeableImageView displaying the player's profile picture or avatar.
* <p>
* 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.
* </p>
* <p>
* The ShapeableImageView type allows for easy customization of the image shape
* through XML attributes, supporting circular, rounded rectangle, or custom shapes.
* </p>
*
* @see #bind(Player)
* @see ShapeableImageView
*/
private ShapeableImageView ivAvatar;
/**
* TextView displaying the player's username.
* <p>
* Shows the {@link Player#username} field. This is the primary identifier
* for the player in the UI.
* </p>
*/
private TextView tvUsername;
/**
* TextView displaying the player's career statistics.
* <p>
* 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.
* </p>
*/
private TextView tvStats;
/**
* Constructor for programmatic instantiation of the PlayerItemView.
* <p>
* 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.
* </p>
* <p>
* Use this constructor when creating PlayerItemView instances directly in code
* rather than inflating from XML.
* </p>
*
* @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.
* <p>
* 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:
* <ol>
* <li>Calls the parent MaterialCardView constructor</li>
* <li>Inflates the item_player_small layout into this container</li>
* <li>Initializes all child views and applies card styling via {@link #initViews()}</li>
* </ol>
* </p>
* <p>
* After construction, the view is ready to receive player data through the
* {@link #bind(Player)} method.
* </p>
*
* @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.
* <p>
* This method performs two main tasks:
* <ol>
* <li><strong>Card Styling:</strong> Configures the MaterialCardView's visual properties
* including background color, corner radius, and elevation for a consistent
* Material Design appearance</li>
* <li><strong>View References:</strong> Retrieves and stores references to all child
* views (avatar, username, stats) for efficient data binding</li>
* </ol>
* </p>
* <p>
* <strong>Styling Details:</strong>
* <ul>
* <li><strong>Background Color:</strong> Uses {@code R.color.surface_primary} for
* consistent theming across the app</li>
* <li><strong>Corner Radius:</strong> Uses {@code R.dimen.radius_m} for medium-sized
* rounded corners following Material Design guidelines</li>
* <li><strong>Elevation:</strong> Uses {@code R.dimen.card_elevation} to create subtle
* depth and visual hierarchy</li>
* </ul>
* </p>
* <p>
* This method is called once during construction and does not need to be called
* again during the view's lifecycle.
* </p>
*
* @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.
* <p>
* This method updates the view's content to display information for the specified player:
* <ul>
* <li><strong>Username:</strong> Sets the player's name in the username TextView</li>
* <li><strong>Career Average:</strong> Formats and displays the player's career statistics</li>
* <li><strong>Profile Picture:</strong> Loads the player's avatar image or shows a default icon</li>
* </ul>
* </p>
* <p>
* <strong>Image Loading Strategy:</strong>
* The method uses Glide library for efficient image loading:
* <ul>
* <li><strong>With Profile Picture:</strong> If {@link Player#profilePictureUri} is not null,
* Glide loads the image from the file URI with automatic caching, memory management,
* and placeholder handling</li>
* <li><strong>Without Profile Picture:</strong> If no URI is available, displays a default
* user icon ({@code R.drawable.ic_users}) as a fallback</li>
* </ul>
* </p>
* <p>
* <strong>Text Formatting:</strong>
* 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.
* </p>
* <p>
* <strong>Performance:</strong>
* 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.
* </p>
* <p>
* <strong>Usage:</strong>
* This method should be called whenever:
* <ul>
* <li>A new player is to be displayed in this view</li>
* <li>Player data has been updated and the view needs to refresh</li>
* <li>The view is being recycled in a RecyclerView adapter</li>
* </ul>
* </p>
*
* @param player The Player entity from the database containing all necessary display information.
* Must not be null.
* @throws NullPointerException if player is null.
* @see Player#username
* @see Player#careerAverage
* @see Player#profilePictureUri
* @see Glide
*/
public void bind(@NonNull final Player player) {
// Set the player's username
tvUsername.setText(player.username);
// Format and set the career average statistics
tvStats.setText(String.format(
getContext().getString(R.string.txt_player_average_base),
player.careerAverage));
// Load and display the profile picture
if (player.profilePictureUri != null) {
// Profile picture exists - load it using Glide for efficient caching
Glide.with(getContext())
.load(player.profilePictureUri)
.into(ivAvatar);
} else {
// No profile picture - show default user icon
ivAvatar.setImageResource(R.drawable.ic_users);
}
}
}

View File

@@ -0,0 +1,360 @@
package com.aldo.apps.ochecompanion.ui;
import android.content.Context;
import android.text.TextUtils;
import android.util.AttributeSet;
import android.view.LayoutInflater;
import android.widget.FrameLayout;
import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import com.aldo.apps.ochecompanion.R;
/**
* Custom hero-style button component for initiating quick match sessions.
* <p>
* 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:
* <ul>
* <li>A large, bold primary label (e.g., "QUICK START")</li>
* <li>A secondary descriptive subtext showing game mode and rules</li>
* <li>Hero-style prominent appearance matching dashboard mockups</li>
* </ul>
* </p>
* <p>
* <strong>Design Philosophy:</strong>
* 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.
* </p>
* <p>
* <strong>Key Features:</strong>
* <ul>
* <li>Dual-text hierarchy with main title and descriptive subtitle</li>
* <li>Automatic text uppercasing for visual consistency</li>
* <li>Convenience method for updating game context (mode and rules)</li>
* <li>Built-in clickable and focusable configuration for accessibility</li>
* <li>Efficient merge-tag layout inflation pattern</li>
* </ul>
* </p>
* <p>
* <strong>Usage Example:</strong>
* <pre>
* QuickStartButton button = findViewById(R.id.quickStartButton);
* button.setMainText("Quick Start");
* button.updateContext("501", "Double Out");
* button.setOnClickListener(v -> startMatch());
* </pre>
* </p>
* <p>
* <strong>Text Formatting:</strong>
* Both main and sub text are automatically converted to uppercase to maintain
* consistent visual styling and match the high-impact design aesthetic.
* </p>
* <p>
* <strong>Accessibility:</strong>
* The component is configured as clickable and focusable, ensuring it works
* properly with touch, keyboard navigation, and accessibility services.
* </p>
*
* @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.
* <p>
* 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)}.
* </p>
* <p>
* This is the most visually prominent element of the component and should
* clearly communicate the primary action to the user.
* </p>
*
* @see #setMainText(String)
*/
private TextView tvMainLabel;
/**
* TextView displaying the secondary descriptive text below the main label.
* <p>
* 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)}.
* </p>
* <p>
* This provides users with quick information about what configuration will
* be used for the match without requiring them to navigate to settings.
* </p>
*
* @see #setSubText(String)
* @see #updateContext(String, String)
*/
private TextView tvSubLabel;
/**
* Constructor for programmatic instantiation of the QuickStartButton.
* <p>
* This constructor delegates to the two-parameter constructor with a null
* AttributeSet, which handles the actual initialization including layout
* inflation and view setup.
* </p>
* <p>
* Use this constructor when creating QuickStartButton instances directly in code
* rather than inflating from XML.
* </p>
*
* @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.
* <p>
* 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:
* <ol>
* <li>Calls the parent FrameLayout constructor</li>
* <li>Configures the view as clickable and focusable for proper interaction handling</li>
* <li>Inflates the internal layout using the merge-tag pattern for efficiency</li>
* <li>Initializes child view references via {@link #initViews()}</li>
* </ol>
* </p>
* <p>
* <strong>Merge Tag Pattern:</strong>
* The layout inflation uses {@code attachToRoot = true}, which is appropriate when
* using the {@code <merge>} tag in the layout XML. This pattern eliminates an
* unnecessary wrapper ViewGroup, making the view hierarchy more efficient.
* </p>
* <p>
* <strong>Interaction Configuration:</strong>
* The view is explicitly set as clickable and focusable to ensure:
* <ul>
* <li>Proper touch event handling</li>
* <li>Visual feedback on interaction (ripple effects, state changes)</li>
* <li>Keyboard navigation support for accessibility</li>
* <li>Screen reader compatibility</li>
* </ul>
* </p>
*
* @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 QuickStartButton(@NonNull Context context, @Nullable AttributeSet attrs) {
super(context, attrs);
// Ensure the component is clickable and focusable for proper interaction
// This enables touch feedback, keyboard navigation, and accessibility
setClickable(true);
setFocusable(true);
// Inflate the internal layout using the merge tag pattern
// attachToRoot = true is used because view_quick_start.xml uses <merge>
LayoutInflater.from(context).inflate(R.layout.view_quick_start, this, true);
// Initialize references to child views
initViews();
}
/**
* Initializes references to child TextView components within the inflated layout.
* <p>
* 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.
* </p>
* <p>
* This method is called once during construction and does not need to be called
* again during the view's lifecycle.
* </p>
* <p>
* <strong>Required Layout Elements:</strong>
* The R.layout.view_quick_start layout must contain:
* <ul>
* <li>{@code R.id.tvQuickStartMain} - The main title TextView</li>
* <li>{@code R.id.tvQuickStartSub} - The subtitle TextView</li>
* </ul>
* </p>
*
* @see #QuickStartButton(Context, AttributeSet)
*/
private void initViews() {
// Get reference to the main label TextView
tvMainLabel = findViewById(R.id.tvQuickStartMain);
// Get reference to the sub-label TextView
tvSubLabel = findViewById(R.id.tvQuickStartSub);
}
/**
* Sets the primary bold text displayed on the button.
* <p>
* 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.
* </p>
* <p>
* <strong>Typical Usage:</strong>
* Use this method to set action-oriented text that clearly communicates the
* button's purpose, such as:
* <ul>
* <li>"Quick Start"</li>
* <li>"Start Match"</li>
* <li>"Play Now"</li>
* </ul>
* </p>
* <p>
* <strong>Text Transformation:</strong>
* 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.
* </p>
* <p>
* <strong>Null Safety:</strong>
* 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.
* </p>
*
* @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.
* <p>
* 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.
* </p>
* <p>
* <strong>Typical Usage:</strong>
* Use this method to set descriptive text that provides details about the
* match configuration, such as:
* <ul>
* <li>"Standard 501 • Double Out"</li>
* <li>"301 - Single In/Double Out"</li>
* <li>"Cricket • Standard Rules"</li>
* </ul>
* </p>
* <p>
* <strong>Text Transformation:</strong>
* 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.
* </p>
* <p>
* <strong>Null Safety:</strong>
* 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.
* </p>
*
* @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.
* <p>
* 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.
* </p>
* <p>
* <strong>Formatting Behavior:</strong>
* <ul>
* <li>If rules are provided: Displays "[MODE] - [RULES]" (e.g., "501 - DOUBLE OUT")</li>
* <li>If rules are empty/null: Displays only "[MODE]" (e.g., "501")</li>
* </ul>
* </p>
* <p>
* <strong>Example Usage:</strong>
* <pre>
* // 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"
* </pre>
* </p>
* <p>
* <strong>Advantages over setSubText:</strong>
* <ul>
* <li>Provides consistent formatting across the app</li>
* <li>Handles null/empty rules gracefully</li>
* <li>Reduces code duplication in calling code</li>
* <li>Makes intent clearer (semantic method name)</li>
* </ul>
* </p>
* <p>
* <strong>Implementation Details:</strong>
* 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.
* </p>
*
* @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());
}
}

View File

@@ -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.
* <p>
* 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.
* </p>
* <p>
* <strong>Key Features:</strong>
* <ul>
* <li>Displays player name, career average, and profile picture</li>
* <li>Automatically sorts players by career average</li>
* <li>Uses custom {@link PlayerItemView} for consistent player display</li>
* <li>Integrates with Glide for efficient image loading</li>
* <li>Optimized for infrequent updates (typically once per activity lifecycle)</li>
* </ul>
* </p>
* <p>
* <strong>Usage:</strong>
* 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.
* </p>
*
* @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<MainMenuGroupMatchAdapter.GroupMatchHolder> {
/**
* Tag for logging and debugging purposes.
* Used to identify log messages originating from this adapter.
*/
private static final String TAG = "MainMenuGroupMatchAdapt";
/**
* Internal list holding the players to be displayed in the RecyclerView.
* <p>
* 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.
* </p>
*
* @see #updateMatch(Match)
* @see Player
*/
private final List<Player> mPlayersList = new ArrayList<>();
/**
* Creates a new {@link GroupMatchHolder} to display player information.
* <p>
* 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.
* </p>
* <p>
* <strong>Layout Configuration:</strong>
* <ul>
* <li>Width: MATCH_PARENT (fills available width)</li>
* <li>Height: WRAP_CONTENT (adjusts to content size)</li>
* </ul>
* </p>
*
* @param parent The ViewGroup into which the new View will be added after it is bound
* to an adapter position.
* @param viewType The view type of the new View. Not used in this implementation as
* all items use the same view type.
* @return A new GroupMatchHolder that holds a PlayerItemView.
* @see GroupMatchHolder
* @see PlayerItemView
*/
@NonNull
@Override
public GroupMatchHolder onCreateViewHolder(@NonNull final ViewGroup parent, final int viewType) {
// Create a new PlayerItemView for displaying player information
final PlayerItemView itemView = new PlayerItemView(parent.getContext());
// Configure layout parameters for the item view
itemView.setLayoutParams(new RecyclerView.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.WRAP_CONTENT
));
return new GroupMatchHolder(itemView);
}
/**
* Binds player data to a ViewHolder at the specified position.
* <p>
* 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.
* </p>
*
* @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.
* <p>
* 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}.
* </p>
*
* @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.
* <p>
* This method performs the following operations:
* <ol>
* <li>Clears the existing player list</li>
* <li>Retrieves all players from the provided match</li>
* <li>Sorts players by their career average (ascending order)</li>
* <li>Adds the sorted players to the internal list</li>
* <li>Notifies the RecyclerView to refresh the display</li>
* </ol>
* </p>
* <p>
* <strong>Performance Consideration:</strong>
* 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.
* </p>
*
* @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<Player> allPlayers = match.getAllPlayers();
// Sort players by career average (lowest to highest)
allPlayers.sort(new PlayerScoreComparator());
// Add sorted players to the display list
mPlayersList.addAll(allPlayers);
// Notify RecyclerView to refresh the display
notifyDataSetChanged();
}
/**
* ViewHolder class for displaying individual player items in group match view.
* <p>
* This ViewHolder manages the UI components for a single player entry, including:
* <ul>
* <li>Player name</li>
* <li>Player career average score</li>
* <li>Player profile picture/avatar</li>
* </ul>
* </p>
* <p>
* The ViewHolder uses a {@link PlayerItemView} as its root view and automatically
* hides the chevron icon since group match items are not clickable/expandable.
* </p>
* <p>
* <strong>Image Loading:</strong>
* 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.
* </p>
*
* @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.
* <p>
* Shows the {@link Player#careerAverage} field formatted according to
* the string resource {@code R.string.txt_player_average_base}.
* </p>
*/
private final TextView mPlayerScoreView;
/**
* ShapeableImageView displaying the player's profile picture.
* <p>
* 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.
* </p>
*/
private final ShapeableImageView mPlayerImageView;
/**
* Constructs a new GroupMatchHolder and initializes its child views.
* <p>
* This constructor performs the following setup:
* <ol>
* <li>Calls the parent ViewHolder constructor</li>
* <li>Retrieves references to the player name, score, and image views</li>
* <li>Hides the chevron icon since group match items are non-interactive</li>
* </ol>
* </p>
* <p>
* The chevron icon is hidden because players in group match view are displayed
* for informational purposes only and do not support click/expand actions.
* </p>
*
* @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.
* <p>
* This method updates the UI components with the player's data:
* <ul>
* <li><strong>Name:</strong> Sets the player's username in the name TextView</li>
* <li><strong>Score:</strong> Formats and displays the career average using a string resource</li>
* <li><strong>Avatar:</strong> Loads the profile picture using Glide, or shows a default icon</li>
* </ul>
* </p>
* <p>
* <strong>Image Loading Strategy:</strong>
* 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.
* </p>
*
* @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.
* <p>
* This comparator implements ascending order sorting, meaning players with lower
* career averages will appear before players with higher averages in the sorted list.
* </p>
* <p>
* <strong>Sorting Behavior:</strong>
* <ul>
* <li>Returns negative if p1's average &lt; p2's average (p1 comes first)</li>
* <li>Returns zero if p1's average == p2's average (equal priority)</li>
* <li>Returns positive if p1's average &gt; p2's average (p2 comes first)</li>
* </ul>
* </p>
* <p>
* <strong>Usage:</strong>
* This comparator is used in {@link #updateMatch(Match)} to sort the player list
* before displaying it in the RecyclerView.
* </p>
*
* @see Comparator
* @see Player#careerAverage
* @see #updateMatch(Match)
*/
public static class PlayerScoreComparator implements Comparator<Player> {
/**
* Compares two Player objects based on their career average scores.
* <p>
* 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.
* </p>
*
* @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);
}
}
}

View File

@@ -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.
* <p>
* 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.
* </p>
* <p>
* <strong>Key Features:</strong>
* <ul>
* <li>Displays player name, career average, and profile picture</li>
* <li>Supports click-to-edit functionality for each player</li>
* <li>Uses custom {@link PlayerItemView} for consistent player display</li>
* <li>Integrates with Glide for efficient image loading and caching</li>
* <li>Optimized for infrequent updates (typically once per activity lifecycle)</li>
* </ul>
* </p>
* <p>
* <strong>Usage:</strong>
* This adapter is used in the Main Menu activity to display the complete squad roster,
* allowing users to view and manage their players.
* </p>
*
* @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<MainMenuPlayerAdapter.PlayerCardHolder> {
/**
* Tag for logging and debugging purposes.
* Used to identify log messages originating from this adapter.
*/
private static final String TAG = "MainMenuPlayerAdapter";
/**
* Internal list holding all players to be displayed in the RecyclerView.
* <p>
* 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.
* </p>
*
* @see #updatePlayers(List)
* @see Player
*/
private final List<Player> mPlayersList = new ArrayList<>();
/**
* Creates a new {@link PlayerCardHolder} to display player information.
* <p>
* 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.
* </p>
* <p>
* <strong>Layout Configuration:</strong>
* <ul>
* <li>Width: MATCH_PARENT (fills available width)</li>
* <li>Height: WRAP_CONTENT (adjusts to content size)</li>
* </ul>
* </p>
*
* @param parent The ViewGroup into which the new View will be added after it is bound
* to an adapter position.
* @param viewType The view type of the new View. Not used in this implementation as
* all items use the same view type.
* @return A new PlayerCardHolder that holds a PlayerItemView.
* @see PlayerCardHolder
* @see PlayerItemView
*/
@NonNull
@Override
public PlayerCardHolder onCreateViewHolder(@NonNull final ViewGroup parent, final int viewType) {
// Create a new PlayerItemView for displaying player information
final PlayerItemView itemView = new PlayerItemView(parent.getContext());
// Configure layout parameters for the item view
itemView.setLayoutParams(new RecyclerView.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.WRAP_CONTENT
));
return new PlayerCardHolder(itemView);
}
/**
* Binds player data to a ViewHolder at the specified position.
* <p>
* 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.
* </p>
*
* @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.
* <p>
* 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}.
* </p>
*
* @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.
* <p>
* This method performs the following operations:
* <ol>
* <li>Clears the existing player list</li>
* <li>Adds all players from the provided list</li>
* <li>Notifies the RecyclerView to refresh the display</li>
* </ol>
* </p>
* <p>
* <strong>Performance Consideration:</strong>
* 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.
* </p>
*
* @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<Player> players) {
if (players == null) {
Log.w(TAG, "updatePlayers: Provided player list is null, aborting update.");
return;
}
// Clear any existing player data
mPlayersList.clear();
if (players.isEmpty()) {
Log.d(TAG, "updatePlayers: Provided player list is empty, cleared existing players.");
notifyDataSetChanged();
return;
}
// Add all new players to the display list
mPlayersList.addAll(players);
// Notify RecyclerView to refresh the display
notifyDataSetChanged();
}
/**
* ViewHolder class for displaying individual player items in the squad list.
* <p>
* This ViewHolder manages the UI components for a single player entry, including:
* <ul>
* <li>Player name</li>
* <li>Player career average score</li>
* <li>Player profile picture/avatar</li>
* <li>Click interaction to edit player details</li>
* </ul>
* </p>
* <p>
* The ViewHolder uses a {@link PlayerItemView} as its root view and supports click
* events that navigate to the {@link AddPlayerActivity} for editing player information.
* </p>
* <p>
* <strong>Image Loading:</strong>
* 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.
* </p>
*
* @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.
* <p>
* Shows the {@link Player#careerAverage} field formatted according to
* the string resource {@code R.string.txt_player_average_base}.
* </p>
*/
private final TextView mPlayerScoreView;
/**
* ShapeableImageView displaying the player's profile picture.
* <p>
* 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.
* </p>
*/
private final ShapeableImageView mPlayerImageView;
/**
* Constructs a new PlayerCardHolder and initializes its child views.
* <p>
* This constructor performs the following setup:
* <ol>
* <li>Calls the parent ViewHolder constructor</li>
* <li>Retrieves references to the player name, score, and image views</li>
* </ol>
* </p>
* <p>
* Unlike group match items, the chevron icon is kept visible since player items
* are interactive and clicking them navigates to the edit screen.
* </p>
*
* @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.
* <p>
* This method updates the UI components with the player's data:
* <ul>
* <li><strong>Click Listener:</strong> Sets up navigation to edit player details</li>
* <li><strong>Name:</strong> Sets the player's username in the name TextView</li>
* <li><strong>Score:</strong> Formats and displays the career average using a string resource</li>
* <li><strong>Avatar:</strong> Loads the profile picture using Glide, or shows a default icon</li>
* </ul>
* </p>
* <p>
* <strong>Interaction:</strong>
* When the item is clicked, it launches {@link AddPlayerActivity} with the player's ID,
* allowing the user to edit the player's information.
* </p>
* <p>
* <strong>Image Loading Strategy:</strong>
* 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.
* </p>
*
* @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.
* <p>
* 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.
* </p>
* <p>
* <strong>Intent Configuration:</strong>
* 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.
* </p>
*
* @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);
}
}
}

View File

@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<!-- Pressed State -->
<item android:state_pressed="true">
<shape>
<solid android:color="@color/text_primary" />
<corners android:radius="@dimen/radius_m" />
</shape>
</item>
<!-- Default State -->
<item>
<shape>
<solid android:color="@color/surface_secondary" />
<stroke android:width="1dp" android:color="@color/border_subtle" />
<corners android:radius="@dimen/radius_m" />
</shape>
</item>
</selector>

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<ripple xmlns:android="http://schemas.android.com/apk/res/android"
android:color="#40000000"> <!-- Dark ripple on bright green -->
<item>
<shape android:shape="rectangle">
<solid android:color="@color/volt_green" />
<corners android:radius="@dimen/radius_l" />
</shape>
</item>
</ripple>

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="M8.59,16.59L13.17,12L8.59,7.41L10,6L16,12L10,18L8.59,16.59Z" />
</vector>

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="M13,3c-4.97,0 -9,4.03 -9,9H1l3.89,3.89 0.07,0.14L9,12H6c0,-3.87 3.13,-7 7,-7s7,3.13 7,7 -3.13,7 -7,7c-1.93,0 -3.68,-0.79 -4.94,-2.06l-1.42,1.42C8.27,19.99 10.51,21 13,21c4.97,0 9,-4.03 9,-9s-4.03,-9 -9,-9zM12,8v5l4.28,2.54 0.72,-1.21 -3.5,-2.08V8H12z" />
</vector>

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#0A0A0A"
android:pathData="M0,0h108v108h-108z" />
</vector>

View File

@@ -0,0 +1,51 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="512"
android:viewportHeight="512">
<path
android:pathData="M128,0L384,0A128,128 0,0 1,512 128L512,384A128,128 0,0 1,384 512L128,512A128,128 0,0 1,0 384L0,128A128,128 0,0 1,128 0z"
android:fillColor="#0A0A0A"/>
<path
android:pathData="M256,256m-180,0a180,180 0,1 1,360 0a180,180 0,1 1,-360 0"
android:strokeAlpha="0.15"
android:strokeWidth="2"
android:fillColor="#00000000"
android:strokeColor="#D4FF00"
android:fillAlpha="0.15"/>
<path
android:pathData="M254,160h4v192h-4z"
android:strokeAlpha="0.2"
android:fillColor="#D4FF00"
android:fillAlpha="0.2"/>
<path
android:pathData="M256,100L220,240L256,265L292,240L256,100Z"
android:strokeAlpha="0.9"
android:fillColor="#D4FF00"
android:fillAlpha="0.9"/>
<path
android:pathData="M256,412L292,272L256,247L220,272L256,412Z"
android:strokeAlpha="0.7"
android:fillColor="#D4FF00"
android:fillAlpha="0.7"/>
<path
android:pathData="M100,256L240,292L265,256L240,220L100,256Z"
android:strokeAlpha="0.5"
android:fillColor="#D4FF00"
android:fillAlpha="0.5"/>
<path
android:pathData="M412,256L272,220L247,256L272,292L412,256Z"
android:strokeAlpha="0.5"
android:fillColor="#D4FF00"
android:fillAlpha="0.5"/>
<path
android:pathData="M256,256m-8,0a8,8 0,1 1,16 0a8,8 0,1 1,-16 0"
android:fillColor="#D4FF00"/>
<path
android:pathData="M256,256m-16,0a16,16 0,1 1,32 0a16,16 0,1 1,-32 0"
android:strokeAlpha="0.4"
android:strokeWidth="1.5"
android:fillColor="#00000000"
android:strokeColor="#D4FF00"
android:fillAlpha="0.4"/>
</vector>

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="M8,5v14l11,-7z" />
</vector>

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="M19.14,12.94c0.04,-0.3 0.06,-0.61 0.06,-0.94c0,-0.32 -0.02,-0.64 -0.07,-0.94l2.03,-1.58c0.18,-0.14 0.23,-0.41 0.12,-0.61l-1.92,-3.32c-0.12,-0.22 -0.37,-0.29 -0.59,-0.22l-2.39,0.96c-0.5,-0.38 -1.03,-0.7 -1.62,-0.94L14.4,2.81c-0.04,-0.24 -0.24,-0.41 -0.48,-0.41h-3.84c-0.24,0 -0.43,0.17 -0.47,0.41L9.25,5.35C8.66,5.59 8.13,5.91 7.63,6.29L5.24,5.33c-0.22,-0.08 -0.47,0 -0.59,0.22L2.74,8.87c-0.11,0.21 -0.06,0.47 0.12,0.61l2.03,1.58C4.84,11.36 4.82,11.68 4.82,12c0,0.32 0.02,0.64 0.07,0.94l-2.03,1.58c-0.18,0.14 -0.23,0.41 -0.12,0.61l1.92,3.32c0.12,0.22 0.37,0.29 0.59,0.22l2.39,-0.96c0.5,0.38 1.03,0.7 1.62,0.94l0.36,2.54c0.05,0.24 0.24,0.41 0.48,0.41h3.84c0.24,0 0.44,-0.17 0.47,-0.41l0.36,-2.54c0.59,-0.24 1.13,-0.56 1.62,-0.94l2.39,0.96c0.22,0.08 0.47,0 0.59,-0.22l1.92,-3.32c0.11,-0.21 0.06,-0.47 -0.12,-0.61L19.14,12.94zM12,15.5c-1.93,0 -3.5,-1.57 -3.5,-3.5s1.57,-3.5 3.5,-3.5s3.5,1.57 3.5,3.5S13.93,15.5 12,15.5z" />
</vector>

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="M12,2C6.47,2 2,6.47 2,12s4.47,10 10,10 10,-4.47 10,-10S17.53,2 12,2zM12,20c-4.41,0 -8,-3.59 -8,-8s3.59,-8 8,-8 8,3.59 8,8 -3.59,8 -8,8zM12,7c-2.76,0 -5,2.24 -5,5s2.24,5 5,5 5,-2.24 5,-5 -2.24,-5 -5,-5zM12,15c-1.66,0 -3,-1.34 -3,-3s1.34,-3 3,-3 3,1.34 3,3 -1.34,3 -3,3z" />
</vector>

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="M16,11c1.66,0 2.99,-1.34 2.99,-3S17.66,5 16,5s-3,1.34 -3,3 1.34,3 3,3zM8,11c1.66,0 3,-1.34 3,-3S9.66,5 8,5 5,6.34 5,8s1.34,3 3,3zM8,13c-2.33,0 -7,1.17 -7,3.5V19h14v-2.5c0,-2.33 -4.67,-3.5 -7,-3.5zM16,13c-0.29,0 -0.62,0.02 -0.97,0.05 1.16,0.84 1.97,1.97 1.97,3.45V19h6v-2.5c0,-2.33 -4.67,-3.5 -7,-3.5z" />
</vector>

View File

@@ -0,0 +1,48 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="512dp"
android:height="512dp"
android:viewportWidth="512"
android:viewportHeight="512">
<path
android:pathData="M256,256m-180,0a180,180 0,1 1,360 0a180,180 0,1 1,-360 0"
android:strokeAlpha="0.15"
android:strokeWidth="2"
android:fillColor="#00000000"
android:strokeColor="#D4FF00"
android:fillAlpha="0.15"/>
<path
android:pathData="M254,160h4v192h-4z"
android:strokeAlpha="0.2"
android:fillColor="#D4FF00"
android:fillAlpha="0.2"/>
<path
android:pathData="M256,100L220,240L256,265L292,240L256,100Z"
android:strokeAlpha="0.9"
android:fillColor="#D4FF00"
android:fillAlpha="0.9"/>
<path
android:pathData="M256,412L292,272L256,247L220,272L256,412Z"
android:strokeAlpha="0.7"
android:fillColor="#D4FF00"
android:fillAlpha="0.7"/>
<path
android:pathData="M100,256L240,292L265,256L240,220L100,256Z"
android:strokeAlpha="0.5"
android:fillColor="#D4FF00"
android:fillAlpha="0.5"/>
<path
android:pathData="M412,256L272,220L247,256L272,292L412,256Z"
android:strokeAlpha="0.5"
android:fillColor="#D4FF00"
android:fillAlpha="0.5"/>
<path
android:pathData="M256,256m-8,0a8,8 0,1 1,16 0a8,8 0,1 1,-16 0"
android:fillColor="#D4FF00"/>
<path
android:pathData="M256,256m-16,0a16,16 0,1 1,32 0a16,16 0,1 1,-32 0"
android:strokeAlpha="0.4"
android:strokeWidth="1.5"
android:fillColor="#00000000"
android:strokeColor="#D4FF00"
android:fillAlpha="0.4"/>
</vector>

View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="#1A007AFF" /> <!-- Electric Blue transparent background -->
<stroke android:width="1dp" android:color="#40D4FF00" /> <!-- Subtle Volt border -->
<corners android:radius="16dp" />
</shape>

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="oval">
<solid android:color="#1A000000" /> <!-- 10% Black overlay -->
<stroke android:width="1dp" android:color="#1A000000" />
</shape>

View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="#1AFFFFFF" /> <!-- Subtle glow/fill -->
<stroke android:width="1dp" android:color="@color/volt_green" />
<corners android:radius="16dp" />
</shape>

View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<stroke android:width="1dp" android:color="@color/text_dim" />
<corners android:radius="16dp" />
<solid android:color="@android:color/transparent" />
</shape>

View File

@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<!-- Transparent background for the empty state -->
<solid android:color="@color/surface_secondary" />
<!-- Dashed stroke definition -->
<stroke
android:width="2dp"
android:color="@color/border_subtle"
android:dashWidth="8dp"
android:dashGap="8dp" />
<!-- Matching the corner radius of the other cards -->
<corners android:radius="@dimen/radius_l" />
</shape>

View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="@color/surface_primary" />
<stroke android:width="1dp" android:color="#1AFFFFFF" />
<corners android:radius="8dp" />
</shape>

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="@color/volt_green" />
<corners android:radius="12dp" />
</shape>

View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<!-- High-visibility highlight for Triple mode -->
<solid android:color="@color/triple_blue" />
<corners android:radius="12dp" />
</shape>

View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<!-- High-visibility highlight for Double mode -->
<solid android:color="@color/double_red" />
<corners android:radius="12dp" />
</shape>

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<!-- The base background for the multiplier toggle bar -->
<solid android:color="@color/surface_secondary" />
<corners android:radius="16dp" />
<stroke
android:width="1dp"
android:color="@color/border_subtle" />
</shape>

View File

@@ -0,0 +1,109 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/midnight_black">
<!-- FORM VIEW -->
<LinearLayout
android:id="@+id/layoutForm"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:padding="24dp"
android:gravity="center_horizontal">
<TextView
android:id="@+id/tvTitle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/txt_create_profile_header"
android:textAppearance="@style/TextAppearance.Oche.Headline"
android:layout_marginBottom="32dp"/>
<com.google.android.material.imageview.ShapeableImageView
android:id="@+id/ivAddPlayerProfile"
android:layout_width="120dp"
android:layout_height="120dp"
android:background="@color/surface_secondary"
android:src="@drawable/ic_users"
app:tint="@color/text_dim"
app:shapeAppearanceOverlay="@style/ShapeAppearance.MaterialComponents.MediumComponent"
app:strokeColor="@color/volt_green"
app:strokeWidth="2dp" />
<com.google.android.material.textfield.TextInputLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="32dp"
style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/etUsername"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@color/surface_secondary"
android:hint="@string/txt_create_profile_username_hint"
android:textColor="@color/text_primary" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.button.MaterialButton
android:id="@+id/btnSavePlayer"
style="@style/Widget.Oche_Button_Primary"
android:layout_width="match_parent"
android:layout_height="64dp"
android:layout_marginTop="32dp"
android:text="@string/txt_create_profile_username_save" />
</LinearLayout>
<!-- CROPPER VIEW -->
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/layoutCropper"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:visibility="gone">
<ImageView
android:id="@+id/ivCropPreview"
android:layout_width="0dp"
android:layout_height="0dp"
android:scaleType="fitCenter"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent" />
<!-- Corrected package reference -->
<com.aldo.apps.ochecompanion.ui.CropOverlayView
android:id="@+id/cropOverlay"
android:layout_width="match_parent"
android:layout_height="match_parent" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:padding="24dp"
app:layout_constraintBottom_toBottomOf="parent">
<Button
android:id="@+id/btnCancelCrop"
android:layout_width="0dp"
android:layout_weight="1"
android:layout_height="wrap_content"
android:text="@string/txt_cancel_crop"
android:backgroundTint="@color/surface_secondary" />
<Button
android:id="@+id/btnConfirmCrop"
android:layout_width="0dp"
android:layout_weight="1"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:text="@string/txt_confirm_crop"
android:backgroundTint="@color/volt_green"
android:textColor="@color/midnight_black" />
</LinearLayout>
</androidx.constraintlayout.widget.ConstraintLayout>
</FrameLayout>

View File

@@ -0,0 +1,158 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/background_primary">
<!-- 1. HIGH-IMPACT SCOREBOARD (TOP) -->
<LinearLayout
android:id="@+id/scoreContainer"
android:layout_width="match_parent"
android:layout_height="200dp"
android:background="@color/surface_primary"
android:gravity="center"
android:orientation="vertical"
app:layout_constraintTop_toTopOf="parent">
<TextView
android:id="@+id/tvPlayerName"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:fontFamily="sans-serif-black"
android:letterSpacing="0.3"
android:textColor="@color/text_secondary"
android:textSize="11sp"
tools:text="SNAKEBITE" />
<TextView
android:id="@+id/tvScorePrimary"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:fontFamily="monospace"
android:letterSpacing="-0.08"
android:textColor="@color/volt_green"
android:textSize="100sp"
android:textStyle="bold"
tools:text="501" />
<TextView
android:id="@+id/tvLegAvg"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textColor="@color/text_dim"
android:textSize="10sp"
tools:text="AVG: 0.0" />
</LinearLayout>
<!-- 2. TRACKER BAR -->
<LinearLayout
android:id="@+id/trackerBar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center"
android:orientation="horizontal"
android:padding="12dp"
app:layout_constraintTop_toBottomOf="@id/scoreContainer">
<TextView android:id="@+id/tvDart1" style="@style/Oche_DartPill" />
<TextView android:id="@+id/tvDart2" style="@style/Oche_DartPill" android:layout_marginHorizontal="12dp" />
<TextView android:id="@+id/tvDart3" style="@style/Oche_DartPill" />
<ImageButton
android:id="@+id/btnUndoDart"
android:layout_width="40dp"
android:layout_height="40dp"
android:layout_marginStart="24dp"
android:background="?attr/selectableItemBackgroundBorderless"
android:src="@drawable/ic_history"
app:tint="@color/text_dim" />
</LinearLayout>
<!-- 3. INTERACTIVE INPUT ZONE (STUCK TO BOTTOM) -->
<LinearLayout
android:id="@+id/bottomSection"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:background="@android:color/transparent"
app:layout_constraintBottom_toBottomOf="parent">
<!-- MULTIPLIER BAR (Directly above the keyboard stack) -->
<LinearLayout
android:id="@+id/multiplierLayout"
android:layout_width="match_parent"
android:layout_height="52dp"
android:layout_marginHorizontal="20dp"
android:layout_marginBottom="8dp"
android:background="@drawable/shape_round_surface"
android:padding="4dp">
<TextView android:id="@+id/btnMultiplierSingle" style="@style/Oche_Multiplier_Label" android:text="@string/txt_game_btn_single" />
<TextView android:id="@+id/btnMultiplierDouble" style="@style/Oche_Multiplier_Label" android:text="@string/txt_game_btn_double" android:layout_marginHorizontal="2dp" />
<TextView android:id="@+id/btnMultiplierTriple" style="@style/Oche_Multiplier_Label" android:text="@string/txt_game_btn_triple" />
</LinearLayout>
<!-- SMART ROUTE (Nested here to appear between multipliers and keys when active) -->
<LinearLayout
android:id="@+id/layoutCheckoutSuggestion"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginHorizontal="20dp"
android:layout_marginBottom="12dp"
android:background="@drawable/shape_checkout_glow"
android:gravity="center"
android:padding="12dp"
android:visibility="gone">
<TextView
android:id="@+id/tvCheckoutSuggestion"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:fontFamily="sans-serif-black"
android:textColor="@color/volt_green"
android:textSize="13sp"
tools:text="T20 • T19 • D12" />
</LinearLayout>
<!-- THE SEAMLESS GRID -->
<GridLayout
android:id="@+id/glKeyboard"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginHorizontal="12dp"
android:columnCount="4"
android:rowCount="5"
android:useDefaultMargins="false">
<!-- Buttons inflated via setupKeyboard() in GameActivity -->
</GridLayout>
<!-- FINAL ACTIONS -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="72dp"
android:orientation="horizontal"
android:paddingHorizontal="14dp"
android:paddingBottom="12dp"
android:layout_marginTop="4dp">
<Button
android:id="@+id/btnBull"
android:layout_width="90dp"
android:layout_height="match_parent"
android:layout_margin="2dp"
android:backgroundTint="@color/surface_primary"
android:onClick="onBullTap"
android:text="@string/txt_game_btn_bull"
android:fontFamily="sans-serif-black"
android:textColor="@color/double_red" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btnSubmitTurn"
style="@style/Widget.Oche_Button_Primary"
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_margin="2dp"
android:layout_weight="1"
android:text="@string/txt_game_btn_submit"
app:icon="@drawable/ic_chevron_right"
app:iconGravity="end" />
</LinearLayout>
</LinearLayout>
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@@ -0,0 +1,158 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/background_primary"
tools:context=".MainMenuActivity"
android:id="@+id/main">
<!-- Top Bar -->
<LinearLayout
android:id="@+id/topBar"
android:layout_width="match_parent"
android:layout_height="72dp"
android:background="@color/surface_primary"
android:gravity="center_vertical"
android:orientation="horizontal"
android:paddingHorizontal="@dimen/spacing_m"
app:layout_constraintTop_toTopOf="parent">
<ImageView
android:layout_width="32dp"
android:layout_height="32dp"
android:src="@drawable/oche_logo"
android:contentDescription="@string/cd_txt_oche_logo"/>
<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="@dimen/spacing_s"
android:layout_weight="1"
android:fontFamily="sans-serif-black"
android:text="@string/app_name"
android:textAllCaps="true"
android:textColor="@color/text_primary"
android:textSize="20sp"
android:letterSpacing="0.05" />
<ImageButton
android:id="@+id/btnSettings"
android:layout_width="40dp"
android:layout_height="40dp"
android:background="?attr/selectableItemBackgroundBorderless"
android:src="@drawable/ic_settings"
app:tint="@color/text_secondary"
android:contentDescription="@string/cd_txt_settings_button"/>
</LinearLayout>
<androidx.core.widget.NestedScrollView
android:layout_width="match_parent"
android:layout_height="0dp"
app:layout_constraintBottom_toTopOf="@id/bottomNav"
app:layout_constraintTop_toBottomOf="@id/topBar">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="@dimen/spacing_m">
<!-- Hero Section: Quick Start -->
<com.aldo.apps.ochecompanion.ui.QuickStartButton
android:id="@+id/quick_start_btn"
android:layout_width="match_parent"
android:layout_height="wrap_content"/>
<!-- Squad Section -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="bottom"
android:layout_marginBottom="@dimen/spacing_m">
<TextView
android:layout_width="0dp"
android:layout_weight="1"
android:layout_height="wrap_content"
android:textAppearance="@style/TextAppearance.Oche.Caption"
android:text="@string/txt_squad_title" />
<TextView
android:id="@+id/btnAddPlayer"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/txt_squad_add"
android:textColor="@color/volt_green"
android:textStyle="bold"
android:textSize="12sp"
android:textAllCaps="true"/>
</LinearLayout>
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/rvSquad"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:nestedScrollingEnabled="false"
tools:listitem="@layout/item_player_small"
android:layout_marginBottom="@dimen/spacing_xl"/>
<!-- Last Match Section -->
<com.aldo.apps.ochecompanion.ui.MatchRecapView
android:id="@+id/match_recap"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
</LinearLayout>
</androidx.core.widget.NestedScrollView>
<!-- Bottom Navigation Mock -->
<LinearLayout
android:id="@+id/bottomNav"
android:layout_width="match_parent"
android:layout_height="64dp"
android:background="@color/surface_primary"
android:orientation="horizontal"
app:layout_constraintBottom_toBottomOf="parent">
<FrameLayout
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1">
<ImageView
android:layout_width="24dp"
android:layout_height="24dp"
android:layout_gravity="center"
android:src="@drawable/ic_target"
app:tint="@color/volt_green"/>
</FrameLayout>
<FrameLayout
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1">
<ImageView
android:layout_width="24dp"
android:layout_height="24dp"
android:layout_gravity="center"
android:src="@drawable/ic_users"
app:tint="@color/text_dim"/>
</FrameLayout>
<FrameLayout
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1">
<ImageView
android:layout_width="24dp"
android:layout_height="24dp"
android:layout_gravity="center"
android:src="@drawable/ic_history"
app:tint="@color/text_dim"/>
</FrameLayout>
</LinearLayout>
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@@ -0,0 +1,55 @@
<?xml version="1.0" encoding="utf-8"?>
<merge xmlns:tools="http://schemas.android.com/tools"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:android="http://schemas.android.com/apk/res/android"
tools:parentTag="com.google.android.material.card.MaterialCardView">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center_vertical"
android:orientation="horizontal"
android:padding="16dp">
<com.google.android.material.imageview.ShapeableImageView
android:id="@+id/ivPlayerProfile"
android:layout_width="56dp"
android:layout_height="56dp"
android:background="@color/surface_secondary"
app:shapeAppearanceOverlay="@style/ShapeAppearance.MaterialComponents.SmallComponent"
app:strokeColor="@color/border_subtle"
app:strokeWidth="1dp" />
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_weight="1"
android:orientation="vertical">
<TextView
android:id="@+id/tvPlayerName"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textAppearance="@style/TextAppearance.MaterialComponents.Subtitle1"
android:textColor="@color/text_primary"
android:textStyle="bold"
tools:text="Snakebite" />
<TextView
android:id="@+id/tvPlayerAvg"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textAppearance="@style/TextAppearance.Oche.Caption"
tools:text="AVG: 94.2" />
</LinearLayout>
<ImageView
android:id="@+id/ivChevron"
android:layout_width="24dp"
android:layout_height="24dp"
android:src="@drawable/ic_chevron_right"
app:tint="@color/text_dim" />
</LinearLayout>
</merge>

View File

@@ -0,0 +1,20 @@
<?xml version="1.0" encoding="utf-8"?>
<com.google.android.material.button.MaterialButton xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="0dp"
android:layout_height="56dp"
android:layout_columnWeight="1"
android:layout_margin="1dp"
android:insetTop="0dp"
android:insetBottom="0dp"
android:insetLeft="0dp"
android:insetRight="0dp"
app:cornerRadius="8dp"
app:strokeWidth="1dp"
app:strokeColor="@color/border_subtle"
android:backgroundTint="@android:color/transparent"
android:textColor="@color/text_primary"
android:textSize="22sp"
android:fontFamily="sans-serif-black"
android:stateListAnimator="@null"
style="@style/Widget.MaterialComponents.Button.UnelevatedButton" />

View File

@@ -0,0 +1,149 @@
<?xml version="1.0" encoding="utf-8"?>
<merge xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
tools:parentTag="android.widget.FrameLayout">
<!-- EMPTY STATE -->
<LinearLayout
android:id="@+id/stateEmpty"
style="@style/Widget.Oche.Card"
android:layout_width="match_parent"
android:layout_height="140dp"
android:background="@drawable/shape_dashed_border"
android:gravity="center"
android:orientation="vertical"
android:visibility="gone">
<ImageView
android:layout_width="40dp"
android:layout_height="40dp"
android:alpha="0.5"
android:contentDescription="@string/cd_text_historic_record"
android:src="@drawable/ic_history"
app:tint="@color/text_primary" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:alpha="0.5"
android:text="@string/txt_no_matches"
android:textAppearance="@style/TextAppearance.Oche.Caption"
android:textColor="@color/text_primary" />
</LinearLayout>
<!-- 1v1 STATE -->
<androidx.cardview.widget.CardView
android:id="@+id/state1v1"
style="@style/Widget.Oche.Card"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@drawable/shape_dashed_border"
android:visibility="visible">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center"
android:orientation="horizontal"
android:padding="24dp">
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1">
<TextView
android:id="@+id/tvP1Score"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:gravity="center"
android:textAppearance="@style/TextAppearance.Oche.Hero"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:text="501" />
<TextView
android:id="@+id/tvP1Name"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:gravity="center"
android:textAppearance="@style/TextAppearance.Oche.Caption"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/tvP1Score"
tools:text="Snakebite" />
</androidx.constraintlayout.widget.ConstraintLayout>
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginHorizontal="16dp"
android:text="@string/txt_match_vs"
android:textColor="@color/text_dim"
android:textStyle="bold|italic" />
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1">
<TextView
android:id="@+id/tvP2Score"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:alpha="0.5"
android:gravity="center"
android:textAppearance="@style/TextAppearance.Oche.Hero"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:text="342" />
<TextView
android:id="@+id/tvP2Name"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:gravity="center"
android:textAppearance="@style/TextAppearance.Oche.Caption"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/tvP2Score"
tools:text="MVG" />
</androidx.constraintlayout.widget.ConstraintLayout>
</LinearLayout>
</androidx.cardview.widget.CardView>
<!-- GROUP STATE -->
<androidx.cardview.widget.CardView
android:id="@+id/stateGroup"
style="@style/Widget.Oche.Card"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:visibility="gone">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/txt_group_match_header"
android:textAppearance="@style/TextAppearance.Oche.Caption"
android:textColor="@color/volt_green" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/rvLeaderboard"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp" />
</LinearLayout>
</androidx.cardview.widget.CardView>
</merge>

View File

@@ -0,0 +1,68 @@
<?xml version="1.0" encoding="utf-8"?>
<merge xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
tools:parentTag="android.widget.FrameLayout">
<!-- The Container -->
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="110dp"
android:background="@drawable/btn_primary_volt"
android:padding="24dp">
<!-- Text Group -->
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:orientation="vertical"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toStartOf="@+id/iconContainer"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent">
<TextView
android:id="@+id/tvQuickStartMain"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:fontFamily="sans-serif-black"
android:text="@string/txt_quick_start_title"
android:textColor="@color/midnight_black"
android:textSize="28sp"
android:letterSpacing="-0.02"
android:textStyle="italic" />
<TextView
android:id="@+id/tvQuickStartSub"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:alpha="0.7"
android:fontFamily="sans-serif-medium"
android:text="STANDARD 501 • DOUBLE OUT"
android:textColor="@color/midnight_black"
android:textSize="11sp"
android:textAllCaps="true"
android:letterSpacing="0.1" />
</LinearLayout>
<!-- Play Icon Circle -->
<FrameLayout
android:id="@+id/iconContainer"
android:layout_width="48dp"
android:layout_height="48dp"
android:background="@drawable/shape_circle_overlay"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent">
<ImageView
android:layout_width="24dp"
android:layout_height="24dp"
android:layout_gravity="center"
android:layout_marginStart="2dp"
android:src="@drawable/ic_play"
app:tint="@color/midnight_black" />
</FrameLayout>
</androidx.constraintlayout.widget.ConstraintLayout>
</merge>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
</adaptive-icon>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
</adaptive-icon>

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

View File

@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Brand Identity (Dark) -->
<color name="volt_green">#D4FF00</color> <!-- Vibrant volt -->
<color name="background_primary">#0A0A0A</color> <!-- True black -->
<color name="surface_primary">#121212</color> <!-- Charcoal headers -->
<color name="surface_secondary">#1E1E1E</color> <!-- Slate cards -->
<!-- Contextual Colors -->
<color name="triple_blue">#007AFF</color>
<color name="double_red">#FF3B30</color>
<!-- Typography & UI -->
<color name="text_primary">#FFFFFF</color>
<color name="text_secondary">#A0A0A0</color>
<color name="text_dim">#48484A</color>
<color name="border_subtle">#1AFFFFFF</color>
</resources>

View File

@@ -0,0 +1,2 @@
<resources>
</resources>

View File

@@ -0,0 +1,7 @@
<resources xmlns:tools="http://schemas.android.com/tools">
<!-- Base application theme. -->
<style name="Base.Theme.OcheCompanion" parent="Theme.Material3.DayNight.NoActionBar">
<!-- Customize your dark theme here. -->
<!-- <item name="colorPrimary">@color/my_dark_primary</item> -->
</style>
</resources>

View File

@@ -0,0 +1,24 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Brand Identity (Light) -->
<color name="volt_green">#B3D900</color> <!-- Deeper volt for white contrast -->
<color name="background_primary">#F2F2F7</color> <!-- Soft light gray -->
<color name="surface_primary">#FFFFFF</color> <!-- Pure white cards -->
<color name="surface_secondary">#E5E5EA</color> <!-- Light gray inputs -->
<color name="midnight_black">#0A0A0A</color> <!-- Real black for OLED DIsplays -->
<!-- Contextual Colors -->
<color name="triple_blue">#0056B3</color>
<color name="double_red">#D70015</color>
<!-- Typography & UI -->
<color name="text_primary">#1C1C1E</color>
<color name="text_secondary">#6E6E73</color>
<color name="text_dim">#AEAEB2</color>
<color name="text_on_volt">#1C1C1E</color>
<color name="border_subtle">#1F000000</color>
<color name="light_blue_400">#FF29B6F6</color>
<color name="light_blue_600">#FF039BE5</color>
<color name="gray_400">#FFBDBDBD</color>
<color name="gray_600">#FF757575</color>
</resources>

View File

@@ -0,0 +1,23 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Text Sizes -->
<dimen name="text_hero">48sp</dimen>
<dimen name="text_headline">24sp</dimen>
<dimen name="text_subhead">16sp</dimen>
<dimen name="text_body">14sp</dimen>
<dimen name="text_caption">12sp</dimen>
<dimen name="text_micro">10sp</dimen>
<!-- Spacing -->
<dimen name="spacing_xs">4dp</dimen>
<dimen name="spacing_s">8dp</dimen>
<dimen name="spacing_m">16dp</dimen>
<dimen name="spacing_l">24dp</dimen>
<dimen name="spacing_xl">32dp</dimen>
<!-- Component Specs -->
<dimen name="radius_m">12dp</dimen>
<dimen name="radius_l">24dp</dimen>
<dimen name="button_height">56dp</dimen>
<dimen name="card_elevation">4dp</dimen>
</resources>

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#0A0A0A</color>
</resources>

View File

@@ -0,0 +1,36 @@
<resources>
<string name="app_name" translatable="false">Oche Companion</string>
<!-- Main menu -->
<string name="txt_quick_start_header">Immediate Action</string>
<string name="txt_quick_start_title">Quick start</string>
<string name="txt_quick_start_subtitle">Start %s</string>
<string name="txt_squad_title">Your Squad</string>
<string name="txt_squad_add">+ Add New</string>
<string name="txt_history_title">Last Match</string>
<string name="txt_no_matches">No matches yet</string>
<string name="txt_match_vs">VS</string>
<string name="txt_group_match_header">Group Standings</string>
<string name="txt_player_average_base">AVG: %.1f</string>
<!-- Add Player -->
<string name="txt_create_profile_header">Create Profile</string>
<string name="txt_create_profile_username_hint">Add your username</string>
<string name="txt_create_profile_username_save">Save to Squad</string>
<string name="txt_update_profile_header">Update Profile</string>
<string name="txt_update_profile_username_save">Update Squad</string>
<string name="txt_cancel_crop">Cancel</string>
<string name="txt_confirm_crop">Confirm Crop</string>
<!-- GameActivity -->
<string name="txt_game_btn_single">Single</string>
<string name="txt_game_btn_double">Double</string>
<string name="txt_game_btn_triple">Triple</string>
<string name="txt_game_btn_bull">Bull</string>
<string name="txt_game_btn_submit">Submit Turn</string>
<!-- Image Content description -->
<string name="cd_txt_oche_logo">Application Logo</string>
<string name="cd_txt_settings_button">Settings</string>
<string name="cd_text_historic_record">Match History</string>
</resources>

View File

@@ -0,0 +1,90 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Giant Score Numbers -->
<style name="TextAppearance.Oche.Hero" parent="TextAppearance.MaterialComponents.Headline1">
<item name="android:textSize">@dimen/text_hero</item>
<item name="android:textStyle">bold</item>
<item name="android:fontFamily">monospace</item> <!-- For fixed width scores -->
<item name="android:textColor">@color/text_primary</item>
<item name="android:letterSpacing">-0.05</item>
</style>
<!-- Section Headers -->
<style name="TextAppearance.Oche.Headline" parent="TextAppearance.MaterialComponents.Headline5">
<item name="android:textSize">@dimen/text_headline</item>
<item name="android:textStyle">bold</item>
<item name="android:fontFamily">sans-serif-black</item>
<item name="android:textColor">@color/text_primary</item>
</style>
<!-- Metadata / Stats Labels -->
<style name="TextAppearance.Oche.Caption" parent="TextAppearance.MaterialComponents.Caption">
<item name="android:textSize">@dimen/text_micro</item>
<item name="android:textStyle">bold</item>
<item name="android:textAllCaps">true</item>
<item name="android:letterSpacing">0.2</item>
<item name="android:textColor">@color/text_secondary</item>
</style>
<!-- The Scoring Grid Tiles -->
<style name="Widget.Oche.Button.Grid" parent="Widget.MaterialComponents.Button.TextButton">
<item name="android:layout_width">0dp</item>
<item name="android:layout_height">80dp</item>
<item name="android:background">@drawable/btn_grid_item</item>
<item name="android:textColor">@color/text_primary</item>
<item name="android:textSize">20sp</item>
<item name="android:textStyle">bold</item>
<item name="android:insetTop">0dp</item>
<item name="android:insetBottom">0dp</item>
</style>
<!-- Player Card Container -->
<style name="Widget.Oche.Card" parent="Widget.MaterialComponents.CardView">
<item name="cardBackgroundColor">@color/surface_secondary</item>
<item name="cardCornerRadius">@dimen/radius_m</item>
<item name="cardElevation">@dimen/card_elevation</item>
<item name="android:layout_margin">@dimen/spacing_s</item>
</style>
<!-- Primary Brand Button (Volt Green) -->
<style name="Widget.Oche_Button_Primary" parent="Widget.MaterialComponents.Button">
<item name="android:layout_width">match_parent</item>
<item name="android:layout_height">@dimen/button_height</item>
<item name="android:insetTop">0dp</item>
<item name="android:insetBottom">0dp</item>
<item name="backgroundTint">@null</item>
<item name="android:background">@drawable/btn_primary_volt</item>
<item name="android:textColor">@color/midnight_black</item>
<item name="android:textStyle">bold</item>
<item name="android:letterSpacing">0.1</item>
<item name="cornerRadius">12dp</item>
</style>
<!-- Dart Tracker Pills -->
<style name="Oche_DartPill">
<item name="android:layout_width">50dp</item>
<item name="android:layout_height">32dp</item>
<item name="android:gravity">center</item>
<item name="android:textSize">14sp</item>
<item name="android:fontFamily">sans-serif-black</item>
</style>
<!-- Multiplier Selector Labels -->
<style name="Oche_Multiplier_Label">
<item name="android:layout_width">0dp</item>
<item name="android:layout_height">match_parent</item>
<item name="android:layout_weight">1</item>
<item name="android:gravity">center</item>
<item name="android:fontFamily">sans-serif-black</item>
<item name="android:textSize">11sp</item>
<item name="android:textAllCaps">true</item>
<item name="android:textColor">@color/text_secondary</item>
</style>
<!-- Custom Shape Appearance for rounded components -->
<style name="Oche_ShapeAppearance_SmallComponent" parent="ShapeAppearance.MaterialComponents.SmallComponent">
<item name="cornerFamily">rounded</item>
<item name="cornerSize">12dp</item>
</style>
</resources>

View File

@@ -0,0 +1,9 @@
<resources xmlns:tools="http://schemas.android.com/tools">
<!-- Base application theme. -->
<style name="Base.Theme.OcheCompanion" parent="Theme.Material3.DayNight.NoActionBar">
<!-- Customize your light theme here. -->
<!-- <item name="colorPrimary">@color/my_light_primary</item> -->
</style>
<style name="Theme.OcheCompanion" parent="Base.Theme.OcheCompanion" />
</resources>

View File

@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?><!--
Sample backup rules file; uncomment and customize as necessary.
See https://developer.android.com/guide/topics/data/autobackup
for details.
Note: This file is ignored for devices older than API 31
See https://developer.android.com/about/versions/12/backup-restore
-->
<full-backup-content>
<!--
<include domain="sharedpref" path="."/>
<exclude domain="sharedpref" path="device.xml"/>
-->
</full-backup-content>

View File

@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?><!--
Sample data extraction rules file; uncomment and customize as necessary.
See https://developer.android.com/about/versions/12/backup-restore#xml-changes
for details.
-->
<data-extraction-rules>
<cloud-backup>
<!-- TODO: Use <include> and <exclude> to control what is backed up.
<include .../>
<exclude .../>
-->
</cloud-backup>
<!--
<device-transfer>
<include .../>
<exclude .../>
</device-transfer>
-->
</data-extraction-rules>

View File

@@ -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 <a href="http://d.android.com/tools/testing">Testing documentation</a>
*/
public class ExampleUnitTest {
@Test
public void addition_isCorrect() {
assertEquals(4, 2 + 2);
}
}

4
build.gradle.kts Normal file
View File

@@ -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
}

21
gradle.properties Normal file
View File

@@ -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

27
gradle/libs.versions.toml Normal file
View File

@@ -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" }

BIN
gradle/wrapper/gradle-wrapper.jar vendored Normal file

Binary file not shown.

View File

@@ -0,0 +1,9 @@
#Mon Jan 26 12:35:35 CET 2026
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionSha256Sum=a17ddd85a26b6a7f5ddb71ff8b05fc5104c0202c6e64782429790c933686c806
distributionUrl=https\://services.gradle.org/distributions/gradle-9.1.0-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

251
gradlew vendored Executable file
View File

@@ -0,0 +1,251 @@
#!/bin/sh
#
# Copyright © 2015 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
CLASSPATH="\\\"\\\""
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"

94
gradlew.bat vendored Normal file
View File

@@ -0,0 +1,94 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:execute
@rem Setup the command line
set CLASSPATH=
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
:end
@rem End local scope for the variables with windows NT shell
if %ERRORLEVEL% equ 0 goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega

23
settings.gradle.kts Normal file
View File

@@ -0,0 +1,23 @@
pluginManagement {
repositories {
google {
content {
includeGroupByRegex("com\\.android.*")
includeGroupByRegex("com\\.google.*")
includeGroupByRegex("androidx.*")
}
}
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
}
}
rootProject.name = "Oche Companion"
include(":app")