Made use of the DatabaseHelper in all cases now.

Fixed continue logic by moving it to onResume
This commit is contained in:
Alexander Doerflinger
2026-02-06 15:45:19 +01:00
parent e2ec4f478e
commit 796e0e4389
7 changed files with 467 additions and 209 deletions

View File

@@ -9,6 +9,7 @@ import android.net.Uri;
import android.os.Build;
import android.os.Bundle;
import com.aldo.apps.ochecompanion.database.DatabaseHelper;
import com.aldo.apps.ochecompanion.ui.PlayerStatsView;
import com.aldo.apps.ochecompanion.utils.Log;
import android.view.MotionEvent;
@@ -43,8 +44,10 @@ import java.io.InputStream;
import java.util.UUID;
/**
* Manages creation and editing of player profiles with username, profile picture, and image cropping.
* Operates in Form Mode (profile editing) or Crop Mode (interactive image cropping with pan and zoom).
* Manages creation and editing of player profiles with username, profile
* picture, and image cropping.
* Operates in Form Mode (profile editing) or Crop Mode (interactive image
* cropping with pan and zoom).
* Pass EXTRA_PLAYER_ID to edit existing player; otherwise creates new player.
*/
public class AddPlayerActivity extends BaseActivity {
@@ -53,39 +56,39 @@ public class AddPlayerActivity extends BaseActivity {
* Tag for logging.
*/
private static final String TAG = "Oche_AddPlayer";
/**
* Intent extra key for passing existing player's ID for editing.
*/
public static final String EXTRA_PLAYER_ID = "extra_player_id";
// ========== UI - Main Form Views ==========
/**
* Container layout for the main player profile form.
*/
private View mLayoutForm;
/**
* Container layout for the image cropping interface.
*/
private View mLayoutCropper;
/**
* ImageView displaying the player's profile picture.
*/
private ShapeableImageView mProfilePictureView;
/**
* EditText field for entering or editing the player's username.
*/
private EditText mUserNameInput;
/**
* TextView displaying the activity title.
*/
private TextView mTitleView;
/**
* Button to save the player profile.
*/
@@ -102,12 +105,12 @@ public class AddPlayerActivity extends BaseActivity {
private ImageView mBtnDelete;
// ========== UI - Cropper Views ==========
/**
* ImageView displaying the full selected image during Crop Mode.
*/
private ImageView mIvCropPreview;
/**
* Custom overlay view that renders the square crop area boundary.
*/
@@ -124,49 +127,55 @@ public class AddPlayerActivity extends BaseActivity {
* Boolean flag indicating whether the stats view is shown.
*/
private boolean mIsStatsViewShown = false;
/**
* Absolute file path to the saved profile picture in internal storage.
*/
private String mInternalImagePath;
/**
* URI of the original image selected from the gallery.
*/
private Uri mRawSelectedUri;
/**
* Database ID of the player being edited (-1 for new player).
*/
private long mExistingPlayerId = -1;
/**
* Player object loaded from the database (null when creating new player).
*/
private Player mExistingPlayer;
// ========== Gesture State ==========
/**
* Last recorded X coordinate during pan gesture.
*/
private float mLastTouchX;
/**
* Last recorded Y coordinate during pan gesture.
*/
private float mLastTouchY;
/**
* Detector for handling pinch-to-zoom gestures.
*/
private ScaleGestureDetector mScaleDetector;
/**
* Current scale factor applied to the crop preview image (1.0 default, clamped 0.1 to 10.0).
* Current scale factor applied to the crop preview image (1.0 default, clamped
* 0.1 to 10.0).
*/
private float mScaleFactor = UIConstants.SCALE_NORMAL;
/**
* Database helper for synchronous database operations.
*/
private DatabaseHelper mDatabaseHelper;
/**
* ActivityResultLauncher for selecting images from the device gallery.
*/
@@ -190,7 +199,8 @@ public class AddPlayerActivity extends BaseActivity {
});
/**
* Called when the activity is first created. Initializes UI and loads existing player if present.
* Called when the activity is first created. Initializes UI and loads existing
* player if present.
*
* @param savedInstanceState Saved instance state.
*/
@@ -199,16 +209,19 @@ public class AddPlayerActivity extends BaseActivity {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_add_player);
Log.d(TAG, "AddPlayerActivity Created");
// Configure window insets to properly handle system bars (status bar, navigation bar)
// 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;
});
mDatabaseHelper = DatabaseHelper.getInstance(this);
// Initialize all UI components and their click listeners
initViews();
// Set up touch gesture handlers for image cropping
setupGestures();
@@ -227,7 +240,8 @@ public class AddPlayerActivity extends BaseActivity {
}
/**
* Handles the back button press. If the stats view is currently shown, it hides it instead of exiting.
* Handles the back button press. If the stats view is currently shown, it hides
* it instead of exiting.
* Otherwise, it finishes the activity as normal.
*/
private void handleBackPressed() {
@@ -248,7 +262,7 @@ public class AddPlayerActivity extends BaseActivity {
// 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);
@@ -257,7 +271,7 @@ public class AddPlayerActivity extends BaseActivity {
mPlayerStatsView = findViewById(R.id.player_stats_view);
mSaveButton = findViewById(R.id.btnSavePlayer);
mBtnDelete = findViewById(R.id.btnDeletePlayer);
// Get references to cropper UI elements
mIvCropPreview = findViewById(R.id.ivCropPreview);
mCropOverlay = findViewById(R.id.cropOverlay);
@@ -290,7 +304,8 @@ public class AddPlayerActivity extends BaseActivity {
}
/**
* Displays a rationale dialog explaining why the app needs the specified permission.
* Displays a rationale dialog explaining why the app needs the specified
* permission.
*
* @param permission The permission for which to show the rationale
*/
@@ -298,7 +313,8 @@ public class AddPlayerActivity extends BaseActivity {
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))
.setPositiveButton(R.string.txt_permission_hint_button_ok,
(d, w) -> requestPermissionLauncher.launch(permission))
.setNegativeButton(R.string.txt_permission_hint_button_cancel, null)
.show();
}
@@ -313,7 +329,7 @@ public class AddPlayerActivity extends BaseActivity {
public boolean onScale(@NonNull final 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(UIConstants.SCALE_MIN_ZOOM, Math.min(mScaleFactor, UIConstants.SCALE_MAX_ZOOM));
@@ -342,11 +358,11 @@ public class AddPlayerActivity extends BaseActivity {
// 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();
@@ -368,11 +384,11 @@ public class AddPlayerActivity extends BaseActivity {
mLayoutCropper.setVisibility(View.VISIBLE);
// Reset transformation state for a fresh start
mScaleFactor = UIConstants.SCALE_NORMAL; // Reset zoom to 100%
mScaleFactor = UIConstants.SCALE_NORMAL; // Reset zoom to 100%
mIvCropPreview.setScaleX(UIConstants.SCALE_NORMAL);
mIvCropPreview.setScaleY(UIConstants.SCALE_NORMAL);
mIvCropPreview.setTranslationX(0); // Reset horizontal position
mIvCropPreview.setTranslationY(0); // Reset vertical position
mIvCropPreview.setTranslationX(0); // Reset horizontal position
mIvCropPreview.setTranslationY(0); // Reset vertical position
// Load the selected image into the preview
mIvCropPreview.setImageURI(uri);
@@ -388,7 +404,8 @@ public class AddPlayerActivity extends BaseActivity {
}
/**
* Performs the pixel-level mathematics to extract a square crop from the selected image.
* Performs the pixel-level mathematics to extract a square crop from the
* selected image.
* Accounts for ImageView fit-center scale, user translation, and user zoom.
*/
private void performCrop() {
@@ -429,32 +446,35 @@ public class AddPlayerActivity extends BaseActivity {
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
// 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;
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.setImageTintList(null); // Remove any tint
mProfilePictureView.setImageBitmap(cropped);
}
// Return to Form Mode
exitCropMode();
// Clean up the full bitmap to free memory
fullBmp.recycle();
@@ -465,7 +485,8 @@ public class AddPlayerActivity extends BaseActivity {
}
/**
* Saves a bitmap to the application's private internal storage as JPEG with 90% quality.
* Saves a bitmap to the application's private internal storage as JPEG with 90%
* quality.
*
* @param bmp The bitmap image to save.
* @return The absolute file path, or null if saving failed.
@@ -474,15 +495,15 @@ public class AddPlayerActivity extends BaseActivity {
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, UIConstants.JPEG_QUALITY, fos);
}
// Return the absolute path for database storage
return file.getAbsolutePath();
} catch (Exception e) {
@@ -498,20 +519,22 @@ public class AddPlayerActivity extends BaseActivity {
private void loadExistingPlayer() {
new Thread(() -> {
// Query the database for the player (background thread)
mExistingPlayer = AppDatabase.getDatabase(this).playerDao().getPlayerById(mExistingPlayerId);
final Statistics statistics = AppDatabase.getDatabase(this).statisticsDao().getStatisticsForPlayer(mExistingPlayerId);
mExistingPlayer = mDatabaseHelper.getPlayerById(mExistingPlayerId);
final Statistics statistics = mDatabaseHelper.getStatisticsForPlayer(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);
if (mBtnDelete != null) mBtnDelete.setVisibility(View.VISIBLE);
if (mShowStatsButton != null) mShowStatsButton.setVisibility(View.VISIBLE);
if (mBtnDelete != null)
mBtnDelete.setVisibility(View.VISIBLE);
if (mShowStatsButton != null)
mShowStatsButton.setVisibility(View.VISIBLE);
mShowStatsButton.setOnClickListener(v -> {
mPlayerStatsView.setVisibility(View.VISIBLE);
mIsStatsViewShown = true;
@@ -519,11 +542,11 @@ public class AddPlayerActivity extends BaseActivity {
if (statistics != null && mPlayerStatsView != null) {
mPlayerStatsView.bind(mExistingPlayer, statistics);
}
// Load existing profile picture if available
if (mExistingPlayer.profilePictureUri != null) {
mInternalImagePath = mExistingPlayer.profilePictureUri;
mProfilePictureView.setImageTintList(null); // Remove placeholder tint
mProfilePictureView.setImageTintList(null); // Remove placeholder tint
mProfilePictureView.setImageURI(Uri.fromFile(new File(mInternalImagePath)));
}
}
@@ -534,12 +557,14 @@ public class AddPlayerActivity extends BaseActivity {
/**
* 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.
* Runs database operation on background thread. Does nothing if no player is
* loaded.
*/
private void deletePlayer() {
if (mExistingPlayer == null) return;
if (mExistingPlayer == null)
return;
new Thread(() -> {
AppDatabase.getDatabase(this).playerDao().delete(mExistingPlayer);
mDatabaseHelper.deletePlayer(mExistingPlayer);
runOnUiThread(() -> {
Toast.makeText(this, "Player removed from squad", Toast.LENGTH_SHORT).show();
finish();
@@ -565,19 +590,19 @@ public class AddPlayerActivity extends BaseActivity {
// Update existing player
mExistingPlayer.username = name;
mExistingPlayer.profilePictureUri = mInternalImagePath;
AppDatabase.getDatabase(this).playerDao().update(mExistingPlayer);
mDatabaseHelper.updatePlayer(mExistingPlayer);
} else {
// Create and insert new player
final Player p = new Player(name, mInternalImagePath);
final long newUserId = AppDatabase.getDatabase(this).playerDao().insert(p);
final Player dbPlayer = AppDatabase.getDatabase(this).playerDao().getPlayerById(newUserId);
final long newUserId = mDatabaseHelper.insertPlayer(p);
final Player dbPlayer = mDatabaseHelper.getPlayerById(newUserId);
if (dbPlayer != null) {
Log.d(TAG, "savePlayer: Player has been created, create stats as well.");
final Statistics playerStats = new Statistics(dbPlayer.id);
AppDatabase.getDatabase(this).statisticsDao().insertStatistics(playerStats);
mDatabaseHelper.insertStats(playerStats);
}
}
// Close activity on main thread after save completes
runOnUiThread(this::finish);
}).start();

View File

@@ -62,6 +62,9 @@ import nl.dionsegijn.konfetti.xml.KonfettiView;
*/
public class GameActivity extends BaseActivity implements GameManager.GameStateCallback {
/**
* Tag for logging purposes.
*/
private static final String TAG = "GameActivity";
/**

View File

@@ -86,21 +86,8 @@ public class MainMenuActivity extends BaseActivity {
return insets;
});
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());
final List<Match> ongoingMatches = mDatabaseHelper.getOngoingMatches();
if (ongoingMatches != null && !ongoingMatches.isEmpty()) {
mOngoingMatch = ongoingMatches.get(0);
}
if (mOngoingMatch != null) {
Log.d(TAG, "onCreate: Found ongoing match [" + mOngoingMatch + "]");
quickStartBtn.setSubText("Continue match with " + mOngoingMatch.gameMode + " score");
}
findViewById(R.id.btnSettings).setOnClickListener(v -> launchSettings());
// Set up match recap view with test data functionality
mMatchRecap = findViewById(R.id.match_recap);
@@ -122,6 +109,19 @@ public class MainMenuActivity extends BaseActivity {
initSquadView();
// Apply the last finished match if available.
applyLastMatch();
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());
final List<Match> ongoingMatches = mDatabaseHelper.getOngoingMatches();
if (ongoingMatches != null && !ongoingMatches.isEmpty()) {
mOngoingMatch = ongoingMatches.get(0);
}
if (mOngoingMatch != null) {
Log.d(TAG, "onCreate: Found ongoing match [" + mOngoingMatch + "]");
quickStartBtn.setSubText("Continue match with " + mOngoingMatch.gameMode + " score");
}
}
/**

View File

@@ -17,10 +17,14 @@ import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
/**
* Centralized database helper that manages all database operations with proper synchronization.
* Handles threading internally to prevent race conditions and simplify database access.
* All database operations are executed on a background thread pool with per-player locking
* to ensure data integrity while allowing concurrent updates for different players.
* Centralized database helper that manages all database operations with proper
* synchronization.
* Handles threading internally to prevent race conditions and simplify database
* access.
* All database operations are executed on a background thread pool with
* per-player locking
* to ensure data integrity while allowing concurrent updates for different
* players.
*/
public class DatabaseHelper {
@@ -40,14 +44,16 @@ public class DatabaseHelper {
private final AppDatabase mDatabase;
/**
* Single-threaded executor for database operations to ensure sequential execution.
* Single-threaded executor for database operations to ensure sequential
* execution.
* Prevents race conditions by serializing all database writes.
*/
private final ExecutorService mExecutor;
/**
* Per-player locks for fine-grained synchronization.
* Allows concurrent updates for different players while preventing conflicts for the same player.
* Allows concurrent updates for different players while preventing conflicts
* for the same player.
*/
private final Map<Long, Object> mPlayerLocks = new HashMap<>();
@@ -95,15 +101,17 @@ public class DatabaseHelper {
* Updates player statistics after a turn.
* Handles darts thrown, points made, bust tracking, and milestone counting.
*
* @param playerId Player's database ID
* @param dartsThrown Number of darts thrown this turn
* @param pointsMade Total points scored this turn
* @param wasBust Whether the turn resulted in a bust
* @param checkoutValue The checkout score if this was a winning turn (0 if not a checkout)
* @param totalDartsThrownInMatch Total darts thrown by player in current match (for first 9 tracking)
* @param playerId Player's database ID
* @param dartsThrown Number of darts thrown this turn
* @param pointsMade Total points scored this turn
* @param wasBust Whether the turn resulted in a bust
* @param checkoutValue The checkout score if this was a winning turn
* (0 if not a checkout)
* @param totalDartsThrownInMatch Total darts thrown by player in current match
* (for first 9 tracking)
*/
public void updatePlayerStatistics(final long playerId, final int dartsThrown, final int pointsMade,
final boolean wasBust, final int checkoutValue, final int totalDartsThrownInMatch) {
public void updatePlayerStatistics(final long playerId, final int dartsThrown, final int pointsMade,
final boolean wasBust, final int checkoutValue, final int totalDartsThrownInMatch) {
mExecutor.execute(() -> {
final Object lock = getPlayerLock(playerId);
synchronized (lock) {
@@ -122,7 +130,8 @@ public class DatabaseHelper {
} else {
// Normal turn - record darts and points
playerStats.saveDartsThrown(dartsThrown, pointsMade);
Log.d(TAG, "updatePlayerStatistics: dartsThrown = [" + dartsThrown + "], pointsMade = [" + pointsMade + "]");
Log.d(TAG, "updatePlayerStatistics: dartsThrown = [" + dartsThrown + "], pointsMade = ["
+ pointsMade + "]");
// Track missed darts if turn ended early (less than 3 darts)
if (dartsThrown < 3) {
@@ -147,7 +156,8 @@ public class DatabaseHelper {
final long dartsToAdd = Math.min(dartsThrown, 9 - totalDartsThrownInMatch);
playerStats.setTotalFirst9Darts(playerStats.getTotalFirst9Darts() + dartsToAdd);
playerStats.setTotalFirst9Points(playerStats.getTotalFirst9Points() + pointsMade);
Log.d(TAG, "updatePlayerStatistics: First 9 tracking - darts: " + dartsToAdd + ", points: " + pointsMade);
Log.d(TAG, "updatePlayerStatistics: First 9 tracking - darts: " + dartsToAdd + ", points: "
+ pointsMade);
}
// Track successful checkout
@@ -194,7 +204,8 @@ public class DatabaseHelper {
if (playerStats != null) {
playerStats.addDoubleOutTarget(isMissed);
mDatabase.statisticsDao().updateStatistics(playerStats);
Log.d(TAG, "trackDoubleAttempt: Recorded double attempt (missed=" + isMissed + ") for player " + playerId);
Log.d(TAG, "trackDoubleAttempt: Recorded double attempt (missed=" + isMissed + ") for player "
+ playerId);
}
} catch (Exception e) {
Log.e(TAG, "trackDoubleAttempt: Failed to track double attempt", e);
@@ -219,7 +230,8 @@ public class DatabaseHelper {
if (playerStats != null) {
playerStats.addCompletedMatch();
mDatabase.statisticsDao().updateStatistics(playerStats);
Log.d(TAG, "incrementMatchesPlayed: Incremented for player " + playerId + ", total: " + playerStats.getMatchesPlayed());
Log.d(TAG, "incrementMatchesPlayed: Incremented for player " + playerId + ", total: "
+ playerStats.getMatchesPlayed());
}
} catch (Exception e) {
Log.e(TAG, "incrementMatchesPlayed: Failed to increment matches for player " + playerId, e);
@@ -246,7 +258,7 @@ public class DatabaseHelper {
/**
* Constructs a DartHit with the specified base value and multiplier.
*
* @param baseValue The dartboard number (1-20 or 25 for bull)
* @param baseValue The dartboard number (1-20 or 25 for bull)
* @param multiplier The multiplier (1=single, 2=double, 3=triple)
*/
public DartHit(final int baseValue, final int multiplier) {
@@ -263,7 +275,8 @@ public class DatabaseHelper {
* @param dartHits List of dart hit details from the turn
*/
public void recordDartHits(final long playerId, final List<DartHit> dartHits) {
if (dartHits == null || dartHits.isEmpty()) return;
if (dartHits == null || dartHits.isEmpty())
return;
mExecutor.execute(() -> {
final Object lock = getPlayerLock(playerId);
@@ -271,12 +284,14 @@ public class DatabaseHelper {
try {
final Statistics playerStats = mDatabase.statisticsDao().getStatisticsForPlayer(playerId);
if (playerStats != null) {
Log.d(TAG, "recordDartHits: Before recording - hitDistribution size: " + playerStats.getHitDistribution().size());
Log.d(TAG, "recordDartHits: Before recording - hitDistribution size: "
+ playerStats.getHitDistribution().size());
// Record all darts from this turn
for (final DartHit hit : dartHits) {
playerStats.recordDartHit(hit.baseValue, hit.multiplier);
}
Log.d(TAG, "recordDartHits: After recording - hitDistribution size: " + playerStats.getHitDistribution().size());
Log.d(TAG, "recordDartHits: After recording - hitDistribution size: "
+ playerStats.getHitDistribution().size());
Log.d(TAG, "recordDartHits: hitDistribution contents: " + playerStats.getHitDistribution());
mDatabase.statisticsDao().updateStatistics(playerStats);
Log.d(TAG, "recordDartHits: Recorded " + dartHits.size() + " darts for player " + playerId);
@@ -290,7 +305,8 @@ public class DatabaseHelper {
/**
* Retrieves all players from the database synchronously.
* Blocks until the operation completes to ensure consistency with any pending writes.
* Blocks until the operation completes to ensure consistency with any pending
* writes.
*
* @return List of all players, or empty list if none exist
*/
@@ -304,12 +320,13 @@ public class DatabaseHelper {
}
/**
* Creates a new match record in the database with the specified game mode and players.
* Creates a new match record in the database with the specified game mode and
* players.
* Initializes the match progress with starting scores and player data.
* Blocks until the operation completes to return the new match ID.
*
* @param gameMode The game mode string (e.g., "501")
* @param players List of Player objects participating in the match
* @param players List of Player objects participating in the match
* @return The ID of the newly created match, or -1 if creation failed
*/
public long createNewMatch(final String gameMode, final List<Player> players) {
@@ -320,13 +337,13 @@ public class DatabaseHelper {
} catch (NumberFormatException e) {
Log.w(TAG, "createNewMatch: Could not parse gameMode as integer, using default 501");
}
// Create initial MatchProgress with player data
final MatchProgress initialProgress = new MatchProgress();
initialProgress.activePlayerIndex = 0; // First player starts
initialProgress.startingScore = startingScore;
initialProgress.players = new ArrayList<>();
// Create player state snapshots with initial values
if (players != null && !players.isEmpty()) {
for (Player player : players) {
@@ -343,14 +360,13 @@ public class DatabaseHelper {
0L, // Guest has ID 0
"GUEST",
startingScore,
0
));
0));
}
// Convert to JSON
final String participantData = MatchProgressConverter.fromProgress(initialProgress);
final Match match = new Match(System.currentTimeMillis(), gameMode,
final Match match = new Match(System.currentTimeMillis(), gameMode,
initialProgress.players.size(), participantData, Match.MatchState.ONGOING);
try {
return mExecutor.submit(() -> {
@@ -384,7 +400,9 @@ public class DatabaseHelper {
/**
* Retrieves all ongoing matches from the database synchronously.
* Blocks until the operation completes to ensure consistency with any pending writes.
* Blocks until the operation completes to ensure consistency with any pending
* writes.
*
* @return List of ongoing matches, or empty list if none exist
*/
public List<Match> getOngoingMatches() {
@@ -419,8 +437,11 @@ public class DatabaseHelper {
/**
* Retrieves the most recently completed match from the database synchronously.
* Blocks until the operation completes to ensure consistency with any pending writes.
* @return The most recent completed match, or null if no completed matches exist
* Blocks until the operation completes to ensure consistency with any pending
* writes.
*
* @return The most recent completed match, or null if no completed matches
* exist
*/
public Match getLastCompletedMatch() {
try {
@@ -438,9 +459,34 @@ public class DatabaseHelper {
}
}
/**
* Retrieves the participant data from the most recent match in the database.
* This method is synchronous and blocks until the database operation completes.
*
* @return The participant data (JSON string) from the most recent match,
* or null if no matches are found.
*/
public String getLastMatchParticipantData() {
try {
return mExecutor.submit(() -> {
try {
return mDatabase.matchDao().getLastMatchParticipantData();
} catch (Exception e) {
Log.e(TAG, "getLastMatchParticipantData: Failed to retrieve last match participant data", e);
return null;
}
}).get();
} catch (Exception e) {
Log.e(TAG, "getLastMatchParticipantData: Failed to submit task", e);
return null;
}
}
/**
* Retrieves a match by its unique ID from the database synchronously.
* Blocks until the operation completes to ensure consistency with any pending writes.
* Blocks until the operation completes to ensure consistency with any pending
* writes.
*
* @param matchId The unique identifier of the match
* @return The match with the specified ID, or null if no such match exists
*/
@@ -462,7 +508,8 @@ public class DatabaseHelper {
/**
* Retrieves statistics for a specific player synchronously.
* Blocks until the operation completes to ensure consistency with any pending writes.
* Blocks until the operation completes to ensure consistency with any pending
* writes.
*
* @param playerId The player's database ID
* @return Statistics object for the player, or null if not found
@@ -486,6 +533,89 @@ public class DatabaseHelper {
}
}
/**
* Inserts a new player into the database.
*
* @param player The Player entity to insert
*/
public long insertPlayer(final Player player) {
try {
return mExecutor.submit(() -> mDatabase.playerDao().insert(player)).get();
} catch (Exception e) {
Log.e(TAG, "insertPlayer: Failed to submit task", e);
return -1;
}
}
/**
* Updates an existing player in the database.
*
* @param player The Player entity to update
*/
public void updatePlayer(final Player player) {
mExecutor.execute(() -> {
try {
mDatabase.playerDao().update(player);
} catch (Exception e) {
Log.e(TAG, "updatePlayer: Failed to update player", e);
}
});
}
/**
* Deletes a player from the database.
*
* @param player The Player entity to delete
*/
public void deletePlayer(final Player player) {
mExecutor.execute(() -> {
try {
mDatabase.playerDao().delete(player);
} catch (Exception e) {
Log.e(TAG, "deletePlayer: Failed to delete player", e);
}
});
}
/**
* Retrieves a player by their database ID synchronously.
* Blocks until the operation completes to ensure consistency with any pending
* writes.
*
* @param playerId The player's database ID
* @return Player object for the specified player, or null if not found
*/
public Player getPlayerById(final long playerId) {
try {
return mExecutor.submit(() -> {
try {
return mDatabase.playerDao().getPlayerById(playerId);
} catch (Exception e) {
Log.e(TAG, "getPlayerById: Failed to retrieve player", e);
return null;
}
}).get();
} catch (final Exception e) {
Log.e(TAG, "getPlayerById: Failed to submit task", e);
return null;
}
}
/**
* Inserts a PlayerStats entity into the database.
*
* @param playerStats The PlayerStats entity to insert
*/
public void insertStats(final Statistics playerStats) {
mExecutor.execute(() -> {
try {
mDatabase.statisticsDao().insertStatistics(playerStats);
} catch (Exception e) {
Log.e(TAG, "insertStats: Failed to insert stats", e);
}
});
}
/**
* Shuts down the executor service.
* Should be called when the helper is no longer needed.

View File

@@ -5,6 +5,7 @@ import androidx.room.Entity;
import androidx.room.Ignore;
import androidx.room.PrimaryKey;
import com.aldo.apps.ochecompanion.utils.Log;
import com.aldo.apps.ochecompanion.utils.MatchProgress;
import com.aldo.apps.ochecompanion.utils.converters.MatchProgressConverter;

View File

@@ -13,6 +13,7 @@ import androidx.recyclerview.widget.RecyclerView;
import com.aldo.apps.ochecompanion.GameActivity;
import com.aldo.apps.ochecompanion.R;
import com.aldo.apps.ochecompanion.database.AppDatabase;
import com.aldo.apps.ochecompanion.database.DatabaseHelper;
import com.aldo.apps.ochecompanion.database.objects.Player;
import com.aldo.apps.ochecompanion.ui.adapter.PlayerSelectionAdapter;
import com.aldo.apps.ochecompanion.utils.DartsConstants;
@@ -27,19 +28,25 @@ import java.util.List;
import java.util.Set;
/**
* PlayerSelectionDialogFragment: A modern bottom-sheet selector for match participants.
* PlayerSelectionDialogFragment: A modern bottom-sheet selector for match
* participants.
* <p>
* This {@link BottomSheetDialogFragment} provides a user interface for selecting players
* This {@link BottomSheetDialogFragment} provides a user interface for
* selecting players
* from the database before starting a new match. It features:
* <ul>
* <li>Automatic pre-selection of players from the most recent match for speed</li>
* <li>Dynamic button state that displays the current selection count</li>
* <li>Integration with {@link PlayerSelectionAdapter} for multi-select functionality</li>
* <li>Validation to ensure at least one player is selected before starting</li>
* <li>Automatic pre-selection of players from the most recent match for
* speed</li>
* <li>Dynamic button state that displays the current selection count</li>
* <li>Integration with {@link PlayerSelectionAdapter} for multi-select
* functionality</li>
* <li>Validation to ensure at least one player is selected before starting</li>
* </ul>
* <p>
* The dialog automatically loads all players from the database on creation and queries
* the last match's participant data to pre-populate selections, improving user experience
* The dialog automatically loads all players from the database on creation and
* queries
* the last match's participant data to pre-populate selections, improving user
* experience
* for consecutive matches with the same players.
*
* @see PlayerSelectionAdapter
@@ -86,50 +93,65 @@ public class PlayerSelectionDialogFragment extends BottomSheetDialogFragment {
private MaterialButton mBtnStart;
/**
* The adapter that manages the player selection list in the {@link RecyclerView}.
* The adapter that manages the player selection list in the
* {@link RecyclerView}.
* <p>
* Handles player selection logic, visual feedback, and communicates
* selection changes via {@link #onSelectionChanged()}.
*/
private PlayerSelectionAdapter mAdapter;
/**
* The database helper instance used for database operations.
*/
private DatabaseHelper mDatabaseHelper;
/**
* Creates and returns the view hierarchy associated with this fragment.
* <p>
* Inflates the player selection bottom sheet layout, which includes:
* <ul>
* <li>A {@link RecyclerView} for displaying the player list</li>
* <li>A {@link MaterialButton} for confirming the selection</li>
* <li>A {@link RecyclerView} for displaying the player list</li>
* <li>A {@link MaterialButton} for confirming the selection</li>
* </ul>
*
* @param inflater The {@link LayoutInflater} used to inflate views in the fragment
* @param container The parent view that the fragment's UI should be attached to,
* @param inflater The {@link LayoutInflater} used to inflate views in
* the fragment
* @param container The parent view that the fragment's UI should be
* attached to,
* or {@code null} if not attached
* @param savedInstanceState If non-null, this fragment is being re-constructed from a
* @param savedInstanceState If non-null, this fragment is being re-constructed
* from a
* previous saved state
* @return The root {@link View} of the inflated layout
*/
@Nullable
@Override
public View onCreateView(@NonNull final LayoutInflater inflater, @Nullable final ViewGroup container, @Nullable final Bundle savedInstanceState) {
public View onCreateView(@NonNull final LayoutInflater inflater, @Nullable final ViewGroup container,
@Nullable final Bundle savedInstanceState) {
mDatabaseHelper = DatabaseHelper.getInstance(getContext());
return inflater.inflate(R.layout.layout_player_selection_sheet, container, false);
}
/**
* Called immediately after {@link #onCreateView} has returned, but before any saved
* Called immediately after {@link #onCreateView} has returned, but before any
* saved
* state has been restored into the view.
* <p>
* This method performs the following initialization steps:
* <ol>
* <li>Binds UI components ({@link RecyclerView}, {@link MaterialButton})</li>
* <li>Configures the {@link RecyclerView} with a {@link LinearLayoutManager}</li>
* <li>Initializes the {@link PlayerSelectionAdapter} with selection callback</li>
* <li>Sets up the confirmation button click listener</li>
* <li>Triggers asynchronous data loading via {@link #loadData()}</li>
* <li>Binds UI components ({@link RecyclerView}, {@link MaterialButton})</li>
* <li>Configures the {@link RecyclerView} with a
* {@link LinearLayoutManager}</li>
* <li>Initializes the {@link PlayerSelectionAdapter} with selection
* callback</li>
* <li>Sets up the confirmation button click listener</li>
* <li>Triggers asynchronous data loading via {@link #loadData()}</li>
* </ol>
*
* @param view The {@link View} returned by {@link #onCreateView}
* @param savedInstanceState If non-null, this fragment is being re-constructed from a
* @param savedInstanceState If non-null, this fragment is being re-constructed
* from a
* previous saved state
*/
@Override
@@ -154,12 +176,12 @@ public class PlayerSelectionDialogFragment extends BottomSheetDialogFragment {
* This method performs database operations on a background thread to avoid
* blocking the UI. The loading process includes:
* <ol>
* <li>Fetching all players from the database</li>
* <li>Retrieving the last match's participant data for pre-selection</li>
* <li>Parsing the last match data using {@link MatchProgressConverter}</li>
* <li>Extracting player IDs from the previous match</li>
* <li>Updating the UI on the main thread with loaded data</li>
* <li>Pre-selecting players who participated in the last match</li>
* <li>Fetching all players from the database</li>
* <li>Retrieving the last match's participant data for pre-selection</li>
* <li>Parsing the last match data using {@link MatchProgressConverter}</li>
* <li>Extracting player IDs from the previous match</li>
* <li>Updating the UI on the main thread with loaded data</li>
* <li>Pre-selecting players who participated in the last match</li>
* </ol>
* <p>
* This pre-selection behavior significantly improves user experience when
@@ -167,13 +189,11 @@ public class PlayerSelectionDialogFragment extends BottomSheetDialogFragment {
*/
private void loadData() {
new Thread(() -> {
final AppDatabase db = AppDatabase.getDatabase(requireContext());
// 1. Get All Players
final List<Player> players = db.playerDao().getAllPlayers();
final List<Player> players = mDatabaseHelper.getAllPlayers();
// 2. Get Last Participants for Pre-selection
final String lastJson = db.matchDao().getLastMatchParticipantData();
final String lastJson = mDatabaseHelper.getLastMatchParticipantData();
final MatchProgress lastProgress = MatchProgressConverter.fromString(lastJson);
final Set<Integer> lastPlayerIds = new HashSet<>();
@@ -204,7 +224,8 @@ public class PlayerSelectionDialogFragment extends BottomSheetDialogFragment {
}
/**
* Callback method invoked by {@link PlayerSelectionAdapter} when the selection state changes.
* Callback method invoked by {@link PlayerSelectionAdapter} when the selection
* state changes.
* <p>
* This method is triggered whenever a player is selected or deselected in the
* {@link RecyclerView}. It delegates to {@link #updateButtonState()} to reflect
@@ -222,10 +243,12 @@ public class PlayerSelectionDialogFragment extends BottomSheetDialogFragment {
* <p>
* This method performs two UI updates:
* <ul>
* <li><b>Enabled State:</b> The button is enabled only when at least one player
* is selected (count > 0), preventing match initiation with empty selections</li>
* <li><b>Text Display:</b> Shows "START MATCH (count)" when players are selected,
* or "SELECT PLAYERS" as a prompt when no selections are made</li>
* <li><b>Enabled State:</b> The button is enabled only when at least one player
* is selected (count > 0), preventing match initiation with empty
* selections</li>
* <li><b>Text Display:</b> Shows "START MATCH (count)" when players are
* selected,
* or "SELECT PLAYERS" as a prompt when no selections are made</li>
* </ul>
* <p>
* This provides clear visual feedback about the current selection state and
@@ -242,12 +265,13 @@ public class PlayerSelectionDialogFragment extends BottomSheetDialogFragment {
* <p>
* This method performs the following operations:
* <ol>
* <li>Constructs a list of selected {@link Player} objects by filtering
* {@link #mAllPlayers} based on {@link #mSelectedIds}</li>
* <li>Validates that at least one player is selected (early return if empty)</li>
* <li>Launches {@link GameActivity} with the selected players and default score
* ({@link DartsConstants#DEFAULT_GAME_SCORE})</li>
* <li>Dismisses this dialog fragment after successfully starting the match</li>
* <li>Constructs a list of selected {@link Player} objects by filtering
* {@link #mAllPlayers} based on {@link #mSelectedIds}</li>
* <li>Validates that at least one player is selected (early return if
* empty)</li>
* <li>Launches {@link GameActivity} with the selected players and default score
* ({@link DartsConstants#DEFAULT_GAME_SCORE})</li>
* <li>Dismisses this dialog fragment after successfully starting the match</li>
* </ol>
* <p>
* This method is triggered by the confirmation button click listener set up
@@ -261,7 +285,8 @@ public class PlayerSelectionDialogFragment extends BottomSheetDialogFragment {
}
}
if (selectedList.isEmpty()) return;
if (selectedList.isEmpty())
return;
// Start the game!
GameActivity.start(requireContext(), selectedList, DartsConstants.DEFAULT_GAME_SCORE);