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
-