taskUpdatedListener;
+ private Goal pendingNewGoal;
@Inject
public GoalTrackerPanel(GoalTrackerPlugin plugin, GoalManager goalManager)
{
super(false);
this.plugin = plugin;
+ this.goalManager = goalManager;
+ this.goalManager.addGoalsChangedListener(() -> javax.swing.SwingUtilities.invokeLater(this::refreshHomeListIfVisible));
setBackground(ColorScheme.DARK_GRAY_COLOR);
setLayout(new BorderLayout());
setBorder(new EmptyBorder(8, 8, 8, 8));
+ // Header with title and + Add goal on right
JPanel titlePanel = new JPanel();
titlePanel.setBorder(new EmptyBorder(10, 10, 10, 10));
titlePanel.setLayout(new BorderLayout());
titlePanel.setBackground(ColorScheme.DARK_GRAY_COLOR);
- titlePanel.add(
- new TextButton("+ Add goal",
- e -> {
- Goal goal = goalManager.createGoal();
- view(goal);
-
- if (Objects.nonNull(this.goalAddedListener)) this.goalAddedListener.accept(goal);
- if (Objects.nonNull(this.goalUpdatedListener)) this.goalUpdatedListener.accept(goal);
- }
- ).narrow(), BorderLayout.EAST);
-
- JLabel title = new JLabel();
- title.setText("Goal Tracker");
+ JLabel title = new JLabel("Goal Tracker v2");
title.setForeground(Color.WHITE);
title.setFont(FontManager.getRunescapeBoldFont());
- titlePanel.add(title, BorderLayout.WEST);
- goalListPanel = new ListPanel<>(goalManager.getGoals(),
- (goal) -> {
- var panel = new ListItemPanel<>(goalManager.getGoals(), goal);
+ JLabel author = new JLabel("By: AhDoozy");
+ author.setForeground(Color.LIGHT_GRAY);
+ author.setFont(title.getFont().deriveFont(title.getFont().getSize2D() - 3f));
- panel.onClick(e -> this.view(goal));
- panel.add(new GoalItemContent(plugin, goal));
+ JPanel titleTextPanel = new JPanel(new GridLayout(2, 1));
+ titleTextPanel.setBackground(ColorScheme.DARK_GRAY_COLOR);
+ titleTextPanel.add(title);
+ titleTextPanel.add(author);
+ titlePanel.add(titleTextPanel, BorderLayout.WEST);
- return panel;
- }
- );
+ JPanel addGoalPanel = new JPanel();
+ addGoalPanel.setLayout(new BoxLayout(addGoalPanel, BoxLayout.Y_AXIS));
+ addGoalPanel.setBackground(ColorScheme.DARK_GRAY_COLOR);
+ JPanel addGoalRow = new JPanel(new FlowLayout(FlowLayout.RIGHT, 5, 0));
+ addGoalRow.setBackground(ColorScheme.DARK_GRAY_COLOR);
+ ActionBarButton addGoalBtn = new ActionBarButton("+ Add goal", this::addNewGoal);
+ addGoalRow.add(addGoalBtn);
+ addGoalPanel.add(addGoalRow);
+ addGoalPanel.add(Box.createVerticalStrut(4));
+ ActionBarButton addFromPresetBtn = new ActionBarButton("Add from Preset…", this::addFromPreset);
+ addGoalPanel.add(addFromPresetBtn);
+ titlePanel.add(addGoalPanel, BorderLayout.EAST);
+
+ // Action bar
+ ActionBar actionBar = new ActionBar();
+ actionBar.right().setBorder(new EmptyBorder(0, 4, 0, 0));
+
+ undoButtonRef = new ActionBarButton("Undo", this::doUndo);
+ redoButtonRef = new ActionBarButton("Redo", this::doRedo);
+ actionBar.left().add(undoButtonRef);
+ actionBar.left().add(redoButtonRef);
+
+ ActionBarButton exportButton = new ActionBarButton("Export", this::exportGoalsToFile);
+ ActionBarButton importButton = new ActionBarButton("Import", this::importGoalsFromFile);
+ actionBar.right().add(exportButton);
+ actionBar.right().add(importButton);
+
+ updateUndoRedoButtons();
+
+ JPanel titleWrapper = new JPanel(new BorderLayout());
+ titleWrapper.setBackground(ColorScheme.DARK_GRAY_COLOR);
+ titleWrapper.add(titlePanel, BorderLayout.CENTER);
+
+ JPanel headerSeparator = new JPanel();
+ headerSeparator.setBackground(ColorScheme.DARKER_GRAY_COLOR);
+ headerSeparator.setPreferredSize(new Dimension(1, 4));
+ titleWrapper.add(headerSeparator, BorderLayout.SOUTH);
+
+ JPanel headerContainer = new JPanel(new BorderLayout());
+ headerContainer.setBackground(ColorScheme.DARK_GRAY_COLOR);
+ headerContainer.add(titleWrapper, BorderLayout.NORTH);
+ headerContainer.add(actionBar, BorderLayout.SOUTH);
+
+ goalListPanel = new ListPanel<>(goalManager.getGoals(), (goal) -> {
+ var panel = new ListItemPanel<>(goalManager.getGoals(), goal);
+ panel.onClick(e -> this.view(goal));
+ panel.add(new GoalItemContent(plugin, goal));
+ panel.onRemovedWithIndex(this::recordGoalRemoval);
+ return panel;
+ });
goalListPanel.setGap(0);
- goalListPanel.setPlaceholder("Add a new goal using the button above");
+ goalListPanel.setPlaceholder("No goals yet.
Click + Add goal above to create your first one.
");
- mainPanel.add(titlePanel, BorderLayout.NORTH);
+ mainPanel.add(headerContainer, BorderLayout.NORTH);
mainPanel.add(goalListPanel, BorderLayout.CENTER);
home();
}
+ private void refreshHomeListIfVisible()
+ {
+ if (goalPanel == null)
+ {
+ sortGoalsForHome();
+ goalListPanel.tryBuildList();
+ goalListPanel.refresh();
+ revalidate();
+ repaint();
+ }
+ }
+
public void view(Goal goal)
{
removeAll();
-
this.goalPanel = new GoalPanel(plugin, goal, this::home);
-
this.goalPanel.onGoalUpdated(this.goalUpdatedListener);
this.goalPanel.onTaskAdded(this.taskAddedListener);
this.goalPanel.onTaskUpdated(this.taskUpdatedListener);
-
add(this.goalPanel, BorderLayout.CENTER);
this.goalPanel.refresh();
-
revalidate();
repaint();
}
public void home()
{
+ if (pendingNewGoal != null)
+ {
+ try {
+ if (pendingNewGoal.getTasks() == null || pendingNewGoal.getTasks().isEmpty()) {
+ goalManager.getGoals().remove(pendingNewGoal);
+ }
+ } finally {
+ pendingNewGoal = null;
+ }
+ }
removeAll();
- add(mainPanel, BorderLayout.CENTER);
+ sortGoalsForHome();
goalListPanel.tryBuildList();
goalListPanel.refresh();
-
+ mainPanel.remove(goalListPanel);
+ mainPanel.add(goalListPanel, BorderLayout.CENTER);
+ add(mainPanel, BorderLayout.CENTER);
+ mainPanel.revalidate();
+ mainPanel.repaint();
revalidate();
repaint();
-
this.goalPanel = null;
}
+
@Override
public void refresh()
{
- // refresh single-view goal
for (Component component : getComponents()) {
if (component instanceof Refreshable) {
((Refreshable) component).refresh();
}
}
-
goalListPanel.refresh();
}
public void onGoalUpdated(Consumer listener)
{
this.goalUpdatedListener = listener;
-
this.goalListPanel.onUpdated(this.goalUpdatedListener);
-
if (this.goalPanel != null) {
this.goalPanel.onGoalUpdated(this.goalUpdatedListener);
}
@@ -139,7 +207,6 @@ public void onGoalUpdated(Consumer listener)
public void onTaskUpdated(Consumer listener)
{
this.taskUpdatedListener = listener;
-
if (this.goalPanel != null) {
this.goalPanel.onTaskUpdated(this.taskUpdatedListener);
}
@@ -148,9 +215,186 @@ public void onTaskUpdated(Consumer listener)
public void onTaskAdded(Consumer listener)
{
this.taskAddedListener = listener;
-
if (this.goalPanel != null) {
this.goalPanel.onTaskAdded(this.taskAddedListener);
}
}
-}
+
+ private void updateUndoRedoButtons()
+ {
+ if (undoButtonRef != null)
+ {
+ undoButtonRef.setEnabled(undoStack.hasUndo());
+ undoButtonRef.setToolTipText(undoStack.hasUndo() ? null : "Nothing to undo");
+ }
+ if (redoButtonRef != null)
+ {
+ redoButtonRef.setEnabled(undoStack.hasRedo());
+ redoButtonRef.setToolTipText(undoStack.hasRedo() ? null : "Nothing to redo");
+ }
+ }
+
+ private void sortGoalsForHome()
+ {
+ java.util.List goals = goalManager.getGoals();
+ Collections.sort(goals, Comparator
+ .comparing(Goal::isPinned).reversed()
+ .thenComparing(g -> {
+ String d = g.getDescription();
+ return d == null ? "" : d.toLowerCase();
+ }));
+ }
+
+ private void doUndo()
+ {
+ var entry = undoStack.popForUndo();
+ if (entry == null) { updateUndoRedoButtons(); return; }
+ java.util.List goals = goalManager.getGoals();
+ int idx = Math.max(0, Math.min(entry.getIndex(), goals.size()));
+ goals.add(idx, entry.getItem());
+ if (goalPanel == null)
+ {
+ goalListPanel.tryBuildList();
+ goalListPanel.refresh();
+ revalidate();
+ repaint();
+ }
+ updateUndoRedoButtons();
+ }
+
+ private void doRedo()
+ {
+ var entry = undoStack.popForRedo();
+ if (entry == null) { updateUndoRedoButtons(); return; }
+ java.util.List goals = goalManager.getGoals();
+ int idx = goals.indexOf(entry.getItem());
+ if (idx >= 0)
+ {
+ goals.remove(idx);
+ }
+ if (goalPanel == null)
+ {
+ goalListPanel.tryBuildList();
+ goalListPanel.refresh();
+ revalidate();
+ repaint();
+ }
+ updateUndoRedoButtons();
+ }
+
+ public void recordGoalRemoval(Goal goal, int index)
+ {
+ undoStack.pushRemove(goal, index);
+ updateUndoRedoButtons();
+ }
+
+ private void addNewGoal()
+ {
+ Goal goal = Goal.builder().tasks(ReorderableList.from()).build();
+ goalManager.getGoals().add(0, goal);
+ pendingNewGoal = goal;
+ view(goal);
+ }
+
+ private void exportGoalsToFile()
+ {
+ JFileChooser chooser = new JFileChooser();
+ chooser.setAcceptAllFileFilterUsed(false);
+ chooser.setMultiSelectionEnabled(false);
+ chooser.setFileFilter(new FileNameExtensionFilter("JSON files (*.json)", "json"));
+ chooser.setDialogTitle("Export goals to JSON");
+ chooser.setSelectedFile(new java.io.File("goals.json"));
+ int res = chooser.showSaveDialog(this);
+ if (res != JFileChooser.APPROVE_OPTION) { return; }
+ java.io.File file = chooser.getSelectedFile();
+ if (!file.getName().toLowerCase().endsWith(".json"))
+ {
+ file = new java.io.File(file.getParentFile(), file.getName() + ".json");
+ }
+ try (java.io.FileWriter fw = new java.io.FileWriter(file))
+ {
+ String json = goalManager.exportJson(true);
+ fw.write(json);
+ fw.flush();
+ JOptionPane.showMessageDialog(this, "Exported " + goalManager.getGoals().size() + " goal(s) to\n" + file.getAbsolutePath(), "Export Complete", JOptionPane.INFORMATION_MESSAGE);
+ }
+ catch (Exception ex)
+ {
+ JOptionPane.showMessageDialog(this, "Failed to export goals: " + ex.getMessage(), "Export Error", JOptionPane.ERROR_MESSAGE);
+ }
+ }
+
+ private void importGoalsFromFile()
+ {
+ JFileChooser chooser = new JFileChooser();
+ chooser.setAcceptAllFileFilterUsed(false);
+ chooser.setMultiSelectionEnabled(false);
+ chooser.setFileFilter(new FileNameExtensionFilter("JSON files (*.json)", "json"));
+ chooser.setDialogTitle("Import goals from JSON");
+ int res = chooser.showOpenDialog(this);
+ if (res != JFileChooser.APPROVE_OPTION) { return; }
+ java.io.File file = chooser.getSelectedFile();
+ try
+ {
+ String json = new String(java.nio.file.Files.readAllBytes(file.toPath()));
+ goalManager.importJson(json);
+ plugin.warmItemIcons();
+ if (goalPanel != null) {
+ home();
+ } else {
+ goalListPanel.tryBuildList();
+ goalListPanel.refresh();
+ revalidate();
+ repaint();
+ }
+ JOptionPane.showMessageDialog(this, "Imported goals from\n" + file.getAbsolutePath(), "Import Complete", JOptionPane.INFORMATION_MESSAGE);
+ }
+ catch (Exception ex)
+ {
+ JOptionPane.showMessageDialog(this, "Failed to import goals: " + ex.getMessage(), "Import Error", JOptionPane.ERROR_MESSAGE);
+ }
+ }
+
+ private void addFromPreset()
+ {
+ java.util.List options = GoalPresetRepository.getAll();
+
+ JComboBox combo = new JComboBox<>(options.toArray(new Preset[0]));
+ JTextArea details = new JTextArea(6, 36);
+ details.setWrapStyleWord(true);
+ details.setLineWrap(true);
+ details.setEditable(false);
+ details.setBackground(new JPanel().getBackground());
+
+ java.util.function.Consumer update = (opt) -> {
+ if (opt == null) { details.setText(""); return; }
+ int goals = opt.getGoals().size();
+ int tasks = opt.getGoals().stream().mapToInt(g -> g.getTasks().size()).sum();
+ details.setText(opt.getDescription() + "\n\nThis will add " + goals + " goal(s) with " + tasks + " task(s).");
+ };
+ combo.addActionListener(e -> update.accept((Preset) combo.getSelectedItem()));
+ update.accept((Preset) combo.getSelectedItem());
+
+ JPanel panel = new JPanel(new BorderLayout(6,6));
+ panel.add(new JLabel("Choose a preset:"), BorderLayout.NORTH);
+ panel.add(combo, BorderLayout.CENTER);
+ panel.add(new JScrollPane(details), BorderLayout.SOUTH);
+
+ int res = JOptionPane.showConfirmDialog(this, panel, "Add from Preset…", JOptionPane.OK_CANCEL_OPTION, JOptionPane.PLAIN_MESSAGE);
+ if (res != JOptionPane.OK_OPTION) { return; }
+
+ Preset selected = (Preset) combo.getSelectedItem();
+ if (selected == null) { return; }
+
+ goalManager.addGoals(selected.getGoals());
+
+ if (goalPanel != null) {
+ home();
+ } else {
+ goalListPanel.tryBuildList();
+ goalListPanel.refresh();
+ revalidate();
+ repaint();
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/com/toofifty/goaltracker/ui/TaskItemContent.java b/src/main/java/com/toofifty/goaltracker/ui/TaskItemContent.java
index 9f0adbf..5bf0f96 100644
--- a/src/main/java/com/toofifty/goaltracker/ui/TaskItemContent.java
+++ b/src/main/java/com/toofifty/goaltracker/ui/TaskItemContent.java
@@ -1,8 +1,17 @@
package com.toofifty.goaltracker.ui;
import com.toofifty.goaltracker.GoalTrackerPlugin;
+import com.toofifty.goaltracker.models.Goal;
import com.toofifty.goaltracker.models.task.Task;
import com.toofifty.goaltracker.services.TaskIconService;
+import com.toofifty.goaltracker.models.task.QuestTask;
+import com.toofifty.goaltracker.utils.QuestRequirements;
+import java.util.List;
+import java.util.Locale;
+
+import com.toofifty.goaltracker.ui.components.ListPanel;
+import com.toofifty.goaltracker.ui.components.ListItemPanel;
+import javax.swing.SwingUtilities;
import javax.swing.*;
import javax.swing.border.EmptyBorder;
@@ -10,35 +19,136 @@
import static com.toofifty.goaltracker.utils.Constants.STATUS_TO_COLOR;
+import com.toofifty.goaltracker.models.ActionHistory;
+import com.toofifty.goaltracker.models.ToggleCompleteAction;
+import java.awt.event.MouseAdapter;
+import java.awt.event.MouseEvent;
+import com.toofifty.goaltracker.models.enums.Status;
+
+import java.awt.event.ComponentAdapter;
+import java.awt.event.ComponentEvent;
+import java.awt.CardLayout;
+
+import com.toofifty.goaltracker.models.task.ManualTask;
+
public class TaskItemContent extends JPanel implements Refreshable
{
private final Task task;
+ private final Goal goal;
private final TaskIconService iconService;
private final JLabel titleLabel = new JLabel();
+ private final JTextField titleEdit = new JTextField();
+ private final JPanel titleStack = new JPanel(new CardLayout());
private final JLabel iconLabel = new JLabel();
+ private JPanel iconWrapper;
+ private boolean titleEditable;
+
+ private final GoalTrackerPlugin plugin;
+ private ActionHistory actionHistory;
- TaskItemContent(GoalTrackerPlugin plugin, Task task)
+ TaskItemContent(GoalTrackerPlugin plugin, Goal goal, Task task)
{
super(new BorderLayout());
+ this.plugin = plugin;
this.task = task;
+ this.goal = goal;
iconService = plugin.getTaskIconService();
titleLabel.setPreferredSize(new Dimension(0, 24));
- add(titleLabel, BorderLayout.CENTER);
+ titleLabel.setBorder(null);
+ titleLabel.setOpaque(false);
+ titleEdit.setBorder(null);
+ titleEdit.setOpaque(false);
+ titleEdit.setDragEnabled(true);
+
+ titleStack.setOpaque(false);
+ titleStack.add(titleLabel, "label");
+ titleStack.add(titleEdit, "edit");
+ add(titleStack, BorderLayout.CENTER);
- JPanel iconWrapper = new JPanel(new BorderLayout());
+ iconWrapper = new JPanel(new BorderLayout());
iconWrapper.setBorder(new EmptyBorder(4, 0, 0, 4));
iconWrapper.add(iconLabel, BorderLayout.NORTH);
add(iconWrapper, BorderLayout.WEST);
plugin.getUiStatusManager().addRefresher(task, this::refresh);
+
+ this.addComponentListener(new ComponentAdapter() {
+ @Override public void componentResized(ComponentEvent e) { updateTitleLabel(); }
+ });
+
+ titleEditable = (task instanceof ManualTask);
+ if (titleEditable) {
+ titleLabel.addMouseListener(new MouseAdapter() {
+ @Override public void mouseClicked(MouseEvent e) { enterEdit(); }
+ });
+ titleEdit.addActionListener(e -> exitEdit(true));
+ titleEdit.addFocusListener(new java.awt.event.FocusAdapter() {
+ @Override public void focusLost(java.awt.event.FocusEvent e) { exitEdit(true); }
+ });
+ }
+
+ // Right-click to toggle completion with ActionHistory
+ MouseAdapter contextMenuListener = new MouseAdapter()
+ {
+ private void showMenuIfNeeded(MouseEvent e)
+ {
+ if (!(e.isPopupTrigger() || javax.swing.SwingUtilities.isRightMouseButton(e)))
+ {
+ return;
+ }
+ // Prefer the parent ListItemPanel context menu (move up/down/remove, etc.)
+ Component src = (Component) e.getSource();
+ JComponent listItem = (JComponent) SwingUtilities.getAncestorOfClass(ListItemPanel.class, src);
+ if (listItem != null && listItem.getComponentPopupMenu() != null)
+ {
+ Point p = SwingUtilities.convertPoint(src, e.getPoint(), listItem);
+ listItem.getComponentPopupMenu().show(listItem, p.x, p.y);
+ return;
+ }
+
+ // Fallback: show simple toggle menu if no parent popup menu is available
+ boolean currentlyComplete = task.getStatus() == Status.COMPLETED;
+ String label = currentlyComplete ? "Mark as Incomplete" : "Mark as Completed";
+
+ JPopupMenu menu = new JPopupMenu();
+ JMenuItem toggle = new JMenuItem(label);
+ toggle.addActionListener(a -> {
+ ToggleCompleteAction act = new ToggleCompleteAction(task, currentlyComplete, !currentlyComplete);
+ act.redo();
+ if (actionHistory != null)
+ {
+ actionHistory.push(act);
+ }
+ plugin.getUiStatusManager().refresh(goal);
+ });
+ menu.add(toggle);
+ Component invoker = (Component) e.getSource();
+ menu.show(invoker, e.getX(), e.getY());
+ }
+
+ @Override public void mousePressed(MouseEvent e) { showMenuIfNeeded(e); }
+ @Override public void mouseReleased(MouseEvent e) { showMenuIfNeeded(e); }
+ };
+
+ // Attach listener to multiple components to make right-click reliable across platforms
+ this.addMouseListener(contextMenuListener);
+ titleStack.addMouseListener(contextMenuListener);
+ titleLabel.addMouseListener(contextMenuListener);
+ titleEdit.addMouseListener(contextMenuListener);
+ iconLabel.addMouseListener(contextMenuListener);
+ }
+
+ public void setActionHistory(ActionHistory history)
+ {
+ this.actionHistory = history;
}
@Override
public void refresh()
{
- titleLabel.setText(task.toString());
titleLabel.setForeground(STATUS_TO_COLOR.get(task.getStatus()));
+ updateTitleLabel();
int indent = 16 * task.getIndentLevel();
iconLabel.setIcon(iconService.get(task));
@@ -55,4 +165,116 @@ public void setBackground(Color bg)
component.setBackground(bg);
}
}
+
+ private void updateTitleLabel()
+ {
+ String full = task.toString();
+ titleLabel.setToolTipText((full == null || full.isEmpty()) ? null : full);
+ if (getWidth() <= 0) { titleLabel.setText(full); return; }
+
+ int insets = 0;
+ if (getBorder() != null) {
+ Insets ins = getBorder().getBorderInsets(this);
+ insets = (ins.left + ins.right);
+ }
+ int iconW = iconWrapper != null ? iconWrapper.getPreferredSize().width : 0;
+ int gap = 8;
+ int avail = Math.max(16, getWidth() - insets - iconW - gap);
+
+ FontMetrics fm = titleLabel.getFontMetrics(titleLabel.getFont());
+ if (full == null) full = "";
+ if (fm.stringWidth(full) <= avail) { titleLabel.setText(full); return; }
+
+ String ellipsis = "…";
+ int lo = 0, hi = full.length();
+ int cut = hi;
+ while (lo <= hi) {
+ int mid = (lo + hi) >>> 1;
+ String candidate = full.substring(0, Math.max(0, mid)) + ellipsis;
+ if (fm.stringWidth(candidate) <= avail) { cut = mid; lo = mid + 1; }
+ else { hi = mid - 1; }
+ }
+ titleLabel.setText(full.substring(0, Math.max(0, cut)) + ellipsis);
+ }
+
+ private void enterEdit()
+ {
+ if (!titleEditable) return;
+ titleEdit.setText(task.toString());
+ ((CardLayout) titleStack.getLayout()).show(titleStack, "edit");
+ titleEdit.requestFocusInWindow();
+ titleEdit.selectAll();
+ }
+
+ private void exitEdit(boolean save)
+ {
+ if (!titleEditable) { ((CardLayout) titleStack.getLayout()).show(titleStack, "label"); return; }
+ if (save) {
+ String newText = titleEdit.getText();
+ if (newText != null && task instanceof ManualTask) {
+ ((ManualTask) task).setDescription(newText);
+ }
+ }
+ ((CardLayout) titleStack.getLayout()).show(titleStack, "label");
+ updateTitleLabel();
+ plugin.getUiStatusManager().refresh(goal);
+ }
+
+ public Task getTask()
+ {
+ return task;
+ }
+
+ /**
+ * Try to invoke the same context menu action as right-click -> "Add prerequisites".
+ * @return true if the action was found and invoked, false otherwise
+ */
+ public boolean addPrereqsFromContext()
+ {
+ JComponent listItem = (JComponent) SwingUtilities.getAncestorOfClass(ListItemPanel.class, this);
+ if (listItem == null || listItem.getComponentPopupMenu() == null)
+ {
+ return false;
+ }
+
+ JPopupMenu menu = listItem.getComponentPopupMenu();
+ // Accept several label variants (case-insensitive)
+ String[] targets = new String[] {
+ "add prerequisites", "add pre-reqs", "add prereqs", "prerequisites"
+ };
+
+ return clickMenuItemByLabels(menu.getSubElements(), targets);
+ }
+
+ // Recursively search menu/submenus for a matching label and click it.
+ private static boolean clickMenuItemByLabels(MenuElement[] items, String[] needles)
+ {
+ for (MenuElement me : items)
+ {
+ if (me instanceof JMenuItem)
+ {
+ JMenuItem it = (JMenuItem) me;
+ String txt = it.getText();
+ if (txt != null)
+ {
+ String lower = txt.toLowerCase(Locale.ROOT).trim();
+ for (String needle : needles)
+ {
+ if (lower.contains(needle))
+ {
+ it.doClick();
+ return true;
+ }
+ }
+ }
+ }
+ // Recurse into submenus and containers
+ MenuElement[] children = me.getSubElements();
+ if (children != null && children.length > 0 && clickMenuItemByLabels(children, needles))
+ {
+ return true;
+ }
+ }
+ return false;
+ }
}
diff --git a/src/main/java/com/toofifty/goaltracker/ui/components/ActionBar.java b/src/main/java/com/toofifty/goaltracker/ui/components/ActionBar.java
new file mode 100644
index 0000000..c919ff0
--- /dev/null
+++ b/src/main/java/com/toofifty/goaltracker/ui/components/ActionBar.java
@@ -0,0 +1,35 @@
+package com.toofifty.goaltracker.ui.components;
+
+import java.awt.FlowLayout;
+import javax.swing.JPanel;
+import javax.swing.border.EmptyBorder;
+import javax.swing.BoxLayout;
+import net.runelite.client.ui.ColorScheme;
+
+public class ActionBar extends JPanel
+{
+ private final JPanel left = new JPanel(new FlowLayout(FlowLayout.LEFT, 3, 0));
+ private final JPanel right = new JPanel(new FlowLayout(FlowLayout.RIGHT, 3, 0));
+
+ public ActionBar()
+ {
+ super();
+ setLayout(new BoxLayout(this, BoxLayout.X_AXIS));
+ setBackground(ColorScheme.DARK_GRAY_COLOR);
+ setBorder(new EmptyBorder(6, 6, 6, 6));
+
+ left.setOpaque(true);
+ left.setBackground(ColorScheme.DARK_GRAY_COLOR);
+
+ right.setOpaque(true);
+ right.setBackground(ColorScheme.DARK_GRAY_COLOR);
+
+ add(left);
+ // Removed strut to eliminate extra spacing
+ // add(javax.swing.Box.createHorizontalStrut(2));
+ add(right);
+ }
+
+ public JPanel left() { return left; }
+ public JPanel right() { return right; }
+}
\ No newline at end of file
diff --git a/src/main/java/com/toofifty/goaltracker/ui/components/ActionBarButton.java b/src/main/java/com/toofifty/goaltracker/ui/components/ActionBarButton.java
new file mode 100644
index 0000000..70b6219
--- /dev/null
+++ b/src/main/java/com/toofifty/goaltracker/ui/components/ActionBarButton.java
@@ -0,0 +1,87 @@
+package com.toofifty.goaltracker.ui.components;
+
+import java.awt.Cursor;
+import java.awt.Graphics;
+import java.awt.Insets;
+import java.awt.RenderingHints;
+import java.awt.event.MouseAdapter;
+import java.awt.event.MouseEvent;
+import javax.swing.JButton;
+import javax.swing.border.EmptyBorder;
+import net.runelite.client.ui.ColorScheme;
+import net.runelite.client.ui.FontManager;
+import java.awt.Color;
+import java.awt.Graphics2D;
+import java.awt.Dimension;
+
+public class ActionBarButton extends JButton
+{
+ private boolean hover;
+
+ public ActionBarButton(String text, Runnable onClick)
+ {
+ super(text);
+ setFont(FontManager.getRunescapeSmallFont());
+ setForeground(Color.WHITE);
+ setFocusable(false);
+ setOpaque(false);
+ setBorder(new EmptyBorder(4, 10, 4, 10));
+ setMargin(new Insets(0,0,0,0));
+ setCursor(Cursor.getPredefinedCursor(Cursor.HAND_CURSOR));
+ setContentAreaFilled(false);
+
+ addActionListener(e -> { if (onClick != null) onClick.run(); });
+
+ addMouseListener(new MouseAdapter()
+ {
+ @Override public void mouseEntered(MouseEvent e) { hover = true; repaint(); }
+ @Override public void mouseExited(MouseEvent e) { hover = false; repaint(); }
+ });
+ }
+
+ @Override
+ protected void paintComponent(Graphics g)
+ {
+ Graphics2D g2 = (Graphics2D) g.create();
+ g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
+
+ Color bg = hover ? ColorScheme.DARKER_GRAY_HOVER_COLOR : ColorScheme.DARK_GRAY_COLOR.brighter();
+ Color outline = ColorScheme.DARKER_GRAY_HOVER_COLOR.darker();
+
+ int arc = 8;
+ int w = getWidth();
+ int h = getHeight();
+
+ int x = 0;
+ int y = 0;
+ int drawW = w;
+ int drawH = h;
+
+ g2.setColor(bg);
+ g2.fillRoundRect(x, y, drawW, drawH, arc, arc);
+
+ g2.setColor(outline);
+ g2.drawRoundRect(x, y, drawW - 1, drawH - 1, arc, arc);
+
+ g2.dispose();
+ super.paintComponent(g);
+ }
+
+ @Override
+ public boolean isContentAreaFilled()
+ {
+ return false;
+ }
+
+ @Override
+ public boolean isOpaque()
+ {
+ return false;
+ }
+
+ @Override
+ public Dimension getMinimumSize()
+ {
+ return getPreferredSize();
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/com/toofifty/goaltracker/ui/components/ComboBox.java b/src/main/java/com/toofifty/goaltracker/ui/components/ComboBox.java
index 70fd11d..3591d57 100644
--- a/src/main/java/com/toofifty/goaltracker/ui/components/ComboBox.java
+++ b/src/main/java/com/toofifty/goaltracker/ui/components/ComboBox.java
@@ -1,12 +1,12 @@
package com.toofifty.goaltracker.ui.components;
-import com.toofifty.goaltracker.GoalTrackerPlugin;
import java.awt.BorderLayout;
import java.awt.Color;
import java.awt.Component;
-import java.awt.image.BufferedImage;
+import java.awt.Font;
import java.util.List;
import java.util.function.Function;
+import javax.swing.DefaultComboBoxModel;
import javax.swing.ImageIcon;
import javax.swing.JButton;
import javax.swing.JComboBox;
@@ -14,100 +14,225 @@
import javax.swing.JList;
import javax.swing.JPanel;
import javax.swing.ListCellRenderer;
+import javax.swing.SwingUtilities;
import javax.swing.border.EmptyBorder;
+import javax.swing.event.PopupMenuEvent;
+import javax.swing.event.PopupMenuListener;
import javax.swing.plaf.basic.BasicComboBoxUI;
+
import net.runelite.client.ui.ColorScheme;
-import net.runelite.client.util.ImageUtil;
import net.runelite.client.util.Text;
+/**
+ * Simple generic ComboBox with optional formatter and a renderer
+ * that matches RuneLite dark styling. Includes a helper to keep the
+ * popup open after a selection (for rapid multi-add flows).
+ *
+ * This version also supports:
+ * - Custom up/down arrow icons loaded from resources (combo_arrow_down.png / combo_arrow_up.png)
+ * - Compact mode (~10% smaller font), or arbitrary font scaling via setFontScale
+ */
public class ComboBox extends JComboBox
{
private Function formatter = null;
- @SuppressWarnings("unchecked")
- public ComboBox(List items)
+ private double fontScale = 1.0; // 1.0 = default; 0.9 = compact
+ private ImageIcon arrowDownIcon;
+ private ImageIcon arrowUpIcon;
+ private JButton arrowButtonRef; // created by UI#createArrowButton
+
+ public ComboBox()
{
- this((T[]) items.toArray());
+ super();
+ setRenderer(new ComboBoxListRenderer());
+ // Keep consistent look with RuneLite
+ setBackground(ColorScheme.DARKER_GRAY_COLOR);
+ setForeground(Color.WHITE);
+
+ // Load arrow icons from resources if available
+ // (If null, we just fall back to no icon customization)
+ arrowDownIcon = loadIcon("/combo_arrow_down.png");
+ arrowUpIcon = loadIcon("/combo_arrow_up.png");
+
+ // Install a BasicComboBoxUI that uses our custom arrow button (if icons are available)
+ setUI(new BasicComboBoxUI()
+ {
+ @Override
+ protected JButton createArrowButton()
+ {
+ JButton b = (arrowDownIcon != null) ? new JButton(arrowDownIcon) : new JButton();
+ b.setBorder(new EmptyBorder(0, 6, 0, 6));
+ b.setContentAreaFilled(false);
+ b.setFocusPainted(false);
+ b.setOpaque(false);
+ arrowButtonRef = b;
+ return b;
+ }
+ });
+
+ // Swap arrow icon on popup visibility changes (if we have icons)
+ addPopupMenuListener(new PopupMenuListener()
+ {
+ @Override public void popupMenuWillBecomeVisible(PopupMenuEvent e)
+ {
+ if (arrowButtonRef != null && arrowUpIcon != null)
+ {
+ arrowButtonRef.setIcon(arrowUpIcon);
+ }
+ }
+ @Override public void popupMenuWillBecomeInvisible(PopupMenuEvent e)
+ {
+ if (arrowButtonRef != null && arrowDownIcon != null)
+ {
+ arrowButtonRef.setIcon(arrowDownIcon);
+ }
+ }
+ @Override public void popupMenuCanceled(PopupMenuEvent e)
+ {
+ if (arrowButtonRef != null && arrowDownIcon != null)
+ {
+ arrowButtonRef.setIcon(arrowDownIcon);
+ }
+ }
+ });
+
+ // Apply initial font scaling
+ applyFontScale();
}
public ComboBox(T[] items)
{
- super(items);
-
- setForeground(Color.WHITE);
- setBackground(ColorScheme.DARKER_GRAY_COLOR);
- setFocusable(false);
- setRenderer(new ComboBoxListRenderer<>(formatter));
- setUI(new ComboBoxUI());
- setBorder(new EmptyBorder(0, 0, 0, 0));
+ this();
+ setItems(java.util.Arrays.asList(items));
}
- public void setFormatter(Function formatter)
+ public ComboBox(List items)
{
- this.formatter = formatter;
- setRenderer(new ComboBoxListRenderer<>(formatter));
+ this();
+ setItems(items);
}
-}
-class ComboBoxUI extends BasicComboBoxUI
-{
- private static final ImageIcon ARROW_UP;
- private static final ImageIcon ARROW_DOWN;
+ private ImageIcon loadIcon(String path)
+ {
+ java.net.URL url = getClass().getResource(path);
+ return (url != null) ? new ImageIcon(url) : null;
+ }
- static {
- final BufferedImage arrowUp = ImageUtil.loadImageResource(
- GoalTrackerPlugin.class, "/combo_arrow_up.png");
- ARROW_UP = new ImageIcon(arrowUp);
+ /**
+ * Compact preset (~10% smaller font).
+ */
+ public void setCompact(boolean compact)
+ {
+ setFontScale(compact ? 0.9 : 1.0);
+ }
- final BufferedImage arrowDown = ImageUtil.loadImageResource(
- GoalTrackerPlugin.class, "/combo_arrow_down.png");
- ARROW_DOWN = new ImageIcon(arrowDown);
+ /**
+ * Arbitrary font scaling (1.0 = normal).
+ */
+ public void setFontScale(double scale)
+ {
+ if (scale <= 0) scale = 1.0;
+ this.fontScale = scale;
+ applyFontScale();
+ repaint();
}
- @Override
- protected JButton createArrowButton()
+ private void applyFontScale()
{
- JButton button = new JButton();
- button.setBackground(ColorScheme.DARKER_GRAY_COLOR);
- button.setBorder(new EmptyBorder(4, 4, 4, 4));
- button.setBorderPainted(false);
- button.add(new JLabel(ARROW_DOWN));
- return button;
+ Font f = getFont();
+ if (f != null)
+ {
+ int newSize = Math.max(10, Math.round(f.getSize() * (float) fontScale));
+ setFont(new Font(f.getName(), f.getStyle(), newSize));
+ }
}
-}
-class ComboBoxListRenderer implements ListCellRenderer
-{
- private Function formatter;
+ public void setItems(List items)
+ {
+ DefaultComboBoxModel model = new DefaultComboBoxModel<>();
+ if (items != null)
+ {
+ for (T it : items)
+ {
+ model.addElement(it);
+ }
+ }
+ setModel(model);
+ }
- ComboBoxListRenderer(Function formatter)
+ public void setFormatter(Function formatter)
{
this.formatter = formatter;
+ repaint();
}
- @Override
- public Component getListCellRendererComponent(
- JList extends T> list, T o, int index, boolean isSelected,
- boolean cellHasFocus)
+ /**
+ * Re-opens the popup after a selection is made, enabling fast repeated adds.
+ * Call this once after constructing the combo if you want the behavior.
+ */
+ public void setStayOpenOnSelection(boolean stayOpen)
{
- JPanel container = new JPanel(new BorderLayout());
-
- container.setBorder(new EmptyBorder(0, 4, 0, 4));
-
- JLabel label = new JLabel();
- if (formatter != null) {
- label.setText(formatter.apply(o));
- } else {
- label.setText(
- o instanceof Enum ? Text.titleCase((Enum>) o) : o.toString());
+ if (stayOpen)
+ {
+ this.addActionListener(e ->
+ SwingUtilities.invokeLater(() -> this.setPopupVisible(true))
+ );
}
- container.add(label, BorderLayout.WEST);
+ }
- if (isSelected) {
- container.setBackground(ColorScheme.DARK_GRAY_COLOR);
- label.setForeground(Color.WHITE);
+ private class ComboBoxListRenderer implements ListCellRenderer
+ {
+ @Override
+ public Component getListCellRendererComponent(
+ JList extends T> list,
+ T value,
+ int index,
+ boolean isSelected,
+ boolean cellHasFocus)
+ {
+ JPanel container = new JPanel(new BorderLayout());
+ container.setBorder(new EmptyBorder(2, 6, 2, 6));
+
+ JLabel label = new JLabel();
+ label.setOpaque(false);
+
+ if (value != null)
+ {
+ if (formatter != null)
+ {
+ label.setText(formatter.apply(value));
+ }
+ else if (value instanceof Enum)
+ {
+ label.setText(Text.titleCase((Enum>) value));
+ }
+ else
+ {
+ label.setText(value.toString());
+ }
+ }
+ else
+ {
+ label.setText("");
+ }
+
+ // Use the exact same font as the combo for crisp text
+ label.setFont(ComboBox.this.getFont());
+
+ container.add(label, BorderLayout.WEST);
+
+ if (isSelected)
+ {
+ container.setBackground(ColorScheme.DARK_GRAY_COLOR);
+ label.setForeground(Color.WHITE);
+ }
+ else
+ {
+ container.setBackground(ColorScheme.DARKER_GRAY_COLOR);
+ label.setForeground(Color.WHITE);
+ }
+
+ return container;
}
-
- return container;
}
}
diff --git a/src/main/java/com/toofifty/goaltracker/ui/components/EditableInput.java b/src/main/java/com/toofifty/goaltracker/ui/components/EditableInput.java
index c86da01..771fc63 100644
--- a/src/main/java/com/toofifty/goaltracker/ui/components/EditableInput.java
+++ b/src/main/java/com/toofifty/goaltracker/ui/components/EditableInput.java
@@ -12,6 +12,13 @@
import javax.swing.border.Border;
import javax.swing.border.CompoundBorder;
import javax.swing.border.EmptyBorder;
+import javax.swing.JPopupMenu;
+import javax.swing.JMenuItem;
+import javax.swing.KeyStroke;
+import javax.swing.ActionMap;
+import javax.swing.InputMap;
+import javax.swing.text.DefaultEditorKit;
+import java.awt.Toolkit;
import lombok.Setter;
import net.runelite.client.ui.ColorScheme;
import net.runelite.client.ui.components.FlatTextField;
@@ -54,7 +61,7 @@ public EditableInput(Consumer saveAction)
cancel.onClick(e -> this.cancel());
edit.onClick(e -> {
- inputField.setEditable(true);
+ inputField.getTextField().setEditable(true);
updateActions(true);
});
@@ -64,12 +71,43 @@ public EditableInput(Consumer saveAction)
inputField.setText(localValue);
inputField.setBorder(null);
- inputField.setEditable(false);
+ inputField.getTextField().setEditable(false);
+ inputField.getTextField().setFocusable(true);
inputField.setBackground(ColorScheme.DARKER_GRAY_COLOR);
inputField.setPreferredSize(new Dimension(50, 24));
inputField.getTextField().setForeground(Color.WHITE);
inputField.getTextField().setBorder(new EmptyBorder(0, 8, 0, 0));
- inputField.addKeyListener(new KeyAdapter()
+ // Ensure standard copy/paste/cut/select-all shortcuts work on all platforms
+ final var tf = inputField.getTextField();
+ InputMap im = tf.getInputMap();
+ ActionMap am = tf.getActionMap();
+ int menuMask = Toolkit.getDefaultToolkit().getMenuShortcutKeyMaskEx();
+ im.put(KeyStroke.getKeyStroke(KeyEvent.VK_C, menuMask), DefaultEditorKit.copyAction);
+ im.put(KeyStroke.getKeyStroke(KeyEvent.VK_V, menuMask), DefaultEditorKit.pasteAction);
+ im.put(KeyStroke.getKeyStroke(KeyEvent.VK_X, menuMask), DefaultEditorKit.cutAction);
+ im.put(KeyStroke.getKeyStroke(KeyEvent.VK_A, menuMask), DefaultEditorKit.selectAllAction);
+ // Also support Insert/Delete variants
+ im.put(KeyStroke.getKeyStroke(KeyEvent.VK_INSERT, KeyEvent.SHIFT_DOWN_MASK), DefaultEditorKit.pasteAction);
+ im.put(KeyStroke.getKeyStroke(KeyEvent.VK_INSERT, KeyEvent.CTRL_DOWN_MASK), DefaultEditorKit.copyAction);
+ im.put(KeyStroke.getKeyStroke(KeyEvent.VK_DELETE, KeyEvent.SHIFT_DOWN_MASK), DefaultEditorKit.cutAction);
+
+ // Context menu for mouse-based copy/paste
+ JPopupMenu popup = new JPopupMenu();
+ JMenuItem cut = new JMenuItem("Cut");
+ cut.addActionListener(e -> tf.cut());
+ popup.add(cut);
+ JMenuItem copy = new JMenuItem("Copy");
+ copy.addActionListener(e -> tf.copy());
+ popup.add(copy);
+ JMenuItem paste = new JMenuItem("Paste");
+ paste.addActionListener(e -> tf.paste());
+ popup.add(paste);
+ JMenuItem selectAll = new JMenuItem("Select All");
+ selectAll.addActionListener(e -> tf.selectAll());
+ popup.add(selectAll);
+ tf.setComponentPopupMenu(popup);
+ tf.setDragEnabled(true);
+ inputField.getTextField().addKeyListener(new KeyAdapter()
{
@Override
public void keyPressed(KeyEvent e)
@@ -91,14 +129,14 @@ private void save()
localValue = inputField.getText();
saveAction.accept(localValue);
- inputField.setEditable(false);
+ inputField.getTextField().setEditable(false);
updateActions(false);
requestFocusInWindow();
}
private void cancel()
{
- inputField.setEditable(false);
+ inputField.getTextField().setEditable(false);
inputField.setText(localValue);
updateActions(false);
diff --git a/src/main/java/com/toofifty/goaltracker/ui/components/ListItemPanel.java b/src/main/java/com/toofifty/goaltracker/ui/components/ListItemPanel.java
index fcc9882..e2bdc16 100644
--- a/src/main/java/com/toofifty/goaltracker/ui/components/ListItemPanel.java
+++ b/src/main/java/com/toofifty/goaltracker/ui/components/ListItemPanel.java
@@ -2,16 +2,20 @@
import com.toofifty.goaltracker.utils.ReorderableList;
import com.toofifty.goaltracker.ui.Refreshable;
+import com.toofifty.goaltracker.models.Goal;
import java.awt.BorderLayout;
import java.awt.Color;
import java.awt.Component;
import java.awt.event.MouseAdapter;
import java.awt.event.MouseEvent;
import java.util.function.Consumer;
+import java.util.function.BiConsumer;
import javax.swing.JMenuItem;
import javax.swing.JPanel;
import javax.swing.JPopupMenu;
+import javax.swing.JComponent;
import javax.swing.border.EmptyBorder;
+import javax.swing.border.MatteBorder;
import net.runelite.client.ui.ColorScheme;
public class ListItemPanel extends JPanel implements Refreshable
@@ -28,6 +32,52 @@ public class ListItemPanel extends JPanel implements Refreshable
protected Consumer reorderedListener;
protected Consumer removedListener;
+ protected BiConsumer removedWithIndexListener;
+
+ private MouseAdapter clickListenerAdapter;
+
+ // Inner face of the goal card; only this area changes color on hover/press
+ private JPanel cardBody;
+
+ private void addClickListenerRecursive(Component c)
+ {
+ if (clickListenerAdapter == null) return;
+ c.addMouseListener(clickListenerAdapter);
+ if (c instanceof java.awt.Container)
+ {
+ for (Component child : ((java.awt.Container) c).getComponents())
+ {
+ addClickListenerRecursive(child);
+ }
+ }
+ }
+
+ private void addContextMenuListenerRecursive(Component c)
+ {
+ c.addMouseListener(contextMenuListener);
+ if (c instanceof java.awt.Container)
+ {
+ for (Component child : ((java.awt.Container) c).getComponents())
+ {
+ addContextMenuListenerRecursive(child);
+ }
+ }
+ }
+
+ private final MouseAdapter contextMenuListener = new MouseAdapter()
+ {
+ private void maybeShow(MouseEvent e)
+ {
+ if (!(e.isPopupTrigger() || javax.swing.SwingUtilities.isRightMouseButton(e)))
+ {
+ return;
+ }
+ popupMenu.show((Component) e.getSource(), e.getX(), e.getY());
+ }
+
+ @Override public void mousePressed(MouseEvent e) { maybeShow(e); }
+ @Override public void mouseReleased(MouseEvent e) { maybeShow(e); }
+ };
public ListItemPanel(ReorderableList list, T item)
{
@@ -35,92 +85,116 @@ public ListItemPanel(ReorderableList list, T item)
this.list = list;
this.item = item;
- setBorder(new EmptyBorder(8, 8, 8, 8));
- setBackground(ColorScheme.DARK_GRAY_COLOR);
+ // Create inner card surface to isolate hover/press background changes
+ cardBody = new JPanel(new BorderLayout());
+ cardBody.setOpaque(true);
+ // Add the cardBody as the main content area
+ super.add(cardBody, BorderLayout.CENTER);
+
+ if (item instanceof Goal) {
+ applyGoalCardDefaultStyle();
+ } else {
+ setBorder(new EmptyBorder(2, 4, 2, 4)); // add horizontal and vertical spacing for tasks
+ setBackground(ColorScheme.DARK_GRAY_COLOR);
+ }
moveUp.addActionListener(e -> {
list.moveUp(item);
- this.reorderedListener.accept(item);
+ if (this.reorderedListener != null) this.reorderedListener.accept(item);
});
moveDown.addActionListener(e -> {
list.moveDown(item);
- this.reorderedListener.accept(item);
+ if (this.reorderedListener != null) this.reorderedListener.accept(item);
});
moveToTop.addActionListener(e -> {
list.moveToTop(item);
- this.reorderedListener.accept(item);
+ if (this.reorderedListener != null) this.reorderedListener.accept(item);
});
moveToBottom.addActionListener(e -> {
list.moveToBottom(item);
- this.reorderedListener.accept(item);
+ if (this.reorderedListener != null) this.reorderedListener.accept(item);
});
removeItem.addActionListener(e -> {
+ int index = list.indexOf(item);
list.remove(item);
- this.removedListener.accept(item);
+ if (this.removedWithIndexListener != null) this.removedWithIndexListener.accept(item, index);
+ if (this.removedListener != null) this.removedListener.accept(item);
});
popupMenu.setBorder(new EmptyBorder(5, 5, 5, 5));
setComponentPopupMenu(popupMenu);
+ // Also show context menu on press/release to handle platform differences
+ this.addMouseListener(contextMenuListener);
+ // Ensure popup and click listeners cover all descendants initially
+ addContextMenuListenerRecursive(this);
+ setOpaque(true);
}
@Override
- public void setBackground(Color bg)
+ public void add(Component comp, Object constraints)
{
- super.setBackground(bg);
- for (Component component : getComponents()) {
- component.setBackground(bg);
+ if (item instanceof Goal)
+ {
+ cardBody.add(comp, BorderLayout.CENTER);
+ // For goal cards, strip inner borders from the content to avoid double outlines
+ if (comp instanceof JComponent)
+ {
+ ((JComponent) comp).setBorder(new EmptyBorder(0, 0, 0, 0));
+ }
+ addContextMenuListenerRecursive(comp);
+ if (clickListenerAdapter != null) addClickListenerRecursive(comp);
+ return;
}
+ super.add(comp, constraints);
}
@Override
- public void refresh()
+ public Component add(String name, Component comp)
{
- refreshMenu();
-
- for (Component component : getComponents()) {
- if (component instanceof Refreshable) {
- ((Refreshable) component).refresh();
+ if (item instanceof Goal)
+ {
+ cardBody.add(comp, BorderLayout.CENTER);
+ if (comp instanceof JComponent)
+ {
+ ((JComponent) comp).setBorder(new EmptyBorder(0, 0, 0, 0));
}
+ addContextMenuListenerRecursive(comp);
+ if (clickListenerAdapter != null) addClickListenerRecursive(comp);
+ return comp;
}
- }
-
- public void refreshMenu()
- {
- popupMenu.removeAll();
- if (!list.isFirst(item)) {
- popupMenu.add(moveUp);
- }
- if (!list.isLast(item)) {
- popupMenu.add(moveDown);
- }
- if (!list.isFirst(item)) {
- popupMenu.add(moveToTop);
- }
- if (!list.isLast(item)) {
- popupMenu.add(moveToBottom);
- }
- popupMenu.add(removeItem);
+ return super.add(name, comp);
}
public ListItemPanel add(Component comp)
{
- super.add(comp, BorderLayout.CENTER);
+ cardBody.add(comp, BorderLayout.CENTER);
+ // For goal cards, strip inner borders from the content to avoid double outlines
+ if (item instanceof Goal && comp instanceof JComponent)
+ {
+ ((JComponent) comp).setBorder(new EmptyBorder(0, 0, 0, 0));
+ }
+ addContextMenuListenerRecursive(comp);
+ if (clickListenerAdapter != null) addClickListenerRecursive(comp);
return this;
}
public void onClick(Consumer clickListener)
{
- addMouseListener(new MouseAdapter()
+ clickListenerAdapter = new MouseAdapter()
{
@Override
public void mousePressed(MouseEvent e)
{
- if (e.getButton() == MouseEvent.BUTTON1) {
+ if (e.getButton() == MouseEvent.BUTTON1)
+ {
+ if (item instanceof Goal) {
+ applyGoalCardPressedStyle();
+ }
clickListener.accept(e);
}
}
@@ -128,15 +202,38 @@ public void mousePressed(MouseEvent e)
@Override
public void mouseEntered(MouseEvent e)
{
- setBackground(ColorScheme.DARK_GRAY_HOVER_COLOR);
+ if (item instanceof Goal) {
+ applyGoalCardHoverStyle();
+ }
}
@Override
public void mouseExited(MouseEvent e)
{
- setBackground(ColorScheme.DARK_GRAY_COLOR);
+ if (item instanceof Goal) {
+ applyGoalCardDefaultStyle();
+ }
}
- });
+
+ @Override
+ public void mouseReleased(MouseEvent e)
+ {
+ if (item instanceof Goal) {
+ if (contains(e.getPoint())) {
+ applyGoalCardHoverStyle();
+ } else {
+ applyGoalCardDefaultStyle();
+ }
+ }
+ }
+ };
+
+ // Attach to this panel and all current children
+ addMouseListener(clickListenerAdapter);
+ addClickListenerRecursive(this);
+
+ // Optional: use a hand cursor to indicate clickability
+ setCursor(new java.awt.Cursor(java.awt.Cursor.HAND_CURSOR));
}
public void onRemoved(Consumer removeListener) {
@@ -146,4 +243,115 @@ public void onRemoved(Consumer removeListener) {
public void onReordered(Consumer reorderListener) {
this.reorderedListener = reorderListener;
}
+
+ public void onRemovedWithIndex(BiConsumer removeListener) {
+ this.removedWithIndexListener = removeListener;
+ }
+
+ private void applyGoalCardDefaultStyle()
+ {
+ // Outer spacing + shadow on the container panel
+ setBorder(javax.swing.BorderFactory.createCompoundBorder(
+ new EmptyBorder(8, 6, 8, 6),
+ new MatteBorder(2, 2, 4, 4, new Color(0, 0, 0, 60)) // shadow on all sides
+ ));
+ setBackground(ColorScheme.DARK_GRAY_COLOR); // keep outer area stable
+
+ // Inner card face: rounded outline + inner padding
+ if (cardBody != null)
+ {
+ cardBody.setBorder(javax.swing.BorderFactory.createCompoundBorder(
+ javax.swing.BorderFactory.createLineBorder(ColorScheme.DARK_GRAY_COLOR, 1, true),
+ new EmptyBorder(6, 8, 6, 8)
+ ));
+ cardBody.setBackground(ColorScheme.DARK_GRAY_COLOR);
+ }
+ }
+
+ private void applyGoalCardHoverStyle()
+ {
+ if (cardBody != null)
+ {
+ cardBody.setBackground(ColorScheme.DARK_GRAY_HOVER_COLOR);
+ }
+ }
+
+ private void applyGoalCardPressedStyle()
+ {
+ if (cardBody != null)
+ {
+ cardBody.setBackground(ColorScheme.DARK_GRAY_COLOR);
+ }
+ }
+
+ @Override
+ public void refresh()
+ {
+ // Refresh the context menu
+ popupMenu.removeAll();
+ if (!list.isFirst(item)) {
+ popupMenu.add(moveUp);
+ }
+ if (!list.isLast(item)) {
+ popupMenu.add(moveDown);
+ }
+ if (!list.isFirst(item)) {
+ popupMenu.add(moveToTop);
+ }
+ if (!list.isLast(item)) {
+ popupMenu.add(moveToBottom);
+ }
+ popupMenu.add(removeItem);
+
+ buildAdditionalMenu();
+
+ // Refresh all descendants that implement Refreshable
+ for (Component component : getComponents()) {
+ refreshDescendants(component);
+ }
+ revalidate();
+ repaint();
+ }
+
+ /**
+ * Hook for subclasses to append extra context‑menu items.
+ * Base implementation adds Goal‑specific actions only.
+ */
+ protected void buildAdditionalMenu()
+ {
+ if (item instanceof Goal)
+ {
+ JMenuItem markAllComplete = new JMenuItem("Mark all as completed");
+ JMenuItem markAllIncomplete = new JMenuItem("Mark all as incomplete");
+
+ markAllComplete.addActionListener(e -> {
+ Goal goal = (Goal) item;
+ goal.setAllTasksCompleted(true);
+ refresh();
+ });
+
+ markAllIncomplete.addActionListener(e -> {
+ Goal goal = (Goal) item;
+ goal.setAllTasksCompleted(false);
+ refresh();
+ });
+
+ popupMenu.addSeparator();
+ popupMenu.add(markAllComplete);
+ popupMenu.add(markAllIncomplete);
+ }
+ // Non‑Goal rows (e.g., Tasks) should add their own items in subclasses like ListTaskPanel
+ }
+
+ private void refreshDescendants(Component c)
+ {
+ if (c instanceof Refreshable) {
+ ((Refreshable) c).refresh();
+ }
+ if (c instanceof java.awt.Container) {
+ for (Component child : ((java.awt.Container) c).getComponents()) {
+ refreshDescendants(child);
+ }
+ }
+ }
}
diff --git a/src/main/java/com/toofifty/goaltracker/ui/components/ListPanel.java b/src/main/java/com/toofifty/goaltracker/ui/components/ListPanel.java
index d514ffc..8fecf26 100644
--- a/src/main/java/com/toofifty/goaltracker/ui/components/ListPanel.java
+++ b/src/main/java/com/toofifty/goaltracker/ui/components/ListPanel.java
@@ -78,7 +78,10 @@ private List> buildItemPanels()
@Override
public void refresh()
{
- // refresh all children
+ // Rebuild the list first in case the underlying data changed
+ tryBuildList();
+
+ // Then refresh all children
for (Component component : listPanel.getComponents()) {
if (component instanceof Refreshable) {
((Refreshable) component).refresh();
@@ -96,14 +99,12 @@ private ListItemPanel buildItemPanel(T item)
itemPanel.onReordered((updatedItem) -> {
tryBuildList();
-
- this.updatedListener.accept(updatedItem);
+ if (this.updatedListener != null) this.updatedListener.accept(updatedItem);
});
itemPanel.onRemoved((updatedItem) -> {
tryBuildList();
-
- this.updatedListener.accept(updatedItem);
+ if (this.updatedListener != null) this.updatedListener.accept(updatedItem);
});
itemPanelMap.put(item, itemPanel);
@@ -131,7 +132,7 @@ private void refreshChildMenus()
{
for (Component component : listPanel.getComponents()) {
if (component instanceof ListItemPanel) {
- ((ListItemPanel>) component).refreshMenu();
+ ((Refreshable) component).refresh();
}
}
}
diff --git a/src/main/java/com/toofifty/goaltracker/ui/components/ListTaskPanel.java b/src/main/java/com/toofifty/goaltracker/ui/components/ListTaskPanel.java
index 46d4e81..ac397f3 100644
--- a/src/main/java/com/toofifty/goaltracker/ui/components/ListTaskPanel.java
+++ b/src/main/java/com/toofifty/goaltracker/ui/components/ListTaskPanel.java
@@ -1,76 +1,224 @@
package com.toofifty.goaltracker.ui.components;
+import com.toofifty.goaltracker.ui.TaskItemContent;
+import com.toofifty.goaltracker.utils.QuestRequirements;
+
import com.toofifty.goaltracker.utils.ReorderableList;
import com.toofifty.goaltracker.models.task.Task;
+import com.toofifty.goaltracker.models.enums.Status;
import java.util.function.Consumer;
import javax.swing.JMenuItem;
+import java.awt.event.MouseAdapter;
+import java.awt.event.MouseEvent;
+import java.awt.Component;
+import javax.swing.SwingUtilities;
+import java.awt.Container;
+import com.toofifty.goaltracker.models.ActionHistory;
+import com.toofifty.goaltracker.models.ReorderTaskAction;
public class ListTaskPanel extends ListItemPanel
{
+ private TaskItemContent taskContent;
+
private final JMenuItem indentItem = new JMenuItem("Indent");
private final JMenuItem unindentItem = new JMenuItem("Unindent");
private Consumer indentedListener;
private Consumer unindentedListener;
+ private ActionHistory history;
+
+ private final MouseAdapter shiftClickRemoveListener = new MouseAdapter() {
+ @Override
+ public void mouseClicked(MouseEvent e) {
+ if (e.isShiftDown() && SwingUtilities.isLeftMouseButton(e)) {
+ removeItem.doClick();
+ }
+ }
+ };
+
+ private void addShiftRemoveListenerRecursive(Component c)
+ {
+ c.addMouseListener(shiftClickRemoveListener);
+ if (c instanceof Container)
+ {
+ for (Component child : ((Container) c).getComponents())
+ {
+ addShiftRemoveListenerRecursive(child);
+ }
+ }
+ }
+
public ListTaskPanel(ReorderableList list, Task item)
{
super(list, item);
indentItem.addActionListener(e -> {
- // Indent all of the items children
- var index = list.indexOf(item);
+ int index = list.indexOf(item);
+ int baseIndent = item.getIndentLevel();
+
+ // Collect affected tasks (item + descendants until sibling/parent)
+ java.util.List affected = new java.util.ArrayList<>();
+ java.util.List oldIndents = new java.util.ArrayList<>();
+
+ affected.add(item);
+ oldIndents.add(baseIndent);
for (int i = index + 1; i < list.size(); i++) {
var child = list.get(i);
+ if (child.getIndentLevel() <= baseIndent) break;
+ affected.add(child);
+ oldIndents.add(child.getIndentLevel());
+ }
- System.out.println(String.format("%s >= %s", item.getIndentLevel(), child.getIndentLevel()));
- // If a child is less indented then this item assume its a parent node and break
- if (item.getIndentLevel() >= child.getIndentLevel()) break;
+ ActionHistory.Action act = new ActionHistory.Action() {
+ @Override public void undo() {
+ for (int i = 0; i < affected.size(); i++) {
+ affected.get(i).setIndentLevel(oldIndents.get(i));
+ }
+ }
+ @Override public void redo() {
+ for (int i = 0; i < affected.size(); i++) {
+ affected.get(i).setIndentLevel(oldIndents.get(i) + 1);
+ }
+ }
+ };
- child.indent();
+ if (history != null) {
+ act.redo();
+ history.push(act);
+ } else {
+ // Fallback: apply directly
+ for (int i = 0; i < affected.size(); i++) {
+ affected.get(i).setIndentLevel(oldIndents.get(i) + 1);
+ }
}
- item.indent();
- this.indentedListener.accept(item);
+ if (this.indentedListener != null) this.indentedListener.accept(item);
+ refreshParentList();
});
unindentItem.addActionListener(e -> {
- // Unindent all of the items children
- var index = list.indexOf(item);
+ int index = list.indexOf(item);
+ int baseIndent = item.getIndentLevel();
+
+ // Collect affected tasks (item + descendants until sibling/parent)
+ java.util.List affected = new java.util.ArrayList<>();
+ java.util.List oldIndents = new java.util.ArrayList<>();
+
+ affected.add(item);
+ oldIndents.add(baseIndent);
for (int i = index + 1; i < list.size(); i++) {
var child = list.get(i);
+ if (child.getIndentLevel() <= baseIndent) break;
+ affected.add(child);
+ oldIndents.add(child.getIndentLevel());
+ }
- System.out.println(String.format("%s >= %s", item.getIndentLevel(), child.getIndentLevel()));
- // If a child is less indented then this item assume its a parent node and break
- if (item.getIndentLevel() >= child.getIndentLevel()) break;
+ ActionHistory.Action act = new ActionHistory.Action() {
+ @Override public void undo() {
+ for (int i = 0; i < affected.size(); i++) {
+ affected.get(i).setIndentLevel(oldIndents.get(i));
+ }
+ }
+ @Override public void redo() {
+ for (int i = 0; i < affected.size(); i++) {
+ affected.get(i).setIndentLevel(Math.max(0, oldIndents.get(i) - 1));
+ }
+ }
+ };
- child.unindent();;
+ if (history != null) {
+ act.redo();
+ history.push(act);
+ } else {
+ for (int i = 0; i < affected.size(); i++) {
+ affected.get(i).setIndentLevel(Math.max(0, oldIndents.get(i) - 1));
+ }
}
- item.unindent();
- this.unindentedListener.accept(item);
+ if (this.unindentedListener != null) this.unindentedListener.accept(item);
+ refreshParentList();
});
+ // Allow shift-click to remove this item and all its indented children
+ addMouseListener(new MouseAdapter() {
+ @Override
+ public void mouseClicked(MouseEvent e) {
+ if (e.isShiftDown() && e.getButton() == MouseEvent.BUTTON1) {
+ // Delegate to the Remove action (which cascades children and notifies listeners)
+ removeItem.doClick();
+ }
+ }
+ });
+ // Apply the same shift-click removal listener to all nested components
+ addShiftRemoveListenerRecursive(this);
}
- @Override
public void refreshMenu()
{
+ super.refresh();
popupMenu.removeAll();
+ javax.swing.JMenu moveMenu = new javax.swing.JMenu("Move");
+ boolean hasMove = false;
if (!list.isFirst(item)) {
- popupMenu.add(moveUp);
+ for (var l : moveUp.getActionListeners()) moveUp.removeActionListener(l);
+ moveUp.addActionListener(e -> {
+ int oldIndex = list.indexOf(item);
+ int newIndex = Math.max(0, oldIndex - 1);
+ list.moveUp(item);
+ if (history != null) {
+ history.push(new ReorderTaskAction(list, item, oldIndex, newIndex));
+ }
+ refreshParentList();
+ });
+ moveMenu.add(moveUp);
+ hasMove = true;
}
if (!list.isLast(item)) {
- popupMenu.add(moveDown);
+ for (var l : moveDown.getActionListeners()) moveDown.removeActionListener(l);
+ moveDown.addActionListener(e -> {
+ int oldIndex = list.indexOf(item);
+ int newIndex = Math.min(list.size() - 1, oldIndex + 1);
+ list.moveDown(item);
+ if (history != null) {
+ history.push(new ReorderTaskAction(list, item, oldIndex, newIndex));
+ }
+ refreshParentList();
+ });
+ moveMenu.add(moveDown);
+ hasMove = true;
}
if (!list.isFirst(item)) {
- popupMenu.add(moveToTop);
+ for (var l : moveToTop.getActionListeners()) moveToTop.removeActionListener(l);
+ moveToTop.addActionListener(e -> {
+ int oldIndex = list.indexOf(item);
+ list.moveToTop(item);
+ if (history != null) {
+ history.push(new ReorderTaskAction(list, item, oldIndex, 0));
+ }
+ refreshParentList();
+ });
+ moveMenu.add(moveToTop);
+ hasMove = true;
}
if (!list.isLast(item)) {
- popupMenu.add(moveToBottom);
+ for (var l : moveToBottom.getActionListeners()) moveToBottom.removeActionListener(l);
+ moveToBottom.addActionListener(e -> {
+ int oldIndex = list.indexOf(item);
+ list.moveToBottom(item);
+ if (history != null) {
+ history.push(new ReorderTaskAction(list, item, oldIndex, list.size() - 1));
+ }
+ refreshParentList();
+ });
+ moveMenu.add(moveToBottom);
+ hasMove = true;
+ }
+ if (hasMove) {
+ popupMenu.add(moveMenu);
}
var previousItem = list.getPreviousItem(item);
-
+
if (item.isNotFullyIndented() && previousItem != null && previousItem.getIndentLevel() >= item.getIndentLevel()) {
popupMenu.add(indentItem);
}
@@ -79,6 +227,137 @@ public void refreshMenu()
popupMenu.add(unindentItem);
}
+ String toggleLabel = "Mark as " + (item.getStatus() == Status.COMPLETED ? "Incomplete" : "Completed");
+ JMenuItem toggleStatusItem = new JMenuItem(toggleLabel);
+ toggleStatusItem.addActionListener(e -> {
+ // Determine new status for the root item
+ Status newStatus = (item.getStatus() == Status.COMPLETED ? Status.NOT_STARTED : Status.COMPLETED);
+
+ // Collect affected tasks (item + descendants until sibling/parent) and their old statuses
+ java.util.List affected = new java.util.ArrayList<>();
+ java.util.List oldStatuses = new java.util.ArrayList<>();
+
+ int index = list.indexOf(item);
+ int baseIndent = item.getIndentLevel();
+ affected.add(item);
+ oldStatuses.add(item.getStatus());
+ for (int i = index + 1; i < list.size(); i++) {
+ Task child = list.get(i);
+ if (child.getIndentLevel() <= baseIndent) break;
+ affected.add(child);
+ oldStatuses.add(child.getStatus());
+ }
+
+ ActionHistory.Action act = new ActionHistory.Action() {
+ @Override public void undo() {
+ for (int i = 0; i < affected.size(); i++) {
+ affected.get(i).setStatus(oldStatuses.get(i));
+ }
+ }
+ @Override public void redo() {
+ for (Task t : affected) {
+ t.setStatus(newStatus);
+ }
+ }
+ };
+
+ if (history != null) {
+ act.redo(); // apply change now
+ history.push(act); // record for undo/redo
+ } else {
+ // Fallback if no history injected
+ for (Task t : affected) {
+ t.setStatus(newStatus);
+ }
+ }
+
+ if (taskContent != null) {
+ taskContent.refresh();
+ }
+ refreshParentList();
+ });
+ popupMenu.add(toggleStatusItem);
+
+ // Add quest pre-reqs menu item only if the quest actually has prereqs
+ if (item instanceof com.toofifty.goaltracker.models.task.QuestTask) {
+ com.toofifty.goaltracker.models.task.QuestTask questTask = (com.toofifty.goaltracker.models.task.QuestTask) item;
+ int baseIndent = item.getIndentLevel();
+ // Gather existing direct/descendant children under this quest to avoid duplicates
+ java.util.Set existingKeys = new java.util.HashSet<>();
+ int parentIndex = list.indexOf(item);
+ for (int i = parentIndex + 1; i < list.size(); i++) {
+ var child = list.get(i);
+ if (child.getIndentLevel() <= baseIndent) {
+ break; // stop at siblings/parents
+ }
+ existingKeys.add(child.getClass().getName() + "|" + child.toString());
+ }
+ var rawPrereqs = QuestRequirements.getRequirements(questTask.getQuest(), baseIndent + 1);
+ java.util.List missingPrereqs = new java.util.ArrayList<>();
+ if (rawPrereqs != null) {
+ for (com.toofifty.goaltracker.models.task.Task p : rawPrereqs) {
+ String key = p.getClass().getName() + "|" + p.toString();
+ if (!existingKeys.contains(key)) {
+ missingPrereqs.add(p);
+ }
+ }
+ }
+ if (!missingPrereqs.isEmpty()) {
+ JMenuItem prereqItem = new JMenuItem("Add pre-reqs");
+ prereqItem.addActionListener(e -> {
+ // Recompute and filter again at click time
+ var raw = QuestRequirements.getRequirements(questTask.getQuest(), baseIndent + 1);
+ if (raw != null) {
+ // Refresh existing keys in case the list changed since menu was built
+ java.util.Set currentKeys = new java.util.HashSet<>();
+ int pIndex = list.indexOf(item);
+ for (int i = pIndex + 1; i < list.size(); i++) {
+ var child = list.get(i);
+ if (child.getIndentLevel() <= baseIndent) break;
+ currentKeys.add(child.getClass().getName() + "|" + child.toString());
+ }
+ java.util.List filtered = new java.util.ArrayList<>();
+ for (com.toofifty.goaltracker.models.task.Task t : raw) {
+ String key = t.getClass().getName() + "|" + t.toString();
+ if (!currentKeys.contains(key)) {
+ filtered.add(t);
+ }
+ }
+ if (!filtered.isEmpty()) {
+ int index = list.indexOf(item);
+ for (com.toofifty.goaltracker.models.task.Task prereq : filtered) {
+ list.add(index + 1, prereq);
+ index++;
+ }
+ refreshParentList();
+ }
+ }
+ });
+ popupMenu.add(prereqItem);
+ }
+ }
+
+ // Make the Remove action also delete all indented children of this item
+ for (var l : removeItem.getActionListeners()) {
+ removeItem.removeActionListener(l);
+ }
+ removeItem.addActionListener(e -> {
+ int removedIndex = list.indexOf(item);
+ int baseIndent = item.getIndentLevel();
+ // Remove all children more indented than this item
+ while (removedIndex + 1 < list.size() && list.get(removedIndex + 1).getIndentLevel() > baseIndent) {
+ list.remove(list.get(removedIndex + 1));
+ }
+ // Remove the item itself
+ list.remove(item);
+ // Notify listeners for Undo/Redo support
+ if (this.removedWithIndexListener != null) this.removedWithIndexListener.accept(item, removedIndex);
+ if (this.removedListener != null) this.removedListener.accept(item);
+ // Refresh UI
+ refreshParentList();
+ });
+
+ removeItem.setText("Remove (Shift+Left Click)");
popupMenu.add(removeItem);
}
@@ -89,4 +368,247 @@ public void onIndented(Consumer indentedListener) {
public void onUnindented(Consumer unindentedListener) {
this.unindentedListener = unindentedListener;
}
+
+ public void setTaskContent(TaskItemContent taskContent) {
+ this.taskContent = taskContent;
+ if (taskContent != null) {
+ addShiftRemoveListenerRecursive(taskContent);
+ }
+ }
+
+ public void setActionHistory(ActionHistory history) {
+ this.history = history;
+ }
+
+ /**
+ * Appends Task-specific context menu items.
+ */
+ @Override
+ protected void buildAdditionalMenu()
+ {
+ javax.swing.JMenu moveMenu = new javax.swing.JMenu("Move");
+ boolean hasMove = false;
+ // Rewire generic move actions to include ActionHistory entries
+ if (!list.isFirst(item)) {
+ for (var l : moveUp.getActionListeners()) moveUp.removeActionListener(l);
+ moveUp.addActionListener(e -> {
+ int oldIndex = list.indexOf(item);
+ int newIndex = Math.max(0, oldIndex - 1);
+ list.moveUp(item);
+ if (history != null) {
+ history.push(new ReorderTaskAction(list, item, oldIndex, newIndex));
+ }
+ refreshParentList();
+ });
+ moveMenu.add(moveUp);
+ hasMove = true;
+ }
+ if (!list.isLast(item)) {
+ for (var l : moveDown.getActionListeners()) moveDown.removeActionListener(l);
+ moveDown.addActionListener(e -> {
+ int oldIndex = list.indexOf(item);
+ int newIndex = Math.min(list.size() - 1, oldIndex + 1);
+ list.moveDown(item);
+ if (history != null) {
+ history.push(new ReorderTaskAction(list, item, oldIndex, newIndex));
+ }
+ refreshParentList();
+ });
+ moveMenu.add(moveDown);
+ hasMove = true;
+ }
+ if (!list.isFirst(item)) {
+ for (var l : moveToTop.getActionListeners()) moveToTop.removeActionListener(l);
+ moveToTop.addActionListener(e -> {
+ int oldIndex = list.indexOf(item);
+ list.moveToTop(item);
+ if (history != null) {
+ history.push(new ReorderTaskAction(list, item, oldIndex, 0));
+ }
+ refreshParentList();
+ });
+ moveMenu.add(moveToTop);
+ hasMove = true;
+ }
+ if (!list.isLast(item)) {
+ for (var l : moveToBottom.getActionListeners()) moveToBottom.removeActionListener(l);
+ moveToBottom.addActionListener(e -> {
+ int oldIndex = list.indexOf(item);
+ list.moveToBottom(item);
+ if (history != null) {
+ history.push(new ReorderTaskAction(list, item, oldIndex, list.size() - 1));
+ }
+ refreshParentList();
+ });
+ moveMenu.add(moveToBottom);
+ hasMove = true;
+ }
+ if (hasMove) {
+ popupMenu.add(moveMenu);
+ }
+
+ // Indent / Unindent
+ var previousItem = list.getPreviousItem(item);
+ if (item.isNotFullyIndented() && previousItem != null && previousItem.getIndentLevel() >= item.getIndentLevel()) {
+ popupMenu.add(indentItem);
+ }
+ if (item.isIndented()) {
+ popupMenu.add(unindentItem);
+ }
+
+ // Toggle Completed/Incomplete for this task and its descendants
+ String toggleLabel = "Mark as " + (item.getStatus() == Status.COMPLETED ? "Incomplete" : "Completed");
+ JMenuItem toggleStatusItem = new JMenuItem(toggleLabel);
+ toggleStatusItem.addActionListener(e -> {
+ Status newStatus = (item.getStatus() == Status.COMPLETED ? Status.NOT_STARTED : Status.COMPLETED);
+ java.util.List affected = new java.util.ArrayList<>();
+ java.util.List oldStatuses = new java.util.ArrayList<>();
+ int index = list.indexOf(item);
+ int baseIndent = item.getIndentLevel();
+ affected.add(item);
+ oldStatuses.add(item.getStatus());
+ for (int i = index + 1; i < list.size(); i++) {
+ Task child = list.get(i);
+ if (child.getIndentLevel() <= baseIndent) break;
+ affected.add(child);
+ oldStatuses.add(child.getStatus());
+ }
+ ActionHistory.Action act = new ActionHistory.Action() {
+ @Override public void undo() { for (int i = 0; i < affected.size(); i++) affected.get(i).setStatus(oldStatuses.get(i)); }
+ @Override public void redo() { for (Task t : affected) t.setStatus(newStatus); }
+ };
+ if (history != null) { act.redo(); history.push(act); }
+ else { for (Task t : affected) t.setStatus(newStatus); }
+ if (taskContent != null) taskContent.refresh();
+ refreshParentList();
+ });
+ popupMenu.add(toggleStatusItem);
+
+ // Quest pre-reqs (if available)
+ if (item instanceof com.toofifty.goaltracker.models.task.QuestTask) {
+ com.toofifty.goaltracker.models.task.QuestTask questTask = (com.toofifty.goaltracker.models.task.QuestTask) item;
+ int baseIndent = item.getIndentLevel();
+ java.util.Set existingKeys = new java.util.HashSet<>();
+ int parentIndex = list.indexOf(item);
+ for (int i = parentIndex + 1; i < list.size(); i++) {
+ var child = list.get(i);
+ if (child.getIndentLevel() <= baseIndent) break;
+ existingKeys.add(child.getClass().getName() + "|" + child.toString());
+ }
+ var rawPrereqs = QuestRequirements.getRequirements(questTask.getQuest(), baseIndent + 1);
+ java.util.List missingPrereqs = new java.util.ArrayList<>();
+ if (rawPrereqs != null) {
+ for (com.toofifty.goaltracker.models.task.Task p : rawPrereqs) {
+ String key = p.getClass().getName() + "|" + p.toString();
+ if (!existingKeys.contains(key)) missingPrereqs.add(p);
+ }
+ }
+ if (!missingPrereqs.isEmpty()) {
+ JMenuItem prereqItem = new JMenuItem("Add pre-reqs");
+ prereqItem.addActionListener(e -> {
+ var raw = QuestRequirements.getRequirements(questTask.getQuest(), baseIndent + 1);
+ if (raw != null) {
+ java.util.Set currentKeys = new java.util.HashSet<>();
+ int pIndex = list.indexOf(item);
+ for (int i = pIndex + 1; i < list.size(); i++) {
+ var child = list.get(i);
+ if (child.getIndentLevel() <= baseIndent) break;
+ currentKeys.add(child.getClass().getName() + "|" + child.toString());
+ }
+ java.util.List filtered = new java.util.ArrayList<>();
+ for (com.toofifty.goaltracker.models.task.Task t : raw) {
+ String key = t.getClass().getName() + "|" + t.toString();
+ if (!currentKeys.contains(key)) filtered.add(t);
+ }
+ if (!filtered.isEmpty()) {
+ int insertIndex = list.indexOf(item);
+ for (com.toofifty.goaltracker.models.task.Task prereq : filtered) {
+ list.add(insertIndex + 1, prereq);
+ insertIndex++;
+ }
+ refreshParentList();
+ }
+ }
+ });
+ popupMenu.add(prereqItem);
+ }
+ }
+
+ // Rewire Remove to cascade children and update label
+ for (var l : removeItem.getActionListeners()) removeItem.removeActionListener(l);
+ removeItem.addActionListener(e -> {
+ int removedIndex = list.indexOf(item);
+ int baseIndent = item.getIndentLevel();
+ while (removedIndex + 1 < list.size() && list.get(removedIndex + 1).getIndentLevel() > baseIndent) {
+ list.remove(list.get(removedIndex + 1));
+ }
+ list.remove(item);
+ if (this.removedWithIndexListener != null) this.removedWithIndexListener.accept(item, removedIndex);
+ if (this.removedListener != null) this.removedListener.accept(item);
+ refreshParentList();
+ });
+ removeItem.setText("Remove (Shift+Left Click)");
+ // Ensure remove is at the bottom: remove and re-add it
+ popupMenu.remove(removeItem);
+ popupMenu.add(removeItem);
+ }
+
+ private void refreshParentList()
+ {
+ Container parent = SwingUtilities.getAncestorOfClass(ListPanel.class, this);
+ if (parent instanceof ListPanel) {
+ ((ListPanel>) parent).tryBuildList();
+ ((ListPanel>) parent).refresh();
+ } else {
+ // Fallback
+ revalidate();
+ repaint();
+ }
+ }
+ /**
+ * Programmatically add quest prerequisites using the same logic as the context menu action.
+ * This lets callers (e.g., presets) trigger prereq insertion without simulating a right-click.
+ */
+ public static void addPrereqsForTask(ReorderableList list, Task item)
+ {
+ if (!(item instanceof com.toofifty.goaltracker.models.task.QuestTask)) {
+ return;
+ }
+ com.toofifty.goaltracker.models.task.QuestTask questTask = (com.toofifty.goaltracker.models.task.QuestTask) item;
+ int baseIndent = item.getIndentLevel();
+
+ // Gather existing children under this task to avoid duplicates
+ java.util.Set existingKeys = new java.util.HashSet<>();
+ int parentIndex = list.indexOf(item);
+ for (int i = parentIndex + 1; i < list.size(); i++) {
+ Task child = list.get(i);
+ if (child.getIndentLevel() <= baseIndent) break;
+ existingKeys.add(child.getClass().getName() + "|" + child.toString());
+ }
+
+ java.util.List raw = QuestRequirements.getRequirements(questTask.getQuest(), baseIndent + 1);
+ if (raw == null || raw.isEmpty()) {
+ return;
+ }
+
+ // Filter out any already-present entries
+ java.util.List filtered = new java.util.ArrayList<>();
+ for (com.toofifty.goaltracker.models.task.Task t : raw) {
+ String key = t.getClass().getName() + "|" + t.toString();
+ if (!existingKeys.contains(key)) {
+ filtered.add(t);
+ }
+ }
+
+ if (filtered.isEmpty()) {
+ return;
+ }
+
+ // Insert directly after the parent item, preserving order from QuestRequirements
+ int insertIndex = list.indexOf(item);
+ for (com.toofifty.goaltracker.models.task.Task prereq : filtered) {
+ list.add(insertIndex + 1, prereq);
+ insertIndex++;
+ }
+ }
}
diff --git a/src/main/java/com/toofifty/goaltracker/ui/components/TextButton.java b/src/main/java/com/toofifty/goaltracker/ui/components/TextButton.java
index be242c2..3adb92a 100644
--- a/src/main/java/com/toofifty/goaltracker/ui/components/TextButton.java
+++ b/src/main/java/com/toofifty/goaltracker/ui/components/TextButton.java
@@ -75,4 +75,7 @@ public TextButton narrow()
setBorder(new EmptyBorder(0, 2, 0, 2));
return this;
}
+
+ public void setOnClick(Object o) {
+ }
}
diff --git a/src/main/java/com/toofifty/goaltracker/ui/inputs/ItemTaskInput.java b/src/main/java/com/toofifty/goaltracker/ui/inputs/ItemTaskInput.java
index 431c780..cefe64a 100644
--- a/src/main/java/com/toofifty/goaltracker/ui/inputs/ItemTaskInput.java
+++ b/src/main/java/com/toofifty/goaltracker/ui/inputs/ItemTaskInput.java
@@ -13,17 +13,32 @@
import net.runelite.client.ui.components.FlatTextField;
import javax.swing.*;
+import javax.swing.SwingUtilities;
import javax.swing.border.EmptyBorder;
import java.awt.*;
+import java.awt.Point;
+import java.awt.KeyboardFocusManager;
+import java.awt.Window;
import java.util.regex.Pattern;
+import java.awt.Color;
+import javax.swing.JDialog;
+import javax.swing.JButton;
+import java.awt.event.ActionListener;
+import javax.swing.event.DocumentEvent;
+import javax.swing.event.DocumentListener;
+import javax.swing.text.JTextComponent;
-public class ItemTaskInput extends TaskInput
+
+
+
+public class ItemTaskInput extends TaskInput
{
private final ItemManager itemManager;
private final ClientThread clientThread;
private final FlatTextField quantityField = new FlatTextField();
private final TextButton searchItemButton = new TextButton("Search...");
+ private boolean searchOpen = false;
private final JLabel selectedItemLabel = new JLabel();
private final JPanel selectedItemPanel = new JPanel(new BorderLayout());
@@ -33,6 +48,7 @@ public class ItemTaskInput extends TaskInput
private String quantityFieldValue = "1";
private ItemComposition selectedItem;
+ private String lastSearchText = "";
public ItemTaskInput(GoalTrackerPlugin plugin, Goal goal)
{
@@ -41,18 +57,29 @@ public ItemTaskInput(GoalTrackerPlugin plugin, Goal goal)
this.clientThread = plugin.getClientThread();
searchItemButton.onClick(e -> {
- if (plugin.getClient().getGameState() != GameState.LOGGED_IN) {
- JOptionPane.showMessageDialog(this,
- "You must be logged in to choose items",
- "UwU",
- JOptionPane.ERROR_MESSAGE);
- return;
- }
+ if (!searchOpen) {
+ if (plugin.getClient().getGameState() != GameState.LOGGED_IN) {
+ JOptionPane.showMessageDialog(this,
+ "You must be logged in to choose items",
+ "UwU",
+ JOptionPane.ERROR_MESSAGE);
+ return;
+ }
- plugin.getItemSearch()
- .tooltipText("Choose an item")
- .onItemSelected(this::setSelectedItem)
- .build();
+ plugin.getItemSearch()
+ .tooltipText("Choose an item")
+ .onItemSelected(this::setSelectedItem)
+ .build();
+ searchItemButton.setText("Close");
+ searchOpen = true;
+ }
+ else {
+ try {
+ plugin.getChatboxPanelManager().close();
+ } catch (Exception ignored) {}
+ searchItemButton.setText("Search...");
+ searchOpen = false;
+ }
});
getInputRow().add(searchItemButton, BorderLayout.WEST);
@@ -108,6 +135,18 @@ private void setSelectedItem(Integer rawId)
revalidate();
repaint();
+
+ // Immediately add the item task
+ submit();
+
+ // Reopen search so user can add multiple items
+ plugin.getItemSearch()
+ .tooltipText("Choose an item")
+ .onItemSelected(this::setSelectedItem)
+ .build();
+
+ searchItemButton.setText("Close");
+ searchOpen = true;
});
}
@@ -122,7 +161,7 @@ protected void submit()
.itemId(selectedItem.getId())
.itemName(selectedItem.getName())
.quantity(Integer.parseInt(quantityField.getText()))
- .build());
+ .build());
}
@Override
@@ -142,5 +181,181 @@ private void clearSelectedItem()
revalidate();
repaint();
+
+ searchItemButton.setText("Search...");
+ searchOpen = false;
+ }
+
+ private boolean containsTextField(Container c)
+ {
+ if (c == null) return false;
+ for (Component comp : c.getComponents())
+ {
+ if (comp instanceof JTextComponent) return true;
+ if (comp instanceof Container && containsTextField((Container) comp)) return true;
+ }
+ return false;
+ }
+
+ private JTextComponent findTextField(Container c)
+ {
+ if (c == null) return null;
+ for (Component comp : c.getComponents())
+ {
+ if (comp instanceof JTextComponent) return (JTextComponent) comp;
+ if (comp instanceof Container)
+ {
+ JTextComponent tf = findTextField((Container) comp);
+ if (tf != null) return tf;
+ }
+ }
+ return null;
+ }
+
+ // Small floating red X button to close the item search popup
+ private void showCloseOverlay()
+ {
+ final int[] attempts = {0};
+ final int maxAttempts = 20;
+ final int delayMs = 150;
+
+ ActionListener tryShow = new ActionListener() {
+ @Override
+ public void actionPerformed(java.awt.event.ActionEvent ev) {
+ Component focusOwner = KeyboardFocusManager.getCurrentKeyboardFocusManager().getFocusOwner();
+ final JTextComponent searchField = (focusOwner instanceof JTextComponent) ? (JTextComponent) focusOwner : null;
+ Window target = (focusOwner != null) ? SwingUtilities.getWindowAncestor(focusOwner) : null;
+
+ if (target == null || searchField == null)
+ {
+ if (++attempts[0] < maxAttempts)
+ {
+ new javax.swing.Timer(delayMs, this) {{ setRepeats(false); }}.start();
+ }
+ return;
+ }
+
+ // Create overlay dialog owned by the search window so it closes with it
+ final JDialog overlay = new JDialog(target);
+ overlay.setUndecorated(true);
+ overlay.setAlwaysOnTop(true);
+ overlay.setFocusableWindowState(false);
+ overlay.setType(Window.Type.UTILITY);
+
+ JPanel header = new JPanel(new BorderLayout());
+ header.setBackground(ColorScheme.DARKER_GRAY_COLOR);
+ JButton close = new JButton("X");
+ close.setForeground(Color.WHITE);
+ close.setBackground(ColorScheme.PROGRESS_ERROR_COLOR);
+ close.setBorder(new EmptyBorder(2, 6, 2, 6));
+ close.setFocusable(false);
+ close.addActionListener(e2 -> {
+ overlay.dispose();
+ lastSearchText = "";
+ if (target instanceof JDialog) {
+ ((JDialog) target).dispose();
+ } else if (target instanceof javax.swing.JFrame) {
+ ((javax.swing.JFrame) target).dispose();
+ } else {
+ target.dispose();
+ }
+ });
+ header.add(close, BorderLayout.EAST);
+ overlay.getContentPane().add(header);
+ overlay.pack();
+
+ // Position at top-right of the search text field (anchor to the actual box)
+ Point tfLoc = searchField.getLocationOnScreen();
+ int tfRight = tfLoc.x + searchField.getWidth();
+ int x = tfRight - overlay.getWidth() - 4; // tuck inside right edge
+ int y = tfLoc.y + 2; // inside the box near the top
+ overlay.setLocation(x, y);
+ overlay.setVisible(true);
+ overlay.toFront();
+
+ // Restore and persist the user's query
+ if (lastSearchText != null && !lastSearchText.isEmpty())
+ {
+ searchField.setText(lastSearchText);
+ searchField.setCaretPosition(searchField.getText().length());
+ searchField.requestFocusInWindow();
+ }
+ searchField.getDocument().addDocumentListener(new DocumentListener()
+ {
+ @Override public void insertUpdate(DocumentEvent e) { lastSearchText = searchField.getText(); }
+ @Override public void removeUpdate(DocumentEvent e) { lastSearchText = searchField.getText(); }
+ @Override public void changedUpdate(DocumentEvent e) { lastSearchText = searchField.getText(); }
+ });
+
+ if (target instanceof Window) {
+ ((Window) target).addWindowListener(new java.awt.event.WindowAdapter() {
+ @Override
+ public void windowClosed(java.awt.event.WindowEvent e) { overlay.dispose(); }
+ @Override
+ public void windowClosing(java.awt.event.WindowEvent e) { overlay.dispose(); }
+ });
+
+ target.addComponentListener(new java.awt.event.ComponentAdapter() {
+ @Override
+ public void componentMoved(java.awt.event.ComponentEvent e) {
+ try {
+ Point tfLoc2 = searchField.getLocationOnScreen();
+ int tfRight2 = tfLoc2.x + searchField.getWidth();
+ int x2 = tfRight2 - overlay.getWidth() - 4;
+ int y2 = tfLoc2.y + 2;
+ overlay.setLocation(x2, y2);
+ } catch (IllegalComponentStateException ignored) { }
+ }
+ @Override
+ public void componentResized(java.awt.event.ComponentEvent e) {
+ try {
+ Point tfLoc2 = searchField.getLocationOnScreen();
+ int tfRight2 = tfLoc2.x + searchField.getWidth();
+ int x2 = tfRight2 - overlay.getWidth() - 4;
+ int y2 = tfLoc2.y + 2;
+ overlay.setLocation(x2, y2);
+ } catch (IllegalComponentStateException ignored) { }
+ }
+ });
+ }
+ }
+ };
+
+ // start first attempt slightly delayed to give the popup time to appear
+ new javax.swing.Timer(delayMs, tryShow) {{ setRepeats(false); }}.start();
+ }
+
+ // Try to locate the RuneLite Item Search popup window reliably
+ private Window findItemSearchWindow()
+ {
+ // Prefer visible JDialogs containing a text field (likely the chatbox item search)
+ for (Window w : Window.getWindows())
+ {
+ if (w != null && w.isShowing() && w instanceof JDialog)
+ {
+ if (w instanceof Container && containsTextField((Container) w))
+ {
+ return w;
+ }
+ }
+ }
+ Window active = KeyboardFocusManager.getCurrentKeyboardFocusManager().getActiveWindow();
+ if (active != null && active.isShowing() && active instanceof Container && containsTextField((Container) active))
+ {
+ return active;
+ }
+ for (Window w : Window.getWindows())
+ {
+ if (w != null && w.isShowing() && w instanceof Container && containsTextField((Container) w))
+ {
+ return w;
+ }
+ }
+ return null;
+ }
+ @Override
+ protected boolean showAddButton()
+ {
+ return false;
}
-}
+}
\ No newline at end of file
diff --git a/src/main/java/com/toofifty/goaltracker/ui/inputs/ManualTaskInput.java b/src/main/java/com/toofifty/goaltracker/ui/inputs/ManualTaskInput.java
index 4a7f1ce..39fe519 100644
--- a/src/main/java/com/toofifty/goaltracker/ui/inputs/ManualTaskInput.java
+++ b/src/main/java/com/toofifty/goaltracker/ui/inputs/ManualTaskInput.java
@@ -10,7 +10,7 @@
import java.awt.event.KeyAdapter;
import java.awt.event.KeyEvent;
-public class ManualTaskInput extends TaskInput
+public class ManualTaskInput extends TaskInput
{
private final FlatTextField titleField;
@@ -50,4 +50,4 @@ protected void reset()
titleField.setText("");
titleField.requestFocusInWindow();
}
-}
+}
\ No newline at end of file
diff --git a/src/main/java/com/toofifty/goaltracker/ui/inputs/QuestTaskInput.java b/src/main/java/com/toofifty/goaltracker/ui/inputs/QuestTaskInput.java
index 9aeff72..2225e6f 100644
--- a/src/main/java/com/toofifty/goaltracker/ui/inputs/QuestTaskInput.java
+++ b/src/main/java/com/toofifty/goaltracker/ui/inputs/QuestTaskInput.java
@@ -3,41 +3,87 @@
import com.toofifty.goaltracker.GoalTrackerPlugin;
import com.toofifty.goaltracker.models.Goal;
import com.toofifty.goaltracker.models.task.QuestTask;
+import com.toofifty.goaltracker.models.task.Task;
+import com.toofifty.goaltracker.utils.QuestRequirements;
import com.toofifty.goaltracker.ui.components.ComboBox;
import net.runelite.api.Quest;
+import net.runelite.client.ui.ColorScheme;
+import javax.swing.*;
+import javax.swing.border.EmptyBorder;
import java.awt.*;
import java.util.Arrays;
import java.util.Comparator;
import java.util.List;
+import java.util.ArrayList;
+import java.awt.event.ActionEvent;
+import java.awt.event.KeyEvent;
-public class QuestTaskInput extends TaskInput
+public class QuestTaskInput extends TaskInput
{
- private final ComboBox questField;
+ private final List allQuests;
+ private Quest bestMatch;
+ private final ComboBox questDropdown;
public QuestTaskInput(GoalTrackerPlugin plugin, Goal goal)
{
super(plugin, goal, "Quest");
- List quests = Arrays.asList(Quest.values());
- quests.sort(Comparator.comparing(
- (quest) -> quest.getName().replaceFirst("^(A|The) ", "")));
- questField = new ComboBox<>(quests);
- questField.setFormatter(Quest::getName);
- getInputRow().add(questField, BorderLayout.CENTER);
+ // Initialize all quests and sort them
+ allQuests = Arrays.asList(Quest.values());
+ allQuests.sort(Comparator.comparing(Quest::getName));
+
+ // Initialize dropdown via shared ComboBox for consistent arrows and crisp fonts
+ questDropdown = new ComboBox<>(allQuests);
+ // questDropdown.setCompact(true); // ~10% smaller font
+ questDropdown.setFormatter(q -> q != null ? q.getName() : "");
+
+ // Force RuneScape UF font at normal size
+ Font rsFont = new Font("RuneScape UF", Font.PLAIN, 10);
+ questDropdown.setFont(rsFont);
+
+
+
+ questDropdown.setSelectedIndex(-1); // no selection initially
+ questDropdown.addActionListener(e -> {
+ Quest selected = (Quest) questDropdown.getSelectedItem();
+ if (selected != null) {
+ bestMatch = selected;
+ }
+ });
+
+ questDropdown.getInputMap(JComponent.WHEN_ANCESTOR_OF_FOCUSED_COMPONENT)
+ .put(KeyStroke.getKeyStroke(KeyEvent.VK_ENTER, 0), "submitQuest");
+ questDropdown.getActionMap().put("submitQuest", new AbstractAction() {
+ @Override
+ public void actionPerformed(ActionEvent e) {
+ submit();
+ }
+ });
+
+ // Add components to layout
+ JPanel container = new JPanel(new BorderLayout(5, 5));
+ container.add(questDropdown, BorderLayout.CENTER);
+ getInputRow().add(container, BorderLayout.CENTER);
}
@Override
protected void submit()
{
- addTask(QuestTask.builder()
- .quest((Quest) questField.getSelectedItem())
- .build());
+ if (bestMatch != null) {
+ QuestTask mainQuestTask = QuestTask.builder()
+ .quest(bestMatch)
+ .indentLevel(0)
+ .build();
+
+ addTask(mainQuestTask);
+ }
}
@Override
protected void reset()
{
- questField.setSelectedIndex(0);
+ questDropdown.setSelectedIndex(-1);
+ bestMatch = null;
}
-}
+}
\ No newline at end of file
diff --git a/src/main/java/com/toofifty/goaltracker/ui/inputs/SkillLevelTaskInput.java b/src/main/java/com/toofifty/goaltracker/ui/inputs/SkillLevelTaskInput.java
index 71d07f1..fc29ba8 100644
--- a/src/main/java/com/toofifty/goaltracker/ui/inputs/SkillLevelTaskInput.java
+++ b/src/main/java/com/toofifty/goaltracker/ui/inputs/SkillLevelTaskInput.java
@@ -14,9 +14,8 @@
import java.awt.*;
import java.util.regex.Pattern;
-public class SkillLevelTaskInput extends TaskInput
+public class SkillLevelTaskInput extends TaskInput
{
-
private FlatTextField levelField;
private String levelFieldValue = "99";
@@ -61,7 +60,7 @@ protected void submit()
addTask(SkillLevelTask.builder()
.skill((Skill) skillField.getSelectedItem())
.level(Integer.parseInt(levelField.getText()))
- .build());
+ .build());
}
@Override
@@ -72,4 +71,4 @@ protected void reset()
skillField.setSelectedIndex(0);
}
-}
+}
\ No newline at end of file
diff --git a/src/main/java/com/toofifty/goaltracker/ui/inputs/SkillXpTaskInput.java b/src/main/java/com/toofifty/goaltracker/ui/inputs/SkillXpTaskInput.java
index 408ba23..f02a9a4 100644
--- a/src/main/java/com/toofifty/goaltracker/ui/inputs/SkillXpTaskInput.java
+++ b/src/main/java/com/toofifty/goaltracker/ui/inputs/SkillXpTaskInput.java
@@ -14,7 +14,7 @@
import java.awt.*;
import java.util.regex.Pattern;
-public class SkillXpTaskInput extends TaskInput
+public class SkillXpTaskInput extends TaskInput
{
private final FlatTextField xpField;
private final ComboBox skillField;
@@ -79,7 +79,7 @@ protected void submit()
addTask(SkillXpTask.builder()
.skill((Skill) skillField.getSelectedItem())
.xp(Integer.parseInt(xpField.getText()))
- .build());
+ .build());
}
@Override
@@ -90,4 +90,4 @@ protected void reset()
skillField.setSelectedIndex(0);
}
-}
+}
\ No newline at end of file
diff --git a/src/main/java/com/toofifty/goaltracker/ui/inputs/TaskInput.java b/src/main/java/com/toofifty/goaltracker/ui/inputs/TaskInput.java
index f651f6f..804231a 100644
--- a/src/main/java/com/toofifty/goaltracker/ui/inputs/TaskInput.java
+++ b/src/main/java/com/toofifty/goaltracker/ui/inputs/TaskInput.java
@@ -7,23 +7,24 @@
import java.awt.BorderLayout;
import java.awt.GridBagConstraints;
import java.awt.GridBagLayout;
-import java.util.function.Consumer;
import javax.swing.JLabel;
import javax.swing.JPanel;
import javax.swing.border.EmptyBorder;
import lombok.Getter;
import net.runelite.client.ui.ColorScheme;
import net.runelite.client.ui.FontManager;
+import java.util.function.Consumer;
+import java.util.Collection;
-public abstract class TaskInput extends JPanel
+public abstract class TaskInput extends JPanel
{
protected final int PREFERRED_INPUT_HEIGHT = 16;
- protected GoalTrackerPlugin plugin;
+ protected final GoalTrackerPlugin plugin;
private final Goal goal;
@Getter
private final JPanel inputRow;
@Getter
- private Consumer listener;
+ private Consumer listener;
TaskInput(GoalTrackerPlugin plugin, Goal goal, String title)
{
@@ -51,30 +52,46 @@ public abstract class TaskInput extends JPanel
inputRow = new JPanel(new BorderLayout());
inputRow.setBackground(ColorScheme.DARKER_GRAY_COLOR);
-
- TextButton addButton = new TextButton("Add");
- addButton.onClick(e -> submit());
-
- inputRow.add(addButton, BorderLayout.EAST);
-
+ if (showAddButton()) {
+ TextButton addButton = new TextButton("Add");
+ addButton.onClick(e -> submit());
+ inputRow.add(addButton, BorderLayout.EAST);
+ }
add(inputRow, constraints);
constraints.gridy++;
}
abstract protected void submit();
- public void addTask(T task)
+ public void addTask(Task task)
{
goal.getTasks().add(task);
- this.listener.accept(task);
+ if (listener != null) {
+ listener.accept(task);
+ }
+ this.reset();
+ }
+
+ public void addTasks(Collection tasks)
+ {
+ goal.getTasks().addAll(tasks);
+ if (listener != null) {
+ for (Task t : tasks) {
+ listener.accept(t);
+ }
+ }
this.reset();
}
abstract protected void reset();
- public TaskInput onSubmit(Consumer listener)
+ public TaskInput onSubmit(Consumer listener)
{
this.listener = listener;
return this;
}
-}
+ protected boolean showAddButton()
+ {
+ return true;
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/com/toofifty/goaltracker/utils/QuestRequirements.java b/src/main/java/com/toofifty/goaltracker/utils/QuestRequirements.java
new file mode 100644
index 0000000..b468856
--- /dev/null
+++ b/src/main/java/com/toofifty/goaltracker/utils/QuestRequirements.java
@@ -0,0 +1,1240 @@
+package com.toofifty.goaltracker.utils;
+
+import com.toofifty.goaltracker.models.task.QuestTask;
+import com.toofifty.goaltracker.models.task.SkillLevelTask;
+import com.toofifty.goaltracker.models.task.Task;
+import net.runelite.api.Quest;
+import net.runelite.api.Skill;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+public class QuestRequirements
+{
+ // Map of quests to their requirements (quests and skills)
+ private static final Map> REQUIREMENT_MAP;
+
+ // Static initializer with minimal dependencies
+ static {
+ REQUIREMENT_MAP = new HashMap<>();
+ REQUIREMENT_MAP.put(
+ Quest.FAIRYTALE_I__GROWING_PAINS,
+ Arrays.asList(
+ QuestTask.builder().quest(Quest.LOST_CITY).build(),
+ QuestTask.builder().quest(Quest.NATURE_SPIRIT).build(),
+ SkillLevelTask.builder().skill(Skill.FARMING).level(18).build()
+ )
+ );
+ REQUIREMENT_MAP.put(
+ Quest.NATURE_SPIRIT,
+ Arrays.asList(
+ QuestTask.builder().quest(Quest.PRIEST_IN_PERIL).build(),
+ QuestTask.builder().quest(Quest.THE_RESTLESS_GHOST).build(),
+ SkillLevelTask.builder().skill(Skill.PRAYER).level(18).build()
+ )
+ );
+ REQUIREMENT_MAP.put(
+ Quest.DRAGON_SLAYER_I,
+ Arrays.asList(
+ SkillLevelTask.builder().skill(Skill.ATTACK).level(32).build(),
+ QuestTask.builder().quest(Quest.PRIEST_IN_PERIL).build()
+ )
+ );
+ // Jungle Potion requires Druidic Ritual and Herblore 3【532018769049742†L45-L49】.
+ REQUIREMENT_MAP.put(
+ Quest.JUNGLE_POTION,
+ Arrays.asList(
+ QuestTask.builder().quest(Quest.DRUIDIC_RITUAL).build(),
+ SkillLevelTask.builder().skill(Skill.HERBLORE).level(3).build()
+ )
+ );
+
+ REQUIREMENT_MAP.put(
+ Quest.BIOHAZARD,
+ Arrays.asList(
+ QuestTask.builder().quest(Quest.PLAGUE_CITY).build()
+ )
+ );
+
+ REQUIREMENT_MAP.put(
+ Quest.EAGLES_PEAK,
+ Arrays.asList(
+ SkillLevelTask.builder().skill(Skill.HUNTER).level(27).build()
+ )
+ );
+
+ REQUIREMENT_MAP.put(
+ Quest.ELEMENTAL_WORKSHOP_I,
+ Arrays.asList(
+ SkillLevelTask.builder().skill(Skill.MINING).level(20).build(),
+ SkillLevelTask.builder().skill(Skill.SMITHING).level(20).build(),
+ SkillLevelTask.builder().skill(Skill.CRAFTING).level(20).build()
+ )
+ );
+
+ REQUIREMENT_MAP.put(
+ Quest.FISHING_CONTEST,
+ Arrays.asList(
+ SkillLevelTask.builder().skill(Skill.FISHING).level(10).build()
+ )
+ );
+
+ REQUIREMENT_MAP.put(
+ Quest.OBSERVATORY_QUEST,
+ Arrays.asList(
+ SkillLevelTask.builder().skill(Skill.CRAFTING).level(10).build()
+ )
+ );
+
+ REQUIREMENT_MAP.put(
+ Quest.RECRUITMENT_DRIVE,
+ Collections.singletonList(
+ QuestTask.builder().quest(Quest.BLACK_KNIGHTS_FORTRESS).build()
+ )
+ );
+
+ REQUIREMENT_MAP.put(
+ Quest.TOWER_OF_LIFE,
+ Arrays.asList(
+ SkillLevelTask.builder().skill(Skill.CONSTRUCTION).level(10).build()
+ )
+ );
+
+ REQUIREMENT_MAP.put(
+ Quest.ANIMAL_MAGNETISM,
+ Arrays.asList(
+ QuestTask.builder().quest(Quest.ERNEST_THE_CHICKEN).build(),
+ QuestTask.builder().quest(Quest.PRIEST_IN_PERIL).build(),
+ QuestTask.builder().quest(Quest.THE_RESTLESS_GHOST).build(),
+ SkillLevelTask.builder().skill(Skill.SLAYER).level(18).build(),
+ SkillLevelTask.builder().skill(Skill.RANGED).level(30).build(),
+ SkillLevelTask.builder().skill(Skill.CRAFTING).level(19).build(),
+ SkillLevelTask.builder().skill(Skill.WOODCUTTING).level(35).build()
+ )
+ );
+
+ REQUIREMENT_MAP.put(
+ Quest.ANOTHER_SLICE_OF_HAM,
+ Arrays.asList(
+ QuestTask.builder().quest(Quest.DEATH_TO_THE_DORGESHUUN).build()
+ )
+ );
+
+ REQUIREMENT_MAP.put(
+ Quest.BIG_CHOMPY_BIRD_HUNTING,
+ Arrays.asList(
+ SkillLevelTask.builder().skill(Skill.COOKING).level(30).build(),
+ SkillLevelTask.builder().skill(Skill.FLETCHING).level(5).build(),
+ SkillLevelTask.builder().skill(Skill.RANGED).level(30).build()
+ )
+ );
+
+ REQUIREMENT_MAP.put(
+ Quest.COLD_WAR,
+ Arrays.asList(
+ SkillLevelTask.builder().skill(Skill.AGILITY).level(30).build(),
+ SkillLevelTask.builder().skill(Skill.CONSTRUCTION).level(34).build(),
+ SkillLevelTask.builder().skill(Skill.CRAFTING).level(30).build(),
+ SkillLevelTask.builder().skill(Skill.HUNTER).level(10).build(),
+ SkillLevelTask.builder().skill(Skill.THIEVING).level(15).build()
+ )
+ );
+
+ REQUIREMENT_MAP.put(
+ Quest.CREATURE_OF_FENKENSTRAIN,
+ Arrays.asList(
+ SkillLevelTask.builder().skill(Skill.CRAFTING).level(20).build(),
+ SkillLevelTask.builder().skill(Skill.THIEVING).level(25).build()
+ )
+ );
+
+ REQUIREMENT_MAP.put(
+ Quest.DARKNESS_OF_HALLOWVALE,
+ Arrays.asList(
+ QuestTask.builder().quest(Quest.IN_AID_OF_THE_MYREQUE).build(),
+ SkillLevelTask.builder().skill(Skill.AGILITY).level(26).build(),
+ SkillLevelTask.builder().skill(Skill.CONSTRUCTION).level(5).build(),
+ SkillLevelTask.builder().skill(Skill.CRAFTING).level(32).build(),
+ SkillLevelTask.builder().skill(Skill.MAGIC).level(33).build(),
+ SkillLevelTask.builder().skill(Skill.MINING).level(20).build(),
+ SkillLevelTask.builder().skill(Skill.STRENGTH).level(40).build(),
+ SkillLevelTask.builder().skill(Skill.THIEVING).level(22).build()
+ )
+ );
+
+ REQUIREMENT_MAP.put(
+ Quest.DEATH_TO_THE_DORGESHUUN,
+ Arrays.asList(
+ QuestTask.builder().quest(Quest.THE_LOST_TRIBE).build(),
+ SkillLevelTask.builder().skill(Skill.AGILITY).level(23).build(),
+ SkillLevelTask.builder().skill(Skill.THIEVING).level(17).build(),
+ SkillLevelTask.builder().skill(Skill.MINING).level(23).build()
+ )
+ );
+
+ REQUIREMENT_MAP.put(
+ Quest.THE_DIG_SITE,
+ Arrays.asList(
+ SkillLevelTask.builder().skill(Skill.AGILITY).level(10).build(),
+ SkillLevelTask.builder().skill(Skill.HERBLORE).level(10).build(),
+ SkillLevelTask.builder().skill(Skill.THIEVING).level(25).build()
+ )
+ );
+
+ REQUIREMENT_MAP.put(
+ Quest.ELEMENTAL_WORKSHOP_II,
+ Arrays.asList(
+ QuestTask.builder().quest(Quest.ELEMENTAL_WORKSHOP_I).build(),
+ SkillLevelTask.builder().skill(Skill.DEFENCE).level(20).build(),
+ SkillLevelTask.builder().skill(Skill.SMITHING).level(20).build(),
+ SkillLevelTask.builder().skill(Skill.CRAFTING).level(20).build(),
+ SkillLevelTask.builder().skill(Skill.MINING).level(30).build()
+ )
+ );
+
+ REQUIREMENT_MAP.put(
+ Quest.ENLIGHTENED_JOURNEY,
+ Arrays.asList(
+ SkillLevelTask.builder().skill(Skill.CRAFTING).level(36).build(),
+ SkillLevelTask.builder().skill(Skill.FIREMAKING).level(20).build(),
+ SkillLevelTask.builder().skill(Skill.FARMING).level(30).build()
+ )
+ );
+
+ REQUIREMENT_MAP.put(
+ Quest.THE_EYES_OF_GLOUPHRIE,
+ Arrays.asList(
+ QuestTask.builder().quest(Quest.THE_GRAND_TREE).build(),
+ SkillLevelTask.builder().skill(Skill.MAGIC).level(46).build(),
+ SkillLevelTask.builder().skill(Skill.FARMING).level(5).build(),
+ SkillLevelTask.builder().skill(Skill.CONSTRUCTION).level(5).build()
+ )
+ );
+
+ REQUIREMENT_MAP.put(
+ Quest.THE_FEUD,
+ Arrays.asList(
+ SkillLevelTask.builder().skill(Skill.THIEVING).level(30).build()
+ )
+ );
+
+ REQUIREMENT_MAP.put(
+ Quest.FORGETTABLE_TALE,
+ Arrays.asList(
+ QuestTask.builder().quest(Quest.THE_GIANT_DWARF).build(),
+ SkillLevelTask.builder().skill(Skill.COOKING).level(22).build(),
+ SkillLevelTask.builder().skill(Skill.FARMING).level(12).build(),
+ SkillLevelTask.builder().skill(Skill.HERBLORE).level(17).build(),
+ SkillLevelTask.builder().skill(Skill.THIEVING).level(16).build(),
+ SkillLevelTask.builder().skill(Skill.FISHING).level(10).build(),
+ SkillLevelTask.builder().skill(Skill.MINING).level(33).build(),
+ SkillLevelTask.builder().skill(Skill.STRENGTH).level(14).build()
+ )
+ );
+
+ REQUIREMENT_MAP.put(
+ Quest.GARDEN_OF_TRANQUILLITY,
+ Arrays.asList(
+ QuestTask.builder().quest(Quest.CREATURE_OF_FENKENSTRAIN).build(),
+ SkillLevelTask.builder().skill(Skill.FARMING).level(25).build(),
+ SkillLevelTask.builder().skill(Skill.HERBLORE).level(20).build(),
+ SkillLevelTask.builder().skill(Skill.THIEVING).level(25).build()
+ )
+ );
+
+ REQUIREMENT_MAP.put(
+ Quest.GHOSTS_AHOY,
+ Arrays.asList(
+ QuestTask.builder().quest(Quest.PRIEST_IN_PERIL).build(),
+ SkillLevelTask.builder().skill(Skill.AGILITY).level(25).build(),
+ SkillLevelTask.builder().skill(Skill.COOKING).level(20).build()
+ )
+ );
+
+ REQUIREMENT_MAP.put(
+ Quest.THE_GIANT_DWARF,
+ Arrays.asList(
+ SkillLevelTask.builder().skill(Skill.CRAFTING).level(12).build(),
+ SkillLevelTask.builder().skill(Skill.FIREMAKING).level(16).build(),
+ SkillLevelTask.builder().skill(Skill.MAGIC).level(33).build(),
+ SkillLevelTask.builder().skill(Skill.THIEVING).level(14).build()
+ )
+ );
+
+ REQUIREMENT_MAP.put(
+ Quest.THE_GOLEM,
+ Arrays.asList(
+ SkillLevelTask.builder().skill(Skill.THIEVING).level(20).build(),
+ SkillLevelTask.builder().skill(Skill.CRAFTING).level(25).build()
+ )
+ );
+
+ REQUIREMENT_MAP.put(
+ Quest.THE_HAND_IN_THE_SAND,
+ Arrays.asList(
+ SkillLevelTask.builder().skill(Skill.CRAFTING).level(49).build(),
+ SkillLevelTask.builder().skill(Skill.THIEVING).level(17).build()
+ )
+ );
+
+ REQUIREMENT_MAP.put(
+ Quest.HOLY_GRAIL,
+ Arrays.asList(
+ QuestTask.builder().quest(Quest.MERLINS_CRYSTAL).build(),
+ SkillLevelTask.builder().skill(Skill.ATTACK).level(20).build()
+ )
+ );
+
+ REQUIREMENT_MAP.put(
+ Quest.ICTHLARINS_LITTLE_HELPER,
+ Arrays.asList(
+ QuestTask.builder().quest(Quest.PRINCE_ALI_RESCUE).build()
+ )
+ );
+
+ REQUIREMENT_MAP.put(
+ Quest.IN_AID_OF_THE_MYREQUE,
+ Arrays.asList(
+ QuestTask.builder().quest(Quest.IN_SEARCH_OF_THE_MYREQUE).build(),
+ SkillLevelTask.builder().skill(Skill.CRAFTING).level(25).build(),
+ SkillLevelTask.builder().skill(Skill.MINING).level(25).build(),
+ SkillLevelTask.builder().skill(Skill.MAGIC).level(7).build(),
+ SkillLevelTask.builder().skill(Skill.FISHING).level(15).build()
+ )
+ );
+
+ REQUIREMENT_MAP.put(
+ Quest.IN_SEARCH_OF_THE_MYREQUE,
+ Arrays.asList(
+ QuestTask.builder().quest(Quest.NATURE_SPIRIT).build(),
+ SkillLevelTask.builder().skill(Skill.AGILITY).level(25).build()
+ )
+ );
+
+ REQUIREMENT_MAP.put(
+ Quest.THE_KNIGHTS_SWORD,
+ Arrays.asList(
+ SkillLevelTask.builder().skill(Skill.MINING).level(10).build()
+ )
+ );
+
+ REQUIREMENT_MAP.put(
+ Quest.THE_LOST_TRIBE,
+ Arrays.asList(
+ QuestTask.builder().quest(Quest.GOBLIN_DIPLOMACY).build(),
+ SkillLevelTask.builder().skill(Skill.AGILITY).level(13).build(),
+ SkillLevelTask.builder().skill(Skill.MINING).level(17).build(),
+ SkillLevelTask.builder().skill(Skill.THIEVING).level(13).build()
+ )
+ );
+
+ REQUIREMENT_MAP.put(
+ Quest.MAKING_HISTORY,
+ Arrays.asList(
+ QuestTask.builder().quest(Quest.PRIEST_IN_PERIL).build()
+ )
+ );
+
+ REQUIREMENT_MAP.put(
+ Quest.MOUNTAIN_DAUGHTER,
+ Arrays.asList(
+ SkillLevelTask.builder().skill(Skill.AGILITY).level(20).build()
+ )
+ );
+
+ REQUIREMENT_MAP.put(
+ Quest.MY_ARMS_BIG_ADVENTURE,
+ Arrays.asList(
+ QuestTask.builder().quest(Quest.JUNGLE_POTION).build(),
+ SkillLevelTask.builder().skill(Skill.AGILITY).level(15).build(),
+ SkillLevelTask.builder().skill(Skill.FARMING).level(29).build(),
+ SkillLevelTask.builder().skill(Skill.HERBLORE).level(31).build(),
+ SkillLevelTask.builder().skill(Skill.WOODCUTTING).level(30).build(),
+ SkillLevelTask.builder().skill(Skill.COOKING).level(10).build()
+ )
+ );
+
+ REQUIREMENT_MAP.put(
+ Quest.OLAFS_QUEST,
+ Arrays.asList(
+ SkillLevelTask.builder().skill(Skill.AGILITY).level(40).build(),
+ SkillLevelTask.builder().skill(Skill.FIREMAKING).level(50).build()
+ )
+ );
+
+ REQUIREMENT_MAP.put(
+ Quest.RATCATCHERS,
+ Arrays.asList(
+ QuestTask.builder().quest(Quest.ICTHLARINS_LITTLE_HELPER).build(),
+ SkillLevelTask.builder().skill(Skill.AGILITY).level(12).build(),
+ SkillLevelTask.builder().skill(Skill.SLAYER).level(16).build(),
+ SkillLevelTask.builder().skill(Skill.THIEVING).level(33).build(),
+ SkillLevelTask.builder().skill(Skill.HUNTER).level(14).build()
+ )
+ );
+
+ REQUIREMENT_MAP.put(
+ Quest.SCORPION_CATCHER,
+ Arrays.asList(
+ QuestTask.builder().quest(Quest.ALFRED_GRIMHANDS_BARCRAWL).build(),
+ SkillLevelTask.builder().skill(Skill.PRAYER).level(31).build()
+ )
+ );
+
+ REQUIREMENT_MAP.put(
+ Quest.SEA_SLUG,
+ Arrays.asList(
+ SkillLevelTask.builder().skill(Skill.FIREMAKING).level(30).build()
+ )
+ );
+
+ REQUIREMENT_MAP.put(
+ Quest.SHADES_OF_MORTTON,
+ Arrays.asList(
+ SkillLevelTask.builder().skill(Skill.CRAFTING).level(20).build(),
+ SkillLevelTask.builder().skill(Skill.HERBLORE).level(5).build(),
+ SkillLevelTask.builder().skill(Skill.MINING).level(15).build()
+ )
+ );
+
+ REQUIREMENT_MAP.put(
+ Quest.SHADOW_OF_THE_STORM,
+ Arrays.asList(
+ QuestTask.builder().quest(Quest.DEMON_SLAYER).build(),
+ QuestTask.builder().quest(Quest.THE_GOLEM).build(),
+ SkillLevelTask.builder().skill(Skill.CRAFTING).level(30).build(),
+ SkillLevelTask.builder().skill(Skill.THIEVING).level(25).build()
+ )
+ );
+
+ REQUIREMENT_MAP.put(
+ Quest.THE_SLUG_MENACE,
+ Arrays.asList(
+ QuestTask.builder().quest(Quest.WANTED).build(),
+ SkillLevelTask.builder().skill(Skill.RUNECRAFT).level(13).build(),
+ SkillLevelTask.builder().skill(Skill.CRAFTING).level(30).build(),
+ SkillLevelTask.builder().skill(Skill.SLAYER).level(30).build(),
+ SkillLevelTask.builder().skill(Skill.THIEVING).level(17).build(),
+ SkillLevelTask.builder().skill(Skill.SMITHING).level(30).build()
+ )
+ );
+
+ REQUIREMENT_MAP.put(
+ Quest.SPIRITS_OF_THE_ELID,
+ Arrays.asList(
+ SkillLevelTask.builder().skill(Skill.MAGIC).level(33).build(),
+ SkillLevelTask.builder().skill(Skill.MINING).level(37).build(),
+ SkillLevelTask.builder().skill(Skill.RANGED).level(37).build(),
+ SkillLevelTask.builder().skill(Skill.THIEVING).level(37).build()
+ )
+ );
+
+ REQUIREMENT_MAP.put(
+ Quest.TAI_BWO_WANNAI_TRIO,
+ Arrays.asList(
+ SkillLevelTask.builder().skill(Skill.AGILITY).level(15).build(),
+ SkillLevelTask.builder().skill(Skill.COOKING).level(30).build(),
+ SkillLevelTask.builder().skill(Skill.HERBLORE).level(5).build()
+ )
+ );
+
+ REQUIREMENT_MAP.put(
+ Quest.A_TAIL_OF_TWO_CATS,
+ Arrays.asList(
+ QuestTask.builder().quest(Quest.ICTHLARINS_LITTLE_HELPER).build()
+ )
+ );
+
+ REQUIREMENT_MAP.put(
+ Quest.TEARS_OF_GUTHIX,
+ Arrays.asList(
+ SkillLevelTask.builder().skill(Skill.CRAFTING).level(20).build(),
+ SkillLevelTask.builder().skill(Skill.FIREMAKING).level(49).build(),
+ SkillLevelTask.builder().skill(Skill.MINING).level(20).build()
+ )
+ );
+
+ REQUIREMENT_MAP.put(
+ Quest.THE_TOURIST_TRAP,
+ Arrays.asList(
+ SkillLevelTask.builder().skill(Skill.FLETCHING).level(10).build(),
+ SkillLevelTask.builder().skill(Skill.SMITHING).level(20).build()
+ )
+ );
+
+ REQUIREMENT_MAP.put(
+ Quest.TRIBAL_TOTEM,
+ Arrays.asList(
+ SkillLevelTask.builder().skill(Skill.THIEVING).level(21).build()
+ )
+ );
+
+ REQUIREMENT_MAP.put(
+ Quest.WANTED,
+ Arrays.asList(
+ QuestTask.builder().quest(Quest.RECRUITMENT_DRIVE).build(),
+ SkillLevelTask.builder().skill(Skill.AGILITY).level(13).build(),
+ SkillLevelTask.builder().skill(Skill.MINING).level(17).build(),
+ SkillLevelTask.builder().skill(Skill.THIEVING).level(13).build()
+ )
+ );
+
+ REQUIREMENT_MAP.put(
+ Quest.WATCHTOWER,
+ Arrays.asList(
+ SkillLevelTask.builder().skill(Skill.AGILITY).level(25).build(),
+ SkillLevelTask.builder().skill(Skill.HERBLORE).level(14).build(),
+ SkillLevelTask.builder().skill(Skill.MAGIC).level(15).build(),
+ SkillLevelTask.builder().skill(Skill.MINING).level(40).build(),
+ SkillLevelTask.builder().skill(Skill.THIEVING).level(15).build()
+ )
+ );
+
+ REQUIREMENT_MAP.put(
+ Quest.WHAT_LIES_BELOW,
+ Arrays.asList(
+ QuestTask.builder().quest(Quest.RUNE_MYSTERIES).build(),
+ SkillLevelTask.builder().skill(Skill.RUNECRAFT).level(35).build()
+ )
+ );
+
+ REQUIREMENT_MAP.put(
+ Quest.ZOGRE_FLESH_EATERS,
+ Arrays.asList(
+ QuestTask.builder().quest(Quest.JUNGLE_POTION).build(),
+ QuestTask.builder().quest(Quest.BIG_CHOMPY_BIRD_HUNTING).build(),
+ SkillLevelTask.builder().skill(Skill.FLETCHING).level(30).build(),
+ SkillLevelTask.builder().skill(Skill.HERBLORE).level(8).build(),
+ SkillLevelTask.builder().skill(Skill.SMITHING).level(4).build(),
+ SkillLevelTask.builder().skill(Skill.RANGED).level(30).build()
+ )
+ );
+
+ REQUIREMENT_MAP.put(
+ Quest.BETWEEN_A_ROCK,
+ Arrays.asList(
+ QuestTask.builder().quest(Quest.DWARF_CANNON).build(),
+ SkillLevelTask.builder().skill(Skill.DEFENCE).level(30).build(),
+ SkillLevelTask.builder().skill(Skill.MINING).level(10).build(),
+ SkillLevelTask.builder().skill(Skill.CRAFTING).level(40).build(),
+ SkillLevelTask.builder().skill(Skill.SMITHING).level(50).build()
+ )
+ );
+
+ REQUIREMENT_MAP.put(
+ Quest.CABIN_FEVER,
+ Arrays.asList(
+ QuestTask.builder().quest(Quest.RUM_DEAL).build(),
+ SkillLevelTask.builder().skill(Skill.AGILITY).level(42).build(),
+ SkillLevelTask.builder().skill(Skill.CRAFTING).level(30).build(),
+ SkillLevelTask.builder().skill(Skill.SMITHING).level(45).build(),
+ SkillLevelTask.builder().skill(Skill.RANGED).level(40).build(),
+ SkillLevelTask.builder().skill(Skill.COOKING).level(50).build()
+ )
+ );
+
+ REQUIREMENT_MAP.put(
+ Quest.DEVIOUS_MINDS,
+ Arrays.asList(
+ QuestTask.builder().quest(Quest.WANTED).build(),
+ SkillLevelTask.builder().skill(Skill.THIEVING).level(50).build(),
+ SkillLevelTask.builder().skill(Skill.RUNECRAFT).level(50).build(),
+ SkillLevelTask.builder().skill(Skill.SMITHING).level(65).build(),
+ SkillLevelTask.builder().skill(Skill.FLETCHING).level(50).build()
+ )
+ );
+
+ REQUIREMENT_MAP.put(
+ Quest.EADGARS_RUSE,
+ Arrays.asList(
+ QuestTask.builder().quest(Quest.DRUIDIC_RITUAL).build(),
+ SkillLevelTask.builder().skill(Skill.HERBLORE).level(31).build()
+ )
+ );
+
+ REQUIREMENT_MAP.put(
+ Quest.ENAKHRAS_LAMENT,
+ Arrays.asList(
+ SkillLevelTask.builder().skill(Skill.CRAFTING).level(50).build(),
+ SkillLevelTask.builder().skill(Skill.FIREMAKING).level(45).build(),
+ SkillLevelTask.builder().skill(Skill.MAGIC).level(39).build(),
+ SkillLevelTask.builder().skill(Skill.PRAYER).level(43).build()
+ )
+ );
+
+ REQUIREMENT_MAP.put(
+ Quest.FAIRYTALE_II__CURE_A_QUEEN,
+ Arrays.asList(
+ QuestTask.builder().quest(Quest.FAIRYTALE_I__GROWING_PAINS).build(),
+ SkillLevelTask.builder().skill(Skill.THIEVING).level(40).build(),
+ SkillLevelTask.builder().skill(Skill.MAGIC).level(49).build(),
+ SkillLevelTask.builder().skill(Skill.HERBLORE).level(57).build(),
+ SkillLevelTask.builder().skill(Skill.FARMING).level(37).build()
+ )
+ );
+
+ REQUIREMENT_MAP.put(
+ Quest.FAMILY_CREST,
+ Arrays.asList(
+ SkillLevelTask.builder().skill(Skill.MINING).level(40).build(),
+ SkillLevelTask.builder().skill(Skill.MAGIC).level(59).build(),
+ SkillLevelTask.builder().skill(Skill.SMITHING).level(40).build(),
+ SkillLevelTask.builder().skill(Skill.CRAFTING).level(40).build()
+ )
+ );
+
+ REQUIREMENT_MAP.put(
+ Quest.THE_FREMENNIK_ISLES,
+ Arrays.asList(
+ QuestTask.builder().quest(Quest.THE_FREMENNIK_TRIALS).build(),
+ SkillLevelTask.builder().skill(Skill.AGILITY).level(40).build(),
+ SkillLevelTask.builder().skill(Skill.CONSTRUCTION).level(20).build()
+ )
+ );
+
+ REQUIREMENT_MAP.put(
+ Quest.THE_GRAND_TREE,
+ Arrays.asList(
+ SkillLevelTask.builder().skill(Skill.AGILITY).level(25).build()
+ )
+ );
+
+ REQUIREMENT_MAP.put(
+ Quest.THE_GREAT_BRAIN_ROBBERY,
+ Arrays.asList(
+ QuestTask.builder().quest(Quest.CABIN_FEVER).build(),
+ SkillLevelTask.builder().skill(Skill.PRAYER).level(50).build(),
+ SkillLevelTask.builder().skill(Skill.CONSTRUCTION).level(30).build(),
+ SkillLevelTask.builder().skill(Skill.COOKING).level(31).build(),
+ SkillLevelTask.builder().skill(Skill.CRAFTING).level(45).build()
+ )
+ );
+
+ REQUIREMENT_MAP.put(
+ Quest.HAUNTED_MINE,
+ Arrays.asList(
+ SkillLevelTask.builder().skill(Skill.AGILITY).level(15).build(),
+ SkillLevelTask.builder().skill(Skill.CRAFTING).level(35).build()
+ )
+ );
+
+ REQUIREMENT_MAP.put(
+ Quest.HEROES_QUEST,
+ Arrays.asList(
+ QuestTask.builder().quest(Quest.SHIELD_OF_ARRAV).build(),
+ QuestTask.builder().quest(Quest.DRAGON_SLAYER_I).build(),
+ QuestTask.builder().quest(Quest.MERLINS_CRYSTAL).build(),
+ QuestTask.builder().quest(Quest.LOST_CITY).build(),
+ SkillLevelTask.builder().skill(Skill.COOKING).level(53).build(),
+ SkillLevelTask.builder().skill(Skill.HERBLORE).level(25).build(),
+ SkillLevelTask.builder().skill(Skill.FISHING).level(53).build(),
+ SkillLevelTask.builder().skill(Skill.AGILITY).level(25).build(),
+ SkillLevelTask.builder().skill(Skill.MINING).level(50).build()
+ )
+ );
+
+ REQUIREMENT_MAP.put(
+ Quest.HORROR_FROM_THE_DEEP,
+ Arrays.asList(
+ SkillLevelTask.builder().skill(Skill.AGILITY).level(35).build()
+ )
+ );
+
+ REQUIREMENT_MAP.put(
+ Quest.KINGS_RANSOM,
+ Arrays.asList(
+ QuestTask.builder().quest(Quest.BLACK_KNIGHTS_FORTRESS).build(),
+ QuestTask.builder().quest(Quest.HOLY_GRAIL).build(),
+ QuestTask.builder().quest(Quest.MURDER_MYSTERY).build(),
+ SkillLevelTask.builder().skill(Skill.MAGIC).level(65).build(),
+ SkillLevelTask.builder().skill(Skill.DEFENCE).level(45).build(),
+ SkillLevelTask.builder().skill(Skill.AGILITY).level(30).build(),
+ SkillLevelTask.builder().skill(Skill.HERBLORE).level(18).build(),
+ SkillLevelTask.builder().skill(Skill.SMITHING).level(45).build()
+ )
+ );
+
+ REQUIREMENT_MAP.put(
+ Quest.LOST_CITY,
+ Arrays.asList(
+ SkillLevelTask.builder().skill(Skill.CRAFTING).level(31).build(),
+ SkillLevelTask.builder().skill(Skill.WOODCUTTING).level(36).build()
+ )
+ );
+
+ REQUIREMENT_MAP.put(
+ Quest.LUNAR_DIPLOMACY,
+ Arrays.asList(
+ QuestTask.builder().quest(Quest.THE_FREMENNIK_TRIALS).build(),
+ SkillLevelTask.builder().skill(Skill.WOODCUTTING).level(55).build(),
+ SkillLevelTask.builder().skill(Skill.MINING).level(60).build(),
+ SkillLevelTask.builder().skill(Skill.HERBLORE).level(5).build(),
+ SkillLevelTask.builder().skill(Skill.FIREMAKING).level(49).build(),
+ SkillLevelTask.builder().skill(Skill.DEFENCE).level(40).build(),
+ SkillLevelTask.builder().skill(Skill.CRAFTING).level(61).build(),
+ SkillLevelTask.builder().skill(Skill.AGILITY).level(32).build()
+ )
+ );
+
+ REQUIREMENT_MAP.put(
+ Quest.ONE_SMALL_FAVOUR,
+ Arrays.asList(
+ SkillLevelTask.builder().skill(Skill.AGILITY).level(36).build(),
+ SkillLevelTask.builder().skill(Skill.CRAFTING).level(25).build(),
+ SkillLevelTask.builder().skill(Skill.HERBLORE).level(18).build(),
+ SkillLevelTask.builder().skill(Skill.SMITHING).level(30).build()
+ )
+ );
+
+ REQUIREMENT_MAP.put(
+ Quest.ROYAL_TROUBLE,
+ Arrays.asList(
+ QuestTask.builder().quest(Quest.THRONE_OF_MISCELLANIA).build(),
+ SkillLevelTask.builder().skill(Skill.AGILITY).level(40).build(),
+ SkillLevelTask.builder().skill(Skill.SLAYER).level(40).build(),
+ SkillLevelTask.builder().skill(Skill.FARMING).level(53).build()
+ )
+ );
+
+ REQUIREMENT_MAP.put(
+ Quest.RUM_DEAL,
+ Arrays.asList(
+ QuestTask.builder().quest(Quest.ZOGRE_FLESH_EATERS).build(),
+ SkillLevelTask.builder().skill(Skill.CRAFTING).level(30).build(),
+ SkillLevelTask.builder().skill(Skill.PRAYER).level(47).build(),
+ SkillLevelTask.builder().skill(Skill.FISHING).level(50).build(),
+ SkillLevelTask.builder().skill(Skill.FARMING).level(40).build()
+ )
+ );
+
+ REQUIREMENT_MAP.put(
+ Quest.SHILO_VILLAGE,
+ Arrays.asList(
+ QuestTask.builder().quest(Quest.JUNGLE_POTION).build(),
+ SkillLevelTask.builder().skill(Skill.AGILITY).level(32).build(),
+ SkillLevelTask.builder().skill(Skill.CRAFTING).level(20).build()
+ )
+ );
+
+ REQUIREMENT_MAP.put(
+ Quest.TEMPLE_OF_IKOV,
+ Arrays.asList(
+ SkillLevelTask.builder().skill(Skill.RANGED).level(40).build(),
+ SkillLevelTask.builder().skill(Skill.THIEVING).level(42).build()
+ )
+ );
+
+ REQUIREMENT_MAP.put(
+ Quest.THRONE_OF_MISCELLANIA,
+ Arrays.asList(
+ QuestTask.builder().quest(Quest.HEROES_QUEST).build(),
+ SkillLevelTask.builder().skill(Skill.COOKING).level(45).build(),
+ SkillLevelTask.builder().skill(Skill.AGILITY).level(40).build()
+ )
+ );
+
+ REQUIREMENT_MAP.put(
+ Quest.TROLL_ROMANCE,
+ Arrays.asList(
+ QuestTask.builder().quest(Quest.TROLL_STRONGHOLD).build(),
+ SkillLevelTask.builder().skill(Skill.AGILITY).level(28).build()
+ )
+ );
+
+ REQUIREMENT_MAP.put(
+ Quest.TROLL_STRONGHOLD,
+ Arrays.asList(
+ QuestTask.builder().quest(Quest.DEATH_PLATEAU).build(),
+ SkillLevelTask.builder().skill(Skill.AGILITY).level(15).build()
+ )
+ );
+
+ REQUIREMENT_MAP.put(
+ Quest.UNDERGROUND_PASS,
+ Arrays.asList(
+ QuestTask.builder().quest(Quest.BIOHAZARD).build(),
+ SkillLevelTask.builder().skill(Skill.RANGED).level(25).build()
+ )
+ );
+
+ REQUIREMENT_MAP.put(
+ Quest.CONTACT,
+ Arrays.asList(
+ QuestTask.builder().quest(Quest.PRINCE_ALI_RESCUE).build(),
+ QuestTask.builder().quest(Quest.ICTHLARINS_LITTLE_HELPER).build()
+ )
+ );
+
+ REQUIREMENT_MAP.put(
+ Quest.DESERT_TREASURE_I,
+ Arrays.asList(
+ QuestTask.builder().quest(Quest.TEMPLE_OF_IKOV).build(),
+ QuestTask.builder().quest(Quest.THE_DIG_SITE).build(),
+ QuestTask.builder().quest(Quest.TROLL_STRONGHOLD).build(),
+ SkillLevelTask.builder().skill(Skill.THIEVING).level(53).build(),
+ SkillLevelTask.builder().skill(Skill.MAGIC).level(50).build(),
+ SkillLevelTask.builder().skill(Skill.SLAYER).level(10).build(),
+ SkillLevelTask.builder().skill(Skill.HERBLORE).level(10).build(),
+ SkillLevelTask.builder().skill(Skill.FIREMAKING).level(50).build()
+ )
+ );
+
+ REQUIREMENT_MAP.put(
+ Quest.DREAM_MENTOR,
+ Arrays.asList(
+ QuestTask.builder().quest(Quest.LUNAR_DIPLOMACY).build(),
+ SkillLevelTask.builder().skill(Skill.DEFENCE).level(85).build(),
+ SkillLevelTask.builder().skill(Skill.HITPOINTS).level(65).build(),
+ SkillLevelTask.builder().skill(Skill.WOODCUTTING).level(55).build(),
+ SkillLevelTask.builder().skill(Skill.MINING).level(60).build(),
+ SkillLevelTask.builder().skill(Skill.MAGIC).level(45).build(),
+ SkillLevelTask.builder().skill(Skill.CRAFTING).level(40).build(),
+ SkillLevelTask.builder().skill(Skill.SMITHING).level(49).build()
+ )
+ );
+
+ REQUIREMENT_MAP.put(
+ Quest.GRIM_TALES,
+ Arrays.asList(
+ SkillLevelTask.builder().skill(Skill.AGILITY).level(59).build(),
+ SkillLevelTask.builder().skill(Skill.HERBLORE).level(52).build(),
+ SkillLevelTask.builder().skill(Skill.THIEVING).level(45).build(),
+ SkillLevelTask.builder().skill(Skill.FISHING).level(58).build(),
+ SkillLevelTask.builder().skill(Skill.WOODCUTTING).level(71).build()
+ )
+ );
+
+ REQUIREMENT_MAP.put(
+ Quest.LEGENDS_QUEST,
+ Arrays.asList(
+ QuestTask.builder().quest(Quest.FAMILY_CREST).build(),
+ QuestTask.builder().quest(Quest.SHILO_VILLAGE).build(),
+ QuestTask.builder().quest(Quest.HEROES_QUEST).build(),
+ QuestTask.builder().quest(Quest.UNDERGROUND_PASS).build(),
+ SkillLevelTask.builder().skill(Skill.AGILITY).level(50).build(),
+ SkillLevelTask.builder().skill(Skill.THIEVING).level(50).build(),
+ SkillLevelTask.builder().skill(Skill.CRAFTING).level(50).build(),
+ SkillLevelTask.builder().skill(Skill.FISHING).level(50).build(),
+ SkillLevelTask.builder().skill(Skill.MINING).level(52).build(),
+ SkillLevelTask.builder().skill(Skill.MAGIC).level(50).build(),
+ SkillLevelTask.builder().skill(Skill.STRENGTH).level(50).build(),
+ SkillLevelTask.builder().skill(Skill.PRAYER).level(42).build(),
+ SkillLevelTask.builder().skill(Skill.WOODCUTTING).level(50).build()
+ )
+ );
+
+ REQUIREMENT_MAP.put(
+ Quest.MONKEY_MADNESS_I,
+ Arrays.asList(
+ QuestTask.builder().quest(Quest.THE_GRAND_TREE).build(),
+ QuestTask.builder().quest(Quest.TREE_GNOME_VILLAGE).build()
+ )
+ );
+
+ REQUIREMENT_MAP.put(
+ Quest.MOURNINGS_END_PART_I,
+ Arrays.asList(
+ QuestTask.builder().quest(Quest.ROVING_ELVES).build(),
+ SkillLevelTask.builder().skill(Skill.AGILITY).level(60).build(),
+ SkillLevelTask.builder().skill(Skill.THIEVING).level(50).build()
+ )
+ );
+
+ REQUIREMENT_MAP.put(
+ Quest.MOURNINGS_END_PART_II,
+ Arrays.asList(
+ QuestTask.builder().quest(Quest.MOURNINGS_END_PART_I).build(),
+ SkillLevelTask.builder().skill(Skill.AGILITY).level(60).build()
+ )
+ );
+
+ REQUIREMENT_MAP.put(
+ Quest.RECIPE_FOR_DISASTER,
+ Arrays.asList(
+ QuestTask.builder().quest(Quest.COOKS_ASSISTANT).build()
+ )
+ );
+
+ REQUIREMENT_MAP.put(
+ Quest.REGICIDE,
+ Arrays.asList(
+ QuestTask.builder().quest(Quest.UNDERGROUND_PASS).build(),
+ SkillLevelTask.builder().skill(Skill.AGILITY).level(56).build(),
+ SkillLevelTask.builder().skill(Skill.CRAFTING).level(10).build(),
+ SkillLevelTask.builder().skill(Skill.FLETCHING).level(25).build()
+ )
+ );
+
+ REQUIREMENT_MAP.put(
+ Quest.ROVING_ELVES,
+ Arrays.asList(
+ QuestTask.builder().quest(Quest.REGICIDE).build(),
+ SkillLevelTask.builder().skill(Skill.AGILITY).level(56).build(),
+ SkillLevelTask.builder().skill(Skill.HERBLORE).level(10).build(),
+ SkillLevelTask.builder().skill(Skill.RANGED).level(25).build()
+ )
+ );
+
+ REQUIREMENT_MAP.put(
+ Quest.SWAN_SONG,
+ Arrays.asList(
+ QuestTask.builder().quest(Quest.ONE_SMALL_FAVOUR).build(),
+ SkillLevelTask.builder().skill(Skill.WOODCUTTING).level(62).build(),
+ SkillLevelTask.builder().skill(Skill.COOKING).level(66).build(),
+ SkillLevelTask.builder().skill(Skill.CRAFTING).level(42).build(),
+ SkillLevelTask.builder().skill(Skill.SMITHING).level(45).build(),
+ SkillLevelTask.builder().skill(Skill.FISHING).level(62).build(),
+ SkillLevelTask.builder().skill(Skill.SLAYER).level(18).build()
+ )
+ );
+
+ REQUIREMENT_MAP.put(
+ Quest.DRAGON_SLAYER_II,
+ Arrays.asList(
+ QuestTask.builder().quest(Quest.LEGENDS_QUEST).build(),
+ QuestTask.builder().quest(Quest.A_TAIL_OF_TWO_CATS).build(),
+ QuestTask.builder().quest(Quest.DREAM_MENTOR).build(),
+ QuestTask.builder().quest(Quest.BONE_VOYAGE).build(),
+ SkillLevelTask.builder().skill(Skill.AGILITY).level(60).build(),
+ SkillLevelTask.builder().skill(Skill.CRAFTING).level(50).build(),
+ SkillLevelTask.builder().skill(Skill.MINING).level(60).build(),
+ SkillLevelTask.builder().skill(Skill.SMITHING).level(68).build(),
+ SkillLevelTask.builder().skill(Skill.HUNTER).level(60).build(),
+ SkillLevelTask.builder().skill(Skill.THIEVING).level(60).build(),
+ SkillLevelTask.builder().skill(Skill.MAGIC).level(75).build()
+ )
+ );
+
+ REQUIREMENT_MAP.put(
+ Quest.MONKEY_MADNESS_II,
+ Arrays.asList(
+ QuestTask.builder().quest(Quest.ENLIGHTENED_JOURNEY).build(),
+ QuestTask.builder().quest(Quest.THE_EYES_OF_GLOUPHRIE).build(),
+ QuestTask.builder().quest(Quest.THE_GRAND_TREE).build(),
+ SkillLevelTask.builder().skill(Skill.SLAYER).level(69).build(),
+ SkillLevelTask.builder().skill(Skill.HUNTER).level(60).build(),
+ SkillLevelTask.builder().skill(Skill.THIEVING).level(55).build(),
+ SkillLevelTask.builder().skill(Skill.AGILITY).level(70).build(),
+ SkillLevelTask.builder().skill(Skill.CRAFTING).level(70).build()
+ )
+ );
+
+ REQUIREMENT_MAP.put(
+ Quest.SONG_OF_THE_ELVES,
+ Arrays.asList(
+ QuestTask.builder().quest(Quest.MOURNINGS_END_PART_II).build(),
+ QuestTask.builder().quest(Quest.MAKING_HISTORY).build(),
+ SkillLevelTask.builder().skill(Skill.AGILITY).level(70).build(),
+ SkillLevelTask.builder().skill(Skill.CONSTRUCTION).level(70).build(),
+ SkillLevelTask.builder().skill(Skill.FARMING).level(70).build(),
+ SkillLevelTask.builder().skill(Skill.HERBLORE).level(70).build(),
+ SkillLevelTask.builder().skill(Skill.HUNTER).level(70).build(),
+ SkillLevelTask.builder().skill(Skill.MINING).level(70).build(),
+ SkillLevelTask.builder().skill(Skill.SMITHING).level(70).build(),
+ SkillLevelTask.builder().skill(Skill.WOODCUTTING).level(70).build()
+ )
+ );
+
+ REQUIREMENT_MAP.put(
+ Quest.SINS_OF_THE_FATHER,
+ Arrays.asList(
+ QuestTask.builder().quest(Quest.A_TASTE_OF_HOPE).build(),
+ SkillLevelTask.builder().skill(Skill.WOODCUTTING).level(62).build(),
+ SkillLevelTask.builder().skill(Skill.FLETCHING).level(60).build(),
+ SkillLevelTask.builder().skill(Skill.CRAFTING).level(56).build(),
+ SkillLevelTask.builder().skill(Skill.AGILITY).level(52).build(),
+ SkillLevelTask.builder().skill(Skill.ATTACK).level(50).build(),
+ SkillLevelTask.builder().skill(Skill.SLAYER).level(50).build(),
+ SkillLevelTask.builder().skill(Skill.MAGIC).level(49).build()
+ )
+ );
+
+ REQUIREMENT_MAP.put(
+ Quest.A_TASTE_OF_HOPE,
+ Arrays.asList(
+ QuestTask.builder().quest(Quest.DARKNESS_OF_HALLOWVALE).build()
+ )
+ );
+
+ REQUIREMENT_MAP.put(
+ Quest.A_KINGDOM_DIVIDED,
+ Arrays.asList(
+ QuestTask.builder().quest(Quest.CLIENT_OF_KOUREND).build(),
+ QuestTask.builder().quest(Quest.X_MARKS_THE_SPOT).build(),
+ QuestTask.builder().quest(Quest.THE_DEPTHS_OF_DESPAIR).build(),
+ QuestTask.builder().quest(Quest.THE_QUEEN_OF_THIEVES).build(),
+ QuestTask.builder().quest(Quest.THE_ASCENT_OF_ARCEUUS).build(),
+ QuestTask.builder().quest(Quest.THE_FORSAKEN_TOWER).build(),
+ QuestTask.builder().quest(Quest.TALE_OF_THE_RIGHTEOUS).build(),
+ SkillLevelTask.builder().skill(Skill.AGILITY).level(54).build(),
+ SkillLevelTask.builder().skill(Skill.THIEVING).level(52).build(),
+ SkillLevelTask.builder().skill(Skill.WOODCUTTING).level(52).build(),
+ SkillLevelTask.builder().skill(Skill.HERBLORE).level(50).build(),
+ SkillLevelTask.builder().skill(Skill.MINING).level(42).build(),
+ SkillLevelTask.builder().skill(Skill.CRAFTING).level(38).build(),
+ SkillLevelTask.builder().skill(Skill.MAGIC).level(35).build()
+ )
+ );
+
+ REQUIREMENT_MAP.put(
+ Quest.A_NIGHT_AT_THE_THEATRE,
+ Arrays.asList(
+ QuestTask.builder().quest(Quest.A_TASTE_OF_HOPE).build()
+ )
+ );
+
+ REQUIREMENT_MAP.put(
+ Quest.THE_FREMENNIK_EXILES,
+ Arrays.asList(
+ QuestTask.builder().quest(Quest.THE_FREMENNIK_ISLES).build(),
+ SkillLevelTask.builder().skill(Skill.CRAFTING).level(65).build(),
+ SkillLevelTask.builder().skill(Skill.SMITHING).level(60).build(),
+ SkillLevelTask.builder().skill(Skill.SLAYER).level(60).build(),
+ SkillLevelTask.builder().skill(Skill.FISHING).level(60).build(),
+ SkillLevelTask.builder().skill(Skill.RUNECRAFT).level(55).build()
+ )
+ );
+
+ REQUIREMENT_MAP.put(
+ Quest.BONE_VOYAGE,
+ Arrays.asList(
+ QuestTask.builder().quest(Quest.THE_DIG_SITE).build()
+ )
+ );
+
+ REQUIREMENT_MAP.put(
+ Quest.SLEEPING_GIANTS,
+ Arrays.asList(
+ SkillLevelTask.builder().skill(Skill.SMITHING).level(15).build()
+ )
+ );
+
+ REQUIREMENT_MAP.put(
+ Quest.TEMPLE_OF_THE_EYE,
+ Arrays.asList(
+ QuestTask.builder().quest(Quest.RUNE_MYSTERIES).build(),
+ SkillLevelTask.builder().skill(Skill.RUNECRAFT).level(10).build()
+ )
+ );
+
+ REQUIREMENT_MAP.put(
+ Quest.LAND_OF_THE_GOBLINS,
+ Arrays.asList(
+ QuestTask.builder().quest(Quest.ANOTHER_SLICE_OF_HAM).build(),
+ SkillLevelTask.builder().skill(Skill.PRAYER).level(30).build(),
+ SkillLevelTask.builder().skill(Skill.AGILITY).level(36).build(),
+ SkillLevelTask.builder().skill(Skill.THIEVING).level(36).build(),
+ SkillLevelTask.builder().skill(Skill.HERBLORE).level(37).build(),
+ SkillLevelTask.builder().skill(Skill.FISHING).level(36).build()
+ )
+ );
+
+ REQUIREMENT_MAP.put(
+ Quest.BENEATH_CURSED_SANDS,
+ Arrays.asList(
+ QuestTask.builder().quest(Quest.CONTACT).build(),
+ SkillLevelTask.builder().skill(Skill.AGILITY).level(62).build(),
+ SkillLevelTask.builder().skill(Skill.CRAFTING).level(55).build(),
+ SkillLevelTask.builder().skill(Skill.FIREMAKING).level(55).build()
+ )
+ );
+
+ REQUIREMENT_MAP.put(
+ Quest.SECRETS_OF_THE_NORTH,
+ Arrays.asList(
+ QuestTask.builder().quest(Quest.MAKING_HISTORY).build(),
+ QuestTask.builder().quest(Quest.HAZEEL_CULT).build(),
+ SkillLevelTask.builder().skill(Skill.AGILITY).level(69).build(),
+ SkillLevelTask.builder().skill(Skill.THIEVING).level(64).build(),
+ SkillLevelTask.builder().skill(Skill.HUNTER).level(56).build()
+ )
+ );
+
+ REQUIREMENT_MAP.put(
+ Quest.DESERT_TREASURE_II__THE_FALLEN_EMPIRE,
+ Arrays.asList(
+ QuestTask.builder().quest(Quest.DESERT_TREASURE_I).build(),
+ QuestTask.builder().quest(Quest.SECRETS_OF_THE_NORTH).build()
+ )
+ );
+
+ REQUIREMENT_MAP.put(
+ Quest.TWILIGHTS_PROMISE,
+ Arrays.asList(
+ QuestTask.builder().quest(Quest.CHILDREN_OF_THE_SUN).build()
+ )
+ );
+
+ REQUIREMENT_MAP.put(
+ Quest.AT_FIRST_LIGHT,
+ Arrays.asList(
+ QuestTask.builder().quest(Quest.TWILIGHTS_PROMISE).build()
+ )
+ );
+
+ REQUIREMENT_MAP.put(
+ Quest.PERILOUS_MOONS,
+ Arrays.asList(
+ QuestTask.builder().quest(Quest.TWILIGHTS_PROMISE).build()
+ )
+ );
+
+ REQUIREMENT_MAP.put(
+ Quest.THE_RIBBITING_TALE_OF_A_LILY_PAD_LABOUR_DISPUTE,
+ Arrays.asList(
+ QuestTask.builder().quest(Quest.TWILIGHTS_PROMISE).build()
+ )
+ );
+
+ REQUIREMENT_MAP.put(
+ Quest.THE_HEART_OF_DARKNESS,
+ Arrays.asList(
+ QuestTask.builder().quest(Quest.TWILIGHTS_PROMISE).build()
+ )
+ );
+
+ REQUIREMENT_MAP.put(
+ Quest.DEATH_ON_THE_ISLE,
+ Arrays.asList(
+ QuestTask.builder().quest(Quest.TWILIGHTS_PROMISE).build()
+ )
+ );
+
+ REQUIREMENT_MAP.put(
+ Quest.MEAT_AND_GREET,
+ Arrays.asList(
+ QuestTask.builder().quest(Quest.TWILIGHTS_PROMISE).build()
+ )
+ );
+
+ REQUIREMENT_MAP.put(
+ Quest.ETHICALLY_ACQUIRED_ANTIQUITIES,
+ Arrays.asList(
+ QuestTask.builder().quest(Quest.TWILIGHTS_PROMISE).build()
+ )
+ );
+
+ REQUIREMENT_MAP.put(
+ Quest.DEFENDER_OF_VARROCK,
+ Arrays.asList(
+ QuestTask.builder().quest(Quest.SHIELD_OF_ARRAV).build(),
+ QuestTask.builder().quest(Quest.ROMEO__JULIET).build(),
+ QuestTask.builder().quest(Quest.THE_KNIGHTS_SWORD).build()
+ )
+ );
+
+ REQUIREMENT_MAP.put(
+ Quest.THE_CURSE_OF_ARRAV,
+ Arrays.asList(
+ QuestTask.builder().quest(Quest.DEFENDER_OF_VARROCK).build()
+ )
+ );
+
+ REQUIREMENT_MAP.put(
+ Quest.WHILE_GUTHIX_SLEEPS,
+ Arrays.asList(
+ QuestTask.builder().quest(Quest.THE_CURSE_OF_ARRAV).build()
+ )
+ );
+
+ REQUIREMENT_MAP.put(
+ Quest.THE_FINAL_DAWN,
+ Arrays.asList(
+ QuestTask.builder().quest(Quest.THE_HEART_OF_DARKNESS).build(),
+ QuestTask.builder().quest(Quest.PERILOUS_MOONS).build(),
+ SkillLevelTask.builder().skill(Skill.THIEVING).level(66).build(),
+ SkillLevelTask.builder().skill(Skill.FLETCHING).level(52).build(),
+ SkillLevelTask.builder().skill(Skill.RUNECRAFT).level(52).build()
+ )
+ );
+
+ REQUIREMENT_MAP.put(
+ Quest.SHADOWS_OF_CUSTODIA,
+ Arrays.asList(
+ QuestTask.builder().quest(Quest.THE_FINAL_DAWN).build()
+ )
+ );
+
+ REQUIREMENT_MAP.put(
+ Quest.SCRAMBLED,
+ Arrays.asList(
+ QuestTask.builder().quest(Quest.THE_FINAL_DAWN).build()
+ )
+ );
+
+ REQUIREMENT_MAP.put(
+ Quest.AN_EXISTENTIAL_CRISIS,
+ Arrays.asList(
+ QuestTask.builder().quest(Quest.THE_FINAL_DAWN).build()
+ )
+ );
+
+ REQUIREMENT_MAP.put(
+ Quest.IMPENDING_CHAOS,
+ Arrays.asList(
+ QuestTask.builder().quest(Quest.THE_FINAL_DAWN).build()
+ )
+ );
+
+ REQUIREMENT_MAP.put(
+ Quest.VALE_TOTEMS,
+ Arrays.asList(
+ QuestTask.builder().quest(Quest.THE_FINAL_DAWN).build()
+ )
+ );
+ }
+
+ /**
+ * Get all requirements for a quest, including recursive sub-quest requirements.
+ * @param quest The quest to get requirements for.
+ * @param indentLevel The indentation level for the tasks (0 for main quest).
+ * @return List of tasks (quest and skill requirements) with appropriate indentation.
+ */
+ public static List getRequirements(Quest quest, int indentLevel)
+ {
+ List tasks = new ArrayList<>();
+ if (indentLevel >= 3) {
+ // Respect Task.java's max indent level of 3
+ return tasks;
+ }
+
+ List directRequirements = REQUIREMENT_MAP.getOrDefault(quest, Collections.emptyList());
+ for (Task task : directRequirements) {
+ Task withIndent = copyWithIndent(task, indentLevel + 1);
+ tasks.add(withIndent);
+
+ if (withIndent instanceof QuestTask) {
+ Quest subQuest = ((QuestTask) withIndent).getQuest();
+ List subRequirements = getRequirements(subQuest, indentLevel + 1);
+ tasks.addAll(subRequirements);
+ }
+ }
+
+ return tasks;
+ }
+
+ private static Task copyWithIndent(Task task, int indentLevel)
+ {
+ if (task instanceof QuestTask) {
+ QuestTask qt = (QuestTask) task;
+ return QuestTask.builder()
+ .quest(qt.getQuest())
+ .indentLevel(indentLevel)
+ .build();
+ }
+ if (task instanceof SkillLevelTask) {
+ SkillLevelTask st = (SkillLevelTask) task;
+ return SkillLevelTask.builder()
+ .skill(st.getSkill())
+ .level(st.getLevel())
+ .indentLevel(indentLevel)
+ .build();
+ }
+ // Fallback: set indent on the same instance (least preferable, but safe for unknown subclasses)
+ task.setIndentLevel(indentLevel);
+ return task;
+ }
+}
\ No newline at end of file
diff --git a/src/test/java/com/toofifty/goaltracker/utils/QuestRequirementsTest.java b/src/test/java/com/toofifty/goaltracker/utils/QuestRequirementsTest.java
new file mode 100644
index 0000000..8d5fec6
--- /dev/null
+++ b/src/test/java/com/toofifty/goaltracker/utils/QuestRequirementsTest.java
@@ -0,0 +1,56 @@
+package com.toofifty.goaltracker.utils;
+
+import com.toofifty.goaltracker.models.task.QuestTask;
+import com.toofifty.goaltracker.models.task.SkillLevelTask;
+import com.toofifty.goaltracker.models.task.Task;
+import net.runelite.api.Quest;
+import net.runelite.api.Skill;
+import org.junit.jupiter.api.Test;
+
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+public class QuestRequirementsTest
+{
+ @Test
+ void testGetRequirements()
+ {
+ List requirements = QuestRequirements.getRequirements(Quest.FAIRYTALE_I__GROWING_PAINS, 0);
+ assertNotNull(requirements, "Requirements list should not be null");
+ assertEquals(3, requirements.size(), "Fairytale I should have 3 direct requirements");
+
+ // All items should be indented one level when called with indentLevel=0
+ requirements.forEach(t -> assertEquals(1, t.getIndentLevel(), "Each requirement should have indent 1"));
+
+ // Expect two quest tasks and one skill requirement
+ long questCount = requirements.stream().filter(t -> t instanceof QuestTask).count();
+ long skillCount = requirements.stream().filter(t -> t instanceof SkillLevelTask).count();
+ assertEquals(2, questCount, "Should contain two QuestTask requirements");
+ assertEquals(1, skillCount, "Should contain one SkillLevelTask requirement");
+
+ // Verify specific quests are present
+ boolean hasLostCity = requirements.stream().anyMatch(t -> t instanceof QuestTask && ((QuestTask) t).getQuest() == Quest.LOST_CITY);
+ boolean hasNatureSpirit = requirements.stream().anyMatch(t -> t instanceof QuestTask && ((QuestTask) t).getQuest() == Quest.NATURE_SPIRIT);
+ assertTrue(hasLostCity, "Lost City should be a requirement");
+ assertTrue(hasNatureSpirit, "Nature Spirit should be a requirement");
+
+ // Verify specific skill requirement (Farming 18)
+ boolean hasFarming18 = requirements.stream().anyMatch(t -> t instanceof SkillLevelTask && ((SkillLevelTask) t).getSkill() == Skill.FARMING && ((SkillLevelTask) t).getLevel() == 18);
+ assertTrue(hasFarming18, "Farming 18 should be a requirement");
+ }
+
+ @Test
+ void testRequirementsAreFreshInstances()
+ {
+ List first = QuestRequirements.getRequirements(Quest.FAIRYTALE_I__GROWING_PAINS, 0);
+ List second = QuestRequirements.getRequirements(Quest.FAIRYTALE_I__GROWING_PAINS, 0);
+
+ assertEquals(first.size(), second.size(), "Both calls should return the same number of items");
+ for (int i = 0; i < first.size(); i++) {
+ assertNotSame(first.get(i), second.get(i), "Each requirement should be a fresh instance on each call");
+ assertEquals(first.get(i).getClass(), second.get(i).getClass(), "Classes should match between calls");
+ assertEquals(first.get(i).getIndentLevel(), second.get(i).getIndentLevel(), "Indent levels should match between calls");
+ }
+ }
+}
\ No newline at end of file