diff --git a/README.md b/README.md
index ae30a12..2e7ce9d 100644
--- a/README.md
+++ b/README.md
@@ -1,62 +1,30 @@
-# Runelite Goal Tracker Plugin
+# 🏆 Goal Tracker
-Keep track of your OSRS goals and complete them automatically.
+> A RuneLite plugin that helps you plan, track, and achieve your Old School RuneScape goals with ease.
+> Goal Tracker provides presets, quest requirements, progress bars, and a modern UI to make managing your tasks and goals simple and efficient.
-## Features
+---
+## Plugin Features
-- Track different types of tasks
- - Manual tasks
- - Skill tasks
- - Quests
- - Item tasks
-- Organise tasks lists into goals
-- Reorder and manage goal and task lists
-- Chat notification on task completion
+- Preset Goal Lists for quick setup of common goals
+- Pin important goals to the top of the Home panel
+- Automatically add prerequisites for quests
+- Track progress with visual progress bars
+- Import and Export goals via JSON for easy sharing or backups
+- Create and manage custom goals with tasks
-### Planned
+## Screenshots
-- More task types
- - Achievement diaries
- - Minigame rewards
- - Kourend favour
- - NPC kills
+- **Home panel with goal cards** — shows progress bars, presets, and export/import buttons.
+
-Suggestions are welcome - please submit an issue :)
+- **Inside a goal with task list** — displays quest prerequisites, right-click task options, item search, and a quest selector.
+
-## Usage
+- **Right‑click menu on a goal card** — options to move, pin, mark complete, or delete.
+
-### Goals
+- **Add from preset** — includes built-in options for Barrows gear, Void armor, and Ironman progression.
+
-Goals are lists of tasks, and at a glance provide a quick way to view your progress towards the goal.
-
-
-
-You can add a new goal with the "+ Add goal" button, and you can reorder/remove goals using right click. Clicking a goal will show the tasks within:
-
-
-
-From here, you can add tasks to the goal.
-
-### Adding tasks
-
-
-
-#### Manual tasks
-
-Basically a simple to-do list item. You can add these via the "Quick add" text box.
-
-You can toggle them on and off manually just by clicking them.
-
-Use the "+ More options" button to reveal the automatic task options.
-
-#### Skill level/XP tasks
-
-Use these tasks to automatically track skill progress. Just select a skill, and the desired level or XP amount. The task will automatically complete once you hit that level/xp.
-
-#### Quest tasks
-
-Track quest progress and completion, just select a quest or miniquest from the dropdown. Will also display in progress quests as orange.
-
-#### Item tasks
-
-Select an item using the search button and searching via the in-game chatbox, then set the desired quantity. The plugin will keep track of your items and tally up quantities stored in different inventories (bank, player, GIMP storage), and will be automatically completed once you get that amount of the item.
+-----
\ No newline at end of file
diff --git a/build.gradle b/build.gradle
index b48403e..cd134de 100644
--- a/build.gradle
+++ b/build.gradle
@@ -3,6 +3,12 @@ plugins {
id 'jacoco'
}
+java {
+ toolchain {
+ languageVersion = JavaLanguageVersion.of(11)
+ }
+}
+
repositories {
mavenLocal()
maven {
@@ -11,7 +17,7 @@ repositories {
mavenCentral()
}
-def runeLiteVersion = 'latest.release'
+def runeLiteVersion = findProperty('rlVersion') ?: 'latest.release'
dependencies {
compileOnly "net.runelite:client:${runeLiteVersion}"
@@ -19,6 +25,8 @@ dependencies {
compileOnly 'org.projectlombok:lombok:1.18.20'
annotationProcessor 'org.projectlombok:lombok:1.18.20'
+ implementation 'org.apache.commons:commons-lang3:3.12.0'
+
testImplementation 'org.junit.jupiter:junit-jupiter-api:5.5.2'
testImplementation 'org.junit.jupiter:junit-jupiter-params:5.5.2'
testImplementation 'org.junit.jupiter:junit-jupiter-engine:5.5.2'
@@ -31,16 +39,15 @@ dependencies {
group = 'com.toofifty.goaltracker'
version = '1.0.4'
-sourceCompatibility = '11'
-targetCompatibility = JavaVersion.VERSION_11
tasks.withType(JavaCompile) {
options.encoding = 'UTF-8'
+ options.release = 11
}
tasks.test {
useJUnitPlatform()
-
+
finalizedBy jacocoTestReport // report is always generated after tests run
}
@@ -50,4 +57,9 @@ jacocoTestReport {
reports {
xml.required = true
}
-}
\ No newline at end of file
+}
+
+tasks.withType(Jar) {
+ preserveFileTimestamps = false
+ reproducibleFileOrder = true
+}
diff --git a/changelog.md b/changelog.md
new file mode 100644
index 0000000..b01556e
--- /dev/null
+++ b/changelog.md
@@ -0,0 +1,56 @@
+# AhDoozy's Changelog
+Please leave for my sanity. thanks :)
+
+### Added
+- Item task auto-detection across inventory, equipment, bank, seed vault, and group storage.
+- Equivalent item detection for Barrows gear: degraded versions (100/75/50/25/0) now treated as base items.
+- New All Barrows Gear preset including every Ahrim’s, Dharok’s, Guthan’s, Karil’s, Torag’s, and Verac’s item.
+- Quest prerequisites button: each quest task has an **Add prereqs** option to insert requirements beneath it.
+- Shift+Click removal to delete a task and its children.
+- Completion cascading: parent quest completion toggles propagate to children.
+- Dropdown quest selector with natural quest names.
+- Right-click **Add prereqs** for quest tasks with automatic insertion and indentation.
+- Right-click menu to mark all child tasks complete or incomplete.
+- Manual completion toggling for preset tasks.
+- Configurable color setting for completion chat messages.
+- Export and import goals to JSON with file filtering and full UI refresh.
+- Preset Goal Lists with an “Add from Preset…” button.
+- Full Void Armor, Free-to-Play Quests, and Fast Travel Unlocks presets.
+- Expanded and reworked Early, Mid, and Late Ironman presets.
+- Inline editing support for goal titles and manual task descriptions.
+- Ellipsized titles with tooltips on hover for goal cards and task rows.
+
+### Changed
+- Quest detection stabilized and debounced with scheduled UI refresh to prevent login lag.
+- Task and goal panels auto-refresh after quest or item detection without requiring navigation.
+- Chat completion messages rewritten as proper `GAMEMESSAGE`s with color customization support.
+- UI polish including a more compact prerequisites button, consistent ActionBar styling, unified headers, and improved readability.
+- Right-click context menus reorganized with a grouped **Move** submenu and cleaner option labels.
+- Search input redesigned to toggle open/close behavior.
+- Preset prerequisite expansion now prevents duplicate additions.
+- Task rows updated with consistent styling, icon warming, and inline editing capabilities.
+- Home panel buttons stacked vertically; header divider thickness increased for clarity.
+- Goal card typography fixed with reserved progress text width to prevent clipping.
+- Home panel placeholder updated to use wrapping text; empty text now displays cleanly without clipping.
+- Export to file now prompts before overwriting existing files.
+- Import from file now prompts with Overwrite / Merge / Cancel options instead of silently replacing goals.
+- Home panel no longer forces alphabetical order; manual Move actions now persist with pinned-first grouping only.
+
+### Fixed
+- Fixed chat completion messages: now appear reliably as `GAMEMESSAGE`s with configured colors; raw `
` tags removed.
+- Shield of Arrav preset partner-finding step properly indented as a subtask.
+- Fixed sidebar and panels not refreshing after quest or item detection (no longer requires re-entering a goal or relogging).
+- Fixed Home and Goal panel refresh issues, including blank panels, stale data, and child task refresh not propagating.
+- Fixed item icons failing to appear until goal entry; now preload on startup, login, and after JSON import.
+- Fixed Export and Import buttons (now fully functional).
+- Fixed Undo/Redo buttons: restricted to Goal view and visual overlaps resolved.
+- Fixed mouse and keyboard input handling for selection, copy/paste, and shortcuts.
+- Fixed empty goals remaining in list; now automatically removed if left without tasks.
+- Fixed overlapping Export button layout issue.
+- Fixed quest tasks not showing correct status on login until entering the goal.
+- Fixed card rename editor stretching beyond panel width; editor now constrained to card body.
+- Fixed inconsistent rename activation; description text is read-only unless rename explicitly invoked.
+- Added right-click Rename option with bounded editor, commit/cancel, and ESC cancel support.
+- Fixed duplicate Pin/Unpin menu items by centralizing handling in ListItemPanel.
+- Fixed unpinning a card not refreshing the Home panel immediately; Home panel now rebuilds/reorders on pin state toggle.
+- Fixed Move right-click options (Up/Down/Top/Bottom) not changing card position; moves now reorder cards properly.
diff --git a/gradlew.bat b/gradlew.bat
index ac1b06f..107acd3 100644
--- a/gradlew.bat
+++ b/gradlew.bat
@@ -1,89 +1,89 @@
-@rem
-@rem Copyright 2015 the original author or authors.
-@rem
-@rem Licensed under the Apache License, Version 2.0 (the "License");
-@rem you may not use this file except in compliance with the License.
-@rem You may obtain a copy of the License at
-@rem
-@rem https://www.apache.org/licenses/LICENSE-2.0
-@rem
-@rem Unless required by applicable law or agreed to in writing, software
-@rem distributed under the License is distributed on an "AS IS" BASIS,
-@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-@rem See the License for the specific language governing permissions and
-@rem limitations under the License.
-@rem
-
-@if "%DEBUG%" == "" @echo off
-@rem ##########################################################################
-@rem
-@rem Gradle startup script for Windows
-@rem
-@rem ##########################################################################
-
-@rem Set local scope for the variables with windows NT shell
-if "%OS%"=="Windows_NT" setlocal
-
-set DIRNAME=%~dp0
-if "%DIRNAME%" == "" set DIRNAME=.
-set APP_BASE_NAME=%~n0
-set APP_HOME=%DIRNAME%
-
-@rem Resolve any "." and ".." in APP_HOME to make it shorter.
-for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
-
-@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
-set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
-
-@rem Find java.exe
-if defined JAVA_HOME goto findJavaFromJavaHome
-
-set JAVA_EXE=java.exe
-%JAVA_EXE% -version >NUL 2>&1
-if "%ERRORLEVEL%" == "0" goto execute
-
-echo.
-echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
-echo.
-echo Please set the JAVA_HOME variable in your environment to match the
-echo location of your Java installation.
-
-goto fail
-
-:findJavaFromJavaHome
-set JAVA_HOME=%JAVA_HOME:"=%
-set JAVA_EXE=%JAVA_HOME%/bin/java.exe
-
-if exist "%JAVA_EXE%" goto execute
-
-echo.
-echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
-echo.
-echo Please set the JAVA_HOME variable in your environment to match the
-echo location of your Java installation.
-
-goto fail
-
-:execute
-@rem Setup the command line
-
-set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
-
-
-@rem Execute Gradle
-"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
-
-:end
-@rem End local scope for the variables with windows NT shell
-if "%ERRORLEVEL%"=="0" goto mainEnd
-
-:fail
-rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
-rem the _cmd.exe /c_ return code!
-if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
-exit /b 1
-
-:mainEnd
-if "%OS%"=="Windows_NT" endlocal
-
-:omega
+@rem
+@rem Copyright 2015 the original author or authors.
+@rem
+@rem Licensed under the Apache License, Version 2.0 (the "License");
+@rem you may not use this file except in compliance with the License.
+@rem You may obtain a copy of the License at
+@rem
+@rem https://www.apache.org/licenses/LICENSE-2.0
+@rem
+@rem Unless required by applicable law or agreed to in writing, software
+@rem distributed under the License is distributed on an "AS IS" BASIS,
+@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+@rem See the License for the specific language governing permissions and
+@rem limitations under the License.
+@rem
+
+@if "%DEBUG%" == "" @echo off
+@rem ##########################################################################
+@rem
+@rem Gradle startup script for Windows
+@rem
+@rem ##########################################################################
+
+@rem Set local scope for the variables with windows NT shell
+if "%OS%"=="Windows_NT" setlocal
+
+set DIRNAME=%~dp0
+if "%DIRNAME%" == "" set DIRNAME=.
+set APP_BASE_NAME=%~n0
+set APP_HOME=%DIRNAME%
+
+@rem Resolve any "." and ".." in APP_HOME to make it shorter.
+for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
+
+@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
+
+@rem Find java.exe
+if defined JAVA_HOME goto findJavaFromJavaHome
+
+set JAVA_EXE=java.exe
+%JAVA_EXE% -version >NUL 2>&1
+if "%ERRORLEVEL%" == "0" goto execute
+
+echo.
+echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
+echo.
+echo Please set the JAVA_HOME variable in your environment to match the
+echo location of your Java installation.
+
+goto fail
+
+:findJavaFromJavaHome
+set JAVA_HOME=%JAVA_HOME:"=%
+set JAVA_EXE=%JAVA_HOME%/bin/java.exe
+
+if exist "%JAVA_EXE%" goto execute
+
+echo.
+echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
+echo.
+echo Please set the JAVA_HOME variable in your environment to match the
+echo location of your Java installation.
+
+goto fail
+
+:execute
+@rem Setup the command line
+
+set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
+
+
+@rem Execute Gradle
+"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
+
+:end
+@rem End local scope for the variables with windows NT shell
+if "%ERRORLEVEL%"=="0" goto mainEnd
+
+:fail
+rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
+rem the _cmd.exe /c_ return code!
+if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
+exit /b 1
+
+:mainEnd
+if "%OS%"=="Windows_NT" endlocal
+
+:omega
diff --git a/img/add_from_preset.jpg b/img/add_from_preset.jpg
new file mode 100644
index 0000000..6210af5
Binary files /dev/null and b/img/add_from_preset.jpg differ
diff --git a/img/goal_task_panel.jpg b/img/goal_task_panel.jpg
new file mode 100644
index 0000000..aa0854a
Binary files /dev/null and b/img/goal_task_panel.jpg differ
diff --git a/img/home_panel_new.jpg b/img/home_panel_new.jpg
new file mode 100644
index 0000000..de19ef9
Binary files /dev/null and b/img/home_panel_new.jpg differ
diff --git a/img/right_click_goals.jpg b/img/right_click_goals.jpg
new file mode 100644
index 0000000..084468d
Binary files /dev/null and b/img/right_click_goals.jpg differ
diff --git a/runelite-plugin.properties b/runelite-plugin.properties
index 677c9ac..81ba3a4 100644
--- a/runelite-plugin.properties
+++ b/runelite-plugin.properties
@@ -1,6 +1,7 @@
displayName=Goal Tracker
author=Toofifty
support=https://github.com/Toofifty/rl-goal-tracker
-description=Keep track of your goals and complete them automatically
-tags=task,to-do,todo
+description=Track and organize RuneScape goals with progress bars, presets, and more.
+tags=task,to-do,todo,Track,organize,progress
plugins=com.toofifty.goaltracker.GoalTrackerPlugin
+version=2.0.0
diff --git a/src/main/java/com/toofifty/goaltracker/GoalManager.java b/src/main/java/com/toofifty/goaltracker/GoalManager.java
index 790bf2f..b62a0a9 100644
--- a/src/main/java/com/toofifty/goaltracker/GoalManager.java
+++ b/src/main/java/com/toofifty/goaltracker/GoalManager.java
@@ -17,7 +17,11 @@
@Slf4j
@Singleton
-public class GoalManager
+/**
+ * Central manager for plugin goals.
+ * Handles create/load/save, JSON import/export, and listener notifications.
+ */
+public final class GoalManager
{
@Inject
private GoalTrackerConfig config;
@@ -28,6 +32,8 @@ public class GoalManager
@Getter
private final ReorderableList goals = new ReorderableList<>();
+ private final List goalsChangedListeners = new ArrayList<>();
+
public Goal createGoal()
{
Goal goal = Goal.builder().build();
@@ -35,39 +41,107 @@ public Goal createGoal()
return goal;
}
+ /**
+ * Append a batch of goals and persist + notify listeners.
+ */
+ public void addGoals(List newGoals)
+ {
+ if (newGoals == null || newGoals.isEmpty())
+ {
+ return;
+ }
+ goals.addAll(newGoals);
+ save();
+ }
+
@SuppressWarnings("unchecked")
- public List getTasksByTypeAndAnyStatus(TaskType type, Status ...statuses)
+ public List getTasksByTypeAndAnyStatus(TaskType type, Status... statuses)
{
List tasks = new ArrayList<>();
- for (Goal goal : goals) {
+ for (Goal goal : goals)
+ {
tasks.addAll((List) goal.getTasks().stream()
- .filter((task) -> task.getType() == type && Arrays.stream(statuses).anyMatch((status) -> status == task.getStatus()))
- .collect(Collectors.toList())
- );
+ .filter(task -> task.getType() == type && Arrays.stream(statuses).anyMatch(status -> status == task.getStatus()))
+ .collect(Collectors.toList()));
}
return tasks;
}
- public List getIncompleteTasksByType(TaskType type) {
+ public List getIncompleteTasksByType(TaskType type)
+ {
return this.getTasksByTypeAndAnyStatus(type, Status.NOT_STARTED, Status.IN_PROGRESS);
}
public void save()
{
config.goalTrackerData(goalSerializer.serialize(goals));
-
log.info("Saved " + goals.size() + " goals");
+ notifyGoalsChanged();
}
public void load()
{
- try {
+ try
+ {
this.goals.clear();
this.goals.addAll(goalSerializer.deserialize(config.goalTrackerData()));
-
+ notifyGoalsChanged();
log.info("Loaded " + this.goals.size() + " goals");
- } catch (Exception e) {
- log.error("Failed to load goals!");
+ }
+ catch (Exception e)
+ {
+ log.error("Failed to load goals!", e);
+ }
+ }
+ /**
+ * Return the current goals as JSON for export.
+ * @param pretty pretty-print output
+ */
+ public String exportJson(boolean pretty)
+ {
+ return goalSerializer.serialize(goals, pretty);
+ }
+
+ public void importJson(String json)
+ {
+ // Backwards-compatible default: overwrite existing goals
+ importJson(json, true);
+ }
+
+ /**
+ * Import goals from JSON, optionally merging with existing goals.
+ * @param json the JSON payload
+ * @param overwrite if true, clears existing goals first; if false, appends imported goals
+ */
+ public void importJson(String json, boolean overwrite)
+ {
+ try
+ {
+ if (overwrite)
+ {
+ this.goals.clear();
+ }
+ this.goals.addAll(goalSerializer.deserialize(json));
+ save();
+ }
+ catch (Exception e)
+ {
+ log.error("Failed to import goals!", e);
+ }
+ }
+ public void addGoalsChangedListener(Runnable listener)
+ {
+ if (listener != null && !goalsChangedListeners.contains(listener))
+ {
+ goalsChangedListeners.add(listener);
+ }
+ }
+
+ private void notifyGoalsChanged()
+ {
+ for (Runnable r : goalsChangedListeners)
+ {
+ try { r.run(); } catch (Exception ignored) {}
}
}
-}
+}
\ No newline at end of file
diff --git a/src/main/java/com/toofifty/goaltracker/GoalSerializer.java b/src/main/java/com/toofifty/goaltracker/GoalSerializer.java
index 2f7d973..5a71b27 100644
--- a/src/main/java/com/toofifty/goaltracker/GoalSerializer.java
+++ b/src/main/java/com/toofifty/goaltracker/GoalSerializer.java
@@ -11,11 +11,14 @@
import net.runelite.api.Quest;
import net.runelite.api.Skill;
-import java.util.List;
-
import javax.inject.Inject;
+import java.util.List;
-public class GoalSerializer
+/**
+ * Handles JSON serialization and deserialization of Goals.
+ * Uses custom adapters for Task, Skill, and Quest types with consistent field naming.
+ */
+public final class GoalSerializer
{
@Inject
private Gson gson;
diff --git a/src/main/java/com/toofifty/goaltracker/GoalTrackerConfig.java b/src/main/java/com/toofifty/goaltracker/GoalTrackerConfig.java
index ad07313..5f06591 100644
--- a/src/main/java/com/toofifty/goaltracker/GoalTrackerConfig.java
+++ b/src/main/java/com/toofifty/goaltracker/GoalTrackerConfig.java
@@ -1,9 +1,16 @@
package com.toofifty.goaltracker;
+import net.runelite.client.config.Alpha;
import net.runelite.client.config.Config;
import net.runelite.client.config.ConfigGroup;
import net.runelite.client.config.ConfigItem;
+import java.awt.*;
+
+/**
+ * RuneLite config group for Goal Tracker plugin.
+ * Stores serialized goals, item cache data, and completion message color.
+ */
@ConfigGroup("goaltracker")
public interface GoalTrackerConfig extends Config
{
@@ -33,4 +40,15 @@ default String goalTrackerItemNoteMapCache()
@ConfigItem(keyName = "goalTrackerItemNoteMapCache", name = "", description = "", hidden = true)
void goalTrackerItemNoteMapCache(String str);
+
+ @ConfigItem(
+ keyName = "completionMessageColor",
+ name = "Completion Message Color",
+ description = "Color of the chat message when a goal is completed"
+ )
+ @Alpha
+ default Color completionMessageColor()
+ {
+ return new Color(0xF227A509, true);
+ }
}
diff --git a/src/main/java/com/toofifty/goaltracker/GoalTrackerPlugin.java b/src/main/java/com/toofifty/goaltracker/GoalTrackerPlugin.java
index 21c110e..7c6ad35 100644
--- a/src/main/java/com/toofifty/goaltracker/GoalTrackerPlugin.java
+++ b/src/main/java/com/toofifty/goaltracker/GoalTrackerPlugin.java
@@ -1,8 +1,12 @@
package com.toofifty.goaltracker;
+
import com.google.inject.Provides;
import com.toofifty.goaltracker.models.enums.TaskType;
-import com.toofifty.goaltracker.models.task.*;
+import com.toofifty.goaltracker.models.task.ItemTask;
+import com.toofifty.goaltracker.models.task.QuestTask;
+import com.toofifty.goaltracker.models.task.SkillLevelTask;
+import com.toofifty.goaltracker.models.task.Task;
import com.toofifty.goaltracker.services.TaskIconService;
import com.toofifty.goaltracker.services.TaskUpdateService;
import com.toofifty.goaltracker.ui.GoalTrackerPanel;
@@ -10,9 +14,11 @@
import lombok.Setter;
import lombok.extern.slf4j.Slf4j;
import net.runelite.api.*;
-import net.runelite.api.events.*;
+import net.runelite.api.events.GameStateChanged;
+import net.runelite.api.events.ItemContainerChanged;
+import net.runelite.api.events.StatChanged;
+import net.runelite.api.events.VarbitChanged;
import net.runelite.client.callback.ClientThread;
-import net.runelite.client.chat.ChatMessageBuilder;
import net.runelite.client.chat.ChatMessageManager;
import net.runelite.client.chat.QueuedMessage;
import net.runelite.client.config.ConfigManager;
@@ -21,27 +27,34 @@
import net.runelite.client.game.ItemManager;
import net.runelite.client.game.SkillIconManager;
import net.runelite.client.game.chatbox.ChatboxItemSearch;
+import net.runelite.client.game.chatbox.ChatboxPanelManager;
import net.runelite.client.plugins.Plugin;
import net.runelite.client.plugins.PluginDescriptor;
import net.runelite.client.ui.ClientToolbar;
-import net.runelite.client.ui.ColorScheme;
import net.runelite.client.ui.NavigationButton;
import net.runelite.client.util.AsyncBufferedImage;
+import net.runelite.client.util.ColorUtil;
import javax.inject.Inject;
+import javax.swing.*;
+import java.awt.*;
import java.util.List;
-import java.util.stream.IntStream;
@Slf4j
@PluginDescriptor(name = "Goal Tracker", description = "Keep track of your goals and complete them automatically")
-public class GoalTrackerPlugin extends Plugin
+/**
+ * Main entry point for the Goal Tracker plugin.
+ * Handles lifecycle (startup/shutdown), UI registration, and listens for
+ * RuneLite events to update tasks and goals automatically.
+ */
+public final class GoalTrackerPlugin extends Plugin
{
public static final int[] PLAYER_INVENTORIES = {
- InventoryID.INVENTORY.getId(),
- InventoryID.EQUIPMENT.getId(),
- InventoryID.BANK.getId(),
- InventoryID.SEED_VAULT.getId(),
- InventoryID.GROUP_STORAGE.getId()
+ InventoryID.INVENTORY.getId(),
+ InventoryID.EQUIPMENT.getId(),
+ InventoryID.BANK.getId(),
+ InventoryID.SEED_VAULT.getId(),
+ InventoryID.GROUP_STORAGE.getId()
};
@Getter
@@ -60,6 +73,10 @@ public class GoalTrackerPlugin extends Plugin
@Inject
private ChatboxItemSearch itemSearch;
+ @Getter
+ @Inject
+ private ChatboxPanelManager chatboxPanelManager;
+
@Inject
private ClientToolbar clientToolbar;
@@ -102,25 +119,114 @@ public class GoalTrackerPlugin extends Plugin
@Setter
private boolean validateAll = true;
+ private boolean warmedIcons = false;
+
+ // Debounced UI refresh timer (coalesces many varbit changes into one repaint)
+ private Timer uiRefreshTimer;
+
+ private void notifyTask(Task task)
+ {
+ if (task == null) { return; }
+
+ try {
+ // Use existing config color for chat prefix
+ final Color chosen = config.completionMessageColor();
+ final String prefix = ColorUtil.wrapWithColorTag("Goal Tracker", chosen);
+
+ final String msg = prefix + ": Completed task — " + task;
+
+ chatMessageManager.queue(
+ QueuedMessage.builder()
+ .type(ChatMessageType.GAMEMESSAGE)
+ .runeLiteFormattedMessage(msg)
+ .build()
+ );
+ }
+ catch (Exception ex) {
+ log.warn("notifyTask failed", ex);
+ }
+ }
+
+ /**
+ * Schedule a debounced refresh of the sidebar panel. If a refresh is already
+ * scheduled, it will be replaced with the new delay. This prevents spammy
+ * repainting during rapid varbit changes.
+ */
+ private void schedulePanelRefresh(final int delayMs)
+ {
+ if (goalTrackerPanel == null)
+ {
+ return;
+ }
+ if (uiRefreshTimer != null && uiRefreshTimer.isRunning())
+ {
+ uiRefreshTimer.stop();
+ }
+ uiRefreshTimer = new Timer(delayMs, e -> SwingUtilities.invokeLater(goalTrackerPanel::refresh));
+ uiRefreshTimer.setRepeats(false);
+ uiRefreshTimer.start();
+ }
+
+ /**
+ * Preloads commonly used item icons so they render instantly in the UI.
+ */
+ public void warmItemIcons()
+ {
+ try
+ {
+ // Example: warm up the TODO_LIST icon at minimum
+ itemManager.getImage(ItemID.TODO_LIST);
+
+ // Warm up skill icons
+ for (Skill skill : Skill.values())
+ {
+ skillIconManager.getSkillImage(skill);
+ }
+ }
+ catch (Exception ex)
+ {
+ log.warn("warmItemIcons failed", ex);
+ }
+ }
+
@Override
protected void startUp()
{
- goalManager.load();
- itemCache.load();
- goalTrackerPanel.home();
+ // Defensive guards to avoid NPEs during test bootstrap if DI bindings are missing
+ if (goalManager == null || itemCache == null || goalTrackerPanel == null || itemManager == null || clientToolbar == null)
+ {
+ log.warn("GoalTrackerPlugin: skipping full startup because a dependency was null. goalManager={}, itemCache={}, panel={}, itemManager={}, toolbar={}",
+ goalManager != null, itemCache != null, goalTrackerPanel != null, itemManager != null, clientToolbar != null);
+ return;
+ }
- final AsyncBufferedImage icon = itemManager.getImage(ItemID.TODO_LIST);
+ try {
+ goalManager.load();
+ itemCache.load();
+ } catch (Exception ex) {
+ log.error("GoalTrackerPlugin: failed to load persisted state", ex);
+ }
- icon.onLoaded(() -> {
- uiNavigationButton = NavigationButton.builder()
- .tooltip("Goal Tracker")
- .icon(icon)
- .priority(7)
- .panel(goalTrackerPanel)
- .build();
+ goalTrackerPanel.home();
- clientToolbar.addNavigation(uiNavigationButton);
- });
+ final AsyncBufferedImage icon = itemManager.getImage(ItemID.TODO_LIST);
+ if (icon == null)
+ {
+ log.warn("GoalTrackerPlugin: icon was null; skipping sidebar button creation");
+ }
+ else
+ {
+ icon.onLoaded(() -> {
+ uiNavigationButton = NavigationButton.builder()
+ .tooltip("Goal Tracker")
+ .icon(icon)
+ .priority(7)
+ .panel(goalTrackerPanel)
+ .build();
+
+ clientToolbar.addNavigation(uiNavigationButton);
+ });
+ }
goalTrackerPanel.onGoalUpdated((goal) -> goalManager.save());
goalTrackerPanel.onTaskAdded((task) -> {
@@ -135,18 +241,31 @@ protected void startUp()
goalManager.save();
});
goalTrackerPanel.onTaskUpdated((task) -> goalManager.save());
+
+ // Preload item icons at plugin startup so they are visible immediately
+ warmItemIcons();
+ warmedIcons = true; // avoid re-warming on first login tick
}
@Override
protected void shutDown()
{
- clientToolbar.removeNavigation(uiNavigationButton);
+ if (uiNavigationButton != null)
+ {
+ clientToolbar.removeNavigation(uiNavigationButton);
+ uiNavigationButton = null;
+ }
}
@Subscribe
public void onSessionOpen(SessionOpen event)
{
- goalManager.load();
+ if (goalManager != null) {
+ try { goalManager.load(); } catch (Exception ex) { log.error("Failed to load goals on session open", ex); }
+ }
+ if (goalTrackerPanel != null) {
+ goalTrackerPanel.refresh();
+ }
}
@Subscribe
@@ -155,116 +274,223 @@ public void onStatChanged(StatChanged event)
List skillLevelTasks = goalManager.getIncompleteTasksByType(TaskType.SKILL_LEVEL);
for (SkillLevelTask task : skillLevelTasks) {
if (!taskUpdateService.update(task, event)) continue;
-
- if (task.getStatus().isCompleted()) {
- notifyTask(task);
- }
-
- uiStatusManager.refresh(task);
- this.goalManager.save();
- }
-
- List skillXpTasks = goalManager.getIncompleteTasksByType(TaskType.SKILL_XP);
- for (SkillXpTask task : skillXpTasks) {
- if (!taskUpdateService.update(task, event)) continue;
-
- if (task.getStatus().isCompleted()) {
- notifyTask(task);
- }
-
- uiStatusManager.refresh(task);
- this.goalManager.save();
}
}
- public void notifyTask(Task task)
+ @Subscribe
+ public void onGameStateChanged(GameStateChanged event)
{
- if (client.getGameState() != GameState.LOGGED_IN || task.isNotified()) return;
-
- log.debug("Notify: [Goal Tracker] You have completed a task: " + task + "!");
+ if (event.getGameState() == GameState.LOGGED_IN)
+ {
+ // Re-check quest tasks after login
+ clientThread.invokeLater(() -> refreshQuestTasks());
- String message = "[Goal Tracker] You have completed a task: " + task + "!";
- String formattedMessage = new ChatMessageBuilder().append(ColorScheme.PROGRESS_COMPLETE_COLOR, message).build();
- chatMessageManager.queue(QueuedMessage.builder()
- .type(ChatMessageType.CONSOLE)
- .name("Goal Tracker")
- .runeLiteFormattedMessage(formattedMessage)
- .build());
-
- task.setNotified(true);
+ // Refresh the panel once, 10s after login, after detection settles
+ schedulePanelRefresh(10_000);
+ }
}
@Subscribe
- public void onGameStateChanged(GameStateChanged event)
+ public void onVarbitChanged(VarbitChanged event)
{
- if (client.getGameState() != GameState.LOGGED_IN) {
- return;
- }
+ // Quest progress often updates via varbits/varps
+ clientThread.invokeLater(() -> refreshQuestTasks());
- // redo the login check on the next game tick
- validateAll = true;
+ // Debounce UI refresh during rapid quest varbit updates
+ schedulePanelRefresh(750);
}
@Subscribe
- public void onGameTick(GameTick event)
+ public void onItemContainerChanged(ItemContainerChanged event)
{
- if (!validateAll) {
+ // Only react to the player's inventory/equipment/bank-like containers
+ if (!isPlayerInventoryContainer(event.getContainerId()))
+ {
return;
}
- if (client.getGameState() != GameState.LOGGED_IN) {
- return;
+
+ List itemTasks = goalManager.getIncompleteTasksByType(TaskType.ITEM);
+ for (ItemTask task : itemTasks)
+ {
+ final int itemId = task.getItemId();
+ if (itemId <= 0)
+ {
+ continue;
+ }
+
+ final int count = countHeldEquivalent(itemId, task.getItemName());
+ final boolean changed = task.recomputeFromCount(count);
+ if (!changed)
+ {
+ continue;
+ }
+
+ uiStatusManager.refresh(task);
+ if (task.getStatus().isCompleted())
+ {
+ notifyTask(task);
+ }
}
- validateAll = false;
- // perform a full refresh just once on login
- // onGameStateChanged reports incorrect quest statuses,
- // so this need to be done in this subscriber
- goalTrackerPanel.refresh();
+ // Debounce panel refresh; coalesce multiple rapid inventory changes
+ schedulePanelRefresh(400);
}
- @Subscribe
- public void onChatMessage(ChatMessage event)
+ private static boolean isPlayerInventoryContainer(int containerId)
{
- if (event.getType() != ChatMessageType.GAMEMESSAGE || !event.getMessage().contains("Quest complete")) return;
-
- List questTasks = goalManager.getIncompleteTasksByType(TaskType.QUEST);
- for (QuestTask task : questTasks) {
- if (!taskUpdateService.update(task)) continue;
-
- if (task.getStatus().isCompleted()) {
- notifyTask(task);
+ for (int id : PLAYER_INVENTORIES)
+ {
+ if (id == containerId)
+ {
+ return true;
}
+ }
+ return false;
+ }
- uiStatusManager.refresh(task);
- this.goalManager.save();
+ private int countHeld(final int itemId)
+ {
+ int total = 0;
+ final ItemContainer inv = client.getItemContainer(InventoryID.INVENTORY);
+ if (inv != null)
+ {
+ for (Item i : inv.getItems())
+ {
+ if (i != null && i.getId() == itemId)
+ {
+ total += Math.max(1, i.getQuantity());
+ }
+ }
+ }
+ final ItemContainer equip = client.getItemContainer(InventoryID.EQUIPMENT);
+ if (equip != null)
+ {
+ for (Item i : equip.getItems())
+ {
+ if (i != null && i.getId() == itemId)
+ {
+ total += Math.max(1, i.getQuantity());
+ }
+ }
+ }
+ final ItemContainer bank = client.getItemContainer(InventoryID.BANK);
+ if (bank != null)
+ {
+ for (Item i : bank.getItems())
+ {
+ if (i != null && i.getId() == itemId)
+ {
+ total += Math.max(1, i.getQuantity());
+ }
+ }
+ }
+ final ItemContainer seedVault = client.getItemContainer(InventoryID.SEED_VAULT);
+ if (seedVault != null)
+ {
+ for (Item i : seedVault.getItems())
+ {
+ if (i != null && i.getId() == itemId)
+ {
+ total += Math.max(1, i.getQuantity());
+ }
+ }
}
+ final ItemContainer groupStorage = client.getItemContainer(InventoryID.GROUP_STORAGE);
+ if (groupStorage != null)
+ {
+ for (Item i : groupStorage.getItems())
+ {
+ if (i != null && i.getId() == itemId)
+ {
+ total += Math.max(1, i.getQuantity());
+ }
+ }
+ }
+ return total;
}
- @Subscribe
- public void onItemContainerChanged(ItemContainerChanged event)
+ private int countHeldEquivalent(final int targetItemId, final String targetItemName)
{
- if (IntStream.of(GoalTrackerPlugin.PLAYER_INVENTORIES).noneMatch((id) -> id == event.getContainerId())) return;
+ // Fallback to ID-only counting if we don't have a name
+ if (targetItemName == null || targetItemName.isEmpty() || itemManager == null)
+ {
+ return countHeld(targetItemId);
+ }
- itemCache.update(event.getContainerId(), event.getItemContainer().getItems());
+ final String baseName = normalizeBarrowsName(targetItemName);
+ int total = 0;
- List itemTasks = goalManager.getIncompleteTasksByType(TaskType.ITEM);
- for (ItemTask task : itemTasks) {
- if (!taskUpdateService.update(task)) continue;
+ total += countContainerByName(InventoryID.INVENTORY, baseName);
+ total += countContainerByName(InventoryID.EQUIPMENT, baseName);
+ total += countContainerByName(InventoryID.BANK, baseName);
+ total += countContainerByName(InventoryID.SEED_VAULT, baseName);
+ total += countContainerByName(InventoryID.GROUP_STORAGE, baseName);
- if (task.getStatus().isCompleted()) {
- notifyTask(task);
+ // Include the exact base ID too, in case some pieces don't use numeric suffixes
+ total += countHeld(targetItemId);
+ return total;
+ }
+
+ private int countContainerByName(final InventoryID id, final String baseName)
+ {
+ final ItemContainer c = client.getItemContainer(id);
+ if (c == null)
+ {
+ return 0;
+ }
+ int subtotal = 0;
+ for (Item i : c.getItems())
+ {
+ if (i == null)
+ {
+ continue;
+ }
+ try
+ {
+ final String name = normalizeBarrowsName(itemManager.getItemComposition(i.getId()).getName());
+ if (name.equals(baseName))
+ {
+ subtotal += Math.max(1, i.getQuantity());
+ }
}
+ catch (Exception ignored) { }
+ }
+ return subtotal;
+ }
- // always refresh item tasks, since the acquired
- // count could have changed
- uiStatusManager.refresh(task);
- this.goalManager.save();
+ /**
+ * Normalize Barrows item names so that degraded variants (e.g., "Torag's platelegs 100/75/50/25/0")
+ * all map to the same base string (e.g., "Torag's platelegs").
+ */
+ private static String normalizeBarrowsName(final String raw)
+ {
+ if (raw == null)
+ {
+ return "";
}
+ // Strip trailing space+digits (e.g., " 100") and collapse double spaces
+ String s = raw.replaceAll("\\s[0-9]{1,3}$", "").trim();
+ s = s.replaceAll("\\s+", " ");
+ return s;
}
+ private void refreshQuestTasks()
+ {
+ if (goalManager == null || client == null) return;
+ List questTasks = goalManager.getIncompleteTasksByType(TaskType.QUEST);
+ for (QuestTask task : questTasks)
+ {
+ task.refreshStatus(client);
+ uiStatusManager.refresh(task);
+ if (task.getStatus().isCompleted())
+ {
+ notifyTask(task);
+ }
+ }
+ }
@Provides
- GoalTrackerConfig getGoalTrackerConfig(ConfigManager configManager)
+ public GoalTrackerConfig provideConfig(ConfigManager configManager)
{
return configManager.getConfig(GoalTrackerConfig.class);
}
-}
+}
\ No newline at end of file
diff --git a/src/main/java/com/toofifty/goaltracker/ItemCache.java b/src/main/java/com/toofifty/goaltracker/ItemCache.java
index 1f7ccf8..3833178 100644
--- a/src/main/java/com/toofifty/goaltracker/ItemCache.java
+++ b/src/main/java/com/toofifty/goaltracker/ItemCache.java
@@ -2,16 +2,21 @@
import com.google.gson.Gson;
import com.google.gson.reflect.TypeToken;
-import java.util.HashMap;
-import java.util.Map;
-import javax.inject.Inject;
-import javax.inject.Singleton;
import net.runelite.api.Client;
import net.runelite.api.Item;
import net.runelite.client.game.ItemManager;
+import javax.inject.Inject;
+import javax.inject.Singleton;
+import java.util.HashMap;
+import java.util.Map;
+
@Singleton
-public class ItemCache
+/**
+ * Tracks player inventory contents across sessions.
+ * Persists item totals and note-link mappings for offline use.
+ */
+public final class ItemCache
{
private Map inventories;
private Map itemTotals;
diff --git a/src/main/java/com/toofifty/goaltracker/TaskUIStatusManager.java b/src/main/java/com/toofifty/goaltracker/TaskUIStatusManager.java
index bb743a6..ae7e881 100644
--- a/src/main/java/com/toofifty/goaltracker/TaskUIStatusManager.java
+++ b/src/main/java/com/toofifty/goaltracker/TaskUIStatusManager.java
@@ -11,7 +11,11 @@
@Slf4j
@Singleton
-public class TaskUIStatusManager
+/**
+ * Keeps track of UI refresh callbacks for tasks and goals.
+ * Ensures updates run on the Swing event dispatch thread.
+ */
+public final class TaskUIStatusManager
{
private final Map taskRefreshers = new HashMap<>();
private final Map goalRefreshers = new HashMap<>();
diff --git a/src/main/java/com/toofifty/goaltracker/adapters/QuestAdapter.java b/src/main/java/com/toofifty/goaltracker/adapters/QuestAdapter.java
index cb5f375..133c2da 100644
--- a/src/main/java/com/toofifty/goaltracker/adapters/QuestAdapter.java
+++ b/src/main/java/com/toofifty/goaltracker/adapters/QuestAdapter.java
@@ -6,7 +6,11 @@
import java.lang.reflect.Type;
import java.util.Arrays;
-public class QuestAdapter implements JsonSerializer, JsonDeserializer {
+/**
+ * Gson adapter for RuneLite Quests.
+ * Serializes/deserializes quests by their unique ID.
+ */
+public final class QuestAdapter implements JsonSerializer, JsonDeserializer {
@Override
public JsonElement serialize(Quest src, Type typeOfSrc, JsonSerializationContext context) {
diff --git a/src/main/java/com/toofifty/goaltracker/adapters/SkillAdapter.java b/src/main/java/com/toofifty/goaltracker/adapters/SkillAdapter.java
index 0e801f1..d3ff145 100644
--- a/src/main/java/com/toofifty/goaltracker/adapters/SkillAdapter.java
+++ b/src/main/java/com/toofifty/goaltracker/adapters/SkillAdapter.java
@@ -6,7 +6,11 @@
import java.lang.reflect.Type;
import java.util.Arrays;
-public class SkillAdapter implements JsonSerializer, JsonDeserializer {
+/**
+ * Gson adapter for RuneLite Skills.
+ * Serializes/deserializes skills by their display name.
+ */
+public final class SkillAdapter implements JsonSerializer, JsonDeserializer {
@Override
public JsonElement serialize(Skill src, Type typeOfSrc, JsonSerializationContext context) {
return context.serialize(src.getName());
diff --git a/src/main/java/com/toofifty/goaltracker/adapters/TaskAdapter.java b/src/main/java/com/toofifty/goaltracker/adapters/TaskAdapter.java
index d17c9f0..94b74eb 100644
--- a/src/main/java/com/toofifty/goaltracker/adapters/TaskAdapter.java
+++ b/src/main/java/com/toofifty/goaltracker/adapters/TaskAdapter.java
@@ -7,8 +7,12 @@
import java.lang.reflect.Type;
+/**
+ * Gson adapter for polymorphic Task serialization.
+ * Adds a "type" field and deserializes into the correct Task subclass.
+ */
@Slf4j
-public class TaskAdapter implements JsonSerializer, JsonDeserializer {
+public final class TaskAdapter implements JsonSerializer, JsonDeserializer {
@Override
public JsonElement serialize(Task src, Type typeOfSrc, JsonSerializationContext context) {
@@ -19,7 +23,7 @@ public JsonElement serialize(Task src, Type typeOfSrc, JsonSerializationContext
@Override
public Task deserialize(JsonElement json, Type typeOfT,
- JsonDeserializationContext context) throws JsonParseException {
+ JsonDeserializationContext context) throws JsonParseException {
TaskType taskType = TaskType.fromString(json.getAsJsonObject().get("type").getAsString());
switch (taskType) {
case SKILL_XP:
diff --git a/src/main/java/com/toofifty/goaltracker/models/ActionHistory.java b/src/main/java/com/toofifty/goaltracker/models/ActionHistory.java
new file mode 100644
index 0000000..ede8fc4
--- /dev/null
+++ b/src/main/java/com/toofifty/goaltracker/models/ActionHistory.java
@@ -0,0 +1,68 @@
+package com.toofifty.goaltracker.models;
+
+import java.util.ArrayDeque;
+import java.util.Deque;
+import java.util.Objects;
+
+/**
+ * Simple undo/redo stack for user actions.
+ * Push new actions with {@link #push(Action)}; call {@link #undo()} / {@link #redo()} from UI.
+ */
+public final class ActionHistory
+{
+ public interface Action
+ {
+ void undo();
+ void redo();
+ }
+
+ private final Deque undoStack = new ArrayDeque<>();
+ private final Deque redoStack = new ArrayDeque<>();
+
+ /** Push a new action and clear the redo history. */
+ public void push(final Action action)
+ {
+ Objects.requireNonNull(action, "action");
+ undoStack.addLast(action);
+ redoStack.clear();
+ }
+
+ /** Undo the most recent action, if any. */
+ public void undo()
+ {
+ final Action action = undoStack.pollLast();
+ if (action != null)
+ {
+ action.undo();
+ redoStack.addLast(action);
+ }
+ }
+
+ /** Redo the most recently undone action, if any. */
+ public void redo()
+ {
+ final Action action = redoStack.pollLast();
+ if (action != null)
+ {
+ action.redo();
+ undoStack.addLast(action);
+ }
+ }
+
+ public boolean hasUndo()
+ {
+ return !undoStack.isEmpty();
+ }
+
+ public boolean hasRedo()
+ {
+ return !redoStack.isEmpty();
+ }
+
+ /** Clear both undo and redo history. */
+ public void clear()
+ {
+ undoStack.clear();
+ redoStack.clear();
+ }
+}
diff --git a/src/main/java/com/toofifty/goaltracker/models/Goal.java b/src/main/java/com/toofifty/goaltracker/models/Goal.java
index 72f6952..bcfcf66 100644
--- a/src/main/java/com/toofifty/goaltracker/models/Goal.java
+++ b/src/main/java/com/toofifty/goaltracker/models/Goal.java
@@ -1,9 +1,9 @@
package com.toofifty.goaltracker.models;
import com.google.gson.annotations.SerializedName;
-import com.toofifty.goaltracker.utils.ReorderableList;
import com.toofifty.goaltracker.models.enums.Status;
import com.toofifty.goaltracker.models.task.Task;
+import com.toofifty.goaltracker.utils.ReorderableList;
import lombok.Builder;
import lombok.Getter;
import lombok.Setter;
@@ -14,10 +14,14 @@
import java.util.function.Predicate;
import java.util.stream.Collectors;
+/**
+ * Represents a player goal containing one or more tasks.
+ * Provides helpers for status aggregation and task updates.
+ */
@Setter
@Getter
@SuperBuilder
-public class Goal
+public final class Goal
{
@Builder.Default
private String description = "New goal";
@@ -25,36 +29,57 @@ public class Goal
@Builder.Default
private int displayOrder = -1;
+ @Builder.Default
+ private boolean pinned = false;
+
@SerializedName("items")
@Builder.Default
private ReorderableList tasks = new ReorderableList<>();
private List filterBy(Predicate predicate)
{
- return this.getTasks().stream().filter(predicate).collect(Collectors.toList());
+ return tasks.stream().filter(predicate).collect(Collectors.toList());
}
- public boolean isStatus(Status status) {
- return this.getTasks().stream().allMatch((task) -> task.getStatus() == status);
+ /** True if all tasks are of the given status. */
+ public boolean isStatus(Status status)
+ {
+ return tasks.stream().allMatch(task -> task.getStatus() == status);
}
- public boolean isAnyStatus(Status ...statuses) {
- return this.getTasks().stream().anyMatch((task) -> Arrays.stream(statuses).anyMatch((status) -> status == task.getStatus()));
+ /** True if any task matches one of the given statuses. */
+ public boolean isAnyStatus(Status... statuses)
+ {
+ return tasks.stream().anyMatch(task ->
+ Arrays.stream(statuses).anyMatch(s -> s == task.getStatus()));
}
- public List getComplete() {
- return this.filterBy(Task::isDone);
+ /** List of completed tasks. */
+ public List getComplete()
+ {
+ return filterBy(Task::isDone);
}
- public Status getStatus() {
- if (this.isStatus(Status.COMPLETED)) {
+ /** Aggregated status of this goal. */
+ public Status getStatus()
+ {
+ if (isStatus(Status.COMPLETED))
+ {
return Status.COMPLETED;
}
-
- if (this.isAnyStatus(Status.IN_PROGRESS, Status.COMPLETED)) {
+ if (isAnyStatus(Status.IN_PROGRESS, Status.COMPLETED))
+ {
return Status.IN_PROGRESS;
}
-
return Status.NOT_STARTED;
}
+
+ /** Mark all tasks as complete or not started. */
+ public void setAllTasksCompleted(boolean completed)
+ {
+ for (Task task : tasks)
+ {
+ task.setStatus(completed ? Status.COMPLETED : Status.NOT_STARTED);
+ }
+ }
}
diff --git a/src/main/java/com/toofifty/goaltracker/models/IndentChangeAction.java b/src/main/java/com/toofifty/goaltracker/models/IndentChangeAction.java
new file mode 100644
index 0000000..d4da20a
--- /dev/null
+++ b/src/main/java/com/toofifty/goaltracker/models/IndentChangeAction.java
@@ -0,0 +1,37 @@
+package com.toofifty.goaltracker.models;
+
+import com.toofifty.goaltracker.models.task.Task;
+
+import java.util.List;
+
+/**
+ * Action for indenting or outdenting a task in a list.
+ * Stores old and new indent levels for undo/redo.
+ */
+public final class IndentChangeAction implements ActionHistory.Action
+{
+ private final List tasks;
+ private final Task task;
+ private final int oldIndent;
+ private final int newIndent;
+
+ public IndentChangeAction(List tasks, Task task, int oldIndent, int newIndent)
+ {
+ this.tasks = tasks;
+ this.task = task;
+ this.oldIndent = oldIndent;
+ this.newIndent = newIndent;
+ }
+
+ @Override
+ public void undo()
+ {
+ task.setIndentLevel(oldIndent);
+ }
+
+ @Override
+ public void redo()
+ {
+ task.setIndentLevel(newIndent);
+ }
+}
diff --git a/src/main/java/com/toofifty/goaltracker/models/RemoveTaskAction.java b/src/main/java/com/toofifty/goaltracker/models/RemoveTaskAction.java
new file mode 100644
index 0000000..24a57e1
--- /dev/null
+++ b/src/main/java/com/toofifty/goaltracker/models/RemoveTaskAction.java
@@ -0,0 +1,36 @@
+package com.toofifty.goaltracker.models;
+
+import com.toofifty.goaltracker.models.task.Task;
+
+import java.util.List;
+
+/**
+ * Action for removing a task from a list.
+ * Stores the original index for correct undo placement.
+ */
+public final class RemoveTaskAction implements ActionHistory.Action
+{
+ private final List tasks;
+ private final Task task;
+ private final int index;
+
+ public RemoveTaskAction(List tasks, Task task, int index)
+ {
+ this.tasks = tasks;
+ this.task = task;
+ this.index = index;
+ }
+
+ @Override
+ public void undo()
+ {
+ int insertIndex = Math.max(0, Math.min(index, tasks.size()));
+ tasks.add(insertIndex, task);
+ }
+
+ @Override
+ public void redo()
+ {
+ tasks.remove(task);
+ }
+}
diff --git a/src/main/java/com/toofifty/goaltracker/models/ReorderTaskAction.java b/src/main/java/com/toofifty/goaltracker/models/ReorderTaskAction.java
new file mode 100644
index 0000000..1211518
--- /dev/null
+++ b/src/main/java/com/toofifty/goaltracker/models/ReorderTaskAction.java
@@ -0,0 +1,41 @@
+package com.toofifty.goaltracker.models;
+
+import com.toofifty.goaltracker.models.task.Task;
+
+import java.util.List;
+
+/**
+ * Action for reordering a task within a list (move up/down).
+ * Stores old and new indices for undo/redo.
+ */
+public final class ReorderTaskAction implements ActionHistory.Action
+{
+ private final List tasks;
+ private final Task task;
+ private final int oldIndex;
+ private final int newIndex;
+
+ public ReorderTaskAction(List tasks, Task task, int oldIndex, int newIndex)
+ {
+ this.tasks = tasks;
+ this.task = task;
+ this.oldIndex = oldIndex;
+ this.newIndex = newIndex;
+ }
+
+ @Override
+ public void undo()
+ {
+ tasks.remove(task);
+ int insertIndex = Math.max(0, Math.min(oldIndex, tasks.size()));
+ tasks.add(insertIndex, task);
+ }
+
+ @Override
+ public void redo()
+ {
+ tasks.remove(task);
+ int insertIndex = Math.max(0, Math.min(newIndex, tasks.size()));
+ tasks.add(insertIndex, task);
+ }
+}
diff --git a/src/main/java/com/toofifty/goaltracker/models/ToggleCompleteAction.java b/src/main/java/com/toofifty/goaltracker/models/ToggleCompleteAction.java
new file mode 100644
index 0000000..d608794
--- /dev/null
+++ b/src/main/java/com/toofifty/goaltracker/models/ToggleCompleteAction.java
@@ -0,0 +1,57 @@
+package com.toofifty.goaltracker.models;
+
+import com.toofifty.goaltracker.models.task.Task;
+
+import java.lang.reflect.Method;
+
+/**
+ * Action for toggling a task's completion state.
+ * Uses reflection to avoid a hard dependency on the Status enum.
+ */
+public final class ToggleCompleteAction implements ActionHistory.Action
+{
+ private final Task task;
+ private final boolean oldValue;
+ private final boolean newValue;
+
+ public ToggleCompleteAction(Task task, boolean oldValue, boolean newValue)
+ {
+ this.task = task;
+ this.oldValue = oldValue;
+ this.newValue = newValue;
+ }
+
+ @Override
+ public void undo()
+ {
+ setStatusByName(oldValue ? "COMPLETED" : "NOT_STARTED");
+ }
+
+ @Override
+ public void redo()
+ {
+ setStatusByName(newValue ? "COMPLETED" : "NOT_STARTED");
+ }
+
+ /**
+ * Set task status by enum name without importing the enum type.
+ * This works whether Status is a nested enum or a top-level type.
+ */
+ private void setStatusByName(String name)
+ {
+ try
+ {
+ Object current = task.getStatus();
+ Class> enumClass = current.getClass();
+ @SuppressWarnings({"unchecked","rawtypes"})
+ Enum newStatus = Enum.valueOf((Class) enumClass, name);
+
+ Method m = task.getClass().getMethod("setStatus", enumClass);
+ m.invoke(task, newStatus);
+ }
+ catch (Exception ignored)
+ {
+ // If we can't reflectively set it, ignore rather than crash.
+ }
+ }
+}
diff --git a/src/main/java/com/toofifty/goaltracker/models/UndoStack.java b/src/main/java/com/toofifty/goaltracker/models/UndoStack.java
new file mode 100644
index 0000000..d5df322
--- /dev/null
+++ b/src/main/java/com/toofifty/goaltracker/models/UndoStack.java
@@ -0,0 +1,93 @@
+package com.toofifty.goaltracker.models;
+
+import java.util.ArrayDeque;
+import java.util.Deque;
+import java.util.Objects;
+
+/**
+ * Generic undo/redo stack for removed items (e.g., Goals, Tasks).
+ * Stores the removed item and its original index for correct restoration.
+ */
+public final class UndoStack
+{
+ public static final class RemovedEntry
+ {
+ private final T item;
+ private final int index;
+
+ public RemovedEntry(T item, int index)
+ {
+ this.item = Objects.requireNonNull(item, "item");
+ this.index = index;
+ }
+
+ public T getItem()
+ {
+ return item;
+ }
+
+ public int getIndex()
+ {
+ return index;
+ }
+ }
+
+ private final Deque> undo = new ArrayDeque<>();
+ private final Deque> redo = new ArrayDeque<>();
+
+ /** Record a removal and clear redo history. */
+ public void pushRemove(T item, int index)
+ {
+ undo.addLast(new RemovedEntry<>(item, index));
+ redo.clear();
+ }
+
+ /**
+ * Prepare to UNDO the most-recent removal.
+ *
+ * Moves the entry from the undo stack to the redo stack and returns it.
+ * Caller should re-insert the item at {@code getIndex()}.
+ */
+ public RemovedEntry popForUndo()
+ {
+ RemovedEntry e = undo.pollLast();
+ if (e != null)
+ {
+ redo.addLast(e);
+ }
+ return e;
+ }
+
+ /**
+ * Prepare to REDO the most-recent undo (i.e., remove again).
+ *
+ * Moves the entry from the redo stack back to the undo stack and returns it.
+ * Caller should remove the item again.
+ */
+ public RemovedEntry popForRedo()
+ {
+ RemovedEntry e = redo.pollLast();
+ if (e != null)
+ {
+ undo.addLast(e);
+ }
+ return e;
+ }
+
+ public boolean hasUndo()
+ {
+ return !undo.isEmpty();
+ }
+
+ public boolean hasRedo()
+ {
+ return !redo.isEmpty();
+ }
+
+ /** Clears all history. */
+ public void clear()
+ {
+ undo.clear();
+ redo.clear();
+ }
+}
diff --git a/src/main/java/com/toofifty/goaltracker/models/enums/Status.java b/src/main/java/com/toofifty/goaltracker/models/enums/Status.java
index c24f3e8..bef80a6 100644
--- a/src/main/java/com/toofifty/goaltracker/models/enums/Status.java
+++ b/src/main/java/com/toofifty/goaltracker/models/enums/Status.java
@@ -5,6 +5,10 @@
import lombok.Getter;
import net.runelite.api.QuestState;
+/**
+ * Generic task/goal status enum (not_started, in_progress, completed).
+ * Provides mapping from RuneLite QuestState and convenience checks.
+ */
@Getter
@AllArgsConstructor
public enum Status
diff --git a/src/main/java/com/toofifty/goaltracker/models/enums/TaskType.java b/src/main/java/com/toofifty/goaltracker/models/enums/TaskType.java
index bd8b504..021768b 100644
--- a/src/main/java/com/toofifty/goaltracker/models/enums/TaskType.java
+++ b/src/main/java/com/toofifty/goaltracker/models/enums/TaskType.java
@@ -6,6 +6,10 @@
@Getter
@AllArgsConstructor
+/**
+ * Enumeration of supported task types for goals.
+ * Used in JSON serialization and deserialization (manual, skill, quest, item).
+ */
public enum TaskType
{
@SerializedName("manual")
diff --git a/src/main/java/com/toofifty/goaltracker/models/task/ItemTask.java b/src/main/java/com/toofifty/goaltracker/models/task/ItemTask.java
index e002232..0ea17b4 100644
--- a/src/main/java/com/toofifty/goaltracker/models/task/ItemTask.java
+++ b/src/main/java/com/toofifty/goaltracker/models/task/ItemTask.java
@@ -1,5 +1,7 @@
package com.toofifty.goaltracker.models.task;
+import com.google.gson.annotations.SerializedName;
+import com.toofifty.goaltracker.models.enums.Status;
import com.toofifty.goaltracker.models.enums.TaskType;
import lombok.Builder;
import lombok.Getter;
@@ -11,15 +13,52 @@
@Setter
@Getter
@SuperBuilder
-public class ItemTask extends Task
+/**
+ * Task representing acquisition of an item.
+ * Tracks item ID, name, desired quantity, acquired count, and cached icon.
+ */
+public final class ItemTask extends Task
{
private transient BufferedImage cachedIcon;
- private int quantity;
@Builder.Default
+ @SerializedName("quantity")
+ private int quantity = 1;
+ @Builder.Default
+ @SerializedName("acquired")
private int acquired = 0;
+ @SerializedName("item_id")
private int itemId;
+ @SerializedName("item_name")
private String itemName;
+ /**
+ * Recompute acquired count and task status from a current item count.
+ * @param count how many of this item are currently held (inventory/equipment/bank, as defined by the caller)
+ * @return true if either acquired or status changed; false otherwise
+ */
+ public boolean recomputeFromCount(final int count)
+ {
+ final int oldAcquired = this.acquired;
+ final Status oldStatus = getStatus();
+
+ this.acquired = Math.max(0, Math.min(count, Math.max(1, quantity)));
+
+ if (this.acquired >= this.quantity)
+ {
+ setStatus(Status.COMPLETED);
+ }
+ else if (this.acquired > 0)
+ {
+ setStatus(Status.IN_PROGRESS);
+ }
+ else
+ {
+ setStatus(Status.NOT_STARTED);
+ }
+
+ return oldAcquired != this.acquired || oldStatus != getStatus();
+ }
+
@Override
public String toString()
{
@@ -34,6 +73,12 @@ public String toString()
return String.format("%d x %s", quantity, itemName);
}
+ @Override
+ public String getDisplayName()
+ {
+ return itemName;
+ }
+
@Override
public TaskType getType()
{
diff --git a/src/main/java/com/toofifty/goaltracker/models/task/ManualTask.java b/src/main/java/com/toofifty/goaltracker/models/task/ManualTask.java
index ce177cb..cb32b9b 100644
--- a/src/main/java/com/toofifty/goaltracker/models/task/ManualTask.java
+++ b/src/main/java/com/toofifty/goaltracker/models/task/ManualTask.java
@@ -9,7 +9,11 @@
@Setter
@Getter
@SuperBuilder
-public class ManualTask extends Task
+/**
+ * Task representing a manually tracked entry (checklist-style).
+ * Toggled complete/incomplete by the user.
+ */
+public final class ManualTask extends Task
{
private String description;
@@ -24,6 +28,12 @@ public String toString()
return description;
}
+ @Override
+ public String getDisplayName()
+ {
+ return description;
+ }
+
@Override
public TaskType getType()
{
diff --git a/src/main/java/com/toofifty/goaltracker/models/task/QuestTask.java b/src/main/java/com/toofifty/goaltracker/models/task/QuestTask.java
index f911ec5..35fb28d 100644
--- a/src/main/java/com/toofifty/goaltracker/models/task/QuestTask.java
+++ b/src/main/java/com/toofifty/goaltracker/models/task/QuestTask.java
@@ -1,26 +1,74 @@
package com.toofifty.goaltracker.models.task;
import com.google.gson.annotations.SerializedName;
+import com.toofifty.goaltracker.models.enums.Status;
import com.toofifty.goaltracker.models.enums.TaskType;
import lombok.Getter;
import lombok.Setter;
import lombok.experimental.SuperBuilder;
+import net.runelite.api.Client;
import net.runelite.api.Quest;
+import net.runelite.api.QuestState;
+/**
+ * Task representing completion of a quest.
+ * Stores the RuneLite Quest reference and returns its name as label.
+ */
@Getter
@Setter
@SuperBuilder
-public class QuestTask extends Task
+public final class QuestTask extends Task
{
@SerializedName("quest_id")
private Quest quest;
+ /**
+ * Map RuneLite's QuestState to this plugin's Task.Status.
+ */
+ private static Status mapQuestState(final QuestState state)
+ {
+ if (state == null)
+ {
+ return Status.NOT_STARTED;
+ }
+ switch (state)
+ {
+ case FINISHED:
+ return Status.COMPLETED;
+ case IN_PROGRESS:
+ return Status.IN_PROGRESS;
+ case NOT_STARTED:
+ default:
+ return Status.NOT_STARTED;
+ }
+ }
+
+ /**
+ * Re-evaluate this quest's completion status from the live client and update the Task.status field.
+ * Safe to call on login and whenever relevant varbits/varps change.
+ */
+ public void refreshStatus(final Client client)
+ {
+ if (client == null || quest == null)
+ {
+ return;
+ }
+ final QuestState qs = quest.getState(client);
+ setStatus(mapQuestState(qs));
+ }
+
@Override
public String toString()
{
return quest.getName();
}
+ @Override
+ public String getDisplayName()
+ {
+ return quest.getName();
+ }
+
@Override
public TaskType getType()
{
diff --git a/src/main/java/com/toofifty/goaltracker/models/task/SkillLevelTask.java b/src/main/java/com/toofifty/goaltracker/models/task/SkillLevelTask.java
index 4aefb48..46ed71c 100644
--- a/src/main/java/com/toofifty/goaltracker/models/task/SkillLevelTask.java
+++ b/src/main/java/com/toofifty/goaltracker/models/task/SkillLevelTask.java
@@ -9,11 +9,21 @@
@Setter
@Getter
@SuperBuilder
-public class SkillLevelTask extends Task
+/**
+ * Task representing reaching a target level in a skill.
+ * Stores the RuneLite Skill and required level.
+ */
+public final class SkillLevelTask extends Task
{
private Skill skill;
private int level;
+ @Override
+ public String getDisplayName()
+ {
+ return String.format("Reach level %d %s", level, skill.getName());
+ }
+
@Override
public String toString()
{
diff --git a/src/main/java/com/toofifty/goaltracker/models/task/SkillXpTask.java b/src/main/java/com/toofifty/goaltracker/models/task/SkillXpTask.java
index f5162f7..d9f1318 100644
--- a/src/main/java/com/toofifty/goaltracker/models/task/SkillXpTask.java
+++ b/src/main/java/com/toofifty/goaltracker/models/task/SkillXpTask.java
@@ -9,7 +9,11 @@
@Getter
@Setter
@SuperBuilder
-public class SkillXpTask extends Task
+/**
+ * Task representing earning a target XP amount in a skill.
+ * Stores the RuneLite Skill and required XP.
+ */
+public final class SkillXpTask extends Task
{
private Skill skill;
private int xp;
@@ -20,6 +24,12 @@ public String toString()
return String.format("%d %s XP", xp, skill.getName());
}
+ @Override
+ public String getDisplayName()
+ {
+ return String.format("%d %s XP", xp, skill.getName());
+ }
+
@Override
public TaskType getType()
{
diff --git a/src/main/java/com/toofifty/goaltracker/models/task/Task.java b/src/main/java/com/toofifty/goaltracker/models/task/Task.java
index 0402a72..9842f5d 100644
--- a/src/main/java/com/toofifty/goaltracker/models/task/Task.java
+++ b/src/main/java/com/toofifty/goaltracker/models/task/Task.java
@@ -11,10 +11,14 @@
@Getter
@Setter
@SuperBuilder
+/**
+ * Base class for all goal tasks.
+ * Provides status tracking, indent level management, and type identification.
+ */
public abstract class Task
{
@Builder.Default
- @SerializedName("previous_result")
+ @SerializedName(value = "status", alternate = {"previous_result"})
private Status status = Status.NOT_STARTED;
@Builder.Default
@@ -57,8 +61,14 @@ public boolean isNotFullyIndented() {
return !isFullyIndented();
}
+
@Override
abstract public String toString();
+ /**
+ * Returns a human-readable name for the task.
+ */
+ public abstract String getDisplayName();
+
abstract public TaskType getType();
}
diff --git a/src/main/java/com/toofifty/goaltracker/presets/GoalPresetRepository.java b/src/main/java/com/toofifty/goaltracker/presets/GoalPresetRepository.java
new file mode 100644
index 0000000..bdeeb4c
--- /dev/null
+++ b/src/main/java/com/toofifty/goaltracker/presets/GoalPresetRepository.java
@@ -0,0 +1,269 @@
+package com.toofifty.goaltracker.presets;
+
+import com.toofifty.goaltracker.models.Goal;
+import com.toofifty.goaltracker.models.task.ItemTask;
+import com.toofifty.goaltracker.models.task.ManualTask;
+import com.toofifty.goaltracker.models.task.QuestTask;
+import com.toofifty.goaltracker.models.task.SkillLevelTask;
+import com.toofifty.goaltracker.utils.ReorderableList;
+import lombok.Getter;
+import net.runelite.api.Quest;
+import net.runelite.api.Skill;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+
+import static net.runelite.api.ItemID.*;
+
+public class GoalPresetRepository {
+
+ @Getter
+ public static final class Preset {
+ private final String name;
+ private final String description;
+ private final List goals;
+ public Preset(String name, String description, List goals) {
+ this.name = name;
+ this.description = description;
+ this.goals = goals;
+ }
+
+ @Override public String toString() { return name; }
+ }
+
+ public static List getAll() {
+ List list = new ArrayList<>();
+ list.add(buildEarlyIronman());
+ list.add(buildMidIronman());
+ list.add(buildLateIronman());
+ list.add(buildFullVoidArmor());
+ list.add(buildFastTravelUnlocks());
+ list.add(buildFreeToPlayQuests());
+ list.add(buildAllBarrowsGear());
+ return list;
+ }
+
+
+ private static Preset buildEarlyIronman() {
+ Goal early = Goal.builder()
+ .description("Early Ironman Progression")
+ .tasks(ReorderableList.from(
+ // Core Early Skills (unlock movement & teleports)
+ SkillLevelTask.builder().skill(Skill.AGILITY).level(30).build(),
+ SkillLevelTask.builder().skill(Skill.MAGIC).level(37).build(), // Falador/Camelot teles
+ SkillLevelTask.builder().skill(Skill.THIEVING).level(20).build(),
+ // Early set-up gear
+ ItemTask.builder().itemId(GRACEFUL_HOOD).itemName("Graceful hood").quantity(1).build(),
+ ItemTask.builder().itemId(GRACEFUL_TOP).itemName("Graceful top").quantity(1).build(),
+ ItemTask.builder().itemId(GRACEFUL_LEGS).itemName("Graceful legs").quantity(1).build(),
+ ItemTask.builder().itemId(GRACEFUL_GLOVES).itemName("Graceful gloves").quantity(1).build(),
+ ItemTask.builder().itemId(GRACEFUL_BOOTS).itemName("Graceful boots").quantity(1).build(),
+ ItemTask.builder().itemId(GRACEFUL_CAPE).itemName("Graceful cape").quantity(1).build(),
+ // Foundational quests for stats/unlocks
+ QuestTask.builder().quest(Quest.DRUIDIC_RITUAL).build(),
+ QuestTask.builder().quest(Quest.WATERFALL_QUEST).build(),
+ QuestTask.builder().quest(Quest.PRIEST_IN_PERIL).build(),
+ QuestTask.builder().quest(Quest.TREE_GNOME_VILLAGE).build(),
+ QuestTask.builder().quest(Quest.THE_GRAND_TREE).build(),
+ // Birdhouse runs (Fossil Island access)
+ QuestTask.builder().quest(Quest.THE_DIG_SITE).build(),
+ QuestTask.builder().quest(Quest.BONE_VOYAGE).build(),
+ SkillLevelTask.builder().skill(Skill.HUNTER).level(5).build(),
+ SkillLevelTask.builder().skill(Skill.CRAFTING).level(8).build(), // clockwork
+ SkillLevelTask.builder().skill(Skill.CONSTRUCTION).level(16).build(), // clockmaker's bench
+
+ // Seaweed runs (Giant seaweed patches on Fossil Island)
+ SkillLevelTask.builder().skill(Skill.FARMING).level(23).build()
+ ))
+ .build();
+ return new Preset("Early Ironman Progression", "Stats, gear, and quest goals for early game Ironman.", Arrays.asList(early));
+ }
+
+ private static Preset buildMidIronman() {
+ Goal mid = Goal.builder()
+ .description("Mid Ironman Progression")
+ .tasks(ReorderableList.from(
+ // Midgame Skill Targets
+ SkillLevelTask.builder().skill(Skill.ATTACK).level(60).build(),
+ SkillLevelTask.builder().skill(Skill.STRENGTH).level(60).build(),
+ SkillLevelTask.builder().skill(Skill.DEFENCE).level(60).build(),
+ SkillLevelTask.builder().skill(Skill.RANGED).level(60).build(),
+ SkillLevelTask.builder().skill(Skill.MAGIC).level(55).build(), // High Alch
+ SkillLevelTask.builder().skill(Skill.PRAYER).level(43).build(), // Protect prayers
+ SkillLevelTask.builder().skill(Skill.AGILITY).level(60).build(),
+ // Midgame Gear & Upgrades
+ ItemTask.builder().itemId(DRAGON_SCIMITAR).itemName("Dragon scimitar").quantity(1).build(),
+ ItemTask.builder().itemId(DRAGON_DEFENDER).itemName("Dragon defender").quantity(1).build(),
+ ItemTask.builder().itemId(FIGHTER_TORSO).itemName("Fighter torso").quantity(1).build(),
+ ItemTask.builder().itemId(RUNE_CROSSBOW).itemName("Rune crossbow").quantity(1).build(),
+ ItemTask.builder().itemId(IBANS_STAFF_U).itemName("Iban's staff (u)").quantity(1).build(),
+ ItemTask.builder().itemId(BARROWS_GLOVES).itemName("Barrows gloves").quantity(1).build(),
+ ItemTask.builder().itemId(AVAS_ACCUMULATOR).itemName("Ava's accumulator").quantity(1).build(),
+ // Midgame Quests / Unlocks
+ QuestTask.builder().quest(Quest.RECIPE_FOR_DISASTER).build(),
+ QuestTask.builder().quest(Quest.FAIRYTALE_II__CURE_A_QUEEN).build(), // Fairy rings access (partial)
+ QuestTask.builder().quest(Quest.LUNAR_DIPLOMACY).build(),
+ QuestTask.builder().quest(Quest.ANIMAL_MAGNETISM).build(),
+ QuestTask.builder().quest(Quest.UNDERGROUND_PASS).build()
+ ))
+ .build();
+ return new Preset("Mid Ironman Progression", "Stats, gear, and quest goals for mid game Ironman.", Arrays.asList(mid));
+ }
+
+ private static Preset buildLateIronman() {
+ Goal late = Goal.builder()
+ .description("Late Ironman Progression")
+ .tasks(ReorderableList.from(
+ // Late Skill Targets
+ SkillLevelTask.builder().skill(Skill.ATTACK).level(85).build(),
+ SkillLevelTask.builder().skill(Skill.STRENGTH).level(85).build(),
+ SkillLevelTask.builder().skill(Skill.DEFENCE).level(85).build(),
+ SkillLevelTask.builder().skill(Skill.MAGIC).level(94).build(), // Vengeance/Ice Barrage
+ SkillLevelTask.builder().skill(Skill.PRAYER).level(77).build(), // Rigour/Augury
+ SkillLevelTask.builder().skill(Skill.RANGED).level(85).build(),
+ // Late Gear Goals
+ ItemTask.builder().itemId(ABYSSAL_TENTACLE).itemName("Abyssal tentacle").quantity(1).build(),
+ ItemTask.builder().itemId(TOXIC_BLOWPIPE).itemName("Toxic blowpipe").quantity(1).build(),
+ ItemTask.builder().itemId(ARMADYL_CROSSBOW).itemName("Armadyl crossbow").quantity(1).build(),
+ ItemTask.builder().itemId(BOW_OF_FAERDHINEN).itemName("Bow of Faerdhinen").quantity(1).build(),
+ ItemTask.builder().itemId(BANDOS_CHESTPLATE).itemName("Bandos chestplate").quantity(1).build(),
+ ItemTask.builder().itemId(BANDOS_TASSETS).itemName("Bandos tassets").quantity(1).build(),
+ ItemTask.builder().itemId(TRIDENT_OF_THE_SEAS).itemName("Trident of the seas").quantity(1).build(),
+ ItemTask.builder().itemId(RING_OF_SUFFERING).itemName("Ring of suffering").quantity(1).build(),
+ ItemTask.builder().itemId(RING_OF_SUFFERING_I).itemName("Ring of suffering (i)").quantity(1).build(),
+ ItemTask.builder().itemId(SALVE_AMULETEI).itemName("Salve amulet (ei)").quantity(1).build(),
+ ItemTask.builder().itemId(INFERNAL_CAPE).itemName("Infernal cape").quantity(1).build(),
+ // Late Quests / Diaries
+ QuestTask.builder().quest(Quest.SONG_OF_THE_ELVES).build(),
+ QuestTask.builder().quest(Quest.SINS_OF_THE_FATHER).build(),
+ QuestTask.builder().quest(Quest.MONKEY_MADNESS_II).build(),
+ QuestTask.builder().quest(Quest.DESERT_TREASURE_I).build()
+ ))
+ .build();
+ return new Preset("Late Ironman Progression", "Stats, gear, and quest goals for late game Ironman.", Arrays.asList(late));
+ }
+ private static Preset buildFullVoidArmor() {
+ Goal voidSet = Goal.builder()
+ .description("Full Void Armor Set")
+ .tasks(ReorderableList.from(
+ ItemTask.builder().itemId(VOID_KNIGHT_TOP).itemName("Void knight top").quantity(1).build(),
+ ItemTask.builder().itemId(VOID_KNIGHT_ROBE).itemName("Void knight robe").quantity(1).build(),
+ ItemTask.builder().itemId(VOID_KNIGHT_GLOVES).itemName("Void knight gloves").quantity(1).build(),
+ ItemTask.builder().itemId(VOID_MELEE_HELM).itemName("Void melee helm").quantity(1).build(),
+ ItemTask.builder().itemId(VOID_RANGER_HELM).itemName("Void ranger helm").quantity(1).build(),
+ ItemTask.builder().itemId(VOID_MAGE_HELM).itemName("Void mage helm").quantity(1).build()
+ ))
+ .build();
+ return new Preset("Full Void Armor", "All base Void pieces: top, robe, gloves, and all three helms.", Arrays.asList(voidSet));
+ }
+ private static Preset buildFreeToPlayQuests() {
+ Goal f2p = Goal.builder()
+ .description("All Free-to-Play Quests")
+ .tasks(ReorderableList.from(
+ // Suggested quick-to-hard order
+ QuestTask.builder().quest(Quest.COOKS_ASSISTANT).build(),
+ QuestTask.builder().quest(Quest.SHEEP_SHEARER).build(),
+ QuestTask.builder().quest(Quest.ROMEO__JULIET).build(),
+ QuestTask.builder().quest(Quest.THE_RESTLESS_GHOST).build(),
+ QuestTask.builder().quest(Quest.IMP_CATCHER).build(),
+ QuestTask.builder().quest(Quest.DORICS_QUEST).build(),
+ QuestTask.builder().quest(Quest.GOBLIN_DIPLOMACY).build(),
+ QuestTask.builder().quest(Quest.ERNEST_THE_CHICKEN).build(),
+ QuestTask.builder().quest(Quest.THE_KNIGHTS_SWORD).build(),
+ QuestTask.builder().quest(Quest.PIRATES_TREASURE).build(),
+ QuestTask.builder().quest(Quest.PRINCE_ALI_RESCUE).build(),
+ QuestTask.builder().quest(Quest.BLACK_KNIGHTS_FORTRESS).build(),
+ QuestTask.builder().quest(Quest.VAMPYRE_SLAYER).build(),
+ QuestTask.builder().quest(Quest.DEMON_SLAYER).build(),
+ QuestTask.builder().quest(Quest.RUNE_MYSTERIES).build(),
+ QuestTask.builder().quest(Quest.MISTHALIN_MYSTERY).build(),
+ QuestTask.builder().quest(Quest.THE_CORSAIR_CURSE).build(),
+ // Note: Shield of Arrav needs a partner
+ QuestTask.builder().quest(Quest.SHIELD_OF_ARRAV).build(),
+ ManualTask.builder().description("Find a partner for Shield of Arrav (Phoenix/Black Arm) or coordinate in a clan chat").indentLevel(1).build(),
+ // Capstone F2P quest
+ QuestTask.builder().quest(Quest.DRAGON_SLAYER_I).build()
+ ))
+ .build();
+ return new Preset("Free-to-Play Quests", "Every F2P quest in OSRS (20 total) as of today.", Arrays.asList(f2p));
+ }
+ private static Preset buildFastTravelUnlocks() {
+ Goal travel = Goal.builder()
+ .description("Fast Travel Unlocks (Quest-gated)")
+ .tasks(ReorderableList.from(
+ // Spirit trees & gliders
+ QuestTask.builder().quest(Quest.TREE_GNOME_VILLAGE).build(),
+ QuestTask.builder().quest(Quest.THE_GRAND_TREE).build(),
+ // Fairy rings (partial completion allows use with dramen/lunar staff)
+ QuestTask.builder().quest(Quest.FAIRYTALE_II__CURE_A_QUEEN).build(),
+ // Balloons
+ QuestTask.builder().quest(Quest.ENLIGHTENED_JOURNEY).build(),
+ // City teleport spells unlocked by quests
+ QuestTask.builder().quest(Quest.WATCHTOWER).build(),
+ QuestTask.builder().quest(Quest.PLAGUE_CITY).build(),
+ QuestTask.builder().quest(Quest.EADGARS_RUSE).build(),
+ // Teleport items from quests
+ QuestTask.builder().quest(Quest.GHOSTS_AHOY).build(), // Ectophial
+ QuestTask.builder().quest(Quest.THE_DIG_SITE).build(), // Digsite pendant access
+ QuestTask.builder().quest(Quest.BONE_VOYAGE).build(), // Fossil Island travel
+ QuestTask.builder().quest(Quest.MONKEY_MADNESS_II).build(), // Royal seed pod
+ QuestTask.builder().quest(Quest.A_TASTE_OF_HOPE).build(), // Drakan's medallion teleports in Morytania
+ // Kourend memoirs teleports
+ QuestTask.builder().quest(Quest.CLIENT_OF_KOUREND).build(),
+ QuestTask.builder().quest(Quest.THE_DEPTHS_OF_DESPAIR).build(),
+ QuestTask.builder().quest(Quest.THE_QUEEN_OF_THIEVES).build(),
+ QuestTask.builder().quest(Quest.TALE_OF_THE_RIGHTEOUS).build(),
+ QuestTask.builder().quest(Quest.THE_FORSAKEN_TOWER).build(),
+ QuestTask.builder().quest(Quest.THE_ASCENT_OF_ARCEUUS).build(),
+ // Other travel networks
+ QuestTask.builder().quest(Quest.EAGLES_PEAK).build(), // Eagle transport
+ QuestTask.builder().quest(Quest.SHILO_VILLAGE).build(), // Brimhaven–Shilo carts
+ QuestTask.builder().quest(Quest.THE_GIANT_DWARF).build(), // Keldagrim mine carts
+ QuestTask.builder().quest(Quest.ANOTHER_SLICE_OF_HAM).build(), // Dorgesh-Kaan ↔ Keldagrim train
+ QuestTask.builder().quest(Quest.THE_FREMENNIK_TRIALS).build(), // Enchanted lyre to Rellekka
+ // Spellbook unlocks with many teleports
+ QuestTask.builder().quest(Quest.DESERT_TREASURE_I).build(), // Ancient Magicks
+ QuestTask.builder().quest(Quest.LUNAR_DIPLOMACY).build() // Lunar teleports
+ ))
+ .build();
+ return new Preset(
+ "Fast Travel Unlocks",
+ "Quests that unlock major transportation methods: spirit trees, gliders, fairy rings, balloons, city teleports, carts, memoirs, seed pod, and more.",
+ Arrays.asList(travel)
+ );
+ }
+
+ private static Preset buildAllBarrowsGear() {
+ Goal barrows = Goal.builder()
+ .description("All Barrows Gear")
+ .tasks(ReorderableList.from(
+ ItemTask.builder().itemId(AHRIMS_HOOD).itemName("Ahrim's hood").quantity(1).build(),
+ ItemTask.builder().itemId(AHRIMS_ROBETOP).itemName("Ahrim's robetop").quantity(1).build(),
+ ItemTask.builder().itemId(AHRIMS_ROBESKIRT).itemName("Ahrim's robeskirt").quantity(1).build(),
+ ItemTask.builder().itemId(AHRIMS_STAFF).itemName("Ahrim's staff").quantity(1).build(),
+ ItemTask.builder().itemId(DHAROKS_HELM).itemName("Dharok's helm").quantity(1).build(),
+ ItemTask.builder().itemId(DHAROKS_PLATEBODY).itemName("Dharok's platebody").quantity(1).build(),
+ ItemTask.builder().itemId(DHAROKS_PLATELEGS).itemName("Dharok's platelegs").quantity(1).build(),
+ ItemTask.builder().itemId(DHAROKS_GREATAXE).itemName("Dharok's greataxe").quantity(1).build(),
+ ItemTask.builder().itemId(GUTHANS_HELM).itemName("Guthan's helm").quantity(1).build(),
+ ItemTask.builder().itemId(GUTHANS_PLATEBODY).itemName("Guthan's platebody").quantity(1).build(),
+ ItemTask.builder().itemId(GUTHANS_CHAINSKIRT).itemName("Guthan's chainskirt").quantity(1).build(),
+ ItemTask.builder().itemId(GUTHANS_WARSPEAR).itemName("Guthan's warspear").quantity(1).build(),
+ ItemTask.builder().itemId(KARILS_COIF).itemName("Karil's coif").quantity(1).build(),
+ ItemTask.builder().itemId(KARILS_LEATHERTOP).itemName("Karil's leathertop").quantity(1).build(),
+ ItemTask.builder().itemId(KARILS_LEATHERSKIRT).itemName("Karil's leatherskirt").quantity(1).build(),
+ ItemTask.builder().itemId(KARILS_CROSSBOW).itemName("Karil's crossbow").quantity(1).build(),
+ ItemTask.builder().itemId(TORAGS_HELM).itemName("Torag's helm").quantity(1).build(),
+ ItemTask.builder().itemId(TORAGS_PLATEBODY).itemName("Torag's platebody").quantity(1).build(),
+ ItemTask.builder().itemId(TORAGS_PLATELEGS).itemName("Torag's platelegs").quantity(1).build(),
+ ItemTask.builder().itemId(TORAGS_HAMMERS).itemName("Torag's hammers").quantity(1).build(),
+ ItemTask.builder().itemId(VERACS_HELM).itemName("Verac's helm").quantity(1).build(),
+ ItemTask.builder().itemId(VERACS_BRASSARD).itemName("Verac's brassard").quantity(1).build(),
+ ItemTask.builder().itemId(VERACS_PLATESKIRT).itemName("Verac's plateskirt").quantity(1).build(),
+ ItemTask.builder().itemId(VERACS_FLAIL).itemName("Verac's flail").quantity(1).build()
+ ))
+ .build();
+ return new Preset("All Barrows Gear", "Collect a full set of each Barrows brothers' equipment.", Arrays.asList(barrows));
+ }
+}
diff --git a/src/main/java/com/toofifty/goaltracker/services/TaskIconService.java b/src/main/java/com/toofifty/goaltracker/services/TaskIconService.java
index 732a0de..d481431 100644
--- a/src/main/java/com/toofifty/goaltracker/services/TaskIconService.java
+++ b/src/main/java/com/toofifty/goaltracker/services/TaskIconService.java
@@ -8,65 +8,76 @@
import net.runelite.client.util.ImageUtil;
import javax.inject.Inject;
+import javax.inject.Singleton;
import javax.swing.*;
+import java.awt.*;
import java.awt.image.BufferedImage;
-public class TaskIconService
+/**
+ * Resolves small 16x16 icons for tasks (items, skills, quests, manual checks).
+ */
+@Singleton
+public final class TaskIconService
{
+ private static final int ICON_SIZE = 16;
+
public static final ImageIcon CROSS_MARK_ICON;
public static final ImageIcon CHECK_MARK_ICON;
public static final ImageIcon QUEST_ICON;
public static final ImageIcon QUEST_COMPLETE_ICON;
public static final ImageIcon UNKNOWN_ICON;
- static {
+ static
+ {
CROSS_MARK_ICON = new ImageIcon(
- ImageUtil.loadImageResource(GoalTrackerPlugin.class, "/cross_mark.png")
+ ImageUtil.loadImageResource(GoalTrackerPlugin.class, "/cross_mark.png")
);
CHECK_MARK_ICON = new ImageIcon(
- ImageUtil.loadImageResource(GoalTrackerPlugin.class, "/check_mark.png")
+ ImageUtil.loadImageResource(GoalTrackerPlugin.class, "/check_mark.png")
);
QUEST_ICON = new ImageIcon(
- ImageUtil.loadImageResource(GoalTrackerPlugin.class, "/quest_icon.png")
+ ImageUtil.loadImageResource(GoalTrackerPlugin.class, "/quest_icon.png")
);
QUEST_COMPLETE_ICON = new ImageIcon(
- ImageUtil.loadImageResource(GoalTrackerPlugin.class, "/quest_complete.png")
+ ImageUtil.loadImageResource(GoalTrackerPlugin.class, "/quest_complete.png")
);
UNKNOWN_ICON = new ImageIcon(
- ImageUtil.loadImageResource(GoalTrackerPlugin.class, "/question_mark.png")
+ ImageUtil.loadImageResource(GoalTrackerPlugin.class, "/question_mark.png")
);
}
- @Inject
- private ItemManager itemManager;
-
- @Inject
- private SkillIconManager skillIconManager;
-
- @Inject
- private Client client;
+ @Inject private ItemManager itemManager;
+ @Inject private SkillIconManager skillIconManager;
+ @Inject private Client client;
public ImageIcon get(Task task)
{
- if (task instanceof ManualTask) {
+ if (task instanceof ManualTask)
+ {
return get((ManualTask) task);
}
-
- if (task instanceof QuestTask) {
+ if (task instanceof QuestTask)
+ {
return get((QuestTask) task);
}
BufferedImage image = null;
- if (task instanceof SkillLevelTask) {
+ if (task instanceof SkillLevelTask)
+ {
image = get((SkillLevelTask) task);
- } else if (task instanceof SkillXpTask) {
+ }
+ else if (task instanceof SkillXpTask)
+ {
image = get((SkillXpTask) task);
- } else if (task instanceof ItemTask) {
+ }
+ else if (task instanceof ItemTask)
+ {
image = get((ItemTask) task);
}
- if (image != null) {
- return new ImageIcon(image.getScaledInstance(16, 16, 32));
+ if (image != null)
+ {
+ return iconify(image);
}
return UNKNOWN_ICON;
@@ -94,10 +105,16 @@ public ImageIcon get(QuestTask task)
public BufferedImage get(ItemTask task)
{
- if (task.getCachedIcon() == null && client.isClientThread()) {
+ if (task.getCachedIcon() == null && client.isClientThread())
+ {
task.setCachedIcon(itemManager.getImage(task.getItemId()));
}
-
return task.getCachedIcon();
}
+
+ private ImageIcon iconify(BufferedImage img)
+ {
+ Image scaled = img.getScaledInstance(ICON_SIZE, ICON_SIZE, Image.SCALE_SMOOTH);
+ return new ImageIcon(scaled);
+ }
}
diff --git a/src/main/java/com/toofifty/goaltracker/services/TaskUpdateService.java b/src/main/java/com/toofifty/goaltracker/services/TaskUpdateService.java
index 522180d..6e30d14 100644
--- a/src/main/java/com/toofifty/goaltracker/services/TaskUpdateService.java
+++ b/src/main/java/com/toofifty/goaltracker/services/TaskUpdateService.java
@@ -8,137 +8,121 @@
import net.runelite.api.events.StatChanged;
import javax.inject.Inject;
+import javax.inject.Singleton;
-public class TaskUpdateService
+/**
+ * Updates task status based on current client state and events.
+ */
+@Singleton
+public final class TaskUpdateService
{
- @Inject
- private Client client;
-
- @Inject
- private ItemCache itemCache;
-
- public boolean update(Task task) {
- switch (task.getType()) {
- case SKILL_LEVEL: return this.update((SkillLevelTask) task);
- case SKILL_XP: return this.update((SkillXpTask) task);
- case QUEST: return this.update((QuestTask) task);
- case ITEM: return this.update((ItemTask) task);
- default: return false;
- }
- }
+ @Inject private Client client;
+ @Inject private ItemCache itemCache;
/**
- * Returns true if an update has occurred
- * @param task the skill level task to validate
+ * Dispatch update for a generic task, returning true if status/values changed.
*/
- public boolean update(SkillLevelTask task)
+ public boolean update(Task task)
{
- if (client.getGameState() != GameState.LOGGED_IN) return false;
+ switch (task.getType())
+ {
+ case SKILL_LEVEL: return update((SkillLevelTask) task);
+ case SKILL_XP: return update((SkillXpTask) task);
+ case QUEST: return update((QuestTask) task);
+ case ITEM: return update((ItemTask) task);
+ default: return false;
+ }
+ }
+
+ // -------------------- Skill Level --------------------
- return this.update(task, client.getRealSkillLevel(task.getSkill()));
+ /** Returns true if an update has occurred. */
+ public boolean update(SkillLevelTask task)
+ {
+ if (client.getGameState() != GameState.LOGGED_IN || !client.isClientThread())
+ {
+ return false;
+ }
+ return update(task, client.getRealSkillLevel(task.getSkill()));
}
- /**
- * Returns true if an update has occurred
- * @param task the skill level task to validate
- * @param event the stat changed event this update is tied to
- */
+ /** Returns true if an update has occurred (event-driven). */
public boolean update(SkillLevelTask task, StatChanged event)
{
- if (event.getSkill() != task.getSkill()) return false;
-
- return this.update(task, event.getLevel());
+ if (event.getSkill() != task.getSkill())
+ {
+ return false;
+ }
+ return update(task, event.getLevel());
}
- /**
- * Returns true if an update has occurred
- * @param task the skill level task to validate
- */
+ /** Returns true if an update has occurred given a specific level. */
public boolean update(SkillLevelTask task, int level)
{
- Status oldStatus = task.getStatus();
-
- task.setStatus(
- level >= task.getLevel()
- ? Status.COMPLETED
- : Status.NOT_STARTED
- );
-
+ final Status oldStatus = task.getStatus();
+ task.setStatus(level >= task.getLevel() ? Status.COMPLETED : Status.NOT_STARTED);
return oldStatus != task.getStatus();
}
- /**
- * Returns true if an update has occurred
- * @param task the skill level task to validate
- */
+ // -------------------- Skill XP --------------------
+
+ /** Returns true if an update has occurred. */
public boolean update(SkillXpTask task)
{
- if (client.getGameState() != GameState.LOGGED_IN) return false;
-
- return this.update(task, client.getSkillExperience(task.getSkill()));
+ if (client.getGameState() != GameState.LOGGED_IN || !client.isClientThread())
+ {
+ return false;
+ }
+ return update(task, client.getSkillExperience(task.getSkill()));
}
- /**
- * Returns true if an update has occurred
- * @param task the skill level task to validate
- * @param event the stat changed event this update is tied to
- */
+ /** Returns true if an update has occurred (event-driven). */
public boolean update(SkillXpTask task, StatChanged event)
{
- if (event.getSkill() != task.getSkill()) return false;
-
- return this.update(task, event.getXp());
+ if (event.getSkill() != task.getSkill())
+ {
+ return false;
+ }
+ return update(task, event.getXp());
}
- /**
- * Returns true if an update has occurred
- * @param task the skill xp task to validate
- */
+ /** Returns true if an update has occurred given a specific XP value. */
public boolean update(SkillXpTask task, int xp)
{
- Status oldStatus = task.getStatus();
-
- task.setStatus(
- xp >= task.getXp()
- ? Status.COMPLETED
- : Status.NOT_STARTED
- );
-
+ final Status oldStatus = task.getStatus();
+ task.setStatus(xp >= task.getXp() ? Status.COMPLETED : Status.NOT_STARTED);
return oldStatus != task.getStatus();
}
- /**
- * Returns true if an update has occurred
- * @param task the quest task to validate
- */
+ // -------------------- Quest --------------------
+
+ /** Returns true if an update has occurred. */
public boolean update(QuestTask task)
{
- if (client.getGameState() != GameState.LOGGED_IN || !client.isClientThread()) return false;
-
- Status oldStatus = task.getStatus();
-
+ if (client.getGameState() != GameState.LOGGED_IN || !client.isClientThread())
+ {
+ return false;
+ }
+ final Status oldStatus = task.getStatus();
task.setStatus(Status.fromQuestState(task.getQuest().getState(client)));
-
return oldStatus != task.getStatus();
}
- /**
- * Returns true if an update has occurred
- * @param task the item task to validate
- */
+ // -------------------- Item --------------------
+
+ /** Returns true if an update has occurred. */
public boolean update(ItemTask task)
{
- int oldAcquired = task.getAcquired();
- Status oldStatus = task.getStatus();
+ final int oldAcquired = task.getAcquired();
+ final Status oldStatus = task.getStatus();
task.setAcquired(Math.min(itemCache.getTotalQuantity(task.getItemId()), task.getQuantity()));
task.setStatus(
task.getAcquired() >= task.getQuantity()
? Status.COMPLETED
- : (task.getAcquired() > 0
- ? Status.IN_PROGRESS
- : Status.NOT_STARTED));
+ : (task.getAcquired() > 0 ? Status.IN_PROGRESS : Status.NOT_STARTED)
+ );
return oldAcquired != task.getAcquired() || oldStatus != task.getStatus();
}
diff --git a/src/main/java/com/toofifty/goaltracker/ui/GoalItemContent.java b/src/main/java/com/toofifty/goaltracker/ui/GoalItemContent.java
index 00dc54f..01f38b7 100644
--- a/src/main/java/com/toofifty/goaltracker/ui/GoalItemContent.java
+++ b/src/main/java/com/toofifty/goaltracker/ui/GoalItemContent.java
@@ -2,28 +2,203 @@
import com.toofifty.goaltracker.GoalTrackerPlugin;
import com.toofifty.goaltracker.models.Goal;
+import com.toofifty.goaltracker.ui.components.ListItemPanel;
+import net.runelite.client.ui.ColorScheme;
import javax.swing.*;
+import javax.swing.event.PopupMenuEvent;
+import javax.swing.event.PopupMenuListener;
import java.awt.*;
+import java.awt.event.ComponentAdapter;
+import java.awt.event.ComponentEvent;
+import java.awt.event.MouseAdapter;
+import java.awt.event.MouseEvent;
import static com.toofifty.goaltracker.utils.Constants.STATUS_TO_COLOR;
-public class GoalItemContent extends JPanel implements Refreshable
+/**
+ * UI component for rendering a Goal in a list.
+ * Shows the title (editable), progress count, and a slim progress bar.
+ * Also supports pinning, context menus, and refresh on goal status change.
+ */
+public final class GoalItemContent extends JPanel implements Refreshable
{
- private final JLabel title = new JLabel();
+ private final JTextArea titleLabel = new JTextArea();
+ private final JTextField titleEdit = new JTextField();
private final JLabel progress = new JLabel();
+ private final SlimBar progressBar = new SlimBar();
+ private final JPanel titleStack = new JPanel(new CardLayout());
private final Goal goal;
+ private JPanel topRow;
+
+ //private static final int PIN_STRIPE_W = 3; // px
+ private static final Color PINNED_BG_COLOR = new Color(45, 45, 45); // darker gray background
+
GoalItemContent(GoalTrackerPlugin plugin, Goal goal)
{
super(new BorderLayout());
this.goal = goal;
- add(title, BorderLayout.WEST);
- add(progress, BorderLayout.EAST);
+ setBorder(BorderFactory.createEmptyBorder(6, 0, 6, 0)); // remove extra left/right padding so title can span edge-to-edge within the card body
+ // Let the parent card body paint the background; avoid double fills
+ setOpaque(false);
+ setBackground(null);
+
+ topRow = new JPanel(new BorderLayout());
+ topRow.setOpaque(false);
+
+ // Title label (display mode)
+ titleLabel.setLineWrap(true);
+ titleLabel.setWrapStyleWord(true);
+ titleLabel.setEditable(false);
+ titleLabel.setOpaque(false);
+ titleLabel.setBorder(null);
+ titleLabel.setFocusable(false);
+ titleLabel.setFont(getFont());
+ titleLabel.setMinimumSize(new Dimension(0, titleLabel.getPreferredSize().height));
+ titleLabel.setMargin(new Insets(0, 0, 0, 0));
+
+ // Title edit (edit mode)
+ titleEdit.setBorder(null);
+ titleEdit.setOpaque(false);
+ titleEdit.setDragEnabled(true);
+
+ // Constrain edit field so it never expands the card width
+ titleEdit.setColumns(1); // keep preferred width minimal; BorderLayout will stretch it as needed
+ int editH = titleEdit.getPreferredSize().height;
+ titleEdit.setMinimumSize(new Dimension(0, editH));
+ titleEdit.setPreferredSize(new Dimension(10, editH)); // tiny preferred width; parent determines real width
+ // ESC cancels rename without saving
+ InputMap im = titleEdit.getInputMap(JComponent.WHEN_FOCUSED);
+ ActionMap am = titleEdit.getActionMap();
+ im.put(KeyStroke.getKeyStroke(java.awt.event.KeyEvent.VK_ESCAPE, 0), "gt.cancelEdit");
+ am.put("gt.cancelEdit", new AbstractAction() {
+ @Override public void actionPerformed(java.awt.event.ActionEvent e) { exitEdit(false); }
+ });
+
+ titleStack.setOpaque(false);
+ titleStack.add(titleLabel, "label");
+ titleStack.add(titleEdit, "edit");
+ topRow.add(titleStack, BorderLayout.CENTER);
+
+ // Swap to edit only on LEFT double‑click (not on right‑click)
+ titleLabel.addMouseListener(new MouseAdapter() {
+ @Override public void mouseClicked(MouseEvent e) {
+ if (SwingUtilities.isLeftMouseButton(e) && e.getClickCount() == 2) {
+ enterEdit();
+ }
+ }
+ });
+ // Commit edits on Enter and when focus is lost
+ titleEdit.addActionListener(e -> exitEdit(true));
+ titleEdit.addFocusListener(new java.awt.event.FocusAdapter() {
+ @Override public void focusLost(java.awt.event.FocusEvent e) { exitEdit(true); }
+ });
+
+ // Place progress at the far right with a small left gap and a yellow border
+ progress.setBorder(new javax.swing.border.EmptyBorder(0, 3, 0, 0));
+ topRow.add(progress, BorderLayout.EAST);
+
+ // Reserve fixed width for progress like 999/999 so it never clips
+ int progW = getFontMetrics(progress.getFont()).stringWidth("999/999");
+ Dimension progSize = new Dimension(progW, progress.getPreferredSize().height);
+ progress.setPreferredSize(progSize);
+ progress.setMinimumSize(progSize);
+ add(topRow, BorderLayout.CENTER);
+
+ // Slim custom progress bar under the title row
+ progressBar.setBorder(BorderFactory.createEmptyBorder(4, 0, 0, 0)); // gap above the bar
+ progressBar.setPreferredSize(new Dimension(0, 6)); // 6px tall bar
+ add(progressBar, BorderLayout.SOUTH);
+
+ // Initialize visible text and colors immediately (before first refresh)
+ {
+ Color color = STATUS_TO_COLOR.get(goal.getStatus());
+ updateTitleLabel();
+ titleEdit.setCaretColor(color);
+ progress.setText(goal.getComplete().size() + "/" + goal.getTasks().size());
+ progress.setForeground(color);
+ }
+
+ topRow.addComponentListener(new ComponentAdapter() {
+ @Override public void componentResized(ComponentEvent e) { updateTitleLabel(); }
+ });
plugin.getUiStatusManager().addRefresher(goal, this::refresh);
+
+ // Ensure right-click on any child shows the parent ListItemPanel context menu
+ MouseAdapter forwardPopup = new MouseAdapter()
+ {
+ private void maybeShow(MouseEvent e)
+ {
+ if (!e.isPopupTrigger())
+ {
+ return;
+ }
+ Component src = (Component) e.getSource();
+ JComponent listItem = (JComponent) SwingUtilities.getAncestorOfClass(ListItemPanel.class, src);
+ if (listItem == null)
+ {
+ listItem = (JComponent) SwingUtilities.getAncestorOfClass(ListItemPanel.class, GoalItemContent.this);
+ }
+ if (listItem != null && listItem.getComponentPopupMenu() != null)
+ {
+ Point p = SwingUtilities.convertPoint(src, e.getPoint(), listItem);
+ JPopupMenu menu = listItem.getComponentPopupMenu();
+
+ JMenuItem rename = new JMenuItem("Rename");
+ ((JComponent) rename).putClientProperty("pinToggle", Boolean.TRUE); // reuse cleanup flag
+ rename.addActionListener(ev -> enterEdit());
+ menu.add(rename);
+
+ PopupMenuListener cleanup = new PopupMenuListener() {
+ @Override public void popupMenuWillBecomeVisible(PopupMenuEvent e) { }
+ @Override public void popupMenuCanceled(PopupMenuEvent e) { cleanup(menu, this); }
+ @Override public void popupMenuWillBecomeInvisible(PopupMenuEvent e) { cleanup(menu, this); }
+ private void cleanup(JPopupMenu m, PopupMenuListener self) {
+ // Remove only the components we added this time
+ java.util.List toRemove = new java.util.ArrayList<>();
+ for (Component c : m.getComponents()) {
+ if (c instanceof JComponent) {
+ Object flag = ((JComponent) c).getClientProperty("pinToggle");
+ if (Boolean.TRUE.equals(flag)) {
+ toRemove.add(c);
+ }
+ }
+ }
+ for (Component c : toRemove) {
+ m.remove(c);
+ }
+ m.removePopupMenuListener(self);
+ }
+ };
+ menu.addPopupMenuListener(cleanup);
+
+ menu.show(listItem, p.x, p.y);
+ }
+ }
+
+ @Override public void mousePressed(MouseEvent e) { maybeShow(e); }
+ @Override public void mouseReleased(MouseEvent e) { maybeShow(e); }
+ };
+
+ this.addMouseListener(forwardPopup);
+ titleLabel.addMouseListener(forwardPopup);
+ titleEdit.addMouseListener(forwardPopup);
+ progress.addMouseListener(forwardPopup);
+ progressBar.addMouseListener(forwardPopup);
+ // Ensure item icon/text initialize on first render (e.g., on login)
+ SwingUtilities.invokeLater(this::refresh);
+ }
+
+ @Override
+ public void addNotify()
+ {
+ super.addNotify();
+ // In case construction happened before UI was realized, refresh when shown
+ SwingUtilities.invokeLater(this::refresh);
}
@Override
@@ -31,11 +206,86 @@ public void refresh()
{
Color color = STATUS_TO_COLOR.get(goal.getStatus());
- title.setText(goal.getDescription());
- title.setForeground(color);
+ updateTitleLabel();
+ titleEdit.setCaretColor(color);
progress.setText(
goal.getComplete().size() + "/" + goal.getTasks().size());
progress.setForeground(color);
+
+ int total = goal.getTasks().size();
+ int done = goal.getComplete().size();
+ progressBar.setVisible(total > 0);
+ progressBar.setProgress(done, total, color);
+ SwingUtilities.invokeLater(this::updateTitleLabel);
+ }
+ private static class SlimBar extends JComponent {
+ private int done = 0;
+ private int total = 1;
+ private Color fill = Color.GREEN;
+
+ void setProgress(int done, int total, Color fill) {
+ this.done = done;
+ this.total = Math.max(1, total);
+ this.fill = fill;
+ repaint();
+ }
+
+ @Override
+ protected void paintComponent(Graphics g) {
+ super.paintComponent(g);
+ Graphics2D g2 = (Graphics2D) g.create();
+ int w = getWidth();
+ int h = getHeight();
+ // track
+ g2.setColor(ColorScheme.DARKER_GRAY_COLOR);
+ g2.fillRect(0, 0, w, h);
+ // fill
+ int barW = (int) ((done / (double) total) * w);
+ g2.setColor(fill != null ? fill : Color.GREEN);
+ g2.fillRect(0, 0, barW, h);
+ g2.dispose();
+ }
+ }
+
+ private void updateTitleLabel()
+ {
+ if (topRow == null) return;
+ String full = goal.getDescription() != null ? goal.getDescription() : "";
+ titleLabel.setText(full.isBlank() ? " " : full);
+ titleLabel.setToolTipText(full.isEmpty() ? null : full);
+ }
+
+ private void enterEdit()
+ {
+ titleEdit.setText(goal.getDescription() != null ? goal.getDescription() : "");
+ ((CardLayout) titleStack.getLayout()).show(titleStack, "edit");
+ titleEdit.requestFocusInWindow();
+ titleEdit.selectAll();
+ }
+
+ private void exitEdit(boolean save)
+ {
+ if (save) {
+ String newText = titleEdit.getText();
+ if (newText != null) {
+ goal.setDescription(newText);
+ }
+ }
+ ((CardLayout) titleStack.getLayout()).show(titleStack, "label");
+ updateTitleLabel();
+ }
+
+ @Override
+ protected void paintComponent(Graphics g)
+ {
+ if (goal != null && goal.isPinned())
+ {
+ Graphics2D g2 = (Graphics2D) g.create();
+ g2.setColor(PINNED_BG_COLOR);
+ g2.fillRect(0, 0, getWidth(), getHeight());
+ g2.dispose();
+ }
+ super.paintComponent(g);
}
}
diff --git a/src/main/java/com/toofifty/goaltracker/ui/GoalPanel.java b/src/main/java/com/toofifty/goaltracker/ui/GoalPanel.java
index e076aad..ac8381f 100644
--- a/src/main/java/com/toofifty/goaltracker/ui/GoalPanel.java
+++ b/src/main/java/com/toofifty/goaltracker/ui/GoalPanel.java
@@ -1,22 +1,28 @@
package com.toofifty.goaltracker.ui;
import com.toofifty.goaltracker.GoalTrackerPlugin;
+import com.toofifty.goaltracker.models.ActionHistory;
import com.toofifty.goaltracker.models.Goal;
+import com.toofifty.goaltracker.models.RemoveTaskAction;
import com.toofifty.goaltracker.models.enums.TaskType;
-import com.toofifty.goaltracker.models.task.ManualTask;
import com.toofifty.goaltracker.models.task.Task;
-import com.toofifty.goaltracker.ui.components.EditableInput;
-import com.toofifty.goaltracker.ui.components.ListPanel;
-import com.toofifty.goaltracker.ui.components.ListTaskPanel;
-import com.toofifty.goaltracker.ui.components.TextButton;
-import java.awt.BorderLayout;
+import com.toofifty.goaltracker.ui.components.*;
+import net.runelite.client.ui.ColorScheme;
+
+import javax.swing.*;
+import javax.swing.border.EmptyBorder;
+import javax.swing.text.DefaultEditorKit;
+import javax.swing.text.JTextComponent;
+import java.awt.*;
+import java.awt.event.KeyEvent;
import java.util.Objects;
import java.util.function.Consumer;
-import javax.swing.JPanel;
-import javax.swing.border.EmptyBorder;
-import net.runelite.client.ui.ColorScheme;
-public class GoalPanel extends JPanel implements Refreshable
+/**
+ * Panel showing a single Goal: header, action bar (undo/redo, prereqs),
+ * editable description, and the task list with add-new controls.
+ */
+public final class GoalPanel extends JPanel implements Refreshable
{
private final GoalTrackerPlugin plugin;
private final Goal goal;
@@ -27,6 +33,11 @@ public class GoalPanel extends JPanel implements Refreshable
private Consumer taskAddedListener;
private Consumer taskUpdatedListener;
+ private final ActionHistory actionHistory = new ActionHistory();
+ private ActionBarButton undoButton;
+ private ActionBarButton redoButton;
+ private ActionBarButton prereqsButton;
+
GoalPanel(GoalTrackerPlugin plugin, Goal goal, Runnable closeListener)
{
super();
@@ -35,53 +46,140 @@ public class GoalPanel extends JPanel implements Refreshable
setLayout(new BorderLayout());
- TextButton backButton = new TextButton("< Back", ColorScheme.PROGRESS_ERROR_COLOR);
- backButton.onClick((e) -> closeListener.run());
-
JPanel headerPanel = new JPanel(new BorderLayout());
- headerPanel.add(backButton, BorderLayout.WEST);
headerPanel.setBorder(new EmptyBorder(0, 0, 8, 0));
add(headerPanel, BorderLayout.NORTH);
+ // Back row above the action bar
+ JPanel backRow = new JPanel(new FlowLayout(FlowLayout.LEFT, 0, 0));
+ backRow.setBackground(ColorScheme.DARK_GRAY_COLOR);
+ ActionBarButton backButton = new ActionBarButton("Back", closeListener::run);
+ backRow.add(backButton);
+
+ // Action bar with Add pre-reqs on the left of Undo/Redo
+ ActionBar actionBar = new ActionBar();
+ prereqsButton = new ActionBarButton("Add Quest pre-reqs", this::addPrereqs);
+ prereqsButton.setToolTipText("Add prerequisite quests for this goal");
+ prereqsButton.setEnabled(true);
+
+ undoButton = new ActionBarButton("Undo", this::doUndo);
+ redoButton = new ActionBarButton("Redo", this::doRedo);
+
+ actionBar.left().add(prereqsButton);
+ actionBar.left().add(undoButton);
+ actionBar.left().add(redoButton);
+
+ ActionBarButton importButton = new ActionBarButton("Import", () -> {
+ String input = javax.swing.JOptionPane.showInputDialog(GoalPanel.this, "Paste goals JSON:");
+ if (input == null) {
+ return; // canceled
+ }
+ input = input.trim();
+ if (input.isEmpty()) {
+ return; // nothing to import
+ }
+
+ Object[] options = {"Overwrite", "Merge", "Cancel"};
+ int choice = javax.swing.JOptionPane.showOptionDialog(
+ GoalPanel.this,
+ "Importing will change your current goals.\nDo you want to overwrite existing goals or merge with them?",
+ "Import Goals",
+ javax.swing.JOptionPane.YES_NO_CANCEL_OPTION,
+ javax.swing.JOptionPane.QUESTION_MESSAGE,
+ null,
+ options,
+ options[2]
+ );
+
+ if (choice == javax.swing.JOptionPane.YES_OPTION) {
+ plugin.getGoalManager().importJson(input, true); // Overwrite
+ } else if (choice == javax.swing.JOptionPane.NO_OPTION) {
+ plugin.getGoalManager().importJson(input, false); // Merge
+ } else {
+ return; // canceled
+ }
+
+ // Refresh current view after import
+ plugin.setValidateAll(true);
+ plugin.getUiStatusManager().refresh(goal);
+ refreshTaskList();
+ });
+ actionBar.left().add(importButton);
+
+ // Stack back row above the action bar in the header's NORTH
+ JPanel headerTop = new JPanel();
+ headerTop.setBackground(ColorScheme.DARK_GRAY_COLOR);
+ headerTop.setLayout(new BoxLayout(headerTop, BoxLayout.Y_AXIS));
+ headerTop.add(backRow);
+ headerTop.add(actionBar);
+
+ headerPanel.add(headerTop, BorderLayout.NORTH);
+
+ updateUndoRedoButtons();
+
descriptionInput = new EditableInput((value) -> {
goal.setDescription(value);
- this.goalUpdatedListener.accept(goal);
+ if (this.goalUpdatedListener != null) this.goalUpdatedListener.accept(goal);
});
headerPanel.add(descriptionInput, BorderLayout.CENTER);
+ SwingUtilities.invokeLater(() -> installClipboardSupport(descriptionInput));
+ descriptionInput.addContainerListener(new java.awt.event.ContainerAdapter()
+ {
+ @Override public void componentAdded(java.awt.event.ContainerEvent e)
+ {
+ SwingUtilities.invokeLater(() -> installClipboardSupport(descriptionInput));
+ }
+ });
taskListPanel = new ListPanel<>(goal.getTasks(), (task) -> {
- ListTaskPanel taskPanel = new ListTaskPanel(goal.getTasks(), task);
- taskPanel.add(new TaskItemContent(plugin, task));
- taskPanel.setBorder(new EmptyBorder(2, 4, 2, 4));
-
- if (TaskType.MANUAL.equals(task.getType())) {
- ManualTask manualTask = (ManualTask) task;
+ try {
+ ListTaskPanel taskPanel = new ListTaskPanel(goal.getTasks(), task);
+ taskPanel.setActionHistory(actionHistory);
+ TaskItemContent taskContent = new TaskItemContent(plugin, goal, task);
+ taskContent.setActionHistory(actionHistory);
+ taskPanel.add(taskContent);
+ taskPanel.setTaskContent(taskContent);
+ taskContent.refresh();
+ taskPanel.setOpaque(true);
+ taskPanel.setBackground(ColorScheme.DARK_GRAY_COLOR);
+ taskPanel.setBorder(BorderFactory.createCompoundBorder(
+ BorderFactory.createMatteBorder(1, 6, 0, 6, ColorScheme.DARKER_GRAY_COLOR), // thinner divider
+ new EmptyBorder(2, 4, 2, 4)
+ ));
- taskPanel.onClick(e -> {
- manualTask.toggle();
- if (task.getStatus().isCompleted()) {
- plugin.notifyTask(task);
- }
-
- plugin.getUiStatusManager().refresh(task);
- this.taskUpdatedListener.accept(manualTask);
+ taskPanel.onIndented(e -> {
+ if (this.goalUpdatedListener != null) this.goalUpdatedListener.accept(goal);
+ plugin.getUiStatusManager().refresh(goal);
+ this.refresh();
});
- }
- taskPanel.onIndented(e -> {
- this.goalUpdatedListener.accept(goal);
- plugin.getUiStatusManager().refresh(goal);
- this.refresh();
- });
+ taskPanel.onUnindented(e -> {
+ if (this.goalUpdatedListener != null) this.goalUpdatedListener.accept(goal);
+ plugin.getUiStatusManager().refresh(goal);
+ this.refresh();
+ });
- taskPanel.onUnindented(e -> {
- this.goalUpdatedListener.accept(goal);
- plugin.getUiStatusManager().refresh(goal);
- this.refresh();
- });
+ taskPanel.onRemovedWithIndex((removedTask, index) -> {
+ actionHistory.push(new RemoveTaskAction(goal.getTasks(), removedTask, index));
+ updateUndoRedoButtons();
+ });
- return taskPanel;
+ return taskPanel;
+ } catch (Throwable t) {
+ t.printStackTrace();
+ // Render a minimal, visible error row instead of breaking the entire list
+ ListTaskPanel fallback = new ListTaskPanel(goal.getTasks(), task);
+ JPanel error = new JPanel(new BorderLayout());
+ error.setOpaque(true);
+ error.setBackground(ColorScheme.DARK_GRAY_COLOR);
+ JLabel msg = new JLabel("\u26A0\uFE0F Failed to render task; see log.");
+ msg.setForeground(ColorScheme.LIGHT_GRAY_COLOR);
+ msg.setBorder(new EmptyBorder(2, 4, 2, 4));
+ error.add(msg, BorderLayout.CENTER);
+ fallback.add(error);
+ return fallback;
+ }
});
taskListPanel.setGap(0);
taskListPanel.setPlaceholder("No tasks added yet");
@@ -90,6 +188,13 @@ public class GoalPanel extends JPanel implements Refreshable
NewTaskPanel newTaskPanel = new NewTaskPanel(plugin, goal);
newTaskPanel.onTaskAdded(this::updateFromNewTask);
add(newTaskPanel, BorderLayout.SOUTH);
+
+ // Global ESC to go back
+ getInputMap(JComponent.WHEN_ANCESTOR_OF_FOCUSED_COMPONENT)
+ .put(KeyStroke.getKeyStroke(java.awt.event.KeyEvent.VK_ESCAPE, 0), "gt.back");
+ getActionMap().put("gt.back", new javax.swing.AbstractAction() {
+ @Override public void actionPerformed(java.awt.event.ActionEvent e) { closeListener.run(); }
+ });
}
public void updateFromNewTask(Task task)
@@ -97,11 +202,23 @@ public void updateFromNewTask(Task task)
taskListPanel.tryBuildList();
taskListPanel.refresh();
plugin.setValidateAll(true);
+ plugin.getUiStatusManager().refresh(goal);
+ revalidate();
+ repaint();
if (Objects.nonNull(this.taskAddedListener)) this.taskAddedListener.accept(task);
if (Objects.nonNull(this.taskUpdatedListener)) this.taskUpdatedListener.accept(task);
}
+ public void refreshTaskList()
+ {
+ taskListPanel.tryBuildList();
+ taskListPanel.refresh();
+ plugin.getUiStatusManager().refresh(goal);
+ revalidate();
+ repaint();
+ }
+
@Override
public void refresh()
{
@@ -127,4 +244,155 @@ public void onTaskUpdated(Consumer listener)
taskListPanel.onUpdated(this.taskUpdatedListener);
}
+ private void installClipboardSupport(Component root)
+ {
+ JTextComponent tc = findTextComponent(root);
+ if (tc == null)
+ {
+ return;
+ }
+
+ // Ensure transfer handler to enable clipboard operations on text property
+ tc.setTransferHandler(new TransferHandler("text"));
+ tc.setDragEnabled(true);
+
+ // Context menu with Cut/Copy/Paste/Select All
+ JPopupMenu menu = new JPopupMenu();
+ JMenuItem cut = new JMenuItem(new DefaultEditorKit.CutAction());
+ cut.setText("Cut");
+ JMenuItem copy = new JMenuItem(new DefaultEditorKit.CopyAction());
+ copy.setText("Copy");
+ JMenuItem paste = new JMenuItem(new DefaultEditorKit.PasteAction());
+ paste.setText("Paste");
+ JMenuItem selectAll = new JMenuItem("Select All");
+ selectAll.addActionListener(e -> tc.selectAll());
+ menu.add(cut);
+ menu.add(copy);
+ menu.add(paste);
+ menu.addSeparator();
+ menu.add(selectAll);
+ tc.setComponentPopupMenu(menu);
+
+ // Keyboard shortcuts (Ctrl/Cmd + C/V/X/A)
+ int mask = Toolkit.getDefaultToolkit().getMenuShortcutKeyMaskEx();
+ InputMap im = tc.getInputMap(JComponent.WHEN_FOCUSED);
+ ActionMap am = tc.getActionMap();
+ im.put(KeyStroke.getKeyStroke(KeyEvent.VK_C, mask), DefaultEditorKit.copyAction);
+ im.put(KeyStroke.getKeyStroke(KeyEvent.VK_V, mask), DefaultEditorKit.pasteAction);
+ im.put(KeyStroke.getKeyStroke(KeyEvent.VK_X, mask), DefaultEditorKit.cutAction);
+ im.put(KeyStroke.getKeyStroke(KeyEvent.VK_A, mask), DefaultEditorKit.selectAllAction);
+ am.put(DefaultEditorKit.copyAction, new DefaultEditorKit.CopyAction());
+ am.put(DefaultEditorKit.pasteAction, new DefaultEditorKit.PasteAction());
+ am.put(DefaultEditorKit.cutAction, new DefaultEditorKit.CutAction());
+ am.put(DefaultEditorKit.selectAllAction, new AbstractAction()
+ {
+ @Override public void actionPerformed(java.awt.event.ActionEvent e) { tc.selectAll(); }
+ });
+ }
+
+ private JTextComponent findTextComponent(Component c)
+ {
+ if (c instanceof JTextComponent)
+ {
+ return (JTextComponent) c;
+ }
+ if (c instanceof Container)
+ {
+ for (Component child : ((Container) c).getComponents())
+ {
+ JTextComponent tc = findTextComponent(child);
+ if (tc != null)
+ {
+ return tc;
+ }
+ }
+ }
+ return null;
+ }
+
+ private void updateUndoRedoButtons()
+ {
+ if (undoButton != null)
+ {
+ undoButton.setEnabled(actionHistory.hasUndo());
+ }
+ if (redoButton != null)
+ {
+ redoButton.setEnabled(actionHistory.hasRedo());
+ }
+ }
+
+ private void doUndo()
+ {
+ actionHistory.undo();
+ refreshTaskList();
+ updateUndoRedoButtons();
+ }
+
+ private void doRedo()
+ {
+ actionHistory.redo();
+ refreshTaskList();
+ updateUndoRedoButtons();
+ }
+
+ private void addPrereqs()
+ {
+ int processed = 0;
+ int invoked = 0;
+
+ java.util.List contents = new java.util.ArrayList<>();
+ collectTaskItemContents(taskListPanel, contents);
+
+ for (TaskItemContent tic : contents)
+ {
+ Task t = tic.getTask();
+ if (t != null && t.getType() == TaskType.QUEST)
+ {
+ processed++;
+ if (tic.addPrereqsFromContext())
+ {
+ invoked++;
+ }
+ }
+ }
+
+ // Refresh UI/state after batch
+ plugin.setValidateAll(true);
+ plugin.getUiStatusManager().refresh(goal);
+ refreshTaskList();
+ updateUndoRedoButtons();
+
+ if (processed == 0)
+ {
+ JOptionPane.showMessageDialog(this,
+ "No quest tasks found in this goal.",
+ "Add pre-reqs",
+ JOptionPane.INFORMATION_MESSAGE);
+ return;
+ }
+
+ if (invoked == 0)
+ {
+ JOptionPane.showMessageDialog(this,
+ "All pre-reqs have already been added.\n",
+ "Add pre-reqs",
+ JOptionPane.WARNING_MESSAGE);
+ }
+ }
+
+ private static void collectTaskItemContents(Component root, java.util.List out)
+ {
+ if (root instanceof TaskItemContent)
+ {
+ out.add((TaskItemContent) root);
+ }
+ if (root instanceof Container)
+ {
+ for (Component child : ((Container) root).getComponents())
+ {
+ collectTaskItemContents(child, out);
+ }
+ }
+ }
}
diff --git a/src/main/java/com/toofifty/goaltracker/ui/GoalTrackerPanel.java b/src/main/java/com/toofifty/goaltracker/ui/GoalTrackerPanel.java
index 18715a1..47c4622 100644
--- a/src/main/java/com/toofifty/goaltracker/ui/GoalTrackerPanel.java
+++ b/src/main/java/com/toofifty/goaltracker/ui/GoalTrackerPanel.java
@@ -1,136 +1,246 @@
package com.toofifty.goaltracker.ui;
+import com.toofifty.goaltracker.presets.GoalPresetRepository.Preset;
import com.toofifty.goaltracker.GoalManager;
import com.toofifty.goaltracker.GoalTrackerPlugin;
import com.toofifty.goaltracker.models.Goal;
+import com.toofifty.goaltracker.models.UndoStack;
import com.toofifty.goaltracker.models.task.Task;
+import com.toofifty.goaltracker.presets.GoalPresetRepository;
+import com.toofifty.goaltracker.ui.components.ActionBar;
+import com.toofifty.goaltracker.ui.components.ActionBarButton;
import com.toofifty.goaltracker.ui.components.ListItemPanel;
import com.toofifty.goaltracker.ui.components.ListPanel;
-import com.toofifty.goaltracker.ui.components.TextButton;
+import com.toofifty.goaltracker.utils.ReorderableList;
import net.runelite.client.ui.ColorScheme;
import net.runelite.client.ui.FontManager;
import net.runelite.client.ui.PluginPanel;
import javax.inject.Inject;
-import javax.inject.Singleton;
import javax.swing.*;
import javax.swing.border.EmptyBorder;
+import javax.swing.filechooser.FileNameExtensionFilter;
import java.awt.*;
-import java.util.Objects;
+import java.util.Collections;
+import java.util.Comparator;
import java.util.function.Consumer;
-@Singleton
-public class GoalTrackerPanel extends PluginPanel implements Refreshable
+/**
+ * Main plugin panel for Goal Tracker.
+ * Shows header (title, add/import/export controls), goal list with undo/redo,
+ * and switches between home view and individual goal panels.
+ */
+public final class GoalTrackerPanel extends PluginPanel implements Refreshable
{
private final JPanel mainPanel = new JPanel(new BorderLayout());
private final ListPanel goalListPanel;
private final GoalTrackerPlugin plugin;
+ private final GoalManager goalManager;
+ private final UndoStack undoStack = new UndoStack<>();
+ private ActionBarButton undoButtonRef;
+ private ActionBarButton redoButtonRef;
private GoalPanel goalPanel;
private Consumer goalAddedListener;
private Consumer goalUpdatedListener;
private Consumer taskAddedListener;
private Consumer taskUpdatedListener;
+ private Goal pendingNewGoal;
@Inject
public GoalTrackerPanel(GoalTrackerPlugin plugin, GoalManager goalManager)
{
super(false);
this.plugin = plugin;
+ this.goalManager = goalManager;
+ this.goalManager.addGoalsChangedListener(() -> 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");
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);
+ // Title row (top)
+ JPanel titleRow = new JPanel(new FlowLayout(FlowLayout.LEFT, 0, 0));
+ titleRow.setBackground(ColorScheme.DARK_GRAY_COLOR);
+ titleRow.add(title);
- panel.onClick(e -> this.view(goal));
- panel.add(new GoalItemContent(plugin, goal));
+ // Buttons row (bottom)
+ JPanel buttonsRow = new JPanel(new FlowLayout(FlowLayout.RIGHT, 5, 0));
+ buttonsRow.setBackground(ColorScheme.DARK_GRAY_COLOR);
+ ActionBarButton addGoalBtn = new ActionBarButton("+ Add goal", this::addNewGoal);
+ ActionBarButton addFromPresetBtn = new ActionBarButton("Add from Preset…", this::addFromPreset);
+ buttonsRow.add(addGoalBtn);
+ buttonsRow.add(addFromPresetBtn);
- return panel;
- }
- );
+ // Stack rows vertically
+ JPanel headerTop = new JPanel();
+ headerTop.setLayout(new BoxLayout(headerTop, BoxLayout.Y_AXIS));
+ headerTop.setBackground(ColorScheme.DARK_GRAY_COLOR);
+ headerTop.add(titleRow);
+ headerTop.add(Box.createVerticalStrut(4));
+ headerTop.add(buttonsRow);
+
+ titlePanel.add(headerTop, BorderLayout.CENTER);
+
+ // 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.safeView(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.setBorder(new EmptyBorder(0, 0, 0, 0));
+ JTextArea placeholder = new JTextArea("No goals yet.\nClick + Add goal above to create your first one.");
+ placeholder.setLineWrap(true);
+ placeholder.setWrapStyleWord(true);
+ placeholder.setEditable(false);
+ placeholder.setOpaque(false);
+ placeholder.setForeground(new Color(0xBFBFBF));
+ placeholder.setFont(FontManager.getRunescapeSmallFont());
+ placeholder.setBorder(new EmptyBorder(8, 0, 8, 0));
+ placeholder.setColumns(30);
+ placeholder.setMaximumSize(new Dimension(Integer.MAX_VALUE, placeholder.getPreferredSize().height));
+ goalListPanel.setPlaceholder(placeholder);
- 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();
}
+ private void safeView(Goal goal)
+ {
+ try
+ {
+ 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();
+ }
+ catch (Throwable t)
+ {
+ // Log to console and show a user-visible error so the panel isn't left blank
+ t.printStackTrace();
+ JOptionPane.showMessageDialog(this,
+ "Sorry, something went wrong opening that goal. Returning to Home.",
+ "Goal Open Error",
+ JOptionPane.ERROR_MESSAGE);
+ this.goalPanel = null;
+ home();
+ }
+ }
+
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.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 +249,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 +257,220 @@ 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()
+ {
+ // Keep pinned goals first, but do NOT alphabetize within groups.
+ // Collections.sort is stable (TimSort), so existing manual order is preserved within each group.
+ java.util.List goals = goalManager.getGoals();
+ Collections.sort(goals, Comparator.comparing(Goal::isPinned).reversed());
+ }
+
+ 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");
+ }
+ // Confirm overwrite if file already exists
+ if (file.exists())
+ {
+ int confirm = JOptionPane.showConfirmDialog(
+ this,
+ "File already exists:\n" + file.getAbsolutePath() + "\n\nOverwrite it?",
+ "Confirm Overwrite",
+ JOptionPane.YES_NO_OPTION,
+ JOptionPane.WARNING_MESSAGE
+ );
+ if (confirm != JOptionPane.YES_OPTION)
+ {
+ return; // user chose not to overwrite
+ }
+ }
+
+ 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()));
+
+ Object[] options = {"Overwrite", "Merge", "Cancel"};
+ int choice = JOptionPane.showOptionDialog(
+ this,
+ "Importing will change your current goals.\nDo you want to overwrite existing goals or merge with them?",
+ "Import Goals",
+ JOptionPane.YES_NO_CANCEL_OPTION,
+ JOptionPane.QUESTION_MESSAGE,
+ null,
+ options,
+ options[2]
+ );
+
+ if (choice == JOptionPane.CANCEL_OPTION || choice == JOptionPane.CLOSED_OPTION)
+ {
+ return; // user canceled
+ }
+
+ boolean overwrite = (choice == JOptionPane.YES_OPTION);
+ goalManager.importJson(json, overwrite);
+ 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());
+
+ 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/NewTaskPanel.java b/src/main/java/com/toofifty/goaltracker/ui/NewTaskPanel.java
index 10c586e..024446e 100644
--- a/src/main/java/com/toofifty/goaltracker/ui/NewTaskPanel.java
+++ b/src/main/java/com/toofifty/goaltracker/ui/NewTaskPanel.java
@@ -4,20 +4,19 @@
import com.toofifty.goaltracker.models.Goal;
import com.toofifty.goaltracker.models.task.Task;
import com.toofifty.goaltracker.ui.components.TextButton;
-import com.toofifty.goaltracker.ui.inputs.ItemTaskInput;
-import com.toofifty.goaltracker.ui.inputs.ManualTaskInput;
-import com.toofifty.goaltracker.ui.inputs.QuestTaskInput;
-import com.toofifty.goaltracker.ui.inputs.SkillLevelTaskInput;
-import com.toofifty.goaltracker.ui.inputs.SkillXpTaskInput;
-import java.awt.BorderLayout;
-import java.awt.GridBagConstraints;
-import java.awt.GridBagLayout;
-import java.util.function.Consumer;
-import javax.swing.JPanel;
-import javax.swing.border.EmptyBorder;
+import com.toofifty.goaltracker.ui.inputs.*;
import net.runelite.client.ui.ColorScheme;
-public class NewTaskPanel extends JPanel
+import javax.swing.*;
+import javax.swing.border.EmptyBorder;
+import java.awt.*;
+import java.util.function.Consumer;
+
+/**
+ * Panel for adding new tasks: quick Manual input and expandable options (skills, quests, items).
+ * Toggles a "More options" section and forwards created tasks to a listener.
+ */
+public final class NewTaskPanel extends JPanel
{
private final TextButton moreOptionsButton;
private final GoalTrackerPlugin plugin;
diff --git a/src/main/java/com/toofifty/goaltracker/ui/TaskItemContent.java b/src/main/java/com/toofifty/goaltracker/ui/TaskItemContent.java
index 9f0adbf..6315c00 100644
--- a/src/main/java/com/toofifty/goaltracker/ui/TaskItemContent.java
+++ b/src/main/java/com/toofifty/goaltracker/ui/TaskItemContent.java
@@ -1,48 +1,161 @@
package com.toofifty.goaltracker.ui;
import com.toofifty.goaltracker.GoalTrackerPlugin;
+import com.toofifty.goaltracker.models.ActionHistory;
+import com.toofifty.goaltracker.models.Goal;
+import com.toofifty.goaltracker.models.ToggleCompleteAction;
+import com.toofifty.goaltracker.models.enums.Status;
+import com.toofifty.goaltracker.models.task.ManualTask;
import com.toofifty.goaltracker.models.task.Task;
import com.toofifty.goaltracker.services.TaskIconService;
+import com.toofifty.goaltracker.ui.components.ListItemPanel;
import javax.swing.*;
import javax.swing.border.EmptyBorder;
import java.awt.*;
+import java.awt.event.ComponentAdapter;
+import java.awt.event.ComponentEvent;
+import java.awt.event.MouseAdapter;
+import java.awt.event.MouseEvent;
+import java.util.Locale;
import static com.toofifty.goaltracker.utils.Constants.STATUS_TO_COLOR;
-public class TaskItemContent extends JPanel implements Refreshable
+/**
+ * UI component for rendering a single Task row inside a Goal.
+ * Shows icon, title (editable for manual tasks), right-click context menu,
+ * and handles refreshing on task updates.
+ */
+public final class TaskItemContent extends JPanel implements Refreshable
{
+ private static final int INDENT_PER_LEVEL = 12; // pixels per indent level
+
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() || 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 level = Math.max(0, task.getIndentLevel());
+ // Shift rendering so level 0 = 0px, level 1 = 0px, level 2 = 12px, etc.
+ // This avoids the first child appearing with an extra indent when prereqs are added.
+ int indent = (level <= 1) ? 0 : (level - 1) * INDENT_PER_LEVEL;
- int indent = 16 * task.getIndentLevel();
iconLabel.setIcon(iconService.get(task));
- iconLabel.setBorder(new EmptyBorder(0, indent, 0, 0));
+ // Apply indent to the wrapper instead of the label to avoid double padding
+ iconLabel.setBorder(new EmptyBorder(0, 0, 0, 0));
+ iconWrapper.setBorder(new EmptyBorder(4, indent, 0, 4));
revalidate();
}
@@ -55,4 +168,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..fb29c12
--- /dev/null
+++ b/src/main/java/com/toofifty/goaltracker/ui/components/ActionBar.java
@@ -0,0 +1,38 @@
+package com.toofifty.goaltracker.ui.components;
+
+import net.runelite.client.ui.ColorScheme;
+
+import javax.swing.*;
+import javax.swing.border.EmptyBorder;
+import java.awt.*;
+
+/**
+ * Horizontal container with left- and right-aligned sections for action buttons.
+ */
+public final 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..efca3bf
--- /dev/null
+++ b/src/main/java/com/toofifty/goaltracker/ui/components/ActionBarButton.java
@@ -0,0 +1,85 @@
+package com.toofifty.goaltracker.ui.components;
+
+import net.runelite.client.ui.ColorScheme;
+import net.runelite.client.ui.FontManager;
+
+import javax.swing.*;
+import javax.swing.border.EmptyBorder;
+import java.awt.*;
+import java.awt.event.MouseAdapter;
+import java.awt.event.MouseEvent;
+
+/**
+ * Styled small button used inside ActionBar, with hover background effect.
+ */
+public final 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..c86c4fe 100644
--- a/src/main/java/com/toofifty/goaltracker/ui/components/ComboBox.java
+++ b/src/main/java/com/toofifty/goaltracker/ui/components/ComboBox.java
@@ -1,113 +1,230 @@
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.util.List;
-import java.util.function.Function;
-import javax.swing.ImageIcon;
-import javax.swing.JButton;
-import javax.swing.JComboBox;
-import javax.swing.JLabel;
-import javax.swing.JList;
-import javax.swing.JPanel;
-import javax.swing.ListCellRenderer;
-import javax.swing.border.EmptyBorder;
-import javax.swing.plaf.basic.BasicComboBoxUI;
import net.runelite.client.ui.ColorScheme;
-import net.runelite.client.util.ImageUtil;
import net.runelite.client.util.Text;
-public class ComboBox extends JComboBox
+import javax.swing.*;
+import javax.swing.border.EmptyBorder;
+import javax.swing.event.PopupMenuEvent;
+import javax.swing.event.PopupMenuListener;
+import javax.swing.plaf.basic.BasicComboBoxUI;
+import java.awt.*;
+import java.util.List;
+import java.util.function.Function;
+
+/**
+ * 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
+ */
+/**
+ * Generic RuneLite-styled combo box with optional formatting, custom arrow icons, and font scaling.
+ */
+public final 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..a6bc764 100644
--- a/src/main/java/com/toofifty/goaltracker/ui/components/EditableInput.java
+++ b/src/main/java/com/toofifty/goaltracker/ui/components/EditableInput.java
@@ -1,22 +1,25 @@
package com.toofifty.goaltracker.ui.components;
-import java.awt.BorderLayout;
-import java.awt.Color;
-import java.awt.Dimension;
+import lombok.Setter;
+import net.runelite.client.ui.ColorScheme;
+import net.runelite.client.ui.components.FlatTextField;
+
+import javax.swing.*;
+import javax.swing.border.Border;
+import javax.swing.border.CompoundBorder;
+import javax.swing.border.EmptyBorder;
+import javax.swing.text.DefaultEditorKit;
+import java.awt.*;
import java.awt.event.KeyAdapter;
import java.awt.event.KeyEvent;
import java.awt.event.MouseListener;
import java.util.function.Consumer;
-import javax.swing.BorderFactory;
-import javax.swing.JPanel;
-import javax.swing.border.Border;
-import javax.swing.border.CompoundBorder;
-import javax.swing.border.EmptyBorder;
-import lombok.Setter;
-import net.runelite.client.ui.ColorScheme;
-import net.runelite.client.ui.components.FlatTextField;
-public class EditableInput extends JPanel
+/**
+ * Editable text field with Save, Cancel, and Edit controls.
+ * Provides copy/paste shortcuts and context menu support.
+ */
+public final class EditableInput extends JPanel
{
private static final Border INPUT_BOTTOM_BORDER = new CompoundBorder(
@@ -54,7 +57,7 @@ public EditableInput(Consumer saveAction)
cancel.onClick(e -> this.cancel());
edit.onClick(e -> {
- inputField.setEditable(true);
+ inputField.getTextField().setEditable(true);
updateActions(true);
});
@@ -64,12 +67,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 +125,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..cca5d88 100644
--- a/src/main/java/com/toofifty/goaltracker/ui/components/ListItemPanel.java
+++ b/src/main/java/com/toofifty/goaltracker/ui/components/ListItemPanel.java
@@ -1,21 +1,33 @@
package com.toofifty.goaltracker.ui.components;
-import com.toofifty.goaltracker.utils.ReorderableList;
+import com.toofifty.goaltracker.models.Goal;
import com.toofifty.goaltracker.ui.Refreshable;
-import java.awt.BorderLayout;
-import java.awt.Color;
-import java.awt.Component;
+import com.toofifty.goaltracker.utils.ReorderableList;
+import net.runelite.client.ui.ColorScheme;
+
+import javax.swing.*;
+import javax.swing.border.Border;
+import javax.swing.border.EmptyBorder;
+import javax.swing.border.MatteBorder;
+import java.awt.*;
import java.awt.event.MouseAdapter;
import java.awt.event.MouseEvent;
+import java.util.function.BiConsumer;
import java.util.function.Consumer;
-import javax.swing.JMenuItem;
-import javax.swing.JPanel;
-import javax.swing.JPopupMenu;
-import javax.swing.border.EmptyBorder;
-import net.runelite.client.ui.ColorScheme;
+/**
+ * Generic panel representing a list item (Goal or Task).
+ * Provides context menu actions (move, remove) and hover/press styling.
+ */
public class ListItemPanel extends JPanel implements Refreshable
{
+ private static final int BASE_TOP = 8, BASE_LEFT = 6, BASE_BOTTOM = 8, BASE_RIGHT = 6;
+ private static final int TAN_THICKNESS = 1;
+ private static final int INNER_TOP = 4, INNER_LEFT = 6, INNER_BOTTOM = 4, INNER_RIGHT = 6;
+
+ private static final Border UNPINNED_BORDER = new EmptyBorder(BASE_TOP, BASE_LEFT, BASE_BOTTOM, BASE_RIGHT);
+ private static final Border PINNED_BORDER = new MatteBorder(TAN_THICKNESS, TAN_THICKNESS, TAN_THICKNESS, TAN_THICKNESS, new Color(210, 180, 140));
+
protected final JMenuItem moveUp = new JMenuItem("Move up");
protected final JMenuItem moveDown = new JMenuItem("Move down");
protected final JMenuItem moveToTop = new JMenuItem("Move to top");
@@ -28,6 +40,51 @@ 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())
+ {
+ 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 +92,141 @@ 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);
+ cardBody.setMaximumSize(new Dimension(Integer.MAX_VALUE, Integer.MAX_VALUE));
+ // 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);
+ requestContainerRefresh();
});
moveDown.addActionListener(e -> {
list.moveDown(item);
- this.reorderedListener.accept(item);
+ if (this.reorderedListener != null) this.reorderedListener.accept(item);
+ requestContainerRefresh();
});
moveToTop.addActionListener(e -> {
list.moveToTop(item);
- this.reorderedListener.accept(item);
+ if (this.reorderedListener != null) this.reorderedListener.accept(item);
+ requestContainerRefresh();
});
moveToBottom.addActionListener(e -> {
list.moveToBottom(item);
- this.reorderedListener.accept(item);
+ if (this.reorderedListener != null) this.reorderedListener.accept(item);
+ requestContainerRefresh();
});
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));
-
+ cardBody.setComponentPopupMenu(popupMenu);
setComponentPopupMenu(popupMenu);
+ // Also show context menu on press/release to handle platform differences
+ // this.addMouseListener(contextMenuListener); // Removed to avoid duplicate popup invocations
+ // Ensure popup and click listeners cover all descendants initially
+ // addContextMenuListenerRecursive(this); // Removed to avoid multiple menus
+
+ setOpaque(true);
+
+ // Attach context-menu and click listeners automatically to any components added later inside the card
+ cardBody.addContainerListener(new java.awt.event.ContainerAdapter() {
+ @Override
+ public void componentAdded(java.awt.event.ContainerEvent e) {
+ java.awt.Component child = e.getChild();
+ if (child instanceof JComponent) {
+ ((JComponent) child).setComponentPopupMenu(popupMenu);
+ }
+ if (clickListenerAdapter != null) {
+ addClickListenerRecursive(child);
+ }
+ }
+ });
}
@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));
+ ((JComponent) comp).setComponentPopupMenu(popupMenu);
+ }
+ // 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));
+ ((JComponent) comp).setComponentPopupMenu(popupMenu);
}
+ // addContextMenuListenerRecursive(comp);
+ if (clickListenerAdapter != null) addClickListenerRecursive(comp);
+ return comp;
}
+ return super.add(name, comp);
}
- public void refreshMenu()
+ public ListItemPanel add(Component comp)
{
- popupMenu.removeAll();
- if (!list.isFirst(item)) {
- popupMenu.add(moveUp);
- }
- if (!list.isLast(item)) {
- popupMenu.add(moveDown);
- }
- if (!list.isFirst(item)) {
- popupMenu.add(moveToTop);
+ 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));
}
- if (!list.isLast(item)) {
- popupMenu.add(moveToBottom);
+ if (comp instanceof JComponent) {
+ ((JComponent) comp).setComponentPopupMenu(popupMenu);
}
- popupMenu.add(removeItem);
- }
-
- public ListItemPanel add(Component comp)
- {
- super.add(comp, BorderLayout.CENTER);
+ // 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 +234,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 +275,199 @@ public void onRemoved(Consumer removeListener) {
public void onReordered(Consumer reorderListener) {
this.reorderedListener = reorderListener;
}
+
+ public void onRemovedWithIndex(BiConsumer removeListener) {
+ this.removedWithIndexListener = removeListener;
+ }
+
+ private void requestContainerRefresh()
+ {
+ java.awt.Container p = getParent();
+ while (p != null)
+ {
+ if (p instanceof com.toofifty.goaltracker.ui.GoalTrackerPanel)
+ {
+ ((com.toofifty.goaltracker.ui.GoalTrackerPanel) p).refresh();
+ return;
+ }
+ p = p.getParent();
+ }
+ // Fallback if no GoalTrackerPanel ancestor is found
+ revalidate();
+ repaint();
+ }
+
+ private void applyBaseBorderWithOptionalTan()
+ {
+ // Outer panel: keep spacing constant
+ setBorder(UNPINNED_BORDER);
+
+ if (cardBody == null)
+ {
+ return;
+ }
+
+ // Ensure the inner content always has consistent padding
+ cardBody.setBorder(new EmptyBorder(INNER_TOP, INNER_LEFT, INNER_BOTTOM, INNER_RIGHT));
+
+ if (item instanceof Goal && ((Goal) item).isPinned())
+ {
+ // Apply tan to the OUTER card border; keep total insets stable
+ int top = Math.max(0, BASE_TOP - TAN_THICKNESS);
+ int left = Math.max(0, BASE_LEFT - TAN_THICKNESS);
+ int bottom = Math.max(0, BASE_BOTTOM - TAN_THICKNESS);
+ int right = Math.max(0, BASE_RIGHT - TAN_THICKNESS);
+ Border inner = new EmptyBorder(top, left, bottom, right);
+ Color tan = new Color(210, 180, 140);
+ setBorder(BorderFactory.createCompoundBorder(
+ new MatteBorder(TAN_THICKNESS, TAN_THICKNESS, TAN_THICKNESS, TAN_THICKNESS, tan),
+ inner
+ ));
+ }
+ // else: leave UNPINNED_BORDER already applied above
+ }
+
+ private void applyGoalCardDefaultStyle()
+ {
+ // Borders
+ applyBaseBorderWithOptionalTan();
+
+ // Backgrounds
+ setBackground(ColorScheme.DARK_GRAY_COLOR); // keep outer area stable
+ if (cardBody != null)
+ {
+ cardBody.setBackground(ColorScheme.DARK_GRAY_COLOR);
+ }
+ }
+
+ private void applyGoalCardHoverStyle()
+ {
+ // Keep borders as set by applyBaseBorderWithOptionalTan();
+ applyBaseBorderWithOptionalTan();
+ if (cardBody != null)
+ {
+ cardBody.setBackground(ColorScheme.DARKER_GRAY_HOVER_COLOR);
+ }
+ }
+
+ private void applyGoalCardPressedStyle()
+ {
+ // Keep borders as set by applyBaseBorderWithOptionalTan();
+ applyBaseBorderWithOptionalTan();
+ 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();
+
+ // Deduplicate Pin/Unpin items if multiple sources added them
+ for (int i = popupMenu.getComponentCount() - 1, seenPin = 0, seenUnpin = 0; i >= 0; i--)
+ {
+ java.awt.Component c = popupMenu.getComponent(i);
+ if (c instanceof javax.swing.JMenuItem)
+ {
+ String text = ((javax.swing.JMenuItem) c).getText();
+ if ("Pin goal".equals(text))
+ {
+ if (seenPin++ > 0) popupMenu.remove(i);
+ }
+ else if ("Unpin goal".equals(text))
+ {
+ if (seenUnpin++ > 0) popupMenu.remove(i);
+ }
+ }
+ }
+
+ // 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)
+ {
+ Goal goal = (Goal) item;
+ JMenuItem pinToggle = new JMenuItem(goal.isPinned() ? "Unpin goal" : "Pin goal");
+ pinToggle.addActionListener(e -> {
+ // Toggle pin state
+ boolean nowPinned = !goal.isPinned();
+ goal.setPinned(nowPinned);
+
+ // Reorder this item in the visible list immediately so it moves without waiting on a rebuild
+ try {
+ if (nowPinned) {
+ list.moveToTop(item);
+ } else {
+ list.moveToBottom(item);
+ }
+ } catch (Throwable ignore) {
+ // If list doesn't support it for some reason, we still continue with refresh
+ }
+
+ // Refresh this card and ask the container to repaint/rebuild
+ refresh();
+ requestContainerRefresh();
+ });
+ popupMenu.add(pinToggle);
+
+ JMenuItem markAllComplete = new JMenuItem("Mark all as completed");
+ JMenuItem markAllIncomplete = new JMenuItem("Mark all as incomplete");
+
+ markAllComplete.addActionListener(e -> {
+ goal.setAllTasksCompleted(true);
+ refresh();
+ });
+
+ markAllIncomplete.addActionListener(e -> {
+ 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..9411730 100644
--- a/src/main/java/com/toofifty/goaltracker/ui/components/ListPanel.java
+++ b/src/main/java/com/toofifty/goaltracker/ui/components/ListPanel.java
@@ -14,7 +14,11 @@
import java.util.function.Function;
import java.util.stream.Collectors;
-public class ListPanel extends JScrollPane implements Refreshable
+/**
+ * Scrollable container that renders and manages a list of items with optional placeholder text.
+ * Supports reordering, removal, and refresh of child panels.
+ */
+public final class ListPanel extends JScrollPane implements Refreshable
{
private final JPanel listPanel = new JPanel(new GridBagLayout());
@@ -24,9 +28,12 @@ public class ListPanel extends JScrollPane implements Refreshable
private final Map> itemPanelMap = new HashMap<>();
private int gap = 2;
- private String placeholder = "Nothing interesting happens.";
+ private JComponent placeholder = new JLabel("Nothing interesting happens.");
private Consumer updatedListener;
+ private int rowLeftInset = 0;
+ private int rowRightInset = 0;
+
public ListPanel(
ReorderableList reorderableList,
Function> renderItem
@@ -35,15 +42,16 @@ public ListPanel(
this.reorderableList = reorderableList;
this.renderItem = renderItem;
- setBorder(new EmptyBorder(10, 10, 10, 10));
listPanel.setBackground(ColorScheme.DARKER_GRAY_COLOR);
+ // Add left/right padding so card content aligns with the header
+ listPanel.setBorder(new EmptyBorder(0, 10, 0, 10));
JPanel wrapperPanel = new JPanel(new BorderLayout());
wrapperPanel.setBackground(ColorScheme.DARKER_GRAY_COLOR);
wrapperPanel.add(listPanel, BorderLayout.NORTH);
- setBorder(new EmptyBorder(4, 4, 4 - gap, 4));
+ setBorder(new EmptyBorder(0, 0, 0, 0));
setBackground(ColorScheme.DARKER_GRAY_COLOR);
getVerticalScrollBar().setPreferredSize(new Dimension(12, 0));
getVerticalScrollBar().setBorder(new EmptyBorder(0, 4, 0, 0));
@@ -57,13 +65,26 @@ public ListPanel(
public void setGap(int gap)
{
this.gap = gap;
- setBorder(new EmptyBorder(4, 4, 4 - gap, 4));
+ setBorder(new EmptyBorder(0, 0, 0, 0));
+ tryBuildList();
+ }
+
+ public void setRowSideInsets(int left, int right)
+ {
+ this.rowLeftInset = Math.max(0, left);
+ this.rowRightInset = Math.max(0, right);
+ tryBuildList();
+ }
+
+ public void setPlaceholder(String placeholderText)
+ {
+ this.placeholder = new JLabel(placeholderText);
tryBuildList();
}
- public void setPlaceholder(String placeholder)
+ public void setPlaceholder(JComponent placeholderComponent)
{
- this.placeholder = placeholder;
+ this.placeholder = placeholderComponent;
tryBuildList();
}
@@ -78,7 +99,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 +120,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);
@@ -123,7 +145,7 @@ private GridBagConstraints getConstraints(int gridy)
constraints.weightx = 1;
constraints.gridy = gridy;
constraints.gridx = 0;
- constraints.insets = new Insets(0, 0, gap, 0);
+ constraints.insets = new Insets(4, rowLeftInset, gap, rowRightInset);
return constraints;
}
@@ -131,7 +153,7 @@ private void refreshChildMenus()
{
for (Component component : listPanel.getComponents()) {
if (component instanceof ListItemPanel) {
- ((ListItemPanel>) component).refreshMenu();
+ ((Refreshable) component).refresh();
}
}
}
@@ -141,14 +163,18 @@ private void refreshChildMenus()
*/
public void tryBuildList()
{
+ itemPanelMap.clear();
+
if (reorderableList.isEmpty()) {
listPanel.removeAll();
- JLabel placeholderLabel = new JLabel(placeholder);
- placeholderLabel.setForeground(ColorScheme.LIGHT_GRAY_COLOR);
+ if (placeholder instanceof JLabel) {
+ ((JLabel) placeholder).setForeground(ColorScheme.LIGHT_GRAY_COLOR);
+ }
JPanel placeholderPanel = new JPanel();
placeholderPanel.setBackground(ColorScheme.DARK_GRAY_COLOR);
- placeholderPanel.add(placeholderLabel);
+ placeholderPanel.setLayout(new FlowLayout(FlowLayout.CENTER, 0, 0));
+ placeholderPanel.add(placeholder);
listPanel.add(placeholderPanel, getConstraints());
} else {
listPanel.removeAll();
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..5dc296e 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,227 @@
package com.toofifty.goaltracker.ui.components;
-import com.toofifty.goaltracker.utils.ReorderableList;
+import com.toofifty.goaltracker.models.ActionHistory;
+import com.toofifty.goaltracker.models.ReorderTaskAction;
+import com.toofifty.goaltracker.models.enums.Status;
+import com.toofifty.goaltracker.models.task.QuestTask;
import com.toofifty.goaltracker.models.task.Task;
+import com.toofifty.goaltracker.ui.TaskItemContent;
+import com.toofifty.goaltracker.utils.QuestRequirements;
+import com.toofifty.goaltracker.utils.ReorderableList;
+
+import javax.swing.*;
+import java.awt.*;
+import java.awt.event.MouseAdapter;
+import java.awt.event.MouseEvent;
import java.util.function.Consumer;
-import javax.swing.JMenuItem;
-public class ListTaskPanel extends ListItemPanel
+/**
+ * Task row panel with context menu (move, indent, toggle),
+ * cascading child handling, and shift-click remove support.
+ */
+public final 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 +230,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 QuestTask) {
+ QuestTask questTask = (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 (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 (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 (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 +371,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 QuestTask) {
+ QuestTask questTask = (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 (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 (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 (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 QuestTask)) {
+ return;
+ }
+ QuestTask questTask = (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 (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 (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..36b2d0c 100644
--- a/src/main/java/com/toofifty/goaltracker/ui/components/TextButton.java
+++ b/src/main/java/com/toofifty/goaltracker/ui/components/TextButton.java
@@ -1,16 +1,20 @@
package com.toofifty.goaltracker.ui.components;
-import java.awt.Color;
-import java.awt.Cursor;
+import net.runelite.client.ui.ColorScheme;
+import net.runelite.client.ui.FontManager;
+
+import javax.swing.*;
+import javax.swing.border.EmptyBorder;
+import java.awt.*;
import java.awt.event.MouseAdapter;
import java.awt.event.MouseEvent;
import java.util.function.Consumer;
-import javax.swing.JLabel;
-import javax.swing.border.EmptyBorder;
-import net.runelite.client.ui.ColorScheme;
-import net.runelite.client.ui.FontManager;
-public class TextButton extends JLabel
+/**
+ * Small clickable label styled as a button, used for text-based actions.
+ * Supports hover effects and consumer-based click listeners.
+ */
+public final class TextButton extends JLabel
{
private Color mainColor = ColorScheme.PROGRESS_COMPLETE_COLOR;
@@ -75,4 +79,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..9a2161d 100644
--- a/src/main/java/com/toofifty/goaltracker/ui/inputs/ItemTaskInput.java
+++ b/src/main/java/com/toofifty/goaltracker/ui/inputs/ItemTaskInput.java
@@ -14,16 +14,28 @@
import javax.swing.*;
import javax.swing.border.EmptyBorder;
+import javax.swing.event.DocumentEvent;
+import javax.swing.event.DocumentListener;
+import javax.swing.text.JTextComponent;
import java.awt.*;
+import java.awt.event.ActionListener;
import java.util.regex.Pattern;
-public class ItemTaskInput extends TaskInput
+
+
+/**
+ * Input panel for creating Item tasks.
+ * Provides search with close overlay, quantity field with k/m suffix support,
+ * and automatic task submission on selection.
+ */
+public final 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 +45,7 @@ public class ItemTaskInput extends TaskInput
private String quantityFieldValue = "1";
private ItemComposition selectedItem;
+ private String lastSearchText = "";
public ItemTaskInput(GoalTrackerPlugin plugin, Goal goal)
{
@@ -41,18 +54,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 +132,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 +158,7 @@ protected void submit()
.itemId(selectedItem.getId())
.itemName(selectedItem.getName())
.quantity(Integer.parseInt(quantityField.getText()))
- .build());
+ .build());
}
@Override
@@ -142,5 +178,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 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 JFrame) {
+ ((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 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..bb5e479 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,11 @@
import java.awt.event.KeyAdapter;
import java.awt.event.KeyEvent;
-public class ManualTaskInput extends TaskInput
+/**
+ * Input panel for creating simple manual tasks.
+ * Provides a text field with Enter-to-submit behavior.
+ */
+public final class ManualTaskInput extends TaskInput
{
private final FlatTextField titleField;
@@ -50,4 +54,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..003c846 100644
--- a/src/main/java/com/toofifty/goaltracker/ui/inputs/QuestTaskInput.java
+++ b/src/main/java/com/toofifty/goaltracker/ui/inputs/QuestTaskInput.java
@@ -6,38 +6,83 @@
import com.toofifty.goaltracker.ui.components.ComboBox;
import net.runelite.api.Quest;
+import javax.swing.*;
import java.awt.*;
+import java.awt.event.ActionEvent;
+import java.awt.event.KeyEvent;
import java.util.Arrays;
import java.util.Comparator;
import java.util.List;
-public class QuestTaskInput extends TaskInput
+/**
+ * Input panel for creating Quest tasks.
+ * Provides a searchable dropdown with RuneScape UF font and Enter-to-submit support.
+ */
+public final 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..b8e9e54 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,12 @@
import java.awt.*;
import java.util.regex.Pattern;
-public class SkillLevelTaskInput extends TaskInput
+/**
+ * Input panel for creating Skill Level tasks.
+ * Provides a numeric level field (validated 1–99) and a skill dropdown.
+ */
+public final class SkillLevelTaskInput extends TaskInput
{
-
private FlatTextField levelField;
private String levelFieldValue = "99";
@@ -61,7 +64,7 @@ protected void submit()
addTask(SkillLevelTask.builder()
.skill((Skill) skillField.getSelectedItem())
.level(Integer.parseInt(levelField.getText()))
- .build());
+ .build());
}
@Override
@@ -72,4 +75,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..6d2275e 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,11 @@
import java.awt.*;
import java.util.regex.Pattern;
-public class SkillXpTaskInput extends TaskInput
+/**
+ * Input panel for creating Skill XP tasks.
+ * Provides a numeric XP field (supports k/m suffixes, capped at 200m) and a skill dropdown.
+ */
+public final class SkillXpTaskInput extends TaskInput
{
private final FlatTextField xpField;
private final ComboBox skillField;
@@ -79,7 +83,7 @@ protected void submit()
addTask(SkillXpTask.builder()
.skill((Skill) skillField.getSelectedItem())
.xp(Integer.parseInt(xpField.getText()))
- .build());
+ .build());
}
@Override
@@ -90,4 +94,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..bed95c1 100644
--- a/src/main/java/com/toofifty/goaltracker/ui/inputs/TaskInput.java
+++ b/src/main/java/com/toofifty/goaltracker/ui/inputs/TaskInput.java
@@ -1,29 +1,33 @@
+
package com.toofifty.goaltracker.ui.inputs;
import com.toofifty.goaltracker.GoalTrackerPlugin;
import com.toofifty.goaltracker.models.Goal;
import com.toofifty.goaltracker.models.task.Task;
import com.toofifty.goaltracker.ui.components.TextButton;
-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;
-public abstract class TaskInput extends JPanel
+import javax.swing.*;
+import javax.swing.border.EmptyBorder;
+import java.awt.*;
+import java.util.Collection;
+import java.util.function.Consumer;
+
+/**
+ * Abstract base panel for creating new tasks.
+ * Provides a title label, input row, optional Add button, and submit handling.
+ */
+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 +55,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/Constants.java b/src/main/java/com/toofifty/goaltracker/utils/Constants.java
index 84b91c3..f03e34c 100644
--- a/src/main/java/com/toofifty/goaltracker/utils/Constants.java
+++ b/src/main/java/com/toofifty/goaltracker/utils/Constants.java
@@ -6,7 +6,10 @@
import java.awt.*;
import java.util.Map;
-public class Constants {
+/**
+ * Shared plugin-wide constants (colors, keys, etc.).
+ */
+public final class Constants {
public static final Map STATUS_TO_COLOR = Map.of(
Status.NOT_STARTED, ColorScheme.PROGRESS_ERROR_COLOR,
Status.IN_PROGRESS, ColorScheme.PROGRESS_INPROGRESS_COLOR,
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..376158c
--- /dev/null
+++ b/src/main/java/com/toofifty/goaltracker/utils/QuestRequirements.java
@@ -0,0 +1,1255 @@
+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.*;
+
+/**
+ * Static lookup of quest prerequisites (quests + skill levels) and a helper to
+ * expand nested requirements with appropriate indent levels.
+ */
+public final 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_PATH_OF_GLOUPHRIE,
+ Arrays.asList(
+ QuestTask.builder().quest(Quest.WATERFALL_QUEST).build(),
+ QuestTask.builder().quest(Quest.THE_EYES_OF_GLOUPHRIE).build(),
+ QuestTask.builder().quest(Quest.TREE_GNOME_VILLAGE).build(),
+ QuestTask.builder().quest(Quest.THE_GRAND_TREE).build(),
+ SkillLevelTask.builder().skill(Skill.STRENGTH).level(60).build(),
+ SkillLevelTask.builder().skill(Skill.SLAYER).level(56).build(),
+ SkillLevelTask.builder().skill(Skill.THIEVING).level(56).build(),
+ SkillLevelTask.builder().skill(Skill.RANGED).level(47).build(),
+ SkillLevelTask.builder().skill(Skill.AGILITY).level(45).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/main/java/com/toofifty/goaltracker/utils/ReorderableList.java b/src/main/java/com/toofifty/goaltracker/utils/ReorderableList.java
index 918df7c..9be4327 100644
--- a/src/main/java/com/toofifty/goaltracker/utils/ReorderableList.java
+++ b/src/main/java/com/toofifty/goaltracker/utils/ReorderableList.java
@@ -1,3 +1,4 @@
+
package com.toofifty.goaltracker.utils;
import java.util.ArrayList;
@@ -5,7 +6,11 @@
import java.util.Collections;
import java.util.List;
-public class ReorderableList extends ArrayList
+/**
+ * ArrayList with helper methods to move elements up, down, top, or bottom.
+ * Used to maintain user-specified ordering for tasks and goals.
+ */
+public final class ReorderableList extends ArrayList
{
private void move(T item, int offset) {
int i = this.indexOf(item);
diff --git a/src/test/java/com/toofifty/goaltracker/GoalSerializerTest.java b/src/test/java/com/toofifty/goaltracker/GoalSerializerTest.java
index 150f499..8faf56b 100644
--- a/src/test/java/com/toofifty/goaltracker/GoalSerializerTest.java
+++ b/src/test/java/com/toofifty/goaltracker/GoalSerializerTest.java
@@ -1,11 +1,15 @@
package com.toofifty.goaltracker;
-import com.google.common.io.Resources;
+import com.toofifty.goaltracker.models.Goal;
+import com.toofifty.goaltracker.models.task.ManualTask;
+import com.toofifty.goaltracker.models.task.SkillLevelTask;
+import com.toofifty.goaltracker.models.task.SkillXpTask;
+import com.toofifty.goaltracker.models.task.QuestTask;
+import com.toofifty.goaltracker.models.task.ItemTask;
import com.toofifty.goaltracker.models.enums.Status;
import com.toofifty.goaltracker.models.enums.TaskType;
-import com.toofifty.goaltracker.models.Goal;
-import com.toofifty.goaltracker.models.task.*;
import com.toofifty.goaltracker.utils.ReorderableList;
+import com.google.common.io.Resources;
import net.runelite.api.Quest;
import net.runelite.api.Skill;
import org.junit.jupiter.api.Test;
diff --git a/src/test/java/com/toofifty/goaltracker/models/GoalTest.java b/src/test/java/com/toofifty/goaltracker/models/GoalTest.java
index ea4cdab..4df7d57 100644
--- a/src/test/java/com/toofifty/goaltracker/models/GoalTest.java
+++ b/src/test/java/com/toofifty/goaltracker/models/GoalTest.java
@@ -1,9 +1,9 @@
package com.toofifty.goaltracker.models;
-import com.toofifty.goaltracker.utils.ReorderableList;
import com.toofifty.goaltracker.models.enums.Status;
import com.toofifty.goaltracker.models.task.ItemTask;
import com.toofifty.goaltracker.models.task.ManualTask;
+import com.toofifty.goaltracker.utils.ReorderableList;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
diff --git a/src/test/java/com/toofifty/goaltracker/models/enums/TaskTypeTest.java b/src/test/java/com/toofifty/goaltracker/models/enums/TaskTypeTest.java
index 124b8a8..93bd2ab 100644
--- a/src/test/java/com/toofifty/goaltracker/models/enums/TaskTypeTest.java
+++ b/src/test/java/com/toofifty/goaltracker/models/enums/TaskTypeTest.java
@@ -2,7 +2,8 @@
import org.junit.jupiter.api.Test;
-import static org.junit.jupiter.api.Assertions.*;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
class TaskTypeTest {
@Test
diff --git a/src/test/java/com/toofifty/goaltracker/models/task/ItemTaskTest.java b/src/test/java/com/toofifty/goaltracker/models/task/ItemTaskTest.java
index ef40ccf..f9bdb33 100644
--- a/src/test/java/com/toofifty/goaltracker/models/task/ItemTaskTest.java
+++ b/src/test/java/com/toofifty/goaltracker/models/task/ItemTaskTest.java
@@ -3,7 +3,7 @@
import com.toofifty.goaltracker.models.enums.TaskType;
import org.junit.jupiter.api.Test;
-import static org.junit.jupiter.api.Assertions.*;
+import static org.junit.jupiter.api.Assertions.assertEquals;
class ItemTaskTest {
diff --git a/src/test/java/com/toofifty/goaltracker/models/task/ManualTaskTest.java b/src/test/java/com/toofifty/goaltracker/models/task/ManualTaskTest.java
index 300d6a1..bf31a3b 100644
--- a/src/test/java/com/toofifty/goaltracker/models/task/ManualTaskTest.java
+++ b/src/test/java/com/toofifty/goaltracker/models/task/ManualTaskTest.java
@@ -4,7 +4,7 @@
import com.toofifty.goaltracker.models.enums.TaskType;
import org.junit.jupiter.api.Test;
-import static org.junit.jupiter.api.Assertions.*;
+import static org.junit.jupiter.api.Assertions.assertEquals;
class ManualTaskTest {
diff --git a/src/test/java/com/toofifty/goaltracker/models/task/QuestTaskTest.java b/src/test/java/com/toofifty/goaltracker/models/task/QuestTaskTest.java
index d3f7117..92f3572 100644
--- a/src/test/java/com/toofifty/goaltracker/models/task/QuestTaskTest.java
+++ b/src/test/java/com/toofifty/goaltracker/models/task/QuestTaskTest.java
@@ -4,7 +4,7 @@
import net.runelite.api.Quest;
import org.junit.jupiter.api.Test;
-import static org.junit.jupiter.api.Assertions.*;
+import static org.junit.jupiter.api.Assertions.assertEquals;
class QuestTaskTest {
diff --git a/src/test/java/com/toofifty/goaltracker/models/task/SkillLevelTaskTest.java b/src/test/java/com/toofifty/goaltracker/models/task/SkillLevelTaskTest.java
index 572fae2..d3d1675 100644
--- a/src/test/java/com/toofifty/goaltracker/models/task/SkillLevelTaskTest.java
+++ b/src/test/java/com/toofifty/goaltracker/models/task/SkillLevelTaskTest.java
@@ -4,7 +4,7 @@
import net.runelite.api.Skill;
import org.junit.jupiter.api.Test;
-import static org.junit.jupiter.api.Assertions.*;
+import static org.junit.jupiter.api.Assertions.assertEquals;
class SkillLevelTaskTest {
diff --git a/src/test/java/com/toofifty/goaltracker/models/task/SkillXpTaskTest.java b/src/test/java/com/toofifty/goaltracker/models/task/SkillXpTaskTest.java
index ff5e777..5f92f4f 100644
--- a/src/test/java/com/toofifty/goaltracker/models/task/SkillXpTaskTest.java
+++ b/src/test/java/com/toofifty/goaltracker/models/task/SkillXpTaskTest.java
@@ -4,7 +4,7 @@
import net.runelite.api.Skill;
import org.junit.jupiter.api.Test;
-import static org.junit.jupiter.api.Assertions.*;
+import static org.junit.jupiter.api.Assertions.assertEquals;
class SkillXpTaskTest {
@Test
diff --git a/src/test/java/com/toofifty/goaltracker/models/task/TaskTest.java b/src/test/java/com/toofifty/goaltracker/models/task/TaskTest.java
index 415f019..7c4c048 100644
--- a/src/test/java/com/toofifty/goaltracker/models/task/TaskTest.java
+++ b/src/test/java/com/toofifty/goaltracker/models/task/TaskTest.java
@@ -3,7 +3,8 @@
import com.toofifty.goaltracker.models.enums.Status;
import org.junit.jupiter.api.Test;
-import static org.junit.jupiter.api.Assertions.*;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
class TaskTest {
@Test
diff --git a/src/test/java/com/toofifty/goaltracker/services/TaskIconServiceTest.java b/src/test/java/com/toofifty/goaltracker/services/TaskIconServiceTest.java
index 61984dc..05de33a 100644
--- a/src/test/java/com/toofifty/goaltracker/services/TaskIconServiceTest.java
+++ b/src/test/java/com/toofifty/goaltracker/services/TaskIconServiceTest.java
@@ -1,7 +1,13 @@
package com.toofifty.goaltracker.services;
+import com.toofifty.goaltracker.models.task.Task;
+import com.toofifty.goaltracker.models.task.ManualTask;
+import com.toofifty.goaltracker.models.task.QuestTask;
+import com.toofifty.goaltracker.models.task.SkillLevelTask;
+import com.toofifty.goaltracker.models.task.SkillXpTask;
+import com.toofifty.goaltracker.models.task.ItemTask;
+
import com.toofifty.goaltracker.models.enums.Status;
-import com.toofifty.goaltracker.models.task.*;
import net.runelite.api.Client;
import net.runelite.api.Skill;
import net.runelite.client.game.ItemManager;
diff --git a/src/test/java/com/toofifty/goaltracker/services/TaskUpdateServiceTest.java b/src/test/java/com/toofifty/goaltracker/services/TaskUpdateServiceTest.java
index beadf6a..e8ddf3a 100644
--- a/src/test/java/com/toofifty/goaltracker/services/TaskUpdateServiceTest.java
+++ b/src/test/java/com/toofifty/goaltracker/services/TaskUpdateServiceTest.java
@@ -1,8 +1,14 @@
package com.toofifty.goaltracker.services;
+import com.toofifty.goaltracker.models.task.Task;
+import com.toofifty.goaltracker.models.task.SkillLevelTask;
+import com.toofifty.goaltracker.models.task.SkillXpTask;
+import com.toofifty.goaltracker.models.task.QuestTask;
+import com.toofifty.goaltracker.models.task.ItemTask;
+import com.toofifty.goaltracker.models.task.ManualTask;
+
import com.toofifty.goaltracker.ItemCache;
import com.toofifty.goaltracker.models.enums.Status;
-import com.toofifty.goaltracker.models.task.*;
import net.runelite.api.*;
import net.runelite.api.events.StatChanged;
import org.junit.jupiter.api.Test;
@@ -11,7 +17,9 @@
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
-import static org.junit.jupiter.api.Assertions.*;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
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