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 { 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 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..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 @@ -25,17 +25,21 @@ 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; 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; 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; @@ -68,6 +72,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 +215,138 @@ 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 = findCoachMarkAnchor(); + if (anchor != null + && anchor.isAttachedToWindow() + && anchor.getVisibility() == View.VISIBLE) { + if (viewModel.isSetupCoachMarkDismissed()) return; + showCoachMark(anchor); + } + }); + } + + 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, MenuItemCompat.getContentDescription(newGameItem)); + 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); + 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); + 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 anchorCenterX = getCoachMarkAnchorCenterX(anchor); + int xOffset = anchorCenterX - pointerCenterX; + coachMarkPopup.showAsDropDown(anchor, xOffset + dpToPx(14), dpToPx(10)); + } + + 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 @@ -1302,6 +1439,11 @@ private void vibrate(long milliseconds) { @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 @@ 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/bg_mtg_coach_mark_pointer.xml b/app/src/main/res/drawable/bg_mtg_coach_mark_pointer.xml new file mode 100644 index 0000000..28c5cda --- /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/drawable/ic_add_circle_24.xml b/app/src/main/res/drawable/ic_add_circle_24.xml new file mode 100644 index 0000000..32b8260 --- /dev/null +++ b/app/src/main/res/drawable/ic_add_circle_24.xml @@ -0,0 +1,14 @@ + + + + + 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/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..c74f462 --- /dev/null +++ b/app/src/main/res/layout/mtg_coach_mark.xml @@ -0,0 +1,59 @@ + + + + + + + + + + + + + + + diff --git a/app/src/main/res/menu/life_actions.xml b/app/src/main/res/menu/life_actions.xml index a67fb16..54811da 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_24" + android:contentDescription="@string/mtg_setup_title" + app:showAsAction="always" /> 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/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index a463df0..d6ecd21 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 + Game settings, timer and finger chooser 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/MtgLifeFragmentTest.java b/app/src/test/java/com/nicue/onetwo/ui/life/MtgLifeFragmentTest.java index 168c53e..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 @@ -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,53 @@ 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; @@ -909,4 +958,105 @@ 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()); + + 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); + assertNotNull(popupContentView.findViewById(R.id.coach_mark_pointer)); + + // 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 @@ 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()); + } }