diff options
Diffstat (limited to '')
5 files changed, 509 insertions, 0 deletions
diff --git a/src/android/app/src/main/java/org/citra/citra_emu/features/cheats/ui/CheatDetailsFragment.java b/src/android/app/src/main/java/org/citra/citra_emu/features/cheats/ui/CheatDetailsFragment.java new file mode 100644 index 000000000..762cdb80e --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/features/cheats/ui/CheatDetailsFragment.java @@ -0,0 +1,174 @@ +package org.citra.citra_emu.features.cheats.ui; + +import android.os.Bundle; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.Button; +import android.widget.EditText; +import android.widget.ScrollView; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.app.AlertDialog; +import androidx.fragment.app.Fragment; +import androidx.lifecycle.ViewModelProvider; + +import org.citra.citra_emu.R; +import org.citra.citra_emu.features.cheats.model.Cheat; +import org.citra.citra_emu.features.cheats.model.CheatsViewModel; + +public class CheatDetailsFragment extends Fragment { + private View mRoot; + private ScrollView mScrollView; + private TextView mLabelName; + private EditText mEditName; + private EditText mEditNotes; + private EditText mEditCode; + private Button mButtonDelete; + private Button mButtonEdit; + private Button mButtonCancel; + private Button mButtonOk; + + private CheatsViewModel mViewModel; + + @Nullable + @Override + public View onCreateView(LayoutInflater inflater, @Nullable ViewGroup container, + @Nullable Bundle savedInstanceState) { + return inflater.inflate(R.layout.fragment_cheat_details, container, false); + } + + @Override + public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { + mRoot = view.findViewById(R.id.root); + mScrollView = view.findViewById(R.id.scroll_view); + mLabelName = view.findViewById(R.id.label_name); + mEditName = view.findViewById(R.id.edit_name); + mEditNotes = view.findViewById(R.id.edit_notes); + mEditCode = view.findViewById(R.id.edit_code); + mButtonDelete = view.findViewById(R.id.button_delete); + mButtonEdit = view.findViewById(R.id.button_edit); + mButtonCancel = view.findViewById(R.id.button_cancel); + mButtonOk = view.findViewById(R.id.button_ok); + + CheatsActivity activity = (CheatsActivity) requireActivity(); + mViewModel = new ViewModelProvider(activity).get(CheatsViewModel.class); + + mViewModel.getSelectedCheat().observe(getViewLifecycleOwner(), + this::onSelectedCheatUpdated); + mViewModel.getIsEditing().observe(getViewLifecycleOwner(), this::onIsEditingUpdated); + + mButtonDelete.setOnClickListener(this::onDeleteClicked); + mButtonEdit.setOnClickListener(this::onEditClicked); + mButtonCancel.setOnClickListener(this::onCancelClicked); + mButtonOk.setOnClickListener(this::onOkClicked); + + // On a portrait phone screen (or other narrow screen), only one of the two panes are shown + // at the same time. If the user is navigating using a d-pad and moves focus to an element + // in the currently hidden pane, we need to manually show that pane. + CheatsActivity.setOnFocusChangeListenerRecursively(view, + (v, hasFocus) -> activity.onDetailsViewFocusChange(hasFocus)); + } + + private void clearEditErrors() { + mEditName.setError(null); + mEditCode.setError(null); + } + + private void onDeleteClicked(View view) { + String name = mEditName.getText().toString(); + + AlertDialog.Builder builder = new AlertDialog.Builder(requireContext()); + builder.setMessage(getString(R.string.cheats_delete_confirmation, name)); + builder.setPositiveButton(android.R.string.yes, + (dialog, i) -> mViewModel.deleteSelectedCheat()); + builder.setNegativeButton(android.R.string.no, null); + builder.show(); + } + + private void onEditClicked(View view) { + mViewModel.setIsEditing(true); + mButtonOk.requestFocus(); + } + + private void onCancelClicked(View view) { + mViewModel.setIsEditing(false); + onSelectedCheatUpdated(mViewModel.getSelectedCheat().getValue()); + mButtonDelete.requestFocus(); + } + + private void onOkClicked(View view) { + clearEditErrors(); + + String name = mEditName.getText().toString(); + String notes = mEditNotes.getText().toString(); + String code = mEditCode.getText().toString(); + + if (name.isEmpty()) { + mEditName.setError(getString(R.string.cheats_error_no_name)); + mScrollView.smoothScrollTo(0, mLabelName.getTop()); + return; + } else if (code.isEmpty()) { + mEditCode.setError(getString(R.string.cheats_error_no_code_lines)); + mScrollView.smoothScrollTo(0, mEditCode.getBottom()); + return; + } + + int validityResult = Cheat.isValidGatewayCode(code); + + if (validityResult != 0) { + mEditCode.setError(getString(R.string.cheats_error_on_line, validityResult)); + mScrollView.smoothScrollTo(0, mEditCode.getBottom()); + return; + } + + Cheat newCheat = Cheat.createGatewayCode(name, notes, code); + + if (mViewModel.getIsAdding().getValue()) { + mViewModel.finishAddingCheat(newCheat); + } else { + mViewModel.updateSelectedCheat(newCheat); + } + + mButtonEdit.requestFocus(); + } + + private void onSelectedCheatUpdated(@Nullable Cheat cheat) { + clearEditErrors(); + + boolean isEditing = mViewModel.getIsEditing().getValue(); + + mRoot.setVisibility(isEditing || cheat != null ? View.VISIBLE : View.GONE); + + // If the fragment was recreated while editing a cheat, it's vital that we + // don't repopulate the fields, otherwise the user's changes will be lost + if (!isEditing) { + if (cheat == null) { + mEditName.setText(""); + mEditNotes.setText(""); + mEditCode.setText(""); + } else { + mEditName.setText(cheat.getName()); + mEditNotes.setText(cheat.getNotes()); + mEditCode.setText(cheat.getCode()); + } + } + } + + private void onIsEditingUpdated(boolean isEditing) { + if (isEditing) { + mRoot.setVisibility(View.VISIBLE); + } + + mEditName.setEnabled(isEditing); + mEditNotes.setEnabled(isEditing); + mEditCode.setEnabled(isEditing); + + mButtonDelete.setVisibility(isEditing ? View.GONE : View.VISIBLE); + mButtonEdit.setVisibility(isEditing ? View.GONE : View.VISIBLE); + mButtonCancel.setVisibility(isEditing ? View.VISIBLE : View.GONE); + mButtonOk.setVisibility(isEditing ? View.VISIBLE : View.GONE); + } +} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/features/cheats/ui/CheatListFragment.java b/src/android/app/src/main/java/org/citra/citra_emu/features/cheats/ui/CheatListFragment.java new file mode 100644 index 000000000..6c67a31d4 --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/features/cheats/ui/CheatListFragment.java @@ -0,0 +1,46 @@ +package org.citra.citra_emu.features.cheats.ui; + +import android.os.Bundle; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.fragment.app.Fragment; +import androidx.lifecycle.ViewModelProvider; +import androidx.recyclerview.widget.LinearLayoutManager; +import androidx.recyclerview.widget.RecyclerView; + +import com.google.android.material.floatingactionbutton.FloatingActionButton; + +import org.citra.citra_emu.R; +import org.citra.citra_emu.features.cheats.model.CheatsViewModel; +import org.citra.citra_emu.ui.DividerItemDecoration; + +public class CheatListFragment extends Fragment { + @Nullable + @Override + public View onCreateView(LayoutInflater inflater, @Nullable ViewGroup container, + @Nullable Bundle savedInstanceState) { + return inflater.inflate(R.layout.fragment_cheat_list, container, false); + } + + @Override + public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { + RecyclerView recyclerView = view.findViewById(R.id.cheat_list); + FloatingActionButton fab = view.findViewById(R.id.fab); + + CheatsActivity activity = (CheatsActivity) requireActivity(); + CheatsViewModel viewModel = new ViewModelProvider(activity).get(CheatsViewModel.class); + + recyclerView.setAdapter(new CheatsAdapter(activity, viewModel)); + recyclerView.setLayoutManager(new LinearLayoutManager(activity)); + recyclerView.addItemDecoration(new DividerItemDecoration(activity, null)); + + fab.setOnClickListener(v -> { + viewModel.startAddingCheat(); + viewModel.openDetailsView(); + }); + } +} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/features/cheats/ui/CheatViewHolder.java b/src/android/app/src/main/java/org/citra/citra_emu/features/cheats/ui/CheatViewHolder.java new file mode 100644 index 000000000..8ba8f86e7 --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/features/cheats/ui/CheatViewHolder.java @@ -0,0 +1,56 @@ +package org.citra.citra_emu.features.cheats.ui; + +import android.view.View; +import android.widget.CheckBox; +import android.widget.CompoundButton; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.lifecycle.ViewModelProvider; +import androidx.recyclerview.widget.RecyclerView; + +import org.citra.citra_emu.R; +import org.citra.citra_emu.features.cheats.model.Cheat; +import org.citra.citra_emu.features.cheats.model.CheatsViewModel; + +public class CheatViewHolder extends RecyclerView.ViewHolder + implements View.OnClickListener, CompoundButton.OnCheckedChangeListener { + private final View mRoot; + private final TextView mName; + private final CheckBox mCheckbox; + + private CheatsViewModel mViewModel; + private Cheat mCheat; + private int mPosition; + + public CheatViewHolder(@NonNull View itemView) { + super(itemView); + + mRoot = itemView.findViewById(R.id.root); + mName = itemView.findViewById(R.id.text_name); + mCheckbox = itemView.findViewById(R.id.checkbox); + } + + public void bind(CheatsActivity activity, Cheat cheat, int position) { + mCheckbox.setOnCheckedChangeListener(null); + + mViewModel = new ViewModelProvider(activity).get(CheatsViewModel.class); + mCheat = cheat; + mPosition = position; + + mName.setText(mCheat.getName()); + mCheckbox.setChecked(mCheat.getEnabled()); + + mRoot.setOnClickListener(this); + mCheckbox.setOnCheckedChangeListener(this); + } + + public void onClick(View root) { + mViewModel.setSelectedCheat(mCheat, mPosition); + mViewModel.openDetailsView(); + } + + public void onCheckedChanged(CompoundButton buttonView, boolean isChecked) { + mCheat.setEnabled(isChecked); + } +} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/features/cheats/ui/CheatsActivity.java b/src/android/app/src/main/java/org/citra/citra_emu/features/cheats/ui/CheatsActivity.java new file mode 100644 index 000000000..a36bf427c --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/features/cheats/ui/CheatsActivity.java @@ -0,0 +1,161 @@ +package org.citra.citra_emu.features.cheats.ui; + +import android.content.Context; +import android.content.Intent; +import android.os.Bundle; +import android.view.Menu; +import android.view.MenuInflater; +import android.view.View; +import android.view.ViewGroup; + +import androidx.annotation.NonNull; +import androidx.appcompat.app.AppCompatActivity; +import androidx.core.view.ViewCompat; +import androidx.lifecycle.ViewModelProvider; +import androidx.slidingpanelayout.widget.SlidingPaneLayout; + +import org.citra.citra_emu.R; +import org.citra.citra_emu.features.cheats.model.Cheat; +import org.citra.citra_emu.features.cheats.model.CheatsViewModel; +import org.citra.citra_emu.ui.TwoPaneOnBackPressedCallback; + +public class CheatsActivity extends AppCompatActivity + implements SlidingPaneLayout.PanelSlideListener { + private CheatsViewModel mViewModel; + + private SlidingPaneLayout mSlidingPaneLayout; + private View mCheatList; + private View mCheatDetails; + + private View mCheatListLastFocus; + private View mCheatDetailsLastFocus; + + public static void launch(Context context) { + Intent intent = new Intent(context, CheatsActivity.class); + context.startActivity(intent); + } + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + mViewModel = new ViewModelProvider(this).get(CheatsViewModel.class); + mViewModel.load(); + + setContentView(R.layout.activity_cheats); + + mSlidingPaneLayout = findViewById(R.id.sliding_pane_layout); + mCheatList = findViewById(R.id.cheat_list); + mCheatDetails = findViewById(R.id.cheat_details); + + mCheatListLastFocus = mCheatList; + mCheatDetailsLastFocus = mCheatDetails; + + mSlidingPaneLayout.addPanelSlideListener(this); + + getOnBackPressedDispatcher().addCallback(this, + new TwoPaneOnBackPressedCallback(mSlidingPaneLayout)); + + mViewModel.getSelectedCheat().observe(this, this::onSelectedCheatChanged); + mViewModel.getIsEditing().observe(this, this::onIsEditingChanged); + onSelectedCheatChanged(mViewModel.getSelectedCheat().getValue()); + + mViewModel.getOpenDetailsViewEvent().observe(this, this::openDetailsView); + + // Show "Up" button in the action bar for navigation + getSupportActionBar().setDisplayHomeAsUpEnabled(true); + } + + @Override + public boolean onCreateOptionsMenu(Menu menu) { + MenuInflater inflater = getMenuInflater(); + inflater.inflate(R.menu.menu_settings, menu); + + return true; + } + + @Override + protected void onStop() { + super.onStop(); + + mViewModel.saveIfNeeded(); + } + + @Override + public void onPanelSlide(@NonNull View panel, float slideOffset) { + } + + @Override + public void onPanelOpened(@NonNull View panel) { + boolean rtl = ViewCompat.getLayoutDirection(panel) == ViewCompat.LAYOUT_DIRECTION_RTL; + mCheatDetailsLastFocus.requestFocus(rtl ? View.FOCUS_LEFT : View.FOCUS_RIGHT); + } + + @Override + public void onPanelClosed(@NonNull View panel) { + boolean rtl = ViewCompat.getLayoutDirection(panel) == ViewCompat.LAYOUT_DIRECTION_RTL; + mCheatListLastFocus.requestFocus(rtl ? View.FOCUS_RIGHT : View.FOCUS_LEFT); + } + + private void onIsEditingChanged(boolean isEditing) { + if (isEditing) { + mSlidingPaneLayout.setLockMode(SlidingPaneLayout.LOCK_MODE_UNLOCKED); + } + } + + private void onSelectedCheatChanged(Cheat selectedCheat) { + boolean cheatSelected = selectedCheat != null || mViewModel.getIsEditing().getValue(); + + if (!cheatSelected && mSlidingPaneLayout.isOpen()) { + mSlidingPaneLayout.close(); + } + + mSlidingPaneLayout.setLockMode(cheatSelected ? + SlidingPaneLayout.LOCK_MODE_UNLOCKED : SlidingPaneLayout.LOCK_MODE_LOCKED_CLOSED); + } + + public void onListViewFocusChange(boolean hasFocus) { + if (hasFocus) { + mCheatListLastFocus = mCheatList.findFocus(); + if (mCheatListLastFocus == null) + throw new NullPointerException(); + + mSlidingPaneLayout.close(); + } + } + + public void onDetailsViewFocusChange(boolean hasFocus) { + if (hasFocus) { + mCheatDetailsLastFocus = mCheatDetails.findFocus(); + if (mCheatDetailsLastFocus == null) + throw new NullPointerException(); + + mSlidingPaneLayout.open(); + } + } + + @Override + public boolean onSupportNavigateUp() { + onBackPressed(); + return true; + } + + private void openDetailsView(boolean open) { + if (open) { + mSlidingPaneLayout.open(); + } + } + + public static void setOnFocusChangeListenerRecursively(@NonNull View view, + View.OnFocusChangeListener listener) { + view.setOnFocusChangeListener(listener); + + if (view instanceof ViewGroup) { + ViewGroup viewGroup = (ViewGroup) view; + for (int i = 0; i < viewGroup.getChildCount(); i++) { + View child = viewGroup.getChildAt(i); + setOnFocusChangeListenerRecursively(child, listener); + } + } + } +} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/features/cheats/ui/CheatsAdapter.java b/src/android/app/src/main/java/org/citra/citra_emu/features/cheats/ui/CheatsAdapter.java new file mode 100644 index 000000000..9cb2ce8d8 --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/features/cheats/ui/CheatsAdapter.java @@ -0,0 +1,72 @@ +package org.citra.citra_emu.features.cheats.ui; + +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; + +import androidx.annotation.NonNull; +import androidx.recyclerview.widget.RecyclerView; + +import org.citra.citra_emu.R; +import org.citra.citra_emu.features.cheats.model.Cheat; +import org.citra.citra_emu.features.cheats.model.CheatsViewModel; + +public class CheatsAdapter extends RecyclerView.Adapter<CheatViewHolder> { + private final CheatsActivity mActivity; + private final CheatsViewModel mViewModel; + + public CheatsAdapter(CheatsActivity activity, CheatsViewModel viewModel) { + mActivity = activity; + mViewModel = viewModel; + + mViewModel.getCheatAddedEvent().observe(activity, (position) -> { + if (position != null) { + notifyItemInserted(position); + } + }); + + mViewModel.getCheatUpdatedEvent().observe(activity, (position) -> { + if (position != null) { + notifyItemChanged(position); + } + }); + + mViewModel.getCheatDeletedEvent().observe(activity, (position) -> { + if (position != null) { + notifyItemRemoved(position); + } + }); + } + + @NonNull + @Override + public CheatViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { + LayoutInflater inflater = LayoutInflater.from(parent.getContext()); + + View cheatView = inflater.inflate(R.layout.list_item_cheat, parent, false); + addViewListeners(cheatView); + return new CheatViewHolder(cheatView); + } + + @Override + public void onBindViewHolder(@NonNull CheatViewHolder holder, int position) { + holder.bind(mActivity, getItemAt(position), position); + } + + @Override + public int getItemCount() { + return mViewModel.getCheats().length; + } + + private void addViewListeners(View view) { + // On a portrait phone screen (or other narrow screen), only one of the two panes are shown + // at the same time. If the user is navigating using a d-pad and moves focus to an element + // in the currently hidden pane, we need to manually show that pane. + CheatsActivity.setOnFocusChangeListenerRecursively(view, + (v, hasFocus) -> mActivity.onListViewFocusChange(hasFocus)); + } + + private Cheat getItemAt(int position) { + return mViewModel.getCheats()[position]; + } +} |