+```
+
+### Help
+
+```
+help
+```
+
+---
+
+## Error Handling
+
+Quokka aims to be forgiving and clear:
+
+* **Unknown command** → e.g., “Alas… I do not know this incantation: …”
+* **Empty parts / missing flags** → e.g., missing `/by`, or `/from`…`/to`.
+* **Invalid dates** → impossible calendar dates (e.g., `2025-02-30`) are rejected.
+* **Event range** → start must be **before** end.
+* **Duplicates** → adding an identical task warns without crashing.
+* **Data file issues** → Quokka auto-creates the folder/file, **skips corrupted lines**, and continues running.
+
+---
+
+## Data File
+
+* Location (default): `data/tasks.txt`
+* Saves are **atomic** (write to temp, then move into place).
+
+---
+
+## Keyboard Shortcuts
+
+* **Enter**: Send command
+* **Ctrl+L**: Clear conversation area
+
+---
+
+## FAQ
+
+**Q: What Java version do I need?**
+A: Java 17+.
-// Feature details
+---
+## Credits
-## Feature XYZ
+* **Strict date parsing** uses `ResolverStyle.STRICT` as per Java Time (JSR-310) docs.
+* JUnit 5 tests use standard APIs per the JUnit 5 user guide.
-// Feature details
\ No newline at end of file
+*All other code authored by the project team. Additional third-party snippets will be listed here as needed.*
diff --git a/docs/Ui.png b/docs/Ui.png
new file mode 100644
index 0000000000..27f1fd4224
Binary files /dev/null and b/docs/Ui.png differ
diff --git a/gradle.properties b/gradle.properties
new file mode 100644
index 0000000000..005e033e2e
--- /dev/null
+++ b/gradle.properties
@@ -0,0 +1 @@
+org.gradle.jvmargs=-Dfile.encoding=UTF-8
diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar
new file mode 100644
index 0000000000..033e24c4cd
Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ
diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties
new file mode 100644
index 0000000000..66c01cfeba
--- /dev/null
+++ b/gradle/wrapper/gradle-wrapper.properties
@@ -0,0 +1,7 @@
+distributionBase=GRADLE_USER_HOME
+distributionPath=wrapper/dists
+distributionUrl=https\://services.gradle.org/distributions/gradle-7.6.2-bin.zip
+networkTimeout=10000
+validateDistributionUrl=true
+zipStoreBase=GRADLE_USER_HOME
+zipStorePath=wrapper/dists
diff --git a/gradlew b/gradlew
new file mode 100755
index 0000000000..fcb6fca147
--- /dev/null
+++ b/gradlew
@@ -0,0 +1,248 @@
+#!/bin/sh
+
+#
+# Copyright © 2015-2021 the original authors.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+
+##############################################################################
+#
+# Gradle start up script for POSIX generated by Gradle.
+#
+# Important for running:
+#
+# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
+# noncompliant, but you have some other compliant shell such as ksh or
+# bash, then to run this script, type that shell name before the whole
+# command line, like:
+#
+# ksh Gradle
+#
+# Busybox and similar reduced shells will NOT work, because this script
+# requires all of these POSIX shell features:
+# * functions;
+# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
+# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
+# * compound commands having a testable exit status, especially «case»;
+# * various built-in commands including «command», «set», and «ulimit».
+#
+# Important for patching:
+#
+# (2) This script targets any POSIX shell, so it avoids extensions provided
+# by Bash, Ksh, etc; in particular arrays are avoided.
+#
+# The "traditional" practice of packing multiple parameters into a
+# space-separated string is a well documented source of bugs and security
+# problems, so this is (mostly) avoided, by progressively accumulating
+# options in "$@", and eventually passing that to Java.
+#
+# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
+# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
+# see the in-line comments for details.
+#
+# There are tweaks for specific operating systems such as AIX, CygWin,
+# Darwin, MinGW, and NonStop.
+#
+# (3) This script is generated from the Groovy template
+# https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
+# within the Gradle project.
+#
+# You can find Gradle at https://github.com/gradle/gradle/.
+#
+##############################################################################
+
+# Attempt to set APP_HOME
+
+# Resolve links: $0 may be a link
+app_path=$0
+
+# Need this for daisy-chained symlinks.
+while
+ APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
+ [ -h "$app_path" ]
+do
+ ls=$( ls -ld "$app_path" )
+ link=${ls#*' -> '}
+ case $link in #(
+ /*) app_path=$link ;; #(
+ *) app_path=$APP_HOME$link ;;
+ esac
+done
+
+# This is normally unused
+# shellcheck disable=SC2034
+APP_BASE_NAME=${0##*/}
+APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit
+
+# Use the maximum available, or set MAX_FD != -1 to use that value.
+MAX_FD=maximum
+
+warn () {
+ echo "$*"
+} >&2
+
+die () {
+ echo
+ echo "$*"
+ echo
+ exit 1
+} >&2
+
+# OS specific support (must be 'true' or 'false').
+cygwin=false
+msys=false
+darwin=false
+nonstop=false
+case "$( uname )" in #(
+ CYGWIN* ) cygwin=true ;; #(
+ Darwin* ) darwin=true ;; #(
+ MSYS* | MINGW* ) msys=true ;; #(
+ NONSTOP* ) nonstop=true ;;
+esac
+
+CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
+
+
+# Determine the Java command to use to start the JVM.
+if [ -n "$JAVA_HOME" ] ; then
+ if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
+ # IBM's JDK on AIX uses strange locations for the executables
+ JAVACMD=$JAVA_HOME/jre/sh/java
+ else
+ JAVACMD=$JAVA_HOME/bin/java
+ fi
+ if [ ! -x "$JAVACMD" ] ; then
+ die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+ fi
+else
+ JAVACMD=java
+ if ! command -v java >/dev/null 2>&1
+ then
+ die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+ fi
+fi
+
+# Increase the maximum file descriptors if we can.
+if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
+ case $MAX_FD in #(
+ max*)
+ # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
+ # shellcheck disable=SC3045
+ MAX_FD=$( ulimit -H -n ) ||
+ warn "Could not query maximum file descriptor limit"
+ esac
+ case $MAX_FD in #(
+ '' | soft) :;; #(
+ *)
+ # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
+ # shellcheck disable=SC3045
+ ulimit -n "$MAX_FD" ||
+ warn "Could not set maximum file descriptor limit to $MAX_FD"
+ esac
+fi
+
+# Collect all arguments for the java command, stacking in reverse order:
+# * args from the command line
+# * the main class name
+# * -classpath
+# * -D...appname settings
+# * --module-path (only if needed)
+# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
+
+# For Cygwin or MSYS, switch paths to Windows format before running java
+if "$cygwin" || "$msys" ; then
+ APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
+ CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
+
+ JAVACMD=$( cygpath --unix "$JAVACMD" )
+
+ # Now convert the arguments - kludge to limit ourselves to /bin/sh
+ for arg do
+ if
+ case $arg in #(
+ -*) false ;; # don't mess with options #(
+ /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
+ [ -e "$t" ] ;; #(
+ *) false ;;
+ esac
+ then
+ arg=$( cygpath --path --ignore --mixed "$arg" )
+ fi
+ # Roll the args list around exactly as many times as the number of
+ # args, so each arg winds up back in the position where it started, but
+ # possibly modified.
+ #
+ # NB: a `for` loop captures its iteration list before it begins, so
+ # changing the positional parameters here affects neither the number of
+ # iterations, nor the values presented in `arg`.
+ shift # remove old arg
+ set -- "$@" "$arg" # push replacement arg
+ done
+fi
+
+
+# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
+
+# Collect all arguments for the java command;
+# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of
+# shell script including quotes and variable substitutions, so put them in
+# double quotes to make sure that they get re-expanded; and
+# * put everything else in single quotes, so that it's not re-expanded.
+
+set -- \
+ "-Dorg.gradle.appname=$APP_BASE_NAME" \
+ -classpath "$CLASSPATH" \
+ org.gradle.wrapper.GradleWrapperMain \
+ "$@"
+
+# Stop when "xargs" is not available.
+if ! command -v xargs >/dev/null 2>&1
+then
+ die "xargs is not available"
+fi
+
+# Use "xargs" to parse quoted args.
+#
+# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
+#
+# In Bash we could simply go:
+#
+# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
+# set -- "${ARGS[@]}" "$@"
+#
+# but POSIX shell has neither arrays nor command substitution, so instead we
+# post-process each arg (as a line of input to sed) to backslash-escape any
+# character that might be a shell metacharacter, then use eval to reverse
+# that process (while maintaining the separation between arguments), and wrap
+# the whole thing up as a single "set" statement.
+#
+# This will of course break if any of these variables contains a newline or
+# an unmatched quote.
+#
+
+eval "set -- $(
+ printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
+ xargs -n1 |
+ sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
+ tr '\n' ' '
+ )" '"$@"'
+
+exec "$JAVACMD" "$@"
diff --git a/gradlew.bat b/gradlew.bat
new file mode 100644
index 0000000000..6689b85bee
--- /dev/null
+++ b/gradlew.bat
@@ -0,0 +1,92 @@
+@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=.
+@rem This is normally unused
+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% equ 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% equ 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!
+set EXIT_CODE=%ERRORLEVEL%
+if %EXIT_CODE% equ 0 set EXIT_CODE=1
+if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
+exit /b %EXIT_CODE%
+
+:mainEnd
+if "%OS%"=="Windows_NT" endlocal
+
+:omega
diff --git a/src/main/java/Duke.java b/src/main/java/Duke.java
deleted file mode 100644
index 5d313334cc..0000000000
--- a/src/main/java/Duke.java
+++ /dev/null
@@ -1,10 +0,0 @@
-public class Duke {
- public static void main(String[] args) {
- String logo = " ____ _ \n"
- + "| _ \\ _ _| | _____ \n"
- + "| | | | | | | |/ / _ \\\n"
- + "| |_| | |_| | < __/\n"
- + "|____/ \\__,_|_|\\_\\___|\n";
- System.out.println("Hello from\n" + logo);
- }
-}
diff --git a/src/main/java/quokka/AppInfo.java b/src/main/java/quokka/AppInfo.java
new file mode 100644
index 0000000000..f67d884f97
--- /dev/null
+++ b/src/main/java/quokka/AppInfo.java
@@ -0,0 +1,8 @@
+package quokka;
+
+/** Central place for product metadata shown in UI and logs. */
+public final class AppInfo {
+ public static final String PRODUCT_NAME = "Quokka";
+ public static final String VERSION = "1.0.0";
+ private AppInfo() {}
+}
diff --git a/src/main/java/quokka/Deadline.java b/src/main/java/quokka/Deadline.java
new file mode 100644
index 0000000000..76a7de167c
--- /dev/null
+++ b/src/main/java/quokka/Deadline.java
@@ -0,0 +1,38 @@
+package quokka;
+
+import quokka.util.Dates;
+
+import java.time.LocalDate;
+
+/** A deadline task that is due on a single date. */
+public class Deadline extends Task {
+ protected LocalDate by;
+
+ public Deadline(String description, String by) {
+ super(description, TaskType.DEADLINE);
+ this.by = Dates.parseFlexibleDate(by);
+ assert this.by != null : "Deadline date must parse";
+ }
+
+ public Deadline(String description, String by, boolean isDone) {
+ super(description, TaskType.DEADLINE, isDone);
+ this.by = Dates.parseFlexibleDate(by);
+ assert this.by != null : "Deadline date must parse";
+ }
+
+ /** Expose the due date for searches/filters. */
+ public LocalDate getByDate() {
+ return by;
+ }
+
+ @Override
+ public String toString() {
+ return super.toString() + " (by: " + Dates.fmt(by) + ")";
+ }
+
+ @Override
+ public String toDataString() {
+ return TaskType.DEADLINE.getLabel() + " | " + (isDone ? "1" : "0")
+ + " | " + description + " | " + by;
+ }
+}
diff --git a/src/main/java/quokka/DukeException.java b/src/main/java/quokka/DukeException.java
new file mode 100644
index 0000000000..423aefe4dd
--- /dev/null
+++ b/src/main/java/quokka/DukeException.java
@@ -0,0 +1,7 @@
+package quokka;
+
+public class DukeException extends Exception {
+ public DukeException(String message) {
+ super(message);
+ }
+}
diff --git a/src/main/java/quokka/Event.java b/src/main/java/quokka/Event.java
new file mode 100644
index 0000000000..8fdeda74bb
--- /dev/null
+++ b/src/main/java/quokka/Event.java
@@ -0,0 +1,43 @@
+/** A concrete task type. */
+
+package quokka;
+
+import java.time.LocalDate;
+import quokka.util.Dates;
+
+public class Event extends Task {
+ protected LocalDate from;
+ protected LocalDate to;
+
+ public Event(String description, String from, String to) {
+ super(description, TaskType.EVENT);
+ this.from = Dates.parseStrictDate(from);
+ this.to = Dates.parseStrictDate(to);
+ if (!this.from.isBefore(this.to)) {
+ throw new IllegalArgumentException("Event start must be strictly before end.");
+ }
+ assert this.from != null && this.to != null : "Event dates must parse";
+ }
+
+
+ public Event(String description, String from, String to, boolean isDone) {
+ super(description, TaskType.EVENT, isDone);
+ this.from = Dates.parseFlexibleDate(from);
+ this.to = Dates.parseFlexibleDate(to);
+ assert this.from != null && this.to != null : "Event dates must parse";
+ assert !this.from.isAfter(this.to) : "Event: start date must be <= end date";
+ }
+
+
+ @Override
+ public String toString() {
+ return super.toString() + " (from: " + Dates.fmt(from) + " to: " + Dates.fmt(to) + ")";
+ }
+
+
+ @Override
+ public String toDataString() {
+ return TaskType.EVENT.getLabel() + " | " + (isDone ? "1" : "0")
+ + " | " + description + " | " + from + " | " + to;
+ }
+}
diff --git a/src/main/java/quokka/Launcher.java b/src/main/java/quokka/Launcher.java
new file mode 100644
index 0000000000..b66ba86ca5
--- /dev/null
+++ b/src/main/java/quokka/Launcher.java
@@ -0,0 +1,10 @@
+package quokka;
+
+import javafx.application.Application;
+
+/** Launches the JavaFX app to work around classpath issues. */
+public class Launcher {
+ public static void main(String[] args) {
+ Application.launch(Main.class, args);
+ }
+}
diff --git a/src/main/java/quokka/Main.java b/src/main/java/quokka/Main.java
new file mode 100644
index 0000000000..a4461ef5c2
--- /dev/null
+++ b/src/main/java/quokka/Main.java
@@ -0,0 +1,244 @@
+package quokka;
+
+import javafx.application.Application;
+import javafx.geometry.Insets;
+import javafx.geometry.Pos;
+import javafx.scene.Scene;
+import javafx.scene.control.Button;
+import javafx.scene.control.Label;
+import javafx.scene.control.ScrollPane;
+import javafx.scene.control.TextField;
+import javafx.scene.image.Image;
+import javafx.scene.image.ImageView;
+import javafx.scene.layout.BorderPane;
+import javafx.scene.layout.HBox;
+import javafx.scene.layout.Priority;
+import javafx.scene.layout.StackPane;
+import javafx.scene.layout.VBox;
+import javafx.stage.Stage;
+
+import java.util.Objects;
+
+/**
+ * JavaFX entry point for the Quokka GUI.
+ * Asymmetric chat layout, centered column, avatars, error highlighting, responsive resizing.
+ */
+public class Main extends Application {
+
+ private Quokka quokka;
+
+ private VBox dialog;
+ private ScrollPane scroller;
+ private VBox lane;
+
+ private Image botAvatar;
+ private Image userAvatar;
+
+ @Override
+ public void start(Stage stage) {
+ // ----- Root -----
+ BorderPane root = new BorderPane();
+ root.getStyleClass().add("hk-pane");
+ root.setPadding(new Insets(12));
+
+ // ----- Header -----
+ HBox headerBar = new HBox(10);
+ headerBar.getStyleClass().add("hk-headerbar");
+
+ ImageView titleIcon = loadIconView("/images/knight.png", 22);
+ Label header = new Label(AppInfo.PRODUCT_NAME);
+ header.getStyleClass().add("hk-header");
+ headerBar.getChildren().addAll(
+ titleIcon != null ? titleIcon : new Label(), header
+ );
+ root.setTop(headerBar);
+
+ // ----- Dialog Area (centered column) -----
+ dialog = new VBox(10);
+ dialog.setFillWidth(true);
+
+ lane = new VBox(dialog);
+ lane.setAlignment(Pos.TOP_CENTER);
+ lane.setPadding(new Insets(8, 12, 12, 12));
+ lane.setMaxWidth(720);
+ lane.getStyleClass().add("lane");
+
+ StackPane content = new StackPane(lane);
+ StackPane.setAlignment(lane, Pos.TOP_CENTER);
+
+ scroller = new ScrollPane(content);
+ scroller.setStyle("-fx-background-color: transparent; -fx-background: transparent;");
+ content.setStyle("-fx-background-color: transparent;");
+ lane.setStyle("-fx-background-color: transparent;");
+ dialog.setStyle("-fx-background-color: transparent;");
+
+ scroller.setFitToWidth(true);
+ scroller.setHbarPolicy(ScrollPane.ScrollBarPolicy.NEVER);
+ scroller.setVbarPolicy(ScrollPane.ScrollBarPolicy.AS_NEEDED);
+ scroller.setPannable(true);
+
+ // Auto-scroll to bottom on new messages
+ dialog.heightProperty().addListener((obs, oldVal, newVal) -> scroller.setVvalue(1.0));
+
+ root.setCenter(scroller);
+
+ // ----- Input Bar -----
+ TextField input = new TextField();
+ input.setPromptText("Type a command…");
+ input.getStyleClass().add("hk-input");
+
+ Button send = new Button("Send");
+ send.getStyleClass().add("hk-send");
+ send.setDefaultButton(true); // Enter triggers
+
+ HBox inputBar = new HBox(10, input, send);
+ inputBar.getStyleClass().add("input-bar");
+ inputBar.setPadding(new Insets(10, 12, 8, 12));
+ HBox.setHgrow(input, Priority.ALWAYS);
+ root.setBottom(inputBar);
+
+ // ----- Handlers -----
+ send.setOnAction(e -> {
+ String cmd = input.getText() == null ? "" : input.getText().trim();
+ if (cmd.isEmpty()) return;
+
+ addUser(cmd);
+
+ Reply r = quokka.process(cmd);
+ addBot(r.message, r.error);
+
+ input.clear();
+ if (r.exit) stage.close();
+ });
+ input.setOnAction(send.getOnAction());
+
+ // Ctrl+L: clear chat
+ root.setOnKeyPressed(ev -> {
+ switch (ev.getCode()) {
+ case L:
+ if (ev.isControlDown()) {
+ dialog.getChildren().clear();
+ addBot("Cleared.", false);
+ }
+ break;
+ default:
+ }
+ });
+
+ // ----- Scene / Stage -----
+ Scene scene = new Scene(root, 560, 720);
+ scene.getStylesheets().add(
+ Objects.requireNonNull(
+ getClass().getResource("/quokka/view/hollowknight.css")
+ ).toExternalForm()
+ );
+
+ stage.setTitle(AppInfo.PRODUCT_NAME);
+ stage.setMinWidth(420);
+ stage.setMinHeight(540);
+ stage.setResizable(true);
+ stage.setScene(scene);
+
+ // Load icon + avatars from classpath (works in fat JAR)
+ Image botIcon = loadImage("/images/knight.png");
+ Image userIcon = loadImage("/images/user.png");
+
+ if (botIcon != null) {
+ stage.getIcons().add(botIcon);
+ botAvatar = botIcon;
+ }
+ if (userIcon != null) {
+ userAvatar = userIcon;
+ } else {
+ userAvatar = botIcon;
+ }
+
+ quokka = new Quokka("data/tasks.txt");
+ stage.show();
+
+ addBot("Hello! I’m " + AppInfo.PRODUCT_NAME + ". Type a command.", false);
+ }
+
+ // -------- Chat row builders --------
+
+ private void addBot(String text, boolean isError) {
+ Label bubble = new Label(text);
+ bubble.setWrapText(true);
+ bubble.getStyleClass().add("bot-bubble");
+ if (isError || text.startsWith("OOPS!!!")) {
+ bubble.getStyleClass().add("error-bubble");
+ }
+
+ bubble.maxWidthProperty().bind(lane.widthProperty().subtract(120));
+
+ HBox row = new HBox(10);
+ row.getStyleClass().add("row");
+ row.setAlignment(Pos.TOP_LEFT);
+
+ ImageView avatar = avatarView(botAvatar, 42, false);
+
+ if (avatar != null) {
+ row.getChildren().addAll(avatar, bubble);
+ } else {
+ row.getChildren().add(bubble);
+ }
+
+ dialog.getChildren().add(row);
+ }
+
+ private void addUser(String text) {
+ Label chip = new Label(text);
+ chip.setWrapText(true);
+ chip.getStyleClass().add("user-chip");
+ chip.maxWidthProperty().bind(lane.widthProperty().subtract(120));
+
+ HBox row = new HBox(10);
+ row.getStyleClass().add("row");
+ row.setAlignment(Pos.TOP_RIGHT);
+
+ ImageView avatar = avatarView(userAvatar, 42, true);
+
+ if (avatar != null) {
+ row.getChildren().addAll(chip, avatar);
+ } else {
+ row.getChildren().add(chip);
+ }
+
+ dialog.getChildren().add(row);
+ }
+
+ // -------- Helpers --------
+
+ private Image loadImage(String classpath) {
+ try {
+ return new Image(Objects.requireNonNull(
+ getClass().getResourceAsStream(classpath),
+ "Missing " + classpath
+ ));
+ } catch (NullPointerException e) {
+ return null;
+ }
+ }
+
+ private ImageView loadIconView(String classpath, double size) {
+ Image img = loadImage(classpath);
+ if (img == null) return null;
+ ImageView iv = new ImageView(img);
+ iv.setFitWidth(size);
+ iv.setFitHeight(size);
+ iv.setPreserveRatio(true);
+ return iv;
+ }
+
+ private ImageView avatarView(Image img, double size, boolean mirror) {
+ if (img == null) return null;
+ ImageView iv = new ImageView(img);
+ iv.setFitWidth(size);
+ iv.setFitHeight(size);
+ iv.setPreserveRatio(true);
+ if (mirror) iv.setScaleX(-1);
+ iv.getStyleClass().add("avatar");
+ return iv;
+ }
+
+}
diff --git a/src/main/java/quokka/Parser.java b/src/main/java/quokka/Parser.java
new file mode 100644
index 0000000000..cdec15c0c2
--- /dev/null
+++ b/src/main/java/quokka/Parser.java
@@ -0,0 +1,59 @@
+/**
+ * Lightweight parser that splits the command word and the remainder.
+ */
+package quokka;
+
+public class Parser {
+
+ /** Normalize unicode spaces (e.g., NBSP) and trim. */
+ private static String normalize(String s) {
+ if (s == null) return "";
+ String t = s.replace('\u00A0', ' ');
+ t = t.trim();
+ t = t.replaceAll("\\s+", " ");
+ return t;
+ }
+
+
+ public static String commandWord(String input) {
+ String s = normalize(input);
+ if (s.isEmpty()) return "";
+ int sp = s.indexOf(' ');
+ String result = (sp == -1) ? s : s.substring(0, sp);
+ assert result != null : "Parser.commandWord must not return null";
+ assert result.indexOf(' ') == -1 : "commandWord should contain no spaces";
+ return result;
+ }
+
+ public static String remainder(String input) {
+ String s = normalize(input);
+ if (s.isEmpty()) return "";
+ int sp = s.indexOf(' ');
+ String result = (sp == -1) ? "" : s.substring(sp + 1).trim();
+ assert result != null : "Parser.remainder must not return null";
+ assert result.equals(result.trim()) : "remainder should be trimmed";
+ return result;
+ }
+
+
+ /** Returns the number of occurrences of a substring token in text (non-overlapping). */
+ public static int countToken(String text, String token) {
+ if (text == null || token == null || token.isEmpty()) return 0;
+ int count = 0, pos = 0;
+ while ((pos = text.indexOf(token, pos)) >= 0) { count++; pos += token.length(); }
+ return count;
+ }
+
+ /**
+ * Split 'source' by the first occurrence of 'token', returning {left,right} trimmed.
+ * If token not found, returns {source.trim(), ""}.
+ */
+ public static String[] splitOnce(String source, String token) {
+ String s = normalize(source);
+ int i = s.indexOf(token);
+ if (i < 0) return new String[]{ s, "" };
+ String left = s.substring(0, i).trim();
+ String right = s.substring(i + token.length()).trim();
+ return new String[]{ left, right };
+ }
+}
diff --git a/src/main/java/quokka/Quokka.java b/src/main/java/quokka/Quokka.java
new file mode 100644
index 0000000000..b138b1f445
--- /dev/null
+++ b/src/main/java/quokka/Quokka.java
@@ -0,0 +1,244 @@
+package quokka;
+
+import java.io.IOException;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.util.List;
+import quokka.Reply;
+
+
+/**
+ * Entry point and command dispatcher for the Quokka chatbot.
+ *
+ * Wires together Ui, TaskList, and Storage, and exposes a process(String) method
+ * that converts a raw user input into a formatted reply (with error/exit flags)
+ * without printing. Also provides a simple CLI run() loop for text mode.
+ */
+public class Quokka {
+
+ private final Ui ui;
+ private final TaskList taskList;
+ private final Path dataFile;
+
+ /** Creates a bot backed by data/tasks.txt. */
+ public Quokka() {
+ this(Paths.get("data", "tasks.txt"));
+ }
+
+ /** Creates a bot backed by the given data file (relative or absolute). */
+ public Quokka(String filePath) {
+ this(Paths.get(filePath));
+ }
+
+ private Quokka(Path dataFile) {
+ this.ui = new Ui();
+ this.taskList = new TaskList();
+ this.dataFile = dataFile;
+
+ // Load tasks, creating folders/files if missing. Skip malformed lines.
+ try {
+ Storage.load(this.dataFile, this.taskList.view());
+ } catch (DukeException e) {
+ System.err.println("Warning: failed to load tasks: " + e.getMessage());
+ }
+ }
+
+ /**
+ * Main CLI loop (text UI).
+ * Prints the greeting, then processes lines until "bye".
+ */
+ public void run() throws IOException {
+ printGreeting();
+ while (true) {
+ String line = ui.readCommand();
+ if (line == null) { // EOF behaves like bye
+ ui.showGoodbye();
+ break;
+ }
+ Reply r = process(line);
+ if (r.error) {
+ ui.showError(r.message);
+ } else if (!r.message.isEmpty()) {
+ System.out.println(r.message);
+ }
+ if (r.exit) {
+ ui.showGoodbye();
+ break;
+ }
+ }
+ }
+
+ /** Processes one command and returns a reply (no printing). */
+ public Reply process(String fullCommand) {
+ try {
+ String cmd = Parser.commandWord(fullCommand);
+ String rem = Parser.remainder(fullCommand);
+
+ switch (cmd) {
+ case "":
+ return Reply.ok(""); // ignore pure whitespace
+ case "list": {
+ return Reply.ok(renderTaskList(taskList.view(), "Here are the tasks in your list:"));
+ }
+ case "todo": {
+ if (rem.isBlank()) {
+ return Reply.error("OOPS!!! The description of a todo cannot be empty.");
+ }
+ Task t = new Todo(rem);
+ if (taskList.containsDuplicate(t)) {
+ return Reply.error("Duplicate todo: an identical task already exists.");
+ }
+ taskList.add(t);
+ Storage.save(dataFile, taskList.view());
+ return Reply.ok(formatAdded(t, taskList.size()));
+ }
+ case "deadline": {
+ if (rem.isBlank()) {
+ return Reply.error("OOPS!!! The description of a deadline cannot be empty.");
+ }
+ if (Parser.countToken(rem, " /by ") > 1) {
+ return Reply.error("OOPS!!! Duplicate '/by'. Use: deadline /by ");
+ }
+ int sep = rem.indexOf(" /by ");
+ if (sep < 0) {
+ return Reply.error("OOPS!!! Missing '/by' in deadline. Use: deadline /by ");
+ }
+ String desc = rem.substring(0, sep).trim();
+ String byRaw = rem.substring(sep + 5).trim();
+ if (desc.isEmpty() || byRaw.isEmpty()) {
+ return Reply.error("OOPS!!! Use: deadline /by ");
+ }
+ Task t;
+ try {
+ java.time.LocalDate by = quokka.util.Dates.parseStrictDate(byRaw);
+ t = new Deadline(desc, by.toString());
+ } catch (IllegalArgumentException ex) {
+ return Reply.error("Invalid date for /by: " + ex.getMessage());
+ }
+ if (taskList.containsDuplicate(t)) {
+ return Reply.error("Duplicate deadline: an identical task already exists.");
+ }
+ taskList.add(t);
+ Storage.save(dataFile, taskList.view());
+ return Reply.ok(formatAdded(t, taskList.size()));
+ }
+ case "event": {
+ if (rem.isBlank()) {
+ return Reply.error("OOPS!!! The description of an event cannot be empty.");
+ }
+ if (Parser.countToken(rem, " /from ") != 1 || Parser.countToken(rem, " /to ") != 1) {
+ return Reply.error("OOPS!!! Use exactly one '/from' and one '/to': event /from /to ");
+ }
+ int f = rem.indexOf(" /from ");
+ int tIdx = rem.indexOf(" /to ");
+ if (f < 0 || tIdx < 0 || tIdx <= f) {
+ return Reply.error("OOPS!!! Use: event /from /to ");
+ }
+ String desc = rem.substring(0, f).trim();
+ String fromRaw = rem.substring(f + 7, tIdx).trim();
+ String toRaw = rem.substring(tIdx + 5).trim();
+ if (desc.isEmpty() || fromRaw.isEmpty() || toRaw.isEmpty()) {
+ return Reply.error("OOPS!!! Use: event /from /to ");
+ }
+
+ Task t;
+ try {
+ java.time.LocalDate from = quokka.util.Dates.parseStrictDate(fromRaw);
+ java.time.LocalDate to = quokka.util.Dates.parseStrictDate(toRaw);
+ if (!from.isBefore(to)) {
+ return Reply.error("Event start must be strictly before end.");
+ }
+ t = new Event(desc, from.toString(), to.toString());
+ } catch (IllegalArgumentException ex) {
+ return Reply.error("Invalid date: " + ex.getMessage());
+ }
+
+ if (taskList.containsDuplicate(t)) {
+ return Reply.error("Duplicate event: an identical task already exists.");
+ }
+ taskList.add(t);
+ Storage.save(dataFile, taskList.view());
+ return Reply.ok(formatAdded(t, taskList.size()));
+ }
+ case "mark": {
+ int idx0 = parseOneBasedIndex(rem);
+ Task t = taskList.get(idx0);
+ t.markAsDone();
+ Storage.save(dataFile, taskList.view());
+ return Reply.ok("Nice! I've marked this task as done:\n " + t);
+ }
+ case "unmark": {
+ int idx0 = parseOneBasedIndex(rem);
+ Task t = taskList.get(idx0);
+ t.markAsNotDone();
+ Storage.save(dataFile, taskList.view());
+ return Reply.ok("OK, I've marked this task as not done yet:\n " + t);
+ }
+ case "delete": {
+ int idx0 = parseOneBasedIndex(rem);
+ Task removed = taskList.removeAt(idx0);
+ Storage.save(dataFile, taskList.view());
+ return Reply.ok("Noted. I've removed this task:\n " + removed
+ + "\nNow you have " + taskList.size() + " tasks in the list.");
+ }
+ case "find": {
+ if (rem.isBlank()) {
+ return Reply.error("OOPS!!! Provide a keyword to find.");
+ }
+ List matches = taskList.find(rem);
+ return Reply.ok(renderTaskList(matches, "Here are the matching tasks in your list:"));
+ }
+ case "bye":
+ return Reply.ok("Bye. Hope to see you again soon!").withExit();
+ default:
+ return Reply.error(ui.showUnknownCommandError(cmd));
+ }
+ } catch (DukeException e) {
+ return Reply.error(e.getMessage());
+ } catch (IllegalArgumentException e) { // e.g., date parse issues from Deadline/Event
+ return Reply.error("OOPS!!! " + e.getMessage());
+ } catch (Exception e) {
+ return Reply.error("OOPS!!! " + e.getClass().getSimpleName() + ": " + e.getMessage());
+ }
+ }
+
+ /* ===================== helpers ===================== */
+
+ private void printGreeting() {
+ String line = "____________________________________________________________";
+ System.out.println(line);
+ System.out.println(" Hello! I'm Quokka");
+ System.out.println(" What can I do for you?");
+ System.out.println(line);
+ }
+
+ private static String renderTaskList(List list, String header) {
+ if (list == null || list.isEmpty()) {
+ return "Your list is empty.";
+ }
+ StringBuilder sb = new StringBuilder(header).append('\n');
+ for (int i = 0; i < list.size(); i++) {
+ sb.append(i + 1).append('.').append(list.get(i)).append('\n');
+ }
+ return sb.toString().trim();
+ }
+
+ private static String formatAdded(Task t, int newSize) {
+ return "Got it. I've added this task:\n " + t + "\nNow you have " + newSize + " tasks in the list.";
+ }
+
+ private int parseOneBasedIndex(String numStr) throws DukeException {
+ if (numStr == null || numStr.trim().isEmpty()) {
+ throw new DukeException("Please provide a task number.");
+ }
+ try {
+ int n = Integer.parseInt(numStr.trim());
+ if (n < 1 || n > taskList.size()) {
+ throw new DukeException("Index out of range. Provide a number between 1 and " + taskList.size() + ".");
+ }
+ return n - 1; // convert to 0-based
+ } catch (NumberFormatException e) {
+ throw new DukeException("Please provide a valid task number.");
+ }
+ }
+}
diff --git a/src/main/java/quokka/Reply.java b/src/main/java/quokka/Reply.java
new file mode 100644
index 0000000000..0ee4439c87
--- /dev/null
+++ b/src/main/java/quokka/Reply.java
@@ -0,0 +1,32 @@
+package quokka;
+
+/**
+ * Small DTO passed from backend to GUI.
+ * Encapsulates the reply text, an error flag for styling, and an exit flag for closing the app.
+ */
+public class Reply {
+ public final String message;
+ public final boolean error;
+ public final boolean exit;
+
+ private Reply(String message, boolean error, boolean exit) {
+ this.message = message;
+ this.error = error;
+ this.exit = exit;
+ }
+
+ /** Create a successful reply (not an error, not exit). */
+ public static Reply ok(String message) {
+ return new Reply(message, false, false);
+ }
+
+ /** Create an error reply (used for styling), but not exit. */
+ public static Reply error(String message) {
+ return new Reply(message, true, false);
+ }
+
+ /** Return a copy of this reply that also signals application exit. */
+ public Reply withExit() {
+ return new Reply(this.message, this.error, true);
+ }
+}
diff --git a/src/main/java/quokka/Storage.java b/src/main/java/quokka/Storage.java
new file mode 100644
index 0000000000..06dd94fa48
--- /dev/null
+++ b/src/main/java/quokka/Storage.java
@@ -0,0 +1,120 @@
+/**
+ * Persists tasks to a human-editable text file and loads them on startup.
+ * Tolerates legacy formats and corrupted lines where possible.
+ */
+package quokka;
+
+import java.io.BufferedWriter;
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.*;
+import java.util.List;
+
+public final class Storage {
+
+ /** Split regex that tolerates spaces around the '|' delimiter. */
+ private static final String SPLIT = "\\s*\\|\\s*";
+
+ private Storage() {}
+
+ /** Save all tasks to file atomically (write to temp, then move). */
+ public static void save(Path file, List tasks) throws DukeException {
+ try {
+ Path parent = file.getParent();
+ if (parent != null && !Files.exists(parent)) {
+ Files.createDirectories(parent);
+ }
+
+ Path tmp = Files.createTempFile(parent != null ? parent : file.getParent(), "quokka-", ".tmp");
+ try (BufferedWriter bw = Files.newBufferedWriter(tmp, StandardCharsets.UTF_8)) {
+ for (Task t : tasks) {
+ bw.write(t.toDataString());
+ bw.newLine();
+ }
+ }
+ // Try atomic move; if not supported, fall back to replace.
+ try {
+ Files.move(tmp, file, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING);
+ } catch (AtomicMoveNotSupportedException e) {
+ Files.move(tmp, file, StandardCopyOption.REPLACE_EXISTING);
+ }
+ } catch (IOException e) {
+ throw new DukeException("Unable to save data: " + e.getMessage());
+ }
+ }
+
+ /** Load tasks from file; creates the file if absent. Corrupted lines are skipped with warnings. */
+ public static void load(Path file, List out) throws DukeException {
+ try {
+ Path parent = file.getParent();
+ if (parent != null && !Files.exists(parent)) {
+ Files.createDirectories(parent);
+ }
+ if (!Files.exists(file)) {
+ Files.createFile(file);
+ return;
+ }
+ List lines = Files.readAllLines(file, StandardCharsets.UTF_8);
+ int lineNo = 0;
+ for (String raw : lines) {
+ lineNo++;
+ String line = stripBom(raw).trim();
+ if (line.isEmpty()) continue;
+ try {
+ Task t = parseLine(line);
+ out.add(t);
+ } catch (Exception ex) {
+ System.err.println("Warning: skipped corrupted line " + lineNo + ": \"" + raw + "\" (" + ex.getMessage() + ")");
+ }
+ }
+ } catch (AccessDeniedException e) {
+ throw new DukeException("Access denied to data file: " + file.toAbsolutePath());
+ } catch (IOException e) {
+ throw new DukeException("Unable to load data: " + e.getMessage());
+ }
+ }
+
+ /** Parse one serialized line into a Task. */
+ private static Task parseLine(String line) throws DukeException {
+ String[] parts = line.split(SPLIT);
+ if (parts.length < 3) throw new DukeException("Too few fields: " + line);
+
+ String type = parts[0].trim();
+ boolean done = parseDone(parts[1].trim());
+ String desc = parts[2].trim();
+
+ Task t;
+ switch (type) {
+ case "T":
+ t = new Todo(desc);
+ break;
+ case "D":
+ if (parts.length < 4) throw new DukeException("Deadline missing date: " + line);
+ t = new Deadline(desc, parts[3].trim());
+ break;
+ case "E":
+ if (parts.length < 5) throw new DukeException("Event missing dates: " + line);
+ t = new Event(desc, parts[3].trim(), parts[4].trim());
+ break;
+ default:
+ throw new DukeException("Unknown type: " + type);
+ }
+ if (done) t.markAsDone();
+ return t;
+ }
+
+ /** Remove UTF-8 BOM if present. */
+ private static String stripBom(String s) {
+ if (s != null && !s.isEmpty() && s.charAt(0) == '\uFEFF') {
+ return s.substring(1);
+ }
+ return s;
+ }
+
+ /** Parses the done flag ("1" or "0"). */
+ private static boolean parseDone(String s) throws DukeException {
+ if ("1".equals(s)) return true;
+ if ("0".equals(s)) return false;
+ throw new DukeException("Invalid done flag: " + s);
+ }
+}
diff --git a/src/main/java/quokka/Task.java b/src/main/java/quokka/Task.java
new file mode 100644
index 0000000000..7068f55292
--- /dev/null
+++ b/src/main/java/quokka/Task.java
@@ -0,0 +1,47 @@
+/**
+ * Base type for all tasks. Holds common fields and serialization hooks.
+ */
+
+
+package quokka;
+
+public class Task {
+ protected String description;
+ protected boolean isDone;
+ protected final TaskType type;
+
+ public Task(String description, TaskType type) {
+ this.description = description;
+ this.isDone = false;
+ this.type = type;
+ }
+
+ public Task(String description, TaskType type, boolean isDone) {
+ this.description = description;
+ this.isDone = isDone;
+ this.type = type;
+ }
+
+ public String getStatusIcon() {
+ return (isDone ? "X" : " ");
+ }
+
+ public TaskType getType() {
+ return type;
+ }
+
+ public void markAsDone() { isDone = true; }
+ public void markAsNotDone() { isDone = false; }
+ public String getDescription() { return description; }
+ public boolean isDone() { return isDone; }
+
+ @Override
+ public String toString() {
+ return "[" + type.getLabel() + "][" + getStatusIcon() + "] " + description;
+ }
+
+ public String toDataString() {
+ return type.getLabel() + " | " + (isDone ? "1" : "0") + " | " + description;
+ }
+
+}
diff --git a/src/main/java/quokka/TaskList.java b/src/main/java/quokka/TaskList.java
new file mode 100644
index 0000000000..fa23dc2cce
--- /dev/null
+++ b/src/main/java/quokka/TaskList.java
@@ -0,0 +1,111 @@
+/**
+ * Mutable list of tasks. Provides operations to add/remove/get and to search (Level-9).
+ */
+
+
+package quokka;
+
+import java.util.ArrayList;
+import java.util.List;
+
+public class TaskList {
+ private final List tasks;
+
+ public TaskList() { this.tasks = new ArrayList<>(); }
+ public TaskList(List existing) { this.tasks = existing; }
+ /** Returns tasks whose description contains the keyword (case-insensitive). */
+ public void add(Task... items) {
+ if (items == null) {
+ return;
+ }
+ for (Task t : items) {
+ if (t != null) {
+ tasks.add(t);
+ }
+ }
+ }
+ /** Returns tasks whose description contains the keyword (case-insensitive). */
+ public Task removeAt(int idx0) { return tasks.remove(idx0); }
+ /** Returns tasks whose description contains the keyword (case-insensitive). */
+ public Task get(int idx0) { return tasks.get(idx0); }
+ /** Returns tasks whose description contains the keyword (case-insensitive). */
+ public int size() { return tasks.size(); }
+ /** Returns tasks whose description contains the keyword (case-insensitive). */
+ public List view() { return tasks; }
+
+ public List find(String keyword) {
+ String kw = keyword.toLowerCase();
+ List out = new ArrayList<>();
+ for (Task t : tasks) {
+ if (t.getDescription().toLowerCase().contains(kw)) {
+ out.add(t);
+ }
+ }
+ return out;
+ }
+
+ /** Case-insensitive search by keyword (non-destructive). */
+ public java.util.List findByKeyword(String keyword) {
+ if (keyword == null || keyword.isBlank()) {
+ return java.util.List.of();
+ }
+ final String k = keyword.toLowerCase();
+ return tasks.stream()
+ .filter(t -> t.getDescription().toLowerCase().contains(k))
+ .collect(java.util.stream.Collectors.toList());
+ }
+
+ /** Count how many tasks are marked done. */
+ public long countDone() {
+ return tasks.stream().filter(Task::isDone).count();
+ }
+
+ /**
+ * Returns true if a task with the same logical identity already exists.
+ * Identity is derived from type + normalized description (+ date fields
+ * for deadlines/events). Used to prevent adding near-duplicates.
+ */
+ public boolean containsDuplicate(Task candidate) {
+ String keyB = dupKey(candidate);
+ for (Task t : tasks) {
+ if (dupKey(t).equals(keyB)) return true;
+ }
+ return false;
+ }
+
+ /**
+ * Build a normalized key used for duplicate detection.
+ * For:
+ * - T: "T|desc"
+ * - D: "D|desc|by"
+ * - E: "E|desc|from|to"
+ * Description is lower-cased and trimmed; dates are taken from data string.
+ */
+ private static String dupKey(Task t) {
+ String[] p = t.toDataString().split("\\s*\\|\\s*");
+ if (p.length < 3) {
+ return t.toDataString().trim();
+ }
+ String type = p[0].trim();
+ String desc = p[2].trim().toLowerCase();
+
+ switch (type) {
+ case "T":
+ return "T|" + desc;
+ case "D": {
+ String by = (p.length >= 4) ? p[3].trim() : "";
+ return "D|" + desc + "|" + by;
+ }
+ case "E": {
+ String from = (p.length >= 4) ? p[3].trim() : "";
+ String to = (p.length >= 5) ? p[4].trim() : "";
+ return "E|" + desc + "|" + from + "|" + to;
+ }
+ default:
+ StringBuilder sb = new StringBuilder(type).append('|').append(desc);
+ for (int i = 3; i < p.length; i++) sb.append('|').append(p[i].trim());
+ return sb.toString();
+ }
+ }
+
+}
diff --git a/src/main/java/quokka/TaskType.java b/src/main/java/quokka/TaskType.java
new file mode 100644
index 0000000000..03291576b7
--- /dev/null
+++ b/src/main/java/quokka/TaskType.java
@@ -0,0 +1,17 @@
+package quokka;
+
+public enum TaskType {
+ TODO("T"),
+ DEADLINE("D"),
+ EVENT("E");
+
+ private final String label;
+
+ TaskType(String label) {
+ this.label = label;
+ }
+
+ public String getLabel() {
+ return label;
+ }
+}
diff --git a/src/main/java/quokka/Todo.java b/src/main/java/quokka/Todo.java
new file mode 100644
index 0000000000..29fdad47ac
--- /dev/null
+++ b/src/main/java/quokka/Todo.java
@@ -0,0 +1,18 @@
+/** A concrete task type. */
+
+package quokka;
+
+public class Todo extends Task {
+ public Todo(String description) {
+ super(description, TaskType.TODO);
+ }
+
+ public Todo(String description, boolean isDone) {
+ super(description, TaskType.TODO, isDone);
+ }
+
+ @Override
+ public String toDataString() {
+ return "T | " + (isDone ? "1" : "0") + " | " + description;
+ }
+}
diff --git a/src/main/java/quokka/Ui.java b/src/main/java/quokka/Ui.java
new file mode 100644
index 0000000000..b08127e345
--- /dev/null
+++ b/src/main/java/quokka/Ui.java
@@ -0,0 +1,113 @@
+package quokka;
+
+import java.util.List;
+import java.util.Scanner;
+
+/**
+ * Handles user interaction concerns.
+ */
+public class Ui {
+
+ private static final String DIVIDER = "____________________________________________________________";
+ private final Scanner in = new Scanner(System.in);
+
+ /* ===================== CLI helpers ===================== */
+
+ /** Reads one line from stdin. Returns null on EOF. */
+ public String readCommand() {
+ try {
+ if (in.hasNextLine()) {
+ return in.nextLine();
+ }
+ return null; // EOF
+ } catch (Exception e) {
+ return null;
+ }
+ }
+
+ /** Prints a friendly goodbye (CLI). GUI uses the string-returning variant instead. */
+ public void showGoodbye() {
+ System.out.println(DIVIDER);
+ System.out.println(" Bye. Hope to see you again soon!");
+ System.out.println(DIVIDER);
+ }
+
+ /** Prints an error block to the console (CLI). In GUI, pass the same message into a bot bubble. */
+ public void showError(String message) {
+ System.out.println(DIVIDER);
+ System.out.println(" " + message);
+ System.out.println(DIVIDER);
+ }
+
+ /* ===================== String-returning helpers (GUI or CLI formatting) ===================== */
+
+ public String getWelcomeMessage() {
+ return "Greetings, wanderer. I am the Chronicler of Quokka Hollow.\n" +
+ "What tale shall we inscribe today?";
+ }
+
+ public String byeMessage() {
+ return "Farewell, brave one. May the path ahead be lit, even in shadow.";
+ }
+
+ /** Generic "unknown command" error string. */
+ public String showUnknownCommandError(String cmd) {
+ return "Alas… I do not know this incantation: " + safe(cmd);
+ }
+
+ /** Formats a full task list. */
+ public String showTaskList(List list) {
+ if (list == null || list.isEmpty()) {
+ return "Your list is empty.";
+ }
+ StringBuilder sb = new StringBuilder("Here are the tasks in your list:\n");
+ for (int i = 0; i < list.size(); i++) {
+ sb.append(i + 1).append('.').append(list.get(i)).append('\n');
+ }
+ return rstrip(sb);
+ }
+
+ /** Formats matching tasks (for 'find'). */
+ public String showMatchingTasks(List list) {
+ if (list == null || list.isEmpty()) {
+ return "No matching tasks found.";
+ }
+ StringBuilder sb = new StringBuilder("Here are the matching tasks in your list:\n");
+ for (int i = 0; i < list.size(); i++) {
+ sb.append(i + 1).append('.').append(list.get(i)).append('\n');
+ }
+ return rstrip(sb);
+ }
+
+ public String formatAdded(Task t, int newSize) {
+ return "A new entry etched into the Chronicle:\n " + t +
+ "\nYou now carry " + newSize + " burdens.";
+ }
+
+ public String formatMarked(Task t) {
+ return "The deed is done, etched with certainty:\n " + t;
+ }
+
+ public String formatUnmarked(Task t) {
+ return "The ink fades… the task remains unfinished:\n " + t;
+ }
+
+ public String formatDeleted(Task t, int newSize) {
+ return "A memory erased from the Chronicle:\n " + t +
+ "\nOnly " + newSize + " tales remain.";
+ }
+
+
+ /* ===================== small utilities ===================== */
+ private static String rstrip(StringBuilder sb) {
+ int len = sb.length();
+ while (len > 0 && Character.isWhitespace(sb.charAt(len - 1))) {
+ len--;
+ }
+ return sb.substring(0, len);
+ }
+
+ private static String safe(String s) {
+ return s == null ? "" : s;
+ }
+}
diff --git a/src/main/java/quokka/util/Dates.java b/src/main/java/quokka/util/Dates.java
new file mode 100644
index 0000000000..57d557616d
--- /dev/null
+++ b/src/main/java/quokka/util/Dates.java
@@ -0,0 +1,118 @@
+/**
+ * Parse a date using strict patterns (yyyy-MM-dd, d/M/yyyy,
+ * d MMM yyyy, etc.). Rejects invalid calendar dates.
+ *
+ * @param raw user input string
+ * @return parsed LocalDate
+ * @throws IllegalArgumentException if format is unrecognized/invalid
+ */
+
+
+package quokka.util;
+
+import java.time.LocalDate;
+import java.time.format.DateTimeFormatter;
+import java.time.format.DateTimeParseException;
+
+/** Date helpers: flexible parse + standard format. */
+public final class Dates {
+ private Dates() {}
+
+ private static final DateTimeFormatter OUT_FMT = DateTimeFormatter.ofPattern("MMM d yyyy");
+
+ /** Parse many human inputs into a LocalDate (throws IllegalArgumentException if fails). */
+ public static LocalDate parseFlexibleDate(String raw) {
+ if (raw == null) {
+ throw new IllegalArgumentException("date is null");
+ }
+ String s = raw.trim();
+
+ try { return LocalDate.parse(s); } catch (DateTimeParseException ignored) {}
+
+ s = s.replaceAll("(?i)(\\d{1,2})(st|nd|rd|th)", "$1").trim().replaceAll("\\s{2,}", " ");
+
+ // strip trailing time-ish parts
+ s = s.replaceFirst("(?i)\\s+(\\d{3,4})$", "");
+ s = s.replaceFirst("(?i)\\s+\\d{1,2}:\\d{2}([ap]m)?$", "");
+ s = s.replaceFirst("(?i)\\s+\\d{1,2}\\s*-\\s*\\d{1,2}\\s*[ap]m$", "");
+
+ String[] patterns = {
+ "yyyy-MM-dd",
+ "d/M/uuuu", "d-M-uuuu",
+ "d MMM uuuu", "d MMMM uuuu",
+ "MMM d uuuu", "MMMM d uuuu"
+ };
+ for (String p : patterns) {
+ try { return LocalDate.parse(s, DateTimeFormatter.ofPattern(p)); }
+ catch (DateTimeParseException ignored) {}
+ }
+
+ String[] noYear = { "MMM d", "MMMM d", "d MMM", "d MMMM" };
+ for (String p : noYear) {
+ try {
+ LocalDate base = LocalDate.parse(s, DateTimeFormatter.ofPattern(p));
+ return base.withYear(LocalDate.now().getYear());
+ } catch (DateTimeParseException ignored) {}
+ }
+
+ throw new IllegalArgumentException("Unrecognized date format: \"" + raw + "\"");
+ }
+
+ /** Format a date consistently for UI. */
+ public static String fmt(LocalDate date) {
+ return date.format(OUT_FMT);
+ }
+
+ /**
+ * Parse a date using strict patterns (yyyy-MM-dd, d/M/yyyy, d MMM yyyy, etc.).
+ * Rejects invalid calendar dates (e.g., 2025-02-30).
+ *
+ * @param raw user input string
+ * @return parsed LocalDate
+ * @throws IllegalArgumentException if format is unrecognized or invalid
+ */
+ public static java.time.LocalDate parseStrictDate(String raw) {
+ if (raw == null) throw new IllegalArgumentException("date is null");
+ String s = raw.trim();
+ java.time.format.ResolverStyle STRICT = java.time.format.ResolverStyle.STRICT;
+
+ String[] patterns = new String[]{
+ "uuuu-MM-dd", "d/M/uuuu", "d-M-uuuu", "d.M.uuuu",
+ "uuuu/M/d", "uuuu-M-d", "M/d/uuuu", "d MMM uuuu", "MMM d uuuu"
+ };
+ for (String p : patterns) {
+ try {
+ java.time.format.DateTimeFormatter f =
+ new java.time.format.DateTimeFormatterBuilder()
+ .parseCaseInsensitive()
+ .appendPattern(p)
+ .toFormatter()
+ .withResolverStyle(STRICT);
+ return java.time.LocalDate.parse(s, f);
+ } catch (java.time.format.DateTimeParseException ignored) {}
+ }
+ throw new IllegalArgumentException("Unparseable or invalid calendar date: " + raw);
+ }
+
+ /**
+ * Validate a 24-hour HHmm time and return minutes since 00:00.
+ *
+ * @param raw four-digit time string in HHmm
+ * @return minutes in [0, 1439]
+ * @throws IllegalArgumentException if not HHmm or out of range
+ */
+ public static int validateHHmm(String raw) {
+ if (raw == null) throw new IllegalArgumentException("time is null");
+ String s = raw.trim();
+ if (!s.matches("\\d{4}")) {
+ throw new IllegalArgumentException("Invalid time, expected HHmm: " + raw);
+ }
+ int hh = Integer.parseInt(s.substring(0, 2));
+ int mm = Integer.parseInt(s.substring(2, 4));
+ if (hh < 0 || hh > 23 || mm < 0 || mm > 59) {
+ throw new IllegalArgumentException("Invalid time, expected 0000..2359: " + raw);
+ }
+ return hh * 60 + mm;
+ }
+
+}
diff --git a/src/main/resources/images/knight.png b/src/main/resources/images/knight.png
new file mode 100644
index 0000000000..5643223a2b
Binary files /dev/null and b/src/main/resources/images/knight.png differ
diff --git a/src/main/resources/images/user.png b/src/main/resources/images/user.png
new file mode 100644
index 0000000000..6adec06344
Binary files /dev/null and b/src/main/resources/images/user.png differ
diff --git a/src/main/resources/quokka/view/hollowknight.css b/src/main/resources/quokka/view/hollowknight.css
new file mode 100644
index 0000000000..608d2f0017
--- /dev/null
+++ b/src/main/resources/quokka/view/hollowknight.css
@@ -0,0 +1,99 @@
+/* === Base layout === */
+.hk-pane {
+ -fx-background-color: linear-gradient(to bottom, #0d0d1a, #0b0b16);
+}
+
+.hk-headerbar {
+ -fx-alignment: center-left;
+ -fx-padding: 10 12;
+ -fx-background-color: #0f1220;
+ -fx-border-color: rgba(255,255,255,0.08);
+ -fx-border-width: 0 0 1 0;
+}
+
+.hk-header {
+ -fx-text-fill: #f0f4ff;
+ -fx-font-size: 22px;
+ -fx-font-weight: bold;
+}
+
+/* === Chat lane === */
+.lane { -fx-background-color: transparent; }
+
+
+/* === Bubbles === */
+.bot-bubble, .user-chip {
+ -fx-padding: 10 14;
+ -fx-background-radius: 16;
+ -fx-font-size: 14px;
+ -fx-line-spacing: 2;
+}
+
+.bot-bubble {
+ -fx-background-color: rgba(255,255,255,0.08);
+ -fx-text-fill: #e9eef7;
+}
+
+.error-bubble {
+ -fx-background-color: rgba(220,50,47,0.25);
+ -fx-text-fill: #ffecec;
+ -fx-effect: dropshadow(three-pass-box, rgba(220,50,47,0.5), 12, 0.3, 0, 3);
+}
+
+.user-chip {
+ -fx-background-color: linear-gradient(to bottom right, #4e7fff, #3a6be0);
+ -fx-text-fill: #ffffff;
+ -fx-font-weight: bold;
+}
+
+/* === Avatars === */
+.avatar {
+ -fx-opacity: 0.9;
+ -fx-effect: dropshadow(three-pass-box, rgba(0,0,0,0.4), 6, 0.3, 0, 2);
+}
+
+/* === Input === */
+.input-bar {
+ -fx-background-color: #0f1220;
+ -fx-border-color: rgba(255,255,255,0.08);
+ -fx-border-width: 1 0 0 0;
+ -fx-padding: 8 12;
+}
+
+.hk-input {
+ -fx-background-radius: 12;
+ -fx-background-color: rgba(255,255,255,0.08);
+ -fx-text-fill: #f0f4ff;
+ -fx-prompt-text-fill: rgba(240,244,255,0.4);
+ -fx-padding: 8 12;
+ -fx-border-color: rgba(255,255,255,0.12);
+ -fx-border-radius: 12;
+}
+
+.hk-input:focused {
+ -fx-border-color: #7aa7ff;
+ -fx-background-color: rgba(255,255,255,0.12);
+}
+
+.hk-send {
+ -fx-background-radius: 12;
+ -fx-background-color: linear-gradient(to bottom right, #4e7fff, #3a6be0);
+ -fx-text-fill: white;
+ -fx-font-weight: bold;
+ -fx-padding: 8 14;
+}
+.hk-send:hover {
+ -fx-background-color: linear-gradient(to bottom right, #5b88ff, #4774e6);
+}
+
+.scroll-pane,
+.scroll-pane .viewport,
+.scroll-pane .content {
+ -fx-background-color: transparent;
+ -fx-background: transparent;
+}
+
+* {
+ -fx-focus-color: transparent;
+ -fx-faint-focus-color: transparent;
+}
diff --git a/src/test/java/quokka/DatesTest.java b/src/test/java/quokka/DatesTest.java
new file mode 100644
index 0000000000..87b01b6167
--- /dev/null
+++ b/src/test/java/quokka/DatesTest.java
@@ -0,0 +1,48 @@
+package quokka;
+
+import org.junit.jupiter.api.Test;
+import quokka.util.Dates;
+
+import java.time.LocalDate;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+class DatesTest {
+
+ @Test
+ void parseStrictDate_acceptsCommonPatterns() {
+ assertEquals(LocalDate.of(2025, 9, 10), Dates.parseStrictDate("2025-09-10"));
+ assertEquals(LocalDate.of(2025, 9, 10), Dates.parseStrictDate("10/9/2025"));
+ assertEquals(LocalDate.of(2025, 9, 10), Dates.parseStrictDate("10 Sep 2025"));
+ assertEquals(LocalDate.of(2025, 9, 1), Dates.parseStrictDate("1-9-2025"));
+ }
+
+ @Test
+ void parseStrictDate_rejectsImpossibleDates() {
+ IllegalArgumentException ex = assertThrows(IllegalArgumentException.class,
+ () -> Dates.parseStrictDate("2025-02-30"));
+ assertTrue(ex.getMessage().toLowerCase().contains("date") || ex.getMessage().toLowerCase().contains("parse"));
+ }
+
+ @Test
+ void validateHHmm_happyPath() {
+ assertEquals(0, Dates.validateHHmm("0000"));
+ assertEquals(9 * 60 + 30, Dates.validateHHmm("0930"));
+ assertEquals(23 * 60 + 59, Dates.validateHHmm("2359"));
+ }
+
+ @Test
+ void validateHHmm_rejectsBadTimes() {
+ assertThrows(IllegalArgumentException.class, () -> Dates.validateHHmm("24:00"));
+ assertThrows(IllegalArgumentException.class, () -> Dates.validateHHmm("2460"));
+ assertThrows(IllegalArgumentException.class, () -> Dates.validateHHmm("abcd"));
+ assertThrows(IllegalArgumentException.class, () -> Dates.validateHHmm("9999"));
+ }
+
+ @Test
+ void fmt_formatsShortMonthStyle() {
+ assertEquals("Sep 10 2025",
+ Dates.fmt(java.time.LocalDate.of(2025, 9, 10)));
+ }
+
+}
diff --git a/src/test/java/quokka/DeadlineTest.java b/src/test/java/quokka/DeadlineTest.java
new file mode 100644
index 0000000000..ac50f4efb2
--- /dev/null
+++ b/src/test/java/quokka/DeadlineTest.java
@@ -0,0 +1,20 @@
+package quokka;
+
+import org.junit.jupiter.api.Test;
+import static org.junit.jupiter.api.Assertions.*;
+
+public class DeadlineTest {
+ @Test
+ void isoParseAndFormat() {
+ Deadline d = new Deadline("return book", "2019-10-15");
+ assertEquals("D | 0 | return book | 2019-10-15", d.toDataString());
+ assertTrue(d.toString().contains("2019"), d.toString());
+ }
+
+ @Test
+ void flexibleParsing_dMY_withTimeIgnored() {
+ Deadline d = new Deadline("return book", "2/12/2019 1800");
+ assertEquals("D | 0 | return book | 2019-12-02", d.toDataString());
+ assertTrue(d.toString().contains("2019"), d.toString());
+ }
+}
diff --git a/src/test/java/quokka/EventValidationTest.java b/src/test/java/quokka/EventValidationTest.java
new file mode 100644
index 0000000000..9f4da6985a
--- /dev/null
+++ b/src/test/java/quokka/EventValidationTest.java
@@ -0,0 +1,30 @@
+package quokka;
+
+import org.junit.jupiter.api.Test;
+import static org.junit.jupiter.api.Assertions.*;
+
+class EventValidationTest {
+
+ @Test
+ void constructor_rejectsSameOrReversedRange() {
+ IllegalArgumentException same = assertThrows(IllegalArgumentException.class,
+ () -> new Event("demo", "2025-09-10", "2025-09-10"));
+ assertTrue(same.getMessage().toLowerCase().contains("start"));
+
+ IllegalArgumentException reversed = assertThrows(IllegalArgumentException.class,
+ () -> new Event("demo", "2025-09-11", "2025-09-10"));
+ assertTrue(reversed.getMessage().toLowerCase().contains("start"));
+ }
+
+ @Test
+ void constructor_acceptsProperRange() {
+ Event e = new Event("demo", "2025-09-10", "2025-09-12");
+ assertNotNull(e);
+ }
+
+ @Test
+ void constructor_rejectsImpossibleDate() {
+ assertThrows(IllegalArgumentException.class,
+ () -> new Event("demo", "2025-02-30", "2025-03-01"));
+ }
+}
diff --git a/src/test/java/quokka/ParserTest.java b/src/test/java/quokka/ParserTest.java
new file mode 100644
index 0000000000..3e710c2745
--- /dev/null
+++ b/src/test/java/quokka/ParserTest.java
@@ -0,0 +1,35 @@
+package quokka;
+
+import org.junit.jupiter.api.Test;
+import static org.junit.jupiter.api.Assertions.*;
+
+class ParserTest {
+
+ @Test
+ void commandWord_trimsAndNormalizes() {
+ String input = "\u00A0 todo\u00A0read book ";
+ assertEquals("todo", Parser.commandWord(input));
+ }
+
+ @Test
+ void remainder_trimsAndNormalizes() {
+ String input = "\u00A0deadline\u00A0 read book \u00A0 /by 2025-09-10 ";
+ assertEquals("read book /by 2025-09-10", Parser.remainder(input));
+ }
+
+ @Test
+ void countToken_countsNonOverlapping() {
+ assertEquals(0, Parser.countToken("", " /by "));
+ assertEquals(1, Parser.countToken("a /by b", " /by "));
+ assertEquals(2, Parser.countToken("x /from a /to b", " /"));
+ }
+
+ @Test
+ void splitOnce_splitsOnFirstOccurrence() {
+ String[] p = Parser.splitOnce("desc /by 2024-01-01", " /by ");
+ assertArrayEquals(new String[]{"desc", "2024-01-01"}, p);
+
+ String[] p2 = Parser.splitOnce("desc only", " /by ");
+ assertArrayEquals(new String[]{"desc only", ""}, p2);
+ }
+}
diff --git a/src/test/java/quokka/StorageRoundTripTest.java b/src/test/java/quokka/StorageRoundTripTest.java
new file mode 100644
index 0000000000..ad02924d2b
--- /dev/null
+++ b/src/test/java/quokka/StorageRoundTripTest.java
@@ -0,0 +1,40 @@
+package quokka;
+
+import org.junit.jupiter.api.Test;
+import static org.junit.jupiter.api.Assertions.*;
+
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.List;
+
+public class StorageRoundTripTest {
+ @Test
+ void saveThenLoad_roundTripPreservesData() throws Exception {
+ Path tempDir = Files.createTempDirectory("quokka-test-");
+ Path file = tempDir.resolve("duke.txt");
+
+ List out = new ArrayList<>();
+ Todo t1 = new Todo("read book");
+ Deadline t2 = new Deadline("return book", "2019-10-15");
+ Event t3 = new Event("project", "2019-12-01", "2019-12-02");
+ t2.markAsDone();
+
+ out.add(t1); out.add(t2); out.add(t3);
+ Storage.save(file, out);
+
+ List in = new ArrayList<>();
+ Storage.load(file, in);
+
+ assertEquals(3, in.size());
+ assertInstanceOf(Todo.class, in.get(0));
+ assertEquals("read book", in.get(0).getDescription());
+ assertFalse(in.get(0).isDone());
+ assertInstanceOf(Deadline.class, in.get(1));
+ assertEquals("return book", in.get(1).getDescription());
+ assertTrue(in.get(1).isDone());
+ assertInstanceOf(Event.class, in.get(2));
+ assertEquals("project", in.get(2).getDescription());
+ assertFalse(in.get(2).isDone());
+ }
+}
diff --git a/src/test/java/quokka/StorageTest.java b/src/test/java/quokka/StorageTest.java
new file mode 100644
index 0000000000..0519a297e4
--- /dev/null
+++ b/src/test/java/quokka/StorageTest.java
@@ -0,0 +1,55 @@
+package quokka;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import java.nio.charset.StandardCharsets;
+import java.nio.file.*;
+import java.util.ArrayList;
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+class StorageTest {
+
+ @TempDir
+ Path tmpDir;
+
+ @Test
+ void saveAndLoad_roundTripAndSkipCorrupted() throws Exception {
+ Path data = tmpDir.resolve("quokka-data.txt");
+
+ List tasks = new ArrayList<>();
+ tasks.add(new Todo("read book"));
+ tasks.add(new Deadline("submit report", "2025-09-10"));
+ tasks.add(new Event("camp", "2025-09-10", "2025-09-12"));
+
+ Storage.save(data, tasks);
+
+ Files.writeString(data, "X | 0 | nonsense line\n", StandardCharsets.UTF_8, StandardOpenOption.APPEND);
+
+ List loaded = new ArrayList<>();
+ Storage.load(data, loaded);
+
+ assertEquals(3, loaded.size());
+
+ assertTrue(loaded.get(0) instanceof Todo);
+ assertTrue(loaded.get(1) instanceof Deadline);
+ assertTrue(loaded.get(2) instanceof Event);
+ }
+
+ @Test
+ void save_createsParentDirectories() throws Exception {
+ Path nested = tmpDir.resolve("a/b/c/data.txt");
+ List tasks = new ArrayList<>();
+ tasks.add(new Todo("hello"));
+
+ Storage.save(nested, tasks);
+ assertTrue(Files.exists(nested), "Data file should be created along nested dirs");
+
+ List loaded = new ArrayList<>();
+ Storage.load(nested, loaded);
+ assertEquals(1, loaded.size());
+ assertTrue(loaded.get(0) instanceof Todo);
+ }
+}
diff --git a/src/test/java/quokka/TaskListDuplicateTest.java b/src/test/java/quokka/TaskListDuplicateTest.java
new file mode 100644
index 0000000000..8f79710add
--- /dev/null
+++ b/src/test/java/quokka/TaskListDuplicateTest.java
@@ -0,0 +1,45 @@
+package quokka;
+
+import org.junit.jupiter.api.Test;
+
+import java.util.ArrayList;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+class TaskListDuplicateTest {
+
+ @Test
+ void containsDuplicate_todo_caseInsensitiveOnDesc() {
+ TaskList tl = new TaskList(new ArrayList<>());
+ Todo a = new Todo("Read Book");
+ Todo b = new Todo("read book");
+
+ assertFalse(tl.containsDuplicate(a));
+ tl.add(a);
+ assertTrue(tl.containsDuplicate(b));
+ }
+
+ @Test
+ void containsDuplicate_deadline_usesDateField() {
+ TaskList tl = new TaskList(new ArrayList<>());
+ Deadline a = new Deadline("submit report", "2025-09-10");
+ Deadline b = new Deadline("Submit Report", "2025-09-10");
+ Deadline c = new Deadline("Submit Report", "2025-09-11");
+
+ tl.add(a);
+ assertTrue(tl.containsDuplicate(b));
+ assertFalse(tl.containsDuplicate(c));
+ }
+
+ @Test
+ void containsDuplicate_event_usesBothDates() {
+ TaskList tl = new TaskList(new ArrayList<>());
+ Event a = new Event("trip", "2025-09-10", "2025-09-12");
+ Event b = new Event("TRIP", "2025-09-10", "2025-09-12");
+ Event c = new Event("trip", "2025-09-11", "2025-09-12");
+
+ tl.add(a);
+ assertTrue(tl.containsDuplicate(b));
+ assertFalse(tl.containsDuplicate(c));
+ }
+}
diff --git a/text-ui-test/ACTUAL.TXT b/text-ui-test/ACTUAL.TXT
new file mode 100644
index 0000000000..e70a09adc1
--- /dev/null
+++ b/text-ui-test/ACTUAL.TXT
@@ -0,0 +1,36 @@
+____________________________________________________________
+ Hello! I'm quokka.Quokka
+ What can I do for you?
+____________________________________________________________
+____________________________________________________________
+ Got it. I've added this task:
+ [T][ ] read book
+ Now you have 1 task in the list.
+____________________________________________________________
+____________________________________________________________
+ Got it. I've added this task:
+ [D][ ] return book (by: Sunday)
+ Now you have 2 tasks in the list.
+____________________________________________________________
+____________________________________________________________
+ Got it. I've added this task:
+ [E][ ] project meeting (from: Mon 2pm to: 4pm)
+ Now you have 3 tasks in the list.
+____________________________________________________________
+____________________________________________________________
+ Here are the tasks in your list:
+ 1.[T][ ] read book
+ 2.[D][ ] return book (by: Sunday)
+ 3.[E][ ] project meeting (from: Mon 2pm to: 4pm)
+____________________________________________________________
+____________________________________________________________
+ Nice! I've marked this task as done:
+ [D][X] return book (by: Sunday)
+____________________________________________________________
+____________________________________________________________
+ OK, I've marked this task as not done yet:
+ [D][ ] return book (by: Sunday)
+____________________________________________________________
+____________________________________________________________
+ Bye. Hope to see you again soon!
+____________________________________________________________
diff --git a/text-ui-test/EXPECTED.TXT b/text-ui-test/EXPECTED.TXT
index 657e74f6e7..e70a09adc1 100644
--- a/text-ui-test/EXPECTED.TXT
+++ b/text-ui-test/EXPECTED.TXT
@@ -1,7 +1,36 @@
-Hello from
- ____ _
-| _ \ _ _| | _____
-| | | | | | | |/ / _ \
-| |_| | |_| | < __/
-|____/ \__,_|_|\_\___|
-
+____________________________________________________________
+ Hello! I'm quokka.Quokka
+ What can I do for you?
+____________________________________________________________
+____________________________________________________________
+ Got it. I've added this task:
+ [T][ ] read book
+ Now you have 1 task in the list.
+____________________________________________________________
+____________________________________________________________
+ Got it. I've added this task:
+ [D][ ] return book (by: Sunday)
+ Now you have 2 tasks in the list.
+____________________________________________________________
+____________________________________________________________
+ Got it. I've added this task:
+ [E][ ] project meeting (from: Mon 2pm to: 4pm)
+ Now you have 3 tasks in the list.
+____________________________________________________________
+____________________________________________________________
+ Here are the tasks in your list:
+ 1.[T][ ] read book
+ 2.[D][ ] return book (by: Sunday)
+ 3.[E][ ] project meeting (from: Mon 2pm to: 4pm)
+____________________________________________________________
+____________________________________________________________
+ Nice! I've marked this task as done:
+ [D][X] return book (by: Sunday)
+____________________________________________________________
+____________________________________________________________
+ OK, I've marked this task as not done yet:
+ [D][ ] return book (by: Sunday)
+____________________________________________________________
+____________________________________________________________
+ Bye. Hope to see you again soon!
+____________________________________________________________
diff --git a/text-ui-test/input.txt b/text-ui-test/input.txt
index e69de29bb2..3a1c77435e 100644
--- a/text-ui-test/input.txt
+++ b/text-ui-test/input.txt
@@ -0,0 +1,7 @@
+todo read book
+deadline return book /by Sunday
+event project meeting /from Mon 2pm /to 4pm
+list
+mark 2
+unmark 2
+bye
diff --git a/text-ui-test/runtest.bat b/text-ui-test/runtest.bat
index 0873744649..2aa9a9d7f8 100644
--- a/text-ui-test/runtest.bat
+++ b/text-ui-test/runtest.bat
@@ -1,21 +1,23 @@
-@ECHO OFF
+@echo off
+setlocal
-REM create bin directory if it doesn't exist
-if not exist ..\bin mkdir ..\bin
+rem Clean previous ACTUAL.TXT if exists
+del text-ui-test\ACTUAL.TXT 2> NUL
-REM delete output from previous run
-if exist ACTUAL.TXT del ACTUAL.TXT
+rem Compile into out/
+if not exist out mkdir out
+javac -cp src\main\java -d out src\main\java\*.java
-REM compile the code into the bin folder
-javac -cp ..\src\main\java -Xlint:none -d ..\bin ..\src\main\java\*.java
-IF ERRORLEVEL 1 (
- echo ********** BUILD FAILURE **********
- exit /b 1
-)
-REM no error here, errorlevel == 0
+rem Run with redirected I/O
+java -classpath out quokka.Quokka < text-ui-test\input.txt > text-ui-test\ACTUAL.TXT
-REM run the program, feed commands from input.txt file and redirect the output to the ACTUAL.TXT
-java -classpath ..\bin Duke < input.txt > ACTUAL.TXT
+rem Compare using fc; suppress diff output
+fc text-ui-test\EXPECTED.TXT text-ui-test\ACTUAL.TXT > NUL
+if %errorlevel%==0 (
+ echo All tests passed
+) else (
+ echo Tests failed
+ exit /b 1
+)
-REM compare the output to the expected output
-FC ACTUAL.TXT EXPECTED.TXT
+endlocal
diff --git a/text-ui-test/runtest.sh b/text-ui-test/runtest.sh
old mode 100644
new mode 100755