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

166
README.md
View File

@@ -1,70 +1,118 @@
# Oche Companion
An Android darts companion app for scoring X01 games (301, 501) with automatic Double Out enforcement, bust detection, and intelligent checkout suggestions. Features player squad management with career statistics, match history tracking, and quick-start practice mode.
An Android darts companion app for scoring X01 games (301, 501, 701) with automatic Double Out enforcement, bust detection, and intelligent checkout suggestions. Features comprehensive player statistics with heatmap visualization, career tracking, match history, sound effects, and configurable settings.
## Features
### Game Scoring
- **X01 Game Support** - Play standard darts games (301, 501, etc.)
### 🎯 Game Scoring
- **X01 Game Support** - Play standard darts games (301, 501, 701, etc.)
- **Double Out Enforcement** - Automatically enforces finishing with doubles
- **Bust Detection** - Prevents invalid scores and reverts turns
- **Checkout Suggestions** - Intelligent route recommendations for finishing combinations
- **Bust Detection** - Prevents invalid scores and reverts turns with visual/haptic feedback
- **Intelligent Checkout Engine** - Real-time route recommendations for finishing combinations (≤170)
- **Custom Keyboard** - Intuitive numeric input with multiplier selection (Single, Double, Triple)
- **Turn Indicators** - Visual dart pills showing current turn progress (3 darts max)
- **Sound Effects** - Audio feedback for wins, busts, and perfect 180 scores
- **Undo Support** - Remove last dart or revert entire turn
- **GameManager Architecture** - Clean separation of business logic from UI via singleton pattern
### Player Management
- **Squad Roster** - Maintain your team with profile pictures
- **Career Statistics** - Track average scores and matches played per player
- **Custom Profiles** - Add players with personalized avatars
### 👥 Player Management
- **Squad Roster** - Maintain your team with custom profile pictures
- **Advanced Statistics** - Comprehensive career tracking including:
- Total matches played and win rate
- Average score per dart and per turn
- Highest checkout and best game average
- Segment-by-segment hit frequency (singles, doubles, triples, bull)
- Total darts thrown and 180s scored
- **Heatmap Visualization** - Interactive dartboard showing hit frequency with color-coded intensity
- **Player Stats Dashboard** - Dedicated view with scrollable statistics and performance metrics
- **Image Cropping** - Custom crop overlay for profile picture editing with zoom controls (0.1x - 10x)
### Match History
- **Match Tracking** - Complete history of all played games
- **Detailed Recaps** - View winners, scores, and participant information
- **Persistent Storage** - All data saved locally using Room database
### 📊 Match History
- **Match Tracking** - Complete history of all played games with timestamps
- **Detailed Recaps** - View winners, final scores, and participant information
- **Persistent Storage** - All data saved locally using Room database with proper relationships
- **Match Resume** - Load and continue unfinished matches
### Quick Features
### ⚙️ Settings & Customization
- **Settings Activity** - Dedicated preferences screen
- **Day/Night Mode** - Theme selection for comfortable viewing
- **Standard Game Mode** - Configure default starting score (301, 501, 701)
- **Quick Start Mode** - Launch practice games instantly without database persistence
- **Material Design UI** - Modern, intuitive interface with custom views
### 🎨 UI/UX
- **Material Design 3** - Modern, polished interface with custom views
- **Confetti Celebrations** - Visual effects for game wins
- **Responsive Layouts** - Optimized for various screen sizes
- **Custom Views** - Purpose-built components (HeatmapView, PlayerStatsView, MatchRecapView, etc.)
- **Vibration Feedback** - Haptic responses for busts and key actions
- **BaseActivity** - Centralized theme management across all activities
## Tech Stack
- **Language**: Java
- **Architecture**: MVVM pattern
- **Database**: Room Persistence Library
- **UI Components**: Material Design, Custom Views
- **Language**: Java 11
- **Architecture**: MVVM pattern with singleton GameManager
- **Database**: Room Persistence Library with DAOs
- **UI Components**: Material Design 3, Custom Views
- **Image Loading**: Glide
- **Min SDK**: 24 (Android 7.0)
- **Animations**: Konfetti (confetti effects)
- **Audio**: SoundPool API for low-latency game sounds
- **Data Serialization**: Gson (for complex data structures)
- **Min SDK**: 24 (Android 7.0 Nougat)
- **Target SDK**: 36
## Project Structure
```
app/src/main/java/com/aldo/apps/ochecompanion/
├── MainMenuActivity.java # Main entry point
├── GameActivity.java # Live game scoring
├── AddPlayerActivity.java # Player creation
├── MainMenuActivity.java # Main entry point with player list & match history
├── GameActivity.java # Live game scoring UI controller
├── AddPlayerActivity.java # Player creation with image cropping
├── SettingsActivity.java # App preferences and configuration
├── BaseActivity.java # Base activity for theme management
├── game/
│ └── GameManager.java # Singleton business logic manager
├── database/
│ ├── AppDatabase.java # Room database singleton
│ ├── DatabaseHelper.java # Database operations helper
│ ├── dao/
│ │ ├── PlayerDao.java # Player CRUD operations
│ │ ── MatchDao.java # Match CRUD operations
│ │ ── MatchDao.java # Match CRUD operations
│ │ └── StatisticsDao.java # Statistics CRUD operations
│ └── objects/
│ ├── Player.java # Player entity
── Match.java # Match entity
├── models/
│ └── Match.java # Match model
└── ui/
├── MatchRecapView.java # Custom match recap view
├── PlayerItemView.java # Custom player card view
├── QuickStartButton.java # Quick start button
├── CropOverlayView.java # Image cropping overlay
── adapter/
├── MainMenuPlayerAdapter.java
── MainMenuGroupMatchAdapter.java
── Match.java # Match entity
│ └── Statistics.java # Statistics entity
├── ui/
│ ├── HeatmapView.java # Dartboard heatmap visualization
├── PlayerStatsView.java # Comprehensive stats dashboard
├── MatchRecapView.java # Custom match recap view
├── PlayerItemView.java # Custom player card view
├── QuickStartButton.java # Quick start button
── CropOverlayView.java # Image cropping overlay
├── PlayerSelectionDialogFragment.java # Player picker dialog
── MainMenuPreferencesFragment.java # Settings fragment
│ └── adapter/
│ ├── MainMenuPlayerAdapter.java
│ ├── MainMenuGroupMatchAdapter.java
│ └── PlayerSelectionAdapter.java
└── utils/
├── CheckoutEngine.java # Checkout route calculation
├── CheckoutConstants.java # Pre-calculated checkout routes (2-170)
├── DartsConstants.java # Game constants (scores, multipliers, labels)
├── UIConstants.java # UI constants (colors, animations, scales)
├── SoundEngine.java # Singleton sound effect manager
├── ResourceHelper.java # Resource utilities
├── Log.java # Custom logging wrapper
├── MatchProgress.java # Match state serialization
└── converters/
├── IntListConverter.java # Room type converter for lists
└── MatchProgressConverter.java # Room type converter for match state
```
## Getting Started
### Prerequisites
- Android Studio Arctic Fox or later
- Android Studio Hedgehog or later
- JDK 11 or later
- Android SDK with API Level 24+
@@ -79,7 +127,7 @@ git clone https://github.com/yourusername/OcheCompanion.git
3. Sync Gradle files
4. Run the app on an emulator or physical device
4. Run the app on an emulator or physical device (API 24+)
### Building
@@ -95,18 +143,43 @@ git clone https://github.com/yourusername/OcheCompanion.git
## Usage
1. **Add Players** - Tap the add button to create player profiles
2. **Start a Game** - Select players and choose game type (301, 501, etc.)
3. **Score Throws** - Use the custom keyboard to input dart scores
4. **Finish Games** - Follow checkout suggestions to finish with doubles
5. **View History** - Review past matches and statistics
### Getting Started
1. **Add Players** - Tap the add button (+) to create player profiles with custom avatars
2. **Configure Settings** - Access settings to choose day/night mode and default game type
3. **Start a Match** - Select players from your roster and choose starting score (301, 501, 701)
4. **Quick Practice** - Use Quick Start button for instant practice games without saving
### During a Game
1. **Select Multiplier** - Choose Single, Double, or Triple before each dart
2. **Tap Numbers** - Input dart scores using the numeric keyboard (1-20 or Bull)
3. **Follow Suggestions** - Checkout routes appear automatically when score ≤ 170
4. **Undo Mistakes** - Use undo button to remove last dart
5. **Submit Turn** - Advance to next player after 3 darts or when ready
6. **View Stats** - Tap player card to see detailed statistics and heatmap
### After a Game
1. **Review Match** - View match recap with winner and final scores
2. **Check Statistics** - Access player stats to see updated career metrics
3. **View History** - Browse past matches in the main menu
## Game Rules
- Games use standard X01 format (301, 501, etc.)
- Players must finish by hitting a double (Double Out)
- Busting (going below zero or landing on 1) reverts the turn
- Checkout suggestions appear when score is 170 or below
- Games use standard X01 format (301, 501, 701, etc.)
- Players must finish by hitting a double (Double Out rule)
- Busting (going below zero or landing on exactly 1) reverts the entire turn
- Checkout suggestions appear when remaining score is 170 or below
- Maximum of 3 darts per turn
- Perfect score of 180 (Triple 20 × 3) triggers special celebration
## Code Quality
This project follows Android best practices:
-**No Magic Numbers** - All constants extracted to centralized constant classes
-**Comprehensive Documentation** - JavaDoc comments on all public methods
-**MVVM Architecture** - Clear separation of concerns
-**Singleton Pattern** - GameManager and SoundEngine for efficient resource management
-**Type Safety** - Room database with compile-time verification
-**Proper Naming** - Android conventions (m-prefix for members, s-prefix for statics)
## License
@@ -117,6 +190,7 @@ This project is licensed under the MIT License - see the LICENSE file for detail
- Darts rules and checkout combinations from standard competitive play
- Material Design guidelines from Google
- Android Architecture Components
- Sound effects for game events
---

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 {
@@ -163,10 +166,16 @@ public class AddPlayerActivity extends BaseActivity {
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,13 +209,16 @@ 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();
@@ -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() {
@@ -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();
}
@@ -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,13 +446,16 @@ 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
// 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);
@@ -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.
@@ -498,8 +519,8 @@ 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(() -> {
@@ -510,8 +531,10 @@ public class AddPlayerActivity extends BaseActivity {
// 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;
@@ -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,16 +590,16 @@ 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);
}
}

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<>();
@@ -99,8 +105,10 @@ public class DatabaseHelper {
* @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 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) {
@@ -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);
@@ -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,7 +320,8 @@ 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.
*
@@ -343,8 +360,7 @@ public class DatabaseHelper {
0L, // Guest has ID 0
"GUEST",
startingScore,
0
));
0));
}
// Convert to JSON
@@ -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>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>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,13 +93,19 @@ 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>
@@ -102,34 +115,43 @@ public class PlayerSelectionDialogFragment extends BottomSheetDialogFragment {
* <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>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
@@ -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
@@ -223,8 +244,10 @@ public class PlayerSelectionDialogFragment extends BottomSheetDialogFragment {
* 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,
* 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>
@@ -244,7 +267,8 @@ public class PlayerSelectionDialogFragment extends BottomSheetDialogFragment {
* <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>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>
@@ -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);