Fixed animation, fixed UI

This commit is contained in:
Alexander Doerflinger
2026-01-30 14:43:24 +01:00
parent 3fc5966e40
commit 60e707b9f6
7 changed files with 180 additions and 34 deletions

View File

@@ -6,8 +6,11 @@
that other apps created.
-->
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"
android:maxSdkVersion="32" />
<uses-permission android:name="android.permission.VIBRATE"/>
<attribution android:tag="oche_gameplay" android:label="@string/attribution_label" />
<application
android:allowBackup="true"

View File

@@ -1,9 +1,12 @@
package com.aldo.apps.ochecompanion;
import android.Manifest;
import android.content.pm.PackageManager;
import android.graphics.Bitmap;
import android.graphics.BitmapFactory;
import android.graphics.RectF;
import android.net.Uri;
import android.os.Build;
import android.os.Bundle;
import android.util.Log;
import android.view.MotionEvent;
@@ -16,7 +19,9 @@ import android.widget.Toast;
import androidx.activity.result.ActivityResultLauncher;
import androidx.activity.result.contract.ActivityResultContracts;
import androidx.annotation.NonNull;
import androidx.appcompat.app.AlertDialog;
import androidx.appcompat.app.AppCompatActivity;
import androidx.core.content.ContextCompat;
import androidx.core.graphics.Insets;
import androidx.core.view.ViewCompat;
import androidx.core.view.WindowInsetsCompat;
@@ -157,6 +162,15 @@ public class AddPlayerActivity extends AppCompatActivity {
}
});
private final ActivityResultLauncher<String> 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;

View File

@@ -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<Player> 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);

View File

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

View File

@@ -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) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
mContext = context.createAttributionContext("oche_gameplay");
} else {
mContext = context;
mSoundPool = new SoundPool.Builder().setMaxStreams(5).build();
}
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);

View File

@@ -1,8 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<translate xmlns:android="http://schemas.android.com/apk/res/android"
android:duration="50"
android:fromXDelta="0"
android:toXDelta="15"
android:repeatCount="8"
android:duration="80"
android:fromXDelta="-12"
android:toXDelta="12"
android:repeatCount="15"
android:repeatMode="reverse"
android:interpolator="@android:anim/linear_interpolator" />
android:interpolator="@android:anim/cycle_interpolator" />

View File

@@ -21,6 +21,10 @@
<string name="txt_update_profile_username_save">Update Squad</string>
<string name="txt_cancel_crop">Cancel</string>
<string name="txt_confirm_crop">Confirm Crop</string>
<string name="txt_permission_hint_title">Image Permission required</string>
<string name="txt_permission_hint_description">This app needs access to your images to set a custom profile picture</string>
<string name="txt_permission_hint_button_ok">OK</string>
<string name="txt_permission_hint_button_cancel">Cancel</string>
<!-- GameActivity -->
<string name="txt_game_btn_single">Single</string>
@@ -32,9 +36,9 @@
<!-- Preference Strings -->
<string name="pref_key_day_night_mode">day_night_mode</string>
<string name="pref_key_standard_game_mode">standard_game_mode</string>
<string name="pref_game_mode_701_value">701</string>
<string name="pref_game_mode_501_value">501</string>
<string name="pref_game_mode_301_value">301</string>
<string name="pref_game_mode_701_value">Standard 701 - Double Out</string>
<string name="pref_game_mode_501_value">Standard 501 - Double Out</string>
<string name="pref_game_mode_301_value">Standard 301 - Double Out</string>
<string name="pref_game_mode_cricket_value">Cricket</string>
<string name="pref_desc_day_night_mode">Day/Night Mode</string>
<string name="pref_title_standard_game_mode">Standard Game Mode</string>
@@ -61,4 +65,7 @@
<string name="attachment_summary_on">Automatically download attachments for incoming emails
</string>
<string name="attachment_summary_off">Only download attachments when manually requested</string>
<!-- Other strings -->
<string name="attribution_label">"Oche Game Effects"</string>
</resources>