Created a Player Selection

When a new Game is started now a Player Selection Screen pops up, to select the players to be performing in the next match.
This commit is contained in:
Alexander Doerflinger
2026-02-06 09:42:05 +01:00
parent 4b8766b304
commit 7e668b664b
9 changed files with 482 additions and 70 deletions

View File

@@ -70,6 +70,11 @@ public class GameActivity extends BaseActivity implements GameManager.GameStateC
*/ */
private static final String EXTRA_MATCH_ID = "extra_match_uuid"; private static final String EXTRA_MATCH_ID = "extra_match_uuid";
/**
* Intent extra for a player list. Making it possible to start a match with pre-defined players.
*/
private static final String EXTRA_PLAYERS = "extra_players";
// ======================================================================================== // ========================================================================================
// Game Manager (Singleton Business Logic Handler) // Game Manager (Singleton Business Logic Handler)
@@ -196,9 +201,18 @@ public class GameActivity extends BaseActivity implements GameManager.GameStateC
* @param matchId The ID of the match to be started/loaded. * @param matchId The ID of the match to be started/loaded.
*/ */
public static void start(final Context context, final int startScore, final int matchId) { public static void start(final Context context, final int startScore, final int matchId) {
final GameManager gameManager = GameManager.getInstance(context);
gameManager.initializeMatch(matchId, startScore,null);
Intent intent = new Intent(context, GameActivity.class);
context.startActivity(intent);
}
public static void start(final Context context, final List<Player> players, final int startScore) {
final GameManager gameManager = GameManager.getInstance(context);
gameManager.initializeMatch(-1, startScore, players,null);
Intent intent = new Intent(context, GameActivity.class); Intent intent = new Intent(context, GameActivity.class);
intent.putExtra(EXTRA_START_SCORE, startScore);
intent.putExtra(EXTRA_MATCH_ID, matchId);
context.startActivity(intent); context.startActivity(intent);
} }
@@ -223,16 +237,12 @@ public class GameActivity extends BaseActivity implements GameManager.GameStateC
// Initialize GameManager singleton // Initialize GameManager singleton
mGameManager = GameManager.getInstance(this); mGameManager = GameManager.getInstance(this);
mGameManager.setCallback(this);
// Extract game parameters from intent
final int startingScore = getIntent().getIntExtra(EXTRA_START_SCORE, DartsConstants.DEFAULT_GAME_SCORE);
final int matchId = getIntent().getIntExtra(EXTRA_MATCH_ID, -1);
// Initialize activity components in order // Initialize activity components in order
initViews(); initViews();
setupKeyboard(); setupKeyboard();
mGameManager.setCallback(this);
getOnBackPressedDispatcher().addCallback(this, new OnBackPressedCallback(true) { getOnBackPressedDispatcher().addCallback(this, new OnBackPressedCallback(true) {
@Override @Override
@@ -240,13 +250,9 @@ public class GameActivity extends BaseActivity implements GameManager.GameStateC
handleBackPressed(); handleBackPressed();
} }
}); });
// Initialize match through GameManager (handles loading existing or creating new)
mGameManager.initializeMatch(matchId, startingScore, () -> runOnUiThread(() -> {
updateUI(); updateUI();
updateTurnIndicators(); updateTurnIndicators();
setMultiplier(DartsConstants.MULTIPLIER_SINGLE); setMultiplier(DartsConstants.MULTIPLIER_SINGLE);
}));
} }
@Override @Override

View File

