diff --git a/README.md b/README.md index af0309a9ef..143251ca9c 100644 --- a/README.md +++ b/README.md @@ -24,3 +24,7 @@ Prerequisites: JDK 17, update Intellij to the most recent version. ``` **Warning:** Keep the `src\main\java` folder as the root folder for Java files (i.e., don't rename those folders or move Java files to another folder outside of this folder path), as this is the default location some tools (e.g., Gradle) expect to find Java files. + +**Dev note:** Assertions are enabled via -ea for run/tests. + +**Note:** Reduced duplication in add messages. diff --git a/build.gradle b/build.gradle new file mode 100644 index 0000000000..ea92312a8c --- /dev/null +++ b/build.gradle @@ -0,0 +1,70 @@ +plugins { + id 'application' + id 'com.github.johnrengelman.shadow' version '8.1.1' + id 'java' + id 'jacoco' + id 'org.openjfx.javafxplugin' version '0.0.14' +} + +repositories { + mavenCentral() +} + +java { + toolchain { languageVersion = JavaLanguageVersion.of(17) } +} + +application { + mainClass = 'larry.gui.Launcher' +} + +dependencies { + // Windows + implementation "org.openjfx:javafx-base:17.0.12:win" + implementation "org.openjfx:javafx-graphics:17.0.12:win" + implementation "org.openjfx:javafx-controls:17.0.12:win" + // macOS + implementation "org.openjfx:javafx-base:17.0.12:mac" + implementation "org.openjfx:javafx-graphics:17.0.12:mac" + implementation "org.openjfx:javafx-controls:17.0.12:mac" + // Linux + implementation "org.openjfx:javafx-base:17.0.12:linux" + implementation "org.openjfx:javafx-graphics:17.0.12:linux" + implementation "org.openjfx:javafx-controls:17.0.12:linux" + + testImplementation 'org.junit.jupiter:junit-jupiter-api:5.10.2' + testRuntimeOnly 'org.junit.jupiter:junit-jupiter-engine:5.10.2' + testImplementation 'org.junit.jupiter:junit-jupiter-params:5.10.2' +} + +test { + useJUnitPlatform() + finalizedBy jacocoTestReport +} + +jacoco { + toolVersion = '0.8.11' +} + +jacocoTestReport { + dependsOn test + reports { + html.required = true + xml.required = true + csv.required = false + } +} + +tasks.withType(JavaExec).configureEach { + jvmArgs += ['-Dprism.order=sw'] +} + +tasks.named('shadowJar') { + archiveFileName.set('larry.jar') + manifest { attributes 'Main-Class': application.mainClass.get() } +} + +javafx { + version = '17.0.9' // or 17.0.11 / 22; keep it consistent with your Java 17 + modules = [ 'javafx.controls', 'javafx.fxml' ] +} diff --git a/data/larry.txt b/data/larry.txt new file mode 100644 index 0000000000..e4b8271a25 --- /dev/null +++ b/data/larry.txt @@ -0,0 +1,7 @@ +E|0|sync|2025-09-12 1400|2025-09-12 1600 +T|0|read +T|0|read book +T|0|write essay +T|0|this +T|0|this and that +T|0|this and that today diff --git a/docs/AI.md b/docs/AI.md new file mode 100644 index 0000000000..b726e48bb9 --- /dev/null +++ b/docs/AI.md @@ -0,0 +1,11 @@ +# AI Assistance + +**Tools used:** ChatGPT (reasoning, code review) + +**Scope:** JavaFX wiring hints, command-alias parsing fix, and addition of Javadoc header comments. All code reviewed and tested locally. + +**Details:** +- Received guidance on FXML/controller wiring, event handlers, resource paths, and basic CSS for the JavaFX UI. +- Got suggestions for parsing/aliasing commands and defensive checks. +- Used AI to draft and refine **Javadoc header comments** for public classes and methods (clarity, parameters, return values, and side-effects). +- Performed local verification of behavior and builds after applying changes. diff --git a/docs/README.md b/docs/README.md index 47b9f984f7..e56ed2d746 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,30 +1,132 @@ -# Duke User Guide +# Larry -// Update the title above to match the actual product name +> “Your mind is for having ideas, not holding them.” — David Allen -// Product screenshot goes here +**Larry** is a simple, text-based task bot with a small JavaFX GUI. It lets you track **todos**, **deadlines**, and **events**, and saves them to disk so your list survives restarts. -// Product intro goes here +* Java 17 +* Gradle build +* CLI **and** GUI +* Saves to `data/larry.txt` -## Adding deadlines +--- -// Describe the action and its outcome. +## Screenshot -// Give examples of usage +![Larry GUI](./Ui.png) -Example: `keyword (optional arguments)` +--- -// A description of the expected outcome goes here +## Quick Start +### GUI (recommended) + +```bash +# from the repo root +./gradlew run +# Windows: +.\gradlew run ``` -expected output + +### JAR + +1. Download the latest release JAR from the **Releases** page. +2. Double-click it **or** run: + +```bash +java -jar larry.jar +``` + +--- + +## What Larry Can Do + +* Add **todos**, **deadlines**, **events** +* List tasks +* Mark / unmark as done +* Delete tasks +* Find tasks by keyword +* Show help +* Exit safely (auto-saves) + +> **Tip:** Type `help` any time to see a quick in-app summary. + +--- + +## Date & Time Format (Important) + +When entering dates/times for **deadlines** and **events**, Larry accepts: + +* **Date only:** `yyyy-MM-dd` + *Example:* `2025-10-20` +* **Date & time:** + `yyyy-MM-dd HH:mm` (*Example:* `2025-10-20 18:30`) + `yyyy-MM-dd HHmm` (*Example:* `2025-10-20 1830`) + +If parsing fails, Larry keeps your original text so you can fix it later. + +--- + +## Commands + +Type a command and press **Enter**. + +| Action | Command & Format | Example | +| --------------- | ----------------------------------------------------------------------------------- | ----------------------------------------------------------- | +| Show all tasks | `list` | `list` | +| Add a todo | `todo ` | `todo read CS2103T notes` | +| Add a deadline | `deadline /by ` | `deadline iP submission /by 2025-10-20 18:00` | +| Add an event | `event /from /to ` | `event hackathon /from 2025-10-21 0900 /to 2025-10-21 1800` | +| Mark as done | `mark ` | `mark 2` | +| Unmark | `unmark ` | `unmark 2` | +| Delete | `delete ` | `delete 3` | +| Find by keyword | `find ` | `find cs2103t` | +| Help | `help` | `help` | +| Exit | `bye` | `bye` | + +**Notes** + +* Indexes are **1-based** (see `list` output). +* Descriptions can contain spaces. +* For **events**, `/from` should be earlier than or equal to `/to`. + +--- + +## Examples + +```text +todo buy milk +deadline project report /by 2025-10-20 +deadline submission /by 2025-10-20 2359 +event team meeting /from 2025-10-21 0900 /to 2025-10-21 1030 +list +find report +mark 1 +unmark 1 +delete 2 +bye ``` -## Feature ABC +--- + +## Storage + +* File: `data/larry.txt` (auto-created if missing) +* Changes save automatically when you modify tasks or exit. + +--- + +## Troubleshooting + +* **“Unknown command”** → Run `help` to see valid commands and formats. +* **Date rejected / looks wrong** → Re-enter using the formats above (e.g., `2025-10-20 1830`). +* **Nothing happens on Enter** → Click the input box and try again. +* **App doesn’t start** → Ensure **Java 17** is installed (`java -version`). -// Feature details +--- +## Shortcuts & Tips -## Feature XYZ +* Use `list` to confirm indexes before `mark`, `unmark`, or `delete`. +* `find ` narrows results so you don’t mark/delete the wrong item. -// Feature details \ No newline at end of file diff --git a/docs/Ui.png b/docs/Ui.png new file mode 100644 index 0000000000..a50038ff96 Binary files /dev/null and b/docs/Ui.png differ diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000..e6441136f3 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..b82aa23a4f --- /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-8.7-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..1aa94a4269 --- /dev/null +++ b/gradlew @@ -0,0 +1,249 @@ +#!/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##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd "${APP_HOME:-./}" > /dev/null && 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=SC2039,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=SC2039,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, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +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..7101f8e467 --- /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. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +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/DateTimeFormats.class b/src/main/java/DateTimeFormats.class new file mode 100644 index 0000000000..90fb08b662 Binary files /dev/null and b/src/main/java/DateTimeFormats.class differ diff --git a/src/main/java/Deadline.class b/src/main/java/Deadline.class new file mode 100644 index 0000000000..bcbc86c92d Binary files /dev/null and b/src/main/java/Deadline.class differ 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/Event.class b/src/main/java/Event.class new file mode 100644 index 0000000000..14f2e0fbfa Binary files /dev/null and b/src/main/java/Event.class differ diff --git a/src/main/java/Larry.class b/src/main/java/Larry.class new file mode 100644 index 0000000000..d13a5bef29 Binary files /dev/null and b/src/main/java/Larry.class differ diff --git a/src/main/java/Parser.class b/src/main/java/Parser.class new file mode 100644 index 0000000000..b1b40f8713 Binary files /dev/null and b/src/main/java/Parser.class differ diff --git a/src/main/java/Storage.class b/src/main/java/Storage.class new file mode 100644 index 0000000000..4231334d48 Binary files /dev/null and b/src/main/java/Storage.class differ diff --git a/src/main/java/Task.class b/src/main/java/Task.class new file mode 100644 index 0000000000..3669cdbf96 Binary files /dev/null and b/src/main/java/Task.class differ diff --git a/src/main/java/TaskList.class b/src/main/java/TaskList.class new file mode 100644 index 0000000000..2b73d57f7c Binary files /dev/null and b/src/main/java/TaskList.class differ diff --git a/src/main/java/Todo.class b/src/main/java/Todo.class new file mode 100644 index 0000000000..e3a4dcf755 Binary files /dev/null and b/src/main/java/Todo.class differ diff --git a/src/main/java/Ui.class b/src/main/java/Ui.class new file mode 100644 index 0000000000..d85dc8e86a Binary files /dev/null and b/src/main/java/Ui.class differ diff --git a/src/main/java/larry/Larry.java b/src/main/java/larry/Larry.java new file mode 100644 index 0000000000..774bf33fcd --- /dev/null +++ b/src/main/java/larry/Larry.java @@ -0,0 +1,161 @@ +package larry; + +import java.util.Scanner; + +import larry.model.Deadline; +import larry.model.Event; +import larry.model.Task; +import larry.model.TaskList; +import larry.model.Todo; +import larry.parser.Parser; +import larry.storage.Storage; +import larry.ui.Ui; + +/** + * Entry pt. of the Larry application. + * Wires Ui, Storage and TaskList, and dispatches user commands. + */ +public class Larry { + + /** Starts the console application. */ + public static void main(String[] args) { + Ui ui = new Ui(); + Storage storage = new Storage("data/larry.txt"); + TaskList tasks = new TaskList(storage.load()); + + ui.showGreeting(); + + Scanner sc = new Scanner(System.in); + while (sc.hasNextLine()) { + String input = sc.nextLine().trim(); + String cmd = Parser.commandWord(input); + + if ("bye".equals(cmd)) { + break; + } + + switch (cmd) { + case "find": { + String keyword = Parser.argTail(input, "find").trim(); + if (keyword.isEmpty()) { + ui.showError("Usage: find "); + break; + } + java.util.List hits = tasks.find(keyword); + ui.showFound(hits, keyword); + break; + } + case "list": { + ui.showList(tasks.asList()); + break; + } + case "mark": { + int idx = Parser.parseIndex(Parser.argTail(input, "mark")); + if (!tasks.isValidIndex(idx)) { + ui.showError("Invalid task index."); + break; + } + Task t = tasks.get(idx); + t.markDone(); + ui.showMarked(t); + storage.save(tasks.asList()); + break; + } + case "unmark": { + int idx = Parser.parseIndex(Parser.argTail(input, "unmark")); + if (!tasks.isValidIndex(idx)) { + ui.showError("Invalid task index."); + break; + } + Task t = tasks.get(idx); + t.markUndone(); + ui.showUnmarked(t); + storage.save(tasks.asList()); + break; + } + case "delete": { + int idx = Parser.parseIndex(Parser.argTail(input, "delete")); + if (!tasks.isValidIndex(idx)) { + ui.showError("Invalid task index."); + break; + } + Task removed = tasks.delete(idx); + ui.showDeleted(removed, tasks.size()); + storage.save(tasks.asList()); + break; + } + case "todo": { + String desc = Parser.argTail(input, "todo"); + if (desc.isEmpty()) { + ui.showError("OOPS!!! The description of a todo cannot be empty."); + break; + } + Task t = new Todo(desc); + tasks.add(t); + ui.showAdded(t, tasks.size()); + storage.save(tasks.asList()); + break; + } + case "deadline": { + String body = Parser.argTail(input, "deadline"); + int byIdx = body.toLowerCase().indexOf("/by"); + String desc = (byIdx == -1) ? body : body.substring(0, byIdx).trim(); + String by = (byIdx == -1) ? "" : body.substring(byIdx + 3).trim(); + + if (desc.isEmpty()) { + ui.showError("OOPS!!! The description of a deadline cannot be empty."); + break; + } + if (byIdx == -1 || by.isEmpty()) { + ui.showError("OOPS!!! Please specify a due time using '/by '."); + break; + } + + Task t = new Deadline(desc, by); + tasks.add(t); + ui.showAdded(t, tasks.size()); + storage.save(tasks.asList()); + break; + } + case "event": { + String body = Parser.argTail(input, "event"); + int fromIdx = body.toLowerCase().indexOf("/from"); + int toIdx = body.toLowerCase().indexOf("/to"); + String desc = (fromIdx == -1) ? body : body.substring(0, fromIdx).trim(); + String from = (fromIdx == -1) ? "" : body.substring(fromIdx + 5, (toIdx == -1 ? body.length() : toIdx)).trim(); + String to = (toIdx == -1) ? "" : body.substring(toIdx + 3).trim(); + if (desc.isEmpty()) { + ui.showError("OOPS!!! The description of an event cannot be empty."); + break; + } + if (fromIdx == -1 || from.isEmpty()) { + ui.showError("OOPS!!! Please specify a start time using '/from '."); + break; + } + if (toIdx == -1 || to.isEmpty()) { + ui.showError("OOPS!!! Please specify an end time using '/to '."); + break; + } + + Task t = new Event(desc, from, to); + tasks.add(t); + ui.showAdded(t, tasks.size()); + storage.save(tasks.asList()); + break; + } + case "help": { + ui.showHelp(); + break; + } + default: { + if (!input.isEmpty()) { + ui.showError("OOPS!!! I'm sorry, but I don't know what that means."); + } + break; + } + } + } + + ui.showExit(); + } +} diff --git a/src/main/java/larry/LarryCore.java b/src/main/java/larry/LarryCore.java new file mode 100644 index 0000000000..88b8d87668 --- /dev/null +++ b/src/main/java/larry/LarryCore.java @@ -0,0 +1,193 @@ +package larry; + +import larry.model.Deadline; +import larry.model.Event; +import larry.model.Task; +import larry.model.TaskList; +import larry.model.Todo; +import larry.parser.Parser; +import larry.storage.Storage; +import larry.ui.Ui; + +import java.util.List; +import java.util.stream.Collectors; + +/** + * Returns whether Larry should terminate. + * This becomes {@code true} after a {@code bye} command is processed. + * + * @return {@code true} if the session should end; {@code false} otherwise + */ +public class LarryCore { + private final Ui ui = new Ui(); + private final Storage storage = new Storage("data/larry.txt"); + private final TaskList tasks = new TaskList(storage.load()); + private boolean shouldExit = false; + + /** + * Returns whether Larry should terminate. + * This becomes {@code true} after a {@code bye} command is processed. + * + * @return {@code true} if the session should end; {@code false} otherwise + */ + public boolean shouldExit() { + return shouldExit; + } + + /** + * Processes one line of user input and produces a response message. + *

