From a4a42fc73f6362762ad367ae7535a3d70b2f1bd5 Mon Sep 17 00:00:00 2001 From: Alexander Doerflinger Date: Wed, 28 Jan 2026 12:33:38 +0100 Subject: [PATCH] Initial Commit --- .gitignore | 15 + .idea/.gitignore | 3 + .idea/.name | 1 + .idea/AndroidProjectSystem.xml | 6 + .idea/compiler.xml | 6 + .idea/deploymentTargetSelector.xml | 18 + .idea/deviceManager.xml | 13 + .idea/gradle.xml | 19 + .idea/migrations.xml | 10 + .idea/misc.xml | 9 + .idea/runConfigurations.xml | 17 + .idea/studiobot.xml | 6 + app/.gitignore | 1 + app/build.gradle.kts | 47 + app/proguard-rules.pro | 21 + .../ExampleInstrumentedTest.java | 26 + app/src/main/AndroidManifest.xml | 36 + app/src/main/ic_launcher-playstore.png | Bin 0 -> 27182 bytes .../apps/ochecompanion/AddPlayerActivity.java | 716 +++++++ .../aldo/apps/ochecompanion/GameActivity.java | 1705 +++++++++++++++++ .../apps/ochecompanion/MainMenuActivity.java | 284 +++ .../ochecompanion/database/AppDatabase.java | 866 +++++++++ .../ochecompanion/database/dao/MatchDao.java | 289 +++ .../ochecompanion/database/dao/PlayerDao.java | 457 +++++ .../ochecompanion/database/objects/Match.java | 804 ++++++++ .../database/objects/Player.java | 1114 +++++++++++ .../aldo/apps/ochecompanion/models/Match.java | 485 +++++ .../ochecompanion/ui/CropOverlayView.java | 357 ++++ .../apps/ochecompanion/ui/MatchRecapView.java | 424 ++++ .../apps/ochecompanion/ui/PlayerItemView.java | 284 +++ .../ochecompanion/ui/QuickStartButton.java | 360 ++++ .../ui/adapter/MainMenuGroupMatchAdapter.java | 372 ++++ .../ui/adapter/MainMenuPlayerAdapter.java | 364 ++++ app/src/main/res/drawable/btn_grid_item.xml | 18 + .../main/res/drawable/btn_primary_volt.xml | 10 + .../main/res/drawable/ic_chevron_right.xml | 10 + app/src/main/res/drawable/ic_history.xml | 10 + .../res/drawable/ic_launcher_background.xml | 10 + .../res/drawable/ic_launcher_foreground.xml | 51 + app/src/main/res/drawable/ic_play.xml | 10 + app/src/main/res/drawable/ic_settings.xml | 10 + app/src/main/res/drawable/ic_target.xml | 10 + app/src/main/res/drawable/ic_users.xml | 10 + app/src/main/res/drawable/oche_logo.xml | 48 + .../main/res/drawable/shape_checkout_glow.xml | 7 + .../res/drawable/shape_circle_overlay.xml | 6 + .../res/drawable/shape_dart_pill_active.xml | 7 + .../res/drawable/shape_dart_pill_empty.xml | 7 + .../main/res/drawable/shape_dashed_border.xml | 18 + .../main/res/drawable/shape_keyboard_tile.xml | 7 + .../res/drawable/shape_multiplier_active.xml | 6 + .../res/drawable/shape_multiplier_blue.xml | 7 + .../res/drawable/shape_multiplier_red.xml | 7 + .../main/res/drawable/shape_round_surface.xml | 10 + .../main/res/layout/activity_add_player.xml | 109 ++ app/src/main/res/layout/activity_game.xml | 158 ++ app/src/main/res/layout/activity_main.xml | 158 ++ app/src/main/res/layout/item_player_small.xml | 55 + .../main/res/layout/view_keyboard_button.xml | 20 + app/src/main/res/layout/view_match_recap.xml | 149 ++ app/src/main/res/layout/view_quick_start.xml | 68 + .../res/mipmap-anydpi-v26/ic_launcher.xml | 5 + .../mipmap-anydpi-v26/ic_launcher_round.xml | 5 + app/src/main/res/mipmap-hdpi/ic_launcher.webp | Bin 0 -> 1788 bytes .../res/mipmap-hdpi/ic_launcher_round.webp | Bin 0 -> 2588 bytes app/src/main/res/mipmap-mdpi/ic_launcher.webp | Bin 0 -> 1104 bytes .../res/mipmap-mdpi/ic_launcher_round.webp | Bin 0 -> 1758 bytes .../main/res/mipmap-xhdpi/ic_launcher.webp | Bin 0 -> 2190 bytes .../res/mipmap-xhdpi/ic_launcher_round.webp | Bin 0 -> 3078 bytes .../main/res/mipmap-xxhdpi/ic_launcher.webp | Bin 0 -> 3756 bytes .../res/mipmap-xxhdpi/ic_launcher_round.webp | Bin 0 -> 5546 bytes .../main/res/mipmap-xxxhdpi/ic_launcher.webp | Bin 0 -> 4612 bytes .../res/mipmap-xxxhdpi/ic_launcher_round.webp | Bin 0 -> 6338 bytes app/src/main/res/values-night/colors.xml | 18 + app/src/main/res/values-night/styles.xml | 2 + app/src/main/res/values-night/themes.xml | 7 + app/src/main/res/values/colors.xml | 24 + app/src/main/res/values/dimens.xml | 23 + .../res/values/ic_launcher_background.xml | 4 + app/src/main/res/values/strings.xml | 36 + app/src/main/res/values/styles.xml | 90 + app/src/main/res/values/themes.xml | 9 + app/src/main/res/xml/backup_rules.xml | 13 + .../main/res/xml/data_extraction_rules.xml | 19 + .../apps/ochecompanion/ExampleUnitTest.java | 17 + build.gradle.kts | 4 + gradle.properties | 21 + gradle/libs.versions.toml | 27 + gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 45457 bytes gradle/wrapper/gradle-wrapper.properties | 9 + gradlew | 251 +++ gradlew.bat | 94 + settings.gradle.kts | 23 + 93 files changed, 10832 insertions(+) create mode 100644 .gitignore create mode 100644 .idea/.gitignore create mode 100644 .idea/.name create mode 100644 .idea/AndroidProjectSystem.xml create mode 100644 .idea/compiler.xml create mode 100644 .idea/deploymentTargetSelector.xml create mode 100644 .idea/deviceManager.xml create mode 100644 .idea/gradle.xml create mode 100644 .idea/migrations.xml create mode 100644 .idea/misc.xml create mode 100644 .idea/runConfigurations.xml create mode 100644 .idea/studiobot.xml create mode 100644 app/.gitignore create mode 100644 app/build.gradle.kts create mode 100644 app/proguard-rules.pro create mode 100644 app/src/androidTest/java/com/aldo/apps/ochecompanion/ExampleInstrumentedTest.java create mode 100644 app/src/main/AndroidManifest.xml create mode 100644 app/src/main/ic_launcher-playstore.png create mode 100644 app/src/main/java/com/aldo/apps/ochecompanion/AddPlayerActivity.java create mode 100644 app/src/main/java/com/aldo/apps/ochecompanion/GameActivity.java create mode 100644 app/src/main/java/com/aldo/apps/ochecompanion/MainMenuActivity.java create mode 100644 app/src/main/java/com/aldo/apps/ochecompanion/database/AppDatabase.java create mode 100644 app/src/main/java/com/aldo/apps/ochecompanion/database/dao/MatchDao.java create mode 100644 app/src/main/java/com/aldo/apps/ochecompanion/database/dao/PlayerDao.java create mode 100644 app/src/main/java/com/aldo/apps/ochecompanion/database/objects/Match.java create mode 100644 app/src/main/java/com/aldo/apps/ochecompanion/database/objects/Player.java create mode 100644 app/src/main/java/com/aldo/apps/ochecompanion/models/Match.java create mode 100644 app/src/main/java/com/aldo/apps/ochecompanion/ui/CropOverlayView.java create mode 100644 app/src/main/java/com/aldo/apps/ochecompanion/ui/MatchRecapView.java create mode 100644 app/src/main/java/com/aldo/apps/ochecompanion/ui/PlayerItemView.java create mode 100644 app/src/main/java/com/aldo/apps/ochecompanion/ui/QuickStartButton.java create mode 100644 app/src/main/java/com/aldo/apps/ochecompanion/ui/adapter/MainMenuGroupMatchAdapter.java create mode 100644 app/src/main/java/com/aldo/apps/ochecompanion/ui/adapter/MainMenuPlayerAdapter.java create mode 100644 app/src/main/res/drawable/btn_grid_item.xml create mode 100644 app/src/main/res/drawable/btn_primary_volt.xml create mode 100644 app/src/main/res/drawable/ic_chevron_right.xml create mode 100644 app/src/main/res/drawable/ic_history.xml create mode 100644 app/src/main/res/drawable/ic_launcher_background.xml create mode 100644 app/src/main/res/drawable/ic_launcher_foreground.xml create mode 100644 app/src/main/res/drawable/ic_play.xml create mode 100644 app/src/main/res/drawable/ic_settings.xml create mode 100644 app/src/main/res/drawable/ic_target.xml create mode 100644 app/src/main/res/drawable/ic_users.xml create mode 100644 app/src/main/res/drawable/oche_logo.xml create mode 100644 app/src/main/res/drawable/shape_checkout_glow.xml create mode 100644 app/src/main/res/drawable/shape_circle_overlay.xml create mode 100644 app/src/main/res/drawable/shape_dart_pill_active.xml create mode 100644 app/src/main/res/drawable/shape_dart_pill_empty.xml create mode 100644 app/src/main/res/drawable/shape_dashed_border.xml create mode 100644 app/src/main/res/drawable/shape_keyboard_tile.xml create mode 100644 app/src/main/res/drawable/shape_multiplier_active.xml create mode 100644 app/src/main/res/drawable/shape_multiplier_blue.xml create mode 100644 app/src/main/res/drawable/shape_multiplier_red.xml create mode 100644 app/src/main/res/drawable/shape_round_surface.xml create mode 100644 app/src/main/res/layout/activity_add_player.xml create mode 100644 app/src/main/res/layout/activity_game.xml create mode 100644 app/src/main/res/layout/activity_main.xml create mode 100644 app/src/main/res/layout/item_player_small.xml create mode 100644 app/src/main/res/layout/view_keyboard_button.xml create mode 100644 app/src/main/res/layout/view_match_recap.xml create mode 100644 app/src/main/res/layout/view_quick_start.xml create mode 100644 app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml create mode 100644 app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml create mode 100644 app/src/main/res/mipmap-hdpi/ic_launcher.webp create mode 100644 app/src/main/res/mipmap-hdpi/ic_launcher_round.webp create mode 100644 app/src/main/res/mipmap-mdpi/ic_launcher.webp create mode 100644 app/src/main/res/mipmap-mdpi/ic_launcher_round.webp create mode 100644 app/src/main/res/mipmap-xhdpi/ic_launcher.webp create mode 100644 app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp create mode 100644 app/src/main/res/mipmap-xxhdpi/ic_launcher.webp create mode 100644 app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp create mode 100644 app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp create mode 100644 app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp create mode 100644 app/src/main/res/values-night/colors.xml create mode 100644 app/src/main/res/values-night/styles.xml create mode 100644 app/src/main/res/values-night/themes.xml create mode 100644 app/src/main/res/values/colors.xml create mode 100644 app/src/main/res/values/dimens.xml create mode 100644 app/src/main/res/values/ic_launcher_background.xml create mode 100644 app/src/main/res/values/strings.xml create mode 100644 app/src/main/res/values/styles.xml create mode 100644 app/src/main/res/values/themes.xml create mode 100644 app/src/main/res/xml/backup_rules.xml create mode 100644 app/src/main/res/xml/data_extraction_rules.xml create mode 100644 app/src/test/java/com/aldo/apps/ochecompanion/ExampleUnitTest.java create mode 100644 build.gradle.kts create mode 100644 gradle.properties create mode 100644 gradle/libs.versions.toml create mode 100644 gradle/wrapper/gradle-wrapper.jar create mode 100644 gradle/wrapper/gradle-wrapper.properties create mode 100755 gradlew create mode 100644 gradlew.bat create mode 100644 settings.gradle.kts diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..aa724b7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,15 @@ +*.iml +.gradle +/local.properties +/.idea/caches +/.idea/libraries +/.idea/modules.xml +/.idea/workspace.xml +/.idea/navEditor.xml +/.idea/assetWizardSettings.xml +.DS_Store +/build +/captures +.externalNativeBuild +.cxx +local.properties diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..26d3352 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,3 @@ +# Default ignored files +/shelf/ +/workspace.xml diff --git a/.idea/.name b/.idea/.name new file mode 100644 index 0000000..3e06eac --- /dev/null +++ b/.idea/.name @@ -0,0 +1 @@ +Oche Companion \ No newline at end of file diff --git a/.idea/AndroidProjectSystem.xml b/.idea/AndroidProjectSystem.xml new file mode 100644 index 0000000..4a53bee --- /dev/null +++ b/.idea/AndroidProjectSystem.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/.idea/compiler.xml b/.idea/compiler.xml new file mode 100644 index 0000000..b86273d --- /dev/null +++ b/.idea/compiler.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/.idea/deploymentTargetSelector.xml b/.idea/deploymentTargetSelector.xml new file mode 100644 index 0000000..9071eba --- /dev/null +++ b/.idea/deploymentTargetSelector.xml @@ -0,0 +1,18 @@ + + + + + + + + + \ No newline at end of file diff --git a/.idea/deviceManager.xml b/.idea/deviceManager.xml new file mode 100644 index 0000000..91f9558 --- /dev/null +++ b/.idea/deviceManager.xml @@ -0,0 +1,13 @@ + + + + + + \ No newline at end of file diff --git a/.idea/gradle.xml b/.idea/gradle.xml new file mode 100644 index 0000000..639c779 --- /dev/null +++ b/.idea/gradle.xml @@ -0,0 +1,19 @@ + + + + + + + \ No newline at end of file diff --git a/.idea/migrations.xml b/.idea/migrations.xml new file mode 100644 index 0000000..f8051a6 --- /dev/null +++ b/.idea/migrations.xml @@ -0,0 +1,10 @@ + + + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 0000000..b2c751a --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,9 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/runConfigurations.xml b/.idea/runConfigurations.xml new file mode 100644 index 0000000..16660f1 --- /dev/null +++ b/.idea/runConfigurations.xml @@ -0,0 +1,17 @@ + + + + + + \ No newline at end of file diff --git a/.idea/studiobot.xml b/.idea/studiobot.xml new file mode 100644 index 0000000..539e3b8 --- /dev/null +++ b/.idea/studiobot.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/.gitignore b/app/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/app/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/app/build.gradle.kts b/app/build.gradle.kts new file mode 100644 index 0000000..03e7243 --- /dev/null +++ b/app/build.gradle.kts @@ -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) +} \ No newline at end of file diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro new file mode 100644 index 0000000..481bb43 --- /dev/null +++ b/app/proguard-rules.pro @@ -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 \ No newline at end of file diff --git a/app/src/androidTest/java/com/aldo/apps/ochecompanion/ExampleInstrumentedTest.java b/app/src/androidTest/java/com/aldo/apps/ochecompanion/ExampleInstrumentedTest.java new file mode 100644 index 0000000..f7a40d1 --- /dev/null +++ b/app/src/androidTest/java/com/aldo/apps/ochecompanion/ExampleInstrumentedTest.java @@ -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 Testing documentation + */ +@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()); + } +} \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..14ef756 --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,36 @@ + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/ic_launcher-playstore.png b/app/src/main/ic_launcher-playstore.png new file mode 100644 index 0000000000000000000000000000000000000000..fbb87a13e163402a3291c2a82b82c4bcfaa6b325 GIT binary patch literal 27182 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 literal 0 HcmV?d00001 diff --git a/app/src/main/java/com/aldo/apps/ochecompanion/AddPlayerActivity.java b/app/src/main/java/com/aldo/apps/ochecompanion/AddPlayerActivity.java new file mode 100644 index 0000000..9e7a318 --- /dev/null +++ b/app/src/main/java/com/aldo/apps/ochecompanion/AddPlayerActivity.java @@ -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. + *

+ * This activity provides a comprehensive user interface for managing player information including: + *

    + *
  • Creating new player profiles with username and profile picture
  • + *
  • Editing existing player profiles
  • + *
  • Advanced image cropping functionality with pan and pinch-to-zoom controls
  • + *
  • Saving profile pictures to internal storage
  • + *
+ *

+ *

+ * The activity features two distinct UI modes: + *

    + *
  • Form Mode: Standard profile editing with username input and profile picture selection
  • + *
  • Crop Mode: Interactive image cropping interface with gesture controls for precise framing
  • + *
+ *

+ *

+ * Image Processing Pipeline: + *

    + *
  1. User selects an image from their gallery
  2. + *
  3. Activity switches to Crop Mode with the selected image
  4. + *
  5. User can pan (drag) and pinch-to-zoom to frame the desired area
  6. + *
  7. A square crop overlay shows the final cutout area
  8. + *
  9. Upon confirmation, the cropped image is saved to internal storage
  10. + *
  11. Activity returns to Form Mode with the cropped image displayed
  12. + *
+ *

+ *