@@ -3,6 +3,8 @@ package com.aldo.apps.ochecompanion;
import android.content.Intent; import android.content.Intent;
import android.content.SharedPreferences; import android.content.SharedPreferences;
import android.os.Bundle; import android.os.Bundle;
import com.aldo.apps.ochecompanion.ui.PlayerSelectionDialogFragment;
import com.aldo.apps.ochecompanion.utils.Log; import com.aldo.apps.ochecompanion.utils.Log;
import android.widget.TextView; import android.widget.TextView;
@@ -145,10 +147,13 @@ public class MainMenuActivity extends BaseActivity {
} catch (final NumberFormatException exception) { } catch (final NumberFormatException exception) {
Log.e(TAG, "quickStart: Could not extract score from [" + mOngoingMatch + "], use default"); Log.e(TAG, "quickStart: Could not extract score from [" + mOngoingMatch + "], use default");
} }
}
Log.d(TAG, "quickStart: Starting match with StartingScore = [" + startingScore Log.d(TAG, "quickStart: Starting match with StartingScore = [" + startingScore
+ "] and matchId = [" + matchId + "]"); + "] and matchId = [" + matchId + "]");
GameActivity.start(MainMenuActivity.this, startingScore, matchId); GameActivity.start(MainMenuActivity.this, startingScore, matchId);
} else {
Log.d(TAG, "quickStart: No ongoing matches found");
new PlayerSelectionDialogFragment().show(getSupportFragmentManager(), "squad_selector");
}
} }
/** /**

View File

@@ -88,4 +88,12 @@ public interface MatchDao {
*/ */
@Query("SELECT * FROM matches WHERE state = :state ORDER BY timestamp DESC") @Query("SELECT * FROM matches WHERE state = :state ORDER BY timestamp DESC")
List<Match> getMatchesByState(final String state); List<Match> getMatchesByState(final String state);
/**
* Retrieves ONLY the participant JSON string from the most recent match.
* Used for the "Auto-select last players" feature in the Squad Selector.
*/
@Query("SELECT participantData FROM matches ORDER BY timestamp DESC LIMIT 1")
String getLastMatchParticipantData();
} }

View File