+ * Side effects: may mutate the task list and save to storage. Also flips {@link #shouldExit} + * to {@code true} when the {@code bye} command is received. + * + * @param input raw user input; {@code null} and blank strings are treated as empty input + * @return a response string suitable for showing in the GUI/CLI (never {@code null}) + */ + public String getResponse(String input) { + String line = input == null ? "" : input.trim(); + + String cmd = Parser.commandWord(line); + + switch (cmd) { + case "bye": { + shouldExit = true; + return " Bye. Hope to see you again soon!"; + } + case "list": { + return "Here are the tasks in your list:\n" + numbered(tasks.asList()); + } + case "mark": { + int idx = Parser.parseIndex(Parser.argTail(line, "mark")); + if (!tasks.isValidIndex(idx)) { + return "Invalid task index."; + } + Task t = tasks.get(idx); + t.markDone(); + storage.save(tasks.asList()); + return "Nice! I've marked this task as done:\n " + t; + } + case "find": { + String keyword = Parser.argTail(line, "find").toLowerCase(); + if (keyword.isEmpty()) { + return "OOPS!!! Please provide a keyword, e.g., find book"; + } + java.util.List matches = new java.util.ArrayList<>(); + for (int i = 0; i < tasks.size(); i++) { + Task t = tasks.get(i + 1); // TaskList is 1-based externally + if (t.toString().toLowerCase().contains(keyword)) { + matches.add(t); + } + } + if (matches.isEmpty()) { + return "No matching tasks found."; + } + return "Here are the matching tasks in your list:\n" + numbered(matches); + } + case "unmark": { + int idx = Parser.parseIndex(Parser.argTail(line, "unmark")); + if (!tasks.isValidIndex(idx)) { + return "Invalid task index."; + } + Task t = tasks.get(idx); + t.markUndone(); + storage.save(tasks.asList()); + return "OK, I've marked this task as not done yet:\n " + t; + } + case "delete": { + int idx = Parser.parseIndex(Parser.argTail(line, "delete")); + if (!tasks.isValidIndex(idx)) { + return "Invalid task index."; + } + Task removed = tasks.delete(idx); + storage.save(tasks.asList()); + return "Noted. I've removed this task:\n " + removed + + "\nNow you have " + tasks.size() + " task" + (tasks.size() == 1 ? "" : "s") + " in the list."; + } + case "todo": { + String desc = Parser.argTail(line, "todo"); + if (desc.isEmpty()) { + return "OOPS!!! The description of a todo cannot be empty."; + } + Task t = new Todo(desc); + tasks.add(t); + storage.save(tasks.asList()); + return addedMsg(t); + } + case "deadline": { + String body = Parser.argTail(line, "deadline"); + int byIdx = body.toLowerCase().indexOf("/by"); + String desc = (byIdx == -1) ? body : body.substring(0, byIdx).trim(); + String by = (byIdx == -1) ? "" : body.substring(byIdx + 3).trim(); + if (desc.isEmpty()) { + return "OOPS!!! The description of a deadline cannot be empty."; + } + if (byIdx == -1 || by.isEmpty()) { + return "OOPS!!! Please specify a due time using '/by '."; + } + Task t = new Deadline(desc, by); + tasks.add(t); + storage.save(tasks.asList()); + return addedMsg(t); + } + case "event": { + String body = Parser.argTail(line, "event"); + int fromIdx = body.toLowerCase().indexOf("/from"); + int toIdx = body.toLowerCase().indexOf("/to"); + String desc = (fromIdx == -1) ? body : body.substring(0, fromIdx).trim(); + String from = (fromIdx == -1) + ? "" + : body.substring(fromIdx + 5, (toIdx == -1 ? body.length() : toIdx)).trim(); + String to = (toIdx == -1) ? "" : body.substring(toIdx + 3).trim(); + if (desc.isEmpty()) { + return "OOPS!!! The description of an event cannot be empty."; + } + if (fromIdx == -1 || from.isEmpty()) { + return "OOPS!!! Please specify a start time using '/from '."; + } + if (toIdx == -1 || to.isEmpty()) { + return "OOPS!!! Please specify an end time using '/to '."; + } + Task t = new Event(desc, from, to); + tasks.add(t); + storage.save(tasks.asList()); + return addedMsg(t); + } + case "help": { + return String.join(System.lineSeparator(), + "Commands (aliases in brackets):", + " list (ls)", + " todo (t)", + " deadline /by (dl)", + " event /from /to (ev)", + " mark (mk)", + " unmark (um)", + " delete (del)", + " find (f)", + " help (h)", + " bye (q)" + ); + } + default: + if (line.isEmpty()) { + return ""; // ignore empty line + } + return "OOPS!!! I'm sorry, but I don't know what that means."; + } + } + + private static String numbered(List tasks) { + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < tasks.size(); i++) { + sb.append(i + 1).append(".").append(tasks.get(i)).append("\n"); + } + return sb.toString().trim(); + } + + private String addedMsg(Task t) { + return larry.util.Strings.lines( + "Got it. I've added this task:", + " " + t, + "Now you have " + tasks.size() + " task" + (tasks.size() == 1 ? "" : "s") + " in the list." + ); + } +} diff --git a/src/main/java/larry/gui/DialogBox.java b/src/main/java/larry/gui/DialogBox.java new file mode 100644 index 0000000000..bc5571f576 --- /dev/null +++ b/src/main/java/larry/gui/DialogBox.java @@ -0,0 +1,52 @@ +package larry.gui; + +import java.io.IOException; +import java.util.Collections; +import javafx.collections.FXCollections; +import javafx.collections.ObservableList; +import javafx.fxml.FXML; +import javafx.fxml.FXMLLoader; +import javafx.geometry.Pos; +import javafx.scene.Node; +import javafx.scene.control.Label; +import javafx.scene.image.Image; +import javafx.scene.image.ImageView; +import javafx.scene.layout.HBox; + +/** A chat-style dialog bubble with text and avatar. */ +public class DialogBox extends HBox { + @FXML private Label dialog; + @FXML private ImageView displayPicture; + + private DialogBox(String text, Image img) { + try { + FXMLLoader loader = new FXMLLoader(Main.class.getResource("/view/DialogBox.fxml")); + loader.setController(this); + loader.setRoot(this); + loader.load(); + } catch (IOException e) { + e.printStackTrace(); + } + dialog.setText(text); + displayPicture.setImage(img); + } + + /** Flip so the avatar is on the left (bot style). */ + private void flip() { + ObservableList tmp = FXCollections.observableArrayList(getChildren()); + Collections.reverse(tmp); + getChildren().setAll(tmp); + setAlignment(Pos.TOP_LEFT); + dialog.getStyleClass().add("reply-label"); + } + + public static DialogBox getUserDialog(String text, Image img) { + return new DialogBox(text, img); + } + + public static DialogBox getLarryDialog(String text, Image img) { + DialogBox db = new DialogBox(text, img); + db.flip(); + return db; + } +} diff --git a/src/main/java/larry/gui/Launcher.java b/src/main/java/larry/gui/Launcher.java new file mode 100644 index 0000000000..a437fbf236 --- /dev/null +++ b/src/main/java/larry/gui/Launcher.java @@ -0,0 +1,21 @@ +package larry.gui; + +/** + * JavaFX bootstrapper. + *

+ * Sets platform hints for JavaFX and delegates to {@link Main}. + * This indirection avoids platform-specific launch issues when starting JavaFX apps. + */ +public class Launcher { + + /** + * Entry point that sets JavaFX system properties and forwards to {@link Main#main(String[])}. + * + * @param args command-line arguments + */ + public static void main(String[] args) { + System.setProperty("prism.order", "sw"); + System.setProperty("javafx.platform", "gtk"); + Main.main(args); + } +} diff --git a/src/main/java/larry/gui/Main.java b/src/main/java/larry/gui/Main.java new file mode 100644 index 0000000000..3b119562f5 --- /dev/null +++ b/src/main/java/larry/gui/Main.java @@ -0,0 +1,39 @@ +package larry.gui; + +import java.io.IOException; +import javafx.application.Application; +import javafx.fxml.FXMLLoader; +import javafx.scene.Scene; +import javafx.scene.layout.AnchorPane; +import javafx.stage.Stage; +import larry.LarryCore; + +public class Main extends Application { + private final LarryCore larry = new LarryCore(); + + /** JavaFX application entry that loads the FXML UI and injects LarryCore. */ + @Override + public void start(Stage stage) { + try { + FXMLLoader loader = new FXMLLoader(Main.class.getResource("/view/MainWindow.fxml")); + AnchorPane root = loader.load(); + + // IMPORTANT: inject logic into controller + MainWindow controller = loader.getController(); + controller.setLarry(larry); + + Scene scene = new Scene(root); + stage.setScene(scene); + stage.setTitle("Larry"); + stage.setMinWidth(417); + stage.setMinHeight(220); + stage.show(); + } catch (IOException e) { + e.printStackTrace(); + } + } + + public static void main(String[] args) { + launch(args); + } +} diff --git a/src/main/java/larry/gui/MainWindow.java b/src/main/java/larry/gui/MainWindow.java new file mode 100644 index 0000000000..cea2c20716 --- /dev/null +++ b/src/main/java/larry/gui/MainWindow.java @@ -0,0 +1,59 @@ +package larry.gui; + +import javafx.fxml.FXML; +import javafx.scene.control.Button; +import javafx.scene.control.ScrollPane; +import javafx.scene.control.TextField; +import javafx.scene.image.Image; +import javafx.scene.layout.VBox; +import larry.LarryCore; + +/** Controller for MainWindow.fxml. */ +public class MainWindow { + @FXML private ScrollPane scrollPane; + @FXML private VBox dialogContainer; + @FXML private TextField userInput; + @FXML private Button sendButton; + + private LarryCore larry; + + private final Image userImage = new Image(this.getClass().getResourceAsStream("/images/User.png")); + private final Image larryImage = new Image(this.getClass().getResourceAsStream("/images/Larry.png")); + + /** Bind autoscroll to the bottom when new content is added. */ + @FXML + public void initialize() { + scrollPane.vvalueProperty().bind(dialogContainer.heightProperty()); + } + + /** Inject Larry core logic. Called by Main after loading FXML. */ + public void setLarry(LarryCore core) { + this.larry = core; + + dialogContainer.getChildren().add( + DialogBox.getLarryDialog("Hello! I'm Larry.\nType 'help' to see what I can do.", larryImage) + ); + } + + /** Handles Enter and Send. */ + @FXML + private void handleUserInput() { + String input = userInput.getText(); + if (input == null || input.isBlank()) { + return; + } + String reply = larry.getResponse(input); + + dialogContainer.getChildren().addAll( + DialogBox.getUserDialog(input, userImage), + DialogBox.getLarryDialog(reply, larryImage) + ); + + userInput.clear(); + + if (larry.shouldExit()) { + sendButton.setDisable(true); + userInput.setDisable(true); + } + } +} diff --git a/src/main/java/larry/model/Deadline.java b/src/main/java/larry/model/Deadline.java new file mode 100644 index 0000000000..6372f48af2 --- /dev/null +++ b/src/main/java/larry/model/Deadline.java @@ -0,0 +1,28 @@ +package larry.model; + +import larry.util.DateTimeFormats; + +/** + * A task that is due by a specific date/time. + * Stores the raw input; formatted when displayed. + */ +public class Deadline extends Task { + private final String by; + + public Deadline(String description, String by) { + super(description); + this.by = by; + } + @Override + protected String typeIcon() { + return "D"; + } + @Override + public String toString() { + + return super.toString() + " (by: " + DateTimeFormats.pretty(by) + ")"; + } + public String getBy() { + return by; + } +} diff --git a/src/main/java/larry/model/Event.java b/src/main/java/larry/model/Event.java new file mode 100644 index 0000000000..28df03ab7b --- /dev/null +++ b/src/main/java/larry/model/Event.java @@ -0,0 +1,46 @@ +package larry.model; + +import larry.util.DateTimeFormats; + +/** + * A task that is due by a specific date/time. + * Stores the raw input; formatted when displayed. + */ +public class Event extends Task { + private final String from; + private final String to; + + public Event(String description, String from, String to) { + super(description); + this.from = from; + this.to = to; + } + @Override + protected String typeIcon() { + return "E"; + } + @Override + public String toString() { + return super.toString() + + " (from: " + DateTimeFormats.pretty(from) + + " to: " + DateTimeFormats.pretty(to) + ")"; + } + + /** + * Returns the raw user-provided start datetime string for this event. + *

+ * Display formatting is handled elsewhere (see {@link larry.util.DateTimeFormats}). + * + * @return the start datetime as originally entered by the user + */ + public String getFrom() { return from; } + + /** + * Returns the raw user-provided end datetime string for this event. + *

+ * Display formatting is handled elsewhere (see {@link larry.util.DateTimeFormats}). + * + * @return the end datetime as originally entered by the user + */ + public String getTo() { return to; } +} diff --git a/src/main/java/larry/model/Task.java b/src/main/java/larry/model/Task.java new file mode 100644 index 0000000000..2e2b716611 --- /dev/null +++ b/src/main/java/larry/model/Task.java @@ -0,0 +1,40 @@ +package larry.model; + +/** + * Represents a user task with a description and completion state. + * Subclasses specialize display (Todo, Deadline, Event). + */ +public class Task { + private final String description; + private boolean isDone; + + public Task(String description) { + this.description = description; + this.isDone = false; + } + + + /** Marks this task as done. */ + public void markDone() { + this.isDone = true; + } + + /** Marks this task as not done yet. */ + public void markUndone() { + this.isDone = false; + } + + /** Returns a human-readable form used by the UI and tests. */ + private String statusIcon() { + return isDone ? "X" : " "; + } + protected String typeIcon() { + return "?"; + } + @Override + public String toString() { + return "[" + typeIcon() + "][" + statusIcon() + "] " + description; + } + public String getDescription() { return description; } + public boolean isDone() { return isDone; } +} diff --git a/src/main/java/larry/model/TaskList.java b/src/main/java/larry/model/TaskList.java new file mode 100644 index 0000000000..82b97379e1 --- /dev/null +++ b/src/main/java/larry/model/TaskList.java @@ -0,0 +1,73 @@ +package larry.model; + +import java.util.ArrayList; +import java.util.List; + +/** + * In-memory list of tasks with 1-based indexing for user-facing operations. + */ +public class TaskList { + + private final List tasks; + + /** Creates an empty task list. */ + public TaskList() { + this.tasks = new ArrayList<>(); + } + + public TaskList(List initial) { + this.tasks = new ArrayList<>(initial); + } + + /** Number of tasks currently in the list. */ + public int size() { + return tasks.size(); + } + + /** Underlying list view used by UI and Storage. */ + public List asList() { + return java.util.Collections.unmodifiableList(tasks); + } + + /** Appends the given task to the end of the list. */ + public void add(Task t) { + assert t != null : "task must not be null"; + tasks.add(t); + } + + /** Removes and returns the task at the given 1-based index (callers validate first). */ + public Task delete(int oneBasedIndex) { + assert oneBasedIndex >= 1 && oneBasedIndex <= tasks.size() : "index out of range"; + return tasks.remove(oneBasedIndex - 1); + } + + /** Returns the task at the given 1-based index (callers validate first). */ + public Task get(int oneBasedIndex) { + assert oneBasedIndex >= 1 && oneBasedIndex <= tasks.size() : "index out of range"; + return tasks.get(oneBasedIndex - 1); + } + + /** Returns true if {@code oneBasedIndex} is within [1, size()]. */ + public boolean isValidIndex(int oneBasedIndex) { + + return oneBasedIndex >= 1 && oneBasedIndex <= tasks.size(); + } + + /** Returns tasks whose string form contains the keyword. */ + public java.util.List find(String keyword) { + java.util.List matches = new java.util.ArrayList<>(); + for (Task t : tasks) { + if (containsIgnoreCase(t.toString(), keyword)) { + matches.add(t); + } + } + return matches; + } + + private static boolean containsIgnoreCase(String haystack, String needle) { + if (haystack == null || needle == null) { + return false; + } + return haystack.toLowerCase().contains(needle.toLowerCase()); + } +} diff --git a/src/main/java/larry/model/Todo.java b/src/main/java/larry/model/Todo.java new file mode 100644 index 0000000000..673ca5988e --- /dev/null +++ b/src/main/java/larry/model/Todo.java @@ -0,0 +1,13 @@ +package larry.model; + +/** A task with only a description and a done/undone state. */ +public class Todo extends Task { + public Todo(String description) { + super(description); + } + + @Override + protected String typeIcon() { + return "T"; + } +} diff --git a/src/main/java/larry/parser/Parser.java b/src/main/java/larry/parser/Parser.java new file mode 100644 index 0000000000..90f1eb977f --- /dev/null +++ b/src/main/java/larry/parser/Parser.java @@ -0,0 +1,55 @@ +/** + * Parses raw user input into command words and arguments. + */ +package larry.parser; + +import java.util.Map; +import java.util.Objects; + +public class Parser { + + private static final Map ALIASES = Map.ofEntries( + Map.entry("t", "todo"), + Map.entry("dl", "deadline"), + Map.entry("ev", "event"), + Map.entry("ls", "list"), + Map.entry("mk", "mark"), + Map.entry("um", "unmark"), + Map.entry("del", "delete"), + Map.entry("f", "find"), + Map.entry("q", "bye"), + Map.entry("h", "help") + ); + + /** Returns the lower-cased command word at the start of {@code input}. */ + public static String commandWord(String input) { + Objects.requireNonNull(input, "input must not be null"); + String trimmed = input.trim(); + int sp = trimmed.indexOf(' '); + String head = (sp == -1) ? trimmed.toLowerCase() : trimmed.substring(0, sp).toLowerCase(); + return ALIASES.getOrDefault(head, head); + } + + /** Returns the remainder of {@code input} after the first word actually typed. */ + public static String argTail(String input, String cmd) { + Objects.requireNonNull(input, "input must not be null"); + String trimmed = input.trim(); + int sp = trimmed.indexOf(' '); + if (sp == -1) { + return ""; + } + return trimmed.substring(sp + 1).trim(); + } + + /** Parses a 1-based index; returns -1 if invalid. */ + public static int parseIndex(String s) { + if (s == null) { + return -1; + } + try { + return Integer.parseInt(s.trim()); + } catch (NumberFormatException e) { + return -1; + } + } +} diff --git a/src/main/java/larry/storage/Storage.java b/src/main/java/larry/storage/Storage.java new file mode 100644 index 0000000000..54e4d9c65a --- /dev/null +++ b/src/main/java/larry/storage/Storage.java @@ -0,0 +1,111 @@ +package larry.storage; + +import larry.model.Deadline; +import larry.model.Event; +import larry.model.Task; +import larry.model.Todo; + +import java.io.BufferedWriter; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; + +/** + * Loads and saves tasks to a local data file using a simple line format. + * Creates the file on first run if it does not exist. + */ +public class Storage { + private final Path savePath; + + public Storage(String relativePath) { + this.savePath = Path.of(relativePath); + } + + /** Loads tasks from disk; returns an empty list on first run or unreadable file. */ + public List load() { + try { + ensureParentDir(); + if (!Files.exists(savePath)) { + Files.createFile(savePath); + return new ArrayList<>(); + } + List lines = Files.readAllLines(savePath, StandardCharsets.UTF_8); + List tasks = new ArrayList<>(); + for (String line : lines) { + if (line.isBlank()){ + continue; + } + + String[] parts = line.split("\\|"); + String type = parts[0]; + boolean done = "1".equals(parts[1]); + String desc = parts.length > 2 ? parts[2] : ""; + + Task t; + switch (type) { + case "T": + t = new Todo(desc); + break; + case "D": + String by = parts.length > 3 ? parts[3] : ""; + t = new Deadline(desc, by); + break; + case "E": + String from = parts.length > 3 ? parts[3] : ""; + String to = parts.length > 4 ? parts[4] : ""; + t = new Event(desc, from, to); + break; + default: + t = new Task(desc); + break; + } + if (done) t.markDone(); + assert t != null : "parsed task should not be null"; + tasks.add(t); + } + return tasks; + } catch (IOException e) { + return new ArrayList<>(); + } + } + + /** Persists the given task list to disk, replacing previous contents. */ + public void save(List tasks) { + assert tasks != null : "tasks list must not be null"; + try { + ensureParentDir(); + try (BufferedWriter bw = Files.newBufferedWriter(savePath, StandardCharsets.UTF_8)) { + for (Task t : tasks) { + bw.write(serialize(t)); + bw.newLine(); + } + } + } catch (IOException e) { + } + } + + private void ensureParentDir() throws IOException { + Path parent = savePath.getParent(); + if (parent != null && !Files.exists(parent)) { + Files.createDirectories(parent); + } + } + + private String serialize(Task t) { + String done = t.isDone() ? "1" : "0"; + if (t instanceof Todo) { + return "T|" + done + "|" + ((Todo) t).getDescription(); + } else if (t instanceof Deadline) { + Deadline d = (Deadline) t; + return "D|" + done + "|" + d.getDescription() + "|" + d.getBy(); + } else if (t instanceof Event) { + Event e = (Event) t; + return "E|" + done + "|" + e.getDescription() + "|" + e.getFrom() + "|" + e.getTo(); + } else { + return "?|" + done + "|" + t.getDescription(); + } + } +} diff --git a/src/main/java/larry/ui/Ui.java b/src/main/java/larry/ui/Ui.java new file mode 100644 index 0000000000..f303bc3806 --- /dev/null +++ b/src/main/java/larry/ui/Ui.java @@ -0,0 +1,91 @@ +package larry.ui; + +import larry.model.Task; + +import java.util.List; + +/** + * Handles all user-visible console messages and prompts. + * Centralizing messages keeps text-UI tests stable. + */ +public class Ui { + + /** Prints the greeting banner shown at program start. */ + public void showGreeting() { + System.out.println(" Hello! I'm Larry"); + System.out.println(" What can I do for you?"); + } + + /** Prints the exit message shown at program end. */ + public void showExit() { + System.out.println(" Bye. Hope to see you again soon!"); + } + + /** Prints the numbered list of tasks in the given order. */ + public void showList(List tasks) { + System.out.println("Here are the tasks in your list:"); + for (int i = 0; i < tasks.size(); i++) { + System.out.println((i + 1) + "." + tasks.get(i)); + } + } + + /** Announces that a task was added and shows the new total size. */ + public void showAdded(Task t, int size) { + System.out.println("Got it. I've added this task:"); + System.out.println(" " + t); + System.out.println("Now you have " + size + " task" + (size == 1 ? "" : "s") + " in the list."); + } + + /** Announces that a task was marked done. */ + public void showMarked(Task t) { + System.out.println("Nice! I've marked this task as done:"); + System.out.println(" " + t); + } + + /** Announces that a task was marked not done. */ + public void showUnmarked(Task t) { + System.out.println("OK, I've marked this task as not done yet:"); + System.out.println(" " + t); + } + + /** Announces that a task was deleted and shows the new total size. */ + public void showDeleted(Task t, int size) { + System.out.println("Noted. I've removed this task:"); + System.out.println(" " + t); + System.out.println("Now you have " + size + " task" + (size == 1 ? "" : "s") + " in the list."); + } + + /** Prints tasks that matched a find query; shows a friendly message if none. */ + public void showFound(java.util.List tasks, String keyword) { + if (tasks.isEmpty()) { + System.out.println("No matches for: " + keyword); + return; + } + System.out.println("Here are the matching tasks in your list:"); + int i = 1; + for (larry.model.Task t : tasks) { + System.out.println(i + "." + t); + i++; + } + } + + /** Prints a short usage guide for available commands (with aliases). */ + public void showHelp() { + System.out.println("Commands (aliases in brackets):"); + System.out.println(" list (ls)"); + System.out.println(" todo (t)"); + System.out.println(" deadline /by (dl)"); + System.out.println(" event /from /to (ev)"); + System.out.println(" mark (mk)"); + System.out.println(" unmark (um)"); + System.out.println(" delete (del)"); + System.out.println(" find (f)"); + System.out.println(" help (h)"); + System.out.println(" bye (q)"); + } + + /** Prints a one-line error message without terminating the app. */ + public void showError(String msg) { + System.out.println(msg != null ? msg : "An error occurred."); + } +} diff --git a/src/main/java/larry/util/DateTimeFormats.java b/src/main/java/larry/util/DateTimeFormats.java new file mode 100644 index 0000000000..ceb426af12 --- /dev/null +++ b/src/main/java/larry/util/DateTimeFormats.java @@ -0,0 +1,48 @@ +package larry.util; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeParseException; + + +/** + * Utilities for parsing and pretty-printing date/time strings. + * Accepts ISO-like inputs (e.g., {@code yyyy-MM-dd}, {@code yyyy-MM-dd HH:mm/HHmm}). + * Falls back to the original string if parsing fails. + */ +public final class DateTimeFormats { + private DateTimeFormats() {} + + private static final DateTimeFormatter ISO_DATE = DateTimeFormatter.ofPattern("yyyy-MM-dd"); + private static final DateTimeFormatter ISO_DATETIME_COLON = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm"); + private static final DateTimeFormatter ISO_DATETIME_COMPACT = DateTimeFormatter.ofPattern("yyyy-MM-dd HHmm"); + + private static final DateTimeFormatter OUT_DATE = DateTimeFormatter.ofPattern("MMM d yyyy"); + private static final DateTimeFormatter OUT_DATETIME = DateTimeFormatter.ofPattern("MMM d yyyy, h:mma"); + + /** Returns a prettier form of {@code raw} if supported; otherwise returns {@code raw}. */ + public static String pretty(String raw) { + if (raw == null || raw.isBlank()) return raw; + + try { + LocalDate d = LocalDate.parse(raw.trim(), ISO_DATE); + return OUT_DATE.format(d); + } catch (DateTimeParseException ignore) { + } + + try { + LocalDateTime dt = LocalDateTime.parse(raw.trim(), ISO_DATETIME_COLON); + return OUT_DATETIME.format(dt); + } catch (DateTimeParseException ignore) { + } + + try { + LocalDateTime dt = LocalDateTime.parse(raw.trim(), ISO_DATETIME_COMPACT); + return OUT_DATETIME.format(dt); + } catch (DateTimeParseException ignore) { + } + + return raw; + } +} diff --git a/src/main/java/larry/util/Strings.java b/src/main/java/larry/util/Strings.java new file mode 100644 index 0000000000..7df918b881 --- /dev/null +++ b/src/main/java/larry/util/Strings.java @@ -0,0 +1,15 @@ +package larry.util; + +public final class Strings { + private Strings() {} + + /** + * Joins parts using the platform line separator. + * + * @param parts strings to join + * @return a single string containing all parts separated by {@code System.lineSeparator()} + */ + public static String lines(String... parts) { + return String.join(System.lineSeparator(), parts); + } +} \ No newline at end of file diff --git a/src/main/resources/css/dialog-box.css b/src/main/resources/css/dialog-box.css new file mode 100644 index 0000000000..13747c9cde --- /dev/null +++ b/src/main/resources/css/dialog-box.css @@ -0,0 +1,16 @@ +.label { + -fx-background-color: linear-gradient(to bottom right, #e5f1ff, #f0f7ff); + -fx-text-fill: #111827; + -fx-border-color: #cfe3ff; + -fx-border-width: 1px; + -fx-background-radius: 12 12 0 12; + -fx-border-radius: 12 12 0 12; + -fx-padding: 6px; + -fx-background-insets: 0 7 0 7; + -fx-border-insets: 0 7 0 7; +} + +.reply-label { + -fx-background-radius: 12 12 12 0; + -fx-border-radius: 12 12 12 0; +} diff --git a/src/main/resources/css/main.css b/src/main/resources/css/main.css new file mode 100644 index 0000000000..ccc976a290 --- /dev/null +++ b/src/main/resources/css/main.css @@ -0,0 +1,22 @@ +.root { + main-color: rgb(245, 247, 250); + -fx-background-color: main-color; +} + +.text-field { + -fx-background-color: white; + -fx-background-insets: 0; + -fx-padding: 6px; + -fx-font-size: 13px; +} + +.button { + -fx-background-color: #3b82f6; + -fx-text-fill: white; + -fx-background-radius: 6; + -fx-font-weight: 600; +} + +.scroll-pane, .scroll-pane .viewport { + -fx-background-color: transparent; +} diff --git a/src/main/resources/images/Larry.png b/src/main/resources/images/Larry.png new file mode 100644 index 0000000000..cab4b32268 Binary files /dev/null and b/src/main/resources/images/Larry.png differ diff --git a/src/main/resources/images/User.png b/src/main/resources/images/User.png new file mode 100644 index 0000000000..8b6fd18aba Binary files /dev/null and b/src/main/resources/images/User.png differ diff --git a/src/main/resources/view/DialogBox.fxml b/src/main/resources/view/DialogBox.fxml new file mode 100644 index 0000000000..5094d44172 --- /dev/null +++ b/src/main/resources/view/DialogBox.fxml @@ -0,0 +1,25 @@ + + + + + + + + + + + + + diff --git a/src/main/resources/view/MainWindow.fxml b/src/main/resources/view/MainWindow.fxml new file mode 100644 index 0000000000..14edce6bcf --- /dev/null +++ b/src/main/resources/view/MainWindow.fxml @@ -0,0 +1,40 @@ + + + + + + + + + + + + + + + + + +