+ * Usage: + * To edit an existing player, pass the player ID via intent extra using {@link #EXTRA_PLAYER_ID}. + * If no extra is provided, the activity operates in "create new player" mode. + *

+ * + * @see AppCompatActivity + * @see Player + * @see CropOverlayView + * @see AppDatabase + * @author Oche Companion Development Team + * @version 1.0 + * @since 1.0 + */ +public class AddPlayerActivity extends AppCompatActivity { + + /** + * Tag for logging and debugging purposes. + * Used to identify log messages originating from this activity. + */ + private static final String TAG = "Oche_AddPlayer"; + + /** + * Intent extra key for passing an existing player's ID for editing. + *

+ * When this extra is present, the activity loads the player's existing data + * and operates in "edit mode". Without this extra, the activity creates a new player. + *

+ *

+ * Usage example: + *

+     * Intent intent = new Intent(context, AddPlayerActivity.class);
+     * intent.putExtra(AddPlayerActivity.EXTRA_PLAYER_ID, playerId);
+     * startActivity(intent);
+     * 
+ *

+ */ + public static final String EXTRA_PLAYER_ID = "extra_player_id"; + + // ========== UI - Main Form Views ========== + + /** + * Container layout for the main player profile form. + * Visible during Form Mode when the user is entering player details. + */ + private View mLayoutForm; + + /** + * Container layout for the image cropping interface. + * Visible during Crop Mode when the user is adjusting their selected image. + */ + private View mLayoutCropper; + + /** + * ImageView displaying the player's profile picture in the main form. + * Clicking this view triggers the image selection process. + */ + private ShapeableImageView mProfilePictureView; + + /** + * EditText field for entering or editing the player's username. + */ + private EditText mUserNameInput; + + /** + * TextView displaying the activity title ("Add Player" or "Update Profile"). + * The title changes based on whether creating a new player or editing an existing one. + */ + private TextView mTitleView; + + /** + * Button to save the player profile (insert new or update existing). + * The button label changes based on the current mode ("Save" or "Update"). + */ + private MaterialButton mSaveButton; + + // ========== UI - Cropper Views ========== + + /** + * ImageView displaying the full selected image during Crop Mode. + * Supports pan and pinch-to-zoom gestures for precise positioning. + */ + private ImageView mIvCropPreview; + + /** + * Custom overlay view that renders the square crop area boundary. + * Shows the user exactly what portion of the image will be extracted. + */ + private CropOverlayView mCropOverlay; + + // ========== Data State ========== + + /** + * Absolute file path to the saved profile picture in internal storage. + * Set after the user confirms their cropped image. This path is persisted + * in the database with the player record. + */ + private String mInternalImagePath; + + /** + * URI of the original, unmodified image selected from the gallery. + * Used as the source for cropping operations. + */ + private Uri mRawSelectedUri; + + /** + * Database ID of the player being edited. + * Defaults to -1, indicating "create new player" mode. When >= 0, + * the activity loads and updates an existing player. + */ + private int mExistingPlayerId = -1; + + /** + * Player object loaded from the database when editing an existing player. + * Null when creating a new player. Used to update existing records. + */ + private Player mExistingPlayer; + + // ========== Gesture State ========== + + /** + * Last recorded X coordinate during pan gesture (drag). + * Used to calculate the delta movement between touch events. + */ + private float mLastTouchX; + + /** + * Last recorded Y coordinate during pan gesture (drag). + * Used to calculate the delta movement between touch events. + */ + private float mLastTouchY; + + /** + * Detector for handling pinch-to-zoom gestures on the crop preview image. + * Monitors multi-touch events to calculate scale changes. + */ + private ScaleGestureDetector mScaleDetector; + + /** + * Current scale factor applied to the crop preview image. + *

+ * Starts at 1.0 (no zoom) and is modified by pinch gestures. + * Clamped between 0.1 (minimum zoom) and 10.0 (maximum zoom) to prevent + * the image from becoming unusably small or excessively large. + *

+ */ + private float mScaleFactor = 1.0f; + + /** + * ActivityResultLauncher for selecting images from the device gallery. + *

+ * Registered using the {@link ActivityResultContracts.GetContent} contract, + * which provides a standard way to pick content of a specific MIME type. + * Upon successful selection, automatically transitions to Crop Mode. + *

+ *

+ * The launcher is triggered when the user taps the profile picture placeholder. + *

+ * + * @see ActivityResultContracts.GetContent + */ + private final ActivityResultLauncher 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. + *

+ * Performs the following initialization tasks: + *

    + *
  • Sets the content view to the add player layout
  • + *
  • Initializes all UI component references
  • + *
  • Sets up gesture detectors for image manipulation
  • + *
  • Checks for existing player ID in intent extras and loads player data if present
  • + *
+ *

+ *

+ * If {@link #EXTRA_PLAYER_ID} is present in the intent, the activity operates in + * "edit mode" and loads the existing player's data. Otherwise, it operates in + * "create new player" mode. + *

+ * + * @param savedInstanceState If the activity is being re-initialized after previously being shut down, + * this Bundle contains the data it most recently supplied in + * {@link #onSaveInstanceState(Bundle)}. Otherwise, it is null. + * @see #initViews() + * @see #setupGestures() + * @see #loadExistingPlayer() + */ + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setContentView(R.layout.activity_add_player); + Log.d(TAG, "AddPlayerActivity Created"); + + // Initialize all UI components and their click listeners + initViews(); + + // Set up touch gesture handlers for image cropping + setupGestures(); + + // Check if editing an existing player + if (getIntent().hasExtra(EXTRA_PLAYER_ID)) { + mExistingPlayerId = getIntent().getIntExtra(EXTRA_PLAYER_ID, -1); + loadExistingPlayer(); + } + } + + /** + * Initializes all UI component references and sets up click listeners. + *

+ * This method performs the following operations: + *

    + *
  • Retrieves references to all UI elements using findViewById
  • + *
  • Configures click listener for profile picture to launch image picker
  • + *
  • Configures click listener for save button to persist player data
  • + *
  • Configures click listener for crop confirmation button
  • + *
  • Configures click listener for crop cancellation button
  • + *
+ *

+ *

+ * The profile picture view is configured to launch the gallery picker when clicked, + * filtering for image MIME types only ("image/*"). + *

+ * + * @see #mGetContent + * @see #savePlayer() + * @see #performCrop() + * @see #exitCropMode() + */ + private void initViews() { + // Get references to layout containers + mLayoutForm = findViewById(R.id.layoutForm); + mLayoutCropper = findViewById(R.id.layoutCropper); + + // Get references to form UI elements + mProfilePictureView = findViewById(R.id.ivAddPlayerProfile); + mUserNameInput = findViewById(R.id.etUsername); + mTitleView = findViewById(R.id.tvTitle); + mSaveButton = findViewById(R.id.btnSavePlayer); + + // Get references to cropper UI elements + mIvCropPreview = findViewById(R.id.ivCropPreview); + mCropOverlay = findViewById(R.id.cropOverlay); + + // Set up click listeners + mProfilePictureView.setOnClickListener(v -> mGetContent.launch("image/*")); + mSaveButton.setOnClickListener(v -> savePlayer()); + findViewById(R.id.btnConfirmCrop).setOnClickListener(v -> performCrop()); + findViewById(R.id.btnCancelCrop).setOnClickListener(v -> exitCropMode()); + } + + /** + * Initializes gesture detectors to handle pinch-to-zoom and pan (drag) gestures. + *

+ * This method configures two types of touch interactions for the crop preview image: + *

    + *
  1. Pinch-to-Zoom: Two-finger pinch gestures to scale the image
  2. + *
  3. Pan (Drag): Single-finger drag to reposition the image
  4. + *
+ *

+ *

+ * Scale Gesture Handling: + * The scale detector monitors multi-touch events and calculates scale changes. + * The scale factor is clamped between 0.1× (minimum) and 10.0× (maximum) to prevent + * the image from becoming unusably small or excessively large. + *

+ *

+ * Pan Gesture Handling: + * Pan gestures are only processed when a scale gesture is not in progress, preventing + * conflicts between the two gesture types. The translation is calculated based on the + * delta between consecutive touch positions. + *

+ * + * @see ScaleGestureDetector + * @see MotionEvent + */ + private void setupGestures() { + // Initialize scale detector for pinch-to-zoom functionality + mScaleDetector = new ScaleGestureDetector(this, new ScaleGestureDetector.SimpleOnScaleGestureListener() { + @Override + public boolean onScale(ScaleGestureDetector detector) { + // Apply the scale factor from the gesture + mScaleFactor *= detector.getScaleFactor(); + + // Prevent the image from becoming too small or impossibly large + // Clamp between 0.1× (10% size) and 10.0× (1000% size) + mScaleFactor = Math.max(0.1f, Math.min(mScaleFactor, 10.0f)); + + // Apply the scale to both X and Y axes for uniform scaling + mIvCropPreview.setScaleX(mScaleFactor); + mIvCropPreview.setScaleY(mScaleFactor); + return true; + } + }); + + // Combined touch listener for both Panning and Scaling + mIvCropPreview.setOnTouchListener((v, event) -> { + // Pass touch event to scale detector first to handle pinch gestures + mScaleDetector.onTouchEvent(event); + + // Handle Panning (drag) if not currently performing a pinch-to-zoom + if (!mScaleDetector.isInProgress()) { + switch (event.getAction()) { + case MotionEvent.ACTION_DOWN: + // Record initial touch position + mLastTouchX = event.getRawX(); + mLastTouchY = event.getRawY(); + break; + case MotionEvent.ACTION_MOVE: + // Calculate movement delta + float dx = event.getRawX() - mLastTouchX; + float dy = event.getRawY() - mLastTouchY; + + // Apply translation to the view + v.setTranslationX(v.getTranslationX() + dx); + v.setTranslationY(v.getTranslationY() + dy); + + // Update last touch position for next delta calculation + mLastTouchX = event.getRawX(); + mLastTouchY = event.getRawY(); + break; + } + } + return true; + }); + } + + /** + * Transitions the UI from Form Mode to Crop Mode. + *

+ * This method performs the following operations: + *

    + *
  • Hides the main form layout
  • + *
  • Shows the cropper layout
  • + *
  • Resets all image transformations (scale, translation) to default values
  • + *
  • Loads the selected image into the crop preview ImageView
  • + *
+ *

+ *

+ * Resetting transformations ensures that each crop session starts with the image + * in a predictable state (1:1 scale, centered position), providing a consistent + * user experience. + *

+ * + * @param uri The URI of the raw, unmodified image selected from the gallery. + * This image will be displayed in the crop preview for manipulation. + * @see #exitCropMode() + */ + private void enterCropMode(Uri uri) { + // Hide form layout and show cropper layout + mLayoutForm.setVisibility(View.GONE); + mLayoutCropper.setVisibility(View.VISIBLE); + + // Reset transformation state for a fresh start + mScaleFactor = 1.0f; // Reset zoom to 100% + mIvCropPreview.setScaleX(1.0f); + mIvCropPreview.setScaleY(1.0f); + mIvCropPreview.setTranslationX(0); // Reset horizontal position + mIvCropPreview.setTranslationY(0); // Reset vertical position + + // Load the selected image into the preview + mIvCropPreview.setImageURI(uri); + } + + /** + * Transitions the UI from Crop Mode back to Form Mode. + *

+ * This method is called when the user either: + *

    + *
  • Confirms their crop selection (after {@link #performCrop()} completes)
  • + *
  • Cancels the crop operation without saving
  • + *
+ *

+ *

+ * The method simply toggles visibility between the two layout containers, + * hiding the cropper and showing the main form. + *

+ * + * @see #enterCropMode(Uri) + * @see #performCrop() + */ + private void exitCropMode() { + // Hide cropper layout and show form layout + mLayoutCropper.setVisibility(View.GONE); + mLayoutForm.setVisibility(View.VISIBLE); + } + + /** + * Performs the pixel-level mathematics to extract a square crop from the selected image. + *

+ * This is the core image processing method that handles the complex coordinate transformations + * required to crop the image accurately. The calculation accounts for multiple transformation layers: + *

    + *
  1. Original Image Dimensions: The actual pixel dimensions of the source bitmap
  2. + *
  3. ImageView Fit-Center Scale: The automatic scaling applied by Android to fit the image in the view
  4. + *
  5. User Translation (Panning): The X/Y offset from user drag gestures
  6. + *
  7. User Scale (Zoom): The scale factor from user pinch-to-zoom gestures
  8. + *
+ *

+ *

+ * Algorithm Overview: + *

    + *
  1. Decode the full bitmap from the URI
  2. + *
  3. Calculate the fit-center scale applied by the ImageView
  4. + *
  5. Combine fit-center scale with user's manual zoom scale
  6. + *
  7. Determine the current position of the bitmap in screen space
  8. + *
  9. Get the crop box coordinates from the overlay
  10. + *
  11. Transform screen coordinates to bitmap pixel coordinates
  12. + *
  13. Apply bounds checking to ensure valid crop dimensions
  14. + *
  15. Extract the cropped region and save to internal storage
  16. + *
  17. Update the profile picture preview with the cropped image
  18. + *
+ *

+ *

+ * Error Handling: + * If any step fails (bitmap decoding, file I/O, etc.), an error is logged and a + * toast message is displayed to the user. The method gracefully handles errors + * without crashing the application. + *

+ * + * @see #saveBitmap(Bitmap) + * @see CropOverlayView#getCropRect() + */ + private void performCrop() { + Log.d(TAG, "Finalizing crop..."); + try (InputStream is = getContentResolver().openInputStream(mRawSelectedUri)) { + // Decode the full bitmap from the selected image URI + Bitmap fullBmp = BitmapFactory.decodeStream(is); + if (fullBmp == null) { + Log.e(TAG, "Failed to decode bitmap from URI"); + return; + } + + // Get the dimensions of the ImageView and the bitmap + float viewW = mIvCropPreview.getWidth(); + float viewH = mIvCropPreview.getHeight(); + float bmpW = fullBmp.getWidth(); + float bmpH = fullBmp.getHeight(); + + // Total scale combines the initial fit-center and the manual pinch-zoom + // Fit-center scale: minimum scale needed to fit the entire image in the view + float fitScale = Math.min(viewW / bmpW, viewH / bmpH); + // Total scale: fit-center scale × user's zoom factor + float totalScale = fitScale * mScaleFactor; + + // Current position of the top-left corner of the bitmap in screen space + // Accounts for both the centering offset and user's pan translation + float currentBmpLeft = (viewW - (bmpW * totalScale)) / 2f + mIvCropPreview.getTranslationX(); + float currentBmpTop = (viewH - (bmpH * totalScale)) / 2f + mIvCropPreview.getTranslationY(); + + // Get the crop box rectangle from the overlay (in screen coordinates) + RectF cropBox = mCropOverlay.getCropRect(); + + // Map screen coordinates to actual bitmap pixel coordinates + // Formula: (screenCoord - bitmapScreenPosition) / totalScale = bitmapPixelCoord + int cX = (int) ((cropBox.left - currentBmpLeft) / totalScale); + int cY = (int) ((cropBox.top - currentBmpTop) / totalScale); + int cSize = (int) (cropBox.width() / totalScale); + + Log.d(TAG, String.format("Crop Pixels: X=%d, Y=%d, Size=%d | UserZoom=%.2f", cX, cY, cSize, mScaleFactor)); + + // Bounds checks to prevent Bitmap.createBitmap from crashing with invalid coordinates + cX = Math.max(0, cX); // Ensure X is not negative + cY = Math.max(0, cY); // Ensure Y is not negative + + // Clamp crop size to not exceed bitmap boundaries + if (cX + cSize > bmpW) cSize = (int) bmpW - cX; + if (cY + cSize > bmpH) cSize = (int) bmpH - cY; + + // Ensure size is at least 1px to avoid crashes + cSize = Math.max(1, cSize); + + // Extract the square crop from the full bitmap + Bitmap cropped = Bitmap.createBitmap(fullBmp, cX, cY, cSize, cSize); + + // Save the cropped bitmap to internal storage + mInternalImagePath = saveBitmap(cropped); + + // Update the profile picture preview if save was successful + if (mInternalImagePath != null) { + mProfilePictureView.setImageTintList(null); // Remove any tint + mProfilePictureView.setImageBitmap(cropped); + } + + // Return to Form Mode + exitCropMode(); + + // Clean up the full bitmap to free memory + fullBmp.recycle(); + + } catch (Exception e) { + Log.e(TAG, "Crop execution failed", e); + Toast.makeText(this, "Failed to crop image", Toast.LENGTH_SHORT).show(); + } + } + + /** + * Saves a bitmap to the application's private internal storage directory. + *

+ * This method generates a unique filename using a UUID and saves the bitmap + * as a JPEG file with 90% quality. The file is stored in the app's private + * files directory, which is only accessible to this application. + *

+ *

+ * File Storage Details: + *

    + *
  • Location: Application's private files directory ({@code getFilesDir()})
  • + *
  • Format: JPEG with 90% compression quality
  • + *
  • Naming: "profile_" + UUID + ".jpg"
  • + *
  • Security: Files are private to this app and not accessible by other apps
  • + *
+ *

+ *

+ * Error Handling: + * If any I/O error occurs during the save operation, the exception is logged + * and null is returned, allowing the caller to handle the failure gracefully. + *

+ * + * @param bmp The bitmap image to save. Must not be null. + * @return The absolute file path to the saved image file, or null if saving failed. + * @see UUID#randomUUID() + * @see Bitmap#compress(Bitmap.CompressFormat, int, java.io.OutputStream) + */ + private String saveBitmap(Bitmap bmp) { + try { + // Generate a unique filename using UUID to prevent collisions + String name = "profile_" + UUID.randomUUID().toString() + ".jpg"; + + // Create file reference in app's private directory + File file = new File(getFilesDir(), name); + + // Write bitmap to file as JPEG with 90% quality (good balance of quality/size) + try (FileOutputStream fos = new FileOutputStream(file)) { + bmp.compress(Bitmap.CompressFormat.JPEG, 90, fos); + } + + // Return the absolute path for database storage + return file.getAbsolutePath(); + } catch (Exception e) { + Log.e(TAG, "IO Error saving bitmap", e); + return null; + } + } + + /** + * Loads an existing player's data from the database and populates the UI. + *

+ * This method is called during {@link #onCreate(Bundle)} when {@link #EXTRA_PLAYER_ID} + * is present in the intent, indicating that the activity should edit an existing player + * rather than create a new one. + *

+ *

+ * Operations performed: + *

    + *
  1. Queries the database for the player by ID (on background thread)
  2. + *
  3. Updates the username field with the player's current username
  4. + *
  5. Changes the title to "Update Profile" instead of "Add Player"
  6. + *
  7. Changes the save button text to "Update" instead of "Save"
  8. + *
  9. Loads and displays the player's profile picture if one exists
  10. + *
+ *

+ *

+ * Threading: + * Database operations are performed on a background thread to avoid blocking the UI. + * UI updates are posted back to the main thread using {@link #runOnUiThread(Runnable)}. + *

+ * + * @see AppDatabase#playerDao() + * @see Player + */ + private void loadExistingPlayer() { + new Thread(() -> { + // Query the database for the player (background thread) + mExistingPlayer = AppDatabase.getDatabase(this).playerDao().getPlayerById(mExistingPlayerId); + + // Update UI on the main thread + runOnUiThread(() -> { + if (mExistingPlayer != null) { + // Populate username field + mUserNameInput.setText(mExistingPlayer.username); + + // Update UI labels for "edit mode" + mTitleView.setText(R.string.txt_update_profile_header); + mSaveButton.setText(R.string.txt_update_profile_username_save); + + // Load existing profile picture if available + if (mExistingPlayer.profilePictureUri != null) { + mInternalImagePath = mExistingPlayer.profilePictureUri; + mProfilePictureView.setImageTintList(null); // Remove placeholder tint + mProfilePictureView.setImageURI(Uri.fromFile(new File(mInternalImagePath))); + } + } + }); + }).start(); + } + + /** + * Validates and persists the player data to the database. + *

+ * This method determines whether to insert a new player or update an existing one + * based on whether {@link #mExistingPlayer} is null. + *

+ *

+ * Validation: + * The username field must not be empty (after trimming whitespace). If validation fails, + * a toast message is shown and the method returns without saving. + *

+ *

+ * Database Operations: + *

    + *
  • Update Mode: If {@link #mExistingPlayer} is not null, updates the + * existing player's username and profile picture URI
  • + *
  • Insert Mode: If {@link #mExistingPlayer} is null, creates a new + * Player object and inserts it into the database
  • + *
+ *

+ *

+ * Threading: + * Database operations are performed on a background thread to prevent blocking the UI. + * After the save operation completes, the activity finishes on the main thread. + *

+ * + * @see Player + * @see AppDatabase#playerDao() + */ + private void savePlayer() { + // Validate username input + String name = mUserNameInput.getText().toString().trim(); + if (name.isEmpty()) { + Toast.makeText(this, "Please enter a name", Toast.LENGTH_SHORT).show(); + return; + } + + // Perform database operation on background thread + new Thread(() -> { + if (mExistingPlayer != null) { + // Update existing player + mExistingPlayer.username = name; + mExistingPlayer.profilePictureUri = mInternalImagePath; + AppDatabase.getDatabase(this).playerDao().update(mExistingPlayer); + } else { + // Create and insert new player + Player p = new Player(name, mInternalImagePath); + AppDatabase.getDatabase(this).playerDao().insert(p); + } + + // Close activity on main thread after save completes + runOnUiThread(() -> finish()); + }).start(); + } +} \ No newline at end of file diff --git a/app/src/main/java/com/aldo/apps/ochecompanion/GameActivity.java b/app/src/main/java/com/aldo/apps/ochecompanion/GameActivity.java new file mode 100644 index 0000000..0b3da4a --- /dev/null +++ b/app/src/main/java/com/aldo/apps/ochecompanion/GameActivity.java @@ -0,0 +1,1705 @@ +package com.aldo.apps.ochecompanion; + +import android.content.Context; +import android.content.Intent; +import android.content.res.ColorStateList; +import android.graphics.Color; +import android.os.Bundle; +import android.view.View; +import android.view.animation.AlphaAnimation; +import android.view.animation.Animation; +import android.widget.GridLayout; +import android.widget.LinearLayout; +import android.widget.TextView; +import android.widget.Toast; +import androidx.appcompat.app.AppCompatActivity; +import androidx.core.content.ContextCompat; +import com.aldo.apps.ochecompanion.database.objects.Player; +import com.google.android.material.button.MaterialButton; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * The main game activity for playing X01 darts games (501, 301, etc.) in the Oche Companion app. + *

+ * This activity provides a high-performance, professional-grade scoring interface optimized for + * rapid dart entry during live gameplay. It features a specialized numeric keyboard with dynamic + * visual feedback, real-time checkout route suggestions, and strict enforcement of standard darts + * rules including Double Out finish requirements and bust conditions. + *

+ *

+ * Game Features: + *

    + *
  • Dynamic Keyboard: Bottom-anchored number pad (1-20 + Bull) with visual + * highlighting based on selected multiplier (Single, Double, Triple)
  • + *
  • Smart Checkout Engine: Real-time route suggestions for finishes up to 170
  • + *
  • Double Out Enforcement: Game can only be won by hitting a double or bullseye
  • + *
  • Bust Detection: Automatically detects and handles bust conditions (score < 0, + * score = 1, or finishing on non-double)
  • + *
  • Live Statistics: Displays current player's leg average in real-time
  • + *
  • Turn Tracking: Visual indicators show all three darts of the current turn
  • + *
+ *

+ *

+ * X01 Game Rules Enforced: + *

    + *
  • Players start at a predefined score (501, 301, etc.)
  • + *
  • Scores are subtracted with each dart throw
  • + *
  • Game must finish exactly on zero (no negative scores)
  • + *
  • Final dart must be a double (Double Out rule)
  • + *
  • Leaving a score of 1 is a bust (impossible to finish)
  • + *
  • Scoring below 0 is a bust (turn ends, score reverts)
  • + *
+ *

+ *

+ * UI Layout Structure: + *

+ * ┌─────────────────────────────────────┐
+ * │  Player Name          AVG: 85.3     │  ← Header
+ * │                                     │
+ * │         REMAINING: 301              │  ← Score Display
+ * │                                     │
+ * │  [Checkout: T20 • D20]             │  ← Smart Route (conditional)
+ * │                                     │
+ * │  [Dart1] [Dart2] [Dart3]           │  ← Turn Indicators
+ * │                                     │
+ * │  [ 1×] [ 2×] [ 3×]                 │  ← Multiplier Buttons
+ * │                                     │
+ * │  ┌───────────────────────────┐     │
+ * │  │ 1   2   3   4   5        │     │  ← Numeric Keyboard
+ * │  │ 6   7   8   9   10       │     │
+ * │  │ 11  12  13  14  15       │     │
+ * │  │ 16  17  18  19  20       │     │
+ * │  └───────────────────────────┘     │
+ * │  [Undo] [Submit Turn]              │  ← Action Buttons
+ * └─────────────────────────────────────┘
+ * 
+ *

+ *

+ * Game Flow: + *

    + *
  1. Activity is started with player list and starting score (e.g., 501)
  2. + *
  3. First player begins their turn
  4. + *
  5. Player selects multiplier (1×, 2×, or 3×)
  6. + *
  7. Player taps number on keyboard to register dart
  8. + *
  9. Score updates immediately, checkout routes appear when applicable
  10. + *
  11. After 3 darts or winning/busting, turn ends (auto or via Submit)
  12. + *
  13. Next player's turn begins automatically
  14. + *
  15. Game continues until a player finishes exactly on 0 with a double
  16. + *
+ *

+ *

+ * Usage Example: + *

+ * // Start a 501 game with two players
+ * ArrayList<Player> players = new ArrayList<>();
+ * players.add(player1);
+ * players.add(player2);
+ * GameActivity.start(context, players, 501);
+ * 
+ * // Or start a 301 game
+ * GameActivity.start(context, players, 301);
+ * 
+ *

+ *

+ * Bust Conditions: + * A turn results in a bust (score reverts, turn ends) when: + *

    + *
  • Negative Score: Dart causes score to go below 0
  • + *
  • Score of 1: Impossible to finish (no double equals 1)
  • + *
  • Zero on Non-Double: Finishing dart must be a double
  • + *
+ * Example: Player on 32 hits single 16, leaving 16. Then hits single 16 = BUST (not a double). + *

+ *

+ * Multiplier System: + *

    + *
  • Single (1×): Normal value (e.g., 20 = 20 points)
  • + *
  • Double (2×): Double value (e.g., 20 = 40 points), red highlight
  • + *
  • Triple (3×): Triple value (e.g., 20 = 60 points), blue highlight
  • + *
  • Bull (25): Single bull = 25, Double bull = 50
  • + *
+ * Note: Multiplier resets to 1× after each dart for safety. + *

+ *

+ * Checkout Engine: + * Provides optimal finishing routes when player is within checkout range (≤170): + *

    + *
  • Direct doubles for scores ≤40
  • + *
  • Setup dart suggestions to leave common doubles
  • + *
  • Pre-calculated routes for classic checkouts (170, 141, etc.)
  • + *
  • Intelligent logic to avoid leaving score of 1
  • + *
+ *

+ *

+ * Performance Optimizations: + *

    + *
  • Keyboard buttons cached in list for rapid style updates
  • + *
  • Score calculations done immediately (no database queries during play)
  • + *
  • UI updates triggered only on state changes
  • + *
  • Minimal layout inflation (keyboard built once)
  • + *
+ *

+ *

+ * Future Enhancements: + * Consider adding: + *

    + *
  • Match statistics (180s, checkout percentage, etc.)
  • + *
  • Undo turn (not just undo dart)
  • + *
  • Pause/resume functionality
  • + *
  • Sound effects for checkouts and busts
  • + *
  • Throw history visualization
  • + *
  • Practice mode with AI opponent
  • + *
+ *

+ * + * @see Player + * @see CheckoutEngine + * @see X01State + * @author Oche Companion Development Team + * @version 1.0 + * @since 1.0 + */ +public class GameActivity extends AppCompatActivity { + + /** + * Intent extra key for passing the list of participating players. + *

+ * Value type: {@code ArrayList} (Parcelable) + *

+ */ + private static final String EXTRA_PLAYERS = "extra_players"; + + /** + * Intent extra key for passing the starting score for the X01 game. + *

+ * Value type: {@code int} (typically 501, 301, or 701) + *

+ */ + private static final String EXTRA_START_SCORE = "extra_start_score"; + + // ======================================================================================== + // Game Logic State + // ======================================================================================== + + /** + * Index of the player whose turn is currently active. + *

+ * Cycles through player indices (0 to playerCount-1) as turns are completed. + * After each turn submission, this increments modulo player count to rotate turns. + *

+ *

+ * Example for 2 players: + *

    + *
  • Player 1 turn: mActivePlayerIndex = 0
  • + *
  • Player 2 turn: mActivePlayerIndex = 1
  • + *
  • Player 1 turn: mActivePlayerIndex = 0 (wraps around)
  • + *
+ *

+ */ + private int mActivePlayerIndex = 0; + + /** + * Current multiplier selected for the next dart throw. + *

+ * Valid values: + *

    + *
  • 1: Single (normal value)
  • + *
  • 2: Double (2× value, red visual)
  • + *
  • 3: Triple (3× value, blue visual)
  • + *
+ *

+ *

+ * Automatically resets to 1 after each dart is thrown for safety, preventing + * accidental double/triple throws. Players must explicitly select multiplier + * before each dart. + *

+ */ + private int mMultiplier = 1; + + /** + * The starting score for this X01 game (typically 501, 301, or 701). + *

+ * This value is set once at game initialization from the intent extra and + * determines each player's initial {@link X01State#remainingScore}. Used for + * calculating three-dart averages throughout the game. + *

+ */ + private int mStartingScore = 501; + + /** + * List of player game states, one for each participant. + *

+ * Each {@link X01State} tracks an individual player's current score, darts thrown, + * and name. The list order determines turn order, and {@link #mActivePlayerIndex} + * references the current player's state. + *

+ *

+ * Initialized in {@link #setupGame(List)} with all players starting at + * {@link #mStartingScore}. + *

+ */ + private List mPlayerStates; + + /** + * Stores the point values of darts thrown in the current turn (up to 3 darts). + *

+ * Each dart's final point value (base × multiplier) is added to this list as + * it's thrown. The list is cleared when the turn is submitted or when switching + * to the next player. + *

+ *

+ * Example: + *

    + *
  • Player hits T20: [60]
  • + *
  • Player hits T20: [60, 60]
  • + *
  • Player hits D10: [60, 60, 20]
  • + *
  • Turn submitted: [] (cleared)
  • + *
+ *

+ *

+ * Used for: + *

    + *
  • Displaying dart indicators in UI
  • + *
  • Calculating turn total
  • + *
  • Determining when 3 darts are complete
  • + *
  • Undo functionality
  • + *
+ *

+ */ + private List mCurrentTurnDarts = new ArrayList<>(); + + /** + * Flag indicating whether the current turn has ended due to bust or win condition. + *

+ * When {@code true}: + *

    + *
  • No more darts can be thrown in this turn
  • + *
  • Player must submit turn (or it auto-submits)
  • + *
  • Prevents accidental additional dart entry
  • + *
+ *

+ *

+ * Set to {@code true} when: + *

    + *
  • Player busts (score < 0, score = 1, or finishes on non-double)
  • + *
  • Player wins (score = 0 on a double)
  • + *
  • Three darts have been thrown
  • + *
+ *

+ *

+ * Reset to {@code false} when turn is submitted and next player's turn begins. + *

+ */ + private boolean mIsTurnOver = false; + + /** + * Cached references to keyboard buttons as MaterialButtons for safe dynamic styling. + *

+ * All 20 keyboard buttons (1-20) are stored in this list for efficient access + * during multiplier changes. This allows rapid visual updates (color, stroke, background) + * without repeated findViewById calls. + *

+ *

+ * Populated once during {@link #setupKeyboard()} and reused throughout the activity + * lifecycle. Each button's onClick listener is set to call {@link #onNumberTap(int)} + * with the corresponding number value. + *

+ */ + private final List mKeyboardButtons = new ArrayList<>(); + + // ======================================================================================== + // UI References + // ======================================================================================== + + /** + * TextView displaying the active player's current remaining score. + *

+ * Shows the number the player needs to reach zero (e.g., "301", "147", "32"). + * Updates after each dart and when switching players. Large, prominently displayed + * as it's the most critical piece of information during play. + *

+ */ + private TextView tvScorePrimary; + + /** + * TextView displaying the active player's name. + *

+ * Shows in uppercase (e.g., "JOHN DOE") to clearly identify whose turn it is. + * Updates when switching to the next player's turn. + *

+ */ + private TextView tvPlayerName; + + /** + * TextView displaying the active player's current leg average. + *

+ * Shows three-dart average for the current leg (e.g., "AVG: 85.3"). + * Calculated as: ((startingScore - remainingScore) / dartsThrown) × 3 + * Updates after each dart to provide real-time performance feedback. + *

+ */ + private TextView tvLegAvg; + + /** + * TextView displaying the suggested checkout route. + *

+ * Shows optimal finishing path when player is within checkout range (≤170). + * Examples: "D16", "T20 • D20", "T20 • T20 • BULL" + * Text is set by {@link CheckoutEngine#getRoute(int, int)}. + *

+ */ + private TextView tvCheckout; + + /** + * Container layout for the checkout suggestion display. + *

+ * Visibility controlled based on whether a checkout route is available: + *

    + *
  • VISIBLE when score ≤ 170 and valid route exists
  • + *
  • GONE when score > 170 or no route available
  • + *
+ * Animated with pulsing alpha effect when visible to draw attention. + *

+ */ + private LinearLayout layoutCheckoutSuggestion; + + /** + * Button view for selecting single (1×) multiplier. + *

+ * Visual state changes when selected (full opacity, active background). + * Default multiplier - automatically selected after each dart throw. + *

+ */ + private View btnSingle; + + /** + * Button view for selecting double (2×) multiplier. + *

+ * Visual state changes when selected (full opacity, red background). + * Used for doubles ring on dartboard and finishing darts (Double Out). + *

+ */ + private View btnDouble; + + /** + * Button view for selecting triple (3×) multiplier. + *

+ * Visual state changes when selected (full opacity, blue background). + * Used for triples ring on dartboard - highest scoring option. + *

+ */ + private View btnTriple; + + /** + * Array of three TextViews showing the darts thrown in the current turn. + *

+ * Each TextView displays a dart's score (e.g., "60", "DB", "25") and changes + * appearance based on whether it's been thrown: + *

    + *
  • Thrown: Active background, green text, score displayed
  • + *
  • Not thrown: Empty background, empty text
  • + *
+ * Provides visual feedback of turn progress ("I've thrown 2 of 3 darts"). + *

+ */ + private TextView[] tvDartPills = new TextView[3]; + + /** + * GridLayout container holding all numeric keyboard buttons. + *

+ * Holds 20 MaterialButton instances (1-20) arranged in a grid pattern for + * easy dart entry. Buttons are dynamically created in {@link #setupKeyboard()} + * and added to this layout. Separate from Bull button which is in layout XML. + *

+ */ + private GridLayout glKeyboard; + + /** + * Static helper method to start GameActivity with the required game parameters. + *

+ * This convenience method creates and configures the intent with all necessary extras, + * then starts the activity. Preferred over manually creating intents to ensure correct + * parameter passing. + *

+ *

+ * Usage Example: + *

+     * // Start a 501 game with two players
+     * ArrayList<Player> players = new ArrayList<>();
+     * players.add(player1);
+     * players.add(player2);
+     * GameActivity.start(this, players, 501);
+     * 
+     * // Start a 301 game
+     * GameActivity.start(this, selectedPlayers, 301);
+     * 
+     * // Start a 701 game
+     * GameActivity.start(this, tournamentPlayers, 701);
+     * 
+ *

+ *

+ * Player Requirements: + * The players list should: + *

    + *
  • Contain at least 1 player (if empty, defaults to "GUEST 1")
  • + *
  • Have all players properly initialized with usernames
  • + *
  • Be an ArrayList to support Parcelable serialization
  • + *
+ *

+ *

+ * Common Start Scores: + *

    + *
  • 501: Standard professional game length
  • + *
  • 301: Shorter casual game
  • + *
  • 701: Longer championship format
  • + *
+ *

+ * + * @param context The context from which to start the activity (Activity, Fragment context, etc.). + * Must not be null. + * @param players The list of players participating in the game. Can be null or empty (defaults + * to single guest player). Players should have valid usernames set. + * @param startScore The starting score for the X01 game. Typically 501, 301, or 701. + * Must be positive. + * @see #EXTRA_PLAYERS + * @see #EXTRA_START_SCORE + */ + public static void start(Context context, ArrayList players, int startScore) { + Intent intent = new Intent(context, GameActivity.class); + intent.putParcelableArrayListExtra(EXTRA_PLAYERS, players); + intent.putExtra(EXTRA_START_SCORE, startScore); + context.startActivity(intent); + } + + /** + * Called when the activity is first created. + *

+ * Initializes the game by: + *

    + *
  1. Setting the content view from layout resource
  2. + *
  3. Extracting start score and player list from intent extras
  4. + *
  5. Initializing UI component references
  6. + *
  7. Setting up the numeric keyboard with buttons
  8. + *
  9. Creating player game states and displaying initial UI
  10. + *
+ *

+ *

+ * If no starting score is provided in the intent, defaults to 501. + * If no players are provided, creates a single guest player. + *

+ * + * @param savedInstanceState Bundle containing saved state (not currently used for restoration) + */ + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setContentView(R.layout.activity_game); + + // Extract game parameters from intent + mStartingScore = getIntent().getIntExtra(EXTRA_START_SCORE, 501); + ArrayList participants = getIntent().getParcelableArrayListExtra(EXTRA_PLAYERS); + + // Initialize activity components in order + initViews(); + setupKeyboard(); + setupGame(participants); + } + + /** + * Initializes all UI component references and sets up click listeners. + *

+ * This method: + *

    + *
  • Finds and caches references to all UI elements via findViewById
  • + *
  • Populates the dart pill TextView array
  • + *
  • Sets up click listeners for multiplier buttons (1×, 2×, 3×)
  • + *
  • Sets up click listeners for action buttons (Submit, Undo)
  • + *
+ *

+ *

+ * Called once during {@link #onCreate(Bundle)} before any game logic runs. + * All UI components must exist in the layout or this will throw NullPointerException. + *

+ */ + private void initViews() { + tvScorePrimary = findViewById(R.id.tvScorePrimary); + tvPlayerName = findViewById(R.id.tvPlayerName); + tvLegAvg = findViewById(R.id.tvLegAvg); + tvCheckout = findViewById(R.id.tvCheckoutSuggestion); + layoutCheckoutSuggestion = findViewById(R.id.layoutCheckoutSuggestion); + + btnSingle = findViewById(R.id.btnMultiplierSingle); + btnDouble = findViewById(R.id.btnMultiplierDouble); + btnTriple = findViewById(R.id.btnMultiplierTriple); + + tvDartPills[0] = findViewById(R.id.tvDart1); + tvDartPills[1] = findViewById(R.id.tvDart2); + tvDartPills[2] = findViewById(R.id.tvDart3); + + glKeyboard = findViewById(R.id.glKeyboard); + + btnSingle.setOnClickListener(v -> setMultiplier(1)); + btnDouble.setOnClickListener(v -> setMultiplier(2)); + btnTriple.setOnClickListener(v -> setMultiplier(3)); + + findViewById(R.id.btnSubmitTurn).setOnClickListener(v -> submitTurn()); + findViewById(R.id.btnUndoDart).setOnClickListener(v -> undoLastDart()); + } + + /** + * Dynamically creates and configures the numeric keyboard buttons (1-20). + *

+ * This method: + *

    + *
  • Clears any existing buttons from the keyboard grid
  • + *
  • Creates 20 MaterialButton instances via layout inflation
  • + *
  • Sets each button's text to its corresponding number
  • + *
  • Assigns onClick listeners to call {@link #onNumberTap(int)}
  • + *
  • Adds buttons to the keyboard GridLayout
  • + *
  • Caches button references in {@link #mKeyboardButtons} for styling
  • + *
+ *

+ *

+ * Buttons are created dynamically rather than in XML to allow flexible styling + * and consistent appearance across all keyboard numbers. Uses view_keyboard_button + * layout resource as a template for each button. + *

+ *

+ * Called once during {@link #onCreate(Bundle)} to build the keyboard interface. + * The cached button list is used later by {@link #setMultiplier(int)} to update + * button colors based on selected multiplier. + *

+ */ + private void setupKeyboard() { + glKeyboard.removeAllViews(); + mKeyboardButtons.clear(); + + // Create buttons for numbers 1-20 + for (int i = 1; i <= 20; i++) { + // Inflate button from template layout + MaterialButton btn = (MaterialButton) getLayoutInflater().inflate( + R.layout.view_keyboard_button, glKeyboard, false); + + final int val = i; + btn.setText(String.valueOf(val)); + btn.setOnClickListener(v -> onNumberTap(val)); + + glKeyboard.addView(btn); + mKeyboardButtons.add(btn); // Cache for styling updates + } + } + + /** + * Initializes game state with player data and displays initial UI. + *

+ * This method: + *

    + *
  • Creates {@link X01State} objects for each player with starting score
  • + *
  • Handles null or empty player lists by creating a default guest player
  • + *
  • Initializes the first player's turn
  • + *
  • Updates UI to show first player's information
  • + *
  • Sets multiplier to default (Single/1×)
  • + *
+ *

+ *

+ * Player List Handling: + *

    + *
  • If players is null or empty: Creates single player "GUEST 1"
  • + *
  • If players provided: Creates state for each player in order
  • + *
+ * Turn order matches the order of players in the list. + *

+ *

+ * Called once during {@link #onCreate(Bundle)} after UI initialization. + *

+ * + * @param players List of Player objects participating in the game. Can be null or empty. + * Each player should have a valid username set. + * @see X01State + * @see #updateUI() + * @see #setMultiplier(int) + */ + private void setupGame(List players) { + mPlayerStates = new ArrayList<>(); + if (players != null && !players.isEmpty()) { + for (Player p : players) { + mPlayerStates.add(new X01State(p.username, mStartingScore)); + } + } else { + mPlayerStates.add(new X01State("GUEST 1", mStartingScore)); + } + updateUI(); + setMultiplier(1); + } + + /** + * Processes a single dart throw when a keyboard number button is tapped. + *

+ * This is the core game logic method that handles: + *

    + *
  • Calculating final dart value (base × multiplier)
  • + *
  • Detecting and enforcing bust conditions
  • + *
  • Detecting and handling winning dart (Double Out)
  • + *
  • Updating turn state and UI for valid throws
  • + *
  • Preventing input when turn is complete
  • + *
+ *

+ *

+ * Scoring Calculation: + *

+     * points = baseValue × multiplier
+     * Special case: Bull (25) with Triple (3×) = 50 (Double Bull)
+     * 
+ *

+ *

+ * Bust Conditions (turn ends, score reverts): + *

    + *
  1. Negative Score: scoreAfterDart < 0
  2. + *
  3. Score of 1: scoreAfterDart == 1 (impossible to finish)
  4. + *
  5. Zero on Non-Double: scoreAfterDart == 0 but dart wasn't a double
  6. + *
+ * When bust occurs: + *
    + *
  • Dart is added to turn darts (for display)
  • + *
  • Turn is marked as over (mIsTurnOver = true)
  • + *
  • Score does NOT change (reverts in submitTurn)
  • + *
  • Toast notification shown
  • + *
+ *

+ *

+ * Win Condition (game ends): + * Player reaches exactly zero with a double or bullseye (50): + *

    + *
  • Dart is added to turn darts
  • + *
  • Turn is marked as over
  • + *
  • {@link #handleWin(X01State)} is called
  • + *
  • Activity finishes after displaying winner
  • + *
+ *

+ *

+ * Valid Throw (continues turn): + *

    + *
  • Dart is added to turn darts
  • + *
  • Turn indicators updated to show dart
  • + *
  • UI updated with new score and checkout suggestions
  • + *
  • If 3 darts thrown, turn is marked as over
  • + *
+ *

+ *

+ * Double Detection: + * A dart is considered a double if: + *

    + *
  • Multiplier is 2 (Double ring), OR
  • + *
  • Points = 50 (Double Bull)
  • + *
+ *

+ *

+ * Multiplier Reset: + * After processing the dart, multiplier automatically resets to 1 (Single) + * for safety, preventing accidental double/triple throws. + *

+ *

+ * Example Scenarios: + *

+     * // Scenario 1: Valid throw
+     * Player on 301, multiplier=3, taps 20
+     * → points = 60, scoreAfter = 241, valid
+     * → Dart added, UI updated, continue turn
+     * 
+     * // Scenario 2: Bust (negative)
+     * Player on 32, multiplier=3, taps 20
+     * → points = 60, scoreAfter = -28, BUST
+     * → Turn over, score stays 32
+     * 
+     * // Scenario 3: Bust (score of 1)
+     * Player on 33, multiplier=1, taps 32
+     * → points = 32, scoreAfter = 1, BUST
+     * → Turn over, score stays 33
+     * 
+     * // Scenario 4: Bust (zero on single)
+     * Player on 20, multiplier=1, taps 20
+     * → points = 20, scoreAfter = 0, isDouble=false, BUST
+     * → Turn over, score stays 20
+     * 
+     * // Scenario 5: Win!
+     * Player on 20, multiplier=2, taps 10
+     * → points = 20, scoreAfter = 0, isDouble=true, WIN!
+     * → Game ends, winner announced
+     * 
+ *

+ * + * @param baseValue The face value of the number hit (1-20, or 25 for Bull). + * Must be positive. Multiplier is applied to calculate final points. + * @see #mMultiplier + * @see #mCurrentTurnDarts + * @see #mIsTurnOver + * @see #handleWin(X01State) + * @see #updateTurnIndicators() + * @see #updateUI() + */ + public void onNumberTap(int baseValue) { + if (mCurrentTurnDarts.size() >= 3 || mIsTurnOver) return; + + int points = baseValue * mMultiplier; + if (baseValue == 25 && mMultiplier == 3) points = 50; // Triple Bull is Double Bull + + X01State active = mPlayerStates.get(mActivePlayerIndex); + int scoreBeforeDart = active.remainingScore; + for (int d : mCurrentTurnDarts) scoreBeforeDart -= d; + + int scoreAfterDart = scoreBeforeDart - points; + boolean isDouble = (mMultiplier == 2) || (points == 50); + + // --- DOUBLE OUT LOGIC CHECK --- + if (scoreAfterDart < 0 || scoreAfterDart == 1 || (scoreAfterDart == 0 && !isDouble)) { + // BUST CONDITION: Score < 0, Score == 1, or Score == 0 on a non-double + mCurrentTurnDarts.add(points); + updateTurnIndicators(); + mIsTurnOver = true; + Toast.makeText(this, "BUST!", Toast.LENGTH_SHORT).show(); + // In a pro interface, we usually wait for "Submit" or auto-submit after a short delay + } else if (scoreAfterDart == 0 && isDouble) { + // VICTORY CONDITION + mCurrentTurnDarts.add(points); + updateTurnIndicators(); + mIsTurnOver = true; + handleWin(active); + } else { + // VALID THROW + mCurrentTurnDarts.add(points); + updateTurnIndicators(); + updateUI(); + + if (mCurrentTurnDarts.size() == 3) { + mIsTurnOver = true; + } + } + + setMultiplier(1); + } + + /** + * Handler for Bull button tap in the UI. + *

+ * This is a convenience method that delegates to {@link #onNumberTap(int)} with + * the Bull's base value of 25. The actual scoring is calculated based on the + * current multiplier: + *

    + *
  • Single (1×): 25 points (outer bull)
  • + *
  • Double (2×): 50 points (double bull / bullseye)
  • + *
  • Triple (3×): 50 points (treated as double bull)
  • + *
+ *

+ *

+ * Note: Triple Bull is not a standard darts scoring area, so it's treated as + * Double Bull (50 points) in the scoring logic. + *

+ *

+ * Can be called from XML layout via android:onClick="onBullTap" attribute. + *

+ * + * @param v The View that was clicked (Bull button), not used in logic + * @see #onNumberTap(int) + */ + public void onBullTap(View v) { + onNumberTap(25); + } + + /** + * Sets the current multiplier and updates all UI elements to reflect the selection. + *

+ * This method handles both the logical state change and all visual feedback: + *

    + *
  • Updates {@link #mMultiplier} field
  • + *
  • Changes multiplier button appearances (opacity, backgrounds)
  • + *
  • Updates all keyboard button styles (colors, backgrounds, strokes)
  • + *
+ *

+ *

+ * Visual Feedback by Multiplier: + *

    + *
  • Single (1×): + *
      + *
    • Button: Full opacity, default background
    • + *
    • Keyboard: Default colors, transparent backgrounds, subtle borders
    • + *
    + *
  • + *
  • Double (2×): + *
      + *
    • Button: Full opacity, red background
    • + *
    • Keyboard: Red text, light red backgrounds, red borders
    • + *
    • Color theme: #FF3B30 (double ring red)
    • + *
    + *
  • + *
  • Triple (3×): + *
      + *
    • Button: Full opacity, blue background
    • + *
    • Keyboard: Blue text, light blue backgrounds, blue borders
    • + *
    • Color theme: #007AFF (triple ring blue)
    • + *
    + *
  • + *
+ * Inactive multiplier buttons are shown at 40% opacity. + *

+ *

+ * MaterialButton Styling: + * The method uses MaterialButton-specific APIs: + *

    + *
  • {@code setTextColor}: Changes button text color
  • + *
  • {@code setBackgroundColor}: Sets button fill color
  • + *
  • {@code setStrokeColor}: Sets button border color
  • + *
+ * These provide consistent Material Design appearance across all keyboard buttons. + *

+ *

+ * Performance: + * Efficiently updates all 20 keyboard buttons using cached {@link #mKeyboardButtons} + * list, avoiding repeated findViewById calls. Style updates are instant with no + * noticeable lag. + *

+ *

+ * Automatic Reset: + * This method is automatically called with multiplier 1 after each dart throw + * (in {@link #onNumberTap(int)}), ensuring players must explicitly select Double + * or Triple for each dart. + *

+ * + * @param m The multiplier value to set. Valid values: + *
    + *
  • 1 = Single
  • + *
  • 2 = Double
  • + *
  • 3 = Triple
  • + *
+ * Other values will cause unexpected visual behavior. + * @see #mMultiplier + * @see #mKeyboardButtons + */ + private void setMultiplier(int m) { + mMultiplier = m; + btnSingle.setAlpha(m == 1 ? 1.0f : 0.4f); + btnDouble.setAlpha(m == 2 ? 1.0f : 0.4f); + btnTriple.setAlpha(m == 3 ? 1.0f : 0.4f); + + btnSingle.setBackgroundResource(m == 1 ? R.drawable.shape_multiplier_active : 0); + btnDouble.setBackgroundResource(m == 2 ? R.drawable.shape_multiplier_red : 0); + btnTriple.setBackgroundResource(m == 3 ? R.drawable.shape_multiplier_blue : 0); + + int bgColor, textColor, strokeColor; + if (m == 3) { + bgColor = Color.parseColor("#1A007AFF"); + textColor = ContextCompat.getColor(this, R.color.triple_blue); + strokeColor = textColor; + } else if (m == 2) { + bgColor = Color.parseColor("#1AFF3B30"); + textColor = ContextCompat.getColor(this, R.color.double_red); + strokeColor = textColor; + } else { + bgColor = Color.TRANSPARENT; + textColor = ContextCompat.getColor(this, R.color.text_primary); + strokeColor = ContextCompat.getColor(this, R.color.border_subtle); + } + + for (MaterialButton btn : mKeyboardButtons) { + btn.setTextColor(textColor); + btn.setBackgroundColor(bgColor); + btn.setStrokeColor(ColorStateList.valueOf(strokeColor)); + } + } + + /** + * Finalizes the current turn and advances to the next player. + *

+ * This method is called when the user explicitly taps "Submit Turn" button or + * when a turn ends automatically (3 darts thrown, bust, or win). It handles: + *

    + *
  • Calculating total points scored in the turn
  • + *
  • Verifying bust conditions (redundant safety check)
  • + *
  • Updating player's score and darts thrown count
  • + *
  • Rotating to next player's turn
  • + *
  • Resetting turn state for new player
  • + *
  • Updating UI to show next player's information
  • + *
+ *

+ *

+ * Turn Processing: + *

    + *
  1. Calculate turn total by summing all dart values
  2. + *
  3. Calculate what final score would be after turn
  4. + *
  5. Perform bust check (redundant as {@link #onNumberTap(int)} already validates)
  6. + *
  7. If not bust: Update player's remaining score and darts thrown count
  8. + *
  9. If bust: Score remains unchanged (darts thrown not counted)
  10. + *
+ *

+ *

+ * Player Rotation: + *

+     * mActivePlayerIndex = (mActivePlayerIndex + 1) % playerCount
+     * 
+ * This cycles through players: 0 → 1 → 2 → 0 → 1 → ... + *

+ *

+ * State Reset: + * After submission: + *

    + *
  • {@link #mCurrentTurnDarts} is cleared
  • + *
  • {@link #mIsTurnOver} is reset to false
  • + *
  • UI is updated to show next player
  • + *
  • Turn indicators are cleared
  • + *
+ *

+ *

+ * Safety Check: + * Returns immediately if no darts have been thrown (empty turn), preventing + * submission of zero-dart turns. + *

+ *

+ * Note: The {@code isFinishDart} check is largely redundant as win conditions + * are already handled in {@link #onNumberTap(int)} and would have ended the game + * before reaching this method. + *

+ * + * @see #mActivePlayerIndex + * @see #mCurrentTurnDarts + * @see #mIsTurnOver + * @see #updateUI() + * @see #updateTurnIndicators() + */ + private void submitTurn() { + // Don't submit if no darts thrown + if (mCurrentTurnDarts.isEmpty()) return; + + // Calculate turn total + int turnTotal = 0; + for (int d : mCurrentTurnDarts) turnTotal += d; + + X01State active = mPlayerStates.get(mActivePlayerIndex); + + // Calculate what final score would be + int finalScore = active.remainingScore - turnTotal; + + // Re-check logic for non-double finish or score of 1 + int lastDartValue = mCurrentTurnDarts.get(mCurrentTurnDarts.size() - 1); + // Note: this check is redundant but safe for manual "Submit" actions + boolean isBust = (finalScore < 0 || finalScore == 1 || (finalScore == 0 && !isFinishDart(mCurrentTurnDarts.size() - 1))); + + // Update score only if not bust + if (!isBust) { + active.remainingScore = finalScore; + active.dartsThrown += mCurrentTurnDarts.size(); + } + // If bust, score remains unchanged + + // Rotate to next player + mActivePlayerIndex = (mActivePlayerIndex + 1) % mPlayerStates.size(); + + // Reset turn state + mCurrentTurnDarts.clear(); + mIsTurnOver = false; + + // Update UI for next player + updateUI(); + updateTurnIndicators(); + } + + /** + * Determines if a specific dart in the current turn sequence was a finishing dart. + *

+ * This is a placeholder method for future enhancement. Currently always returns + * {@code true} because win logic is handled immediately in {@link #onNumberTap(int)} + * when the dart is thrown. + *

+ *

+ * Current Limitation: + * The implementation doesn't track which multiplier was used for each dart in the + * turn, making it impossible to retrospectively determine if a dart was a double. + * The immediate win detection in {@code onNumberTap} makes this method largely + * unnecessary in current flow. + *

+ *

+ * Future Enhancement: + * To properly implement this method, would need to: + *

    + *
  • Store multiplier with each dart value in a data structure like + * {@code List>} (value, multiplier)
  • + *
  • Check if the dart at dartIndex had multiplier of 2 or was value 50
  • + *
  • Use this for post-turn validation or turn history display
  • + *
+ *

+ * + * @param dartIndex The index of the dart in {@link #mCurrentTurnDarts} to check (0-2) + * @return Currently always returns {@code true}. Should return {@code true} if the + * dart was a double or bullseye, {@code false} otherwise. + * @see #onNumberTap(int) + * @see #submitTurn() + */ + private boolean isFinishDart(int dartIndex) { + // In this UI implementation, we'd need to track multipliers per dart if we wanted + // to check history post-hoc. For now, onNumberTap handles immediate win logic. + return true; + } + + /** + * Removes the most recently thrown dart from the current turn. + *

+ * This method provides undo functionality, allowing players to correct mistakes + * during their turn. It: + *

    + *
  • Removes the last dart from {@link #mCurrentTurnDarts}
  • + *
  • Resets {@link #mIsTurnOver} flag (allows more darts to be thrown)
  • + *
  • Updates turn indicators to remove the dart pill display
  • + *
  • Updates UI to recalculate score and checkout suggestions
  • + *
+ *

+ *

+ * Use Cases: + *

    + *
  • Player accidentally tapped wrong number
  • + *
  • Player selected wrong multiplier before tapping
  • + *
  • Scorer recorded incorrect dart
  • + *
  • Player wants to review before submitting turn
  • + *
+ *

+ *

+ * Limitations: + *

    + *
  • Only affects current turn (can't undo previous turns)
  • + *
  • Only removes one dart at a time (must call multiple times for multiple darts)
  • + *
  • Does nothing if turn is empty (safe to call repeatedly)
  • + *
+ *

+ *

+ * Example Usage: + *

+     * Turn state: [60, 60, 20] (T20, T20, D10)
+     * User taps Undo
+     * New state: [60, 60] (last dart removed)
+     * Turn can continue with new dart entry
+     * 
+ *

+ *

+ * Triggered by the "Undo" button in the UI. + *

+ * + * @see #mCurrentTurnDarts + * @see #mIsTurnOver + * @see #updateTurnIndicators() + * @see #updateUI() + */ + private void undoLastDart() { + if (!mCurrentTurnDarts.isEmpty()) { + // Remove last dart from list + mCurrentTurnDarts.remove(mCurrentTurnDarts.size() - 1); + + // Allow turn to continue + mIsTurnOver = false; + + // Update displays + updateTurnIndicators(); + updateUI(); + } + } + + /** + * Updates all UI elements to reflect the current game state. + *

+ * This method refreshes all dynamic UI components based on the active player's + * current state and the darts thrown so far in the turn. It: + *

    + *
  • Updates primary score display with remaining score
  • + *
  • Updates player name display
  • + *
  • Calculates and displays current three-dart average
  • + *
  • Calculates current target score (accounting for turn darts)
  • + *
  • Updates checkout route suggestions
  • + *
+ *

+ *

+ * Three-Dart Average Calculation: + *

+     * Average = ((startingScore - remainingScore) / dartsThrown) × 3
+     * 
+     * Example:
+     * Starting score: 501
+     * Remaining score: 201
+     * Darts thrown: 30
+     * Points scored: 501 - 201 = 300
+     * Average: (300 / 30) × 3 = 30.0
+     * 
+ * Displayed as "AVG: 30.0" in the UI. + *

+ *

+ * Current Target Calculation: + * The target score considers darts thrown but not yet submitted: + *

+     * currentTarget = remainingScore - sumOfCurrentTurnDarts
+     * 
+ * This allows checkout suggestions to update in real-time as darts are thrown. + *

+ *

+ * When Called: + *

    + *
  • After each valid dart throw
  • + *
  • When switching to next player's turn
  • + *
  • After undoing a dart
  • + *
  • During initial game setup
  • + *
+ *

+ *

+ * Performance: + * This method is called frequently during gameplay, so it's optimized to: + *

    + *
  • Only update TextViews (fast operations)
  • + *
  • Perform minimal calculations
  • + *
  • Not trigger layout recalculations
  • + *
+ *

+ * + * @see X01State + * @see #mActivePlayerIndex + * @see #mCurrentTurnDarts + * @see #updateCheckoutSuggestion(int, int) + */ + private void updateUI() { + X01State active = mPlayerStates.get(mActivePlayerIndex); + + // Update score display + tvScorePrimary.setText(String.valueOf(active.remainingScore)); + + // Update player name (uppercase for emphasis) + tvPlayerName.setText(active.name.toUpperCase()); + + // Calculate and display three-dart average + double avg = active.dartsThrown == 0 ? 0.0 : + ((double)(mStartingScore - active.remainingScore) / active.dartsThrown) * 3; + tvLegAvg.setText(String.format("AVG: %.1f", avg)); + + // Calculate current target (remaining score minus current turn darts) + int turnPointsSoFar = 0; + for (int d : mCurrentTurnDarts) turnPointsSoFar += d; + int currentTarget = active.remainingScore - turnPointsSoFar; + int dartsRemaining = 3 - mCurrentTurnDarts.size(); + + // Update checkout suggestions based on current target + updateCheckoutSuggestion(currentTarget, dartsRemaining); + } + + /** + * Updates the checkout route suggestion display based on the current score and darts left. + *

+ * This method determines whether to show a checkout suggestion and what route to display. + * The checkout engine provides optimal finishing paths when a score is within achievable + * range (≤170 points with available darts). + *

+ *

+ * Visibility Conditions: + * Checkout suggestion is shown when ALL conditions are met: + *

    + *
  • Score is ≤ 170 (maximum 3-dart checkout)
  • + *
  • Score is > 1 (score of 1 is impossible to finish)
  • + *
  • At least 1 dart remaining in turn
  • + *
  • {@link CheckoutEngine} returns a valid route (not null)
  • + *
+ * Otherwise, the suggestion is hidden. + *

+ *

+ * Visual Effects: + * When a checkout route is available: + *

    + *
  • Suggestion layout becomes VISIBLE
  • + *
  • Route text is displayed (e.g., "T20 • D20", "D16")
  • + *
  • Pulsing animation starts (alpha fades 0.5 → 1.0 → 0.5 continuously)
  • + *
  • Animation draws attention to the suggested route
  • + *
+ * When no route is available: + *
    + *
  • Animation is stopped
  • + *
  • Suggestion layout becomes GONE
  • + *
+ *

+ *

+ * Example Scenarios: + *

+     * Score = 32, Darts = 1 → Shows "D16"
+     * Score = 40, Darts = 2 → Shows "D20" or setup route
+     * Score = 170, Darts = 3 → Shows "T20 • T20 • BULL"
+     * Score = 1, Darts = 3 → Hidden (impossible)
+     * Score = 180, Darts = 3 → Hidden (too high)
+     * 
+ *

+ *

+ * Animation Details: + * The pulsing animation: + *

    + *
  • Duration: 1000ms per cycle
  • + *
  • Range: 50% to 100% opacity
  • + *
  • Mode: REVERSE (fades in and out smoothly)
  • + *
  • Repeat: INFINITE (continues until hidden)
  • + *
+ *

+ *

+ * Called automatically by {@link #updateUI()} whenever game state changes. + *

+ * + * @param score The target score to finish, accounting for darts already thrown in + * current turn. Must be non-negative. + * @param dartsLeft The number of darts remaining in the current turn (0-3). + * @see CheckoutEngine#getRoute(int, int) + * @see #layoutCheckoutSuggestion + * @see #tvCheckout + */ + private void updateCheckoutSuggestion(int score, int dartsLeft) { + if (score <= 170 && score > 1 && dartsLeft > 0) { + String route = CheckoutEngine.getRoute(score, dartsLeft); + + if (route != null) { + layoutCheckoutSuggestion.setVisibility(View.VISIBLE); + tvCheckout.setText(route); + + Animation pulse = new AlphaAnimation(0.5f, 1.0f); + pulse.setDuration(1000); + pulse.setRepeatMode(Animation.REVERSE); + pulse.setRepeatCount(Animation.INFINITE); + layoutCheckoutSuggestion.startAnimation(pulse); + return; + } + } + layoutCheckoutSuggestion.clearAnimation(); + layoutCheckoutSuggestion.setVisibility(View.GONE); + } + + /** + * Updates the three dart indicator pills to show the current turn's dart values. + *

+ * This method provides visual feedback for the darts thrown in the current turn by + * updating the three "pill" TextViews. Each pill can be in one of two states: + *

    + *
  • Active (dart thrown): Shows dart value, green text, active background
  • + *
  • Empty (dart not thrown): Blank text, empty/inactive background
  • + *
+ *

+ *

+ * Visual States: + *

+     * Dart not thrown: [   ] (empty background)
+     * Dart thrown:     [60] (green text, active background)
+     * 
+ *

+ *

+ * Display Labels: + * Dart values are formatted for clarity: + *

    + *
  • Regular scores: Numeric value (e.g., "60", "20", "15")
  • + *
  • Single Bull: "B" (25 points)
  • + *
  • Double Bull: "DB" (50 points)
  • + *
+ *

+ *

+ * Example Progression: + *

+     * No darts:    [   ] [   ] [   ]
+     * 1 dart:      [60] [   ] [   ]
+     * 2 darts:     [60] [60] [   ]
+     * 3 darts:     [60] [60] [20]
+     * After submit:[   ] [   ] [   ]  (cleared for next turn)
+     * 
+ *

+ *

+ * When Called: + *

    + *
  • After each dart is thrown ({@link #onNumberTap(int)})
  • + *
  • After undoing a dart ({@link #undoLastDart()})
  • + *
  • After submitting a turn ({@link #submitTurn()})
  • + *
+ *

+ *

+ * The visual feedback helps players: + *

    + *
  • See what they've scored so far in the turn
  • + *
  • Know how many darts they have left (empty pills)
  • + *
  • Verify dart entry before submitting
  • + *
  • Calculate turn totals mentally
  • + *
+ *

+ * + * @see #mCurrentTurnDarts + * @see #tvDartPills + * @see #getDartLabel(int) + */ + private void updateTurnIndicators() { + for (int i = 0; i < 3; i++) { + if (i < mCurrentTurnDarts.size()) { + // Dart has been thrown - show active state + tvDartPills[i].setText(getDartLabel(mCurrentTurnDarts.get(i))); + tvDartPills[i].setBackgroundResource(R.drawable.shape_dart_pill_active); + tvDartPills[i].setTextColor(ContextCompat.getColor(this, R.color.volt_green)); + } else { + // Dart not thrown yet - show empty state + tvDartPills[i].setText(""); + tvDartPills[i].setBackgroundResource(R.drawable.shape_dart_pill_empty); + } + } + } + + /** + * Converts a dart point value into a display-friendly label. + *

+ * This method formats dart scores for clarity in the turn indicator pills, + * using special abbreviations for Bull scores to save space and improve + * readability. + *

+ *

+ * Label Formats: + *

    + *
  • 50: "DB" (Double Bull / Bullseye)
  • + *
  • 25: "B" (Bull / Outer Bull)
  • + *
  • Other scores: Numeric string (e.g., "60", "20", "15")
  • + *
+ *

+ *

+ * Example Conversions: + *

+     * getDartLabel(50) → "DB"
+     * getDartLabel(25) → "B"
+     * getDartLabel(60) → "60"
+     * getDartLabel(20) → "20"
+     * getDartLabel(1) → "1"
+     * 
+ *

+ * + * @param score The dart's point value to format. Typically 0-60, 25, or 50. + * @return A string label suitable for display in UI. Never returns null. + * @see #updateTurnIndicators() + */ + private String getDartLabel(int score) { + if (score == 50) return "DB"; // Double Bull / Bullseye + if (score == 25) return "B"; // Single Bull + // Return numeric value for all other scores + return String.valueOf(score); + } + + /** + * Handles the win condition when a player finishes the game. + *

+ * This method is called from {@link #onNumberTap(int)} when a player successfully + * finishes on exactly zero with a double. It: + *

    + *
  • Displays a toast notification announcing the winner
  • + *
  • Finishes the activity, returning to previous screen
  • + *
+ *

+ *

+ * Current Implementation: + * The current implementation is minimal and immediately ends the game. Future + * enhancements could include: + *

    + *
  • Displaying a more elaborate win screen
  • + *
  • Showing final game statistics
  • + *
  • Playing win sound effects or animations
  • + *
  • Saving match results to database
  • + *
  • Asking if players want a rematch
  • + *
  • Updating player career statistics
  • + *
+ *

+ *

+ * Toast Duration: + * Uses LENGTH_LONG (approximately 3.5 seconds) to ensure players see the win + * message before the activity closes. + *

+ * + * @param winner The {@link X01State} of the player who won the game. Used to + * display the winner's name in the toast notification. + * @see X01State + * @see #onNumberTap(int) + */ + private void handleWin(X01State winner) { + // Show win notification + Toast.makeText(this, winner.name + " WINS!", Toast.LENGTH_LONG).show(); + + // End game and return to previous screen + finish(); + + // TODO: Consider adding: + // - Win animation/sound + // - Statistics display + // - Save match to database + // - Offer rematch + } + + /** + * Internal state holder for a single player's X01 game progress. + *

+ * This lightweight class tracks all necessary information for one player during + * an X01 game. Each player in the game has their own X01State instance stored + * in the {@link #mPlayerStates} list. + *

+ *

+ * Fields: + *

    + *
  • name: Player's display name (from Player.username)
  • + *
  • remainingScore: Current score (starts at game's starting score)
  • + *
  • dartsThrown: Total number of darts thrown so far (for average calc)
  • + *
+ *

+ *

+ * Usage Example: + *

+     * X01State player1 = new X01State("John Doe", 501);
+     * // Player throws T20, T20, T20 (180 points)
+     * player1.remainingScore -= 180;  // Now 321
+     * player1.dartsThrown += 3;
+     * 
+     * // Calculate three-dart average
+     * double avg = ((501 - player1.remainingScore) / player1.dartsThrown) * 3;
+     * 
+ *

+ *

+ * State Lifecycle: + *

    + *
  1. Created in {@link #setupGame(List)} with starting score
  2. + *
  3. Updated throughout game as darts are thrown
  4. + *
  5. Used to calculate averages and display current score
  6. + *
  7. Discarded when activity ends
  8. + *
+ *

+ *

+ * Note: This is an inner class (not static) but could be made static as it + * doesn't access outer class members. + *

+ * + * @see #mPlayerStates + * @see #setupGame(List) + * @see #updateUI() + */ + private static class X01State { + /** + * The player's display name. + *

+ * Copied from {@link Player#username} during game initialization. + * Used for UI display and win announcements. + *

+ */ + String name; + + /** + * The player's current remaining score. + *

+ * Starts at the game's starting score (e.g., 501) and decreases as darts + * are scored. Game is won when this reaches exactly 0 with a double. + *

+ *

+ * Updated in {@link GameActivity#submitTurn()} after each turn (unless bust). + *

+ */ + int remainingScore; + + /** + * Total number of darts thrown by this player so far in the game. + *

+ * Used to calculate the three-dart average: + *

+         * average = ((startScore - remainingScore) / dartsThrown) × 3
+         * 
+ *

+ *

+ * Incremented in {@link GameActivity#submitTurn()} after each valid turn. + * Bust turns don't increment this counter. + *

+ */ + int dartsThrown = 0; + + /** + * Constructs a new X01State for a player. + * + * @param name The player's display name + * @param startScore The game's starting score (e.g., 501, 301, 701) + */ + X01State(String name, int startScore) { + this.name = name; + this.remainingScore = startScore; + } + } + + /** + * Static helper class that provides optimal checkout route suggestions for X01 games. + *

+ * The CheckoutEngine calculates the best way to finish a game given a target score + * and number of darts remaining. It combines pre-calculated routes for classic + * finishes with intelligent logic for direct doubles and setup darts. + *

+ *

+ * Checkout Logic Priority: + *

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

+ *

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

+ *

+ * Example Suggestions: + *

+     * getRoute(32, 1) → "D16"           (Direct double)
+     * getRoute(50, 1) → "BULL"          (Bullseye)
+     * getRoute(40, 2) → "D20"           (Direct double)
+     * getRoute(41, 2) → "1 • D20"       (Setup dart to leave 40)
+     * getRoute(170, 3) → "T20 • T20 • BULL"  (Pre-calculated route)
+     * getRoute(100, 2) → "T20 Route"    (General high-score advice)
+     * 
+ *

+ *

+ * Design Pattern: + * This is a pure static utility class with no instance state. All methods are + * static and thread-safe. The checkout map is initialized once in a static block. + *

+ * + * @see #getRoute(int, int) + */ + private static class CheckoutEngine { + /** + * Map of pre-calculated checkout routes for classic finishes. + *

+ * Key: Target score (e.g., 170, 141) + * Value: Array of dart descriptions (e.g., ["T20", "T20", "BULL"]) + *

+ *

+ * Current Routes: + *

    + *
  • 170: T20 • T20 • BULL (maximum 3-dart checkout)
  • + *
  • 141: T20 • T19 • D12 (common high finish)
  • + *
+ *

+ *

+ * Additional routes can be added for other common finishes (e.g., 167, 164, 160). + *

+ */ + private static final Map checkoutMap = new HashMap<>(); + + // Initialize pre-calculated checkout routes + static { + checkoutMap.put(170, new String[]{"T20", "T20", "BULL"}); // Max 3-dart checkout + checkoutMap.put(141, new String[]{"T20", "T19", "D12"}); // Common high finish + // ... add more fixed routes as needed + // Examples to add: + // checkoutMap.put(167, new String[]{"T20", "T19", "BULL"}); + // checkoutMap.put(164, new String[]{"T20", "T18", "BULL"}); + // checkoutMap.put(160, new String[]{"T20", "T20", "D20"}); + } + + /** + * Returns an optimal checkout route for the given score and darts remaining. + *

+ * This method implements sophisticated checkout logic that considers: + *

    + *
  • Whether the score can be finished directly with a double
  • + *
  • If setup darts are needed to reach a double
  • + *
  • Avoiding leaving a score of 1 (impossible to finish)
  • + *
  • Pre-calculated routes for classic high finishes
  • + *
+ *

+ *

+ * Return Format: + * Returns strings formatted as: + *

    + *
  • Single dart: "D16", "BULL"
  • + *
  • Multiple darts: "T20 • D20", "1 • D20", "T20 • T20 • BULL"
  • + *
  • Generic advice: "T20 Route" (for high scores)
  • + *
+ * The bullet character (•) separates individual darts in multi-dart routes. + *

+ *

+ * Algorithm Steps: + *

    + *
  1. Check for direct double finish (score ≤40, even) → "D[N]"
  2. + *
  3. Check for bullseye finish (score = 50) → "BULL"
  4. + *
  5. If multiple darts available and odd score: + *
      + *
    • Try to leave common double (32→D16, 40→D20, 16→D8)
    • + *
    • Default: suggest 1 to leave even score for double
    • + *
    + *
  6. + *
  7. Check pre-calculated routes map
  8. + *
  9. Fallback to generic "T20 Route" for high scores (>60)
  10. + *
  11. Return null if no route available
  12. + *
+ *

+ *

+ * Setup Dart Logic: + * When score is odd with 2+ darts left, suggests setup darts to leave doubles: + *

+         * Score 41: Suggest "1 • D20" (leaves 40)
+         * Score 39: Suggest "7 • D16" (leaves 32)
+         * Score 47: Suggest "7 • D20" (leaves 40)
+         * 
+ * Prioritizes leaving 32 (D16) or 40 (D20) as these are common target doubles. + *

+ *

+ * Example Calls: + *

+         * CheckoutEngine.getRoute(32, 1);   // Returns "D16"
+         * CheckoutEngine.getRoute(50, 1);   // Returns "BULL"
+         * CheckoutEngine.getRoute(40, 2);   // Returns "D20"
+         * CheckoutEngine.getRoute(41, 2);   // Returns "1 • D20"
+         * CheckoutEngine.getRoute(170, 3);  // Returns "T20 • T20 • BULL"
+         * CheckoutEngine.getRoute(180, 3);  // Returns null (impossible)
+         * CheckoutEngine.getRoute(100, 2);  // Returns "T20 Route"
+         * CheckoutEngine.getRoute(1, 1);    // Returns null (impossible)
+         * 
+ *

+ *

+ * Limitations: + *

    + *
  • Doesn't verify if player has the skill to hit suggested route
  • + *
  • Setup dart logic is simplified (doesn't consider all optimal routes)
  • + *
  • Pre-calculated map is incomplete (only has a few common finishes)
  • + *
  • "T20 Route" is vague advice for complex high-score situations
  • + *
+ *

+ * + * @param score The target score to finish. Must be positive. Scores > 170 typically + * return null as they're not achievable in 3 darts. + * @param dartsLeft Number of darts remaining in the turn (1-3). With 0 darts, + * returns null. + * @return A string describing the optimal checkout route, or null if no route + * is available or the score is impossible to finish with the given darts. + * @see #checkoutMap + */ + public static String getRoute(int score, int dartsLeft) { + // 1. Direct Out check (highest priority) + if (score <= 40 && score % 2 == 0) return "D" + (score / 2); + if (score == 50) return "BULL"; + + // 2. Logic for Setup Darts (preventing score of 1) + if (dartsLeft >= 2) { + // Example: Score is 7. Suggesting D3 leaves 1 (Bust). + // Suggesting 1 leaves 6 (D3). Correct. + if (score <= 41 && score % 2 != 0) { + // Try to leave a common double (32, 40, 16) + if (score - 32 > 0 && score - 32 <= 20) return (score - 32) + " • D16"; + if (score - 40 > 0 && score - 40 <= 20) return (score - 40) + " • D20"; + return "1 • D" + ((score - 1) / 2); // Default setup + } + } + + // 3. Fallback to Map or High Scoring Route + if (checkoutMap.containsKey(score) && checkoutMap.get(score).length <= dartsLeft) { + String[] parts = checkoutMap.get(score); + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < parts.length; i++) { + sb.append(parts[i]); + if (i < parts.length - 1) sb.append(" • "); + } + return sb.toString(); + } + + if (score > 60) return "T20 Route"; + return null; + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/aldo/apps/ochecompanion/MainMenuActivity.java b/app/src/main/java/com/aldo/apps/ochecompanion/MainMenuActivity.java new file mode 100644 index 0000000..08f9100 --- /dev/null +++ b/app/src/main/java/com/aldo/apps/ochecompanion/MainMenuActivity.java @@ -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. + *

+ * This activity provides the following functionality: + *

    + *
  • Displays the squad of players in a RecyclerView
  • + *
  • Allows adding new players to the squad
  • + *
  • Shows a match recap view with test data for development purposes
  • + *
  • Manages the application's database connection
  • + *
+ *

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

+ * This view shows match information including players, scores, and match status. + * It can be clicked to cycle through different test data states for development purposes. + *

+ * + * @see MatchRecapView + */ + private MatchRecapView mMatchRecap; + + /** + * Counter used for cycling through different test data scenarios. + *

+ * This counter increments each time the match recap view is clicked, allowing + * developers to cycle through different test states (null match, 1v1 match, group match). + * The counter value modulo 3 determines which test scenario is displayed. + *

+ */ + private int testCounter = 0; + + /** + * Called when the activity is first created. + *

+ * This method performs the following initialization tasks: + *

    + *
  • Enables edge-to-edge display for modern Android UI
  • + *
  • Sets the activity's content view to the main layout
  • + *
  • Configures window insets for proper system bar handling
  • + *
  • Initializes the database connection
  • + *
  • Sets up the match recap view with a test data click listener
  • + *
+ *

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

+ * This method is called every time the activity comes to the foreground, making it + * the ideal place to refresh the squad view with the latest player data from the database. + *

+ * + * @see AppCompatActivity#onResume() + */ + @Override + protected void onResume() { + super.onResume(); + // Refresh the squad view with latest player data + initSquadView(); + } + + /** + * Initiates a quick-start 501 game with two test players. + *

+ * This convenience method creates two test players with minimal configuration + * and immediately launches a 501 game. It's designed for rapid testing and + * development, allowing developers or users to quickly jump into a game without + * setting up real player profiles. + *

+ *

+ * The method: + *

    + *
  • Creates two test players named "Test1" and "Test2" with no profile pictures
  • + *
  • Adds them to a player list
  • + *
  • Launches {@link GameActivity} with a starting score of 501
  • + *
+ *

+ *

+ * Use Cases: + *

    + *
  • Quick testing of game functionality during development
  • + *
  • Demonstrating the app without setting up a full squad
  • + *
  • Rapid game start for users who want to practice scoring
  • + *
+ *

+ *

+ * Note: The test players created here are not persisted to the database. + * They exist only for the duration of the game session. + *

+ *

+ * Triggered by the quick start button in the main menu UI. + *

+ * + * @see GameActivity#start(android.content.Context, ArrayList, int) + * @see Player + */ + private void quickStart() { + final Player playerOne = new Player("Test1", null); + final Player playerTwo = new Player("Test2", null); + final ArrayList players = new ArrayList<>(); + players.add(playerOne); + players.add(playerTwo); + + GameActivity.start(MainMenuActivity.this, players, 501); + } + + /** + * Initializes and configures the squad view component. + *

+ * This method performs the following operations: + *

    + *
  • Retrieves references to UI components (add player button and squad RecyclerView)
  • + *
  • Sets up the RecyclerView with a LinearLayoutManager
  • + *
  • Initializes and attaches the MainMenuPlayerAdapter to the RecyclerView
  • + *
  • Configures the add player button to launch the AddPlayerActivity
  • + *
  • Loads all players from the database on a background thread
  • + *
  • Updates the adapter with player data on the UI thread
  • + *
+ *

+ *

+ * Note: Database operations are performed on a background thread to prevent + * blocking the main UI thread, which ensures the application remains responsive. + *

+ * + * @see MainMenuPlayerAdapter + * @see AddPlayerActivity + * @see LinearLayoutManager + */ + private void initSquadView() { + // Get references to UI components + final TextView addPlayerBtn = findViewById(R.id.btnAddPlayer); + final RecyclerView squadView = findViewById(R.id.rvSquad); + + // Configure RecyclerView with linear layout + squadView.setLayoutManager(new LinearLayoutManager(MainMenuActivity.this)); + + // Create and attach adapter + final MainMenuPlayerAdapter adapter = new MainMenuPlayerAdapter(); + squadView.setAdapter(adapter); + + // Set up button to launch AddPlayerActivity + addPlayerBtn.setOnClickListener(v -> { + final Intent intent = new Intent(MainMenuActivity.this, AddPlayerActivity.class); + startActivity(intent); + }); + + // Database operations must be run on a background thread to keep the UI responsive. + new Thread(() -> { + // Access the singleton database and query all players + final List 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. + *

+ * This method creates sample player and match objects and cycles through different + * display states based on the provided counter value: + *

    + *
  • When counter % 3 == 0: Displays null (no match)
  • + *
  • When counter % 3 == 1: Displays a 1v1 match (two players)
  • + *
  • When counter % 3 == 2: Displays a group match (four players)
  • + *
+ *

+ *

+ * Note: This method is intended for development and testing only + * and should be removed or disabled in production builds. + *

+ * + * @param counter The counter value used to determine which test scenario to display. + * The value is evaluated using modulo 3 to cycle through test states. + * @see Match + * @see Player + * @see MatchRecapView#setMatch(Match) + */ + private void applyTestData(final int counter) { + // Create test player objects + final Player playerOne = new Player("Test1", null); + final Player playerTwo = new Player("Test2", null); + final Player playerThree = new Player("Test3", null); + final Player playerFour = new Player("Test4", null); + + // Create test match objects with different player configurations + final Match match1on1 = new Match(playerOne, playerTwo); + final Match matchGroup = new Match(playerOne, playerTwo, playerThree, playerFour); + + // Cycle through different test scenarios based on counter value + if (counter % 3 == 0) { + // Scenario 1: No match (null state) + mMatchRecap.setMatch(null); + } else if (counter % 3 == 1) { + // Scenario 2: 1v1 match (two players) + mMatchRecap.setMatch(match1on1); + } else { + // Scenario 3: Group match (four players) + mMatchRecap.setMatch(matchGroup); + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/aldo/apps/ochecompanion/database/AppDatabase.java b/app/src/main/java/com/aldo/apps/ochecompanion/database/AppDatabase.java new file mode 100644 index 0000000..f2a78db --- /dev/null +++ b/app/src/main/java/com/aldo/apps/ochecompanion/database/AppDatabase.java @@ -0,0 +1,866 @@ +package com.aldo.apps.ochecompanion.database; + +import android.content.Context; +import androidx.room.Database; +import androidx.room.Room; +import androidx.room.RoomDatabase; + +import com.aldo.apps.ochecompanion.database.dao.MatchDao; +import com.aldo.apps.ochecompanion.database.dao.PlayerDao; +import com.aldo.apps.ochecompanion.database.objects.Match; +import com.aldo.apps.ochecompanion.database.objects.Player; + +/** + * The main Room database class for the Oche Companion darts application. + *

+ * This abstract class serves as the central database access point, managing all + * data persistence for players, matches, and related statistics. It implements + * the Singleton design pattern to ensure only one database instance exists + * throughout the application lifecycle, preventing resource conflicts and ensuring + * data consistency. + *

+ *

+ * Room Database Architecture: + * Room is Android's SQLite object-relational mapping (ORM) library that provides + * an abstraction layer over SQLite, offering: + *

    + *
  • Compile-time SQL query verification
  • + *
  • Convenient annotation-based entity and DAO definitions
  • + *
  • Automatic conversion between SQLite and Java/Kotlin objects
  • + *
  • LiveData and Flow support for reactive UI updates
  • + *
  • Migration support for database schema changes
  • + *
+ *

+ *

+ * Database Entities: + * This database manages two primary tables: + *

    + *
  • players: Squad roster with player profiles and career statistics + * (defined by {@link Player} entity)
  • + *
  • matches: Completed match records with participant data and results + * (defined by {@link Match} entity)
  • + *
+ *

+ *

+ * Database Access Objects (DAOs): + * Database operations are performed through specialized DAO interfaces: + *

    + *
  • {@link PlayerDao}: CRUD operations for player management (insert, update, query)
  • + *
  • {@link MatchDao}: Match record operations (insert, query all, query last match)
  • + *
+ *

+ *

+ * Singleton Pattern Implementation: + * The class uses the thread-safe double-checked locking pattern to ensure a single + * database instance. This approach: + *

    + *
  • Prevents multiple database connections that waste resources
  • + *
  • Ensures data consistency across the application
  • + *
  • Reduces memory overhead by sharing one connection pool
  • + *
  • Thread-safe initialization prevents race conditions
  • + *
+ *

+ *

+ * Usage Example: + *

+ * // Get database instance (can be called from any activity/fragment)
+ * AppDatabase db = AppDatabase.getDatabase(context);
+ * 
+ * // Access DAOs for database operations
+ * PlayerDao playerDao = db.playerDao();
+ * MatchDao matchDao = db.matchDao();
+ * 
+ * // Perform database operations (must be on background thread)
+ * new Thread(() -> {
+ *     // Insert a new player
+ *     Player player = new Player("John Doe", "/path/to/pic.jpg");
+ *     playerDao.insert(player);
+ *     
+ *     // Query all players
+ *     List<Player> allPlayers = playerDao.getAllPlayers();
+ *     
+ *     // Insert a match
+ *     Match match = new Match(System.currentTimeMillis(), "501", 2, participantJson);
+ *     matchDao.insert(match);
+ *     
+ *     // Get recent matches
+ *     List<Match> recentMatches = matchDao.getAllMatches();
+ * }).start();
+ * 
+ *

+ *

+ * Database Versioning: + * Currently at version 2, indicating one schema change since initial creation. + * Version increments are required when: + *

    + *
  • Adding or removing tables
  • + *
  • Adding or removing columns
  • + *
  • Changing column types or constraints
  • + *
  • Modifying primary keys or indices
  • + *
+ * 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. + *

+ *

+ * Migration Strategy: + * For production releases, replace destructive migration with proper migration paths: + *

+ * static final Migration MIGRATION_1_2 = new Migration(1, 2) {
+ *     @Override
+ *     public void migrate(@NonNull SupportSQLiteDatabase database) {
+ *         // Example: Add new column to players table
+ *         database.execSQL("ALTER TABLE players ADD COLUMN wins INTEGER NOT NULL DEFAULT 0");
+ *     }
+ * };
+ * 
+ * INSTANCE = Room.databaseBuilder(context.getApplicationContext(),
+ *                 AppDatabase.class, "oche_companion_db")
+ *         .addMigrations(MIGRATION_1_2)  // Add migration instead of destructive
+ *         .build();
+ * 
+ *

+ *

+ * Database File Location: + * The SQLite database file "oche_companion_db" is stored in the app's private + * storage at: + *

+ * /data/data/com.aldo.apps.ochecompanion/databases/oche_companion_db
+ * 
+ * This location is: + *
    + *
  • Private to the application (other apps cannot access)
  • + *
  • Automatically backed up with Auto Backup (if enabled)
  • + *
  • Cleared when the app is uninstalled
  • + *
  • Accessible for inspection via Android Studio Database Inspector
  • + *
+ *

+ *

+ * Schema Export: + * The {@code exportSchema = false} setting disables automatic schema JSON export. + * For production apps, consider enabling this: + *

+ * @Database(entities = {...}, version = 2, exportSchema = true)
+ * 
+ * And specify the export directory in build.gradle: + *
+ * android {
+ *     defaultConfig {
+ *         javaCompileOptions {
+ *             annotationProcessorOptions {
+ *                 arguments += ["room.schemaLocation": "$projectDir/schemas"]
+ *             }
+ *         }
+ *     }
+ * }
+ * 
+ * This creates version-controlled schema files for tracking changes and testing migrations. + *

+ *

+ * Threading Requirements: + * Room enforces that database operations occur on background threads: + *

    + *
  • Attempting database operations on the main thread throws {@link IllegalStateException}
  • + *
  • Use {@link Thread}, {@link java.util.concurrent.ExecutorService}, or Kotlin coroutines
  • + *
  • UI updates after database operations must use {@code runOnUiThread()} or similar
  • + *
  • Consider using LiveData or Flow for automatic thread handling and UI updates
  • + *
+ *

+ *

+ * Database Inspection: + * For debugging, you can inspect the database using: + *

    + *
  • Android Studio Database Inspector: View > Tool Windows > Database Inspector
  • + *
  • ADB: {@code adb shell} and SQLite command line
  • + *
  • Third-party tools: DB Browser for SQLite (requires root or backup)
  • + *
+ *

+ *

+ * Performance Optimization: + *

    + *
  • Database queries are optimized with SQLite indices on primary keys
  • + *
  • Connection pooling is handled automatically by Room
  • + *
  • Consider adding custom indices for frequently queried columns: + *
    @Entity(indices = {@Index(value = {"username"})})
  • + *
  • Use transactions for batch operations to improve performance
  • + *
  • Avoid N+1 query problems by using JOIN queries or @Relation
  • + *
+ *

+ *

+ * Testing: + * For unit testing, create an in-memory database: + *

+ * @Before
+ * public void createDb() {
+ *     Context context = ApplicationProvider.getApplicationContext();
+ *     db = Room.inMemoryDatabaseBuilder(context, AppDatabase.class)
+ *             .allowMainThreadQueries()  // OK for tests
+ *             .build();
+ *     playerDao = db.playerDao();
+ * }
+ * 
+ * @After
+ * public void closeDb() {
+ *     db.close();
+ * }
+ * 
+ *

+ *

+ * Data Backup and Restore: + * Consider implementing backup functionality: + *

    + *
  • Export database to external storage or cloud
  • + *
  • Use Android's Auto Backup for automatic cloud backup
  • + *
  • Provide manual export/import for user control
  • + *
  • Validate data integrity after restore operations
  • + *
+ *

+ *

+ * Security Considerations: + *

    + *
  • Database is stored in app's private directory (secure by default)
  • + *
  • For sensitive data, consider using SQLCipher for encryption
  • + *
  • Be cautious when exporting database (may contain user data)
  • + *
  • Validate and sanitize all data before insertion to prevent SQL injection + * (Room's parameterized queries provide protection)
  • + *
+ *

+ *

+ * Future Enhancements: + * Consider these improvements for future versions: + *

    + *
  • Add proper migration strategies instead of destructive migration
  • + *
  • Implement database encryption with SQLCipher
  • + *
  • Add support for exporting/importing data
  • + *
  • Create additional entities for tournaments, achievements, settings
  • + *
  • Implement multi-user support with user profiles table
  • + *
  • Add full-text search capabilities with FTS tables
  • + *
+ *

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

+ * This abstract method is implemented by Room at compile time, returning an + * instance of the {@link PlayerDao} interface. The DAO (Data Access Object) + * provides methods for all player-related database operations including + * creating, reading, updating, and managing player records. + *

+ *

+ * Available Operations: + * The returned PlayerDao provides these methods: + *

    + *
  • {@link PlayerDao#insert(Player)} - Add new player to squad
  • + *
  • {@link PlayerDao#update(Player)} - Update existing player information
  • + *
  • {@link PlayerDao#getPlayerById(int)} - Retrieve specific player by ID
  • + *
  • {@link PlayerDao#getAllPlayers()} - Get all players sorted alphabetically
  • + *
+ *

+ *

+ * Usage Example: + *

+     * // Get database instance
+     * AppDatabase db = AppDatabase.getDatabase(context);
+     * 
+     * // Get PlayerDao
+     * PlayerDao playerDao = db.playerDao();
+     * 
+     * // Perform operations on background thread
+     * new Thread(() -> {
+     *     // Create and insert new player
+     *     Player newPlayer = new Player("Alice", "/path/to/pic.jpg");
+     *     playerDao.insert(newPlayer);
+     *     
+     *     // Query all players
+     *     List<Player> squad = playerDao.getAllPlayers();
+     *     
+     *     // Update player stats
+     *     Player player = playerDao.getPlayerById(5);
+     *     if (player != null) {
+     *         player.careerAverage = 85.5;
+     *         player.matchesPlayed++;
+     *         playerDao.update(player);
+     *     }
+     *     
+     *     // Update UI on main thread
+     *     runOnUiThread(() -> updateSquadDisplay(squad));
+     * }).start();
+     * 
+ *

+ *

+ * Thread Safety: + * While the DAO instance itself is thread-safe and can be reused across multiple + * threads, all database operations must be performed on background threads to + * comply with Room's threading policy. Attempting to execute queries on the + * main thread will result in an {@link IllegalStateException}. + *

+ *

+ * DAO Lifecycle: + * The DAO instance is created once by Room and can be cached and reused: + *

+     * // Good: Reuse DAO instance
+     * PlayerDao playerDao = db.playerDao();
+     * // Use playerDao for multiple operations
+     * 
+     * // Also fine: Get DAO each time (Room caches internally)
+     * db.playerDao().insert(player1);
+     * db.playerDao().insert(player2);
+     * 
+ *

+ *

+ * Common Usage Patterns: + *

+     * // Pattern 1: Simple query and UI update
+     * ExecutorService executor = Executors.newSingleThreadExecutor();
+     * executor.execute(() -> {
+     *     List<Player> players = db.playerDao().getAllPlayers();
+     *     runOnUiThread(() -> adapter.setPlayers(players));
+     * });
+     * 
+     * // Pattern 2: Insert and navigate
+     * new Thread(() -> {
+     *     Player player = new Player("John", picUri);
+     *     db.playerDao().insert(player);
+     *     runOnUiThread(() -> {
+     *         Toast.makeText(context, "Player added", Toast.LENGTH_SHORT).show();
+     *         finish();
+     *     });
+     * }).start();
+     * 
+     * // Pattern 3: Edit flow (query, modify, update)
+     * new Thread(() -> {
+     *     Player player = db.playerDao().getPlayerById(playerId);
+     *     if (player != null) {
+     *         player.username = newName;
+     *         player.profilePictureUri = newPicUri;
+     *         db.playerDao().update(player);
+     *         runOnUiThread(() -> Toast.makeText(context, "Updated", Toast.LENGTH_SHORT).show());
+     *     }
+     * }).start();
+     * 
+ *

+ *

+ * Alternative Reactive Approaches: + * Consider using LiveData or Flow for automatic UI updates: + *

+     * // If PlayerDao had LiveData support:
+     * // @Query("SELECT * FROM players ORDER BY username ASC")
+     * // LiveData<List<Player>> getAllPlayersLive();
+     * 
+     * // Usage in Activity/Fragment:
+     * db.playerDao().getAllPlayersLive().observe(this, players -> {
+     *     // UI automatically updates when data changes
+     *     adapter.setPlayers(players);
+     * });
+     * 
+ *

+ *

+ * Error Handling: + *

+     * new Thread(() -> {
+     *     try {
+     *         playerDao.insert(player);
+     *         runOnUiThread(() -> showSuccess());
+     *     } catch (SQLiteConstraintException e) {
+     *         Log.e(TAG, "Constraint violation", e);
+     *         runOnUiThread(() -> showError("Failed to save player"));
+     *     } catch (Exception e) {
+     *         Log.e(TAG, "Database error", e);
+     *         runOnUiThread(() -> showError("Unexpected error"));
+     *     }
+     * }).start();
+     * 
+ *

+ * + * @return The PlayerDao instance for accessing player-related database operations. + * Never returns null. The returned instance can be safely cached and reused. + * @see PlayerDao + * @see Player + */ + public abstract PlayerDao playerDao(); + + /** + * Provides access to Match-related database operations. + *

+ * This abstract method is implemented by Room at compile time, returning an + * instance of the {@link MatchDao} interface. The DAO provides methods for + * storing and retrieving match records, enabling match history tracking and + * statistical analysis. + *

+ *

+ * Available Operations: + * The returned MatchDao provides these methods: + *

    + *
  • {@link MatchDao#insert(Match)} - Save completed match to history
  • + *
  • {@link MatchDao#getAllMatches()} - Retrieve all matches ordered by most recent
  • + *
  • {@link MatchDao#getLastMatch()} - Get the most recently completed match
  • + *
+ *

+ *

+ * Usage Example: + *

+     * // Get database instance
+     * AppDatabase db = AppDatabase.getDatabase(context);
+     * 
+     * // Get MatchDao
+     * MatchDao matchDao = db.matchDao();
+     * 
+     * // Save completed match (background thread)
+     * new Thread(() -> {
+     *     // Create match record with participant data
+     *     String participantJson = buildParticipantData(players, scores);
+     *     Match match = new Match(
+     *         System.currentTimeMillis(),
+     *         "501",
+     *         2,
+     *         participantJson
+     *     );
+     *     
+     *     // Insert into database
+     *     matchDao.insert(match);
+     *     
+     *     // Get match history for display
+     *     List<Match> allMatches = matchDao.getAllMatches();
+     *     
+     *     // Update UI with latest match
+     *     Match lastMatch = matchDao.getLastMatch();
+     *     runOnUiThread(() -> displayMatchRecap(lastMatch));
+     * }).start();
+     * 
+ *

+ *

+ * Match History Display: + *

+     * // Load match history for main menu
+     * new Thread(() -> {
+     *     List<Match> recentMatches = db.matchDao().getAllMatches();
+     *     
+     *     runOnUiThread(() -> {
+     *         if (recentMatches.isEmpty()) {
+     *             // Show empty state - no matches played yet
+     *             showEmptyMatchHistory();
+     *         } else {
+     *             // Display most recent match in recap view
+     *             Match lastMatch = recentMatches.get(0);
+     *             matchRecapView.setMatch(lastMatch);
+     *         }
+     *     });
+     * }).start();
+     * 
+ *

+ *

+ * Match Completion Flow: + *

+     * // After match ends, save to database
+     * private void saveMatchResult(List<Player> players, Map<Player, Score> scores) {
+     *     new Thread(() -> {
+     *         // Build participant data JSON
+     *         JSONArray participants = new JSONArray();
+     *         for (Player player : players) {
+     *             JSONObject data = new JSONObject();
+     *             data.put("id", player.id);
+     *             data.put("username", player.username);
+     *             data.put("rank", scores.get(player).rank);
+     *             data.put("average", scores.get(player).average);
+     *             participants.put(data);
+     *         }
+     *         
+     *         // Create and save match
+     *         Match match = new Match(
+     *             System.currentTimeMillis(),
+     *             currentGameMode,
+     *             players.size(),
+     *             participants.toString()
+     *         );
+     *         
+     *         db.matchDao().insert(match);
+     *         
+     *         // Navigate to results screen
+     *         runOnUiThread(() -> {
+     *             Intent intent = new Intent(this, MatchResultsActivity.class);
+     *             intent.putExtra("match", match);
+     *             startActivity(intent);
+     *         });
+     *     }).start();
+     * }
+     * 
+ *

+ *

+ * Thread Safety: + * Like PlayerDao, the MatchDao instance is thread-safe and can be reused across + * threads. However, all database operations must execute on background threads. + * Room will throw an {@link IllegalStateException} if database operations are + * attempted on the main thread. + *

+ *

+ * Performance Considerations: + *

    + *
  • getAllMatches() loads all match records - consider pagination for users + * with hundreds of matches
  • + *
  • getLastMatch() uses LIMIT 1 for efficient querying
  • + *
  • Matches are ordered by timestamp DESC for chronological display
  • + *
  • Consider adding date range queries for filtering match history
  • + *
+ *

+ *

+ * Statistical Queries: + *

+     * // Get match history for analysis
+     * new Thread(() -> {
+     *     List<Match> allMatches = db.matchDao().getAllMatches();
+     *     
+     *     // Calculate statistics
+     *     int totalMatches = allMatches.size();
+     *     Map<String, Integer> gameModeCount = new HashMap<>();
+     *     
+     *     for (Match match : allMatches) {
+     *         gameModeCount.merge(match.gameMode, 1, Integer::sum);
+     *     }
+     *     
+     *     // Display stats
+     *     runOnUiThread(() -> showStatistics(totalMatches, gameModeCount));
+     * }).start();
+     * 
+ *

+ *

+ * DAO Lifecycle: + * The DAO instance can be cached and reused throughout the app: + *

+     * // Cache in Application class or repository
+     * private MatchDao matchDao;
+     * 
+     * public MatchDao getMatchDao() {
+     *     if (matchDao == null) {
+     *         matchDao = AppDatabase.getDatabase(context).matchDao();
+     *     }
+     *     return matchDao;
+     * }
+     * 
+ *

+ *

+ * Error Handling: + *

+     * new Thread(() -> {
+     *     try {
+     *         matchDao.insert(match);
+     *         runOnUiThread(() -> {
+     *             Toast.makeText(context, "Match saved", Toast.LENGTH_SHORT).show();
+     *         });
+     *     } catch (Exception e) {
+     *         Log.e(TAG, "Failed to save match", e);
+     *         runOnUiThread(() -> {
+     *             Toast.makeText(context, "Error saving match", Toast.LENGTH_SHORT).show();
+     *         });
+     *     }
+     * }).start();
+     * 
+ *

+ * + * @return The MatchDao instance for accessing match-related database operations. + * Never returns null. The returned instance can be safely cached and reused. + * @see MatchDao + * @see Match + */ + public abstract MatchDao matchDao(); + + /** + * The singleton instance of the AppDatabase. + *

+ * This static field holds the single database instance for the entire application. + * Using the volatile keyword ensures proper visibility across threads in the + * double-checked locking pattern, preventing potential issues where one thread's + * changes might not be visible to other threads. + *

+ *

+ * Volatile Keyword: + * The volatile modifier guarantees: + *

    + *
  • Happens-before relationship: Writes to INSTANCE are visible to all threads
  • + *
  • Prevents instruction reordering that could break double-checked locking
  • + *
  • Ensures thread sees the fully constructed object, not a partially initialized one
  • + *
  • No caching of the variable value in CPU registers
  • + *
+ *

+ *

+ * Initialization State: + *

    + *
  • null: Before first call to {@link #getDatabase(Context)}
  • + *
  • non-null: After database is created, remains set for app lifetime
  • + *
+ *

+ *

+ * Memory Lifecycle: + * The database instance is retained in memory for the lifetime of the application + * process. It will be garbage collected only when: + *

    + *
  • The app process is terminated by Android
  • + *
  • The app is killed by the user
  • + *
  • System needs to reclaim memory and kills the app's process
  • + *
+ *

+ *

+ * Testing Considerations: + * For unit tests, you may need to reset the singleton: + *

+     * // In test teardown (requires reflection or test-only setter)
+     * @After
+     * public void tearDown() {
+     *     // Close database
+     *     if (AppDatabase.INSTANCE != null) {
+     *         AppDatabase.INSTANCE.close();
+     *         // Reset singleton (would need package-private setter)
+     *         AppDatabase.INSTANCE = null;
+     *     }
+     * }
+     * 
+ *

+ *

+ * Why Singleton: + * Using a singleton for the database instance provides: + *

    + *
  • Single connection pool shared across the app
  • + *
  • Consistent data state across all components
  • + *
  • Reduced memory overhead (no duplicate instances)
  • + *
  • Prevention of conflicting database access
  • + *
  • Simplified database access (no need to pass instance around)
  • + *
+ *

+ *

+ * Thread Safety Analysis: + * The volatile keyword combined with synchronized block in {@link #getDatabase(Context)} + * ensures thread-safe lazy initialization: + *

+     * // Thread 1 checks: INSTANCE == null (true)
+     * // Thread 1 enters synchronized block
+     * // Thread 1 creates database instance
+     * // Thread 1 assigns to INSTANCE (volatile write)
+     * 
+     * // Thread 2 checks: INSTANCE == null (false, sees Thread 1's write)
+     * // Thread 2 returns existing instance without entering synchronized block
+     * 
+ *

+ * + * @see #getDatabase(Context) + * @see RoomDatabase + */ + private static volatile AppDatabase INSTANCE; + + /** + * Gets the singleton instance of the AppDatabase, creating it if necessary. + *

+ * This method implements the thread-safe double-checked locking pattern to ensure + * only one database instance is created, even when called simultaneously from + * multiple threads. The method is safe to call from any thread and can be invoked + * from any component (Activity, Fragment, Service, etc.). + *

+ *

+ * Double-Checked Locking Pattern: + * The implementation uses two null checks to optimize performance: + *

    + *
  1. First check (unsynchronized): Fast path for when instance exists. + * Avoids expensive synchronization on subsequent calls.
  2. + *
  3. Synchronized block: Ensures only one thread creates the instance. + * Prevents race conditions during initialization.
  4. + *
  5. Second check (synchronized): Prevents multiple creation if several + * threads passed the first check simultaneously.
  6. + *
+ *

+ *

+ * Usage Example: + *

+     * // From Activity
+     * public class MainActivity extends AppCompatActivity {
+     *     @Override
+     *     protected void onCreate(Bundle savedInstanceState) {
+     *         super.onCreate(savedInstanceState);
+     *         
+     *         // Get database instance
+     *         AppDatabase db = AppDatabase.getDatabase(this);
+     *         
+     *         // Use DAOs for database operations
+     *         new Thread(() -> {
+     *             List<Player> players = db.playerDao().getAllPlayers();
+     *             runOnUiThread(() -> displayPlayers(players));
+     *         }).start();
+     *     }
+     * }
+     * 
+     * // From Fragment
+     * AppDatabase db = AppDatabase.getDatabase(requireContext());
+     * 
+     * // From Service
+     * AppDatabase db = AppDatabase.getDatabase(getApplicationContext());
+     * 
+     * // From anywhere with Context
+     * AppDatabase db = AppDatabase.getDatabase(context);
+     * 
+ *

+ *

+ * Context Parameter: + * The method accepts any Context but internally uses {@code context.getApplicationContext()} + * to avoid memory leaks. This means: + *

    + *
  • Activity context is safe to pass (converted to app context)
  • + *
  • Fragment context is safe to pass (converted to app context)
  • + *
  • Application context is ideal but not required
  • + *
  • No risk of leaking Activity or Fragment references
  • + *
+ *

+ *

+ * Database Builder Configuration: + * The database is created with these settings: + *

+     * Room.databaseBuilder(
+     *     context.getApplicationContext(),          // App context to prevent leaks
+     *     AppDatabase.class,                        // Database class
+     *     "oche_companion_db"                       // Database file name
+     * )
+     * .fallbackToDestructiveMigration()            // Drop tables on version change
+     * .build();
+     * 
+ *

+ *

+ * Destructive Migration Strategy: + * The {@code fallbackToDestructiveMigration()} setting means: + *

    + *
  • Advantage: No need to write migration code during development
  • + *
  • Disadvantage: All data is lost when database version changes
  • + *
  • Development: Acceptable for testing and iteration
  • + *
  • Production: Should be replaced with proper migrations: + *
    .addMigrations(MIGRATION_1_2, MIGRATION_2_3)
    + *
  • + *
+ *

+ *

+ * First Call Initialization: + * On the first call, this method will: + *

    + *
  1. Create the SQLite database file in app's private storage
  2. + *
  3. Create all tables defined by entity classes (players, matches)
  4. + *
  5. Set up indices and constraints
  6. + *
  7. Initialize Room's internal structures
  8. + *
  9. Return the ready-to-use database instance
  10. + *
+ * 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. + *

+ *

+ * Subsequent Calls: + * After initialization, subsequent calls: + *

    + *
  • Return immediately (fast path, no synchronization)
  • + *
  • No database access or initialization overhead
  • + *
  • Thread-safe from all threads
  • + *
  • Essentially free performance-wise
  • + *
+ *

+ *

+ * Thread Safety Guarantee: + *

+     * // Safe to call from multiple threads simultaneously
+     * Thread thread1 = new Thread(() -> {
+     *     AppDatabase db = AppDatabase.getDatabase(context);
+     *     db.playerDao().insert(player1);
+     * });
+     * 
+     * Thread thread2 = new Thread(() -> {
+     *     AppDatabase db = AppDatabase.getDatabase(context);
+     *     db.playerDao().insert(player2);
+     * });
+     * 
+     * thread1.start();
+     * thread2.start();
+     * // Both threads will use the same database instance
+     * 
+ *

+ *

+ * Best Practices: + *

    + *
  • Call early in app lifecycle (Application.onCreate()) to avoid first-call delay
  • + *
  • Use application context when possible for clarity
  • + *
  • No need to cache the returned instance (method is fast)
  • + *
  • Safe to call repeatedly throughout the app
  • + *
+ *

+ *

+ * Proactive Initialization: + *

+     * public class OcheCompanionApplication extends Application {
+     *     @Override
+     *     public void onCreate() {
+     *         super.onCreate();
+     *         
+     *         // Initialize database early to avoid delay on first access
+     *         AppDatabase.getDatabase(this);
+     *     }
+     * }
+     * 
+ *

+ *

+ * Error Handling: + * In rare cases, database creation might fail due to: + *

    + *
  • Insufficient storage space
  • + *
  • Corrupted database file
  • + *
  • File system errors
  • + *
+ * Room will throw RuntimeException in these cases. Consider adding error handling: + *
+     * try {
+     *     AppDatabase db = AppDatabase.getDatabase(context);
+     *     // Use database
+     * } catch (Exception e) {
+     *     Log.e(TAG, "Failed to open database", e);
+     *     // Show error to user, attempt recovery, etc.
+     * }
+     * 
+ *

+ * + * @param context The application context used to create the database. While any + * Context type can be passed (Activity, Fragment, Service, etc.), + * the method internally uses {@code context.getApplicationContext()} + * to prevent memory leaks. Must not be null. + * @return The singleton AppDatabase instance, fully initialized and ready for use. + * Never returns null. The same instance is returned on all subsequent calls. + * @throws IllegalArgumentException if context is null (thrown by Room.databaseBuilder) + * @throws RuntimeException if database creation fails due to filesystem or other errors + * @see Room#databaseBuilder(Context, Class, String) + * @see RoomDatabase.Builder#fallbackToDestructiveMigration() + */ + public static AppDatabase getDatabase(final Context context) { + // First check (unsynchronized): Fast path when instance already exists + // Most calls will return here after first initialization + if (INSTANCE == null) { + // Synchronize on the class to ensure only one thread can create the instance + synchronized (AppDatabase.class) { + // Second check (synchronized): Prevent creation if another thread + // created the instance while we were waiting for the lock + if (INSTANCE == null) { + // Create the database instance + // Use application context to prevent memory leaks + INSTANCE = Room.databaseBuilder(context.getApplicationContext(), + AppDatabase.class, "oche_companion_db") + .fallbackToDestructiveMigration() // Drop tables on version change + .build(); + } + } + } + // Return the singleton instance (thread-safe due to volatile) + return INSTANCE; + } +} + + diff --git a/app/src/main/java/com/aldo/apps/ochecompanion/database/dao/MatchDao.java b/app/src/main/java/com/aldo/apps/ochecompanion/database/dao/MatchDao.java new file mode 100644 index 0000000..d5c28f5 --- /dev/null +++ b/app/src/main/java/com/aldo/apps/ochecompanion/database/dao/MatchDao.java @@ -0,0 +1,289 @@ +package com.aldo.apps.ochecompanion.database.dao; + +import androidx.room.Dao; +import androidx.room.Insert; +import androidx.room.Query; +import com.aldo.apps.ochecompanion.database.objects.Match; +import java.util.List; + +/** + * Data Access Object (DAO) interface for performing database operations on Match entities. + *

+ * This interface defines the contract for accessing and manipulating match data in the + * Room database. It provides methods for inserting new match records and querying match + * history. Room generates the implementation of this interface at compile time. + *

+ *

+ * Room Database Integration: + * The {@code @Dao} annotation indicates that this is a Room DAO interface. Room will + * automatically generate an implementation class that handles all the database operations, + * SQLite connections, cursor management, and data mapping. + *

+ *

+ * Key Features: + *

    + *
  • Insert completed match records into the database
  • + *
  • Retrieve complete match history sorted by most recent first
  • + *
  • Query the last played match for dashboard/recap displays
  • + *
  • Thread-safe operations managed by Room
  • + *
+ *

+ *

+ * Thread Safety: + * All database operations should be performed on a background thread to avoid blocking + * the main UI thread. Room enforces this for most operations on main thread and will + * throw an exception if database operations are attempted on the main thread (unless + * explicitly configured otherwise). + *

+ *

+ * Usage Example: + *

+ * // Get DAO instance from database
+ * MatchDao matchDao = AppDatabase.getDatabase(context).matchDao();
+ * 
+ * // Insert a new match (on background thread)
+ * new Thread(() -> {
+ *     matchDao.insert(newMatch);
+ * }).start();
+ * 
+ * // Query last match (on background thread)
+ * new Thread(() -> {
+ *     Match lastMatch = matchDao.getLastMatch();
+ *     // Use lastMatch on UI thread
+ *     runOnUiThread(() -> displayMatch(lastMatch));
+ * }).start();
+ * 
+ *

+ *

+ * Database Table: + * This DAO operates on the "matches" table, which is defined by the {@link Match} + * entity class with its {@code @Entity} annotation. + *

+ * + * @see Match + * @see Dao + * @see com.aldo.apps.ochecompanion.database.AppDatabase + * @author Oche Companion Development Team + * @version 1.0 + * @since 1.0 + */ +@Dao +public interface MatchDao { + + /** + * Inserts a completed match record into the database. + *

+ * This method persists a new match entry to the "matches" table. The match should + * represent a completed game with all necessary information (players, scores, timestamp, etc.). + * Room will handle the actual SQL INSERT operation and auto-increment primary keys if configured. + *

+ *

+ * Threading: + * This operation must be performed on a background thread. Attempting to call this + * on the main thread will result in an exception (unless Room is configured to allow + * main thread queries, which is not recommended). + *

+ *

+ * Transaction Behavior: + * By default, Room wraps this operation in a transaction. If the insert fails, the + * transaction will be rolled back, maintaining database consistency. + *

+ *

+ * Conflict Strategy: + * The default conflict strategy is {@code OnConflictStrategy.ABORT}, which means if + * a conflict occurs (e.g., primary key violation), the insert will fail with an exception. + * This can be customized by adding a parameter to the {@code @Insert} annotation. + *

+ *

+ * Return Value: + * While this method returns void, the {@code @Insert} annotation can be configured + * to return: + *

    + *
  • {@code long} - The row ID of the newly inserted match
  • + *
  • {@code Long} - Same as above, but nullable
  • + *
  • {@code long[]} or {@code Long[]} - For bulk inserts
  • + *
  • {@code List} - Alternative for bulk inserts
  • + *
+ *

+ *

+ * Usage Example: + *

+     * Match match = new Match(...);
+     * match.setTimestamp(System.currentTimeMillis());
+     * 
+     * new Thread(() -> {
+     *     try {
+     *         matchDao.insert(match);
+     *         // Match successfully saved
+     *     } catch (Exception e) {
+     *         // Handle insert failure
+     *         Log.e(TAG, "Failed to insert match", e);
+     *     }
+ * }).start();
+     * 
+ *

+ * + * @param match The Match entity to persist. Must not be null. Should contain all + * required fields including timestamp, player information, and scores. + * @throws IllegalStateException if called on the main thread (Room's default behavior) + * @throws SQLiteException if the database operation fails + * @see Insert + * @see Match + */ + @Insert + void insert(Match match); + + /** + * Retrieves all match records from the database, ordered by most recent first. + *

+ * This method queries the complete match history from the "matches" table and returns + * them in descending order by timestamp. The most recently played match will be the + * first element in the returned list. + *

+ *

+ * SQL Query: + * Executes: {@code SELECT * FROM matches ORDER BY timestamp DESC} + *

+ *

+ * Sorting: + * Matches are ordered by the "timestamp" field in descending order (DESC), meaning: + *

    + *
  • Index 0: Most recent match
  • + *
  • Index 1: Second most recent match
  • + *
  • Last index: Oldest match
  • + *
+ *

+ *

+ * Threading: + * This operation must be performed on a background thread. Attempting to call this + * on the main thread will result in an exception (unless Room is configured to allow + * main thread queries, which is not recommended). + *

+ *

+ * Performance Considerations: + *

    + *
  • This query loads ALL matches into memory, which could be problematic for + * large datasets (thousands of matches)
  • + *
  • Consider using pagination (e.g., {@code LIMIT} and {@code OFFSET}) for better + * performance with large history
  • + *
  • Consider using LiveData or Flow for reactive updates
  • + *
+ *

+ *

+ * Empty Result: + * If no matches exist in the database, this method returns an empty list (not null). + *

+ *

+ * Usage Example: + *

+     * new Thread(() -> {
+     *     List<Match> matches = matchDao.getAllMatches();
+     *     runOnUiThread(() -> {
+     *         if (matches.isEmpty()) {
+     *             showEmptyState();
+     *         } else {
+     *             displayMatchHistory(matches);
+     *         }
+     *     });
+     * }).start();
+     * 
+ *

+ *

+ * Suggested Improvements: + * Consider adding an index on the timestamp column for faster sorting: + *

+     * @Entity(tableName = "matches",
+     *        indices = {@Index(value = {"timestamp"}, name = "index_timestamp")})
+     * 
+ *

+ * + * @return A list of all Match entities sorted by timestamp in descending order + * (most recent first). Returns an empty list if no matches exist. + * Never returns null. + * @throws IllegalStateException if called on the main thread (Room's default behavior) + * @throws SQLiteException if the database query fails + * @see Query + * @see Match + * @see #getLastMatch() + */ + @Query("SELECT * FROM matches ORDER BY timestamp DESC") + List getAllMatches(); + + /** + * Retrieves the most recently played match from the database. + *

+ * This method is specifically designed for dashboard and recap displays where only + * the last match needs to be shown. It queries the "matches" table and returns + * just the single most recent match based on timestamp. + *

+ *

+ * SQL Query: + * Executes: {@code SELECT * FROM matches ORDER BY timestamp DESC LIMIT 1} + *

+ *

+ * Query Optimization: + * The {@code LIMIT 1} clause ensures that only one record is retrieved, making this + * significantly more efficient than {@link #getAllMatches()} when you only need the + * last match. The database can stop searching after finding the first result. + *

+ *

+ * Threading: + * This operation must be performed on a background thread. Attempting to call this + * on the main thread will result in an exception (unless Room is configured to allow + * main thread queries, which is not recommended). + *

+ *

+ * Null Return Value: + * This method returns {@code null} if no matches exist in the database. Callers + * must check for null before using the returned value: + *

+     * Match lastMatch = matchDao.getLastMatch();
+     * if (lastMatch != null) {
+     *     // Use the match
+     * } else {
+     *     // Show empty state
+     * }
+     * 
+ *

+ *

+ * Usage Example: + *

+     * new Thread(() -> {
+     *     Match lastMatch = matchDao.getLastMatch();
+     *     runOnUiThread(() -> {
+     *         if (lastMatch != null) {
+     *             matchRecapView.setMatch(lastMatch);
+     *         } else {
+     *             matchRecapView.setMatch(null); // Shows empty state
+     *         }
+     *     });
+     * }).start();
+     * 
+ *

+ *

+ * Use Cases: + *

    + *
  • Displaying the last match on the dashboard/main menu
  • + *
  • Showing a "play again" option with the most recent configuration
  • + *
  • Populating match recap views
  • + *
  • Checking if any matches have been played
  • + *
+ *

+ *

+ * Performance: + * This is an efficient query due to the {@code LIMIT 1} clause. If the timestamp + * column is indexed, performance will be excellent even with thousands of matches. + *

+ * + * @return The most recent Match entity based on timestamp, or {@code null} if + * no matches exist in the database. + * @throws IllegalStateException if called on the main thread (Room's default behavior) + * @throws SQLiteException if the database query fails + * @see Query + * @see Match + * @see #getAllMatches() + * @see com.aldo.apps.ochecompanion.ui.MatchRecapView#setMatch(com.aldo.apps.ochecompanion.models.Match) + */ + @Query("SELECT * FROM matches ORDER BY timestamp DESC LIMIT 1") + Match getLastMatch(); +} diff --git a/app/src/main/java/com/aldo/apps/ochecompanion/database/dao/PlayerDao.java b/app/src/main/java/com/aldo/apps/ochecompanion/database/dao/PlayerDao.java new file mode 100644 index 0000000..e6b0553 --- /dev/null +++ b/app/src/main/java/com/aldo/apps/ochecompanion/database/dao/PlayerDao.java @@ -0,0 +1,457 @@ +package com.aldo.apps.ochecompanion.database.dao; + +import androidx.room.Dao; +import androidx.room.Insert; +import androidx.room.Query; +import androidx.room.Update; + +import com.aldo.apps.ochecompanion.database.objects.Player; + +import java.util.List; + +/** + * Data Access Object (DAO) interface for performing database operations on Player entities. + *

+ * This interface defines the contract for accessing and manipulating player data in the + * Room database. It provides comprehensive CRUD (Create, Read, Update) operations for + * managing the squad roster. Room generates the implementation of this interface at + * compile time, handling all SQL generation, cursor management, and data mapping. + *

+ *

+ * Room Database Integration: + * The {@code @Dao} annotation marks this as a Room DAO interface. Room's annotation + * processor will automatically generate an implementation class that handles: + *

    + *
  • SQL query construction and execution
  • + *
  • SQLite connection management
  • + *
  • Result cursor handling and mapping to Player objects
  • + *
  • Transaction management and error handling
  • + *
+ *

+ *

+ * Key Features: + *

    + *
  • Insert: Add new players to the squad roster
  • + *
  • Update: Modify existing player information (username, profile picture, stats)
  • + *
  • Query by ID: Retrieve a specific player for editing
  • + *
  • Query All: Get the complete squad list, alphabetically sorted
  • + *
+ *

+ *

+ * Thread Safety: + * All database operations must be performed on a background thread to avoid blocking + * the main UI thread. Room enforces this requirement by default and will throw an + * {@link IllegalStateException} if database operations are attempted on the main thread + * (unless explicitly configured to allow it, which is not recommended). + *

+ *

+ * Usage Example: + *

+ * // Get DAO instance from database
+ * PlayerDao playerDao = AppDatabase.getDatabase(context).playerDao();
+ * 
+ * // Insert a new player (background thread)
+ * new Thread(() -> {
+ *     Player newPlayer = new Player("John Doe", "/path/to/pic.jpg");
+ *     playerDao.insert(newPlayer);
+ * }).start();
+ * 
+ * // Query all players (background thread)
+ * new Thread(() -> {
+ *     List<Player> players = playerDao.getAllPlayers();
+ *     runOnUiThread(() -> updateUI(players));
+ * }).start();
+ * 
+ * // Update existing player (background thread)
+ * new Thread(() -> {
+ *     Player player = playerDao.getPlayerById(playerId);
+ *     player.username = "New Name";
+ *     playerDao.update(player);
+ * }).start();
+ * 
+ *

+ *

+ * Database Table: + * This DAO operates on the "players" table, which is defined by the {@link Player} + * entity class with its {@code @Entity} annotation. + *

+ *

+ * Missing Operations: + * Note that this DAO does not currently include a DELETE operation. If player deletion + * is required, consider adding: + *

+ * @Delete
+ * void delete(Player player);
+ * 
+ * // or
+ * @Query("DELETE FROM players WHERE id = :playerId")
+ * void deleteById(int playerId);
+ * 
+ *

+ * + * @see Player + * @see Dao + * @see com.aldo.apps.ochecompanion.database.AppDatabase + * @author Oche Companion Development Team + * @version 1.0 + * @since 1.0 + */ +@Dao +public interface PlayerDao { + /** + * Inserts a new Player entity into the database. + *

+ * This method persists a new player to the "players" table, adding them to the + * squad roster. Room will handle the actual SQL INSERT operation and automatically + * generate a primary key ID for the player if the ID field is configured with + * {@code @PrimaryKey(autoGenerate = true)}. + *

+ *

+ * Threading: + * This operation must be performed on a background thread. Attempting to call this + * on the main thread will result in an {@link IllegalStateException} (unless Room + * is explicitly configured to allow main thread queries, which is not recommended). + *

+ *

+ * Transaction Behavior: + * By default, Room wraps this operation in a database transaction. If the insert + * fails for any reason, the transaction will be rolled back, ensuring database + * consistency and atomicity. + *

+ *

+ * Conflict Strategy: + * The default conflict strategy is {@code OnConflictStrategy.ABORT}, which means + * if a conflict occurs (e.g., duplicate primary key), the insert will fail with + * an exception. This can be customized by adding a parameter to the {@code @Insert} + * annotation: + *

+     * @Insert(onConflict = OnConflictStrategy.REPLACE)
+     * void insert(Player player);
+     * 
+ *

+ *

+ * Auto-Generated ID: + * After insertion, if the Player entity's ID field is auto-generated, the passed + * player object's ID field will be updated with the generated value (assuming the + * ID field is not final). + *

+ *

+ * Usage Example: + *

+     * Player newPlayer = new Player("Alice", "/path/to/profile.jpg");
+     * newPlayer.careerAverage = 45.5;
+     * 
+     * new Thread(() -> {
+     *     try {
+     *         playerDao.insert(newPlayer);
+     *         // After insert, newPlayer.id will contain the auto-generated ID
+     *         Log.d(TAG, "Inserted player with ID: " + newPlayer.id);
+     *     } catch (Exception e) {
+     *         Log.e(TAG, "Failed to insert player", e);
+     *     }
+     * }).start();
+     * 
+ *

+ *

+ * Validation: + * Ensure the player object contains all required fields before insertion: + *

    + *
  • Username should not be null or empty
  • + *
  • Profile picture URI can be null (default avatar will be used)
  • + *
  • Career average should be initialized (default to 0.0 if not set)
  • + *
+ *

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

+ * This method modifies an existing player record in the "players" table. Room + * identifies the player to update using the primary key ID field in the provided + * Player object. All fields of the player will be updated to match the provided values. + *

+ *

+ * Primary Key Matching: + * Room uses the {@code @PrimaryKey} field (typically "id") to identify which + * database row to update. The player object must have a valid ID that exists in + * the database, or the update will have no effect. + *

+ *

+ * Threading: + * This operation must be performed on a background thread. Attempting to call this + * on the main thread will result in an {@link IllegalStateException} (unless Room + * is explicitly configured to allow main thread queries, which is not recommended). + *

+ *

+ * Transaction Behavior: + * By default, Room wraps this operation in a database transaction. If the update + * fails, the transaction will be rolled back, preventing partial updates and + * maintaining database consistency. + *

+ *

+ * Conflict Strategy: + * The default conflict strategy is {@code OnConflictStrategy.ABORT}. This can be + * customized if needed: + *

+     * @Update(onConflict = OnConflictStrategy.REPLACE)
+     * void update(Player player);
+     * 
+ *

+ *

+ * Return Value: + * While this method returns void, the {@code @Update} annotation can be configured + * to return {@code int} indicating the number of rows updated (typically 1 for + * successful single-player updates, 0 if no matching player was found). + *

+ *

+ * Usage Example: + *

+     * // Typical update flow: query, modify, update
+     * new Thread(() -> {
+     *     // First, retrieve the player
+     *     Player player = playerDao.getPlayerById(playerId);
+     *     
+     *     if (player != null) {
+     *         // Modify the player's data
+     *         player.username = "Updated Name";
+     *         player.profilePictureUri = "/path/to/new/pic.jpg";
+     *         player.careerAverage = 52.3;
+     *         
+     *         // Update in database
+     *         playerDao.update(player);
+     *         
+     *         runOnUiThread(() -> {
+     *             Toast.makeText(context, "Player updated", Toast.LENGTH_SHORT).show();
+     *         });
+     *     }
+     * }).start();
+     * 
+ *

+ *

+ * Common Use Cases: + *

    + *
  • Updating player username after editing in {@link com.aldo.apps.ochecompanion.AddPlayerActivity}
  • + *
  • Changing profile picture after cropping and saving new image
  • + *
  • Updating career statistics after completing a match
  • + *
  • Modifying any player information through the edit interface
  • + *
+ *

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

+ * This method queries the "players" table for a player with the specified ID. + * It's primarily used when editing player information, as it provides the current + * player data to populate the edit form. + *

+ *

+ * SQL Query: + * Executes: {@code SELECT * FROM players WHERE id = :id LIMIT 1} + *

+ *

+ * Query Optimization: + * The {@code LIMIT 1} clause ensures that only one record is retrieved, even though + * the ID is a primary key and should be unique. This is a safeguard and optimization + * that tells the database to stop searching after finding the first match. + *

+ *

+ * Threading: + * This operation must be performed on a background thread. Attempting to call this + * on the main thread will result in an {@link IllegalStateException} (unless Room + * is explicitly configured to allow main thread queries, which is not recommended). + *

+ *

+ * Null Return Value: + * This method returns {@code null} if no player exists with the specified ID. + * Callers must always check for null before using the returned value: + *

+     * Player player = playerDao.getPlayerById(playerId);
+     * if (player != null) {
+     *     // Use the player
+     * } else {
+     *     // Handle player not found
+     * }
+     * 
+ *

+ *

+ * Usage Example: + *

+     * // Load player for editing
+     * int playerIdToEdit = getIntent().getIntExtra(EXTRA_PLAYER_ID, -1);
+     * 
+     * new Thread(() -> {
+     *     Player existingPlayer = playerDao.getPlayerById(playerIdToEdit);
+     *     
+     *     runOnUiThread(() -> {
+     *         if (existingPlayer != null) {
+     *             // Populate edit form with existing data
+     *             usernameEditText.setText(existingPlayer.username);
+     *             loadProfilePicture(existingPlayer.profilePictureUri);
+     *         } else {
+     *             // Player not found - show error or close activity
+     *             Toast.makeText(this, "Player not found", Toast.LENGTH_SHORT).show();
+     *             finish();
+     *         }
+     *     });
+     * }).start();
+     * 
+ *

+ *

+ * Common Use Cases: + *

    + *
  • Loading player data in {@link com.aldo.apps.ochecompanion.AddPlayerActivity} + * when editing an existing player
  • + *
  • Retrieving player information before updating
  • + *
  • Validating that a player exists before performing operations
  • + *
  • Displaying detailed player information in a profile view
  • + *
+ *

+ *

+ * Performance: + * This is an efficient query as it uses the primary key index. Lookup by ID is + * O(log n) or better, making it suitable for frequent calls. + *

+ * + * @param id The unique primary key ID of the player to retrieve. Should be a + * positive integer representing an existing player's ID. + * @return The Player object if found, or {@code null} if no player exists with + * the specified ID. + * @throws IllegalStateException if called on the main thread (Room's default behavior) + * @throws android.database.sqlite.SQLiteException if the database query fails + * @see Query + * @see Player + * @see #update(Player) + * @see com.aldo.apps.ochecompanion.AddPlayerActivity + */ + @Query("SELECT * FROM players WHERE id = :id LIMIT 1") + Player getPlayerById(int id); + + /** + * Retrieves all players from the database, ordered alphabetically by username. + *

+ * This method queries the complete player roster from the "players" table and + * returns them sorted alphabetically (A-Z) by username. This provides a consistent, + * user-friendly ordering for displaying the squad in lists and selection interfaces. + *

+ *

+ * SQL Query: + * Executes: {@code SELECT * FROM players ORDER BY username ASC} + *

+ *

+ * Sorting: + * Players are ordered by the "username" field in ascending alphabetical order (ASC): + *

    + *
  • Players with names starting with 'A' appear first
  • + *
  • Players with names starting with 'Z' appear last
  • + *
  • Case-insensitive sorting depends on database collation settings
  • + *
+ *

+ *

+ * Threading: + * This operation must be performed on a background thread. Attempting to call this + * on the main thread will result in an {@link IllegalStateException} (unless Room + * is explicitly configured to allow main thread queries, which is not recommended). + *

+ *

+ * Performance Considerations: + *

    + *
  • This query loads ALL players into memory at once
  • + *
  • For small to medium squads (10-100 players), this is efficient
  • + *
  • For very large datasets, consider pagination or filtering
  • + *
  • Consider using LiveData or Flow for automatic UI updates when data changes
  • + *
+ *

+ *

+ * Empty Result: + * If no players exist in the database, this method returns an empty list (not null). + * This makes it safe to iterate without null checking: + *

+     * List<Player> players = playerDao.getAllPlayers();
+     * for (Player player : players) {
+     *     // Process each player
+     * }
+     * 
+ *

+ *

+ * Usage Example: + *

+     * // Load squad for display in RecyclerView
+     * new Thread(() -> {
+     *     List<Player> allPlayers = playerDao.getAllPlayers();
+     *     
+     *     runOnUiThread(() -> {
+     *         if (allPlayers.isEmpty()) {
+     *             // Show empty state - encourage user to add players
+     *             showEmptySquadMessage();
+     *         } else {
+     *             // Update RecyclerView adapter with player list
+     *             playerAdapter.updatePlayers(allPlayers);
+     *         }
+     *     });
+     * }).start();
+     * 
+ *

+ *

+ * Common Use Cases: + *

    + *
  • Displaying the squad roster in {@link com.aldo.apps.ochecompanion.MainMenuActivity}
  • + *
  • Populating player selection lists when creating a new match
  • + *
  • Showing all players in management/roster views
  • + *
  • Calculating squad-wide statistics
  • + *
+ *

+ *

+ * Alternative Implementations: + * Consider using LiveData for automatic UI updates: + *

+     * @Query("SELECT * FROM players ORDER BY username ASC")
+     * LiveData<List<Player>> getAllPlayersLive();
+     * 
+ * Or Flow for Kotlin coroutines: + *
+     * @Query("SELECT * FROM players ORDER BY username ASC")
+     * Flow<List<Player>> getAllPlayersFlow();
+     * 
+ *

+ *

+ * Suggested Improvements: + * Consider adding an index on the username column for faster sorting: + *

+     * @Entity(tableName = "players",
+     *        indices = {@Index(value = {"username"}, name = "index_username")})
+     * 
+ *

+ * + * @return A list of all Player entities sorted alphabetically by username in + * ascending order (A-Z). Returns an empty list if no players exist. + * Never returns null. + * @throws IllegalStateException if called on the main thread (Room's default behavior) + * @throws android.database.sqlite.SQLiteException if the database query fails + * @see Query + * @see Player + * @see com.aldo.apps.ochecompanion.ui.adapter.MainMenuPlayerAdapter#updatePlayers(List) + */ + @Query("SELECT * FROM players ORDER BY username ASC") + List getAllPlayers(); +} diff --git a/app/src/main/java/com/aldo/apps/ochecompanion/database/objects/Match.java b/app/src/main/java/com/aldo/apps/ochecompanion/database/objects/Match.java new file mode 100644 index 0000000..a57c03c --- /dev/null +++ b/app/src/main/java/com/aldo/apps/ochecompanion/database/objects/Match.java @@ -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. + *

+ * This entity class stores comprehensive information about a finished darts match, + * including the game mode played, completion timestamp, participant count, and + * detailed performance data for all players involved. The Match entity serves as + * the primary record for match history and statistics tracking. + *

+ *

+ * Room Database Entity: + * The {@code @Entity} annotation designates this class as a Room database table. + * Match records are stored in the "matches" table and can be queried using + * {@link com.aldo.apps.ochecompanion.database.dao.MatchDao} methods. + *

+ *

+ * Serializable Interface: + * This class implements {@link Serializable} to allow Match objects to be passed + * between Android components using Intent extras or Bundle arguments. This is + * useful when navigating to match detail screens or sharing match data between + * activities. + *

+ *

+ * Data Structure: + * Match data is stored with the following components: + *

    + *
  • ID: Auto-generated primary key for database uniqueness
  • + *
  • Timestamp: Unix epoch time marking match completion
  • + *
  • Game Mode: The variant of darts played (e.g., "501", "301", "Cricket")
  • + *
  • Player Count: Number of participants (supports 1v1 and group matches)
  • + *
  • Participant Data: JSON-serialized performance metrics for all players
  • + *
+ *

+ *

+ * Participant Data Format: + * The {@code participantData} field contains a JSON array with detailed player performance: + *

+ * [
+ *   {
+ *     "id": 1,
+ *     "username": "John Doe",
+ *     "rank": 1,
+ *     "score": 501,
+ *     "average": 92.4,
+ *     "highestCheckout": 170
+ *   },
+ *   {
+ *     "id": 2,
+ *     "username": "Jane Smith",
+ *     "rank": 2,
+ *     "score": 420,
+ *     "average": 81.0,
+ *     "highestCheckout": 120
+ *   }
+ * ]
+ * 
+ * This structure allows for flexible storage of various statistics while maintaining + * database normalization (avoiding separate tables for each match-player relationship). + *

+ *

+ * Game Mode Support: + * The application supports multiple darts game variants: + *

    + *
  • 501: Standard countdown game starting at 501 points
  • + *
  • 301: Faster countdown game starting at 301 points
  • + *
  • Cricket: Number-based strategy game (15-20 and bulls)
  • + *
  • Around the Clock: Sequential number hitting game
  • + *
  • Custom game modes can be added by extending the gameMode field
  • + *
+ *

+ *

+ * Match Types: + * The system supports different match configurations: + *

    + *
  • 1v1 Matches: Two-player head-to-head games (playerCount = 2)
  • + *
  • Group Matches: Three or more players (playerCount >= 3)
  • + *
  • Solo Practice: Single player practice sessions (playerCount = 1)
  • + *
+ * The UI adapts based on player count, showing specialized layouts for each match type. + *

+ *

+ * Usage Example: + *

+ * // Create a new match record after game completion
+ * String participantJson = buildParticipantJson(players, finalScores, rankings);
+ * Match newMatch = new Match(
+ *     System.currentTimeMillis(),  // Current time
+ *     "501",                        // Game mode
+ *     2,                            // Two players
+ *     participantJson               // Serialized player data
+ * );
+ * 
+ * // Insert into database (on background thread)
+ * new Thread(() -> {
+ *     matchDao.insert(newMatch);
+ *     // After insert, newMatch.id will contain the auto-generated ID
+ * }).start();
+ * 
+ * // Query and display matches
+ * new Thread(() -> {
+ *     List<Match> recentMatches = matchDao.getAllMatches();
+ *     runOnUiThread(() -> updateMatchRecapUI(recentMatches));
+ * }).start();
+ * 
+ * // Pass match to detail activity
+ * Intent intent = new Intent(this, MatchDetailActivity.class);
+ * intent.putExtra("match_object", matchToView); // Uses Serializable
+ * startActivity(intent);
+ * 
+ *

+ *

+ * Database Relationships: + * While this entity doesn't use Room's {@code @Relation} annotations, it maintains + * logical relationships through the participant data: + *

    + *
  • Each match references multiple players via IDs in participantData JSON
  • + *
  • Player objects are stored separately in the "players" table
  • + *
  • The denormalized JSON approach optimizes read performance for match history
  • + *
  • Updates to player usernames won't automatically reflect in historical matches
  • + *
+ *

+ *

+ * Performance Considerations: + *

    + *
  • JSON parsing adds minimal overhead for typical match sizes (2-8 players)
  • + *
  • The timestamp field can be indexed for efficient chronological queries
  • + *
  • Consider paginating match history for users with hundreds of matches
  • + *
  • The Serializable interface has some overhead; consider Parcelable for better performance
  • + *
+ *

+ *

+ * Data Integrity: + *

    + *
  • The auto-generated ID ensures each match is uniquely identifiable
  • + *
  • Timestamp should always be positive and in milliseconds (Unix epoch)
  • + *
  • Player count should match the number of entries in participantData JSON
  • + *
  • Game mode string should be validated against supported game types
  • + *
+ *

+ *

+ * Future Enhancements: + * Consider adding these fields for expanded functionality: + *

    + *
  • duration: Match duration in seconds for time tracking
  • + *
  • location: Venue or location where match was played
  • + *
  • notes: User-added comments or observations
  • + *
  • isRanked: Boolean flag for competitive vs casual matches
  • + *
  • tournamentId: Reference to tournament entity for organized play
  • + *
+ *

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

+ * This field is auto-generated by Room when a new match is inserted into the + * database. The ID is automatically assigned by SQLite's AUTOINCREMENT mechanism, + * ensuring that each match has a unique, sequential identifier. + *

+ *

+ * Auto-Generation: + * The {@code @PrimaryKey(autoGenerate = true)} annotation tells Room to: + *

    + *
  • Automatically assign a unique ID when inserting a new match
  • + *
  • Increment the ID value for each subsequent insert
  • + *
  • Update the Match object's ID field after successful insertion
  • + *
  • Use this ID for update and delete operations
  • + *
+ *

+ *

+ * Initial Value: + * Before insertion, this field typically has a value of 0. After the match is + * inserted into the database, Room updates this field with the generated ID + * (usually starting from 1 and incrementing). + *

+ *

+ * Usage: + *

+     * Match match = new Match(timestamp, gameMode, playerCount, participantData);
+     * // match.id is 0 at this point
+     * 
+     * matchDao.insert(match);
+     * // match.id now contains the auto-generated value (e.g., 42)
+     * 
+     * // Use the ID for subsequent operations
+     * Match retrieved = matchDao.getLastMatch();
+     * Log.d(TAG, "Match ID: " + retrieved.id);
+     * 
+ *

+ *

+ * Uniqueness Guarantee: + * SQLite's AUTOINCREMENT ensures that IDs are never reused, even if matches + * are deleted. This prevents conflicts and maintains referential integrity + * if match IDs are stored externally. + *

+ * + * @see PrimaryKey + */ + @PrimaryKey(autoGenerate = true) + public int id; + + /** + * Unix epoch timestamp indicating when this match was completed. + *

+ * This field stores the precise moment the match ended, measured in milliseconds + * since January 1, 1970, 00:00:00 UTC (Unix epoch). The timestamp is used for: + *

    + *
  • Sorting matches chronologically in match history views
  • + *
  • Displaying relative time ("2 hours ago", "Yesterday")
  • + *
  • Filtering matches by date range
  • + *
  • Calculating statistics over time periods
  • + *
+ *

+ *

+ * Format: + * The timestamp is stored as a {@code long} value representing milliseconds. + * This is the standard Java/Android time format obtained via: + *

+     * long timestamp = System.currentTimeMillis();
+     * 
+ *

+ *

+ * Example Values: + *

    + *
  • 1737158400000L = January 17, 2025, 00:00:00 UTC
  • + *
  • 1704067200000L = January 1, 2024, 00:00:00 UTC
  • + *
+ *

+ *

+ * Conversion Examples: + *

+     * // Convert to Date object
+     * Date matchDate = new Date(match.timestamp);
+     * 
+     * // Format for display
+     * SimpleDateFormat sdf = new SimpleDateFormat("MMM dd, yyyy HH:mm", Locale.getDefault());
+     * String displayDate = sdf.format(matchDate);
+     * 
+     * // Calculate time ago
+     * long hoursAgo = (System.currentTimeMillis() - match.timestamp) / (1000 * 60 * 60);
+     * 
+     * // Use with Calendar
+     * Calendar calendar = Calendar.getInstance();
+     * calendar.setTimeInMillis(match.timestamp);
+     * int dayOfMonth = calendar.get(Calendar.DAY_OF_MONTH);
+     * 
+ *

+ *

+ * Sorting: + * Matches can be ordered by timestamp to show most recent first: + *

+     * @Query("SELECT * FROM matches ORDER BY timestamp DESC LIMIT 10")
+     * List<Match> getRecentMatches();
+     * 
+ *

+ *

+ * Validation: + * The timestamp should always be: + *

    + *
  • Positive (non-zero)
  • + *
  • Not in the future (unless testing)
  • + *
  • Reasonable (not before app creation date)
  • + *
+ *

+ *

+ * Timezone Considerations: + * While the timestamp is stored in UTC (Unix epoch), display formatting should + * consider the user's local timezone for proper date/time presentation. + *

+ * + * @see System#currentTimeMillis() + * @see java.util.Date + * @see java.text.SimpleDateFormat + */ + public long timestamp; + + /** + * The name or identifier of the game variant that was played in this match. + *

+ * This field specifies which darts game mode was used, allowing the application + * to display appropriate statistics, rules, and UI elements for that particular + * game type. The game mode determines scoring rules, win conditions, and the + * overall structure of the match. + *

+ *

+ * Supported Game Modes: + *

    + *
  • "501": The classic countdown game starting at 501 points. + * Players subtract their scores and must finish exactly on zero with a double.
  • + *
  • "301": A faster variant starting at 301 points, following + * the same rules as 501 but requiring quicker gameplay.
  • + *
  • "Cricket": Strategic game focusing on numbers 15-20 and + * the bullseye. Players must "close" numbers by hitting them three times.
  • + *
  • "Around the Clock": Sequential game where players must + * hit numbers 1-20 in order, then finish with a bullseye.
  • + *
  • "Killer": Multiplayer elimination game where each player + * has a designated number and tries to eliminate opponents.
  • + *
+ *

+ *

+ * String Format: + * Game mode strings should be: + *

    + *
  • Non-null and non-empty
  • + *
  • Consistent in naming (avoid "501", "Five-Oh-One", "501 Game" variations)
  • + *
  • Preferably using predefined constants to avoid typos
  • + *
+ *

+ *

+ * Recommended Usage: + *

+     * // Define constants for game modes
+     * public static final String GAME_MODE_501 = "501";
+     * public static final String GAME_MODE_301 = "301";
+     * public static final String GAME_MODE_CRICKET = "Cricket";
+     * 
+     * // Use constants when creating matches
+     * Match match = new Match(
+     *     System.currentTimeMillis(),
+     *     GAME_MODE_501,  // Instead of hardcoded "501"
+     *     2,
+     *     participantJson
+     * );
+     * 
+ *

+ *

+ * UI Adaptation: + * The game mode affects how matches are displayed: + *

+     * switch (match.gameMode) {
+     *     case "501":
+     *     case "301":
+     *         // Show countdown score display
+     *         // Highlight checkout attempts
+     *         break;
+     *     case "Cricket":
+     *         // Show cricket scoreboard with marks
+     *         // Display closed numbers
+     *         break;
+     *     case "Around the Clock":
+     *         // Show sequential progress indicator
+     *         break;
+     * }
+     * 
+ *

+ *

+ * Filtering and Statistics: + * Game mode enables targeted queries and statistics: + *

+     * // Get all 501 matches
+     * @Query("SELECT * FROM matches WHERE gameMode = '501'")
+     * List<Match> get501Matches();
+     * 
+     * // Calculate average score by game mode
+     * Map<String, Double> averagesByMode = calculateAveragesByGameMode();
+     * 
+ *

+ *

+ * Extensibility: + * New game modes can be added without schema changes: + *

    + *
  • Simply use a new string identifier
  • + *
  • Implement game-specific logic in the game engine
  • + *
  • Update UI to handle the new mode
  • + *
  • Ensure participant data JSON includes mode-specific metrics
  • + *
+ *

+ *

+ * Validation: + * Consider validating game mode before inserting matches: + *

+     * private static final Set<String> VALID_GAME_MODES = new HashSet<>(
+     *     Arrays.asList("501", "301", "Cricket", "Around the Clock", "Killer")
+     * );
+     * 
+     * if (!VALID_GAME_MODES.contains(gameMode)) {
+     *     throw new IllegalArgumentException("Invalid game mode: " + gameMode);
+     * }
+     * 
+ *

+ * + * @see com.aldo.apps.ochecompanion.ui.view.MatchRecapView#setMatch(Match) + */ + public String gameMode; + + /** + * The total number of players who participated in this match. + *

+ * This field indicates how many individuals competed in the match, which + * determines the match type and affects how the UI displays the results. + * The player count must match the number of player entries in the + * {@link #participantData} JSON array. + *

+ *

+ * Valid Range: + *

    + *
  • 1: Solo practice session (single player)
  • + *
  • 2: Head-to-head match (1v1 duel)
  • + *
  • 3+: Group match or multiplayer game
  • + *
+ * Typically ranges from 1 to 8 players, though the system can support more. + *

+ *

+ * Match Type Determination: + * The player count affects UI rendering and game logic: + *

+     * if (match.playerCount == 1) {
+     *     // Show solo practice UI
+     *     // Display personal stats only
+     *     // No ranking or comparison
+     * } else if (match.playerCount == 2) {
+     *     // Show 1v1 layout (MatchRecapView.setup1v1State)
+     *     // Display winner/loser clearly
+     *     // Show head-to-head comparison
+     * } else {
+     *     // Show group match layout (MatchRecapView.setupGroupState)
+     *     // Display ranked list of all players
+     *     // Show leaderboard-style results
+     * }
+     * 
+ *

+ *

+ * Data Consistency: + * The player count should always match the participant data: + *

+     * // Validate consistency
+     * JSONArray participants = new JSONArray(match.participantData);
+     * if (participants.length() != match.playerCount) {
+     *     Log.w(TAG, "Mismatch: playerCount=" + match.playerCount + 
+     *           " but participantData has " + participants.length() + " entries");
+     * }
+     * 
+ *

+ *

+ * Performance Implications: + *

    + *
  • Higher player counts require more complex UI layouts
  • + *
  • Sorting and ranking algorithms scale with player count
  • + *
  • JSON parsing time increases with more participants
  • + *
  • Consider pagination for matches with many players
  • + *
+ *

+ *

+ * Usage in Queries: + *

+     * // Get all 1v1 matches
+     * @Query("SELECT * FROM matches WHERE playerCount = 2")
+     * List<Match> getDuelMatches();
+     * 
+     * // Get group matches only
+     * @Query("SELECT * FROM matches WHERE playerCount >= 3")
+     * List<Match> getGroupMatches();
+     * 
+ *

+ *

+ * Statistical Analysis: + * Player count enables performance tracking by match type: + *

+     * // Calculate win rate in 1v1 matches
+     * double winRate1v1 = calculateWinRate(playerId, 2);
+     * 
+     * // Calculate average placement in group matches
+     * double avgPlacement = calculateAveragePlacement(playerId, 3);
+     * 
+ *

+ *

+ * Validation: + * Always validate player count before creating a match: + *

+     * if (playerCount < 1) {
+     *     throw new IllegalArgumentException("playerCount must be at least 1");
+     * }
+     * if (playerCount > MAX_PLAYERS) {
+     *     throw new IllegalArgumentException("Too many players: " + playerCount);
+     * }
+     * 
+ *

+ * + * @see com.aldo.apps.ochecompanion.ui.view.MatchRecapView#setup1v1State() + * @see com.aldo.apps.ochecompanion.ui.view.MatchRecapView#setupGroupState() + */ + public int playerCount; + + /** + * Serialized JSON string containing detailed performance data for all match participants. + *

+ * This field stores comprehensive information about each player's performance in + * the match, including their identity, final ranking, scores, and various statistics. + * Using JSON serialization allows flexible storage of different metrics without + * requiring separate database tables for each match-player relationship. + *

+ *

+ * JSON Structure: + * The participant data is stored as a JSON array of player objects: + *

+     * [
+     *   {
+     *     "id": 1,                    // Player database ID
+     *     "username": "John Doe",     // Player display name
+     *     "rank": 1,                  // Final placement (1 = winner)
+     *     "score": 501,               // Final score or points
+     *     "average": 92.4,            // Three-dart average
+     *     "highestCheckout": 170,     // Largest finish
+     *     "dartsThrown": 45,          // Total darts thrown
+     *     "profilePictureUri": "/path/to/pic.jpg"  // Profile image
+     *   },
+     *   {
+     *     "id": 2,
+     *     "username": "Jane Smith",
+     *     "rank": 2,
+     *     "score": 420,
+     *     "average": 81.0,
+     *     "highestCheckout": 120,
+     *     "dartsThrown": 51,
+     *     "profilePictureUri": "/path/to/pic2.jpg"
+     *   }
+     * ]
+     * 
+ *

+ *

+ * Required Fields: + * Each participant object must contain: + *

    + *
  • id: Player database ID for linking to Player entity
  • + *
  • username: Display name at time of match (snapshot)
  • + *
  • rank: Final placement (1 = winner, 2 = second, etc.)
  • + *
+ *

+ *

+ * Optional Fields: + * Additional metrics that may be included: + *

    + *
  • score: Final score or remaining points
  • + *
  • average: Three-dart average throughout the match
  • + *
  • highestCheckout: Largest checkout/finish
  • + *
  • dartsThrown: Total number of darts thrown
  • + *
  • profilePictureUri: Profile image path (snapshot)
  • + *
  • 180s: Number of maximum scores (180) hit
  • + *
  • checkoutPercentage: Success rate on finish attempts
  • + *
+ *

+ *

+ * Parsing Example: + *

+     * try {
+     *     JSONArray participants = new JSONArray(match.participantData);
+     *     
+     *     for (int i = 0; i < participants.length(); i++) {
+     *         JSONObject player = participants.getJSONObject(i);
+     *         
+     *         int playerId = player.getInt("id");
+     *         String username = player.getString("username");
+     *         int rank = player.getInt("rank");
+     *         double average = player.optDouble("average", 0.0);
+     *         
+     *         // Use the data to populate UI
+     *         displayPlayerResult(username, rank, average);
+     *     }
+     * } catch (JSONException e) {
+     *     Log.e(TAG, "Failed to parse participant data", e);
+     * }
+     * 
+ *

+ *

+ * Building Participant Data: + *

+     * // Create JSON array when match ends
+     * JSONArray participants = new JSONArray();
+     * 
+     * for (Player player : matchPlayers) {
+     *     JSONObject playerData = new JSONObject();
+     *     playerData.put("id", player.id);
+     *     playerData.put("username", player.username);
+     *     playerData.put("rank", player.finalRank);
+     *     playerData.put("score", player.finalScore);
+     *     playerData.put("average", player.calculateAverage());
+     *     playerData.put("highestCheckout", player.highestCheckout);
+     *     playerData.put("profilePictureUri", player.profilePictureUri);
+     *     
+     *     participants.put(playerData);
+     * }
+     * 
+     * String participantDataString = participants.toString();
+     * Match match = new Match(timestamp, gameMode, playerCount, participantDataString);
+     * 
+ *

+ *

+ * Why JSON Instead of Relations: + *

    + *
  • Performance: Single query retrieves complete match data
  • + *
  • Historical Accuracy: Captures player data as it was at match time
  • + *
  • Flexibility: Can store game-specific metrics without schema changes
  • + *
  • Simplicity: Avoids complex join queries and relationship management
  • + *
  • Immutability: Match data remains unchanged if player profiles are updated
  • + *
+ *

+ *

+ * Data Integrity: + *

    + *
  • Array length should match {@link #playerCount}
  • + *
  • Ranks should be sequential (1, 2, 3, ...) without gaps
  • + *
  • Player IDs should reference valid players (though not enforced by foreign key)
  • + *
  • JSON must be well-formed and parseable
  • + *
+ *

+ *

+ * Snapshot Advantage: + * Storing username and profile picture in the match data (rather than just ID) + * preserves the historical record. If a player later changes their username from + * "John Doe" to "JD_Pro", the old match will still show "John Doe" as they were + * known at that time. + *

+ *

+ * Sorting Participants: + * Usually pre-sorted by rank before serialization, but can be sorted after parsing: + *

+     * // Sort by rank after parsing
+     * Collections.sort(playerList, (p1, p2) -> 
+     *     Integer.compare(p1.rank, p2.rank));
+     * 
+ *

+ *

+ * Null Handling: + * This field should never be null. If a match has no valid participant data, + * consider using an empty array "[]" or not creating the match at all. + *

+ * + * @see org.json.JSONArray + * @see org.json.JSONObject + * @see com.aldo.apps.ochecompanion.database.objects.Player + */ + public String participantData; + + /** + * Constructs a new Match entity with the specified parameters. + *

+ * This constructor creates a complete match record ready for insertion into the + * database. The match ID will be auto-generated by Room upon insertion; there is + * no need to set it manually. All parameters are required to create a valid match. + *

+ *

+ * Constructor Parameters: + * Each parameter serves a specific purpose in defining the match: + *

    + *
  • timestamp: Records when the match was completed for chronological ordering
  • + *
  • gameMode: Identifies which darts variant was played
  • + *
  • playerCount: Specifies how many players participated
  • + *
  • participantData: Contains detailed performance data for all players
  • + *
+ *

+ *

+ * Usage Example: + *

+     * // Build participant data JSON
+     * JSONArray participants = new JSONArray();
+     * for (Player player : finalStandings) {
+     *     JSONObject playerData = new JSONObject();
+     *     playerData.put("id", player.id);
+     *     playerData.put("username", player.username);
+     *     playerData.put("rank", player.rank);
+     *     playerData.put("average", player.calculateAverage());
+     *     participants.put(playerData);
+     * }
+     * 
+     * // Create match object
+     * Match completedMatch = new Match(
+     *     System.currentTimeMillis(),        // Current time in milliseconds
+     *     "501",                             // Game mode identifier
+     *     2,                                 // Number of players
+     *     participants.toString()            // Serialized participant data
+     * );
+     * 
+     * // Insert into database (background thread required)
+     * new Thread(() -> {
+     *     matchDao.insert(completedMatch);
+     *     // completedMatch.id will now contain the auto-generated ID
+     *     Log.d(TAG, "Match saved with ID: " + completedMatch.id);
+     * }).start();
+     * 
+ *

+ *

+ * Parameter Validation: + * While the constructor doesn't enforce validation, consider checking parameters + * before construction: + *

+     * // Validate before creating match
+     * if (timestamp <= 0) {
+     *     throw new IllegalArgumentException("Invalid timestamp");
+     * }
+     * if (gameMode == null || gameMode.isEmpty()) {
+     *     throw new IllegalArgumentException("Game mode is required");
+     * }
+     * if (playerCount < 1) {
+     *     throw new IllegalArgumentException("At least one player required");
+     * }
+     * if (participantData == null || participantData.isEmpty()) {
+     *     throw new IllegalArgumentException("Participant data is required");
+     * }
+     * 
+     * // Validate JSON format
+     * try {
+     *     JSONArray test = new JSONArray(participantData);
+     *     if (test.length() != playerCount) {
+     *         throw new IllegalArgumentException("Player count mismatch");
+     *     }
+     * } catch (JSONException e) {
+     *     throw new IllegalArgumentException("Invalid JSON format", e);
+     * }
+     * 
+     * // Create match after validation
+     * Match match = new Match(timestamp, gameMode, playerCount, participantData);
+     * 
+ *

+ *

+ * Field Initialization: + * The constructor initializes all fields except {@code id}: + *

    + *
  • id: Remains 0 (default) until Room assigns auto-generated value
  • + *
  • timestamp: Set to the provided value (milliseconds since epoch)
  • + *
  • gameMode: Set to the provided game identifier string
  • + *
  • playerCount: Set to the provided player count
  • + *
  • participantData: Set to the provided JSON string
  • + *
+ *

+ *

+ * Database Insertion: + * After construction, insert the match using the DAO: + *

+     * // Get DAO instance
+     * MatchDao matchDao = AppDatabase.getDatabase(context).matchDao();
+     * 
+     * // Insert on background thread (required by Room)
+     * ExecutorService executor = Executors.newSingleThreadExecutor();
+     * executor.execute(() -> {
+     *     matchDao.insert(completedMatch);
+     *     
+     *     // Update UI on main thread
+     *     runOnUiThread(() -> {
+     *         Toast.makeText(context, "Match saved!", Toast.LENGTH_SHORT).show();
+     *         refreshMatchHistory();
+     *     });
+     * });
+     * 
+ *

+ *

+ * Immutability Consideration: + * Once created and inserted, match data should generally be treated as immutable + * (historical record). If corrections are needed, consider whether to: + *

    + *
  • Update the existing match (rare, only for data corrections)
  • + *
  • Delete and recreate (for significant errors)
  • + *
  • Leave as-is and add a note field (preserves history)
  • + *
+ *

+ *

+ * Thread Safety: + * The constructor itself is thread-safe, but database insertion must occur on + * a background thread due to Room's main thread restrictions. + *

+ * + * @param timestamp The Unix epoch timestamp in milliseconds indicating when the match + * was completed. Should be positive and not in the future. Typically + * obtained via {@link System#currentTimeMillis()}. + * @param gameMode The identifier for the darts game variant played (e.g., "501", + * "301", "Cricket"). Should match one of the supported game modes. + * Must not be null or empty. + * @param playerCount The total number of players who participated in the match. + * Must be at least 1. Should match the number of entries in + * the participantData JSON array. + * @param participantData A JSON-formatted string containing an array of player + * performance objects. Each object should include player ID, + * username, rank, and relevant statistics. Must be valid JSON + * and must not be null or empty. + * @see #timestamp + * @see #gameMode + * @see #playerCount + * @see #participantData + * @see com.aldo.apps.ochecompanion.database.dao.MatchDao#insert(Match) + */ + public Match(final long timestamp, final String gameMode, final int playerCount, final String participantData) { + // Initialize all fields with provided values + // The id field remains at default value (0) and will be auto-generated by Room upon insertion + this.timestamp = timestamp; + this.gameMode = gameMode; + this.playerCount = playerCount; + this.participantData = participantData; + } +} diff --git a/app/src/main/java/com/aldo/apps/ochecompanion/database/objects/Player.java b/app/src/main/java/com/aldo/apps/ochecompanion/database/objects/Player.java new file mode 100644 index 0000000..eff9be9 --- /dev/null +++ b/app/src/main/java/com/aldo/apps/ochecompanion/database/objects/Player.java @@ -0,0 +1,1114 @@ +package com.aldo.apps.ochecompanion.database.objects; + +import android.os.Parcel; +import android.os.Parcelable; + +import androidx.room.Entity; +import androidx.room.PrimaryKey; + +/** + * Represents a player entity in the Oche Companion darts application. + *

+ * This entity class stores comprehensive information about each player in the squad, + * including their identity, profile picture, and career statistics. Player objects + * serve as the foundation for roster management, match participation tracking, and + * statistical analysis throughout the application. + *

+ *

+ * Room Database Entity: + * The {@code @Entity} annotation designates this class as a Room database table. + * Player records are stored in the "players" table and can be queried, inserted, + * updated, and managed using {@link com.aldo.apps.ochecompanion.database.dao.PlayerDao} + * methods. + *

+ *

+ * Data Structure: + * Each player record contains: + *

    + *
  • ID: Auto-generated unique identifier for database operations
  • + *
  • Username: Display name shown throughout the application
  • + *
  • Profile Picture URI: File path to the player's avatar image
  • + *
  • Career Average: Overall three-dart average across all matches
  • + *
  • Matches Played: Total count of completed matches
  • + *
+ *

+ *

+ * Player Lifecycle: + *

    + *
  1. Creation: Player is created via {@link com.aldo.apps.ochecompanion.AddPlayerActivity} + * with username and optional profile picture
  2. + *
  3. Roster Display: Players appear in the squad list on the main menu
  4. + *
  5. Match Participation: Players are selected for matches, their performance + * is recorded
  6. + *
  7. Statistics Update: Career stats are updated after each match completion
  8. + *
  9. Profile Editing: Username and picture can be modified through edit interface
  10. + *
+ *

+ *

+ * Usage Example: + *

+ * // Create a new player
+ * Player newPlayer = new Player("John Doe", "/path/to/profile.jpg");
+ * newPlayer.careerAverage = 85.5;  // Optional initial stats
+ * newPlayer.matchesPlayed = 0;     // Starts at 0 by default
+ * 
+ * // Insert into database (background thread required)
+ * new Thread(() -> {
+ *     playerDao.insert(newPlayer);
+ *     // After insert, newPlayer.id contains the auto-generated ID
+ *     Log.d(TAG, "Player created with ID: " + newPlayer.id);
+ * }).start();
+ * 
+ * // Query and display players
+ * new Thread(() -> {
+ *     List<Player> allPlayers = playerDao.getAllPlayers();
+ *     runOnUiThread(() -> updateSquadUI(allPlayers));
+ * }).start();
+ * 
+ * // Update player statistics after match
+ * new Thread(() -> {
+ *     Player player = playerDao.getPlayerById(playerId);
+ *     player.matchesPlayed++;
+ *     player.careerAverage = calculateNewAverage(player, matchAverage);
+ *     playerDao.update(player);
+ * }).start();
+ * 
+ *

+ *

+ * Profile Picture Management: + * Profile pictures are stored as URI strings pointing to local files in the app's + * private storage. This approach: + *

    + *
  • Avoids bloating the database with binary data
  • + *
  • Allows efficient image loading with Glide or Picasso
  • + *
  • Enables easy image replacement without database updates
  • + *
  • Supports null values for players without custom avatars (default avatar shown)
  • + *
+ *

+ *

+ * Career Statistics: + * The {@code careerAverage} field tracks the player's overall performance: + *

    + *
  • Calculated as three-dart average across all completed matches
  • + *
  • Updated after each match using weighted or running average algorithm
  • + *
  • Displayed in player cards and comparison views
  • + *
  • Used for skill-based matchmaking or handicap calculations
  • + *
+ *

+ *

+ * Match Count Tracking: + * The {@code matchesPlayed} counter: + *

    + *
  • Increments by 1 after each completed match
  • + *
  • Provides context for statistical significance (100 matches vs 5 matches)
  • + *
  • Enables filtering of experienced vs. new players
  • + *
  • Used in achievement and milestone tracking
  • + *
+ *

+ *

+ * Database Relationships: + * While this entity doesn't use explicit Room relations, players are referenced in: + *

    + *
  • {@link Match} entities via player IDs in participantData JSON
  • + *
  • RecyclerView adapters for squad and player selection displays
  • + *
  • Match creation flows where players are chosen from the roster
  • + *
+ *

+ *

+ * Thread Safety: + * All database operations on Player objects must be performed on background threads + * to comply with Room's threading requirements. Direct field access is not + * thread-safe; use proper synchronization if accessing from multiple threads. + *

+ *

+ * Validation Considerations: + * When creating or updating players, consider validating: + *

    + *
  • Username is not null or empty
  • + *
  • Username length is within reasonable bounds (e.g., 1-30 characters)
  • + *
  • Profile picture URI points to a valid, accessible file (if not null)
  • + *
  • Career average is non-negative and realistic (typically 0-120 for darts)
  • + *
  • Matches played count is non-negative
  • + *
+ *

+ *

+ * Future Enhancements: + * Consider adding these fields for expanded functionality: + *

    + *
  • wins/losses: Win-loss record tracking
  • + *
  • highestCheckout: Best finish/checkout across career
  • + *
  • favourite Game Mode: Most-played game variant
  • + *
  • createdDate: Timestamp when player was added to squad
  • + *
  • nickname: Alternative display name or alias
  • + *
  • skillRating: ELO or skill-based rating system
  • + *
+ *

+ *

+ * UI Integration: + * Players are displayed in various UI components: + *

    + *
  • {@link com.aldo.apps.ochecompanion.ui.adapter.MainMenuPlayerAdapter} - Squad roster grid
  • + *
  • {@link com.aldo.apps.ochecompanion.ui.view.PlayerItemView} - Individual player cards
  • + *
  • {@link com.aldo.apps.ochecompanion.AddPlayerActivity} - Player creation/editing form
  • + *
  • Match setup screens - Player selection for new matches
  • + *
+ *

+ *

+ * Performance Notes: + *

    + *
  • Player queries are fast due to indexed primary key (ID)
  • + *
  • Consider adding index on username for search functionality: + * {@code @Entity(indices = {@Index(value = {"username"})})}
  • + *
  • Profile picture URIs keep queries lightweight compared to storing images directly
  • + *
  • The simple schema makes inserts, updates, and queries efficient
  • + *
+ *

+ * + * @see com.aldo.apps.ochecompanion.database.dao.PlayerDao + * @see com.aldo.apps.ochecompanion.database.objects.Match + * @see com.aldo.apps.ochecompanion.AddPlayerActivity + * @see com.aldo.apps.ochecompanion.ui.adapter.MainMenuPlayerAdapter + * @see com.aldo.apps.ochecompanion.ui.view.PlayerItemView + * @see Entity + * @author Oche Companion Development Team + * @version 1.0 + * @since 1.0 + */ +@Entity(tableName = "players") +public class Player implements Parcelable { + /** + * The unique primary key identifier for this player in the database. + *

+ * This field serves as the player's permanent identifier throughout the application + * and is used for all database operations, match participation tracking, and + * cross-referencing with match records. + *

+ *

+ * Auto-Generation: + * The {@code @PrimaryKey(autoGenerate = true)} annotation instructs Room to: + *

    + *
  • Automatically assign a unique ID when a new player is inserted
  • + *
  • Increment the ID value sequentially (1, 2, 3, ...)
  • + *
  • Update the Player object's ID field after successful insertion
  • + *
  • Use this ID as the primary key for update and query operations
  • + *
+ *

+ *

+ * Initial State: + * Before database insertion, this field has a default value of 0. After the + * player is inserted via {@link com.aldo.apps.ochecompanion.database.dao.PlayerDao#insert(Player)}, + * Room automatically populates this field with the generated ID value. + *

+ *

+ * Usage Examples: + *

+     * // Before insertion
+     * Player player = new Player("Alice", "/path/to/pic.jpg");
+     * Log.d(TAG, "ID before insert: " + player.id);  // Output: 0
+     * 
+     * // Insert into database
+     * playerDao.insert(player);
+     * Log.d(TAG, "ID after insert: " + player.id);   // Output: 15 (or next available ID)
+     * 
+     * // Use ID to query specific player
+     * Player retrieved = playerDao.getPlayerById(player.id);
+     * 
+     * // Use ID in match participant data
+     * JSONObject participantData = new JSONObject();
+     * participantData.put("id", player.id);  // Reference this player in match
+     * 
+ *

+ *

+ * Uniqueness and Permanence: + * SQLite's AUTOINCREMENT mechanism ensures: + *

    + *
  • No two players will ever have the same ID
  • + *
  • IDs are never reused, even after player deletion
  • + *
  • ID sequence continues across app restarts
  • + *
  • Historical match references remain valid
  • + *
+ *

+ *

+ * Cross-References: + * The player ID is stored in: + *

    + *
  • {@link Match#participantData} JSON arrays to link match participants
  • + *
  • Intent extras when navigating to {@link com.aldo.apps.ochecompanion.AddPlayerActivity} + * for editing
  • + *
  • Adapter item positions for click handling and UI updates
  • + *
+ *

+ *

+ * Integer Range: + * Using {@code int} supports up to 2,147,483,647 players, which is far beyond + * any realistic squad size. If migrating to multi-user cloud sync in the future, + * consider using {@code long} or UUID strings for globally unique identifiers. + *

+ * + * @see PrimaryKey + * @see com.aldo.apps.ochecompanion.database.dao.PlayerDao#getPlayerById(int) + * @see com.aldo.apps.ochecompanion.database.dao.PlayerDao#insert(Player) + */ + @PrimaryKey(autoGenerate = true) + public int id; + + /** + * The display name of the player shown throughout the application. + *

+ * This field stores the player's chosen name or alias, which appears in squad + * lists, player selection screens, match results, leaderboards, and all other + * UI components where the player is identified. The username serves as the + * primary human-readable identifier for each player. + *

+ *

+ * Display Locations: + * The username is prominently shown in: + *

    + *
  • Squad roster grid on the main menu
  • + *
  • Player cards in {@link com.aldo.apps.ochecompanion.ui.view.PlayerItemView}
  • + *
  • Match recap views showing participant names
  • + *
  • Player selection lists when creating new matches
  • + *
  • Edit player form in {@link com.aldo.apps.ochecompanion.AddPlayerActivity}
  • + *
  • Match history and statistical comparisons
  • + *
+ *

+ *

+ * Character Guidelines: + * While not enforced by the database schema, usernames should ideally: + *

    + *
  • Be non-null and non-empty (required for meaningful identification)
  • + *
  • Contain 1-30 characters (reasonable display length)
  • + *
  • Support Unicode characters (international names, emojis)
  • + *
  • Avoid leading/trailing whitespace (trim before saving)
  • + *
  • Be unique within the squad (not enforced, but recommended for UX)
  • + *
+ *

+ *

+ * Validation Example: + *

+     * // Recommended validation before creating player
+     * private boolean isValidUsername(String username) {
+     *     if (username == null || username.trim().isEmpty()) {
+     *         showError("Username cannot be empty");
+     *         return false;
+     *     }
+     *     
+     *     String trimmed = username.trim();
+     *     if (trimmed.length() > 30) {
+     *         showError("Username too long (max 30 characters)");
+     *         return false;
+     *     }
+     *     
+     *     // Optional: Check for duplicate names
+     *     if (isUsernameTaken(trimmed)) {
+     *         showError("Username already exists in squad");
+     *         return false;
+     *     }
+     *     
+     *     return true;
+     * }
+     * 
+     * // Use validated username
+     * String validUsername = username.trim();
+     * Player player = new Player(validUsername, profilePicUri);
+     * 
+ *

+ *

+ * Editing Usernames: + * Players can change their username via the edit interface: + *

+     * // Load player for editing
+     * Player player = playerDao.getPlayerById(playerId);
+     * player.username = "New Name";
+     * playerDao.update(player);
+     * 
+     * // Note: Historical match records store username snapshots,
+     * // so past matches will still show the old name
+     * 
+ *

+ *

+ * Database Storage: + * Stored as TEXT in SQLite, supporting any valid UTF-8 string. Room handles + * all string encoding/decoding automatically. Consider adding an index if + * implementing username search: + *

+     * @Entity(tableName = "players",
+     *        indices = {@Index(value = {"username"}, name = "index_username")})
+     * 
+ *

+ *

+ * Null Handling: + * This field should never be null in practice. If a player somehow has a null + * username, the UI should display a placeholder like "Unnamed Player" or + * "Player #[ID]" to prevent blank spaces or crashes. + *

+ *

+ * Case Sensitivity: + * SQLite comparisons are case-insensitive by default for ASCII characters. + * "John" and "john" are considered different usernames, but queries like + * {@code WHERE username = 'john'} will match "JOHN" or "John" unless + * {@code COLLATE BINARY} is specified. + *

+ *

+ * International Support: + * The field fully supports Unicode characters, allowing names in any language: + *

    + *
  • Chinese: 王明
  • + *
  • Arabic: محمد
  • + *
  • Cyrillic: Иван
  • + *
  • Emoji: John 🎯
  • + *
+ * Ensure UI fonts support these characters for proper display. + *

+ * + * @see com.aldo.apps.ochecompanion.AddPlayerActivity + * @see com.aldo.apps.ochecompanion.ui.view.PlayerItemView#bind(Player) + */ + public String username; + + /** + * The file system path (URI) to the player's profile picture image. + *

+ * This field stores a string representation of the file path where the player's + * avatar or profile picture is located in the app's private storage. Rather than + * storing image data directly in the database (which would bloat it), we store + * only the path reference and load the image on-demand using image loading + * libraries like Glide. + *

+ *

+ * Storage Strategy: + * Profile pictures are stored as local files because: + *

    + *
  • Database Efficiency: Avoiding BLOB storage keeps database + * size small and queries fast
  • + *
  • Image Loading: Glide and Picasso efficiently cache and + * load images from file paths
  • + *
  • Easy Replacement: Updating the image file doesn't require + * database updates
  • + *
  • Memory Management: Images aren't loaded into memory until + * needed for display
  • + *
+ *

+ *

+ * File Path Format: + * The URI typically follows this pattern: + *

+     * /data/data/com.aldo.apps.ochecompanion/files/profile_pictures/player_123_20250128.jpg
+     * 
+ * Or uses content URI format: + *
+     * content://media/external/images/media/456
+     * 
+ *

+ *

+ * Image Creation Flow: + *

+     * // In AddPlayerActivity, after user crops image:
+     * File outputFile = new File(getFilesDir(), "profile_pictures/player_" + 
+     *                           System.currentTimeMillis() + ".jpg");
+     * 
+     * // Save cropped bitmap to file
+     * try (FileOutputStream out = new FileOutputStream(outputFile)) {
+     *     croppedBitmap.compress(Bitmap.CompressFormat.JPEG, 90, out);
+     * }
+     * 
+     * // Store file path in player object
+     * String profilePicUri = outputFile.getAbsolutePath();
+     * Player player = new Player(username, profilePicUri);
+     * playerDao.insert(player);
+     * 
+ *

+ *

+ * Loading Images with Glide: + *

+     * // In PlayerItemView or adapter
+     * if (player.profilePictureUri != null && !player.profilePictureUri.isEmpty()) {
+     *     // Load custom profile picture
+     *     Glide.with(context)
+     *          .load(new File(player.profilePictureUri))
+     *          .placeholder(R.drawable.default_avatar)
+     *          .error(R.drawable.default_avatar)
+     *          .circleCrop()
+     *          .into(profileImageView);
+     * } else {
+     *     // Show default avatar
+     *     profileImageView.setImageResource(R.drawable.default_avatar);
+     * }
+     * 
+ *

+ *

+ * Null and Empty Values: + * This field can be null or empty, indicating the player has no custom profile + * picture. In such cases, the UI should display a default avatar image: + *

    + *
  • Generic silhouette or icon
  • + *
  • Player's initials in a colored circle
  • + *
  • Default darts-themed avatar
  • + *
+ *

+ *

+ * File Management: + * Consider these file management practices: + *

    + *
  • Deletion: When a player is deleted or changes their picture, + * clean up old image files to prevent storage bloat
  • + *
  • Validation: Verify file exists before attempting to load
  • + *
  • Error Handling: Gracefully fall back to default avatar if + * file is missing or corrupted
  • + *
  • Format: Support common formats (JPEG, PNG, WebP)
  • + *
+ *

+ *

+ * File Cleanup Example: + *

+     * // When updating player's profile picture
+     * Player player = playerDao.getPlayerById(playerId);
+     * String oldPicturePath = player.profilePictureUri;
+     * 
+     * // Set new picture
+     * player.profilePictureUri = newPicturePath;
+     * playerDao.update(player);
+     * 
+     * // Delete old picture file
+     * if (oldPicturePath != null && !oldPicturePath.isEmpty()) {
+     *     File oldFile = new File(oldPicturePath);
+     *     if (oldFile.exists()) {
+     *         oldFile.delete();
+     *     }
+     * }
+     * 
+ *

+ *

+ * Image Optimization: + * Profile pictures should be: + *

    + *
  • Resized to reasonable dimensions (e.g., 500x500 pixels max)
  • + *
  • Compressed to balance quality and file size (JPEG quality 85-90)
  • + *
  • Cropped to square aspect ratio for consistent display
  • + *
  • Stored in app's private storage for security and isolation
  • + *
+ *

+ *

+ * Migration Consideration: + * If implementing cloud sync in the future, consider: + *

    + *
  • Uploading images to cloud storage (Firebase Storage, S3)
  • + *
  • Storing cloud URLs instead of local file paths
  • + *
  • Implementing offline caching with local fallbacks
  • + *
  • Supporting both local and remote image sources
  • + *
+ *

+ * + * @see com.aldo.apps.ochecompanion.AddPlayerActivity + * @see com.aldo.apps.ochecompanion.ui.view.PlayerItemView#bind(Player) + * @see com.bumptech.glide.Glide + */ + public String profilePictureUri; + + /** + * The player's career-long three-dart average across all completed matches. + *

+ * This field represents the player's overall skill level and consistency, + * calculated as the average score per three darts thrown throughout their + * entire match history. In darts, the three-dart average is the standard + * metric for measuring player performance and ability. + *

+ *

+ * Calculation Method: + * The career average is typically calculated using one of these approaches: + *

    + *
  • Running Average: (Total points scored) / (Total darts thrown / 3)
  • + *
  • Weighted Average: Recent matches weighted more heavily than older ones
  • + *
  • Match Average of Averages: Average of individual match averages
  • + *
+ *

+ *

+ * Update Example: + *

+     * // After a match completes, update player's career average
+     * Player player = playerDao.getPlayerById(playerId);
+     * 
+     * // Method 1: Simple running average
+     * double totalPoints = player.careerAverage * player.matchesPlayed;
+     * totalPoints += matchAverage;  // Add this match's average
+     * player.matchesPlayed++;
+     * player.careerAverage = totalPoints / player.matchesPlayed;
+     * 
+     * // Method 2: Weighted average (70% old, 30% new)
+     * player.careerAverage = (player.careerAverage * 0.7) + (matchAverage * 0.3);
+     * player.matchesPlayed++;
+     * 
+     * playerDao.update(player);
+     * 
+ *

+ *

+ * Typical Value Ranges: + * Three-dart averages in darts typically fall within these ranges: + *

    + *
  • 0-40: Beginner player, still learning fundamentals
  • + *
  • 40-60: Casual player, recreational skill level
  • + *
  • 60-80: Experienced player, competitive at local level
  • + *
  • 80-100: Advanced player, regional competitive level
  • + *
  • 100+: Professional or semi-professional level
  • + *
+ * World-class professionals maintain averages of 100-105+. + *

+ *

+ * Display Format: + *

+     * // Format for display in UI
+     * String displayAverage = String.format(Locale.getDefault(), "%.1f", 
+     *                                      player.careerAverage);
+     * // Example output: "85.3"
+     * 
+     * // Or with context
+     * String statText = "Average: " + displayAverage + " ppd";
+     * // Example output: "Average: 85.3 ppd" (points per dart)
+     * 
+ *

+ *

+ * Default Value: + * Initialized to 0.0 by default, indicating no match history yet. When displaying + * a player with 0.0 average and 0 matches played, consider showing placeholder + * text like "No matches yet" or "N/A" instead of "0.0". + *

+ *

+ * Statistical Significance: + * The reliability of the career average increases with match count: + *

+     * if (player.matchesPlayed < 5) {
+     *     // Low sample size - show with disclaimer
+     *     displayText = player.careerAverage + " (limited data)";
+     * } else if (player.matchesPlayed < 20) {
+     *     // Moderate sample size
+     *     displayText = player.careerAverage + " (" + player.matchesPlayed + " matches)";
+     * } else {
+     *     // Reliable sample size
+     *     displayText = String.valueOf(player.careerAverage);
+     * }
+     * 
+ *

+ *

+ * Comparison and Ranking: + * Used to rank players in leaderboards and skill-based groupings: + *

+     * // Sort players by skill level
+     * List<Player> rankedPlayers = new ArrayList<>(allPlayers);
+     * Collections.sort(rankedPlayers, (p1, p2) -> 
+     *     Double.compare(p2.careerAverage, p1.careerAverage));  // Descending
+     * 
+     * // Find players in similar skill range for balanced matches
+     * double targetAverage = 75.0;
+     * double tolerance = 10.0;
+     * List<Player> similarSkill = players.stream()
+     *     .filter(p -> Math.abs(p.careerAverage - targetAverage) <= tolerance)
+     *     .collect(Collectors.toList());
+     * 
+ *

+ *

+ * Data Integrity: + *

    + *
  • Should never be negative (minimum 0.0)
  • + *
  • Realistically shouldn't exceed 120 (theoretical maximum ~180)
  • + *
  • Should be 0.0 if matchesPlayed is 0
  • + *
  • Precision to 1-2 decimal places is sufficient
  • + *
+ *

+ *

+ * Alternative Metrics: + * Future enhancements might track additional averages: + *

    + *
  • First 9 darts average (excludes checkout phase)
  • + *
  • Per-game-mode averages (501 average, Cricket average)
  • + *
  • Recent form average (last 10 matches only)
  • + *
  • Peak average (highest match average achieved)
  • + *
+ *

+ * + * @see #matchesPlayed + * @see com.aldo.apps.ochecompanion.ui.view.PlayerItemView#bind(Player) + */ + public double careerAverage = 0.0; + + /** + * The total number of matches this player has completed. + *

+ * This counter tracks how many times the player has participated in and + * finished a match. It provides context for statistical significance, + * enables experience-based features, and supports achievement tracking. + * The count increases by 1 after each match completion, regardless of + * the outcome (win, loss, or placement in group matches). + *

+ *

+ * Update Pattern: + *

+     * // After completing a match
+     * Player player = playerDao.getPlayerById(playerId);
+     * 
+     * // Increment match count
+     * player.matchesPlayed++;
+     * 
+     * // Also update career average at the same time
+     * player.careerAverage = calculateNewAverage(player, matchAverage);
+     * 
+     * playerDao.update(player);
+     * 
+ *

+ *

+ * Default Value: + * Initialized to 0 by default, indicating a newly created player who hasn't + * yet participated in any matches. This is appropriate for new squad members + * added through {@link com.aldo.apps.ochecompanion.AddPlayerActivity}. + *

+ *

+ * Display Usage: + *

+     * // Show experience level in player profile
+     * String experienceText;
+     * if (player.matchesPlayed == 0) {
+     *     experienceText = "New Player";
+     * } else if (player.matchesPlayed == 1) {
+     *     experienceText = "1 match played";
+     * } else {
+     *     experienceText = player.matchesPlayed + " matches played";
+     * }
+     * 
+     * // Use for statistical context
+     * if (player.matchesPlayed < 10) {
+     *     showDisclaimer("Statistics based on limited match history");
+     * }
+     * 
+ *

+ *

+ * Experience-Based Features: + * Match count enables various gameplay features: + *

    + *
  • Achievements: Unlock badges at 10, 50, 100, 500 matches
  • + *
  • Ranking Eligibility: Require minimum matches for leaderboards
  • + *
  • Skill Confidence: More matches = more reliable average
  • + *
  • Veteran Status: Display special icons for experienced players
  • + *
+ *

+ *

+ * Statistical Significance Example: + *

+     * // Determine if statistics are reliable
+     * public String getSkillReliability(Player player) {
+     *     if (player.matchesPlayed == 0) {
+     *         return "No data";
+     *     } else if (player.matchesPlayed < 5) {
+     *         return "Very limited data";
+     *     } else if (player.matchesPlayed < 10) {
+     *         return "Limited data";
+     *     } else if (player.matchesPlayed < 30) {
+     *         return "Moderate data";
+     *     } else {
+     *         return "Reliable data";
+     *     }
+     * }
+     * 
+ *

+ *

+ * Filtering and Queries: + *

+     * // Get experienced players only
+     * @Query("SELECT * FROM players WHERE matchesPlayed >= 20 ORDER BY careerAverage DESC")
+     * List<Player> getExperiencedPlayers();
+     * 
+     * // Get players who need more matches for stats
+     * @Query("SELECT * FROM players WHERE matchesPlayed < 10")
+     * List<Player> getNewPlayers();
+     * 
+ *

+ *

+ * Data Consistency: + * The match count should align with the player's career average: + *

    + *
  • If matchesPlayed = 0, careerAverage should be 0.0
  • + *
  • If careerAverage > 0, matchesPlayed should be > 0
  • + *
  • Both values should increase together after each match
  • + *
+ *

+ *

+ * Validation: + *

+     * // Validate data consistency
+     * if (player.matchesPlayed < 0) {
+     *     Log.e(TAG, "Invalid match count: " + player.matchesPlayed);
+     *     player.matchesPlayed = 0;
+     * }
+     * 
+     * if (player.matchesPlayed == 0 && player.careerAverage != 0.0) {
+     *     Log.w(TAG, "Inconsistent data: 0 matches but non-zero average");
+     *     player.careerAverage = 0.0;
+     * }
+     * 
+ *

+ *

+ * Match Type Consideration: + * This counter increments regardless of match type: + *

    + *
  • 1v1 head-to-head matches
  • + *
  • Group matches (3+ players)
  • + *
  • Solo practice sessions (if tracked)
  • + *
  • Tournament games
  • + *
+ * Consider adding separate counters for different match types if detailed + * tracking is needed. + *

+ *

+ * UI Presentation: + * Match count is typically displayed alongside the career average: + *

+     * // In player card or profile
+     * averageText.setText(String.format("%.1f avg", player.careerAverage));
+     * matchesText.setText(player.matchesPlayed + " matches");
+     * 
+     * // Or combined
+     * statsText.setText(String.format("%.1f avg • %d matches", 
+     *                   player.careerAverage, player.matchesPlayed));
+     * 
+ *

+ *

+ * Maximum Value: + * Using {@code int} supports up to 2,147,483,647 matches, which is essentially + * unlimited for any realistic usage. Even playing 10 matches per day, it would + * take 588,000+ years to overflow. + *

+ * + * @see #careerAverage + * @see com.aldo.apps.ochecompanion.database.objects.Match + */ + public int matchesPlayed = 0; + + /** + * Constructs a new Player with the specified username and profile picture. + *

+ * This constructor creates a player object ready for insertion into the database. + * The player's ID will be auto-generated by Room upon insertion, and statistical + * fields (careerAverage, matchesPlayed) are initialized to their default values + * of 0.0 and 0 respectively. + *

+ *

+ * Required Parameters: + *

    + *
  • username: The player's display name (should be non-null)
  • + *
  • profilePictureUri: Path to profile image (can be null for + * default avatar)
  • + *
+ *

+ *

+ * Usage Examples: + *

+     * // Create player with custom profile picture
+     * String picturePath = "/data/data/app/files/profile_pics/player_123.jpg";
+     * Player player1 = new Player("John Doe", picturePath);
+     * 
+     * // Create player without profile picture (will use default avatar)
+     * Player player2 = new Player("Jane Smith", null);
+     * 
+     * // Create player with empty string (equivalent to null for display purposes)
+     * Player player3 = new Player("Bob Wilson", "");
+     * 
+     * // Insert into database (background thread required)
+     * new Thread(() -> {
+     *     playerDao.insert(player1);
+     *     // After insertion, player1.id contains auto-generated ID
+     *     // careerAverage is 0.0, matchesPlayed is 0
+     * }).start();
+     * 
+ *

+ *

+ * Parameter Validation: + * While the constructor doesn't enforce validation, it's recommended to validate + * parameters before construction: + *

+     * // Validate before creating player
+     * String trimmedName = username.trim();
+     * if (trimmedName.isEmpty()) {
+     *     throw new IllegalArgumentException("Username cannot be empty");
+     * }
+     * if (trimmedName.length() > 30) {
+     *     throw new IllegalArgumentException("Username too long (max 30 chars)");
+     * }
+     * 
+     * // Validate profile picture path if provided
+     * if (profilePictureUri != null && !profilePictureUri.isEmpty()) {
+     *     File pictureFile = new File(profilePictureUri);
+     *     if (!pictureFile.exists()) {
+     *         Log.w(TAG, "Profile picture file does not exist: " + profilePictureUri);
+     *         // Decide whether to use null or keep invalid path
+     *     }
+     * }
+     * 
+     * // Create player with validated data
+     * Player player = new Player(trimmedName, profilePictureUri);
+     * 
+ *

+ *

+ * Field Initialization: + * After construction, the player object has these values: + *

    + *
  • id: 0 (will be auto-generated on insert)
  • + *
  • username: Set to provided value
  • + *
  • profilePictureUri: Set to provided value (can be null)
  • + *
  • careerAverage: 0.0 (default initialization)
  • + *
  • matchesPlayed: 0 (default initialization)
  • + *
+ *

+ *

+ * Typical Creation Flow: + *

+     * // In AddPlayerActivity after user completes form
+     * 
+     * // 1. Get username from input
+     * String username = usernameEditText.getText().toString().trim();
+     * 
+     * // 2. Get profile picture path (or null if no picture selected)
+     * String profilePicUri = (croppedImageFile != null) ? 
+     *                        croppedImageFile.getAbsolutePath() : null;
+     * 
+     * // 3. Create player object
+     * final Player newPlayer = new Player(username, profilePicUri);
+     * 
+     * // 4. Insert into database on background thread
+     * new Thread(() -> {
+     *     playerDao.insert(newPlayer);
+     *     
+     *     runOnUiThread(() -> {
+     *         Toast.makeText(this, "Player added to squad!", Toast.LENGTH_SHORT).show();
+     *         finish();  // Return to main menu
+     *     });
+     * }).start();
+     * 
+ *

+ *

+ * Alternative Construction Approach: + * For more complex initialization, create and then modify: + *

+     * // Create base player
+     * Player player = new Player("Alice Johnson", profilePath);
+     * 
+     * // Set initial statistics if importing from another system
+     * player.careerAverage = 78.5;  // Imported average
+     * player.matchesPlayed = 42;     // Imported match count
+     * 
+     * // Insert with pre-populated stats
+     * playerDao.insert(player);
+     * 
+ *

+ *

+ * Null Handling: + * The constructor accepts null for profilePictureUri, which is valid and indicates + * no custom profile picture. The UI should display a default avatar in this case. + * However, passing null for username will create a player with a null name, which + * should be avoided as it causes UI issues. + *

+ *

+ * Database Insertion: + * Remember that the Player object is not persisted until explicitly inserted: + *

+     * Player player = new Player("Tom", null);
+     * // player exists only in memory at this point
+     * 
+     * playerDao.insert(player);
+     * // now player is persisted in database and player.id is set
+     * 
+ *

+ *

+ * Room Constructor Requirements: + * Room requires this constructor to instantiate Player objects when reading from + * the database. The constructor parameters must match the entity fields (excluding + * the auto-generated ID and default-initialized fields). + *

+ * + * @param username The display name for the player. Should be non-null, non-empty, + * and ideally 1-30 characters. Supports Unicode for international + * names. This will be shown throughout the app in player lists, + * match results, and statistics. + * @param profilePictureUri The file system path or content URI to the player's + * profile picture. Can be null or empty string, in which + * case the UI will display a default avatar. Should point + * to a valid image file if provided. + * @see #id + * @see #username + * @see #profilePictureUri + * @see #careerAverage + * @see #matchesPlayed + * @see com.aldo.apps.ochecompanion.AddPlayerActivity + * @see com.aldo.apps.ochecompanion.database.dao.PlayerDao#insert(Player) + */ + public Player(final String username, final String profilePictureUri) { + // Initialize the player's identity fields with provided values + // Statistical fields (careerAverage, matchesPlayed) use their default values (0.0 and 0) + // The id field remains 0 and will be auto-generated by Room upon database insertion + this.username = username; + this.profilePictureUri = profilePictureUri; + } + + + + /** + * Parcelable constructor used by the CREATOR to reconstruct the object. + * @param in The Parcel containing the serialized data. + */ + protected Player(final Parcel in) { + id = in.readInt(); + username = in.readString(); + profilePictureUri = in.readString(); + careerAverage = in.readDouble(); + matchesPlayed = in.readInt(); + } + + /** + * Required CREATOR field for Parcelable implementation. + */ + public static final Creator CREATOR = new Creator() { + @Override + public Player createFromParcel(Parcel in) { + return new Player(in); + } + + @Override + public Player[] newArray(int size) { + return new Player[size]; + } + }; + + @Override + public int describeContents() { + return 0; + } + + /** + * Flattens the object into a Parcel. + */ + @Override + public void writeToParcel(Parcel dest, int flags) { + dest.writeInt(id); + dest.writeString(username); + dest.writeString(profilePictureUri); + dest.writeDouble(careerAverage); + dest.writeInt(matchesPlayed); + } + + /** + * Returns a string representation of this Player object for debugging and logging. + *

+ * This method provides a human-readable representation of the player's state, + * including all field values. It's primarily used for debugging, logging, and + * development purposes to quickly inspect player data without a debugger. + *

+ *

+ * Output Format: + * The returned string follows this pattern: + *

+     * Player{id=15, username='John Doe', profilePictureUri='/path/to/pic.jpg', careerAverage=85.3, matchesPlayed=42}
+     * 
+ *

+ *

+ * Usage Examples: + *

+     * // Logging player creation
+     * Player player = new Player("Alice", "/path/to/pic.jpg");
+     * Log.d(TAG, "Created: " + player.toString());
+     * // Output: Player{id=0, username='Alice', profilePictureUri='/path/to/pic.jpg', careerAverage=0.0, matchesPlayed=0}
+     * 
+     * // Debugging database queries
+     * List<Player> players = playerDao.getAllPlayers();
+     * for (Player p : players) {
+     *     Log.d(TAG, p.toString());
+     * }
+     * 
+     * // Quick inspection during development
+     * System.out.println(player);  // Implicitly calls toString()
+     * 
+     * // Logging state changes
+     * Log.d(TAG, "Before update: " + player);
+     * player.careerAverage = 90.0;
+     * Log.d(TAG, "After update: " + player);
+     * 
+ *

+ *

+ * Field Inclusion: + * All fields are included in the string representation: + *

    + *
  • id: Shows the database ID (0 before insertion)
  • + *
  • username: Enclosed in single quotes to show exact value
  • + *
  • profilePictureUri: Full path string in single quotes
  • + *
  • careerAverage: Shown with decimal precision
  • + *
  • matchesPlayed: Integer count
  • + *
+ *

+ *

+ * Null Handling: + * If profilePictureUri is null, it will be displayed as the literal string "null": + *

+     * Player{id=5, username='Bob', profilePictureUri='null', careerAverage=0.0, matchesPlayed=0}
+     * 
+ *

+ *

+ * Not for UI Display: + * This method is NOT intended for user-facing text. For UI display, format + * individual fields appropriately: + *

+     * // DON'T use toString() in UI
+     * textView.setText(player.toString());  // Shows debug format
+     * 
+     * // DO format for UI display
+     * textView.setText(player.username);
+     * statsText.setText(String.format("%.1f avg • %d matches", 
+     *                   player.careerAverage, player.matchesPlayed));
+     * 
+ *

+ *

+ * Logging Best Practices: + *

+     * // Verbose logging for detailed debugging
+     * Log.v(TAG, "Player loaded: " + player);
+     * 
+     * // Debug logging for development
+     * Log.d(TAG, "Updated player stats: " + player);
+     * 
+     * // Error logging with context
+     * Log.e(TAG, "Failed to save player: " + player, exception);
+     * 
+     * // Conditional logging
+     * if (BuildConfig.DEBUG) {
+     *     Log.d(TAG, "All players: " + players.toString());
+     * }
+     * 
+ *

+ *

+ * Performance Note: + * String concatenation in toString() creates temporary objects. Avoid calling + * toString() in performance-critical loops unless necessary. For production builds, + * consider using Timber or similar libraries that can be disabled in release builds. + *

+ *

+ * Privacy Consideration: + * Be cautious when logging player data in production: + *

    + *
  • Username is personally identifiable information
  • + *
  • Profile picture paths might reveal device file structure
  • + *
  • Consider redacting sensitive info in production logs
  • + *
  • Ensure logs don't leak to crash reporting services unintentionally
  • + *
+ *

+ * + * @return A string representation of this Player object in the format: + * "Player{id=[id], username='[name]', profilePictureUri='[uri]', + * careerAverage=[avg], matchesPlayed=[count]}" + * @see Object#toString() + */ + @Override + public String toString() { + return "Player{" + + "id=" + id + + ", username='" + username + '\'' + + ", profilePictureUri='" + profilePictureUri + '\'' + + ", careerAverage=" + careerAverage + + ", matchesPlayed=" + matchesPlayed + + '}'; + } +} diff --git a/app/src/main/java/com/aldo/apps/ochecompanion/models/Match.java b/app/src/main/java/com/aldo/apps/ochecompanion/models/Match.java new file mode 100644 index 0000000..2ef177c --- /dev/null +++ b/app/src/main/java/com/aldo/apps/ochecompanion/models/Match.java @@ -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. + *

+ * This class serves as a data container for match information in the Oche Companion app, + * maintaining a collection of players participating in a single darts game session. + * It provides convenient methods for accessing player information by position and + * retrieving match statistics. + *

+ *

+ * Key Features: + *

    + *
  • Support for matches with any number of players (1v1, group matches, etc.)
  • + *
  • Position-based player access for ordered operations
  • + *
  • Convenient retrieval of player names and career averages
  • + *
  • Flexible construction with varargs or default empty initialization
  • + *
+ *

+ *

+ * Match Types: + * This class supports different match configurations: + *

    + *
  • 1v1 Match: Exactly 2 players for head-to-head competition
  • + *
  • Group Match: 3 or more players for multi-player games
  • + *
  • Solo Practice: Single player for practice sessions (if supported)
  • + *
+ *

+ *

+ * Player Ordering: + * Players are stored in the order they are added. This ordering is significant for: + *

    + *
  • Displaying players in consistent positions (left/right, top/bottom)
  • + *
  • Turn order during gameplay
  • + *
  • Result display and leaderboards
  • + *
+ *

+ *

+ * Usage Examples: + *

+ * // Create a 1v1 match
+ * Match match = new Match(player1, player2);
+ * 
+ * // Create a group match
+ * Match groupMatch = new Match(player1, player2, player3, player4);
+ * 
+ * // Create an empty match and add players later
+ * Match emptyMatch = new Match();
+ * // (Note: No public add method currently, consider adding if needed)
+ * 
+ *

+ *

+ * Design Notes: + *

    + *
  • The class is mutable in that it stores player references, but doesn't provide + * methods to add/remove players after construction
  • + *
  • Player data (names, averages) is accessed directly from Player objects
  • + *
  • Currently focused on displaying match information rather than tracking live gameplay
  • + *
+ *

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

+ * Players are stored in the order they were added during match creation. + * This ordering is preserved and can be used for position-based queries + * via {@link #getPlayerNameByPosition(int)} and {@link #getPlayerAverageByPosition(int)}. + *

+ *

+ * The list is initialized as an ArrayList to provide efficient random access + * by index, which is the primary access pattern for this class. + *

+ *

+ * Immutability Note: + * While the list reference is final, the list contents are mutable. However, + * no public methods currently allow modification after construction. + *

+ * + * @see #Match(Player...) + * @see #getAllPlayers() + * @see #getParticipantCount() + */ + private final List mPlayers; + + /** + * Constructs an empty Match with no participants. + *

+ * This constructor creates a new match instance with an empty player list. + * It's useful for scenarios where players will be added dynamically later, + * or for placeholder/initialization purposes. + *

+ *

+ * Usage Scenarios: + *

    + *
  • Creating a match template before players are selected
  • + *
  • Initialization in builders or factory methods
  • + *
  • Default construction in frameworks requiring no-arg constructors
  • + *
+ *

+ *

+ * Note: + * Currently, there are no public methods to add players after construction. + * Consider using {@link #Match(Player...)} if players are known at creation time. + *

+ *

+ * A debug log message is generated when an empty match is created. + *

+ * + * @see #Match(Player...) + */ + public Match() { + // Initialize empty player list + mPlayers = new ArrayList<>(); + // Log creation for debugging purposes + Log.d(TAG, "Match: Creating new empty match."); + } + + /** + * Constructs a Match with the specified players. + *

+ * This constructor creates a new match instance and populates it with the provided + * players. The players are added in the order they appear in the parameter list, + * which establishes their position indices for future queries. + *

+ *

+ * Varargs Convenience: + * The varargs parameter allows flexible calling patterns: + *

+     * // 1v1 match
+     * Match match1 = new Match(player1, player2);
+     * 
+     * // Group match
+     * Match match2 = new Match(player1, player2, player3, player4);
+     * 
+     * // Single player (if supported)
+     * Match match3 = new Match(player1);
+     * 
+     * // From array
+     * Player[] playerArray = {player1, player2, player3};
+     * Match match4 = new Match(playerArray);
+     * 
+ *

+ *

+ * Player Ordering: + * Players are stored in the exact order they are passed. For example: + *

    + *
  • First player: position 0
  • + *
  • Second player: position 1
  • + *
  • And so on...
  • + *
+ * This ordering is important for methods like {@link #getPlayerNameByPosition(int)}. + *

+ *

+ * Logging: + * Each player addition is logged at debug level for troubleshooting and verification. + *

+ *

+ * Null Safety: + * This constructor does not explicitly check for null players. Callers should ensure + * all provided Player objects are non-null to avoid NullPointerExceptions in + * subsequent operations. + *

+ * + * @param players Variable number of Player objects to participate in the match. + * Can be empty (equivalent to calling {@link #Match()}), + * but typically contains 2 or more players for competitive games. + * Players should not be null. + * @see Player + * @see #Match() + * @see #getParticipantCount() + */ + public Match(final Player... players) { + // Initialize empty player list + mPlayers = new ArrayList<>(); + + // Add each player to the match in order + for (final Player player : players) { + // Log the addition for debugging + Log.d(TAG, "Match: Adding [" + player + "]"); + // Add player to the internal list + mPlayers.add(player); + } + } + + /** + * Returns the number of players participating in this match. + *

+ * This method provides the count of players currently registered for the match. + * The count corresponds to the number of players added during match construction. + *

+ *

+ * Usage Examples: + *

+     * Match match1v1 = new Match(player1, player2);
+     * int count1 = match1v1.getParticipantCount(); // Returns 2
+     * 
+     * Match groupMatch = new Match(p1, p2, p3, p4);
+     * int count2 = groupMatch.getParticipantCount(); // Returns 4
+     * 
+     * Match emptyMatch = new Match();
+     * int count3 = emptyMatch.getParticipantCount(); // Returns 0
+     * 
+ *

+ *

+ * Use Cases: + * This method is commonly used to: + *

    + *
  • Determine which UI layout to display (1v1 vs group match views)
  • + *
  • Validate match configuration before starting gameplay
  • + *
  • Loop through players without accessing the list directly
  • + *
  • Check if the match has any participants
  • + *
+ *

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

+ * This method provides position-based access to player names, useful for displaying + * players in specific UI locations (e.g., player 1 on the left, player 2 on the right + * in a 1v1 display). + *

+ *

+ * Position Indexing: + * Positions are zero-based indices: + *

    + *
  • Position 0: First player added to the match
  • + *
  • Position 1: Second player added to the match
  • + *
  • Position n: (n+1)th player added to the match
  • + *
+ *

+ *

+ * Bounds Checking: + * The method includes basic bounds validation. If the position is out of range + * (negative or greater than the number of players), it returns "INVALID" rather + * than throwing an exception. + *

+ *

+ * Note on Bounds Check: + * The current implementation has a potential bug: the condition {@code position <= mPlayers.size()} + * should likely be {@code position < mPlayers.size()} since valid indices are 0 to (size-1). + * The current code may throw an IndexOutOfBoundsException when position equals size. + *

+ *

+ * Usage Example: + *

+     * Match match = new Match(player1, player2, player3);
+     * String name0 = match.getPlayerNameByPosition(0); // Returns player1.username
+     * String name1 = match.getPlayerNameByPosition(1); // Returns player2.username
+     * String name2 = match.getPlayerNameByPosition(2); // Returns player3.username
+     * String invalid = match.getPlayerNameByPosition(3); // Returns "INVALID"
+     * 
+ *

+ * + * @param position The zero-based index of the player in the match. + * Should be in the range [0, participantCount). + * @return The username of the player at the specified position, or "INVALID" + * if the position is out of bounds. + * @see Player#username + * @see #getPlayerAverageByPosition(int) + * @see #getParticipantCount() + */ + public String getPlayerNameByPosition(final int position) { + // Validate position is within bounds + // Note: Consider changing <= to < to prevent IndexOutOfBoundsException + if (position >= 0 && position <= mPlayers.size()) { + // Return the username of the player at this position + return mPlayers.get(position).username; + } + // Return sentinel value for invalid position + return "INVALID"; + } + + /** + * Retrieves the career average of the player at the specified position. + *

+ * This method provides position-based access to player career statistics, useful for + * displaying performance metrics in match summaries and leaderboards. + *

+ *

+ * Position Indexing: + * Positions are zero-based indices: + *

    + *
  • Position 0: First player added to the match
  • + *
  • Position 1: Second player added to the match
  • + *
  • Position n: (n+1)th player added to the match
  • + *
+ *

+ *

+ * Bounds Checking: + * The method includes basic bounds validation. If the position is out of range + * (negative or greater than the number of players), it returns -1 as a sentinel + * value rather than throwing an exception. + *

+ *

+ * Note on Bounds Check: + * The current implementation has a potential bug: the condition {@code position <= mPlayers.size()} + * should likely be {@code position < mPlayers.size()} since valid indices are 0 to (size-1). + * The current code may throw an IndexOutOfBoundsException when position equals size. + *

+ *

+ * Career Average: + * The returned value represents the player's career average score across all matches + * they've played, as stored in {@link Player#careerAverage}. This is typically + * calculated and updated by other parts of the application. + *

+ *

+ * Usage Example: + *

+     * Match match = new Match(player1, player2, player3);
+     * double avg0 = match.getPlayerAverageByPosition(0); // Returns player1.careerAverage
+     * double avg1 = match.getPlayerAverageByPosition(1); // Returns player2.careerAverage
+     * double avg2 = match.getPlayerAverageByPosition(2); // Returns player3.careerAverage
+     * double invalid = match.getPlayerAverageByPosition(3); // Returns -1
+     * 
+ *

+ * + * @param position The zero-based index of the player in the match. + * Should be in the range [0, participantCount). + * @return The career average of the player at the specified position, or -1 + * if the position is out of bounds. The average is a double value + * representing the player's historical performance. + * @see Player#careerAverage + * @see #getPlayerNameByPosition(int) + * @see #getParticipantCount() + */ + public double getPlayerAverageByPosition(final int position) { + // Validate position is within bounds + // Note: Consider changing <= to < to prevent IndexOutOfBoundsException + if (position >= 0 && position <= mPlayers.size()) { + // Return the career average of the player at this position + return mPlayers.get(position).careerAverage; + } + // Return sentinel value for invalid position + return -1; + } + + /** + * Returns a direct reference to the internal list of all players in this match. + *

+ * This method provides access to the complete player list, useful for operations + * that need to process all players (e.g., sorting, filtering, bulk display). + *

+ *

+ * List Contents: + * The returned list contains players in the order they were added during match + * construction. The list is the same instance used internally by this Match object. + *

+ *

+ * Mutability Warning: + * This method returns a direct reference to the internal list, not a copy. + * Modifications to the returned list will affect the match's internal state: + *

+     * List<Player> players = match.getAllPlayers();
+     * players.clear(); // WARNING: This clears the match's player list!
+     * 
+ * If you need to modify the list without affecting the match, create a copy: + *
+     * List<Player> playersCopy = new ArrayList<>(match.getAllPlayers());
+     * playersCopy.clear(); // Safe: Only affects the copy
+     * 
+ *

+ *

+ * Common Use Cases: + *

    + *
  • Populating adapters for RecyclerViews (e.g., {@link com.aldo.apps.ochecompanion.ui.adapter.MainMenuGroupMatchAdapter})
  • + *
  • Sorting players by score for leaderboard display
  • + *
  • Iterating through all players for statistics calculation
  • + *
  • Filtering players based on specific criteria
  • + *
+ *

+ *

+ * Performance: + * This is an O(1) operation as it returns a reference, not a copy. + *

+ * + * @return A direct reference to the list of all Player objects in this match. + * The list maintains the order in which players were added. + * Never null, but may be empty if no players were added. + * @see Player + * @see #getParticipantCount() + * @see com.aldo.apps.ochecompanion.ui.adapter.MainMenuGroupMatchAdapter#updateMatch(Match) + */ + public List getAllPlayers() { + return mPlayers; + } + + /** + * Returns a string representation of this Match for debugging and logging. + *

+ * This method generates a human-readable string that includes information about + * all players participating in the match. The format is designed to be concise + * yet informative for debugging purposes. + *

+ *

+ * Output Format: + * The generated string follows this pattern: + *

+     * Match {[Player1][Player2][Player3]]
+     * 
+ * Each player is represented by its own {@link Player#toString()} output, + * wrapped in square brackets. + *

+ *

+ * Example Output: + *

+     * Match {[Player{name='Alice', avg=45.5}][Player{name='Bob', avg=52.3}]]
+     * 
+ *

+ *

+ * Note on Formatting: + * The method includes an extra closing bracket at the end, which appears to be + * unintentional. The string ends with "]]" instead of "}". Consider changing + * the final append from "].append("]")} to just "}") for proper bracket matching. + *

+ *

+ * Performance: + * Uses {@link StringBuilder} for efficient string concatenation, which is important + * when the match contains many players. + *

+ *

+ * Usage: + * This method is automatically called when: + *

    + *
  • Logging a Match object: {@code Log.d(TAG, "Match: " + match)}
  • + *
  • Concatenating with strings: {@code "Current match: " + match}
  • + *
  • Debugging in IDE debugger (some IDEs display toString() output)
  • + *
+ *

+ * + * @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(); + } +} diff --git a/app/src/main/java/com/aldo/apps/ochecompanion/ui/CropOverlayView.java b/app/src/main/java/com/aldo/apps/ochecompanion/ui/CropOverlayView.java new file mode 100644 index 0000000..6fd5b52 --- /dev/null +++ b/app/src/main/java/com/aldo/apps/ochecompanion/ui/CropOverlayView.java @@ -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. + *

+ * This view is designed for use in the Oche Companion app's image cropping interface, + * specifically within the {@link com.aldo.apps.ochecompanion.AddPlayerActivity}. + * It creates a semi-transparent dark overlay across the entire view with a transparent + * square "window" in the center, allowing users to see exactly which portion of their + * image will be captured as their profile picture. + *

+ *

+ * Visual Design: + *

    + *
  • Dark semi-transparent mask (85% opacity Midnight Black) covers the entire view
  • + *
  • Transparent square cutout in the center shows the crop area
  • + *
  • Crop box is 80% of the view's width, maintaining a square aspect ratio
  • + *
  • Automatically centers the crop box within the view
  • + *
+ *

+ *

+ * Technical Implementation: + * The overlay uses a {@link Path} with clockwise and counter-clockwise rectangles to create + * a "hole punch" effect. The outer rectangle (entire view) is drawn clockwise, while the + * inner rectangle (crop area) is drawn counter-clockwise. This winding direction technique + * causes the inner rectangle to subtract from the outer one, creating a transparent window. + *

+ *

+ * Usage: + * This view is typically overlaid on top of an {@link android.widget.ImageView} in crop mode. + * The parent activity can retrieve the crop rectangle coordinates via {@link #getCropRect()} + * to perform the actual pixel-level cropping calculations. + *

+ * + * @see com.aldo.apps.ochecompanion.AddPlayerActivity + * @see Path + * @see RectF + * @author Oche Companion Development Team + * @version 1.0 + * @since 1.0 + */ +public class CropOverlayView extends View { + + /** + * Paint object for rendering the semi-transparent dark overlay mask. + *

+ * Configured with: + *

    + *
  • Anti-aliasing enabled for smooth edges
  • + *
  • Color: Midnight Black (#0A0A0A) at 85% opacity (#D90A0A0A)
  • + *
  • Style: FILL to cover the entire path area
  • + *
+ *

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

+ * This rectangle represents the "window" through which the user sees the unobscured + * portion of their image. The coordinates are calculated in {@link #onLayout(boolean, int, int, int, int)} + * and are used both for drawing the overlay and for providing crop coordinates to the + * parent activity. + *

+ *

+ * The rectangle dimensions are: + *

    + *
  • Width: 80% of the view's width
  • + *
  • Height: Equal to width (square aspect ratio)
  • + *
  • Position: Centered horizontally and vertically
  • + *
+ *

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

+ * This path consists of two rectangles: + *

    + *
  1. Outer Rectangle (Clockwise): Covers the entire view area
  2. + *
  3. Inner Rectangle (Counter-Clockwise): Defines the crop area
  4. + *
+ *

+ *

+ * The opposing winding directions create a "hole punch" effect where the inner + * rectangle subtracts from the outer one, resulting in a transparent window. + * This technique leverages Android's path fill-type rules (even-odd or winding). + *

+ *

+ * The path is recalculated whenever the view's layout changes to ensure proper + * sizing and positioning. + *

+ * + * @see Path.Direction + * @see #onLayout(boolean, int, int, int, int) + */ + private final Path mPath = new Path(); + + /** + * The calculated side length of the square crop box in pixels. + *

+ * This value is computed in {@link #onLayout(boolean, int, int, int, int)} as + * 80% of the view's width. It's stored for potential reuse and to maintain + * consistency between layout calculations. + *

+ *

+ * The 80% size ensures adequate padding around the crop area while maximizing + * the useful cropping space. + *

+ */ + private float mBoxSize; + + /** + * Constructor for programmatic instantiation of the CropOverlayView. + *

+ * This constructor is used when creating the view directly in Java/Kotlin code + * rather than inflating from XML. It initializes the view and configures all + * necessary paint and drawing resources. + *

+ * + * @param context The Context in which the view is running, through which it can + * access the current theme, resources, etc. + * @see #init() + */ + public CropOverlayView(Context context) { + super(context); + init(); + } + + /** + * Constructor for XML inflation of the CropOverlayView. + *

+ * This constructor is called when the view is inflated from an XML layout file. + * It allows the view to be defined declaratively in layout resources. The AttributeSet + * parameter provides access to any XML attributes defined for this view. + *

+ * + * @param context The Context in which the view is running, through which it can + * access the current theme, resources, etc. + * @param attrs The attributes of the XML tag that is inflating the view. May be null + * if no attributes are specified. + * @see #init() + */ + public CropOverlayView(Context context, @Nullable AttributeSet attrs) { + super(context, attrs); + init(); + } + + /** + * Constructor for XML inflation of the CropOverlayView with a specific style. + *

+ * This constructor is called when the view is inflated from an XML layout file + * with a style attribute. It allows for theme-based customization of the view's + * appearance, though this view currently uses hardcoded visual properties. + *

+ * + * @param context The Context in which the view is running, through which it can + * access the current theme, resources, etc. + * @param attrs The attributes of the XML tag that is inflating the view. May be null + * if no attributes are specified. + * @param defStyleAttr An attribute in the current theme that contains a reference to + * a style resource that supplies default values for the view. + * Can be 0 to not look for defaults. + * @see #init() + */ + public CropOverlayView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + init(); + } + + /** + * Initializes the Paint object used for rendering the overlay mask. + *

+ * This method configures the visual properties of the semi-transparent overlay + * that will darken the non-crop area of the image. The configuration includes: + *

    + *
  • Color: Midnight Black (#0A0A0A) with 85% opacity
  • + *
  • Style: FILL to cover the entire masked area
  • + *
  • Anti-aliasing: Already enabled via constructor flag
  • + *
+ *

+ *

+ * Color Choice Rationale: + * The 85% opacity (0xD9) provides strong contrast to highlight the crop area + * while maintaining enough transparency for users to see the portions of the + * image that will be cropped out. This helps with precise positioning. + *

+ *

+ * This method is called by all constructors to ensure consistent initialization + * regardless of how the view is instantiated. + *

+ * + * @see Paint.Style#FILL + */ + private void init() { + // Set darkened background color: Midnight Black (#0A0A0A) at 85% opacity + // Alpha value 0xD9 = 217/255 ≈ 85% + mMaskPaint.setColor(Color.parseColor("#D90A0A0A")); + + // Use FILL style to cover the entire path area (except the hole) + mMaskPaint.setStyle(Paint.Style.FILL); + } + + /** + * Called when the view's size or position changes to recalculate the crop area. + *

+ * This method is responsible for: + *

    + *
  1. Calculating the size of the square crop box (80% of view width)
  2. + *
  3. Centering the crop box both horizontally and vertically
  4. + *
  5. Updating the crop rectangle coordinates
  6. + *
  7. Rebuilding the path used for rendering the overlay mask
  8. + *
+ *

+ *

+ * Crop Box Sizing: + * The crop box is sized at 80% of the view's width to provide: + *

    + *
  • Adequate padding (10% on each side) for visual clarity
  • + *
  • Sufficient space for users to see what will be cropped out
  • + *
  • Maximum useful cropping area without overwhelming the interface
  • + *
+ *

+ *

+ * Path Construction: + * The path is built with two rectangles using opposite winding directions: + *

    + *
  • Outer (CW): Full view bounds - creates the mask base
  • + *
  • Inner (CCW): Crop area - subtracts from the mask
  • + *
+ * This winding technique creates the transparent "hole" effect. + *

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

+ * This method is called by the Android framework whenever the view needs to be drawn. + * It draws the pre-calculated path that contains the full-screen mask with a + * transparent square cutout in the center. + *

+ *

+ * Rendering Process: + * The single {@link Canvas#drawPath(Path, Paint)} call efficiently renders both: + *

    + *
  • The dark semi-transparent overlay covering the entire view
  • + *
  • The transparent crop area in the center (created by the CCW inner rectangle)
  • + *
+ *

+ *

+ * Performance: + * The path is pre-calculated in {@link #onLayout(boolean, int, int, int, int)} rather + * than being recalculated on every draw call, ensuring smooth rendering performance. + *

+ * + * @param canvas The Canvas on which the view will be drawn. This canvas is provided + * by the Android framework and is used to draw the overlay mask. + * @see #onLayout(boolean, int, int, int, int) + * @see Canvas#drawPath(Path, Paint) + */ + @Override + protected void onDraw(Canvas canvas) { + super.onDraw(canvas); + + // Draw the path which contains the full screen minus the square cutout + // The path was pre-calculated in onLayout() for performance + canvas.drawPath(mPath, mMaskPaint); + } + + /** + * Provides the coordinates of the crop box for pixel-level cropping calculations. + *

+ * This method returns the rectangle that defines the transparent crop area in screen + * coordinates. The parent activity (typically {@link com.aldo.apps.ochecompanion.AddPlayerActivity}) + * uses these coordinates to calculate which pixels from the source image should be + * extracted for the cropped result. + *

+ *

+ * Coordinate System: + * The returned RectF contains screen coordinates relative to this view: + *

    + *
  • left: X-coordinate of the left edge of the crop box
  • + *
  • top: Y-coordinate of the top edge of the crop box
  • + *
  • right: X-coordinate of the right edge of the crop box
  • + *
  • bottom: Y-coordinate of the bottom edge of the crop box
  • + *
+ *

+ *

+ * Usage: + * The parent activity must transform these screen coordinates to bitmap pixel coordinates + * by accounting for: + *

    + *
  • ImageView fit-center scaling
  • + *
  • User's manual pan (translation) gestures
  • + *
  • User's pinch-to-zoom (scale) gestures
  • + *
+ *

+ * + * @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; + } +} \ No newline at end of file diff --git a/app/src/main/java/com/aldo/apps/ochecompanion/ui/MatchRecapView.java b/app/src/main/java/com/aldo/apps/ochecompanion/ui/MatchRecapView.java new file mode 100644 index 0000000..faa03dd --- /dev/null +++ b/app/src/main/java/com/aldo/apps/ochecompanion/ui/MatchRecapView.java @@ -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. + *

+ * This view is designed to provide users with a quick overview of their last game session + * in the Oche Companion app. It intelligently adapts its display based on the match type + * and data availability, supporting three distinct visual states. + *

+ *

+ * Supported States: + *

    + *
  1. Empty State: Displayed when no match history exists yet. + * Shows a placeholder or welcome message to encourage users to start playing.
  2. + *
  3. 1v1 State: Displayed for head-to-head matches (exactly 2 players). + * Shows a side-by-side comparison of player names and scores.
  4. + *
  5. Group State: Displayed for group matches (3 or more players). + * Shows a mini-leaderboard with all participants sorted by their performance.
  6. + *
+ *

+ *

+ * Key Features: + *

    + *
  • Automatic state switching based on match data
  • + *
  • Clean state management ensuring only one view state is visible at a time
  • + *
  • Efficient RecyclerView implementation for group matches
  • + *
  • Graceful handling of null/missing match data
  • + *
+ *

+ *

+ * Usage: + * This view is typically used in the Main Menu activity to display the most recent match. + * The parent activity calls {@link #setMatch(Match)} to update the display whenever the + * match data changes or when the activity resumes. + *

+ * + * @see Match + * @see MainMenuGroupMatchAdapter + * @see FrameLayout + * @author Oche Companion Development Team + * @version 1.0 + * @since 1.0 + */ +public class MatchRecapView extends FrameLayout { + + /** + * View container for the empty state display. + *

+ * This view is shown when no match history exists in the database. + * It typically contains placeholder content, empty state illustrations, + * or encouraging messages to prompt users to start their first game. + *

+ *

+ * Visibility is managed by {@link #updateVisibility(View)} to ensure + * only one state is visible at a time. + *

+ * + * @see #setMatch(Match) + * @see #updateVisibility(View) + */ + private View stateEmpty; + + /** + * View container for the 1v1 match state display. + *

+ * This view is shown when the last match was a head-to-head game between + * exactly two players. It presents a side-by-side comparison showing both + * players' names and their respective scores. + *

+ *

+ * Visibility is managed by {@link #updateVisibility(View)} to ensure + * only one state is visible at a time. + *

+ * + * @see #setup1v1State(Match) + * @see #updateVisibility(View) + */ + private View state1v1; + + /** + * View container for the group match state display. + *

+ * This view is shown when the last match involved 3 or more players. + * It contains a RecyclerView that displays a mini-leaderboard with all + * participants sorted by their performance. + *

+ *

+ * Visibility is managed by {@link #updateVisibility(View)} to ensure + * only one state is visible at a time. + *

+ * + * @see #setupGroupState(Match) + * @see #updateVisibility(View) + */ + private View stateGroup; + + // ========== 1v1 View References ========== + + /** + * TextView displaying the name of the first player in a 1v1 match. + * Used only in the 1v1 state. + */ + private TextView tvP1Name; + + /** + * TextView displaying the name of the second player in a 1v1 match. + * Used only in the 1v1 state. + */ + private TextView tvP2Name; + + /** + * TextView displaying the score/average of the first player in a 1v1 match. + * Used only in the 1v1 state. + */ + private TextView tvP1Score; + + /** + * TextView displaying the score/average of the second player in a 1v1 match. + * Used only in the 1v1 state. + */ + private TextView tvP2Score; + + // ========== Group View References ========== + + /** + * RecyclerView displaying the leaderboard for group matches. + *

+ * This RecyclerView is configured with a {@link LinearLayoutManager} and uses + * a {@link MainMenuGroupMatchAdapter} to display all participants in the match, + * sorted by their performance scores. + *

+ *

+ * Used only in the group state. + *

+ * + * @see MainMenuGroupMatchAdapter + * @see #setupGroupState(Match) + */ + private RecyclerView rvLeaderboard; + + /** + * Constructor for programmatic instantiation of the MatchRecapView. + *

+ * This constructor delegates to the two-parameter constructor with a null + * AttributeSet, which in turn inflates the layout and initializes all child views. + *

+ * + * @param context The Context in which the view is running, through which it can + * access the current theme, resources, etc. + * @see #MatchRecapView(Context, AttributeSet) + */ + public MatchRecapView(@NonNull final Context context) { + this(context, null); + } + + /** + * Constructor for XML inflation of the MatchRecapView. + *

+ * This constructor is called when the view is inflated from an XML layout file. + * It performs the following initialization: + *

    + *
  1. Calls the parent FrameLayout constructor
  2. + *
  3. Inflates the view_match_recap layout into this container
  4. + *
  5. Initializes all child view references via {@link #initViews()}
  6. + *
+ *

+ *

+ * After construction, the view defaults to showing no content until + * {@link #setMatch(Match)} is called with valid match data. + *

+ * + * @param context The Context in which the view is running, through which it can + * access the current theme, resources, etc. + * @param attrs The attributes of the XML tag that is inflating the view. May be null + * if no attributes are specified. + * @see #initViews() + */ + public MatchRecapView(@NonNull final Context context, @Nullable final AttributeSet attrs) { + super(context, attrs); + // Inflate the layout for this composite view + inflate(context, R.layout.view_match_recap, this); + // Initialize all child view references + initViews(); + } + + /** + * Initializes references to all child views within the inflated layout. + *

+ * This method retrieves and stores references to all UI components needed for + * the three different states: + *

    + *
  • State containers: Empty, 1v1, and Group view containers
  • + *
  • 1v1 components: Player name and score TextViews
  • + *
  • Group components: Leaderboard RecyclerView
  • + *
+ *

+ *

+ * All views must exist in the R.layout.view_match_recap layout file, + * otherwise this method will throw a NullPointerException. + *

+ *

+ * This method is called once during construction and does not need to be + * called again during the view's lifecycle. + *

+ * + * @see #MatchRecapView(Context, AttributeSet) + */ + private void initViews() { + // Initialize state container references + stateEmpty = findViewById(R.id.stateEmpty); + state1v1 = findViewById(R.id.state1v1); + stateGroup = findViewById(R.id.stateGroup); + + // Initialize 1v1 match view references + tvP1Name = findViewById(R.id.tvP1Name); + tvP1Score = findViewById(R.id.tvP1Score); + tvP2Name = findViewById(R.id.tvP2Name); + tvP2Score = findViewById(R.id.tvP2Score); + + // Initialize group match view references + rvLeaderboard = findViewById(R.id.rvLeaderboard); + } + + /** + * Binds a Match object to the view and updates the display accordingly. + *

+ * This is the main entry point for updating the view's content. It analyzes the + * provided match data and automatically selects the appropriate state to display: + *

    + *
  • Null match: Shows the empty state
  • + *
  • 2 players: Shows the 1v1 state with head-to-head comparison
  • + *
  • 3+ players: Shows the group state with leaderboard
  • + *
+ *

+ *

+ * State Selection Logic: + * The method first checks for null, then examines the participant count to determine + * which state is appropriate. Each state has its own setup method that handles the + * specific data binding and view configuration. + *

+ *

+ * Usage: + * This method should be called whenever the match data changes, such as: + *

    + *
  • When the activity first loads
  • + *
  • After a new match is completed
  • + *
  • When resuming the activity to refresh with latest data
  • + *
+ *

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

+ * This method sets up the view for displaying a head-to-head match between two players. + * It performs the following operations: + *

    + *
  1. Switches visibility to show only the 1v1 state container
  2. + *
  3. Retrieves player data by position (0 for player 1, 1 for player 2)
  4. + *
  5. Populates the player name TextViews
  6. + *
  7. Populates the player score/average TextViews
  8. + *
+ *

+ *

+ * Data Retrieval: + * Player information is retrieved by position index rather than by ID, assuming + * the match stores players in a predictable order. Position 0 corresponds to the + * first player (typically displayed on the left), and position 1 corresponds to + * the second player (typically displayed on the right). + *

+ *

+ * Assumptions: + * This method assumes the match contains exactly 2 players. The caller + * ({@link #setMatch(Match)}) should verify the participant count before calling this method. + *

+ * + * @param match The Match object containing exactly 2 players. Must not be null. + * @see Match#getPlayerNameByPosition(int) + * @see Match#getPlayerAverageByPosition(int) + * @see #updateVisibility(View) + */ + private void setup1v1State(final Match match) { + // Switch to 1v1 state visibility + updateVisibility(state1v1); + + // Populate player 1 information (left side) + tvP1Name.setText(match.getPlayerNameByPosition(0)); + tvP1Score.setText(String.valueOf(match.getPlayerAverageByPosition(0))); + + // Populate player 2 information (right side) + tvP2Name.setText(match.getPlayerNameByPosition(1)); + tvP2Score.setText(String.valueOf(match.getPlayerAverageByPosition(1))); + } + + /** + * Configures and displays the group match state with a leaderboard. + *

+ * This method sets up the view for displaying a match with 3 or more players. + * It performs the following operations: + *

    + *
  1. Switches visibility to show only the group state container
  2. + *
  3. Configures the RecyclerView with a LinearLayoutManager
  4. + *
  5. Creates and attaches a MainMenuGroupMatchAdapter
  6. + *
  7. Populates the adapter with match data (players are automatically sorted by score)
  8. + *
+ *

+ *

+ * RecyclerView Configuration: + * The RecyclerView is configured with a {@link LinearLayoutManager} in vertical + * orientation, displaying players in a scrollable list. Each player entry shows + * their name, score/average, and profile picture. + *

+ *

+ * Adapter Behavior: + * The {@link MainMenuGroupMatchAdapter} automatically sorts players by their + * career average when the match data is provided, displaying them in ascending + * order (lowest to highest scores). + *

+ *

+ * Performance Note: + * A new adapter instance is created each time this method is called. For better + * performance in scenarios with frequent updates, consider reusing the adapter + * and calling only {@link MainMenuGroupMatchAdapter#updateMatch(Match)}. + *

+ * + * @param match The Match object containing 3 or more players. Must not be null. + * @see MainMenuGroupMatchAdapter + * @see LinearLayoutManager + * @see #updateVisibility(View) + */ + private void setupGroupState(final Match match) { + // Switch to group state visibility + updateVisibility(stateGroup); + + // Configure the RecyclerView with a vertical LinearLayoutManager + rvLeaderboard.setLayoutManager(new LinearLayoutManager(getContext())); + + // Create and configure the adapter for displaying the player leaderboard + final MainMenuGroupMatchAdapter adapter = new MainMenuGroupMatchAdapter(); + rvLeaderboard.setAdapter(adapter); + + // Populate the adapter with match data (players will be sorted automatically) + adapter.updateMatch(match); + } + + /** + * Updates the visibility of all state containers, showing only the specified active view. + *

+ * This method implements a mutually exclusive visibility pattern, ensuring that only + * one state container is visible at any given time. It iterates through all three state + * containers (empty, 1v1, group) and sets each one to either VISIBLE or GONE based on + * whether it matches the activeView parameter. + *

+ *

+ * Visibility Logic: + *

    + *
  • The view matching activeView is set to {@link View#VISIBLE}
  • + *
  • All other views are set to {@link View#GONE} (completely removed from layout)
  • + *
+ *

+ *

+ * Why GONE instead of INVISIBLE: + * Using {@link View#GONE} rather than {@link View#INVISIBLE} ensures that hidden + * states don't occupy any layout space, resulting in cleaner rendering and better + * performance. + *

+ *

+ * This centralized visibility management prevents inconsistent states where multiple + * containers might be visible simultaneously. + *

+ * + * @param activeView The view container that should be made visible. Must be one of: + * {@link #stateEmpty}, {@link #state1v1}, or {@link #stateGroup}. + * All other state containers will be hidden. + * @see View#VISIBLE + * @see View#GONE + */ + private void updateVisibility(final View activeView) { + // Set empty state visibility + stateEmpty.setVisibility(activeView == stateEmpty ? VISIBLE : GONE); + + // Set 1v1 state visibility + state1v1.setVisibility(activeView == state1v1 ? VISIBLE : GONE); + + // Set group state visibility + stateGroup.setVisibility(activeView == stateGroup ? VISIBLE : GONE); + } +} diff --git a/app/src/main/java/com/aldo/apps/ochecompanion/ui/PlayerItemView.java b/app/src/main/java/com/aldo/apps/ochecompanion/ui/PlayerItemView.java new file mode 100644 index 0000000..554cda6 --- /dev/null +++ b/app/src/main/java/com/aldo/apps/ochecompanion/ui/PlayerItemView.java @@ -0,0 +1,284 @@ +package com.aldo.apps.ochecompanion.ui; + +import android.content.Context; +import android.util.AttributeSet; +import android.widget.TextView; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.aldo.apps.ochecompanion.database.objects.Player; +import com.bumptech.glide.Glide; +import com.google.android.material.card.MaterialCardView; +import com.google.android.material.imageview.ShapeableImageView; +import com.aldo.apps.ochecompanion.R; + +/** + * Reusable custom view component for displaying individual player information in a card format. + *

+ * This view extends {@link MaterialCardView} to provide a consistent, styled card layout for + * displaying player information throughout the Oche Companion app. It encapsulates the UI + * components and binding logic needed to present a player's profile picture, username, and + * career statistics in a visually appealing and consistent manner. + *

+ *

+ * Key Features: + *

    + *
  • Styled MaterialCardView with custom colors, elevation, and corner radius
  • + *
  • Profile picture display with Glide integration for efficient image loading
  • + *
  • Automatic fallback to default avatar icon when no profile picture exists
  • + *
  • Career average statistics display with formatted text
  • + *
  • Reusable across different contexts (squad lists, match recaps, leaderboards)
  • + *
+ *

+ *

+ * Usage Contexts: + * This view is used in multiple places throughout the app: + *

    + *
  • Squad List: In {@link com.aldo.apps.ochecompanion.ui.adapter.MainMenuPlayerAdapter} + * for displaying the user's roster of players
  • + *
  • Group Matches: In {@link com.aldo.apps.ochecompanion.ui.adapter.MainMenuGroupMatchAdapter} + * for displaying match participants in leaderboard format
  • + *
  • Match Recaps: Anywhere player information needs to be displayed consistently
  • + *
+ *

+ *

+ * Design Pattern: + * This view follows the ViewHolder pattern by encapsulating both the layout and binding logic, + * making it easy to reuse across different RecyclerView adapters without code duplication. + *

+ *

+ * Styling: + * The card appearance is configured in {@link #initViews()} with: + *

    + *
  • Background color from {@code R.color.surface_primary}
  • + *
  • Corner radius from {@code R.dimen.radius_m}
  • + *
  • Elevation from {@code R.dimen.card_elevation}
  • + *
+ *

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

+ * This ImageView is configured to display circular profile pictures. It uses + * the Glide library to load images from file URIs when available. If the player + * has no profile picture, a default user icon ({@code R.drawable.ic_users}) is displayed. + *

+ *

+ * The ShapeableImageView type allows for easy customization of the image shape + * through XML attributes, supporting circular, rounded rectangle, or custom shapes. + *

+ * + * @see #bind(Player) + * @see ShapeableImageView + */ + private ShapeableImageView ivAvatar; + + /** + * TextView displaying the player's username. + *

+ * Shows the {@link Player#username} field. This is the primary identifier + * for the player in the UI. + *

+ */ + private TextView tvUsername; + + /** + * TextView displaying the player's career statistics. + *

+ * Shows the {@link Player#careerAverage} formatted using the string resource + * {@code R.string.txt_player_average_base}. This provides users with a quick + * overview of the player's performance history. + *

+ */ + private TextView tvStats; + + /** + * Constructor for programmatic instantiation of the PlayerItemView. + *

+ * This constructor delegates to the two-parameter constructor with a null + * AttributeSet, which in turn inflates the layout and initializes all child views + * and styling. + *

+ *

+ * Use this constructor when creating PlayerItemView instances directly in code + * rather than inflating from XML. + *

+ * + * @param context The Context in which the view is running, through which it can + * access the current theme, resources, etc. + * @see #PlayerItemView(Context, AttributeSet) + */ + public PlayerItemView(@NonNull Context context) { + this(context, null); + } + + /** + * Constructor for XML inflation of the PlayerItemView. + *

+ * This constructor is called when the view is inflated from an XML layout file, + * or when programmatically created via the single-parameter constructor. + * It performs the following initialization: + *

    + *
  1. Calls the parent MaterialCardView constructor
  2. + *
  3. Inflates the item_player_small layout into this container
  4. + *
  5. Initializes all child views and applies card styling via {@link #initViews()}
  6. + *
+ *

+ *

+ * After construction, the view is ready to receive player data through the + * {@link #bind(Player)} method. + *

+ * + * @param context The Context in which the view is running, through which it can + * access the current theme, resources, etc. + * @param attrs The attributes of the XML tag that is inflating the view. May be null + * if instantiated programmatically. + * @see #initViews() + */ + public PlayerItemView(@NonNull Context context, @Nullable AttributeSet attrs) { + super(context, attrs); + // Inflate the player item layout into this card view + inflate(context, R.layout.item_player_small, this); + // Initialize child views and apply styling + initViews(); + } + + /** + * Initializes child view references and applies MaterialCardView styling. + *

+ * This method performs two main tasks: + *

    + *
  1. Card Styling: Configures the MaterialCardView's visual properties + * including background color, corner radius, and elevation for a consistent + * Material Design appearance
  2. + *
  3. View References: Retrieves and stores references to all child + * views (avatar, username, stats) for efficient data binding
  4. + *
+ *

+ *

+ * Styling Details: + *

    + *
  • Background Color: Uses {@code R.color.surface_primary} for + * consistent theming across the app
  • + *
  • Corner Radius: Uses {@code R.dimen.radius_m} for medium-sized + * rounded corners following Material Design guidelines
  • + *
  • Elevation: Uses {@code R.dimen.card_elevation} to create subtle + * depth and visual hierarchy
  • + *
+ *

+ *

+ * This method is called once during construction and does not need to be called + * again during the view's lifecycle. + *

+ * + * @see #PlayerItemView(Context, AttributeSet) + * @see MaterialCardView#setCardBackgroundColor(int) + * @see MaterialCardView#setRadius(float) + * @see MaterialCardView#setCardElevation(float) + */ + private void initViews() { + // ========== Card Styling Configuration ========== + + // Set card background color from theme + setCardBackgroundColor(getContext().getColor(R.color.surface_primary)); + + // Set corner radius for rounded edges + setRadius(getResources().getDimension(R.dimen.radius_m)); + + // Set elevation for Material Design shadow effect + setCardElevation(getResources().getDimension(R.dimen.card_elevation)); + + // ========== Child View References ========== + + // Get reference to the avatar/profile picture ImageView + ivAvatar = findViewById(R.id.ivPlayerProfile); + + // Get reference to the username TextView + tvUsername = findViewById(R.id.tvPlayerName); + + // Get reference to the career stats TextView + tvStats = findViewById(R.id.tvPlayerAvg); + } + + /** + * Binds a Player object to this view, populating all UI components with player data. + *

+ * This method updates the view's content to display information for the specified player: + *

    + *
  • Username: Sets the player's name in the username TextView
  • + *
  • Career Average: Formats and displays the player's career statistics
  • + *
  • Profile Picture: Loads the player's avatar image or shows a default icon
  • + *
+ *

+ *

+ * Image Loading Strategy: + * The method uses Glide library for efficient image loading: + *

    + *
  • With Profile Picture: If {@link Player#profilePictureUri} is not null, + * Glide loads the image from the file URI with automatic caching, memory management, + * and placeholder handling
  • + *
  • Without Profile Picture: If no URI is available, displays a default + * user icon ({@code R.drawable.ic_users}) as a fallback
  • + *
+ *

+ *

+ * Text Formatting: + * The career average is formatted using {@code R.string.txt_player_average_base} which + * typically includes a format specifier (e.g., "Avg: %.2f") to ensure consistent + * numerical presentation across the app. + *

+ *

+ * Performance: + * This method is designed to be called frequently (e.g., during RecyclerView scrolling) + * and uses efficient operations. Glide handles image caching automatically to minimize + * disk I/O and network requests. + *

+ *

+ * Usage: + * This method should be called whenever: + *

    + *
  • A new player is to be displayed in this view
  • + *
  • Player data has been updated and the view needs to refresh
  • + *
  • The view is being recycled in a RecyclerView adapter
  • + *
+ *

+ * + * @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); + } + } +} diff --git a/app/src/main/java/com/aldo/apps/ochecompanion/ui/QuickStartButton.java b/app/src/main/java/com/aldo/apps/ochecompanion/ui/QuickStartButton.java new file mode 100644 index 0000000..b8ac67a --- /dev/null +++ b/app/src/main/java/com/aldo/apps/ochecompanion/ui/QuickStartButton.java @@ -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. + *

+ * 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: + *

    + *
  • A large, bold primary label (e.g., "QUICK START")
  • + *
  • A secondary descriptive subtext showing game mode and rules
  • + *
  • Hero-style prominent appearance matching dashboard mockups
  • + *
+ *

+ *

+ * Design Philosophy: + * This button follows the "hero component" design pattern, where a single large, + * visually distinctive element serves as the primary action on a screen. This makes + * it immediately obvious to users how to start playing without navigating through + * multiple menus or configuration screens. + *

+ *

+ * Key Features: + *

    + *
  • Dual-text hierarchy with main title and descriptive subtitle
  • + *
  • Automatic text uppercasing for visual consistency
  • + *
  • Convenience method for updating game context (mode and rules)
  • + *
  • Built-in clickable and focusable configuration for accessibility
  • + *
  • Efficient merge-tag layout inflation pattern
  • + *
+ *

+ *

+ * Usage Example: + *

+ * QuickStartButton button = findViewById(R.id.quickStartButton);
+ * button.setMainText("Quick Start");
+ * button.updateContext("501", "Double Out");
+ * button.setOnClickListener(v -> startMatch());
+ * 
+ *

+ *

+ * Text Formatting: + * Both main and sub text are automatically converted to uppercase to maintain + * consistent visual styling and match the high-impact design aesthetic. + *

+ *

+ * Accessibility: + * The component is configured as clickable and focusable, ensuring it works + * properly with touch, keyboard navigation, and accessibility services. + *

+ * + * @see FrameLayout + * @see TextView + * @author Oche Companion Development Team + * @version 1.0 + * @since 1.0 + */ +public class QuickStartButton extends FrameLayout { + + /** + * TextView displaying the primary, bold label for the button. + *

+ * This TextView typically shows the main action text such as "QUICK START". + * The text is rendered in a large, prominent font and is automatically + * converted to uppercase when set via {@link #setMainText(String)}. + *

+ *

+ * This is the most visually prominent element of the component and should + * clearly communicate the primary action to the user. + *

+ * + * @see #setMainText(String) + */ + private TextView tvMainLabel; + + /** + * TextView displaying the secondary descriptive text below the main label. + *

+ * This TextView shows additional context about the quick start action, + * such as the game mode and rules (e.g., "501 - DOUBLE OUT"). The text + * is automatically converted to uppercase when set via {@link #setSubText(String)} + * or {@link #updateContext(String, String)}. + *

+ *

+ * This provides users with quick information about what configuration will + * be used for the match without requiring them to navigate to settings. + *

+ * + * @see #setSubText(String) + * @see #updateContext(String, String) + */ + private TextView tvSubLabel; + + /** + * Constructor for programmatic instantiation of the QuickStartButton. + *

+ * This constructor delegates to the two-parameter constructor with a null + * AttributeSet, which handles the actual initialization including layout + * inflation and view setup. + *

+ *

+ * Use this constructor when creating QuickStartButton instances directly in code + * rather than inflating from XML. + *

+ * + * @param context The Context in which the view is running, through which it can + * access the current theme, resources, etc. + * @see #QuickStartButton(Context, AttributeSet) + */ + public QuickStartButton(@NonNull Context context) { + this(context, null); + } + + /** + * Constructor for XML inflation of the QuickStartButton. + *

+ * This constructor is called when the view is inflated from an XML layout file, + * or when programmatically created via the single-parameter constructor. + * It performs the following initialization: + *

    + *
  1. Calls the parent FrameLayout constructor
  2. + *
  3. Configures the view as clickable and focusable for proper interaction handling
  4. + *
  5. Inflates the internal layout using the merge-tag pattern for efficiency
  6. + *
  7. Initializes child view references via {@link #initViews()}
  8. + *
+ *

+ *

+ * Merge Tag Pattern: + * The layout inflation uses {@code attachToRoot = true}, which is appropriate when + * using the {@code } tag in the layout XML. This pattern eliminates an + * unnecessary wrapper ViewGroup, making the view hierarchy more efficient. + *

+ *

+ * Interaction Configuration: + * The view is explicitly set as clickable and focusable to ensure: + *

    + *
  • Proper touch event handling
  • + *
  • Visual feedback on interaction (ripple effects, state changes)
  • + *
  • Keyboard navigation support for accessibility
  • + *
  • Screen reader compatibility
  • + *
+ *

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

+ * This method retrieves and stores references to the main label and sub-label + * TextViews that comprise the button's visual content. These references are + * used by the public setter methods to update the button's displayed text. + *

+ *

+ * This method is called once during construction and does not need to be called + * again during the view's lifecycle. + *

+ *

+ * Required Layout Elements: + * The R.layout.view_quick_start layout must contain: + *

    + *
  • {@code R.id.tvQuickStartMain} - The main title TextView
  • + *
  • {@code R.id.tvQuickStartSub} - The subtitle TextView
  • + *
+ *

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

+ * This method updates the main label TextView with the provided text, which is + * automatically converted to uppercase to maintain visual consistency with the + * component's bold, high-impact design aesthetic. + *

+ *

+ * Typical Usage: + * Use this method to set action-oriented text that clearly communicates the + * button's purpose, such as: + *

    + *
  • "Quick Start"
  • + *
  • "Start Match"
  • + *
  • "Play Now"
  • + *
+ *

+ *

+ * Text Transformation: + * The input text is converted to uppercase using {@link String#toUpperCase()} + * before being set on the TextView, ensuring consistent visual presentation + * regardless of how the input string is formatted. + *

+ *

+ * Null Safety: + * The method includes a null check for the TextView reference to prevent + * NullPointerExceptions if called before the view is fully initialized, + * though this should not occur under normal circumstances. + *

+ * + * @param text The main title string to display. Should be concise and action-oriented. + * Will be converted to uppercase automatically. + * @see #setSubText(String) + */ + public void setMainText(final String text) { + // Check if TextView is initialized before setting text + if (tvMainLabel != null) { + // Convert text to uppercase for consistent styling + tvMainLabel.setText(text.toUpperCase()); + } + } + + /** + * Sets the secondary descriptive text displayed below the main label. + *

+ * This method updates the subtitle TextView with the provided text, which is + * automatically converted to uppercase to maintain visual consistency. The + * subtitle typically provides additional context about the quick start action, + * such as game mode and rules information. + *

+ *

+ * Typical Usage: + * Use this method to set descriptive text that provides details about the + * match configuration, such as: + *

    + *
  • "Standard 501 • Double Out"
  • + *
  • "301 - Single In/Double Out"
  • + *
  • "Cricket • Standard Rules"
  • + *
+ *

+ *

+ * Text Transformation: + * The input text is converted to uppercase using {@link String#toUpperCase()} + * before being set on the TextView, ensuring consistent visual presentation + * with the main label. + *

+ *

+ * Null Safety: + * The method includes a null check for the TextView reference to prevent + * NullPointerExceptions if called before the view is fully initialized, + * though this should not occur under normal circumstances. + *

+ * + * @param text The subtitle string to display. Should provide context about the + * match configuration. Will be converted to uppercase automatically. + * @see #setMainText(String) + * @see #updateContext(String, String) + */ + public void setSubText(final String text) { + // Check if TextView is initialized before setting text + if (tvSubLabel != null) { + // Convert text to uppercase for consistent styling + tvSubLabel.setText(text.toUpperCase()); + } + } + + /** + * Convenience method to update the subtitle based on game mode and rules. + *

+ * This method provides a structured way to update the button's subtitle by combining + * game mode and rules information into a formatted string. It handles the concatenation + * logic and properly formats the output with a separator between mode and rules. + *

+ *

+ * Formatting Behavior: + *

    + *
  • If rules are provided: Displays "[MODE] - [RULES]" (e.g., "501 - DOUBLE OUT")
  • + *
  • If rules are empty/null: Displays only "[MODE]" (e.g., "501")
  • + *
+ *

+ *

+ * Example Usage: + *

+     * // With rules
+     * button.updateContext("501", "Double Out");
+     * // Results in: "501 - DOUBLE OUT"
+     * 
+     * // Without rules
+     * button.updateContext("301", null);
+     * // Results in: "301"
+     * 
+     * button.updateContext("Cricket", "");
+     * // Results in: "CRICKET"
+     * 
+ *

+ *

+ * Advantages over setSubText: + *

    + *
  • Provides consistent formatting across the app
  • + *
  • Handles null/empty rules gracefully
  • + *
  • Reduces code duplication in calling code
  • + *
  • Makes intent clearer (semantic method name)
  • + *
+ *

+ *

+ * Implementation Details: + * The method uses {@link StringBuilder} for efficient string concatenation, + * especially useful if called frequently. It checks if rules are empty using + * {@link TextUtils#isEmpty(CharSequence)} which handles both null and empty strings. + *

+ * + * @param mode The game mode identifier (e.g., "501", "301", "Cricket"). + * Should not be null or empty as this is the primary information. + * @param rules Optional summary of game rules (e.g., "Double Out", "Single In/Double Out"). + * Can be null or empty, in which case only the mode is displayed. + * @see #setSubText(String) + * @see TextUtils#isEmpty(CharSequence) + * @see StringBuilder + */ + public void updateContext(final String mode, final String rules) { + // Use StringBuilder for efficient string concatenation + final StringBuilder stringBuilder = new StringBuilder(mode); + + // Only append rules if they are provided (not null and not empty) + if (!TextUtils.isEmpty(rules)) { + // Add separator between mode and rules + stringBuilder.append(" - "); + // Append the rules text + stringBuilder.append(rules); + } + + // Set the combined text as the subtitle + setSubText(stringBuilder.toString()); + } +} diff --git a/app/src/main/java/com/aldo/apps/ochecompanion/ui/adapter/MainMenuGroupMatchAdapter.java b/app/src/main/java/com/aldo/apps/ochecompanion/ui/adapter/MainMenuGroupMatchAdapter.java new file mode 100644 index 0000000..f9cd7bb --- /dev/null +++ b/app/src/main/java/com/aldo/apps/ochecompanion/ui/adapter/MainMenuGroupMatchAdapter.java @@ -0,0 +1,372 @@ +package com.aldo.apps.ochecompanion.ui.adapter; + +import android.annotation.SuppressLint; +import android.util.Log; +import android.view.View; +import android.view.ViewGroup; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.recyclerview.widget.RecyclerView; + +import com.aldo.apps.ochecompanion.R; +import com.aldo.apps.ochecompanion.database.objects.Player; +import com.aldo.apps.ochecompanion.models.Match; +import com.aldo.apps.ochecompanion.ui.PlayerItemView; +import com.bumptech.glide.Glide; +import com.google.android.material.imageview.ShapeableImageView; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; + +/** + * RecyclerView adapter for displaying group match player information in the Main Menu. + *

+ * This adapter is specifically designed for matches with 3 or more players (group matches). + * It displays a sorted list of players with their names, career averages, and profile pictures. + * Players are automatically sorted by their career average scores when the match data is updated. + *

+ *

+ * Key Features: + *

    + *
  • Displays player name, career average, and profile picture
  • + *
  • Automatically sorts players by career average
  • + *
  • Uses custom {@link PlayerItemView} for consistent player display
  • + *
  • Integrates with Glide for efficient image loading
  • + *
  • Optimized for infrequent updates (typically once per activity lifecycle)
  • + *
+ *

+ *

+ * Usage: + * This adapter is used in the Main Menu to display the results of the last played group match, + * providing users with a quick overview of player standings. + *

+ * + * @see RecyclerView.Adapter + * @see GroupMatchHolder + * @see PlayerScoreComparator + * @see Match + * @see Player + * @author Oche Companion Development Team + * @version 1.0 + * @since 1.0 + */ +public class MainMenuGroupMatchAdapter extends RecyclerView.Adapter { + + /** + * 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. + *

+ * This list is populated and sorted when {@link #updateMatch(Match)} is called. + * Players are sorted by their career average using {@link PlayerScoreComparator}. + * The list is cleared and repopulated on each match update. + *

+ * + * @see #updateMatch(Match) + * @see Player + */ + private final List mPlayersList = new ArrayList<>(); + + /** + * Creates a new {@link GroupMatchHolder} to display player information. + *

+ * This method is called by the RecyclerView when it needs a new ViewHolder to represent + * a player item. The method creates a custom {@link PlayerItemView} and configures its + * layout parameters to match the parent's width and wrap its content height. + *

+ *

+ * Layout Configuration: + *

    + *
  • Width: MATCH_PARENT (fills available width)
  • + *
  • Height: WRAP_CONTENT (adjusts to content size)
  • + *
+ *

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

+ * This method is called by the RecyclerView to display player data at the specified position. + * It retrieves the player from the internal list and delegates the display logic to the + * {@link GroupMatchHolder#setPlayer(Player)} method. + *

+ * + * @param holder The ViewHolder which should be updated to represent the player + * at the given position in the data set. + * @param position The position of the player within the adapter's data set. + * @see GroupMatchHolder#setPlayer(Player) + */ + @Override + public void onBindViewHolder(@NonNull final GroupMatchHolder holder, final int position) { + // Retrieve the player at this position and bind it to the holder + holder.setPlayer(mPlayersList.get(position)); + } + + /** + * Returns the total number of players in the data set held by the adapter. + *

+ * This method is called by the RecyclerView to determine how many items to display. + * The count is based on the number of players currently stored in {@link #mPlayersList}. + *

+ * + * @return The total number of players in the list. + */ + @Override + public int getItemCount() { + return mPlayersList.size(); + } + + /** + * Updates the adapter with new match data and refreshes the display. + *

+ * This method performs the following operations: + *

    + *
  1. Clears the existing player list
  2. + *
  3. Retrieves all players from the provided match
  4. + *
  5. Sorts players by their career average (ascending order)
  6. + *
  7. Adds the sorted players to the internal list
  8. + *
  9. Notifies the RecyclerView to refresh the display
  10. + *
+ *

+ *

+ * Performance Consideration: + * This method uses {@link #notifyDataSetChanged()}, which triggers a full refresh + * of the RecyclerView. The {@code @SuppressLint("NotifyDataSetChanged")} annotation + * is used because this method is typically called only once per activity lifecycle, + * making the performance impact negligible. For frequent updates, more granular + * notification methods (like notifyItemInserted, notifyItemChanged) would be preferable. + *

+ * + * @param match The Match object containing the players to display. Must not be null + * and should contain at least one player. + * @see Match#getAllPlayers() + * @see PlayerScoreComparator + */ + @SuppressLint("NotifyDataSetChanged") + public void updateMatch(final Match match) { + if (match == null) { + Log.d(TAG, "updateMatch: match is null, aborting update."); + return; + } + // Clear any existing player data + mPlayersList.clear(); + + if (match.getAllPlayers() == null || match.getAllPlayers().isEmpty()) { + Log.d(TAG, "updateMatch: No players found in the match, just clearing."); + notifyDataSetChanged(); + return; + } + + // Get all players from the match + final List 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. + *

+ * This ViewHolder manages the UI components for a single player entry, including: + *

    + *
  • Player name
  • + *
  • Player career average score
  • + *
  • Player profile picture/avatar
  • + *
+ *

+ *

+ * The ViewHolder uses a {@link PlayerItemView} as its root view and automatically + * hides the chevron icon since group match items are not clickable/expandable. + *

+ *

+ * Image Loading: + * Profile pictures are loaded using the Glide library for efficient memory management + * and caching. If a player has no profile picture, a default user icon is displayed. + *

+ * + * @see RecyclerView.ViewHolder + * @see PlayerItemView + * @see Player + */ + public static class GroupMatchHolder extends RecyclerView.ViewHolder { + + /** + * TextView displaying the player's username. + * Shows the {@link Player#username} field. + */ + private final TextView mPlayerNameView; + + /** + * TextView displaying the player's career average score. + *

+ * Shows the {@link Player#careerAverage} field formatted according to + * the string resource {@code R.string.txt_player_average_base}. + *

+ */ + private final TextView mPlayerScoreView; + + /** + * ShapeableImageView displaying the player's profile picture. + *

+ * Displays the image from {@link Player#profilePictureUri} if available, + * or a default user icon ({@code R.drawable.ic_users}) if no picture is set. + * Images are loaded using the Glide library for optimal performance. + *

+ */ + private final ShapeableImageView mPlayerImageView; + + /** + * Constructs a new GroupMatchHolder and initializes its child views. + *

+ * This constructor performs the following setup: + *

    + *
  1. Calls the parent ViewHolder constructor
  2. + *
  3. Retrieves references to the player name, score, and image views
  4. + *
  5. Hides the chevron icon since group match items are non-interactive
  6. + *
+ *

+ *

+ * The chevron icon is hidden because players in group match view are displayed + * for informational purposes only and do not support click/expand actions. + *

+ * + * @param itemView The root view of the ViewHolder, expected to be a {@link PlayerItemView}. + * Must not be null and must contain the required child views. + */ + public GroupMatchHolder(@NonNull final View itemView) { + super(itemView); + + // Initialize references to child views + mPlayerNameView = itemView.findViewById(R.id.tvPlayerName); + mPlayerScoreView = itemView.findViewById(R.id.tvPlayerAvg); + mPlayerImageView = itemView.findViewById(R.id.ivPlayerProfile); + + // Hide the chevron icon as group match items are not interactive + itemView.findViewById(R.id.ivChevron).setVisibility(View.GONE); + } + + /** + * Binds a Player object to this ViewHolder, updating all displayed information. + *

+ * This method updates the UI components with the player's data: + *

    + *
  • Name: Sets the player's username in the name TextView
  • + *
  • Score: Formats and displays the career average using a string resource
  • + *
  • Avatar: Loads the profile picture using Glide, or shows a default icon
  • + *
+ *

+ *

+ * Image Loading Strategy: + * If the player has a profile picture URI, Glide is used to load the image asynchronously + * with automatic caching and memory management. If no URI is available, a default user + * icon ({@code R.drawable.ic_users}) is displayed instead. + *

+ * + * @param player The Player object whose information should be displayed. + * Must not be null. + * @see Player#username + * @see Player#careerAverage + * @see Player#profilePictureUri + */ + public void setPlayer(final Player player) { + // Set player name + mPlayerNameView.setText(player.username); + + // Format and set career average score + mPlayerScoreView.setText(String.format( + itemView.getContext().getString(R.string.txt_player_average_base), + player.careerAverage)); + + // Load profile picture or show default icon + if (player.profilePictureUri != null) { + // Use Glide to load image from URI with caching and memory management + Glide.with(itemView.getContext()) + .load(player.profilePictureUri) + .into(mPlayerImageView); + } else { + // No profile picture available - show default user icon + mPlayerImageView.setImageResource(R.drawable.ic_users); + } + } + } + + /** + * Comparator for sorting Player objects based on their career average scores. + *

+ * This comparator implements ascending order sorting, meaning players with lower + * career averages will appear before players with higher averages in the sorted list. + *

+ *

+ * Sorting Behavior: + *

    + *
  • Returns negative if p1's average < p2's average (p1 comes first)
  • + *
  • Returns zero if p1's average == p2's average (equal priority)
  • + *
  • Returns positive if p1's average > p2's average (p2 comes first)
  • + *
+ *

+ *

+ * Usage: + * This comparator is used in {@link #updateMatch(Match)} to sort the player list + * before displaying it in the RecyclerView. + *

+ * + * @see Comparator + * @see Player#careerAverage + * @see #updateMatch(Match) + */ + public static class PlayerScoreComparator implements Comparator { + + /** + * Compares two Player objects based on their career average scores. + *

+ * Uses {@link Double#compare(double, double)} to perform a numerical comparison + * of the career average values, which properly handles special cases like NaN and infinity. + *

+ * + * @param p1 The first Player to compare. + * @param p2 The second Player to compare. + * @return A negative integer if p1's average is less than p2's average, + * zero if they are equal, or a positive integer if p1's average is greater. + */ + @Override + public int compare(final Player p1, final Player p2) { + // Compare career averages in ascending order + return Double.compare(p1.careerAverage, p2.careerAverage); + } + } +} diff --git a/app/src/main/java/com/aldo/apps/ochecompanion/ui/adapter/MainMenuPlayerAdapter.java b/app/src/main/java/com/aldo/apps/ochecompanion/ui/adapter/MainMenuPlayerAdapter.java new file mode 100644 index 0000000..7de578b --- /dev/null +++ b/app/src/main/java/com/aldo/apps/ochecompanion/ui/adapter/MainMenuPlayerAdapter.java @@ -0,0 +1,364 @@ +package com.aldo.apps.ochecompanion.ui.adapter; + +import static com.aldo.apps.ochecompanion.AddPlayerActivity.EXTRA_PLAYER_ID; + +import android.annotation.SuppressLint; +import android.content.Context; +import android.content.Intent; +import android.util.Log; +import android.view.View; +import android.view.ViewGroup; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.recyclerview.widget.RecyclerView; + +import com.aldo.apps.ochecompanion.AddPlayerActivity; +import com.aldo.apps.ochecompanion.R; +import com.aldo.apps.ochecompanion.database.objects.Player; +import com.aldo.apps.ochecompanion.ui.PlayerItemView; +import com.bumptech.glide.Glide; +import com.google.android.material.imageview.ShapeableImageView; + +import java.util.ArrayList; +import java.util.List; + +/** + * RecyclerView adapter for displaying the squad of players in the Main Menu. + *

+ * This adapter manages the display of all players in the user's squad, showing their names, + * career statistics, and profile pictures. Each player item is clickable, allowing users to + * navigate to the player edit screen to update their information. + *

+ *

+ * Key Features: + *

    + *
  • Displays player name, career average, and profile picture
  • + *
  • Supports click-to-edit functionality for each player
  • + *
  • Uses custom {@link PlayerItemView} for consistent player display
  • + *
  • Integrates with Glide for efficient image loading and caching
  • + *
  • Optimized for infrequent updates (typically once per activity lifecycle)
  • + *
+ *

+ *

+ * Usage: + * This adapter is used in the Main Menu activity to display the complete squad roster, + * allowing users to view and manage their players. + *

+ * + * @see RecyclerView.Adapter + * @see PlayerCardHolder + * @see Player + * @see AddPlayerActivity + * @author Oche Companion Development Team + * @version 1.0 + * @since 1.0 + */ +public class MainMenuPlayerAdapter extends RecyclerView.Adapter { + + /** + * 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. + *

+ * This list is populated when {@link #updatePlayers(List)} is called. + * The list maintains the order in which players are added from the database. + * All modifications to this list trigger a full RecyclerView refresh. + *

+ * + * @see #updatePlayers(List) + * @see Player + */ + private final List mPlayersList = new ArrayList<>(); + + + /** + * Creates a new {@link PlayerCardHolder} to display player information. + *

+ * This method is called by the RecyclerView when it needs a new ViewHolder to represent + * a player item. The method creates a custom {@link PlayerItemView} and configures its + * layout parameters to match the parent's width and wrap its content height. + *

+ *

+ * Layout Configuration: + *

    + *
  • Width: MATCH_PARENT (fills available width)
  • + *
  • Height: WRAP_CONTENT (adjusts to content size)
  • + *
+ *

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

+ * This method is called by the RecyclerView to display player data at the specified position. + * It retrieves the player from the internal list and delegates the display logic to the + * {@link PlayerCardHolder#setPlayer(Player)} method. + *

+ * + * @param holder The ViewHolder which should be updated to represent the player + * at the given position in the data set. + * @param position The position of the player within the adapter's data set. + * @see PlayerCardHolder#setPlayer(Player) + */ + @Override + public void onBindViewHolder(@NonNull final PlayerCardHolder holder, final int position) { + // Retrieve the player at this position and bind it to the holder + holder.setPlayer(mPlayersList.get(position)); + } + + /** + * Returns the total number of players in the data set held by the adapter. + *

+ * This method is called by the RecyclerView to determine how many items to display. + * The count is based on the number of players currently stored in {@link #mPlayersList}. + *

+ * + * @return The total number of players in the list. + */ + @Override + public int getItemCount() { + return mPlayersList.size(); + } + + /** + * Updates the adapter with a new list of players and refreshes the display. + *

+ * This method performs the following operations: + *

    + *
  1. Clears the existing player list
  2. + *
  3. Adds all players from the provided list
  4. + *
  5. Notifies the RecyclerView to refresh the display
  6. + *
+ *

+ *

+ * Performance Consideration: + * This method uses {@link #notifyDataSetChanged()}, which triggers a full refresh + * of the RecyclerView. The {@code @SuppressLint("NotifyDataSetChanged")} annotation + * is used because this method is typically called only once per activity lifecycle + * (when the activity resumes), making the performance impact negligible. For frequent + * updates, more granular notification methods (like notifyItemInserted, notifyItemChanged) + * would be preferable. + *

+ * + * @param players The list of Player objects to display. Must not be null. + * Can be empty to clear the display. + * @see Player + */ + @SuppressLint("NotifyDataSetChanged") + public void updatePlayers(final List 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. + *

+ * This ViewHolder manages the UI components for a single player entry, including: + *

    + *
  • Player name
  • + *
  • Player career average score
  • + *
  • Player profile picture/avatar
  • + *
  • Click interaction to edit player details
  • + *
+ *

+ *

+ * The ViewHolder uses a {@link PlayerItemView} as its root view and supports click + * events that navigate to the {@link AddPlayerActivity} for editing player information. + *

+ *

+ * Image Loading: + * Profile pictures are loaded using the Glide library for efficient memory management, + * caching, and smooth scrolling performance. If a player has no profile picture, + * a default user icon is displayed. + *

+ * + * @see RecyclerView.ViewHolder + * @see PlayerItemView + * @see Player + * @see AddPlayerActivity + */ + public static class PlayerCardHolder extends RecyclerView.ViewHolder { + + /** + * TextView displaying the player's username. + * Shows the {@link Player#username} field. + */ + private final TextView mPlayerNameView; + + /** + * TextView displaying the player's career average score. + *

+ * Shows the {@link Player#careerAverage} field formatted according to + * the string resource {@code R.string.txt_player_average_base}. + *

+ */ + private final TextView mPlayerScoreView; + + /** + * ShapeableImageView displaying the player's profile picture. + *

+ * Displays the image from {@link Player#profilePictureUri} if available, + * or a default user icon ({@code R.drawable.ic_users}) if no picture is set. + * Images are loaded using the Glide library for optimal performance. + *

+ */ + private final ShapeableImageView mPlayerImageView; + + /** + * Constructs a new PlayerCardHolder and initializes its child views. + *

+ * This constructor performs the following setup: + *

    + *
  1. Calls the parent ViewHolder constructor
  2. + *
  3. Retrieves references to the player name, score, and image views
  4. + *
+ *

+ *

+ * Unlike group match items, the chevron icon is kept visible since player items + * are interactive and clicking them navigates to the edit screen. + *

+ * + * @param itemView The root view of the ViewHolder, expected to be a {@link PlayerItemView}. + * Must not be null and must contain the required child views. + */ + public PlayerCardHolder(@NonNull final View itemView) { + super(itemView); + + // Initialize references to child views + mPlayerNameView = itemView.findViewById(R.id.tvPlayerName); + mPlayerScoreView = itemView.findViewById(R.id.tvPlayerAvg); + mPlayerImageView = itemView.findViewById(R.id.ivPlayerProfile); + } + + /** + * Binds a Player object to this ViewHolder, updating all displayed information. + *

+ * This method updates the UI components with the player's data: + *

    + *
  • Click Listener: Sets up navigation to edit player details
  • + *
  • Name: Sets the player's username in the name TextView
  • + *
  • Score: Formats and displays the career average using a string resource
  • + *
  • Avatar: Loads the profile picture using Glide, or shows a default icon
  • + *
+ *

+ *

+ * Interaction: + * When the item is clicked, it launches {@link AddPlayerActivity} with the player's ID, + * allowing the user to edit the player's information. + *

+ *

+ * Image Loading Strategy: + * If the player has a profile picture URI, Glide is used to load the image asynchronously + * with automatic caching and memory management. If no URI is available, a default user + * icon ({@code R.drawable.ic_users}) is displayed instead. + *

+ * + * @param player The Player object whose information should be displayed. + * Must not be null. + * @see Player#username + * @see Player#careerAverage + * @see Player#profilePictureUri + * @see Player#id + * @see #startEditPlayerActivity(Context, Player) + */ + public void setPlayer(final Player player) { + Log.d(TAG, "setPlayer() called with: player = [" + player + "]"); + + // Set up click listener to navigate to edit player screen + itemView.setOnClickListener(v -> startEditPlayerActivity(itemView.getContext(), player)); + + // Set player name + mPlayerNameView.setText(player.username); + + // Format and set career average score + mPlayerScoreView.setText(String.format( + itemView.getContext().getString(R.string.txt_player_average_base), + player.careerAverage)); + + // Load profile picture or show default icon + if (player.profilePictureUri != null) { + // Use Glide to load image from URI with caching and memory management + Glide.with(itemView.getContext()) + .load(player.profilePictureUri) + .into(mPlayerImageView); + } else { + // No profile picture available - show default user icon + mPlayerImageView.setImageResource(R.drawable.ic_users); + } + } + + /** + * Launches the AddPlayerActivity to edit the specified player's information. + *

+ * This helper method creates an intent to start {@link AddPlayerActivity} in edit mode, + * passing the player's ID as an extra. The activity will load the player's existing data + * and allow the user to modify their username and profile picture. + *

+ *

+ * Intent Configuration: + * The player's ID is passed via the {@link AddPlayerActivity#EXTRA_PLAYER_ID} extra, + * which signals to the activity that it should load and edit an existing player + * rather than creating a new one. + *

+ * + * @param context The Context from which the activity should be launched. + * Typically obtained from the item view. + * @param player The Player object whose information should be edited. + * The player's ID is passed to the edit activity. + * @see AddPlayerActivity + * @see AddPlayerActivity#EXTRA_PLAYER_ID + */ + private void startEditPlayerActivity(final Context context, final Player player) { + // Create intent for AddPlayerActivity + final Intent intent = new Intent(context, AddPlayerActivity.class); + + // Pass player ID to enable edit mode + intent.putExtra(EXTRA_PLAYER_ID, player.id); + + // Launch the activity + context.startActivity(intent); + } + } +} diff --git a/app/src/main/res/drawable/btn_grid_item.xml b/app/src/main/res/drawable/btn_grid_item.xml new file mode 100644 index 0000000..b1f6c34 --- /dev/null +++ b/app/src/main/res/drawable/btn_grid_item.xml @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/btn_primary_volt.xml b/app/src/main/res/drawable/btn_primary_volt.xml new file mode 100644 index 0000000..5fcafa2 --- /dev/null +++ b/app/src/main/res/drawable/btn_primary_volt.xml @@ -0,0 +1,10 @@ + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_chevron_right.xml b/app/src/main/res/drawable/ic_chevron_right.xml new file mode 100644 index 0000000..00d91d0 --- /dev/null +++ b/app/src/main/res/drawable/ic_chevron_right.xml @@ -0,0 +1,10 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_history.xml b/app/src/main/res/drawable/ic_history.xml new file mode 100644 index 0000000..b4e5c64 --- /dev/null +++ b/app/src/main/res/drawable/ic_history.xml @@ -0,0 +1,10 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_launcher_background.xml b/app/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 0000000..b9f8761 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,10 @@ + + + + diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/app/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 0000000..1b5fb0a --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,51 @@ + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_play.xml b/app/src/main/res/drawable/ic_play.xml new file mode 100644 index 0000000..88a9354 --- /dev/null +++ b/app/src/main/res/drawable/ic_play.xml @@ -0,0 +1,10 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_settings.xml b/app/src/main/res/drawable/ic_settings.xml new file mode 100644 index 0000000..9699baa --- /dev/null +++ b/app/src/main/res/drawable/ic_settings.xml @@ -0,0 +1,10 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_target.xml b/app/src/main/res/drawable/ic_target.xml new file mode 100644 index 0000000..a9c003f --- /dev/null +++ b/app/src/main/res/drawable/ic_target.xml @@ -0,0 +1,10 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_users.xml b/app/src/main/res/drawable/ic_users.xml new file mode 100644 index 0000000..3eceb2e --- /dev/null +++ b/app/src/main/res/drawable/ic_users.xml @@ -0,0 +1,10 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/oche_logo.xml b/app/src/main/res/drawable/oche_logo.xml new file mode 100644 index 0000000..b9a938a --- /dev/null +++ b/app/src/main/res/drawable/oche_logo.xml @@ -0,0 +1,48 @@ + + + + + + + + + + diff --git a/app/src/main/res/drawable/shape_checkout_glow.xml b/app/src/main/res/drawable/shape_checkout_glow.xml new file mode 100644 index 0000000..ed51b4a --- /dev/null +++ b/app/src/main/res/drawable/shape_checkout_glow.xml @@ -0,0 +1,7 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/shape_circle_overlay.xml b/app/src/main/res/drawable/shape_circle_overlay.xml new file mode 100644 index 0000000..7f91249 --- /dev/null +++ b/app/src/main/res/drawable/shape_circle_overlay.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/shape_dart_pill_active.xml b/app/src/main/res/drawable/shape_dart_pill_active.xml new file mode 100644 index 0000000..386705b --- /dev/null +++ b/app/src/main/res/drawable/shape_dart_pill_active.xml @@ -0,0 +1,7 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/shape_dart_pill_empty.xml b/app/src/main/res/drawable/shape_dart_pill_empty.xml new file mode 100644 index 0000000..4e3914e --- /dev/null +++ b/app/src/main/res/drawable/shape_dart_pill_empty.xml @@ -0,0 +1,7 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/shape_dashed_border.xml b/app/src/main/res/drawable/shape_dashed_border.xml new file mode 100644 index 0000000..f242a93 --- /dev/null +++ b/app/src/main/res/drawable/shape_dashed_border.xml @@ -0,0 +1,18 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/shape_keyboard_tile.xml b/app/src/main/res/drawable/shape_keyboard_tile.xml new file mode 100644 index 0000000..df50878 --- /dev/null +++ b/app/src/main/res/drawable/shape_keyboard_tile.xml @@ -0,0 +1,7 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/shape_multiplier_active.xml b/app/src/main/res/drawable/shape_multiplier_active.xml new file mode 100644 index 0000000..fa19242 --- /dev/null +++ b/app/src/main/res/drawable/shape_multiplier_active.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/shape_multiplier_blue.xml b/app/src/main/res/drawable/shape_multiplier_blue.xml new file mode 100644 index 0000000..4d81487 --- /dev/null +++ b/app/src/main/res/drawable/shape_multiplier_blue.xml @@ -0,0 +1,7 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/shape_multiplier_red.xml b/app/src/main/res/drawable/shape_multiplier_red.xml new file mode 100644 index 0000000..94b6511 --- /dev/null +++ b/app/src/main/res/drawable/shape_multiplier_red.xml @@ -0,0 +1,7 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/shape_round_surface.xml b/app/src/main/res/drawable/shape_round_surface.xml new file mode 100644 index 0000000..8aa6ebb --- /dev/null +++ b/app/src/main/res/drawable/shape_round_surface.xml @@ -0,0 +1,10 @@ + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/activity_add_player.xml b/app/src/main/res/layout/activity_add_player.xml new file mode 100644 index 0000000..452c0ee --- /dev/null +++ b/app/src/main/res/layout/activity_add_player.xml @@ -0,0 +1,109 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + +