diff --git a/Icons/cauldron-svgrepo-com.svg b/Icons/cauldron-svgrepo-com.svg new file mode 100644 index 00000000..4e623770 --- /dev/null +++ b/Icons/cauldron-svgrepo-com.svg @@ -0,0 +1,36 @@ + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/build.gradle b/app/build.gradle index f4eb5b3b..d7ab3794 100755 --- a/app/build.gradle +++ b/app/build.gradle @@ -1,5 +1,5 @@ apply plugin: 'com.android.application' -apply plugin: 'placeholder-resolver' +apply plugin: 'com.likethesalad.stem' android { signingConfigs { @@ -21,8 +21,8 @@ android { signingConfig signingConfigs.release } compileOptions { - sourceCompatibility JavaVersion.VERSION_1_8 - targetCompatibility JavaVersion.VERSION_1_8 + sourceCompatibility JavaVersion.VERSION_11 + targetCompatibility JavaVersion.VERSION_11 } buildTypes { release { @@ -39,29 +39,38 @@ android { viewBinding true dataBinding true } - stringXmlReference { - keepResolvedFiles = true - } testOptions { unitTests { includeAndroidResources = true } } - lintOptions { + lint { abortOnError false } + namespace 'dnd.jon.spellbook' } dependencies { + implementation 'androidx.navigation:navigation-runtime:2.5.2' + constraints { + implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.8.0") { + because 'align all versions of Kotlin transitive dependencies' + } + } + def nav_version = "2.6.0" + implementation fileTree(include: ['*.jar'], dir: 'libs') - implementation 'androidx.core:core:1.6.0' - implementation 'androidx.appcompat:appcompat:1.3.1' - implementation 'androidx.fragment:fragment:1.4.0' - implementation 'androidx.lifecycle:lifecycle-livedata:2.3.1' + implementation 'androidx.core:core:1.10.1' + implementation 'androidx.appcompat:appcompat:1.6.1' + implementation 'androidx.fragment:fragment:1.6.1' + implementation 'androidx.lifecycle:lifecycle-livedata:2.6.2' + implementation "androidx.navigation:navigation-fragment:$nav_version" + implementation "androidx.navigation:navigation-ui:$nav_version" + //implementation 'androidx.constraintlayout:constraintlayout:1.1.3' - implementation 'androidx.constraintlayout:constraintlayout:2.0.4' - implementation 'androidx.preference:preference:1.1.1' - implementation 'com.google.android.material:material:1.4.0' + implementation 'androidx.constraintlayout:constraintlayout:2.1.4' + implementation 'androidx.preference:preference:1.2.1' + implementation 'com.google.android.material:material:1.9.0' implementation "androidx.gridlayout:gridlayout:1.0.0" implementation "androidx.swiperefreshlayout:swiperefreshlayout:1.1.0" implementation 'com.github.deano2390:MaterialShowcaseView:1.3.4' @@ -72,13 +81,17 @@ dependencies { implementation 'com.github.kizitonwose.colorpreference:support:1.1.0' implementation 'com.github.skydoves:colorpickerview:2.2.4' implementation 'io.github.cdimascio:dotenv-java:2.2.4' + implementation "com.leinardi.android:speed-dial:3.3.0" //implementation 'org.sufficientlysecure:html-textview:4.0' + testImplementation 'junit:junit:4.13.1' - testImplementation 'androidx.test:core:1.4.0' + testImplementation 'androidx.test:core:1.5.0' testImplementation 'com.google.truth:truth:1.1.2' testImplementation 'org.robolectric:robolectric:4.4' testImplementation 'org.json:json:20180813' - androidTestImplementation 'androidx.test.ext:junit:1.1.3' - androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0' + androidTestImplementation 'androidx.test.ext:junit:1.1.5' + androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1' implementation files('libs/commons-lang3-3.8.jar') } + + diff --git a/app/release/app-release.aab b/app/release/app-release.aab new file mode 100644 index 00000000..3ea3d45c Binary files /dev/null and b/app/release/app-release.aab differ diff --git a/app/src/debug/res/drawable-anydpi/ic_add_white.xml b/app/src/debug/res/drawable-anydpi/ic_add_white.xml new file mode 100644 index 00000000..672478e3 --- /dev/null +++ b/app/src/debug/res/drawable-anydpi/ic_add_white.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/debug/res/drawable-anydpi/ic_close_white.xml b/app/src/debug/res/drawable-anydpi/ic_close_white.xml new file mode 100644 index 00000000..6d8f2825 --- /dev/null +++ b/app/src/debug/res/drawable-anydpi/ic_close_white.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/debug/res/drawable-anydpi/ic_delete.xml b/app/src/debug/res/drawable-anydpi/ic_delete.xml new file mode 100644 index 00000000..18b7e774 --- /dev/null +++ b/app/src/debug/res/drawable-anydpi/ic_delete.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/debug/res/drawable-hdpi/ic_add_white.png b/app/src/debug/res/drawable-hdpi/ic_add_white.png new file mode 100644 index 00000000..9a20be84 Binary files /dev/null and b/app/src/debug/res/drawable-hdpi/ic_add_white.png differ diff --git a/app/src/debug/res/drawable-hdpi/ic_close_white.png b/app/src/debug/res/drawable-hdpi/ic_close_white.png new file mode 100644 index 00000000..8a4491ab Binary files /dev/null and b/app/src/debug/res/drawable-hdpi/ic_close_white.png differ diff --git a/app/src/debug/res/drawable-hdpi/ic_delete.png b/app/src/debug/res/drawable-hdpi/ic_delete.png new file mode 100644 index 00000000..bde3d479 Binary files /dev/null and b/app/src/debug/res/drawable-hdpi/ic_delete.png differ diff --git a/app/src/debug/res/drawable-mdpi/ic_add_white.png b/app/src/debug/res/drawable-mdpi/ic_add_white.png new file mode 100644 index 00000000..be6d6259 Binary files /dev/null and b/app/src/debug/res/drawable-mdpi/ic_add_white.png differ diff --git a/app/src/debug/res/drawable-mdpi/ic_close_white.png b/app/src/debug/res/drawable-mdpi/ic_close_white.png new file mode 100644 index 00000000..81f85b02 Binary files /dev/null and b/app/src/debug/res/drawable-mdpi/ic_close_white.png differ diff --git a/app/src/debug/res/drawable-mdpi/ic_delete.png b/app/src/debug/res/drawable-mdpi/ic_delete.png new file mode 100644 index 00000000..f5f1e1eb Binary files /dev/null and b/app/src/debug/res/drawable-mdpi/ic_delete.png differ diff --git a/app/src/debug/res/drawable-xhdpi/ic_add_white.png b/app/src/debug/res/drawable-xhdpi/ic_add_white.png new file mode 100644 index 00000000..6eedadfb Binary files /dev/null and b/app/src/debug/res/drawable-xhdpi/ic_add_white.png differ diff --git a/app/src/debug/res/drawable-xhdpi/ic_close_white.png b/app/src/debug/res/drawable-xhdpi/ic_close_white.png new file mode 100644 index 00000000..538ddf1a Binary files /dev/null and b/app/src/debug/res/drawable-xhdpi/ic_close_white.png differ diff --git a/app/src/debug/res/drawable-xhdpi/ic_delete.png b/app/src/debug/res/drawable-xhdpi/ic_delete.png new file mode 100644 index 00000000..13057cbf Binary files /dev/null and b/app/src/debug/res/drawable-xhdpi/ic_delete.png differ diff --git a/app/src/debug/res/drawable-xxhdpi/ic_add_white.png b/app/src/debug/res/drawable-xxhdpi/ic_add_white.png new file mode 100644 index 00000000..7a27534d Binary files /dev/null and b/app/src/debug/res/drawable-xxhdpi/ic_add_white.png differ diff --git a/app/src/debug/res/drawable-xxhdpi/ic_close_white.png b/app/src/debug/res/drawable-xxhdpi/ic_close_white.png new file mode 100644 index 00000000..f45eedf3 Binary files /dev/null and b/app/src/debug/res/drawable-xxhdpi/ic_close_white.png differ diff --git a/app/src/debug/res/drawable-xxhdpi/ic_delete.png b/app/src/debug/res/drawable-xxhdpi/ic_delete.png new file mode 100644 index 00000000..ee98d4cd Binary files /dev/null and b/app/src/debug/res/drawable-xxhdpi/ic_delete.png differ diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index b2c8b0a0..6f8eee0b 100755 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -1,6 +1,5 @@ - + screenDimensions(Activity activity) { + final DisplayMetrics displayMetrics = new DisplayMetrics(); + activity.getWindowManager().getDefaultDisplay().getMetrics(displayMetrics); + return new Pair<>(displayMetrics.widthPixels, displayMetrics.heightPixels); + } +} diff --git a/app/src/main/java/dnd/jon/spellbook/CastingTime.java b/app/src/main/java/dnd/jon/spellbook/CastingTime.java index 18652569..aee125de 100755 --- a/app/src/main/java/dnd/jon/spellbook/CastingTime.java +++ b/app/src/main/java/dnd/jon/spellbook/CastingTime.java @@ -66,6 +66,9 @@ public static CastingTimeType fromInternalName(String name) { // Convenience constructors CastingTime(CastingTimeType type, float value, TimeUnit unit, String str) { super(type, value, unit, str); } + CastingTime(CastingTimeType type, float value, TimeUnit unit) { super(type, value, unit); } + CastingTime(CastingTimeType type, float value) { super(type, value, TimeUnit.SECOND); } + CastingTime(CastingTimeType type) { this(type, 1); } CastingTime() { this(CastingTimeType.ACTION, 1, TimeUnit.SECOND, ""); } // For Parcelable @@ -83,17 +86,13 @@ private CastingTime(Parcel in) { String makeString(boolean useStored, Function typeNameGetter, Function unitSingularNameGetter, Function unitPluralNameGetter) { if (useStored && !str.isEmpty()) { return str; } final String name = typeNameGetter.apply(type); - final String valueString = DisplayUtils.DECIMAL_FORMAT.format(value); if (type == CastingTimeType.TIME) { + final String valueString = DisplayUtils.DECIMAL_FORMAT.format(value); final Function unitNameGetter = (value == 1) ? unitSingularNameGetter : unitPluralNameGetter; final String unitStr = unitNameGetter.apply(unit); return valueString + " " + unitStr; } else { - String typeStr = name; - if (value != 1) { - typeStr += "s"; - } - return valueString + " " + typeStr; + return name; } } @@ -108,22 +107,27 @@ String internalString() { // Create a range from a string static CastingTime fromString(String s, Function typeNameGetter, Function timeUnitMaker, boolean useForStr) { try { + String[] sSplit = s.split(" ", 2); - final float value = Float.parseFloat(sSplit[0]); - final String typeStr = sSplit[1]; - //System.out.println("sSplit0: " + sSplit[0]); - //System.out.println("sSplit1: " + sSplit[1]); + float value = 1; + String typeStr = ""; + try { + value = Float.parseFloat(sSplit[0]); + typeStr = sSplit[1]; + } catch (NumberFormatException e) { + e.printStackTrace(); + } // If the type is one of the action types CastingTimeType type = null; for (CastingTimeType ct : CastingTimeType.actionTypes) { - if (typeStr.startsWith(typeNameGetter.apply(ct))) { + final String typeName = typeNameGetter.apply(ct); + if (s.startsWith(typeName) || typeStr.startsWith(typeName)) { type = ct; break; } } if (type != null) { - //final int inRounds = value * SECONDS_PER_ROUND; final String str = useForStr ? s : ""; return new CastingTime(type, 1, TimeUnit.SECOND, str); } diff --git a/app/src/main/java/dnd/jon/spellbook/CenterReveal.java b/app/src/main/java/dnd/jon/spellbook/CenterReveal.java index 0c2dfe65..89d903ac 100644 --- a/app/src/main/java/dnd/jon/spellbook/CenterReveal.java +++ b/app/src/main/java/dnd/jon/spellbook/CenterReveal.java @@ -12,17 +12,15 @@ public class CenterReveal { private final View view; - private final View container; - private ObjectAnimator viewTranslation; - private ObjectAnimator viewAlpha; - private ObjectAnimator viewScale; - private ObjectAnimator containerAlpha; + private final ObjectAnimator viewTranslation; + private final ObjectAnimator viewAlpha; + private final ObjectAnimator viewScale; + private final ObjectAnimator containerAlpha; private static final long duration = 150L; CenterReveal(View view, View container) { this.view = view; - this.container = container; final ViewGroup parent = (ViewGroup) view.getParent(); final ViewGroup.MarginLayoutParams marginLayoutParams = (ViewGroup.MarginLayoutParams) view.getLayoutParams(); final float cX = marginLayoutParams.getMarginEnd() + view.getWidth() / 2f - parent.getWidth() / 2f; diff --git a/app/src/main/java/dnd/jon/spellbook/CharacterAdapter.java b/app/src/main/java/dnd/jon/spellbook/CharacterAdapter.java index bd5c1e8b..3be085e5 100644 --- a/app/src/main/java/dnd/jon/spellbook/CharacterAdapter.java +++ b/app/src/main/java/dnd/jon/spellbook/CharacterAdapter.java @@ -6,6 +6,7 @@ import android.content.Intent; import android.os.Bundle; import android.view.LayoutInflater; +import android.view.MenuItem; import android.view.View; import android.view.ViewGroup; import android.widget.PopupMenu; @@ -20,6 +21,10 @@ public class CharacterAdapter extends NamedItemAdapter { + private static final String confirmDeleteTag = "confirmDeleteCharacter"; + private static final String duplicateTag = "duplicateCharacter"; + private static final String renameTag = "changeCharacterName"; + CharacterAdapter(FragmentActivity fragmentActivity) { super(fragmentActivity, SpellbookViewModel::currentCharacterNames); } @@ -47,26 +52,30 @@ public void bind(String name) { binding.optionsButton.setOnClickListener((View v) -> { final PopupMenu popupMenu = new PopupMenu(activity, binding.optionsButton); popupMenu.inflate(R.menu.options_menu); + final MenuItem updateItem = popupMenu.getMenu().findItem(R.id.options_update); + if (updateItem != null) { + updateItem.setTitle(R.string.rename); + } popupMenu.setOnMenuItemClickListener((menuItem) -> { final int itemID = menuItem.getItemId(); - if (itemID == R.id.options_rename) { + if (itemID == R.id.options_update) { final Bundle args = new Bundle(); args.putString(NameChangeDialog.nameKey, binding.getName()); final CharacterNameChangeDialog dialog = new CharacterNameChangeDialog(); dialog.setArguments(args); - dialog.show(activity.getSupportFragmentManager(), "changeCharacterName"); + dialog.show(activity.getSupportFragmentManager(), renameTag); } else if (itemID == R.id.options_duplicate) { final Bundle args = new Bundle(); args.putParcelable(CreateCharacterDialog.PROFILE_KEY, viewModel.getProfileByName(binding.getName())); final CreateCharacterDialog dialog = new CreateCharacterDialog(); dialog.setArguments(args); - dialog.show(activity.getSupportFragmentManager(), "duplicateCharacter"); + dialog.show(activity.getSupportFragmentManager(), duplicateTag); } else if (itemID == R.id.options_delete) { final Bundle args = new Bundle(); args.putString(DeleteCharacterDialog.NAME_KEY, binding.getName()); final DeleteCharacterDialog dialog = new DeleteCharacterDialog(); dialog.setArguments(args); - dialog.show(activity.getSupportFragmentManager(), "confirmDeleteCharacter"); + dialog.show(activity.getSupportFragmentManager(), confirmDeleteTag); } else if (itemID == R.id.options_export) { // String permissionNeeded; // if (GlobalInfo.ANDROID_VERSION >= Build.VERSION_CODES.R) { diff --git a/app/src/main/java/dnd/jon/spellbook/CharacterProfile.java b/app/src/main/java/dnd/jon/spellbook/CharacterProfile.java index adda28f5..3d1b6906 100755 --- a/app/src/main/java/dnd/jon/spellbook/CharacterProfile.java +++ b/app/src/main/java/dnd/jon/spellbook/CharacterProfile.java @@ -215,7 +215,6 @@ public JSONObject toJSON() throws JSONException { // Construct a profile from a JSON object // Basically the inverse to toJSON static CharacterProfile fromJSON(JSONObject json) throws JSONException { - //System.out.println(json.toString(4)); if (json.has(versionCodeKey)) { final String versionCode = json.getString(versionCodeKey); final Version version = SpellbookUtils.coalesce(Version.fromString(versionCode), GlobalInfo.VERSION); @@ -356,7 +355,7 @@ private static CharacterProfile fromJSONNew(JSONObject json, Version version) th final EnumSet visibleCasterClasses = visibleSetFromLegacyJSON(json, CasterClass.class); final EnumSet visibleSchools = visibleSetFromLegacyJSON(json, School.class); - final Set visibleSources = visibleSetFromLegacyJSON(json, Source.class, Source.values()); + final Set visibleSources = visibleSetFromLegacyJSON(json, Source.class, Source.sourcebooks()); final EnumSet visibleCastingTimeTypes = visibleSetFromLegacyJSON(json, CastingTimeType.class); final EnumSet visibleDurationTypes = visibleSetFromLegacyJSON(json, DurationType.class); final EnumSet visibleRangeTypes = visibleSetFromLegacyJSON(json, RangeType.class); @@ -463,7 +462,7 @@ private static CharacterProfile fromJSONPre2_10(JSONObject json) throws JSONExce final EnumSet visibleCasterClasses = visibleSetFromLegacyJSON(json, CasterClass.class); final EnumSet visibleSchools = visibleSetFromLegacyJSON(json, School.class); - final Set visibleSources = visibleSetFromLegacyJSON(json, Source.class, Source.values()); + final Set visibleSources = visibleSetFromLegacyJSON(json, Source.class, Source.sourcebooks()); final EnumSet visibleCastingTimeTypes = visibleSetFromLegacyJSON(json, CastingTimeType.class); final EnumSet visibleDurationTypes = visibleSetFromLegacyJSON(json, DurationType.class); final EnumSet visibleRangeTypes = visibleSetFromLegacyJSON(json, RangeType.class); diff --git a/app/src/main/java/dnd/jon/spellbook/DefaultSpinnerAdapter.java b/app/src/main/java/dnd/jon/spellbook/DefaultSpinnerAdapter.java index 5a11b517..540f6023 100644 --- a/app/src/main/java/dnd/jon/spellbook/DefaultSpinnerAdapter.java +++ b/app/src/main/java/dnd/jon/spellbook/DefaultSpinnerAdapter.java @@ -87,7 +87,7 @@ private View getSpinnerRow(int position, ViewGroup parent) { int itemIndex(T item) { final String itemName = textFunction.apply(context, item); final int index = itemStrings.indexOf(itemName); - return (index == -1) ? index : 0; + return (index != -1) ? index : 0; } T[] getData() { diff --git a/app/src/main/java/dnd/jon/spellbook/DeleteNamedItemDialog.java b/app/src/main/java/dnd/jon/spellbook/DeleteNamedItemDialog.java index 9365f5f8..b7cd4c40 100644 --- a/app/src/main/java/dnd/jon/spellbook/DeleteNamedItemDialog.java +++ b/app/src/main/java/dnd/jon/spellbook/DeleteNamedItemDialog.java @@ -22,12 +22,14 @@ class DeleteNamedItemDialog extends DialogFragment { private FragmentActivity activity; private SpellbookViewModel viewModel; private final int typeNameID; - private final BiFunction deleter; + private final BiFunction deleter; + private Runnable onCancel = null; + private Runnable onConfirm = null; static final String NAME_KEY = "name"; public DeleteNamedItemDialog(int typeNameID, - BiFunction deleter) { + BiFunction deleter) { this.typeNameID = typeNameID; this.deleter = deleter; } @@ -70,14 +72,22 @@ public Dialog onCreateDialog(Bundle savedInstanceState) { final String typeName = activity.getString(typeNameID); toastMessage = activity.getString(R.string.item_deleted, typeName, name); } else { - toastMessage = activity.getString(R.string.error_deleting); + toastMessage = activity.getString(R.string.error_deleting, name); } Toast.makeText(activity, toastMessage, Toast.LENGTH_SHORT).show(); this.dismiss(); + if (this.onConfirm != null) { + this.onConfirm.run(); + } }; // The listener to cancel; for the no button - final View.OnClickListener noListener = (v) -> this.dismiss(); + final View.OnClickListener noListener = (v) -> { + if (this.onCancel != null) { + this.onCancel.run(); + } + this.dismiss(); + }; // Set the button listeners binding.yesButton.setOnClickListener(yesListener); @@ -86,6 +96,18 @@ public Dialog onCreateDialog(Bundle savedInstanceState) { // Return the dialog return builder.create(); } + + void setOnCancel(Runnable runnable) { + this.onCancel = runnable; + } + + void setOnConfirm(Runnable runnable) { + this.onConfirm = runnable; + } + + SpellbookViewModel getViewModel() { + return viewModel; + } } class DeleteStatusDialog extends DeleteNamedItemDialog { diff --git a/app/src/main/java/dnd/jon/spellbook/DeleteSourceDialog.java b/app/src/main/java/dnd/jon/spellbook/DeleteSourceDialog.java new file mode 100644 index 00000000..ee9ce7ea --- /dev/null +++ b/app/src/main/java/dnd/jon/spellbook/DeleteSourceDialog.java @@ -0,0 +1,16 @@ +package dnd.jon.spellbook; + +public class DeleteSourceDialog extends DeleteNamedItemDialog { + + private static boolean deleteSource(SpellbookViewModel viewModel, String name) { + final Source source = Source.fromInternalName(name); + return viewModel.deleteSourceByNameOrCode(source.getCode()); + } + + // We want the name to be what gets used in the dialog text + // but we actually store created sources by code, + // so we need this intermediate function + public DeleteSourceDialog() { + super(R.string.source_capitalized, DeleteSourceDialog::deleteSource); + } +} diff --git a/app/src/main/java/dnd/jon/spellbook/DeleteSpellDialog.java b/app/src/main/java/dnd/jon/spellbook/DeleteSpellDialog.java new file mode 100644 index 00000000..beb86758 --- /dev/null +++ b/app/src/main/java/dnd/jon/spellbook/DeleteSpellDialog.java @@ -0,0 +1,7 @@ +package dnd.jon.spellbook; + +public class DeleteSpellDialog extends DeleteNamedItemDialog { + public DeleteSpellDialog() { + super(R.string.spell, SpellbookViewModel::deleteSpellByName); + } +} diff --git a/app/src/main/java/dnd/jon/spellbook/DisplayUtils.java b/app/src/main/java/dnd/jon/spellbook/DisplayUtils.java index 444d810d..0b3b4221 100644 --- a/app/src/main/java/dnd/jon/spellbook/DisplayUtils.java +++ b/app/src/main/java/dnd/jon/spellbook/DisplayUtils.java @@ -7,12 +7,15 @@ import java.util.Arrays; import java.util.Collection; import java.util.Map; +import java.util.Objects; +import java.util.SortedSet; import java.util.function.BiFunction; import java.util.function.Function; import java.util.function.ToIntFunction; public class DisplayUtils { + // TODO: Is constructing a DecimalFormat like this Locale-aware? static final DecimalFormat DECIMAL_FORMAT = new DecimalFormat("0.#"); ///// General functions @@ -46,12 +49,13 @@ public static R getProperty(Context context, T item, Function r } ///// Display names + static String[] getDisplayNames(Context context, T[] items, BiFunction idGetter) { + return Arrays.stream(items).map((t) -> idGetter.apply(context, t)).toArray(String[]::new); + } static > String[] getDisplayNames(Context context, Class enumType, BiFunction idGetter) { final E[] es = enumType.getEnumConstants(); if (es == null) { return null; } - final String[] names = Arrays.stream(es).map((e) -> idGetter.apply(context, e)).toArray(String[]::new); - Arrays.sort(names); - return names; + return getDisplayNames(context, es, idGetter); } static & NameDisplayable> String[] getDisplayNames(Context context, Class enumType) { @@ -59,6 +63,10 @@ static & NameDisplayable> String[] getDisplayNames(Context co } public static String getDisplayName(Context context, T item) { + // Kinda hacky! + if (item instanceof Source) { + return getDisplayName((Source) item, context); + } return getProperty(context, item, NameDisplayable::getDisplayNameID, Context::getString); } @@ -92,10 +100,19 @@ public static String locationString(Context context, Spell spell) { final String[] locationStrings = new String[locations.size()]; int i = 0; for (Map.Entry entry: locations.entrySet()) { - final String sbString = context.getString(entry.getKey().getCodeID()); - locationStrings[i++] = sbString + " " + entry.getValue(); + String sbString = DisplayUtils.getCode(entry.getKey(), context); + final int page = entry.getValue(); + if (page > 0) { + sbString += " " + page; + } + locationStrings[i++] = sbString; + } + final String locationString = TextUtils.join(", ", locationStrings); + if (locations.size() > 0) { + return locationString; + } else { + return context.getString(R.string.none); } - return TextUtils.join(", ", locationStrings); } public static String sourcebooksString(Context context, Spell spell) { @@ -104,7 +121,7 @@ public static String sourcebooksString(Context context, Spell spell) { final String[] locationStrings = new String[locations.size()]; int i = 0; for (Map.Entry entry: locations.entrySet()) { - locationStrings[i++] = context.getString(entry.getKey().getCodeID()); + locationStrings[i++] = DisplayUtils.getCode(entry.getKey(), context); } return TextUtils.join(", ", locationStrings); } @@ -182,7 +199,6 @@ static Range rangeFromString(Context context, String s) { return Range.fromString(s, (t) -> getDisplayName(context, t), (us) -> unitFromString(context, LengthUnit.class, us)); } - // Spell prompt text public static String locationPrompt(Context context, int nLocations) { return context.getString(nLocations == 1 ? R.string.location : R.string.location); @@ -198,4 +214,51 @@ public static String locationPrompt(Context context, int nLocations) { public static String tceExpandedClassesPrompt(Context context) { return context.getString(R.string.tce_expanded_classes); } public static String descriptionPrompt(Context context) { return context.getString(R.string.description); } public static String higherLevelsPrompt(Context context) { return context.getString(R.string.higher_level); } + + ///// Sources + static String getDisplayName(Source source, Context context) { + if (source.isCreated()) { + return source.getDisplayName(); + } else { + return getProperty(context, source, Source::getDisplayNameID, Context::getString); + } + } + + static String getCode(Source source, Context context) { + if (source.isCreated()) { + return source.getCode(); + } else { + return getProperty(context, source, Source::getCodeID, Context::getString); + } + } + + static Source sourceFromCode(Context context, String code) { + try { + final Source source = DisplayUtils.getItemFromResourceValue(context, Source.values(), code, Source::getCodeID, Context::getString); + if (source != null) { + return source; + } + throw new Exception(); + } catch (Exception e) { + for (Source source : Source.createdSources()) { + if (source.getCode().equals(code)) { + return source; + } + } + return null; + } + } + + static Source sourceFromDisplayName(Context context, String name) { + try { + return DisplayUtils.getItemFromResourceValue(context, Source.values(), name, Source::getDisplayNameID, Context::getString); + } catch (Exception e) { + for (Source source : Source.createdSources()) { + if (source.getDisplayName().equals(name)) { + return source; + } + } + return null; + } + } } diff --git a/app/src/main/java/dnd/jon/spellbook/Duration.java b/app/src/main/java/dnd/jon/spellbook/Duration.java index a7bd552a..ac544080 100755 --- a/app/src/main/java/dnd/jon/spellbook/Duration.java +++ b/app/src/main/java/dnd/jon/spellbook/Duration.java @@ -51,6 +51,9 @@ public enum DurationType implements QuantityType { // Convenience constructors Duration(DurationType type, float value, TimeUnit unit, String str) { super(type, value, unit, str); } + Duration(DurationType type, float value, TimeUnit unit) { super(type, value, unit); } + Duration(DurationType type, float value) { super(type, value, TimeUnit.SECOND); } + Duration(DurationType type) { this(type, 0); } Duration() { this(DurationType.INSTANTANEOUS, 0, TimeUnit.SECOND, ""); } // For Parcelable diff --git a/app/src/main/java/dnd/jon/spellbook/HomebrewInformationDialog.java b/app/src/main/java/dnd/jon/spellbook/HomebrewInformationDialog.java new file mode 100644 index 00000000..02d20fd7 --- /dev/null +++ b/app/src/main/java/dnd/jon/spellbook/HomebrewInformationDialog.java @@ -0,0 +1,30 @@ +package dnd.jon.spellbook; + +import android.app.AlertDialog; +import android.app.Dialog; +import android.os.Bundle; +import androidx.annotation.NonNull; +import androidx.fragment.app.DialogFragment; +import androidx.fragment.app.FragmentActivity; + +import dnd.jon.spellbook.databinding.HomebrewInformationBinding; + +public class HomebrewInformationDialog extends DialogFragment { + + @Override + @NonNull + public Dialog onCreateDialog(Bundle savedInstanceState) { + super.onCreateDialog(savedInstanceState); + + final FragmentActivity activity = requireActivity(); + final HomebrewInformationBinding binding = HomebrewInformationBinding.inflate(activity.getLayoutInflater()); + + final AlertDialog.Builder builder = new AlertDialog.Builder(activity); + builder.setView(binding.getRoot()); + binding.closeHomebrewInfo.setOnClickListener((v) -> { + this.dismiss(); + }); + return builder.create(); + } + +} diff --git a/app/src/main/java/dnd/jon/spellbook/HomebrewItemsAdapter.java b/app/src/main/java/dnd/jon/spellbook/HomebrewItemsAdapter.java new file mode 100644 index 00000000..7ede95fd --- /dev/null +++ b/app/src/main/java/dnd/jon/spellbook/HomebrewItemsAdapter.java @@ -0,0 +1,160 @@ +package dnd.jon.spellbook; + +import android.content.Context; +import android.view.LayoutInflater; +import android.view.View; +import android.util.Pair; +import android.view.ViewGroup; +import android.widget.BaseExpandableListAdapter; +import android.widget.TextView; + + +import java.text.Collator; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.Comparator; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import dnd.jon.spellbook.databinding.HomebrewSourceHeaderBinding; +import dnd.jon.spellbook.databinding.HomebrewSpellItemBinding; + +public class HomebrewItemsAdapter extends BaseExpandableListAdapter { + + private final Context context; + private final Map> items = new HashMap<>(); + private final List sources = new ArrayList<>(); + + // It's possible to have a spell without a source - we don't allow creating one, + // but one could delete the spell's only source later + private final List spellsWithoutSources = new ArrayList<>(); + private final Comparator spellComparator; + private final Comparator sourceComparator = new Comparator<>() { + final Collator collator = Collator.getInstance(LocalizationUtils.getLocale()); + @Override + public int compare(Source s1, Source s2) { + final String name1 = DisplayUtils.getDisplayName(context, s1); + final String name2 = DisplayUtils.getDisplayName(context, s2); + return collator.compare(name1, name2); + } + }; + + HomebrewItemsAdapter(Context context, Collection createdSpells) { + this.context = context; + this.spellComparator = new SpellComparator(context, List.of(new Pair<>(SortField.NAME, false))); + populateItems(createdSpells); + } + + private void populateItems(Collection createdSpells) { + reset(); + for (Spell spell : createdSpells) { + for (Source source : spell.getSourcebooks()) { + final List spells = items.get(source); + if (spells == null) { + final List spellsList = new ArrayList<>() {{ add(spell); }}; + items.put(source, spellsList); + sources.add(source); + } else { + spells.add(spell); + } + } + if (spell.getLocations().size() == 0) { + spellsWithoutSources.add(spell); + } + } + sources.sort(sourceComparator); + spellsWithoutSources.sort(spellComparator); + } + + void reset() { + items.clear(); + sources.clear(); + spellsWithoutSources.clear(); + } + + void updateSpells(Collection createdSpells) { + populateItems(createdSpells); + notifyDataSetChanged(); + } + + @Override public int getGroupCount() { + int size = items.size(); + if (spellsWithoutSources.size() > 0) { + size += 1; + } + return size; + } + @Override public Object getGroup(int groupPosition) { + if (groupPosition < sources.size()) { + return sources.get(groupPosition); + } else { + return null; + } + } + @Override public long getGroupId(int groupPosition) { return groupPosition; } + @Override public long getChildId(int groupPosition, int childPosition) { return childPosition; } + @Override public boolean isChildSelectable(int groupPosition, int childPosition) { return true; } + @Override public boolean hasStableIds() { return false; } + + @Override + public int getChildrenCount(int position) { + if (position < sources.size()) { + final Collection spells = items.get(sources.get(position)); + return spells != null ? spells.size() : 0; + } else { + return spellsWithoutSources.size(); + } + } + + @Override + public Object getChild(int groupPosition, int childPosition) { + if (groupPosition < sources.size()) { + return items.get(sources.get(groupPosition)).get(childPosition); + } else { + return spellsWithoutSources.get(childPosition); + } + } + + void addSpellForSource(Spell spell, Source source) { + if (!sources.contains(source)) { + sources.add(source); + items.put(source, Arrays.asList(spell)); + sources.sort(sourceComparator); + } else { + items.get(source).add(spell); + } + notifyDataSetChanged(); + } + + @Override + public View getGroupView(int groupPosition, boolean isExpanded, View convertView, ViewGroup parent) { + final Source source = (Source) getGroup(groupPosition); + if (convertView == null) { + final LayoutInflater inflater = (LayoutInflater) context.getSystemService(Context.LAYOUT_INFLATER_SERVICE); + final HomebrewSourceHeaderBinding binding = HomebrewSourceHeaderBinding.inflate(inflater); + convertView = binding.getRoot(); + } + final TextView header = convertView.findViewById(R.id.header); + if (source != null) { + header.setText(DisplayUtils.getDisplayName(source, context)); + } else { + header.setText(R.string.spells_without_sources); + } + return convertView; + } + + @Override + public View getChildView(int groupPosition, int childPosition, boolean isLastChild, View convertView, ViewGroup parent) { + final Spell spell = (Spell) getChild(groupPosition, childPosition); + if (convertView == null) { + final LayoutInflater inflater = (LayoutInflater) context.getSystemService(Context.LAYOUT_INFLATER_SERVICE); + final HomebrewSpellItemBinding binding = HomebrewSpellItemBinding.inflate(inflater); + convertView = binding.getRoot(); + } + final TextView childTV = convertView.findViewById(R.id.submenu); + childTV.setText(spell.getName()); + return convertView; + } +} \ No newline at end of file diff --git a/app/src/main/java/dnd/jon/spellbook/HomebrewManagementFragment.java b/app/src/main/java/dnd/jon/spellbook/HomebrewManagementFragment.java new file mode 100644 index 00000000..3b87c561 --- /dev/null +++ b/app/src/main/java/dnd/jon/spellbook/HomebrewManagementFragment.java @@ -0,0 +1,119 @@ +package dnd.jon.spellbook; + +import android.content.res.Resources; +import android.os.Bundle; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; + +import androidx.annotation.NonNull; +import androidx.fragment.app.DialogFragment; +import androidx.navigation.NavController; +import androidx.navigation.fragment.NavHostFragment; + +import com.leinardi.android.speeddial.SpeedDialActionItem; +import com.leinardi.android.speeddial.SpeedDialView; + +import dnd.jon.spellbook.databinding.HomebrewManagementBinding; + +public class HomebrewManagementFragment extends SpellbookFragment { + + private HomebrewItemsAdapter adapter; + + private static final String SOURCE_CREATION_TAG = "SOURCE_CREATION"; + private static final String SPELL_CREATION_TAG = "SPELL_CREATION"; + private static final String HOMEBREW_INFORMATION_TAG = "HOMEBREW_INFORMATION"; + + @Override + public View onCreateView(@NonNull LayoutInflater inflater, + ViewGroup container, + Bundle savedInstanceState) { + binding = HomebrewManagementBinding.inflate(inflater, container, false); + return binding.getRoot(); + } + + @Override + public void onViewCreated(@NonNull View view, Bundle savedInstanceState) { + super.onViewCreated(view, savedInstanceState); + adapter = new HomebrewItemsAdapter(context, viewModel.currentCreatedSpells().getValue()); + binding.createdItemsEl.setAdapter(adapter); + + // requireActivity().registerForContextMenu(binding.createdItemsEl); + + binding.homebrewHelpButton.setOnClickListener((button) -> { + final HomebrewInformationDialog dialog = new HomebrewInformationDialog(); + dialog.show(requireActivity().getSupportFragmentManager(), HOMEBREW_INFORMATION_TAG); + }); + + // Set up the adapter to open the spell editing window when a child is clicked + binding.createdItemsEl.setOnChildClickListener((elView, vw, gp, cp, id) -> { + final Spell spell = (Spell) adapter.getChild(gp, cp); + viewModel.setCurrentEditingSpell(spell); + openSpellCreationView(); + return true; + }); + + // Update the list of spells whenever a spell or source is added/deleted + viewModel.currentCreatedSpells().observe(getViewLifecycleOwner(), adapter::updateSpells); + viewModel.currentCreatedSources().observe(getViewLifecycleOwner(), (sources) -> adapter.updateSpells(viewModel.currentCreatedSpells().getValue())); + + // Set up the FAB + final SpeedDialView speedDialView = binding.speeddialHomebrewFab; + final Resources.Theme theme = context.getTheme(); + final int darkBrown = getResources().getColor(R.color.darkBrown, theme); + final int transparent = getResources().getColor(android.R.color.transparent, theme); + final int white = getResources().getColor(android.R.color.white, theme); + speedDialView.addActionItem( + new SpeedDialActionItem.Builder(R.id.homebrew_fab_add_source, R.drawable.book_filled) + .setLabel(R.string.homebrew_add_source) + .setLabelBackgroundColor(transparent) + .setLabelColor(white) + .setFabImageTintColor(white) + .setFabBackgroundColor(darkBrown) + .create() + ); + speedDialView.addActionItem( + new SpeedDialActionItem.Builder(R.id.homebrew_fab_add_spell, R.drawable.wand_filled) + .setLabel(R.string.homebrew_add_spell) + .setLabelBackgroundColor(transparent) + .setLabelColor(white) + .setFabImageTintColor(white) + .setFabBackgroundColor(darkBrown) + .create() + ); + + speedDialView.setOnActionSelectedListener(actionItem -> { + boolean handled = false; + if (actionItem.getId() == R.id.homebrew_fab_add_source) { + final SourceCreationDialog dialog = new SourceCreationDialog(); + dialog.show(requireActivity().getSupportFragmentManager(), SOURCE_CREATION_TAG); + handled = true; + } + if (actionItem.getId() == R.id.homebrew_fab_add_spell) { + openSpellCreationView(); + handled = true; + } + if (handled) { + speedDialView.close(); + } + return handled; + }); + + } + + private NavController navController() { + final NavHostFragment navHostFragment = (NavHostFragment) requireActivity().getSupportFragmentManager().findFragmentById(R.id.nav_host_fragment); + return navHostFragment.getNavController(); + } + + private void openSpellCreationView() { + final boolean onTablet = getResources().getBoolean(R.bool.isTablet); + if (onTablet) { + final DialogFragment spellCreationDialog = new SpellCreationDialog(); + spellCreationDialog.show(requireActivity().getSupportFragmentManager(), SPELL_CREATION_TAG); + } else { + navController().navigate(R.id.action_homebrewManagementFragment_to_spellCreationFragment); + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/dnd/jon/spellbook/JSONUtils.java b/app/src/main/java/dnd/jon/spellbook/JSONUtils.java index 5f288c87..7585c1c7 100644 --- a/app/src/main/java/dnd/jon/spellbook/JSONUtils.java +++ b/app/src/main/java/dnd/jon/spellbook/JSONUtils.java @@ -11,15 +11,14 @@ import java.io.IOException; import java.io.InputStream; import java.nio.charset.StandardCharsets; +import java.util.function.Function; import android.content.Context; class JSONUtils { - @FunctionalInterface - public interface ThrowsExceptionFunction { - R apply(T t) throws E; - } + static private final String SOURCE_NAME_KEY = "name"; + static private final String SOURCE_CODE_KEY = "code"; private static String stringFromInputStream(InputStream inputStream) throws IOException { final int size = inputStream.available(); @@ -38,7 +37,7 @@ private static String stringFromInputStream(InputStream inputStream) throws IOEx // } // } - private static T loadJSONFromAsset(Context context, String assetFilename, ThrowsExceptionFunction creator) throws JSONException { + private static T loadJSONFromAsset(Context context, String assetFilename, SpellbookUtils.ThrowsExceptionFunction creator) throws JSONException { try { final InputStream inputStream = context.getAssets().open(assetFilename); final String str = stringFromInputStream(inputStream); @@ -79,6 +78,11 @@ static JSONObject loadJSONFromData(Context context, String dataFilename) throws return loadJSONFromData(new File(context.getFilesDir(), dataFilename)); } + static T loadItemFromJSONData(File file, SpellbookUtils.ThrowsExceptionFunction createFromJSON) throws JSONException { + final JSONObject json = loadJSONFromData(file); + return createFromJSON.apply(json); + } + private static boolean saveJSON(JSONObject json, File file) { try (BufferedWriter bw = new BufferedWriter(new FileWriter(file))) { bw.write(json.toString(4)); @@ -103,7 +107,7 @@ static boolean saveAsJSON(T item, File file) { return saveAsJSON(item, T::toJSON, file); } - static boolean saveAsJSON(T item, JSONUtils.ThrowsExceptionFunction jsonifier, File file) { + static boolean saveAsJSON(T item, SpellbookUtils.ThrowsExceptionFunction jsonifier, File file) { try { final JSONObject json = jsonifier.apply(item); return saveJSON(json, file); @@ -113,4 +117,23 @@ static boolean saveAsJSON(T item, JSONUtils.ThrowsExceptionFunction _nameMap = new HashMap<>(); diff --git a/app/src/main/java/dnd/jon/spellbook/MainActivity.java b/app/src/main/java/dnd/jon/spellbook/MainActivity.java index b55dd201..48669777 100755 --- a/app/src/main/java/dnd/jon/spellbook/MainActivity.java +++ b/app/src/main/java/dnd/jon/spellbook/MainActivity.java @@ -42,16 +42,20 @@ import com.google.android.material.bottomnavigation.BottomNavigationView; import com.google.android.material.navigation.NavigationView; -import androidx.fragment.app.FragmentContainerView; import androidx.fragment.app.FragmentTransaction; import androidx.lifecycle.Observer; import androidx.lifecycle.ViewModelProvider; +import androidx.navigation.NavController; +import androidx.navigation.NavDestination; +import androidx.navigation.fragment.NavHostFragment; +import org.javatuples.Pair; import org.json.JSONObject; import java.io.File; import java.util.Arrays; import java.util.ArrayList; +import java.util.Collection; import java.util.HashMap; import java.util.List; import java.util.Locale; @@ -68,19 +72,6 @@ public class MainActivity extends AppCompatActivity implements SharedPreferences.OnSharedPreferenceChangeListener { - private enum WindowStatus { - TABLE, - SPELL, - FILTER, - SETTINGS, - SLOTS, - INFO, - SPELL_CREATION, - } - - private WindowStatus windowStatus; - private WindowStatus prevWindowStatus; - private boolean openedSpellSlotsFromFAB = false; // Fragment tags @@ -89,7 +80,10 @@ private enum WindowStatus { private static final String SPELL_WINDOW_FRAGMENT_TAG = "SpellWindowFragment"; private static final String SPELL_SLOTS_FRAGMENT_TAG = "SpellSlotsFragment"; private static final String SETTINGS_FRAGMENT_TAG = "SettingsFragment"; + private static final String HOMEBREW_FRAGMENT_TAG = "HomebrewFragment"; private static final String SPELL_SLOTS_DIALOG_TAG = "SpellSlotsDialog"; + private static final String DELETE_SPELL_DIALOG_TAG = "DeleteSpellDialog"; + private static final String MANAGE_SOURCES_DIALOG_TAG = "ManageSourcesDialog"; // Tags for dialogs private static final String CREATE_CHARACTER_TAG = "createCharacter"; @@ -114,11 +108,18 @@ private enum WindowStatus { private ExpandableListAdapter rightAdapter; private SearchView searchView; private MenuItem searchViewIcon; - private MenuItem filterMenuIcon; + private MenuItem baseFragment1Icon; + private MenuItem baseFragment2Icon; private MenuItem infoMenuIcon; private MenuItem editSlotsMenuIcon; private MenuItem manageSlotsMenuIcon; private MenuItem regainSlotsMenuIcon; + private MenuItem deleteIcon; + private MenuItem homebrewIcon; + private MenuItem manageSourcesIcon; + + private List baseFragments; + private List baseFragmentMenuIDs = Arrays.asList(id.action_base_fragment_1, id.action_base_fragment_2); private ActionBarDrawerToggle leftNavToggle; // For close spell windows on a swipe, on a phone @@ -151,21 +152,44 @@ private enum WindowStatus { private static final String spellBundleKey = "SPELL"; private static final String spellIndexBundleKey = "SPELL_INDEX"; - // For feedback messages - private static final String devEmail = "dndspellbookapp@gmail.com"; - private static final String emailMessage = "[Android] Feedback"; - // Logging tag private static final String TAG = "MainActivity"; // The map ID -> StatusFilterField relating left nav bar items to the corresponding spell status filter - private static final HashMap statusFilterIDs = new HashMap() {{ + private static final HashMap statusFilterIDs = new HashMap<>() {{ put(id.nav_all, StatusFilterField.ALL); put(id.nav_favorites, StatusFilterField.FAVORITES); put(id.nav_prepared, StatusFilterField.PREPARED); put(id.nav_known, StatusFilterField.KNOWN); }}; + private static final Map globalNavigationActions = new HashMap<>() {{ + put(id.spellSlotManagerFragment, id.action_navigate_to_spell_slots_fragment); + put(id.settingsFragment, id.action_navigate_to_settings_fragment); + put(id.homebrewManagementFragment, id.action_navigate_to_homebrew_fragment); + put(id.spellWindowFragment, id.action_navigate_to_spell_window_fragment); + put(id.sortFilterFragment, id.action_navigate_to_sort_filter_fragment); + }}; + + private static final Map,Integer> graphNavigationActions = new HashMap<>() {{ + put(new Pair<>(id.spellTableFragment, id.sortFilterFragment), id.action_spellTableFragment_to_sortFilterFragment); + put(new Pair<>(id.sortFilterFragment, id.spellTableFragment), id.action_sortFilterFragment_to_spellTableFragment); + put(new Pair<>(id.spellWindowFragment, id.sortFilterFragment), id.action_spellWindowFragment_to_sortFilterFragment); + put(new Pair<>(id.sortFilterFragment, id.spellWindowFragment), id.action_sortFilterFragment_to_spellWindowFragment); + put(new Pair<>(id.homebrewManagementFragment, id.spellCreationFragment), id.action_homebrewManagementFragment_to_spellCreationFragment); + put(new Pair<>(id.homebrewManagementFragment, id.sortFilterFragment), id.action_homebrewManagementFragment_to_sortFilterFragment); + put(new Pair<>(id.homebrewManagementFragment, id.spellWindowFragment), id.action_homebrewManagementFragment_to_spellWindowFragment); + put(new Pair<>(id.sortFilterFragment, id.homebrewManagementFragment), id.action_sortFilterFragment_to_homebrewManagementFragment); + put(new Pair<>(id.spellWindowFragment, id.homebrewManagementFragment), id.action_spellWindowFragment_to_homebrewManagementFragment); + }}; + + private static final Map> actionBarData = new HashMap<>() {{ + put(id.spellWindowFragment, new Pair<>(string.action_spell_window, drawable.ic_text_snippet)); + put(id.spellTableFragment, new Pair<>(string.action_table, drawable.ic_list)); + put(id.sortFilterFragment, new Pair<>(string.action_filter, drawable.ic_filter)); + put(id.homebrewManagementFragment, new Pair<>(string.homebrew, drawable.cauldron)); + }}; + // For listening to keyboard visibility events //private Unregistrar unregistrar; @@ -174,11 +198,13 @@ private enum WindowStatus { // For view and data binding private ActivityMainBinding binding; - private SpellTableFragment spellTableFragment; - private SortFilterFragment sortFilterFragment; private SpellWindowFragment spellWindowFragment; - private SpellSlotManagerFragment spellSlotFragment; - private SettingsFragment settingsFragment; + private NavHostFragment navHostFragment; + private NavController navController; + + // For navigation via the action bar + + private Collection currentNavFragmentIDs; private boolean ignoreSpellStatusUpdate = false; @@ -194,18 +220,23 @@ protected void onCreate(final Bundle savedInstanceState) { binding = ActivityMainBinding.inflate(getLayoutInflater()); setContentView(binding.getRoot()); - spellTableFragment = (SpellTableFragment) getSupportFragmentManager().findFragmentByTag(SPELL_TABLE_FRAGMENT_TAG); - sortFilterFragment = (SortFilterFragment) getSupportFragmentManager().findFragmentByTag(SORT_FILTER_FRAGMENT_TAG); spellWindowFragment = (SpellWindowFragment) getSupportFragmentManager().findFragmentByTag(SPELL_WINDOW_FRAGMENT_TAG); - settingsFragment = (SettingsFragment) getSupportFragmentManager().findFragmentByTag(SETTINGS_FRAGMENT_TAG); - // Should be null unless we're coming off a rotation where it was open - spellSlotFragment = (SpellSlotManagerFragment) getSupportFragmentManager().findFragmentByTag(SPELL_SLOTS_FRAGMENT_TAG); + navHostFragment = (NavHostFragment) getSupportFragmentManager().findFragmentById(id.nav_host_fragment); + navController = navHostFragment.getNavController(); // Are we on a tablet or not? // If we're on a tablet, do the necessary setup onTablet = getResources().getBoolean(bool.isTablet); - if (onTablet) { tabletSetup(); } + if (onTablet) { + tabletSetup(); + } + + if (onTablet) { + baseFragments = Arrays.asList(id.spellWindowFragment, id.sortFilterFragment, id.homebrewManagementFragment); + } else { + baseFragments = Arrays.asList(id.spellTableFragment, id.sortFilterFragment); + } // Get the spell view model viewModel = new ViewModelProvider(this).get(SpellbookViewModel.class); @@ -228,9 +259,7 @@ protected void onCreate(final Bundle savedInstanceState) { // Whether or not various views are visible if (savedInstanceState != null) { - savedInstanceState.getBoolean(FILTER_VISIBLE_KEY, false); - windowStatus = (WindowStatus) savedInstanceState.getSerializable(WINDOW_STATUS_KEY); - prevWindowStatus = (WindowStatus) savedInstanceState.getSerializable(PREV_WINDOW_STATUS_KEY); + filterVisible = savedInstanceState.getBoolean(FILTER_VISIBLE_KEY, false); openedSpellSlotsFromFAB = savedInstanceState.getBoolean(SLOTS_OPENED_FAB_KEY, false); } @@ -251,7 +280,7 @@ protected void onCreate(final Bundle savedInstanceState) { openCharacterSelection(); } else if (index == id.subnav_spell_slots) { openedSpellSlotsFromFAB = false; - updateWindowStatus(WindowStatus.SLOTS); + globalNavigateTo(id.spellSlotManagerFragment); close = true; } else if (index == id.nav_feedback) { sendFeedback(); @@ -260,10 +289,15 @@ protected void onCreate(final Bundle savedInstanceState) { } else if (index == id.nav_whats_new) { showUpdateDialog(false); } else if (index == id.nav_settings) { - updateWindowStatus(WindowStatus.SETTINGS); + globalNavigateTo(id.settingsFragment); + close = true; + } else if (index == id.subnav_manage_homebrew) { + if (onTablet) { + navController.navigate(id.homebrewManagementFragment); + } else { + globalNavigateTo(id.homebrewManagementFragment); + } close = true; - //} else if (index == R.id.create_a_spell) { - // openSpellCreationWindow(); } else if (statusFilterIDs.containsKey(index)) { final StatusFilterField sff = statusFilterIDs.get(index); sortFilterStatus.setStatusFilterField(sff); @@ -289,7 +323,12 @@ protected void onCreate(final Bundle savedInstanceState) { @Override public void onDrawerOpened(@NonNull View drawerView) { - spellTableFragment.stopScrolling(); + if (currentDestinationId() == id.spellTableFragment) { + final SpellTableFragment fragment = (SpellTableFragment) currentNavigationFragment(); + if (fragment != null) { + fragment.stopScrolling(); + } + } } }); @@ -297,7 +336,7 @@ public void onDrawerOpened(@NonNull View drawerView) { swipeCloseListener = new OnSwipeTouchListener(this) { @Override public void onSwipeRight() { - if (!onTablet && !spellTableFragment.isHidden()) { + if (!onTablet) { closeSpellWindow(); } } @@ -308,14 +347,15 @@ public void onSwipeRight() { drawerLayout.addDrawerListener(leftNavToggle); setNavigationToHome(); - // Back stack listener - //getSupportFragmentManager().addOnBackStackChangedListener(this); + navController.addOnDestinationChangedListener((navController, navDestination, bundle) -> { + saveCharacterProfile(); + updateFAB(navDestination); + updateActionBar(navDestination); + updateBottomBarVisibility(navDestination); - // Set up various views - setupRightNav(); - setupFAB(); - setupBottomNavBar(); - setupSideMenu(); + final int destinationId = navDestination.getId(); + setAppBarScrollingAllowed(destinationId != id.settingsFragment); + }); if (!onTablet && binding.fab != null) { final String fabMovableKey = getString(string.fab_movable_key); @@ -365,6 +405,8 @@ public void onSwipeRight() { viewModel.currentSpell().observe(this, this::handleSpellUpdate); viewModel.spellTableCurrentlyVisible().observe(this, this::onSpellTableVisibilityChange); viewModel.currentToastEvent().observe(this, this::displayToastMessageFromEvent); + viewModel.currentEditingSpell().observe(this, this::handleEditingSpellUpdate); + } @@ -382,21 +424,24 @@ public boolean onCreateOptionsMenu(Menu menu) { searchView.setSearchableInfo(searchManager.getSearchableInfo(getComponentName())); searchViewIcon.setOnActionExpandListener(new MenuItem.OnActionExpandListener() { @Override - public boolean onMenuItemActionExpand(MenuItem menuItem) { + public boolean onMenuItemActionExpand(@NonNull MenuItem menuItem) { return true; } @Override - public boolean onMenuItemActionCollapse(MenuItem menuItem) { + public boolean onMenuItemActionCollapse(@NonNull MenuItem menuItem) { return onTablet || !isSpellWindowOpen(); } }); - filterMenuIcon = menu.findItem(id.action_filter); + baseFragment1Icon = menu.findItem(id.action_base_fragment_1); + baseFragment2Icon = menu.findItem(id.action_base_fragment_2); infoMenuIcon = menu.findItem(id.action_info); editSlotsMenuIcon = menu.findItem(id.action_edit); manageSlotsMenuIcon = menu.findItem(id.action_slots); regainSlotsMenuIcon = menu.findItem(id.action_regain); + deleteIcon = menu.findItem(id.action_delete); + manageSourcesIcon = menu.findItem(id.action_sources); // Set up the SearchView functions searchView.setOnQueryTextListener(new SearchView.OnQueryTextListener() { @@ -408,24 +453,40 @@ public boolean onQueryTextSubmit(String text) { @Override public boolean onQueryTextChange(String text) { viewModel.setSearchQuery(text); - spellTableFragment.stopScrolling(); + if (currentDestinationId() == id.spellTableFragment) { + final SpellTableFragment fragment = (SpellTableFragment) currentNavigationFragment(); + if (fragment != null) { + fragment.stopScrolling(); + } + } return true; } }); - - updateActionBar(); - + updateActionBar(navController.getCurrentDestination()); return true; } - // To handle actions + private void updateActionBarBaseFragmentIcon(int index) { + final int baseFragmentMenuID = baseFragmentMenuIDs.get(index); + final int fragmentID = baseFragmentID(index); + final MenuItem item = binding.toolbar.getMenu().findItem(baseFragmentMenuID); + final Pair data = actionBarData.get(fragmentID); + if (data != null) { + item.setTitle(data.getValue0()); + item.setIcon(data.getValue1()); + } + } + @Override public boolean onOptionsItemSelected(MenuItem item) { final int itemID = item.getItemId(); - if (itemID == id.action_filter) { - final WindowStatus notFilterStatus = onTablet ? WindowStatus.SPELL : WindowStatus.TABLE; - final WindowStatus newStatus = (windowStatus == WindowStatus.FILTER) ? notFilterStatus : WindowStatus.FILTER; - updateWindowStatus(newStatus); + if (itemID == id.action_base_fragment_1) { + final int fragmentID = baseFragmentID(0); + navigateToBaseFragment(fragmentID); + return true; + } else if (itemID == id.action_base_fragment_2) { + final int fragmentID = baseFragmentID(1); + navigateToBaseFragment(fragmentID); return true; } else if (itemID == id.action_info) { if (drawerLayout.isDrawerOpen(GravityCompat.END)) { @@ -441,59 +502,40 @@ public boolean onOptionsItemSelected(MenuItem item) { if (onTablet) { showSpellSlotsDialog(); } else { - updateWindowStatus(WindowStatus.SLOTS); + globalNavigateTo(id.spellSlotManagerFragment); } return true; } else if (itemID == id.action_regain) { viewModel.getSpellSlotStatus().regainAllSlots(); return true; + } else if (itemID == id.action_delete) { + // TODO: In the future this may need to be navigation-aware + // as "delete" is in principle a pretty generic action + final Spell spell = viewModel.currentEditingSpell().getValue(); + if (spell != null) { + final DeleteSpellDialog dialog = new DeleteSpellDialog(); + dialog.setOnConfirm(this::onBackPressed); + final Bundle args = new Bundle(); + args.putString(DeleteSpellDialog.NAME_KEY, spell.getName()); + dialog.setArguments(args); + dialog.show(getSupportFragmentManager(), DELETE_SPELL_DIALOG_TAG); + } + return true; + } else if (itemID == id.action_sources) { + final SourceManagementDialog dialog = new SourceManagementDialog(); + dialog.show(getSupportFragmentManager(), MANAGE_SOURCES_DIALOG_TAG); + return true; } else { return super.onOptionsItemSelected(item); } } private void initializeWindow() { - Fragment toHide; - if (windowStatus == null) { - final WindowStatus initialWindowStatus = onTablet ? WindowStatus.SPELL : WindowStatus.TABLE; - toHide = sortFilterFragment; - windowStatus = initialWindowStatus; - hideFragment(toHide); - } - updateFabVisibility(); updateSideMenuItemsVisibility(); - updateActionBar(); - updateBottomBarVisibility(); - - if (onTablet && windowStatus == WindowStatus.FILTER) { - spellWindowFragment.onHiddenChanged(true); -// hideFragment(spellWindowFragment, () -> { -// System.out.println(spellWindowFragment.isHidden()); -// }); - } - - // TODO: This is a kludgy-ish fix for the following bug on a phone: - // If one opens the spell slots window, rotates with it open, closes the spell slot window - // and then opens the settings, rotates with them open, and closes the settings, then then - // spell slot container will still be visible and block the table - final List slotFragments = getSpellSlotFragments(); - for (SpellSlotManagerFragment fragment : slotFragments) { - removeFragment(fragment, true); - } - spellSlotFragment = null; - - // Remove unneeded settings fragments as well - final List settingsFragments = getSettingsFragments(); - for (SettingsFragment fragment : settingsFragments) { - removeFragment(fragment, true); - } - settingsFragment = null; - - if (windowStatus == WindowStatus.SETTINGS) { - openSettings(); - } else if (windowStatus == WindowStatus.SLOTS) { - openSpellSlotsFragment(); - } + setupRightNav(); + setupFAB(); + setupBottomNavBar(); + setupSideMenu(); } private void setLeftDrawerLocked(boolean lock) { @@ -511,14 +553,12 @@ private void setRightDrawerLocked(boolean lock) { private void showSpellSlotAdjustTotalsDialog() { - if (spellSlotFragment != null) { - final SpellSlotStatus spellSlotStatus = viewModel.getSpellSlotStatus(); - final Bundle args = new Bundle(); - args.putParcelable(SpellSlotAdjustTotalsDialog.SPELL_SLOT_STATUS_KEY, spellSlotStatus); - final SpellSlotAdjustTotalsDialog dialog = new SpellSlotAdjustTotalsDialog(); - dialog.setArguments(args); - dialog.show(getSupportFragmentManager(), SPELL_SLOT_ADJUST_TOTALS_TAG); - } + final SpellSlotStatus spellSlotStatus = viewModel.getSpellSlotStatus(); + final Bundle args = new Bundle(); + args.putParcelable(SpellSlotAdjustTotalsDialog.SPELL_SLOT_STATUS_KEY, spellSlotStatus); + final SpellSlotAdjustTotalsDialog dialog = new SpellSlotAdjustTotalsDialog(); + dialog.setArguments(args); + dialog.show(getSupportFragmentManager(), SPELL_SLOT_ADJUST_TOTALS_TAG); } private void showSpellSlotsDialog() { @@ -539,41 +579,87 @@ private void setNavigationToBack() { binding.toolbar.setNavigationOnClickListener((v) -> this.onBackPressed()); } - private void setNavigationToHome() { - binding.toolbar.setNavigationIcon(drawable.ic_hamburger); - binding.toolbar.setNavigationOnClickListener((v) -> this.toggleDrawer()); + private Fragment currentNavigationFragment() { + return navHostFragment.getChildFragmentManager().getPrimaryNavigationFragment(); } - private void addFragment(int containerID, Fragment fragment, String tag) { - getSupportFragmentManager() - .beginTransaction() - .add(containerID, fragment, tag) - .commit(); - getSupportFragmentManager().executePendingTransactions(); + private int currentDestinationId() { + final NavDestination destination = navController.getCurrentDestination(); + return (destination != null) ? destination.getId() : id.null_id; } - private void addFragment(int containerID, Class fragmentClass, String tag) { - getSupportFragmentManager() - .beginTransaction() - .add(containerID, fragmentClass, null, tag) - .commit(); - getSupportFragmentManager().executePendingTransactions(); + private void globalNavigateTo(int destinationId) { + final Integer actionId = globalNavigationActions.get(destinationId); + if (actionId != null) { + navController.navigate(actionId); + } } - private void replaceFragment(int containerID, Fragment fragment, String tag, boolean addToBackStack) { - final FragmentTransaction transaction = getSupportFragmentManager() - .beginTransaction() - .replace(containerID, fragment, tag); - if (addToBackStack) { - transaction.addToBackStack(tag); + private void navigateToSpellWindowFragment() { + globalNavigateTo(id.spellWindowFragment); + } + + private void navigateToSpellTableFragment() { + // NB: This should only ever be called when we're in the sort/filter fragment + if (currentDestinationId() == id.sortFilterFragment) { + navController.navigate(id.action_sortFilterFragment_to_spellTableFragment); } - transaction.commit(); } - private void replaceFragment(int containerID, Class fragmentClass, String tag, boolean addToBackStack) { + private void navigateToSortFilterFragment() { + if (onTablet) { + globalNavigateTo(id.sortFilterFragment); + } else if (currentDestinationId() == id.spellTableFragment) { + navController.navigate(id.action_spellTableFragment_to_sortFilterFragment); + } + } + + private void navigateToHomebrewFragment() { + if (onTablet) { + final NavDestination destination = navController.getCurrentDestination(); + final int destinationID = destination.getId(); + if (destinationID == id.spellWindowFragment) { + navController.navigate(id.action_spellWindowFragment_to_homebrewManagementFragment); + } else if (destinationID == id.sortFilterFragment) { + navController.navigate(id.action_sortFilterFragment_to_homebrewManagementFragment); + } + } else { + globalNavigateTo(id.homebrewManagementFragment); + } + } + + private void navigateToBaseFragment(int destinationID) { + if (destinationID == id.spellWindowFragment) { + navigateToSpellWindowFragment(); + } else if (destinationID == id.spellTableFragment) { + navigateToSpellTableFragment(); + } else if (destinationID == id.sortFilterFragment) { + navigateToSortFilterFragment(); + } else if (destinationID == id.homebrewManagementFragment) { + navigateToHomebrewFragment(); + } + } + + private int baseFragmentID(int index) { + final int currentID = currentDestinationId(); + int count = 0; + for (final int id : baseFragments) { + if (id == currentID) { continue; } + if (count == index) { return id; } + count++; + } + return -1; + } + + private void setNavigationToHome() { + binding.toolbar.setNavigationIcon(drawable.ic_hamburger); + binding.toolbar.setNavigationOnClickListener((v) -> this.toggleDrawer()); + } + + private void replaceFragment(int containerID, Fragment fragment, String tag, boolean addToBackStack) { final FragmentTransaction transaction = getSupportFragmentManager() .beginTransaction() - .replace(containerID, fragmentClass, null, tag); + .replace(containerID, fragment, tag); if (addToBackStack) { transaction.addToBackStack(tag); } @@ -592,8 +678,6 @@ private void removeFragment(Fragment fragment, boolean commitNow) { } } - private void removeFragment(Fragment fragment) { removeFragment(fragment, false); } - private void hideFragment(Fragment fragment, Runnable onCommit) { getSupportFragmentManager() .beginTransaction() @@ -602,8 +686,6 @@ private void hideFragment(Fragment fragment, Runnable onCommit) { .commit(); } - private void hideFragment(Fragment fragment) { hideFragment(fragment, () -> {});} - private void showFragment(Fragment fragment, Runnable onCommit) { getSupportFragmentManager() .beginTransaction() @@ -653,8 +735,6 @@ public void onDestroy() { public void onSaveInstanceState(@NonNull Bundle outState) { super.onSaveInstanceState(outState); outState.putBoolean(FILTER_VISIBLE_KEY, filterVisible); - outState.putSerializable(WINDOW_STATUS_KEY, windowStatus); - outState.putSerializable(PREV_WINDOW_STATUS_KEY, prevWindowStatus); outState.putBoolean(SLOTS_OPENED_FAB_KEY, openedSpellSlotsFromFAB); viewModel.saveCurrentProfile(); viewModel.saveSettings(); @@ -668,13 +748,15 @@ public void onBackPressed() { drawerLayout.closeDrawer(GravityCompat.START); } else if (drawerLayout.isDrawerOpen(GravityCompat.END)) { drawerLayout.closeDrawer(GravityCompat.END); - } else { - final WindowStatus backStatus = backStatus(windowStatus); - if (backStatus != null) { - updateWindowStatus(backStatus); + } else if (currentDestinationId() == id.homebrewManagementFragment) { + final HomebrewManagementFragment fragment = (HomebrewManagementFragment) currentNavigationFragment(); + if (fragment != null && fragment.binding.speeddialHomebrewFab.isOpen()) { + fragment.binding.speeddialHomebrewFab.close(); } else { super.onBackPressed(); } + } else { + super.onBackPressed(); } } @@ -729,12 +811,10 @@ void updateSpellWindow(Spell spell, int pos) { if (onTablet) { spellWindowFragment.updateSpell(spell); - //spellWindowFragment.updateUseExpanded(sortFilterStatus.getUseTashasExpandedLists()); filterVisible = false; - //updateWindowVisibilities(); } else { final SpellWindowFragment fragment = new SpellWindowFragment(); - replaceFragment(id.phone_fullscreen_fragment_container, fragment, SPELL_WINDOW_FRAGMENT_TAG, true); + // replaceFragment(id.phone_fullscreen_fragment_container, fragment, SPELL_WINDOW_FRAGMENT_TAG, true); } } @@ -744,52 +824,13 @@ void openSpellPopup(View view, Spell spell) { ssp.showUnderView(view); } - private void openSpellSlotsFragment() { - spellSlotFragment = new SpellSlotManagerFragment(); - binding.appBarLayout.setExpanded(true, false); - if (editSlotsMenuIcon != null) { - editSlotsMenuIcon.setVisible(true); - } - final int containerID = onTablet ? id.tablet_detail_container : id.phone_fragment_container; - getSupportFragmentManager() - .beginTransaction() - .add(containerID, spellSlotFragment, SPELL_SLOTS_FRAGMENT_TAG) - .commit(); - - if (!onTablet) { - hideFragment(spellTableFragment, () -> { - if (binding.bottomNavBar != null) { - binding.bottomNavBar.setVisibility(View.GONE); - } - }); - } - - // Adjust icons on the Action Bar - //binding.toolbar.setNavigationIcon(drawable.ic_action_back); - final List menuItems = Arrays.asList(infoMenuIcon, filterMenuIcon, searchViewIcon, manageSlotsMenuIcon); - for (MenuItem item : menuItems) { - if (item != null) { - item.setVisible(false); - } - } - } - - private void closeSpellSlotsFragment() { - if (onTablet) { - replaceFragment(id.tablet_detail_container, spellTableFragment, SPELL_TABLE_FRAGMENT_TAG, false); - } else { - removeFragment(spellSlotFragment, true); - spellSlotFragment = null; - showFragment(spellTableFragment); - } - } - private void setupFAB() { - if (onTablet) { return; } + if (onTablet || binding.fab == null) { return; } binding.fab.setOnClickListener((v) -> { - fabCenterReveal = new CenterReveal(binding.fab, binding.phoneFragmentContainer); openedSpellSlotsFromFAB = true; - fabCenterReveal.start(() -> updateWindowStatus(WindowStatus.SLOTS)); + fabCenterReveal = new CenterReveal(binding.fab, null); + //fabCenterReveal = new CenterReveal(binding.fab, binding.phoneFragmentContainer); + fabCenterReveal.start(() -> globalNavigateTo(id.spellSlotManagerFragment)); }); } @@ -898,7 +939,7 @@ private void setMenuTitleText(Menu menu, int itemID, CharSequence text) { final MenuItem menuItem = menu.findItem(itemID); final CharSequence title = text != null ? text : (menuItem.getTitle() != null ? menuItem.getTitle() : ""); final SpannableString ss = new SpannableString(title); - ss.setSpan(new ForegroundColorSpan(getColor(color.defaultTextColor)), 0, ss.length(), 0); + ss.setSpan(new ForegroundColorSpan(getColor(color.menuHeaderColor)), 0, ss.length(), 0); menuItem.setTitle(ss); } @@ -1016,13 +1057,7 @@ void setCharacterProfile(CharacterProfile cp, boolean initialLoad) { sortFilterStatus = null; openCharacterCreationDialog(); } - - // Reset the spell view if on the tablet - if (onTablet && !initialLoad) { - spellWindowFragment.updateSpell(null); - } } - // Sets the given character profile to be the active one void setCharacterProfile(CharacterProfile cp) { setCharacterProfile(cp, false); @@ -1044,8 +1079,8 @@ void openCharacterCreationDialog() { void sendFeedback() { final Intent intent = new Intent(Intent.ACTION_SEND); intent.setType("message/rfc822"); - intent.putExtra(Intent.EXTRA_EMAIL, new String[]{devEmail}); - intent.putExtra(Intent.EXTRA_SUBJECT, emailMessage); + intent.putExtra(Intent.EXTRA_EMAIL, new String[]{getString(string.dev_email)}); + intent.putExtra(Intent.EXTRA_SUBJECT, getString(string.email_subject)); try { startActivity(Intent.createChooser(intent, getString(string.send_email))); } catch (android.content.ActivityNotFoundException ex) { @@ -1065,7 +1100,7 @@ void openCharacterSelection() { // This function takes care of any setup that's needed only on a tablet layout private void tabletSetup() { //spellWindowFragment = new SpellWindowFragment(); - spellWindowFragment.updateSpell(null); + //spellWindowFragment.updateSpell(null); } // If we're on a tablet, this function updates the spell window to match its status in the character profile @@ -1100,21 +1135,6 @@ private void hideSoftKeyboard() { } } - private void showSpellTable() { - if (onTablet) { - return; - } - - // Clear the focus from an EditText, if that's where it is - // since they have an OnFocusChangedListener - // We want to do this BEFORE we sort/filter so that any changes can be made to the CharacterProfile - final View view = getCurrentFocus(); - if (view != null) { - hideSoftKeyboard(view, this); - } - showFragment(spellTableFragment); - } - private void updateSpellListMenuVisibility() { final SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(this); final String bottomNav = getResources().getString(string.bottom_navbar); @@ -1130,6 +1150,7 @@ private void updateSpellListMenuVisibility() { } private void updateSpellSlotMenuVisibility() { + if (onTablet) { return; } final SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(this); final String fab = getString(string.circular_button); final String locationsKey = getString(string.spell_slot_locations); @@ -1144,26 +1165,26 @@ private void updateSideMenuItemsVisibility() { updateSpellSlotMenuVisibility(); } - private void updateFabVisibility() { - if (onTablet) { return; } + private void updateFABVisibility(NavDestination destination) { + final int destinationId = destination.getId(); + if (onTablet || binding.fab == null) { return; } final SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(this); final String fab = getString(string.circular_button); final String sideDrawer = getString(string.side_drawer); final String locationOption = prefs.getString(getString(string.spell_slot_locations), fab); boolean visible = !locationOption.equals(sideDrawer); - visible = visible && ((windowStatus == WindowStatus.TABLE) || (onTablet && windowStatus == WindowStatus.SPELL)); + visible = visible && (destinationId == id.spellTableFragment); final int visibility = visible ? View.VISIBLE : View.GONE; binding.fab.setVisibility(visibility); - if (visible && prevWindowStatus == WindowStatus.SLOTS && openedSpellSlotsFromFAB) { + if (visible && openedSpellSlotsFromFAB) { if (fabCenterReveal == null) { - fabCenterReveal = new CenterReveal(binding.fab, binding.phoneFragmentContainer); + fabCenterReveal = new CenterReveal(binding.fab, null); } - fabCenterReveal.reverse(() -> binding.phoneFragmentContainer.setAlpha(1.0f)); + fabCenterReveal.reverse(null); + openedSpellSlotsFromFAB = false; } } - - private void openPlayStoreForRating() { final Uri uri = Uri.parse("market://details?id=" + getPackageName()); final Intent goToPlayStore = new Intent(Intent.ACTION_VIEW, uri); @@ -1221,9 +1242,9 @@ public boolean dispatchKeyEvent(KeyEvent ev) { private void handleSpellUpdate(Spell spell) { // We want to do this no matter what - if (onTablet) { - final boolean forceHide = windowStatus == WindowStatus.FILTER; - spellWindowFragment.updateSpell(spell, forceHide); + if (onTablet && currentDestinationId() == id.spellWindowFragment) { + final SpellWindowFragment fragment = (SpellWindowFragment) currentNavigationFragment(); + fragment.updateSpell(spell); } if (ignoreSpellStatusUpdate) { @@ -1232,7 +1253,7 @@ private void handleSpellUpdate(Spell spell) { } if (onTablet) { - updateWindowStatus(WindowStatus.SPELL); + globalNavigateTo(id.spellWindowFragment); } else { openSpellWindow(spell); final boolean actualSpell = spell != null; @@ -1244,6 +1265,10 @@ private void handleSpellUpdate(Spell spell) { } } + private void handleEditingSpellUpdate(Spell spell) { + // TODO: Implement this + } + private void openSpellWindow(Spell spell) { if (onTablet || spell == null) { return; } final Bundle args = new Bundle(); @@ -1290,19 +1315,6 @@ private boolean isSpellWindowOpen() { return spellWindowFragment != null; } - private List visibleMainContainers(WindowStatus status) { - final List containers = new ArrayList<>(); - if (onTablet || (status == WindowStatus.TABLE)) { - containers.add(binding.spellTableContainer); - } - if (status == WindowStatus.SPELL) { - containers.add(binding.spellWindowContainer); - } else if (status == WindowStatus.FILTER) { - containers.add(binding.sortFilterContainer); - } - return containers; - } - private void setAppBarScrollingAllowed(boolean allow) { AppBarLayout.LayoutParams params = (AppBarLayout.LayoutParams) binding.toolbar.getLayoutParams(); final int flags = allow ? AppBarLayout.LayoutParams.SCROLL_FLAG_SCROLL | @@ -1311,57 +1323,6 @@ private void setAppBarScrollingAllowed(boolean allow) { params.setScrollFlags(flags); } - private void openSettings() { - final int containerID = id.settings_container; - final List viewsToDisable = visibleMainContainers(windowStatus); - final FragmentTransaction transaction = getSupportFragmentManager() - .beginTransaction() - .setCustomAnimations(anim.left_to_right_enter, anim.identity); - if (settingsFragment != null) { - transaction.show(settingsFragment); - } else { - transaction.add(containerID, SettingsFragment.class, null, SETTINGS_FRAGMENT_TAG); - } - transaction.hide(spellTableFragment); -// if (onTablet) { -// final Fragment fragment = (prevWindowStatus == WindowStatus.FILTER) ? sortFilterFragment : spellWindowFragment; -// transaction.hide(fragment); -// } - transaction.runOnCommit(() -> { - this.settingsFragment = (SettingsFragment) getSupportFragmentManager().findFragmentByTag(SETTINGS_FRAGMENT_TAG); - for (FragmentContainerView container : viewsToDisable) { - container.setEnabled(false); - } - }) - .commit(); - if (!onTablet) { - setAppBarScrollingAllowed(false); - } - } - - private void closeSettings() { - final List viewsToEnable = visibleMainContainers(windowStatus); - final FragmentTransaction transaction = getSupportFragmentManager() - .beginTransaction() - .setCustomAnimations(anim.identity, anim.right_to_left_exit) - .remove(settingsFragment) - .show(spellTableFragment); - if (onTablet) { - final Fragment fragment = (windowStatus == WindowStatus.FILTER) ? sortFilterFragment : spellWindowFragment; - transaction.show(fragment); - } - transaction.runOnCommit(() -> { - this.settingsFragment = null; - for (FragmentContainerView container : viewsToEnable) { - container.setEnabled(true); - } - }) - .commit(); - if (!onTablet) { - setAppBarScrollingAllowed(true); - } - } - private void showUpdateDialog(boolean checkIfNecessary) { final SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(this); final String key = "first_time_" + GlobalInfo.UPDATE_LOG_CODE; @@ -1398,7 +1359,7 @@ private void setupSpellWindowCloseOnSwipe() { view.setOnTouchListener(swipeCloseListener); } - private boolean shouldBottomNavBarBeVisible() { + private boolean shouldBottomNavBarBeVisible(NavDestination destination) { final SharedPreferences sharedPreferences = PreferenceManager.getDefaultSharedPreferences(this); final String sideDrawer = getResources().getString(string.side_drawer); final String bottomNav = getResources().getString(string.bottom_navbar); @@ -1407,16 +1368,15 @@ private boolean shouldBottomNavBarBeVisible() { boolean visible = !locationOption.equals(sideDrawer); if (!visible) { return false; } if (onTablet) { - return (windowStatus == WindowStatus.SPELL) || (windowStatus == WindowStatus.FILTER); + return destination.getId() == id.sortFilterFragment; } else { - return windowStatus == WindowStatus.TABLE; + return destination.getId() == id.spellTableFragment; } } void setupBottomNavBar() { final BottomNavigationView bottomNavBar = binding.bottomNavBar; - if (bottomNavBar == null) { return; } - final boolean bottomNavVisible = shouldBottomNavBarBeVisible(); + final boolean bottomNavVisible = shouldBottomNavBarBeVisible(navController.getCurrentDestination()); final int visibility = bottomNavVisible ? View.VISIBLE : View.GONE; bottomNavBar.setVisibility(visibility); bottomNavBar.setOnItemSelectedListener(item -> { @@ -1447,10 +1407,10 @@ private void setupSideMenu() { @Override public void onSharedPreferenceChanged(SharedPreferences sharedPreferences, String key) { if (key.equals(getString(string.spell_slot_locations))) { - updateFabVisibility(); + updateFABVisibility(navController.getCurrentDestination()); updateSpellSlotMenuVisibility(); } else if (key.equals(getString(string.spell_list_locations))) { - updateBottomBarVisibility(); + updateBottomBarVisibility(navController.getCurrentDestination()); updateSpellListMenuVisibility(); } else if (key.equals(getString(string.spell_language_key))) { final Locale locale = new Locale(sharedPreferences.getString(key, getString(string.english_code))); @@ -1466,21 +1426,48 @@ public void onSharedPreferenceChanged(SharedPreferences sharedPreferences, Strin } } - private void updateActionBar() { - final boolean searchViewVisible = onTablet || (windowStatus == WindowStatus.TABLE); - final boolean filterIconVisible = (windowStatus == WindowStatus.TABLE) || - (windowStatus == WindowStatus.FILTER) || - ((windowStatus == WindowStatus.SPELL) && onTablet); - final boolean infoIconVisible = filterIconVisible; - final boolean editIconVisible = (windowStatus == WindowStatus.SLOTS); - final boolean regainIconVisible = (windowStatus == WindowStatus.SLOTS); - + private int actionBarTitleId(NavDestination destination) { + // IDs are non-final in Gradle 8 + // so Android Studio warns against using a switch + final int destinationId = destination.getId(); + if (destinationId == id.spellSlotManagerFragment) { + return string.spell_slots_title; + } else if (destinationId == id.settingsFragment) { + return string.settings; + } else if (destinationId == id.homebrewManagementFragment) { + return string.homebrew_management_title; + } else if (destinationId == id.spellCreationFragment) { + // TODO: Not 100% satisfied with this + // What would be better? + return string.spell_creation; + } + return string.app_name; + } + + private void updateActionBar(NavDestination destination) { + final int destinationId = destination.getId(); + final boolean searchViewVisible = onTablet || destinationId == id.spellTableFragment; + final boolean atBaseFragment = baseFragments.contains(destinationId); + final boolean homebrewIconVisible = atBaseFragment && onTablet; + final boolean fragmentIcon1Visible = atBaseFragment; + final boolean fragmentIcon2Visible = atBaseFragment; + final boolean infoIconVisible = atBaseFragment && destinationId != id.homebrewManagementFragment; + final boolean editIconVisible = destinationId == id.spellSlotManagerFragment; + final boolean regainIconVisible = destinationId == id.spellSlotManagerFragment; + final boolean deleteIconVisible = destinationId == id.spellCreationFragment; + final boolean manageSourcesIconVisible = destinationId == id.homebrewManagementFragment; + final boolean manageSlotsIconVisible = onTablet && atBaseFragment; if (searchViewIcon != null) { searchViewIcon.setVisible(searchViewVisible); } - if (filterMenuIcon != null) { - filterMenuIcon.setVisible(filterIconVisible); + if (baseFragment1Icon != null) { + baseFragment1Icon.setVisible(fragmentIcon1Visible); + updateActionBarBaseFragmentIcon(0); + } + if (baseFragment2Icon != null) { + baseFragment2Icon.setVisible(fragmentIcon2Visible); + updateActionBarBaseFragmentIcon(1); } if (editSlotsMenuIcon != null) { editSlotsMenuIcon.setVisible(editIconVisible); @@ -1489,118 +1476,55 @@ private void updateActionBar() { infoMenuIcon.setVisible(infoIconVisible); } if (manageSlotsMenuIcon != null) { - manageSlotsMenuIcon.setVisible(onTablet); + manageSlotsMenuIcon.setVisible(manageSlotsIconVisible); } if (regainSlotsMenuIcon != null) { regainSlotsMenuIcon.setVisible(regainIconVisible); } + if (deleteIcon != null) { + deleteIcon.setVisible(deleteIconVisible); + } + if (homebrewIcon != null) { + homebrewIcon.setVisible(homebrewIconVisible); + } + if (manageSourcesIcon != null) { + manageSourcesIcon.setVisible(manageSourcesIconVisible); + } if (!onTablet && searchView != null && searchView.isIconified()) { searchViewIcon.collapseActionView(); } - int title = string.app_name; - switch (windowStatus) { - case SLOTS: - title = string.spell_slots_title; - setNavigationToBack(); - break; - case SETTINGS: - title = string.settings; - setNavigationToBack(); - break; - default: - setNavigationToHome(); + boolean navigationToHome = destinationId == id.sortFilterFragment; + if (onTablet) { + navigationToHome |= (destinationId == id.spellWindowFragment) || (destinationId == id.homebrewManagementFragment); + } else { + navigationToHome |= (destinationId == id.spellTableFragment); } - binding.toolbar.setTitle(title); - // Update the filter icon on the action bar - // If the filters are open, we show a list or data icon (depending on the platform) - // instead ("return to the data") - if (filterMenuIcon != null) { - final int filterIcon = onTablet ? drawable.ic_text_snippet : drawable.ic_list; - final int icon = (windowStatus == WindowStatus.FILTER) ? filterIcon : drawable.ic_filter; - filterMenuIcon.setIcon(icon); - } - } - - private void updateFragments() { - - // Close any fragments that need to be closed - //boolean filter = windowStatus == WindowStatus.FILTER; - //boolean navVisible = filter; - //final Runnable onCommit = ()-> binding.bottomNavBar.setVisibility(navVisible ? View.GONE : View.VISIBLE); - final Runnable onCommit = () -> {}; - if (prevWindowStatus != null) { - switch (prevWindowStatus) { - case SETTINGS: - closeSettings(); - break; - case SLOTS: - closeSpellSlotsFragment(); - break; - case SPELL: - if (onTablet && windowStatus == WindowStatus.FILTER) { - hideFragment(spellWindowFragment, onCommit); -// if (onTablet) { -// hideFragment(spellWindowFragment, onCommit); - } else { - closeSpellWindow(); - } - break; - case TABLE: - //if (!onTablet && windowStatus == WindowStatus.FILTER) { - if (!onTablet) { - hideFragment(spellTableFragment, onCommit); - } - break; - case FILTER: - hideFragment(sortFilterFragment, onCommit); - break; - } + if (navigationToHome) { + setNavigationToHome(); + } else { + setNavigationToBack(); } - switch (windowStatus) { - case SETTINGS: - openSettings(); - break; - case SLOTS: - openSpellSlotsFragment(); - break; - case FILTER: - showFragment(sortFilterFragment); - break; - case TABLE: - if (!onTablet) { - showFragment(spellTableFragment); - } - break; - case SPELL: - if (onTablet) { - showFragment(spellTableFragment); - showFragment(spellWindowFragment); - } else { - final Spell spell = viewModel.currentSpell().getValue(); - if (spell != null) { - openSpellWindow(spell); - } - } - break; - } + final int title = actionBarTitleId(destination); + binding.toolbar.setTitle(title); } - private void updateBottomBarVisibility() { - final boolean visible = shouldBottomNavBarBeVisible(); + private void updateBottomBarVisibility(NavDestination destination) { + final boolean visible = shouldBottomNavBarBeVisible(destination); final BottomNavigationView bottomBar = binding.bottomNavBar; bottomBar.setVisibility(visible ? View.VISIBLE : View.GONE); } - private void updateDrawerStatus() { + private void updateDrawerStatus(NavDestination destination) { boolean lock; + final int destinationId = destination.getId(); if (onTablet) { - lock = (windowStatus == WindowStatus.SETTINGS); + lock = destinationId == id.settingsFragment; } else { - lock = Arrays.asList(WindowStatus.SETTINGS, WindowStatus.SLOTS).contains(windowStatus); + lock = Arrays.asList(id.settingsFragment, id.spellSlotManagerFragment, id.homebrewManagementFragment, id.spellCreationFragment).contains(destinationId); } if (lock) { @@ -1613,55 +1537,8 @@ private void updateDrawerStatus() { setRightDrawerLocked(lockRightDrawer); } - private void updateWindowStatus(WindowStatus newStatus, boolean force) { - final boolean changing = newStatus != windowStatus; - if (!(force || changing)) { - return; - } - if (changing) { - prevWindowStatus = windowStatus; - windowStatus = newStatus; - } - saveCharacterProfile(); - updateActionBar(); - updateBottomBarVisibility(); - updateDrawerStatus(); - updateFabVisibility(); - updateFragments(); - } - - private void updateWindowStatus(WindowStatus newStatus) { - updateWindowStatus(newStatus, false); - } - - private void updateWindowStatus(boolean force) { - updateWindowStatus(windowStatus, force); - } - - private WindowStatus backStatus(WindowStatus status) { - switch (status) { - case TABLE: - return null; - case SPELL: - return onTablet ? null : WindowStatus.TABLE; - case FILTER: - case SETTINGS: - return onTablet ? WindowStatus.SPELL : WindowStatus.TABLE; - default: - return prevWindowStatus; - } - } - - private boolean isMainViewStatus(WindowStatus status) { - switch (status) { - case TABLE: - case FILTER: - return true; - case SPELL: - return onTablet; - default: - return false; - } + private void updateFAB(NavDestination destination) { + updateFABVisibility(destination); } private List getSpellSlotFragments() { diff --git a/app/src/main/java/dnd/jon/spellbook/NameDisplayableEnumSpinnerAdapter.java b/app/src/main/java/dnd/jon/spellbook/NameDisplayableEnumSpinnerAdapter.java new file mode 100644 index 00000000..a2b77d4d --- /dev/null +++ b/app/src/main/java/dnd/jon/spellbook/NameDisplayableEnumSpinnerAdapter.java @@ -0,0 +1,8 @@ +package dnd.jon.spellbook; + +import android.content.Context; + +class NameDisplayableEnumSpinnerAdapter & NameDisplayable> extends NamedEnumSpinnerAdapter { + NameDisplayableEnumSpinnerAdapter(Context context, Class type, int textSize) { super(context, type, DisplayUtils::getDisplayName, textSize); } + NameDisplayableEnumSpinnerAdapter(Context context, Class type) { super(context, type, DisplayUtils::getDisplayName); } +} \ No newline at end of file diff --git a/app/src/main/java/dnd/jon/spellbook/NameDisplayableSpinnerAdapter.java b/app/src/main/java/dnd/jon/spellbook/NameDisplayableSpinnerAdapter.java index 8db4d6bf..210749a3 100644 --- a/app/src/main/java/dnd/jon/spellbook/NameDisplayableSpinnerAdapter.java +++ b/app/src/main/java/dnd/jon/spellbook/NameDisplayableSpinnerAdapter.java @@ -6,3 +6,4 @@ class NameDisplayableSpinnerAdapter & NameDisplayable> extends NameDisplayableSpinnerAdapter(Context context, Class type, int textSize) { super(context, type, DisplayUtils::getDisplayName, textSize); } NameDisplayableSpinnerAdapter(Context context, Class type) { super(context, type, DisplayUtils::getDisplayName); } } + diff --git a/app/src/main/java/dnd/jon/spellbook/NameRowHolder.java b/app/src/main/java/dnd/jon/spellbook/NameRowHolder.java index 9890f092..41da9d05 100644 --- a/app/src/main/java/dnd/jon/spellbook/NameRowHolder.java +++ b/app/src/main/java/dnd/jon/spellbook/NameRowHolder.java @@ -2,7 +2,6 @@ import dnd.jon.spellbook.databinding.NameRowBinding; -// The row holder class public class NameRowHolder extends ItemViewHolder { public NameRowHolder(NameRowBinding binding) { diff --git a/app/src/main/java/dnd/jon/spellbook/NamedEnumSpinnerAdapter.java b/app/src/main/java/dnd/jon/spellbook/NamedEnumSpinnerAdapter.java new file mode 100644 index 00000000..b65372ad --- /dev/null +++ b/app/src/main/java/dnd/jon/spellbook/NamedEnumSpinnerAdapter.java @@ -0,0 +1,30 @@ +package dnd.jon.spellbook; + +import android.content.Context; +import android.view.Gravity; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.ArrayAdapter; +import android.widget.TextView; + +import androidx.annotation.NonNull; + +import java.util.Arrays; +import java.util.function.BiFunction; + +class NamedEnumSpinnerAdapter> extends NamedSpinnerAdapter { + + // Member values + private final Class type; + + NamedEnumSpinnerAdapter(Context context, Class type, BiFunction namingFunction, int textSize) { + super(context, type, namingFunction, textSize); + this.type = type; + } + + NamedEnumSpinnerAdapter(Context context, Class type, BiFunction namingFunction) { this(context, type, namingFunction,12); } + + T[] getData() { return type.getEnumConstants(); } + +} diff --git a/app/src/main/java/dnd/jon/spellbook/NamedSpinnerAdapter.java b/app/src/main/java/dnd/jon/spellbook/NamedSpinnerAdapter.java index 81e9f1c1..b0b4c04d 100644 --- a/app/src/main/java/dnd/jon/spellbook/NamedSpinnerAdapter.java +++ b/app/src/main/java/dnd/jon/spellbook/NamedSpinnerAdapter.java @@ -15,15 +15,9 @@ class NamedSpinnerAdapter> extends DefaultSpinnerAdapter { - private static String[] objects = null; - - // Member values - private final Class type; - NamedSpinnerAdapter(Context context, Class type, BiFunction namingFunction, int textSize) { - super(context, type.getEnumConstants(), namingFunction); - this.type = type; - } + super(context, type.getEnumConstants(), namingFunction, textSize); + } NamedSpinnerAdapter(Context context, Class type, BiFunction namingFunction) { this(context, type, namingFunction,12); } diff --git a/app/src/main/java/dnd/jon/spellbook/Range.java b/app/src/main/java/dnd/jon/spellbook/Range.java index 4cd0758b..0df44d24 100755 --- a/app/src/main/java/dnd/jon/spellbook/Range.java +++ b/app/src/main/java/dnd/jon/spellbook/Range.java @@ -54,6 +54,7 @@ public enum RangeType implements QuantityType { Range(RangeType type, float value, LengthUnit unit, String str) { super(type, value, unit, str); } + Range(RangeType type, float value, LengthUnit unit) { super(type, value, unit); } Range(RangeType type, float length) { super(type, length, LengthUnit.FOOT); } @@ -96,7 +97,6 @@ String makeString(boolean useStored, Function typeNameGetter, case RANGED: { final Function unitNameGetter = (value == 1) ? unitSingularNameGetter : unitPluralNameGetter; final String ft = unitNameGetter.apply(unit); - //System.out.println("ft is " + ft); return valueString + " " + ft; } default: @@ -109,10 +109,9 @@ String makeString(Function typeNameGetter, Function typeNameGetter, Function lengthUnitMaker, boolean useForStr) { try { diff --git a/app/src/main/java/dnd/jon/spellbook/RetainedViewSpellbookFragment.java b/app/src/main/java/dnd/jon/spellbook/RetainedViewSpellbookFragment.java new file mode 100644 index 00000000..e595bab7 --- /dev/null +++ b/app/src/main/java/dnd/jon/spellbook/RetainedViewSpellbookFragment.java @@ -0,0 +1,35 @@ +package dnd.jon.spellbook; + +import android.os.Bundle; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; + +import androidx.annotation.NonNull; +import androidx.viewbinding.ViewBinding; + +// I'm not a huge fan of this! +// but there are some fragments that we only ever need one of +// and that we can reuse throughout the application. +// Navigation by default will destroy/recreate the fragment & view +// on each navigation (which is noticeable particularly for the +// non-animated transitions). So we do this instead. +public abstract class RetainedViewSpellbookFragment extends SpellbookFragment { + + protected static ViewBinding retainedBinding = null; + + RetainedViewSpellbookFragment() { super();} + RetainedViewSpellbookFragment(int layoutID) { super(layoutID); } + + @Override + public View onCreateView(@NonNull LayoutInflater inflater, + ViewGroup container, + Bundle savedInstanceState) { + if (retainedBinding != null && retainedBinding.getRoot().getContext() == context) { + binding = (VB) retainedBinding; + return binding.getRoot(); + } + return super.onCreateView(inflater, container, savedInstanceState); + } + +} diff --git a/app/src/main/java/dnd/jon/spellbook/SortFilterFragment.java b/app/src/main/java/dnd/jon/spellbook/SortFilterFragment.java index 247b087a..8f7ca67f 100644 --- a/app/src/main/java/dnd/jon/spellbook/SortFilterFragment.java +++ b/app/src/main/java/dnd/jon/spellbook/SortFilterFragment.java @@ -1,6 +1,6 @@ package dnd.jon.spellbook; -import android.content.Context; +import android.annotation.SuppressLint; import android.os.Bundle; import android.text.InputFilter; import android.view.LayoutInflater; @@ -15,13 +15,9 @@ import androidx.annotation.NonNull; import androidx.databinding.DataBindingUtil; -import androidx.fragment.app.Fragment; -import androidx.fragment.app.FragmentActivity; -import androidx.lifecycle.ViewModelProvider; import androidx.viewbinding.ViewBinding; import org.apache.commons.lang3.ArrayUtils; -import org.javatuples.Quintet; import org.javatuples.Sextet; import org.javatuples.Triplet; @@ -52,12 +48,9 @@ import dnd.jon.spellbook.databinding.SortLayoutBinding; import dnd.jon.spellbook.databinding.YesNoFilterViewBinding; -public class SortFilterFragment extends Fragment { +public class SortFilterFragment extends SpellbookFragment { - private SortFilterLayoutBinding binding; private SortFilterStatus sortFilterStatus; - private SpellbookViewModel viewModel; - private Context context; // Header/expanding views private final HashMap expandingViews = new HashMap<>(); @@ -89,34 +82,45 @@ public SortFilterFragment() { super(R.layout.sort_filter_layout); } - @Override - public void onAttach(@NonNull Context context) { - super.onAttach(context); - this.context = context; - } + @SuppressLint("StaticFieldLeak") + private static SortFilterLayoutBinding rootBinding = null; + private static boolean needSetup = true; @Override public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { + + // I don't really like doing this! + // but there's only ever one of these views on screen at a time + // and we're going to use it over and over and over. + // There's a slight, but noticeable delay in recreating this on + // every navigation. So we do this instead. + // Note that we will need to recreate the view on a context change, so check for that. + // AFAICT, this isn't a concern for other views like the spell window view - + // this view is just (relatively) heavyweight + needSetup = (rootBinding == null) || (rootBinding.getRoot().getContext() != context); + if (!needSetup) { + acquireViewModel(); + sortFilterStatus = viewModel.getSortFilterStatus(); + binding = rootBinding; + return rootBinding.getRoot(); + } super.onCreateView(inflater, container, savedInstanceState); - final FragmentActivity activity = requireActivity(); - this.viewModel = new ViewModelProvider(activity).get(SpellbookViewModel.class); - viewModel.currentSortFilterStatus().observe(getViewLifecycleOwner(), this::updateSortFilterStatus); binding = SortFilterLayoutBinding.inflate(inflater, container, false); + rootBinding = binding; return binding.getRoot(); } @Override public void onViewCreated(@NonNull View view, Bundle savedInstanceState) { super.onViewCreated(view, savedInstanceState); - setup(); - } - - @Override - public void onDestroyView() { - super.onDestroyView(); - binding = null; + if (needSetup) { + viewModel.currentSortFilterStatus().observe(getViewLifecycleOwner(), this::updateSortFilterStatus); + setup(); + needSetup = false; + } + viewModel.currentCreatedSources().observe(getViewLifecycleOwner(), (sources) -> this.refreshSourceFilters()); } private String stringFromID(int stringID) { return getResources().getString(stringID); } @@ -145,8 +149,8 @@ private void setupSortElements() { // Populate the dropdown spinners final int sortTextSize = 18; - final NamedSpinnerAdapter sortAdapter1 = new NamedSpinnerAdapter<>(context, SortField.class, DisplayUtils::getDisplayName, sortTextSize); - final NamedSpinnerAdapter sortAdapter2 = new NamedSpinnerAdapter<>(context, SortField.class, DisplayUtils::getDisplayName, sortTextSize); + final NamedEnumSpinnerAdapter sortAdapter1 = new NamedEnumSpinnerAdapter<>(context, SortField.class, DisplayUtils::getDisplayName, sortTextSize); + final NamedEnumSpinnerAdapter sortAdapter2 = new NamedEnumSpinnerAdapter<>(context, SortField.class, DisplayUtils::getDisplayName, sortTextSize); sortSpinner1.setAdapter(sortAdapter1); sortSpinner2.setAdapter(sortAdapter2); @@ -515,9 +519,27 @@ private List populateFilters( // if (items == null) { return new ArrayList<>(); } // return populateFilters(enumType, allItems, items); // } - - private void populateFilterBindings() { + + private void clearFilters(Class type) { + final Sextet,Integer,Integer,Integer,Integer> data = filterBlockInfo.get(type); + final ViewBinding filterBinding = data.getValue1().apply(binding); + final Sextet filterViews = getFilterViews(filterBinding); + final GridLayout gridLayout = filterViews.getValue0(); + gridLayout.removeAllViews(); + classToBindingsMap.remove(type); + } + + private void populateSourceFilters() { classToBindingsMap.put(Source.class, populateFilters(Source.class, LocalizationUtils.supportedSources(), LocalizationUtils.supportedCoreSourcebooks())); + } + + private void refreshSourceFilters() { + clearFilters(Source.class); + populateSourceFilters(); + } + + private void populateFilterBindings() { + populateSourceFilters(); classToBindingsMap.put(CasterClass.class, populateFilters(CasterClass.class, LocalizationUtils.supportedClasses())); classToBindingsMap.put(School.class, populateFilters(School.class)); classToBindingsMap.put(CastingTime.CastingTimeType.class, populateFilters(CastingTime.CastingTimeType.class)); @@ -753,7 +775,7 @@ private void setSortSettings() { // Set the spinners to the appropriate positions final Spinner sortSpinner1 = sortBinding.sortField1Spinner; final Spinner sortSpinner2 = sortBinding.sortField2Spinner; - final NamedSpinnerAdapter adapter = (NamedSpinnerAdapter) sortSpinner1.getAdapter(); + final NamedEnumSpinnerAdapter adapter = (NamedEnumSpinnerAdapter) sortSpinner1.getAdapter(); final List sortData = Arrays.asList(adapter.getData()); final SortField sf1 = sortFilterStatus.getFirstSortField(); sortSpinner1.setSelection(sortData.indexOf(sf1), false); diff --git a/app/src/main/java/dnd/jon/spellbook/SortFilterStatus.java b/app/src/main/java/dnd/jon/spellbook/SortFilterStatus.java index 3966a5a8..23e99453 100644 --- a/app/src/main/java/dnd/jon/spellbook/SortFilterStatus.java +++ b/app/src/main/java/dnd/jon/spellbook/SortFilterStatus.java @@ -16,6 +16,7 @@ import java.util.Collection; import java.util.EnumSet; import java.util.HashSet; +import java.util.Objects; import java.util.Set; import java.util.function.Consumer; import java.util.function.Function; @@ -183,9 +184,8 @@ private static > Collection getVisibleValues(boolean b, Col return getVisibleValues(b, visibleValues, type.getEnumConstants()); } - private static Set createSetFromNames(Class type, String[] names, Function nameConstructor) { + private static Set createSetFromNames(String[] names, Function nameConstructor) { return Arrays.stream(names).map(nameConstructor).collect(Collectors.toSet()); - } private static > EnumSet createEnumSetFromNames(Class type, String[] names, Function nameConstructor) { @@ -688,7 +688,13 @@ static SortFilterStatus fromJSON(JSONObject json) throws JSONException { } status.setComponents(false, noComponents); - status.setVisibleSourcebooks(createSetFromNames(Source.class, stringArrayFromJSON(json.getJSONArray(sourcebooksKey)), Source::fromInternalName)); + // Unlike the rest of our filtering options, users can create, and thus delete, sources + // so we need to filter out any null sources in case there are sources listed as visible + // that no longer exist + final Set sources = createSetFromNames(stringArrayFromJSON(json.getJSONArray(sourcebooksKey)), Source::fromInternalName); + sources.removeIf(Objects::isNull); + status.setVisibleSourcebooks(sources); + status.setVisibleSchools(createEnumSetFromNames(School.class, stringArrayFromJSON(json.getJSONArray(schoolsKey)), School::fromInternalName)); status.setVisibleClasses(createEnumSetFromNames(CasterClass.class, stringArrayFromJSON(json.getJSONArray(classesKey)), CasterClass::fromInternalName)); status.setVisibleCastingTimeTypes(createEnumSetFromNames(CastingTime.CastingTimeType.class, stringArrayFromJSON(json.getJSONArray(castingTimeTypesKey)), CastingTime.CastingTimeType::fromInternalName)); diff --git a/app/src/main/java/dnd/jon/spellbook/SortFilterStatusAdapter.java b/app/src/main/java/dnd/jon/spellbook/SortFilterStatusAdapter.java index 3c44e8c5..12a64664 100644 --- a/app/src/main/java/dnd/jon/spellbook/SortFilterStatusAdapter.java +++ b/app/src/main/java/dnd/jon/spellbook/SortFilterStatusAdapter.java @@ -45,7 +45,7 @@ public void bind(String name) { popupMenu.inflate(R.menu.options_menu); popupMenu.setOnMenuItemClickListener((menuItem) -> { final int itemID = menuItem.getItemId(); - if (itemID == R.id.options_rename) { + if (itemID == R.id.options_update) { final Bundle args = new Bundle(); args.putString(NameChangeDialog.nameKey, binding.getName()); final StatusNameChangeDialog dialog = new StatusNameChangeDialog(); diff --git a/app/src/main/java/dnd/jon/spellbook/Source.java b/app/src/main/java/dnd/jon/spellbook/Source.java index 040a7be3..bd647145 100755 --- a/app/src/main/java/dnd/jon/spellbook/Source.java +++ b/app/src/main/java/dnd/jon/spellbook/Source.java @@ -4,13 +4,19 @@ import androidx.annotation.Keep; +import org.json.JSONException; +import org.json.JSONObject; + +import java.util.ArrayList; import java.util.Arrays; import java.util.Collection; import java.util.HashMap; +import java.util.List; import java.util.Map; import java.util.function.Predicate; public class Source implements NameDisplayable { + private static Source[] _values = new Source[]{}; private static final SparseArray _valueMap = new SparseArray<>(); private static final Map _nameMap = new HashMap<>(); @@ -41,6 +47,9 @@ private Source(int value, int displayNameID, int codeID, String internalName, St this.core = core; this.created = created; + this.displayName = null; + this.code = null; + addToStructures(this); } @@ -52,9 +61,36 @@ public Source(int displayNameID, int codeID, String internalName, String interna this(_values.length, displayNameID, codeID, internalName, internalCode, core, false); } + Source(String name, String code, boolean core) { + this.value = _values.length; + this.displayName = name; + this.code = code; + this.displayNameID = -1; + this.codeID = -1; + this.internalName = name; + this.internalCode = code; + this.core = core; + this.created = true; + + addToStructures(this); + } + + Source(String name, String code) { + this(name, code, false); + } + + public static Source create(String name, String code) { + if (_codeMap.containsKey(code)) { + return _codeMap.get(code); + } + return new Source(name, code); + } + final private int value; final private int displayNameID; + private String displayName; // For created sources final private int codeID; + private String code; // For created sources final private String internalName; final private String internalCode; final private boolean core; @@ -65,6 +101,26 @@ public Source(int displayNameID, int codeID, String internalName, String interna public int getCodeID() { return codeID; } public String getInternalName() { return internalName; } public String getInternalCode() { return internalCode; } + public String getDisplayName() { return displayName; } + public String getCode() { return code; } + boolean isCore() { return core; } + boolean isCreated() { return created; } + + boolean rename(String newName) { + if (!created || displayName == null) { + return false; + } + displayName = newName; + return true; + } + + boolean changeCode(String newCode) { + if (!created || code == null) { + return false; + } + code = newCode; + return true; + } static Source[] values() { return _values; } static Collection collection() { return Arrays.asList(_values.clone()); } @@ -77,6 +133,17 @@ private static void addToStructures(Source source) { _codeMap.put(source.internalCode, source); } + private static void removeFromStructures(Source source) { + _values = SpellbookUtils.removeElement(Source.class, _values, source); + _valueMap.remove(source.value); + _nameMap.remove(source.internalName); + _codeMap.remove(source.internalCode); + } + + public void delete() { + removeFromStructures(this); + } + static Source fromValue(int value) { return _valueMap.get(value); } @@ -112,6 +179,4 @@ static Source[] createdSources() { public boolean equals(Source other) { return this.internalName.equals(other.internalName) && this.internalCode.equals(other.internalCode); } - - } diff --git a/app/src/main/java/dnd/jon/spellbook/SourceAdapter.java b/app/src/main/java/dnd/jon/spellbook/SourceAdapter.java new file mode 100644 index 00000000..80926b66 --- /dev/null +++ b/app/src/main/java/dnd/jon/spellbook/SourceAdapter.java @@ -0,0 +1,112 @@ +package dnd.jon.spellbook; + +import android.content.Intent; +import android.os.Bundle; +import android.view.LayoutInflater; +import android.view.MenuItem; +import android.view.View; +import android.view.ViewGroup; +import android.widget.PopupMenu; + +import androidx.annotation.NonNull; +import androidx.fragment.app.FragmentActivity; + +import org.json.JSONException; + +import dnd.jon.spellbook.databinding.NameRowBinding; + +public class SourceAdapter extends NamedItemAdapter { + + private static final String confirmDeleteTag = "confirmDeleteSource"; + private static final String duplicateTag = "duplicateSource"; + private static final String updateSourceTag = "updateSource"; + + SourceAdapter(FragmentActivity fragmentActivity) { + super(fragmentActivity, SpellbookViewModel::currentCreatedSourceNames); + } + + @NonNull + public SourceRowHolder onCreateViewHolder(ViewGroup parent, int viewType) { + final LayoutInflater inflater = LayoutInflater.from(parent.getContext()); + final NameRowBinding binding = NameRowBinding.inflate(inflater, parent, false); + return new SourceRowHolder(binding); + } + + + public class SourceRowHolder extends NameRowHolder { + SourceRowHolder(NameRowBinding binding) { + super(binding); + } + + public void bind(String name) { + super.bind(name); + + if (name != null) { + binding.optionsButton.setOnClickListener((View v) -> { + final PopupMenu popupMenu = new PopupMenu(activity, binding.optionsButton); + popupMenu.inflate(R.menu.options_menu); + + // "Duplicate" is kind of a pointless option + // for an item with two text fields, neither of which + // one would really want to reuse + final MenuItem duplicateItem = popupMenu.getMenu().findItem(R.id.options_duplicate); + if (duplicateItem != null) { + duplicateItem.setVisible(false); + } + + popupMenu.setOnMenuItemClickListener((menuItem) -> { + final int itemID = menuItem.getItemId(); + if (itemID == R.id.options_update) { + final Bundle args = new Bundle(); + args.putString(SourceCreationDialog.NAME_KEY, binding.getName()); + final SourceCreationDialog dialog = new SourceCreationDialog(); + dialog.setArguments(args); + dialog.show(activity.getSupportFragmentManager(), updateSourceTag); + + // In case the duplicate option somehow is displayed, + // we may as well do something sensible + } else if (itemID == R.id.options_duplicate) { + final Bundle args = new Bundle(); + args.putString(SourceCreationDialog.NAME_KEY, binding.getName()); + final SourceCreationDialog dialog = new SourceCreationDialog(); + dialog.setArguments(args); + dialog.show(activity.getSupportFragmentManager(), duplicateTag); + } else if (itemID == R.id.options_delete) { + final Bundle args = new Bundle(); + args.putString(DeleteSourceDialog.NAME_KEY, binding.getName()); + final DeleteSourceDialog dialog = new DeleteSourceDialog(); + dialog.setArguments(args); + dialog.show(activity.getSupportFragmentManager(), confirmDeleteTag); + } else if (itemID == R.id.options_export) { + try { + final Source source = viewModel.getCreatedSourceByName(name); + final String json = JSONUtils.asJSON(source, activity).toString(); + final Intent sendIntent = new Intent(); + sendIntent.setAction(Intent.ACTION_SEND); + sendIntent.putExtra(Intent.EXTRA_TEXT, json); + sendIntent.setType("application/json"); + + final Intent shareIntent = Intent.createChooser(sendIntent, null); + activity.startActivity(shareIntent); + } catch (JSONException e) { + e.printStackTrace(); + } + } + return false; + }); + popupMenu.show(); + }); + + // Set the listener for the label + binding.nameLabel.setOnClickListener((v) -> { + final String sourceName = binding.getName(); + final SourceCreationDialog dialog = new SourceCreationDialog(); + final Bundle args = new Bundle(); + args.putString(SourceCreationDialog.NAME_KEY, sourceName); + dialog.setArguments(args); + dialog.show(activity.getSupportFragmentManager(), updateSourceTag); + }); + } + } + } +} diff --git a/app/src/main/java/dnd/jon/spellbook/SourceCreationDialog.java b/app/src/main/java/dnd/jon/spellbook/SourceCreationDialog.java new file mode 100644 index 00000000..b5dc225e --- /dev/null +++ b/app/src/main/java/dnd/jon/spellbook/SourceCreationDialog.java @@ -0,0 +1,113 @@ +package dnd.jon.spellbook; + +import android.app.Dialog; +import android.graphics.Color; +import android.os.Bundle; +import android.util.TypedValue; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.appcompat.app.AlertDialog; +import androidx.fragment.app.DialogFragment; +import androidx.fragment.app.FragmentActivity; +import androidx.lifecycle.ViewModelProvider; + +import dnd.jon.spellbook.databinding.SourceCreationBinding; + +public class SourceCreationDialog extends DialogFragment { + + private static final String SOURCE_KEY = "source"; + static final String NAME_KEY = "name"; + private static final String ABBREVIATION_KEY = "abbreviation"; + + private Source baseSource; + private SourceCreationBinding binding; + private SpellbookViewModel viewModel; + + @NonNull + @Override + public Dialog onCreateDialog(Bundle savedInstanceState) { + + final FragmentActivity activity = requireActivity(); + AlertDialog.Builder builder = new AlertDialog.Builder(activity); + viewModel = new ViewModelProvider(activity).get(SpellbookViewModel.class); + + binding = SourceCreationBinding.inflate(getLayoutInflater()); + builder.setView(binding.getRoot()); + + // For e.g. coming off a rotation + if (savedInstanceState != null) { + final String name = savedInstanceState.getString(NAME_KEY, ""); + final String abbreviation = savedInstanceState.getString(ABBREVIATION_KEY, ""); + binding.nameEntry.setText(name); + binding.abbreviationEntry.setText(abbreviation); + } + + // For editing an existing source + final Bundle args = getArguments(); + if (args != null) { + final String name = args.getString(NAME_KEY); + baseSource = viewModel.getCreatedSourceByName(name); + if (baseSource != null) { + binding.nameEntry.setText(DisplayUtils.getDisplayName(baseSource, activity)); + binding.abbreviationEntry.setText(DisplayUtils.getCode(baseSource, activity)); + + binding.title.setText(R.string.edit_source); + binding.createSourceButton.setText(R.string.update_source); + } + } + + binding.sourceCancelButton.setOnClickListener((v) -> this.dismiss()); + binding.createSourceButton.setOnClickListener((v) -> this.createSourceIfValid()); + + return builder.create(); + + } + + @Override + public void onDestroyView() { + super.onDestroyView(); + binding = null; + } + + // For handling rotations + public void onSaveInstanceState(@NonNull Bundle outState) { + super.onSaveInstanceState(outState); + outState.putString(NAME_KEY, binding.nameEntry.getText().toString()); + outState.putString(ABBREVIATION_KEY, binding.abbreviationEntry.toString()); + } + + private void createSourceIfValid() { + // Check that the source name and abbreviation are okay + final String name = binding.nameEntry.getText().toString(); + final boolean checkExisting = baseSource == null; + String error = viewModel.sourceNameValidator(name, checkExisting); + if (!error.isEmpty()) { + setErrorMessage(error); + return; + } + + final String abbreviation = binding.abbreviationEntry.getText().toString(); + error = viewModel.sourceAbbreviationValidator(abbreviation); + if (!error.isEmpty()) { + setErrorMessage(error); + return; + } + + // Create the source + final Source source = Source.create(name, abbreviation); + if (baseSource != null) { + viewModel.updateSourceFile(source, baseSource.getDisplayName(), baseSource.getCode()); + } else { + viewModel.addCreatedSource(source); + } + this.dismiss(); + } + + private void setErrorMessage(String error) { + final TextView tv = binding.errorText; + tv.setTextSize(TypedValue.COMPLEX_UNIT_DIP, 15); + tv.setTextColor(Color.RED); + tv.setText(error); + } +} \ No newline at end of file diff --git a/app/src/main/java/dnd/jon/spellbook/SourceManagementDialog.java b/app/src/main/java/dnd/jon/spellbook/SourceManagementDialog.java new file mode 100644 index 00000000..bb2b2c3b --- /dev/null +++ b/app/src/main/java/dnd/jon/spellbook/SourceManagementDialog.java @@ -0,0 +1,44 @@ +package dnd.jon.spellbook; + +import android.app.AlertDialog; +import android.app.Dialog; +import android.os.Bundle; +import android.view.LayoutInflater; + +import androidx.annotation.NonNull; +import androidx.fragment.app.DialogFragment; +import androidx.fragment.app.FragmentActivity; +import androidx.lifecycle.ViewModelProvider; +import androidx.recyclerview.widget.LinearLayoutManager; +import androidx.recyclerview.widget.RecyclerView; + +import dnd.jon.spellbook.databinding.SourceManagementBinding; + +public class SourceManagementDialog extends DialogFragment { + + private SourceAdapter adapter; + + @NonNull + @Override + public Dialog onCreateDialog(Bundle savedInstanceState) { + super.onCreateDialog(savedInstanceState); + + final FragmentActivity activity = requireActivity(); + final AlertDialog.Builder builder = new AlertDialog.Builder(activity); + + final LayoutInflater inflater = getLayoutInflater(); + final SourceManagementBinding binding = SourceManagementBinding.inflate(inflater); + builder.setView(binding.getRoot()); + + adapter = new SourceAdapter(activity); + final RecyclerView recyclerView = binding.sourceManagementRecyclerView; + recyclerView.setAdapter(adapter); + recyclerView.setLayoutManager(new LinearLayoutManager(activity)); + + final AlertDialog dialog = builder.create(); + dialog.setCanceledOnTouchOutside(true); + return dialog; + } + +} + diff --git a/app/src/main/java/dnd/jon/spellbook/SpellCodec.java b/app/src/main/java/dnd/jon/spellbook/SpellCodec.java index 5805714b..1e915a78 100755 --- a/app/src/main/java/dnd/jon/spellbook/SpellCodec.java +++ b/app/src/main/java/dnd/jon/spellbook/SpellCodec.java @@ -55,7 +55,7 @@ class SpellCodec { } - private Spell parseSpell(JSONObject json, SpellBuilder b, boolean useInternal) throws Exception { + Spell parseSpell(JSONObject json, SpellBuilder b, boolean useInternal) throws JSONException { // Set the values that need no/trivial parsing //System.out.println(json.toString()); @@ -63,7 +63,7 @@ private Spell parseSpell(JSONObject json, SpellBuilder b, boolean useInternal) t //System.out.println("Using internal: " + useInternal); // Value getters - final Function sourcebookGetter = useInternal ? Source::fromInternalName : (string) -> DisplayUtils.getItemFromResourceValue(context, Source.values(), string, Source::getCodeID, Context::getString); + final Function sourcebookGetter = useInternal ? Source::fromInternalName : (string) -> DisplayUtils.sourceFromCode(context, string); final Function rangeGetter = useInternal ? Range::fromInternalString : (string) -> DisplayUtils.rangeFromString(context, string); final Function castingTimeGetter = useInternal ? CastingTime::fromInternalString : (string) -> DisplayUtils.castingTimeFromString(context, string); final Function schoolGetter = useInternal ? School::fromInternalName : (string) -> DisplayUtils.getEnumFromDisplayName(context, School.class, string); @@ -87,9 +87,11 @@ private Spell parseSpell(JSONObject json, SpellBuilder b, boolean useInternal) t final JSONArray locationsArray = json.getJSONArray(LOCATIONS_KEY); for (int i = 0; i < locationsArray.length(); i++) { final JSONObject location = locationsArray.getJSONObject(i); - final Source sb = sourcebookGetter.apply(location.getString(SOURCEBOOK_KEY)); - final Integer page = location.getInt(PAGE_KEY); - b.addLocation(sb, page); + final Source source = sourcebookGetter.apply(location.getString(SOURCEBOOK_KEY)); + if (source != null) { + final Integer page = location.getInt(PAGE_KEY); + b.addLocation(source, page); + } } // Duration, concentration, and ritual @@ -204,7 +206,7 @@ JSONObject toJSON(Spell spell) throws JSONException { final JSONArray locations = new JSONArray(); for (Map.Entry entry: spell.getLocations().entrySet()) { final JSONObject location = new JSONObject(); - location.put(SOURCEBOOK_KEY, DisplayUtils.getProperty(context, entry.getKey(), Source::getCodeID, Context::getString)); + location.put(SOURCEBOOK_KEY, DisplayUtils.getCode(entry.getKey(), context)); location.put(PAGE_KEY, entry.getValue()); locations.put(i++, location); } diff --git a/app/src/main/java/dnd/jon/spellbook/SpellCreationDialog.java b/app/src/main/java/dnd/jon/spellbook/SpellCreationDialog.java new file mode 100644 index 00000000..7c60094e --- /dev/null +++ b/app/src/main/java/dnd/jon/spellbook/SpellCreationDialog.java @@ -0,0 +1,65 @@ +package dnd.jon.spellbook; + +import android.app.AlertDialog; +import android.app.Dialog; +import android.content.DialogInterface; +import android.os.Bundle; +import android.view.LayoutInflater; + +import androidx.annotation.NonNull; +import androidx.fragment.app.DialogFragment; +import androidx.fragment.app.FragmentActivity; +import androidx.lifecycle.ViewModelProvider; + +import dnd.jon.spellbook.databinding.SpellCreationBinding; + +public class SpellCreationDialog extends DialogFragment { + private static final String TAG = "SpellCreationDialog"; + private static final String SPELL_KEY = "spell"; + private SpellbookViewModel viewModel; + private SpellCreationHandler handler; + + @NonNull + @Override + public Dialog onCreateDialog(Bundle savedInstanceState) { + super.onCreateDialog(savedInstanceState); + + final FragmentActivity activity = requireActivity(); + viewModel = new ViewModelProvider(activity).get(SpellbookViewModel.class); + + Spell editingSpell = viewModel.currentEditingSpell().getValue(); + Spell spell = editingSpell; + if (savedInstanceState != null) { + spell = savedInstanceState.getParcelable(SPELL_KEY); + if (editingSpell == null && spell != null) { + viewModel.setCurrentEditingSpell(spell); + } + } + + final AlertDialog.Builder builder = new AlertDialog.Builder(activity); + final LayoutInflater inflater = getLayoutInflater(); + final SpellCreationBinding binding = SpellCreationBinding.inflate(inflater); + handler = new SpellCreationHandler(activity, binding, TAG, spell); + handler.setOnSpellCreated(this::dismiss); + handler.setup(); + builder.setView(binding.getRoot()); + viewModel.currentEditingSpell().observe(requireActivity(), (newSpell) -> { + if (newSpell != null) { + handler.setSpellInfo(newSpell); + } + }); + return builder.create(); + } + + @Override + public void onSaveInstanceState(@NonNull Bundle outState) { + super.onSaveInstanceState(outState); + outState.putParcelable(SPELL_KEY, viewModel.currentEditingSpell().getValue()); + } + + @Override + public void onDismiss(@NonNull DialogInterface dialog) { + viewModel.setCurrentEditingSpell(null); + super.onDismiss(dialog); + } +} diff --git a/app/src/main/java/dnd/jon/spellbook/SpellCreationFragment.java b/app/src/main/java/dnd/jon/spellbook/SpellCreationFragment.java index 53910e02..39a31273 100644 --- a/app/src/main/java/dnd/jon/spellbook/SpellCreationFragment.java +++ b/app/src/main/java/dnd/jon/spellbook/SpellCreationFragment.java @@ -1,57 +1,25 @@ package dnd.jon.spellbook; -import android.content.Context; import android.os.Bundle; -import android.util.Log; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; -import android.widget.AdapterView; -import android.widget.CheckBox; -import android.widget.GridLayout; -import android.widget.RadioButton; -import android.widget.ScrollView; -import android.widget.Spinner; import androidx.annotation.NonNull; -import androidx.fragment.app.Fragment; import androidx.fragment.app.FragmentActivity; import androidx.lifecycle.ViewModelProvider; +import androidx.navigation.fragment.NavHostFragment; -import java.lang.reflect.Constructor; -import java.lang.reflect.InvocationTargetException; -import java.util.Arrays; -import java.util.Collection; -import java.util.List; -import java.util.Locale; -import java.util.Map; -import java.util.HashMap; -import java.util.SortedSet; -import java.util.TreeSet; -import java.util.function.Function; - -import org.javatuples.Pair; -import org.javatuples.Quartet; - -import dnd.jon.spellbook.databinding.QuantityTypeCreationBinding; import dnd.jon.spellbook.databinding.SpellCreationBinding; -public final class SpellCreationFragment extends Fragment { - +public final class SpellCreationFragment extends SpellbookFragment { + private static final String TAG = "SpellCreationFragment"; private static final String SPELL_KEY = "spell"; + private SpellCreationHandler handler; - private SpellbookViewModel viewModel; - private SpellCreationBinding binding; - - private static final String TAG = "SpellCreationActivity"; // For logging - - private static final Map, Quartet, Class, Function, Integer>> quantityTypeInfo = new HashMap, Quartet, Class, Function, Integer>>() {{ - put(CastingTime.CastingTimeType.class, new Quartet<>(CastingTime.class, TimeUnit.class, (b) -> b.castingTimeSelection, R.string.casting_time)); - put(Duration.DurationType.class, new Quartet<>(Duration.class, TimeUnit.class, (b) -> b.durationSelection, R.string.duration)); - put(Range.RangeType.class, new Quartet<>(Range.class, LengthUnit.class, (b) -> b.rangeSelection, R.string.range)); - }}; - - public SpellCreationFragment() { super(R.layout.spell_creation); } + public SpellCreationFragment() { + super(R.layout.spell_creation); + } @Override public View onCreateView(@NonNull LayoutInflater inflater, @@ -59,315 +27,53 @@ public View onCreateView(@NonNull LayoutInflater inflater, Bundle savedInstanceState) { super.onCreateView(inflater, container, savedInstanceState); binding = SpellCreationBinding.inflate(inflater); - final FragmentActivity activity = requireActivity(); - this.viewModel = new ViewModelProvider(activity, activity.getDefaultViewModelProviderFactory()).get(SpellbookViewModel.class); - setup(); - return binding.getRoot(); - } - - @Override - public void onDestroyView() { - super.onDestroyView(); - binding = null; - } - - private void setup() { - - // Set the toolbar as the app bar for the activity - //final Toolbar toolbar = binding.toolbar; - //setSupportActionBar(toolbar); - //toolbar.setTitle(R.string.spell_creation); - - // Set up the back arrow on the navigation bar - //toolbar.setNavigationIcon(R.drawable.ic_action_back); - //toolbar.setNavigationOnClickListener((v) -> this.finish()); - - final Context context = requireContext(); - - // Populate the school spinner - final NameDisplayableSpinnerAdapter schoolAdapter = new NameDisplayableSpinnerAdapter<>(context, School.class); - binding.schoolSelector.setAdapter(schoolAdapter); - - // Populate the checkbox grid for caster classes - populateCheckboxGrid(CasterClass.class, binding.classesSelectionGrid); - - // Populate the options for the quantity types - populateRangeSelectionWindow(CastingTime.CastingTimeType.class, TimeUnit.class, binding.castingTimeSelection); - populateRangeSelectionWindow(Duration.DurationType.class, TimeUnit.class, binding.durationSelection); - populateRangeSelectionWindow(Range.RangeType.class, LengthUnit.class, binding.rangeSelection); - - // Set up the materials entry to show when the material checkbox is selected, and hide when it isn't - binding.materialCheckbox.setOnCheckedChangeListener((cb, checked) -> { - final int visibility = checked ? View.VISIBLE : View.GONE; - binding.materialsEntry.setVisibility(visibility); - }); - // Set up the create spell button - binding.createSpellButton.setOnClickListener( (v) -> createSpell() ); - - // Determine whether we're creating a new spell, or modifying an existing created spell - final Bundle args = getArguments(); - final Spell spell = args != null ? args.getParcelable(SPELL_KEY) : null; - if (spell != null) { - setSpellInfo(spell); - } - - } - - private & NameDisplayable> void populateCheckboxGrid(Class enumType, GridLayout grid) { - - // Get the enum constants - final E[] enums = enumType.getEnumConstants(); - - // If E somehow isn't an enum type, we return - // Note that the generic bounds guarantee that this won't happen - if (enums == null) { return; } - - // For each enum instance, do the following: - // Create a checkbox with the enum's name as its text - // Add it to the grid layout - final Context context = requireContext(); - for (E e : enums) { - final CheckBox checkBox = new CheckBox(context); - checkBox.setText(DisplayUtils.getDisplayName(context, e)); - checkBox.setTag(e); - grid.addView(checkBox); + final FragmentActivity activity = requireActivity(); + viewModel = new ViewModelProvider(activity).get(SpellbookViewModel.class); + + Spell editingSpell = viewModel.currentEditingSpell().getValue(); + Spell spell = editingSpell; + if (savedInstanceState != null) { + spell = savedInstanceState.getParcelable(SPELL_KEY); + if (editingSpell == null && spell != null) { + viewModel.setCurrentEditingSpell(spell); + } } - } - private & QuantityType> void populateRadioGrid(Class enumType, RadioGridGroup radioGrid) { + handler = new SpellCreationHandler(activity, binding, TAG, spell); - // Get the enum constants - final E[] enums = enumType.getEnumConstants(); - - // If E somehow isn't an enum type, we return - // Note that the generic bounds guarantee that this won't happen - if (enums == null) { return; } - - // For each enum instance, do the following: - // Create a radio with the enum's name as its text - // Add it to the radio group - final Context context = requireContext(); - for (E e : enums) { - final RadioButton button = new RadioButton(context); - button.setText(DisplayUtils.getDisplayName(context, e)); - button.setTag(e); - radioGrid.addView(button); - } + return binding.getRoot(); } - private & QuantityType, U extends Enum & Unit> void populateRangeSelectionWindow(Class enumType, Class unitType, QuantityTypeCreationBinding qtcBinding) { - - // Get the context - final Context context = requireContext(); - - // Set the choices for the first spinner - final Spinner optionsSpinner = qtcBinding.quantityTypeSpinner; - final NameDisplayableSpinnerAdapter optionsAdapter = new NameDisplayableSpinnerAdapter(context, enumType, 12); - optionsSpinner.setAdapter(optionsAdapter); - - // If the spanning type is selected, we want to display the spanning option choices - optionsSpinner.setOnItemSelectedListener(new AdapterView.OnItemSelectedListener() { - @Override - public void onItemSelected(AdapterView parent, View view, int position, long id) { - - // Show or hide the spanning stuff as needed - final E type = enumType.cast(parent.getItemAtPosition(position)); - if (type == null) { return; } - final int spanningVisibility = type.isSpanningType() ? View.VISIBLE : View.GONE; - qtcBinding.spanningTypeContent.setVisibility(spanningVisibility); - } - - @Override - public void onNothingSelected(AdapterView parent) { - - } + @Override + public void onViewCreated(@NonNull View view, Bundle savedInstanceState) { + super.onViewCreated(view, savedInstanceState); + handler.setup(); + handler.setOnSpellCreated(() -> { + final NavHostFragment navHostFragment = (NavHostFragment) requireActivity().getSupportFragmentManager().findFragmentById(R.id.nav_host_fragment); + navHostFragment.getNavController().navigateUp(); }); - optionsSpinner.setSelection(0); - - // Populate the spanning type elements - // Note that they're hidden to start - final Spinner unitSpinner = qtcBinding.spanningUnitSelector; - final UnitTypeSpinnerAdapter unitAdapter = new UnitTypeSpinnerAdapter(context, unitType, 12); - unitSpinner.setAdapter(unitAdapter); - - } - - private SortedSet selectedClasses() { - final TreeSet classes = new TreeSet<>(); - final GridLayout grid = binding.classesSelectionGrid; - for (int i = 0; i < grid.getChildCount(); ++i) { - final CheckBox cb = (CheckBox) grid.getChildAt(i); - if (cb.isChecked()) { - classes.add((CasterClass) cb.getTag()); + viewModel.currentEditingSpell().observe(requireActivity(), (newSpell) -> { + if (newSpell != null) { + handler.setSpellInfo(newSpell); } - } - return classes; - } - - private void showErrorMessage(String text) { - binding.errorText.setText(text); - binding.spellCreationScroll.fullScroll(ScrollView.FOCUS_UP); + }); } - private void setSpellInfo(Spell spell) { - - // Set any text fields - binding.nameEntry.setText(spell.getName()); - binding.levelEntry.setText(String.format(Locale.US, "%d", spell.getLevel())); - binding.descriptionEntry.setText(spell.getDescription()); - binding.higherLevelEntry.setText(spell.getHigherLevel()); - - // Set the ritual and concentration switches - binding.ritualSelector.setChecked(spell.getRitual()); - binding.concentrationSelector.setChecked(spell.getConcentration()); - - // Set the school spinner to the correct position - SpellbookUtils.setNamedSpinnerByItem(binding.schoolSelector, spell.getSchool()); - - // Set the quantity type UI elements - final List>> spinnersAndGetters = Arrays.asList( - new Pair<>(binding.castingTimeSelection, Spell::getCastingTime), - new Pair<>(binding.durationSelection, Spell::getDuration), - new Pair<>(binding.rangeSelection, Spell::getRange) - ); - for (Pair> pair : spinnersAndGetters) { - final QuantityTypeCreationBinding qtcBinding = pair.getValue0(); - final Function quantityGetter = pair.getValue1(); - final Quantity quantity = quantityGetter.apply(spell); - final QuantityType quantityType = (QuantityType) quantity.type; - SpellbookUtils.setNamedSpinnerByItem(qtcBinding.quantityTypeSpinner, quantity.type); - if (quantityType.isSpanningType()) { - qtcBinding.spanningValueEntry.setText(String.format(Locale.US, "%d", quantity.getValue())); - SpellbookUtils.setNamedSpinnerByItem(qtcBinding.spanningUnitSelector, (Enum) quantity.getUnit()); - } - } - - // Set the checkboxes in the class selection grid - final Collection spellClasses = spell.getClasses(); - for (int i = 0; i < binding.classesSelectionGrid.getChildCount(); ++i) { - final View view = binding.classesSelectionGrid.getChildAt(i); - if (view instanceof RadioButton) { - final RadioButton rb = (RadioButton) view; - final CasterClass cc = (CasterClass) rb.getTag(); - rb.setChecked(spellClasses.contains(cc)); - } - } + @Override + public void onStop() { + viewModel.setCurrentEditingSpell(null); + super.onStop(); } - private void createSpell() { - - // Check the spell name - final String name = binding.nameEntry.getText().toString(); - final String spellNameString = "spell name"; - if (name.isEmpty()) { showErrorMessage("The spell name is empty"); return; } - final Character illegalCharacter = SpellbookViewModel.firstIllegalCharacter(name); - if (illegalCharacter != null) { - showErrorMessage(getString(R.string.illegal_character, spellNameString, illegalCharacter.toString())); - return; - } - - // Check the spell level - int level; - try { - level = Integer.parseInt(binding.levelEntry.getText().toString()); - } catch (NumberFormatException e) { - showErrorMessage(String.format(Locale.US, "The spell level must be an integer between %d and %d", Spellbook.MIN_SPELL_LEVEL, Spellbook.MAX_SPELL_LEVEL)); - return; - } - - // Check the components - final boolean[] components = new boolean[]{ binding.verbalCheckbox.isChecked(), binding.somaticCheckbox.isChecked(), binding.materialCheckbox.isChecked() }; - final boolean oneChecked = components[0] || components[1] || components[2]; - if (!oneChecked) { - showErrorMessage("The spell has no components selected."); return; - } - - // If material is selected, check that the materials description isn't empty - final boolean materialChecked = components[2]; - final String materialsString = materialChecked ? binding.materialsEntry.getText().toString() : ""; - if (materialChecked && materialsString.isEmpty()) { - showErrorMessage("The description of the material components is empty."); - return; - } - - // Get the selected classes - // At least one class must be selected - final SortedSet classes = selectedClasses(); - if (classes.size() == 0) { - showErrorMessage("No caster classes are selected."); - return; - } - - // If one of the spanning types is selected, the text field needs to be filled out - final Map, Quantity> quantityValues = new HashMap<>(); - for (Map.Entry, Quartet, Class, Function, Integer>> entry : quantityTypeInfo.entrySet()) { - - final Class quantityType = entry.getKey(); - final Quartet, Class, Function, Integer> data = entry.getValue(); - final Class quantityClass = data.getValue0(); - final QuantityTypeCreationBinding qtcBinding = data.getValue2().apply(binding); - - final Spinner quantityTypeSpinner = qtcBinding.quantityTypeSpinner; - final QuantityType type = quantityType.cast(quantityTypeSpinner.getSelectedItem()); - - // Get the quantity - Quantity quantity = null; - try { - if (type.isSpanningType()) { - final String spanningText = qtcBinding.spanningValueEntry.getText().toString(); - final boolean spanningTextMissing = spanningText.isEmpty(); - if (spanningTextMissing) { - final int quantityTypeNameID = data.getValue3(); - final String quantityTypeName = getResources().getString(quantityTypeNameID); - showErrorMessage("The entry field for " + quantityTypeName + " is empty."); - return; - } - final Class unitType = data.getValue1(); - final Unit unit = unitType.cast(qtcBinding.spanningUnitSelector.getSelectedItem()); - final int value = Integer.parseInt(qtcBinding.spanningValueEntry.toString()); - final Constructor constructor = quantityClass.getDeclaredConstructor(quantityType, int.class, unitType); - quantity = quantityClass.cast(constructor.newInstance(type, value, unit)); - } else { - final Constructor constructor = quantityClass.getDeclaredConstructor(quantityType); - quantity = quantityClass.cast(constructor.newInstance(type)); - } - } catch (NoSuchMethodException e) { - Log.e(TAG, "Couldn't find constructor:\n" + SpellbookUtils.stackTrace(e)); - } catch (IllegalAccessException | java.lang.InstantiationException | InvocationTargetException e) { - Log.e(TAG, "Error creating quantity:\n" + SpellbookUtils.stackTrace(e)); - } - quantityValues.put(quantityType, quantity); - - } - - // Check if the description is empty - final String description = binding.descriptionEntry.getText().toString(); - if (description.isEmpty()) { - showErrorMessage("The spell description is empty."); - return; - } - - // Once we've passed all of the checks, create the spell - final SpellBuilder spellBuilder = new SpellBuilder(requireActivity()); - final Spell spell = spellBuilder - .setName(name) - .setSchool(School.fromInternalName((String) binding.schoolSelector.getSelectedItem())) - .setLevel(level) - .setRitual(binding.ritualSelector.isChecked()) - .setConcentration(binding.concentrationSelector.isChecked()) - .setCastingTime((CastingTime) quantityValues.get(CastingTime.CastingTimeType.class)) - .setRange((Range) quantityValues.get(Range.RangeType.class)) - .setComponents(components) - .setDuration((Duration) quantityValues.get(Duration.DurationType.class)) - .setClasses(classes) - .setDescription(description) - .setHigherLevelDesc(binding.higherLevelEntry.getText().toString()) - .build(); - - // Tell the ViewModel about the new spell - viewModel.addCreatedSpell(spell); - + // Note that for API > 28, `onStop` is called before `onSaveInstanceState` + // This poses a bit of a problem for us - we want to set the editing spell + // to null in `onStop`, but we want to keep track of what it was in `onSaveInstanceState` + // So we need to pull out the spell from the handler + @Override + public void onSaveInstanceState(@NonNull Bundle outState) { + super.onSaveInstanceState(outState); + outState.putParcelable(SPELL_KEY, handler.getSpell()); } } diff --git a/app/src/main/java/dnd/jon/spellbook/SpellCreationHandler.java b/app/src/main/java/dnd/jon/spellbook/SpellCreationHandler.java new file mode 100644 index 00000000..7df0f2e5 --- /dev/null +++ b/app/src/main/java/dnd/jon/spellbook/SpellCreationHandler.java @@ -0,0 +1,459 @@ +package dnd.jon.spellbook; + +import android.app.AlertDialog; +import android.content.Context; +import android.util.Log; +import android.view.View; +import android.widget.AdapterView; +import android.widget.Button; +import android.widget.CheckBox; +import android.widget.GridLayout; +import android.widget.RadioButton; +import android.widget.ScrollView; +import android.widget.Spinner; + +import androidx.annotation.NonNull; +import androidx.fragment.app.DialogFragment; +import androidx.fragment.app.FragmentActivity; +import androidx.lifecycle.ViewModelProvider; + +import org.javatuples.Pair; +import org.javatuples.Quartet; + +import java.lang.reflect.Constructor; +import java.lang.reflect.InvocationTargetException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.SortedSet; +import java.util.TreeSet; +import java.util.function.Function; +import java.util.stream.Collectors; + +import dnd.jon.spellbook.databinding.QuantityTypeCreationBinding; +import dnd.jon.spellbook.databinding.SpellCreationBinding; + +// The purpose of this class is to allow shared functionality between +// the spell creation fragment and dialog +public class SpellCreationHandler { + + private static final Map, Quartet, Class, Function, Integer>> quantityTypeInfo = new HashMap, Quartet, Class, Function, Integer>>() {{ + put(CastingTime.CastingTimeType.class, new Quartet<>(CastingTime.class, TimeUnit.class, (b) -> b.castingTimeSelection, R.string.casting_time)); + put(Duration.DurationType.class, new Quartet<>(Duration.class, TimeUnit.class, (b) -> b.durationSelection, R.string.duration)); + put(Range.RangeType.class, new Quartet<>(Range.class, LengthUnit.class, (b) -> b.rangeSelection, R.string.range)); + }}; + + private static final String SOURCE_CREATION_TAG = "SOURCE_CREATION"; + + final SpellCreationBinding binding; + private final FragmentActivity activity; + private final SpellbookViewModel viewModel; + private Runnable onSpellCreated; + final Collection selectedSources = new ArrayList<>(); + private final String tag; + private Spell spell = null; + + SpellCreationHandler(FragmentActivity activity, SpellCreationBinding binding, String tag, Spell spell) { + this.activity = activity; + this.viewModel = new ViewModelProvider(activity).get(SpellbookViewModel.class); + this.binding = binding; + this.tag = tag; + this.spell = spell; + } + + void setup() { + + setupSchoolSpinner(); + updateSourceSelectionButtonText(); + + // Populate the checkbox grid for caster classes + populateCheckboxGrid(CasterClass.class, binding.classesSelectionGrid); + + // Populate the options for the quantity types + populateRangeSelectionWindow(CastingTime.CastingTimeType.class, TimeUnit.class, binding.castingTimeSelection); + populateRangeSelectionWindow(Duration.DurationType.class, TimeUnit.class, binding.durationSelection); + populateRangeSelectionWindow(Range.RangeType.class, LengthUnit.class, binding.rangeSelection); + + // Set some reasonable defaults for casting time/duration/range + SpellbookUtils.setNamedSpinnerByItem(binding.castingTimeSelection.quantityTypeSpinner, CastingTime.CastingTimeType.ACTION); + SpellbookUtils.setNamedSpinnerByItem(binding.durationSelection.quantityTypeSpinner, Duration.DurationType.SPANNING); + SpellbookUtils.setNamedSpinnerByItem(binding.rangeSelection.quantityTypeSpinner, Range.RangeType.RANGED); + + // Set up the materials entry to show when the material checkbox is selected, and hide when it isn't + binding.materialCheckbox.setOnCheckedChangeListener((cb, checked) -> { + final int materialsVisibility = checked ? View.VISIBLE : View.GONE; + binding.spellCreationMaterialsContent.setVisibility(materialsVisibility); + }); + + // Do the same for the royalty checkbox and entry + binding.royaltyCheckbox.setOnCheckedChangeListener((cb, checked) -> { + final int royaltyVisibility = checked ? View.VISIBLE : View.GONE; + binding.spellCreationRoyaltyContent.setVisibility(royaltyVisibility); + }); + + // Set up button listeners + binding.createSpellButton.setOnClickListener(view -> createOrUpdateSpell()); + binding.sourceSelectionButton.setOnClickListener(view -> openSourceSelectionDialog()); + binding.sourceCreationButton.setOnClickListener(view -> openSourceCreationDialog()); + + // Determine whether we're creating a new spell, or modifying an existing created spell + if (spell != null) { + binding.title.setText(R.string.update_spell); + binding.createSpellButton.setText(R.string.update_spell); + selectedSources.addAll(spell.getSourcebooks()); + setSpellInfo(spell); + } + + } + + void setOnSpellCreated(Runnable runnable) { + this.onSpellCreated = runnable; + } + + private void setupSchoolSpinner() { + final NameDisplayableEnumSpinnerAdapter schoolAdapter = new NameDisplayableEnumSpinnerAdapter<>(activity, School.class); + binding.schoolSelector.setAdapter(schoolAdapter); + } + + private & NameDisplayable> void populateButtonGrid(Class enumType, GridLayout grid, Function buttonMaker) { + // Get the enum constants + final E[] enums = enumType.getEnumConstants(); + + // If E somehow isn't an enum type, we return + // Note that the generic bounds guarantee that this won't happen + if (enums == null) { return; } + + // For each enum instance, do the following: + // Create a checkbox with the enum's name as its text + // Add it to the grid layout + for (E e : enums) { + final Button button = buttonMaker.apply(activity); + button.setText(DisplayUtils.getDisplayName(activity, e)); + button.setTag(e); + grid.addView(button); + } + } + + private & NameDisplayable> void populateCheckboxGrid(Class enumType, GridLayout grid) { + populateButtonGrid(enumType, grid, CheckBox::new); + } + + private & QuantityType> void populateRadioGrid(Class enumType, RadioGridGroup radioGrid) { + populateButtonGrid(enumType, radioGrid, RadioButton::new); + } + + private & QuantityType, U extends Enum & Unit> void populateRangeSelectionWindow(Class enumType, Class unitType, QuantityTypeCreationBinding qtcBinding) { + + // Set the choices for the first spinner + final Spinner optionsSpinner = qtcBinding.quantityTypeSpinner; + final NameDisplayableEnumSpinnerAdapter optionsAdapter = new NameDisplayableEnumSpinnerAdapter<>(activity, enumType, 12); + optionsSpinner.setAdapter(optionsAdapter); + + // If the spanning type is selected, we want to display the spanning option choices + optionsSpinner.setOnItemSelectedListener(new AdapterView.OnItemSelectedListener() { + @Override + public void onItemSelected(AdapterView parent, View view, int position, long id) { + + // Show or hide the spanning stuff as needed + final E type = enumType.cast(parent.getItemAtPosition(position)); + if (type == null) { return; } + + final int spanningVisibility = type.isSpanningType() ? View.VISIBLE : View.GONE; + qtcBinding.spanningTypeContent.setVisibility(spanningVisibility); + + } + + @Override + public void onNothingSelected(AdapterView parent) { + qtcBinding.spanningTypeContent.setVisibility(View.GONE); + } + }); + optionsSpinner.setSelection(0); + + // Populate the spanning type elements + // Note that they're hidden to start + final Spinner unitSpinner = qtcBinding.spanningUnitSelector; + final UnitTypeSpinnerAdapter unitAdapter = new UnitTypeSpinnerAdapter<>(activity, unitType, 12); + unitSpinner.setAdapter(unitAdapter); + + } + + + private String sourceSelectionButtonText() { + if (selectedSources.size() == 0) { + return activity.getString(R.string.select_sources); + } else { + final List codes = selectedSources + .stream() + .map(source -> DisplayUtils.getCode(source, activity)) + .collect(Collectors.toList()); + return String.join(", ", codes); + } + } + + void updateSourceSelectionButtonText() { + binding.sourceSelectionButton.setText(sourceSelectionButtonText()); + } + + void setSpellInfo(@NonNull Spell spell) { + + this.spell = spell; + + // Set any text fields + binding.nameEntry.setText(spell.getName()); + binding.levelEntry.setText(String.format(LocalizationUtils.getLocale(), "%d", spell.getLevel())); + binding.descriptionEntry.setText(spell.getDescription()); + binding.higherLevelEntry.setText(spell.getHigherLevel()); + + // Set the ritual and concentration switches + binding.ritualSelector.setChecked(spell.getRitual()); + binding.concentrationSelector.setChecked(spell.getConcentration()); + + // Set the school spinner to the correct position + SpellbookUtils.setNamedSpinnerByItem(binding.schoolSelector, spell.getSchool()); + + selectedSources.clear(); + selectedSources.addAll(spell.getSourcebooks()); + updateSourceSelectionButtonText(); + + // Set the quantity type UI elements + final List>> spinnersAndGetters = Arrays.asList( + new Pair<>(binding.castingTimeSelection, Spell::getCastingTime), + new Pair<>(binding.durationSelection, Spell::getDuration), + new Pair<>(binding.rangeSelection, Spell::getRange) + ); + for (Pair> pair : spinnersAndGetters) { + final QuantityTypeCreationBinding qtcBinding = pair.getValue0(); + final Function quantityGetter = pair.getValue1(); + final Quantity quantity = quantityGetter.apply(spell); + final QuantityType quantityType = (QuantityType) quantity.type; + SpellbookUtils.setNamedSpinnerByItem(qtcBinding.quantityTypeSpinner, quantity.type); + final boolean spanningType = quantityType.isSpanningType(); + qtcBinding.spanningTypeContent.setVisibility(spanningType ? View.VISIBLE : View.GONE); + if (spanningType) { + qtcBinding.spanningValueEntry.setText(DisplayUtils.DECIMAL_FORMAT.format(quantity.getValue())); + SpellbookUtils.setNamedSpinnerByItem(qtcBinding.spanningUnitSelector, (Enum) quantity.getUnit()); + } + + } + + // Set the checkboxes in the class selection grid + final Collection spellClasses = spell.getClasses(); + for (int i = 0; i < binding.classesSelectionGrid.getChildCount(); ++i) { + final View view = binding.classesSelectionGrid.getChildAt(i); + if (view instanceof CheckBox) { + final CheckBox cb = (CheckBox) view; + final CasterClass cc = (CasterClass) cb.getTag(); + cb.setChecked(spellClasses.contains(cc)); + } + } + + final boolean[] components = spell.getComponents(); + binding.verbalCheckbox.setChecked(components[0]); + binding.somaticCheckbox.setChecked(components[1]); + binding.materialCheckbox.setChecked(components[2]); + binding.royaltyCheckbox.setChecked(components[3]); + } + + private void showErrorMessage(String text) { + binding.errorText.setText(text); + binding.spellCreationScroll.fullScroll(ScrollView.FOCUS_UP); + } + + private void showErrorMessage(int textID) { + showErrorMessage(activity.getString(textID)); + } + + SortedSet selectedClasses() { + final TreeSet classes = new TreeSet<>(); + final GridLayout grid = binding.classesSelectionGrid; + for (int i = 0; i < grid.getChildCount(); ++i) { + final CheckBox cb = (CheckBox) grid.getChildAt(i); + if (cb.isChecked()) { + classes.add((CasterClass) cb.getTag()); + } + } + return classes; + } + + void createOrUpdateSpell() { + // Check the spell name + final String name = binding.nameEntry.getText().toString(); + if (name.isEmpty()) { showErrorMessage(R.string.spell_name_empty); return; } + final Character illegalCharacter = SpellbookViewModel.firstIllegalCharacter(name); + if (illegalCharacter != null) { + showErrorMessage(activity.getString(R.string.illegal_character, activity.getString(R.string.spell_name_lowercase), illegalCharacter.toString())); + return; + } + + // Check the spell level + int level; + try { + level = Integer.parseInt(binding.levelEntry.getText().toString()); + } catch (NumberFormatException e) { + showErrorMessage(activity.getString(R.string.spell_level_range, Spellbook.MIN_SPELL_LEVEL, Spellbook.MAX_SPELL_LEVEL)); + return; + } + + // Check the components + final boolean[] components = new boolean[]{ binding.verbalCheckbox.isChecked(), binding.somaticCheckbox.isChecked(), binding.materialCheckbox.isChecked(), binding.royaltyCheckbox.isChecked() }; + final boolean oneChecked = components[0] || components[1] || components[2] || components[3]; + if (!oneChecked) { + showErrorMessage(R.string.spell_no_components); return; + } + + // If material is selected, check that the materials description isn't empty + final boolean materialChecked = components[2]; + final String materialsString = materialChecked ? binding.materialsEntry.getText().toString() : ""; + if (materialChecked && materialsString.isEmpty()) { + showErrorMessage(R.string.spell_material_empty); + return; + } + + // Same for royalty + final boolean royaltyChecked = components[3]; + final String royaltyString = royaltyChecked ? binding.royaltyEntry.getText().toString() : ""; + if (royaltyChecked && royaltyString.isEmpty()) { + showErrorMessage(R.string.spell_royalty_empty); + return; + } + + // At least one class must be selected + final SortedSet classes = selectedClasses(); + if (classes.size() == 0) { + showErrorMessage(R.string.spell_no_caster_classes); + return; + } + + // At least one source must be selected + // if (selectedSources.size() == 0) { + // showErrorMessage(R.string.spell_no_sources); + // return; + // } + + // If one of the spanning types is selected, the text field needs to be filled out + final Map, Quantity> quantityValues = new HashMap<>(); + for (Map.Entry, Quartet, Class, Function, Integer>> entry : quantityTypeInfo.entrySet()) { + + final Class quantityType = entry.getKey(); + final Quartet, Class, Function, Integer> data = entry.getValue(); + final Class quantityClass = data.getValue0(); + final QuantityTypeCreationBinding qtcBinding = data.getValue2().apply(binding); + + final Spinner quantityTypeSpinner = qtcBinding.quantityTypeSpinner; + final QuantityType type = quantityType.cast(quantityTypeSpinner.getSelectedItem()); + + // Get the quantity + Quantity quantity = null; + try { + if (type.isSpanningType()) { + final String spanningText = qtcBinding.spanningValueEntry.getText().toString(); + final boolean spanningTextMissing = spanningText.isEmpty(); + if (spanningTextMissing) { + final int quantityTypeNameID = data.getValue3(); + final String quantityTypeName = activity.getResources().getString(quantityTypeNameID); + showErrorMessage(activity.getString(R.string.spell_entry_field_empty, quantityTypeName)); + return; + } + final Class unitType = data.getValue1(); + final Unit unit = unitType.cast(qtcBinding.spanningUnitSelector.getSelectedItem()); + final String valueString = qtcBinding.spanningValueEntry.getText().toString(); + final int value = Integer.parseInt(valueString); + final Constructor constructor = quantityClass.getDeclaredConstructor(quantityType, float.class, unitType, String.class); + quantity = quantityClass.cast(constructor.newInstance(type, value, unit, "")); + } else { + final Constructor constructor = quantityClass.getDeclaredConstructor(quantityType); + quantity = quantityClass.cast(constructor.newInstance(type)); + } + } catch (NoSuchMethodException e) { + Log.e(tag, "Couldn't find constructor:\n" + SpellbookUtils.stackTrace(e)); + } catch (IllegalAccessException | java.lang.InstantiationException | + InvocationTargetException e) { + Log.e(tag, "Error creating quantity:\n" + SpellbookUtils.stackTrace(e)); + } + quantityValues.put(quantityType, quantity); + + } + + // Check if the description is empty + final String description = binding.descriptionEntry.getText().toString(); + if (description.isEmpty()) { + showErrorMessage(R.string.spell_description_empty); + return; + } + + // Once we've passed all of the checks, create the spell + final SpellBuilder spellBuilder = new SpellBuilder(activity); + final int id = spell != null ? spell.getID() : viewModel.newSpellID(); + spellBuilder + .setID(id) + .setName(name) + .setSchool((School) binding.schoolSelector.getSelectedItem()) + .setLevel(level) + .setRitual(binding.ritualSelector.isChecked()) + .setConcentration(binding.concentrationSelector.isChecked()) + .setCastingTime((CastingTime) quantityValues.get(CastingTime.CastingTimeType.class)) + .setDuration((Duration) quantityValues.get(Duration.DurationType.class)) + .setRange((Range) quantityValues.get(Range.RangeType.class)) + .setComponents(components) + .setClasses(classes) + .setDescription(description) + .setHigherLevelDesc(binding.higherLevelEntry.getText().toString()); + for (Source source : selectedSources) { + spellBuilder.addLocation(source, -1); + } + + final Spell newSpell = spellBuilder.build(); + if (spell == null) { + // Tell the ViewModel about the new spell + viewModel.addCreatedSpell(newSpell); + } else { + viewModel.updateSpell(spell, newSpell); + } + + if (onSpellCreated != null) { + onSpellCreated.run(); + } + } + + private void openSourceSelectionDialog() { + final Source[] sources = Source.createdSources(); + final String[] sourceNames = DisplayUtils.getDisplayNames(activity, sources, (context, source) -> DisplayUtils.getDisplayName(source, context)); + + // It seems like there should be a way to do this with a stream? + final boolean[] selectedIndices = new boolean[sources.length]; + if (spell != null) { + for (int i = 0; i < sources.length; i++) { + selectedIndices[i] = selectedSources.contains(sources[i]); + } + } + + final AlertDialog.Builder builder = new AlertDialog.Builder(activity); + final AlertDialog dialog = builder + .setTitle(R.string.select_sources) + .setNegativeButton(R.string.cancel, (dialogInterface, index) -> dialogInterface.dismiss()) + .setPositiveButton(R.string.ok, (dialogInterface, index) -> updateSourceSelectionButtonText()) + .setMultiChoiceItems(sourceNames, selectedIndices, (dialogInterface, index, isChecked) -> { + final Source source = DisplayUtils.sourceFromDisplayName(activity, sourceNames[index]); + final boolean alreadySelected = selectedSources.contains(source); + if (isChecked && !alreadySelected) { + selectedSources.add(source); + } else if (!isChecked && alreadySelected) { + selectedSources.remove(source); + } + }) + .create(); + + dialog.show(); + } + + private void openSourceCreationDialog() { + final DialogFragment sourceCreationDialog = new SourceCreationDialog(); + sourceCreationDialog.show(activity.getSupportFragmentManager(), SOURCE_CREATION_TAG); + } + + Spell getSpell() { return spell; } +} diff --git a/app/src/main/java/dnd/jon/spellbook/SpellSlotManagerDialog.java b/app/src/main/java/dnd/jon/spellbook/SpellSlotManagerDialog.java index 5d431960..08183a98 100644 --- a/app/src/main/java/dnd/jon/spellbook/SpellSlotManagerDialog.java +++ b/app/src/main/java/dnd/jon/spellbook/SpellSlotManagerDialog.java @@ -38,7 +38,7 @@ public Dialog onCreateDialog(Bundle savedInstanceState) { viewModel = new ViewModelProvider(activity).get(SpellbookViewModel.class); final AlertDialog.Builder builder = new AlertDialog.Builder(activity); - final LayoutInflater inflater = (LayoutInflater) activity.getSystemService(Context.LAYOUT_INFLATER_SERVICE); + final LayoutInflater inflater = getLayoutInflater(); binding = SpellSlotManagerBinding.inflate(inflater); binding.setStatus(viewModel.getSpellSlotStatus()); binding.slotManagerEditButton.setVisibility(View.VISIBLE); diff --git a/app/src/main/java/dnd/jon/spellbook/SpellTableFragment.java b/app/src/main/java/dnd/jon/spellbook/SpellTableFragment.java index a57e59be..c56bd91f 100644 --- a/app/src/main/java/dnd/jon/spellbook/SpellTableFragment.java +++ b/app/src/main/java/dnd/jon/spellbook/SpellTableFragment.java @@ -1,60 +1,40 @@ package dnd.jon.spellbook; -import android.app.Activity; -import android.content.Context; -import android.content.SharedPreferences; import android.os.Bundle; import android.view.LayoutInflater; -import android.view.MenuItem; import android.view.View; import android.view.ViewGroup; import androidx.annotation.NonNull; -import androidx.constraintlayout.widget.ConstraintLayout; -import androidx.coordinatorlayout.widget.CoordinatorLayout; -import androidx.fragment.app.Fragment; -import androidx.fragment.app.FragmentActivity; import androidx.lifecycle.LifecycleOwner; -import androidx.lifecycle.ViewModelProvider; import androidx.recyclerview.widget.LinearLayoutManager; import androidx.recyclerview.widget.RecyclerView; import androidx.swiperefreshlayout.widget.SwipeRefreshLayout; import dnd.jon.spellbook.databinding.SpellTableBinding; -public class SpellTableFragment extends Fragment { +public class SpellTableFragment extends SpellbookFragment { - private SpellTableBinding binding; private SpellAdapter spellAdapter; - private SpellbookViewModel viewModel; public SpellTableFragment() { super(R.layout.spell_table); } - @Override - public void onAttach(@NonNull Context context) { - super.onAttach(context); - } - @Override public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { super.onCreateView(inflater, container, savedInstanceState); binding = SpellTableBinding.inflate(inflater); - final FragmentActivity activity = requireActivity(); - this.viewModel = new ViewModelProvider(activity, activity.getDefaultViewModelProviderFactory()).get(SpellbookViewModel.class); - //final LifecycleOwner lifecycleOwner = getViewLifecycleOwner(); - //viewModel.currentSpell().observe(lifecycleOwner, this::updateSpell); - setup(); return binding.getRoot(); } - @Override - public void onDestroyView() { - super.onDestroyView(); - binding = null; + public void onViewCreated(@NonNull View view, Bundle savedInstanceState) { + super.onViewCreated(view, savedInstanceState); + //final LifecycleOwner lifecycleOwner = getViewLifecycleOwner(); + //viewModel.currentSpell().observe(lifecycleOwner, this::updateSpell); + setup(); } private void setupSwipeRefreshLayout() { @@ -87,47 +67,8 @@ private void setupSpellRecycler() { spellAdapter = new SpellAdapter(viewModel); spellRecycler.setAdapter(spellAdapter); spellRecycler.setLayoutManager(spellLayoutManager); - -// spellAdapter.registerAdapterDataObserver(new RecyclerView.AdapterDataObserver() { -// @Override -// public void onItemRangeChanged(int positionStart, int itemCount, Object payload) { -// if (itemCount != 1 || !(payload instanceof SpellAdapter.SpellRowProperty)) { return; } -// final SpellAdapter.SpellRowProperty property = (SpellAdapter.SpellRowProperty) payload; -// final Spell spell = spellAdapter.getSpellAtPosition(positionStart); -// switch (property) { -// case FAVORITE: -// viewModel.toggleFavorite(spell); -// break; -// case PREPARED: -// viewModel.togglePrepared(spell); -// break; -// case KNOWN: -// viewModel.toggleKnown(spell); -// } -// } -// }); } -// void setupBottomNavBar() { -// final BottomNavigationView bottomNavBar = binding.bottomNavBar; -// bottomNavBar.setOnItemSelectedListener(item -> { -// final int id = item.getItemId(); -// final SortFilterStatus sortFilterStatus = viewModel.getSortFilterStatus(); -// StatusFilterField statusFilterField; -// if (id == R.id.action_select_favorites) { -// statusFilterField = StatusFilterField.FAVORITES; -// } else if (id == R.id.action_select_prepared) { -// statusFilterField = StatusFilterField.PREPARED; -// } else if (id == R.id.action_select_known) { -// statusFilterField = StatusFilterField.KNOWN; -// } else { -// statusFilterField = StatusFilterField.ALL; -// } -// sortFilterStatus.setStatusFilterField(statusFilterField); -// return true; -// }); -// } - private void setup() { setupSpellRecycler(); setupSwipeRefreshLayout(); diff --git a/app/src/main/java/dnd/jon/spellbook/SpellWindowFragment.java b/app/src/main/java/dnd/jon/spellbook/SpellWindowFragment.java index a9efa99b..57f2632e 100644 --- a/app/src/main/java/dnd/jon/spellbook/SpellWindowFragment.java +++ b/app/src/main/java/dnd/jon/spellbook/SpellWindowFragment.java @@ -12,7 +12,6 @@ import androidx.annotation.NonNull; import androidx.fragment.app.DialogFragment; -import androidx.fragment.app.Fragment; import androidx.fragment.app.FragmentActivity; import androidx.lifecycle.LifecycleOwner; import androidx.lifecycle.ViewModelProvider; @@ -21,7 +20,7 @@ import dnd.jon.spellbook.databinding.SpellWindowBinding; -public class SpellWindowFragment extends Fragment +public class SpellWindowFragment extends SpellbookFragment implements SharedPreferences.OnSharedPreferenceChangeListener { @@ -32,8 +31,6 @@ public class SpellWindowFragment extends Fragment static final String USE_NEXT_AVAILABLE_TAG = "use_next_available_tag"; static final String defaultTextSizeString = Integer.toString(14); - private SpellWindowBinding binding; - private SpellbookViewModel viewModel; private SpellStatus spellStatus; private boolean onTablet; @@ -62,15 +59,18 @@ public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { super.onCreateView(inflater, container, savedInstanceState); - inflateBinding(inflater); + return binding.getRoot(); + } - final FragmentActivity activity = requireActivity(); - this.viewModel = new ViewModelProvider(activity).get(SpellbookViewModel.class); + @Override + public void onViewCreated(@NonNull View view, Bundle savedInstanceState) { + super.onViewCreated(view, savedInstanceState); final LifecycleOwner lifecycleOwner = getViewLifecycleOwner(); viewModel.currentSpell().observe(lifecycleOwner, this::updateSpell); viewModel.currentUseExpanded().observe(lifecycleOwner, this::updateUseExpanded); + final FragmentActivity activity = requireActivity(); PreferenceManager.getDefaultSharedPreferences(activity).registerOnSharedPreferenceChangeListener(this); onTablet = activity.getResources().getBoolean(R.bool.isTablet); @@ -120,14 +120,6 @@ public View onCreateView(@NonNull LayoutInflater inflater, // }); setupButtons(); - - return binding.getRoot(); - } - - @Override - public void onDestroyView() { - super.onDestroyView(); - binding = null; } // For handling rotations @@ -193,10 +185,6 @@ void updateUseExpanded(boolean useExpanded) { binding.executePendingBindings(); } - SpellWindowBinding getBinding() { - return binding; - } - private void setupButtons() { binding.favoriteButton.setOnClickListener( (v) -> buttonListener(viewModel::setFavorite, binding.favoriteButton) ); binding.knownButton.setOnClickListener( (v) -> buttonListener(viewModel::setKnown, binding.knownButton) ); @@ -239,7 +227,6 @@ private void changeTextSize(float size) { // tv.setTextSize(TypedValue.COMPLEX_UNIT_SP, size); // } // } - System.out.println("Changing binding size"); binding.setTextSize(size); } diff --git a/app/src/main/java/dnd/jon/spellbook/SpellbookFragment.java b/app/src/main/java/dnd/jon/spellbook/SpellbookFragment.java new file mode 100644 index 00000000..3691bec1 --- /dev/null +++ b/app/src/main/java/dnd/jon/spellbook/SpellbookFragment.java @@ -0,0 +1,51 @@ +package dnd.jon.spellbook; + +import android.content.Context; +import android.os.Bundle; +import android.view.View; + +import androidx.annotation.NonNull; +import androidx.fragment.app.Fragment; +import androidx.fragment.app.FragmentActivity; +import androidx.lifecycle.ViewModelProvider; +import androidx.viewbinding.ViewBinding; + +public abstract class SpellbookFragment extends Fragment { + + Context context; + VB binding; + SpellbookViewModel viewModel; + + SpellbookFragment() { super(); } + SpellbookFragment(int layoutID) { super(layoutID); } + + void acquireViewModel() { + viewModel = new ViewModelProvider(requireActivity()).get(SpellbookViewModel.class); + } + + @Override + public void onAttach(@NonNull Context context) { + super.onAttach(context); + this.context = context; + } + + @Override + public void onViewCreated(@NonNull View view, Bundle savedInstanceState) { + acquireViewModel(); + } + + @Override + public void onDestroyView() { + super.onDestroyView(); + binding = null; + } + + @Override + public void onSaveInstanceState(@NonNull Bundle outState) { + super.onSaveInstanceState(outState); + if (viewModel == null) { + acquireViewModel(); + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/dnd/jon/spellbook/SpellbookUtils.java b/app/src/main/java/dnd/jon/spellbook/SpellbookUtils.java index d01512d1..908d40dd 100755 --- a/app/src/main/java/dnd/jon/spellbook/SpellbookUtils.java +++ b/app/src/main/java/dnd/jon/spellbook/SpellbookUtils.java @@ -3,8 +3,6 @@ import android.app.AlertDialog; import android.app.Dialog; import android.content.Context; -import android.content.res.Configuration; -import android.content.res.Resources; import android.graphics.Color; import android.view.LayoutInflater; import android.widget.Spinner; @@ -14,6 +12,7 @@ import org.json.JSONArray; +import java.io.File; import java.io.PrintWriter; import java.io.StringWriter; import java.lang.reflect.Array; @@ -24,12 +23,13 @@ import java.util.HashMap; import java.util.HashSet; import java.util.List; -import java.util.Locale; import java.util.Map; import java.util.Set; import java.util.function.BiFunction; import java.util.function.Function; +import java.util.function.Predicate; import java.util.stream.Collectors; +import java.util.stream.IntStream; import dnd.jon.spellbook.databinding.MessageDialogBinding; @@ -38,8 +38,14 @@ class SpellbookUtils { static T coalesce(@Nullable T one, @NonNull T two) { return one != null ? one : two; } + static final int defaultColor = Color.argb(138, 0, 0, 0); + @FunctionalInterface + public interface ThrowsExceptionFunction { + R apply(T t) throws E; + } + static Integer intParse(String s) { try { return Integer.parseInt(s); @@ -59,7 +65,9 @@ static boolean yn_to_bool(String yn) throws Exception { } } - static String bool_to_yn(boolean yn) { return yn ? "yes" : "no"; } + static String bool_to_yn(boolean yn) { + return yn ? "yes" : "no"; + } static int parseFromString(final String s, final int defaultValue) { int x; @@ -71,7 +79,7 @@ static int parseFromString(final String s, final int defaultValue) { return x; } - static T[] jsonToArray(JSONArray jarr, Class elementType, BiFunction itemGetter) { + static T[] jsonToArray(JSONArray jarr, Class elementType, BiFunction itemGetter) { final T[] arr = (T[]) Array.newInstance(elementType, jarr.length()); for (int i = 0; i < jarr.length(); ++i) { arr[i] = itemGetter.apply(jarr, i); @@ -85,15 +93,17 @@ static T[] jsonToArray(JSONArray jarr, Class elementType, BiFunction> void setNamedSpinnerByItem(Spinner spinner, T item) { try { - final NamedSpinnerAdapter adapter = (NamedSpinnerAdapter) spinner.getAdapter(); + final NamedEnumSpinnerAdapter adapter = (NamedEnumSpinnerAdapter) spinner.getAdapter(); spinner.setSelection(adapter.itemIndex(item)); } catch (ClassCastException e) { e.printStackTrace(); } } - static void clickButtons(Collection buttons, Function filter) { - if (buttons == null) { return; } + static void clickButtons(Collection buttons, Function filter) { + if (buttons == null) { + return; + } for (ToggleButton tb : buttons) { if (filter.apply(tb)) { tb.callOnClick(); @@ -148,7 +158,7 @@ static void showMessageDialog(Context context, int titleID, int messageID, boole }); // Set whether or not we must press the OK button - dialog.setCancelable(!mustPressOK) ; + dialog.setCancelable(!mustPressOK); dialog.setCanceledOnTouchOutside(!mustPressOK); dialog.setOnDismissListener((di) -> { if (onDismissAction != null) { @@ -159,7 +169,7 @@ static void showMessageDialog(Context context, int titleID, int messageID, boole dialog.show(); } - static Map copyOfMap(Map map, Class keyType) { + static Map copyOfMap(Map map, Class keyType) { if (keyType.isEnum()) { return new EnumMap(map); } else { @@ -182,7 +192,9 @@ static T[] concatenateAll(T[] first, T[]... rest) { } static T[] concatenateAll(List arrays) { - if (arrays.size() <= 0) { return null; } + if (arrays.size() <= 0) { + return null; + } T[] first = arrays.get(0); int totalLength = arrays.stream().map(array -> array.length).reduce(0, Integer::sum); T[] result = Arrays.copyOf(first, totalLength); @@ -200,7 +212,7 @@ static T[] arrayDifference(Class type, T[] array, T[] remove) { final Set arrayAsSet = new HashSet<>(Arrays.asList(array)); final Set removeSet = new HashSet<>(Arrays.asList(remove)); arrayAsSet.removeAll(removeSet); - return arrayAsSet.toArray((T[])Array.newInstance(type, arrayAsSet.size())); + return arrayAsSet.toArray((T[]) Array.newInstance(type, arrayAsSet.size())); } static Collection complement(Collection items, Collection allItems) { @@ -219,4 +231,45 @@ static Collection mutableCollectionFromArray(T[] items) { return new ArrayList<>(Arrays.asList(items)); } + static boolean filenameEndsWith(File file, String extension) { + return file.getName().endsWith(extension); + } + + static Predicate extensionFilter(String extension) { + return (file) -> file.getName().endsWith(extension); + } + + static Unit defaultUnit(Class unitType) { + if (unitType == TimeUnit.class) { + return TimeUnit.SECOND; + } else if (unitType == LengthUnit.class) { + return LengthUnit.FOOT; + } + return null; + + } + + public static T[] removeElement(Class type, T[] items, T element) { + for (int i = 0; i < items.length; i++) { + if (items[i] == element) { + final T[] copy = (T[]) Array.newInstance(type, items.length - 1); + System.arraycopy(items, 0, copy, 0, i); + System.arraycopy(items, i + 1, copy, i, items.length - i - 1); + return copy; + } + } + return items; + } + + public static int firstIndex(List items, Predicate condition) { + return IntStream.range(0, items.size()) + .filter(i -> condition.test(items.get(i))) + .findFirst().orElse(-1); + } + + public static double clamp(double value, double min, double max) { + return Math.min(max, Math.max(min, value)); + } + } + diff --git a/app/src/main/java/dnd/jon/spellbook/SpellbookViewModel.java b/app/src/main/java/dnd/jon/spellbook/SpellbookViewModel.java index 9a449907..e3d53acc 100644 --- a/app/src/main/java/dnd/jon/spellbook/SpellbookViewModel.java +++ b/app/src/main/java/dnd/jon/spellbook/SpellbookViewModel.java @@ -26,11 +26,19 @@ import java.io.File; import java.util.ArrayList; import java.util.Arrays; +import java.util.Collection; +import java.util.Comparator; import java.util.List; import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.TreeSet; import java.util.function.BiConsumer; import java.util.function.BiFunction; import java.util.function.Consumer; +import java.util.function.Predicate; +import java.util.function.Supplier; +import java.util.stream.Collectors; public class SpellbookViewModel extends ViewModel implements Filterable { @@ -63,8 +71,8 @@ public class SpellbookViewModel extends ViewModel implements Filterable { private final MutableLiveData> characterNamesLD; private final MutableLiveData> statusNamesLD; - private final MutableLiveData> createdSourceNamesLD; - private final MutableLiveData> createdSpellNamesLD; + private final MutableLiveData> createdSourcesLD; + private final MutableLiveData> createdSpellsLD; private CharacterProfile profile = null; private CharSequence searchQuery; private boolean filterNeeded = false; @@ -80,7 +88,7 @@ public class SpellbookViewModel extends ViewModel implements Filterable { private static List englishSpells = new ArrayList<>(); private List spells; private List currentSpellList; - private String spellsFilename; + private final String spellsFilename; private final MutableLiveData spellsContext; private Locale spellsLocale; private final MutableLiveData> currentSpellsLD; @@ -90,15 +98,17 @@ public class SpellbookViewModel extends ViewModel implements Filterable { private final MutableLiveData currentSpellLD; private final MutableLiveData currentUseExpandedLD; private final MutableLiveData spellTableVisibleLD; - private SpellCodec spellCodec; + private final MutableLiveData currentEditingSpellLD; private final MutableLiveData> toastEventLD; private static final List SORT_PROPERTY_IDS = Arrays.asList(BR.firstSortField, BR.firstSortReverse, BR.secondSortField, BR.secondSortReverse); + private static final int CREATED_SPELL_ID_OFFSET = 100000; + private static LiveData distinctTransform(LiveData source, Function transform) { - return Transformations.distinctUntilChanged(Transformations.map(source, transform)); + return Transformations.distinctUntilChanged(Transformations.map(source, transform::apply)); } public SpellbookViewModel(Application application) { @@ -141,23 +151,28 @@ public SpellbookViewModel(Application application) { this.currentProfileLD = new MutableLiveData<>(); this.characterNamesLD = new MutableLiveData<>(); this.statusNamesLD = new MutableLiveData<>(); - this.createdSourceNamesLD = new MutableLiveData<>(); - this.createdSpellNamesLD = new MutableLiveData<>(); + this.createdSourcesLD = new MutableLiveData<>(new ArrayList<>()); + this.createdSpellsLD = new MutableLiveData<>(new ArrayList<>()); this.currentSpellFilterStatusLD = new MutableLiveData<>(); this.currentSortFilterStatusLD = new MutableLiveData<>(); this.currentSpellSlotStatusLD = new MutableLiveData<>(); this.spellFilterEventLD = new MutableLiveData<>(); + initialUpdates(); + this.spellsFilename = spellsContext.getResources().getString(R.string.spells_filename); this.spells = loadSpellsFromFile(spellsFilename, this.spellsLocale); + this.spells.addAll(this.getCreatedSpells()); this.currentSpellList = new ArrayList<>(spells); this.currentSpellsLD = new MutableLiveData<>(spells); this.currentSpellLD = new MutableLiveData<>(); this.currentSpellFavoriteLD = new MutableLiveData<>(); this.currentSpellPreparedLD = new MutableLiveData<>(); this.currentSpellKnownLD = new MutableLiveData<>(); + + this.currentEditingSpellLD = new MutableLiveData<>(); + this.currentUseExpandedLD = new MutableLiveData<>(); this.spellTableVisibleLD = new MutableLiveData<>(); - updateCharacterNames(); // Load the settings and the character profile this.settings = loadSettings(); @@ -179,8 +194,8 @@ public SpellbookViewModel(Application application) { // Same with the sort/filter statuses, sources, and created spells this.profilesDirObserver = filenamesObserver(profilesDir, this::updateCharacterNames); this.statusesDirObserver = filenamesObserver(statusesDir, this::updateStatusNames); - this.createdSourcesDirObserver = filenamesObserver(createdSourcesDir, this::updateCreatedSourceNames); - this.createdSpellsDirObserver = filenamesObserver(createdSpellsDir, this::updateCreatedSpellNames); + this.createdSourcesDirObserver = filenamesObserver(createdSourcesDir, this::updateCreatedSources); + this.createdSpellsDirObserver = filenamesObserver(createdSpellsDir, this::updateCreatedSpells); profilesDirObserver.startWatching(); statusesDirObserver.startWatching(); createdSourcesDirObserver.startWatching(); @@ -225,7 +240,7 @@ private List loadSpellsFromFile(String filename, Locale locale) { private FileObserver filenamesObserver(File directory, Runnable executeOnEvent) { final BiConsumer runOnEvent = (event, path) -> { switch (event) { - case FileObserver.CREATE: + case FileObserver.CLOSE_WRITE: case FileObserver.DELETE: executeOnEvent.run(); } @@ -251,8 +266,8 @@ public void onEvent(int event, @Nullable String path) { LiveData currentSpellFavoriteLD() { return currentSpellFavoriteLD; } LiveData currentSpellPreparedLD() { return currentSpellPreparedLD; } LiveData currentSpellKnownLD() { return currentSpellKnownLD; } - LiveData spellFilterEvent() { return spellFilterEventLD; } + LiveData currentEditingSpell() { return currentEditingSpellLD; } void setCurrentSpell(Spell spell) { currentSpellLD.setValue(spell); @@ -260,12 +275,14 @@ void setCurrentSpell(Spell spell) { currentSpellPreparedLD.setValue(getPrepared(spell)); currentSpellKnownLD.setValue(getKnown(spell)); } + void setCurrentEditingSpell(Spell spell) { currentEditingSpellLD.setValue(spell); } List getAllSpells() { return spells; } LiveData currentSpellsContext() { return spellsContext; } Context getSpellContext() { return spellsContext.getValue(); } - private String nameValidator(String name, int emptyItemID, int itemTypeID, List existingItems) { + private String nameValidator(String name, int emptyItemID, int itemTypeID, + Collection existingItems, Supplier duplicateErrorMaker) { if (name.isEmpty()) { final String emptyItem = application.getString(emptyItemID); return application.getString(R.string.cannot_be_empty, emptyItem); @@ -280,12 +297,16 @@ private String nameValidator(String name, int emptyItemID, int itemTypeID, List< } if (existingItems != null && existingItems.contains(name)) { - return application.getString(R.string.duplicate_name, itemType); + return duplicateErrorMaker.get(); } return ""; } + private String nameValidator(String name, int emptyID, int itemTypeID, Collection existingItems) { + return nameValidator(name, emptyID, itemTypeID, existingItems, () -> application.getString(R.string.duplicate_name, application.getString(itemTypeID))); + } + String characterNameValidator(String name) { return nameValidator(name, R.string.character_name, R.string.character_lowercase, characterNamesLD.getValue()); } @@ -294,6 +315,21 @@ String statusNameValidator(String name) { return nameValidator(name, R.string.status_lowercase, R.string.status_lowercase, statusNamesLD.getValue()); } + String sourceNameValidator(String name, boolean checkExisting) { + final List sourceNames = checkExisting ? + Arrays.stream(Source.values()).map(s -> DisplayUtils.getDisplayName(s, getContext())).collect(Collectors.toList()) : + null; + return nameValidator(name, R.string.source_name, R.string.source, sourceNames); + } + + String sourceAbbreviationValidator(String abbreviation) { + final List sourceAbbrs = Arrays.stream(Source.values()).map(s -> DisplayUtils.getCode(s, getContext())).collect(Collectors.toList()); + final String source = application.getString(R.string.source); + final String abbreviationString = application.getString(R.string.abbreviation); + final Supplier duplicateNameGetter = () -> application.getString(R.string.duplicate_something, source, abbreviationString); + return nameValidator(abbreviation, R.string.source_abbreviation, R.string.abbreviation, sourceAbbrs, duplicateNameGetter); + } + static boolean isLegal(Character c) { return !ILLEGAL_CHARACTERS.contains(c); } @@ -308,21 +344,55 @@ static Character firstIllegalCharacter(String name) { return null; } - private List getNamesFromDirectory(File directory, String extension) { - final List names = new ArrayList<>(); - final int toRemove = extension.length(); + private List getItemsFromDirectory(File directory, Predicate fileFilter, SpellbookUtils.ThrowsExceptionFunction itemCreator, Comparator sorter) { + final List items = new ArrayList<>(); final File[] files = directory.listFiles(); - if (files != null) { - for (File file : files) { - final String filename = file.getName(); - if (filename.endsWith(extension)) { - final String name = filename.substring(0, filename.length() - toRemove); - names.add(name); - } + if (files == null) { return items; } + for (File file: files) { + if (!fileFilter.test(file)) { continue; } + try { + final T item = itemCreator.apply(file); + items.add(item); + } catch (Exception e) { + Log.e(LOGGING_TAG, "getItemsFromDirectory: Error creating item, file " + file.getAbsolutePath()); } } - names.sort(String::compareToIgnoreCase); - return names; + if (sorter != null) { + items.sort(sorter); + } + return items; + } + + private void updateItemsFromDirectory(File directory, + Predicate fileFilter, + SpellbookUtils.ThrowsExceptionFunction itemCreator, + Comparator sorter, + MutableLiveData> liveData, + boolean mainThread) { + final List items = getItemsFromDirectory(directory, fileFilter, itemCreator, sorter); + if (mainThread) { + liveData.setValue(items); + } else { + liveData.postValue(items); + } + } + + private void updateItemsFromDirectory(File directory, + Predicate fileFilter, + SpellbookUtils.ThrowsExceptionFunction itemCreator, + Comparator sorter, + MutableLiveData> liveData) { + updateItemsFromDirectory(directory, fileFilter, itemCreator, sorter, liveData, false); + } + + private static String getNameFromFile(File file, String extension) { + final int toRemove = extension.length(); + final String filename = file.getName(); + return filename.substring(0, filename.length() - toRemove); + } + + private List getNamesFromDirectory(File directory, String extension) { + return getItemsFromDirectory(directory, SpellbookUtils.extensionFilter(extension), (f) -> getNameFromFile(f, extension), String::compareToIgnoreCase); } private void updateNamesFromDirectory(File directory, String extension, MutableLiveData> liveData) { @@ -342,15 +412,42 @@ void updateStatusNames() { updateNamesFromDirectory(statusesDir, STATUS_EXTENSION, statusNamesLD); } - private void updateCreatedSourceNames() { - updateNamesFromDirectory(createdSourcesDir, CREATED_SOURCE_EXTENSION, createdSourceNamesLD); + private void updateCreatedSources(boolean mainThread) { + updateItemsFromDirectory(createdSourcesDir, + SpellbookUtils.extensionFilter(CREATED_SOURCE_EXTENSION), + JSONUtils::sourceFromJSON, + null, + createdSourcesLD, + mainThread); + } + + private void updateCreatedSources() { updateCreatedSources(false); } + + private Spell spellFromFile(File file) throws JSONException { + final SpellBuilder builder = new SpellBuilder(getContext(), LocalizationUtils.getLocale()); + final JSONObject json = JSONUtils.loadJSONFromData(file); + if (json == null) { + throw new JSONException("Error loading spell JSON"); + } + return spellCodec.parseSpell(json, builder, false); } - private void updateCreatedSpellNames() { - updateNamesFromDirectory(createdSpellsDir, CREATED_SPELL_EXTENSION, createdSpellNamesLD); + private List getCreatedSpells() { + return getItemsFromDirectory(createdSpellsDir, SpellbookUtils.extensionFilter(CREATED_SPELL_EXTENSION), this::spellFromFile, null); } - private T getDataItemByName(String name, String extension, File directory, JSONUtils.ThrowsExceptionFunction creator) { + private void updateCreatedSpells(boolean mainThread) { + updateItemsFromDirectory(createdSpellsDir, + SpellbookUtils.extensionFilter(CREATED_SPELL_EXTENSION), + this::spellFromFile, + null, + createdSpellsLD, + mainThread); + } + + private void updateCreatedSpells() { updateCreatedSpells(false); } + + private T getDataItemByName(String name, String extension, File directory, SpellbookUtils.ThrowsExceptionFunction creator) { final String filename = name + extension; final File filepath = new File(directory, filename); if (!(filepath.exists() && filepath.isFile())) { @@ -376,6 +473,15 @@ SortFilterStatus getSortFilterStatusByName(String name) { return getDataItemByName(name, STATUS_EXTENSION, statusesDir, SortFilterStatus::fromJSON); } + Source getCreatedSourceByName(String name) { + for (Source source : Source.createdSources()) { + if (source.getDisplayName().equals(name)) { + return source; + } + } + return null; + } + CharacterProfile getProfile() { return profile; } boolean getUseExpanded() { return profile.getSortFilterStatus().getUseTashasExpandedLists(); } @@ -553,12 +659,74 @@ boolean deleteProfileByName(String name) { return success; } + boolean updateSourceFile(Source source, String originalName, String originalCode) { + final boolean nameChanged = !source.getDisplayName().equals(originalName) || + !source.getCode().equals(originalCode); + final Source originalSource = Source.fromInternalName(originalName); + addCreatedSource(source); + if (nameChanged) { + deleteSourceByNameOrCode(originalName); + final List createdSpells = createdSpellsLD.getValue(); + if (createdSpells != null) { + for (Spell spell : createdSpells) { + if (spell.getLocations().containsKey(originalSource)) { + final int page = spell.getPage(originalSource); + spell.getLocations().remove(originalSource); + spell.getLocations().put(source, page); + saveCreatedSpell(spell); + } + } + } + } + + return true; + } + boolean deleteSortFilterStatusByName(String name) { return deleteItemByName(name, STATUS_EXTENSION, statusesDir); } - void update() { + boolean deleteSpellByName(String name) { + final boolean success = deleteItemByName(name, CREATED_SPELL_EXTENSION, createdSpellsDir); + spells.removeIf(s -> s.getName().equals(name)); + return success; + } + + void removeSourceFromCreatedSpells(Source source) { + final List createdSpells = createdSpellsLD.getValue(); + if (createdSpells == null) { + return; + } + + boolean changed = false; + for (Spell spell : createdSpells) { + final Map locations = spell.getLocations(); + if (locations.containsKey(source)) { + locations.remove(source); + saveCreatedSpell(spell); + changed = true; + } + } + + if (changed) { + this.setFilteredSpells(this.currentSpellList); + } + } + + boolean deleteSourceByNameOrCode(String identifier) { + final Source source = Source.fromInternalName(identifier); + boolean success = deleteItemByName(identifier, CREATED_SOURCE_EXTENSION, createdSourcesDir); + if (success) { + removeSourceFromCreatedSpells(source); + source.delete(); + } + return success; + } + + void initialUpdates() { updateCharacterNames(); + updateCreatedSources(true); + updateCreatedSpells(true); } SpellFilterStatus getSpellFilterStatus() { return profile != null ? profile.getSpellFilterStatus() : null; } @@ -571,8 +739,13 @@ void update() { LiveData> currentCharacterNames() { return characterNamesLD; } LiveData> currentStatusNames() { return statusNamesLD; } - LiveData> currentCreatedSourceNames() { return createdSourceNamesLD; } - LiveData> currentCreatedSpellNames() { return createdSpellNamesLD; } + LiveData> currentCreatedSources() { return createdSourcesLD; } + LiveData> currentCreatedSourceNames() { + return Transformations.distinctUntilChanged(Transformations.map(createdSourcesLD, + sources -> sources.stream().map(source -> DisplayUtils.getDisplayName(getContext(), source)).collect(Collectors.toList())) + ); + } + LiveData> currentCreatedSpells() { return createdSpellsLD; } boolean saveCurrentProfile() { final CharacterProfile profile = currentProfileLD.getValue(); @@ -587,11 +760,51 @@ boolean saveCurrentProfile() { boolean saveSpellSlotStatus() { return saveCurrentProfile(); } boolean addCreatedSpell(Spell spell) { + final boolean success = saveCreatedSpell(spell); + this.spells.add(spell); + setFilterNeeded(); + return success; + } + + boolean saveCreatedSpell(Spell spell) { final String filename = spell.getName() + CREATED_SPELL_EXTENSION; final File filepath = new File(createdSpellsDir, filename); return JSONUtils.saveAsJSON(spell, spellCodec::toJSON, filepath); } + void updateSpell(Spell oldSpell, Spell newSpell) { + final Predicate idTest = s -> s.getID() == oldSpell.getID(); + final int oldIndex = SpellbookUtils.firstIndex(spells, idTest); + final int oldCurrentIndex = SpellbookUtils.firstIndex(currentSpellList, idTest); + final boolean renamed = !oldSpell.getName().equals(newSpell.getName()); + if (renamed) { + deleteSpellByName(oldSpell.getName()); + } + saveCreatedSpell(newSpell); + + if (oldIndex > -1) { + spells.set(oldIndex, newSpell); + } + + if (renamed) { + spells.add(newSpell); + } else if (oldCurrentIndex > -1) { + currentSpellList.set(oldCurrentIndex, newSpell); + } + + setFilteredSpells(currentSpellList); + } + + private boolean saveSource(Source source, File filepath) { + return JSONUtils.saveAsJSON(source, (src) -> JSONUtils.asJSON(src, getContext()), filepath); + } + + boolean addCreatedSource(Source source) { + final String filename = DisplayUtils.getCode(source, getContext()) + CREATED_SOURCE_EXTENSION; + final File filepath = new File(createdSourcesDir, filename); + return saveSource(source, filepath); + } + CharSequence getSearchQuery() { return searchQuery; } void setSearchQuery(CharSequence searchQuery) { this.searchQuery = searchQuery; @@ -655,7 +868,7 @@ void setFilteredSpells(List filteredSpells) { filterNeeded = false; } - int getIndex(Spell spell) { + int getCurrentIndex(Spell spell) { return currentSpellList.indexOf(spell); } @@ -792,4 +1005,28 @@ private void castSpell(Spell spell, int level, boolean levelInMessage) { toastEventLD.postValue(new Event<>(message)); } + private Set createdSpellIDs() { + final List createdSpells = createdSpellsLD.getValue(); + if (createdSpells == null) { + return new TreeSet<>(); + } + return createdSpells.stream().map(Spell::getID).collect(Collectors.toSet()); + } + + // We distinguish official and created spell IDs by adding an offset that we assume will be + // larger than the number of official spells. + // We can't just start the created spell IDs after the official spell ones, as the list of + // official spells will likely grow. + // This feels a bit hacky (it would be nicer to have these sorts of constraints built into + // the data structure), but I'm not sure what a better way to do this would be, since a lot of + // the app infrastructure is looking to grab spells by their (integer) IDs + int newSpellID() { + final Set ids = createdSpellIDs(); + int id = CREATED_SPELL_ID_OFFSET; + while (ids.contains(id)) { + id += 1; + } + return id; + } + } diff --git a/app/src/main/java/dnd/jon/spellbook/UnitTypeSpinnerAdapter.java b/app/src/main/java/dnd/jon/spellbook/UnitTypeSpinnerAdapter.java index 18ade23e..4e28e350 100644 --- a/app/src/main/java/dnd/jon/spellbook/UnitTypeSpinnerAdapter.java +++ b/app/src/main/java/dnd/jon/spellbook/UnitTypeSpinnerAdapter.java @@ -2,7 +2,7 @@ import android.content.Context; -class UnitTypeSpinnerAdapter & Unit> extends NamedSpinnerAdapter { +class UnitTypeSpinnerAdapter & Unit> extends NamedEnumSpinnerAdapter { UnitTypeSpinnerAdapter(Context context, Class type, int textSize) { super(context, type, DisplayUtils::getPluralName, textSize); } UnitTypeSpinnerAdapter(Context context, Class type) { super(context, type, DisplayUtils::getPluralName); } } diff --git a/app/src/main/res/drawable-anydpi/book_icon.xml b/app/src/main/res/drawable-anydpi/book_icon.xml new file mode 100644 index 00000000..16920ec4 --- /dev/null +++ b/app/src/main/res/drawable-anydpi/book_icon.xml @@ -0,0 +1,20 @@ + + + + + + diff --git a/app/src/main/res/drawable-anydpi/ic_add_white.xml b/app/src/main/res/drawable-anydpi/ic_add_white.xml new file mode 100644 index 00000000..672478e3 --- /dev/null +++ b/app/src/main/res/drawable-anydpi/ic_add_white.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/drawable-anydpi/ic_close_white.xml b/app/src/main/res/drawable-anydpi/ic_close_white.xml new file mode 100644 index 00000000..6d8f2825 --- /dev/null +++ b/app/src/main/res/drawable-anydpi/ic_close_white.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/drawable-anydpi/ic_delete.xml b/app/src/main/res/drawable-anydpi/ic_delete.xml new file mode 100644 index 00000000..18b7e774 --- /dev/null +++ b/app/src/main/res/drawable-anydpi/ic_delete.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/drawable-hdpi/book_icon.png b/app/src/main/res/drawable-hdpi/book_icon.png new file mode 100644 index 00000000..bf8163fb Binary files /dev/null and b/app/src/main/res/drawable-hdpi/book_icon.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_add_white.png b/app/src/main/res/drawable-hdpi/ic_add_white.png new file mode 100644 index 00000000..9a20be84 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_add_white.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_close_white.png b/app/src/main/res/drawable-hdpi/ic_close_white.png new file mode 100644 index 00000000..8a4491ab Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_close_white.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_delete.png b/app/src/main/res/drawable-hdpi/ic_delete.png new file mode 100644 index 00000000..bde3d479 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_delete.png differ diff --git a/app/src/main/res/drawable-mdpi/book_icon.png b/app/src/main/res/drawable-mdpi/book_icon.png new file mode 100644 index 00000000..281da2cf Binary files /dev/null and b/app/src/main/res/drawable-mdpi/book_icon.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_add_white.png b/app/src/main/res/drawable-mdpi/ic_add_white.png new file mode 100644 index 00000000..be6d6259 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_add_white.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_close_white.png b/app/src/main/res/drawable-mdpi/ic_close_white.png new file mode 100644 index 00000000..81f85b02 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_close_white.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_delete.png b/app/src/main/res/drawable-mdpi/ic_delete.png new file mode 100644 index 00000000..f5f1e1eb Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_delete.png differ diff --git a/app/src/main/res/drawable-sw600dp/book_background_on_phone.xml b/app/src/main/res/drawable-sw600dp/book_background_on_phone.xml new file mode 100644 index 00000000..7957b64a --- /dev/null +++ b/app/src/main/res/drawable-sw600dp/book_background_on_phone.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable-xhdpi/book_icon.png b/app/src/main/res/drawable-xhdpi/book_icon.png new file mode 100644 index 00000000..8eb3a8ce Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/book_icon.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_add_white.png b/app/src/main/res/drawable-xhdpi/ic_add_white.png new file mode 100644 index 00000000..6eedadfb Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_add_white.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_close_white.png b/app/src/main/res/drawable-xhdpi/ic_close_white.png new file mode 100644 index 00000000..538ddf1a Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_close_white.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_delete.png b/app/src/main/res/drawable-xhdpi/ic_delete.png new file mode 100644 index 00000000..13057cbf Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_delete.png differ diff --git a/app/src/main/res/drawable-xxhdpi/book_icon.png b/app/src/main/res/drawable-xxhdpi/book_icon.png new file mode 100644 index 00000000..bb623fde Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/book_icon.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_add_white.png b/app/src/main/res/drawable-xxhdpi/ic_add_white.png new file mode 100644 index 00000000..7a27534d Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_add_white.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_close_white.png b/app/src/main/res/drawable-xxhdpi/ic_close_white.png new file mode 100644 index 00000000..f45eedf3 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_close_white.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_delete.png b/app/src/main/res/drawable-xxhdpi/ic_delete.png new file mode 100644 index 00000000..ee98d4cd Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_delete.png differ diff --git a/app/src/main/res/drawable/book_background_on_phone.xml b/app/src/main/res/drawable/book_background_on_phone.xml new file mode 100644 index 00000000..6eb5340c --- /dev/null +++ b/app/src/main/res/drawable/book_background_on_phone.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/cauldron.xml b/app/src/main/res/drawable/cauldron.xml new file mode 100644 index 00000000..cadb4243 --- /dev/null +++ b/app/src/main/res/drawable/cauldron.xml @@ -0,0 +1,19 @@ + + + + + + diff --git a/app/src/main/res/drawable/spacer_2dp.xml b/app/src/main/res/drawable/spacer_2dp.xml new file mode 100644 index 00000000..d5e56f52 --- /dev/null +++ b/app/src/main/res/drawable/spacer_2dp.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout-sw600dp/activity_main.xml b/app/src/main/res/layout-sw600dp/activity_main.xml index 3e94f0d5..4adf1e24 100644 --- a/app/src/main/res/layout-sw600dp/activity_main.xml +++ b/app/src/main/res/layout-sw600dp/activity_main.xml @@ -78,46 +78,14 @@ - - - - - - diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml index 861b0fe3..bedb3799 100755 --- a/app/src/main/res/layout/activity_main.xml +++ b/app/src/main/res/layout/activity_main.xml @@ -44,35 +44,13 @@ - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/expandable_submenu_item.xml b/app/src/main/res/layout/expandable_submenu_item.xml new file mode 100644 index 00000000..7c9f01c1 --- /dev/null +++ b/app/src/main/res/layout/expandable_submenu_item.xml @@ -0,0 +1,16 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/filter_option.xml b/app/src/main/res/layout/filter_option.xml index 17723dd2..f184de03 100644 --- a/app/src/main/res/layout/filter_option.xml +++ b/app/src/main/res/layout/filter_option.xml @@ -41,7 +41,7 @@ android:paddingHorizontal="8dp" /> - + + + + + + + + + + + + + + + +