package com.marverenic.music.viewmodel; import android.app.DatePickerDialog; import android.content.Context; import android.content.DialogInterface; import android.content.res.Resources; import android.databinding.BaseObservable; import android.databinding.Bindable; import android.support.design.widget.TextInputLayout; import android.support.v7.app.AlertDialog; import android.support.v7.widget.AppCompatEditText; import android.text.InputType; import android.text.TextUtils; import android.text.format.DateUtils; import android.view.KeyEvent; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; import android.widget.AdapterView; import android.widget.BaseAdapter; import android.widget.SpinnerAdapter; import android.widget.TextView; import com.android.databinding.library.baseAdapters.BR; import com.marverenic.music.JockeyApplication; import com.marverenic.music.R; import com.marverenic.music.data.store.MusicStore; import com.marverenic.music.data.store.PlaylistStore; import com.marverenic.music.model.Album; import com.marverenic.music.model.Artist; import com.marverenic.music.model.Genre; import com.marverenic.music.model.Playlist; import com.marverenic.music.model.Song; import com.marverenic.music.model.playlistrules.AutoPlaylistRule; import com.marverenic.music.model.playlistrules.RuleEnumeration; import java.util.Calendar; import java.util.Formatter; import java.util.List; import javax.inject.Inject; import rx.Subscription; import timber.log.Timber; import static android.view.WindowManager.LayoutParams.SOFT_INPUT_STATE_VISIBLE; public class RuleViewModel extends BaseObservable { private Context mContext; @Inject MusicStore mMusicStore; @Inject PlaylistStore mPlaylistStore; private AutoPlaylistRule.Factory mFactory; private RuleEnumeration mEnumeratedRule; private FilterAdapter mFilterAdapter; private ValueAdapter<?> mValueAdapter; private Subscription mValueSubscription; private OnRemovalListener mRemovalListener; private List<AutoPlaylistRule> mRules; private int mIndex; public RuleViewModel(Context context) { mContext = context; JockeyApplication.getComponent(context).inject(this); mEnumeratedRule = RuleEnumeration.IS; } public void setRule(List<AutoPlaylistRule> rules, int index) { if (rules.equals(mRules) && index == mIndex) { return; } mRules = rules; mIndex = index; mFactory = new AutoPlaylistRule.Factory(rules.get(index)); mEnumeratedRule = RuleEnumeration.from(mFactory.getField(), mFactory.getMatch()); if (mFilterAdapter != null) { mFilterAdapter.onTypeChanged(); } setupValueAdapter(); // Setup everything but the value spinner (since the adapter has to be made asynchronously) notifyPropertyChanged(BR.selectedType); notifyPropertyChanged(BR.selectedFilter); notifyPropertyChanged(BR.valueSpinnerVisibility); notifyPropertyChanged(BR.valueTextVisibility); notifyPropertyChanged(BR.valueText); } public void setOnRemovalListener(OnRemovalListener listener) { mRemovalListener = listener; } private void apply() { mRules.set(mIndex, mFactory.build()); } @Bindable public int getSelectedType() { return mFactory.getType(); } @Bindable public String getFieldPrompt() { return mContext.getResources().getStringArray(R.array.auto_plist_types)[mFactory.getType()]; } public AdapterView.OnItemSelectedListener getTypeSelectedListener() { return new AdapterView.OnItemSelectedListener() { @Override public void onItemSelected(AdapterView<?> parent, View view, int pos, long id) { if (mFactory.getType() == pos) { return; } mFactory.setType(pos); apply(); mEnumeratedRule = RuleEnumeration.from(mFactory.getField(), mFactory.getMatch()); mFilterAdapter.onTypeChanged(); setupValueAdapter(); notifyPropertyChanged(BR.fieldPrompt); notifyPropertyChanged(BR.valueTextVisibility); notifyPropertyChanged(BR.valueSpinnerVisibility); } @Override public void onNothingSelected(AdapterView<?> parent) {} }; } @Bindable public int getSelectedFilter() { RuleEnumeration[] values = RuleEnumeration.values(); for (int i = 0; i < values.length; i++) { RuleEnumeration filter = values[i]; if (filter.equals(mEnumeratedRule)) { return i; } } return -1; } public AdapterView.OnItemSelectedListener getFilterSelectedListener() { return new AdapterView.OnItemSelectedListener() { @Override public void onItemSelected(AdapterView<?> parent, View view, int pos, long id) { RuleEnumeration nextRule = mFilterAdapter.mFilters.get(pos); if (nextRule.getField() == mEnumeratedRule.getField() && nextRule.getMatch() == mEnumeratedRule.getMatch()) { return; } if (nextRule.getInputType() != mEnumeratedRule.getInputType()) { mFactory.setValue(""); } mEnumeratedRule = nextRule; mFactory.setField(mEnumeratedRule.getField()); mFactory.setMatch(mEnumeratedRule.getMatch()); apply(); setupValueAdapter(); notifyPropertyChanged(BR.valueText); notifyPropertyChanged(BR.valueTextVisibility); notifyPropertyChanged(BR.valueSpinnerVisibility); notifyPropertyChanged(BR.valuePrompt); } @Override public void onNothingSelected(AdapterView<?> parent) {} }; } @Bindable public String getValuePrompt() { Resources res = mContext.getResources(); String type = res.getStringArray(R.array.auto_plist_types)[getSelectedType()]; String match = res.getString(mEnumeratedRule.getNameRes()).toLowerCase(); return type + " " + match; } private void setupValueAdapter() { if (mValueSubscription != null) { mValueSubscription.unsubscribe(); } if (getValueSpinnerVisibility() != View.VISIBLE) { return; } mValueAdapter = null; switch (mFactory.getType()) { case AutoPlaylistRule.SONG: setupSongAdapter(); break; case AutoPlaylistRule.ARTIST: setupArtistAdapter(); break; case AutoPlaylistRule.ALBUM: setupAlbumAdapter(); break; case AutoPlaylistRule.GENRE: setupGenreAdapter(); break; case AutoPlaylistRule.PLAYLIST: setupPlaylistAdapter(); break; } notifyPropertyChanged(BR.valueAdapter); } private void setupSongAdapter() { mValueSubscription = mMusicStore.getSongs() .take(1) .subscribe(songs -> { mValueAdapter = new ValueAdapter<Song>(songs) { @Override public long getItemId(Song item) { return item.getSongId(); } @Override public String getItemName(Song item) { return item.getSongName(); } }; notifyPropertyChanged(BR.valueAdapter); notifyPropertyChanged(BR.selectedValue); }, throwable -> { Timber.e(throwable, "setupSongAdapter: Failed to setup song adapter"); }); } private void setupArtistAdapter() { mValueSubscription = mMusicStore.getArtists() .take(1) .subscribe(artists -> { mValueAdapter = new ValueAdapter<Artist>(artists) { @Override public long getItemId(Artist item) { return item.getArtistId(); } @Override public String getItemName(Artist item) { return item.getArtistName(); } }; notifyPropertyChanged(BR.valueAdapter); notifyPropertyChanged(BR.selectedValue); }, throwable -> { Timber.e(throwable, "setupArtistAdapter: Failed to setup artist adapter"); }); } private void setupAlbumAdapter() { mValueSubscription = mMusicStore.getAlbums() .take(1) .subscribe(albums -> { mValueAdapter = new ValueAdapter<Album>(albums) { @Override public long getItemId(Album item) { return item.getAlbumId(); } @Override public String getItemName(Album item) { return item.getAlbumName(); } }; notifyPropertyChanged(BR.valueAdapter); notifyPropertyChanged(BR.selectedValue); }, throwable -> { Timber.e(throwable, "setupAlbumAdapter: Failed to setup album adapter"); }); } private void setupGenreAdapter() { mValueSubscription = mMusicStore.getGenres() .take(1) .subscribe(genres -> { mValueAdapter = new ValueAdapter<Genre>(genres) { @Override public long getItemId(Genre item) { return item.getGenreId(); } @Override public String getItemName(Genre item) { return item.getGenreName(); } }; notifyPropertyChanged(BR.valueAdapter); notifyPropertyChanged(BR.selectedValue); }, throwable -> { Timber.e(throwable, "setupGenreAdapter: Failed to setup genre adapter"); }); } private void setupPlaylistAdapter() { mValueSubscription = mPlaylistStore.getPlaylists() .take(1) .subscribe(playlists -> { mValueAdapter = new ValueAdapter<Playlist>(playlists) { @Override public long getItemId(Playlist item) { return item.getPlaylistId(); } @Override public String getItemName(Playlist item) { return item.getPlaylistName(); } }; notifyPropertyChanged(BR.valueAdapter); notifyPropertyChanged(BR.selectedValue); }, throwable -> { Timber.e(throwable, "setupPlaylistAdapter: Failed to setup playlist adapter"); }); } public SpinnerAdapter getFilterAdapter() { if (mFilterAdapter == null) { mFilterAdapter = new FilterAdapter(); } else { mFilterAdapter.onTypeChanged(); } return mFilterAdapter; } @Bindable public SpinnerAdapter getValueAdapter() { return mValueAdapter; } @Bindable public int getValueSpinnerVisibility() { return (mEnumeratedRule.getInputType() == InputType.TYPE_NULL) ? View.VISIBLE : View.GONE; } @Bindable public int getValueTextVisibility() { return (mEnumeratedRule.getInputType() != InputType.TYPE_NULL) ? View.VISIBLE : View.GONE; } public View.OnClickListener onValueTextClick() { return v -> { if ((mEnumeratedRule.getInputType() & InputType.TYPE_CLASS_DATETIME) != 0) { showDateValueDialog(); } else { showValueDialog(); } }; } private void showValueDialog() { /* Ideally, the View that this ViewHolder wraps would have the EditText directly in it without doing the trickery below where it disguises a TextView as an EditText and opens an AlertDialog, but there are severe penalties with nesting EditTexts in a RecyclerView with a LinearLayoutManager. With no code in the ReyclerView Adapter's .onBindViewHolder() method, GC will kick in frequently when scrolling to free ~2MB from the heap while pausing for around 60ms (which may also be complimented by extra layout calls with the EditText). This has been previously reported to Google's AOSP bug tracker which provides more insight into this problem https://code.google.com/p/android/issues/detail?id=82586 (closed Feb '15) There are some workarounds to this issue, but the most practical suggestions that keep the previously mentioned layout are to use a ListView or to extend EditText or LinearLayout Manager (which either cause problems in themselves, don't work, or both). The solution used here simply avoids the problem all together by not nesting an EditText in a RecyclerView. When an EditText is needed, the user is prompted with an AlertDialog. It's not the best UX, but it's the most practical one for now. 10/8/15 */ TextInputLayout inputLayout = new TextInputLayout(mContext); AppCompatEditText editText = new AppCompatEditText(mContext); editText.setInputType(mEnumeratedRule.getInputType()); inputLayout.addView(editText); Resources res = mContext.getResources(); String type = res.getStringArray(R.array.auto_plist_types)[getSelectedType()]; String match = res.getString(mEnumeratedRule.getNameRes()).toLowerCase(); AlertDialog valueDialog = new AlertDialog.Builder(mContext) .setMessage(type + " " + match) .setView(inputLayout) .setNegativeButton(R.string.action_cancel, null) .setPositiveButton(R.string.action_done, (dialog, which) -> { String value = editText.getText().toString().trim(); if (editText.getInputType() == InputType.TYPE_CLASS_NUMBER) { // Verify the input if this rule needs a numeric value if (TextUtils.isDigitsOnly(value)) { mFactory.setValue(value); } else { // If the user inputted something that's not a number, reset it mFactory.setValue("0"); } } else { mFactory.setValue(value); } apply(); notifyPropertyChanged(BR.valueText); }) .create(); valueDialog.getWindow().setSoftInputMode(SOFT_INPUT_STATE_VISIBLE); valueDialog.show(); int padding = (int) mContext.getResources().getDimension(R.dimen.alert_padding); ((View) inputLayout.getParent()).setPadding( padding - inputLayout.getPaddingLeft(), 0, padding - inputLayout.getPaddingRight(), 0); editText.setText(mFactory.getValue()); editText.setSelection(mFactory.getValue().length()); editText.setOnEditorActionListener((v, actionId, event) -> { if (actionId == KeyEvent.KEYCODE_ENDCALL) { valueDialog.getButton(DialogInterface.BUTTON_POSITIVE).callOnClick(); } return false; }); } private void showDateValueDialog() { // Calculate the date stored in the reference Calendar currentDate = Calendar.getInstance(); try { long timestamp = Long.parseLong(mFactory.getValue()); currentDate.setTimeInMillis(timestamp * 1000L); } catch (NumberFormatException ignored) { // If the reference's value isn't valid, just use the current time as the // selected date currentDate.setTimeInMillis(System.currentTimeMillis()); } DatePickerDialog dateDialog = new DatePickerDialog(mContext, (view, year, monthOfYear, dayOfMonth) -> { Calendar nextDate = Calendar.getInstance(); nextDate.set(year, monthOfYear, dayOfMonth); mFactory.setValue(Long.toString(nextDate.getTimeInMillis() / 1000L)); apply(); notifyPropertyChanged(BR.valueText); }, currentDate.get(Calendar.YEAR), currentDate.get(Calendar.MONTH), currentDate.get(Calendar.DAY_OF_MONTH)); dateDialog.show(); } public View.OnClickListener onRemoveClick() { return v -> mRemovalListener.onRuleRemoved(mIndex); } @Bindable public String getValueText() { if ((mEnumeratedRule.getInputType() & InputType.TYPE_CLASS_DATETIME) != 0) { long dateAsUnixTimestamp; try { dateAsUnixTimestamp = Long.parseLong(mFactory.getValue()) * 1000; } catch (NumberFormatException e) { dateAsUnixTimestamp = System.currentTimeMillis(); } int flags = DateUtils.FORMAT_SHOW_DATE | DateUtils.FORMAT_SHOW_YEAR; Formatter date = DateUtils.formatDateRange(mContext, new Formatter(), dateAsUnixTimestamp, dateAsUnixTimestamp, flags, "UTC"); return date.toString(); } else { return mFactory.getValue(); } } @Bindable public int getSelectedValue() { if (mValueAdapter == null) { return 0; } long value; try { value = Long.parseLong(mFactory.getValue()); } catch (NumberFormatException exception) { return 0; } for (int i = 0; i < mValueAdapter.getCount(); i++) { if (mValueAdapter.getItemId(i) == value) { return i; } } return 0; } public AdapterView.OnItemSelectedListener getValueSelectedListener() { return new AdapterView.OnItemSelectedListener() { @Override public void onItemSelected(AdapterView<?> parent, View view, int pos, long id) { if (getValueSpinnerVisibility() == View.VISIBLE) { mFactory.setValue(Long.toString(id)); apply(); } } @Override public void onNothingSelected(AdapterView<?> adapterView) {} }; } public interface OnRemovalListener { void onRuleRemoved(int removedIndex); } private class FilterAdapter extends BaseAdapter { private List<RuleEnumeration> mFilters; public FilterAdapter() { onTypeChanged(); } public void onTypeChanged() { switch (mFactory.getType()) { case AutoPlaylistRule.SONG: mFilters = RuleEnumeration.getAllSongRules(); break; case AutoPlaylistRule.ALBUM: mFilters = RuleEnumeration.getAllAlbumRules(); break; case AutoPlaylistRule.ARTIST: mFilters = RuleEnumeration.getAllArtistRules(); break; case AutoPlaylistRule.GENRE: mFilters = RuleEnumeration.getAllGenreRules(); break; case AutoPlaylistRule.PLAYLIST: mFilters = RuleEnumeration.getAllPlaylistRules(); break; } notifyDataSetChanged(); } @Override public int getCount() { return mFilters.size(); } @Override public Object getItem(int pos) { return mFilters.get(pos); } @Override public long getItemId(int pos) { return mFilters.get(pos).getId(); } @Override public View getView(int pos, View convertView, ViewGroup parent) { if (convertView == null) { convertView = LayoutInflater.from(parent.getContext()).inflate( android.R.layout.simple_spinner_item, parent, false); } TextView textView = (TextView) convertView.findViewById(android.R.id.text1); textView.setText(mFilters.get(pos).getNameRes()); return convertView; } @Override public View getDropDownView(int pos, View convertView, ViewGroup parent) { if (convertView == null) { convertView = LayoutInflater.from(parent.getContext()).inflate( android.R.layout.simple_spinner_dropdown_item, parent, false); } TextView textView = (TextView) convertView.findViewById(android.R.id.text1); textView.setText(mFilters.get(pos).getNameRes()); return convertView; } } private abstract class ValueAdapter<Type> extends BaseAdapter { private List<Type> mValues; public ValueAdapter(List<Type> values) { mValues = values; } @Override public int getCount() { return mValues.size(); } @Override public Object getItem(int pos) { return mValues.get(pos); } @Override public long getItemId(int pos) { return getItemId(mValues.get(pos)); } public abstract long getItemId(Type item); public abstract String getItemName(Type item); @Override public View getView(int pos, View convertView, ViewGroup parent) { if (convertView == null) { convertView = LayoutInflater.from(parent.getContext()).inflate( android.R.layout.simple_spinner_dropdown_item, parent, false); } TextView textView = (TextView) convertView.findViewById(android.R.id.text1); textView.setText(getItemName(mValues.get(pos))); return convertView; } } }