diff --git a/app/build.gradle b/app/build.gradle
index 1dd0acf..5e28df9 100644
--- a/app/build.gradle
+++ b/app/build.gradle
@@ -19,6 +19,12 @@ android {
testInstrumentationRunner 'androidx.test.runner.AndroidJUnitRunner'
}
buildTypes {
+ debug {
+ // Distinct applicationId so debug builds install alongside a
+ // release install of OneTwo rather than replacing it.
+ applicationIdSuffix ".debug"
+ versionNameSuffix "-debug"
+ }
release {
minifyEnabled true
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
diff --git a/app/src/debug/res/values/strings.xml b/app/src/debug/res/values/strings.xml
new file mode 100644
index 0000000..8e18e1d
--- /dev/null
+++ b/app/src/debug/res/values/strings.xml
@@ -0,0 +1,4 @@
+
+
+ OneTwo Debug
+
diff --git a/app/src/main/java/com/nicue/onetwo/ui/dice/DiceAdapter.java b/app/src/main/java/com/nicue/onetwo/ui/dice/DiceAdapter.java
index b20b72f..bacc020 100644
--- a/app/src/main/java/com/nicue/onetwo/ui/dice/DiceAdapter.java
+++ b/app/src/main/java/com/nicue/onetwo/ui/dice/DiceAdapter.java
@@ -1,5 +1,6 @@
package com.nicue.onetwo.ui.dice;
+import android.content.res.ColorStateList;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
@@ -17,6 +18,8 @@ public interface Listener {
void onRollDie(int position);
void onRemoveDie(int position);
+
+ void onToggleLock(int position);
}
private final Listener listener;
@@ -70,7 +73,8 @@ public boolean areContentsTheSame(
DieUiModel oldItem = dice.get(oldItemPosition);
DieUiModel newItem = newDice.get(newItemPosition);
return oldItem.getFaces() == newItem.getFaces()
- && oldItem.getValue() == newItem.getValue();
+ && oldItem.getValue() == newItem.getValue()
+ && oldItem.isLocked() == newItem.isLocked();
}
});
this.dice = newDice == null ? new ArrayList() : new ArrayList<>(newDice);
@@ -82,7 +86,7 @@ public void animateAllVisibleItems(RecyclerView recyclerView, final Runnable end
for (int i = 0; i < recyclerView.getChildCount(); i++) {
RecyclerView.ViewHolder holder =
recyclerView.getChildViewHolder(recyclerView.getChildAt(i));
- if (holder instanceof DiceViewHolder) {
+ if (isRollable(holder)) {
animatedItemCount++;
}
}
@@ -95,7 +99,7 @@ public void animateAllVisibleItems(RecyclerView recyclerView, final Runnable end
for (int i = 0; i < recyclerView.getChildCount(); i++) {
RecyclerView.ViewHolder holder =
recyclerView.getChildViewHolder(recyclerView.getChildAt(i));
- if (holder instanceof DiceViewHolder) {
+ if (isRollable(holder)) {
((DiceViewHolder) holder)
.animateRoll(
new Runnable() {
@@ -111,8 +115,17 @@ public void run() {
}
}
+ private boolean isRollable(RecyclerView.ViewHolder holder) {
+ return holder instanceof DiceViewHolder && !((DiceViewHolder) holder).isLocked();
+ }
+
class DiceViewHolder extends RecyclerView.ViewHolder {
private final DiceItemBinding binding;
+ private final ColorStateList defaultStrokeColor;
+ private final int defaultStrokeWidth;
+ private final int lockedStrokeWidth;
+ private final float nudgeTranslation;
+ private boolean locked;
private final int[] diceColors = {
R.color.diceColor0, R.color.diceColor1, R.color.diceColor2,
R.color.diceColor3, R.color.diceColor4, R.color.diceColor5,
@@ -123,23 +136,33 @@ class DiceViewHolder extends RecyclerView.ViewHolder {
DiceViewHolder(DiceItemBinding binding) {
super(binding.getRoot());
this.binding = binding;
+ this.defaultStrokeColor = binding.diceCv.getStrokeColorStateList();
+ this.defaultStrokeWidth = binding.diceCv.getStrokeWidth();
+ float density = binding.getRoot().getResources().getDisplayMetrics().density;
+ this.lockedStrokeWidth = Math.round(2 * density);
+ this.nudgeTranslation = 6 * density;
binding.getRoot()
.setOnClickListener(
new View.OnClickListener() {
@Override
public void onClick(View v) {
- if (getAdapterPosition() != RecyclerView.NO_POSITION) {
- animateRoll(
- new Runnable() {
- @Override
- public void run() {
- int position = getAdapterPosition();
- if (position != RecyclerView.NO_POSITION) {
- listener.onRollDie(position);
- }
- }
- });
+ if (getAdapterPosition() == RecyclerView.NO_POSITION) {
+ return;
+ }
+ if (locked) {
+ animateLockedNudge();
+ return;
}
+ animateRoll(
+ new Runnable() {
+ @Override
+ public void run() {
+ int position = getAdapterPosition();
+ if (position != RecyclerView.NO_POSITION) {
+ listener.onRollDie(position);
+ }
+ }
+ });
}
});
binding.getRoot()
@@ -154,14 +177,37 @@ public boolean onLongClick(View v) {
return true;
}
});
+ binding.btnLock.setOnClickListener(
+ new View.OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ int position = getAdapterPosition();
+ if (position != RecyclerView.NO_POSITION) {
+ listener.onToggleLock(position);
+ }
+ }
+ });
+ // The button consumes touches, so without this a long press on the
+ // lock corner would be swallowed instead of removing the die.
+ binding.btnLock.setOnLongClickListener(
+ new View.OnLongClickListener() {
+ @Override
+ public boolean onLongClick(View v) {
+ int position = getAdapterPosition();
+ if (position != RecyclerView.NO_POSITION) {
+ listener.onRemoveDie(position);
+ }
+ return true;
+ }
+ });
+ }
+
+ boolean isLocked() {
+ return locked;
}
public void animateRoll(final Runnable endAction) {
- binding.getRoot().animate().cancel();
- binding.getRoot().setRotation(0f);
- binding.getRoot().setScaleX(1f);
- binding.getRoot().setScaleY(1f);
- binding.getRoot().setTranslationZ(0f);
+ cancelAndResetTile();
binding.getRoot()
.animate()
@@ -184,10 +230,7 @@ public void run() {
new Runnable() {
@Override
public void run() {
- binding.getRoot().setRotation(0f);
- binding.getRoot().setScaleX(1f);
- binding.getRoot().setScaleY(1f);
- binding.getRoot().setTranslationZ(0f);
+ resetTileProperties();
if (endAction != null) {
endAction.run();
}
@@ -199,17 +242,56 @@ public void run() {
.start();
}
- void bind(DieUiModel dieUiModel) {
- int faces = dieUiModel.getFaces();
- int position = getAdapterPosition();
- int colorRes = diceColors[position % diceColors.length];
- int color = binding.getRoot().getContext().getResources().getColor(colorRes);
+ void animateLockedNudge() {
+ final View root = binding.getRoot();
+ cancelAndResetTile();
+ root.animate()
+ .translationX(nudgeTranslation)
+ .setDuration(45)
+ .withEndAction(
+ new Runnable() {
+ @Override
+ public void run() {
+ root.animate()
+ .translationX(-nudgeTranslation)
+ .setDuration(45)
+ .withEndAction(
+ new Runnable() {
+ @Override
+ public void run() {
+ root.animate()
+ .translationX(0f)
+ .setDuration(45)
+ .start();
+ }
+ })
+ .start();
+ }
+ })
+ .start();
+ }
+
+ private void cancelAndResetTile() {
binding.getRoot().animate().cancel();
+ resetTileProperties();
+ }
+
+ private void resetTileProperties() {
binding.getRoot().setRotation(0f);
binding.getRoot().setScaleX(1f);
binding.getRoot().setScaleY(1f);
+ binding.getRoot().setTranslationX(0f);
binding.getRoot().setTranslationZ(0f);
+ }
+
+ void bind(DieUiModel dieUiModel) {
+ int faces = dieUiModel.getFaces();
+ int position = getAdapterPosition();
+ int colorRes = diceColors[position % diceColors.length];
+ int color = binding.getRoot().getContext().getResources().getColor(colorRes);
+
+ cancelAndResetTile();
binding.diceCv.setCardBackgroundColor(color);
@@ -225,6 +307,34 @@ void bind(DieUiModel dieUiModel) {
binding.tvDice.setText(String.valueOf(dieUiModel.getValue()));
binding.tvDieType.setText(
binding.getRoot().getContext().getString(R.string.dice_type_label, faces));
+
+ bindLockState(dieUiModel.isLocked(), textColor);
+ }
+
+ private void bindLockState(boolean isLocked, int contrastColor) {
+ this.locked = isLocked;
+
+ binding.btnLock.setImageResource(
+ isLocked ? R.drawable.ic_lock_24 : R.drawable.ic_lock_open_24);
+ binding.btnLock.setColorFilter(contrastColor);
+ binding.btnLock.setAlpha(isLocked ? 1f : 0.5f);
+ binding.btnLock.setContentDescription(
+ binding.getRoot()
+ .getContext()
+ .getString(
+ isLocked
+ ? R.string.content_desc_unlock_die
+ : R.string.content_desc_lock_die));
+
+ if (isLocked) {
+ binding.diceCv.setStrokeColor(contrastColor);
+ binding.diceCv.setStrokeWidth(lockedStrokeWidth);
+ } else {
+ if (defaultStrokeColor != null) {
+ binding.diceCv.setStrokeColor(defaultStrokeColor);
+ }
+ binding.diceCv.setStrokeWidth(defaultStrokeWidth);
+ }
}
@DrawableRes
diff --git a/app/src/main/java/com/nicue/onetwo/ui/dice/DiceFragment.java b/app/src/main/java/com/nicue/onetwo/ui/dice/DiceFragment.java
index 7a0fd31..d71b50b 100644
--- a/app/src/main/java/com/nicue/onetwo/ui/dice/DiceFragment.java
+++ b/app/src/main/java/com/nicue/onetwo/ui/dice/DiceFragment.java
@@ -34,6 +34,8 @@ public class DiceFragment extends Fragment implements DiceAdapter.Listener, Menu
private DiceLayoutBinding binding;
private DiceAdapter adapter;
private DiceViewModel viewModel;
+ private boolean hasLockedDice;
+ private boolean hasRollableDice;
@Nullable @Override
public View onCreateView(
@@ -97,6 +99,7 @@ public void onClick(View v) {
public void onChanged(DiceUiState state) {
adapter.submitList(state.getDice());
renderResultSummary(state);
+ updateLockActionState(state);
}
});
@@ -120,9 +123,28 @@ public void onCreateMenu(@NonNull Menu menu, @NonNull MenuInflater menuInflater)
menuInflater.inflate(R.menu.dice_actions, menu);
}
+ @Override
+ public void onPrepareMenu(@NonNull Menu menu) {
+ MenuItem unlockAll = menu.findItem(R.id.action_unlock_all);
+ if (unlockAll != null) {
+ unlockAll.setVisible(hasLockedDice);
+ }
+ MenuItem rollAll = menu.findItem(R.id.action_roll_all);
+ if (rollAll != null) {
+ rollAll.setEnabled(hasRollableDice);
+ }
+ }
+
@Override
public boolean onMenuItemSelected(@NonNull MenuItem menuItem) {
+ if (menuItem.getItemId() == R.id.action_unlock_all) {
+ viewModel.unlockAllDice();
+ return true;
+ }
if (menuItem.getItemId() == R.id.action_roll_all) {
+ if (!hasRollableDice) {
+ return true;
+ }
vibrate(new long[] {0, 15, 10, 15, 10, 15, 10, 15});
animateSummaryCard();
adapter.animateAllVisibleItems(
@@ -180,6 +202,25 @@ public void onRemoveDie(int position) {
viewModel.removeDie(position);
}
+ @Override
+ public void onToggleLock(int position) {
+ if (binding == null) {
+ return;
+ }
+ vibrate(30L);
+ viewModel.toggleLock(position);
+ }
+
+ private void updateLockActionState(DiceUiState state) {
+ boolean lockedChanged = hasLockedDice != state.hasLockedDice();
+ boolean rollableChanged = hasRollableDice != state.hasRollableDice();
+ hasLockedDice = state.hasLockedDice();
+ hasRollableDice = state.hasRollableDice();
+ if (lockedChanged || rollableChanged) {
+ requireActivity().invalidateMenu();
+ }
+ }
+
public static String normalizeFacesInput(String input) {
if (input == null || input.trim().isEmpty()) {
return "6";
@@ -277,6 +318,13 @@ private void renderResultSummary(DiceUiState state) {
}
}
+ private void vibrate(long milliseconds) {
+ Vibrator vibrator = (Vibrator) requireContext().getSystemService(Context.VIBRATOR_SERVICE);
+ if (vibrator != null) {
+ vibrator.vibrate(milliseconds);
+ }
+ }
+
private void vibrate(long[] pattern) {
Vibrator vibrator = (Vibrator) requireContext().getSystemService(Context.VIBRATOR_SERVICE);
if (vibrator != null) {
diff --git a/app/src/main/java/com/nicue/onetwo/ui/dice/DiceUiState.java b/app/src/main/java/com/nicue/onetwo/ui/dice/DiceUiState.java
index af36299..ae874f8 100644
--- a/app/src/main/java/com/nicue/onetwo/ui/dice/DiceUiState.java
+++ b/app/src/main/java/com/nicue/onetwo/ui/dice/DiceUiState.java
@@ -6,14 +6,25 @@
public class DiceUiState {
private final ArrayList dice;
private final int total;
+ private final boolean hasLockedDice;
+ private final boolean hasRollableDice;
public DiceUiState(List dice) {
this.dice = new ArrayList<>(dice);
int total = 0;
+ boolean hasLockedDice = false;
+ boolean hasRollableDice = false;
for (DieUiModel die : dice) {
total += die.getValue();
+ if (die.isLocked()) {
+ hasLockedDice = true;
+ } else {
+ hasRollableDice = true;
+ }
}
this.total = total;
+ this.hasLockedDice = hasLockedDice;
+ this.hasRollableDice = hasRollableDice;
}
public ArrayList getDice() {
@@ -23,4 +34,12 @@ public ArrayList getDice() {
public int getTotal() {
return total;
}
+
+ public boolean hasLockedDice() {
+ return hasLockedDice;
+ }
+
+ public boolean hasRollableDice() {
+ return hasRollableDice;
+ }
}
diff --git a/app/src/main/java/com/nicue/onetwo/ui/dice/DiceViewModel.java b/app/src/main/java/com/nicue/onetwo/ui/dice/DiceViewModel.java
index a0dd9ad..d75eeff 100644
--- a/app/src/main/java/com/nicue/onetwo/ui/dice/DiceViewModel.java
+++ b/app/src/main/java/com/nicue/onetwo/ui/dice/DiceViewModel.java
@@ -13,6 +13,7 @@ public class DiceViewModel extends ViewModel {
private static final String KEY_FACES = "dice_faces";
private static final String KEY_VALUES = "dice_values";
private static final String KEY_IDS = "dice_ids";
+ private static final String KEY_LOCKED = "dice_locked";
private static final String KEY_NEXT_ID = "dice_next_id";
private final DiceRepository diceRepository;
@@ -27,7 +28,7 @@ public DiceViewModel(DiceRepository diceRepository, SavedStateHandle savedStateH
ArrayList savedFaces = savedStateHandle.get(KEY_FACES);
ArrayList savedValues = savedStateHandle.get(KEY_VALUES);
ArrayList savedIds = savedStateHandle.get(KEY_IDS);
- Long nextId = savedStateHandle.get(KEY_NEXT_ID);
+ ArrayList savedLocks = savedStateHandle.get(KEY_LOCKED);
if (savedFaces == null) {
savedFaces = new ArrayList<>(diceRepository.readDiceFaces());
@@ -46,7 +47,13 @@ public DiceViewModel(DiceRepository diceRepository, SavedStateHandle savedStateH
}
savedStateHandle.set(KEY_NEXT_ID, currentId);
}
- updateState(savedFaces, savedValues, savedIds);
+ if (savedLocks == null || savedLocks.size() != savedFaces.size()) {
+ savedLocks = new ArrayList<>();
+ for (int i = 0; i < savedFaces.size(); i++) {
+ savedLocks.add(Boolean.FALSE);
+ }
+ }
+ updateState(savedFaces, savedValues, savedIds, savedLocks);
}
public LiveData getUiState() {
@@ -57,6 +64,7 @@ public void addDie(int faces) {
ArrayList dieFaces = getFaces();
ArrayList dieValues = getValues();
ArrayList dieIds = getIds();
+ ArrayList dieLocks = getLocks();
Long nextId = savedStateHandle.get(KEY_NEXT_ID);
if (nextId == null) {
nextId = 0L;
@@ -65,45 +73,94 @@ public void addDie(int faces) {
dieFaces.add(Math.max(2, faces));
dieValues.add(Math.max(2, faces));
dieIds.add(nextId);
+ dieLocks.add(Boolean.FALSE);
savedStateHandle.set(KEY_NEXT_ID, nextId + 1);
persistFaces(dieFaces);
- updateState(dieFaces, dieValues, dieIds);
+ updateState(dieFaces, dieValues, dieIds, dieLocks);
}
public void removeDie(int position) {
ArrayList dieFaces = getFaces();
ArrayList dieValues = getValues();
ArrayList dieIds = getIds();
+ ArrayList dieLocks = getLocks();
if (position < 0 || position >= dieFaces.size()) {
return;
}
dieFaces.remove(position);
dieValues.remove(position);
dieIds.remove(position);
+ dieLocks.remove(position);
persistFaces(dieFaces);
- updateState(dieFaces, dieValues, dieIds);
+ updateState(dieFaces, dieValues, dieIds, dieLocks);
}
public void rollDie(int position) {
ArrayList dieFaces = getFaces();
ArrayList dieValues = getValues();
ArrayList dieIds = getIds();
+ ArrayList dieLocks = getLocks();
if (position < 0 || position >= dieFaces.size()) {
return;
}
+ if (isLocked(dieLocks, position)) {
+ return;
+ }
dieValues.set(position, roll(dieFaces.get(position)));
- updateState(dieFaces, dieValues, dieIds);
+ updateState(dieFaces, dieValues, dieIds, dieLocks);
}
public void rollAllDice() {
ArrayList dieFaces = getFaces();
ArrayList dieValues = getValues();
ArrayList dieIds = getIds();
+ ArrayList dieLocks = getLocks();
for (int i = 0; i < dieFaces.size(); i++) {
+ if (isLocked(dieLocks, i)) {
+ continue;
+ }
dieValues.set(i, roll(dieFaces.get(i)));
}
- updateState(dieFaces, dieValues, dieIds);
+ updateState(dieFaces, dieValues, dieIds, dieLocks);
+ }
+
+ public void toggleLock(int position) {
+ ArrayList dieFaces = getFaces();
+ ArrayList dieValues = getValues();
+ ArrayList dieIds = getIds();
+ ArrayList dieLocks = getLocks();
+ if (position < 0 || position >= dieLocks.size()) {
+ return;
+ }
+ dieLocks.set(position, !isLocked(dieLocks, position));
+ updateState(dieFaces, dieValues, dieIds, dieLocks);
+ }
+
+ public void unlockAllDice() {
+ ArrayList dieFaces = getFaces();
+ ArrayList dieValues = getValues();
+ ArrayList dieIds = getIds();
+ ArrayList dieLocks = getLocks();
+ boolean changed = false;
+ for (int i = 0; i < dieLocks.size(); i++) {
+ if (isLocked(dieLocks, i)) {
+ dieLocks.set(i, Boolean.FALSE);
+ changed = true;
+ }
+ }
+ if (!changed) {
+ return;
+ }
+ updateState(dieFaces, dieValues, dieIds, dieLocks);
+ }
+
+ private boolean isLocked(List dieLocks, int position) {
+ if (position < 0 || position >= dieLocks.size()) {
+ return false;
+ }
+ Boolean locked = dieLocks.get(position);
+ return locked != null && locked;
}
private int roll(int faces) {
@@ -129,14 +186,28 @@ private ArrayList getIds() {
return values == null ? new ArrayList() : new ArrayList<>(values);
}
+ private ArrayList getLocks() {
+ ArrayList values = savedStateHandle.get(KEY_LOCKED);
+ return values == null ? new ArrayList() : new ArrayList<>(values);
+ }
+
private void updateState(
- ArrayList dieFaces, ArrayList dieValues, ArrayList dieIds) {
+ ArrayList dieFaces,
+ ArrayList dieValues,
+ ArrayList dieIds,
+ ArrayList dieLocks) {
savedStateHandle.set(KEY_FACES, new ArrayList<>(dieFaces));
savedStateHandle.set(KEY_VALUES, new ArrayList<>(dieValues));
savedStateHandle.set(KEY_IDS, new ArrayList<>(dieIds));
+ savedStateHandle.set(KEY_LOCKED, new ArrayList<>(dieLocks));
ArrayList dice = new ArrayList<>();
for (int i = 0; i < dieFaces.size(); i++) {
- dice.add(new DieUiModel(dieIds.get(i), dieFaces.get(i), dieValues.get(i)));
+ dice.add(
+ new DieUiModel(
+ dieIds.get(i),
+ dieFaces.get(i),
+ dieValues.get(i),
+ isLocked(dieLocks, i)));
}
uiState.setValue(new DiceUiState(dice));
}
diff --git a/app/src/main/java/com/nicue/onetwo/ui/dice/DieUiModel.java b/app/src/main/java/com/nicue/onetwo/ui/dice/DieUiModel.java
index ecc9fba..9093f45 100644
--- a/app/src/main/java/com/nicue/onetwo/ui/dice/DieUiModel.java
+++ b/app/src/main/java/com/nicue/onetwo/ui/dice/DieUiModel.java
@@ -4,11 +4,13 @@ public class DieUiModel {
private final long id;
private final int faces;
private final int value;
+ private final boolean locked;
- public DieUiModel(long id, int faces, int value) {
+ public DieUiModel(long id, int faces, int value, boolean locked) {
this.id = id;
this.faces = faces;
this.value = value;
+ this.locked = locked;
}
public long getId() {
@@ -22,4 +24,8 @@ public int getFaces() {
public int getValue() {
return value;
}
+
+ public boolean isLocked() {
+ return locked;
+ }
}
diff --git a/app/src/main/res/drawable/ic_lock_24.xml b/app/src/main/res/drawable/ic_lock_24.xml
new file mode 100644
index 0000000..966e9a4
--- /dev/null
+++ b/app/src/main/res/drawable/ic_lock_24.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_lock_open_24.xml b/app/src/main/res/drawable/ic_lock_open_24.xml
new file mode 100644
index 0000000..801ee72
--- /dev/null
+++ b/app/src/main/res/drawable/ic_lock_open_24.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/layout/dice_item.xml b/app/src/main/res/layout/dice_item.xml
index be16bcf..4c8506f 100644
--- a/app/src/main/res/layout/dice_item.xml
+++ b/app/src/main/res/layout/dice_item.xml
@@ -46,6 +46,18 @@
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
+
+