Initial Commit
1
app/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/build
|
||||
47
app/build.gradle.kts
Normal 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
@@ -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
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
36
app/src/main/AndroidManifest.xml
Normal 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>
|
||||
BIN
app/src/main/ic_launcher-playstore.png
Normal file
|
After Width: | Height: | Size: 26 KiB |
@@ -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();
|
||||
}
|
||||
}
|
||||
1705
app/src/main/java/com/aldo/apps/ochecompanion/GameActivity.java
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<Player> allPlayers = playerDao.getAllPlayers();
|
||||
*
|
||||
* // Insert a match
|
||||
* Match match = new Match(System.currentTimeMillis(), "501", 2, participantJson);
|
||||
* matchDao.insert(match);
|
||||
*
|
||||
* // Get recent matches
|
||||
* List<Match> recentMatches = matchDao.getAllMatches();
|
||||
* }).start();
|
||||
* </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<Player> squad = playerDao.getAllPlayers();
|
||||
*
|
||||
* // Update player stats
|
||||
* Player player = playerDao.getPlayerById(5);
|
||||
* if (player != null) {
|
||||
* player.careerAverage = 85.5;
|
||||
* player.matchesPlayed++;
|
||||
* playerDao.update(player);
|
||||
* }
|
||||
*
|
||||
* // Update UI on main thread
|
||||
* runOnUiThread(() -> updateSquadDisplay(squad));
|
||||
* }).start();
|
||||
* </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<Player> players = db.playerDao().getAllPlayers();
|
||||
* runOnUiThread(() -> adapter.setPlayers(players));
|
||||
* });
|
||||
*
|
||||
* // Pattern 2: Insert and navigate
|
||||
* new Thread(() -> {
|
||||
* Player player = new Player("John", picUri);
|
||||
* db.playerDao().insert(player);
|
||||
* runOnUiThread(() -> {
|
||||
* Toast.makeText(context, "Player added", Toast.LENGTH_SHORT).show();
|
||||
* finish();
|
||||
* });
|
||||
* }).start();
|
||||
*
|
||||
* // Pattern 3: Edit flow (query, modify, update)
|
||||
* new Thread(() -> {
|
||||
* Player player = db.playerDao().getPlayerById(playerId);
|
||||
* if (player != null) {
|
||||
* player.username = newName;
|
||||
* player.profilePictureUri = newPicUri;
|
||||
* db.playerDao().update(player);
|
||||
* runOnUiThread(() -> Toast.makeText(context, "Updated", Toast.LENGTH_SHORT).show());
|
||||
* }
|
||||
* }).start();
|
||||
* </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<List<Player>> 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<Match> 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<Match> recentMatches = db.matchDao().getAllMatches();
|
||||
*
|
||||
* runOnUiThread(() -> {
|
||||
* if (recentMatches.isEmpty()) {
|
||||
* // Show empty state - no matches played yet
|
||||
* showEmptyMatchHistory();
|
||||
* } else {
|
||||
* // Display most recent match in recap view
|
||||
* Match lastMatch = recentMatches.get(0);
|
||||
* matchRecapView.setMatch(lastMatch);
|
||||
* }
|
||||
* });
|
||||
* }).start();
|
||||
* </pre>
|
||||
* </p>
|
||||
* <p>
|
||||
* <strong>Match Completion Flow:</strong>
|
||||
* <pre>
|
||||
* // After match ends, save to database
|
||||
* private void saveMatchResult(List<Player> players, Map<Player, Score> scores) {
|
||||
* new Thread(() -> {
|
||||
* // Build participant data JSON
|
||||
* JSONArray participants = new JSONArray();
|
||||
* for (Player player : players) {
|
||||
* JSONObject data = new JSONObject();
|
||||
* data.put("id", player.id);
|
||||
* data.put("username", player.username);
|
||||
* data.put("rank", scores.get(player).rank);
|
||||
* data.put("average", scores.get(player).average);
|
||||
* participants.put(data);
|
||||
* }
|
||||
*
|
||||
* // Create and save match
|
||||
* Match match = new Match(
|
||||
* System.currentTimeMillis(),
|
||||
* currentGameMode,
|
||||
* players.size(),
|
||||
* participants.toString()
|
||||
* );
|
||||
*
|
||||
* db.matchDao().insert(match);
|
||||
*
|
||||
* // Navigate to results screen
|
||||
* runOnUiThread(() -> {
|
||||
* Intent intent = new Intent(this, MatchResultsActivity.class);
|
||||
* intent.putExtra("match", match);
|
||||
* startActivity(intent);
|
||||
* });
|
||||
* }).start();
|
||||
* }
|
||||
* </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<Match> allMatches = db.matchDao().getAllMatches();
|
||||
*
|
||||
* // Calculate statistics
|
||||
* int totalMatches = allMatches.size();
|
||||
* Map<String, Integer> gameModeCount = new HashMap<>();
|
||||
*
|
||||
* for (Match match : allMatches) {
|
||||
* gameModeCount.merge(match.gameMode, 1, Integer::sum);
|
||||
* }
|
||||
*
|
||||
* // Display stats
|
||||
* runOnUiThread(() -> showStatistics(totalMatches, gameModeCount));
|
||||
* }).start();
|
||||
* </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<Player> players = db.playerDao().getAllPlayers();
|
||||
* runOnUiThread(() -> displayPlayers(players));
|
||||
* }).start();
|
||||
* }
|
||||
* }
|
||||
*
|
||||
* // From Fragment
|
||||
* AppDatabase db = AppDatabase.getDatabase(requireContext());
|
||||
*
|
||||
* // From Service
|
||||
* AppDatabase db = AppDatabase.getDatabase(getApplicationContext());
|
||||
*
|
||||
* // From anywhere with Context
|
||||
* AppDatabase db = AppDatabase.getDatabase(context);
|
||||
* </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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<Match> 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();
|
||||
}
|
||||
@@ -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<Player> players = playerDao.getAllPlayers();
|
||||
* runOnUiThread(() -> updateUI(players));
|
||||
* }).start();
|
||||
*
|
||||
* // Update existing player (background thread)
|
||||
* new Thread(() -> {
|
||||
* Player player = playerDao.getPlayerById(playerId);
|
||||
* player.username = "New Name";
|
||||
* playerDao.update(player);
|
||||
* }).start();
|
||||
* </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<Player> 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<Player> allPlayers = playerDao.getAllPlayers();
|
||||
*
|
||||
* runOnUiThread(() -> {
|
||||
* if (allPlayers.isEmpty()) {
|
||||
* // Show empty state - encourage user to add players
|
||||
* showEmptySquadMessage();
|
||||
* } else {
|
||||
* // Update RecyclerView adapter with player list
|
||||
* playerAdapter.updatePlayers(allPlayers);
|
||||
* }
|
||||
* });
|
||||
* }).start();
|
||||
* </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<List<Player>> getAllPlayersLive();
|
||||
* </pre>
|
||||
* Or Flow for Kotlin coroutines:
|
||||
* <pre>
|
||||
* @Query("SELECT * FROM players ORDER BY username ASC")
|
||||
* Flow<List<Player>> 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();
|
||||
}
|
||||
@@ -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<Match> recentMatches = matchDao.getAllMatches();
|
||||
* runOnUiThread(() -> updateMatchRecapUI(recentMatches));
|
||||
* }).start();
|
||||
*
|
||||
* // Pass match to detail activity
|
||||
* Intent intent = new Intent(this, MatchDetailActivity.class);
|
||||
* intent.putExtra("match_object", matchToView); // Uses Serializable
|
||||
* startActivity(intent);
|
||||
* </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<Match> 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<Match> get501Matches();
|
||||
*
|
||||
* // Calculate average score by game mode
|
||||
* Map<String, Double> 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<String> VALID_GAME_MODES = new HashSet<>(
|
||||
* Arrays.asList("501", "301", "Cricket", "Around the Clock", "Killer")
|
||||
* );
|
||||
*
|
||||
* if (!VALID_GAME_MODES.contains(gameMode)) {
|
||||
* throw new IllegalArgumentException("Invalid game mode: " + gameMode);
|
||||
* }
|
||||
* </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<Match> getDuelMatches();
|
||||
*
|
||||
* // Get group matches only
|
||||
* @Query("SELECT * FROM matches WHERE playerCount >= 3")
|
||||
* List<Match> 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 < 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;
|
||||
}
|
||||
}
|
||||
485
app/src/main/java/com/aldo/apps/ochecompanion/models/Match.java
Normal 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<Player> 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<Player> playersCopy = new ArrayList<>(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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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 < 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 > 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
18
app/src/main/res/drawable/btn_grid_item.xml
Normal 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>
|
||||
10
app/src/main/res/drawable/btn_primary_volt.xml
Normal 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>
|
||||
10
app/src/main/res/drawable/ic_chevron_right.xml
Normal 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>
|
||||
10
app/src/main/res/drawable/ic_history.xml
Normal 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>
|
||||
10
app/src/main/res/drawable/ic_launcher_background.xml
Normal 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>
|
||||
51
app/src/main/res/drawable/ic_launcher_foreground.xml
Normal 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>
|
||||
10
app/src/main/res/drawable/ic_play.xml
Normal 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>
|
||||
10
app/src/main/res/drawable/ic_settings.xml
Normal 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>
|
||||
10
app/src/main/res/drawable/ic_target.xml
Normal 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>
|
||||
10
app/src/main/res/drawable/ic_users.xml
Normal 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>
|
||||
48
app/src/main/res/drawable/oche_logo.xml
Normal 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>
|
||||
7
app/src/main/res/drawable/shape_checkout_glow.xml
Normal 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>
|
||||
6
app/src/main/res/drawable/shape_circle_overlay.xml
Normal 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>
|
||||
7
app/src/main/res/drawable/shape_dart_pill_active.xml
Normal 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>
|
||||
7
app/src/main/res/drawable/shape_dart_pill_empty.xml
Normal 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>
|
||||
18
app/src/main/res/drawable/shape_dashed_border.xml
Normal 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>
|
||||
7
app/src/main/res/drawable/shape_keyboard_tile.xml
Normal 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>
|
||||
6
app/src/main/res/drawable/shape_multiplier_active.xml
Normal 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>
|
||||
7
app/src/main/res/drawable/shape_multiplier_blue.xml
Normal 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>
|
||||
7
app/src/main/res/drawable/shape_multiplier_red.xml
Normal 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>
|
||||
10
app/src/main/res/drawable/shape_round_surface.xml
Normal 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>
|
||||
109
app/src/main/res/layout/activity_add_player.xml
Normal 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>
|
||||
158
app/src/main/res/layout/activity_game.xml
Normal 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>
|
||||
158
app/src/main/res/layout/activity_main.xml
Normal 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>
|
||||
55
app/src/main/res/layout/item_player_small.xml
Normal 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>
|
||||
20
app/src/main/res/layout/view_keyboard_button.xml
Normal 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" />
|
||||
149
app/src/main/res/layout/view_match_recap.xml
Normal 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>
|
||||
68
app/src/main/res/layout/view_quick_start.xml
Normal 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>
|
||||
5
app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
Normal 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>
|
||||
5
app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
Normal 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>
|
||||
BIN
app/src/main/res/mipmap-hdpi/ic_launcher.webp
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
BIN
app/src/main/res/mipmap-hdpi/ic_launcher_round.webp
Normal file
|
After Width: | Height: | Size: 2.5 KiB |
BIN
app/src/main/res/mipmap-mdpi/ic_launcher.webp
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
BIN
app/src/main/res/mipmap-mdpi/ic_launcher_round.webp
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
BIN
app/src/main/res/mipmap-xhdpi/ic_launcher.webp
Normal file
|
After Width: | Height: | Size: 2.1 KiB |
BIN
app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp
Normal file
|
After Width: | Height: | Size: 3.0 KiB |
BIN
app/src/main/res/mipmap-xxhdpi/ic_launcher.webp
Normal file
|
After Width: | Height: | Size: 3.7 KiB |
BIN
app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp
Normal file
|
After Width: | Height: | Size: 5.4 KiB |
BIN
app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp
Normal file
|
After Width: | Height: | Size: 4.5 KiB |
BIN
app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp
Normal file
|
After Width: | Height: | Size: 6.2 KiB |
18
app/src/main/res/values-night/colors.xml
Normal 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>
|
||||
2
app/src/main/res/values-night/styles.xml
Normal file
@@ -0,0 +1,2 @@
|
||||
<resources>
|
||||
</resources>
|
||||
7
app/src/main/res/values-night/themes.xml
Normal 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>
|
||||
24
app/src/main/res/values/colors.xml
Normal 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>
|
||||
23
app/src/main/res/values/dimens.xml
Normal 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>
|
||||
4
app/src/main/res/values/ic_launcher_background.xml
Normal file
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="ic_launcher_background">#0A0A0A</color>
|
||||
</resources>
|
||||
36
app/src/main/res/values/strings.xml
Normal 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>
|
||||
90
app/src/main/res/values/styles.xml
Normal 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>
|
||||
9
app/src/main/res/values/themes.xml
Normal 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>
|
||||
13
app/src/main/res/xml/backup_rules.xml
Normal 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>
|
||||
19
app/src/main/res/xml/data_extraction_rules.xml
Normal 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>
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||