diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index e1ca4a6..0dff777 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -6,8 +6,11 @@ that other apps created. --> + + requestPermissionLauncher = registerForActivityResult( + new ActivityResultContracts.RequestPermission(), isGranted -> { + if (isGranted) { + mGetContent.launch("image/*"); + } else { + Toast.makeText(this, "Permission denied to read images", Toast.LENGTH_SHORT).show(); + } + }); + /** * Called when the activity is first created. Initializes UI and loads existing player if present. * @@ -207,7 +221,7 @@ public class AddPlayerActivity extends AppCompatActivity { mCropOverlay = findViewById(R.id.cropOverlay); // Set up click listeners - mProfilePictureView.setOnClickListener(v -> mGetContent.launch("image/*")); + mProfilePictureView.setOnClickListener(v -> checkForPermissionAndLaunchImagePicker()); mSaveButton.setOnClickListener(v -> savePlayer()); if (mBtnDelete != null) { mBtnDelete.setOnClickListener(v -> deletePlayer()); @@ -216,6 +230,32 @@ public class AddPlayerActivity extends AppCompatActivity { findViewById(R.id.btnCancelCrop).setOnClickListener(v -> exitCropMode()); } + private void checkForPermissionAndLaunchImagePicker() { + final String permission = Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU + ? Manifest.permission.READ_MEDIA_IMAGES + : Manifest.permission.READ_EXTERNAL_STORAGE; + + if (ContextCompat.checkSelfPermission(this, permission) == PackageManager.PERMISSION_GRANTED) { + // Already have permission + mGetContent.launch("image/*"); + } else if (shouldShowRequestPermissionRationale(permission)) { + // Explain to the user why the permission is needed before requesting + showRationaleDialog(permission); + } else { + // Directly request the permission + requestPermissionLauncher.launch(permission); + } + } + + private void showRationaleDialog(final String permission) { + new AlertDialog.Builder(this) + .setTitle(R.string.txt_permission_hint_title) + .setMessage(R.string.txt_permission_hint_description) + .setPositiveButton(R.string.txt_permission_hint_button_ok, (d, w) -> requestPermissionLauncher.launch(permission)) + .setNegativeButton(R.string.txt_permission_hint_button_cancel, null) + .show(); + } + /** * Initializes gesture detectors to handle pinch-to-zoom and pan gestures. */ @@ -436,7 +476,9 @@ public class AddPlayerActivity extends AppCompatActivity { } /** - * Deletes the selected player from the database. + * Deletes the currently loaded player from the database. + * Shows confirmation toast and closes the activity upon successful deletion. + * Runs database operation on background thread. Does nothing if no player is loaded. */ private void deletePlayer() { if (mExistingPlayer == null) return; diff --git a/app/src/main/java/com/aldo/apps/ochecompanion/GameActivity.java b/app/src/main/java/com/aldo/apps/ochecompanion/GameActivity.java index 1641ce3..66168b1 100644 --- a/app/src/main/java/com/aldo/apps/ochecompanion/GameActivity.java +++ b/app/src/main/java/com/aldo/apps/ochecompanion/GameActivity.java @@ -4,7 +4,6 @@ import android.content.Context; import android.content.Intent; import android.content.res.ColorStateList; import android.graphics.Color; -import android.media.SoundPool; import android.os.Build; import android.os.Bundle; import android.os.VibrationEffect; @@ -26,10 +25,8 @@ import androidx.core.view.ViewCompat; import androidx.core.view.WindowInsetsCompat; import com.aldo.apps.ochecompanion.database.AppDatabase; -import com.aldo.apps.ochecompanion.database.objects.Match; import com.aldo.apps.ochecompanion.database.objects.Player; import com.aldo.apps.ochecompanion.database.objects.Statistics; -import com.aldo.apps.ochecompanion.utils.CheckoutConstants; import com.aldo.apps.ochecompanion.utils.CheckoutEngine; import com.aldo.apps.ochecompanion.utils.DartsConstants; import com.aldo.apps.ochecompanion.utils.SoundEngine; @@ -169,7 +166,7 @@ public class GameActivity extends AppCompatActivity { /** * Array of three TextViews showing darts thrown in current turn. */ - private TextView[] tvDartPills = new TextView[3]; + private final TextView[] tvDartPills = new TextView[3]; /** * GridLayout container holding numeric keyboard buttons (1-20). @@ -220,9 +217,7 @@ public class GameActivity extends AppCompatActivity { new Thread(() -> { final List allAvailablePlayers = AppDatabase.getDatabase(GameActivity.this).playerDao().getAllPlayers(); Log.d(TAG, "onCreate: allAvailablePlayers = [" + allAvailablePlayers + "]"); - runOnUiThread(() -> { - setupGame(allAvailablePlayers); - }); + runOnUiThread(() -> setupGame(allAvailablePlayers)); }).start(); } @@ -395,6 +390,16 @@ public class GameActivity extends AppCompatActivity { } } + /** + * Updates player statistics in the database after a turn. + * Tracks darts thrown, points made, missed darts, and updates career average. + * Runs on background thread to avoid blocking UI. + * + * @param active Current player's game state + * @param dartsThrown Number of darts thrown this turn + * @param pointsMade Total points scored this turn + * @param wasBust Whether the turn resulted in a bust + */ private void updatePlayerStats(GameActivity.X01State active, int dartsThrown, int pointsMade, boolean wasBust) { new Thread(() -> { final Player player = active.player; @@ -411,7 +416,7 @@ public class GameActivity extends AppCompatActivity { // Calculate career average: total points / total darts thrown final long totalDarts = playerStats.getDartsThrown(); if (totalDarts > 0) { - player.careerAverage = (double) playerStats.getOverallPointsMade() / totalDarts; + player.careerAverage = (double) playerStats.getOverallPointsMade() / totalDarts * 3; } else { player.careerAverage = 0.0; } @@ -443,7 +448,15 @@ public class GameActivity extends AppCompatActivity { final Vibrator vibrator = (Vibrator) getSystemService(VIBRATOR_SERVICE); if (vibrator != null && Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - vibrator.vibrate(VibrationEffect.createOneShot(300, VibrationEffect.DEFAULT_AMPLITUDE)); + Log.d(TAG, "submitTurn: Pattern vibration"); + // Pattern that should match the 180 shout. + long[] pattern = {0, 150, 100, 1650, 50, 150, 10, 500, 300, 200}; + vibrator.vibrate(VibrationEffect.createWaveform(pattern, -1)); + } else if (vibrator != null) { + Log.d(TAG, "submitTurn: Vibrating legacy mode"); + vibrator.vibrate(500); + } else { + Log.e(TAG, "submitTurn: Vibrator not available"); } mSoundEngine.playOneHundredAndEightySound(); } @@ -601,9 +614,11 @@ public class GameActivity extends AppCompatActivity { /** * Handles win condition when a player finishes on zero with a double. - * Displays win toast and finishes activity. + * Updates statistics, displays win toast, and plays celebration animation. * * @param winner X01State of the winning player + * @param dartsThrown Number of darts thrown in the winning turn + * @param pointsMade Points scored in the winning turn */ private void handleWin(final X01State winner, final int dartsThrown, final int pointsMade) { updatePlayerStats(winner, dartsThrown, pointsMade, false); @@ -619,6 +634,12 @@ public class GameActivity extends AppCompatActivity { // - Offer rematch } + /** + * Plays confetti animation and displays winner's name overlay. + * Shows full-screen dimmer with celebratory confetti effect. + * + * @param winnerName Name of the winning player to display + */ private void playWinnerAnimation(final String winnerName) { final KonfettiView konfettiView = findViewById(R.id.konfetti_view); final View dimmerLayout = findViewById(R.id.dimmer); diff --git a/app/src/main/java/com/aldo/apps/ochecompanion/MainMenuActivity.java b/app/src/main/java/com/aldo/apps/ochecompanion/MainMenuActivity.java index f9421b6..fd46fbf 100644 --- a/app/src/main/java/com/aldo/apps/ochecompanion/MainMenuActivity.java +++ b/app/src/main/java/com/aldo/apps/ochecompanion/MainMenuActivity.java @@ -4,7 +4,6 @@ import android.content.Intent; import android.content.SharedPreferences; import android.os.Bundle; import android.util.Log; -import android.view.View; import android.widget.TextView; import androidx.activity.EdgeToEdge; @@ -20,6 +19,7 @@ import com.aldo.apps.ochecompanion.database.AppDatabase; import com.aldo.apps.ochecompanion.database.objects.Player; import com.aldo.apps.ochecompanion.database.objects.Match; import com.aldo.apps.ochecompanion.ui.MatchRecapView; +import com.aldo.apps.ochecompanion.ui.QuickStartButton; import com.aldo.apps.ochecompanion.ui.adapter.MainMenuPlayerAdapter; import com.aldo.apps.ochecompanion.utils.DartsConstants; import com.aldo.apps.ochecompanion.utils.UIConstants; @@ -77,7 +77,11 @@ public class MainMenuActivity extends AppCompatActivity { return insets; }); - findViewById(R.id.quick_start_btn).setOnClickListener(v -> quickStart()); + final QuickStartButton quickStartBtn = findViewById(R.id.quick_start_btn); + final String defaultGameMode = mSettingsPref.getString(getString(R.string.pref_desc_standard_game_mode), + getString(R.string.pref_game_mode_501_value)); + quickStartBtn.setSubText(defaultGameMode); + quickStartBtn.setOnClickListener(v -> quickStart()); findViewById(R.id.btnSettings).setOnClickListener(v -> launchSettings()); // Set up match recap view with test data functionality diff --git a/app/src/main/java/com/aldo/apps/ochecompanion/utils/SoundEngine.java b/app/src/main/java/com/aldo/apps/ochecompanion/utils/SoundEngine.java index c9772d5..2bb74b0 100644 --- a/app/src/main/java/com/aldo/apps/ochecompanion/utils/SoundEngine.java +++ b/app/src/main/java/com/aldo/apps/ochecompanion/utils/SoundEngine.java @@ -1,14 +1,21 @@ package com.aldo.apps.ochecompanion.utils; +import static android.media.AudioAttributes.CONTENT_TYPE_SONIFICATION; +import static android.media.AudioAttributes.USAGE_GAME; + import android.content.Context; +import android.media.AudioAttributes; import android.media.SoundPool; +import android.os.Build; import android.util.Log; import com.aldo.apps.ochecompanion.R; -import java.lang.ref.WeakReference; -import java.util.SimpleTimeZone; - +/** + * Singleton sound engine for managing game audio effects. + * Uses Android SoundPool API for low-latency sound playback during gameplay. + * Supports winner celebration, bust notifications, and perfect score achievements. + */ public final class SoundEngine { /** @@ -16,30 +23,80 @@ public final class SoundEngine { */ private static final String TAG = "SoundEngine"; - private Context mContext; + /** + * Application context used for audio operations. + * On Android R+, uses attribution context for proper audio tracking. + */ + private final Context mContext; + /** + * Singleton instance of the SoundEngine. + */ private static SoundEngine sInstance; + /** + * Android SoundPool for low-latency audio playback. + * Configured with game audio attributes and max 5 concurrent streams. + */ private final SoundPool mSoundPool; + /** + * Flag indicating whether all sound resources have been loaded. + * Set to true by the OnLoadCompleteListener once all sounds are ready. + */ private boolean mIsReady; - private int mWinnerSoundId; + /** + * Sound ID for the winner celebration audio effect. + * Loaded from R.raw.winner resource. + */ + private final int mWinnerSoundId; - private int mBustedSoundId; + /** + * Sound ID for the bust notification audio effect. + * Loaded from R.raw.busted resource. + */ + private final int mBustedSoundId; - private int m180SoundId; + /** + * Sound ID for the perfect score (180) celebration audio effect. + * Loaded from R.raw.onehundredandeighty resource. + */ + private final int m180SoundId; + /** + * Private constructor for singleton pattern. + * Initializes SoundPool with game audio attributes and preloads sound effects. + * + * @param context Application context for loading sound resources + */ private SoundEngine(final Context context) { - mContext = context; - mSoundPool = new SoundPool.Builder().setMaxStreams(5).build(); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + mContext = context.createAttributionContext("oche_gameplay"); + } else { + mContext = context; + } + mSoundPool = new SoundPool.Builder() + .setMaxStreams(5) + .setAudioAttributes(new AudioAttributes.Builder() + .setUsage(USAGE_GAME) + .setContentType(CONTENT_TYPE_SONIFICATION) + .build()) + .build(); mSoundPool.setOnLoadCompleteListener((soundPool, sampleId, status) -> mIsReady = true); - mWinnerSoundId = mSoundPool.load(context, R.raw.winner, 1); - mBustedSoundId = mSoundPool.load(context, R.raw.busted, 1); - m180SoundId = mSoundPool.load(context, R.raw.onehundredandeighty, 1); + mWinnerSoundId = mSoundPool.load(context.getApplicationContext(), R.raw.winner, 1); + mBustedSoundId = mSoundPool.load(context.getApplicationContext(), R.raw.busted, 1); + m180SoundId = mSoundPool.load(context.getApplicationContext(), R.raw.onehundredandeighty, 1); } + /** + * Gets the singleton SoundEngine instance. + * Creates instance on first call using the provided context. + * + * @param context Application context for initialization + * @return Singleton SoundEngine instance + */ public static SoundEngine getInstance(final Context context) { if (sInstance == null) { sInstance = new SoundEngine(context); @@ -47,6 +104,10 @@ public final class SoundEngine { return sInstance; } + /** + * Plays the winner celebration sound effect. + * Only plays if sound engine has finished loading resources. + */ public void playWinnerSound() { if (mIsReady) { mSoundPool.play(mWinnerSoundId, 1.0f, 1.0f, 0, 0, 1.0f); @@ -55,6 +116,10 @@ public final class SoundEngine { } } + /** + * Plays the bust notification sound effect. + * Only plays if sound engine has finished loading resources. + */ public void playBustedSound() { if (mIsReady) { mSoundPool.play(mBustedSoundId, 1.0f, 1.0f, 0, 0, 1.0f); @@ -63,6 +128,10 @@ public final class SoundEngine { } } + /** + * Plays the 180 score notification sound effect. + * Only plays if sound engine has finished loading resources. + */ public void playOneHundredAndEightySound() { if (mIsReady) { mSoundPool.play(m180SoundId, 1.0f, 1.0f, 0, 0, 1.0f); diff --git a/app/src/main/res/anim/shake.xml b/app/src/main/res/anim/shake.xml index f3d3989..f7343f4 100644 --- a/app/src/main/res/anim/shake.xml +++ b/app/src/main/res/anim/shake.xml @@ -1,8 +1,8 @@ \ No newline at end of file + android:interpolator="@android:anim/cycle_interpolator" /> \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 520c6d8..b789a9a 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -21,6 +21,10 @@ Update Squad Cancel Confirm Crop + Image Permission required + This app needs access to your images to set a custom profile picture + OK + Cancel Single @@ -32,9 +36,9 @@ day_night_mode standard_game_mode - 701 - 501 - 301 + Standard 701 - Double Out + Standard 501 - Double Out + Standard 301 - Double Out Cricket Day/Night Mode Standard Game Mode @@ -61,4 +65,7 @@ Automatically download attachments for incoming emails Only download attachments when manually requested + + + "Oche Game Effects" \ No newline at end of file