diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 00000000..a3ee44e4 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "lib/vintage-chroma"] + path = lib/vintage-chroma + url = https://github.com/louis-hildebrand/VintageChroma.git diff --git a/app/build.gradle b/app/build.gradle index 1b5b2ae1..b3bceab3 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -6,7 +6,7 @@ android { } } compileSdkVersion 33 - buildToolsVersion '28.0.3' + buildToolsVersion '29.0.2' defaultConfig { applicationId "com.aricneto.twistytimer" minSdkVersion 16 @@ -56,10 +56,11 @@ dependencies { implementation 'androidx.percentlayout:percentlayout:1.0.0' implementation "androidx.annotation:annotation:1.1.0" implementation 'androidx.constraintlayout:constraintlayout:2.0.0-beta3' - implementation 'com.takisoft.fix:preference-v7:26.0.1.0' + implementation 'androidx.preference:preference:1.2.1' implementation 'com.android.support:multidex:1.0.3' // Butterknife ("apt" dependency is defined in root "build.gradle" script). - implementation 'com.jakewharton:butterknife:10.0.0' + implementation 'com.jakewharton:butterknife:10.2.3' + testImplementation project(':app') annotationProcessor 'com.jakewharton:butterknife-compiler:10.0.0' // Observable scrollview implementation 'com.github.ksoichiro:android-observablescrollview:1.5.2' @@ -87,13 +88,18 @@ dependencies { // Inapp Billing implementation 'com.anjlab.android.iab.v3:library:2.0.3' // Material color picker - implementation 'com.pavelsikun:vintage-chroma:1.5' + implementation project(':lib:vintage-chroma:library') - testImplementation 'junit:junit:4.12' + testImplementation 'junit:junit:4.13' } repositories { - jcenter() maven { url 'http://maven.google.com' } maven { url 'https://jitpack.io' } } + +tasks.withType(Test) { + testLogging { + events "passed", "skipped", "failed" + } +} diff --git a/app/src/androidTest/java/com/aricneto/twistify/ApplicationTest.java b/app/src/androidTest/java/com/aricneto/twistify/ApplicationTest.java index 2736dbc9..233610c5 100644 --- a/app/src/androidTest/java/com/aricneto/twistify/ApplicationTest.java +++ b/app/src/androidTest/java/com/aricneto/twistify/ApplicationTest.java @@ -1,13 +1,18 @@ package com.aricneto.twistify; import android.app.Application; -import android.test.ApplicationTestCase; +// TODO: compiler says android.test does not exist +// Adding dependency androidTestImplementation 'com.google.android:android-test:4.1.1.4' in build.gradle +// seems to solve that issue, but then causes others... +//import android.test.ApplicationTestCase; +// +///** +// * Testing Fundamentals +// */ +//public class ApplicationTest extends ApplicationTestCase { +// public ApplicationTest() { +// super(Application.class); +// } +//} -/** - * Testing Fundamentals - */ -public class ApplicationTest extends ApplicationTestCase { - public ApplicationTest() { - super(Application.class); - } -} \ No newline at end of file +public class ApplicationTest {} \ No newline at end of file diff --git a/app/src/main/java/com/aricneto/twistytimer/activity/MainActivity.java b/app/src/main/java/com/aricneto/twistytimer/activity/MainActivity.java index 889dd670..a1a5963e 100644 --- a/app/src/main/java/com/aricneto/twistytimer/activity/MainActivity.java +++ b/app/src/main/java/com/aricneto/twistytimer/activity/MainActivity.java @@ -116,6 +116,7 @@ public class MainActivity extends AppCompatActivity private static final int SETTINGS_ID = 5; private static final int TRAINER_OLL_ID = 14; private static final int TRAINER_PLL_ID = 15; + private static final int TRAINER_3STYLE_CORNERS_ID = 16; private static final int REQUEST_SETTING = 42; @@ -302,7 +303,13 @@ private void handleDrawer(Bundle savedInstanceState) { .withLevel(2) .withIcon(R.drawable.ic_pll_black_24dp) .withIconTintingEnabled(true) - .withIdentifier(TRAINER_PLL_ID)), + .withIdentifier(TRAINER_PLL_ID), + new SecondaryDrawerItem() + .withName(R.string.drawer_title_3style_corners) + .withLevel(2) + .withIcon(R.drawable.ic_3style_corners_black_24dp) + .withIconTintingEnabled(true) + .withIdentifier(TRAINER_3STYLE_CORNERS_ID)), new ExpandableDrawerItem() .withName(R.string.title_algorithms) @@ -418,6 +425,19 @@ public void run() { }); break; + case TRAINER_3STYLE_CORNERS_ID: + mDrawerToggle.runWhenIdle(new Runnable() { + @Override + public void run() { + fragmentManager + .beginTransaction() + .replace(R.id.main_activity_container, + TimerFragmentMain.newInstance(TrainerScrambler.TrainerSubset.THREE_STYLE_CORNERS.name(), "Normal", TimerFragment.TIMER_MODE_TRAINER, TrainerScrambler.TrainerSubset.THREE_STYLE_CORNERS), "fragment_main") + .commit(); + } + }); + break; + case OLL_ID: mDrawerToggle.runWhenIdle(new Runnable() { @Override diff --git a/app/src/main/java/com/aricneto/twistytimer/activity/SettingsActivity.java b/app/src/main/java/com/aricneto/twistytimer/activity/SettingsActivity.java index befc37db..36d9e588 100644 --- a/app/src/main/java/com/aricneto/twistytimer/activity/SettingsActivity.java +++ b/app/src/main/java/com/aricneto/twistytimer/activity/SettingsActivity.java @@ -33,13 +33,12 @@ import com.aricneto.twistytimer.fragment.dialog.CrossHintFaceSelectDialog; import com.aricneto.twistytimer.fragment.dialog.LocaleSelectDialog; import com.aricneto.twistytimer.listener.OnBackPressedInFragmentListener; +import com.aricneto.twistytimer.puzzle.CornerSticker; +import com.aricneto.twistytimer.puzzle.LetterScheme; import com.aricneto.twistytimer.utils.LocaleUtils; import com.aricneto.twistytimer.utils.Prefs; import com.aricneto.twistytimer.utils.ThemeUtils; -import com.takisoft.fix.support.v7.preference.PreferenceFragmentCompat; - -import java.lang.ref.PhantomReference; -import java.util.function.Function; +import androidx.preference.PreferenceFragmentCompat; import butterknife.BindView; import butterknife.ButterKnife; @@ -149,6 +148,8 @@ public boolean onPreferenceClick(androidx.preference.Preference preference) { R.string.pk_scramble_text_size, R.string.pk_advanced_timer_settings_enabled, R.string.pk_stat_trim_size, + R.string.pk_corner_letter_scheme, + R.string.pk_corner_buffer, R.string.pk_stat_acceptable_dnf_size, R.string.pk_timer_animation_duration)) { @@ -269,6 +270,12 @@ public void onStopTrackingTouch(SeekBar seekBar) { trimChangeListener.onProgressChanged(trimSeekBar, trimSeekBar.getProgress(), false); trimDialogView.show(); break; + case R.string.pk_corner_letter_scheme: + createLetterSchemeDialog(R.string.pk_corner_letter_scheme, R.string.corner_letter_scheme); + break; + case R.string.pk_corner_buffer: + createCornerStickerDialog(R.string.pk_corner_buffer, R.string.corner_buffer_title); + break; case R.string.pk_stat_acceptable_dnf_size: MaterialDialog dnfDialogView = createAverageSeekDialog(R.string.pk_stat_acceptable_dnf_size, 0, @@ -319,11 +326,12 @@ public void onStopTrackingTouch(SeekBar seekBar) { } }; - @Override public void onCreate(@Nullable Bundle savedInstanceState) { AppCompatDelegate.setCompatVectorFromResourcesEnabled(true); + super.onCreate(savedInstanceState); + this.onCreatePreferences(savedInstanceState, null); mContext = getContext(); @@ -331,10 +339,11 @@ public void onCreate(@Nullable Bundle savedInstanceState) { } @Override - public void onCreatePreferencesFix(Bundle bundle, String rootKey) { + public void onCreatePreferences(Bundle savedInstanceState, String rootKey) { setPreferencesFromResource(R.xml.prefs, rootKey); - int listenerPrefIds[] = {R.string.pk_inspection_time, + int listenerPrefIds[] = { + R.string.pk_inspection_time, R.string.pref_screen_title_timer_appearance_settings, R.string.pk_show_scramble_x_cross_hints, R.string.pk_locale, @@ -344,12 +353,18 @@ public void onCreatePreferencesFix(Bundle bundle, String rootKey) { R.string.pk_scramble_image_size, R.string.pk_advanced_timer_settings_enabled, R.string.pk_stat_trim_size, - R.string.pk_stat_acceptable_dnf_size, - R.string.pk_timer_animation_duration}; + // TODO: this seems to be missing + // R.string.pk_stat_acceptable_dnf_size, + R.string.pk_timer_animation_duration, + R.string.pk_corner_letter_scheme, + R.string.pk_corner_buffer + }; for (int prefId : listenerPrefIds) { - findPreference(getString(prefId)) - .setOnPreferenceClickListener(clickListener); + Preference pref = findPreference(getString(prefId)); + if (pref != null) { + pref.setOnPreferenceClickListener(clickListener); + } } mainScreen = getPreferenceScreen(); @@ -397,6 +412,53 @@ public boolean onBackPressedInFragment() { return false; } + private void createLetterSchemeDialog(final int prefKeyResID, @StringRes int title) { + ThemeUtils.roundAndShowDialog(mContext, new MaterialDialog.Builder(mContext) + .title(title) + .content(R.string.corner_letter_scheme_hint) + .input( + "", + Prefs.getString(prefKeyResID, LetterScheme.SPEFFZ_LETTERS), + (dialog, input) -> { + try { + String s = input.toString(); + new LetterScheme(s); + Prefs.edit().putString(prefKeyResID, s).apply(); + } catch (IllegalArgumentException e) { + // TODO: Show this without closing the popup? + Toast.makeText(getActivity(), R.string.invalid_letter_scheme, Toast.LENGTH_SHORT).show(); + } + }) + .positiveText(R.string.action_done) + .negativeText(R.string.action_cancel) + .neutralText(R.string.action_default) + .onNeutral((dialog, which) -> Prefs.edit().putString(prefKeyResID, LetterScheme.SPEFFZ_LETTERS).apply()) + .build()); + } + + private void createCornerStickerDialog(final int prefKeyResID, @StringRes int title) { + ThemeUtils.roundAndShowDialog(mContext, new MaterialDialog.Builder(mContext) + .title(title) + .input( + "", + Prefs.getString(prefKeyResID, getString(R.string.default_corner_buffer)), + (dialog, input) -> { + try { + String s = input.toString(); + CornerSticker.parse(s); + Prefs.edit().putString(prefKeyResID, s).apply(); + } catch (IllegalArgumentException e) { + // TODO: Show this without closing the popup? + Toast.makeText(getActivity(), R.string.invalid_sticker, Toast.LENGTH_SHORT).show(); + } + }) + .positiveText(R.string.action_done) + .negativeText(R.string.action_cancel) + .neutralText(R.string.action_default) + .onNeutral((dialog, which) -> Prefs.edit().putString(prefKeyResID, getString(R.string.default_corner_buffer)).apply()) + .build()); + } + private void createNumberDialog(@StringRes int title, final int prefKeyResID) { ThemeUtils.roundAndShowDialog(mContext, new MaterialDialog.Builder(mContext) .title(title) diff --git a/app/src/main/java/com/aricneto/twistytimer/adapter/TrainerCursorAdapter.java b/app/src/main/java/com/aricneto/twistytimer/adapter/TrainerCursorAdapter.java index 6162eaa3..af261991 100644 --- a/app/src/main/java/com/aricneto/twistytimer/adapter/TrainerCursorAdapter.java +++ b/app/src/main/java/com/aricneto/twistytimer/adapter/TrainerCursorAdapter.java @@ -18,7 +18,6 @@ import java.util.Locale; import androidx.cardview.widget.CardView; -import androidx.core.content.ContextCompat; import androidx.fragment.app.Fragment; import androidx.fragment.app.FragmentManager; @@ -51,7 +50,7 @@ public TrainerCursorAdapter(Context context, Cursor cursor, Fragment listFragmen Color.BLACK, 14, 2); selectedItems = new ArrayList<>(); - selectedItems.addAll(TrainerScrambler.fetchSelectedItems(subset, category)); + selectedItems.addAll(TrainerScrambler.fetchCaseSelection(subset, category)); this.currentSubset = subset; this.currentPuzzleCategory = category; @@ -62,11 +61,6 @@ private boolean isSelected(String name) { return selectedItems.contains(name); } - public void unselectAll() { - selectedItems.clear(); - TrainerScrambler.saveSelectedItems(currentSubset, currentPuzzleCategory, selectedItems); - } - public void selectAll() { int size = selectedItems.size(); Log.d("TRAINER","selecteditems: " + size); @@ -84,8 +78,10 @@ public void selectAll() { selectedItems.addAll(Arrays.asList(pll_cases)); } break; + case THREE_STYLE_CORNERS: + throw new IllegalArgumentException("TrainerCursorAdapter should not be used for 3-style corners."); } - TrainerScrambler.saveSelectedItems(currentSubset, currentPuzzleCategory, selectedItems); + TrainerScrambler.saveCaseSelection(currentSubset, currentPuzzleCategory, selectedItems); } private void toggleSelection(String name, CardView card) { @@ -96,7 +92,7 @@ private void toggleSelection(String name, CardView card) { selectedItems.remove(name); card.setBackground(cardBackground); } - TrainerScrambler.saveSelectedItems(currentSubset, currentPuzzleCategory, selectedItems); + TrainerScrambler.saveCaseSelection(currentSubset, currentPuzzleCategory, selectedItems); } @Override diff --git a/app/src/main/java/com/aricneto/twistytimer/fragment/TimerFragment.java b/app/src/main/java/com/aricneto/twistytimer/fragment/TimerFragment.java index 2431542e..273f049e 100644 --- a/app/src/main/java/com/aricneto/twistytimer/fragment/TimerFragment.java +++ b/app/src/main/java/com/aricneto/twistytimer/fragment/TimerFragment.java @@ -60,6 +60,7 @@ import com.aricneto.twistytimer.items.Solve; import com.aricneto.twistytimer.layout.ChronometerMilli; import com.aricneto.twistytimer.listener.OnBackPressedInFragmentListener; +import com.aricneto.twistytimer.puzzle.TrainerCase; import com.aricneto.twistytimer.puzzle.TrainerScrambler; import com.aricneto.twistytimer.solver.RubiksCubeOptimalCross; import com.aricneto.twistytimer.solver.RubiksCubeOptimalXCross; @@ -133,6 +134,8 @@ public class private static final String TRAINER_SUBSET = "trainer_subset"; private static final String TIMER_MODE = "timer_mode"; private static final String SCRAMBLE = "scramble"; + private static final String IS_SCRAMBLE_VALID = "is_scramble_valid"; + private static final String TRAINER_CASE_NAME = "trainer_case_name"; private static final String HAS_STOPPED_TIMER_ONCE = "has_stopped_timer_once"; @@ -150,12 +153,12 @@ public class * The last generated scramble, related to the current solve. When the timer is started, * the timer will generate a new scramble, but it will be saved in realScramble. */ - private String currentScramble = ""; + private TrainerCase currentScramble = null; /** * The scramble that is currently being shown to the user. MAY NOT BE currentScramble! */ - private String realScramble = null; + private TrainerCase realScramble = null; private Solve currentSolve = null; @@ -217,6 +220,7 @@ public class @BindView(R.id.detail_average_record_message) View detailAverageRecordMesssage; + @BindView(R.id.timerTrainerCase) TextView trainerCaseText; @BindView(R.id.chronometer) ChronometerMilli chronometer; @BindView(R.id.scramble_box) CardView scrambleBox; @BindView(R.id.scramble_text) @@ -432,7 +436,7 @@ public void onClick(View view) { .title(R.string.edit_scramble) .input("", "", (dialog1, input) -> { - setScramble(input.toString()); + setScramble(TrainerCase.makeValid("", input.toString())); // The hint solver will crash if you give it invalid scrambles, // so we shouldn't calculate hints for custom scrambles. @@ -454,7 +458,8 @@ public void onClick(View view) { editScrambleDialog.show(); break; case R.id.scramble_button_manual_entry: - AddTimeDialog addTimeDialog = AddTimeDialog.newInstance(currentPuzzle, currentPuzzleCategory, realScramble); + String scramble = realScramble == null ? null : realScramble.getScramble(); + AddTimeDialog addTimeDialog = AddTimeDialog.newInstance(currentPuzzle, currentPuzzleCategory, scramble); FragmentManager manager = getFragmentManager(); if (manager != null) addTimeDialog.show(manager, "dialog_add_time"); @@ -497,9 +502,17 @@ public void onCreate(Bundle savedInstanceState) { if (savedInstanceState != null) { if (savedInstanceState.getString(PUZZLE) == getArguments().get(PUZZLE)) { - realScramble = savedInstanceState.getString(SCRAMBLE); + // TODO: What is going on here? Maybe test what happens if I go home and then + // re-open the app. + boolean valid = savedInstanceState.getBoolean(IS_SCRAMBLE_VALID); + String scramble = savedInstanceState.getString(SCRAMBLE); + if (valid) { + String name = savedInstanceState.getString(TRAINER_CASE_NAME); + realScramble = TrainerCase.makeValid(name, scramble); + } else { + realScramble = TrainerCase.makeInvalid(scramble); + } } - //hasStoppedTimerOnce = savedInstanceState.getBoolean(HAS_STOPPED_TIMER_ONCE, false); } detailTextNamesArray = getResources().getStringArray(R.array.timer_detail_stats); @@ -996,10 +1009,11 @@ private float calculateScrambleImageHeightMultiplier(float multiplier) { } private void addNewSolve() { + String scramble = currentScramble == null ? "" : currentScramble.getScramble(); currentSolve = new Solve( (int) chronometer.getElapsedTime(), // Includes any "+2" penalty. Is zero for "DNF". currentPuzzle, currentPuzzleCategory, - System.currentTimeMillis(), currentScramble, currentPenalty, "", false); + System.currentTimeMillis(), scramble, currentPenalty, "", false); if (currentPenalty != PENALTY_DNF) { declareRecordTimes(currentSolve); @@ -1337,6 +1351,15 @@ private void startChronometer() { chronometer.start(); chronometer.setHighlighted(false); // Clear any start cue or hold-for-start highlight. + boolean shouldShowName = TIMER_MODE_TRAINER.equals(currentTimerMode); + if (shouldShowName) { + trainerCaseText.setText(realScramble == null ? "" : realScramble.getName()); + trainerCaseText.setVisibility(View.VISIBLE); + } else { + trainerCaseText.setVisibility(View.GONE); + trainerCaseText.setText(""); + } + // isRunning should be set before generateNewScramble so the loading spinner doesn't appear // during a solve, since generateNewScramble checks if isRunning is false before setting // the spinner to visible. @@ -1354,6 +1377,7 @@ private void startChronometer() { private void stopChronometer() { chronometer.stop(); chronometer.setHighlighted(false); + trainerCaseText.setVisibility(View.GONE); isRunning = false; hasStoppedTimerOnce = true; showToolbar(); @@ -1376,8 +1400,11 @@ private void cancelChronometer() { @Override public void onSaveInstanceState(Bundle outState) { + // TODO: Also save whether or not we're in trainer mode? super.onSaveInstanceState(outState); - outState.putString(SCRAMBLE, realScramble); + outState.putString(SCRAMBLE, realScramble == null ? "" : realScramble.getScramble()); + outState.putString(TRAINER_CASE_NAME, realScramble == null ? null : realScramble.getName()); + outState.putBoolean(IS_SCRAMBLE_VALID, realScramble == null || realScramble.isValid()); outState.putString(PUZZLE, currentPuzzle); outState.putBoolean(HAS_STOPPED_TIMER_ONCE, hasStoppedTimerOnce); } @@ -1440,7 +1467,7 @@ private void getNewOptimalCross() { if (showHintsEnabled) { if (optimalCrossAsync != null) optimalCrossAsync.cancel(true); - optimalCrossAsync = new GetOptimalCross(realScramble, + optimalCrossAsync = new GetOptimalCross(realScramble == null ? null : realScramble.getScramble(), optimalCross, optimalXCross, showHintsXCrossEnabled, isRunning, @@ -1458,7 +1485,10 @@ private void generateNewScramble() { scrambleGeneratorAsync = new GenerateScrambleSequence(); scrambleGeneratorAsync.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR); } else if (currentTimerMode.equals(TIMER_MODE_TRAINER)) { - setScramble(TrainerScrambler.generateTrainerCase(getContext(), currentSubset, currentPuzzleCategory)); + TrainerCase tc = TrainerScrambler.generateTrainerCase(getContext(), currentSubset, currentPuzzleCategory); + setScramble(tc); + // TODO: Somehow prevent starting the timer if the case is invalid (e.g., no cases are + // selected) canShowHint = false; hideButtons(true, true); } @@ -1562,7 +1592,7 @@ protected void onProgressUpdate(Void... values) { @Override protected void onPostExecute(String scramble) { - setScramble(scramble); + setScramble(TrainerCase.makeValid("", scramble)); } } @@ -1570,9 +1600,9 @@ protected void onPostExecute(String scramble) { * Updates everything related to displaying the current scramble * Ex. scramble image, box, text, dialogs */ - private void setScramble(final String scramble) { + private void setScramble(final TrainerCase scramble) { realScramble = scramble; - scrambleText.setText(scramble); + scrambleText.setText(scramble == null ? "" : scramble.getScramble()); scrambleText.post(() -> chronometer.post(() -> { if (scrambleText != null) { // Calculate surrounding layouts to make sure the scramble text doesn't intersect any element @@ -1626,7 +1656,7 @@ private void setScramble(final String scramble) { // Broadcast the new scramble new BroadcastBuilder(CATEGORY_UI_INTERACTIONS, ACTION_SCRAMBLE_MODIFIED) - .scramble(realScramble) + .scramble(realScramble == null ? null : realScramble.getScramble()) .broadcast(); } @@ -1634,7 +1664,7 @@ private void setScramble(final String scramble) { @Override public void onClick(View v) { scrambleDialog = new BottomSheetDetailDialog(); - scrambleDialog.setDetailText(realScramble); + scrambleDialog.setDetailText(realScramble == null ? null : realScramble.getScramble()); scrambleDialog.setDetailTextSize(scrambleTextSize); if (canShowHint && showHintsEnabled && currentPuzzle.equals(TYPE_333)) { getNewOptimalCross(); @@ -1651,7 +1681,7 @@ private class GenerateScrambleImage extends AsyncTask { protected Drawable doInBackground(Void... voids) { return generator.generateImageFromScramble( PreferenceManager.getDefaultSharedPreferences(TwistyTimer.getAppContext()), - realScramble); + realScramble == null ? null : realScramble.getScramble()); } @Override diff --git a/app/src/main/java/com/aricneto/twistytimer/fragment/TimerFragmentMain.java b/app/src/main/java/com/aricneto/twistytimer/fragment/TimerFragmentMain.java index 3ee5dd2b..082120a5 100644 --- a/app/src/main/java/com/aricneto/twistytimer/fragment/TimerFragmentMain.java +++ b/app/src/main/java/com/aricneto/twistytimer/fragment/TimerFragmentMain.java @@ -12,6 +12,7 @@ import androidx.annotation.NonNull; import androidx.annotation.Nullable; +import com.aricneto.twistytimer.fragment.dialog.BottomSheetTrainerRegexDialog; import com.aricneto.twistytimer.fragment.dialog.CategorySelectDialog; import com.aricneto.twistytimer.fragment.dialog.BottomSheetTrainerDialog; import com.aricneto.twistytimer.fragment.dialog.PuzzleSelectDialog; @@ -330,7 +331,7 @@ private void updatePuzzleSpinnerHeader() { updateHistorySwitchItem(); puzzleCategoryText.setText(currentPuzzleCategory.toLowerCase()); if (currentTimerMode.equals(TIMER_MODE_TRAINER)) - puzzleNameText.setText(getString(R.string.title_trainer, currentPuzzleSubset.name())); + puzzleNameText.setText(getString(R.string.title_trainer, currentPuzzleSubset.toString())); else puzzleNameText.setText(PuzzleUtils.getPuzzleNameFromType(currentPuzzle)); } @@ -648,11 +649,12 @@ private void updateCurrentCategory() { } private void handleHeaderSpinner() { - - // Setup action bar click listener puzzleSpinnerLayout.setOnClickListener(v -> { - if (currentTimerMode.equals(TimerFragment.TIMER_MODE_TRAINER)) { + if (TimerFragment.TIMER_MODE_TRAINER.equals(currentTimerMode) && currentPuzzleSubset == TrainerScrambler.TrainerSubset.THREE_STYLE_CORNERS) { + BottomSheetTrainerRegexDialog dialog = BottomSheetTrainerRegexDialog.newInstance(currentPuzzleSubset, currentPuzzleCategory); + dialog.show(mFragmentManager, "trainer_regex_dialog_fragment"); + } else if (TIMER_MODE_TRAINER.equals(currentTimerMode)) { BottomSheetTrainerDialog bottomSheetTrainerDialog = BottomSheetTrainerDialog.newInstance(currentPuzzleSubset, currentPuzzleCategory); bottomSheetTrainerDialog.show(mFragmentManager, "trainer_dialog_fragment"); } @@ -665,7 +667,6 @@ private void handleHeaderSpinner() { }); updatePuzzleSpinnerHeader(); - } // A new puzzle has been selected diff --git a/app/src/main/java/com/aricneto/twistytimer/fragment/dialog/BottomSheetTrainerDialog.java b/app/src/main/java/com/aricneto/twistytimer/fragment/dialog/BottomSheetTrainerDialog.java index c5163183..8203b097 100644 --- a/app/src/main/java/com/aricneto/twistytimer/fragment/dialog/BottomSheetTrainerDialog.java +++ b/app/src/main/java/com/aricneto/twistytimer/fragment/dialog/BottomSheetTrainerDialog.java @@ -1,5 +1,6 @@ package com.aricneto.twistytimer.fragment.dialog; +import android.annotation.SuppressLint; import android.app.Activity; import android.content.Context; import android.content.DialogInterface; @@ -128,6 +129,7 @@ public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle sa mUnbinder = ButterKnife.bind(this, dialogView); titleView.setText(R.string.trainer_spinner_title); + @SuppressLint("ResourceType") Drawable icon = ThemeUtils.tintDrawable(getContext(), R.drawable.ic_outline_control_camera_24px, ContextCompat.getColor(getContext(), R.color.md_blue_A700)); titleView.setCompoundDrawablesWithIntrinsicBounds(null, null, null, null); diff --git a/app/src/main/java/com/aricneto/twistytimer/fragment/dialog/BottomSheetTrainerRegexDialog.java b/app/src/main/java/com/aricneto/twistytimer/fragment/dialog/BottomSheetTrainerRegexDialog.java new file mode 100644 index 00000000..4b58916c --- /dev/null +++ b/app/src/main/java/com/aricneto/twistytimer/fragment/dialog/BottomSheetTrainerRegexDialog.java @@ -0,0 +1,151 @@ +package com.aricneto.twistytimer.fragment.dialog; + +import static com.aricneto.twistytimer.utils.TTIntent.ACTION_GENERATE_SCRAMBLE; +import static com.aricneto.twistytimer.utils.TTIntent.CATEGORY_UI_INTERACTIONS; +import static com.aricneto.twistytimer.utils.TTIntent.broadcast; + +import android.annotation.SuppressLint; +import android.content.DialogInterface; +import android.graphics.drawable.Drawable; +import android.os.Bundle; +import android.text.Editable; +import android.text.TextWatcher; +import android.text.method.LinkMovementMethod; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.EditText; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.appcompat.widget.AppCompatTextView; +import androidx.core.content.ContextCompat; + +import com.aricneto.twistify.R; +import com.aricneto.twistytimer.activity.MainActivity; +import com.aricneto.twistytimer.puzzle.TrainerScrambler; +import com.aricneto.twistytimer.utils.ThemeUtils; +import com.google.android.material.bottomsheet.BottomSheetDialogFragment; + +import java.util.List; +import java.util.Set; +import java.util.regex.Pattern; +import java.util.regex.PatternSyntaxException; + +import butterknife.BindView; +import butterknife.ButterKnife; +import butterknife.Unbinder; + +/** + * Bottom sheet dialog to select 3BLD cases using a simple regex-like syntax. + */ +public class BottomSheetTrainerRegexDialog extends BottomSheetDialogFragment { + private static final String KEY_SUBSET = "subset"; + private static final String KEY_CATEGORY = "category"; + + @BindView(R.id.title) + TextView titleView; + @BindView(R.id.regex_case_select_instructions) + TextView instructionsView; + @BindView(R.id.button) + AppCompatTextView button; + @BindView(R.id.cases_regex) + EditText casesRegex; + @BindView(R.id.num_cases_selected) + TextView numCasesSelected; + private Unbinder mUnbinder; + private TrainerScrambler.TrainerSubset currentSubset; + private String currentCategory; + + public static BottomSheetTrainerRegexDialog newInstance(TrainerScrambler.TrainerSubset subset, String category) { + if (!TrainerScrambler.TrainerSubset.THREE_STYLE_CORNERS.equals(subset)) { + throw new IllegalArgumentException("Invalid trainer subset for BottomSheetTrainerRegexDialog: " + subset.toString()); + } + + BottomSheetTrainerRegexDialog fragment = new BottomSheetTrainerRegexDialog(); + Bundle args = new Bundle(); + args.putSerializable(KEY_SUBSET, subset); + args.putString(KEY_CATEGORY, category); + fragment.setArguments(args); + return fragment; + } + + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + Bundle args = getArguments(); + if (args != null) { + currentSubset = (TrainerScrambler.TrainerSubset) getArguments().getSerializable(KEY_SUBSET); + currentCategory = getArguments().getString(KEY_CATEGORY); + } + } + + @Override + public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { + View dialogView = inflater.inflate(R.layout.dialog_bottomsheet_regex_trainer, container); + mUnbinder = ButterKnife.bind(this, dialogView); + + @SuppressLint("ResourceType") + Drawable icon = ThemeUtils.tintDrawable( + getContext(), + R.drawable.ic_outline_control_camera_24px, + ContextCompat.getColor(getContext(), R.color.md_blue_A700) + ); + titleView.setCompoundDrawablesWithIntrinsicBounds(null, null, null, null); + + instructionsView.setMovementMethod(LinkMovementMethod.getInstance()); + + button.setCompoundDrawablesWithIntrinsicBounds(icon, null, null, null); + button.setOnClickListener(v -> { + casesRegex.setText(".*"); + }); + + int initialNumCases = TrainerScrambler.fetchSelectedCaseSet(currentSubset, currentCategory, getContext()).size(); + numCasesSelected.setText(getString(R.string.num_cases_selected, initialNumCases)); + Set previouslySelectedItems = TrainerScrambler.fetchCaseSelection(currentSubset, currentCategory); + String initialRegex = ""; + for (String s : previouslySelectedItems) { + initialRegex = s; + break; + } + casesRegex.setText(initialRegex); + casesRegex.addTextChangedListener(new TextWatcher() { + @Override + public void beforeTextChanged(CharSequence charSequence, int i, int i1, int i2) {} + + @Override + public void onTextChanged(CharSequence charSequence, int start, int before, int count) { + } + + @Override + public void afterTextChanged(Editable s) { + String str = s.toString().trim(); + try { + Pattern.compile(str); + TrainerScrambler.saveCaseSelection(currentSubset, currentCategory, List.of(str)); + int numCases = TrainerScrambler.fetchSelectedCaseSet(currentSubset, currentCategory, getContext()).size(); + numCasesSelected.setText(getString(R.string.num_cases_selected, numCases)); + casesRegex.setError(null); + } + catch (PatternSyntaxException e) { + casesRegex.setError("Syntax error"); + } + } + }); + + return dialogView; + } + + @Override + public void onDestroyView() { + super.onDestroyView(); + mUnbinder.unbind(); + getLoaderManager().destroyLoader(MainActivity.ALG_LIST_LOADER_ID); + } + + @Override + public void onDismiss(@NonNull DialogInterface dialog) { + super.onDismiss(dialog); + broadcast(CATEGORY_UI_INTERACTIONS, ACTION_GENERATE_SCRAMBLE); + } +} diff --git a/app/src/main/java/com/aricneto/twistytimer/fragment/dialog/CategorySelectDialog.java b/app/src/main/java/com/aricneto/twistytimer/fragment/dialog/CategorySelectDialog.java index 77aaa0b3..d61f760f 100644 --- a/app/src/main/java/com/aricneto/twistytimer/fragment/dialog/CategorySelectDialog.java +++ b/app/src/main/java/com/aricneto/twistytimer/fragment/dialog/CategorySelectDialog.java @@ -87,6 +87,9 @@ public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup c mUnbinder = ButterKnife.bind(this, view); mContext = getContext(); + getDialog().getWindow().setBackgroundDrawable(new ColorDrawable(Color.TRANSPARENT)); + getDialog().getWindow().requestFeature(Window.FEATURE_NO_TITLE); + return view; } @@ -95,9 +98,6 @@ public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup c public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { super.onViewCreated(view, savedInstanceState); - getDialog().getWindow().setBackgroundDrawable(new ColorDrawable(Color.TRANSPARENT)); - getDialog().getWindow().requestFeature(Window.FEATURE_NO_TITLE); - // retrieve arguments currentPuzzle = getArguments().getString("puzzle"); currentSubtype = getArguments().getString("subtype"); diff --git a/app/src/main/java/com/aricneto/twistytimer/fragment/dialog/LocaleSelectDialog.java b/app/src/main/java/com/aricneto/twistytimer/fragment/dialog/LocaleSelectDialog.java index 660775af..90709b43 100644 --- a/app/src/main/java/com/aricneto/twistytimer/fragment/dialog/LocaleSelectDialog.java +++ b/app/src/main/java/com/aricneto/twistytimer/fragment/dialog/LocaleSelectDialog.java @@ -51,20 +51,15 @@ public View onCreateView(LayoutInflater inflater, @Nullable ViewGroup container, View dialogView = inflater.inflate(R.layout.dialog_settings_change_locale, container); mUnbinder = ButterKnife.bind(this, dialogView); + getDialog().getWindow().setBackgroundDrawable(new ColorDrawable(Color.TRANSPARENT)); + getDialog().getWindow().requestFeature(Window.FEATURE_NO_TITLE); + recyclerView.setLayoutManager(new GridLayoutManager(getContext(), 2)); recyclerView.setAdapter(new LocaleSelectAdapter(getActivity(), this)); return dialogView; } - @Override - public void onViewCreated(View view, @Nullable Bundle savedInstanceState) { - super.onViewCreated(view, savedInstanceState); - - getDialog().getWindow().setBackgroundDrawable(new ColorDrawable(Color.TRANSPARENT)); - getDialog().getWindow().requestFeature(Window.FEATURE_NO_TITLE); - } - @Override public void onDestroyView() { super.onDestroyView(); diff --git a/app/src/main/java/com/aricneto/twistytimer/puzzle/CornerSticker.java b/app/src/main/java/com/aricneto/twistytimer/puzzle/CornerSticker.java new file mode 100644 index 00000000..4ed27010 --- /dev/null +++ b/app/src/main/java/com/aricneto/twistytimer/puzzle/CornerSticker.java @@ -0,0 +1,93 @@ +package com.aricneto.twistytimer.puzzle; + +public enum CornerSticker { + UBL, UBR, UFR, UFL, + LBU, LFU, LFD, LBD, + FLU, FRU, FRD, FLD, + RFU, RBU, RBD, RFD, + BRU, BLU, BLD, BRD, + DLF, DRF, DRB, DLB; + + /** + * @param s A sticker specified as a string (e.g., "UFR"). + * @return The corresponding sticker enum. + */ + public static CornerSticker parse(String s) { + switch ((s == null ? "" : s).toUpperCase()) { + case "UBL": + case "ULB": + return CornerSticker.UBL; + case "UBR": + case "URB": + return CornerSticker.UBR; + case "UFR": + case "URF": + return CornerSticker.UFR; + case "UFL": + case "ULF": + return CornerSticker.UFL; + case "LBU": + case "LUB": + return CornerSticker.LBU; + case "LFU": + case "LUF": + return CornerSticker.LFU; + case "LFD": + case "LDF": + return CornerSticker.LFD; + case "LBD": + case "LDB": + return CornerSticker.LBD; + case "FLU": + case "FUL": + return CornerSticker.FLU; + case "FRU": + case "FUR": + return CornerSticker.FRU; + case "FRD": + case "FDR": + return CornerSticker.FRD; + case "FLD": + case "FDL": + return CornerSticker.FLD; + case "RFU": + case "RUF": + return CornerSticker.RFU; + case "RBU": + case "RUB": + return CornerSticker.RBU; + case "RBD": + case "RDB": + return CornerSticker.RBD; + case "RFD": + case "RDF": + return CornerSticker.RFD; + case "BRU": + case "BUR": + return CornerSticker.BRU; + case "BLU": + case "BUL": + return CornerSticker.BLU; + case "BLD": + case "BDL": + return CornerSticker.BLD; + case "BRD": + case "BDR": + return CornerSticker.BRD; + case "DLF": + case "DFL": + return CornerSticker.DLF; + case "DRF": + case "DFR": + return CornerSticker.DRF; + case "DRB": + case "DBR": + return CornerSticker.DRB; + case "DLB": + case "DBL": + return CornerSticker.DLB; + default: + throw new IllegalArgumentException(String.format("\"%s\" is not a valid corner sticker", s)); + } + } +} diff --git a/app/src/main/java/com/aricneto/twistytimer/puzzle/LetterScheme.java b/app/src/main/java/com/aricneto/twistytimer/puzzle/LetterScheme.java new file mode 100644 index 00000000..e94c920b --- /dev/null +++ b/app/src/main/java/com/aricneto/twistytimer/puzzle/LetterScheme.java @@ -0,0 +1,254 @@ +package com.aricneto.twistytimer.puzzle; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.apache.commons.lang3.NotImplementedException; + +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; + +/** + * Represents a lettering scheme, as used in blindfolded solving. + */ +public class LetterScheme { + public static final String SPEFFZ_LETTERS = "ABCDEFGHIJKLMNOPQRSTUVWX"; + + private final Map speffzLetterToCustomLetter; + private final Map customLetterToSpeffzLetter; + + // How to permute letters when a rotation is applied. + // newLetters.charAt(i) is given by oldLetters.charAt(permutation[i]). + private static final int[] NO_OP = { + 0, 1, 2, 3, + 4, 5, 6, 7, + 8, 9, 10, 11, + 12, 13, 14, 15, + 16, 17, 18, 19, + 20, 21, 22, 23 + }; + private static final int[] ROTATE_X = { + 8, 9, 10, 11, + 5, 6, 7, 4, + 20, 21, 22, 23, + 15, 12, 13, 14, + 2, 3, 0, 1, + 18, 19, 16, 17 + }; + private static final int[] ROTATE_X2 = compose(ROTATE_X, ROTATE_X); + private static final int[] ROTATE_X_PRIME = compose(ROTATE_X2, ROTATE_X); + private static final int[] ROTATE_Y = { + 3, 0, 1, 2, + 8, 9, 10, 11, + 12, 13, 14, 15, + 16, 17, 18, 19, + 4, 5, 6, 7, + 21, 22, 23, 20 + }; + private static final int[] ROTATE_Y2 = compose(ROTATE_Y, ROTATE_Y); + private static final int[] ROTATE_Y_PRIME = compose(ROTATE_Y2, ROTATE_Y); + private static final int[] ROTATE_Z = compose(compose(ROTATE_X, ROTATE_Y), ROTATE_X_PRIME); + private static final int[] ROTATE_Z2 = compose(ROTATE_Z, ROTATE_Z); + private static final int[] ROTATE_Z_PRIME = compose(ROTATE_Z2, ROTATE_Z); + + /** + * @param letters The letters of this lettering scheme, in the standard Speffz order: UBL, UBR, + * UFR, UFL, LBU, LFU, LFD, LBD, FLU, FRU, FRD, FLD, RFU, RBU, RBD, RFD, BRU, + * BLU, BLD, BRD, DLF, DRF, DRB, DLB. + */ + public LetterScheme(String letters) { + if (letters == null) { + letters = ""; + } else { + letters = letters.trim(); + } + if (letters.length() != 24) { + throw new IllegalArgumentException(String.format("Invalid length: expected 24 but got %d.", letters.length())); + } + + this.customLetterToSpeffzLetter = new HashMap<>(24); + this.speffzLetterToCustomLetter = new HashMap<>(24); + for (char speffz = 'A'; speffz <= 'X'; speffz++) { + char custom = letters.charAt(speffz - 'A'); + speffzLetterToCustomLetter.put(speffz, custom); + if (customLetterToSpeffzLetter.containsKey(custom)) { + throw new IllegalArgumentException(String.format("The letter '%c' appears multiple times.", custom)); + } + customLetterToSpeffzLetter.put(custom, speffz); + } + } + + /** + * The Speffz lettering scheme. + */ + public static LetterScheme speffz() { + return new LetterScheme(SPEFFZ_LETTERS); + } + + /** + * The set of letters used in this scheme. + */ + public Set getLetters() { + // Make a copy so the map isn't modified + return new HashSet<>(this.customLetterToSpeffzLetter.keySet()); + } + + private String getLettersInOrder() { + StringBuilder builder = new StringBuilder(); + for (char c : SPEFFZ_LETTERS.toCharArray()) { + builder.append(this.speffzLetterToCustomLetter.get(c)); + } + return builder.toString(); + } + + /** + * Convert from this lettering scheme into the Speffz lettering scheme. + * For example, {@code new LetterScheme("ABCDQRSTEFGHIJKLMNOPUVWX").toSpeffz('L') == 'P'}. + * + * @param c A letter in this lettering scheme. + * @return The corresponding letter in the Speffz lettering scheme. + */ + public char toSpeffz(char c) { + Character speffz = this.customLetterToSpeffzLetter.get(c); + if (speffz == null) { + throw new IllegalArgumentException(String.format("'%c' is not a valid letter in this scheme.", c)); + } + return speffz; + } + + /** + * Convert from this lettering scheme into the Speffz lettering scheme. + * For example, {@code new LetterScheme("ABCDQRSTEFGHIJKLMNOPUVWX").toSpeffz("LH") == "PL"}. + * + * @param letters A string (e.g., a 3-style case) in this lettering scheme. + * @return The provided string, translated letter-by-letter to the Speffz lettering scheme. + */ + public String toSpeffz(String letters) { + if (letters == null || "".equals(letters.trim())) { + return ""; + } + StringBuilder translatedString = new StringBuilder(letters.length()); + for (char custom : letters.toCharArray()) { + translatedString.append(this.toSpeffz(custom)); + } + return translatedString.toString(); + } + + /** + * Convert from the Speffz lettering scheme into this lettering scheme. + * For example, {@code new LetterScheme("ABCDQRSTEFGHIJKLMNOPUVWX").fromSpeffz('L') == 'H'}. + * + * @param c A letter in the Speffz lettering scheme. + * @return The corresponding letter in this lettering scheme. + */ + public char fromSpeffz(char c) { + Character custom = this.speffzLetterToCustomLetter.get(c); + if (custom == null) { + throw new IllegalArgumentException(String.format("'%c' is not a valid letter in the Speffz scheme.", c)); + } + return custom; + } + + /** + * Convert from the Speffz lettering scheme into this lettering scheme. + * For example, {@code new LetterScheme("ABCDQRSTEFGHIJKLMNOPUVWX").fromSpeffz("LH") == "HT"}. + * + * @param letters A string (e.g., a 3-style case) in the Speffz lettering scheme. + * @return The provided string, translated letter-by-letter to this lettering scheme. + */ + public String fromSpeffz(String letters) { + if (letters == null || "".equals(letters.trim())) { + return ""; + } + StringBuilder translatedString = new StringBuilder(letters.length()); + for (char speffz : letters.toCharArray()) { + translatedString.append(this.fromSpeffz(speffz)); + } + return translatedString.toString(); + } + + /** + * Compute the letter scheme you'd get by writing the current letter scheme onto the physical + * stickers and then applying the given sequence of rotations. For example, + * {@code new LetterScheme("ABCDEFGHIJKLMNOPQRSTUVWX").rotate("y") == + * new LetterScheme("DABCIJKLMNOPQRSTEFGHVWXU")} + * + * @param rotations A sequence of rotations (x, y', z2, etc.) + * @return The new letter scheme. + */ + public LetterScheme rotate(String rotations) { + if (rotations == null || rotations.trim().isEmpty()) { + return this; + } + + int[] permutation = NO_OP; + for (String move : rotations.split("\\s+")) { + switch (move) { + case "x": + permutation = compose(permutation, ROTATE_X); + break; + case "x'": + permutation = compose(permutation, ROTATE_X_PRIME); + break; + case "x2": + permutation = compose(permutation, ROTATE_X2); + break; + case "y": + permutation = compose(permutation, ROTATE_Y); + break; + case "y'": + permutation = compose(permutation, ROTATE_Y_PRIME); + break; + case "y2": + permutation = compose(permutation, ROTATE_Y2); + break; + case "z": + permutation = compose(permutation, ROTATE_Z); + break; + case "z'": + permutation = compose(permutation, ROTATE_Z_PRIME); + break; + case "z2": + permutation = compose(permutation, ROTATE_Z2); + break; + default: + throw new IllegalArgumentException(String.format("'%s' is not a valid rotation.", move)); + } + } + + String currentLetters = this.getLettersInOrder(); + StringBuilder newLetters = new StringBuilder(); + for (int i = 0; i < SPEFFZ_LETTERS.length(); i++) { + newLetters.append(currentLetters.charAt(permutation[i])); + } + return new LetterScheme(newLetters.toString()); + } + + private static int[] compose(int[] p1, int[] p2) { + if (p1.length != p2.length) { + throw new IllegalArgumentException("Cannot compose permutations due to length mismatch."); + } + int[] out = new int[p1.length]; + for (int i = 0; i < p1.length; i++) { + out[i] = p1[p2[i]]; + } + return out; + } + + @Override + public boolean equals(@Nullable Object obj) { + if (!(obj instanceof LetterScheme)) { + return false; + } + LetterScheme that = (LetterScheme) obj; + return this.getLettersInOrder().equals(that.getLettersInOrder()); + } + + @NonNull + @Override + public String toString() { + return String.format("LetterScheme(\"%s\")", this.getLettersInOrder()); + } +} diff --git a/app/src/main/java/com/aricneto/twistytimer/puzzle/TrainerCase.java b/app/src/main/java/com/aricneto/twistytimer/puzzle/TrainerCase.java new file mode 100644 index 00000000..c2914ec5 --- /dev/null +++ b/app/src/main/java/com/aricneto/twistytimer/puzzle/TrainerCase.java @@ -0,0 +1,46 @@ +package com.aricneto.twistytimer.puzzle; + +import java.io.Serializable; + +public class TrainerCase implements Serializable { + private final String name; + private final String scramble; + private final boolean isValid; + + private TrainerCase(String name, String scramble, boolean isValid) { + this.name = name; + this.scramble = scramble; + this.isValid = isValid; + } + + public static TrainerCase makeValid(String name, String scramble) { + return new TrainerCase(name, scramble, true); + } + + public static TrainerCase makeInvalid(String msg) { + return new TrainerCase("", msg, false); + } + + /** + * The name of this case. + */ + public String getName() { + return name; + } + + /** + * For a valid trainer case, this is the scramble sequence. + * For an invalid trainer case, this is the error message to show to the user. + */ + public String getScramble() { + return scramble; + } + + /** + * Whether or not this trainer case is valid. + * For example, if the user has not selected any cases to train, an invalid trainer case could be generated. + */ + public boolean isValid() { + return isValid; + } +} diff --git a/app/src/main/java/com/aricneto/twistytimer/puzzle/TrainerScrambler.java b/app/src/main/java/com/aricneto/twistytimer/puzzle/TrainerScrambler.java index 7a72ea67..ae6948ca 100644 --- a/app/src/main/java/com/aricneto/twistytimer/puzzle/TrainerScrambler.java +++ b/app/src/main/java/com/aricneto/twistytimer/puzzle/TrainerScrambler.java @@ -2,28 +2,25 @@ import android.content.Context; import android.content.res.Resources; -import android.content.res.TypedArray; -import android.text.TextUtils; import android.util.Log; import com.aricneto.twistify.R; -import com.aricneto.twistytimer.items.Algorithm; -import com.aricneto.twistytimer.utils.AlgUtils; import com.aricneto.twistytimer.utils.Prefs; import com.aricneto.twistytimer.utils.PuzzleUtils; import net.gnehzr.tnoodle.scrambles.InvalidScrambleException; -import net.gnehzr.tnoodle.scrambles.Puzzle; -import java.lang.reflect.Field; +import org.jetbrains.annotations.NotNull; + import java.util.ArrayList; -import java.util.Arrays; import java.util.HashSet; import java.util.List; -import java.util.Objects; +import java.util.Locale; import java.util.Random; import java.util.Set; -import java.util.stream.Collectors; +import java.util.logging.Logger; +import java.util.regex.Pattern; +import java.util.regex.PatternSyntaxException; import puzzle.CubePuzzle; import puzzle.ThreeByThreeCubePuzzle; @@ -40,6 +37,7 @@ public abstract class TrainerScrambler { private static CubePuzzle puzzle = new ThreeByThreeCubePuzzle(); private static CubePuzzle.CubeState solved = puzzle.getSolvedState(); + private static final Logger logger = Logger.getLogger(TrainerScrambler.class.getName()); private TrainerScrambler() {} @@ -49,101 +47,396 @@ private TrainerScrambler() {} // implementation, causing crashes. public static final String KEY_TRAINER = "TRAINER_V2"; - public static enum TrainerSubset { - OLL, PLL - }; + public enum TrainerSubset { + OLL, + PLL, + THREE_STYLE_CORNERS; - /** - * Amount of different variations for each registered subset - */ - private static int getSubsetVariations(TrainerSubset subset) { - switch (subset) { - case OLL: - return 57; - case PLL: - return 0; + @Override + @NotNull + public String toString() { + switch (this) { + case OLL: + return "OLL"; + case PLL: + return "PLL"; + case THREE_STYLE_CORNERS: + return "3-style corners"; + default: + throw new IllegalArgumentException("Unknown trainer subset."); + } } - return 0; - } + }; /** - * Saves selected cases to preferences, to be fetched later + * Saves case selection to preferences, to be fetched later. + * The case selection can either be a case list (e.g., for the OLL and PLL trainers) or a + * regular expression (e.g., for the 3-style trainer). * Preference will be saved in the format: * key: TRAINER[SUBSET][CATEGORY] * set: ITEMS */ - public static void saveSelectedItems(TrainerSubset subset, String category, Set selectedItems) { + public static void saveCaseSelection(TrainerSubset subset, String category, Set selectedItems) { Prefs.getPrefs().edit() .putStringSet(KEY_TRAINER + subset.name() + category, selectedItems) .apply(); } /** - * Saves selected cases to preferences, to be fetched later. Accepts a List input. + * Saves case selection to preferences, to be fetched later. + * The case selection can either be a case list (e.g., for the OLL and PLL trainers) or a + * regular expression (e.g., for the 3-style trainer). * Preference will be saved in the format: * key: TRAINER[SUBSET][CATEGORY] * set: ITEMS */ - public static void saveSelectedItems(TrainerSubset subset, String category, List selectedItems) { - saveSelectedItems(subset, category, new HashSet<>(selectedItems)); + public static void saveCaseSelection(TrainerSubset subset, String category, List selectedItems) { + saveCaseSelection(subset, category, new HashSet<>(selectedItems)); } /** - * Utility function to rename a Trainer category, maintaining trainer subsets - * @param subset - * @param oldCategoryName - * @param newCategoryName + * Utility function to rename a trainer category, maintaining trainer subsets. */ public static void renameCategory(TrainerSubset subset, String oldCategoryName, String newCategoryName) { - Set items = fetchSelectedItems(subset, oldCategoryName); + Set items = fetchCaseSelection(subset, oldCategoryName); Prefs.getPrefs().edit().remove(KEY_TRAINER + subset.name() + oldCategoryName).apply(); - saveSelectedItems(subset, newCategoryName, items); + saveCaseSelection(subset, newCategoryName, items); } /** - * Fetches previously selected cases depending on current subset and category - * @param subset - * @param category - * @return + * Fetches the previously-saved case selection for the given subset and category. + * The case selection can either be a case list (e.g., for the OLL and PLL trainers) or a + * regular expression (e.g., for the 3-style trainer). */ - public static Set fetchSelectedItems(TrainerSubset subset, String category) { + public static Set fetchCaseSelection(TrainerSubset subset, String category) { return Prefs.getPrefs() .getStringSet(KEY_TRAINER + subset.name() + category, new HashSet<>()); } /** - * Generates a random trainer case from the selected cases + * Fetches the set of selected cases for the given subset and category. */ - public static String generateTrainerCase(Context context, TrainerSubset subset, String category) { - List selectedItems = new ArrayList<>(fetchSelectedItems(subset, category)); - String caseAlg = ""; - String scramble = ""; - - CubePuzzle.CubeState state = null; - - if (selectedItems.size() != 0) { - try { - // Fetch a random setup algorithm and set it as the cube state - caseAlg = fetchCaseAlgorithm(context, subset.name(), selectedItems.get(random.nextInt(selectedItems.size()))); - state = (CubePuzzle.CubeState) solved.applyAlgorithm(caseAlg); - - // Solve the state - scramble = ((ThreeByThreeCubePuzzle) puzzle).solveIn(state, 20, null, null); - } catch (InvalidScrambleException e) { - e.printStackTrace(); + public static Set fetchSelectedCaseSet(TrainerSubset subset, String category, Context context) { + Set caseSelection = fetchCaseSelection(subset, category); + switch (subset) { + case OLL: + case PLL: + return caseSelection; + case THREE_STYLE_CORNERS: + String letterSchemeStr = Prefs.getString(R.string.pk_corner_letter_scheme, LetterScheme.SPEFFZ_LETTERS); + String bufferStr = Prefs.getString(R.string.pk_corner_buffer, context.getString(R.string.default_corner_buffer)); + LetterScheme letterScheme; + CornerSticker buffer; + try { + letterScheme = new LetterScheme(letterSchemeStr); + buffer = CornerSticker.parse(bufferStr); + } + catch (IllegalArgumentException e) { + return new HashSet<>(); + } + Pattern p = getRegex(caseSelection); + if (p == null) { + return new HashSet<>(); + } + return findMatchingCases(letterScheme, buffer, p); + default: + throw new IllegalArgumentException(String.format("Unsupported trainer subset %s.", subset.name())); + } + } + + /** + * Compile the given case selection to a regular expression (or null if the case selection + * is empty). + */ + private static Pattern getRegex(Set caseSelection) { + String regex = null; + for (String s : caseSelection) { + regex = s; + break; + } + if (regex == null || regex.isBlank()) { + return null; + } + try { + return Pattern.compile(regex); + } + catch (PatternSyntaxException e) { + return null; + } + } + + private static Set findMatchingCases(LetterScheme scheme, CornerSticker buffer, Pattern p) { + if (scheme == null) { + throw new IllegalArgumentException("Missing letter scheme."); + } + if (p == null) { + throw new IllegalArgumentException("Missing regex."); + } + + LetterScheme rotatedScheme = scheme.rotate(bufferToUFR(buffer)); + + Set nonBufferStickers = scheme.getLetters(); + nonBufferStickers.remove(rotatedScheme.fromSpeffz('C')); + nonBufferStickers.remove(rotatedScheme.fromSpeffz('J')); + nonBufferStickers.remove(rotatedScheme.fromSpeffz('M')); + + // Each corner twist case has one name but three IDs. + // (TODO: Maybe it would be better to use the case name everywhere and get rid of the ID.) + // To get an accurate count of the number of selected cases, don't add a given case name + // more than once. + Set selectedCaseNames = new HashSet<>(); + Set selectedCaseIds = new HashSet<>(); + for (char c1 : nonBufferStickers) { + for (char c2 : nonBufferStickers) { + if (c1 == c2) { + continue; + } + String caseId = "" + c1 + c2; + String caseName = name3SCCase(caseId, scheme); + if (!selectedCaseNames.contains(caseName) && p.matcher(caseName).find()) { + selectedCaseNames.add(caseName); + selectedCaseIds.add(caseId); + } } - } else { - scramble = context.getString(R.string.trainer_help_message); } - return PuzzleUtils.applyRotationForAlgorithm(scramble, Y_ROTATIONS[random.nextInt(4)]); + return selectedCaseIds; + } + + /** + * Rotate the cube so that the given sticker is now at UFR. + * + * @param buffer The buffer position, in the Speffz scheme. + * @return A sequence of whole-cube rotations that will move the given buffer to UFR. + */ + private static String bufferToUFR(CornerSticker buffer) { + switch (buffer) { + case UBL: + return "y2"; + case UBR: + return "y"; + case UFR: + return ""; + case UFL: + return "y'"; + case FLU: + return "x y2"; + case FRU: + return "x y"; + case FRD: + return "x"; + case FLD: + return "x y'"; + case RFU: + return "z' y'"; + case RBU: + return "z' y2"; + case RBD: + return "z' y"; + case RFD: + return "z'"; + case BRU: + return "x'"; + case BLU: + return "x' y'"; + case BLD: + return "x' y2"; + case BRD: + return "x' y"; + case LBU: + return "z y"; + case LFU: + return "z"; + case LFD: + return "z y'"; + case LBD: + return "z y2"; + case DLF: + return "z2"; + case DRF: + return "z2 y'"; + case DRB: + return "z2 y2"; + case DLB: + return "z2 y"; + default: + throw new IllegalArgumentException(String.format("Cannot rotate %s to C because %s is not a valid letter in the Speffz scheme.", buffer.name(), buffer.name())); + } } + /** + * Generates a random trainer case from the selected cases + */ + public static TrainerCase generateTrainerCase(Context context, TrainerSubset subset, String category) { + List allowedCases = new ArrayList<>(fetchSelectedCaseSet(subset, category, context)); + if (allowedCases.isEmpty()) { + return TrainerCase.makeInvalid(context.getString(R.string.trainer_help_message)); + } + String caseId = allowedCases.get(random.nextInt(allowedCases.size())); + + switch (subset) { + case OLL: + case PLL: + return generateOLLPLLTrainerCase(context, subset, caseId); + case THREE_STYLE_CORNERS: + String letterSchemeStr = Prefs.getString(R.string.pk_corner_letter_scheme, LetterScheme.SPEFFZ_LETTERS); + LetterScheme letterScheme; + try { + letterScheme = new LetterScheme(letterSchemeStr); + } + catch (IllegalArgumentException e) { + return TrainerCase.makeInvalid(context.getString(R.string.trainer_help_invalid_letter_scheme)); + } + + String bufferStr = Prefs.getString(R.string.pk_corner_buffer, context.getString(R.string.default_corner_buffer)); + CornerSticker buffer; + try { + buffer = CornerSticker.parse(bufferStr); + } + catch (IllegalArgumentException e) { + return TrainerCase.makeInvalid(context.getString(R.string.trainer_help_invalid_corner_buffer)); + } + + return generateThreeStyleTrainerCase(context, subset, caseId, letterScheme, buffer); + default: + throw new IllegalArgumentException(String.format("Unsupported trainer subset %s.", subset.name())); + } + } + + private static TrainerCase generateOLLPLLTrainerCase(Context context, TrainerSubset subset, String caseName) { + String caseAlg = fetchCaseAlgorithm(context, subset.name(), caseName); + + CubePuzzle.CubeState state; + try { + state = (CubePuzzle.CubeState) solved.applyAlgorithm(caseAlg); + } catch (InvalidScrambleException e) { + e.printStackTrace(); + // Should never happen + return TrainerCase.makeInvalid("Failed to generate scramble because the stored solution is invalid."); + } + String scramble = ((ThreeByThreeCubePuzzle) puzzle).solveIn(state, 20, null, null); + scramble = PuzzleUtils.applyRotationForAlgorithm(scramble, Y_ROTATIONS[random.nextInt(4)]); + + return TrainerCase.makeValid(caseName, scramble); + } + + private static TrainerCase generateThreeStyleTrainerCase(Context context, TrainerSubset subset, String caseId, LetterScheme scheme, CornerSticker buffer) { + String rotateBufferAlg = bufferToUFR(buffer); + LetterScheme rotatedScheme = scheme.rotate(rotateBufferAlg); + + String speffzCase = rotatedScheme.toSpeffz(caseId); + String alg = fetchCaseAlgorithm(context, subset.name(), speffzCase); + // Add a random prefix and suffix so that the scramble isn't the same each time. + // Let A be the solution for this case, P be the random prefix, and S be the random suffix. + // We want to find A', the inverse of A. + // A' = S S' A' P' P + // = S (S' A' P') P' + // = S (P A S)' P + // So we can just add the prefix and suffix to A, call the solver, and then add the suffix + // and prefix onto the resulting scramble to find an algorithm that's equal to A' but + // starts with the suffix and ends with the prefix. + String[] faces = {"U", "F", "R", "B", "L", "D"}; + String[] turns = {"", "'", "2"}; + String prefix = faces[random.nextInt(faces.length)] + turns[random.nextInt(turns.length)]; + String suffix = faces[random.nextInt(faces.length)] + turns[random.nextInt(turns.length)]; + alg = String.format("%s %s %s", prefix, alg, suffix); + + CubePuzzle.CubeState state; + try { + state = (CubePuzzle.CubeState) solved.applyAlgorithm(alg); + } catch (InvalidScrambleException e) { + e.printStackTrace(); + // Should never happen + return TrainerCase.makeInvalid("Failed to generate scramble because the stored solution is invalid."); + } + + // Use firstAxisRestriction and lastAxisRestriction in the puzzle.solveIn() method to + // ensure the solver doesn't just undo the prefix and suffix. + String[] algMoves = alg.split("\\s+"); + String firstMoveFace = algMoves[0].substring(0, 1); + if (!isValidAxis(firstMoveFace)) { + firstMoveFace = null; + } + String lastMoveFace = algMoves[algMoves.length - 1].substring(0, 1); + if (!isValidAxis(lastMoveFace)) { + lastMoveFace = null; + } + logger.info(String.format("Searching for scramble for case \"%s\" with firstAxisRestriction=\"%s\" and lastAxisRestriction=\"%s\".", caseId, lastMoveFace, firstMoveFace)); + String scramble = ((ThreeByThreeCubePuzzle) puzzle).solveIn(state, 20, lastMoveFace, firstMoveFace); + + scramble = String.format("%s %s %s", suffix, scramble, prefix); + scramble = PuzzleUtils.applyRotationsForAlgorithm(scramble, PuzzleUtils.invertRotations(rotateBufferAlg)); + + return TrainerCase.makeValid(name3SCCase(caseId, scheme), scramble); + } + + private static boolean isValidAxis(String axis) { + return "U".equals(axis) + || "F".equals(axis) + || "R".equals(axis) + || "B".equals(axis) + || "L".equals(axis) + || "D".equals(axis); + } + + /** + * Choose a user-friendly name for a 3-style corners case. + * + * @param caseId The 2-character case ID (in the user's chosen letter scheme). + * @param scheme The user's letter scheme. + * @return The name to show the user. + */ + private static String name3SCCase(@NotNull String caseId, LetterScheme scheme) { + if (caseId.length() != 2) { + String msg = String.format( + Locale.US, + "Expected a 2-letter case name, but got %d letters.", + caseId.length()); + throw new IllegalArgumentException(msg); + } + String speffzCase = scheme.toSpeffz(caseId).toUpperCase(); + + Character speffzWhiteOrYellowSticker = nameSpeffzCornerTwist(speffzCase); + if (speffzWhiteOrYellowSticker != null) { + char whiteOrYellowSticker = scheme.fromSpeffz(speffzWhiteOrYellowSticker); + String twist = Prefs.getString(R.string.corner_twist_case_prefix, "@"); + return String.format("%s%c", twist, whiteOrYellowSticker); + } + + return caseId; + } + + /** + * If this is a corner twist case, find the white or yellow sticker (assuming white and yellow + * are the top and bottom colours, respectively). + * + * @param speffzCase The case as a 2-character string in the Speffz letter scheme. + * @return The white or yellow sticker in the Speffz letter scheme. + */ + private static Character nameSpeffzCornerTwist(String speffzCase) { + String[] corners = {"ARE", "BQN", "CJM", "DIF", "ULG", "VKP", "WTO", "XSH"}; + for (String corner : corners) { + // e.g., "BQNB" + String forwardCycle = corner + corner.charAt(0); + if (forwardCycle.contains(speffzCase)) { + return forwardCycle.charAt(2); + } + // e.g., "BNQB" + String backwardCycle = new StringBuilder(forwardCycle).reverse().toString(); + if (backwardCycle.contains(speffzCase)) { + return backwardCycle.charAt(2); + } + } + return null; + } + + /** + * Find an algorithm which solves the given case. + */ private static String fetchCaseAlgorithm(Context context, String subset, String name) { Resources resources = context.getResources(); // Finds an algorithm resource with a matching name on the file trainer_scrambles.xml - try { // Find the resource int resId = resources.getIdentifier( @@ -152,7 +445,7 @@ private static String fetchCaseAlgorithm(Context context, String subset, String context.getPackageName() ); - // Split the resouce entries + // Split the resource entries String[] res = resources.getStringArray(resId); // Return one of the entries @@ -163,7 +456,4 @@ private static String fetchCaseAlgorithm(Context context, String subset, String return "U"; } - } - - diff --git a/app/src/main/java/com/aricneto/twistytimer/utils/PuzzleUtils.java b/app/src/main/java/com/aricneto/twistytimer/utils/PuzzleUtils.java index 2f707fe5..552fc503 100644 --- a/app/src/main/java/com/aricneto/twistytimer/utils/PuzzleUtils.java +++ b/app/src/main/java/com/aricneto/twistytimer/utils/PuzzleUtils.java @@ -387,44 +387,135 @@ private static String replaceAll(String str, HashMap map) { return rotated.toString(); } - // returns new string with transformed algorithm. - // Returnes sequence of moves that get the cube to the same position as (alg + rot) does, but without cube rotations. - // Example: applyRotationForAlgorithm("R U R'", "y") = "F U F'" + /** + * Returns the sequence of moves that get the cube to the same position as (alg + rot) does, but without cube rotations. + * Example: applyRotationForAlgorithm("R U R'", "y") = "F U F'". + */ public static String applyRotationForAlgorithm(String alg, String rot) { - HashMap map; + rot = (rot == null ? "" : rot).trim(); + if (rot.isEmpty()) { + return alg; + } + HashMap map = new HashMap<>(); switch (rot) { + case "x": + map.put("U", "B"); + map.put("F", "U"); + map.put("B", "D"); + map.put("D", "F"); + break; + case "x'": + map.put("U", "F"); + map.put("F", "D"); + map.put("B", "U"); + map.put("D", "B"); + break; + case "x2": + map.put("U", "D"); + map.put("F", "B"); + map.put("D", "U"); + map.put("B", "F"); + break; case "y": - map = new HashMap() {{ - put("R", "F"); - put("F", "L"); - put("L", "B"); - put("B", "R"); - }}; + map.put("R", "F"); + map.put("F", "L"); + map.put("L", "B"); + map.put("B", "R"); break; case "y'": - map = new HashMap() {{ - put("R", "B"); - put("B", "L"); - put("L", "F"); - put("F", "R"); - }}; + map.put("R", "B"); + map.put("B", "L"); + map.put("L", "F"); + map.put("F", "R"); break; case "y2": - map = new HashMap() {{ - put("R", "L"); - put("L", "R"); - put("B", "F"); - put("F", "B"); - }}; + map.put("R", "L"); + map.put("L", "R"); + map.put("B", "F"); + map.put("F", "B"); + break; + case "z": + map.put("U", "R"); + map.put("R", "D"); + map.put("D", "L"); + map.put("L", "U"); + break; + case "z'": + map.put("U", "L"); + map.put("R", "U"); + map.put("D", "R"); + map.put("L", "D"); + break; + case "z2": + map.put("U", "D"); + map.put("R", "L"); + map.put("D", "U"); + map.put("L", "R"); break; default: - return alg; + throw new IllegalArgumentException(String.format("Invalid rotation: '%s'", rot)); } - return replaceAll(alg, map); } - /** + /** + * Returns the sequence of moves that get the cube to the same position as (alg + rot) does, but without cube rotations. + * Example: applyRotationForAlgorithm("R U R'", "y z'") = "F L F'". + * + * @param alg Any algorithm. + * @param rotations A whitespace-separated sequence of only whole-cube rotations (x, y', z2, etc.). + */ + public static String applyRotationsForAlgorithm(String alg, String rotations) { + if ((rotations == null ? "" : rotations).trim().isEmpty()) { + return alg; + } + for (String r : rotations.split("\\s+")) { + alg = applyRotationForAlgorithm(alg, r); + } + return alg; + } + + public static String invertRotations(String rotations) { + if ((rotations == null ? "" : rotations).trim().isEmpty()) { + return ""; + } + StringBuilder builder = new StringBuilder(); + String[] moves = rotations.split("\\s+"); + for (int i = moves.length - 1; i >= 0; i--) { + builder.append(invertRotation(moves[i])); + if (i > 0) { + builder.append(" "); + } + } + return builder.toString(); + } + + private static String invertRotation(String rot) { + switch (rot) { + case "x": + return "x'"; + case "x'": + return "x"; + case "x2": + return "x2"; + case "y": + return "y'"; + case "y'": + return "y"; + case "y2": + return "y2"; + case "z": + return "z'"; + case "z'": + return "z"; + case "z2": + return "z2"; + default: + throw new IllegalArgumentException(String.format("'%s' is not a valid rotation.", rot)); + } + } + + /** * Shares an average-of-N, formatted to a simple string. * * @param n diff --git a/app/src/main/res/drawable-hdpi/ic_3style_corners_black_24dp.png b/app/src/main/res/drawable-hdpi/ic_3style_corners_black_24dp.png new file mode 100644 index 00000000..845a7a3f Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_3style_corners_black_24dp.png differ diff --git a/app/src/main/res/drawable/ic_outline_control_camera_teal_24px.xml b/app/src/main/res/drawable/ic_outline_control_camera_teal_24px.xml new file mode 100644 index 00000000..aeec30f4 --- /dev/null +++ b/app/src/main/res/drawable/ic_outline_control_camera_teal_24px.xml @@ -0,0 +1,12 @@ + + + + diff --git a/app/src/main/res/layout-land/fragment_timer.xml b/app/src/main/res/layout-land/fragment_timer.xml index c011f17f..a365a3a3 100644 --- a/app/src/main/res/layout-land/fragment_timer.xml +++ b/app/src/main/res/layout-land/fragment_timer.xml @@ -54,6 +54,22 @@ android:visibility="gone" tools:visibility="visible" /> + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/fragment_timer.xml b/app/src/main/res/layout/fragment_timer.xml index 007e490b..0b05c0fb 100644 --- a/app/src/main/res/layout/fragment_timer.xml +++ b/app/src/main/res/layout/fragment_timer.xml @@ -1,10 +1,11 @@ @@ -17,15 +18,15 @@ + tools:visibility="gone" /> + android:layout_marginTop="?actionBarPadding" + android:layout_marginRight="8dp" /> + + + android:textIsSelectable="false" + android:textSize="76sp" + app:autoSizeMaxTextSize="90sp" + app:autoSizeTextType="uniform" /> @@ -101,20 +118,20 @@ + android:paddingBottom="8dp" + android:tint="?colorTimerText" + android:visibility="gone" + app:srcCompat="@drawable/ic_outline_undo_24px" + tools:visibility="visible" /> + android:visibility="gone" + tools:visibility="visible" /> @@ -195,8 +212,8 @@ android:layout_width="match_parent" android:layout_height="match_parent" android:layout_below="@id/scramble_box" - tools:visibility="gone" - android:visibility="gone" /> + android:visibility="gone" + tools:visibility="gone" /> diff --git a/app/src/main/res/values/pref_keys.xml b/app/src/main/res/values/pref_keys.xml index 6e7693af..7ab2ef6a 100644 --- a/app/src/main/res/values/pref_keys.xml +++ b/app/src/main/res/values/pref_keys.xml @@ -53,6 +53,9 @@ THESE KEYS MUST NOT BE LOCALIZED! pk_stat_discrete_graph_dataset timerAppearance + trainerBehavior + cornerLetterScheme + cornerBuffer prefScreenOtherGeneral prefScreenOtherList prefScreenOtherStatistics diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index c7877974..1703ab5c 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,4 +1,7 @@ + +]> Add Archive @@ -103,6 +106,7 @@ OLL Other PLL + 3-style corners Reference Timer @@ -222,6 +226,7 @@ Timer Inspection behavior Timer behavior + Trainer behavior Timer appearance Scramble background @@ -434,4 +439,33 @@ Ascending Descending + %1$d cases selected + Lettering scheme used to generate cases for blindfolded training + + UBL UBR UFR UFL + \nLBU LFU LFD LBD + \nFLU FRU FRD FLD + \nRFU RBU RBD RFD + \nBRU BLU BLD BRD + \nDLF DRF DRB DLB + + 3BLD corner letter scheme + ABCDEFGHIJKLMNOPQRSTUVWX + Buffer used to generate cases for blindfolded training + 3BLD corner buffer + UFR + Invalid letter scheme + Invalid sticker + The selected letter scheme is invalid. Please select a valid one in the trainer settings. + The selected corner buffer is invalid. Please select a valid one in the trainer settings. + Enter a regular expression that matches the cases you want to select. + + \n\nNormal cases are pairs of letters, like AB or LH. + For example, the regular expression \"A|LH\" (without quotation marks) matches all cases starting or ending with A (AB, DA, etc.) as well as LH. + + \n\nCorner twist cases are \"&corner_twist_case_prefix;\" followed by the white or yellow sticker (assuming white and yellow are the top and bottom colors). + For example, the regular expression \"&corner_twist_case_prefix;E\" (without quotation marks) matches the corner twist case that rotates E to A, assuming E is LUB and A is UBL in your letter scheme. + + &corner_twist_case_prefix; + diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml index ea2b9b34..f5e8a3ae 100644 --- a/app/src/main/res/values/styles.xml +++ b/app/src/main/res/values/styles.xml @@ -122,7 +122,7 @@ #0062ff -