@@ -149,24 +149,15 @@ public class GameManager {
*/ */
public void setCallback(final GameStateCallback callback) { public void setCallback(final GameStateCallback callback) {
mCallback = callback; mCallback = callback;
//Send one initial callback
notifyGameStateChanged();
} }
/** public void initializeMatch(final int matchId,final int startingScore, final List<Player> players, final Runnable onComplete) {
* Initializes a new game or loads an existing match from the database.
* This should be called when starting/resuming a match.
*
* @param matchId The match ID to load, or -1 to create a new match
* @param startingScore The starting score (501, 301, etc.)
* @param onComplete Callback when initialization is complete
*/
public void initializeMatch(final int matchId, final int startingScore, final Runnable onComplete) {
mStartingScore = startingScore; mStartingScore = startingScore;
mMatchId = matchId; mMatchId = matchId;
new Thread(() -> { new Thread(() -> {
final List<Player> allPlayers = mDatabaseHelper.getAllPlayers();
Log.d(TAG, "initializeMatch: Loading players, count = " + (allPlayers != null ? allPlayers.size() : 0));
Match match = null; Match match = null;
if (matchId > 0) { if (matchId > 0) {
// Try to load existing match // Try to load existing match
@@ -181,7 +172,7 @@ public class GameManager {
if (progress != null) { if (progress != null) {
Log.d(TAG, "initializeMatch: Found saved progress with " + progress.players.size() + " players"); Log.d(TAG, "initializeMatch: Found saved progress with " + progress.players.size() + " players");
// Initialize player states // Initialize player states
initializePlayerStates(allPlayers); initializePlayerStates(players);
loadMatchProgress(progress); loadMatchProgress(progress);
if (onComplete != null) { if (onComplete != null) {
@@ -202,7 +193,7 @@ public class GameManager {
// Create new match if not found or invalid // Create new match if not found or invalid
if (match == null) { if (match == null) {
final long newMatchId = mDatabaseHelper.createNewMatch(String.valueOf(startingScore), allPlayers); final long newMatchId = mDatabaseHelper.createNewMatch(String.valueOf(startingScore), players);
if (newMatchId > 0) { if (newMatchId > 0) {
mMatchId = (int) newMatchId; mMatchId = (int) newMatchId;
Log.d(TAG, "initializeMatch: Created new match with ID: " + mMatchId); Log.d(TAG, "initializeMatch: Created new match with ID: " + mMatchId);
@@ -211,7 +202,7 @@ public class GameManager {
} }
// Setup new game // Setup new game
initializePlayerStates(allPlayers); initializePlayerStates(players);
} }
if (onComplete != null) { if (onComplete != null) {
@@ -221,6 +212,26 @@ public class GameManager {
}).start(); }).start();
} }
/**
* Initializes a new game or loads an existing match from the database.
* This should be called when starting/resuming a match.
*
* @param matchId The match ID to load, or -1 to create a new match
* @param startingScore The starting score (501, 301, etc.)
* @param onComplete Callback when initialization is complete
*/
public void initializeMatch(final int matchId, final int startingScore, final Runnable onComplete) {
mStartingScore = startingScore;
mMatchId = matchId;
new Thread(() -> {
final List<Player> allPlayers = mDatabaseHelper.getAllPlayers();
Log.d(TAG, "initializeMatch: Loading players, count = " + (allPlayers != null ? allPlayers.size() : 0));
initializeMatch(matchId, startingScore, allPlayers, onComplete);
}).start();
}
/** /**
* Initializes player states from the provided player list. * Initializes player states from the provided player list.
*/ */

View File

@@ -0,0 +1,134 @@
package com.aldo.apps.ochecompanion.ui;
import android.os.Bundle;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.recyclerview.widget.LinearLayoutManager;
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.objects.Player;
import com.aldo.apps.ochecompanion.ui.adapter.PlayerSelectionAdapter;
import com.aldo.apps.ochecompanion.utils.DartsConstants;
import com.aldo.apps.ochecompanion.utils.MatchProgress;
import com.aldo.apps.ochecompanion.utils.converters.MatchProgressConverter;
import com.google.android.material.bottomsheet.BottomSheetDialogFragment;
import com.google.android.material.button.MaterialButton;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
/**
* PlayerSelectionDialogFragment: A modern bottom-sheet selector for match participants.
* Automatically pre-selects players from the most recent match for speed.
*/
public class PlayerSelectionDialogFragment extends BottomSheetDialogFragment {
/**
* Tag for debugging and logging purposes.
*/
private static final String TAG = "PlayerSelectionDialogFragment";
/**
* The {@link List} of the selected {@link Player} for the match.
*/
private final List<Player> mAllPlayers = new ArrayList<>();
private final Set<Integer> mSelectedIds = new HashSet<>();
private RecyclerView mRvSquad;
private MaterialButton mBtnStart;
private PlayerSelectionAdapter mAdapter;
@Nullable
@Override
public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) {
return inflater.inflate(R.layout.layout_player_selection_sheet, container, false);
}
@Override
public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
super.onViewCreated(view, savedInstanceState);
mRvSquad = view.findViewById(R.id.rvSquadSelection);
mBtnStart = view.findViewById(R.id.btnConfirmSelection);
mRvSquad.setLayoutManager(new LinearLayoutManager(getContext()));
mAdapter = new PlayerSelectionAdapter(mAllPlayers, mSelectedIds, this::onSelectionChanged);
mRvSquad.setAdapter(mAdapter);
mBtnStart.setOnClickListener(v -> initiateMatch());
loadData();
}
private void loadData() {
new Thread(() -> {
AppDatabase db = AppDatabase.getDatabase(requireContext());
// 1. Get All Players
List<Player> players = db.playerDao().getAllPlayers();
// 2. Get Last Participants for Pre-selection
String lastJson = db.matchDao().getLastMatchParticipantData();
MatchProgress lastProgress = MatchProgressConverter.fromString(lastJson);
final Set<Integer> lastPlayerIds = new HashSet<>();
if (lastProgress != null && lastProgress.players != null) {
for (MatchProgress.PlayerStateSnapshot p : lastProgress.players) {
lastPlayerIds.add((int) p.playerId);
}
}
if (getActivity() != null) {
getActivity().runOnUiThread(() -> {
mAllPlayers.clear();
mAllPlayers.addAll(players);
// Auto-select players from the previous session
mSelectedIds.clear();
for (Player p : mAllPlayers) {
if (lastPlayerIds.contains((int) p.id)) {
mSelectedIds.add((int) p.id);
}
}
mAdapter.notifyDataSetChanged();
updateButtonState();
});
}
}).start();
}
private void onSelectionChanged() {
updateButtonState();
}
private void updateButtonState() {
int count = mSelectedIds.size();
mBtnStart.setEnabled(count > 0);
mBtnStart.setText(count > 0 ? "START MATCH (" + count + ")" : "SELECT PLAYERS");
}
private void initiateMatch() {
ArrayList<Player> selectedList = new ArrayList<>();
for (Player p : mAllPlayers) {
if (mSelectedIds.contains((int) p.id)) {
selectedList.add(p);
}
}
if (selectedList.isEmpty()) return;
// Start the game!
GameActivity.start(requireContext(), selectedList, DartsConstants.DEFAULT_GAME_SCORE);
dismiss();
}
}

View File

@@ -0,0 +1,105 @@
package com.aldo.apps.ochecompanion.ui.adapter;
import android.content.Context;
import android.os.Vibrator;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.core.content.ContextCompat;
import androidx.recyclerview.widget.RecyclerView;
import com.aldo.apps.ochecompanion.R;
import com.aldo.apps.ochecompanion.database.objects.Player;
import com.bumptech.glide.Glide;
import com.google.android.material.card.MaterialCardView;
import com.google.android.material.imageview.ShapeableImageView;
import java.util.List;
import java.util.Set;
/**
* PlayerSelectionAdapter: Optimized for the "Squad Selector" Bottom Sheet.
* Features specialized selection visual states and haptic feedback.
*/
public class PlayerSelectionAdapter extends RecyclerView.Adapter<PlayerSelectionAdapter.SelectionHolder> {
private final List<Player> mPlayers;
private final Set<Integer> mSelectedIds;
private final Runnable mOnChanged;
public PlayerSelectionAdapter(List<Player> players, Set<Integer> selected, Runnable onChanged) {
this.mPlayers = players;
this.mSelectedIds = selected;
this.mOnChanged = onChanged;
}
@NonNull
@Override
public SelectionHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
View v = LayoutInflater.from(parent.getContext()).inflate(R.layout.item_player_selection, parent, false);
return new SelectionHolder(v);
}
@Override
public void onBindViewHolder(@NonNull SelectionHolder holder, int position) {
Player p = mPlayers.get(position);
holder.bind(p, mSelectedIds.contains((int) p.id));
holder.itemView.setOnClickListener(v -> {
// Haptic Feedback
Vibrator vib = (Vibrator) v.getContext().getSystemService(Context.VIBRATOR_SERVICE);
if (vib != null) vib.vibrate(20);
if (mSelectedIds.contains((int) p.id)) {
mSelectedIds.remove((int) p.id);
} else {
if (mSelectedIds.size() < 8) { // PDC/Standard limit
mSelectedIds.add((int) p.id);
}
}
notifyItemChanged(position);
mOnChanged.run();
});
}
@Override
public int getItemCount() {
return mPlayers.size();
}
static class SelectionHolder extends RecyclerView.ViewHolder {
private final ShapeableImageView ivAvatar;
private final TextView tvName;
private final MaterialCardView card;
private final View selectionIndicator;
public SelectionHolder(@NonNull View itemView) {
super(itemView);
ivAvatar = itemView.findViewById(R.id.ivPlayerProfile);
tvName = itemView.findViewById(R.id.tvPlayerName);
card = (MaterialCardView) itemView;
selectionIndicator = itemView.findViewById(R.id.selectionOverlay);
}
public void bind(Player p, boolean isSelected) {
tvName.setText(p.username);
if (p.profilePictureUri != null) {
Glide.with(itemView.getContext()).load(p.profilePictureUri).into(ivAvatar);
} else {
ivAvatar.setImageResource(R.drawable.ic_users);
}
// Visual toggle
if (isSelected) {
card.setStrokeColor(ContextCompat.getColor(itemView.getContext(), R.color.volt_green));
card.setStrokeWidth(4);
selectionIndicator.setVisibility(View.VISIBLE);
} else {
card.setStrokeColor(ContextCompat.getColor(itemView.getContext(), R.color.border_subtle));
card.setStrokeWidth(1);
selectionIndicator.setVisibility(View.GONE);
}
}
}
}

View File

@@ -1,21 +1,31 @@
package com.aldo.apps.ochecompanion.utils.converters; package com.aldo.apps.ochecompanion.utils.converters;
import android.util.Log;
import androidx.room.TypeConverter; import androidx.room.TypeConverter;
import com.aldo.apps.ochecompanion.utils.MatchProgress; import com.aldo.apps.ochecompanion.utils.MatchProgress;
import com.google.gson.Gson; import com.google.gson.Gson;
import com.google.gson.JsonSyntaxException;
/** /**
* MatchProgressConverter: Handles serialization of complex match states * MatchProgressConverter: Handles serialization of complex match states
* into JSON strings for storage in the Room 'matches' table. * into JSON strings for storage in the Room 'matches' table.
*/ */
public class MatchProgressConverter { public class MatchProgressConverter {
private static final String TAG = "MatchProgressConverter";
private static final Gson gson = new Gson(); private static final Gson gson = new Gson();
@TypeConverter @TypeConverter
public static MatchProgress fromString(String value) { public static MatchProgress fromString(String value) {
if (value == null || value.isEmpty()) return null; if (value == null || value.isEmpty()) return null;
try {
return gson.fromJson(value, MatchProgress.class); return gson.fromJson(value, MatchProgress.class);
} catch (final JsonSyntaxException ex) {
Log.e(TAG, "fromString: Failed to parse json, return null", ex);
}
return null;
} }
@TypeConverter @TypeConverter

View File

@@ -0,0 +1,71 @@
<?xml version="1.0" encoding="utf-8"?>
<com.google.android.material.card.MaterialCardView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginVertical="6dp"
app:cardBackgroundColor="@color/surface_secondary"
app:cardCornerRadius="12dp"
app:cardElevation="0dp"
app:strokeColor="@color/border_subtle"
app:strokeWidth="1dp">
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:padding="12dp">
<com.google.android.material.imageview.ShapeableImageView
android:id="@+id/ivPlayerProfile"
android:layout_width="44dp"
android:layout_height="44dp"
android:scaleType="centerCrop"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:shapeAppearanceOverlay="@style/ShapeAppearance.MaterialComponents.SmallComponent"
tools:src="@drawable/ic_users" />
<TextView
android:id="@+id/tvPlayerName"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:fontFamily="sans-serif-black"
android:textColor="@color/text_primary"
android:textSize="15sp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toStartOf="@+id/selectionOverlay"
app:layout_constraintStart_toEndOf="@id/ivPlayerProfile"
app:layout_constraintTop_toTopOf="parent"
tools:text="Snakebite" />
<!-- Checkmark/Selection Indicator -->
<FrameLayout
android:id="@+id/selectionOverlay"
android:layout_width="24dp"
android:layout_height="24dp"
android:visibility="gone"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:visibility="visible">
<View
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@drawable/shape_circle_overlay"
android:backgroundTint="@color/volt_green" />
<ImageView
android:layout_width="16dp"
android:layout_height="16dp"
android:layout_gravity="center"
android:src="@drawable/ic_chevron_right"
app:tint="@color/midnight_black" />
</FrameLayout>
</androidx.constraintlayout.widget.ConstraintLayout>
</com.google.android.material.card.MaterialCardView>

View File

@@ -0,0 +1,62 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@color/surface_primary"
android:orientation="vertical"
android:padding="24dp">
<!-- Bottom Sheet Drag Handle -->
<View
android:layout_width="40dp"
android:layout_height="4dp"
android:layout_gravity="center"
android:layout_marginBottom="24dp"
android:background="@color/text_dim"
android:alpha="0.5" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="WHO'S ON THE OCHE?"
android:fontFamily="sans-serif-black"
android:textColor="@color/text_primary"
android:textSize="20sp"
android:letterSpacing="0.05" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:layout_marginBottom="20dp"
android:text="Select up to 8 players for this match"
android:textColor="@color/text_secondary"
android:textSize="12sp"
android:textAllCaps="true"
android:letterSpacing="0.1" />
<!-- Squad List -->
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/rvSquadSelection"
android:layout_width="match_parent"
android:layout_height="320dp"
android:clipToPadding="false"
android:paddingBottom="80dp"
tools:listitem="@layout/item_player_selection" />
<!-- Action Button (Positioned at bottom of sheet) -->
<com.google.android.material.button.MaterialButton
android:id="@+id/btnConfirmSelection"
style="@style/Widget.Oche_Button_Primary"
android:layout_width="match_parent"
android:layout_height="64dp"
android:layout_marginTop="-70dp"
android:text="START MATCH"
app:icon="@drawable/ic_play"
app:iconGravity="textEnd"
app:iconSize="20dp"
app:cornerRadius="16dp" />
</LinearLayout>