From 5a0681654278ba6a4945df9434e82790bf08942b Mon Sep 17 00:00:00 2001 From: NICOLAS LEYTON Date: Mon, 1 Jun 2026 23:11:13 -0400 Subject: [PATCH 01/14] Add MTG setup coach mark --- .../settings/SettingsPrefsDataSource.java | 9 +++ .../data/settings/SettingsRepository.java | 8 +++ .../nicue/onetwo/ui/life/MtgLifeFragment.java | 55 +++++++++++++++++++ .../onetwo/ui/life/MtgLifeViewModel.java | 8 +++ .../res/drawable/ic_add_circle_outline_24.xml | 10 ++++ app/src/main/res/layout/mtg_coach_mark.xml | 25 +++++++++ app/src/main/res/menu/life_actions.xml | 4 +- app/src/main/res/values/strings.xml | 1 + .../data/settings/SettingsRepositoryTest.java | 20 +++++++ .../onetwo/ui/life/MtgLifeViewModelTest.java | 21 +++++++ 10 files changed, 160 insertions(+), 1 deletion(-) create mode 100644 app/src/main/res/drawable/ic_add_circle_outline_24.xml create mode 100644 app/src/main/res/layout/mtg_coach_mark.xml diff --git a/app/src/main/java/com/nicue/onetwo/data/settings/SettingsPrefsDataSource.java b/app/src/main/java/com/nicue/onetwo/data/settings/SettingsPrefsDataSource.java index 68c3b5c..0361d6e 100644 --- a/app/src/main/java/com/nicue/onetwo/data/settings/SettingsPrefsDataSource.java +++ b/app/src/main/java/com/nicue/onetwo/data/settings/SettingsPrefsDataSource.java @@ -9,6 +9,7 @@ public class SettingsPrefsDataSource { private static final String KEY_DARK_MODE = "dark_mode"; private static final String KEY_CHOOSER_ORDER = "chooser_order"; private static final String KEY_LIFE_COUNTER_HAPTIC_FEEDBACK = "life_counter_haptic_feedback"; + private static final String KEY_MTG_SETUP_COACH_MARK_DISMISSED = "mtg_setup_coach_mark_dismissed"; private final SharedPreferences sharedPreferences; public SettingsPrefsDataSource(Context context) { @@ -46,4 +47,12 @@ public boolean isLifeCounterHapticFeedbackEnabled() { public void setLifeCounterHapticFeedbackEnabled(boolean enabled) { sharedPreferences.edit().putBoolean(KEY_LIFE_COUNTER_HAPTIC_FEEDBACK, enabled).apply(); } + + public boolean isMtgSetupCoachMarkDismissed() { + return sharedPreferences.getBoolean(KEY_MTG_SETUP_COACH_MARK_DISMISSED, false); + } + + public void setMtgSetupCoachMarkDismissed(boolean dismissed) { + sharedPreferences.edit().putBoolean(KEY_MTG_SETUP_COACH_MARK_DISMISSED, dismissed).apply(); + } } diff --git a/app/src/main/java/com/nicue/onetwo/data/settings/SettingsRepository.java b/app/src/main/java/com/nicue/onetwo/data/settings/SettingsRepository.java index 3cff99f..81c6c1f 100644 --- a/app/src/main/java/com/nicue/onetwo/data/settings/SettingsRepository.java +++ b/app/src/main/java/com/nicue/onetwo/data/settings/SettingsRepository.java @@ -43,6 +43,14 @@ public void setLifeCounterHapticFeedbackEnabled(boolean enabled) { dataSource.setLifeCounterHapticFeedbackEnabled(enabled); } + public boolean isMtgSetupCoachMarkDismissed() { + return dataSource.isMtgSetupCoachMarkDismissed(); + } + + public void setMtgSetupCoachMarkDismissed(boolean dismissed) { + dataSource.setMtgSetupCoachMarkDismissed(dismissed); + } + public void applyNightMode() { boolean darkModeEnabled = isDarkModeEnabled(); AppCompatDelegate.setDefaultNightMode( diff --git a/app/src/main/java/com/nicue/onetwo/ui/life/MtgLifeFragment.java b/app/src/main/java/com/nicue/onetwo/ui/life/MtgLifeFragment.java index 588f9f4..7ad81de 100644 --- a/app/src/main/java/com/nicue/onetwo/ui/life/MtgLifeFragment.java +++ b/app/src/main/java/com/nicue/onetwo/ui/life/MtgLifeFragment.java @@ -36,6 +36,7 @@ import androidx.fragment.app.Fragment; import androidx.lifecycle.Lifecycle; import androidx.lifecycle.ViewModelProvider; +import android.widget.PopupWindow; import com.google.android.material.dialog.MaterialAlertDialogBuilder; import com.nicue.onetwo.OneTwoApplication; import com.nicue.onetwo.R; @@ -210,6 +211,60 @@ public void onCreateMenu(@NonNull Menu menu, @NonNull MenuInflater menuInflater) newGameItem.setVisible(!currentUiState.isShowingSetup()); } } + showCoachMarkIfNecessary(); + } + + private void showCoachMarkIfNecessary() { + if (viewModel == null || viewModel.isSetupCoachMarkDismissed()) { + return; + } + MtgLifeUiState state = viewModel.getUiState().getValue(); + if (state != null && state.isShowingSetup()) { + return; + } + + View view = getView(); + if (view == null) return; + view.post( + () -> { + if (getActivity() == null) return; + View anchor = requireActivity().findViewById(R.id.action_new_game); + if (anchor != null + && anchor.isAttachedToWindow() + && anchor.getVisibility() == View.VISIBLE) { + if (viewModel.isSetupCoachMarkDismissed()) return; + showCoachMark(anchor); + } + }); + } + + private void showCoachMark(View anchor) { + View popupView = + LayoutInflater.from(requireContext()).inflate(R.layout.mtg_coach_mark, null); + PopupWindow popupWindow = + new PopupWindow( + popupView, + ViewGroup.LayoutParams.WRAP_CONTENT, + ViewGroup.LayoutParams.WRAP_CONTENT, + true); + popupWindow.setOutsideTouchable(true); + popupWindow.setElevation(dpToPx(8)); + popupWindow.setBackgroundDrawable(new ColorDrawable(Color.TRANSPARENT)); + + popupView.setOnClickListener( + v -> { + popupWindow.dismiss(); + anchor.performClick(); + }); + + popupWindow.setOnDismissListener( + () -> { + viewModel.markSetupCoachMarkDismissed(); + }); + + popupView.measure(View.MeasureSpec.UNSPECIFIED, View.MeasureSpec.UNSPECIFIED); + int xOffset = anchor.getWidth() - popupView.getMeasuredWidth(); + popupWindow.showAsDropDown(anchor, xOffset, dpToPx(4)); } @Override diff --git a/app/src/main/java/com/nicue/onetwo/ui/life/MtgLifeViewModel.java b/app/src/main/java/com/nicue/onetwo/ui/life/MtgLifeViewModel.java index bfbf1d5..bca8172 100644 --- a/app/src/main/java/com/nicue/onetwo/ui/life/MtgLifeViewModel.java +++ b/app/src/main/java/com/nicue/onetwo/ui/life/MtgLifeViewModel.java @@ -358,6 +358,14 @@ private void handleTick(long nowMs) { updateUiState(); } + public boolean isSetupCoachMarkDismissed() { + return settingsRepository.isMtgSetupCoachMarkDismissed(); + } + + public void markSetupCoachMarkDismissed() { + settingsRepository.setMtgSetupCoachMarkDismissed(true); + } + public void resetToSetup() { pauseTimer(); savedStateHandle.set(KEY_SHOWING_SETUP, true); diff --git a/app/src/main/res/drawable/ic_add_circle_outline_24.xml b/app/src/main/res/drawable/ic_add_circle_outline_24.xml new file mode 100644 index 0000000..e3bcb33 --- /dev/null +++ b/app/src/main/res/drawable/ic_add_circle_outline_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/layout/mtg_coach_mark.xml b/app/src/main/res/layout/mtg_coach_mark.xml new file mode 100644 index 0000000..ecfedee --- /dev/null +++ b/app/src/main/res/layout/mtg_coach_mark.xml @@ -0,0 +1,25 @@ + + + + + + + + diff --git a/app/src/main/res/menu/life_actions.xml b/app/src/main/res/menu/life_actions.xml index a67fb16..ae65cc8 100644 --- a/app/src/main/res/menu/life_actions.xml +++ b/app/src/main/res/menu/life_actions.xml @@ -4,5 +4,7 @@ + android:icon="@drawable/ic_add_circle_outline_24" + android:contentDescription="@string/mtg_setup_coach_mark" + app:showAsAction="always" /> diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index a463df0..5212019 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -123,4 +123,5 @@ Start timer for player %1$d Player %1$d remaining time: %2$s Choose 1st & Start + Set players, life, commander damage, and choose first diff --git a/app/src/test/java/com/nicue/onetwo/data/settings/SettingsRepositoryTest.java b/app/src/test/java/com/nicue/onetwo/data/settings/SettingsRepositoryTest.java index e23d0e7..cbdab0f 100644 --- a/app/src/test/java/com/nicue/onetwo/data/settings/SettingsRepositoryTest.java +++ b/app/src/test/java/com/nicue/onetwo/data/settings/SettingsRepositoryTest.java @@ -43,4 +43,24 @@ public void setLifeCounterHapticFeedbackEnabledPersistsValue() { new SettingsRepository(new SettingsPrefsDataSource(context)); assertFalse(reloadedRepository.isLifeCounterHapticFeedbackEnabled()); } + + @Test + public void mtgSetupCoachMarkDefaultsToNotDismissed() { + SettingsRepository repository = + new SettingsRepository(new SettingsPrefsDataSource(context)); + + assertFalse(repository.isMtgSetupCoachMarkDismissed()); + } + + @Test + public void setMtgSetupCoachMarkDismissedPersistsValue() { + SettingsRepository repository = + new SettingsRepository(new SettingsPrefsDataSource(context)); + + repository.setMtgSetupCoachMarkDismissed(true); + + SettingsRepository reloadedRepository = + new SettingsRepository(new SettingsPrefsDataSource(context)); + assertTrue(reloadedRepository.isMtgSetupCoachMarkDismissed()); + } } diff --git a/app/src/test/java/com/nicue/onetwo/ui/life/MtgLifeViewModelTest.java b/app/src/test/java/com/nicue/onetwo/ui/life/MtgLifeViewModelTest.java index 4857c0a..43d0d62 100644 --- a/app/src/test/java/com/nicue/onetwo/ui/life/MtgLifeViewModelTest.java +++ b/app/src/test/java/com/nicue/onetwo/ui/life/MtgLifeViewModelTest.java @@ -42,6 +42,7 @@ void advanceBy(long deltaMs) { private static final class FakeSettingsRepository extends com.nicue.onetwo.data.settings.SettingsRepository { private boolean hapticEnabled = true; + private boolean setupCoachMarkDismissed = false; FakeSettingsRepository() { super(null); @@ -55,6 +56,16 @@ public boolean isLifeCounterHapticFeedbackEnabled() { public void setHapticEnabled(boolean enabled) { this.hapticEnabled = enabled; } + + @Override + public boolean isMtgSetupCoachMarkDismissed() { + return setupCoachMarkDismissed; + } + + @Override + public void setMtgSetupCoachMarkDismissed(boolean dismissed) { + this.setupCoachMarkDismissed = dismissed; + } } @Before @@ -761,4 +772,14 @@ public void testHapticFeedbackSettingDelegation() { settingsRepository.setHapticEnabled(false); assertFalse(viewModel.isLifeCounterHapticFeedbackEnabled()); } + + @Test + public void testSetupCoachMarkDismissalDelegation() { + settingsRepository.setMtgSetupCoachMarkDismissed(false); + assertFalse(viewModel.isSetupCoachMarkDismissed()); + + viewModel.markSetupCoachMarkDismissed(); + assertTrue(settingsRepository.isMtgSetupCoachMarkDismissed()); + assertTrue(viewModel.isSetupCoachMarkDismissed()); + } } From e69f3643fc34c91cb6707aa364a94c65bc01e9e7 Mon Sep 17 00:00:00 2001 From: NICOLAS LEYTON Date: Mon, 1 Jun 2026 23:12:31 -0400 Subject: [PATCH 02/14] Dismiss coach mark popup on fragment destroy --- .../nicue/onetwo/ui/life/MtgLifeFragment.java | 25 +++++++++++---- .../onetwo/ui/life/MtgLifeFragmentTest.java | 32 +++++++++++++++++++ 2 files changed, 50 insertions(+), 7 deletions(-) diff --git a/app/src/main/java/com/nicue/onetwo/ui/life/MtgLifeFragment.java b/app/src/main/java/com/nicue/onetwo/ui/life/MtgLifeFragment.java index 7ad81de..64e9559 100644 --- a/app/src/main/java/com/nicue/onetwo/ui/life/MtgLifeFragment.java +++ b/app/src/main/java/com/nicue/onetwo/ui/life/MtgLifeFragment.java @@ -69,6 +69,7 @@ public class MtgLifeFragment extends Fragment implements MenuProvider { private final Map activeDialogDamageTextViews = new HashMap<>(); private final Handler holdRepeatHandler = new Handler(Looper.getMainLooper()); private final Map activeLifeHoldRunnables = new HashMap<>(); + private PopupWindow coachMarkPopup; private static final class CommanderGridLayout { private final int rows; @@ -239,32 +240,38 @@ private void showCoachMarkIfNecessary() { } private void showCoachMark(View anchor) { + if (coachMarkPopup != null) { + coachMarkPopup.dismiss(); + } View popupView = LayoutInflater.from(requireContext()).inflate(R.layout.mtg_coach_mark, null); - PopupWindow popupWindow = + coachMarkPopup = new PopupWindow( popupView, ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT, true); - popupWindow.setOutsideTouchable(true); - popupWindow.setElevation(dpToPx(8)); - popupWindow.setBackgroundDrawable(new ColorDrawable(Color.TRANSPARENT)); + coachMarkPopup.setOutsideTouchable(true); + coachMarkPopup.setElevation(dpToPx(8)); + coachMarkPopup.setBackgroundDrawable(new ColorDrawable(Color.TRANSPARENT)); popupView.setOnClickListener( v -> { - popupWindow.dismiss(); + if (coachMarkPopup != null) { + coachMarkPopup.dismiss(); + } anchor.performClick(); }); - popupWindow.setOnDismissListener( + coachMarkPopup.setOnDismissListener( () -> { viewModel.markSetupCoachMarkDismissed(); + coachMarkPopup = null; }); popupView.measure(View.MeasureSpec.UNSPECIFIED, View.MeasureSpec.UNSPECIFIED); int xOffset = anchor.getWidth() - popupView.getMeasuredWidth(); - popupWindow.showAsDropDown(anchor, xOffset, dpToPx(4)); + coachMarkPopup.showAsDropDown(anchor, xOffset, dpToPx(4)); } @Override @@ -1357,6 +1364,10 @@ private void vibrate(long milliseconds) { @Override public void onDestroyView() { super.onDestroyView(); + if (coachMarkPopup != null) { + coachMarkPopup.dismiss(); + coachMarkPopup = null; + } for (Runnable repeatRunnable : activeLifeHoldRunnables.values()) { holdRepeatHandler.removeCallbacks(repeatRunnable); } diff --git a/app/src/test/java/com/nicue/onetwo/ui/life/MtgLifeFragmentTest.java b/app/src/test/java/com/nicue/onetwo/ui/life/MtgLifeFragmentTest.java index 168c53e..4733b3d 100644 --- a/app/src/test/java/com/nicue/onetwo/ui/life/MtgLifeFragmentTest.java +++ b/app/src/test/java/com/nicue/onetwo/ui/life/MtgLifeFragmentTest.java @@ -909,4 +909,36 @@ private static void assertCommanderCellContainsSource( defenderPlayerNumber)); assertNotNull(incrementZone); } + + @Test + public void testCoachMarkDismissedOnDestroyView() { + try (FragmentScenario scenario = + FragmentScenario.launchInContainer(MtgLifeFragment.class, null, R.style.AppTheme)) { + scenario.onFragment( + fragment -> { + View anchor = new View(fragment.requireContext()); + try { + java.lang.reflect.Method showCoachMarkMethod = + MtgLifeFragment.class.getDeclaredMethod("showCoachMark", View.class); + showCoachMarkMethod.setAccessible(true); + showCoachMarkMethod.invoke(fragment, anchor); + + java.lang.reflect.Field coachMarkField = + MtgLifeFragment.class.getDeclaredField("coachMarkPopup"); + coachMarkField.setAccessible(true); + android.widget.PopupWindow popup = (android.widget.PopupWindow) coachMarkField.get(fragment); + + assertNotNull(popup); + assertTrue(popup.isShowing()); + + fragment.onDestroyView(); + + org.junit.Assert.assertNull(coachMarkField.get(fragment)); + org.junit.Assert.assertFalse(popup.isShowing()); + } catch (ReflectiveOperationException e) { + throw new AssertionError(e); + } + }); + } + } } From 9188e24fe9776b751d48ad8bbe1eaf93a7058974 Mon Sep 17 00:00:00 2001 From: NICOLAS LEYTON Date: Mon, 1 Jun 2026 23:15:23 -0400 Subject: [PATCH 03/14] test: add accessibility and interaction coverage for coach marks and update menu content description --- app/src/main/res/menu/life_actions.xml | 2 +- .../onetwo/ui/life/MtgLifeFragmentTest.java | 55 +++++++++++++++++++ 2 files changed, 56 insertions(+), 1 deletion(-) diff --git a/app/src/main/res/menu/life_actions.xml b/app/src/main/res/menu/life_actions.xml index ae65cc8..5c81161 100644 --- a/app/src/main/res/menu/life_actions.xml +++ b/app/src/main/res/menu/life_actions.xml @@ -5,6 +5,6 @@ android:id="@+id/action_new_game" android:title="@string/mtg_setup_title" android:icon="@drawable/ic_add_circle_outline_24" - android:contentDescription="@string/mtg_setup_coach_mark" + android:contentDescription="@string/mtg_setup_title" app:showAsAction="always" /> diff --git a/app/src/test/java/com/nicue/onetwo/ui/life/MtgLifeFragmentTest.java b/app/src/test/java/com/nicue/onetwo/ui/life/MtgLifeFragmentTest.java index 4733b3d..a05c93b 100644 --- a/app/src/test/java/com/nicue/onetwo/ui/life/MtgLifeFragmentTest.java +++ b/app/src/test/java/com/nicue/onetwo/ui/life/MtgLifeFragmentTest.java @@ -941,4 +941,59 @@ public void testCoachMarkDismissedOnDestroyView() { }); } } + + @Test + public void testCoachMarkAccessibilityAndInteraction() { + try (FragmentScenario scenario = + FragmentScenario.launchInContainer(MtgLifeFragment.class, null, R.style.AppTheme)) { + scenario.onFragment( + fragment -> { + View anchor = new View(fragment.requireContext()); + boolean[] anchorClicked = {false}; + anchor.setOnClickListener(v -> anchorClicked[0] = true); + + try { + java.lang.reflect.Method showCoachMarkMethod = + MtgLifeFragment.class.getDeclaredMethod("showCoachMark", View.class); + showCoachMarkMethod.setAccessible(true); + showCoachMarkMethod.invoke(fragment, anchor); + + java.lang.reflect.Field coachMarkField = + MtgLifeFragment.class.getDeclaredField("coachMarkPopup"); + coachMarkField.setAccessible(true); + android.widget.PopupWindow popup = (android.widget.PopupWindow) coachMarkField.get(fragment); + + assertNotNull(popup); + assertTrue(popup.isShowing()); + assertTrue(popup.isOutsideTouchable()); // Accessibility: can be dismissed by touching outside + + View popupContentView = popup.getContentView(); + assertNotNull(popupContentView); + + // Simulate click on the coach mark + popupContentView.performClick(); + + // Verify anchor was clicked as a result + assertTrue(anchorClicked[0]); + + // Verify popup was dismissed + org.junit.Assert.assertFalse(popup.isShowing()); + + // Verify ViewModel received the dismiss signal + java.lang.reflect.Field viewModelField = + MtgLifeFragment.class.getDeclaredField("viewModel"); + viewModelField.setAccessible(true); + MtgLifeViewModel viewModel = (MtgLifeViewModel) viewModelField.get(fragment); + assertTrue(viewModel.isSetupCoachMarkDismissed()); + + // Verify reference is cleared via dismiss listener + Shadows.shadowOf(Looper.getMainLooper()).idle(); + org.junit.Assert.assertNull(coachMarkField.get(fragment)); + + } catch (ReflectiveOperationException e) { + throw new AssertionError(e); + } + }); + } + } } From eaeccb7895895b08494aebb9fd51c1a689bffb37 Mon Sep 17 00:00:00 2001 From: NICOLAS LEYTON Date: Mon, 1 Jun 2026 23:22:23 -0400 Subject: [PATCH 04/14] fix: clear coach mark dismiss listener to prevent memory leaks and update test validation --- .../main/java/com/nicue/onetwo/ui/life/MtgLifeFragment.java | 2 ++ .../java/com/nicue/onetwo/ui/life/MtgLifeFragmentTest.java | 6 ++++++ 2 files changed, 8 insertions(+) diff --git a/app/src/main/java/com/nicue/onetwo/ui/life/MtgLifeFragment.java b/app/src/main/java/com/nicue/onetwo/ui/life/MtgLifeFragment.java index 64e9559..817e5cb 100644 --- a/app/src/main/java/com/nicue/onetwo/ui/life/MtgLifeFragment.java +++ b/app/src/main/java/com/nicue/onetwo/ui/life/MtgLifeFragment.java @@ -241,6 +241,7 @@ private void showCoachMarkIfNecessary() { private void showCoachMark(View anchor) { if (coachMarkPopup != null) { + coachMarkPopup.setOnDismissListener(null); coachMarkPopup.dismiss(); } View popupView = @@ -1365,6 +1366,7 @@ private void vibrate(long milliseconds) { public void onDestroyView() { super.onDestroyView(); if (coachMarkPopup != null) { + coachMarkPopup.setOnDismissListener(null); coachMarkPopup.dismiss(); coachMarkPopup = null; } diff --git a/app/src/test/java/com/nicue/onetwo/ui/life/MtgLifeFragmentTest.java b/app/src/test/java/com/nicue/onetwo/ui/life/MtgLifeFragmentTest.java index a05c93b..f153a1c 100644 --- a/app/src/test/java/com/nicue/onetwo/ui/life/MtgLifeFragmentTest.java +++ b/app/src/test/java/com/nicue/onetwo/ui/life/MtgLifeFragmentTest.java @@ -935,6 +935,12 @@ public void testCoachMarkDismissedOnDestroyView() { org.junit.Assert.assertNull(coachMarkField.get(fragment)); org.junit.Assert.assertFalse(popup.isShowing()); + + java.lang.reflect.Field viewModelField = + MtgLifeFragment.class.getDeclaredField("viewModel"); + viewModelField.setAccessible(true); + MtgLifeViewModel viewModel = (MtgLifeViewModel) viewModelField.get(fragment); + org.junit.Assert.assertFalse(viewModel.isSetupCoachMarkDismissed()); } catch (ReflectiveOperationException e) { throw new AssertionError(e); } From 8e975b649cfd8677e8437ecf32e004b45af38897 Mon Sep 17 00:00:00 2001 From: NICOLAS LEYTON Date: Mon, 1 Jun 2026 23:37:48 -0400 Subject: [PATCH 05/14] Format coach mark tests and cleanup imports --- .../settings/SettingsPrefsDataSource.java | 3 ++- .../nicue/onetwo/ui/life/MtgLifeFragment.java | 2 +- .../onetwo/ui/life/MtgLifeFragmentTest.java | 21 ++++++++++++------- 3 files changed, 17 insertions(+), 9 deletions(-) diff --git a/app/src/main/java/com/nicue/onetwo/data/settings/SettingsPrefsDataSource.java b/app/src/main/java/com/nicue/onetwo/data/settings/SettingsPrefsDataSource.java index 0361d6e..33e6682 100644 --- a/app/src/main/java/com/nicue/onetwo/data/settings/SettingsPrefsDataSource.java +++ b/app/src/main/java/com/nicue/onetwo/data/settings/SettingsPrefsDataSource.java @@ -9,7 +9,8 @@ public class SettingsPrefsDataSource { private static final String KEY_DARK_MODE = "dark_mode"; private static final String KEY_CHOOSER_ORDER = "chooser_order"; private static final String KEY_LIFE_COUNTER_HAPTIC_FEEDBACK = "life_counter_haptic_feedback"; - private static final String KEY_MTG_SETUP_COACH_MARK_DISMISSED = "mtg_setup_coach_mark_dismissed"; + private static final String KEY_MTG_SETUP_COACH_MARK_DISMISSED = + "mtg_setup_coach_mark_dismissed"; private final SharedPreferences sharedPreferences; public SettingsPrefsDataSource(Context context) { diff --git a/app/src/main/java/com/nicue/onetwo/ui/life/MtgLifeFragment.java b/app/src/main/java/com/nicue/onetwo/ui/life/MtgLifeFragment.java index 817e5cb..8fc0eb6 100644 --- a/app/src/main/java/com/nicue/onetwo/ui/life/MtgLifeFragment.java +++ b/app/src/main/java/com/nicue/onetwo/ui/life/MtgLifeFragment.java @@ -25,6 +25,7 @@ import android.widget.EditText; import android.widget.FrameLayout; import android.widget.LinearLayout; +import android.widget.PopupWindow; import android.widget.TextView; import androidx.annotation.NonNull; import androidx.annotation.Nullable; @@ -36,7 +37,6 @@ import androidx.fragment.app.Fragment; import androidx.lifecycle.Lifecycle; import androidx.lifecycle.ViewModelProvider; -import android.widget.PopupWindow; import com.google.android.material.dialog.MaterialAlertDialogBuilder; import com.nicue.onetwo.OneTwoApplication; import com.nicue.onetwo.R; diff --git a/app/src/test/java/com/nicue/onetwo/ui/life/MtgLifeFragmentTest.java b/app/src/test/java/com/nicue/onetwo/ui/life/MtgLifeFragmentTest.java index f153a1c..4097334 100644 --- a/app/src/test/java/com/nicue/onetwo/ui/life/MtgLifeFragmentTest.java +++ b/app/src/test/java/com/nicue/onetwo/ui/life/MtgLifeFragmentTest.java @@ -919,14 +919,16 @@ public void testCoachMarkDismissedOnDestroyView() { View anchor = new View(fragment.requireContext()); try { java.lang.reflect.Method showCoachMarkMethod = - MtgLifeFragment.class.getDeclaredMethod("showCoachMark", View.class); + MtgLifeFragment.class.getDeclaredMethod( + "showCoachMark", View.class); showCoachMarkMethod.setAccessible(true); showCoachMarkMethod.invoke(fragment, anchor); java.lang.reflect.Field coachMarkField = MtgLifeFragment.class.getDeclaredField("coachMarkPopup"); coachMarkField.setAccessible(true); - android.widget.PopupWindow popup = (android.widget.PopupWindow) coachMarkField.get(fragment); + android.widget.PopupWindow popup = + (android.widget.PopupWindow) coachMarkField.get(fragment); assertNotNull(popup); assertTrue(popup.isShowing()); @@ -939,7 +941,8 @@ public void testCoachMarkDismissedOnDestroyView() { java.lang.reflect.Field viewModelField = MtgLifeFragment.class.getDeclaredField("viewModel"); viewModelField.setAccessible(true); - MtgLifeViewModel viewModel = (MtgLifeViewModel) viewModelField.get(fragment); + MtgLifeViewModel viewModel = + (MtgLifeViewModel) viewModelField.get(fragment); org.junit.Assert.assertFalse(viewModel.isSetupCoachMarkDismissed()); } catch (ReflectiveOperationException e) { throw new AssertionError(e); @@ -960,18 +963,21 @@ public void testCoachMarkAccessibilityAndInteraction() { try { java.lang.reflect.Method showCoachMarkMethod = - MtgLifeFragment.class.getDeclaredMethod("showCoachMark", View.class); + MtgLifeFragment.class.getDeclaredMethod( + "showCoachMark", View.class); showCoachMarkMethod.setAccessible(true); showCoachMarkMethod.invoke(fragment, anchor); java.lang.reflect.Field coachMarkField = MtgLifeFragment.class.getDeclaredField("coachMarkPopup"); coachMarkField.setAccessible(true); - android.widget.PopupWindow popup = (android.widget.PopupWindow) coachMarkField.get(fragment); + android.widget.PopupWindow popup = + (android.widget.PopupWindow) coachMarkField.get(fragment); assertNotNull(popup); assertTrue(popup.isShowing()); - assertTrue(popup.isOutsideTouchable()); // Accessibility: can be dismissed by touching outside + assertTrue(popup.isOutsideTouchable()); // Accessibility: can be + // dismissed by touching outside View popupContentView = popup.getContentView(); assertNotNull(popupContentView); @@ -989,7 +995,8 @@ public void testCoachMarkAccessibilityAndInteraction() { java.lang.reflect.Field viewModelField = MtgLifeFragment.class.getDeclaredField("viewModel"); viewModelField.setAccessible(true); - MtgLifeViewModel viewModel = (MtgLifeViewModel) viewModelField.get(fragment); + MtgLifeViewModel viewModel = + (MtgLifeViewModel) viewModelField.get(fragment); assertTrue(viewModel.isSetupCoachMarkDismissed()); // Verify reference is cleared via dismiss listener From 208122e92bc05a9f6cdcb7bbb73edf5e78ef3e9d Mon Sep 17 00:00:00 2001 From: NICOLAS LEYTON Date: Mon, 1 Jun 2026 23:57:25 -0400 Subject: [PATCH 06/14] Align coach mark with new game toolbar button --- .../nicue/onetwo/ui/life/MtgLifeFragment.java | 10 +- .../drawable/bg_mtg_coach_mark_pointer.xml | 6 + app/src/main/res/layout/mtg_coach_mark.xml | 20 +- .../onetwo/ui/life/MtgLifeFragmentTest.java | 1 + pr_diff.txt | 416 ++++++++++++++++++ 5 files changed, 446 insertions(+), 7 deletions(-) create mode 100644 app/src/main/res/drawable/bg_mtg_coach_mark_pointer.xml create mode 100644 pr_diff.txt diff --git a/app/src/main/java/com/nicue/onetwo/ui/life/MtgLifeFragment.java b/app/src/main/java/com/nicue/onetwo/ui/life/MtgLifeFragment.java index 8fc0eb6..9cb600f 100644 --- a/app/src/main/java/com/nicue/onetwo/ui/life/MtgLifeFragment.java +++ b/app/src/main/java/com/nicue/onetwo/ui/life/MtgLifeFragment.java @@ -271,8 +271,14 @@ private void showCoachMark(View anchor) { }); popupView.measure(View.MeasureSpec.UNSPECIFIED, View.MeasureSpec.UNSPECIFIED); - int xOffset = anchor.getWidth() - popupView.getMeasuredWidth(); - coachMarkPopup.showAsDropDown(anchor, xOffset, dpToPx(4)); + popupView.layout(0, 0, popupView.getMeasuredWidth(), popupView.getMeasuredHeight()); + View pointer = popupView.findViewById(R.id.coach_mark_pointer); + int pointerCenterX = popupView.getMeasuredWidth() / 2; + if (pointer != null) { + pointerCenterX = pointer.getLeft() + (pointer.getMeasuredWidth() / 2); + } + int xOffset = Math.round((anchor.getWidth() / 2f) - pointerCenterX); + coachMarkPopup.showAsDropDown(anchor, xOffset, dpToPx(2)); } @Override diff --git a/app/src/main/res/drawable/bg_mtg_coach_mark_pointer.xml b/app/src/main/res/drawable/bg_mtg_coach_mark_pointer.xml new file mode 100644 index 0000000..9aa2400 --- /dev/null +++ b/app/src/main/res/drawable/bg_mtg_coach_mark_pointer.xml @@ -0,0 +1,6 @@ + + + + + diff --git a/app/src/main/res/layout/mtg_coach_mark.xml b/app/src/main/res/layout/mtg_coach_mark.xml index ecfedee..52c51c2 100644 --- a/app/src/main/res/layout/mtg_coach_mark.xml +++ b/app/src/main/res/layout/mtg_coach_mark.xml @@ -1,15 +1,25 @@ - + + - + diff --git a/app/src/test/java/com/nicue/onetwo/ui/life/MtgLifeFragmentTest.java b/app/src/test/java/com/nicue/onetwo/ui/life/MtgLifeFragmentTest.java index 4097334..30b7120 100644 --- a/app/src/test/java/com/nicue/onetwo/ui/life/MtgLifeFragmentTest.java +++ b/app/src/test/java/com/nicue/onetwo/ui/life/MtgLifeFragmentTest.java @@ -981,6 +981,7 @@ public void testCoachMarkAccessibilityAndInteraction() { View popupContentView = popup.getContentView(); assertNotNull(popupContentView); + assertNotNull(popupContentView.findViewById(R.id.coach_mark_pointer)); // Simulate click on the coach mark popupContentView.performClick(); diff --git a/pr_diff.txt b/pr_diff.txt new file mode 100644 index 0000000..1da7d33 --- /dev/null +++ b/pr_diff.txt @@ -0,0 +1,416 @@ +diff --git a/app/src/main/java/com/nicue/onetwo/data/settings/SettingsPrefsDataSource.java b/app/src/main/java/com/nicue/onetwo/data/settings/SettingsPrefsDataSource.java +index 68c3b5c..33e6682 100644 +--- a/app/src/main/java/com/nicue/onetwo/data/settings/SettingsPrefsDataSource.java ++++ b/app/src/main/java/com/nicue/onetwo/data/settings/SettingsPrefsDataSource.java +@@ -9,6 +9,8 @@ public class SettingsPrefsDataSource { + private static final String KEY_DARK_MODE = "dark_mode"; + private static final String KEY_CHOOSER_ORDER = "chooser_order"; + private static final String KEY_LIFE_COUNTER_HAPTIC_FEEDBACK = "life_counter_haptic_feedback"; ++ private static final String KEY_MTG_SETUP_COACH_MARK_DISMISSED = ++ "mtg_setup_coach_mark_dismissed"; + private final SharedPreferences sharedPreferences; + + public SettingsPrefsDataSource(Context context) { +@@ -46,4 +48,12 @@ public class SettingsPrefsDataSource { + public void setLifeCounterHapticFeedbackEnabled(boolean enabled) { + sharedPreferences.edit().putBoolean(KEY_LIFE_COUNTER_HAPTIC_FEEDBACK, enabled).apply(); + } ++ ++ public boolean isMtgSetupCoachMarkDismissed() { ++ return sharedPreferences.getBoolean(KEY_MTG_SETUP_COACH_MARK_DISMISSED, false); ++ } ++ ++ public void setMtgSetupCoachMarkDismissed(boolean dismissed) { ++ sharedPreferences.edit().putBoolean(KEY_MTG_SETUP_COACH_MARK_DISMISSED, dismissed).apply(); ++ } + } +diff --git a/app/src/main/java/com/nicue/onetwo/data/settings/SettingsRepository.java b/app/src/main/java/com/nicue/onetwo/data/settings/SettingsRepository.java +index 3cff99f..81c6c1f 100644 +--- a/app/src/main/java/com/nicue/onetwo/data/settings/SettingsRepository.java ++++ b/app/src/main/java/com/nicue/onetwo/data/settings/SettingsRepository.java +@@ -43,6 +43,14 @@ public class SettingsRepository { + dataSource.setLifeCounterHapticFeedbackEnabled(enabled); + } + ++ public boolean isMtgSetupCoachMarkDismissed() { ++ return dataSource.isMtgSetupCoachMarkDismissed(); ++ } ++ ++ public void setMtgSetupCoachMarkDismissed(boolean dismissed) { ++ dataSource.setMtgSetupCoachMarkDismissed(dismissed); ++ } ++ + public void applyNightMode() { + boolean darkModeEnabled = isDarkModeEnabled(); + AppCompatDelegate.setDefaultNightMode( +diff --git a/app/src/main/java/com/nicue/onetwo/ui/life/MtgLifeFragment.java b/app/src/main/java/com/nicue/onetwo/ui/life/MtgLifeFragment.java +index 588f9f4..8fc0eb6 100644 +--- a/app/src/main/java/com/nicue/onetwo/ui/life/MtgLifeFragment.java ++++ b/app/src/main/java/com/nicue/onetwo/ui/life/MtgLifeFragment.java +@@ -25,6 +25,7 @@ import android.view.ViewGroup; + import android.widget.EditText; + import android.widget.FrameLayout; + import android.widget.LinearLayout; ++import android.widget.PopupWindow; + import android.widget.TextView; + import androidx.annotation.NonNull; + import androidx.annotation.Nullable; +@@ -68,6 +69,7 @@ public class MtgLifeFragment extends Fragment implements MenuProvider { + private final Map activeDialogDamageTextViews = new HashMap<>(); + private final Handler holdRepeatHandler = new Handler(Looper.getMainLooper()); + private final Map activeLifeHoldRunnables = new HashMap<>(); ++ private PopupWindow coachMarkPopup; + + private static final class CommanderGridLayout { + private final int rows; +@@ -210,6 +212,67 @@ public class MtgLifeFragment extends Fragment implements MenuProvider { + newGameItem.setVisible(!currentUiState.isShowingSetup()); + } + } ++ showCoachMarkIfNecessary(); ++ } ++ ++ private void showCoachMarkIfNecessary() { ++ if (viewModel == null || viewModel.isSetupCoachMarkDismissed()) { ++ return; ++ } ++ MtgLifeUiState state = viewModel.getUiState().getValue(); ++ if (state != null && state.isShowingSetup()) { ++ return; ++ } ++ ++ View view = getView(); ++ if (view == null) return; ++ view.post( ++ () -> { ++ if (getActivity() == null) return; ++ View anchor = requireActivity().findViewById(R.id.action_new_game); ++ if (anchor != null ++ && anchor.isAttachedToWindow() ++ && anchor.getVisibility() == View.VISIBLE) { ++ if (viewModel.isSetupCoachMarkDismissed()) return; ++ showCoachMark(anchor); ++ } ++ }); ++ } ++ ++ private void showCoachMark(View anchor) { ++ if (coachMarkPopup != null) { ++ coachMarkPopup.setOnDismissListener(null); ++ coachMarkPopup.dismiss(); ++ } ++ View popupView = ++ LayoutInflater.from(requireContext()).inflate(R.layout.mtg_coach_mark, null); ++ coachMarkPopup = ++ new PopupWindow( ++ popupView, ++ ViewGroup.LayoutParams.WRAP_CONTENT, ++ ViewGroup.LayoutParams.WRAP_CONTENT, ++ true); ++ coachMarkPopup.setOutsideTouchable(true); ++ coachMarkPopup.setElevation(dpToPx(8)); ++ coachMarkPopup.setBackgroundDrawable(new ColorDrawable(Color.TRANSPARENT)); ++ ++ popupView.setOnClickListener( ++ v -> { ++ if (coachMarkPopup != null) { ++ coachMarkPopup.dismiss(); ++ } ++ anchor.performClick(); ++ }); ++ ++ coachMarkPopup.setOnDismissListener( ++ () -> { ++ viewModel.markSetupCoachMarkDismissed(); ++ coachMarkPopup = null; ++ }); ++ ++ popupView.measure(View.MeasureSpec.UNSPECIFIED, View.MeasureSpec.UNSPECIFIED); ++ int xOffset = anchor.getWidth() - popupView.getMeasuredWidth(); ++ coachMarkPopup.showAsDropDown(anchor, xOffset, dpToPx(4)); + } + + @Override +@@ -1302,6 +1365,11 @@ public class MtgLifeFragment extends Fragment implements MenuProvider { + @Override + public void onDestroyView() { + super.onDestroyView(); ++ if (coachMarkPopup != null) { ++ coachMarkPopup.setOnDismissListener(null); ++ coachMarkPopup.dismiss(); ++ coachMarkPopup = null; ++ } + for (Runnable repeatRunnable : activeLifeHoldRunnables.values()) { + holdRepeatHandler.removeCallbacks(repeatRunnable); + } +diff --git a/app/src/main/java/com/nicue/onetwo/ui/life/MtgLifeViewModel.java b/app/src/main/java/com/nicue/onetwo/ui/life/MtgLifeViewModel.java +index bfbf1d5..bca8172 100644 +--- a/app/src/main/java/com/nicue/onetwo/ui/life/MtgLifeViewModel.java ++++ b/app/src/main/java/com/nicue/onetwo/ui/life/MtgLifeViewModel.java +@@ -358,6 +358,14 @@ public class MtgLifeViewModel extends ViewModel { + updateUiState(); + } + ++ public boolean isSetupCoachMarkDismissed() { ++ return settingsRepository.isMtgSetupCoachMarkDismissed(); ++ } ++ ++ public void markSetupCoachMarkDismissed() { ++ settingsRepository.setMtgSetupCoachMarkDismissed(true); ++ } ++ + public void resetToSetup() { + pauseTimer(); + savedStateHandle.set(KEY_SHOWING_SETUP, true); +diff --git a/app/src/main/res/drawable/ic_add_circle_outline_24.xml b/app/src/main/res/drawable/ic_add_circle_outline_24.xml +new file mode 100644 +index 0000000..e3bcb33 +--- /dev/null ++++ b/app/src/main/res/drawable/ic_add_circle_outline_24.xml +@@ -0,0 +1,10 @@ ++ ++ ++ +diff --git a/app/src/main/res/layout/mtg_coach_mark.xml b/app/src/main/res/layout/mtg_coach_mark.xml +new file mode 100644 +index 0000000..ecfedee +--- /dev/null ++++ b/app/src/main/res/layout/mtg_coach_mark.xml +@@ -0,0 +1,25 @@ ++ ++ ++ ++ ++ ++ ++ ++ +diff --git a/app/src/main/res/menu/life_actions.xml b/app/src/main/res/menu/life_actions.xml +index a67fb16..5c81161 100644 +--- a/app/src/main/res/menu/life_actions.xml ++++ b/app/src/main/res/menu/life_actions.xml +@@ -4,5 +4,7 @@ + ++ android:icon="@drawable/ic_add_circle_outline_24" ++ android:contentDescription="@string/mtg_setup_title" ++ app:showAsAction="always" /> + +diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml +index a463df0..5212019 100644 +--- a/app/src/main/res/values/strings.xml ++++ b/app/src/main/res/values/strings.xml +@@ -123,4 +123,5 @@ + Start timer for player %1$d + Player %1$d remaining time: %2$s + Choose 1st & Start ++ Set players, life, commander damage, and choose first + +diff --git a/app/src/test/java/com/nicue/onetwo/data/settings/SettingsRepositoryTest.java b/app/src/test/java/com/nicue/onetwo/data/settings/SettingsRepositoryTest.java +index e23d0e7..cbdab0f 100644 +--- a/app/src/test/java/com/nicue/onetwo/data/settings/SettingsRepositoryTest.java ++++ b/app/src/test/java/com/nicue/onetwo/data/settings/SettingsRepositoryTest.java +@@ -43,4 +43,24 @@ public class SettingsRepositoryTest { + new SettingsRepository(new SettingsPrefsDataSource(context)); + assertFalse(reloadedRepository.isLifeCounterHapticFeedbackEnabled()); + } ++ ++ @Test ++ public void mtgSetupCoachMarkDefaultsToNotDismissed() { ++ SettingsRepository repository = ++ new SettingsRepository(new SettingsPrefsDataSource(context)); ++ ++ assertFalse(repository.isMtgSetupCoachMarkDismissed()); ++ } ++ ++ @Test ++ public void setMtgSetupCoachMarkDismissedPersistsValue() { ++ SettingsRepository repository = ++ new SettingsRepository(new SettingsPrefsDataSource(context)); ++ ++ repository.setMtgSetupCoachMarkDismissed(true); ++ ++ SettingsRepository reloadedRepository = ++ new SettingsRepository(new SettingsPrefsDataSource(context)); ++ assertTrue(reloadedRepository.isMtgSetupCoachMarkDismissed()); ++ } + } +diff --git a/app/src/test/java/com/nicue/onetwo/ui/life/MtgLifeFragmentTest.java b/app/src/test/java/com/nicue/onetwo/ui/life/MtgLifeFragmentTest.java +index 168c53e..4097334 100644 +--- a/app/src/test/java/com/nicue/onetwo/ui/life/MtgLifeFragmentTest.java ++++ b/app/src/test/java/com/nicue/onetwo/ui/life/MtgLifeFragmentTest.java +@@ -909,4 +909,104 @@ public class MtgLifeFragmentTest { + defenderPlayerNumber)); + assertNotNull(incrementZone); + } ++ ++ @Test ++ public void testCoachMarkDismissedOnDestroyView() { ++ try (FragmentScenario scenario = ++ FragmentScenario.launchInContainer(MtgLifeFragment.class, null, R.style.AppTheme)) { ++ scenario.onFragment( ++ fragment -> { ++ View anchor = new View(fragment.requireContext()); ++ try { ++ java.lang.reflect.Method showCoachMarkMethod = ++ MtgLifeFragment.class.getDeclaredMethod( ++ "showCoachMark", View.class); ++ showCoachMarkMethod.setAccessible(true); ++ showCoachMarkMethod.invoke(fragment, anchor); ++ ++ java.lang.reflect.Field coachMarkField = ++ MtgLifeFragment.class.getDeclaredField("coachMarkPopup"); ++ coachMarkField.setAccessible(true); ++ android.widget.PopupWindow popup = ++ (android.widget.PopupWindow) coachMarkField.get(fragment); ++ ++ assertNotNull(popup); ++ assertTrue(popup.isShowing()); ++ ++ fragment.onDestroyView(); ++ ++ org.junit.Assert.assertNull(coachMarkField.get(fragment)); ++ org.junit.Assert.assertFalse(popup.isShowing()); ++ ++ java.lang.reflect.Field viewModelField = ++ MtgLifeFragment.class.getDeclaredField("viewModel"); ++ viewModelField.setAccessible(true); ++ MtgLifeViewModel viewModel = ++ (MtgLifeViewModel) viewModelField.get(fragment); ++ org.junit.Assert.assertFalse(viewModel.isSetupCoachMarkDismissed()); ++ } catch (ReflectiveOperationException e) { ++ throw new AssertionError(e); ++ } ++ }); ++ } ++ } ++ ++ @Test ++ public void testCoachMarkAccessibilityAndInteraction() { ++ try (FragmentScenario scenario = ++ FragmentScenario.launchInContainer(MtgLifeFragment.class, null, R.style.AppTheme)) { ++ scenario.onFragment( ++ fragment -> { ++ View anchor = new View(fragment.requireContext()); ++ boolean[] anchorClicked = {false}; ++ anchor.setOnClickListener(v -> anchorClicked[0] = true); ++ ++ try { ++ java.lang.reflect.Method showCoachMarkMethod = ++ MtgLifeFragment.class.getDeclaredMethod( ++ "showCoachMark", View.class); ++ showCoachMarkMethod.setAccessible(true); ++ showCoachMarkMethod.invoke(fragment, anchor); ++ ++ java.lang.reflect.Field coachMarkField = ++ MtgLifeFragment.class.getDeclaredField("coachMarkPopup"); ++ coachMarkField.setAccessible(true); ++ android.widget.PopupWindow popup = ++ (android.widget.PopupWindow) coachMarkField.get(fragment); ++ ++ assertNotNull(popup); ++ assertTrue(popup.isShowing()); ++ assertTrue(popup.isOutsideTouchable()); // Accessibility: can be ++ // dismissed by touching outside ++ ++ View popupContentView = popup.getContentView(); ++ assertNotNull(popupContentView); ++ ++ // Simulate click on the coach mark ++ popupContentView.performClick(); ++ ++ // Verify anchor was clicked as a result ++ assertTrue(anchorClicked[0]); ++ ++ // Verify popup was dismissed ++ org.junit.Assert.assertFalse(popup.isShowing()); ++ ++ // Verify ViewModel received the dismiss signal ++ java.lang.reflect.Field viewModelField = ++ MtgLifeFragment.class.getDeclaredField("viewModel"); ++ viewModelField.setAccessible(true); ++ MtgLifeViewModel viewModel = ++ (MtgLifeViewModel) viewModelField.get(fragment); ++ assertTrue(viewModel.isSetupCoachMarkDismissed()); ++ ++ // Verify reference is cleared via dismiss listener ++ Shadows.shadowOf(Looper.getMainLooper()).idle(); ++ org.junit.Assert.assertNull(coachMarkField.get(fragment)); ++ ++ } catch (ReflectiveOperationException e) { ++ throw new AssertionError(e); ++ } ++ }); ++ } ++ } + } +diff --git a/app/src/test/java/com/nicue/onetwo/ui/life/MtgLifeViewModelTest.java b/app/src/test/java/com/nicue/onetwo/ui/life/MtgLifeViewModelTest.java +index 4857c0a..43d0d62 100644 +--- a/app/src/test/java/com/nicue/onetwo/ui/life/MtgLifeViewModelTest.java ++++ b/app/src/test/java/com/nicue/onetwo/ui/life/MtgLifeViewModelTest.java +@@ -42,6 +42,7 @@ public class MtgLifeViewModelTest { + private static final class FakeSettingsRepository + extends com.nicue.onetwo.data.settings.SettingsRepository { + private boolean hapticEnabled = true; ++ private boolean setupCoachMarkDismissed = false; + + FakeSettingsRepository() { + super(null); +@@ -55,6 +56,16 @@ public class MtgLifeViewModelTest { + public void setHapticEnabled(boolean enabled) { + this.hapticEnabled = enabled; + } ++ ++ @Override ++ public boolean isMtgSetupCoachMarkDismissed() { ++ return setupCoachMarkDismissed; ++ } ++ ++ @Override ++ public void setMtgSetupCoachMarkDismissed(boolean dismissed) { ++ this.setupCoachMarkDismissed = dismissed; ++ } + } + + @Before +@@ -761,4 +772,14 @@ public class MtgLifeViewModelTest { + settingsRepository.setHapticEnabled(false); + assertFalse(viewModel.isLifeCounterHapticFeedbackEnabled()); + } ++ ++ @Test ++ public void testSetupCoachMarkDismissalDelegation() { ++ settingsRepository.setMtgSetupCoachMarkDismissed(false); ++ assertFalse(viewModel.isSetupCoachMarkDismissed()); ++ ++ viewModel.markSetupCoachMarkDismissed(); ++ assertTrue(settingsRepository.isMtgSetupCoachMarkDismissed()); ++ assertTrue(viewModel.isSetupCoachMarkDismissed()); ++ } + } From 1f0b5af81b59646c866d56f7dfd657b8bb4d7baa Mon Sep 17 00:00:00 2001 From: NICOLAS LEYTON Date: Tue, 2 Jun 2026 00:12:39 -0400 Subject: [PATCH 07/14] Refine MTG life coach mark anchoring and styling --- .../nicue/onetwo/ui/life/MtgLifeFragment.java | 70 ++++++++++++++++++- .../drawable/bg_mtg_coach_mark_pointer.xml | 2 +- app/src/main/res/layout/mtg_coach_mark.xml | 59 +++++++++++----- app/src/main/res/values/strings.xml | 2 +- .../onetwo/ui/life/MtgLifeFragmentTest.java | 48 +++++++++++++ 5 files changed, 160 insertions(+), 21 deletions(-) diff --git a/app/src/main/java/com/nicue/onetwo/ui/life/MtgLifeFragment.java b/app/src/main/java/com/nicue/onetwo/ui/life/MtgLifeFragment.java index 9cb600f..88ec128 100644 --- a/app/src/main/java/com/nicue/onetwo/ui/life/MtgLifeFragment.java +++ b/app/src/main/java/com/nicue/onetwo/ui/life/MtgLifeFragment.java @@ -33,10 +33,12 @@ import androidx.core.graphics.ColorUtils; import androidx.core.view.MenuHost; import androidx.core.view.MenuProvider; +import androidx.core.view.ViewCompat; import androidx.core.widget.TextViewCompat; import androidx.fragment.app.Fragment; import androidx.lifecycle.Lifecycle; import androidx.lifecycle.ViewModelProvider; +import com.google.android.material.appbar.MaterialToolbar; import com.google.android.material.dialog.MaterialAlertDialogBuilder; import com.nicue.onetwo.OneTwoApplication; import com.nicue.onetwo.R; @@ -229,7 +231,7 @@ private void showCoachMarkIfNecessary() { view.post( () -> { if (getActivity() == null) return; - View anchor = requireActivity().findViewById(R.id.action_new_game); + View anchor = findCoachMarkAnchor(); if (anchor != null && anchor.isAttachedToWindow() && anchor.getVisibility() == View.VISIBLE) { @@ -239,6 +241,56 @@ private void showCoachMarkIfNecessary() { }); } + private View findCoachMarkAnchor() { + MaterialToolbar toolbar = requireActivity().findViewById(R.id.app_toolbar); + if (toolbar == null) { + toolbar = requireActivity().findViewById(R.id.toolbar); + } + if (toolbar == null) { + return null; + } + + View directMatch = toolbar.findViewById(R.id.action_new_game); + if (directMatch != null) { + return directMatch; + } + + MenuItem newGameItem = toolbar.getMenu().findItem(R.id.action_new_game); + if (newGameItem == null) { + return toolbar; + } + + View contentDescriptionMatch = + findViewWithContentDescription(toolbar, newGameItem.getContentDescription()); + if (contentDescriptionMatch != null) { + return contentDescriptionMatch; + } + + View titleMatch = findViewWithContentDescription(toolbar, newGameItem.getTitle()); + return titleMatch != null ? titleMatch : toolbar; + } + + private View findViewWithContentDescription(View root, CharSequence contentDescription) { + if (root == null || contentDescription == null) { + return null; + } + CharSequence rootDescription = root.getContentDescription(); + if (rootDescription != null && rootDescription.toString().contentEquals(contentDescription)) { + return root; + } + if (root instanceof ViewGroup rootGroup) { + for (int i = 0; i < rootGroup.getChildCount(); i++) { + View match = + findViewWithContentDescription( + rootGroup.getChildAt(i), contentDescription); + if (match != null) { + return match; + } + } + } + return null; + } + private void showCoachMark(View anchor) { if (coachMarkPopup != null) { coachMarkPopup.setOnDismissListener(null); @@ -277,10 +329,24 @@ private void showCoachMark(View anchor) { if (pointer != null) { pointerCenterX = pointer.getLeft() + (pointer.getMeasuredWidth() / 2); } - int xOffset = Math.round((anchor.getWidth() / 2f) - pointerCenterX); + int anchorCenterX = getCoachMarkAnchorCenterX(anchor); + int xOffset = anchorCenterX - pointerCenterX; coachMarkPopup.showAsDropDown(anchor, xOffset, dpToPx(2)); } + private int getCoachMarkAnchorCenterX(View anchor) { + if (anchor.getId() != R.id.toolbar || !(anchor instanceof MaterialToolbar toolbar)) { + return Math.round(anchor.getWidth() / 2f); + } + + int actionCenterOffset = dpToPx(24); + boolean isRtl = ViewCompat.getLayoutDirection(toolbar) == ViewCompat.LAYOUT_DIRECTION_RTL; + if (isRtl) { + return toolbar.getContentInsetStart() + actionCenterOffset; + } + return toolbar.getWidth() - toolbar.getContentInsetEnd() - actionCenterOffset; + } + @Override public boolean onMenuItemSelected(@NonNull MenuItem menuItem) { if (menuItem.getItemId() == R.id.action_new_game) { diff --git a/app/src/main/res/drawable/bg_mtg_coach_mark_pointer.xml b/app/src/main/res/drawable/bg_mtg_coach_mark_pointer.xml index 9aa2400..28c5cda 100644 --- a/app/src/main/res/drawable/bg_mtg_coach_mark_pointer.xml +++ b/app/src/main/res/drawable/bg_mtg_coach_mark_pointer.xml @@ -1,6 +1,6 @@ - + diff --git a/app/src/main/res/layout/mtg_coach_mark.xml b/app/src/main/res/layout/mtg_coach_mark.xml index 52c51c2..a03acec 100644 --- a/app/src/main/res/layout/mtg_coach_mark.xml +++ b/app/src/main/res/layout/mtg_coach_mark.xml @@ -3,33 +3,58 @@ xmlns:app="http://schemas.android.com/apk/res-auto" android:layout_width="wrap_content" android:layout_height="wrap_content" - android:padding="8dp"> + android:paddingStart="6dp" + android:paddingTop="6dp" + android:paddingEnd="6dp" + android:paddingBottom="2dp"> + android:layout_gravity="top|end" + android:layout_marginTop="7dp" + app:cardBackgroundColor="?attr/colorSurfaceContainerHigh" + app:cardCornerRadius="16dp" + app:cardElevation="2dp" + app:strokeColor="?attr/colorOutlineVariant" + app:strokeWidth="1dp"> - + android:orientation="vertical" + android:paddingStart="12dp" + android:paddingTop="10dp" + android:paddingEnd="12dp" + android:paddingBottom="10dp"> + + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 5212019..d6ecd21 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -123,5 +123,5 @@ Start timer for player %1$d Player %1$d remaining time: %2$s Choose 1st & Start - Set players, life, commander damage, and choose first + Game settings, timer and finger chooser diff --git a/app/src/test/java/com/nicue/onetwo/ui/life/MtgLifeFragmentTest.java b/app/src/test/java/com/nicue/onetwo/ui/life/MtgLifeFragmentTest.java index 30b7120..44b9e1a 100644 --- a/app/src/test/java/com/nicue/onetwo/ui/life/MtgLifeFragmentTest.java +++ b/app/src/test/java/com/nicue/onetwo/ui/life/MtgLifeFragmentTest.java @@ -20,6 +20,8 @@ import androidx.test.core.app.ActivityScenario; import com.nicue.onetwo.MainActivity; import com.nicue.onetwo.R; +import com.nicue.onetwo.data.settings.SettingsPrefsDataSource; +import com.nicue.onetwo.data.settings.SettingsRepository; import com.nicue.onetwo.databinding.LifePlayerCellBinding; import java.util.concurrent.TimeUnit; import org.junit.Rule; @@ -834,6 +836,52 @@ public void testChooseAndStartButtonNavigatesToChooser() { } } + @Test + public void testCoachMarkShowsFromActivityToolbar() { + try (ActivityScenario scenario = + ActivityScenario.launch(MainActivity.class)) { + scenario.onActivity( + activity -> { + SettingsRepository settingsRepository = + new SettingsRepository(new SettingsPrefsDataSource(activity)); + settingsRepository.setMtgSetupCoachMarkDismissed(false); + activity.invalidateOptionsMenu(); + Shadows.shadowOf(Looper.getMainLooper()).idle(); + + Fragment fragment = + activity.getSupportFragmentManager() + .findFragmentById(R.id.nav_host_fragment); + assertNotNull(fragment); + + Fragment currentFragment = + fragment.getChildFragmentManager().getFragments().get(0); + assertTrue(currentFragment instanceof MtgLifeFragment); + MtgLifeFragment mtgLifeFragment = (MtgLifeFragment) currentFragment; + + try { + java.lang.reflect.Method showCoachMarkIfNecessaryMethod = + MtgLifeFragment.class.getDeclaredMethod( + "showCoachMarkIfNecessary"); + showCoachMarkIfNecessaryMethod.setAccessible(true); + showCoachMarkIfNecessaryMethod.invoke(mtgLifeFragment); + + Shadows.shadowOf(Looper.getMainLooper()).idle(); + + java.lang.reflect.Field coachMarkField = + MtgLifeFragment.class.getDeclaredField("coachMarkPopup"); + coachMarkField.setAccessible(true); + android.widget.PopupWindow popup = + (android.widget.PopupWindow) coachMarkField.get(mtgLifeFragment); + + assertNotNull(popup); + assertTrue(popup.isShowing()); + } catch (ReflectiveOperationException e) { + throw new AssertionError(e); + } + }); + } + } + private static View findViewWithContentDescription(View root, CharSequence contentDescription) { if (root == null) { return null; From 4ee96781bda4a82de054d2aae5087917c9b1b25e Mon Sep 17 00:00:00 2001 From: NICOLAS LEYTON Date: Tue, 2 Jun 2026 00:13:31 -0400 Subject: [PATCH 08/14] Add coach mark translations --- .../main/java/com/nicue/onetwo/ui/life/MtgLifeFragment.java | 6 +++--- app/src/main/res/values-es/strings.xml | 1 + app/src/main/res/values-fr/strings.xml | 1 + app/src/main/res/values-ja/strings.xml | 1 + .../java/com/nicue/onetwo/ui/life/MtgLifeFragmentTest.java | 3 ++- 5 files changed, 8 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/com/nicue/onetwo/ui/life/MtgLifeFragment.java b/app/src/main/java/com/nicue/onetwo/ui/life/MtgLifeFragment.java index 88ec128..7fd5dbd 100644 --- a/app/src/main/java/com/nicue/onetwo/ui/life/MtgLifeFragment.java +++ b/app/src/main/java/com/nicue/onetwo/ui/life/MtgLifeFragment.java @@ -275,14 +275,14 @@ private View findViewWithContentDescription(View root, CharSequence contentDescr return null; } CharSequence rootDescription = root.getContentDescription(); - if (rootDescription != null && rootDescription.toString().contentEquals(contentDescription)) { + if (rootDescription != null + && rootDescription.toString().contentEquals(contentDescription)) { return root; } if (root instanceof ViewGroup rootGroup) { for (int i = 0; i < rootGroup.getChildCount(); i++) { View match = - findViewWithContentDescription( - rootGroup.getChildAt(i), contentDescription); + findViewWithContentDescription(rootGroup.getChildAt(i), contentDescription); if (match != null) { return match; } diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 6cdd2ff..9cdc595 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -113,4 +113,5 @@ Iniciar temporizador para el jugador %1$d Tiempo restante del jugador %1$d: %2$s Elegir 1º y empezar + Configuración de partida, temporizador y selector de dedos diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index fadd783..cc1e1f4 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -67,5 +67,6 @@ Passer le tour du joueur %1$d au joueur suivant Démarrer le minuteur pour le joueur %1$d Temps restant du joueur %1$d : %2$s + Paramètres de partie, minuteur et sélecteur tactile diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index b9018f6..ceed92f 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -67,5 +67,6 @@ プレイヤー%1$dのターンを次のプレイヤーに渡す プレイヤー%1$dのタイマーを開始する プレイヤー%1$dの残り時間: %2$s + ゲーム設定、タイマー、指で選ぶ機能 diff --git a/app/src/test/java/com/nicue/onetwo/ui/life/MtgLifeFragmentTest.java b/app/src/test/java/com/nicue/onetwo/ui/life/MtgLifeFragmentTest.java index 44b9e1a..8e81954 100644 --- a/app/src/test/java/com/nicue/onetwo/ui/life/MtgLifeFragmentTest.java +++ b/app/src/test/java/com/nicue/onetwo/ui/life/MtgLifeFragmentTest.java @@ -871,7 +871,8 @@ public void testCoachMarkShowsFromActivityToolbar() { MtgLifeFragment.class.getDeclaredField("coachMarkPopup"); coachMarkField.setAccessible(true); android.widget.PopupWindow popup = - (android.widget.PopupWindow) coachMarkField.get(mtgLifeFragment); + (android.widget.PopupWindow) + coachMarkField.get(mtgLifeFragment); assertNotNull(popup); assertTrue(popup.isShowing()); From 8af6021fb8917a45f29fe0b38382071f9f78d088 Mon Sep 17 00:00:00 2001 From: NICOLAS LEYTON Date: Tue, 2 Jun 2026 00:47:50 -0400 Subject: [PATCH 09/14] Use filled add icon for new game action --- app/src/main/res/drawable/ic_add_circle_24.xml | 10 ++++++++++ app/src/main/res/menu/life_actions.xml | 2 +- 2 files changed, 11 insertions(+), 1 deletion(-) create mode 100644 app/src/main/res/drawable/ic_add_circle_24.xml diff --git a/app/src/main/res/drawable/ic_add_circle_24.xml b/app/src/main/res/drawable/ic_add_circle_24.xml new file mode 100644 index 0000000..a4911cc --- /dev/null +++ b/app/src/main/res/drawable/ic_add_circle_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/menu/life_actions.xml b/app/src/main/res/menu/life_actions.xml index 5c81161..54811da 100644 --- a/app/src/main/res/menu/life_actions.xml +++ b/app/src/main/res/menu/life_actions.xml @@ -4,7 +4,7 @@ From 48170b49a7a1bbbeb0dc277793edf4d4fd6d5f5c Mon Sep 17 00:00:00 2001 From: NICOLAS LEYTON Date: Tue, 2 Jun 2026 23:44:05 -0400 Subject: [PATCH 10/14] fix: use MenuItemCompat to retrieve content description for toolbar menu items --- .../main/java/com/nicue/onetwo/ui/life/MtgLifeFragment.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/nicue/onetwo/ui/life/MtgLifeFragment.java b/app/src/main/java/com/nicue/onetwo/ui/life/MtgLifeFragment.java index 7fd5dbd..2b200af 100644 --- a/app/src/main/java/com/nicue/onetwo/ui/life/MtgLifeFragment.java +++ b/app/src/main/java/com/nicue/onetwo/ui/life/MtgLifeFragment.java @@ -32,6 +32,7 @@ import androidx.core.content.ContextCompat; import androidx.core.graphics.ColorUtils; import androidx.core.view.MenuHost; +import androidx.core.view.MenuItemCompat; import androidx.core.view.MenuProvider; import androidx.core.view.ViewCompat; import androidx.core.widget.TextViewCompat; @@ -261,7 +262,7 @@ private View findCoachMarkAnchor() { } View contentDescriptionMatch = - findViewWithContentDescription(toolbar, newGameItem.getContentDescription()); + findViewWithContentDescription(toolbar, MenuItemCompat.getContentDescription(newGameItem)); if (contentDescriptionMatch != null) { return contentDescriptionMatch; } From e4b4f037ba364367b52b73b4d8c969d72ff16c97 Mon Sep 17 00:00:00 2001 From: NICOLAS LEYTON Date: Tue, 2 Jun 2026 23:45:42 -0400 Subject: [PATCH 11/14] style: refactor formatting of content description lookup in MtgLifeFragment --- .../main/java/com/nicue/onetwo/ui/life/MtgLifeFragment.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/nicue/onetwo/ui/life/MtgLifeFragment.java b/app/src/main/java/com/nicue/onetwo/ui/life/MtgLifeFragment.java index 2b200af..4245b35 100644 --- a/app/src/main/java/com/nicue/onetwo/ui/life/MtgLifeFragment.java +++ b/app/src/main/java/com/nicue/onetwo/ui/life/MtgLifeFragment.java @@ -262,7 +262,8 @@ private View findCoachMarkAnchor() { } View contentDescriptionMatch = - findViewWithContentDescription(toolbar, MenuItemCompat.getContentDescription(newGameItem)); + findViewWithContentDescription( + toolbar, MenuItemCompat.getContentDescription(newGameItem)); if (contentDescriptionMatch != null) { return contentDescriptionMatch; } From 3b79d5c51da7e35c9ee24bc873522831f280236b Mon Sep 17 00:00:00 2001 From: NICOLAS LEYTON Date: Wed, 3 Jun 2026 00:59:34 -0400 Subject: [PATCH 12/14] feat: adjust coach mark position and add inset to add circle icon --- .../java/com/nicue/onetwo/ui/life/MtgLifeFragment.java | 2 +- app/src/main/res/drawable/ic_add_circle_24.xml | 10 +++++++--- app/src/main/res/drawable/ic_add_circle_inset.xml | 7 +++++++ app/src/main/res/layout/mtg_coach_mark.xml | 3 +-- 4 files changed, 16 insertions(+), 6 deletions(-) create mode 100644 app/src/main/res/drawable/ic_add_circle_inset.xml diff --git a/app/src/main/java/com/nicue/onetwo/ui/life/MtgLifeFragment.java b/app/src/main/java/com/nicue/onetwo/ui/life/MtgLifeFragment.java index 4245b35..8fa9d57 100644 --- a/app/src/main/java/com/nicue/onetwo/ui/life/MtgLifeFragment.java +++ b/app/src/main/java/com/nicue/onetwo/ui/life/MtgLifeFragment.java @@ -333,7 +333,7 @@ private void showCoachMark(View anchor) { } int anchorCenterX = getCoachMarkAnchorCenterX(anchor); int xOffset = anchorCenterX - pointerCenterX; - coachMarkPopup.showAsDropDown(anchor, xOffset, dpToPx(2)); + coachMarkPopup.showAsDropDown(anchor, xOffset + dpToPx(14), dpToPx(10)); } private int getCoachMarkAnchorCenterX(View anchor) { diff --git a/app/src/main/res/drawable/ic_add_circle_24.xml b/app/src/main/res/drawable/ic_add_circle_24.xml index a4911cc..32b8260 100644 --- a/app/src/main/res/drawable/ic_add_circle_24.xml +++ b/app/src/main/res/drawable/ic_add_circle_24.xml @@ -4,7 +4,11 @@ android:viewportWidth="24" android:viewportHeight="24" android:tint="?attr/colorControlNormal"> - + + + diff --git a/app/src/main/res/drawable/ic_add_circle_inset.xml b/app/src/main/res/drawable/ic_add_circle_inset.xml new file mode 100644 index 0000000..b340bc0 --- /dev/null +++ b/app/src/main/res/drawable/ic_add_circle_inset.xml @@ -0,0 +1,7 @@ + + diff --git a/app/src/main/res/layout/mtg_coach_mark.xml b/app/src/main/res/layout/mtg_coach_mark.xml index a03acec..c74f462 100644 --- a/app/src/main/res/layout/mtg_coach_mark.xml +++ b/app/src/main/res/layout/mtg_coach_mark.xml @@ -27,8 +27,7 @@ app:cardBackgroundColor="?attr/colorSurfaceContainerHigh" app:cardCornerRadius="16dp" app:cardElevation="2dp" - app:strokeColor="?attr/colorOutlineVariant" - app:strokeWidth="1dp"> + app:strokeWidth="0dp"> Date: Wed, 3 Jun 2026 01:02:27 -0400 Subject: [PATCH 13/14] chore: remove artifact --- pr_diff.txt | 416 ---------------------------------------------------- 1 file changed, 416 deletions(-) delete mode 100644 pr_diff.txt diff --git a/pr_diff.txt b/pr_diff.txt deleted file mode 100644 index 1da7d33..0000000 --- a/pr_diff.txt +++ /dev/null @@ -1,416 +0,0 @@ -diff --git a/app/src/main/java/com/nicue/onetwo/data/settings/SettingsPrefsDataSource.java b/app/src/main/java/com/nicue/onetwo/data/settings/SettingsPrefsDataSource.java -index 68c3b5c..33e6682 100644 ---- a/app/src/main/java/com/nicue/onetwo/data/settings/SettingsPrefsDataSource.java -+++ b/app/src/main/java/com/nicue/onetwo/data/settings/SettingsPrefsDataSource.java -@@ -9,6 +9,8 @@ public class SettingsPrefsDataSource { - private static final String KEY_DARK_MODE = "dark_mode"; - private static final String KEY_CHOOSER_ORDER = "chooser_order"; - private static final String KEY_LIFE_COUNTER_HAPTIC_FEEDBACK = "life_counter_haptic_feedback"; -+ private static final String KEY_MTG_SETUP_COACH_MARK_DISMISSED = -+ "mtg_setup_coach_mark_dismissed"; - private final SharedPreferences sharedPreferences; - - public SettingsPrefsDataSource(Context context) { -@@ -46,4 +48,12 @@ public class SettingsPrefsDataSource { - public void setLifeCounterHapticFeedbackEnabled(boolean enabled) { - sharedPreferences.edit().putBoolean(KEY_LIFE_COUNTER_HAPTIC_FEEDBACK, enabled).apply(); - } -+ -+ public boolean isMtgSetupCoachMarkDismissed() { -+ return sharedPreferences.getBoolean(KEY_MTG_SETUP_COACH_MARK_DISMISSED, false); -+ } -+ -+ public void setMtgSetupCoachMarkDismissed(boolean dismissed) { -+ sharedPreferences.edit().putBoolean(KEY_MTG_SETUP_COACH_MARK_DISMISSED, dismissed).apply(); -+ } - } -diff --git a/app/src/main/java/com/nicue/onetwo/data/settings/SettingsRepository.java b/app/src/main/java/com/nicue/onetwo/data/settings/SettingsRepository.java -index 3cff99f..81c6c1f 100644 ---- a/app/src/main/java/com/nicue/onetwo/data/settings/SettingsRepository.java -+++ b/app/src/main/java/com/nicue/onetwo/data/settings/SettingsRepository.java -@@ -43,6 +43,14 @@ public class SettingsRepository { - dataSource.setLifeCounterHapticFeedbackEnabled(enabled); - } - -+ public boolean isMtgSetupCoachMarkDismissed() { -+ return dataSource.isMtgSetupCoachMarkDismissed(); -+ } -+ -+ public void setMtgSetupCoachMarkDismissed(boolean dismissed) { -+ dataSource.setMtgSetupCoachMarkDismissed(dismissed); -+ } -+ - public void applyNightMode() { - boolean darkModeEnabled = isDarkModeEnabled(); - AppCompatDelegate.setDefaultNightMode( -diff --git a/app/src/main/java/com/nicue/onetwo/ui/life/MtgLifeFragment.java b/app/src/main/java/com/nicue/onetwo/ui/life/MtgLifeFragment.java -index 588f9f4..8fc0eb6 100644 ---- a/app/src/main/java/com/nicue/onetwo/ui/life/MtgLifeFragment.java -+++ b/app/src/main/java/com/nicue/onetwo/ui/life/MtgLifeFragment.java -@@ -25,6 +25,7 @@ import android.view.ViewGroup; - import android.widget.EditText; - import android.widget.FrameLayout; - import android.widget.LinearLayout; -+import android.widget.PopupWindow; - import android.widget.TextView; - import androidx.annotation.NonNull; - import androidx.annotation.Nullable; -@@ -68,6 +69,7 @@ public class MtgLifeFragment extends Fragment implements MenuProvider { - private final Map activeDialogDamageTextViews = new HashMap<>(); - private final Handler holdRepeatHandler = new Handler(Looper.getMainLooper()); - private final Map activeLifeHoldRunnables = new HashMap<>(); -+ private PopupWindow coachMarkPopup; - - private static final class CommanderGridLayout { - private final int rows; -@@ -210,6 +212,67 @@ public class MtgLifeFragment extends Fragment implements MenuProvider { - newGameItem.setVisible(!currentUiState.isShowingSetup()); - } - } -+ showCoachMarkIfNecessary(); -+ } -+ -+ private void showCoachMarkIfNecessary() { -+ if (viewModel == null || viewModel.isSetupCoachMarkDismissed()) { -+ return; -+ } -+ MtgLifeUiState state = viewModel.getUiState().getValue(); -+ if (state != null && state.isShowingSetup()) { -+ return; -+ } -+ -+ View view = getView(); -+ if (view == null) return; -+ view.post( -+ () -> { -+ if (getActivity() == null) return; -+ View anchor = requireActivity().findViewById(R.id.action_new_game); -+ if (anchor != null -+ && anchor.isAttachedToWindow() -+ && anchor.getVisibility() == View.VISIBLE) { -+ if (viewModel.isSetupCoachMarkDismissed()) return; -+ showCoachMark(anchor); -+ } -+ }); -+ } -+ -+ private void showCoachMark(View anchor) { -+ if (coachMarkPopup != null) { -+ coachMarkPopup.setOnDismissListener(null); -+ coachMarkPopup.dismiss(); -+ } -+ View popupView = -+ LayoutInflater.from(requireContext()).inflate(R.layout.mtg_coach_mark, null); -+ coachMarkPopup = -+ new PopupWindow( -+ popupView, -+ ViewGroup.LayoutParams.WRAP_CONTENT, -+ ViewGroup.LayoutParams.WRAP_CONTENT, -+ true); -+ coachMarkPopup.setOutsideTouchable(true); -+ coachMarkPopup.setElevation(dpToPx(8)); -+ coachMarkPopup.setBackgroundDrawable(new ColorDrawable(Color.TRANSPARENT)); -+ -+ popupView.setOnClickListener( -+ v -> { -+ if (coachMarkPopup != null) { -+ coachMarkPopup.dismiss(); -+ } -+ anchor.performClick(); -+ }); -+ -+ coachMarkPopup.setOnDismissListener( -+ () -> { -+ viewModel.markSetupCoachMarkDismissed(); -+ coachMarkPopup = null; -+ }); -+ -+ popupView.measure(View.MeasureSpec.UNSPECIFIED, View.MeasureSpec.UNSPECIFIED); -+ int xOffset = anchor.getWidth() - popupView.getMeasuredWidth(); -+ coachMarkPopup.showAsDropDown(anchor, xOffset, dpToPx(4)); - } - - @Override -@@ -1302,6 +1365,11 @@ public class MtgLifeFragment extends Fragment implements MenuProvider { - @Override - public void onDestroyView() { - super.onDestroyView(); -+ if (coachMarkPopup != null) { -+ coachMarkPopup.setOnDismissListener(null); -+ coachMarkPopup.dismiss(); -+ coachMarkPopup = null; -+ } - for (Runnable repeatRunnable : activeLifeHoldRunnables.values()) { - holdRepeatHandler.removeCallbacks(repeatRunnable); - } -diff --git a/app/src/main/java/com/nicue/onetwo/ui/life/MtgLifeViewModel.java b/app/src/main/java/com/nicue/onetwo/ui/life/MtgLifeViewModel.java -index bfbf1d5..bca8172 100644 ---- a/app/src/main/java/com/nicue/onetwo/ui/life/MtgLifeViewModel.java -+++ b/app/src/main/java/com/nicue/onetwo/ui/life/MtgLifeViewModel.java -@@ -358,6 +358,14 @@ public class MtgLifeViewModel extends ViewModel { - updateUiState(); - } - -+ public boolean isSetupCoachMarkDismissed() { -+ return settingsRepository.isMtgSetupCoachMarkDismissed(); -+ } -+ -+ public void markSetupCoachMarkDismissed() { -+ settingsRepository.setMtgSetupCoachMarkDismissed(true); -+ } -+ - public void resetToSetup() { - pauseTimer(); - savedStateHandle.set(KEY_SHOWING_SETUP, true); -diff --git a/app/src/main/res/drawable/ic_add_circle_outline_24.xml b/app/src/main/res/drawable/ic_add_circle_outline_24.xml -new file mode 100644 -index 0000000..e3bcb33 ---- /dev/null -+++ b/app/src/main/res/drawable/ic_add_circle_outline_24.xml -@@ -0,0 +1,10 @@ -+ -+ -+ -diff --git a/app/src/main/res/layout/mtg_coach_mark.xml b/app/src/main/res/layout/mtg_coach_mark.xml -new file mode 100644 -index 0000000..ecfedee ---- /dev/null -+++ b/app/src/main/res/layout/mtg_coach_mark.xml -@@ -0,0 +1,25 @@ -+ -+ -+ -+ -+ -+ -+ -+ -diff --git a/app/src/main/res/menu/life_actions.xml b/app/src/main/res/menu/life_actions.xml -index a67fb16..5c81161 100644 ---- a/app/src/main/res/menu/life_actions.xml -+++ b/app/src/main/res/menu/life_actions.xml -@@ -4,5 +4,7 @@ - -+ android:icon="@drawable/ic_add_circle_outline_24" -+ android:contentDescription="@string/mtg_setup_title" -+ app:showAsAction="always" /> - -diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml -index a463df0..5212019 100644 ---- a/app/src/main/res/values/strings.xml -+++ b/app/src/main/res/values/strings.xml -@@ -123,4 +123,5 @@ - Start timer for player %1$d - Player %1$d remaining time: %2$s - Choose 1st & Start -+ Set players, life, commander damage, and choose first - -diff --git a/app/src/test/java/com/nicue/onetwo/data/settings/SettingsRepositoryTest.java b/app/src/test/java/com/nicue/onetwo/data/settings/SettingsRepositoryTest.java -index e23d0e7..cbdab0f 100644 ---- a/app/src/test/java/com/nicue/onetwo/data/settings/SettingsRepositoryTest.java -+++ b/app/src/test/java/com/nicue/onetwo/data/settings/SettingsRepositoryTest.java -@@ -43,4 +43,24 @@ public class SettingsRepositoryTest { - new SettingsRepository(new SettingsPrefsDataSource(context)); - assertFalse(reloadedRepository.isLifeCounterHapticFeedbackEnabled()); - } -+ -+ @Test -+ public void mtgSetupCoachMarkDefaultsToNotDismissed() { -+ SettingsRepository repository = -+ new SettingsRepository(new SettingsPrefsDataSource(context)); -+ -+ assertFalse(repository.isMtgSetupCoachMarkDismissed()); -+ } -+ -+ @Test -+ public void setMtgSetupCoachMarkDismissedPersistsValue() { -+ SettingsRepository repository = -+ new SettingsRepository(new SettingsPrefsDataSource(context)); -+ -+ repository.setMtgSetupCoachMarkDismissed(true); -+ -+ SettingsRepository reloadedRepository = -+ new SettingsRepository(new SettingsPrefsDataSource(context)); -+ assertTrue(reloadedRepository.isMtgSetupCoachMarkDismissed()); -+ } - } -diff --git a/app/src/test/java/com/nicue/onetwo/ui/life/MtgLifeFragmentTest.java b/app/src/test/java/com/nicue/onetwo/ui/life/MtgLifeFragmentTest.java -index 168c53e..4097334 100644 ---- a/app/src/test/java/com/nicue/onetwo/ui/life/MtgLifeFragmentTest.java -+++ b/app/src/test/java/com/nicue/onetwo/ui/life/MtgLifeFragmentTest.java -@@ -909,4 +909,104 @@ public class MtgLifeFragmentTest { - defenderPlayerNumber)); - assertNotNull(incrementZone); - } -+ -+ @Test -+ public void testCoachMarkDismissedOnDestroyView() { -+ try (FragmentScenario scenario = -+ FragmentScenario.launchInContainer(MtgLifeFragment.class, null, R.style.AppTheme)) { -+ scenario.onFragment( -+ fragment -> { -+ View anchor = new View(fragment.requireContext()); -+ try { -+ java.lang.reflect.Method showCoachMarkMethod = -+ MtgLifeFragment.class.getDeclaredMethod( -+ "showCoachMark", View.class); -+ showCoachMarkMethod.setAccessible(true); -+ showCoachMarkMethod.invoke(fragment, anchor); -+ -+ java.lang.reflect.Field coachMarkField = -+ MtgLifeFragment.class.getDeclaredField("coachMarkPopup"); -+ coachMarkField.setAccessible(true); -+ android.widget.PopupWindow popup = -+ (android.widget.PopupWindow) coachMarkField.get(fragment); -+ -+ assertNotNull(popup); -+ assertTrue(popup.isShowing()); -+ -+ fragment.onDestroyView(); -+ -+ org.junit.Assert.assertNull(coachMarkField.get(fragment)); -+ org.junit.Assert.assertFalse(popup.isShowing()); -+ -+ java.lang.reflect.Field viewModelField = -+ MtgLifeFragment.class.getDeclaredField("viewModel"); -+ viewModelField.setAccessible(true); -+ MtgLifeViewModel viewModel = -+ (MtgLifeViewModel) viewModelField.get(fragment); -+ org.junit.Assert.assertFalse(viewModel.isSetupCoachMarkDismissed()); -+ } catch (ReflectiveOperationException e) { -+ throw new AssertionError(e); -+ } -+ }); -+ } -+ } -+ -+ @Test -+ public void testCoachMarkAccessibilityAndInteraction() { -+ try (FragmentScenario scenario = -+ FragmentScenario.launchInContainer(MtgLifeFragment.class, null, R.style.AppTheme)) { -+ scenario.onFragment( -+ fragment -> { -+ View anchor = new View(fragment.requireContext()); -+ boolean[] anchorClicked = {false}; -+ anchor.setOnClickListener(v -> anchorClicked[0] = true); -+ -+ try { -+ java.lang.reflect.Method showCoachMarkMethod = -+ MtgLifeFragment.class.getDeclaredMethod( -+ "showCoachMark", View.class); -+ showCoachMarkMethod.setAccessible(true); -+ showCoachMarkMethod.invoke(fragment, anchor); -+ -+ java.lang.reflect.Field coachMarkField = -+ MtgLifeFragment.class.getDeclaredField("coachMarkPopup"); -+ coachMarkField.setAccessible(true); -+ android.widget.PopupWindow popup = -+ (android.widget.PopupWindow) coachMarkField.get(fragment); -+ -+ assertNotNull(popup); -+ assertTrue(popup.isShowing()); -+ assertTrue(popup.isOutsideTouchable()); // Accessibility: can be -+ // dismissed by touching outside -+ -+ View popupContentView = popup.getContentView(); -+ assertNotNull(popupContentView); -+ -+ // Simulate click on the coach mark -+ popupContentView.performClick(); -+ -+ // Verify anchor was clicked as a result -+ assertTrue(anchorClicked[0]); -+ -+ // Verify popup was dismissed -+ org.junit.Assert.assertFalse(popup.isShowing()); -+ -+ // Verify ViewModel received the dismiss signal -+ java.lang.reflect.Field viewModelField = -+ MtgLifeFragment.class.getDeclaredField("viewModel"); -+ viewModelField.setAccessible(true); -+ MtgLifeViewModel viewModel = -+ (MtgLifeViewModel) viewModelField.get(fragment); -+ assertTrue(viewModel.isSetupCoachMarkDismissed()); -+ -+ // Verify reference is cleared via dismiss listener -+ Shadows.shadowOf(Looper.getMainLooper()).idle(); -+ org.junit.Assert.assertNull(coachMarkField.get(fragment)); -+ -+ } catch (ReflectiveOperationException e) { -+ throw new AssertionError(e); -+ } -+ }); -+ } -+ } - } -diff --git a/app/src/test/java/com/nicue/onetwo/ui/life/MtgLifeViewModelTest.java b/app/src/test/java/com/nicue/onetwo/ui/life/MtgLifeViewModelTest.java -index 4857c0a..43d0d62 100644 ---- a/app/src/test/java/com/nicue/onetwo/ui/life/MtgLifeViewModelTest.java -+++ b/app/src/test/java/com/nicue/onetwo/ui/life/MtgLifeViewModelTest.java -@@ -42,6 +42,7 @@ public class MtgLifeViewModelTest { - private static final class FakeSettingsRepository - extends com.nicue.onetwo.data.settings.SettingsRepository { - private boolean hapticEnabled = true; -+ private boolean setupCoachMarkDismissed = false; - - FakeSettingsRepository() { - super(null); -@@ -55,6 +56,16 @@ public class MtgLifeViewModelTest { - public void setHapticEnabled(boolean enabled) { - this.hapticEnabled = enabled; - } -+ -+ @Override -+ public boolean isMtgSetupCoachMarkDismissed() { -+ return setupCoachMarkDismissed; -+ } -+ -+ @Override -+ public void setMtgSetupCoachMarkDismissed(boolean dismissed) { -+ this.setupCoachMarkDismissed = dismissed; -+ } - } - - @Before -@@ -761,4 +772,14 @@ public class MtgLifeViewModelTest { - settingsRepository.setHapticEnabled(false); - assertFalse(viewModel.isLifeCounterHapticFeedbackEnabled()); - } -+ -+ @Test -+ public void testSetupCoachMarkDismissalDelegation() { -+ settingsRepository.setMtgSetupCoachMarkDismissed(false); -+ assertFalse(viewModel.isSetupCoachMarkDismissed()); -+ -+ viewModel.markSetupCoachMarkDismissed(); -+ assertTrue(settingsRepository.isMtgSetupCoachMarkDismissed()); -+ assertTrue(viewModel.isSetupCoachMarkDismissed()); -+ } - } From 5ca9ba6c4192af4bf27d2c593d9be3ef6c9a8f7b Mon Sep 17 00:00:00 2001 From: NICOLAS LEYTON Date: Wed, 3 Jun 2026 01:03:26 -0400 Subject: [PATCH 14/14] chore: bump version --- app/build.gradle | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index 2594a51..1dd0acf 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -14,8 +14,8 @@ android { applicationId "com.nicue.onetwo" minSdkVersion 21 targetSdkVersion 35 - versionCode 40 - versionName "2.1.3" + versionCode 41 + versionName "2.1.4" testInstrumentationRunner 'androidx.test.runner.AndroidJUnitRunner' } buildTypes {